feat(process): show input-wait hints in log and poll

Show input-wait hints in process log/poll for idle interactive background sessions, keep list markers and structured stdin metadata, and document the recovery flow through log plus existing input actions.

Docs: updated docs/gateway/background-process.md.

Verification:
- pnpm test src/agents/bash-tools.test.ts
- pnpm test src/agents/bash-tools.process.input-hints.test.ts
- pnpm test src/agents/bash-tools.process.input-hints.test.ts src/agents/bash-tools.process.poll-timeout.test.ts src/agents/bash-tools.process.supervisor.test.ts src/agents/bash-tools.process-send-keys.test.ts
- pnpm check:docs
- git diff --check
- CI on 4aea1f11fe: check, check-additional, check-docs, checks-node-core, process/security relevant shards, real behavior proof passed

Fixes #33957.
Thanks @bitloi and @vincentkoc.

Co-authored-by: bitloi <89318445+bitloi@users.noreply.github.com>
Co-authored-by: bitloi <raphaelaloi.eth@gmail.com>
This commit is contained in:
bitloi
2026-05-10 05:13:07 -03:00
committed by GitHub
parent 09132effa8
commit ed6b030a43
14 changed files with 502 additions and 33 deletions

View File

@@ -184,6 +184,7 @@ Docs: https://docs.openclaw.ai
- Matrix: attach `com.openclaw.presentation` metadata to semantic presentation replies so OpenClaw-aware Matrix clients can render rich buttons, selects, context rows, and dividers while stock clients keep the plain text fallback. (#73312) Thanks @kakahu2015.
- Codex app-server: disarm the short post-tool completion watchdog after current-turn activity, expose `appServer.turnCompletionIdleTimeoutMs`, and include raw assistant item context in idle-timeout diagnostics so status-only post-tool stalls stop failing as idle. Fixes #77984. Thanks @roseware-dev and @rubencu.
- Plugin skills/Windows: publish plugin-provided skill directories as junctions on Windows so standard users without Developer Mode can register plugin skills without symlink EPERM failures. Fixes #77958. (#77971) Thanks @hclsys and @jarro.
- Process tool: show input-wait hints from `log` and `poll` for idle interactive background sessions so operators can inspect stuck CLIs and resume them with existing input actions. Fixes #33957. Thanks @bitloi and @vincentkoc.
- Shell env/Windows: hide the login-shell environment probe child window so gateway startup and shell-env refreshes do not flash a console on Windows. Fixes #78159. (#78266) Thanks @BradGroux.
- MS Teams: surface blocked Bot Framework egress by logging JWKS fetch network failures and adding a Bot Connector send hint for transport-level reply failures. Fixes #77674. (#78081) Thanks @Beandon13.
- Media/host-read: allow buffer-verified ZIP archives in the host-local media validator so agents can send ZIP attachments via the message tool. Fixes #78057. (#78292) Thanks @Linux2010.

View File

@@ -46,6 +46,7 @@ Environment overrides:
- `PI_BASH_MAX_OUTPUT_CHARS`: in-memory output cap (chars)
- `OPENCLAW_BASH_PENDING_MAX_OUTPUT_CHARS`: pending stdout/stderr cap per stream (chars)
- `PI_BASH_JOB_TTL_MS`: TTL for finished sessions (ms, bounded to 1m3h)
- `OPENCLAW_PROCESS_INPUT_WAIT_IDLE_MS`: idle-output threshold before writable background sessions are marked as likely waiting for input (default 15000 ms)
Config (preferred):
@@ -61,7 +62,7 @@ Actions:
- `list`: running + finished sessions
- `poll`: drain new output for a session (also reports exit status)
- `log`: read the aggregated output (supports `offset` + `limit`)
- `log`: read the aggregated output and show input recovery hints (supports `offset` + `limit`)
- `write`: send stdin (`data`, optional `eof`)
- `send-keys`: send explicit key tokens or bytes to a PTY-backed session
- `submit`: send Enter / carriage return to a PTY-backed session
@@ -78,9 +79,14 @@ Notes:
- `process` is scoped per agent; it only sees sessions started by that agent.
- Use `poll` / `log` for status, logs, quiet-success confirmation, or
completion confirmation when automatic completion wake is unavailable.
- Use `log` before recovering an interactive CLI so the current transcript,
stdin state, and input-wait hint are visible together.
- Use `write` / `send-keys` / `submit` / `paste` / `kill` when you need input
or intervention.
- `process list` includes a derived `name` (command verb + target) for quick scans.
- `process list`, `poll`, and `log` report `waitingForInput` only
when the session still has writable stdin and has been idle longer than the
input-wait threshold.
- `process log` uses line-based `offset`/`limit`.
- When both `offset` and `limit` are omitted, it returns the last 200 lines and includes a paging hint.
- When `offset` is provided and `limit` is omitted, it returns from `offset` to the end (not capped to 200).
@@ -99,6 +105,12 @@ Run a long task and poll later:
{ "tool": "process", "action": "poll", "sessionId": "<id>" }
```
Inspect an interactive session before sending input:
```json
{ "tool": "process", "action": "log", "sessionId": "<id>" }
```
Start immediately in background:
```json

View File

@@ -25,6 +25,9 @@ export type SessionStdin = {
// When backed by a real Node stream (child.stdin), this exists; for PTY wrappers it may not.
destroy?: () => void;
destroyed?: boolean;
writable?: boolean;
writableEnded?: boolean;
writableFinished?: boolean;
};
export interface ProcessSession {

View File

@@ -66,7 +66,7 @@ export function describeExecTool(params?: { agentId?: string; hasCronTool?: bool
export function describeProcessTool(params?: { hasCronTool?: boolean }): string {
return [
"Manage running exec sessions for commands already started: list, poll, log, write, send-keys, submit, paste, kill.",
"Use poll/log when you need status, logs, quiet-success confirmation, or completion confirmation when automatic completion wake is unavailable. Use write/send-keys/submit/paste/kill for input or intervention.",
"Use poll/log when you need status, logs, quiet-success confirmation, or completion confirmation when automatic completion wake is unavailable. Use poll/log also for input-wait hints. Use write/send-keys/submit/paste/kill for input or intervention.",
params?.hasCronTool
? "Do not use process polling to emulate timers or reminders; use cron for scheduled follow-ups."
: undefined,

View File

@@ -1610,7 +1610,7 @@ export function createExecTool(
type: "text",
text: `${getWarningText()}Command still running (session ${run.session.id}, pid ${
run.session.pid ?? "n/a"
}). Use process (list/poll/log/write/kill/clear/remove) for follow-up.`,
}). Use process (list/poll/log/write/send-keys/submit/paste/kill/clear/remove) for follow-up.`,
},
],
details: {

View File

@@ -7,6 +7,9 @@ export type WritableStdin = {
write: (data: string, cb?: (err?: Error | null) => void) => void;
end: () => void;
destroyed?: boolean;
writable?: boolean;
writableEnded?: boolean;
writableFinished?: boolean;
};
function failText(text: string): AgentToolResult<unknown> {

View File

@@ -0,0 +1,225 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
addSession,
appendOutput,
markExited,
resetProcessRegistryForTests,
} from "./bash-process-registry.js";
import { createProcessSessionFixture } from "./bash-process-registry.test-helpers.js";
import { createProcessTool } from "./bash-tools.process.js";
type ProcessTool = ReturnType<typeof createProcessTool>;
type ProcessToolResult = Awaited<ReturnType<ProcessTool["execute"]>>;
afterEach(() => {
resetProcessRegistryForTests();
vi.useRealTimers();
});
async function runProcessAction(
processTool: ProcessTool,
args: Record<string, unknown>,
): Promise<ProcessToolResult> {
return processTool.execute("toolcall", args as Parameters<ProcessTool["execute"]>[1], undefined);
}
function textOf(result: ProcessToolResult): string {
const item = result.content[0];
return item?.type === "text" ? item.text : "";
}
function installWritableStdin(
session: ReturnType<typeof createProcessSessionFixture>,
state?: { writableEnded?: boolean; writableFinished?: boolean; destroyed?: boolean },
) {
session.stdin = {
write: vi.fn((_data: string, cb?: (err?: Error | null) => void) => cb?.(null)),
end: vi.fn(),
destroyed: state?.destroyed ?? false,
writableEnded: state?.writableEnded,
writableFinished: state?.writableFinished,
} as NonNullable<typeof session.stdin> & {
writableEnded?: boolean;
writableFinished?: boolean;
};
}
describe("process input-wait hints", () => {
it("adds output and input-wait metadata to log for an idle writable session", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:20.000Z"));
const processTool = createProcessTool();
const session = createProcessSessionFixture({
id: "sess-log-hint",
command: "node cli.js",
backgrounded: true,
startedAt: Date.now() - 20_000,
});
installWritableStdin(session);
appendOutput(session, "stdout", "Name? ");
addSession(session);
const result = await runProcessAction(processTool, {
action: "log",
sessionId: "sess-log-hint",
});
const text = textOf(result);
expect(text).toContain("Name? ");
expect(text).toContain("No new output for 20s");
expect(text).toContain("Use process write, send-keys, submit, or paste to provide input.");
expect(result.details).toMatchObject({
status: "running",
sessionId: "sess-log-hint",
stdinWritable: true,
waitingForInput: true,
idleMs: 20_000,
lastOutputAt: Date.now() - 20_000,
});
});
it("adds input-wait hints to poll when no new output arrives", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:16.000Z"));
const processTool = createProcessTool();
const session = createProcessSessionFixture({
id: "sess-poll",
command: "python prompt.py",
backgrounded: true,
startedAt: Date.now() - 16_000,
});
installWritableStdin(session);
addSession(session);
const result = await runProcessAction(processTool, {
action: "poll",
sessionId: "sess-poll",
});
expect(textOf(result)).toContain("(no new output)");
expect(textOf(result)).toContain("may be waiting for input");
expect(result.details).toMatchObject({
status: "running",
sessionId: "sess-poll",
stdinWritable: true,
waitingForInput: true,
idleMs: 16_000,
lastOutputAt: Date.now() - 16_000,
});
});
it("marks idle writable sessions in process list", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:30.000Z"));
const processTool = createProcessTool();
const session = createProcessSessionFixture({
id: "sess-list",
command: "npm run interactive",
backgrounded: true,
startedAt: Date.now() - 30_000,
});
installWritableStdin(session);
addSession(session);
const result = await runProcessAction(processTool, { action: "list" });
expect(textOf(result)).toContain("sess-list");
expect(textOf(result)).toContain("[input-wait]");
const sessions = (result.details as { sessions?: Array<Record<string, unknown>> }).sessions;
expect(sessions?.[0]).toMatchObject({
sessionId: "sess-list",
stdinWritable: true,
waitingForInput: true,
idleMs: 30_000,
});
});
it("adds input-wait metadata and hint text to log", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:25.000Z"));
const processTool = createProcessTool();
const session = createProcessSessionFixture({
id: "sess-log",
command: "node prompt.js",
backgrounded: true,
startedAt: Date.now() - 25_000,
});
installWritableStdin(session);
appendOutput(session, "stdout", "Password: ");
addSession(session);
const result = await runProcessAction(processTool, {
action: "log",
sessionId: "sess-log",
});
expect(textOf(result)).toContain("Password: ");
expect(textOf(result)).toContain("No new output for 25s");
expect(textOf(result)).toContain("Use process write, send-keys, submit, or paste");
expect(result.details).toMatchObject({
status: "running",
sessionId: "sess-log",
stdinWritable: true,
waitingForInput: true,
idleMs: 25_000,
});
});
it("does not treat ended stdin as writable input-wait state", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:01:00.000Z"));
const processTool = createProcessTool();
const session = createProcessSessionFixture({
id: "sess-ended",
command: "node closed-stdin.js",
backgrounded: true,
startedAt: Date.now() - 60_000,
});
installWritableStdin(session, { writableEnded: true });
addSession(session);
const log = await runProcessAction(processTool, {
action: "log",
sessionId: "sess-ended",
});
expect(textOf(log)).not.toContain("provide input");
expect(log.details).toMatchObject({
status: "running",
stdinWritable: false,
waitingForInput: false,
});
const write = await runProcessAction(processTool, {
action: "write",
sessionId: "sess-ended",
data: "answer\n",
});
expect(textOf(write)).toContain("stdin is not writable");
expect(write.details).toMatchObject({ status: "failed" });
});
it("can read finished session logs without exposing input controls", async () => {
const processTool = createProcessTool();
const session = createProcessSessionFixture({
id: "sess-finished",
command: "echo done",
backgrounded: true,
});
appendOutput(session, "stdout", "done\n");
addSession(session);
markExited(session, 0, null, "completed");
const result = await runProcessAction(processTool, {
action: "log",
sessionId: "sess-finished",
});
expect(textOf(result)).toContain("done");
expect(textOf(result)).not.toContain("provide input");
expect(result.details).toMatchObject({
status: "completed",
sessionId: "sess-finished",
exitCode: 0,
});
});
});

View File

@@ -17,7 +17,14 @@ import {
import { describeProcessTool } from "./bash-tools.descriptions.js";
import { handleProcessSendKeys, type WritableStdin } from "./bash-tools.process-send-keys.js";
import { processSchema } from "./bash-tools.schemas.js";
import { deriveSessionName, pad, sliceLogLines, truncateMiddle } from "./bash-tools.shared.js";
import {
clampWithDefault,
deriveSessionName,
pad,
readEnvInt,
sliceLogLines,
truncateMiddle,
} from "./bash-tools.shared.js";
import { recordCommandPoll, resetCommandPollCount } from "./command-poll-backoff.js";
import { encodePaste } from "./pty-keys.js";
import { PROCESS_TOOL_DISPLAY_SUMMARY } from "./tool-description-presets.js";
@@ -26,10 +33,14 @@ import type { AgentToolWithMeta } from "./tools/common.js";
export type ProcessToolDefaults = {
cleanupMs?: number;
hasCronTool?: boolean;
inputWaitIdleMs?: number;
scopeKey?: string;
};
const DEFAULT_LOG_TAIL_LINES = 200;
const DEFAULT_INPUT_WAIT_IDLE_MS = 15_000;
const MIN_INPUT_WAIT_IDLE_MS = 1_000;
const MAX_INPUT_WAIT_IDLE_MS = 10 * 60 * 1000;
function resolveLogSliceWindow(offset?: number, limit?: number) {
const usingDefaultTail = offset === undefined && limit === undefined;
@@ -51,6 +62,36 @@ function defaultTailNote(totalLines: number, usingDefaultTail: boolean) {
const MAX_POLL_WAIT_MS = 30_000;
type RunningSessionRuntime = {
stdinWritable: boolean;
waitingForInput: boolean;
idleMs: number;
lastOutputAt: number;
};
function resolveSessionStdin(session: ProcessSession): WritableStdin | undefined {
return (session.stdin ?? session.child?.stdin) as WritableStdin | undefined;
}
function isWritableStdin(stdin: WritableStdin | undefined): stdin is WritableStdin {
if (!stdin || stdin.destroyed) {
return false;
}
if (stdin.writable === false || stdin.writableEnded === true || stdin.writableFinished === true) {
return false;
}
return true;
}
function runningSessionInputDetails(runtime: RunningSessionRuntime) {
return {
stdinWritable: runtime.stdinWritable,
waitingForInput: runtime.waitingForInput,
idleMs: runtime.idleMs,
lastOutputAt: runtime.lastOutputAt,
};
}
function resolvePollWaitMs(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) {
return Math.max(0, Math.min(MAX_POLL_WAIT_MS, Math.floor(value)));
@@ -140,9 +181,36 @@ export function createProcessTool(
}
const scopeKey = defaults?.scopeKey;
const supervisor = getProcessSupervisor();
const inputWaitIdleMs = clampWithDefault(
defaults?.inputWaitIdleMs ?? readEnvInt("OPENCLAW_PROCESS_INPUT_WAIT_IDLE_MS"),
DEFAULT_INPUT_WAIT_IDLE_MS,
MIN_INPUT_WAIT_IDLE_MS,
MAX_INPUT_WAIT_IDLE_MS,
);
const isInScope = (session?: { scopeKey?: string } | null) =>
!scopeKey || session?.scopeKey === scopeKey;
const describeRunningSession = (session: ProcessSession): RunningSessionRuntime => {
const record = supervisor.getRecord(session.id);
const lastOutputAt = record?.lastOutputAtMs ?? session.startedAt;
const idleMs = Math.max(0, Date.now() - lastOutputAt);
const stdinWritable = isWritableStdin(resolveSessionStdin(session));
return {
stdinWritable,
waitingForInput: stdinWritable && idleMs >= inputWaitIdleMs,
idleMs,
lastOutputAt,
};
};
const buildInputWaitHint = (runtime: RunningSessionRuntime | undefined) => {
if (!runtime?.waitingForInput) {
return "";
}
const idle = formatDurationCompact(runtime.idleMs) ?? `${runtime.idleMs}ms`;
return `\n\nNo new output for ${idle}; this session may be waiting for input. Use process write, send-keys, submit, or paste to provide input.`;
};
const cancelManagedSession = (sessionId: string) => {
const record = supervisor.getRecord(sessionId);
if (!record || record.state === "exited") {
@@ -196,18 +264,25 @@ export function createProcessTool(
if (params.action === "list") {
const running = listRunningSessions()
.filter((s) => isInScope(s))
.map((s) => ({
sessionId: s.id,
status: "running",
pid: s.pid ?? undefined,
startedAt: s.startedAt,
runtimeMs: Date.now() - s.startedAt,
cwd: s.cwd,
command: s.command,
name: deriveSessionName(s.command),
tail: s.tail,
truncated: s.truncated,
}));
.map((s) => {
const runtime = describeRunningSession(s);
return {
sessionId: s.id,
status: "running",
pid: s.pid ?? undefined,
startedAt: s.startedAt,
runtimeMs: Date.now() - s.startedAt,
cwd: s.cwd,
command: s.command,
name: deriveSessionName(s.command),
tail: s.tail,
truncated: s.truncated,
stdinWritable: runtime.stdinWritable,
waitingForInput: runtime.waitingForInput,
idleMs: runtime.idleMs,
lastOutputAt: runtime.lastOutputAt,
};
});
const finished = listFinishedSessions()
.filter((s) => isInScope(s))
.map((s) => ({
@@ -228,7 +303,10 @@ export function createProcessTool(
.toSorted((a, b) => b.startedAt - a.startedAt)
.map((s) => {
const label = s.name ? truncateMiddle(s.name, 80) : truncateMiddle(s.command, 120);
return `${s.sessionId} ${pad(s.status, 9)} ${formatDurationCompact(s.runtimeMs) ?? "n/a"} :: ${label}`;
const marker = "waitingForInput" in s && s.waitingForInput ? " [input-wait]" : "";
return `${s.sessionId} ${pad(s.status, 9)} ${
formatDurationCompact(s.runtimeMs) ?? "n/a"
}${marker} :: ${label}`;
});
return {
content: [
@@ -271,14 +349,14 @@ export function createProcessTool(
result: failedResult(`Session ${params.sessionId} is not backgrounded.`),
};
}
const stdin = scopedSession.stdin ?? scopedSession.child?.stdin;
if (!stdin || stdin.destroyed) {
const stdin = resolveSessionStdin(scopedSession);
if (!isWritableStdin(stdin)) {
return {
ok: false as const,
result: failedResult(`Session ${params.sessionId} stdin is not writable.`),
};
}
return { ok: true as const, session: scopedSession, stdin: stdin as WritableStdin };
return { ok: true as const, session: scopedSession, stdin };
};
const writeToStdin = async (stdin: WritableStdin, data: string) => {
@@ -374,6 +452,7 @@ export function createProcessTool(
if (exited) {
resetPollRetrySuggestion(params.sessionId);
}
const runtime = exited ? undefined : describeRunningSession(scopedSession);
return {
content: [
{
@@ -384,7 +463,7 @@ export function createProcessTool(
? `\n\nProcess exited with ${
exitSignal ? `signal ${exitSignal}` : `code ${exitCode}`
}.`
: "\n\nProcess still running."),
: buildInputWaitHint(runtime) || "\n\nProcess still running."),
},
],
details: {
@@ -393,6 +472,7 @@ export function createProcessTool(
exitCode: exited ? exitCode : undefined,
aggregated: scopedSession.aggregated,
name: deriveSessionName(scopedSession.command),
...(runtime ? runningSessionInputDetails(runtime) : {}),
...(typeof retryInMs === "number" ? { retryInMs } : {}),
},
};
@@ -417,9 +497,16 @@ export function createProcessTool(
window.effectiveOffset,
window.effectiveLimit,
);
const runtime = describeRunningSession(scopedSession);
const logDefaultTailNote = defaultTailNote(totalLines, window.usingDefaultTail);
return {
content: [{ type: "text", text: (slice || "(no output yet)") + logDefaultTailNote }],
content: [
{
type: "text",
text:
(slice || "(no output yet)") + logDefaultTailNote + buildInputWaitHint(runtime),
},
],
details: {
status: scopedSession.exited ? "completed" : "running",
sessionId: params.sessionId,
@@ -428,6 +515,7 @@ export function createProcessTool(
totalChars,
truncated: scopedSession.truncated,
name: deriveSessionName(scopedSession.command),
...runningSessionInputDetails(runtime),
},
};
}

View File

@@ -50,7 +50,9 @@ export const execSchema = Type.Object({
});
export const processSchema = Type.Object({
action: Type.String({ description: "Process action" }),
action: Type.String({
description: "Process action (list|poll|log|write|send-keys|submit|paste|kill|clear|remove)",
}),
sessionId: Type.Optional(Type.String({ description: "Session id for actions other than list" })),
data: Type.Optional(Type.String({ description: "Data to write for write" })),
keys: Type.Optional(

View File

@@ -186,6 +186,60 @@ describe("createChildAdapter", () => {
expect(killMock).toHaveBeenCalledWith("SIGTERM");
});
it("preserves inherited stdin when no input pipe is requested", async () => {
const { child } = createStubChild(5656);
child.stdin = null;
spawnWithFallbackMock.mockResolvedValue({
child,
usedFallback: false,
});
const adapter = await createChildAdapter({
argv: ["node", "-e", "setTimeout(() => {}, 1000)"],
});
const spawnArgs = spawnWithFallbackMock.mock.calls[0]?.[0] as {
options?: { stdio?: Array<string> };
};
expect(spawnArgs.options?.stdio?.[0]).toBe("inherit");
expect(adapter.stdin).toBeUndefined();
});
it("reports stdin as non-writable after end or destroy", async () => {
const { adapter } = await createAdapterHarness({ pid: 6767 });
expect(adapter.stdin?.writable).toBe(true);
expect(adapter.stdin?.writableEnded).toBe(false);
adapter.stdin?.end();
expect(adapter.stdin?.writable).toBe(false);
expect(adapter.stdin?.writableEnded).toBe(true);
const writeCallback = vi.fn();
adapter.stdin?.write("late", writeCallback);
expect(writeCallback.mock.calls[0]?.[0]).toBeInstanceOf(Error);
adapter.stdin?.destroy?.();
expect(adapter.stdin?.destroyed).toBe(true);
expect(adapter.stdin?.writable).toBe(false);
});
it("reports pipe-closed stdin as ended", async () => {
const { child } = createStubChild(3434);
spawnWithFallbackMock.mockResolvedValue({
child,
usedFallback: false,
});
const adapter = await createChildAdapter({
argv: ["node", "-e", "process.exit(0)"],
stdinMode: "pipe-closed",
});
expect(adapter.stdin?.writable).toBe(false);
expect(adapter.stdin?.writableEnded).toBe(true);
});
it("wait does not settle immediately on SIGKILL", async () => {
vi.useFakeTimers();
const { adapter } = await createAdapterHarness({ pid: 4567 });

View File

@@ -73,35 +73,68 @@ export async function createChildAdapter(params: {
});
const child = spawned.child as ChildProcessWithoutNullStreams;
if (child.stdin) {
const childStdin = spawned.child.stdin;
let stdinDestroyed = childStdin?.destroyed ?? false;
let stdinEnded = childStdin?.writableEnded === true || childStdin?.writableFinished === true;
if (childStdin) {
childStdin.once("finish", () => {
stdinEnded = true;
});
childStdin.once("close", () => {
stdinEnded = true;
stdinDestroyed = true;
});
childStdin.once("error", () => {
stdinDestroyed = true;
});
if (params.input !== undefined) {
child.stdin.write(params.input);
child.stdin.end();
childStdin.write(params.input);
stdinEnded = true;
childStdin.end();
} else if (stdinMode === "pipe-closed") {
child.stdin.end();
stdinEnded = true;
childStdin.end();
}
}
const stdin: ManagedRunStdin | undefined = child.stdin
const stdin: ManagedRunStdin | undefined = childStdin
? {
destroyed: false,
get destroyed() {
return stdinDestroyed || childStdin.destroyed;
},
get writable() {
return !stdinDestroyed && !stdinEnded && childStdin.writable;
},
get writableEnded() {
return stdinEnded || childStdin.writableEnded;
},
get writableFinished() {
return childStdin.writableFinished;
},
write: (data: string, cb?: (err?: Error | null) => void) => {
if (stdinDestroyed || stdinEnded || !childStdin.writable) {
cb?.(new Error("stdin is not writable"));
return;
}
try {
child.stdin.write(data, cb);
childStdin.write(data, cb);
} catch (err) {
cb?.(err as Error);
}
},
end: () => {
try {
child.stdin.end();
stdinEnded = true;
childStdin.end();
} catch {
// ignore close errors
}
},
destroy: () => {
try {
child.stdin.destroy();
stdinDestroyed = true;
stdinEnded = true;
childStdin.destroy();
} catch {
// ignore destroy errors
}

View File

@@ -153,6 +153,29 @@ describe("createPtyAdapter", () => {
expect(stub.onExit).toHaveBeenCalledTimes(1);
stub.emitExit({ exitCode: 3, signal: 0 });
await expect(adapter.wait()).resolves.toEqual({ code: 3, signal: null });
expect(adapter.stdin?.destroyed).toBe(true);
expect(adapter.stdin?.writable).toBe(false);
});
it("reports stdin as non-writable after EOF or dispose", async () => {
const stub = createStubPty();
spawnMock.mockReturnValue(stub);
const adapter = await createPtyAdapter({
shell: "bash",
args: ["-lc", "cat"],
});
expect(adapter.stdin?.writable).toBe(true);
expect(adapter.stdin?.writableEnded).toBe(false);
adapter.stdin?.end();
expect(stub.write).toHaveBeenCalledWith(process.platform === "win32" ? "\x1a" : "\x04");
expect(adapter.stdin?.writable).toBe(false);
expect(adapter.stdin?.writableEnded).toBe(true);
adapter.dispose();
expect(adapter.stdin?.destroyed).toBe(true);
});
it("disposes PTY listeners", async () => {

View File

@@ -75,6 +75,8 @@ export async function createPtyAdapter(params: {
let waitPromise: Promise<{ code: number | null; signal: NodeJS.Signals | number | null }> | null =
null;
let forceKillWaitFallbackTimer: NodeJS.Timeout | null = null;
let stdinDestroyed = false;
let stdinEnded = false;
const clearForceKillWaitFallback = () => {
if (!forceKillWaitFallbackTimer) {
@@ -89,6 +91,8 @@ export async function createPtyAdapter(params: {
return;
}
clearForceKillWaitFallback();
stdinDestroyed = true;
stdinEnded = true;
waitResult = value;
if (resolveWait) {
const resolve = resolveWait;
@@ -114,7 +118,18 @@ export async function createPtyAdapter(params: {
}) ?? null;
const stdin: ManagedRunStdin = {
destroyed: false,
get destroyed() {
return stdinDestroyed;
},
get writable() {
return !stdinDestroyed && !stdinEnded;
},
get writableEnded() {
return stdinEnded;
},
get writableFinished() {
return stdinEnded;
},
write: (data, cb) => {
try {
pty.write(data);
@@ -125,12 +140,17 @@ export async function createPtyAdapter(params: {
},
end: () => {
try {
stdinEnded = true;
const eof = process.platform === "win32" ? "\x1a" : "\x04";
pty.write(eof);
} catch {
// ignore EOF errors
}
},
destroy: () => {
stdinDestroyed = true;
stdinEnded = true;
},
};
const onStdout = (listener: (chunk: string) => void) => {
@@ -182,6 +202,8 @@ export async function createPtyAdapter(params: {
};
const dispose = () => {
stdinDestroyed = true;
stdinEnded = true;
try {
dataListener?.dispose();
} catch {

View File

@@ -52,6 +52,9 @@ export type ManagedRunStdin = {
end: () => void;
destroy?: () => void;
destroyed?: boolean;
writable?: boolean;
writableEnded?: boolean;
writableFinished?: boolean;
};
export type SpawnProcessAdapter<WaitSignal = NodeJS.Signals | number | null> = {