@oh-my-pi/pi-coding-agent 16.1.15 → 16.1.17

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 (143) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/dist/cli.js +3531 -3812
  3. package/dist/types/auto-thinking/classifier.d.ts +4 -2
  4. package/dist/types/cli/args.d.ts +2 -5
  5. package/dist/types/cli/flag-tables.d.ts +2 -2
  6. package/dist/types/cli/session-picker.d.ts +4 -2
  7. package/dist/types/commands/launch.d.ts +1 -1
  8. package/dist/types/config/model-discovery.d.ts +1 -0
  9. package/dist/types/config/model-registry.d.ts +4 -0
  10. package/dist/types/config/settings-schema.d.ts +12 -1
  11. package/dist/types/eval/agent-bridge.d.ts +19 -0
  12. package/dist/types/eval/js/shared/helpers.d.ts +1 -13
  13. package/dist/types/eval/js/shared/types.d.ts +1 -1
  14. package/dist/types/eval/js/worker-protocol.d.ts +1 -1
  15. package/dist/types/eval/py/executor.d.ts +1 -1
  16. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +22 -7
  17. package/dist/types/extensibility/plugins/loader.d.ts +16 -9
  18. package/dist/types/extensibility/tool-event-input.d.ts +2 -0
  19. package/dist/types/hindsight/content.d.ts +2 -10
  20. package/dist/types/internal-urls/local-protocol.d.ts +18 -1
  21. package/dist/types/main.d.ts +2 -0
  22. package/dist/types/modes/components/chat-transcript-builder.d.ts +2 -0
  23. package/dist/types/modes/components/custom-editor.d.ts +3 -0
  24. package/dist/types/modes/components/plugin-settings.d.ts +5 -0
  25. package/dist/types/modes/components/session-selector.d.ts +25 -0
  26. package/dist/types/modes/components/status-line/types.d.ts +1 -0
  27. package/dist/types/modes/controllers/command-controller.d.ts +2 -0
  28. package/dist/types/modes/controllers/input-controller.d.ts +14 -0
  29. package/dist/types/modes/controllers/selector-controller.d.ts +11 -0
  30. package/dist/types/session/agent-session.d.ts +6 -6
  31. package/dist/types/session/provider-image-budget.d.ts +3 -0
  32. package/dist/types/session/session-context.d.ts +6 -5
  33. package/dist/types/task/isolation-runner.d.ts +128 -0
  34. package/dist/types/task/parallel.d.ts +4 -0
  35. package/dist/types/task/worktree.d.ts +14 -1
  36. package/dist/types/thinking.d.ts +23 -1
  37. package/dist/types/tiny/title-client.d.ts +2 -2
  38. package/dist/types/tools/eval-render.d.ts +3 -0
  39. package/dist/types/tools/eval.d.ts +11 -17
  40. package/dist/types/tools/todo.d.ts +26 -28
  41. package/dist/types/tui/output-block.d.ts +8 -0
  42. package/dist/types/utils/image-resize.d.ts +2 -0
  43. package/dist/types/utils/tools-manager.d.ts +2 -0
  44. package/dist/types/web/search/providers/exa.d.ts +2 -0
  45. package/package.json +12 -12
  46. package/scripts/build-binary.ts +18 -4
  47. package/src/auto-thinking/classifier.ts +7 -2
  48. package/src/cli/args.ts +4 -5
  49. package/src/cli/flag-tables.ts +3 -3
  50. package/src/cli/gallery-fixtures/interaction.ts +6 -9
  51. package/src/cli/gallery-fixtures/shell.ts +15 -23
  52. package/src/cli/profile-alias.ts +38 -7
  53. package/src/cli/session-picker.ts +17 -3
  54. package/src/cli/usage-cli.ts +5 -1
  55. package/src/commands/launch.ts +3 -3
  56. package/src/config/model-discovery.ts +59 -8
  57. package/src/config/model-registry.ts +74 -3
  58. package/src/config/settings-schema.ts +13 -1
  59. package/src/discovery/omp-extension-roots.ts +1 -3
  60. package/src/edit/renderer.ts +34 -12
  61. package/src/eval/__tests__/agent-bridge.test.ts +462 -3
  62. package/src/eval/__tests__/helpers-local-roots.test.ts +2 -5
  63. package/src/eval/__tests__/julia-prelude.test.ts +1 -30
  64. package/src/eval/__tests__/prelude-agent.test.ts +42 -8
  65. package/src/eval/agent-bridge.ts +301 -71
  66. package/src/eval/jl/prelude.jl +32 -227
  67. package/src/eval/jl/runner.jl +38 -12
  68. package/src/eval/js/shared/helpers.ts +1 -114
  69. package/src/eval/js/shared/prelude.txt +13 -27
  70. package/src/eval/js/shared/runtime.ts +0 -6
  71. package/src/eval/js/shared/types.ts +1 -1
  72. package/src/eval/js/worker-protocol.ts +1 -1
  73. package/src/eval/py/__tests__/prelude.test.ts +13 -0
  74. package/src/eval/py/executor.ts +1 -1
  75. package/src/eval/py/prelude.py +47 -105
  76. package/src/eval/py/runner.py +0 -6
  77. package/src/eval/rb/prelude.rb +21 -189
  78. package/src/eval/rb/runner.rb +116 -9
  79. package/src/export/html/tool-views.generated.js +29 -29
  80. package/src/extensibility/extensions/wrapper.ts +3 -2
  81. package/src/extensibility/hooks/tool-wrapper.ts +4 -3
  82. package/src/extensibility/plugins/legacy-pi-compat.ts +40 -8
  83. package/src/extensibility/plugins/loader.ts +71 -23
  84. package/src/extensibility/plugins/marketplace/manager.ts +134 -0
  85. package/src/extensibility/tool-event-input.ts +57 -0
  86. package/src/hindsight/content.ts +21 -12
  87. package/src/internal-urls/docs-index.generated.txt +1 -1
  88. package/src/internal-urls/local-protocol.ts +100 -53
  89. package/src/main.ts +15 -4
  90. package/src/mcp/tool-bridge.ts +27 -2
  91. package/src/mnemopi/state.ts +5 -2
  92. package/src/modes/acp/acp-event-mapper.ts +7 -2
  93. package/src/modes/components/agent-transcript-viewer.ts +193 -41
  94. package/src/modes/components/chat-transcript-builder.ts +6 -0
  95. package/src/modes/components/custom-editor.test.ts +18 -1
  96. package/src/modes/components/custom-editor.ts +77 -45
  97. package/src/modes/components/hook-editor.ts +15 -2
  98. package/src/modes/components/plugin-settings.ts +7 -1
  99. package/src/modes/components/session-selector.ts +143 -29
  100. package/src/modes/components/settings-selector.ts +2 -2
  101. package/src/modes/components/status-line/component.ts +52 -8
  102. package/src/modes/components/status-line/segments.ts +5 -1
  103. package/src/modes/components/status-line/types.ts +1 -0
  104. package/src/modes/components/welcome.ts +12 -14
  105. package/src/modes/controllers/command-controller.ts +21 -5
  106. package/src/modes/controllers/input-controller.ts +115 -3
  107. package/src/modes/controllers/selector-controller.ts +19 -1
  108. package/src/modes/interactive-mode.ts +3 -3
  109. package/src/modes/rpc/rpc-mode.ts +6 -0
  110. package/src/modes/utils/copy-targets.ts +7 -2
  111. package/src/modes/utils/ui-helpers.ts +3 -3
  112. package/src/prompts/system/system-prompt.md +3 -3
  113. package/src/prompts/system/workflow-notice.md +3 -3
  114. package/src/prompts/tools/bash.md +16 -0
  115. package/src/prompts/tools/eval.md +19 -19
  116. package/src/prompts/tools/todo.md +1 -1
  117. package/src/sdk.ts +8 -10
  118. package/src/session/agent-session.ts +422 -97
  119. package/src/session/provider-image-budget.ts +86 -0
  120. package/src/session/session-context.ts +14 -7
  121. package/src/session/session-storage.ts +24 -2
  122. package/src/session/snapcompact-inline.ts +19 -3
  123. package/src/slash-commands/builtin-registry.ts +0 -22
  124. package/src/slash-commands/helpers/usage-report.ts +9 -1
  125. package/src/task/index.ts +61 -207
  126. package/src/task/isolation-runner.ts +354 -0
  127. package/src/task/parallel.ts +6 -1
  128. package/src/task/worktree.ts +46 -9
  129. package/src/thinking.ts +29 -2
  130. package/src/tiny/title-client.ts +75 -21
  131. package/src/tools/ask.ts +44 -38
  132. package/src/tools/bash.ts +9 -2
  133. package/src/tools/browser/tab-worker.ts +1 -1
  134. package/src/tools/eval-render.ts +34 -27
  135. package/src/tools/eval.ts +100 -103
  136. package/src/tools/index.ts +8 -1
  137. package/src/tools/read.ts +136 -60
  138. package/src/tools/todo.ts +60 -64
  139. package/src/tui/code-cell.ts +1 -1
  140. package/src/tui/output-block.ts +11 -0
  141. package/src/utils/image-resize.ts +30 -0
  142. package/src/utils/tools-manager.ts +67 -10
  143. package/src/web/search/providers/exa.ts +85 -1
@@ -0,0 +1,354 @@
1
+ /**
2
+ * Reusable isolation lifecycle for subagent execution.
3
+ *
4
+ * Both `TaskTool` and the eval `agent()` bridge spawn subagents that can run
5
+ * inside a copy-on-write worktree, capture their changes, and (optionally)
6
+ * apply those changes back to the parent repo. The orchestration is identical
7
+ * for both callers; this module hosts the shared lifecycle so eval `agent()`
8
+ * does not need to round-trip through `TaskTool.#runSpawn`.
9
+ *
10
+ * Shape:
11
+ * 1. {@link prepareIsolationContext} — resolve git root + capture baseline.
12
+ * 2. {@link runIsolatedSubprocess} — start worktree, run, capture
13
+ * branch/patch, tear worktree down.
14
+ * 3. {@link mergeIsolatedChanges} — apply captured changes back to the
15
+ * parent repo (skip when the caller
16
+ * opted out).
17
+ *
18
+ * Step 1 happens once per top-level call (the baseline is cloned per spawn
19
+ * before mutation); steps 2 and 3 are per-spawn.
20
+ */
21
+ import * as path from "node:path";
22
+ import type * as natives from "@oh-my-pi/pi-natives";
23
+ import type { ToolSession } from "../tools";
24
+ import { generateCommitMessage } from "../utils/commit-message-generator";
25
+ import * as git from "../utils/git";
26
+ import type { ExecutorOptions } from "./executor";
27
+ import { runSubprocess } from "./executor";
28
+ import type { SingleResult } from "./types";
29
+ import {
30
+ applyNestedPatches,
31
+ captureBaseline,
32
+ captureDeltaPatch,
33
+ cleanupIsolation,
34
+ cleanupTaskBranches,
35
+ commitToBranch,
36
+ ensureIsolation,
37
+ getRepoRoot,
38
+ type IsolationHandle,
39
+ mergeTaskBranches,
40
+ type WorktreeBaseline,
41
+ } from "./worktree";
42
+
43
+ type IsoBackendKind = natives.IsoBackendKind;
44
+
45
+ /** Resolved repo + baseline used by every isolated spawn in a single call. */
46
+ export interface IsolationContext {
47
+ repoRoot: string;
48
+ baseline: WorktreeBaseline;
49
+ }
50
+
51
+ /**
52
+ * Resolve the git repo root and capture the worktree baseline used to diff
53
+ * each isolated spawn against. Throws when the cwd is not inside a git
54
+ * repository; callers surface the error as a task-tool failure.
55
+ */
56
+ export async function prepareIsolationContext(cwd: string): Promise<IsolationContext> {
57
+ const repoRoot = await getRepoRoot(cwd);
58
+ const baseline = await captureBaseline(repoRoot);
59
+ return { repoRoot, baseline };
60
+ }
61
+
62
+ /** Build a commit-message callback for branch/nested commits; `undefined` ⇒ fall back to generic message. */
63
+ export type BuildCommitMessage = () => undefined | ((diff: string) => Promise<string | null>);
64
+
65
+ /**
66
+ * Construct the commit-message factory used by isolation branch commits and
67
+ * nested-repo patch commits. Returns a closure that, each time it's called,
68
+ * either yields an AI-backed `(diff) => Promise<string|null>` callback (when
69
+ * `task.isolation.commits === "ai"` and a model registry is available) or
70
+ * `undefined` so the caller falls back to a generic commit message.
71
+ *
72
+ * Centralized so `TaskTool` and the eval `agent()` bridge share one wiring;
73
+ * a drift here previously meant the two callers built subtly different
74
+ * generators for the same setting.
75
+ */
76
+ export function makeIsolationCommitMessage(session: ToolSession): BuildCommitMessage {
77
+ return () => {
78
+ const style = session.settings.get("task.isolation.commits");
79
+ if (style !== "ai" || !session.modelRegistry) return undefined;
80
+ const registry = session.modelRegistry;
81
+ const settings = session.settings;
82
+ const sessionId = session.getSessionId?.() ?? undefined;
83
+ return async (diff: string) => generateCommitMessage(diff, registry, settings, sessionId);
84
+ };
85
+ }
86
+
87
+ export interface IsolatedRunOptions {
88
+ /**
89
+ * Base run options handed to the subagent subprocess. This helper sets
90
+ * `worktree`, clears `preloadedExtensionPaths` / `preloadedCustomToolPaths`
91
+ * (isolated runs re-discover inside the worktree), and forwards everything
92
+ * else unchanged.
93
+ */
94
+ baseOptions: ExecutorOptions;
95
+ /** Context returned by {@link prepareIsolationContext}. Baseline is cloned per spawn. */
96
+ context: IsolationContext;
97
+ /** PAL backend hint from `parseIsolationMode(...)` (undefined ⇒ resolver picks). */
98
+ preferredBackend: IsoBackendKind | undefined;
99
+ /** Stable id used as the isolation worktree namespace and as the branch suffix. */
100
+ agentId: string;
101
+ /** Merge mode driving how changes are captured ("branch" commits, "patch" diffs). */
102
+ mergeMode: "patch" | "branch";
103
+ /** Output dir for `${agentId}.patch` artifacts (patch mode). */
104
+ artifactsDir: string;
105
+ /** Human description carried onto the branch commit (branch mode). */
106
+ description?: string;
107
+ /** Build a commit-message callback (`task.isolation.commits === "ai"`). */
108
+ buildCommitMessage?: BuildCommitMessage;
109
+ /**
110
+ * Construct a `SingleResult` when isolation setup throws — the caller has
111
+ * the full metadata (index, agent, assignment, modelOverride) needed to
112
+ * build a result shape consistent with their non-isolated path.
113
+ */
114
+ buildFailureResult: (err: unknown) => SingleResult;
115
+ }
116
+
117
+ /**
118
+ * Run a subagent inside an isolation worktree and capture its changes.
119
+ *
120
+ * Branch mode: on success, commits the diff onto `omp/task/${agentId}` and
121
+ * returns `branchName` + `nestedPatches`. On commit failure the branch is
122
+ * deleted and `result.error` carries the merge-failure message.
123
+ *
124
+ * Patch mode: on success, writes `${artifactsDir}/${agentId}.patch` and
125
+ * returns `patchPath` + `nestedPatches`.
126
+ *
127
+ * Failure paths preserve the underlying `SingleResult` whenever possible so
128
+ * the caller can still surface the subagent's output; only isolation setup
129
+ * itself routes through {@link IsolatedRunOptions.buildFailureResult}.
130
+ *
131
+ * The isolation handle is always torn down in `finally`.
132
+ */
133
+ export async function runIsolatedSubprocess(opts: IsolatedRunOptions): Promise<SingleResult> {
134
+ let handle: IsolationHandle | undefined;
135
+ try {
136
+ const taskBaseline = structuredClone(opts.context.baseline);
137
+ handle = await ensureIsolation(opts.context.repoRoot, opts.agentId, opts.preferredBackend);
138
+ const isolationDir = handle.mergedDir;
139
+ const result = await runSubprocess({
140
+ ...opts.baseOptions,
141
+ worktree: isolationDir,
142
+ preloadedExtensionPaths: undefined,
143
+ preloadedCustomToolPaths: undefined,
144
+ });
145
+ if (opts.mergeMode === "branch" && result.exitCode === 0) {
146
+ try {
147
+ const commitResult = await commitToBranch(
148
+ isolationDir,
149
+ taskBaseline,
150
+ opts.agentId,
151
+ opts.description,
152
+ opts.buildCommitMessage?.(),
153
+ );
154
+ return {
155
+ ...result,
156
+ branchName: commitResult?.branchName,
157
+ nestedPatches: commitResult?.nestedPatches,
158
+ };
159
+ } catch (mergeErr) {
160
+ // Agent succeeded but branch commit failed — clean up stale branch
161
+ const branchName = `omp/task/${opts.agentId}`;
162
+ await git.branch.tryDelete(opts.context.repoRoot, branchName);
163
+ const msg = mergeErr instanceof Error ? mergeErr.message : String(mergeErr);
164
+ return { ...result, error: `Merge failed: ${msg}` };
165
+ }
166
+ }
167
+ if (result.exitCode === 0) {
168
+ try {
169
+ const delta = await captureDeltaPatch(isolationDir, taskBaseline);
170
+ const patchPath = path.join(opts.artifactsDir, `${opts.agentId}.patch`);
171
+ await Bun.write(patchPath, delta.rootPatch);
172
+ return {
173
+ ...result,
174
+ patchPath,
175
+ nestedPatches: delta.nestedPatches,
176
+ };
177
+ } catch (patchErr) {
178
+ const msg = patchErr instanceof Error ? patchErr.message : String(patchErr);
179
+ return { ...result, error: `Patch capture failed: ${msg}` };
180
+ }
181
+ }
182
+ return result;
183
+ } catch (err) {
184
+ return opts.buildFailureResult(err);
185
+ } finally {
186
+ if (handle) {
187
+ await cleanupIsolation(handle);
188
+ }
189
+ }
190
+ }
191
+
192
+ export interface IsolationMergeOptions {
193
+ result: SingleResult;
194
+ repoRoot: string;
195
+ mergeMode: "patch" | "branch";
196
+ }
197
+
198
+ export interface IsolationMergeOutcome {
199
+ /** Trailing summary appended to the subagent's result text. May be empty. */
200
+ summary: string;
201
+ /**
202
+ * Tri-state apply outcome:
203
+ * - `true` — merge ran (or had nothing to apply) and left the repo clean.
204
+ * - `false` — merge attempted and failed; artifacts are preserved.
205
+ * - `null` — caller skipped the merge phase entirely (e.g. `apply=false`).
206
+ */
207
+ changesApplied: boolean | null;
208
+ hadAnyChanges: boolean;
209
+ /** True iff the root branch actually merged — gates nested-repo patch application. */
210
+ mergedBranchForNestedPatches: boolean;
211
+ }
212
+
213
+ /**
214
+ * Apply changes captured by {@link runIsolatedSubprocess} back to the parent
215
+ * repo: patch apply (patch mode) or cherry-pick + cleanup (branch mode).
216
+ *
217
+ * The caller decides whether to run this at all — eval `agent()` with
218
+ * `apply=False` skips this step and surfaces the patch artifact / branch name
219
+ * instead.
220
+ */
221
+ export async function mergeIsolatedChanges(opts: IsolationMergeOptions): Promise<IsolationMergeOutcome> {
222
+ const { result, repoRoot, mergeMode } = opts;
223
+ try {
224
+ if (mergeMode === "branch") {
225
+ const canApplyNestedOnly =
226
+ !result.branchName && result.exitCode === 0 && !result.aborted && (result.nestedPatches?.length ?? 0) > 0;
227
+ if (!result.branchName || result.exitCode !== 0 || result.aborted) {
228
+ return {
229
+ summary: canApplyNestedOnly
230
+ ? "\n\nNo root changes to apply; nested repository patches captured."
231
+ : "\n\nNo changes to apply.",
232
+ changesApplied: true,
233
+ hadAnyChanges: canApplyNestedOnly,
234
+ mergedBranchForNestedPatches: canApplyNestedOnly,
235
+ };
236
+ }
237
+ const mergeResult = await mergeTaskBranches(repoRoot, [
238
+ { branchName: result.branchName, taskId: result.id, description: result.description },
239
+ ]);
240
+ const mergedBranchForNestedPatches = mergeResult.merged.includes(result.branchName);
241
+ const changesApplied = mergeResult.failed.length === 0;
242
+ const hadAnyChanges = changesApplied && mergeResult.merged.length > 0;
243
+
244
+ let summary: string;
245
+ if (changesApplied) {
246
+ summary = hadAnyChanges ? `\n\nMerged branch: ${result.branchName}` : "\n\nNo changes to apply.";
247
+ } else {
248
+ const conflictPart = mergeResult.conflict ? `\nConflict: ${mergeResult.conflict}` : "";
249
+ summary = `\n\n<system-notification>Branch merge failed: ${result.branchName}.${conflictPart}\nThe unmerged branch remains for manual resolution.</system-notification>`;
250
+ }
251
+ if (mergeResult.stashConflict) {
252
+ summary += `\n\n<system-notification>${mergeResult.stashConflict}</system-notification>`;
253
+ }
254
+
255
+ // Clean up the merged branch (keep failed ones for manual resolution)
256
+ if (changesApplied) {
257
+ await cleanupTaskBranches(repoRoot, [result.branchName]);
258
+ }
259
+ return { summary, changesApplied, hadAnyChanges, mergedBranchForNestedPatches };
260
+ }
261
+
262
+ // Patch mode: apply the patch from a successful run. A failed or
263
+ // aborted run has nothing to apply and must not block the result.
264
+ let changesApplied: boolean;
265
+ let hadAnyChanges: boolean;
266
+ const succeeded = result.exitCode === 0 && !result.error && !result.aborted;
267
+ if (!succeeded) {
268
+ changesApplied = true;
269
+ hadAnyChanges = false;
270
+ } else if (!result.patchPath) {
271
+ changesApplied = false;
272
+ hadAnyChanges = false;
273
+ } else {
274
+ const patchText = await Bun.file(result.patchPath).text();
275
+ if (!patchText.trim()) {
276
+ changesApplied = true;
277
+ hadAnyChanges = false;
278
+ } else {
279
+ const normalized = patchText.endsWith("\n") ? patchText : `${patchText}\n`;
280
+ changesApplied = await git.patch.canApplyText(repoRoot, normalized);
281
+ hadAnyChanges = false;
282
+ if (changesApplied) {
283
+ try {
284
+ await git.patch.applyText(repoRoot, normalized);
285
+ hadAnyChanges = true;
286
+ } catch {
287
+ changesApplied = false;
288
+ }
289
+ }
290
+ }
291
+ }
292
+
293
+ let summary: string;
294
+ if (changesApplied) {
295
+ summary = hadAnyChanges ? "\n\nApplied patches: yes" : "\n\nNo changes to apply.";
296
+ } else {
297
+ const notification =
298
+ "<system-notification>Patches were not applied and must be handled manually.</system-notification>";
299
+ const patchList = result.patchPath ? `\n\nPatch artifact:\n- ${result.patchPath}` : "";
300
+ summary = `\n\n${notification}${patchList}`;
301
+ }
302
+ return { summary, changesApplied, hadAnyChanges, mergedBranchForNestedPatches: false };
303
+ } catch (mergeErr) {
304
+ const msg = mergeErr instanceof Error ? mergeErr.message : String(mergeErr);
305
+ return {
306
+ summary: `\n\n<system-notification>Merge phase failed: ${msg}\nTask outputs are preserved but changes were not applied.</system-notification>`,
307
+ changesApplied: false,
308
+ hadAnyChanges: false,
309
+ mergedBranchForNestedPatches: false,
310
+ };
311
+ }
312
+ }
313
+
314
+ export interface NestedPatchApplyOptions {
315
+ /** Subagent result carrying `nestedPatches`/`exitCode`/`aborted`. */
316
+ result: SingleResult;
317
+ repoRoot: string;
318
+ mergeMode: "patch" | "branch";
319
+ /** Parent merge outcome — patch mode skips nested apply when this is `false`. */
320
+ changesApplied: boolean | null;
321
+ /** Branch mode gates nested apply on whether the root branch merged. */
322
+ mergedBranchForNestedPatches: boolean;
323
+ /** Optional AI commit-message callback for nested commits; falls back to a generic message. */
324
+ commitMessage?: (diff: string) => Promise<string | null>;
325
+ }
326
+
327
+ /**
328
+ * Apply nested-repo patches after the parent merge phase. Centralizes the
329
+ * three-way gate (exitCode/aborted, patch-mode failed parent, branch-mode
330
+ * branch-merged) and the non-fatal failure handling so `TaskTool` and the
331
+ * eval `agent()` bridge use one implementation.
332
+ *
333
+ * Returns a system-notification suffix to append to the parent merge summary,
334
+ * or an empty string when nothing was applied or the nested apply succeeded.
335
+ */
336
+ export async function applyEligibleNestedPatches(opts: NestedPatchApplyOptions): Promise<string> {
337
+ const { result, repoRoot, mergeMode, changesApplied, mergedBranchForNestedPatches, commitMessage } = opts;
338
+ if (mergeMode === "patch" && changesApplied === false) return "";
339
+ const nestedPatches = result.nestedPatches ?? [];
340
+ const eligible =
341
+ nestedPatches.length > 0 &&
342
+ result.exitCode === 0 &&
343
+ !result.aborted &&
344
+ (mergeMode !== "branch" || mergedBranchForNestedPatches);
345
+ if (!eligible) return "";
346
+ try {
347
+ const warnings = await applyNestedPatches(repoRoot, nestedPatches, commitMessage);
348
+ if (warnings.length === 0) return "";
349
+ return `\n\n<system-notification>${warnings.join("\n")}</system-notification>`;
350
+ } catch {
351
+ // Nested patch failures are non-fatal to the parent merge.
352
+ return "\n\n<system-notification>Some nested repository patches failed to apply.</system-notification>";
353
+ }
354
+ }
@@ -85,6 +85,10 @@ export async function mapWithConcurrencyLimit<T, R>(
85
85
 
86
86
  /**
87
87
  * Simple counting semaphore for limiting concurrency across independently-scheduled async work.
88
+ *
89
+ * `max <= 0` (or any non-finite input) means unbounded — every `acquire()` resolves
90
+ * immediately — matching `task.maxConcurrency = 0`'s "Unlimited" semantics in the
91
+ * settings UI ([#3305](https://github.com/can1357/oh-my-pi/issues/3305)).
88
92
  */
89
93
  export class Semaphore {
90
94
  #max: number;
@@ -92,7 +96,8 @@ export class Semaphore {
92
96
  #queue: Array<() => void> = [];
93
97
 
94
98
  constructor(max: number) {
95
- this.#max = Math.max(1, max);
99
+ const normalizedMax = Number.isFinite(max) ? Math.trunc(max) : 0;
100
+ this.#max = normalizedMax > 0 ? normalizedMax : Number.POSITIVE_INFINITY;
96
101
  }
97
102
 
98
103
  async acquire(): Promise<void> {
@@ -194,6 +194,19 @@ export async function captureDeltaPatch(isolationDir: string, baseline: Worktree
194
194
 
195
195
  /**
196
196
  * Apply nested repo patches directly to their working directories after parent merge.
197
+ *
198
+ * Pre-existing dirty state in a nested repo is stashed before the patch is
199
+ * applied and popped back (with `--index` so staged WIP stays staged) after
200
+ * the commit, so unrelated user edits never get folded into the agent's
201
+ * commit. A failing `git stash pop` (e.g. user edits collide with the patched
202
+ * lines) leaves the stash entry intact, emits a `logger.warn`, and is
203
+ * returned to the caller as a human-readable warning string — the agent
204
+ * commit already landed, so this is a partial success the workflow needs to
205
+ * see, not a thrown failure.
206
+ *
207
+ * Returns the collected stash-restore warnings (empty when every nested repo
208
+ * was restored cleanly). Throws when the patch apply itself fails.
209
+ *
197
210
  * @param commitMessage Optional async function to generate a commit message from the combined diff.
198
211
  * If omitted or returns null, falls back to a generic message.
199
212
  */
@@ -201,7 +214,8 @@ export async function applyNestedPatches(
201
214
  repoRoot: string,
202
215
  patches: NestedRepoPatch[],
203
216
  commitMessage?: (diff: string) => Promise<string | null>,
204
- ): Promise<void> {
217
+ ): Promise<string[]> {
218
+ const warnings: string[] = [];
205
219
  // Group patches by target repo to apply all at once and commit
206
220
  const byRepo = new Map<string, NestedRepoPatch[]>();
207
221
  for (const p of patches) {
@@ -220,17 +234,40 @@ export async function applyNestedPatches(
220
234
  }
221
235
 
222
236
  const combinedDiff = repoPatches.map(p => p.patch).join("\n");
223
- for (const { patch } of repoPatches) {
224
- await git.patch.applyText(nestedDir, patch);
225
- }
226
237
 
227
- // Commit so nested repo history reflects the task changes
228
- if ((await git.status(nestedDir)).trim().length > 0) {
229
- const msg = (await commitMessage?.(combinedDiff)) ?? "changes from isolated task(s)";
230
- await git.stage.files(nestedDir);
231
- await git.commit(nestedDir, msg);
238
+ // Preserve any pre-existing dirty state (tracked + untracked) so we
239
+ // commit only the agent delta, not the user's in-flight work.
240
+ const stashed =
241
+ (await git.status(nestedDir)).trim().length > 0
242
+ ? await git.stash.push(nestedDir, `omp-isolation-${Snowflake.next()}`)
243
+ : false;
244
+ try {
245
+ for (const { patch } of repoPatches) {
246
+ await git.patch.applyText(nestedDir, patch);
247
+ }
248
+ if ((await git.status(nestedDir)).trim().length > 0) {
249
+ const msg = (await commitMessage?.(combinedDiff)) ?? "changes from isolated task(s)";
250
+ await git.stage.files(nestedDir);
251
+ await git.commit(nestedDir, msg);
252
+ }
253
+ } finally {
254
+ if (stashed) {
255
+ try {
256
+ await git.stash.pop(nestedDir, { index: true });
257
+ } catch (popErr) {
258
+ const message = popErr instanceof Error ? popErr.message : String(popErr);
259
+ logger.warn("Pre-existing nested-repo dirty state could not be auto-restored", {
260
+ nestedDir,
261
+ error: message,
262
+ });
263
+ warnings.push(
264
+ `Pre-existing dirty state in nested repo \`${relativePath}\` could not be auto-restored after the agent commit; stash entry preserved (${message}).`,
265
+ );
266
+ }
267
+ }
232
268
  }
233
269
  }
270
+ return warnings;
234
271
  }
235
272
 
236
273
  // ═══════════════════════════════════════════════════════════════════════════
package/src/thinking.ts CHANGED
@@ -153,6 +153,26 @@ export function getConfiguredThinkingLevelMetadata(level: ConfiguredThinkingLeve
153
153
  return level === AUTO_THINKING ? AUTO_THINKING_METADATA : getThinkingLevelMetadata(level);
154
154
  }
155
155
 
156
+ /**
157
+ * Thinking selectors accepted by the `--thinking` CLI flag, in display order:
158
+ * `off`, every concrete effort (`minimal`..`xhigh`), then `auto`. Single source
159
+ * for the flag's `options` list, shell completions, and the "invalid level"
160
+ * warning so all three stay in sync.
161
+ */
162
+ export const CLI_THINKING_LEVELS: readonly string[] = [ThinkingLevel.Off, ...THINKING_EFFORTS, AUTO_THINKING];
163
+
164
+ /**
165
+ * Parses a `--thinking` CLI value. Accepts every {@link parseConfiguredThinkingLevel}
166
+ * selector (`off`, `auto`, `minimal`..`xhigh`, plus the `max` alias) but rejects
167
+ * `inherit`: an explicit `inherit` on the command line would suppress the
168
+ * settings/scoped-model fallback during startup resolution only to resolve back
169
+ * to the provider default, which is never what the user means.
170
+ */
171
+ export function parseCliThinkingLevel(value: string | null | undefined): ConfiguredThinkingLevel | undefined {
172
+ const level = parseConfiguredThinkingLevel(value);
173
+ return level === ThinkingLevel.Inherit ? undefined : level;
174
+ }
175
+
156
176
  /**
157
177
  * Resolves an auto-classified effort against the active model's supported
158
178
  * range. Unlike {@link clampThinkingLevelForModel}, `auto` never resolves below
@@ -160,10 +180,17 @@ export function getConfiguredThinkingLevelMetadata(level: ConfiguredThinkingLeve
160
180
  * above Low (falling back to the full supported set only when the model maxes
161
181
  * out below Low). Within that pool the request snaps to the highest level not
162
182
  * exceeding it, or the pool minimum when the request is below the pool.
183
+ *
184
+ * Returns `undefined` for reasoning-capable models without a controllable
185
+ * effort surface (`thinking.efforts` empty — e.g. devin-agent models, where
186
+ * Cascade selects effort by routing to sibling model ids). Matches
187
+ * {@link clampThinkingLevelForModel}: with no effort to pick, `auto` must not
188
+ * forward a concrete effort that would then trip {@link requireSupportedEffort}
189
+ * downstream.
163
190
  */
164
- export function clampAutoThinkingEffort(model: Model | undefined, effort: Effort): Effort {
191
+ export function clampAutoThinkingEffort(model: Model | undefined, effort: Effort): Effort | undefined {
165
192
  const supported = model ? getSupportedEfforts(model) : THINKING_EFFORTS;
166
- if (supported.length === 0) return effort;
193
+ if (supported.length === 0) return undefined;
167
194
  const lowIndex = THINKING_EFFORTS.indexOf(Effort.Low);
168
195
  const eligible = supported.filter(level => THINKING_EFFORTS.indexOf(level) >= lowIndex);
169
196
  const pool = eligible.length > 0 ? eligible : supported;