rovecode 0.4.0 → 0.4.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.
package/package.json CHANGED
@@ -1,62 +1,62 @@
1
- {
2
- "name": "rovecode",
3
- "version": "0.4.0",
4
- "license": "AGPL-3.0-only",
5
- "author": "9Code Labs",
6
- "repository": {
7
- "type": "git",
8
- "url": "https://github.com/9Code-Labs/rovecode.git"
9
- },
10
- "homepage": "https://github.com/9Code-Labs/rovecode",
11
- "description": "Rovecode: a coding agent for the terminal. Research-derived harness on Bun/TypeScript with evidence-based ports from open-source harnesses (sextant TUI, MCP, ACP, headless server, repo-map, shadow-git checkpoints, execpolicy, model routers).",
12
- "type": "module",
13
- "module": "src/index.ts",
14
- "exports": {
15
- ".": "./src/index.ts",
16
- "./sdk": "./src/sdk/index.ts",
17
- "./package.json": "./package.json",
18
- "./*": "./*"
19
- },
20
- "bin": {
21
- "rovecode": "bin/rovecode.ts"
22
- },
23
- "files": [
24
- "bin",
25
- "src",
26
- "vendor",
27
- "tsconfig.json",
28
- "THIRD_PARTY_NOTICES.md"
29
- ],
30
- "engines": {
31
- "bun": ">=1.3.14"
32
- },
33
- "scripts": {
34
- "build": "bun run scripts/build.ts",
35
- "build:cli": "bun -e \"require('node:fs').rmSync('dist/cli', { recursive: true, force: true })\" && bun build --target=bun --splitting --sourcemap=external src/cli/main.ts --outdir dist/cli && bun run smoke:cli && bun run smoke:dist",
36
- "smoke:cli": "bun dist/cli/main.js --version",
37
- "smoke:dist": "bun dist/cli/main.js smoke-tui --sextant",
38
- "test": "bun test",
39
- "gauntlet": "bun src/cli/main.ts gauntlet",
40
- "typecheck": "tsc --noEmit",
41
- "check": "node scripts/build-model-index.mjs --check && tsc --noEmit && bun test",
42
- "smoke": "bun src/cli/main.ts smoke-tui --sextant",
43
- "publish:site": "bash scripts/publish-site-data.sh",
44
- "build:model-index": "node scripts/build-model-index.mjs"
45
- },
46
- "dependencies": {
47
- "@ast-grep/napi": "^0.45.3",
48
- "@modelcontextprotocol/sdk": "^1.30.0",
49
- "@zed-industries/agent-client-protocol": "^0.4.5",
50
- "diff": "^9.0.0",
51
- "get-east-asian-width": "1.6.0",
52
- "gpt-tokenizer": "^4.0.0",
53
- "marked": "18.0.11",
54
- "tokenlens": "^1.3.1"
55
- },
56
- "devDependencies": {
57
- "@opencode-ai/models": "^0.0.64",
58
- "@types/bun": "latest",
59
- "@xterm/headless": "6.0.0",
60
- "typescript": "^5.9.3"
61
- }
62
- }
1
+ {
2
+ "name": "rovecode",
3
+ "version": "0.4.2",
4
+ "license": "AGPL-3.0-only",
5
+ "author": "9Code Labs",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/9Code-Labs/rovecode.git"
9
+ },
10
+ "homepage": "https://github.com/9Code-Labs/rovecode",
11
+ "description": "Rovecode: a coding agent for the terminal. Research-derived harness on Bun/TypeScript with evidence-based ports from open-source harnesses (sextant TUI, MCP, ACP, headless server, repo-map, shadow-git checkpoints, execpolicy, model routers).",
12
+ "type": "module",
13
+ "module": "src/index.ts",
14
+ "exports": {
15
+ ".": "./src/index.ts",
16
+ "./sdk": "./src/sdk/index.ts",
17
+ "./package.json": "./package.json",
18
+ "./*": "./*"
19
+ },
20
+ "bin": {
21
+ "rovecode": "bin/rovecode.ts"
22
+ },
23
+ "files": [
24
+ "bin",
25
+ "src",
26
+ "vendor",
27
+ "tsconfig.json",
28
+ "THIRD_PARTY_NOTICES.md"
29
+ ],
30
+ "engines": {
31
+ "bun": ">=1.3.14"
32
+ },
33
+ "scripts": {
34
+ "build": "bun run scripts/build.ts",
35
+ "build:cli": "bun -e \"require('node:fs').rmSync('dist/cli', { recursive: true, force: true })\" && bun build --target=bun --splitting --sourcemap=external src/cli/main.ts --outdir dist/cli && bun run smoke:cli && bun run smoke:dist",
36
+ "smoke:cli": "bun dist/cli/main.js --version",
37
+ "smoke:dist": "bun dist/cli/main.js smoke-tui --sextant",
38
+ "test": "bun test",
39
+ "gauntlet": "bun src/cli/main.ts gauntlet",
40
+ "typecheck": "tsc --noEmit",
41
+ "check": "node scripts/build-model-index.mjs --check && tsc --noEmit && bun test",
42
+ "smoke": "bun src/cli/main.ts smoke-tui --sextant",
43
+ "publish:site": "bash scripts/publish-site-data.sh",
44
+ "build:model-index": "node scripts/build-model-index.mjs"
45
+ },
46
+ "dependencies": {
47
+ "@ast-grep/napi": "^0.45.3",
48
+ "@modelcontextprotocol/sdk": "^1.30.0",
49
+ "@zed-industries/agent-client-protocol": "^0.4.5",
50
+ "diff": "^9.0.0",
51
+ "get-east-asian-width": "1.6.0",
52
+ "gpt-tokenizer": "^4.0.0",
53
+ "marked": "18.0.11",
54
+ "tokenlens": "^1.3.1"
55
+ },
56
+ "devDependencies": {
57
+ "@opencode-ai/models": "^0.0.64",
58
+ "@types/bun": "latest",
59
+ "@xterm/headless": "6.0.0",
60
+ "typescript": "^5.9.3"
61
+ }
62
+ }
@@ -0,0 +1,74 @@
1
+ /** The boot gate (#46): the cockpit stays in its reveal animation until every panel the layout asked
2
+ * for has painted CLEAN at least once — then `ready` flips and the surface accepts submits. A panel
3
+ * that keeps faulting can never lock the UI: the cap forces readiness and the toast already names the
4
+ * fault (frame.ts safe()). Future surfaces/versions get the same discipline by constructing one of
5
+ * these and asking it, instead of re-rolling boot timing by hand.
6
+ *
7
+ * Wiring: the frame loop owns the gate → renderFrame's per-panel guard reports clean/fault into it →
8
+ * the loop holds FRAME_MS pacing and swallows submits until `ready`. */
9
+
10
+ import { REVEAL_STEP_MS } from "./frame.ts";
11
+
12
+ /** panels the gate knows about; the loop re-declares the live set every frame (layout-dependent) */
13
+ export const BOOT_PANELS = ["files", "code", "messages", "plan", "usage", "pet"] as const;
14
+
15
+ /** the animation itself: six reveal steps at 90 ms + a beat to settle */
16
+ export const BOOT_ANIM_MS = REVEAL_STEP_MS * 6;
17
+
18
+ /** a faulting panel may delay readiness at most this long — then the gate opens anyway */
19
+ export const BOOT_CAP_MS = 3000;
20
+
21
+ export interface BootGate {
22
+ /** true once every live panel painted clean (and the animation finished) or the cap elapsed */
23
+ readonly ready: boolean;
24
+ /** 0..1 — animation progress for loaders; faults do not stall it (the cap guarantees 1) */
25
+ readonly progress: number;
26
+ /** names of live panels that have not painted clean yet */
27
+ readonly pending: readonly string[];
28
+ }
29
+
30
+ export interface Boot {
31
+ readonly gate: BootGate;
32
+ /** frame loop → gate: the clock, and which panels this layout actually shows */
33
+ tick(now: number, livePanels: readonly string[]): void;
34
+ /** renderFrame safe() → gate: a panel painted clean / threw */
35
+ clean(panel: string): void;
36
+ fault(panel: string): void;
37
+ }
38
+
39
+ export function createBoot(bootAt: number): Boot {
40
+ const cleanPanels = new Set<string>();
41
+ const faulted = new Set<string>();
42
+ let live: readonly string[] = [...BOOT_PANELS];
43
+ let now = bootAt;
44
+
45
+ const gate: BootGate = {
46
+ get ready(): boolean {
47
+ const animDone = now - bootAt >= BOOT_ANIM_MS;
48
+ if (now - bootAt >= BOOT_CAP_MS) return true;
49
+ if (!animDone) return false;
50
+ return live.every((p) => cleanPanels.has(p) || faulted.has(p));
51
+ },
52
+ get progress(): number {
53
+ return Math.min(1, (now - bootAt) / BOOT_ANIM_MS);
54
+ },
55
+ get pending(): readonly string[] {
56
+ return live.filter((p) => !cleanPanels.has(p) && !faulted.has(p));
57
+ },
58
+ };
59
+
60
+ return {
61
+ gate,
62
+ tick(n: number, livePanels: readonly string[]): void {
63
+ now = n;
64
+ live = livePanels;
65
+ },
66
+ clean(panel: string): void {
67
+ cleanPanels.add(panel);
68
+ faulted.delete(panel);
69
+ },
70
+ fault(panel: string): void {
71
+ faulted.add(panel); // faults count toward readiness — the cap + toast carry the story
72
+ },
73
+ };
74
+ }
@@ -9,7 +9,7 @@ import { GridScreen } from "./grid.ts";
9
9
  import { drawFiles, drawFrame, drawToasts } from "./draw-frame.ts";
10
10
  import { drawPlan, drawUsage } from "./draw-plan.ts";
11
11
  import { panel } from "./layout.ts";
12
- import { pruneToasts } from "./model.ts";
12
+ import { pruneToasts, pushToast } from "./model.ts";
13
13
  import type { DumpFrame, Layout, LayoutOptions, Rect, ScreenLike, SextantState, Theme } from "./types.ts";
14
14
  import { mainPage } from "./draw-tabs.ts";
15
15
 
@@ -22,6 +22,9 @@ export interface FrameDeps {
22
22
  /** overrides per panel; a missing painter draws an empty titled box */
23
23
  painters?: Partial<Painters>;
24
24
  layoutOpts?: LayoutOptions;
25
+ /** #46 boot gate: the per-panel guard reports clean/fault into it so the surface can hold its
26
+ * reveal until every live panel has painted once. Omit = no gating (tests, dumps). */
27
+ boot?: import("./boot.ts").Boot;
25
28
  }
26
29
 
27
30
  /** boot reveal (app.js render()): panels appear in 90 ms steps after bootAt */
@@ -73,20 +76,46 @@ export function renderFrame(scr: ScreenLike, s: SextantState, theme: Theme, now:
73
76
  const L = (deps.layout ?? layoutFallback)(scr.w, scr.h, deps.layoutOpts ?? { pet: true });
74
77
  pruneToasts(s, now);
75
78
  const age = now - s.bootAt;
76
- P.frame(scr, L.frame, s, theme, now);
77
- if (L.files && age >= 0) P.files(scr, L.files, s, theme, now);
79
+ // A painter that throws must never take the rest of the frame down with it — a blank panel with a
80
+ // toast beats a half-drawn cockpit. The first failure per panel is recorded on the state so the
81
+ // loop can surface it once instead of spamming every frame.
82
+ // the panels this layout actually shows — the gate waits on exactly these, no more
83
+ deps.boot?.tick(now, [
84
+ ...(L.files ? ["files"] : []),
85
+ "code",
86
+ "messages",
87
+ ...(L.plan ? ["plan"] : []),
88
+ ...(L.usage ? ["usage"] : []),
89
+ ...(L.pet ? ["pet"] : []),
90
+ ]);
91
+ const safe = (name: keyof Painters, fn: () => void): void => {
92
+ try {
93
+ fn();
94
+ if (s.painterError === name || s.painterError?.startsWith(name + ":")) s.painterError = undefined;
95
+ deps.boot?.clean(name);
96
+ } catch (e) {
97
+ const msg = `${name}: ${e instanceof Error ? e.message : String(e)}`;
98
+ deps.boot?.fault(name);
99
+ if (s.painterError !== msg) {
100
+ s.painterError = msg;
101
+ pushToast(s, `panel fault — ${msg}`, now, "error");
102
+ }
103
+ }
104
+ };
105
+ safe("frame", () => P.frame(scr, L.frame, s, theme, now));
106
+ if (L.files && age >= 0) safe("files", () => P.files(scr, L.files!, s, theme, now));
78
107
  // paging (draw-tabs.ts): on a narrow terminal the main slot shows the panel `s.page` names when
79
108
  // the layout hid it; the tab strip itself is painted by the frame loop after the panels
80
109
  const main = mainPage(L, s);
81
110
  if (age >= REVEAL_STEP_MS) {
82
- if (main === "files") P.files(scr, L.code, s, theme, now);
83
- else if (main === "plan") P.plan(scr, L.code, s, theme, now);
84
- else P.code(scr, L.code, s, theme, now);
111
+ if (main === "files") safe("files", () => P.files(scr, L.code, s, theme, now));
112
+ else if (main === "plan") safe("plan", () => P.plan(scr, L.code, s, theme, now));
113
+ else safe("code", () => P.code(scr, L.code, s, theme, now));
85
114
  }
86
- if (age >= REVEAL_STEP_MS * 2) P.messages(scr, L.messages, s, theme, now);
87
- if (L.plan && age >= REVEAL_STEP_MS * 3) P.plan(scr, L.plan, s, theme, now);
88
- if (L.usage && age >= REVEAL_STEP_MS * 4) P.usage(scr, L.usage, s, theme, now);
89
- if (L.pet && age >= REVEAL_STEP_MS * 5) P.pet(scr, L.pet, s, theme, now);
115
+ if (age >= REVEAL_STEP_MS * 2) safe("messages", () => P.messages(scr, L.messages, s, theme, now));
116
+ if (L.plan && age >= REVEAL_STEP_MS * 3) safe("plan", () => P.plan(scr, L.plan!, s, theme, now));
117
+ if (L.usage && age >= REVEAL_STEP_MS * 4) safe("usage", () => P.usage(scr, L.usage!, s, theme, now));
118
+ if (L.pet && age >= REVEAL_STEP_MS * 5) safe("pet", () => P.pet(scr, L.pet!, s, theme, now));
90
119
  drawToasts(scr, s, theme, now);
91
120
  return L;
92
121
  }
@@ -12,10 +12,11 @@ import { drawMessages, messagesScroll, promptCursor } from "./draw-messages.ts";
12
12
  import { drawPet, SWAY_MS } from "./draw-pet.ts";
13
13
  import { fuzzy, tokenize } from "./engine.ts";
14
14
  import { renderFrame } from "./frame.ts";
15
+ import { createBoot, type Boot } from "./boot.ts";
15
16
  import { parseInput } from "./input.ts";
16
17
  import { handleInput, type KeyCtx } from "./keys.ts";
17
18
  import { layout as layoutFn } from "./layout.ts";
18
- import { treeRows } from "./model.ts";
19
+ import { treeRows, pushToast } from "./model.ts";
19
20
  import { drawHelp, drawPalette, drawSuggest, suggestions } from "./overlays.ts";
20
21
  import { drawMarket } from "./draw-market.ts";
21
22
  import { drawContext } from "./draw-context.ts";
@@ -95,6 +96,11 @@ export class FrameLoop {
95
96
  private rows: TreeRow[] = [];
96
97
  /** frames painted (tests: "a render happened") */
97
98
  frames = 0;
99
+ /** #46: the cockpit opens for submits once every live panel painted clean (boot.ts) */
100
+ private boot: Boot | null = null;
101
+ private bootToastAt = 0;
102
+ /** Enter presses caught while the boot gate was closed; replayed in order when it opens */
103
+ private bootQueue: InputEvent[] = [];
98
104
 
99
105
  constructor(private readonly d: FrameLoopDeps) {
100
106
  const { cols, rows } = d.io.size();
@@ -191,6 +197,16 @@ export class FrameLoop {
191
197
  dispatch(ev: InputEvent): void {
192
198
  const now = this.d.clock();
193
199
  this.markDirty(); // and wake: the tick that follows this key (onTick: file reload) must come in one frame, not one sleep
200
+ // #46: while the boot reveal runs, Enter would submit into a half-loaded surface — QUEUE it and
201
+ // replay once the gate opens (typing still buffers into the editor live). Nothing is ever lost.
202
+ if (this.boot && !this.boot.gate.ready && ev.type === "key" && ev.name === "enter") {
203
+ this.bootQueue.push(ev);
204
+ if (now - this.bootToastAt > 2000) {
205
+ this.bootToastAt = now;
206
+ pushToast(this.d.state, "cockpit is loading — your input is queued", now, "info");
207
+ }
208
+ return;
209
+ }
194
210
  if (this.d.beforeInput?.(ev, now)) return;
195
211
  const kc = this.d.keyCtx();
196
212
  const ctx: KeyCtx = { layout: this.L, hooks: kc.hooks, local: kc.local, hits: this.hits, rows: this.rows, fuzzy, drag: this.drag };
@@ -202,6 +218,8 @@ export class FrameLoop {
202
218
  * storms and effects, the boot reveal. These hold the loop at FRAME_MS. */
203
219
  moving(now: number): boolean {
204
220
  const s = this.d.state, P = this.d.pet.state;
221
+ // #46: the gate holds the loop hot until the surface is truly ready (not just REVEAL_MS elapsed)
222
+ if (this.boot && !this.boot.gate.ready) return true;
205
223
  return s.running || s.card !== null || s.files.touched.size > 0 || now - s.bootAt < REVEAL_MS
206
224
  || now < P.stormUntil || P.fx.some((f) => f.until > now);
207
225
  }
@@ -239,17 +257,36 @@ export class FrameLoop {
239
257
  return Math.min(IDLE_TICK_MS, Math.max(FRAME_MS, this.ambientDue - now));
240
258
  }
241
259
 
242
- /** paint one frame: panels (renderFrame) → suggestion box → palette → help → scroll write-backs → cursor → flush */
260
+ /** paint one frame: panels (renderFrame) → suggestion box → palette → help → scroll write-backs → cursor → flush.
261
+ * The whole body is guarded: a fault anywhere (huge home-dir file tree, odd git state, a painter
262
+ * edge case) must surface as a toast and leave the LAST good frame on screen — never a blank cockpit. */
243
263
  render(now: number): void {
264
+ try {
265
+ this.renderInner(now);
266
+ this.lastFrameError = undefined;
267
+ } catch (e) {
268
+ const msg = e instanceof Error ? e.message : String(e);
269
+ if (this.lastFrameError !== msg) {
270
+ this.lastFrameError = msg;
271
+ try { pushToast(this.d.state, `frame fault — ${msg}`, now, "error"); } catch { /* state itself is sick */ }
272
+ }
273
+ }
274
+ }
275
+
276
+ private lastFrameError: string | undefined;
277
+
278
+ private renderInner(now: number): void {
244
279
  const scr = this.screen;
245
280
  if (!scr) return;
246
281
  const s = this.d.state, theme = this.d.theme(), pet = this.d.pet;
247
282
  scr.begin(theme.bg);
248
283
  this.rows = treeRows(s, now); // the same (version, now) key drawFiles uses → one build per frame (model.ts cache)
249
284
  const hits: HitZone[] = [];
285
+ if (!this.boot) this.boot = createBoot(s.bootAt);
250
286
  const L = renderFrame(scr, s, theme, now, {
251
287
  layout: layoutFn,
252
288
  layoutOpts: this.layoutOpts(),
289
+ boot: this.boot,
253
290
  painters: {
254
291
  code: (g, r, st, th, t) => drawCode(g, r, st, th, t, { tokenize }),
255
292
  messages: drawMessages,
@@ -257,6 +294,12 @@ export class FrameLoop {
257
294
  },
258
295
  });
259
296
  this.L = L;
297
+ // the gate just opened → flush queued submits through the normal path
298
+ if (this.boot.gate.ready && this.bootQueue.length > 0) {
299
+ const queued = this.bootQueue;
300
+ this.bootQueue = [];
301
+ for (const ev of queued) this.dispatch(ev);
302
+ }
260
303
  if (L.pet) hits.push({ rect: L.pet, onClick: () => pet.poke(this.d.clock()) });
261
304
  // the frame's border rows (frame-hits.ts): unread badge → notices, theme name → next theme, effort → /effort
262
305
  for (const z of frameHits(L.frame, s, theme, now)) hits.push(z);
@@ -388,6 +388,9 @@ export interface SextantState {
388
388
  context: ContextState | null;
389
389
  help: boolean;
390
390
  toasts: Toast[];
391
+ /** last painter failure seen by renderFrame's per-panel guard (frame.ts); surfaced as a toast by
392
+ * the frame loop, cleared on the next clean frame of that panel. Undefined = all panels clean. */
393
+ painterError?: string;
391
394
  /** the notification history behind the toasts (Notice); newest last */
392
395
  notices: Notice[];
393
396
  /** images staged for the next user message (names), shown as chips above the prompt; synced from