hoomanjs 1.46.0 → 1.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/README.md +7 -5
  2. package/dist/acp/acp-agent.d.ts +42 -0
  3. package/dist/acp/acp-agent.js +322 -38
  4. package/dist/acp/acp-agent.js.map +1 -1
  5. package/dist/acp/session-config.d.ts +2 -2
  6. package/dist/acp/session-config.js +24 -20
  7. package/dist/acp/session-config.js.map +1 -1
  8. package/dist/acp/utils/tool-kind.js +2 -0
  9. package/dist/acp/utils/tool-kind.js.map +1 -1
  10. package/dist/chat/app.js +75 -2
  11. package/dist/chat/app.js.map +1 -1
  12. package/dist/chat/components/ApprovalPrompt.js +7 -6
  13. package/dist/chat/components/ApprovalPrompt.js.map +1 -1
  14. package/dist/chat/components/BackgroundJobsPanel.d.ts +10 -0
  15. package/dist/chat/components/BackgroundJobsPanel.js +15 -0
  16. package/dist/chat/components/BackgroundJobsPanel.js.map +1 -0
  17. package/dist/chat/components/BottomChrome.d.ts +6 -1
  18. package/dist/chat/components/BottomChrome.js +3 -2
  19. package/dist/chat/components/BottomChrome.js.map +1 -1
  20. package/dist/chat/components/ChromePicker.d.ts +7 -2
  21. package/dist/chat/components/ChromePicker.js +18 -2
  22. package/dist/chat/components/ChromePicker.js.map +1 -1
  23. package/dist/chat/components/Composer.js +3 -2
  24. package/dist/chat/components/Composer.js.map +1 -1
  25. package/dist/chat/components/DownloadPanel.js +2 -1
  26. package/dist/chat/components/DownloadPanel.js.map +1 -1
  27. package/dist/chat/components/EmptyChatBanner.js +2 -1
  28. package/dist/chat/components/EmptyChatBanner.js.map +1 -1
  29. package/dist/chat/components/PromptInput.js +2 -1
  30. package/dist/chat/components/PromptInput.js.map +1 -1
  31. package/dist/chat/components/QuestionPrompt.js +5 -4
  32. package/dist/chat/components/QuestionPrompt.js.map +1 -1
  33. package/dist/chat/components/QueuedPrompts.js +3 -2
  34. package/dist/chat/components/QueuedPrompts.js.map +1 -1
  35. package/dist/chat/components/ReasoningStrip.js +2 -1
  36. package/dist/chat/components/ReasoningStrip.js.map +1 -1
  37. package/dist/chat/components/RetryEvent.js +2 -1
  38. package/dist/chat/components/RetryEvent.js.map +1 -1
  39. package/dist/chat/components/SelectPicker.js +2 -1
  40. package/dist/chat/components/SelectPicker.js.map +1 -1
  41. package/dist/chat/components/ShellToolEvent.d.ts +12 -0
  42. package/dist/chat/components/ShellToolEvent.js +94 -0
  43. package/dist/chat/components/ShellToolEvent.js.map +1 -0
  44. package/dist/chat/components/SlashCommands.js +2 -1
  45. package/dist/chat/components/SlashCommands.js.map +1 -1
  46. package/dist/chat/components/Spinner.js +2 -1
  47. package/dist/chat/components/Spinner.js.map +1 -1
  48. package/dist/chat/components/StatusBar.js +16 -15
  49. package/dist/chat/components/StatusBar.js.map +1 -1
  50. package/dist/chat/components/ThinkingStatus.js +2 -1
  51. package/dist/chat/components/ThinkingStatus.js.map +1 -1
  52. package/dist/chat/components/ThoughtEvent.js +2 -1
  53. package/dist/chat/components/ThoughtEvent.js.map +1 -1
  54. package/dist/chat/components/TodoPanel.js +2 -1
  55. package/dist/chat/components/TodoPanel.js.map +1 -1
  56. package/dist/chat/components/ToolEvent.d.ts +2 -1
  57. package/dist/chat/components/ToolEvent.js +9 -2
  58. package/dist/chat/components/ToolEvent.js.map +1 -1
  59. package/dist/chat/components/ToolEventFileResult.js +8 -7
  60. package/dist/chat/components/ToolEventFileResult.js.map +1 -1
  61. package/dist/chat/components/Transcript.d.ts +4 -2
  62. package/dist/chat/components/Transcript.js +4 -4
  63. package/dist/chat/components/Transcript.js.map +1 -1
  64. package/dist/chat/components/markdown/BlockRenderer.js +3 -2
  65. package/dist/chat/components/markdown/BlockRenderer.js.map +1 -1
  66. package/dist/chat/components/markdown/CodeBlock.js +3 -2
  67. package/dist/chat/components/markdown/CodeBlock.js.map +1 -1
  68. package/dist/chat/components/markdown/InlineRenderer.js +6 -5
  69. package/dist/chat/components/markdown/InlineRenderer.js.map +1 -1
  70. package/dist/chat/components/shared.js +7 -6
  71. package/dist/chat/components/shared.js.map +1 -1
  72. package/dist/chat/types.d.ts +4 -0
  73. package/dist/configure/components/BusyScreen.js +2 -1
  74. package/dist/configure/components/BusyScreen.js.map +1 -1
  75. package/dist/configure/components/MenuScreen.js +3 -2
  76. package/dist/configure/components/MenuScreen.js.map +1 -1
  77. package/dist/configure/components/PromptForm.js +2 -1
  78. package/dist/configure/components/PromptForm.js.map +1 -1
  79. package/dist/configure/components/SelectMenuItem.js +4 -3
  80. package/dist/configure/components/SelectMenuItem.js.map +1 -1
  81. package/dist/configure/utils.js +4 -3
  82. package/dist/configure/utils.js.map +1 -1
  83. package/dist/core/agent/index.js +2 -1
  84. package/dist/core/agent/index.js.map +1 -1
  85. package/dist/core/modes/definitions.js +4 -0
  86. package/dist/core/modes/definitions.js.map +1 -1
  87. package/dist/core/prompts/static/shell.md +21 -1
  88. package/dist/core/shell/index.d.ts +7 -0
  89. package/dist/core/shell/index.js +6 -0
  90. package/dist/core/shell/index.js.map +1 -0
  91. package/dist/core/shell/manager.d.ts +23 -0
  92. package/dist/core/shell/manager.js +604 -0
  93. package/dist/core/shell/manager.js.map +1 -0
  94. package/dist/core/shell/output-buffer.d.ts +30 -0
  95. package/dist/core/shell/output-buffer.js +116 -0
  96. package/dist/core/shell/output-buffer.js.map +1 -0
  97. package/dist/core/shell/registry.d.ts +5 -0
  98. package/dist/core/shell/registry.js +24 -0
  99. package/dist/core/shell/registry.js.map +1 -0
  100. package/dist/core/shell/shell.d.ts +41 -0
  101. package/dist/core/{tools → shell}/shell.js +185 -10
  102. package/dist/core/shell/shell.js.map +1 -0
  103. package/dist/core/{tools/shell.d.ts → shell/terminal-backend.d.ts} +20 -18
  104. package/dist/core/shell/terminal-backend.js +18 -0
  105. package/dist/core/shell/terminal-backend.js.map +1 -0
  106. package/dist/core/shell/types.d.ts +95 -0
  107. package/dist/core/shell/types.js +2 -0
  108. package/dist/core/shell/types.js.map +1 -0
  109. package/dist/core/state/shell-jobs.d.ts +9 -0
  110. package/dist/core/state/shell-jobs.js +20 -0
  111. package/dist/core/state/shell-jobs.js.map +1 -0
  112. package/dist/core/state/tool-approvals.js +3 -0
  113. package/dist/core/state/tool-approvals.js.map +1 -1
  114. package/dist/core/state/turn-boundaries.d.ts +33 -0
  115. package/dist/core/state/turn-boundaries.js +49 -0
  116. package/dist/core/state/turn-boundaries.js.map +1 -0
  117. package/dist/core/theme.d.ts +14 -0
  118. package/dist/core/theme.js +14 -0
  119. package/dist/core/theme.js.map +1 -0
  120. package/dist/core/tools/index.d.ts +0 -1
  121. package/dist/core/tools/index.js +0 -1
  122. package/dist/core/tools/index.js.map +1 -1
  123. package/dist/index.d.ts +2 -2
  124. package/dist/index.js +2 -2
  125. package/dist/index.js.map +1 -1
  126. package/package.json +1 -1
  127. package/dist/core/tools/shell.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../../src/core/modes/definitions.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../../src/core/modes/definitions.ts"],"names":[],"mappings":"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"}
@@ -1,6 +1,6 @@
1
1
  ## Shell
2
2
 
3
- You have access to a `shell` tool for local command execution.
3
+ You have access to a `shell` tool for local command execution, plus `shell_output` and `shell_stop` for managing background jobs.
4
4
 
5
5
  - Use `shell` when local command execution is the most direct way to inspect, verify, or operate on the environment.
6
6
  - Prefer dedicated file and search tools when the task is fundamentally about files or text.
@@ -9,3 +9,23 @@ You have access to a `shell` tool for local command execution.
9
9
  - Use sensible timeouts, use `ignore_errors` only when partial success is acceptable, and prefer commands or flags with naturally bounded output.
10
10
  - Summarize important command output instead of echoing long logs back to the user.
11
11
  - Avoid destructive or risky commands unless they are clearly necessary and appropriate, and be careful with package managers, process control, and commands that affect the wider system.
12
+
13
+ ### Background jobs
14
+
15
+ For long-running processes (dev servers, watchers, monitors, builds that should keep going):
16
+
17
+ - Set `run_in_background: true` with a short `description` (required). The tool returns a `job_id` immediately while the process keeps running.
18
+ - Or use `block_until_ms: 0` (same as immediate background) / a positive `block_until_ms` to wait up to that long before detaching.
19
+ - Use `notify_on_output: { pattern }` to block until a regex matches output, then return a `job_id` while the process continues (e.g. wait for a log line).
20
+ - Use `ready: { pattern?, port?, timeout_ms? }` to wait until a readiness probe succeeds (regex and/or local TCP port) before returning.
21
+ - Background mode supports a **single** command only (not command arrays) and does not support `stdin`.
22
+
23
+ Managing jobs (for you — do not explain these tools to the user unless they ask):
24
+
25
+ - `shell_output` — read output; by default blocks until exit. Pass `block: false` for a snapshot, or `pattern` to wait for a new match. Prefer one wait with a reasonable `timeout_ms` over tight polling loops.
26
+ - `shell_stop` — kill the job by `job_id`.
27
+ - Do not start a second copy of a server that is already running as a background job; use `shell_output` / `shell_stop` instead.
28
+ - Job completion is also surfaced as a notification; you do not need to poll continuously.
29
+ - The UI shows active background jobs and a Stop control; the user can also ask you to stop a job in plain language.
30
+
31
+ When you start a background job, reply in one or two short sentences (what is running). Do **not** dump `job_id`, tool names, parameter recipes, or “how to use shell_output / shell_stop” unless the user asks how to manage it.
@@ -0,0 +1,7 @@
1
+ export type { HostTerminalHandle, NotifyOnOutput, ReadyProbe, ShellJobEvent, ShellJobInfo, ShellJobListener, ShellJobOutputSnapshot, ShellJobStartOptions, ShellJobStatus, ShellJobWaitOptions, } from "./types.js";
2
+ export { OutputBuffer, RING_CAPACITY_BYTES, WATCHDOG_MAX_BYTES, } from "./output-buffer.js";
3
+ export { ShellJobManager } from "./manager.js";
4
+ export { clearShellJobManager, getShellJobManager, peekShellJobManager, } from "./registry.js";
5
+ export type { TerminalBackend, TerminalOutputSnapshot, TerminalRunRequest, TerminalRunResult, TerminalSpawnResult, } from "./terminal-backend.js";
6
+ export { getTerminalBackend, setTerminalBackend } from "./terminal-backend.js";
7
+ export { createShellTools, SHELL_OUTPUT_TOOL_NAME, SHELL_STOP_TOOL_NAME, SHELL_TOOL_NAME, } from "./shell.js";
@@ -0,0 +1,6 @@
1
+ export { OutputBuffer, RING_CAPACITY_BYTES, WATCHDOG_MAX_BYTES, } from "./output-buffer.js";
2
+ export { ShellJobManager } from "./manager.js";
3
+ export { clearShellJobManager, getShellJobManager, peekShellJobManager, } from "./registry.js";
4
+ export { getTerminalBackend, setTerminalBackend } from "./terminal-backend.js";
5
+ export { createShellTools, SHELL_OUTPUT_TOOL_NAME, SHELL_STOP_TOOL_NAME, SHELL_TOOL_NAME, } from "./shell.js";
6
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/core/shell/index.ts"],"names":[],"mappings":"AAYA,OAAO,EACL,YAAY,EACZ,mBAAmB,EACnB,kBAAkB,GACnB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAC/C,OAAO,EACL,oBAAoB,EACpB,kBAAkB,EAClB,mBAAmB,GACpB,MAAM,eAAe,CAAC;AAQvB,OAAO,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AAC/E,OAAO,EACL,gBAAgB,EAChB,sBAAsB,EACtB,oBAAoB,EACpB,eAAe,GAChB,MAAM,YAAY,CAAC"}
@@ -0,0 +1,23 @@
1
+ import type { ShellJobInfo, ShellJobListener, ShellJobOutputSnapshot, ShellJobStartOptions, ShellJobWaitOptions } from "./types.js";
2
+ /**
3
+ * Session-scoped manager for background (and early-return) shell jobs.
4
+ * Local spawn uses child_process; when a TerminalBackend with `spawn` is
5
+ * registered, jobs are hosted by the ACP client instead.
6
+ */
7
+ export declare class ShellJobManager {
8
+ #private;
9
+ constructor(agent: object);
10
+ on(listener: ShellJobListener): () => void;
11
+ list(): ShellJobInfo[];
12
+ listActive(): ShellJobInfo[];
13
+ get(jobId: string): ShellJobInfo | undefined;
14
+ /**
15
+ * Start a job. Returns a snapshot once the block condition is met
16
+ * (immediate BG, notify/ready match, block_until deadline, or exit).
17
+ */
18
+ start(options: ShellJobStartOptions): Promise<ShellJobOutputSnapshot>;
19
+ output(jobId: string, opts?: ShellJobWaitOptions): Promise<ShellJobOutputSnapshot>;
20
+ stop(jobId: string): Promise<ShellJobOutputSnapshot>;
21
+ /** Kill all active jobs (session teardown). */
22
+ clear(): Promise<void>;
23
+ }
@@ -0,0 +1,604 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createConnection } from "node:net";
3
+ import { randomBytes } from "node:crypto";
4
+ import { setTimeout as sleep } from "node:timers/promises";
5
+ import { getTerminalBackend, } from "./terminal-backend.js";
6
+ import { OutputBuffer } from "./output-buffer.js";
7
+ const SIGKILL_TIMEOUT_MS = 200;
8
+ const PORT_PROBE_INTERVAL_MS = 250;
9
+ const DEFAULT_READY_TIMEOUT_MS = 30_000;
10
+ const DEFAULT_WAIT_TIMEOUT_MS = 30_000;
11
+ const ACTIVE_STATUSES = new Set([
12
+ "starting",
13
+ "running",
14
+ "ready",
15
+ ]);
16
+ function newJobId() {
17
+ return `job-${randomBytes(4).toString("hex")}`;
18
+ }
19
+ function compileRegex(pattern) {
20
+ return new RegExp(pattern, "m");
21
+ }
22
+ async function killTree(proc, opts) {
23
+ const pid = proc.pid;
24
+ if (!pid || opts?.exited?.()) {
25
+ return;
26
+ }
27
+ if (process.platform === "win32") {
28
+ await new Promise((resolve) => {
29
+ const killer = spawn("taskkill", ["/pid", String(pid), "/f", "/t"], {
30
+ stdio: "ignore",
31
+ windowsHide: true,
32
+ });
33
+ killer.once("exit", () => resolve());
34
+ killer.once("error", () => resolve());
35
+ });
36
+ return;
37
+ }
38
+ try {
39
+ process.kill(-pid, "SIGTERM");
40
+ await sleep(SIGKILL_TIMEOUT_MS);
41
+ if (!opts?.exited?.()) {
42
+ process.kill(-pid, "SIGKILL");
43
+ }
44
+ }
45
+ catch {
46
+ proc.kill("SIGTERM");
47
+ await sleep(SIGKILL_TIMEOUT_MS);
48
+ if (!opts?.exited?.()) {
49
+ proc.kill("SIGKILL");
50
+ }
51
+ }
52
+ }
53
+ function probePort(port) {
54
+ return new Promise((resolve) => {
55
+ const socket = createConnection({ host: "127.0.0.1", port }, () => {
56
+ socket.destroy();
57
+ resolve(true);
58
+ });
59
+ socket.on("error", () => {
60
+ socket.destroy();
61
+ resolve(false);
62
+ });
63
+ socket.setTimeout(500, () => {
64
+ socket.destroy();
65
+ resolve(false);
66
+ });
67
+ });
68
+ }
69
+ /**
70
+ * Session-scoped manager for background (and early-return) shell jobs.
71
+ * Local spawn uses child_process; when a TerminalBackend with `spawn` is
72
+ * registered, jobs are hosted by the ACP client instead.
73
+ */
74
+ export class ShellJobManager {
75
+ #jobs = new Map();
76
+ #listeners = new Set();
77
+ #agent;
78
+ constructor(agent) {
79
+ this.#agent = agent;
80
+ }
81
+ on(listener) {
82
+ this.#listeners.add(listener);
83
+ return () => {
84
+ this.#listeners.delete(listener);
85
+ };
86
+ }
87
+ list() {
88
+ return [...this.#jobs.values()].map((j) => ({ ...j.info }));
89
+ }
90
+ listActive() {
91
+ return this.list().filter((j) => ACTIVE_STATUSES.has(j.status));
92
+ }
93
+ get(jobId) {
94
+ const job = this.#jobs.get(jobId);
95
+ return job ? { ...job.info } : undefined;
96
+ }
97
+ /**
98
+ * Start a job. Returns a snapshot once the block condition is met
99
+ * (immediate BG, notify/ready match, block_until deadline, or exit).
100
+ */
101
+ async start(options) {
102
+ const id = newJobId();
103
+ const backend = getTerminalBackend(this.#agent);
104
+ const useHost = Boolean(backend?.spawn);
105
+ const info = {
106
+ id,
107
+ description: options.description,
108
+ command: [options.command, ...options.args].join(" "),
109
+ cwd: options.cwd,
110
+ status: "starting",
111
+ ready: false,
112
+ toolUseId: options.toolUseId,
113
+ exitCode: null,
114
+ signal: null,
115
+ startedAt: Date.now(),
116
+ outputTruncated: false,
117
+ };
118
+ const job = {
119
+ info,
120
+ buffer: new OutputBuffer(id, {
121
+ onWatchdog: () => {
122
+ if (!job.watchdogFired) {
123
+ job.watchdogFired = true;
124
+ void this.stop(id);
125
+ }
126
+ },
127
+ }),
128
+ exited: false,
129
+ readOffset: 0,
130
+ exitWaiters: [],
131
+ patternWaiters: [],
132
+ notifyDebounceMs: options.notifyOnOutput?.debounce_ms ?? 0,
133
+ lastNotifyCheckAt: 0,
134
+ watchdogFired: false,
135
+ stopRequested: false,
136
+ syncInFlight: false,
137
+ backend: useHost ? backend : undefined,
138
+ };
139
+ if (options.notifyOnOutput?.pattern) {
140
+ job.notifyPattern = compileRegex(options.notifyOnOutput.pattern);
141
+ }
142
+ if (options.ready?.pattern) {
143
+ job.readyPattern = compileRegex(options.ready.pattern);
144
+ }
145
+ if (options.ready?.port !== undefined) {
146
+ job.readyPort = options.ready.port;
147
+ }
148
+ this.#jobs.set(id, job);
149
+ this.#emit({ type: "started", job: { ...info } });
150
+ try {
151
+ if (useHost && backend?.spawn) {
152
+ await this.#startHost(job, options, backend);
153
+ }
154
+ else {
155
+ this.#startLocal(job, options);
156
+ }
157
+ }
158
+ catch (error) {
159
+ job.info.status = "failed";
160
+ job.info.endedAt = Date.now();
161
+ job.exited = true;
162
+ const message = error instanceof Error ? error.message : String(error);
163
+ this.#emit({ type: "failed", job: { ...job.info }, error: message });
164
+ return this.#snapshot(job);
165
+ }
166
+ job.info.status = "running";
167
+ if (options.timeoutMs && options.timeoutMs > 0) {
168
+ job.timeoutTimer = setTimeout(() => {
169
+ void this.stop(id);
170
+ }, options.timeoutMs);
171
+ }
172
+ if (job.readyPort !== undefined) {
173
+ this.#startPortProbe(job, options.ready?.timeout_ms);
174
+ }
175
+ // Immediate background: block_until_ms === 0 and no notify/ready wait.
176
+ const blockUntil = options.blockUntilMs !== undefined
177
+ ? options.blockUntilMs
178
+ : options.notifyOnOutput || options.ready
179
+ ? (options.ready?.timeout_ms ?? DEFAULT_READY_TIMEOUT_MS)
180
+ : undefined;
181
+ if (blockUntil === 0) {
182
+ return this.#snapshot(job);
183
+ }
184
+ // Foreground-style wait for exit when no early-return condition.
185
+ if (blockUntil === undefined && !options.notifyOnOutput && !options.ready) {
186
+ await this.#waitForExit(job, options.cancelSignal);
187
+ return this.#snapshot(job);
188
+ }
189
+ // Wait for notify/ready or deadline, then return while process continues.
190
+ const deadline = blockUntil ?? DEFAULT_READY_TIMEOUT_MS;
191
+ await this.#waitForEarlyReturn(job, deadline, options.cancelSignal);
192
+ return this.#snapshot(job);
193
+ }
194
+ async output(jobId, opts) {
195
+ const job = this.#require(jobId);
196
+ await this.#syncHostOutput(job);
197
+ const block = opts?.block ?? true;
198
+ const timeoutMs = opts?.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS;
199
+ const pattern = opts?.pattern ? compileRegex(opts.pattern) : undefined;
200
+ if (!block && !pattern) {
201
+ return this.#snapshot(job, opts?.tailLines);
202
+ }
203
+ if (pattern) {
204
+ const since = job.readOffset;
205
+ const matched = await this.#waitForPattern(job, pattern, since, timeoutMs, opts?.cancelSignal);
206
+ const snap = this.#snapshot(job, opts?.tailLines);
207
+ snap.matched = matched;
208
+ job.readOffset = job.buffer.readAll().length;
209
+ return snap;
210
+ }
211
+ // Block until exit.
212
+ if (ACTIVE_STATUSES.has(job.info.status)) {
213
+ await Promise.race([
214
+ this.#waitForExit(job, opts?.cancelSignal),
215
+ sleep(timeoutMs).then(() => undefined),
216
+ ]);
217
+ await this.#syncHostOutput(job);
218
+ }
219
+ return this.#snapshot(job, opts?.tailLines);
220
+ }
221
+ async stop(jobId) {
222
+ const job = this.#require(jobId);
223
+ job.stopRequested = true;
224
+ // Stop polling before kill so we don't pile terminal/output RPCs on top
225
+ // of the kill path (chatty jobs like `top` make that expensive).
226
+ this.#clearTimers(job);
227
+ if (job.info.status === "stopped") {
228
+ return this.#snapshot(job);
229
+ }
230
+ // Kill the host/local process. Under ACP this nests `terminal/kill` under
231
+ // the caller (stop_shell_job / shell_stop) — same concurrent-request
232
+ // pattern as terminal/* during session/prompt.
233
+ if (ACTIVE_STATUSES.has(job.info.status) || !job.exited) {
234
+ if (job.terminalId && job.backend?.kill) {
235
+ await job.backend.kill(job.terminalId).catch(() => undefined);
236
+ }
237
+ else if (job.child) {
238
+ await killTree(job.child, { exited: () => job.exited });
239
+ }
240
+ }
241
+ if (!job.exited) {
242
+ this.#markEnded(job, "stopped", null, null);
243
+ }
244
+ else if (job.info.status === "completed") {
245
+ job.info.status = "stopped";
246
+ this.#emit({ type: "stopped", job: { ...job.info } });
247
+ }
248
+ return this.#snapshot(job);
249
+ }
250
+ /** Kill all active jobs (session teardown). */
251
+ async clear() {
252
+ const ids = [...this.#jobs.keys()];
253
+ await Promise.all(ids.map((id) => this.stop(id).catch(() => undefined)));
254
+ for (const job of this.#jobs.values()) {
255
+ this.#clearTimers(job);
256
+ }
257
+ this.#jobs.clear();
258
+ this.#listeners.clear();
259
+ }
260
+ #require(jobId) {
261
+ const job = this.#jobs.get(jobId);
262
+ if (!job) {
263
+ throw new Error(`Unknown shell job "${jobId}"`);
264
+ }
265
+ return job;
266
+ }
267
+ #snapshot(job, tailLines) {
268
+ job.info.outputTruncated = job.buffer.truncated;
269
+ const output = tailLines !== undefined
270
+ ? job.buffer.readTailLines(tailLines)
271
+ : job.buffer.readTail();
272
+ return {
273
+ jobId: job.info.id,
274
+ output,
275
+ truncated: job.buffer.truncated,
276
+ status: job.info.status,
277
+ ready: job.info.ready,
278
+ exitCode: job.info.exitCode,
279
+ signal: job.info.signal,
280
+ };
281
+ }
282
+ #emit(event) {
283
+ for (const listener of this.#listeners) {
284
+ try {
285
+ listener(event);
286
+ }
287
+ catch {
288
+ // Listeners must not break the manager.
289
+ }
290
+ }
291
+ }
292
+ #startLocal(job, options) {
293
+ const child = spawn(options.command, options.args, {
294
+ cwd: options.cwd,
295
+ env: process.env,
296
+ detached: process.platform !== "win32",
297
+ stdio: "pipe",
298
+ windowsHide: true,
299
+ });
300
+ job.child = child;
301
+ job.info.pid = child.pid;
302
+ const onData = (chunk) => {
303
+ this.#appendOutput(job, chunk.toString());
304
+ };
305
+ child.stdout?.on("data", onData);
306
+ child.stderr?.on("data", onData);
307
+ child.stdin?.end();
308
+ child.once("error", (error) => {
309
+ this.#appendOutput(job, `\n${error.message}`);
310
+ this.#markEnded(job, "failed", 1, null);
311
+ });
312
+ child.once("close", (code, signal) => {
313
+ const status = job.stopRequested ? "stopped" : "completed";
314
+ this.#markEnded(job, status, code, signal ? String(signal) : null);
315
+ });
316
+ // Foreground-only: turn cancellation must not kill background jobs.
317
+ const detachAsBackground = options.blockUntilMs === 0 ||
318
+ options.notifyOnOutput !== undefined ||
319
+ options.ready !== undefined;
320
+ if (options.cancelSignal && !detachAsBackground) {
321
+ const onAbort = () => {
322
+ void this.stop(job.info.id);
323
+ };
324
+ if (options.cancelSignal.aborted) {
325
+ onAbort();
326
+ }
327
+ else {
328
+ options.cancelSignal.addEventListener("abort", onAbort, { once: true });
329
+ }
330
+ }
331
+ }
332
+ async #startHost(job, options, backend) {
333
+ const spawned = await backend.spawn({
334
+ toolUseId: options.toolUseId,
335
+ command: options.command,
336
+ args: options.args,
337
+ cwd: options.cwd,
338
+ timeoutMs: options.timeoutMs ?? 0,
339
+ outputByteLimit: options.outputByteLimit,
340
+ cancelSignal: options.cancelSignal,
341
+ jobId: job.info.id,
342
+ });
343
+ job.terminalId = spawned.terminalId;
344
+ job.info.terminalId = spawned.terminalId;
345
+ // Poll host output while the job is active.
346
+ job.pollTimer = setInterval(() => {
347
+ void this.#syncHostOutput(job);
348
+ }, 300);
349
+ }
350
+ async #syncHostOutput(job) {
351
+ if (!job.terminalId || !job.backend?.readOutput || job.syncInFlight) {
352
+ return;
353
+ }
354
+ job.syncInFlight = true;
355
+ try {
356
+ const snap = await job.backend.readOutput(job.terminalId);
357
+ const current = job.buffer.readAll();
358
+ if (snap.output.length > current.length) {
359
+ this.#appendOutput(job, snap.output.slice(current.length));
360
+ }
361
+ else if (snap.output !== current && snap.output.length > 0) {
362
+ // Host may have truncated from the front; replace buffer content.
363
+ // Append only the delta suffix if possible.
364
+ const idx = snap.output.indexOf(current.slice(-200));
365
+ if (idx >= 0) {
366
+ this.#appendOutput(job, snap.output.slice(idx + 200));
367
+ }
368
+ }
369
+ if (snap.exitCode !== null || snap.signal !== null) {
370
+ if (!job.exited) {
371
+ const status = job.stopRequested ? "stopped" : "completed";
372
+ this.#markEnded(job, status, snap.exitCode, snap.signal);
373
+ }
374
+ }
375
+ }
376
+ catch {
377
+ // Host may have released the terminal.
378
+ }
379
+ finally {
380
+ job.syncInFlight = false;
381
+ }
382
+ }
383
+ #appendOutput(job, chunk) {
384
+ if (!chunk) {
385
+ return;
386
+ }
387
+ job.buffer.append(chunk);
388
+ this.#emit({ type: "output", job: { ...job.info }, chunk });
389
+ this.#checkPatterns(job);
390
+ }
391
+ #checkPatterns(job) {
392
+ const text = job.buffer.readAll();
393
+ if (!job.info.ready && job.readyPattern && job.readyPattern.test(text)) {
394
+ this.#markReady(job);
395
+ }
396
+ if (job.notifyPattern) {
397
+ const now = Date.now();
398
+ if (now - job.lastNotifyCheckAt >= job.notifyDebounceMs) {
399
+ job.lastNotifyCheckAt = now;
400
+ if (job.notifyPattern.test(text)) {
401
+ for (const waiter of job.patternWaiters.splice(0)) {
402
+ if (waiter.regex === job.notifyPattern || waiter.regex.test(text)) {
403
+ waiter.resolve(true);
404
+ }
405
+ else {
406
+ job.patternWaiters.push(waiter);
407
+ }
408
+ }
409
+ }
410
+ }
411
+ }
412
+ const remaining = [];
413
+ for (const waiter of job.patternWaiters) {
414
+ const slice = text.slice(waiter.since);
415
+ if (waiter.regex.test(slice) || waiter.regex.test(text)) {
416
+ waiter.resolve(true);
417
+ }
418
+ else {
419
+ remaining.push(waiter);
420
+ }
421
+ }
422
+ job.patternWaiters = remaining;
423
+ }
424
+ #markReady(job) {
425
+ if (job.info.ready) {
426
+ return;
427
+ }
428
+ job.info.ready = true;
429
+ if (job.info.status === "running" || job.info.status === "starting") {
430
+ job.info.status = "ready";
431
+ }
432
+ this.#clearPortProbe(job);
433
+ this.#emit({ type: "ready", job: { ...job.info } });
434
+ // Resolve pattern waiters that were waiting for ready.
435
+ for (const waiter of job.patternWaiters.splice(0)) {
436
+ waiter.resolve(true);
437
+ }
438
+ }
439
+ #startPortProbe(job, readyTimeoutMs) {
440
+ const port = job.readyPort;
441
+ if (port === undefined) {
442
+ return;
443
+ }
444
+ const deadline = Date.now() + (readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS);
445
+ job.portProbeTimer = setInterval(() => {
446
+ if (job.info.ready || job.exited || Date.now() > deadline) {
447
+ this.#clearPortProbe(job);
448
+ return;
449
+ }
450
+ void probePort(port).then((ok) => {
451
+ if (ok && !job.info.ready && !job.exited) {
452
+ this.#markReady(job);
453
+ }
454
+ });
455
+ }, PORT_PROBE_INTERVAL_MS);
456
+ }
457
+ #clearPortProbe(job) {
458
+ if (job.portProbeTimer) {
459
+ clearInterval(job.portProbeTimer);
460
+ job.portProbeTimer = undefined;
461
+ }
462
+ }
463
+ #clearTimers(job) {
464
+ if (job.pollTimer) {
465
+ clearInterval(job.pollTimer);
466
+ job.pollTimer = undefined;
467
+ }
468
+ if (job.timeoutTimer) {
469
+ clearTimeout(job.timeoutTimer);
470
+ job.timeoutTimer = undefined;
471
+ }
472
+ this.#clearPortProbe(job);
473
+ }
474
+ #markEnded(job, status, exitCode, signal) {
475
+ if (job.exited && job.info.status !== "starting") {
476
+ // Already ended; allow stop to override completed → stopped.
477
+ if (!(status === "stopped" && job.info.status === "completed")) {
478
+ return;
479
+ }
480
+ }
481
+ job.exited = true;
482
+ job.info.status = status;
483
+ job.info.exitCode = exitCode;
484
+ job.info.signal = signal;
485
+ job.info.endedAt = Date.now();
486
+ this.#clearTimers(job);
487
+ for (const waiter of job.exitWaiters.splice(0)) {
488
+ waiter();
489
+ }
490
+ for (const waiter of job.patternWaiters.splice(0)) {
491
+ waiter.resolve(false);
492
+ }
493
+ if (status === "stopped") {
494
+ this.#emit({ type: "stopped", job: { ...job.info } });
495
+ }
496
+ else if (status === "failed") {
497
+ this.#emit({
498
+ type: "failed",
499
+ job: { ...job.info },
500
+ error: "process failed",
501
+ });
502
+ }
503
+ else {
504
+ this.#emit({ type: "completed", job: { ...job.info } });
505
+ }
506
+ }
507
+ #waitForExit(job, cancelSignal) {
508
+ if (job.exited) {
509
+ return Promise.resolve();
510
+ }
511
+ return new Promise((resolve) => {
512
+ job.exitWaiters.push(resolve);
513
+ if (cancelSignal) {
514
+ const onAbort = () => {
515
+ void this.stop(job.info.id).finally(resolve);
516
+ };
517
+ if (cancelSignal.aborted) {
518
+ onAbort();
519
+ }
520
+ else {
521
+ cancelSignal.addEventListener("abort", onAbort, { once: true });
522
+ }
523
+ }
524
+ });
525
+ }
526
+ #waitForPattern(job, regex, since, timeoutMs, cancelSignal) {
527
+ const text = job.buffer.readAll();
528
+ if (regex.test(text.slice(since)) || regex.test(text)) {
529
+ return Promise.resolve(true);
530
+ }
531
+ if (job.exited) {
532
+ return Promise.resolve(false);
533
+ }
534
+ return new Promise((resolve) => {
535
+ let settled = false;
536
+ const settle = (matched) => {
537
+ if (settled) {
538
+ return;
539
+ }
540
+ settled = true;
541
+ clearTimeout(timer);
542
+ resolve(matched);
543
+ };
544
+ job.patternWaiters.push({ regex, since, resolve: settle });
545
+ const timer = setTimeout(() => settle(false), timeoutMs);
546
+ if (cancelSignal) {
547
+ const onAbort = () => settle(false);
548
+ if (cancelSignal.aborted) {
549
+ onAbort();
550
+ }
551
+ else {
552
+ cancelSignal.addEventListener("abort", onAbort, { once: true });
553
+ }
554
+ }
555
+ });
556
+ }
557
+ async #waitForEarlyReturn(job, deadlineMs, cancelSignal) {
558
+ if (job.exited || job.info.ready) {
559
+ return;
560
+ }
561
+ const patterns = [];
562
+ if (job.notifyPattern) {
563
+ patterns.push(job.notifyPattern);
564
+ }
565
+ if (job.readyPattern) {
566
+ patterns.push(job.readyPattern);
567
+ }
568
+ if (patterns.length === 0 && job.readyPort === undefined) {
569
+ // Just wait for the deadline, then detach.
570
+ await Promise.race([
571
+ this.#waitForExit(job, cancelSignal),
572
+ sleep(deadlineMs),
573
+ ]);
574
+ return;
575
+ }
576
+ const since = 0;
577
+ const waiters = patterns.map((regex) => this.#waitForPattern(job, regex, since, deadlineMs, cancelSignal));
578
+ // Also resolve early when ready via port.
579
+ const readyWait = job.readyPort !== undefined
580
+ ? new Promise((resolve) => {
581
+ const check = () => {
582
+ if (job.info.ready) {
583
+ resolve(true);
584
+ return;
585
+ }
586
+ if (job.exited) {
587
+ resolve(false);
588
+ return;
589
+ }
590
+ setTimeout(check, 100);
591
+ };
592
+ check();
593
+ setTimeout(() => resolve(job.info.ready), deadlineMs);
594
+ })
595
+ : null;
596
+ await Promise.race([
597
+ ...waiters,
598
+ ...(readyWait ? [readyWait] : []),
599
+ this.#waitForExit(job, cancelSignal),
600
+ sleep(deadlineMs),
601
+ ]);
602
+ }
603
+ }
604
+ //# sourceMappingURL=manager.js.map