mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 15:34:46 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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": "<id>" }
|
||||
```
|
||||
|
||||
Inspect an interactive session before sending input:
|
||||
|
||||
```json
|
||||
{ "tool": "process", "action": "log", "sessionId": "<id>" }
|
||||
```
|
||||
|
||||
Start immediately in background:
|
||||
|
||||
```json
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
225
src/agents/bash-tools.process.input-hints.test.ts
Normal file
225
src/agents/bash-tools.process.input-hints.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
Reference in New Issue
Block a user