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,318 @@
1
+ /** Notifications for the interactive TUI: ONE decorator over the Renderer seam rings the terminal (BEL / OSC 9 / OSC 777)
2
+ * and/or spawns a desktop hook when a run ends or a card needs the user — and ONLY while the terminal is unfocused, because
3
+ * the whole value is telling someone who looked away; a bell on every turn is a bell nobody hears. Both surfaces, one
4
+ * place: the renderers themselves no longer ring (their `ring()` moved here on 2026-09-07).
5
+ *
6
+ * Pattern source: openai/codex (Apache-2.0, pattern level; no code copied) — codex-rs/tui/src/tui.rs (the unfocused | always
7
+ * gate; terminal_focused starts true; a write error warns once and disables the backend for the session),
8
+ * codex-rs/tui/src/chatwidget/notifications.rs (the event set: turn complete / approval requested / elicitation),
9
+ * codex-rs/hooks/src/legacy_notify.rs (the `notify` argv hook: payload appended as the LAST argument, stdin/stdout/stderr
10
+ * nulled, never awaited; the agent-turn-complete payload shape — the approval / question payloads are rovecode's own).
11
+ * Deviations: codex disables focus reporting on Windows — Windows Terminal ≥ 1.14 reports focus through VT input, so the
12
+ * tracker is installed on every TTY; codex fires the hook regardless of focus — here the hook obeys the SAME gate, because a
13
+ * desktop toast while the person is looking at the terminal is the same noise wearing a different coat.
14
+ *
15
+ * Knobs (core/settings.ts; env beats file, project beats user, read directly at boot):
16
+ * bell (existing) true (default) | false — the on/off switch for terminal notifications
17
+ * notify auto (default) | bell | osc9 | osc777 — the method; auto = OSC 9 toast on Ghostty / iTerm2 / kitty /
18
+ * Warp / WezTerm, BEL everywhere else. ROVECODE_NOTIFY=off is the env spelling of `bell: false`.
19
+ * notify_when unfocused (default) | always ROVECODE_NOTIFY_WHEN
20
+ * notify_command argv — a JSON string array or whitespace-split words, NEVER a shell ROVECODE_NOTIFY_COMMAND
21
+ *
22
+ * notify_command's permission path (Berkay's 2026-09-04 trust decision applied): from the env or the USER file it is the
23
+ * person's own configuration; from the PROJECT file it is a repo-supplied command and applies only when that file is
24
+ * approved in the digest trust store (mcp/trust.ts — the same store as project mcp.json; any edit re-asks), else it is
25
+ * dropped with one note naming the key. The argv then goes through the same classification bash commands get (port #9:
26
+ * execpolicy forbidden / dangerous-command → the hook is off, with a note). It is spawned as argv with no shell, every
27
+ * stdio nulled, never awaited; the run's text reaches it ONLY as one JSON object in argv[last]. Not through the sandbox
28
+ * rung: the hook is a desktop-side program (notify-send, a toast exe) — under docker/wsl the toast would appear inside the
29
+ * container. Headless surfaces (`run`, `serve`, `acp`, `--plain`) never construct any of this. */
30
+
31
+ import { dangerousCommandMatch, defaultExecPolicy } from "../core/execpolicy.ts";
32
+ import { loadSettingsScoped, type ScopedSettings, type Settings } from "../core/settings.ts";
33
+ import { FocusTerminal } from "./focus-terminal.ts";
34
+ import { FOCUS_OFF, FOCUS_ON, FocusTracker, autoMethod, isArgvError, isTmux, message, parseArgv, sequenceFor, type Env, type NotifyDetail, type NotifyKind, type NotifyMethod } from "./notify-seq.ts";
35
+ import type { AssistantView, Renderer } from "./renderer.ts";
36
+ import { pickRenderer, type RawStdin, type RawStdout } from "./sextant-io.ts";
37
+
38
+ export type NotifyWhen = "unfocused" | "always";
39
+ export const NOTIFY_METHODS = ["auto", "bell", "osc9", "osc777"] as const;
40
+ export type NotifyMethodSetting = (typeof NOTIFY_METHODS)[number];
41
+ export const NOTIFY_WHENS: readonly NotifyWhen[] = ["unfocused", "always"];
42
+
43
+ /** everything fire() needs, decided once at boot */
44
+ export interface NotifyConfig {
45
+ /** the terminal method; undefined = terminal notifications off (`bell: false` / ROVECODE_NOTIFY=off) */
46
+ method?: NotifyMethod;
47
+ when: NotifyWhen;
48
+ /** the hook argv, validated and classified; undefined = no hook */
49
+ hook?: string[];
50
+ /** where the hook came from, for the notes (`ROVECODE_NOTIFY_COMMAND` or a settings file path) */
51
+ hookSource?: string;
52
+ /** OSC sequences ride a DCS passthrough inside tmux */
53
+ tmux: boolean;
54
+ /** one-shot warn notes: what was dropped and why (shown once the surface is live) */
55
+ notes: string[];
56
+ }
57
+
58
+ export interface ResolveDeps {
59
+ /** default process.env */
60
+ env?: Env;
61
+ /** the two settings files, the project layer already gated (default: core/settings.ts loadSettingsScoped(cwd)) */
62
+ settings?: ScopedSettings;
63
+ }
64
+
65
+ const norm = (v: string | undefined): string => (v ?? "").trim().toLowerCase();
66
+
67
+ /** the knobs → one config. Never throws; every refusal is a note. */
68
+ export function resolveNotifyConfig(cwd: string, d: ResolveDeps = {}): NotifyConfig {
69
+ const env = d.env ?? process.env;
70
+ const scoped = d.settings ?? loadSettingsScoped(cwd);
71
+ const merged: Settings = { ...scoped.user, ...scoped.project };
72
+ const notes: string[] = [];
73
+
74
+ // the terminal method: ROVECODE_NOTIFY (off | auto | bell | osc9 | osc777) beats the files; `bell: false` is off
75
+ let method: NotifyMethodSetting | undefined;
76
+ const envMethod = norm(env.ROVECODE_NOTIFY);
77
+ if (envMethod === "off") method = undefined;
78
+ else if ((NOTIFY_METHODS as readonly string[]).includes(envMethod)) method = envMethod as NotifyMethodSetting;
79
+ else {
80
+ if (envMethod !== "") notes.push(`notify: unknown value "${envMethod}" in ROVECODE_NOTIFY — off | auto | bell | osc9 | osc777; using the settings files`);
81
+ method = merged.bell === false ? undefined : (merged.notify ?? "auto");
82
+ }
83
+ const resolved: NotifyMethod | undefined = method === "auto" ? autoMethod(env) : method;
84
+
85
+ // the gate: ROVECODE_NOTIFY_WHEN beats the files; unfocused unless told otherwise
86
+ let when: NotifyWhen = merged.notify_when ?? "unfocused";
87
+ const envWhen = norm(env.ROVECODE_NOTIFY_WHEN);
88
+ if ((NOTIFY_WHENS as readonly string[]).includes(envWhen)) when = envWhen as NotifyWhen;
89
+ else if (envWhen !== "") notes.push(`notify_when: unknown value "${envWhen}" in ROVECODE_NOTIFY_WHEN — unfocused | always; using ${when}`);
90
+
91
+ // the hook: env → the project file (trust-gated) → the user file
92
+ let raw: string | undefined, source: string | undefined;
93
+ const envCmd = env.ROVECODE_NOTIFY_COMMAND;
94
+ if (envCmd !== undefined && envCmd.trim() !== "") { raw = envCmd; source = "ROVECODE_NOTIFY_COMMAND"; }
95
+ else {
96
+ // the project layer arrives GATED (core/settings.ts COMMAND_KEYS): an untrusted file's notify_command is already gone
97
+ // and named in `dropped` — the same store and the same yes as hooks.ts, sandbox.json and mcp.json (`rovecode trust`)
98
+ if (scoped.project.notify_command !== undefined) { raw = scoped.project.notify_command; source = scoped.projectPath; }
99
+ else if (scoped.dropped.includes("notify_command")) notes.push(`notify_command: set by ${scoped.projectPath}, a repository file this machine has not approved — ignored (rovecode trust show · rovecode trust; in the TUI: /trust); set it in ~/.rovecode/settings.json or ROVECODE_NOTIFY_COMMAND to use your own`);
100
+ if (raw === undefined && scoped.user.notify_command !== undefined) { raw = scoped.user.notify_command; source = "~/.rovecode/settings.json"; }
101
+ }
102
+ let hook: string[] | undefined;
103
+ if (raw !== undefined) {
104
+ const argv = parseArgv(raw);
105
+ if (isArgvError(argv)) notes.push(`notify_command (${source}): ${argv.error} — the hook is off`);
106
+ else {
107
+ const danger = dangerousCommandMatch(argv);
108
+ const decision = defaultExecPolicy().check(argv).decision;
109
+ if (danger !== null) notes.push(`notify_command (${source}): "${argv.join(" ")}" is a dangerous command (${danger}) — the hook is off`);
110
+ else if (decision === "forbidden") notes.push(`notify_command (${source}): execpolicy forbids "${argv.join(" ")}" — the hook is off`);
111
+ else hook = argv;
112
+ }
113
+ }
114
+ return { ...(resolved !== undefined ? { method: resolved } : {}), when, ...(hook !== undefined ? { hook, hookSource: source } : {}), tmux: isTmux(env), notes };
115
+ }
116
+
117
+ /** the last completed assistant text kept for the preview / payload */
118
+ const PREVIEW_MAX = 400;
119
+
120
+ /** the JSON handed to `notify_command` as its LAST argument (codex legacy-notify shape for the turn; rovecode's own for the cards) */
121
+ export type NotifyPayload =
122
+ | { type: "agent-turn-complete"; cwd: string; "last-assistant-message": string | null }
123
+ | { type: "approval-requested"; cwd: string; tool: string; "args-preview": string }
124
+ | { type: "question-requested"; cwd: string; question: string };
125
+
126
+ export function payloadFor(kind: NotifyKind, d: NotifyDetail, cwd: string): NotifyPayload {
127
+ if (kind === "approval") return { type: "approval-requested", cwd, tool: d.tool ?? "", "args-preview": d.argsPreview ?? "" };
128
+ if (kind === "question") return { type: "question-requested", cwd, question: d.question ?? "" };
129
+ return { type: "agent-turn-complete", cwd, "last-assistant-message": d.lastText ?? null };
130
+ }
131
+
132
+ /** the Bun.spawn slice the default spawner needs (tests inject a recorder) */
133
+ export interface SpawnLike { (argv: string[], opts: { stdio: ["ignore", "ignore", "ignore"] }): { unref(): void } }
134
+ const bunSpawn: SpawnLike = (argv, opts) => Bun.spawn(argv, opts);
135
+
136
+ /** the default hook spawner: argv, no shell, every stdio nulled, `unref()` so a hung hook never pins the event loop at
137
+ * quit, never awaited */
138
+ export function defaultSpawner(spawnFn: SpawnLike = bunSpawn): (argv: string[]) => void {
139
+ return (argv) => { spawnFn(argv, { stdio: ["ignore", "ignore", "ignore"] }).unref(); };
140
+ }
141
+
142
+ export interface NotifierDeps {
143
+ config: NotifyConfig;
144
+ /** payload cwd (default process.cwd()) */
145
+ cwd?: string;
146
+ /** terminal sequences go here (default: nowhere — the composition root wires stdout when it is a TTY) */
147
+ write?: (s: string) => void;
148
+ /** the FocusTracker's flag (default: always focused = watched = silent under `unfocused`) */
149
+ focused?: () => boolean;
150
+ /** the hook spawner (default defaultSpawner()) */
151
+ spawn?: (argv: string[]) => void;
152
+ /** the one-shot warn notes (default: dropped) */
153
+ note?: (text: string, tone: "warn") => void;
154
+ }
155
+
156
+ const reason = (e: unknown): string => (e instanceof Error ? e.message : String(e));
157
+
158
+ export class Notifier {
159
+ readonly config: NotifyConfig;
160
+ private readonly cwd: string;
161
+ private readonly write: (s: string) => void;
162
+ private readonly focused: () => boolean;
163
+ private readonly spawn: (argv: string[]) => void;
164
+ private readonly note: (text: string, tone: "warn") => void;
165
+ private seqOff = false;
166
+ private hookOff = false;
167
+ private notesShown = false;
168
+ private decset = false;
169
+
170
+ constructor(d: NotifierDeps) {
171
+ this.config = d.config;
172
+ this.cwd = d.cwd ?? process.cwd();
173
+ this.write = d.write ?? (() => {});
174
+ this.focused = d.focused ?? (() => true);
175
+ this.spawn = d.spawn ?? defaultSpawner();
176
+ this.note = d.note ?? (() => {});
177
+ }
178
+
179
+ /** can anything fire — a terminal method or a hook */
180
+ active(): boolean { return this.config.method !== undefined || this.config.hook !== undefined; }
181
+
182
+ /** the config's notes, once, the first time the surface can show them */
183
+ private showNotes(): void {
184
+ if (this.notesShown) return;
185
+ this.notesShown = true;
186
+ for (const n of this.config.notes) this.note(n, "warn");
187
+ }
188
+
189
+ /** DECSET 1004 — written by withNotifications right after the surface's start writes; true when it was written */
190
+ focusOn(): boolean {
191
+ this.showNotes();
192
+ if (!this.active()) return false;
193
+ this.decset = true;
194
+ this.emit(FOCUS_ON);
195
+ return true;
196
+ }
197
+ /** DECRST 1004 — right before the surface's stop writes; a no-op unless a focusOn() wrote (never an unpaired `?1004l`) */
198
+ focusOff(): void {
199
+ if (!this.decset) return;
200
+ this.decset = false;
201
+ this.emit(FOCUS_OFF);
202
+ }
203
+
204
+ /** one trigger → the sequence and/or the hook, ONLY when `when` is always or the terminal is unfocused. Never throws. */
205
+ fire(kind: NotifyKind, d: NotifyDetail): void {
206
+ this.showNotes();
207
+ if (!(this.config.when === "always" || !this.focused())) return; // the person is watching: nothing to tell them
208
+ const { method, hook } = this.config;
209
+ if (method !== undefined) this.emit(sequenceFor(method, message(kind, d), this.config.tmux));
210
+ if (hook !== undefined && !this.hookOff) this.runHook(hook, payloadFor(kind, d, this.cwd));
211
+ }
212
+
213
+ private emit(seq: string): void {
214
+ if (this.seqOff) return;
215
+ try { this.write(seq); } catch (e) {
216
+ this.seqOff = true;
217
+ this.note(`notify: the terminal write failed (${reason(e)}) — terminal notifications are off for this session`, "warn");
218
+ }
219
+ }
220
+
221
+ private runHook(argv: string[], payload: NotifyPayload): void {
222
+ try { this.spawn([...argv, JSON.stringify(payload)]); } catch (e) {
223
+ this.hookOff = true;
224
+ this.note(`notify_command: could not start ${argv[0]} (${reason(e)}) — the hook is off for this session`, "warn");
225
+ }
226
+ }
227
+ }
228
+
229
+ type Overridden = Pick<Renderer, "start" | "stop" | "askApproval" | "askQuestion" | "setBusy" | "beginAssistant">;
230
+
231
+ /** The decorator: a Proxy over the inner renderer that adds NO member the inner lacks (`"onEvent" in wrapped` follows the
232
+ * inner; `instanceof` follows the inner's prototype) and forwards every other member bound to the inner. Overridden: start
233
+ * (DECSET 1004 after the surface's own start writes, when anything can fire) · stop (DECRST 1004 before its stop writes) ·
234
+ * askApproval / askQuestion (a trigger each, from ANY caller) · setBusy (run end = a label-less `false` after a `true` —
235
+ * never a labelled outcome, never a second `false`) · beginAssistant (keeps the last completed text for the preview / payload). */
236
+ export function withNotifications<R extends Renderer>(inner: R, n: Notifier): R {
237
+ let busy = false, started = false, decset = false;
238
+ let generation = 0;
239
+ let lastText: string | undefined;
240
+ const over: Overridden = {
241
+ start(hooks, options) {
242
+ const epoch = generation;
243
+ const starting = inner.start(hooks, options);
244
+ const activated = () => { if (epoch === generation && !started) { started = true; decset = n.focusOn(); } };
245
+ if (starting) return starting.then(activated);
246
+ activated();
247
+ },
248
+ stop() {
249
+ generation++; // a prepared renderer resolving after cancellation must not re-enable focus reporting
250
+ if (decset) { decset = false; n.focusOff(); }
251
+ started = false; // after leave nothing rings: a run's finally racing the user's exit has no terminal to reach
252
+ inner.stop();
253
+ },
254
+ askApproval(tool, argsPreview, detail) {
255
+ if (started) n.fire("approval", { tool, argsPreview });
256
+ return inner.askApproval(tool, argsPreview, detail);
257
+ },
258
+ askQuestion(q, signal) {
259
+ if (started) n.fire("question", { question: q.question });
260
+ return inner.askQuestion(q, signal);
261
+ },
262
+ setBusy(b, label) {
263
+ inner.setBusy(b, label);
264
+ if (b) { busy = true; lastText = undefined; return; }
265
+ const ended = busy && label === undefined;
266
+ busy = false;
267
+ if (ended && started) n.fire("run_end", { ...(lastText !== undefined ? { lastText } : {}) }); // a surface that never started has no terminal to hear it
268
+ },
269
+ beginAssistant(): AssistantView {
270
+ const v = inner.beginAssistant();
271
+ let buf = "";
272
+ return {
273
+ append(delta) { v.append(delta); if (buf.length < PREVIEW_MAX) buf = (buf + delta).slice(0, PREVIEW_MAX); },
274
+ done() { v.done(); lastText = buf; },
275
+ };
276
+ },
277
+ };
278
+ return new Proxy(inner, {
279
+ get(target, prop) {
280
+ if (typeof prop === "string" && Object.hasOwn(over, prop)) return over[prop as keyof Overridden];
281
+ const v = Reflect.get(target, prop, target);
282
+ return typeof v === "function" ? (v as (...a: unknown[]) => unknown).bind(target) : v;
283
+ },
284
+ });
285
+ }
286
+
287
+ /** main.ts's ONE composition root for the interactive TUI: the surface pickRenderer would have chosen (sextant on a capable
288
+ * TTY, else the classic PiTuiRenderer with `cwd` — app.ts parity), its raw-input path carrying a FocusTracker on every TTY
289
+ * (classic: FocusTerminal around ProcessTerminal; sextant: the ProcessIO option), wrapped in withNotifications over a
290
+ * Notifier that writes to `stdout` only when it is a TTY. */
291
+ export function interactiveRenderer(cli: { classic: boolean; pet?: string }, env: Env = process.env, stdout: RawStdout = process.stdout, stdin: RawStdin = process.stdin, cwd: string = process.cwd()): Renderer {
292
+ const tty = stdout.isTTY === true;
293
+ const tracker = tty ? new FocusTracker() : undefined;
294
+ let inner: Renderer | undefined = pickRenderer(cli, env, stdout, { stdin, ...(tracker ? { focus: tracker } : {}) });
295
+ if (!inner) {
296
+ // The classic surface is optional: do not load its editor/markdown graph into a sextant session.
297
+ const { PiTuiRenderer } = require("./pi-renderer.ts") as typeof import("./pi-renderer.ts");
298
+ const { ProcessTerminal } = require("../../vendor/pi-tui/src/terminal.ts") as typeof import("../../vendor/pi-tui/src/terminal.ts");
299
+ inner = new PiTuiRenderer({ cwd, terminal: tracker ? new FocusTerminal(new ProcessTerminal(), tracker) : new ProcessTerminal() });
300
+ }
301
+ // EOF on stdin (the pipe or wrapper that launched us went away). The classic surface listens for
302
+ // "data" only and the vendored terminal never sees an end, so a piped launch that loses its writer
303
+ // hangs forever — measured 60 s+ under a test timeout. The sextant side handles this in ProcessIO
304
+ // (sextant-io.ts, same ETX shape); the classic side cannot be reached through its own seams, so
305
+ // the fallback here is direct: exit the PROCESS, after restoring the terminal through the crash
306
+ // guard's own path. A TUI whose input is gone has nothing left to wait for, and its terminal
307
+ // state is the only thing that must not be lost on the way out. Skipped for a TTY — a TTY does not end.
308
+ if ((stdin as { isTTY?: boolean }).isTTY !== true) {
309
+ stdin.on?.("end", () => { try { process.exit(0); } catch { /* already going */ } });
310
+ }
311
+ const notifier = new Notifier({
312
+ config: resolveNotifyConfig(cwd, { env }), cwd,
313
+ write: tty ? (s) => { stdout.write(s); } : () => {},
314
+ focused: () => tracker?.focused ?? true,
315
+ note: (text, tone) => inner!.addSystemNote(text, tone),
316
+ });
317
+ return withNotifications(inner, notifier);
318
+ }
@@ -0,0 +1,97 @@
1
+ /** Overlay bodies for PiTuiRenderer: the port #24 approval diff card and the port #33 question
2
+ * card. Each is a pi-tui Component that routes keys to the SelectList (or Input) it wraps, so
3
+ * the renderer's overlay lifecycle (show → settle → hide → refocus editor) is shared and the
4
+ * wrapped list's bindings are untouched. Only pi-renderer.ts, this module and theme.ts may
5
+ * import from vendor/pi-tui. */
6
+
7
+ import {
8
+ type Component,
9
+ Input,
10
+ type SelectList,
11
+ truncateToWidth,
12
+ visibleWidth,
13
+ wrapTextWithAnsi,
14
+ } from "../../vendor/pi-tui/src/index.ts";
15
+ import { pal, st } from "./theme.ts";
16
+
17
+ /** One overlay row: leading space, clipped to width-2, padded so the right gutter stays clean. */
18
+ export function fitLine(s: string, width: number): string {
19
+ const t = truncateToWidth(s, width - 2, "…");
20
+ return ` ${t}${" ".repeat(Math.max(0, width - 1 - visibleWidth(t)))}`;
21
+ }
22
+
23
+ /** Port #24: body of an edit/write approval overlay — title, the bounded unified diff
24
+ * (+ green, - red, headers/@@ dim), a spacer, then the verdict list. Keys go straight
25
+ * to the list, so verdicts and bindings are identical to the plain approval overlay. */
26
+ const MORE_RE = /^… \+(\d+) more line/;
27
+ export class ApprovalCard implements Component {
28
+ constructor(
29
+ private readonly title: string,
30
+ private readonly diff: string[],
31
+ private readonly list: SelectList,
32
+ private readonly rows: () => number,
33
+ ) {}
34
+ handleInput(data: string): void { this.list.handleInput(data); }
35
+ invalidate(): void { this.list.invalidate(); }
36
+ render(width: number): string[] {
37
+ // physical bound: title, list and some transcript must stay visible. previewDiff already
38
+ // clipped logically — fold its marker's count into ours rather than stacking two markers.
39
+ const max = Math.max(4, this.rows() - 12);
40
+ let lines = this.diff;
41
+ if (lines.length > max) {
42
+ const tail = MORE_RE.exec(lines[lines.length - 1]!);
43
+ const hidden = lines.length - max + (tail ? Number(tail[1]) - 1 : 0);
44
+ lines = [...lines.slice(0, max), `… +${hidden} more line${hidden === 1 ? "" : "s"}`];
45
+ }
46
+ return [fitLine(pal.warn(this.title), width), ...lines.map((l, i) => fitLine(paintDiff(l, i), width)), " ".repeat(width), ...this.list.render(width)];
47
+ }
48
+ }
49
+
50
+ function paintDiff(line: string, idx: number): string {
51
+ if ((idx === 0 && line.startsWith("--- ")) || (idx === 1 && line.startsWith("+++ "))) return st.dim(line);
52
+ const c = line[0];
53
+ return c === "+" ? pal.ok(line) : c === "-" ? pal.err(line) : c === " " ? line : st.dim(line);
54
+ }
55
+
56
+ /** SelectList value of the "type an answer…" entry — can never collide with an option index. */
57
+ export const FREE_TEXT = "\u0000free-text";
58
+
59
+ /** SelectList value of the "skip this question" entry: the NON-destructive decline (resolves null →
60
+ * the tool reports "user declined to answer"), so a busy run — where Escape means "stop the run" —
61
+ * can still be left unanswered without killing it (port #33 critic LOW). Same escape idiom as
62
+ * FREE_TEXT: an option index can never look like this. */
63
+ export const SKIP_QUESTION = "\u0000skip-question";
64
+
65
+ /** Port #33: body of a question overlay — title, the wrapped question (bounded by the terminal),
66
+ * a spacer, then either the option list (options + the free-text entry + skip) or a one-line
67
+ * Input for a typed answer, each with a key hint. Keys route to whichever is active; the renderer
68
+ * wires the list/input callbacks (select, submit, escape) and flips `typing`. */
69
+ export class QuestionCard implements Component {
70
+ private typing: boolean;
71
+ readonly input = new Input();
72
+ constructor(
73
+ private readonly question: string,
74
+ private readonly list: SelectList,
75
+ private readonly optionCount: number,
76
+ private readonly freeText: boolean,
77
+ private readonly rows: () => number,
78
+ /** true while a run is in flight — decides what Escape means in the hint */
79
+ private readonly busy: () => boolean,
80
+ ) { this.typing = optionCount === 0; } // no options: straight to the input
81
+ get isTyping(): boolean { return this.typing; }
82
+ setTyping(on: boolean): void { this.typing = on; }
83
+ handleInput(data: string): void { if (this.typing) this.input.handleInput(data); else this.list.handleInput(data); }
84
+ invalidate(): void { this.list.invalidate(); this.input.invalidate(); }
85
+ render(width: number): string[] {
86
+ const bodyRows = this.typing ? 1 : this.optionCount + (this.freeText ? 1 : 0) + 1; // + the skip entry
87
+ // physical bound: title, body, hint and some transcript must stay visible
88
+ const max = Math.max(2, this.rows() - 9 - bodyRows);
89
+ let lines = wrapTextWithAnsi(this.question, width - 2);
90
+ if (lines.length > max) lines = [...lines.slice(0, max - 1), st.dim(`… +${lines.length - max + 1} more lines`)];
91
+ const esc = this.busy() ? "Esc stop the run" : "Esc skip";
92
+ const body = this.typing
93
+ ? [fitLine(this.input.render(width - 2)[0] ?? "", width), fitLine(st.dim(this.optionCount > 0 ? "Enter sends · Esc back to the options" : `Enter sends · ${esc}`), width)]
94
+ : [...this.list.render(width), fitLine(st.dim(`↑↓ choose · Enter answer · ${esc}`), width)];
95
+ return [fitLine(pal.warn("question"), width), ...lines.map((l) => fitLine(l, width)), " ".repeat(width), ...body];
96
+ }
97
+ }