@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 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
- `/subagent list` lists agents, `/subagent <name>` shows an agent's resolved
38
- chain, and `/subagent @role` (or `/subagent fast`) shows a role's chain and
39
- the agents using it.
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, a unified diff of the child's changes is returned in the
168
- result and shown in the thread viewer as a `🌿 worktree` badge.
169
- - **Merging is explicit**: the parent receives the diff and applies it via
170
- `apply_patch` / cherry-pick / discard. Nothing is auto-merged.
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, a unified diff of the changes is returned in the result (visible in the thread viewer as a `🌿 worktree` badge) the parent merges explicitly via `apply_patch`/cherry-pick; nothing is applied automatically. Falls back to in-process execution when the cwd is not a git repo (with a warning). Requires git.
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
@@ -6,6 +6,7 @@ model: "@smart"
6
6
  thinking: high
7
7
  color: blue
8
8
  sandbox: read-only
9
+ timeout: 10
9
10
  ---
10
11
 
11
12
  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.
@@ -6,6 +6,7 @@ model: "@smart"
6
6
  thinking: high
7
7
  color: purple
8
8
  sandbox: read-only
9
+ timeout: 10
9
10
  ---
10
11
 
11
12
  You are an independent senior code reviewer. Inspect the requested Git scope with read-only tools.
@@ -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,
@@ -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: failed
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,
@@ -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
- isRateLimitError,
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
- roles: () => [...Object.keys(DEFAULT_ROLES), ...Object.keys(readSubagentRolesGlobal().roles)],
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
- build: (cfg) => buildRows(cfg, discovery.agents, panelOptions),
429
+ actions,
430
+ build: (cfg, panelActions) => buildRows(cfg, discovery.agents, panelOptions, panelActions),
415
431
  title: "Subagent model roles",
416
- onSave: (saved, editedKeys) => {
417
- if (!(saved && editedKeys && editedKeys.size > 0)) return;
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
- effectiveTimeoutMs = resolveChildTimeout(timeoutMs, params.timeout);
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) => Boolean(result.errorMessage && isRateLimitError(result.errorMessage)),
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
- return `### [${r.agent}] ${status}\n\n${output}`;
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 { DEFAULT_ROLES, readSubagentRoles, type RolesConfig } from "./roles.ts";
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
- export function buildRows(cfg: RolesPanelCfg, agents: AgentConfig[], options?: RolesPanelOptions): PanelGroup[] {
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. */
@@ -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 lifecycle event is actual child activity, unlike a parent heartbeat.
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) result.patch = diff.diff;
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--) {
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-subagent",
3
- "version": "0.16.1",
3
+ "version": "0.19.2",
4
4
  "description": "In-process subagents for Pi with isolated SDK sessions, parallel and chained delegation, and inspectable threads.",
5
5
  "type": "module",
6
6
  "license": "MIT",