@gkorepanov/ccodex 0.5.0-rc.8 → 0.5.0

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 (88) hide show
  1. package/README.md +159 -157
  2. package/config/compatibility.json +1 -1
  3. package/dist/claude/delegation.d.ts +10 -0
  4. package/dist/claude/delegation.js +41 -0
  5. package/dist/claude/delegation.js.map +1 -0
  6. package/dist/claude/native/catalog.d.ts +5 -0
  7. package/dist/claude/native/catalog.js +26 -17
  8. package/dist/claude/native/catalog.js.map +1 -1
  9. package/dist/claude/native/history.js +4 -3
  10. package/dist/claude/native/history.js.map +1 -1
  11. package/dist/claude/native/pages.d.ts +2 -0
  12. package/dist/claude/native/pages.js +6 -1
  13. package/dist/claude/native/pages.js.map +1 -1
  14. package/dist/claude/native/projector.js +33 -12
  15. package/dist/claude/native/projector.js.map +1 -1
  16. package/dist/claude/native/records.js +19 -0
  17. package/dist/claude/native/records.js.map +1 -1
  18. package/dist/claude/native/search.d.ts +7 -0
  19. package/dist/claude/native/search.js +79 -0
  20. package/dist/claude/native/search.js.map +1 -0
  21. package/dist/claude/native/summary.d.ts +6 -1
  22. package/dist/claude/native/summary.js +24 -6
  23. package/dist/claude/native/summary.js.map +1 -1
  24. package/dist/claude/peers.d.ts +2 -0
  25. package/dist/claude/peers.js +13 -4
  26. package/dist/claude/peers.js.map +1 -1
  27. package/dist/claude/processes.d.ts +2 -0
  28. package/dist/claude/processes.js +15 -0
  29. package/dist/claude/processes.js.map +1 -1
  30. package/dist/claude/sdk.d.ts +6 -5
  31. package/dist/claude/sdk.js +15 -8
  32. package/dist/claude/sdk.js.map +1 -1
  33. package/dist/claude/session.d.ts +25 -5
  34. package/dist/claude/session.js +134 -29
  35. package/dist/claude/session.js.map +1 -1
  36. package/dist/claude/threads.d.ts +43 -3
  37. package/dist/claude/threads.js +255 -60
  38. package/dist/claude/threads.js.map +1 -1
  39. package/dist/claude/toolMapper.d.ts +7 -0
  40. package/dist/claude/toolMapper.js +14 -1
  41. package/dist/claude/toolMapper.js.map +1 -1
  42. package/dist/config.d.ts +2 -0
  43. package/dist/config.js +2 -1
  44. package/dist/config.js.map +1 -1
  45. package/dist/daemon/daemon.d.ts +3 -0
  46. package/dist/daemon/daemon.js +6 -3
  47. package/dist/daemon/daemon.js.map +1 -1
  48. package/dist/daemon/supervisor.d.ts +3 -0
  49. package/dist/daemon/supervisor.js +2 -0
  50. package/dist/daemon/supervisor.js.map +1 -1
  51. package/dist/desktop/stdioFrontend.js +1 -1
  52. package/dist/desktop/stdioFrontend.js.map +1 -1
  53. package/dist/gateway/catalog.js +35 -20
  54. package/dist/gateway/catalog.js.map +1 -1
  55. package/dist/gateway/connection.d.ts +2 -0
  56. package/dist/gateway/connection.js +14 -2
  57. package/dist/gateway/connection.js.map +1 -1
  58. package/dist/gateway/lineage.d.ts +9 -1
  59. package/dist/gateway/lineage.js +134 -37
  60. package/dist/gateway/lineage.js.map +1 -1
  61. package/dist/gateway/server.d.ts +2 -1
  62. package/dist/gateway/server.js +24 -12
  63. package/dist/gateway/server.js.map +1 -1
  64. package/dist/gateway/status.d.ts +15 -5
  65. package/dist/gateway/status.js +142 -47
  66. package/dist/gateway/status.js.map +1 -1
  67. package/dist/gateway/stock.js +17 -2
  68. package/dist/gateway/stock.js.map +1 -1
  69. package/dist/instructions.d.ts +4 -0
  70. package/dist/instructions.js +13 -0
  71. package/dist/instructions.js.map +1 -0
  72. package/dist/management/commands.js +19 -2
  73. package/dist/management/commands.js.map +1 -1
  74. package/dist/meta.d.ts +39 -11
  75. package/dist/meta.js +59 -28
  76. package/dist/meta.js.map +1 -1
  77. package/dist/protocol/codex.d.ts +1 -3
  78. package/dist/protocol/codex.js +1 -4
  79. package/dist/protocol/codex.js.map +1 -1
  80. package/dist/protocol/turnPagination.d.ts +27 -2
  81. package/dist/protocol/turnPagination.js +46 -18
  82. package/dist/protocol/turnPagination.js.map +1 -1
  83. package/examples/config.toml +6 -2
  84. package/instructions/ccodex_extra_claude_instructions.md +20 -0
  85. package/instructions/ccodex_extra_common_instructions.md +23 -0
  86. package/legal/THIRD_PARTY_NOTICES.md +9 -9
  87. package/package.json +6 -5
  88. package/scripts/migrate-0.4-to-0.5.mjs +29 -18
@@ -6,15 +6,17 @@ import { deleteSession, forkSession, renameSession } from "@anthropic-ai/claude-
6
6
  import { v7 as uuidv7 } from "uuid";
7
7
  import { packageVersion } from "../management/commands.js";
8
8
  import { invalidParams, invalidRequest, requestedModel } from "../protocol/codex.js";
9
- import { anchorCursor, 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";
10
11
  import { normalizeUserInput } from "./inputMapper.js";
11
12
  import { claudeModelLabel, modelCatalogValue, normalizeClaudeModelIdentifier } from "./modelSelection.js";
12
13
  import { NativeSessionCatalog } from "./native/catalog.js";
14
+ import { firstMatch } from "./native/search.js";
13
15
  import { nativeThread } from "./native/projector.js";
14
16
  import { projectSubagents } from "./native/subagents.js";
15
17
  import { readTranscriptRecords } from "./native/records.js";
16
- import { summarizeTranscript, userText } from "./native/summary.js";
17
- import { codexPermissions, mapClaudeModel, mapSkill, permissionSettings, withProbeQuery } from "./sdk.js";
18
+ import { preview, summarizeTranscript, userText } from "./native/summary.js";
19
+ import { claudeMode, codexPermissions, mapClaudeModel, mapSkill, permissionSettings, withProbeQuery } from "./sdk.js";
18
20
  import { killProcesses, sessionProcesses } from "./processes.js";
19
21
  import { ClaudeSession } from "./session.js";
20
22
  /** Claude's standard context window, until a session of the model reports its own. */
@@ -29,6 +31,13 @@ const IDLE_MS = Number(process.env.CCODEX_E2E_IDLE_MS) || 30 * 60_000;
29
31
  const RESUME_WARM_MS = Number(process.env.CCODEX_E2E_RESUME_WARM_MS) || 10_000;
30
32
  /** Claude processes kept at most (each holds its session in memory: 250–550 MB); tests lower it. */
31
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
+ }
32
41
  /** The Claude side of the gateway: catalog of native sessions, live sessions, side chats, models, skills. */
33
42
  export class ClaudeThreads {
34
43
  config;
@@ -39,6 +48,9 @@ export class ClaudeThreads {
39
48
  sides = new Map();
40
49
  /** `agent-<id>` sub-agent thread → the native session whose transcript directory holds it. */
41
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();
42
54
  /** Sub-agents spawned live, shown until Claude has written their transcript. */
43
55
  spawnedSubagents = new Map();
44
56
  /** Context window of each Claude model, as its sessions report it. */
@@ -48,6 +60,11 @@ export class ClaudeThreads {
48
60
  models_;
49
61
  defaultModel = null;
50
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 = [];
51
68
  stopWatching;
52
69
  onTurnCompleted;
53
70
  constructor(config, gateway, logger) {
@@ -78,7 +95,7 @@ export class ClaudeThreads {
78
95
  if (!known.has(id) && !this.sessions.has(id) && !this.gateway.meta.hidden(id)) {
79
96
  this.gateway.broadcast("thread/started", { thread: this.decorate(nativeThread(id, header, { status: this.status(id) })) });
80
97
  }
81
- else if (known.has(id) && known.get(id) !== name && name) {
98
+ else if (known.has(id) && known.get(id) !== name && name && !this.gateway.meta.hidden(id)) {
82
99
  this.gateway.emit(id, "thread/name/updated", { threadId: id, threadName: name });
83
100
  }
84
101
  known.set(id, name);
@@ -95,7 +112,7 @@ export class ClaudeThreads {
95
112
  /** CPU seconds each command of a loaded session used, and since when unchanged. */
96
113
  cpuSeen = new Map();
97
114
  sweeper;
98
- /** 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). */
99
116
  actions = new Map();
100
117
  act(session, event, text, pids) {
101
118
  this.logger.info(event, { threadId: session.threadId, pids });
@@ -135,7 +152,7 @@ export class ClaudeThreads {
135
152
  this.act(session, "claude.tasks.hung", `stopped its background tasks: their commands used no CPU for ${idle}`, pids);
136
153
  void session.stopTasks().catch(() => killProcesses(pids));
137
154
  }
138
- 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) {
139
156
  this.act(session, "claude.unloaded", `unloaded the chat: nobody had it open and it was quiet for ${idle}`, pids);
140
157
  this.sessions.delete(session.threadId);
141
158
  void session.unload().then(() => killProcesses(pids ?? []));
@@ -190,20 +207,34 @@ export class ClaudeThreads {
190
207
  return {
191
208
  ...summary,
192
209
  aiTitle: this.gateway.titles.naming(summary.sessionId) ? null : summary.aiTitle,
193
- model: session?.settings.model ?? (summary.model && this.pickerModel(summary.model)),
194
- reasoningEffort: session?.settings.effort ?? summary.reasoningEffort,
210
+ model: session?.settings.model ?? this.recordedModel(summary),
211
+ reasoningEffort: session?.settings.effort ?? this.recordedEffort(summary),
195
212
  };
196
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
+ }
197
225
  /** Transcripts name the resolved model (`claude-haiku-4-5-…`); the picker (and a live session) its value (`haiku`). */
198
226
  pickerModel(model) {
199
227
  const id = normalizeClaudeModelIdentifier(model);
200
228
  return this.pickerValues.get(id) ?? id;
201
229
  }
202
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
+ }
203
234
  threads() {
204
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) })));
205
236
  for (const session of this.sessions.values()) {
206
- if (!this.catalog.get(session.threadId))
237
+ if (session.started && !this.catalog.get(session.threadId))
207
238
  rows.push(this.decorate(this.freshThread(session)));
208
239
  }
209
240
  return rows;
@@ -224,7 +255,7 @@ export class ClaudeThreads {
224
255
  return nativeThread(session.threadId, {
225
256
  cwd: session.settings.cwd, gitBranch: null, createdAt: now, updatedAt: now, preview: "",
226
257
  customTitle: this.pendingNames.get(session.threadId) ?? null, aiTitle: null, model: session.settings.model, reasoningEffort: session.settings.effort,
227
- 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,
228
259
  }, { status: this.status(session.threadId) });
229
260
  }
230
261
  // ---- reads ----
@@ -233,7 +264,7 @@ export class ClaudeThreads {
233
264
  await this.catalog.refresh();
234
265
  if (!this.catalog.get(threadId))
235
266
  return undefined;
236
- return this.catalog.projection(threadId, this.gateway.meta.leaf(threadId));
267
+ return this.catalog.projection(threadId, this.leaves.get(threadId));
237
268
  }
238
269
  subagentRoot(threadId) {
239
270
  const known = this.subagentRoots.get(threadId);
@@ -286,7 +317,7 @@ export class ClaudeThreads {
286
317
  subagentSpawned(session, item, running) {
287
318
  const childId = item.receiverThreadIds[0];
288
319
  const now = Math.floor(Date.now() / 1000);
289
- 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 };
290
321
  const thread = nativeThread(childId, header, {
291
322
  status: { type: "active", activeFlags: [] },
292
323
  subagent: { parentThreadId: session.threadId, depth: 1, nickname: `${item.agentsStates[childId].message} [${claudeModelLabel(item.model ?? "Claude")}]` },
@@ -403,7 +434,7 @@ export class ClaudeThreads {
403
434
  return !threadId.startsWith("agent-") && !this.sides.has(threadId) && this.catalog.has(threadId);
404
435
  }
405
436
  pages(threadId) {
406
- return this.catalog.pages(threadId, this.gateway.meta.leaf(threadId));
437
+ return this.catalog.pages(threadId, this.leaves.get(threadId));
407
438
  }
408
439
  /** The running turn is the newest one, before Claude writes any of it too (a turn it goes on in after an answer). */
409
440
  running(threadId, turnId) {
@@ -418,8 +449,10 @@ export class ClaudeThreads {
418
449
  items: turn.items.map((item) => running.has(item.id) ? { ...item, status: "inProgress", exitCode: null, aggregatedOutput: null, durationMs: null } : item),
419
450
  })) : [...history];
420
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.
421
454
  if (!live)
422
- return turns;
455
+ return threadId.startsWith("agent-") ? turns : turns.map((turn) => turn.status === "inProgress" ? { ...turn, status: "interrupted" } : turn);
423
456
  const index = turns.findIndex((turn) => turn.id === live.id);
424
457
  if (index >= 0)
425
458
  turns = [...turns.slice(0, index), { ...turns[index], status: "inProgress", completedAt: null }];
@@ -452,6 +485,22 @@ export class ClaudeThreads {
452
485
  : await pages.since(source, anchor.anchor);
453
486
  return paginateTurns(this.withLive(threadId, window.turns), params);
454
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
+ }
455
504
  async itemsPage(threadId, params) {
456
505
  if (!this.catalog.get(threadId))
457
506
  await this.catalog.refresh();
@@ -479,10 +528,10 @@ export class ClaudeThreads {
479
528
  const summary = this.catalog.get(threadId);
480
529
  return {
481
530
  cwd: summary?.cwd ?? process.cwd(),
482
- model: summary?.model ? this.pickerModel(summary.model) : this.defaultModel,
483
- effort: summary?.reasoningEffort ?? null,
531
+ model: (summary && this.recordedModel(summary)) || this.defaultModel,
532
+ effort: summary ? this.recordedEffort(summary) : null,
484
533
  fast: summary?.serviceTier === "fast",
485
- permissionMode: (summary?.permissionMode ?? "default"),
534
+ ...recordedPermissions(threadId, summary?.permissionMode, this.gateway.meta),
486
535
  };
487
536
  }
488
537
  /** Common fields of thread/start|resume|fork responses and thread/settings/updated. */
@@ -519,7 +568,7 @@ export class ClaudeThreads {
519
568
  effort: settings.effort,
520
569
  summary: null,
521
570
  collaborationMode: {
522
- mode: settings.permissionMode === "plan" ? "plan" : "default",
571
+ mode: settings.plan ? "plan" : "default",
523
572
  settings: { model: response.model, reasoning_effort: settings.effort, developer_instructions: null },
524
573
  },
525
574
  multiAgentMode: "explicitRequestOnly",
@@ -545,14 +594,14 @@ export class ClaudeThreads {
545
594
  return session;
546
595
  if (!this.catalog.get(threadId))
547
596
  throw invalidParams(`thread not found: ${threadId}`);
548
- const leaf = this.gateway.meta.leaf(threadId);
597
+ const leaf = this.leaves.get(threadId);
549
598
  session = new ClaudeSession(this, threadId, this.settings(threadId), { exists: true, ...(leaf ? { resumeAt: leaf } : {}) });
550
599
  this.sessions.set(threadId, session);
551
600
  return session;
552
601
  }
553
602
  /** The session continued from the rollback leaf: its new records end the history from now on. */
554
603
  resumedAtLeaf(threadId) {
555
- this.gateway.meta.setLeaf(threadId, null);
604
+ this.leaves.delete(threadId);
556
605
  }
557
606
  turnCompleted(session, turnId) {
558
607
  const threadId = session.threadId;
@@ -565,6 +614,9 @@ export class ClaudeThreads {
565
614
  const goal = this.goal(threadId);
566
615
  if (JSON.stringify(goal) === before)
567
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;
568
620
  if (goal)
569
621
  this.gateway.emit(threadId, "thread/goal/updated", { threadId, turnId, goal });
570
622
  else
@@ -572,13 +624,22 @@ export class ClaudeThreads {
572
624
  });
573
625
  this.onTurnCompleted?.(threadId, turnId);
574
626
  }
575
- /** 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
+ */
576
631
  goal(threadId) {
577
- 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 };
578
639
  if (!goal)
579
640
  return null;
580
641
  return {
581
- 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,
582
643
  timeUsedSeconds: Math.max(0, goal.updatedAt - goal.createdAt), createdAt: goal.createdAt, updatedAt: goal.updatedAt,
583
644
  };
584
645
  }
@@ -586,14 +647,26 @@ export class ClaudeThreads {
586
647
  // Sub-agent activity is shown by the parent's collabAgentToolCall item; child history is read from disk.
587
648
  }
588
649
  onRateLimit(info) {
589
- if (!info?.rateLimitType)
650
+ if (!info)
590
651
  return;
591
- this.rateLimitWindows.set(info.rateLimitType, {
592
- status: info.status,
593
- usedPercent: Math.round(Number(info.utilization ?? (info.status === "rejected" ? 1 : 0)) * 100),
594
- windowDurationMins: WINDOW_MINUTES[info.rateLimitType] ?? null,
595
- resetsAt: typeof info.resetsAt === "number" ? info.resetsAt : null,
596
- });
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
+ }
597
670
  const snapshot = this.rateLimitSnapshot();
598
671
  for (const connection of this.gateway.connections) {
599
672
  if (connection.provider === "claude")
@@ -615,10 +688,44 @@ export class ClaudeThreads {
615
688
  rateLimitReachedType: [...this.rateLimitWindows.values()].some((value) => value.status === "rejected") ? "rate_limit_reached" : null,
616
689
  };
617
690
  }
691
+ /** Claude's plan limits: read from Claude's `/usage` data at most once a minute, and as turns report them. */
618
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;
619
696
  const snapshot = this.rateLimitSnapshot();
620
697
  return { rateLimits: snapshot, rateLimitsByLimitId: { claude: snapshot } };
621
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
+ }
622
729
  rateLimitWindowsText() {
623
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})`);
624
731
  }
@@ -635,7 +742,9 @@ export class ClaudeThreads {
635
742
  }
636
743
  cachedModels() {
637
744
  try {
638
- return JSON.parse(readFileSync(this.modelsPath, "utf8"));
745
+ const cache = JSON.parse(readFileSync(this.modelsPath, "utf8"));
746
+ // As stock's models cache: one another version wrote (another Claude, another model list) is stale.
747
+ return cache.version === packageVersion() ? cache.models : undefined;
639
748
  }
640
749
  catch {
641
750
  return undefined;
@@ -643,9 +752,17 @@ export class ClaudeThreads {
643
752
  }
644
753
  /** Asks Claude for its models and keeps them for the next start. */
645
754
  async probeModels() {
646
- const models = await withProbeQuery(this.config, undefined, (probe) => probe.supportedModels());
755
+ const models = await withProbeQuery(this.config, undefined, async (probe) => {
756
+ const models = await probe.supportedModels();
757
+ // Claude's own context budget per model: its window, capped by settings such as CLAUDE_CODE_AUTO_COMPACT_WINDOW.
758
+ for (const model of models) {
759
+ await probe.setModel(model.value);
760
+ model.contextWindow = (await probe.getContextUsage({ detail: "summary" })).maxTokens;
761
+ }
762
+ return models;
763
+ });
647
764
  const temporary = `${this.modelsPath}.${process.pid}.tmp`;
648
- void writeFile(temporary, JSON.stringify(models), { mode: 0o600 }).then(() => rename(temporary, this.modelsPath)).catch(() => undefined);
765
+ void writeFile(temporary, JSON.stringify({ version: packageVersion(), models }), { mode: 0o600 }).then(() => rename(temporary, this.modelsPath)).catch(() => undefined);
649
766
  return this.useModels(models);
650
767
  }
651
768
  useModels(models) {
@@ -654,14 +771,37 @@ export class ClaudeThreads {
654
771
  for (const model of models) {
655
772
  if (model.value !== "default" && model.resolvedModel)
656
773
  this.pickerValues.set(normalizeClaudeModelIdentifier(model.resolvedModel), modelCatalogValue(model));
774
+ if (!model.contextWindow)
775
+ continue;
776
+ this.contextWindows.set(modelCatalogValue(model), model.contextWindow);
777
+ if (model.resolvedModel)
778
+ this.contextWindows.set(normalizeClaudeModelIdentifier(model.resolvedModel), model.contextWindow);
657
779
  }
658
- return models.filter((model) => model.value !== "default").map((model) => mapClaudeModel(model, this.config.modelPrefix));
780
+ const mapped = models.filter((model) => model.value !== "default").map((model) => mapClaudeModel(model, this.config.modelPrefix));
781
+ for (const [index, model] of models.filter((model) => model.value !== "default").entries()) {
782
+ if (!model.supportedEffortLevels?.length)
783
+ continue;
784
+ const effort = mapped[index].defaultReasoningEffort;
785
+ this.defaultEfforts.set(modelCatalogValue(model), effort);
786
+ if (model.resolvedModel)
787
+ this.defaultEfforts.set(normalizeClaudeModelIdentifier(model.resolvedModel), effort);
788
+ }
789
+ return mapped;
790
+ }
791
+ /** The effort Claude runs at: the chat's, else its model's default as the picker shows it (Claude's own may differ). */
792
+ effort(settings) {
793
+ return settings.effort ?? this.defaultEfforts.get(normalizeClaudeModelIdentifier(settings.model || this.defaultModel || "")) ?? null;
794
+ }
795
+ /** The context window Claude works with for a model (picker value or resolved id; none: the default model). */
796
+ contextWindow(model) {
797
+ return this.contextWindows.get(normalizeClaudeModelIdentifier(model || this.defaultModel || "")) ?? DEFAULT_CONTEXT_WINDOW;
659
798
  }
660
799
  modelLabel(model) {
661
800
  return model ? claudeModelLabel(model) : "Claude (default)";
662
801
  }
663
802
  pendingNames = new Map();
664
803
  pickerValues = new Map();
804
+ defaultEfforts = new Map();
665
805
  skillCache = new Map();
666
806
  async skills(cwds) {
667
807
  const entries = await Promise.all(cwds.map(async (cwd) => {
@@ -693,7 +833,10 @@ export class ClaudeThreads {
693
833
  }
694
834
  async sideTurn(side, params) {
695
835
  const input = normalizeUserInput(params.input ?? []);
696
- const question = input.flatMap((item) => item.type === "text" ? [item.text] : []).join("\n");
836
+ // Desktop's description turn (after a rename) wants JSON matching its schema; Claude's side question answers in prose.
837
+ const schema = params.outputSchema;
838
+ const typed = input.flatMap((item) => item.type === "text" ? [item.text] : []).join("\n");
839
+ const question = schema ? `${typed}\n\nAnswer with only a JSON object matching this JSON Schema, nothing else:\n${JSON.stringify(schema)}` : typed;
697
840
  const id = randomUUID();
698
841
  const started = Math.floor(Date.now() / 1000);
699
842
  const turn = { id, items: [], itemsView: "full", status: "inProgress", error: null, startedAt: started, completedAt: null, durationMs: null };
@@ -712,6 +855,8 @@ export class ClaudeThreads {
712
855
  text = await this.session(side.sourceId).askSideQuestion(history.length
713
856
  ? `Earlier in this side conversation:\n${history.join("\n")}\n\nNew question: ${question}`
714
857
  : question);
858
+ if (schema)
859
+ text = JSON.stringify(JSON.parse(text.slice(text.indexOf("{"), text.lastIndexOf("}") + 1)));
715
860
  }
716
861
  catch (error) {
717
862
  text = `Side question failed: ${error instanceof Error ? error.message : String(error)}`;
@@ -742,6 +887,7 @@ export class ClaudeThreads {
742
887
  case "thread/read": return { thread: params.includeTurns ? await this.read(threadId).then(({ thread, turns }) => ({ ...thread, turns })) : await this.thread(threadId) };
743
888
  case "thread/turns/list": return this.turnsPage(threadId, params);
744
889
  case "thread/items/list": return this.itemsPage(threadId, params);
890
+ case "thread/searchOccurrences": return this.searchOccurrences(threadId, params);
745
891
  case "turn/start": {
746
892
  this.gateway.subscribe(threadId, connection);
747
893
  return { turn: await this.session(threadId).startTurn(params) };
@@ -772,10 +918,7 @@ export class ClaudeThreads {
772
918
  return {};
773
919
  }
774
920
  case "thread/settings/update": {
775
- const session = this.session(threadId);
776
- if (await session.updateSettings(params)) {
777
- this.gateway.emit(threadId, "thread/settings/updated", { threadId, threadSettings: this.threadSettings(session.settings) });
778
- }
921
+ await this.session(threadId).updateSettings(params);
779
922
  return {};
780
923
  }
781
924
  // Claude's settings have no per-turn scope: the running turn's change stays for the chat.
@@ -798,9 +941,11 @@ export class ClaudeThreads {
798
941
  return { goal: goal?.status === "complete" ? null : goal };
799
942
  }
800
943
  case "thread/goal/set": {
944
+ await this.catalog.refresh();
801
945
  const now = Math.floor(Date.now() / 1000);
802
946
  const current = this.goal(threadId);
803
947
  if (params.objective) {
948
+ this.gateway.meta.setPausedGoal(threadId, null);
804
949
  const session = this.session(threadId);
805
950
  const goal = {
806
951
  threadId, objective: params.objective, status: "active", tokenBudget: null, tokensUsed: 0, timeUsedSeconds: 0,
@@ -809,21 +954,41 @@ export class ClaudeThreads {
809
954
  // Like stock, the goal's turn starts after the answer: Desktop shows the goal message itself on the answer.
810
955
  setImmediate(() => {
811
956
  this.gateway.emit(threadId, "thread/goal/updated", { threadId, turnId: null, goal });
812
- session.command(`/goal ${params.objective}`)
957
+ session.goal(params.objective, current?.status === "active")
813
958
  .catch((error) => this.logger.warn("claude.goal.start-failed", { threadId, error: String(error) }));
814
959
  });
815
960
  return { goal };
816
961
  }
817
- if (params.status && params.status !== "active" && current?.status === "active")
818
- await this.session(threadId).command("/goal clear");
819
- return { goal: current ? { ...current, status: params.status ?? current.status, updatedAt: now } : null };
962
+ // Claude's `/goal` has no pause: pausing clears it there (stopping its turn) and keeps it in meta.json; resuming
963
+ // sets it again.
964
+ const pausing = params.status === "paused" && current?.status === "active";
965
+ const resuming = params.status === "active" && current?.status === "paused";
966
+ if (!pausing && !resuming)
967
+ return { goal: current };
968
+ const goal = { ...current, status: params.status, updatedAt: now, timeUsedSeconds: Math.max(0, now - current.createdAt) };
969
+ this.gateway.meta.setPausedGoal(threadId, pausing ? { objective: goal.objective, createdAt: goal.createdAt, updatedAt: now } : null);
970
+ setImmediate(() => {
971
+ this.gateway.emit(threadId, "thread/goal/updated", { threadId, turnId: null, goal });
972
+ this.session(threadId).goal(pausing ? "clear" : goal.objective, pausing)
973
+ .catch((error) => this.logger.warn("claude.goal.update-failed", { threadId, error: String(error) }));
974
+ });
975
+ return { goal };
820
976
  }
821
977
  case "thread/goal/clear": {
978
+ await this.catalog.refresh();
822
979
  const goal = this.goal(threadId);
823
- if (goal?.status === "active")
824
- await this.session(threadId).command("/goal clear");
825
- this.gateway.emit(threadId, "thread/goal/cleared", { threadId });
826
- return { cleared: goal !== null };
980
+ if (!goal)
981
+ return { cleared: false };
982
+ this.gateway.meta.setPausedGoal(threadId, null);
983
+ setImmediate(() => {
984
+ this.gateway.emit(threadId, "thread/goal/cleared", { threadId });
985
+ // Claude drops a met goal itself.
986
+ if (goal.status === "active") {
987
+ this.session(threadId).goal("clear", true)
988
+ .catch((error) => this.logger.warn("claude.goal.clear-failed", { threadId, error: String(error) }));
989
+ }
990
+ });
991
+ return { cleared: true };
827
992
  }
828
993
  case "thread/queue/list": return { data: this.sessions.get(threadId)?.queued ?? [], nextCursor: null };
829
994
  case "thread/queue/add": {
@@ -843,7 +1008,7 @@ export class ClaudeThreads {
843
1008
  case "thread/backgroundTerminals/list": {
844
1009
  const session = this.sessions.get(threadId);
845
1010
  return {
846
- data: [...session?.tasks.values() ?? []].filter((task) => task.taskType === "local_bash").map((task) => ({
1011
+ data: (session?.backgroundTasks ?? []).filter((task) => task.taskType === "local_bash").map((task) => ({
847
1012
  itemId: task.toolUseId ?? task.taskId, processId: task.taskId, command: task.description, cwd: session.settings.cwd,
848
1013
  osPid: null, cpuPercent: null, rssKb: null,
849
1014
  })),
@@ -879,6 +1044,7 @@ export class ClaudeThreads {
879
1044
  }
880
1045
  case "thread/turns/list": return paginateTurns(side.turns, params);
881
1046
  case "thread/items/list": return paginateItems(side.turns, params);
1047
+ case "thread/searchOccurrences": return occurrencesPage(turnOccurrences(side.turns, params.searchTerm), params);
882
1048
  case "thread/unsubscribe":
883
1049
  case "thread/delete":
884
1050
  case "thread/archive":
@@ -906,13 +1072,14 @@ export class ClaudeThreads {
906
1072
  async startThread(connection, params) {
907
1073
  await this.models().catch(() => undefined);
908
1074
  const settings = this.settingsFrom(params, {
909
- cwd: params.cwd ?? process.cwd(), model: this.defaultModel, effort: null, fast: false, permissionMode: "default",
1075
+ cwd: params.cwd ?? process.cwd(), model: this.defaultModel, effort: null, fast: false, permissionMode: "default", plan: false,
910
1076
  });
911
1077
  const session = this.create(settings);
912
1078
  const threadId = session.threadId;
913
1079
  this.gateway.subscribe(threadId, connection);
914
1080
  const thread = this.decorate(this.freshThread(session));
915
- this.gateway.emit(threadId, "thread/started", { thread });
1081
+ // After the response, as stock: Desktop hides its new-chat draft's `thread/started` only once it knows the id.
1082
+ setImmediate(() => this.gateway.emit(threadId, "thread/started", { thread }));
916
1083
  this.gateway.titles.track(threadId);
917
1084
  this.prewarm(session);
918
1085
  return { thread, ...this.settingsResponse(settings) };
@@ -945,7 +1112,7 @@ export class ClaudeThreads {
945
1112
  const { turns, usage } = await this.newestTurns(threadId, 1);
946
1113
  // Like stock, the context meter follows a resume (Desktop's /status reads it).
947
1114
  if (usage?.last) {
948
- const modelContextWindow = this.contextWindows.get((thread.model ?? "").slice(this.config.modelPrefix.length)) ?? DEFAULT_CONTEXT_WINDOW;
1115
+ const modelContextWindow = this.contextWindow(thread.model?.slice(this.config.modelPrefix.length));
949
1116
  setImmediate(() => this.gateway.emit(threadId, "thread/tokenUsage/updated", { threadId, turnId: turns.at(-1)?.id ?? null, tokenUsage: { ...usage, modelContextWindow } }));
950
1117
  }
951
1118
  // A sub-agent has no session of its own: it shows the model and directory it runs with.
@@ -957,7 +1124,7 @@ export class ClaudeThreads {
957
1124
  const response = {
958
1125
  thread: { ...thread, turns: params.excludeTurns ? [] : turns },
959
1126
  ...this.settingsResponse(settings),
960
- collaborationMode: null,
1127
+ collaborationMode: this.threadSettings(settings).collaborationMode,
961
1128
  initialTurnsPage: params.initialTurnsPage ? await this.turnsPage(threadId, { ...params.initialTurnsPage, threadId }) : null,
962
1129
  ...historyCursors(turns),
963
1130
  };
@@ -978,6 +1145,33 @@ export class ClaudeThreads {
978
1145
  this.gateway.emit(threadId, "thread/name/updated", { threadId, threadName: name });
979
1146
  return {};
980
1147
  }
1148
+ /** The goal of a Claude backend the chat switches away from, out of Claude (a running goal turn stops). */
1149
+ async takeGoal(threadId) {
1150
+ await this.catalog.refresh();
1151
+ const goal = this.goal(threadId);
1152
+ if (goal?.status === "active")
1153
+ await this.session(threadId).goal("clear", true);
1154
+ this.gateway.meta.setPausedGoal(threadId, null);
1155
+ return goal?.status === "complete" ? null : goal;
1156
+ }
1157
+ /** A goal the chat brings to this Claude backend as it switches (or keeps as a switch fails). */
1158
+ async giveGoal(threadId, goal) {
1159
+ if (goal.status === "paused")
1160
+ this.gateway.meta.setPausedGoal(threadId, { objective: goal.objective, createdAt: goal.createdAt, updatedAt: goal.updatedAt });
1161
+ else
1162
+ await this.session(threadId).goal(goal.objective, false);
1163
+ }
1164
+ /** A Claude backend the chat switched away from: nothing of it runs on (background tasks, process). */
1165
+ async retire(threadId) {
1166
+ const session = this.sessions.get(threadId);
1167
+ if (!session)
1168
+ return;
1169
+ this.sessions.delete(threadId);
1170
+ await session.stopTasks();
1171
+ const pids = sessionProcesses().filter((process) => process.session === threadId).map((process) => process.pid);
1172
+ await session.unload();
1173
+ killProcesses(pids);
1174
+ }
981
1175
  /** Removes a session with its transcript (also what a failed switch to Claude leaves behind). */
982
1176
  async discard(threadId) {
983
1177
  await this.sessions.get(threadId)?.unload();
@@ -1015,7 +1209,7 @@ export class ClaudeThreads {
1015
1209
  }
1016
1210
  const upTo = params.lastTurnId ? await this.boundaryBefore(sourceId, params.lastTurnId, true)
1017
1211
  : params.beforeTurnId ? await this.boundaryBefore(sourceId, params.beforeTurnId, false)
1018
- : this.gateway.meta.leaf(sourceId) ?? null;
1212
+ : this.leaves.get(sourceId) ?? null;
1019
1213
  const summary = this.catalog.get(sourceId);
1020
1214
  if (!summary)
1021
1215
  throw invalidParams(`thread not found: ${sourceId}`);
@@ -1025,7 +1219,7 @@ export class ClaudeThreads {
1025
1219
  const thread = await this.thread(sessionId);
1026
1220
  const turns = params.excludeTurns ? [] : (await this.newestTurns(sessionId, 25)).turns;
1027
1221
  const forked = { ...thread, forkedFromId: sourceId };
1028
- this.gateway.emit(sessionId, "thread/started", { thread: forked });
1222
+ setImmediate(() => this.gateway.emit(sessionId, "thread/started", { thread: forked }));
1029
1223
  const settings = this.settingsFrom(params, this.settings(sourceId));
1030
1224
  this.sessions.set(sessionId, new ClaudeSession(this, sessionId, settings, { exists: true }));
1031
1225
  return { thread: { ...forked, turns }, ...this.settingsResponse(settings) };
@@ -1038,7 +1232,7 @@ export class ClaudeThreads {
1038
1232
  await session?.unload();
1039
1233
  this.sessions.delete(threadId);
1040
1234
  if (leaf) {
1041
- this.gateway.meta.setLeaf(threadId, leaf);
1235
+ this.leaves.set(threadId, leaf);
1042
1236
  return;
1043
1237
  }
1044
1238
  // Everything rolled back: Claude neither resumes nor starts anew a transcript without messages, so the session
@@ -1046,7 +1240,7 @@ export class ClaudeThreads {
1046
1240
  const name = this.catalog.get(threadId).customTitle;
1047
1241
  await deleteSession(threadId);
1048
1242
  this.catalog.dropPages(threadId);
1049
- this.gateway.meta.setLeaf(threadId, null);
1243
+ this.leaves.delete(threadId);
1050
1244
  await this.catalog.refresh();
1051
1245
  this.sessions.set(threadId, new ClaudeSession(this, threadId, settings, { exists: false }));
1052
1246
  if (name)
@@ -1112,15 +1306,16 @@ export class ClaudeThreads {
1112
1306
  effort: settings.effort,
1113
1307
  fast: settings.fast,
1114
1308
  permissionMode: settings.permissionMode,
1309
+ plan: settings.plan,
1310
+ cwd: settings.cwd,
1115
1311
  loaded: session !== undefined,
1116
1312
  process: session?.loaded ?? false,
1117
1313
  actions: this.actions.get(threadId) ?? [],
1118
- running: session?.busy ?? false,
1314
+ turnId: session?.turn?.id ?? null,
1119
1315
  usage: session?.totalUsage,
1120
- contextWindow: session?.contextWindow ?? null,
1316
+ contextWindow: this.contextWindow(session?.liveModel ?? settings.model),
1121
1317
  lastUsage: session?.lastUsage,
1122
- costUsd: session?.costUsd ?? 0,
1123
- backgroundTasks: session?.tasks.size ?? 0,
1318
+ backgroundTasks: session?.backgroundTasks.length ?? 0,
1124
1319
  };
1125
1320
  }
1126
1321
  /** The summary of Claude's latest compaction if nothing was said since ("" otherwise). */