@adecore/agents 0.21.1 → 0.22.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 (121) hide show
  1. package/dist/async.js +3 -8
  2. package/dist/chat/backend.d.ts +4 -0
  3. package/dist/chat/chat-core.d.ts +5 -2
  4. package/dist/chat/chat-core.js +8 -7
  5. package/dist/chat/chat-log.js +2 -2
  6. package/dist/chat/chat-session.d.ts +11 -2
  7. package/dist/chat/chat-session.js +141 -13
  8. package/dist/chat/chat-store.js +4 -7
  9. package/dist/chat/chat-title.js +4 -8
  10. package/dist/chat/claude-backend.d.ts +1 -0
  11. package/dist/chat/claude-backend.js +8 -6
  12. package/dist/chat/claude-protocol.d.ts +2 -0
  13. package/dist/chat/claude-protocol.js +21 -1
  14. package/dist/chat/claude-title.js +1 -1
  15. package/dist/chat/claude-transcript.d.ts +1 -0
  16. package/dist/chat/claude-transcript.js +1 -1
  17. package/dist/chat/codex-backend.d.ts +2 -2
  18. package/dist/chat/codex-backend.js +5 -8
  19. package/dist/chat/codex-protocol.js +1 -1
  20. package/dist/chat/codex-thread.js +4 -5
  21. package/dist/chat/composer-preferences.js +2 -3
  22. package/dist/chat/errors.d.ts +1 -1
  23. package/dist/chat/external-sessions.d.ts +26 -0
  24. package/dist/chat/external-sessions.js +136 -0
  25. package/dist/chat/fake-claude.js +73 -0
  26. package/dist/chat/limit-resume.js +2 -7
  27. package/dist/chat/thread-search.d.ts +14 -0
  28. package/dist/chat/thread-search.js +64 -0
  29. package/dist/chat/visual-store.js +8 -6
  30. package/dist/context/refusal.js +3 -7
  31. package/dist/context/verb.js +1 -5
  32. package/dist/host/agent-host.js +2 -2
  33. package/dist/host/handlers.js +1 -2
  34. package/dist/host/wiring.js +1 -1
  35. package/dist/lineage.js +2 -3
  36. package/dist/messages/deliver-message.js +6 -11
  37. package/dist/messages/deliver-notice.js +8 -12
  38. package/dist/messages/notice-store.js +3 -5
  39. package/dist/outbox/outbox-worker.js +5 -2
  40. package/dist/providers/accounts/service.d.ts +2 -1
  41. package/dist/providers/accounts/service.js +11 -13
  42. package/dist/providers/catalog.js +2 -1
  43. package/dist/providers/claude.js +0 -1
  44. package/dist/providers/detect.js +2 -10
  45. package/dist/record-directory.js +0 -1
  46. package/dist/tasks/task-coordinator.d.ts +1 -0
  47. package/dist/tasks/task-coordinator.js +34 -34
  48. package/dist/tasks/task-store.js +1 -1
  49. package/dist/tasks/task-work.js +2 -2
  50. package/dist/tasks/waiting-child.d.ts +1 -0
  51. package/dist/tasks/waiting-child.js +8 -6
  52. package/dist/tasks/wake-parent.js +15 -10
  53. package/dist/tasks/wiring.js +32 -37
  54. package/dist/usage/aggregate.js +62 -46
  55. package/dist/usage/exchange.js +1 -1
  56. package/dist/usage/limits/monitor.d.ts +2 -2
  57. package/dist/usage/limits/monitor.js +2 -2
  58. package/dist/usage/limits/probe.js +1 -1
  59. package/dist/usage/pricing.js +1 -1
  60. package/dist/usage/readers/codex.d.ts +1 -0
  61. package/dist/usage/readers/codex.js +1 -1
  62. package/dist/usage/roots.js +1 -2
  63. package/dist/usage/scanner.js +7 -9
  64. package/dist/usage/usage-service.d.ts +1 -2
  65. package/dist/usage/usage-service.js +3 -6
  66. package/package.json +3 -3
  67. package/src/async.ts +3 -9
  68. package/src/chat/backend.ts +2 -0
  69. package/src/chat/chat-core.ts +8 -7
  70. package/src/chat/chat-log.ts +2 -2
  71. package/src/chat/chat-session.ts +148 -13
  72. package/src/chat/chat-store.ts +4 -8
  73. package/src/chat/chat-title.ts +4 -8
  74. package/src/chat/claude-backend.ts +9 -6
  75. package/src/chat/claude-protocol.ts +23 -1
  76. package/src/chat/claude-title.ts +1 -1
  77. package/src/chat/claude-transcript.ts +2 -2
  78. package/src/chat/codex-backend.ts +5 -8
  79. package/src/chat/codex-protocol.ts +1 -1
  80. package/src/chat/codex-thread.ts +6 -5
  81. package/src/chat/composer-preferences.ts +2 -3
  82. package/src/chat/errors.ts +2 -0
  83. package/src/chat/external-sessions.ts +177 -0
  84. package/src/chat/fake-claude.ts +75 -1
  85. package/src/chat/limit-resume.ts +2 -7
  86. package/src/chat/thread-search.ts +87 -0
  87. package/src/chat/thread.ts +1 -1
  88. package/src/chat/visual-store.ts +9 -6
  89. package/src/chat/wake-chat.ts +4 -1
  90. package/src/context/refusal.ts +3 -7
  91. package/src/context/verb.ts +1 -5
  92. package/src/host/agent-host.ts +2 -2
  93. package/src/host/handlers.ts +1 -2
  94. package/src/host/wiring.ts +1 -1
  95. package/src/lineage.ts +2 -3
  96. package/src/messages/deliver-message.ts +6 -11
  97. package/src/messages/deliver-notice.ts +8 -12
  98. package/src/messages/notice-store.ts +3 -5
  99. package/src/outbox/outbox-worker.ts +5 -2
  100. package/src/providers/accounts/service.ts +12 -13
  101. package/src/providers/accounts/variables.ts +1 -0
  102. package/src/providers/catalog.ts +2 -1
  103. package/src/providers/claude.ts +0 -1
  104. package/src/providers/detect.ts +2 -11
  105. package/src/record-directory.ts +0 -1
  106. package/src/skills.ts +0 -1
  107. package/src/tasks/task-coordinator.ts +35 -33
  108. package/src/tasks/task-store.ts +1 -1
  109. package/src/tasks/task-work.ts +2 -2
  110. package/src/tasks/waiting-child.ts +10 -7
  111. package/src/tasks/wake-parent.ts +21 -16
  112. package/src/tasks/wiring.ts +33 -41
  113. package/src/usage/aggregate.ts +80 -50
  114. package/src/usage/exchange.ts +1 -1
  115. package/src/usage/limits/monitor.ts +4 -4
  116. package/src/usage/limits/probe.ts +2 -2
  117. package/src/usage/pricing.ts +1 -1
  118. package/src/usage/readers/codex.ts +1 -1
  119. package/src/usage/roots.ts +1 -2
  120. package/src/usage/scanner.ts +8 -10
  121. package/src/usage/usage-service.ts +5 -9
@@ -7,6 +7,7 @@ import type {
7
7
  ChatInfo,
8
8
  ChatItem,
9
9
  ChatQueuedMessage,
10
+ ChatQuoteSource,
10
11
  ChatSkill,
11
12
  ChatSubagentItem,
12
13
  ChatTurnItem,
@@ -42,6 +43,8 @@ export interface TurnCheckpoints {
42
43
  diff(cwd: string, tree: string): Promise<ChatCheckpointDiff | null>;
43
44
  /* The diff of a turn that ended, with the tree of the folder it was taken against: where that turn left the files. */
44
45
  settle(cwd: string, tree: string): Promise<{ diff: ChatCheckpointDiff; after: string } | null>;
46
+ /* The commit HEAD points at, or null when unborn or outside a repository. A host without it records no heads. */
47
+ head?(cwd: string): Promise<string | null>;
45
48
  }
46
49
 
47
50
  /* What a host says in front of the next real prompt, beside the preambles a chat keeps itself. */
@@ -123,6 +126,7 @@ export interface ChatSendExtras {
123
126
  mentions?: string[];
124
127
  skills?: string[];
125
128
  chats?: string[];
129
+ quoteSource?: ChatQuoteSource;
126
130
  attachments?: ChatAttachment[];
127
131
  }
128
132
 
@@ -193,6 +197,10 @@ export class ChatSession {
193
197
  private naming = false;
194
198
  // Set while the host goes down: a CLI dying with it must not end the turn a restart takes up again.
195
199
  private frozen = false;
200
+ // Which CLI process (generation) announced each subagent of its own, by call id; only that one can stop it.
201
+ private readonly subagentOwners = new Map<string, number>();
202
+ // Subagents a stop_task went out for, as task id to call id, until their notification settles them.
203
+ private readonly stopping = new Map<string, string>();
196
204
  // Background subagents a load found running whose transcript did not show an end yet; no CLI here will report them.
197
205
  private readonly orphans = new Set<string>();
198
206
  private pendingPreambles: readonly string[];
@@ -302,7 +310,8 @@ export class ChatSession {
302
310
  /*
303
311
  * Internal follow-ups wait for a new turn. User input goes through `sendInput`, which can steer native work.
304
312
  */
305
- send(text: string, extras: ChatSendExtras = {}): { queued: boolean; turnId: string } {
313
+ send(text: string, sent: ChatSendExtras = {}): { queued: boolean; turnId: string } {
314
+ const extras = this.vetted(sent);
306
315
  const turnId = newId('turn');
307
316
  // Behind whatever still waits (a CLI that crashed leaves its queue), so everything goes out in the order it was sent.
308
317
  if (this.busy || this.queue.length > 0 || this.replacementWaits()) {
@@ -318,6 +327,7 @@ export class ChatSession {
318
327
  ...(extras.mentions?.length ? { mentions: extras.mentions } : {}),
319
328
  ...(extras.skills?.length ? { skills: extras.skills } : {}),
320
329
  ...(extras.chats?.length ? { chats: extras.chats } : {}),
330
+ ...(extras.quoteSource ? { quoteSource: extras.quoteSource } : {}),
321
331
  ...(extras.attachments?.length ? { attachments: extras.attachments } : {})
322
332
  };
323
333
  this.setQueue([...this.queue, message]);
@@ -424,11 +434,22 @@ export class ChatSession {
424
434
  this.options.persist();
425
435
  }
426
436
 
437
+ /* A quote whose source is no reply of this thread is sent as the plain text it is; the message itself is never refused for it. */
438
+ private vetted(extras: ChatSendExtras): ChatSendExtras {
439
+ if (extras.quoteSource === undefined || this.thread.get(extras.quoteSource.itemId)?.kind === 'assistant') {
440
+ return extras;
441
+ }
442
+ const plain = { ...extras };
443
+ delete plain.quoteSource;
444
+ return plain;
445
+ }
446
+
427
447
  sendInput(text: string, extras: ChatSendExtras = {}): Promise<{ queued: boolean; turnId: string }> {
428
448
  return this.submitInput(text, extras);
429
449
  }
430
450
 
431
- private async submitInput(text: string, extras: ChatSendExtras): Promise<{ queued: boolean; turnId: string }> {
451
+ private async submitInput(text: string, sent: ChatSendExtras): Promise<{ queued: boolean; turnId: string }> {
452
+ const extras = this.vetted(sent);
432
453
  const backend = this.backend;
433
454
  const turnId = this.info.activeTurnId;
434
455
  if (
@@ -472,7 +493,8 @@ export class ChatSession {
472
493
  mentions: extras.mentions,
473
494
  skills: extras.skills,
474
495
  chats: references.ids,
475
- attachments: extras.attachments
496
+ attachments: extras.attachments,
497
+ ...(extras.quoteSource ? { quoteSource: extras.quoteSource } : {})
476
498
  }),
477
499
  this.thread.patchInfo({ queue: this.queue.filter((entry) => entry.id !== message.id) })
478
500
  ]);
@@ -562,7 +584,14 @@ export class ChatSession {
562
584
  this.setQueue(rest);
563
585
  this.dispatch(
564
586
  next.text,
565
- { mentions: next.mentions, skills: next.skills, chats: next.chats, attachments: next.attachments, uiChoice: next.uiChoice },
587
+ {
588
+ mentions: next.mentions,
589
+ skills: next.skills,
590
+ chats: next.chats,
591
+ attachments: next.attachments,
592
+ uiChoice: next.uiChoice,
593
+ quoteSource: next.quoteSource
594
+ },
566
595
  next.turnId
567
596
  );
568
597
  }
@@ -1212,6 +1241,98 @@ export class ChatSession {
1212
1241
  this.deliverNote({ noteId: `lost-background-${item.id}`, note: text, preamble: text });
1213
1242
  }
1214
1243
 
1244
+ /*
1245
+ * Stops one subagent of the CLI's own. The process that announced it ends it with `stop_task` and the
1246
+ * parent goes on; a process that did not start it has nothing to address, so while a turn runs that
1247
+ * is refused, and otherwise the row is only marked.
1248
+ */
1249
+ stopSubagent(toolUseId: string): 'stopping' | 'marked' {
1250
+ const row = this.thread.find('subagent', (item) => item.toolUseId === toolUseId);
1251
+ const agentId = row?.native?.agentId;
1252
+ const owned = this.subagentOwners.get(toolUseId) === this.generation;
1253
+ if (agentId !== undefined && owned && this.backend?.stopTask) {
1254
+ this.stopping.set(agentId, toolUseId);
1255
+ this.backend.stopTask(agentId);
1256
+ return 'stopping';
1257
+ }
1258
+ if (agentId !== undefined && !owned && this.thread.info.activeTurnId !== null) {
1259
+ throw new ChatError('subagent-stale', 'This sub-agent belongs to a process that has ended');
1260
+ }
1261
+ this.markSubagentStopped(toolUseId);
1262
+ return 'marked';
1263
+ }
1264
+
1265
+ private trackSubagentOwner(generation: number, event: BackendEvent): void {
1266
+ if (event.type === 'task.started' && event.taskId) {
1267
+ this.subagentOwners.set(event.ref, generation);
1268
+ } else if (event.type === 'task.done') {
1269
+ this.noteStopped(event);
1270
+ if (event.ref !== null) {
1271
+ this.subagentOwners.delete(event.ref);
1272
+ }
1273
+ } else if (event.type === 'exit') {
1274
+ this.stopping.clear();
1275
+ }
1276
+ }
1277
+
1278
+ /* The CLI would not stop it: with no turn running the row is only marked, and while one runs a note says it still goes on. */
1279
+ private refuseStop(agentId: string, message: string): void {
1280
+ const toolUseId = this.stopping.get(agentId);
1281
+ if (toolUseId === undefined) {
1282
+ return;
1283
+ }
1284
+ this.stopping.delete(agentId);
1285
+ const row = this.thread.find('subagent', (item) => item.toolUseId === toolUseId);
1286
+ if (row?.status !== 'running') {
1287
+ return;
1288
+ }
1289
+ if (this.thread.info.activeTurnId === null) {
1290
+ this.markSubagentStopped(toolUseId);
1291
+ return;
1292
+ }
1293
+ this.emit([
1294
+ this.thread.upsert({
1295
+ id: `stop-refused-${toolUseId}-${Date.now()}`,
1296
+ kind: 'note',
1297
+ createdAt: Date.now(),
1298
+ turnId: null,
1299
+ level: 'warning',
1300
+ text: `"${row.description || row.subagentType || 'Sub-agent'}" could not be stopped: ${message}`
1301
+ })
1302
+ ]);
1303
+ this.options.persist();
1304
+ }
1305
+
1306
+ /* The line under a subagent that `stop_task` ended, once the CLI reported it settled. */
1307
+ private noteStopped(event: Extract<BackendEvent, { type: 'task.done' }>): void {
1308
+ const stopped = [...this.stopping].find(([agentId, toolUseId]) => agentId === event.taskId || toolUseId === event.ref);
1309
+ if (stopped === undefined) {
1310
+ return;
1311
+ }
1312
+ this.stopping.delete(stopped[0]);
1313
+ // It finished on its own just before the stop arrived; the row already says so.
1314
+ if (event.ok) {
1315
+ return;
1316
+ }
1317
+ const row = this.thread.find('subagent', (item) => item.toolUseId === stopped[1]);
1318
+ if (!row) {
1319
+ return;
1320
+ }
1321
+ const now = Date.now();
1322
+ this.emit([
1323
+ ...(row.status === 'running' ? [this.thread.upsert({ ...row, status: 'failed' as const, finishedAt: now })] : []),
1324
+ this.thread.upsert({
1325
+ id: `stopped-${row.toolUseId}`,
1326
+ kind: 'note',
1327
+ createdAt: now,
1328
+ turnId: null,
1329
+ level: 'info',
1330
+ text: `"${row.description || row.subagentType || 'Sub-agent'}" was stopped.`
1331
+ })
1332
+ ]);
1333
+ this.options.persist();
1334
+ }
1335
+
1215
1336
  /*
1216
1337
  * Marks a subagent of the CLI's own as stopped: `failed` with a note that says what it means, since
1217
1338
  * no CLI stops one subagent on its own. Refused while a turn runs, which may still be waiting on it.
@@ -1325,7 +1446,8 @@ export class ChatSession {
1325
1446
  skills,
1326
1447
  chats,
1327
1448
  attachments,
1328
- ...(extras.uiChoice ? { uiChoice: extras.uiChoice } : {})
1449
+ ...(extras.uiChoice ? { uiChoice: extras.uiChoice } : {}),
1450
+ ...(extras.quoteSource ? { quoteSource: extras.quoteSource } : {})
1329
1451
  })
1330
1452
  );
1331
1453
  }
@@ -1362,14 +1484,14 @@ export class ChatSession {
1362
1484
  if (!checkpoints) {
1363
1485
  return Promise.resolve();
1364
1486
  }
1365
- return checkpoints
1366
- .take(this.thread.info.cwd)
1367
- .then((tree) => {
1487
+ const cwd = this.thread.info.cwd;
1488
+ return Promise.all([checkpoints.take(cwd), checkpoints.head?.(cwd) ?? null])
1489
+ .then(([tree, head]) => {
1368
1490
  const turn = this.thread.get(turnId);
1369
1491
  if (tree === null || turn?.kind !== 'turn' || turn.state !== 'running' || this.info.activeTurnId !== turnId) {
1370
1492
  return;
1371
1493
  }
1372
- this.emit([this.thread.upsert({ ...turn, checkpoint: tree })]);
1494
+ this.emit([this.thread.upsert({ ...turn, checkpoint: tree, ...(head === null ? {} : { checkpointHead: head }) })]);
1373
1495
  })
1374
1496
  .catch(() => undefined);
1375
1497
  }
@@ -1380,14 +1502,22 @@ export class ChatSession {
1380
1502
  if (turn?.kind !== 'turn' || turn.checkpoint === undefined || turn.checkpointDiff || !this.options.checkpoints) {
1381
1503
  return;
1382
1504
  }
1383
- void this.options.checkpoints
1384
- .settle(this.thread.info.cwd, turn.checkpoint)
1385
- .then((answer) => {
1505
+ const { checkpoints } = this.options;
1506
+ const cwd = this.thread.info.cwd;
1507
+ void Promise.all([checkpoints.settle(cwd, turn.checkpoint), checkpoints.head?.(cwd) ?? null])
1508
+ .then(([answer, head]) => {
1386
1509
  const settled = this.thread.get(turnId);
1387
1510
  if (answer === null || settled?.kind !== 'turn') {
1388
1511
  return;
1389
1512
  }
1390
- this.emit([this.thread.upsert({ ...settled, checkpointDiff: answer.diff, checkpointAfter: answer.after })]);
1513
+ this.emit([
1514
+ this.thread.upsert({
1515
+ ...settled,
1516
+ checkpointDiff: answer.diff,
1517
+ checkpointAfter: answer.after,
1518
+ ...(head === null ? {} : { checkpointHeadAfter: head })
1519
+ })
1520
+ ]);
1391
1521
  this.options.persist();
1392
1522
  })
1393
1523
  .catch(() => undefined);
@@ -1631,6 +1761,10 @@ export class ChatSession {
1631
1761
  this.applyTitle(event.title);
1632
1762
  return;
1633
1763
  }
1764
+ if (event.type === 'stop.refused') {
1765
+ this.refuseStop(event.taskId, event.message);
1766
+ return;
1767
+ }
1634
1768
  /*
1635
1769
  * A CLI reports the window it was started on. A pick made since waits for the restart the next
1636
1770
  * send does, so its report may not put the old size back under a meter that already reads the new one.
@@ -1687,6 +1821,7 @@ export class ChatSession {
1687
1821
  this.awaitingBackgroundReport = true;
1688
1822
  }
1689
1823
  this.emit(projected);
1824
+ this.trackSubagentOwner(generation, event);
1690
1825
  this.watchWorkflows(event);
1691
1826
  if (isMainAgentOutput(event)) {
1692
1827
  this.spokenTurnId = this.thread.info.activeTurnId;
@@ -4,12 +4,12 @@ import { join } from 'node:path';
4
4
  import { ChatInfoSchema, ChatItemSchema, type ChatInfo, type ChatItem } from '@adecore/agent-contracts';
5
5
  import { z } from 'zod';
6
6
  import { isNotFound, writeAtomic, writeAtomicSync } from '../fs.ts';
7
- import { isBookmarkFileName } from './bookmark-store.ts';
7
+ import { recordFileName } from '../record-directory.ts';
8
8
  import { migrateInlineAttachments, type AttachmentStore } from './attachment-store.ts';
9
+ import { isBookmarkFileName } from './bookmark-store.ts';
9
10
  import { parseLog, type ChatLogLine } from './chat-log.ts';
10
11
  import { ChatThread } from './thread.ts';
11
12
  import { isVisualFileName } from './visual-store.ts';
12
- import { recordFileName } from '../record-directory.ts';
13
13
 
14
14
  // zod strips what it does not know inside the info and the items, so a file written by an older build
15
15
  // (with `interactionMode`, say) still parses and loses only the dropped field. A record without `seq`
@@ -30,7 +30,7 @@ const OWN_KEYS = new Set(['info', 'items', 'seq', 'resetSeq', 'preambles', 'uiAc
30
30
  /* What a host keeps in a chat's record beside the thread; it never reaches the wire. */
31
31
  export type ChatRecordExtras = Record<string, unknown>;
32
32
 
33
- function extrasOf(record: Record<string, unknown>): ChatRecordExtras {
33
+ function withoutOwnKeys(record: Record<string, unknown>): ChatRecordExtras {
34
34
  return Object.fromEntries(Object.entries(record).filter(([key]) => !OWN_KEYS.has(key)));
35
35
  }
36
36
 
@@ -90,10 +90,6 @@ function liftUiAccess(record: unknown): unknown {
90
90
  return Object.keys(uiAccess).length === 0 ? record : { ...record, uiAccess };
91
91
  }
92
92
 
93
- function withoutOwnKeys(extras: ChatRecordExtras): ChatRecordExtras {
94
- return Object.fromEntries(Object.entries(extras).filter(([key]) => !OWN_KEYS.has(key)));
95
- }
96
-
97
93
  export interface ChatStoreOptions {
98
94
  attachments?: AttachmentStore | null;
99
95
  // Files a host keeps beside a record under names that also end in .json (a chat's plans), which are no chats.
@@ -214,7 +210,7 @@ export class ChatStore {
214
210
  resetSeq,
215
211
  preambles: snapshot.preambles ?? [],
216
212
  uiAccess: snapshot.uiAccess ?? {},
217
- extras: extrasOf(snapshot),
213
+ extras: withoutOwnKeys(snapshot),
218
214
  lines
219
215
  };
220
216
  }
@@ -1,7 +1,7 @@
1
1
  import type { AgentKind } from '@adecore/agent-contracts';
2
- import { cleanTitle } from '../title-file.ts';
3
2
  import type { ProviderRegistry } from '../providers/registry.ts';
4
3
  import { runProcess } from '../run-process.ts';
4
+ import { cleanTitle } from '../title-file.ts';
5
5
 
6
6
  // Enough of a conversation to name it; the rest of a long prompt or answer says nothing a title needs.
7
7
  const MAX_PROMPT_CHARS = 2000;
@@ -19,10 +19,7 @@ function cap(text: string, limit: number): string {
19
19
  return text.length <= limit ? text : `${text.slice(0, limit)}...`;
20
20
  }
21
21
 
22
- /*
23
- * What the CLI is asked. JSON again, as for a commit message: a model left to write freely opens
24
- * with a sentence, and that sentence would become the name of the node.
25
- */
22
+ /* JSON, since a model left to write freely opens with a sentence, and that sentence would become the name. */
26
23
  export function buildTitlePrompt(prompt: string, answer: string): string {
27
24
  return [
28
25
  'Write a short title for the conversation below, as a person would name it in a list of chats.',
@@ -42,9 +39,8 @@ export function buildTitlePrompt(prompt: string, answer: string): string {
42
39
  }
43
40
 
44
41
  /*
45
- * The title out of what the CLI printed, or null. Strict on purpose, unlike a commit message: a run
46
- * that holds no `{"title": "..."}` object printed something other than a title, and a derived name
47
- * is better than a stray line of it.
42
+ * Strict on purpose: a run that holds no `{"title": "..."}` object printed something other than a
43
+ * title, and a derived name is better than a stray line of it.
48
44
  */
49
45
  export function parseTitle(output: string): string | null {
50
46
  const start = [...output.matchAll(/\{\s*"title"/g)].at(-1)?.index;
@@ -1,5 +1,5 @@
1
- import { errorText } from '../error-text.ts';
2
1
  import { randomUUID } from 'node:crypto';
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
5
  import { ChatChild } from './chat-process.ts';
@@ -71,12 +71,10 @@ export class ClaudeBackend implements ChatBackend {
71
71
  }
72
72
 
73
73
  sendTurn(input: TurnInput): void {
74
- // The prefix is what the CLI must see first (ultrathink), then the note about the links, then what was typed.
75
- const prefix = `${promptPrefix(this.launch.selection)}${input.preamble === null ? '' : `${input.preamble}\n\n`}`;
76
74
  const promptId = input.promptId ?? randomUUID();
77
75
  this.active = true;
78
76
  this.protocol.beginPrompt(promptId);
79
- this.write(buildUserMessage({ text: input.text, attachments: input.attachments, prefix, skills: input.skills, promptId }));
77
+ this.write(this.userMessage(input, promptId));
80
78
  }
81
79
 
82
80
  steerTurn(input: TurnInput): Promise<boolean> {
@@ -88,8 +86,7 @@ export class ClaudeBackend implements ChatBackend {
88
86
  this.steers.set(promptId, { resolve, reject });
89
87
  this.protocol.beginSteer(promptId);
90
88
  try {
91
- const prefix = `${promptPrefix(this.launch.selection)}${input.preamble === null ? '' : `${input.preamble}\n\n`}`;
92
- this.write(buildUserMessage({ text: input.text, attachments: input.attachments, prefix, skills: input.skills, promptId }));
89
+ this.write(this.userMessage(input, promptId));
93
90
  } catch (error) {
94
91
  this.steers.delete(promptId);
95
92
  reject(error instanceof Error ? error : new Error(errorText(error)));
@@ -147,6 +144,12 @@ export class ClaudeBackend implements ChatBackend {
147
144
  return child?.end() ?? Promise.resolve();
148
145
  }
149
146
 
147
+ private userMessage(input: TurnInput, promptId: string): Record<string, unknown> {
148
+ // What the CLI must see first (ultrathink), then the preamble, then what was typed.
149
+ const prefix = `${promptPrefix(this.launch.selection)}${input.preamble === null ? '' : `${input.preamble}\n\n`}`;
150
+ return buildUserMessage({ text: input.text, attachments: input.attachments, prefix, skills: input.skills, promptId });
151
+ }
152
+
150
153
  private async readLines(child: ChatChild): Promise<void> {
151
154
  const reader = child.process.stdout.getReader();
152
155
  const decoder = new TextDecoder();
@@ -147,6 +147,9 @@ export class ClaudeProtocol {
147
147
  private apiError: string | null = null;
148
148
  private readonly refusedWindows = new Map<string, number | null>();
149
149
 
150
+ // The task each `stop_task` request named, so an error answer finds it again.
151
+ private readonly stopRequests = new Map<string, string>();
152
+
150
153
  private readonly steerIds = new Set<string>();
151
154
 
152
155
  beginSteer(promptId: string): void {
@@ -185,6 +188,9 @@ export class ClaudeProtocol {
185
188
  case 'control_request':
186
189
  this.handleControlRequest(frame, events);
187
190
  break;
191
+ case 'control_response':
192
+ this.handleControlResponse(frame, events);
193
+ break;
188
194
  case 'control_cancel_request': {
189
195
  const requestId = str(frame.request_id);
190
196
  if (requestId) {
@@ -445,7 +451,23 @@ export class ClaudeProtocol {
445
451
 
446
452
  /* The control request that ends one task the CLI runs beside its turns. */
447
453
  stopTaskRequest(taskId: string): unknown {
448
- return { type: 'control_request', request_id: `stop-task-${taskId}-${Date.now()}`, request: { subtype: 'stop_task', task_id: taskId } };
454
+ const requestId = `stop-task-${taskId}-${Date.now()}`;
455
+ this.stopRequests.set(requestId, taskId);
456
+ return { type: 'control_request', request_id: requestId, request: { subtype: 'stop_task', task_id: taskId } };
457
+ }
458
+
459
+ /* Only a refused `stop_task` matters; every other answer is the CLI saying it took the request. */
460
+ private handleControlResponse(frame: Frame, events: BackendEvent[]): void {
461
+ const response = isRecord(frame.response) ? frame.response : null;
462
+ const requestId = str(response?.request_id);
463
+ const taskId = requestId === null ? undefined : this.stopRequests.get(requestId);
464
+ if (response === null || requestId === null || taskId === undefined) {
465
+ return;
466
+ }
467
+ this.stopRequests.delete(requestId);
468
+ if (response.subtype === 'error') {
469
+ events.push({ type: 'stop.refused', taskId, message: str(response.error) ?? 'The CLI refused to stop it' });
470
+ }
449
471
  }
450
472
 
451
473
  private handleStreamEvent(frame: Frame, events: BackendEvent[]): void {
@@ -1,8 +1,8 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { readdir, stat } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
- import { usageRoots } from '../usage/roots.ts';
5
4
  import { cleanTitle, readLines } from '../title-file.ts';
5
+ import { usageRoots } from '../usage/roots.ts';
6
6
 
7
7
  interface Progress {
8
8
  offset: number;
@@ -5,8 +5,8 @@ import type { ChatHistoryResult, ChatItem } from '@adecore/agent-contracts';
5
5
  import { readLines } from '../title-file.ts';
6
6
  import { ClaudeProtocol } from './claude-protocol.ts';
7
7
  import { readingThread, settledReading } from './subagent-projection.ts';
8
- import type { ChatThread } from './thread.ts';
9
8
  import type { ThreadProjector } from './projector.ts';
9
+ import type { ChatThread } from './thread.ts';
10
10
 
11
11
  const CHUNK_BYTES = 4 * 1024 * 1024;
12
12
 
@@ -19,7 +19,7 @@ function str(value: unknown): string | null {
19
19
  }
20
20
 
21
21
  /* The words of a user line that somebody said, or null for a line that carries tool results. */
22
- function spokenText(content: unknown): string | null {
22
+ export function spokenText(content: unknown): string | null {
23
23
  if (typeof content === 'string') {
24
24
  return content;
25
25
  }
@@ -1,11 +1,11 @@
1
1
  import { accessSync, constants } from 'node:fs';
2
2
  import { attachmentImageMime, type ChatSkill } from '@adecore/agent-contracts';
3
+ import { errorText } from '../error-text.ts';
3
4
  import { codexServiceTier, codexThreadOptions } from '../providers/codex.ts';
4
5
  import type { ApprovalDecision, BackendEvent, BackendHost, BackendLaunch, ChatBackend, TurnInput } from './backend.ts';
5
6
  import { CodexProtocol } from './codex-protocol.ts';
6
- import { attachmentNote } from './input.ts';
7
7
  import { CodexTransport, DEFAULT_CODEX_CLIENT, type CodexClientInfo, type CodexFrame } from './codex-transport.ts';
8
- import { errorText } from '../error-text.ts';
8
+ import { attachmentNote } from './input.ts';
9
9
 
10
10
  function textInput(text: string) {
11
11
  return [{ type: 'text', text, text_elements: [] }];
@@ -46,18 +46,15 @@ export interface CodexBackendOptions {
46
46
  */
47
47
  export class CodexBackend implements ChatBackend {
48
48
  readonly acknowledgesTurns = true;
49
- private promptId: string | null = null;
50
49
  private readonly launch: BackendLaunch;
51
- private imageWrites: Promise<void> | null = null;
52
50
  private readonly host: BackendHost;
53
51
  private readonly protocol: CodexProtocol;
54
52
  private readonly client: CodexClientInfo;
53
+ private promptId: string | null = null;
54
+ private imageWrites: Promise<void> | null = null;
55
55
  private transport: CodexTransport | null = null;
56
56
  private threadId = '';
57
- /*
58
- * Codex 0.154 ignores developer instructions on `thread/resume` (measured), so a thread started
59
- * before anything was linked would never hear of its links; the first prompt of a resumed process says so instead.
60
- */
57
+ // Codex 0.154 ignores developer instructions on `thread/resume` (measured), so the first prompt of a resumed process carries them.
61
58
  private contextPending: string | null = null;
62
59
  private imageInputSupported: boolean | null = null;
63
60
  /* A turn asked for that Codex has not named yet; `turn/interrupt` needs that name. */
@@ -172,7 +172,7 @@ function elicitationInput(meta: Frame): Frame {
172
172
  return input;
173
173
  }
174
174
 
175
- // Every spent window must reset; one unknown reset keeps resuming a person's choice.
175
+ // The latest reset of every spent window; one without a known reset leaves resuming to a person.
176
176
  function spentUntil(windows: readonly UsageWindow[]): number | null {
177
177
  const spent = windows.filter((window) => window.used >= 1);
178
178
  if (spent.length === 0 || spent.some((window) => window.resetsAt === null)) {
@@ -1,10 +1,10 @@
1
1
  import type { ChatItem } from '@adecore/agent-contracts';
2
+ import { errorText } from '../error-text.ts';
2
3
  import type { SpawnChatProcess } from './chat-process.ts';
3
4
  import { CodexProtocol } from './codex-protocol.ts';
4
5
  import { CodexTransport, DEFAULT_CODEX_CLIENT, type CodexClientInfo } from './codex-transport.ts';
5
6
  import { ChatError } from './errors.ts';
6
7
  import { readingThread, settledReading } from './subagent-projection.ts';
7
- import { errorText } from '../error-text.ts';
8
8
 
9
9
  function isRecord(value: unknown): value is Record<string, unknown> {
10
10
  return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -116,14 +116,15 @@ async function nthTurnId(transport: CodexTransport, threadId: string, turns: num
116
116
  let seen = 0;
117
117
  let cursor: string | null = null;
118
118
  do {
119
- const page = await transport.request('thread/turns/list', { threadId, limit: 100, sortDirection: 'asc', ...(cursor === null ? {} : { cursor }) });
120
- const data = isRecord(page) && Array.isArray(page.data) ? page.data.filter(isRecord) : [];
121
- for (const turn of data) {
119
+ const page = parseThreadItemsPage(
120
+ await transport.request('thread/turns/list', { threadId, limit: 100, sortDirection: 'asc', ...(cursor === null ? {} : { cursor }) })
121
+ );
122
+ for (const turn of page.entries) {
122
123
  if (++seen === turns) {
123
124
  return typeof turn.id === 'string' ? turn.id : null;
124
125
  }
125
126
  }
126
- cursor = isRecord(page) && typeof page.nextCursor === 'string' && page.nextCursor !== '' ? page.nextCursor : null;
127
+ cursor = page.nextCursor;
127
128
  } while (cursor !== null);
128
129
  return null;
129
130
  }
@@ -15,9 +15,8 @@ interface Held {
15
15
 
16
16
  /*
17
17
  * What each connected client says a new chat starts with, for the chats the host starts with no
18
- * client mounting them. Held per socket like the approval switch: a machine never remembers a
19
- * person's pick after that person's client is gone, so with nobody connected a chat gets the
20
- * host's own defaults.
18
+ * client mounting them. Held per client only, so with nobody connected a chat gets the host's own
19
+ * defaults rather than the pick of a person who left.
21
20
  */
22
21
  export class ComposerPreferences {
23
22
  private readonly byClient = new Map<string, Held>();
@@ -12,6 +12,8 @@ export type ChatErrorCode =
12
12
  | 'chat-unsupported'
13
13
  | 'invalid-attachments'
14
14
  | 'subagent-not-found'
15
+ // The sub-agent was started by a CLI process that has ended, so no process here can stop it.
16
+ | 'subagent-stale'
15
17
  | 'task-not-found'
16
18
  // What reading or forking a CLI's own conversation is refused with.
17
19
  | 'transcript-missing'