@mystilleef/pi-subagent 0.3.1 → 0.5.0

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
@@ -1,38 +1,81 @@
1
1
  # Subagent
2
2
 
3
- This `pi` extension provides a `subagent` tool designed for the
4
- [SPAE Framework](https://github.com/mystilleef/spae-framework).
3
+ `pi-subagent` adds isolated subagent orchestration to
4
+ [Pi](https://github.com/earendil-works/pi). It provides a `subagent`
5
+ tool and `/run` command for delegating work to specialized agents in
6
+ separate child Pi processes. Designed especially for the `SPAE`
7
+ framework, but doesn't require it.
8
+
9
+ ## Action
10
+
11
+ Agents in
12
+ [action](https://raw.githubusercontent.com/mystilleef/pi-subagent/main/assets/parallel-agents-demo.mp4).
5
13
 
6
14
  ## Installation
7
15
 
16
+ **Install from `npm`:**
17
+
8
18
  ```sh
9
19
  pi install npm:@mystilleef/pi-subagent
10
20
  ```
11
21
 
22
+ **Try temporarily without installing:**
23
+
24
+ ```sh
25
+ pi -e npm:@mystilleef/pi-subagent
26
+ ```
27
+
12
28
  ## Features
13
29
 
14
- - **Asynchronous:** Agents always run in background.
15
- - **Parallel:** Run more than one agents simultaneously.
16
- - **Simplicity:** No advanced orchestration workflows.
17
- - **Bloat-free:** No pre-installed agents.
30
+ - **Asynchronous:** Agents run in the background.
31
+ - **Parallel:** Run many agents simultaneously.
32
+ - **Isolated:** Each delegated task receives a separate context window.
33
+ - **Simple:** No complex orchestration workflow required.
34
+ - **Bloat-free:** No bundled agents.
18
35
 
19
36
  ## Usage
20
37
 
21
- Invoke an agent with:
38
+ **Run an agent with an optional task:**
22
39
 
23
40
  ```text
24
41
  /run agent [optional task]
25
42
  ```
26
43
 
27
- Stop running agents with:
44
+ **Examples:**
45
+
46
+ ```text
47
+ /run spec implement google login screen
48
+ /run plan
49
+ /run inspect
50
+ /run build
51
+ /run verify
52
+ ```
53
+
54
+ **Use natural language to launch agents in parallel:**
55
+
56
+ ```text
57
+ use the work agent to write a poem about linux; use the commit agent to make
58
+ commits; use the query agent to summarize the project.
59
+ ```
60
+
61
+ **Show active and completed jobs:**
62
+
63
+ ```text
64
+ /jobs
65
+ ```
66
+
67
+ **Cancel running `subagents`:**
28
68
 
29
69
  ```text
30
70
  /cancel-subagent
31
71
  ```
32
72
 
33
- ## Workflow
73
+ ## _SPAE_ Workflow
34
74
 
35
- The [SPAE Framework](https://github.com/mystilleef/spae-framework) emphasizes a structured workflow.
75
+ `pi-subagent` supports the
76
+ [`SPAE` Framework](https://github.com/mystilleef/spae-framework), but
77
+ doesn't require it. `SPAE` provides pre-built agents and skills for a
78
+ structured workflow.
36
79
 
37
80
  | Phase | Agent | Purpose |
38
81
  | ----- | ------------------------- | --------------------------------------------- |
@@ -42,4 +85,119 @@ The [SPAE Framework](https://github.com/mystilleef/spae-framework) emphasizes a
42
85
  | 4 | `/run build` | Carry out tasks from `PLAN.md` |
43
86
  | 5 | `/run verify` | Verify implementation against `SPEC.md` |
44
87
 
45
- Visit the [SPAE Framework](https://github.com/mystilleef/spae-framework) for pre-packaged agents and their associated skills.
88
+ ## Agent definitions
89
+
90
+ This package ships no agents. Define agents as Markdown files with YAML
91
+ `frontmatter` and a Markdown system prompt body.
92
+
93
+ **Discovery locations:**
94
+
95
+ - User-global agents: `~/.pi/agents/*.md`
96
+ - Project-local agents: nearest `.pi/agents/*.md`
97
+
98
+ **Required `frontmatter`:**
99
+
100
+ ```yaml
101
+ name: review
102
+ description: Review code for correctness and maintainability.
103
+ ```
104
+
105
+ **Optional `frontmatter`:**
106
+
107
+ ```yaml
108
+ tools: read, bash, edit
109
+ skills: code-review
110
+ thinking: medium
111
+ ```
112
+
113
+ **Accepted `thinking` values:**
114
+
115
+ - `off`
116
+ - `minimal`
117
+ - `low`
118
+ - `medium`
119
+ - `high`
120
+ - `xhigh`
121
+
122
+ ## Tool
123
+
124
+ The extension also registers a `subagent` tool for model-driven
125
+ delegation.
126
+
127
+ **Inputs:**
128
+
129
+ - `agent`: agent name.
130
+ - `task`: task prompt for the child agent.
131
+ - `agentScope`: optional lookup scope, one of `user`, `project`, or
132
+ `both`.
133
+ - `debug`: optional flag that includes full child messages in result
134
+ details.
135
+
136
+ ## Security
137
+
138
+ `Subagents` launch child `pi --json` processes. Agents, tools, and
139
+ extensions run with user permissions, so treat agent definitions like
140
+ executable automation.
141
+
142
+ **Trust guidance:**
143
+
144
+ - Review project-local agents before running them.
145
+ - Avoid delegating secrets unless the agent and tools need them.
146
+ - Prefer trusted repositories for shared agent definitions.
147
+ - Remember that child agents can call their configured tools.
148
+
149
+ ## Configuration and limits
150
+
151
+ **Environment variables:**
152
+
153
+ - `PI_SUBAGENT_DEPTH`: nested subagent depth guard. Nested calls stop at
154
+ depth `1`.
155
+ - `PI_SUBAGENT_MAX_OUTPUT_BYTES`: max returned output bytes. Default:
156
+ `50000`.
157
+ - `PI_SUBAGENT_MAX_OUTPUT_LINES`: max returned output lines. Default:
158
+ `500`.
159
+
160
+ ## Troubleshooting
161
+
162
+ **Missing agent:**
163
+
164
+ - Confirm the file lives under `~/.pi/agents/` or the nearest
165
+ `.pi/agents/`.
166
+ - Confirm `frontmatter` includes `name` and `description`.
167
+ - Confirm `/run` uses the `name` value, not the filename.
168
+
169
+ **Project-local agent prompt:**
170
+
171
+ - Pi may request confirmation before loading project-local agents when
172
+ UI context exists.
173
+
174
+ **Nested subagent blocked:**
175
+
176
+ - Nested delegation hits the `PI_SUBAGENT_DEPTH` safety limit.
177
+ - Run the child task directly from the parent session instead.
178
+
179
+ **Truncated output:**
180
+
181
+ - Raise `PI_SUBAGENT_MAX_OUTPUT_BYTES` or
182
+ `PI_SUBAGENT_MAX_OUTPUT_LINES`.
183
+ - Ask the child agent for a shorter summary.
184
+
185
+ ## Development
186
+
187
+ **Install dependencies:**
188
+
189
+ ```sh
190
+ bun install
191
+ ```
192
+
193
+ **Run full verification:**
194
+
195
+ ```sh
196
+ bun verify
197
+ ```
198
+
199
+ **Check npm package contents:**
200
+
201
+ ```sh
202
+ bun pack:smoke
203
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mystilleef/pi-subagent",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "description": "Pi subagent for the SPAE Framework",
5
5
  "author": "Lateef Alabi-Oki <mystilleef@gmail.com>",
6
6
  "license": "MIT",
@@ -8,7 +8,13 @@
8
8
  "type": "git",
9
9
  "url": "git+https://github.com/mystilleef/pi-subagent.git"
10
10
  },
11
+ "bugs": {
12
+ "url": "https://github.com/mystilleef/pi-subagent/issues"
13
+ },
14
+ "homepage": "https://github.com/mystilleef/pi-subagent#readme",
11
15
  "keywords": [
16
+ "pi-package",
17
+ "pi-extension",
12
18
  "pi",
13
19
  "agent",
14
20
  "subagent",
@@ -23,7 +29,8 @@
23
29
  },
24
30
  "type": "module",
25
31
  "engines": {
26
- "bun": ">=1.3.13"
32
+ "bun": ">=1.3.13",
33
+ "node": ">=18"
27
34
  },
28
35
  "files": [
29
36
  "src",
@@ -33,26 +40,37 @@
33
40
  "pi": {
34
41
  "extensions": [
35
42
  "./src/index.ts"
36
- ]
43
+ ],
44
+ "video": "https://raw.githubusercontent.com/mystilleef/pi-subagent/main/assets/parallel-agents-demo.mp4"
45
+ },
46
+ "publishConfig": {
47
+ "access": "public"
37
48
  },
38
49
  "scripts": {
39
- "verify": "bun check && bun test",
40
- "coverage": "bun check && bun test --coverage",
41
- "check": "biome check --fix --unsafe . && tsc --noEmit",
50
+ "verify": "bun fix && bun check && bun test",
51
+ "coverage": "bun fix && bun check && bun test --coverage",
52
+ "check": "biome check . && tsc --noEmit",
53
+ "fix": "biome check --write --unsafe .",
54
+ "pack:smoke": "bun scripts/pack-smoke.ts",
42
55
  "migrate": "biome migrate --write",
43
56
  "release": "sh -c 'npm version \"$1\" -m \"chore(release): %s\" && git push --follow-tags' --"
44
57
  },
45
- "dependencies": {
46
- "@earendil-works/pi-agent-core": "^0.74.0",
47
- "@earendil-works/pi-ai": "^0.74.0",
48
- "@earendil-works/pi-coding-agent": "^0.74.0",
49
- "@earendil-works/pi-tui": "^0.74.0",
50
- "typebox": "^1.1.38"
58
+ "peerDependencies": {
59
+ "@earendil-works/pi-agent-core": "*",
60
+ "@earendil-works/pi-ai": "*",
61
+ "@earendil-works/pi-coding-agent": "*",
62
+ "@earendil-works/pi-tui": "*",
63
+ "typebox": "*"
51
64
  },
52
65
  "devDependencies": {
53
66
  "@biomejs/biome": "^2.4.15",
67
+ "@earendil-works/pi-agent-core": "^0.75.4",
68
+ "@earendil-works/pi-ai": "^0.75.4",
69
+ "@earendil-works/pi-coding-agent": "^0.75.4",
70
+ "@earendil-works/pi-tui": "^0.75.4",
54
71
  "@types/bun": "^1.3.14",
55
- "@types/node": "^25.8.0",
72
+ "@types/node": "^25.9.1",
73
+ "typebox": "^1.1.38",
56
74
  "typescript": "^6.0.3"
57
75
  }
58
76
  }
@@ -15,7 +15,9 @@ export async function cancelSubagentCommandHandler(
15
15
  return;
16
16
  }
17
17
  const options = [
18
- ...jobs.map((job) => `${job.agentName} (${job.requestId})`),
18
+ ...jobs.map(
19
+ (job) => `${job.agentName} ${job.instanceName} (${job.requestId})`,
20
+ ),
19
21
  "All running subagents",
20
22
  ];
21
23
  const selection = await ctx.ui.select("Cancel subagent", options);
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  resetAgentDiscoveryCache,
8
8
  } from "./agent-cache.js";
9
9
  import { cancelSubagentCommandHandler } from "./cancel-command.js";
10
+ import { jobsCommandHandler } from "./jobs-command.js";
10
11
  import { renderSubagentProgress } from "./progress.js";
11
12
  import { renderSubagentResultMessage } from "./run.js";
12
13
  import { runCommandHandler } from "./run-command.js";
@@ -38,6 +39,10 @@ export default function registerSubagentExtension(pi: ExtensionAPI) {
38
39
  "Cancel active /run subagents: /cancel-subagent [requestId|all]",
39
40
  handler: async (args, ctx) => cancelSubagentCommandHandler(ctx, args),
40
41
  });
42
+ pi.registerCommand("jobs", {
43
+ description: "List all /run jobs and their statuses: /jobs",
44
+ handler: async (args, ctx) => jobsCommandHandler(ctx, args),
45
+ });
41
46
  pi.registerTool({
42
47
  name: "subagent",
43
48
  label: "Subagent",
@@ -0,0 +1,164 @@
1
+ const DEFAULT_ADJECTIVES = [
2
+ "able",
3
+ "agile",
4
+ "alert",
5
+ "amber",
6
+ "ample",
7
+ "apt",
8
+ "arctic",
9
+ "avid",
10
+ "bold",
11
+ "brave",
12
+ "bright",
13
+ "brisk",
14
+ "calm",
15
+ "clever",
16
+ "cosmic",
17
+ "crisp",
18
+ "daring",
19
+ "dawn",
20
+ "eager",
21
+ "early",
22
+ "fair",
23
+ "fast",
24
+ "fierce",
25
+ "fine",
26
+ "fresh",
27
+ "gentle",
28
+ "golden",
29
+ "grand",
30
+ "happy",
31
+ "honest",
32
+ "jolly",
33
+ "keen",
34
+ "kind",
35
+ "lively",
36
+ "lucky",
37
+ "merry",
38
+ "mighty",
39
+ "nimble",
40
+ "noble",
41
+ "novel",
42
+ "patient",
43
+ "proud",
44
+ "quick",
45
+ "quiet",
46
+ "rapid",
47
+ "ready",
48
+ "sharp",
49
+ "smart",
50
+ "solid",
51
+ "steady",
52
+ "swift",
53
+ "tidy",
54
+ "vivid",
55
+ "warm",
56
+ "wise",
57
+ ] as const;
58
+
59
+ const DEFAULT_NOUNS = [
60
+ "badger",
61
+ "beacon",
62
+ "bison",
63
+ "brook",
64
+ "cedar",
65
+ "comet",
66
+ "coral",
67
+ "coyote",
68
+ "crane",
69
+ "dolphin",
70
+ "eagle",
71
+ "ember",
72
+ "falcon",
73
+ "finch",
74
+ "forest",
75
+ "fox",
76
+ "gecko",
77
+ "glade",
78
+ "harbor",
79
+ "hawk",
80
+ "heron",
81
+ "island",
82
+ "jaguar",
83
+ "koala",
84
+ "lagoon",
85
+ "lemur",
86
+ "lynx",
87
+ "maple",
88
+ "meadow",
89
+ "otter",
90
+ "panda",
91
+ "panther",
92
+ "pelican",
93
+ "phoenix",
94
+ "puma",
95
+ "raven",
96
+ "reef",
97
+ "river",
98
+ "salmon",
99
+ "sparrow",
100
+ "summit",
101
+ "tiger",
102
+ "valley",
103
+ "violet",
104
+ "walrus",
105
+ "willow",
106
+ "wolf",
107
+ "wren",
108
+ "yak",
109
+ "zephyr",
110
+ ] as const;
111
+
112
+ const usedInstanceNames = new Set<string>();
113
+
114
+ let adjectives: readonly string[] = DEFAULT_ADJECTIVES;
115
+ let nouns: readonly string[] = DEFAULT_NOUNS;
116
+ let randomSource: () => number = Math.random;
117
+
118
+ function normalizeRandomIndex(limit: number): number {
119
+ const value = randomSource();
120
+ if (!Number.isFinite(value)) return 0;
121
+ return Math.min(limit - 1, Math.max(0, Math.floor(value * limit)));
122
+ }
123
+
124
+ function nameAt(index: number): string {
125
+ const adjective = adjectives[Math.floor(index / nouns.length)];
126
+ const noun = nouns[index % nouns.length];
127
+ return `${adjective}-${noun}`;
128
+ }
129
+
130
+ export function generateSubagentInstanceName(): string {
131
+ const capacity = adjectives.length * nouns.length;
132
+ if (usedInstanceNames.size >= capacity) {
133
+ throw new Error(
134
+ "No unused subagent instance names remain for this session.",
135
+ );
136
+ }
137
+ const start = normalizeRandomIndex(capacity);
138
+ for (let offset = 0; offset < capacity; offset += 1) {
139
+ const candidate = nameAt((start + offset) % capacity);
140
+ if (!usedInstanceNames.has(candidate)) {
141
+ usedInstanceNames.add(candidate);
142
+ return candidate;
143
+ }
144
+ }
145
+ throw new Error("No unused subagent instance names remain for this session.");
146
+ }
147
+
148
+ export function resetSubagentInstanceNamesForTest() {
149
+ usedInstanceNames.clear();
150
+ adjectives = DEFAULT_ADJECTIVES;
151
+ nouns = DEFAULT_NOUNS;
152
+ randomSource = Math.random;
153
+ }
154
+
155
+ export function configureSubagentInstanceNamesForTest(options: {
156
+ adjectives?: readonly string[];
157
+ nouns?: readonly string[];
158
+ randomSource?: () => number;
159
+ }) {
160
+ usedInstanceNames.clear();
161
+ adjectives = options.adjectives ?? DEFAULT_ADJECTIVES;
162
+ nouns = options.nouns ?? DEFAULT_NOUNS;
163
+ randomSource = options.randomSource ?? Math.random;
164
+ }
@@ -0,0 +1,41 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ import {
4
+ getAllProgressStates,
5
+ type SubagentProgressState,
6
+ } from "./progress-state.js";
7
+ import { listRunJobs } from "./run-registry.js";
8
+ import { renderRunsBoard } from "./ui.js";
9
+
10
+ export async function jobsCommandHandler(
11
+ ctx: ExtensionCommandContext,
12
+ _args: string,
13
+ ): Promise<void> {
14
+ const activeRequestIds = new Set(listRunJobs().map((j) => j.requestId));
15
+ const active: SubagentProgressState[] = [];
16
+ const completed: SubagentProgressState[] = [];
17
+ for (const s of getAllProgressStates()) {
18
+ if (activeRequestIds.has(s.requestId)) active.push(s);
19
+ else if (s.status !== "running") completed.push(s);
20
+ }
21
+ const all = [...active, ...completed];
22
+ if (all.length === 0) {
23
+ ctx.ui.notify("No /run jobs in this session.");
24
+ return;
25
+ }
26
+ const output = await ctx.ui.custom<string>(
27
+ (_tui, theme, _keybindings, done) => ({
28
+ invalidate() {},
29
+ render(width) {
30
+ const tuiLines = renderRunsBoard(all, theme, width).render(width);
31
+ const notifyWidth = Math.max(1, width - 2);
32
+ const notifyLines = renderRunsBoard(all, theme, notifyWidth).render(
33
+ notifyWidth,
34
+ );
35
+ done(notifyLines.join("\n"));
36
+ return tuiLines;
37
+ },
38
+ }),
39
+ );
40
+ ctx.ui.notify(output);
41
+ }
@@ -1,3 +1,4 @@
1
+ import type { ThemeColor } from "@earendil-works/pi-coding-agent";
1
2
  import {
2
3
  isStatusOnlyFailure,
3
4
  isStatusOnlySuccess,
@@ -9,11 +10,35 @@ import {
9
10
  } from "./normalize.js";
10
11
  import type { SubagentDetails } from "./types.js";
11
12
 
13
+ export type ThemeBg = "toolPendingBg" | "toolSuccessBg" | "toolErrorBg";
14
+
12
15
  export type ProgressStatus = "running" | "success" | "error" | "cancelled";
13
16
 
17
+ export const STATUS_COLOR: Record<ProgressStatus, ThemeColor> = {
18
+ success: "success",
19
+ error: "error",
20
+ cancelled: "error",
21
+ running: "accent",
22
+ };
23
+
24
+ export const STATUS_ICON: Record<ProgressStatus, string> = {
25
+ success: "✓",
26
+ error: "✗",
27
+ cancelled: "⊘",
28
+ running: "⟳",
29
+ };
30
+
31
+ export const STATUS_BG: Record<ProgressStatus, ThemeBg> = {
32
+ success: "toolSuccessBg",
33
+ error: "toolErrorBg",
34
+ cancelled: "toolErrorBg",
35
+ running: "toolPendingBg",
36
+ };
37
+
14
38
  export interface SubagentProgressState {
15
39
  requestId: string;
16
40
  agent: string;
41
+ instanceName?: string;
17
42
  taskPreview: string;
18
43
  status: ProgressStatus;
19
44
  startTime: number;
@@ -34,10 +59,12 @@ export function createProgressState(
34
59
  requestId: string,
35
60
  agent: string,
36
61
  task: string,
62
+ instanceName = requestId,
37
63
  ): void {
38
64
  store.set(requestId, {
39
65
  requestId,
40
66
  agent,
67
+ instanceName,
41
68
  taskPreview: makeTaskPreview(task),
42
69
  status: "running",
43
70
  startTime: Date.now(),
@@ -50,6 +77,9 @@ export function getProgressState(
50
77
  ): SubagentProgressState | undefined {
51
78
  return store.get(requestId);
52
79
  }
80
+ export function getAllProgressStates(): SubagentProgressState[] {
81
+ return [...store.values()].sort((a, b) => b.startTime - a.startTime);
82
+ }
53
83
 
54
84
  export function patchProgressState(
55
85
  requestId: string,
@@ -231,3 +261,53 @@ export function isToolCallPart(part: unknown): part is {
231
261
  typeof maybe.name === "string"
232
262
  );
233
263
  }
264
+
265
+ /**
266
+ * Format a millisecond duration for compact display.
267
+ * Renders sub-minute durations as decimal seconds (`45.2s`),
268
+ * longer durations as minutes and whole seconds (`2m 15s`).
269
+ */
270
+ export function formatElapsed(ms: number): string {
271
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
272
+ const mins = Math.floor(ms / 60000);
273
+ const secs = Math.floor((ms % 60000) / 1000);
274
+ return `${mins}m ${secs}s`;
275
+ }
276
+
277
+ /**
278
+ * Format a raw token count for compact inline display.
279
+ * Values below 1000 rendered as-is. Larger counts use `k`
280
+ * or `M` suffixes with one decimal place, stripping trailing `.0`.
281
+ */
282
+ export function formatTokenCount(count: number): string {
283
+ if (count < 1000) return String(count);
284
+ const unit = count >= 1_000_000 ? "M" : "k";
285
+ const divisor = count >= 1_000_000 ? 1_000_000 : 1000;
286
+ return `${trimTrailingZero((count / divisor).toFixed(1))}${unit}`;
287
+ }
288
+
289
+ function trimTrailingZero(value: string): string {
290
+ return value.endsWith(".0") ? value.slice(0, -2) : value;
291
+ }
292
+
293
+ export function formatContextPercent(state: SubagentProgressState): string {
294
+ const d = state.contextWindowTokens;
295
+ if (!d || d <= 0 || !Number.isFinite(d)) return "--%";
296
+ const n = state.contextTokens;
297
+ if (!n || n <= 0 || !Number.isFinite(n)) return "0%";
298
+ return `${Math.round((n / d) * 100)}%`;
299
+ }
300
+
301
+ /**
302
+ * Format the one-line statistics header for a subagent progress display.
303
+ * Includes tool count, context window usage, and elapsed time.
304
+ * When the subagent is still running (`durationMs` unset), elapsed is
305
+ * computed live from `startTime`.
306
+ *
307
+ * @returns Single line ending in `\n`, e.g. `"3 tools · 45% ctx · 12.3s\n"`
308
+ */
309
+ export function formatHeaderStats(state: SubagentProgressState): string {
310
+ const elapsedMs = state.durationMs ?? Date.now() - state.startTime;
311
+ const toolLabel = state.toolCount === 1 ? "tool" : "tools";
312
+ return `${state.toolCount} ${toolLabel} · ${formatContextPercent(state)} ctx · ${formatElapsed(elapsedMs)}\n`;
313
+ }
package/src/progress.ts CHANGED
@@ -16,36 +16,19 @@
16
16
  * @module progress
17
17
  */
18
18
 
19
- import type { ThemeColor } from "@earendil-works/pi-coding-agent";
20
19
  import type { Component } from "@earendil-works/pi-tui";
21
- import { Text } from "@earendil-works/pi-tui";
20
+ import { Box, Text } from "@earendil-works/pi-tui";
22
21
  import {
22
+ formatHeaderStats,
23
23
  getProgressState,
24
24
  type ProgressStatus,
25
+ STATUS_BG,
26
+ STATUS_COLOR,
27
+ STATUS_ICON,
25
28
  type SubagentProgressState,
29
+ type ThemeBg,
26
30
  } from "./progress-state.js";
27
- import type { SubagentTheme, ThemeBg } from "./ui.js";
28
-
29
- const STATUS_COLOR: Record<ProgressStatus, ThemeColor> = {
30
- success: "success",
31
- error: "error",
32
- cancelled: "error",
33
- running: "accent",
34
- };
35
-
36
- const STATUS_ICON: Record<ProgressStatus, string> = {
37
- success: "✓",
38
- error: "✗",
39
- cancelled: "⊘",
40
- running: "⟳",
41
- };
42
-
43
- const STATUS_BG: Record<ProgressStatus, ThemeBg> = {
44
- success: "toolSuccessBg",
45
- error: "toolErrorBg",
46
- cancelled: "toolErrorBg",
47
- running: "toolPendingBg",
48
- };
31
+ import { formatSubagentTitle, type SubagentTheme } from "./ui.js";
49
32
 
50
33
  export { makeToolPreview } from "./normalize.js";
51
34
  export {
@@ -55,64 +38,21 @@ export {
55
38
  extractProgressFromDetails,
56
39
  failProgressState,
57
40
  finalizeProgressState,
41
+ formatContextPercent,
42
+ formatElapsed,
43
+ formatHeaderStats,
44
+ formatTokenCount,
58
45
  getProgressState,
59
46
  makeTaskPreview,
60
47
  type ProgressStatus,
61
48
  patchProgressState,
62
49
  resetProgressStore,
50
+ STATUS_COLOR,
51
+ STATUS_ICON,
63
52
  type SubagentProgressState,
53
+ type ThemeBg,
64
54
  } from "./progress-state.js";
65
55
 
66
- /**
67
- * Format a millisecond duration for compact display.
68
- * Renders sub-minute durations as decimal seconds (`45.2s`),
69
- * longer durations as minutes and whole seconds (`2m 15s`).
70
- */
71
- export function formatElapsed(ms: number): string {
72
- if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
73
- const mins = Math.floor(ms / 60000);
74
- const secs = Math.floor((ms % 60000) / 1000);
75
- return `${mins}m ${secs}s`;
76
- }
77
-
78
- /**
79
- * Format a raw token count for compact inline display.
80
- * Values below 1000 rendered as-is. Larger counts use `k`
81
- * or `M` suffixes with one decimal place, stripping trailing `.0`.
82
- */
83
- export function formatTokenCount(count: number): string {
84
- if (count < 1000) return String(count);
85
- const unit = count >= 1_000_000 ? "M" : "k";
86
- const divisor = count >= 1_000_000 ? 1_000_000 : 1000;
87
- return `${trimTrailingZero((count / divisor).toFixed(1))}${unit}`;
88
- }
89
-
90
- /**
91
- * Format the one-line statistics header for a subagent progress display.
92
- * Includes tool count, context window usage, and elapsed time.
93
- * When the subagent is still running (`durationMs` unset), elapsed is
94
- * computed live from `startTime`.
95
- *
96
- * @returns Single line ending in `\n`, e.g. `"3 tools · 45% ctx · 12.3s\n"`
97
- */
98
- export function formatHeaderStats(state: SubagentProgressState): string {
99
- const elapsedMs = state.durationMs ?? Date.now() - state.startTime;
100
- const toolLabel = state.toolCount === 1 ? "tool" : "tools";
101
- return `${state.toolCount} ${toolLabel} · ${formatContextPercent(state)} ctx · ${formatElapsed(elapsedMs)}\n`;
102
- }
103
-
104
- function formatContextPercent(state: SubagentProgressState): string {
105
- const d = state.contextWindowTokens;
106
- if (!d || d <= 0 || !Number.isFinite(d)) return "--%";
107
- const n = state.contextTokens;
108
- if (!n || n <= 0 || !Number.isFinite(n)) return "0%";
109
- return `${Math.round((n / d) * 100)}%`;
110
- }
111
-
112
- function trimTrailingZero(value: string): string {
113
- return value.endsWith(".0") ? value.slice(0, -2) : value;
114
- }
115
-
116
56
  /**
117
57
  * Create a live-updating TUI progress component from a pi message.
118
58
  *
@@ -156,11 +96,7 @@ class DynamicSubagentProgressText implements Component {
156
96
  render(width: number): string[] {
157
97
  const state = getProgressState(this.requestId);
158
98
  if (!state) return [];
159
- const text = formatProgressText(this.requestId, this.options, this.theme);
160
- const bg = getProgressBackground(state.status);
161
- return text
162
- ? new Text(text, 1, 1, (line) => this.theme.bg(bg, line)).render(width)
163
- : [];
99
+ return renderProgressBox(state, this.options, this.theme).render(width);
164
100
  }
165
101
  }
166
102
 
@@ -168,44 +104,97 @@ function getProgressBackground(status: ProgressStatus): ThemeBg {
168
104
  return STATUS_BG[status];
169
105
  }
170
106
 
171
- function formatProgressText(
172
- requestId: string,
107
+ function renderProgressBox(
108
+ state: SubagentProgressState,
173
109
  options: { expanded: boolean },
174
110
  theme: SubagentTheme,
175
- ): string | undefined {
176
- const state = getProgressState(requestId);
177
- if (!state) return undefined;
111
+ ): Box {
178
112
  const status = state.status;
179
- const header = `${theme.fg(STATUS_COLOR[status], STATUS_ICON[status])} ${theme.fg("toolTitle", theme.bold(state.agent))} ${theme.fg("dim", `[${status}]`)} ${theme.fg("muted", formatHeaderStats(state))}`;
180
- if (status === "running") {
181
- const toolLine = state.lastToolPreview
182
- ? `\n ${formatRunningToolPreview(state.lastToolPreview, theme)}`
183
- : "";
184
- const taskLine = options.expanded
185
- ? `\n ${theme.fg("dim", state.taskPreview)}`
186
- : "";
187
- return header + toolLine + taskLine;
113
+ const title = formatSubagentTitle(state.agent, state.instanceName, theme);
114
+ const header = `${theme.fg(STATUS_COLOR[status], STATUS_ICON[status])} ${title} ${theme.fg("dim", `[${status}]`)} ${theme.fg("muted", formatHeaderStats(state))}`;
115
+ const box = new Box(1, 1, (line) =>
116
+ theme.bg(getProgressBackground(status), line),
117
+ );
118
+ box.addChild(new Text(header, 0, 0));
119
+ addProgressBody(box, state, options, theme);
120
+ return box;
121
+ }
122
+
123
+ function addProgressBody(
124
+ box: Box,
125
+ state: SubagentProgressState,
126
+ options: { expanded: boolean },
127
+ theme: SubagentTheme,
128
+ ): void {
129
+ const body = makeProgressBody(state, options, theme);
130
+ if (body.length === 0) return;
131
+ for (const line of body) box.addChild(line);
132
+ }
133
+
134
+ function makeProgressBody(
135
+ state: SubagentProgressState,
136
+ options: { expanded: boolean },
137
+ theme: SubagentTheme,
138
+ ): Text[] {
139
+ if (state.status === "running")
140
+ return makeRunningProgressBody(state, options, theme);
141
+ if (state.status === "error" || state.status === "cancelled") {
142
+ return makeStoppedProgressBody(state, options, theme);
188
143
  }
189
- if (status === "error" || status === "cancelled") {
190
- const errorLine = state.errorText
191
- ? `\n ${theme.fg("error", state.errorText)}`
192
- : "";
193
- const taskLine = options.expanded
194
- ? `\n ${theme.fg("dim", state.taskPreview)}`
195
- : "";
196
- return header + errorLine + taskLine;
144
+ if (state.status === "success")
145
+ return makeSuccessProgressBody(state, options, theme);
146
+ return [];
147
+ }
148
+
149
+ function makeRunningProgressBody(
150
+ state: SubagentProgressState,
151
+ options: { expanded: boolean },
152
+ theme: SubagentTheme,
153
+ ): Text[] {
154
+ const body: Text[] = [];
155
+ if (state.lastToolPreview) {
156
+ body.push(
157
+ new Text(formatRunningToolPreview(state.lastToolPreview, theme), 2, 0),
158
+ );
197
159
  }
198
- if (status === "success") {
199
- const output = state.finalOutput?.trim().split("\n")[0] ?? "";
200
- if (!options.expanded) {
201
- return output ? `${header}\n ${theme.fg("toolOutput", output)}` : header;
202
- }
203
- const outputSection = output
204
- ? `\n${theme.fg("muted", "─── Output ───")}\n${theme.fg("toolOutput", output)}`
205
- : `\n${theme.fg("muted", "(no output)")}`;
206
- return `${header}\n ${theme.fg("dim", state.taskPreview)}${outputSection}`;
160
+ if (options.expanded)
161
+ body.push(new Text(theme.fg("dim", state.taskPreview), 2, 0));
162
+ return body;
163
+ }
164
+
165
+ function makeStoppedProgressBody(
166
+ state: SubagentProgressState,
167
+ options: { expanded: boolean },
168
+ theme: SubagentTheme,
169
+ ): Text[] {
170
+ const body: Text[] = [];
171
+ if (state.errorText)
172
+ body.push(new Text(theme.fg("error", state.errorText), 2, 0));
173
+ if (options.expanded)
174
+ body.push(new Text(theme.fg("dim", state.taskPreview), 2, 0));
175
+ return body;
176
+ }
177
+
178
+ function makeSuccessProgressBody(
179
+ state: SubagentProgressState,
180
+ options: { expanded: boolean },
181
+ theme: SubagentTheme,
182
+ ): Text[] {
183
+ const output = state.finalOutput?.trim().split("\n")[0] ?? "";
184
+ if (!options.expanded) {
185
+ return output ? [new Text(theme.fg("toolOutput", output), 2, 0)] : [];
207
186
  }
208
- return header;
187
+ const body = [new Text(theme.fg("dim", state.taskPreview), 2, 0)];
188
+ body.push(
189
+ output
190
+ ? new Text(
191
+ `${theme.fg("muted", "─── Output ───")}\n${theme.fg("toolOutput", output)}`,
192
+ 0,
193
+ 0,
194
+ )
195
+ : new Text(theme.fg("muted", "(no output)"), 0, 0),
196
+ );
197
+ return body;
209
198
  }
210
199
 
211
200
  function formatRunningToolPreview(
@@ -1,6 +1,7 @@
1
1
  export type RunJob = {
2
2
  requestId: string;
3
3
  agentName: string;
4
+ instanceName: string;
4
5
  controller: AbortController;
5
6
  startedAt: number;
6
7
  cancelReason?: string;
@@ -11,12 +11,14 @@ import {
11
11
  discoverAgents,
12
12
  type ThinkingLevel,
13
13
  } from "./agents.js";
14
+ import { generateSubagentInstanceName } from "./instance-name.js";
14
15
  import { runSingleAgent } from "./process.js";
15
16
  import {
16
17
  cancelProgressState,
17
18
  createProgressState,
18
19
  failProgressState,
19
20
  finalizeProgressState,
21
+ getProgressState,
20
22
  } from "./progress.js";
21
23
  import {
22
24
  createSubagentError,
@@ -26,7 +28,12 @@ import {
26
28
  patchProgressFromDetails,
27
29
  sanitizeDetailsForDisplay,
28
30
  } from "./result-details.js";
29
- import { type RunJob, registerRunJob, removeRunJob } from "./run-registry.js";
31
+ import {
32
+ listRunJobs,
33
+ type RunJob,
34
+ registerRunJob,
35
+ removeRunJob,
36
+ } from "./run-registry.js";
30
37
  import { formatSubagentResultForParent } from "./summary.js";
31
38
  import type {
32
39
  OnUpdateCallback,
@@ -167,6 +174,16 @@ function sendSubagentResultMessage(
167
174
  });
168
175
  }
169
176
 
177
+ export function emitCompletionAlert(
178
+ state: ReturnType<typeof getProgressState>,
179
+ ): void {
180
+ if (!state) return;
181
+ if (state.status === "cancelled") return;
182
+ const tty = (process.stdout as { isTTY?: boolean }).isTTY;
183
+ if (!tty) return;
184
+ process.stdout.write("\x07");
185
+ }
186
+
170
187
  async function runSubagentWorker(
171
188
  pi: ExtensionAPI,
172
189
  ctx: ExtensionContext,
@@ -252,11 +269,22 @@ async function runSubagentWorker(
252
269
  } finally {
253
270
  requestProgressRender();
254
271
  removeRunJob(requestId);
272
+ if (listRunJobs().length === 0) {
273
+ const state = getProgressState(requestId);
274
+ if (state) {
275
+ emitCompletionAlert(state);
276
+ }
277
+ }
255
278
  }
256
279
  }
257
280
 
258
281
  export type StartJobResult =
259
- | { kind: "started"; requestId: string; makeDetails: DetailsBuilder }
282
+ | {
283
+ kind: "started";
284
+ requestId: string;
285
+ instanceName: string;
286
+ makeDetails: DetailsBuilder;
287
+ }
260
288
  | { kind: "cancelled"; makeDetails: DetailsBuilder }
261
289
  | { kind: "not_found"; makeDetails: DetailsBuilder };
262
290
 
@@ -266,7 +294,7 @@ export function formatStartJobStatus(
266
294
  ): string {
267
295
  if (result.kind === "not_found") return `Unknown agent: "${agentName}"`;
268
296
  if (result.kind === "cancelled") return "Canceled";
269
- return `Subagent ${agentName} started (job: ${result.requestId})`;
297
+ return `Subagent ${agentName} ${result.instanceName} started (job: ${result.requestId})`;
270
298
  }
271
299
 
272
300
  function needsProjectAgentConfirmation(
@@ -305,6 +333,16 @@ export async function startSubagentJob(
305
333
  );
306
334
  const requested = agents.find((a) => a.name === params.agent);
307
335
  if (!requested) return { kind: "not_found", makeDetails };
336
+ if (hostSignal?.aborted) return { kind: "cancelled", makeDetails };
337
+ const task = params.task?.trim() ?? "";
338
+ if (needsProjectAgentConfirmation(ctx, requested)) {
339
+ const confirmed = await confirmProjectAgentRun(
340
+ ctx,
341
+ requested,
342
+ discovery.projectAgentsDir,
343
+ );
344
+ if (!confirmed) return { kind: "cancelled", makeDetails };
345
+ }
308
346
  if (requested.source === "project") {
309
347
  const userAgents = discoverAgents(ctx.cwd, "user");
310
348
  const hasUserCollision = userAgents.agents.some(
@@ -319,36 +357,34 @@ export async function startSubagentJob(
319
357
  });
320
358
  }
321
359
  }
322
- const task = params.task?.trim() ?? "";
323
- if (needsProjectAgentConfirmation(ctx, requested)) {
324
- const confirmed = await confirmProjectAgentRun(
325
- ctx,
326
- requested,
327
- discovery.projectAgentsDir,
328
- );
329
- if (!confirmed) return { kind: "cancelled", makeDetails };
330
- }
331
360
  const parentModel = ctx.model
332
361
  ? { provider: ctx.model.provider, id: ctx.model.id }
333
362
  : undefined;
334
363
  const parentThinking = pi.getThinkingLevel() as ThinkingLevel;
335
364
  const requestId = crypto.randomUUID();
365
+ const instanceName = generateSubagentInstanceName();
336
366
  const controller = new AbortController();
337
367
  const job: RunJob = registerRunJob({
338
368
  requestId,
339
369
  agentName: params.agent,
370
+ instanceName,
340
371
  controller,
341
372
  startedAt: Date.now(),
342
373
  });
343
374
  const mergedSignal = hostSignal
344
375
  ? AbortSignal.any([hostSignal, job.controller.signal])
345
376
  : job.controller.signal;
346
- createProgressState(requestId, params.agent, task);
377
+ const makeStartedDetails: DetailsBuilder = (results, options) =>
378
+ makeDetails(
379
+ results.map((result) => ({ ...result, instanceName })),
380
+ options,
381
+ );
382
+ createProgressState(requestId, params.agent, task, instanceName);
347
383
  pi.sendMessage({
348
384
  customType: "subagent-progress",
349
385
  content: "",
350
386
  display: true,
351
- details: { requestId },
387
+ details: { agent: params.agent, instanceName, requestId },
352
388
  });
353
389
  const requestProgressRender = createProgressRenderRequester(ctx, requestId);
354
390
  setImmediate(() => {
@@ -366,12 +402,17 @@ export async function startSubagentJob(
366
402
  debug,
367
403
  parentModel,
368
404
  parentThinking,
369
- makeDetails,
405
+ makeStartedDetails,
370
406
  requestId,
371
407
  job,
372
408
  mergedSignal,
373
409
  );
374
410
  });
375
411
  if (mergedSignal.aborted) return { kind: "cancelled", makeDetails };
376
- return { kind: "started", requestId, makeDetails };
412
+ return {
413
+ kind: "started",
414
+ requestId,
415
+ instanceName,
416
+ makeDetails: makeStartedDetails,
417
+ };
377
418
  }
@@ -124,10 +124,13 @@ function sendTreeSignal(
124
124
  sendDirectSignal(proc, signal, state, options);
125
125
  return;
126
126
  }
127
- if (options.killProcessTree) {
128
- options.killProcessTree(proc, signal, platform);
127
+ const markTreeKilled = () => {
129
128
  state.metadata.target = "tree";
130
129
  state.metadata.processTreeKilled = true;
130
+ };
131
+ if (options.killProcessTree) {
132
+ options.killProcessTree(proc, signal, platform);
133
+ markTreeKilled();
131
134
  return;
132
135
  }
133
136
  if (platform !== "win32") {
@@ -137,8 +140,7 @@ function sendTreeSignal(
137
140
  options.killProcessGroup ??
138
141
  ((pid, nextSignal) => process.kill(pid, nextSignal))
139
142
  )(-pid, signal);
140
- state.metadata.target = "tree";
141
- state.metadata.processTreeKilled = true;
143
+ markTreeKilled();
142
144
  return;
143
145
  }
144
146
  if (signal === "SIGKILL") {
@@ -148,8 +150,7 @@ function sendTreeSignal(
148
150
  "/t",
149
151
  "/f",
150
152
  ]);
151
- state.metadata.target = "tree";
152
- state.metadata.processTreeKilled = true;
153
+ markTreeKilled();
153
154
  return;
154
155
  }
155
156
  throw new Error("unsupported tree termination platform");
package/src/types.ts CHANGED
@@ -26,6 +26,7 @@ export interface StreamingProgress {
26
26
 
27
27
  export interface SingleResult {
28
28
  agent: string;
29
+ instanceName?: string;
29
30
  agentSource: "user" | "project" | "unknown";
30
31
  task: string;
31
32
  exitCode: number;
package/src/ui.ts CHANGED
@@ -12,13 +12,20 @@ import {
12
12
  extractSemanticToolTarget,
13
13
  normalizeSummaryValue,
14
14
  } from "./normalize.js";
15
+ import {
16
+ formatContextPercent,
17
+ formatElapsed,
18
+ type ProgressStatus,
19
+ STATUS_BG,
20
+ STATUS_COLOR,
21
+ STATUS_ICON,
22
+ type SubagentProgressState,
23
+ type ThemeBg,
24
+ } from "./progress-state.js";
15
25
  import { hasSubagentFailed } from "./result-details.js";
16
26
  import type { SubagentDetails, UsageStats } from "./types.js";
17
27
 
18
- /**
19
- * Background theme keys for subagent tool status.
20
- */
21
- export type ThemeBg = "toolPendingBg" | "toolSuccessBg" | "toolErrorBg";
28
+ export type { ThemeBg };
22
29
 
23
30
  /**
24
31
  * Abstraction for theme-aware text formatting.
@@ -27,8 +34,35 @@ export type SubagentTheme = {
27
34
  fg: (color: ThemeColor, text: string) => string;
28
35
  bg: (color: ThemeBg, text: string) => string;
29
36
  bold: (text: string) => string;
37
+ italic?: (text: string) => string;
30
38
  };
31
39
 
40
+ const ANSI_ITALIC_ON = "\x1b[3m";
41
+ const ANSI_ITALIC_OFF = "\x1b[23m";
42
+ const ANSI_STRIKETHROUGH_ON = "\x1b[9m";
43
+ const ANSI_STRIKETHROUGH_OFF = "\x1b[29m";
44
+ const ANSI_UNDERLINE_ON = "\x1b[4m";
45
+ const ANSI_UNDERLINE_OFF = "\x1b[24m";
46
+
47
+ function italicText(text: string, theme: SubagentTheme): string {
48
+ return theme.italic
49
+ ? theme.italic(text)
50
+ : `${ANSI_ITALIC_ON}${text}${ANSI_ITALIC_OFF}`;
51
+ }
52
+
53
+ /**
54
+ * Formats the shared subagent title from agent and optional instance name.
55
+ */
56
+ export function formatSubagentTitle(
57
+ agent: string,
58
+ instanceName: string | undefined,
59
+ theme: SubagentTheme,
60
+ ): string {
61
+ const agentSegment = theme.fg("toolTitle", theme.bold(agent));
62
+ if (!instanceName) return agentSegment;
63
+ return `${agentSegment} ${theme.fg("accent", italicText(instanceName, theme))}`;
64
+ }
65
+
32
66
  /**
33
67
  * Formats token counts into human-readable strings (e.g., "1.2k", "1.5M").
34
68
  */
@@ -96,7 +130,7 @@ export function formatResultFooter(
96
130
  parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
97
131
  if (typeof durationMs === "number") parts.push(formatDuration(durationMs));
98
132
  if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
99
- return `\n${parts.join(" · ")}`;
133
+ return parts.join(" · ");
100
134
  }
101
135
 
102
136
  /**
@@ -149,9 +183,10 @@ function makeMarkdownTheme(theme: SubagentTheme): MarkdownTheme {
149
183
  hr: fg("mdHr"),
150
184
  listBullet: fg("mdListBullet"),
151
185
  bold: (text) => theme.bold(text),
152
- italic: (text) => `\x1b[3m${text}\x1b[23m`,
153
- strikethrough: (text) => `\x1b[9m${text}\x1b[29m`,
154
- underline: (text) => `\x1b[4m${text}\x1b[24m`,
186
+ italic: (text) => `${ANSI_ITALIC_ON}${text}${ANSI_ITALIC_OFF}`,
187
+ strikethrough: (text) =>
188
+ `${ANSI_STRIKETHROUGH_ON}${text}${ANSI_STRIKETHROUGH_OFF}`,
189
+ underline: (text) => `${ANSI_UNDERLINE_ON}${text}${ANSI_UNDERLINE_OFF}`,
155
190
  };
156
191
  }
157
192
 
@@ -199,20 +234,168 @@ export function renderSubagentResult(
199
234
  );
200
235
  }
201
236
  const failed = hasSubagentFailed(r);
237
+ const cancelled = r.stopReason === "aborted";
238
+ const resultStatus: ProgressStatus = cancelled
239
+ ? "cancelled"
240
+ : failed
241
+ ? "error"
242
+ : "success";
202
243
  const finalOutput = r.finalOutput ?? getFinalOutput(r.messages ?? []);
203
- const bg = failed ? "toolErrorBg" : "toolSuccessBg";
204
- const box = new Box(1, 1, (line) => theme.bg(bg, line));
244
+ const title = formatSubagentTitle(r.agent, r.instanceName, theme);
205
245
  const bodyText = stripOutcomeLineForResultUi(bodyOverride ?? finalOutput);
206
- if (bodyText) {
207
- box.addChild(
246
+ const usageStr = formatResultFooter(r.usage, r.model, r.durationMs);
247
+ return renderStatusCard(
248
+ {
249
+ status: resultStatus,
250
+ title,
251
+ variant: "full",
252
+ body: bodyText,
253
+ footer: usageStr,
254
+ },
255
+ theme,
256
+ );
257
+ }
258
+
259
+ type StatusCardVariant = "full" | "abridged";
260
+
261
+ type StatusCardOptions = {
262
+ status: ProgressStatus;
263
+ title: string;
264
+ variant: StatusCardVariant;
265
+ metadata?: string;
266
+ body?: string;
267
+ footer?: string;
268
+ };
269
+
270
+ function renderStatusCard(
271
+ options: StatusCardOptions,
272
+ theme: SubagentTheme,
273
+ ): Box {
274
+ const box = new Box(1, 1, (line) =>
275
+ theme.bg(STATUS_BG[options.status], line),
276
+ );
277
+ const icon = theme.fg(
278
+ STATUS_COLOR[options.status],
279
+ STATUS_ICON[options.status],
280
+ );
281
+ const status = theme.fg("dim", `[${options.status}]`);
282
+ const metadata = options.metadata
283
+ ? ` ${theme.fg("muted", options.metadata)}`
284
+ : "";
285
+ box.addChild(new Text(`${icon} ${options.title} ${status}${metadata}`, 0, 0));
286
+ box.addChild(makeStatusCardBody(options, theme));
287
+ if (options.variant === "full" && options.footer)
288
+ box.addChild(new Text(theme.fg("dim", options.footer), 0, 0));
289
+ return box;
290
+ }
291
+
292
+ function makeStatusCardBody(
293
+ options: StatusCardOptions,
294
+ theme: SubagentTheme,
295
+ ): Box {
296
+ const body = new Box(2, options.variant === "full" ? 1 : 0);
297
+ const bodyText = options.body ?? "";
298
+ if (bodyText && options.variant === "full") {
299
+ body.addChild(
208
300
  new Markdown(bodyText, 0, 0, makeMarkdownTheme(theme), {
209
301
  color: (text) => theme.fg("toolOutput", text),
210
302
  }),
211
303
  );
304
+ } else if (bodyText) {
305
+ body.addChild(new Text(theme.fg("toolOutput", bodyText), 0, 0));
212
306
  } else {
213
- box.addChild(new Text(theme.fg("muted", "(no output)"), 0, 0));
307
+ body.addChild(new Text(theme.fg("muted", "(no output)"), 0, 0));
214
308
  }
215
- const usageStr = formatResultFooter(r.usage, r.model, r.durationMs);
216
- if (usageStr) box.addChild(new Text(theme.fg("dim", usageStr), 0, 0));
309
+ return body;
310
+ }
311
+
312
+ const BODY_PREVIEW_MAX = 120;
313
+
314
+ function selectRunsBoardBody(state: SubagentProgressState): string {
315
+ return (
316
+ [state.finalOutput, state.errorText, state.taskPreview].find(
317
+ (c): c is string => typeof c === "string" && c.trim().length > 0,
318
+ ) ?? ""
319
+ );
320
+ }
321
+
322
+ function renderJobCard(
323
+ state: SubagentProgressState,
324
+ theme: SubagentTheme,
325
+ ): Box {
326
+ const title = formatSubagentTitle(state.agent, state.instanceName, theme);
327
+ const elapsed = formatElapsed(
328
+ state.durationMs ?? Date.now() - state.startTime,
329
+ );
330
+ const ctxPercent = formatContextPercent(state);
331
+ const toolLabel = state.toolCount === 1 ? "tool" : "tools";
332
+ const metadata = `${state.toolCount} ${toolLabel} · ${ctxPercent} ctx · ${elapsed}`;
333
+ const bodyText = selectRunsBoardBody(state);
334
+ const preview =
335
+ bodyText.length > BODY_PREVIEW_MAX
336
+ ? `${bodyText.slice(0, BODY_PREVIEW_MAX - 1)}…`
337
+ : bodyText;
338
+ return renderStatusCard(
339
+ {
340
+ status: state.status,
341
+ title,
342
+ variant: "abridged",
343
+ metadata,
344
+ body: preview,
345
+ },
346
+ theme,
347
+ );
348
+ }
349
+
350
+ /** Sort states by startTime descending (newest first). */
351
+ function sortByStartTimeDesc(
352
+ a: SubagentProgressState,
353
+ b: SubagentProgressState,
354
+ ): number {
355
+ return b.startTime - a.startTime;
356
+ }
357
+
358
+ /** Ordered section definitions: label → status filter for the runs board. */
359
+ const BOARD_SECTIONS: [string, ProgressStatus][] = [
360
+ ["ACTIVE", "running"],
361
+ ["FAILED", "error"],
362
+ ["CANCELLED", "cancelled"],
363
+ ["SUCCEEDED", "success"],
364
+ ];
365
+
366
+ /**
367
+ * Renders a unified job board for the `/jobs` command.
368
+ * Jobs render in status-specific sections, each sorted by `startTime` descending.
369
+ * Status icons preserve the existing /jobs contract for running and cancelled jobs.
370
+ */
371
+ export function renderRunsBoard(
372
+ states: SubagentProgressState[],
373
+ theme: SubagentTheme,
374
+ width = 80,
375
+ ): Component {
376
+ if (states.length === 0) {
377
+ return new Text(theme.fg("muted", "No /run jobs in this session."), 0, 0);
378
+ }
379
+ const grouped = new Map<ProgressStatus, SubagentProgressState[]>();
380
+ for (const s of states) {
381
+ const bucket = grouped.get(s.status);
382
+ if (bucket) bucket.push(s);
383
+ else grouped.set(s.status, [s]);
384
+ }
385
+ for (const bucket of grouped.values()) bucket.sort(sortByStartTimeDesc);
386
+ const box = new Box(0, 0);
387
+ const addSection = (
388
+ label: string,
389
+ sectionStates: SubagentProgressState[],
390
+ ) => {
391
+ if (sectionStates.length === 0) return;
392
+ const sectionHeader = `${label} (${sectionStates.length})`;
393
+ const ruler = "─".repeat(Math.max(0, width - sectionHeader.length - 1));
394
+ box.addChild(new Text(theme.fg("dim", `${sectionHeader} ${ruler}`), 0, 0));
395
+ for (const state of sectionStates)
396
+ box.addChild(renderJobCard(state, theme));
397
+ };
398
+ for (const [label, status] of BOARD_SECTIONS)
399
+ addSection(label, grouped.get(status) ?? []);
217
400
  return box;
218
401
  }