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,469 @@
1
+ /** Append-only JSONL session tree (ADR-004).
2
+ * Entries form a tree by (id, parentId); a leaf pointer selects the active path.
3
+ * One serde path (JSON) for every backend. Corruption is detected, classified, and reported. */
4
+
5
+ import { createHash, randomUUID } from "node:crypto";
6
+ import { mkdirSync, existsSync, readFileSync, writeFileSync, appendFileSync, readdirSync, renameSync, statSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import type { ImagePart, Message, RunEvent, TextPart } from "./types.ts";
9
+ import { hydrateImageParts, sidecarImageParts } from "./session-images.ts";
10
+ import { oneLineTitle, previewText } from "./session-text.ts";
11
+
12
+ export type Entry = Message | ({ id: string; kind: "event"; parentId: string | null; createdAt: number; event: RunEvent });
13
+
14
+ /** Port #34 image sidecars (session-images.ts): `<session>/attachments/<sha256>.<ext>`. The store's
15
+ * sidecar writer references them from entries.jsonl in ONE form, the session-relative
16
+ * `attachments/<file>`, resolved to an absolute path in memory only. Any other persisted path —
17
+ * whatever wrote it — never resolves to a readable file: a non-canonical relative path stays
18
+ * relative (F2) and an absolute path is dropped at load (F3), so the part lowers to a "file
19
+ * unavailable" placeholder and no line in entries.jsonl can point a hydrated part at a file
20
+ * outside `<session>/attachments/`.
21
+ * `rovecode export --json` copies entries.jsonl ALONE — the attachments directory travels with the
22
+ * session directory, not with the export (the JSONL stays a small, verbatim-copyable record). */
23
+
24
+ export type CorruptionKind =
25
+ | "orphan-entry" // parentId points at nothing
26
+ | "cycle" // ancestry loop
27
+ | "duplicate-id"
28
+ | "malformed-json"
29
+ | "unknown-shape"
30
+ | "chain-broken"; // prevHash disagrees with the parent entry's hash (tree/chain fork)
31
+
32
+ export interface Corruption { kind: CorruptionKind; entryId?: string; line: number; detail: string }
33
+
34
+ /** `leaf` (optional, port #2): durable active-leaf pointer. Absent = legacy = last entry wins.
35
+ * `title` / `forkedFrom` (aion port #84, 2026-09-07): a user-given name (`rovecode sessions rename`, one-lined on
36
+ * write AND on read — session-text.ts) and the id a fork was copied from. Written only through patchMeta. */
37
+ export interface SessionMeta { id: string; createdAt: number; goal?: string; model?: string; leaf?: string; title?: string; forkedFrom?: string }
38
+
39
+ /** Hash chain: each entry carries sha256(prevHash + canonical(entry)). Tamper-evident replay.
40
+ * Accepts the wrapped envelope too — the chain hashes the full wrapper (hash field empty). */
41
+ export function chainHash(prev: string, entry: Entry | object): string {
42
+ const canon = JSON.stringify(sortKeys(entry));
43
+ return createHash("sha256").update(prev + canon).digest("hex");
44
+ }
45
+
46
+ function sortKeys(v: unknown): unknown {
47
+ if (Array.isArray(v)) return v.map(sortKeys);
48
+ if (v && typeof v === "object") {
49
+ return Object.fromEntries(
50
+ Object.entries(v as Record<string, unknown>).sort(([a], [b]) => a.localeCompare(b)).map(([k, x]) => [k, sortKeys(x)])
51
+ );
52
+ }
53
+ return v;
54
+ }
55
+
56
+ interface Wrapped { id: string; parentId: string | null; createdAt: number; prevHash: string; hash: string; entry: Entry }
57
+
58
+ /** Shape of a loaded entry (ADR-004: classify foreign/corrupt lines, never crash on them):
59
+ * "event" = kind "event"; "message" = a role plus a parts ARRAY whose members are objects with a
60
+ * string kind; undefined = anything else (null, a scalar, `parts: [null]`, `parts: "x"`, `{}`) —
61
+ * reload reports it as unknown-shape, path()/turnPoints() skip it, hydration leaves it alone. */
62
+ export function entryShape(e: unknown): "message" | "event" | undefined {
63
+ if (!e || typeof e !== "object") return undefined;
64
+ if ((e as { kind?: unknown }).kind === "event") return "event";
65
+ const m = e as { role?: unknown; parts?: unknown };
66
+ if (!("role" in m) || !Array.isArray(m.parts)) return undefined;
67
+ return m.parts.every((p: unknown) => !!p && typeof p === "object" && typeof (p as { kind?: unknown }).kind === "string") ? "message" : undefined;
68
+ }
69
+
70
+ /** Event entry (kind "event") vs message — tolerant of foreign/corrupt entry shapes. */
71
+ function isEventWrapped(w: Wrapped): boolean { return entryShape(w.entry) === "event"; }
72
+
73
+ export interface SessionSummary {
74
+ id: string;
75
+ createdAt: number;
76
+ updatedAt: number; // max entry createdAt, else meta createdAt
77
+ entryCount: number;
78
+ preview: string; // first user-message text, single-line, ≤80 chars, "" if none
79
+ /** user messages recorded in the session file (every branch) */
80
+ turns: number;
81
+ /** the user-given title (meta.json `title`, one-lined on read) — the key is present ONLY when one is set */
82
+ title?: string;
83
+ }
84
+
85
+ export interface ScanOptions {
86
+ /** read the details (meta + entries) of at most this many sessions, newest file first; the rest are not opened */
87
+ limit?: number;
88
+ /** also list the hollow directories (a meta.json and no entry ever written) as sessions with entryCount 0 */
89
+ includeHollow?: boolean;
90
+ }
91
+
92
+ export interface SessionScan {
93
+ /** the sessions that hold something, newest first (plus the hollow ones when asked) */
94
+ sessions: SessionSummary[];
95
+ /** directory names that have a meta.json but no entries.jsonl (or an empty one) — nothing was ever written
96
+ * to them. Counted from the stat alone, never opened; `rovecode sessions` prints the count so the question of
97
+ * deleting them stays askable (Berkay has not decided; 35 of 36 on his machine, 2026-09-07). */
98
+ hollow: string[];
99
+ }
100
+
101
+ /** Scan rootDir WITHOUT opening every file (2026-09-07): one readdir, one stat of entries.jsonl per directory, and
102
+ * meta.json + entries are read only for the directories that hold something — and only the newest `limit` of those
103
+ * when a limit is given (`--continue` needs one session, the picker twenty; the file's mtime orders the candidates
104
+ * before anything is opened, so a limit trusts the filesystem clock rather than the entries' own timestamps). The
105
+ * scan used to read the whole entries.jsonl of every directory to answer "which is newest": 36 directories on one
106
+ * machine, 35 of them empty, for one answer. A directory with neither file is foreign and skipped silently as
107
+ * before; corrupt files never throw. Sorted updatedAt desc. */
108
+ export function scanSessions(rootDir: string, opts: ScanOptions = {}): SessionScan {
109
+ const hollow: string[] = [];
110
+ const candidates: { name: string; mtime: number }[] = [];
111
+ let names: string[];
112
+ try { names = readdirSync(rootDir); } catch { return { sessions: [], hollow }; }
113
+ for (const name of names) {
114
+ let size = -1, mtime = 0;
115
+ try { const st = statSync(join(rootDir, name, "entries.jsonl")); if (st.isFile()) { size = st.size; mtime = st.mtimeMs; } } catch { /* no entries file */ }
116
+ if (size > 0) { candidates.push({ name, mtime }); continue; }
117
+ try { if (statSync(join(rootDir, name, "meta.json")).isFile()) hollow.push(name); } catch { /* neither file: foreign, skipped */ }
118
+ }
119
+ candidates.sort((a, b) => b.mtime - a.mtime);
120
+ const chosen = opts.limit !== undefined ? candidates.slice(0, Math.max(0, opts.limit)) : candidates;
121
+ const sessions: SessionSummary[] = [];
122
+ for (const { name } of chosen) { const s = summarize(rootDir, name); if (s) sessions.push(s); }
123
+ if (opts.includeHollow) for (const name of hollow) { const s = summarize(rootDir, name); if (s) sessions.push(s); }
124
+ return { sessions: sessions.sort((a, b) => b.updatedAt - a.updatedAt), hollow };
125
+ }
126
+
127
+ /** one directory's summary: meta.json (identity = the DIRECTORY name, never meta.id — a copied/tampered meta.json
128
+ * must not redirect resume to another path) plus a pass over entries.jsonl; undefined for a corrupt or foreign dir */
129
+ function summarize(rootDir: string, name: string): SessionSummary | undefined {
130
+ try {
131
+ const raw: unknown = JSON.parse(readFileSync(join(rootDir, name, "meta.json"), "utf8"));
132
+ if (!raw || typeof raw !== "object") return undefined;
133
+ const meta = raw as SessionMeta;
134
+ if (typeof meta.id !== "string" || typeof meta.createdAt !== "number") return undefined;
135
+ let updatedAt = meta.createdAt; let entryCount = 0; let preview = ""; let turns = 0;
136
+ const entriesPath = join(rootDir, name, "entries.jsonl");
137
+ if (existsSync(entriesPath)) {
138
+ for (const line of readFileSync(entriesPath, "utf8").split("\n")) {
139
+ if (!line.trim()) continue;
140
+ let parsed: unknown;
141
+ try { parsed = JSON.parse(line); } catch { continue; }
142
+ if (!parsed || typeof parsed !== "object") continue;
143
+ const w = parsed as Wrapped;
144
+ entryCount++;
145
+ if (typeof w.createdAt === "number" && w.createdAt > updatedAt) updatedAt = w.createdAt;
146
+ const e: unknown = w.entry;
147
+ if (entryShape(e) === "message" && (e as Message).role === "user") {
148
+ turns++;
149
+ if (!preview) preview = previewText(e as Message);
150
+ }
151
+ }
152
+ }
153
+ const title = typeof meta.title === "string" ? oneLineTitle(meta.title) : undefined; // one-lined on READ; "" → no key
154
+ return { id: name, createdAt: meta.createdAt, updatedAt, entryCount, preview, turns, ...(title !== undefined ? { title } : {}) };
155
+ } catch { return undefined; /* foreign or corrupt dir: skip, never throw */ }
156
+ }
157
+
158
+ /** The sessions that hold something, newest first — scanSessions without the hollow count. `opts.includeHollow`
159
+ * lists the hollow directories too (the TUI picker and the CLI table do not; an exact id still opens one). */
160
+ export function listSessions(rootDir: string, opts: ScanOptions = {}): SessionSummary[] {
161
+ return scanSessions(rootDir, opts).sessions;
162
+ }
163
+
164
+ /** The session `rovecode --continue` (or `--resume` with no id) reopens: the most recently updated one that
165
+ * HOLDS something. Costs one readdir, one stat per directory and ONE read (the newest file); a newest file made
166
+ * only of unparseable lines falls back to the full list rather than answering "nothing". */
167
+ export function newestSession(rootDir: string): SessionSummary | undefined {
168
+ const first = scanSessions(rootDir, { limit: 1 }).sessions[0];
169
+ if (first !== undefined && first.entryCount > 0) return first;
170
+ return listSessions(rootDir).find((s) => s.entryCount > 0);
171
+ }
172
+
173
+ export interface TurnPoint {
174
+ entryId: string; // the user message's wrapped-entry id
175
+ index: number; // 1-based position among user turns on the active path
176
+ text: string; // single-line preview ≤80 chars (overlay label ONLY)
177
+ /** untruncated message text — the edit-and-resubmit prefill (pi sessions.md:113) */
178
+ fullText: string;
179
+ parentId: string | null; // the entry's parent (rewind target: leaf moves HERE)
180
+ branches: number; // children of parentId in the whole tree MINUS the active-path child (0 = linear)
181
+ }
182
+
183
+ export class SessionStore {
184
+ private readonly dir: string;
185
+ private leaf = "root";
186
+ private prevHash = "";
187
+ private cache: Wrapped[] = [];
188
+ private meta: SessionMeta;
189
+ /** true once meta.json carries a leaf field — appends then keep it in step. */
190
+ private leafPersisted = false;
191
+ /** port #34: image parts waiting for the next user message (stageAttachments) */
192
+ private staged: ImagePart[] = [];
193
+
194
+ /** Opening a store touches nothing on disk. The directory and meta.json appear with the FIRST entry
195
+ * (append, appendEvent, branch) — see materialize(). Constructing used to write both immediately, so
196
+ * every start that never got a prompt (`rovecode --help` paths that boot a runtime, `rovecode context`,
197
+ * a TUI opened and closed, every test) left `<sessions>/<uuid>/meta.json` behind: dozens of empty
198
+ * directories that listSessions offered as sessions with nothing in them. Deleting them afterwards was
199
+ * the other option, and it races: process B prunes the directory process A has just opened and not
200
+ * yet written to. Not creating it has no such window. An existing session (meta.json + entries) opens
201
+ * exactly as before; nothing is ever deleted. */
202
+ constructor(rootDir: string, public readonly id: string) {
203
+ this.dir = join(rootDir, id);
204
+ this.meta = { id, createdAt: Date.now() };
205
+ this.reload();
206
+ }
207
+
208
+ private get file() { return join(this.dir, "entries.jsonl"); }
209
+
210
+ /** Make the session real on disk: the directory plus meta.json (once). Called on every write path,
211
+ * before the write. Idempotent and cheap (a mkdir on an existing directory and one stat), so it also
212
+ * means a session whose empty directory was swept away by hand recovers on its next entry instead of
213
+ * throwing ENOENT from appendFileSync. `createdAt` is the construction time, written whenever the first
214
+ * entry lands — the session began when it was opened, not when someone first spoke. */
215
+ private materialize(): void {
216
+ mkdirSync(this.dir, { recursive: true });
217
+ const metaP = join(this.dir, "meta.json");
218
+ if (!existsSync(metaP)) writeFileSync(metaP, JSON.stringify(this.meta, null, 2));
219
+ }
220
+
221
+ /** Replay JSONL → cache; detect corruption instead of crashing (pi reducer pattern).
222
+ * Restores a persisted leaf (durable branch) when meta.json names an existing entry;
223
+ * missing/invalid leaf falls back to the last tree-linked, well-shaped entry (LOW-A below). */
224
+ reload(): Corruption[] {
225
+ this.cache = [];
226
+ this.leaf = "root"; this.prevHash = ""; // a replay that finds no leaf (no file, only foreign lines) is a fresh root
227
+ const seen = new Set<string>();
228
+ const corrupt: Corruption[] = [];
229
+ let persistedLeaf: string | undefined;
230
+ this.leafPersisted = false;
231
+ try {
232
+ const raw: unknown = JSON.parse(readFileSync(join(this.dir, "meta.json"), "utf8"));
233
+ if (raw && typeof raw === "object") {
234
+ const m = raw as SessionMeta;
235
+ if (typeof m.id === "string" && typeof m.createdAt === "number") this.meta = m;
236
+ if (typeof m.leaf === "string") { persistedLeaf = m.leaf; this.leafPersisted = true; }
237
+ }
238
+ } catch { /* unreadable meta behaves as legacy (no persisted leaf) */ }
239
+ if (!existsSync(this.file)) return corrupt;
240
+ const lines = readFileSync(this.file, "utf8").split("\n").filter(Boolean);
241
+ const byLine = new Map<string, Wrapped>(); // ids seen so far (first occurrence wins)
242
+ // LOW-A (#34): the fallback leaf = the last entry that is BOTH tree-linked (a root, or its parent seen
243
+ // above it) AND a message/event. As cache.at(-1) it was whatever id-bearing line came last, so a
244
+ // foreign `{"id":"ghost","parentId":"nope",…}` hijacked the active path (messages() shrank to the
245
+ // ghost, the next append parented on it) and a bare `{"id":"bare"}` emptied it and re-rooted the next
246
+ // append — silently: only `rovecode trace` shows these findings, the constructor discards them. Such
247
+ // lines stay in the cache for chain/reporting and keep their orphan-entry / unknown-shape findings.
248
+ let tail: Wrapped | undefined;
249
+ lines.forEach((line, i) => {
250
+ let w: Wrapped;
251
+ try {
252
+ w = JSON.parse(line) as Wrapped;
253
+ } catch {
254
+ corrupt.push({ kind: "malformed-json", line: i, detail: `unparseable line ${i}` });
255
+ return;
256
+ }
257
+ // F1: a line that parses but is not an entry object (null, a number, a string) is reported, never dereferenced
258
+ if (!w || typeof w !== "object") { corrupt.push({ kind: "unknown-shape", line: i, detail: "line is not an entry object" }); return; }
259
+ // F4: an object line with no string id (`{"entry":null}`) is not a tree node either — reported and
260
+ // skipped; cached, it became the leaf (cache.at(-1)) with id undefined, emptied messages() and
261
+ // re-rooted the next append (the loop parents on history.at(-1) ?? null)
262
+ if (typeof w.id !== "string") { corrupt.push({ kind: "unknown-shape", line: i, detail: "entry line has no string id" }); return; }
263
+ if (seen.has(w.id)) corrupt.push({ kind: "duplicate-id", entryId: w.id, line: i, detail: "duplicate id" });
264
+ seen.add(w.id);
265
+ const orphan = w.parentId !== null && !seen.has(w.parentId);
266
+ if (orphan) corrupt.push({ kind: "orphan-entry", entryId: w.id, line: i, detail: `parent ${w.parentId} missing` });
267
+ // chain linkage: prevHash must equal the PARENT's hash ("" for roots). A leaf moved
268
+ // mid-run used to fork the hash chain away from the parent pointer — detect it.
269
+ // Missing parents are skipped here (already reported as orphan-entry above).
270
+ const expected = w.parentId === null ? "" : byLine.get(w.parentId)?.hash;
271
+ if (expected !== undefined && w.prevHash !== expected) {
272
+ corrupt.push({ kind: "chain-broken", entryId: w.id, line: i, detail: `prevHash disagrees with parent ${w.parentId ?? "(root)"}` });
273
+ }
274
+ if (!byLine.has(w.id)) byLine.set(w.id, w);
275
+ // F1: a foreign/corrupt entry (null, a scalar, parts:[null], …) is reported and kept in the tree
276
+ // for chain purposes; path()/turnPoints() skip it and hydrateImages leaves it untouched
277
+ const shape = entryShape(w.entry);
278
+ if (shape === undefined) corrupt.push({ kind: "unknown-shape", entryId: w.id, line: i, detail: "entry is neither a message nor an event" });
279
+ w.entry = this.hydrateImages(w.entry); // session-relative sidecar paths → absolute (in memory only)
280
+ this.cache.push(w);
281
+ if (!orphan && shape !== undefined) tail = w;
282
+ });
283
+ // cycle check over ancestry
284
+ const byId = new Map(this.cache.map((w) => [w.id, w]));
285
+ for (const w of this.cache) {
286
+ const anc = new Set<string>(); let cur: Wrapped | undefined = w;
287
+ while (cur && cur.parentId !== null) {
288
+ if (anc.has(cur.id)) { corrupt.push({ kind: "cycle", entryId: w.id, line: -1, detail: "ancestry cycle" }); break; }
289
+ anc.add(cur.id); cur = byId.get(cur.parentId);
290
+ }
291
+ }
292
+ if (tail) { this.leaf = tail.id; this.prevHash = tail.hash; }
293
+ if (persistedLeaf !== undefined) {
294
+ const w = byId.get(persistedLeaf);
295
+ if (w) { this.leaf = w.id; this.prevHash = w.hash; }
296
+ }
297
+ return corrupt;
298
+ }
299
+
300
+ append(entry: Entry): void {
301
+ // A supplied parentId can lag the leaf (the loop snapshots history at run start;
302
+ // /new and /rewind may move the leaf mid-run). The chain must follow the PARENT
303
+ // pointer, never the moved leaf, or hash chain and tree silently disagree.
304
+ this.materialize();
305
+ const supplied = (entry as { parentId?: string | null }).parentId;
306
+ const parentId = supplied !== undefined ? supplied : this.leaf;
307
+ let prevHash = this.prevHash;
308
+ if (parentId !== this.leaf) {
309
+ prevHash = parentId === null ? "" : (this.cache.find((c) => c.id === parentId)?.hash ?? this.prevHash);
310
+ }
311
+ // port #34: staged attachments ride on this user message — folded into the caller's parts
312
+ // array IN PLACE, because the loop persists the very object it keeps in its history
313
+ // (loop.ts:116-122); that is what puts the image on the wire this run without a loop change
314
+ if (this.staged.length > 0 && "role" in entry && entry.role === "user") {
315
+ entry.parts.push(...this.staged);
316
+ this.staged = [];
317
+ }
318
+ const persisted = this.sidecarImages(entry); // inline bytes → sidecar files; entries.jsonl stays small
319
+ const w: Wrapped = {
320
+ id: entry.id, parentId,
321
+ createdAt: entry.createdAt ?? Date.now(),
322
+ prevHash,
323
+ hash: "",
324
+ entry: persisted,
325
+ };
326
+ w.hash = chainHash(prevHash, w);
327
+ appendFileSync(this.file, JSON.stringify(w) + "\n");
328
+ this.cache.push(persisted === entry ? w : { ...w, entry: this.hydrateImages(persisted) });
329
+ this.leaf = w.id; this.prevHash = w.hash;
330
+ if (this.leafPersisted) this.persistLeaf(); // keep the durable leaf in step after a branch
331
+ }
332
+
333
+ /** Port #34 TUI attach path (`/attach <path>` → next submit): image parts staged here are
334
+ * appended to the parts of the NEXT user message that lands in append(), then cleared.
335
+ * System/assistant/tool entries in between (a pending mode switch, tool results) leave the
336
+ * stage untouched. Replaces any earlier stage; `[]` clears it. */
337
+ stageAttachments(parts: readonly ImagePart[]): void { this.staged = [...parts]; }
338
+
339
+ get stagedAttachments(): readonly ImagePart[] { return this.staged; }
340
+
341
+ /** The on-disk form (session-images.ts sidecarImageParts): inline image bytes → sidecar files +
342
+ * session-relative paths. Same object when there is nothing to do or the shape is foreign (F1). */
343
+ private sidecarImages(entry: Entry): Entry {
344
+ if (entryShape(entry) !== "message" || !("role" in entry)) return entry;
345
+ const parts = sidecarImageParts(this.dir, entry.parts);
346
+ return parts === undefined ? entry : { ...entry, parts };
347
+ }
348
+
349
+ /** The in-memory form (session-images.ts hydrateImageParts): canonical sidecar paths → absolute
350
+ * under this session's dir; a persisted absolute path is dropped (F3), any other relative one
351
+ * stays put (F2). Same object when there is nothing to do or the shape is foreign (F1). */
352
+ private hydrateImages(entry: Entry): Entry {
353
+ if (entryShape(entry) !== "message" || !("role" in entry)) return entry;
354
+ const parts = hydrateImageParts(this.dir, entry.parts);
355
+ return parts === undefined ? entry : { ...entry, parts };
356
+ }
357
+
358
+ /** Persist a RunEvent as an ANNOTATION of the current leaf (port #25: the loop's compaction
359
+ * marker). The entry hangs off the leaf without becoming one — the loop parents its next
360
+ * message on the last real message (history.at(-1)), so a chain-linked event would turn
361
+ * into a dead sibling the moment that message lands. Hash-chained like any entry; path()
362
+ * folds it back in right after the message it annotates; messages() never sees it. */
363
+ appendEvent(event: RunEvent): Entry {
364
+ this.materialize();
365
+ const parentId = this.leaf === "root" ? null : this.leaf;
366
+ const entry: Entry = { id: randomUUID(), kind: "event", parentId, createdAt: Date.now(), event };
367
+ const w: Wrapped = { id: entry.id, parentId, createdAt: entry.createdAt, prevHash: this.prevHash, hash: "", entry };
368
+ w.hash = chainHash(this.prevHash, w);
369
+ appendFileSync(this.file, JSON.stringify(w) + "\n");
370
+ this.cache.push(w);
371
+ return entry;
372
+ }
373
+
374
+ /** Active path = root → leaf (omp buildSessionContext); unknown-shape entries (reload F1) are skipped. */
375
+ path(): Entry[] { return this.wrappedPath().filter((w) => entryShape(w.entry) !== undefined).map((w) => w.entry); }
376
+
377
+ private wrappedPath(): Wrapped[] {
378
+ const byId = new Map(this.cache.map((w) => [w.id, w]));
379
+ const out: Wrapped[] = [];
380
+ let cur = byId.get(this.leaf);
381
+ // A tampered or corrupt file can carry an ancestry cycle (reload() already reports it as `cycle`);
382
+ // walking it here looped forever and died with `RangeError: Out of memory`, so ANY surface that
383
+ // resumed such a session — run --resume, trace, export, context — crashed instead of reporting the
384
+ // corruption. Found by the gauntlet's session-tamper case (eval/gauntlet-wave4.ts), 2026-09-07. The
385
+ // walk now stops at the first id it has already seen: the path truncates there, the finding stays on
386
+ // reload()'s list, and nothing hangs.
387
+ const seen = new Set<string>();
388
+ while (cur && !seen.has(cur.id)) { seen.add(cur.id); out.unshift(cur); cur = cur.parentId ? byId.get(cur.parentId) : undefined; }
389
+ // event annotations (appendEvent) are children of path entries but never parents: fold each
390
+ // in right after the entry it annotates, file order. Chain-linked events (a supplied
391
+ // parentId via append, e.g. the export fixture) are already on the path and stay put.
392
+ const onPath = new Set(out.map((w) => w.id));
393
+ const notes = new Map<string | null, Wrapped[]>();
394
+ for (const w of this.cache) {
395
+ if (onPath.has(w.id) || !isEventWrapped(w)) continue;
396
+ if (w.parentId !== null && !onPath.has(w.parentId)) continue;
397
+ const list = notes.get(w.parentId) ?? []; list.push(w); notes.set(w.parentId, list);
398
+ }
399
+ if (notes.size === 0) return out;
400
+ const folded: Wrapped[] = [...(notes.get(null) ?? [])];
401
+ for (const w of out) folded.push(w, ...(notes.get(w.id) ?? []));
402
+ return folded;
403
+ }
404
+
405
+ /** User turns along the ACTIVE path only, root→leaf order. */
406
+ turnPoints(): TurnPoint[] {
407
+ const children = new Map<string | null, number>();
408
+ // event annotations hang off entries without forking them: not a branch (port #25)
409
+ for (const w of this.cache) if (!isEventWrapped(w)) children.set(w.parentId, (children.get(w.parentId) ?? 0) + 1);
410
+ const out: TurnPoint[] = [];
411
+ for (const w of this.wrappedPath()) {
412
+ const e = w.entry;
413
+ if (entryShape(e) !== "message" || !("role" in e) || e.role !== "user") continue; // F1: foreign shapes skipped
414
+ out.push({
415
+ entryId: w.id,
416
+ index: out.length + 1,
417
+ text: previewText(e),
418
+ fullText: e.parts.filter((p): p is TextPart => p.kind === "text").map((p) => p.text).join(""),
419
+ parentId: w.parentId,
420
+ branches: (children.get(w.parentId) ?? 1) - 1,
421
+ });
422
+ }
423
+ return out;
424
+ }
425
+
426
+ /** Branch: move leaf back to an earlier entry without deleting anything.
427
+ * Durable (port #2): the leaf survives restarts via meta.json. Unknown id → false. */
428
+ branch(entryId: string): boolean {
429
+ const target = this.cache.find((w) => w.id === entryId);
430
+ if (!target) return false;
431
+ this.leaf = target.id;
432
+ this.prevHash = target.hash;
433
+ this.persistLeaf();
434
+ return true;
435
+ }
436
+
437
+ /** Atomically rewrite meta.json carrying the active leaf (write tmp + rename). */
438
+ private persistLeaf(): void {
439
+ this.materialize();
440
+ this.meta = { ...this.meta, leaf: this.leaf };
441
+ this.writeMeta(this.meta);
442
+ this.leafPersisted = true;
443
+ }
444
+
445
+ /** The ONE meta writer for everything that is not the leaf (aion port #84): `title`, `forkedFrom`, `createdAt`.
446
+ * Reads the file as it is on disk so unknown keys and their order survive byte-for-byte, applies the patch, and
447
+ * forces `id` = the directory name (a meta.json copied from another session self-heals). The entries file is
448
+ * untouched. Materialises the directory first, so a fork that copied meta.json in already exists. */
449
+ patchMeta(patch: Partial<Pick<SessionMeta, "title" | "forkedFrom" | "createdAt">>): void {
450
+ this.materialize();
451
+ let current: Record<string, unknown> = {};
452
+ try {
453
+ const raw: unknown = JSON.parse(readFileSync(join(this.dir, "meta.json"), "utf8"));
454
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) current = raw as Record<string, unknown>;
455
+ } catch { /* unreadable: rebuild from what this store knows */ current = { ...this.meta }; }
456
+ const next = { ...current, ...patch, id: this.id } as Record<string, unknown>;
457
+ this.writeMeta(next);
458
+ this.meta = { ...this.meta, ...patch, id: this.id };
459
+ }
460
+
461
+ private writeMeta(meta: object): void {
462
+ const metaP = join(this.dir, "meta.json");
463
+ const tmp = metaP + ".tmp";
464
+ writeFileSync(tmp, JSON.stringify(meta, null, 2));
465
+ renameSync(tmp, metaP);
466
+ }
467
+
468
+ messages(): Message[] { return this.path().filter((e): e is Message => "role" in e); }
469
+ }
@@ -0,0 +1,170 @@
1
+ /** Persisted preferences — the answers you should only have to give once.
2
+ *
3
+ * Until this file existed, the permission level lived in the session: `/yolo` and `/accept-edits`
4
+ * died with the terminal, so every launch started back at "ask before every write and every
5
+ * command". That is the difference between a dial and a nag.
6
+ *
7
+ * Two scopes, the providers.json idiom exactly (providers/provider-config.ts): `~/.rovecode/
8
+ * settings.json` is you, `<cwd>/.rovecode/settings.json` is this repository, and the project file
9
+ * wins — "in THIS checkout, stop asking" is a different sentence from "stop asking anywhere".
10
+ *
11
+ * Precedence, highest first: an explicit CLI flag → ROVECODE_PERMISSION → project → user → "ask".
12
+ * A flag is about this run, an env var about this shell, a file about this place; the narrower the
13
+ * intent, the louder it speaks.
14
+ *
15
+ * A missing, unreadable or malformed file reads as "no preference" — never an error. A settings
16
+ * file is a convenience; losing it must never stop the agent from starting. */
17
+
18
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
19
+ import { dirname, join } from "node:path";
20
+ import { rovecodeHome } from "../providers/auth.ts";
21
+ import { isTrustedFile, untrustedFileNote } from "./trust.ts";
22
+ import { THINKING_EFFORTS, type PermissionLevel, type ThinkingEffort } from "./types.ts";
23
+
24
+ export type SettingsScope = "user" | "project";
25
+
26
+ /** The keys whose value is something rovecode EXECUTES — a shell string, an argv table, an argv. From the PROJECT file
27
+ * they are honoured only once that file is trusted on this machine (core/trust.ts): a cloned repo must not decide what
28
+ * we run. The user file is the person's own and is never gated. ONE list, enforced inside loadSettings itself, so a key
29
+ * added later joins the gate by being named here — not by every consumer remembering to ask. `verify: false` is a
30
+ * refusal, not a command, and stays honoured from any file. */
31
+ export const COMMAND_KEYS = ["verify", "lsp", "notify_command"] as const;
32
+ export type CommandKey = (typeof COMMAND_KEYS)[number];
33
+
34
+ export interface Settings {
35
+ /** how much the human is asked — see types.ts PermissionLevel */
36
+ permission?: PermissionLevel;
37
+ /** how hard the model thinks before answering */
38
+ effort?: ThinkingEffort;
39
+ /** terminal notifications when a run ends or a card needs you — default true; `false` turns them off.
40
+ * A terminal that beeps when you did not ask is worse than silence, so this is one key, in the file
41
+ * you already have, rather than a flag you have to remember every launch. Since 2026-09-07 the signal
42
+ * fires only while the terminal is UNFOCUSED (tui/notify.ts; `notify_when: "always"` restores every run). */
43
+ bell?: boolean;
44
+ /** the notification method (tui/notify.ts): auto (default — an OSC 9 toast on Ghostty / iTerm2 / kitty / Warp /
45
+ * WezTerm, the bell everywhere else) | bell | osc9 | osc777. ROVECODE_NOTIFY overrides it (off = `bell: false`). */
46
+ notify?: "auto" | "bell" | "osc9" | "osc777";
47
+ /** when to notify: unfocused (default — the terminal must report focus, which Windows Terminal ≥ 1.14, Ghostty,
48
+ * kitty, WezTerm, iTerm2, xterm and VTE do) | always. ROVECODE_NOTIFY_WHEN overrides it. */
49
+ notify_when?: "unfocused" | "always";
50
+ /** a desktop hook run on every notification, under the same gate: argv as a JSON string array
51
+ * (`["notify-send","rovecode"]`) or whitespace-split words, the JSON payload appended as its last argument —
52
+ * never a shell. TRUST-REQUIRED from the project file: a repo-supplied command applies only once that file is
53
+ * approved in the digest store (mcp/trust.ts); the user file and ROVECODE_NOTIFY_COMMAND always may. */
54
+ notify_command?: string;
55
+ /** the check the loop runs after the agent's last edit before its reply counts as done (core/verify.ts):
56
+ * one shell command, or a list run in order; `false` turns the gate off AND stops any inference. The
57
+ * one source that needs no guessing — a project should set this rather than let a `test` script be
58
+ * inferred, because a gate that runs the wrong command once is turned off forever. */
59
+ verify?: string | string[] | false;
60
+ /** the LSP server table (coding/lsp-servers.ts): `ext[,ext]=argv;…`, `ext=off`, or `off` — merged over the
61
+ * built-in typescript-language-server entry; ROVECODE_LSP overrides it. Kept as written: a malformed table is
62
+ * named at boot and in `rovecode doctor` (coding/lsp-gate.ts lspAvailabilityNotes) instead of being dropped here
63
+ * in silence; the gate applies its valid entries. */
64
+ lsp?: string;
65
+ }
66
+
67
+ const FILE = "settings.json";
68
+
69
+ export function settingsPath(scope: SettingsScope, cwd: string): string {
70
+ return scope === "user" ? join(rovecodeHome(), FILE) : join(cwd, ".rovecode", FILE);
71
+ }
72
+
73
+ const PERMISSIONS: readonly string[] = ["ask", "accept-edits", "auto"];
74
+
75
+ /** Only keys we recognize survive, and only with values we recognize: a hand-edited
76
+ * `"permission": "yes"` must not become a truthy something downstream. */
77
+ function sanitize(raw: unknown): Settings {
78
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {};
79
+ const r = raw as Record<string, unknown>;
80
+ const out: Settings = {};
81
+ if (typeof r.permission === "string" && PERMISSIONS.includes(r.permission)) out.permission = r.permission as PermissionLevel;
82
+ if (typeof r.effort === "string" && (THINKING_EFFORTS as readonly string[]).includes(r.effort)) out.effort = r.effort as ThinkingEffort;
83
+ if (typeof r.bell === "boolean") out.bell = r.bell; // "off"/"no" are not false: a string is ignored, the bell stays on
84
+ if (typeof r.notify === "string" && ["auto", "bell", "osc9", "osc777"].includes(r.notify)) out.notify = r.notify as Settings["notify"];
85
+ if (r.notify_when === "unfocused" || r.notify_when === "always") out.notify_when = r.notify_when;
86
+ // notify_command: one non-blank string (the argv grammar lives in tui/notify-seq.ts parseArgv); blank means "not set"
87
+ if (typeof r.notify_command === "string" && r.notify_command.trim() !== "" && r.notify_command.length <= 2_000) out.notify_command = r.notify_command;
88
+ // verify: a non-empty command string, a list of them (empty strings dropped, at most 8, each under 500 chars),
89
+ // or `false`. Not a table with the other keys on purpose: the shapes differ (two enums, a boolean, this).
90
+ if (r.verify === false) out.verify = false;
91
+ else if (typeof r.verify === "string" && r.verify.trim() !== "" && r.verify.length <= 500) out.verify = r.verify.trim();
92
+ else if (Array.isArray(r.verify)) {
93
+ const cmds = r.verify.filter((v): v is string => typeof v === "string" && v.trim() !== "" && v.length <= 500).map((v) => v.trim()).slice(0, 8);
94
+ if (cmds.length > 0) out.verify = cmds;
95
+ }
96
+ // lsp: one string (a table can be a whole line; 2 000 chars is room for a dozen servers with quoted paths); blank
97
+ // means "not set" — the knob's own grammar treats blank as the defaults anyway. Not validated here: see the key's doc.
98
+ if (typeof r.lsp === "string" && r.lsp.trim() !== "" && r.lsp.length <= 2_000) out.lsp = r.lsp;
99
+ return out;
100
+ }
101
+
102
+ function readOne(path: string): Settings {
103
+ try { return sanitize(JSON.parse(readFileSync(path, "utf8"))); } catch { return {}; }
104
+ }
105
+
106
+ /** ONE settings file, sanitized and UNGATED — what the file says, for `rovecode trust show` (a person deciding to
107
+ * approve a file needs to see what it would do) and for a reader that must know WHICH file said something */
108
+ export function readSettingsFile(path: string): Settings { return readOne(path); }
109
+
110
+ /** the command-bearing keys an untrusted project file carries (`verify: false` excepted — a refusal is not a command) */
111
+ function commandKeysIn(s: Settings): CommandKey[] {
112
+ return COMMAND_KEYS.filter((k) => s[k] !== undefined && !(k === "verify" && s.verify === false));
113
+ }
114
+
115
+ export interface ScopedSettings {
116
+ user: Settings;
117
+ /** the project file with its command-bearing keys REMOVED when the file is not trusted on this machine */
118
+ project: Settings;
119
+ projectPath: string;
120
+ /** the command-bearing keys the project file carried and lost to the gate — empty when trusted or none */
121
+ dropped: CommandKey[];
122
+ }
123
+
124
+ /** the two scopes apart, the project layer already gated (the trust store is keyed by `projectPath`) */
125
+ export function loadSettingsScoped(cwd: string, home: string = rovecodeHome()): ScopedSettings {
126
+ const projectPath = settingsPath("project", cwd);
127
+ const raw = readOne(projectPath);
128
+ const carried = commandKeysIn(raw);
129
+ if (carried.length === 0 || isTrustedFile(home, projectPath)) return { user: readOne(settingsPath("user", cwd)), project: raw, projectPath, dropped: [] };
130
+ const project: Settings = { ...raw };
131
+ for (const k of carried) delete project[k];
132
+ return { user: readOne(settingsPath("user", cwd)), project, projectPath, dropped: carried };
133
+ }
134
+
135
+ /** user then project, project winning key by key — a repo may pin the permission level while the thinking effort
136
+ * stays whatever you chose globally. The project's command-bearing keys (COMMAND_KEYS) are here ONLY when that file is
137
+ * trusted: every consumer — verify, lsp, notify — is safe by construction, none of them has to ask. */
138
+ export function loadSettings(cwd: string): Settings {
139
+ const s = loadSettingsScoped(cwd);
140
+ return { ...s.user, ...s.project };
141
+ }
142
+
143
+ /** the one boot / doctor line when the gate dropped something from the project file; [] otherwise */
144
+ export function settingsTrustNotes(cwd: string, home: string = rovecodeHome()): string[] {
145
+ const s = loadSettingsScoped(cwd, home);
146
+ if (s.dropped.length === 0) return [];
147
+ const keys = s.dropped.join(", ");
148
+ return [untrustedFileNote(s.projectPath, `its ${keys} key${s.dropped.length === 1 ? " is" : "s are"} ignored (a repo file would decide what we run)`)];
149
+ }
150
+
151
+ /** Merge one key into a scope's file, leaving the rest of it (and the other scope) alone. */
152
+ export function saveSetting<K extends keyof Settings>(key: K, value: Settings[K], scope: SettingsScope, cwd: string): string {
153
+ const path = settingsPath(scope, cwd);
154
+ const next: Settings = { ...readOne(path), [key]: value };
155
+ mkdirSync(dirname(path), { recursive: true });
156
+ writeFileSync(path, JSON.stringify(next, null, 2) + "\n");
157
+ return path;
158
+ }
159
+
160
+ /** The level this run starts at. `flag` is the CLI's answer (undefined when it said nothing);
161
+ * the env var is next; then the files; then the deny-default "ask". */
162
+ export function resolvePermission(cwd: string, flag: PermissionLevel | undefined, env: { ROVECODE_PERMISSION?: string | undefined; ROVECODE_YOLO?: string | undefined; ROVECODE_ACCEPT_EDITS?: string | undefined }): PermissionLevel {
163
+ if (flag !== undefined) return flag;
164
+ const named = (env.ROVECODE_PERMISSION ?? "").trim().toLowerCase();
165
+ if (PERMISSIONS.includes(named)) return named as PermissionLevel;
166
+ // the older single-purpose switches keep working; auto wins because it is the wider of the two
167
+ if (env.ROVECODE_YOLO === "1") return "auto";
168
+ if (env.ROVECODE_ACCEPT_EDITS === "1") return "accept-edits";
169
+ return loadSettings(cwd).permission ?? "ask";
170
+ }