@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
@@ -1,7 +1,7 @@
1
1
  import { Type } from "@sinclair/typebox";
2
- import type { ControlledGit } from "../../../commit/git";
3
2
  import type { DiffHunk, FileHunks } from "../../../commit/types";
4
3
  import type { CustomTool } from "../../../extensibility/custom-tools/types";
4
+ import * as git from "../../../utils/git";
5
5
 
6
6
  const gitHunkSchema = Type.Object({
7
7
  file: Type.String({ description: "File path" }),
@@ -15,7 +15,7 @@ function selectHunks(fileHunks: FileHunks, requested?: number[]): DiffHunk[] {
15
15
  return fileHunks.hunks.filter(hunk => wanted.has(hunk.index + 1));
16
16
  }
17
17
 
18
- export function createGitHunkTool(git: ControlledGit): CustomTool<typeof gitHunkSchema> {
18
+ export function createGitHunkTool(cwd: string): CustomTool<typeof gitHunkSchema> {
19
19
  return {
20
20
  name: "git_hunk",
21
21
  label: "Git Hunk",
@@ -23,7 +23,7 @@ export function createGitHunkTool(git: ControlledGit): CustomTool<typeof gitHunk
23
23
  parameters: gitHunkSchema,
24
24
  async execute(_toolCallId, params) {
25
25
  const staged = params.staged ?? true;
26
- const hunks = await git.getHunks([params.file], staged);
26
+ const hunks = await git.diff.hunks(cwd, [params.file], { cached: staged });
27
27
  const fileHunks = hunks.find(entry => entry.filename === params.file) ?? {
28
28
  filename: params.file,
29
29
  isBinary: false,
@@ -1,8 +1,8 @@
1
1
  import { Type } from "@sinclair/typebox";
2
2
  import type { CommitAgentState, GitOverviewSnapshot } from "../../../commit/agentic/state";
3
3
  import { extractScopeCandidates } from "../../../commit/analysis/scope";
4
- import type { ControlledGit } from "../../../commit/git";
5
4
  import type { CustomTool } from "../../../extensibility/custom-tools/types";
5
+ import * as git from "../../../utils/git";
6
6
 
7
7
  const EXCLUDED_LOCK_FILES = new Set([
8
8
  "Cargo.lock",
@@ -47,10 +47,7 @@ const gitOverviewSchema = Type.Object({
47
47
  include_untracked: Type.Optional(Type.Boolean({ description: "Include untracked files when staged=false" })),
48
48
  });
49
49
 
50
- export function createGitOverviewTool(
51
- git: ControlledGit,
52
- state: CommitAgentState,
53
- ): CustomTool<typeof gitOverviewSchema> {
50
+ export function createGitOverviewTool(cwd: string, state: CommitAgentState): CustomTool<typeof gitOverviewSchema> {
54
51
  return {
55
52
  name: "git_overview",
56
53
  label: "Git Overview",
@@ -58,13 +55,13 @@ export function createGitOverviewTool(
58
55
  parameters: gitOverviewSchema,
59
56
  async execute(_toolCallId, params) {
60
57
  const staged = params.staged ?? true;
61
- const allFiles = staged ? await git.getStagedFiles() : await git.getChangedFiles(false);
58
+ const allFiles = await git.diff.changedFiles(cwd, { cached: staged });
62
59
  const { filtered: files, excluded } = filterExcludedFiles(allFiles);
63
- const stat = await git.getStat(staged);
64
- const allNumstat = await git.getNumstat(staged);
60
+ const stat = await git.diff(cwd, { stat: true, cached: staged });
61
+ const allNumstat = await git.diff.numstat(cwd, { cached: staged });
65
62
  const numstat = allNumstat.filter(entry => !isExcludedFile(entry.path));
66
63
  const scopeResult = extractScopeCandidates(numstat);
67
- const untrackedFiles = !staged && params.include_untracked ? await git.getUntrackedFiles() : undefined;
64
+ const untrackedFiles = !staged && params.include_untracked ? await git.ls.untracked(cwd) : undefined;
68
65
  const snapshot: GitOverviewSnapshot = {
69
66
  files,
70
67
  stat,
@@ -1,5 +1,4 @@
1
1
  import type { CommitAgentState } from "../../../commit/agentic/state";
2
- import type { ControlledGit } from "../../../commit/git";
3
2
  import type { ModelRegistry } from "../../../config/model-registry";
4
3
  import type { Settings } from "../../../config/settings";
5
4
  import type { CustomTool } from "../../../extensibility/custom-tools/types";
@@ -15,7 +14,6 @@ import { createSplitCommitTool } from "./split-commit";
15
14
 
16
15
  export interface CommitToolOptions {
17
16
  cwd: string;
18
- git: ControlledGit;
19
17
  authStorage: AuthStorage;
20
18
  modelRegistry: ModelRegistry;
21
19
  settings: Settings;
@@ -27,10 +25,10 @@ export interface CommitToolOptions {
27
25
 
28
26
  export function createCommitTools(options: CommitToolOptions): Array<CustomTool<any, any>> {
29
27
  const tools: Array<CustomTool<any, any>> = [
30
- createGitOverviewTool(options.git, options.state),
31
- createGitFileDiffTool(options.git, options.state),
32
- createGitHunkTool(options.git),
33
- createRecentCommitsTool(options.git),
28
+ createGitOverviewTool(options.cwd, options.state),
29
+ createGitFileDiffTool(options.cwd, options.state),
30
+ createGitHunkTool(options.cwd),
31
+ createRecentCommitsTool(options.cwd),
34
32
  ];
35
33
 
36
34
  if (options.enableAnalyzeFiles ?? true) {
@@ -48,8 +46,8 @@ export function createCommitTools(options: CommitToolOptions): Array<CustomTool<
48
46
 
49
47
  tools.push(
50
48
  createProposeChangelogTool(options.state, options.changelogTargets),
51
- createProposeCommitTool(options.git, options.state),
52
- createSplitCommitTool(options.git, options.state, options.changelogTargets),
49
+ createProposeCommitTool(options.cwd, options.state),
50
+ createSplitCommitTool(options.cwd, options.state, options.changelogTargets),
53
51
  );
54
52
 
55
53
  return tools;
@@ -9,9 +9,9 @@ import {
9
9
  validateTypeConsistency,
10
10
  } from "../../../commit/agentic/validation";
11
11
  import { validateAnalysis } from "../../../commit/analysis/validation";
12
- import type { ControlledGit } from "../../../commit/git";
13
12
  import type { CommitType, ConventionalAnalysis, ConventionalDetail } from "../../../commit/types";
14
13
  import type { CustomTool } from "../../../extensibility/custom-tools/types";
14
+ import * as git from "../../../utils/git";
15
15
  import { commitTypeSchema, detailSchema } from "./schemas.js";
16
16
 
17
17
  const proposeCommitSchema = Type.Object({
@@ -49,10 +49,7 @@ function normalizeDetails(
49
49
  }));
50
50
  }
51
51
 
52
- export function createProposeCommitTool(
53
- git: ControlledGit,
54
- state: CommitAgentState,
55
- ): CustomTool<typeof proposeCommitSchema> {
52
+ export function createProposeCommitTool(cwd: string, state: CommitAgentState): CustomTool<typeof proposeCommitSchema> {
56
53
  return {
57
54
  name: "propose_commit",
58
55
  label: "Propose Commit",
@@ -72,8 +69,8 @@ export function createProposeCommitTool(
72
69
 
73
70
  const summaryValidation = validateSummaryRules(summary);
74
71
  const analysisValidation = validateAnalysis(analysis);
75
- const stagedFiles = state.overview?.files ?? (await git.getStagedFiles());
76
- const diffText = state.diffText ?? (await git.getDiff(true));
72
+ const stagedFiles = state.overview?.files ?? (await git.diff.changedFiles(cwd, { cached: true }));
73
+ const diffText = state.diffText ?? (await git.diff(cwd, { cached: true }));
77
74
  const typeValidation = validateTypeConsistency(params.type, stagedFiles, {
78
75
  diffText,
79
76
  summary,
@@ -1,6 +1,6 @@
1
1
  import { Type } from "@sinclair/typebox";
2
- import type { ControlledGit } from "../../../commit/git";
3
2
  import type { CustomTool } from "../../../extensibility/custom-tools/types";
3
+ import * as git from "../../../utils/git";
4
4
 
5
5
  const recentCommitsSchema = Type.Object({
6
6
  count: Type.Optional(Type.Number({ description: "Number of commits to fetch", minimum: 1, maximum: 50 })),
@@ -25,7 +25,7 @@ function extractScope(subject: string): string | null {
25
25
  return match?.[1]?.trim() ?? null;
26
26
  }
27
27
 
28
- export function createRecentCommitsTool(git: ControlledGit): CustomTool<typeof recentCommitsSchema> {
28
+ export function createRecentCommitsTool(cwd: string): CustomTool<typeof recentCommitsSchema> {
29
29
  return {
30
30
  name: "recent_commits",
31
31
  label: "Recent Commits",
@@ -33,7 +33,7 @@ export function createRecentCommitsTool(git: ControlledGit): CustomTool<typeof r
33
33
  parameters: recentCommitsSchema,
34
34
  async execute(_toolCallId, params) {
35
35
  const count = params.count ?? 8;
36
- const commits = await git.getRecentCommits(count);
36
+ const commits = await git.log.subjects(cwd, count);
37
37
  const verbs: Record<string, number> = {};
38
38
  const scopes: Record<string, number> = {};
39
39
  const lengths: number[] = [];
@@ -10,9 +10,9 @@ import {
10
10
  validateTypeConsistency,
11
11
  } from "../../../commit/agentic/validation";
12
12
  import { validateScope } from "../../../commit/analysis/validation";
13
- import type { ControlledGit } from "../../../commit/git";
14
13
  import type { ConventionalDetail } from "../../../commit/types";
15
14
  import type { CustomTool } from "../../../extensibility/custom-tools/types";
15
+ import * as git from "../../../utils/git";
16
16
  import { commitTypeSchema, detailSchema } from "./schemas.js";
17
17
 
18
18
  const hunkSelectorSchema = Type.Union([
@@ -64,7 +64,7 @@ function normalizeDetails(
64
64
  }
65
65
 
66
66
  export function createSplitCommitTool(
67
- git: ControlledGit,
67
+ cwd: string,
68
68
  state: CommitAgentState,
69
69
  changelogTargets: string[],
70
70
  ): CustomTool<typeof splitCommitSchema> {
@@ -74,13 +74,13 @@ export function createSplitCommitTool(
74
74
  description: "Propose multiple atomic commits for unrelated changes.",
75
75
  parameters: splitCommitSchema,
76
76
  async execute(_toolCallId, params) {
77
- const stagedFiles = state.overview?.files ?? (await git.getStagedFiles());
77
+ const stagedFiles = state.overview?.files ?? (await git.diff.changedFiles(cwd, { cached: true }));
78
78
  const stagedSet = new Set(stagedFiles);
79
79
  const changelogSet = new Set(changelogTargets);
80
80
  const usedFiles = new Set<string>();
81
81
  const errors: string[] = [];
82
82
  const warnings: string[] = [];
83
- const diffText = await git.getDiff(true);
83
+ const diffText = await git.diff(cwd, { cached: true });
84
84
 
85
85
  const commits: SplitCommitGroup[] = params.commits.map((commit, index) => {
86
86
  const scope = commit.scope?.trim() || null;
@@ -2,8 +2,8 @@ import * as path from "node:path";
2
2
  import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
3
3
  import type { Api, Model } from "@oh-my-pi/pi-ai";
4
4
  import { logger } from "@oh-my-pi/pi-utils";
5
- import type { ControlledGit } from "../../commit/git";
6
5
  import { CHANGELOG_CATEGORIES } from "../../commit/types";
6
+ import * as git from "../../utils/git";
7
7
  import { detectChangelogBoundaries } from "./detect";
8
8
  import { generateChangelogEntries } from "./generate";
9
9
  import { parseUnreleasedSection } from "./parse";
@@ -13,7 +13,6 @@ const CHANGELOG_SECTIONS = CHANGELOG_CATEGORIES;
13
13
  const DEFAULT_MAX_DIFF_CHARS = 120_000;
14
14
 
15
15
  export interface ChangelogFlowInput {
16
- git: ControlledGit;
17
16
  cwd: string;
18
17
  model: Model<Api>;
19
18
  apiKey: string;
@@ -25,7 +24,6 @@ export interface ChangelogFlowInput {
25
24
  }
26
25
 
27
26
  export interface ChangelogProposalInput {
28
- git: ControlledGit;
29
27
  cwd: string;
30
28
  proposals: Array<{
31
29
  path: string;
@@ -40,7 +38,6 @@ export interface ChangelogProposalInput {
40
38
  * Update CHANGELOG.md entries for staged changes.
41
39
  */
42
40
  export async function runChangelogFlow({
43
- git,
44
41
  cwd,
45
42
  model,
46
43
  apiKey,
@@ -58,9 +55,9 @@ export async function runChangelogFlow({
58
55
  const updated: string[] = [];
59
56
  for (const boundary of boundaries) {
60
57
  onProgress?.(`Generating entries for ${boundary.changelogPath}…`);
61
- const diff = await git.getDiffForFiles(boundary.files, true);
58
+ const diff = await git.diff(cwd, { cached: true, files: boundary.files });
62
59
  if (!diff.trim()) continue;
63
- const stat = await git.getStatForFiles(boundary.files, true);
60
+ const stat = await git.diff(cwd, { stat: true, cached: true, files: boundary.files });
64
61
  const diffForPrompt = truncateDiff(diff, maxDiffChars ?? DEFAULT_MAX_DIFF_CHARS);
65
62
  const changelogContent = await Bun.file(boundary.changelogPath).text();
66
63
  let unreleased: { startLine: number; endLine: number; entries: Record<string, string[]> };
@@ -87,7 +84,7 @@ export async function runChangelogFlow({
87
84
  const updatedContent = applyChangelogEntries(changelogContent, unreleased, generated.entries);
88
85
  if (!dryRun) {
89
86
  await Bun.write(boundary.changelogPath, updatedContent);
90
- await git.stageFiles([path.relative(cwd, boundary.changelogPath)]);
87
+ await git.stage.files(cwd, [path.relative(cwd, boundary.changelogPath)]);
91
88
  }
92
89
  updated.push(boundary.changelogPath);
93
90
  }
@@ -99,7 +96,6 @@ export async function runChangelogFlow({
99
96
  * Apply changelog entries provided by the commit agent.
100
97
  */
101
98
  export async function applyChangelogProposals({
102
- git,
103
99
  cwd,
104
100
  proposals,
105
101
  dryRun,
@@ -132,7 +128,7 @@ export async function applyChangelogProposals({
132
128
  const updatedContent = applyChangelogEntries(changelogContent, unreleased, normalized, normalizedDeletions);
133
129
  if (!dryRun) {
134
130
  await Bun.write(proposal.path, updatedContent);
135
- await git.stageFiles([path.relative(cwd, proposal.path)]);
131
+ await git.stage.files(cwd, [path.relative(cwd, proposal.path)]);
136
132
  }
137
133
  updated.push(proposal.path);
138
134
  }
@@ -7,6 +7,7 @@ import { renderPromptTemplate } from "../config/prompt-templates";
7
7
  import { Settings } from "../config/settings";
8
8
  import { discoverAuthStorage } from "../sdk";
9
9
  import { loadProjectContextFiles } from "../system-prompt";
10
+ import * as git from "../utils/git";
10
11
  import { runAgenticCommit } from "./agentic";
11
12
  import {
12
13
  extractScopeCandidates,
@@ -16,7 +17,6 @@ import {
16
17
  validateSummary,
17
18
  } from "./analysis";
18
19
  import { runChangelogFlow } from "./changelog";
19
- import { ControlledGit } from "./git";
20
20
  import { runMapReduceAnalysis, shouldUseMapReduce } from "./map-reduce";
21
21
  import { formatCommitMessage } from "./message";
22
22
  import { resolvePrimaryModel, resolveSmolModel } from "./model-selection";
@@ -57,12 +57,11 @@ async function runLegacyCommitCommand(args: CommitCommandArgs): Promise<void> {
57
57
  thinkingLevel: smolThinkingLevel,
58
58
  } = await resolveSmolModel(settings, modelRegistry, primaryModel, primaryApiKey);
59
59
 
60
- const git = new ControlledGit(cwd);
61
- let stagedFiles = await git.getStagedFiles();
60
+ let stagedFiles = await git.diff.changedFiles(cwd, { cached: true });
62
61
  if (stagedFiles.length === 0) {
63
62
  process.stdout.write("No staged changes detected, staging all changes...\n");
64
- await git.stageAll();
65
- stagedFiles = await git.getStagedFiles();
63
+ await git.stage.files(cwd);
64
+ stagedFiles = await git.diff.changedFiles(cwd, { cached: true });
66
65
  }
67
66
  if (stagedFiles.length === 0) {
68
67
  process.stderr.write("No changes to commit.\n");
@@ -71,7 +70,6 @@ async function runLegacyCommitCommand(args: CommitCommandArgs): Promise<void> {
71
70
 
72
71
  if (!args.noChangelog) {
73
72
  await runChangelogFlow({
74
- git,
75
73
  cwd,
76
74
  model: primaryModel,
77
75
  apiKey: primaryApiKey,
@@ -82,11 +80,11 @@ async function runLegacyCommitCommand(args: CommitCommandArgs): Promise<void> {
82
80
  });
83
81
  }
84
82
 
85
- const diff = await git.getDiff(true);
86
- const stat = await git.getStat(true);
87
- const numstat = await git.getNumstat(true);
83
+ const diff = await git.diff(cwd, { cached: true });
84
+ const stat = await git.diff(cwd, { stat: true, cached: true });
85
+ const numstat = await git.diff.numstat(cwd, { cached: true });
88
86
  const scopeCandidates = extractScopeCandidates(numstat).scopeCandidates;
89
- const recentCommits = await git.getRecentCommits(RECENT_COMMITS_COUNT);
87
+ const recentCommits = await git.log.subjects(cwd, RECENT_COMMITS_COUNT);
90
88
  const contextFiles = await loadProjectContextFiles({ cwd });
91
89
  const formattedContextFiles = contextFiles.map(file => ({
92
90
  path: path.relative(cwd, file.path),
@@ -131,10 +129,10 @@ async function runLegacyCommitCommand(args: CommitCommandArgs): Promise<void> {
131
129
  return;
132
130
  }
133
131
 
134
- await git.commit(commitMessage);
132
+ await git.commit(cwd, commitMessage);
135
133
  process.stdout.write("Commit created.\n");
136
134
  if (args.push) {
137
- await git.push();
135
+ await git.push(cwd);
138
136
  process.stdout.write("Pushed to remote.\n");
139
137
  }
140
138
  }
@@ -37,6 +37,7 @@ interface AppKeybindings {
37
37
  "app.session.tree": true;
38
38
  "app.session.fork": true;
39
39
  "app.session.resume": true;
40
+ "app.session.observe": true;
40
41
  "app.session.togglePath": true;
41
42
  "app.session.toggleSort": true;
42
43
  "app.session.rename": true;
@@ -144,6 +145,10 @@ export const KEYBINDINGS = {
144
145
  defaultKeys: [],
145
146
  description: "Resume session",
146
147
  },
148
+ "app.session.observe": {
149
+ defaultKeys: "ctrl+s",
150
+ description: "Observe subagent sessions",
151
+ },
147
152
  "app.session.togglePath": {
148
153
  defaultKeys: "ctrl+p",
149
154
  description: "Toggle session path display",
@@ -214,6 +219,7 @@ const KEYBINDING_NAME_MIGRATIONS = {
214
219
  tree: "app.session.tree",
215
220
  fork: "app.session.fork",
216
221
  resume: "app.session.resume",
222
+ observeSessions: "app.session.observe",
217
223
  toggleSTT: "app.stt.toggle",
218
224
  // TUI editor (old names for backward compatibility)
219
225
  cursorUp: "tui.editor.cursorUp",
@@ -260,9 +266,6 @@ function isLegacyKeybindingName(key: string): key is keyof typeof KEYBINDING_NAM
260
266
  return key in KEYBINDING_NAME_MIGRATIONS;
261
267
  }
262
268
 
263
- /**
264
- * Normalize input to KeybindingsConfig, validating types.
265
- */
266
269
  function toKeybindingsConfig(value: unknown): KeybindingsConfig {
267
270
  if (typeof value !== "object" || value === null) {
268
271
  return {};
@@ -270,15 +273,13 @@ function toKeybindingsConfig(value: unknown): KeybindingsConfig {
270
273
 
271
274
  const config: KeybindingsConfig = {};
272
275
  for (const [key, val] of Object.entries(value)) {
273
- // Allow undefined, string (KeyId), or array of strings
274
276
  if (val === undefined) {
275
277
  config[key] = undefined;
276
278
  } else if (typeof val === "string") {
277
279
  config[key] = val as KeyId;
278
280
  } else if (Array.isArray(val) && val.every(v => typeof v === "string")) {
279
- config[key] = val as string[] as KeyId[];
281
+ config[key] = val as KeyId[];
280
282
  }
281
- // Silently skip invalid entries
282
283
  }
283
284
  return config;
284
285
  }
@@ -194,6 +194,15 @@ export const SETTINGS_SCHEMA = {
194
194
  // ────────────────────────────────────────────────────────────────────────
195
195
  lastChangelogVersion: { type: "string", default: undefined },
196
196
 
197
+ autoResume: {
198
+ type: "boolean",
199
+ default: false,
200
+ ui: {
201
+ tab: "interaction",
202
+ label: "Auto Resume",
203
+ description: "Automatically resume the most recent session in the current directory",
204
+ },
205
+ },
197
206
  shellPath: { type: "string", default: undefined },
198
207
 
199
208
  extensions: { type: "array", default: EMPTY_STRING_ARRAY },
@@ -797,6 +806,38 @@ export const SETTINGS_SCHEMA = {
797
806
 
798
807
  "compaction.remoteEndpoint": { type: "string", default: undefined },
799
808
 
809
+ // Idle compaction
810
+ "compaction.idleEnabled": {
811
+ type: "boolean",
812
+ default: false,
813
+ ui: {
814
+ tab: "context",
815
+ label: "Idle Compaction",
816
+ description: "Compact context while idle when token count exceeds threshold",
817
+ },
818
+ },
819
+
820
+ "compaction.idleThresholdTokens": {
821
+ type: "number",
822
+ default: 200000,
823
+ ui: {
824
+ tab: "context",
825
+ label: "Idle Compaction Threshold",
826
+ description: "Token count above which idle compaction triggers",
827
+ submenu: true,
828
+ },
829
+ },
830
+
831
+ "compaction.idleTimeoutSeconds": {
832
+ type: "number",
833
+ default: 300,
834
+ ui: {
835
+ tab: "context",
836
+ label: "Idle Compaction Delay",
837
+ description: "Seconds to wait while idle before compacting",
838
+ submenu: true,
839
+ },
840
+ },
800
841
  // Branch summaries
801
842
  "branchSummary.enabled": {
802
843
  type: "boolean",
@@ -1701,6 +1742,9 @@ export interface CompactionSettings {
1701
1742
  autoContinue: boolean;
1702
1743
  remoteEnabled: boolean;
1703
1744
  remoteEndpoint: string | undefined;
1745
+ idleEnabled: boolean;
1746
+ idleThresholdTokens: number;
1747
+ idleTimeoutSeconds: number;
1704
1748
  }
1705
1749
 
1706
1750
  export interface ContextPromotionSettings {
@@ -2,26 +2,14 @@ import { renderPromptTemplate } from "../../../../config/prompt-templates";
2
2
  import type { CustomCommand, CustomCommandAPI } from "../../../../extensibility/custom-commands/types";
3
3
  import type { HookCommandContext } from "../../../../extensibility/hooks/types";
4
4
  import ciGreenRequestTemplate from "../../../../prompts/ci-green-request.md" with { type: "text" };
5
+ import * as git from "../../../../utils/git";
5
6
 
6
7
  async function getHeadTag(api: CustomCommandAPI): Promise<string | undefined> {
7
- const result = await api.exec("git", [
8
- "for-each-ref",
9
- "--points-at",
10
- "HEAD",
11
- "--sort=-version:refname",
12
- "--format=%(refname:strip=2)",
13
- "refs/tags",
14
- ]);
15
-
16
- if (result.code !== 0 || result.killed) {
8
+ try {
9
+ return (await git.ref.tags(api.cwd))[0];
10
+ } catch {
17
11
  return undefined;
18
12
  }
19
-
20
- const tag = result.stdout
21
- .split("\n")
22
- .map(line => line.trim())
23
- .find(Boolean);
24
- return tag || undefined;
25
13
  }
26
14
 
27
15
  export class GreenCommand implements CustomCommand {
@@ -15,6 +15,7 @@ import { renderPromptTemplate } from "../../../../config/prompt-templates";
15
15
  import type { CustomCommand, CustomCommandAPI } from "../../../../extensibility/custom-commands/types";
16
16
  import type { HookCommandContext } from "../../../../extensibility/hooks/types";
17
17
  import reviewRequestTemplate from "../../../../prompts/review-request.md" with { type: "text" };
18
+ import * as git from "../../../../utils/git";
18
19
 
19
20
  // ─────────────────────────────────────────────────────────────────────────────
20
21
  // Types
@@ -258,20 +259,20 @@ export class ReviewCommand implements CustomCommand {
258
259
  if (!baseBranch) return undefined;
259
260
 
260
261
  const currentBranch = await getCurrentBranch(this.api);
261
- const diffResult = await this.api.exec("git", ["diff", `${baseBranch}...${currentBranch}`], {
262
- timeout: 30000,
263
- });
264
- if (diffResult.code !== 0) {
265
- ctx.ui.notify(`Failed to get diff: ${diffResult.stderr}`, "error");
262
+ let diffText: string;
263
+ try {
264
+ diffText = await git.diff(this.api.cwd, { base: `${baseBranch}...${currentBranch}` });
265
+ } catch (err) {
266
+ ctx.ui.notify(`Failed to get diff: ${err instanceof Error ? err.message : String(err)}`, "error");
266
267
  return undefined;
267
268
  }
268
269
 
269
- if (!diffResult.stdout.trim()) {
270
+ if (!diffText.trim()) {
270
271
  ctx.ui.notify(`No changes between ${baseBranch} and ${currentBranch}`, "warning");
271
272
  return undefined;
272
273
  }
273
274
 
274
- const stats = parseDiff(diffResult.stdout);
275
+ const stats = parseDiff(diffText);
275
276
  if (stats.files.length === 0) {
276
277
  ctx.ui.notify("No reviewable files (all changes filtered out)", "warning");
277
278
  return undefined;
@@ -280,7 +281,7 @@ export class ReviewCommand implements CustomCommand {
280
281
  return buildReviewPrompt(
281
282
  `Reviewing changes between \`${baseBranch}\` and \`${currentBranch}\` (PR-style)`,
282
283
  stats,
283
- diffResult.stdout,
284
+ diffText,
284
285
  );
285
286
  }
286
287
 
@@ -292,12 +293,19 @@ export class ReviewCommand implements CustomCommand {
292
293
  return undefined;
293
294
  }
294
295
 
295
- const [unstagedResult, stagedResult] = await Promise.all([
296
- this.api.exec("git", ["diff"], { timeout: 30000 }),
297
- this.api.exec("git", ["diff", "--cached"], { timeout: 30000 }),
298
- ]);
296
+ let unstagedDiff: string;
297
+ let stagedDiff: string;
298
+ try {
299
+ [unstagedDiff, stagedDiff] = await Promise.all([
300
+ git.diff(this.api.cwd),
301
+ git.diff(this.api.cwd, { cached: true }),
302
+ ]);
303
+ } catch (err) {
304
+ ctx.ui.notify(`Failed to get diff: ${err instanceof Error ? err.message : String(err)}`, "error");
305
+ return undefined;
306
+ }
299
307
 
300
- const combinedDiff = [unstagedResult.stdout, stagedResult.stdout].filter(Boolean).join("\n");
308
+ const combinedDiff = [unstagedDiff, stagedDiff].filter(Boolean).join("\n");
301
309
 
302
310
  if (!combinedDiff.trim()) {
303
311
  ctx.ui.notify("No diff content found", "warning");
@@ -327,25 +335,26 @@ export class ReviewCommand implements CustomCommand {
327
335
  // Extract commit hash from selection (format: "abc1234 message")
328
336
  const hash = selected.split(" ")[0];
329
337
 
330
- // Get the commit diff (with timeout)
331
- const showResult = await this.api.exec("git", ["show", "--format=", hash], { timeout: 30000 });
332
- if (showResult.code !== 0) {
333
- ctx.ui.notify(`Failed to get commit: ${showResult.stderr}`, "error");
338
+ let diffText: string;
339
+ try {
340
+ diffText = await git.show(this.api.cwd, hash, { format: "" });
341
+ } catch (err) {
342
+ ctx.ui.notify(`Failed to get commit: ${err instanceof Error ? err.message : String(err)}`, "error");
334
343
  return undefined;
335
344
  }
336
345
 
337
- if (!showResult.stdout.trim()) {
346
+ if (!diffText.trim()) {
338
347
  ctx.ui.notify("Commit has no diff content", "warning");
339
348
  return undefined;
340
349
  }
341
350
 
342
- const stats = parseDiff(showResult.stdout);
351
+ const stats = parseDiff(diffText);
343
352
  if (stats.files.length === 0) {
344
353
  ctx.ui.notify("No reviewable files in commit (all changes filtered out)", "warning");
345
354
  return undefined;
346
355
  }
347
356
 
348
- return buildReviewPrompt(`Reviewing commit \`${hash}\``, stats, showResult.stdout);
357
+ return buildReviewPrompt(`Reviewing commit \`${hash}\``, stats, diffText);
349
358
  }
350
359
 
351
360
  case 4: {
@@ -354,16 +363,21 @@ export class ReviewCommand implements CustomCommand {
354
363
  if (!instructions?.trim()) return undefined;
355
364
 
356
365
  // For custom, we still try to get current diff for context
357
- const diffResult = await this.api.exec("git", ["diff", "HEAD"], { timeout: 30000 });
358
- const hasDiff = diffResult.code === 0 && diffResult.stdout.trim();
366
+ let diffText: string | undefined;
367
+ try {
368
+ diffText = await git.diff(this.api.cwd, { base: "HEAD" });
369
+ } catch {
370
+ diffText = undefined;
371
+ }
372
+ const reviewDiff = diffText?.trim();
359
373
 
360
- if (hasDiff) {
361
- const stats = parseDiff(diffResult.stdout);
374
+ if (reviewDiff) {
375
+ const stats = parseDiff(reviewDiff);
362
376
  // Even if all files filtered, include the custom instructions
363
377
  return `${buildReviewPrompt(
364
378
  `Custom review: ${instructions.split("\n")[0].slice(0, 60)}…`,
365
379
  stats,
366
- diffResult.stdout,
380
+ reviewDiff,
367
381
  )}\n\n### Additional Instructions\n\n${instructions}`;
368
382
  }
369
383
 
@@ -388,12 +402,7 @@ Use the Task tool with \`agent: "reviewer"\` to execute this review.`;
388
402
 
389
403
  async function getGitBranches(api: CustomCommandAPI): Promise<string[]> {
390
404
  try {
391
- const result = await api.exec("git", ["branch", "-a", "--format=%(refname:short)"]);
392
- if (result.code !== 0) return [];
393
- return result.stdout
394
- .split("\n")
395
- .map(b => b.trim())
396
- .filter(Boolean);
405
+ return await git.branch.list(api.cwd, { all: true });
397
406
  } catch {
398
407
  return [];
399
408
  }
@@ -401,8 +410,7 @@ async function getGitBranches(api: CustomCommandAPI): Promise<string[]> {
401
410
 
402
411
  async function getCurrentBranch(api: CustomCommandAPI): Promise<string> {
403
412
  try {
404
- const result = await api.exec("git", ["branch", "--show-current"]);
405
- return result.stdout.trim() || "HEAD";
413
+ return (await git.branch.current(api.cwd)) ?? "HEAD";
406
414
  } catch {
407
415
  return "HEAD";
408
416
  }
@@ -410,8 +418,7 @@ async function getCurrentBranch(api: CustomCommandAPI): Promise<string> {
410
418
 
411
419
  async function getGitStatus(api: CustomCommandAPI): Promise<string> {
412
420
  try {
413
- const result = await api.exec("git", ["status", "--porcelain"]);
414
- return result.stdout;
421
+ return await git.status(api.cwd);
415
422
  } catch {
416
423
  return "";
417
424
  }
@@ -419,12 +426,7 @@ async function getGitStatus(api: CustomCommandAPI): Promise<string> {
419
426
 
420
427
  async function getRecentCommits(api: CustomCommandAPI, count: number): Promise<string[]> {
421
428
  try {
422
- const result = await api.exec("git", ["log", `-${count}`, "--oneline", "--no-decorate"]);
423
- if (result.code !== 0) return [];
424
- return result.stdout
425
- .split("\n")
426
- .map(c => c.trim())
427
- .filter(Boolean);
429
+ return await git.log.onelines(api.cwd, count);
428
430
  } catch {
429
431
  return [];
430
432
  }