@adecore/agents 0.39.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 (168) 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.js +0 -1
  31. package/dist/providers/detect.js +1 -1
  32. package/dist/providers/shell-commands.js +1 -2
  33. package/dist/run-process.js +1 -3
  34. package/dist/skills.d.ts +1 -1
  35. package/dist/skills.js +5 -8
  36. package/dist/tasks/background-limit.js +3 -2
  37. package/dist/tasks/task-coordinator.js +11 -17
  38. package/dist/testing/watch.js +0 -1
  39. package/dist/usage/limits/monitor.js +1 -2
  40. package/dist/usage/readers/claude.js +3 -10
  41. package/dist/usage/readers/codex.js +2 -9
  42. package/dist/usage/readers/grok.js +3 -10
  43. package/dist/usage/readers/host-log.js +3 -10
  44. package/dist/usage/record.d.ts +2 -0
  45. package/dist/usage/record.js +13 -0
  46. package/package.json +6 -6
  47. package/src/async.ts +23 -5
  48. package/src/chat/acp-backend.ts +121 -0
  49. package/src/chat/acp-protocol.ts +172 -6
  50. package/src/chat/attachment-store.ts +21 -0
  51. package/src/chat/backend.ts +0 -2
  52. package/src/chat/bookmark-store.ts +23 -0
  53. package/src/chat/chat-core.ts +208 -7
  54. package/src/chat/chat-log.ts +19 -0
  55. package/src/chat/chat-process.ts +52 -1
  56. package/src/chat/chat-session.ts +401 -10
  57. package/src/chat/chat-store.ts +50 -0
  58. package/src/chat/chat-title.ts +9 -0
  59. package/src/chat/claude-backend.ts +50 -0
  60. package/src/chat/claude-protocol.ts +155 -8
  61. package/src/chat/claude-title.ts +10 -0
  62. package/src/chat/claude-transcript.ts +47 -4
  63. package/src/chat/codex-backend.ts +131 -0
  64. package/src/chat/codex-protocol.ts +180 -8
  65. package/src/chat/codex-thread.ts +23 -0
  66. package/src/chat/composer-preferences.ts +6 -0
  67. package/src/chat/context-breakdown.ts +11 -0
  68. package/src/chat/delta-coalescer.ts +8 -0
  69. package/src/chat/external-sessions.ts +28 -0
  70. package/src/chat/generated-image.ts +25 -0
  71. package/src/chat/index.ts +5 -0
  72. package/src/chat/input.ts +13 -0
  73. package/src/chat/json-rpc-transport.ts +28 -0
  74. package/src/chat/limit-resume.ts +13 -0
  75. package/src/chat/projector.ts +160 -4
  76. package/src/chat/readable-text.ts +3 -0
  77. package/src/chat/request-summary.ts +18 -4
  78. package/src/chat/subagent-projection.ts +3 -0
  79. package/src/chat/subagent-reader.ts +76 -0
  80. package/src/chat/subagent-settlement.ts +24 -0
  81. package/src/chat/thread-search.ts +13 -0
  82. package/src/chat/thread.ts +31 -0
  83. package/src/chat/ui-queries.ts +122 -0
  84. package/src/chat/ui-replies.ts +34 -0
  85. package/src/chat/visual-store.ts +49 -0
  86. package/src/chat/wake-chat.ts +3 -0
  87. package/src/client-sinks.ts +2 -0
  88. package/src/coded-error.ts +1 -5
  89. package/src/context/argv.ts +18 -2
  90. package/src/context/index.ts +1 -0
  91. package/src/context/refusal.ts +3 -0
  92. package/src/context/verb.ts +32 -4
  93. package/src/coordination/index.ts +10 -0
  94. package/src/error-text.ts +7 -0
  95. package/src/fields.ts +14 -0
  96. package/src/fs.ts +20 -4
  97. package/src/host/agent-host.ts +20 -0
  98. package/src/host/environment.ts +6 -0
  99. package/src/host/handlers.ts +18 -0
  100. package/src/host/memory-port.ts +4 -0
  101. package/src/host/wiring.ts +10 -0
  102. package/src/index.ts +1 -0
  103. package/src/lineage.ts +24 -5
  104. package/src/messages/deliver-message.ts +6 -0
  105. package/src/messages/deliver-notice.ts +13 -0
  106. package/src/messages/notice-store.ts +16 -4
  107. package/src/modes.ts +1 -0
  108. package/src/outbox/outbox-worker.ts +31 -0
  109. package/src/outbox/outbox.ts +5 -0
  110. package/src/providers/accounts/accounts.ts +36 -0
  111. package/src/providers/accounts/acp-account.ts +22 -27
  112. package/src/providers/accounts/launch.ts +4 -0
  113. package/src/providers/accounts/service.ts +110 -12
  114. package/src/providers/accounts/shadow-home.ts +10 -0
  115. package/src/providers/accounts/status.ts +13 -4
  116. package/src/providers/accounts/store.ts +25 -0
  117. package/src/providers/accounts/variables.ts +11 -1
  118. package/src/providers/acp-agent-state.ts +24 -3
  119. package/src/providers/acp-provider.ts +5 -0
  120. package/src/providers/acp-usage-log.ts +2 -0
  121. package/src/providers/catalog.ts +17 -0
  122. package/src/providers/claude.ts +10 -1
  123. package/src/providers/codex-rules.ts +3 -0
  124. package/src/providers/codex.ts +2 -1
  125. package/src/providers/detect.ts +6 -1
  126. package/src/providers/provider.ts +1 -1
  127. package/src/providers/registry.ts +18 -2
  128. package/src/providers/shell-commands.ts +37 -2
  129. package/src/record-directory.ts +19 -0
  130. package/src/run-process.ts +24 -3
  131. package/src/serializer.ts +3 -0
  132. package/src/skills.ts +73 -10
  133. package/src/tasks/background-limit.ts +9 -2
  134. package/src/tasks/end-children.ts +9 -0
  135. package/src/tasks/give-task.ts +8 -0
  136. package/src/tasks/task-coordinator.ts +81 -17
  137. package/src/tasks/task-store.ts +28 -0
  138. package/src/tasks/waiting-child.ts +21 -0
  139. package/src/tasks/wake-parent.ts +26 -0
  140. package/src/tasks/wiring.ts +22 -0
  141. package/src/testing/accounts.ts +2 -0
  142. package/src/testing/fake-acp-capture.ts +16 -0
  143. package/src/testing/fake-acp.ts +66 -0
  144. package/src/testing/fake-claude.ts +177 -0
  145. package/src/testing/fake-cli.ts +29 -0
  146. package/src/testing/fake-codex.ts +122 -0
  147. package/src/testing/manual-clock.ts +2 -0
  148. package/src/testing/watch.ts +7 -1
  149. package/src/title-file.ts +12 -0
  150. package/src/usage/accounts.ts +6 -0
  151. package/src/usage/aggregate.ts +19 -0
  152. package/src/usage/exchange.ts +12 -0
  153. package/src/usage/index-file.ts +27 -0
  154. package/src/usage/limits/monitor.ts +31 -3
  155. package/src/usage/limits/normalize.ts +30 -0
  156. package/src/usage/limits/probe.ts +17 -0
  157. package/src/usage/pricing.ts +35 -0
  158. package/src/usage/projects.ts +8 -0
  159. package/src/usage/readers/claude.ts +12 -9
  160. package/src/usage/readers/codex.ts +40 -8
  161. package/src/usage/readers/grok.ts +17 -9
  162. package/src/usage/readers/host-log.ts +9 -9
  163. package/src/usage/record.ts +22 -0
  164. package/src/usage/roots.ts +6 -0
  165. package/src/usage/scanner.ts +61 -0
  166. package/src/usage/usage-service.ts +14 -0
  167. package/src/watch-seam.ts +8 -0
  168. package/src/windows-command.ts +12 -0
package/src/skills.ts CHANGED
@@ -3,7 +3,8 @@ import { open, readdir, readFile, realpath, stat } from 'node:fs/promises';
3
3
  import { homedir } from 'node:os';
4
4
  import { dirname, join, parse } from 'node:path';
5
5
  import type { AgentKind, ChatSkill, ChatSkillSource } from '@adecore/agent-contracts';
6
- import { isNotFound } from './fs.ts';
6
+ import { wait } from './async.ts';
7
+ import { hasErrorCode, isNotFound } from './fs.ts';
7
8
 
8
9
  export interface SkillRoot {
9
10
  dir: string;
@@ -62,7 +63,6 @@ const HEAD_BYTES = 64 * 1024;
62
63
  const MAX_DESCRIPTION = 1024;
63
64
 
64
65
  export interface SkillRootOptions {
65
- // The person's home directory; a test points it at a temporary tree.
66
66
  home?: string;
67
67
  // Where Claude Code keeps its own configuration, when it is not `<home>/.claude`.
68
68
  claudeConfigDir?: string;
@@ -72,6 +72,7 @@ const NAME = /^[A-Za-z][A-Za-z0-9_:-]*$/;
72
72
 
73
73
  function unquote(value: string): string {
74
74
  const quoted = /^(['"])([\s\S]*)\1$/.exec(value);
75
+
75
76
  return quoted ? quoted[2]! : value;
76
77
  }
77
78
 
@@ -81,28 +82,38 @@ function unquote(value: string): string {
81
82
  */
82
83
  export function parseFrontmatter(text: string): Record<string, string> {
83
84
  const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
85
+
84
86
  if (!match) {
85
87
  return {};
86
88
  }
89
+
87
90
  const fields: Record<string, string> = {};
88
91
  const lines = match[1]!.split(/\r?\n/);
92
+
89
93
  for (let i = 0; i < lines.length; i++) {
90
94
  const pair = /^([A-Za-z0-9_-]+):[ \t]*(.*)$/.exec(lines[i]!);
95
+
91
96
  if (!pair) {
92
97
  continue;
93
98
  }
99
+
94
100
  let value = pair[2]!.trim();
101
+
95
102
  if (value === '' || value === '>' || value === '>-' || value === '|' || value === '|-') {
96
103
  // A block scalar puts the value on the indented lines under it; `|` keeps the newlines.
97
104
  const keepNewlines = value.startsWith('|');
98
105
  const parts: string[] = [];
106
+
99
107
  while (i + 1 < lines.length && /^[ \t]+\S/.test(lines[i + 1]!)) {
100
108
  parts.push(lines[++i]!.trim());
101
109
  }
110
+
102
111
  value = parts.join(keepNewlines ? '\n' : ' ');
103
112
  }
113
+
104
114
  fields[pair[1]!] = unquote(value);
105
115
  }
116
+
106
117
  return fields;
107
118
  }
108
119
 
@@ -113,58 +124,76 @@ export function parseFrontmatter(text: string): Record<string, string> {
113
124
  function projectDirs(cwd: string, home: string): string[] {
114
125
  const dirs: string[] = [];
115
126
  let current = cwd;
127
+
116
128
  while (current !== '' && current !== parse(current).root && current !== home && current !== dirname(home) && dirs.length < 8) {
117
129
  dirs.push(current);
130
+
118
131
  // Anything above a repository root belongs to another project, so the walk ends there.
119
132
  if (existsSync(join(current, '.git'))) {
120
133
  break;
121
134
  }
135
+
122
136
  const parent = dirname(current);
137
+
123
138
  if (parent === current) {
124
139
  break;
125
140
  }
141
+
126
142
  current = parent;
127
143
  }
144
+
128
145
  return dirs;
129
146
  }
130
147
 
131
148
  /* The plugin folders Claude Code installed, each named the way its skills are addressed. */
132
149
  export async function pluginSkillRoots(configDir: string): Promise<SkillRoot[]> {
133
150
  let raw: string;
151
+
134
152
  try {
135
153
  raw = await readFile(join(configDir, 'plugins', 'installed_plugins.json'), 'utf8');
136
154
  } catch (e) {
137
155
  if (isNotFound(e)) {
138
156
  return [];
139
157
  }
158
+
140
159
  throw e;
141
160
  }
161
+
142
162
  let parsed: unknown;
163
+
143
164
  try {
144
165
  parsed = JSON.parse(raw);
145
166
  } catch {
146
167
  return [];
147
168
  }
169
+
148
170
  const plugins = typeof parsed === 'object' && parsed !== null ? (parsed as { plugins?: unknown }).plugins : undefined;
171
+
149
172
  if (typeof plugins !== 'object' || plugins === null) {
150
173
  return [];
151
174
  }
175
+
152
176
  const roots: SkillRoot[] = [];
177
+
153
178
  for (const [key, installs] of Object.entries(plugins as Record<string, unknown>)) {
154
179
  const name = key.split('@')[0] ?? key;
180
+
155
181
  for (const install of Array.isArray(installs) ? installs : []) {
156
182
  const path = typeof install === 'object' && install !== null ? (install as { installPath?: unknown }).installPath : undefined;
183
+
157
184
  if (typeof path === 'string' && path !== '') {
158
185
  roots.push({ dir: join(path, 'skills'), source: 'plugin', prefix: name });
159
186
  }
160
187
  }
161
188
  }
189
+
162
190
  return roots;
163
191
  }
164
192
 
165
193
  /* Where a provider's CLI looks for skills, in the order a name clash is decided. */
166
194
  export async function skillRootsFor(kind: AgentKind, cwd: string, options: SkillRootOptions = {}): Promise<SkillRoot[]> {
167
195
  const home = options.home ?? homedir();
196
+
168
197
  if (kind === 'codex') {
169
198
  return [
170
199
  { dir: join(home, '.agents', 'skills'), source: 'user', depth: CODEX_SKILL_DEPTH },
@@ -172,7 +201,9 @@ export async function skillRootsFor(kind: AgentKind, cwd: string, options: Skill
172
201
  ...projectDirs(cwd, home).map((dir): SkillRoot => ({ dir: join(dir, '.agents', 'skills'), source: 'project', depth: CODEX_SKILL_DEPTH }))
173
202
  ];
174
203
  }
204
+
175
205
  const configDir = options.claudeConfigDir ?? join(home, '.claude');
206
+
176
207
  return [
177
208
  { dir: join(configDir, 'skills'), source: 'user' },
178
209
  ...projectDirs(cwd, home).map((dir): SkillRoot => ({ dir: join(dir, '.claude', 'skills'), source: 'project' })),
@@ -182,15 +213,11 @@ export async function skillRootsFor(kind: AgentKind, cwd: string, options: Skill
182
213
 
183
214
  /* Too many files open, in this process or on the system: it says nothing about the path that was asked for. */
184
215
  function isBusy(e: unknown): boolean {
185
- return typeof e === 'object' && e !== null && 'code' in e && (e.code === 'EMFILE' || e.code === 'ENFILE');
186
- }
187
-
188
- function sleep(ms: number): Promise<void> {
189
- return new Promise((resolve) => setTimeout(resolve, ms));
216
+ return hasErrorCode(e, 'EMFILE', 'ENFILE');
190
217
  }
191
218
 
192
219
  /* Runs `operation` again a few times while the process is short of file handles, and throws if it still is. */
193
- export async function whileBusy<T>(operation: () => Promise<T>, wait: (ms: number) => Promise<void> = sleep): Promise<T> {
220
+ export async function whileBusy<T>(operation: () => Promise<T>, sleep: (ms: number) => Promise<void> = wait): Promise<T> {
194
221
  for (let attempt = 1; ; attempt++) {
195
222
  try {
196
223
  return await operation();
@@ -198,7 +225,8 @@ export async function whileBusy<T>(operation: () => Promise<T>, wait: (ms: numbe
198
225
  if (!isBusy(e) || attempt > BUSY_RETRIES) {
199
226
  throw e;
200
227
  }
201
- await wait(BUSY_RETRY_MS * attempt);
228
+
229
+ await sleep(BUSY_RETRY_MS * attempt);
202
230
  }
203
231
  }
204
232
  }
@@ -207,33 +235,41 @@ export async function whileBusy<T>(operation: () => Promise<T>, wait: (ms: numbe
207
235
  export async function mapLimited<T, R>(items: readonly T[], limit: number, work: (item: T) => Promise<R>): Promise<R[]> {
208
236
  const results = new Array<R>(items.length);
209
237
  let next = 0;
238
+
210
239
  const worker = async (): Promise<void> => {
211
240
  while (next < items.length) {
212
241
  const index = next++;
213
242
  results[index] = await work(items[index]!);
214
243
  }
215
244
  };
245
+
216
246
  await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
247
+
217
248
  return results;
218
249
  }
219
250
 
220
251
  /* The top of a folder's SKILL.md, or null when it has none. A process short of file handles throws instead of finding none. */
221
252
  async function skillHead(folder: string): Promise<string | null> {
222
253
  let handle;
254
+
223
255
  try {
224
256
  handle = await whileBusy(() => open(join(folder, 'SKILL.md'), 'r'));
225
257
  } catch (e) {
226
258
  if (isBusy(e)) {
227
259
  throw e;
228
260
  }
261
+
229
262
  return null;
230
263
  }
264
+
231
265
  try {
232
266
  if (!(await handle.stat()).isFile()) {
233
267
  return null;
234
268
  }
269
+
235
270
  const buffer = Buffer.alloc(HEAD_BYTES);
236
271
  const { bytesRead } = await handle.read(buffer, 0, HEAD_BYTES, 0);
272
+
237
273
  return buffer.subarray(0, bytesRead).toString('utf8');
238
274
  } catch {
239
275
  return null;
@@ -246,9 +282,11 @@ function skillOf(root: SkillRoot, folder: string, text: string): ChatSkill | nul
246
282
  const fields = parseFrontmatter(text);
247
283
  const own = fields.name && NAME.test(fields.name) ? fields.name : folder;
248
284
  const name = root.prefix ? `${root.prefix}:${own}` : own;
285
+
249
286
  if (!NAME.test(name)) {
250
287
  return null;
251
288
  }
289
+
252
290
  return { name, description: (fields.description ?? '').slice(0, MAX_DESCRIPTION), source: root.source };
253
291
  }
254
292
 
@@ -256,11 +294,13 @@ function skillOf(root: SkillRoot, folder: string, text: string): ChatSkill | nul
256
294
  async function realFolder(path: string): Promise<string | null> {
257
295
  try {
258
296
  const real = await whileBusy(() => realpath(path));
297
+
259
298
  return (await whileBusy(() => stat(real))).isDirectory() ? real : null;
260
299
  } catch (e) {
261
300
  if (isBusy(e)) {
262
301
  throw e;
263
302
  }
303
+
264
304
  return null;
265
305
  }
266
306
  }
@@ -272,52 +312,68 @@ async function realFolder(path: string): Promise<string | null> {
272
312
  */
273
313
  async function scanRoot(root: SkillRoot, limits: SkillScanLimits): Promise<ChatSkill[]> {
274
314
  const start = await realFolder(root.dir);
315
+
275
316
  if (start === null) {
276
317
  return [];
277
318
  }
319
+
278
320
  const visited = new Set([start]);
279
321
  const depth = root.depth ?? 1;
280
322
  const entriesPerFolder = limits.entries ?? ENTRIES_PER_FOLDER;
281
323
  const skills: ChatSkill[] = [];
282
324
  let level = [root.dir];
283
325
  let listed = 0;
326
+
284
327
  for (let below = 1; below <= depth && level.length > 0; below++) {
285
328
  const next: string[] = [];
329
+
286
330
  for (const folder of level) {
287
331
  if (listed >= limits.directories || skills.length >= limits.skills) {
288
332
  return skills;
289
333
  }
334
+
290
335
  listed++;
291
336
  let entries: Dirent[];
337
+
292
338
  try {
293
339
  entries = await whileBusy(() => readdir(folder, { withFileTypes: true }));
294
340
  } catch (e) {
295
341
  if (isBusy(e)) {
296
342
  throw e;
297
343
  }
344
+
298
345
  continue;
299
346
  }
347
+
300
348
  // A skill manager links each skill in from a shared folder, so a link counts as much as a folder.
301
349
  const candidates = entries
302
350
  .filter((entry) => (entry.isDirectory() || entry.isSymbolicLink()) && !entry.name.startsWith('.'))
303
351
  .map((entry) => ({ name: entry.name, path: join(folder, entry.name) }))
304
352
  .sort((a, b) => a.name.localeCompare(b.name))
305
353
  .slice(0, entriesPerFolder);
354
+
306
355
  const reals = await mapLimited(candidates, SCAN_CONCURRENCY, (candidate) => realFolder(candidate.path));
356
+
307
357
  const fresh = candidates.flatMap((candidate, index) => {
308
358
  const real = reals[index];
359
+
309
360
  return real === null || real === undefined || visited.has(real) ? [] : [{ ...candidate, real }];
310
361
  });
362
+
311
363
  const heads = await mapLimited(fresh, SCAN_CONCURRENCY, (candidate) => skillHead(candidate.path));
364
+
312
365
  for (const [index, candidate] of fresh.entries()) {
313
366
  // Two links in one folder can lead to the same place; the first one read it.
314
367
  if (visited.has(candidate.real)) {
315
368
  continue;
316
369
  }
370
+
317
371
  const head = heads[index];
372
+
318
373
  if (head !== null && head !== undefined) {
319
374
  visited.add(candidate.real);
320
375
  const skill = skillOf(root, candidate.name, head);
376
+
321
377
  if (skill !== null && skills.length < limits.skills) {
322
378
  skills.push(skill);
323
379
  }
@@ -327,24 +383,29 @@ async function scanRoot(root: SkillRoot, limits: SkillScanLimits): Promise<ChatS
327
383
  }
328
384
  }
329
385
  }
386
+
330
387
  level = next;
331
388
  }
389
+
332
390
  return skills;
333
391
  }
334
392
 
335
393
  /* Every skill under these roots, sorted by name; the first root that carries a name wins. */
336
394
  export async function scanSkillRoots(roots: SkillRoot[], limits: SkillScanLimits = SCAN_LIMITS): Promise<ChatSkill[]> {
337
395
  const found = new Map<string, ChatSkill>();
396
+
338
397
  for (const root of roots) {
339
398
  if (found.size >= limits.skills) {
340
399
  break;
341
400
  }
401
+
342
402
  for (const skill of await scanRoot(root, limits)) {
343
403
  if (!found.has(skill.name) && found.size < limits.skills) {
344
404
  found.set(skill.name, skill);
345
405
  }
346
406
  }
347
407
  }
408
+
348
409
  return [...found.values()].sort((a, b) => a.name.localeCompare(b.name));
349
410
  }
350
411
 
@@ -352,7 +413,6 @@ export async function discoverSkills(kind: AgentKind, cwd: string, options: Skil
352
413
  return scanSkillRoots(await skillRootsFor(kind, cwd, options));
353
414
  }
354
415
 
355
- // How long a scan stays good; a skill written mid-session shows up on the next look.
356
416
  const CACHE_TTL_MS = 30_000;
357
417
 
358
418
  /* The skills of one CLI in one folder, scanned at most once per half minute. There is no watcher. */
@@ -367,11 +427,14 @@ export class SkillIndex {
367
427
  async list(kind: AgentKind, cwd: string): Promise<ChatSkill[]> {
368
428
  const key = `${kind}:${cwd}`;
369
429
  const cached = this.cache.get(key);
430
+
370
431
  if (cached && Date.now() - cached.at < CACHE_TTL_MS) {
371
432
  return cached.skills;
372
433
  }
434
+
373
435
  const skills = await discoverSkills(kind, cwd, this.options);
374
436
  this.cache.set(key, { at: Date.now(), skills });
437
+
375
438
  return skills;
376
439
  }
377
440
 
@@ -21,16 +21,21 @@ export function backgroundLimitHandler(deps: BackgroundLimitDeps) {
21
21
  if (deps.tasks.get(entry.payload.taskId)?.status !== 'open') {
22
22
  return;
23
23
  }
24
+
24
25
  const chat = await loadChat(deps, entry.target);
26
+
25
27
  if (chat === null) {
26
28
  return;
27
29
  }
30
+
28
31
  if (chat.info.activeTurnId !== null) {
29
32
  return 'wait';
30
33
  }
34
+
31
35
  if (runningInBackground(chat.thread.list()).length > 0) {
32
36
  return;
33
37
  }
38
+
34
39
  // A child whose process went since the limit started runs nothing any more; what it ran then is what the parent is told.
35
40
  const running = (chat.info.background ?? []).map(commandLabel);
36
41
  await deps.coordinator.outlasted(entry.target, running.length > 0 ? running : entry.payload.commands, entry.payload.restarted ? 'restart' : 'limit');
@@ -57,13 +62,15 @@ export async function restartBackgroundLimits(outbox: RestartedOutbox, now: numb
57
62
 
58
63
  /* The `background-limit` entries of a host's outbox, one per task at most, as the coordinator owes and drops them. */
59
64
  export function backgroundLimits(outbox: Pick<TaskOutbox, 'list' | 'enqueue' | 'remove'>): TaskCoordinatorDeps['limit'] {
65
+ const limitOf = (entry: AnyOutboxEntry, taskId: string): boolean => isBackgroundLimit(entry) && entry.payload.taskId === taskId;
66
+
60
67
  return {
61
- owed: (taskId) => outbox.list().some((entry) => isBackgroundLimit(entry) && entry.payload.taskId === taskId),
68
+ owed: (taskId) => outbox.list().some((entry) => limitOf(entry, taskId)),
62
69
  owe: (task, commands, at) =>
63
70
  outbox.enqueue(task.projectId, task.childId, { kind: 'background-limit', payload: { taskId: task.id, commands: [...commands] } }, at),
64
71
  lapse: async (taskId) => {
65
72
  for (const entry of outbox.list()) {
66
- if (isBackgroundLimit(entry) && entry.payload.taskId === taskId) {
73
+ if (limitOf(entry, taskId)) {
67
74
  await outbox.remove(entry.id);
68
75
  }
69
76
  }
@@ -65,19 +65,24 @@ export function endChildren(deps: EndChildrenDeps): EndChildren {
65
65
  const end = async (nodeIds: readonly string[], reason: string): Promise<void> => {
66
66
  await lineage.markEnded(nodeIds);
67
67
  const ended = new Set(nodeIds);
68
+
68
69
  for (const owed of outbox.list()) {
69
70
  if (reviving.has(owed.kind) && ended.has(owed.target)) {
70
71
  await outbox.remove(owed.id);
71
72
  }
72
73
  }
74
+
73
75
  await tasks.cancelOpen(ended, reason, now());
76
+
74
77
  // A stopped chat that gave tasks of its own is not woken about them either.
75
78
  for (const nodeId of nodeIds) {
76
79
  await tasks.dropWake(nodeId);
77
80
  }
81
+
78
82
  for (const nodeId of [...nodeIds].reverse()) {
79
83
  await deps.stop(nodeId, reason);
80
84
  }
85
+
81
86
  await deps.ended?.(nodeIds);
82
87
  };
83
88
 
@@ -85,13 +90,17 @@ export function endChildren(deps: EndChildrenDeps): EndChildren {
85
90
  owe: async (target, named) => {
86
91
  const nodeIds = named === undefined ? lineage.descendants(target) : [...named];
87
92
  const projectId = nodeIds.map((id) => lineage.projectOf(id)).find((id) => id !== null) ?? null;
93
+
88
94
  if (nodeIds.length === 0 || projectId === null) {
89
95
  return 0;
90
96
  }
97
+
91
98
  const owed = outbox.list().some((entry) => entry.kind === 'end-children' && entry.target === target);
99
+
92
100
  if (!owed) {
93
101
  await outbox.enqueue(projectId, target, { kind: 'end-children', payload: { nodeIds } });
94
102
  }
103
+
95
104
  return nodeIds.length;
96
105
  },
97
106
  handler: (entry) => end([...new Set([...entry.payload.nodeIds, ...lineage.descendants(entry.target)])], deps.reason),
@@ -29,24 +29,32 @@ export interface GiveTaskDeps {
29
29
  export function giveTaskHandler(deps: GiveTaskDeps) {
30
30
  return async (entry: GiveTaskEntry): Promise<OutboxOutcome> => {
31
31
  const task = deps.tasks.get(entry.payload.taskId);
32
+
32
33
  // Settled or cancelled before the turn could open, or a node the project no longer places.
33
34
  if (!task || task.status !== 'open' || !deps.placed(task.childId)) {
34
35
  return;
35
36
  }
37
+
36
38
  const chat = await deps.chat(task.childId);
39
+
37
40
  if (chat === null) {
38
41
  throw new Error(`${task.childId} has no chat to open a turn in`);
39
42
  }
43
+
40
44
  // The turn names the task, so a restart between opening it and dropping this entry gives it once.
41
45
  if (chat.items().some((item) => item.kind === 'turn' && (item.taskIds ?? []).includes(task.id))) {
42
46
  await chat.persist?.();
47
+
43
48
  return;
44
49
  }
50
+
45
51
  const from = deps.titleFor(task.parentId) ?? task.parentId;
46
52
  const text = deps.assignment?.(task) ?? task.prompt;
53
+
47
54
  if (!chat.wake({ text, label: task.title, note: taskNote(from), taskIds: [task.id] })) {
48
55
  return 'wait';
49
56
  }
57
+
50
58
  await chat.persist?.();
51
59
  };
52
60
  }