killeros 1.5.3 → 1.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,38 @@ All notable changes to KillerOS are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.5.5] - 2026-08-05
8
+
9
+ ### Added
10
+
11
+ - Added live background subagent progress with queued, running, and completed states plus per-child and batch usage.
12
+ - Added inline subagent roles for single, parallel, and chain spawns, bounded their tools to those active for the parent, and kept them scoped to one non-resumable spawn.
13
+ - Added `message` as a single-spawn task alias and visible fallback to `worker` for unknown named roles.
14
+
15
+ ### Changed
16
+
17
+ - Replaced default deterministic context compaction with model-backed summaries at 40% remaining context, preserved manual focus prompts, and disclosed deterministic fallback after model retries fail.
18
+
19
+ ### Fixed
20
+
21
+ - Warned when a settled or failed background subagent handoff cannot reach the parent and pointed users to `list` and `collect` for recovery.
22
+ - Overrode vulnerable transitive `brace-expansion` and `undici` versions with patched releases in the development and CI install tree.
23
+
24
+ ## [1.5.4] - 2026-08-04
25
+
26
+ ### Added
27
+
28
+ - Added named child sessions with `wait` and `resume`, persisted lifecycle records, real `/subagents` controls, empty-response failure, a 30-minute default wall time, bounded process-exit cleanup, and serialized shared-worktree writers.
29
+
30
+ ### Fixed
31
+
32
+ - Added guarded automatic context compaction at 30% remaining, with structured summaries and goal continuation after the compaction is saved.
33
+ - Kept the parent-facing cancellation reason when a steer was already in flight: aborting the parent turn after a steer now reports `abort` on the settled thread and result and no longer triggers a replacement follow-up turn for the cancelled batch.
34
+ - Isolated host update callbacks in child-process and tool telemetry paths so a throwing callback cannot crash the host, strand the result promise, or fail a settled batch.
35
+ - Rejected steering explicitly once 20 messages are pending for a thread instead of silently dropping the oldest steers; task-size overflow is also rejected before mutation, and bounded steering history keeps the earliest messages.
36
+ - `interrupt all` now also stops queued children of the batch, matching `interrupt` on one thread and parent-turn abort, so queued writers cannot run after a stop command.
37
+ - Recreated the subagent thread registry on `session_start`, stopped old children, and fenced old callbacks so embedding hosts that keep the extension instance between sessions can still spawn children without stale follow-ups.
38
+
7
39
  ## [1.5.3] - 2026-08-03
8
40
 
9
41
  ### Fixed
package/Killeros.ts CHANGED
@@ -1,18 +1,27 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { registerSubagentTool } from "./killeros/subagents.ts";
3
- import { registerAliases, registerSlashAutocomplete } from "./killeros/commands.ts";
3
+ import {
4
+ createSubagentControlApi,
5
+ registerAliases,
6
+ registerSlashAutocomplete,
7
+ registerSubagentCommand,
8
+ type SubagentControlApi,
9
+ type SubagentToolLike,
10
+ } from "./killeros/commands.ts";
4
11
  import { registerConcisePrompt } from "./killeros/concise.ts";
12
+ import { registerContextCompaction } from "./killeros/context-compaction.ts";
5
13
  import { registerFooter } from "./killeros/footer.ts";
6
14
  import { registerGoal, registerGoalSettlement } from "./killeros/goals.ts";
7
15
  import { registerLifecycleHooks } from "./killeros/hooks.ts";
8
16
  import { registerInitCommand, registerInitSettlement } from "./killeros/init.ts";
9
17
  import { registerPersonalInstructions } from "./killeros/personal-instructions.ts";
10
18
  import { registerQuestionTool } from "./killeros/question.ts";
11
- import { createGoalRuntime, createInitRuntime } from "./killeros/runtime.ts";
19
+ import { createCompactionRuntime, createGoalRuntime, createInitRuntime } from "./killeros/runtime.ts";
12
20
  import { registerShellUi } from "./killeros/shell-ui.ts";
13
21
  import { registerVariants } from "./killeros/variants.ts";
14
22
 
15
23
  export { CONCISE_SYSTEM_PROMPT, isConcisedEnabled } from "./killeros/concise.ts";
24
+ export { contextPercentRemaining } from "./killeros/context-compaction.ts";
16
25
  export { formatCost, formatContextProgress } from "./killeros/footer.ts";
17
26
  export { executeHook } from "./killeros/hooks.ts";
18
27
  export { INIT_WORKFLOW_PROMPT, writeInitAgentsFile } from "./killeros/init.ts";
@@ -20,18 +29,35 @@ export { INIT_WORKFLOW_PROMPT, writeInitAgentsFile } from "./killeros/init.ts";
20
29
  export default function Killeros(pi: ExtensionAPI): void {
21
30
  const initRuntime = createInitRuntime();
22
31
  const goalRuntime = createGoalRuntime();
32
+ const compactionRuntime = createCompactionRuntime();
23
33
  registerShellUi(pi);
24
34
  registerConcisePrompt(pi);
25
35
  registerGoal(pi, goalRuntime, initRuntime);
26
36
  registerPersonalInstructions(pi, initRuntime);
27
37
  registerQuestionTool(pi);
28
- registerSubagentTool(pi);
38
+ let subagentTool: SubagentToolLike | undefined;
39
+ const registrationPi = new Proxy(pi, {
40
+ get(target, property, receiver) {
41
+ if (property === "registerTool") {
42
+ return (tool: Parameters<ExtensionAPI["registerTool"]>[0]) => {
43
+ if (tool.name === "subagent") subagentTool = tool as unknown as SubagentToolLike;
44
+ return target.registerTool(tool);
45
+ };
46
+ }
47
+ return Reflect.get(target, property, receiver);
48
+ },
49
+ });
50
+ const subagents = registerSubagentTool(registrationPi);
51
+ const subagentControl = (subagents as unknown as SubagentControlApi | undefined)
52
+ ?? (subagentTool ? createSubagentControlApi(subagentTool) : undefined);
53
+ registerSubagentCommand(pi, subagentControl);
29
54
  registerAliases(pi);
30
55
  registerSlashAutocomplete(pi);
31
56
  registerFooter(pi, goalRuntime);
32
57
  registerVariants(pi);
33
58
  registerInitCommand(pi, initRuntime, goalRuntime);
34
59
  registerLifecycleHooks(pi);
35
- registerGoalSettlement(pi, goalRuntime, initRuntime);
60
+ registerContextCompaction(pi, compactionRuntime, goalRuntime);
61
+ registerGoalSettlement(pi, goalRuntime, initRuntime, compactionRuntime);
36
62
  registerInitSettlement(pi, initRuntime);
37
63
  }
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.5.3
36
+ pi install git:github.com/KyrosHendrix/pi-KillerOS@v1.5.4
37
37
  ```
38
38
 
39
39
  Add `-l` to either command for a project-only install. Restart Pi after installing.
@@ -45,6 +45,7 @@ Add `-l` to either command for a project-only install. Restart Pi after installi
45
45
  - Coral Spark activity indicator with Claude-adjacent verbs that advance between agent runs and a quiet hidden-thinking label
46
46
  - Framed multiline editor with Shift+Enter support
47
47
  - Responsive footer with polished model/provider identity, plain-language context, and active goal state remaining; reasoning, Git branch, elapsed time, cost, and path cut down by available width
48
+ - Automatic model-backed context compaction at 40% remaining; active goals continue after the saved summary
48
49
  - `/variants` selector and direct reasoning-level arguments
49
50
  - Codex-style `/goal` for durable long-running objectives with pause, resume, edit, clear, automatic continuation, and explicit completion
50
51
  - Pi-native `subagent` tool with named, inspectable child threads, Markdown roles, explicit read/write boundaries, parent controls, natural completion, and cancellation propagation
@@ -66,6 +67,7 @@ Add `-l` to either command for a project-only install. Restart Pi after installi
66
67
  /goal clear Remove the current goal
67
68
  /variants Open the reasoning-level selector
68
69
  /variants high Set a reasoning level directly
70
+ /subagents Open child-thread selectors in TUI mode
69
71
  /clear Start a new session after confirmation
70
72
  /exit Quit Pi gracefully
71
73
  ```
@@ -97,48 +99,66 @@ KillerOS ships `planner`, `reviewer`, `scout`, and `security` as read-only roles
97
99
 
98
100
  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
101
 
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.
102
+ The tool supports a single `agent` plus `task` or its `message` alias, parallel `tasks`, or a sequential `chain` whose task text may include `{previous}`. `agent` may be a role name or an inline `{name, description, access, tools}` role for one spawn. Inline tools must be active for the parent, and inline role settings are not saved or resumable. Each task may set a `name`; names are unique within the parent session without regard to case and are passed to child Pi as `--name`. Read-only-only batches run concurrently, up to four at a time. Batches with write-capable roles are serialized in the shared worktree with one shared slot. Reader-only batches reject `writerConcurrency` because it does not apply. 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.
101
103
 
102
104
  | Action | Required fields | Allowed optional fields |
103
105
  |---|---|---|
104
- | omitted / `spawn` single | `agent`, `task` | `model`, `thinking`, `agentScope` |
105
- | omitted / `spawn` parallel | `tasks` | `writerConcurrency`, `model`, `thinking`, `agentScope` |
106
- | omitted / `spawn` chain | `chain` | `model`, `thinking`, `agentScope` |
106
+ | omitted / `spawn` single | `agent`, one of `task` or `message` | `name`, `model`, `thinking`, `agentScope` |
107
+ | omitted / `spawn` parallel | `tasks` | per-task `name`, `writerConcurrency`, `model`, `thinking`, `agentScope` |
108
+ | omitted / `spawn` chain | `chain` | per-task `name`, `model`, `thinking`, `agentScope` |
107
109
  | `list` | none | none |
108
110
  | `inspect` | `threadId` | none |
111
+ | `wait` | none | `threadId`, `all: true`, `timeoutMs` |
109
112
  | `steer` | `threadId`, `message` | none |
110
113
  | `interrupt` one | `threadId` | none |
111
114
  | `interrupt` all | `all: true` | none |
112
115
  | `collect` | `threadId` | none |
116
+ | `resume` | `threadId` | `task` |
113
117
  | `close` | `threadId` | none |
114
118
 
115
- The three spawn shapes cannot be mixed. The `message` field is only valid with `action: "steer"`, and lifecycle actions reject spawn fields. KillerOS rejects malformed requests before role discovery, project confirmation, thread creation, or child launch. The TUI shows a parallel or shared-pool schedule only after shape validation; malformed calls show `invalid request` instead of queued work. For example:
119
+ The three spawn shapes cannot be mixed. On a single spawn, `message` aliases `task`; supplying both is invalid. With `action: "steer"`, `message` remains required, and other lifecycle actions reject it. An unknown role name falls back to `worker` on a new spawn and reports that choice in the tool output. The `wait` action defaults to all queued or active children, waits up to 30 seconds by default, and never stops a child when it times out. The `resume` action keeps the same thread ID, name, session ID, and session directory and increments `attempt`; it requires the original named role and rejects inline roles. KillerOS rejects malformed requests before role discovery, project confirmation, thread creation, or child launch. The TUI shows a parallel or shared-pool schedule only after shape validation; malformed calls show `invalid request` instead of queued work. For example:
116
120
 
117
121
  ```json
118
- {"agent":"reviewer","task":"Review the change","model":"provider/model","thinking":"high"}
122
+ {"agent":"reviewer","task":"Review the change","name":"auth-audit","model":"provider/model","thinking":"high"}
119
123
  ```
120
124
 
121
- Spawn returns the generated thread IDs immediately while the children continue in the background. This lets the parent use `list`, `inspect`, `steer`, `interrupt`, `collect`, and `close` in later tool calls. When the batch settles, KillerOS delivers its bounded handoff as a Pi follow-up and triggers the parent turn. A batch cancelled by parent Escape remains inspectable but does not trigger a replacement turn.
125
+ Spawn returns the generated thread IDs immediately while the children continue in the background. This lets the parent use `list`, `inspect`, `wait`, `steer`, `interrupt`, `collect`, `resume`, and `close` in later tool calls. Compact thread records persist through Pi custom session entries. On parent restart, an active record restores as `orphaned`; `close` removes the child session only after confirmed process exit. When the batch settles, KillerOS delivers its bounded handoff as a Pi follow-up and triggers the parent turn. A batch cancelled by parent Escape remains inspectable but does not trigger a replacement turn.
122
126
 
123
- 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. Aborting the originating parent turn stops its queued and active children; explicit `interrupt` actions and session shutdown also terminate active children and escalate after five seconds.
127
+ 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. An empty final assistant response is a failure. The default child wall time is 30 minutes; token and dollar quotas remain opt-in. 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. Aborting the originating parent turn stops its queued and active children; explicit `interrupt` actions and session shutdown also terminate active children and use a bounded 10-second process-exit wait.
128
+
129
+ The command grammar is:
130
+
131
+ ```text
132
+ /subagents
133
+ /subagents list
134
+ /subagents inspect <id-or-name>
135
+ /subagents wait [<id-or-name>] [timeout-ms]
136
+ /subagents steer <id-or-name> <message>
137
+ /subagents interrupt <id-or-name|all>
138
+ /subagents collect <id-or-name>
139
+ /subagents resume <id-or-name> [task]
140
+ /subagents close <id-or-name>
141
+ ```
142
+
143
+ Bare `/subagents` opens TUI selectors. RPC, JSON, and print modes require an explicit verb and never open a UI prompt.
124
144
 
125
145
  ### Thread lifecycle
126
146
 
127
147
  Each delegated task creates a named child thread. Its contract records the parent ID, child ID, role, prompt, model, requested capability boundary, trace, usage, and result state. Roles define the child’s access and tools; they do not own lifecycle controls or grant new filesystem powers. The parent owns scope, waits, inspection, steering, collection, and closure.
128
148
 
129
- 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.
149
+ Threads move through `queued`, `active`, `done`, `failed`, `stopped`, `orphaned`, 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.
130
150
 
131
- 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.
151
+ The parent can inspect a thread’s prompt, role, model, tools, trace, usage, and handoff; wait for one named or ID child or all queued and active children; steer an active or queued thread with a bounded follow-up (at most 20 pending messages; further steering is rejected explicitly until the child restarts or drains the queue); interrupt one child or all active and queued children; collect a concise handoff into parent context; resume a terminal or orphaned child; and close a finished, stopped, or orphaned 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.
132
152
 
133
- 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. Aborting the originating parent turn settles queued work as cancelled and terminates active children; explicit `interrupt` actions and real child-process failures remain visible. Session shutdown also terminates active children and escalates after five seconds.
153
+ A child completes only when it returns usable final assistant text. The default wall time is 30 minutes; token and dollar quotas remain opt-in, while every JSONL record has an 8 MiB parser ceiling. Explicit embedding options can add 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. Aborting the originating parent turn settles queued work as cancelled and terminates active children; explicit `interrupt` actions and real child-process failures remain visible. Session shutdown also terminates active children and waits up to 10 seconds for confirmed process exit.
134
154
 
135
155
  The replacement lifecycle has nine phases:
136
156
 
137
157
  1. **Dispatch:** create a named thread and store its contract before launch.
138
158
  2. **Track:** maintain lifecycle states and Active/Done visibility.
139
159
  3. **Inspect:** keep the trace in the child thread, not the parent context.
140
- 4. **Steer:** append a bounded parent follow-up to an active thread.
141
- 5. **Interrupt:** stop one or all active children while preserving partial work.
160
+ 4. **Steer:** append a bounded parent follow-up to an active or queued thread.
161
+ 5. **Interrupt:** stop one or all active or queued children while preserving partial work.
142
162
  6. **Collect:** return a concise handoff while retaining the expanded trace.
143
163
  7. **Guard:** honor only explicitly configured child resource guards; do not impose a routine turn stop.
144
164
  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.
@@ -150,6 +170,8 @@ KillerOS activates its packaged `killeros` theme when a TUI session starts. Tool
150
170
 
151
171
  KillerOS displays session costs in USD. The footer uses Pi's human-readable model name when available, keeps the provider visually secondary, and renders context as `percent left (tokens)` without a progress bar. When a goal exists, the footer adds its active time or terminal state; at narrow widths, context pressure and goal state take priority.
152
172
 
173
+ KillerOS checks context after each agent turn. At 40% remaining, it starts Pi's model-backed compaction after the current run settles, so the active turn is not aborted. Manual `/compact` uses the same model path and keeps custom focus instructions. If model compaction is unavailable or exhausts its retries, KillerOS uses the disclosed deterministic fallback and warns that repeated compaction can reduce accuracy.
174
+
153
175
  For trusted projects, KillerOS loads `AGENTS.local.md` after Pi's shared repository context. A one-line `@path` or `@~/path` file imports personal guidance from another location.
154
176
 
155
177
  Lifecycle hooks are loaded from `.pi/killeros-hooks.json` at session start. Supported event keys are `tool_call`, `tool_result`, and `agent_settled`; matchers are JavaScript regular expressions over Pi tool names. Hook commands run from the repository root with `KILLEROS_EVENT`, `KILLEROS_TOOL`, and `KILLEROS_PAYLOAD` environment variables. Failed `tool_call` hooks block the tool, while later-event failures notify the user.
@@ -180,7 +202,7 @@ The package manifest lists Pi’s built-in modules as peer dependencies, so npm
180
202
 
181
203
  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.
182
204
 
183
- For release `1.5.0`, publish after the validation checks pass:
205
+ For a release, publish after the validation checks pass:
184
206
 
185
207
  ```bash
186
208
  npm login
@@ -1,5 +1,76 @@
1
- import { type ExtensionAPI, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
1
+ import { type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { AutocompleteItem } from "@earendil-works/pi-tui";
3
+ import { formatThreadControls, type ThreadStatus } from "./subagent-ui.ts";
4
+
5
+ export type SubagentControlAction = "list" | "inspect" | "wait" | "steer" | "interrupt" | "collect" | "resume" | "close";
6
+
7
+ export interface SubagentControlRequest {
8
+ action: SubagentControlAction;
9
+ threadId?: string;
10
+ all?: true;
11
+ message?: string;
12
+ task?: string;
13
+ timeoutMs?: number;
14
+ }
15
+
16
+ interface SubagentControlThread {
17
+ id: string;
18
+ displayName?: string;
19
+ name?: string;
20
+ agent?: string;
21
+ role?: string;
22
+ task?: string;
23
+ prompt?: string;
24
+ status?: string;
25
+ state?: string;
26
+ }
27
+
28
+ export interface SubagentControlDetails {
29
+ results?: readonly SubagentControlThread[];
30
+ threads?: readonly SubagentControlThread[];
31
+ }
32
+
33
+ export interface SubagentControlResult {
34
+ text: string;
35
+ details?: SubagentControlDetails;
36
+ usage?: unknown;
37
+ }
38
+
39
+ export interface SubagentControlApi {
40
+ execute(request: SubagentControlRequest, ctx: ExtensionContext): Promise<SubagentControlResult>;
41
+ }
42
+
43
+ export interface SubagentToolLike {
44
+ name: string;
45
+ execute(
46
+ toolCallId: string,
47
+ params: unknown,
48
+ signal: AbortSignal | undefined,
49
+ onUpdate: undefined,
50
+ ctx: ExtensionContext,
51
+ ): Promise<{
52
+ content?: readonly { type: string; text?: string }[];
53
+ details?: unknown;
54
+ usage?: unknown;
55
+ }>;
56
+ }
57
+
58
+ export function createSubagentControlApi(tool: SubagentToolLike): SubagentControlApi {
59
+ return {
60
+ async execute(request, ctx) {
61
+ const toolRequest = request.action === "interrupt" && request.threadId === "all"
62
+ ? { action: "interrupt", all: true }
63
+ : request;
64
+ const result = await tool.execute("subagents-command", toolRequest, ctx.signal, undefined, ctx);
65
+ const text = result.content?.find((item) => item.type === "text")?.text ?? "";
66
+ return {
67
+ text,
68
+ details: result.details as SubagentControlDetails | undefined,
69
+ usage: result.usage,
70
+ };
71
+ },
72
+ };
73
+ }
3
74
 
4
75
  async function confirmNewSession(ctx: ExtensionCommandContext): Promise<boolean> {
5
76
  if (!ctx.hasUI) return true;
@@ -57,6 +128,7 @@ const BUILTIN_COMMANDS: ReadonlyArray<{ name: string; description: string }> = [
57
128
  const COMMAND_SYNTAX_HINTS: Readonly<Record<string, string>> = {
58
129
  goal: "/goal [objective|clear|edit|pause|resume]",
59
130
  variants: "/variants [level]",
131
+ subagents: "/subagents [list|inspect|wait|steer|interrupt|collect|resume|close] [thread]",
60
132
  model: "/model [provider/model]",
61
133
  "scoped-models": "/scoped-models",
62
134
  login: "/login [provider]",
@@ -79,6 +151,162 @@ function scoreCommandMatch(name: string, prefix: string): number {
79
151
  return 0;
80
152
  }
81
153
 
154
+ const SUBAGENT_COMMAND_USAGE = "/subagents [list|inspect|wait|steer|interrupt|collect|resume|close] [thread]";
155
+
156
+ function subagentCommandError(message: string): Error {
157
+ return new Error(`${message} Usage: ${SUBAGENT_COMMAND_USAGE}`);
158
+ }
159
+
160
+ function parseThreadReference(action: SubagentControlAction, tail: string): string {
161
+ const reference = tail.match(/^(\S+)(?:\s+([\s\S]*))?$/u)?.[1];
162
+ if (!reference) throw subagentCommandError(`/subagents ${action} requires a thread reference.`);
163
+ return reference;
164
+ }
165
+
166
+ function parseExplicitSubagentCommand(args: string): SubagentControlRequest {
167
+ const trimmed = args.trim();
168
+ const match = trimmed.match(/^(\S+)(?:\s+([\s\S]*))?$/u);
169
+ if (!match) throw subagentCommandError("/subagents requires an explicit verb outside TUI.");
170
+ const action = match[1]!.toLocaleLowerCase() as SubagentControlAction;
171
+ const tail = match[2]?.trim() ?? "";
172
+
173
+ if (action === "list") {
174
+ if (tail) throw subagentCommandError("/subagents list does not accept arguments.");
175
+ return { action };
176
+ }
177
+ if (action === "wait") {
178
+ if (!tail) return { action };
179
+ const parts = tail.split(/\s+/u);
180
+ if (parts.length > 2) throw subagentCommandError("/subagents wait accepts one thread reference and one timeout-ms value.");
181
+ if (parts.length === 1 && /^\d+$/u.test(parts[0]!)) {
182
+ return { action, timeoutMs: parseTimeout(parts[0]!) };
183
+ }
184
+ const request: SubagentControlRequest = { action, threadId: parts[0] };
185
+ if (parts[1] !== undefined) request.timeoutMs = parseTimeout(parts[1]);
186
+ return request;
187
+ }
188
+ if (action === "steer") {
189
+ const referenceAndMessage = tail.match(/^(\S+)(?:\s+([\s\S]+))?$/u);
190
+ if (!referenceAndMessage?.[1]) throw subagentCommandError("/subagents steer requires a thread reference.");
191
+ if (!referenceAndMessage[2]?.trim()) throw subagentCommandError("/subagents steer requires a message.");
192
+ return { action, threadId: referenceAndMessage[1], message: referenceAndMessage[2] };
193
+ }
194
+ if (action === "resume") {
195
+ const referenceAndTask = tail.match(/^(\S+)(?:\s+([\s\S]+))?$/u);
196
+ if (!referenceAndTask?.[1]) throw subagentCommandError("/subagents resume requires a thread reference.");
197
+ return {
198
+ action,
199
+ threadId: referenceAndTask[1],
200
+ ...(referenceAndTask[2] ? { task: referenceAndTask[2] } : {}),
201
+ };
202
+ }
203
+ if (action === "inspect" || action === "interrupt" || action === "collect" || action === "close") {
204
+ const threadId = parseThreadReference(action, tail);
205
+ if (tail.slice(threadId.length).trim()) throw subagentCommandError(`/subagents ${action} accepts one thread reference.`);
206
+ return { action, threadId };
207
+ }
208
+ throw subagentCommandError(`Unknown /subagents action ${JSON.stringify(match[1])}.`);
209
+ }
210
+
211
+ function parseTimeout(value: string): number {
212
+ const timeoutMs = Number(value);
213
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 0) {
214
+ throw subagentCommandError("/subagents wait timeout-ms must be a non-negative integer.");
215
+ }
216
+ return timeoutMs;
217
+ }
218
+
219
+ function controlThreads(result: SubagentControlResult): SubagentControlThread[] {
220
+ const details = result.details;
221
+ if (!details) return [];
222
+ const results = [...(details.results ?? [])];
223
+ const threads = [...(details.threads ?? [])];
224
+ const candidates = results.length ? results : threads;
225
+ return candidates.filter((thread) => thread && typeof thread.id === "string" && thread.state !== "closed" && thread.status !== "closed");
226
+ }
227
+
228
+ function threadStatus(thread: SubagentControlThread): ThreadStatus {
229
+ const status = (thread.status ?? thread.state ?? "queued").toLocaleLowerCase();
230
+ if (status === "active" || status === "running") return "running";
231
+ if (status === "done" || status === "complete" || status === "closed") return "complete";
232
+ if (status === "stopped" || status === "cancelled") return "cancelled";
233
+ if (status === "limited") return "limited";
234
+ if (status === "orphaned") return "orphaned";
235
+ if (status === "failed") return "failed";
236
+ return "queued";
237
+ }
238
+
239
+ function threadLabel(thread: SubagentControlThread): string {
240
+ const name = thread.displayName ?? thread.name ?? thread.agent ?? thread.role ?? thread.id;
241
+ return `${name} · ${thread.id} · ${threadStatus(thread)}`;
242
+ }
243
+
244
+ function selectedThread(threads: readonly SubagentControlThread[], labels: readonly string[], choice: string): SubagentControlThread | undefined {
245
+ const index = labels.indexOf(choice);
246
+ if (index >= 0) return threads[index];
247
+ return threads.find((thread) => thread.id === choice || thread.displayName === choice || thread.name === choice);
248
+ }
249
+
250
+ async function executeSubagentControl(
251
+ control: SubagentControlApi | undefined,
252
+ request: SubagentControlRequest,
253
+ ctx: ExtensionCommandContext,
254
+ ): Promise<void> {
255
+ if (!control) throw new Error("Subagent control API is not available.");
256
+ const result = await control.execute(request, ctx);
257
+ if (result?.text) ctx.ui.notify(result.text, "info");
258
+ }
259
+
260
+ async function runTuiSubagentCommand(control: SubagentControlApi | undefined, ctx: ExtensionCommandContext): Promise<void> {
261
+ if (!control) throw new Error("Subagent control API is not available.");
262
+ const listed = await control.execute({ action: "list" }, ctx);
263
+ const threads = controlThreads(listed);
264
+ if (!threads.length) {
265
+ ctx.ui.notify("No child threads.", "info");
266
+ return;
267
+ }
268
+
269
+ const labels = threads.map(threadLabel);
270
+ const selected = await ctx.ui.select("Select a thread", labels);
271
+ if (selected === undefined) return;
272
+ const thread = selectedThread(threads, labels, selected);
273
+ if (!thread) return;
274
+
275
+ const controls = formatThreadControls(threadStatus(thread)).filter((item) => item.enabled);
276
+ const controlLabels = controls.map((item) => item.label);
277
+ const selectedControl = await ctx.ui.select("Select a control", controlLabels);
278
+ if (selectedControl === undefined) return;
279
+ const chosen = controls.find((item) => item.label === selectedControl || item.id === selectedControl);
280
+ if (!chosen) return;
281
+
282
+ const request: SubagentControlRequest = { action: chosen.id, threadId: thread.id };
283
+ if (chosen.id === "steer") {
284
+ const message = await ctx.ui.input("Steer child thread", "Message");
285
+ if (message === undefined || !message.trim()) return;
286
+ request.message = message;
287
+ } else if (chosen.id === "resume") {
288
+ const task = await ctx.ui.input("Resume child thread", "Optional task");
289
+ if (task === undefined) return;
290
+ if (task) request.task = task;
291
+ }
292
+ await executeSubagentControl(control, request, ctx);
293
+ }
294
+
295
+ export function registerSubagentCommand(pi: ExtensionAPI, control?: SubagentControlApi | void): void {
296
+ const api = control && typeof control.execute === "function" ? control : undefined;
297
+ pi.registerCommand("subagents", {
298
+ description: "Inspect and control child threads",
299
+ handler: async (args, ctx) => {
300
+ if (!args.trim()) {
301
+ if (ctx.mode !== "tui") throw subagentCommandError("/subagents requires an explicit verb outside TUI.");
302
+ await runTuiSubagentCommand(api, ctx);
303
+ return;
304
+ }
305
+ await executeSubagentControl(api, parseExplicitSubagentCommand(args), ctx);
306
+ },
307
+ });
308
+ }
309
+
82
310
  export function registerSlashAutocomplete(pi: ExtensionAPI): void {
83
311
  const usage = new Map<string, number>();
84
312
  pi.on("session_start", (_event, ctx) => {
@@ -121,6 +349,15 @@ export function registerSlashAutocomplete(pi: ExtensionAPI): void {
121
349
  }
122
350
  }
123
351
 
352
+ if (!commands.has("subagents")) {
353
+ commands.set("subagents", {
354
+ name: "subagents",
355
+ description: "Inspect and control child threads",
356
+ category: "Extension",
357
+ syntaxHint: COMMAND_SYNTAX_HINTS.subagents,
358
+ });
359
+ }
360
+
124
361
  const ranked = [...commands.values()]
125
362
  .map((command) => ({
126
363
  command,