killeros 1.4.1 → 1.4.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to KillerOS are documented here.
4
4
 
5
+ ## [1.4.2] - 2026-08-01
6
+
7
+ ### Added
8
+
9
+ - Added named child threads with inspectable lifecycle state, Active/Done views, steering, interruption, collection, and closure controls.
10
+ - Added isolated child-process resource guards for wall time, JSONL lines, retained trace, stderr, output, token quota, cost quota, task count, and concurrency.
11
+
12
+ ### Changed
13
+
14
+ - Replaced routine child turn limits with natural completion plus named resource guards.
15
+ - Preserved partial traces and handoffs when a child fails, stops, or reaches a limit.
16
+
5
17
  ## [1.4.1] - 2026-08-01
6
18
 
7
19
  ### Added
package/README.md CHANGED
@@ -47,7 +47,7 @@ Add `-l` to either command for a project-only install. Restart Pi after installi
47
47
  - Responsive footer with polished model/provider identity, plain-language context, and active goal state remaining; reasoning, Git branch, elapsed time, cost, and path cut down by available width
48
48
  - `/variants` selector and direct reasoning-level arguments
49
49
  - Codex-style `/goal` for durable long-running objectives with pause, resume, edit, clear, automatic continuation, and explicit completion
50
- - Pi-native `subagent` tool with isolated child processes, Markdown roles, explicit read/write boundaries, parallel readers, one serialized writer, bounded output, and cancellation propagation
50
+ - Pi-native `subagent` tool with named, inspectable child threads, Markdown roles, explicit read/write boundaries, parent controls, bounded resources, and cancellation propagation
51
51
  - Claude Code-style `/init` that scans the repository and generates a concise root `AGENTS.md` without setup questions
52
52
  - `question` tool with filtering, proposal previews, keyboard selection, custom answers, history, cancellation, and resize-safe rendering
53
53
  - Mid-prompt slash completion with current Pi `0.82.1` commands, extensions, prompts, and skills
@@ -95,7 +95,7 @@ KillerOS ships `planner`, `reviewer`, `scout`, and `security` as read-only roles
95
95
  2. Personal: `~/.pi/agent/agents/*.md`
96
96
  3. Trusted project: `<repo>/.pi/agents/*.md`
97
97
 
98
- The default `agentScope: "user"` uses bundled and personal roles. Use `"project"` or `"both"` to opt into trusted project roles; a selected project override requires interactive confirmation. Role frontmatter requires `name`, `description`, `access`, and an explicit `tools` list. Optional fields are `model`, `thinking`, `maxTurns`, and `timeoutMs`. Every bundled role shows `model: inherit` and `thinking: inherit` as editable placeholders. Replace them with an available `provider/model` and a separate thinking level when you want to pin a role; `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max` are checked against that model’s supported capabilities.
98
+ The default `agentScope: "user"` uses bundled and personal roles. Use `"project"` or `"both"` to opt into trusted project roles; a selected project override requires interactive confirmation. Role frontmatter requires `name`, `description`, `access`, and an explicit `tools` list. Optional fields are `model`, `thinking`, and `timeoutMs`. Every bundled role shows `model: inherit` and `thinking: inherit` as editable placeholders. Replace them with an available `provider/model` and a separate thinking level when you want to pin a role; `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max` are checked against that model’s supported capabilities.
99
99
 
100
100
  The tool supports a single `agent` + `task`, parallel `tasks`, or a sequential `chain` whose task text may include `{previous}`. A call can also set `model` and `thinking` for every task, overriding role settings; use `inherit` to fall back to each role and then the active parent model. For example:
101
101
 
@@ -103,7 +103,29 @@ The tool supports a single `agent` + `task`, parallel `tasks`, or a sequential `
103
103
  {"agent":"reviewer","task":"Review the change","model":"provider/model","thinking":"high"}
104
104
  ```
105
105
 
106
- Use the separate `model` and `thinking` fields for new configuration. The older `provider/model:thinking` model form remains accepted. Children run as ephemeral `pi --mode json -p --no-session` processes with explicit local tools plus `web_search`, `source_check`, `fetch_content`, and `get_search_content`. Each child explicitly loads `npm:pi-web-access`, discovers available skills, and keeps arbitrary extensions and prompt templates disabled; project-local skills load only when the parent project is trusted. Every bundled role is instructed to load the most relevant `SKILL.md` before work and to use web research when external evidence is needed. KillerOS allows at most eight tasks, four parallel readers, one serialized writer, 12 turns, ten minutes, a 32 MiB JSONL line, 2 MiB retained trace, 64 KiB stderr, and 50 KiB returned output per task. Esc cancellation terminates active children and escalates after five seconds.
106
+ Use the separate `model` and `thinking` fields for new configuration. The older `provider/model:thinking` model form remains accepted. Children run as ephemeral `pi --mode json -p --no-session` processes with explicit local tools plus `web_search`, `source_check`, `fetch_content`, and `get_search_content`. Each child explicitly loads `npm:pi-web-access`, discovers available skills, and keeps arbitrary extensions and prompt templates disabled; project-local skills load only when the parent project is trusted. Every bundled role is instructed to load the most relevant `SKILL.md` before work and to use web research when external evidence is needed. KillerOS allows at most eight tasks, four parallel readers, ten minutes, a 32 MiB JSONL line, 2 MiB retained trace, 64 KiB stderr, 50 KiB returned output, one million tokens, and $10 per child. Esc cancellation terminates active children and escalates after five seconds.
107
+
108
+ ### Thread lifecycle
109
+
110
+ Each delegated task creates a named child thread. Its contract records the parent ID, child ID, role, prompt, model, requested capability boundary, trace, usage, and result state. Roles define the child’s access and tools; they do not own lifecycle controls or grant new filesystem powers. The parent owns scope, waits, inspection, steering, collection, and closure.
111
+
112
+ Threads move through `queued`, `active`, `done`, `failed`, `stopped`, and `closed`. The parent renders separate **Active** and **Done** lists. Active threads show their name, task, model, usage, and direct controls. Done threads keep their handoff and trace available until the parent closes them.
113
+
114
+ The parent can inspect a thread’s prompt, role, model, tools, trace, usage, and handoff; steer an active thread with one bounded follow-up; interrupt one child or all active children; collect a concise handoff into parent context; and close a finished or stopped thread. An interrupt preserves the partial trace, states the reason, and reports the handoff as partial rather than successful.
115
+
116
+ A child completes naturally when it returns a final answer. Routine turn caps do not end useful work. Named resource guards still stop unsafe growth: wall time, output bytes, retained trace, stderr, quota, task count, and concurrency. A guard reports its cause and returns any partial work clearly. Esc cancellation terminates active children and escalates after five seconds.
117
+
118
+ The replacement lifecycle has nine phases:
119
+
120
+ 1. **Dispatch:** create a named thread and store its contract before launch.
121
+ 2. **Track:** maintain lifecycle states and Active/Done visibility.
122
+ 3. **Inspect:** keep the trace in the child thread, not the parent context.
123
+ 4. **Steer:** append a bounded parent follow-up to an active thread.
124
+ 5. **Interrupt:** stop one or all active children while preserving partial work.
125
+ 6. **Collect:** return a concise handoff while retaining the expanded trace.
126
+ 7. **Bound:** apply named resource guards instead of a routine turn stop.
127
+ 8. **Close:** remove a finished or stopped thread from the workspace without deleting its result record.
128
+ 9. **Prove:** test identity, visibility, controls, natural completion, guards, partial handoffs, and closure.
107
129
 
108
130
  ## Configuration
109
131
 
@@ -141,7 +163,7 @@ The package manifest lists Pi’s built-in modules as peer dependencies, so npm
141
163
 
142
164
  The [`pi-package`](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/packages.md) keyword makes a published npm release visible in Pi’s package catalog.
143
165
 
144
- For the current unreleased `1.4.1`, keep the version unchanged and publish after the validation checks pass:
166
+ For the current unreleased `1.4.2`, publish after the validation checks pass:
145
167
 
146
168
  ```bash
147
169
  npm login
@@ -6,7 +6,6 @@ tools: read, grep, find, ls, edit, write, bash, web_search, source_check, fetch_
6
6
  # Replace inherit with provider/model to pin this role; set thinking separately when needed.
7
7
  model: inherit
8
8
  thinking: inherit
9
- maxTurns: 8
10
9
  timeoutMs: 300000
11
10
  ---
12
11
 
@@ -6,7 +6,6 @@ tools: read, grep, find, ls, edit, write, bash, web_search, source_check, fetch_
6
6
  # Replace inherit with provider/model to pin this role; set thinking separately when needed.
7
7
  model: inherit
8
8
  thinking: inherit
9
- maxTurns: 8
10
9
  timeoutMs: 300000
11
10
  ---
12
11
 
package/agents/planner.md CHANGED
@@ -6,7 +6,6 @@ tools: read, grep, find, ls, web_search, source_check, fetch_content, get_search
6
6
  # Replace inherit with provider/model to pin this role; set thinking separately when needed.
7
7
  model: inherit
8
8
  thinking: inherit
9
- maxTurns: 8
10
9
  timeoutMs: 300000
11
10
  ---
12
11
 
@@ -6,7 +6,6 @@ tools: read, grep, find, ls, web_search, source_check, fetch_content, get_search
6
6
  # Replace inherit with provider/model to pin this role; set thinking separately when needed.
7
7
  model: inherit
8
8
  thinking: inherit
9
- maxTurns: 8
10
9
  timeoutMs: 300000
11
10
  ---
12
11
 
package/agents/scout.md CHANGED
@@ -6,7 +6,6 @@ tools: read, grep, find, ls, web_search, source_check, fetch_content, get_search
6
6
  # Replace inherit with provider/model to pin this role; set thinking separately when needed.
7
7
  model: inherit
8
8
  thinking: inherit
9
- maxTurns: 8
10
9
  timeoutMs: 300000
11
10
  ---
12
11
 
@@ -6,7 +6,6 @@ tools: read, grep, find, ls, web_search, source_check, fetch_content, get_search
6
6
  # Replace inherit with provider/model to pin this role; set thinking separately when needed.
7
7
  model: inherit
8
8
  thinking: inherit
9
- maxTurns: 8
10
9
  timeoutMs: 300000
11
10
  ---
12
11
 
package/agents/tester.md CHANGED
@@ -6,7 +6,6 @@ tools: read, grep, find, ls, edit, write, bash, web_search, source_check, fetch_
6
6
  # Replace inherit with provider/model to pin this role; set thinking separately when needed.
7
7
  model: inherit
8
8
  thinking: inherit
9
- maxTurns: 8
10
9
  timeoutMs: 300000
11
10
  ---
12
11
 
package/agents/worker.md CHANGED
@@ -6,7 +6,6 @@ tools: read, grep, find, ls, edit, write, bash, web_search, source_check, fetch_
6
6
  # Replace inherit with provider/model to pin this role; set thinking separately when needed.
7
7
  model: inherit
8
8
  thinking: inherit
9
- maxTurns: 8
10
9
  timeoutMs: 300000
11
10
  ---
12
11
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "killeros",
3
- "version": "1.4.1",
3
+ "version": "1.4.2",
4
4
  "description": "A production-hardened TUI and workflow extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -22,6 +22,9 @@
22
22
  "files": [
23
23
  "Killeros.ts",
24
24
  "subagents.ts",
25
+ "subagent-lifecycle.ts",
26
+ "subagent-process.ts",
27
+ "subagent-ui.ts",
25
28
  "agents/*.md",
26
29
  "themes/killeros.json",
27
30
  "README.md",
@@ -0,0 +1,506 @@
1
+ /** A dependency-free lifecycle model for child agent threads. */
2
+
3
+ declare const threadIdBrand: unique symbol;
4
+
5
+ export type SubagentThreadId = string & { readonly [threadIdBrand]: "SubagentThreadId" };
6
+ export type SubagentThreadState = "queued" | "active" | "done" | "failed" | "stopped" | "closed";
7
+ export type SubagentTerminalState = Extract<SubagentThreadState, "done" | "failed" | "stopped">;
8
+ export type SubagentFilesystemAccess = "none" | "read" | "write";
9
+ export type SubagentNetworkAccess = "none" | "read" | "full";
10
+ export type SubagentProcessAccess = "none" | "limited" | "full";
11
+
12
+ export interface SubagentCapabilityBoundary {
13
+ filesystem: SubagentFilesystemAccess;
14
+ network: SubagentNetworkAccess;
15
+ process: SubagentProcessAccess;
16
+ childThreads: boolean;
17
+ }
18
+
19
+ export interface SubagentHandoff {
20
+ summary: string;
21
+ nextAction?: string;
22
+ artifacts?: readonly string[];
23
+ }
24
+
25
+ export interface SubagentUsage {
26
+ inputTokens: number;
27
+ outputTokens: number;
28
+ cacheReadTokens: number;
29
+ cacheWriteTokens: number;
30
+ totalTokens: number;
31
+ costUsd: number;
32
+ turns: number;
33
+ }
34
+
35
+ export interface SubagentTraceEvent {
36
+ at: number;
37
+ kind: string;
38
+ message?: string;
39
+ details?: Readonly<Record<string, string | number | boolean | null>>;
40
+ }
41
+
42
+ export interface SubagentTraceUpdate {
43
+ kind: string;
44
+ message?: string;
45
+ details?: Readonly<Record<string, string | number | boolean | null>>;
46
+ }
47
+
48
+ export interface SubagentSteeringMessage {
49
+ id: number;
50
+ at: number;
51
+ message: string;
52
+ }
53
+
54
+ export interface SubagentThreadTimestamps {
55
+ createdAt: number;
56
+ updatedAt: number;
57
+ startedAt?: number;
58
+ endedAt?: number;
59
+ closedAt?: number;
60
+ }
61
+
62
+ export interface SubagentThreadSpec {
63
+ parentId?: SubagentThreadId;
64
+ role: string;
65
+ prompt: string;
66
+ model: string;
67
+ tools: readonly string[];
68
+ capabilityBoundary: SubagentCapabilityBoundary;
69
+ handoff?: SubagentHandoff;
70
+ }
71
+
72
+ export interface SubagentThreadPatch {
73
+ usage?: Partial<SubagentUsage>;
74
+ handoff?: SubagentHandoff | null;
75
+ result?: string | null;
76
+ }
77
+
78
+ export interface SubagentCompletion extends SubagentThreadPatch {
79
+ result?: string;
80
+ }
81
+
82
+ export interface SubagentFailure extends SubagentThreadPatch {
83
+ message: string;
84
+ code?: string;
85
+ }
86
+
87
+ export interface SubagentStop extends SubagentThreadPatch {
88
+ reason?: string;
89
+ }
90
+
91
+ export interface SubagentThread extends SubagentThreadSpec {
92
+ id: SubagentThreadId;
93
+ state: SubagentThreadState;
94
+ usage: SubagentUsage;
95
+ trace: SubagentTraceEvent[];
96
+ steering: SubagentSteeringMessage[];
97
+ result?: string;
98
+ failure?: { message: string; code?: string };
99
+ stopReason?: string;
100
+ timestamps: SubagentThreadTimestamps;
101
+ version: number;
102
+ }
103
+
104
+ export type SubagentThreadChangeType =
105
+ | "spawn"
106
+ | "begin"
107
+ | "patch"
108
+ | "trace"
109
+ | "steer"
110
+ | "complete"
111
+ | "fail"
112
+ | "stop"
113
+ | "interrupt"
114
+ | "close";
115
+
116
+ export interface SubagentThreadChange {
117
+ type: SubagentThreadChangeType;
118
+ thread: SubagentThread;
119
+ }
120
+
121
+ export interface SubagentThreadRegistryOptions {
122
+ createId?: () => string;
123
+ now?: () => number;
124
+ maxSteeringMessages?: number;
125
+ maxSteeringMessageLength?: number;
126
+ }
127
+
128
+ export type SubagentThreadListener = (change: SubagentThreadChange) => void;
129
+
130
+ const DEFAULT_MAX_STEERING_MESSAGES = 20;
131
+ const DEFAULT_MAX_STEERING_MESSAGE_LENGTH = 4_000;
132
+ const UPDATABLE_STATES = new Set<SubagentThreadState>(["queued", "active"]);
133
+ const TERMINAL_STATES = new Set<SubagentThreadState>(["done", "failed", "stopped"]);
134
+ const USAGE_FIELDS = [
135
+ "inputTokens",
136
+ "outputTokens",
137
+ "cacheReadTokens",
138
+ "cacheWriteTokens",
139
+ "totalTokens",
140
+ "costUsd",
141
+ "turns",
142
+ ] as const;
143
+
144
+ function emptyUsage(): SubagentUsage {
145
+ return {
146
+ inputTokens: 0,
147
+ outputTokens: 0,
148
+ cacheReadTokens: 0,
149
+ cacheWriteTokens: 0,
150
+ totalTokens: 0,
151
+ costUsd: 0,
152
+ turns: 0,
153
+ };
154
+ }
155
+
156
+ function copyHandoff(handoff: SubagentHandoff | undefined): SubagentHandoff | undefined {
157
+ if (!handoff) return undefined;
158
+ return {
159
+ summary: handoff.summary,
160
+ nextAction: handoff.nextAction,
161
+ artifacts: handoff.artifacts ? [...handoff.artifacts] : undefined,
162
+ };
163
+ }
164
+
165
+ function copyBoundary(boundary: SubagentCapabilityBoundary): SubagentCapabilityBoundary {
166
+ return { ...boundary };
167
+ }
168
+
169
+ function copyTraceEvent(event: SubagentTraceEvent): SubagentTraceEvent {
170
+ return {
171
+ at: event.at,
172
+ kind: event.kind,
173
+ message: event.message,
174
+ details: event.details ? { ...event.details } : undefined,
175
+ };
176
+ }
177
+
178
+ function snapshot(thread: SubagentThread): SubagentThread {
179
+ return {
180
+ id: thread.id,
181
+ parentId: thread.parentId,
182
+ role: thread.role,
183
+ prompt: thread.prompt,
184
+ model: thread.model,
185
+ tools: [...thread.tools],
186
+ capabilityBoundary: copyBoundary(thread.capabilityBoundary),
187
+ handoff: copyHandoff(thread.handoff),
188
+ state: thread.state,
189
+ usage: { ...thread.usage },
190
+ trace: thread.trace.map(copyTraceEvent),
191
+ steering: thread.steering.map((message) => ({ ...message })),
192
+ result: thread.result,
193
+ failure: thread.failure ? { ...thread.failure } : undefined,
194
+ stopReason: thread.stopReason,
195
+ timestamps: { ...thread.timestamps },
196
+ version: thread.version,
197
+ };
198
+ }
199
+
200
+ function requireText(value: string, name: string): void {
201
+ if (!value.trim()) throw new Error(`${name} must be non-empty`);
202
+ }
203
+
204
+ function requirePositiveInteger(value: number, name: string): void {
205
+ if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`);
206
+ }
207
+
208
+ function validateUsage(patch: Partial<SubagentUsage>): void {
209
+ for (const field of USAGE_FIELDS) {
210
+ const value = patch[field];
211
+ if (value !== undefined && (!Number.isFinite(value) || value < 0)) {
212
+ throw new Error(`usage.${field} must be a non-negative finite number`);
213
+ }
214
+ }
215
+ }
216
+
217
+ function validateHandoff(handoff: SubagentHandoff): void {
218
+ requireText(handoff.summary, "handoff.summary");
219
+ if (handoff.nextAction !== undefined) requireText(handoff.nextAction, "handoff.nextAction");
220
+ for (const artifact of handoff.artifacts ?? []) requireText(artifact, "handoff artifact");
221
+ }
222
+
223
+ function validateBoundary(boundary: SubagentCapabilityBoundary): void {
224
+ if (!(["none", "read", "write"] as string[]).includes(boundary.filesystem)) {
225
+ throw new Error("capabilityBoundary.filesystem must be none, read, or write");
226
+ }
227
+ if (!(["none", "read", "full"] as string[]).includes(boundary.network)) {
228
+ throw new Error("capabilityBoundary.network must be none, read, or full");
229
+ }
230
+ if (!(["none", "limited", "full"] as string[]).includes(boundary.process)) {
231
+ throw new Error("capabilityBoundary.process must be none, limited, or full");
232
+ }
233
+ if (typeof boundary.childThreads !== "boolean") throw new Error("capabilityBoundary.childThreads must be a boolean");
234
+ }
235
+
236
+ function isTerminal(state: SubagentThreadState): state is SubagentTerminalState {
237
+ return TERMINAL_STATES.has(state);
238
+ }
239
+
240
+ /**
241
+ * Owns child-thread state only. Callers execute, cancel, and transport work.
242
+ * Each read returns a copy, so callers cannot mutate registry state.
243
+ */
244
+ export class SubagentThreadRegistry {
245
+ private readonly threads = new Map<SubagentThreadId, SubagentThread>();
246
+ private readonly listeners = new Set<SubagentThreadListener>();
247
+ private readonly now: () => number;
248
+ private readonly createId: () => string;
249
+ private readonly maxSteeringMessages: number;
250
+ private readonly maxSteeringMessageLength: number;
251
+ private nextId = 0;
252
+ private nextSteeringId = 0;
253
+ private disposed = false;
254
+
255
+ constructor(options: SubagentThreadRegistryOptions = {}) {
256
+ this.now = options.now ?? Date.now;
257
+ this.maxSteeringMessages = options.maxSteeringMessages ?? DEFAULT_MAX_STEERING_MESSAGES;
258
+ this.maxSteeringMessageLength = options.maxSteeringMessageLength ?? DEFAULT_MAX_STEERING_MESSAGE_LENGTH;
259
+ requirePositiveInteger(this.maxSteeringMessages, "maxSteeringMessages");
260
+ requirePositiveInteger(this.maxSteeringMessageLength, "maxSteeringMessageLength");
261
+ this.createId = options.createId ?? (() => `subagent-${++this.nextId}`);
262
+ }
263
+
264
+ get isDisposed(): boolean {
265
+ return this.disposed;
266
+ }
267
+
268
+ spawn(spec: SubagentThreadSpec): SubagentThread {
269
+ this.assertOpen();
270
+ this.validateSpec(spec);
271
+ const rawId = this.createId();
272
+ requireText(rawId, "thread id");
273
+ const id = rawId as SubagentThreadId;
274
+ if (this.threads.has(id)) throw new Error(`Duplicate thread id ${rawId}`);
275
+
276
+ const timestamp = this.now();
277
+ const thread: SubagentThread = {
278
+ id,
279
+ parentId: spec.parentId,
280
+ role: spec.role,
281
+ prompt: spec.prompt,
282
+ model: spec.model,
283
+ tools: [...spec.tools],
284
+ capabilityBoundary: copyBoundary(spec.capabilityBoundary),
285
+ handoff: copyHandoff(spec.handoff),
286
+ state: "queued",
287
+ usage: emptyUsage(),
288
+ trace: [],
289
+ steering: [],
290
+ timestamps: { createdAt: timestamp, updatedAt: timestamp },
291
+ version: 1,
292
+ };
293
+ this.threads.set(id, thread);
294
+ this.emit("spawn", thread);
295
+ return snapshot(thread);
296
+ }
297
+
298
+ begin(id: SubagentThreadId): SubagentThread {
299
+ const thread = this.requireState(id, ["queued"]);
300
+ thread.state = "active";
301
+ thread.timestamps.startedAt = this.now();
302
+ this.changed(thread, "begin");
303
+ return snapshot(thread);
304
+ }
305
+
306
+ patch(id: SubagentThreadId, patch: SubagentThreadPatch): SubagentThread {
307
+ const thread = this.requireState(id, UPDATABLE_STATES);
308
+ this.applyPatch(thread, patch);
309
+ this.changed(thread, "patch");
310
+ return snapshot(thread);
311
+ }
312
+
313
+ trace(id: SubagentThreadId, update: SubagentTraceUpdate): SubagentThread {
314
+ const thread = this.requireState(id, UPDATABLE_STATES);
315
+ requireText(update.kind, "trace.kind");
316
+ if (update.message !== undefined) requireText(update.message, "trace.message");
317
+ if (update.details) {
318
+ for (const value of Object.values(update.details)) {
319
+ if (value !== null && !["string", "number", "boolean"].includes(typeof value)) {
320
+ throw new Error("trace.details values must be strings, numbers, booleans, or null");
321
+ }
322
+ }
323
+ }
324
+ thread.trace.push({ at: this.now(), kind: update.kind, message: update.message, details: update.details ? { ...update.details } : undefined });
325
+ this.changed(thread, "trace");
326
+ return snapshot(thread);
327
+ }
328
+
329
+ steer(id: SubagentThreadId, message: string): SubagentThread {
330
+ const thread = this.requireState(id, UPDATABLE_STATES);
331
+ requireText(message, "steering message");
332
+ if (message.length > this.maxSteeringMessageLength) {
333
+ throw new Error(`steering message exceeds ${this.maxSteeringMessageLength} characters`);
334
+ }
335
+ thread.steering.push({ id: ++this.nextSteeringId, at: this.now(), message });
336
+ if (thread.steering.length > this.maxSteeringMessages) thread.steering.splice(0, thread.steering.length - this.maxSteeringMessages);
337
+ this.changed(thread, "steer");
338
+ return snapshot(thread);
339
+ }
340
+
341
+ complete(id: SubagentThreadId, completion: SubagentCompletion = {}): SubagentThread {
342
+ const thread = this.requireState(id, ["active"]);
343
+ this.applyPatch(thread, completion);
344
+ thread.state = "done";
345
+ thread.timestamps.endedAt = this.now();
346
+ this.changed(thread, "complete");
347
+ return snapshot(thread);
348
+ }
349
+
350
+ fail(id: SubagentThreadId, failure: SubagentFailure): SubagentThread {
351
+ const thread = this.requireState(id, ["active"]);
352
+ requireText(failure.message, "failure.message");
353
+ if (failure.code !== undefined) requireText(failure.code, "failure.code");
354
+ this.applyPatch(thread, failure);
355
+ thread.failure = { message: failure.message, code: failure.code };
356
+ thread.state = "failed";
357
+ thread.timestamps.endedAt = this.now();
358
+ this.changed(thread, "fail");
359
+ return snapshot(thread);
360
+ }
361
+
362
+ stop(id: SubagentThreadId, stop: SubagentStop = {}): SubagentThread {
363
+ const thread = this.requireState(id, UPDATABLE_STATES);
364
+ if (stop.reason !== undefined) requireText(stop.reason, "stop.reason");
365
+ this.applyPatch(thread, stop);
366
+ thread.stopReason = stop.reason ?? "stopped";
367
+ thread.state = "stopped";
368
+ thread.timestamps.endedAt = this.now();
369
+ this.changed(thread, "stop");
370
+ return snapshot(thread);
371
+ }
372
+
373
+ interrupt(id: SubagentThreadId, reason = "interrupted"): SubagentThread {
374
+ const thread = this.requireState(id, ["active"]);
375
+ requireText(reason, "interrupt reason");
376
+ thread.stopReason = reason;
377
+ thread.state = "stopped";
378
+ thread.timestamps.endedAt = this.now();
379
+ this.changed(thread, "interrupt");
380
+ return snapshot(thread);
381
+ }
382
+
383
+ stopAllActive(stop: SubagentStop = {}): SubagentThread[] {
384
+ return this.listActive().map((thread) => this.stop(thread.id, stop));
385
+ }
386
+
387
+ interruptAllActive(reason = "interrupted"): SubagentThread[] {
388
+ return this.listActive().map((thread) => this.interrupt(thread.id, reason));
389
+ }
390
+
391
+ inspect(id: SubagentThreadId): SubagentThread | undefined {
392
+ const thread = this.threads.get(id);
393
+ return thread ? snapshot(thread) : undefined;
394
+ }
395
+
396
+ listActive(): SubagentThread[] {
397
+ return this.list((thread) => thread.state === "active");
398
+ }
399
+
400
+ /** Returns all terminal records: done, failed, and stopped. */
401
+ listDone(): SubagentThread[] {
402
+ return this.list((thread) => isTerminal(thread.state));
403
+ }
404
+
405
+ listAll(): SubagentThread[] {
406
+ return this.list(() => true);
407
+ }
408
+
409
+ /** Returns a terminal snapshot without removing the record. */
410
+ collect(id: SubagentThreadId): SubagentThread {
411
+ const thread = this.requireThread(id);
412
+ if (!isTerminal(thread.state)) throw new Error(`Thread ${id} is ${thread.state}, not terminal`);
413
+ return snapshot(thread);
414
+ }
415
+
416
+ /** Closes a terminal record while retaining its result for inspection. */
417
+ close(id: SubagentThreadId): SubagentThread {
418
+ this.assertOpen();
419
+ const thread = this.requireThread(id);
420
+ if (thread.state === "closed") return snapshot(thread);
421
+ if (!isTerminal(thread.state)) throw new Error(`Cannot close thread ${id} from ${thread.state}`);
422
+ thread.state = "closed";
423
+ thread.timestamps.closedAt = this.now();
424
+ this.changed(thread, "close");
425
+ return snapshot(thread);
426
+ }
427
+
428
+ subscribe(listener: SubagentThreadListener): () => void {
429
+ if (this.disposed) return () => {};
430
+ this.listeners.add(listener);
431
+ return () => this.listeners.delete(listener);
432
+ }
433
+
434
+ dispose(): void {
435
+ if (this.disposed) return;
436
+ for (const thread of this.listAll().filter((candidate) => candidate.state === "queued" || candidate.state === "active")) {
437
+ this.stop(thread.id, { reason: "disposed" });
438
+ }
439
+ for (const thread of this.listDone()) this.close(thread.id);
440
+ this.listeners.clear();
441
+ this.disposed = true;
442
+ }
443
+
444
+ private validateSpec(spec: SubagentThreadSpec): void {
445
+ requireText(spec.role, "role");
446
+ requireText(spec.prompt, "prompt");
447
+ requireText(spec.model, "model");
448
+ validateBoundary(spec.capabilityBoundary);
449
+ const tools = new Set<string>();
450
+ for (const tool of spec.tools) {
451
+ requireText(tool, "tool");
452
+ if (tools.has(tool)) throw new Error(`Duplicate tool ${tool}`);
453
+ tools.add(tool);
454
+ }
455
+ if (spec.handoff) validateHandoff(spec.handoff);
456
+ }
457
+
458
+ private applyPatch(thread: SubagentThread, patch: SubagentThreadPatch): void {
459
+ if (patch.usage) {
460
+ validateUsage(patch.usage);
461
+ Object.assign(thread.usage, patch.usage);
462
+ }
463
+ if (patch.handoff !== undefined) {
464
+ if (patch.handoff) validateHandoff(patch.handoff);
465
+ thread.handoff = copyHandoff(patch.handoff ?? undefined);
466
+ }
467
+ if (patch.result !== undefined) {
468
+ if (patch.result !== null) requireText(patch.result, "result");
469
+ thread.result = patch.result ?? undefined;
470
+ }
471
+ }
472
+
473
+ private list(matches: (thread: SubagentThread) => boolean): SubagentThread[] {
474
+ return [...this.threads.values()].filter(matches).map(snapshot);
475
+ }
476
+
477
+ private requireThread(id: SubagentThreadId): SubagentThread {
478
+ this.assertOpen();
479
+ const thread = this.threads.get(id);
480
+ if (!thread) throw new Error(`Unknown thread ${id}`);
481
+ return thread;
482
+ }
483
+
484
+ private requireState(id: SubagentThreadId, allowed: Iterable<SubagentThreadState>): SubagentThread {
485
+ const thread = this.requireThread(id);
486
+ const allowedStates = [...allowed];
487
+ if (!allowedStates.includes(thread.state)) {
488
+ throw new Error(`Cannot change thread ${id} from ${thread.state}; expected ${allowedStates.join(" or ")}`);
489
+ }
490
+ return thread;
491
+ }
492
+
493
+ private changed(thread: SubagentThread, type: SubagentThreadChangeType): void {
494
+ thread.timestamps.updatedAt = this.now();
495
+ thread.version += 1;
496
+ this.emit(type, thread);
497
+ }
498
+
499
+ private emit(type: SubagentThreadChangeType, thread: SubagentThread): void {
500
+ for (const listener of this.listeners) listener({ type, thread: snapshot(thread) });
501
+ }
502
+
503
+ private assertOpen(): void {
504
+ if (this.disposed) throw new Error("SubagentThreadRegistry is disposed");
505
+ }
506
+ }