@oh-my-pi/pi-coding-agent 13.2.0 → 13.3.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 (243) hide show
  1. package/CHANGELOG.md +54 -1
  2. package/package.json +7 -7
  3. package/scripts/format-prompts.ts +33 -14
  4. package/scripts/generate-docs-index.ts +2 -2
  5. package/src/capability/index.ts +1 -2
  6. package/src/cli/args.ts +3 -3
  7. package/src/cli/config-cli.ts +1 -1
  8. package/src/cli/file-processor.ts +1 -2
  9. package/src/cli/grep-cli.ts +1 -1
  10. package/src/cli/jupyter-cli.ts +1 -1
  11. package/src/cli/plugin-cli.ts +1 -1
  12. package/src/cli/setup-cli.ts +1 -1
  13. package/src/cli/shell-cli.ts +1 -1
  14. package/src/cli/ssh-cli.ts +1 -1
  15. package/src/cli/stats-cli.ts +1 -2
  16. package/src/cli/update-cli.ts +1 -2
  17. package/src/cli/web-search-cli.ts +1 -1
  18. package/src/cli.ts +1 -1
  19. package/src/commands/launch.ts +2 -1
  20. package/src/commit/agentic/agent.ts +2 -1
  21. package/src/commit/agentic/index.ts +1 -2
  22. package/src/commit/agentic/prompts/system.md +3 -3
  23. package/src/commit/agentic/tools/propose-changelog.ts +30 -19
  24. package/src/commit/changelog/generate.ts +16 -6
  25. package/src/commit/changelog/index.ts +2 -1
  26. package/src/commit/pipeline.ts +1 -2
  27. package/src/commit/prompts/reduce-system.md +1 -1
  28. package/src/commit/types.ts +10 -1
  29. package/src/config/keybindings.ts +1 -2
  30. package/src/config/model-registry.ts +1 -1
  31. package/src/config/prompt-templates.ts +14 -2
  32. package/src/config/settings-schema.ts +36 -4
  33. package/src/config/settings.ts +19 -2
  34. package/src/config.ts +1 -2
  35. package/src/debug/index.ts +1 -1
  36. package/src/debug/report-bundle.ts +1 -2
  37. package/src/debug/system-info.ts +1 -2
  38. package/src/discovery/agents.ts +2 -2
  39. package/src/discovery/builtin.ts +8 -9
  40. package/src/discovery/claude-plugins.ts +2 -2
  41. package/src/discovery/claude.ts +30 -12
  42. package/src/discovery/codex.ts +3 -3
  43. package/src/discovery/cursor.ts +5 -4
  44. package/src/discovery/gemini.ts +5 -5
  45. package/src/discovery/helpers.ts +47 -69
  46. package/src/discovery/mcp-json.ts +3 -3
  47. package/src/discovery/opencode.ts +7 -8
  48. package/src/discovery/ssh.ts +3 -3
  49. package/src/discovery/vscode.ts +3 -2
  50. package/src/discovery/windsurf.ts +3 -2
  51. package/src/exa/company.ts +1 -1
  52. package/src/exa/factory.ts +1 -6
  53. package/src/exa/linkedin.ts +1 -1
  54. package/src/exa/mcp-client.ts +19 -8
  55. package/src/exa/search.ts +2 -2
  56. package/src/exa/types.ts +3 -3
  57. package/src/exec/bash-executor.ts +2 -1
  58. package/src/exec/non-interactive-env.ts +43 -0
  59. package/src/export/custom-share.ts +1 -1
  60. package/src/export/html/index.ts +1 -2
  61. package/src/extensibility/custom-commands/loader.ts +1 -2
  62. package/src/extensibility/plugins/installer.ts +1 -2
  63. package/src/extensibility/plugins/loader.ts +1 -2
  64. package/src/extensibility/plugins/manager.ts +3 -2
  65. package/src/extensibility/skills.ts +59 -115
  66. package/src/index.ts +1 -3
  67. package/src/internal-urls/docs-index.generated.ts +1 -1
  68. package/src/ipy/executor.ts +1 -2
  69. package/src/ipy/gateway-coordinator.ts +1 -2
  70. package/src/ipy/modules.ts +1 -1
  71. package/src/ipy/runtime.ts +2 -3
  72. package/src/main.ts +1 -2
  73. package/src/mcp/config.ts +2 -2
  74. package/src/mcp/transports/stdio.ts +1 -2
  75. package/src/memories/index.ts +1 -2
  76. package/src/modes/components/extensions/extension-dashboard.ts +1 -1
  77. package/src/modes/components/extensions/inspector-panel.ts +8 -2
  78. package/src/modes/components/footer.ts +1 -2
  79. package/src/modes/components/settings-defs.ts +17 -1
  80. package/src/modes/components/status-line/segments.ts +1 -2
  81. package/src/modes/components/status-line.ts +7 -5
  82. package/src/modes/components/tool-execution.ts +3 -10
  83. package/src/modes/components/welcome.ts +1 -1
  84. package/src/modes/controllers/command-controller.ts +1 -2
  85. package/src/modes/controllers/mcp-command-controller.ts +5 -4
  86. package/src/modes/controllers/selector-controller.ts +22 -1
  87. package/src/modes/controllers/ssh-command-controller.ts +1 -1
  88. package/src/modes/interactive-mode.ts +11 -3
  89. package/src/modes/oauth-manual-input.ts +42 -0
  90. package/src/modes/shared.ts +1 -2
  91. package/src/modes/theme/theme.ts +1 -2
  92. package/src/modes/types.ts +2 -0
  93. package/src/patch/hashline.ts +19 -1
  94. package/src/patch/index.ts +1 -25
  95. package/src/prompts/agents/designer.md +7 -10
  96. package/src/prompts/agents/explore.md +15 -23
  97. package/src/prompts/agents/init.md +23 -23
  98. package/src/prompts/agents/plan.md +14 -77
  99. package/src/prompts/agents/reviewer.md +6 -5
  100. package/src/prompts/agents/task.md +13 -11
  101. package/src/prompts/compaction/branch-summary.md +3 -3
  102. package/src/prompts/compaction/compaction-short-summary.md +7 -7
  103. package/src/prompts/compaction/compaction-summary-context.md +1 -1
  104. package/src/prompts/compaction/compaction-summary.md +5 -5
  105. package/src/prompts/compaction/compaction-turn-prefix.md +3 -3
  106. package/src/prompts/compaction/compaction-update-summary.md +11 -11
  107. package/src/prompts/memories/consolidation.md +5 -5
  108. package/src/prompts/memories/read-path.md +6 -6
  109. package/src/prompts/memories/stage_one_input.md +1 -1
  110. package/src/prompts/memories/stage_one_system.md +5 -5
  111. package/src/prompts/review-request.md +4 -4
  112. package/src/prompts/system/agent-creation-architect.md +17 -17
  113. package/src/prompts/system/agent-creation-user.md +2 -2
  114. package/src/prompts/system/commit-message-system.md +2 -0
  115. package/src/prompts/system/custom-system-prompt.md +4 -4
  116. package/src/prompts/system/plan-mode-active.md +20 -20
  117. package/src/prompts/system/plan-mode-approved.md +7 -7
  118. package/src/prompts/system/plan-mode-reference.md +2 -2
  119. package/src/prompts/system/plan-mode-subagent.md +8 -8
  120. package/src/prompts/system/subagent-submit-reminder.md +5 -5
  121. package/src/prompts/system/subagent-system-prompt.md +29 -22
  122. package/src/prompts/system/subagent-user-prompt.md +7 -3
  123. package/src/prompts/system/summarization-system.md +1 -1
  124. package/src/prompts/system/system-prompt.md +214 -226
  125. package/src/prompts/system/title-system.md +2 -2
  126. package/src/prompts/system/ttsr-interrupt.md +1 -1
  127. package/src/prompts/system/web-search.md +16 -16
  128. package/src/prompts/tools/ask.md +1 -3
  129. package/src/prompts/tools/await.md +2 -4
  130. package/src/prompts/tools/bash.md +5 -7
  131. package/src/prompts/tools/browser.md +4 -6
  132. package/src/prompts/tools/calculator.md +1 -3
  133. package/src/prompts/tools/cancel-job.md +2 -4
  134. package/src/prompts/tools/exit-plan-mode.md +7 -7
  135. package/src/prompts/tools/fetch.md +0 -2
  136. package/src/prompts/tools/find.md +3 -5
  137. package/src/prompts/tools/gemini-image.md +6 -22
  138. package/src/prompts/tools/grep.md +4 -6
  139. package/src/prompts/tools/hashline.md +56 -15
  140. package/src/prompts/tools/lsp.md +1 -3
  141. package/src/prompts/tools/patch.md +7 -9
  142. package/src/prompts/tools/python.md +10 -14
  143. package/src/prompts/tools/read.md +0 -2
  144. package/src/prompts/tools/replace.md +5 -7
  145. package/src/prompts/tools/ssh.md +3 -5
  146. package/src/prompts/tools/task-summary.md +4 -4
  147. package/src/prompts/tools/task.md +7 -9
  148. package/src/prompts/tools/todo-write.md +7 -9
  149. package/src/prompts/tools/web-search.md +3 -5
  150. package/src/prompts/tools/write.md +3 -5
  151. package/src/sdk.ts +4 -2
  152. package/src/session/agent-session.ts +10 -26
  153. package/src/session/agent-storage.ts +1 -2
  154. package/src/session/history-storage.ts +1 -2
  155. package/src/session/session-manager.ts +10 -2
  156. package/src/slash-commands/builtin-registry.ts +26 -1
  157. package/src/ssh/connection-manager.ts +11 -2
  158. package/src/ssh/sshfs-mount.ts +7 -1
  159. package/src/system-prompt.ts +29 -103
  160. package/src/task/agents.ts +1 -1
  161. package/src/task/index.ts +211 -70
  162. package/src/task/render.ts +24 -8
  163. package/src/task/types.ts +6 -1
  164. package/src/task/worktree.ts +394 -32
  165. package/src/tools/ask.ts +0 -1
  166. package/src/tools/bash-interactive.ts +2 -45
  167. package/src/tools/bash.ts +5 -5
  168. package/src/tools/browser.ts +1 -2
  169. package/src/tools/gemini-image.ts +8 -28
  170. package/src/tools/json-tree.ts +2 -1
  171. package/src/tools/python.ts +1 -1
  172. package/src/tools/read.ts +1 -2
  173. package/src/tools/submit-result.ts +22 -23
  174. package/src/utils/commit-message-generator.ts +132 -0
  175. package/src/utils/tools-manager.ts +1 -2
  176. package/src/web/scrapers/artifacthub.ts +2 -1
  177. package/src/web/scrapers/aur.ts +2 -1
  178. package/src/web/scrapers/biorxiv.ts +2 -1
  179. package/src/web/scrapers/bluesky.ts +2 -1
  180. package/src/web/scrapers/chocolatey.ts +2 -1
  181. package/src/web/scrapers/cisa-kev.ts +2 -1
  182. package/src/web/scrapers/clojars.ts +2 -1
  183. package/src/web/scrapers/coingecko.ts +2 -1
  184. package/src/web/scrapers/crates-io.ts +2 -1
  185. package/src/web/scrapers/crossref.ts +2 -1
  186. package/src/web/scrapers/discogs.ts +3 -1
  187. package/src/web/scrapers/discourse.ts +2 -1
  188. package/src/web/scrapers/dockerhub.ts +2 -1
  189. package/src/web/scrapers/fdroid.ts +2 -1
  190. package/src/web/scrapers/firefox-addons.ts +2 -1
  191. package/src/web/scrapers/flathub.ts +2 -1
  192. package/src/web/scrapers/gitlab.ts +1 -1
  193. package/src/web/scrapers/go-pkg.ts +2 -1
  194. package/src/web/scrapers/hackage.ts +2 -1
  195. package/src/web/scrapers/hackernews.ts +2 -1
  196. package/src/web/scrapers/hex.ts +2 -1
  197. package/src/web/scrapers/huggingface.ts +2 -1
  198. package/src/web/scrapers/jetbrains-marketplace.ts +2 -1
  199. package/src/web/scrapers/lemmy.ts +2 -1
  200. package/src/web/scrapers/lobsters.ts +2 -1
  201. package/src/web/scrapers/mastodon.ts +2 -1
  202. package/src/web/scrapers/maven.ts +2 -1
  203. package/src/web/scrapers/mdn.ts +2 -1
  204. package/src/web/scrapers/metacpan.ts +2 -1
  205. package/src/web/scrapers/musicbrainz.ts +3 -1
  206. package/src/web/scrapers/npm.ts +2 -1
  207. package/src/web/scrapers/nuget.ts +2 -1
  208. package/src/web/scrapers/nvd.ts +2 -1
  209. package/src/web/scrapers/ollama.ts +2 -1
  210. package/src/web/scrapers/open-vsx.ts +2 -1
  211. package/src/web/scrapers/opencorporates.ts +2 -1
  212. package/src/web/scrapers/openlibrary.ts +2 -1
  213. package/src/web/scrapers/orcid.ts +3 -1
  214. package/src/web/scrapers/osv.ts +2 -1
  215. package/src/web/scrapers/packagist.ts +2 -1
  216. package/src/web/scrapers/pub-dev.ts +2 -1
  217. package/src/web/scrapers/pubmed.ts +2 -1
  218. package/src/web/scrapers/pypi.ts +2 -1
  219. package/src/web/scrapers/rawg.ts +2 -8
  220. package/src/web/scrapers/reddit.ts +2 -1
  221. package/src/web/scrapers/repology.ts +2 -1
  222. package/src/web/scrapers/rfc.ts +2 -1
  223. package/src/web/scrapers/rubygems.ts +2 -1
  224. package/src/web/scrapers/searchcode.ts +2 -1
  225. package/src/web/scrapers/sec-edgar.ts +2 -1
  226. package/src/web/scrapers/semantic-scholar.ts +2 -1
  227. package/src/web/scrapers/snapcraft.ts +2 -1
  228. package/src/web/scrapers/sourcegraph.ts +2 -1
  229. package/src/web/scrapers/spdx.ts +2 -1
  230. package/src/web/scrapers/stackoverflow.ts +2 -1
  231. package/src/web/scrapers/terraform.ts +2 -1
  232. package/src/web/scrapers/types.ts +0 -11
  233. package/src/web/scrapers/vimeo.ts +2 -1
  234. package/src/web/scrapers/vscode-marketplace.ts +2 -1
  235. package/src/web/scrapers/w3c.ts +2 -1
  236. package/src/web/scrapers/wikidata.ts +2 -1
  237. package/src/web/search/index.ts +10 -14
  238. package/src/web/search/provider.ts +2 -2
  239. package/src/web/search/providers/codex.ts +1 -2
  240. package/src/web/search/providers/exa.ts +42 -10
  241. package/src/web/search/providers/gemini.ts +1 -1
  242. package/src/web/search/providers/perplexity.ts +20 -9
  243. package/src/web/search/providers/utils.ts +1 -1
@@ -1,17 +1,26 @@
1
+ import type { Dirent } from "node:fs";
1
2
  import * as fs from "node:fs/promises";
2
3
  import * as os from "node:os";
3
4
  import path from "node:path";
4
- import { isEnoent, Snowflake } from "@oh-my-pi/pi-utils";
5
- import { getWorktreeDir } from "@oh-my-pi/pi-utils/dirs";
5
+ import { getWorktreeDir, isEnoent, logger, Snowflake } from "@oh-my-pi/pi-utils";
6
6
  import { $ } from "bun";
7
7
 
8
- export interface WorktreeBaseline {
8
+ /** Baseline state for a single git repository. */
9
+ export interface RepoBaseline {
9
10
  repoRoot: string;
11
+ headCommit: string;
10
12
  staged: string;
11
13
  unstaged: string;
12
14
  untracked: string[];
13
15
  }
14
16
 
17
+ /** Baseline state for the project, including any nested git repos. */
18
+ export interface WorktreeBaseline {
19
+ root: RepoBaseline;
20
+ /** Nested git repos (path relative to root.repoRoot). */
21
+ nested: Array<{ relativePath: string; baseline: RepoBaseline }>;
22
+ }
23
+
15
24
  export function getEncodedProjectName(cwd: string): string {
16
25
  return `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
17
26
  }
@@ -39,7 +48,56 @@ export async function ensureWorktree(baseCwd: string, id: string): Promise<strin
39
48
  return worktreeDir;
40
49
  }
41
50
 
42
- export async function captureBaseline(repoRoot: string): Promise<WorktreeBaseline> {
51
+ /** Find nested git repositories (non-submodule) under the given root. */
52
+ async function discoverNestedRepos(repoRoot: string): Promise<string[]> {
53
+ // Get submodule paths so we can exclude them
54
+ const submoduleRaw = await $`git submodule --quiet foreach --recursive 'echo $sm_path'`
55
+ .cwd(repoRoot)
56
+ .quiet()
57
+ .nothrow()
58
+ .text();
59
+ const submodulePaths = new Set(
60
+ submoduleRaw
61
+ .split("\n")
62
+ .map(l => l.trim())
63
+ .filter(Boolean),
64
+ );
65
+
66
+ // Find all .git dirs/files that aren't the root or known submodules
67
+ const result: string[] = [];
68
+ async function walk(dir: string): Promise<void> {
69
+ let entries: Dirent[];
70
+ try {
71
+ entries = await fs.readdir(dir, { withFileTypes: true });
72
+ } catch {
73
+ return;
74
+ }
75
+ for (const entry of entries) {
76
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
77
+ if (!entry.isDirectory()) continue;
78
+ const full = path.join(dir, entry.name);
79
+ const rel = path.relative(repoRoot, full);
80
+ // Check if this directory is itself a git repo
81
+ const gitDir = path.join(full, ".git");
82
+ let hasGit = false;
83
+ try {
84
+ await fs.access(gitDir);
85
+ hasGit = true;
86
+ } catch {}
87
+ if (hasGit && !submodulePaths.has(rel)) {
88
+ result.push(rel);
89
+ // Don't recurse into nested repos — they manage their own tree
90
+ continue;
91
+ }
92
+ await walk(full);
93
+ }
94
+ }
95
+ await walk(repoRoot);
96
+ return result;
97
+ }
98
+
99
+ async function captureRepoBaseline(repoRoot: string): Promise<RepoBaseline> {
100
+ const headCommit = (await $`git rev-parse HEAD`.cwd(repoRoot).quiet().text()).trim();
43
101
  const staged = await $`git diff --cached --binary`.cwd(repoRoot).quiet().text();
44
102
  const unstaged = await $`git diff --binary`.cwd(repoRoot).quiet().text();
45
103
  const untrackedRaw = await $`git ls-files --others --exclude-standard`.cwd(repoRoot).quiet().text();
@@ -47,13 +105,18 @@ export async function captureBaseline(repoRoot: string): Promise<WorktreeBaselin
47
105
  .split("\n")
48
106
  .map(line => line.trim())
49
107
  .filter(line => line.length > 0);
108
+ return { repoRoot, headCommit, staged, unstaged, untracked };
109
+ }
50
110
 
51
- return {
52
- repoRoot,
53
- staged,
54
- unstaged,
55
- untracked,
56
- };
111
+ export async function captureBaseline(repoRoot: string): Promise<WorktreeBaseline> {
112
+ const [root, nestedPaths] = await Promise.all([captureRepoBaseline(repoRoot), discoverNestedRepos(repoRoot)]);
113
+ const nested = await Promise.all(
114
+ nestedPaths.map(async relativePath => ({
115
+ relativePath,
116
+ baseline: await captureRepoBaseline(path.join(repoRoot, relativePath)),
117
+ })),
118
+ );
119
+ return { root, nested };
57
120
  }
58
121
 
59
122
  async function writeTempPatchFile(patch: string): Promise<string> {
@@ -81,13 +144,13 @@ async function applyPatch(
81
144
  }
82
145
  }
83
146
 
84
- export async function applyBaseline(worktreeDir: string, baseline: WorktreeBaseline): Promise<void> {
85
- await applyPatch(worktreeDir, baseline.staged, { cached: true });
86
- await applyPatch(worktreeDir, baseline.staged);
87
- await applyPatch(worktreeDir, baseline.unstaged);
147
+ async function applyRepoBaseline(worktreeDir: string, rb: RepoBaseline, sourceRoot: string): Promise<void> {
148
+ await applyPatch(worktreeDir, rb.staged, { cached: true });
149
+ await applyPatch(worktreeDir, rb.staged);
150
+ await applyPatch(worktreeDir, rb.unstaged);
88
151
 
89
- for (const entry of baseline.untracked) {
90
- const source = path.join(baseline.repoRoot, entry);
152
+ for (const entry of rb.untracked) {
153
+ const source = path.join(sourceRoot, entry);
91
154
  const destination = path.join(worktreeDir, entry);
92
155
  try {
93
156
  await fs.mkdir(path.dirname(destination), { recursive: true });
@@ -99,6 +162,39 @@ export async function applyBaseline(worktreeDir: string, baseline: WorktreeBasel
99
162
  }
100
163
  }
101
164
 
165
+ export async function applyBaseline(worktreeDir: string, baseline: WorktreeBaseline): Promise<void> {
166
+ await applyRepoBaseline(worktreeDir, baseline.root, baseline.root.repoRoot);
167
+
168
+ // Restore nested repos into the worktree
169
+ for (const entry of baseline.nested) {
170
+ const nestedDir = path.join(worktreeDir, entry.relativePath);
171
+ // Copy the nested repo wholesale (it's not managed by root git)
172
+ const sourceDir = path.join(baseline.root.repoRoot, entry.relativePath);
173
+ try {
174
+ await fs.cp(sourceDir, nestedDir, { recursive: true });
175
+ } catch (err) {
176
+ if (isEnoent(err)) continue;
177
+ throw err;
178
+ }
179
+ // Apply any uncommitted changes from the nested baseline
180
+ await applyRepoBaseline(nestedDir, entry.baseline, entry.baseline.repoRoot);
181
+ // Commit baseline state so captureRepoDeltaPatch can cleanly subtract it.
182
+ // Without this, `git add -A && git commit` by the task would include
183
+ // baseline untracked files in the diff-tree output.
184
+ const hasChanges = (await $`git status --porcelain`.cwd(nestedDir).quiet().nothrow().text()).trim();
185
+ if (hasChanges) {
186
+ await $`git add -A`.cwd(nestedDir).quiet();
187
+ await $`git commit -m omp-baseline --allow-empty`.cwd(nestedDir).quiet();
188
+ // Update baseline to reflect the committed state — prevents double-apply
189
+ // in captureRepoDeltaPatch's temp-index path
190
+ entry.baseline.headCommit = (await $`git rev-parse HEAD`.cwd(nestedDir).quiet().text()).trim();
191
+ entry.baseline.staged = "";
192
+ entry.baseline.unstaged = "";
193
+ entry.baseline.untracked = [];
194
+ }
195
+ }
196
+ }
197
+
102
198
  async function applyPatchToIndex(cwd: string, patch: string, indexFile: string): Promise<void> {
103
199
  if (!patch.trim()) return;
104
200
  const tempPath = await writeTempPatchFile(patch);
@@ -122,31 +218,62 @@ async function listUntracked(cwd: string): Promise<string[]> {
122
218
  .filter(line => line.length > 0);
123
219
  }
124
220
 
125
- export async function captureDeltaPatch(worktreeDir: string, baseline: WorktreeBaseline): Promise<string> {
126
- const tempIndex = path.join(os.tmpdir(), `omp-task-index-${Snowflake.next()}`);
127
- try {
128
- await $`git read-tree HEAD`.cwd(worktreeDir).env({
129
- GIT_INDEX_FILE: tempIndex,
130
- });
131
- await applyPatchToIndex(worktreeDir, baseline.staged, tempIndex);
132
- await applyPatchToIndex(worktreeDir, baseline.unstaged, tempIndex);
133
- const diff = await $`git diff --binary`
134
- .cwd(worktreeDir)
135
- .env({
136
- GIT_INDEX_FILE: tempIndex,
137
- })
221
+ async function captureRepoDeltaPatch(repoDir: string, rb: RepoBaseline): Promise<string> {
222
+ // Check if HEAD advanced (task committed changes)
223
+ const currentHead = (await $`git rev-parse HEAD`.cwd(repoDir).quiet().nothrow().text()).trim();
224
+ const headAdvanced = currentHead && currentHead !== rb.headCommit;
225
+
226
+ if (headAdvanced) {
227
+ // HEAD moved: use diff-tree to capture committed changes, plus any uncommitted on top
228
+ const parts: string[] = [];
229
+
230
+ // Committed changes since baseline
231
+ const committedDiff = await $`git diff-tree -r -p --binary ${rb.headCommit} ${currentHead}`
232
+ .cwd(repoDir)
138
233
  .quiet()
234
+ .nothrow()
139
235
  .text();
236
+ if (committedDiff.trim()) parts.push(committedDiff);
237
+
238
+ // Uncommitted changes on top of the new HEAD
239
+ const staged = await $`git diff --cached --binary`.cwd(repoDir).quiet().text();
240
+ const unstaged = await $`git diff --binary`.cwd(repoDir).quiet().text();
241
+ if (staged.trim()) parts.push(staged);
242
+ if (unstaged.trim()) parts.push(unstaged);
243
+
244
+ // New untracked files (relative to both baseline and current tracking)
245
+ const currentUntracked = await listUntracked(repoDir);
246
+ const baselineUntracked = new Set(rb.untracked);
247
+ const newUntracked = currentUntracked.filter(entry => !baselineUntracked.has(entry));
248
+ if (newUntracked.length > 0) {
249
+ const untrackedDiffs = await Promise.all(
250
+ newUntracked.map(entry =>
251
+ $`git diff --binary --no-index /dev/null ${entry}`.cwd(repoDir).quiet().nothrow().text(),
252
+ ),
253
+ );
254
+ parts.push(...untrackedDiffs.filter(d => d.trim()));
255
+ }
256
+
257
+ return parts.join("\n");
258
+ }
140
259
 
141
- const currentUntracked = await listUntracked(worktreeDir);
142
- const baselineUntracked = new Set(baseline.untracked);
260
+ // HEAD unchanged: use temp index approach (subtracts baseline from delta)
261
+ const tempIndex = path.join(os.tmpdir(), `omp-task-index-${Snowflake.next()}`);
262
+ try {
263
+ await $`git read-tree ${rb.headCommit}`.cwd(repoDir).env({ GIT_INDEX_FILE: tempIndex });
264
+ await applyPatchToIndex(repoDir, rb.staged, tempIndex);
265
+ await applyPatchToIndex(repoDir, rb.unstaged, tempIndex);
266
+ const diff = await $`git diff --binary`.cwd(repoDir).env({ GIT_INDEX_FILE: tempIndex }).quiet().text();
267
+
268
+ const currentUntracked = await listUntracked(repoDir);
269
+ const baselineUntracked = new Set(rb.untracked);
143
270
  const newUntracked = currentUntracked.filter(entry => !baselineUntracked.has(entry));
144
271
 
145
272
  if (newUntracked.length === 0) return diff;
146
273
 
147
274
  const untrackedDiffs = await Promise.all(
148
275
  newUntracked.map(entry =>
149
- $`git diff --binary --no-index /dev/null ${entry}`.cwd(worktreeDir).quiet().nothrow().text(),
276
+ $`git diff --binary --no-index /dev/null ${entry}`.cwd(repoDir).quiet().nothrow().text(),
150
277
  ),
151
278
  );
152
279
  return `${diff}${diff && !diff.endsWith("\n") ? "\n" : ""}${untrackedDiffs.join("\n")}`;
@@ -155,6 +282,76 @@ export async function captureDeltaPatch(worktreeDir: string, baseline: WorktreeB
155
282
  }
156
283
  }
157
284
 
285
+ export interface NestedRepoPatch {
286
+ relativePath: string;
287
+ patch: string;
288
+ }
289
+
290
+ export interface DeltaPatchResult {
291
+ rootPatch: string;
292
+ nestedPatches: NestedRepoPatch[];
293
+ }
294
+
295
+ export async function captureDeltaPatch(isolationDir: string, baseline: WorktreeBaseline): Promise<DeltaPatchResult> {
296
+ const rootPatch = await captureRepoDeltaPatch(isolationDir, baseline.root);
297
+ const nestedPatches: NestedRepoPatch[] = [];
298
+
299
+ for (const { relativePath, baseline: nb } of baseline.nested) {
300
+ const nestedDir = path.join(isolationDir, relativePath);
301
+ try {
302
+ await fs.access(path.join(nestedDir, ".git"));
303
+ } catch {
304
+ continue;
305
+ }
306
+ const patch = await captureRepoDeltaPatch(nestedDir, nb);
307
+ if (patch.trim()) nestedPatches.push({ relativePath, patch });
308
+ }
309
+
310
+ return { rootPatch, nestedPatches };
311
+ }
312
+
313
+ /**
314
+ * Apply nested repo patches directly to their working directories after parent merge.
315
+ * @param commitMessage Optional async function to generate a commit message from the combined diff.
316
+ * If omitted or returns null, falls back to a generic message.
317
+ */
318
+ export async function applyNestedPatches(
319
+ repoRoot: string,
320
+ patches: NestedRepoPatch[],
321
+ commitMessage?: (diff: string) => Promise<string | null>,
322
+ ): Promise<void> {
323
+ // Group patches by target repo to apply all at once and commit
324
+ const byRepo = new Map<string, NestedRepoPatch[]>();
325
+ for (const p of patches) {
326
+ if (!p.patch.trim()) continue;
327
+ const group = byRepo.get(p.relativePath) ?? [];
328
+ group.push(p);
329
+ byRepo.set(p.relativePath, group);
330
+ }
331
+
332
+ for (const [relativePath, repoPatches] of byRepo) {
333
+ const nestedDir = path.join(repoRoot, relativePath);
334
+ try {
335
+ await fs.access(path.join(nestedDir, ".git"));
336
+ } catch {
337
+ continue;
338
+ }
339
+
340
+ const combinedDiff = repoPatches.map(p => p.patch).join("\n");
341
+ for (const { patch } of repoPatches) {
342
+ await applyPatch(nestedDir, patch);
343
+ }
344
+
345
+ // Commit so nested repo history reflects the task changes
346
+ const hasChanges = (await $`git status --porcelain`.cwd(nestedDir).quiet().nothrow().text()).trim();
347
+ if (hasChanges) {
348
+ const msg = (await commitMessage?.(combinedDiff)) ?? "changes from isolated task(s)";
349
+ await $`git add -A`.cwd(nestedDir).quiet();
350
+ await $`git commit -m ${msg}`.cwd(nestedDir).quiet();
351
+ }
352
+ }
353
+ }
354
+
158
355
  export async function cleanupWorktree(dir: string): Promise<void> {
159
356
  try {
160
357
  const commonDirRaw = await $`git rev-parse --git-common-dir`.cwd(dir).quiet().nothrow().text();
@@ -168,3 +365,168 @@ export async function cleanupWorktree(dir: string): Promise<void> {
168
365
  await fs.rm(dir, { recursive: true, force: true });
169
366
  }
170
367
  }
368
+
369
+ // ═══════════════════════════════════════════════════════════════════════════
370
+ // Fuse-overlay isolation
371
+ // ═══════════════════════════════════════════════════════════════════════════
372
+
373
+ export async function ensureFuseOverlay(baseCwd: string, id: string): Promise<string> {
374
+ const repoRoot = await getRepoRoot(baseCwd);
375
+ const encodedProject = getEncodedProjectName(repoRoot);
376
+ const baseDir = getWorktreeDir(encodedProject, id);
377
+ const upperDir = path.join(baseDir, "upper");
378
+ const workDir = path.join(baseDir, "work");
379
+ const mergedDir = path.join(baseDir, "merged");
380
+
381
+ // Clean up any stale mount at this path
382
+ const fusermount = Bun.which("fusermount3") ?? Bun.which("fusermount");
383
+ if (fusermount) {
384
+ await $`${fusermount} -u ${mergedDir}`.quiet().nothrow();
385
+ }
386
+ await fs.rm(baseDir, { recursive: true, force: true });
387
+
388
+ await fs.mkdir(upperDir, { recursive: true });
389
+ await fs.mkdir(workDir, { recursive: true });
390
+ await fs.mkdir(mergedDir, { recursive: true });
391
+
392
+ const binary = Bun.which("fuse-overlayfs");
393
+ if (!binary) {
394
+ await fs.rm(baseDir, { recursive: true, force: true });
395
+ throw new Error(
396
+ "fuse-overlayfs not found. Install it (e.g. `apt install fuse-overlayfs` or `pacman -S fuse-overlayfs`) to use fuse-overlay isolation.",
397
+ );
398
+ }
399
+
400
+ const result = await $`${binary} -o lowerdir=${repoRoot},upperdir=${upperDir},workdir=${workDir} ${mergedDir}`
401
+ .quiet()
402
+ .nothrow();
403
+ if (result.exitCode !== 0) {
404
+ const stderr = result.stderr.toString().trim();
405
+ await fs.rm(baseDir, { recursive: true, force: true });
406
+ throw new Error(`fuse-overlayfs mount failed (exit ${result.exitCode}): ${stderr}`);
407
+ }
408
+
409
+ return mergedDir;
410
+ }
411
+
412
+ export async function cleanupFuseOverlay(mergedDir: string): Promise<void> {
413
+ try {
414
+ const fusermount = Bun.which("fusermount3") ?? Bun.which("fusermount");
415
+ if (fusermount) {
416
+ await $`${fusermount} -u ${mergedDir}`.quiet().nothrow();
417
+ }
418
+ } finally {
419
+ // baseDir is the parent of the merged directory
420
+ const baseDir = path.dirname(mergedDir);
421
+ await fs.rm(baseDir, { recursive: true, force: true });
422
+ }
423
+ }
424
+
425
+ // ═══════════════════════════════════════════════════════════════════════════
426
+ // Branch-mode isolation
427
+ // ═══════════════════════════════════════════════════════════════════════════
428
+
429
+ export interface CommitToBranchResult {
430
+ branchName?: string;
431
+ nestedPatches: NestedRepoPatch[];
432
+ }
433
+
434
+ /**
435
+ * Commit task-only changes to a new branch.
436
+ * Only root repo changes go on the branch. Nested repo patches are returned
437
+ * separately since the parent git can't track files inside gitlinks.
438
+ */
439
+ export async function commitToBranch(
440
+ isolationDir: string,
441
+ baseline: WorktreeBaseline,
442
+ taskId: string,
443
+ description: string | undefined,
444
+ commitMessage?: (diff: string) => Promise<string | null>,
445
+ ): Promise<CommitToBranchResult | null> {
446
+ const { rootPatch, nestedPatches } = await captureDeltaPatch(isolationDir, baseline);
447
+ if (!rootPatch.trim() && nestedPatches.length === 0) return null;
448
+
449
+ const repoRoot = baseline.root.repoRoot;
450
+ const branchName = `omp/task/${taskId}`;
451
+ const fallbackMessage = description || taskId;
452
+
453
+ // Only create a branch if the root repo has changes
454
+ if (rootPatch.trim()) {
455
+ await $`git branch ${branchName} HEAD`.cwd(repoRoot).quiet();
456
+ const tmpDir = path.join(os.tmpdir(), `omp-branch-${Snowflake.next()}`);
457
+ try {
458
+ await $`git worktree add ${tmpDir} ${branchName}`.cwd(repoRoot).quiet();
459
+ const patchPath = path.join(os.tmpdir(), `omp-branch-patch-${Snowflake.next()}.patch`);
460
+ try {
461
+ await Bun.write(patchPath, rootPatch);
462
+ const applyResult = await $`git apply --binary ${patchPath}`.cwd(tmpDir).quiet().nothrow();
463
+ if (applyResult.exitCode !== 0) {
464
+ const stderr = applyResult.stderr.toString().slice(0, 2000);
465
+ logger.error("commitToBranch: git apply failed", {
466
+ taskId,
467
+ exitCode: applyResult.exitCode,
468
+ stderr,
469
+ patchSize: rootPatch.length,
470
+ patchHead: rootPatch.slice(0, 500),
471
+ });
472
+ throw new Error(`git apply failed for task ${taskId}: ${stderr}`);
473
+ }
474
+ } finally {
475
+ await fs.rm(patchPath, { force: true });
476
+ }
477
+ await $`git add -A`.cwd(tmpDir).quiet();
478
+ const msg = (commitMessage && (await commitMessage(rootPatch))) || fallbackMessage;
479
+ await $`git commit -m ${msg}`.cwd(tmpDir).quiet();
480
+ } finally {
481
+ await $`git worktree remove -f ${tmpDir}`.cwd(repoRoot).quiet().nothrow();
482
+ await fs.rm(tmpDir, { recursive: true, force: true });
483
+ }
484
+ }
485
+
486
+ return { branchName: rootPatch.trim() ? branchName : undefined, nestedPatches };
487
+ }
488
+
489
+ export interface MergeBranchResult {
490
+ merged: string[];
491
+ failed: string[];
492
+ conflict?: string;
493
+ }
494
+
495
+ /**
496
+ * Cherry-pick task branch commits sequentially onto HEAD.
497
+ * Each branch has a single commit that gets replayed cleanly.
498
+ * Stops on first conflict and reports which branches succeeded.
499
+ */
500
+ export async function mergeTaskBranches(
501
+ repoRoot: string,
502
+ branches: Array<{ branchName: string; taskId: string; description?: string }>,
503
+ ): Promise<MergeBranchResult> {
504
+ const merged: string[] = [];
505
+ const failed: string[] = [];
506
+
507
+ for (const { branchName } of branches) {
508
+ const result = await $`git cherry-pick ${branchName}`.cwd(repoRoot).quiet().nothrow();
509
+
510
+ if (result.exitCode !== 0) {
511
+ await $`git cherry-pick --abort`.cwd(repoRoot).quiet().nothrow();
512
+ const stderr = result.stderr.toString().trim();
513
+ failed.push(branchName);
514
+ return {
515
+ merged,
516
+ failed: [...failed, ...branches.slice(merged.length + failed.length).map(b => b.branchName)],
517
+ conflict: `${branchName}: ${stderr}`,
518
+ };
519
+ }
520
+
521
+ merged.push(branchName);
522
+ }
523
+
524
+ return { merged, failed };
525
+ }
526
+
527
+ /** Clean up temporary task branches. */
528
+ export async function cleanupTaskBranches(repoRoot: string, branches: string[]): Promise<void> {
529
+ for (const branch of branches) {
530
+ await $`git branch -D ${branch}`.cwd(repoRoot).quiet().nothrow();
531
+ }
532
+ }
package/src/tools/ask.ts CHANGED
@@ -59,7 +59,6 @@ export interface QuestionResult {
59
59
  }
60
60
 
61
61
  export interface AskToolDetails {
62
- /** Single question mode (backwards compatible) */
63
62
  question?: string;
64
63
  options?: string[];
65
64
  multi?: boolean;
@@ -11,6 +11,7 @@ import {
11
11
  } from "@oh-my-pi/pi-tui";
12
12
  import type { Terminal as XtermTerminalType } from "@xterm/headless";
13
13
  import xterm from "@xterm/headless";
14
+ import { NON_INTERACTIVE_ENV } from "../exec/non-interactive-env";
14
15
  import type { Theme } from "../modes/theme/theme";
15
16
  import { OutputSink, type OutputSummary } from "../session/streaming-output";
16
17
  import { formatStatusIcon, replaceTabs } from "./render-utils";
@@ -275,50 +276,6 @@ class BashInteractiveOverlayComponent implements Component {
275
276
  }
276
277
  }
277
278
 
278
- export const NO_PAGER_ENV = {
279
- // Disable pagers so commands don't block on interactive views.
280
- PAGER: "cat",
281
- GIT_PAGER: "cat",
282
- MANPAGER: "cat",
283
- SYSTEMD_PAGER: "cat",
284
- BAT_PAGER: "cat",
285
- DELTA_PAGER: "cat",
286
- GH_PAGER: "cat",
287
- GLAB_PAGER: "cat",
288
- PSQL_PAGER: "cat",
289
- MYSQL_PAGER: "cat",
290
- AWS_PAGER: "",
291
- HOMEBREW_PAGER: "cat",
292
- LESS: "FRX",
293
- // Disable editor and terminal credential prompts.
294
- GIT_EDITOR: "true",
295
- VISUAL: "true",
296
- EDITOR: "true",
297
- GIT_TERMINAL_PROMPT: "0",
298
- SSH_ASKPASS: "/usr/bin/false",
299
- CI: "1",
300
- // Package manager defaults for unattended execution.
301
- npm_config_yes: "true",
302
- npm_config_update_notifier: "false",
303
- npm_config_fund: "false",
304
- npm_config_audit: "false",
305
- npm_config_progress: "false",
306
- PNPM_DISABLE_SELF_UPDATE_CHECK: "true",
307
- PNPM_UPDATE_NOTIFIER: "false",
308
- YARN_ENABLE_TELEMETRY: "0",
309
- YARN_ENABLE_PROGRESS_BARS: "0",
310
- // Cross-language/tooling non-interactive defaults.
311
- CARGO_TERM_PROGRESS_WHEN: "never",
312
- DEBIAN_FRONTEND: "noninteractive",
313
- PIP_NO_INPUT: "1",
314
- PIP_DISABLE_PIP_VERSION_CHECK: "1",
315
- TF_INPUT: "0",
316
- TF_IN_AUTOMATION: "1",
317
- GH_PROMPT_DISABLED: "1",
318
- COMPOSER_NO_INTERACTION: "1",
319
- CLOUDSDK_CORE_DISABLE_PROMPTS: "1",
320
- };
321
-
322
279
  export async function runInteractiveBashPty(
323
280
  ui: NonNullable<AgentToolContext["ui"]>,
324
281
  options: {
@@ -388,8 +345,8 @@ export async function runInteractiveBashPty(
388
345
  cwd: options.cwd,
389
346
  timeoutMs: options.timeoutMs,
390
347
  env: {
348
+ ...NON_INTERACTIVE_ENV,
391
349
  ...options.env,
392
- ...NO_PAGER_ENV,
393
350
  },
394
351
  signal: options.signal,
395
352
  cols,
package/src/tools/bash.ts CHANGED
@@ -3,11 +3,11 @@ import * as path from "node:path";
3
3
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
4
4
  import type { Component } from "@oh-my-pi/pi-tui";
5
5
  import { Text } from "@oh-my-pi/pi-tui";
6
- import { $env, isEnoent } from "@oh-my-pi/pi-utils";
7
- import { getProjectDir } from "@oh-my-pi/pi-utils/dirs";
6
+ import { $env, getProjectDir, isEnoent } from "@oh-my-pi/pi-utils";
8
7
  import { Type } from "@sinclair/typebox";
9
8
  import { renderPromptTemplate } from "../config/prompt-templates";
10
9
  import { type BashResult, executeBash } from "../exec/bash-executor";
10
+ import { NON_INTERACTIVE_ENV } from "../exec/non-interactive-env";
11
11
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
12
12
  import { truncateToVisualLines } from "../modes/components/visual-truncate";
13
13
  import type { Theme } from "../modes/theme/theme";
@@ -16,7 +16,7 @@ import { DEFAULT_MAX_BYTES, TailBuffer } from "../session/streaming-output";
16
16
  import { renderStatusLine } from "../tui";
17
17
  import { CachedOutputBlock } from "../tui/output-block";
18
18
  import type { ToolSession } from ".";
19
- import { type BashInteractiveResult, NO_PAGER_ENV, runInteractiveBashPty } from "./bash-interactive";
19
+ import { type BashInteractiveResult, runInteractiveBashPty } from "./bash-interactive";
20
20
  import { checkBashInterception } from "./bash-interceptor";
21
21
  import { applyHeadTail } from "./bash-normalize";
22
22
  import { expandInternalUrls, type InternalUrlExpansionOptions } from "./bash-skill-urls";
@@ -222,7 +222,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
222
222
  sessionKey: `${this.session.getSessionId?.() ?? ""}:async:${jobId}`,
223
223
  timeout: timeoutMs,
224
224
  signal: runSignal,
225
- env: NO_PAGER_ENV,
225
+ env: NON_INTERACTIVE_ENV,
226
226
  artifactPath,
227
227
  artifactId,
228
228
  onChunk: chunk => {
@@ -273,7 +273,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
273
273
  sessionKey: this.session.getSessionId?.() ?? undefined,
274
274
  timeout: timeoutMs,
275
275
  signal,
276
- env: NO_PAGER_ENV,
276
+ env: NON_INTERACTIVE_ENV,
277
277
  artifactPath,
278
278
  artifactId,
279
279
  onChunk: chunk => {
@@ -3,8 +3,7 @@ import * as path from "node:path";
3
3
  import { Readability } from "@mozilla/readability";
4
4
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
5
5
  import { StringEnum } from "@oh-my-pi/pi-ai";
6
- import { logger, Snowflake, untilAborted } from "@oh-my-pi/pi-utils";
7
- import { getPuppeteerDir } from "@oh-my-pi/pi-utils/dirs";
6
+ import { getPuppeteerDir, logger, Snowflake, untilAborted } from "@oh-my-pi/pi-utils";
8
7
  import { type Static, Type } from "@sinclair/typebox";
9
8
  import { type HTMLElement, parseHTML } from "linkedom";
10
9
  import type {