killeros 1.4.6 → 1.4.8
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 +16 -0
- package/README.md +5 -5
- package/package.json +1 -1
- package/subagent-process.ts +19 -1
- package/subagents.ts +129 -44
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to KillerOS are documented here.
|
|
4
4
|
|
|
5
|
+
## [1.4.8] - 2026-08-02
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Parallel batches with write-capable roles now run through a shared pool by default; `writerConcurrency` caps the entire batch and setting it to `1` serializes the writer-containing batch. Reader-only batches reject `writerConcurrency` because it does not apply.
|
|
10
|
+
|
|
11
|
+
## [1.4.7] - 2026-08-01
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- Serialized every write-capable task in a parallel batch in input order instead of rejecting batches with multiple writers.
|
|
16
|
+
- Added opt-in `writerConcurrency` scheduling for independent batches while keeping serialization as the safe default and documenting shared-worktree conflict responsibility.
|
|
17
|
+
- Parent tool-call aborts now settle only queued tasks; active children finish naturally, and session directories remain until child exit is confirmed.
|
|
18
|
+
- Settled queued tasks on interrupted parallel batches and documented the shared-worktree execution model.
|
|
19
|
+
- Restricted the `message` parameter to `action: "steer"` and added focused regression coverage.
|
|
20
|
+
|
|
5
21
|
## [1.4.6] - 2026-08-01
|
|
6
22
|
|
|
7
23
|
### Fixed
|
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.
|
|
36
|
+
pi install git:github.com/KyrosHendrix/pi-KillerOS@v1.4.8
|
|
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}`. 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:
|
|
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 pool by default, up to ten tasks at once; set `writerConcurrency` to choose a smaller cap, or set it to `1` to serialize the entire batch. 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
|
|
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, 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
|
|
|
@@ -113,7 +113,7 @@ Threads move through `queued`, `active`, `done`, `failed`, `stopped`, and `close
|
|
|
113
113
|
|
|
114
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
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.
|
|
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. 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
|
|
|
@@ -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.
|
|
166
|
+
For release `1.4.8`, publish after the validation checks pass:
|
|
167
167
|
|
|
168
168
|
```bash
|
|
169
169
|
npm login
|
package/package.json
CHANGED
package/subagent-process.ts
CHANGED
|
@@ -96,6 +96,10 @@ export interface SubagentProcessRetention {
|
|
|
96
96
|
|
|
97
97
|
export interface SubagentProcessHandle {
|
|
98
98
|
readonly pid: number | undefined;
|
|
99
|
+
/** True after the child close event, or when no child was spawned. */
|
|
100
|
+
readonly hasExited: boolean;
|
|
101
|
+
/** Resolves after the child close event, or when no child was spawned. */
|
|
102
|
+
readonly exited: Promise<void>;
|
|
99
103
|
readonly result: Promise<SubagentProcessResult>;
|
|
100
104
|
/** Stop this child and retain any work received before it exits. */
|
|
101
105
|
stop(reason?: string): void;
|
|
@@ -324,6 +328,7 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
|
|
|
324
328
|
durationMs: 0,
|
|
325
329
|
};
|
|
326
330
|
let child: SubagentProcessChild | undefined;
|
|
331
|
+
let processExited = false;
|
|
327
332
|
let closed = false;
|
|
328
333
|
let finishing = false;
|
|
329
334
|
let requestedStatus: Exclude<SubagentProcessStatus, "running" | "complete"> | undefined;
|
|
@@ -338,7 +343,14 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
|
|
|
338
343
|
let settleTimer: NodeJS.Timeout | undefined;
|
|
339
344
|
let timeoutTimer: NodeJS.Timeout | undefined;
|
|
340
345
|
let resolveResult!: (result: SubagentProcessResult) => void;
|
|
346
|
+
let resolveExited!: () => void;
|
|
341
347
|
const result = new Promise<SubagentProcessResult>((resolve) => { resolveResult = resolve; });
|
|
348
|
+
const exited = new Promise<void>((resolve) => { resolveExited = resolve; });
|
|
349
|
+
const markExited = (): void => {
|
|
350
|
+
if (processExited) return;
|
|
351
|
+
processExited = true;
|
|
352
|
+
resolveExited();
|
|
353
|
+
};
|
|
342
354
|
|
|
343
355
|
const clearStdoutLine = (): void => {
|
|
344
356
|
if (stdoutLineSpoolDescriptor !== undefined) {
|
|
@@ -385,6 +397,7 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
|
|
|
385
397
|
const finish = (code: number | null): void => {
|
|
386
398
|
if (closed || finishing) return;
|
|
387
399
|
finishing = true;
|
|
400
|
+
if (!child || processExited) markExited();
|
|
388
401
|
if (stdoutLineBytes && !requestedStatus) processLine(readStdoutLine());
|
|
389
402
|
else clearStdoutLine();
|
|
390
403
|
closed = true;
|
|
@@ -559,7 +572,10 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
|
|
|
559
572
|
}
|
|
560
573
|
});
|
|
561
574
|
child.on("error", (error) => requestTermination("failed", "spawn_error", error.message));
|
|
562
|
-
child.once("close",
|
|
575
|
+
child.once("close", (code) => {
|
|
576
|
+
markExited();
|
|
577
|
+
finish(code);
|
|
578
|
+
});
|
|
563
579
|
if (limits.wallTimeMs !== undefined) timeoutTimer = setTimeout(() => requestTermination("limited", "wall_time_limit"), limits.wallTimeMs);
|
|
564
580
|
options.signal?.addEventListener("abort", abortHandler, { once: true });
|
|
565
581
|
if (options.signal?.aborted) abortHandler();
|
|
@@ -570,6 +586,8 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
|
|
|
570
586
|
|
|
571
587
|
return {
|
|
572
588
|
get pid() { return child?.pid; },
|
|
589
|
+
get hasExited() { return processExited; },
|
|
590
|
+
exited,
|
|
573
591
|
result,
|
|
574
592
|
stop(reason = "stopped") { requestTermination("cancelled", reason); },
|
|
575
593
|
snapshot: () => cloneResult(state),
|
package/subagents.ts
CHANGED
|
@@ -113,6 +113,7 @@ export interface SubagentDetails {
|
|
|
113
113
|
mode: "single" | "parallel" | "chain";
|
|
114
114
|
agentScope: AgentScope;
|
|
115
115
|
projectAgentsDir: string | null;
|
|
116
|
+
executionNote?: string;
|
|
116
117
|
results: SubagentTaskResult[];
|
|
117
118
|
aggregateUsage: SubagentUsage;
|
|
118
119
|
parentId?: string;
|
|
@@ -712,7 +713,23 @@ async function mapReadTasks<T>(items: T[], concurrency: number, run: (item: T, i
|
|
|
712
713
|
await Promise.all(workers);
|
|
713
714
|
}
|
|
714
715
|
|
|
715
|
-
function
|
|
716
|
+
function waitForConfirmedProcessExit(handle: SubagentProcessHandle, timeoutMs = 1_000): Promise<boolean> {
|
|
717
|
+
if (handle.hasExited) return Promise.resolve(true);
|
|
718
|
+
return new Promise((resolve) => {
|
|
719
|
+
let settled = false;
|
|
720
|
+
let timeout: NodeJS.Timeout | undefined;
|
|
721
|
+
const finish = (exited: boolean): void => {
|
|
722
|
+
if (settled) return;
|
|
723
|
+
settled = true;
|
|
724
|
+
if (timeout) clearTimeout(timeout);
|
|
725
|
+
resolve(exited);
|
|
726
|
+
};
|
|
727
|
+
timeout = setTimeout(() => finish(handle.hasExited), timeoutMs);
|
|
728
|
+
void handle.exited.then(() => finish(true));
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function createSubagentParams(limits: Pick<SubagentLimits, "maxTasks" | "maxReadConcurrency" | "taskCharacters">) {
|
|
716
733
|
const taskSchema = Type.Object({
|
|
717
734
|
agent: Type.String({ minLength: 1, maxLength: 64, description: "Agent role name" }),
|
|
718
735
|
task: Type.String({ minLength: 1, maxLength: limits.taskCharacters, description: "Bounded task for the role" }),
|
|
@@ -727,11 +744,12 @@ function createSubagentParams(limits: Pick<SubagentLimits, "maxTasks" | "taskCha
|
|
|
727
744
|
description: "Thread lifecycle action",
|
|
728
745
|
})),
|
|
729
746
|
threadId: Type.Optional(Type.String({ minLength: 1, maxLength: 128, description: "Stable child thread ID" })),
|
|
730
|
-
message: Type.Optional(Type.String({ minLength: 1, maxLength: 4_000, description: "Bounded steering message" })),
|
|
747
|
+
message: Type.Optional(Type.String({ minLength: 1, maxLength: 4_000, description: "Bounded steering message; only valid with action steer" })),
|
|
731
748
|
all: Type.Optional(Type.Boolean({ description: "Interrupt every active child thread" })),
|
|
732
749
|
agent: Type.Optional(Type.String({ minLength: 1, maxLength: 64, description: "Agent role for single mode" })),
|
|
733
750
|
task: Type.Optional(Type.String({ minLength: 1, maxLength: limits.taskCharacters, description: "Task for single mode" })),
|
|
734
|
-
tasks: Type.Optional(Type.Array(taskSchema, { minItems: 1, maxItems: limits.maxTasks, description:
|
|
751
|
+
tasks: Type.Optional(Type.Array(taskSchema, { minItems: 1, maxItems: limits.maxTasks, description: `Parallel role tasks: read-only batches run concurrently up to ${limits.maxReadConcurrency}; batches with writers use a shared pool by default up to ${limits.maxTasks}. Set writerConcurrency to cap that pool, or set it to 1 to serialize the entire writer-containing batch; concurrent writers share the parent worktree, so callers must avoid file conflicts` })),
|
|
752
|
+
writerConcurrency: Type.Optional(Type.Integer({ minimum: 1, maximum: limits.maxTasks, description: `Optional shared-pool cap for parallel tasks that include writers. Defaults to ${limits.maxTasks}; set 1 to serialize the entire writer-containing batch. Concurrent writers share the parent worktree, so callers must avoid file conflicts` })),
|
|
735
753
|
chain: Type.Optional(Type.Array(chainTaskSchema, { minItems: 1, maxItems: limits.maxTasks, description: "Sequential role tasks; {previous} inserts the prior result" })),
|
|
736
754
|
model: Type.Optional(Type.String({ minLength: 1, maxLength: 256, description: "Model for every task as provider/model; inherit uses each role setting or the active parent" })),
|
|
737
755
|
thinking: Type.Optional(Type.String({ minLength: 1, maxLength: 16, description: "Thinking effort for every task: off, minimal, low, medium, high, xhigh, max, or inherit" })),
|
|
@@ -804,6 +822,7 @@ function statusIcon(status: SubagentStatus): string {
|
|
|
804
822
|
interface ActiveThreadRuntime {
|
|
805
823
|
controller: AbortController;
|
|
806
824
|
handle?: SubagentProcessHandle;
|
|
825
|
+
handles: Set<SubagentProcessHandle>;
|
|
807
826
|
steering: string[];
|
|
808
827
|
restarting: boolean;
|
|
809
828
|
traceCount: number;
|
|
@@ -1029,11 +1048,11 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
|
|
|
1029
1048
|
pi.registerTool({
|
|
1030
1049
|
name: "subagent",
|
|
1031
1050
|
label: "Subagents",
|
|
1032
|
-
description:
|
|
1051
|
+
description: `Spawn and manage named child threads. Children finish naturally. Parallel tasks with write-capable roles use one shared pool by default, up to ${limits.maxTasks}; read-only batches run concurrently up to ${limits.maxReadConcurrency}. Set writerConcurrency to cap the shared pool, or set it to 1 to serialize the entire writer-containing batch. Concurrent writers share the parent worktree, so callers must avoid file conflicts. The message parameter is only valid with action steer. Use action list, inspect, steer, interrupt, collect, and close to manage active and completed handoffs.`,
|
|
1033
1052
|
promptSnippet: "Delegate bounded specialist work to isolated KillerOS subagents",
|
|
1034
1053
|
promptGuidelines: [
|
|
1035
1054
|
"Use subagent for clearly separable specialist work; prefer read-only scout, planner, reviewer, or security roles before a writer.",
|
|
1036
|
-
|
|
1055
|
+
`Parallel tasks with write-capable roles use one shared pool by default because all children share the parent worktree. Set writerConcurrency to cap the pool, or set it to 1 to serialize the entire writer-containing batch; callers remain responsible for file conflicts.`,
|
|
1037
1056
|
"Every child can load relevant skills with read and can use web_search, source_check, fetch_content, and get_search_content for external research.",
|
|
1038
1057
|
"When the user names a model or thinking effort, pass model and thinking separately; use inherit when the active parent or role setting should decide.",
|
|
1039
1058
|
"Keep completed and stopped threads inspectable until the parent explicitly closes them.",
|
|
@@ -1043,6 +1062,9 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
|
|
|
1043
1062
|
|
|
1044
1063
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
1045
1064
|
const action = params.action ?? "spawn";
|
|
1065
|
+
if (params.message !== undefined && action !== "steer") {
|
|
1066
|
+
throw new Error("message is only valid with action steer");
|
|
1067
|
+
}
|
|
1046
1068
|
const parentId = parentThreadId(ctx);
|
|
1047
1069
|
const actionDetails = (selectedThreadId?: string): SubagentDetails => detailsFor(parentId, "single", params.agentScope ?? "user", null, selectedThreadId);
|
|
1048
1070
|
const actionResult = (text: string, selectedThreadId?: string) => {
|
|
@@ -1130,6 +1152,12 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
|
|
|
1130
1152
|
|| hasChain && params.chain!.length === 0) {
|
|
1131
1153
|
throw new Error("Provide exactly one subagent mode: agent + task, tasks, or chain");
|
|
1132
1154
|
}
|
|
1155
|
+
if (params.writerConcurrency !== undefined) {
|
|
1156
|
+
if (!hasParallel) throw new Error("writerConcurrency is only valid with parallel tasks");
|
|
1157
|
+
if (!Number.isSafeInteger(params.writerConcurrency) || params.writerConcurrency < 1 || params.writerConcurrency > limits.maxTasks) {
|
|
1158
|
+
throw new Error(`writerConcurrency must be a positive integer no greater than ${limits.maxTasks}`);
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1133
1161
|
|
|
1134
1162
|
const discovery = discoverAgentRoles(ctx.cwd, scope, ctx.isProjectTrusted(), options);
|
|
1135
1163
|
const roles = new Map(discovery.agents.map((agent) => [agent.name, agent]));
|
|
@@ -1161,10 +1189,22 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
|
|
|
1161
1189
|
? [{ agent: params.agent!, task: params.task! }]
|
|
1162
1190
|
: hasParallel ? params.tasks! : params.chain!;
|
|
1163
1191
|
if (inputs.length > limits.maxTasks) throw new Error(`At most ${limits.maxTasks} subagent tasks are allowed`);
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1192
|
+
const readIndexes = hasParallel
|
|
1193
|
+
? inputs.map((input, index) => ({ input, index })).filter(({ input }) => roles.get(input.agent)!.access === "read")
|
|
1194
|
+
: [];
|
|
1195
|
+
const writerIndexes = hasParallel
|
|
1196
|
+
? inputs.map((input, index) => ({ input, index })).filter(({ input }) => roles.get(input.agent)!.access === "write").map(({ index }) => index)
|
|
1197
|
+
: [];
|
|
1198
|
+
if (params.writerConcurrency !== undefined && hasParallel && writerIndexes.length === 0) {
|
|
1199
|
+
throw new Error("writerConcurrency requires at least one write-capable role");
|
|
1167
1200
|
}
|
|
1201
|
+
const writerConcurrency = params.writerConcurrency ?? limits.maxTasks;
|
|
1202
|
+
const useSharedParallelPool = hasParallel && writerIndexes.length > 0;
|
|
1203
|
+
const executionNote = hasParallel
|
|
1204
|
+
? writerIndexes.length
|
|
1205
|
+
? `Parallel schedule: all tasks run through a shared pool of up to ${writerConcurrency}${params.writerConcurrency === undefined ? " (default)" : ""}; concurrent write-capable tasks share the parent worktree, so callers must avoid file conflicts.`
|
|
1206
|
+
: `Parallel schedule: read-only tasks run concurrently up to ${limits.maxReadConcurrency}.`
|
|
1207
|
+
: undefined;
|
|
1168
1208
|
|
|
1169
1209
|
const inFlight = threads.listAll().filter((thread) => ["queued", "active"].includes(thread.state)).length;
|
|
1170
1210
|
if (inFlight + inputs.length > limits.maxTasks) {
|
|
@@ -1187,7 +1227,7 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
|
|
|
1187
1227
|
const currentResults = results.map(cloneResult);
|
|
1188
1228
|
(onUpdate as ToolUpdate | undefined)?.({
|
|
1189
1229
|
content: [{ type: "text", text: message }],
|
|
1190
|
-
details: { ...board, results: currentResults, aggregateUsage: aggregateUsage(currentResults) },
|
|
1230
|
+
details: { ...board, executionNote, results: currentResults, aggregateUsage: aggregateUsage(currentResults) },
|
|
1191
1231
|
});
|
|
1192
1232
|
};
|
|
1193
1233
|
const runAt = async (index: number, task: string): Promise<void> => {
|
|
@@ -1228,10 +1268,21 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
|
|
|
1228
1268
|
emit();
|
|
1229
1269
|
return;
|
|
1230
1270
|
}
|
|
1271
|
+
const controller = new AbortController();
|
|
1272
|
+
const runtime: ActiveThreadRuntime = {
|
|
1273
|
+
controller,
|
|
1274
|
+
handles: new Set(),
|
|
1275
|
+
steering: [],
|
|
1276
|
+
restarting: false,
|
|
1277
|
+
traceCount: 0,
|
|
1278
|
+
startedAt: Date.now(),
|
|
1279
|
+
};
|
|
1280
|
+
activeRuntimes.set(threadId, runtime);
|
|
1231
1281
|
let sessionDirectory: string;
|
|
1232
1282
|
try {
|
|
1233
1283
|
sessionDirectory = await mkdtemp(path.join(os.tmpdir(), "killeros-subagent-session-"));
|
|
1234
1284
|
} catch (error) {
|
|
1285
|
+
activeRuntimes.delete(threadId);
|
|
1235
1286
|
const message = error instanceof Error ? error.message : String(error);
|
|
1236
1287
|
results[index] = {
|
|
1237
1288
|
...results[index]!,
|
|
@@ -1244,15 +1295,22 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
|
|
|
1244
1295
|
emit();
|
|
1245
1296
|
return;
|
|
1246
1297
|
}
|
|
1247
|
-
const
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1298
|
+
const currentThread = threads.inspect(threadId);
|
|
1299
|
+
if (controller.signal.aborted || threads.isDisposed || currentThread?.state !== "active") {
|
|
1300
|
+
activeRuntimes.delete(threadId);
|
|
1301
|
+
try {
|
|
1302
|
+
await rm(sessionDirectory, { recursive: true, force: true });
|
|
1303
|
+
} catch {
|
|
1304
|
+
// Temporary child session cleanup is best effort before process startup.
|
|
1305
|
+
}
|
|
1306
|
+
const reason = runtime.requestedReason ?? currentThread?.stopReason ?? (threads.isDisposed ? "session_shutdown" : "interrupted");
|
|
1307
|
+
results[index] = { ...results[index]!, status: "cancelled", terminationReason: reason };
|
|
1308
|
+
if (!threads.isDisposed && currentThread?.state === "active") threads.stop(threadId, { reason });
|
|
1309
|
+
savedResults.set(threadId, cloneResult(results[index]!));
|
|
1310
|
+
emit();
|
|
1311
|
+
return;
|
|
1253
1312
|
}
|
|
1254
|
-
const
|
|
1255
|
-
activeRuntimes.set(threadId, runtime);
|
|
1313
|
+
const sessionId = `killeros-${threadId.replace(/[^A-Za-z0-9_.-]/gu, "_")}`;
|
|
1256
1314
|
const agent = roles.get(input.agent)!;
|
|
1257
1315
|
const queuedSteering = initialThread.steering.map((entry) => entry.message);
|
|
1258
1316
|
let currentTask = queuedSteering.length ? buildSteeredTask(task, queuedSteering, limits.taskCharacters) : task;
|
|
@@ -1331,7 +1389,10 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
|
|
|
1331
1389
|
...(limits.quotaUsd === undefined ? {} : { quotaUsd: limits.quotaUsd - usedCost }),
|
|
1332
1390
|
},
|
|
1333
1391
|
timeoutMs: remainingWallTimeMs,
|
|
1334
|
-
onHandle: (handle) => {
|
|
1392
|
+
onHandle: (handle) => {
|
|
1393
|
+
runtime.handle = handle;
|
|
1394
|
+
runtime.handles.add(handle);
|
|
1395
|
+
},
|
|
1335
1396
|
onChange: (changed) => {
|
|
1336
1397
|
results[index] = syncThread(threadId, changed, runtime);
|
|
1337
1398
|
emit();
|
|
@@ -1344,6 +1405,9 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
|
|
|
1344
1405
|
savedResults.set(threadId, cloneResult(runtime.aggregate));
|
|
1345
1406
|
const shouldRestart = runtime.steering.length > 0 && !controller.signal.aborted && (runtime.restarting || next.status === "complete" || next.status === "cancelled");
|
|
1346
1407
|
if (!shouldRestart) break;
|
|
1408
|
+
const previousHandle = runtime.handle;
|
|
1409
|
+
if (previousHandle && !(await waitForConfirmedProcessExit(previousHandle))) break;
|
|
1410
|
+
if (controller.signal.aborted || threads.isDisposed) break;
|
|
1347
1411
|
const steering = runtime.steering.splice(0);
|
|
1348
1412
|
runtime.restarting = false;
|
|
1349
1413
|
runtime.requestedReason = undefined;
|
|
@@ -1358,16 +1422,39 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
|
|
|
1358
1422
|
}
|
|
1359
1423
|
} finally {
|
|
1360
1424
|
activeRuntimes.delete(threadId);
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1425
|
+
const removeSessionDirectory = async (): Promise<void> => {
|
|
1426
|
+
try {
|
|
1427
|
+
await rm(sessionDirectory, { recursive: true, force: true });
|
|
1428
|
+
} catch {
|
|
1429
|
+
// Temporary child session cleanup is best effort after process termination.
|
|
1430
|
+
}
|
|
1431
|
+
};
|
|
1432
|
+
const pendingExits = [...runtime.handles]
|
|
1433
|
+
.filter((handle) => !handle.hasExited)
|
|
1434
|
+
.map((handle) => handle.exited);
|
|
1435
|
+
if (!pendingExits.length) await removeSessionDirectory();
|
|
1436
|
+
else void Promise.all(pendingExits).then(removeSessionDirectory);
|
|
1367
1437
|
}
|
|
1368
1438
|
emit();
|
|
1369
1439
|
};
|
|
1370
1440
|
|
|
1441
|
+
const settleQueued = (reason: string): void => {
|
|
1442
|
+
for (let index = 0; index < results.length; index += 1) {
|
|
1443
|
+
const result = results[index]!;
|
|
1444
|
+
if (result.status !== "queued") continue;
|
|
1445
|
+
const thread = threads.inspect(threadRecords[index]!.id);
|
|
1446
|
+
const alreadyStopped = thread?.state === "stopped";
|
|
1447
|
+
result.status = signal?.aborted || alreadyStopped ? "cancelled" : "failed";
|
|
1448
|
+
result.terminationReason = alreadyStopped
|
|
1449
|
+
? thread.stopReason ?? "interrupted"
|
|
1450
|
+
: signal?.aborted ? "abort" : reason;
|
|
1451
|
+
if (thread?.state === "queued" || thread?.state === "active") {
|
|
1452
|
+
threads.stop(threadRecords[index]!.id, { reason: result.terminationReason });
|
|
1453
|
+
}
|
|
1454
|
+
savedResults.set(threadRecords[index]!.id, cloneResult(result));
|
|
1455
|
+
}
|
|
1456
|
+
};
|
|
1457
|
+
|
|
1371
1458
|
emit(`${mode}: ${results.length} queued`);
|
|
1372
1459
|
if (hasChain) {
|
|
1373
1460
|
let previous = "";
|
|
@@ -1377,33 +1464,26 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
|
|
|
1377
1464
|
if (results[index]!.status !== "complete") break;
|
|
1378
1465
|
previous = results[index]!.output;
|
|
1379
1466
|
}
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
const
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
if (thread?.state === "queued" || thread?.state === "active") {
|
|
1390
|
-
threads.stop(threadRecords[index]!.id, { reason: result.terminationReason });
|
|
1391
|
-
}
|
|
1392
|
-
savedResults.set(threadRecords[index]!.id, cloneResult(result));
|
|
1467
|
+
settleQueued("chain_stopped");
|
|
1468
|
+
} else if (hasParallel) {
|
|
1469
|
+
try {
|
|
1470
|
+
if (useSharedParallelPool) {
|
|
1471
|
+
const indexes = inputs.map((_, index) => index);
|
|
1472
|
+
await mapReadTasks(indexes, writerConcurrency, async (index) => runAt(index, inputs[index]!.task));
|
|
1473
|
+
} else {
|
|
1474
|
+
await mapReadTasks(readIndexes, limits.maxReadConcurrency, async ({ index }) => runAt(index, inputs[index]!.task));
|
|
1475
|
+
for (const index of writerIndexes) await runAt(index, inputs[index]!.task);
|
|
1393
1476
|
}
|
|
1477
|
+
} finally {
|
|
1478
|
+
settleQueued("parallel_stopped");
|
|
1394
1479
|
}
|
|
1395
|
-
} else if (hasParallel) {
|
|
1396
|
-
const readIndexes = inputs.map((input, index) => ({ input, index })).filter(({ input }) => roles.get(input.agent)!.access === "read");
|
|
1397
|
-
const writerIndex = inputs.findIndex((input) => roles.get(input.agent)!.access === "write");
|
|
1398
|
-
await mapReadTasks(readIndexes, limits.maxReadConcurrency, async ({ index }) => runAt(index, inputs[index]!.task));
|
|
1399
|
-
if (writerIndex >= 0) await runAt(writerIndex, inputs[writerIndex]!.task);
|
|
1400
1480
|
} else {
|
|
1401
1481
|
await runAt(0, inputs[0]!.task);
|
|
1402
1482
|
}
|
|
1403
1483
|
|
|
1404
1484
|
const board = detailsFor(parentId, mode, scope, discovery.projectAgentsDir);
|
|
1405
1485
|
const currentResults = results.map(cloneResult);
|
|
1406
|
-
const details: SubagentDetails = { ...board, results: currentResults, aggregateUsage: aggregateUsage(currentResults) };
|
|
1486
|
+
const details: SubagentDetails = { ...board, executionNote, results: currentResults, aggregateUsage: aggregateUsage(currentResults) };
|
|
1407
1487
|
return {
|
|
1408
1488
|
content: [{ type: "text", text: buildToolContent(mode, details.results, limits.toolOutputBytes) }],
|
|
1409
1489
|
details,
|
|
@@ -1414,7 +1494,10 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
|
|
|
1414
1494
|
renderCall(args, theme) {
|
|
1415
1495
|
const scope = args.agentScope ?? "user";
|
|
1416
1496
|
if (args.action && args.action !== "spawn") return new Text(`${theme.fg("toolTitle", theme.bold("threads "))}${theme.fg("accent", args.action)}${theme.fg("dim", args.threadId ? ` · ${args.threadId}` : "")}`, 0, 0);
|
|
1417
|
-
if (args.tasks?.length)
|
|
1497
|
+
if (args.tasks?.length) {
|
|
1498
|
+
const schedule = args.writerConcurrency === undefined ? "parallel default" : `shared pool ${args.writerConcurrency}`;
|
|
1499
|
+
return new Text(`${theme.fg("toolTitle", theme.bold("subagents "))}${theme.fg("accent", `parallel ${args.tasks.length} · ${schedule}`)}${theme.fg("dim", ` · ${scope}`)}`, 0, 0);
|
|
1500
|
+
}
|
|
1418
1501
|
if (args.chain?.length) return new Text(`${theme.fg("toolTitle", theme.bold("subagents "))}${theme.fg("accent", `chain ${args.chain.length}`)}${theme.fg("dim", ` · ${scope}`)}`, 0, 0);
|
|
1419
1502
|
return new Text(`${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", args.agent ?? "…")}${theme.fg("dim", ` · ${scope}`)}`, 0, 0);
|
|
1420
1503
|
},
|
|
@@ -1437,6 +1520,7 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
|
|
|
1437
1520
|
theme.fg("toolTitle", theme.bold(`Done (${board.done.length})`)),
|
|
1438
1521
|
...board.done.map((task) => `${theme.fg(task.state.status === "complete" ? "success" : "warning", `${task.state.label}`)} ${theme.fg("toolTitle", theme.bold(task.agent))}${theme.fg("dim", ` · ${task.id} · ${task.usage.text}`)}`),
|
|
1439
1522
|
];
|
|
1523
|
+
if (details.executionNote) lines.push(theme.fg("dim", details.executionNote));
|
|
1440
1524
|
lines.push(theme.fg("dim", `Total · ${formatUsage(details.aggregateUsage)} · Ctrl+O to expand`));
|
|
1441
1525
|
return new Text(lines.join("\n"), 0, 0);
|
|
1442
1526
|
}
|
|
@@ -1444,6 +1528,7 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
|
|
|
1444
1528
|
const container = new Container();
|
|
1445
1529
|
container.addChild(new Text(theme.fg("toolTitle", theme.bold(`Subagents · ${details.mode}`)), 0, 0));
|
|
1446
1530
|
container.addChild(new Text(theme.fg("dim", `Active ${board.active.length} · Done ${board.done.length} · Controls: Inspect · Steer · Interrupt · Collect · Close`), 0, 0));
|
|
1531
|
+
if (details.executionNote) container.addChild(new Text(theme.fg("dim", details.executionNote), 0, 0));
|
|
1447
1532
|
if (board.selected) {
|
|
1448
1533
|
const inspection = formatThreadInspection(threadBoardRecord(details.results.find((task) => task.id === board.selected!.id)!));
|
|
1449
1534
|
container.addChild(new Spacer(1));
|