killeros 1.4.7 → 1.4.9

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/README.md CHANGED
@@ -33,7 +33,7 @@ pi install git:github.com/KyrosHendrix/pi-KillerOS
33
33
  Pin an install to a release:
34
34
 
35
35
  ```bash
36
- pi install git:github.com/KyrosHendrix/pi-KillerOS@v1.4.7
36
+ pi install git:github.com/KyrosHendrix/pi-KillerOS@v1.4.9
37
37
  ```
38
38
 
39
39
  Add `-l` to either command for a project-only install. Restart Pi after installing.
@@ -97,13 +97,13 @@ KillerOS ships `planner`, `reviewer`, `scout`, and `security` as read-only roles
97
97
 
98
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
- The tool supports a single `agent` + `task`, parallel `tasks`, or a sequential `chain` whose task text may include `{previous}`. Parallel batches run read-only roles concurrently, up to four at a time, then run every write-capable role serially in input order because all children share the parent worktree. 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. The `message` field is only valid with `action: "steer"`. For example:
100
+ The tool supports a single `agent` + `task`, parallel `tasks`, or a sequential `chain` whose task text may include `{previous}`. Read-only-only batches run concurrently, up to four at a time. Batches with write-capable roles use one shared slot by default; set `writerConcurrency` above `1` only after proving path ownership in the shared worktree. Reader-only batches reject `writerConcurrency` because it does not apply. All children share the parent worktree, so concurrent writers must avoid file conflicts. 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. The `message` field is only valid with `action: "steer"`. For example:
101
101
 
102
102
  ```json
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 isolated `pi --mode json -p` processes with a private `--session-dir` and `--session-id`, plus explicit local tools and `web_search`, `source_check`, `fetch_content`, and `get_search_content`. Steering restarts the same child session, so the child keeps its prior conversation. 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` and report useful evidence. Children have no default token, dollar, turn, tool-call, research, wall-time, JSONL-line, trace, stderr, or returned-output execution quota. KillerOS bounds retained trace, stderr, and returned text and spills a large JSONL line to temporary storage; retention never stops a child or marks it `limited`. The parent limits each request to ten tasks and four parallel readers and bounds role files, task input, and combined parent output. An embedding caller may opt into named child resource guards. 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 isolated `pi --mode json -p` processes with a private `--session-dir` and `--session-id`, plus explicit local tools and `web_search`, `source_check`, `fetch_content`, and `get_search_content`. Steering restarts the same child session, so the child keeps its prior conversation. 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` and report useful evidence. Children have no default token, dollar, turn, tool-call, research, wall-time, trace, stderr, or returned-output execution quota; each JSONL record still has a bounded 8 MiB parser ceiling. KillerOS bounds retained trace, stderr, and returned text and spills a large JSONL line to temporary storage; retention never stops a child or marks it `limited`. The parent limits each request to ten tasks, read-only-only batches to four concurrent readers, and bounds role files, task input, and combined parent output. An embedding caller may opt into named child resource guards. A parent tool-call abort cancels queued tasks but lets already-running children finish; explicit `interrupt` actions and session shutdown terminate active children and escalate after five seconds.
107
107
 
108
108
  ### Thread lifecycle
109
109
 
@@ -111,9 +111,9 @@ Each delegated task creates a named child thread. Its contract records the paren
111
111
 
112
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
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.
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. Closing removes a thread from the active workspace; heavy trace and result payloads are evicted as needed under the bounded retention budget, leaving a small inspectable tombstone.
115
115
 
116
- A child completes naturally when it returns a final answer. The default path has no per-child execution quota. Explicit embedding options can add wall-time, output, trace, stderr, JSONL, token, or cost guards; those guards report their cause and return partial work clearly. The parent still bounds task count, reader concurrency, role files, task input, and combined parent output. Explicit user interruptions and real child-process failures remain visible. Esc cancellation terminates active children and escalates after five seconds.
116
+ A child completes naturally when it returns a final answer. The default path has no per-child execution quota, while every JSONL record has an 8 MiB parser ceiling. Explicit embedding options can add wall-time, output, trace, stderr, JSONL, token, or cost guards; those guards report their cause and return partial work clearly. The parent still bounds task count, reader concurrency, role files, task input, and combined parent output. Parent tool-call aborts leave active children running while queued work is settled as cancelled; explicit `interrupt` actions and real child-process failures remain visible. Session shutdown still terminates active children and escalates after five seconds.
117
117
 
118
118
  The replacement lifecycle has nine phases:
119
119
 
@@ -124,8 +124,8 @@ The replacement lifecycle has nine phases:
124
124
  5. **Interrupt:** stop one or all active children while preserving partial work.
125
125
  6. **Collect:** return a concise handoff while retaining the expanded trace.
126
126
  7. **Guard:** honor only explicitly configured child resource guards; do not impose 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.
127
+ 8. **Close:** remove a finished or stopped thread from the workspace while retaining a small inspectable tombstone; heavy payloads may be evicted under the retention budget.
128
+ 9. **Prove:** test identity, visibility, controls, natural completion, guards, partial handoffs, bounded retention, and closure.
129
129
 
130
130
  ## Configuration
131
131
 
@@ -163,7 +163,7 @@ The package manifest lists Pi’s built-in modules as peer dependencies, so npm
163
163
 
164
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.
165
165
 
166
- For release `1.4.7`, publish after the validation checks pass:
166
+ For release `1.4.9`, publish after the validation checks pass:
167
167
 
168
168
  ```bash
169
169
  npm login
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "killeros",
3
- "version": "1.4.7",
3
+ "version": "1.4.9",
4
4
  "description": "A production-hardened TUI and workflow extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -46,11 +46,11 @@
46
46
  ]
47
47
  },
48
48
  "peerDependencies": {
49
- "@earendil-works/pi-ai": "*",
50
- "@earendil-works/pi-coding-agent": "*",
51
- "@earendil-works/pi-tui": "*",
52
- "pi-web-access": "*",
53
- "typebox": "*"
49
+ "@earendil-works/pi-ai": ">=0.82.1",
50
+ "@earendil-works/pi-coding-agent": ">=0.82.1",
51
+ "@earendil-works/pi-tui": ">=0.82.1",
52
+ "pi-web-access": ">=0.17.1",
53
+ "typebox": ">=1.1.38 <2"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@earendil-works/pi-coding-agent": "0.82.1",
@@ -97,6 +97,8 @@ export interface SubagentThread extends SubagentThreadSpec {
97
97
  result?: string;
98
98
  failure?: { message: string; code?: string };
99
99
  stopReason?: string;
100
+ /** True when close evicted the heavy trace, prompt, handoff, and result fields. */
101
+ evicted: boolean;
100
102
  timestamps: SubagentThreadTimestamps;
101
103
  version: number;
102
104
  }
@@ -192,6 +194,7 @@ function snapshot(thread: SubagentThread): SubagentThread {
192
194
  result: thread.result,
193
195
  failure: thread.failure ? { ...thread.failure } : undefined,
194
196
  stopReason: thread.stopReason,
197
+ evicted: thread.evicted,
195
198
  timestamps: { ...thread.timestamps },
196
199
  version: thread.version,
197
200
  };
@@ -287,6 +290,7 @@ export class SubagentThreadRegistry {
287
290
  usage: emptyUsage(),
288
291
  trace: [],
289
292
  steering: [],
293
+ evicted: false,
290
294
  timestamps: { createdAt: timestamp, updatedAt: timestamp },
291
295
  version: 1,
292
296
  };
@@ -413,7 +417,7 @@ export class SubagentThreadRegistry {
413
417
  return snapshot(thread);
414
418
  }
415
419
 
416
- /** Closes a terminal record while retaining its result for inspection. */
420
+ /** Closes a terminal record and retains only a small tombstone for inspection. */
417
421
  close(id: SubagentThreadId): SubagentThread {
418
422
  this.assertOpen();
419
423
  const thread = this.requireThread(id);
@@ -421,10 +425,32 @@ export class SubagentThreadRegistry {
421
425
  if (!isTerminal(thread.state)) throw new Error(`Cannot close thread ${id} from ${thread.state}`);
422
426
  thread.state = "closed";
423
427
  thread.timestamps.closedAt = this.now();
428
+ thread.prompt = "[closed thread prompt evicted]";
429
+ thread.handoff = undefined;
430
+ thread.trace = [];
431
+ thread.steering = [];
432
+ thread.result = undefined;
433
+ thread.failure = thread.failure ? { message: thread.failure.message.slice(0, 512), code: thread.failure.code } : undefined;
434
+ thread.evicted = true;
424
435
  this.changed(thread, "close");
425
436
  return snapshot(thread);
426
437
  }
427
438
 
439
+ /** Remove the oldest closed tombstones and return bounded eviction notices. */
440
+ pruneClosed(maxRecords: number): SubagentThread[] {
441
+ this.assertOpen();
442
+ const closed = [...this.threads.values()]
443
+ .filter((thread) => thread.state === "closed")
444
+ .sort((left, right) => (left.timestamps.closedAt ?? left.timestamps.updatedAt) - (right.timestamps.closedAt ?? right.timestamps.updatedAt));
445
+ const removed: SubagentThread[] = [];
446
+ while (closed.length > Math.max(0, Math.floor(maxRecords))) {
447
+ const thread = closed.shift()!;
448
+ this.threads.delete(thread.id);
449
+ removed.push(snapshot(thread));
450
+ }
451
+ return removed;
452
+ }
453
+
428
454
  subscribe(listener: SubagentThreadListener): () => void {
429
455
  if (this.disposed) return () => {};
430
456
  this.listeners.add(listener);
@@ -3,7 +3,10 @@ import { closeSync, mkdtempSync, openSync, readFileSync, rmSync, statSync, write
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
 
6
+ export const MAX_NODE_TIMER_MS = 2_147_483_647;
7
+
6
8
  export const SUBAGENT_PROCESS_LIMITS = {
9
+ jsonlLineBytes: 8 * 1024 * 1024,
7
10
  killGraceMs: 5_000,
8
11
  } as const;
9
12
 
@@ -96,6 +99,10 @@ export interface SubagentProcessRetention {
96
99
 
97
100
  export interface SubagentProcessHandle {
98
101
  readonly pid: number | undefined;
102
+ /** True after the child close event, or when no child was spawned. */
103
+ readonly hasExited: boolean;
104
+ /** Resolves after the child close event, or when no child was spawned. */
105
+ readonly exited: Promise<void>;
99
106
  readonly result: Promise<SubagentProcessResult>;
100
107
  /** Stop this child and retain any work received before it exits. */
101
108
  stop(reason?: string): void;
@@ -215,7 +222,10 @@ function normalizeLimits(overrides: Partial<SubagentProcessLimits> | undefined):
215
222
  const limits = { ...SUBAGENT_PROCESS_LIMITS, ...overrides };
216
223
  for (const name of ["wallTimeMs", "jsonlLineBytes", "traceBytes", "stderrBytes", "outputBytes", "killGraceMs"] as const) {
217
224
  const value = limits[name];
218
- if (value !== undefined && (!Number.isSafeInteger(value) || value <= 0)) throw new RangeError(`${name} must be a positive safe integer`);
225
+ if (value !== undefined && (!Number.isSafeInteger(value) || value <= 0 || value > MAX_NODE_TIMER_MS && (name === "wallTimeMs" || name === "killGraceMs"))) {
226
+ const bound = name === "wallTimeMs" || name === "killGraceMs" ? ` no greater than ${MAX_NODE_TIMER_MS}` : "";
227
+ throw new RangeError(`${name} must be a positive safe integer${bound}`);
228
+ }
219
229
  }
220
230
  for (const name of ["quotaTokens", "quotaUsd"] as const) {
221
231
  const value = limits[name];
@@ -324,6 +334,7 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
324
334
  durationMs: 0,
325
335
  };
326
336
  let child: SubagentProcessChild | undefined;
337
+ let processExited = false;
327
338
  let closed = false;
328
339
  let finishing = false;
329
340
  let requestedStatus: Exclude<SubagentProcessStatus, "running" | "complete"> | undefined;
@@ -338,7 +349,14 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
338
349
  let settleTimer: NodeJS.Timeout | undefined;
339
350
  let timeoutTimer: NodeJS.Timeout | undefined;
340
351
  let resolveResult!: (result: SubagentProcessResult) => void;
352
+ let resolveExited!: () => void;
341
353
  const result = new Promise<SubagentProcessResult>((resolve) => { resolveResult = resolve; });
354
+ const exited = new Promise<void>((resolve) => { resolveExited = resolve; });
355
+ const markExited = (): void => {
356
+ if (processExited) return;
357
+ processExited = true;
358
+ resolveExited();
359
+ };
342
360
 
343
361
  const clearStdoutLine = (): void => {
344
362
  if (stdoutLineSpoolDescriptor !== undefined) {
@@ -385,6 +403,7 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
385
403
  const finish = (code: number | null): void => {
386
404
  if (closed || finishing) return;
387
405
  finishing = true;
406
+ if (!child || processExited) markExited();
388
407
  if (stdoutLineBytes && !requestedStatus) processLine(readStdoutLine());
389
408
  else clearStdoutLine();
390
409
  closed = true;
@@ -559,7 +578,10 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
559
578
  }
560
579
  });
561
580
  child.on("error", (error) => requestTermination("failed", "spawn_error", error.message));
562
- child.once("close", finish);
581
+ child.once("close", (code) => {
582
+ markExited();
583
+ finish(code);
584
+ });
563
585
  if (limits.wallTimeMs !== undefined) timeoutTimer = setTimeout(() => requestTermination("limited", "wall_time_limit"), limits.wallTimeMs);
564
586
  options.signal?.addEventListener("abort", abortHandler, { once: true });
565
587
  if (options.signal?.aborted) abortHandler();
@@ -570,6 +592,8 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
570
592
 
571
593
  return {
572
594
  get pid() { return child?.pid; },
595
+ get hasExited() { return processExited; },
596
+ exited,
573
597
  result,
574
598
  stop(reason = "stopped") { requestTermination("cancelled", reason); },
575
599
  snapshot: () => cloneResult(state),