@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 +169 -11
- package/package.json +31 -13
- package/src/cancel-command.ts +3 -1
- package/src/index.ts +5 -0
- package/src/instance-name.ts +164 -0
- package/src/jobs-command.ts +41 -0
- package/src/progress-state.ts +80 -0
- package/src/progress.ts +100 -111
- package/src/run-registry.ts +1 -0
- package/src/subagent-orchestrator.ts +57 -16
- package/src/termination.ts +7 -6
- package/src/types.ts +1 -0
- package/src/ui.ts +198 -15
package/README.md
CHANGED
|
@@ -1,38 +1,81 @@
|
|
|
1
1
|
# Subagent
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
[
|
|
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
|
|
15
|
-
- **Parallel:** Run
|
|
16
|
-
- **
|
|
17
|
-
- **
|
|
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
|
-
|
|
38
|
+
**Run an agent with an optional task:**
|
|
22
39
|
|
|
23
40
|
```text
|
|
24
41
|
/run agent [optional task]
|
|
25
42
|
```
|
|
26
43
|
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
"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
|
|
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
|
-
"
|
|
46
|
-
"@earendil-works/pi-agent-core": "
|
|
47
|
-
"@earendil-works/pi-ai": "
|
|
48
|
-
"@earendil-works/pi-coding-agent": "
|
|
49
|
-
"@earendil-works/pi-tui": "
|
|
50
|
-
"typebox": "
|
|
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.
|
|
72
|
+
"@types/node": "^25.9.1",
|
|
73
|
+
"typebox": "^1.1.38",
|
|
56
74
|
"typescript": "^6.0.3"
|
|
57
75
|
}
|
|
58
76
|
}
|
package/src/cancel-command.ts
CHANGED
|
@@ -15,7 +15,9 @@ export async function cancelSubagentCommandHandler(
|
|
|
15
15
|
return;
|
|
16
16
|
}
|
|
17
17
|
const options = [
|
|
18
|
-
...jobs.map(
|
|
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
|
+
}
|
package/src/progress-state.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
172
|
-
|
|
107
|
+
function renderProgressBox(
|
|
108
|
+
state: SubagentProgressState,
|
|
173
109
|
options: { expanded: boolean },
|
|
174
110
|
theme: SubagentTheme,
|
|
175
|
-
):
|
|
176
|
-
const state = getProgressState(requestId);
|
|
177
|
-
if (!state) return undefined;
|
|
111
|
+
): Box {
|
|
178
112
|
const status = state.status;
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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 === "
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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 (
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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(
|
package/src/run-registry.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
| {
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
412
|
+
return {
|
|
413
|
+
kind: "started",
|
|
414
|
+
requestId,
|
|
415
|
+
instanceName,
|
|
416
|
+
makeDetails: makeStartedDetails,
|
|
417
|
+
};
|
|
377
418
|
}
|
package/src/termination.ts
CHANGED
|
@@ -124,10 +124,13 @@ function sendTreeSignal(
|
|
|
124
124
|
sendDirectSignal(proc, signal, state, options);
|
|
125
125
|
return;
|
|
126
126
|
}
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
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
|
|
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) =>
|
|
153
|
-
strikethrough: (text) =>
|
|
154
|
-
|
|
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
|
|
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
|
-
|
|
207
|
-
|
|
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
|
-
|
|
307
|
+
body.addChild(new Text(theme.fg("muted", "(no output)"), 0, 0));
|
|
214
308
|
}
|
|
215
|
-
|
|
216
|
-
|
|
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
|
}
|