@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/threads.js
CHANGED
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { existsSync, statSync } from "node:fs";
|
|
2
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { rename, writeFile } from "node:fs/promises";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import { deleteSession, forkSession, renameSession } from "@anthropic-ai/claude-agent-sdk";
|
|
5
6
|
import { v7 as uuidv7 } from "uuid";
|
|
6
7
|
import { packageVersion } from "../management/commands.js";
|
|
7
8
|
import { invalidParams, invalidRequest, requestedModel } from "../protocol/codex.js";
|
|
8
|
-
import { historyCursors, paginateItems, paginateTurns, startedTurn } from "../protocol/turnPagination.js";
|
|
9
|
+
import { anchorCursor, historyCursors, occurrencesPage, paginateItems, paginateTurns, startedTurn, turnOccurrences } from "../protocol/turnPagination.js";
|
|
10
|
+
import { claudeEffort, ULTRA } from "./delegation.js";
|
|
9
11
|
import { normalizeUserInput } from "./inputMapper.js";
|
|
10
12
|
import { claudeModelLabel, modelCatalogValue, normalizeClaudeModelIdentifier } from "./modelSelection.js";
|
|
11
13
|
import { NativeSessionCatalog } from "./native/catalog.js";
|
|
14
|
+
import { firstMatch } from "./native/search.js";
|
|
12
15
|
import { nativeThread } from "./native/projector.js";
|
|
13
16
|
import { projectSubagents } from "./native/subagents.js";
|
|
14
17
|
import { readTranscriptRecords } from "./native/records.js";
|
|
15
|
-
import { summarizeTranscript, userText } from "./native/summary.js";
|
|
16
|
-
import { codexPermissions, mapClaudeModel, mapSkill,
|
|
18
|
+
import { preview, summarizeTranscript, userText } from "./native/summary.js";
|
|
19
|
+
import { claudeMode, codexPermissions, mapClaudeModel, mapSkill, permissionSettings, withProbeQuery } from "./sdk.js";
|
|
17
20
|
import { killProcesses, sessionProcesses } from "./processes.js";
|
|
18
21
|
import { ClaudeSession } from "./session.js";
|
|
19
22
|
/** Claude's standard context window, until a session of the model reports its own. */
|
|
@@ -28,6 +31,13 @@ const IDLE_MS = Number(process.env.CCODEX_E2E_IDLE_MS) || 30 * 60_000;
|
|
|
28
31
|
const RESUME_WARM_MS = Number(process.env.CCODEX_E2E_RESUME_WARM_MS) || 10_000;
|
|
29
32
|
/** Claude processes kept at most (each holds its session in memory: 250–550 MB); tests lower it. */
|
|
30
33
|
const MAX_PROCESSES = Number(process.env.CCODEX_E2E_MAX_PROCESSES) || 10;
|
|
34
|
+
/** A chat's permissions and plan mode from the mode its transcript last recorded (in plan mode, meta.json keeps its own). */
|
|
35
|
+
function recordedPermissions(threadId, recorded, meta) {
|
|
36
|
+
if (recorded !== "plan")
|
|
37
|
+
return { permissionMode: (recorded ?? "default"), plan: false };
|
|
38
|
+
const kept = meta.plan(threadId);
|
|
39
|
+
return { permissionMode: (kept?.permissionMode ?? "default"), plan: kept?.plan ?? true };
|
|
40
|
+
}
|
|
31
41
|
/** The Claude side of the gateway: catalog of native sessions, live sessions, side chats, models, skills. */
|
|
32
42
|
export class ClaudeThreads {
|
|
33
43
|
config;
|
|
@@ -38,6 +48,9 @@ export class ClaudeThreads {
|
|
|
38
48
|
sides = new Map();
|
|
39
49
|
/** `agent-<id>` sub-agent thread → the native session whose transcript directory holds it. */
|
|
40
50
|
subagentRoots = new Map();
|
|
51
|
+
/** Threads rolled back but not continued yet: their kept history ends at this record. A restart meanwhile shows
|
|
52
|
+
* the rolled-back messages again. */
|
|
53
|
+
leaves = new Map();
|
|
41
54
|
/** Sub-agents spawned live, shown until Claude has written their transcript. */
|
|
42
55
|
spawnedSubagents = new Map();
|
|
43
56
|
/** Context window of each Claude model, as its sessions report it. */
|
|
@@ -47,6 +60,11 @@ export class ClaudeThreads {
|
|
|
47
60
|
models_;
|
|
48
61
|
defaultModel = null;
|
|
49
62
|
rateLimitWindows = new Map();
|
|
63
|
+
usage;
|
|
64
|
+
/** Why Claude's plan limits could not be read, when they could not. */
|
|
65
|
+
usageError;
|
|
66
|
+
/** Weekly windows of single models (e.g. Fable), from Claude's `/usage` data. */
|
|
67
|
+
modelLimits = [];
|
|
50
68
|
stopWatching;
|
|
51
69
|
onTurnCompleted;
|
|
52
70
|
constructor(config, gateway, logger) {
|
|
@@ -56,8 +74,15 @@ export class ClaudeThreads {
|
|
|
56
74
|
this.catalog = new NativeSessionCatalog(join(config.claudeHome, "projects"), { path: join(config.dataDir, "claude-catalog.json"), key: packageVersion() });
|
|
57
75
|
}
|
|
58
76
|
async start() {
|
|
59
|
-
// The model list maps transcripts' resolved model ids to picker values (see pickerModel)
|
|
60
|
-
|
|
77
|
+
// The model list maps transcripts' resolved model ids to picker values (see pickerModel): the one Claude reported last
|
|
78
|
+
// time at once (a chat opened right after a start waits for no probe), the fresh one once the probe answers.
|
|
79
|
+
const cached = this.cachedModels();
|
|
80
|
+
if (cached) {
|
|
81
|
+
this.models_ = Promise.resolve(this.useModels(cached));
|
|
82
|
+
void this.probeModels().then((models) => { this.models_ = Promise.resolve(models); }, () => undefined);
|
|
83
|
+
}
|
|
84
|
+
else
|
|
85
|
+
void this.models().catch(() => undefined);
|
|
61
86
|
await this.catalog.refresh();
|
|
62
87
|
this.gateway.meta.prune((segment) => segment.provider === "codex" || this.catalog.get(segment.threadId) !== undefined);
|
|
63
88
|
const known = new Map(this.catalog.sessions().map((summary) => [summary.sessionId, summary.customTitle ?? summary.aiTitle]));
|
|
@@ -87,7 +112,7 @@ export class ClaudeThreads {
|
|
|
87
112
|
/** CPU seconds each command of a loaded session used, and since when unchanged. */
|
|
88
113
|
cpuSeen = new Map();
|
|
89
114
|
sweeper;
|
|
90
|
-
/** What CCodex last did to each chat's process and commands, and why (shown by /
|
|
115
|
+
/** What CCodex last did to each chat's process and commands, and why (shown by /cc). */
|
|
91
116
|
actions = new Map();
|
|
92
117
|
act(session, event, text, pids) {
|
|
93
118
|
this.logger.info(event, { threadId: session.threadId, pids });
|
|
@@ -127,7 +152,7 @@ export class ClaudeThreads {
|
|
|
127
152
|
this.act(session, "claude.tasks.hung", `stopped its background tasks: their commands used no CPU for ${idle}`, pids);
|
|
128
153
|
void session.stopTasks().catch(() => killProcesses(pids));
|
|
129
154
|
}
|
|
130
|
-
else if (session.quiet(now, IDLE_MS) && !this.gateway.subscribers(session.threadId)) {
|
|
155
|
+
else if (session.quiet(now, IDLE_MS) && !this.gateway.subscribers(session.threadId).size) {
|
|
131
156
|
this.act(session, "claude.unloaded", `unloaded the chat: nobody had it open and it was quiet for ${idle}`, pids);
|
|
132
157
|
this.sessions.delete(session.threadId);
|
|
133
158
|
void session.unload().then(() => killProcesses(pids ?? []));
|
|
@@ -182,20 +207,34 @@ export class ClaudeThreads {
|
|
|
182
207
|
return {
|
|
183
208
|
...summary,
|
|
184
209
|
aiTitle: this.gateway.titles.naming(summary.sessionId) ? null : summary.aiTitle,
|
|
185
|
-
model: session?.settings.model ??
|
|
186
|
-
reasoningEffort: session?.settings.effort ?? summary
|
|
210
|
+
model: session?.settings.model ?? this.recordedModel(summary),
|
|
211
|
+
reasoningEffort: session?.settings.effort ?? this.recordedEffort(summary),
|
|
187
212
|
};
|
|
188
213
|
}
|
|
214
|
+
/** A chat's model from its transcript; in plan mode meta.json's (Claude plans a Haiku chat on Sonnet, see Meta.setPlan). */
|
|
215
|
+
recordedModel(summary) {
|
|
216
|
+
const planned = summary.permissionMode === "plan" ? this.gateway.meta.plan(summary.sessionId)?.model : undefined;
|
|
217
|
+
return planned ?? (summary.model && this.pickerModel(summary.model));
|
|
218
|
+
}
|
|
219
|
+
/** A chat's effort from its transcript (Claude records `ultra` as the max it runs at, told to delegate); in plan mode meta.json's. */
|
|
220
|
+
recordedEffort(summary) {
|
|
221
|
+
if (summary.permissionMode === "plan")
|
|
222
|
+
return this.gateway.meta.plan(summary.sessionId)?.effort ?? null;
|
|
223
|
+
return summary.delegating && summary.reasoningEffort === claudeEffort(ULTRA) ? ULTRA : summary.reasoningEffort;
|
|
224
|
+
}
|
|
189
225
|
/** Transcripts name the resolved model (`claude-haiku-4-5-…`); the picker (and a live session) its value (`haiku`). */
|
|
190
226
|
pickerModel(model) {
|
|
191
227
|
const id = normalizeClaudeModelIdentifier(model);
|
|
192
228
|
return this.pickerValues.get(id) ?? id;
|
|
193
229
|
}
|
|
194
230
|
/** Every native session as a list row (sub-agents excluded; they are listed through their parent). */
|
|
231
|
+
search(term) {
|
|
232
|
+
return this.catalog.search(this.config.claudeBinary, term);
|
|
233
|
+
}
|
|
195
234
|
threads() {
|
|
196
235
|
const rows = this.catalog.sessions().map((summary) => this.decorate(nativeThread(summary.sessionId, this.headerOf(summary, this.sessions.get(summary.sessionId)), { status: this.status(summary.sessionId) })));
|
|
197
236
|
for (const session of this.sessions.values()) {
|
|
198
|
-
if (!this.catalog.get(session.threadId))
|
|
237
|
+
if (session.started && !this.catalog.get(session.threadId))
|
|
199
238
|
rows.push(this.decorate(this.freshThread(session)));
|
|
200
239
|
}
|
|
201
240
|
return rows;
|
|
@@ -216,7 +255,7 @@ export class ClaudeThreads {
|
|
|
216
255
|
return nativeThread(session.threadId, {
|
|
217
256
|
cwd: session.settings.cwd, gitBranch: null, createdAt: now, updatedAt: now, preview: "",
|
|
218
257
|
customTitle: this.pendingNames.get(session.threadId) ?? null, aiTitle: null, model: session.settings.model, reasoningEffort: session.settings.effort,
|
|
219
|
-
serviceTier: session.settings.fast ? "fast" : null, permissionMode: session.settings
|
|
258
|
+
serviceTier: session.settings.fast ? "fast" : null, permissionMode: claudeMode(session.settings), cliVersion: null, goal: null,
|
|
220
259
|
}, { status: this.status(session.threadId) });
|
|
221
260
|
}
|
|
222
261
|
// ---- reads ----
|
|
@@ -225,7 +264,7 @@ export class ClaudeThreads {
|
|
|
225
264
|
await this.catalog.refresh();
|
|
226
265
|
if (!this.catalog.get(threadId))
|
|
227
266
|
return undefined;
|
|
228
|
-
return this.catalog.projection(threadId, this.
|
|
267
|
+
return this.catalog.projection(threadId, this.leaves.get(threadId));
|
|
229
268
|
}
|
|
230
269
|
subagentRoot(threadId) {
|
|
231
270
|
const known = this.subagentRoots.get(threadId);
|
|
@@ -278,7 +317,7 @@ export class ClaudeThreads {
|
|
|
278
317
|
subagentSpawned(session, item, running) {
|
|
279
318
|
const childId = item.receiverThreadIds[0];
|
|
280
319
|
const now = Math.floor(Date.now() / 1000);
|
|
281
|
-
const header = { ...summarizeTranscript([]), cwd: session.settings.cwd, preview: item.prompt ?? "", model: item.model, createdAt: now, updatedAt: now };
|
|
320
|
+
const header = { ...summarizeTranscript([]), cwd: session.settings.cwd, preview: preview(item.prompt ?? ""), model: item.model, createdAt: now, updatedAt: now };
|
|
282
321
|
const thread = nativeThread(childId, header, {
|
|
283
322
|
status: { type: "active", activeFlags: [] },
|
|
284
323
|
subagent: { parentThreadId: session.threadId, depth: 1, nickname: `${item.agentsStates[childId].message} [${claudeModelLabel(item.model ?? "Claude")}]` },
|
|
@@ -370,15 +409,7 @@ export class ClaudeThreads {
|
|
|
370
409
|
throw invalidParams(`thread not found: ${threadId}`);
|
|
371
410
|
return { thread: this.decorate(this.freshThread(session)), turns: session.liveTurn() ? [session.liveTurn()] : [] };
|
|
372
411
|
}
|
|
373
|
-
|
|
374
|
-
const live = session?.liveTurn();
|
|
375
|
-
if (live) {
|
|
376
|
-
const index = turns.findIndex((turn) => turn.id === live.id);
|
|
377
|
-
if (index >= 0)
|
|
378
|
-
turns = [...turns.slice(0, index), { ...turns[index], status: "inProgress", completedAt: null }];
|
|
379
|
-
else
|
|
380
|
-
turns.push(live);
|
|
381
|
-
}
|
|
412
|
+
const turns = this.withLive(threadId, projection.turns);
|
|
382
413
|
const summary = this.catalog.get(threadId);
|
|
383
414
|
const header = summary ? this.headerOf(summary, session) : undefined;
|
|
384
415
|
const thread = header
|
|
@@ -386,6 +417,110 @@ export class ClaudeThreads {
|
|
|
386
417
|
: { ...projection.thread, turns: [] };
|
|
387
418
|
return { thread: this.decorate(thread), turns, usage: projection.tokenUsage };
|
|
388
419
|
}
|
|
420
|
+
/** The thread without its history: the catalog's header, no transcript read. */
|
|
421
|
+
async thread(threadId) {
|
|
422
|
+
const side = this.sides.get(threadId);
|
|
423
|
+
if (side)
|
|
424
|
+
return this.sideThread(side);
|
|
425
|
+
if (!this.catalog.get(threadId))
|
|
426
|
+
await this.catalog.refresh();
|
|
427
|
+
if (!this.paged(threadId))
|
|
428
|
+
return (await this.read(threadId)).thread;
|
|
429
|
+
await this.models().catch(() => undefined);
|
|
430
|
+
return this.decorate(nativeThread(threadId, this.headerOf(this.catalog.get(threadId), this.sessions.get(threadId)), { status: this.status(threadId) }));
|
|
431
|
+
}
|
|
432
|
+
/** A main session with a transcript: its history is read page by page (a sub-agent's, a small file of one turn, whole). */
|
|
433
|
+
paged(threadId) {
|
|
434
|
+
return !threadId.startsWith("agent-") && !this.sides.has(threadId) && this.catalog.has(threadId);
|
|
435
|
+
}
|
|
436
|
+
pages(threadId) {
|
|
437
|
+
return this.catalog.pages(threadId, this.leaves.get(threadId));
|
|
438
|
+
}
|
|
439
|
+
/** The running turn is the newest one, before Claude writes any of it too (a turn it goes on in after an answer). */
|
|
440
|
+
running(threadId, turnId) {
|
|
441
|
+
return this.sessions.get(threadId)?.liveTurn()?.id === turnId;
|
|
442
|
+
}
|
|
443
|
+
/** History turns with the live turn as the session shows it, and the background commands still running as running. */
|
|
444
|
+
withLive(threadId, history) {
|
|
445
|
+
const session = this.sessions.get(threadId);
|
|
446
|
+
const running = session?.runningCommands;
|
|
447
|
+
let turns = running?.size ? history.map((turn) => ({
|
|
448
|
+
...turn,
|
|
449
|
+
items: turn.items.map((item) => running.has(item.id) ? { ...item, status: "inProgress", exitCode: null, aggregatedOutput: null, durationMs: null } : item),
|
|
450
|
+
})) : [...history];
|
|
451
|
+
const live = session?.liveTurn();
|
|
452
|
+
// Stock's stale turns: one the transcript leaves unfinished with none running here (its Claude died with a daemon
|
|
453
|
+
// restart) was interrupted. A sub-agent runs in its parent's session.
|
|
454
|
+
if (!live)
|
|
455
|
+
return threadId.startsWith("agent-") ? turns : turns.map((turn) => turn.status === "inProgress" ? { ...turn, status: "interrupted" } : turn);
|
|
456
|
+
const index = turns.findIndex((turn) => turn.id === live.id);
|
|
457
|
+
if (index >= 0)
|
|
458
|
+
turns = [...turns.slice(0, index), { ...turns[index], status: "inProgress", completedAt: null }];
|
|
459
|
+
else
|
|
460
|
+
turns.push(live);
|
|
461
|
+
return turns;
|
|
462
|
+
}
|
|
463
|
+
/** The newest turns (at least `count` unless history is shorter). */
|
|
464
|
+
async newestTurns(threadId, count) {
|
|
465
|
+
if (!this.catalog.get(threadId))
|
|
466
|
+
await this.catalog.refresh();
|
|
467
|
+
if (!this.paged(threadId))
|
|
468
|
+
return this.read(threadId);
|
|
469
|
+
const { pages, source } = this.pages(threadId);
|
|
470
|
+
const window = await pages.newest(source, count);
|
|
471
|
+
return { turns: this.withLive(threadId, window.turns), usage: window.projection.tokenUsage };
|
|
472
|
+
}
|
|
473
|
+
async turnsPage(threadId, params) {
|
|
474
|
+
if (!this.catalog.get(threadId))
|
|
475
|
+
await this.catalog.refresh();
|
|
476
|
+
const anchor = params.cursor ? anchorCursor("turnId", params.cursor) : undefined;
|
|
477
|
+
const descending = (params.sortDirection ?? "desc") === "desc";
|
|
478
|
+
// Oldest first from the start: the one page that needs all of history.
|
|
479
|
+
if (!this.paged(threadId) || !descending && !anchor)
|
|
480
|
+
return paginateTurns((await this.read(threadId)).turns, params);
|
|
481
|
+
const { pages, source } = this.pages(threadId);
|
|
482
|
+
const limit = Math.max(1, Math.min(params.limit ?? 25, 100));
|
|
483
|
+
const window = !anchor || this.running(threadId, anchor.anchor) ? await pages.newest(source, limit + 1)
|
|
484
|
+
: descending ? await pages.around(source, anchor.anchor, limit + 1)
|
|
485
|
+
: await pages.since(source, anchor.anchor);
|
|
486
|
+
return paginateTurns(this.withLive(threadId, window.turns), params);
|
|
487
|
+
}
|
|
488
|
+
/** Stock's `thread/searchOccurrences`: history is read back only to the first message holding the term (ripgrep finds it). */
|
|
489
|
+
async searchOccurrences(threadId, params) {
|
|
490
|
+
if (!String(params.searchTerm ?? "").trim())
|
|
491
|
+
throw invalidRequest("thread/searchOccurrences requires a non-empty searchTerm");
|
|
492
|
+
if (!this.catalog.get(threadId))
|
|
493
|
+
await this.catalog.refresh();
|
|
494
|
+
let turns;
|
|
495
|
+
if (this.paged(threadId)) {
|
|
496
|
+
const { pages, source } = this.pages(threadId);
|
|
497
|
+
const at = await firstMatch(this.config.claudeBinary, source.path, params.searchTerm);
|
|
498
|
+
turns = this.withLive(threadId, at === undefined ? [] : (await pages.reaching(source, at)).turns);
|
|
499
|
+
}
|
|
500
|
+
else
|
|
501
|
+
turns = (await this.read(threadId)).turns;
|
|
502
|
+
return occurrencesPage(turnOccurrences(turns, params.searchTerm), params);
|
|
503
|
+
}
|
|
504
|
+
async itemsPage(threadId, params) {
|
|
505
|
+
if (!this.catalog.get(threadId))
|
|
506
|
+
await this.catalog.refresh();
|
|
507
|
+
// Desktop always names the turn; items across the whole thread need all of it.
|
|
508
|
+
if (!this.paged(threadId) || !params.turnId)
|
|
509
|
+
return paginateItems((await this.read(threadId)).turns, params);
|
|
510
|
+
const { pages, source } = this.pages(threadId);
|
|
511
|
+
const window = this.running(threadId, params.turnId) ? await pages.newest(source, 1) : await pages.around(source, params.turnId, 0);
|
|
512
|
+
const turn = this.withLive(threadId, window.turns).find((candidate) => candidate.id === params.turnId);
|
|
513
|
+
const anchor = params.cursor ? anchorCursor("itemId", params.cursor)?.anchor : undefined;
|
|
514
|
+
if (!anchor || turn.items.some((item) => item.id === anchor))
|
|
515
|
+
return paginateItems([turn], params);
|
|
516
|
+
// Desktop pages an older turn from the thread's last item (the resume's backwards cursor): stock places the anchor
|
|
517
|
+
// in the whole thread, so it stands here in a marker turn on its side of this one.
|
|
518
|
+
const newest = (await this.newestTurns(threadId, 1)).turns;
|
|
519
|
+
const turnAt = newest.findIndex((candidate) => candidate.id === turn.id);
|
|
520
|
+
const anchorAt = newest.findIndex((candidate) => candidate.items.some((item) => item.id === anchor));
|
|
521
|
+
const marker = { ...turn, id: `${turn.id}:anchor`, items: [{ type: "agentMessage", id: anchor, text: "", phase: null, memoryCitation: null }] };
|
|
522
|
+
return paginateItems((anchorAt >= 0 ? anchorAt > turnAt : turnAt < 0) ? [turn, marker] : [marker, turn], params);
|
|
523
|
+
}
|
|
389
524
|
settings(threadId) {
|
|
390
525
|
const session = this.sessions.get(threadId);
|
|
391
526
|
if (session)
|
|
@@ -393,10 +528,10 @@ export class ClaudeThreads {
|
|
|
393
528
|
const summary = this.catalog.get(threadId);
|
|
394
529
|
return {
|
|
395
530
|
cwd: summary?.cwd ?? process.cwd(),
|
|
396
|
-
model: summary
|
|
397
|
-
effort: summary
|
|
531
|
+
model: (summary && this.recordedModel(summary)) || this.defaultModel,
|
|
532
|
+
effort: summary ? this.recordedEffort(summary) : null,
|
|
398
533
|
fast: summary?.serviceTier === "fast",
|
|
399
|
-
|
|
534
|
+
...recordedPermissions(threadId, summary?.permissionMode, this.gateway.meta),
|
|
400
535
|
};
|
|
401
536
|
}
|
|
402
537
|
/** Common fields of thread/start|resume|fork responses and thread/settings/updated. */
|
|
@@ -433,7 +568,7 @@ export class ClaudeThreads {
|
|
|
433
568
|
effort: settings.effort,
|
|
434
569
|
summary: null,
|
|
435
570
|
collaborationMode: {
|
|
436
|
-
mode: settings.
|
|
571
|
+
mode: settings.plan ? "plan" : "default",
|
|
437
572
|
settings: { model: response.model, reasoning_effort: settings.effort, developer_instructions: null },
|
|
438
573
|
},
|
|
439
574
|
multiAgentMode: "explicitRequestOnly",
|
|
@@ -449,7 +584,7 @@ export class ClaudeThreads {
|
|
|
449
584
|
model,
|
|
450
585
|
effort: params.effort ?? params.collaborationMode?.settings?.reasoning_effort ?? current.effort,
|
|
451
586
|
fast: tier === undefined ? current.fast : tier === "fast" || tier === "priority",
|
|
452
|
-
|
|
587
|
+
...permissionSettings(params, current),
|
|
453
588
|
};
|
|
454
589
|
}
|
|
455
590
|
// ---- sessions ----
|
|
@@ -459,14 +594,14 @@ export class ClaudeThreads {
|
|
|
459
594
|
return session;
|
|
460
595
|
if (!this.catalog.get(threadId))
|
|
461
596
|
throw invalidParams(`thread not found: ${threadId}`);
|
|
462
|
-
const leaf = this.
|
|
597
|
+
const leaf = this.leaves.get(threadId);
|
|
463
598
|
session = new ClaudeSession(this, threadId, this.settings(threadId), { exists: true, ...(leaf ? { resumeAt: leaf } : {}) });
|
|
464
599
|
this.sessions.set(threadId, session);
|
|
465
600
|
return session;
|
|
466
601
|
}
|
|
467
602
|
/** The session continued from the rollback leaf: its new records end the history from now on. */
|
|
468
603
|
resumedAtLeaf(threadId) {
|
|
469
|
-
this.
|
|
604
|
+
this.leaves.delete(threadId);
|
|
470
605
|
}
|
|
471
606
|
turnCompleted(session, turnId) {
|
|
472
607
|
const threadId = session.threadId;
|
|
@@ -479,6 +614,9 @@ export class ClaudeThreads {
|
|
|
479
614
|
const goal = this.goal(threadId);
|
|
480
615
|
if (JSON.stringify(goal) === before)
|
|
481
616
|
return;
|
|
617
|
+
// A goal replaced meanwhile (edited, paused, cleared) is no news: Desktop would clear the new goal for its completion.
|
|
618
|
+
if (goal && session.goalObjective !== undefined && goal.objective !== session.goalObjective)
|
|
619
|
+
return;
|
|
482
620
|
if (goal)
|
|
483
621
|
this.gateway.emit(threadId, "thread/goal/updated", { threadId, turnId, goal });
|
|
484
622
|
else
|
|
@@ -486,13 +624,22 @@ export class ClaudeThreads {
|
|
|
486
624
|
});
|
|
487
625
|
this.onTurnCompleted?.(threadId, turnId);
|
|
488
626
|
}
|
|
489
|
-
/**
|
|
627
|
+
/**
|
|
628
|
+
* Claude's native `/goal` as a Codex ThreadGoal (Claude tracks no budget or time); a paused one is kept in meta.json.
|
|
629
|
+
* The goal last sent to Claude is the chat's: Claude records a `/goal` a moment after taking it.
|
|
630
|
+
*/
|
|
490
631
|
goal(threadId) {
|
|
491
|
-
const
|
|
632
|
+
const paused = this.gateway.meta.pausedGoal(threadId);
|
|
633
|
+
const recorded = this.catalog.get(threadId)?.goal;
|
|
634
|
+
const sent = this.sessions.get(threadId)?.goalObjective;
|
|
635
|
+
const now = Math.floor(Date.now() / 1000);
|
|
636
|
+
const goal = paused ? { ...paused, met: false }
|
|
637
|
+
: sent === undefined || sent === recorded?.objective ? recorded
|
|
638
|
+
: sent && { objective: sent, createdAt: now, updatedAt: now, met: false };
|
|
492
639
|
if (!goal)
|
|
493
640
|
return null;
|
|
494
641
|
return {
|
|
495
|
-
threadId, objective: goal.objective, status: goal.met ? "complete" : "active", tokenBudget: null, tokensUsed: 0,
|
|
642
|
+
threadId, objective: goal.objective, status: paused ? "paused" : goal.met ? "complete" : "active", tokenBudget: null, tokensUsed: 0,
|
|
496
643
|
timeUsedSeconds: Math.max(0, goal.updatedAt - goal.createdAt), createdAt: goal.createdAt, updatedAt: goal.updatedAt,
|
|
497
644
|
};
|
|
498
645
|
}
|
|
@@ -500,14 +647,26 @@ export class ClaudeThreads {
|
|
|
500
647
|
// Sub-agent activity is shown by the parent's collabAgentToolCall item; child history is read from disk.
|
|
501
648
|
}
|
|
502
649
|
onRateLimit(info) {
|
|
503
|
-
if (!info
|
|
650
|
+
if (!info)
|
|
504
651
|
return;
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
652
|
+
// A turn's event carries every plan window Claude knows in `unifiedWindows` (utilization as a fraction); `status`,
|
|
653
|
+
// and `utilization` if any, are about the window `rateLimitType` names. A window without one keeps the last known.
|
|
654
|
+
const windows = { ...info.unifiedWindows };
|
|
655
|
+
if (info.rateLimitType)
|
|
656
|
+
windows[info.rateLimitType] = { resetsAt: info.resetsAt, utilization: info.utilization, ...windows[info.rateLimitType] };
|
|
657
|
+
for (const [type, window] of Object.entries(windows)) {
|
|
658
|
+
const status = type === info.rateLimitType ? info.status : "allowed";
|
|
659
|
+
const usedPercent = typeof window.utilization === "number" ? Math.round(window.utilization * 100)
|
|
660
|
+
: status === "rejected" ? 100 : this.rateLimitWindows.get(type)?.usedPercent;
|
|
661
|
+
if (usedPercent === undefined)
|
|
662
|
+
continue;
|
|
663
|
+
this.rateLimitWindows.set(type, {
|
|
664
|
+
status,
|
|
665
|
+
usedPercent,
|
|
666
|
+
windowDurationMins: WINDOW_MINUTES[type] ?? null,
|
|
667
|
+
resetsAt: typeof window.resetsAt === "number" ? window.resetsAt : null,
|
|
668
|
+
});
|
|
669
|
+
}
|
|
511
670
|
const snapshot = this.rateLimitSnapshot();
|
|
512
671
|
for (const connection of this.gateway.connections) {
|
|
513
672
|
if (connection.provider === "claude")
|
|
@@ -529,34 +688,118 @@ export class ClaudeThreads {
|
|
|
529
688
|
rateLimitReachedType: [...this.rateLimitWindows.values()].some((value) => value.status === "rejected") ? "rate_limit_reached" : null,
|
|
530
689
|
};
|
|
531
690
|
}
|
|
691
|
+
/** Claude's plan limits: read from Claude's `/usage` data at most once a minute, and as turns report them. */
|
|
532
692
|
async rateLimits() {
|
|
693
|
+
if (!this.usage || Date.now() - this.usage.at > 60_000)
|
|
694
|
+
this.usage = { at: Date.now(), done: this.readUsage() };
|
|
695
|
+
await this.usage.done;
|
|
533
696
|
const snapshot = this.rateLimitSnapshot();
|
|
534
697
|
return { rateLimits: snapshot, rateLimitsByLimitId: { claude: snapshot } };
|
|
535
698
|
}
|
|
699
|
+
async readUsage() {
|
|
700
|
+
try {
|
|
701
|
+
const usage = await withProbeQuery(this.config, undefined, (probe) => probe.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET({ skipBehaviors: true }));
|
|
702
|
+
// No windows at all: claude.ai's usage endpoint failed (e.g. rate-limited) and Claude had nothing recent cached.
|
|
703
|
+
if (usage.rate_limits_available && !usage.rate_limits)
|
|
704
|
+
throw new Error("claude.ai's usage endpoint did not answer");
|
|
705
|
+
this.usageError = usage.rate_limits_available ? undefined : "no plan limits for this login (API key or cloud provider)";
|
|
706
|
+
// Absent when Claude answers from its cached data: the last known stay.
|
|
707
|
+
if (usage.rate_limits?.model_scoped)
|
|
708
|
+
this.modelLimits = usage.rate_limits.model_scoped.filter((row) => typeof row.utilization === "number").map((row) => ({
|
|
709
|
+
name: row.display_name,
|
|
710
|
+
window: { usedPercent: Math.round(row.utilization), windowDurationMins: 10_080, resetsAt: row.resets_at ? Math.floor(Date.parse(row.resets_at) / 1000) : null },
|
|
711
|
+
}));
|
|
712
|
+
for (const type of ["five_hour", "seven_day", "seven_day_opus", "seven_day_sonnet"]) {
|
|
713
|
+
const window = usage.rate_limits?.[type];
|
|
714
|
+
if (typeof window?.utilization !== "number")
|
|
715
|
+
continue;
|
|
716
|
+
this.rateLimitWindows.set(type, {
|
|
717
|
+
status: window.utilization >= 100 ? "rejected" : "allowed",
|
|
718
|
+
usedPercent: Math.round(window.utilization),
|
|
719
|
+
windowDurationMins: WINDOW_MINUTES[type],
|
|
720
|
+
resetsAt: window.resets_at ? Math.floor(Date.parse(window.resets_at) / 1000) : null,
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
catch (error) {
|
|
725
|
+
this.usageError = `limits unavailable: ${error instanceof Error ? error.message : String(error)}`;
|
|
726
|
+
this.logger.warn("claude.usage.unavailable", { error: this.usageError });
|
|
727
|
+
}
|
|
728
|
+
}
|
|
536
729
|
rateLimitWindowsText() {
|
|
537
730
|
return [...this.rateLimitWindows.entries()].map(([key, value]) => `${key}: ${value.usedPercent}% used${value.resetsAt ? `, resets ${new Date(value.resetsAt * 1000).toISOString().slice(0, 16).replace("T", " ")} UTC` : ""} (${value.status})`);
|
|
538
731
|
}
|
|
539
732
|
// ---- models and skills ----
|
|
540
733
|
models() {
|
|
541
|
-
this.models_ ??=
|
|
542
|
-
const resolved = models.find((model) => model.value === "default")?.resolvedModel;
|
|
543
|
-
this.defaultModel = resolved ? normalizeClaudeModelIdentifier(resolved) : null;
|
|
544
|
-
for (const model of models) {
|
|
545
|
-
if (model.value !== "default" && model.resolvedModel)
|
|
546
|
-
this.pickerValues.set(normalizeClaudeModelIdentifier(model.resolvedModel), modelCatalogValue(model));
|
|
547
|
-
}
|
|
548
|
-
return models.filter((model) => model.value !== "default").map((model) => mapClaudeModel(model, this.config.modelPrefix));
|
|
549
|
-
}).catch((error) => {
|
|
734
|
+
this.models_ ??= this.probeModels().catch((error) => {
|
|
550
735
|
this.models_ = undefined;
|
|
551
736
|
throw error;
|
|
552
737
|
});
|
|
553
738
|
return this.models_;
|
|
554
739
|
}
|
|
740
|
+
get modelsPath() {
|
|
741
|
+
return join(this.config.dataDir, "claude-models.json");
|
|
742
|
+
}
|
|
743
|
+
cachedModels() {
|
|
744
|
+
try {
|
|
745
|
+
return JSON.parse(readFileSync(this.modelsPath, "utf8"));
|
|
746
|
+
}
|
|
747
|
+
catch {
|
|
748
|
+
return undefined;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
/** Asks Claude for its models and keeps them for the next start. */
|
|
752
|
+
async probeModels() {
|
|
753
|
+
const models = await withProbeQuery(this.config, undefined, async (probe) => {
|
|
754
|
+
const models = await probe.supportedModels();
|
|
755
|
+
// Claude's own context budget per model: its window, capped by settings such as CLAUDE_CODE_AUTO_COMPACT_WINDOW.
|
|
756
|
+
for (const model of models) {
|
|
757
|
+
await probe.setModel(model.value);
|
|
758
|
+
model.contextWindow = (await probe.getContextUsage({ detail: "summary" })).maxTokens;
|
|
759
|
+
}
|
|
760
|
+
return models;
|
|
761
|
+
});
|
|
762
|
+
const temporary = `${this.modelsPath}.${process.pid}.tmp`;
|
|
763
|
+
void writeFile(temporary, JSON.stringify(models), { mode: 0o600 }).then(() => rename(temporary, this.modelsPath)).catch(() => undefined);
|
|
764
|
+
return this.useModels(models);
|
|
765
|
+
}
|
|
766
|
+
useModels(models) {
|
|
767
|
+
const resolved = models.find((model) => model.value === "default")?.resolvedModel;
|
|
768
|
+
this.defaultModel = resolved ? normalizeClaudeModelIdentifier(resolved) : null;
|
|
769
|
+
for (const model of models) {
|
|
770
|
+
if (model.value !== "default" && model.resolvedModel)
|
|
771
|
+
this.pickerValues.set(normalizeClaudeModelIdentifier(model.resolvedModel), modelCatalogValue(model));
|
|
772
|
+
if (!model.contextWindow)
|
|
773
|
+
continue;
|
|
774
|
+
this.contextWindows.set(modelCatalogValue(model), model.contextWindow);
|
|
775
|
+
if (model.resolvedModel)
|
|
776
|
+
this.contextWindows.set(normalizeClaudeModelIdentifier(model.resolvedModel), model.contextWindow);
|
|
777
|
+
}
|
|
778
|
+
const mapped = models.filter((model) => model.value !== "default").map((model) => mapClaudeModel(model, this.config.modelPrefix));
|
|
779
|
+
for (const [index, model] of models.filter((model) => model.value !== "default").entries()) {
|
|
780
|
+
if (!model.supportedEffortLevels?.length)
|
|
781
|
+
continue;
|
|
782
|
+
const effort = mapped[index].defaultReasoningEffort;
|
|
783
|
+
this.defaultEfforts.set(modelCatalogValue(model), effort);
|
|
784
|
+
if (model.resolvedModel)
|
|
785
|
+
this.defaultEfforts.set(normalizeClaudeModelIdentifier(model.resolvedModel), effort);
|
|
786
|
+
}
|
|
787
|
+
return mapped;
|
|
788
|
+
}
|
|
789
|
+
/** The effort Claude runs at: the chat's, else its model's default as the picker shows it (Claude's own may differ). */
|
|
790
|
+
effort(settings) {
|
|
791
|
+
return settings.effort ?? this.defaultEfforts.get(normalizeClaudeModelIdentifier(settings.model || this.defaultModel || "")) ?? null;
|
|
792
|
+
}
|
|
793
|
+
/** The context window Claude works with for a model (picker value or resolved id; none: the default model). */
|
|
794
|
+
contextWindow(model) {
|
|
795
|
+
return this.contextWindows.get(normalizeClaudeModelIdentifier(model || this.defaultModel || "")) ?? DEFAULT_CONTEXT_WINDOW;
|
|
796
|
+
}
|
|
555
797
|
modelLabel(model) {
|
|
556
798
|
return model ? claudeModelLabel(model) : "Claude (default)";
|
|
557
799
|
}
|
|
558
800
|
pendingNames = new Map();
|
|
559
801
|
pickerValues = new Map();
|
|
802
|
+
defaultEfforts = new Map();
|
|
560
803
|
skillCache = new Map();
|
|
561
804
|
async skills(cwds) {
|
|
562
805
|
const entries = await Promise.all(cwds.map(async (cwd) => {
|
|
@@ -588,7 +831,10 @@ export class ClaudeThreads {
|
|
|
588
831
|
}
|
|
589
832
|
async sideTurn(side, params) {
|
|
590
833
|
const input = normalizeUserInput(params.input ?? []);
|
|
591
|
-
|
|
834
|
+
// Desktop's description turn (after a rename) wants JSON matching its schema; Claude's side question answers in prose.
|
|
835
|
+
const schema = params.outputSchema;
|
|
836
|
+
const typed = input.flatMap((item) => item.type === "text" ? [item.text] : []).join("\n");
|
|
837
|
+
const question = schema ? `${typed}\n\nAnswer with only a JSON object matching this JSON Schema, nothing else:\n${JSON.stringify(schema)}` : typed;
|
|
592
838
|
const id = randomUUID();
|
|
593
839
|
const started = Math.floor(Date.now() / 1000);
|
|
594
840
|
const turn = { id, items: [], itemsView: "full", status: "inProgress", error: null, startedAt: started, completedAt: null, durationMs: null };
|
|
@@ -607,6 +853,8 @@ export class ClaudeThreads {
|
|
|
607
853
|
text = await this.session(side.sourceId).askSideQuestion(history.length
|
|
608
854
|
? `Earlier in this side conversation:\n${history.join("\n")}\n\nNew question: ${question}`
|
|
609
855
|
: question);
|
|
856
|
+
if (schema)
|
|
857
|
+
text = JSON.stringify(JSON.parse(text.slice(text.indexOf("{"), text.lastIndexOf("}") + 1)));
|
|
610
858
|
}
|
|
611
859
|
catch (error) {
|
|
612
860
|
text = `Side question failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
@@ -633,12 +881,11 @@ export class ClaudeThreads {
|
|
|
633
881
|
switch (method) {
|
|
634
882
|
case "thread/start": return this.startThread(connection, params);
|
|
635
883
|
case "thread/resume": return this.resume(connection, params);
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
case "thread/
|
|
641
|
-
case "thread/items/list": return paginateItems((await this.read(threadId)).turns, params);
|
|
884
|
+
// All turns only when asked for them (deprecated for paginated threads; Desktop pages).
|
|
885
|
+
case "thread/read": return { thread: params.includeTurns ? await this.read(threadId).then(({ thread, turns }) => ({ ...thread, turns })) : await this.thread(threadId) };
|
|
886
|
+
case "thread/turns/list": return this.turnsPage(threadId, params);
|
|
887
|
+
case "thread/items/list": return this.itemsPage(threadId, params);
|
|
888
|
+
case "thread/searchOccurrences": return this.searchOccurrences(threadId, params);
|
|
642
889
|
case "turn/start": {
|
|
643
890
|
this.gateway.subscribe(threadId, connection);
|
|
644
891
|
return { turn: await this.session(threadId).startTurn(params) };
|
|
@@ -658,7 +905,7 @@ export class ClaudeThreads {
|
|
|
658
905
|
const archived = method === "thread/archive";
|
|
659
906
|
this.gateway.meta.setArchived(threadId, archived);
|
|
660
907
|
this.gateway.emit(threadId, archived ? "thread/archived" : "thread/unarchived", { threadId });
|
|
661
|
-
return archived ? {} : { thread:
|
|
908
|
+
return archived ? {} : { thread: await this.thread(threadId) };
|
|
662
909
|
}
|
|
663
910
|
case "thread/delete": return this.delete(threadId);
|
|
664
911
|
case "thread/fork": return this.fork(connection, params);
|
|
@@ -669,13 +916,18 @@ export class ClaudeThreads {
|
|
|
669
916
|
return {};
|
|
670
917
|
}
|
|
671
918
|
case "thread/settings/update": {
|
|
672
|
-
|
|
673
|
-
if (await session.updateSettings(params)) {
|
|
674
|
-
this.gateway.emit(threadId, "thread/settings/updated", { threadId, threadSettings: this.threadSettings(session.settings) });
|
|
675
|
-
}
|
|
919
|
+
await this.session(threadId).updateSettings(params);
|
|
676
920
|
return {};
|
|
677
921
|
}
|
|
678
|
-
|
|
922
|
+
// Claude's settings have no per-turn scope: the running turn's change stays for the chat.
|
|
923
|
+
case "turn/settings/update": {
|
|
924
|
+
const session = this.sessions.get(threadId);
|
|
925
|
+
if (!session?.busy)
|
|
926
|
+
return { status: "targetUnavailable" };
|
|
927
|
+
await session.updateSettings(params);
|
|
928
|
+
return { status: "applied" };
|
|
929
|
+
}
|
|
930
|
+
case "thread/metadata/update": return { thread: await this.thread(threadId) };
|
|
679
931
|
case "thread/attachment/list": return { data: [], nextCursor: null };
|
|
680
932
|
case "thread/inject_items": {
|
|
681
933
|
await this.session(threadId).inject((params.items ?? []).map((item) => JSON.stringify(item)).join("\n"));
|
|
@@ -687,9 +939,11 @@ export class ClaudeThreads {
|
|
|
687
939
|
return { goal: goal?.status === "complete" ? null : goal };
|
|
688
940
|
}
|
|
689
941
|
case "thread/goal/set": {
|
|
942
|
+
await this.catalog.refresh();
|
|
690
943
|
const now = Math.floor(Date.now() / 1000);
|
|
691
944
|
const current = this.goal(threadId);
|
|
692
945
|
if (params.objective) {
|
|
946
|
+
this.gateway.meta.setPausedGoal(threadId, null);
|
|
693
947
|
const session = this.session(threadId);
|
|
694
948
|
const goal = {
|
|
695
949
|
threadId, objective: params.objective, status: "active", tokenBudget: null, tokensUsed: 0, timeUsedSeconds: 0,
|
|
@@ -698,21 +952,41 @@ export class ClaudeThreads {
|
|
|
698
952
|
// Like stock, the goal's turn starts after the answer: Desktop shows the goal message itself on the answer.
|
|
699
953
|
setImmediate(() => {
|
|
700
954
|
this.gateway.emit(threadId, "thread/goal/updated", { threadId, turnId: null, goal });
|
|
701
|
-
session.
|
|
955
|
+
session.goal(params.objective, current?.status === "active")
|
|
702
956
|
.catch((error) => this.logger.warn("claude.goal.start-failed", { threadId, error: String(error) }));
|
|
703
957
|
});
|
|
704
958
|
return { goal };
|
|
705
959
|
}
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
960
|
+
// Claude's `/goal` has no pause: pausing clears it there (stopping its turn) and keeps it in meta.json; resuming
|
|
961
|
+
// sets it again.
|
|
962
|
+
const pausing = params.status === "paused" && current?.status === "active";
|
|
963
|
+
const resuming = params.status === "active" && current?.status === "paused";
|
|
964
|
+
if (!pausing && !resuming)
|
|
965
|
+
return { goal: current };
|
|
966
|
+
const goal = { ...current, status: params.status, updatedAt: now, timeUsedSeconds: Math.max(0, now - current.createdAt) };
|
|
967
|
+
this.gateway.meta.setPausedGoal(threadId, pausing ? { objective: goal.objective, createdAt: goal.createdAt, updatedAt: now } : null);
|
|
968
|
+
setImmediate(() => {
|
|
969
|
+
this.gateway.emit(threadId, "thread/goal/updated", { threadId, turnId: null, goal });
|
|
970
|
+
this.session(threadId).goal(pausing ? "clear" : goal.objective, pausing)
|
|
971
|
+
.catch((error) => this.logger.warn("claude.goal.update-failed", { threadId, error: String(error) }));
|
|
972
|
+
});
|
|
973
|
+
return { goal };
|
|
709
974
|
}
|
|
710
975
|
case "thread/goal/clear": {
|
|
976
|
+
await this.catalog.refresh();
|
|
711
977
|
const goal = this.goal(threadId);
|
|
712
|
-
if (goal
|
|
713
|
-
|
|
714
|
-
this.gateway.
|
|
715
|
-
|
|
978
|
+
if (!goal)
|
|
979
|
+
return { cleared: false };
|
|
980
|
+
this.gateway.meta.setPausedGoal(threadId, null);
|
|
981
|
+
setImmediate(() => {
|
|
982
|
+
this.gateway.emit(threadId, "thread/goal/cleared", { threadId });
|
|
983
|
+
// Claude drops a met goal itself.
|
|
984
|
+
if (goal.status === "active") {
|
|
985
|
+
this.session(threadId).goal("clear", true)
|
|
986
|
+
.catch((error) => this.logger.warn("claude.goal.clear-failed", { threadId, error: String(error) }));
|
|
987
|
+
}
|
|
988
|
+
});
|
|
989
|
+
return { cleared: true };
|
|
716
990
|
}
|
|
717
991
|
case "thread/queue/list": return { data: this.sessions.get(threadId)?.queued ?? [], nextCursor: null };
|
|
718
992
|
case "thread/queue/add": {
|
|
@@ -768,6 +1042,7 @@ export class ClaudeThreads {
|
|
|
768
1042
|
}
|
|
769
1043
|
case "thread/turns/list": return paginateTurns(side.turns, params);
|
|
770
1044
|
case "thread/items/list": return paginateItems(side.turns, params);
|
|
1045
|
+
case "thread/searchOccurrences": return occurrencesPage(turnOccurrences(side.turns, params.searchTerm), params);
|
|
771
1046
|
case "thread/unsubscribe":
|
|
772
1047
|
case "thread/delete":
|
|
773
1048
|
case "thread/archive":
|
|
@@ -795,13 +1070,14 @@ export class ClaudeThreads {
|
|
|
795
1070
|
async startThread(connection, params) {
|
|
796
1071
|
await this.models().catch(() => undefined);
|
|
797
1072
|
const settings = this.settingsFrom(params, {
|
|
798
|
-
cwd: params.cwd ?? process.cwd(), model: this.defaultModel, effort: null, fast: false, permissionMode: "default",
|
|
1073
|
+
cwd: params.cwd ?? process.cwd(), model: this.defaultModel, effort: null, fast: false, permissionMode: "default", plan: false,
|
|
799
1074
|
});
|
|
800
1075
|
const session = this.create(settings);
|
|
801
1076
|
const threadId = session.threadId;
|
|
802
1077
|
this.gateway.subscribe(threadId, connection);
|
|
803
1078
|
const thread = this.decorate(this.freshThread(session));
|
|
804
|
-
|
|
1079
|
+
// After the response, as stock: Desktop hides its new-chat draft's `thread/started` only once it knows the id.
|
|
1080
|
+
setImmediate(() => this.gateway.emit(threadId, "thread/started", { thread }));
|
|
805
1081
|
this.gateway.titles.track(threadId);
|
|
806
1082
|
this.prewarm(session);
|
|
807
1083
|
return { thread, ...this.settingsResponse(settings) };
|
|
@@ -830,10 +1106,11 @@ export class ClaudeThreads {
|
|
|
830
1106
|
async resume(connection, params) {
|
|
831
1107
|
const threadId = params.threadId;
|
|
832
1108
|
this.gateway.subscribe(threadId, connection);
|
|
833
|
-
const
|
|
1109
|
+
const thread = await this.thread(threadId);
|
|
1110
|
+
const { turns, usage } = await this.newestTurns(threadId, 1);
|
|
834
1111
|
// Like stock, the context meter follows a resume (Desktop's /status reads it).
|
|
835
1112
|
if (usage?.last) {
|
|
836
|
-
const modelContextWindow = this.
|
|
1113
|
+
const modelContextWindow = this.contextWindow(thread.model?.slice(this.config.modelPrefix.length));
|
|
837
1114
|
setImmediate(() => this.gateway.emit(threadId, "thread/tokenUsage/updated", { threadId, turnId: turns.at(-1)?.id ?? null, tokenUsage: { ...usage, modelContextWindow } }));
|
|
838
1115
|
}
|
|
839
1116
|
// A sub-agent has no session of its own: it shows the model and directory it runs with.
|
|
@@ -845,8 +1122,8 @@ export class ClaudeThreads {
|
|
|
845
1122
|
const response = {
|
|
846
1123
|
thread: { ...thread, turns: params.excludeTurns ? [] : turns },
|
|
847
1124
|
...this.settingsResponse(settings),
|
|
848
|
-
collaborationMode:
|
|
849
|
-
initialTurnsPage: params.initialTurnsPage ?
|
|
1125
|
+
collaborationMode: this.threadSettings(settings).collaborationMode,
|
|
1126
|
+
initialTurnsPage: params.initialTurnsPage ? await this.turnsPage(threadId, { ...params.initialTurnsPage, threadId }) : null,
|
|
850
1127
|
...historyCursors(turns),
|
|
851
1128
|
};
|
|
852
1129
|
return response;
|
|
@@ -866,6 +1143,33 @@ export class ClaudeThreads {
|
|
|
866
1143
|
this.gateway.emit(threadId, "thread/name/updated", { threadId, threadName: name });
|
|
867
1144
|
return {};
|
|
868
1145
|
}
|
|
1146
|
+
/** The goal of a Claude backend the chat switches away from, out of Claude (a running goal turn stops). */
|
|
1147
|
+
async takeGoal(threadId) {
|
|
1148
|
+
await this.catalog.refresh();
|
|
1149
|
+
const goal = this.goal(threadId);
|
|
1150
|
+
if (goal?.status === "active")
|
|
1151
|
+
await this.session(threadId).goal("clear", true);
|
|
1152
|
+
this.gateway.meta.setPausedGoal(threadId, null);
|
|
1153
|
+
return goal?.status === "complete" ? null : goal;
|
|
1154
|
+
}
|
|
1155
|
+
/** A goal the chat brings to this Claude backend as it switches (or keeps as a switch fails). */
|
|
1156
|
+
async giveGoal(threadId, goal) {
|
|
1157
|
+
if (goal.status === "paused")
|
|
1158
|
+
this.gateway.meta.setPausedGoal(threadId, { objective: goal.objective, createdAt: goal.createdAt, updatedAt: goal.updatedAt });
|
|
1159
|
+
else
|
|
1160
|
+
await this.session(threadId).goal(goal.objective, false);
|
|
1161
|
+
}
|
|
1162
|
+
/** A Claude backend the chat switched away from: nothing of it runs on (background tasks, process). */
|
|
1163
|
+
async retire(threadId) {
|
|
1164
|
+
const session = this.sessions.get(threadId);
|
|
1165
|
+
if (!session)
|
|
1166
|
+
return;
|
|
1167
|
+
this.sessions.delete(threadId);
|
|
1168
|
+
await session.stopTasks();
|
|
1169
|
+
const pids = sessionProcesses().filter((process) => process.session === threadId).map((process) => process.pid);
|
|
1170
|
+
await session.unload();
|
|
1171
|
+
killProcesses(pids);
|
|
1172
|
+
}
|
|
869
1173
|
/** Removes a session with its transcript (also what a failed switch to Claude leaves behind). */
|
|
870
1174
|
async discard(threadId) {
|
|
871
1175
|
await this.sessions.get(threadId)?.unload();
|
|
@@ -873,6 +1177,7 @@ export class ClaudeThreads {
|
|
|
873
1177
|
await this.catalog.refresh();
|
|
874
1178
|
if (this.catalog.get(threadId))
|
|
875
1179
|
await deleteSession(threadId);
|
|
1180
|
+
this.catalog.dropPages(threadId);
|
|
876
1181
|
this.gateway.meta.forget(threadId);
|
|
877
1182
|
await this.catalog.refresh();
|
|
878
1183
|
}
|
|
@@ -883,14 +1188,12 @@ export class ClaudeThreads {
|
|
|
883
1188
|
}
|
|
884
1189
|
/** Last transcript record of the turn before `turnId` (or of `turnId` itself when `inclusive`). */
|
|
885
1190
|
async boundaryBefore(threadId, turnId, inclusive) {
|
|
886
|
-
|
|
887
|
-
|
|
1191
|
+
if (!this.catalog.get(threadId))
|
|
1192
|
+
await this.catalog.refresh();
|
|
1193
|
+
if (!this.catalog.get(threadId))
|
|
888
1194
|
throw invalidParams(`thread not found: ${threadId}`);
|
|
889
|
-
const
|
|
890
|
-
|
|
891
|
-
throw invalidParams(`turn not found: ${turnId}`);
|
|
892
|
-
const kept = projection.turns[inclusive ? index : index - 1];
|
|
893
|
-
return kept ? projection.turnBoundaries.find((boundary) => boundary.turnId === kept.id)?.messageUuid ?? null : null;
|
|
1195
|
+
const { pages, source } = this.pages(threadId);
|
|
1196
|
+
return pages.boundary(source, turnId, inclusive).catch(() => { throw invalidParams(`turn not found: ${turnId}`); });
|
|
894
1197
|
}
|
|
895
1198
|
async fork(connection, params) {
|
|
896
1199
|
const sourceId = params.threadId;
|
|
@@ -904,19 +1207,20 @@ export class ClaudeThreads {
|
|
|
904
1207
|
}
|
|
905
1208
|
const upTo = params.lastTurnId ? await this.boundaryBefore(sourceId, params.lastTurnId, true)
|
|
906
1209
|
: params.beforeTurnId ? await this.boundaryBefore(sourceId, params.beforeTurnId, false)
|
|
907
|
-
: this.
|
|
1210
|
+
: this.leaves.get(sourceId) ?? null;
|
|
908
1211
|
const summary = this.catalog.get(sourceId);
|
|
909
1212
|
if (!summary)
|
|
910
1213
|
throw invalidParams(`thread not found: ${sourceId}`);
|
|
911
1214
|
const { sessionId } = await forkSession(sourceId, { ...(upTo ? { upToMessageId: upTo } : {}) });
|
|
912
1215
|
await this.catalog.refresh();
|
|
913
1216
|
this.gateway.subscribe(sessionId, connection);
|
|
914
|
-
const
|
|
1217
|
+
const thread = await this.thread(sessionId);
|
|
1218
|
+
const turns = params.excludeTurns ? [] : (await this.newestTurns(sessionId, 25)).turns;
|
|
915
1219
|
const forked = { ...thread, forkedFromId: sourceId };
|
|
916
|
-
this.gateway.emit(sessionId, "thread/started", { thread: forked });
|
|
1220
|
+
setImmediate(() => this.gateway.emit(sessionId, "thread/started", { thread: forked }));
|
|
917
1221
|
const settings = this.settingsFrom(params, this.settings(sourceId));
|
|
918
1222
|
this.sessions.set(sessionId, new ClaudeSession(this, sessionId, settings, { exists: true }));
|
|
919
|
-
return { thread: { ...forked, turns
|
|
1223
|
+
return { thread: { ...forked, turns }, ...this.settingsResponse(settings) };
|
|
920
1224
|
}
|
|
921
1225
|
async truncate(threadId, leaf) {
|
|
922
1226
|
const session = this.sessions.get(threadId);
|
|
@@ -926,32 +1230,35 @@ export class ClaudeThreads {
|
|
|
926
1230
|
await session?.unload();
|
|
927
1231
|
this.sessions.delete(threadId);
|
|
928
1232
|
if (leaf) {
|
|
929
|
-
this.
|
|
1233
|
+
this.leaves.set(threadId, leaf);
|
|
930
1234
|
return;
|
|
931
1235
|
}
|
|
932
1236
|
// Everything rolled back: Claude neither resumes nor starts anew a transcript without messages, so the session
|
|
933
1237
|
// starts over under its id with its settings, and its name comes back with its first turn.
|
|
934
1238
|
const name = this.catalog.get(threadId).customTitle;
|
|
935
1239
|
await deleteSession(threadId);
|
|
936
|
-
this.
|
|
1240
|
+
this.catalog.dropPages(threadId);
|
|
1241
|
+
this.leaves.delete(threadId);
|
|
937
1242
|
await this.catalog.refresh();
|
|
938
1243
|
this.sessions.set(threadId, new ClaudeSession(this, threadId, settings, { exists: false }));
|
|
939
1244
|
if (name)
|
|
940
1245
|
this.pendingNames.set(threadId, name);
|
|
941
1246
|
}
|
|
942
1247
|
async rollback(threadId, numTurns) {
|
|
943
|
-
|
|
944
|
-
|
|
1248
|
+
if (!this.catalog.get(threadId))
|
|
1249
|
+
await this.catalog.refresh();
|
|
1250
|
+
if (!this.catalog.get(threadId))
|
|
945
1251
|
throw invalidParams(`thread not found: ${threadId}`);
|
|
946
|
-
const
|
|
947
|
-
const
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
return { thread: { ...thread, turns } };
|
|
1252
|
+
const { pages, source } = this.pages(threadId);
|
|
1253
|
+
const { turns: history } = await pages.newest(source, numTurns + 1);
|
|
1254
|
+
const kept = history.at(-numTurns - 1);
|
|
1255
|
+
await this.truncate(threadId, kept ? await pages.boundary(source, kept.id, true) : null);
|
|
1256
|
+
return { thread: { ...await this.thread(threadId), turns: (await this.newestTurns(threadId, 25)).turns } };
|
|
951
1257
|
}
|
|
952
1258
|
async revert(threadId, beforeTurnId) {
|
|
953
1259
|
await this.truncate(threadId, await this.boundaryBefore(threadId, beforeTurnId, false));
|
|
954
|
-
const
|
|
1260
|
+
const thread = await this.thread(threadId);
|
|
1261
|
+
const { turns } = await this.newestTurns(threadId, 1);
|
|
955
1262
|
this.gateway.emit(threadId, "thread/reverted", { threadId });
|
|
956
1263
|
return { thread, ...historyCursors(turns) };
|
|
957
1264
|
}
|
|
@@ -997,14 +1304,15 @@ export class ClaudeThreads {
|
|
|
997
1304
|
effort: settings.effort,
|
|
998
1305
|
fast: settings.fast,
|
|
999
1306
|
permissionMode: settings.permissionMode,
|
|
1307
|
+
plan: settings.plan,
|
|
1308
|
+
cwd: settings.cwd,
|
|
1000
1309
|
loaded: session !== undefined,
|
|
1001
1310
|
process: session?.loaded ?? false,
|
|
1002
1311
|
actions: this.actions.get(threadId) ?? [],
|
|
1003
|
-
|
|
1312
|
+
turnId: session?.turn?.id ?? null,
|
|
1004
1313
|
usage: session?.totalUsage,
|
|
1005
|
-
contextWindow: session?.
|
|
1314
|
+
contextWindow: this.contextWindow(session?.liveModel ?? settings.model),
|
|
1006
1315
|
lastUsage: session?.lastUsage,
|
|
1007
|
-
costUsd: session?.costUsd ?? 0,
|
|
1008
1316
|
backgroundTasks: session?.tasks.size ?? 0,
|
|
1009
1317
|
};
|
|
1010
1318
|
}
|