@oh-my-pi/pi-coding-agent 13.18.0 → 13.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/package.json +7 -11
  3. package/src/autoresearch/git.ts +25 -30
  4. package/src/autoresearch/tools/log-experiment.ts +61 -74
  5. package/src/commit/agentic/agent.ts +0 -3
  6. package/src/commit/agentic/index.ts +19 -22
  7. package/src/commit/agentic/tools/git-file-diff.ts +3 -6
  8. package/src/commit/agentic/tools/git-hunk.ts +3 -3
  9. package/src/commit/agentic/tools/git-overview.ts +6 -9
  10. package/src/commit/agentic/tools/index.ts +6 -8
  11. package/src/commit/agentic/tools/propose-commit.ts +4 -7
  12. package/src/commit/agentic/tools/recent-commits.ts +3 -3
  13. package/src/commit/agentic/tools/split-commit.ts +4 -4
  14. package/src/commit/changelog/index.ts +5 -9
  15. package/src/commit/pipeline.ts +10 -12
  16. package/src/config/keybindings.ts +7 -6
  17. package/src/config/settings-schema.ts +44 -0
  18. package/src/extensibility/custom-commands/bundled/ci-green/index.ts +4 -16
  19. package/src/extensibility/custom-commands/bundled/review/index.ts +43 -41
  20. package/src/extensibility/custom-tools/types.ts +1 -1
  21. package/src/extensibility/extensions/types.ts +3 -1
  22. package/src/extensibility/hooks/types.ts +1 -1
  23. package/src/extensibility/plugins/marketplace/fetcher.ts +2 -57
  24. package/src/extensibility/plugins/marketplace/source-resolver.ts +4 -4
  25. package/src/index.ts +1 -0
  26. package/src/main.ts +24 -2
  27. package/src/modes/components/footer.ts +9 -29
  28. package/src/modes/components/hook-editor.ts +3 -3
  29. package/src/modes/components/hook-selector.ts +6 -1
  30. package/src/modes/components/session-observer-overlay.ts +472 -0
  31. package/src/modes/components/settings-defs.ts +19 -0
  32. package/src/modes/components/status-line.ts +15 -61
  33. package/src/modes/controllers/command-controller.ts +1 -0
  34. package/src/modes/controllers/event-controller.ts +59 -2
  35. package/src/modes/controllers/extension-ui-controller.ts +1 -0
  36. package/src/modes/controllers/input-controller.ts +3 -0
  37. package/src/modes/controllers/selector-controller.ts +26 -0
  38. package/src/modes/interactive-mode.ts +195 -43
  39. package/src/modes/session-observer-registry.ts +146 -0
  40. package/src/modes/shared.ts +0 -42
  41. package/src/modes/types.ts +2 -0
  42. package/src/modes/utils/keybinding-matchers.ts +9 -0
  43. package/src/prompts/system/custom-system-prompt.md +5 -0
  44. package/src/prompts/system/system-prompt.md +6 -0
  45. package/src/sdk.ts +28 -13
  46. package/src/secrets/index.ts +1 -1
  47. package/src/secrets/obfuscator.ts +24 -16
  48. package/src/session/agent-session.ts +75 -30
  49. package/src/session/session-manager.ts +15 -5
  50. package/src/system-prompt.ts +4 -0
  51. package/src/task/executor.ts +28 -0
  52. package/src/task/index.ts +88 -78
  53. package/src/task/types.ts +25 -0
  54. package/src/task/worktree.ts +127 -145
  55. package/src/tools/exit-plan-mode.ts +1 -0
  56. package/src/tools/gh.ts +120 -297
  57. package/src/tools/read.ts +13 -79
  58. package/src/utils/external-editor.ts +11 -5
  59. package/src/utils/git.ts +1400 -0
  60. package/src/web/search/render.ts +6 -4
  61. package/src/commit/git/errors.ts +0 -9
  62. package/src/commit/git/index.ts +0 -210
  63. package/src/commit/git/operations.ts +0 -54
  64. package/src/tools/gh-cli.ts +0 -125
package/src/tools/gh.ts CHANGED
@@ -3,7 +3,6 @@ import * as path from "node:path";
3
3
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
4
4
  import { abortableSleep, isEnoent, untilAborted } from "@oh-my-pi/pi-utils";
5
5
  import { type Static, Type } from "@sinclair/typebox";
6
- import { $ } from "bun";
7
6
  import { renderPromptTemplate } from "../config/prompt-templates";
8
7
  import ghIssueViewDescription from "../prompts/tools/gh-issue-view.md" with { type: "text" };
9
8
  import ghPrCheckoutDescription from "../prompts/tools/gh-pr-checkout.md" with { type: "text" };
@@ -14,8 +13,8 @@ import ghRepoViewDescription from "../prompts/tools/gh-repo-view.md" with { type
14
13
  import ghRunWatchDescription from "../prompts/tools/gh-run-watch.md" with { type: "text" };
15
14
  import ghSearchIssuesDescription from "../prompts/tools/gh-search-issues.md" with { type: "text" };
16
15
  import ghSearchPrsDescription from "../prompts/tools/gh-search-prs.md" with { type: "text" };
16
+ import * as git from "../utils/git";
17
17
  import type { ToolSession } from ".";
18
- import { isGhAvailable, runGhCommand, runGhJson, runGhText } from "./gh-cli";
19
18
  import type { OutputMeta } from "./output-meta";
20
19
  import { ToolError, throwIfAborted } from "./tool-errors";
21
20
  import { toolResult } from "./tool-result";
@@ -401,19 +400,6 @@ interface GhPrViewData extends GhIssueViewData {
401
400
  reviewDecision?: string;
402
401
  }
403
402
 
404
- interface GitCommandResult {
405
- exitCode: number;
406
- stdout: string;
407
- stderr: string;
408
- }
409
-
410
- interface GitWorktreeEntry {
411
- path: string;
412
- head?: string;
413
- branch?: string;
414
- detached: boolean;
415
- }
416
-
417
403
  interface GhPrReviewCommit {
418
404
  oid?: string | null;
419
405
  }
@@ -641,142 +627,45 @@ function stripHeadsRef(value: string | undefined): string | undefined {
641
627
  return value.startsWith("refs/heads/") ? value.slice("refs/heads/".length) : value;
642
628
  }
643
629
 
644
- function formatGitFailure(args: string[], result: GitCommandResult): string {
645
- const output = normalizeOptionalString(result.stderr) ?? normalizeOptionalString(result.stdout);
646
- if (output) {
647
- return output;
630
+ async function requireGitRepoRoot(cwd: string, signal?: AbortSignal): Promise<string> {
631
+ const repoRoot = await git.repo.root(cwd, signal);
632
+ if (!repoRoot) {
633
+ throw new ToolError("Current git repository is unavailable.");
648
634
  }
649
635
 
650
- return `git ${args.join(" ")} failed with exit code ${result.exitCode}`;
651
- }
652
-
653
- async function runGitCommand(cwd: string, args: string[], signal?: AbortSignal): Promise<GitCommandResult> {
654
- return untilAborted(signal, async () => {
655
- throwIfAborted(signal);
656
- const child = Bun.spawn(["git", ...args], {
657
- cwd,
658
- stdin: "ignore",
659
- stdout: "pipe",
660
- stderr: "pipe",
661
- windowsHide: true,
662
- signal,
663
- });
664
- throwIfAborted(signal);
665
-
666
- if (!child.stdout || !child.stderr) {
667
- throw new ToolError("Failed to capture git command output.");
668
- }
669
-
670
- const [stdout, stderr, exitCode] = await Promise.all([
671
- new Response(child.stdout).text(),
672
- new Response(child.stderr).text(),
673
- child.exited,
674
- ]);
675
- throwIfAborted(signal);
676
-
677
- return {
678
- exitCode: exitCode ?? 0,
679
- stdout: normalizeBlock(stdout),
680
- stderr: normalizeBlock(stderr),
681
- };
682
- });
636
+ return repoRoot;
683
637
  }
684
638
 
685
- async function runGitTextChecked(cwd: string, args: string[], signal?: AbortSignal): Promise<string> {
686
- const result = await runGitChecked(cwd, args, signal);
687
-
688
- const text = normalizeOptionalString(result.stdout);
689
- if (!text) {
690
- throw new ToolError(`git ${args.join(" ")} returned empty output.`);
639
+ async function requirePrimaryGitRepoRoot(cwd: string, signal?: AbortSignal): Promise<string> {
640
+ const primaryRepoRoot = await git.repo.primaryRoot(cwd, signal);
641
+ if (!primaryRepoRoot) {
642
+ throw new ToolError("Current git repository is unavailable.");
691
643
  }
692
644
 
693
- return text;
645
+ return primaryRepoRoot;
694
646
  }
695
647
 
696
- async function runGitChecked(cwd: string, args: string[], signal?: AbortSignal): Promise<GitCommandResult> {
697
- const result = await runGitCommand(cwd, args, signal);
698
- if (result.exitCode !== 0) {
699
- throw new ToolError(formatGitFailure(args, result));
648
+ async function requireCurrentGitBranch(cwd: string, signal?: AbortSignal): Promise<string> {
649
+ const branch = await git.branch.current(cwd, signal);
650
+ if (!branch) {
651
+ throw new ToolError("Current git branch is unavailable. Pass `branch` or `run` explicitly.");
700
652
  }
701
653
 
702
- return result;
654
+ return branch;
703
655
  }
704
656
 
705
- async function tryRunGitText(cwd: string, args: string[], signal?: AbortSignal): Promise<string | undefined> {
706
- const result = await runGitCommand(cwd, args, signal);
707
- if (result.exitCode !== 0) {
708
- return undefined;
657
+ async function requireCurrentGitHead(cwd: string, signal?: AbortSignal): Promise<string> {
658
+ const headSha = await git.head.sha(cwd, signal);
659
+ if (!headSha) {
660
+ throw new ToolError("Current git HEAD is unavailable. Pass `run` explicitly.");
709
661
  }
710
662
 
711
- return normalizeOptionalString(result.stdout);
712
- }
713
-
714
- async function resolveGitRepoRoot(cwd: string, signal?: AbortSignal): Promise<string> {
715
- return runGitTextChecked(cwd, ["rev-parse", "--show-toplevel"], signal);
716
- }
717
-
718
- async function resolvePrimaryGitRepoRoot(repoRoot: string, signal?: AbortSignal): Promise<string> {
719
- const commonDir = await runGitTextChecked(
720
- repoRoot,
721
- ["rev-parse", "--path-format=absolute", "--git-common-dir"],
722
- signal,
723
- );
724
- if (path.basename(commonDir) === ".git") {
725
- return path.dirname(commonDir);
726
- }
727
-
728
- return repoRoot;
729
- }
730
-
731
- function parseGitWorktreeList(text: string): GitWorktreeEntry[] {
732
- const trimmed = text.trim();
733
- if (!trimmed) {
734
- return [];
735
- }
736
-
737
- return trimmed
738
- .split(/\n\s*\n/)
739
- .map(block => block.trim())
740
- .filter(Boolean)
741
- .map(block => {
742
- const entry: GitWorktreeEntry = {
743
- path: "",
744
- detached: false,
745
- };
746
- for (const line of block.split("\n")) {
747
- if (line.startsWith("worktree ")) {
748
- entry.path = line.slice("worktree ".length);
749
- continue;
750
- }
751
- if (line.startsWith("HEAD ")) {
752
- entry.head = line.slice("HEAD ".length);
753
- continue;
754
- }
755
- if (line.startsWith("branch ")) {
756
- entry.branch = line.slice("branch ".length);
757
- continue;
758
- }
759
- if (line === "detached") {
760
- entry.detached = true;
761
- }
762
- }
763
- return entry;
764
- });
765
- }
766
-
767
- async function listGitWorktrees(repoRoot: string, signal?: AbortSignal): Promise<GitWorktreeEntry[]> {
768
- const output = await runGitTextChecked(repoRoot, ["worktree", "list", "--porcelain"], signal);
769
- return parseGitWorktreeList(output);
770
- }
771
-
772
- async function gitRefExists(repoRoot: string, ref: string, signal?: AbortSignal): Promise<boolean> {
773
- const result = await runGitCommand(repoRoot, ["show-ref", "--verify", "--quiet", ref], signal);
774
- return result.exitCode === 0;
663
+ return headSha;
775
664
  }
776
665
 
777
666
  async function ensureGitWorktreePathAvailable(
778
667
  worktreePath: string,
779
- existingWorktrees: GitWorktreeEntry[],
668
+ existingWorktrees: git.GitWorktreeEntry[],
780
669
  ): Promise<void> {
781
670
  const normalizedTarget = path.resolve(worktreePath);
782
671
  const conflictingWorktree = existingWorktrees.find(entry => path.resolve(entry.path) === normalizedTarget);
@@ -804,15 +693,10 @@ function selectPrCloneUrl(originUrl: string | undefined, repo: Pick<GhRepoViewDa
804
693
  }
805
694
 
806
695
  async function getRemoteUrls(repoRoot: string, signal?: AbortSignal): Promise<Map<string, string>> {
807
- const remoteList = await tryRunGitText(repoRoot, ["remote"], signal);
808
- const remotes =
809
- remoteList
810
- ?.split("\n")
811
- .map(value => value.trim())
812
- .filter(Boolean) ?? [];
696
+ const remotes = await git.remote.list(repoRoot, signal);
813
697
  const urls = new Map<string, string>();
814
698
  for (const remoteName of remotes) {
815
- const remoteUrl = await tryRunGitText(repoRoot, ["remote", "get-url", remoteName], signal);
699
+ const remoteUrl = await git.remote.url(repoRoot, remoteName, signal);
816
700
  if (remoteUrl) {
817
701
  urls.set(remoteName, remoteUrl);
818
702
  }
@@ -826,7 +710,7 @@ async function ensurePrRemote(
826
710
  signal?: AbortSignal,
827
711
  ): Promise<{ name: string; url: string }> {
828
712
  if (!data.isCrossRepository) {
829
- const originUrl = normalizeOptionalString(await tryRunGitText(repoRoot, ["remote", "get-url", "origin"], signal));
713
+ const originUrl = await git.remote.url(repoRoot, "origin", signal);
830
714
  if (!originUrl) {
831
715
  throw new ToolError("origin remote is unavailable for this repository.");
832
716
  }
@@ -838,13 +722,13 @@ async function ensurePrRemote(
838
722
  }
839
723
 
840
724
  const headRepository = requireNonEmpty(data.headRepository?.nameWithOwner, "head repository");
841
- const repoSummary = await runGhJson<GhRepoViewData>(
725
+ const repoSummary = await git.github.json<GhRepoViewData>(
842
726
  repoRoot,
843
727
  ["repo", "view", headRepository, "--json", GH_REPO_CLONE_FIELDS.join(",")],
844
728
  signal,
845
729
  { repoProvided: true },
846
730
  );
847
- const originUrl = await tryRunGitText(repoRoot, ["remote", "get-url", "origin"], signal);
731
+ const originUrl = await git.remote.url(repoRoot, "origin", signal);
848
732
  const remoteUrl = selectPrCloneUrl(originUrl, repoSummary);
849
733
  if (!remoteUrl) {
850
734
  throw new ToolError(`Could not determine a clone URL for ${headRepository}.`);
@@ -867,10 +751,7 @@ async function ensurePrRemote(
867
751
  suffix += 1;
868
752
  }
869
753
 
870
- const result = await runGitCommand(repoRoot, ["remote", "add", remoteName, remoteUrl], signal);
871
- if (result.exitCode !== 0) {
872
- throw new ToolError(formatGitFailure(["remote", "add", remoteName, remoteUrl], result));
873
- }
754
+ await git.remote.add(repoRoot, remoteName, remoteUrl, signal);
874
755
 
875
756
  return {
876
757
  name: remoteName,
@@ -878,28 +759,6 @@ async function ensurePrRemote(
878
759
  };
879
760
  }
880
761
 
881
- async function setBranchConfig(
882
- repoRoot: string,
883
- localBranch: string,
884
- key: string,
885
- value: string,
886
- signal?: AbortSignal,
887
- ): Promise<void> {
888
- const result = await runGitCommand(repoRoot, ["config", `branch.${localBranch}.${key}`, value], signal);
889
- if (result.exitCode !== 0) {
890
- throw new ToolError(formatGitFailure(["config", `branch.${localBranch}.${key}`, value], result));
891
- }
892
- }
893
-
894
- async function getBranchConfig(
895
- repoRoot: string,
896
- localBranch: string,
897
- key: string,
898
- signal?: AbortSignal,
899
- ): Promise<string | undefined> {
900
- return tryRunGitText(repoRoot, ["config", "--get", `branch.${localBranch}.${key}`], signal);
901
- }
902
-
903
762
  async function resolvePrBranchPushTarget(
904
763
  repoRoot: string,
905
764
  localBranch: string,
@@ -912,13 +771,18 @@ async function resolvePrBranchPushTarget(
912
771
  maintainerCanModify?: boolean;
913
772
  isCrossRepository: boolean;
914
773
  }> {
915
- const pushRemote = await getBranchConfig(repoRoot, localBranch, "pushRemote", signal);
916
- const remote = await getBranchConfig(repoRoot, localBranch, "remote", signal);
917
- const mergeRef = await getBranchConfig(repoRoot, localBranch, "merge", signal);
918
- const headRef = await getBranchConfig(repoRoot, localBranch, "ompPrHeadRef", signal);
919
- const prUrl = await getBranchConfig(repoRoot, localBranch, "ompPrUrl", signal);
920
- const maintainerCanModifyValue = await getBranchConfig(repoRoot, localBranch, "ompPrMaintainerCanModify", signal);
921
- const isCrossRepositoryValue = await getBranchConfig(repoRoot, localBranch, "ompPrIsCrossRepository", signal);
774
+ const pushRemote = await git.config.getBranch(repoRoot, localBranch, "pushRemote", signal);
775
+ const remote = await git.config.getBranch(repoRoot, localBranch, "remote", signal);
776
+ const mergeRef = await git.config.getBranch(repoRoot, localBranch, "merge", signal);
777
+ const headRef = await git.config.getBranch(repoRoot, localBranch, "ompPrHeadRef", signal);
778
+ const prUrl = await git.config.getBranch(repoRoot, localBranch, "ompPrUrl", signal);
779
+ const maintainerCanModifyValue = await git.config.getBranch(
780
+ repoRoot,
781
+ localBranch,
782
+ "ompPrMaintainerCanModify",
783
+ signal,
784
+ );
785
+ const isCrossRepositoryValue = await git.config.getBranch(repoRoot, localBranch, "ompPrIsCrossRepository", signal);
922
786
 
923
787
  const remoteName = pushRemote ?? remote;
924
788
  if (!remoteName) {
@@ -933,7 +797,7 @@ async function resolvePrBranchPushTarget(
933
797
  return {
934
798
  remoteName,
935
799
  remoteBranch,
936
- remoteUrl: await tryRunGitText(repoRoot, ["remote", "get-url", remoteName], signal),
800
+ remoteUrl: await git.remote.url(repoRoot, remoteName, signal),
937
801
  prUrl,
938
802
  maintainerCanModify:
939
803
  maintainerCanModifyValue === undefined
@@ -1085,6 +949,10 @@ function getRunCollectionOutcome(runs: GhRunSnapshot[]): "success" | "failure" |
1085
949
 
1086
950
  let pending = false;
1087
951
  for (const run of runs) {
952
+ if (run.jobs.some(isFailedJob)) {
953
+ return "failure";
954
+ }
955
+
1088
956
  const outcome = getRunSnapshotOutcome(run);
1089
957
  if (outcome === "failure") {
1090
958
  return "failure";
@@ -1483,44 +1351,6 @@ function buildCommitRunWatchDetails(
1483
1351
  };
1484
1352
  }
1485
1353
 
1486
- async function resolveCurrentGitBranch(cwd: string, signal?: AbortSignal): Promise<string> {
1487
- return untilAborted(signal, async () => {
1488
- throwIfAborted(signal);
1489
- const result = await $`git symbolic-ref --short HEAD`.cwd(cwd).quiet().nothrow();
1490
- throwIfAborted(signal);
1491
-
1492
- if (result.exitCode !== 0) {
1493
- throw new ToolError("Current git branch is unavailable. Pass `branch` or `run` explicitly.");
1494
- }
1495
-
1496
- const branch = normalizeOptionalString(result.text());
1497
- if (!branch) {
1498
- throw new ToolError("Current git branch is unavailable. Pass `branch` or `run` explicitly.");
1499
- }
1500
-
1501
- return branch;
1502
- });
1503
- }
1504
-
1505
- async function resolveCurrentGitHead(cwd: string, signal?: AbortSignal): Promise<string> {
1506
- return untilAborted(signal, async () => {
1507
- throwIfAborted(signal);
1508
- const result = await $`git rev-parse HEAD`.cwd(cwd).quiet().nothrow();
1509
- throwIfAborted(signal);
1510
-
1511
- if (result.exitCode !== 0) {
1512
- throw new ToolError("Current git HEAD is unavailable. Pass `run` explicitly.");
1513
- }
1514
-
1515
- const headSha = normalizeOptionalString(result.text());
1516
- if (!headSha) {
1517
- throw new ToolError("Current git HEAD is unavailable. Pass `run` explicitly.");
1518
- }
1519
-
1520
- return headSha;
1521
- });
1522
- }
1523
-
1524
1354
  async function resolveGitHubRepo(
1525
1355
  cwd: string,
1526
1356
  repo: string | undefined,
@@ -1539,7 +1369,11 @@ async function resolveGitHubRepo(
1539
1369
  return runRepo;
1540
1370
  }
1541
1371
 
1542
- const resolved = await runGhText(cwd, ["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"], signal);
1372
+ const resolved = await git.github.text(
1373
+ cwd,
1374
+ ["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"],
1375
+ signal,
1376
+ );
1543
1377
  return requireNonEmpty(resolved, "repo");
1544
1378
  }
1545
1379
 
@@ -1549,7 +1383,7 @@ async function resolveGitHubBranchHead(
1549
1383
  branch: string,
1550
1384
  signal?: AbortSignal,
1551
1385
  ): Promise<string> {
1552
- const response = await runGhJson<GhBranchApiResponse>(
1386
+ const response = await git.github.json<GhBranchApiResponse>(
1553
1387
  cwd,
1554
1388
  ["api", "--method", "GET", `/repos/${repo}/branches/${encodeURIComponent(branch)}`],
1555
1389
  signal,
@@ -1565,7 +1399,7 @@ async function fetchRunsForCommit(
1565
1399
  branch: string | undefined,
1566
1400
  signal?: AbortSignal,
1567
1401
  ): Promise<GhRunSnapshot[]> {
1568
- const response = await runGhJson<GhActionsRunListResponse>(
1402
+ const response = await git.github.json<GhActionsRunListResponse>(
1569
1403
  cwd,
1570
1404
  [
1571
1405
  "api",
@@ -1602,7 +1436,7 @@ async function fetchRunJobs(
1602
1436
  let page = 1;
1603
1437
 
1604
1438
  while (true) {
1605
- const response = await runGhJson<GhActionsJobsResponse>(
1439
+ const response = await git.github.json<GhActionsJobsResponse>(
1606
1440
  cwd,
1607
1441
  [
1608
1442
  "api",
@@ -1646,7 +1480,7 @@ async function fetchPrReviewComments(
1646
1480
  let page = 1;
1647
1481
 
1648
1482
  while (true) {
1649
- const response = await runGhJson<GhPrReviewCommentApi[]>(
1483
+ const response = await git.github.json<GhPrReviewCommentApi[]>(
1650
1484
  cwd,
1651
1485
  [
1652
1486
  "api",
@@ -1684,9 +1518,14 @@ async function fetchRunSnapshot(
1684
1518
  signal?: AbortSignal,
1685
1519
  ): Promise<GhRunSnapshot> {
1686
1520
  const [run, jobs] = await Promise.all([
1687
- runGhJson<GhActionsRunApi>(cwd, ["api", "--method", "GET", `/repos/${repo}/actions/runs/${runId}`], signal, {
1688
- repoProvided: true,
1689
- }),
1521
+ git.github.json<GhActionsRunApi>(
1522
+ cwd,
1523
+ ["api", "--method", "GET", `/repos/${repo}/actions/runs/${runId}`],
1524
+ signal,
1525
+ {
1526
+ repoProvided: true,
1527
+ },
1528
+ ),
1690
1529
  fetchRunJobs(cwd, repo, runId, signal),
1691
1530
  ]);
1692
1531
 
@@ -1712,7 +1551,7 @@ async function fetchFailedJobLogs(
1712
1551
  ): Promise<GhFailedJobLog[]> {
1713
1552
  return Promise.all(
1714
1553
  failedJobs.map(async entry => {
1715
- const result = await runGhCommand(cwd, ["api", `/repos/${repo}/actions/jobs/${entry.job.id}/logs`], signal);
1554
+ const result = await git.github.run(cwd, ["api", `/repos/${repo}/actions/jobs/${entry.job.id}/logs`], signal);
1716
1555
  const fullLog = result.exitCode === 0 ? normalizeBlock(result.stdout) : undefined;
1717
1556
  const logTail = fullLog ? tailLogLines(fullLog, tail) : undefined;
1718
1557
  return {
@@ -2070,7 +1909,7 @@ export class GhRepoViewTool implements AgentTool<typeof ghRepoViewSchema, GhTool
2070
1909
  constructor(private readonly session: ToolSession) {}
2071
1910
 
2072
1911
  static createIf(session: ToolSession): GhRepoViewTool | null {
2073
- if (!isGhAvailable()) return null;
1912
+ if (!git.github.available()) return null;
2074
1913
  return new GhRepoViewTool(session);
2075
1914
  }
2076
1915
 
@@ -2093,7 +1932,9 @@ export class GhRepoViewTool implements AgentTool<typeof ghRepoViewSchema, GhTool
2093
1932
  }
2094
1933
  args.push("--json", GH_REPO_FIELDS.join(","));
2095
1934
 
2096
- const data = await runGhJson<GhRepoViewData>(this.session.cwd, args, signal, { repoProvided: Boolean(repo) });
1935
+ const data = await git.github.json<GhRepoViewData>(this.session.cwd, args, signal, {
1936
+ repoProvided: Boolean(repo),
1937
+ });
2097
1938
  return buildTextResult(formatRepoView(data, { repo, branch }), data.url);
2098
1939
  });
2099
1940
  }
@@ -2109,7 +1950,7 @@ export class GhIssueViewTool implements AgentTool<typeof ghIssueViewSchema, GhTo
2109
1950
  constructor(private readonly session: ToolSession) {}
2110
1951
 
2111
1952
  static createIf(session: ToolSession): GhIssueViewTool | null {
2112
- if (!isGhAvailable()) return null;
1953
+ if (!git.github.available()) return null;
2113
1954
  return new GhIssueViewTool(session);
2114
1955
  }
2115
1956
 
@@ -2128,7 +1969,9 @@ export class GhIssueViewTool implements AgentTool<typeof ghIssueViewSchema, GhTo
2128
1969
  appendRepoFlag(args, repo, issue);
2129
1970
  args.push("--json", (includeComments ? GH_ISSUE_FIELDS : GH_ISSUE_FIELDS_NO_COMMENTS).join(","));
2130
1971
 
2131
- const data = await runGhJson<GhIssueViewData>(this.session.cwd, args, signal, { repoProvided: Boolean(repo) });
1972
+ const data = await git.github.json<GhIssueViewData>(this.session.cwd, args, signal, {
1973
+ repoProvided: Boolean(repo),
1974
+ });
2132
1975
  return buildTextResult(formatIssueView(data, { issue, repo, comments: includeComments }), data.url);
2133
1976
  });
2134
1977
  }
@@ -2144,7 +1987,7 @@ export class GhPrViewTool implements AgentTool<typeof ghPrViewSchema, GhToolDeta
2144
1987
  constructor(private readonly session: ToolSession) {}
2145
1988
 
2146
1989
  static createIf(session: ToolSession): GhPrViewTool | null {
2147
- if (!isGhAvailable()) return null;
1990
+ if (!git.github.available()) return null;
2148
1991
  return new GhPrViewTool(session);
2149
1992
  }
2150
1993
 
@@ -2166,7 +2009,9 @@ export class GhPrViewTool implements AgentTool<typeof ghPrViewSchema, GhToolDeta
2166
2009
  appendRepoFlag(args, repo, pr);
2167
2010
  args.push("--json", (includeComments ? GH_PR_FIELDS : GH_PR_FIELDS_NO_COMMENTS).join(","));
2168
2011
 
2169
- const data = await runGhJson<GhPrViewData>(this.session.cwd, args, signal, { repoProvided: Boolean(repo) });
2012
+ const data = await git.github.json<GhPrViewData>(this.session.cwd, args, signal, {
2013
+ repoProvided: Boolean(repo),
2014
+ });
2170
2015
  const resolvedRepo = repo ?? parsePullRequestUrl(data.url).repo;
2171
2016
  if (includeComments && resolvedRepo && typeof data.number === "number") {
2172
2017
  data.reviewComments = await fetchPrReviewComments(this.session.cwd, resolvedRepo, data.number, signal);
@@ -2186,7 +2031,7 @@ export class GhPrDiffTool implements AgentTool<typeof ghPrDiffSchema, GhToolDeta
2186
2031
  constructor(private readonly session: ToolSession) {}
2187
2032
 
2188
2033
  static createIf(session: ToolSession): GhPrDiffTool | null {
2189
- if (!isGhAvailable()) return null;
2034
+ if (!git.github.available()) return null;
2190
2035
  return new GhPrDiffTool(session);
2191
2036
  }
2192
2037
 
@@ -2214,7 +2059,7 @@ export class GhPrDiffTool implements AgentTool<typeof ghPrDiffSchema, GhToolDeta
2214
2059
  args.push("--exclude", normalizedPattern);
2215
2060
  }
2216
2061
 
2217
- const output = await runGhText(this.session.cwd, args, signal, {
2062
+ const output = await git.github.text(this.session.cwd, args, signal, {
2218
2063
  repoProvided: Boolean(repo),
2219
2064
  trimOutput: false,
2220
2065
  });
@@ -2235,7 +2080,7 @@ export class GhPrCheckoutTool implements AgentTool<typeof ghPrCheckoutSchema, Gh
2235
2080
  constructor(private readonly session: ToolSession) {}
2236
2081
 
2237
2082
  static createIf(session: ToolSession): GhPrCheckoutTool | null {
2238
- if (!isGhAvailable()) return null;
2083
+ if (!git.github.available()) return null;
2239
2084
  return new GhPrCheckoutTool(session);
2240
2085
  }
2241
2086
 
@@ -2259,7 +2104,7 @@ export class GhPrCheckoutTool implements AgentTool<typeof ghPrCheckoutSchema, Gh
2259
2104
  appendRepoFlag(args, repo, pr);
2260
2105
  args.push("--json", GH_PR_CHECKOUT_FIELDS.join(","));
2261
2106
 
2262
- const data = await runGhJson<GhPrViewData>(this.session.cwd, args, signal, {
2107
+ const data = await git.github.json<GhPrViewData>(this.session.cwd, args, signal, {
2263
2108
  repoProvided: Boolean(repo),
2264
2109
  });
2265
2110
  const prNumber = data.number;
@@ -2269,68 +2114,56 @@ export class GhPrCheckoutTool implements AgentTool<typeof ghPrCheckoutSchema, Gh
2269
2114
 
2270
2115
  const headRefName = requireNonEmpty(data.headRefName, "head branch");
2271
2116
  const headRefOid = requireNonEmpty(data.headRefOid, "head commit");
2272
- const repoRoot = await resolveGitRepoRoot(this.session.cwd, signal);
2273
- const primaryRepoRoot = await resolvePrimaryGitRepoRoot(repoRoot, signal);
2117
+ const repoRoot = await requireGitRepoRoot(this.session.cwd, signal);
2118
+ const primaryRepoRoot = await requirePrimaryGitRepoRoot(repoRoot, signal);
2274
2119
  const localBranch = requestedBranch ?? `pr-${prNumber}`;
2275
2120
  const worktreePath = requestedWorktree
2276
2121
  ? path.resolve(this.session.cwd, requestedWorktree)
2277
2122
  : path.join(primaryRepoRoot, ".worktrees", localBranch);
2278
- const existingWorktrees = await listGitWorktrees(repoRoot, signal);
2123
+ const existingWorktrees = await git.worktree.list(repoRoot, signal);
2279
2124
  const existingWorktree = existingWorktrees.find(entry => entry.branch === toLocalBranchRef(localBranch));
2280
2125
 
2281
2126
  const remote = await ensurePrRemote(repoRoot, data, signal);
2282
- await runGitChecked(
2127
+ await git.fetch(
2283
2128
  repoRoot,
2284
- ["fetch", remote.name, `+refs/heads/${headRefName}:refs/remotes/${remote.name}/${headRefName}`],
2129
+ remote.name,
2130
+ `refs/heads/${headRefName}`,
2131
+ `refs/remotes/${remote.name}/${headRefName}`,
2285
2132
  signal,
2286
2133
  );
2287
2134
 
2288
2135
  if (!existingWorktree) {
2289
2136
  const localBranchRef = toLocalBranchRef(localBranch);
2290
- const localBranchExists = await gitRefExists(repoRoot, localBranchRef, signal);
2137
+ const localBranchExists = await git.ref.exists(repoRoot, localBranchRef, signal);
2291
2138
  if (localBranchExists) {
2292
- const existingOid = await runGitTextChecked(repoRoot, ["rev-parse", localBranchRef], signal);
2139
+ const existingOid = await git.ref.resolve(repoRoot, localBranchRef, signal);
2293
2140
  if (existingOid !== headRefOid) {
2294
2141
  if (!force) {
2295
2142
  throw new ToolError(
2296
- `local branch ${localBranch} already exists at ${formatShortSha(existingOid) ?? existingOid}; pass force=true to reset it`,
2143
+ `local branch ${localBranch} already exists at ${formatShortSha(existingOid ?? undefined) ?? existingOid ?? "unknown commit"}; pass force=true to reset it`,
2297
2144
  );
2298
2145
  }
2299
2146
 
2300
- const resetResult = await runGitCommand(
2301
- repoRoot,
2302
- ["branch", "--force", localBranch, `refs/remotes/${remote.name}/${headRefName}`],
2303
- signal,
2304
- );
2305
- if (resetResult.exitCode !== 0) {
2306
- throw new ToolError(formatGitFailure(["branch", "--force", localBranch], resetResult));
2307
- }
2147
+ await git.branch.force(repoRoot, localBranch, `refs/remotes/${remote.name}/${headRefName}`, signal);
2308
2148
  }
2309
2149
  } else {
2310
- const createResult = await runGitCommand(
2311
- repoRoot,
2312
- ["branch", localBranch, `refs/remotes/${remote.name}/${headRefName}`],
2313
- signal,
2314
- );
2315
- if (createResult.exitCode !== 0) {
2316
- throw new ToolError(formatGitFailure(["branch", localBranch], createResult));
2317
- }
2150
+ await git.branch.create(repoRoot, localBranch, `refs/remotes/${remote.name}/${headRefName}`, signal);
2318
2151
  }
2319
2152
  }
2320
2153
 
2321
- await setBranchConfig(repoRoot, localBranch, "remote", remote.name, signal);
2322
- await setBranchConfig(repoRoot, localBranch, "merge", `refs/heads/${headRefName}`, signal);
2323
- await setBranchConfig(repoRoot, localBranch, "pushRemote", remote.name, signal);
2324
- await setBranchConfig(repoRoot, localBranch, "ompPrHeadRef", headRefName, signal);
2325
- await setBranchConfig(repoRoot, localBranch, "ompPrUrl", data.url ?? "", signal);
2326
- await setBranchConfig(
2154
+ await git.config.setBranch(repoRoot, localBranch, "remote", remote.name, signal);
2155
+ await git.config.setBranch(repoRoot, localBranch, "merge", `refs/heads/${headRefName}`, signal);
2156
+ await git.config.setBranch(repoRoot, localBranch, "pushRemote", remote.name, signal);
2157
+ await git.config.setBranch(repoRoot, localBranch, "ompPrHeadRef", headRefName, signal);
2158
+ await git.config.setBranch(repoRoot, localBranch, "ompPrUrl", data.url ?? "", signal);
2159
+ await git.config.setBranch(
2327
2160
  repoRoot,
2328
2161
  localBranch,
2329
2162
  "ompPrIsCrossRepository",
2330
2163
  String(Boolean(data.isCrossRepository)),
2331
2164
  signal,
2332
2165
  );
2333
- await setBranchConfig(
2166
+ await git.config.setBranch(
2334
2167
  repoRoot,
2335
2168
  localBranch,
2336
2169
  "ompPrMaintainerCanModify",
@@ -2342,21 +2175,15 @@ export class GhPrCheckoutTool implements AgentTool<typeof ghPrCheckoutSchema, Gh
2342
2175
  if (!existingWorktree) {
2343
2176
  await ensureGitWorktreePathAvailable(finalWorktreePath, existingWorktrees);
2344
2177
  await fs.mkdir(path.dirname(finalWorktreePath), { recursive: true });
2345
- const addResult = await runGitCommand(
2346
- repoRoot,
2347
- ["worktree", "add", finalWorktreePath, localBranch],
2348
- signal,
2349
- );
2350
- if (addResult.exitCode !== 0) {
2351
- throw new ToolError(formatGitFailure(["worktree", "add", finalWorktreePath, localBranch], addResult));
2352
- }
2178
+ await git.worktree.add(repoRoot, finalWorktreePath, localBranch, { signal });
2353
2179
  }
2180
+ const resolvedWorktreePath = await fs.realpath(finalWorktreePath);
2354
2181
 
2355
2182
  return buildTextResult(
2356
2183
  formatPrCheckoutResult({
2357
2184
  data,
2358
2185
  localBranch,
2359
- worktreePath: finalWorktreePath,
2186
+ worktreePath: resolvedWorktreePath,
2360
2187
  remoteName: remote.name,
2361
2188
  remoteUrl: remote.url,
2362
2189
  reused: Boolean(existingWorktree),
@@ -2365,7 +2192,7 @@ export class GhPrCheckoutTool implements AgentTool<typeof ghPrCheckoutSchema, Gh
2365
2192
  {
2366
2193
  repo: repo ?? data.headRepository?.nameWithOwner,
2367
2194
  branch: localBranch,
2368
- worktreePath: finalWorktreePath,
2195
+ worktreePath: resolvedWorktreePath,
2369
2196
  remote: remote.name,
2370
2197
  remoteBranch: headRefName,
2371
2198
  },
@@ -2384,7 +2211,7 @@ export class GhPrPushTool implements AgentTool<typeof ghPrPushSchema, GhToolDeta
2384
2211
  constructor(private readonly session: ToolSession) {}
2385
2212
 
2386
2213
  static createIf(session: ToolSession): GhPrPushTool | null {
2387
- if (!isGhAvailable()) return null;
2214
+ if (!git.github.available()) return null;
2388
2215
  return new GhPrPushTool(session);
2389
2216
  }
2390
2217
 
@@ -2396,28 +2223,24 @@ export class GhPrPushTool implements AgentTool<typeof ghPrPushSchema, GhToolDeta
2396
2223
  _context?: AgentToolContext,
2397
2224
  ): Promise<AgentToolResult<GhToolDetails>> {
2398
2225
  return untilAborted(signal, async () => {
2399
- const repoRoot = await resolveGitRepoRoot(this.session.cwd, signal);
2226
+ const repoRoot = await requireGitRepoRoot(this.session.cwd, signal);
2400
2227
  const localBranch =
2401
- normalizeOptionalString(params.branch) ?? (await resolveCurrentGitBranch(repoRoot, signal));
2402
- const refExists = await gitRefExists(repoRoot, toLocalBranchRef(localBranch), signal);
2228
+ normalizeOptionalString(params.branch) ?? (await requireCurrentGitBranch(repoRoot, signal));
2229
+ const refExists = await git.ref.exists(repoRoot, toLocalBranchRef(localBranch), signal);
2403
2230
  if (!refExists) {
2404
2231
  throw new ToolError(`local branch ${localBranch} does not exist`);
2405
2232
  }
2406
2233
 
2407
2234
  const target = await resolvePrBranchPushTarget(repoRoot, localBranch, signal);
2408
- const currentBranch = await tryRunGitText(repoRoot, ["branch", "--show-current"], signal);
2235
+ const currentBranch = await git.branch.current(repoRoot, signal);
2409
2236
  const sourceRef = currentBranch === localBranch ? "HEAD" : toLocalBranchRef(localBranch);
2410
2237
  const refspec = `${sourceRef}:refs/heads/${target.remoteBranch}`;
2411
- const pushArgs = ["push"];
2412
- if (params.forceWithLease) {
2413
- pushArgs.push("--force-with-lease");
2414
- }
2415
- pushArgs.push(target.remoteName, refspec);
2416
-
2417
- const pushResult = await runGitCommand(repoRoot, pushArgs, signal);
2418
- if (pushResult.exitCode !== 0) {
2419
- throw new ToolError(formatGitFailure(pushArgs, pushResult));
2420
- }
2238
+ await git.push(repoRoot, {
2239
+ forceWithLease: params.forceWithLease,
2240
+ refspec,
2241
+ remote: target.remoteName,
2242
+ signal,
2243
+ });
2421
2244
 
2422
2245
  return buildTextResult(
2423
2246
  formatPrPushResult({
@@ -2449,7 +2272,7 @@ export class GhSearchIssuesTool implements AgentTool<typeof ghSearchIssuesSchema
2449
2272
  constructor(private readonly session: ToolSession) {}
2450
2273
 
2451
2274
  static createIf(session: ToolSession): GhSearchIssuesTool | null {
2452
- if (!isGhAvailable()) return null;
2275
+ if (!git.github.available()) return null;
2453
2276
  return new GhSearchIssuesTool(session);
2454
2277
  }
2455
2278
 
@@ -2466,7 +2289,7 @@ export class GhSearchIssuesTool implements AgentTool<typeof ghSearchIssuesSchema
2466
2289
  const limit = resolveSearchLimit(params.limit);
2467
2290
  const args = buildGhSearchArgs("issues", query, limit, repo);
2468
2291
 
2469
- const items = await runGhJson<GhSearchResult[]>(this.session.cwd, args, signal, {
2292
+ const items = await git.github.json<GhSearchResult[]>(this.session.cwd, args, signal, {
2470
2293
  repoProvided: Boolean(repo),
2471
2294
  });
2472
2295
  return buildTextResult(formatSearchResults("issues", query, repo, items));
@@ -2484,7 +2307,7 @@ export class GhSearchPrsTool implements AgentTool<typeof ghSearchPrsSchema, GhTo
2484
2307
  constructor(private readonly session: ToolSession) {}
2485
2308
 
2486
2309
  static createIf(session: ToolSession): GhSearchPrsTool | null {
2487
- if (!isGhAvailable()) return null;
2310
+ if (!git.github.available()) return null;
2488
2311
  return new GhSearchPrsTool(session);
2489
2312
  }
2490
2313
 
@@ -2501,7 +2324,7 @@ export class GhSearchPrsTool implements AgentTool<typeof ghSearchPrsSchema, GhTo
2501
2324
  const limit = resolveSearchLimit(params.limit);
2502
2325
  const args = buildGhSearchArgs("prs", query, limit, repo);
2503
2326
 
2504
- const items = await runGhJson<GhSearchResult[]>(this.session.cwd, args, signal, {
2327
+ const items = await git.github.json<GhSearchResult[]>(this.session.cwd, args, signal, {
2505
2328
  repoProvided: Boolean(repo),
2506
2329
  });
2507
2330
  return buildTextResult(formatSearchResults("pull requests", query, repo, items));
@@ -2519,7 +2342,7 @@ export class GhRunWatchTool implements AgentTool<typeof ghRunWatchSchema, GhTool
2519
2342
  constructor(private readonly session: ToolSession) {}
2520
2343
 
2521
2344
  static createIf(session: ToolSession): GhRunWatchTool | null {
2522
- if (!isGhAvailable()) return null;
2345
+ if (!git.github.available()) return null;
2523
2346
  return new GhRunWatchTool(session);
2524
2347
  }
2525
2348
 
@@ -2613,10 +2436,10 @@ export class GhRunWatchTool implements AgentTool<typeof ghRunWatchSchema, GhTool
2613
2436
  }
2614
2437
  }
2615
2438
 
2616
- const branch = branchInput ?? (await resolveCurrentGitBranch(this.session.cwd, signal));
2439
+ const branch = branchInput ?? (await requireCurrentGitBranch(this.session.cwd, signal));
2617
2440
  const headSha = branchInput
2618
2441
  ? await resolveGitHubBranchHead(this.session.cwd, repo, branch, signal)
2619
- : await resolveCurrentGitHead(this.session.cwd, signal);
2442
+ : await requireCurrentGitHead(this.session.cwd, signal);
2620
2443
  let pollCount = 0;
2621
2444
  let settledSuccessSignature: string | undefined;
2622
2445