killeros 1.5.2 → 1.5.4

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.
@@ -3,8 +3,8 @@
3
3
  declare const threadIdBrand: unique symbol;
4
4
 
5
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">;
6
+ export type SubagentThreadState = "queued" | "active" | "done" | "failed" | "stopped" | "orphaned" | "closed";
7
+ export type SubagentTerminalState = Extract<SubagentThreadState, "done" | "failed" | "stopped" | "orphaned">;
8
8
  export type SubagentFilesystemAccess = "none" | "read" | "write";
9
9
  export type SubagentNetworkAccess = "none" | "read" | "full";
10
10
  export type SubagentProcessAccess = "none" | "limited" | "full";
@@ -59,13 +59,20 @@ export interface SubagentThreadTimestamps {
59
59
  closedAt?: number;
60
60
  }
61
61
 
62
+ export interface SubagentThreadSession {
63
+ id: string;
64
+ directory: string;
65
+ }
66
+
62
67
  export interface SubagentThreadSpec {
63
68
  parentId?: SubagentThreadId;
69
+ displayName: string;
64
70
  role: string;
65
71
  prompt: string;
66
72
  model: string;
67
73
  tools: readonly string[];
68
74
  capabilityBoundary: SubagentCapabilityBoundary;
75
+ session: SubagentThreadSession;
69
76
  handoff?: SubagentHandoff;
70
77
  }
71
78
 
@@ -90,6 +97,7 @@ export interface SubagentStop extends SubagentThreadPatch {
90
97
 
91
98
  export interface SubagentThread extends SubagentThreadSpec {
92
99
  id: SubagentThreadId;
100
+ attempt: number;
93
101
  state: SubagentThreadState;
94
102
  usage: SubagentUsage;
95
103
  trace: SubagentTraceEvent[];
@@ -113,6 +121,7 @@ export type SubagentThreadChangeType =
113
121
  | "fail"
114
122
  | "stop"
115
123
  | "interrupt"
124
+ | "resume"
116
125
  | "close";
117
126
 
118
127
  export interface SubagentThreadChange {
@@ -129,10 +138,21 @@ export interface SubagentThreadRegistryOptions {
129
138
 
130
139
  export type SubagentThreadListener = (change: SubagentThreadChange) => void;
131
140
 
141
+ export interface SubagentWaitResult {
142
+ threadIds: readonly SubagentThreadId[];
143
+ completedThreadIds: readonly SubagentThreadId[];
144
+ pendingThreadIds: readonly SubagentThreadId[];
145
+ timedOut: boolean;
146
+ waitedMs: number;
147
+ threads: readonly SubagentThread[];
148
+ }
149
+
132
150
  const DEFAULT_MAX_STEERING_MESSAGES = 20;
133
151
  const DEFAULT_MAX_STEERING_MESSAGE_LENGTH = 4_000;
134
152
  const UPDATABLE_STATES = new Set<SubagentThreadState>(["queued", "active"]);
135
- const TERMINAL_STATES = new Set<SubagentThreadState>(["done", "failed", "stopped"]);
153
+ const TERMINAL_STATES = new Set<SubagentThreadState>(["done", "failed", "stopped", "orphaned"]);
154
+ const DISPLAY_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._ -]{0,47}$/u;
155
+ const MAX_WAIT_TIMEOUT_MS = 2_147_483_647;
136
156
  const USAGE_FIELDS = [
137
157
  "inputTokens",
138
158
  "outputTokens",
@@ -168,6 +188,10 @@ function copyBoundary(boundary: SubagentCapabilityBoundary): SubagentCapabilityB
168
188
  return { ...boundary };
169
189
  }
170
190
 
191
+ function copySession(session: SubagentThreadSession): SubagentThreadSession {
192
+ return { id: session.id, directory: session.directory };
193
+ }
194
+
171
195
  function copyTraceEvent(event: SubagentTraceEvent): SubagentTraceEvent {
172
196
  return {
173
197
  at: event.at,
@@ -181,12 +205,15 @@ function snapshot(thread: SubagentThread): SubagentThread {
181
205
  return {
182
206
  id: thread.id,
183
207
  parentId: thread.parentId,
208
+ displayName: thread.displayName,
184
209
  role: thread.role,
185
210
  prompt: thread.prompt,
186
211
  model: thread.model,
187
212
  tools: [...thread.tools],
188
213
  capabilityBoundary: copyBoundary(thread.capabilityBoundary),
214
+ session: copySession(thread.session),
189
215
  handoff: copyHandoff(thread.handoff),
216
+ attempt: thread.attempt,
190
217
  state: thread.state,
191
218
  usage: { ...thread.usage },
192
219
  trace: thread.trace.map(copyTraceEvent),
@@ -201,7 +228,7 @@ function snapshot(thread: SubagentThread): SubagentThread {
201
228
  }
202
229
 
203
230
  function requireText(value: string, name: string): void {
204
- if (!value.trim()) throw new Error(`${name} must be non-empty`);
231
+ if (typeof value !== "string" || !value.trim()) throw new Error(`${name} must be non-empty`);
205
232
  }
206
233
 
207
234
  function requirePositiveInteger(value: number, name: string): void {
@@ -218,12 +245,27 @@ function validateUsage(patch: Partial<SubagentUsage>): void {
218
245
  }
219
246
 
220
247
  function validateHandoff(handoff: SubagentHandoff): void {
248
+ if (!handoff || typeof handoff !== "object") throw new Error("handoff must be an object");
221
249
  requireText(handoff.summary, "handoff.summary");
222
250
  if (handoff.nextAction !== undefined) requireText(handoff.nextAction, "handoff.nextAction");
251
+ if (handoff.artifacts !== undefined && !Array.isArray(handoff.artifacts)) throw new Error("handoff.artifacts must be an array");
223
252
  for (const artifact of handoff.artifacts ?? []) requireText(artifact, "handoff artifact");
224
253
  }
225
254
 
255
+ function validateDisplayName(displayName: string): void {
256
+ if (typeof displayName !== "string" || !DISPLAY_NAME_PATTERN.test(displayName)) {
257
+ throw new Error("display name must match ^[A-Za-z0-9][A-Za-z0-9._ -]{0,47}$");
258
+ }
259
+ }
260
+
261
+ function validateSession(session: SubagentThreadSession): void {
262
+ if (!session || typeof session !== "object") throw new Error("session must be an object");
263
+ requireText(session.id, "session.id");
264
+ requireText(session.directory, "session.directory");
265
+ }
266
+
226
267
  function validateBoundary(boundary: SubagentCapabilityBoundary): void {
268
+ if (!boundary || typeof boundary !== "object") throw new Error("capabilityBoundary must be an object");
227
269
  if (!(["none", "read", "write"] as string[]).includes(boundary.filesystem)) {
228
270
  throw new Error("capabilityBoundary.filesystem must be none, read, or write");
229
271
  }
@@ -236,10 +278,56 @@ function validateBoundary(boundary: SubagentCapabilityBoundary): void {
236
278
  if (typeof boundary.childThreads !== "boolean") throw new Error("capabilityBoundary.childThreads must be a boolean");
237
279
  }
238
280
 
281
+ function validateTraceDetails(details: Readonly<Record<string, string | number | boolean | null>> | undefined): void {
282
+ if (details === undefined) return;
283
+ if (!details || typeof details !== "object" || Array.isArray(details)) throw new Error("trace.details must be an object");
284
+ for (const value of Object.values(details)) {
285
+ if (value !== null && !["string", "number", "boolean"].includes(typeof value)) {
286
+ throw new Error("trace.details values must be strings, numbers, booleans, or null");
287
+ }
288
+ }
289
+ }
290
+
291
+ function validateTraceEvent(event: SubagentTraceEvent, index: number): void {
292
+ if (!event || typeof event !== "object") throw new Error(`trace[${index}] must be an object`);
293
+ if (!Number.isFinite(event.at)) throw new Error(`trace[${index}].at must be a finite number`);
294
+ requireText(event.kind, `trace[${index}].kind`);
295
+ if (event.message !== undefined) requireText(event.message, `trace[${index}].message`);
296
+ validateTraceDetails(event.details);
297
+ }
298
+
299
+ function validateSteeringMessage(message: SubagentSteeringMessage, index: number): void {
300
+ if (!message || typeof message !== "object") throw new Error(`steering[${index}] must be an object`);
301
+ requirePositiveInteger(message.id, `steering[${index}].id`);
302
+ if (!Number.isFinite(message.at)) throw new Error(`steering[${index}].at must be a finite number`);
303
+ requireText(message.message, `steering[${index}].message`);
304
+ }
305
+
306
+ function validateCompleteUsage(usage: SubagentUsage): void {
307
+ if (!usage || typeof usage !== "object") throw new Error("usage must be an object");
308
+ for (const field of USAGE_FIELDS) {
309
+ if (!(field in usage)) throw new Error(`usage.${field} is required`);
310
+ }
311
+ validateUsage(usage);
312
+ }
313
+
314
+ function validateTimestamps(timestamps: SubagentThreadTimestamps): void {
315
+ if (!timestamps || typeof timestamps !== "object") throw new Error("timestamps must be an object");
316
+ for (const field of ["createdAt", "updatedAt", "startedAt", "endedAt", "closedAt"] as const) {
317
+ const value = timestamps[field];
318
+ if (value !== undefined && !Number.isFinite(value)) throw new Error(`timestamps.${field} must be a finite number`);
319
+ }
320
+ if (timestamps.createdAt === undefined) throw new Error("timestamps.createdAt is required");
321
+ if (timestamps.updatedAt === undefined) throw new Error("timestamps.updatedAt is required");
322
+ }
323
+
239
324
  function isTerminal(state: SubagentThreadState): state is SubagentTerminalState {
240
325
  return TERMINAL_STATES.has(state);
241
326
  }
242
327
 
328
+ // Module-scoped so fresh registries keep assigning fresh ids across session replacements.
329
+ let nextId = 0;
330
+
243
331
  /**
244
332
  * Owns child-thread state only. Callers execute, cancel, and transport work.
245
333
  * Each read returns a copy, so callers cannot mutate registry state.
@@ -251,7 +339,6 @@ export class SubagentThreadRegistry {
251
339
  private readonly createId: () => string;
252
340
  private readonly maxSteeringMessages: number;
253
341
  private readonly maxSteeringMessageLength: number;
254
- private nextId = 0;
255
342
  private nextSteeringId = 0;
256
343
  private disposed = false;
257
344
 
@@ -261,7 +348,7 @@ export class SubagentThreadRegistry {
261
348
  this.maxSteeringMessageLength = options.maxSteeringMessageLength ?? DEFAULT_MAX_STEERING_MESSAGE_LENGTH;
262
349
  requirePositiveInteger(this.maxSteeringMessages, "maxSteeringMessages");
263
350
  requirePositiveInteger(this.maxSteeringMessageLength, "maxSteeringMessageLength");
264
- this.createId = options.createId ?? (() => `subagent-${++this.nextId}`);
351
+ this.createId = options.createId ?? (() => `subagent-${++nextId}`);
265
352
  }
266
353
 
267
354
  get isDisposed(): boolean {
@@ -271,6 +358,7 @@ export class SubagentThreadRegistry {
271
358
  spawn(spec: SubagentThreadSpec): SubagentThread {
272
359
  this.assertOpen();
273
360
  this.validateSpec(spec);
361
+ this.assertUniqueDisplayName(spec.displayName, spec.parentId);
274
362
  const rawId = this.createId();
275
363
  requireText(rawId, "thread id");
276
364
  const id = rawId as SubagentThreadId;
@@ -280,12 +368,15 @@ export class SubagentThreadRegistry {
280
368
  const thread: SubagentThread = {
281
369
  id,
282
370
  parentId: spec.parentId,
371
+ displayName: spec.displayName,
283
372
  role: spec.role,
284
373
  prompt: spec.prompt,
285
374
  model: spec.model,
286
375
  tools: [...spec.tools],
287
376
  capabilityBoundary: copyBoundary(spec.capabilityBoundary),
377
+ session: copySession(spec.session),
288
378
  handoff: copyHandoff(spec.handoff),
379
+ attempt: 1,
289
380
  state: "queued",
290
381
  usage: emptyUsage(),
291
382
  trace: [],
@@ -337,13 +428,16 @@ export class SubagentThreadRegistry {
337
428
  throw new Error(`steering message exceeds ${this.maxSteeringMessageLength} characters`);
338
429
  }
339
430
  thread.steering.push({ id: ++this.nextSteeringId, at: this.now(), message });
340
- if (thread.steering.length > this.maxSteeringMessages) thread.steering.splice(0, thread.steering.length - this.maxSteeringMessages);
431
+ if (thread.steering.length > this.maxSteeringMessages) thread.steering.splice(this.maxSteeringMessages);
341
432
  this.changed(thread, "steer");
342
433
  return snapshot(thread);
343
434
  }
344
435
 
345
436
  complete(id: SubagentThreadId, completion: SubagentCompletion = {}): SubagentThread {
346
437
  const thread = this.requireState(id, ["active"]);
438
+ const result = completion.result !== undefined ? completion.result : thread.result;
439
+ if (result === undefined || result === null) throw new Error(`Cannot complete thread ${id} without a usable result`);
440
+ requireText(result, "result");
347
441
  this.applyPatch(thread, completion);
348
442
  thread.state = "done";
349
443
  thread.timestamps.endedAt = this.now();
@@ -397,11 +491,107 @@ export class SubagentThreadRegistry {
397
491
  return thread ? snapshot(thread) : undefined;
398
492
  }
399
493
 
494
+ resolve(reference: string, parentId?: SubagentThreadId): SubagentThread | undefined {
495
+ if (typeof reference !== "string") return undefined;
496
+ const exact = this.threads.get(reference as SubagentThreadId);
497
+ if (exact) return snapshot(exact);
498
+ const name = reference.toLocaleLowerCase();
499
+ const match = [...this.threads.values()].find((thread) =>
500
+ thread.parentId === parentId && thread.displayName.toLocaleLowerCase() === name,
501
+ );
502
+ return match ? snapshot(match) : undefined;
503
+ }
504
+
505
+ hydrate(thread: SubagentThread): SubagentThread {
506
+ this.assertOpen();
507
+ this.validateThreadSnapshot(thread);
508
+ if (this.threads.has(thread.id)) throw new Error(`Duplicate thread id ${thread.id}`);
509
+ this.assertUniqueDisplayName(thread.displayName, thread.parentId);
510
+
511
+ const hydrated = snapshot(thread);
512
+ if (hydrated.state === "queued" || hydrated.state === "active") {
513
+ hydrated.state = "orphaned";
514
+ hydrated.stopReason = "parent_restarted";
515
+ }
516
+ this.threads.set(hydrated.id, hydrated);
517
+ for (const message of hydrated.steering) this.nextSteeringId = Math.max(this.nextSteeringId, message.id);
518
+ return snapshot(hydrated);
519
+ }
520
+
521
+ resume(id: SubagentThreadId, prompt?: string): SubagentThread {
522
+ const thread = this.requireState(id, ["done", "failed", "stopped", "orphaned"]);
523
+ if (prompt !== undefined) {
524
+ requireText(prompt, "prompt");
525
+ thread.prompt = prompt;
526
+ }
527
+ thread.attempt += 1;
528
+ thread.result = undefined;
529
+ thread.failure = undefined;
530
+ thread.stopReason = undefined;
531
+ delete thread.timestamps.startedAt;
532
+ delete thread.timestamps.endedAt;
533
+ thread.state = "queued";
534
+ this.changed(thread, "resume");
535
+ return snapshot(thread);
536
+ }
537
+
538
+ waitForTerminal(ids: readonly SubagentThreadId[], timeoutMs: number): Promise<SubagentWaitResult> {
539
+ const threadIds = [...ids];
540
+ if (threadIds.length === 0) {
541
+ return Promise.resolve({
542
+ threadIds,
543
+ completedThreadIds: [],
544
+ pendingThreadIds: [],
545
+ timedOut: false,
546
+ waitedMs: 0,
547
+ threads: [],
548
+ });
549
+ }
550
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 0 || timeoutMs > MAX_WAIT_TIMEOUT_MS) {
551
+ throw new Error(`timeoutMs must be an integer from 0 to ${MAX_WAIT_TIMEOUT_MS}`);
552
+ }
553
+ for (const id of threadIds) this.requireThread(id);
554
+
555
+ const startedAt = Date.now();
556
+ const currentThreads = (): SubagentThread[] => threadIds.map((id) => snapshot(this.threads.get(id)!));
557
+ const makeResult = (timedOut: boolean): SubagentWaitResult => {
558
+ const threads = currentThreads();
559
+ const completedThreadIds = threads.filter((thread) => isTerminal(thread.state)).map((thread) => thread.id);
560
+ return {
561
+ threadIds,
562
+ completedThreadIds,
563
+ pendingThreadIds: threads.filter((thread) => !isTerminal(thread.state)).map((thread) => thread.id),
564
+ timedOut,
565
+ waitedMs: Math.max(0, Date.now() - startedAt),
566
+ threads,
567
+ };
568
+ };
569
+ const allTerminal = (): boolean => threadIds.every((id) => isTerminal(this.threads.get(id)!.state));
570
+ if (allTerminal()) return Promise.resolve(makeResult(false));
571
+
572
+ return new Promise<SubagentWaitResult>((resolve) => {
573
+ let settled = false;
574
+ let timer: ReturnType<typeof setTimeout> | undefined;
575
+ let unsubscribe = (): void => {};
576
+ const finish = (timedOut: boolean): void => {
577
+ if (settled) return;
578
+ settled = true;
579
+ unsubscribe();
580
+ if (timer !== undefined) clearTimeout(timer);
581
+ resolve(makeResult(timedOut));
582
+ };
583
+ unsubscribe = this.subscribe((change) => {
584
+ if (threadIds.includes(change.thread.id) && allTerminal()) finish(false);
585
+ });
586
+ timer = setTimeout(() => finish(true), timeoutMs);
587
+ });
588
+ }
589
+
400
590
  listActive(): SubagentThread[] {
401
591
  return this.list((thread) => thread.state === "active");
402
592
  }
403
593
 
404
- /** Returns all terminal records: done, failed, and stopped. */
594
+ /** Returns all terminal records: done, failed, stopped, and orphaned. */
405
595
  listDone(): SubagentThread[] {
406
596
  return this.list((thread) => isTerminal(thread.state));
407
597
  }
@@ -468,10 +658,15 @@ export class SubagentThreadRegistry {
468
658
  }
469
659
 
470
660
  private validateSpec(spec: SubagentThreadSpec): void {
661
+ if (!spec || typeof spec !== "object") throw new Error("thread spec must be an object");
662
+ if (spec.parentId !== undefined) requireText(spec.parentId, "parent id");
663
+ validateDisplayName(spec.displayName);
471
664
  requireText(spec.role, "role");
472
665
  requireText(spec.prompt, "prompt");
473
666
  requireText(spec.model, "model");
474
667
  validateBoundary(spec.capabilityBoundary);
668
+ validateSession(spec.session);
669
+ if (!Array.isArray(spec.tools)) throw new Error("tools must be an array");
475
670
  const tools = new Set<string>();
476
671
  for (const tool of spec.tools) {
477
672
  requireText(tool, "tool");
@@ -481,6 +676,31 @@ export class SubagentThreadRegistry {
481
676
  if (spec.handoff) validateHandoff(spec.handoff);
482
677
  }
483
678
 
679
+ private validateThreadSnapshot(thread: SubagentThread): void {
680
+ if (!thread || typeof thread !== "object") throw new Error("thread snapshot must be an object");
681
+ requireText(thread.id, "thread id");
682
+ this.validateSpec(thread);
683
+ if (!(["queued", "active", "done", "failed", "stopped", "orphaned", "closed"] as string[]).includes(thread.state)) {
684
+ throw new Error(`Unknown thread state ${thread.state}`);
685
+ }
686
+ requirePositiveInteger(thread.attempt, "attempt");
687
+ validateCompleteUsage(thread.usage);
688
+ if (!Array.isArray(thread.trace)) throw new Error("trace must be an array");
689
+ thread.trace.forEach(validateTraceEvent);
690
+ if (!Array.isArray(thread.steering)) throw new Error("steering must be an array");
691
+ thread.steering.forEach(validateSteeringMessage);
692
+ if (thread.result !== undefined) requireText(thread.result, "result");
693
+ if (thread.failure !== undefined) {
694
+ if (!thread.failure || typeof thread.failure !== "object") throw new Error("failure must be an object");
695
+ requireText(thread.failure.message, "failure.message");
696
+ if (thread.failure.code !== undefined) requireText(thread.failure.code, "failure.code");
697
+ }
698
+ if (thread.stopReason !== undefined) requireText(thread.stopReason, "stopReason");
699
+ if (typeof thread.evicted !== "boolean") throw new Error("evicted must be a boolean");
700
+ validateTimestamps(thread.timestamps);
701
+ requirePositiveInteger(thread.version, "version");
702
+ }
703
+
484
704
  private applyPatch(thread: SubagentThread, patch: SubagentThreadPatch): void {
485
705
  if (patch.usage) {
486
706
  validateUsage(patch.usage);
@@ -500,6 +720,15 @@ export class SubagentThreadRegistry {
500
720
  return [...this.threads.values()].filter(matches).map(snapshot);
501
721
  }
502
722
 
723
+ private assertUniqueDisplayName(displayName: string, parentId?: SubagentThreadId): void {
724
+ const name = displayName.toLocaleLowerCase();
725
+ if ([...this.threads.values()].some((thread) =>
726
+ thread.parentId === parentId && thread.displayName.toLocaleLowerCase() === name,
727
+ )) {
728
+ throw new Error(`display name ${displayName} already exists for this parent`);
729
+ }
730
+ }
731
+
503
732
  private requireThread(id: SubagentThreadId): SubagentThread {
504
733
  this.assertOpen();
505
734
  const thread = this.threads.get(id);