@oh-my-pi/pi-coding-agent 17.1.7 → 17.2.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 (246) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/dist/{CHANGELOG-5k4dq4g6.md → CHANGELOG-be1f2t8h.md} +127 -0
  3. package/dist/cli.js +9554 -12379
  4. package/dist/template-8vdv6xb4.js +1671 -0
  5. package/dist/template-f8wx9vfn.css +1355 -0
  6. package/dist/template-qat058wr.html +55 -0
  7. package/dist/tool-views.generated-jdfmzwmn.js +35 -0
  8. package/dist/types/advisor/advise-tool.d.ts +0 -7
  9. package/dist/types/advisor/runtime.d.ts +8 -11
  10. package/dist/types/advisor/transcript-recorder.d.ts +15 -0
  11. package/dist/types/async/job-manager.d.ts +11 -0
  12. package/dist/types/auto-thinking/classifier.d.ts +9 -2
  13. package/dist/types/cleanse/agent.d.ts +19 -0
  14. package/dist/types/cleanse/balance.d.ts +7 -0
  15. package/dist/types/cleanse/checkers.d.ts +13 -0
  16. package/dist/types/cleanse/index.d.ts +16 -0
  17. package/dist/types/cleanse/loop.d.ts +16 -0
  18. package/dist/types/cleanse/parsers.d.ts +13 -0
  19. package/dist/types/cleanse/progress.d.ts +14 -0
  20. package/dist/types/cleanse/types.d.ts +64 -0
  21. package/dist/types/cli/args.d.ts +2 -0
  22. package/dist/types/cli/session-picker.d.ts +14 -7
  23. package/dist/types/collab/guest.d.ts +21 -2
  24. package/dist/types/commands/cleanse.d.ts +23 -0
  25. package/dist/types/commands/launch.d.ts +6 -0
  26. package/dist/types/config/settings-schema.d.ts +63 -8
  27. package/dist/types/cursor-bridge-tools.d.ts +54 -0
  28. package/dist/types/cursor.d.ts +193 -8
  29. package/dist/types/edit/edit-clipboard.d.ts +19 -0
  30. package/dist/types/edit/hashline/diff.d.ts +9 -1
  31. package/dist/types/edit/index.d.ts +8 -1
  32. package/dist/types/edit/streaming.d.ts +8 -1
  33. package/dist/types/export/html/index.d.ts +2 -0
  34. package/dist/types/extensibility/extensions/runner.d.ts +19 -2
  35. package/dist/types/extensibility/extensions/types.d.ts +25 -1
  36. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +27 -1
  37. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +2 -0
  38. package/dist/types/internal-urls/local-protocol.d.ts +3 -2
  39. package/dist/types/launch/terminal-output-worker-client.d.ts +5 -0
  40. package/dist/types/launch/terminal-output-worker-protocol.d.ts +14 -0
  41. package/dist/types/launch/terminal-output-worker.d.ts +1 -0
  42. package/dist/types/lsp/client.d.ts +2 -0
  43. package/dist/types/main.d.ts +2 -0
  44. package/dist/types/mcp/manager.d.ts +33 -4
  45. package/dist/types/modes/components/codex-reset-fireworks.d.ts +51 -0
  46. package/dist/types/modes/components/read-tool-group.d.ts +12 -0
  47. package/dist/types/modes/components/session-selector.d.ts +6 -0
  48. package/dist/types/modes/components/status-line/component.d.ts +23 -0
  49. package/dist/types/modes/components/tool-execution.d.ts +16 -7
  50. package/dist/types/modes/components/usage-row.d.ts +2 -0
  51. package/dist/types/modes/controllers/mcp-command-controller.d.ts +26 -1
  52. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  53. package/dist/types/modes/interactive-mode.d.ts +8 -5
  54. package/dist/types/modes/rpc/rpc-client.d.ts +7 -0
  55. package/dist/types/modes/rpc/rpc-types.d.ts +16 -0
  56. package/dist/types/modes/theme/theme.d.ts +12 -1
  57. package/dist/types/modes/types.d.ts +2 -1
  58. package/dist/types/sdk.d.ts +1 -0
  59. package/dist/types/session/agent-session-events.d.ts +2 -0
  60. package/dist/types/session/agent-session-types.d.ts +36 -1
  61. package/dist/types/session/agent-session.d.ts +4 -2
  62. package/dist/types/session/async-job-delivery.d.ts +8 -0
  63. package/dist/types/session/claude-session-store.d.ts +13 -0
  64. package/dist/types/session/codex-session-store.d.ts +14 -0
  65. package/dist/types/session/credential-pin.d.ts +58 -0
  66. package/dist/types/session/foreign-session-import.d.ts +15 -0
  67. package/dist/types/session/foreign-session-jsonl.d.ts +9 -0
  68. package/dist/types/session/foreign-session-store.d.ts +23 -0
  69. package/dist/types/session/model-controls.d.ts +3 -3
  70. package/dist/types/session/session-advisors.d.ts +47 -2
  71. package/dist/types/session/session-entries.d.ts +19 -1
  72. package/dist/types/session/session-handoff.d.ts +4 -1
  73. package/dist/types/session/session-loader.d.ts +2 -0
  74. package/dist/types/session/session-maintenance.d.ts +1 -1
  75. package/dist/types/session/session-manager.d.ts +21 -0
  76. package/dist/types/session/session-tools.d.ts +25 -1
  77. package/dist/types/session/turn-recovery.d.ts +10 -4
  78. package/dist/types/system-prompt.d.ts +2 -1
  79. package/dist/types/task/executor.d.ts +11 -2
  80. package/dist/types/task/isolation-ownership.d.ts +34 -0
  81. package/dist/types/task/render.d.ts +7 -0
  82. package/dist/types/thinking.d.ts +18 -6
  83. package/dist/types/tools/acp-bridge.d.ts +28 -5
  84. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +1 -2
  85. package/dist/types/tools/browser/tab-worker.d.ts +1 -4
  86. package/dist/types/tools/grep.d.ts +17 -1
  87. package/dist/types/tools/hub/launch.d.ts +5 -1
  88. package/dist/types/tools/index.d.ts +4 -1
  89. package/dist/types/tools/path-utils.d.ts +19 -0
  90. package/dist/types/tools/read.d.ts +2 -0
  91. package/dist/types/tools/xdev.d.ts +2 -1
  92. package/dist/types/tui/output-block.d.ts +5 -5
  93. package/dist/types/utils/changelog.d.ts +19 -0
  94. package/dist/types/utils/git.d.ts +10 -0
  95. package/dist/types/utils/jj.d.ts +11 -4
  96. package/dist/types/web/search/types.d.ts +2 -2
  97. package/package.json +16 -12
  98. package/scripts/bundle-dist.ts +3 -1
  99. package/src/advisor/advise-tool.ts +0 -11
  100. package/src/advisor/runtime.ts +138 -24
  101. package/src/advisor/transcript-recorder.ts +56 -0
  102. package/src/async/job-manager.ts +30 -7
  103. package/src/auto-thinking/classifier.ts +48 -10
  104. package/src/cleanse/agent.ts +226 -0
  105. package/src/cleanse/balance.ts +79 -0
  106. package/src/cleanse/checkers.ts +996 -0
  107. package/src/cleanse/index.ts +190 -0
  108. package/src/cleanse/loop.ts +51 -0
  109. package/src/cleanse/parsers.ts +726 -0
  110. package/src/cleanse/progress.ts +50 -0
  111. package/src/cleanse/prompts/assignment.md +47 -0
  112. package/src/cleanse/types.ts +72 -0
  113. package/src/cli/args.ts +6 -0
  114. package/src/cli/flag-tables.ts +2 -0
  115. package/src/cli/gallery-fixtures/fs.ts +25 -0
  116. package/src/cli/session-picker.ts +34 -17
  117. package/src/cli/ttsr-cli.ts +19 -1
  118. package/src/cli/update-cli.ts +3 -0
  119. package/src/cli/usage-cli.ts +29 -4
  120. package/src/cli/worktree-cli.ts +28 -11
  121. package/src/cli-commands.ts +1 -0
  122. package/src/cli.ts +11 -1
  123. package/src/collab/guest.ts +37 -4
  124. package/src/collab/host.ts +1 -0
  125. package/src/commands/cleanse.ts +45 -0
  126. package/src/commands/launch.ts +6 -0
  127. package/src/config/model-registry.ts +103 -25
  128. package/src/config/settings-schema.ts +65 -6
  129. package/src/config/settings.ts +25 -0
  130. package/src/cursor-bridge-tools.ts +81 -0
  131. package/src/cursor.ts +463 -12
  132. package/src/discovery/claude-plugins.ts +144 -34
  133. package/src/edit/edit-clipboard.ts +23 -0
  134. package/src/edit/hashline/diff.ts +49 -10
  135. package/src/edit/hashline/execute.ts +38 -6
  136. package/src/edit/hashline/filesystem.ts +27 -3
  137. package/src/edit/index.ts +12 -2
  138. package/src/edit/renderer.ts +4 -4
  139. package/src/edit/streaming.ts +17 -2
  140. package/src/export/html/index.ts +17 -10
  141. package/src/export/html/template.js +1 -1
  142. package/src/extensibility/extensions/runner.ts +70 -0
  143. package/src/extensibility/extensions/types.ts +30 -0
  144. package/src/extensibility/legacy-pi-ai-shim.ts +46 -1
  145. package/src/extensibility/legacy-pi-coding-agent-shim.ts +12 -13
  146. package/src/internal-urls/local-protocol.ts +3 -2
  147. package/src/internal-urls/mcp-protocol.ts +4 -1
  148. package/src/launch/broker.ts +14 -4
  149. package/src/launch/terminal-output-worker-client.ts +53 -0
  150. package/src/launch/terminal-output-worker-protocol.ts +11 -0
  151. package/src/launch/terminal-output-worker.ts +23 -0
  152. package/src/lsp/client.ts +18 -3
  153. package/src/lsp/index.ts +4 -0
  154. package/src/lsp/render.ts +1 -1
  155. package/src/main.ts +127 -56
  156. package/src/mcp/manager.ts +120 -18
  157. package/src/modes/acp/acp-agent.ts +27 -18
  158. package/src/modes/acp/acp-event-mapper.ts +38 -4
  159. package/src/modes/components/chat-transcript-builder.ts +30 -11
  160. package/src/modes/components/codex-reset-fireworks.ts +369 -0
  161. package/src/modes/components/read-tool-group.ts +159 -17
  162. package/src/modes/components/session-selector.ts +19 -3
  163. package/src/modes/components/settings-selector.ts +9 -1
  164. package/src/modes/components/status-line/component.ts +381 -54
  165. package/src/modes/components/tool-execution.ts +78 -31
  166. package/src/modes/components/usage-row.ts +10 -5
  167. package/src/modes/controllers/event-controller.ts +26 -4
  168. package/src/modes/controllers/mcp-command-controller.ts +68 -3
  169. package/src/modes/controllers/selector-controller.ts +94 -36
  170. package/src/modes/controllers/tan-command-controller.ts +14 -0
  171. package/src/modes/interactive-mode.ts +67 -64
  172. package/src/modes/prompt-action-autocomplete.ts +5 -3
  173. package/src/modes/rpc/rpc-client.ts +18 -0
  174. package/src/modes/rpc/rpc-mode.ts +16 -1
  175. package/src/modes/rpc/rpc-types.ts +11 -0
  176. package/src/modes/theme/theme.ts +151 -6
  177. package/src/modes/types.ts +2 -1
  178. package/src/modes/utils/ui-helpers.ts +38 -21
  179. package/src/prompts/goals/guided-goal-interview.md +41 -6
  180. package/src/prompts/system/auto-thinking-difficulty.md +4 -2
  181. package/src/prompts/system/plan-mode-active.md +2 -2
  182. package/src/prompts/system/system-prompt.md +3 -0
  183. package/src/prompts/system/vibe-mode-active.md +4 -1
  184. package/src/prompts/system/xdev-mount-notice.md +1 -1
  185. package/src/prompts/tools/bash.md +15 -17
  186. package/src/prompts/tools/browser.md +1 -0
  187. package/src/prompts/tools/checkpoint.md +1 -1
  188. package/src/prompts/tools/glob.md +7 -6
  189. package/src/prompts/tools/goal.md +1 -1
  190. package/src/prompts/tools/grep.md +7 -6
  191. package/src/sdk.ts +178 -15
  192. package/src/session/agent-session-events.ts +1 -0
  193. package/src/session/agent-session-types.ts +40 -1
  194. package/src/session/agent-session.ts +330 -179
  195. package/src/session/async-job-delivery.ts +8 -0
  196. package/src/session/claude-session-store.ts +426 -0
  197. package/src/session/codex-session-store.ts +673 -0
  198. package/src/session/credential-pin.ts +93 -0
  199. package/src/session/foreign-session-import.ts +52 -0
  200. package/src/session/foreign-session-jsonl.ts +29 -0
  201. package/src/session/foreign-session-store.ts +26 -0
  202. package/src/session/model-controls.ts +15 -7
  203. package/src/session/session-advisors.ts +142 -23
  204. package/src/session/session-entries.ts +21 -1
  205. package/src/session/session-handoff.ts +20 -3
  206. package/src/session/session-history-format.ts +31 -6
  207. package/src/session/session-loader.ts +25 -9
  208. package/src/session/session-maintenance.ts +59 -11
  209. package/src/session/session-manager.ts +61 -0
  210. package/src/session/session-tools.ts +107 -5
  211. package/src/session/settings-stream-fn.ts +1 -0
  212. package/src/session/turn-recovery.ts +97 -51
  213. package/src/slash-commands/builtin-registry.ts +136 -5
  214. package/src/system-prompt.ts +3 -2
  215. package/src/task/executor.ts +17 -4
  216. package/src/task/isolation-ownership.ts +106 -0
  217. package/src/task/render.ts +20 -4
  218. package/src/task/worktree.ts +8 -0
  219. package/src/thinking.ts +29 -10
  220. package/src/tools/acp-bridge.ts +52 -8
  221. package/src/tools/ask.ts +3 -3
  222. package/src/tools/ast-edit.ts +9 -2
  223. package/src/tools/browser/cmux/cmux-tab.ts +9 -14
  224. package/src/tools/browser/tab-worker.ts +12 -35
  225. package/src/tools/browser.ts +7 -2
  226. package/src/tools/checkpoint.ts +0 -13
  227. package/src/tools/gh-renderer.ts +3 -3
  228. package/src/tools/grep.ts +64 -8
  229. package/src/tools/hub/launch.ts +10 -15
  230. package/src/tools/index.ts +38 -5
  231. package/src/tools/path-utils.ts +88 -0
  232. package/src/tools/read.ts +3 -0
  233. package/src/tools/write.ts +44 -3
  234. package/src/tools/xdev.ts +39 -14
  235. package/src/tui/code-cell.ts +4 -4
  236. package/src/tui/output-block.ts +25 -8
  237. package/src/utils/changelog.ts +144 -4
  238. package/src/utils/git.ts +61 -14
  239. package/src/utils/jj.ts +19 -13
  240. package/src/utils/shell-snapshot-fn-env.sh +5 -2
  241. package/src/web/search/providers/codex.ts +34 -0
  242. package/src/web/search/render.ts +2 -2
  243. package/src/web/search/types.ts +1 -1
  244. package/dist/types/goals/guided-setup.d.ts +0 -30
  245. package/src/goals/guided-setup.ts +0 -171
  246. package/src/prompts/goals/guided-goal-system.md +0 -33
@@ -49,6 +49,13 @@ interface TaskRenderContext {
49
49
  * commit-eligible rows do not repaint after entering native scrollback.
50
50
  */
51
51
  frozen?: boolean;
52
+ /**
53
+ * Wall clock for time-derived rows (current-tool elapsed, retry countdown).
54
+ * The component freezes it once the block settles or any of its rows enter
55
+ * native scrollback, so identical-input rebuilds stay byte-identical with
56
+ * committed history. Absent: render with the live clock.
57
+ */
58
+ nowMs?: number;
52
59
  }
53
60
  type TaskRenderOptions = RenderResultOptions & { renderContext?: TaskRenderContext };
54
61
 
@@ -888,6 +895,7 @@ function renderAgentProgress(
888
895
  frozen = false,
889
896
  seenNestedTasks?: WeakSet<object>,
890
897
  nestedDepth = 0,
898
+ nowMs = Date.now(),
891
899
  ): string[] {
892
900
  const lines: string[] = [];
893
901
 
@@ -964,7 +972,7 @@ function renderAgentProgress(
964
972
  toolLine += `: ${theme.fg("dim", previewLine(sanitizeText(toolDetail), 40))}`;
965
973
  }
966
974
  if (progress.currentToolStartMs) {
967
- const elapsed = Date.now() - progress.currentToolStartMs;
975
+ const elapsed = nowMs - progress.currentToolStartMs;
968
976
  if (elapsed > 5000) {
969
977
  toolLine += `${theme.sep.dot}${theme.fg("warning", formatDuration(elapsed))}`;
970
978
  }
@@ -986,7 +994,7 @@ function renderAgentProgress(
986
994
  // long until the next attempt. Without this, the parent UI would just
987
995
  // keep spinning while a child sleeps on a 3-hour provider rate-limit.
988
996
  if (progress.retryState && progress.status === "running") {
989
- const remainingMs = Math.max(0, progress.retryState.startedAtMs + progress.retryState.delayMs - Date.now());
997
+ const remainingMs = Math.max(0, progress.retryState.startedAtMs + progress.retryState.delayMs - nowMs);
990
998
  const waitLabel = remainingMs > 0 ? `in ${formatDuration(remainingMs)}` : "now";
991
999
  const summary =
992
1000
  `retrying ${progress.retryState.attempt}/${progress.retryState.maxAttempts} ${waitLabel}: ` +
@@ -1079,6 +1087,7 @@ function renderAgentProgress(
1079
1087
  frozen,
1080
1088
  seenNestedTasks,
1081
1089
  nestedDepth,
1090
+ nowMs,
1082
1091
  );
1083
1092
  for (const line of nestedLines) {
1084
1093
  lines.push(`${continuePrefix}${line}`);
@@ -1560,6 +1569,7 @@ export function renderResult(
1560
1569
  return framedBlock(theme, width => {
1561
1570
  const { expanded, isPartial, spinnerFrame } = options;
1562
1571
  const frozen = options.renderContext?.frozen === true;
1572
+ const nowMs = options.renderContext?.nowMs ?? Date.now();
1563
1573
  const lines: string[] = [];
1564
1574
 
1565
1575
  // Result rows win once any exist; progress rows for spawns without a
@@ -1577,7 +1587,9 @@ export function renderResult(
1577
1587
  lines.push(formatHiddenProgressLine(ordered.slice(0, ordered.length - visible.length), theme));
1578
1588
  }
1579
1589
  for (const progress of visible) {
1580
- lines.push(...renderAgentProgress(progress, "", " ", expanded, theme, spinnerFrame, frozen));
1590
+ lines.push(
1591
+ ...renderAgentProgress(progress, "", " ", expanded, theme, spinnerFrame, frozen, undefined, 0, nowMs),
1592
+ );
1581
1593
  }
1582
1594
  } else if (details.results && details.results.length > 0) {
1583
1595
  const ordered = orderResultsForDisplay(details.results);
@@ -1602,7 +1614,9 @@ export function renderResult(
1602
1614
  )
1603
1615
  : [];
1604
1616
  for (const progress of supplementalProgress) {
1605
- lines.push(...renderAgentProgress(progress, "", " ", expanded, theme, spinnerFrame, frozen));
1617
+ lines.push(
1618
+ ...renderAgentProgress(progress, "", " ", expanded, theme, spinnerFrame, frozen, undefined, 0, nowMs),
1619
+ );
1606
1620
  }
1607
1621
 
1608
1622
  const summaryParts: string[] = [];
@@ -1742,6 +1756,7 @@ function renderNestedTaskTree(
1742
1756
  frozen = false,
1743
1757
  seen: WeakSet<object> = new WeakSet<object>(),
1744
1758
  depth = 0,
1759
+ nowMs = Date.now(),
1745
1760
  ): string[] {
1746
1761
  const lines: string[] = [];
1747
1762
  for (const details of detailsList) {
@@ -1788,6 +1803,7 @@ function renderNestedTaskTree(
1788
1803
  frozen,
1789
1804
  seen,
1790
1805
  depth + 1,
1806
+ nowMs,
1791
1807
  ),
1792
1808
  );
1793
1809
  });
@@ -6,6 +6,7 @@ import * as natives from "@oh-my-pi/pi-natives";
6
6
  import { getWorktreeDir, logger, Snowflake } from "@oh-my-pi/pi-utils";
7
7
  import * as git from "../utils/git";
8
8
  import * as jj from "../utils/jj";
9
+ import { writeIsolationOwner } from "./isolation-ownership";
9
10
  import { mapWithConcurrencyLimit } from "./parallel";
10
11
 
11
12
  const { IsoBackendKind } = natives;
@@ -434,6 +435,13 @@ export async function ensureIsolation(
434
435
 
435
436
  for (const candidate of candidates) {
436
437
  await fs.rm(baseDir, { recursive: true, force: true });
438
+ // Claim ownership before the backend materialises `m`. Backends only
439
+ // create/replace `mergedDir` (and overlay upper/work), never the base
440
+ // dir, so the marker survives `isoStart` — and a concurrent
441
+ // `omp worktree clear` never sees this sandbox without a live owner,
442
+ // even while a large clone is still in progress.
443
+ await fs.mkdir(baseDir, { recursive: true });
444
+ await writeIsolationOwner(baseDir, id);
437
445
  try {
438
446
  await natives.isoStart(candidate, repoRoot, mergedDir);
439
447
  // Sever the isolation's git metadata from the source checkout. Copy
package/src/thinking.ts CHANGED
@@ -191,7 +191,7 @@ export interface ConfiguredThinkingLevelMetadata {
191
191
  const AUTO_THINKING_METADATA: ConfiguredThinkingLevelMetadata = {
192
192
  value: AUTO_THINKING,
193
193
  label: "auto",
194
- description: "Auto-detect per prompt (low–xhigh)",
194
+ description: "Auto-detect per prompt",
195
195
  };
196
196
 
197
197
  /**
@@ -236,6 +236,12 @@ export function parseCliThinkingLevel(value: string | null | undefined): Configu
236
236
  * above Low (falling back to the full supported set only when the model maxes
237
237
  * out below Low). Within that pool the request snaps to the highest level not
238
238
  * exceeding it, or the pool minimum when the request is below the pool.
239
+ * `ceiling` bounds the pool from above, so a policy ceiling survives the model
240
+ * clamp: a sparse ladder such as `["max"]` must not snap an `xhigh` request up
241
+ * to `max`. The Low floor is resolved against the model's own ladder *before*
242
+ * the ceiling applies — a ceiling that hides every tier at or above Low means
243
+ * there is nothing legal to pick (`undefined`), not a licence to fall through
244
+ * to a sub-Low tier the model happens to expose.
239
245
  *
240
246
  * Returns `undefined` for reasoning-capable models without a controllable
241
247
  * effort surface (`thinking.efforts` empty — e.g. devin-agent models, where
@@ -244,12 +250,19 @@ export function parseCliThinkingLevel(value: string | null | undefined): Configu
244
250
  * forward a concrete effort that would then trip {@link requireSupportedEffort}
245
251
  * downstream.
246
252
  */
247
- export function clampAutoThinkingEffort(model: Model | undefined, effort: Effort): Effort | undefined {
253
+ export function clampAutoThinkingEffort(
254
+ model: Model | undefined,
255
+ effort: Effort,
256
+ ceiling: Effort = Effort.Max,
257
+ ): Effort | undefined {
248
258
  const supported = model ? getSupportedEfforts(model) : THINKING_EFFORTS;
249
259
  if (supported.length === 0) return undefined;
250
260
  const lowIndex = THINKING_EFFORTS.indexOf(Effort.Low);
251
- const eligible = supported.filter(level => THINKING_EFFORTS.indexOf(level) >= lowIndex);
252
- const pool = eligible.length > 0 ? eligible : supported;
261
+ const ceilingIndex = THINKING_EFFORTS.indexOf(ceiling);
262
+ const atOrAboveLow = supported.filter(level => THINKING_EFFORTS.indexOf(level) >= lowIndex);
263
+ const floored = atOrAboveLow.length > 0 ? atOrAboveLow : supported;
264
+ const pool = floored.filter(level => THINKING_EFFORTS.indexOf(level) <= ceilingIndex);
265
+ if (pool.length === 0) return undefined;
253
266
  const requestedIndex = THINKING_EFFORTS.indexOf(effort);
254
267
  let chosen = pool[0];
255
268
  for (const candidate of pool) {
@@ -351,14 +364,20 @@ export function modelSupportsEffortCeiling(model: Model, ceiling: Effort): boole
351
364
 
352
365
  /**
353
366
  * The provisional concrete level shown while `auto` is configured but before a
354
- * turn has been classified. Prefers the model's `defaultLevel`, otherwise High,
355
- * clamped into the auto range. Auto never provisions {@link Effort.Max} (the
356
- * classifier ceiling is XHigh; only an explicit user request reaches Max), so a
357
- * `defaultLevel` of `max` is capped at XHigh before clamping. Returns
358
- * `undefined` for non-reasoning models.
367
+ * turn has been classified, and the fallback when classification fails. Prefers
368
+ * the model's `defaultLevel`, otherwise High, clamped into the auto range.
369
+ *
370
+ * Deliberately stays below {@link Effort.Max}: the placeholder must not bill the
371
+ * top tier for a turn nobody classified, so XHigh is passed as a hard ceiling
372
+ * rather than only capping the preferred level — otherwise a sparse `["max"]`
373
+ * ladder would snap straight back up. A model whose ladder offers nothing at or
374
+ * below XHigh therefore has no provisional level, and `auto` leaves the current
375
+ * one in place. Classification itself may still resolve Max on models that
376
+ * expose the tier when the user opts in. Returns `undefined` for non-reasoning
377
+ * models.
359
378
  */
360
379
  export function resolveProvisionalAutoLevel(model: Model | undefined): Effort | undefined {
361
380
  if (!model?.reasoning) return undefined;
362
381
  const preferred = model.thinking?.defaultLevel ?? Effort.High;
363
- return clampAutoThinkingEffort(model, preferred === Effort.Max ? Effort.XHigh : preferred);
382
+ return clampAutoThinkingEffort(model, preferred === Effort.Max ? Effort.XHigh : preferred, Effort.XHigh);
364
383
  }
@@ -40,15 +40,39 @@ export function shouldRouteWriteThroughBridge(
40
40
  return absolutePath !== resolvePlanPath(session, state.planFilePath);
41
41
  }
42
42
 
43
+ /**
44
+ * Result of a bridge-routed write: the content actually verified on disk
45
+ * after the client processed the write, plus whether that content diverges
46
+ * from what the tool asked to persist.
47
+ *
48
+ * ACP's `fs/write_text_file` has no "verbatim, no side effects" guarantee —
49
+ * a client (e.g. Zed with `format_on_save: on`) may reformat the buffer as
50
+ * part of handling the write before it settles on disk. Silently trusting
51
+ * the requested `content` as "what's now on disk" lets that drift poison
52
+ * every snapshot/tag/hash a caller derives from the write, which then reads
53
+ * back as unrelated whole-file corruption on the *next* edit. Reading the
54
+ * file back and reporting what's actually there keeps callers honest.
55
+ */
56
+ export interface BridgeWriteResult {
57
+ /** Content actually present on disk immediately after the bridge write. */
58
+ text: string;
59
+ /** `true` when `text` differs from the content the tool asked to write. */
60
+ driftedFromRequest: boolean;
61
+ }
62
+
43
63
  /**
44
64
  * Try to route a file write through the ACP client bridge.
45
65
  *
46
66
  * Performs the full guard check, bridge call (wrapped in {@link ToolError}),
47
- * FS-scan cache invalidation, and session mutation-version bump.
67
+ * a post-write read-back to detect client-side transformation (e.g.
68
+ * format-on-save), FS-scan cache invalidation, and session mutation-version
69
+ * bump.
48
70
  *
49
- * Returns `true` when the bridge was used and the caller must skip the
50
- * writethrough path. Returns `false` when the bridge is unavailable or the
51
- * path should not be routed through it.
71
+ * Returns `undefined` when the bridge is unavailable or the path should not
72
+ * be routed through it the caller must fall back to the writethrough path.
73
+ * Returns a {@link BridgeWriteResult} when the bridge was used; callers MUST
74
+ * use `result.text` (not the content they requested) for any snapshot, hash,
75
+ * or tag derived from this write.
52
76
  */
53
77
  export async function routeWriteThroughBridge(
54
78
  session: ToolSession,
@@ -56,11 +80,11 @@ export async function routeWriteThroughBridge(
56
80
  absolutePath: string,
57
81
  content: string,
58
82
  signal?: AbortSignal,
59
- ): Promise<boolean> {
60
- if (!shouldRouteWriteThroughBridge(session, requestedPath, absolutePath)) return false;
83
+ ): Promise<BridgeWriteResult | undefined> {
84
+ if (!shouldRouteWriteThroughBridge(session, requestedPath, absolutePath)) return undefined;
61
85
 
62
86
  const bridge = session.getClientBridge?.();
63
- if (!bridge?.capabilities.writeTextFile || !bridge.writeTextFile) return false;
87
+ if (!bridge?.capabilities.writeTextFile || !bridge.writeTextFile) return undefined;
64
88
 
65
89
  const changeType = (await Bun.file(absolutePath).exists()) ? FileChangeType.Changed : FileChangeType.Created;
66
90
  // The ACP protocol has no cancellation for fs writes; the most we can do is
@@ -77,5 +101,25 @@ export async function routeWriteThroughBridge(
77
101
  }
78
102
  invalidateFsScanAfterWrite(absolutePath);
79
103
  session.bumpFileMutationVersion?.(absolutePath);
80
- return true;
104
+
105
+ // Best-effort verification: the client already flushed the write (that's
106
+ // the whole point of `fs/write_text_file`), so the file on disk reflects
107
+ // whatever the client actually persisted, formatter and all. If the
108
+ // read-back itself fails, fall back to trusting `content` rather than
109
+ // failing an otherwise-successful write. This is a best-effort signal,
110
+ // not a guarantee: ACP defines no ordering between a client acking the
111
+ // write and its own async format-on-save settling, so a client that acks
112
+ // before its formatter runs will look verbatim here. That degrades to
113
+ // the pre-fix behavior for THIS write, but never corrupts state: the
114
+ // next `readText` still observes whatever the client eventually settles
115
+ // on, and a real desync there surfaces as an honest stale-tag error
116
+ // instead of a silently wrong tag.
117
+ let actualText = content;
118
+ try {
119
+ actualText = await Bun.file(absolutePath).text();
120
+ } catch {
121
+ // Unreadable right after a reported-successful write; nothing more we
122
+ // can verify here.
123
+ }
124
+ return { text: actualText, driftedFromRequest: actualText !== content };
81
125
  }
package/src/tools/ask.ts CHANGED
@@ -36,7 +36,7 @@ import type { ExtensionUISelectItem } from "../extensibility/extensions";
36
36
  import { getMarkdownTheme, type Theme, theme } from "../modes/theme/theme";
37
37
  import askDescription from "../prompts/tools/ask.md" with { type: "text" };
38
38
  import { vocalizer } from "../tts/vocalizer";
39
- import { framedBlock, renderStatusLine } from "../tui";
39
+ import { framedBlock, outputBlockContentWidth, renderStatusLine } from "../tui";
40
40
  import type { ToolSession } from ".";
41
41
  import { formatErrorMessage, formatMeta, formatTitle } from "./render-utils";
42
42
  import { ToolAbortError } from "./tool-errors";
@@ -1276,7 +1276,7 @@ export const askToolRenderer = {
1276
1276
  const mdTheme = getMarkdownTheme();
1277
1277
  const accentStyle = { color: (t: string) => uiTheme.fg("accent", t) };
1278
1278
  const md = (text: string, width: number) =>
1279
- new Markdown(text, 1, 0, mdTheme, accentStyle).render(Math.max(1, width - 3 + 1));
1279
+ new Markdown(text, 1, 0, mdTheme, accentStyle).render(Math.max(1, outputBlockContentWidth(width) + 1));
1280
1280
 
1281
1281
  // Multi-part questions: one divider-labelled section per question.
1282
1282
  // Call args are untrusted (partially streamed or model-mangled) and a
@@ -1345,7 +1345,7 @@ export const askToolRenderer = {
1345
1345
  const mdTheme = getMarkdownTheme();
1346
1346
  const accentStyle = { color: (t: string) => uiTheme.fg("accent", t) };
1347
1347
  const md = (text: string, width: number) =>
1348
- new Markdown(text, 1, 0, mdTheme, accentStyle).render(Math.max(1, width - 3 + 1));
1348
+ new Markdown(text, 1, 0, mdTheme, accentStyle).render(Math.max(1, outputBlockContentWidth(width) + 1));
1349
1349
 
1350
1350
  if (!details) {
1351
1351
  const txt = result.content[0];
@@ -12,7 +12,14 @@ import { normalizeToLF } from "../edit/normalize";
12
12
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
13
13
  import type { Theme } from "../modes/theme/theme";
14
14
  import astEditDescription from "../prompts/tools/ast-edit.md" with { type: "text" };
15
- import { Ellipsis, fileHyperlink, framedBlock, renderStatusLine, truncateToWidth } from "../tui";
15
+ import {
16
+ Ellipsis,
17
+ fileHyperlink,
18
+ framedBlock,
19
+ outputBlockContentWidth,
20
+ renderStatusLine,
21
+ truncateToWidth,
22
+ } from "../tui";
16
23
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
17
24
  import type { ToolSession } from ".";
18
25
  import { truncateForPrompt } from "./approval";
@@ -695,7 +702,7 @@ export const astEditToolRenderer = {
695
702
  }
696
703
  return framedBlock(uiTheme, width => {
697
704
  const changeLines = buildChangeBody(changeGroups, Boolean(options.expanded), COLLAPSED_CHANGE_LIMIT, uiTheme);
698
- const innerWidth = Math.max(1, width - 3);
705
+ const innerWidth = outputBlockContentWidth(width);
699
706
  const bodyLines = [...changeLines, ...extraLines].map(l => truncateToWidth(l, innerWidth, Ellipsis.Omit));
700
707
  while (bodyLines.length > 0 && bodyLines[0].trim() === "") bodyLines.shift();
701
708
  return {
@@ -37,7 +37,6 @@ import type { CmuxSocketClient } from "./socket-client";
37
37
  interface ScreenshotOptions {
38
38
  selector?: string;
39
39
  fullPage?: boolean;
40
- save?: string;
41
40
  silent?: boolean;
42
41
  encoding?: "base64" | "binary";
43
42
  }
@@ -487,7 +486,7 @@ export class CmuxTab {
487
486
  return content;
488
487
  }
489
488
 
490
- async screenshot(opts: ScreenshotOptions = {}): Promise<ScreenshotResult> {
489
+ async screenshot(opts: ScreenshotOptions = {}): Promise<string> {
491
490
  const context = this.#requireRunContext("tab.screenshot()");
492
491
  // The cmux daemon's `browser.screenshot` captures the surface viewport
493
492
  // only — it has no element-clip or full-page mode, and Bun.Image cannot
@@ -518,20 +517,16 @@ export class CmuxTab {
518
517
  excludeWebP: context.session.excludeWebP,
519
518
  },
520
519
  );
521
- const explicitPath = opts.save ? resolveToCwd(opts.save, context.session.cwd) : undefined;
522
- const returnedPath = typeof result.path === "string" && result.path.length > 0 ? result.path : undefined;
523
- const saveFullRes = !!(explicitPath || context.session.browserScreenshotDir || returnedPath);
520
+ const saveFullRes = !!context.session.browserScreenshotDir;
524
521
  const savedBuffer = saveFullRes ? buffer : Buffer.from(resized.buffer);
525
522
  const savedMimeType = saveFullRes ? captureMime : resized.mimeType;
526
523
  const ext = savedMimeType === "image/webp" ? "webp" : savedMimeType === "image/jpeg" ? "jpg" : "png";
527
- const dest =
528
- explicitPath ??
529
- (context.session.browserScreenshotDir
530
- ? path.join(
531
- context.session.browserScreenshotDir,
532
- `screenshot-${new Date().toISOString().replace(/[:.]/g, "-").slice(0, -1)}.${ext}`,
533
- )
534
- : (returnedPath ?? path.join(os.tmpdir(), `omp-sshots-${Snowflake.next()}.${ext}`)));
524
+ const dest = context.session.browserScreenshotDir
525
+ ? path.join(
526
+ context.session.browserScreenshotDir,
527
+ `screenshot-${new Date().toISOString().replace(/[:.]/g, "-").slice(0, -1)}.${ext}`,
528
+ )
529
+ : path.join(os.tmpdir(), `omp-sshots-${Snowflake.next()}.${ext}`);
535
530
  await fs.promises.mkdir(path.dirname(dest), { recursive: true });
536
531
  await Bun.write(dest, savedBuffer);
537
532
  const info: ScreenshotResult = {
@@ -556,7 +551,7 @@ export class CmuxTab {
556
551
  context.output.push({ type: "text", text: lines.join("\n") });
557
552
  context.output.push({ type: "image", data: resized.data, mimeType: resized.mimeType });
558
553
  }
559
- return info;
554
+ return dest;
560
555
  }
561
556
 
562
557
  async waitForUrl(pattern: string | RegExp, opts?: { timeout?: number }): Promise<string> {
@@ -11,7 +11,6 @@ import type {
11
11
  ElementHandle,
12
12
  ElementScreenshotOptions,
13
13
  HTTPResponse,
14
- ImageFormat,
15
14
  KeyInput,
16
15
  Page,
17
16
  SerializedAXNode,
@@ -217,7 +216,6 @@ export function resolveWaitTimeout(cellTimeoutMs: number, explicit?: number): nu
217
216
  interface ScreenshotOptions {
218
217
  selector?: string;
219
218
  fullPage?: boolean;
220
- save?: string;
221
219
  silent?: boolean;
222
220
  }
223
221
 
@@ -233,7 +231,7 @@ interface TabApi {
233
231
  ): Promise<void>;
234
232
  observe(opts?: { includeAll?: boolean; viewportOnly?: boolean }): Promise<Observation>;
235
233
  ariaSnapshot(selector?: string, opts?: AriaSnapshotOptions): Promise<string>;
236
- screenshot(opts?: ScreenshotOptions): Promise<ScreenshotResult>;
234
+ screenshot(opts?: ScreenshotOptions): Promise<string>;
237
235
  extract(format?: ReadableFormat): Promise<string>;
238
236
  click(selector: string): Promise<void>;
239
237
  type(selector: string, text: string): Promise<void>;
@@ -714,19 +712,6 @@ export function describeScreenshot(opts?: ScreenshotOptions): string {
714
712
  return "tab.screenshot()";
715
713
  }
716
714
 
717
- /** Map an explicit save path's extension to a puppeteer capture format (default png). */
718
- export function imageFormatForPath(filePath: string): ImageFormat {
719
- switch (path.extname(filePath).toLowerCase()) {
720
- case ".webp":
721
- return "webp";
722
- case ".jpg":
723
- case ".jpeg":
724
- return "jpeg";
725
- default:
726
- return "png";
727
- }
728
- }
729
-
730
715
  /** Summarize still-running helpers (oldest first) so a cell timeout names what stalled. */
731
716
  export function describeInflight(inflight: Map<number, InflightOp>): string {
732
717
  const now = Date.now();
@@ -1532,7 +1517,7 @@ export class WorkerCore {
1532
1517
  screenshots: ScreenshotResult[],
1533
1518
  signal: AbortSignal | undefined,
1534
1519
  opts: ScreenshotOptions = {},
1535
- ): Promise<ScreenshotResult> {
1520
+ ): Promise<string> {
1536
1521
  const page = this.#requirePage();
1537
1522
  // Multiple tabs can share one Chromium (sibling headless tabs on a shared
1538
1523
  // endpoint, cdp/app attach). CDP `Page.captureScreenshot` reads the
@@ -1542,12 +1527,8 @@ export class WorkerCore {
1542
1527
  // already-active or freshly-closed target never fails the capture.
1543
1528
  await untilAborted(signal, () => page.bringToFront()).catch(() => undefined);
1544
1529
  const fullPage = opts.selector ? false : (opts.fullPage ?? false);
1545
- // An explicit save path picks the full-res capture format: puppeteer encodes
1546
- // png/jpeg/webp natively, so `save: "shot.webp"` gets real WebP bytes instead
1547
- // of PNG bytes hiding behind a .webp name. Unknown/missing extensions stay PNG.
1548
- const explicitPath = opts.save ? resolveToCwd(opts.save, session.cwd) : undefined;
1549
- const captureType = explicitPath ? imageFormatForPath(explicitPath) : "png";
1550
- const captureMime = `image/${captureType}` as const;
1530
+ const captureType = "png";
1531
+ const captureMime = "image/png" as const;
1551
1532
  let buffer: Buffer;
1552
1533
  if (opts.selector) {
1553
1534
  const handle =
@@ -1581,20 +1562,16 @@ export class WorkerCore {
1581
1562
  { type: "image", data: buffer.toBase64(), mimeType: captureMime },
1582
1563
  { maxWidth: 1024, maxHeight: 1024, maxBytes: 150 * 1024, jpegQuality: 70, excludeWebP: session.excludeWebP },
1583
1564
  );
1584
- const saveFullRes = !!(explicitPath || session.browserScreenshotDir);
1565
+ const saveFullRes = !!session.browserScreenshotDir;
1585
1566
  const savedBuffer = saveFullRes ? buffer : resized.buffer;
1586
1567
  const savedMimeType = saveFullRes ? captureMime : resized.mimeType;
1587
- // Names must match the bytes we actually write: full-res follows the capture
1588
- // format, the resized buffer is whichever of PNG/JPEG/WebP encoded smallest.
1589
1568
  const ext = savedMimeType === "image/webp" ? "webp" : savedMimeType === "image/jpeg" ? "jpg" : "png";
1590
- const dest =
1591
- explicitPath ??
1592
- (session.browserScreenshotDir
1593
- ? path.join(
1594
- session.browserScreenshotDir,
1595
- `screenshot-${new Date().toISOString().replace(/[:.]/g, "-").slice(0, -1)}.${ext}`,
1596
- )
1597
- : path.join(os.tmpdir(), `omp-sshots-${Snowflake.next()}.${ext}`));
1569
+ const dest = session.browserScreenshotDir
1570
+ ? path.join(
1571
+ session.browserScreenshotDir,
1572
+ `screenshot-${new Date().toISOString().replace(/[:.]/g, "-").slice(0, -1)}.${ext}`,
1573
+ )
1574
+ : path.join(os.tmpdir(), `omp-sshots-${Snowflake.next()}.${ext}`);
1598
1575
  await fs.promises.mkdir(path.dirname(dest), { recursive: true });
1599
1576
  await Bun.write(dest, savedBuffer);
1600
1577
  const info: ScreenshotResult = {
@@ -1616,7 +1593,7 @@ export class WorkerCore {
1616
1593
  output.push({ type: "text", text: lines.join("\n") });
1617
1594
  output.push({ type: "image", data: resized.data, mimeType: resized.mimeType });
1618
1595
  }
1619
- return info;
1596
+ return dest;
1620
1597
  }
1621
1598
 
1622
1599
  async #drag(from: DragTarget, to: DragTarget, signal: AbortSignal): Promise<void> {
@@ -95,6 +95,11 @@ function resolveBrowserKind(params: BrowserParams, session: ToolSession): Browse
95
95
  const exe = resolveToCwd(app.path, session.cwd);
96
96
  return { kind: "spawned", path: exe };
97
97
  }
98
+ // A configured endpoint is a default, not an override: explicit app options win.
99
+ const configuredCdpUrl = (session.settings.get("browser.cdpUrl") as string | undefined)?.trim();
100
+ if (configuredCdpUrl) {
101
+ return { kind: "connected", cdpUrl: configuredCdpUrl.replace(/\/+$/, "") };
102
+ }
98
103
  const cmuxKind = resolveCmuxKind({
99
104
  settingEnabled: session.settings.get("browser.cmux") as boolean | undefined,
100
105
  });
@@ -163,11 +168,11 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
163
168
  },
164
169
  },
165
170
  {
166
- caption: "Screenshot to look at the page no save path",
171
+ caption: "Capture a screenshot and return its saved path",
167
172
  call: {
168
173
  action: "run",
169
174
  name: "docs",
170
- code: "await tab.screenshot();",
175
+ code: "return await tab.screenshot();",
171
176
  },
172
177
  },
173
178
  {
@@ -50,11 +50,6 @@ export interface RewindToolDetails {
50
50
  meta?: OutputMeta;
51
51
  }
52
52
 
53
- function isTopLevelSession(session: ToolSession): boolean {
54
- const depth = session.taskDepth;
55
- return depth === undefined || depth === 0;
56
- }
57
-
58
53
  export class CheckpointTool implements AgentTool<typeof checkpointSchema, CheckpointToolDetails> {
59
54
  readonly name = "checkpoint";
60
55
  readonly approval = "read" as const;
@@ -71,7 +66,6 @@ export class CheckpointTool implements AgentTool<typeof checkpointSchema, Checkp
71
66
  }
72
67
 
73
68
  static createIf(session: ToolSession): CheckpointTool | null {
74
- if (!isTopLevelSession(session)) return null;
75
69
  return new CheckpointTool(session);
76
70
  }
77
71
 
@@ -82,9 +76,6 @@ export class CheckpointTool implements AgentTool<typeof checkpointSchema, Checkp
82
76
  _onUpdate?: AgentToolUpdateCallback<CheckpointToolDetails>,
83
77
  _context?: AgentToolContext,
84
78
  ): Promise<AgentToolResult<CheckpointToolDetails>> {
85
- if (!isTopLevelSession(this.session)) {
86
- throw new ToolError("Checkpoint not available in subagents.");
87
- }
88
79
  if (this.session.getCheckpointState?.()) {
89
80
  throw new ToolError("Checkpoint already active.");
90
81
  }
@@ -117,7 +108,6 @@ export class RewindTool implements AgentTool<typeof rewindSchema, RewindToolDeta
117
108
  }
118
109
 
119
110
  static createIf(session: ToolSession): RewindTool | null {
120
- if (!isTopLevelSession(session)) return null;
121
111
  return new RewindTool(session);
122
112
  }
123
113
 
@@ -128,9 +118,6 @@ export class RewindTool implements AgentTool<typeof rewindSchema, RewindToolDeta
128
118
  _onUpdate?: AgentToolUpdateCallback<RewindToolDetails>,
129
119
  _context?: AgentToolContext,
130
120
  ): Promise<AgentToolResult<RewindToolDetails>> {
131
- if (!isTopLevelSession(this.session)) {
132
- throw new ToolError("Checkpoint not available in subagents.");
133
- }
134
121
  if (!this.session.getCheckpointState?.()) {
135
122
  if (this.session.getLastCompletedRewind?.()) {
136
123
  throw new ToolError(
@@ -1,7 +1,7 @@
1
1
  import { type Component, padding, Text, visibleWidth } from "@oh-my-pi/pi-tui";
2
2
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
3
3
  import type { Theme, ThemeColor } from "../modes/theme/theme";
4
- import { framedBlock, renderStatusLine } from "../tui";
4
+ import { framedBlock, outputBlockContentWidth, renderStatusLine } from "../tui";
5
5
  import type {
6
6
  GhRunWatchFailedLogDetails,
7
7
  GhRunWatchJobDetails,
@@ -364,7 +364,7 @@ function renderFallbackComponent(
364
364
  }
365
365
 
366
366
  return framedBlock(theme, width => {
367
- const lineWidth = Math.max(1, (width || FALLBACK_WIDTH) - 3);
367
+ const lineWidth = outputBlockContentWidth(width || FALLBACK_WIDTH);
368
368
  const expanded = options.expanded;
369
369
  const limit = expanded ? allLines.length : Math.min(allLines.length, PREVIEW_LIMITS.OUTPUT_EXPANDED);
370
370
  const visible = allLines.slice(0, limit);
@@ -464,7 +464,7 @@ export const githubToolRenderer = {
464
464
  uiTheme,
465
465
  );
466
466
  return framedBlock(uiTheme, width => {
467
- const innerWidth = Math.max(1, (width || FALLBACK_WIDTH) - 3);
467
+ const innerWidth = outputBlockContentWidth(width || FALLBACK_WIDTH);
468
468
  const sections = buildWatchSections(watch, uiTheme, options, innerWidth);
469
469
  return {
470
470
  header,