klyro 1.0.0 → 1.0.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 (96) hide show
  1. package/dist/agent/anthropic-adapter.d.ts +36 -0
  2. package/dist/agent/anthropic-adapter.js +68 -16
  3. package/dist/agent/capabilities.d.ts +23 -0
  4. package/dist/agent/capabilities.js +46 -5
  5. package/dist/agent/child-worker.d.ts +104 -0
  6. package/dist/agent/child-worker.js +250 -0
  7. package/dist/agent/orchestrator.d.ts +106 -5
  8. package/dist/agent/orchestrator.js +378 -58
  9. package/dist/agent/provider-adapter.d.ts +8 -0
  10. package/dist/agent/provider-adapter.js +12 -3
  11. package/dist/agent/retry.d.ts +1 -1
  12. package/dist/agent/retry.js +2 -2
  13. package/dist/agent/runtime.d.ts +50 -8
  14. package/dist/agent/runtime.js +196 -33
  15. package/dist/agent/worktree-manager.d.ts +74 -0
  16. package/dist/agent/worktree-manager.js +189 -0
  17. package/dist/checkpoints/store.js +30 -5
  18. package/dist/cli/auth.js +16 -1
  19. package/dist/cli/config.d.ts +9 -3
  20. package/dist/cli/config.js +64 -3
  21. package/dist/cli/eval.d.ts +6 -1
  22. package/dist/cli/eval.js +9 -0
  23. package/dist/cli/repl.js +155 -28
  24. package/dist/cli/run.d.ts +7 -11
  25. package/dist/cli/run.js +68 -17
  26. package/dist/cli/update.d.ts +5 -0
  27. package/dist/cli/update.js +62 -10
  28. package/dist/context/import-graph.d.ts +2 -0
  29. package/dist/context/import-graph.js +31 -3
  30. package/dist/context/klyro-md.js +4 -1
  31. package/dist/context/memory.d.ts +8 -0
  32. package/dist/context/memory.js +50 -2
  33. package/dist/context/project-map.d.ts +6 -0
  34. package/dist/context/project-map.js +50 -2
  35. package/dist/context/repo-map.d.ts +2 -0
  36. package/dist/context/repo-map.js +31 -1
  37. package/dist/events/catalog.d.ts +28 -0
  38. package/dist/index.js +89 -4
  39. package/dist/mcp/client.d.ts +6 -4
  40. package/dist/mcp/client.js +83 -14
  41. package/dist/mcp/config.d.ts +10 -0
  42. package/dist/mcp/config.js +18 -1
  43. package/dist/mcp/registry.d.ts +23 -1
  44. package/dist/mcp/registry.js +78 -6
  45. package/dist/mcp/schema.d.ts +11 -4
  46. package/dist/mcp/schema.js +27 -16
  47. package/dist/mcp/trust.d.ts +20 -0
  48. package/dist/mcp/trust.js +74 -0
  49. package/dist/persistence/audit.d.ts +28 -0
  50. package/dist/persistence/audit.js +101 -1
  51. package/dist/persistence/store.d.ts +26 -2
  52. package/dist/persistence/store.js +140 -13
  53. package/dist/policy/approval.d.ts +14 -0
  54. package/dist/policy/approval.js +44 -2
  55. package/dist/policy/engine.d.ts +1 -0
  56. package/dist/policy/engine.js +88 -8
  57. package/dist/policy/secret-redactor.js +4 -0
  58. package/dist/providers/model-info.d.ts +17 -0
  59. package/dist/providers/model-info.js +35 -2
  60. package/dist/repl.d.ts +6 -0
  61. package/dist/repl.js +12 -7
  62. package/dist/tools/agent/spawn-agent.js +5 -5
  63. package/dist/tools/agent/task-apply.d.ts +4 -0
  64. package/dist/tools/agent/task-apply.js +44 -0
  65. package/dist/tools/agent/task-stop.d.ts +6 -0
  66. package/dist/tools/agent/task-stop.js +39 -0
  67. package/dist/tools/agent/task-wait.d.ts +17 -0
  68. package/dist/tools/agent/task-wait.js +79 -0
  69. package/dist/tools/fs/apply-patch.js +71 -0
  70. package/dist/tools/fs/edit-file.js +65 -0
  71. package/dist/tools/fs/multi-edit.d.ts +4 -0
  72. package/dist/tools/fs/multi-edit.js +66 -0
  73. package/dist/tools/fs/write-file.js +67 -0
  74. package/dist/tools/registry.js +6 -0
  75. package/dist/tools/shell/background.js +6 -3
  76. package/dist/tools/shell/sandbox.d.ts +51 -0
  77. package/dist/tools/shell/sandbox.js +143 -0
  78. package/dist/tools/shell/shell-exec.d.ts +1 -0
  79. package/dist/tools/shell/shell-exec.js +83 -11
  80. package/dist/tools/shell/worker-entry.d.ts +12 -0
  81. package/dist/tools/shell/worker-entry.js +43 -0
  82. package/dist/tools/types.d.ts +6 -0
  83. package/dist/tools/verify/run-verify.js +3 -1
  84. package/dist/trace/writer.d.ts +13 -0
  85. package/dist/trace/writer.js +55 -4
  86. package/dist/tui/app.js +1 -1
  87. package/dist/tui/approval.js +20 -21
  88. package/dist/util.d.ts +1 -0
  89. package/dist/util.js +1 -0
  90. package/dist/verification/baseline.js +17 -3
  91. package/dist/verification/classify.js +23 -12
  92. package/dist/verification/engine.js +3 -1
  93. package/dist/verification/registry.d.ts +2 -0
  94. package/dist/verification/registry.js +33 -0
  95. package/dist/verification/scoped.js +28 -6
  96. package/package.json +1 -1
@@ -12,12 +12,19 @@
12
12
  * The compact result is a `ChildSummary` — a `ToolResult` the parent model
13
13
  * can act on — never the full child transcript.
14
14
  */
15
- import { run } from './runtime.js';
15
+ import { run, resolveSystemPrompt } from './runtime.js';
16
16
  import { ScopedRegistry } from './scoped-registry.js';
17
17
  import { globalBus } from '../events/bus.js';
18
18
  import { TaskManager } from './task-manager.js';
19
19
  import { WorkerSpawner } from './worker-spawner.js';
20
20
  import { resolveCapabilities, DEFAULT_WRITE_TOOLS, DEFAULT_SPAWN_TOOLS, DEFAULT_DENIED_TOOLS, } from './capabilities.js';
21
+ import { forkChild, workerEntryPath } from './child-worker.js';
22
+ import { resolveAndFollowSymlinks } from '../policy/path-guard.js';
23
+ import { ensureGitRepo, createWorktree, mergeWorktree, removeWorktree, deleteBranch, } from './worktree-manager.js';
24
+ /** Concurrency budgets enforced in `spawnAgent` (CONCURRENCY_LIMIT on exceed). */
25
+ export const MAX_CONCURRENT_TASKS = 4;
26
+ export const MAX_TASKS_PER_PARENT = 8;
27
+ export const MAX_TOTAL_TASKS_PER_SESSION = 32;
21
28
  /** Default agents a model can delegate to. */
22
29
  export const BUILTIN_AGENTS = [
23
30
  {
@@ -25,20 +32,20 @@ export const BUILTIN_AGENTS = [
25
32
  description: 'Read-only reconnaissance: map the repo, find symbols and tests.',
26
33
  readonly: true,
27
34
  canSpawn: false,
28
- allowedTools: ['read_file', 'list_dir', 'glob', 'grep', 'search_files', 'repo_map', 'find_symbol', 'git_status', 'git_log', 'git_diff', 'recent_files', 'imports_of', 'importers_of'],
35
+ allowedTools: ['read_file', 'list_directory', 'glob', 'grep', 'search_files', 'repo_map', 'find_symbol', 'git_status', 'git_log', 'git_diff', 'recent_files', 'imports_of', 'importers_of'],
29
36
  },
30
37
  {
31
38
  id: 'implementer',
32
39
  description: 'Write-capable worker for concrete, well-scoped coding tasks.',
33
40
  canSpawn: false,
34
- allowedTools: ['read_file', 'list_dir', 'glob', 'grep', 'search_files', 'write_file', 'edit_file', 'multi_edit', 'apply_patch', 'shell_exec', 'git_status', 'git_log', 'git_diff', 'run_verify', 'todo_write'],
41
+ allowedTools: ['read_file', 'list_directory', 'glob', 'grep', 'search_files', 'write_file', 'edit_file', 'multi_edit', 'apply_patch', 'shell_exec', 'git_status', 'git_log', 'git_diff', 'run_verify', 'todo_write'],
35
42
  maxSteps: 60,
36
43
  },
37
44
  {
38
45
  id: 'tester',
39
46
  description: 'Runs verification and tests, reports failures with diagnostics.',
40
47
  canSpawn: false,
41
- allowedTools: ['read_file', 'list_dir', 'glob', 'grep', 'shell_exec', 'run_verify', 'git_status', 'git_log', 'git_diff'],
48
+ allowedTools: ['read_file', 'list_directory', 'glob', 'grep', 'shell_exec', 'run_verify', 'git_status', 'git_log', 'git_diff'],
42
49
  maxTimeMs: 120_000,
43
50
  },
44
51
  {
@@ -46,7 +53,21 @@ export const BUILTIN_AGENTS = [
46
53
  description: 'Read-only review of a diff or change set for bugs.',
47
54
  readonly: true,
48
55
  canSpawn: false,
49
- allowedTools: ['read_file', 'list_dir', 'glob', 'grep', 'search_files', 'git_status', 'git_diff', 'git_log', 'imports_of', 'importers_of', 'find_symbol'],
56
+ allowedTools: ['read_file', 'list_directory', 'glob', 'grep', 'search_files', 'git_status', 'git_diff', 'git_log', 'imports_of', 'importers_of', 'find_symbol'],
57
+ },
58
+ {
59
+ id: 'debugger',
60
+ description: 'Read-only diagnosis: inspect failures, traces, and code paths without modifying files.',
61
+ readonly: true,
62
+ canSpawn: false,
63
+ allowedTools: ['read_file', 'list_directory', 'glob', 'grep', 'search_files', 'repo_map', 'find_symbol', 'imports_of', 'importers_of', 'git_status', 'git_log', 'git_diff', 'recent_files', 'run_verify'],
64
+ },
65
+ {
66
+ id: 'docs',
67
+ description: 'Read-only documentation lookup: find and summarise docs, READMEs, and code structure.',
68
+ readonly: true,
69
+ canSpawn: false,
70
+ allowedTools: ['read_file', 'list_directory', 'glob', 'grep', 'search_files', 'repo_map', 'recent_files'],
50
71
  },
51
72
  ];
52
73
  /** Map a runtime `RunResult.status` to a task status. */
@@ -71,11 +92,17 @@ export class AgentOrchestrator {
71
92
  deps;
72
93
  taskManager;
73
94
  workerSpawner;
95
+ isTui;
96
+ /** Per-task spawn metadata: capability drops + worktree placement. */
97
+ taskMeta = new Map();
98
+ /** Finished summaries not yet drained via `drainCompletions`. */
99
+ undrained = new Map();
74
100
  constructor(opts) {
75
101
  this.sessionId = opts.sessionId;
76
102
  this.deps = opts.deps;
77
103
  this.taskManager = opts.taskManager ?? new TaskManager({ sessionId: opts.sessionId });
78
104
  this.workerSpawner = opts.workerSpawner ?? new WorkerSpawner();
105
+ this.isTui = opts.isTui ?? false;
79
106
  }
80
107
  listAgents() {
81
108
  return [...BUILTIN_AGENTS];
@@ -98,6 +125,10 @@ export class AgentOrchestrator {
98
125
  const s = this.taskManager.toSummary(r);
99
126
  return r.error ? { ...s, error: { code: r.error.code, message: r.error.message } } : s;
100
127
  },
128
+ drainCompletions: () => this.drainCompletions(),
129
+ waitForTasks: (taskIds, timeoutMs) => this.waitForTasks(taskIds, timeoutMs),
130
+ cancelTask: (taskId) => this.cancelTask(taskId),
131
+ applyTask: (taskId) => this.applyTask(taskId),
101
132
  };
102
133
  }
103
134
  /** Compute a child's effective capabilities from the parent's own. */
@@ -111,13 +142,14 @@ export class AgentOrchestrator {
111
142
  spawnTools: DEFAULT_SPAWN_TOOLS,
112
143
  denied: DEFAULT_DENIED_TOOLS,
113
144
  };
114
- const resolved = resolveCapabilities({ ...input, maxDepth: parent.maxDepth });
145
+ const resolved = resolveCapabilities({ ...input, maxDepth: parent.maxDepth, parentAllowedPaths: parent.allowedPaths });
115
146
  return resolved;
116
147
  }
117
148
  /**
118
- * Spawn a child agent for a given capability context, await its run, and
119
- * return a compact summary. Blocks until the child settles (P0 scope;
120
- * async task_wait arrives in a later slice).
149
+ * Spawn a child agent asynchronously: start the child worker and return
150
+ * IMMEDIATELY with `{ status: 'running' }`. Completion/failure bus emits
151
+ * and `taskManager.finish` happen in the worker closure; the parent
152
+ * observes them via `task_wait` / `task_get` / `drainCompletions`.
121
153
  */
122
154
  async spawnAgent(input, parent) {
123
155
  const def = this.getAgent(input.agent);
@@ -139,10 +171,64 @@ export class AgentOrchestrator {
139
171
  },
140
172
  };
141
173
  }
174
+ // Concurrency budgets — enforced before creating the task.
175
+ const all = this.taskManager.list();
176
+ const runningCount = all.filter((t) => t.status === 'running').length;
177
+ if (runningCount >= MAX_CONCURRENT_TASKS) {
178
+ return {
179
+ ok: false,
180
+ error: { code: 'CONCURRENCY_LIMIT', message: `maxConcurrentTasks (${MAX_CONCURRENT_TASKS}) reached` },
181
+ };
182
+ }
183
+ const perParentCount = all.filter((t) => t.parentTaskId === parent.taskId).length;
184
+ if (perParentCount >= MAX_TASKS_PER_PARENT) {
185
+ return {
186
+ ok: false,
187
+ error: { code: 'CONCURRENCY_LIMIT', message: `maxTasksPerParent (${MAX_TASKS_PER_PARENT}) reached` },
188
+ };
189
+ }
190
+ if (all.length >= MAX_TOTAL_TASKS_PER_SESSION) {
191
+ return {
192
+ ok: false,
193
+ error: { code: 'CONCURRENCY_LIMIT', message: `maxTotalTasksPerSession (${MAX_TOTAL_TASKS_PER_SESSION}) reached` },
194
+ };
195
+ }
196
+ // Spawn cwd containment (S6): an explicit cwd must stay inside the parent.
197
+ let baseCwd = parent.cwd;
198
+ if (input.cwd) {
199
+ try {
200
+ baseCwd = (await resolveAndFollowSymlinks(parent.cwd, input.cwd)).resolved;
201
+ }
202
+ catch (err) {
203
+ return {
204
+ ok: false,
205
+ error: {
206
+ code: 'PATH_ESCAPE',
207
+ message: err instanceof Error ? err.message : `cwd escapes parent: ${input.cwd}`,
208
+ },
209
+ };
210
+ }
211
+ }
142
212
  const registryTools = new Set(this.deps.registry.list().map((t) => t.name));
143
213
  const resolved = this.resolveChild(def, parent, registryTools);
144
214
  const childModel = input.model ?? resolved.model ?? parent.model;
145
- const childCwd = input.cwd ?? parent.cwd;
215
+ // Worktree isolation: write-capable children without an explicit cwd get
216
+ // their own worktree; readonly agents keep the parent cwd. A
217
+ // write-capable spawn outside a git repo is rejected outright.
218
+ const writeCapable = [...resolved.allowed].some((t) => DEFAULT_WRITE_TOOLS.has(t));
219
+ let childCwd = baseCwd;
220
+ let worktree;
221
+ let repoCwd;
222
+ if (!resolved.readonly && writeCapable && !input.cwd) {
223
+ const isRepo = await ensureGitRepo(baseCwd).catch(() => false);
224
+ if (!isRepo) {
225
+ return {
226
+ ok: false,
227
+ error: { code: 'WRITES_REQUIRE_GIT', message: 'parallel write agents require a git repository for worktree isolation' },
228
+ };
229
+ }
230
+ repoCwd = baseCwd;
231
+ }
146
232
  const createOpts = {
147
233
  agentName: def.id,
148
234
  cwd: childCwd,
@@ -154,8 +240,42 @@ export class AgentOrchestrator {
154
240
  abortOnParent: undefined, // wired below via the parent signal
155
241
  };
156
242
  const record = this.taskManager.create(createOpts);
243
+ // Worktree creation needs the task id (branch `klyro/<taskId>`), so it
244
+ // happens after `create`. On failure the task is marked failed and the
245
+ // spawn returns the error.
246
+ if (repoCwd !== undefined) {
247
+ try {
248
+ worktree = await createWorktree({ repoCwd, taskId: record.id });
249
+ childCwd = worktree.worktreePath;
250
+ record.cwd = childCwd;
251
+ }
252
+ catch (err) {
253
+ this.taskManager.finish(record.id, 'failed', {
254
+ error: { code: 'WORKTREE_FAILED', message: err instanceof Error ? err.message : String(err) },
255
+ });
256
+ const failed = this.taskManager.get(record.id);
257
+ this.stashSummary(failed, def, resolved.dropped);
258
+ this.taskMeta.set(record.id, { def, dropped: resolved.dropped });
259
+ return {
260
+ ok: false,
261
+ error: { code: 'WORKTREE_FAILED', message: err instanceof Error ? err.message : String(err) },
262
+ };
263
+ }
264
+ }
265
+ this.taskMeta.set(record.id, worktree ? { def, dropped: resolved.dropped, worktree, repoCwd } : { def, dropped: resolved.dropped });
157
266
  const childRegistry = new ScopedRegistry(this.deps.registry, resolved.allowed);
158
267
  const childDeps = { ...this.deps, registry: childRegistry };
268
+ const childRef = {
269
+ taskId: record.id,
270
+ ...(parent.taskId !== undefined ? { parentTaskId: parent.taskId } : {}),
271
+ sessionId: this.sessionId,
272
+ cwd: childCwd,
273
+ depth: childDepth,
274
+ maxDepth,
275
+ allowedTools: resolved.allowed,
276
+ ...(childModel !== undefined ? { model: childModel } : {}),
277
+ ...(resolved.allowedPaths !== undefined ? { allowedPaths: resolved.allowedPaths } : {}),
278
+ };
159
279
  const childOptions = {
160
280
  task: input.task,
161
281
  cwd: childCwd,
@@ -165,6 +285,10 @@ export class AgentOrchestrator {
165
285
  maxTimeMs: def.maxTimeMs ?? input.timeoutMs,
166
286
  signal: record.abortController.signal,
167
287
  nonInteractive: true,
288
+ // Grandchildren: only children that canSpawn receive the bridge —
289
+ // otherwise tools see NO_ORCHESTRATOR as before.
290
+ ...(resolved.canSpawn ? { agentBridge: this.bridgeFor(childRef) } : {}),
291
+ ...(def.maxTokens !== undefined ? { maxTokens: def.maxTokens } : {}),
168
292
  };
169
293
  // Lifecycle events on the shared bus (mirror TaskManager transitions).
170
294
  globalBus.emit({
@@ -177,65 +301,261 @@ export class AgentOrchestrator {
177
301
  depth: childDepth,
178
302
  ...(typeof childModel === 'string' ? { model: childModel } : {}),
179
303
  });
180
- const handle = this.workerSpawner.spawn(async () => {
181
- let result;
304
+ // G2 — process isolation for headless sub-agents. In-process is the
305
+ // fallback (and mandatory for TUI children — see OrchestratorOpts.isTui),
306
+ // and opt-out via KLYRO_WORKER=0 for tests/dev.
307
+ const useProcessIsolation = !this.isTui && process.env.KLYRO_WORKER !== '0';
308
+ this.workerSpawner.spawn(async (signal) => {
309
+ // Both the in-process path and the forked child resolve to the same
310
+ // minimal outcome shape the settle tail needs.
311
+ let childOutcome;
182
312
  try {
183
- result = await run(childOptions, childDeps);
313
+ if (useProcessIsolation) {
314
+ const sysPrompt = resolveSystemPrompt(this.deps.systemPrompt, { cwd: childCwd });
315
+ // Splice the volatile telemetry suffix into the stable prefix so the
316
+ // child's provider sees one system string. Telemetry is best-effort
317
+ // inside the child (it re-emits); the goal here is parity, not
318
+ // perfect replay.
319
+ const systemPrompt = sysPrompt.suffix ? `${sysPrompt.system}\n${sysPrompt.suffix}` : sysPrompt.system;
320
+ const payload = {
321
+ cwd: childCwd,
322
+ task: input.task,
323
+ // A concrete provider model must reach the child — 'inherit' only
324
+ // exists to defer resolution inside the parent's run().
325
+ model: (childModel ?? parent.model),
326
+ systemPrompt,
327
+ agentId: def.id,
328
+ maxSteps: def.maxSteps,
329
+ maxCost: def.maxCost,
330
+ maxTimeMs: def.maxTimeMs ?? input.timeoutMs,
331
+ ...(def.maxTokens !== undefined ? { maxTokens: def.maxTokens } : {}),
332
+ };
333
+ const cr = await forkChild(workerEntryPath(), payload, { signal });
334
+ childOutcome = cr; // ChildResult is the minimal settle shape
335
+ }
336
+ else {
337
+ const r = await run(childOptions, childDeps);
338
+ childOutcome = { status: r.status, steps: r.steps, toolCalls: r.toolCalls, finalText: r.finalText };
339
+ }
184
340
  }
185
341
  catch (err) {
186
- this.taskManager.finish(record.id, 'failed', {
187
- error: { code: 'CHILD_CRASH', message: err instanceof Error ? err.message : String(err) },
188
- });
342
+ const isCrash = err instanceof Error && err.name === 'ChildCrashError';
343
+ const crashErr = isCrash ? err : undefined;
344
+ this.settleChild(record.id, 'failed', {
345
+ error: {
346
+ code: 'CHILD_CRASH',
347
+ message: isCrash
348
+ ? `child process isolated failure (${crashErr?.likelyCause ?? 'exit'}): ${crashErr?.message ?? ''}`.trim()
349
+ : err instanceof Error ? err.message : String(err),
350
+ },
351
+ }, { steps: 0, toolCalls: 0 });
189
352
  return;
190
353
  }
191
- const status = mapResultStatus(result.status);
192
- if (status === 'cancelled') {
193
- this.taskManager.cancel(record.id, 'parent aborted');
194
- }
195
- else {
196
- this.taskManager.finish(record.id, status, {
197
- summary: [`status: ${status}`, `steps: ${result.steps}`, `toolCalls: ${result.toolCalls}`],
198
- error: status === 'failed'
199
- ? { code: mapFailureCode(result.status), message: result.finalText?.slice(0, 300) ?? 'child failed' }
200
- : undefined,
201
- });
202
- }
203
- const final = this.taskManager.get(record.id);
204
- const durationMs = final.finishedAt ? final.finishedAt - final.startedAt : 0;
205
- if (status === 'succeeded') {
206
- globalBus.emit({
207
- type: 'subtask.completed',
208
- ts: Date.now(),
209
- sessionId: this.sessionId,
210
- taskId: record.id,
211
- status: 'succeeded',
212
- durationMs,
213
- steps: result?.steps ?? 0,
214
- toolCalls: result?.toolCalls ?? 0,
215
- });
216
- }
217
- else {
218
- globalBus.emit({
219
- type: 'subtask.failed',
220
- ts: Date.now(),
221
- sessionId: this.sessionId,
222
- taskId: record.id,
223
- status: status,
224
- durationMs,
225
- error: final.error ? { code: final.error.code, message: final.error.message } : undefined,
226
- });
227
- }
354
+ const status = mapResultStatus(childOutcome.status);
355
+ this.settleChild(record.id, status, {
356
+ summary: [`status: ${status}`, `steps: ${childOutcome.steps}`, `toolCalls: ${childOutcome.toolCalls}`],
357
+ error: status === 'failed'
358
+ ? { code: mapFailureCode(childOutcome.status), message: childOutcome.finalText?.slice(0, 300) ?? 'child failed' }
359
+ : undefined,
360
+ }, { steps: childOutcome.steps, toolCalls: childOutcome.toolCalls });
228
361
  }, { label: `agent:${def.id}` });
229
- await handle.done.catch(() => undefined);
230
- const finalRecord = this.taskManager.get(record.id);
231
- if (!finalRecord) {
362
+ // Async contract: return immediately with the running summary. The final
363
+ // text is omitted while running; observe via task_wait / drainCompletions.
364
+ return {
365
+ ok: true,
366
+ value: { taskId: record.id, agentName: def.id, status: 'running', durationMs: 0, changedFiles: [] },
367
+ };
368
+ }
369
+ /**
370
+ * Settle a child in the worker closure: finish the task (idempotent —
371
+ * a TaskManager timeout/cancel that fired first wins), emit the terminal
372
+ * bus event, stash the summary for `drainCompletions`, and best-effort
373
+ * remove the worktree unless the child succeeded (merge happens later in
374
+ * `task_apply`).
375
+ */
376
+ settleChild(taskId, status, patch, counts) {
377
+ const existing = this.taskManager.get(taskId);
378
+ if (!existing)
379
+ return;
380
+ let finalStatus = status;
381
+ if (existing.status === 'queued' || existing.status === 'running') {
382
+ this.taskManager.finish(taskId, status, patch);
383
+ }
384
+ else {
385
+ // Already terminal (timeout/cancel raced the run) — preserve it.
386
+ finalStatus = existing.status;
387
+ }
388
+ const final = this.taskManager.get(taskId);
389
+ this.stashSummary(final);
390
+ const durationMs = final.finishedAt ? final.finishedAt - final.startedAt : 0;
391
+ const error = final.error ? { code: final.error.code, message: final.error.message } : undefined;
392
+ const steps = counts?.steps ?? 0;
393
+ const toolCalls = counts?.toolCalls ?? 0;
394
+ if (finalStatus === 'succeeded') {
395
+ globalBus.emit({
396
+ type: 'subtask.completed',
397
+ ts: Date.now(),
398
+ sessionId: this.sessionId,
399
+ taskId,
400
+ status: 'succeeded',
401
+ durationMs,
402
+ steps,
403
+ toolCalls,
404
+ });
405
+ }
406
+ else if (finalStatus === 'cancelled') {
407
+ globalBus.emit({
408
+ type: 'subtask.cancelled',
409
+ ts: Date.now(),
410
+ sessionId: this.sessionId,
411
+ taskId,
412
+ status: 'cancelled',
413
+ durationMs,
414
+ ...(error ? { error } : {}),
415
+ });
416
+ }
417
+ else if (finalStatus === 'timed_out') {
418
+ globalBus.emit({
419
+ type: 'subtask.timed_out',
420
+ ts: Date.now(),
421
+ sessionId: this.sessionId,
422
+ taskId,
423
+ status: 'timed_out',
424
+ durationMs,
425
+ ...(error ? { error } : {}),
426
+ });
427
+ }
428
+ else {
429
+ globalBus.emit({
430
+ type: 'subtask.failed',
431
+ ts: Date.now(),
432
+ sessionId: this.sessionId,
433
+ taskId,
434
+ status: finalStatus,
435
+ durationMs,
436
+ ...(error ? { error } : {}),
437
+ });
438
+ }
439
+ // Non-success terminal states never merge — drop the worktree.
440
+ const meta = this.taskMeta.get(taskId);
441
+ if (meta?.worktree && meta.repoCwd && finalStatus !== 'succeeded') {
442
+ const { worktree, repoCwd } = meta;
443
+ void removeWorktree({ repoCwd, worktreePath: worktree.worktreePath, force: true }).catch(() => undefined);
444
+ }
445
+ }
446
+ /** Build and stash the finished summary for `drainCompletions`. */
447
+ stashSummary(r, def, dropped) {
448
+ const meta = this.taskMeta.get(r.id);
449
+ const d = def ?? meta?.def;
450
+ if (!d)
451
+ return;
452
+ this.undrained.set(r.id, this.toChildSummary(r, d, dropped ?? meta?.dropped ?? []));
453
+ }
454
+ /** Completed-then-undrained child summaries; each drained exactly once. */
455
+ drainCompletions() {
456
+ const out = [...this.undrained.values()];
457
+ this.undrained.clear();
458
+ return out;
459
+ }
460
+ /**
461
+ * Await each task's `done` up to `timeoutMs` per task. Unsettled entries
462
+ * come back with status `'running'` and a running summary — the underlying
463
+ * task is NOT cancelled.
464
+ */
465
+ async waitForTasks(taskIds, timeoutMs) {
466
+ const tasks = await Promise.all(taskIds.map(async (taskId) => {
467
+ const rec = this.taskManager.get(taskId);
468
+ if (!rec) {
469
+ return { taskId, status: 'failed', error: { code: 'NOT_FOUND', message: `Unknown task: ${taskId}` } };
470
+ }
471
+ const meta = this.taskMeta.get(taskId);
472
+ const settled = await this.awaitDone(rec, timeoutMs);
473
+ const summary = meta ? this.toChildSummary(settled, meta.def, meta.dropped) : undefined;
474
+ const entry = { taskId, status: settled.status };
475
+ if (summary)
476
+ entry.summary = summary;
477
+ if (settled.error)
478
+ entry.error = { code: settled.error.code, message: settled.error.message };
479
+ return entry;
480
+ }));
481
+ return { tasks };
482
+ }
483
+ async awaitDone(rec, timeoutMs) {
484
+ if (timeoutMs === undefined)
485
+ return rec.done;
486
+ let timer;
487
+ try {
488
+ return await Promise.race([
489
+ rec.done,
490
+ new Promise((resolve) => {
491
+ timer = setTimeout(() => resolve(this.taskManager.get(rec.id)), timeoutMs);
492
+ }),
493
+ ]);
494
+ }
495
+ finally {
496
+ if (timer)
497
+ clearTimeout(timer);
498
+ }
499
+ }
500
+ /** Signal abort for a live task via `taskManager.cancel` (in-process workers; no subprocesses yet). Idempotent on terminal tasks. */
501
+ cancelTask(taskId) {
502
+ const rec = this.taskManager.get(taskId);
503
+ if (!rec) {
504
+ return { ok: false, error: { code: 'NOT_FOUND', message: `Unknown task: ${taskId}` } };
505
+ }
506
+ this.taskManager.cancel(taskId, 'stopped via task_stop');
507
+ const final = this.taskManager.get(taskId);
508
+ return { ok: true, value: { taskId, status: final.status } };
509
+ }
510
+ /**
511
+ * Apply a succeeded task: merge its worktree branch into the parent tree
512
+ * (or acknowledge a shared-cwd task whose edits already landed) and emit
513
+ * `subtask.merged`. Non-succeeded tasks error with NOT_READY; merge
514
+ * conflicts error with MERGE_CONFLICT (merge already aborted, branch kept).
515
+ */
516
+ async applyTask(taskId) {
517
+ const rec = this.taskManager.get(taskId);
518
+ if (!rec) {
519
+ return { ok: false, error: { code: 'NOT_FOUND', message: `Unknown task: ${taskId}` } };
520
+ }
521
+ if (rec.status !== 'succeeded') {
232
522
  return {
233
523
  ok: false,
234
- error: { code: 'INTERNAL', message: `task ${record.id} missing after child run` },
524
+ error: { code: 'NOT_READY', message: `task ${taskId} is ${rec.status}, not succeeded` },
235
525
  };
236
526
  }
237
- return { ok: true, value: this.toChildSummary(finalRecord, def, resolved.dropped) };
527
+ const meta = this.taskMeta.get(taskId);
528
+ const summary = meta ? this.toChildSummary(rec, meta.def, meta.dropped) : this.toChildSummary(rec, this.getAgent(rec.agentName) ?? { id: rec.agentName, description: '' }, []);
529
+ // merged=true only when a real worktree branch merge ran; shared-cwd
530
+ // tasks (edits already in the parent tree) are an acknowledgement.
531
+ let didMerge = false;
532
+ if (meta?.worktree && meta.repoCwd) {
533
+ const merged = await mergeWorktree({ repoCwd: meta.repoCwd, branch: meta.worktree.branch });
534
+ if (!merged.merged) {
535
+ return {
536
+ ok: false,
537
+ error: {
538
+ code: 'MERGE_CONFLICT',
539
+ message: `merge of ${meta.worktree.branch} conflicted`,
540
+ details: { conflictFiles: merged.conflictFiles },
541
+ },
542
+ };
543
+ }
544
+ await removeWorktree({ repoCwd: meta.repoCwd, worktreePath: meta.worktree.worktreePath }).catch(() => undefined);
545
+ await deleteBranch({ repoCwd: meta.repoCwd, branch: meta.worktree.branch });
546
+ didMerge = true;
547
+ }
548
+ globalBus.emit({
549
+ type: 'subtask.merged',
550
+ ts: Date.now(),
551
+ sessionId: this.sessionId,
552
+ taskId,
553
+ changedFiles: summary.changedFiles,
554
+ merged: didMerge,
555
+ });
556
+ return { ok: true, value: summary };
238
557
  }
558
+ /** Compact summary for a task record (also used by task_wait). */
239
559
  toChildSummary(r, def, dropped) {
240
560
  const s = this.taskManager.toSummary(r);
241
561
  return {
@@ -28,6 +28,8 @@ export type StreamEvent = {
28
28
  usage?: {
29
29
  input: number;
30
30
  output: number;
31
+ cacheRead?: number;
32
+ cacheWrite?: number;
31
33
  };
32
34
  } | {
33
35
  kind: 'tool_call_start';
@@ -56,6 +58,12 @@ export interface ToolDefinition {
56
58
  export interface CallRequest {
57
59
  model: string;
58
60
  system?: string;
61
+ /**
62
+ * Volatile prompt suffix (Level-7 runtime telemetry). Adapters keep it
63
+ * out of the cacheable prefix: Anthropic sends the array system form
64
+ * (breakpoint on the stable part), OpenAI appends it with a separator.
65
+ */
66
+ systemSuffix?: string;
59
67
  messages: Message[];
60
68
  tools: ToolDefinition[];
61
69
  maxTokens?: number;
@@ -108,8 +108,12 @@ function zodFieldSchema(s) {
108
108
  */
109
109
  export function buildChatCompletionsBody(req) {
110
110
  const messages = [];
111
+ // OpenAI has no system array form: the volatile suffix rides along as a
112
+ // plain concatenation (behavior-preserving when absent).
111
113
  if (req.system)
112
- messages.push({ role: 'system', content: req.system });
114
+ messages.push({ role: 'system', content: req.systemSuffix ? `${req.system}\n\n${req.systemSuffix}` : req.system });
115
+ else if (req.systemSuffix)
116
+ messages.push({ role: 'system', content: req.systemSuffix });
113
117
  for (const m of req.messages) {
114
118
  if (m.role === 'assistant') {
115
119
  const text = m.content.filter((b) => b.kind === 'text').map((b) => b.text).join('');
@@ -202,11 +206,16 @@ async function* streamChatCompletions(url, opts, req, fetchImpl) {
202
206
  req.signal?.removeEventListener('abort', onAbort);
203
207
  const rawErr = await res.text().catch(() => '');
204
208
  const errText = redact(rawErr).slice(0, 500);
205
- const retryable = res.status >= 500 || res.status === 429;
209
+ // Context overflow is a client-side budget problem, not a transient
210
+ // failure: surface a dedicated code (never retryable here — the runtime
211
+ // owns the compress-and-retry recovery) so callers can distinguish it
212
+ // from 429/5xx backoff cases.
213
+ const isOverflow = res.status === 413 || /request_too_large|too_large|prompt_too_long|context_length|maximum context length/i.test(rawErr);
214
+ const retryable = !isOverflow && (res.status >= 500 || res.status === 429);
206
215
  const retryAfterMs = retryable ? parseRetryAfterMs(res.headers?.get('retry-after')) : undefined;
207
216
  yield {
208
217
  kind: 'error',
209
- code: `HTTP_${res.status}`,
218
+ code: isOverflow ? 'REQUEST_TOO_LARGE' : `HTTP_${res.status}`,
210
219
  message: `provider returned ${res.status}: ${errText}`,
211
220
  retryable,
212
221
  status: String(res.status),
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * delay = min(maxMs, baseMs * 2^attempt) ± jitter
17
17
  *
18
- * The default policy matches the L6 plan: 3 attempts, 500ms base, 8s cap.
18
+ * The default policy matches the L6 plan: 5 attempts, 500ms base, 8s cap.
19
19
  */
20
20
  import type { ProviderAdapter } from './provider-adapter.js';
21
21
  export interface RetryOptions {
@@ -15,10 +15,10 @@
15
15
  *
16
16
  * delay = min(maxMs, baseMs * 2^attempt) ± jitter
17
17
  *
18
- * The default policy matches the L6 plan: 3 attempts, 500ms base, 8s cap.
18
+ * The default policy matches the L6 plan: 5 attempts, 500ms base, 8s cap.
19
19
  */
20
20
  export const DEFAULT_RETRY = {
21
- maxAttempts: 3,
21
+ maxAttempts: 5,
22
22
  baseMs: 500,
23
23
  maxMs: 8_000,
24
24
  sleep: defaultSleep,