@ghoulm370/pi-subagent-ui 0.1.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/LICENSE +21 -0
- package/README.md +123 -0
- package/package.json +45 -0
- package/skills/pi-subagent-ui/SKILL.md +70 -0
- package/src/agents.ts +213 -0
- package/src/index.ts +744 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ghoulm370
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# @ghoulm370/pi-subagent-ui
|
|
2
|
+
|
|
3
|
+
A [pi](https://github.com/earendil-works/pi-coding-agent) extension that adds a **`subagent`** tool with live status and TUI widgets. Delegate focused work (review, testing, planning, research, analysis) to specialized subagents with isolated context and persisted child sessions.
|
|
4
|
+
|
|
5
|
+
Supports three run modes: **single**, **parallel** (`tasks`), and **chain** (`chain`, with `{previous}` templating).
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Three orchestration modes**
|
|
10
|
+
- `single` — one agent + one task
|
|
11
|
+
- `parallel` — run many tasks concurrently (configurable, default 3, max 8, up to 16 tasks)
|
|
12
|
+
- `chain` — sequential tasks; each task may embed the previous output via `{previous}`
|
|
13
|
+
- **Multi-source agent discovery** (later sources override earlier ones by name)
|
|
14
|
+
1. pi-web agents from `~/.pi/agent/agents.json` (global defaults)
|
|
15
|
+
2. User markdown agents at `~/.pi/agent/agents/*.md`
|
|
16
|
+
3. Project-local agents at `<cwd>/.pi/agents/*.md` (opt-in via `agentScope`)
|
|
17
|
+
- **Isolated child context** — each subagent runs in its own `AgentSession` with its own resource loader, injected with a "you are a subagent" instruction so it doesn't recurse.
|
|
18
|
+
- **Live status** — TUI widget + status bar + `subagent:*` events for web/TUI consumers.
|
|
19
|
+
- **Persisted & restorable** — runs are written to a `<session>.subagents.json` sidecar and restored on `session_start`, so refreshes/session switches keep history.
|
|
20
|
+
- **Resilient** — per-task timeout, abort propagation, and `stopReason: error/aborted` detection all mark tasks correctly.
|
|
21
|
+
- **Usage accounting** — input/output/cache/cost/turns aggregated per task and per run.
|
|
22
|
+
- **Safety rails** — project-local agents require explicit UI confirmation; the available-agent catalog is injected into context to prevent hallucinated agent names.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm install @ghoulm370/pi-subagent-ui
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Pi auto-discovers the extension via the `pi.extensions` field in `package.json`. No build step — pi loads the TypeScript source directly.
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
Read the **"Available subagents for the `subagent` tool"** list that is injected into context, then call the tool with an exact agent `name` or `id`.
|
|
35
|
+
|
|
36
|
+
### Single
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
subagent({
|
|
40
|
+
agent: "code-reviewer",
|
|
41
|
+
task: "Review the current diff for correctness and edge cases"
|
|
42
|
+
})
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Parallel
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
subagent({
|
|
49
|
+
tasks: [
|
|
50
|
+
{ agent: "researcher", task: "Summarize the market context for X" },
|
|
51
|
+
{ agent: "risk-reviewer", task: "List implementation risks for X" }
|
|
52
|
+
],
|
|
53
|
+
concurrency: 2
|
|
54
|
+
})
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Chain
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
subagent({
|
|
61
|
+
chain: [
|
|
62
|
+
{ agent: "analyst", task: "Analyze the current implementation" },
|
|
63
|
+
{ agent: "planner", task: "Create an execution plan from this analysis:\n{previous}" }
|
|
64
|
+
]
|
|
65
|
+
})
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Per-task overrides
|
|
69
|
+
|
|
70
|
+
Each task item accepts optional `cwd`, `model` (`provider/model-id`), `thinking`, `tools` (allowlist), and `maxRuntimeMs`.
|
|
71
|
+
|
|
72
|
+
## Parameters
|
|
73
|
+
|
|
74
|
+
| Field | Type | Description |
|
|
75
|
+
| --------------------- | --------------------------------- | ------------------------------------------------------ |
|
|
76
|
+
| `agent` + `task` | `string` | Single mode |
|
|
77
|
+
| `tasks` | `TaskItem[]` | Parallel mode |
|
|
78
|
+
| `chain` | `TaskItem[]` | Sequential mode; `{previous}` replaced with prior output |
|
|
79
|
+
| `agentScope` | `"user" \| "project" \| "both"` | Default `"user"` |
|
|
80
|
+
| `confirmProjectAgents`| `boolean` | Default `true`; require UI confirm for project agents |
|
|
81
|
+
| `concurrency` | `number` | Parallel concurrency (default 3, max 8) |
|
|
82
|
+
| `model` | `string` | `provider/model-id` override for single mode |
|
|
83
|
+
| `thinking` | `string` | Thinking-level override |
|
|
84
|
+
| `tools` | `string[]` | Tool allowlist override (default read-only) |
|
|
85
|
+
| `maxRuntimeMs` | `number` | Per-task timeout (default 10 min) |
|
|
86
|
+
|
|
87
|
+
Exactly one mode (`single`, `tasks`, or `chain`) must be provided.
|
|
88
|
+
|
|
89
|
+
## Defining agents
|
|
90
|
+
|
|
91
|
+
### Markdown (user or project)
|
|
92
|
+
|
|
93
|
+
`~/.pi/agent/agents/my-agent.md` or `<repo>/.pi/agents/my-agent.md`:
|
|
94
|
+
|
|
95
|
+
```markdown
|
|
96
|
+
---
|
|
97
|
+
name: code-reviewer
|
|
98
|
+
description: Reviews diffs for correctness and edge cases
|
|
99
|
+
tools: read, grep, find, ls
|
|
100
|
+
model: zenmux/claude-sonnet-4-6
|
|
101
|
+
thinking: medium
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
You are a meticulous code reviewer. Focus on correctness, edge cases,
|
|
105
|
+
and regressions. Return a concise, actionable summary.
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### pi-web JSON
|
|
109
|
+
|
|
110
|
+
Configured through pi-web's `~/.pi/agent/agents.json`.
|
|
111
|
+
|
|
112
|
+
## Events
|
|
113
|
+
|
|
114
|
+
Emitted on the pi event bus for web/TUI consumers:
|
|
115
|
+
|
|
116
|
+
- `subagent:run-created` / `subagent:run-updated` / `subagent:run-completed`
|
|
117
|
+
- `subagent:task-updated`
|
|
118
|
+
- `subagent:session-event` (forwarded child `message_end` / `tool_execution_*`)
|
|
119
|
+
- `subagent:runs-restored` (after sidecar reload on `session_start`)
|
|
120
|
+
|
|
121
|
+
## License
|
|
122
|
+
|
|
123
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ghoulm370/pi-subagent-ui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Pi extension that adds a subagent tool with live status/widgets for pi-web.",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"author": "ghoulm370",
|
|
9
|
+
"main": "./src/index.ts",
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"skills",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"keywords": [
|
|
17
|
+
"pi",
|
|
18
|
+
"pi-coding-agent",
|
|
19
|
+
"pi-extension",
|
|
20
|
+
"subagent",
|
|
21
|
+
"agent-orchestration",
|
|
22
|
+
"pi-web"
|
|
23
|
+
],
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public",
|
|
29
|
+
"registry": "https://registry.npmjs.org/"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@earendil-works/pi-ai": "^0.78.0",
|
|
33
|
+
"@earendil-works/pi-coding-agent": "^0.78.0",
|
|
34
|
+
"@earendil-works/pi-tui": "^0.78.0",
|
|
35
|
+
"typebox": "^1.0.0"
|
|
36
|
+
},
|
|
37
|
+
"pi": {
|
|
38
|
+
"extensions": [
|
|
39
|
+
"./src/index.ts"
|
|
40
|
+
],
|
|
41
|
+
"skills": [
|
|
42
|
+
"./skills"
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pi-subagent-ui
|
|
3
|
+
description: Delegate focused review, testing, planning, research, and analysis work to configured Pi subagents through the `subagent` tool.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Pi Subagent UI
|
|
7
|
+
|
|
8
|
+
Use this skill when specialized or parallel subagent work would improve quality without stuffing the full parent transcript into each child.
|
|
9
|
+
|
|
10
|
+
## Required First Step
|
|
11
|
+
|
|
12
|
+
Before every `subagent` call, read the injected "Available subagents for the `subagent` tool" list. It contains each usable agent's `name`, optional `id`, source, tools/model hints, and description.
|
|
13
|
+
|
|
14
|
+
Use only an exact `name` or `id` from that list. Do not invent generic names such as `pi`, `reviewer`, `tester`, `analyst`, or `planner` unless they appear in the list.
|
|
15
|
+
|
|
16
|
+
## How To Choose
|
|
17
|
+
|
|
18
|
+
- Match the task to the agent description.
|
|
19
|
+
- Prefer a specialized configured agent when its description fits.
|
|
20
|
+
- For generic work, choose the most general configured agent from the list.
|
|
21
|
+
- Use `agentScope: "both"` only when you intentionally want project-local `.pi/agents` definitions included.
|
|
22
|
+
|
|
23
|
+
## How To Invoke
|
|
24
|
+
|
|
25
|
+
Single subagent:
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
subagent({
|
|
29
|
+
agent: "exact-agent-name-or-id",
|
|
30
|
+
task: "Review the current diff for correctness and edge cases"
|
|
31
|
+
})
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Parallel subagents:
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
subagent({
|
|
38
|
+
tasks: [
|
|
39
|
+
{ agent: "exact-agent-name-or-id", task: "Research the current market context" },
|
|
40
|
+
{ agent: "another-exact-agent-name-or-id", task: "Review the implementation risks" }
|
|
41
|
+
],
|
|
42
|
+
concurrency: 2
|
|
43
|
+
})
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Chain mode:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
subagent({
|
|
50
|
+
chain: [
|
|
51
|
+
{ agent: "exact-agent-name-or-id", task: "Analyze the current implementation" },
|
|
52
|
+
{ agent: "another-exact-agent-name-or-id", task: "Create an execution plan from this analysis:\n{previous}" }
|
|
53
|
+
]
|
|
54
|
+
})
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Agent Sources
|
|
58
|
+
|
|
59
|
+
The subagent tool discovers agents from:
|
|
60
|
+
|
|
61
|
+
1. pi-web agents configured in `~/.pi/agent/agents.json`.
|
|
62
|
+
2. User-level markdown agents in `~/.pi/agent/agents/*.md`.
|
|
63
|
+
3. Project-local markdown agents in `.pi/agents/*.md` when `agentScope` includes project.
|
|
64
|
+
|
|
65
|
+
## Guardrails
|
|
66
|
+
|
|
67
|
+
- Keep child prompts concise and task-specific.
|
|
68
|
+
- Ask for concise final outputs.
|
|
69
|
+
- Prefer read-only work unless the user explicitly wants implementation.
|
|
70
|
+
- Never call `subagent` with an agent name that was not listed in the injected available-subagents context.
|
package/src/agents.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent discovery and configuration
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
import * as path from "node:path";
|
|
7
|
+
import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
|
|
9
|
+
export type AgentScope = "user" | "project" | "both";
|
|
10
|
+
export type AgentSource = "user" | "project" | "pi-web";
|
|
11
|
+
|
|
12
|
+
export interface AgentConfig {
|
|
13
|
+
name: string;
|
|
14
|
+
description: string;
|
|
15
|
+
tools?: string[];
|
|
16
|
+
model?: string;
|
|
17
|
+
thinking?: string;
|
|
18
|
+
systemPrompt: string;
|
|
19
|
+
promptMode?: "append" | "replace";
|
|
20
|
+
source: AgentSource;
|
|
21
|
+
filePath: string;
|
|
22
|
+
id?: string;
|
|
23
|
+
resources?: { extensions?: string[]; skills?: string[]; prompts?: string[] };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AgentDiscoveryResult {
|
|
27
|
+
agents: AgentConfig[];
|
|
28
|
+
projectAgentsDir: string | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface PiWebAgentEntry {
|
|
32
|
+
id: string;
|
|
33
|
+
name: string;
|
|
34
|
+
description: string;
|
|
35
|
+
promptMode: "append" | "replace";
|
|
36
|
+
systemPrompt: string;
|
|
37
|
+
toolNames: string[];
|
|
38
|
+
resources?: { extensions?: string[]; skills?: string[]; prompts?: string[] };
|
|
39
|
+
defaultModel?: { provider: string; modelId: string };
|
|
40
|
+
defaultThinkingLevel?: string;
|
|
41
|
+
builtIn?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface PiWebAgentsJson {
|
|
45
|
+
version: number;
|
|
46
|
+
agents: PiWebAgentEntry[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig[] {
|
|
50
|
+
const agents: AgentConfig[] = [];
|
|
51
|
+
|
|
52
|
+
if (!fs.existsSync(dir)) {
|
|
53
|
+
return agents;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let entries: fs.Dirent[];
|
|
57
|
+
try {
|
|
58
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
59
|
+
} catch {
|
|
60
|
+
return agents;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const entry of entries) {
|
|
64
|
+
if (!entry.name.endsWith(".md")) continue;
|
|
65
|
+
if (!entry.isFile() && !entry.isSymbolicLink()) continue;
|
|
66
|
+
|
|
67
|
+
const filePath = path.join(dir, entry.name);
|
|
68
|
+
let content: string;
|
|
69
|
+
try {
|
|
70
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
71
|
+
} catch {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const { frontmatter, body } = parseFrontmatter<Record<string, string>>(content);
|
|
76
|
+
|
|
77
|
+
if (!frontmatter.name || !frontmatter.description) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const tools = frontmatter.tools
|
|
82
|
+
?.split(",")
|
|
83
|
+
.map((t: string) => t.trim())
|
|
84
|
+
.filter(Boolean);
|
|
85
|
+
|
|
86
|
+
agents.push({
|
|
87
|
+
name: frontmatter.name,
|
|
88
|
+
description: frontmatter.description,
|
|
89
|
+
tools: tools && tools.length > 0 ? tools : undefined,
|
|
90
|
+
model: frontmatter.model,
|
|
91
|
+
thinking: frontmatter.thinking,
|
|
92
|
+
systemPrompt: body,
|
|
93
|
+
source,
|
|
94
|
+
filePath,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return agents;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function loadPiWebAgents(agentDir: string): AgentConfig[] {
|
|
102
|
+
const filePath = path.join(agentDir, "agents.json");
|
|
103
|
+
if (!fs.existsSync(filePath)) return [];
|
|
104
|
+
|
|
105
|
+
let data: PiWebAgentsJson;
|
|
106
|
+
try {
|
|
107
|
+
data = JSON.parse(fs.readFileSync(filePath, "utf-8")) as PiWebAgentsJson;
|
|
108
|
+
} catch {
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
if (!data.agents || !Array.isArray(data.agents)) return [];
|
|
112
|
+
|
|
113
|
+
return data.agents.map((agent): AgentConfig => ({
|
|
114
|
+
name: agent.name,
|
|
115
|
+
description: agent.description,
|
|
116
|
+
tools: agent.toolNames && agent.toolNames.length > 0 ? agent.toolNames : undefined,
|
|
117
|
+
model: agent.defaultModel ? `${agent.defaultModel.provider}/${agent.defaultModel.modelId}` : undefined,
|
|
118
|
+
thinking: agent.defaultThinkingLevel,
|
|
119
|
+
systemPrompt: agent.systemPrompt,
|
|
120
|
+
promptMode: agent.promptMode,
|
|
121
|
+
source: "pi-web",
|
|
122
|
+
filePath,
|
|
123
|
+
id: agent.id,
|
|
124
|
+
resources: agent.resources,
|
|
125
|
+
}));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isDirectory(p: string): boolean {
|
|
129
|
+
try {
|
|
130
|
+
return fs.statSync(p).isDirectory();
|
|
131
|
+
} catch {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function findNearestProjectAgentsDir(cwd: string): string | null {
|
|
137
|
+
let currentDir = cwd;
|
|
138
|
+
while (true) {
|
|
139
|
+
const candidate = path.join(currentDir, ".pi", "agents");
|
|
140
|
+
if (isDirectory(candidate)) return candidate;
|
|
141
|
+
|
|
142
|
+
const parentDir = path.dirname(currentDir);
|
|
143
|
+
if (parentDir === currentDir) return null;
|
|
144
|
+
currentDir = parentDir;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult {
|
|
149
|
+
const userDir = path.join(getAgentDir(), "agents");
|
|
150
|
+
const projectAgentsDir = findNearestProjectAgentsDir(cwd);
|
|
151
|
+
const piWebAgents = loadPiWebAgents(getAgentDir());
|
|
152
|
+
|
|
153
|
+
const userAgents = scope === "project" ? [] : loadAgentsFromDir(userDir, "user");
|
|
154
|
+
const projectAgents = scope === "user" || !projectAgentsDir ? [] : loadAgentsFromDir(projectAgentsDir, "project");
|
|
155
|
+
|
|
156
|
+
const agentMap = new Map<string, AgentConfig>();
|
|
157
|
+
|
|
158
|
+
// pi-web agents act as global defaults; local markdown agents override them.
|
|
159
|
+
for (const agent of piWebAgents) agentMap.set(agent.name, agent);
|
|
160
|
+
if (scope === "both" || scope === "user") {
|
|
161
|
+
for (const agent of userAgents) agentMap.set(agent.name, agent);
|
|
162
|
+
}
|
|
163
|
+
if (scope === "both" || scope === "project") {
|
|
164
|
+
for (const agent of projectAgents) agentMap.set(agent.name, agent);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return { agents: Array.from(agentMap.values()), projectAgentsDir };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function formatAgentList(agents: AgentConfig[], maxItems: number): { text: string; remaining: number } {
|
|
171
|
+
if (agents.length === 0) return { text: "none", remaining: 0 };
|
|
172
|
+
const listed = agents.slice(0, maxItems);
|
|
173
|
+
const remaining = agents.length - listed.length;
|
|
174
|
+
return {
|
|
175
|
+
text: listed.map((a) => `${a.name} (${a.source}): ${a.description}`).join("; "),
|
|
176
|
+
remaining,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function agentDisplayName(agent: AgentConfig): string {
|
|
181
|
+
return agent.id ? `${agent.name} [id: ${agent.id}] (${agent.source})` : `${agent.name} (${agent.source})`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function formatAvailableAgents(agents: AgentConfig[]): string {
|
|
185
|
+
return agents.length > 0 ? agents.map(agentDisplayName).join(", ") : "none";
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function optionalAgentFields(agent: AgentConfig): string {
|
|
189
|
+
const parts: string[] = [];
|
|
190
|
+
if (agent.id) parts.push(`id: ${agent.id}`);
|
|
191
|
+
if (agent.model) parts.push(`model: ${agent.model}`);
|
|
192
|
+
if (agent.thinking) parts.push(`thinking: ${agent.thinking}`);
|
|
193
|
+
if (agent.tools?.length) parts.push(`tools: ${agent.tools.join(", ")}`);
|
|
194
|
+
return parts.length > 0 ? ` [${parts.join("; ")}]` : "";
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function formatAgentCatalog(agents: AgentConfig[]): string {
|
|
198
|
+
if (agents.length === 0) return "No subagents are configured.";
|
|
199
|
+
return agents
|
|
200
|
+
.map((agent) => `- name: ${agent.name}\n description: ${agent.description || "(no description)"}\n source: ${agent.source}${optionalAgentFields(agent)}`)
|
|
201
|
+
.join("\n");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function resolveAgent(agents: AgentConfig[], requested: string | undefined): AgentConfig | undefined {
|
|
205
|
+
const raw = requested?.trim();
|
|
206
|
+
if (!raw) return undefined;
|
|
207
|
+
const normalized = raw.toLowerCase();
|
|
208
|
+
const exact = agents.find((agent) => agent.name === raw || agent.id === raw);
|
|
209
|
+
if (exact) return exact;
|
|
210
|
+
const caseInsensitive = agents.find((agent) => agent.name.toLowerCase() === normalized || agent.id?.toLowerCase() === normalized);
|
|
211
|
+
if (caseInsensitive) return caseInsensitive;
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,744 @@
|
|
|
1
|
+
import type { AgentSessionEvent, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
createAgentSession,
|
|
4
|
+
DefaultResourceLoader,
|
|
5
|
+
getAgentDir,
|
|
6
|
+
getMarkdownTheme,
|
|
7
|
+
SessionManager,
|
|
8
|
+
} from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
10
|
+
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
11
|
+
import { Type } from "typebox";
|
|
12
|
+
import { type AgentConfig, type AgentScope, discoverAgents, formatAgentCatalog, formatAvailableAgents, resolveAgent } from "./agents.ts";
|
|
13
|
+
|
|
14
|
+
const DEFAULT_CONCURRENCY = 3;
|
|
15
|
+
const MAX_CONCURRENCY = 8;
|
|
16
|
+
const MAX_TASKS = 16;
|
|
17
|
+
const DEFAULT_RUNTIME_MS = 10 * 60 * 1000;
|
|
18
|
+
const DEFAULT_READONLY_TOOLS = ["read", "grep", "find", "ls"];
|
|
19
|
+
const FINAL_OUTPUT_CAP = 24 * 1024;
|
|
20
|
+
|
|
21
|
+
type RunMode = "single" | "parallel" | "chain";
|
|
22
|
+
type TaskStatus = "queued" | "starting" | "running" | "completed" | "failed" | "aborted";
|
|
23
|
+
type RunStatus = "queued" | "running" | "completed" | "failed" | "aborted";
|
|
24
|
+
|
|
25
|
+
interface UsageStats {
|
|
26
|
+
input: number;
|
|
27
|
+
output: number;
|
|
28
|
+
cacheRead: number;
|
|
29
|
+
cacheWrite: number;
|
|
30
|
+
cost: number;
|
|
31
|
+
turns: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface TaskInput {
|
|
35
|
+
id?: string;
|
|
36
|
+
agent: string;
|
|
37
|
+
task: string;
|
|
38
|
+
cwd?: string;
|
|
39
|
+
model?: string;
|
|
40
|
+
thinking?: string;
|
|
41
|
+
tools?: string[];
|
|
42
|
+
maxRuntimeMs?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface TaskSnapshot {
|
|
46
|
+
id: string;
|
|
47
|
+
runId: string;
|
|
48
|
+
index: number;
|
|
49
|
+
agent: string;
|
|
50
|
+
task: string;
|
|
51
|
+
cwd: string;
|
|
52
|
+
status: TaskStatus;
|
|
53
|
+
agentSource?: "user" | "project" | "pi-web" | "unknown";
|
|
54
|
+
sessionId?: string;
|
|
55
|
+
sessionFile?: string;
|
|
56
|
+
model?: string;
|
|
57
|
+
thinking?: string;
|
|
58
|
+
tools?: string[];
|
|
59
|
+
startedAt?: number;
|
|
60
|
+
endedAt?: number;
|
|
61
|
+
currentTool?: { toolCallId: string; toolName: string; args: unknown; startedAt: number };
|
|
62
|
+
usage: UsageStats;
|
|
63
|
+
finalText?: string;
|
|
64
|
+
error?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface RunSnapshot {
|
|
68
|
+
id: string;
|
|
69
|
+
parentSessionId?: string;
|
|
70
|
+
parentToolCallId: string;
|
|
71
|
+
mode: RunMode;
|
|
72
|
+
status: RunStatus;
|
|
73
|
+
createdAt: number;
|
|
74
|
+
startedAt?: number;
|
|
75
|
+
endedAt?: number;
|
|
76
|
+
concurrency: number;
|
|
77
|
+
tasks: TaskSnapshot[];
|
|
78
|
+
aggregateUsage: UsageStats;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface ToolDetails {
|
|
82
|
+
run: RunSnapshot;
|
|
83
|
+
agents: Array<{ name: string; description: string; source: string; filePath: string; id?: string }>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface ChildFailure {
|
|
87
|
+
status: Extract<TaskStatus, "failed" | "aborted">;
|
|
88
|
+
message: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function newId(prefix: string): string {
|
|
92
|
+
return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function emptyUsage(): UsageStats {
|
|
96
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function aggregateUsage(tasks: TaskSnapshot[]): UsageStats {
|
|
100
|
+
const total = emptyUsage();
|
|
101
|
+
for (const task of tasks) {
|
|
102
|
+
total.input += task.usage.input;
|
|
103
|
+
total.output += task.usage.output;
|
|
104
|
+
total.cacheRead += task.usage.cacheRead;
|
|
105
|
+
total.cacheWrite += task.usage.cacheWrite;
|
|
106
|
+
total.cost += task.usage.cost;
|
|
107
|
+
total.turns += task.usage.turns;
|
|
108
|
+
}
|
|
109
|
+
return total;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function truncateText(text: string, max = FINAL_OUTPUT_CAP): string {
|
|
113
|
+
if (Buffer.byteLength(text, "utf8") <= max) return text;
|
|
114
|
+
let out = text.slice(0, max);
|
|
115
|
+
while (Buffer.byteLength(out, "utf8") > max) out = out.slice(0, -1);
|
|
116
|
+
return `${out}\n\n[Output truncated. Full child session is available in the session file.]`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function previewValue(value: unknown, max = 1024): unknown {
|
|
120
|
+
if (typeof value === "string") return truncateText(value, max);
|
|
121
|
+
try {
|
|
122
|
+
return truncateText(JSON.stringify(value), max);
|
|
123
|
+
} catch {
|
|
124
|
+
return String(value);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function getFirstText(message: any): string {
|
|
129
|
+
for (const part of message?.content ?? []) {
|
|
130
|
+
if (part?.type === "text" && typeof part.text === "string") return part.text;
|
|
131
|
+
}
|
|
132
|
+
return "";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function getParentSessionFile(ctx: ExtensionContext): string | undefined {
|
|
136
|
+
try {
|
|
137
|
+
return ctx.sessionManager.getSessionFile?.();
|
|
138
|
+
} catch {
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function getAssistantFailure(message: any): ChildFailure | undefined {
|
|
144
|
+
if (message?.role !== "assistant") return undefined;
|
|
145
|
+
if (message.stopReason === "error") {
|
|
146
|
+
return { status: "failed", message: message.errorMessage || "Subagent failed with an assistant error." };
|
|
147
|
+
}
|
|
148
|
+
if (message.stopReason === "aborted") {
|
|
149
|
+
return { status: "aborted", message: message.errorMessage || "Subagent was aborted." };
|
|
150
|
+
}
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function getLastAssistantFailure(messages: any[] | undefined): ChildFailure | undefined {
|
|
155
|
+
for (const message of [...(messages ?? [])].reverse()) {
|
|
156
|
+
const failure = getAssistantFailure(message);
|
|
157
|
+
if (failure) return failure;
|
|
158
|
+
if (message?.role === "assistant") return undefined;
|
|
159
|
+
}
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function makeChildFailureError(failure: ChildFailure): Error {
|
|
164
|
+
const error = new Error(failure.message);
|
|
165
|
+
(error as Error & { subagentStatus?: ChildFailure["status"] }).subagentStatus = failure.status;
|
|
166
|
+
return error;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function updateUsageFromMessage(task: TaskSnapshot, message: any): void {
|
|
170
|
+
if (message?.role !== "assistant") return;
|
|
171
|
+
task.usage.turns += 1;
|
|
172
|
+
const usage = message.usage;
|
|
173
|
+
if (!usage) return;
|
|
174
|
+
task.usage.input += usage.input ?? 0;
|
|
175
|
+
task.usage.output += usage.output ?? 0;
|
|
176
|
+
task.usage.cacheRead += usage.cacheRead ?? 0;
|
|
177
|
+
task.usage.cacheWrite += usage.cacheWrite ?? 0;
|
|
178
|
+
task.usage.cost += usage.cost?.total ?? usage.cost ?? 0;
|
|
179
|
+
if (message.model && !task.model) task.model = message.model;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function formatUsage(usage: UsageStats): string {
|
|
183
|
+
const parts: string[] = [];
|
|
184
|
+
if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
|
|
185
|
+
if (usage.input) parts.push(`in ${usage.input}`);
|
|
186
|
+
if (usage.output) parts.push(`out ${usage.output}`);
|
|
187
|
+
if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
|
|
188
|
+
return parts.join(" · ");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function statusIcon(status: TaskStatus | RunStatus): string {
|
|
192
|
+
if (status === "completed") return "✓";
|
|
193
|
+
if (status === "failed") return "✗";
|
|
194
|
+
if (status === "aborted") return "⏹";
|
|
195
|
+
if (status === "queued") return "○";
|
|
196
|
+
return "⏳";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function renderWidgetLines(run: RunSnapshot): string[] {
|
|
200
|
+
const done = run.tasks.filter((t) => ["completed", "failed", "aborted"].includes(t.status)).length;
|
|
201
|
+
const lines = [`Subagents ${statusIcon(run.status)} ${run.mode}: ${done}/${run.tasks.length} done`];
|
|
202
|
+
for (const task of run.tasks.slice(0, 8)) {
|
|
203
|
+
const tool = task.currentTool ? ` · ${task.currentTool.toolName}` : "";
|
|
204
|
+
lines.push(`${statusIcon(task.status)} ${task.agent}: ${task.status}${tool}`);
|
|
205
|
+
}
|
|
206
|
+
if (run.tasks.length > 8) lines.push(`… +${run.tasks.length - 8} more`);
|
|
207
|
+
return lines;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function makeSummary(run: RunSnapshot): string {
|
|
211
|
+
const succeeded = run.tasks.filter((t) => t.status === "completed").length;
|
|
212
|
+
const failed = run.tasks.filter((t) => t.status === "failed").length;
|
|
213
|
+
const aborted = run.tasks.filter((t) => t.status === "aborted").length;
|
|
214
|
+
const lines = [`Subagents ${run.mode} finished: ${succeeded}/${run.tasks.length} succeeded${failed ? `, ${failed} failed` : ""}${aborted ? `, ${aborted} aborted` : ""}.`];
|
|
215
|
+
const usage = formatUsage(run.aggregateUsage);
|
|
216
|
+
if (usage) lines.push(`Usage: ${usage}`);
|
|
217
|
+
for (const task of run.tasks) {
|
|
218
|
+
const heading = `\n## ${task.agent} ${statusIcon(task.status)}`;
|
|
219
|
+
const session = task.sessionId ? `\nSession: ${task.sessionId}${task.sessionFile ? ` (${task.sessionFile})` : ""}` : "";
|
|
220
|
+
const body = task.error ? `Error: ${task.error}` : truncateText(task.finalText || "(no output)");
|
|
221
|
+
lines.push(`${heading}\n${body}${session}`);
|
|
222
|
+
}
|
|
223
|
+
return lines.join("\n");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function cloneRun(run: RunSnapshot): RunSnapshot {
|
|
227
|
+
return JSON.parse(JSON.stringify(run)) as RunSnapshot;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function parseModelRef(ref: string | undefined): { provider: string; modelId: string } | undefined {
|
|
231
|
+
if (!ref) return undefined;
|
|
232
|
+
const index = ref.indexOf("/");
|
|
233
|
+
if (index <= 0 || index === ref.length - 1) return undefined;
|
|
234
|
+
return { provider: ref.slice(0, index), modelId: ref.slice(index + 1) };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function makeAgentCatalogContext(cwd: string, scope: AgentScope = "user"): string {
|
|
238
|
+
const discovery = discoverAgents(cwd, scope);
|
|
239
|
+
return [
|
|
240
|
+
"Available subagents for the `subagent` tool:",
|
|
241
|
+
formatAgentCatalog(discovery.agents),
|
|
242
|
+
"",
|
|
243
|
+
"Rules:",
|
|
244
|
+
"- Use only an exact `name` or `id` from this list in subagent calls.",
|
|
245
|
+
"- Do not invent agent names such as `pi`, `reviewer`, `tester`, or `analyst` unless they appear above.",
|
|
246
|
+
"- Choose agents by matching the task to each agent description. If multiple tasks are similar (e.g., all are search, research, or analysis), use the SAME most suitable agent for all of them — do NOT spread similar tasks across different agents just because they are available.",
|
|
247
|
+
"- Use `agentScope: \"both\"` only when intentionally including project-local `.pi/agents` definitions.",
|
|
248
|
+
].join("\n");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function appendContextMessage<TMessage>(messages: TMessage[], text: string): TMessage[] {
|
|
252
|
+
return [
|
|
253
|
+
...messages,
|
|
254
|
+
{
|
|
255
|
+
role: "user",
|
|
256
|
+
content: [{ type: "text", text }],
|
|
257
|
+
timestamp: Date.now(),
|
|
258
|
+
} as TMessage,
|
|
259
|
+
];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function normalizeTask(input: TaskInput, runId: string, index: number, defaultCwd: string): TaskSnapshot {
|
|
263
|
+
return {
|
|
264
|
+
id: input.id ?? `task_${index + 1}`,
|
|
265
|
+
runId,
|
|
266
|
+
index,
|
|
267
|
+
agent: input.agent,
|
|
268
|
+
task: input.task,
|
|
269
|
+
cwd: input.cwd ?? defaultCwd,
|
|
270
|
+
status: "queued",
|
|
271
|
+
model: input.model,
|
|
272
|
+
thinking: input.thinking,
|
|
273
|
+
tools: input.tools,
|
|
274
|
+
usage: emptyUsage(),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function mapWithConcurrency<T>(items: T[], concurrency: number, fn: (item: T, index: number) => Promise<void>): Promise<void> {
|
|
279
|
+
let next = 0;
|
|
280
|
+
const workers = Array.from({ length: Math.max(1, Math.min(concurrency, items.length)) }, async () => {
|
|
281
|
+
while (next < items.length) {
|
|
282
|
+
const index = next++;
|
|
283
|
+
await fn(items[index], index);
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
await Promise.all(workers);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
class SubagentManager {
|
|
290
|
+
private runs = new Map<string, RunSnapshot>();
|
|
291
|
+
private eventSeq = 0;
|
|
292
|
+
|
|
293
|
+
constructor(private readonly pi: ExtensionAPI) {}
|
|
294
|
+
|
|
295
|
+
listRuns(): RunSnapshot[] {
|
|
296
|
+
return Array.from(this.runs.values()).sort((a, b) => b.createdAt - a.createdAt);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
clearRuns(): void {
|
|
300
|
+
this.runs.clear();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async restoreFromSidecar(ctx: ExtensionContext): Promise<void> {
|
|
304
|
+
const parentFile = getParentSessionFile(ctx);
|
|
305
|
+
if (!parentFile) return;
|
|
306
|
+
const sidecar = parentFile.replace(/\.jsonl$/, ".subagents.json");
|
|
307
|
+
let runs: RunSnapshot[] | undefined;
|
|
308
|
+
try {
|
|
309
|
+
const { readFileSync, existsSync } = await import("fs");
|
|
310
|
+
if (!existsSync(sidecar)) return;
|
|
311
|
+
const raw = JSON.parse(readFileSync(sidecar, "utf-8"));
|
|
312
|
+
if (!Array.isArray(raw)) return;
|
|
313
|
+
runs = raw as RunSnapshot[];
|
|
314
|
+
} catch {
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
let added = 0;
|
|
318
|
+
for (const run of runs ?? []) {
|
|
319
|
+
if (!run?.id || this.runs.has(run.id)) continue;
|
|
320
|
+
this.runs.set(run.id, run);
|
|
321
|
+
added += 1;
|
|
322
|
+
}
|
|
323
|
+
if (added > 0) {
|
|
324
|
+
this.emit("subagent:runs-restored", { count: added });
|
|
325
|
+
if (ctx.hasUI) {
|
|
326
|
+
const latest = this.listRuns()[0];
|
|
327
|
+
if (latest) {
|
|
328
|
+
ctx.ui.setStatus("subagents", `subagents: restored ${added} run${added > 1 ? "s" : ""}`);
|
|
329
|
+
ctx.ui.setWidget("subagents", renderWidgetLines(latest), { placement: "aboveEditor" });
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private emit(type: string, payload: Record<string, unknown>): void {
|
|
336
|
+
this.pi.events.emit(type, { type, timestamp: Date.now(), ...payload });
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private updateRun(run: RunSnapshot, ctx?: ExtensionContext, onUpdate?: (partial: any) => void): void {
|
|
340
|
+
run.aggregateUsage = aggregateUsage(run.tasks);
|
|
341
|
+
this.runs.set(run.id, run);
|
|
342
|
+
this.emit("subagent:run-updated", { run: cloneRun(run) });
|
|
343
|
+
if (ctx?.hasUI) {
|
|
344
|
+
ctx.ui.setStatus("subagents", `subagents: ${run.tasks.filter((t) => t.status === "running" || t.status === "starting").length} running`);
|
|
345
|
+
ctx.ui.setWidget("subagents", renderWidgetLines(run), { placement: "aboveEditor" });
|
|
346
|
+
}
|
|
347
|
+
onUpdate?.({ content: [{ type: "text", text: renderWidgetLines(run).join("\n") }], details: { run: cloneRun(run) } });
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private updateTask(run: RunSnapshot, task: TaskSnapshot, patch: Partial<TaskSnapshot>, ctx: ExtensionContext, onUpdate?: (partial: any) => void): void {
|
|
351
|
+
Object.assign(task, patch);
|
|
352
|
+
this.emit("subagent:task-updated", { runId: run.id, taskId: task.id, patch, task: { ...task } });
|
|
353
|
+
this.updateRun(run, ctx, onUpdate);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
private async runChild(
|
|
357
|
+
run: RunSnapshot,
|
|
358
|
+
task: TaskSnapshot,
|
|
359
|
+
input: TaskInput,
|
|
360
|
+
agents: AgentConfig[],
|
|
361
|
+
ctx: ExtensionContext,
|
|
362
|
+
signal: AbortSignal | undefined,
|
|
363
|
+
onUpdate?: (partial: any) => void,
|
|
364
|
+
): Promise<void> {
|
|
365
|
+
const requestedAgent = task.agent;
|
|
366
|
+
const agent = resolveAgent(agents, requestedAgent);
|
|
367
|
+
if (!agent) {
|
|
368
|
+
this.updateTask(run, task, {
|
|
369
|
+
status: "failed",
|
|
370
|
+
error: `Unknown agent: ${requestedAgent}. Available agents: ${formatAvailableAgents(agents)}`,
|
|
371
|
+
endedAt: Date.now(),
|
|
372
|
+
agentSource: "unknown",
|
|
373
|
+
}, ctx, onUpdate);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
this.updateTask(run, task, {
|
|
378
|
+
status: "starting",
|
|
379
|
+
agent: agent.name,
|
|
380
|
+
startedAt: Date.now(),
|
|
381
|
+
agentSource: agent.source,
|
|
382
|
+
model: input.model ?? agent.model,
|
|
383
|
+
thinking: input.thinking ?? agent.thinking,
|
|
384
|
+
tools: input.tools ?? agent.tools ?? DEFAULT_READONLY_TOOLS,
|
|
385
|
+
}, ctx, onUpdate);
|
|
386
|
+
|
|
387
|
+
let child: Awaited<ReturnType<typeof createAgentSession>>["session"] | undefined;
|
|
388
|
+
let unsubscribe: (() => void) | undefined;
|
|
389
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
390
|
+
let abortListener: (() => void) | undefined;
|
|
391
|
+
let pendingFailure: ChildFailure | undefined;
|
|
392
|
+
let failChildEnd: ((error: Error) => void) | undefined;
|
|
393
|
+
let childEndResolve: (() => void) | undefined;
|
|
394
|
+
|
|
395
|
+
try {
|
|
396
|
+
const modelRef = parseModelRef(input.model ?? agent.model);
|
|
397
|
+
const model = modelRef ? ctx.modelRegistry.find(modelRef.provider, modelRef.modelId) : undefined;
|
|
398
|
+
if (modelRef && !model) throw new Error(`Model not found: ${modelRef.provider}/${modelRef.modelId}`);
|
|
399
|
+
|
|
400
|
+
const subagentInstruction = "You are running as a subagent. Do not call subagent/delegation tools unless the parent explicitly asks you to. Return a concise final answer for the parent agent.";
|
|
401
|
+
const isPiWebAgent = agent.source === "pi-web";
|
|
402
|
+
|
|
403
|
+
const loaderOptions: ConstructorParameters<typeof DefaultResourceLoader>[0] = {
|
|
404
|
+
cwd: task.cwd,
|
|
405
|
+
agentDir: getAgentDir(),
|
|
406
|
+
noExtensions: !isPiWebAgent,
|
|
407
|
+
additionalExtensionPaths: isPiWebAgent ? agent.resources?.extensions : undefined,
|
|
408
|
+
additionalSkillPaths: isPiWebAgent ? agent.resources?.skills : undefined,
|
|
409
|
+
additionalPromptTemplatePaths: isPiWebAgent ? agent.resources?.prompts : undefined,
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
if (isPiWebAgent && agent.promptMode === "replace") {
|
|
413
|
+
loaderOptions.systemPrompt = agent.systemPrompt.trim();
|
|
414
|
+
loaderOptions.appendSystemPrompt = [subagentInstruction];
|
|
415
|
+
} else {
|
|
416
|
+
const appendPrompt = [agent.systemPrompt.trim(), subagentInstruction].filter(Boolean).join("\n\n");
|
|
417
|
+
loaderOptions.appendSystemPromptOverride = (base) => [...base, appendPrompt];
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const loader = new DefaultResourceLoader(loaderOptions);
|
|
421
|
+
await loader.reload();
|
|
422
|
+
|
|
423
|
+
const created = await createAgentSession({
|
|
424
|
+
cwd: task.cwd,
|
|
425
|
+
resourceLoader: loader,
|
|
426
|
+
sessionManager: SessionManager.create(task.cwd, undefined, { parentSession: getParentSessionFile(ctx) }),
|
|
427
|
+
model,
|
|
428
|
+
thinkingLevel: (input.thinking ?? agent.thinking) as any,
|
|
429
|
+
tools: input.tools ?? agent.tools ?? DEFAULT_READONLY_TOOLS,
|
|
430
|
+
modelRegistry: ctx.modelRegistry,
|
|
431
|
+
});
|
|
432
|
+
child = created.session;
|
|
433
|
+
child.setSessionName?.(`subagent: ${agent.name}`);
|
|
434
|
+
this.updateTask(run, task, { status: "running", sessionId: child.sessionId, sessionFile: child.sessionFile }, ctx, onUpdate);
|
|
435
|
+
|
|
436
|
+
const childFailurePromise = new Promise<never>((_, reject) => {
|
|
437
|
+
failChildEnd = reject;
|
|
438
|
+
});
|
|
439
|
+
const childEndPromise = new Promise<void>((resolve) => {
|
|
440
|
+
childEndResolve = resolve;
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
unsubscribe = child.subscribe((event: AgentSessionEvent) => {
|
|
444
|
+
if (event.type === "message_end" || event.type === "tool_execution_start" || event.type === "tool_execution_end") {
|
|
445
|
+
this.emit("subagent:session-event", {
|
|
446
|
+
runId: run.id,
|
|
447
|
+
taskId: task.id,
|
|
448
|
+
sessionId: child?.sessionId,
|
|
449
|
+
sessionFile: child?.sessionFile,
|
|
450
|
+
seq: ++this.eventSeq,
|
|
451
|
+
event: { type: event.type },
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (event.type === "tool_execution_start") {
|
|
456
|
+
this.updateTask(run, task, {
|
|
457
|
+
currentTool: { toolCallId: (event as any).toolCallId, toolName: (event as any).toolName, args: previewValue((event as any).args), startedAt: Date.now() },
|
|
458
|
+
}, ctx, onUpdate);
|
|
459
|
+
}
|
|
460
|
+
if (event.type === "tool_execution_end") {
|
|
461
|
+
this.updateTask(run, task, { currentTool: undefined }, ctx, onUpdate);
|
|
462
|
+
}
|
|
463
|
+
if (event.type === "message_end") {
|
|
464
|
+
const message = (event as any).message;
|
|
465
|
+
updateUsageFromMessage(task, message);
|
|
466
|
+
const text = getFirstText(message);
|
|
467
|
+
if (message?.role === "assistant" && text) task.finalText = truncateText(text);
|
|
468
|
+
pendingFailure = getAssistantFailure(message);
|
|
469
|
+
this.updateRun(run, ctx, onUpdate);
|
|
470
|
+
}
|
|
471
|
+
if (event.type === "agent_end") {
|
|
472
|
+
const willRetry = Boolean((event as any).willRetry);
|
|
473
|
+
const failure = getLastAssistantFailure((event as any).messages);
|
|
474
|
+
if (failure && !willRetry) {
|
|
475
|
+
pendingFailure = failure;
|
|
476
|
+
failChildEnd?.(makeChildFailureError(failure));
|
|
477
|
+
} else if (!willRetry) {
|
|
478
|
+
childEndResolve?.();
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
if (signal) {
|
|
484
|
+
const listener = () => void child?.abort();
|
|
485
|
+
signal.addEventListener("abort", listener, { once: true });
|
|
486
|
+
abortListener = () => signal.removeEventListener("abort", listener);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const maxRuntimeMs = input.maxRuntimeMs ?? DEFAULT_RUNTIME_MS;
|
|
490
|
+
const promptPromise = child.prompt(task.task, { source: "extension" as any });
|
|
491
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
492
|
+
timeout = setTimeout(() => reject(new Error(`Subagent timed out after ${maxRuntimeMs}ms`)), maxRuntimeMs);
|
|
493
|
+
});
|
|
494
|
+
await Promise.race([promptPromise, childFailurePromise, childEndPromise, timeoutPromise]);
|
|
495
|
+
if (timeout) clearTimeout(timeout);
|
|
496
|
+
pendingFailure ??= getLastAssistantFailure(child.messages as any[]);
|
|
497
|
+
if (pendingFailure) throw makeChildFailureError(pendingFailure);
|
|
498
|
+
|
|
499
|
+
const finalText = task.finalText || truncateText(child.messages.map(getFirstText).filter(Boolean).at(-1) || "");
|
|
500
|
+
this.updateTask(run, task, { status: "completed", finalText, currentTool: undefined, endedAt: Date.now() }, ctx, onUpdate);
|
|
501
|
+
} catch (err) {
|
|
502
|
+
if (timeout) clearTimeout(timeout);
|
|
503
|
+
const aborted = signal?.aborted;
|
|
504
|
+
const subagentStatus = (err as Error & { subagentStatus?: ChildFailure["status"] })?.subagentStatus;
|
|
505
|
+
try { await child?.abort(); } catch { /* ignore */ }
|
|
506
|
+
this.updateTask(run, task, {
|
|
507
|
+
status: aborted ? "aborted" : subagentStatus ?? "failed",
|
|
508
|
+
error: err instanceof Error ? err.message : String(err),
|
|
509
|
+
currentTool: undefined,
|
|
510
|
+
endedAt: Date.now(),
|
|
511
|
+
}, ctx, onUpdate);
|
|
512
|
+
} finally {
|
|
513
|
+
abortListener?.();
|
|
514
|
+
unsubscribe?.();
|
|
515
|
+
child?.dispose();
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
async run(params: any, toolCallId: string, signal: AbortSignal | undefined, onUpdate: ((partial: any) => void) | undefined, ctx: ExtensionContext): Promise<ToolDetails> {
|
|
520
|
+
const agentScope: AgentScope = params.agentScope ?? "user";
|
|
521
|
+
const discovery = discoverAgents(ctx.cwd, agentScope);
|
|
522
|
+
const agents = discovery.agents;
|
|
523
|
+
|
|
524
|
+
const hasChain = (params.chain?.length ?? 0) > 0;
|
|
525
|
+
const hasTasks = (params.tasks?.length ?? 0) > 0;
|
|
526
|
+
const hasSingle = Boolean(params.agent && params.task);
|
|
527
|
+
if (Number(hasChain) + Number(hasTasks) + Number(hasSingle) !== 1) {
|
|
528
|
+
throw new Error(`Provide exactly one subagent mode. Available agents: ${agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none"}`);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const requested = new Set<string>();
|
|
532
|
+
if (hasSingle) requested.add(params.agent);
|
|
533
|
+
for (const t of params.tasks ?? []) requested.add(t.agent);
|
|
534
|
+
for (const t of params.chain ?? []) requested.add(t.agent);
|
|
535
|
+
|
|
536
|
+
if ((agentScope === "project" || agentScope === "both") && (params.confirmProjectAgents ?? true) && ctx.hasUI) {
|
|
537
|
+
const projectAgents = [...requested]
|
|
538
|
+
.map((name) => resolveAgent(agents, name))
|
|
539
|
+
.filter((a): a is AgentConfig => a?.source === "project");
|
|
540
|
+
if (projectAgents.length > 0) {
|
|
541
|
+
const ok = await ctx.ui.confirm(
|
|
542
|
+
"Run project-local subagents?",
|
|
543
|
+
`Agents: ${projectAgents.map((a) => a.name).join(", ")}\nSource: ${discovery.projectAgentsDir}\n\nProject agents are repo-controlled. Continue only for trusted repositories.`,
|
|
544
|
+
);
|
|
545
|
+
if (!ok) throw new Error("Canceled: project-local subagents not approved.");
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const mode: RunMode = hasChain ? "chain" : hasTasks ? "parallel" : "single";
|
|
550
|
+
const runId = newId("run");
|
|
551
|
+
const inputs: TaskInput[] = hasSingle
|
|
552
|
+
? [{ agent: params.agent, task: params.task, cwd: params.cwd, model: params.model, thinking: params.thinking, tools: params.tools, maxRuntimeMs: params.maxRuntimeMs }]
|
|
553
|
+
: hasTasks
|
|
554
|
+
? params.tasks
|
|
555
|
+
: params.chain;
|
|
556
|
+
if (inputs.length > MAX_TASKS) throw new Error(`Too many subagent tasks (${inputs.length}). Max is ${MAX_TASKS}.`);
|
|
557
|
+
|
|
558
|
+
const run: RunSnapshot = {
|
|
559
|
+
id: runId,
|
|
560
|
+
parentSessionId: ctx.sessionManager.getSessionId?.(),
|
|
561
|
+
parentToolCallId: toolCallId,
|
|
562
|
+
mode,
|
|
563
|
+
status: "queued",
|
|
564
|
+
createdAt: Date.now(),
|
|
565
|
+
concurrency: Math.max(1, Math.min(params.concurrency ?? DEFAULT_CONCURRENCY, MAX_CONCURRENCY)),
|
|
566
|
+
tasks: inputs.map((input, index) => normalizeTask(input, runId, index, input.cwd ?? params.cwd ?? ctx.cwd)),
|
|
567
|
+
aggregateUsage: emptyUsage(),
|
|
568
|
+
};
|
|
569
|
+
this.runs.set(run.id, run);
|
|
570
|
+
this.emit("subagent:run-created", { run: cloneRun(run) });
|
|
571
|
+
this.updateRun(run, ctx, onUpdate);
|
|
572
|
+
|
|
573
|
+
run.status = "running";
|
|
574
|
+
run.startedAt = Date.now();
|
|
575
|
+
this.updateRun(run, ctx, onUpdate);
|
|
576
|
+
|
|
577
|
+
if (mode === "chain") {
|
|
578
|
+
let previous = "";
|
|
579
|
+
for (let i = 0; i < inputs.length; i++) {
|
|
580
|
+
if (signal?.aborted) {
|
|
581
|
+
this.updateTask(run, run.tasks[i], { status: "aborted", error: "Aborted before start", endedAt: Date.now() }, ctx, onUpdate);
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
const input = { ...inputs[i], task: inputs[i].task.replace(/\{previous\}/g, previous) };
|
|
585
|
+
run.tasks[i].task = input.task;
|
|
586
|
+
await this.runChild(run, run.tasks[i], input, agents, ctx, signal, onUpdate);
|
|
587
|
+
if (run.tasks[i].status !== "completed") break;
|
|
588
|
+
previous = run.tasks[i].finalText ?? "";
|
|
589
|
+
}
|
|
590
|
+
} else {
|
|
591
|
+
await mapWithConcurrency(run.tasks, mode === "single" ? 1 : run.concurrency, async (task, index) => {
|
|
592
|
+
await this.runChild(run, task, inputs[index], agents, ctx, signal, onUpdate);
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const failed = run.tasks.some((t) => t.status === "failed");
|
|
597
|
+
const aborted = run.tasks.some((t) => t.status === "aborted") || signal?.aborted;
|
|
598
|
+
run.status = aborted ? "aborted" : failed ? "failed" : "completed";
|
|
599
|
+
run.endedAt = Date.now();
|
|
600
|
+
this.updateRun(run, ctx, onUpdate);
|
|
601
|
+
this.emit("subagent:run-completed", { runId: run.id, status: run.status, run: cloneRun(run), aggregateUsage: run.aggregateUsage });
|
|
602
|
+
|
|
603
|
+
// Persist all subagent runs to a sidecar JSON file next to the parent
|
|
604
|
+
// session, so the UI can restore them across refreshes / session restarts
|
|
605
|
+
// without polluting the main .jsonl file.
|
|
606
|
+
try {
|
|
607
|
+
const parentFile = getParentSessionFile(ctx);
|
|
608
|
+
if (parentFile) {
|
|
609
|
+
const { writeFileSync } = await import("fs");
|
|
610
|
+
const sidecar = parentFile.replace(/\.jsonl$/, ".subagents.json");
|
|
611
|
+
const allRuns = this.listRuns().map((r) => cloneRun(r));
|
|
612
|
+
writeFileSync(sidecar, JSON.stringify(allRuns, null, 2));
|
|
613
|
+
}
|
|
614
|
+
} catch {
|
|
615
|
+
// silently ignore persistence errors
|
|
616
|
+
}
|
|
617
|
+
if (ctx.hasUI) {
|
|
618
|
+
ctx.ui.setStatus("subagents", `subagents: ${run.status}`);
|
|
619
|
+
ctx.ui.setWidget("subagents", renderWidgetLines(run), { placement: "aboveEditor" });
|
|
620
|
+
}
|
|
621
|
+
return {
|
|
622
|
+
run: cloneRun(run),
|
|
623
|
+
agents: agents.map((agent) => ({ name: agent.name, description: agent.description, source: agent.source, filePath: agent.filePath, id: agent.id })),
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const TaskItem = Type.Object({
|
|
629
|
+
id: Type.Optional(Type.String({ description: "Optional stable task id" })),
|
|
630
|
+
agent: Type.String({ description: "Exact agent name or id from the injected available subagents list" }),
|
|
631
|
+
task: Type.String({ description: "Task to delegate to the agent" }),
|
|
632
|
+
cwd: Type.Optional(Type.String({ description: "Working directory for this task" })),
|
|
633
|
+
model: Type.Optional(Type.String({ description: "Model override as provider/model-id" })),
|
|
634
|
+
thinking: Type.Optional(Type.String({ description: "Thinking level override" })),
|
|
635
|
+
tools: Type.Optional(Type.Array(Type.String(), { description: "Tool allowlist for this task" })),
|
|
636
|
+
maxRuntimeMs: Type.Optional(Type.Number({ description: "Per-task timeout in milliseconds" })),
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
const SubagentParams = Type.Object({
|
|
640
|
+
agent: Type.Optional(Type.String({ description: "Exact agent name or id from the injected available subagents list for single mode" })),
|
|
641
|
+
task: Type.Optional(Type.String({ description: "Task for single mode" })),
|
|
642
|
+
tasks: Type.Optional(Type.Array(TaskItem, { description: "Parallel tasks" })),
|
|
643
|
+
chain: Type.Optional(Type.Array(TaskItem, { description: "Sequential tasks. Each task may use {previous}." })),
|
|
644
|
+
agentScope: Type.Optional(StringEnum(["user", "project", "both"] as const, { default: "user" })),
|
|
645
|
+
confirmProjectAgents: Type.Optional(Type.Boolean({ default: true })),
|
|
646
|
+
cwd: Type.Optional(Type.String({ description: "Default working directory" })),
|
|
647
|
+
model: Type.Optional(Type.String({ description: "Model override for single mode as provider/model-id" })),
|
|
648
|
+
thinking: Type.Optional(Type.String({ description: "Thinking override for single mode" })),
|
|
649
|
+
tools: Type.Optional(Type.Array(Type.String(), { description: "Tool allowlist override for single mode" })),
|
|
650
|
+
concurrency: Type.Optional(Type.Number({ description: `Parallel concurrency. Default ${DEFAULT_CONCURRENCY}, max ${MAX_CONCURRENCY}.` })),
|
|
651
|
+
maxRuntimeMs: Type.Optional(Type.Number({ description: "Per-task timeout for single mode" })),
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
export default function (pi: ExtensionAPI) {
|
|
655
|
+
const manager = new SubagentManager(pi);
|
|
656
|
+
|
|
657
|
+
pi.registerCommand("subagents", {
|
|
658
|
+
description: "Show recent subagent runs",
|
|
659
|
+
handler: async (_args, ctx) => {
|
|
660
|
+
const runs = manager.listRuns().slice(0, 10);
|
|
661
|
+
if (runs.length === 0) {
|
|
662
|
+
ctx.ui.notify("No subagent runs in this session.", "info");
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
ctx.ui.setWidget("subagents", runs.flatMap((run) => renderWidgetLines(run).concat("")), { placement: "aboveEditor" });
|
|
666
|
+
ctx.ui.notify(`Showing ${runs.length} subagent run(s).`, "info");
|
|
667
|
+
},
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
671
|
+
await manager.restoreFromSidecar(ctx);
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
675
|
+
if (ctx?.hasUI) {
|
|
676
|
+
try {
|
|
677
|
+
ctx.ui.setStatus("subagents", "");
|
|
678
|
+
ctx.ui.setWidget("subagents", [], { placement: "aboveEditor" });
|
|
679
|
+
} catch {
|
|
680
|
+
/* ignore UI teardown errors */
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
manager.clearRuns();
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
pi.on("context", async (event, ctx) => {
|
|
687
|
+
return { messages: appendContextMessage(event.messages, makeAgentCatalogContext(ctx.cwd)) };
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
pi.registerTool<typeof SubagentParams, ToolDetails>({
|
|
691
|
+
name: "subagent",
|
|
692
|
+
label: "Subagent",
|
|
693
|
+
description: [
|
|
694
|
+
"Delegate work to specialized subagents with isolated context and persisted child sessions.",
|
|
695
|
+
"Use exactly one mode: single (agent+task), parallel (tasks), or chain (chain with optional {previous}).",
|
|
696
|
+
"Agents can be pi-web configured agents (referenced by name or id), markdown files under ~/.pi/agent/agents, or project-local .pi/agents when agentScope includes project.",
|
|
697
|
+
"Read the injected available subagents list before calling this tool, and choose an exact agent name or id from it.",
|
|
698
|
+
].join(" "),
|
|
699
|
+
promptSnippet: "Delegate analysis/review/test/planning work to specialized subagents.",
|
|
700
|
+
promptGuidelines: [
|
|
701
|
+
"Use subagent when independent review, testing, research, or parallel analysis would improve quality.",
|
|
702
|
+
"Use only exact configured agent names or ids from the injected available subagents list.",
|
|
703
|
+
"Choose agents by matching the task to their descriptions; do not invent generic names. For multiple similar tasks, reuse the same most suitable agent instead of spreading them across different agents.",
|
|
704
|
+
"Keep subagent tasks focused and ask for concise final outputs; do not pass the full parent transcript unless needed.",
|
|
705
|
+
"Prefer read-only subagent tools unless the user explicitly asks for implementation work.",
|
|
706
|
+
],
|
|
707
|
+
parameters: SubagentParams,
|
|
708
|
+
executionMode: "sequential",
|
|
709
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
710
|
+
const details = await manager.run(params, toolCallId, signal, onUpdate, ctx);
|
|
711
|
+
return {
|
|
712
|
+
content: [{ type: "text", text: makeSummary(details.run) }],
|
|
713
|
+
details,
|
|
714
|
+
};
|
|
715
|
+
},
|
|
716
|
+
renderCall(args, theme) {
|
|
717
|
+
const mode = args.chain?.length ? `chain ${args.chain.length}` : args.tasks?.length ? `parallel ${args.tasks.length}` : `single ${args.agent ?? "?"}`;
|
|
718
|
+
return new Text(`${theme.fg("toolTitle", theme.bold("subagent"))} ${theme.fg("accent", mode)}`, 0, 0);
|
|
719
|
+
},
|
|
720
|
+
renderResult(result, { expanded, isPartial }, theme) {
|
|
721
|
+
const run = result.details?.run;
|
|
722
|
+
if (!run) return new Text(result.content[0]?.type === "text" ? result.content[0].text : "", 0, 0);
|
|
723
|
+
const header = `${isPartial ? "⏳" : statusIcon(run.status)} ${theme.fg("toolTitle", theme.bold(`subagents ${run.mode}`))} ${theme.fg("accent", `${run.tasks.filter((t) => t.status === "completed").length}/${run.tasks.length}`)} ${theme.fg("muted", run.status)}`;
|
|
724
|
+
if (!expanded) {
|
|
725
|
+
const lines = [header, ...run.tasks.map((task) => ` ${statusIcon(task.status)} ${task.agent}: ${task.status}${task.currentTool ? ` · ${task.currentTool.toolName}` : ""}`)];
|
|
726
|
+
const usage = formatUsage(run.aggregateUsage);
|
|
727
|
+
if (usage) lines.push(theme.fg("dim", usage));
|
|
728
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
729
|
+
}
|
|
730
|
+
const container = new Container();
|
|
731
|
+
container.addChild(new Text(header, 0, 0));
|
|
732
|
+
const mdTheme = getMarkdownTheme();
|
|
733
|
+
for (const task of run.tasks) {
|
|
734
|
+
container.addChild(new Spacer(1));
|
|
735
|
+
container.addChild(new Text(`${statusIcon(task.status)} ${theme.fg("accent", task.agent)} ${theme.fg("muted", task.sessionId ?? "")}`, 0, 0));
|
|
736
|
+
if (task.error) container.addChild(new Text(theme.fg("error", task.error), 0, 0));
|
|
737
|
+
else if (task.finalText) container.addChild(new Markdown(task.finalText.trim(), 0, 0, mdTheme));
|
|
738
|
+
const usage = formatUsage(task.usage);
|
|
739
|
+
if (usage) container.addChild(new Text(theme.fg("dim", usage), 0, 0));
|
|
740
|
+
}
|
|
741
|
+
return container;
|
|
742
|
+
},
|
|
743
|
+
});
|
|
744
|
+
}
|