@bacnh85/pi-subagent 0.17.0 → 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,74 @@
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
+
3
72
  ## 0.17.0 (2026-08-31)
4
73
 
5
74
  ### Added
package/README.md CHANGED
@@ -166,10 +166,18 @@ two `worker` agents editing the same files can no longer clobber each other —
166
166
  each writes into its own checkout.
167
167
 
168
168
  - All file mutations land in the worktree; the main checkout stays untouched.
169
- - On completion, a unified diff of the child's changes is returned in the
170
- result and shown in the thread viewer as a `🌿 worktree` badge.
171
- - **Merging is explicit**: the parent receives the diff and applies it via
172
- `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.
173
181
  - The worktree is removed on completion (success, error, or abort).
174
182
  - Requires git; when the cwd is not a git repo, the agent falls back to
175
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
  });
@@ -596,6 +601,7 @@ export default function (pi: ExtensionAPI) {
596
601
  "Bundled agents: scout (fast recon), tester (verification), worker (implementation), general-purpose (fallback), planner (planning), reviewer (review).",
597
602
  "For background single tasks use background:true — you will be notified on completion; DO NOT poll or sleep.",
598
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).",
599
605
  "Use /subagent list to list all available agents, /subagent <name> for agent details, /subagent @role for role detail.",
600
606
  ],
601
607
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
@@ -671,6 +677,7 @@ export default function (pi: ExtensionAPI) {
671
677
  ];
672
678
  if (snap.result) {
673
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)`);
674
681
  } else {
675
682
  lines.push("(still running — no final output yet)");
676
683
  }
@@ -872,6 +879,7 @@ export default function (pi: ExtensionAPI) {
872
879
  heartbeatDetails?: () => SubagentDetails,
873
880
  onHeartbeat?: () => void,
874
881
  isReadOnly?: boolean,
882
+ merge?: "3way",
875
883
  ): Promise<SubAgentResult> {
876
884
  const agent = agents.find((a) => a.name === agentName);
877
885
 
@@ -912,12 +920,19 @@ export default function (pi: ExtensionAPI) {
912
920
  let tools: string[];
913
921
  let loadExtensions: boolean;
914
922
  let effectiveTimeoutMs: number | undefined;
923
+ let effectiveHardMs: number | undefined;
915
924
  let safeCwd: string;
916
925
  try {
917
926
  const resolved = resolveChildTools(agent.tools, agent.sandbox, isReadOnly);
918
927
  tools = resolved.tools;
919
928
  loadExtensions = resolved.loadExtensions;
920
- 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);
921
936
  safeCwd = resolveChildCwd(cwd);
922
937
  } catch (err: unknown) {
923
938
  const errorMsg = err instanceof Error ? err.message : String(err);
@@ -958,6 +973,7 @@ export default function (pi: ExtensionAPI) {
958
973
  runSubAgent({
959
974
  cwd: safeCwd,
960
975
  sandbox: agent.sandbox === "worktree" ? "worktree" : undefined,
976
+ merge: agent.sandbox === "worktree" ? merge : undefined,
961
977
  systemPrompt: params.instructions
962
978
  ? `${agent.systemPrompt}\n\n## Task Contract\n${params.instructions.slice(0, MAX_INSTRUCTIONS_LENGTH)}`
963
979
  : agent.systemPrompt,
@@ -969,6 +985,7 @@ export default function (pi: ExtensionAPI) {
969
985
  modelRegistry,
970
986
  signal: parentSignal,
971
987
  timeoutMs: effectiveTimeoutMs,
988
+ hardTimeoutMs: effectiveHardMs,
972
989
  agentName,
973
990
  thinkingLevel,
974
991
  onMessage: onProgress,
@@ -976,7 +993,7 @@ export default function (pi: ExtensionAPI) {
976
993
  loadExtensions,
977
994
  projectTrusted,
978
995
  }),
979
- isRateLimited: (result) => Boolean(result.errorMessage && isRateLimitError(result.errorMessage)),
996
+ isRateLimited: (result) => isRetryableModelResult(result),
980
997
  onExhausted: (reason, triedModels, remaining) => {
981
998
  const exhaustedStderr = reason === "no-model"
982
999
  ? [
@@ -1035,6 +1052,8 @@ export default function (pi: ExtensionAPI) {
1035
1052
  (progress) => threadStore.updateProgress(thread.id, progress),
1036
1053
  () => makeDetails("chain")(results),
1037
1054
  () => threadStore.refreshHeartbeat(thread.id),
1055
+ undefined,
1056
+ step.merge,
1038
1057
  );
1039
1058
  threadStore.updateThread(thread.id, {
1040
1059
  status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
@@ -1055,6 +1074,8 @@ export default function (pi: ExtensionAPI) {
1055
1074
  // Include successful previous step outputs in the error content
1056
1075
  const prevCount = i;
1057
1076
  let contentText = `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}`;
1077
+ const failedPatch = formatPatchBlock(result);
1078
+ if (failedPatch) contentText += `\n\n${failedPatch}`;
1058
1079
  if (prevCount > 0) {
1059
1080
  const prevSummaries = results
1060
1081
  .slice(0, prevCount)
@@ -1076,16 +1097,22 @@ export default function (pi: ExtensionAPI) {
1076
1097
 
1077
1098
  if (onUpdate) {
1078
1099
  onUpdate({
1079
- 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") }],
1080
1101
  details: makeDetails("chain")(results),
1081
1102
  });
1082
1103
  }
1083
1104
  }
1084
1105
 
1085
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");
1086
1113
  return {
1087
1114
  content: [
1088
- { type: "text", text: getFinalOutput(last.messages) || "(no output)" },
1115
+ { type: "text", text: [getFinalOutput(last.messages) || "(no output)", chainPatches].filter(Boolean).join("\n\n") },
1089
1116
  ],
1090
1117
  details: makeDetails("chain")(results),
1091
1118
  };
@@ -1195,6 +1222,8 @@ export default function (pi: ExtensionAPI) {
1195
1222
  (progress) => threadStore.updateProgress(parallelThreads[index].id, progress),
1196
1223
  () => makeDetails("parallel")([...allResults]),
1197
1224
  () => threadStore.refreshHeartbeat(parallelThreads[index].id),
1225
+ undefined,
1226
+ t.merge,
1198
1227
  );
1199
1228
  allResults[index] = result;
1200
1229
  threadStore.updateThread(parallelThreads[index].id, {
@@ -1219,7 +1248,8 @@ export default function (pi: ExtensionAPI) {
1219
1248
  const status = isFailedResult(r)
1220
1249
  ? `failed${r.stopReason ? ` (${r.stopReason})` : ""}`
1221
1250
  : "completed";
1222
- return `### [${r.agent}] ${status}\n\n${output}`;
1251
+ const patch = formatPatchBlock(r);
1252
+ return `### [${r.agent}] ${status}\n\n${output}${patch ? `\n\n${patch}` : ""}`;
1223
1253
  });
1224
1254
 
1225
1255
  let headerText = `Parallel: ${successCount}/${results.length} succeeded`;
@@ -1247,6 +1277,7 @@ export default function (pi: ExtensionAPI) {
1247
1277
  task: params.task,
1248
1278
  cwd: params.cwd,
1249
1279
  timeout: params.timeout,
1280
+ merge: params.merge,
1250
1281
  agentColor: agentToThemeColor(params.agent),
1251
1282
  toolCallId: _toolCallId,
1252
1283
  deps: { pi, ctx, runOne, threadStore },
@@ -1274,6 +1305,8 @@ export default function (pi: ExtensionAPI) {
1274
1305
  (progress) => threadStore.updateProgress(thread.id, progress),
1275
1306
  () => makeDetails("single")([]),
1276
1307
  () => threadStore.refreshHeartbeat(thread.id),
1308
+ undefined,
1309
+ params.merge,
1277
1310
  );
1278
1311
  threadStore.updateThread(thread.id, {
1279
1312
  status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
@@ -1293,11 +1326,12 @@ export default function (pi: ExtensionAPI) {
1293
1326
 
1294
1327
  if (isError) {
1295
1328
  const errorMsg = getResultOutput(result);
1329
+ const patch = formatPatchBlock(result);
1296
1330
  return {
1297
1331
  content: [
1298
1332
  {
1299
1333
  type: "text",
1300
- text: `Agent ${result.stopReason || "failed"}: ${errorMsg}`,
1334
+ text: `Agent ${result.stopReason || "failed"}: ${errorMsg}${patch ? `\n\n${patch}` : ""}`,
1301
1335
  },
1302
1336
  ],
1303
1337
  details: makeDetails("single")([result]),
@@ -1307,7 +1341,7 @@ export default function (pi: ExtensionAPI) {
1307
1341
 
1308
1342
  return {
1309
1343
  content: [
1310
- { type: "text", text: getFinalOutput(result.messages) || "(no output)" },
1344
+ { type: "text", text: [getFinalOutput(result.messages) || "(no output)", formatPatchBlock(result)].filter(Boolean).join("\n\n") },
1311
1345
  ],
1312
1346
  details: makeDetails("single")([result]),
1313
1347
  };
@@ -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.17.0",
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",