killeros 1.4.0 → 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 +32 -0
- package/README.md +52 -9
- package/agents/debugger.md +51 -0
- package/agents/documenter.md +50 -0
- package/agents/planner.md +48 -4
- package/agents/reviewer.md +53 -4
- package/agents/scout.md +51 -4
- package/agents/security.md +55 -0
- package/agents/tester.md +51 -0
- package/agents/worker.md +49 -4
- package/package.json +5 -1
- package/subagent-lifecycle.ts +506 -0
- package/subagent-process.ts +474 -0
- package/subagent-ui.ts +210 -0
- package/subagents.ts +683 -286
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "killeros",
|
|
3
|
-
"version": "1.4.
|
|
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",
|
|
@@ -46,6 +49,7 @@
|
|
|
46
49
|
"@earendil-works/pi-ai": "*",
|
|
47
50
|
"@earendil-works/pi-coding-agent": "*",
|
|
48
51
|
"@earendil-works/pi-tui": "*",
|
|
52
|
+
"pi-web-access": "*",
|
|
49
53
|
"typebox": "*"
|
|
50
54
|
},
|
|
51
55
|
"devDependencies": {
|
|
@@ -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
|
+
}
|