@gkorepanov/ccodex 0.5.0-rc.9 → 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 +252 -59
  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) {
@@ -454,6 +485,22 @@ export class ClaudeThreads {
454
485
  : await pages.since(source, anchor.anchor);
455
486
  return paginateTurns(this.withLive(threadId, window.turns), params);
456
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
+ }
457
504
  async itemsPage(threadId, params) {
458
505
  if (!this.catalog.get(threadId))
459
506
  await this.catalog.refresh();
@@ -481,10 +528,10 @@ export class ClaudeThreads {
481
528
  const summary = this.catalog.get(threadId);
482
529
  return {
483
530
  cwd: summary?.cwd ?? process.cwd(),
484
- model: summary?.model ? this.pickerModel(summary.model) : this.defaultModel,
485
- effort: summary?.reasoningEffort ?? null,
531
+ model: (summary && this.recordedModel(summary)) || this.defaultModel,
532
+ effort: summary ? this.recordedEffort(summary) : null,
486
533
  fast: summary?.serviceTier === "fast",
487
- permissionMode: (summary?.permissionMode ?? "default"),
534
+ ...recordedPermissions(threadId, summary?.permissionMode, this.gateway.meta),
488
535
  };
489
536
  }
490
537
  /** Common fields of thread/start|resume|fork responses and thread/settings/updated. */
@@ -521,7 +568,7 @@ export class ClaudeThreads {
521
568
  effort: settings.effort,
522
569
  summary: null,
523
570
  collaborationMode: {
524
- mode: settings.permissionMode === "plan" ? "plan" : "default",
571
+ mode: settings.plan ? "plan" : "default",
525
572
  settings: { model: response.model, reasoning_effort: settings.effort, developer_instructions: null },
526
573
  },
527
574
  multiAgentMode: "explicitRequestOnly",
@@ -547,14 +594,14 @@ export class ClaudeThreads {
547
594
  return session;
548
595
  if (!this.catalog.get(threadId))
549
596
  throw invalidParams(`thread not found: ${threadId}`);
550
- const leaf = this.gateway.meta.leaf(threadId);
597
+ const leaf = this.leaves.get(threadId);
551
598
  session = new ClaudeSession(this, threadId, this.settings(threadId), { exists: true, ...(leaf ? { resumeAt: leaf } : {}) });
552
599
  this.sessions.set(threadId, session);
553
600
  return session;
554
601
  }
555
602
  /** The session continued from the rollback leaf: its new records end the history from now on. */
556
603
  resumedAtLeaf(threadId) {
557
- this.gateway.meta.setLeaf(threadId, null);
604
+ this.leaves.delete(threadId);
558
605
  }
559
606
  turnCompleted(session, turnId) {
560
607
  const threadId = session.threadId;
@@ -567,6 +614,9 @@ export class ClaudeThreads {
567
614
  const goal = this.goal(threadId);
568
615
  if (JSON.stringify(goal) === before)
569
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;
570
620
  if (goal)
571
621
  this.gateway.emit(threadId, "thread/goal/updated", { threadId, turnId, goal });
572
622
  else
@@ -574,13 +624,22 @@ export class ClaudeThreads {
574
624
  });
575
625
  this.onTurnCompleted?.(threadId, turnId);
576
626
  }
577
- /** 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
+ */
578
631
  goal(threadId) {
579
- 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 };
580
639
  if (!goal)
581
640
  return null;
582
641
  return {
583
- 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,
584
643
  timeUsedSeconds: Math.max(0, goal.updatedAt - goal.createdAt), createdAt: goal.createdAt, updatedAt: goal.updatedAt,
585
644
  };
586
645
  }
@@ -588,14 +647,26 @@ export class ClaudeThreads {
588
647
  // Sub-agent activity is shown by the parent's collabAgentToolCall item; child history is read from disk.
589
648
  }
590
649
  onRateLimit(info) {
591
- if (!info?.rateLimitType)
650
+ if (!info)
592
651
  return;
593
- this.rateLimitWindows.set(info.rateLimitType, {
594
- status: info.status,
595
- usedPercent: Math.round(Number(info.utilization ?? (info.status === "rejected" ? 1 : 0)) * 100),
596
- windowDurationMins: WINDOW_MINUTES[info.rateLimitType] ?? null,
597
- resetsAt: typeof info.resetsAt === "number" ? info.resetsAt : null,
598
- });
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
+ }
599
670
  const snapshot = this.rateLimitSnapshot();
600
671
  for (const connection of this.gateway.connections) {
601
672
  if (connection.provider === "claude")
@@ -617,10 +688,44 @@ export class ClaudeThreads {
617
688
  rateLimitReachedType: [...this.rateLimitWindows.values()].some((value) => value.status === "rejected") ? "rate_limit_reached" : null,
618
689
  };
619
690
  }
691
+ /** Claude's plan limits: read from Claude's `/usage` data at most once a minute, and as turns report them. */
620
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;
621
696
  const snapshot = this.rateLimitSnapshot();
622
697
  return { rateLimits: snapshot, rateLimitsByLimitId: { claude: snapshot } };
623
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
+ }
624
729
  rateLimitWindowsText() {
625
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})`);
626
731
  }
@@ -637,7 +742,9 @@ export class ClaudeThreads {
637
742
  }
638
743
  cachedModels() {
639
744
  try {
640
- 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;
641
748
  }
642
749
  catch {
643
750
  return undefined;
@@ -645,9 +752,17 @@ export class ClaudeThreads {
645
752
  }
646
753
  /** Asks Claude for its models and keeps them for the next start. */
647
754
  async probeModels() {
648
- 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
+ });
649
764
  const temporary = `${this.modelsPath}.${process.pid}.tmp`;
650
- 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);
651
766
  return this.useModels(models);
652
767
  }
653
768
  useModels(models) {
@@ -656,14 +771,37 @@ export class ClaudeThreads {
656
771
  for (const model of models) {
657
772
  if (model.value !== "default" && model.resolvedModel)
658
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);
659
779
  }
660
- 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;
661
798
  }
662
799
  modelLabel(model) {
663
800
  return model ? claudeModelLabel(model) : "Claude (default)";
664
801
  }
665
802
  pendingNames = new Map();
666
803
  pickerValues = new Map();
804
+ defaultEfforts = new Map();
667
805
  skillCache = new Map();
668
806
  async skills(cwds) {
669
807
  const entries = await Promise.all(cwds.map(async (cwd) => {
@@ -695,7 +833,10 @@ export class ClaudeThreads {
695
833
  }
696
834
  async sideTurn(side, params) {
697
835
  const input = normalizeUserInput(params.input ?? []);
698
- 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;
699
840
  const id = randomUUID();
700
841
  const started = Math.floor(Date.now() / 1000);
701
842
  const turn = { id, items: [], itemsView: "full", status: "inProgress", error: null, startedAt: started, completedAt: null, durationMs: null };
@@ -714,6 +855,8 @@ export class ClaudeThreads {
714
855
  text = await this.session(side.sourceId).askSideQuestion(history.length
715
856
  ? `Earlier in this side conversation:\n${history.join("\n")}\n\nNew question: ${question}`
716
857
  : question);
858
+ if (schema)
859
+ text = JSON.stringify(JSON.parse(text.slice(text.indexOf("{"), text.lastIndexOf("}") + 1)));
717
860
  }
718
861
  catch (error) {
719
862
  text = `Side question failed: ${error instanceof Error ? error.message : String(error)}`;
@@ -744,6 +887,7 @@ export class ClaudeThreads {
744
887
  case "thread/read": return { thread: params.includeTurns ? await this.read(threadId).then(({ thread, turns }) => ({ ...thread, turns })) : await this.thread(threadId) };
745
888
  case "thread/turns/list": return this.turnsPage(threadId, params);
746
889
  case "thread/items/list": return this.itemsPage(threadId, params);
890
+ case "thread/searchOccurrences": return this.searchOccurrences(threadId, params);
747
891
  case "turn/start": {
748
892
  this.gateway.subscribe(threadId, connection);
749
893
  return { turn: await this.session(threadId).startTurn(params) };
@@ -774,10 +918,7 @@ export class ClaudeThreads {
774
918
  return {};
775
919
  }
776
920
  case "thread/settings/update": {
777
- const session = this.session(threadId);
778
- if (await session.updateSettings(params)) {
779
- this.gateway.emit(threadId, "thread/settings/updated", { threadId, threadSettings: this.threadSettings(session.settings) });
780
- }
921
+ await this.session(threadId).updateSettings(params);
781
922
  return {};
782
923
  }
783
924
  // Claude's settings have no per-turn scope: the running turn's change stays for the chat.
@@ -800,9 +941,11 @@ export class ClaudeThreads {
800
941
  return { goal: goal?.status === "complete" ? null : goal };
801
942
  }
802
943
  case "thread/goal/set": {
944
+ await this.catalog.refresh();
803
945
  const now = Math.floor(Date.now() / 1000);
804
946
  const current = this.goal(threadId);
805
947
  if (params.objective) {
948
+ this.gateway.meta.setPausedGoal(threadId, null);
806
949
  const session = this.session(threadId);
807
950
  const goal = {
808
951
  threadId, objective: params.objective, status: "active", tokenBudget: null, tokensUsed: 0, timeUsedSeconds: 0,
@@ -811,21 +954,41 @@ export class ClaudeThreads {
811
954
  // Like stock, the goal's turn starts after the answer: Desktop shows the goal message itself on the answer.
812
955
  setImmediate(() => {
813
956
  this.gateway.emit(threadId, "thread/goal/updated", { threadId, turnId: null, goal });
814
- session.command(`/goal ${params.objective}`)
957
+ session.goal(params.objective, current?.status === "active")
815
958
  .catch((error) => this.logger.warn("claude.goal.start-failed", { threadId, error: String(error) }));
816
959
  });
817
960
  return { goal };
818
961
  }
819
- if (params.status && params.status !== "active" && current?.status === "active")
820
- await this.session(threadId).command("/goal clear");
821
- 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 };
822
976
  }
823
977
  case "thread/goal/clear": {
978
+ await this.catalog.refresh();
824
979
  const goal = this.goal(threadId);
825
- if (goal?.status === "active")
826
- await this.session(threadId).command("/goal clear");
827
- this.gateway.emit(threadId, "thread/goal/cleared", { threadId });
828
- 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 };
829
992
  }
830
993
  case "thread/queue/list": return { data: this.sessions.get(threadId)?.queued ?? [], nextCursor: null };
831
994
  case "thread/queue/add": {
@@ -845,7 +1008,7 @@ export class ClaudeThreads {
845
1008
  case "thread/backgroundTerminals/list": {
846
1009
  const session = this.sessions.get(threadId);
847
1010
  return {
848
- data: [...session?.tasks.values() ?? []].filter((task) => task.taskType === "local_bash").map((task) => ({
1011
+ data: (session?.backgroundTasks ?? []).filter((task) => task.taskType === "local_bash").map((task) => ({
849
1012
  itemId: task.toolUseId ?? task.taskId, processId: task.taskId, command: task.description, cwd: session.settings.cwd,
850
1013
  osPid: null, cpuPercent: null, rssKb: null,
851
1014
  })),
@@ -881,6 +1044,7 @@ export class ClaudeThreads {
881
1044
  }
882
1045
  case "thread/turns/list": return paginateTurns(side.turns, params);
883
1046
  case "thread/items/list": return paginateItems(side.turns, params);
1047
+ case "thread/searchOccurrences": return occurrencesPage(turnOccurrences(side.turns, params.searchTerm), params);
884
1048
  case "thread/unsubscribe":
885
1049
  case "thread/delete":
886
1050
  case "thread/archive":
@@ -908,13 +1072,14 @@ export class ClaudeThreads {
908
1072
  async startThread(connection, params) {
909
1073
  await this.models().catch(() => undefined);
910
1074
  const settings = this.settingsFrom(params, {
911
- 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,
912
1076
  });
913
1077
  const session = this.create(settings);
914
1078
  const threadId = session.threadId;
915
1079
  this.gateway.subscribe(threadId, connection);
916
1080
  const thread = this.decorate(this.freshThread(session));
917
- 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 }));
918
1083
  this.gateway.titles.track(threadId);
919
1084
  this.prewarm(session);
920
1085
  return { thread, ...this.settingsResponse(settings) };
@@ -947,7 +1112,7 @@ export class ClaudeThreads {
947
1112
  const { turns, usage } = await this.newestTurns(threadId, 1);
948
1113
  // Like stock, the context meter follows a resume (Desktop's /status reads it).
949
1114
  if (usage?.last) {
950
- 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));
951
1116
  setImmediate(() => this.gateway.emit(threadId, "thread/tokenUsage/updated", { threadId, turnId: turns.at(-1)?.id ?? null, tokenUsage: { ...usage, modelContextWindow } }));
952
1117
  }
953
1118
  // A sub-agent has no session of its own: it shows the model and directory it runs with.
@@ -959,7 +1124,7 @@ export class ClaudeThreads {
959
1124
  const response = {
960
1125
  thread: { ...thread, turns: params.excludeTurns ? [] : turns },
961
1126
  ...this.settingsResponse(settings),
962
- collaborationMode: null,
1127
+ collaborationMode: this.threadSettings(settings).collaborationMode,
963
1128
  initialTurnsPage: params.initialTurnsPage ? await this.turnsPage(threadId, { ...params.initialTurnsPage, threadId }) : null,
964
1129
  ...historyCursors(turns),
965
1130
  };
@@ -980,6 +1145,33 @@ export class ClaudeThreads {
980
1145
  this.gateway.emit(threadId, "thread/name/updated", { threadId, threadName: name });
981
1146
  return {};
982
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
+ }
983
1175
  /** Removes a session with its transcript (also what a failed switch to Claude leaves behind). */
984
1176
  async discard(threadId) {
985
1177
  await this.sessions.get(threadId)?.unload();
@@ -1017,7 +1209,7 @@ export class ClaudeThreads {
1017
1209
  }
1018
1210
  const upTo = params.lastTurnId ? await this.boundaryBefore(sourceId, params.lastTurnId, true)
1019
1211
  : params.beforeTurnId ? await this.boundaryBefore(sourceId, params.beforeTurnId, false)
1020
- : this.gateway.meta.leaf(sourceId) ?? null;
1212
+ : this.leaves.get(sourceId) ?? null;
1021
1213
  const summary = this.catalog.get(sourceId);
1022
1214
  if (!summary)
1023
1215
  throw invalidParams(`thread not found: ${sourceId}`);
@@ -1027,7 +1219,7 @@ export class ClaudeThreads {
1027
1219
  const thread = await this.thread(sessionId);
1028
1220
  const turns = params.excludeTurns ? [] : (await this.newestTurns(sessionId, 25)).turns;
1029
1221
  const forked = { ...thread, forkedFromId: sourceId };
1030
- this.gateway.emit(sessionId, "thread/started", { thread: forked });
1222
+ setImmediate(() => this.gateway.emit(sessionId, "thread/started", { thread: forked }));
1031
1223
  const settings = this.settingsFrom(params, this.settings(sourceId));
1032
1224
  this.sessions.set(sessionId, new ClaudeSession(this, sessionId, settings, { exists: true }));
1033
1225
  return { thread: { ...forked, turns }, ...this.settingsResponse(settings) };
@@ -1040,7 +1232,7 @@ export class ClaudeThreads {
1040
1232
  await session?.unload();
1041
1233
  this.sessions.delete(threadId);
1042
1234
  if (leaf) {
1043
- this.gateway.meta.setLeaf(threadId, leaf);
1235
+ this.leaves.set(threadId, leaf);
1044
1236
  return;
1045
1237
  }
1046
1238
  // Everything rolled back: Claude neither resumes nor starts anew a transcript without messages, so the session
@@ -1048,7 +1240,7 @@ export class ClaudeThreads {
1048
1240
  const name = this.catalog.get(threadId).customTitle;
1049
1241
  await deleteSession(threadId);
1050
1242
  this.catalog.dropPages(threadId);
1051
- this.gateway.meta.setLeaf(threadId, null);
1243
+ this.leaves.delete(threadId);
1052
1244
  await this.catalog.refresh();
1053
1245
  this.sessions.set(threadId, new ClaudeSession(this, threadId, settings, { exists: false }));
1054
1246
  if (name)
@@ -1114,15 +1306,16 @@ export class ClaudeThreads {
1114
1306
  effort: settings.effort,
1115
1307
  fast: settings.fast,
1116
1308
  permissionMode: settings.permissionMode,
1309
+ plan: settings.plan,
1310
+ cwd: settings.cwd,
1117
1311
  loaded: session !== undefined,
1118
1312
  process: session?.loaded ?? false,
1119
1313
  actions: this.actions.get(threadId) ?? [],
1120
- running: session?.busy ?? false,
1314
+ turnId: session?.turn?.id ?? null,
1121
1315
  usage: session?.totalUsage,
1122
- contextWindow: session?.contextWindow ?? null,
1316
+ contextWindow: this.contextWindow(session?.liveModel ?? settings.model),
1123
1317
  lastUsage: session?.lastUsage,
1124
- costUsd: session?.costUsd ?? 0,
1125
- backgroundTasks: session?.tasks.size ?? 0,
1318
+ backgroundTasks: session?.backgroundTasks.length ?? 0,
1126
1319
  };
1127
1320
  }
1128
1321
  /** The summary of Claude's latest compaction if nothing was said since ("" otherwise). */