@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/CHANGELOG.md CHANGED
@@ -2,6 +2,53 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [13.19.0] - 2026-04-05
6
+ ### Added
7
+
8
+ - Added idle auto-compaction settings and scheduling so sessions can compact after inactive turns without auto-continuing.
9
+ - Added `onExternalEditor` callback to extension UI dialog options for handling external editor shortcut in select dialogs
10
+ - Added external editor shortcut support in plan review selector, allowing users to open and edit the plan in their configured editor
11
+ - Added `matchesAppExternalEditor` keybinding matcher to detect external editor shortcut (Ctrl+G or configured binding)
12
+ - Added `trimTrailingNewline` option to `openInEditor` function to preserve trailing newlines when editing files
13
+ - Added GitHub CLI utilities to git module (`utils/git.github`) with `available()`, `run()`, `json()`, and `text()` methods for GitHub CLI operations
14
+ - Exported git utilities from main package entry point for use by extensions
15
+ - Added comprehensive git utility module (`utils/git`) with organized namespaces for common git operations (branch, commit, diff, log, patch, ref, stage, status, head, repository)
16
+
17
+ ### Changed
18
+
19
+ - Changed idle compaction settings (`compaction.idleThresholdTokens` and `compaction.idleTimeoutSeconds`) from enum to numeric type for flexible configuration
20
+ - Modified secret obfuscation to deobfuscate restored session messages for local display while keeping outbound LLM messages obfuscated
21
+ - Updated stash pop operation to preserve staged changes with `--index` flag when restoring after task branch merges
22
+ - Changed secret placeholders to deterministic hash-style redaction tokens and deobfuscated assistant output for local display.
23
+ - Updated hook editor and hook selector components to use `matchesAppExternalEditor` matcher for consistent external editor keybinding detection
24
+ - Modified plan review flow to read the latest plan content from disk before approval, allowing changes made in external editor to be reflected
25
+ - Enhanced plan review help text to dynamically display the configured external editor keybinding
26
+ - Refactored git operations to use centralized utility module instead of `ControlledGit` class throughout codebase
27
+ - Replaced `ControlledGit` dependency injection pattern with direct `cwd` parameter in commit agent tools
28
+ - Migrated git HEAD resolution in footer and status-line components to use new synchronous and asynchronous utilities
29
+ - Updated git status summary calculation in status-line component to use new git utility API
30
+ - Simplified git branch operations in task execution and cleanup to use new utility functions
31
+ - Refactored patch application logic in task worktree to use new git patch utilities
32
+
33
+ ### Removed
34
+
35
+ - Removed `gh-cli.ts` module; GitHub CLI functionality now available via `utils/git.github`
36
+ - Removed `ControlledGit` class and associated git wrapper infrastructure from `commit/git` module
37
+ - Removed `mergeStdoutStderr` helper function from autoresearch git utilities
38
+ - Removed `findGitHeadPathAsync` and `findGitHeadPathSync` from modes/shared module (replaced by git utilities)
39
+ - Removed `./commit/git` export from package.json (internal diff parsing still available via `./commit/git/*`)
40
+
41
+ ### Fixed
42
+
43
+ - Fixed idle compaction timer to properly cancel when event controller is disposed, preventing memory leaks
44
+ - Fixed session resumption to preserve the last non-empty session when starting a fresh session
45
+ - Fixed stash detection to use git ref resolution instead of output parsing for reliable stash state tracking
46
+ - Fixed isolated task merge-back to preserve task outputs on merge failure and stash dirty worktrees before cherry-pick.
47
+ - Fixed web search source rendering to truncate long title, metadata, and URL lines before they overflow the UI.
48
+ - Fixed PR checkout tool to resolve symlinks in worktree paths, ensuring consistent path references in results and metadata
49
+ - Fixed `read` output for file-backed internal URLs like `local://...` to include hashline prefixes in hashline edit mode, preserving usable line refs for follow-up edits
50
+ - Fixed the plan review selector to support the external editor shortcut for opening and updating the current plan from the approval screen
51
+
5
52
  ## [13.18.0] - 2026-04-02
6
53
  ### Breaking Changes
7
54
 
@@ -354,6 +401,9 @@
354
401
 
355
402
  - Fixed resumed and session-switched GitHub Copilot/OpenAI Responses conversations replaying stale assistant native history from older saved sessions by sanitizing persisted assistant replay metadata on rehydration and resetting provider session state across live session boundaries ([#505](https://github.com/can1357/oh-my-pi/issues/505))
356
403
 
404
+ ### Added
405
+
406
+ - Session observer overlay (`Ctrl+S`): view running subagent sessions with a picker and read-only transcript showing thinking, text, tool calls, and results
357
407
  ## [13.14.0] - 2026-03-20
358
408
 
359
409
  ### Added
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "13.18.0",
4
+ "version": "13.19.0",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -42,12 +42,12 @@
42
42
  "dependencies": {
43
43
  "@agentclientprotocol/sdk": "0.16.1",
44
44
  "@mozilla/readability": "^0.6",
45
- "@oh-my-pi/omp-stats": "13.18.0",
46
- "@oh-my-pi/pi-agent-core": "13.18.0",
47
- "@oh-my-pi/pi-ai": "13.18.0",
48
- "@oh-my-pi/pi-natives": "13.18.0",
49
- "@oh-my-pi/pi-tui": "13.18.0",
50
- "@oh-my-pi/pi-utils": "13.18.0",
45
+ "@oh-my-pi/omp-stats": "13.19.0",
46
+ "@oh-my-pi/pi-agent-core": "13.19.0",
47
+ "@oh-my-pi/pi-ai": "13.19.0",
48
+ "@oh-my-pi/pi-natives": "13.19.0",
49
+ "@oh-my-pi/pi-tui": "13.19.0",
50
+ "@oh-my-pi/pi-utils": "13.19.0",
51
51
  "@sinclair/typebox": "^0.34",
52
52
  "@xterm/headless": "^6.0",
53
53
  "ajv": "^8.18",
@@ -146,10 +146,6 @@
146
146
  "types": "./src/commit/changelog/*.ts",
147
147
  "import": "./src/commit/changelog/*.ts"
148
148
  },
149
- "./commit/git": {
150
- "types": "./src/commit/git/index.ts",
151
- "import": "./src/commit/git/index.ts"
152
- },
153
149
  "./commit/git/*": {
154
150
  "types": "./src/commit/git/*.ts",
155
151
  "import": "./src/commit/git/*.ts"
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionAPI } from "../extensibility/extensions";
2
+ import * as git from "../utils/git";
2
3
  import { isAutoresearchLocalStatePath, normalizeAutoresearchPath } from "./helpers";
3
4
 
4
5
  const AUTORESEARCH_BRANCH_PREFIX = "autoresearch/";
@@ -17,9 +18,8 @@ export interface EnsureAutoresearchBranchSuccess {
17
18
 
18
19
  export type EnsureAutoresearchBranchResult = EnsureAutoresearchBranchFailure | EnsureAutoresearchBranchSuccess;
19
20
 
20
- export async function getCurrentAutoresearchBranch(api: ExtensionAPI, workDir: string): Promise<string | null> {
21
- const currentBranchResult = await api.exec("git", ["branch", "--show-current"], { cwd: workDir, timeout: 5_000 });
22
- const currentBranch = currentBranchResult.stdout.trim();
21
+ export async function getCurrentAutoresearchBranch(_api: ExtensionAPI, workDir: string): Promise<string | null> {
22
+ const currentBranch = (await git.branch.current(workDir)) ?? "";
23
23
  return currentBranch.startsWith(AUTORESEARCH_BRANCH_PREFIX) ? currentBranch : null;
24
24
  }
25
25
 
@@ -28,28 +28,30 @@ export async function ensureAutoresearchBranch(
28
28
  workDir: string,
29
29
  goal: string | null,
30
30
  ): Promise<EnsureAutoresearchBranchResult> {
31
- const repoRootResult = await api.exec("git", ["rev-parse", "--show-toplevel"], { cwd: workDir, timeout: 5_000 });
32
- if (repoRootResult.code !== 0) {
31
+ const repoRoot = await git.repo.root(workDir);
32
+ if (!repoRoot) {
33
33
  return {
34
34
  error: "Autoresearch requires a git repository so it can isolate experiments and revert failed runs safely.",
35
35
  ok: false,
36
36
  };
37
37
  }
38
- const repoRoot = repoRootResult.stdout.trim() || workDir;
39
38
 
40
- const dirtyPathsResult = await api.exec("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], {
41
- cwd: repoRoot,
42
- timeout: 5_000,
43
- });
44
- if (dirtyPathsResult.code !== 0) {
39
+ let dirtyPathsOutput: string;
40
+ try {
41
+ dirtyPathsOutput = await git.status(repoRoot, {
42
+ porcelainV1: true,
43
+ untrackedFiles: "all",
44
+ z: true,
45
+ });
46
+ } catch (err) {
45
47
  return {
46
- error: `Unable to inspect git status before starting autoresearch: ${mergeStdoutStderr(dirtyPathsResult).trim() || `exit ${dirtyPathsResult.code}`}`,
48
+ error: `Unable to inspect git status before starting autoresearch: ${err instanceof Error ? err.message : String(err)}`,
47
49
  ok: false,
48
50
  };
49
51
  }
50
52
 
51
53
  const workDirPrefix = await readGitWorkDirPrefix(api, workDir);
52
- const unsafeDirtyPaths = collectUnsafeDirtyPaths(dirtyPathsResult.stdout, workDirPrefix);
54
+ const unsafeDirtyPaths = collectUnsafeDirtyPaths(dirtyPathsOutput, workDirPrefix);
53
55
  const currentBranch = await getCurrentAutoresearchBranch(api, workDir);
54
56
  if (currentBranch) {
55
57
  if (unsafeDirtyPaths.length > 0) {
@@ -66,12 +68,11 @@ export async function ensureAutoresearchBranch(
66
68
  }
67
69
 
68
70
  const branchName = await allocateBranchName(api, workDir, goal);
69
- const checkoutResult = await api.exec("git", ["checkout", "-b", branchName], { cwd: workDir, timeout: 10_000 });
70
- if (checkoutResult.code !== 0) {
71
+ try {
72
+ await git.branch.checkoutNew(workDir, branchName);
73
+ } catch (err) {
71
74
  return {
72
- error:
73
- `Failed to create autoresearch branch ${branchName}: ` +
74
- `${mergeStdoutStderr(checkoutResult).trim() || `exit ${checkoutResult.code}`}`,
75
+ error: `Failed to create autoresearch branch ${branchName}: ${err instanceof Error ? err.message : String(err)}`,
75
76
  ok: false,
76
77
  };
77
78
  }
@@ -109,11 +110,12 @@ export function relativizeGitPathToWorkDir(repoRelativePath: string, workDirPref
109
110
  }
110
111
 
111
112
  async function readGitWorkDirPrefix(api: ExtensionAPI, workDir: string): Promise<string> {
112
- const prefixResult = await api.exec("git", ["rev-parse", "--show-prefix"], { cwd: workDir, timeout: 5_000 });
113
- if (prefixResult.code !== 0) {
113
+ void api;
114
+ try {
115
+ return await git.show.prefix(workDir);
116
+ } catch {
114
117
  return "";
115
118
  }
116
- return prefixResult.stdout.trim();
117
119
  }
118
120
 
119
121
  export function parseDirtyPaths(statusOutput: string): string[] {
@@ -180,11 +182,8 @@ async function allocateBranchName(api: ExtensionAPI, workDir: string, goal: stri
180
182
  }
181
183
 
182
184
  async function branchExists(api: ExtensionAPI, workDir: string, branchName: string): Promise<boolean> {
183
- const result = await api.exec("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], {
184
- cwd: workDir,
185
- timeout: 5_000,
186
- });
187
- return result.code === 0;
185
+ void api;
186
+ return git.ref.exists(workDir, `refs/heads/${branchName}`);
188
187
  }
189
188
 
190
189
  function slugifyGoal(goal: string | null): string {
@@ -204,10 +203,6 @@ function currentDateStamp(): string {
204
203
  return `${year}${month}${day}`;
205
204
  }
206
205
 
207
- function mergeStdoutStderr(result: { stderr: string; stdout: string }): string {
208
- return `${result.stdout}${result.stderr}`;
209
- }
210
-
211
206
  function addDirtyPath(paths: Set<string>, rawPath: string): void {
212
207
  const normalizedPath = normalizeStatusPath(rawPath);
213
208
  if (normalizedPath.length === 0) return;
@@ -7,6 +7,7 @@ import { Type } from "@sinclair/typebox";
7
7
  import type { ToolDefinition } from "../../extensibility/extensions";
8
8
  import type { Theme } from "../../modes/theme/theme";
9
9
  import { replaceTabs, truncateToWidth } from "../../tools/render-utils";
10
+ import * as git from "../../utils/git";
10
11
  import { getAutoresearchFingerprintMismatchError, pathMatchesContractPath } from "../contract";
11
12
  import { getCurrentAutoresearchBranch, parseWorkDirDirtyPaths } from "../git";
12
13
  import {
@@ -493,7 +494,7 @@ function validateObservedStatus(
493
494
  }
494
495
 
495
496
  async function commitKeptExperiment(
496
- options: AutoresearchToolFactoryOptions,
497
+ _options: AutoresearchToolFactoryOptions,
497
498
  workDir: string,
498
499
  state: ExperimentState,
499
500
  experiment: ExperimentResult,
@@ -503,25 +504,15 @@ async function commitKeptExperiment(
503
504
  return { note: "nothing to commit" };
504
505
  }
505
506
 
506
- const addResult = await options.pi.exec("git", ["add", "--all", "--", ...scopeValidation.committablePaths], {
507
- cwd: workDir,
508
- timeout: 10_000,
509
- });
510
- if (addResult.code !== 0) {
507
+ try {
508
+ await git.stage.files(workDir, scopeValidation.committablePaths);
509
+ } catch (err) {
511
510
  return {
512
- error: `git add failed: ${mergeStdoutStderr(addResult).trim() || `exit ${addResult.code}`}`,
511
+ error: `git add failed: ${err instanceof Error ? err.message : String(err)}`,
513
512
  };
514
513
  }
515
514
 
516
- const diffResult = await options.pi.exec(
517
- "git",
518
- ["diff", "--cached", "--quiet", "--", ...scopeValidation.committablePaths],
519
- {
520
- cwd: workDir,
521
- timeout: 10_000,
522
- },
523
- );
524
- if (diffResult.code === 0) {
515
+ if (!(await git.diff.has(workDir, { cached: true, files: scopeValidation.committablePaths }))) {
525
516
  return { note: "nothing to commit" };
526
517
  }
527
518
 
@@ -533,32 +524,23 @@ async function commitKeptExperiment(
533
524
  payload[name] = value;
534
525
  }
535
526
  const commitMessage = `${experiment.description}\n\nResult: ${JSON.stringify(payload)}`;
536
- const commitResult = await options.pi.exec(
537
- "git",
538
- ["commit", "-m", commitMessage, "--", ...scopeValidation.committablePaths],
539
- {
540
- cwd: workDir,
541
- timeout: 10_000,
542
- },
543
- );
544
- if (commitResult.code !== 0) {
527
+ let commitResultText = "";
528
+ try {
529
+ const commitResult = await git.commit(workDir, commitMessage, {
530
+ files: scopeValidation.committablePaths,
531
+ });
532
+ commitResultText = mergeStdoutStderr(commitResult);
533
+ } catch (err) {
545
534
  return {
546
- error: `git commit failed: ${mergeStdoutStderr(commitResult).trim() || `exit ${commitResult.code}`}`,
535
+ error: `git commit failed: ${err instanceof Error ? err.message : String(err)}`,
547
536
  };
548
537
  }
549
538
 
550
- const revParseResult = await options.pi.exec("git", ["rev-parse", "--short=7", "HEAD"], {
551
- cwd: workDir,
552
- timeout: 5_000,
553
- });
554
- const newCommit = revParseResult.stdout.trim();
539
+ const newCommit = (await git.head.short(workDir, 7)) ?? "";
555
540
  if (newCommit.length >= 7) {
556
541
  experiment.commit = newCommit;
557
542
  }
558
- const summaryLine =
559
- mergeStdoutStderr(commitResult)
560
- .split("\n")
561
- .find(line => line.trim().length > 0) ?? "committed";
543
+ const summaryLine = commitResultText.split("\n").find(line => line.trim().length > 0) ?? "committed";
562
544
  return { note: summaryLine.trim() };
563
545
  }
564
546
 
@@ -567,44 +549,46 @@ async function revertFailedExperiment(
567
549
  workDir: string,
568
550
  ): Promise<KeepCommitResult> {
569
551
  const preservedFiles = preserveAutoresearchFiles(workDir);
570
- const restoreResult = await options.pi.exec(
571
- "git",
572
- ["restore", "--source=HEAD", "--staged", "--worktree", "--", "."],
573
- { cwd: workDir, timeout: 10_000 },
574
- );
575
- const cleanResult = await options.pi.exec("git", ["clean", "-fd", "--", "."], { cwd: workDir, timeout: 10_000 });
576
- const cleanIgnoredResult = await options.pi.exec("git", ["clean", "-fdX", "--", "."], {
577
- cwd: workDir,
578
- timeout: 10_000,
579
- });
580
- restoreAutoresearchFiles(preservedFiles);
581
- if (restoreResult.code !== 0) {
552
+ try {
553
+ await git.restore(workDir, { files: ["."], source: "HEAD", staged: true, worktree: true });
554
+ } catch (err) {
555
+ restoreAutoresearchFiles(preservedFiles);
582
556
  return {
583
- error: `git restore failed: ${mergeStdoutStderr(restoreResult).trim() || `exit ${restoreResult.code}`}`,
557
+ error: `git restore failed: ${err instanceof Error ? err.message : String(err)}`,
584
558
  };
585
559
  }
586
- if (cleanResult.code !== 0) {
560
+ try {
561
+ await git.clean(workDir, { paths: ["."] });
562
+ } catch (err) {
563
+ restoreAutoresearchFiles(preservedFiles);
587
564
  return {
588
- error: `git clean failed: ${mergeStdoutStderr(cleanResult).trim() || `exit ${cleanResult.code}`}`,
565
+ error: `git clean failed: ${err instanceof Error ? err.message : String(err)}`,
589
566
  };
590
567
  }
591
- if (cleanIgnoredResult.code !== 0) {
568
+ try {
569
+ await git.clean(workDir, { ignoredOnly: true, paths: ["."] });
570
+ } catch (err) {
571
+ restoreAutoresearchFiles(preservedFiles);
592
572
  return {
593
- error: `git clean -X failed: ${mergeStdoutStderr(cleanIgnoredResult).trim() || `exit ${cleanIgnoredResult.code}`}`,
573
+ error: `git clean -X failed: ${err instanceof Error ? err.message : String(err)}`,
594
574
  };
595
575
  }
596
- const dirtyCheckResult = await options.pi.exec(
597
- "git",
598
- ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--", "."],
599
- { cwd: workDir, timeout: 10_000 },
600
- );
601
- if (dirtyCheckResult.code !== 0) {
576
+ restoreAutoresearchFiles(preservedFiles);
577
+ let dirtyStatus = "";
578
+ try {
579
+ dirtyStatus = await git.status(workDir, {
580
+ pathspecs: ["."],
581
+ porcelainV1: true,
582
+ untrackedFiles: "all",
583
+ z: true,
584
+ });
585
+ } catch (err) {
602
586
  return {
603
- error: `git status failed after cleanup: ${mergeStdoutStderr(dirtyCheckResult).trim() || `exit ${dirtyCheckResult.code}`}`,
587
+ error: `git status failed after cleanup: ${err instanceof Error ? err.message : String(err)}`,
604
588
  };
605
589
  }
606
590
  const workDirPrefix = await readGitWorkDirPrefix(options, workDir);
607
- const remainingDirtyPaths = parseWorkDirDirtyPaths(dirtyCheckResult.stdout, workDirPrefix).filter(
591
+ const remainingDirtyPaths = parseWorkDirDirtyPaths(dirtyStatus, workDirPrefix).filter(
608
592
  relativePath => !isAutoresearchLocalStatePath(relativePath),
609
593
  );
610
594
  if (remainingDirtyPaths.length > 0) {
@@ -654,21 +638,21 @@ async function validateKeepPaths(
654
638
  return "Files in Scope is empty for the current segment. Re-run init_experiment after fixing autoresearch.md.";
655
639
  }
656
640
 
657
- const statusResult = await options.pi.exec(
658
- "git",
659
- ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--", "."],
660
- {
661
- cwd: workDir,
662
- timeout: 10_000,
663
- },
664
- );
665
- if (statusResult.code !== 0) {
666
- return `git status failed: ${mergeStdoutStderr(statusResult).trim() || `exit ${statusResult.code}`}`;
641
+ let statusText: string;
642
+ try {
643
+ statusText = await git.status(workDir, {
644
+ pathspecs: ["."],
645
+ porcelainV1: true,
646
+ untrackedFiles: "all",
647
+ z: true,
648
+ });
649
+ } catch (err) {
650
+ return `git status failed: ${err instanceof Error ? err.message : String(err)}`;
667
651
  }
668
652
 
669
653
  const workDirPrefix = await readGitWorkDirPrefix(options, workDir);
670
654
  const committablePaths: string[] = [];
671
- for (const normalizedPath of parseWorkDirDirtyPaths(statusResult.stdout, workDirPrefix)) {
655
+ for (const normalizedPath of parseWorkDirDirtyPaths(statusText, workDirPrefix)) {
672
656
  if (isAutoresearchLocalStatePath(normalizedPath)) {
673
657
  continue;
674
658
  }
@@ -808,9 +792,12 @@ function buildLogText(
808
792
  }
809
793
 
810
794
  async function readGitWorkDirPrefix(options: AutoresearchToolFactoryOptions, workDir: string): Promise<string> {
811
- const prefixResult = await options.pi.exec("git", ["rev-parse", "--show-prefix"], { cwd: workDir, timeout: 5_000 });
812
- if (prefixResult.code !== 0) return "";
813
- return prefixResult.stdout.trim();
795
+ void options;
796
+ try {
797
+ return await git.show.prefix(workDir);
798
+ } catch {
799
+ return "";
800
+ }
814
801
  }
815
802
 
816
803
  function truncateAsiValue(value: ASIData[string]): string {
@@ -2,7 +2,6 @@ import { INTENT_FIELD, type ThinkingLevel } from "@oh-my-pi/pi-agent-core";
2
2
  import type { Api, Model } from "@oh-my-pi/pi-ai";
3
3
  import { Markdown } from "@oh-my-pi/pi-tui";
4
4
  import chalk from "chalk";
5
- import type { ControlledGit } from "../../commit/git";
6
5
  import typesDescriptionPrompt from "../../commit/prompts/types-description.md" with { type: "text" };
7
6
  import type { ModelRegistry } from "../../config/model-registry";
8
7
  import { renderPromptTemplate } from "../../config/prompt-templates";
@@ -18,7 +17,6 @@ import { createCommitTools } from "./tools";
18
17
 
19
18
  export interface CommitAgentInput {
20
19
  cwd: string;
21
- git: ControlledGit;
22
20
  model: Model<Api>;
23
21
  thinkingLevel?: ThinkingLevel;
24
22
  settings: Settings;
@@ -46,7 +44,6 @@ export async function runCommitAgentSession(input: CommitAgentInput): Promise<Co
46
44
  const spawns = "quick_task";
47
45
  const tools = createCommitTools({
48
46
  cwd: input.cwd,
49
- git: input.git,
50
47
  authStorage: input.authStorage,
51
48
  modelRegistry: input.modelRegistry,
52
49
  settings: input.settings,
@@ -4,7 +4,6 @@ import { $env, getProjectDir, isEnoent } from "@oh-my-pi/pi-utils";
4
4
  import { applyChangelogProposals } from "../../commit/changelog";
5
5
  import { detectChangelogBoundaries } from "../../commit/changelog/detect";
6
6
  import { parseUnreleasedSection } from "../../commit/changelog/parse";
7
- import { ControlledGit } from "../../commit/git";
8
7
  import { formatCommitMessage } from "../../commit/message";
9
8
  import { resolvePrimaryModel, resolveSmolModel } from "../../commit/model-selection";
10
9
  import type { CommitCommandArgs, ConventionalAnalysis } from "../../commit/types";
@@ -12,6 +11,7 @@ import { ModelRegistry } from "../../config/model-registry";
12
11
  import { renderPromptTemplate } from "../../config/prompt-templates";
13
12
  import { Settings } from "../../config/settings";
14
13
  import { discoverAuthStorage, discoverContextFiles } from "../../sdk";
14
+ import * as git from "../../utils/git";
15
15
  import { type ExistingChangelogEntries, runCommitAgentSession } from "./agent";
16
16
  import { generateFallbackProposal } from "./fallback";
17
17
  import splitConfirmPrompt from "./prompts/split-confirm.md" with { type: "text" };
@@ -20,25 +20,24 @@ import { computeDependencyOrder } from "./topo-sort";
20
20
  import { detectTrivialChange } from "./trivial";
21
21
 
22
22
  interface CommitExecutionContext {
23
- git: ControlledGit;
23
+ cwd: string;
24
24
  dryRun: boolean;
25
25
  push: boolean;
26
26
  }
27
27
 
28
28
  export async function runAgenticCommit(args: CommitCommandArgs): Promise<void> {
29
29
  const cwd = getProjectDir();
30
- const git = new ControlledGit(cwd);
31
30
  const [settings, authStorage] = await Promise.all([Settings.init({ cwd }), discoverAuthStorage()]);
32
31
 
33
32
  process.stdout.write("● Resolving model...\n");
34
33
  const modelRegistry = new ModelRegistry(authStorage);
35
34
  await modelRegistry.refresh();
36
35
  const stagedFilesPromise = (async () => {
37
- let stagedFiles = await git.getStagedFiles();
36
+ let stagedFiles = await git.diff.changedFiles(cwd, { cached: true });
38
37
  if (stagedFiles.length === 0) {
39
38
  process.stdout.write("No staged changes detected, staging all changes...\n");
40
- await git.stageAll();
41
- stagedFiles = await git.getStagedFiles();
39
+ await git.stage.files(cwd);
40
+ stagedFiles = await git.diff.changedFiles(cwd, { cached: true });
42
41
  }
43
42
  return stagedFiles;
44
43
  })();
@@ -66,8 +65,8 @@ export async function runAgenticCommit(args: CommitCommandArgs): Promise<void> {
66
65
  const [changelogBoundaries, contextFiles, numstat, diff] = await Promise.all([
67
66
  args.noChangelog ? [] : detectChangelogBoundaries(cwd, stagedFiles),
68
67
  discoverContextFiles(cwd),
69
- git.getNumstat(true),
70
- git.getDiff(true),
68
+ git.diff.numstat(cwd, { cached: true }),
69
+ git.diff(cwd, { cached: true }),
71
70
  ]);
72
71
  const changelogTargets = changelogBoundaries.map(boundary => boundary.changelogPath);
73
72
  if (!args.noChangelog) {
@@ -93,7 +92,7 @@ export async function runAgenticCommit(args: CommitCommandArgs): Promise<void> {
93
92
  if (forceFallback) {
94
93
  process.stdout.write("● Forcing fallback commit generation...\n");
95
94
  const fallbackProposal = generateFallbackProposal(numstat);
96
- await runSingleCommit(fallbackProposal, { git, dryRun: args.dryRun, push: args.push });
95
+ await runSingleCommit(fallbackProposal, { cwd, dryRun: args.dryRun, push: args.push });
97
96
  return;
98
97
  }
99
98
 
@@ -110,7 +109,7 @@ export async function runAgenticCommit(args: CommitCommandArgs): Promise<void> {
110
109
  summary: trivialChange.summary,
111
110
  warnings: [],
112
111
  };
113
- await runSingleCommit(trivialProposal, { git, dryRun: args.dryRun, push: args.push });
112
+ await runSingleCommit(trivialProposal, { cwd, dryRun: args.dryRun, push: args.push });
114
113
  return;
115
114
  }
116
115
 
@@ -129,7 +128,6 @@ export async function runAgenticCommit(args: CommitCommandArgs): Promise<void> {
129
128
  try {
130
129
  commitState = await runCommitAgentSession({
131
130
  cwd,
132
- git,
133
131
  model: agentModel,
134
132
  thinkingLevel: agentThinkingLevel,
135
133
  settings,
@@ -169,7 +167,6 @@ export async function runAgenticCommit(args: CommitCommandArgs): Promise<void> {
169
167
  }
170
168
  process.stdout.write("● Applying changelog entries...\n");
171
169
  const updated = await applyChangelogProposals({
172
- git,
173
170
  cwd,
174
171
  proposals: commitState.changelogProposal.entries,
175
172
  dryRun: args.dryRun,
@@ -188,13 +185,13 @@ export async function runAgenticCommit(args: CommitCommandArgs): Promise<void> {
188
185
  }
189
186
 
190
187
  if (commitState.proposal) {
191
- await runSingleCommit(commitState.proposal, { git, dryRun: args.dryRun, push: args.push });
188
+ await runSingleCommit(commitState.proposal, { cwd, dryRun: args.dryRun, push: args.push });
192
189
  return;
193
190
  }
194
191
 
195
192
  if (commitState.splitProposal) {
196
193
  await runSplitCommit(commitState.splitProposal, {
197
- git,
194
+ cwd,
198
195
  dryRun: args.dryRun,
199
196
  push: args.push,
200
197
  additionalFiles: updatedChangelogFiles,
@@ -215,10 +212,10 @@ async function runSingleCommit(proposal: CommitProposal, ctx: CommitExecutionCon
215
212
  process.stdout.write(`${commitMessage}\n`);
216
213
  return;
217
214
  }
218
- await ctx.git.commit(commitMessage);
215
+ await git.commit(ctx.cwd, commitMessage);
219
216
  process.stdout.write("Commit created.\n");
220
217
  if (ctx.push) {
221
- await ctx.git.push();
218
+ await git.push(ctx.cwd);
222
219
  process.stdout.write("Pushed to remote.\n");
223
220
  }
224
221
  }
@@ -233,7 +230,7 @@ async function runSplitCommit(
233
230
  if (ctx.additionalFiles && ctx.additionalFiles.length > 0) {
234
231
  appendFilesToLastCommit(plan, ctx.additionalFiles);
235
232
  }
236
- const stagedFiles = await ctx.git.getStagedFiles();
233
+ const stagedFiles = await git.diff.changedFiles(ctx.cwd, { cached: true });
237
234
  const plannedFiles = new Set(plan.commits.flatMap(commit => commit.changes.map(change => change.path)));
238
235
  const missingFiles = stagedFiles.filter(file => !plannedFiles.has(file));
239
236
  if (missingFiles.length > 0) {
@@ -270,10 +267,10 @@ async function runSplitCommit(
270
267
  throw new Error(order.error);
271
268
  }
272
269
 
273
- await ctx.git.resetStaging();
270
+ await git.stage.reset(ctx.cwd);
274
271
  for (const commitIndex of order) {
275
272
  const commit = plan.commits[commitIndex];
276
- await ctx.git.stageHunks(commit.changes);
273
+ await git.stage.hunks(ctx.cwd, commit.changes);
277
274
  const analysis: ConventionalAnalysis = {
278
275
  type: commit.type,
279
276
  scope: commit.scope,
@@ -281,12 +278,12 @@ async function runSplitCommit(
281
278
  issueRefs: commit.issueRefs,
282
279
  };
283
280
  const message = formatCommitMessage(analysis, commit.summary);
284
- await ctx.git.commit(message);
285
- await ctx.git.resetStaging();
281
+ await git.commit(ctx.cwd, message);
282
+ await git.stage.reset(ctx.cwd);
286
283
  }
287
284
  process.stdout.write("Split commits created.\n");
288
285
  if (ctx.push) {
289
- await ctx.git.push();
286
+ await git.push(ctx.cwd);
290
287
  process.stdout.write("Pushed to remote.\n");
291
288
  }
292
289
  }
@@ -1,7 +1,7 @@
1
1
  import { Type } from "@sinclair/typebox";
2
2
  import type { CommitAgentState } from "../../../commit/agentic/state";
3
- import type { ControlledGit } from "../../../commit/git";
4
3
  import type { CustomTool } from "../../../extensibility/custom-tools/types";
4
+ import * as git from "../../../utils/git";
5
5
 
6
6
  const TARGET_TOKENS = 30000;
7
7
  const CHARS_PER_TOKEN = 4;
@@ -136,10 +136,7 @@ const gitFileDiffSchema = Type.Object({
136
136
  staged: Type.Optional(Type.Boolean({ description: "Use staged changes (default: true)" })),
137
137
  });
138
138
 
139
- export function createGitFileDiffTool(
140
- git: ControlledGit,
141
- state: CommitAgentState,
142
- ): CustomTool<typeof gitFileDiffSchema> {
139
+ export function createGitFileDiffTool(cwd: string, state: CommitAgentState): CustomTool<typeof gitFileDiffSchema> {
143
140
  return {
144
141
  name: "git_file_diff",
145
142
  label: "Git File Diff",
@@ -167,7 +164,7 @@ export function createGitFileDiffTool(
167
164
 
168
165
  if (uncachedFiles.length > 0) {
169
166
  for (const file of uncachedFiles) {
170
- const diff = await git.getDiffForFiles([file], staged);
167
+ const diff = await git.diff(cwd, { cached: staged, files: [file] });
171
168
  if (diff) {
172
169
  diffs.set(file, diff);
173
170
  state.diffCache.set(cacheKey(file), diff);