@bacnh85/pi-subagent 0.8.1 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/README.md +17 -13
- package/agent-format.md +12 -8
- package/agents/general-purpose.md +4 -0
- package/agents/planner.md +15 -0
- package/agents/reviewer.md +3 -0
- package/agents/scout.md +4 -0
- package/agents/tester.md +15 -0
- package/agents/worker.md +3 -0
- package/extensions/agents.ts +26 -3
- package/extensions/index.ts +35 -20
- package/extensions/model.ts +26 -45
- package/extensions/runner.ts +88 -199
- package/extensions/security.ts +3 -4
- package/extensions/service.ts +6 -3
- package/extensions/thread-viewer.ts +8 -0
- package/extensions/threads.ts +26 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.1 (2026-07-16)
|
|
4
|
+
|
|
5
|
+
### Activity-aware timeouts
|
|
6
|
+
|
|
7
|
+
- Child `timeout` values now define a sliding inactivity window (three minutes by default); real SDK lifecycle events reset it while a fixed 20-minute hard cap remains.
|
|
8
|
+
- `/agent` distinguishes real activity from transport heartbeats and reports idle versus hard timeouts.
|
|
9
|
+
|
|
10
|
+
## 0.9.0 (2026-07-16)
|
|
11
|
+
|
|
12
|
+
### Model routing
|
|
13
|
+
|
|
14
|
+
- Bundled roles now select the first authenticated model from an ordered preference list, with the authenticated parent model as the final fallback.
|
|
15
|
+
- Added read-only `planner` and focused `tester` roles for consequential design and cheap routine verification.
|
|
16
|
+
- Agent files accept `models` as a YAML array or comma-separated string; legacy `model` remains the explicit first choice.
|
|
17
|
+
|
|
18
|
+
## 0.8.2 (2026-07-16)
|
|
19
|
+
|
|
20
|
+
### Reliability
|
|
21
|
+
|
|
22
|
+
- Transient provider and transport failures receive one bounded SDK retry. Retrying Codex WebSocket failures uses the session's SSE fallback, waits through retrying `agent_end` events, clears recovered error state, preserves nonzero failure exit codes, and reports explicit timeout messages.
|
|
23
|
+
|
|
3
24
|
## 0.8.1 (2026-07-15)
|
|
4
25
|
|
|
5
26
|
### Review handoff
|
package/README.md
CHANGED
|
@@ -14,14 +14,16 @@ Requires Node.js >= 20.18.
|
|
|
14
14
|
|
|
15
15
|
## Bundled roles
|
|
16
16
|
|
|
17
|
-
| Role |
|
|
17
|
+
| Role | Ordered model preferences | Thinking | Tools |
|
|
18
18
|
| --- | --- | --- | --- |
|
|
19
|
-
| `scout` |
|
|
20
|
-
| `
|
|
21
|
-
| `worker` |
|
|
22
|
-
| `general-purpose` |
|
|
19
|
+
| `scout` | `opencode-go/deepseek-v4-flash` → `openai-codex/gpt-5.6-luna` → `opencode-go/mimo-v2.5` | low | read, grep, find, ls |
|
|
20
|
+
| `tester` | `openai-codex/gpt-5.6-luna` → `opencode-go/mimo-v2.5` → `opencode-go/deepseek-v4-flash` | low | read, bash, grep, find, ls |
|
|
21
|
+
| `worker` | `openai-codex/gpt-5.6-terra` → `opencode-go/deepseek-v4-pro` | medium | read, bash, edit, write, grep, find, ls |
|
|
22
|
+
| `general-purpose` | `openai-codex/gpt-5.6-terra` → `opencode-go/deepseek-v4-pro` | medium | read, bash, edit, write, grep, find, ls |
|
|
23
|
+
| `planner` | `openai-codex/gpt-5.6-sol` → `opencode-go/deepseek-v4-pro` | high | read, grep, find, ls |
|
|
24
|
+
| `reviewer` | `openai-codex/gpt-5.6-sol` → `opencode-go/deepseek-v4-pro` | high | read, grep, find, ls |
|
|
23
25
|
|
|
24
|
-
|
|
26
|
+
Each role uses the first authenticated preference available through Pi's model registry, then falls back to the authenticated parent model. User/project agent files remain stronger overrides and may set legacy `model`, ordered `models`, and `thinking`.
|
|
25
27
|
|
|
26
28
|
## Agent files
|
|
27
29
|
|
|
@@ -32,7 +34,8 @@ Create `~/.pi/agent/agents/*.md` or `.pi/agents/*.md`:
|
|
|
32
34
|
name: scout-fast
|
|
33
35
|
description: Locate relevant files and symbols
|
|
34
36
|
tools: read, grep, find, ls
|
|
35
|
-
model:
|
|
37
|
+
model: openai-codex/gpt-5.6-luna
|
|
38
|
+
models: opencode-go/mimo-v2.5, opencode-go/deepseek-v4-flash
|
|
36
39
|
---
|
|
37
40
|
|
|
38
41
|
Return concise evidence with file/symbol anchors.
|
|
@@ -83,12 +86,12 @@ Unknown or misspelled tool names produce clear diagnostics. Duplicate tool names
|
|
|
83
86
|
|
|
84
87
|
Every child execution receives a timeout:
|
|
85
88
|
|
|
86
|
-
- **Default:**
|
|
87
|
-
- **
|
|
88
|
-
-
|
|
89
|
-
- Timeout
|
|
90
|
-
-
|
|
91
|
-
-
|
|
89
|
+
- **Default inactivity window:** 3 minutes (`DEFAULT_TIMEOUT_MS`); real SDK lifecycle activity resets it.
|
|
90
|
+
- **Absolute cap:** 20 minutes for every child, even when active.
|
|
91
|
+
- **Maximum requested inactivity window:** 60 minutes (`MAX_TIMEOUT_MS`); values must be positive integers.
|
|
92
|
+
- Timeout diagnostics distinguish `Idle timeout` from `Hard timeout` and parent cancellation.
|
|
93
|
+
- 30-second progress heartbeats only keep the parent transport alive; they never reset inactivity.
|
|
94
|
+
- `/agent` shows last real activity and the remaining idle window; parallel tasks and chain steps may have per-item windows.
|
|
92
95
|
|
|
93
96
|
### Output safety
|
|
94
97
|
|
|
@@ -159,6 +162,7 @@ The raw `stopReason` from the Pi SDK is preserved in the result.
|
|
|
159
162
|
- **Parent cancellation:** Aborting the parent tool call cancels all children.
|
|
160
163
|
- **Sibling cancellation:** In parallel mode with `abortOnFailure: true`, the first failed task cancels running siblings.
|
|
161
164
|
- **Timeout vs. abort:** Timeout errors set `status: "timeout"` and `stopReason: "timeout"`; parent cancellation sets `status: "aborted"`.
|
|
165
|
+
- **Transient provider failures:** One automatic retry runs within the same timeout; Codex WebSocket failures use the SDK's SSE fallback on retry.
|
|
162
166
|
- **Transport idle:** The parent tool receives periodic progress heartbeats while a child runs; these do not extend its timeout.
|
|
163
167
|
|
|
164
168
|
## Extension contract
|
package/agent-format.md
CHANGED
|
@@ -18,8 +18,11 @@ Project agents override user agents with the same name when `agentScope: "both"`
|
|
|
18
18
|
---
|
|
19
19
|
name: my-agent # Required. Unique identifier (kebab-case).
|
|
20
20
|
description: ... # Required. When to use this agent.
|
|
21
|
-
tools: read, grep, ...
|
|
22
|
-
model: provider/model # Optional
|
|
21
|
+
tools: read, grep, ... # Optional. Comma-separated tool names. Defaults to all.
|
|
22
|
+
model: provider/model # Optional explicit first choice (legacy compatible).
|
|
23
|
+
models: # Optional ordered fallbacks; comma form also accepted.
|
|
24
|
+
- provider/fast-model
|
|
25
|
+
- provider/backup-model
|
|
23
26
|
thinking: low # Optional: off|minimal|low|medium|high|xhigh|max.
|
|
24
27
|
sandbox: read-only # Optional: read-only | workspace-write. Auto-derives tool restrictions.
|
|
25
28
|
color: cyan # Optional: red|blue|green|yellow|purple|orange|pink|cyan.
|
|
@@ -62,13 +65,14 @@ Read-only service execution (used by `pi-review`) restricts tools to the read-on
|
|
|
62
65
|
|
|
63
66
|
## Model Resolution
|
|
64
67
|
|
|
65
|
-
|
|
66
|
-
- `claude-haiku-4-5` (Anthropic Haiku — fast, cheap)
|
|
67
|
-
- `claude-sonnet-4-20250514` (Anthropic Sonnet — balanced)
|
|
68
|
-
- `gpt-4o` (OpenAI)
|
|
69
|
-
- Any model available in your pi configuration.
|
|
68
|
+
Pi selects the first authenticated/configured model reported by the parent session's `ModelRegistry`. Resolution order is legacy `model`, then each `models` entry, then the authenticated parent model. Duplicate candidates are ignored. If no candidate is available, the error lists every attempted model.
|
|
70
69
|
|
|
71
|
-
|
|
70
|
+
```yaml
|
|
71
|
+
model: openai-codex/gpt-5.6-terra
|
|
72
|
+
models: opencode-go/deepseek-v4-pro, opencode-go/mimo-v2.5
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The registry includes OAuth subscriptions, API-key subscriptions such as OpenCode Go, environment/runtime credentials, and custom `models.json` providers. Use provider-qualified IDs for predictable routing.
|
|
72
76
|
|
|
73
77
|
## Instruction handoff
|
|
74
78
|
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
name: general-purpose
|
|
3
3
|
description: General-purpose sub-agent for any delegated task. Use when no specialized agent fits. Good for complex research, multi-step operations, and code modifications.
|
|
4
4
|
tools: read, bash, edit, write, grep, find, ls
|
|
5
|
+
models:
|
|
6
|
+
- openai-codex/gpt-5.6-terra
|
|
7
|
+
- opencode-go/deepseek-v4-pro
|
|
8
|
+
thinking: medium
|
|
5
9
|
color: yellow
|
|
6
10
|
---
|
|
7
11
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: planner
|
|
3
|
+
description: Read-only planning and architecture specialist. Use for consequential design, tradeoff analysis, and implementation plans.
|
|
4
|
+
tools: read, grep, find, ls
|
|
5
|
+
models:
|
|
6
|
+
- openai-codex/gpt-5.6-sol
|
|
7
|
+
- opencode-go/deepseek-v4-pro
|
|
8
|
+
thinking: high
|
|
9
|
+
color: blue
|
|
10
|
+
sandbox: read-only
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
You are a senior software planner. Investigate the repository, identify the smallest complete implementation path, and return a concrete plan with file and symbol anchors.
|
|
14
|
+
|
|
15
|
+
Do not modify files. Resolve discoverable facts from the codebase before raising questions. Call out material risks, compatibility constraints, and the narrowest verification that proves the change.
|
package/agents/reviewer.md
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
name: reviewer
|
|
3
3
|
description: Code review specialist. Use for correctness, security, regression, and meaningful test-gap review.
|
|
4
4
|
tools: read, grep, find, ls
|
|
5
|
+
models:
|
|
6
|
+
- openai-codex/gpt-5.6-sol
|
|
7
|
+
- opencode-go/deepseek-v4-pro
|
|
5
8
|
thinking: high
|
|
6
9
|
color: purple
|
|
7
10
|
sandbox: read-only
|
package/agents/scout.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
name: scout
|
|
3
3
|
description: Fast codebase recon that returns compressed context for handoff. Use for finding files, understanding structure, locating symbols.
|
|
4
4
|
tools: read, grep, find, ls
|
|
5
|
+
models:
|
|
6
|
+
- opencode-go/deepseek-v4-flash
|
|
7
|
+
- openai-codex/gpt-5.6-luna
|
|
8
|
+
- opencode-go/mimo-v2.5
|
|
5
9
|
thinking: low
|
|
6
10
|
color: cyan
|
|
7
11
|
sandbox: read-only
|
package/agents/tester.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: tester
|
|
3
|
+
description: Focused verification agent. Use for cheap routine test, typecheck, lint, build, and regression checks without editing files.
|
|
4
|
+
tools: read, bash, grep, find, ls
|
|
5
|
+
models:
|
|
6
|
+
- openai-codex/gpt-5.6-luna
|
|
7
|
+
- opencode-go/mimo-v2.5
|
|
8
|
+
- opencode-go/deepseek-v4-flash
|
|
9
|
+
thinking: low
|
|
10
|
+
color: orange
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
You are a focused verification agent. Inspect the requested scope, run the narrowest relevant checks, and report exact commands, outcomes, and actionable failures.
|
|
14
|
+
|
|
15
|
+
Do not edit files. Avoid unrelated broad test suites unless the task requires them.
|
package/agents/worker.md
CHANGED
package/extensions/agents.ts
CHANGED
|
@@ -20,6 +20,7 @@ export interface AgentConfig {
|
|
|
20
20
|
description: string;
|
|
21
21
|
tools?: string[];
|
|
22
22
|
model?: string;
|
|
23
|
+
models?: string[];
|
|
23
24
|
thinking?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
24
25
|
sandbox?: "read-only" | "workspace-write";
|
|
25
26
|
color?: AgentColor;
|
|
@@ -28,6 +29,10 @@ export interface AgentConfig {
|
|
|
28
29
|
filePath: string;
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
export function getModelCandidates(agent: Pick<AgentConfig, "model" | "models">): string[] {
|
|
33
|
+
return [...new Set([agent.model, ...(agent.models ?? [])].filter((model): model is string => Boolean(model)))];
|
|
34
|
+
}
|
|
35
|
+
|
|
31
36
|
export interface AgentDiscoveryResult {
|
|
32
37
|
agents: AgentConfig[];
|
|
33
38
|
projectAgentsDir: string | null;
|
|
@@ -160,11 +165,28 @@ function loadAgentsFromDir(
|
|
|
160
165
|
: Array.isArray(frontmatter.tools)
|
|
161
166
|
? (frontmatter.tools as unknown[]).filter((t): t is string => typeof t === "string")
|
|
162
167
|
: undefined;
|
|
168
|
+
const model = typeof frontmatter.model === "string" ? frontmatter.model.trim() || undefined : undefined;
|
|
169
|
+
const models =
|
|
170
|
+
typeof frontmatter.models === "string"
|
|
171
|
+
? frontmatter.models.split(",").map((item) => item.trim()).filter(Boolean)
|
|
172
|
+
: Array.isArray(frontmatter.models)
|
|
173
|
+
? (frontmatter.models as unknown[]).filter((item): item is string => typeof item === "string" && Boolean(item.trim())).map((item) => item.trim())
|
|
174
|
+
: undefined;
|
|
163
175
|
|
|
164
|
-
if (typeof frontmatter.
|
|
176
|
+
if (frontmatter.models !== undefined && typeof frontmatter.models !== "string" && !Array.isArray(frontmatter.models)) {
|
|
177
|
+
diagnostics.push({ filePath, issue: `"models" must be a YAML array or comma-separated string. Ignoring.`, severity: "warn" });
|
|
178
|
+
}
|
|
179
|
+
if (Array.isArray(frontmatter.models)) {
|
|
180
|
+
for (const item of frontmatter.models) {
|
|
181
|
+
if (typeof item === "string" && item.trim()) continue;
|
|
182
|
+
diagnostics.push({ filePath, issue: `"models" entries must be non-empty strings. Ignoring invalid entry.`, severity: "warn" });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
for (const modelName of getModelCandidates({ model, models })) {
|
|
186
|
+
if (modelName.includes("/")) continue;
|
|
165
187
|
diagnostics.push({
|
|
166
188
|
filePath,
|
|
167
|
-
issue: `Model "${
|
|
189
|
+
issue: `Model "${modelName}" does not include a provider prefix (e.g., "anthropic/claude-sonnet-4-20250514"). Resolution may fail.`,
|
|
168
190
|
severity: "warn",
|
|
169
191
|
});
|
|
170
192
|
}
|
|
@@ -204,7 +226,8 @@ function loadAgentsFromDir(
|
|
|
204
226
|
name: frontmatter.name,
|
|
205
227
|
description: frontmatter.description,
|
|
206
228
|
tools: tools && tools.length > 0 ? tools : undefined,
|
|
207
|
-
model
|
|
229
|
+
model,
|
|
230
|
+
models: models && models.length > 0 ? models : undefined,
|
|
208
231
|
thinking: typeof frontmatter.thinking === "string" && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(frontmatter.thinking)
|
|
209
232
|
? frontmatter.thinking as AgentConfig["thinking"]
|
|
210
233
|
: undefined,
|
package/extensions/index.ts
CHANGED
|
@@ -30,8 +30,9 @@ import {
|
|
|
30
30
|
import { Container, Markdown, SelectList, Spacer, Text } from "@earendil-works/pi-tui";
|
|
31
31
|
import { Type } from "typebox";
|
|
32
32
|
|
|
33
|
-
import { type AgentColor, type AgentConfig, type AgentScope, discoverAgents, formatAgentList, invalidateAgentCache } from "./agents.ts";
|
|
33
|
+
import { type AgentColor, type AgentConfig, type AgentScope, discoverAgents, formatAgentList, getModelCandidates, invalidateAgentCache } from "./agents.ts";
|
|
34
34
|
import {
|
|
35
|
+
type SubAgentProgress,
|
|
35
36
|
type SubAgentResult,
|
|
36
37
|
getFinalOutput,
|
|
37
38
|
getResultOutput,
|
|
@@ -94,14 +95,14 @@ const TaskItem = Type.Object({
|
|
|
94
95
|
agent: Type.String({ description: "Name of the agent to invoke" }),
|
|
95
96
|
task: Type.String({ description: "Task to delegate to the agent" }),
|
|
96
97
|
cwd: Type.Optional(Type.String({ description: "Working directory for the agent" })),
|
|
97
|
-
timeout: Type.Optional(Type.Number({ description: "
|
|
98
|
+
timeout: Type.Optional(Type.Number({ description: "Inactivity timeout in milliseconds for this task; real child activity resets it (default 3 minutes, absolute cap 20 minutes)" })),
|
|
98
99
|
});
|
|
99
100
|
|
|
100
101
|
const ChainItem = Type.Object({
|
|
101
102
|
agent: Type.String({ description: "Name of the agent to invoke" }),
|
|
102
103
|
task: Type.String({ description: "Task with optional {previous} placeholder for prior output" }),
|
|
103
104
|
cwd: Type.Optional(Type.String({ description: "Working directory for the agent" })),
|
|
104
|
-
timeout: Type.Optional(Type.Number({ description: "
|
|
105
|
+
timeout: Type.Optional(Type.Number({ description: "Inactivity timeout in milliseconds for this step; real child activity resets it (default 3 minutes, absolute cap 20 minutes)" })),
|
|
105
106
|
});
|
|
106
107
|
|
|
107
108
|
const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, {
|
|
@@ -126,7 +127,7 @@ const SubagentParams = Type.Object({
|
|
|
126
127
|
// Project-agent confirmation is enforced via trusted configuration.
|
|
127
128
|
// See Security model section in README.
|
|
128
129
|
cwd: Type.Optional(Type.String({ description: "Working directory (single mode, must be inside workspace)" })),
|
|
129
|
-
timeout: Type.Optional(Type.Number({ description: "Global timeout in milliseconds
|
|
130
|
+
timeout: Type.Optional(Type.Number({ description: "Global inactivity timeout in milliseconds (default 3 minutes; real activity resets it; fixed 20-minute absolute cap)" })),
|
|
130
131
|
instructions: Type.Optional(Type.String({ description: "Bounded repository/task instructions passed to each child (max 16 KB)" })),
|
|
131
132
|
abortOnFailure: Type.Optional(Type.Boolean({ description: "In parallel mode, cancel remaining tasks when one fails. Default: false.", default: false })),
|
|
132
133
|
});
|
|
@@ -164,11 +165,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
164
165
|
const ctx = currentCtx;
|
|
165
166
|
const discovery = discoverAgents(ctx?.cwd ?? process.cwd(), "both", bundledAgentsDir);
|
|
166
167
|
const catalog = discovery.agents
|
|
167
|
-
.map((
|
|
168
|
-
const
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
168
|
+
.map((agent) => {
|
|
169
|
+
const candidates = getModelCandidates(agent);
|
|
170
|
+
const modelInfo = candidates.length > 0
|
|
171
|
+
? ` (models: ${candidates.join(" → ")} → parent fallback)`
|
|
172
|
+
: " (parent fallback)";
|
|
173
|
+
const thinkingInfo = agent.thinking ? `, thinking: ${agent.thinking}` : "";
|
|
174
|
+
const sandboxInfo = agent.sandbox ? `, sandbox: ${agent.sandbox}` : "";
|
|
175
|
+
return `- **${agent.name}**: ${agent.description}${modelInfo}${thinkingInfo}${sandboxInfo}`;
|
|
172
176
|
})
|
|
173
177
|
.join("\n");
|
|
174
178
|
return {
|
|
@@ -177,9 +181,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
177
181
|
`\n\n## Available Subagents\n${catalog}\n\n` +
|
|
178
182
|
"The subagent tool can delegate tasks to these specialized agents with isolated context. " +
|
|
179
183
|
"Use for read-heavy exploration, parallel analysis, or work that would flood the main context.\n" +
|
|
180
|
-
"Prefer **scout** for
|
|
181
|
-
"Prefer **
|
|
182
|
-
"Prefer **
|
|
184
|
+
"Prefer **scout** and **tester** for cheap routine work. " +
|
|
185
|
+
"Prefer **worker** or **general-purpose** for normal coding. " +
|
|
186
|
+
"Prefer **planner** and **reviewer** for consequential reasoning. " +
|
|
183
187
|
"Modes: single, parallel (max 8 tasks, 4 concurrent), chain.",
|
|
184
188
|
};
|
|
185
189
|
});
|
|
@@ -205,6 +209,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
205
209
|
instructions: request.instructions,
|
|
206
210
|
signal: request.signal,
|
|
207
211
|
onMessage: (result) => threadStore.updateThread(thread.id, { result }),
|
|
212
|
+
onProgress: (progress) => { threadStore.updateProgress(thread.id, progress); request.onProgress?.(progress); },
|
|
208
213
|
}).then((result) => {
|
|
209
214
|
threadStore.updateThread(thread.id, {
|
|
210
215
|
status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
|
|
@@ -272,12 +277,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
272
277
|
ctx.ui.notify(`Unknown agent: "${args.trim()}". Use /subagent to list all.`, "error");
|
|
273
278
|
return;
|
|
274
279
|
}
|
|
280
|
+
const candidates = getModelCandidates(agent);
|
|
275
281
|
pi.sendMessage({
|
|
276
282
|
customType: "pi-subagent",
|
|
277
283
|
content: [
|
|
278
284
|
`Agent: ${agent.name} (${agent.source})`,
|
|
279
285
|
`Description: ${agent.description}`,
|
|
280
|
-
`
|
|
286
|
+
`Models: ${candidates.length > 0 ? `${candidates.join(" → ")} → parent fallback` : "parent fallback"}`,
|
|
281
287
|
`Thinking: ${agent.thinking || "off"}`,
|
|
282
288
|
`Tools: ${agent.tools?.join(", ") || "all default"}`,
|
|
283
289
|
`Source file: ${agent.filePath}`,
|
|
@@ -343,11 +349,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
343
349
|
`To enable project-local agents in ${CONFIG_DIR_NAME}/agents, set agentScope: "both" or "project".`,
|
|
344
350
|
].join(" "),
|
|
345
351
|
parameters: SubagentParams,
|
|
346
|
-
promptSnippet: "Delegate tasks to specialized sub-agents
|
|
352
|
+
promptSnippet: "Delegate tasks to specialized sub-agents with automatic role-based model routing",
|
|
347
353
|
promptGuidelines: [
|
|
348
354
|
"Use subagent to delegate work that would flood the main context with search results or file contents.",
|
|
349
355
|
"Modes: single {agent, task}, parallel {tasks: [...]} (max 8, 4 concurrent), chain {chain: [...]} (sequential with {previous}).",
|
|
350
|
-
"Bundled agents: scout (fast recon),
|
|
356
|
+
"Bundled agents: scout (fast recon), tester (verification), worker (implementation), general-purpose (fallback), planner (planning), reviewer (review).",
|
|
351
357
|
"Use /subagent to list all available agents or /subagent <name> for agent details.",
|
|
352
358
|
],
|
|
353
359
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
@@ -516,7 +522,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
516
522
|
parentSignal?: AbortSignal,
|
|
517
523
|
timeoutMs?: number,
|
|
518
524
|
onProgress?: (partial: SubAgentResult) => void,
|
|
525
|
+
onActivity?: (progress: SubAgentProgress) => void,
|
|
519
526
|
heartbeatDetails?: () => SubagentDetails,
|
|
527
|
+
onHeartbeat?: () => void,
|
|
520
528
|
isReadOnly?: boolean,
|
|
521
529
|
): Promise<SubAgentResult> {
|
|
522
530
|
const agent = agents.find((a) => a.name === agentName);
|
|
@@ -536,7 +544,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
536
544
|
};
|
|
537
545
|
}
|
|
538
546
|
|
|
539
|
-
const resolved = resolveModel(agent
|
|
547
|
+
const resolved = await resolveModel(getModelCandidates(agent), ctx.model, ctx.modelRegistry);
|
|
540
548
|
if (!resolved.model) {
|
|
541
549
|
const tried = resolved.attempted.join(", ") || "none";
|
|
542
550
|
const parentInfo = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "none";
|
|
@@ -578,10 +586,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
578
586
|
};
|
|
579
587
|
}
|
|
580
588
|
|
|
581
|
-
const stopHeartbeat = onUpdate ? startHeartbeat(() =>
|
|
582
|
-
|
|
583
|
-
details: heartbeatDetails?.() ?? makeDetails("single")([])
|
|
584
|
-
})
|
|
589
|
+
const stopHeartbeat = onUpdate ? startHeartbeat(() => {
|
|
590
|
+
onHeartbeat?.();
|
|
591
|
+
onUpdate({ content: [{ type: "text", text: `Subagent ${agentName} is still running…` }], details: heartbeatDetails?.() ?? makeDetails("single")([]) });
|
|
592
|
+
}) : undefined;
|
|
585
593
|
try {
|
|
586
594
|
return await runSubAgent({
|
|
587
595
|
cwd: safeCwd,
|
|
@@ -598,6 +606,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
598
606
|
agentName,
|
|
599
607
|
thinkingLevel: agent.thinking,
|
|
600
608
|
onMessage: onProgress,
|
|
609
|
+
onProgress: onActivity,
|
|
601
610
|
});
|
|
602
611
|
} finally {
|
|
603
612
|
stopHeartbeat?.();
|
|
@@ -624,7 +633,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
624
633
|
step.agent, taskWithContext, step.cwd,
|
|
625
634
|
signal, step.timeout ?? params.timeout,
|
|
626
635
|
(partial) => threadStore.updateThread(thread.id, { result: partial }),
|
|
636
|
+
(progress) => threadStore.updateProgress(thread.id, progress),
|
|
627
637
|
() => makeDetails("chain")(results),
|
|
638
|
+
() => threadStore.refreshHeartbeat(thread.id),
|
|
628
639
|
);
|
|
629
640
|
threadStore.updateThread(thread.id, {
|
|
630
641
|
status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
|
|
@@ -778,7 +789,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
778
789
|
t.agent, t.task, t.cwd,
|
|
779
790
|
parallelController.signal, t.timeout ?? params.timeout,
|
|
780
791
|
(partial) => threadStore.updateThread(parallelThreads[index].id, { result: partial }),
|
|
792
|
+
(progress) => threadStore.updateProgress(parallelThreads[index].id, progress),
|
|
781
793
|
() => makeDetails("parallel")([...allResults]),
|
|
794
|
+
() => threadStore.refreshHeartbeat(parallelThreads[index].id),
|
|
782
795
|
);
|
|
783
796
|
allResults[index] = result;
|
|
784
797
|
threadStore.updateThread(parallelThreads[index].id, {
|
|
@@ -834,7 +847,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
834
847
|
params.agent, params.task, params.cwd,
|
|
835
848
|
signal, params.timeout,
|
|
836
849
|
(partial) => threadStore.updateThread(thread.id, { result: partial }),
|
|
850
|
+
(progress) => threadStore.updateProgress(thread.id, progress),
|
|
837
851
|
() => makeDetails("single")([]),
|
|
852
|
+
() => threadStore.refreshHeartbeat(thread.id),
|
|
838
853
|
);
|
|
839
854
|
threadStore.updateThread(thread.id, {
|
|
840
855
|
status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
|
package/extensions/model.ts
CHANGED
|
@@ -5,14 +5,12 @@
|
|
|
5
5
|
* (index.ts) and the event-driven service path (service.ts), ensuring
|
|
6
6
|
* consistent error reporting across all sub-agent invocation paths.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* to the built-in registry for unconfigured models.
|
|
8
|
+
* Selects the first authenticated candidate reported by the parent
|
|
9
|
+
* ModelRegistry, then falls back to the authenticated parent model.
|
|
11
10
|
* For unqualified names (no provider prefix), known naming conventions
|
|
12
11
|
* are tried before assuming Anthropic.
|
|
13
12
|
*/
|
|
14
13
|
|
|
15
|
-
import { getModel } from "@earendil-works/pi-ai/compat";
|
|
16
14
|
import type { Model } from "@earendil-works/pi-ai";
|
|
17
15
|
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
18
16
|
|
|
@@ -32,55 +30,38 @@ const KNOWN_PROVIDERS: [string, RegExp][] = [
|
|
|
32
30
|
["groq", /^(groq-|llama-)/i],
|
|
33
31
|
];
|
|
34
32
|
|
|
35
|
-
function
|
|
36
|
-
|
|
37
|
-
id: string,
|
|
38
|
-
modelRegistry?: ModelRegistry,
|
|
39
|
-
): Model<any> | null {
|
|
40
|
-
// Query parent ModelRegistry first — it includes custom-configured models
|
|
41
|
-
// (overridden base URLs, headers, compatibility settings, per-model overrides).
|
|
42
|
-
// Fall back to built-in registry for unconfigured models.
|
|
43
|
-
if (modelRegistry) {
|
|
44
|
-
const found = modelRegistry.find(provider as any, id as any) ?? null;
|
|
45
|
-
if (found) return found;
|
|
46
|
-
}
|
|
47
|
-
const builtIn = getModel(provider as any, id as any) ?? null;
|
|
48
|
-
if (builtIn) return builtIn;
|
|
49
|
-
return null;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export function resolveModel(
|
|
53
|
-
modelName: string | undefined,
|
|
33
|
+
export async function resolveModel(
|
|
34
|
+
modelNames: readonly string[],
|
|
54
35
|
parentModel: Model<any> | undefined,
|
|
55
36
|
modelRegistry?: ModelRegistry,
|
|
56
|
-
): ResolvedModel {
|
|
37
|
+
): Promise<ResolvedModel> {
|
|
57
38
|
const attempted: string[] = [];
|
|
58
|
-
|
|
39
|
+
const available = modelRegistry?.getAvailable() ?? [];
|
|
40
|
+
const byName = new Map(available.map((model) => [`${model.provider}/${model.id}`, model]));
|
|
41
|
+
const tryAvailable = (qualifiedName: string): Model<any> | undefined => {
|
|
42
|
+
if (!attempted.includes(qualifiedName)) attempted.push(qualifiedName);
|
|
43
|
+
return byName.get(qualifiedName);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
for (const modelName of [...new Set(modelNames.map((name) => name.trim()).filter(Boolean))]) {
|
|
59
47
|
const idx = modelName.indexOf("/");
|
|
60
48
|
if (idx > 0) {
|
|
61
|
-
|
|
62
|
-
const provider = modelName.slice(0, idx);
|
|
63
|
-
const id = modelName.slice(idx + 1);
|
|
64
|
-
attempted.push(modelName);
|
|
65
|
-
const found = tryGetModel(provider, id, modelRegistry);
|
|
49
|
+
const found = tryAvailable(modelName);
|
|
66
50
|
if (found) return { model: found, attempted };
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const found = tryGetModel(provider, modelName, modelRegistry);
|
|
73
|
-
if (found) return { model: found, attempted };
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
// Fall back to Anthropic shorthand (backward compat)
|
|
77
|
-
attempted.push(`anthropic/${modelName}`);
|
|
78
|
-
const found = tryGetModel("anthropic", modelName, modelRegistry);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
for (const [provider, pattern] of KNOWN_PROVIDERS) {
|
|
54
|
+
if (!pattern.test(modelName)) continue;
|
|
55
|
+
const found = tryAvailable(`${provider}/${modelName}`);
|
|
79
56
|
if (found) return { model: found, attempted };
|
|
80
57
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
58
|
+
const found = tryAvailable(`anthropic/${modelName}`);
|
|
59
|
+
if (found) return { model: found, attempted };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (parentModel) {
|
|
63
|
+
const found = tryAvailable(`${parentModel.provider}/${parentModel.id}`);
|
|
64
|
+
if (found) return { model: found, attempted };
|
|
84
65
|
}
|
|
85
66
|
return { model: null, attempted };
|
|
86
67
|
}
|
package/extensions/runner.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* - Only the agent's system prompt is used (no pi defaults).
|
|
9
9
|
* - No AGENTS.md, no extensions, no skills, no prompt templates loaded.
|
|
10
|
-
* - Thinking disabled, compaction disabled, retry
|
|
10
|
+
* - Thinking disabled, compaction disabled, one transient retry.
|
|
11
11
|
* - In-memory session (no disk I/O).
|
|
12
12
|
* - Shared auth/model infrastructure (no re-connection).
|
|
13
13
|
*
|
|
@@ -45,6 +45,18 @@ export interface UsageStats {
|
|
|
45
45
|
turns: number;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
export const DEFAULT_INACTIVITY_TIMEOUT_MS = 3 * 60 * 1000;
|
|
49
|
+
export const HARD_TIMEOUT_MS = 20 * 60 * 1000;
|
|
50
|
+
|
|
51
|
+
export interface SubAgentProgress {
|
|
52
|
+
label: string;
|
|
53
|
+
at: number;
|
|
54
|
+
elapsedMs: number;
|
|
55
|
+
inactivityDeadline: number;
|
|
56
|
+
hardDeadline: number;
|
|
57
|
+
result: SubAgentResult;
|
|
58
|
+
}
|
|
59
|
+
|
|
48
60
|
export interface SubAgentResult {
|
|
49
61
|
agent: string;
|
|
50
62
|
task: string;
|
|
@@ -81,233 +93,110 @@ export async function runSubAgent(options: {
|
|
|
81
93
|
agentName?: string;
|
|
82
94
|
thinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
83
95
|
onMessage?: (partialResult: SubAgentResult) => void;
|
|
84
|
-
|
|
96
|
+
onProgress?: (progress: SubAgentProgress) => void;
|
|
85
97
|
timeoutMs?: number;
|
|
98
|
+
hardTimeoutMs?: number;
|
|
86
99
|
}): Promise<SubAgentResult> {
|
|
87
100
|
const {
|
|
88
|
-
cwd,
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
tools,
|
|
92
|
-
model,
|
|
93
|
-
authStorage,
|
|
94
|
-
modelRegistry,
|
|
95
|
-
signal,
|
|
96
|
-
agentName = "subagent",
|
|
97
|
-
thinkingLevel = "off",
|
|
98
|
-
onMessage,
|
|
99
|
-
timeoutMs,
|
|
101
|
+
cwd, systemPrompt, task, tools, model, authStorage, modelRegistry, signal,
|
|
102
|
+
agentName = "subagent", thinkingLevel = "off", onMessage, onProgress,
|
|
103
|
+
timeoutMs = DEFAULT_INACTIVITY_TIMEOUT_MS, hardTimeoutMs = HARD_TIMEOUT_MS,
|
|
100
104
|
} = options;
|
|
101
|
-
|
|
102
105
|
const result: SubAgentResult = {
|
|
103
|
-
agent: agentName,
|
|
104
|
-
task,
|
|
105
|
-
exitCode: 0,
|
|
106
|
-
messages: [],
|
|
107
|
-
stderr: "",
|
|
106
|
+
agent: agentName, task, exitCode: 0, messages: [], stderr: "",
|
|
108
107
|
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
109
|
-
model: `${model.provider}/${model.id}`,
|
|
110
|
-
status: undefined,
|
|
108
|
+
model: `${model.provider}/${model.id}`, status: undefined,
|
|
111
109
|
};
|
|
112
|
-
|
|
113
|
-
// Build a minimal resource loader. The sub-agent sees ONLY the agent's
|
|
114
|
-
// system prompt — no pi defaults, no AGENTS.md, no extensions, no skills.
|
|
115
110
|
const resourceLoader: ResourceLoader = {
|
|
116
111
|
getExtensions: () => ({ extensions: [], errors: [], runtime: createExtensionRuntime() }),
|
|
117
|
-
getSkills: () => ({ skills: [], diagnostics: [] }),
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
getAgentsFiles: () => ({ agentsFiles: [] }),
|
|
121
|
-
getSystemPrompt: () => systemPrompt,
|
|
122
|
-
getAppendSystemPrompt: () => [],
|
|
123
|
-
extendResources: () => {},
|
|
124
|
-
reload: async () => {},
|
|
112
|
+
getSkills: () => ({ skills: [], diagnostics: [] }), getPrompts: () => ({ prompts: [], diagnostics: [] }),
|
|
113
|
+
getThemes: () => ({ themes: [], diagnostics: [] }), getAgentsFiles: () => ({ agentsFiles: [] }),
|
|
114
|
+
getSystemPrompt: () => systemPrompt, getAppendSystemPrompt: () => [], extendResources: () => {}, reload: async () => {},
|
|
125
115
|
};
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
let
|
|
134
|
-
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
116
|
+
const settingsManager = SettingsManager.inMemory({ compaction: { enabled: false }, retry: { enabled: true, maxRetries: 1 } });
|
|
117
|
+
const startedAt = Date.now();
|
|
118
|
+
let inactivityDeadline = startedAt + timeoutMs;
|
|
119
|
+
const hardDeadline = startedAt + hardTimeoutMs;
|
|
120
|
+
let timeoutKind: "idle" | "hard" | undefined;
|
|
121
|
+
const timeoutController = new AbortController();
|
|
122
|
+
let idleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
123
|
+
let hardTimer: ReturnType<typeof setTimeout> | undefined;
|
|
135
124
|
let cleanupCombined: (() => void) | undefined;
|
|
136
|
-
|
|
125
|
+
const clearTimers = () => { if (idleTimer) clearTimeout(idleTimer); if (hardTimer) clearTimeout(hardTimer); };
|
|
126
|
+
const armIdle = () => {
|
|
127
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
128
|
+
inactivityDeadline = Date.now() + timeoutMs;
|
|
129
|
+
idleTimer = setTimeout(() => { timeoutKind = "idle"; timeoutController.abort(new Error(`Idle timeout after ${timeoutMs}ms`)); }, timeoutMs);
|
|
130
|
+
};
|
|
131
|
+
const snapshot = (label: string): SubAgentProgress => ({ label, at: Date.now(), elapsedMs: Date.now() - startedAt, inactivityDeadline, hardDeadline, result: { ...result, messages: [...result.messages] } });
|
|
137
132
|
try {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
if (timeoutMs && timeoutMs > 0) {
|
|
143
|
-
timeoutController = new AbortController();
|
|
144
|
-
timeoutId = setTimeout(() => timeoutController!.abort(new Error(`Timeout after ${timeoutMs}ms`)), timeoutMs);
|
|
145
|
-
signalsToCombine.push(timeoutController.signal);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const { signal: combinedSignal, cleanup: cleanupCb } = createCombinedAbortSignal(signalsToCombine);
|
|
149
|
-
cleanupCombined = cleanupCb;
|
|
150
|
-
|
|
133
|
+
armIdle();
|
|
134
|
+
hardTimer = setTimeout(() => { timeoutKind = "hard"; timeoutController.abort(new Error(`Hard timeout after ${hardTimeoutMs}ms`)); }, hardTimeoutMs);
|
|
135
|
+
const { signal: combinedSignal, cleanup } = createCombinedAbortSignal([signal, timeoutController.signal]);
|
|
136
|
+
cleanupCombined = cleanup;
|
|
151
137
|
if (combinedSignal.aborted) {
|
|
152
138
|
result.exitCode = 1;
|
|
153
|
-
const
|
|
154
|
-
result.stopReason =
|
|
155
|
-
result.errorMessage =
|
|
156
|
-
result.status = classifyStopReason(result.stopReason, !
|
|
157
|
-
cleanupCombined?.();
|
|
158
|
-
if (timeoutId) clearTimeout(timeoutId);
|
|
139
|
+
const timedOut = timeoutController.signal.aborted && !signal?.aborted;
|
|
140
|
+
result.stopReason = timedOut ? "timeout" : "aborted";
|
|
141
|
+
result.errorMessage = timedOut ? `${timeoutKind === "idle" ? "Idle" : "Hard"} timeout after ${timeoutKind === "idle" ? timeoutMs : hardTimeoutMs}ms` : "Sub-agent aborted before start";
|
|
142
|
+
result.status = classifyStopReason(result.stopReason, !timedOut, timedOut);
|
|
159
143
|
return result;
|
|
160
144
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
model,
|
|
165
|
-
thinkingLevel,
|
|
166
|
-
authStorage,
|
|
167
|
-
modelRegistry,
|
|
168
|
-
resourceLoader,
|
|
169
|
-
tools,
|
|
170
|
-
sessionManager: SessionManager.inMemory(cwd),
|
|
171
|
-
settingsManager,
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
let cleanupAbort: (() => void) | undefined;
|
|
175
|
-
let cleanupEventAbort: (() => void) | undefined;
|
|
176
|
-
let abortedBySignal = false;
|
|
177
|
-
let timedOut = false;
|
|
178
|
-
let eventUnsubscribe: (() => void) | undefined;
|
|
179
|
-
|
|
145
|
+
const { session } = await createAgentSession({ cwd, model, thinkingLevel, authStorage, modelRegistry, resourceLoader, tools, sessionManager: SessionManager.inMemory(cwd), settingsManager });
|
|
146
|
+
let unsubscribe: (() => void) | undefined;
|
|
147
|
+
let removeAbort: (() => void) | undefined;
|
|
180
148
|
try {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
};
|
|
185
|
-
if (combinedSignal.aborted) {
|
|
186
|
-
abortedBySignal = true;
|
|
187
|
-
timedOut = timeoutController?.signal.aborted === true && signal?.aborted !== true;
|
|
188
|
-
onAbort();
|
|
189
|
-
return result;
|
|
190
|
-
}
|
|
191
|
-
combinedSignal.addEventListener("abort", onAbort, { once: true });
|
|
192
|
-
cleanupAbort = () => combinedSignal.removeEventListener("abort", onAbort);
|
|
193
|
-
|
|
194
|
-
// Collect all messages and usage stats from events
|
|
195
|
-
const eventPromise = new Promise<void>((resolve, reject) => {
|
|
196
|
-
let settled = false;
|
|
197
|
-
const finish = (fn: () => void) => {
|
|
198
|
-
if (settled) return;
|
|
199
|
-
settled = true;
|
|
200
|
-
fn();
|
|
201
|
-
};
|
|
202
|
-
|
|
203
|
-
let unsubscribe: (() => void) | undefined;
|
|
149
|
+
const eventDone = new Promise<void>((resolve, reject) => {
|
|
150
|
+
let done = false;
|
|
151
|
+
const finish = (fn: () => void) => { if (!done) { done = true; unsubscribe?.(); fn(); } };
|
|
204
152
|
unsubscribe = session.subscribe((event) => {
|
|
205
153
|
try {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
result.usage.cost += msg.usage.cost?.total || 0;
|
|
217
|
-
result.usage.contextTokens = msg.usage.totalTokens || 0;
|
|
218
|
-
}
|
|
219
|
-
if (!result.model && msg.model) {
|
|
220
|
-
result.model = `${msg.provider || "?"}/${msg.model}`;
|
|
221
|
-
}
|
|
222
|
-
if (msg.stopReason) result.stopReason = msg.stopReason;
|
|
223
|
-
if (msg.errorMessage) result.errorMessage = msg.errorMessage;
|
|
224
|
-
}
|
|
225
|
-
// Collect all messages for extraction
|
|
226
|
-
result.messages.push(msg as unknown as Message);
|
|
227
|
-
if (onMessage) onMessage({ ...result, messages: [...result.messages] });
|
|
228
|
-
break;
|
|
229
|
-
}
|
|
230
|
-
case "agent_end": {
|
|
231
|
-
// agent_end carries all messages; use them if we haven't collected
|
|
232
|
-
if (result.messages.length === 0 && event.messages) {
|
|
233
|
-
result.messages = event.messages as unknown as Message[];
|
|
234
|
-
}
|
|
235
|
-
finish(() => {
|
|
236
|
-
unsubscribe?.();
|
|
237
|
-
resolve();
|
|
238
|
-
});
|
|
239
|
-
break;
|
|
154
|
+
// Any SDK session lifecycle event is actual child activity, unlike a parent heartbeat.
|
|
155
|
+
armIdle(); onProgress?.(snapshot(event.type));
|
|
156
|
+
if (event.type === "message_end") {
|
|
157
|
+
const msg = event.message as AgentMessage;
|
|
158
|
+
if (msg.role === "assistant") {
|
|
159
|
+
result.usage.turns++;
|
|
160
|
+
if (msg.usage) { result.usage.input += msg.usage.input || 0; result.usage.output += msg.usage.output || 0; result.usage.cacheRead += msg.usage.cacheRead || 0; result.usage.cacheWrite += msg.usage.cacheWrite || 0; result.usage.cost += msg.usage.cost?.total || 0; result.usage.contextTokens = msg.usage.totalTokens || 0; }
|
|
161
|
+
if (!result.model && msg.model) result.model = `${msg.provider || "?"}/${msg.model}`;
|
|
162
|
+
if (msg.stopReason) result.stopReason = msg.stopReason;
|
|
163
|
+
result.errorMessage = msg.errorMessage;
|
|
240
164
|
}
|
|
165
|
+
result.messages.push(msg as unknown as Message);
|
|
166
|
+
onMessage?.({ ...result, messages: [...result.messages] });
|
|
167
|
+
} else if (event.type === "agent_end" && !event.willRetry) {
|
|
168
|
+
if (!result.messages.length && event.messages) result.messages = event.messages as unknown as Message[];
|
|
169
|
+
finish(resolve);
|
|
241
170
|
}
|
|
242
|
-
} catch (
|
|
243
|
-
finish(() => {
|
|
244
|
-
unsubscribe?.();
|
|
245
|
-
reject(err);
|
|
246
|
-
});
|
|
247
|
-
}
|
|
171
|
+
} catch (error) { finish(() => reject(error)); }
|
|
248
172
|
});
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
const onAbortResolve = () => {
|
|
253
|
-
finish(() => {
|
|
254
|
-
result.exitCode = 1;
|
|
255
|
-
if (!result.errorMessage) result.errorMessage = "Sub-agent aborted";
|
|
256
|
-
unsubscribe?.();
|
|
257
|
-
resolve();
|
|
258
|
-
});
|
|
259
|
-
};
|
|
260
|
-
combinedSignal.addEventListener("abort", onAbortResolve, { once: true });
|
|
261
|
-
cleanupEventAbort = () => combinedSignal.removeEventListener("abort", onAbortResolve);
|
|
173
|
+
const abort = () => finish(resolve);
|
|
174
|
+
combinedSignal.addEventListener("abort", abort, { once: true });
|
|
175
|
+
removeAbort = () => combinedSignal.removeEventListener("abort", abort);
|
|
262
176
|
});
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
abortedBySignal = combinedSignal.aborted && !timedOut;
|
|
272
|
-
|
|
273
|
-
if (timedOut) {
|
|
274
|
-
result.exitCode = 1;
|
|
275
|
-
result.stopReason = "timeout";
|
|
276
|
-
result.errorMessage ||= `Timeout after ${timeoutMs}ms`;
|
|
277
|
-
} else if (abortedBySignal) {
|
|
278
|
-
result.exitCode = 1;
|
|
279
|
-
result.stopReason = "aborted";
|
|
280
|
-
result.errorMessage ||= "Sub-agent aborted";
|
|
281
|
-
} else {
|
|
282
|
-
result.exitCode = 0;
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
// Classify canonical status.
|
|
177
|
+
const abortSession = () => session.abort();
|
|
178
|
+
combinedSignal.addEventListener("abort", abortSession, { once: true });
|
|
179
|
+
const removeSessionAbort = () => combinedSignal.removeEventListener("abort", abortSession);
|
|
180
|
+
await Promise.race([session.prompt(task), eventDone]);
|
|
181
|
+
removeSessionAbort();
|
|
182
|
+
const timedOut = timeoutController.signal.aborted && !signal?.aborted;
|
|
183
|
+
if (timedOut) { result.stopReason = "timeout"; result.errorMessage = `${timeoutKind === "idle" ? "Idle" : "Hard"} timeout after ${timeoutKind === "idle" ? timeoutMs : hardTimeoutMs}ms`; }
|
|
184
|
+
else if (combinedSignal.aborted) { result.stopReason = "aborted"; result.errorMessage ||= "Sub-agent aborted"; }
|
|
286
185
|
result.status = classifyStopReason(result.stopReason, result.stopReason === "aborted", result.stopReason === "timeout");
|
|
287
|
-
|
|
186
|
+
result.exitCode = result.status === "success" || result.status === "partial" ? 0 : 1;
|
|
288
187
|
return result;
|
|
289
188
|
} finally {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
cleanupCombined();
|
|
293
|
-
eventUnsubscribe?.();
|
|
294
|
-
if (timeoutId) clearTimeout(timeoutId);
|
|
295
|
-
try {
|
|
296
|
-
session.dispose();
|
|
297
|
-
} catch {
|
|
298
|
-
// Best-effort cleanup
|
|
299
|
-
}
|
|
189
|
+
unsubscribe?.(); removeAbort?.();
|
|
190
|
+
try { session.dispose(); } catch { /* best effort */ }
|
|
300
191
|
}
|
|
301
|
-
} catch (
|
|
302
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
192
|
+
} catch (error) {
|
|
303
193
|
result.exitCode = 1;
|
|
304
|
-
result.errorMessage = message;
|
|
305
|
-
|
|
306
|
-
result.status = classifyStopReason(
|
|
307
|
-
// Ensure cleanup runs even when the outer try fails before the inner finally.
|
|
308
|
-
cleanupCombined?.();
|
|
309
|
-
if (timeoutId) clearTimeout(timeoutId);
|
|
194
|
+
result.errorMessage = error instanceof Error ? error.message : String(error);
|
|
195
|
+
result.stopReason ||= "error";
|
|
196
|
+
result.status = classifyStopReason(result.stopReason, false, false);
|
|
310
197
|
return result;
|
|
198
|
+
} finally {
|
|
199
|
+
clearTimers(); cleanupCombined?.();
|
|
311
200
|
}
|
|
312
201
|
}
|
|
313
202
|
|
package/extensions/security.ts
CHANGED
|
@@ -28,11 +28,10 @@ export const MUTATION_TOOLS: readonly string[] = ["edit", "write"];
|
|
|
28
28
|
export const EXECUTION_TOOLS: readonly string[] = ["bash"];
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
|
-
* Default timeout
|
|
32
|
-
*
|
|
33
|
-
* the allowed range.
|
|
31
|
+
* Default inactivity timeout. Real SDK lifecycle activity resets this window;
|
|
32
|
+
* the runner separately enforces a fixed 20-minute absolute cap.
|
|
34
33
|
*/
|
|
35
|
-
export const DEFAULT_TIMEOUT_MS =
|
|
34
|
+
export const DEFAULT_TIMEOUT_MS = 3 * 60 * 1_000; // 3 minutes
|
|
36
35
|
|
|
37
36
|
/**
|
|
38
37
|
* Absolute maximum timeout. Any requested value above this cap is rejected
|
package/extensions/service.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { AuthStorage, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type
|
|
3
|
-
import { runSubAgent, type SubAgentResult } from "./runner.ts";
|
|
2
|
+
import { type AgentConfig, getModelCandidates } from "./agents.ts";
|
|
3
|
+
import { runSubAgent, type SubAgentProgress, type SubAgentResult } from "./runner.ts";
|
|
4
4
|
import { resolveModel } from "./model.ts";
|
|
5
5
|
import {
|
|
6
6
|
validateAgentTools,
|
|
@@ -23,6 +23,7 @@ export interface SubagentRunRequest {
|
|
|
23
23
|
signal?: AbortSignal;
|
|
24
24
|
accept?: () => boolean;
|
|
25
25
|
respond: (response: SubagentRunResponse) => void;
|
|
26
|
+
onProgress?: (progress: SubAgentProgress) => void;
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
export type SubagentRunResponse =
|
|
@@ -38,8 +39,9 @@ export async function runNamedAgent(options: {
|
|
|
38
39
|
instructions?: string;
|
|
39
40
|
signal?: AbortSignal;
|
|
40
41
|
onMessage?: (result: SubAgentResult) => void;
|
|
42
|
+
onProgress?: (progress: SubAgentProgress) => void;
|
|
41
43
|
}): Promise<SubAgentResult> {
|
|
42
|
-
const { model, attempted } = resolveModel(options.agent
|
|
44
|
+
const { model, attempted } = await resolveModel(getModelCandidates(options.agent), options.ctx.model, options.ctx.modelRegistry);
|
|
43
45
|
if (!model) throw new Error(`No model resolved for agent "${options.agent.name}" (tried: ${attempted.join(", ") || "none"})`);
|
|
44
46
|
|
|
45
47
|
const authStorage = AuthStorage.inMemory();
|
|
@@ -88,6 +90,7 @@ export async function runNamedAgent(options: {
|
|
|
88
90
|
agentName: options.agent.name,
|
|
89
91
|
thinkingLevel: options.agent.thinking,
|
|
90
92
|
onMessage: options.onMessage,
|
|
93
|
+
onProgress: options.onProgress,
|
|
91
94
|
});
|
|
92
95
|
return result;
|
|
93
96
|
} finally {
|
|
@@ -146,6 +146,14 @@ export class ThreadViewer {
|
|
|
146
146
|
lines.push(truncateToWidth(t.fg("dim", this.thread.task), width));
|
|
147
147
|
lines.push("");
|
|
148
148
|
|
|
149
|
+
if (status === "running") {
|
|
150
|
+
const now = Date.now();
|
|
151
|
+
const elapsed = Math.floor((now - this.thread.createdAt) / 1000);
|
|
152
|
+
const activity = this.thread.lastActivityAt ? `${Math.floor((now - this.thread.lastActivityAt) / 1000)}s ago (${this.thread.lastActivityLabel})` : "none yet";
|
|
153
|
+
const idleMs = this.thread.inactivityDeadline ? this.thread.inactivityDeadline - now : 0;
|
|
154
|
+
const idle = this.thread.inactivityDeadline ? `${Math.max(0, Math.ceil(idleMs / 1000))}s remaining` : "pending";
|
|
155
|
+
lines.push(truncateToWidth(t.fg(idleMs < 30_000 ? "warning" : "muted", `Elapsed ${elapsed}s · last activity ${activity} · idle ${idle}`), width));
|
|
156
|
+
}
|
|
149
157
|
if (status === "running" && (!result || result.messages.length === 0)) {
|
|
150
158
|
lines.push(truncateToWidth(t.fg("muted", "(waiting for first message...)"), width));
|
|
151
159
|
} else if (result) {
|
package/extensions/threads.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Supports subscriptions so UIs can react to thread status changes.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import type { SubAgentResult } from "./runner.ts";
|
|
9
|
+
import type { SubAgentProgress, SubAgentResult } from "./runner.ts";
|
|
10
10
|
|
|
11
11
|
// ---------------------------------------------------------------------------
|
|
12
12
|
// Types
|
|
@@ -26,6 +26,11 @@ export interface SubagentThread {
|
|
|
26
26
|
color?: string;
|
|
27
27
|
createdAt: number;
|
|
28
28
|
updatedAt: number;
|
|
29
|
+
lastActivityAt?: number;
|
|
30
|
+
lastActivityLabel?: string;
|
|
31
|
+
lastHeartbeatAt?: number;
|
|
32
|
+
inactivityDeadline?: number;
|
|
33
|
+
hardDeadline?: number;
|
|
29
34
|
}
|
|
30
35
|
|
|
31
36
|
// ---------------------------------------------------------------------------
|
|
@@ -84,6 +89,26 @@ export class ThreadStore {
|
|
|
84
89
|
this.notify();
|
|
85
90
|
}
|
|
86
91
|
|
|
92
|
+
updateProgress(id: string, progress: SubAgentProgress): void {
|
|
93
|
+
const thread = this.threads.get(id);
|
|
94
|
+
if (!thread) return;
|
|
95
|
+
thread.result = progress.result;
|
|
96
|
+
thread.lastActivityAt = progress.at;
|
|
97
|
+
thread.lastActivityLabel = progress.label;
|
|
98
|
+
thread.inactivityDeadline = progress.inactivityDeadline;
|
|
99
|
+
thread.hardDeadline = progress.hardDeadline;
|
|
100
|
+
thread.updatedAt = Date.now();
|
|
101
|
+
this.notify();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
refreshHeartbeat(id: string): void {
|
|
105
|
+
const thread = this.threads.get(id);
|
|
106
|
+
if (!thread) return;
|
|
107
|
+
thread.lastHeartbeatAt = Date.now();
|
|
108
|
+
thread.updatedAt = thread.lastHeartbeatAt;
|
|
109
|
+
this.notify();
|
|
110
|
+
}
|
|
111
|
+
|
|
87
112
|
getThread(id: string): SubagentThread | undefined {
|
|
88
113
|
return this.threads.get(id);
|
|
89
114
|
}
|
package/package.json
CHANGED