klyro 0.1.63 → 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 (117) hide show
  1. package/dist/agent/anthropic-adapter.d.ts +36 -0
  2. package/dist/agent/anthropic-adapter.js +73 -16
  3. package/dist/agent/capabilities.d.ts +145 -0
  4. package/dist/agent/capabilities.js +191 -0
  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 +232 -0
  8. package/dist/agent/orchestrator.js +589 -0
  9. package/dist/agent/provider-adapter.d.ts +17 -0
  10. package/dist/agent/provider-adapter.js +35 -3
  11. package/dist/agent/registry.d.ts +1 -0
  12. package/dist/agent/registry.js +1 -0
  13. package/dist/agent/retry.d.ts +13 -2
  14. package/dist/agent/retry.js +21 -3
  15. package/dist/agent/runtime.d.ts +70 -8
  16. package/dist/agent/runtime.js +205 -34
  17. package/dist/agent/scoped-registry.d.ts +22 -0
  18. package/dist/agent/scoped-registry.js +42 -0
  19. package/dist/agent/task-manager.d.ts +115 -0
  20. package/dist/agent/task-manager.js +250 -0
  21. package/dist/agent/worker-spawner.d.ts +17 -12
  22. package/dist/agent/worker-spawner.js +26 -20
  23. package/dist/agent/worktree-manager.d.ts +74 -0
  24. package/dist/agent/worktree-manager.js +189 -0
  25. package/dist/checkpoints/store.js +30 -5
  26. package/dist/cli/auth.js +16 -1
  27. package/dist/cli/config.d.ts +9 -3
  28. package/dist/cli/config.js +64 -3
  29. package/dist/cli/dotenv.d.ts +3 -0
  30. package/dist/cli/dotenv.js +57 -0
  31. package/dist/cli/eval.d.ts +6 -1
  32. package/dist/cli/eval.js +9 -0
  33. package/dist/cli/repl.js +183 -17
  34. package/dist/cli/run.d.ts +10 -11
  35. package/dist/cli/run.js +176 -14
  36. package/dist/cli/update.d.ts +5 -0
  37. package/dist/cli/update.js +62 -10
  38. package/dist/context/import-graph.d.ts +2 -0
  39. package/dist/context/import-graph.js +31 -3
  40. package/dist/context/klyro-md.d.ts +6 -0
  41. package/dist/context/klyro-md.js +25 -16
  42. package/dist/context/memory.d.ts +8 -0
  43. package/dist/context/memory.js +50 -2
  44. package/dist/context/project-map.d.ts +6 -0
  45. package/dist/context/project-map.js +50 -2
  46. package/dist/context/repo-map.d.ts +2 -0
  47. package/dist/context/repo-map.js +31 -1
  48. package/dist/context/trust.d.ts +42 -0
  49. package/dist/context/trust.js +111 -0
  50. package/dist/events/catalog.d.ts +99 -0
  51. package/dist/index.js +93 -4
  52. package/dist/mcp/client.d.ts +55 -0
  53. package/dist/mcp/client.js +294 -0
  54. package/dist/mcp/config.d.ts +40 -0
  55. package/dist/mcp/config.js +99 -0
  56. package/dist/mcp/policy.d.ts +13 -0
  57. package/dist/mcp/policy.js +12 -0
  58. package/dist/mcp/registry.d.ts +72 -0
  59. package/dist/mcp/registry.js +244 -0
  60. package/dist/mcp/schema.d.ts +18 -0
  61. package/dist/mcp/schema.js +57 -0
  62. package/dist/mcp/trust.d.ts +20 -0
  63. package/dist/mcp/trust.js +74 -0
  64. package/dist/persistence/audit.d.ts +28 -0
  65. package/dist/persistence/audit.js +101 -1
  66. package/dist/persistence/store.d.ts +26 -2
  67. package/dist/persistence/store.js +140 -13
  68. package/dist/policy/approval.d.ts +14 -0
  69. package/dist/policy/approval.js +44 -2
  70. package/dist/policy/engine.d.ts +1 -0
  71. package/dist/policy/engine.js +91 -10
  72. package/dist/policy/secret-redactor.js +4 -0
  73. package/dist/providers/model-info.d.ts +17 -0
  74. package/dist/providers/model-info.js +35 -2
  75. package/dist/repl.d.ts +6 -0
  76. package/dist/repl.js +12 -7
  77. package/dist/tools/agent/spawn-agent.d.ts +9 -0
  78. package/dist/tools/agent/spawn-agent.js +50 -0
  79. package/dist/tools/agent/task-apply.d.ts +4 -0
  80. package/dist/tools/agent/task-apply.js +44 -0
  81. package/dist/tools/agent/task-get.d.ts +8 -0
  82. package/dist/tools/agent/task-get.js +40 -0
  83. package/dist/tools/agent/task-list.d.ts +4 -0
  84. package/dist/tools/agent/task-list.js +41 -0
  85. package/dist/tools/agent/task-stop.d.ts +6 -0
  86. package/dist/tools/agent/task-stop.js +39 -0
  87. package/dist/tools/agent/task-wait.d.ts +17 -0
  88. package/dist/tools/agent/task-wait.js +79 -0
  89. package/dist/tools/fs/apply-patch.js +71 -0
  90. package/dist/tools/fs/edit-file.js +65 -0
  91. package/dist/tools/fs/multi-edit.d.ts +4 -0
  92. package/dist/tools/fs/multi-edit.js +66 -0
  93. package/dist/tools/fs/write-file.js +67 -0
  94. package/dist/tools/plan/todo-write.d.ts +1 -1
  95. package/dist/tools/registry.js +12 -0
  96. package/dist/tools/shell/background.js +6 -3
  97. package/dist/tools/shell/sandbox.d.ts +51 -0
  98. package/dist/tools/shell/sandbox.js +143 -0
  99. package/dist/tools/shell/shell-exec.d.ts +1 -0
  100. package/dist/tools/shell/shell-exec.js +83 -11
  101. package/dist/tools/shell/worker-entry.d.ts +12 -0
  102. package/dist/tools/shell/worker-entry.js +43 -0
  103. package/dist/tools/types.d.ts +18 -0
  104. package/dist/tools/verify/run-verify.js +3 -1
  105. package/dist/trace/writer.d.ts +13 -0
  106. package/dist/trace/writer.js +55 -4
  107. package/dist/tui/app.js +1 -1
  108. package/dist/tui/approval.js +20 -21
  109. package/dist/util.d.ts +1 -0
  110. package/dist/util.js +1 -0
  111. package/dist/verification/baseline.js +17 -3
  112. package/dist/verification/classify.js +23 -12
  113. package/dist/verification/engine.js +3 -1
  114. package/dist/verification/registry.d.ts +2 -0
  115. package/dist/verification/registry.js +33 -0
  116. package/dist/verification/scoped.js +28 -6
  117. package/package.json +1 -1
@@ -0,0 +1,589 @@
1
+ /**
2
+ * AgentOrchestrator — spawns child agents and turns them into tasks.
3
+ *
4
+ * The lowest-level way to run Klyro is `run(options, deps)` (one agent,
5
+ * one loop). Orchestration layers on top of that: a parent agent calls
6
+ * `spawn_agent`, which resolves the target `AgentDefinition`, computes its
7
+ * effective capabilities (via `resolveCapabilities`), creates a
8
+ * `TaskRecord`, and runs a *scoped* child loop. The child runs in-process
9
+ * with a `ScopedRegistry` (narrowed tool set), an optional model override,
10
+ * a depth cap, and an AbortController wired to the parent's signal.
11
+ *
12
+ * The compact result is a `ChildSummary` — a `ToolResult` the parent model
13
+ * can act on — never the full child transcript.
14
+ */
15
+ import { run, resolveSystemPrompt } from './runtime.js';
16
+ import { ScopedRegistry } from './scoped-registry.js';
17
+ import { globalBus } from '../events/bus.js';
18
+ import { TaskManager } from './task-manager.js';
19
+ import { WorkerSpawner } from './worker-spawner.js';
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;
28
+ /** Default agents a model can delegate to. */
29
+ export const BUILTIN_AGENTS = [
30
+ {
31
+ id: 'explorer',
32
+ description: 'Read-only reconnaissance: map the repo, find symbols and tests.',
33
+ readonly: true,
34
+ canSpawn: false,
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'],
36
+ },
37
+ {
38
+ id: 'implementer',
39
+ description: 'Write-capable worker for concrete, well-scoped coding tasks.',
40
+ canSpawn: false,
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'],
42
+ maxSteps: 60,
43
+ },
44
+ {
45
+ id: 'tester',
46
+ description: 'Runs verification and tests, reports failures with diagnostics.',
47
+ canSpawn: false,
48
+ allowedTools: ['read_file', 'list_directory', 'glob', 'grep', 'shell_exec', 'run_verify', 'git_status', 'git_log', 'git_diff'],
49
+ maxTimeMs: 120_000,
50
+ },
51
+ {
52
+ id: 'reviewer',
53
+ description: 'Read-only review of a diff or change set for bugs.',
54
+ readonly: true,
55
+ canSpawn: false,
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'],
71
+ },
72
+ ];
73
+ /** Map a runtime `RunResult.status` to a task status. */
74
+ function mapResultStatus(status) {
75
+ switch (status) {
76
+ case 'complete':
77
+ return 'succeeded';
78
+ case 'aborted':
79
+ return 'cancelled';
80
+ case 'blocked':
81
+ return 'blocked';
82
+ case 'max_steps':
83
+ case 'no_final':
84
+ case 'verify_failed':
85
+ case 'limit':
86
+ case 'stuck':
87
+ return 'failed';
88
+ }
89
+ }
90
+ export class AgentOrchestrator {
91
+ sessionId;
92
+ deps;
93
+ taskManager;
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();
100
+ constructor(opts) {
101
+ this.sessionId = opts.sessionId;
102
+ this.deps = opts.deps;
103
+ this.taskManager = opts.taskManager ?? new TaskManager({ sessionId: opts.sessionId });
104
+ this.workerSpawner = opts.workerSpawner ?? new WorkerSpawner();
105
+ this.isTui = opts.isTui ?? false;
106
+ }
107
+ listAgents() {
108
+ return [...BUILTIN_AGENTS];
109
+ }
110
+ getAgent(id) {
111
+ return BUILTIN_AGENTS.find((a) => a.id === id);
112
+ }
113
+ /** Build the bridge the parent's runtime hands to tools. */
114
+ bridgeFor(parent) {
115
+ return {
116
+ parent,
117
+ listAgents: () => this.listAgents(),
118
+ getAgent: (id) => this.getAgent(id),
119
+ spawnAgent: (input) => this.spawnAgent(input, parent),
120
+ listTasks: (filter) => this.taskManager.list(filter),
121
+ getTask: (id) => {
122
+ const r = this.taskManager.get(id);
123
+ if (!r)
124
+ return undefined;
125
+ const s = this.taskManager.toSummary(r);
126
+ return r.error ? { ...s, error: { code: r.error.code, message: r.error.message } } : s;
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),
132
+ };
133
+ }
134
+ /** Compute a child's effective capabilities from the parent's own. */
135
+ resolveChild(def, parent, registryTools) {
136
+ const input = {
137
+ parentTools: parent.allowedTools,
138
+ agent: def,
139
+ policyAllowed: registryTools,
140
+ registryTools,
141
+ writeTools: DEFAULT_WRITE_TOOLS,
142
+ spawnTools: DEFAULT_SPAWN_TOOLS,
143
+ denied: DEFAULT_DENIED_TOOLS,
144
+ };
145
+ const resolved = resolveCapabilities({ ...input, maxDepth: parent.maxDepth, parentAllowedPaths: parent.allowedPaths });
146
+ return resolved;
147
+ }
148
+ /**
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`.
153
+ */
154
+ async spawnAgent(input, parent) {
155
+ const def = this.getAgent(input.agent);
156
+ if (!def) {
157
+ return {
158
+ ok: false,
159
+ error: { code: 'UNKNOWN_AGENT', message: `Unknown agent: ${input.agent}` },
160
+ };
161
+ }
162
+ // Depth guard — block before creating a (running) task.
163
+ const childDepth = parent.depth + 1;
164
+ const maxDepth = def.maxDepth ?? parent.maxDepth;
165
+ if (childDepth > maxDepth) {
166
+ return {
167
+ ok: false,
168
+ error: {
169
+ code: 'RECURSION_LIMIT',
170
+ message: `maxDepth exceeded for agent "${def.id}": cannot spawn at depth ${childDepth} (cap ${maxDepth})`,
171
+ },
172
+ };
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
+ }
212
+ const registryTools = new Set(this.deps.registry.list().map((t) => t.name));
213
+ const resolved = this.resolveChild(def, parent, registryTools);
214
+ const childModel = input.model ?? resolved.model ?? parent.model;
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
+ }
232
+ const createOpts = {
233
+ agentName: def.id,
234
+ cwd: childCwd,
235
+ depth: childDepth,
236
+ maxDepth,
237
+ model: childModel,
238
+ parentTaskId: parent.taskId,
239
+ timeoutMs: input.timeoutMs ?? def.maxTimeMs,
240
+ abortOnParent: undefined, // wired below via the parent signal
241
+ };
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 });
266
+ const childRegistry = new ScopedRegistry(this.deps.registry, resolved.allowed);
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
+ };
279
+ const childOptions = {
280
+ task: input.task,
281
+ cwd: childCwd,
282
+ model: childModel ?? 'inherit', // model override must reach the adapter (see runtime)
283
+ maxSteps: def.maxSteps,
284
+ maxCost: def.maxCost,
285
+ maxTimeMs: def.maxTimeMs ?? input.timeoutMs,
286
+ signal: record.abortController.signal,
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 } : {}),
292
+ };
293
+ // Lifecycle events on the shared bus (mirror TaskManager transitions).
294
+ globalBus.emit({
295
+ type: 'subtask.started',
296
+ ts: Date.now(),
297
+ sessionId: this.sessionId,
298
+ taskId: record.id,
299
+ ...(record.parentTaskId ? { parentTaskId: record.parentTaskId } : {}),
300
+ agentName: def.id,
301
+ depth: childDepth,
302
+ ...(typeof childModel === 'string' ? { model: childModel } : {}),
303
+ });
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;
312
+ try {
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
+ }
340
+ }
341
+ catch (err) {
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 });
352
+ return;
353
+ }
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 });
361
+ }, { label: `agent:${def.id}` });
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') {
522
+ return {
523
+ ok: false,
524
+ error: { code: 'NOT_READY', message: `task ${taskId} is ${rec.status}, not succeeded` },
525
+ };
526
+ }
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 };
557
+ }
558
+ /** Compact summary for a task record (also used by task_wait). */
559
+ toChildSummary(r, def, dropped) {
560
+ const s = this.taskManager.toSummary(r);
561
+ return {
562
+ taskId: s.id,
563
+ agentName: s.agentName,
564
+ status: s.status,
565
+ durationMs: s.durationMs ?? 0,
566
+ ...(def.model ? { model: def.model } : {}),
567
+ changedFiles: r.summary.filter((line) => line.startsWith('changed: ')).map((line) => line.slice('changed: '.length)),
568
+ droppedTools: dropped.length ? dropped : undefined,
569
+ error: r.error ? { code: r.error.code, message: r.error.message } : undefined,
570
+ };
571
+ }
572
+ }
573
+ function mapFailureCode(status) {
574
+ switch (status) {
575
+ case 'max_steps':
576
+ return 'MAX_STEPS';
577
+ case 'verify_failed':
578
+ return 'VERIFY_FAILED';
579
+ case 'limit':
580
+ return 'LIMIT';
581
+ case 'stuck':
582
+ return 'STUCK';
583
+ case 'no_final':
584
+ default:
585
+ return 'NO_FINAL';
586
+ }
587
+ }
588
+ /** Module-scoped holder the orchestrator sets so children inherit the parent's signal. */
589
+ export const parentAbortSignalRef = { current: null };
@@ -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';
@@ -45,6 +47,8 @@ export type StreamEvent = {
45
47
  code: string;
46
48
  message: string;
47
49
  retryable: boolean;
50
+ status?: string;
51
+ retryAfterMs?: number;
48
52
  };
49
53
  export interface ToolDefinition {
50
54
  name: string;
@@ -54,6 +58,12 @@ export interface ToolDefinition {
54
58
  export interface CallRequest {
55
59
  model: string;
56
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;
57
67
  messages: Message[];
58
68
  tools: ToolDefinition[];
59
69
  maxTokens?: number;
@@ -72,6 +82,13 @@ export interface HttpAdapterOptions {
72
82
  /** Override fetch (e.g. for tests). */
73
83
  fetchImpl?: typeof fetch;
74
84
  }
85
+ /**
86
+ * Parse a `Retry-After` response header value into milliseconds.
87
+ * Returns undefined when absent or unparseable. Handles both forms:
88
+ * delay-seconds ("120") and HTTP-date ("Wed, 21 Oct 2015 07:28:00 GMT",
89
+ * clamped at 0 when the date is in the past).
90
+ */
91
+ export declare function parseRetryAfterMs(value: string | null | undefined): number | undefined;
75
92
  /** Convert a Zod schema to a permissive JSON Schema object for tool defs. */
76
93
  export declare function zodToJsonSchema(schema: z.ZodType<unknown>): Record<string, unknown>;
77
94
  interface ChatCompletionsRequest {