@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.
Files changed (93) hide show
  1. package/CHANGELOG.md +41 -1
  2. package/dist/cli.js +3089 -3104
  3. package/dist/types/cli/bench-cli.d.ts +6 -1
  4. package/dist/types/commands/bench.d.ts +8 -0
  5. package/dist/types/config/model-discovery.d.ts +6 -1
  6. package/dist/types/config/service-tier.d.ts +39 -24
  7. package/dist/types/config/settings-schema.d.ts +37 -37
  8. package/dist/types/edit/index.d.ts +1 -0
  9. package/dist/types/edit/renderer.d.ts +4 -0
  10. package/dist/types/edit/snapshot-details.d.ts +33 -0
  11. package/dist/types/mcp/config-writer.d.ts +48 -0
  12. package/dist/types/mcp/oauth-flow.d.ts +4 -6
  13. package/dist/types/mcp/types.d.ts +3 -0
  14. package/dist/types/modes/controllers/tool-args-reveal.d.ts +9 -2
  15. package/dist/types/session/agent-session.d.ts +37 -13
  16. package/dist/types/session/messages.d.ts +1 -1
  17. package/dist/types/session/session-context.d.ts +2 -2
  18. package/dist/types/session/session-entries.d.ts +2 -2
  19. package/dist/types/session/session-manager.d.ts +5 -6
  20. package/dist/types/system-prompt.test.d.ts +1 -0
  21. package/dist/types/task/executor.d.ts +6 -6
  22. package/dist/types/task/types.d.ts +6 -0
  23. package/dist/types/task/worktree.d.ts +32 -6
  24. package/dist/types/tiny/title-client.d.ts +5 -1
  25. package/dist/types/tools/index.d.ts +3 -3
  26. package/dist/types/utils/git.d.ts +17 -0
  27. package/dist/types/web/search/providers/duckduckgo.d.ts +2 -2
  28. package/dist/types/web/search/types.d.ts +1 -1
  29. package/package.json +12 -12
  30. package/src/cli/args.ts +32 -1
  31. package/src/cli/bench-cli.ts +97 -22
  32. package/src/cli/tiny-models-cli.ts +18 -4
  33. package/src/cli/web-search-cli.ts +6 -1
  34. package/src/commands/bench.ts +10 -1
  35. package/src/config/mcp-schema.json +11 -2
  36. package/src/config/model-discovery.ts +66 -8
  37. package/src/config/model-registry.ts +13 -6
  38. package/src/config/service-tier.ts +85 -56
  39. package/src/config/settings-schema.ts +42 -36
  40. package/src/config/settings.ts +47 -0
  41. package/src/edit/hashline/execute.ts +15 -9
  42. package/src/edit/index.ts +19 -6
  43. package/src/edit/modes/patch.ts +3 -2
  44. package/src/edit/modes/replace.ts +3 -2
  45. package/src/edit/renderer.ts +4 -0
  46. package/src/edit/snapshot-details.ts +77 -0
  47. package/src/eval/agent-bridge.ts +4 -2
  48. package/src/extensibility/plugins/legacy-pi-compat.ts +2 -2
  49. package/src/internal-urls/docs-index.generated.txt +1 -1
  50. package/src/main.ts +1 -1
  51. package/src/mcp/config-writer.ts +121 -0
  52. package/src/mcp/config.ts +10 -6
  53. package/src/mcp/oauth-flow.ts +10 -8
  54. package/src/mcp/transports/stdio.ts +9 -17
  55. package/src/mcp/types.ts +3 -0
  56. package/src/modes/components/extensions/extension-dashboard.ts +46 -0
  57. package/src/modes/components/extensions/state-manager.ts +24 -3
  58. package/src/modes/controllers/event-controller.ts +24 -8
  59. package/src/modes/controllers/tool-args-reveal.ts +100 -22
  60. package/src/modes/utils/transcript-render-helpers.ts +2 -2
  61. package/src/prompts/bench.md +4 -10
  62. package/src/prompts/tools/irc.md +19 -29
  63. package/src/prompts/tools/job.md +8 -14
  64. package/src/prompts/tools/lsp.md +19 -30
  65. package/src/prompts/tools/task.md +42 -62
  66. package/src/sdk.ts +13 -11
  67. package/src/session/agent-session.ts +196 -76
  68. package/src/session/messages.ts +1 -1
  69. package/src/session/session-context.ts +4 -4
  70. package/src/session/session-entries.ts +2 -2
  71. package/src/session/session-listing.ts +9 -8
  72. package/src/session/session-loader.ts +98 -3
  73. package/src/session/session-manager.ts +43 -6
  74. package/src/slash-commands/builtin-registry.ts +2 -10
  75. package/src/subprocess/worker-client.ts +12 -4
  76. package/src/system-prompt.test.ts +158 -0
  77. package/src/system-prompt.ts +69 -26
  78. package/src/task/executor.ts +23 -16
  79. package/src/task/index.ts +7 -5
  80. package/src/task/isolation-runner.ts +15 -1
  81. package/src/task/types.ts +6 -0
  82. package/src/task/worktree.ts +219 -38
  83. package/src/tiny/title-client.ts +19 -13
  84. package/src/tools/index.ts +3 -3
  85. package/src/tools/irc.ts +9 -3
  86. package/src/tools/path-utils.ts +4 -2
  87. package/src/tools/read.ts +28 -28
  88. package/src/utils/file-mentions.ts +10 -1
  89. package/src/utils/git.ts +38 -0
  90. package/src/web/search/index.ts +14 -8
  91. package/src/web/search/providers/duckduckgo.ts +150 -78
  92. package/src/web/search/providers/gemini.ts +268 -185
  93. package/src/web/search/types.ts +1 -1
@@ -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, ServiceTier, Usage } from "@oh-my-pi/pi-ai";
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 effective service tier, the source of truth for a
348
- * subagent whose `serviceTierSubagent` is `"inherit"`. `null` = the parent
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 `serviceTier` setting.
350
+ * inherit falls back to the subagent's configured `tier.*` settings.
351
351
  */
352
- parentServiceTier?: ServiceTier | null;
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?: ServiceTier | null,
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 service tier from `serviceTierSubagent` ("inherit" =
749
- // match the parent's live tier when a live session supplied one, else the
750
- // configured `serviceTier`). The result is stamped back onto the snapshot so
751
- // createAgentSession's `settings.get("serviceTier")` read picks it up.
752
- snapshot.serviceTier = resolveSubagentServiceTier(
753
- baseSettings.get("serviceTierSubagent"),
754
- baseSettings.get("serviceTier"),
755
- inheritedServiceTier,
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 `serviceTierSubagent: inherit`. When the
1300
- // session exposes a tier accessor, pass tier-or-null (null = explicit
1301
- // none, e.g. /fast off); otherwise leave undefined so inherit falls
1302
- // back to the configured serviceTier setting.
1303
- parentServiceTier: this.session.getServiceTier ? (this.session.getServiceTier() ?? null) : undefined,
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
- { branchName: result.branchName, taskId: result.id, description: result.description },
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) */
@@ -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
- const baselineTree = await writeSyntheticTree(repoDir, rb.headCommit, [rb.staged, rb.unstaged, rb.untrackedPatch]);
159
- const currentTree = await writeSyntheticTree(repoDir, currentHead, [
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(repoDir, baselineTree, currentTree, {
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
- * Commit task-only changes to a new branch.
467
- * Only root repo changes go on the branch. Nested repo patches are returned
468
- * separately since the parent git can't track files inside gitlinks.
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
- // Only create a branch if the root repo has changes
485
- if (rootPatch.trim()) {
486
- await git.branch.create(repoRoot, branchName);
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
- try {
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 git.commit(tmpDir, msg);
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 { branchName: rootPatch.trim() ? branchName : undefined, nestedPatches };
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
- * Each branch has a single commit that gets replayed cleanly.
529
- * Stops on first conflict and reports which branches succeeded.
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
- await git.cherryPick(repoRoot, branchName);
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);
@@ -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: (ok: boolean) => void };
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<boolean> {
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<boolean>();
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: error instanceof Error ? error.message : String(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 === "generate" || pending.kind === "complete") pending.resolve(null);
393
- else pending.resolve(false);
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();
@@ -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, ServiceTier, ToolChoice } from "@oh-my-pi/pi-ai";
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 effective service tier (undefined = none). Source of truth for subagent `serviceTierSubagent: inherit`. */
244
- getServiceTier?: () => ServiceTier | undefined;
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 messages = IrcBus.global().inbox(senderId, { peek: params.peek });
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." }],
@@ -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 = stripFileUrl(normalizeUnicodeSpaces(normalizeAtPrefix(filePath)));
150
+ const normalized = stripWindowsExtendedLengthPathPrefix(
151
+ stripFileUrl(normalizeUnicodeSpaces(normalizeAtPrefix(filePath))),
152
+ );
151
153
  return expandTilde(normalized);
152
154
  }
153
155