@adecore/agents 0.23.1 → 0.24.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 (153) hide show
  1. package/README.md +3 -3
  2. package/dist/chat/attachment-store.js +4 -5
  3. package/dist/chat/backend.d.ts +6 -1
  4. package/dist/chat/background-work.d.ts +1 -1
  5. package/dist/chat/background-work.js +2 -1
  6. package/dist/chat/chat-core.d.ts +53 -161
  7. package/dist/chat/chat-core.js +146 -112
  8. package/dist/chat/chat-log.js +3 -3
  9. package/dist/chat/chat-process.d.ts +21 -0
  10. package/dist/chat/chat-process.js +109 -9
  11. package/dist/chat/chat-session.d.ts +10 -17
  12. package/dist/chat/chat-session.js +141 -182
  13. package/dist/chat/chat-store.d.ts +1 -0
  14. package/dist/chat/chat-store.js +105 -1
  15. package/dist/chat/claude-backend.d.ts +4 -0
  16. package/dist/chat/claude-backend.js +51 -27
  17. package/dist/chat/claude-protocol.d.ts +12 -0
  18. package/dist/chat/claude-protocol.js +137 -7
  19. package/dist/chat/claude-title.d.ts +1 -3
  20. package/dist/chat/claude-title.js +11 -39
  21. package/dist/chat/claude-transcript.d.ts +1 -0
  22. package/dist/chat/claude-transcript.js +36 -17
  23. package/dist/chat/codex-backend.d.ts +21 -1
  24. package/dist/chat/codex-backend.js +168 -10
  25. package/dist/chat/codex-protocol.d.ts +2 -0
  26. package/dist/chat/codex-protocol.js +37 -3
  27. package/dist/chat/codex-thread.js +1 -3
  28. package/dist/chat/codex-transport.d.ts +3 -0
  29. package/dist/chat/codex-transport.js +38 -35
  30. package/dist/chat/context-breakdown.js +2 -2
  31. package/dist/chat/errors.d.ts +1 -1
  32. package/dist/chat/external-sessions.js +2 -2
  33. package/dist/chat/fixtures/claude-2.1.296-goal.json +136 -0
  34. package/dist/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10.jsonl +2 -0
  35. package/dist/chat/index.d.ts +29 -0
  36. package/dist/chat/index.js +23 -0
  37. package/dist/chat/projector.d.ts +2 -1
  38. package/dist/chat/projector.js +12 -3
  39. package/dist/chat/request-summary.js +1 -4
  40. package/dist/chat/subagent-settlement.js +1 -3
  41. package/dist/chat/thread.d.ts +3 -0
  42. package/dist/chat/thread.js +20 -3
  43. package/dist/chat/ui-queries.d.ts +2 -0
  44. package/dist/chat/ui-queries.js +40 -5
  45. package/dist/chat/ui-replies.d.ts +33 -0
  46. package/dist/chat/ui-replies.js +152 -0
  47. package/dist/context/index.d.ts +2 -0
  48. package/dist/context/index.js +3 -0
  49. package/dist/coordination/index.d.ts +15 -0
  50. package/dist/coordination/index.js +16 -0
  51. package/dist/host/handlers.js +8 -0
  52. package/dist/host/index.d.ts +7 -0
  53. package/dist/host/index.js +7 -0
  54. package/dist/index.d.ts +9 -0
  55. package/dist/index.js +10 -0
  56. package/dist/providers/accounts/status.js +1 -3
  57. package/dist/providers/claude.js +3 -1
  58. package/dist/providers/codex.js +3 -1
  59. package/dist/providers/index.d.ts +14 -0
  60. package/dist/providers/index.js +12 -0
  61. package/dist/run-process.js +54 -14
  62. package/dist/skills.d.ts +1 -0
  63. package/dist/skills.js +4 -0
  64. package/dist/tasks/task-coordinator.d.ts +4 -0
  65. package/dist/tasks/task-coordinator.js +16 -2
  66. package/dist/tasks/wiring.js +7 -1
  67. package/dist/{providers/accounts/test-accounts.d.ts → testing/accounts.d.ts} +2 -2
  68. package/dist/{providers/accounts/test-accounts.js → testing/accounts.js} +3 -3
  69. package/dist/testing/bin/fake-claude.d.ts +1 -0
  70. package/dist/testing/bin/fake-claude.js +6 -0
  71. package/dist/testing/bin/fake-codex.d.ts +1 -0
  72. package/dist/testing/bin/fake-codex.js +6 -0
  73. package/dist/{chat → testing}/fake-claude.d.ts +1 -1
  74. package/dist/{chat → testing}/fake-claude.js +0 -4
  75. package/dist/{chat → testing}/fake-cli.d.ts +1 -1
  76. package/dist/{chat → testing}/fake-codex.d.ts +1 -1
  77. package/dist/{chat → testing}/fake-codex.js +70 -8
  78. package/dist/testing/index.d.ts +6 -0
  79. package/dist/testing/index.js +7 -0
  80. package/dist/{outbox → testing}/manual-clock.d.ts +1 -1
  81. package/dist/{watch-test-helpers.d.ts → testing/watch.d.ts} +1 -1
  82. package/dist/usage/exchange.d.ts +5 -3
  83. package/dist/usage/exchange.js +25 -16
  84. package/dist/usage/index-file.d.ts +1 -0
  85. package/dist/usage/index-file.js +20 -2
  86. package/dist/usage/index.d.ts +9 -0
  87. package/dist/usage/index.js +6 -0
  88. package/dist/usage/limits/normalize.js +1 -3
  89. package/dist/usage/pricing.d.ts +5 -3
  90. package/dist/usage/pricing.js +28 -19
  91. package/dist/usage/scanner.js +39 -11
  92. package/dist/usage/usage-service.js +8 -3
  93. package/package.json +72 -11
  94. package/src/chat/attachment-store.ts +4 -6
  95. package/src/chat/backend.ts +8 -0
  96. package/src/chat/background-work.ts +2 -2
  97. package/src/chat/chat-core.ts +221 -143
  98. package/src/chat/chat-log.ts +3 -3
  99. package/src/chat/chat-process.ts +128 -8
  100. package/src/chat/chat-session.ts +153 -204
  101. package/src/chat/chat-store.ts +104 -1
  102. package/src/chat/claude-backend.ts +58 -26
  103. package/src/chat/claude-protocol.ts +147 -8
  104. package/src/chat/claude-title.ts +11 -39
  105. package/src/chat/claude-transcript.ts +38 -18
  106. package/src/chat/codex-backend.ts +182 -20
  107. package/src/chat/codex-protocol.ts +40 -5
  108. package/src/chat/codex-thread.ts +1 -4
  109. package/src/chat/codex-transport.ts +45 -35
  110. package/src/chat/context-breakdown.ts +2 -2
  111. package/src/chat/errors.ts +1 -0
  112. package/src/chat/external-sessions.ts +2 -2
  113. package/src/chat/fixtures/claude-2.1.296-goal.json +136 -0
  114. package/src/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10.jsonl +2 -0
  115. package/src/chat/index.ts +49 -0
  116. package/src/chat/projector.ts +13 -4
  117. package/src/chat/request-summary.ts +8 -5
  118. package/src/chat/subagent-settlement.ts +1 -4
  119. package/src/chat/thread.ts +22 -3
  120. package/src/chat/ui-queries.ts +46 -5
  121. package/src/chat/ui-replies.ts +187 -0
  122. package/src/context/index.ts +26 -0
  123. package/src/coordination/index.ts +67 -0
  124. package/src/host/handlers.ts +8 -0
  125. package/src/host/index.ts +8 -0
  126. package/src/index.ts +20 -0
  127. package/src/providers/accounts/status.ts +1 -4
  128. package/src/providers/claude.ts +3 -1
  129. package/src/providers/codex.ts +3 -1
  130. package/src/providers/index.ts +15 -0
  131. package/src/run-process.ts +56 -16
  132. package/src/skills.ts +5 -0
  133. package/src/tasks/task-coordinator.ts +22 -2
  134. package/src/tasks/wiring.ts +7 -1
  135. package/src/{providers/accounts/test-accounts.ts → testing/accounts.ts} +4 -4
  136. package/src/testing/bin/fake-claude.ts +7 -0
  137. package/src/testing/bin/fake-codex.ts +7 -0
  138. package/src/{chat → testing}/fake-claude.ts +1 -5
  139. package/src/{chat → testing}/fake-cli.ts +1 -1
  140. package/src/{chat → testing}/fake-codex.ts +75 -6
  141. package/src/testing/index.ts +7 -0
  142. package/src/{outbox → testing}/manual-clock.ts +1 -1
  143. package/src/{watch-test-helpers.ts → testing/watch.ts} +1 -1
  144. package/src/usage/exchange.ts +31 -20
  145. package/src/usage/index-file.ts +23 -2
  146. package/src/usage/index.ts +10 -0
  147. package/src/usage/limits/normalize.ts +1 -4
  148. package/src/usage/pricing.ts +34 -23
  149. package/src/usage/scanner.ts +48 -11
  150. package/src/usage/usage-service.ts +8 -3
  151. /package/dist/{chat → testing}/fake-cli.js +0 -0
  152. /package/dist/{outbox → testing}/manual-clock.js +0 -0
  153. /package/dist/{watch-test-helpers.js → testing/watch.js} +0 -0
@@ -2,10 +2,14 @@ import { randomUUID } from 'node:crypto';
2
2
  import { errorText } from '../error-text.ts';
3
3
  import { claudeArgs, claudeEnv, promptPrefix } from '../providers/claude.ts';
4
4
  import type { ApprovalDecision, BackendHost, BackendLaunch, ChatBackend, TurnInput } from './backend.ts';
5
- import { ChatChild } from './chat-process.ts';
5
+ import { ChatChild, MAX_LINE_LENGTH, readStreamLines } from './chat-process.ts';
6
6
  import { ClaudeProtocol } from './claude-protocol.ts';
7
7
  import { buildUserMessage } from './input.ts';
8
8
 
9
+ // Claude may echo a steer only once a slow tool returns, so a running turn sets no deadline; after the turn ended, a message
10
+ // it still holds opens the next turn at once or never arrives.
11
+ const STEER_GRACE_MS = 30_000;
12
+
9
13
  export interface ClaudeBackendOptions {
10
14
  // Tools the CLI runs without asking, in every mode, as `--allowedTools` patterns (`Bash(git status)`).
11
15
  allowedTools?: readonly string[];
@@ -22,18 +26,20 @@ export class ClaudeBackend implements ChatBackend {
22
26
  private readonly launch: BackendLaunch;
23
27
  private readonly host: BackendHost;
24
28
  private readonly allowedTools: readonly string[];
25
- private readonly protocol = new ClaudeProtocol();
29
+ private readonly protocol: ClaudeProtocol;
26
30
  private child: ChatChild | null = null;
27
31
  private stdinClosed = false;
28
32
  // The person asked to stop; the result frame that follows closes the turn as aborted.
29
33
  private interrupted = false;
30
34
  private readonly steers = new Map<string, { resolve(accepted: boolean): void; reject(reason: Error): void }>();
35
+ private steerTimer: ReturnType<typeof setTimeout> | null = null;
31
36
  private active = false;
32
37
 
33
38
  constructor(launch: BackendLaunch, host: BackendHost, options: ClaudeBackendOptions = {}) {
34
39
  this.launch = launch;
35
40
  this.host = host;
36
41
  this.allowedTools = options.allowedTools ?? [];
42
+ this.protocol = new ClaudeProtocol(launch.goal ?? null);
37
43
  }
38
44
 
39
45
  get running(): boolean {
@@ -62,7 +68,7 @@ export class ClaudeBackend implements ChatBackend {
62
68
  cwd: this.launch.cwd,
63
69
  env: { ...this.launch.env, ...claudeEnv(selection) },
64
70
  ...(this.launch.spawn ? { spawn: this.launch.spawn } : {}),
65
- onError: (error) => this.failed(error),
71
+ onError: (error) => this.broken(error),
66
72
  onExit: (exitCode, stderr) => this.handleExit(child, exitCode, stderr)
67
73
  });
68
74
  this.child = child;
@@ -101,6 +107,7 @@ export class ClaudeBackend implements ChatBackend {
101
107
 
102
108
  interrupt(): void {
103
109
  this.interrupted = true;
110
+ this.protocol.noteInterrupt();
104
111
  this.write({ type: 'control_request', request_id: `interrupt-${Date.now()}`, request: { subtype: 'interrupt' } });
105
112
  }
106
113
 
@@ -151,32 +158,20 @@ export class ClaudeBackend implements ChatBackend {
151
158
  }
152
159
 
153
160
  private async readLines(child: ChatChild): Promise<void> {
154
- const reader = child.process.stdout.getReader();
155
- const decoder = new TextDecoder();
156
- let buffered = '';
157
161
  try {
158
- while (true) {
159
- const { value, done } = await reader.read();
160
- if (done) {
161
- break;
162
- }
163
- buffered += decoder.decode(value, { stream: true });
164
- let newline = buffered.indexOf('\n');
165
- while (newline >= 0) {
166
- this.readLine(child, buffered.slice(0, newline));
167
- buffered = buffered.slice(newline + 1);
168
- newline = buffered.indexOf('\n');
162
+ await readStreamLines(
163
+ child.process.stdout,
164
+ (line) => this.readLine(child, line),
165
+ () => {
166
+ if (this.child === child) {
167
+ this.failed(new Error(`Claude wrote a frame of more than ${MAX_LINE_LENGTH / 1_000_000} million characters, which was dropped`));
168
+ }
169
169
  }
170
- }
171
- if (buffered.trim() !== '') {
172
- this.readLine(child, buffered);
173
- }
170
+ );
174
171
  } catch (error) {
175
172
  if (this.child === child) {
176
- this.failed(error);
173
+ this.broken(error);
177
174
  }
178
- } finally {
179
- reader.releaseLock();
180
175
  }
181
176
  }
182
177
 
@@ -194,10 +189,14 @@ export class ClaudeBackend implements ChatBackend {
194
189
  if (echoed?.type === 'user' && typeof echoed.uuid === 'string' && echoed.parent_tool_use_id == null) {
195
190
  this.steers.get(echoed.uuid)?.resolve(true);
196
191
  this.steers.delete(echoed.uuid);
192
+ if (this.steers.size === 0) {
193
+ this.clearSteerTimer();
194
+ }
197
195
  }
198
196
  for (const event of this.protocol.handle(frame)) {
199
197
  if (event.type === 'turn.done') {
200
198
  this.active = false;
199
+ this.awaitSteers();
201
200
  }
202
201
  if (event.type === 'turn.done' && this.interrupted) {
203
202
  this.interrupted = false;
@@ -216,14 +215,42 @@ export class ClaudeBackend implements ChatBackend {
216
215
  }
217
216
  }
218
217
 
218
+ private awaitSteers(): void {
219
+ if (this.steers.size === 0 || this.steerTimer !== null) {
220
+ return;
221
+ }
222
+ this.steerTimer = setTimeout(() => {
223
+ this.steerTimer = null;
224
+ for (const pending of this.steers.values()) {
225
+ pending.reject(new Error('Claude ended its turn without taking your message'));
226
+ }
227
+ this.steers.clear();
228
+ }, STEER_GRACE_MS);
229
+ this.steerTimer.unref?.();
230
+ }
231
+
232
+ private clearSteerTimer(): void {
233
+ if (this.steerTimer !== null) {
234
+ clearTimeout(this.steerTimer);
235
+ this.steerTimer = null;
236
+ }
237
+ }
238
+
219
239
  private rejectSteers(reason: string): void {
220
240
  this.active = false;
241
+ this.clearSteerTimer();
221
242
  for (const pending of this.steers.values()) {
222
243
  pending.reject(new Error(reason));
223
244
  }
224
245
  this.steers.clear();
225
246
  }
226
247
 
248
+ /* A pipe that failed takes no more writes; a frame a consumer choked on leaves it as it was. */
249
+ private broken(error: unknown): void {
250
+ this.stdinClosed = true;
251
+ this.failed(error);
252
+ }
253
+
227
254
  private failed(error: unknown): void {
228
255
  this.rejectSteers(`Claude transport failed: ${errorText(error)}`);
229
256
  this.protocol.forgetPending();
@@ -250,8 +277,13 @@ export class ClaudeBackend implements ChatBackend {
250
277
  if (!child || this.stdinClosed) {
251
278
  throw new Error('Claude stdin is closed');
252
279
  }
253
- child.process.stdin.write(`${JSON.stringify(frame)}\n`);
254
- child.process.stdin.flush();
280
+ try {
281
+ child.process.stdin.write(`${JSON.stringify(frame)}\n`);
282
+ child.process.stdin.flush();
283
+ } catch (error) {
284
+ this.stdinClosed = true;
285
+ throw error;
286
+ }
255
287
  }
256
288
 
257
289
  private closeStdin(): void {
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ ChatGoal,
2
3
  ChatQuestion,
3
4
  ChatSubagentStatus,
4
5
  ChatSubagentUsage,
@@ -7,16 +8,13 @@ import type {
7
8
  ChatWorkflowAgent,
8
9
  ChatWorkflowPhase
9
10
  } from '@adecore/agent-contracts';
11
+ import { isRecord } from '@adecore/agent-contracts';
10
12
  import { readClaudeEvent } from '../usage/limits/normalize.ts';
11
13
  import { claudeRuntimeMode } from '../providers/claude.ts';
12
14
  import type { ApprovalDecision, BackendEvent } from './backend.ts';
13
15
 
14
16
  type Frame = Record<string, unknown>;
15
17
 
16
- function isRecord(value: unknown): value is Frame {
17
- return typeof value === 'object' && value !== null && !Array.isArray(value);
18
- }
19
-
20
18
  function str(value: unknown): string | null {
21
19
  return typeof value === 'string' ? value : null;
22
20
  }
@@ -118,6 +116,70 @@ type Pending =
118
116
  | { type: 'approval'; toolUseId: string | null; input: unknown; suggestions: unknown[] }
119
117
  | { type: 'question'; toolUseId: string | null; input: unknown };
120
118
 
119
+ /*
120
+ * What Claude Code 2.1.296 answers a `/goal` with, as a `<synthetic>` assistant message of its own: the goal
121
+ * it set, the one it cleared, or the one that is active with how many turns its check sent back. Undefined
122
+ * for any other text, null where no goal is left.
123
+ */
124
+ const GOAL_SET = 'Goal set: ';
125
+ const GOAL_ACTIVE = /^Goal active: ([\s\S]+?) \(([^)]*)\)(?:\nLast check: ([\s\S]*))?$/;
126
+ const GOAL_GONE = ['Goal cleared: ', 'Goal cleared after ', 'No goal set'];
127
+
128
+ export function claudeGoalFromCommand(text: string, current: ChatGoal | null): ChatGoal | null | undefined {
129
+ const said = text.trim();
130
+ if (said.startsWith(GOAL_SET)) {
131
+ const objective = said.slice(GOAL_SET.length).trim();
132
+ return objective === '' ? undefined : { objective, status: 'active', checks: 0 };
133
+ }
134
+ if (GOAL_GONE.some((prefix) => said.startsWith(prefix))) {
135
+ return null;
136
+ }
137
+ const active = GOAL_ACTIVE.exec(said);
138
+ if (!active) {
139
+ return undefined;
140
+ }
141
+ const objective = active[1]!;
142
+ const checks = /\d+/.exec(active[2]!)?.[0];
143
+ const lastCheck = active[3]?.trim();
144
+ return {
145
+ ...(current?.objective === objective ? current : {}),
146
+ objective,
147
+ status: 'active',
148
+ checks: checks === undefined ? 0 : Number(checks),
149
+ ...(lastCheck ? { lastCheck } : {})
150
+ };
151
+ }
152
+
153
+ // The messages whose blocks are still counted. The frames of one message come close together, so only the newest can still grow.
154
+ const COUNTED_MESSAGES = 256;
155
+
156
+ /* The ordinal of the next block of `messageId`; a count moves to the back on every block and the oldest goes. */
157
+ function nextOrdinal(counts: Map<string, number>, messageId: string): number {
158
+ const ordinal = counts.get(messageId) ?? 0;
159
+ counts.delete(messageId);
160
+ counts.set(messageId, ordinal + 1);
161
+ if (counts.size > COUNTED_MESSAGES) {
162
+ counts.delete(counts.keys().next().value!);
163
+ }
164
+ return ordinal;
165
+ }
166
+
167
+ /*
168
+ * One check of the goal's Stop hook that found it not met yet: a synthetic user message the CLI feeds the
169
+ * agent before it goes on, which names the objective in brackets. Undefined for any other message.
170
+ */
171
+ export function claudeGoalCheck(text: string, current: ChatGoal | null): ChatGoal | undefined {
172
+ if (current === null) {
173
+ return undefined;
174
+ }
175
+ const prefix = `Stop hook feedback:\n[${current.objective}]: `;
176
+ if (!text.startsWith(prefix)) {
177
+ return undefined;
178
+ }
179
+ const lastCheck = text.slice(prefix.length).trim();
180
+ return { ...current, status: 'active', checks: (current.checks ?? 0) + 1, ...(lastCheck === '' ? {} : { lastCheck }) };
181
+ }
182
+
121
183
  /*
122
184
  * Claude Code's stream-json frames, in and out. Text is keyed by message id plus the ordinal of the
123
185
  * text block inside that message, because the streaming events and the final `assistant` frames
@@ -151,6 +213,28 @@ export class ClaudeProtocol {
151
213
  private readonly stopRequests = new Map<string, string>();
152
214
 
153
215
  private readonly steerIds = new Set<string>();
216
+ // The goal this process works toward, which `--resume` takes up from the transcript; a model answer since it was
217
+ // set or last checked is what lets a turn that ends normally count as its check passing.
218
+ private goal: ChatGoal | null;
219
+ private goalAnswered = false;
220
+ private interrupting = false;
221
+ // Background subagents still at work; Claude Code holds a goal's check until they report.
222
+ private readonly backgroundAgents = new Set<string>();
223
+
224
+ constructor(goal: ChatGoal | null = null) {
225
+ this.goal = goal;
226
+ }
227
+
228
+ /* A stop is on its way; the turn it ends leaves the goal set but nobody working on it. */
229
+ noteInterrupt(): void {
230
+ this.interrupting = true;
231
+ }
232
+
233
+ private setGoal(goal: ChatGoal | null, events: BackendEvent[]): void {
234
+ this.goal = goal;
235
+ this.goalAnswered = false;
236
+ events.push({ type: 'goal', goal });
237
+ }
154
238
 
155
239
  beginSteer(promptId: string): void {
156
240
  this.steerIds.add(promptId);
@@ -347,6 +431,7 @@ export class ClaudeProtocol {
347
431
  if (taskId) {
348
432
  this.endBackground(taskId, events);
349
433
  this.workflows.delete(taskId);
434
+ this.backgroundAgents.delete(taskId);
350
435
  }
351
436
  // The CLI wakes the main agent itself when a background task settles; this frame is the only
352
437
  // thing that says what it was about, and it arrives before the turn nobody asked for.
@@ -369,6 +454,10 @@ export class ClaudeProtocol {
369
454
  private handleTaskStarted(frame: Frame, events: BackendEvent[]): void {
370
455
  const ref = str(frame.tool_use_id);
371
456
  if (frame.task_type === 'local_agent') {
457
+ const agentTask = str(frame.task_id);
458
+ if (agentTask && frame.is_backgrounded === true) {
459
+ this.backgroundAgents.add(agentTask);
460
+ }
372
461
  if (ref) {
373
462
  events.push({
374
463
  type: 'task.started',
@@ -417,6 +506,7 @@ export class ClaudeProtocol {
417
506
  const status = str(patch.status);
418
507
  if (status !== null && ENDED_TASK_STATUSES.has(status)) {
419
508
  this.foregroundShells.delete(taskId);
509
+ this.backgroundAgents.delete(taskId);
420
510
  this.endBackground(taskId, events);
421
511
  this.endWorkflow(taskId, null, status === 'completed', events);
422
512
  }
@@ -525,6 +615,9 @@ export class ClaudeProtocol {
525
615
  this.apiError = frame.error;
526
616
  }
527
617
  const model = str(message.model);
618
+ if (!parentRef) {
619
+ this.trackGoal(model, content, events);
620
+ }
528
621
  // The CLI answers some frames itself and names them `<synthetic>`; no model wrote those.
529
622
  if (parentRef && model && model !== '<synthetic>' && this.subagentModels.get(parentRef) !== model) {
530
623
  this.subagentModels.set(parentRef, model);
@@ -535,13 +628,11 @@ export class ClaudeProtocol {
535
628
  continue;
536
629
  }
537
630
  if (block.type === 'text') {
538
- const ordinal = this.frameTextCount.get(messageId) ?? 0;
539
- this.frameTextCount.set(messageId, ordinal + 1);
631
+ const ordinal = nextOrdinal(this.frameTextCount, messageId);
540
632
  // A subagent's text is its own report, not the thread's answer; the parent says whose row it is.
541
633
  events.push({ type: 'text.done', ref: this.textRef(messageId, ordinal), text: str(block.text) ?? '', ...(parentRef ? { parentRef } : {}) });
542
634
  } else if (block.type === 'thinking' && !parentRef) {
543
- const ordinal = this.frameThinkingCount.get(messageId) ?? 0;
544
- this.frameThinkingCount.set(messageId, ordinal + 1);
635
+ const ordinal = nextOrdinal(this.frameThinkingCount, messageId);
545
636
  events.push({ type: 'thinking.done', ref: this.thinkingRef(messageId, ordinal), text: str(block.thinking) ?? '' });
546
637
  } else if (block.type === 'tool_use') {
547
638
  events.push({
@@ -563,6 +654,45 @@ export class ClaudeProtocol {
563
654
  }
564
655
  }
565
656
 
657
+ private trackGoal(model: string | null, content: unknown[], events: BackendEvent[]): void {
658
+ if (model === '<synthetic>') {
659
+ const next = claudeGoalFromCommand(resultText(content), this.goal);
660
+ if (next !== undefined) {
661
+ this.setGoal(next, events);
662
+ }
663
+ return;
664
+ }
665
+ if (this.goal === null) {
666
+ return;
667
+ }
668
+ this.goalAnswered = true;
669
+ // A message after a stop takes the goal up again; its hook never went away.
670
+ if (this.goal.status === 'paused') {
671
+ this.goal = { ...this.goal, status: 'active' };
672
+ events.push({ type: 'goal', goal: this.goal });
673
+ }
674
+ }
675
+
676
+ /*
677
+ * Where a turn leaves the goal. Claude Code ends a turn on its own only once the goal's check passed, so
678
+ * a turn that ends normally after the model answered met it; Claude Code 2.1.296 says nothing more
679
+ * about it in stream-json. A check it defers while background work runs leaves the goal active, and a
680
+ * stopped or failed turn leaves it set with nobody working on it.
681
+ */
682
+ private settleGoal(failed: boolean, events: BackendEvent[]): void {
683
+ const goal = this.goal;
684
+ const interrupted = this.interrupting;
685
+ this.interrupting = false;
686
+ if (goal?.status !== 'active') {
687
+ return;
688
+ }
689
+ if (failed || interrupted) {
690
+ this.setGoal({ ...goal, status: 'paused' }, events);
691
+ } else if (this.goalAnswered && this.backgroundTasks.size === 0 && this.workflows.size === 0 && this.backgroundAgents.size === 0) {
692
+ this.setGoal({ ...goal, status: 'complete' }, events);
693
+ }
694
+ }
695
+
566
696
  /*
567
697
  * `tool_progress` carries how long the call has run (once per 30 s for a Bash under the CLI's
568
698
  * remote gate, or a heartbeat for a slow MCP tool); the start is kept so the client can count on.
@@ -583,6 +713,13 @@ export class ClaudeProtocol {
583
713
  }
584
714
  const message = isRecord(frame.message) ? frame.message : {};
585
715
  const content = Array.isArray(message.content) ? message.content : [];
716
+ if (frame.isSynthetic === true && !str(frame.parent_tool_use_id)) {
717
+ const checked = claudeGoalCheck(resultText(message.content), this.goal);
718
+ if (checked !== undefined) {
719
+ this.setGoal(checked, events);
720
+ events.push({ type: 'note', level: 'info', text: `Goal not met yet: ${checked.lastCheck ?? checked.objective}` });
721
+ }
722
+ }
586
723
  for (const block of content) {
587
724
  if (!isRecord(block) || block.type !== 'tool_result') {
588
725
  continue;
@@ -630,6 +767,8 @@ export class ClaudeProtocol {
630
767
  const limit = failed ? this.limit() : null;
631
768
  this.apiError = null;
632
769
  this.refusedWindows.clear();
770
+ // Before the turn ends, so whoever settles work on that end already reads where the goal stands.
771
+ this.settleGoal(failed, events);
633
772
  events.push({
634
773
  type: 'turn.done',
635
774
  ...(promptId === null ? {} : { promptId }),
@@ -1,8 +1,8 @@
1
- import { existsSync } from 'node:fs';
2
- import { readdir, stat } from 'node:fs/promises';
3
- import { join } from 'node:path';
1
+ import { stat } from 'node:fs/promises';
2
+ import { isRecord } from '@adecore/agent-contracts';
4
3
  import { cleanTitle, readLines } from '../title-file.ts';
5
4
  import { usageRoots } from '../usage/roots.ts';
5
+ import { findClaudeSession } from './claude-transcript.ts';
6
6
 
7
7
  interface Progress {
8
8
  offset: number;
@@ -22,7 +22,6 @@ const MARKERS = ['"ai-title"', '"custom-title"'];
22
22
  */
23
23
  export class ClaudeTitleReader {
24
24
  private readonly progress = new Map<string, Progress>();
25
- private readonly found = new Map<string, string>();
26
25
  private readonly projectsDir: string;
27
26
  private readonly chunkBytes: number;
28
27
 
@@ -31,9 +30,9 @@ export class ClaudeTitleReader {
31
30
  this.chunkBytes = chunkBytes;
32
31
  }
33
32
 
34
- /* The title of a session found by its id, for a chat, which knows no transcript path; in the projects of its account. */
35
- async forSession(agentSessionId: string, projectsDir: string = this.projectsDir): Promise<string | null> {
36
- const path = await this.locate(agentSessionId, projectsDir);
33
+ /* The title of a session found by its id, for a chat, which knows no transcript path; in the projects of its account, its own folder first. */
34
+ async forSession(agentSessionId: string, projectsDir: string = this.projectsDir, cwd?: string): Promise<string | null> {
35
+ const path = await findClaudeSession(projectsDir, agentSessionId, cwd);
37
36
  return path === null ? null : await this.forTranscript(path);
38
37
  }
39
38
 
@@ -67,40 +66,13 @@ export class ClaudeTitleReader {
67
66
  } catch {
68
67
  return;
69
68
  }
70
- if (typeof record !== 'object' || record === null) {
69
+ if (!isRecord(record)) {
71
70
  return;
72
71
  }
73
- const entry = record as Record<string, unknown>;
74
- if (entry.type === 'ai-title') {
75
- progress.aiTitle = cleanTitle(entry.aiTitle) ?? progress.aiTitle;
76
- } else if (entry.type === 'custom-title') {
77
- progress.customTitle = cleanTitle(entry.customTitle) ?? progress.customTitle;
72
+ if (record.type === 'ai-title') {
73
+ progress.aiTitle = cleanTitle(record.aiTitle) ?? progress.aiTitle;
74
+ } else if (record.type === 'custom-title') {
75
+ progress.customTitle = cleanTitle(record.customTitle) ?? progress.customTitle;
78
76
  }
79
77
  }
80
-
81
- /* The folder a transcript sits in is named after the working directory, so the file name is the only key a session id gives. */
82
- private async locate(agentSessionId: string, projectsDir: string): Promise<string | null> {
83
- const known = this.found.get(agentSessionId);
84
- if (known !== undefined && existsSync(known)) {
85
- return known;
86
- }
87
- if (projectsDir === '' || agentSessionId.includes('/')) {
88
- return null;
89
- }
90
- let dirs: string[];
91
- try {
92
- dirs = await readdir(projectsDir);
93
- } catch {
94
- return null;
95
- }
96
- const file = `${agentSessionId}.jsonl`;
97
- for (const dir of dirs) {
98
- const path = join(projectsDir, dir, file);
99
- if (existsSync(path)) {
100
- this.found.set(agentSessionId, path);
101
- return path;
102
- }
103
- }
104
- return null;
105
- }
106
78
  }
@@ -1,7 +1,9 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { readdir, readFile, stat } from 'node:fs/promises';
3
- import { join } from 'node:path';
3
+ import { dirname, join } from 'node:path';
4
4
  import type { ChatHistoryResult, ChatItem } from '@adecore/agent-contracts';
5
+ import { isRecord } from '@adecore/agent-contracts';
6
+ import { fileExists } from '../fs.ts';
5
7
  import { readLines } from '../title-file.ts';
6
8
  import { ClaudeProtocol } from './claude-protocol.ts';
7
9
  import { readingThread, settledReading } from './subagent-projection.ts';
@@ -10,10 +12,6 @@ import type { ChatThread } from './thread.ts';
10
12
 
11
13
  const CHUNK_BYTES = 4 * 1024 * 1024;
12
14
 
13
- function isRecord(value: unknown): value is Record<string, unknown> {
14
- return typeof value === 'object' && value !== null && !Array.isArray(value);
15
- }
16
-
17
15
  function str(value: unknown): string | null {
18
16
  return typeof value === 'string' && value !== '' ? value : null;
19
17
  }
@@ -45,32 +43,54 @@ export function claudeProjectSlug(cwd: string): string {
45
43
  return cwd.replace(/[^a-zA-Z0-9]/g, '-');
46
44
  }
47
45
 
46
+ // Where each session was found last, by projects folder and session id; a path whose file went is searched for again.
47
+ const sessionPaths = new Map<string, string>();
48
+
48
49
  /*
49
- * Where the subagents of a session keep their transcripts. The folder of the chat's own directory
50
- * first; a slug the CLI wrote differently (or a session resumed from somewhere else) is found by the
51
- * session id alone, which is unique across every project.
50
+ * The transcript Claude Code keeps of a session, found by its id: in the folder of `cwd` first, then
51
+ * where it was found before, then in every project folder, since the id is unique across all of them
52
+ * and a slug the CLI wrote differently (or a session resumed from elsewhere) sits in another folder.
53
+ * Null when no folder holds it, and for an id that would reach outside the projects folder.
52
54
  */
53
- export async function findSubagentsDir(projectsDir: string, cwd: string, sessionId: string): Promise<string | null> {
54
- if (projectsDir === '' || sessionId.includes('/') || sessionId.includes('..')) {
55
+ export async function findClaudeSession(projectsDir: string, sessionId: string, cwd?: string): Promise<string | null> {
56
+ if (projectsDir === '' || sessionId === '' || /[/\\]|\.\./.test(sessionId)) {
55
57
  return null;
56
58
  }
57
- const own = join(projectsDir, claudeProjectSlug(cwd), sessionId, 'subagents');
58
- if (existsSync(own)) {
59
+ const file = `${sessionId}.jsonl`;
60
+ const key = `${projectsDir}\n${sessionId}`;
61
+ const own = cwd === undefined ? null : join(projectsDir, claudeProjectSlug(cwd), file);
62
+ if (own !== null && (await fileExists(own))) {
63
+ sessionPaths.set(key, own);
59
64
  return own;
60
65
  }
66
+ const known = sessionPaths.get(key);
67
+ if (known !== undefined && known !== own && (await fileExists(known))) {
68
+ return known;
69
+ }
70
+ sessionPaths.delete(key);
61
71
  let dirs: string[];
62
72
  try {
63
73
  dirs = await readdir(projectsDir);
64
74
  } catch {
65
75
  return null;
66
76
  }
67
- for (const dir of dirs) {
68
- const candidate = join(projectsDir, dir, sessionId, 'subagents');
69
- if (existsSync(candidate)) {
70
- return candidate;
71
- }
77
+ const candidates = dirs.map((dir) => join(projectsDir, dir, file)).filter((candidate) => candidate !== own);
78
+ const present = await Promise.all(candidates.map(fileExists));
79
+ const found = candidates.find((_candidate, i) => present[i]) ?? null;
80
+ if (found !== null) {
81
+ sessionPaths.set(key, found);
72
82
  }
73
- return null;
83
+ return found;
84
+ }
85
+
86
+ /* Where the subagents of a session keep their transcripts: the folder beside its own transcript, once the CLI made it. */
87
+ export async function findSubagentsDir(projectsDir: string, cwd: string, sessionId: string): Promise<string | null> {
88
+ const transcript = await findClaudeSession(projectsDir, sessionId, cwd);
89
+ if (transcript === null) {
90
+ return null;
91
+ }
92
+ const dir = join(dirname(transcript), sessionId, 'subagents');
93
+ return (await fileExists(dir)) ? dir : null;
74
94
  }
75
95
 
76
96
  /* Every meta file in a subagents folder that says which agent it is about; one that does not parse is left out. */