@oh-my-pi/pi-coding-agent 16.2.5 → 16.2.7
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 +41 -1
- package/dist/cli.js +3089 -3104
- package/dist/types/cli/bench-cli.d.ts +6 -1
- package/dist/types/commands/bench.d.ts +8 -0
- package/dist/types/config/model-discovery.d.ts +6 -1
- package/dist/types/config/service-tier.d.ts +39 -24
- package/dist/types/config/settings-schema.d.ts +37 -37
- package/dist/types/edit/index.d.ts +1 -0
- package/dist/types/edit/renderer.d.ts +4 -0
- package/dist/types/edit/snapshot-details.d.ts +33 -0
- package/dist/types/mcp/config-writer.d.ts +48 -0
- package/dist/types/mcp/oauth-flow.d.ts +4 -6
- package/dist/types/mcp/types.d.ts +3 -0
- package/dist/types/modes/controllers/tool-args-reveal.d.ts +9 -2
- package/dist/types/session/agent-session.d.ts +37 -13
- package/dist/types/session/messages.d.ts +1 -1
- package/dist/types/session/session-context.d.ts +2 -2
- package/dist/types/session/session-entries.d.ts +2 -2
- package/dist/types/session/session-manager.d.ts +5 -6
- package/dist/types/system-prompt.test.d.ts +1 -0
- package/dist/types/task/executor.d.ts +6 -6
- package/dist/types/task/types.d.ts +6 -0
- package/dist/types/task/worktree.d.ts +32 -6
- package/dist/types/tiny/title-client.d.ts +5 -1
- package/dist/types/tools/index.d.ts +3 -3
- package/dist/types/utils/git.d.ts +17 -0
- package/dist/types/web/search/providers/duckduckgo.d.ts +2 -2
- package/dist/types/web/search/types.d.ts +1 -1
- package/package.json +12 -12
- package/src/cli/args.ts +32 -1
- package/src/cli/bench-cli.ts +97 -22
- package/src/cli/tiny-models-cli.ts +18 -4
- package/src/cli/web-search-cli.ts +6 -1
- package/src/commands/bench.ts +10 -1
- package/src/config/mcp-schema.json +11 -2
- package/src/config/model-discovery.ts +66 -8
- package/src/config/model-registry.ts +13 -6
- package/src/config/service-tier.ts +85 -56
- package/src/config/settings-schema.ts +42 -36
- package/src/config/settings.ts +47 -0
- package/src/edit/hashline/execute.ts +15 -9
- package/src/edit/index.ts +19 -6
- package/src/edit/modes/patch.ts +3 -2
- package/src/edit/modes/replace.ts +3 -2
- package/src/edit/renderer.ts +4 -0
- package/src/edit/snapshot-details.ts +77 -0
- package/src/eval/agent-bridge.ts +4 -2
- package/src/extensibility/plugins/legacy-pi-compat.ts +2 -2
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/main.ts +1 -1
- package/src/mcp/config-writer.ts +121 -0
- package/src/mcp/config.ts +10 -6
- package/src/mcp/oauth-flow.ts +10 -8
- package/src/mcp/transports/stdio.ts +9 -17
- package/src/mcp/types.ts +3 -0
- package/src/modes/components/extensions/extension-dashboard.ts +46 -0
- package/src/modes/components/extensions/state-manager.ts +24 -3
- package/src/modes/controllers/event-controller.ts +24 -8
- package/src/modes/controllers/tool-args-reveal.ts +100 -22
- package/src/modes/utils/transcript-render-helpers.ts +2 -2
- package/src/prompts/bench.md +4 -10
- package/src/prompts/tools/irc.md +19 -29
- package/src/prompts/tools/job.md +8 -14
- package/src/prompts/tools/lsp.md +19 -30
- package/src/prompts/tools/task.md +42 -62
- package/src/sdk.ts +13 -11
- package/src/session/agent-session.ts +196 -76
- package/src/session/messages.ts +1 -1
- package/src/session/session-context.ts +4 -4
- package/src/session/session-entries.ts +2 -2
- package/src/session/session-listing.ts +9 -8
- package/src/session/session-loader.ts +98 -3
- package/src/session/session-manager.ts +43 -6
- package/src/slash-commands/builtin-registry.ts +2 -10
- package/src/subprocess/worker-client.ts +12 -4
- package/src/system-prompt.test.ts +158 -0
- package/src/system-prompt.ts +69 -26
- package/src/task/executor.ts +23 -16
- package/src/task/index.ts +7 -5
- package/src/task/isolation-runner.ts +15 -1
- package/src/task/types.ts +6 -0
- package/src/task/worktree.ts +219 -38
- package/src/tiny/title-client.ts +19 -13
- package/src/tools/index.ts +3 -3
- package/src/tools/irc.ts +9 -3
- package/src/tools/path-utils.ts +4 -2
- package/src/tools/read.ts +28 -28
- package/src/utils/file-mentions.ts +10 -1
- package/src/utils/git.ts +38 -0
- package/src/web/search/index.ts +14 -8
- package/src/web/search/providers/duckduckgo.ts +150 -78
- package/src/web/search/providers/gemini.ts +268 -185
- package/src/web/search/types.ts +1 -1
package/src/task/executor.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import type { AgentEvent, AgentIdentity, AgentTelemetryConfig, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
|
9
9
|
import { recordHandoff, resolveTelemetry } from "@oh-my-pi/pi-agent-core";
|
|
10
|
-
import type { Api, Model,
|
|
10
|
+
import type { Api, Model, ServiceTierByFamily, Usage } from "@oh-my-pi/pi-ai";
|
|
11
11
|
import { logger, popLoopPhase, prompt, pushLoopPhase, untilAborted } from "@oh-my-pi/pi-utils";
|
|
12
12
|
import type { Rule } from "../capability/rule";
|
|
13
13
|
import { ModelRegistry } from "../config/model-registry";
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
resolveModelOverrideWithAuthFallback,
|
|
19
19
|
} from "../config/model-resolver";
|
|
20
20
|
import type { PromptTemplate } from "../config/prompt-templates";
|
|
21
|
-
import { resolveSubagentServiceTier } from "../config/service-tier";
|
|
21
|
+
import { buildServiceTierByFamily, resolveSubagentServiceTier } from "../config/service-tier";
|
|
22
22
|
import { Settings } from "../config/settings";
|
|
23
23
|
import { SETTINGS_SCHEMA, type SettingPath } from "../config/settings-schema";
|
|
24
24
|
import type { ToolPathWithSource } from "../extensibility/custom-tools";
|
|
@@ -344,12 +344,12 @@ export interface ExecutorOptions {
|
|
|
344
344
|
modelRegistry?: ModelRegistry;
|
|
345
345
|
settings?: Settings;
|
|
346
346
|
/**
|
|
347
|
-
* Parent session's live
|
|
348
|
-
* subagent whose `
|
|
347
|
+
* Parent session's live per-family service tiers, the source of truth for a
|
|
348
|
+
* subagent whose `tier.subagent` is `"inherit"`. `null` = the parent
|
|
349
349
|
* explicitly has no tier (e.g. `/fast off`); omitted = no live session, so
|
|
350
|
-
* inherit falls back to the configured `
|
|
350
|
+
* inherit falls back to the subagent's configured `tier.*` settings.
|
|
351
351
|
*/
|
|
352
|
-
parentServiceTier?:
|
|
352
|
+
parentServiceTier?: ServiceTierByFamily | null;
|
|
353
353
|
/** Override local:// protocol options so subagent shares parent's local:// root */
|
|
354
354
|
localProtocolOptions?: LocalProtocolOptions;
|
|
355
355
|
/**
|
|
@@ -739,21 +739,28 @@ export function createMCPProxyTools(mcpManager: MCPManager): CustomTool[] {
|
|
|
739
739
|
export function createSubagentSettings(
|
|
740
740
|
baseSettings: Settings,
|
|
741
741
|
overrides?: Partial<Record<SettingPath, unknown>>,
|
|
742
|
-
inheritedServiceTier?:
|
|
742
|
+
inheritedServiceTier?: ServiceTierByFamily | null,
|
|
743
743
|
): Settings {
|
|
744
744
|
const snapshot: Partial<Record<SettingPath, unknown>> = {};
|
|
745
745
|
for (const key of Object.keys(SETTINGS_SCHEMA) as SettingPath[]) {
|
|
746
746
|
snapshot[key] = baseSettings.get(key);
|
|
747
747
|
}
|
|
748
|
-
// Resolve the subagent's
|
|
749
|
-
// match the parent's live
|
|
750
|
-
// configured
|
|
751
|
-
// createAgentSession's
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
748
|
+
// Resolve the subagent's per-family tiers from `tier.subagent` ("inherit" =
|
|
749
|
+
// match the parent's live tiers when a live session supplied them, else the
|
|
750
|
+
// subagent's own configured tier.* settings). The result is stamped back onto
|
|
751
|
+
// the snapshot so createAgentSession's tier.* reads pick it up.
|
|
752
|
+
const inheritedTiers =
|
|
753
|
+
inheritedServiceTier === undefined
|
|
754
|
+
? buildServiceTierByFamily(
|
|
755
|
+
baseSettings.get("tier.openai"),
|
|
756
|
+
baseSettings.get("tier.anthropic"),
|
|
757
|
+
baseSettings.get("tier.google"),
|
|
758
|
+
)
|
|
759
|
+
: (inheritedServiceTier ?? {});
|
|
760
|
+
const subagentTiers = resolveSubagentServiceTier(baseSettings.get("tier.subagent"), inheritedTiers);
|
|
761
|
+
snapshot["tier.openai"] = subagentTiers.openai ?? "none";
|
|
762
|
+
snapshot["tier.anthropic"] = subagentTiers.anthropic ?? "none";
|
|
763
|
+
snapshot["tier.google"] = subagentTiers.google ?? "none";
|
|
757
764
|
return Settings.isolated({
|
|
758
765
|
...snapshot,
|
|
759
766
|
"async.enabled": false,
|
package/src/task/index.ts
CHANGED
|
@@ -1296,11 +1296,13 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
|
|
|
1296
1296
|
parentTelemetry: this.session.getTelemetry?.(),
|
|
1297
1297
|
parentEvalSessionId,
|
|
1298
1298
|
parentAgentId: this.session.getAgentId?.() ?? MAIN_AGENT_ID,
|
|
1299
|
-
// Live source of truth for `
|
|
1300
|
-
//
|
|
1301
|
-
// none, e.g. /fast off); otherwise leave undefined so inherit
|
|
1302
|
-
// back to the configured
|
|
1303
|
-
parentServiceTier: this.session.
|
|
1299
|
+
// Live source of truth for `tier.subagent: inherit`. When the session
|
|
1300
|
+
// exposes a tier accessor, pass the per-family map or null (null =
|
|
1301
|
+
// explicit none, e.g. /fast off); otherwise leave undefined so inherit
|
|
1302
|
+
// falls back to the subagent's configured tier.* settings.
|
|
1303
|
+
parentServiceTier: this.session.getServiceTierByFamily
|
|
1304
|
+
? (this.session.getServiceTierByFamily() ?? null)
|
|
1305
|
+
: undefined,
|
|
1304
1306
|
};
|
|
1305
1307
|
|
|
1306
1308
|
const runTask = async (): Promise<SingleResult> => {
|
|
@@ -154,6 +154,7 @@ export async function runIsolatedSubprocess(opts: IsolatedRunOptions): Promise<S
|
|
|
154
154
|
return {
|
|
155
155
|
...result,
|
|
156
156
|
branchName: commitResult?.branchName,
|
|
157
|
+
branchBaseSha: commitResult?.baseSha,
|
|
157
158
|
nestedPatches: commitResult?.nestedPatches,
|
|
158
159
|
};
|
|
159
160
|
} catch (mergeErr) {
|
|
@@ -222,6 +223,14 @@ export async function mergeIsolatedChanges(opts: IsolationMergeOptions): Promise
|
|
|
222
223
|
const { result, repoRoot, mergeMode } = opts;
|
|
223
224
|
try {
|
|
224
225
|
if (mergeMode === "branch") {
|
|
226
|
+
if (!result.branchName && result.exitCode === 0 && !result.aborted && result.error) {
|
|
227
|
+
return {
|
|
228
|
+
summary: `\n\n<system-notification>Branch merge failed before a task branch could be created: ${result.error}\nTask outputs are preserved but changes were not applied.</system-notification>`,
|
|
229
|
+
changesApplied: false,
|
|
230
|
+
hadAnyChanges: false,
|
|
231
|
+
mergedBranchForNestedPatches: false,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
225
234
|
const canApplyNestedOnly =
|
|
226
235
|
!result.branchName && result.exitCode === 0 && !result.aborted && (result.nestedPatches?.length ?? 0) > 0;
|
|
227
236
|
if (!result.branchName || result.exitCode !== 0 || result.aborted) {
|
|
@@ -235,7 +244,12 @@ export async function mergeIsolatedChanges(opts: IsolationMergeOptions): Promise
|
|
|
235
244
|
};
|
|
236
245
|
}
|
|
237
246
|
const mergeResult = await mergeTaskBranches(repoRoot, [
|
|
238
|
-
{
|
|
247
|
+
{
|
|
248
|
+
branchName: result.branchName,
|
|
249
|
+
taskId: result.id,
|
|
250
|
+
description: result.description,
|
|
251
|
+
baseSha: result.branchBaseSha,
|
|
252
|
+
},
|
|
239
253
|
]);
|
|
240
254
|
const mergedBranchForNestedPatches = mergeResult.merged.includes(result.branchName);
|
|
241
255
|
const changesApplied = mergeResult.failed.length === 0;
|
package/src/task/types.ts
CHANGED
|
@@ -383,6 +383,12 @@ export interface SingleResult {
|
|
|
383
383
|
patchPath?: string;
|
|
384
384
|
/** Branch name for isolated branch-mode output */
|
|
385
385
|
branchName?: string;
|
|
386
|
+
/**
|
|
387
|
+
* Baseline commit SHA the task branch was created from. Passed to
|
|
388
|
+
* `mergeTaskBranches` so cherry-pick uses the inclusive range
|
|
389
|
+
* `branchBaseSha..branchName` and preserves every agent commit's message.
|
|
390
|
+
*/
|
|
391
|
+
branchBaseSha?: string;
|
|
386
392
|
/** Nested repo patches to apply after parent merge */
|
|
387
393
|
nestedPatches?: NestedRepoPatch[];
|
|
388
394
|
/** Data extracted by registered subprocess tool handlers (keyed by tool name) */
|
package/src/task/worktree.ts
CHANGED
|
@@ -148,21 +148,33 @@ export async function captureBaseline(repoRoot: string): Promise<WorktreeBaselin
|
|
|
148
148
|
return { root, nested };
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
-
async function captureRepoDeltaPatch(repoDir: string, rb: RepoBaseline): Promise<string> {
|
|
151
|
+
async function captureRepoDeltaPatch(repoDir: string, rb: RepoBaseline, objectRepoDir = repoDir): Promise<string> {
|
|
152
152
|
const currentHead = (await git.head.sha(repoDir)) ?? "";
|
|
153
153
|
const currentStaged = await git.diff(repoDir, { binary: true, cached: true });
|
|
154
154
|
const currentUnstaged = await git.diff(repoDir, { binary: true });
|
|
155
155
|
const currentUntracked = await git.ls.untracked(repoDir);
|
|
156
156
|
const currentUntrackedPatch = await captureUntrackedPatch(repoDir, currentUntracked);
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
157
|
+
const committedPatch =
|
|
158
|
+
currentHead && currentHead !== rb.headCommit
|
|
159
|
+
? await git.diff.tree(repoDir, rb.headCommit, currentHead, {
|
|
160
|
+
allowFailure: true,
|
|
161
|
+
binary: true,
|
|
162
|
+
})
|
|
163
|
+
: "";
|
|
164
|
+
|
|
165
|
+
const baselineTree = await writeSyntheticTree(objectRepoDir, rb.headCommit, [
|
|
166
|
+
rb.staged,
|
|
167
|
+
rb.unstaged,
|
|
168
|
+
rb.untrackedPatch,
|
|
169
|
+
]);
|
|
170
|
+
const currentTree = await writeSyntheticTree(objectRepoDir, rb.headCommit, [
|
|
171
|
+
committedPatch,
|
|
160
172
|
currentStaged,
|
|
161
173
|
currentUnstaged,
|
|
162
174
|
currentUntrackedPatch,
|
|
163
175
|
]);
|
|
164
176
|
|
|
165
|
-
return git.diff.tree(
|
|
177
|
+
return git.diff.tree(objectRepoDir, baselineTree, currentTree, {
|
|
166
178
|
allowFailure: true,
|
|
167
179
|
binary: true,
|
|
168
180
|
});
|
|
@@ -212,7 +224,7 @@ export interface DeltaPatchResult {
|
|
|
212
224
|
}
|
|
213
225
|
|
|
214
226
|
export async function captureDeltaPatch(isolationDir: string, baseline: WorktreeBaseline): Promise<DeltaPatchResult> {
|
|
215
|
-
const rootPatch = await captureRepoDeltaPatch(isolationDir, baseline.root);
|
|
227
|
+
const rootPatch = await captureRepoDeltaPatch(isolationDir, baseline.root, baseline.root.repoRoot);
|
|
216
228
|
const nestedPatches: NestedRepoPatch[] = [];
|
|
217
229
|
|
|
218
230
|
for (const { relativePath, baseline: nb } of baseline.nested) {
|
|
@@ -222,7 +234,7 @@ export async function captureDeltaPatch(isolationDir: string, baseline: Worktree
|
|
|
222
234
|
} catch {
|
|
223
235
|
continue;
|
|
224
236
|
}
|
|
225
|
-
const patch = await captureRepoDeltaPatch(nestedDir, nb);
|
|
237
|
+
const patch = await captureRepoDeltaPatch(nestedDir, nb, nb.repoRoot);
|
|
226
238
|
if (patch.trim()) nestedPatches.push({ relativePath, patch });
|
|
227
239
|
}
|
|
228
240
|
|
|
@@ -460,12 +472,137 @@ export async function cleanupIsolation(handle: IsolationHandle): Promise<void> {
|
|
|
460
472
|
export interface CommitToBranchResult {
|
|
461
473
|
branchName?: string;
|
|
462
474
|
nestedPatches: NestedRepoPatch[];
|
|
475
|
+
/**
|
|
476
|
+
* SHA of the parent-repo commit the task branch was created on top of, so
|
|
477
|
+
* {@link mergeTaskBranches} can cherry-pick the range `baseSha..branchName`
|
|
478
|
+
* and preserve every agent commit's message and author.
|
|
479
|
+
*/
|
|
480
|
+
baseSha?: string;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function baselineHasRootWip(baseline: RepoBaseline): boolean {
|
|
484
|
+
return !!(baseline.staged.trim() || baseline.unstaged.trim() || baseline.untrackedPatch.trim());
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async function commitPatchToBranchWorktree(
|
|
488
|
+
tmpDir: string,
|
|
489
|
+
taskId: string,
|
|
490
|
+
patchText: string,
|
|
491
|
+
message: string,
|
|
492
|
+
author?: git.CommitAuthor,
|
|
493
|
+
): Promise<void> {
|
|
494
|
+
try {
|
|
495
|
+
await git.patch.applyText(tmpDir, patchText);
|
|
496
|
+
} catch (err) {
|
|
497
|
+
if (!(err instanceof git.GitCommandError)) throw err;
|
|
498
|
+
// Plain apply rejects when the parent checkout carries unrelated dirty
|
|
499
|
+
// context near the task's edits; retry with --3way before giving up.
|
|
500
|
+
try {
|
|
501
|
+
await git.patch.applyText(tmpDir, patchText, { threeWay: true });
|
|
502
|
+
} catch (threeWayErr) {
|
|
503
|
+
if (threeWayErr instanceof git.GitCommandError) {
|
|
504
|
+
const stderr = threeWayErr.result.stderr.slice(0, 2000);
|
|
505
|
+
logger.error("commitToBranch: git apply --3way failed", {
|
|
506
|
+
taskId,
|
|
507
|
+
exitCode: threeWayErr.result.exitCode,
|
|
508
|
+
stderr,
|
|
509
|
+
initialStderr: err.result.stderr.slice(0, 2000),
|
|
510
|
+
patchSize: patchText.length,
|
|
511
|
+
patchHead: patchText.slice(0, 500),
|
|
512
|
+
});
|
|
513
|
+
throw new Error(`git apply --3way failed for task ${taskId}: ${stderr}`);
|
|
514
|
+
}
|
|
515
|
+
throw threeWayErr;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
await git.stage.files(tmpDir);
|
|
519
|
+
await git.commit(tmpDir, message, author ? { author } : {});
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
interface FilteredAgentReplayOptions {
|
|
523
|
+
baseline: WorktreeBaseline;
|
|
524
|
+
branchName: string;
|
|
525
|
+
commitMessage?: (diff: string) => Promise<string | null>;
|
|
526
|
+
fallbackMessage: string;
|
|
527
|
+
isolationDir: string;
|
|
528
|
+
isolationHead: string;
|
|
529
|
+
repoRoot: string;
|
|
530
|
+
rootPatch: string;
|
|
531
|
+
taskId: string;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
async function replayFilteredAgentCommits(opts: FilteredAgentReplayOptions): Promise<void> {
|
|
535
|
+
const baselineSha = opts.baseline.root.headCommit;
|
|
536
|
+
await git.branch.create(opts.repoRoot, opts.branchName, baselineSha);
|
|
537
|
+
|
|
538
|
+
const tmpDir = path.join(os.tmpdir(), `omp-branch-${Snowflake.next()}`);
|
|
539
|
+
try {
|
|
540
|
+
await git.worktree.add(opts.repoRoot, tmpDir, opts.branchName);
|
|
541
|
+
const agentCommits = await git.revList.range(opts.isolationDir, baselineSha, opts.isolationHead);
|
|
542
|
+
const dirtyBaselineTree = await writeSyntheticTree(opts.isolationDir, baselineSha, [
|
|
543
|
+
opts.baseline.root.staged,
|
|
544
|
+
opts.baseline.root.unstaged,
|
|
545
|
+
opts.baseline.root.untrackedPatch,
|
|
546
|
+
]);
|
|
547
|
+
let previousFilteredTree = baselineSha;
|
|
548
|
+
|
|
549
|
+
for (const commitSha of agentCommits) {
|
|
550
|
+
const taskStatePatch = await git.diff.tree(opts.isolationDir, dirtyBaselineTree, `${commitSha}^{tree}`, {
|
|
551
|
+
allowFailure: true,
|
|
552
|
+
binary: true,
|
|
553
|
+
});
|
|
554
|
+
const currentFilteredTree = await writeSyntheticTree(opts.repoRoot, baselineSha, [taskStatePatch]);
|
|
555
|
+
const commitPatch = await git.diff.tree(opts.repoRoot, previousFilteredTree, currentFilteredTree, {
|
|
556
|
+
allowFailure: true,
|
|
557
|
+
binary: true,
|
|
558
|
+
});
|
|
559
|
+
if (commitPatch.trim()) {
|
|
560
|
+
const details = await git.commitDetails(opts.isolationDir, commitSha);
|
|
561
|
+
await commitPatchToBranchWorktree(
|
|
562
|
+
tmpDir,
|
|
563
|
+
opts.taskId,
|
|
564
|
+
commitPatch,
|
|
565
|
+
details.message || commitSha,
|
|
566
|
+
details.author,
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
previousFilteredTree = currentFilteredTree;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const finalFilteredTree = await writeSyntheticTree(opts.repoRoot, baselineSha, [opts.rootPatch]);
|
|
573
|
+
const leftoverPatch = await git.diff.tree(opts.repoRoot, previousFilteredTree, finalFilteredTree, {
|
|
574
|
+
allowFailure: true,
|
|
575
|
+
binary: true,
|
|
576
|
+
});
|
|
577
|
+
if (leftoverPatch.trim()) {
|
|
578
|
+
const msg = (opts.commitMessage && (await opts.commitMessage(leftoverPatch))) || opts.fallbackMessage;
|
|
579
|
+
await commitPatchToBranchWorktree(tmpDir, opts.taskId, leftoverPatch, msg);
|
|
580
|
+
}
|
|
581
|
+
} finally {
|
|
582
|
+
await git.worktree.tryRemove(opts.repoRoot, tmpDir);
|
|
583
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
584
|
+
}
|
|
463
585
|
}
|
|
464
586
|
|
|
465
587
|
/**
|
|
466
|
-
*
|
|
467
|
-
* Only root
|
|
468
|
-
* separately
|
|
588
|
+
* Capture task-only changes from the isolation worktree onto a parent-repo
|
|
589
|
+
* branch named `omp/task/${taskId}`. Only root-repo changes go on the branch;
|
|
590
|
+
* nested-repo patches are returned separately because the parent git can't
|
|
591
|
+
* track files inside gitlinks.
|
|
592
|
+
*
|
|
593
|
+
* If the agent committed inside isolation (HEAD moved past
|
|
594
|
+
* `baseline.root.headCommit`), clean-baseline runs fetch the raw commit range
|
|
595
|
+
* into the parent repo and later cherry-pick `baseSha..branchName`, preserving
|
|
596
|
+
* every message and author verbatim. Dirty-baseline runs rewrite each agent
|
|
597
|
+
* commit against the captured baseline WIP before committing it to the task
|
|
598
|
+
* branch, so user staged/unstaged/untracked changes present at isolation
|
|
599
|
+
* start are not replayed into the parent commit history.
|
|
600
|
+
*
|
|
601
|
+
* If the agent did not commit, the captured delta is collapsed onto a single
|
|
602
|
+
* branch commit with an AI-generated (or fallback) message — the legacy
|
|
603
|
+
* behaviour.
|
|
604
|
+
*
|
|
605
|
+
* Returns `null` when no root or nested changes exist.
|
|
469
606
|
*/
|
|
470
607
|
export async function commitToBranch(
|
|
471
608
|
isolationDir: string,
|
|
@@ -474,45 +611,84 @@ export async function commitToBranch(
|
|
|
474
611
|
description: string | undefined,
|
|
475
612
|
commitMessage?: (diff: string) => Promise<string | null>,
|
|
476
613
|
): Promise<CommitToBranchResult | null> {
|
|
614
|
+
const baselineSha = baseline.root.headCommit;
|
|
615
|
+
const isolationHead = (await git.head.sha(isolationDir)) ?? "";
|
|
616
|
+
const agentCommitted = isolationHead !== "" && isolationHead !== baselineSha;
|
|
617
|
+
|
|
477
618
|
const { rootPatch, nestedPatches } = await captureDeltaPatch(isolationDir, baseline);
|
|
478
619
|
if (!rootPatch.trim() && nestedPatches.length === 0) return null;
|
|
620
|
+
if (!rootPatch.trim()) return { nestedPatches };
|
|
479
621
|
|
|
480
622
|
const repoRoot = baseline.root.repoRoot;
|
|
481
623
|
const branchName = `omp/task/${taskId}`;
|
|
482
624
|
const fallbackMessage = description || taskId;
|
|
483
625
|
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
626
|
+
let branchCreated = false;
|
|
627
|
+
|
|
628
|
+
if (agentCommitted) {
|
|
629
|
+
if (baselineHasRootWip(baseline.root)) {
|
|
630
|
+
await replayFilteredAgentCommits({
|
|
631
|
+
baseline,
|
|
632
|
+
branchName,
|
|
633
|
+
commitMessage,
|
|
634
|
+
fallbackMessage,
|
|
635
|
+
isolationDir,
|
|
636
|
+
isolationHead,
|
|
637
|
+
repoRoot,
|
|
638
|
+
rootPatch,
|
|
639
|
+
taskId,
|
|
640
|
+
});
|
|
641
|
+
} else {
|
|
642
|
+
// Transfer the agent's commit objects (which live in isolation's `.git`,
|
|
643
|
+
// stranded once `cleanupIsolation` tears the overlay down) into the parent
|
|
644
|
+
// repo's object DB and create the branch at the agent's HEAD. `+HEAD:…`
|
|
645
|
+
// force-overwrites a stale branch from a prior run.
|
|
646
|
+
await git.fetch(repoRoot, isolationDir, "HEAD", `refs/heads/${branchName}`);
|
|
647
|
+
|
|
648
|
+
// Leftover = anything still uncommitted in isolation on top of the
|
|
649
|
+
// agent's last commit (staged, unstaged, untracked). The agent didn't
|
|
650
|
+
// commit it, so it goes in as one AI-summarized trailing commit.
|
|
651
|
+
const leftoverPatch = await captureRepoDeltaPatch(isolationDir, {
|
|
652
|
+
repoRoot: isolationDir,
|
|
653
|
+
headCommit: isolationHead,
|
|
654
|
+
staged: "",
|
|
655
|
+
unstaged: "",
|
|
656
|
+
untracked: [],
|
|
657
|
+
untrackedPatch: "",
|
|
658
|
+
});
|
|
659
|
+
if (leftoverPatch.trim()) {
|
|
660
|
+
const tmpDir = path.join(os.tmpdir(), `omp-branch-${Snowflake.next()}`);
|
|
661
|
+
try {
|
|
662
|
+
await git.worktree.add(repoRoot, tmpDir, branchName);
|
|
663
|
+
const msg = (commitMessage && (await commitMessage(leftoverPatch))) || fallbackMessage;
|
|
664
|
+
await commitPatchToBranchWorktree(tmpDir, taskId, leftoverPatch, msg);
|
|
665
|
+
} finally {
|
|
666
|
+
await git.worktree.tryRemove(repoRoot, tmpDir);
|
|
667
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
branchCreated = true;
|
|
672
|
+
} else if (rootPatch.trim()) {
|
|
673
|
+
await git.branch.create(repoRoot, branchName, baselineSha);
|
|
674
|
+
branchCreated = true;
|
|
487
675
|
const tmpDir = path.join(os.tmpdir(), `omp-branch-${Snowflake.next()}`);
|
|
488
676
|
try {
|
|
489
677
|
await git.worktree.add(repoRoot, tmpDir, branchName);
|
|
490
|
-
|
|
491
|
-
await git.patch.applyText(tmpDir, rootPatch);
|
|
492
|
-
} catch (err) {
|
|
493
|
-
if (err instanceof git.GitCommandError) {
|
|
494
|
-
const stderr = err.result.stderr.slice(0, 2000);
|
|
495
|
-
logger.error("commitToBranch: git apply failed", {
|
|
496
|
-
taskId,
|
|
497
|
-
exitCode: err.result.exitCode,
|
|
498
|
-
stderr,
|
|
499
|
-
patchSize: rootPatch.length,
|
|
500
|
-
patchHead: rootPatch.slice(0, 500),
|
|
501
|
-
});
|
|
502
|
-
throw new Error(`git apply failed for task ${taskId}: ${stderr}`);
|
|
503
|
-
}
|
|
504
|
-
throw err;
|
|
505
|
-
}
|
|
506
|
-
await git.stage.files(tmpDir);
|
|
678
|
+
|
|
507
679
|
const msg = (commitMessage && (await commitMessage(rootPatch))) || fallbackMessage;
|
|
508
|
-
await
|
|
680
|
+
await commitPatchToBranchWorktree(tmpDir, taskId, rootPatch, msg);
|
|
509
681
|
} finally {
|
|
510
682
|
await git.worktree.tryRemove(repoRoot, tmpDir);
|
|
511
683
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
512
684
|
}
|
|
513
685
|
}
|
|
514
686
|
|
|
515
|
-
return {
|
|
687
|
+
return {
|
|
688
|
+
branchName: branchCreated ? branchName : undefined,
|
|
689
|
+
baseSha: baselineSha,
|
|
690
|
+
nestedPatches,
|
|
691
|
+
};
|
|
516
692
|
}
|
|
517
693
|
|
|
518
694
|
export interface MergeBranchResult {
|
|
@@ -524,13 +700,17 @@ export interface MergeBranchResult {
|
|
|
524
700
|
}
|
|
525
701
|
|
|
526
702
|
/**
|
|
527
|
-
* Cherry-pick task branch commits sequentially onto HEAD.
|
|
528
|
-
*
|
|
529
|
-
*
|
|
703
|
+
* Cherry-pick task branch commits sequentially onto HEAD. When `baseSha` is
|
|
704
|
+
* provided the cherry-pick uses the inclusive range `baseSha..branchName`,
|
|
705
|
+
* replaying every commit individually and preserving each commit's message
|
|
706
|
+
* and author. When omitted, the branch is cherry-picked as a single commit
|
|
707
|
+
* (legacy callers).
|
|
708
|
+
*
|
|
709
|
+
* Stops on the first conflict and reports which branches succeeded.
|
|
530
710
|
*/
|
|
531
711
|
export async function mergeTaskBranches(
|
|
532
712
|
repoRoot: string,
|
|
533
|
-
branches: Array<{ branchName: string; taskId: string; description?: string }>,
|
|
713
|
+
branches: Array<{ branchName: string; taskId: string; description?: string; baseSha?: string }>,
|
|
534
714
|
): Promise<MergeBranchResult> {
|
|
535
715
|
// Serialize against other in-process git mutations on this repo: concurrent
|
|
536
716
|
// background merges interleaving stash push/pop + cherry-pick would corrupt
|
|
@@ -546,9 +726,10 @@ export async function mergeTaskBranches(
|
|
|
546
726
|
let conflictResult: MergeBranchResult | undefined;
|
|
547
727
|
|
|
548
728
|
try {
|
|
549
|
-
for (const { branchName } of branches) {
|
|
729
|
+
for (const { branchName, baseSha } of branches) {
|
|
550
730
|
try {
|
|
551
|
-
|
|
731
|
+
const target = baseSha ? `${baseSha}..${branchName}` : branchName;
|
|
732
|
+
await git.cherryPick(repoRoot, target);
|
|
552
733
|
} catch (err) {
|
|
553
734
|
try {
|
|
554
735
|
await git.cherryPick.abort(repoRoot);
|
package/src/tiny/title-client.ts
CHANGED
|
@@ -29,7 +29,12 @@ import type { TinyTitleProgressEvent, TinyTitleWorkerInbound, TinyTitleWorkerOut
|
|
|
29
29
|
type PendingRequest =
|
|
30
30
|
| { kind: "generate"; modelKey: TinyTitleLocalModelKey; resolve: (title: string | null) => void }
|
|
31
31
|
| { kind: "complete"; modelKey: TinyMemoryLocalModelKey; resolve: (text: string | null) => void }
|
|
32
|
-
| { kind: "download"; modelKey: TinyLocalModelKey; resolve: (
|
|
32
|
+
| { kind: "download"; modelKey: TinyLocalModelKey; resolve: (result: TinyTitleDownloadResult) => void };
|
|
33
|
+
|
|
34
|
+
export interface TinyTitleDownloadResult {
|
|
35
|
+
ok: boolean;
|
|
36
|
+
error?: string;
|
|
37
|
+
}
|
|
33
38
|
|
|
34
39
|
export interface TinyTitleDownloadOptions {
|
|
35
40
|
signal?: AbortSignal;
|
|
@@ -269,21 +274,21 @@ export class TinyTitleClient {
|
|
|
269
274
|
}
|
|
270
275
|
}
|
|
271
276
|
|
|
272
|
-
async downloadModel(modelKey: string, options: TinyTitleDownloadOptions = {}): Promise<
|
|
273
|
-
if (!isTinyLocalModelKey(modelKey)) return false;
|
|
274
|
-
if (options.signal?.aborted) return false;
|
|
277
|
+
async downloadModel(modelKey: string, options: TinyTitleDownloadOptions = {}): Promise<TinyTitleDownloadResult> {
|
|
278
|
+
if (!isTinyLocalModelKey(modelKey)) return { ok: false };
|
|
279
|
+
if (options.signal?.aborted) return { ok: false };
|
|
275
280
|
|
|
276
281
|
const unsubscribe = options.onProgress ? this.onProgress(options.onProgress) : undefined;
|
|
277
282
|
try {
|
|
278
283
|
const worker = this.#ensureWorker();
|
|
279
284
|
const id = String(++this.#nextRequestId);
|
|
280
|
-
const { promise, resolve } = Promise.withResolvers<
|
|
285
|
+
const { promise, resolve } = Promise.withResolvers<TinyTitleDownloadResult>();
|
|
281
286
|
this.#addPending(id, { kind: "download", modelKey, resolve });
|
|
282
287
|
const abort = (): void => {
|
|
283
288
|
const pending = this.#pending.get(id);
|
|
284
289
|
if (pending?.kind !== "download") return;
|
|
285
290
|
this.#deletePending(id);
|
|
286
|
-
pending.resolve(false);
|
|
291
|
+
pending.resolve({ ok: false });
|
|
287
292
|
};
|
|
288
293
|
options.signal?.addEventListener("abort", abort, { once: true });
|
|
289
294
|
try {
|
|
@@ -294,11 +299,12 @@ export class TinyTitleClient {
|
|
|
294
299
|
this.#deletePending(id);
|
|
295
300
|
}
|
|
296
301
|
} catch (error) {
|
|
302
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
297
303
|
logger.debug("tiny-title: local model download failed", {
|
|
298
304
|
modelKey,
|
|
299
|
-
error:
|
|
305
|
+
error: message,
|
|
300
306
|
});
|
|
301
|
-
return false;
|
|
307
|
+
return { ok: false, error: message };
|
|
302
308
|
} finally {
|
|
303
309
|
unsubscribe?.();
|
|
304
310
|
}
|
|
@@ -314,7 +320,7 @@ export class TinyTitleClient {
|
|
|
314
320
|
for (const pending of this.#pending.values()) {
|
|
315
321
|
this.#emitProgress({ modelKey: pending.modelKey, status: "error" });
|
|
316
322
|
if (pending.kind === "generate" || pending.kind === "complete") pending.resolve(null);
|
|
317
|
-
else pending.resolve(false);
|
|
323
|
+
else pending.resolve({ ok: false });
|
|
318
324
|
}
|
|
319
325
|
this.#pending.clear();
|
|
320
326
|
this.#refed = false;
|
|
@@ -379,7 +385,7 @@ export class TinyTitleClient {
|
|
|
379
385
|
return;
|
|
380
386
|
}
|
|
381
387
|
if (message.type === "downloaded") {
|
|
382
|
-
if (pending.kind === "download") pending.resolve(true);
|
|
388
|
+
if (pending.kind === "download") pending.resolve({ ok: true });
|
|
383
389
|
return;
|
|
384
390
|
}
|
|
385
391
|
if (message.type === "completion") {
|
|
@@ -389,8 +395,8 @@ export class TinyTitleClient {
|
|
|
389
395
|
logger.debug("tiny-title: worker returned error", { error: message.error });
|
|
390
396
|
this.#markFailedModel(pending);
|
|
391
397
|
this.#emitProgress({ modelKey: pending.modelKey, status: "error" });
|
|
392
|
-
if (pending.kind === "
|
|
393
|
-
else pending.resolve(
|
|
398
|
+
if (pending.kind === "download") pending.resolve({ ok: false, error: message.error });
|
|
399
|
+
else pending.resolve(null);
|
|
394
400
|
void this.terminate();
|
|
395
401
|
}
|
|
396
402
|
|
|
@@ -407,7 +413,7 @@ export class TinyTitleClient {
|
|
|
407
413
|
for (const pending of this.#pending.values()) {
|
|
408
414
|
this.#emitProgress({ modelKey: pending.modelKey, status: "error" });
|
|
409
415
|
if (pending.kind === "generate" || pending.kind === "complete") pending.resolve(null);
|
|
410
|
-
else pending.resolve(false);
|
|
416
|
+
else pending.resolve({ ok: false, error: error.message });
|
|
411
417
|
}
|
|
412
418
|
this.#pending.clear();
|
|
413
419
|
void this.terminate();
|
package/src/tools/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { InMemorySnapshotStore } from "@oh-my-pi/hashline";
|
|
2
2
|
import type { AgentTelemetryConfig, AgentTool } from "@oh-my-pi/pi-agent-core";
|
|
3
|
-
import type { FetchImpl, ImageContent, Model,
|
|
3
|
+
import type { FetchImpl, ImageContent, Model, ServiceTierByFamily, ToolChoice } from "@oh-my-pi/pi-ai";
|
|
4
4
|
import { logger } from "@oh-my-pi/pi-utils";
|
|
5
5
|
import type { AsyncJobManager } from "../async/job-manager";
|
|
6
6
|
import type { Rule } from "../capability/rule";
|
|
@@ -240,8 +240,8 @@ export interface ToolSession {
|
|
|
240
240
|
getActiveModelString?: () => string | undefined;
|
|
241
241
|
/** Get the current session model object (provider/api capabilities), regardless of how it was chosen. */
|
|
242
242
|
getActiveModel?: () => Model | undefined;
|
|
243
|
-
/** Get the session's live
|
|
244
|
-
|
|
243
|
+
/** Get the session's live per-family service tiers (undefined = none). Source of truth for subagent `tier.subagent: inherit`. */
|
|
244
|
+
getServiceTierByFamily?: () => ServiceTierByFamily | undefined;
|
|
245
245
|
/** Auth storage for passing to subagents (avoids re-discovery) */
|
|
246
246
|
authStorage?: import("../session/auth-storage").AuthStorage;
|
|
247
247
|
/** Model registry for passing to subagents (avoids re-discovery) */
|
package/src/tools/irc.ts
CHANGED
|
@@ -172,7 +172,7 @@ export class IrcTool implements AgentTool<typeof ircSchema, IrcDetails> {
|
|
|
172
172
|
case "wait":
|
|
173
173
|
return this.#executeWait(senderId, params, signal);
|
|
174
174
|
case "inbox":
|
|
175
|
-
return this.#executeInbox(senderId, params);
|
|
175
|
+
return this.#executeInbox(registry, senderId, params);
|
|
176
176
|
default:
|
|
177
177
|
return errorResult("Unknown irc op.", { op: params.op });
|
|
178
178
|
}
|
|
@@ -371,8 +371,14 @@ export class IrcTool implements AgentTool<typeof ircSchema, IrcDetails> {
|
|
|
371
371
|
};
|
|
372
372
|
}
|
|
373
373
|
|
|
374
|
-
#executeInbox(senderId: string, params: IrcParams): AgentToolResult<IrcDetails> {
|
|
375
|
-
const
|
|
374
|
+
#executeInbox(registry: AgentRegistry, senderId: string, params: IrcParams): AgentToolResult<IrcDetails> {
|
|
375
|
+
const busMessages = IrcBus.global().inbox(senderId, { peek: params.peek });
|
|
376
|
+
const session = registry.get(senderId)?.session;
|
|
377
|
+
const pendingMessages =
|
|
378
|
+
typeof session?.drainPendingIrcInboxMessages === "function"
|
|
379
|
+
? session.drainPendingIrcInboxMessages(senderId)
|
|
380
|
+
: [];
|
|
381
|
+
const messages = [...busMessages, ...pendingMessages].sort((a, b) => a.ts - b.ts);
|
|
376
382
|
if (messages.length === 0) {
|
|
377
383
|
return {
|
|
378
384
|
content: [{ type: "text", text: "Inbox empty." }],
|
package/src/tools/path-utils.ts
CHANGED
|
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import * as url from "node:url";
|
|
5
|
-
import { isEnoent } from "@oh-my-pi/pi-utils";
|
|
5
|
+
import { isEnoent, stripWindowsExtendedLengthPathPrefix } from "@oh-my-pi/pi-utils";
|
|
6
6
|
import type { Skill } from "../extensibility/skills";
|
|
7
7
|
import { InternalUrlRouter, type LocalProtocolOptions } from "../internal-urls";
|
|
8
8
|
import { ToolError } from "./tool-errors";
|
|
@@ -147,7 +147,9 @@ export function expandTilde(filePath: string, home?: string): string {
|
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
export function expandPath(filePath: string): string {
|
|
150
|
-
const normalized =
|
|
150
|
+
const normalized = stripWindowsExtendedLengthPathPrefix(
|
|
151
|
+
stripFileUrl(normalizeUnicodeSpaces(normalizeAtPrefix(filePath))),
|
|
152
|
+
);
|
|
151
153
|
return expandTilde(normalized);
|
|
152
154
|
}
|
|
153
155
|
|