@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.
- package/README.md +156 -157
- package/claude/chat-formatting.md +25 -0
- package/config/compatibility.json +1 -1
- package/dist/claude/commandActions.d.ts +2 -0
- package/dist/claude/commandActions.js +43 -24
- package/dist/claude/commandActions.js.map +1 -1
- package/dist/claude/delegation.d.ts +10 -0
- package/dist/claude/delegation.js +41 -0
- package/dist/claude/delegation.js.map +1 -0
- package/dist/claude/native/catalog.d.ts +14 -0
- package/dist/claude/native/catalog.js +52 -17
- package/dist/claude/native/catalog.js.map +1 -1
- package/dist/claude/native/history.d.ts +8 -1
- package/dist/claude/native/history.js +43 -19
- package/dist/claude/native/history.js.map +1 -1
- package/dist/claude/native/pages.d.ts +54 -0
- package/dist/claude/native/pages.js +271 -0
- package/dist/claude/native/pages.js.map +1 -0
- package/dist/claude/native/projector.d.ts +18 -2
- package/dist/claude/native/projector.js +104 -46
- package/dist/claude/native/projector.js.map +1 -1
- package/dist/claude/native/records.d.ts +2 -0
- package/dist/claude/native/records.js +35 -13
- package/dist/claude/native/records.js.map +1 -1
- package/dist/claude/native/search.d.ts +7 -0
- package/dist/claude/native/search.js +79 -0
- package/dist/claude/native/search.js.map +1 -0
- package/dist/claude/native/summary.d.ts +6 -1
- package/dist/claude/native/summary.js +24 -6
- package/dist/claude/native/summary.js.map +1 -1
- package/dist/claude/peers.d.ts +4 -0
- package/dist/claude/peers.js +19 -5
- package/dist/claude/peers.js.map +1 -1
- package/dist/claude/processes.d.ts +2 -0
- package/dist/claude/processes.js +15 -0
- package/dist/claude/processes.js.map +1 -1
- package/dist/claude/sdk.d.ts +8 -2
- package/dist/claude/sdk.js +23 -6
- package/dist/claude/sdk.js.map +1 -1
- package/dist/claude/session.d.ts +34 -7
- package/dist/claude/session.js +184 -72
- package/dist/claude/session.js.map +1 -1
- package/dist/claude/threads.d.ts +61 -3
- package/dist/claude/threads.js +408 -100
- package/dist/claude/threads.js.map +1 -1
- package/dist/claude/toolMapper.d.ts +23 -3
- package/dist/claude/toolMapper.js +50 -1
- package/dist/claude/toolMapper.js.map +1 -1
- package/dist/cli/args.js +2 -1
- package/dist/cli/args.js.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.js +7 -3
- package/dist/config.js.map +1 -1
- package/dist/daemon/daemon.d.ts +3 -0
- package/dist/daemon/daemon.js +6 -3
- package/dist/daemon/daemon.js.map +1 -1
- package/dist/daemon/supervisor.d.ts +8 -0
- package/dist/daemon/supervisor.js +15 -7
- package/dist/daemon/supervisor.js.map +1 -1
- package/dist/desktop/stdioFrontend.js +1 -1
- package/dist/desktop/stdioFrontend.js.map +1 -1
- package/dist/gateway/catalog.js +35 -20
- package/dist/gateway/catalog.js.map +1 -1
- package/dist/gateway/connection.js +2 -2
- package/dist/gateway/connection.js.map +1 -1
- package/dist/gateway/lineage.d.ts +29 -1
- package/dist/gateway/lineage.js +260 -38
- package/dist/gateway/lineage.js.map +1 -1
- package/dist/gateway/remote.js +3 -8
- package/dist/gateway/remote.js.map +1 -1
- package/dist/gateway/server.d.ts +2 -1
- package/dist/gateway/server.js +26 -13
- package/dist/gateway/server.js.map +1 -1
- package/dist/gateway/status.d.ts +15 -5
- package/dist/gateway/status.js +142 -47
- package/dist/gateway/status.js.map +1 -1
- package/dist/gateway/stock.js +17 -2
- package/dist/gateway/stock.js.map +1 -1
- package/dist/management/commands.d.ts +2 -0
- package/dist/management/commands.js +66 -3
- package/dist/management/commands.js.map +1 -1
- package/dist/meta.d.ts +39 -11
- package/dist/meta.js +59 -28
- package/dist/meta.js.map +1 -1
- package/dist/protocol/codex.d.ts +1 -3
- package/dist/protocol/codex.js +1 -4
- package/dist/protocol/codex.js.map +1 -1
- package/dist/protocol/turnPagination.d.ts +35 -2
- package/dist/protocol/turnPagination.js +53 -28
- package/dist/protocol/turnPagination.js.map +1 -1
- package/examples/config.toml +2 -2
- package/package.json +5 -4
- package/scripts/migrate-0.4-to-0.5.mjs +31 -20
package/dist/claude/session.js
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
190
|
+
/** No turn runs, only background tasks. */
|
|
169
191
|
get waitingOnTasks() {
|
|
170
|
-
return this.turn
|
|
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.
|
|
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
|
-
...(
|
|
254
|
+
...(effort ? { effort: effort } : {}),
|
|
228
255
|
...(settings.fast ? { settings: { fastMode: true } } : {}),
|
|
229
|
-
permissionMode: settings
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
401
|
-
await this.sdk.setPermissionMode(next
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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() };
|