@cjhyy/code-shell-core 0.7.0-beta.1 → 0.7.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 (57) hide show
  1. package/dist/cc-orchestrator/agent-adapter.d.ts +2 -0
  2. package/dist/cc-orchestrator/agent-adapter.js +4 -0
  3. package/dist/cc-orchestrator/codex-session-history.d.ts +14 -1
  4. package/dist/cc-orchestrator/codex-session-history.js +64 -4
  5. package/dist/cc-orchestrator/external-agent-changes.js +22 -5
  6. package/dist/cc-orchestrator/external-agent-driver.d.ts +1 -1
  7. package/dist/cc-orchestrator/external-agent-driver.js +202 -38
  8. package/dist/cc-orchestrator/session-history.d.ts +35 -0
  9. package/dist/cc-orchestrator/session-history.js +96 -13
  10. package/dist/credentials/access.d.ts +1 -0
  11. package/dist/credentials/access.js +2 -0
  12. package/dist/credentials/index.d.ts +2 -1
  13. package/dist/credentials/index.js +1 -0
  14. package/dist/credentials/oauth.d.ts +20 -0
  15. package/dist/credentials/oauth.js +114 -0
  16. package/dist/credentials/store.d.ts +1 -0
  17. package/dist/credentials/store.js +3 -1
  18. package/dist/credentials/types.d.ts +47 -1
  19. package/dist/engine/engine.d.ts +6 -2
  20. package/dist/engine/engine.js +159 -192
  21. package/dist/engine/goal.d.ts +17 -0
  22. package/dist/engine/goal.js +16 -6
  23. package/dist/engine/input-attachments.js +156 -13
  24. package/dist/engine/run-image-input.d.ts +22 -0
  25. package/dist/engine/run-image-input.js +195 -0
  26. package/dist/engine/steer-queue.d.ts +3 -1
  27. package/dist/engine/steer-queue.js +10 -2
  28. package/dist/engine/turn-loop.d.ts +30 -1
  29. package/dist/engine/turn-loop.js +112 -17
  30. package/dist/hooks/goal-stop-hook.d.ts +33 -1
  31. package/dist/hooks/goal-stop-hook.js +202 -34
  32. package/dist/index.d.ts +2 -2
  33. package/dist/index.js +2 -2
  34. package/dist/preset/index.js +14 -4
  35. package/dist/protocol/server.js +1 -1
  36. package/dist/protocol/types.d.ts +2 -0
  37. package/dist/session/session-manager.js +34 -1
  38. package/dist/tool-system/builtin/agent-notifications.d.ts +11 -4
  39. package/dist/tool-system/builtin/agent-notifications.js +19 -7
  40. package/dist/tool-system/builtin/background-jobs.d.ts +25 -5
  41. package/dist/tool-system/builtin/background-jobs.js +105 -7
  42. package/dist/tool-system/builtin/cron-list.definition.d.ts +3 -0
  43. package/dist/tool-system/builtin/cron-list.definition.js +6 -0
  44. package/dist/tool-system/builtin/cron.d.ts +1 -2
  45. package/dist/tool-system/builtin/cron.js +9 -7
  46. package/dist/tool-system/builtin/drive-claude-code.d.ts +7 -0
  47. package/dist/tool-system/builtin/drive-claude-code.js +307 -20
  48. package/dist/tool-system/builtin/index.js +15 -3
  49. package/dist/tool-system/builtin/sleep.d.ts +1 -2
  50. package/dist/tool-system/builtin/sleep.definition.d.ts +8 -0
  51. package/dist/tool-system/builtin/sleep.definition.js +28 -0
  52. package/dist/tool-system/builtin/sleep.js +1 -22
  53. package/dist/tool-system/context.d.ts +18 -0
  54. package/dist/tool-system/mcp-manager.d.ts +14 -2
  55. package/dist/tool-system/mcp-manager.js +56 -7
  56. package/dist/types.d.ts +23 -7
  57. package/package.json +1 -1
@@ -2,6 +2,8 @@ export type PermissionMode = "default" | "acceptEdits" | "bypassPermissions";
2
2
  export interface BuildArgsOpts {
3
3
  prompt: string;
4
4
  resumeSessionId?: string;
5
+ /** Optional caller-specified model override. Passed through without validation; the CLI reports unknown models. */
6
+ model?: string;
5
7
  permissionMode: PermissionMode;
6
8
  cwd: string;
7
9
  imagePaths?: string[];
@@ -14,6 +14,8 @@ export const claudeAdapter = {
14
14
  buildArgs(opts) {
15
15
  // -p (print/headless) + stream-json REQUIRES --verbose (verified).
16
16
  const args = ["-p", opts.prompt, "--output-format", "stream-json", "--verbose"];
17
+ if (opts.model)
18
+ args.push("--model", opts.model);
17
19
  if (opts.resumeSessionId)
18
20
  args.push("--resume", opts.resumeSessionId);
19
21
  // Hard-disallow Workflow: driving CC unattended (esp. bypassPermissions),
@@ -82,6 +84,8 @@ export const codexAdapter = {
82
84
  else {
83
85
  args.push("--sandbox", opts.permissionMode === "acceptEdits" ? "workspace-write" : "read-only");
84
86
  }
87
+ if (opts.model)
88
+ args.push("--model", opts.model);
85
89
  if (opts.codexImageInputSupported) {
86
90
  for (const imagePath of opts.imagePaths ?? [])
87
91
  args.push("-i", imagePath);
@@ -1,4 +1,4 @@
1
- import type { HistoryMessage } from "./session-history.js";
1
+ import type { HistoryMessage, SessionTailEvent } from "./session-history.js";
2
2
  /**
3
3
  * Read the last `limit` user/assistant messages from a codex CLI session,
4
4
  * returning the SAME shape as the claude-side `readRecentHistory` so the room
@@ -23,3 +23,16 @@ export declare function readCodexRecentHistory(cwd: string, threadId: string, li
23
23
  hasMore: boolean;
24
24
  totalCount: number;
25
25
  };
26
+ /** Parse a bounded/raw Codex rollout snapshot. Shared with the desktop tail
27
+ * follower so its initial history and EOF cursor are atomic. */
28
+ export declare function parseCodexRecentHistory(raw: string, limit: number): {
29
+ messages: HistoryMessage[];
30
+ hasMore: boolean;
31
+ totalCount: number;
32
+ };
33
+ /** Parse one newly-appended Codex rollout JSONL line. `response_item` is the
34
+ * authoritative rendered stream; parallel `event_msg.agent_message` records
35
+ * are deliberately ignored to avoid duplicate assistant bubbles. */
36
+ export declare function parseCodexTranscriptLine(line: string): SessionTailEvent[];
37
+ /** Find the rollout file whose `session_meta` matches both `threadId` and `cwd`. */
38
+ export declare function findCodexRolloutFile(codexHome: string, cwd: string, threadId: string): string | undefined;
@@ -1,4 +1,4 @@
1
- import { readdirSync, readFileSync, statSync, existsSync, openSync, readSync, closeSync } from "node:fs";
1
+ import { readdirSync, readFileSync, statSync, existsSync, openSync, readSync, closeSync, } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
  /**
@@ -22,7 +22,7 @@ import { homedir } from "node:os";
22
22
  */
23
23
  export function readCodexRecentHistory(cwd, threadId, limit, codexHome = join(homedir(), ".codex")) {
24
24
  const empty = { messages: [], hasMore: false, totalCount: 0 };
25
- const file = findRolloutFile(codexHome, cwd, threadId);
25
+ const file = findCodexRolloutFile(codexHome, cwd, threadId);
26
26
  if (!file)
27
27
  return empty;
28
28
  let raw;
@@ -32,6 +32,11 @@ export function readCodexRecentHistory(cwd, threadId, limit, codexHome = join(ho
32
32
  catch {
33
33
  return empty;
34
34
  }
35
+ return parseCodexRecentHistory(raw, limit);
36
+ }
37
+ /** Parse a bounded/raw Codex rollout snapshot. Shared with the desktop tail
38
+ * follower so its initial history and EOF cursor are atomic. */
39
+ export function parseCodexRecentHistory(raw, limit) {
35
40
  const all = [];
36
41
  for (const line of raw.split("\n")) {
37
42
  if (!line.trim())
@@ -55,7 +60,11 @@ export function readCodexRecentHistory(cwd, threadId, limit, codexHome = join(ho
55
60
  all.push({ role: p.role, text: t });
56
61
  }
57
62
  else if (p.type === "function_call" || p.type === "custom_tool_call") {
58
- const tool = { name: typeof p.name === "string" ? p.name : "tool", summary: summaryOf(p), args: argsOf(p) };
63
+ const tool = {
64
+ name: typeof p.name === "string" ? p.name : "tool",
65
+ summary: summaryOf(p),
66
+ args: argsOf(p),
67
+ };
59
68
  const last = all[all.length - 1];
60
69
  // Attach the tool to the preceding assistant turn; otherwise start one.
61
70
  if (last && last.role === "assistant") {
@@ -70,6 +79,57 @@ export function readCodexRecentHistory(cwd, threadId, limit, codexHome = join(ho
70
79
  const start = Math.max(0, all.length - lim);
71
80
  return { messages: all.slice(start), hasMore: start > 0, totalCount: all.length };
72
81
  }
82
+ /** Parse one newly-appended Codex rollout JSONL line. `response_item` is the
83
+ * authoritative rendered stream; parallel `event_msg.agent_message` records
84
+ * are deliberately ignored to avoid duplicate assistant bubbles. */
85
+ export function parseCodexTranscriptLine(line) {
86
+ let d;
87
+ try {
88
+ d = JSON.parse(line);
89
+ }
90
+ catch {
91
+ return [];
92
+ }
93
+ if (d?.type === "event_msg" && d.payload?.type === "task_complete") {
94
+ return [{ type: "turn_end", reason: "completed" }];
95
+ }
96
+ if (d?.type !== "response_item" || !d.payload)
97
+ return [];
98
+ const p = d.payload;
99
+ if (p.type === "message" && (p.role === "user" || p.role === "assistant")) {
100
+ const text = textOf(p.content).trim();
101
+ if (!text || text.startsWith("<environment_context>"))
102
+ return [];
103
+ return [{ type: p.role === "user" ? "user" : "assistant", text }];
104
+ }
105
+ if (p.type === "function_call" || p.type === "custom_tool_call") {
106
+ return [
107
+ {
108
+ type: "tool",
109
+ id: typeof p.call_id === "string" ? p.call_id : undefined,
110
+ name: typeof p.name === "string" ? p.name : "tool",
111
+ summary: summaryOf(p),
112
+ args: argsOf(p),
113
+ },
114
+ ];
115
+ }
116
+ if (p.type === "function_call_output" || p.type === "custom_tool_call_output") {
117
+ const result = typeof p.output === "string"
118
+ ? p.output
119
+ : typeof p.content === "string"
120
+ ? p.content
121
+ : JSON.stringify(p.output ?? p.content ?? "");
122
+ return [
123
+ {
124
+ type: "tool_result",
125
+ id: typeof p.call_id === "string" ? p.call_id : undefined,
126
+ result: result.slice(0, 4000),
127
+ isError: Boolean(p.is_error),
128
+ },
129
+ ];
130
+ }
131
+ return [];
132
+ }
73
133
  /** Join the text of codex content parts (`input_text`/`output_text`). */
74
134
  function textOf(content) {
75
135
  if (typeof content === "string")
@@ -121,7 +181,7 @@ function argsOf(payload) {
121
181
  return undefined;
122
182
  }
123
183
  /** Find the rollout file whose `session_meta` matches both `threadId` and `cwd`. */
124
- function findRolloutFile(codexHome, cwd, threadId) {
184
+ export function findCodexRolloutFile(codexHome, cwd, threadId) {
125
185
  const root = join(codexHome, "sessions");
126
186
  if (!existsSync(root))
127
187
  return undefined;
@@ -2,6 +2,7 @@ import { readFileSync, existsSync, readdirSync, statSync, openSync, readSync, cl
2
2
  import { join } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
  import { encodeCwd } from "./session-discovery.js";
5
+ import { logger } from "../logging/logger.js";
5
6
  /**
6
7
  * #6 — attribute file changes made by a background external agent (DriveAgent
7
8
  * running `claude` / `codex`). Those Edit/Write calls land in the external CLI's
@@ -143,11 +144,27 @@ export function readCodexChangedFiles(cwd, threadId, codexHome = join(homedir(),
143
144
  /** Dispatch by CLI. Unknown cli → []. Never throws. */
144
145
  export function readExternalChangedFiles(cli, cwd, sessionId) {
145
146
  try {
146
- if (cli === "codex")
147
- return readCodexChangedFiles(cwd, sessionId);
148
- return readClaudeChangedFiles(cwd, sessionId);
149
- }
150
- catch {
147
+ const files = cli === "codex"
148
+ ? readCodexChangedFiles(cwd, sessionId)
149
+ : readClaudeChangedFiles(cwd, sessionId);
150
+ logger.debug("changed_files.external.extracted", {
151
+ cat: "changed_files",
152
+ cli,
153
+ cwd,
154
+ externalSessionId: sessionId,
155
+ size: files.length,
156
+ files,
157
+ });
158
+ return files;
159
+ }
160
+ catch (err) {
161
+ logger.debug("changed_files.external.extract_failed", {
162
+ cat: "changed_files",
163
+ cli,
164
+ cwd,
165
+ externalSessionId: sessionId,
166
+ error: err instanceof Error ? err.message : String(err),
167
+ });
151
168
  return [];
152
169
  }
153
170
  }
@@ -11,7 +11,7 @@ export declare function runWithLines(adapter: AgentAdapter, lines: string[], exi
11
11
  export interface DriverRunOpts extends Omit<BuildArgsOpts, "permissionMode"> {
12
12
  permissionMode?: PermissionMode;
13
13
  }
14
- export declare function detectCodexImageInput(command: string, cwd: string): Promise<boolean>;
14
+ export declare function detectCodexImageInput(command: string, cwd: string, signal?: AbortSignal): Promise<boolean>;
15
15
  /** Spawn ONE headless agent run, collect stream-json to exit, return result.
16
16
  * No time concept — a single turn. Honors AbortSignal (kills the child). */
17
17
  export declare function runAgentOnce(adapter: AgentAdapter, opts: DriverRunOpts & {
@@ -1,17 +1,130 @@
1
- import { spawn } from "node:child_process";
1
+ import { execFile, spawn } from "node:child_process";
2
2
  import { createInterface } from "node:readline";
3
3
  import { pathWithCommonBins } from "./cc-capability.js";
4
+ import { killProcessGroup } from "../runtime/spawn-common.js";
4
5
  /** Pure: reduce collected output lines + exit code to a result. Unit-testable. */
5
6
  export function runWithLines(adapter, lines, exitCode) {
6
7
  const parsed = adapter.parseResult(lines);
7
8
  return { ...parsed, exitCode, lines };
8
9
  }
9
10
  const codexImageFlagCache = new Map();
10
- export function detectCodexImageInput(command, cwd) {
11
- const cached = codexImageFlagCache.get(command);
12
- if (cached)
13
- return cached;
14
- const probe = new Promise((resolve) => {
11
+ const CODEX_IMAGE_PROBE_TIMEOUT_MS = 5_000;
12
+ const AGENT_TERMINATE_GRACE_MS = 500;
13
+ function listPosixProcessTree(rootPid) {
14
+ return new Promise((resolve) => {
15
+ execFile("ps", ["-axo", "pid=,ppid="], { encoding: "utf8" }, (err, stdout) => {
16
+ if (err) {
17
+ resolve([rootPid]);
18
+ return;
19
+ }
20
+ const childrenByParent = new Map();
21
+ for (const line of stdout.split("\n")) {
22
+ const [pidText, parentText] = line.trim().split(/\s+/, 2);
23
+ const pid = Number(pidText);
24
+ const parentPid = Number(parentText);
25
+ if (!Number.isInteger(pid) || !Number.isInteger(parentPid))
26
+ continue;
27
+ const children = childrenByParent.get(parentPid) ?? [];
28
+ children.push(pid);
29
+ childrenByParent.set(parentPid, children);
30
+ }
31
+ const pids = [];
32
+ const visit = (pid) => {
33
+ if (pids.includes(pid))
34
+ return;
35
+ pids.push(pid);
36
+ for (const childPid of childrenByParent.get(pid) ?? [])
37
+ visit(childPid);
38
+ };
39
+ visit(rootPid);
40
+ resolve(pids);
41
+ });
42
+ });
43
+ }
44
+ function isPidAlive(pid) {
45
+ try {
46
+ process.kill(pid, 0);
47
+ return true;
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ }
53
+ function delay(ms) {
54
+ return new Promise((resolve) => setTimeout(resolve, ms));
55
+ }
56
+ async function waitForPidsToExit(pids, timeoutMs) {
57
+ const deadline = Date.now() + timeoutMs;
58
+ while ([...pids].some(isPidAlive)) {
59
+ if (Date.now() >= deadline)
60
+ return false;
61
+ await delay(25);
62
+ }
63
+ return true;
64
+ }
65
+ function signalPids(pids, killSignal) {
66
+ for (const pid of pids) {
67
+ try {
68
+ process.kill(pid, killSignal);
69
+ }
70
+ catch {
71
+ // already gone
72
+ }
73
+ }
74
+ }
75
+ async function terminateAttachedProcessTree(child) {
76
+ const pid = child.pid;
77
+ if (!pid) {
78
+ try {
79
+ child.kill("SIGKILL");
80
+ }
81
+ catch {
82
+ // already gone
83
+ }
84
+ return;
85
+ }
86
+ if (process.platform === "win32") {
87
+ // Windows has no POSIX SIGTERM semantics or negative-pid groups. Reap the
88
+ // tree via taskkill, then use positive-pid process.kill as the fallback the
89
+ // platform supports when taskkill is unavailable/races.
90
+ await killProcessGroup(pid, { graceMs: AGENT_TERMINATE_GRACE_MS });
91
+ if (isPidAlive(pid)) {
92
+ try {
93
+ process.kill(pid);
94
+ }
95
+ catch {
96
+ // already gone
97
+ }
98
+ }
99
+ return;
100
+ }
101
+ const trackedPids = new Set(await listPosixProcessTree(pid));
102
+ signalPids(trackedPids, "SIGTERM");
103
+ if (await waitForPidsToExit(trackedPids, AGENT_TERMINATE_GRACE_MS))
104
+ return;
105
+ // Capture descendants created during the grace window while the leader is
106
+ // still addressable, but retain the first snapshot so reparented stubborn
107
+ // descendants cannot disappear from the escalation set.
108
+ for (const treePid of await listPosixProcessTree(pid))
109
+ trackedPids.add(treePid);
110
+ signalPids(trackedPids, "SIGKILL");
111
+ await waitForPidsToExit(trackedPids, 1_000);
112
+ }
113
+ function abortError() {
114
+ const err = new Error("Agent run aborted");
115
+ err.name = "AbortError";
116
+ return err;
117
+ }
118
+ function throwIfAborted(signal) {
119
+ if (signal?.aborted)
120
+ throw abortError();
121
+ }
122
+ function createCodexImageProbe(command, cwd, signal) {
123
+ return new Promise((resolve, reject) => {
124
+ if (signal?.aborted) {
125
+ reject(abortError());
126
+ return;
127
+ }
15
128
  const child = spawn(command, ["exec", "--help"], {
16
129
  cwd,
17
130
  env: { ...process.env, PATH: pathWithCommonBins() },
@@ -20,31 +133,65 @@ export function detectCodexImageInput(command, cwd) {
20
133
  });
21
134
  let out = "";
22
135
  let settled = false;
136
+ let terminating = false;
137
+ const cleanup = () => {
138
+ clearTimeout(timer);
139
+ signal?.removeEventListener("abort", onAbort);
140
+ };
23
141
  const done = (value) => {
24
142
  if (settled)
25
143
  return;
26
144
  settled = true;
27
- clearTimeout(timer);
145
+ cleanup();
28
146
  resolve(value);
29
147
  };
148
+ const fail = (err) => {
149
+ if (settled)
150
+ return;
151
+ settled = true;
152
+ cleanup();
153
+ reject(err);
154
+ };
155
+ const onAbort = () => {
156
+ if (settled || terminating)
157
+ return;
158
+ terminating = true;
159
+ cleanup();
160
+ void terminateAttachedProcessTree(child).then(() => fail(abortError()));
161
+ };
30
162
  const timer = setTimeout(() => {
31
- try {
32
- child.kill("SIGTERM");
33
- }
34
- catch {
35
- // already gone
36
- }
37
- done(false);
38
- }, 2_000);
163
+ if (settled || terminating)
164
+ return;
165
+ terminating = true;
166
+ cleanup();
167
+ void terminateAttachedProcessTree(child).then(() => done(false));
168
+ }, CODEX_IMAGE_PROBE_TIMEOUT_MS);
169
+ signal?.addEventListener("abort", onAbort, { once: true });
170
+ if (signal?.aborted)
171
+ onAbort();
39
172
  child.stdout?.on("data", (chunk) => {
40
173
  out += chunk.toString("utf8");
41
174
  });
42
175
  child.stderr?.on("data", (chunk) => {
43
176
  out += chunk.toString("utf8");
44
177
  });
45
- child.on("error", () => done(false));
46
- child.on("exit", () => done(/(?:^|\s)-i(?:,|\s)|--image\b/.test(out)));
178
+ child.on("error", () => {
179
+ if (!terminating)
180
+ done(false);
181
+ });
182
+ child.on("exit", () => {
183
+ if (!terminating)
184
+ done(/(?:^|\s)-i(?:,|\s)|--image\b/.test(out));
185
+ });
47
186
  });
187
+ }
188
+ export function detectCodexImageInput(command, cwd, signal) {
189
+ if (signal)
190
+ return createCodexImageProbe(command, cwd, signal);
191
+ const cached = codexImageFlagCache.get(command);
192
+ if (cached)
193
+ return cached;
194
+ const probe = createCodexImageProbe(command, cwd);
48
195
  codexImageFlagCache.set(command, probe);
49
196
  return probe;
50
197
  }
@@ -53,17 +200,25 @@ export function detectCodexImageInput(command, cwd) {
53
200
  export function runAgentOnce(adapter, opts, signal) {
54
201
  return new Promise((resolve, reject) => {
55
202
  void (async () => {
203
+ throwIfAborted(signal);
56
204
  const codexImageInputSupported = adapter.kind === "codex" && (opts.imagePaths?.length ?? 0) > 0
57
- ? await detectCodexImageInput(opts.command, opts.cwd).catch(() => false)
205
+ ? await detectCodexImageInput(opts.command, opts.cwd, signal).catch((err) => {
206
+ if (signal?.aborted || err?.name === "AbortError")
207
+ throw err;
208
+ return false;
209
+ })
58
210
  : false;
211
+ throwIfAborted(signal);
59
212
  const args = adapter.buildArgs({
60
213
  prompt: opts.prompt,
61
214
  resumeSessionId: opts.resumeSessionId,
215
+ model: opts.model,
62
216
  permissionMode: opts.permissionMode ?? "default",
63
217
  cwd: opts.cwd,
64
218
  imagePaths: opts.imagePaths,
65
219
  codexImageInputSupported,
66
220
  });
221
+ throwIfAborted(signal);
67
222
  // claude takes the prompt in argv (`-p <prompt>`) and wants stdin closed
68
223
  // (verified: avoids a 3s wait). codex `exec` reads the prompt from stdin
69
224
  // (argv ends with `-`), so adapters that set promptViaStdin get a piped
@@ -72,14 +227,27 @@ export function runAgentOnce(adapter, opts, signal) {
72
227
  const child = spawn(opts.command, args, {
73
228
  cwd: opts.cwd,
74
229
  env: { ...process.env, PATH: pathWithCommonBins() },
75
- // NOT detached: this child is owned by the (long-lived) worker that reads
76
- // its stdout. Detaching orphaned it across a worker/app restart the
77
- // reader promise then never resolved and the completion notification never
78
- // fired (the "后台任务没返回" bug). Bound to the worker, it lives or dies
79
- // with the process that's actually listening for its result.
230
+ // Keep the agent in the owning worker/app process group. If that owner
231
+ // is force-terminated, the CLI and its descendants must not escape into
232
+ // a detached group that can keep editing the workspace unseen.
80
233
  detached: false,
81
234
  stdio: [viaStdin ? "pipe" : "ignore", "pipe", "pipe"],
82
235
  });
236
+ let settled = false;
237
+ let abortRequested = false;
238
+ let termination;
239
+ const cleanup = () => {
240
+ signal?.removeEventListener("abort", onAbort);
241
+ };
242
+ const onAbort = () => {
243
+ if (abortRequested)
244
+ return;
245
+ abortRequested = true;
246
+ termination = terminateAttachedProcessTree(child);
247
+ };
248
+ signal?.addEventListener("abort", onAbort, { once: true });
249
+ if (signal?.aborted)
250
+ onAbort();
83
251
  if (viaStdin && child.stdin) {
84
252
  child.stdin.end(opts.prompt);
85
253
  }
@@ -88,20 +256,11 @@ export function runAgentOnce(adapter, opts, signal) {
88
256
  const rl = createInterface({ input: child.stdout });
89
257
  rl.on("line", (line) => lines.push(line));
90
258
  }
91
- // Not detached → no own process group, so kill the child directly (a
92
- // negative-pid group kill would target the worker's group). claude has no
93
- // long-lived child tree of its own here, so a direct SIGTERM is sufficient.
94
- const onAbort = () => {
95
- try {
96
- child.kill("SIGTERM");
97
- }
98
- catch {
99
- /* already gone */
100
- }
101
- };
102
- signal?.addEventListener("abort", onAbort, { once: true });
103
259
  child.on("error", (err) => {
104
- signal?.removeEventListener("abort", onAbort);
260
+ if (settled)
261
+ return;
262
+ settled = true;
263
+ cleanup();
105
264
  // A missing binary is the most common failure (user hasn't installed the
106
265
  // CLI, or GUI-launched Electron's PATH misses it). Turn the cryptic
107
266
  // "spawn codex ENOENT" into something actionable that names the command.
@@ -112,8 +271,13 @@ export function runAgentOnce(adapter, opts, signal) {
112
271
  reject(err);
113
272
  });
114
273
  child.on("exit", (code) => {
115
- signal?.removeEventListener("abort", onAbort);
116
- resolve(runWithLines(adapter, lines, code));
274
+ if (settled)
275
+ return;
276
+ settled = true;
277
+ cleanup();
278
+ void (termination ?? Promise.resolve()).then(() => {
279
+ resolve(runWithLines(adapter, lines, code));
280
+ });
117
281
  });
118
282
  })().catch(reject);
119
283
  });
@@ -11,6 +11,41 @@ export interface HistoryMessage {
11
11
  }[];
12
12
  ts?: number;
13
13
  }
14
+ /** One render-relevant event parsed from an appended external-CLI transcript
15
+ * line. Kept CLI-neutral so the desktop room follower can feed Claude Code and
16
+ * Codex through the same RoomManager push path. */
17
+ export type SessionTailEvent = {
18
+ type: "user";
19
+ text: string;
20
+ } | {
21
+ type: "assistant";
22
+ text: string;
23
+ } | {
24
+ type: "tool";
25
+ id?: string;
26
+ name: string;
27
+ summary: string;
28
+ args?: Record<string, unknown>;
29
+ } | {
30
+ type: "tool_result";
31
+ id?: string;
32
+ result: string;
33
+ isError: boolean;
34
+ } | {
35
+ type: "turn_end";
36
+ reason: string;
37
+ };
38
+ /** Parse a bounded/raw Claude Code transcript snapshot. Exported so a live
39
+ * follower can take its initial snapshot and EOF cursor from the same read,
40
+ * eliminating the snapshot→subscribe race. */
41
+ export declare function parseRecentHistory(raw: string, limit: number): {
42
+ messages: HistoryMessage[];
43
+ hasMore: boolean;
44
+ totalCount: number;
45
+ };
46
+ /** Parse one newly-appended Claude Code transcript JSONL line into the compact
47
+ * event vocabulary consumed by the desktop room follower. */
48
+ export declare function parseClaudeTranscriptLine(line: string): SessionTailEvent[];
14
49
  /** Read the last `limit` user/assistant messages from a claude session jsonl. */
15
50
  export declare function readRecentHistory(cwd: string, sessionId: string, limit: number, claudeHome?: string): {
16
51
  messages: HistoryMessage[];