@addai/node 0.4.0 → 0.5.1

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/dist/cli.js CHANGED
@@ -20,7 +20,6 @@ const lockfile_1 = require("./lockfile");
20
20
  const index_1 = require("./index");
21
21
  const store_1 = require("./store");
22
22
  const supabase_client_1 = require("./supabase-client");
23
- const harnesses_1 = require("./tui/harnesses");
24
23
  const run_1 = require("./tui/run");
25
24
  const args = process.argv.slice(2);
26
25
  function token() {
@@ -71,7 +70,7 @@ async function main() {
71
70
  // could never come back from a crash without someone deleting the file
72
71
  // by hand. Same liveness check acquireLockfile() uses.
73
72
  const existing = (0, lockfile_1.readLockfile)();
74
- if (existing && (0, lockfile_1.processAlive)(existing.pid) && tui) {
73
+ if ((0, lockfile_1.lockfileAlive)(existing) && existing && tui) {
75
74
  await (0, run_1.runDashboard)({
76
75
  viewerMode: true,
77
76
  pid: existing.pid,
@@ -98,7 +97,7 @@ async function main() {
98
97
  }
99
98
  switch (cmd) {
100
99
  case 'harnesses':
101
- await (0, harnesses_1.runHarnessesTui)();
100
+ await (0, run_1.runHarnessesTui)();
102
101
  return;
103
102
  case 'unpair':
104
103
  await cmdUnpair();
@@ -3,28 +3,68 @@ export interface LockfileBody {
3
3
  port: number;
4
4
  version: string;
5
5
  startedAt: number;
6
+ /** Absolute path of the script this daemon is running. Recorded so a later
7
+ * `ainode` can identify the process exactly, however it was invoked. */
8
+ script?: string;
6
9
  }
10
+ /** The script this process is running — what ends up in the lockfile. */
11
+ export declare function entryScript(): string;
7
12
  /**
8
13
  * Does this command line belong to one of our daemons?
9
14
  *
10
15
  * Kept deliberately broad across the +Ai Node rename: a box mid-roll may
11
16
  * still be running a pre-rename daemon, and treating it as an unrelated
12
17
  * process would let a second daemon start alongside it.
18
+ *
19
+ * This is now a FALLBACK, used only for lockfiles written before we started
20
+ * recording the script path. On its own it is not a sound test: a daemon
21
+ * started from a checkout as `node dist/cli.js` matches none of these names,
22
+ * and calling that live daemon stale is exactly how two of them ended up
23
+ * heartbeating for the same node.
13
24
  */
14
25
  export declare function isOwnDaemonCmdline(cmdline: string): boolean;
26
+ /**
27
+ * Is `cmdline` running `script`?
28
+ *
29
+ * Compares the full path first, then the last two segments, so a daemon
30
+ * reached through a symlink or a realpath'd temp dir still identifies as
31
+ * itself. Exported for tests.
32
+ */
33
+ export declare function cmdlineRunsScript(cmdline: string, script: string): boolean;
34
+ /** What the lockfile claims, for `processAlive` to check the pid against. */
35
+ export interface ProcessIdentity {
36
+ script?: string;
37
+ startedAt?: number;
38
+ }
15
39
  /**
16
40
  * Is the pid in the lockfile a live daemon of ours?
17
41
  *
18
42
  * Exported because the CLI's read-only "viewer" path needs the same answer
19
43
  * acquireLockfile() needs: a lockfile left behind by a crashed daemon must
20
- * not be mistaken for a running one.
44
+ * not be mistaken for a running one, and a live one must never be mistaken
45
+ * for stale.
46
+ *
47
+ * The order matters. We ask, in turn:
48
+ *
49
+ * 1. Can we signal it at all? ESRCH means dead, and that's the end of it.
50
+ * 2. Is it running the exact script the lockfile recorded? This is the
51
+ * sound test — it does not care whether the daemon was started as
52
+ * `ainode`, `npx @addai/node` or `node dist/cli.js`.
53
+ * 3. Failing that (old lockfile, no script recorded), does the command line
54
+ * carry one of the names we've shipped under?
55
+ * 4. Failing that, did the process start when the lockfile says it did?
56
+ * A recycled pid will have started much later.
57
+ *
58
+ * Only when all of those fail do we call the lockfile stale.
21
59
  */
22
- export declare function processAlive(pid: number): boolean;
60
+ export declare function processAlive(pid: number, expect?: ProcessIdentity): boolean;
61
+ /** `processAlive` for a lockfile body — the form every caller actually wants. */
62
+ export declare function lockfileAlive(body: LockfileBody | null): boolean;
23
63
  export declare function readLockfile(): LockfileBody | null;
24
64
  /**
25
65
  * Throw if another runtime is already running. Returns the lockfile body
26
66
  * we wrote on success. The caller is responsible for calling
27
67
  * `releaseLockfile()` on clean shutdown.
28
68
  */
29
- export declare function acquireLockfile(body: Omit<LockfileBody, 'pid'>): LockfileBody;
69
+ export declare function acquireLockfile(body: Omit<LockfileBody, 'pid' | 'script'>): LockfileBody;
30
70
  export declare function releaseLockfile(): void;
package/dist/lockfile.js CHANGED
@@ -1,7 +1,8 @@
1
1
  "use strict";
2
2
  // Tiny lockfile at `~/.ainode/runtime.json`. Written on start
3
- // (with pid + port + version), polled by the CodeFlows desktop to decide
4
- // whether the runtime is alive, and unlinked on a clean exit.
3
+ // (with pid + port + version + the script that is running), polled by the
4
+ // CodeFlows desktop to decide whether the runtime is alive, and unlinked on a
5
+ // clean exit.
5
6
  //
6
7
  // We trust pid checks via `process.kill(pid, 0)` to detect stale lockfiles
7
8
  // from previous crashes — Node throws if the pid doesn't belong to a
@@ -40,8 +41,11 @@ var __importStar = (this && this.__importStar) || (function () {
40
41
  };
41
42
  })();
42
43
  Object.defineProperty(exports, "__esModule", { value: true });
44
+ exports.entryScript = entryScript;
43
45
  exports.isOwnDaemonCmdline = isOwnDaemonCmdline;
46
+ exports.cmdlineRunsScript = cmdlineRunsScript;
44
47
  exports.processAlive = processAlive;
48
+ exports.lockfileAlive = lockfileAlive;
45
49
  exports.readLockfile = readLockfile;
46
50
  exports.acquireLockfile = acquireLockfile;
47
51
  exports.releaseLockfile = releaseLockfile;
@@ -52,12 +56,22 @@ function ensureHome() {
52
56
  fs.mkdirSync(paths_1.RUNTIME_HOME, { recursive: true, mode: 0o700 });
53
57
  }
54
58
  }
59
+ /** The script this process is running — what ends up in the lockfile. */
60
+ function entryScript() {
61
+ return require.main?.filename ?? process.argv[1] ?? '';
62
+ }
55
63
  /**
56
64
  * Does this command line belong to one of our daemons?
57
65
  *
58
66
  * Kept deliberately broad across the +Ai Node rename: a box mid-roll may
59
67
  * still be running a pre-rename daemon, and treating it as an unrelated
60
68
  * process would let a second daemon start alongside it.
69
+ *
70
+ * This is now a FALLBACK, used only for lockfiles written before we started
71
+ * recording the script path. On its own it is not a sound test: a daemon
72
+ * started from a checkout as `node dist/cli.js` matches none of these names,
73
+ * and calling that live daemon stale is exactly how two of them ended up
74
+ * heartbeating for the same node.
61
75
  */
62
76
  function isOwnDaemonCmdline(cmdline) {
63
77
  const s = cmdline.toLowerCase();
@@ -74,14 +88,82 @@ function isOwnDaemonCmdline(cmdline) {
74
88
  || s.includes('entity-runtime')
75
89
  || s.includes('entities-runtime');
76
90
  }
91
+ /** Normalise for comparison: lowercase, forward slashes. */
92
+ function normalisePath(p) {
93
+ return p.toLowerCase().replace(/\\/g, '/');
94
+ }
95
+ /**
96
+ * Is `cmdline` running `script`?
97
+ *
98
+ * Compares the full path first, then the last two segments, so a daemon
99
+ * reached through a symlink or a realpath'd temp dir still identifies as
100
+ * itself. Exported for tests.
101
+ */
102
+ function cmdlineRunsScript(cmdline, script) {
103
+ if (!script.trim() || !cmdline.trim())
104
+ return false;
105
+ const c = normalisePath(cmdline);
106
+ const s = normalisePath(script);
107
+ if (c.includes(s))
108
+ return true;
109
+ const tail = s.split('/').slice(-2).join('/');
110
+ return tail.length > 0 && c.includes(tail);
111
+ }
112
+ /** Run a probe for the pid, returning trimmed stdout or null if it failed. */
113
+ function probe(args, pid) {
114
+ try {
115
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
116
+ const { execFileSync } = require('child_process');
117
+ const out = process.platform === 'win32'
118
+ ? execFileSync('powershell.exe', [
119
+ '-NoProfile', '-NonInteractive', '-Command',
120
+ args.win.replace('$PID', String(Number(pid))),
121
+ ], { encoding: 'utf8', timeout: 5000, windowsHide: true })
122
+ : execFileSync('ps', args.unix.map(a => a.replace('$PID', String(Number(pid)))), {
123
+ encoding: 'utf8',
124
+ timeout: 2000,
125
+ });
126
+ return out.toString().trim();
127
+ }
128
+ catch {
129
+ return null;
130
+ }
131
+ }
132
+ /** When did this pid start, in epoch ms? Null if we can't tell. */
133
+ function processStartedAt(pid) {
134
+ const raw = probe({
135
+ unix: ['-p', '$PID', '-o', 'lstart='],
136
+ win: '(Get-CimInstance Win32_Process -Filter "ProcessId=$PID").CreationDate.ToString("o")',
137
+ }, pid);
138
+ if (!raw)
139
+ return null;
140
+ const ms = new Date(raw).getTime();
141
+ return Number.isFinite(ms) ? ms : null;
142
+ }
143
+ /** A process may legitimately start slightly before it writes its lockfile. */
144
+ const START_TIME_TOLERANCE_MS = 60_000;
77
145
  /**
78
146
  * Is the pid in the lockfile a live daemon of ours?
79
147
  *
80
148
  * Exported because the CLI's read-only "viewer" path needs the same answer
81
149
  * acquireLockfile() needs: a lockfile left behind by a crashed daemon must
82
- * not be mistaken for a running one.
150
+ * not be mistaken for a running one, and a live one must never be mistaken
151
+ * for stale.
152
+ *
153
+ * The order matters. We ask, in turn:
154
+ *
155
+ * 1. Can we signal it at all? ESRCH means dead, and that's the end of it.
156
+ * 2. Is it running the exact script the lockfile recorded? This is the
157
+ * sound test — it does not care whether the daemon was started as
158
+ * `ainode`, `npx @addai/node` or `node dist/cli.js`.
159
+ * 3. Failing that (old lockfile, no script recorded), does the command line
160
+ * carry one of the names we've shipped under?
161
+ * 4. Failing that, did the process start when the lockfile says it did?
162
+ * A recycled pid will have started much later.
163
+ *
164
+ * Only when all of those fail do we call the lockfile stale.
83
165
  */
84
- function processAlive(pid) {
166
+ function processAlive(pid, expect = {}) {
85
167
  let signalSucceeded;
86
168
  try {
87
169
  process.kill(pid, 0);
@@ -98,41 +180,35 @@ function processAlive(pid) {
98
180
  // a system process owned by another user. `process.kill(pid, 0)` then
99
181
  // returns EPERM (legitimately — that process IS alive, just not ours)
100
182
  // and the naive check would refuse to start the new daemon forever.
101
- // Verify the PID actually points at OUR daemon by checking its command
102
- // line for the expected binary name. If the cmdline doesn't include
103
- // 'entity-runtime' or 'entities-runtime', the lockfile is stale.
104
- try {
105
- // eslint-disable-next-line @typescript-eslint/no-require-imports
106
- const { execFileSync } = require('child_process');
107
- let out;
108
- if (process.platform === 'win32') {
109
- // No `ps` on Windows; the daemon runs as node.exe so tasklist's image
110
- // name can't identify it — read the full command line via CIM.
111
- out = execFileSync('powershell.exe', [
112
- '-NoProfile', '-NonInteractive', '-Command',
113
- `(Get-CimInstance Win32_Process -Filter "ProcessId=${Number(pid)}").CommandLine`,
114
- ], { encoding: 'utf8', timeout: 5000, windowsHide: true }).toString().toLowerCase();
115
- }
116
- else {
117
- out = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
118
- encoding: 'utf8',
119
- timeout: 2000,
120
- }).toString().toLowerCase();
121
- }
122
- // Both probes exit 0 with empty output if pid is dead — handle that too.
123
- if (!out.trim())
124
- return false;
125
- if (!isOwnDaemonCmdline(out)) {
126
- return false; // pid was recycled to an unrelated process
127
- }
128
- return true;
129
- }
130
- catch {
183
+ const cmdline = probe({
184
+ unix: ['-p', '$PID', '-o', 'command='],
185
+ win: '(Get-CimInstance Win32_Process -Filter "ProcessId=$PID").CommandLine',
186
+ }, pid);
187
+ if (cmdline === null) {
131
188
  // ps/powershell failed (timeout, weird platform). Fall back to the
132
- // original signal result — better to refuse start than to clobber a
133
- // real daemon.
189
+ // signal result — better to refuse start than to clobber a real daemon.
134
190
  return signalSucceeded;
135
191
  }
192
+ // Both probes exit 0 with empty output if the pid is dead.
193
+ if (!cmdline)
194
+ return false;
195
+ if (expect.script && cmdlineRunsScript(cmdline, expect.script))
196
+ return true;
197
+ if (isOwnDaemonCmdline(cmdline))
198
+ return true;
199
+ if (expect.startedAt) {
200
+ const started = processStartedAt(pid);
201
+ if (started !== null && Math.abs(started - expect.startedAt) <= START_TIME_TOLERANCE_MS) {
202
+ return true;
203
+ }
204
+ }
205
+ return false; // pid was recycled to an unrelated process
206
+ }
207
+ /** `processAlive` for a lockfile body — the form every caller actually wants. */
208
+ function lockfileAlive(body) {
209
+ if (!body)
210
+ return false;
211
+ return processAlive(body.pid, { script: body.script, startedAt: body.startedAt });
136
212
  }
137
213
  function readLockfile() {
138
214
  try {
@@ -154,10 +230,10 @@ function readLockfile() {
154
230
  function acquireLockfile(body) {
155
231
  ensureHome();
156
232
  const existing = readLockfile();
157
- if (existing && processAlive(existing.pid)) {
233
+ if (lockfileAlive(existing)) {
158
234
  throw new Error(`ainode is already running (pid ${existing.pid}, port ${existing.port}).`);
159
235
  }
160
- const final = { pid: process.pid, ...body };
236
+ const final = { pid: process.pid, script: entryScript(), ...body };
161
237
  fs.writeFileSync(paths_1.RUNTIME_LOCK_FILE, JSON.stringify(final, null, 2), { mode: 0o600 });
162
238
  return final;
163
239
  }
@@ -19,7 +19,16 @@ function buildCapturePayload(req, userText, assistantText, transcriptRef) {
19
19
  transcript_ref: transcriptRef,
20
20
  };
21
21
  }
22
- async function postCapture(baseUrl, serviceToken, payload, timeoutMs = 5000) {
22
+ // timeoutMs default is 15000 (not 5000): the entity-memory-capture edge fn
23
+ // synchronously summarizes each turn via an Anthropic Haiku call on its
24
+ // DEFAULT path, so its server-side latency is inherently ~3-5s (measured over
25
+ // 24h: p50 ~3.4s, p95 ~4.75s, p99 ~6.35s). A 5s abort clipped that long tail,
26
+ // surfacing "operation was aborted due to timeout" and dropping those turns'
27
+ // captures. postCapture is fire-and-forget (void, post-terminal — see
28
+ // session-runner notifyCaptureIfEnabled), so a longer timeout NEVER slows a
29
+ // run; it only lets slow/tail captures land. 15s clears observed p99 with 2x
30
+ // headroom for network/TLS.
31
+ async function postCapture(baseUrl, serviceToken, payload, timeoutMs = 15000) {
23
32
  try {
24
33
  const res = await fetch(`${baseUrl}/functions/v1/entity-memory-capture`, {
25
34
  method: 'POST',
package/dist/paths.d.ts CHANGED
@@ -3,6 +3,9 @@ export declare const RUNTIME_HOME: string;
3
3
  export declare const LEGACY_RUNTIME_HOME: string;
4
4
  export declare const RUNTIME_STATE_FILE: string;
5
5
  export declare const RUNTIME_LOCK_FILE: string;
6
+ /** Where the daemon's output lands: the file nohup/launchd redirect into,
7
+ * and the file the console appends to while it owns the terminal. */
8
+ export declare const RUNTIME_LOG_FILE: string;
6
9
  export declare const RUNTIME_SESSIONS_DIR: string;
7
10
  export declare const CLAUDE_HOME: string;
8
11
  export declare const CLAUDE_PROJECTS_DIR: string;
package/dist/paths.js CHANGED
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.RELAY_HOST = exports.CLAUDE_PROJECTS_DIR = exports.CLAUDE_HOME = exports.RUNTIME_SESSIONS_DIR = exports.RUNTIME_LOCK_FILE = exports.RUNTIME_STATE_FILE = exports.LEGACY_RUNTIME_HOME = exports.RUNTIME_HOME = void 0;
36
+ exports.RELAY_HOST = exports.CLAUDE_PROJECTS_DIR = exports.CLAUDE_HOME = exports.RUNTIME_SESSIONS_DIR = exports.RUNTIME_LOG_FILE = exports.RUNTIME_LOCK_FILE = exports.RUNTIME_STATE_FILE = exports.LEGACY_RUNTIME_HOME = exports.RUNTIME_HOME = void 0;
37
37
  // All on-disk paths the runtime uses.
38
38
  const os = __importStar(require("os"));
39
39
  const path = __importStar(require("path"));
@@ -42,6 +42,9 @@ exports.RUNTIME_HOME = path.join(os.homedir(), '.ainode');
42
42
  exports.LEGACY_RUNTIME_HOME = path.join(os.homedir(), '.entities-runtime');
43
43
  exports.RUNTIME_STATE_FILE = path.join(exports.RUNTIME_HOME, 'state.json');
44
44
  exports.RUNTIME_LOCK_FILE = path.join(exports.RUNTIME_HOME, 'runtime.json');
45
+ /** Where the daemon's output lands: the file nohup/launchd redirect into,
46
+ * and the file the console appends to while it owns the terminal. */
47
+ exports.RUNTIME_LOG_FILE = path.join(exports.RUNTIME_HOME, 'daemon.log');
45
48
  exports.RUNTIME_SESSIONS_DIR = path.join(exports.RUNTIME_HOME, 'sessions');
46
49
  exports.CLAUDE_HOME = path.join(os.homedir(), '.claude');
47
50
  exports.CLAUDE_PROJECTS_DIR = path.join(exports.CLAUDE_HOME, 'projects');
package/dist/tui/app.d.ts CHANGED
@@ -4,16 +4,29 @@ export interface Key {
4
4
  shift?: boolean;
5
5
  sequence?: string;
6
6
  }
7
+ /** One line of the `?` help screen. */
8
+ export interface KeyHint {
9
+ keys: string;
10
+ label: string;
11
+ }
7
12
  export interface Screen {
8
13
  id: string;
9
14
  title: string;
10
- render(width: number): string[];
15
+ /** Must return at most `height` lines. The app clamps as a backstop, but a
16
+ * screen that leans on the clamp loses its own footer to the cut. */
17
+ render(width: number, height: number): string[];
11
18
  onKey?(key: Key): void | Promise<void>;
12
19
  poll?(): Promise<void>;
13
20
  pollMs?(): number;
14
21
  /** True while the screen is consuming raw text (a filter prompt), so the
15
22
  * global q-to-go-back binding must not steal the keystroke. */
16
23
  capturesKeys?(): boolean;
24
+ /** Screen-specific bindings, listed by the `?` screen. */
25
+ keys?(): KeyHint[];
26
+ /** Animation tick, several times a second. Return true to ask for a
27
+ * repaint — a screen with nothing moving should return false so an idle
28
+ * node isn't repainting the terminal for no reason. */
29
+ tick?(n: number): boolean;
17
30
  }
18
31
  export interface AppHost {
19
32
  push(s: Screen): void;
@@ -25,11 +38,20 @@ export interface AppHost {
25
38
  redraw(): void;
26
39
  note(msg: string | null): void;
27
40
  }
41
+ export declare const GLOBAL_KEYS: KeyHint[];
28
42
  export declare function createApp(root: Screen, hooks?: {
29
43
  onQuit?: () => void;
30
44
  onRedraw?: () => void;
31
45
  }): AppHost & {
32
46
  handleKey(k: Key): Promise<void>;
33
- frame(width: number): string[];
47
+ frame(width: number, height: number): string[];
48
+ /** The screen due for a poll now, or null. Marks it polled. */
49
+ duePoll(now: number): Screen | null;
34
50
  lastError(): string | null;
51
+ helpOpen(): boolean;
35
52
  };
53
+ /** Screens build their footers with this so the grammar is identical
54
+ * everywhere: dim, two-space indent, key then label. */
55
+ export declare function footerHint(hints: KeyHint[]): string;
56
+ /** A screen heading: bold label with dim trailing detail. */
57
+ export declare function heading(label: string, detail?: string): string;
package/dist/tui/app.js CHANGED
@@ -7,20 +7,31 @@
7
7
  // The hard rule this file enforces: a screen may throw, and the daemon
8
8
  // must not care. Every screen call is fenced.
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.GLOBAL_KEYS = void 0;
10
11
  exports.createApp = createApp;
12
+ exports.footerHint = footerHint;
13
+ exports.heading = heading;
11
14
  const render_1 = require("./render");
15
+ exports.GLOBAL_KEYS = [
16
+ { keys: '?', label: 'this help' },
17
+ { keys: 'q', label: 'back — quit from the home screen' },
18
+ { keys: 'ctrl-c', label: 'quit immediately' },
19
+ ];
12
20
  function createApp(root, hooks = {}) {
13
- const stack = [root];
21
+ const stack = [{ screen: root, lastPoll: 0 }];
14
22
  let error = null;
15
23
  let noteMsg = null;
24
+ let help = false;
25
+ const top = () => stack[stack.length - 1];
16
26
  const host = {
17
- push(s) { stack.push(s); host.redraw(); },
27
+ push(s) { stack.push({ screen: s, lastPoll: 0 }); help = false; host.redraw(); },
18
28
  pop() { if (stack.length > 1) {
19
29
  stack.pop();
30
+ help = false;
20
31
  host.redraw();
21
32
  } },
22
- replace(s) { stack[stack.length - 1] = s; host.redraw(); },
23
- current: () => stack[stack.length - 1],
33
+ replace(s) { stack[stack.length - 1] = { screen: s, lastPoll: 0 }; host.redraw(); },
34
+ current: () => top().screen,
24
35
  depth: () => stack.length,
25
36
  quit() { hooks.onQuit?.(); },
26
37
  redraw() { hooks.onRedraw?.(); },
@@ -28,15 +39,32 @@ function createApp(root, hooks = {}) {
28
39
  };
29
40
  async function toScreen(k) {
30
41
  try {
31
- await host.current().onKey?.(k);
42
+ await top().screen.onKey?.(k);
32
43
  }
33
44
  catch (err) {
34
45
  error = err.message;
35
46
  host.redraw();
36
47
  }
37
48
  }
49
+ function helpLines(width) {
50
+ let own = [];
51
+ try {
52
+ own = top().screen.keys?.() ?? [];
53
+ }
54
+ catch {
55
+ own = [];
56
+ }
57
+ const rows = [...own, ...exports.GLOBAL_KEYS];
58
+ const gutter = Math.max(6, ...rows.map(r => r.keys.length));
59
+ return [
60
+ ...(0, render_1.panel)(`Keys ${(0, render_1.dim)('·')} ${top().screen.title}`, rows.map(r => `${(0, render_1.cyan)(r.keys.padEnd(gutter))} ${(0, render_1.dim)(r.label)}`), width),
61
+ '',
62
+ (0, render_1.dim)(' any key closes this'),
63
+ ];
64
+ }
38
65
  return {
39
66
  ...host,
67
+ helpOpen: () => help,
40
68
  async handleKey(k) {
41
69
  if (!k)
42
70
  return;
@@ -47,11 +75,23 @@ function createApp(root, hooks = {}) {
47
75
  }
48
76
  let capturing = false;
49
77
  try {
50
- capturing = host.current().capturesKeys?.() === true;
78
+ capturing = top().screen.capturesKeys?.() === true;
51
79
  }
52
80
  catch {
53
81
  capturing = false;
54
82
  }
83
+ // Help is a reference card, not a mode: any key dismisses it, and the
84
+ // keystroke is spent doing so rather than also acting on the screen.
85
+ if (help && !capturing) {
86
+ help = false;
87
+ host.redraw();
88
+ return;
89
+ }
90
+ if (!capturing && (k.name === '?' || k.sequence === '?')) {
91
+ help = true;
92
+ host.redraw();
93
+ return;
94
+ }
55
95
  if (k.name === 'q' && !capturing) {
56
96
  if (stack.length > 1)
57
97
  host.pop();
@@ -61,22 +101,51 @@ function createApp(root, hooks = {}) {
61
101
  }
62
102
  await toScreen(k);
63
103
  },
64
- frame(width) {
65
- let body;
66
- try {
67
- body = host.current().render(width);
68
- }
69
- catch (err) {
70
- error = err.message;
71
- body = [(0, render_1.red)(`screen error: ${err.message}`)];
72
- }
104
+ frame(width, height) {
73
105
  const footer = [];
74
106
  if (noteMsg)
75
107
  footer.push('', noteMsg);
76
108
  if (error)
77
109
  footer.push('', (0, render_1.dim)(`last error: ${error}`));
78
- return [...body, ...footer];
110
+ const room = Math.max(1, height - footer.length);
111
+ let body;
112
+ if (help) {
113
+ body = helpLines(width);
114
+ }
115
+ else {
116
+ try {
117
+ body = top().screen.render(width, room);
118
+ }
119
+ catch (err) {
120
+ error = err.message;
121
+ body = [(0, render_1.red)(`screen error: ${err.message}`)];
122
+ }
123
+ }
124
+ return [...body.slice(0, room), ...footer].slice(0, height);
125
+ },
126
+ duePoll(now) {
127
+ const entry = top();
128
+ let due = 5000;
129
+ try {
130
+ due = entry.screen.pollMs?.() ?? 5000;
131
+ }
132
+ catch {
133
+ due = 5000;
134
+ }
135
+ if (entry.lastPoll !== 0 && now - entry.lastPoll < due)
136
+ return null;
137
+ entry.lastPoll = now;
138
+ return entry.screen;
79
139
  },
80
140
  lastError: () => error,
81
141
  };
82
142
  }
143
+ /** Screens build their footers with this so the grammar is identical
144
+ * everywhere: dim, two-space indent, key then label. */
145
+ function footerHint(hints) {
146
+ return (0, render_1.dim)(' ' + hints.map(h => `${h.keys} ${h.label}`).join(' '));
147
+ }
148
+ /** A screen heading: bold label with dim trailing detail. */
149
+ function heading(label, detail) {
150
+ return ` ${(0, render_1.bold)(label)}${detail ? ` ${(0, render_1.dim)(detail)}` : ''}`;
151
+ }
@@ -7,7 +7,12 @@ export interface ConsoleCapture {
7
7
  lines(): CapturedLog[];
8
8
  last(): CapturedLog | null;
9
9
  count(): number;
10
- /** Restore the real console and replay everything captured. */
11
- restoreAndReplay(): void;
10
+ /** Called on every captured line, so the UI can repaint a live log view. */
11
+ onLine(fn: (line: CapturedLog) => void): void;
12
+ /** Put the real console back. Does not replay: the lines are already on
13
+ * disk, and 500 of them scrolling past on exit helps nobody. */
14
+ restore(): void;
12
15
  }
13
- export declare function captureConsole(): ConsoleCapture;
16
+ export declare function captureConsole(opts?: {
17
+ logFile?: string;
18
+ }): ConsoleCapture;