@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
@@ -10,11 +10,15 @@ export class Transcript {
10
10
  events = [];
11
11
  filePath;
12
12
  currentTurn = 0;
13
+ writer;
14
+ dirty = false;
15
+ lastFlushFailure;
13
16
  getFilePath() {
14
17
  return this.filePath;
15
18
  }
16
- constructor(filePath) {
19
+ constructor(filePath, writer = appendFileSync) {
17
20
  this.filePath = filePath;
21
+ this.writer = writer;
18
22
  mkdirSync(dirname(filePath), { recursive: true });
19
23
  if (!existsSync(filePath)) {
20
24
  writeFileSync(filePath, "", "utf-8");
@@ -179,18 +183,65 @@ export class Transcript {
179
183
  get eventCount() {
180
184
  return this.events.length;
181
185
  }
186
+ /** True once any event failed both persistence attempts. Sticky by design. */
187
+ flushFailed() {
188
+ return this.dirty;
189
+ }
190
+ /** Structured details for the most recent unrecoverable flush failure. */
191
+ getFlushFailure() {
192
+ return this.lastFlushFailure ? { ...this.lastFlushFailure } : undefined;
193
+ }
182
194
  findMessageByClientId(clientMessageId) {
183
195
  return this.events.find((event) => event.type === "message" &&
184
196
  event.data.clientMessageId === clientMessageId);
185
197
  }
186
198
  flush(event) {
199
+ const line = JSON.stringify(event) + "\n";
187
200
  try {
188
- appendFileSync(this.filePath, JSON.stringify(event) + "\n", "utf-8");
201
+ this.writer(this.filePath, line, "utf-8");
202
+ return true;
189
203
  }
190
- catch {
191
- // Silently fail on flush errors — events are still in memory
204
+ catch (firstError) {
205
+ try {
206
+ this.writer(this.filePath, line, "utf-8");
207
+ logger.warn("transcript.flush_retry_recovered", {
208
+ filePath: this.filePath,
209
+ errno: this.errorErrno(firstError),
210
+ message: this.errorMessage(firstError),
211
+ });
212
+ return true;
213
+ }
214
+ catch (retryError) {
215
+ const err = retryError;
216
+ const failure = {
217
+ errno: this.errorErrno(retryError),
218
+ ...(typeof err?.code === "string" ? { code: err.code } : {}),
219
+ message: this.errorMessage(retryError),
220
+ timestamp: Date.now(),
221
+ attempts: 2,
222
+ recoverable: false,
223
+ filePath: this.filePath,
224
+ };
225
+ // Sticky: a later successful append cannot restore an earlier missing
226
+ // JSONL event, so the transcript remains degraded for this instance.
227
+ this.dirty = true;
228
+ this.lastFlushFailure = failure;
229
+ logger.error("transcript.flush_failed", { ...failure });
230
+ return false;
231
+ }
192
232
  }
193
233
  }
234
+ errorErrno(error) {
235
+ const err = error;
236
+ if (typeof err?.errno === "number" || typeof err?.errno === "string")
237
+ return err.errno;
238
+ if (typeof err?.code === "string")
239
+ return err.code;
240
+ return "UNKNOWN";
241
+ }
242
+ errorMessage(error) {
243
+ return error instanceof Error ? error.message : String(error);
244
+ }
194
245
  /**
195
246
  * Repair tool_result pairing issues:
196
247
  * - Orphaned tool_results (no matching tool_use) get removed
@@ -227,6 +278,70 @@ export class Transcript {
227
278
  return true;
228
279
  });
229
280
  }
281
+ static readEvents(filePath) {
282
+ let raw;
283
+ try {
284
+ raw = readFileSync(filePath, "utf-8");
285
+ }
286
+ catch {
287
+ return { events: [], malformedLineCount: 0 };
288
+ }
289
+ const lines = raw.split("\n").filter((line) => line.trim().length > 0);
290
+ const events = [];
291
+ let malformedLineCount = 0;
292
+ for (const line of lines) {
293
+ try {
294
+ events.push(JSON.parse(line));
295
+ }
296
+ catch {
297
+ malformedLineCount++;
298
+ }
299
+ }
300
+ return { events, malformedLineCount };
301
+ }
302
+ static eventsToMessages(events) {
303
+ const messages = [];
304
+ for (const event of events) {
305
+ switch (event.type) {
306
+ case "message": {
307
+ const { role, content } = event.data;
308
+ messages.push({ role, content });
309
+ break;
310
+ }
311
+ case "tool_use":
312
+ break;
313
+ case "tool_result": {
314
+ const { toolCallId, result, error, contentBlocks } = event.data;
315
+ const block = {
316
+ type: "tool_result",
317
+ tool_use_id: toolCallId,
318
+ content: error
319
+ ? `Error: ${error}`
320
+ : Array.isArray(contentBlocks) && contentBlocks.length > 0
321
+ ? contentBlocks
322
+ : (result ?? "(no output)"),
323
+ };
324
+ const last = messages[messages.length - 1];
325
+ if (last?.role === "user" && Array.isArray(last.content)) {
326
+ last.content.push(block);
327
+ }
328
+ else {
329
+ messages.push({ role: "user", content: [block] });
330
+ }
331
+ break;
332
+ }
333
+ case "summary": {
334
+ const { summary } = event.data;
335
+ messages.push({
336
+ role: "user",
337
+ content: `<system-reminder>Previous conversation was summarized:\n${summary}</system-reminder>`,
338
+ });
339
+ break;
340
+ }
341
+ }
342
+ }
343
+ return messages;
344
+ }
230
345
  static loadFromFile(filePath) {
231
346
  const transcript = new Transcript(filePath);
232
347
  if (!existsSync(filePath))
@@ -6,8 +6,9 @@ import type { BackgroundAgentCompletedEvent, StreamEvent } from "../../types.js"
6
6
  * background sub-agent that finishes (completed | failed) enqueues an item
7
7
  * here; the UI layer subscribes and, when the main agent is idle, drains
8
8
  * the queue and submits the formatted XML as a new user turn so the LLM
9
- * sees the result. Cancellation does NOT enqueue (user explicitly stopped
10
- * the agent; no follow-up needed).
9
+ * sees the result. Sub-agent cancellation does NOT enqueue (user explicitly
10
+ * stopped the agent; no follow-up needed). DriveAgent cancellation does enqueue
11
+ * so a detached external CLI job never leaves the waiting session hanging.
11
12
  *
12
13
  * The result text lives only in this queue + the eventual user message —
13
14
  * not in `asyncAgentRegistry`. Registry stays metadata-only.
@@ -29,7 +30,7 @@ export type NotificationItem = {
29
30
  agentId: string;
30
31
  name?: string;
31
32
  description: string;
32
- status: "completed" | "failed";
33
+ status: "completed" | "failed" | "cancelled";
33
34
  /** What kind of background work this was (lets UIs localize the toast). */
34
35
  workKind?: "agent" | "shell" | "video" | "cc";
35
36
  /** For workKind === "shell": the command that ran. */
@@ -41,8 +42,14 @@ export type NotificationItem = {
41
42
  * if this notification is lost, and the user sees a real session id (not just
42
43
  * an opaque background jobId). */
43
44
  ccSessionId?: string;
44
- /** Error message (failed only). */
45
+ /** Error message (failed/cancelled only). */
45
46
  error?: string;
47
+ /** Files attributed to an external DriveAgent transcript. */
48
+ changedFiles?: string[];
49
+ /** DriveAgent cwd used to canonicalize path aliases in clients. */
50
+ cwd?: string;
51
+ /** Client id of the real user turn that launched this background work. */
52
+ originClientMessageId?: string;
46
53
  enqueuedAt: number;
47
54
  };
48
55
  type Listener = () => void;
@@ -139,6 +139,15 @@ export function notificationItemToStreamEvent(item) {
139
139
  event.finalText = item.finalText;
140
140
  if (item.error !== undefined)
141
141
  event.error = item.error;
142
+ if (item.ccSessionId !== undefined)
143
+ event.ccSessionId = item.ccSessionId;
144
+ if (item.changedFiles !== undefined)
145
+ event.changedFiles = item.changedFiles;
146
+ if (item.cwd !== undefined)
147
+ event.cwd = item.cwd;
148
+ if (item.originClientMessageId !== undefined) {
149
+ event.originClientMessageId = item.originClientMessageId;
150
+ }
142
151
  return event;
143
152
  }
144
153
  /**
@@ -147,10 +156,7 @@ export function notificationItemToStreamEvent(item) {
147
156
  * values get the quote escape too; element bodies don't need it.
148
157
  */
149
158
  function escapeXmlText(s) {
150
- return s
151
- .replace(/&/g, "&amp;")
152
- .replace(/</g, "&lt;")
153
- .replace(/>/g, "&gt;");
159
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
154
160
  }
155
161
  function escapeXmlAttr(s) {
156
162
  return escapeXmlText(s).replace(/"/g, "&quot;");
@@ -173,7 +179,9 @@ export function buildNotificationMessage(items) {
173
179
  const desc = ` <description>${escapeXmlText(item.description)}</description>`;
174
180
  const body = item.status === "completed"
175
181
  ? ` <result>\n${escapeXmlText(item.finalText ?? "")}\n </result>`
176
- : ` <error>${escapeXmlText(item.error ?? "")}</error>`;
182
+ : item.status === "cancelled"
183
+ ? ` <cancelled>${escapeXmlText(item.error ?? "cancelled")}</cancelled>`
184
+ : ` <error>${escapeXmlText(item.error ?? "")}</error>`;
177
185
  return [opening, desc, body, " </agent>"].join("\n");
178
186
  })
179
187
  .join("\n");
@@ -194,9 +202,13 @@ export function buildNotificationMessage(items) {
194
202
  export function buildNotificationSummary(items) {
195
203
  const header = "📨 background agents completed";
196
204
  const rows = items.map((item) => {
197
- const badge = item.status === "completed" ? "✓" : "✗";
205
+ const badge = item.status === "completed" ? "✓" : item.status === "cancelled" ? "cancelled" : "✗";
198
206
  const namePart = item.name ? `${item.name} · ` : "";
199
- const statusPart = item.status === "failed" ? ` · failed: ${item.error ?? "unknown"}` : "";
207
+ const statusPart = item.status === "failed"
208
+ ? ` · failed: ${item.error ?? "unknown"}`
209
+ : item.status === "cancelled"
210
+ ? ` · cancelled`
211
+ : "";
200
212
  return ` └─ ${namePart}${item.description} · ${badge}${statusPart}`;
201
213
  });
202
214
  return [header, ...rows].join("\n");
@@ -269,7 +269,9 @@ streamOverride) {
269
269
  // that no longer exists. The child Engine is a fresh instance; plan-mode
270
270
  // isolation between parent and child is enforced via separate Engine
271
271
  // instances (finalized in T6).
272
- const { text } = await spawner.spawn({ ...opts, streamOverride });
272
+ const { text, usage } = await spawner.spawn({ ...opts, streamOverride });
273
+ if (usage)
274
+ opts.recordBilledUsage?.(usage);
273
275
  const finalText = text || `Agent completed but produced no text output.`;
274
276
  safeEmit(startEndSink, { type: "agent_end", agentId, name, description, text: finalText, agentType });
275
277
  emitSubAgentHook(opts.hooks, "subagent_finish", { agentId, description, text: finalText });
@@ -372,6 +374,7 @@ export async function agentTool(args, ctx) {
372
374
  appendSystemPrompt: overrides.appendSystemPrompt,
373
375
  readOnlySession: overrides.resolvedType === "researcher" || overrides.resolvedType === "explorer",
374
376
  hooks: ctx?.hooks,
377
+ recordBilledUsage: ctx?.recordBilledUsage,
375
378
  signal: controller.signal,
376
379
  }, parentStream, // uiStream: agent_start/end → main feed
377
380
  transcriptSink)
@@ -581,6 +584,7 @@ async function runSyncSubAgent(args) {
581
584
  readOnlySession: overrides.resolvedType === "researcher" || overrides.resolvedType === "explorer",
582
585
  resumeSessionId,
583
586
  hooks: ctx?.hooks,
587
+ recordBilledUsage: ctx?.recordBilledUsage,
584
588
  signal: syncController.signal,
585
589
  });
586
590
  // Sentinel that the timer resolves with, so we can tell "agent finished" from
@@ -390,6 +390,7 @@ export async function arenaTool(args, ctx) {
390
390
  signal,
391
391
  });
392
392
  const result = await arena.run(topic);
393
+ ctx.recordBilledUsage?.(result.usage);
393
394
  // Use session renderer (markdown, no ANSI) for tool results.
394
395
  // Terminal renderer (chalk) is only for direct stdout printing.
395
396
  const formattedResult = formatArenaResultForSession(result);
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Registry of NON-agent background jobs (GenerateVideo poll loops, DriveAgent
3
3
  * external CLI runs). Mirrors the role `asyncAgentRegistry` plays for background
4
- * sub-agents, but a job is not an agent — it has no transcript, no abort(), and
5
- * must NOT show up in AgentStatus.
4
+ * sub-agents, but a job is not an agent — it has no transcript and must NOT
5
+ * show up in AgentStatus.
6
6
  *
7
7
  * The engine's wait-for-background loop parks the turn until a session has no
8
8
  * more RUNNING jobs, so the goal-stop-hook doesn't force the model to busy-loop
@@ -21,11 +21,15 @@
21
21
  * loop observe the same instance.
22
22
  */
23
23
  type Listener = () => void;
24
- export type BackgroundJobStatus = "running" | "completed" | "failed";
24
+ export type BackgroundJobStatus = "running" | "cancelling" | "completed" | "failed" | "cancelled";
25
+ export type BackgroundJobKind = "drive-agent" | "video" | "job";
26
+ export type ExternalCliKind = "claude" | "codex";
25
27
  /** A background job, running or finished. */
26
28
  export interface BackgroundJobEntry {
27
29
  jobId: string;
28
30
  sessionId: string;
31
+ /** Machine-readable kind for tools that need to target a subset of jobs. */
32
+ kind?: BackgroundJobKind;
29
33
  /** Human description (e.g. "Generating video: <prompt>"). Shown to the goal
30
34
  * judge (running) and in the background panel (all). */
31
35
  description: string;
@@ -42,16 +46,31 @@ export interface BackgroundJobEntry {
42
46
  changedFiles?: string[];
43
47
  /** Working directory for jobs that operate on the filesystem, e.g. DriveAgent. */
44
48
  cwd?: string;
49
+ /** DriveAgent prompt summary, separate from the UI-oriented description. */
50
+ promptSummary?: string;
51
+ /** External CLI kind for DriveAgent jobs. */
52
+ cli?: ExternalCliKind;
53
+ /** Client message that launched this external job. */
54
+ originClientMessageId?: string;
55
+ /** Optional cancellation hook for jobs backed by a live process. It may
56
+ * return a richer terminal outcome when cancellation races with a result
57
+ * that must still be retained (for example, a remotely rendered video). */
58
+ abort?: () => void | BackgroundJobOutcome | Promise<void | BackgroundJobOutcome>;
45
59
  }
46
60
  /** Outcome passed to finish() to record how a job ended. */
47
61
  export interface BackgroundJobOutcome {
48
- status?: "completed" | "failed";
62
+ status?: "completed" | "failed" | "cancelled";
49
63
  finalText?: string;
50
64
  ccSessionId?: string;
51
65
  changedFiles?: string[];
52
66
  }
53
67
  export interface BackgroundJobStartOptions {
68
+ kind?: BackgroundJobKind;
54
69
  cwd?: string;
70
+ promptSummary?: string;
71
+ cli?: ExternalCliKind;
72
+ originClientMessageId?: string;
73
+ abort?: () => void | BackgroundJobOutcome | Promise<void | BackgroundJobOutcome>;
55
74
  }
56
75
  declare class BackgroundJobRegistry {
57
76
  private jobs;
@@ -61,7 +80,11 @@ declare class BackgroundJobRegistry {
61
80
  /** Mark a job terminal (retained, not deleted). Unknown id is a no-op (no
62
81
  * notify) so a double-finish or a finish after reset stays quiet. */
63
82
  finish(jobId: string, outcome?: BackgroundJobOutcome): void;
64
- /** True while any RUNNING job spawned by `sessionId` remains. */
83
+ get(jobId: string): BackgroundJobEntry | undefined;
84
+ /** Persist artifacts discovered while a running job is winding down. */
85
+ recordArtifacts(jobId: string, artifacts: Pick<BackgroundJobOutcome, "ccSessionId" | "changedFiles">): void;
86
+ cancel(jobId: string, outcome?: Omit<BackgroundJobOutcome, "status">): Promise<boolean>;
87
+ /** True while any running/cancelling job spawned by `sessionId` remains. */
65
88
  hasRunningForSession(sessionId: string): boolean;
66
89
  /** Running jobs spawned by `sessionId`. Feeds the goal judge's task list. */
67
90
  listRunningForSession(sessionId: string): BackgroundJobEntry[];
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Registry of NON-agent background jobs (GenerateVideo poll loops, DriveAgent
3
3
  * external CLI runs). Mirrors the role `asyncAgentRegistry` plays for background
4
- * sub-agents, but a job is not an agent — it has no transcript, no abort(), and
5
- * must NOT show up in AgentStatus.
4
+ * sub-agents, but a job is not an agent — it has no transcript and must NOT
5
+ * show up in AgentStatus.
6
6
  *
7
7
  * The engine's wait-for-background loop parks the turn until a session has no
8
8
  * more RUNNING jobs, so the goal-stop-hook doesn't force the model to busy-loop
@@ -27,6 +27,31 @@ function isValidSessionId(sid) {
27
27
  /** Generous per-session cap on retained terminal jobs — a leak backstop, not a
28
28
  * UX limit; a human never spawns this many background jobs in one session. */
29
29
  const MAX_TERMINAL_JOBS_PER_SESSION = 50;
30
+ const CANCEL_WAIT_TIMEOUT_MS = 5_000;
31
+ function isActiveStatus(status) {
32
+ return status === "running" || status === "cancelling";
33
+ }
34
+ async function waitForAbort(abort) {
35
+ let timer;
36
+ try {
37
+ const result = await Promise.race([
38
+ Promise.resolve().then(abort),
39
+ new Promise((resolve) => {
40
+ timer = setTimeout(() => resolve(undefined), CANCEL_WAIT_TIMEOUT_MS);
41
+ }),
42
+ ]);
43
+ return result && typeof result === "object" ? result : undefined;
44
+ }
45
+ catch {
46
+ // Cancellation is best-effort; the terminal state still closes after the
47
+ // abort hook settles or the hard deadline expires.
48
+ return undefined;
49
+ }
50
+ finally {
51
+ if (timer)
52
+ clearTimeout(timer);
53
+ }
54
+ }
30
55
  class BackgroundJobRegistry {
31
56
  jobs = new Map(); // jobId -> entry (insertion-ordered)
32
57
  listeners = new Set();
@@ -37,10 +62,17 @@ class BackgroundJobRegistry {
37
62
  this.jobs.set(jobId, {
38
63
  jobId,
39
64
  sessionId,
65
+ ...(options?.kind ? { kind: options.kind } : {}),
40
66
  description,
41
67
  status: "running",
42
68
  startedAt: Date.now(),
43
69
  ...(options?.cwd ? { cwd: normalizeCwdPath(options.cwd) } : {}),
70
+ ...(options?.promptSummary ? { promptSummary: options.promptSummary } : {}),
71
+ ...(options?.cli ? { cli: options.cli } : {}),
72
+ ...(options?.originClientMessageId
73
+ ? { originClientMessageId: options.originClientMessageId }
74
+ : {}),
75
+ ...(options?.abort ? { abort: options.abort } : {}),
44
76
  });
45
77
  this.notify();
46
78
  }
@@ -50,6 +82,8 @@ class BackgroundJobRegistry {
50
82
  const entry = this.jobs.get(jobId);
51
83
  if (!entry)
52
84
  return;
85
+ if (entry.status !== "running")
86
+ return;
53
87
  entry.status = outcome?.status ?? "completed";
54
88
  entry.finishedAt = Date.now();
55
89
  if (outcome?.finalText !== undefined)
@@ -61,22 +95,67 @@ class BackgroundJobRegistry {
61
95
  this.evictTerminalOverCap(entry.sessionId);
62
96
  this.notify();
63
97
  }
64
- /** True while any RUNNING job spawned by `sessionId` remains. */
98
+ get(jobId) {
99
+ return this.jobs.get(jobId);
100
+ }
101
+ /** Persist artifacts discovered while a running job is winding down. */
102
+ recordArtifacts(jobId, artifacts) {
103
+ const entry = this.jobs.get(jobId);
104
+ if (!entry || !isActiveStatus(entry.status))
105
+ return;
106
+ if (artifacts.ccSessionId !== undefined)
107
+ entry.ccSessionId = artifacts.ccSessionId;
108
+ if (artifacts.changedFiles !== undefined)
109
+ entry.changedFiles = artifacts.changedFiles;
110
+ }
111
+ async cancel(jobId, outcome) {
112
+ const entry = this.jobs.get(jobId);
113
+ if (!entry)
114
+ return false;
115
+ if (entry.status !== "running")
116
+ return false;
117
+ // Publish a non-terminal guard before invoking external code. A completion
118
+ // callback may synchronously re-enter finish(); it must not publish
119
+ // completed/failed, while cwd/session conflict checks must still see the
120
+ // process as active until the abort hook confirms exit.
121
+ entry.status = "cancelling";
122
+ this.notify();
123
+ const abortOutcome = entry.abort ? await waitForAbort(entry.abort) : undefined;
124
+ // Session teardown/reset may have removed the entry while termination was
125
+ // in flight. Do not resurrect or notify for a closed session.
126
+ if (this.jobs.get(jobId) !== entry || entry.status !== "cancelling")
127
+ return false;
128
+ entry.status = abortOutcome?.status ?? "cancelled";
129
+ entry.finishedAt = Date.now();
130
+ const finalText = abortOutcome?.finalText ?? outcome?.finalText;
131
+ const ccSessionId = abortOutcome?.ccSessionId ?? outcome?.ccSessionId;
132
+ const changedFiles = abortOutcome?.changedFiles ?? outcome?.changedFiles;
133
+ if (finalText !== undefined)
134
+ entry.finalText = finalText;
135
+ if (ccSessionId !== undefined)
136
+ entry.ccSessionId = ccSessionId;
137
+ if (changedFiles !== undefined)
138
+ entry.changedFiles = changedFiles;
139
+ this.evictTerminalOverCap(entry.sessionId);
140
+ this.notify();
141
+ return true;
142
+ }
143
+ /** True while any running/cancelling job spawned by `sessionId` remains. */
65
144
  hasRunningForSession(sessionId) {
66
145
  for (const e of this.jobs.values()) {
67
- if (e.sessionId === sessionId && e.status === "running")
146
+ if (e.sessionId === sessionId && isActiveStatus(e.status))
68
147
  return true;
69
148
  }
70
149
  return false;
71
150
  }
72
151
  /** Running jobs spawned by `sessionId`. Feeds the goal judge's task list. */
73
152
  listRunningForSession(sessionId) {
74
- return [...this.jobs.values()].filter((e) => e.sessionId === sessionId && e.status === "running");
153
+ return [...this.jobs.values()].filter((e) => e.sessionId === sessionId && isActiveStatus(e.status));
75
154
  }
76
155
  /** Running jobs, across all sessions, that are operating in the same cwd. */
77
156
  listRunningByCwd(cwd) {
78
157
  const normalized = normalizeCwdPath(cwd);
79
- return [...this.jobs.values()].filter((e) => e.status === "running" && e.cwd === normalized);
158
+ return [...this.jobs.values()].filter((e) => isActiveStatus(e.status) && e.cwd === normalized);
80
159
  }
81
160
  /** All jobs (running + retained terminal) for `sessionId`. Feeds the panel. */
82
161
  listForSession(sessionId) {
@@ -89,12 +168,26 @@ class BackgroundJobRegistry {
89
168
  /** Drop every job of a session — called when the session is deleted/closed. */
90
169
  dropForSession(sessionId) {
91
170
  let removed = false;
171
+ const aborts = [];
92
172
  for (const [id, e] of this.jobs) {
93
173
  if (e.sessionId === sessionId) {
174
+ // Delete first so a synchronous/queued completion caused by abort sees
175
+ // no live registry entry and cannot publish a late result for a closed
176
+ // session. Terminal jobs have nothing left to stop.
94
177
  this.jobs.delete(id);
178
+ if (isActiveStatus(e.status) && e.abort)
179
+ aborts.push(e.abort);
95
180
  removed = true;
96
181
  }
97
182
  }
183
+ for (const abort of aborts) {
184
+ try {
185
+ void Promise.resolve(abort()).catch(() => undefined);
186
+ }
187
+ catch {
188
+ // teardown is best-effort across jobs; continue reaping the rest
189
+ }
190
+ }
98
191
  if (removed)
99
192
  this.notify();
100
193
  }
@@ -106,12 +199,21 @@ class BackgroundJobRegistry {
106
199
  };
107
200
  /** Test helper: drop all tracked jobs. */
108
201
  reset() {
202
+ const aborts = [...this.jobs.values()].flatMap((entry) => isActiveStatus(entry.status) && entry.abort ? [entry.abort] : []);
109
203
  this.jobs.clear();
204
+ for (const abort of aborts) {
205
+ try {
206
+ void Promise.resolve(abort()).catch(() => undefined);
207
+ }
208
+ catch {
209
+ // ignore
210
+ }
211
+ }
110
212
  }
111
213
  /** Backstop: keep at most MAX_TERMINAL_JOBS_PER_SESSION terminal jobs per
112
214
  * session, evicting the oldest (insertion order). Running jobs never evicted. */
113
215
  evictTerminalOverCap(sessionId) {
114
- const terminal = [...this.jobs.values()].filter((e) => e.sessionId === sessionId && e.status !== "running");
216
+ const terminal = [...this.jobs.values()].filter((e) => e.sessionId === sessionId && !isActiveStatus(e.status));
115
217
  const over = terminal.length - MAX_TERMINAL_JOBS_PER_SESSION;
116
218
  if (over <= 0)
117
219
  return;
@@ -1,5 +1,5 @@
1
1
  import { type AsyncAgentStatus } from "./agent-registry.js";
2
- import { type BackgroundJobStatus } from "./background-jobs.js";
2
+ import { type BackgroundJobKind, type BackgroundJobStatus, type ExternalCliKind } from "./background-jobs.js";
3
3
  import { type BgShell } from "../../runtime/background-shell.js";
4
4
  export type BackgroundWorkKind = "subagent" | "job" | "shell";
5
5
  export interface BackgroundWorkItem {
@@ -51,6 +51,11 @@ export type BackgroundWorkEntry = WithSource<{
51
51
  finalText?: string;
52
52
  /** Files an external agent (DriveAgent) changed, parsed from its transcript. */
53
53
  changedFiles?: string[];
54
+ /** Machine-readable job kind and external-session linkage for DriveAgent. */
55
+ jobKind?: BackgroundJobKind;
56
+ externalSessionId?: string;
57
+ cli?: ExternalCliKind;
58
+ cwd?: string;
54
59
  }>;
55
60
  /**
56
61
  * Every background-work item spawned by `sessionId`, with per-kind detail, for
@@ -14,7 +14,7 @@
14
14
  import { readFileSync, statSync } from "node:fs";
15
15
  import { join } from "node:path";
16
16
  import { asyncAgentRegistry } from "./agent-registry.js";
17
- import { backgroundJobRegistry } from "./background-jobs.js";
17
+ import { backgroundJobRegistry, } from "./background-jobs.js";
18
18
  import { backgroundShellManager } from "../../runtime/background-shell.js";
19
19
  import { codeShellHome } from "../../session/session-manager.js";
20
20
  /** List every still-running background work item spawned by `sessionId`. */
@@ -125,6 +125,10 @@ export function listBackgroundWorkForUI(sessionId, opts = {}) {
125
125
  ...(j.finishedAt != null ? { finishedAt: j.finishedAt } : {}),
126
126
  ...(j.finalText != null ? { finalText: j.finalText } : {}),
127
127
  ...(j.changedFiles && j.changedFiles.length ? { changedFiles: j.changedFiles } : {}),
128
+ ...(j.kind ? { jobKind: j.kind } : {}),
129
+ ...(j.ccSessionId ? { externalSessionId: j.ccSessionId } : {}),
130
+ ...(j.cli === "claude" || j.cli === "codex" ? { cli: j.cli } : {}),
131
+ ...(j.cwd ? { cwd: j.cwd } : {}),
128
132
  sourceSession: sourceSession(sessionId, j.sessionId),
129
133
  });
130
134
  }
@@ -16,9 +16,7 @@
16
16
  * Killed by signal, the truncation message, sandbox denial hints).
17
17
  */
18
18
  import type { ToolContext } from "../context.js";
19
- import type { ToolDefinition, ToolResult } from "../../types.js";
19
+ import type { ToolDefinition } from "../../types.js";
20
+ import type { ToolExecutionResult } from "./index.js";
20
21
  export declare const bashToolDef: ToolDefinition;
21
- export declare function bashTool(args: Record<string, unknown>, ctx?: ToolContext): Promise<string | {
22
- result: string;
23
- sandbox: ToolResult["sandbox"];
24
- }>;
22
+ export declare function bashTool(args: Record<string, unknown>, ctx?: ToolContext): Promise<string | ToolExecutionResult>;
@@ -82,7 +82,8 @@ export async function bashTool(args, ctx) {
82
82
  const sandbox = sandboxMark(backend);
83
83
  // Tag every textual return below with the sandbox mark so the UI badge
84
84
  // shows up whether the command succeeded, failed, timed out, or aborted.
85
- const mark = (result) => ({ result, sandbox });
85
+ const mark = (result) => ({ ok: true, result, sandbox });
86
+ const fail = (error, details = {}) => ({ ok: false, error, sandbox, ...details });
86
87
  // Project localEnvironment.env (ctx.shellEnv) layers on top of either the
87
88
  // full passthrough (off) or the hardened allowlist (sandboxed) — see
88
89
  // mergeShellEnv. Background mode merges the same way inside the manager.
@@ -93,7 +94,8 @@ export async function bashTool(args, ctx) {
93
94
  // blocked, and Engine.run's wait-for-background loop never waits on it
94
95
  // (§难点5: that loop only looks at asyncAgentRegistry).
95
96
  if (args.run_in_background === true) {
96
- return mark(runInBackground(command, ctx));
97
+ const backgroundResult = runInBackground(command, ctx);
98
+ return backgroundResult.startsWith("Error:") ? fail(backgroundResult) : mark(backgroundResult);
97
99
  }
98
100
  // A6: lifecycle (spawn, kill cascade, IO drain, byte cap, abort/timeout)
99
101
  // is centralized in safeSpawnShell. We still handle Bash's user-facing
@@ -112,13 +114,13 @@ export async function bashTool(args, ctx) {
112
114
  signal: ctx?.signal,
113
115
  });
114
116
  if (result.aborted) {
115
- return mark(wasAbortedBeforeStart ? "Bash aborted before starting." : "Bash aborted by signal.");
117
+ return fail(wasAbortedBeforeStart ? "Bash aborted before starting." : "Bash aborted by signal.");
116
118
  }
117
119
  if (result.timedOut) {
118
- return mark(`Command timed out after ${timeout}ms`);
120
+ return fail(`Command timed out after ${timeout}ms`);
119
121
  }
120
122
  if (result.spawnFailed) {
121
- return mark(`Failed to spawn command: ${result.error ?? "unknown error"}`);
123
+ return fail(`Failed to spawn command: ${result.error ?? "unknown error"}`);
122
124
  }
123
125
  let body = "";
124
126
  if (result.stdout)
@@ -150,6 +152,9 @@ export async function bashTool(args, ctx) {
150
152
  const hint = backend.hintForBlockedOutput?.(result.stderr);
151
153
  if (hint)
152
154
  output += hint;
155
+ if ((code !== 0 && code !== null) || (code === null && sig)) {
156
+ return fail(output, { exitCode: code, signal: sig, stderr: result.stderr });
157
+ }
153
158
  return mark(output);
154
159
  }
155
160
  /**
@@ -22,9 +22,9 @@
22
22
  */
23
23
  import type { ToolDefinition } from "../../types.js";
24
24
  import type { ToolContext } from "../context.js";
25
- import type { BuiltinToolResult } from "./index.js";
25
+ import type { BuiltinToolReturn } from "./index.js";
26
26
  export declare const browserObserveToolDef: ToolDefinition;
27
- export declare function browserObserveTool(args: Record<string, unknown>, ctx?: ToolContext): Promise<BuiltinToolResult>;
27
+ export declare function browserObserveTool(args: Record<string, unknown>, ctx?: ToolContext): Promise<BuiltinToolReturn>;
28
28
  export declare const browserActToolDef: ToolDefinition;
29
29
  export declare function browserActTool(args: Record<string, unknown>, ctx?: ToolContext): Promise<string>;
30
30
  export declare const browserNavigateToolDef: ToolDefinition;