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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +43 -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/cli/config-cli.ts +32 -20
  6. package/src/config/settings-schema.ts +96 -14
  7. package/src/config/settings.ts +10 -0
  8. package/src/discovery/claude.ts +24 -6
  9. package/src/discovery/helpers.ts +9 -2
  10. package/src/ipy/runtime.ts +1 -0
  11. package/src/mcp/config.ts +1 -1
  12. package/src/modes/components/settings-defs.ts +53 -1
  13. package/src/modes/components/status-line.ts +7 -5
  14. package/src/modes/controllers/mcp-command-controller.ts +4 -3
  15. package/src/modes/controllers/selector-controller.ts +46 -0
  16. package/src/modes/interactive-mode.ts +9 -0
  17. package/src/modes/oauth-manual-input.ts +42 -0
  18. package/src/modes/types.ts +2 -0
  19. package/src/patch/hashline.ts +19 -1
  20. package/src/patch/index.ts +7 -8
  21. package/src/prompts/system/commit-message-system.md +2 -0
  22. package/src/prompts/system/subagent-submit-reminder.md +3 -3
  23. package/src/prompts/system/subagent-system-prompt.md +4 -4
  24. package/src/prompts/system/system-prompt.md +13 -0
  25. package/src/prompts/tools/hashline.md +45 -1
  26. package/src/prompts/tools/task-summary.md +4 -4
  27. package/src/prompts/tools/task.md +1 -1
  28. package/src/sdk.ts +8 -0
  29. package/src/slash-commands/builtin-registry.ts +26 -1
  30. package/src/system-prompt.ts +4 -0
  31. package/src/task/index.ts +211 -70
  32. package/src/task/render.ts +44 -16
  33. package/src/task/types.ts +6 -1
  34. package/src/task/worktree.ts +394 -31
  35. package/src/tools/review.ts +50 -1
  36. package/src/tools/submit-result.ts +22 -23
  37. package/src/utils/commit-message-generator.ts +132 -0
  38. package/src/web/search/providers/exa.ts +41 -4
  39. package/src/web/search/providers/perplexity.ts +20 -8
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
  }
@@ -22,6 +22,7 @@ import {
22
22
  type FindingPriority,
23
23
  getPriorityInfo,
24
24
  PRIORITY_LABELS,
25
+ parseReportFindingDetails,
25
26
  type ReportFindingDetails,
26
27
  type SubmitReviewDetails,
27
28
  } from "../tools/review";
@@ -68,6 +69,16 @@ function formatFindingSummary(findings: ReportFindingDetails[], theme: Theme): s
68
69
  return `${theme.fg("dim", "Findings:")} ${parts.join(theme.sep.dot)}`;
69
70
  }
70
71
 
72
+ function normalizeReportFindings(value: unknown): ReportFindingDetails[] {
73
+ if (!Array.isArray(value)) return [];
74
+ const findings: ReportFindingDetails[] = [];
75
+ for (const item of value) {
76
+ const finding = parseReportFindingDetails(item);
77
+ if (finding) findings.push(finding);
78
+ }
79
+ return findings;
80
+ }
81
+
71
82
  function formatJsonScalar(value: unknown, _theme: Theme): string {
72
83
  if (value === null) return "null";
73
84
  if (typeof value === "string") {
@@ -569,13 +580,13 @@ function renderAgentProgress(
569
580
  // For completed tasks, check for review verdict from submit_result tool
570
581
  if (progress.status === "completed") {
571
582
  const completeData = progress.extractedToolData.submit_result as Array<{ data: unknown }> | undefined;
572
- const reportFindingData = progress.extractedToolData.report_finding as ReportFindingDetails[] | undefined;
583
+ const reportFindingData = normalizeReportFindings(progress.extractedToolData.report_finding);
573
584
  const reviewData = completeData
574
585
  ?.map(c => c.data as SubmitReviewDetails)
575
586
  .filter(d => d && typeof d === "object" && "overall_correctness" in d);
576
587
  if (reviewData && reviewData.length > 0) {
577
588
  const summary = reviewData[reviewData.length - 1];
578
- const findings = reportFindingData ?? [];
589
+ const findings = reportFindingData;
579
590
  lines.push(...renderReviewResult(summary, findings, continuePrefix, expanded, theme));
580
591
  return lines; // Review result handles its own rendering
581
592
  }
@@ -583,8 +594,9 @@ function renderAgentProgress(
583
594
 
584
595
  for (const [toolName, dataArray] of Object.entries(progress.extractedToolData)) {
585
596
  // Handle report_finding with tree formatting
586
- if (toolName === "report_finding" && (dataArray as ReportFindingDetails[]).length > 0) {
587
- const findings = dataArray as ReportFindingDetails[];
597
+ if (toolName === "report_finding") {
598
+ const findings = normalizeReportFindings(dataArray);
599
+ if (findings.length === 0) continue;
588
600
  lines.push(`${continuePrefix}${formatFindingSummary(findings, theme)}`);
589
601
  lines.push(...renderFindings(findings, continuePrefix, expanded, theme));
590
602
  continue;
@@ -693,7 +705,7 @@ function renderFindings(
693
705
 
694
706
  const { color } = getPriorityInfo(finding.priority);
695
707
  const titleText = finding.title?.replace(/^\[P\d\]\s*/, "") ?? "Untitled";
696
- const loc = `${path.basename(finding.file_path)}:${finding.line_start}`;
708
+ const loc = `${path.basename(finding.file_path || "<unknown>")}:${finding.line_start}`;
697
709
 
698
710
  lines.push(
699
711
  `${continuePrefix}${findingPrefix} ${theme.fg(color, `[${finding.priority}]`)} ${titleText} ${theme.fg("dim", loc)}`,
@@ -728,7 +740,8 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool
728
740
  result.output,
729
741
  );
730
742
  const aborted = result.aborted ?? false;
731
- const success = !aborted && result.exitCode === 0;
743
+ const mergeFailed = !aborted && result.exitCode === 0 && !!result.error;
744
+ const success = !aborted && result.exitCode === 0 && !result.error;
732
745
  const needsWarning = Boolean(missingCompleteWarning) && success;
733
746
  const icon = aborted
734
747
  ? theme.status.aborted
@@ -737,8 +750,16 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool
737
750
  : success
738
751
  ? theme.status.success
739
752
  : theme.status.error;
740
- const iconColor = needsWarning ? "warning" : success ? "success" : "error";
741
- const statusText = aborted ? "aborted" : needsWarning ? "warning" : success ? "done" : "failed";
753
+ const iconColor = needsWarning ? "warning" : success ? "success" : mergeFailed ? "warning" : "error";
754
+ const statusText = aborted
755
+ ? "aborted"
756
+ : needsWarning
757
+ ? "warning"
758
+ : success
759
+ ? "done"
760
+ : mergeFailed
761
+ ? "merge failed"
762
+ : "failed";
742
763
 
743
764
  // Main status line: id: description [status] · stats · ⟨agent⟩
744
765
  const description = result.description?.trim();
@@ -764,7 +785,7 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool
764
785
 
765
786
  // Check for review result (submit_result with review schema + report_finding)
766
787
  const completeData = result.extractedToolData?.submit_result as Array<{ data: unknown }> | undefined;
767
- const reportFindingData = result.extractedToolData?.report_finding as ReportFindingDetails[] | undefined;
788
+ const reportFindingData = normalizeReportFindings(result.extractedToolData?.report_finding);
768
789
 
769
790
  // Extract review verdict from submit_result tool's data field if it matches SubmitReviewDetails
770
791
  const reviewData = completeData
@@ -775,11 +796,11 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool
775
796
  if (submitReviewData && submitReviewData.length > 0) {
776
797
  // Use combined review renderer
777
798
  const summary = submitReviewData[submitReviewData.length - 1];
778
- const findings = reportFindingData ?? [];
799
+ const findings = reportFindingData;
779
800
  lines.push(...renderReviewResult(summary, findings, continuePrefix, expanded, theme));
780
801
  return lines;
781
802
  }
782
- if (reportFindingData && reportFindingData.length > 0) {
803
+ if (reportFindingData.length > 0) {
783
804
  const hasCompleteData = completeData && completeData.length > 0;
784
805
  const message = hasCompleteData
785
806
  ? "Review verdict missing expected fields"
@@ -847,11 +868,13 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool
847
868
 
848
869
  if (result.patchPath && !aborted && result.exitCode === 0) {
849
870
  lines.push(`${continuePrefix}${theme.fg("dim", `Patch: ${result.patchPath}`)}`);
871
+ } else if (result.branchName && !aborted && result.exitCode === 0) {
872
+ lines.push(`${continuePrefix}${theme.fg("dim", `Branch: ${result.branchName}`)}`);
850
873
  }
851
874
 
852
875
  // Error message
853
- if (result.error && !success) {
854
- lines.push(`${continuePrefix}${theme.fg("error", truncateToWidth(result.error, 70))}`);
876
+ if (result.error && (!success || mergeFailed)) {
877
+ lines.push(`${continuePrefix}${theme.fg(mergeFailed ? "warning" : "error", truncateToWidth(result.error, 70))}`);
855
878
  }
856
879
 
857
880
  return lines;
@@ -902,15 +925,20 @@ export function renderResult(
902
925
  });
903
926
 
904
927
  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;
928
+ const mergeFailedCount = details.results.filter(r => !r.aborted && r.exitCode === 0 && r.error).length;
929
+ const successCount = details.results.filter(r => !r.aborted && r.exitCode === 0 && !r.error).length;
930
+ const failCount = details.results.length - successCount - mergeFailedCount - abortedCount;
907
931
  let summary = `${theme.fg("dim", "Total:")} `;
908
932
  if (abortedCount > 0) {
909
933
  summary += theme.fg("error", `${abortedCount} aborted`);
910
- if (successCount > 0 || failCount > 0) summary += theme.sep.dot;
934
+ if (successCount > 0 || mergeFailedCount > 0 || failCount > 0) summary += theme.sep.dot;
911
935
  }
912
936
  if (successCount > 0) {
913
937
  summary += theme.fg("success", `${successCount} succeeded`);
938
+ if (mergeFailedCount > 0 || failCount > 0) summary += theme.sep.dot;
939
+ }
940
+ if (mergeFailedCount > 0) {
941
+ summary += theme.fg("warning", `${mergeFailedCount} merge failed`);
914
942
  if (failCount > 0) summary += theme.sep.dot;
915
943
  }
916
944
  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 */