@gkorepanov/ccodex 0.5.0-rc.2 → 0.5.0-rc.21

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 (93) hide show
  1. package/README.md +156 -157
  2. package/claude/chat-formatting.md +25 -0
  3. package/config/compatibility.json +1 -1
  4. package/dist/claude/commandActions.d.ts +2 -0
  5. package/dist/claude/commandActions.js +43 -24
  6. package/dist/claude/commandActions.js.map +1 -1
  7. package/dist/claude/delegation.d.ts +10 -0
  8. package/dist/claude/delegation.js +41 -0
  9. package/dist/claude/delegation.js.map +1 -0
  10. package/dist/claude/native/catalog.d.ts +14 -0
  11. package/dist/claude/native/catalog.js +52 -17
  12. package/dist/claude/native/catalog.js.map +1 -1
  13. package/dist/claude/native/history.d.ts +8 -1
  14. package/dist/claude/native/history.js +43 -19
  15. package/dist/claude/native/history.js.map +1 -1
  16. package/dist/claude/native/pages.d.ts +54 -0
  17. package/dist/claude/native/pages.js +271 -0
  18. package/dist/claude/native/pages.js.map +1 -0
  19. package/dist/claude/native/projector.d.ts +18 -2
  20. package/dist/claude/native/projector.js +104 -46
  21. package/dist/claude/native/projector.js.map +1 -1
  22. package/dist/claude/native/records.d.ts +2 -0
  23. package/dist/claude/native/records.js +35 -13
  24. package/dist/claude/native/records.js.map +1 -1
  25. package/dist/claude/native/search.d.ts +7 -0
  26. package/dist/claude/native/search.js +79 -0
  27. package/dist/claude/native/search.js.map +1 -0
  28. package/dist/claude/native/summary.d.ts +6 -1
  29. package/dist/claude/native/summary.js +24 -6
  30. package/dist/claude/native/summary.js.map +1 -1
  31. package/dist/claude/peers.d.ts +4 -0
  32. package/dist/claude/peers.js +19 -5
  33. package/dist/claude/peers.js.map +1 -1
  34. package/dist/claude/processes.d.ts +2 -0
  35. package/dist/claude/processes.js +15 -0
  36. package/dist/claude/processes.js.map +1 -1
  37. package/dist/claude/sdk.d.ts +8 -2
  38. package/dist/claude/sdk.js +23 -6
  39. package/dist/claude/sdk.js.map +1 -1
  40. package/dist/claude/session.d.ts +34 -7
  41. package/dist/claude/session.js +184 -72
  42. package/dist/claude/session.js.map +1 -1
  43. package/dist/claude/threads.d.ts +61 -3
  44. package/dist/claude/threads.js +408 -100
  45. package/dist/claude/threads.js.map +1 -1
  46. package/dist/claude/toolMapper.d.ts +23 -3
  47. package/dist/claude/toolMapper.js +50 -1
  48. package/dist/claude/toolMapper.js.map +1 -1
  49. package/dist/cli/args.js +2 -1
  50. package/dist/cli/args.js.map +1 -1
  51. package/dist/config.d.ts +1 -0
  52. package/dist/config.js +7 -3
  53. package/dist/config.js.map +1 -1
  54. package/dist/daemon/daemon.d.ts +3 -0
  55. package/dist/daemon/daemon.js +6 -3
  56. package/dist/daemon/daemon.js.map +1 -1
  57. package/dist/daemon/supervisor.d.ts +8 -0
  58. package/dist/daemon/supervisor.js +15 -7
  59. package/dist/daemon/supervisor.js.map +1 -1
  60. package/dist/desktop/stdioFrontend.js +1 -1
  61. package/dist/desktop/stdioFrontend.js.map +1 -1
  62. package/dist/gateway/catalog.js +35 -20
  63. package/dist/gateway/catalog.js.map +1 -1
  64. package/dist/gateway/connection.js +2 -2
  65. package/dist/gateway/connection.js.map +1 -1
  66. package/dist/gateway/lineage.d.ts +29 -1
  67. package/dist/gateway/lineage.js +260 -38
  68. package/dist/gateway/lineage.js.map +1 -1
  69. package/dist/gateway/remote.js +3 -8
  70. package/dist/gateway/remote.js.map +1 -1
  71. package/dist/gateway/server.d.ts +2 -1
  72. package/dist/gateway/server.js +26 -13
  73. package/dist/gateway/server.js.map +1 -1
  74. package/dist/gateway/status.d.ts +15 -5
  75. package/dist/gateway/status.js +142 -47
  76. package/dist/gateway/status.js.map +1 -1
  77. package/dist/gateway/stock.js +17 -2
  78. package/dist/gateway/stock.js.map +1 -1
  79. package/dist/management/commands.d.ts +2 -0
  80. package/dist/management/commands.js +66 -3
  81. package/dist/management/commands.js.map +1 -1
  82. package/dist/meta.d.ts +39 -11
  83. package/dist/meta.js +59 -28
  84. package/dist/meta.js.map +1 -1
  85. package/dist/protocol/codex.d.ts +1 -3
  86. package/dist/protocol/codex.js +1 -4
  87. package/dist/protocol/codex.js.map +1 -1
  88. package/dist/protocol/turnPagination.d.ts +35 -2
  89. package/dist/protocol/turnPagination.js +53 -28
  90. package/dist/protocol/turnPagination.js.map +1 -1
  91. package/examples/config.toml +2 -2
  92. package/package.json +5 -4
  93. package/scripts/migrate-0.4-to-0.5.mjs +31 -20
@@ -6,15 +6,15 @@ import { query, startup, } from "@anthropic-ai/claude-agent-sdk";
6
6
  import { invalidRequest } from "../protocol/codex.js";
7
7
  import { startedTurn } from "../protocol/turnPagination.js";
8
8
  import { CODEX_MCP_TOOLS, codexMcpItem, tailCodexRollout, } from "./codexRollout.js";
9
- import { claudeContent, normalizeUserInput, userMessage } from "./inputMapper.js";
9
+ import { claudeContent, inputText, normalizeUserInput, userMessage } from "./inputMapper.js";
10
10
  import { completedToolItem } from "./native/projector.js";
11
11
  import { ANSWER_CHARS, assistantBlockItemId, continuationTurnId } from "./native/ids.js";
12
12
  import { readTranscriptRecords } from "./native/records.js";
13
13
  import { userText } from "./native/summary.js";
14
- import { normalizeClaudeModelIdentifier } from "./modelSelection.js";
15
- import { peerKey, peerMessageItem, peerOrigin, sentMessageItem } from "./peers.js";
16
- import { baseOptions } from "./sdk.js";
17
- import { proposedChanges, startTool, updateToolInput } from "./toolMapper.js";
14
+ import { claudeEffort, DELEGATION_OFF, DELEGATION_ON, ULTRA } from "./delegation.js";
15
+ import { foreignOwner, peerKey, peerMessageItem, peerOrigin, sentMessageItem, subagentFiles } from "./peers.js";
16
+ import { baseOptions, claudeMode } from "./sdk.js";
17
+ import { endedBackground, killedCommand, proposedChanges, startTool, stoppedCommand, updateToolInput } from "./toolMapper.js";
18
18
  /** The complete records Claude wrote from byte `start` on (at most the last 256 KB), and the byte they end at. */
19
19
  function recordsFrom(path, start) {
20
20
  const fd = openSync(path, "r");
@@ -44,6 +44,7 @@ function lastPrompt(path) {
44
44
  && !(Array.isArray(record.message?.content) && record.message.content.some((block) => block.type === "tool_result")));
45
45
  }
46
46
  /** How long a process started ahead of a first prompt waits for it. */
47
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u;
47
48
  const WARM_MS = 10 * 60_000;
48
49
  class Inbox {
49
50
  items = [];
@@ -73,7 +74,6 @@ const EMPTY_USAGE = {
73
74
  };
74
75
  const YIELD_BUDGET_MS = 20;
75
76
  export const INJECTED_PREFIX = "[Injected model-visible history]";
76
- const CONTINUATION_GRACE_MS = 3_000;
77
77
  function breakdown(usage) {
78
78
  const input = Number(usage?.input_tokens ?? 0);
79
79
  const cached = Number(usage?.cache_read_input_tokens ?? 0);
@@ -99,6 +99,7 @@ function addUsage(left, right) {
99
99
  };
100
100
  }
101
101
  const FILE_TOOLS = new Set(["Edit", "Write", "NotebookEdit", "MultiEdit"]);
102
+ const PLAN_PROPOSED = "The plan is shown to the user, who answers in their next message (approving it ends plan mode). End your turn now, with no further text.";
102
103
  /**
103
104
  * One live Claude session: a single streaming `query()` and one sequential loop that turns SDK messages
104
105
  * into Codex notifications. Claude persists everything; this object only holds the running turn.
@@ -108,15 +109,21 @@ export class ClaudeSession {
108
109
  threadId;
109
110
  settings;
110
111
  turn;
112
+ /** A turn was started here: before that the chat is Desktop's new-chat draft, which stock lists nowhere. */
113
+ started = false;
111
114
  state = "idle";
112
115
  tasks = new Map();
116
+ /** Background commands running on past their turn (stock's background terminals), by task id. */
117
+ background = new Map();
118
+ /** Output files of commands Stop killed while Claude waited on them, by their call. */
119
+ killed = new Map();
120
+ /** TaskStop calls: shown on the command they stop, as stock shows a Ctrl-C (by tool use id). */
121
+ stops = new Map();
113
122
  /** Claude's sub-agents running (in the background they keep no turn open: each has a chat of its own). */
114
123
  agents = new Set();
115
124
  queued = [];
116
125
  totalUsage = EMPTY_USAGE;
117
126
  lastUsage = EMPTY_USAGE;
118
- contextWindow = null;
119
- costUsd = 0;
120
127
  liveModel = null;
121
128
  sdk;
122
129
  /** Last sign of life: a message from Claude, a prompt, a command. */
@@ -135,7 +142,6 @@ export class ClaudeSession {
135
142
  /** Injected context (`shouldQuery: false`) runs a silent query of its own: no turn is shown for it. */
136
143
  injections = new Map();
137
144
  turnWaiters = new Map();
138
- continuationTimer;
139
145
  runningTimer;
140
146
  exists;
141
147
  compactSummary;
@@ -144,8 +150,14 @@ export class ClaudeSession {
144
150
  plan = new Map();
145
151
  /** Messages from other agents shown (peerKey), whichever of transcript and result told them first. */
146
152
  peerMessages = new Set();
153
+ /** The goal last sent to Claude with `/goal` (null: cleared); undefined until this session sends one. */
154
+ goalObjective;
147
155
  /** The running turn's result came: Claude answering again means it took another prompt by itself. */
148
156
  afterResult = false;
157
+ /** Proactive delegation (the `ultra` effort) as last told to Claude (its transcript's, for a chat loaded again). */
158
+ delegating;
159
+ /** A task's end came with no turn running: the command Claude runs for it goes on after the last answer. */
160
+ notified = false;
149
161
  /** Claude's last block since a turn began (its item id; the text item while it streams text). */
150
162
  lastBlock;
151
163
  transcript;
@@ -157,21 +169,35 @@ export class ClaudeSession {
157
169
  this.settings = settings;
158
170
  this.exists = options.exists;
159
171
  this.resumeAt = options.resumeAt;
172
+ this.delegating = host.summary(threadId)?.delegating ?? false;
173
+ this.keepPlan();
174
+ }
175
+ /** Claude's transcript records only its plan mode and the model it plans on: from plan mode on, the chat's own settings
176
+ * wait in meta.json until a turn out of plan mode records them (turning plan mode off records nothing). */
177
+ keepPlan() {
178
+ const { meta } = this.host.gateway;
179
+ if (this.settings.plan || meta.plan(this.threadId)) {
180
+ meta.setPlan(this.threadId, { permissionMode: this.settings.permissionMode, model: this.settings.model, effort: this.settings.effort, plan: this.settings.plan });
181
+ }
160
182
  }
161
183
  resumeAt;
162
184
  get loaded() { return this.sdk !== undefined; }
163
185
  get busy() { return this.turn !== undefined; }
164
186
  /** Nothing to do for `idleMs` (closing its process loses nothing: the next turn resumes from disk). */
165
187
  quiet(now, idleMs) {
166
- return !this.turn && !this.queued.length && !this.injections.size && !this.crons && now - this.activeAt >= idleMs;
188
+ return !this.turn && !this.tasks.size && !this.queued.length && !this.injections.size && !this.crons && now - this.activeAt >= idleMs;
167
189
  }
168
- /** The turn has its answer and only waits for background tasks to end. */
190
+ /** No turn runs, only background tasks. */
169
191
  get waitingOnTasks() {
170
- return this.turn?.resultSeen === true && this.state === "idle" && this.tasks.size > 0;
192
+ return !this.turn && this.tasks.size > 0;
193
+ }
194
+ /** The item ids of the background commands running (history shows them ended unless they run here). */
195
+ get runningCommands() {
196
+ return new Set([...this.background.values()].map(({ item }) => item.id));
171
197
  }
172
198
  /** Stops the background tasks as Claude's own stop control does: Claude learns they were stopped, not that they failed. */
173
199
  async stopTasks() {
174
- await Promise.all([...this.tasks.keys()].map((taskId) => this.sdk.stopTask(taskId)));
200
+ await Promise.all([...this.tasks.keys()].map((taskId) => this.stopTask(taskId)));
175
201
  }
176
202
  // ---- lifecycle ----
177
203
  /** The process started ahead of the first prompt (prewarm), and what it was started with. */
@@ -220,13 +246,16 @@ export class ClaudeSession {
220
246
  }
221
247
  options() {
222
248
  const settings = this.settings;
249
+ const effort = claudeEffort(this.host.effort(settings));
223
250
  return {
224
251
  ...baseOptions(this.host.config),
225
252
  cwd: settings.cwd,
226
253
  ...(settings.model ? { model: settings.model } : {}),
227
- ...(settings.effort ? { effort: settings.effort } : {}),
254
+ ...(effort ? { effort: effort } : {}),
228
255
  ...(settings.fast ? { settings: { fastMode: true } } : {}),
229
- permissionMode: settings.permissionMode,
256
+ permissionMode: claudeMode(settings),
257
+ // Claude enters plan mode only as the user sets it (stock's collaboration mode).
258
+ disallowedTools: ["EnterPlanMode"],
230
259
  // Claude 5 omits its thinking by default: summarized, it shows as the turn's reasoning summary like stock's.
231
260
  extraArgs: { "thinking-display": "summarized" },
232
261
  allowDangerouslySkipPermissions: true,
@@ -271,6 +300,9 @@ export class ClaudeSession {
271
300
  }
272
301
  if (this.sdk !== sdk)
273
302
  return;
303
+ // Claude stopped before it wrote anything (its first turn failed): the next turn starts the session anew.
304
+ if (failure && !this.transcriptPath())
305
+ this.exists = false;
274
306
  // Context Claude never took (it stopped, or never started) fails whoever waits on it.
275
307
  for (const { reject } of this.injections.values())
276
308
  reject(new Error(`Claude stopped${failure}`));
@@ -297,15 +329,22 @@ export class ClaudeSession {
297
329
  }
298
330
  // ---- inputs ----
299
331
  async startTurn(params) {
332
+ // Two processes writing one transcript fork it: whatever the other one writes next is lost to the chat.
333
+ const owner = this.turn ? undefined : foreignOwner(this.host.config.claudeHome, this.threadId);
334
+ if (owner)
335
+ throw invalidRequest(`This chat is open in another Claude process (pid ${owner}): close it there or wait until it ends.`);
300
336
  const input = normalizeUserInput(params.input ?? []);
337
+ const goal = /^\/goal\s+(\S[\s\S]*)/u.exec(inputText(input))?.[1].trim();
338
+ if (goal)
339
+ this.goalObjective = goal === "clear" ? null : goal;
301
340
  const uuid = params.turnId ?? randomUUID();
302
341
  await this.applyTurnSettings(params);
342
+ // Its message records the chat's own mode in the transcript.
343
+ if (!this.settings.plan)
344
+ this.host.gateway.meta.setPlan(this.threadId, null);
303
345
  const content = await claudeContent(input, this.settings.cwd);
304
346
  // Desktop shows `/goal` messages itself.
305
347
  const hidden = typeof content === "string" && /^\/(?:compact|goal)(?:\s|$)/u.test(content);
306
- // Claude only waits on background tasks: it takes the message as a prompt of its own.
307
- if (this.waitingOnTasks)
308
- this.completeTurn();
309
348
  if (this.turn) {
310
349
  // Claude folds a message sent mid-turn into the running turn, like a steer.
311
350
  this.pendingInputs.set(uuid, { input, clientId: params.clientUserMessageId ?? null, hidden });
@@ -313,6 +352,11 @@ export class ClaudeSession {
313
352
  this.inbox.push(userMessage(content, uuid));
314
353
  return startedTurn(this.liveTurn());
315
354
  }
355
+ // Like stock's multi-agent mode message: in the thread once the mode changes.
356
+ if ((this.settings.effort === ULTRA) !== this.delegating) {
357
+ this.delegating = !this.delegating;
358
+ await this.inject(this.delegating ? DELEGATION_ON : DELEGATION_OFF);
359
+ }
316
360
  const turn = this.openTurn(uuid, input, params.clientUserMessageId ?? null, hidden, params.turnId !== undefined);
317
361
  this.ensureQuery();
318
362
  this.inbox.push(userMessage(content, uuid));
@@ -325,10 +369,9 @@ export class ClaudeSession {
325
369
  async steer(params) {
326
370
  if (!this.turn)
327
371
  throw invalidRequest("no active turn to steer");
328
- if (this.waitingOnTasks)
329
- return (await this.startTurn(params)).id;
330
372
  const input = normalizeUserInput(params.input ?? []);
331
- const uuid = randomUUID();
373
+ // The client's id is the message's in the transcript: history gives it back as the message's client id.
374
+ const uuid = UUID.test(params.clientUserMessageId ?? "") ? params.clientUserMessageId : randomUUID();
332
375
  this.pendingInputs.set(uuid, { input, clientId: params.clientUserMessageId ?? null, hidden: false });
333
376
  this.ensureQuery();
334
377
  this.inbox.push(userMessage(await claudeContent(input, this.settings.cwd), uuid));
@@ -336,11 +379,36 @@ export class ClaudeSession {
336
379
  }
337
380
  /** Model-visible context without a model reply (provider switch summary, injected items). */
338
381
  inject(text) {
382
+ return this.quietly(`${INJECTED_PREFIX}\n${text}`, { shouldQuery: false, origin: undefined });
383
+ }
384
+ /**
385
+ * Claude's `/goal` (its only goal API). Claude pursues a goal in one turn until it is met and runs a command sent
386
+ * meanwhile only after that: with a goal active, a running turn stops first. Clearing is no turn: Codex clients show
387
+ * goals themselves.
388
+ */
389
+ async goal(args, active) {
390
+ // Desktop's goal mode sends the goal's message as a turn, then sets the goal: Claude has it already (a second
391
+ // `/goal` would wait behind that turn and set the old goal again once an edit or pause stops it).
392
+ if (args === this.goalObjective && this.turn)
393
+ return;
394
+ this.goalObjective = args === "clear" ? null : args;
395
+ if (active && this.turn) {
396
+ const done = this.turnDone(this.turn.id);
397
+ await this.interrupt();
398
+ await done;
399
+ }
400
+ if (args === "clear")
401
+ await this.quietly("/goal clear");
402
+ else
403
+ await this.command(`/goal ${args}`);
404
+ }
405
+ /** A message that opens no turn; resolves once Claude has taken it. */
406
+ quietly(content, extra) {
339
407
  const uuid = randomUUID();
340
408
  this.ensureQuery();
341
409
  return new Promise((resolve, reject) => {
342
410
  this.injections.set(uuid, { resolve, reject });
343
- this.inbox.push(userMessage(`${INJECTED_PREFIX}\n${text}`, uuid, { shouldQuery: false, origin: undefined }));
411
+ this.inbox.push(userMessage(content, uuid, extra));
344
412
  });
345
413
  }
346
414
  /** Resolves with the final status once the turn completes. */
@@ -358,7 +426,16 @@ export class ClaudeSession {
358
426
  this.host.gateway.cancelServerRequests(this.threadId);
359
427
  // Stop stops all Claude runs: its background tasks and sub-agents too (a message sent meanwhile leaves them running).
360
428
  const sdk = this.sdk;
361
- await Promise.all([sdk.interrupt(), ...[...this.tasks.keys(), ...this.agents].map((id) => sdk.stopTask(id))].map((done) => done.catch(() => undefined)));
429
+ // A command Claude waits on goes to the background first: only there Claude keeps a killed command's output.
430
+ await Promise.all([...this.tasks.values()].filter((task) => task.taskType === "local_bash" && task.toolUseId && !this.background.has(task.taskId))
431
+ .map((task) => sdk.backgroundTasks(task.toolUseId).catch(() => false)));
432
+ // As stock and Claude Code's own Stop: messages steered in but not yet read are dropped, not run after it.
433
+ const interrupt = sdk.interrupt({ cancelQueued: true })
434
+ .then((receipt) => { for (const uuid of receipt?.cancelled ?? [])
435
+ this.pendingInputs.delete(uuid); });
436
+ await Promise.all([interrupt, ...[...this.tasks.keys(), ...this.agents].map((id) => sdk.stopTask(id))].map((done) => done.catch(() => undefined)));
437
+ for (const taskId of this.tasks.keys())
438
+ this.endBackground(taskId, { status: "stopped", summary: "", atMs: Date.now() });
362
439
  this.tasks.clear();
363
440
  this.agents.clear();
364
441
  if (this.state === "idle" && this.turn) {
@@ -368,6 +445,20 @@ export class ClaudeSession {
368
445
  }
369
446
  async stopTask(taskId) {
370
447
  await this.sdk?.stopTask(taskId);
448
+ this.endBackground(taskId, { status: "stopped", summary: "", atMs: Date.now() });
449
+ }
450
+ /** A background command ended: its item completes in the turn it started in, as stock's background terminal does. */
451
+ endBackground(taskId, end) {
452
+ const running = this.background.get(taskId);
453
+ if (!running)
454
+ return;
455
+ this.background.delete(taskId);
456
+ const { item, turnId } = running;
457
+ const ended = endedBackground(item, end, running.startedAtMs);
458
+ const index = this.turn?.id === turnId ? this.turn.items.findIndex((candidate) => candidate.id === item.id) : -1;
459
+ if (index >= 0)
460
+ this.turn.items[index] = ended;
461
+ this.emit("item/completed", { item: ended, threadId: this.threadId, turnId, completedAtMs: end.atMs });
371
462
  }
372
463
  async askSideQuestion(question) {
373
464
  const sdk = this.ensureQuery();
@@ -390,20 +481,23 @@ export class ClaudeSession {
390
481
  const next = this.host.settingsFrom(params, this.settings);
391
482
  const changed = JSON.stringify(next) !== JSON.stringify(this.settings);
392
483
  if (!changed)
393
- return false;
484
+ return;
394
485
  const previous = this.settings;
395
486
  this.settings = next;
487
+ this.keepPlan();
488
+ // Like stock, every client with the chat open learns the change (Desktop's composer follows it).
489
+ this.emit("thread/settings/updated", { threadId: this.threadId, threadSettings: this.host.threadSettings(next) });
396
490
  if (this.sdk) {
397
491
  if (next.model !== previous.model)
398
492
  await this.sdk.setModel(next.model ?? undefined);
399
493
  // The CLI settles the mode per model (no auto mode on Haiku falls back to default): a new model re-applies it.
400
- if (next.permissionMode !== previous.permissionMode || next.model !== previous.model)
401
- await this.sdk.setPermissionMode(next.permissionMode);
402
- if (next.effort !== previous.effort || next.fast !== previous.fast) {
403
- await this.sdk.applyFlagSettings({ effortLevel: next.effort, fastMode: next.fast });
494
+ if (claudeMode(next) !== claudeMode(previous) || next.model !== previous.model)
495
+ await this.sdk.setPermissionMode(claudeMode(next));
496
+ const effort = this.host.effort(next);
497
+ if (effort !== this.host.effort(previous) || next.fast !== previous.fast) {
498
+ await this.sdk.applyFlagSettings({ effortLevel: claudeEffort(effort), fastMode: next.fast });
404
499
  }
405
500
  }
406
- return true;
407
501
  }
408
502
  // ---- turn bookkeeping ----
409
503
  newTurnObject(id) {
@@ -416,8 +510,10 @@ export class ClaudeSession {
416
510
  openTurn(id, input, clientId, hidden, announced = false) {
417
511
  const turn = this.newTurnObject(id);
418
512
  this.turn = { id, startedAt: Date.now(), items: turn.items, resultSeen: false, interrupted: false, error: null };
513
+ this.started = true;
419
514
  this.activeAt = Date.now();
420
515
  this.afterResult = false;
516
+ this.notified = false;
421
517
  this.lastBlock = undefined;
422
518
  try {
423
519
  this.transcriptRead = statSync(this.transcriptPath() ?? "").size;
@@ -446,16 +542,11 @@ export class ClaudeSession {
446
542
  };
447
543
  }
448
544
  maybeComplete() {
449
- if (!this.turn || !this.turn.resultSeen || this.state !== "idle" || this.continuationTimer)
545
+ // Background commands run on past the turn (stock's background terminals); a task's end that wakes Claude up starts
546
+ // a turn of its own.
547
+ if (!this.turn || !this.turn.resultSeen || this.state !== "idle")
450
548
  return;
451
- if (!this.tasks.size || this.queued.length)
452
- return this.completeTurn();
453
- // Background tasks run on after the answer: it ends its turn, and a new one keeps the chat working until they end
454
- // or wake Claude up.
455
- if (!this.lastBlock)
456
- return;
457
- this.continueTurn();
458
- this.turn.resultSeen = true;
549
+ this.completeTurn();
459
550
  }
460
551
  /** Claude goes on after an answer with no prompt: the answer ends its turn, the work goes on in a new one (history's). */
461
552
  continueTurn() {
@@ -533,6 +624,9 @@ export class ClaudeSession {
533
624
  this.host.subagentMessage(this, m);
534
625
  return;
535
626
  }
627
+ // Claude's own word on `/goal` ("Goal set: …"), streamed as a reply: Codex clients show goals themselves.
628
+ if (m.local_command_run?.command === "goal")
629
+ return;
536
630
  if (this.afterResult && (m.type === "stream_event" || m.type === "assistant"))
537
631
  this.continued();
538
632
  switch (m.type) {
@@ -565,6 +659,8 @@ export class ClaudeSession {
565
659
  // A command Claude started by itself (a message another agent sent): its turn has the id history gives it.
566
660
  if (m.state === "started" && uuid && !pending && !this.injections.has(uuid)) {
567
661
  clearTimeout(this.runningTimer);
662
+ if (!this.turn && this.notified)
663
+ return this.ensureTurn();
568
664
  if (!this.turn)
569
665
  this.ensureTurn(uuid);
570
666
  void this.showPeerMessage(uuid);
@@ -584,13 +680,8 @@ export class ClaudeSession {
584
680
  this.itemStarted(item);
585
681
  this.itemCompleted(item);
586
682
  }
587
- /** The session's sub-agents are its transcripts under `<session>/subagents` (a resumed session's earlier ones too). */
588
683
  get peers() {
589
- const session = this.transcriptPath()?.replace(/\.jsonl$/u, "");
590
- return {
591
- directory: this.host.catalog,
592
- children: { has: (agentId) => session !== undefined && /^[\w-]+$/u.test(agentId) && existsSync(join(session, "subagents", `agent-${agentId}.jsonl`)) },
593
- };
684
+ return { directory: this.host.catalog, children: subagentFiles(this.transcriptPath()) };
594
685
  }
595
686
  /**
596
687
  * Claude answers again right after a result, without going idle: the transcript tells what it took. A message another
@@ -695,8 +786,6 @@ export class ClaudeSession {
695
786
  case "session_state_changed":
696
787
  this.state = m.state;
697
788
  if (m.state === "running") {
698
- clearTimeout(this.continuationTimer);
699
- this.continuationTimer = undefined;
700
789
  // Claude started it by itself: the command it runs (announced right after) gives the turn its id.
701
790
  if (!this.injections.size && !this.turn) {
702
791
  clearTimeout(this.runningTimer);
@@ -722,19 +811,18 @@ export class ClaudeSession {
722
811
  this.tasks.set(m.task_id, { taskId: m.task_id, toolUseId: m.tool_use_id, description: m.description ?? "", taskType: m.task_type });
723
812
  return;
724
813
  case "task_notification":
814
+ if (m.status === "stopped" && m.tool_use_id && m.output_file && !this.background.has(m.task_id))
815
+ this.killed.set(m.tool_use_id, m.output_file);
725
816
  this.tasks.delete(m.task_id);
726
817
  this.agents.delete(m.task_id);
727
818
  this.host.subagentFinished(`agent-${m.task_id}`);
728
- this.awaitWakeup();
819
+ this.notified ||= !this.turn;
820
+ this.endBackground(m.task_id, { status: String(m.status), summary: String(m.summary ?? ""), ...(m.output_file ? { outputFile: m.output_file } : {}), atMs: Date.now() });
729
821
  return;
730
822
  case "background_tasks_changed": {
731
- // Claude tells the change before the task's notification: it may wake Claude up still.
732
823
  const live = new Set((m.tasks ?? []).map((task) => task.task_id));
733
- const ended = [...this.tasks.keys()].filter((id) => !live.has(id));
734
- for (const id of ended)
824
+ for (const id of [...this.tasks.keys()].filter((id) => !live.has(id)))
735
825
  this.tasks.delete(id);
736
- if (ended.length)
737
- this.awaitWakeup();
738
826
  return;
739
827
  }
740
828
  case "api_retry":
@@ -755,15 +843,6 @@ export class ClaudeSession {
755
843
  }
756
844
  }
757
845
  /** A task ended while Claude was idle: its notification may wake Claude up, so the turn waits a moment for that. */
758
- awaitWakeup() {
759
- if (this.state !== "idle" || !this.turn)
760
- return;
761
- clearTimeout(this.continuationTimer);
762
- this.continuationTimer = setTimeout(() => {
763
- this.continuationTimer = undefined;
764
- this.maybeComplete();
765
- }, CONTINUATION_GRACE_MS);
766
- }
767
846
  /** Visible CCodex/Claude notice inside the running turn (e.g. `/goal` output). */
768
847
  systemText(text) {
769
848
  if (!text.trim() || !this.turn)
@@ -896,6 +975,14 @@ export class ClaudeSession {
896
975
  }
897
976
  : startTool(index, block, this.settings.cwd, this.threadId);
898
977
  started.item = sentMessageItem(started.item, started.state.input, undefined, this.peers);
978
+ // Stopping a background command shows on the command, as stock's Ctrl-C; what else it stops shows with its result.
979
+ if (block.name === "TaskStop") {
980
+ const taskId = String(block.input?.task_id);
981
+ const running = this.background.get(taskId);
982
+ if (running)
983
+ this.emit("item/commandExecution/terminalInteraction", { threadId: this.threadId, turnId: running.turnId, itemId: running.item.id, processId: taskId, stdin: "\u0003" });
984
+ return void this.stops.set(block.id, started);
985
+ }
899
986
  this.tools.set(block.id, started);
900
987
  this.itemStarted(started.item);
901
988
  }
@@ -947,13 +1034,39 @@ export class ClaudeSession {
947
1034
  continue;
948
1035
  this.codexTails.get(block.tool_use_id)?.();
949
1036
  this.codexTails.delete(block.tool_use_id);
1037
+ const result = typeof m.tool_use_result === "object" && m.tool_use_result !== null ? m.tool_use_result : undefined;
1038
+ const stop = this.stops.get(block.tool_use_id);
1039
+ if (stop) {
1040
+ this.stops.delete(block.tool_use_id);
1041
+ const stopped = stoppedCommand(result);
1042
+ if (stopped) {
1043
+ this.endBackground(stopped, { status: "stopped", summary: "", atMs: Date.now() });
1044
+ continue;
1045
+ }
1046
+ this.tools.set(block.tool_use_id, stop);
1047
+ this.itemStarted(stop.item);
1048
+ }
950
1049
  const tool = this.tools.get(block.tool_use_id);
951
1050
  if (!tool)
952
1051
  continue;
953
1052
  this.tools.delete(block.tool_use_id);
954
- const result = typeof m.tool_use_result === "object" && m.tool_use_result !== null ? m.tool_use_result : undefined;
1053
+ // A command in the background runs on past its turn: its item completes when it ends (stock's background terminal).
1054
+ const taskId = tool.state.name === "Bash" && typeof result?.backgroundTaskId === "string" ? result.backgroundTaskId : undefined;
1055
+ if (taskId && this.turn) {
1056
+ const item = { ...tool.item, processId: taskId };
1057
+ this.background.set(taskId, { item, turnId: this.turn.id, startedAtMs: tool.state.startedAtMs });
1058
+ // Desktop shows a background terminal only for a command with its process (stock's has it from the start).
1059
+ const index = this.turn.items.findIndex((candidate) => candidate.id === item.id);
1060
+ if (index >= 0)
1061
+ this.turn.items[index] = item;
1062
+ this.emit("item/started", { item, threadId: this.threadId, turnId: this.turn.id, startedAtMs: tool.state.startedAtMs });
1063
+ continue;
1064
+ }
955
1065
  // The result tells where a message went (its msg_id) when the call alone did not.
956
- const item = completedToolItem({ ...tool, item: sentMessageItem(tool.item, tool.state.input, result, this.peers) }, { record: { toolUseResult: result }, block }, this.settings.cwd);
1066
+ const completed = completedToolItem({ ...tool, item: sentMessageItem(tool.item, tool.state.input, result, this.peers) }, { record: { toolUseResult: result }, block }, this.settings.cwd);
1067
+ const killed = this.killed.get(block.tool_use_id);
1068
+ this.killed.delete(block.tool_use_id);
1069
+ const item = killed ? killedCommand(completed, killed) : completed;
957
1070
  if (item.type === "collabAgentToolCall" && item.tool === "spawnAgent" && item.receiverThreadIds.length) {
958
1071
  this.host.subagentSpawned(this, item, result?.status === "async_launched");
959
1072
  }
@@ -978,19 +1091,14 @@ export class ClaudeSession {
978
1091
  if (!this.turn)
979
1092
  return;
980
1093
  this.turn.resultSeen = true;
1094
+ // Fast Claude can't serve (the account's usage credits, the model): Claude answers at standard speed, and the chat shows so.
1095
+ if (this.settings.fast && m.fast_mode_state === "off")
1096
+ void this.updateSettings({ serviceTier: null });
981
1097
  this.afterResult = true;
982
1098
  // A turn a message from another agent started, unless showPeerMessage read it back already.
983
1099
  const origin = peerOrigin(m.origin);
984
1100
  if (origin)
985
1101
  this.showPeer(this.turn.id, origin, "");
986
- this.costUsd += Number(m.total_cost_usd ?? 0);
987
- for (const [model, usage] of Object.entries(m.modelUsage ?? {})) {
988
- if (usage?.contextWindow)
989
- this.host.contextWindows.set(normalizeClaudeModelIdentifier(model), Number(usage.contextWindow));
990
- }
991
- const windows = Object.values(m.modelUsage ?? {}).map((usage) => Number(usage?.contextWindow ?? 0)).filter(Boolean);
992
- if (windows.length)
993
- this.contextWindow = Math.max(...windows);
994
1102
  if (m.subtype !== "success" && !this.turn.interrupted) {
995
1103
  const errors = Array.isArray(m.errors) ? m.errors.join("\n") : "";
996
1104
  if (m.subtype === "error_during_execution" && !errors)
@@ -1010,11 +1118,15 @@ export class ClaudeSession {
1010
1118
  this.emit("thread/tokenUsage/updated", {
1011
1119
  threadId: this.threadId,
1012
1120
  turnId: this.turn.id,
1013
- tokenUsage: { total: this.totalUsage, last: this.lastUsage, modelContextWindow: this.contextWindow },
1121
+ tokenUsage: { total: this.totalUsage, last: this.lastUsage, modelContextWindow: this.host.contextWindow(this.liveModel ?? this.settings.model) },
1014
1122
  });
1015
1123
  }
1016
1124
  // ---- approvals and questions ----
1017
1125
  canUseTool = async (toolName, input, options) => {
1126
+ // Stock plan mode: the turn ends with the proposed plan (its item) and Desktop asks to implement it; accepting
1127
+ // switches the collaboration mode back, which gives the chat the mode it had before plan mode.
1128
+ if (toolName === "ExitPlanMode" && this.settings.plan)
1129
+ return { behavior: "deny", message: PLAN_PROPOSED };
1018
1130
  const turnId = this.turn?.id ?? "";
1019
1131
  const itemId = options.toolUseID ?? randomUUID();
1020
1132
  const base = { threadId: this.threadId, turnId, itemId, startedAtMs: Date.now() };