@cjhyy/code-shell-core 0.7.0-beta.1 → 0.7.1

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 (146) hide show
  1. package/dist/arena/arena.d.ts +2 -0
  2. package/dist/arena/arena.js +30 -1
  3. package/dist/arena/phases/adjudication.d.ts +2 -1
  4. package/dist/arena/phases/adjudication.js +2 -1
  5. package/dist/arena/phases/build-consensus.d.ts +2 -1
  6. package/dist/arena/phases/build-consensus.js +3 -1
  7. package/dist/arena/phases/cross-review.d.ts +3 -1
  8. package/dist/arena/phases/cross-review.js +8 -2
  9. package/dist/arena/phases/debate-rounds.d.ts +2 -1
  10. package/dist/arena/phases/debate-rounds.js +4 -2
  11. package/dist/arena/phases/participant-research.d.ts +2 -1
  12. package/dist/arena/phases/participant-research.js +3 -1
  13. package/dist/arena/phases/planning-detail-expansion.d.ts +2 -1
  14. package/dist/arena/phases/planning-detail-expansion.js +2 -1
  15. package/dist/arena/planner.d.ts +2 -1
  16. package/dist/arena/planner.js +2 -1
  17. package/dist/arena/types.d.ts +5 -1
  18. package/dist/cc-orchestrator/agent-adapter.d.ts +2 -0
  19. package/dist/cc-orchestrator/agent-adapter.js +4 -0
  20. package/dist/cc-orchestrator/codex-session-history.d.ts +14 -1
  21. package/dist/cc-orchestrator/codex-session-history.js +64 -4
  22. package/dist/cc-orchestrator/external-agent-changes.js +22 -5
  23. package/dist/cc-orchestrator/external-agent-driver.d.ts +1 -1
  24. package/dist/cc-orchestrator/external-agent-driver.js +202 -38
  25. package/dist/cc-orchestrator/session-history.d.ts +35 -0
  26. package/dist/cc-orchestrator/session-history.js +96 -13
  27. package/dist/cli/agent-server-tcp.js +13 -2
  28. package/dist/context/manager.d.ts +3 -3
  29. package/dist/context/manager.js +6 -6
  30. package/dist/context/token-counter.js +5 -3
  31. package/dist/context/tool-result-storage.d.ts +6 -0
  32. package/dist/context/tool-result-storage.js +25 -4
  33. package/dist/credentials/access.d.ts +11 -1
  34. package/dist/credentials/access.js +77 -1
  35. package/dist/credentials/index.d.ts +3 -1
  36. package/dist/credentials/index.js +2 -0
  37. package/dist/credentials/oauth.d.ts +25 -0
  38. package/dist/credentials/oauth.js +179 -0
  39. package/dist/credentials/store.d.ts +2 -1
  40. package/dist/credentials/store.js +19 -8
  41. package/dist/credentials/types.d.ts +84 -1
  42. package/dist/credentials/types.js +16 -1
  43. package/dist/engine/engine.d.ts +67 -34
  44. package/dist/engine/engine.js +448 -247
  45. package/dist/engine/goal.d.ts +19 -0
  46. package/dist/engine/goal.js +16 -6
  47. package/dist/engine/input-attachments.js +156 -13
  48. package/dist/engine/run-image-input.d.ts +22 -0
  49. package/dist/engine/run-image-input.js +195 -0
  50. package/dist/engine/session-title.d.ts +2 -1
  51. package/dist/engine/session-title.js +4 -1
  52. package/dist/engine/steer-queue.d.ts +3 -1
  53. package/dist/engine/steer-queue.js +10 -2
  54. package/dist/engine/turn-loop.d.ts +48 -1
  55. package/dist/engine/turn-loop.js +307 -37
  56. package/dist/engine/types.d.ts +6 -2
  57. package/dist/git/worktree/crud.d.ts +3 -0
  58. package/dist/git/worktree/crud.js +32 -3
  59. package/dist/git/worktree/git-exec.d.ts +2 -2
  60. package/dist/git/worktree/git-exec.js +47 -11
  61. package/dist/git/worktree/query.d.ts +8 -7
  62. package/dist/git/worktree/query.js +27 -20
  63. package/dist/hooks/events.d.ts +3 -0
  64. package/dist/hooks/events.js +0 -3
  65. package/dist/hooks/goal-stop-hook.d.ts +44 -2
  66. package/dist/hooks/goal-stop-hook.js +775 -52
  67. package/dist/hooks/registry.js +3 -0
  68. package/dist/hooks/shell-runner.d.ts +12 -1
  69. package/dist/hooks/shell-runner.js +160 -9
  70. package/dist/index.d.ts +7 -6
  71. package/dist/index.js +6 -5
  72. package/dist/llm/client-base.js +12 -10
  73. package/dist/llm/types.d.ts +12 -5
  74. package/dist/plugins/pluginCommandHook.d.ts +4 -4
  75. package/dist/plugins/pluginCommandHook.js +111 -13
  76. package/dist/preset/index.js +14 -4
  77. package/dist/protocol/chat-session-manager.d.ts +13 -2
  78. package/dist/protocol/chat-session-manager.js +90 -18
  79. package/dist/protocol/chat-session.d.ts +12 -0
  80. package/dist/protocol/chat-session.js +30 -5
  81. package/dist/protocol/client.d.ts +5 -2
  82. package/dist/protocol/client.js +22 -1
  83. package/dist/protocol/server.d.ts +25 -11
  84. package/dist/protocol/server.js +291 -73
  85. package/dist/protocol/types.d.ts +36 -2
  86. package/dist/protocol/types.js +2 -0
  87. package/dist/services/dream-consolidation.d.ts +3 -0
  88. package/dist/services/dream-consolidation.js +4 -1
  89. package/dist/services/index.d.ts +1 -1
  90. package/dist/services/index.js +1 -1
  91. package/dist/services/oauth.d.ts +34 -10
  92. package/dist/services/oauth.js +233 -98
  93. package/dist/session/session-manager.d.ts +35 -6
  94. package/dist/session/session-manager.js +396 -27
  95. package/dist/session/transcript.d.ts +30 -1
  96. package/dist/session/transcript.js +119 -4
  97. package/dist/tool-system/builtin/agent-notifications.d.ts +11 -4
  98. package/dist/tool-system/builtin/agent-notifications.js +19 -7
  99. package/dist/tool-system/builtin/agent.js +5 -1
  100. package/dist/tool-system/builtin/arena.js +1 -0
  101. package/dist/tool-system/builtin/background-jobs.d.ts +28 -5
  102. package/dist/tool-system/builtin/background-jobs.js +109 -7
  103. package/dist/tool-system/builtin/background-work.d.ts +6 -1
  104. package/dist/tool-system/builtin/background-work.js +5 -1
  105. package/dist/tool-system/builtin/bash.d.ts +3 -5
  106. package/dist/tool-system/builtin/bash.js +10 -5
  107. package/dist/tool-system/builtin/browser-tools.d.ts +2 -2
  108. package/dist/tool-system/builtin/cron-list.definition.d.ts +3 -0
  109. package/dist/tool-system/builtin/cron-list.definition.js +6 -0
  110. package/dist/tool-system/builtin/cron.d.ts +1 -2
  111. package/dist/tool-system/builtin/cron.js +9 -7
  112. package/dist/tool-system/builtin/drive-claude-code.d.ts +7 -0
  113. package/dist/tool-system/builtin/drive-claude-code.js +307 -20
  114. package/dist/tool-system/builtin/edit.d.ts +2 -1
  115. package/dist/tool-system/builtin/edit.js +12 -4
  116. package/dist/tool-system/builtin/generate-video.d.ts +4 -0
  117. package/dist/tool-system/builtin/generate-video.js +138 -21
  118. package/dist/tool-system/builtin/glob.d.ts +2 -1
  119. package/dist/tool-system/builtin/glob.js +28 -3
  120. package/dist/tool-system/builtin/grep.d.ts +1 -0
  121. package/dist/tool-system/builtin/grep.js +82 -17
  122. package/dist/tool-system/builtin/index.d.ts +25 -11
  123. package/dist/tool-system/builtin/index.js +60 -5
  124. package/dist/tool-system/builtin/sleep.d.ts +1 -2
  125. package/dist/tool-system/builtin/sleep.definition.d.ts +8 -0
  126. package/dist/tool-system/builtin/sleep.definition.js +28 -0
  127. package/dist/tool-system/builtin/sleep.js +1 -22
  128. package/dist/tool-system/builtin/video-providers.d.ts +12 -15
  129. package/dist/tool-system/builtin/video-providers.js +1 -0
  130. package/dist/tool-system/builtin/view-image.d.ts +2 -2
  131. package/dist/tool-system/builtin/web-fetch.js +44 -3
  132. package/dist/tool-system/builtin/worktree.js +25 -7
  133. package/dist/tool-system/builtin/write.d.ts +2 -1
  134. package/dist/tool-system/builtin/write.js +14 -4
  135. package/dist/tool-system/context.d.ts +35 -5
  136. package/dist/tool-system/executor.js +24 -8
  137. package/dist/tool-system/mcp-manager.d.ts +20 -2
  138. package/dist/tool-system/mcp-manager.js +111 -12
  139. package/dist/tool-system/path-policy.d.ts +19 -0
  140. package/dist/tool-system/path-policy.js +62 -1
  141. package/dist/tool-system/permission.d.ts +43 -3
  142. package/dist/tool-system/permission.js +383 -30
  143. package/dist/tool-system/registry.d.ts +3 -2
  144. package/dist/tool-system/registry.js +52 -34
  145. package/dist/types.d.ts +38 -7
  146. package/package.json +1 -1
@@ -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[];
@@ -19,23 +19,28 @@ function toolsOf(content) {
19
19
  const inp = p.input ?? {};
20
20
  const summary = inp.command ?? inp.file_path ?? inp.path ?? inp.url ?? inp.pattern ?? inp.query ?? "";
21
21
  const args = inp && typeof inp === "object" && Object.keys(inp).length > 0 ? inp : undefined;
22
- out.push({ name: typeof p.name === "string" ? p.name : "tool", summary: String(summary).slice(0, 120), args });
22
+ out.push({
23
+ name: typeof p.name === "string" ? p.name : "tool",
24
+ summary: String(summary).slice(0, 120),
25
+ args,
26
+ });
23
27
  }
24
28
  }
25
29
  return out;
26
30
  }
27
- /** Read the last `limit` user/assistant messages from a claude session jsonl. */
28
- export function readRecentHistory(cwd, sessionId, limit, claudeHome = join(homedir(), ".claude")) {
29
- const file = join(claudeHome, "projects", encodeCwd(cwd), `${sessionId}.jsonl`);
30
- if (!existsSync(file))
31
- return { messages: [], hasMore: false, totalCount: 0 };
32
- let raw;
33
- try {
34
- raw = readFileSync(file, "utf-8");
35
- }
36
- catch {
37
- return { messages: [], hasMore: false, totalCount: 0 };
38
- }
31
+ function toolResultText(content) {
32
+ if (typeof content === "string")
33
+ return content;
34
+ if (!Array.isArray(content))
35
+ return "";
36
+ return content
37
+ .map((part) => typeof part === "string" ? part : typeof part?.text === "string" ? part.text : "")
38
+ .join("");
39
+ }
40
+ /** Parse a bounded/raw Claude Code transcript snapshot. Exported so a live
41
+ * follower can take its initial snapshot and EOF cursor from the same read,
42
+ * eliminating the snapshot→subscribe race. */
43
+ export function parseRecentHistory(raw, limit) {
39
44
  const all = [];
40
45
  for (const line of raw.split("\n")) {
41
46
  if (!line.trim())
@@ -65,3 +70,81 @@ export function readRecentHistory(cwd, sessionId, limit, claudeHome = join(homed
65
70
  const start = Math.max(0, all.length - lim);
66
71
  return { messages: all.slice(start), hasMore: start > 0, totalCount: all.length };
67
72
  }
73
+ /** Parse one newly-appended Claude Code transcript JSONL line into the compact
74
+ * event vocabulary consumed by the desktop room follower. */
75
+ export function parseClaudeTranscriptLine(line) {
76
+ let d;
77
+ try {
78
+ d = JSON.parse(line);
79
+ }
80
+ catch {
81
+ return [];
82
+ }
83
+ const out = [];
84
+ if (d.type === "user") {
85
+ const content = d.message?.content;
86
+ if (Array.isArray(content)) {
87
+ for (const part of content) {
88
+ if (part?.type !== "tool_result")
89
+ continue;
90
+ out.push({
91
+ type: "tool_result",
92
+ id: typeof part.tool_use_id === "string" ? part.tool_use_id : undefined,
93
+ result: toolResultText(part.content).slice(0, 4000),
94
+ isError: Boolean(part.is_error),
95
+ });
96
+ }
97
+ }
98
+ const text = textOf(content).trim();
99
+ if (text && !NOISE.some((noise) => text.startsWith(noise))) {
100
+ out.unshift({ type: "user", text });
101
+ }
102
+ return out;
103
+ }
104
+ if (d.type === "assistant" && Array.isArray(d.message?.content)) {
105
+ for (const part of d.message.content) {
106
+ if (part?.type === "text" && typeof part.text === "string" && part.text) {
107
+ out.push({ type: "assistant", text: part.text });
108
+ }
109
+ else if (part?.type === "tool_use") {
110
+ const input = part.input && typeof part.input === "object"
111
+ ? part.input
112
+ : undefined;
113
+ out.push({
114
+ type: "tool",
115
+ id: typeof part.id === "string" ? part.id : undefined,
116
+ name: typeof part.name === "string" ? part.name : "tool",
117
+ summary: toolsOf([part])[0]?.summary ?? "",
118
+ args: input,
119
+ });
120
+ }
121
+ }
122
+ if (d.message.stop_reason === "end_turn") {
123
+ out.push({ type: "turn_end", reason: "completed" });
124
+ }
125
+ return out;
126
+ }
127
+ if (d.type === "result") {
128
+ return [
129
+ {
130
+ type: "turn_end",
131
+ reason: typeof d.subtype === "string" ? d.subtype : "completed",
132
+ },
133
+ ];
134
+ }
135
+ return [];
136
+ }
137
+ /** Read the last `limit` user/assistant messages from a claude session jsonl. */
138
+ export function readRecentHistory(cwd, sessionId, limit, claudeHome = join(homedir(), ".claude")) {
139
+ const file = join(claudeHome, "projects", encodeCwd(cwd), `${sessionId}.jsonl`);
140
+ if (!existsSync(file))
141
+ return { messages: [], hasMore: false, totalCount: 0 };
142
+ let raw;
143
+ try {
144
+ raw = readFileSync(file, "utf-8");
145
+ }
146
+ catch {
147
+ return { messages: [], hasMore: false, totalCount: 0 };
148
+ }
149
+ return parseRecentHistory(raw, limit);
150
+ }
@@ -33,6 +33,8 @@ import { createRunManager } from "../run/factory.js";
33
33
  import { startAutomation } from "../automation/index.js";
34
34
  import { CronStore, defaultCronStorePath } from "../automation/store.js";
35
35
  import { resolveLLMConfigForTag } from "../engine/resolve-llm-config.js";
36
+ import { randomUUID } from "node:crypto";
37
+ import { getApprovalRouter } from "../tool-system/permission.js";
36
38
  const cwd = process.env.AGENT_CWD ?? process.cwd();
37
39
  const port = Number(process.env.AGENT_TCP_PORT ?? "4321");
38
40
  const host = process.env.AGENT_TCP_HOST ?? "127.0.0.1";
@@ -113,9 +115,18 @@ const automation = startAutomation({
113
115
  // ── Serve over TCP ──────────────────────────────────────────────
114
116
  // One AgentServer per accepted connection, all sharing the same chatManager.
115
117
  const servers = new Set();
116
- listenTcp({ port, host }, (transport) => {
117
- const server = new AgentServer({ chatManager, transport });
118
+ listenTcp({ port, host }, (transport, socket) => {
119
+ const server = new AgentServer({
120
+ chatManager,
121
+ transport,
122
+ connectionId: randomUUID(),
123
+ approvalRouter: getApprovalRouter(),
124
+ });
118
125
  servers.add(server);
126
+ socket.once("close", () => {
127
+ server.disconnect();
128
+ servers.delete(server);
129
+ });
119
130
  })
120
131
  .then((listener) => {
121
132
  process.stderr.write(`[code-shell] automation server listening on ${host}:${listener.port}\n`);
@@ -30,7 +30,7 @@ export interface ContextManagerConfig {
30
30
  * Async function type for LLM summarization calls.
31
31
  * Injected by the Engine so the ContextManager doesn't depend on LLM directly.
32
32
  */
33
- export type SummarizeFn = (prompt: string) => Promise<string>;
33
+ export type SummarizeFn = (prompt: string, signal?: AbortSignal) => Promise<string>;
34
34
  export type CompactStrategy = "micro" | "summary" | "window" | "snip" | "emergency";
35
35
  export type OnCompactFn = (info: {
36
36
  strategy: CompactStrategy;
@@ -111,7 +111,7 @@ export declare class ContextManager {
111
111
  * Async context management — attempts LLM summarization before falling back.
112
112
  * Call this when you have access to the LLM (between turns).
113
113
  */
114
- manageAsync(messages: Message[]): Promise<Message[]>;
114
+ manageAsync(messages: Message[], signal?: AbortSignal): Promise<Message[]>;
115
115
  /**
116
116
  * Force maximum compaction, ignoring the ratio gates. This is what a manual
117
117
  * `/compact` invokes: the user explicitly asked to shrink NOW, so we don't
@@ -126,7 +126,7 @@ export declare class ContextManager {
126
126
  * a long-but-under-threshold text-only conversation (the /compact bug) still
127
127
  * gets summarized here.
128
128
  */
129
- forceSummarize(messages: Message[]): Promise<Message[]>;
129
+ forceSummarize(messages: Message[], signal?: AbortSignal): Promise<Message[]>;
130
130
  /**
131
131
  * Persist large tool_result blocks to disk and replace them with a
132
132
  * preview + filepath. No-op when transcript path hasn't been set
@@ -162,7 +162,7 @@ export class ContextManager {
162
162
  setSummarizeFn(fn) {
163
163
  this.summarizeFn = fn;
164
164
  }
165
- async trySummaryCompact(messages, before, logEvent) {
165
+ async trySummaryCompact(messages, before, logEvent, signal) {
166
166
  if (!this.summarizeFn || this.consecutiveSummaryFailures >= 3) {
167
167
  return { messages, tokens: before, compacted: false, noProgress: false };
168
168
  }
@@ -178,7 +178,7 @@ export class ContextManager {
178
178
  // re-summarizes from scratch.
179
179
  const priorSummary = extractAnchoredSummary(messages) ?? this.lastSummary;
180
180
  const prompt = buildSummarizationPrompt(messagesToSummarize, priorSummary);
181
- const summary = await this.summarizeFn(prompt);
181
+ const summary = await this.summarizeFn(prompt, signal);
182
182
  if (!summary || summary.length <= 50) {
183
183
  this.consecutiveSummaryFailures++;
184
184
  logger.warn("context.summary_failed", {
@@ -358,7 +358,7 @@ export class ContextManager {
358
358
  * Async context management — attempts LLM summarization before falling back.
359
359
  * Call this when you have access to the LLM (between turns).
360
360
  */
361
- async manageAsync(messages) {
361
+ async manageAsync(messages, signal) {
362
362
  let result = messages;
363
363
  // Tier 0a: Persist large tool_results to disk + replace with preview.
364
364
  result = this.persistLargeToolResults(result);
@@ -428,7 +428,7 @@ export class ContextManager {
428
428
  // Tier 2: LLM summary if approaching limit, or if micro was the only tier
429
429
  // available in the 0.70-0.85 band and it freed nothing.
430
430
  if (ratio >= this.config.compactAtRatio || shouldEscalateNoOpMicro) {
431
- const summarized = await this.trySummaryCompact(result, tokens, "context.summary_compact");
431
+ const summarized = await this.trySummaryCompact(result, tokens, "context.summary_compact", signal);
432
432
  result = summarized.messages;
433
433
  tokens = summarized.tokens;
434
434
  if (summarized.compacted) {
@@ -488,7 +488,7 @@ export class ContextManager {
488
488
  * a long-but-under-threshold text-only conversation (the /compact bug) still
489
489
  * gets summarized here.
490
490
  */
491
- async forceSummarize(messages) {
491
+ async forceSummarize(messages, signal) {
492
492
  let result = messages;
493
493
  // Tier 0: same waste-removal + micro as the automatic path.
494
494
  result = this.persistLargeToolResults(result);
@@ -505,7 +505,7 @@ export class ContextManager {
505
505
  let tokens = this.estimateTokensHybrid(result);
506
506
  // Unconditional LLM summary (no ratio gate).
507
507
  {
508
- const summarized = await this.trySummaryCompact(result, tokens, "context.force_summary_compact");
508
+ const summarized = await this.trySummaryCompact(result, tokens, "context.force_summary_compact", signal);
509
509
  result = summarized.messages;
510
510
  tokens = summarized.tokens;
511
511
  if (summarized.compacted)
@@ -15,6 +15,9 @@ const RATIOS = {
15
15
  mixed: 3.6, // Mixed content (typical for tool results)
16
16
  cjk: 1.5, // CJK characters: ~1.5 chars per token
17
17
  };
18
+ // Base64 is transport data, not text. A fixed vision estimate prevents large
19
+ // images from spuriously exhausting the text context window.
20
+ const IMAGE_TOKEN_ESTIMATE = 170;
18
21
  /**
19
22
  * Estimate token count for a string, detecting content type.
20
23
  */
@@ -80,12 +83,11 @@ function estimateBlockTokens(block) {
80
83
  if (block.name)
81
84
  tokens += estimateStringTokens(block.name);
82
85
  if (block.source?.data) {
83
- tokens += estimateStringTokens(block.source.media_type);
84
- tokens += estimateStringTokens(block.source.data);
86
+ tokens += IMAGE_TOKEN_ESTIMATE;
85
87
  }
86
88
  const maybeOpenAI = block;
87
89
  if (typeof maybeOpenAI.image_url?.url === "string") {
88
- tokens += estimateStringTokens(maybeOpenAI.image_url.url);
90
+ tokens += IMAGE_TOKEN_ESTIMATE;
89
91
  }
90
92
  return tokens;
91
93
  }