@oh-my-pi/pi-coding-agent 16.1.15 → 16.1.16
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 +48 -0
- package/dist/cli.js +3725 -4029
- package/dist/types/cli/args.d.ts +2 -5
- package/dist/types/cli/flag-tables.d.ts +2 -2
- package/dist/types/cli/session-picker.d.ts +4 -2
- package/dist/types/commands/launch.d.ts +1 -1
- package/dist/types/config/settings-schema.d.ts +12 -1
- package/dist/types/eval/agent-bridge.d.ts +19 -0
- package/dist/types/eval/js/shared/helpers.d.ts +1 -13
- package/dist/types/eval/js/shared/types.d.ts +1 -1
- package/dist/types/eval/js/worker-protocol.d.ts +1 -1
- package/dist/types/eval/py/executor.d.ts +1 -1
- package/dist/types/internal-urls/local-protocol.d.ts +18 -1
- package/dist/types/main.d.ts +2 -0
- package/dist/types/modes/components/plugin-settings.d.ts +5 -0
- package/dist/types/modes/components/session-selector.d.ts +25 -0
- package/dist/types/task/isolation-runner.d.ts +128 -0
- package/dist/types/task/worktree.d.ts +14 -1
- package/dist/types/thinking.d.ts +15 -0
- package/dist/types/tools/eval-render.d.ts +3 -0
- package/dist/types/tools/eval.d.ts +11 -17
- package/dist/types/tools/todo.d.ts +26 -28
- package/dist/types/tui/output-block.d.ts +8 -0
- package/dist/types/utils/image-resize.d.ts +2 -0
- package/dist/types/web/search/providers/exa.d.ts +2 -0
- package/package.json +12 -12
- package/scripts/build-binary.ts +18 -4
- package/src/cli/args.ts +4 -5
- package/src/cli/flag-tables.ts +3 -3
- package/src/cli/gallery-fixtures/interaction.ts +6 -9
- package/src/cli/gallery-fixtures/shell.ts +15 -23
- package/src/cli/session-picker.ts +17 -3
- package/src/commands/launch.ts +3 -3
- package/src/config/settings-schema.ts +13 -1
- package/src/edit/renderer.ts +34 -12
- package/src/eval/__tests__/agent-bridge.test.ts +462 -3
- package/src/eval/__tests__/helpers-local-roots.test.ts +2 -5
- package/src/eval/__tests__/julia-prelude.test.ts +1 -30
- package/src/eval/__tests__/prelude-agent.test.ts +42 -8
- package/src/eval/agent-bridge.ts +301 -71
- package/src/eval/jl/prelude.jl +32 -227
- package/src/eval/jl/runner.jl +38 -12
- package/src/eval/js/shared/helpers.ts +1 -114
- package/src/eval/js/shared/prelude.txt +13 -27
- package/src/eval/js/shared/runtime.ts +0 -6
- package/src/eval/js/shared/types.ts +1 -1
- package/src/eval/js/worker-protocol.ts +1 -1
- package/src/eval/py/__tests__/prelude.test.ts +13 -0
- package/src/eval/py/executor.ts +1 -1
- package/src/eval/py/prelude.py +47 -105
- package/src/eval/py/runner.py +0 -6
- package/src/eval/rb/prelude.rb +21 -189
- package/src/eval/rb/runner.rb +116 -9
- package/src/export/html/tool-views.generated.js +29 -29
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/internal-urls/local-protocol.ts +100 -53
- package/src/main.ts +15 -4
- package/src/modes/acp/acp-event-mapper.ts +7 -2
- package/src/modes/components/plugin-settings.ts +7 -1
- package/src/modes/components/session-selector.ts +143 -29
- package/src/modes/controllers/command-controller.ts +5 -0
- package/src/modes/rpc/rpc-mode.ts +6 -0
- package/src/modes/utils/copy-targets.ts +7 -2
- package/src/prompts/system/system-prompt.md +3 -3
- package/src/prompts/system/workflow-notice.md +3 -3
- package/src/prompts/tools/bash.md +16 -0
- package/src/prompts/tools/eval.md +19 -19
- package/src/prompts/tools/todo.md +1 -1
- package/src/session/agent-session.ts +231 -50
- package/src/task/index.ts +61 -207
- package/src/task/isolation-runner.ts +354 -0
- package/src/task/worktree.ts +46 -9
- package/src/thinking.ts +20 -0
- package/src/tools/ask.ts +44 -38
- package/src/tools/bash.ts +9 -2
- package/src/tools/browser/tab-worker.ts +1 -1
- package/src/tools/eval-render.ts +34 -27
- package/src/tools/eval.ts +100 -103
- package/src/tools/index.ts +8 -1
- package/src/tools/read.ts +136 -60
- package/src/tools/todo.ts +60 -64
- package/src/tui/code-cell.ts +1 -1
- package/src/tui/output-block.ts +11 -0
- package/src/utils/image-resize.ts +30 -0
- package/src/web/search/providers/exa.ts +85 -1
package/src/task/index.ts
CHANGED
|
@@ -47,29 +47,22 @@ import type { AsyncJobManager } from "../async";
|
|
|
47
47
|
import type { LocalProtocolOptions } from "../internal-urls";
|
|
48
48
|
import { loadOverallPlanReference } from "../plan-mode/plan-handoff";
|
|
49
49
|
import { AgentRegistry, MAIN_AGENT_ID } from "../registry/agent-registry";
|
|
50
|
-
import { generateCommitMessage } from "../utils/commit-message-generator";
|
|
51
|
-
import * as git from "../utils/git";
|
|
52
50
|
import { type DiscoveryResult, discoverAgents, getAgent } from "./discovery";
|
|
53
51
|
import { runSubprocess } from "./executor";
|
|
52
|
+
import {
|
|
53
|
+
applyEligibleNestedPatches,
|
|
54
|
+
type IsolationContext,
|
|
55
|
+
makeIsolationCommitMessage,
|
|
56
|
+
mergeIsolatedChanges,
|
|
57
|
+
prepareIsolationContext,
|
|
58
|
+
runIsolatedSubprocess,
|
|
59
|
+
} from "./isolation-runner";
|
|
54
60
|
import { generateTaskName } from "./name-generator";
|
|
55
61
|
import { AgentOutputManager } from "./output-manager";
|
|
56
62
|
import { mapWithConcurrencyLimit, Semaphore } from "./parallel";
|
|
57
63
|
import { renderResult, renderCall as renderTaskCall } from "./render";
|
|
58
64
|
import { repairTaskParams } from "./repair-args";
|
|
59
|
-
import {
|
|
60
|
-
applyNestedPatches,
|
|
61
|
-
captureBaseline,
|
|
62
|
-
captureDeltaPatch,
|
|
63
|
-
cleanupIsolation,
|
|
64
|
-
cleanupTaskBranches,
|
|
65
|
-
commitToBranch,
|
|
66
|
-
ensureIsolation,
|
|
67
|
-
getRepoRoot,
|
|
68
|
-
type IsolationHandle,
|
|
69
|
-
mergeTaskBranches,
|
|
70
|
-
parseIsolationMode,
|
|
71
|
-
type WorktreeBaseline,
|
|
72
|
-
} from "./worktree";
|
|
65
|
+
import { parseIsolationMode } from "./worktree";
|
|
73
66
|
|
|
74
67
|
function renderSubagentUserPrompt(assignment: string): string {
|
|
75
68
|
return prompt.render(subagentUserPromptTemplate, {
|
|
@@ -1047,7 +1040,6 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
|
|
|
1047
1040
|
const isolationRequested = "isolated" in params ? params.isolated === true : false;
|
|
1048
1041
|
const isIsolated = isolationMode !== "none" && isolationRequested;
|
|
1049
1042
|
const mergeMode = this.session.settings.get("task.isolation.merge");
|
|
1050
|
-
const commitStyle = this.session.settings.get("task.isolation.commits");
|
|
1051
1043
|
const taskDepth = this.session.taskDepth ?? 0;
|
|
1052
1044
|
const subagentLspEnabled = (this.session.enableLsp ?? true) && this.session.settings.get("task.enableLsp");
|
|
1053
1045
|
|
|
@@ -1118,12 +1110,10 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
|
|
|
1118
1110
|
// structured output go through eval agent(prompt, schema).
|
|
1119
1111
|
const effectiveOutputSchema = effectiveAgent.output ?? this.session.outputSchema;
|
|
1120
1112
|
|
|
1121
|
-
let
|
|
1122
|
-
let baseline: WorktreeBaseline | null = null;
|
|
1113
|
+
let isolationContext: IsolationContext | null = null;
|
|
1123
1114
|
if (isIsolated) {
|
|
1124
1115
|
try {
|
|
1125
|
-
|
|
1126
|
-
baseline = await captureBaseline(repoRoot);
|
|
1116
|
+
isolationContext = await prepareIsolationContext(this.session.cwd);
|
|
1127
1117
|
} catch (err) {
|
|
1128
1118
|
const message = err instanceof Error ? err.message : String(err);
|
|
1129
1119
|
return {
|
|
@@ -1132,6 +1122,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
|
|
|
1132
1122
|
};
|
|
1133
1123
|
}
|
|
1134
1124
|
}
|
|
1125
|
+
const repoRoot = isolationContext?.repoRoot ?? null;
|
|
1135
1126
|
|
|
1136
1127
|
const preferredIsolationBackend = parseIsolationMode(isolationMode);
|
|
1137
1128
|
|
|
@@ -1251,17 +1242,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
|
|
|
1251
1242
|
};
|
|
1252
1243
|
emitProgress();
|
|
1253
1244
|
|
|
1254
|
-
const buildCommitMessageFn = ()
|
|
1255
|
-
commitStyle === "ai" && this.session.modelRegistry
|
|
1256
|
-
? async (diff: string) => {
|
|
1257
|
-
return generateCommitMessage(
|
|
1258
|
-
diff,
|
|
1259
|
-
this.session.modelRegistry!,
|
|
1260
|
-
this.session.settings,
|
|
1261
|
-
this.session.getSessionId?.() ?? undefined,
|
|
1262
|
-
);
|
|
1263
|
-
}
|
|
1264
|
-
: undefined;
|
|
1245
|
+
const buildCommitMessageFn = makeIsolationCommitMessage(this.session);
|
|
1265
1246
|
|
|
1266
1247
|
const sharedRunOptions = {
|
|
1267
1248
|
cwd: this.session.cwd,
|
|
@@ -1321,192 +1302,65 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
|
|
|
1321
1302
|
if (!isIsolated) {
|
|
1322
1303
|
return runSubprocess(sharedRunOptions);
|
|
1323
1304
|
}
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
let isolationHandle: IsolationHandle | undefined;
|
|
1327
|
-
try {
|
|
1328
|
-
if (!repoRoot || !baseline) {
|
|
1329
|
-
throw new Error("Isolated task execution not initialized.");
|
|
1330
|
-
}
|
|
1331
|
-
const taskBaseline = structuredClone(baseline);
|
|
1332
|
-
|
|
1333
|
-
isolationHandle = await ensureIsolation(repoRoot, agentId, preferredIsolationBackend);
|
|
1334
|
-
const isolationDir = isolationHandle.mergedDir;
|
|
1335
|
-
|
|
1336
|
-
// Isolated runs re-discover extensions/custom tools inside the
|
|
1337
|
-
// worktree instead of reusing the parent's source paths.
|
|
1338
|
-
const result = await runSubprocess({
|
|
1339
|
-
...sharedRunOptions,
|
|
1340
|
-
worktree: isolationDir,
|
|
1341
|
-
preloadedExtensionPaths: undefined,
|
|
1342
|
-
preloadedCustomToolPaths: undefined,
|
|
1343
|
-
});
|
|
1344
|
-
if (mergeMode === "branch" && result.exitCode === 0) {
|
|
1345
|
-
try {
|
|
1346
|
-
const commitResult = await commitToBranch(
|
|
1347
|
-
isolationDir,
|
|
1348
|
-
taskBaseline,
|
|
1349
|
-
agentId,
|
|
1350
|
-
params.description,
|
|
1351
|
-
buildCommitMessageFn(),
|
|
1352
|
-
);
|
|
1353
|
-
return {
|
|
1354
|
-
...result,
|
|
1355
|
-
branchName: commitResult?.branchName,
|
|
1356
|
-
nestedPatches: commitResult?.nestedPatches,
|
|
1357
|
-
};
|
|
1358
|
-
} catch (mergeErr) {
|
|
1359
|
-
// Agent succeeded but branch commit failed — clean up stale branch
|
|
1360
|
-
const branchName = `omp/task/${agentId}`;
|
|
1361
|
-
await git.branch.tryDelete(repoRoot, branchName);
|
|
1362
|
-
const msg = mergeErr instanceof Error ? mergeErr.message : String(mergeErr);
|
|
1363
|
-
return { ...result, error: `Merge failed: ${msg}` };
|
|
1364
|
-
}
|
|
1365
|
-
}
|
|
1366
|
-
if (result.exitCode === 0) {
|
|
1367
|
-
try {
|
|
1368
|
-
const delta = await captureDeltaPatch(isolationDir, taskBaseline);
|
|
1369
|
-
const patchPath = path.join(effectiveArtifactsDir, `${agentId}.patch`);
|
|
1370
|
-
await Bun.write(patchPath, delta.rootPatch);
|
|
1371
|
-
return {
|
|
1372
|
-
...result,
|
|
1373
|
-
patchPath,
|
|
1374
|
-
nestedPatches: delta.nestedPatches,
|
|
1375
|
-
};
|
|
1376
|
-
} catch (patchErr) {
|
|
1377
|
-
const msg = patchErr instanceof Error ? patchErr.message : String(patchErr);
|
|
1378
|
-
return { ...result, error: `Patch capture failed: ${msg}` };
|
|
1379
|
-
}
|
|
1380
|
-
}
|
|
1381
|
-
return result;
|
|
1382
|
-
} catch (err) {
|
|
1383
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
1384
|
-
return {
|
|
1385
|
-
index: spawnIndex,
|
|
1386
|
-
id: agentId,
|
|
1387
|
-
agent: agent.name,
|
|
1388
|
-
agentSource: agent.source,
|
|
1389
|
-
task: renderSubagentUserPrompt(assignment),
|
|
1390
|
-
assignment,
|
|
1391
|
-
description: params.description,
|
|
1392
|
-
exitCode: 1,
|
|
1393
|
-
output: "",
|
|
1394
|
-
stderr: message,
|
|
1395
|
-
truncated: false,
|
|
1396
|
-
durationMs: Date.now() - taskStart,
|
|
1397
|
-
tokens: 0,
|
|
1398
|
-
requests: 0,
|
|
1399
|
-
modelOverride,
|
|
1400
|
-
error: message,
|
|
1401
|
-
};
|
|
1402
|
-
} finally {
|
|
1403
|
-
if (isolationHandle) {
|
|
1404
|
-
await cleanupIsolation(isolationHandle);
|
|
1405
|
-
}
|
|
1305
|
+
if (!isolationContext) {
|
|
1306
|
+
throw new Error("Isolated task execution not initialized.");
|
|
1406
1307
|
}
|
|
1308
|
+
const taskStart = Date.now();
|
|
1309
|
+
return runIsolatedSubprocess({
|
|
1310
|
+
baseOptions: sharedRunOptions,
|
|
1311
|
+
context: isolationContext,
|
|
1312
|
+
preferredBackend: preferredIsolationBackend,
|
|
1313
|
+
agentId,
|
|
1314
|
+
mergeMode,
|
|
1315
|
+
artifactsDir: effectiveArtifactsDir,
|
|
1316
|
+
description: params.description,
|
|
1317
|
+
buildCommitMessage: buildCommitMessageFn,
|
|
1318
|
+
buildFailureResult: err => {
|
|
1319
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1320
|
+
return {
|
|
1321
|
+
index: spawnIndex,
|
|
1322
|
+
id: agentId,
|
|
1323
|
+
agent: agent.name,
|
|
1324
|
+
agentSource: agent.source,
|
|
1325
|
+
task: renderSubagentUserPrompt(assignment),
|
|
1326
|
+
assignment,
|
|
1327
|
+
description: params.description,
|
|
1328
|
+
exitCode: 1,
|
|
1329
|
+
output: "",
|
|
1330
|
+
stderr: message,
|
|
1331
|
+
truncated: false,
|
|
1332
|
+
durationMs: Date.now() - taskStart,
|
|
1333
|
+
tokens: 0,
|
|
1334
|
+
requests: 0,
|
|
1335
|
+
modelOverride,
|
|
1336
|
+
error: message,
|
|
1337
|
+
};
|
|
1338
|
+
},
|
|
1339
|
+
});
|
|
1407
1340
|
};
|
|
1408
1341
|
|
|
1409
1342
|
const result = await runTask();
|
|
1410
1343
|
|
|
1411
1344
|
let mergeSummary = "";
|
|
1412
1345
|
let changesApplied: boolean | null = null;
|
|
1413
|
-
let hadAnyChanges = false;
|
|
1414
1346
|
let mergedBranchForNestedPatches = false;
|
|
1415
1347
|
if (isIsolated && repoRoot) {
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
mergeSummary = "\n\nNo changes to apply.";
|
|
1421
|
-
} else {
|
|
1422
|
-
const mergeResult = await mergeTaskBranches(repoRoot, [
|
|
1423
|
-
{ branchName: result.branchName, taskId: result.id, description: result.description },
|
|
1424
|
-
]);
|
|
1425
|
-
mergedBranchForNestedPatches = mergeResult.merged.includes(result.branchName);
|
|
1426
|
-
changesApplied = mergeResult.failed.length === 0;
|
|
1427
|
-
hadAnyChanges = changesApplied && mergeResult.merged.length > 0;
|
|
1428
|
-
|
|
1429
|
-
if (changesApplied) {
|
|
1430
|
-
mergeSummary = hadAnyChanges
|
|
1431
|
-
? `\n\nMerged branch: ${result.branchName}`
|
|
1432
|
-
: "\n\nNo changes to apply.";
|
|
1433
|
-
} else {
|
|
1434
|
-
const conflictPart = mergeResult.conflict ? `\nConflict: ${mergeResult.conflict}` : "";
|
|
1435
|
-
mergeSummary = `\n\n<system-notification>Branch merge failed: ${result.branchName}.${conflictPart}\nThe unmerged branch remains for manual resolution.</system-notification>`;
|
|
1436
|
-
}
|
|
1437
|
-
if (mergeResult.stashConflict) {
|
|
1438
|
-
mergeSummary += `\n\n<system-notification>${mergeResult.stashConflict}</system-notification>`;
|
|
1439
|
-
}
|
|
1440
|
-
|
|
1441
|
-
// Clean up the merged branch (keep failed ones for manual resolution)
|
|
1442
|
-
if (changesApplied) {
|
|
1443
|
-
await cleanupTaskBranches(repoRoot, [result.branchName]);
|
|
1444
|
-
}
|
|
1445
|
-
}
|
|
1446
|
-
} else {
|
|
1447
|
-
// Patch mode: apply the patch from a successful run. A failed or
|
|
1448
|
-
// aborted run has nothing to apply and must not block the result.
|
|
1449
|
-
const succeeded = result.exitCode === 0 && !result.error && !result.aborted;
|
|
1450
|
-
if (!succeeded) {
|
|
1451
|
-
changesApplied = true;
|
|
1452
|
-
hadAnyChanges = false;
|
|
1453
|
-
} else if (!result.patchPath) {
|
|
1454
|
-
changesApplied = false;
|
|
1455
|
-
hadAnyChanges = false;
|
|
1456
|
-
} else {
|
|
1457
|
-
const patchText = await Bun.file(result.patchPath).text();
|
|
1458
|
-
if (!patchText.trim()) {
|
|
1459
|
-
changesApplied = true;
|
|
1460
|
-
hadAnyChanges = false;
|
|
1461
|
-
} else {
|
|
1462
|
-
const normalized = patchText.endsWith("\n") ? patchText : `${patchText}\n`;
|
|
1463
|
-
changesApplied = await git.patch.canApplyText(repoRoot, normalized);
|
|
1464
|
-
if (changesApplied) {
|
|
1465
|
-
try {
|
|
1466
|
-
await git.patch.applyText(repoRoot, normalized);
|
|
1467
|
-
hadAnyChanges = true;
|
|
1468
|
-
} catch {
|
|
1469
|
-
changesApplied = false;
|
|
1470
|
-
hadAnyChanges = false;
|
|
1471
|
-
}
|
|
1472
|
-
}
|
|
1473
|
-
}
|
|
1474
|
-
}
|
|
1475
|
-
|
|
1476
|
-
if (changesApplied) {
|
|
1477
|
-
mergeSummary = hadAnyChanges ? "\n\nApplied patches: yes" : "\n\nNo changes to apply.";
|
|
1478
|
-
} else {
|
|
1479
|
-
const notification =
|
|
1480
|
-
"<system-notification>Patches were not applied and must be handled manually.</system-notification>";
|
|
1481
|
-
const patchList = result.patchPath ? `\n\nPatch artifact:\n- ${result.patchPath}` : "";
|
|
1482
|
-
mergeSummary = `\n\n${notification}${patchList}`;
|
|
1483
|
-
}
|
|
1484
|
-
}
|
|
1485
|
-
} catch (mergeErr) {
|
|
1486
|
-
const msg = mergeErr instanceof Error ? mergeErr.message : String(mergeErr);
|
|
1487
|
-
changesApplied = false;
|
|
1488
|
-
hadAnyChanges = false;
|
|
1489
|
-
mergeSummary = `\n\n<system-notification>Merge phase failed: ${msg}\nTask outputs are preserved but changes were not applied.</system-notification>`;
|
|
1490
|
-
}
|
|
1348
|
+
const outcome = await mergeIsolatedChanges({ result, repoRoot, mergeMode });
|
|
1349
|
+
mergeSummary = outcome.summary;
|
|
1350
|
+
changesApplied = outcome.changesApplied;
|
|
1351
|
+
mergedBranchForNestedPatches = outcome.mergedBranchForNestedPatches;
|
|
1491
1352
|
}
|
|
1492
1353
|
|
|
1493
|
-
// Apply nested repo patches (separate from parent git)
|
|
1494
|
-
if (isIsolated && repoRoot
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
await applyNestedPatches(repoRoot, nestedPatches, buildCommitMessageFn());
|
|
1504
|
-
} catch {
|
|
1505
|
-
// Nested patch failures are non-fatal to the parent merge
|
|
1506
|
-
mergeSummary +=
|
|
1507
|
-
"\n\n<system-notification>Some nested repository patches failed to apply.</system-notification>";
|
|
1508
|
-
}
|
|
1509
|
-
}
|
|
1354
|
+
// Apply nested repo patches (separate from parent git).
|
|
1355
|
+
if (isIsolated && repoRoot) {
|
|
1356
|
+
mergeSummary += await applyEligibleNestedPatches({
|
|
1357
|
+
result,
|
|
1358
|
+
repoRoot,
|
|
1359
|
+
mergeMode,
|
|
1360
|
+
changesApplied,
|
|
1361
|
+
mergedBranchForNestedPatches,
|
|
1362
|
+
commitMessage: buildCommitMessageFn(),
|
|
1363
|
+
});
|
|
1510
1364
|
}
|
|
1511
1365
|
|
|
1512
1366
|
// Cleanup temp directory if used
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reusable isolation lifecycle for subagent execution.
|
|
3
|
+
*
|
|
4
|
+
* Both `TaskTool` and the eval `agent()` bridge spawn subagents that can run
|
|
5
|
+
* inside a copy-on-write worktree, capture their changes, and (optionally)
|
|
6
|
+
* apply those changes back to the parent repo. The orchestration is identical
|
|
7
|
+
* for both callers; this module hosts the shared lifecycle so eval `agent()`
|
|
8
|
+
* does not need to round-trip through `TaskTool.#runSpawn`.
|
|
9
|
+
*
|
|
10
|
+
* Shape:
|
|
11
|
+
* 1. {@link prepareIsolationContext} — resolve git root + capture baseline.
|
|
12
|
+
* 2. {@link runIsolatedSubprocess} — start worktree, run, capture
|
|
13
|
+
* branch/patch, tear worktree down.
|
|
14
|
+
* 3. {@link mergeIsolatedChanges} — apply captured changes back to the
|
|
15
|
+
* parent repo (skip when the caller
|
|
16
|
+
* opted out).
|
|
17
|
+
*
|
|
18
|
+
* Step 1 happens once per top-level call (the baseline is cloned per spawn
|
|
19
|
+
* before mutation); steps 2 and 3 are per-spawn.
|
|
20
|
+
*/
|
|
21
|
+
import * as path from "node:path";
|
|
22
|
+
import type * as natives from "@oh-my-pi/pi-natives";
|
|
23
|
+
import type { ToolSession } from "../tools";
|
|
24
|
+
import { generateCommitMessage } from "../utils/commit-message-generator";
|
|
25
|
+
import * as git from "../utils/git";
|
|
26
|
+
import type { ExecutorOptions } from "./executor";
|
|
27
|
+
import { runSubprocess } from "./executor";
|
|
28
|
+
import type { SingleResult } from "./types";
|
|
29
|
+
import {
|
|
30
|
+
applyNestedPatches,
|
|
31
|
+
captureBaseline,
|
|
32
|
+
captureDeltaPatch,
|
|
33
|
+
cleanupIsolation,
|
|
34
|
+
cleanupTaskBranches,
|
|
35
|
+
commitToBranch,
|
|
36
|
+
ensureIsolation,
|
|
37
|
+
getRepoRoot,
|
|
38
|
+
type IsolationHandle,
|
|
39
|
+
mergeTaskBranches,
|
|
40
|
+
type WorktreeBaseline,
|
|
41
|
+
} from "./worktree";
|
|
42
|
+
|
|
43
|
+
type IsoBackendKind = natives.IsoBackendKind;
|
|
44
|
+
|
|
45
|
+
/** Resolved repo + baseline used by every isolated spawn in a single call. */
|
|
46
|
+
export interface IsolationContext {
|
|
47
|
+
repoRoot: string;
|
|
48
|
+
baseline: WorktreeBaseline;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolve the git repo root and capture the worktree baseline used to diff
|
|
53
|
+
* each isolated spawn against. Throws when the cwd is not inside a git
|
|
54
|
+
* repository; callers surface the error as a task-tool failure.
|
|
55
|
+
*/
|
|
56
|
+
export async function prepareIsolationContext(cwd: string): Promise<IsolationContext> {
|
|
57
|
+
const repoRoot = await getRepoRoot(cwd);
|
|
58
|
+
const baseline = await captureBaseline(repoRoot);
|
|
59
|
+
return { repoRoot, baseline };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Build a commit-message callback for branch/nested commits; `undefined` ⇒ fall back to generic message. */
|
|
63
|
+
export type BuildCommitMessage = () => undefined | ((diff: string) => Promise<string | null>);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Construct the commit-message factory used by isolation branch commits and
|
|
67
|
+
* nested-repo patch commits. Returns a closure that, each time it's called,
|
|
68
|
+
* either yields an AI-backed `(diff) => Promise<string|null>` callback (when
|
|
69
|
+
* `task.isolation.commits === "ai"` and a model registry is available) or
|
|
70
|
+
* `undefined` so the caller falls back to a generic commit message.
|
|
71
|
+
*
|
|
72
|
+
* Centralized so `TaskTool` and the eval `agent()` bridge share one wiring;
|
|
73
|
+
* a drift here previously meant the two callers built subtly different
|
|
74
|
+
* generators for the same setting.
|
|
75
|
+
*/
|
|
76
|
+
export function makeIsolationCommitMessage(session: ToolSession): BuildCommitMessage {
|
|
77
|
+
return () => {
|
|
78
|
+
const style = session.settings.get("task.isolation.commits");
|
|
79
|
+
if (style !== "ai" || !session.modelRegistry) return undefined;
|
|
80
|
+
const registry = session.modelRegistry;
|
|
81
|
+
const settings = session.settings;
|
|
82
|
+
const sessionId = session.getSessionId?.() ?? undefined;
|
|
83
|
+
return async (diff: string) => generateCommitMessage(diff, registry, settings, sessionId);
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface IsolatedRunOptions {
|
|
88
|
+
/**
|
|
89
|
+
* Base run options handed to the subagent subprocess. This helper sets
|
|
90
|
+
* `worktree`, clears `preloadedExtensionPaths` / `preloadedCustomToolPaths`
|
|
91
|
+
* (isolated runs re-discover inside the worktree), and forwards everything
|
|
92
|
+
* else unchanged.
|
|
93
|
+
*/
|
|
94
|
+
baseOptions: ExecutorOptions;
|
|
95
|
+
/** Context returned by {@link prepareIsolationContext}. Baseline is cloned per spawn. */
|
|
96
|
+
context: IsolationContext;
|
|
97
|
+
/** PAL backend hint from `parseIsolationMode(...)` (undefined ⇒ resolver picks). */
|
|
98
|
+
preferredBackend: IsoBackendKind | undefined;
|
|
99
|
+
/** Stable id used as the isolation worktree namespace and as the branch suffix. */
|
|
100
|
+
agentId: string;
|
|
101
|
+
/** Merge mode driving how changes are captured ("branch" commits, "patch" diffs). */
|
|
102
|
+
mergeMode: "patch" | "branch";
|
|
103
|
+
/** Output dir for `${agentId}.patch` artifacts (patch mode). */
|
|
104
|
+
artifactsDir: string;
|
|
105
|
+
/** Human description carried onto the branch commit (branch mode). */
|
|
106
|
+
description?: string;
|
|
107
|
+
/** Build a commit-message callback (`task.isolation.commits === "ai"`). */
|
|
108
|
+
buildCommitMessage?: BuildCommitMessage;
|
|
109
|
+
/**
|
|
110
|
+
* Construct a `SingleResult` when isolation setup throws — the caller has
|
|
111
|
+
* the full metadata (index, agent, assignment, modelOverride) needed to
|
|
112
|
+
* build a result shape consistent with their non-isolated path.
|
|
113
|
+
*/
|
|
114
|
+
buildFailureResult: (err: unknown) => SingleResult;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Run a subagent inside an isolation worktree and capture its changes.
|
|
119
|
+
*
|
|
120
|
+
* Branch mode: on success, commits the diff onto `omp/task/${agentId}` and
|
|
121
|
+
* returns `branchName` + `nestedPatches`. On commit failure the branch is
|
|
122
|
+
* deleted and `result.error` carries the merge-failure message.
|
|
123
|
+
*
|
|
124
|
+
* Patch mode: on success, writes `${artifactsDir}/${agentId}.patch` and
|
|
125
|
+
* returns `patchPath` + `nestedPatches`.
|
|
126
|
+
*
|
|
127
|
+
* Failure paths preserve the underlying `SingleResult` whenever possible so
|
|
128
|
+
* the caller can still surface the subagent's output; only isolation setup
|
|
129
|
+
* itself routes through {@link IsolatedRunOptions.buildFailureResult}.
|
|
130
|
+
*
|
|
131
|
+
* The isolation handle is always torn down in `finally`.
|
|
132
|
+
*/
|
|
133
|
+
export async function runIsolatedSubprocess(opts: IsolatedRunOptions): Promise<SingleResult> {
|
|
134
|
+
let handle: IsolationHandle | undefined;
|
|
135
|
+
try {
|
|
136
|
+
const taskBaseline = structuredClone(opts.context.baseline);
|
|
137
|
+
handle = await ensureIsolation(opts.context.repoRoot, opts.agentId, opts.preferredBackend);
|
|
138
|
+
const isolationDir = handle.mergedDir;
|
|
139
|
+
const result = await runSubprocess({
|
|
140
|
+
...opts.baseOptions,
|
|
141
|
+
worktree: isolationDir,
|
|
142
|
+
preloadedExtensionPaths: undefined,
|
|
143
|
+
preloadedCustomToolPaths: undefined,
|
|
144
|
+
});
|
|
145
|
+
if (opts.mergeMode === "branch" && result.exitCode === 0) {
|
|
146
|
+
try {
|
|
147
|
+
const commitResult = await commitToBranch(
|
|
148
|
+
isolationDir,
|
|
149
|
+
taskBaseline,
|
|
150
|
+
opts.agentId,
|
|
151
|
+
opts.description,
|
|
152
|
+
opts.buildCommitMessage?.(),
|
|
153
|
+
);
|
|
154
|
+
return {
|
|
155
|
+
...result,
|
|
156
|
+
branchName: commitResult?.branchName,
|
|
157
|
+
nestedPatches: commitResult?.nestedPatches,
|
|
158
|
+
};
|
|
159
|
+
} catch (mergeErr) {
|
|
160
|
+
// Agent succeeded but branch commit failed — clean up stale branch
|
|
161
|
+
const branchName = `omp/task/${opts.agentId}`;
|
|
162
|
+
await git.branch.tryDelete(opts.context.repoRoot, branchName);
|
|
163
|
+
const msg = mergeErr instanceof Error ? mergeErr.message : String(mergeErr);
|
|
164
|
+
return { ...result, error: `Merge failed: ${msg}` };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (result.exitCode === 0) {
|
|
168
|
+
try {
|
|
169
|
+
const delta = await captureDeltaPatch(isolationDir, taskBaseline);
|
|
170
|
+
const patchPath = path.join(opts.artifactsDir, `${opts.agentId}.patch`);
|
|
171
|
+
await Bun.write(patchPath, delta.rootPatch);
|
|
172
|
+
return {
|
|
173
|
+
...result,
|
|
174
|
+
patchPath,
|
|
175
|
+
nestedPatches: delta.nestedPatches,
|
|
176
|
+
};
|
|
177
|
+
} catch (patchErr) {
|
|
178
|
+
const msg = patchErr instanceof Error ? patchErr.message : String(patchErr);
|
|
179
|
+
return { ...result, error: `Patch capture failed: ${msg}` };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return result;
|
|
183
|
+
} catch (err) {
|
|
184
|
+
return opts.buildFailureResult(err);
|
|
185
|
+
} finally {
|
|
186
|
+
if (handle) {
|
|
187
|
+
await cleanupIsolation(handle);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface IsolationMergeOptions {
|
|
193
|
+
result: SingleResult;
|
|
194
|
+
repoRoot: string;
|
|
195
|
+
mergeMode: "patch" | "branch";
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface IsolationMergeOutcome {
|
|
199
|
+
/** Trailing summary appended to the subagent's result text. May be empty. */
|
|
200
|
+
summary: string;
|
|
201
|
+
/**
|
|
202
|
+
* Tri-state apply outcome:
|
|
203
|
+
* - `true` — merge ran (or had nothing to apply) and left the repo clean.
|
|
204
|
+
* - `false` — merge attempted and failed; artifacts are preserved.
|
|
205
|
+
* - `null` — caller skipped the merge phase entirely (e.g. `apply=false`).
|
|
206
|
+
*/
|
|
207
|
+
changesApplied: boolean | null;
|
|
208
|
+
hadAnyChanges: boolean;
|
|
209
|
+
/** True iff the root branch actually merged — gates nested-repo patch application. */
|
|
210
|
+
mergedBranchForNestedPatches: boolean;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Apply changes captured by {@link runIsolatedSubprocess} back to the parent
|
|
215
|
+
* repo: patch apply (patch mode) or cherry-pick + cleanup (branch mode).
|
|
216
|
+
*
|
|
217
|
+
* The caller decides whether to run this at all — eval `agent()` with
|
|
218
|
+
* `apply=False` skips this step and surfaces the patch artifact / branch name
|
|
219
|
+
* instead.
|
|
220
|
+
*/
|
|
221
|
+
export async function mergeIsolatedChanges(opts: IsolationMergeOptions): Promise<IsolationMergeOutcome> {
|
|
222
|
+
const { result, repoRoot, mergeMode } = opts;
|
|
223
|
+
try {
|
|
224
|
+
if (mergeMode === "branch") {
|
|
225
|
+
const canApplyNestedOnly =
|
|
226
|
+
!result.branchName && result.exitCode === 0 && !result.aborted && (result.nestedPatches?.length ?? 0) > 0;
|
|
227
|
+
if (!result.branchName || result.exitCode !== 0 || result.aborted) {
|
|
228
|
+
return {
|
|
229
|
+
summary: canApplyNestedOnly
|
|
230
|
+
? "\n\nNo root changes to apply; nested repository patches captured."
|
|
231
|
+
: "\n\nNo changes to apply.",
|
|
232
|
+
changesApplied: true,
|
|
233
|
+
hadAnyChanges: canApplyNestedOnly,
|
|
234
|
+
mergedBranchForNestedPatches: canApplyNestedOnly,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
const mergeResult = await mergeTaskBranches(repoRoot, [
|
|
238
|
+
{ branchName: result.branchName, taskId: result.id, description: result.description },
|
|
239
|
+
]);
|
|
240
|
+
const mergedBranchForNestedPatches = mergeResult.merged.includes(result.branchName);
|
|
241
|
+
const changesApplied = mergeResult.failed.length === 0;
|
|
242
|
+
const hadAnyChanges = changesApplied && mergeResult.merged.length > 0;
|
|
243
|
+
|
|
244
|
+
let summary: string;
|
|
245
|
+
if (changesApplied) {
|
|
246
|
+
summary = hadAnyChanges ? `\n\nMerged branch: ${result.branchName}` : "\n\nNo changes to apply.";
|
|
247
|
+
} else {
|
|
248
|
+
const conflictPart = mergeResult.conflict ? `\nConflict: ${mergeResult.conflict}` : "";
|
|
249
|
+
summary = `\n\n<system-notification>Branch merge failed: ${result.branchName}.${conflictPart}\nThe unmerged branch remains for manual resolution.</system-notification>`;
|
|
250
|
+
}
|
|
251
|
+
if (mergeResult.stashConflict) {
|
|
252
|
+
summary += `\n\n<system-notification>${mergeResult.stashConflict}</system-notification>`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Clean up the merged branch (keep failed ones for manual resolution)
|
|
256
|
+
if (changesApplied) {
|
|
257
|
+
await cleanupTaskBranches(repoRoot, [result.branchName]);
|
|
258
|
+
}
|
|
259
|
+
return { summary, changesApplied, hadAnyChanges, mergedBranchForNestedPatches };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Patch mode: apply the patch from a successful run. A failed or
|
|
263
|
+
// aborted run has nothing to apply and must not block the result.
|
|
264
|
+
let changesApplied: boolean;
|
|
265
|
+
let hadAnyChanges: boolean;
|
|
266
|
+
const succeeded = result.exitCode === 0 && !result.error && !result.aborted;
|
|
267
|
+
if (!succeeded) {
|
|
268
|
+
changesApplied = true;
|
|
269
|
+
hadAnyChanges = false;
|
|
270
|
+
} else if (!result.patchPath) {
|
|
271
|
+
changesApplied = false;
|
|
272
|
+
hadAnyChanges = false;
|
|
273
|
+
} else {
|
|
274
|
+
const patchText = await Bun.file(result.patchPath).text();
|
|
275
|
+
if (!patchText.trim()) {
|
|
276
|
+
changesApplied = true;
|
|
277
|
+
hadAnyChanges = false;
|
|
278
|
+
} else {
|
|
279
|
+
const normalized = patchText.endsWith("\n") ? patchText : `${patchText}\n`;
|
|
280
|
+
changesApplied = await git.patch.canApplyText(repoRoot, normalized);
|
|
281
|
+
hadAnyChanges = false;
|
|
282
|
+
if (changesApplied) {
|
|
283
|
+
try {
|
|
284
|
+
await git.patch.applyText(repoRoot, normalized);
|
|
285
|
+
hadAnyChanges = true;
|
|
286
|
+
} catch {
|
|
287
|
+
changesApplied = false;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
let summary: string;
|
|
294
|
+
if (changesApplied) {
|
|
295
|
+
summary = hadAnyChanges ? "\n\nApplied patches: yes" : "\n\nNo changes to apply.";
|
|
296
|
+
} else {
|
|
297
|
+
const notification =
|
|
298
|
+
"<system-notification>Patches were not applied and must be handled manually.</system-notification>";
|
|
299
|
+
const patchList = result.patchPath ? `\n\nPatch artifact:\n- ${result.patchPath}` : "";
|
|
300
|
+
summary = `\n\n${notification}${patchList}`;
|
|
301
|
+
}
|
|
302
|
+
return { summary, changesApplied, hadAnyChanges, mergedBranchForNestedPatches: false };
|
|
303
|
+
} catch (mergeErr) {
|
|
304
|
+
const msg = mergeErr instanceof Error ? mergeErr.message : String(mergeErr);
|
|
305
|
+
return {
|
|
306
|
+
summary: `\n\n<system-notification>Merge phase failed: ${msg}\nTask outputs are preserved but changes were not applied.</system-notification>`,
|
|
307
|
+
changesApplied: false,
|
|
308
|
+
hadAnyChanges: false,
|
|
309
|
+
mergedBranchForNestedPatches: false,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export interface NestedPatchApplyOptions {
|
|
315
|
+
/** Subagent result carrying `nestedPatches`/`exitCode`/`aborted`. */
|
|
316
|
+
result: SingleResult;
|
|
317
|
+
repoRoot: string;
|
|
318
|
+
mergeMode: "patch" | "branch";
|
|
319
|
+
/** Parent merge outcome — patch mode skips nested apply when this is `false`. */
|
|
320
|
+
changesApplied: boolean | null;
|
|
321
|
+
/** Branch mode gates nested apply on whether the root branch merged. */
|
|
322
|
+
mergedBranchForNestedPatches: boolean;
|
|
323
|
+
/** Optional AI commit-message callback for nested commits; falls back to a generic message. */
|
|
324
|
+
commitMessage?: (diff: string) => Promise<string | null>;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Apply nested-repo patches after the parent merge phase. Centralizes the
|
|
329
|
+
* three-way gate (exitCode/aborted, patch-mode failed parent, branch-mode
|
|
330
|
+
* branch-merged) and the non-fatal failure handling so `TaskTool` and the
|
|
331
|
+
* eval `agent()` bridge use one implementation.
|
|
332
|
+
*
|
|
333
|
+
* Returns a system-notification suffix to append to the parent merge summary,
|
|
334
|
+
* or an empty string when nothing was applied or the nested apply succeeded.
|
|
335
|
+
*/
|
|
336
|
+
export async function applyEligibleNestedPatches(opts: NestedPatchApplyOptions): Promise<string> {
|
|
337
|
+
const { result, repoRoot, mergeMode, changesApplied, mergedBranchForNestedPatches, commitMessage } = opts;
|
|
338
|
+
if (mergeMode === "patch" && changesApplied === false) return "";
|
|
339
|
+
const nestedPatches = result.nestedPatches ?? [];
|
|
340
|
+
const eligible =
|
|
341
|
+
nestedPatches.length > 0 &&
|
|
342
|
+
result.exitCode === 0 &&
|
|
343
|
+
!result.aborted &&
|
|
344
|
+
(mergeMode !== "branch" || mergedBranchForNestedPatches);
|
|
345
|
+
if (!eligible) return "";
|
|
346
|
+
try {
|
|
347
|
+
const warnings = await applyNestedPatches(repoRoot, nestedPatches, commitMessage);
|
|
348
|
+
if (warnings.length === 0) return "";
|
|
349
|
+
return `\n\n<system-notification>${warnings.join("\n")}</system-notification>`;
|
|
350
|
+
} catch {
|
|
351
|
+
// Nested patch failures are non-fatal to the parent merge.
|
|
352
|
+
return "\n\n<system-notification>Some nested repository patches failed to apply.</system-notification>";
|
|
353
|
+
}
|
|
354
|
+
}
|