@phnx-labs/agents-cli 1.20.64 → 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 +39 -3
- package/README.md +37 -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 +94 -49
- package/dist/commands/feed.js +25 -11
- package/dist/commands/hosts.js +44 -6
- 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 +23 -2
- 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.js +8 -3
- package/dist/commands/sessions.d.ts +16 -0
- package/dist/commands/sessions.js +36 -6
- package/dist/commands/ssh.js +45 -2
- package/dist/commands/versions.js +7 -3
- package/dist/commands/view.d.ts +26 -0
- package/dist/commands/view.js +32 -9
- package/dist/commands/webhook.js +10 -2
- package/dist/index.js +34 -14
- package/dist/lib/agents.d.ts +18 -0
- package/dist/lib/agents.js +53 -3
- 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/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/computer-rpc.js +3 -1
- package/dist/lib/crabbox/cli.js +5 -1
- package/dist/lib/crabbox/runtimes.js +11 -2
- package/dist/lib/daemon.d.ts +20 -4
- package/dist/lib/daemon.js +62 -19
- package/dist/lib/devices/fleet.d.ts +3 -2
- package/dist/lib/devices/fleet.js +9 -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/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/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 +16 -0
- package/dist/lib/routines.js +19 -0
- package/dist/lib/runner.d.ts +1 -0
- package/dist/lib/runner.js +102 -9
- 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 +14 -0
- package/dist/lib/session/bundle.js +12 -1
- 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/config.js +8 -2
- package/dist/lib/session/types.d.ts +30 -0
- package/dist/lib/ssh-tunnel.d.ts +19 -1
- package/dist/lib/ssh-tunnel.js +86 -7
- package/dist/lib/startup/command-registry.d.ts +2 -0
- package/dist/lib/startup/command-registry.js +4 -0
- package/dist/lib/state.d.ts +5 -0
- package/dist/lib/state.js +12 -0
- 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 +26 -1
- package/dist/lib/usage.js +7 -5
- 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
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
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** One issued token, stored by hash only. */
|
|
2
|
+
export interface ControlTokenRecord {
|
|
3
|
+
/** Short public id for display / revocation (not secret). */
|
|
4
|
+
id: string;
|
|
5
|
+
/** SHA-256 hex of the raw token. */
|
|
6
|
+
hash: string;
|
|
7
|
+
/** Human label, e.g. "muqsit-iphone". */
|
|
8
|
+
label: string;
|
|
9
|
+
createdAt: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Mint a new control token, persist its hash, and return the raw token ONCE.
|
|
13
|
+
* The caller must surface it to the operator immediately — it cannot be
|
|
14
|
+
* recovered later.
|
|
15
|
+
*/
|
|
16
|
+
export declare function addControlToken(label: string): {
|
|
17
|
+
id: string;
|
|
18
|
+
token: string;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Ensure at least one token exists. Returns the raw token only when it had to
|
|
22
|
+
* mint one (first `--control` boot); otherwise `{ created: false }` and the
|
|
23
|
+
* existing tokens stand.
|
|
24
|
+
*/
|
|
25
|
+
export declare function ensureControlToken(label?: string): {
|
|
26
|
+
created: true;
|
|
27
|
+
id: string;
|
|
28
|
+
token: string;
|
|
29
|
+
} | {
|
|
30
|
+
created: false;
|
|
31
|
+
};
|
|
32
|
+
/** True when `presented` matches a stored token hash (constant-time compare). */
|
|
33
|
+
export declare function verifyControlToken(presented: string | undefined): boolean;
|
|
34
|
+
/** List issued tokens (hashes only — safe to display). */
|
|
35
|
+
export declare function listControlTokens(): ControlTokenRecord[];
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bearer-token store for the authenticated `agents serve --control` server.
|
|
3
|
+
*
|
|
4
|
+
* Only the SHA-256 *hash* of each token is written to disk — the raw token is
|
|
5
|
+
* shown once at creation (like an API key) and never persisted, so a leaked
|
|
6
|
+
* store file cannot be replayed. This is the anchor-side credential the iOS
|
|
7
|
+
* cockpit presents on every request; `agents devices pair-ios` (Phase 3) mints
|
|
8
|
+
* additional per-device tokens through {@link addControlToken}.
|
|
9
|
+
*
|
|
10
|
+
* Store path: `<cache>/serve/control-tokens.json` — under `.cache/`, which is
|
|
11
|
+
* gitignored, so no credential material lands in a version-controlled repo.
|
|
12
|
+
*/
|
|
13
|
+
import fs from 'fs';
|
|
14
|
+
import path from 'path';
|
|
15
|
+
import { createHash, randomBytes, timingSafeEqual } from 'crypto';
|
|
16
|
+
import { getCacheDir } from '../state.js';
|
|
17
|
+
function storePath() {
|
|
18
|
+
return path.join(getCacheDir(), 'serve', 'control-tokens.json');
|
|
19
|
+
}
|
|
20
|
+
function readStore() {
|
|
21
|
+
try {
|
|
22
|
+
const raw = fs.readFileSync(storePath(), 'utf-8');
|
|
23
|
+
const parsed = JSON.parse(raw);
|
|
24
|
+
if (parsed && Array.isArray(parsed.tokens))
|
|
25
|
+
return parsed;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// Missing/corrupt store → start empty; a fresh token gets minted below.
|
|
29
|
+
}
|
|
30
|
+
return { tokens: [] };
|
|
31
|
+
}
|
|
32
|
+
function writeStore(store) {
|
|
33
|
+
const p = storePath();
|
|
34
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
35
|
+
// 0600: readable only by the owner — it holds token hashes. `mode` on
|
|
36
|
+
// writeFileSync is honored only at *creation*, so chmod every write to
|
|
37
|
+
// self-heal if the perms were ever widened externally (this is a credential
|
|
38
|
+
// file, even if only hashes).
|
|
39
|
+
fs.writeFileSync(p, JSON.stringify(store, null, 2), { mode: 0o600 });
|
|
40
|
+
fs.chmodSync(p, 0o600);
|
|
41
|
+
}
|
|
42
|
+
function sha256(s) {
|
|
43
|
+
return createHash('sha256').update(s).digest('hex');
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Mint a new control token, persist its hash, and return the raw token ONCE.
|
|
47
|
+
* The caller must surface it to the operator immediately — it cannot be
|
|
48
|
+
* recovered later.
|
|
49
|
+
*/
|
|
50
|
+
export function addControlToken(label) {
|
|
51
|
+
const store = readStore();
|
|
52
|
+
const id = randomBytes(4).toString('hex');
|
|
53
|
+
const token = randomBytes(32).toString('hex');
|
|
54
|
+
store.tokens.push({ id, hash: sha256(token), label, createdAt: new Date().toISOString() });
|
|
55
|
+
writeStore(store);
|
|
56
|
+
return { id, token };
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Ensure at least one token exists. Returns the raw token only when it had to
|
|
60
|
+
* mint one (first `--control` boot); otherwise `{ created: false }` and the
|
|
61
|
+
* existing tokens stand.
|
|
62
|
+
*/
|
|
63
|
+
export function ensureControlToken(label = 'default') {
|
|
64
|
+
const store = readStore();
|
|
65
|
+
if (store.tokens.length > 0)
|
|
66
|
+
return { created: false };
|
|
67
|
+
const { id, token } = addControlToken(label);
|
|
68
|
+
return { created: true, id, token };
|
|
69
|
+
}
|
|
70
|
+
/** True when `presented` matches a stored token hash (constant-time compare). */
|
|
71
|
+
export function verifyControlToken(presented) {
|
|
72
|
+
if (!presented)
|
|
73
|
+
return false;
|
|
74
|
+
const want = Buffer.from(sha256(presented), 'hex');
|
|
75
|
+
for (const rec of readStore().tokens) {
|
|
76
|
+
const have = Buffer.from(rec.hash, 'hex');
|
|
77
|
+
if (have.length === want.length && timingSafeEqual(have, want))
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
/** List issued tokens (hashes only — safe to display). */
|
|
83
|
+
export function listControlTokens() {
|
|
84
|
+
return readStore().tokens;
|
|
85
|
+
}
|
|
@@ -76,6 +76,12 @@ export interface BuildRecordOpts {
|
|
|
76
76
|
redact: boolean;
|
|
77
77
|
/** Non-null → seal each body with this key; null → plaintext bodies. */
|
|
78
78
|
encryptKey: Buffer | null;
|
|
79
|
+
/**
|
|
80
|
+
* Literal secret values to mask verbatim during redaction (value-aware pass):
|
|
81
|
+
* e.g. live credentials from an injected secrets bundle, so they never leak in
|
|
82
|
+
* an exported transcript regardless of format. Only used when `redact` is set.
|
|
83
|
+
*/
|
|
84
|
+
knownSecrets?: readonly string[];
|
|
79
85
|
}
|
|
80
86
|
/** Look up the sync spec for an agent id (undefined → agent not sync-representable). */
|
|
81
87
|
export declare function specForAgent(agentId: string): SyncAgentSpec | undefined;
|
|
@@ -104,6 +110,14 @@ export declare function makeHeader(args: {
|
|
|
104
110
|
export declare function mergeRecords(sets: BundleRecord[][]): BundleRecord[];
|
|
105
111
|
/** Serialize a bundle to its NDJSON wire form (header line + one line per record). */
|
|
106
112
|
export declare function serializeBundle(header: BundleHeader, records: BundleRecord[]): string;
|
|
113
|
+
/**
|
|
114
|
+
* Write a serialized bundle to disk owner-only (0600). A bundle carries raw
|
|
115
|
+
* transcript bodies — even redacted, never world/group-readable — and encryption
|
|
116
|
+
* is opt-in, so the file mode is the baseline confidentiality guard. `mode` on
|
|
117
|
+
* `writeFileSync` only applies when the file is created, so we `chmod` too to
|
|
118
|
+
* clamp an existing (possibly looser) file on overwrite.
|
|
119
|
+
*/
|
|
120
|
+
export declare function writeBundleFile(outPath: string, wire: string): void;
|
|
107
121
|
/** Parse an NDJSON bundle, validating the header kind + version. Throws on malformed input. */
|
|
108
122
|
export declare function parseBundle(text: string): ParsedBundle;
|
|
109
123
|
/** Placement outcome for one record, computed against what is already on disk. */
|
|
@@ -42,7 +42,7 @@ export function isExportableAgent(agentId) {
|
|
|
42
42
|
export function buildRecord(file, opts) {
|
|
43
43
|
let body = fs.readFileSync(file.absPath, 'utf-8');
|
|
44
44
|
if (opts.redact)
|
|
45
|
-
body = redactSecrets(body);
|
|
45
|
+
body = redactSecrets(body, opts.knownSecrets);
|
|
46
46
|
const hash = hashContent(body);
|
|
47
47
|
const size = Buffer.byteLength(body, 'utf-8');
|
|
48
48
|
let stored = body;
|
|
@@ -105,6 +105,17 @@ export function serializeBundle(header, records) {
|
|
|
105
105
|
lines.push(JSON.stringify(r));
|
|
106
106
|
return lines.join('\n') + '\n';
|
|
107
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Write a serialized bundle to disk owner-only (0600). A bundle carries raw
|
|
110
|
+
* transcript bodies — even redacted, never world/group-readable — and encryption
|
|
111
|
+
* is opt-in, so the file mode is the baseline confidentiality guard. `mode` on
|
|
112
|
+
* `writeFileSync` only applies when the file is created, so we `chmod` too to
|
|
113
|
+
* clamp an existing (possibly looser) file on overwrite.
|
|
114
|
+
*/
|
|
115
|
+
export function writeBundleFile(outPath, wire) {
|
|
116
|
+
fs.writeFileSync(outPath, wire, { encoding: 'utf-8', mode: 0o600 });
|
|
117
|
+
fs.chmodSync(outPath, 0o600);
|
|
118
|
+
}
|
|
108
119
|
/** Parse an NDJSON bundle, validating the header kind + version. Throws on malformed input. */
|
|
109
120
|
export function parseBundle(text) {
|
|
110
121
|
const lines = text.split('\n').filter(l => l.trim().length > 0);
|
|
@@ -18,7 +18,7 @@ import chalk from 'chalk';
|
|
|
18
18
|
import { SSH_OPTS, controlOpts, assertValidSshTarget, shellQuote } from '../ssh-exec.js';
|
|
19
19
|
import { sshTargetFor } from '../devices/connect.js';
|
|
20
20
|
import { resolveExplicitTargets } from '../devices/resolve-target.js';
|
|
21
|
-
import { loadDevices } from '../devices/registry.js';
|
|
21
|
+
import { loadDevices, isControlDevice } from '../devices/registry.js';
|
|
22
22
|
import { remoteShellFor, buildWindowsAgentsCommand } from '../hosts/remote-cmd.js';
|
|
23
23
|
import { machineId, normalizeHost } from './sync/config.js';
|
|
24
24
|
import { NO_FANOUT_ENV } from './remote-active.js';
|
|
@@ -129,6 +129,10 @@ export async function gatherRemoteList(forwardedArgs, hosts) {
|
|
|
129
129
|
continue;
|
|
130
130
|
if (normalizeHost(d.name) === self)
|
|
131
131
|
continue;
|
|
132
|
+
// Control-only devices (a phone/tablet running the cockpit) drive the fleet
|
|
133
|
+
// but never run agents — never dial them, whatever their platform reads as.
|
|
134
|
+
if (isControlDevice(d))
|
|
135
|
+
continue;
|
|
132
136
|
// Only machines that can actually run the CLI. iOS/tablet nodes register as
|
|
133
137
|
// `unknown` platform and can never answer, so skip them rather than burn a
|
|
134
138
|
// full ConnectTimeout on each.
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
* markers. Codex has no such tools, so it falls back to last-role + question
|
|
15
15
|
* shape + mtime — same function, driven off the normalized events.
|
|
16
16
|
*/
|
|
17
|
-
import type { SessionAttachment, SessionEvent } from './types.js';
|
|
17
|
+
import type { SessionAttachment, SessionEvent, TodoItem, TodoProgress } from './types.js';
|
|
18
|
+
export type { TodoItem, TodoProgress };
|
|
18
19
|
export type SessionActivity = 'working' | 'waiting_input' | 'idle';
|
|
19
20
|
export type AwaitingReason = 'question' | 'plan_review' | 'permission';
|
|
20
21
|
/** One discrete choice the agent offered the user. */
|
|
@@ -41,28 +42,6 @@ export interface StructuredQuestion {
|
|
|
41
42
|
reason: AwaitingReason;
|
|
42
43
|
options?: QuestionOption[];
|
|
43
44
|
}
|
|
44
|
-
/** One `TodoWrite` checklist item, as Claude's plan tool emits it. */
|
|
45
|
-
export interface TodoItem {
|
|
46
|
-
content: string;
|
|
47
|
-
status: 'pending' | 'in_progress' | 'completed';
|
|
48
|
-
/** Present-continuous label shown while this item is the active step. */
|
|
49
|
-
activeForm?: string;
|
|
50
|
-
}
|
|
51
|
-
/**
|
|
52
|
-
* Live plan progress derived from the most recent `TodoWrite` in the transcript
|
|
53
|
-
* (RUSH-1380). Lets the Factory Floor show "N/M done" + the current step for any
|
|
54
|
-
* session — including remote / device-dispatched agents that carry no local
|
|
55
|
-
* tool-call stream — instead of only a coarse working/idle verb.
|
|
56
|
-
*/
|
|
57
|
-
export interface TodoProgress {
|
|
58
|
-
items: TodoItem[];
|
|
59
|
-
/** Count of completed items. */
|
|
60
|
-
done: number;
|
|
61
|
-
/** Total items. */
|
|
62
|
-
total: number;
|
|
63
|
-
/** The in-progress item's activeForm (falls back to its content). The live step. */
|
|
64
|
-
activeForm?: string;
|
|
65
|
-
}
|
|
66
45
|
export interface DetectedPr {
|
|
67
46
|
url: string;
|
|
68
47
|
number?: number;
|
|
@@ -140,8 +119,11 @@ export interface StateContext {
|
|
|
140
119
|
activeWindowMs?: number;
|
|
141
120
|
}
|
|
142
121
|
/**
|
|
143
|
-
* Derive live plan progress from a
|
|
144
|
-
*
|
|
122
|
+
* Derive live plan progress from a checklist tool call's args. Accepts both
|
|
123
|
+
* Claude's `TodoWrite` (`todos: [{content,status,activeForm}]`) and Codex's
|
|
124
|
+
* `update_plan` (`plan: [{step,status}]`) shapes, so the CLI is the single source
|
|
125
|
+
* of checklist state for every agent. Returns undefined when there is no usable
|
|
126
|
+
* list, so a session with no plan carries no `todos` field.
|
|
145
127
|
*/
|
|
146
128
|
export declare function extractTodoProgress(args?: Record<string, any>): TodoProgress | undefined;
|
|
147
129
|
/** Detect a worktree from the session cwd, per the `.agents/worktrees/<slug>/` convention. */
|
|
@@ -47,14 +47,22 @@ const PROSE_QUESTION_FRESH_MS = 30 * 60_000;
|
|
|
47
47
|
/** Claude tool names that structurally mean "the agent handed control back to you". */
|
|
48
48
|
const PLAN_TOOL = 'ExitPlanMode';
|
|
49
49
|
const ASK_TOOL = 'AskUserQuestion';
|
|
50
|
-
/**
|
|
50
|
+
/** The live plan/checklist tools: Claude's `TodoWrite` and Codex's `update_plan`. */
|
|
51
51
|
const TODO_TOOL = 'TodoWrite';
|
|
52
|
+
const CODEX_PLAN_TOOL = 'update_plan';
|
|
52
53
|
/**
|
|
53
|
-
* Derive live plan progress from a
|
|
54
|
-
*
|
|
54
|
+
* Derive live plan progress from a checklist tool call's args. Accepts both
|
|
55
|
+
* Claude's `TodoWrite` (`todos: [{content,status,activeForm}]`) and Codex's
|
|
56
|
+
* `update_plan` (`plan: [{step,status}]`) shapes, so the CLI is the single source
|
|
57
|
+
* of checklist state for every agent. Returns undefined when there is no usable
|
|
58
|
+
* list, so a session with no plan carries no `todos` field.
|
|
55
59
|
*/
|
|
56
60
|
export function extractTodoProgress(args) {
|
|
57
|
-
const raw = args?.todos
|
|
61
|
+
const raw = Array.isArray(args?.todos)
|
|
62
|
+
? args.todos
|
|
63
|
+
: Array.isArray(args?.plan)
|
|
64
|
+
? args.plan
|
|
65
|
+
: undefined;
|
|
58
66
|
if (!Array.isArray(raw) || raw.length === 0)
|
|
59
67
|
return undefined;
|
|
60
68
|
const items = [];
|
|
@@ -64,7 +72,9 @@ export function extractTodoProgress(args) {
|
|
|
64
72
|
? t.content
|
|
65
73
|
: typeof t?.text === 'string' && t.text
|
|
66
74
|
? t.text
|
|
67
|
-
:
|
|
75
|
+
: typeof t?.step === 'string' && t.step
|
|
76
|
+
? t.step
|
|
77
|
+
: activeForm ?? '';
|
|
68
78
|
if (!content)
|
|
69
79
|
continue;
|
|
70
80
|
const status = t?.status === 'completed' || t?.status === 'in_progress' ? t.status : 'pending';
|
|
@@ -318,7 +328,7 @@ export function inferActivity(events, ctx = {}) {
|
|
|
318
328
|
// Live plan progress: the most recent TodoWrite's checklist (RUSH-1380). Attached
|
|
319
329
|
// to `base` so every return path below carries it — a working, waiting, or idle
|
|
320
330
|
// session all keep showing how far the plan got.
|
|
321
|
-
const lastTodo = lastOf(meaningful, e => e.type === 'tool_use' && e.tool === TODO_TOOL);
|
|
331
|
+
const lastTodo = lastOf(meaningful, e => e.type === 'tool_use' && (e.tool === TODO_TOOL || e.tool === CODEX_PLAN_TOOL));
|
|
322
332
|
const todos = lastTodo ? extractTodoProgress(lastTodo.args) : undefined;
|
|
323
333
|
const base = {
|
|
324
334
|
activity: 'idle',
|