klyro 1.0.0 → 1.0.2

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 (110) hide show
  1. package/dist/agent/anthropic-adapter.d.ts +49 -5
  2. package/dist/agent/anthropic-adapter.js +86 -17
  3. package/dist/agent/capabilities.d.ts +23 -0
  4. package/dist/agent/capabilities.js +53 -6
  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 +124 -6
  8. package/dist/agent/orchestrator.js +425 -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 +54 -12
  13. package/dist/agent/runtime.d.ts +84 -8
  14. package/dist/agent/runtime.js +352 -39
  15. package/dist/agent/stream-budget.d.ts +36 -0
  16. package/dist/agent/stream-budget.js +121 -0
  17. package/dist/agent/worktree-manager.d.ts +74 -0
  18. package/dist/agent/worktree-manager.js +189 -0
  19. package/dist/checkpoints/store.d.ts +9 -0
  20. package/dist/checkpoints/store.js +56 -5
  21. package/dist/cli/auth.js +16 -1
  22. package/dist/cli/commit.d.ts +31 -0
  23. package/dist/cli/commit.js +142 -0
  24. package/dist/cli/config.d.ts +54 -3
  25. package/dist/cli/config.js +146 -3
  26. package/dist/cli/doctor.d.ts +1 -0
  27. package/dist/cli/doctor.js +71 -6
  28. package/dist/cli/eval.d.ts +6 -1
  29. package/dist/cli/eval.js +9 -0
  30. package/dist/cli/hooks.d.ts +47 -0
  31. package/dist/cli/hooks.js +181 -0
  32. package/dist/cli/repl.js +196 -29
  33. package/dist/cli/run.d.ts +13 -11
  34. package/dist/cli/run.js +144 -20
  35. package/dist/cli/update.d.ts +5 -0
  36. package/dist/cli/update.js +62 -10
  37. package/dist/context/import-graph.d.ts +2 -0
  38. package/dist/context/import-graph.js +31 -3
  39. package/dist/context/klyro-md.js +4 -1
  40. package/dist/context/memory.d.ts +8 -0
  41. package/dist/context/memory.js +50 -2
  42. package/dist/context/project-map.d.ts +6 -0
  43. package/dist/context/project-map.js +50 -2
  44. package/dist/context/repo-map.d.ts +2 -0
  45. package/dist/context/repo-map.js +31 -1
  46. package/dist/events/catalog.d.ts +37 -0
  47. package/dist/events/catalog.js +9 -0
  48. package/dist/index.js +177 -8
  49. package/dist/mcp/client.d.ts +6 -4
  50. package/dist/mcp/client.js +83 -14
  51. package/dist/mcp/config.d.ts +10 -0
  52. package/dist/mcp/config.js +18 -1
  53. package/dist/mcp/registry.d.ts +23 -19
  54. package/dist/mcp/registry.js +127 -8
  55. package/dist/mcp/schema.d.ts +11 -4
  56. package/dist/mcp/schema.js +27 -16
  57. package/dist/mcp/trust.d.ts +20 -0
  58. package/dist/mcp/trust.js +74 -0
  59. package/dist/persistence/audit.d.ts +28 -0
  60. package/dist/persistence/audit.js +101 -1
  61. package/dist/persistence/store.d.ts +26 -2
  62. package/dist/persistence/store.js +140 -13
  63. package/dist/policy/approval.d.ts +14 -0
  64. package/dist/policy/approval.js +44 -2
  65. package/dist/policy/engine.d.ts +17 -0
  66. package/dist/policy/engine.js +162 -9
  67. package/dist/policy/path-guard.d.ts +24 -0
  68. package/dist/policy/path-guard.js +46 -0
  69. package/dist/policy/secret-redactor.js +4 -0
  70. package/dist/providers/model-info.d.ts +23 -0
  71. package/dist/providers/model-info.js +43 -2
  72. package/dist/repl.d.ts +6 -0
  73. package/dist/repl.js +12 -7
  74. package/dist/tools/agent/spawn-agent.js +5 -5
  75. package/dist/tools/agent/task-apply.d.ts +4 -0
  76. package/dist/tools/agent/task-apply.js +44 -0
  77. package/dist/tools/agent/task-stop.d.ts +6 -0
  78. package/dist/tools/agent/task-stop.js +39 -0
  79. package/dist/tools/agent/task-wait.d.ts +17 -0
  80. package/dist/tools/agent/task-wait.js +79 -0
  81. package/dist/tools/fs/apply-patch.js +77 -1
  82. package/dist/tools/fs/edit-file.js +69 -1
  83. package/dist/tools/fs/multi-edit.d.ts +4 -0
  84. package/dist/tools/fs/multi-edit.js +70 -1
  85. package/dist/tools/fs/write-file.js +83 -6
  86. package/dist/tools/plan/todo-write.js +1 -1
  87. package/dist/tools/registry.js +6 -0
  88. package/dist/tools/shell/background.js +6 -3
  89. package/dist/tools/shell/sandbox.d.ts +51 -0
  90. package/dist/tools/shell/sandbox.js +143 -0
  91. package/dist/tools/shell/shell-exec.d.ts +29 -0
  92. package/dist/tools/shell/shell-exec.js +170 -12
  93. package/dist/tools/shell/worker-entry.d.ts +12 -0
  94. package/dist/tools/shell/worker-entry.js +43 -0
  95. package/dist/tools/types.d.ts +6 -0
  96. package/dist/tools/verify/run-verify.js +3 -1
  97. package/dist/trace/writer.d.ts +20 -0
  98. package/dist/trace/writer.js +62 -4
  99. package/dist/tui/app.js +1 -1
  100. package/dist/tui/approval.js +20 -21
  101. package/dist/util.d.ts +1 -0
  102. package/dist/util.js +1 -0
  103. package/dist/verification/baseline.js +17 -3
  104. package/dist/verification/classify.js +27 -15
  105. package/dist/verification/engine.d.ts +8 -0
  106. package/dist/verification/engine.js +28 -1
  107. package/dist/verification/registry.d.ts +2 -0
  108. package/dist/verification/registry.js +44 -0
  109. package/dist/verification/scoped.js +64 -11
  110. 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. */
@@ -66,16 +87,61 @@ function mapResultStatus(status) {
66
87
  return 'failed';
67
88
  }
68
89
  }
90
+ /**
91
+ * Build a `subtask.progress` note for one finished tool call.
92
+ * Pure — unit-tested directly (see agent-tools.test.ts).
93
+ */
94
+ export function progressNote(step, tool, isError) {
95
+ return `step ${step}: ${tool} ${isError ? 'ERR' : 'ok'}`;
96
+ }
97
+ /**
98
+ * Build the `RunOptions.onEvent` handler the orchestrator passes into each
99
+ * child's run options. Emits at most one `subtask.progress` per tool call:
100
+ * a `tool_result` is only mirrored when its `tool_call_end` was observed
101
+ * first, so duplicate/late results can never double-emit. (The note needs
102
+ * the ok/ERR outcome, which only `tool_result` carries — `tool_call_end`
103
+ * alone cannot build it — hence the end-gated result throttle.)
104
+ */
105
+ export function createSubtaskProgressEmitter(opts) {
106
+ let step = 0;
107
+ const ended = new Set();
108
+ return (ev) => {
109
+ if (ev.kind === 'step_start') {
110
+ step = ev.step;
111
+ }
112
+ else if (ev.kind === 'tool_call_end') {
113
+ ended.add(ev.id);
114
+ }
115
+ else if (ev.kind === 'tool_result') {
116
+ if (!ended.has(ev.id))
117
+ return;
118
+ ended.delete(ev.id);
119
+ globalBus.emit({
120
+ type: 'subtask.progress',
121
+ ts: Date.now(),
122
+ sessionId: opts.sessionId,
123
+ taskId: opts.taskId,
124
+ note: progressNote(step, ev.name, ev.isError),
125
+ });
126
+ }
127
+ };
128
+ }
69
129
  export class AgentOrchestrator {
70
130
  sessionId;
71
131
  deps;
72
132
  taskManager;
73
133
  workerSpawner;
134
+ isTui;
135
+ /** Per-task spawn metadata: capability drops + worktree placement. */
136
+ taskMeta = new Map();
137
+ /** Finished summaries not yet drained via `drainCompletions`. */
138
+ undrained = new Map();
74
139
  constructor(opts) {
75
140
  this.sessionId = opts.sessionId;
76
141
  this.deps = opts.deps;
77
142
  this.taskManager = opts.taskManager ?? new TaskManager({ sessionId: opts.sessionId });
78
143
  this.workerSpawner = opts.workerSpawner ?? new WorkerSpawner();
144
+ this.isTui = opts.isTui ?? false;
79
145
  }
80
146
  listAgents() {
81
147
  return [...BUILTIN_AGENTS];
@@ -98,6 +164,10 @@ export class AgentOrchestrator {
98
164
  const s = this.taskManager.toSummary(r);
99
165
  return r.error ? { ...s, error: { code: r.error.code, message: r.error.message } } : s;
100
166
  },
167
+ drainCompletions: () => this.drainCompletions(),
168
+ waitForTasks: (taskIds, timeoutMs) => this.waitForTasks(taskIds, timeoutMs),
169
+ cancelTask: (taskId) => this.cancelTask(taskId),
170
+ applyTask: (taskId) => this.applyTask(taskId),
101
171
  };
102
172
  }
103
173
  /** Compute a child's effective capabilities from the parent's own. */
@@ -111,13 +181,14 @@ export class AgentOrchestrator {
111
181
  spawnTools: DEFAULT_SPAWN_TOOLS,
112
182
  denied: DEFAULT_DENIED_TOOLS,
113
183
  };
114
- const resolved = resolveCapabilities({ ...input, maxDepth: parent.maxDepth });
184
+ const resolved = resolveCapabilities({ ...input, maxDepth: parent.maxDepth, parentAllowedPaths: parent.allowedPaths });
115
185
  return resolved;
116
186
  }
117
187
  /**
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).
188
+ * Spawn a child agent asynchronously: start the child worker and return
189
+ * IMMEDIATELY with `{ status: 'running' }`. Completion/failure bus emits
190
+ * and `taskManager.finish` happen in the worker closure; the parent
191
+ * observes them via `task_wait` / `task_get` / `drainCompletions`.
121
192
  */
122
193
  async spawnAgent(input, parent) {
123
194
  const def = this.getAgent(input.agent);
@@ -139,10 +210,67 @@ export class AgentOrchestrator {
139
210
  },
140
211
  };
141
212
  }
213
+ // Concurrency budgets — enforced before creating the task.
214
+ const all = this.taskManager.list();
215
+ const runningCount = all.filter((t) => t.status === 'running').length;
216
+ if (runningCount >= MAX_CONCURRENT_TASKS) {
217
+ return {
218
+ ok: false,
219
+ error: { code: 'CONCURRENCY_LIMIT', message: `maxConcurrentTasks (${MAX_CONCURRENT_TASKS}) reached` },
220
+ };
221
+ }
222
+ const perParentCount = all.filter((t) => t.parentTaskId === parent.taskId).length;
223
+ if (perParentCount >= MAX_TASKS_PER_PARENT) {
224
+ return {
225
+ ok: false,
226
+ error: { code: 'CONCURRENCY_LIMIT', message: `maxTasksPerParent (${MAX_TASKS_PER_PARENT}) reached` },
227
+ };
228
+ }
229
+ if (all.length >= MAX_TOTAL_TASKS_PER_SESSION) {
230
+ return {
231
+ ok: false,
232
+ error: { code: 'CONCURRENCY_LIMIT', message: `maxTotalTasksPerSession (${MAX_TOTAL_TASKS_PER_SESSION}) reached` },
233
+ };
234
+ }
235
+ // Spawn cwd containment (S6): an explicit cwd must stay inside the parent.
236
+ let baseCwd = parent.cwd;
237
+ if (input.cwd) {
238
+ try {
239
+ baseCwd = (await resolveAndFollowSymlinks(parent.cwd, input.cwd)).resolved;
240
+ }
241
+ catch (err) {
242
+ return {
243
+ ok: false,
244
+ error: {
245
+ code: 'PATH_ESCAPE',
246
+ message: err instanceof Error ? err.message : `cwd escapes parent: ${input.cwd}`,
247
+ },
248
+ };
249
+ }
250
+ }
142
251
  const registryTools = new Set(this.deps.registry.list().map((t) => t.name));
143
252
  const resolved = this.resolveChild(def, parent, registryTools);
144
253
  const childModel = input.model ?? resolved.model ?? parent.model;
145
- const childCwd = input.cwd ?? parent.cwd;
254
+ // Worktree isolation: write-capable children get their own worktree —
255
+ // including when the spawn carries an explicit cwd (the worktree is
256
+ // then rooted at the resolved explicit cwd, which containment above
257
+ // already pinned inside the parent). Readonly agents keep the resolved
258
+ // cwd with no worktree. A write-capable spawn outside a git repo is
259
+ // rejected outright.
260
+ const writeCapable = [...resolved.allowed].some((t) => DEFAULT_WRITE_TOOLS.has(t));
261
+ let childCwd = baseCwd;
262
+ let worktree;
263
+ let repoCwd;
264
+ if (!resolved.readonly && writeCapable) {
265
+ const isRepo = await ensureGitRepo(baseCwd).catch(() => false);
266
+ if (!isRepo) {
267
+ return {
268
+ ok: false,
269
+ error: { code: 'WRITES_REQUIRE_GIT', message: 'parallel write agents require a git repository for worktree isolation' },
270
+ };
271
+ }
272
+ repoCwd = baseCwd;
273
+ }
146
274
  const createOpts = {
147
275
  agentName: def.id,
148
276
  cwd: childCwd,
@@ -154,8 +282,42 @@ export class AgentOrchestrator {
154
282
  abortOnParent: undefined, // wired below via the parent signal
155
283
  };
156
284
  const record = this.taskManager.create(createOpts);
285
+ // Worktree creation needs the task id (branch `klyro/<taskId>`), so it
286
+ // happens after `create`. On failure the task is marked failed and the
287
+ // spawn returns the error.
288
+ if (repoCwd !== undefined) {
289
+ try {
290
+ worktree = await createWorktree({ repoCwd, taskId: record.id });
291
+ childCwd = worktree.worktreePath;
292
+ record.cwd = childCwd;
293
+ }
294
+ catch (err) {
295
+ this.taskManager.finish(record.id, 'failed', {
296
+ error: { code: 'WORKTREE_FAILED', message: err instanceof Error ? err.message : String(err) },
297
+ });
298
+ const failed = this.taskManager.get(record.id);
299
+ this.stashSummary(failed, def, resolved.dropped);
300
+ this.taskMeta.set(record.id, { def, dropped: resolved.dropped });
301
+ return {
302
+ ok: false,
303
+ error: { code: 'WORKTREE_FAILED', message: err instanceof Error ? err.message : String(err) },
304
+ };
305
+ }
306
+ }
307
+ this.taskMeta.set(record.id, worktree ? { def, dropped: resolved.dropped, worktree, repoCwd } : { def, dropped: resolved.dropped });
157
308
  const childRegistry = new ScopedRegistry(this.deps.registry, resolved.allowed);
158
309
  const childDeps = { ...this.deps, registry: childRegistry };
310
+ const childRef = {
311
+ taskId: record.id,
312
+ ...(parent.taskId !== undefined ? { parentTaskId: parent.taskId } : {}),
313
+ sessionId: this.sessionId,
314
+ cwd: childCwd,
315
+ depth: childDepth,
316
+ maxDepth,
317
+ allowedTools: resolved.allowed,
318
+ ...(childModel !== undefined ? { model: childModel } : {}),
319
+ ...(resolved.allowedPaths !== undefined ? { allowedPaths: resolved.allowedPaths } : {}),
320
+ };
159
321
  const childOptions = {
160
322
  task: input.task,
161
323
  cwd: childCwd,
@@ -165,6 +327,15 @@ export class AgentOrchestrator {
165
327
  maxTimeMs: def.maxTimeMs ?? input.timeoutMs,
166
328
  signal: record.abortController.signal,
167
329
  nonInteractive: true,
330
+ // Mid-life progress: mirror each finished tool call as one
331
+ // `subtask.progress` bus event (see createSubtaskProgressEmitter).
332
+ // Process-isolated children don't run this closure — only the
333
+ // in-process path reports mid-life progress.
334
+ onEvent: createSubtaskProgressEmitter({ taskId: record.id, sessionId: this.sessionId }),
335
+ // Grandchildren: only children that canSpawn receive the bridge —
336
+ // otherwise tools see NO_ORCHESTRATOR as before.
337
+ ...(resolved.canSpawn ? { agentBridge: this.bridgeFor(childRef) } : {}),
338
+ ...(def.maxTokens !== undefined ? { maxTokens: def.maxTokens } : {}),
168
339
  };
169
340
  // Lifecycle events on the shared bus (mirror TaskManager transitions).
170
341
  globalBus.emit({
@@ -177,65 +348,261 @@ export class AgentOrchestrator {
177
348
  depth: childDepth,
178
349
  ...(typeof childModel === 'string' ? { model: childModel } : {}),
179
350
  });
180
- const handle = this.workerSpawner.spawn(async () => {
181
- let result;
351
+ // G2 — process isolation for headless sub-agents. In-process is the
352
+ // fallback (and mandatory for TUI children — see OrchestratorOpts.isTui),
353
+ // and opt-out via KLYRO_WORKER=0 for tests/dev.
354
+ const useProcessIsolation = !this.isTui && process.env.KLYRO_WORKER !== '0';
355
+ this.workerSpawner.spawn(async (signal) => {
356
+ // Both the in-process path and the forked child resolve to the same
357
+ // minimal outcome shape the settle tail needs.
358
+ let childOutcome;
182
359
  try {
183
- result = await run(childOptions, childDeps);
360
+ if (useProcessIsolation) {
361
+ const sysPrompt = resolveSystemPrompt(this.deps.systemPrompt, { cwd: childCwd });
362
+ // Splice the volatile telemetry suffix into the stable prefix so the
363
+ // child's provider sees one system string. Telemetry is best-effort
364
+ // inside the child (it re-emits); the goal here is parity, not
365
+ // perfect replay.
366
+ const systemPrompt = sysPrompt.suffix ? `${sysPrompt.system}\n${sysPrompt.suffix}` : sysPrompt.system;
367
+ const payload = {
368
+ cwd: childCwd,
369
+ task: input.task,
370
+ // A concrete provider model must reach the child — 'inherit' only
371
+ // exists to defer resolution inside the parent's run().
372
+ model: (childModel ?? parent.model),
373
+ systemPrompt,
374
+ agentId: def.id,
375
+ maxSteps: def.maxSteps,
376
+ maxCost: def.maxCost,
377
+ maxTimeMs: def.maxTimeMs ?? input.timeoutMs,
378
+ ...(def.maxTokens !== undefined ? { maxTokens: def.maxTokens } : {}),
379
+ };
380
+ const cr = await forkChild(workerEntryPath(), payload, { signal });
381
+ childOutcome = cr; // ChildResult is the minimal settle shape
382
+ }
383
+ else {
384
+ const r = await run(childOptions, childDeps);
385
+ childOutcome = { status: r.status, steps: r.steps, toolCalls: r.toolCalls, finalText: r.finalText };
386
+ }
184
387
  }
185
388
  catch (err) {
186
- this.taskManager.finish(record.id, 'failed', {
187
- error: { code: 'CHILD_CRASH', message: err instanceof Error ? err.message : String(err) },
188
- });
389
+ const isCrash = err instanceof Error && err.name === 'ChildCrashError';
390
+ const crashErr = isCrash ? err : undefined;
391
+ this.settleChild(record.id, 'failed', {
392
+ error: {
393
+ code: 'CHILD_CRASH',
394
+ message: isCrash
395
+ ? `child process isolated failure (${crashErr?.likelyCause ?? 'exit'}): ${crashErr?.message ?? ''}`.trim()
396
+ : err instanceof Error ? err.message : String(err),
397
+ },
398
+ }, { steps: 0, toolCalls: 0 });
189
399
  return;
190
400
  }
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
- }
401
+ const status = mapResultStatus(childOutcome.status);
402
+ this.settleChild(record.id, status, {
403
+ summary: [`status: ${status}`, `steps: ${childOutcome.steps}`, `toolCalls: ${childOutcome.toolCalls}`],
404
+ error: status === 'failed'
405
+ ? { code: mapFailureCode(childOutcome.status), message: childOutcome.finalText?.slice(0, 300) ?? 'child failed' }
406
+ : undefined,
407
+ }, { steps: childOutcome.steps, toolCalls: childOutcome.toolCalls });
228
408
  }, { label: `agent:${def.id}` });
229
- await handle.done.catch(() => undefined);
230
- const finalRecord = this.taskManager.get(record.id);
231
- if (!finalRecord) {
409
+ // Async contract: return immediately with the running summary. The final
410
+ // text is omitted while running; observe via task_wait / drainCompletions.
411
+ return {
412
+ ok: true,
413
+ value: { taskId: record.id, agentName: def.id, status: 'running', durationMs: 0, changedFiles: [] },
414
+ };
415
+ }
416
+ /**
417
+ * Settle a child in the worker closure: finish the task (idempotent —
418
+ * a TaskManager timeout/cancel that fired first wins), emit the terminal
419
+ * bus event, stash the summary for `drainCompletions`, and best-effort
420
+ * remove the worktree unless the child succeeded (merge happens later in
421
+ * `task_apply`).
422
+ */
423
+ settleChild(taskId, status, patch, counts) {
424
+ const existing = this.taskManager.get(taskId);
425
+ if (!existing)
426
+ return;
427
+ let finalStatus = status;
428
+ if (existing.status === 'queued' || existing.status === 'running') {
429
+ this.taskManager.finish(taskId, status, patch);
430
+ }
431
+ else {
432
+ // Already terminal (timeout/cancel raced the run) — preserve it.
433
+ finalStatus = existing.status;
434
+ }
435
+ const final = this.taskManager.get(taskId);
436
+ this.stashSummary(final);
437
+ const durationMs = final.finishedAt ? final.finishedAt - final.startedAt : 0;
438
+ const error = final.error ? { code: final.error.code, message: final.error.message } : undefined;
439
+ const steps = counts?.steps ?? 0;
440
+ const toolCalls = counts?.toolCalls ?? 0;
441
+ if (finalStatus === 'succeeded') {
442
+ globalBus.emit({
443
+ type: 'subtask.completed',
444
+ ts: Date.now(),
445
+ sessionId: this.sessionId,
446
+ taskId,
447
+ status: 'succeeded',
448
+ durationMs,
449
+ steps,
450
+ toolCalls,
451
+ });
452
+ }
453
+ else if (finalStatus === 'cancelled') {
454
+ globalBus.emit({
455
+ type: 'subtask.cancelled',
456
+ ts: Date.now(),
457
+ sessionId: this.sessionId,
458
+ taskId,
459
+ status: 'cancelled',
460
+ durationMs,
461
+ ...(error ? { error } : {}),
462
+ });
463
+ }
464
+ else if (finalStatus === 'timed_out') {
465
+ globalBus.emit({
466
+ type: 'subtask.timed_out',
467
+ ts: Date.now(),
468
+ sessionId: this.sessionId,
469
+ taskId,
470
+ status: 'timed_out',
471
+ durationMs,
472
+ ...(error ? { error } : {}),
473
+ });
474
+ }
475
+ else {
476
+ globalBus.emit({
477
+ type: 'subtask.failed',
478
+ ts: Date.now(),
479
+ sessionId: this.sessionId,
480
+ taskId,
481
+ status: finalStatus,
482
+ durationMs,
483
+ ...(error ? { error } : {}),
484
+ });
485
+ }
486
+ // Non-success terminal states never merge — drop the worktree.
487
+ const meta = this.taskMeta.get(taskId);
488
+ if (meta?.worktree && meta.repoCwd && finalStatus !== 'succeeded') {
489
+ const { worktree, repoCwd } = meta;
490
+ void removeWorktree({ repoCwd, worktreePath: worktree.worktreePath, force: true }).catch(() => undefined);
491
+ }
492
+ }
493
+ /** Build and stash the finished summary for `drainCompletions`. */
494
+ stashSummary(r, def, dropped) {
495
+ const meta = this.taskMeta.get(r.id);
496
+ const d = def ?? meta?.def;
497
+ if (!d)
498
+ return;
499
+ this.undrained.set(r.id, this.toChildSummary(r, d, dropped ?? meta?.dropped ?? []));
500
+ }
501
+ /** Completed-then-undrained child summaries; each drained exactly once. */
502
+ drainCompletions() {
503
+ const out = [...this.undrained.values()];
504
+ this.undrained.clear();
505
+ return out;
506
+ }
507
+ /**
508
+ * Await each task's `done` up to `timeoutMs` per task. Unsettled entries
509
+ * come back with status `'running'` and a running summary — the underlying
510
+ * task is NOT cancelled.
511
+ */
512
+ async waitForTasks(taskIds, timeoutMs) {
513
+ const tasks = await Promise.all(taskIds.map(async (taskId) => {
514
+ const rec = this.taskManager.get(taskId);
515
+ if (!rec) {
516
+ return { taskId, status: 'failed', error: { code: 'NOT_FOUND', message: `Unknown task: ${taskId}` } };
517
+ }
518
+ const meta = this.taskMeta.get(taskId);
519
+ const settled = await this.awaitDone(rec, timeoutMs);
520
+ const summary = meta ? this.toChildSummary(settled, meta.def, meta.dropped) : undefined;
521
+ const entry = { taskId, status: settled.status };
522
+ if (summary)
523
+ entry.summary = summary;
524
+ if (settled.error)
525
+ entry.error = { code: settled.error.code, message: settled.error.message };
526
+ return entry;
527
+ }));
528
+ return { tasks };
529
+ }
530
+ async awaitDone(rec, timeoutMs) {
531
+ if (timeoutMs === undefined)
532
+ return rec.done;
533
+ let timer;
534
+ try {
535
+ return await Promise.race([
536
+ rec.done,
537
+ new Promise((resolve) => {
538
+ timer = setTimeout(() => resolve(this.taskManager.get(rec.id)), timeoutMs);
539
+ }),
540
+ ]);
541
+ }
542
+ finally {
543
+ if (timer)
544
+ clearTimeout(timer);
545
+ }
546
+ }
547
+ /** Signal abort for a live task via `taskManager.cancel` (in-process workers; no subprocesses yet). Idempotent on terminal tasks. */
548
+ cancelTask(taskId) {
549
+ const rec = this.taskManager.get(taskId);
550
+ if (!rec) {
551
+ return { ok: false, error: { code: 'NOT_FOUND', message: `Unknown task: ${taskId}` } };
552
+ }
553
+ this.taskManager.cancel(taskId, 'stopped via task_stop');
554
+ const final = this.taskManager.get(taskId);
555
+ return { ok: true, value: { taskId, status: final.status } };
556
+ }
557
+ /**
558
+ * Apply a succeeded task: merge its worktree branch into the parent tree
559
+ * (or acknowledge a shared-cwd task whose edits already landed) and emit
560
+ * `subtask.merged`. Non-succeeded tasks error with NOT_READY; merge
561
+ * conflicts error with MERGE_CONFLICT (merge already aborted, branch kept).
562
+ */
563
+ async applyTask(taskId) {
564
+ const rec = this.taskManager.get(taskId);
565
+ if (!rec) {
566
+ return { ok: false, error: { code: 'NOT_FOUND', message: `Unknown task: ${taskId}` } };
567
+ }
568
+ if (rec.status !== 'succeeded') {
232
569
  return {
233
570
  ok: false,
234
- error: { code: 'INTERNAL', message: `task ${record.id} missing after child run` },
571
+ error: { code: 'NOT_READY', message: `task ${taskId} is ${rec.status}, not succeeded` },
235
572
  };
236
573
  }
237
- return { ok: true, value: this.toChildSummary(finalRecord, def, resolved.dropped) };
574
+ const meta = this.taskMeta.get(taskId);
575
+ const summary = meta ? this.toChildSummary(rec, meta.def, meta.dropped) : this.toChildSummary(rec, this.getAgent(rec.agentName) ?? { id: rec.agentName, description: '' }, []);
576
+ // merged=true only when a real worktree branch merge ran; shared-cwd
577
+ // tasks (edits already in the parent tree) are an acknowledgement.
578
+ let didMerge = false;
579
+ if (meta?.worktree && meta.repoCwd) {
580
+ const merged = await mergeWorktree({ repoCwd: meta.repoCwd, branch: meta.worktree.branch });
581
+ if (!merged.merged) {
582
+ return {
583
+ ok: false,
584
+ error: {
585
+ code: 'MERGE_CONFLICT',
586
+ message: `merge of ${meta.worktree.branch} conflicted`,
587
+ details: { conflictFiles: merged.conflictFiles },
588
+ },
589
+ };
590
+ }
591
+ await removeWorktree({ repoCwd: meta.repoCwd, worktreePath: meta.worktree.worktreePath }).catch(() => undefined);
592
+ await deleteBranch({ repoCwd: meta.repoCwd, branch: meta.worktree.branch });
593
+ didMerge = true;
594
+ }
595
+ globalBus.emit({
596
+ type: 'subtask.merged',
597
+ ts: Date.now(),
598
+ sessionId: this.sessionId,
599
+ taskId,
600
+ changedFiles: summary.changedFiles,
601
+ merged: didMerge,
602
+ });
603
+ return { ok: true, value: summary };
238
604
  }
605
+ /** Compact summary for a task record (also used by task_wait). */
239
606
  toChildSummary(r, def, dropped) {
240
607
  const s = this.taskManager.toSummary(r);
241
608
  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 {