@adecore/agents 0.39.0 → 0.40.1

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 (172) 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.js +0 -2
  5. package/dist/chat/chat-process.d.ts +1 -0
  6. package/dist/chat/chat-process.js +1 -1
  7. package/dist/chat/chat-session.js +6 -8
  8. package/dist/chat/claude-protocol.js +1 -6
  9. package/dist/chat/claude-transcript.js +1 -3
  10. package/dist/chat/codex-protocol.js +1 -6
  11. package/dist/chat/projector.js +1 -3
  12. package/dist/chat/request-summary.js +1 -3
  13. package/dist/coded-error.js +1 -5
  14. package/dist/context/argv.js +1 -2
  15. package/dist/context/verb.js +1 -3
  16. package/dist/error-text.d.ts +1 -0
  17. package/dist/error-text.js +4 -0
  18. package/dist/fields.d.ts +3 -0
  19. package/dist/fields.js +12 -0
  20. package/dist/fs.d.ts +1 -0
  21. package/dist/fs.js +8 -4
  22. package/dist/lineage.js +11 -5
  23. package/dist/messages/notice-store.js +3 -4
  24. package/dist/providers/accounts/acp-account.js +3 -20
  25. package/dist/providers/accounts/service.d.ts +1 -2
  26. package/dist/providers/accounts/service.js +5 -9
  27. package/dist/providers/accounts/status.js +1 -3
  28. package/dist/providers/acp-agent-state.js +1 -3
  29. package/dist/providers/claude.js +0 -1
  30. package/dist/providers/codex-models.json +3 -2
  31. package/dist/providers/codex.js +0 -1
  32. package/dist/providers/detect.js +1 -1
  33. package/dist/providers/shell-commands.js +1 -2
  34. package/dist/run-process.js +1 -3
  35. package/dist/skills.d.ts +1 -1
  36. package/dist/skills.js +5 -8
  37. package/dist/tasks/background-limit.js +3 -2
  38. package/dist/tasks/task-coordinator.js +11 -17
  39. package/dist/testing/watch.js +0 -1
  40. package/dist/usage/limits/monitor.js +1 -2
  41. package/dist/usage/prices-snapshot.json +8 -0
  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 +208 -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 +401 -10
  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-models.json +3 -2
  126. package/src/providers/codex-rules.ts +3 -0
  127. package/src/providers/codex.ts +2 -1
  128. package/src/providers/detect.ts +6 -1
  129. package/src/providers/provider.ts +1 -1
  130. package/src/providers/registry.ts +18 -2
  131. package/src/providers/shell-commands.ts +37 -2
  132. package/src/record-directory.ts +19 -0
  133. package/src/run-process.ts +24 -3
  134. package/src/serializer.ts +3 -0
  135. package/src/skills.ts +73 -10
  136. package/src/tasks/background-limit.ts +9 -2
  137. package/src/tasks/end-children.ts +9 -0
  138. package/src/tasks/give-task.ts +8 -0
  139. package/src/tasks/task-coordinator.ts +81 -17
  140. package/src/tasks/task-store.ts +28 -0
  141. package/src/tasks/waiting-child.ts +21 -0
  142. package/src/tasks/wake-parent.ts +26 -0
  143. package/src/tasks/wiring.ts +22 -0
  144. package/src/testing/accounts.ts +2 -0
  145. package/src/testing/fake-acp-capture.ts +16 -0
  146. package/src/testing/fake-acp.ts +66 -0
  147. package/src/testing/fake-claude.ts +177 -0
  148. package/src/testing/fake-cli.ts +29 -0
  149. package/src/testing/fake-codex.ts +122 -0
  150. package/src/testing/manual-clock.ts +2 -0
  151. package/src/testing/watch.ts +7 -1
  152. package/src/title-file.ts +12 -0
  153. package/src/usage/accounts.ts +6 -0
  154. package/src/usage/aggregate.ts +19 -0
  155. package/src/usage/exchange.ts +12 -0
  156. package/src/usage/index-file.ts +27 -0
  157. package/src/usage/limits/monitor.ts +31 -3
  158. package/src/usage/limits/normalize.ts +30 -0
  159. package/src/usage/limits/probe.ts +17 -0
  160. package/src/usage/prices-snapshot.json +8 -0
  161. package/src/usage/pricing.ts +35 -0
  162. package/src/usage/projects.ts +8 -0
  163. package/src/usage/readers/claude.ts +12 -9
  164. package/src/usage/readers/codex.ts +40 -8
  165. package/src/usage/readers/grok.ts +17 -9
  166. package/src/usage/readers/host-log.ts +9 -9
  167. package/src/usage/record.ts +22 -0
  168. package/src/usage/roots.ts +6 -0
  169. package/src/usage/scanner.ts +61 -0
  170. package/src/usage/usage-service.ts +14 -0
  171. package/src/watch-seam.ts +8 -0
  172. package/src/windows-command.ts +12 -0
@@ -50,18 +50,22 @@ export class UiReplies {
50
50
  /* The preview of every reply still streaming, as deltas, for a client that attached in the middle of one. */
51
51
  previews(): ChatEvent[] {
52
52
  const events: ChatEvent[] = [];
53
+
53
54
  for (const [itemId, preview] of this.compiled) {
54
55
  const item = this.options.thread.get(itemId);
56
+
55
57
  if (item?.kind === 'assistant' && item.streaming && item.text.length === preview.textLength) {
56
58
  events.push({ type: 'delta', itemId, text: '', ui: preview.blocks, textLength: preview.textLength });
57
59
  }
58
60
  }
61
+
59
62
  return events;
60
63
  }
61
64
 
62
65
  previewItems(items: ChatItem[]): ChatItem[] {
63
66
  return items.map((item) => {
64
67
  const preview = this.compiled.get(item.id);
68
+
65
69
  return item.kind === 'assistant' && preview?.textLength === item.text.length ? { ...item, ui: preview.blocks } : item;
66
70
  });
67
71
  }
@@ -69,27 +73,38 @@ export class UiReplies {
69
73
  /* Hands one event of the chat on, with the blocks of a reply it finished in place of its fences. */
70
74
  pass(event: ChatEvent): void {
71
75
  const { thread, emit } = this.options;
76
+
72
77
  if (event.type === 'reset') {
73
78
  this.clear();
74
79
  }
80
+
75
81
  if (event.type === 'item' && event.item.kind === 'tool') {
76
82
  this.attachments.delete(event.item.turnId);
77
83
  }
84
+
78
85
  const item = event.type === 'item' ? event.item : event.type === 'delta' ? thread.get(event.itemId) : null;
86
+
79
87
  if (item?.kind !== 'assistant' || item.parentToolUseId) {
80
88
  emit(event);
89
+
81
90
  return;
82
91
  }
92
+
83
93
  const tail = this.unscannedText(item, event.type === 'item');
94
+
84
95
  if (uiMayReferenceHost(tail)) {
85
96
  this.referencing.add(item.id);
86
97
  }
98
+
87
99
  let stream = this.streams.get(item.id);
100
+
88
101
  if (!stream && !uiHasFence(tail, this.options.fenceLanguage)) {
89
102
  this.forgetScan(item);
90
103
  emit(event);
104
+
91
105
  return;
92
106
  }
107
+
93
108
  if (!stream) {
94
109
  stream = new UiStream({
95
110
  querySchemas: this.options.host?.schemas,
@@ -101,21 +116,28 @@ export class UiReplies {
101
116
  emit({ type: 'delta', itemId: item.id, text: '', ui: preview.blocks, textLength: preview.textLength });
102
117
  }
103
118
  });
119
+
104
120
  this.streams.set(item.id, stream);
105
121
  }
122
+
106
123
  if (this.referencing.has(item.id)) {
107
124
  this.options.host?.observe(item, false);
108
125
  }
126
+
109
127
  this.forgetScan(item);
110
128
  const latestAttachment = this.latestAttachment(item.turnId);
129
+
111
130
  if (event.type !== 'item' || item.streaming) {
112
131
  emit(event);
113
132
  stream.update(item.text, latestAttachment);
133
+
114
134
  return;
115
135
  }
136
+
116
137
  const ui = stream
117
138
  .finish(item.text, latestAttachment)
118
139
  .map((block) => ({ ...block, revision: createHash('sha256').update(JSON.stringify(block)).digest('hex') }));
140
+
119
141
  this.streams.delete(item.id);
120
142
  this.compiled.delete(item.id);
121
143
  this.repair(ui.flatMap((block) => block.diagnostics));
@@ -128,6 +150,7 @@ export class UiReplies {
128
150
  private repair(diagnostics: UiDiagnostic[]): void {
129
151
  const say = (found: UiDiagnostic[], audience: 'person' | 'agent') => {
130
152
  const messages = [...new Set(found.map((diagnostic) => diagnostic.message))].slice(0, 6);
153
+
131
154
  if (messages.length > 0) {
132
155
  this.options.repair(
133
156
  `Your last ${this.options.fenceLanguage} block had these diagnostics: ${messages.join(' ')} Use the catalog's supported props and components.`.slice(
@@ -138,10 +161,12 @@ export class UiReplies {
138
161
  );
139
162
  }
140
163
  };
164
+
141
165
  say(
142
166
  diagnostics.filter((diagnostic) => diagnostic.severity !== 'warning'),
143
167
  'person'
144
168
  );
169
+
145
170
  say(
146
171
  diagnostics.filter((diagnostic) => diagnostic.severity === 'warning'),
147
172
  'agent'
@@ -152,6 +177,7 @@ export class UiReplies {
152
177
  for (const stream of this.streams.values()) {
153
178
  stream.dispose();
154
179
  }
180
+
155
181
  this.streams.clear();
156
182
  this.compiled.clear();
157
183
  this.scanned.clear();
@@ -166,11 +192,14 @@ export class UiReplies {
166
192
  private unscannedText(item: ChatAssistantItem, whole: boolean): string {
167
193
  const scanned = whole ? 0 : (this.scanned.get(item.id) ?? 0);
168
194
  this.scanned.set(item.id, item.text.length);
195
+
169
196
  if (scanned === 0 || scanned > item.text.length) {
170
197
  return item.text;
171
198
  }
199
+
172
200
  const line = item.text.lastIndexOf('\n', scanned - 1) + 1;
173
201
  const from = Math.max(line, scanned - UI_SCAN_OVERLAP);
202
+
174
203
  return from === line ? item.text.slice(from) : `\0${item.text.slice(from)}`;
175
204
  }
176
205
 
@@ -186,8 +215,10 @@ export class UiReplies {
186
215
  if (this.attachments.has(turnId)) {
187
216
  return this.attachments.get(turnId);
188
217
  }
218
+
189
219
  const attachment = this.findAttachment(turnId);
190
220
  this.attachments.set(turnId, attachment);
221
+
191
222
  return attachment;
192
223
  }
193
224
 
@@ -196,11 +227,14 @@ export class UiReplies {
196
227
  if (item.kind !== 'tool' || item.turnId !== turnId || item.name !== 'ImageGeneration' || item.state !== 'done') {
197
228
  continue;
198
229
  }
230
+
199
231
  const input = GeneratedImageInputSchema.safeParse(item.input);
232
+
200
233
  if (input.success && input.data.attachment?.mime.startsWith('image/')) {
201
234
  return input.data.attachment.id;
202
235
  }
203
236
  }
237
+
204
238
  return undefined;
205
239
  }
206
240
  }
@@ -72,26 +72,34 @@ function checked(input: VisualInput): {
72
72
  heights: VisualHeight[] | undefined;
73
73
  } {
74
74
  const title = typeof input.title === 'string' ? input.title.trim() : '';
75
+
75
76
  if (title === '') {
76
77
  refuse('visual-invalid', 'A visual needs a title; give it a short one that says what the page shows');
77
78
  }
79
+
78
80
  if (title.length > VISUAL_LIMITS.title) {
79
81
  refuse('visual-invalid', `The title has ${title.length} characters; keep it to ${VISUAL_LIMITS.title} or fewer`);
80
82
  }
83
+
81
84
  if (typeof input.html !== 'string' || input.html.trim() === '') {
82
85
  refuse('visual-invalid', 'The page is empty; pass one self-contained HTML document');
83
86
  }
87
+
84
88
  const maxHeight = input.maxHeight ?? VISUAL_LIMITS.maxHeight;
89
+
85
90
  if (!isSize(maxHeight) || maxHeight < VISUAL_LIMITS.minHeight || maxHeight > VISUAL_LIMITS.maxHeight) {
86
91
  refuse(
87
92
  'visual-invalid',
88
93
  `The maximum height must be from ${VISUAL_LIMITS.minHeight} to ${VISUAL_LIMITS.maxHeight} CSS pixels; leave it out for ${VISUAL_LIMITS.maxHeight}`
89
94
  );
90
95
  }
96
+
91
97
  if (input.layout !== undefined && !VisualLayoutSchema.safeParse(input.layout).success) {
92
98
  refuse('visual-invalid', 'The layout must be inline or wide; leave it out for the reply column');
93
99
  }
100
+
94
101
  const heights = input.heights ?? [];
102
+
95
103
  if (
96
104
  !Array.isArray(heights) ||
97
105
  heights.length > VISUAL_LIMITS.heights ||
@@ -99,9 +107,11 @@ function checked(input: VisualInput): {
99
107
  ) {
100
108
  refuse('visual-invalid', `Measured heights are at most ${VISUAL_LIMITS.heights} pairs of [width, height] in CSS pixels, with a positive width`);
101
109
  }
110
+
102
111
  // One measurement per width, the last one given, ascending as `visualFrameHeight` reads them.
103
112
  const byWidth = new Map(heights.map((pair): [number, number] => [Math.max(1, Math.round(pair[0])), Math.ceil(pair[1])]));
104
113
  const sorted = [...byWidth].sort((a, b) => a[0] - b[0]);
114
+
105
115
  return { title, html: input.html, maxHeight: Math.round(maxHeight), layout: input.layout, heights: sorted.length === 0 ? undefined : sorted };
106
116
  }
107
117
 
@@ -115,6 +125,7 @@ function tooLarge(bytes: number): never {
115
125
 
116
126
  function refuseOversized(html: string): void {
117
127
  const bytes = Buffer.byteLength(html, 'utf8');
128
+
118
129
  if (bytes > VISUAL_LIMITS.bytes) {
119
130
  tooLarge(bytes);
120
131
  }
@@ -143,6 +154,7 @@ export class VisualStore {
143
154
 
144
155
  listen(listener: VisualListener): () => void {
145
156
  this.listeners.add(listener);
157
+
146
158
  return () => {
147
159
  this.listeners.delete(listener);
148
160
  };
@@ -160,6 +172,7 @@ export class VisualStore {
160
172
  async prepareWorkspace(chatId: string): Promise<string> {
161
173
  const path = this.workspacePath(chatId);
162
174
  await mkdir(path, { recursive: true, mode: 0o700 });
175
+
163
176
  return path;
164
177
  }
165
178
 
@@ -168,10 +181,13 @@ export class VisualStore {
168
181
  if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,119}\.html$/.test(name)) {
169
182
  refuse('visual-invalid', 'Use an HTML filename such as chart.html, with letters, numbers, dots, hyphens or underscores and no directory');
170
183
  }
184
+
171
185
  if (html.trim() === '') {
172
186
  refuse('visual-invalid', 'The page is empty; pass one self-contained HTML document');
173
187
  }
188
+
174
189
  refuseOversized(html);
190
+
175
191
  return this.writeWorkspaceFile(chatId, name, html);
176
192
  }
177
193
 
@@ -185,12 +201,16 @@ export class VisualStore {
185
201
  // Before the bootstrap is added, so a page far too large is never copied first.
186
202
  refuseOversized(html);
187
203
  const page = Buffer.from(injectVisualBootstrap(html), 'utf8');
204
+
188
205
  if (page.byteLength > VISUAL_LIMITS.bytes) {
189
206
  tooLarge(page.byteLength);
190
207
  }
208
+
191
209
  const turnId = typeof input.turnId === 'string' && input.turnId !== '' ? input.turnId : undefined;
210
+
192
211
  return this.writes.run(chatId, async () => {
193
212
  const visuals = await this.readFile(chatId);
213
+
194
214
  const visual: ChatVisual = {
195
215
  id: randomBytes(8).toString('hex'),
196
216
  title,
@@ -201,16 +221,21 @@ export class VisualStore {
201
221
  size: page.byteLength,
202
222
  ...(turnId === undefined ? {} : { turnId })
203
223
  };
224
+
204
225
  await mkdir(this.attachments.folderOf(chatId), { recursive: true, mode: 0o700 });
205
226
  await writeAtomic(this.pagePath(chatId, visual.id), page);
206
227
  const next = [...visuals, visual];
228
+
207
229
  try {
208
230
  await this.writeFile(chatId, next);
209
231
  } catch (e) {
210
232
  await rm(this.pagePath(chatId, visual.id), { force: true });
233
+
211
234
  throw e;
212
235
  }
236
+
213
237
  this.tell(chatId, next);
238
+
214
239
  return visual;
215
240
  });
216
241
  }
@@ -219,13 +244,16 @@ export class VisualStore {
219
244
  remove(chatId: string, visualId: string): Promise<ChatVisual[]> {
220
245
  return this.writes.run(chatId, async () => {
221
246
  const visuals = await this.readFile(chatId);
247
+
222
248
  if (!visuals.some((visual) => visual.id === visualId)) {
223
249
  return visuals;
224
250
  }
251
+
225
252
  const next = visuals.filter((visual) => visual.id !== visualId);
226
253
  await this.writeFile(chatId, next);
227
254
  await rm(this.pagePath(chatId, visualId), { force: true });
228
255
  this.tell(chatId, next);
256
+
229
257
  return next;
230
258
  });
231
259
  }
@@ -238,6 +266,7 @@ export class VisualStore {
238
266
  await rm(this.workspacePath(chatId), { recursive: true, force: true });
239
267
  await Promise.all((visuals ?? []).map((visual) => rm(this.pagePath(chatId, visual.id), { force: true })));
240
268
  this.known.delete(chatId);
269
+
241
270
  if (visuals === null || visuals.length > 0) {
242
271
  this.tell(chatId, []);
243
272
  }
@@ -250,29 +279,36 @@ export class VisualStore {
250
279
  */
251
280
  async copyChat(fromChatId: string, toChatId: string, keep: (visual: ChatVisual) => boolean = () => true): Promise<ChatVisual[]> {
252
281
  const kept = (await this.list(fromChatId)).filter(keep);
282
+
253
283
  if (kept.length === 0) {
254
284
  return this.list(toChatId);
255
285
  }
286
+
256
287
  return this.writes.run(toChatId, async () => {
257
288
  const copied: ChatVisual[] = [];
258
289
  await mkdir(this.attachments.folderOf(toChatId), { recursive: true, mode: 0o700 });
290
+
259
291
  for (const visual of kept) {
260
292
  const page = await readFile(this.pagePath(fromChatId, visual.id)).catch((e: unknown) => {
261
293
  if (isNotFound(e)) {
262
294
  return null;
263
295
  }
296
+
264
297
  throw e;
265
298
  });
299
+
266
300
  if (page !== null) {
267
301
  await writeAtomic(this.pagePath(toChatId, visual.id), page);
268
302
  copied.push(visual);
269
303
  }
270
304
  }
305
+
271
306
  const existing = await this.readFile(toChatId);
272
307
  const ids = new Set(existing.map((visual) => visual.id));
273
308
  const next = [...existing, ...copied.filter((visual) => !ids.has(visual.id))];
274
309
  await this.writeFile(toChatId, next);
275
310
  this.tell(toChatId, next);
311
+
276
312
  return next;
277
313
  });
278
314
  }
@@ -280,9 +316,11 @@ export class VisualStore {
280
316
  /* A page as an attachment of its chat, from the lists this store has seen; null for one it does not know. */
281
317
  attachment(chatId: string, visualId: string): ChatAttachment | null {
282
318
  const visual = this.known.get(chatId)?.find((candidate) => candidate.id === visualId);
319
+
283
320
  if (!visual) {
284
321
  return null;
285
322
  }
323
+
286
324
  return { id: visual.id, name: `${visual.title}.html`, mime: 'text/html', size: visual.size, path: this.pagePath(chatId, visual.id) };
287
325
  }
288
326
 
@@ -294,6 +332,7 @@ export class VisualStore {
294
332
  return this.writes.run(chatId, async () => {
295
333
  const path = join(await this.prepareWorkspace(chatId), name);
296
334
  await writeAtomic(path, content);
335
+
297
336
  return path;
298
337
  });
299
338
  }
@@ -306,31 +345,41 @@ export class VisualStore {
306
345
 
307
346
  private async readFile(chatId: string): Promise<ChatVisual[]> {
308
347
  let raw: string;
348
+
309
349
  try {
310
350
  raw = await readFile(join(this.dir, visualFileName(chatId)), 'utf8');
311
351
  } catch (e) {
312
352
  if (isNotFound(e)) {
313
353
  this.known.delete(chatId);
354
+
314
355
  return [];
315
356
  }
357
+
316
358
  throw e;
317
359
  }
360
+
318
361
  // A file that does not parse is refused rather than read as empty, which the next write would make true.
319
362
  const parsed = VisualFileSchema.safeParse(JSON.parse(raw));
363
+
320
364
  if (!parsed.success) {
321
365
  throw new Error(`The visuals file of chat ${chatId} is not valid: ${parsed.error.issues[0]?.message ?? 'unknown problem'}`);
322
366
  }
367
+
323
368
  this.known.set(chatId, parsed.data.visuals);
369
+
324
370
  return parsed.data.visuals;
325
371
  }
326
372
 
327
373
  private async writeFile(chatId: string, visuals: readonly ChatVisual[]): Promise<void> {
328
374
  const path = join(this.dir, visualFileName(chatId));
375
+
329
376
  if (visuals.length === 0) {
330
377
  await rm(path, { force: true });
331
378
  this.known.delete(chatId);
379
+
332
380
  return;
333
381
  }
382
+
334
383
  await mkdir(this.dir, { recursive: true, mode: 0o700 });
335
384
  await writeAtomic(path, JSON.stringify({ version: 1, visuals }));
336
385
  this.known.set(chatId, [...visuals]);
@@ -29,8 +29,10 @@ export async function loadChat(deps: ChatOpenerDeps, chatId: string): Promise<Ch
29
29
  if (!deps.placed(chatId) || !(await deps.chats.hasStored(chatId))) {
30
30
  return null;
31
31
  }
32
+
32
33
  await deps.chats.create({ chatId });
33
34
  }
35
+
34
36
  return deps.chats.get(chatId) ?? null;
35
37
  }
36
38
 
@@ -38,6 +40,7 @@ export async function loadChat(deps: ChatOpenerDeps, chatId: string): Promise<Ch
38
40
  export function chatOpener(deps: ChatOpenerDeps) {
39
41
  return async (chatId: string): Promise<WakeChat | null> => {
40
42
  const session = await loadChat(deps, chatId);
43
+
41
44
  return session
42
45
  ? {
43
46
  items: () => session.thread.list(),
@@ -13,11 +13,13 @@ export class ClientSinks<TEvent> {
13
13
 
14
14
  subscribe(clientId: string, sink: (event: TEvent) => void): () => void {
15
15
  this.sinks.set(clientId, sink);
16
+
16
17
  return () => {
17
18
  // A client that subscribes again before it unsubscribes keeps its newer sink.
18
19
  if (this.sinks.get(clientId) !== sink) {
19
20
  return;
20
21
  }
22
+
21
23
  this.sinks.delete(clientId);
22
24
  this.onRelease?.(clientId);
23
25
  };
@@ -1,8 +1,4 @@
1
- /*
2
- * The shape every failure on the wire has: a code a client can branch on and a message a person reads.
3
- * A domain gives it a union of its own codes, so a typo in a code is a compile error instead of a
4
- * string the other end never matches.
5
- */
1
+ /* The shape every failure on the wire has: a code a client can branch on and a message a person reads. */
6
2
  export class CodedError<TCode extends string = string> extends Error {
7
3
  readonly code: TCode;
8
4
  /* What the actor can do instead. Only a refusal fills this; the rest carry an empty list. */
@@ -6,22 +6,26 @@ export type ParsedArgv =
6
6
  * Splits the words after a verb into positionals, `--flag value` pairs (`--flag=value` too, for a
7
7
  * value that itself starts with `--`) and the switches the verb declared. Anything not declared a
8
8
  * switch takes a value, so a flag that lost its argument is refused instead of swallowing the next
9
- * word, and a flag given twice is refused rather than one of the two silently winning. A name that is
10
- * both a switch and a flag is on when written bare and takes a value only after `=` (`--state=full`).
9
+ * word. A name that is both a switch and a flag takes a value only after `=` (`--state=full`).
11
10
  */
12
11
  export function parseArgv(argv: readonly string[], known: readonly string[], switches: readonly string[] = []): ParsedArgv {
13
12
  const positionals: string[] = [];
14
13
  const flags: Record<string, string> = {};
15
14
  const given = new Set<string>();
16
15
  const all = [...new Set([...known, ...switches])];
16
+
17
17
  for (let i = 0; i < argv.length; i++) {
18
18
  const word = argv[i]!;
19
+
19
20
  if (!word.startsWith('--')) {
20
21
  positionals.push(word);
22
+
21
23
  continue;
22
24
  }
25
+
23
26
  const equals = word.indexOf('=');
24
27
  const name = word.slice(2, equals === -1 ? undefined : equals);
28
+
25
29
  if (!all.includes(name)) {
26
30
  return {
27
31
  ok: false,
@@ -29,33 +33,45 @@ export function parseArgv(argv: readonly string[], known: readonly string[], swi
29
33
  message: all.length === 0 ? `--${name} is not a flag here; this verb takes none` : `--${name} is not one of --${all.join(', --')}`
30
34
  };
31
35
  }
36
+
32
37
  if (Object.hasOwn(flags, name) || given.has(name)) {
33
38
  return { ok: false, code: 'duplicate-flag', message: `--${name} is given twice` };
34
39
  }
40
+
35
41
  if (switches.includes(name)) {
36
42
  if (equals !== -1 && known.includes(name)) {
37
43
  flags[name] = word.slice(equals + 1);
38
44
  given.add(name);
45
+
39
46
  continue;
40
47
  }
48
+
41
49
  if (equals !== -1) {
42
50
  return { ok: false, code: 'unexpected-value', message: `--${name} takes no value; it is on when you write it and off when you do not` };
43
51
  }
52
+
44
53
  given.add(name);
54
+
45
55
  continue;
46
56
  }
57
+
47
58
  let value: string;
59
+
48
60
  if (equals !== -1) {
49
61
  value = word.slice(equals + 1);
50
62
  } else {
51
63
  const next = argv[i + 1];
64
+
52
65
  if (next === undefined || next.startsWith('--')) {
53
66
  return { ok: false, code: 'missing-value', message: `--${name} needs a value (write --${name}=<value> for one that starts with --)` };
54
67
  }
68
+
55
69
  value = next;
56
70
  i++;
57
71
  }
72
+
58
73
  flags[name] = value;
59
74
  }
75
+
60
76
  return { ok: true, positionals, flags, switches: given };
61
77
  }
@@ -1,5 +1,6 @@
1
1
  // Public API: exports.test.ts snapshots this list, so a name added or dropped here is an API change.
2
2
  export { field, refusalBody, refusalRows, type ParsedRefusal, parseRefusalBody } from './refusal.ts';
3
+
3
4
  export {
4
5
  VerbRefusal,
5
6
  type VerbCallBase,
@@ -20,6 +20,7 @@ function rows(code: string, message: string, lines: readonly string[]): string[]
20
20
  /* What a verb is refused with, whole: the `refused` row and what to do instead under it. */
21
21
  export function refusalBody(code: string, message: string, lines: readonly string[] = []): string {
22
22
  const [first, ...rest] = rows(code, message, lines);
23
+
23
24
  return [`refused\t${first}`, ...rest].join('\n');
24
25
  }
25
26
 
@@ -39,8 +40,10 @@ export function parseRefusalBody(text: string): ParsedRefusal | null {
39
40
  const [first = '', ...lines] = text.split('\n').filter((line) => line.trim() !== '');
40
41
  const parts = first.split('\t');
41
42
  const [code, message] = parts[0] === 'refused' ? parts.slice(1) : parts;
43
+
42
44
  if (!code || !message) {
43
45
  return null;
44
46
  }
47
+
45
48
  return { code, message, lines };
46
49
  }