@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 +2 -3
- package/dist/lockfile.d.ts +43 -3
- package/dist/lockfile.js +114 -38
- package/dist/memory-capture.js +10 -1
- package/dist/paths.d.ts +3 -0
- package/dist/paths.js +4 -1
- package/dist/tui/app.d.ts +24 -2
- package/dist/tui/app.js +85 -16
- package/dist/tui/console-capture.d.ts +8 -3
- package/dist/tui/console-capture.js +73 -15
- package/dist/tui/dashboard.d.ts +23 -12
- package/dist/tui/dashboard.js +169 -129
- package/dist/tui/harnesses.d.ts +50 -13
- package/dist/tui/harnesses.js +179 -171
- package/dist/tui/logs.d.ts +18 -0
- package/dist/tui/logs.js +157 -0
- package/dist/tui/render.d.ts +40 -0
- package/dist/tui/render.js +99 -0
- package/dist/tui/request-row.d.ts +35 -0
- package/dist/tui/request-row.js +144 -0
- package/dist/tui/requests.d.ts +10 -2
- package/dist/tui/requests.js +112 -20
- package/dist/tui/run.d.ts +2 -2
- package/dist/tui/run.js +146 -85
- package/dist/tui/transcript.d.ts +3 -2
- package/dist/tui/transcript.js +39 -15
- package/package.json +1 -1
- package/scripts/probe-tui.mjs +122 -0
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 (
|
|
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,
|
|
100
|
+
await (0, run_1.runHarnessesTui)();
|
|
102
101
|
return;
|
|
103
102
|
case 'unpair':
|
|
104
103
|
await cmdUnpair();
|
package/dist/lockfile.d.ts
CHANGED
|
@@ -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
|
|
4
|
-
// whether the runtime is alive, and unlinked on a
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
//
|
|
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 (
|
|
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
|
}
|
package/dist/memory-capture.js
CHANGED
|
@@ -19,7 +19,16 @@ function buildCapturePayload(req, userText, assistantText, transcriptRef) {
|
|
|
19
19
|
transcript_ref: transcriptRef,
|
|
20
20
|
};
|
|
21
21
|
}
|
|
22
|
-
|
|
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
|
-
|
|
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: () =>
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
/**
|
|
11
|
-
|
|
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(
|
|
16
|
+
export declare function captureConsole(opts?: {
|
|
17
|
+
logFile?: string;
|
|
18
|
+
}): ConsoleCapture;
|