From ed6b030a43372d1e708f22b8337b0f61e2a9e3c8 Mon Sep 17 00:00:00 2001 From: bitloi <89318445+bitloi@users.noreply.github.com> Date: Sun, 10 May 2026 05:13:07 -0300 Subject: [PATCH] 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 4aea1f11fed2d42762437a5585606300bc3be86a: 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 --- CHANGELOG.md | 1 + docs/gateway/background-process.md | 14 +- src/agents/bash-process-registry.ts | 3 + src/agents/bash-tools.descriptions.ts | 2 +- src/agents/bash-tools.exec.ts | 2 +- src/agents/bash-tools.process-send-keys.ts | 3 + .../bash-tools.process.input-hints.test.ts | 225 ++++++++++++++++++ src/agents/bash-tools.process.ts | 126 ++++++++-- src/agents/bash-tools.schemas.ts | 4 +- src/process/supervisor/adapters/child.test.ts | 54 +++++ src/process/supervisor/adapters/child.ts | 51 +++- src/process/supervisor/adapters/pty.test.ts | 23 ++ src/process/supervisor/adapters/pty.ts | 24 +- src/process/supervisor/types.ts | 3 + 14 files changed, 502 insertions(+), 33 deletions(-) create mode 100644 src/agents/bash-tools.process.input-hints.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f7dfd1ce47c..78f53f63421 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/gateway/background-process.md b/docs/gateway/background-process.md index 1857cf6548a..b0cd85e42dc 100644 --- a/docs/gateway/background-process.md +++ b/docs/gateway/background-process.md @@ -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 1m–3h) +- `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": "" } ``` +Inspect an interactive session before sending input: + +```json +{ "tool": "process", "action": "log", "sessionId": "" } +``` + Start immediately in background: ```json diff --git a/src/agents/bash-process-registry.ts b/src/agents/bash-process-registry.ts index 2e3ffe48764..26ee5c1de4c 100644 --- a/src/agents/bash-process-registry.ts +++ b/src/agents/bash-process-registry.ts @@ -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 { diff --git a/src/agents/bash-tools.descriptions.ts b/src/agents/bash-tools.descriptions.ts index e64314fd2fb..ba978e7055b 100644 --- a/src/agents/bash-tools.descriptions.ts +++ b/src/agents/bash-tools.descriptions.ts @@ -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, diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index f379dbef36a..68b1052df19 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -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: { diff --git a/src/agents/bash-tools.process-send-keys.ts b/src/agents/bash-tools.process-send-keys.ts index c62ae8ed1a5..880d98f6a9d 100644 --- a/src/agents/bash-tools.process-send-keys.ts +++ b/src/agents/bash-tools.process-send-keys.ts @@ -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 { diff --git a/src/agents/bash-tools.process.input-hints.test.ts b/src/agents/bash-tools.process.input-hints.test.ts new file mode 100644 index 00000000000..78a88d6a29f --- /dev/null +++ b/src/agents/bash-tools.process.input-hints.test.ts @@ -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; +type ProcessToolResult = Awaited>; + +afterEach(() => { + resetProcessRegistryForTests(); + vi.useRealTimers(); +}); + +async function runProcessAction( + processTool: ProcessTool, + args: Record, +): Promise { + return processTool.execute("toolcall", args as Parameters[1], undefined); +} + +function textOf(result: ProcessToolResult): string { + const item = result.content[0]; + return item?.type === "text" ? item.text : ""; +} + +function installWritableStdin( + session: ReturnType, + 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 & { + 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> }).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, + }); + }); +}); diff --git a/src/agents/bash-tools.process.ts b/src/agents/bash-tools.process.ts index 2aeda9bd911..65f88b5dd00 100644 --- a/src/agents/bash-tools.process.ts +++ b/src/agents/bash-tools.process.ts @@ -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), }, }; } diff --git a/src/agents/bash-tools.schemas.ts b/src/agents/bash-tools.schemas.ts index 0251736b6b5..80fe33a9f01 100644 --- a/src/agents/bash-tools.schemas.ts +++ b/src/agents/bash-tools.schemas.ts @@ -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( diff --git a/src/process/supervisor/adapters/child.test.ts b/src/process/supervisor/adapters/child.test.ts index c0a23b81f06..e90f7f35a2c 100644 --- a/src/process/supervisor/adapters/child.test.ts +++ b/src/process/supervisor/adapters/child.test.ts @@ -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 }; + }; + 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 }); diff --git a/src/process/supervisor/adapters/child.ts b/src/process/supervisor/adapters/child.ts index 88b07800e86..7f737ce6552 100644 --- a/src/process/supervisor/adapters/child.ts +++ b/src/process/supervisor/adapters/child.ts @@ -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 } diff --git a/src/process/supervisor/adapters/pty.test.ts b/src/process/supervisor/adapters/pty.test.ts index 14525471e7d..3274511ba8e 100644 --- a/src/process/supervisor/adapters/pty.test.ts +++ b/src/process/supervisor/adapters/pty.test.ts @@ -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 () => { diff --git a/src/process/supervisor/adapters/pty.ts b/src/process/supervisor/adapters/pty.ts index e1bb7c3373b..aa0743fc86c 100644 --- a/src/process/supervisor/adapters/pty.ts +++ b/src/process/supervisor/adapters/pty.ts @@ -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 { diff --git a/src/process/supervisor/types.ts b/src/process/supervisor/types.ts index 7bb0f39c1df..24a4f03bfff 100644 --- a/src/process/supervisor/types.ts +++ b/src/process/supervisor/types.ts @@ -52,6 +52,9 @@ export type ManagedRunStdin = { end: () => void; destroy?: () => void; destroyed?: boolean; + writable?: boolean; + writableEnded?: boolean; + writableFinished?: boolean; }; export type SpawnProcessAdapter = {