@oh-my-pi/pi-coding-agent 13.2.1 → 13.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +29 -2
  2. package/package.json +7 -7
  3. package/scripts/generate-docs-index.ts +2 -2
  4. package/src/cli/args.ts +2 -1
  5. package/src/config/settings-schema.ts +36 -4
  6. package/src/config/settings.ts +10 -0
  7. package/src/discovery/claude.ts +24 -6
  8. package/src/ipy/runtime.ts +1 -0
  9. package/src/mcp/config.ts +1 -1
  10. package/src/modes/components/settings-defs.ts +17 -1
  11. package/src/modes/components/status-line.ts +7 -5
  12. package/src/modes/controllers/mcp-command-controller.ts +4 -3
  13. package/src/modes/controllers/selector-controller.ts +21 -0
  14. package/src/modes/interactive-mode.ts +9 -0
  15. package/src/modes/oauth-manual-input.ts +42 -0
  16. package/src/modes/types.ts +2 -0
  17. package/src/patch/hashline.ts +19 -1
  18. package/src/prompts/system/commit-message-system.md +2 -0
  19. package/src/prompts/system/subagent-submit-reminder.md +3 -3
  20. package/src/prompts/system/subagent-system-prompt.md +4 -4
  21. package/src/prompts/system/system-prompt.md +13 -0
  22. package/src/prompts/tools/hashline.md +45 -1
  23. package/src/prompts/tools/task-summary.md +4 -4
  24. package/src/prompts/tools/task.md +1 -1
  25. package/src/sdk.ts +3 -0
  26. package/src/slash-commands/builtin-registry.ts +26 -1
  27. package/src/system-prompt.ts +4 -0
  28. package/src/task/index.ts +211 -70
  29. package/src/task/render.ts +24 -8
  30. package/src/task/types.ts +6 -1
  31. package/src/task/worktree.ts +394 -31
  32. package/src/tools/submit-result.ts +22 -23
  33. package/src/utils/commit-message-generator.ts +132 -0
  34. package/src/web/search/providers/exa.ts +41 -4
  35. package/src/web/search/providers/perplexity.ts +20 -8
@@ -40,7 +40,9 @@ Every edit has `op`, `pos`, and `lines`. Range replaces also have `end`. Both `p
40
40
  <rules>
41
41
  1. **Minimize scope:** You **MUST** use one logical mutation per operation.
42
42
  2. **Prefer insertion over neighbor rewrites:** You **SHOULD** anchor on structural boundaries (`}`, `]`, `},`), not interior lines.
43
- 3. **Range end tag:** When replacing a block (e.g., an `if` body), the `end` tag **MUST** include the block's closing brace/bracket — not just the last interior line. Verify the `end` tag covers all lines being logically removed, including trailing `}`, `]`, or `)`. An off-by-one on `end` orphans a brace and breaks syntax.
43
+ 3. **Range end tag (inclusive):** `end` is inclusive and **MUST** point to the final line being replaced.
44
+ - If `lines` includes a closing boundary token (`}`, `]`, `)`, `);`, `},`), `end` **MUST** include the original boundary line.
45
+ - You **MUST NOT** set `end` to an interior line and then re-add the boundary token in `lines`; that duplicates the next surviving line.
44
46
  </rules>
45
47
 
46
48
  <recovery>
@@ -131,6 +133,48 @@ Range — add `end`:
131
133
  ```
132
134
  </example>
133
135
 
136
+ <example name="inclusive end avoids duplicate boundary">
137
+ ```ts
138
+ {{hlinefull 70 "if (ok) {"}}
139
+ {{hlinefull 71 " run();"}}
140
+ {{hlinefull 72 "}"}}
141
+ {{hlinefull 73 "after();"}}
142
+ ```
143
+ Bad — `end` stops before `}` while `lines` already includes `}`:
144
+ ```
145
+ {
146
+ path: "…",
147
+ edits: [{
148
+ op: "replace",
149
+ pos: "{{hlineref 70 "if (ok) {"}}",
150
+ end: "{{hlineref 71 " run();"}}",
151
+ lines: [
152
+ "if (ok) {",
153
+ " runSafe();",
154
+ "}"
155
+ ]
156
+ }]
157
+ }
158
+ ```
159
+ Good — include original `}` in the replaced range when replacement keeps `}`:
160
+ ```
161
+ {
162
+ path: "…",
163
+ edits: [{
164
+ op: "replace",
165
+ pos: "{{hlineref 70 "if (ok) {"}}",
166
+ end: "{{hlineref 72 "}"}}",
167
+ lines: [
168
+ "if (ok) {",
169
+ " runSafe();",
170
+ "}"
171
+ ]
172
+ }]
173
+ }
174
+ ```
175
+ Also apply the same rule to `);`, `],`, and `},` closers: if replacement includes the closer token, `end` must include the original closer line.
176
+ </example>
177
+
134
178
  <example name="insert between siblings">
135
179
  ```ts
136
180
  {{hlinefull 44 " \"build\": \"bun run compile\","}}
@@ -20,9 +20,9 @@
20
20
  {{/unless}}
21
21
  {{/each}}
22
22
 
23
- {{#if patchApplySummary}}
24
- <patch-summary>
25
- {{patchApplySummary}}
26
- </patch-summary>
23
+ {{#if mergeSummary}}
24
+ <merge-summary>
25
+ {{mergeSummary}}
26
+ </merge-summary>
27
27
  {{/if}}
28
28
  </task-summary>
@@ -16,7 +16,7 @@ Subagents lack your conversation history. Every decision, file content, and user
16
16
  - `context`: Shared background prepended to every assignment. Session-specific info only.
17
17
  - `schema`: JTD schema for expected output. Format lives here — **MUST NOT** be duplicated in assignments.
18
18
  - `tasks`: Tasks to execute in parallel.
19
- - `isolated`: Run in isolated git worktree; returns patches. Use when tasks edit overlapping files.
19
+ - `isolated`: Run in isolated environment; returns patches. Use when tasks edit overlapping files.
20
20
  </parameters>
21
21
 
22
22
  <critical>
package/src/sdk.ts CHANGED
@@ -1120,6 +1120,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1120
1120
  });
1121
1121
 
1122
1122
  const repeatToolDescriptions = settings.get("repeatToolDescriptions");
1123
+ const eagerTasks = settings.get("task.eager");
1123
1124
  const intentField = settings.get("tools.intentTracing") || $env.PI_INTENT_TRACING === "1" ? INTENT_FIELD : undefined;
1124
1125
  const rebuildSystemPrompt = async (toolNames: string[], tools: Map<string, AgentTool>): Promise<string> => {
1125
1126
  toolContextStore.setToolNames(toolNames);
@@ -1135,6 +1136,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1135
1136
  skillsSettings: settings.getGroup("skills") as SkillsSettings,
1136
1137
  appendSystemPrompt: memoryInstructions,
1137
1138
  repeatToolDescriptions,
1139
+ eagerTasks,
1138
1140
  intentField,
1139
1141
  });
1140
1142
 
@@ -1154,6 +1156,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1154
1156
  customPrompt: options.systemPrompt,
1155
1157
  appendSystemPrompt: memoryInstructions,
1156
1158
  repeatToolDescriptions,
1159
+ eagerTasks,
1157
1160
  intentField,
1158
1161
  });
1159
1162
  }
@@ -259,7 +259,32 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
259
259
  {
260
260
  name: "login",
261
261
  description: "Login with OAuth provider",
262
- handle: (_command, runtime) => {
262
+ inlineHint: "[redirect URL]",
263
+ allowArgs: true,
264
+ handle: (command, runtime) => {
265
+ const manualInput = runtime.ctx.oauthManualInput;
266
+ const args = command.args.trim();
267
+ if (args.length > 0) {
268
+ const submitted = manualInput.submit(args);
269
+ if (submitted) {
270
+ runtime.ctx.showStatus("OAuth callback received; completing login…");
271
+ } else {
272
+ runtime.ctx.showWarning("No OAuth login is waiting for a manual callback.");
273
+ }
274
+ runtime.ctx.editor.setText("");
275
+ return;
276
+ }
277
+
278
+ if (manualInput.hasPending()) {
279
+ const provider = manualInput.pendingProviderId;
280
+ const message = provider
281
+ ? `OAuth login already in progress for ${provider}. Paste the redirect URL with /login <url>.`
282
+ : "OAuth login already in progress. Paste the redirect URL with /login <url>.";
283
+ runtime.ctx.showWarning(message);
284
+ runtime.ctx.editor.setText("");
285
+ return;
286
+ }
287
+
263
288
  void runtime.ctx.showOAuthSelector("login");
264
289
  runtime.ctx.editor.setText("");
265
290
  },
@@ -358,6 +358,8 @@ export interface BuildSystemPromptOptions {
358
358
  rules?: Array<{ name: string; description?: string; path: string; globs?: string[] }>;
359
359
  /** Intent field name injected into every tool schema. If set, explains the field in the prompt. */
360
360
  intentField?: string;
361
+ /** Encourage the agent to delegate via tasks unless changes are trivial. */
362
+ eagerTasks?: boolean;
361
363
  }
362
364
 
363
365
  /** Build the system prompt with tools, guidelines, and context */
@@ -379,6 +381,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
379
381
  preloadedSkills: providedPreloadedSkills,
380
382
  rules,
381
383
  intentField,
384
+ eagerTasks = false,
382
385
  } = options;
383
386
  const resolvedCwd = cwd ?? getProjectDir();
384
387
  const preloadedSkills = providedPreloadedSkills;
@@ -535,6 +538,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
535
538
  cwd: resolvedCwd,
536
539
  intentTracing: !!intentField,
537
540
  intentField: intentField ?? "",
541
+ eagerTasks,
538
542
  };
539
543
  return renderPromptTemplate(resolvedCustomPrompt ? customSystemPromptTemplate : systemPromptTemplate, data);
540
544
  }
package/src/task/index.ts CHANGED
@@ -29,6 +29,7 @@ import taskSummaryTemplate from "../prompts/tools/task-summary.md" with { type:
29
29
  import { formatBytes, formatDuration } from "../tools/render-utils";
30
30
  // Import review tools for side effects (registers subagent tool handlers)
31
31
  import "../tools/review";
32
+ import { generateCommitMessage } from "../utils/commit-message-generator";
32
33
  import { discoverAgents, getAgent } from "./discovery";
33
34
  import { runSubprocess } from "./executor";
34
35
  import { AgentOutputManager } from "./output-manager";
@@ -47,11 +48,17 @@ import {
47
48
  } from "./types";
48
49
  import {
49
50
  applyBaseline,
51
+ applyNestedPatches,
50
52
  captureBaseline,
51
53
  captureDeltaPatch,
54
+ cleanupFuseOverlay,
55
+ cleanupTaskBranches,
52
56
  cleanupWorktree,
57
+ commitToBranch,
58
+ ensureFuseOverlay,
53
59
  ensureWorktree,
54
60
  getRepoRoot,
61
+ mergeTaskBranches,
55
62
  type WorktreeBaseline,
56
63
  } from "./worktree";
57
64
 
@@ -145,11 +152,11 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
145
152
  get description(): string {
146
153
  const disabledAgents = this.session.settings.get("task.disabledAgents") as string[];
147
154
  const maxConcurrency = this.session.settings.get("task.maxConcurrency");
148
- const isolationEnabled = this.session.settings.get("task.isolation.enabled");
155
+ const isolationMode = this.session.settings.get("task.isolation.mode");
149
156
  return renderDescription(
150
157
  this.#discoveredAgents,
151
158
  maxConcurrency,
152
- isolationEnabled,
159
+ isolationMode !== "none",
153
160
  this.session.settings.get("async.enabled"),
154
161
  disabledAgents,
155
162
  );
@@ -168,9 +175,9 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
168
175
  * Create a TaskTool instance with async agent discovery.
169
176
  */
170
177
  static async create(session: ToolSession): Promise<TaskTool> {
171
- const isolationEnabled = session.settings.get("task.isolation.enabled");
178
+ const isolationMode = session.settings.get("task.isolation.mode");
172
179
  const { agents } = await discoverAgents(session.cwd);
173
- return new TaskTool(session, agents, isolationEnabled);
180
+ return new TaskTool(session, agents, isolationMode !== "none");
174
181
  }
175
182
 
176
183
  async execute(
@@ -422,18 +429,20 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
422
429
  const startTime = Date.now();
423
430
  const { agents, projectAgentsDir } = await discoverAgents(this.session.cwd);
424
431
  const { agent: agentName, context, schema: outputSchema } = params;
425
- const isolationEnabled = this.session.settings.get("task.isolation.enabled");
432
+ const isolationMode = this.session.settings.get("task.isolation.mode");
426
433
  const isolationRequested = "isolated" in params ? params.isolated === true : false;
427
- const isIsolated = isolationEnabled && isolationRequested;
434
+ const isIsolated = isolationMode !== "none" && isolationRequested;
435
+ const mergeMode = this.session.settings.get("task.isolation.merge");
436
+ const commitStyle = this.session.settings.get("task.isolation.commits");
428
437
  const maxConcurrency = this.session.settings.get("task.maxConcurrency");
429
438
  const taskDepth = this.session.taskDepth ?? 0;
430
439
 
431
- if (!isolationEnabled && "isolated" in params) {
440
+ if (isolationMode === "none" && "isolated" in params) {
432
441
  return {
433
442
  content: [
434
443
  {
435
444
  type: "text",
436
- text: "Task isolation is disabled. Remove the isolated argument to run subagents.",
445
+ text: "Task isolation is disabled. Remove the isolated argument or set task.isolation.mode to 'worktree' or 'fuse-overlay'.",
437
446
  },
438
447
  ],
439
448
  details: {
@@ -789,16 +798,23 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
789
798
  }
790
799
 
791
800
  const taskStart = Date.now();
792
- let worktreeDir: string | undefined;
801
+ let isolationDir: string | undefined;
793
802
  try {
794
803
  if (!repoRoot || !baseline) {
795
804
  throw new Error("Isolated task execution not initialized.");
796
805
  }
797
- worktreeDir = await ensureWorktree(repoRoot, task.id);
798
- await applyBaseline(worktreeDir, baseline);
806
+ const taskBaseline = structuredClone(baseline);
807
+
808
+ if (isolationMode === "fuse-overlay") {
809
+ isolationDir = await ensureFuseOverlay(repoRoot, task.id);
810
+ } else {
811
+ isolationDir = await ensureWorktree(repoRoot, task.id);
812
+ await applyBaseline(isolationDir, taskBaseline);
813
+ }
814
+
799
815
  const result = await runSubprocess({
800
816
  cwd: this.session.cwd,
801
- worktree: worktreeDir,
817
+ worktree: isolationDir,
802
818
  agent,
803
819
  task: task.task,
804
820
  description: task.description,
@@ -830,13 +846,56 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
830
846
  preloadedSkills: task.preloadedSkills,
831
847
  promptTemplates,
832
848
  });
833
- const patch = await captureDeltaPatch(worktreeDir, baseline);
834
- const patchPath = path.join(effectiveArtifactsDir, `${task.id}.patch`);
835
- await Bun.write(patchPath, patch);
836
- return {
837
- ...result,
838
- patchPath,
839
- };
849
+ if (mergeMode === "branch" && result.exitCode === 0) {
850
+ try {
851
+ const commitMsg =
852
+ commitStyle === "ai" && this.session.modelRegistry
853
+ ? async (diff: string) => {
854
+ const smolModel = this.session.settings.getModelRole("smol");
855
+ return generateCommitMessage(
856
+ diff,
857
+ this.session.modelRegistry!,
858
+ smolModel,
859
+ this.session.getSessionId?.() ?? undefined,
860
+ );
861
+ }
862
+ : undefined;
863
+ const commitResult = await commitToBranch(
864
+ isolationDir,
865
+ taskBaseline,
866
+ task.id,
867
+ task.description,
868
+ commitMsg,
869
+ );
870
+ return {
871
+ ...result,
872
+ branchName: commitResult?.branchName,
873
+ nestedPatches: commitResult?.nestedPatches,
874
+ };
875
+ } catch (mergeErr) {
876
+ // Agent succeeded but branch commit failed — clean up stale branch
877
+ const branchName = `omp/task/${task.id}`;
878
+ await $`git branch -D ${branchName}`.cwd(repoRoot).quiet().nothrow();
879
+ const msg = mergeErr instanceof Error ? mergeErr.message : String(mergeErr);
880
+ return { ...result, error: `Merge failed: ${msg}` };
881
+ }
882
+ }
883
+ if (result.exitCode === 0) {
884
+ try {
885
+ const delta = await captureDeltaPatch(isolationDir, taskBaseline);
886
+ const patchPath = path.join(effectiveArtifactsDir, `${task.id}.patch`);
887
+ await Bun.write(patchPath, delta.rootPatch);
888
+ return {
889
+ ...result,
890
+ patchPath,
891
+ nestedPatches: delta.nestedPatches,
892
+ };
893
+ } catch (patchErr) {
894
+ const msg = patchErr instanceof Error ? patchErr.message : String(patchErr);
895
+ return { ...result, error: `Patch capture failed: ${msg}` };
896
+ }
897
+ }
898
+ return result;
840
899
  } catch (err) {
841
900
  const message = err instanceof Error ? err.message : String(err);
842
901
  return {
@@ -856,8 +915,12 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
856
915
  error: message,
857
916
  };
858
917
  } finally {
859
- if (worktreeDir) {
860
- await cleanupWorktree(worktreeDir);
918
+ if (isolationDir) {
919
+ if (isolationMode === "fuse-overlay") {
920
+ await cleanupFuseOverlay(isolationDir);
921
+ } else {
922
+ await cleanupWorktree(isolationDir);
923
+ }
861
924
  }
862
925
  }
863
926
  };
@@ -917,74 +980,152 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
917
980
  }
918
981
  }
919
982
 
920
- let patchApplySummary = "";
921
- let patchesApplied: boolean | null = null;
922
- if (isIsolated) {
923
- const patchesInOrder = results.map(result => result.patchPath).filter(Boolean) as string[];
924
- const missingPatch = results.some(result => !result.patchPath);
925
- if (!repoRoot || missingPatch) {
926
- patchesApplied = false;
983
+ let mergeSummary = "";
984
+ let changesApplied: boolean | null = null;
985
+ let mergedBranchesForNestedPatches: Set<string> | null = null;
986
+ if (isIsolated && repoRoot) {
987
+ if (mergeMode === "branch") {
988
+ // Branch mode: merge task branches sequentially
989
+ const branchEntries = results
990
+ .filter(r => r.branchName && r.exitCode === 0 && !r.aborted)
991
+ .map(r => ({ branchName: r.branchName!, taskId: r.id, description: r.description }));
992
+
993
+ if (branchEntries.length === 0) {
994
+ changesApplied = true;
995
+ } else {
996
+ const mergeResult = await mergeTaskBranches(repoRoot, branchEntries);
997
+ mergedBranchesForNestedPatches = new Set(mergeResult.merged);
998
+ changesApplied = mergeResult.failed.length === 0;
999
+
1000
+ if (changesApplied) {
1001
+ mergeSummary = `\n\nMerged ${mergeResult.merged.length} branch${mergeResult.merged.length === 1 ? "" : "es"}: ${mergeResult.merged.join(", ")}`;
1002
+ } else {
1003
+ const mergedPart =
1004
+ mergeResult.merged.length > 0 ? `Merged: ${mergeResult.merged.join(", ")}.\n` : "";
1005
+ const failedPart = `Failed: ${mergeResult.failed.join(", ")}.`;
1006
+ const conflictPart = mergeResult.conflict ? `\nConflict: ${mergeResult.conflict}` : "";
1007
+ mergeSummary = `\n\n<system-notification>Branch merge failed. ${mergedPart}${failedPart}${conflictPart}\nUnmerged branches remain for manual resolution.</system-notification>`;
1008
+ }
1009
+ }
1010
+
1011
+ // Clean up merged branches (keep failed ones for manual resolution)
1012
+ const allBranches = branchEntries.map(b => b.branchName);
1013
+ if (changesApplied) {
1014
+ await cleanupTaskBranches(repoRoot, allBranches);
1015
+ }
927
1016
  } else {
928
- const patchStats = await Promise.all(
929
- patchesInOrder.map(async patchPath => ({
930
- patchPath,
931
- size: (await fs.stat(patchPath)).size,
932
- })),
933
- );
934
- const nonEmptyPatches = patchStats.filter(patch => patch.size > 0).map(patch => patch.patchPath);
935
- if (nonEmptyPatches.length === 0) {
936
- patchesApplied = true;
1017
+ // Patch mode: combine and apply patches
1018
+ const patchesInOrder = results.map(result => result.patchPath).filter(Boolean) as string[];
1019
+ const missingPatch = results.some(result => !result.patchPath);
1020
+ if (missingPatch) {
1021
+ changesApplied = false;
937
1022
  } else {
938
- const patchTexts = await Promise.all(
939
- nonEmptyPatches.map(async patchPath => Bun.file(patchPath).text()),
1023
+ const patchStats = await Promise.all(
1024
+ patchesInOrder.map(async patchPath => ({
1025
+ patchPath,
1026
+ size: (await fs.stat(patchPath)).size,
1027
+ })),
940
1028
  );
941
- const combinedPatch = patchTexts.map(text => (text.endsWith("\n") ? text : `${text}\n`)).join("");
942
- if (!combinedPatch.trim()) {
943
- patchesApplied = true;
1029
+ const nonEmptyPatches = patchStats.filter(patch => patch.size > 0).map(patch => patch.patchPath);
1030
+ if (nonEmptyPatches.length === 0) {
1031
+ changesApplied = true;
944
1032
  } else {
945
- const combinedPatchPath = path.join(os.tmpdir(), `omp-task-combined-${Snowflake.next()}.patch`);
946
- try {
947
- await Bun.write(combinedPatchPath, combinedPatch);
948
- const checkResult = await $`git apply --check --binary ${combinedPatchPath}`
949
- .cwd(repoRoot)
950
- .quiet()
951
- .nothrow();
952
- if (checkResult.exitCode !== 0) {
953
- patchesApplied = false;
954
- } else {
955
- const applyResult = await $`git apply --binary ${combinedPatchPath}`
1033
+ const patchTexts = await Promise.all(
1034
+ nonEmptyPatches.map(async patchPath => Bun.file(patchPath).text()),
1035
+ );
1036
+ const combinedPatch = patchTexts.map(text => (text.endsWith("\n") ? text : `${text}\n`)).join("");
1037
+ if (!combinedPatch.trim()) {
1038
+ changesApplied = true;
1039
+ } else {
1040
+ const combinedPatchPath = path.join(os.tmpdir(), `omp-task-combined-${Snowflake.next()}.patch`);
1041
+ try {
1042
+ await Bun.write(combinedPatchPath, combinedPatch);
1043
+ const checkResult = await $`git apply --check --binary ${combinedPatchPath}`
956
1044
  .cwd(repoRoot)
957
1045
  .quiet()
958
1046
  .nothrow();
959
- patchesApplied = applyResult.exitCode === 0;
1047
+ if (checkResult.exitCode !== 0) {
1048
+ changesApplied = false;
1049
+ } else {
1050
+ const applyResult = await $`git apply --binary ${combinedPatchPath}`
1051
+ .cwd(repoRoot)
1052
+ .quiet()
1053
+ .nothrow();
1054
+ changesApplied = applyResult.exitCode === 0;
1055
+ }
1056
+ } finally {
1057
+ await fs.rm(combinedPatchPath, { force: true });
960
1058
  }
961
- } finally {
962
- await fs.rm(combinedPatchPath, { force: true });
963
1059
  }
964
1060
  }
965
1061
  }
1062
+
1063
+ if (changesApplied) {
1064
+ mergeSummary = "\n\nApplied patches: yes";
1065
+ } else {
1066
+ const notification =
1067
+ "<system-notification>Patches were not applied and must be handled manually.</system-notification>";
1068
+ const patchList =
1069
+ patchPaths.length > 0
1070
+ ? `\n\nPatch artifacts:\n${patchPaths.map(patch => `- ${patch}`).join("\n")}`
1071
+ : "";
1072
+ mergeSummary = `\n\n${notification}${patchList}`;
1073
+ }
966
1074
  }
1075
+ }
967
1076
 
968
- if (patchesApplied) {
969
- patchApplySummary = "\n\nApplied patches: yes";
970
- } else {
971
- const notification =
972
- "<system-notification>Patches were not applied and must be handled manually.</system-notification>";
973
- const patchList =
974
- patchPaths.length > 0
975
- ? `\n\nPatch artifacts:\n${patchPaths.map(patch => `- ${patch}`).join("\n")}`
976
- : "";
977
- patchApplySummary = `\n\n${notification}${patchList}`;
1077
+ // Apply nested repo patches (separate from parent git)
1078
+ if (isIsolated && repoRoot && (mergeMode === "branch" || changesApplied !== false)) {
1079
+ const allNestedPatches = results
1080
+ .filter(r => {
1081
+ if (!r.nestedPatches || r.nestedPatches.length === 0 || r.exitCode !== 0 || r.aborted) {
1082
+ return false;
1083
+ }
1084
+ if (mergeMode !== "branch") {
1085
+ return true;
1086
+ }
1087
+ if (!r.branchName || !mergedBranchesForNestedPatches) {
1088
+ return false;
1089
+ }
1090
+ return mergedBranchesForNestedPatches.has(r.branchName);
1091
+ })
1092
+ .flatMap(r => r.nestedPatches!);
1093
+ if (allNestedPatches.length > 0) {
1094
+ try {
1095
+ const commitMsg =
1096
+ commitStyle === "ai" && this.session.modelRegistry
1097
+ ? async (diff: string) => {
1098
+ const smolModel = this.session.settings.getModelRole("smol");
1099
+ return generateCommitMessage(
1100
+ diff,
1101
+ this.session.modelRegistry!,
1102
+ smolModel,
1103
+ this.session.getSessionId?.() ?? undefined,
1104
+ );
1105
+ }
1106
+ : undefined;
1107
+ await applyNestedPatches(repoRoot, allNestedPatches, commitMsg);
1108
+ } catch {
1109
+ // Nested patch failures are non-fatal to the parent merge
1110
+ mergeSummary +=
1111
+ "\n\n<system-notification>Some nested repository patches failed to apply.</system-notification>";
1112
+ }
978
1113
  }
979
1114
  }
980
1115
 
981
1116
  // Build final output - match plugin format
982
- const successCount = results.filter(r => r.exitCode === 0).length;
1117
+ const successCount = results.filter(r => r.exitCode === 0 && !r.error).length;
983
1118
  const cancelledCount = results.filter(r => r.aborted).length;
984
1119
  const totalDuration = Date.now() - startTime;
985
1120
 
986
1121
  const summaries = results.map(r => {
987
- const status = r.aborted ? "cancelled" : r.exitCode === 0 ? "completed" : `failed (exit ${r.exitCode})`;
1122
+ const status = r.aborted
1123
+ ? "cancelled"
1124
+ : r.exitCode === 0 && r.error
1125
+ ? "merge failed"
1126
+ : r.exitCode === 0
1127
+ ? "completed"
1128
+ : `failed (exit ${r.exitCode})`;
988
1129
  const output = r.output.trim() || r.stderr.trim() || "(no output)";
989
1130
  const outputCharCount = r.outputMeta?.charCount ?? output.length;
990
1131
  const fullOutputThreshold = 5000;
@@ -1021,12 +1162,12 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
1021
1162
  summaries,
1022
1163
  outputIds,
1023
1164
  agentName,
1024
- patchApplySummary,
1165
+ mergeSummary,
1025
1166
  });
1026
1167
 
1027
1168
  // Cleanup temp directory if used
1028
1169
  const shouldCleanupTempArtifacts =
1029
- tempArtifactsDir && (!isIsolated || patchesApplied === true || patchesApplied === null);
1170
+ tempArtifactsDir && (!isIsolated || changesApplied === true || changesApplied === null);
1030
1171
  if (shouldCleanupTempArtifacts) {
1031
1172
  await fs.rm(tempArtifactsDir, { recursive: true, force: true });
1032
1173
  }
@@ -728,7 +728,8 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool
728
728
  result.output,
729
729
  );
730
730
  const aborted = result.aborted ?? false;
731
- const success = !aborted && result.exitCode === 0;
731
+ const mergeFailed = !aborted && result.exitCode === 0 && !!result.error;
732
+ const success = !aborted && result.exitCode === 0 && !result.error;
732
733
  const needsWarning = Boolean(missingCompleteWarning) && success;
733
734
  const icon = aborted
734
735
  ? theme.status.aborted
@@ -737,8 +738,16 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool
737
738
  : success
738
739
  ? theme.status.success
739
740
  : theme.status.error;
740
- const iconColor = needsWarning ? "warning" : success ? "success" : "error";
741
- const statusText = aborted ? "aborted" : needsWarning ? "warning" : success ? "done" : "failed";
741
+ const iconColor = needsWarning ? "warning" : success ? "success" : mergeFailed ? "warning" : "error";
742
+ const statusText = aborted
743
+ ? "aborted"
744
+ : needsWarning
745
+ ? "warning"
746
+ : success
747
+ ? "done"
748
+ : mergeFailed
749
+ ? "merge failed"
750
+ : "failed";
742
751
 
743
752
  // Main status line: id: description [status] · stats · ⟨agent⟩
744
753
  const description = result.description?.trim();
@@ -847,11 +856,13 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool
847
856
 
848
857
  if (result.patchPath && !aborted && result.exitCode === 0) {
849
858
  lines.push(`${continuePrefix}${theme.fg("dim", `Patch: ${result.patchPath}`)}`);
859
+ } else if (result.branchName && !aborted && result.exitCode === 0) {
860
+ lines.push(`${continuePrefix}${theme.fg("dim", `Branch: ${result.branchName}`)}`);
850
861
  }
851
862
 
852
863
  // Error message
853
- if (result.error && !success) {
854
- lines.push(`${continuePrefix}${theme.fg("error", truncateToWidth(result.error, 70))}`);
864
+ if (result.error && (!success || mergeFailed)) {
865
+ lines.push(`${continuePrefix}${theme.fg(mergeFailed ? "warning" : "error", truncateToWidth(result.error, 70))}`);
855
866
  }
856
867
 
857
868
  return lines;
@@ -902,15 +913,20 @@ export function renderResult(
902
913
  });
903
914
 
904
915
  const abortedCount = details.results.filter(r => r.aborted).length;
905
- const successCount = details.results.filter(r => !r.aborted && r.exitCode === 0).length;
906
- const failCount = details.results.length - successCount - abortedCount;
916
+ const mergeFailedCount = details.results.filter(r => !r.aborted && r.exitCode === 0 && r.error).length;
917
+ const successCount = details.results.filter(r => !r.aborted && r.exitCode === 0 && !r.error).length;
918
+ const failCount = details.results.length - successCount - mergeFailedCount - abortedCount;
907
919
  let summary = `${theme.fg("dim", "Total:")} `;
908
920
  if (abortedCount > 0) {
909
921
  summary += theme.fg("error", `${abortedCount} aborted`);
910
- if (successCount > 0 || failCount > 0) summary += theme.sep.dot;
922
+ if (successCount > 0 || mergeFailedCount > 0 || failCount > 0) summary += theme.sep.dot;
911
923
  }
912
924
  if (successCount > 0) {
913
925
  summary += theme.fg("success", `${successCount} succeeded`);
926
+ if (mergeFailedCount > 0 || failCount > 0) summary += theme.sep.dot;
927
+ }
928
+ if (mergeFailedCount > 0) {
929
+ summary += theme.fg("warning", `${mergeFailedCount} merge failed`);
914
930
  if (failCount > 0) summary += theme.sep.dot;
915
931
  }
916
932
  if (failCount > 0) {
package/src/task/types.ts CHANGED
@@ -2,6 +2,7 @@ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
2
2
  import type { Usage } from "@oh-my-pi/pi-ai";
3
3
  import { $env } from "@oh-my-pi/pi-utils";
4
4
  import { type Static, Type } from "@sinclair/typebox";
5
+ import type { NestedRepoPatch } from "./worktree";
5
6
 
6
7
  /** Source of an agent definition */
7
8
  export type AgentSource = "bundled" | "user" | "project";
@@ -77,7 +78,7 @@ const createTaskSchema = (options: { isolationEnabled: boolean }) => {
77
78
  ...properties,
78
79
  isolated: Type.Optional(
79
80
  Type.Boolean({
80
- description: "Run in isolated git worktree; returns patches. Use when tasks edit overlapping files.",
81
+ description: "Run in isolated environment; returns patches. Use when tasks edit overlapping files.",
81
82
  }),
82
83
  ),
83
84
  });
@@ -179,6 +180,10 @@ export interface SingleResult {
179
180
  outputPath?: string;
180
181
  /** Patch path for isolated worktree output */
181
182
  patchPath?: string;
183
+ /** Branch name for isolated branch-mode output */
184
+ branchName?: string;
185
+ /** Nested repo patches to apply after parent merge */
186
+ nestedPatches?: NestedRepoPatch[];
182
187
  /** Data extracted by registered subprocess tool handlers (keyed by tool name) */
183
188
  extractedToolData?: Record<string, unknown[]>;
184
189
  /** Output metadata for agent:// URL integration */