@adecore/agents 0.38.0 → 0.40.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 (170) hide show
  1. package/dist/async.d.ts +5 -0
  2. package/dist/async.js +13 -5
  3. package/dist/chat/acp-protocol.js +3 -5
  4. package/dist/chat/chat-core.d.ts +1 -0
  5. package/dist/chat/chat-core.js +9 -2
  6. package/dist/chat/chat-process.d.ts +1 -0
  7. package/dist/chat/chat-process.js +1 -1
  8. package/dist/chat/chat-session.d.ts +2 -0
  9. package/dist/chat/chat-session.js +9 -10
  10. package/dist/chat/claude-protocol.js +1 -6
  11. package/dist/chat/claude-transcript.js +1 -3
  12. package/dist/chat/codex-protocol.js +1 -6
  13. package/dist/chat/projector.js +1 -3
  14. package/dist/chat/request-summary.js +1 -3
  15. package/dist/coded-error.js +1 -5
  16. package/dist/context/argv.js +1 -2
  17. package/dist/context/verb.js +1 -3
  18. package/dist/error-text.d.ts +1 -0
  19. package/dist/error-text.js +4 -0
  20. package/dist/fields.d.ts +3 -0
  21. package/dist/fields.js +12 -0
  22. package/dist/fs.d.ts +1 -0
  23. package/dist/fs.js +8 -4
  24. package/dist/lineage.js +11 -5
  25. package/dist/messages/notice-store.js +3 -4
  26. package/dist/providers/accounts/acp-account.js +3 -20
  27. package/dist/providers/accounts/service.d.ts +1 -2
  28. package/dist/providers/accounts/service.js +5 -9
  29. package/dist/providers/accounts/status.js +1 -3
  30. package/dist/providers/acp-agent-state.js +1 -3
  31. package/dist/providers/claude.js +0 -1
  32. package/dist/providers/codex.js +0 -1
  33. package/dist/providers/detect.js +1 -1
  34. package/dist/providers/shell-commands.js +1 -2
  35. package/dist/run-process.js +1 -3
  36. package/dist/skills.d.ts +1 -1
  37. package/dist/skills.js +5 -8
  38. package/dist/tasks/background-limit.js +3 -2
  39. package/dist/tasks/task-coordinator.js +11 -17
  40. package/dist/testing/watch.js +0 -1
  41. package/dist/usage/limits/monitor.js +1 -2
  42. package/dist/usage/readers/claude.js +3 -10
  43. package/dist/usage/readers/codex.js +2 -9
  44. package/dist/usage/readers/grok.js +3 -10
  45. package/dist/usage/readers/host-log.js +3 -10
  46. package/dist/usage/record.d.ts +2 -0
  47. package/dist/usage/record.js +13 -0
  48. package/package.json +6 -6
  49. package/src/async.ts +23 -5
  50. package/src/chat/acp-backend.ts +121 -0
  51. package/src/chat/acp-protocol.ts +172 -6
  52. package/src/chat/attachment-store.ts +21 -0
  53. package/src/chat/backend.ts +0 -2
  54. package/src/chat/bookmark-store.ts +23 -0
  55. package/src/chat/chat-core.ts +218 -7
  56. package/src/chat/chat-log.ts +19 -0
  57. package/src/chat/chat-process.ts +52 -1
  58. package/src/chat/chat-session.ts +414 -13
  59. package/src/chat/chat-store.ts +50 -0
  60. package/src/chat/chat-title.ts +9 -0
  61. package/src/chat/claude-backend.ts +50 -0
  62. package/src/chat/claude-protocol.ts +155 -8
  63. package/src/chat/claude-title.ts +10 -0
  64. package/src/chat/claude-transcript.ts +47 -4
  65. package/src/chat/codex-backend.ts +131 -0
  66. package/src/chat/codex-protocol.ts +180 -8
  67. package/src/chat/codex-thread.ts +23 -0
  68. package/src/chat/composer-preferences.ts +6 -0
  69. package/src/chat/context-breakdown.ts +11 -0
  70. package/src/chat/delta-coalescer.ts +8 -0
  71. package/src/chat/external-sessions.ts +28 -0
  72. package/src/chat/generated-image.ts +25 -0
  73. package/src/chat/index.ts +5 -0
  74. package/src/chat/input.ts +13 -0
  75. package/src/chat/json-rpc-transport.ts +28 -0
  76. package/src/chat/limit-resume.ts +13 -0
  77. package/src/chat/projector.ts +160 -4
  78. package/src/chat/readable-text.ts +3 -0
  79. package/src/chat/request-summary.ts +18 -4
  80. package/src/chat/subagent-projection.ts +3 -0
  81. package/src/chat/subagent-reader.ts +76 -0
  82. package/src/chat/subagent-settlement.ts +24 -0
  83. package/src/chat/thread-search.ts +13 -0
  84. package/src/chat/thread.ts +31 -0
  85. package/src/chat/ui-queries.ts +122 -0
  86. package/src/chat/ui-replies.ts +34 -0
  87. package/src/chat/visual-store.ts +49 -0
  88. package/src/chat/wake-chat.ts +3 -0
  89. package/src/client-sinks.ts +2 -0
  90. package/src/coded-error.ts +1 -5
  91. package/src/context/argv.ts +18 -2
  92. package/src/context/index.ts +1 -0
  93. package/src/context/refusal.ts +3 -0
  94. package/src/context/verb.ts +32 -4
  95. package/src/coordination/index.ts +10 -0
  96. package/src/error-text.ts +7 -0
  97. package/src/fields.ts +14 -0
  98. package/src/fs.ts +20 -4
  99. package/src/host/agent-host.ts +20 -0
  100. package/src/host/environment.ts +6 -0
  101. package/src/host/handlers.ts +18 -0
  102. package/src/host/memory-port.ts +4 -0
  103. package/src/host/wiring.ts +10 -0
  104. package/src/index.ts +1 -0
  105. package/src/lineage.ts +24 -5
  106. package/src/messages/deliver-message.ts +6 -0
  107. package/src/messages/deliver-notice.ts +13 -0
  108. package/src/messages/notice-store.ts +16 -4
  109. package/src/modes.ts +1 -0
  110. package/src/outbox/outbox-worker.ts +31 -0
  111. package/src/outbox/outbox.ts +5 -0
  112. package/src/providers/accounts/accounts.ts +36 -0
  113. package/src/providers/accounts/acp-account.ts +22 -27
  114. package/src/providers/accounts/launch.ts +4 -0
  115. package/src/providers/accounts/service.ts +110 -12
  116. package/src/providers/accounts/shadow-home.ts +10 -0
  117. package/src/providers/accounts/status.ts +13 -4
  118. package/src/providers/accounts/store.ts +25 -0
  119. package/src/providers/accounts/variables.ts +11 -1
  120. package/src/providers/acp-agent-state.ts +24 -3
  121. package/src/providers/acp-provider.ts +5 -0
  122. package/src/providers/acp-usage-log.ts +2 -0
  123. package/src/providers/catalog.ts +17 -0
  124. package/src/providers/claude.ts +10 -1
  125. package/src/providers/codex-rules.ts +3 -0
  126. package/src/providers/codex.ts +2 -1
  127. package/src/providers/detect.ts +6 -1
  128. package/src/providers/provider.ts +1 -1
  129. package/src/providers/registry.ts +18 -2
  130. package/src/providers/shell-commands.ts +37 -2
  131. package/src/record-directory.ts +19 -0
  132. package/src/run-process.ts +24 -3
  133. package/src/serializer.ts +3 -0
  134. package/src/skills.ts +73 -10
  135. package/src/tasks/background-limit.ts +9 -2
  136. package/src/tasks/end-children.ts +9 -0
  137. package/src/tasks/give-task.ts +8 -0
  138. package/src/tasks/task-coordinator.ts +81 -17
  139. package/src/tasks/task-store.ts +28 -0
  140. package/src/tasks/waiting-child.ts +21 -0
  141. package/src/tasks/wake-parent.ts +26 -0
  142. package/src/tasks/wiring.ts +22 -0
  143. package/src/testing/accounts.ts +2 -0
  144. package/src/testing/fake-acp-capture.ts +16 -0
  145. package/src/testing/fake-acp.ts +66 -0
  146. package/src/testing/fake-claude.ts +177 -0
  147. package/src/testing/fake-cli.ts +29 -0
  148. package/src/testing/fake-codex.ts +122 -0
  149. package/src/testing/manual-clock.ts +2 -0
  150. package/src/testing/watch.ts +7 -1
  151. package/src/title-file.ts +12 -0
  152. package/src/usage/accounts.ts +6 -0
  153. package/src/usage/aggregate.ts +19 -0
  154. package/src/usage/exchange.ts +12 -0
  155. package/src/usage/index-file.ts +27 -0
  156. package/src/usage/limits/monitor.ts +31 -3
  157. package/src/usage/limits/normalize.ts +30 -0
  158. package/src/usage/limits/probe.ts +17 -0
  159. package/src/usage/pricing.ts +35 -0
  160. package/src/usage/projects.ts +8 -0
  161. package/src/usage/readers/claude.ts +12 -9
  162. package/src/usage/readers/codex.ts +40 -8
  163. package/src/usage/readers/grok.ts +17 -9
  164. package/src/usage/readers/host-log.ts +9 -9
  165. package/src/usage/record.ts +22 -0
  166. package/src/usage/roots.ts +6 -0
  167. package/src/usage/scanner.ts +61 -0
  168. package/src/usage/usage-service.ts +14 -0
  169. package/src/watch-seam.ts +8 -0
  170. package/src/windows-command.ts +12 -0
@@ -10,9 +10,12 @@ export function readableAssistantText(item: Pick<ChatAssistantItem, 'text' | 'ui
10
10
  if (!item.ui?.length) {
11
11
  return item.text;
12
12
  }
13
+
13
14
  const blocks = item.ui.map((block) => {
14
15
  const frozen = item.uiQueries?.blocks[block.id];
16
+
15
17
  return frozen && frozen.revision === block.revision ? { ...block, fallback: frozen.fallback } : block;
16
18
  });
19
+
17
20
  return uiFallbackText(item.text, blocks);
18
21
  }
@@ -6,10 +6,7 @@ import {
6
6
  type ChatRequestApproval,
7
7
  type ChatRequestSummary
8
8
  } from '@adecore/agent-contracts';
9
-
10
- function str(value: unknown): string | null {
11
- return typeof value === 'string' ? value : null;
12
- }
9
+ import { str } from '../fields.ts';
13
10
 
14
11
  // The keys a tool's input names its subject under, in the order a person would look for one.
15
12
  const SUBJECT_KEYS = ['file_path', 'notebook_path', 'path', 'url', 'query', 'pattern', 'skill', 'description'];
@@ -19,7 +16,9 @@ function cut(text: string, max: number): string {
19
16
  if (text.length <= max) {
20
17
  return text;
21
18
  }
19
+
22
20
  const end = /[\uD800-\uDBFF]/.test(text[max - 1] ?? '') ? max - 1 : max;
21
+
23
22
  return text.slice(0, end);
24
23
  }
25
24
 
@@ -32,7 +31,9 @@ function clipPath(path: string, max: number): string {
32
31
  if (path.length <= max) {
33
32
  return path;
34
33
  }
34
+
35
35
  const start = path.length - (max - 1);
36
+
36
37
  return `…${path.slice(/[\uDC00-\uDFFF]/.test(path[start] ?? '') ? start + 1 : start)}`;
37
38
  }
38
39
 
@@ -44,16 +45,20 @@ function firstLine(text: string): string {
44
45
  function clipLines(lines: readonly string[], maxLines: number, maxLength: number): { text: string; truncated: boolean } {
45
46
  const kept: string[] = [];
46
47
  let length = 0;
48
+
47
49
  for (const line of lines) {
48
50
  if (kept.length === maxLines || length + line.length + (kept.length > 0 ? 1 : 0) > maxLength) {
49
51
  if (kept.length === 0) {
50
52
  return { text: cut(line, maxLength), truncated: true };
51
53
  }
54
+
52
55
  return { text: kept.join('\n'), truncated: true };
53
56
  }
57
+
54
58
  length += line.length + (kept.length > 0 ? 1 : 0);
55
59
  kept.push(line);
56
60
  }
61
+
57
62
  return { text: kept.join('\n'), truncated: false };
58
63
  }
59
64
 
@@ -84,19 +89,25 @@ function fileChange(toolName: string, input: Record<string, unknown>): FileChang
84
89
  (change): change is { path: string; diff: string } => isRecord(change) && typeof change.path === 'string' && typeof change.diff === 'string'
85
90
  )
86
91
  : [];
92
+
87
93
  if (patches.length > 0) {
88
94
  return { path: patches[0]!.path, files: patches.length, lines: changedLines(patches[0]!.diff) };
89
95
  }
96
+
90
97
  const path = str(input.file_path);
98
+
91
99
  if (path === null) {
92
100
  return null;
93
101
  }
102
+
94
103
  if (toolName === 'Edit') {
95
104
  return { path, files: 1, lines: replacementLines(str(input.old_string) ?? '', str(input.new_string) ?? '') };
96
105
  }
106
+
97
107
  if (toolName === 'Write') {
98
108
  return { path, files: 1, lines: replacementLines('', str(input.content) ?? '') };
99
109
  }
110
+
100
111
  if (toolName === 'MultiEdit' && Array.isArray(input.edits)) {
101
112
  return {
102
113
  path,
@@ -104,12 +115,14 @@ function fileChange(toolName: string, input: Record<string, unknown>): FileChang
104
115
  lines: input.edits.filter(isRecord).flatMap((edit) => replacementLines(str(edit.old_string) ?? '', str(edit.new_string) ?? ''))
105
116
  };
106
117
  }
118
+
107
119
  return null;
108
120
  }
109
121
 
110
122
  function subjectOf(input: Record<string, unknown>): string {
111
123
  const named = SUBJECT_KEYS.map((key) => str(input[key])).find((value) => value !== null && value.trim() !== '');
112
124
  const first = named ?? Object.values(input).find((value): value is string => typeof value === 'string' && value.trim() !== '') ?? '';
125
+
113
126
  return clip(firstLine(first), CHAT_REQUEST_LIMITS.subject);
114
127
  }
115
128
 
@@ -120,6 +133,7 @@ export function approvalSummary(item: ChatApprovalItem): ChatRequestApproval {
120
133
  const diff = change === null ? null : clipLines(change.lines, CHAT_REQUEST_LIMITS.diffLines, CHAT_REQUEST_LIMITS.diff);
121
134
  const shell = command === null ? null : clipLines(splitLines(command), CHAT_REQUEST_LIMITS.commandLines, CHAT_REQUEST_LIMITS.command);
122
135
  const truncated = diff?.truncated === true || shell?.truncated === true;
136
+
123
137
  return {
124
138
  toolName: clip(item.toolName, CHAT_REQUEST_LIMITS.subject),
125
139
  subject:
@@ -27,6 +27,7 @@ function readingInfo(): ChatInfo {
27
27
  /* A thread and the projector that writes it, for a conversation read back rather than streamed. */
28
28
  export function readingThread(providerName: string, now: () => number): { thread: ChatThread; projector: ThreadProjector } {
29
29
  const thread = new ChatThread(readingInfo());
30
+
30
31
  return { thread, projector: new ThreadProjector(thread, { providerName, now }) };
31
32
  }
32
33
 
@@ -38,8 +39,10 @@ export function settledReading(item: ChatItem): ChatItem {
38
39
  if (item.kind === 'assistant') {
39
40
  return { ...item, turnId: null, streaming: false };
40
41
  }
42
+
41
43
  if (item.kind === 'thinking') {
42
44
  return { ...item, turnId: null, streaming: false, endedAt: item.endedAt ?? item.createdAt };
43
45
  }
46
+
44
47
  return { ...item, turnId: null };
45
48
  }
@@ -110,22 +110,27 @@ export class SubagentReader {
110
110
 
111
111
  async read(chatId: string, toolUseId: string, cursor?: string, limit = DEFAULT_PAGE): Promise<ChatSubagentResult> {
112
112
  const chat = this.require(chatId);
113
+
113
114
  return chat.info.provider === 'codex' ? this.readCodex(chat, toolUseId, cursor, limit) : this.readClaude(chat, toolUseId, cursor, limit);
114
115
  }
115
116
 
116
117
  /* Every item, for an agent reading the conversation as text; a Codex thread is paged through to its start. */
117
118
  async readAll(chatId: string, toolUseId: string): Promise<ChatItem[]> {
118
119
  const chat = this.require(chatId);
120
+
119
121
  if (chat.info.provider !== 'codex') {
120
122
  return (await this.claudeProjection(chat, toolUseId)).items();
121
123
  }
124
+
122
125
  const pages: ChatItem[][] = [];
123
126
  let cursor: string | undefined;
127
+
124
128
  do {
125
129
  const page = await this.readCodex(chat, toolUseId, cursor, 100);
126
130
  pages.unshift(page.items);
127
131
  cursor = page.history.cursor ?? undefined;
128
132
  } while (cursor !== undefined);
133
+
129
134
  return pages.flat();
130
135
  }
131
136
 
@@ -135,9 +140,11 @@ export class SubagentReader {
135
140
  */
136
141
  async claudeSettlement(chatId: string, toolUseId: string): Promise<SubagentSettlement | null> {
137
142
  const chat = this.options.chat(chatId);
143
+
138
144
  if (!chat || chat.info.provider !== 'claude') {
139
145
  return null;
140
146
  }
147
+
141
148
  try {
142
149
  return await readSubagentSettlement((await this.claudeFile(chat, toolUseId)).path);
143
150
  } catch {
@@ -149,14 +156,19 @@ export class SubagentReader {
149
156
  hold(clientId: string, chatId: string, toolUseId: string): void {
150
157
  const key = holdKey(chatId, toolUseId);
151
158
  const existing = this.holds.get(key);
159
+
152
160
  if (existing) {
153
161
  existing.clients.add(clientId);
162
+
154
163
  return;
155
164
  }
165
+
156
166
  const chat = this.options.chat(chatId);
167
+
157
168
  if (!chat) {
158
169
  return;
159
170
  }
171
+
160
172
  const stop = chat.info.provider === 'codex' ? this.pollCodex(key, chatId, toolUseId) : this.watchClaude(key);
161
173
  this.holds.set(key, { chatId, toolUseId, clients: new Set([clientId]), stop });
162
174
  }
@@ -164,10 +176,13 @@ export class SubagentReader {
164
176
  release(clientId: string, chatId: string, toolUseId: string): void {
165
177
  const key = holdKey(chatId, toolUseId);
166
178
  const hold = this.holds.get(key);
179
+
167
180
  if (!hold) {
168
181
  return;
169
182
  }
183
+
170
184
  hold.clients.delete(clientId);
185
+
171
186
  if (hold.clients.size === 0) {
172
187
  this.drop(key, hold);
173
188
  }
@@ -182,17 +197,20 @@ export class SubagentReader {
182
197
  /* Forgets everything about a chat that is gone. */
183
198
  releaseChat(chatId: string): void {
184
199
  const ofChat = (key: string): boolean => key.startsWith(`${chatId}\n`);
200
+
185
201
  for (const [key, hold] of [...this.holds]) {
186
202
  if (ofChat(key)) {
187
203
  this.drop(key, hold);
188
204
  }
189
205
  }
206
+
190
207
  for (const [key, file] of [...this.claudeFiles]) {
191
208
  if (ofChat(key)) {
192
209
  this.claudeFiles.delete(key);
193
210
  this.projections.delete(file.path);
194
211
  }
195
212
  }
213
+
196
214
  for (const map of [this.codexRows, this.codexSeen]) {
197
215
  for (const key of [...map.keys()]) {
198
216
  if (ofChat(key)) {
@@ -209,9 +227,11 @@ export class SubagentReader {
209
227
 
210
228
  private require(chatId: string): SubagentChat {
211
229
  const chat = this.options.chat(chatId);
230
+
212
231
  if (!chat) {
213
232
  throw new ChatError('chat-not-found', `No chat ${chatId}`);
214
233
  }
234
+
215
235
  return chat;
216
236
  }
217
237
 
@@ -224,10 +244,13 @@ export class SubagentReader {
224
244
  /* Running while its row says so, whether that row is in the chat's thread or in a conversation read before. */
225
245
  private live(chat: SubagentChat, toolUseId: string, inside: ChatItem[]): boolean {
226
246
  const workflowAgent = workflowAgentIdOf(toolUseId);
247
+
227
248
  if (workflowAgent !== null) {
228
249
  return workflowAgentRunning(chat.items(), workflowAgent);
229
250
  }
251
+
230
252
  const row = findSubagent(chat.items(), toolUseId) ?? findSubagent(inside, toolUseId);
253
+
231
254
  return row?.kind === 'subagent' && row.status === 'running';
232
255
  }
233
256
 
@@ -235,6 +258,7 @@ export class SubagentReader {
235
258
  const projection = await this.claudeProjection(chat, toolUseId);
236
259
  const page = projection.page(limit, cursor);
237
260
  const seen = [...this.projections.values()].filter((entry) => entry.chatId === chat.info.chatId).flatMap((entry) => entry.projection.items());
261
+
238
262
  return {
239
263
  items: page.items,
240
264
  history: page.history,
@@ -247,31 +271,39 @@ export class SubagentReader {
247
271
  private async claudeProjection(chat: SubagentChat, toolUseId: string): Promise<TranscriptProjection> {
248
272
  const file = await this.claudeFile(chat, toolUseId);
249
273
  let entry = this.projections.get(file.path);
274
+
250
275
  if (!entry) {
251
276
  entry = { chatId: chat.info.chatId, projection: new TranscriptProjection(file.path) };
252
277
  this.projections.set(file.path, entry);
253
278
  this.trimProjections();
254
279
  }
280
+
255
281
  if (!(await entry.projection.refresh())) {
256
282
  throw new ChatError('subagent-not-found', 'Claude has not written a transcript for this subagent');
257
283
  }
284
+
258
285
  return entry.projection;
259
286
  }
260
287
 
261
288
  private async claudeFile(chat: SubagentChat, toolUseId: string): Promise<ClaudeFile> {
262
289
  const key = holdKey(chat.info.chatId, toolUseId);
263
290
  const known = this.claudeFiles.get(key);
291
+
264
292
  if (known) {
265
293
  return known;
266
294
  }
295
+
267
296
  const found = await this.claudeSubagentOf(chat.info, toolUseId);
297
+
268
298
  if (found === null) {
269
299
  throw new ChatError('subagent-not-found', 'Claude has not written a transcript for this subagent');
270
300
  }
301
+
271
302
  const { dir, meta } = found;
272
303
  const file = { dir, path: meta.transcript };
273
304
  this.claudeFiles.set(key, file);
274
305
  chat.noteNative(toolUseId, { agentId: meta.agentId });
306
+
275
307
  return file;
276
308
  }
277
309
 
@@ -279,6 +311,7 @@ export class SubagentReader {
279
311
  private trimProjections(): void {
280
312
  const held = new Set([...this.holds.values()].map((hold) => this.claudeFiles.get(holdKey(hold.chatId, hold.toolUseId))?.path));
281
313
  const idle = [...this.projections.keys()].filter((path) => !held.has(path));
314
+
282
315
  for (const path of idle.slice(0, Math.max(0, idle.length - MAX_IDLE_PROJECTIONS))) {
283
316
  this.projections.delete(path);
284
317
  }
@@ -292,53 +325,69 @@ export class SubagentReader {
292
325
  private async claudeSubagentOf(info: ChatInfo, toolUseId: string): Promise<{ dir: string; meta: SubagentMeta } | null> {
293
326
  const seen = new Set<string>();
294
327
  let current: ChatInfo | null = info;
328
+
295
329
  while (current !== null && !seen.has(current.chatId)) {
296
330
  seen.add(current.chatId);
297
331
  const sessionId = current.agentSessionId;
332
+
298
333
  const dir =
299
334
  sessionId === null
300
335
  ? null
301
336
  : await findSubagentsDir(this.options.claudeProjectsDirOf?.(current) ?? this.options.claudeProjectsDir, current.cwd, sessionId);
337
+
302
338
  const workflowAgent = workflowAgentIdOf(toolUseId);
339
+
303
340
  if (dir !== null && workflowAgent !== null) {
304
341
  const meta = await findWorkflowAgent(dir, workflowAgent);
342
+
305
343
  if (meta !== null) {
306
344
  return { dir: dirname(meta.transcript), meta };
307
345
  }
308
346
  }
347
+
309
348
  // A grandchild has a file of its own in the same folder, found by the call that opened it like any other.
310
349
  const meta = dir === null ? undefined : (await readSubagentMetas(dir)).find((candidate) => candidate.toolUseId === toolUseId);
350
+
311
351
  if (dir !== null && meta !== undefined) {
312
352
  return { dir, meta };
313
353
  }
354
+
314
355
  const origin: string | undefined = current.forkOf?.chatId;
315
356
  current = origin === undefined || !this.options.chatInfo ? null : await this.options.chatInfo(origin);
316
357
  }
358
+
317
359
  return null;
318
360
  }
319
361
 
320
362
  private async readCodex(chat: SubagentChat, toolUseId: string, cursor: string | undefined, limit: number): Promise<ChatSubagentResult> {
321
363
  const threadId = this.codexThread(chat, toolUseId);
364
+
322
365
  if (cursor !== undefined && !cursor.startsWith(CODEX_CURSOR)) {
323
366
  throw new ChatError('history-expired', 'The conversation changed. Reload its history.');
324
367
  }
368
+
325
369
  const params: ThreadItemsParams = {
326
370
  threadId,
327
371
  limit,
328
372
  sortDirection: 'desc',
329
373
  ...(cursor === undefined ? {} : { cursor: cursor.slice(CODEX_CURSOR.length) })
330
374
  };
375
+
331
376
  const { entries, nextCursor } = parseThreadItemsPage(await this.codexPage(chat, params));
377
+
332
378
  if (cursor === undefined) {
333
379
  // What a poll that starts after this read compares against, so a change in between is not lost.
334
380
  this.codexSeen.set(holdKey(chat.info.chatId, toolUseId), JSON.stringify(entries.slice(0, CODEX_POLL_ITEMS)));
335
381
  }
382
+
336
383
  const items = projectCodexItems([...entries].reverse(), this.now());
384
+
337
385
  for (const item of items) {
338
386
  if (item.kind === 'subagent') {
339
387
  this.codexRows.set(holdKey(chat.info.chatId, item.toolUseId), item);
340
388
  }
341
389
  }
390
+
342
391
  return {
343
392
  items,
344
393
  history: { cursor: nextCursor === null ? null : `${CODEX_CURSOR}${nextCursor}` },
@@ -350,45 +399,58 @@ export class SubagentReader {
350
399
 
351
400
  private codexThreadOf(chat: SubagentChat, toolUseId: string): string | null {
352
401
  const row = findSubagent(chat.items(), toolUseId) ?? this.codexRows.get(holdKey(chat.info.chatId, toolUseId));
402
+
353
403
  return row?.kind === 'subagent' ? (row.native?.threadId ?? null) : null;
354
404
  }
355
405
 
356
406
  private codexThread(chat: SubagentChat, toolUseId: string): string {
357
407
  const threadId = this.codexThreadOf(chat, toolUseId);
408
+
358
409
  if (threadId === null) {
359
410
  throw new ChatError('subagent-not-found', 'Codex has not said which thread this subagent works in');
360
411
  }
412
+
361
413
  return threadId;
362
414
  }
363
415
 
364
416
  private async codexPage(chat: SubagentChat, params: ThreadItemsParams): Promise<unknown> {
365
417
  const running = chat.listThreadItems(params);
418
+
366
419
  if (running !== null) {
367
420
  return running;
368
421
  }
422
+
369
423
  const key = JSON.stringify(params);
370
424
  const now = this.now();
371
425
  const cached = this.codexCache.get(key);
426
+
372
427
  if (cached && now - cached.at < CODEX_CACHE_MS) {
373
428
  return cached.result;
374
429
  }
430
+
375
431
  const result = await this.listOnce(this.options.codexProcess(chat.info), params);
432
+
376
433
  for (const [stale, entry] of [...this.codexCache]) {
377
434
  if (now - entry.at >= CODEX_CACHE_MS) {
378
435
  this.codexCache.delete(stale);
379
436
  }
380
437
  }
438
+
381
439
  this.codexCache.set(key, { at: now, result });
440
+
382
441
  return result;
383
442
  }
384
443
 
385
444
  /* One watch per subagents folder, however many of its transcripts are held. */
386
445
  private watchClaude(key: string): () => void {
387
446
  const file = this.claudeFiles.get(key);
447
+
388
448
  if (!file) {
389
449
  return () => undefined;
390
450
  }
451
+
391
452
  let shared = this.dirWatches.get(file.dir);
453
+
392
454
  if (!shared) {
393
455
  const watcher = this.seams.watch(file.dir, { recursive: false }, (_event, filename) => this.onClaudeChange(file.dir, filename));
394
456
  // A folder that disappears ends the growing, not the host.
@@ -396,10 +458,13 @@ export class SubagentReader {
396
458
  shared = { watcher, keys: new Set() };
397
459
  this.dirWatches.set(file.dir, shared);
398
460
  }
461
+
399
462
  shared.keys.add(key);
463
+
400
464
  return () => {
401
465
  const current = this.dirWatches.get(file.dir);
402
466
  current?.keys.delete(key);
467
+
403
468
  if (current && current.keys.size === 0) {
404
469
  current.watcher.close();
405
470
  this.dirWatches.delete(file.dir);
@@ -410,6 +475,7 @@ export class SubagentReader {
410
475
  private onClaudeChange(dir: string, filename: string | null): void {
411
476
  for (const key of this.dirWatches.get(dir)?.keys ?? []) {
412
477
  const path = this.claudeFiles.get(key)?.path;
478
+
413
479
  // A platform that names no file could mean any of them.
414
480
  if (filename === null || (path !== undefined && basename(path) === filename)) {
415
481
  this.markChanged(key);
@@ -420,14 +486,17 @@ export class SubagentReader {
420
486
  private pollCodex(key: string, chatId: string, toolUseId: string): () => void {
421
487
  let cancel: (() => void) | null = null;
422
488
  let stopped = false;
489
+
423
490
  const tick = async (): Promise<void> => {
424
491
  cancel = null;
425
492
  const chat = this.options.chat(chatId);
426
493
  const threadId = chat ? this.codexThreadOf(chat, toolUseId) : null;
427
494
  const asked = chat && threadId ? chat.listThreadItems({ threadId, limit: CODEX_POLL_ITEMS, sortDirection: 'desc' }) : null;
495
+
428
496
  if (asked !== null) {
429
497
  try {
430
498
  const seen = JSON.stringify(parseThreadItemsPage(await asked).entries);
499
+
431
500
  if (!stopped && this.codexSeen.get(key) !== seen) {
432
501
  this.codexSeen.set(key, seen);
433
502
  this.markChanged(key);
@@ -436,11 +505,14 @@ export class SubagentReader {
436
505
  // The process went between the question and the answer; the next tick asks again.
437
506
  }
438
507
  }
508
+
439
509
  if (!stopped) {
440
510
  cancel = this.seams.schedule(() => tick(), CODEX_POLL_MS);
441
511
  }
442
512
  };
513
+
443
514
  cancel = this.seams.schedule(() => tick(), CODEX_POLL_MS);
515
+
444
516
  return () => {
445
517
  stopped = true;
446
518
  cancel?.();
@@ -449,9 +521,11 @@ export class SubagentReader {
449
521
 
450
522
  private markChanged(key: string): void {
451
523
  this.changed.add(key);
524
+
452
525
  if (this.cancelSettle !== null) {
453
526
  return;
454
527
  }
528
+
455
529
  this.cancelSettle = this.seams.schedule(() => this.flushChanged(), CHANGE_SETTLE_MS);
456
530
  }
457
531
 
@@ -459,8 +533,10 @@ export class SubagentReader {
459
533
  this.cancelSettle = null;
460
534
  const keys = [...this.changed];
461
535
  this.changed.clear();
536
+
462
537
  for (const key of keys) {
463
538
  const hold = this.holds.get(key);
539
+
464
540
  for (const clientId of hold?.clients ?? []) {
465
541
  this.options.notify(clientId, { chatId: hold!.chatId, toolUseId: hold!.toolUseId });
466
542
  }
@@ -14,20 +14,25 @@ export interface SubagentSettlement {
14
14
  function handbackBefore(lines: string[], end: number): string | null {
15
15
  for (let i = end - 1; i >= 0; i--) {
16
16
  let entry: unknown;
17
+
17
18
  try {
18
19
  entry = JSON.parse(lines[i]!);
19
20
  } catch {
20
21
  continue;
21
22
  }
23
+
22
24
  const message = isRecord(entry) && entry.type === 'assistant' && isRecord(entry.message) ? entry.message : null;
23
25
  const content = message && Array.isArray(message.content) ? message.content.filter(isRecord) : [];
26
+
24
27
  for (const block of content) {
25
28
  const input = block.type === 'tool_use' && block.name === 'SubagentHandback' && isRecord(block.input) ? block.input : null;
29
+
26
30
  if (input && typeof input.message === 'string' && input.message.trim() !== '') {
27
31
  return input.message;
28
32
  }
29
33
  }
30
34
  }
35
+
31
36
  return null;
32
37
  }
33
38
 
@@ -42,36 +47,48 @@ export function settlementOf(text: string): SubagentSettlement | null {
42
47
  if (!text.endsWith('\n')) {
43
48
  return null;
44
49
  }
50
+
45
51
  const lines = text.split('\n');
52
+
46
53
  for (let i = lines.length - 1; i >= 0; i--) {
47
54
  const line = lines[i]!.trim();
55
+
48
56
  if (line === '') {
49
57
  continue;
50
58
  }
59
+
51
60
  let entry: unknown;
61
+
52
62
  try {
53
63
  entry = JSON.parse(line);
54
64
  } catch {
55
65
  return null;
56
66
  }
67
+
57
68
  if (!isRecord(entry) || (entry.type !== 'user' && entry.type !== 'assistant')) {
58
69
  continue;
59
70
  }
71
+
60
72
  const message = isRecord(entry.message) ? entry.message : {};
61
73
  const content = Array.isArray(message.content) ? message.content.filter(isRecord) : [];
74
+
62
75
  if (entry.type !== 'assistant' || message.stop_reason !== 'end_turn' || content.some((block) => block.type === 'tool_use')) {
63
76
  return null;
64
77
  }
78
+
65
79
  const time = typeof entry.timestamp === 'string' ? Date.parse(entry.timestamp) : Number.NaN;
66
80
  const handedBack = handbackBefore(lines, i);
81
+
67
82
  const report =
68
83
  handedBack ??
69
84
  content
70
85
  .map((block) => (block.type === 'text' && typeof block.text === 'string' ? block.text : ''))
71
86
  .filter((part) => part.trim() !== '')
72
87
  .join('\n');
88
+
73
89
  return { finishedAt: Number.isFinite(time) ? time : null, report: report === '' ? null : report };
74
90
  }
91
+
75
92
  return null;
76
93
  }
77
94
 
@@ -81,14 +98,17 @@ export function settlementOf(text: string): SubagentSettlement | null {
81
98
  */
82
99
  export async function readSubagentSettlement(path: string): Promise<SubagentSettlement | null> {
83
100
  let before: { size: number; mtimeMs: number };
101
+
84
102
  try {
85
103
  before = await stat(path);
86
104
  } catch {
87
105
  return null;
88
106
  }
107
+
89
108
  const from = Math.max(0, before.size - TAIL_BYTES);
90
109
  const handle = await open(path, 'r');
91
110
  let text: string;
111
+
92
112
  try {
93
113
  const bytes = Buffer.alloc(before.size - from);
94
114
  const { bytesRead } = await handle.read(bytes, 0, bytes.length, from);
@@ -96,13 +116,17 @@ export async function readSubagentSettlement(path: string): Promise<SubagentSett
96
116
  } finally {
97
117
  await handle.close();
98
118
  }
119
+
99
120
  const after = await stat(path).catch(() => null);
121
+
100
122
  if (after === null || after.size !== before.size || after.mtimeMs !== before.mtimeMs) {
101
123
  return null;
102
124
  }
125
+
103
126
  if (from > 0) {
104
127
  const start = text.indexOf('\n');
105
128
  text = start < 0 ? '' : text.slice(start + 1);
106
129
  }
130
+
107
131
  return text === '' ? null : settlementOf(text);
108
132
  }
@@ -29,7 +29,9 @@ function wordStart(text: string, from: number, limit: number): number {
29
29
  if (from === 0 || /\s/.test(text[from - 1]!) || /\s/.test(text[from]!)) {
30
30
  return from;
31
31
  }
32
+
32
33
  const space = text.slice(from, limit).search(/\s/);
34
+
33
35
  return space === -1 ? from : from + space + 1;
34
36
  }
35
37
 
@@ -38,7 +40,9 @@ function wordEnd(text: string, to: number, limit: number): number {
38
40
  if (to === text.length || /\s/.test(text[to - 1]!) || /\s/.test(text[to]!)) {
39
41
  return to;
40
42
  }
43
+
41
44
  const space = text.slice(limit, to).search(/\s\S*$/);
45
+
42
46
  return space === -1 ? to : limit + space;
43
47
  }
44
48
 
@@ -48,6 +52,7 @@ function snippetOf(text: string, index: number, length: number): { snippet: stri
48
52
  const before = `${from > 0 ? '…' : ''}${plain(text.slice(from, index))}`;
49
53
  const match = plain(text.slice(index, index + length));
50
54
  const after = `${plain(text.slice(index + length, to))}${to < text.length ? '…' : ''}`;
55
+
51
56
  return { snippet: `${before}${match}${after}`, matchStart: before.length, matchLength: match.length };
52
57
  }
53
58
 
@@ -59,19 +64,25 @@ export function searchThreadItems(items: readonly ChatItem[], query: string, { l
59
64
  if (query === '' || limit <= 0) {
60
65
  return [];
61
66
  }
67
+
62
68
  const pattern = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
63
69
  const hits: ThreadHit[] = [];
70
+
64
71
  for (const item of items) {
65
72
  if (item.kind !== 'user' && item.kind !== 'assistant') {
66
73
  continue;
67
74
  }
75
+
68
76
  if (item.kind === 'assistant' && item.parentToolUseId) {
69
77
  continue;
70
78
  }
79
+
71
80
  const found = pattern.exec(item.text);
81
+
72
82
  if (found === null) {
73
83
  continue;
74
84
  }
85
+
75
86
  hits.push({
76
87
  itemId: item.id,
77
88
  author: item.kind === 'user' ? 'person' : 'agent',
@@ -79,9 +90,11 @@ export function searchThreadItems(items: readonly ChatItem[], query: string, { l
79
90
  createdAt: item.createdAt,
80
91
  turnId: item.turnId
81
92
  });
93
+
82
94
  if (hits.length >= limit) {
83
95
  break;
84
96
  }
85
97
  }
98
+
86
99
  return hits;
87
100
  }