@adecore/agents 0.23.0 → 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
@@ -1,9 +1,12 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { errorText } from '../error-text.js';
3
3
  import { claudeArgs, claudeEnv, promptPrefix } from '../providers/claude.js';
4
- import { ChatChild } from './chat-process.js';
4
+ import { ChatChild, MAX_LINE_LENGTH, readStreamLines } from './chat-process.js';
5
5
  import { ClaudeProtocol } from './claude-protocol.js';
6
6
  import { buildUserMessage } from './input.js';
7
+ // Claude may echo a steer only once a slow tool returns, so a running turn sets no deadline; after the turn ended, a message
8
+ // it still holds opens the next turn at once or never arrives.
9
+ const STEER_GRACE_MS = 30_000;
7
10
  /*
8
11
  * One `claude -p` process on the stream-json protocol: flags from the selection and the modes,
9
12
  * frames in over stdout, frames out over stdin. The process stays alive between turns; the session
@@ -15,17 +18,19 @@ export class ClaudeBackend {
15
18
  launch;
16
19
  host;
17
20
  allowedTools;
18
- protocol = new ClaudeProtocol();
21
+ protocol;
19
22
  child = null;
20
23
  stdinClosed = false;
21
24
  // The person asked to stop; the result frame that follows closes the turn as aborted.
22
25
  interrupted = false;
23
26
  steers = new Map();
27
+ steerTimer = null;
24
28
  active = false;
25
29
  constructor(launch, host, options = {}) {
26
30
  this.launch = launch;
27
31
  this.host = host;
28
32
  this.allowedTools = options.allowedTools ?? [];
33
+ this.protocol = new ClaudeProtocol(launch.goal ?? null);
29
34
  }
30
35
  get running() {
31
36
  return this.child !== null;
@@ -51,7 +56,7 @@ export class ClaudeBackend {
51
56
  cwd: this.launch.cwd,
52
57
  env: { ...this.launch.env, ...claudeEnv(selection) },
53
58
  ...(this.launch.spawn ? { spawn: this.launch.spawn } : {}),
54
- onError: (error) => this.failed(error),
59
+ onError: (error) => this.broken(error),
55
60
  onExit: (exitCode, stderr) => this.handleExit(child, exitCode, stderr)
56
61
  });
57
62
  this.child = child;
@@ -87,6 +92,7 @@ export class ClaudeBackend {
87
92
  }
88
93
  interrupt() {
89
94
  this.interrupted = true;
95
+ this.protocol.noteInterrupt();
90
96
  this.write({ type: 'control_request', request_id: `interrupt-${Date.now()}`, request: { subtype: 'interrupt' } });
91
97
  }
92
98
  stopTask(taskId) {
@@ -129,35 +135,18 @@ export class ClaudeBackend {
129
135
  return buildUserMessage({ text: input.text, attachments: input.attachments, prefix, skills: input.skills, promptId });
130
136
  }
131
137
  async readLines(child) {
132
- const reader = child.process.stdout.getReader();
133
- const decoder = new TextDecoder();
134
- let buffered = '';
135
138
  try {
136
- while (true) {
137
- const { value, done } = await reader.read();
138
- if (done) {
139
- break;
139
+ await readStreamLines(child.process.stdout, (line) => this.readLine(child, line), () => {
140
+ if (this.child === child) {
141
+ this.failed(new Error(`Claude wrote a frame of more than ${MAX_LINE_LENGTH / 1_000_000} million characters, which was dropped`));
140
142
  }
141
- buffered += decoder.decode(value, { stream: true });
142
- let newline = buffered.indexOf('\n');
143
- while (newline >= 0) {
144
- this.readLine(child, buffered.slice(0, newline));
145
- buffered = buffered.slice(newline + 1);
146
- newline = buffered.indexOf('\n');
147
- }
148
- }
149
- if (buffered.trim() !== '') {
150
- this.readLine(child, buffered);
151
- }
143
+ });
152
144
  }
153
145
  catch (error) {
154
146
  if (this.child === child) {
155
- this.failed(error);
147
+ this.broken(error);
156
148
  }
157
149
  }
158
- finally {
159
- reader.releaseLock();
160
- }
161
150
  }
162
151
  handleLine(child, line) {
163
152
  if (this.child !== child || line.trim() === '') {
@@ -174,10 +163,14 @@ export class ClaudeBackend {
174
163
  if (echoed?.type === 'user' && typeof echoed.uuid === 'string' && echoed.parent_tool_use_id == null) {
175
164
  this.steers.get(echoed.uuid)?.resolve(true);
176
165
  this.steers.delete(echoed.uuid);
166
+ if (this.steers.size === 0) {
167
+ this.clearSteerTimer();
168
+ }
177
169
  }
178
170
  for (const event of this.protocol.handle(frame)) {
179
171
  if (event.type === 'turn.done') {
180
172
  this.active = false;
173
+ this.awaitSteers();
181
174
  }
182
175
  if (event.type === 'turn.done' && this.interrupted) {
183
176
  this.interrupted = false;
@@ -195,13 +188,38 @@ export class ClaudeBackend {
195
188
  this.failed(error);
196
189
  }
197
190
  }
191
+ awaitSteers() {
192
+ if (this.steers.size === 0 || this.steerTimer !== null) {
193
+ return;
194
+ }
195
+ this.steerTimer = setTimeout(() => {
196
+ this.steerTimer = null;
197
+ for (const pending of this.steers.values()) {
198
+ pending.reject(new Error('Claude ended its turn without taking your message'));
199
+ }
200
+ this.steers.clear();
201
+ }, STEER_GRACE_MS);
202
+ this.steerTimer.unref?.();
203
+ }
204
+ clearSteerTimer() {
205
+ if (this.steerTimer !== null) {
206
+ clearTimeout(this.steerTimer);
207
+ this.steerTimer = null;
208
+ }
209
+ }
198
210
  rejectSteers(reason) {
199
211
  this.active = false;
212
+ this.clearSteerTimer();
200
213
  for (const pending of this.steers.values()) {
201
214
  pending.reject(new Error(reason));
202
215
  }
203
216
  this.steers.clear();
204
217
  }
218
+ /* A pipe that failed takes no more writes; a frame a consumer choked on leaves it as it was. */
219
+ broken(error) {
220
+ this.stdinClosed = true;
221
+ this.failed(error);
222
+ }
205
223
  failed(error) {
206
224
  this.rejectSteers(`Claude transport failed: ${errorText(error)}`);
207
225
  this.protocol.forgetPending();
@@ -228,8 +246,14 @@ export class ClaudeBackend {
228
246
  if (!child || this.stdinClosed) {
229
247
  throw new Error('Claude stdin is closed');
230
248
  }
231
- child.process.stdin.write(`${JSON.stringify(frame)}\n`);
232
- child.process.stdin.flush();
249
+ try {
250
+ child.process.stdin.write(`${JSON.stringify(frame)}\n`);
251
+ child.process.stdin.flush();
252
+ }
253
+ catch (error) {
254
+ this.stdinClosed = true;
255
+ throw error;
256
+ }
233
257
  }
234
258
  closeStdin() {
235
259
  if (!this.child || this.stdinClosed) {
@@ -1,4 +1,7 @@
1
+ import type { ChatGoal } from '@adecore/agent-contracts';
1
2
  import type { ApprovalDecision, BackendEvent } from './backend.ts';
3
+ export declare function claudeGoalFromCommand(text: string, current: ChatGoal | null): ChatGoal | null | undefined;
4
+ export declare function claudeGoalCheck(text: string, current: ChatGoal | null): ChatGoal | undefined;
2
5
  export declare class ClaudeProtocol {
3
6
  private promptId;
4
7
  private readonly pending;
@@ -17,6 +20,13 @@ export declare class ClaudeProtocol {
17
20
  private readonly refusedWindows;
18
21
  private readonly stopRequests;
19
22
  private readonly steerIds;
23
+ private goal;
24
+ private goalAnswered;
25
+ private interrupting;
26
+ private readonly backgroundAgents;
27
+ constructor(goal?: ChatGoal | null);
28
+ noteInterrupt(): void;
29
+ private setGoal;
20
30
  beginSteer(promptId: string): void;
21
31
  beginPrompt(promptId: string): void;
22
32
  handle(frame: unknown): BackendEvent[];
@@ -34,6 +44,8 @@ export declare class ClaudeProtocol {
34
44
  private handleControlResponse;
35
45
  private handleStreamEvent;
36
46
  private handleAssistant;
47
+ private trackGoal;
48
+ private settleGoal;
37
49
  private handleToolProgress;
38
50
  private handleUser;
39
51
  private handleResult;
@@ -1,8 +1,6 @@
1
+ import { isRecord } from '@adecore/agent-contracts';
1
2
  import { readClaudeEvent } from '../usage/limits/normalize.js';
2
3
  import { claudeRuntimeMode } from '../providers/claude.js';
3
- function isRecord(value) {
4
- return typeof value === 'object' && value !== null && !Array.isArray(value);
5
- }
6
4
  function str(value) {
7
5
  return typeof value === 'string' ? value : null;
8
6
  }
@@ -89,6 +87,65 @@ function parseQuestions(input) {
89
87
  multiSelect: question.multiSelect === true
90
88
  }));
91
89
  }
90
+ /*
91
+ * What Claude Code 2.1.296 answers a `/goal` with, as a `<synthetic>` assistant message of its own: the goal
92
+ * it set, the one it cleared, or the one that is active with how many turns its check sent back. Undefined
93
+ * for any other text, null where no goal is left.
94
+ */
95
+ const GOAL_SET = 'Goal set: ';
96
+ const GOAL_ACTIVE = /^Goal active: ([\s\S]+?) \(([^)]*)\)(?:\nLast check: ([\s\S]*))?$/;
97
+ const GOAL_GONE = ['Goal cleared: ', 'Goal cleared after ', 'No goal set'];
98
+ export function claudeGoalFromCommand(text, current) {
99
+ const said = text.trim();
100
+ if (said.startsWith(GOAL_SET)) {
101
+ const objective = said.slice(GOAL_SET.length).trim();
102
+ return objective === '' ? undefined : { objective, status: 'active', checks: 0 };
103
+ }
104
+ if (GOAL_GONE.some((prefix) => said.startsWith(prefix))) {
105
+ return null;
106
+ }
107
+ const active = GOAL_ACTIVE.exec(said);
108
+ if (!active) {
109
+ return undefined;
110
+ }
111
+ const objective = active[1];
112
+ const checks = /\d+/.exec(active[2])?.[0];
113
+ const lastCheck = active[3]?.trim();
114
+ return {
115
+ ...(current?.objective === objective ? current : {}),
116
+ objective,
117
+ status: 'active',
118
+ checks: checks === undefined ? 0 : Number(checks),
119
+ ...(lastCheck ? { lastCheck } : {})
120
+ };
121
+ }
122
+ // The messages whose blocks are still counted. The frames of one message come close together, so only the newest can still grow.
123
+ const COUNTED_MESSAGES = 256;
124
+ /* The ordinal of the next block of `messageId`; a count moves to the back on every block and the oldest goes. */
125
+ function nextOrdinal(counts, messageId) {
126
+ const ordinal = counts.get(messageId) ?? 0;
127
+ counts.delete(messageId);
128
+ counts.set(messageId, ordinal + 1);
129
+ if (counts.size > COUNTED_MESSAGES) {
130
+ counts.delete(counts.keys().next().value);
131
+ }
132
+ return ordinal;
133
+ }
134
+ /*
135
+ * One check of the goal's Stop hook that found it not met yet: a synthetic user message the CLI feeds the
136
+ * agent before it goes on, which names the objective in brackets. Undefined for any other message.
137
+ */
138
+ export function claudeGoalCheck(text, current) {
139
+ if (current === null) {
140
+ return undefined;
141
+ }
142
+ const prefix = `Stop hook feedback:\n[${current.objective}]: `;
143
+ if (!text.startsWith(prefix)) {
144
+ return undefined;
145
+ }
146
+ const lastCheck = text.slice(prefix.length).trim();
147
+ return { ...current, status: 'active', checks: (current.checks ?? 0) + 1, ...(lastCheck === '' ? {} : { lastCheck }) };
148
+ }
92
149
  /*
93
150
  * Claude Code's stream-json frames, in and out. Text is keyed by message id plus the ordinal of the
94
151
  * text block inside that message, because the streaming events and the final `assistant` frames
@@ -120,6 +177,25 @@ export class ClaudeProtocol {
120
177
  // The task each `stop_task` request named, so an error answer finds it again.
121
178
  stopRequests = new Map();
122
179
  steerIds = new Set();
180
+ // The goal this process works toward, which `--resume` takes up from the transcript; a model answer since it was
181
+ // set or last checked is what lets a turn that ends normally count as its check passing.
182
+ goal;
183
+ goalAnswered = false;
184
+ interrupting = false;
185
+ // Background subagents still at work; Claude Code holds a goal's check until they report.
186
+ backgroundAgents = new Set();
187
+ constructor(goal = null) {
188
+ this.goal = goal;
189
+ }
190
+ /* A stop is on its way; the turn it ends leaves the goal set but nobody working on it. */
191
+ noteInterrupt() {
192
+ this.interrupting = true;
193
+ }
194
+ setGoal(goal, events) {
195
+ this.goal = goal;
196
+ this.goalAnswered = false;
197
+ events.push({ type: 'goal', goal });
198
+ }
123
199
  beginSteer(promptId) {
124
200
  this.steerIds.add(promptId);
125
201
  }
@@ -313,6 +389,7 @@ export class ClaudeProtocol {
313
389
  if (taskId) {
314
390
  this.endBackground(taskId, events);
315
391
  this.workflows.delete(taskId);
392
+ this.backgroundAgents.delete(taskId);
316
393
  }
317
394
  // The CLI wakes the main agent itself when a background task settles; this frame is the only
318
395
  // thing that says what it was about, and it arrives before the turn nobody asked for.
@@ -335,6 +412,10 @@ export class ClaudeProtocol {
335
412
  handleTaskStarted(frame, events) {
336
413
  const ref = str(frame.tool_use_id);
337
414
  if (frame.task_type === 'local_agent') {
415
+ const agentTask = str(frame.task_id);
416
+ if (agentTask && frame.is_backgrounded === true) {
417
+ this.backgroundAgents.add(agentTask);
418
+ }
338
419
  if (ref) {
339
420
  events.push({
340
421
  type: 'task.started',
@@ -383,6 +464,7 @@ export class ClaudeProtocol {
383
464
  const status = str(patch.status);
384
465
  if (status !== null && ENDED_TASK_STATUSES.has(status)) {
385
466
  this.foregroundShells.delete(taskId);
467
+ this.backgroundAgents.delete(taskId);
386
468
  this.endBackground(taskId, events);
387
469
  this.endWorkflow(taskId, null, status === 'completed', events);
388
470
  }
@@ -485,6 +567,9 @@ export class ClaudeProtocol {
485
567
  this.apiError = frame.error;
486
568
  }
487
569
  const model = str(message.model);
570
+ if (!parentRef) {
571
+ this.trackGoal(model, content, events);
572
+ }
488
573
  // The CLI answers some frames itself and names them `<synthetic>`; no model wrote those.
489
574
  if (parentRef && model && model !== '<synthetic>' && this.subagentModels.get(parentRef) !== model) {
490
575
  this.subagentModels.set(parentRef, model);
@@ -495,14 +580,12 @@ export class ClaudeProtocol {
495
580
  continue;
496
581
  }
497
582
  if (block.type === 'text') {
498
- const ordinal = this.frameTextCount.get(messageId) ?? 0;
499
- this.frameTextCount.set(messageId, ordinal + 1);
583
+ const ordinal = nextOrdinal(this.frameTextCount, messageId);
500
584
  // A subagent's text is its own report, not the thread's answer; the parent says whose row it is.
501
585
  events.push({ type: 'text.done', ref: this.textRef(messageId, ordinal), text: str(block.text) ?? '', ...(parentRef ? { parentRef } : {}) });
502
586
  }
503
587
  else if (block.type === 'thinking' && !parentRef) {
504
- const ordinal = this.frameThinkingCount.get(messageId) ?? 0;
505
- this.frameThinkingCount.set(messageId, ordinal + 1);
588
+ const ordinal = nextOrdinal(this.frameThinkingCount, messageId);
506
589
  events.push({ type: 'thinking.done', ref: this.thinkingRef(messageId, ordinal), text: str(block.thinking) ?? '' });
507
590
  }
508
591
  else if (block.type === 'tool_use') {
@@ -524,6 +607,44 @@ export class ClaudeProtocol {
524
607
  events.push({ type: 'note', level: 'error', text: `The request failed: ${error.replaceAll('_', ' ')}` });
525
608
  }
526
609
  }
610
+ trackGoal(model, content, events) {
611
+ if (model === '<synthetic>') {
612
+ const next = claudeGoalFromCommand(resultText(content), this.goal);
613
+ if (next !== undefined) {
614
+ this.setGoal(next, events);
615
+ }
616
+ return;
617
+ }
618
+ if (this.goal === null) {
619
+ return;
620
+ }
621
+ this.goalAnswered = true;
622
+ // A message after a stop takes the goal up again; its hook never went away.
623
+ if (this.goal.status === 'paused') {
624
+ this.goal = { ...this.goal, status: 'active' };
625
+ events.push({ type: 'goal', goal: this.goal });
626
+ }
627
+ }
628
+ /*
629
+ * Where a turn leaves the goal. Claude Code ends a turn on its own only once the goal's check passed, so
630
+ * a turn that ends normally after the model answered met it; Claude Code 2.1.296 says nothing more
631
+ * about it in stream-json. A check it defers while background work runs leaves the goal active, and a
632
+ * stopped or failed turn leaves it set with nobody working on it.
633
+ */
634
+ settleGoal(failed, events) {
635
+ const goal = this.goal;
636
+ const interrupted = this.interrupting;
637
+ this.interrupting = false;
638
+ if (goal?.status !== 'active') {
639
+ return;
640
+ }
641
+ if (failed || interrupted) {
642
+ this.setGoal({ ...goal, status: 'paused' }, events);
643
+ }
644
+ else if (this.goalAnswered && this.backgroundTasks.size === 0 && this.workflows.size === 0 && this.backgroundAgents.size === 0) {
645
+ this.setGoal({ ...goal, status: 'complete' }, events);
646
+ }
647
+ }
527
648
  /*
528
649
  * `tool_progress` carries how long the call has run (once per 30 s for a Bash under the CLI's
529
650
  * remote gate, or a heartbeat for a slow MCP tool); the start is kept so the client can count on.
@@ -543,6 +664,13 @@ export class ClaudeProtocol {
543
664
  }
544
665
  const message = isRecord(frame.message) ? frame.message : {};
545
666
  const content = Array.isArray(message.content) ? message.content : [];
667
+ if (frame.isSynthetic === true && !str(frame.parent_tool_use_id)) {
668
+ const checked = claudeGoalCheck(resultText(message.content), this.goal);
669
+ if (checked !== undefined) {
670
+ this.setGoal(checked, events);
671
+ events.push({ type: 'note', level: 'info', text: `Goal not met yet: ${checked.lastCheck ?? checked.objective}` });
672
+ }
673
+ }
546
674
  for (const block of content) {
547
675
  if (!isRecord(block) || block.type !== 'tool_result') {
548
676
  continue;
@@ -587,6 +715,8 @@ export class ClaudeProtocol {
587
715
  const limit = failed ? this.limit() : null;
588
716
  this.apiError = null;
589
717
  this.refusedWindows.clear();
718
+ // Before the turn ends, so whoever settles work on that end already reads where the goal stands.
719
+ this.settleGoal(failed, events);
590
720
  events.push({
591
721
  type: 'turn.done',
592
722
  ...(promptId === null ? {} : { promptId }),
@@ -1,11 +1,9 @@
1
1
  export declare class ClaudeTitleReader {
2
2
  private readonly progress;
3
- private readonly found;
4
3
  private readonly projectsDir;
5
4
  private readonly chunkBytes;
6
5
  constructor(projectsDir?: string, chunkBytes?: number);
7
- forSession(agentSessionId: string, projectsDir?: string): Promise<string | null>;
6
+ forSession(agentSessionId: string, projectsDir?: string, cwd?: string): Promise<string | null>;
8
7
  forTranscript(path: string): Promise<string | null>;
9
8
  private take;
10
- private locate;
11
9
  }
@@ -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.js';
5
4
  import { usageRoots } from '../usage/roots.js';
5
+ import { findClaudeSession } from './claude-transcript.js';
6
6
  // The two records carry these words; a line without them is never parsed.
7
7
  const MARKERS = ['"ai-title"', '"custom-title"'];
8
8
  /*
@@ -14,16 +14,15 @@ const MARKERS = ['"ai-title"', '"custom-title"'];
14
14
  */
15
15
  export class ClaudeTitleReader {
16
16
  progress = new Map();
17
- found = new Map();
18
17
  projectsDir;
19
18
  chunkBytes;
20
19
  constructor(projectsDir = usageRoots().find((root) => root.provider === 'claude')?.path ?? '', chunkBytes = 4 * 1024 * 1024) {
21
20
  this.projectsDir = projectsDir;
22
21
  this.chunkBytes = chunkBytes;
23
22
  }
24
- /* The title of a session found by its id, for a chat, which knows no transcript path; in the projects of its account. */
25
- async forSession(agentSessionId, projectsDir = this.projectsDir) {
26
- const path = await this.locate(agentSessionId, projectsDir);
23
+ /* 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. */
24
+ async forSession(agentSessionId, projectsDir = this.projectsDir, cwd) {
25
+ const path = await findClaudeSession(projectsDir, agentSessionId, cwd);
27
26
  return path === null ? null : await this.forTranscript(path);
28
27
  }
29
28
  async forTranscript(path) {
@@ -57,41 +56,14 @@ export class ClaudeTitleReader {
57
56
  catch {
58
57
  return;
59
58
  }
60
- if (typeof record !== 'object' || record === null) {
59
+ if (!isRecord(record)) {
61
60
  return;
62
61
  }
63
- const entry = record;
64
- if (entry.type === 'ai-title') {
65
- progress.aiTitle = cleanTitle(entry.aiTitle) ?? progress.aiTitle;
62
+ if (record.type === 'ai-title') {
63
+ progress.aiTitle = cleanTitle(record.aiTitle) ?? progress.aiTitle;
66
64
  }
67
- else if (entry.type === 'custom-title') {
68
- progress.customTitle = cleanTitle(entry.customTitle) ?? progress.customTitle;
65
+ else if (record.type === 'custom-title') {
66
+ progress.customTitle = cleanTitle(record.customTitle) ?? progress.customTitle;
69
67
  }
70
68
  }
71
- /* The folder a transcript sits in is named after the working directory, so the file name is the only key a session id gives. */
72
- async locate(agentSessionId, projectsDir) {
73
- const known = this.found.get(agentSessionId);
74
- if (known !== undefined && existsSync(known)) {
75
- return known;
76
- }
77
- if (projectsDir === '' || agentSessionId.includes('/')) {
78
- return null;
79
- }
80
- let dirs;
81
- try {
82
- dirs = await readdir(projectsDir);
83
- }
84
- catch {
85
- return null;
86
- }
87
- const file = `${agentSessionId}.jsonl`;
88
- for (const dir of dirs) {
89
- const path = join(projectsDir, dir, file);
90
- if (existsSync(path)) {
91
- this.found.set(agentSessionId, path);
92
- return path;
93
- }
94
- }
95
- return null;
96
- }
97
69
  }
@@ -8,6 +8,7 @@ export interface SubagentMeta {
8
8
  transcript: string;
9
9
  }
10
10
  export declare function claudeProjectSlug(cwd: string): string;
11
+ export declare function findClaudeSession(projectsDir: string, sessionId: string, cwd?: string): Promise<string | null>;
11
12
  export declare function findSubagentsDir(projectsDir: string, cwd: string, sessionId: string): Promise<string | null>;
12
13
  export declare function readSubagentMetas(dir: string): Promise<SubagentMeta[]>;
13
14
  export declare function findWorkflowAgent(subagentsDir: string, agentId: string): Promise<SubagentMeta | null>;
@@ -1,13 +1,12 @@
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
+ import { isRecord } from '@adecore/agent-contracts';
5
+ import { fileExists } from '../fs.js';
4
6
  import { readLines } from '../title-file.js';
5
7
  import { ClaudeProtocol } from './claude-protocol.js';
6
8
  import { readingThread, settledReading } from './subagent-projection.js';
7
9
  const CHUNK_BYTES = 4 * 1024 * 1024;
8
- function isRecord(value) {
9
- return typeof value === 'object' && value !== null && !Array.isArray(value);
10
- }
11
10
  function str(value) {
12
11
  return typeof value === 'string' && value !== '' ? value : null;
13
12
  }
@@ -26,19 +25,30 @@ export function spokenText(content) {
26
25
  export function claudeProjectSlug(cwd) {
27
26
  return cwd.replace(/[^a-zA-Z0-9]/g, '-');
28
27
  }
28
+ // Where each session was found last, by projects folder and session id; a path whose file went is searched for again.
29
+ const sessionPaths = new Map();
29
30
  /*
30
- * Where the subagents of a session keep their transcripts. The folder of the chat's own directory
31
- * first; a slug the CLI wrote differently (or a session resumed from somewhere else) is found by the
32
- * session id alone, which is unique across every project.
31
+ * The transcript Claude Code keeps of a session, found by its id: in the folder of `cwd` first, then
32
+ * where it was found before, then in every project folder, since the id is unique across all of them
33
+ * and a slug the CLI wrote differently (or a session resumed from elsewhere) sits in another folder.
34
+ * Null when no folder holds it, and for an id that would reach outside the projects folder.
33
35
  */
34
- export async function findSubagentsDir(projectsDir, cwd, sessionId) {
35
- if (projectsDir === '' || sessionId.includes('/') || sessionId.includes('..')) {
36
+ export async function findClaudeSession(projectsDir, sessionId, cwd) {
37
+ if (projectsDir === '' || sessionId === '' || /[/\\]|\.\./.test(sessionId)) {
36
38
  return null;
37
39
  }
38
- const own = join(projectsDir, claudeProjectSlug(cwd), sessionId, 'subagents');
39
- if (existsSync(own)) {
40
+ const file = `${sessionId}.jsonl`;
41
+ const key = `${projectsDir}\n${sessionId}`;
42
+ const own = cwd === undefined ? null : join(projectsDir, claudeProjectSlug(cwd), file);
43
+ if (own !== null && (await fileExists(own))) {
44
+ sessionPaths.set(key, own);
40
45
  return own;
41
46
  }
47
+ const known = sessionPaths.get(key);
48
+ if (known !== undefined && known !== own && (await fileExists(known))) {
49
+ return known;
50
+ }
51
+ sessionPaths.delete(key);
42
52
  let dirs;
43
53
  try {
44
54
  dirs = await readdir(projectsDir);
@@ -46,13 +56,22 @@ export async function findSubagentsDir(projectsDir, cwd, sessionId) {
46
56
  catch {
47
57
  return null;
48
58
  }
49
- for (const dir of dirs) {
50
- const candidate = join(projectsDir, dir, sessionId, 'subagents');
51
- if (existsSync(candidate)) {
52
- return candidate;
53
- }
59
+ const candidates = dirs.map((dir) => join(projectsDir, dir, file)).filter((candidate) => candidate !== own);
60
+ const present = await Promise.all(candidates.map(fileExists));
61
+ const found = candidates.find((_candidate, i) => present[i]) ?? null;
62
+ if (found !== null) {
63
+ sessionPaths.set(key, found);
54
64
  }
55
- return null;
65
+ return found;
66
+ }
67
+ /* Where the subagents of a session keep their transcripts: the folder beside its own transcript, once the CLI made it. */
68
+ export async function findSubagentsDir(projectsDir, cwd, sessionId) {
69
+ const transcript = await findClaudeSession(projectsDir, sessionId, cwd);
70
+ if (transcript === null) {
71
+ return null;
72
+ }
73
+ const dir = join(dirname(transcript), sessionId, 'subagents');
74
+ return (await fileExists(dir)) ? dir : null;
56
75
  }
57
76
  /* Every meta file in a subagents folder that says which agent it is about; one that does not parse is left out. */
58
77
  export async function readSubagentMetas(dir) {
@@ -1,7 +1,20 @@
1
- import { type ChatSkill } from '@adecore/agent-contracts';
1
+ import { type ChatGoalAction, type ChatSkill } from '@adecore/agent-contracts';
2
2
  import type { ApprovalDecision, BackendHost, BackendLaunch, ChatBackend, TurnInput } from './backend.ts';
3
3
  import { type CodexClientInfo } from './codex-transport.ts';
4
4
  export declare function parseSkillsList(result: unknown): ChatSkill[];
5
+ export type GoalCommand = {
6
+ kind: 'show';
7
+ } | {
8
+ kind: 'clear';
9
+ } | {
10
+ kind: 'pause';
11
+ } | {
12
+ kind: 'resume';
13
+ } | {
14
+ kind: 'set';
15
+ objective: string;
16
+ };
17
+ export declare function parseGoalCommand(text: string): GoalCommand | null;
5
18
  export interface CodexBackendOptions {
6
19
  client?: CodexClientInfo;
7
20
  }
@@ -20,12 +33,19 @@ export declare class CodexBackend implements ChatBackend {
20
33
  private imageInputSupported;
21
34
  private turnUnnamed;
22
35
  private interruptOwed;
36
+ private goalNow;
23
37
  constructor(launch: BackendLaunch, host: BackendHost, options?: CodexBackendOptions);
24
38
  get running(): boolean;
25
39
  get pid(): number | null;
26
40
  start(): Promise<void>;
41
+ private handshake;
42
+ private readGoal;
27
43
  private turnInput;
28
44
  sendTurn(input: TurnInput): void;
45
+ private turnStartParams;
46
+ private runGoalCommand;
47
+ private reply;
48
+ goal(action: ChatGoalAction): Promise<void>;
29
49
  steerTurn(input: TurnInput): Promise<boolean>;
30
50
  private acceptPrompt;
31
51
  private fail;