aiden-runtime 4.5.0 → 4.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +17 -2
  2. package/dist/cli/v4/aidenCLI.js +185 -99
  3. package/dist/cli/v4/chatSession.js +107 -0
  4. package/dist/cli/v4/commands/_runtimeToggleHelpers.js +2 -0
  5. package/dist/cli/v4/commands/fanout.js +42 -59
  6. package/dist/cli/v4/commands/help.js +6 -0
  7. package/dist/cli/v4/commands/index.js +16 -1
  8. package/dist/cli/v4/commands/mcp.js +80 -54
  9. package/dist/cli/v4/commands/plannerGuard.js +53 -0
  10. package/dist/cli/v4/commands/recovery.js +122 -0
  11. package/dist/cli/v4/commands/runs.js +22 -2
  12. package/dist/cli/v4/commands/spawnPause.js +93 -0
  13. package/dist/cli/v4/daemonAgentBuilder.js +4 -1
  14. package/dist/cli/v4/defaultSoul.js +1 -1
  15. package/dist/core/v4/aidenAgent.js +219 -1
  16. package/dist/core/v4/daemon/bootstrap.js +47 -0
  17. package/dist/core/v4/daemon/db/migrations.js +66 -0
  18. package/dist/core/v4/daemon/runStore.js +33 -3
  19. package/dist/core/v4/providerFallback.js +35 -2
  20. package/dist/core/v4/runtimeToggles.js +30 -3
  21. package/dist/core/v4/selfimprovement/recoveryStore.js +307 -0
  22. package/dist/core/v4/selfimprovement/signatureBuilder.js +158 -0
  23. package/dist/core/v4/subagent/childBuilder.js +391 -0
  24. package/dist/core/v4/subagent/fanout.js +75 -51
  25. package/dist/core/v4/subagent/spawnPause.js +191 -0
  26. package/dist/core/v4/subagent/spawnSubAgent.js +310 -0
  27. package/dist/core/v4/toolRegistry.js +19 -3
  28. package/dist/core/version.js +1 -1
  29. package/dist/moat/plannerGuard.js +29 -0
  30. package/dist/providers/v4/anthropicAdapter.js +31 -3
  31. package/dist/providers/v4/chatCompletionsAdapter.js +26 -3
  32. package/dist/providers/v4/codexResponsesAdapter.js +25 -2
  33. package/dist/providers/v4/ollamaPromptToolsAdapter.js +57 -2
  34. package/dist/tools/v4/index.js +17 -3
  35. package/dist/tools/v4/skills/lookupToolSchema.js +6 -1
  36. package/dist/tools/v4/subagent/spawnSubAgentTool.js +334 -0
  37. package/dist/tools/v4/subagent/subagentFanout.js +53 -1
  38. package/package.json +7 -3
@@ -0,0 +1,191 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) 2026 Shiva Deore (Taracod).
4
+ * Licensed under AGPL-3.0. See LICENSE for details.
5
+ *
6
+ * Aiden — local-first agent.
7
+ */
8
+ /**
9
+ * core/v4/subagent/spawnPause.ts — v4.6 Phase 3A.
10
+ *
11
+ * Operator kill-switch for sub-agent spawning. When PAUSED, any new
12
+ * `spawn_sub_agent` or `subagent_fanout` invocation returns a typed
13
+ * failure envelope (`errorCode: 'SUBAGENT_SPAWN_PAUSED'`) BEFORE any
14
+ * runs row is written, child agent built, or provider hit. In-flight
15
+ * children continue uninterrupted — the gate is at tool-handler
16
+ * entry only.
17
+ *
18
+ * Storage: a file marker at `$aidenHome/spawn.paused` (the
19
+ * `paths.root` returned by `resolveAidenPaths()`). This choice
20
+ * differs deliberately from the reference multi-agent system, whose
21
+ * pause flag is an in-process boolean — Aiden's REPL, daemon, and
22
+ * MCP server can all coexist on the same machine, so a single
23
+ * shared marker file is the cheapest way to coordinate pause state
24
+ * across all three runtimes. The marker survives process restart;
25
+ * the boot card surfaces a "spawn-paused" indicator so an operator
26
+ * who forgot they paused last week doesn't sit confused.
27
+ *
28
+ * Marker format: a single-line JSON document
29
+ * { pausedAt: number; reason: string | null; pausedBy: string }
30
+ *
31
+ * Atomic writes: every `pause()` writes to a sibling `.tmp` path
32
+ * and renames atomically so a concurrent reader can never observe
33
+ * a half-written file. `status()` tolerates an unreadable marker
34
+ * (returns `{paused: true}` with no metadata) rather than crashing
35
+ * — the marker EXISTING is the durable fact; the JSON payload is
36
+ * forensic detail.
37
+ *
38
+ * Module-level singleton: `initSpawnPause({aidenHome})` then
39
+ * `getSpawnPause()`. Mirrors the `runtimeToggles` pattern in
40
+ * `core/v4/runtimeToggles.ts` but does NOT route through it —
41
+ * runtimeToggles is config-yaml backed (no per-toggle metadata
42
+ * field), and the reason/pausedAt/pausedBy fields are first-class
43
+ * here.
44
+ */
45
+ var __importDefault = (this && this.__importDefault) || function (mod) {
46
+ return (mod && mod.__esModule) ? mod : { "default": mod };
47
+ };
48
+ Object.defineProperty(exports, "__esModule", { value: true });
49
+ exports.SpawnPauseState = void 0;
50
+ exports.initSpawnPause = initSpawnPause;
51
+ exports.getSpawnPause = getSpawnPause;
52
+ exports._resetSpawnPauseForTests = _resetSpawnPauseForTests;
53
+ const node_fs_1 = __importDefault(require("node:fs"));
54
+ const node_path_1 = __importDefault(require("node:path"));
55
+ // ── Implementation ───────────────────────────────────────────────────────
56
+ const MARKER_FILENAME = 'spawn.paused';
57
+ /**
58
+ * File-marker-backed pause state. Concurrent processes (REPL, daemon,
59
+ * MCP server) all read/write the same marker, so flipping pause from
60
+ * a REPL slash command is observed by an MCP-mode `subagent_fanout`
61
+ * call within milliseconds (next read).
62
+ */
63
+ class SpawnPauseState {
64
+ constructor(opts) {
65
+ this.markerPath = node_path_1.default.join(opts.aidenHome, MARKER_FILENAME);
66
+ this.now = opts.now ?? (() => Date.now());
67
+ }
68
+ /**
69
+ * Hot path — called at the top of every `spawn_sub_agent` and
70
+ * `subagent_fanout` invocation. MUST stay cheap (single
71
+ * `fs.existsSync`). The metadata read is deferred to `status()`.
72
+ *
73
+ * Any error (FS busy, permission, etc.) silently returns
74
+ * `false` — failing-open is the right default because a paused
75
+ * state that operators can't query/clear due to FS hiccups would
76
+ * brick the whole spawning surface.
77
+ */
78
+ isPaused() {
79
+ try {
80
+ return node_fs_1.default.existsSync(this.markerPath);
81
+ }
82
+ catch {
83
+ return false;
84
+ }
85
+ }
86
+ /**
87
+ * Apply the pause marker. Atomic via tmp-file + rename so a
88
+ * mid-write status read never sees corrupt JSON. Idempotent —
89
+ * pausing while already paused just overwrites the marker (which
90
+ * is the right semantic for "re-pause with a fresh reason").
91
+ */
92
+ pause(opts) {
93
+ const payload = {
94
+ pausedAt: this.now(),
95
+ reason: opts.reason ?? null,
96
+ pausedBy: opts.pausedBy,
97
+ };
98
+ const tmpPath = `${this.markerPath}.tmp`;
99
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(this.markerPath), { recursive: true });
100
+ node_fs_1.default.writeFileSync(tmpPath, JSON.stringify(payload), { encoding: 'utf8' });
101
+ node_fs_1.default.renameSync(tmpPath, this.markerPath);
102
+ }
103
+ /**
104
+ * Clear the pause marker. Idempotent — ENOENT (already resumed)
105
+ * is treated as success, so two operators calling resume back-
106
+ * to-back don't error on the second.
107
+ */
108
+ resume() {
109
+ try {
110
+ node_fs_1.default.unlinkSync(this.markerPath);
111
+ }
112
+ catch (e) {
113
+ const code = e.code;
114
+ if (code !== 'ENOENT')
115
+ throw e;
116
+ }
117
+ }
118
+ /**
119
+ * Read the current pause state with metadata. When the marker
120
+ * exists but is unreadable / malformed JSON, returns
121
+ * `{paused: true}` with no metadata fields — the EXISTENCE of
122
+ * the marker is the durable contract; the JSON payload is best-
123
+ * effort forensic detail.
124
+ */
125
+ status() {
126
+ if (!this.isPaused()) {
127
+ return { paused: false };
128
+ }
129
+ let raw;
130
+ try {
131
+ raw = node_fs_1.default.readFileSync(this.markerPath, 'utf8');
132
+ }
133
+ catch {
134
+ return { paused: true };
135
+ }
136
+ let parsed;
137
+ try {
138
+ parsed = JSON.parse(raw);
139
+ }
140
+ catch {
141
+ return { paused: true };
142
+ }
143
+ const pausedAt = typeof parsed.pausedAt === 'number' ? parsed.pausedAt : undefined;
144
+ return {
145
+ paused: true,
146
+ pausedAt,
147
+ reason: parsed.reason ?? null,
148
+ pausedBy: parsed.pausedBy ?? 'unknown',
149
+ durationMs: pausedAt !== undefined ? Math.max(0, this.now() - pausedAt) : undefined,
150
+ };
151
+ }
152
+ }
153
+ exports.SpawnPauseState = SpawnPauseState;
154
+ // ── Module-level singleton ───────────────────────────────────────────────
155
+ let _singleton = null;
156
+ /**
157
+ * Initialize the process-wide pause state. Called once at boot
158
+ * (REPL: `buildAgentRuntime`; daemon: dispatcher bootstrap; MCP:
159
+ * `wireSubagentFanout`). Subsequent calls REPLACE the singleton —
160
+ * tests rely on this to swap the marker dir cleanly.
161
+ */
162
+ function initSpawnPause(opts) {
163
+ _singleton = new SpawnPauseState(opts);
164
+ return _singleton;
165
+ }
166
+ /**
167
+ * Read the current singleton. Throws if `initSpawnPause` hasn't
168
+ * been called yet — the spawn / fanout tool handlers cannot
169
+ * function without it, and a silent fallback to "not paused" would
170
+ * defeat the kill-switch's purpose. Boot wiring is responsible for
171
+ * calling init before any tool handler can fire.
172
+ *
173
+ * For environments that genuinely don't have a marker dir (some
174
+ * test contexts), call `initSpawnPause({aidenHome: <tmp>})` with
175
+ * a throwaway path.
176
+ */
177
+ function getSpawnPause() {
178
+ if (!_singleton) {
179
+ throw new Error('spawnPause: not initialized — call initSpawnPause({aidenHome}) at boot. ' +
180
+ 'This usually means a sub-agent tool handler fired before runtime wiring completed.');
181
+ }
182
+ return _singleton;
183
+ }
184
+ /**
185
+ * Test-only — reset the singleton so the next `initSpawnPause`
186
+ * call wires a fresh state. Production callers should never need
187
+ * this; `initSpawnPause` is already idempotent for re-init.
188
+ */
189
+ function _resetSpawnPauseForTests() {
190
+ _singleton = null;
191
+ }
@@ -0,0 +1,310 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) 2026 Shiva Deore (Taracod).
4
+ * Licensed under AGPL-3.0. See LICENSE for details.
5
+ *
6
+ * Aiden — local-first agent.
7
+ */
8
+ /**
9
+ * core/v4/subagent/spawnSubAgent.ts — v4.6 Phase 1.
10
+ *
11
+ * Public spawn primitive. Synchronously runs one child agent to handle
12
+ * a delegated sub-task, returns a structured `SubAgentResult` envelope.
13
+ * NEVER throws — every error path produces an envelope with the
14
+ * appropriate `status` + `error` fields so the parent's LLM can
15
+ * reason about the failure.
16
+ *
17
+ * Contract per `docs/v4.6/phase-1-design.md` §3, §6, §8.
18
+ *
19
+ * - Single child (Phase 1; batch is Phase 2 via subagent_fanout
20
+ * refactor)
21
+ * - Synchronous: the parent's tool dispatch awaits this Promise
22
+ * - Cooperative cancellation: parent's AbortSignal cascades to
23
+ * child via a linked AbortController
24
+ * - Wall-clock timeout: hard cap via setTimeout → child interrupt
25
+ * - Persistence: writes a `runs` row with `spawned_from_run_id` +
26
+ * `spawned_from_session_id` linking back to the parent
27
+ */
28
+ Object.defineProperty(exports, "__esModule", { value: true });
29
+ exports.spawnSubAgent = spawnSubAgent;
30
+ const node_crypto_1 = require("node:crypto");
31
+ const childBuilder_1 = require("./childBuilder");
32
+ const factory_1 = require("../logger/factory");
33
+ // ── Constants ─────────────────────────────────────────────────────────────
34
+ const DEFAULT_TIMEOUT_MS = 600000;
35
+ const MIN_TIMEOUT_MS = 1000;
36
+ const MAX_TIMEOUT_MS = 3600000;
37
+ const DEFAULT_MAX_ITERATIONS = 50;
38
+ const MIN_MAX_ITERATIONS = 1;
39
+ const MAX_MAX_ITERATIONS = 200;
40
+ // ── Implementation ────────────────────────────────────────────────────────
41
+ /**
42
+ * Spawn one child agent. Always returns an envelope; never throws.
43
+ *
44
+ * Lifecycle (per §6 state machine):
45
+ *
46
+ * 1. Generate child sessionId (flat UUID).
47
+ * 2. Insert `runs` row with `status: 'running'` + lineage columns.
48
+ * 3. Build child agent (clones FallbackAdapter, intersects toolsets,
49
+ * filters blocklist, fresh ApprovalEngine with auto-deny).
50
+ * 4. Construct linked AbortController: parent's signal feeds into
51
+ * it; a setTimeout on `timeoutMs` also aborts it.
52
+ * 5. Run `child.runConversation(history, { signal: childCtrl.signal })`.
53
+ * 6. On return / throw / timeout, classify into the envelope's
54
+ * status + exitReason and update the runs row's status.
55
+ * 7. Clean up timer + signal listener.
56
+ */
57
+ async function spawnSubAgent(spec, deps, ctx) {
58
+ const startedAt = Date.now();
59
+ // ── 1. Clamp inputs ─────────────────────────────────────────────────────
60
+ const maxIterations = clamp(spec.maxIterations ?? DEFAULT_MAX_ITERATIONS, MIN_MAX_ITERATIONS, MAX_MAX_ITERATIONS);
61
+ const timeoutMs = clamp(spec.timeoutMs ?? DEFAULT_TIMEOUT_MS, MIN_TIMEOUT_MS, MAX_TIMEOUT_MS);
62
+ // ── 2. Fresh sessionId + run row ────────────────────────────────────────
63
+ const childSessionId = (0, node_crypto_1.randomUUID)();
64
+ // Pre-create the child run row in 'running' state so the envelope
65
+ // always carries a valid childRunId — even if buildChildAgent throws.
66
+ let childRunId;
67
+ try {
68
+ childRunId = deps.runStore.create({
69
+ sessionId: childSessionId,
70
+ instanceId: deps.instanceId,
71
+ status: 'running',
72
+ startedAt,
73
+ spawnedFromRunId: ctx.parentRunId,
74
+ spawnedFromSessionId: ctx.parentSessionId,
75
+ });
76
+ }
77
+ catch (err) {
78
+ // Persistence failed before we even started — surface as failed
79
+ // envelope with a synthetic id of '0' so the contract holds.
80
+ return failureEnvelope({
81
+ childRunId: '0',
82
+ childSessionId,
83
+ error: `Failed to create child run row: ${errorMessage(err)}`,
84
+ durationMs: Date.now() - startedAt,
85
+ });
86
+ }
87
+ // ── 3. Build child agent ────────────────────────────────────────────────
88
+ const logger = deps.logger ?? (0, factory_1.noopLogger)();
89
+ let agentBundle;
90
+ try {
91
+ agentBundle = (0, childBuilder_1.buildChildAgent)({
92
+ ...deps,
93
+ // v4.6 Phase 1 observability — pass runStore + childRunId
94
+ // through so childBuilder can wire onToolCall → run_events
95
+ // for the child's tool dispatches. Both are optional in
96
+ // ChildBuilderDeps so unit tests of buildChildAgent stay
97
+ // dependency-light.
98
+ runStore: deps.runStore,
99
+ childRunId,
100
+ logger,
101
+ }, {
102
+ sessionId: childSessionId,
103
+ goal: spec.goal,
104
+ context: spec.context,
105
+ requestedToolsets: spec.toolsets,
106
+ maxIterations,
107
+ // v4.6 Phase 2P — per-spawn provider override (per design doc §12.2).
108
+ providerOverride: spec.provider,
109
+ });
110
+ }
111
+ catch (err) {
112
+ // v4.6 Phase 2P — distinguish provider-not-found from other build
113
+ // failures. ProviderNotFoundError carries the failing name + the
114
+ // list of valid alternatives, surfaced verbatim to the LLM in the
115
+ // envelope so it can pick a real provider next time. Other build
116
+ // failures (constructor throws, registry issues, etc.) collapse
117
+ // to the generic 'error' exitReason.
118
+ if (err instanceof childBuilder_1.ProviderNotFoundError) {
119
+ deps.runStore.setStatus(childRunId, 'failed', { finishReason: 'provider_not_found' });
120
+ return {
121
+ ok: false,
122
+ status: 'failed',
123
+ summary: null,
124
+ error: err.message,
125
+ exitReason: 'provider_not_found',
126
+ metrics: { apiCalls: 0, durationMs: Date.now() - startedAt, tokensIn: 0, tokensOut: 0 },
127
+ childRunId: String(childRunId),
128
+ childSessionId,
129
+ };
130
+ }
131
+ deps.runStore.setStatus(childRunId, 'failed', { finishReason: 'error' });
132
+ return failureEnvelope({
133
+ childRunId: String(childRunId),
134
+ childSessionId,
135
+ error: `Failed to build child agent: ${errorMessage(err)}`,
136
+ durationMs: Date.now() - startedAt,
137
+ });
138
+ }
139
+ // v4.6 Phase 1 observability — log the child's actual tool catalog
140
+ // so we can see whether the toolsets-resolution path produced a
141
+ // sensible set or stripped everything. The single most-load-bearing
142
+ // diagnostic for the "child returned 0" class of bugs.
143
+ const childToolNames = agentBundle.agent.tools.map((t) => t.name);
144
+ logger.info('spawn_sub_agent child built', {
145
+ childRunId: String(childRunId),
146
+ childSessionId,
147
+ toolCount: childToolNames.length,
148
+ toolNames: childToolNames,
149
+ requestedToolsets: spec.toolsets ?? null,
150
+ maxIterations,
151
+ timeoutMs,
152
+ });
153
+ // ── 4. Linked AbortController ───────────────────────────────────────────
154
+ // Two abort sources cascade into the child's signal:
155
+ // (a) parent signal aborts — child aborts.
156
+ // (b) timeoutMs elapses — child aborts.
157
+ // Track which one fired so we can label the envelope as
158
+ // 'interrupted' vs 'timeout' (the spec distinguishes them).
159
+ const childCtrl = new AbortController();
160
+ let timedOut = false;
161
+ const timer = setTimeout(() => {
162
+ timedOut = true;
163
+ childCtrl.abort();
164
+ }, timeoutMs);
165
+ let parentAbortHandler = null;
166
+ if (ctx.signal) {
167
+ if (ctx.signal.aborted) {
168
+ childCtrl.abort();
169
+ }
170
+ else {
171
+ parentAbortHandler = () => childCtrl.abort();
172
+ ctx.signal.addEventListener('abort', parentAbortHandler, { once: true });
173
+ }
174
+ }
175
+ const cleanupAbortWiring = () => {
176
+ clearTimeout(timer);
177
+ if (parentAbortHandler && ctx.signal) {
178
+ ctx.signal.removeEventListener('abort', parentAbortHandler);
179
+ }
180
+ };
181
+ // ── 5. Run the child ─────────────────────────────────────────────────────
182
+ // `child.runConversation` propagates the signal into the loop's
183
+ // between-iteration + pre-tool-call abort checks via the prep
184
+ // dispatch (commit fd62f96d).
185
+ let summary = null;
186
+ let error = null;
187
+ let status = 'completed';
188
+ let exitReason = 'completed';
189
+ let apiCalls = 0;
190
+ let tokensIn = 0;
191
+ let tokensOut = 0;
192
+ try {
193
+ const result = await agentBundle.agent.runConversation(agentBundle.history, { signal: childCtrl.signal });
194
+ apiCalls = result.turnCount; // one provider call per turn
195
+ tokensIn = result.totalUsage.inputTokens;
196
+ tokensOut = result.totalUsage.outputTokens;
197
+ // Classify the result per design doc §8.
198
+ if (result.finishReason === 'interrupted') {
199
+ // Distinguish timeout from parent-interrupt by which source fired.
200
+ if (timedOut) {
201
+ status = 'timeout';
202
+ exitReason = 'timeout';
203
+ error = `Sub-agent timed out after ${timeoutMs}ms (maxIterations=${maxIterations})`;
204
+ }
205
+ else {
206
+ status = 'interrupted';
207
+ exitReason = 'interrupted';
208
+ error = 'Parent interrupted — child did not finish in time';
209
+ }
210
+ }
211
+ else if (result.finishReason === 'budget_exhausted') {
212
+ // Hit maxIterations. If the model produced a partial final reply,
213
+ // we ship it as a 'completed/max_iterations' (partial summary);
214
+ // otherwise it's a failure.
215
+ if (result.finalContent && result.finalContent.length > 0) {
216
+ status = 'completed';
217
+ exitReason = 'max_iterations';
218
+ summary = result.finalContent;
219
+ }
220
+ else {
221
+ status = 'failed';
222
+ exitReason = 'error';
223
+ error = `Sub-agent hit max_iterations (${maxIterations}) without producing a summary`;
224
+ }
225
+ }
226
+ else if (result.finishReason === 'error') {
227
+ status = 'failed';
228
+ exitReason = 'error';
229
+ error = 'Sub-agent loop reported an internal error';
230
+ }
231
+ else if (result.finishReason === 'tool_loop') {
232
+ // TCE surfaced a tool loop — treat as a failure with structured
233
+ // payload buried in error string (Phase 1 doesn't yet ship the
234
+ // capability-card detail into the envelope).
235
+ status = 'failed';
236
+ exitReason = 'error';
237
+ error = `Sub-agent detected a tool loop and stopped: ${result.toolLoopCard?.title ?? 'tool_loop'}`;
238
+ }
239
+ else {
240
+ // 'stop' → natural completion.
241
+ status = 'completed';
242
+ exitReason = 'completed';
243
+ summary = result.finalContent;
244
+ }
245
+ }
246
+ catch (err) {
247
+ // child.runConversation threw — typically only happens when the
248
+ // provider call fails after exhausting fallback chain. Surface as
249
+ // a failed envelope with the error string.
250
+ status = 'failed';
251
+ exitReason = 'error';
252
+ error = `Sub-agent threw: ${errorMessage(err)}`;
253
+ }
254
+ finally {
255
+ cleanupAbortWiring();
256
+ }
257
+ // ── 6. Update run row + emit envelope ────────────────────────────────────
258
+ const dbStatus = status === 'completed' ? 'completed'
259
+ : status === 'interrupted' ? 'interrupted'
260
+ : 'failed';
261
+ deps.runStore.setStatus(childRunId, dbStatus, { finishReason: exitReason });
262
+ const durationMs = Date.now() - startedAt;
263
+ const ok = status === 'completed' && exitReason !== 'error';
264
+ return {
265
+ ok,
266
+ status,
267
+ summary,
268
+ error,
269
+ exitReason,
270
+ metrics: {
271
+ apiCalls,
272
+ durationMs,
273
+ tokensIn,
274
+ tokensOut,
275
+ },
276
+ childRunId: String(childRunId),
277
+ childSessionId,
278
+ };
279
+ }
280
+ // ── Helpers ───────────────────────────────────────────────────────────────
281
+ function clamp(n, lo, hi) {
282
+ if (!Number.isFinite(n))
283
+ return lo;
284
+ return Math.max(lo, Math.min(hi, Math.floor(n)));
285
+ }
286
+ function errorMessage(err) {
287
+ return err instanceof Error ? err.message : String(err);
288
+ }
289
+ /**
290
+ * Build a failed envelope for pre-run errors (run-row creation,
291
+ * agent construction). Always carries `summary: null`, `ok: false`,
292
+ * `status: 'failed'`, `exitReason: 'error'`.
293
+ */
294
+ function failureEnvelope(opts) {
295
+ return {
296
+ ok: false,
297
+ status: 'failed',
298
+ summary: null,
299
+ error: opts.error,
300
+ exitReason: 'error',
301
+ metrics: {
302
+ apiCalls: 0,
303
+ durationMs: opts.durationMs,
304
+ tokensIn: 0,
305
+ tokensOut: 0,
306
+ },
307
+ childRunId: opts.childRunId,
308
+ childSessionId: opts.childSessionId,
309
+ };
310
+ }
@@ -49,10 +49,19 @@ class ToolRegistry {
49
49
  return [...this.handlers.keys()];
50
50
  }
51
51
  /**
52
- * Schemas to advertise to the LLM. When `filterToolsets` is provided,
53
- * only handlers whose `toolset` matches one of the entries are returned.
52
+ * Schemas to advertise to the LLM. Two optional filters, AND-combined:
53
+ *
54
+ * - `filterToolsets`: include only handlers whose `toolset` matches
55
+ * one of the entries. Applied first (preserves pre-v4.6 behaviour
56
+ * when called with one argument).
57
+ * - `context` (v4.6 Phase 1): include only handlers whose
58
+ * `contexts` array contains this value, OR whose `contexts` is
59
+ * undefined (default = visible everywhere). Applied second.
60
+ *
61
+ * Both filters default to "no filter" when omitted. Callers that
62
+ * predate v4.6 pass one arg or none and continue working unchanged.
54
63
  */
55
- getSchemas(filterToolsets) {
64
+ getSchemas(filterToolsets, context) {
56
65
  const out = [];
57
66
  for (const handler of this.handlers.values()) {
58
67
  if (filterToolsets && filterToolsets.length > 0) {
@@ -60,6 +69,13 @@ class ToolRegistry {
60
69
  continue;
61
70
  }
62
71
  }
72
+ if (context !== undefined) {
73
+ // contexts undefined → tool is visible in both REPL and daemon
74
+ // (backward-compat default for every pre-v4.6 tool).
75
+ if (handler.contexts !== undefined && !handler.contexts.includes(context)) {
76
+ continue;
77
+ }
78
+ }
63
79
  out.push(handler.schema);
64
80
  }
65
81
  return out;
@@ -2,4 +2,4 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.VERSION = void 0;
4
4
  // AUTO-GENERATED by scripts/inject-version.js — do not edit by hand
5
- exports.VERSION = '4.5.0';
5
+ exports.VERSION = '4.6.0';
@@ -87,10 +87,39 @@ const RULES = [
87
87
  toolsets: ['execute'],
88
88
  },
89
89
  // Process registry
90
+ //
91
+ // Note: the bare word "spawn" appears in this rule's keyword list
92
+ // (legacy — predates v4.6 sub-agents). The dedicated 'subagent'
93
+ // rule below ALSO matches "spawn" via a tighter delegation
94
+ // vocabulary, so a message like "spawn a background server" hits
95
+ // BOTH rules and adds both toolsets — UNION semantics make this
96
+ // additive, not conflicting.
90
97
  {
91
98
  keywords: /\b(process|background|long.?running|server|spawn|kill|daemon)\b/i,
92
99
  toolsets: ['process'],
93
100
  },
101
+ // v4.6 Phase 1 — sub-agent delegation surface (spawn_sub_agent +
102
+ // subagent_fanout). Both tools live in toolset `'subagent'`, which
103
+ // no pre-v4.6 rule mapped to — so PlannerGuard's per-turn narrowing
104
+ // silently stripped them from the model's catalog whenever any
105
+ // other rule fired. The model could see them via lookup_tool_schema
106
+ // but failed to actually invoke them because the provider tool list
107
+ // (post-narrow) didn't include them — see Dispatch 2H diagnostic.
108
+ //
109
+ // Regex notes:
110
+ // - `spawn_sub_agent` and `subagent_fanout` literals are listed
111
+ // explicitly because `\bspawn\b` does NOT match within
112
+ // `spawn_sub_agent` (underscore is a word char in JS regex,
113
+ // so there's no word boundary between `n` and `_`). Users who
114
+ // name the tool directly hit the literal arm.
115
+ // - The free-form vocabulary arm (`spawn`, `delegate`, etc.)
116
+ // catches natural-language delegation intent. UNION semantics
117
+ // with other rules let "spawn a child to read files" surface
118
+ // both 'subagent' AND 'files'.
119
+ {
120
+ keywords: /\b(spawn_sub_agent|subagent_fanout|spawn|subagent|sub.?agent|delegate|fanout|fan.?out|child.?agent|parallel|isolated)\b/i,
121
+ toolsets: ['subagent'],
122
+ },
94
123
  // Media playback control (v4.1.4-media)
95
124
  //
96
125
  // Without this, intents like "list media sessions" matched the
@@ -92,14 +92,14 @@ class AnthropicAdapter {
92
92
  // ── Public: non-streaming ────────────────────────────────────────────────
93
93
  async call(input) {
94
94
  const body = this.buildBody(input, /* streaming */ false);
95
- const reply = await this.dispatch(body, /* streaming */ false);
95
+ const reply = await this.dispatch(body, /* streaming */ false, input.signal);
96
96
  const json = (await reply.json());
97
97
  return decodeResponse(json);
98
98
  }
99
99
  // ── Public: streaming ────────────────────────────────────────────────────
100
100
  async *callStream(input) {
101
101
  const body = this.buildBody(input, /* streaming */ true);
102
- const reply = await this.dispatch(body, /* streaming */ true);
102
+ const reply = await this.dispatch(body, /* streaming */ true, input.signal);
103
103
  if (!reply.body) {
104
104
  // Server promised SSE but gave us nothing — fall through to a synthetic
105
105
  // empty done event so the agent loop terminates rather than hangs.
@@ -163,7 +163,7 @@ class AnthropicAdapter {
163
163
  // beta flags, or per-deployment routing tags without forking the adapter.
164
164
  return { ...headers, ...this.extraHeaders };
165
165
  }
166
- async dispatch(body, streaming) {
166
+ async dispatch(body, streaming, externalSignal) {
167
167
  // Resolved once per process via the userAgent module's cache, so paying
168
168
  // for the version detection here is cheap on every retry/turn.
169
169
  const userAgent = await (0, userAgent_1.getClaudeCliUserAgent)();
@@ -174,6 +174,22 @@ class AnthropicAdapter {
174
174
  for (let attempt = 0; attempt < totalTries; attempt++) {
175
175
  const controller = new AbortController();
176
176
  const timer = setTimeout(() => controller.abort(), this.timeoutMs);
177
+ // v4.6 prep — forward an external AbortSignal into this attempt's
178
+ // internal controller so a parent agent that aborts mid-flight
179
+ // cancels the in-flight fetch. External aborts surface as a raw
180
+ // AbortError (NOT ProviderTimeoutError) so AidenAgent can route
181
+ // them as `finishReason: 'interrupted'` instead of treating them
182
+ // as a retryable timeout.
183
+ let externalAbortHandler = null;
184
+ if (externalSignal) {
185
+ if (externalSignal.aborted) {
186
+ controller.abort();
187
+ }
188
+ else {
189
+ externalAbortHandler = () => controller.abort();
190
+ externalSignal.addEventListener('abort', externalAbortHandler, { once: true });
191
+ }
192
+ }
177
193
  let response;
178
194
  try {
179
195
  response = await fetch(this.endpoint, {
@@ -185,7 +201,16 @@ class AnthropicAdapter {
185
201
  }
186
202
  catch (err) {
187
203
  clearTimeout(timer);
204
+ if (externalAbortHandler && externalSignal) {
205
+ externalSignal.removeEventListener('abort', externalAbortHandler);
206
+ }
188
207
  if (err?.name === 'AbortError') {
208
+ // v4.6 prep — external abort takes priority over internal
209
+ // timeout. Surface the raw AbortError immediately (no retry)
210
+ // so AidenAgent's catch routes it as 'interrupted'.
211
+ if (externalSignal?.aborted) {
212
+ throw err;
213
+ }
189
214
  // Treat timeout as retryable; only surface ProviderTimeoutError if
190
215
  // we've burned the last attempt.
191
216
  lastErr = new errors_1.ProviderTimeoutError(this.providerName, this.timeoutMs);
@@ -200,6 +225,9 @@ class AnthropicAdapter {
200
225
  throw lastErr;
201
226
  }
202
227
  clearTimeout(timer);
228
+ if (externalAbortHandler && externalSignal) {
229
+ externalSignal.removeEventListener('abort', externalAbortHandler);
230
+ }
203
231
  if (response.ok)
204
232
  return response;
205
233
  // Phase 25.1.5d diagnostic: gated dump of request + response so we