@bacnh85/pi-subagent 0.16.1 → 0.19.2
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 +86 -0
- package/README.md +17 -7
- package/agent-format.md +2 -1
- package/agents/planner.md +1 -0
- package/agents/reviewer.md +1 -0
- package/extensions/agents.ts +27 -0
- package/extensions/background.ts +14 -7
- package/extensions/index.ts +58 -13
- package/extensions/roles-panel.ts +119 -6
- package/extensions/runner.ts +88 -6
- package/extensions/security.ts +24 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,91 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.19.2 (2026-09-02)
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **Stalled streams now fall back to the next model** — when a provider
|
|
8
|
+
accepts a request but the stream emits zero events for the entire idle
|
|
9
|
+
window (known router/provider failure mode, e.g. slow-TTFT models behind
|
|
10
|
+
omniroute), the run previously died on model #1 with "Idle timeout" and
|
|
11
|
+
never tried the fallback chain. An IDLE timeout is now treated as a
|
|
12
|
+
capacity signal (`isRetryableModelResult`): the task retries on the next
|
|
13
|
+
candidate (each candidate is tried at most once, so worst case is N idle
|
|
14
|
+
windows). The HARD lifetime cap stays terminal — a task that ran 20 min is
|
|
15
|
+
genuinely huge, not a stall. Found by live background-task test: trivial
|
|
16
|
+
20+22 task timed out on glm-5-turbo with zero stream events.
|
|
17
|
+
|
|
18
|
+
## 0.19.1 (2026-09-02)
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- **Model fallback now engages on credential cooldown** — router providers
|
|
23
|
+
report "All credentials for model X are cooling down" when every key for a
|
|
24
|
+
model is in its rate-limit window. That message didn't match
|
|
25
|
+
`RATE_LIMIT_PATTERNS`, so `runWithModelFallback` treated it as a fatal error
|
|
26
|
+
instead of advancing to the next candidate (e.g. `@fast`
|
|
27
|
+
glm-5-turbo → gpt-oss-20b → deepseek-v4-flash), aborting subagent and chain
|
|
28
|
+
runs with "All credentials … are cooling down". Added the cooldown pattern
|
|
29
|
+
(credential cooldown / cooldown window / cooling down) so capacity signals
|
|
30
|
+
from the credential layer trigger the same fallback as 429s.
|
|
31
|
+
|
|
32
|
+
## 0.19.0 (2026-09-02)
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- **Reviewer/planner agents no longer abort at the 3-min idle timeout while
|
|
37
|
+
thinking** — root cause: a `thinking: high` child on a slow model can spend
|
|
38
|
+
many minutes in one reasoning stretch, and the default 3-min inactivity
|
|
39
|
+
window kills the run even though it is healthy. Fix: new agent frontmatter
|
|
40
|
+
field **`timeout: <minutes>`** (1–60) bakes a per-agent idle window; the
|
|
41
|
+
hard lifetime cap is raised to match so long windows are actually
|
|
42
|
+
enforceable. Bundled `reviewer` and `planner` agents ship with
|
|
43
|
+
`timeout: 10`. Per-call `timeout` still overrides. (The idle timer already
|
|
44
|
+
treats every SDK event — including streaming deltas — as activity; a run
|
|
45
|
+
that emits no events at all for the window is genuinely hung.)
|
|
46
|
+
Reported by peer mbp-sao-9915 (review rounds hitting the 180s default).
|
|
47
|
+
|
|
48
|
+
## 0.18.0 (2026-09-02)
|
|
49
|
+
|
|
50
|
+
### Fixed
|
|
51
|
+
|
|
52
|
+
- **Worktree patches now reach the parent model** — `sandbox: worktree` results
|
|
53
|
+
previously exposed the diff only in TUI details; the tool-result text the
|
|
54
|
+
parent model reads contained just the child's final message, so the
|
|
55
|
+
documented "parent merges via apply_patch" flow was impossible in practice.
|
|
56
|
+
The diff is now appended as a `🌿 worktree patch` block (capped at the
|
|
57
|
+
per-task output limit) in single, parallel, chain, and background-completion
|
|
58
|
+
results, and `operation: "status"` reports `Patch: N diff lines`.
|
|
59
|
+
|
|
60
|
+
### Added
|
|
61
|
+
|
|
62
|
+
- **`merge: "3way"` per call/item** — with a worktree-sandboxed agent, the
|
|
63
|
+
captured diff is applied to the parent checkout via `git apply --3way`
|
|
64
|
+
(temp-file at repo root, applied before worktree removal so the staged blobs
|
|
65
|
+
are available for 3-way reconstruction). Results carry
|
|
66
|
+
`mergeStatus: "applied" | "conflict"`; conflicts keep git's markers, report
|
|
67
|
+
the apply error, and still deliver the patch for manual merging — never
|
|
68
|
+
silently resolved. Concurrent applies are serialized (OMP `withRepoLock`
|
|
69
|
+
equivalent) so parallel siblings cannot race the checkout.
|
|
70
|
+
- Applies are mutex-serialized across parallel/background tasks.
|
|
71
|
+
|
|
72
|
+
## 0.17.0 (2026-08-31)
|
|
73
|
+
|
|
74
|
+
### Added
|
|
75
|
+
|
|
76
|
+
- **Add/remove model roles from the `/subagent` panel** — new `+ Add role` /
|
|
77
|
+
`− Remove role` action rows (two-prompt flow: name → chain). Custom roles are
|
|
78
|
+
deletable; built-in roles (`fast`/`coder`/`smart`) reset to their bundled
|
|
79
|
+
default. New names validate against `[A-Za-z0-9._-]{1,64}` with
|
|
80
|
+
case-insensitive collision checks.
|
|
81
|
+
- **Per-agent default display** — each override row now shows the agent's
|
|
82
|
+
default (no-override) chain, e.g. `scout (default: @fast → zai-coding-cn/
|
|
83
|
+
glm-5-turbo, …)`, so blank = inherit is meaningful. Labels track live role
|
|
84
|
+
edits and freshly added roles appear in `@role` completions immediately.
|
|
85
|
+
- Panel save is now guarded by a working-copy content diff (action-only
|
|
86
|
+
sessions — add/remove without row edits — previously left `editedKeys` empty
|
|
87
|
+
and silently skipped persistence).
|
|
88
|
+
|
|
3
89
|
## 0.16.1 (2026-08-29)
|
|
4
90
|
|
|
5
91
|
### Added
|
package/README.md
CHANGED
|
@@ -34,9 +34,11 @@ in `~/.pi/agent/settings.json` under `subagent.roles`:
|
|
|
34
34
|
|
|
35
35
|
`/subagent` opens the interactive role editor (TUI panel via the shared
|
|
36
36
|
`@bacnh85/pi-config-panel` kernel; prints the effective mapping headless).
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
The panel supports `+ Add role` / `− Remove role` (custom roles are deletable,
|
|
38
|
+
built-ins reset to their bundled default) and shows each agent's default
|
|
39
|
+
(no-override) chain on its row. `/subagent list` lists agents,
|
|
40
|
+
`/subagent <name>` shows an agent's resolved chain, and `/subagent @role`
|
|
41
|
+
(or `/subagent fast`) shows a role's chain and the agents using it.
|
|
40
42
|
|
|
41
43
|
## Live progress widget
|
|
42
44
|
|
|
@@ -164,10 +166,18 @@ two `worker` agents editing the same files can no longer clobber each other —
|
|
|
164
166
|
each writes into its own checkout.
|
|
165
167
|
|
|
166
168
|
- All file mutations land in the worktree; the main checkout stays untouched.
|
|
167
|
-
- On completion,
|
|
168
|
-
result
|
|
169
|
-
|
|
170
|
-
|
|
169
|
+
- On completion, the unified diff of the child's changes is included in the
|
|
170
|
+
tool result as a `🌿 worktree patch` block (capped at the per-task output
|
|
171
|
+
limit) and shown in the thread viewer as a `🌿 worktree` badge.
|
|
172
|
+
- **Merging**: by default the parent merges explicitly via `apply_patch` /
|
|
173
|
+
`git apply` / discard. Pass `merge: "3way"` (per call/item) to have the diff
|
|
174
|
+
applied automatically to the parent checkout via `git apply --3way` once the
|
|
175
|
+
child completes — conflicts leave git's conflict markers in place, are
|
|
176
|
+
reported in the result (`mergeStatus: "conflict"`), and the patch is still
|
|
177
|
+
delivered for manual merging. Nothing is ever silently resolved. Applies are
|
|
178
|
+
serialized so parallel siblings cannot race the checkout.
|
|
179
|
+
- Children start from `HEAD`: uncommitted changes in the parent checkout are
|
|
180
|
+
invisible to the child and will surface as apply conflicts when merging.
|
|
171
181
|
- The worktree is removed on completion (success, error, or abort).
|
|
172
182
|
- Requires git; when the cwd is not a git repo, the agent falls back to
|
|
173
183
|
in-process execution with a warning (`ponytail`: isolation optimization,
|
package/agent-format.md
CHANGED
|
@@ -26,6 +26,7 @@ models: # Optional ordered fallbacks; comma form also accepted
|
|
|
26
26
|
thinking: low # Optional: off|minimal|low|medium|high|xhigh|max.
|
|
27
27
|
sandbox: read-only # Optional: read-only | workspace-write | worktree. Auto-derives tool restrictions.
|
|
28
28
|
color: cyan # Optional: red|blue|green|yellow|purple|orange|pink|cyan.
|
|
29
|
+
timeout: 10 # Optional: per-agent inactivity timeout in minutes (1–60). Default 3. For slow-thinking (thinking: high) agents.
|
|
29
30
|
---
|
|
30
31
|
```
|
|
31
32
|
|
|
@@ -42,7 +43,7 @@ agent's `thinking` for that match. See README → *Role-based model routing*.
|
|
|
42
43
|
|
|
43
44
|
- `read-only`: Restricts tools to the read-only allowlist (`read`, `grep`, `find`, `ls` plus read-only extension tools when inherited — `web_*`, `serena_*`, `munin_*`, `fff*`, …). Overrides any `tools` field.
|
|
44
45
|
- `workspace-write` (default): Uses the agent's `tools` list or defaults to all tools.
|
|
45
|
-
- `worktree`: Runs the agent in an isolated git worktree (`.pi-worktrees/<id>` under the repo root). All file mutations land in the worktree; the main checkout is untouched. On completion,
|
|
46
|
+
- `worktree`: Runs the agent in an isolated git worktree (`.pi-worktrees/<id>` under the repo root). All file mutations land in the worktree; the main checkout is untouched. On completion, the unified diff is delivered in the tool result as a `🌿 worktree patch` block; pass `merge: "3way"` to apply it to the parent checkout automatically (`git apply --3way`, conflicts reported as `mergeStatus: "conflict"` with markers left in place). Falls back to in-process execution when the cwd is not a git repo (with a warning). Requires git.
|
|
46
47
|
|
|
47
48
|
### `color`
|
|
48
49
|
|
package/agents/planner.md
CHANGED
package/agents/reviewer.md
CHANGED
package/extensions/agents.ts
CHANGED
|
@@ -24,6 +24,8 @@ export interface AgentConfig {
|
|
|
24
24
|
thinking?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
25
25
|
sandbox?: "read-only" | "workspace-write" | "worktree";
|
|
26
26
|
color?: AgentColor;
|
|
27
|
+
/** Per-agent default inactivity timeout in minutes (1–60). Per-call timeout overrides. */
|
|
28
|
+
timeout?: number;
|
|
27
29
|
systemPrompt: string;
|
|
28
30
|
source: "user" | "project" | "bundled";
|
|
29
31
|
filePath: string;
|
|
@@ -224,6 +226,30 @@ function loadAgentsFromDir(
|
|
|
224
226
|
});
|
|
225
227
|
}
|
|
226
228
|
|
|
229
|
+
// Per-agent inactivity timeout (minutes) — lets slow-thinking agents
|
|
230
|
+
// (thinking: high) raise the 3-min default without per-call params.
|
|
231
|
+
let timeout: number | undefined;
|
|
232
|
+
if (frontmatter.timeout !== undefined && typeof frontmatter.timeout !== "boolean" && !Array.isArray(frontmatter.timeout)) {
|
|
233
|
+
const t = Number(frontmatter.timeout);
|
|
234
|
+
if (Number.isInteger(t) && t >= 1 && t <= 60) {
|
|
235
|
+
timeout = t;
|
|
236
|
+
} else {
|
|
237
|
+
diagnostics.push({
|
|
238
|
+
filePath,
|
|
239
|
+
issue: `Invalid timeout "${frontmatter.timeout}". Must be an integer 1–60 (minutes). Ignoring.`,
|
|
240
|
+
severity: "warn",
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
} else if (frontmatter.timeout !== undefined) {
|
|
244
|
+
// Booleans/arrays/objects: Number() would coerce (true→1, [10]→10), so
|
|
245
|
+
// reject up front with the same diagnostic instead of silently accepting.
|
|
246
|
+
diagnostics.push({
|
|
247
|
+
filePath,
|
|
248
|
+
issue: `Invalid timeout "${String(frontmatter.timeout)}". Must be an integer 1–60 (minutes). Ignoring.`,
|
|
249
|
+
severity: "warn",
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
227
253
|
agents.push({
|
|
228
254
|
name: frontmatter.name,
|
|
229
255
|
description: frontmatter.description,
|
|
@@ -239,6 +265,7 @@ function loadAgentsFromDir(
|
|
|
239
265
|
color: typeof frontmatter.color === "string" && VALID_COLORS.includes(frontmatter.color as any)
|
|
240
266
|
? frontmatter.color as AgentColor
|
|
241
267
|
: undefined,
|
|
268
|
+
timeout,
|
|
242
269
|
systemPrompt: body,
|
|
243
270
|
source,
|
|
244
271
|
filePath,
|
package/extensions/background.ts
CHANGED
|
@@ -12,7 +12,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
12
12
|
import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
import type { SubAgentResult, SubAgentProgress } from "./runner.ts";
|
|
15
|
-
import { isFailedResult, getResultOutput, getFinalOutput } from "./runner.ts";
|
|
15
|
+
import { isFailedResult, getResultOutput, getFinalOutput, formatPatchBlock } from "./runner.ts";
|
|
16
16
|
import type { threadStore as ThreadStoreType } from "./threads.ts";
|
|
17
17
|
import type { AgentScope } from "./agents.ts";
|
|
18
18
|
import { parseStructuredResult } from "./result.ts";
|
|
@@ -59,6 +59,7 @@ export interface BackgroundDeps {
|
|
|
59
59
|
onHeartbeatDetails: () => BackgroundDetails,
|
|
60
60
|
onHeartbeat: () => void,
|
|
61
61
|
isReadOnly?: boolean,
|
|
62
|
+
merge?: "3way",
|
|
62
63
|
) => Promise<SubAgentResult>;
|
|
63
64
|
threadStore: typeof ThreadStoreType;
|
|
64
65
|
}
|
|
@@ -97,7 +98,7 @@ export interface TaskSnapshot {
|
|
|
97
98
|
completedAt?: number;
|
|
98
99
|
elapsedMs: number;
|
|
99
100
|
threadId: string;
|
|
100
|
-
result?: { output: string; model?: string; usage?: unknown };
|
|
101
|
+
result?: { output: string; model?: string; usage?: unknown; patchLines?: number };
|
|
101
102
|
}
|
|
102
103
|
|
|
103
104
|
export function snapshotTask(task: BackgroundTask, now = Date.now()): TaskSnapshot {
|
|
@@ -112,7 +113,7 @@ export function snapshotTask(task: BackgroundTask, now = Date.now()): TaskSnapsh
|
|
|
112
113
|
elapsedMs: now - task.startedAt,
|
|
113
114
|
threadId: task.threadId,
|
|
114
115
|
result: result
|
|
115
|
-
? { output: getResultOutput(result), model: result.model, usage: result.usage }
|
|
116
|
+
? { output: getResultOutput(result), model: result.model, usage: result.usage, patchLines: result.patch && result.patch !== "(no changes)" ? result.patch.split("\n").length : undefined }
|
|
116
117
|
: undefined,
|
|
117
118
|
};
|
|
118
119
|
}
|
|
@@ -126,6 +127,7 @@ export interface StartBackgroundInput {
|
|
|
126
127
|
task: string;
|
|
127
128
|
cwd?: string;
|
|
128
129
|
timeout?: number;
|
|
130
|
+
merge?: "3way";
|
|
129
131
|
agentColor?: string;
|
|
130
132
|
toolCallId?: string;
|
|
131
133
|
deps: BackgroundDeps;
|
|
@@ -141,7 +143,7 @@ export interface StartBackgroundResult {
|
|
|
141
143
|
* On completion, delivers a follow-up turn to the parent session.
|
|
142
144
|
*/
|
|
143
145
|
export function startBackgroundTask(input: StartBackgroundInput): StartBackgroundResult {
|
|
144
|
-
const { agent, task, cwd, timeout, agentColor, toolCallId, deps } = input;
|
|
146
|
+
const { agent, task, cwd, timeout, merge, agentColor, toolCallId, deps } = input;
|
|
145
147
|
const taskId = `bg-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
|
|
146
148
|
const controller = new AbortController();
|
|
147
149
|
const startedAt = Date.now();
|
|
@@ -191,6 +193,8 @@ export function startBackgroundTask(input: StartBackgroundInput): StartBackgroun
|
|
|
191
193
|
(progress) => deps.threadStore.updateProgress(thread.id, progress),
|
|
192
194
|
() => ({ mode: "single" as const, agentScope: "user" as const, projectAgentsDir: null, results: [] }),
|
|
193
195
|
() => deps.threadStore.refreshHeartbeat(thread.id),
|
|
196
|
+
undefined,
|
|
197
|
+
merge,
|
|
194
198
|
)
|
|
195
199
|
.then((result) => {
|
|
196
200
|
bgTask.result = result;
|
|
@@ -261,13 +265,16 @@ function deliverCompletion(task: BackgroundTask, result: SubAgentResult, deps: B
|
|
|
261
265
|
const phase = task.status;
|
|
262
266
|
const summary = output.split("\n").slice(0, 1)[0]!.slice(0, 200);
|
|
263
267
|
const elapsedMs = (task.completedAt ?? Date.now()) - task.startedAt;
|
|
268
|
+
// Worktree patch must reach the parent model in the follow-up turn (P0).
|
|
269
|
+
const patchBlock = formatPatchBlock(result);
|
|
270
|
+
const body = failed
|
|
271
|
+
? `Background task ${task.id} (${task.agent}) ${phase}.\n\n${getResultOutput(result)}`
|
|
272
|
+
: `Background task ${task.id} (${task.agent}) completed.\n\n${output}`;
|
|
264
273
|
|
|
265
274
|
deps.pi.sendMessage(
|
|
266
275
|
{
|
|
267
276
|
customType: "pi-subagent-complete",
|
|
268
|
-
content:
|
|
269
|
-
? `Background task ${task.id} (${task.agent}) ${phase}.\n\n${getResultOutput(result)}`
|
|
270
|
-
: `Background task ${task.id} (${task.agent}) completed.\n\n${output}`,
|
|
277
|
+
content: patchBlock ? `${body}\n\n${patchBlock}` : body,
|
|
271
278
|
display: true,
|
|
272
279
|
details: {
|
|
273
280
|
task_id: task.id,
|
package/extensions/index.ts
CHANGED
|
@@ -31,6 +31,7 @@ import { type AgentColor, type AgentConfig, type AgentScope, discoverAgents, for
|
|
|
31
31
|
import {
|
|
32
32
|
type SubAgentProgress,
|
|
33
33
|
type SubAgentResult,
|
|
34
|
+
formatPatchBlock,
|
|
34
35
|
getFinalOutput,
|
|
35
36
|
getResultOutput,
|
|
36
37
|
isFailedResult,
|
|
@@ -40,7 +41,7 @@ import {
|
|
|
40
41
|
} from "./runner.ts";
|
|
41
42
|
import {
|
|
42
43
|
flushWarnings,
|
|
43
|
-
|
|
44
|
+
isRetryableModelResult,
|
|
44
45
|
normalizeTimeout,
|
|
45
46
|
resolveSafeCwd,
|
|
46
47
|
validateAgentTools,
|
|
@@ -52,6 +53,7 @@ import {
|
|
|
52
53
|
MAX_PARALLEL_TASKS,
|
|
53
54
|
MAX_CHAIN_LENGTH,
|
|
54
55
|
MAX_INSTRUCTIONS_LENGTH,
|
|
56
|
+
HARD_TIMEOUT_MS,
|
|
55
57
|
} from "./security.ts";
|
|
56
58
|
import {
|
|
57
59
|
aggregateUsage,
|
|
@@ -111,6 +113,7 @@ const TaskItem = Type.Object({
|
|
|
111
113
|
task: Type.String({ description: "Task to delegate to the agent" }),
|
|
112
114
|
cwd: Type.Optional(Type.String({ description: "Working directory for the agent" })),
|
|
113
115
|
timeout: Type.Optional(Type.Number({ description: "Inactivity timeout in ms; aborts on no activity within timeout. Default: 3 min (PI_SUBAGENT_INACTIVITY_TIMEOUT_MINS). The agent always has a lifetime cap: default 20 min or (PI_SUBAGENT_HARD_TIMEOUT_MINS)." })),
|
|
116
|
+
merge: Type.Optional(StringEnum(["3way"] as const, { description: "With a worktree-sandboxed agent, apply its diff to the parent checkout via git apply --3way after it completes. Conflicts are reported, not resolved. Default: patch returned only." })),
|
|
114
117
|
});
|
|
115
118
|
|
|
116
119
|
const ChainItem = Type.Object({
|
|
@@ -118,6 +121,7 @@ const ChainItem = Type.Object({
|
|
|
118
121
|
task: Type.String({ description: "Task with optional {previous} placeholder for prior output" }),
|
|
119
122
|
cwd: Type.Optional(Type.String({ description: "Working directory for the agent" })),
|
|
120
123
|
timeout: Type.Optional(Type.Number({ description: "Inactivity timeout in ms; aborts on no activity within timeout. Default: 3 min (PI_SUBAGENT_INACTIVITY_TIMEOUT_MINS). The agent always has a lifetime cap: default 20 min or (PI_SUBAGENT_HARD_TIMEOUT_MINS)." })),
|
|
124
|
+
merge: Type.Optional(StringEnum(["3way"] as const, { description: "With a worktree-sandboxed agent, apply its diff to the parent checkout via git apply --3way after the step completes. Conflicts are reported, not resolved." })),
|
|
121
125
|
});
|
|
122
126
|
|
|
123
127
|
const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, {
|
|
@@ -157,6 +161,7 @@ const SubagentParams = Type.Object({
|
|
|
157
161
|
// See Security model section in README.
|
|
158
162
|
cwd: Type.Optional(Type.String({ description: "Working directory (single mode, must be inside workspace)" })),
|
|
159
163
|
timeout: Type.Optional(Type.Number({ description: "Inactivity timeout for the whole run, in ms; resets on activity, aborts on silence. Default 3 min (PI_SUBAGENT_INACTIVITY_TIMEOUT_MINS). Lifetime cap: 20 min or (PI_SUBAGENT_HARD_TIMEOUT_MINS)." })),
|
|
164
|
+
merge: Type.Optional(StringEnum(["3way"] as const, { description: "Single mode: with a worktree-sandboxed agent, apply its diff to the parent checkout via git apply --3way after it completes. Conflicts are reported, not resolved. Default: patch returned only." })),
|
|
160
165
|
instructions: Type.Optional(Type.String({ description: "Bounded repository/task instructions passed to each child (max 16 KB)" })),
|
|
161
166
|
abortOnFailure: Type.Optional(Type.Boolean({ description: "In parallel mode, cancel remaining tasks when one fails. Default: false.", default: false })),
|
|
162
167
|
});
|
|
@@ -378,7 +383,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
378
383
|
|
|
379
384
|
const openRolesEditor = async (): Promise<void> => {
|
|
380
385
|
// Role mapping editor: panel in TUI, plain text otherwise.
|
|
381
|
-
const [{ openConfigPanel }, { buildRows, buildRolesPanelCfg, cfgToPatch, preserveUnknownAgentModels, writeSubagentSection }] = await Promise.all([
|
|
386
|
+
const [{ openConfigPanel }, { buildRows, buildRolesPanelCfg, cfgToPatch, makeAddRoleAction, makeRemoveRoleAction, preserveUnknownAgentModels, writeSubagentSection }] = await Promise.all([
|
|
382
387
|
import("@bacnh85/pi-config-panel"),
|
|
383
388
|
import("./roles-panel.ts"),
|
|
384
389
|
]);
|
|
@@ -401,20 +406,31 @@ export default function (pi: ExtensionAPI) {
|
|
|
401
406
|
}
|
|
402
407
|
const current = readSubagentRolesGlobal();
|
|
403
408
|
const working = buildRolesPanelCfg(discovery.agents, current);
|
|
409
|
+
// Actions (add/remove role) set model.dirty but not editedKeys — guard
|
|
410
|
+
// the save on a working-copy diff instead (pi-a2a pattern).
|
|
411
|
+
const before = JSON.stringify([working.roles, working.agentModels]);
|
|
412
|
+
const notify = (message: string, kind?: "info" | "warning" | "error") => ctx.ui.notify(message, kind ?? "info");
|
|
404
413
|
const panelOptions = {
|
|
405
414
|
models: () => {
|
|
406
415
|
try { return ctx.modelRegistry.getAvailable().map((m) => `${m.provider}/${m.id}`); }
|
|
407
416
|
catch { return []; }
|
|
408
417
|
},
|
|
409
|
-
|
|
418
|
+
// Working copy: tracks live edits + freshly added (unsaved) roles.
|
|
419
|
+
roles: () => Object.keys(working.roles),
|
|
420
|
+
effectiveRoles: working.roles,
|
|
421
|
+
};
|
|
422
|
+
const actions = {
|
|
423
|
+
addRole: makeAddRoleAction(working, { notify }),
|
|
424
|
+
removeRole: makeRemoveRoleAction(working, { notify }),
|
|
410
425
|
};
|
|
411
426
|
await openConfigPanel({
|
|
412
427
|
ctx,
|
|
413
428
|
cfg: working,
|
|
414
|
-
|
|
429
|
+
actions,
|
|
430
|
+
build: (cfg, panelActions) => buildRows(cfg, discovery.agents, panelOptions, panelActions),
|
|
415
431
|
title: "Subagent model roles",
|
|
416
|
-
onSave: (saved
|
|
417
|
-
if (!
|
|
432
|
+
onSave: (saved) => {
|
|
433
|
+
if (!saved || JSON.stringify([working.roles, working.agentModels]) === before) return;
|
|
418
434
|
const patch = cfgToPatch(working);
|
|
419
435
|
patch.agentModels = preserveUnknownAgentModels(
|
|
420
436
|
patch.agentModels,
|
|
@@ -585,6 +601,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
585
601
|
"Bundled agents: scout (fast recon), tester (verification), worker (implementation), general-purpose (fallback), planner (planning), reviewer (review).",
|
|
586
602
|
"For background single tasks use background:true — you will be notified on completion; DO NOT poll or sleep.",
|
|
587
603
|
"Use operation: \"status\" with taskId to inspect a running/completed background task; operation: \"cancel\" to abort one.",
|
|
604
|
+
"Worktree-sandboxed agents return their diff as a patch block in the result; pass merge: \"3way\" to auto-apply it to the parent checkout (git apply --3way; conflicts reported, never silently resolved).",
|
|
588
605
|
"Use /subagent list to list all available agents, /subagent <name> for agent details, /subagent @role for role detail.",
|
|
589
606
|
],
|
|
590
607
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
@@ -660,6 +677,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
660
677
|
];
|
|
661
678
|
if (snap.result) {
|
|
662
679
|
lines.push(`Output: ${String(snap.result.output).slice(0, 2000)}`);
|
|
680
|
+
if (snap.result.patchLines) lines.push(`Patch: ${snap.result.patchLines} diff lines (worktree)`);
|
|
663
681
|
} else {
|
|
664
682
|
lines.push("(still running — no final output yet)");
|
|
665
683
|
}
|
|
@@ -861,6 +879,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
861
879
|
heartbeatDetails?: () => SubagentDetails,
|
|
862
880
|
onHeartbeat?: () => void,
|
|
863
881
|
isReadOnly?: boolean,
|
|
882
|
+
merge?: "3way",
|
|
864
883
|
): Promise<SubAgentResult> {
|
|
865
884
|
const agent = agents.find((a) => a.name === agentName);
|
|
866
885
|
|
|
@@ -901,12 +920,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
901
920
|
let tools: string[];
|
|
902
921
|
let loadExtensions: boolean;
|
|
903
922
|
let effectiveTimeoutMs: number | undefined;
|
|
923
|
+
let effectiveHardMs: number | undefined;
|
|
904
924
|
let safeCwd: string;
|
|
905
925
|
try {
|
|
906
926
|
const resolved = resolveChildTools(agent.tools, agent.sandbox, isReadOnly);
|
|
907
927
|
tools = resolved.tools;
|
|
908
928
|
loadExtensions = resolved.loadExtensions;
|
|
909
|
-
|
|
929
|
+
// Precedence: per-call timeout > agent frontmatter default > global default.
|
|
930
|
+
// The hard lifetime cap must never be shorter than the idle window
|
|
931
|
+
// (same invariant as the env-var clamp in security.ts) — an agent
|
|
932
|
+
// with timeout: 45 under a 20-min default cap would otherwise be
|
|
933
|
+
// hard-killed mid-stream while visibly producing deltas.
|
|
934
|
+
effectiveTimeoutMs = resolveChildTimeout(timeoutMs ?? (agent.timeout ? agent.timeout * 60 * 1_000 : undefined), params.timeout);
|
|
935
|
+
effectiveHardMs = Math.max(HARD_TIMEOUT_MS, effectiveTimeoutMs ?? 0);
|
|
910
936
|
safeCwd = resolveChildCwd(cwd);
|
|
911
937
|
} catch (err: unknown) {
|
|
912
938
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
@@ -947,6 +973,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
947
973
|
runSubAgent({
|
|
948
974
|
cwd: safeCwd,
|
|
949
975
|
sandbox: agent.sandbox === "worktree" ? "worktree" : undefined,
|
|
976
|
+
merge: agent.sandbox === "worktree" ? merge : undefined,
|
|
950
977
|
systemPrompt: params.instructions
|
|
951
978
|
? `${agent.systemPrompt}\n\n## Task Contract\n${params.instructions.slice(0, MAX_INSTRUCTIONS_LENGTH)}`
|
|
952
979
|
: agent.systemPrompt,
|
|
@@ -958,6 +985,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
958
985
|
modelRegistry,
|
|
959
986
|
signal: parentSignal,
|
|
960
987
|
timeoutMs: effectiveTimeoutMs,
|
|
988
|
+
hardTimeoutMs: effectiveHardMs,
|
|
961
989
|
agentName,
|
|
962
990
|
thinkingLevel,
|
|
963
991
|
onMessage: onProgress,
|
|
@@ -965,7 +993,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
965
993
|
loadExtensions,
|
|
966
994
|
projectTrusted,
|
|
967
995
|
}),
|
|
968
|
-
isRateLimited: (result) =>
|
|
996
|
+
isRateLimited: (result) => isRetryableModelResult(result),
|
|
969
997
|
onExhausted: (reason, triedModels, remaining) => {
|
|
970
998
|
const exhaustedStderr = reason === "no-model"
|
|
971
999
|
? [
|
|
@@ -1024,6 +1052,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1024
1052
|
(progress) => threadStore.updateProgress(thread.id, progress),
|
|
1025
1053
|
() => makeDetails("chain")(results),
|
|
1026
1054
|
() => threadStore.refreshHeartbeat(thread.id),
|
|
1055
|
+
undefined,
|
|
1056
|
+
step.merge,
|
|
1027
1057
|
);
|
|
1028
1058
|
threadStore.updateThread(thread.id, {
|
|
1029
1059
|
status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
|
|
@@ -1044,6 +1074,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1044
1074
|
// Include successful previous step outputs in the error content
|
|
1045
1075
|
const prevCount = i;
|
|
1046
1076
|
let contentText = `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}`;
|
|
1077
|
+
const failedPatch = formatPatchBlock(result);
|
|
1078
|
+
if (failedPatch) contentText += `\n\n${failedPatch}`;
|
|
1047
1079
|
if (prevCount > 0) {
|
|
1048
1080
|
const prevSummaries = results
|
|
1049
1081
|
.slice(0, prevCount)
|
|
@@ -1065,16 +1097,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
1065
1097
|
|
|
1066
1098
|
if (onUpdate) {
|
|
1067
1099
|
onUpdate({
|
|
1068
|
-
content: [{ type: "text", text: getFinalOutput(result.messages) || "(no output)" }],
|
|
1100
|
+
content: [{ type: "text", text: [getFinalOutput(result.messages) || "(no output)", formatPatchBlock(result)].filter(Boolean).join("\n\n") }],
|
|
1069
1101
|
details: makeDetails("chain")(results),
|
|
1070
1102
|
});
|
|
1071
1103
|
}
|
|
1072
1104
|
}
|
|
1073
1105
|
|
|
1074
1106
|
const last = results[results.length - 1];
|
|
1107
|
+
// Chain deliverable: final output plus every step's worktree patch,
|
|
1108
|
+
// labeled so the parent can merge them in order.
|
|
1109
|
+
const chainPatches = results
|
|
1110
|
+
.map((r, i) => (formatPatchBlock(r) ? `Step ${i + 1} (${r.agent}): ${formatPatchBlock(r)}` : ""))
|
|
1111
|
+
.filter(Boolean)
|
|
1112
|
+
.join("\n\n");
|
|
1075
1113
|
return {
|
|
1076
1114
|
content: [
|
|
1077
|
-
{ type: "text", text: getFinalOutput(last.messages) || "(no output)" },
|
|
1115
|
+
{ type: "text", text: [getFinalOutput(last.messages) || "(no output)", chainPatches].filter(Boolean).join("\n\n") },
|
|
1078
1116
|
],
|
|
1079
1117
|
details: makeDetails("chain")(results),
|
|
1080
1118
|
};
|
|
@@ -1184,6 +1222,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1184
1222
|
(progress) => threadStore.updateProgress(parallelThreads[index].id, progress),
|
|
1185
1223
|
() => makeDetails("parallel")([...allResults]),
|
|
1186
1224
|
() => threadStore.refreshHeartbeat(parallelThreads[index].id),
|
|
1225
|
+
undefined,
|
|
1226
|
+
t.merge,
|
|
1187
1227
|
);
|
|
1188
1228
|
allResults[index] = result;
|
|
1189
1229
|
threadStore.updateThread(parallelThreads[index].id, {
|
|
@@ -1208,7 +1248,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1208
1248
|
const status = isFailedResult(r)
|
|
1209
1249
|
? `failed${r.stopReason ? ` (${r.stopReason})` : ""}`
|
|
1210
1250
|
: "completed";
|
|
1211
|
-
|
|
1251
|
+
const patch = formatPatchBlock(r);
|
|
1252
|
+
return `### [${r.agent}] ${status}\n\n${output}${patch ? `\n\n${patch}` : ""}`;
|
|
1212
1253
|
});
|
|
1213
1254
|
|
|
1214
1255
|
let headerText = `Parallel: ${successCount}/${results.length} succeeded`;
|
|
@@ -1236,6 +1277,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1236
1277
|
task: params.task,
|
|
1237
1278
|
cwd: params.cwd,
|
|
1238
1279
|
timeout: params.timeout,
|
|
1280
|
+
merge: params.merge,
|
|
1239
1281
|
agentColor: agentToThemeColor(params.agent),
|
|
1240
1282
|
toolCallId: _toolCallId,
|
|
1241
1283
|
deps: { pi, ctx, runOne, threadStore },
|
|
@@ -1263,6 +1305,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1263
1305
|
(progress) => threadStore.updateProgress(thread.id, progress),
|
|
1264
1306
|
() => makeDetails("single")([]),
|
|
1265
1307
|
() => threadStore.refreshHeartbeat(thread.id),
|
|
1308
|
+
undefined,
|
|
1309
|
+
params.merge,
|
|
1266
1310
|
);
|
|
1267
1311
|
threadStore.updateThread(thread.id, {
|
|
1268
1312
|
status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
|
|
@@ -1282,11 +1326,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
1282
1326
|
|
|
1283
1327
|
if (isError) {
|
|
1284
1328
|
const errorMsg = getResultOutput(result);
|
|
1329
|
+
const patch = formatPatchBlock(result);
|
|
1285
1330
|
return {
|
|
1286
1331
|
content: [
|
|
1287
1332
|
{
|
|
1288
1333
|
type: "text",
|
|
1289
|
-
text: `Agent ${result.stopReason || "failed"}: ${errorMsg}`,
|
|
1334
|
+
text: `Agent ${result.stopReason || "failed"}: ${errorMsg}${patch ? `\n\n${patch}` : ""}`,
|
|
1290
1335
|
},
|
|
1291
1336
|
],
|
|
1292
1337
|
details: makeDetails("single")([result]),
|
|
@@ -1296,7 +1341,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1296
1341
|
|
|
1297
1342
|
return {
|
|
1298
1343
|
content: [
|
|
1299
|
-
{ type: "text", text: getFinalOutput(result.messages) || "(no output)" },
|
|
1344
|
+
{ type: "text", text: [getFinalOutput(result.messages) || "(no output)", formatPatchBlock(result)].filter(Boolean).join("\n\n") },
|
|
1300
1345
|
],
|
|
1301
1346
|
details: makeDetails("single")([result]),
|
|
1302
1347
|
};
|
|
@@ -13,7 +13,7 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "
|
|
|
13
13
|
import { homedir } from "node:os";
|
|
14
14
|
import { dirname, join } from "node:path";
|
|
15
15
|
import { row } from "@bacnh85/pi-config-panel";
|
|
16
|
-
import type { PanelGroup } from "@bacnh85/pi-config-panel";
|
|
16
|
+
import type { PanelAction, PanelGroup, PanelRow } from "@bacnh85/pi-config-panel";
|
|
17
17
|
|
|
18
18
|
// ponytail: local structural type — the kernel only reads value/label/
|
|
19
19
|
// description, so this stays compatible with the published 0.1.0 range while
|
|
@@ -24,7 +24,8 @@ interface CompletionItem {
|
|
|
24
24
|
description?: string;
|
|
25
25
|
}
|
|
26
26
|
import type { AgentConfig } from "./agents.ts";
|
|
27
|
-
import {
|
|
27
|
+
import { getModelCandidates } from "./agents.ts";
|
|
28
|
+
import { DEFAULT_ROLES, readSubagentRoles, resolveAgentModelChain, type RoleMap, type RolesConfig } from "./roles.ts";
|
|
28
29
|
|
|
29
30
|
// ---------------------------------------------------------------------------
|
|
30
31
|
// Settings persistence (global only — repo .pi/settings.json is read-only)
|
|
@@ -98,6 +99,9 @@ export interface RolesPanelOptions {
|
|
|
98
99
|
models: () => string[];
|
|
99
100
|
/** Known role names (defaults + configured), offered as `@role` on agent rows. */
|
|
100
101
|
roles: () => string[];
|
|
102
|
+
/** Effective role chains used to render each agent's default (falls back to
|
|
103
|
+
* DEFAULT_ROLES). Pass the working copy's roles so labels track live edits. */
|
|
104
|
+
effectiveRoles?: RoleMap;
|
|
101
105
|
}
|
|
102
106
|
|
|
103
107
|
/** Seed a working config from current effective settings + bundled agents. */
|
|
@@ -115,10 +119,29 @@ export function buildRolesPanelCfg(agents: AgentConfig[], current: RolesConfig):
|
|
|
115
119
|
return cfg;
|
|
116
120
|
}
|
|
117
121
|
|
|
122
|
+
/** Human-readable default (no-override) chain for an agent's panel row label. */
|
|
123
|
+
export function defaultChainLabel(
|
|
124
|
+
agent: Pick<AgentConfig, "name" | "model" | "models">,
|
|
125
|
+
roles: RoleMap,
|
|
126
|
+
): string {
|
|
127
|
+
const raw = getModelCandidates(agent);
|
|
128
|
+
const alias = raw.filter((m) => m.startsWith("@")).join(", ");
|
|
129
|
+
const { candidates } = resolveAgentModelChain(agent, { roles, agentModels: {} });
|
|
130
|
+
const chain = candidates.length > 0 ? candidates.join(", ") : "parent fallback";
|
|
131
|
+
// alias is @-prefixed, candidates are expanded model ids — never equal.
|
|
132
|
+
return alias ? `default: ${alias} → ${chain}` : `default: ${chain}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
118
135
|
/** Build panel groups. Role rows first, then one override row per agent.
|
|
119
136
|
* `options` adds inline model/@role completions when provided (optional so
|
|
120
|
-
* existing unit tests and non-TUI callers stay unchanged).
|
|
121
|
-
|
|
137
|
+
* existing unit tests and non-TUI callers stay unchanged). `actions` appends
|
|
138
|
+
* the + Add role / − Remove role action rows when provided. */
|
|
139
|
+
export function buildRows(
|
|
140
|
+
cfg: RolesPanelCfg,
|
|
141
|
+
agents: AgentConfig[],
|
|
142
|
+
options?: RolesPanelOptions,
|
|
143
|
+
actions?: Record<string, PanelAction>,
|
|
144
|
+
): PanelGroup[] {
|
|
122
145
|
const defaultChain = (name: string) => Array.isArray(DEFAULT_ROLES[name]) ? (DEFAULT_ROLES[name] as string[]).join(", ") : String(DEFAULT_ROLES[name] ?? "");
|
|
123
146
|
const modelItems = (): CompletionItem[] =>
|
|
124
147
|
(options?.models() ?? []).sort().map((ref) => ({ value: ref }));
|
|
@@ -135,15 +158,23 @@ export function buildRows(cfg: RolesPanelCfg, agents: AgentConfig[], options?: R
|
|
|
135
158
|
cfg.roles[name] = String(v ?? "").trim();
|
|
136
159
|
}, withCompletions(modelItems));
|
|
137
160
|
});
|
|
161
|
+
const effective = options?.effectiveRoles ?? DEFAULT_ROLES;
|
|
138
162
|
const agentRows = agents.map((agent) =>
|
|
139
|
-
row(`agent.${agent.name}`, agent.name, "string", cfg.agentModels[agent.name] ?? "", (v) => {
|
|
163
|
+
row(`agent.${agent.name}`, `${agent.name} (${defaultChainLabel(agent, effective)})`, "string", cfg.agentModels[agent.name] ?? "", (v) => {
|
|
140
164
|
const value = String(v ?? "").trim();
|
|
141
165
|
if (value) cfg.agentModels[agent.name] = value;
|
|
142
166
|
else delete cfg.agentModels[agent.name];
|
|
143
167
|
}, withCompletions(() => [...modelItems(), ...roleItems()])),
|
|
144
168
|
);
|
|
169
|
+
const actionRows: PanelRow[] = [];
|
|
170
|
+
if (actions?.addRole) {
|
|
171
|
+
actionRows.push({ key: "action.addRole", label: "+ Add role", kind: "action", value: undefined, set: (p) => actions.addRole!.run(p as never) });
|
|
172
|
+
}
|
|
173
|
+
if (actions?.removeRole) {
|
|
174
|
+
actionRows.push({ key: "action.removeRole", label: "− Remove role", kind: "action", value: undefined, set: (p) => actions.removeRole!.run(p as never) });
|
|
175
|
+
}
|
|
145
176
|
return [
|
|
146
|
-
{ key: "roles", label: "Model roles (chain, blank = default)", rows: roleRows },
|
|
177
|
+
{ key: "roles", label: "Model roles (chain, blank = default)", rows: [...roleRows, ...actionRows] },
|
|
147
178
|
{ key: "agents", label: "Per-agent overrides (blank = inherit)", rows: agentRows },
|
|
148
179
|
];
|
|
149
180
|
}
|
|
@@ -165,6 +196,88 @@ export function cfgToPatch(cfg: RolesPanelCfg): { roles: RolesConfig["roles"]; a
|
|
|
165
196
|
return { roles, agentModels };
|
|
166
197
|
}
|
|
167
198
|
|
|
199
|
+
/** Validate a new role name; returns an error message or null when OK. */
|
|
200
|
+
export function validateNewRoleName(name: string, known: readonly string[]): string | null {
|
|
201
|
+
const trimmed = name.trim();
|
|
202
|
+
if (!trimmed) return "Role name is empty.";
|
|
203
|
+
if (!/^[A-Za-z0-9._-]{1,64}$/.test(trimmed)) {
|
|
204
|
+
return `Invalid role name "${trimmed}" — use letters, digits, dot, dash, underscore (max 64).`;
|
|
205
|
+
}
|
|
206
|
+
const clash = known.find((k) => k.toLowerCase() === trimmed.toLowerCase());
|
|
207
|
+
if (clash) return `Role @${clash} already exists.`;
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Apply a role removal to the working config. Built-in roles (DEFAULT_ROLES)
|
|
212
|
+
* can only be reset to their bundled chain (blank); custom roles are deleted
|
|
213
|
+
* (row disappears on rebuild, key dropped by cfgToPatch). Unknown → null. */
|
|
214
|
+
export function removeRoleFromCfg(cfg: RolesPanelCfg, name: string): "reset" | "deleted" | null {
|
|
215
|
+
const key = Object.keys(cfg.roles).find((k) => k.toLowerCase() === name.trim().toLowerCase());
|
|
216
|
+
if (!key) return null;
|
|
217
|
+
if (DEFAULT_ROLES[key] !== undefined) {
|
|
218
|
+
cfg.roles[key] = "";
|
|
219
|
+
return "reset";
|
|
220
|
+
}
|
|
221
|
+
delete cfg.roles[key];
|
|
222
|
+
return "deleted";
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export interface RoleActionOpts {
|
|
226
|
+
/** User feedback (defaults to no-op so tests stay quiet). */
|
|
227
|
+
notify?: (message: string, kind?: "info" | "warning" | "error") => void;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** "+ Add role" panel action: prompt name → validate → prompt chain → mutate
|
|
231
|
+
* the working config (kernel rebuilds rows + marks dirty after the action). */
|
|
232
|
+
export function makeAddRoleAction(cfg: RolesPanelCfg, opts: RoleActionOpts = {}): PanelAction {
|
|
233
|
+
return {
|
|
234
|
+
label: "Add role",
|
|
235
|
+
run: (prompt) => new Promise<void>((resolve) => {
|
|
236
|
+
prompt("Role name (e.g. writer)", (name) => {
|
|
237
|
+
const trimmed = (name ?? "").trim();
|
|
238
|
+
if (!trimmed) return resolve();
|
|
239
|
+
const err = validateNewRoleName(trimmed, Object.keys(cfg.roles));
|
|
240
|
+
if (err) {
|
|
241
|
+
opts.notify?.(err, "warning");
|
|
242
|
+
return resolve();
|
|
243
|
+
}
|
|
244
|
+
prompt("Model chain (comma-separated; @role or * allowed)", (chain) => {
|
|
245
|
+
const value = (chain ?? "").trim();
|
|
246
|
+
if (!value) {
|
|
247
|
+
opts.notify?.("Chain required — role not added.", "warning");
|
|
248
|
+
return resolve();
|
|
249
|
+
}
|
|
250
|
+
cfg.roles[trimmed] = value;
|
|
251
|
+
resolve();
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
}),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** "− Remove role" panel action: prompt pick from known roles, then reset
|
|
259
|
+
* (built-in) or delete (custom) via removeRoleFromCfg. */
|
|
260
|
+
export function makeRemoveRoleAction(cfg: RolesPanelCfg, opts: RoleActionOpts = {}): PanelAction {
|
|
261
|
+
return {
|
|
262
|
+
label: "Remove role",
|
|
263
|
+
run: (prompt) => new Promise<void>((resolve) => {
|
|
264
|
+
const names = Object.keys(cfg.roles).sort();
|
|
265
|
+
if (names.length === 0) {
|
|
266
|
+
opts.notify?.("No roles to remove.", "warning");
|
|
267
|
+
return resolve();
|
|
268
|
+
}
|
|
269
|
+
prompt(`Remove role (${names.join(", ")})`, (pick) => {
|
|
270
|
+
if (!pick) return resolve();
|
|
271
|
+
const result = removeRoleFromCfg(cfg, pick);
|
|
272
|
+
if (result === "reset") opts.notify?.(`@${pick.trim()} reset to bundled default`, "info");
|
|
273
|
+
else if (result === "deleted") opts.notify?.(`@${pick.trim()} removed`, "info");
|
|
274
|
+
else opts.notify?.(`No role named "${pick.trim()}".`, "warning");
|
|
275
|
+
resolve();
|
|
276
|
+
});
|
|
277
|
+
}),
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
168
281
|
/** Keep overrides for agents NOT shown in the panel (e.g. overrides for
|
|
169
282
|
* project-local agents saved globally from another project) so a panel save
|
|
170
283
|
* doesn't wipe them. Discovered-agent entries always follow the panel. */
|
package/extensions/runner.ts
CHANGED
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
|
|
17
17
|
import type { Message, Model } from "@earendil-works/pi-ai";
|
|
18
18
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
19
|
+
import * as fs from "node:fs/promises";
|
|
20
|
+
import * as os from "node:os";
|
|
19
21
|
import * as path from "node:path";
|
|
20
22
|
import {
|
|
21
23
|
createAgentSession,
|
|
@@ -32,6 +34,7 @@ import {
|
|
|
32
34
|
type SubagentStatus,
|
|
33
35
|
DEFAULT_TIMEOUT_MS,
|
|
34
36
|
HARD_TIMEOUT_MS,
|
|
37
|
+
truncateParallelOutput,
|
|
35
38
|
} from "./security.ts";
|
|
36
39
|
|
|
37
40
|
// ---------------------------------------------------------------------------
|
|
@@ -122,6 +125,10 @@ export interface SubAgentResult {
|
|
|
122
125
|
errorMessage?: string;
|
|
123
126
|
/** Unified diff of changes made in an isolated worktree (sandbox: "worktree"). */
|
|
124
127
|
patch?: string;
|
|
128
|
+
/** Set when merge:"3way" was requested: outcome of applying the patch to the parent checkout. */
|
|
129
|
+
mergeStatus?: "applied" | "conflict";
|
|
130
|
+
/** git apply error excerpt when mergeStatus === "conflict". */
|
|
131
|
+
mergeError?: string;
|
|
125
132
|
/** Canonical result status (added in 0.6.0). */
|
|
126
133
|
status?: SubagentStatus;
|
|
127
134
|
/** Wall-clock duration of the run, set by runSubAgent (Date.now() - startedAt). */
|
|
@@ -146,6 +153,8 @@ export async function runSubAgent(options: {
|
|
|
146
153
|
model: Model<any>;
|
|
147
154
|
/** "worktree" runs the child in an isolated git worktree; the resulting diff is returned as result.patch. */
|
|
148
155
|
sandbox?: "worktree";
|
|
156
|
+
/** With sandbox:"worktree", apply the captured diff to the parent checkout via `git apply --3way` after the run. */
|
|
157
|
+
merge?: "3way";
|
|
149
158
|
/** Pi 0.80.10's canonical credential/model runtime. */
|
|
150
159
|
modelRuntime?: unknown;
|
|
151
160
|
/** Legacy Pi SDK session options retained for 0.80.6 tests and hosts. */
|
|
@@ -177,7 +186,7 @@ export async function runSubAgent(options: {
|
|
|
177
186
|
cwd, systemPrompt, task, tools, model, modelRuntime, authStorage, modelRegistry, signal,
|
|
178
187
|
agentName = "subagent", thinkingLevel = "off", onMessage, onProgress,
|
|
179
188
|
timeoutMs = DEFAULT_INACTIVITY_TIMEOUT_MS, hardTimeoutMs = HARD_TIMEOUT_MS,
|
|
180
|
-
loadExtensions = false, projectTrusted = true, sandbox, exec,
|
|
189
|
+
loadExtensions = false, projectTrusted = true, sandbox, merge, exec,
|
|
181
190
|
} = options;
|
|
182
191
|
const result: SubAgentResult = {
|
|
183
192
|
agent: agentName, task, exitCode: 0, messages: [], stderr: "",
|
|
@@ -229,11 +238,13 @@ export async function runSubAgent(options: {
|
|
|
229
238
|
// ponytail: falls back to the in-process cwd when git is unavailable — the
|
|
230
239
|
// worktree is an isolation optimization, not a hard requirement.
|
|
231
240
|
let worktreeDir: string | undefined;
|
|
241
|
+
let worktreeRepoRoot: string | undefined;
|
|
232
242
|
let childCwd = cwd;
|
|
233
243
|
if (sandbox === "worktree") {
|
|
234
244
|
const wt = await createWorktree(cwd, exec);
|
|
235
245
|
if (wt.ok) {
|
|
236
246
|
worktreeDir = wt.path;
|
|
247
|
+
worktreeRepoRoot = wt.repoRoot;
|
|
237
248
|
childCwd = wt.path!;
|
|
238
249
|
} else if (wt.error) {
|
|
239
250
|
result.stderr = `Worktree unavailable (${wt.error}); running in workspace.`;
|
|
@@ -261,7 +272,10 @@ export async function runSubAgent(options: {
|
|
|
261
272
|
const finish = (fn: () => void) => { if (!done) { done = true; unsubscribe?.(); fn(); } };
|
|
262
273
|
unsubscribe = session.subscribe((event) => {
|
|
263
274
|
try {
|
|
264
|
-
// Any SDK session
|
|
275
|
+
// Any SDK session event — including message_update streaming
|
|
276
|
+
// deltas (thinking_delta/text_delta) — is real child activity
|
|
277
|
+
// and resets the idle timer. Only a stream with NO events at all
|
|
278
|
+
// for timeoutMs indicates a hung child.
|
|
265
279
|
armIdle(); onProgress?.(snapshot(event.type));
|
|
266
280
|
if (event.type === "message_end") {
|
|
267
281
|
const msg = event.message as AgentMessage;
|
|
@@ -297,7 +311,25 @@ export async function runSubAgent(options: {
|
|
|
297
311
|
if (worktreeDir) {
|
|
298
312
|
// Capture the child's changes as a unified diff before tearing down.
|
|
299
313
|
const diff = await captureWorktreeDiff(cwd, worktreeDir, exec);
|
|
300
|
-
if (diff.ok)
|
|
314
|
+
if (diff.ok) {
|
|
315
|
+
result.patch = diff.diff;
|
|
316
|
+
// Opt-in 3-way merge: apply while the worktree still exists — the
|
|
317
|
+
// blobs `git add -A` staged live in the shared object DB and
|
|
318
|
+
// `git apply --3way` needs them. Applies are serialized (see
|
|
319
|
+
// applyWorktreePatch3way) so parallel siblings cannot race. Only
|
|
320
|
+
// successfully completed children auto-merge: a timed-out or
|
|
321
|
+
// aborted child's half-finished edits must not land on the parent
|
|
322
|
+
// checkout — their patch is still delivered for manual review.
|
|
323
|
+
if (merge === "3way" && result.status === "success" && worktreeRepoRoot && diff.diff && diff.diff !== "(no changes)") {
|
|
324
|
+
const applied = await applyWorktreePatch3way(worktreeRepoRoot, diff.diff, exec);
|
|
325
|
+
if (applied.ok) {
|
|
326
|
+
result.mergeStatus = "applied";
|
|
327
|
+
} else {
|
|
328
|
+
result.mergeStatus = "conflict";
|
|
329
|
+
result.mergeError = applied.stderr.slice(0, 1000);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
301
333
|
else if (diff.error) result.stderr = result.stderr ? `${result.stderr}; diff unavailable (${diff.error})` : `Diff unavailable (${diff.error})`;
|
|
302
334
|
}
|
|
303
335
|
return result;
|
|
@@ -323,7 +355,7 @@ export async function runSubAgent(options: {
|
|
|
323
355
|
// ---------------------------------------------------------------------------
|
|
324
356
|
|
|
325
357
|
/** Minimal exec fallback (child_process spawn) used when no exec is injected. */
|
|
326
|
-
async function defaultExec(
|
|
358
|
+
export async function defaultExec(
|
|
327
359
|
command: string,
|
|
328
360
|
args: string[],
|
|
329
361
|
options?: { cwd?: string; timeout?: number },
|
|
@@ -358,7 +390,7 @@ async function runGit(
|
|
|
358
390
|
export async function createWorktree(
|
|
359
391
|
cwd: string,
|
|
360
392
|
exec?: (command: string, args: string[], options?: { cwd?: string; timeout?: number }) => Promise<{ code: number; stdout: string; stderr: string }>,
|
|
361
|
-
): Promise<{ ok: boolean; path?: string; error?: string }> {
|
|
393
|
+
): Promise<{ ok: boolean; path?: string; repoRoot?: string; error?: string }> {
|
|
362
394
|
try {
|
|
363
395
|
const root = await runGit(cwd, ["rev-parse", "--show-toplevel"], exec);
|
|
364
396
|
if (!root.ok) return { ok: false, error: root.stderr.trim() || "not a git repo" };
|
|
@@ -368,7 +400,7 @@ export async function createWorktree(
|
|
|
368
400
|
const wtPath = path.join(repoRoot, ".pi-worktrees", id);
|
|
369
401
|
const add = await runGit(repoRoot, ["worktree", "add", "--detach", wtPath, "HEAD"], exec);
|
|
370
402
|
if (!add.ok) return { ok: false, error: add.stderr.trim() || "git worktree add failed" };
|
|
371
|
-
return { ok: true, path: wtPath };
|
|
403
|
+
return { ok: true, path: wtPath, repoRoot };
|
|
372
404
|
} catch (error) {
|
|
373
405
|
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
374
406
|
}
|
|
@@ -403,6 +435,56 @@ export async function removeWorktree(
|
|
|
403
435
|
} catch { /* best effort */ }
|
|
404
436
|
}
|
|
405
437
|
|
|
438
|
+
/** Serialized `git apply` on the parent checkout: parallel siblings finishing
|
|
439
|
+
* at the same time must not race the merge (OMP's withRepoLock equivalent). */
|
|
440
|
+
let applyChain: Promise<unknown> = Promise.resolve();
|
|
441
|
+
|
|
442
|
+
/** Apply a unified diff to the repo root via `git apply --3way`. Never throws;
|
|
443
|
+
* ok:false means conflict/failure — the caller reports it and still delivers
|
|
444
|
+
* the patch text for manual merging. */
|
|
445
|
+
export async function applyWorktreePatch3way(
|
|
446
|
+
repoRoot: string,
|
|
447
|
+
diff: string,
|
|
448
|
+
exec?: (command: string, args: string[], options?: { cwd?: string; timeout?: number }) => Promise<{ code: number; stdout: string; stderr: string }>,
|
|
449
|
+
): Promise<{ ok: boolean; stderr: string }> {
|
|
450
|
+
const run = applyChain.then(async (): Promise<{ ok: boolean; stderr: string }> => {
|
|
451
|
+
const tmp = path.join(os.tmpdir(), `pi-subagent-apply-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}.patch`);
|
|
452
|
+
try {
|
|
453
|
+
// git apply interprets paths relative to cwd — write the diff to a file
|
|
454
|
+
// and apply at the repo root. captureWorktreeDiff trims trailing
|
|
455
|
+
// whitespace; git rejects a patch whose last line has no newline, so
|
|
456
|
+
// restore it (CRLF-aware: trimming "...\r\n" must not downgrade the
|
|
457
|
+
// final line ending to LF).
|
|
458
|
+
const patchText = diff.endsWith("\n") ? diff : `${diff}${diff.includes("\r\n") ? "\r\n" : "\n"}`;
|
|
459
|
+
await fs.writeFile(tmp, patchText, "utf8");
|
|
460
|
+
const res = await runGit(repoRoot, ["apply", "--3way", tmp], exec);
|
|
461
|
+
return { ok: res.ok, stderr: (res.stderr || res.stdout).trim() };
|
|
462
|
+
} catch (error) {
|
|
463
|
+
return { ok: false, stderr: error instanceof Error ? error.message : String(error) };
|
|
464
|
+
} finally {
|
|
465
|
+
await fs.rm(tmp, { force: true }).catch(() => { /* best effort */ });
|
|
466
|
+
}
|
|
467
|
+
});
|
|
468
|
+
applyChain = run.then(() => undefined, () => undefined);
|
|
469
|
+
return run;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Model-facing patch block for a worktree result; "" when there is no patch.
|
|
473
|
+
* Applied merges omit the diff (the parent checkout already has it);
|
|
474
|
+
* conflicts include it plus the git apply error. */
|
|
475
|
+
export function formatPatchBlock(result: SubAgentResult): string {
|
|
476
|
+
if (!result.patch || result.patch === "(no changes)") return "";
|
|
477
|
+
const lines = result.patch.split("\n").length;
|
|
478
|
+
if (result.mergeStatus === "applied") {
|
|
479
|
+
return `🌿 worktree patch (${lines} diff lines) — already applied to the parent checkout via 3-way merge.`;
|
|
480
|
+
}
|
|
481
|
+
const head = result.mergeStatus === "conflict"
|
|
482
|
+
? `🌿 worktree patch (${lines} diff lines) — 3-way merge CONFLICTED; resolve the markers in the files, or merge manually:`
|
|
483
|
+
: `🌿 worktree patch (${lines} diff lines) — merge explicitly via apply_patch or \`git apply\` (or pass merge:"3way" next time):`;
|
|
484
|
+
const err = result.mergeError ? `\n\ngit apply: ${result.mergeError}` : "";
|
|
485
|
+
return `${head}${err}\n\n${truncateParallelOutput(result.patch)}`;
|
|
486
|
+
}
|
|
487
|
+
|
|
406
488
|
|
|
407
489
|
export function getFinalOutput(messages: Message[]): string {
|
|
408
490
|
for (let i = messages.length - 1; i >= 0; i--) {
|
package/extensions/security.ts
CHANGED
|
@@ -668,6 +668,10 @@ const RATE_LIMIT_PATTERNS = [
|
|
|
668
668
|
/capacity[\s_]exceeded/i,
|
|
669
669
|
/usage[\s_]limit/i,
|
|
670
670
|
/overloaded/i,
|
|
671
|
+
// Credential-cooldown errors (e.g. "All credentials for model X are
|
|
672
|
+
// cooling down") are capacity signals too: the model may be unavailable
|
|
673
|
+
// NOW but the next fallback candidate may not be in cooldown.
|
|
674
|
+
/credential[\s_]cooldown|cooldown[\s_]window|cooling[\s_]down/i,
|
|
671
675
|
];
|
|
672
676
|
|
|
673
677
|
/**
|
|
@@ -679,3 +683,23 @@ const RATE_LIMIT_PATTERNS = [
|
|
|
679
683
|
export function isRateLimitError(message: string): boolean {
|
|
680
684
|
return RATE_LIMIT_PATTERNS.some(p => p.test(message));
|
|
681
685
|
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Whether a sub-agent result should trigger model fallback: a rate-limit /
|
|
689
|
+
* credential-cooldown error, or an IDLE timeout (a stalled stream with zero
|
|
690
|
+
* events for the whole idle window — a known router/provider failure mode).
|
|
691
|
+
* The HARD lifetime cap is terminal: a task that ran 20 min is genuinely
|
|
692
|
+
* huge, not a stall, and retrying it on another model doubles the cost.
|
|
693
|
+
*/
|
|
694
|
+
export function isRetryableModelResult(result: {
|
|
695
|
+
errorMessage?: string;
|
|
696
|
+
stopReason?: string;
|
|
697
|
+
status?: string;
|
|
698
|
+
}): boolean {
|
|
699
|
+
if (result.errorMessage && isRateLimitError(result.errorMessage)) return true;
|
|
700
|
+
return (
|
|
701
|
+
result.stopReason === "timeout" &&
|
|
702
|
+
result.status === "timeout" &&
|
|
703
|
+
!String(result.errorMessage ?? "").includes("Hard timeout")
|
|
704
|
+
);
|
|
705
|
+
}
|
package/package.json
CHANGED