rovecode 0.3.2

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 (334) hide show
  1. package/LICENSE +662 -0
  2. package/README.md +737 -0
  3. package/THIRD_PARTY_NOTICES.md +268 -0
  4. package/bin/rovecode.ts +21 -0
  5. package/package.json +56 -0
  6. package/src/acp/server.ts +374 -0
  7. package/src/cli/auth-login.ts +122 -0
  8. package/src/cli/connect.ts +244 -0
  9. package/src/cli/context-cmd.ts +199 -0
  10. package/src/cli/dispatch.ts +82 -0
  11. package/src/cli/doctor.ts +362 -0
  12. package/src/cli/export.ts +276 -0
  13. package/src/cli/help.ts +293 -0
  14. package/src/cli/is-tui-invocation.ts +8 -0
  15. package/src/cli/main.ts +583 -0
  16. package/src/cli/market-cmd.ts +658 -0
  17. package/src/cli/mcp-login.ts +141 -0
  18. package/src/cli/mcp-market-cmd.ts +302 -0
  19. package/src/cli/output.ts +382 -0
  20. package/src/cli/repl.ts +250 -0
  21. package/src/cli/repomap-root.ts +14 -0
  22. package/src/cli/resume.ts +57 -0
  23. package/src/cli/run-flags.ts +43 -0
  24. package/src/cli/run-limits.ts +78 -0
  25. package/src/cli/runtime.ts +931 -0
  26. package/src/cli/session-arg.ts +30 -0
  27. package/src/cli/sessions-cmd.ts +145 -0
  28. package/src/cli/setup.ts +153 -0
  29. package/src/cli/skills-cmd.ts +194 -0
  30. package/src/cli/start-chat.ts +65 -0
  31. package/src/cli/trust-cmd.ts +52 -0
  32. package/src/coding/bash.ts +148 -0
  33. package/src/coding/checkpoints.ts +327 -0
  34. package/src/coding/diff.ts +138 -0
  35. package/src/coding/files.ts +341 -0
  36. package/src/coding/hashline.ts +274 -0
  37. package/src/coding/lsp-gate.ts +254 -0
  38. package/src/coding/lsp-servers.ts +147 -0
  39. package/src/coding/lsp.ts +283 -0
  40. package/src/coding/repomap-cache.ts +99 -0
  41. package/src/coding/repomap-files.ts +192 -0
  42. package/src/coding/repomap.ts +481 -0
  43. package/src/core/agents.ts +255 -0
  44. package/src/core/compaction.ts +259 -0
  45. package/src/core/config.ts +289 -0
  46. package/src/core/context-report.ts +228 -0
  47. package/src/core/context.ts +60 -0
  48. package/src/core/count-remote.ts +107 -0
  49. package/src/core/execpolicy-rules.ts +196 -0
  50. package/src/core/execpolicy.ts +385 -0
  51. package/src/core/executor.ts +454 -0
  52. package/src/core/guardrails.ts +400 -0
  53. package/src/core/hooks.ts +411 -0
  54. package/src/core/images.ts +230 -0
  55. package/src/core/intro.ts +266 -0
  56. package/src/core/loop.ts +567 -0
  57. package/src/core/modes.ts +372 -0
  58. package/src/core/orchestrator.ts +245 -0
  59. package/src/core/proc-group.ts +48 -0
  60. package/src/core/project-trust.ts +98 -0
  61. package/src/core/reflection.ts +165 -0
  62. package/src/core/sandbox-config.ts +186 -0
  63. package/src/core/session-id.ts +24 -0
  64. package/src/core/session-images.ts +73 -0
  65. package/src/core/session-ops.ts +183 -0
  66. package/src/core/session-text.ts +29 -0
  67. package/src/core/session.ts +469 -0
  68. package/src/core/settings.ts +170 -0
  69. package/src/core/tasks.ts +646 -0
  70. package/src/core/token-scale.ts +108 -0
  71. package/src/core/tools.ts +309 -0
  72. package/src/core/trust.ts +104 -0
  73. package/src/core/types.ts +330 -0
  74. package/src/core/update-check.ts +171 -0
  75. package/src/core/usage.ts +204 -0
  76. package/src/core/validate.ts +121 -0
  77. package/src/core/verify-gate.ts +159 -0
  78. package/src/core/verify.ts +236 -0
  79. package/src/core/voice.ts +158 -0
  80. package/src/core/win-job.ts +183 -0
  81. package/src/core/workspace.ts +184 -0
  82. package/src/design/audit.ts +797 -0
  83. package/src/design/direction.ts +190 -0
  84. package/src/design/rules.ts +157 -0
  85. package/src/eval/bench.ts +150 -0
  86. package/src/eval/gauntlet-runner.ts +215 -0
  87. package/src/eval/gauntlet-support.ts +84 -0
  88. package/src/eval/gauntlet-wave3.ts +269 -0
  89. package/src/eval/gauntlet-wave4.ts +217 -0
  90. package/src/eval/gauntlet.ts +253 -0
  91. package/src/index.ts +17 -0
  92. package/src/lanes/agy.ts +95 -0
  93. package/src/lanes/approval.ts +24 -0
  94. package/src/lanes/claude.ts +129 -0
  95. package/src/lanes/codex.ts +127 -0
  96. package/src/lanes/events.ts +130 -0
  97. package/src/lanes/job.ts +142 -0
  98. package/src/lanes/opencode.ts +122 -0
  99. package/src/lanes/process.ts +184 -0
  100. package/src/lanes/progress.ts +183 -0
  101. package/src/lanes/registry.ts +178 -0
  102. package/src/lanes/runner.ts +124 -0
  103. package/src/lanes/types.ts +112 -0
  104. package/src/market/catalogs/mcp-docs.json +111 -0
  105. package/src/market/catalogs/plugins.json +111 -0
  106. package/src/market/catalogs/skills.json +478 -0
  107. package/src/market/clone.ts +72 -0
  108. package/src/market/context-cost.ts +121 -0
  109. package/src/market/digest.ts +106 -0
  110. package/src/market/index.ts +22 -0
  111. package/src/market/install.ts +578 -0
  112. package/src/market/manifest.ts +187 -0
  113. package/src/market/prereq.ts +145 -0
  114. package/src/market/registry.ts +363 -0
  115. package/src/market/resolve.ts +111 -0
  116. package/src/market/types.ts +236 -0
  117. package/src/market/validate.ts +227 -0
  118. package/src/mcp/client.ts +449 -0
  119. package/src/mcp/config.ts +252 -0
  120. package/src/mcp/local-package.ts +211 -0
  121. package/src/mcp/market-catalog.ts +84 -0
  122. package/src/mcp/market-install.ts +289 -0
  123. package/src/mcp/market.ts +362 -0
  124. package/src/mcp/oauth.ts +251 -0
  125. package/src/mcp/prompts-resources.ts +249 -0
  126. package/src/mcp/shared.ts +149 -0
  127. package/src/mcp/status.ts +67 -0
  128. package/src/mcp/tools.ts +275 -0
  129. package/src/mcp/transport.ts +122 -0
  130. package/src/mcp/trust.ts +25 -0
  131. package/src/memory/blocks.ts +278 -0
  132. package/src/memory/recall.ts +355 -0
  133. package/src/memory/scope.ts +182 -0
  134. package/src/memory/store.ts +105 -0
  135. package/src/memory/tools.ts +99 -0
  136. package/src/plugins/cli.ts +119 -0
  137. package/src/plugins/discover.ts +108 -0
  138. package/src/plugins/index.ts +50 -0
  139. package/src/plugins/install.ts +184 -0
  140. package/src/plugins/load.ts +124 -0
  141. package/src/plugins/manifest.ts +92 -0
  142. package/src/plugins/state.ts +83 -0
  143. package/src/providers/auth.ts +408 -0
  144. package/src/providers/cache.ts +223 -0
  145. package/src/providers/catalog-local.ts +160 -0
  146. package/src/providers/catalog.ts +421 -0
  147. package/src/providers/middleware-context.ts +86 -0
  148. package/src/providers/middleware.ts +373 -0
  149. package/src/providers/model-list.ts +23 -0
  150. package/src/providers/models-index.json +1 -0
  151. package/src/providers/oauth/common.ts +105 -0
  152. package/src/providers/oauth/device-code.ts +107 -0
  153. package/src/providers/oauth/github-copilot.ts +146 -0
  154. package/src/providers/oauth/loopback.ts +158 -0
  155. package/src/providers/oauth/openai.ts +163 -0
  156. package/src/providers/oauth/openrouter.ts +89 -0
  157. package/src/providers/oauth/pkce.ts +45 -0
  158. package/src/providers/oauth/registry.ts +39 -0
  159. package/src/providers/oauth/seam.ts +89 -0
  160. package/src/providers/profile-glm53.ts +111 -0
  161. package/src/providers/profile-sonnet5-persona.ts +65 -0
  162. package/src/providers/profile-sonnet5-voice.ts +23 -0
  163. package/src/providers/profiles.ts +156 -0
  164. package/src/providers/provider-config.ts +311 -0
  165. package/src/providers/registry.ts +333 -0
  166. package/src/providers/responses.ts +209 -0
  167. package/src/providers/retry.ts +234 -0
  168. package/src/providers/router.ts +294 -0
  169. package/src/providers/sse.ts +26 -0
  170. package/src/providers/stream-errors.ts +117 -0
  171. package/src/providers/stream.ts +566 -0
  172. package/src/providers/thinking.ts +189 -0
  173. package/src/providers/wire-messages.ts +129 -0
  174. package/src/providers/wire-responses.ts +79 -0
  175. package/src/providers/wire-select.ts +53 -0
  176. package/src/server/http.ts +291 -0
  177. package/src/server/openapi.ts +246 -0
  178. package/src/sextant/card-hits.ts +102 -0
  179. package/src/sextant/card-keys.ts +55 -0
  180. package/src/sextant/context-source.ts +157 -0
  181. package/src/sextant/crew-cards.ts +350 -0
  182. package/src/sextant/draw-agents.ts +273 -0
  183. package/src/sextant/draw-code.ts +388 -0
  184. package/src/sextant/draw-context.ts +222 -0
  185. package/src/sextant/draw-frame.ts +164 -0
  186. package/src/sextant/draw-market.ts +573 -0
  187. package/src/sextant/draw-messages.ts +386 -0
  188. package/src/sextant/draw-pet.ts +230 -0
  189. package/src/sextant/draw-plan.ts +187 -0
  190. package/src/sextant/draw-tabs.ts +85 -0
  191. package/src/sextant/draw-util.ts +65 -0
  192. package/src/sextant/draw-wizard.ts +378 -0
  193. package/src/sextant/engine.ts +230 -0
  194. package/src/sextant/frame-hits.ts +25 -0
  195. package/src/sextant/frame.ts +101 -0
  196. package/src/sextant/git-status.ts +197 -0
  197. package/src/sextant/grid.ts +59 -0
  198. package/src/sextant/input.ts +119 -0
  199. package/src/sextant/keys.ts +521 -0
  200. package/src/sextant/layout.ts +86 -0
  201. package/src/sextant/local-commands.ts +169 -0
  202. package/src/sextant/market-source.ts +287 -0
  203. package/src/sextant/mentions.ts +200 -0
  204. package/src/sextant/message-hits.ts +26 -0
  205. package/src/sextant/model.ts +387 -0
  206. package/src/sextant/overlays.ts +456 -0
  207. package/src/sextant/panel-hits.ts +38 -0
  208. package/src/sextant/pet.ts +399 -0
  209. package/src/sextant/screen.ts +324 -0
  210. package/src/sextant/scroll-hits.ts +66 -0
  211. package/src/sextant/scrollbar.ts +82 -0
  212. package/src/sextant/sextant-bridge.ts +174 -0
  213. package/src/sextant/sextant-cards.ts +142 -0
  214. package/src/sextant/sextant-diff-base.ts +63 -0
  215. package/src/sextant/sextant-files.ts +154 -0
  216. package/src/sextant/sextant-frame-loop.ts +335 -0
  217. package/src/sextant/sextant-renderer.ts +574 -0
  218. package/src/sextant/sextant-repo.ts +140 -0
  219. package/src/sextant/theme.ts +66 -0
  220. package/src/sextant/tool-rows.ts +189 -0
  221. package/src/sextant/types.ts +493 -0
  222. package/src/skills/index.ts +387 -0
  223. package/src/skills/pack.ts +220 -0
  224. package/src/skills/spec.ts +162 -0
  225. package/src/skills/tools.ts +69 -0
  226. package/src/skills/versioned.ts +227 -0
  227. package/src/telemetry/otel-export.ts +122 -0
  228. package/src/telemetry/otel-lanes.ts +89 -0
  229. package/src/telemetry/otel-logs.ts +131 -0
  230. package/src/telemetry/otel-metrics.ts +136 -0
  231. package/src/telemetry/otel.ts +397 -0
  232. package/src/telemetry/otlp.ts +76 -0
  233. package/src/tools/ask-user.ts +156 -0
  234. package/src/tools/bash-bg.ts +94 -0
  235. package/src/tools/bash-jobs.ts +237 -0
  236. package/src/tools/design.ts +151 -0
  237. package/src/tools/evalcell.ts +338 -0
  238. package/src/tools/html-text.ts +139 -0
  239. package/src/tools/provider.ts +149 -0
  240. package/src/tools/task.ts +250 -0
  241. package/src/tools/todo.ts +320 -0
  242. package/src/tools/webfetch.ts +332 -0
  243. package/src/tools/websearch.ts +359 -0
  244. package/src/tui/agents-cmd.ts +41 -0
  245. package/src/tui/app.ts +749 -0
  246. package/src/tui/attach.ts +127 -0
  247. package/src/tui/boot-notes.ts +41 -0
  248. package/src/tui/builtin-prompts.ts +59 -0
  249. package/src/tui/checkpoints-cmd.ts +70 -0
  250. package/src/tui/clipboard-image.ts +81 -0
  251. package/src/tui/clipboard.ts +78 -0
  252. package/src/tui/commands.ts +283 -0
  253. package/src/tui/config-view.ts +53 -0
  254. package/src/tui/context-cmds.ts +282 -0
  255. package/src/tui/cost.ts +108 -0
  256. package/src/tui/crash-guard.ts +173 -0
  257. package/src/tui/focus-terminal.ts +34 -0
  258. package/src/tui/git-cmds.ts +273 -0
  259. package/src/tui/git-plain.ts +58 -0
  260. package/src/tui/info-cmd.ts +150 -0
  261. package/src/tui/input-plain.ts +76 -0
  262. package/src/tui/mcp-cmd.ts +128 -0
  263. package/src/tui/memory-note.ts +77 -0
  264. package/src/tui/modes-cmd.ts +45 -0
  265. package/src/tui/notify-seq.ts +100 -0
  266. package/src/tui/notify.ts +318 -0
  267. package/src/tui/overlays.ts +97 -0
  268. package/src/tui/pi-renderer.ts +428 -0
  269. package/src/tui/providers-cmd.ts +377 -0
  270. package/src/tui/reasoning-view.ts +56 -0
  271. package/src/tui/renderer.ts +128 -0
  272. package/src/tui/replay-marker.ts +29 -0
  273. package/src/tui/session-cmd.ts +148 -0
  274. package/src/tui/session-manage.ts +95 -0
  275. package/src/tui/sextant-attach.ts +102 -0
  276. package/src/tui/sextant-io.ts +202 -0
  277. package/src/tui/sextant-smoke.ts +110 -0
  278. package/src/tui/shell-cmd.ts +158 -0
  279. package/src/tui/smoke.ts +72 -0
  280. package/src/tui/staged-terminal.ts +50 -0
  281. package/src/tui/startup.ts +12 -0
  282. package/src/tui/theme.ts +59 -0
  283. package/src/tui/todo-label.ts +7 -0
  284. package/src/tui/trust-card.ts +107 -0
  285. package/src/tui/tui-commands.ts +87 -0
  286. package/tsconfig.json +30 -0
  287. package/vendor/pi-tui/LICENSE +21 -0
  288. package/vendor/pi-tui/PATCHES.md +12 -0
  289. package/vendor/pi-tui/PROVENANCE.md +12 -0
  290. package/vendor/pi-tui/README.upstream.md +854 -0
  291. package/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node +0 -0
  292. package/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-console-mode.node +0 -0
  293. package/vendor/pi-tui/src/alt-screen-search.ts +158 -0
  294. package/vendor/pi-tui/src/autocomplete.ts +827 -0
  295. package/vendor/pi-tui/src/components/alt-screen-flash.ts +52 -0
  296. package/vendor/pi-tui/src/components/box.ts +138 -0
  297. package/vendor/pi-tui/src/components/cancellable-loader.ts +41 -0
  298. package/vendor/pi-tui/src/components/editor.ts +2364 -0
  299. package/vendor/pi-tui/src/components/h-stack.ts +45 -0
  300. package/vendor/pi-tui/src/components/image.ts +128 -0
  301. package/vendor/pi-tui/src/components/input.ts +448 -0
  302. package/vendor/pi-tui/src/components/loader.ts +93 -0
  303. package/vendor/pi-tui/src/components/markdown.ts +1016 -0
  304. package/vendor/pi-tui/src/components/scroll-view.ts +217 -0
  305. package/vendor/pi-tui/src/components/select-list.ts +230 -0
  306. package/vendor/pi-tui/src/components/settings-list.ts +277 -0
  307. package/vendor/pi-tui/src/components/spacer.ts +29 -0
  308. package/vendor/pi-tui/src/components/stack.ts +155 -0
  309. package/vendor/pi-tui/src/components/text.ts +108 -0
  310. package/vendor/pi-tui/src/components/truncated-text.ts +66 -0
  311. package/vendor/pi-tui/src/components/v-stack.ts +34 -0
  312. package/vendor/pi-tui/src/editor-component.ts +75 -0
  313. package/vendor/pi-tui/src/fuzzy.ts +138 -0
  314. package/vendor/pi-tui/src/index.ts +149 -0
  315. package/vendor/pi-tui/src/keybindings.ts +321 -0
  316. package/vendor/pi-tui/src/keys.ts +1402 -0
  317. package/vendor/pi-tui/src/kill-ring.ts +47 -0
  318. package/vendor/pi-tui/src/latex.ts +1381 -0
  319. package/vendor/pi-tui/src/layout-node.ts +52 -0
  320. package/vendor/pi-tui/src/layout.ts +411 -0
  321. package/vendor/pi-tui/src/native-modifiers.ts +60 -0
  322. package/vendor/pi-tui/src/native-module-path.ts +32 -0
  323. package/vendor/pi-tui/src/stdin-buffer.ts +445 -0
  324. package/vendor/pi-tui/src/terminal-colors.ts +74 -0
  325. package/vendor/pi-tui/src/terminal-image.ts +701 -0
  326. package/vendor/pi-tui/src/terminal.ts +554 -0
  327. package/vendor/pi-tui/src/tui-alt-screen.ts +1379 -0
  328. package/vendor/pi-tui/src/tui-main-screen.ts +655 -0
  329. package/vendor/pi-tui/src/tui.ts +1264 -0
  330. package/vendor/pi-tui/src/undo-stack.ts +29 -0
  331. package/vendor/pi-tui/src/utils.ts +1327 -0
  332. package/vendor/pi-tui/src/word-navigation.ts +118 -0
  333. package/vendor/pi-tui/test/test-themes.ts +39 -0
  334. package/vendor/pi-tui/test/virtual-terminal.ts +219 -0
@@ -0,0 +1,148 @@
1
+ /** The bash tool (ports #10, #21, #55). Runs a shell command through the Executor seam — the probed rung
2
+ * (direct / wsl / docker) shapes the argv, and the abort path is the rung's tree kill (Windows Job Object
3
+ * + taskkill sweep; SIGTERM to the process group on POSIX).
4
+ *
5
+ * It lived at the end of coding/hashline.ts until #55 gave it a timeout and a background flag. That file
6
+ * is the anchored-edit implementation; shell execution had simply drifted into its last fifty lines.
7
+ * hashline.ts re-exports `bashTool`, so the ten files importing it from there are untouched.
8
+ *
9
+ * ONE TOOL, THREE SHAPES, and the reason it is one tool rather than three: policy runs UPSTREAM of
10
+ * execute() — the permission rules, execpolicy, the hooks and the human approval all decide before this
11
+ * function is entered. A separate `bash_background` tool would be a second door onto the same room, and
12
+ * every rule written for `bash` would have to be written again for it.
13
+ * plain await it, one automatic retry on a non-zero exit
14
+ * timeout_ms the same, with a deadline; a timed-out run KEEPS its partial output and says so
15
+ * run_in_background hand it to the job manager and return the id at once (tools/bash-jobs.ts) */
16
+
17
+ import type { Tool, ToolContext, ToolOutput } from "../core/types.ts";
18
+ import { getExecutor } from "../core/executor.ts";
19
+ import { startBashJob, hasJobManager } from "../tools/bash-jobs.ts";
20
+
21
+ /** Upper bound on `timeout_ms`, so a model cannot ask for a deadline that never arrives. Ten minutes:
22
+ * past that the honest answer is a background job, which is the flag sitting next to it. */
23
+ export const MAX_TIMEOUT_MS = 600_000;
24
+
25
+ export const bashTool: Tool = {
26
+ schema: {
27
+ name: "bash",
28
+ description:
29
+ "Run a shell command in the workspace (cwd locked to the session cwd). One automatic retry on non-zero exit. " +
30
+ "Destructive system commands are refused by a best-effort blocklist — this is NOT a sandbox. Output truncated to 10k chars. " +
31
+ `\`timeout_ms\` gives up after that long and RETURNS what the command printed (max ${MAX_TIMEOUT_MS}ms); the process tree is killed. ` +
32
+ "`run_in_background: true` returns a job id at once instead of waiting — for a dev server, a long build, a slow test run. " +
33
+ "Read it later with bash_output (only NEW output per read), see them with bash_list, stop one with bash_kill; a finished job also posts one note here by itself.",
34
+ args: {
35
+ type: "object",
36
+ properties: {
37
+ command: { type: "string" },
38
+ timeout_ms: { type: "number", description: `give up after this many ms and return the partial output (1..${MAX_TIMEOUT_MS}); a timeout is never retried` },
39
+ run_in_background: { type: "boolean", description: "start it as a background job and return its id immediately (bash_output reads it)" },
40
+ },
41
+ required: ["command"],
42
+ },
43
+ },
44
+ kind: "execute",
45
+ sequential: true,
46
+ async execute(args, ctx): Promise<ToolOutput> {
47
+ const a = args as { command: string; timeout_ms?: unknown; run_in_background?: unknown };
48
+ const cmd = String(a.command);
49
+ const denied = deniedCommand(cmd);
50
+ if (denied) return { ok: false, output: denied };
51
+
52
+ // #55: the background flag is read AFTER the blocklist and BEFORE anything runs — a command we
53
+ // would refuse in the foreground must not become startable by asking for it in the background.
54
+ if (a.run_in_background === true) {
55
+ if (!hasJobManager()) {
56
+ // Deliberately a refusal, not a silent foreground run: the caller asked NOT to be blocked, and
57
+ // quietly blocking it is the wrong answer to a request we cannot honour.
58
+ return { ok: false, output: "background jobs are not available on this surface — drop run_in_background to run it in the foreground" };
59
+ }
60
+ const started = startBashJob(cmd, ctx.cwd);
61
+ if (!started.ok) return { ok: false, output: started.reason };
62
+ return {
63
+ ok: true,
64
+ output: `started background job ${started.info.id}: ${cmd}\nIt runs while you continue — do NOT poll in a loop. \`bash_output ${started.info.id}\` reads what is new; a note lands here when it finishes.`,
65
+ data: started.info,
66
+ };
67
+ }
68
+
69
+ const timeout = timeoutFrom(a.timeout_ms);
70
+ if (timeout instanceof Error) return { ok: false, output: timeout.message };
71
+
72
+ // port #10: shell execution goes through the Executor seam (direct/wsl/docker
73
+ // rungs, probed not assumed). Direct rung is byte-compatible with the old
74
+ // inline runOnce; a missing bash now returns exit=-1 instead of throwing.
75
+ const run = async (): Promise<{ code: number; text: string; timedOut: boolean }> => {
76
+ if (timeout === undefined) {
77
+ const r = await getExecutor().run(cmd, ctx.cwd, ctx.signal);
78
+ return { code: r.code, text: r.text, timedOut: false };
79
+ }
80
+ // The deadline aborts a controller the executor is watching, which IS the tree-kill path — the
81
+ // same one Esc uses. A timeout that returned while the process kept running would be a lie in
82
+ // the other direction, and on Windows it would hold the pipe open too.
83
+ const ac = new AbortController();
84
+ const onOuter = (): void => ac.abort();
85
+ ctx.signal.addEventListener("abort", onOuter, { once: true });
86
+ let timer: ReturnType<typeof setTimeout> | undefined;
87
+ let fired = false;
88
+ try {
89
+ timer = setTimeout(() => { fired = true; ac.abort(); }, timeout);
90
+ const r = await getExecutor().run(cmd, ctx.cwd, ac.signal);
91
+ return { code: r.code, text: r.text, timedOut: fired };
92
+ } finally {
93
+ clearTimeout(timer);
94
+ ctx.signal.removeEventListener("abort", onOuter);
95
+ }
96
+ };
97
+
98
+ let r = await run();
99
+ // single self-contained retry — but NEVER after an abort (port #21): the kill
100
+ // makes the exit non-zero, and a blind retry would respawn the cancelled
101
+ // command as a detached subprocess that outlives the run. Nor after a TIMEOUT:
102
+ // a command that needed more time than it was given needs more time, not twice.
103
+ if (r.code !== 0 && !r.timedOut && !ctx.signal.aborted) r = await run();
104
+ if (r.timedOut) {
105
+ // The partial output is the whole point. A build that timed out has usually already printed the
106
+ // reason it was slow, and throwing that away leaves the model with only "it took too long".
107
+ return { ok: false, output: `timed out after ${timeout}ms (process tree killed; what it printed follows)\n${r.text}` };
108
+ }
109
+ return { ok: r.code === 0, output: `exit=${r.code}\n${r.text}` };
110
+ },
111
+ };
112
+
113
+ /** undefined = no deadline; an Error is a refusal to be RETURNED as tool output, never thrown. */
114
+ function timeoutFrom(raw: unknown): number | undefined | Error {
115
+ if (raw === undefined || raw === null) return undefined;
116
+ const n = typeof raw === "number" ? raw : Number(raw);
117
+ if (!Number.isFinite(n) || n <= 0) return new Error(`timeout_ms must be a positive number of milliseconds (got ${JSON.stringify(raw)})`);
118
+ if (n > MAX_TIMEOUT_MS) return new Error(`timeout_ms ${n} is longer than the ${MAX_TIMEOUT_MS}ms cap — use run_in_background for work that takes longer`);
119
+ return Math.floor(n);
120
+ }
121
+
122
+ // ---------- Bash safety (best-effort blocklist, NOT a sandbox) ----------
123
+
124
+ /** Footgun guard on the raw command string. A determined agent bypasses it;
125
+ * real isolation belongs at the process/OS layer. */
126
+ const denyPatterns: RegExp[] = [
127
+ /rm\s+(-[a-z]*\s+)*\/(\s|$)/, // rm -rf /
128
+ /rm\s+(-[a-z]*\s+)*\/\*/, // rm -rf /*
129
+ /rm\s+(-[a-z]*\s+)*(--no-preserve-root\s+)?\*(\s|$)/, // rm -rf *
130
+ /:\(\)\s*\{/, // fork bomb body :(){ ...
131
+ /\bmkfs(\.\w+)?\b/,
132
+ /\b(shutdown|reboot|poweroff|halt)\b/,
133
+ /(^|[;&|\s])(sudo\s+)?format\s+(\/|[c-z]:)/i, // windows format
134
+ /(^|[;&|\s])(sudo\s+)?del\s+\/[fqs]/i, // windows del /f
135
+ /(^|[;&|\s])(sudo\s+)?rd\s+\/[sq]/i,
136
+ /(^|[;&|\s])(sudo\s+)?remove-item\s+-(rec|r|f|force)/i,
137
+ /\bdd\s+[^|]*of=\/dev\/(sd|nvme|hd|disk)/, // raw disk overwrite
138
+ />\s*\/dev\/(sd|nvme|hd|disk)/,
139
+ /\bsudo\b.*\b(rm|mkfs|dd|shutdown|reboot|halt|format)\b/, // sudo + destructive core
140
+ ];
141
+
142
+ export function deniedCommand(cmd: string): string | null {
143
+ const hit = denyPatterns.find((re) => re.test(cmd));
144
+ return hit
145
+ ? `command refused by safety blocklist (matched ${hit.source}): this tool is not a sandbox; rephrase without destructive system commands`
146
+ : null;
147
+ }
148
+
@@ -0,0 +1,327 @@
1
+ /** Shadow-git checkpoints (PORT #11, cline port, Apache-2.0 — see THIRD_PARTY_NOTICES).
2
+ *
3
+ * KNOWN COST, and the design that would remove it — measured 2026-09-06, kept here so the next reader
4
+ * starts from the answer rather than the question. The shadow git-dir is per SESSION, so every new
5
+ * session pays `git init` + a full `add` of the workspace inside its FIRST write or bash call: 3.45–3.56 s
6
+ * in this repository (init+config 160 ms, first add 1.4 s, first commit 1.5 s writing 565 loose objects
7
+ * on Windows). Later calls in that session are 210–335 ms, of which ~200 ms is git. It does not amortise
8
+ * across sessions because nothing is shared.
9
+ *
10
+ * The cut is one shadow repo per REPOSITORY with a ref per session — a new session's first snapshot
11
+ * becomes an incremental add, ~80 ms measured. cline keeps one shadow repo per TASK because a task is its
12
+ * unit of restore; ours is the repository, so sessions in one workspace can share an object store and
13
+ * differ only by ref. It is NOT a configuration change: a shared git-dir means a shared index and a
14
+ * shared HEAD, and today's `add . && commit` / `reset --hard && clean -fd` would collide on index.lock
15
+ * (the second writer silently gets no checkpoint) and move HEAD under another session. It has to be
16
+ * re-done on plumbing: GIT_INDEX_FILE=<shadow>/<session>.index per session, then add → write-tree →
17
+ * commit-tree -p <that session's last> → update-ref refs/rovecode/<session> (atomic per ref, and the
18
+ * object store is atomic per object); restore becomes read-tree --reset -u then clean -fd against that
19
+ * same index, with no HEAD involved. A hash stays a hash, so restoring one session's checkpoint from
20
+ * another finally works — the case per-session storage never allowed, and the one that needs its own
21
+ * test, alongside a two-writers concurrency test. Sessions whose old per-session repo exists keep using
22
+ * it; nothing needs migrating.
23
+ *
24
+ * A SECOND git repository whose git-dir lives under .rovecode/checkpoints/<session> and whose
25
+ * work-tree is the WORKSPACE, so the user's own .git is never written. This is cline's
26
+ * shadow-git design: the @8eb5f3d snapshot's docs still describe it (docs/core-workflows/
27
+ * checkpoints.mdx:17 "shadow Git repository separate from your project's actual Git
28
+ * history … Your main Git repository stays untouched"), but its v4 CODE moved to in-repo
29
+ * `git stash create` + refs/cline/* (sdk/packages/core/src/hooks/checkpoint-hooks.ts:172,
30
+ * sdk/packages/core/src/session/checkpoint-restore.ts:444-477), which requires a git
31
+ * workspace and rewrites the user's HEAD — incompatible with this bar. The mechanics here
32
+ * therefore port cline's last shadow-git implementation, v3.89.2
33
+ * apps/vscode/src/integrations/checkpoints/:
34
+ * - `git init` in the checkpoints dir, then core.worktree=<workspace>, commit.gpgSign
35
+ * off, own identity (CheckpointGitOperations.ts:88-94); git-dir = <dir>/.git and
36
+ * worktree-mismatch reuse check (CheckpointUtils.ts:20-23, GitOperations.ts:70-73)
37
+ * - excludes written to <git-dir>/info/exclude, list headed by ".git/"
38
+ * (CheckpointExclusions.ts:42-46 + 297-301)
39
+ * - snapshot = `add . --ignore-errors` (CheckpointGitOperations.ts:213) +
40
+ * `commit --allow-empty --no-verify` (CheckpointTracker.ts:251-253)
41
+ * - restore = `reset --hard <hash>` (CheckpointTracker.ts:364) + `clean -fd` so files
42
+ * created after the checkpoint are rewound away while ignored paths (node_modules,
43
+ * .rovecode, build output) survive — the reset+clean pair of the snapshot's own restore
44
+ * (checkpoint-restore.ts:458-470)
45
+ * Deviations from v3.89.2: the shadow repo lives IN-WORKSPACE under .rovecode (bar) so
46
+ * ".rovecode/" is excluded from itself; the nested-.git rename dance
47
+ * (CheckpointGitOperations.ts:148-166, 207 ".git_disabled") is NOT ported — renaming the
48
+ * user's nested .git would violate "user .git never touched", so nested repos become
49
+ * inert gitlink entries instead (their contents are not checkpointed, never modified);
50
+ * core.autocrlf=false is set so restores are byte-exact on Windows.
51
+ *
52
+ * Conversation restore returns the session entryId to branch to — the CALLER feeds it to
53
+ * SessionStore.branch() (port #2 leaf machinery); this module never imports session.ts. */
54
+
55
+ import { execFile } from "node:child_process";
56
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
57
+ import { join, resolve } from "node:path";
58
+
59
+ export interface Checkpoint {
60
+ hash: string; // shadow commit hash
61
+ label: string; // e.g. the mutating tool's name
62
+ entryId?: string; // session entry to branch to on conversation restore
63
+ createdAt: number;
64
+ }
65
+
66
+ export type RestoreMode = "files" | "conversation" | "both";
67
+
68
+ export type RestoreResult =
69
+ | { ok: true; mode: RestoreMode; checkpoint: Checkpoint; entryId?: string }
70
+ | { ok: false; error: string };
71
+
72
+ /** ToolKind values whose calls mutate the workspace → snapshot after each (bar:
73
+ * "snapshot commit after every mutating tool call"). memory writes land under the
74
+ * excluded .rovecode/; spawned children's own write/execute calls hit the same hook. */
75
+ export const MUTATING_KINDS: ReadonlySet<string> = new Set(["write", "execute"]);
76
+
77
+ /** Conversation-restore anchor for a snapshot: the LAST role:"user" message on the
78
+ * active path. At snapshot time the tail entry is the assistant message that ISSUED
79
+ * the in-flight tool call (the loop appends it pre-dispatch), so anchoring the tail
80
+ * branches to a history ending in tool_calls with no tool replies → provider 400.
81
+ * cline anchors the user run message instead (checkpoint-restore.ts:217-250).
82
+ * Wiring contract (like MUTATING_KINDS): runtime.ts withCheckpoint computes
83
+ * `anchorEntryId(activeStore.messages())`. */
84
+ export function anchorEntryId(messages: ReadonlyArray<{ id: string; role: string }>): string | undefined {
85
+ return messages.findLast((m) => m.role === "user")?.id;
86
+ }
87
+
88
+ export interface CheckpointsInit {
89
+ workspace: string;
90
+ sessionId: string;
91
+ /** override the shadow root (default <workspace>/.rovecode/checkpoints) — tests/global mode */
92
+ shadowRoot?: string;
93
+ }
94
+
95
+ /** cline's default exclusions (CheckpointExclusions.ts:42-70), structural entries first, then the media /
96
+ * archive / binary categories. The first port kept only the structural entries, and the snapshot's
97
+ * `git add .` then hashed every file the WORKSPACE tracks: in a repo carrying 165 MB of tracked video
98
+ * (site/media-src) the first mutating tool call of every session took 26–35 s and left a 232 MB shadow
99
+ * repo under .rovecode/checkpoints/<session> (measured 2026-09-06, scripts/probe-turn.ts). A checkpoint
100
+ * exists to restore what the agent changed, and the agent does not edit videos, screenshots, archives or
101
+ * compiled binaries — those are excluded by extension, like cline does. With videos alone excluded the
102
+ * same repo still took 10.7 s and 79 MB: 82 MB of site/screenshots/*.png. Text of any size is still
103
+ * snapshotted, and so is SVG (text a designer or the agent writes). */
104
+ const EXCLUDES = [
105
+ ".git/",
106
+ ".rovecode/", // the shadow repo itself lives here (deviation: in-workspace)
107
+ "node_modules/",
108
+ "dist/",
109
+ "build/",
110
+ "out/",
111
+ ".next/",
112
+ "__pycache__/",
113
+ ".venv/",
114
+ "venv/",
115
+ ".DS_Store",
116
+ // media (cline getMediaFilePatterns): video, audio, raster images — not SVG
117
+ "*.mp4", "*.m4v", "*.mov", "*.avi", "*.mkv", "*.webm", "*.wmv", "*.flv", "*.mpg", "*.mpeg",
118
+ "*.mp3", "*.m4a", "*.wav", "*.flac", "*.ogg", "*.aac", "*.wma",
119
+ "*.png", "*.jpg", "*.jpeg", "*.gif", "*.bmp", "*.ico", "*.webp", "*.tif", "*.tiff", "*.heic", "*.avif", "*.psd",
120
+ // archives and disk images (getLargeDataFilePatterns)
121
+ "*.zip", "*.tar", "*.gz", "*.tgz", "*.bz2", "*.xz", "*.7z", "*.rar", "*.iso", "*.dmg",
122
+ // compiled binaries and native libraries
123
+ "*.exe", "*.dll", "*.so", "*.dylib", "*.node", "*.wasm", "*.o", "*.a", "*.class", "*.jar", "*.pyc",
124
+ // databases and caches (getDatabaseFilePatterns / getCacheFilePatterns)
125
+ "*.sqlite", "*.sqlite3", "*.db", "*.mdb", "*.log",
126
+ ];
127
+
128
+ /** `verb` names the failing subcommand in errors; the default suits bare invocations
129
+ * like ["init"], but --git-dir'd calls must pass it (the first non-dash arg there is
130
+ * the git-dir PATH — blaming a path instead of the verb misled /restore users). */
131
+ function runGit(args: string[], cwd: string, verb = args.find((a) => !a.startsWith("-")) ?? "", extra: Record<string, string> = {}): Promise<string> {
132
+ // Explicit env hygiene: a caller's GIT_* vars must not redirect shadow commands
133
+ // at the USER repo (cline relies on simple-git cwd instead — GitOperations.ts:88).
134
+ // `extra` is OUR explicit override (position()'s scratch GIT_INDEX_FILE), applied after the scrub.
135
+ const env = { ...process.env };
136
+ for (const k of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_COMMON_DIR"]) delete env[k];
137
+ Object.assign(env, extra);
138
+ return new Promise((res, rej) => {
139
+ execFile("git", args, { cwd, env, windowsHide: true, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
140
+ if (err) rej(new Error(`git ${verb} failed: ${stderr.trim() || err.message}`));
141
+ else res(stdout.trim());
142
+ });
143
+ });
144
+ }
145
+
146
+ /** Canonical form for workspace-identity compares: realpath fixes case/8.3 aliases of
147
+ * EXISTING paths (C:\foo vs c:\foo reopened the shadow repo as "another workspace"
148
+ * and silently disabled checkpoints); the case-fold below covers paths realpath
149
+ * cannot resolve, on the case-insensitive platform only. */
150
+ function canonPath(p: string): string {
151
+ let r = p;
152
+ try { r = (realpathSync.native ?? realpathSync)(p); } catch { /* nonexistent: compare as given */ }
153
+ return process.platform === "win32" ? r.toLowerCase() : r;
154
+ }
155
+
156
+ /** The ONE spelling of a session's shadow directory, `<workspace>/.rovecode/checkpoints/<sanitised id>`: the id is
157
+ * folded charwise to [A-Za-z0-9._-] and dot-only ids ("."/"..", which survive the charwise filter but escape or
158
+ * collapse the shadow root under join()) and "" become underscores. init() creates it; `rovecode sessions delete`
159
+ * (core/session-ops.ts) removes it — both through here, so a delete never builds the path a second way. */
160
+ export function checkpointShadowDir(workspace: string, sessionId: string, shadowRoot?: string): string {
161
+ const cleaned = sessionId.replace(/[^A-Za-z0-9._-]/g, "_");
162
+ const session = /^\.*$/.test(cleaned) ? cleaned.replace(/\./g, "_") || "_" : cleaned;
163
+ return join(shadowRoot ?? join(workspace, ".rovecode", "checkpoints"), session);
164
+ }
165
+
166
+ export class Checkpoints {
167
+ /** history, oldest first (sidecar-backed: survives process restarts) */
168
+ private readonly log: Checkpoint[] = [];
169
+
170
+ private constructor(
171
+ readonly workspace: string,
172
+ /** shadow repo GIT DIR: <workspace>/.rovecode/checkpoints/<session>/.git */
173
+ readonly gitDir: string,
174
+ private readonly sidecar: string,
175
+ ) {}
176
+
177
+ /** Every shadow command names its git-dir and work-tree explicitly, so no cwd or
178
+ * environment state can ever point one at the user's repo. */
179
+ private git(...args: string[]): Promise<string> {
180
+ return runGit(["--git-dir", this.gitDir, "--work-tree", this.workspace, ...args], this.workspace, args[0]);
181
+ }
182
+
183
+ /** Create or reopen the shadow repo for a session. Works whether or not the workspace
184
+ * is a git repo — the shadow git-dir is entirely separate (non-git workspaces bar). */
185
+ static async init(opts: CheckpointsInit): Promise<Checkpoints> {
186
+ const workspace = resolve(opts.workspace);
187
+ const shadowDir = checkpointShadowDir(workspace, opts.sessionId, opts.shadowRoot);
188
+ const gitDir = join(shadowDir, ".git"); // cline layout: <checkpointsDir>/.git (CheckpointUtils.ts:20-23)
189
+ mkdirSync(shadowDir, { recursive: true });
190
+ const cp = new Checkpoints(workspace, gitDir, join(shadowDir, "checkpoints.jsonl"));
191
+
192
+ if (!existsSync(join(gitDir, "HEAD"))) {
193
+ // plain `git init` in the shadow dir, exactly GitOperations.ts:88
194
+ await runGit(["init"], shadowDir);
195
+ // GitOperations.ts:91-94 config block (identity ours; autocrlf is an rovecode addition)
196
+ for (const [k, v] of [
197
+ ["core.worktree", workspace],
198
+ ["commit.gpgSign", "false"],
199
+ ["core.autocrlf", "false"],
200
+ ["user.name", "Rovecode Checkpoint"],
201
+ ["user.email", "checkpoint@rovecode.local"],
202
+ ] as const) await cp.git("config", k, v);
203
+ } else {
204
+ // reuse check: refuse a shadow repo whose recorded worktree is another path
205
+ // (GitOperations.ts:70-73 "Checkpoints can only be used in the original workspace").
206
+ // Compared canonically — a case-variant reopen (C:\foo vs c:\foo) is the SAME dir.
207
+ const wt = await cp.git("config", "core.worktree").catch(() => "");
208
+ if (canonPath(resolve(wt)) !== canonPath(workspace)) throw new Error(`checkpoints: shadow repo belongs to ${wt}, not ${workspace}`);
209
+ }
210
+ // (re)write excludes into the shadow git-dir every init (CheckpointExclusions.ts:297-301)
211
+ mkdirSync(join(gitDir, "info"), { recursive: true });
212
+ writeFileSync(join(gitDir, "info", "exclude"), EXCLUDES.join("\n") + "\n");
213
+ cp.loadSidecar();
214
+ return cp;
215
+ }
216
+
217
+ private loadSidecar(): void {
218
+ if (!existsSync(this.sidecar)) return;
219
+ for (const line of readFileSync(this.sidecar, "utf8").split("\n")) {
220
+ if (!line.trim()) continue;
221
+ try {
222
+ const c = JSON.parse(line) as Checkpoint;
223
+ if (typeof c.hash === "string" && typeof c.label === "string") this.log.push(c);
224
+ } catch { /* corrupt sidecar line: skip, never throw (session.ts reload pattern) */ }
225
+ }
226
+ }
227
+
228
+ /** Snapshot the whole workspace: stage-all + allow-empty commit
229
+ * (GitOperations.ts:213 + CheckpointTracker.ts:251-253). Call after every mutating
230
+ * tool call; `entryId` is the session entry a conversation restore should branch to. */
231
+ async snapshot(label: string, entryId?: string): Promise<Checkpoint> {
232
+ await this.git("add", ".", "--ignore-errors");
233
+ await this.git("commit", "--allow-empty", "--no-verify", "-m", `rovecode-checkpoint: ${label}`);
234
+ const hash = await this.git("rev-parse", "HEAD");
235
+ const c: Checkpoint = { hash, label, createdAt: Date.now(), ...(entryId !== undefined ? { entryId } : {}) };
236
+ appendFileSync(this.sidecar, JSON.stringify(c) + "\n");
237
+ this.log.push(c);
238
+ return c;
239
+ }
240
+
241
+ /** History oldest→newest. */
242
+ list(): Checkpoint[] { return [...this.log]; }
243
+
244
+ /** Where the workspace sits in the snapshot history (port #65 /undo). `at` — the NEWEST checkpoint whose tree
245
+ * equals the work tree as it is now (staged into a SCRATCH index under the shadow git-dir, so the real index —
246
+ * and changedSince's untracked list — stay untouched); null when no snapshot matches (the workspace drifted).
247
+ * `previous` — the nearest ANCESTOR of `at` whose content differs (the pre-edit state; re-snapshots of an
248
+ * unchanged tree are stepped over; after a restore the shadow history forks, so the parent chain is followed,
249
+ * not the list order); null when nothing older differs. The git-call count does not grow with the history
250
+ * (rev-parse takes the hashes in batches); any git failure degrades to `{ at: null, previous: null }`, which a
251
+ * caller treats as a drifted workspace. */
252
+ async position(): Promise<{ at: Checkpoint | null; previous: Checkpoint | null }> {
253
+ const none = { at: null, previous: null };
254
+ if (this.log.length === 0) return none;
255
+ const scratch = join(this.gitDir, "rovecode-undo-index");
256
+ const base = ["--git-dir", this.gitDir, "--work-tree", this.workspace];
257
+ try {
258
+ rmSync(scratch, { force: true });
259
+ await runGit([...base, "add", ".", "--ignore-errors"], this.workspace, "add", { GIT_INDEX_FILE: scratch });
260
+ const tree = await runGit([...base, "write-tree"], this.workspace, "write-tree", { GIT_INDEX_FILE: scratch });
261
+ const trees: string[] = [];
262
+ for (let i = 0; i < this.log.length; i += 200) {
263
+ trees.push(...(await this.git("rev-parse", ...this.log.slice(i, i + 200).map((c) => `${c.hash}^{tree}`))).split("\n").map((s) => s.trim()));
264
+ }
265
+ const atIdx = trees.lastIndexOf(tree);
266
+ if (atIdx < 0) return none;
267
+ const at = this.log[atIdx]!;
268
+ for (const h of (await this.git("rev-list", at.hash)).split("\n").slice(1)) {
269
+ const i = this.log.findLastIndex((c) => c.hash === h.trim());
270
+ if (i >= 0 && trees[i] !== tree) return { at, previous: this.log[i]! };
271
+ }
272
+ return { at, previous: null };
273
+ } catch { return none; }
274
+ finally { rmSync(scratch, { force: true }); }
275
+ }
276
+
277
+ /** What a "files" restore of `hash` would touch (port #65 /undo's confirmation card): tracked paths whose
278
+ * work-tree content differs from the snapshot (`diff --name-status <hash>`: M rewritten, D recreated) plus
279
+ * the untracked, non-excluded paths `clean -fd` would remove (`?`). Read-only; null when git fails, so a
280
+ * caller falls back to a generic card instead of a wrong list. */
281
+ async changedSince(hash: string): Promise<{ path: string; status: string }[] | null> {
282
+ try {
283
+ const out: { path: string; status: string }[] = [];
284
+ for (const line of (await this.git("diff", "--name-status", hash)).split("\n")) {
285
+ const m = /^([A-Z])\S*\t(.+)$/.exec(line.trim());
286
+ if (m) out.push({ status: m[1]!, path: m[2]!.split("\t").at(-1)! });
287
+ }
288
+ for (const p of (await this.git("ls-files", "--others", "--exclude-standard")).split("\n")) if (p.trim()) out.push({ status: "?", path: p.trim() });
289
+ return out;
290
+ } catch { return null; }
291
+ }
292
+
293
+ /** Restore a checkpoint by full hash or unique prefix.
294
+ * - "files": worktree → checkpoint state (reset --hard + clean -fd; ignored paths survive)
295
+ * - "conversation": NO file changes; returns the entryId for SessionStore.branch()
296
+ * - "both": files restored AND entryId returned
297
+ * Never throws — bad refs/modes AND shadow-git failures (a stale index.lock used to
298
+ * escape here and kill the TUI on unhandled rejection) come back structured, the
299
+ * error naming the failing verb (reset/clean). */
300
+ async restore(ref: string, mode: RestoreMode): Promise<RestoreResult> {
301
+ const hits = this.log.filter((c) => c.hash === ref || c.hash.startsWith(ref));
302
+ // duplicate hashes (identical content re-snapshotted) are ONE candidate — the
303
+ // LATEST entry wins so its (newer) conversation anchor is the one restored
304
+ const target = hits.at(-1);
305
+ if (!target || ref.length < 4) return { ok: false, error: `no checkpoint matches ${ref}` };
306
+ if (new Set(hits.map((h) => h.hash)).size > 1) return { ok: false, error: `ambiguous checkpoint prefix ${ref}` };
307
+ // conversation restore needs a recorded entryId — reject BEFORE touching any file,
308
+ // so "both" can never half-apply
309
+ if (mode !== "files" && target.entryId === undefined) {
310
+ return { ok: false, error: `checkpoint ${target.hash.slice(0, 8)} has no session entryId` };
311
+ }
312
+ if (mode !== "conversation") {
313
+ try {
314
+ await this.git("reset", "--hard", target.hash); // CheckpointTracker.ts:364
315
+ // remove files created after the checkpoint; single -f spares nested git repos,
316
+ // no -x spares ignored/excluded paths (checkpoint-restore.ts:458-470)
317
+ await this.git("clean", "-fd");
318
+ } catch (e) {
319
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
320
+ }
321
+ }
322
+ return {
323
+ ok: true, mode, checkpoint: target,
324
+ ...(mode !== "files" && target.entryId !== undefined ? { entryId: target.entryId } : {}),
325
+ };
326
+ }
327
+ }
@@ -0,0 +1,138 @@
1
+ /** Port #24 — bounded unified-diff preview for edit/write approvals.
2
+ *
3
+ * The post-change content is computed IN MEMORY (edit: hashline's pure apply over the
4
+ * current file; write: the proposed content), diffed against the file with jsdiff
5
+ * (`diff`, BSD-3-Clause — see THIRD_PARTY_NOTICES.md), and rendered git-style, clipped
6
+ * to maxLines with an exact "… +N more lines" marker. Never throws: stale anchors,
7
+ * binary content, malformed args and I/O errors all come back as kind "unavailable"
8
+ * carrying the reason, so the approval overlay degrades instead of blocking the ask.
9
+ * Shape follows gemini-cli's confirmation diffs — structuredPatch with a small context
10
+ * (packages/core/src/tools/diffOptions.ts:10-18) and CR-tolerant line comparison
11
+ * (diff-utils.ts:22 splits on /\r?\n/; here jsdiff's stripTrailingCr). No code copied. */
12
+
13
+ import { existsSync, readFileSync } from "node:fs";
14
+ import { relative } from "node:path";
15
+ import { toolPath } from "../core/workspace.ts";
16
+ import { structuredPatch } from "diff";
17
+ import { applyEditsToContent, type EditFailure, type EditOp } from "./hashline.ts";
18
+
19
+ export type DiffKind = "modify" | "create" | "unchanged" | "unavailable";
20
+ export interface DiffPreview { text: string; truncated: boolean; kind: DiffKind }
21
+ export interface DiffPreviewOptions { maxLines?: number; context?: number }
22
+
23
+ /** default cap on rendered lines (headers + hunks); the TUI clips further to its rows */
24
+ export const DIFF_MAX_LINES = 40;
25
+ const DIFF_CONTEXT = 3;
26
+ /** git's buffer_is_binary heuristic: a NUL byte within the first 8000 bytes */
27
+ const BINARY_SNIFF = 8000;
28
+ /** Myers budget: a rewrite beyond this many edited lines is reported, not diffed (bounded CPU);
29
+ * the wall-clock cap is a safety net for huge files, the edit-length cap is the deterministic one */
30
+ const MAX_EDIT_LENGTH = 2000;
31
+ const MAX_DIFF_MS = 1000;
32
+ const MAX_CHARS = 4_000_000;
33
+
34
+ /** `… +N more lines` — the exact marker appended when the output is clipped. */
35
+ export function moreMarker(n: number): string { return `… +${n} more line${n === 1 ? "" : "s"}`; }
36
+
37
+ /** Keep the first `max` lines; the marker accounts for every hidden line. */
38
+ export function clipLines(lines: string[], max: number): string[] {
39
+ return lines.length > max ? [...lines.slice(0, max), moreMarker(lines.length - max)] : lines;
40
+ }
41
+
42
+ /** the ladder's own spelling (core/workspace.ts toolPath): the preview names the file the tool will open */
43
+ function resolvePath(cwd: string, p: string): string { return toolPath(cwd, p); }
44
+
45
+ function hasNul(buf: Uint8Array): boolean {
46
+ const n = Math.min(buf.length, BINARY_SNIFF);
47
+ for (let i = 0; i < n; i++) if (buf[i] === 0) return true;
48
+ return false;
49
+ }
50
+
51
+ const unavailable = (reason: string): DiffPreview => ({ text: `diff unavailable: ${reason}`, truncated: false, kind: "unavailable" });
52
+
53
+ function failureReason(f: EditFailure): string {
54
+ switch (f.kind) {
55
+ case "tag-mismatch": return `stale read — file TAG is now ${f.actual}, the edit expects ${f.expected}`;
56
+ case "hash-mismatch": return `stale anchor — line ${f.line} hash is ${f.actual}, the edit expects ${f.expected}; ${f.nearest}`;
57
+ case "out-of-range": return `line ${f.line} out of range (file has ${f.lineCount} lines)`;
58
+ }
59
+ }
60
+
61
+ /** The edit tool's args, validated (a malformed call yields "unavailable", never a throw). */
62
+ function editOps(args: Record<string, unknown>, path: string): EditOp[] | null {
63
+ if (!Array.isArray(args.edits)) return null;
64
+ const ops: EditOp[] = [];
65
+ for (const e of args.edits as unknown[]) {
66
+ const o = (typeof e === "object" && e !== null ? e : {}) as Record<string, unknown>;
67
+ if (typeof o.tag !== "string" || typeof o.anchorHash !== "string" || typeof o.anchorLine !== "number") return null;
68
+ if (!Array.isArray(o.newLines) || !o.newLines.every((l) => typeof l === "string")) return null;
69
+ ops.push({ path, tag: o.tag, anchorLine: o.anchorLine, anchorHash: o.anchorHash, newLines: o.newLines as string[] });
70
+ }
71
+ return ops;
72
+ }
73
+
74
+ /** Content → signed display lines: CRLF-tolerant, trailing newline dropped, missing EOF newline noted. */
75
+ function signedLines(content: string, sign: "+" | "-"): string[] {
76
+ const lines = content.replace(/\r\n/g, "\n").split("\n");
77
+ const eofNewline = lines[lines.length - 1] === "";
78
+ if (eofNewline) lines.pop();
79
+ const out = lines.map((l) => sign + l);
80
+ if (!eofNewline && out.length > 0) out.push("\");
81
+ return out;
82
+ }
83
+
84
+ function renderUnified(rel: string, before: string | null, after: string, opts: DiffPreviewOptions): DiffPreview {
85
+ const maxLines = opts.maxLines ?? DIFF_MAX_LINES;
86
+ if (before === after) return { text: "", truncated: false, kind: "unchanged" };
87
+ const body: string[] = [];
88
+ let kind: DiffKind = "modify";
89
+ if (before === null) {
90
+ // new file: every line is an add — no Myers pass needed (nor its cost on big files)
91
+ kind = "create";
92
+ const adds = signedLines(after, "+");
93
+ if (adds.length > 0) body.push(`@@ -0,0 +1,${adds.filter((l) => l[0] === "+").length} @@`, ...adds);
94
+ } else {
95
+ if (before.length + after.length > MAX_CHARS) return unavailable("file too large to preview");
96
+ // stripTrailingCr: a CRLF file diffs by content, not by line ending (gemini-cli diff-utils.ts:22)
97
+ const patch = structuredPatch(rel, rel, before, after, undefined, undefined,
98
+ { context: opts.context ?? DIFF_CONTEXT, stripTrailingCr: true, maxEditLength: MAX_EDIT_LENGTH, timeout: MAX_DIFF_MS });
99
+ if (patch === undefined) return unavailable(`change too large to preview (over ${MAX_EDIT_LENGTH} edited lines or ${MAX_DIFF_MS}ms)`);
100
+ for (const h of patch.hunks) body.push(`@@ -${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines} @@`, ...h.lines);
101
+ if (body.length === 0) return { text: "no content changes (line endings only)", truncated: false, kind: "unchanged" };
102
+ }
103
+ const lines = [`--- ${before === null ? "/dev/null" : `a/${rel}`}`, `+++ b/${rel}`, ...body];
104
+ return { text: clipLines(lines, maxLines).join("\n"), truncated: lines.length > maxLines, kind };
105
+ }
106
+
107
+ /** Preview what an edit/write tool call would do to its file, as a bounded unified diff.
108
+ * `args` are the (revised) tool args exactly as the tool would receive them. */
109
+ export function previewDiff(tool: "edit" | "write", args: unknown, cwd: string, opts: DiffPreviewOptions = {}): DiffPreview {
110
+ try {
111
+ const a = (typeof args === "object" && args !== null ? args : {}) as Record<string, unknown>;
112
+ if (typeof a.path !== "string" || a.path === "") return unavailable("missing path");
113
+ const abs = resolvePath(cwd, a.path);
114
+ const rel = relative(cwd, abs).replace(/\\/g, "/") || a.path;
115
+ let before: string | null = null;
116
+ if (existsSync(abs)) {
117
+ const buf = readFileSync(abs);
118
+ if (hasNul(buf)) return unavailable("binary file");
119
+ before = buf.toString("utf8");
120
+ }
121
+ let after: string;
122
+ if (tool === "write") {
123
+ if (typeof a.content !== "string") return unavailable("missing content");
124
+ after = a.content;
125
+ } else {
126
+ if (before === null) return unavailable(`file not found: ${abs}`);
127
+ const ops = editOps(a, abs);
128
+ if (ops === null) return unavailable("malformed edits");
129
+ const r = applyEditsToContent(before, ops, abs);
130
+ if (!r.ok) return unavailable(failureReason(r.failure));
131
+ after = r.content;
132
+ }
133
+ if (after.includes("\0")) return unavailable("binary content");
134
+ return renderUnified(rel, before, after, opts);
135
+ } catch (e) {
136
+ return unavailable(e instanceof Error ? e.message : String(e));
137
+ }
138
+ }