@phnx-labs/agents-cli 1.20.63 → 1.20.65
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/CHANGELOG.md +56 -3
- package/README.md +46 -2
- package/dist/bin/agents +0 -0
- package/dist/commands/apply.d.ts +12 -0
- package/dist/commands/apply.js +274 -0
- package/dist/commands/browser.js +2 -2
- package/dist/commands/cloud.js +32 -2
- package/dist/commands/doctor.js +4 -1
- package/dist/commands/exec.js +149 -77
- package/dist/commands/feed.d.ts +4 -0
- package/dist/commands/feed.js +52 -19
- package/dist/commands/hosts.js +44 -6
- package/dist/commands/lease.d.ts +23 -0
- package/dist/commands/lease.js +201 -0
- package/dist/commands/mailboxes.d.ts +20 -0
- package/dist/commands/mailboxes.js +390 -0
- package/dist/commands/mcp.js +55 -5
- package/dist/commands/monitors.d.ts +12 -0
- package/dist/commands/monitors.js +740 -0
- package/dist/commands/output.js +2 -2
- package/dist/commands/routines.js +43 -16
- package/dist/commands/secrets.d.ts +16 -0
- package/dist/commands/secrets.js +215 -64
- package/dist/commands/serve.js +31 -0
- package/dist/commands/sessions-export.d.ts +2 -0
- package/dist/commands/sessions-export.js +284 -0
- package/dist/commands/sessions-import.d.ts +2 -0
- package/dist/commands/sessions-import.js +230 -0
- package/dist/commands/sessions.d.ts +16 -0
- package/dist/commands/sessions.js +40 -6
- package/dist/commands/ssh.js +143 -5
- package/dist/commands/usage.d.ts +2 -0
- package/dist/commands/usage.js +7 -2
- package/dist/commands/versions.js +7 -3
- package/dist/commands/view.d.ts +27 -1
- package/dist/commands/view.js +32 -9
- package/dist/commands/webhook.js +10 -2
- package/dist/index.js +35 -14
- package/dist/lib/agents.d.ts +36 -0
- package/dist/lib/agents.js +80 -20
- package/dist/lib/auto-dispatch-provider.js +7 -2
- package/dist/lib/auto-dispatch.d.ts +3 -0
- package/dist/lib/auto-dispatch.js +3 -0
- package/dist/lib/browser/chrome.js +2 -2
- package/dist/lib/browser/drivers/ssh.js +19 -2
- package/dist/lib/cloud/antigravity.js +2 -2
- package/dist/lib/cloud/host.d.ts +59 -0
- package/dist/lib/cloud/host.js +224 -0
- package/dist/lib/cloud/registry.js +4 -0
- package/dist/lib/cloud/types.d.ts +6 -4
- package/dist/lib/comms-render.d.ts +37 -0
- package/dist/lib/comms-render.js +89 -0
- package/dist/lib/computer-rpc.js +3 -1
- package/dist/lib/crabbox/cli.d.ts +72 -0
- package/dist/lib/crabbox/cli.js +162 -9
- package/dist/lib/crabbox/runtimes.d.ts +13 -0
- package/dist/lib/crabbox/runtimes.js +35 -2
- package/dist/lib/daemon.d.ts +20 -4
- package/dist/lib/daemon.js +68 -20
- package/dist/lib/devices/fleet.d.ts +3 -2
- package/dist/lib/devices/fleet.js +9 -0
- package/dist/lib/devices/health.d.ts +77 -0
- package/dist/lib/devices/health.js +186 -0
- package/dist/lib/devices/registry.d.ts +15 -0
- package/dist/lib/devices/registry.js +9 -0
- package/dist/lib/exec.d.ts +19 -2
- package/dist/lib/exec.js +41 -13
- package/dist/lib/fleet/apply.d.ts +63 -0
- package/dist/lib/fleet/apply.js +214 -0
- package/dist/lib/fleet/auth-sync.d.ts +67 -0
- package/dist/lib/fleet/auth-sync.js +142 -0
- package/dist/lib/fleet/manifest.d.ts +29 -0
- package/dist/lib/fleet/manifest.js +127 -0
- package/dist/lib/fleet/types.d.ts +129 -0
- package/dist/lib/fleet/types.js +13 -0
- package/dist/lib/git.d.ts +27 -0
- package/dist/lib/git.js +34 -2
- package/dist/lib/hosts/dispatch.d.ts +29 -8
- package/dist/lib/hosts/dispatch.js +46 -18
- package/dist/lib/hosts/passthrough.js +2 -0
- package/dist/lib/hosts/providers/devices.d.ts +27 -0
- package/dist/lib/hosts/providers/devices.js +98 -0
- package/dist/lib/hosts/registry.d.ts +10 -16
- package/dist/lib/hosts/registry.js +17 -50
- package/dist/lib/hosts/remote-cmd.d.ts +23 -0
- package/dist/lib/hosts/remote-cmd.js +71 -0
- package/dist/lib/hosts/run-target.d.ts +84 -0
- package/dist/lib/hosts/run-target.js +99 -0
- package/dist/lib/hosts/types.d.ts +23 -5
- package/dist/lib/hosts/types.js +22 -4
- package/dist/lib/linear-autoclose.d.ts +30 -0
- package/dist/lib/linear-autoclose.js +22 -0
- package/dist/lib/mailbox.d.ts +39 -0
- package/dist/lib/mailbox.js +112 -0
- package/dist/lib/mcp.d.ts +27 -1
- package/dist/lib/mcp.js +126 -12
- package/dist/lib/monitors/config.d.ts +161 -0
- package/dist/lib/monitors/config.js +372 -0
- package/dist/lib/monitors/dispatch.d.ts +28 -0
- package/dist/lib/monitors/dispatch.js +91 -0
- package/dist/lib/monitors/engine.d.ts +61 -0
- package/dist/lib/monitors/engine.js +201 -0
- package/dist/lib/monitors/sources/command.d.ts +11 -0
- package/dist/lib/monitors/sources/command.js +31 -0
- package/dist/lib/monitors/sources/device.d.ts +13 -0
- package/dist/lib/monitors/sources/device.js +35 -0
- package/dist/lib/monitors/sources/file.d.ts +14 -0
- package/dist/lib/monitors/sources/file.js +57 -0
- package/dist/lib/monitors/sources/http.d.ts +10 -0
- package/dist/lib/monitors/sources/http.js +34 -0
- package/dist/lib/monitors/sources/index.d.ts +14 -0
- package/dist/lib/monitors/sources/index.js +31 -0
- package/dist/lib/monitors/sources/poll.d.ts +9 -0
- package/dist/lib/monitors/sources/poll.js +9 -0
- package/dist/lib/monitors/sources/types.d.ts +18 -0
- package/dist/lib/monitors/sources/types.js +9 -0
- package/dist/lib/monitors/sources/webhook.d.ts +23 -0
- package/dist/lib/monitors/sources/webhook.js +47 -0
- package/dist/lib/monitors/sources/ws.d.ts +14 -0
- package/dist/lib/monitors/sources/ws.js +45 -0
- package/dist/lib/monitors/state.d.ts +69 -0
- package/dist/lib/monitors/state.js +144 -0
- package/dist/lib/paths.d.ts +13 -0
- package/dist/lib/paths.js +26 -4
- package/dist/lib/platform/exec.d.ts +16 -0
- package/dist/lib/platform/exec.js +17 -0
- package/dist/lib/plugins.js +101 -2
- package/dist/lib/redact.d.ts +14 -1
- package/dist/lib/redact.js +47 -1
- package/dist/lib/remote-agents-json.js +7 -1
- package/dist/lib/rotate.d.ts +6 -3
- package/dist/lib/rotate.js +0 -1
- package/dist/lib/routines.d.ts +37 -2
- package/dist/lib/routines.js +54 -12
- package/dist/lib/runner.d.ts +1 -0
- package/dist/lib/runner.js +348 -13
- package/dist/lib/sandbox.d.ts +9 -1
- package/dist/lib/sandbox.js +11 -2
- package/dist/lib/secrets/agent.d.ts +48 -10
- package/dist/lib/secrets/agent.js +123 -15
- package/dist/lib/secrets/bundles.d.ts +26 -0
- package/dist/lib/secrets/bundles.js +59 -8
- package/dist/lib/secrets/mcp.js +4 -2
- package/dist/lib/secrets/remote.d.ts +17 -0
- package/dist/lib/secrets/remote.js +40 -0
- package/dist/lib/self-update.d.ts +20 -0
- package/dist/lib/self-update.js +54 -1
- package/dist/lib/serve/control.d.ts +95 -0
- package/dist/lib/serve/control.js +260 -0
- package/dist/lib/serve/server.d.ts +35 -1
- package/dist/lib/serve/server.js +106 -76
- package/dist/lib/serve/stream.d.ts +43 -0
- package/dist/lib/serve/stream.js +116 -0
- package/dist/lib/serve/token.d.ts +35 -0
- package/dist/lib/serve/token.js +85 -0
- package/dist/lib/session/bundle.d.ts +164 -0
- package/dist/lib/session/bundle.js +200 -0
- package/dist/lib/session/remote-bundle.d.ts +12 -0
- package/dist/lib/session/remote-bundle.js +61 -0
- package/dist/lib/session/remote-list.js +5 -1
- package/dist/lib/session/state.d.ts +7 -25
- package/dist/lib/session/state.js +16 -6
- package/dist/lib/session/sync/agents.d.ts +54 -6
- package/dist/lib/session/sync/agents.js +0 -0
- package/dist/lib/session/sync/config.js +8 -2
- package/dist/lib/session/sync/manifest.d.ts +14 -3
- package/dist/lib/session/sync/manifest.js +4 -0
- package/dist/lib/session/sync/sync.d.ts +23 -2
- package/dist/lib/session/sync/sync.js +177 -74
- package/dist/lib/session/types.d.ts +30 -0
- package/dist/lib/ssh-tunnel.d.ts +19 -1
- package/dist/lib/ssh-tunnel.js +99 -8
- package/dist/lib/staleness/detectors/subagents.d.ts +5 -0
- package/dist/lib/staleness/detectors/subagents.js +5 -192
- package/dist/lib/staleness/writers/subagents.d.ts +10 -0
- package/dist/lib/staleness/writers/subagents.js +11 -102
- package/dist/lib/startup/command-registry.d.ts +4 -0
- package/dist/lib/startup/command-registry.js +9 -0
- package/dist/lib/state.d.ts +5 -0
- package/dist/lib/state.js +12 -0
- package/dist/lib/subagents-registry.d.ts +85 -0
- package/dist/lib/subagents-registry.js +393 -0
- package/dist/lib/subagents.d.ts +8 -8
- package/dist/lib/subagents.js +32 -663
- package/dist/lib/sync-umbrella.d.ts +1 -0
- package/dist/lib/sync-umbrella.js +14 -3
- package/dist/lib/tmux/session.d.ts +7 -0
- package/dist/lib/tmux/session.js +3 -1
- package/dist/lib/triggers/webhook.d.ts +18 -0
- package/dist/lib/triggers/webhook.js +105 -0
- package/dist/lib/types.d.ts +35 -1
- package/dist/lib/usage.d.ts +42 -3
- package/dist/lib/usage.js +163 -21
- package/dist/lib/versions.js +14 -11
- package/dist/lib/workflows.d.ts +20 -0
- package/dist/lib/workflows.js +24 -0
- package/package.json +2 -1
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { createServer } from 'http';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import { spawn } from 'child_process';
|
|
4
|
+
import { randomUUID, randomBytes } from 'crypto';
|
|
5
|
+
import { getAgentsInvocation } from '../daemon.js';
|
|
6
|
+
import { handleServeGet, resolveServeContext } from './server.js';
|
|
7
|
+
import { verifyControlToken } from './token.js';
|
|
8
|
+
import { readNewEvents, streamDir, streamLogPath } from './stream.js';
|
|
9
|
+
const MAX_BODY_BYTES = 64 * 1024;
|
|
10
|
+
const DEFAULT_STREAM_POLL_MS = 300;
|
|
11
|
+
/** Pull the presented bearer token from either header form. */
|
|
12
|
+
function presentedToken(req) {
|
|
13
|
+
const auth = req.headers['authorization'];
|
|
14
|
+
if (typeof auth === 'string' && auth.toLowerCase().startsWith('bearer ')) {
|
|
15
|
+
return auth.slice(7).trim();
|
|
16
|
+
}
|
|
17
|
+
const x = req.headers['x-agents-token'];
|
|
18
|
+
return typeof x === 'string' ? x.trim() : undefined;
|
|
19
|
+
}
|
|
20
|
+
/** Read a JSON request body with a hard size cap. Rejects on overflow/parse error. */
|
|
21
|
+
function readJsonBody(req) {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
let size = 0;
|
|
24
|
+
const chunks = [];
|
|
25
|
+
req.on('data', (c) => {
|
|
26
|
+
size += c.length;
|
|
27
|
+
if (size > MAX_BODY_BYTES) {
|
|
28
|
+
reject(new Error('request body too large'));
|
|
29
|
+
req.destroy();
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
chunks.push(c);
|
|
33
|
+
});
|
|
34
|
+
req.on('end', () => {
|
|
35
|
+
const raw = Buffer.concat(chunks).toString('utf-8').trim();
|
|
36
|
+
if (!raw)
|
|
37
|
+
return resolve({});
|
|
38
|
+
try {
|
|
39
|
+
resolve(JSON.parse(raw));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
reject(new Error('invalid JSON body'));
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
req.on('error', reject);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
function sendJson(res, code, obj) {
|
|
49
|
+
res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' });
|
|
50
|
+
res.end(JSON.stringify(obj));
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* SSE stream of a session's normalized NDJSON events, offset-tailed from its
|
|
54
|
+
* capture file. Resumes from `?offset=<bytes>` or the `Last-Event-ID` header
|
|
55
|
+
* (each event's `id:` is its exact byte offset, so resume neither loses nor
|
|
56
|
+
* duplicates). Closes when a terminal (`result`/`error`) event is seen or the
|
|
57
|
+
* client disconnects. A not-yet-created file simply yields nothing until the
|
|
58
|
+
* run starts writing — the phone can open the stream the instant it dispatches.
|
|
59
|
+
*/
|
|
60
|
+
export function startSessionStream(file, req, res, pollMs) {
|
|
61
|
+
res.writeHead(200, {
|
|
62
|
+
'content-type': 'text/event-stream',
|
|
63
|
+
'cache-control': 'no-cache',
|
|
64
|
+
connection: 'keep-alive',
|
|
65
|
+
});
|
|
66
|
+
const params = new URL(req.url ?? '/', 'http://anchor').searchParams;
|
|
67
|
+
const qOffset = Number.parseInt(params.get('offset') ?? '', 10);
|
|
68
|
+
const lastId = Number.parseInt(Array.isArray(req.headers['last-event-id'])
|
|
69
|
+
? req.headers['last-event-id'][0]
|
|
70
|
+
: req.headers['last-event-id'] ?? '', 10);
|
|
71
|
+
// Clamp at the source: a negative/garbage offset (or Last-Event-ID) is
|
|
72
|
+
// meaningless and, worse, `Buffer.subarray(neg)` indexes from the END — so an
|
|
73
|
+
// unclamped negative offset would read nothing and never self-correct. A bad
|
|
74
|
+
// resume value replays from the start, the EventSource-friendly behavior.
|
|
75
|
+
const wanted = Number.isFinite(qOffset) ? qOffset : Number.isFinite(lastId) ? lastId : 0;
|
|
76
|
+
let offset = Math.max(0, wanted);
|
|
77
|
+
let closed = false;
|
|
78
|
+
let timer;
|
|
79
|
+
const stop = () => {
|
|
80
|
+
closed = true;
|
|
81
|
+
if (timer)
|
|
82
|
+
clearInterval(timer);
|
|
83
|
+
};
|
|
84
|
+
req.on('close', stop);
|
|
85
|
+
const tick = () => {
|
|
86
|
+
if (closed)
|
|
87
|
+
return;
|
|
88
|
+
const { events, newOffset, done } = readNewEvents(file, offset);
|
|
89
|
+
for (const { event, offset: at } of events) {
|
|
90
|
+
// `at` is the per-event resume point the CLIENT echoes as Last-Event-ID.
|
|
91
|
+
res.write(`id: ${at}\nevent: ${event.type}\ndata: ${JSON.stringify(event.raw)}\n\n`);
|
|
92
|
+
}
|
|
93
|
+
// The SERVER cursor always adopts newOffset — past every complete line,
|
|
94
|
+
// including trailing blank/non-JSON lines after the last event — so the
|
|
95
|
+
// stream always makes progress (even on an empty read) and never re-scans.
|
|
96
|
+
offset = newOffset;
|
|
97
|
+
if (done && !closed) {
|
|
98
|
+
res.write('event: end\ndata: {"ok":true}\n\n');
|
|
99
|
+
stop();
|
|
100
|
+
res.end();
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
tick(); // immediate catch-up from the resume offset
|
|
104
|
+
if (!closed)
|
|
105
|
+
timer = setInterval(tick, pollMs);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Spawn a detached child and settle once it has either successfully spawned or
|
|
109
|
+
* failed to. Attaching an `'error'` listener is mandatory: a `ChildProcess` is
|
|
110
|
+
* an `EventEmitter`, so an `'error'` event (e.g. ENOENT from a stale/missing
|
|
111
|
+
* `agents` binary — a real condition `validateDaemonBinary` guards against)
|
|
112
|
+
* with no listener throws and would take down the whole anchor process, not
|
|
113
|
+
* just the one request. Mirrors the `once('spawn') / once('error')` pattern in
|
|
114
|
+
* `teams/agents.ts`. The `'error'` listener registered by `once` survives after
|
|
115
|
+
* a successful spawn, so a later error is absorbed rather than crashing.
|
|
116
|
+
*/
|
|
117
|
+
export function spawnDetached(command, args, stdio = 'ignore') {
|
|
118
|
+
return new Promise((resolve, reject) => {
|
|
119
|
+
const child = spawn(command, args, { detached: true, stdio, env: process.env });
|
|
120
|
+
child.once('spawn', () => {
|
|
121
|
+
child.unref();
|
|
122
|
+
resolve();
|
|
123
|
+
});
|
|
124
|
+
child.once('error', (err) => reject(err instanceof Error ? err : new Error(String(err))));
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Default run dispatcher: re-invoke `agents run` with a server-minted id so the
|
|
129
|
+
* run is addressable, detached so it outlives this request (and the anchor
|
|
130
|
+
* process). Local runs detach here; `--host` runs detach on the executor via
|
|
131
|
+
* the existing dispatch path.
|
|
132
|
+
*/
|
|
133
|
+
export const defaultRunner = async (req) => {
|
|
134
|
+
if (!req.agent?.trim())
|
|
135
|
+
throw new Error('agent is required');
|
|
136
|
+
if (!req.prompt?.trim())
|
|
137
|
+
throw new Error('prompt is required');
|
|
138
|
+
const name = `ios-${randomBytes(3).toString('hex')}`;
|
|
139
|
+
// claude accepts an explicit session UUID; other agents resolve by --name.
|
|
140
|
+
const sessionId = req.agent === 'claude' ? randomUUID() : name;
|
|
141
|
+
const argv = ['run', req.agent, req.prompt, '--json', '--headless', '--quiet', '--name', name];
|
|
142
|
+
if (req.agent === 'claude')
|
|
143
|
+
argv.push('--session-id', sessionId);
|
|
144
|
+
if (req.mode)
|
|
145
|
+
argv.push('--mode', req.mode);
|
|
146
|
+
if (req.host)
|
|
147
|
+
argv.push('--host', req.host);
|
|
148
|
+
if (req.cwd)
|
|
149
|
+
argv.push('--cwd', req.cwd);
|
|
150
|
+
const inv = getAgentsInvocation(argv);
|
|
151
|
+
// Await spawn/error so a failed launch becomes a clean 400, never an
|
|
152
|
+
// unhandled 'error' that crashes the anchor for every other session.
|
|
153
|
+
//
|
|
154
|
+
// For an anchor-local run, capture the `--json` NDJSON to a per-session file
|
|
155
|
+
// so `GET /api/session/:id/stream` can offset-tail it. A `--host` run emits
|
|
156
|
+
// its NDJSON on the remote box (streaming that reuses pullRemoteLogDelta — a
|
|
157
|
+
// follow-up), so we don't capture the local dispatcher's output.
|
|
158
|
+
if (req.host) {
|
|
159
|
+
await spawnDetached(inv.command, inv.args);
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
fs.mkdirSync(streamDir(), { recursive: true });
|
|
163
|
+
const fd = fs.openSync(streamLogPath(sessionId), 'a');
|
|
164
|
+
try {
|
|
165
|
+
await spawnDetached(inv.command, inv.args, ['ignore', fd, fd]);
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
// The child inherited the fd; the parent's copy is no longer needed.
|
|
169
|
+
fs.closeSync(fd);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return { sessionId, name };
|
|
173
|
+
};
|
|
174
|
+
/** Default messenger: re-invoke `agents message <id> <text>` and await its exit. */
|
|
175
|
+
export const defaultMessenger = async (id, text, from) => {
|
|
176
|
+
const argv = ['message', id, text];
|
|
177
|
+
if (from)
|
|
178
|
+
argv.push('--from', from);
|
|
179
|
+
const inv = getAgentsInvocation(argv);
|
|
180
|
+
const code = await new Promise((resolve) => {
|
|
181
|
+
const child = spawn(inv.command, inv.args, { stdio: 'ignore', env: process.env });
|
|
182
|
+
child.on('exit', (c) => resolve(c ?? 1));
|
|
183
|
+
child.on('error', () => resolve(1));
|
|
184
|
+
});
|
|
185
|
+
if (code !== 0)
|
|
186
|
+
throw new Error(`agents message exited with code ${code}`);
|
|
187
|
+
};
|
|
188
|
+
/**
|
|
189
|
+
* Create (but do not start) the authenticated control server. Caller invokes
|
|
190
|
+
* `.listen(port, bind)`.
|
|
191
|
+
*/
|
|
192
|
+
export function createControlServer(opts = {}) {
|
|
193
|
+
const ctx = resolveServeContext(opts);
|
|
194
|
+
const verify = opts.verifyToken ?? verifyControlToken;
|
|
195
|
+
const runner = opts.runner ?? defaultRunner;
|
|
196
|
+
const messenger = opts.messenger ?? defaultMessenger;
|
|
197
|
+
const streamPollMs = opts.streamPollMs ?? DEFAULT_STREAM_POLL_MS;
|
|
198
|
+
const streamLogPathFor = opts.streamLogPathFor ?? streamLogPath;
|
|
199
|
+
return createServer(async (req, res) => {
|
|
200
|
+
// Auth gates EVERY request — this server may be reachable off-box.
|
|
201
|
+
if (!verify(presentedToken(req))) {
|
|
202
|
+
res.writeHead(401, { 'content-type': 'application/json', 'www-authenticate': 'Bearer' });
|
|
203
|
+
res.end(JSON.stringify({ error: 'unauthorized' }));
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const method = req.method ?? 'GET';
|
|
207
|
+
const url = (req.url || '/').split('?')[0];
|
|
208
|
+
if (method === 'GET') {
|
|
209
|
+
const streamMatch = /^\/api\/session\/([^/]+)\/stream$/.exec(url);
|
|
210
|
+
if (streamMatch) {
|
|
211
|
+
const id = decodeURIComponent(streamMatch[1]);
|
|
212
|
+
startSessionStream(streamLogPathFor(id), req, res, streamPollMs);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const handled = await handleServeGet(url, req, res, ctx);
|
|
216
|
+
if (!handled)
|
|
217
|
+
sendJson(res, 404, { error: 'not found' });
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (method === 'POST' && url === '/api/run') {
|
|
221
|
+
try {
|
|
222
|
+
const body = (await readJsonBody(req));
|
|
223
|
+
const result = await runner(body);
|
|
224
|
+
sendJson(res, 200, result);
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
sendJson(res, 400, { error: String(err?.message ?? err) });
|
|
228
|
+
}
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const msgMatch = method === 'POST' && /^\/api\/session\/([^/]+)\/message$/.exec(url);
|
|
232
|
+
if (msgMatch) {
|
|
233
|
+
const id = decodeURIComponent(msgMatch[1]);
|
|
234
|
+
try {
|
|
235
|
+
const body = (await readJsonBody(req));
|
|
236
|
+
if (!body.text?.trim())
|
|
237
|
+
throw new Error('text is required');
|
|
238
|
+
await messenger(id, body.text, body.from);
|
|
239
|
+
sendJson(res, 200, { ok: true, id });
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
sendJson(res, 400, { error: String(err?.message ?? err) });
|
|
243
|
+
}
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
sendJson(res, 404, { error: 'not found' });
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
/** Start the control server on `port`, bound to `bind`. Resolves the bound port. */
|
|
250
|
+
export function startControlServer(port, bind, opts = {}) {
|
|
251
|
+
const server = createControlServer(opts);
|
|
252
|
+
return new Promise((resolve, reject) => {
|
|
253
|
+
server.once('error', reject);
|
|
254
|
+
server.listen(port, bind, () => {
|
|
255
|
+
const addr = server.address();
|
|
256
|
+
const boundPort = typeof addr === 'object' && addr ? addr.port : port;
|
|
257
|
+
resolve({ server, port: boundPort });
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
}
|
|
@@ -9,11 +9,26 @@
|
|
|
9
9
|
*
|
|
10
10
|
* The whole thing is a viewer over data other commands already own
|
|
11
11
|
* ({@link assembleState}); it writes nothing.
|
|
12
|
+
*
|
|
13
|
+
* The GET routing is exported as {@link handleServeGet} so the authenticated
|
|
14
|
+
* `--control` variant (see control.ts) can reuse it verbatim rather than
|
|
15
|
+
* duplicate the state/SSE surface.
|
|
12
16
|
*/
|
|
13
|
-
import http from 'http';
|
|
17
|
+
import type http from 'http';
|
|
14
18
|
import type { ServeState } from './data.js';
|
|
15
19
|
/** Loopback address — the server binds here and nowhere else. */
|
|
16
20
|
export declare const SERVE_HOST = "127.0.0.1";
|
|
21
|
+
/**
|
|
22
|
+
* DNS-rebinding guard. The socket binds loopback, but binding alone does not
|
|
23
|
+
* stop a remote page: an attacker can point a hostname's DNS at 127.0.0.1 and
|
|
24
|
+
* drive the victim's browser to `GET /api/state`, which exposes uncommitted
|
|
25
|
+
* `git diff HEAD` of every worktree plus routine + cloud config. A browser
|
|
26
|
+
* always sends a `Host` header naming the site it *thinks* it reached, so we
|
|
27
|
+
* trust only a Host that is itself loopback. A missing Host (HTTP/1.0 or a raw
|
|
28
|
+
* non-browser client like curl-without-Host) is not a rebind vector and is
|
|
29
|
+
* allowed; any explicit non-loopback Host is rejected.
|
|
30
|
+
*/
|
|
31
|
+
export declare function isAllowedServeHost(hostHeader: string | undefined): boolean;
|
|
17
32
|
/** Default port for `agents serve`. */
|
|
18
33
|
export declare const DEFAULT_SERVE_PORT = 4477;
|
|
19
34
|
/** Default SSE push cadence. */
|
|
@@ -31,6 +46,25 @@ export interface ServeOptions {
|
|
|
31
46
|
*/
|
|
32
47
|
snapshot?: () => Promise<ServeState>;
|
|
33
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* Resolved GET-route context: the snapshot source and SSE cadence, with all
|
|
51
|
+
* defaults applied. Built once by {@link resolveServeContext}.
|
|
52
|
+
*/
|
|
53
|
+
export interface ServeContext {
|
|
54
|
+
snapshot: () => Promise<ServeState>;
|
|
55
|
+
intervalMs: number;
|
|
56
|
+
}
|
|
57
|
+
/** Apply defaults to {@link ServeOptions}, producing a {@link ServeContext}. */
|
|
58
|
+
export declare function resolveServeContext(opts?: ServeOptions): ServeContext;
|
|
59
|
+
/**
|
|
60
|
+
* Handle the shared read-only GET surface (`/`, `/api/state`, `/events`).
|
|
61
|
+
* Returns `true` when the route was recognized and a response was written (or
|
|
62
|
+
* begun, for SSE); `false` when the path is unknown so the caller can 404 or
|
|
63
|
+
* try its own routes first. Extracted so the `--control` server reuses the
|
|
64
|
+
* exact same state/SSE behavior — including the load-bearing SSE close/timer
|
|
65
|
+
* ordering below — without duplicating it.
|
|
66
|
+
*/
|
|
67
|
+
export declare function handleServeGet(url: string, req: http.IncomingMessage, res: http.ServerResponse, ctx: ServeContext): Promise<boolean>;
|
|
34
68
|
/**
|
|
35
69
|
* Create (but do not start) the read-only serve server. Caller invokes
|
|
36
70
|
* `.listen(port, SERVE_HOST)`. Returned so tests can bind an ephemeral port.
|
package/dist/lib/serve/server.js
CHANGED
|
@@ -1,33 +1,118 @@
|
|
|
1
|
-
|
|
2
|
-
* Read-only localhost HTTP + Server-Sent-Events server for `agents serve`.
|
|
3
|
-
*
|
|
4
|
-
* There is deliberately NO framework and NO mutation surface:
|
|
5
|
-
* - binds 127.0.0.1 only (loopback) — never reachable off the machine;
|
|
6
|
-
* - answers GET only (every other method → 405);
|
|
7
|
-
* - routes: `GET /` → HTML page, `GET /api/state` → JSON snapshot,
|
|
8
|
-
* `GET /events` → SSE stream that re-pushes the snapshot on an interval.
|
|
9
|
-
*
|
|
10
|
-
* The whole thing is a viewer over data other commands already own
|
|
11
|
-
* ({@link assembleState}); it writes nothing.
|
|
12
|
-
*/
|
|
13
|
-
import http from 'http';
|
|
1
|
+
import { createServer } from 'http';
|
|
14
2
|
import { assembleState } from './data.js';
|
|
15
3
|
import { renderPage } from './page.js';
|
|
16
4
|
/** Loopback address — the server binds here and nowhere else. */
|
|
17
5
|
export const SERVE_HOST = '127.0.0.1';
|
|
6
|
+
/**
|
|
7
|
+
* DNS-rebinding guard. The socket binds loopback, but binding alone does not
|
|
8
|
+
* stop a remote page: an attacker can point a hostname's DNS at 127.0.0.1 and
|
|
9
|
+
* drive the victim's browser to `GET /api/state`, which exposes uncommitted
|
|
10
|
+
* `git diff HEAD` of every worktree plus routine + cloud config. A browser
|
|
11
|
+
* always sends a `Host` header naming the site it *thinks* it reached, so we
|
|
12
|
+
* trust only a Host that is itself loopback. A missing Host (HTTP/1.0 or a raw
|
|
13
|
+
* non-browser client like curl-without-Host) is not a rebind vector and is
|
|
14
|
+
* allowed; any explicit non-loopback Host is rejected.
|
|
15
|
+
*/
|
|
16
|
+
export function isAllowedServeHost(hostHeader) {
|
|
17
|
+
if (!hostHeader)
|
|
18
|
+
return true;
|
|
19
|
+
const host = hostHeader.replace(/:\d+$/, '').toLowerCase().replace(/^\[|\]$/g, '');
|
|
20
|
+
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
|
21
|
+
}
|
|
18
22
|
/** Default port for `agents serve`. */
|
|
19
23
|
export const DEFAULT_SERVE_PORT = 4477;
|
|
20
24
|
/** Default SSE push cadence. */
|
|
21
25
|
export const DEFAULT_INTERVAL_MS = 3000;
|
|
26
|
+
/** Apply defaults to {@link ServeOptions}, producing a {@link ServeContext}. */
|
|
27
|
+
export function resolveServeContext(opts = {}) {
|
|
28
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
29
|
+
return {
|
|
30
|
+
snapshot: opts.snapshot ?? (() => assembleState(cwd)),
|
|
31
|
+
intervalMs: opts.intervalMs ?? DEFAULT_INTERVAL_MS,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Handle the shared read-only GET surface (`/`, `/api/state`, `/events`).
|
|
36
|
+
* Returns `true` when the route was recognized and a response was written (or
|
|
37
|
+
* begun, for SSE); `false` when the path is unknown so the caller can 404 or
|
|
38
|
+
* try its own routes first. Extracted so the `--control` server reuses the
|
|
39
|
+
* exact same state/SSE behavior — including the load-bearing SSE close/timer
|
|
40
|
+
* ordering below — without duplicating it.
|
|
41
|
+
*/
|
|
42
|
+
export async function handleServeGet(url, req, res, ctx) {
|
|
43
|
+
if (url === '/' || url === '/index.html') {
|
|
44
|
+
const html = renderPage();
|
|
45
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
46
|
+
res.end(html);
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
if (url === '/api/state') {
|
|
50
|
+
try {
|
|
51
|
+
const state = await ctx.snapshot();
|
|
52
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
|
|
53
|
+
res.end(JSON.stringify(state));
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
res.writeHead(500, { 'content-type': 'application/json' });
|
|
57
|
+
res.end(JSON.stringify({ error: String(err?.message ?? err) }));
|
|
58
|
+
}
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
if (url === '/events') {
|
|
62
|
+
res.writeHead(200, {
|
|
63
|
+
'content-type': 'text/event-stream',
|
|
64
|
+
'cache-control': 'no-cache',
|
|
65
|
+
connection: 'keep-alive',
|
|
66
|
+
});
|
|
67
|
+
let closed = false;
|
|
68
|
+
// Declare the timer first and register the disconnect cleanup BEFORE the
|
|
69
|
+
// first `await push()`. `assembleState()` (team scan + git diffs) can be
|
|
70
|
+
// slow; if the client disconnects during it, the one-shot 'close' must
|
|
71
|
+
// already have a listener — otherwise it fires into the void, the interval
|
|
72
|
+
// starts anyway, and we leak a timer writing to a destroyed socket forever.
|
|
73
|
+
let timer;
|
|
74
|
+
req.on('close', () => {
|
|
75
|
+
closed = true;
|
|
76
|
+
if (timer)
|
|
77
|
+
clearInterval(timer);
|
|
78
|
+
});
|
|
79
|
+
const push = async () => {
|
|
80
|
+
if (closed)
|
|
81
|
+
return;
|
|
82
|
+
try {
|
|
83
|
+
const state = await ctx.snapshot();
|
|
84
|
+
if (closed)
|
|
85
|
+
return;
|
|
86
|
+
res.write(`event: state\ndata: ${JSON.stringify(state)}\n\n`);
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
if (!closed)
|
|
90
|
+
res.write(`event: error\ndata: ${JSON.stringify({ error: String(err) })}\n\n`);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
await push(); // immediate first frame
|
|
94
|
+
// Only arm the interval if the client is still connected. If 'close' fired
|
|
95
|
+
// during the first push, `closed` is already true (and the handler ran
|
|
96
|
+
// while `timer` was undefined), so starting an interval here would leak.
|
|
97
|
+
if (!closed)
|
|
98
|
+
timer = setInterval(push, ctx.intervalMs);
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
22
103
|
/**
|
|
23
104
|
* Create (but do not start) the read-only serve server. Caller invokes
|
|
24
105
|
* `.listen(port, SERVE_HOST)`. Returned so tests can bind an ephemeral port.
|
|
25
106
|
*/
|
|
26
107
|
export function createServeServer(opts = {}) {
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
108
|
+
const ctx = resolveServeContext(opts);
|
|
109
|
+
const server = createServer(async (req, res) => {
|
|
110
|
+
// DNS-rebind guard: reject any explicit non-loopback Host before doing work.
|
|
111
|
+
if (!isAllowedServeHost(req.headers.host)) {
|
|
112
|
+
res.writeHead(403, { 'content-type': 'text/plain' });
|
|
113
|
+
res.end('forbidden: non-local Host');
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
31
116
|
// Read-only: reject anything that could mutate. Only GET is served.
|
|
32
117
|
if (req.method !== 'GET') {
|
|
33
118
|
res.writeHead(405, { 'content-type': 'text/plain', allow: 'GET' });
|
|
@@ -35,66 +120,11 @@ export function createServeServer(opts = {}) {
|
|
|
35
120
|
return;
|
|
36
121
|
}
|
|
37
122
|
const url = (req.url || '/').split('?')[0];
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
res.writeHead(
|
|
41
|
-
res.end(
|
|
42
|
-
return;
|
|
43
|
-
}
|
|
44
|
-
if (url === '/api/state') {
|
|
45
|
-
try {
|
|
46
|
-
const state = await snapshot();
|
|
47
|
-
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
|
|
48
|
-
res.end(JSON.stringify(state));
|
|
49
|
-
}
|
|
50
|
-
catch (err) {
|
|
51
|
-
res.writeHead(500, { 'content-type': 'application/json' });
|
|
52
|
-
res.end(JSON.stringify({ error: String(err?.message ?? err) }));
|
|
53
|
-
}
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
56
|
-
if (url === '/events') {
|
|
57
|
-
res.writeHead(200, {
|
|
58
|
-
'content-type': 'text/event-stream',
|
|
59
|
-
'cache-control': 'no-cache',
|
|
60
|
-
connection: 'keep-alive',
|
|
61
|
-
});
|
|
62
|
-
let closed = false;
|
|
63
|
-
// Declare the timer first and register the disconnect cleanup BEFORE the
|
|
64
|
-
// first `await push()`. `assembleState()` (team scan + git diffs) can be
|
|
65
|
-
// slow; if the client disconnects during it, the one-shot 'close' must
|
|
66
|
-
// already have a listener — otherwise it fires into the void, the interval
|
|
67
|
-
// starts anyway, and we leak a timer writing to a destroyed socket forever.
|
|
68
|
-
let timer;
|
|
69
|
-
req.on('close', () => {
|
|
70
|
-
closed = true;
|
|
71
|
-
if (timer)
|
|
72
|
-
clearInterval(timer);
|
|
73
|
-
});
|
|
74
|
-
const push = async () => {
|
|
75
|
-
if (closed)
|
|
76
|
-
return;
|
|
77
|
-
try {
|
|
78
|
-
const state = await snapshot();
|
|
79
|
-
if (closed)
|
|
80
|
-
return;
|
|
81
|
-
res.write(`event: state\ndata: ${JSON.stringify(state)}\n\n`);
|
|
82
|
-
}
|
|
83
|
-
catch (err) {
|
|
84
|
-
if (!closed)
|
|
85
|
-
res.write(`event: error\ndata: ${JSON.stringify({ error: String(err) })}\n\n`);
|
|
86
|
-
}
|
|
87
|
-
};
|
|
88
|
-
await push(); // immediate first frame
|
|
89
|
-
// Only arm the interval if the client is still connected. If 'close' fired
|
|
90
|
-
// during the first push, `closed` is already true (and the handler ran
|
|
91
|
-
// while `timer` was undefined), so starting an interval here would leak.
|
|
92
|
-
if (!closed)
|
|
93
|
-
timer = setInterval(push, intervalMs);
|
|
94
|
-
return;
|
|
123
|
+
const handled = await handleServeGet(url, req, res, ctx);
|
|
124
|
+
if (!handled) {
|
|
125
|
+
res.writeHead(404, { 'content-type': 'text/plain' });
|
|
126
|
+
res.end('not found');
|
|
95
127
|
}
|
|
96
|
-
res.writeHead(404, { 'content-type': 'text/plain' });
|
|
97
|
-
res.end('not found');
|
|
98
128
|
});
|
|
99
129
|
return server;
|
|
100
130
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Directory holding per-session NDJSON capture files. */
|
|
2
|
+
export declare function streamDir(): string;
|
|
3
|
+
/** Capture-file path for a session id (id is sanitized to a safe filename). */
|
|
4
|
+
export declare function streamLogPath(sessionId: string): string;
|
|
5
|
+
/** A normalized event handed to the phone. `raw` is the untouched harness line. */
|
|
6
|
+
export interface StreamEvent {
|
|
7
|
+
/** UI-facing discriminator: message | tool_use | tool_result | result | error | system | unknown. */
|
|
8
|
+
type: string;
|
|
9
|
+
raw: unknown;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Normalize one harness JSON line to a {@link StreamEvent}. Schemas differ per
|
|
13
|
+
* agent (there is no unified enum in the CLI), so we key off the common `type`
|
|
14
|
+
* field (Claude stream-json: `assistant` | `user` | `result` | `system`; a
|
|
15
|
+
* tool-use lives inside an `assistant` message's content) and carry `raw`
|
|
16
|
+
* through untouched so the client can render richer detail without the anchor
|
|
17
|
+
* having to model every agent.
|
|
18
|
+
*/
|
|
19
|
+
export declare function normalizeEvent(obj: unknown): StreamEvent;
|
|
20
|
+
/** A parsed event paired with the byte offset immediately AFTER its line. */
|
|
21
|
+
export interface OffsetEvent {
|
|
22
|
+
event: StreamEvent;
|
|
23
|
+
/** Resume point: absolute byte offset just past this event's newline. */
|
|
24
|
+
offset: number;
|
|
25
|
+
}
|
|
26
|
+
/** Result of one offset-tail read of a capture file. */
|
|
27
|
+
export interface ReadResult {
|
|
28
|
+
events: OffsetEvent[];
|
|
29
|
+
/** Byte offset advanced ONLY past complete newline-terminated lines. */
|
|
30
|
+
newOffset: number;
|
|
31
|
+
/** True once a terminal event (result/error) has been parsed. */
|
|
32
|
+
done: boolean;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Read complete NDJSON lines from `fromOffset`, byte-accurately. Each event
|
|
36
|
+
* carries the exact byte offset past its own newline so an SSE client can set
|
|
37
|
+
* `Last-Event-ID` per event and resume with neither loss nor duplication even
|
|
38
|
+
* if the connection drops mid-batch. A trailing partial line (no newline yet)
|
|
39
|
+
* is left unconsumed so the offset never splits a multi-byte event — the next
|
|
40
|
+
* read picks it up once the harness flushes the newline. Non-JSON preamble
|
|
41
|
+
* lines are skipped, not fatal.
|
|
42
|
+
*/
|
|
43
|
+
export declare function readNewEvents(file: string, fromOffset: number): ReadResult;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live NDJSON event bridge for the iOS cockpit (RUSH-1732).
|
|
3
|
+
*
|
|
4
|
+
* A control-mode run (`defaultRunner`) is launched with `--json`, so the agent
|
|
5
|
+
* harness emits one JSON event per line. We capture that stream to a per-session
|
|
6
|
+
* logfile and replay it to the phone over SSE at `GET /api/session/:id/stream`,
|
|
7
|
+
* normalized to the small event set the UI needs. The read is **offset-tailed**
|
|
8
|
+
* — the same resumable pattern `hosts/progress.ts` uses for remote logs — so a
|
|
9
|
+
* phone that drops mid-run reconnects with `?offset=<bytes>` (or the standard
|
|
10
|
+
* `Last-Event-ID` header) and never loses or double-counts an event.
|
|
11
|
+
*
|
|
12
|
+
* Scope: this streams runs whose executor is the anchor itself (no `--host`).
|
|
13
|
+
* Streaming a run offloaded to another box reuses `pullRemoteLogDelta` and is a
|
|
14
|
+
* follow-up; see the changelog fragment.
|
|
15
|
+
*/
|
|
16
|
+
import fs from 'fs';
|
|
17
|
+
import path from 'path';
|
|
18
|
+
import { getCacheDir } from '../state.js';
|
|
19
|
+
/** Directory holding per-session NDJSON capture files. */
|
|
20
|
+
export function streamDir() {
|
|
21
|
+
return path.join(getCacheDir(), 'serve', 'streams');
|
|
22
|
+
}
|
|
23
|
+
/** Capture-file path for a session id (id is sanitized to a safe filename). */
|
|
24
|
+
export function streamLogPath(sessionId) {
|
|
25
|
+
const safe = sessionId.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
26
|
+
return path.join(streamDir(), `${safe}.ndjson`);
|
|
27
|
+
}
|
|
28
|
+
/** Event types that terminate a run's stream (close the SSE). */
|
|
29
|
+
const TERMINAL = new Set(['result', 'error']);
|
|
30
|
+
/**
|
|
31
|
+
* Whether the run has ended — i.e. the file's LAST complete (newline-terminated)
|
|
32
|
+
* line is a terminal event. This is independent of any read offset, so a client
|
|
33
|
+
* that resumes from an offset already at/past the terminal event still learns
|
|
34
|
+
* the run is done and the SSE closes (instead of hanging on a `done:false` read
|
|
35
|
+
* that finds no new lines). Cheap: `readNewEvents` already has the full buffer.
|
|
36
|
+
*/
|
|
37
|
+
function fileIsDone(buf) {
|
|
38
|
+
const lastNl = buf.lastIndexOf(0x0a);
|
|
39
|
+
if (lastNl < 0)
|
|
40
|
+
return false;
|
|
41
|
+
const lines = buf.subarray(0, lastNl + 1).toString('utf-8').split('\n');
|
|
42
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
43
|
+
const s = lines[i].trim();
|
|
44
|
+
if (!s)
|
|
45
|
+
continue; // skip trailing blank lines
|
|
46
|
+
try {
|
|
47
|
+
return TERMINAL.has(normalizeEvent(JSON.parse(s)).type);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return false; // last complete line is a partial/non-JSON write — not done
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Normalize one harness JSON line to a {@link StreamEvent}. Schemas differ per
|
|
57
|
+
* agent (there is no unified enum in the CLI), so we key off the common `type`
|
|
58
|
+
* field (Claude stream-json: `assistant` | `user` | `result` | `system`; a
|
|
59
|
+
* tool-use lives inside an `assistant` message's content) and carry `raw`
|
|
60
|
+
* through untouched so the client can render richer detail without the anchor
|
|
61
|
+
* having to model every agent.
|
|
62
|
+
*/
|
|
63
|
+
export function normalizeEvent(obj) {
|
|
64
|
+
const rec = obj;
|
|
65
|
+
const t = rec && typeof rec.type === 'string' ? rec.type : 'unknown';
|
|
66
|
+
return { type: t, raw: obj };
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Read complete NDJSON lines from `fromOffset`, byte-accurately. Each event
|
|
70
|
+
* carries the exact byte offset past its own newline so an SSE client can set
|
|
71
|
+
* `Last-Event-ID` per event and resume with neither loss nor duplication even
|
|
72
|
+
* if the connection drops mid-batch. A trailing partial line (no newline yet)
|
|
73
|
+
* is left unconsumed so the offset never splits a multi-byte event — the next
|
|
74
|
+
* read picks it up once the harness flushes the newline. Non-JSON preamble
|
|
75
|
+
* lines are skipped, not fatal.
|
|
76
|
+
*/
|
|
77
|
+
export function readNewEvents(file, fromOffset) {
|
|
78
|
+
let buf;
|
|
79
|
+
try {
|
|
80
|
+
buf = fs.readFileSync(file);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// File not created yet (run still starting) — nothing to read, hold offset.
|
|
84
|
+
return { events: [], newOffset: fromOffset, done: false };
|
|
85
|
+
}
|
|
86
|
+
// `done` reflects the whole file's terminal state, so a resume at/past the
|
|
87
|
+
// terminal event still closes the stream rather than hanging forever.
|
|
88
|
+
const done = fileIsDone(buf);
|
|
89
|
+
if (fromOffset >= buf.length)
|
|
90
|
+
return { events: [], newOffset: fromOffset, done };
|
|
91
|
+
const slice = buf.subarray(fromOffset);
|
|
92
|
+
const lastNl = slice.lastIndexOf(0x0a);
|
|
93
|
+
if (lastNl < 0)
|
|
94
|
+
return { events: [], newOffset: fromOffset, done }; // no complete line yet
|
|
95
|
+
const events = [];
|
|
96
|
+
let lineStart = 0; // byte index within slice
|
|
97
|
+
for (let i = 0; i <= lastNl; i++) {
|
|
98
|
+
if (slice[i] !== 0x0a)
|
|
99
|
+
continue;
|
|
100
|
+
const lineBuf = slice.subarray(lineStart, i); // excludes the newline
|
|
101
|
+
const endOffset = fromOffset + i + 1; // absolute byte offset just past '\n'
|
|
102
|
+
lineStart = i + 1;
|
|
103
|
+
const s = lineBuf.toString('utf-8').trim();
|
|
104
|
+
if (!s)
|
|
105
|
+
continue;
|
|
106
|
+
let obj;
|
|
107
|
+
try {
|
|
108
|
+
obj = JSON.parse(s);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
continue; // skip non-JSON (banner/preamble) lines
|
|
112
|
+
}
|
|
113
|
+
events.push({ event: normalizeEvent(obj), offset: endOffset });
|
|
114
|
+
}
|
|
115
|
+
return { events, newOffset: fromOffset + lastNl + 1, done };
|
|
116
|
+
}
|