@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
@@ -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, permissionModeFrom, withProbeQuery } from "./sdk.js";
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
- void this.models().catch(() => undefined);
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 /ccstate). */
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 ?? (summary.model && this.pickerModel(summary.model)),
186
- reasoningEffort: session?.settings.effort ?? summary.reasoningEffort,
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.permissionMode, cliVersion: null, goal: null,
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.gateway.meta.leaf(threadId));
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
- let turns = [...projection.turns];
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?.model ? this.pickerModel(summary.model) : this.defaultModel,
397
- effort: summary?.reasoningEffort ?? null,
531
+ model: (summary && this.recordedModel(summary)) || this.defaultModel,
532
+ effort: summary ? this.recordedEffort(summary) : null,
398
533
  fast: summary?.serviceTier === "fast",
399
- permissionMode: (summary?.permissionMode ?? "default"),
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.permissionMode === "plan" ? "plan" : "default",
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
- permissionMode: permissionModeFrom(params) ?? current.permissionMode,
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.gateway.meta.leaf(threadId);
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.gateway.meta.setLeaf(threadId, null);
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
- /** Claude's native `/goal` as a Codex ThreadGoal (Claude tracks no budget or time). */
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 goal = this.catalog.get(threadId)?.goal;
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?.rateLimitType)
650
+ if (!info)
504
651
  return;
505
- this.rateLimitWindows.set(info.rateLimitType, {
506
- status: info.status,
507
- usedPercent: Math.round(Number(info.utilization ?? (info.status === "rejected" ? 1 : 0)) * 100),
508
- windowDurationMins: WINDOW_MINUTES[info.rateLimitType] ?? null,
509
- resetsAt: typeof info.resetsAt === "number" ? info.resetsAt : null,
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_ ??= withProbeQuery(this.config, undefined, (probe) => probe.supportedModels()).then((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
- const question = input.flatMap((item) => item.type === "text" ? [item.text] : []).join("\n");
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
- case "thread/read": {
637
- const { thread, turns } = await this.read(threadId);
638
- return { thread: { ...thread, turns: params.includeTurns ? turns : [] } };
639
- }
640
- case "thread/turns/list": return paginateTurns((await this.read(threadId)).turns, params);
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: (await this.read(threadId)).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
- const session = this.session(threadId);
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
- case "thread/metadata/update": return { thread: (await this.read(threadId)).thread };
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.command(`/goal ${params.objective}`)
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
- if (params.status && params.status !== "active" && current?.status === "active")
707
- await this.session(threadId).command("/goal clear");
708
- return { goal: current ? { ...current, status: params.status ?? current.status, updatedAt: now } : null };
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?.status === "active")
713
- await this.session(threadId).command("/goal clear");
714
- this.gateway.emit(threadId, "thread/goal/cleared", { threadId });
715
- return { cleared: goal !== null };
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
- this.gateway.emit(threadId, "thread/started", { thread });
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 { thread, turns, usage } = await this.read(threadId);
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.contextWindows.get((thread.model ?? "").slice(this.config.modelPrefix.length)) ?? DEFAULT_CONTEXT_WINDOW;
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: null,
849
- initialTurnsPage: params.initialTurnsPage ? paginateTurns(turns, params.initialTurnsPage) : null,
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
- const projection = await this.projection(threadId);
887
- if (!projection)
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 index = projection.turns.findIndex((turn) => turn.id === turnId);
890
- if (index < 0)
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.gateway.meta.leaf(sourceId) ?? null;
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 { thread, turns } = await this.read(sessionId);
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: params.excludeTurns ? [] : turns }, ...this.settingsResponse(settings) };
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.gateway.meta.setLeaf(threadId, leaf);
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.gateway.meta.setLeaf(threadId, null);
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
- const projection = await this.projection(threadId);
944
- if (!projection)
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 keep = projection.turns.length - numTurns;
947
- const leaf = keep > 0 ? projection.turnBoundaries.find((boundary) => boundary.turnId === projection.turns[keep - 1].id)?.messageUuid ?? null : null;
948
- await this.truncate(threadId, leaf);
949
- const { thread, turns } = await this.read(threadId);
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 { thread, turns } = await this.read(threadId);
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
- running: session?.busy ?? false,
1312
+ turnId: session?.turn?.id ?? null,
1004
1313
  usage: session?.totalUsage,
1005
- contextWindow: session?.contextWindow ?? null,
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
  }