@phnx-labs/agents-cli 1.22.114 → 1.22.115

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.
Files changed (176) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/README.md +22 -102
  3. package/dist/browser.js +0 -0
  4. package/dist/cli/command-registry.js +0 -7
  5. package/dist/commands/accounts.d.ts +3 -77
  6. package/dist/commands/accounts.js +13 -366
  7. package/dist/commands/audit.js +1 -1
  8. package/dist/commands/auth.js +0 -2
  9. package/dist/commands/browser.d.ts +53 -0
  10. package/dist/commands/browser.js +199 -9
  11. package/dist/commands/doctor.d.ts +2 -3
  12. package/dist/commands/doctor.js +0 -11
  13. package/dist/commands/events.d.ts +1 -3
  14. package/dist/commands/events.js +4 -7
  15. package/dist/commands/exec.d.ts +0 -1
  16. package/dist/commands/exec.js +2 -7
  17. package/dist/commands/feed-watch.js +52 -8
  18. package/dist/commands/focus.js +1 -1
  19. package/dist/commands/go.d.ts +0 -17
  20. package/dist/commands/go.js +2 -19
  21. package/dist/commands/logs.js +1 -1
  22. package/dist/commands/mcp.js +8 -83
  23. package/dist/commands/memory.js +4 -47
  24. package/dist/commands/message.js +4 -4
  25. package/dist/commands/plugins.js +1 -93
  26. package/dist/commands/repo.js +0 -44
  27. package/dist/commands/resume.js +4 -1
  28. package/dist/commands/secrets-passthrough.js +2 -2
  29. package/dist/commands/send.d.ts +4 -4
  30. package/dist/commands/send.js +6 -51
  31. package/dist/commands/sessions-backup-setup.js +1 -1
  32. package/dist/commands/sessions-resume.js +0 -1
  33. package/dist/commands/sessions-share.d.ts +5 -7
  34. package/dist/commands/sessions-share.js +98 -49
  35. package/dist/commands/sessions.js +1 -8
  36. package/dist/commands/setup-browser.js +18 -2
  37. package/dist/commands/setup-computer.js +20 -6
  38. package/dist/commands/setup-secrets.js +24 -4
  39. package/dist/commands/setup-terminal.d.ts +3 -0
  40. package/dist/commands/setup-terminal.js +22 -0
  41. package/dist/commands/setup.d.ts +1 -1
  42. package/dist/commands/setup.js +20 -10
  43. package/dist/commands/skills.js +0 -8
  44. package/dist/commands/ssh.d.ts +6 -0
  45. package/dist/commands/ssh.js +92 -233
  46. package/dist/commands/sync.js +14 -5
  47. package/dist/commands/teams.js +1 -1
  48. package/dist/commands/traces.js +1 -1
  49. package/dist/lib/accounts/add.d.ts +0 -5
  50. package/dist/lib/accounts/add.js +1 -7
  51. package/dist/lib/artifacts-client.d.ts +20 -0
  52. package/dist/lib/artifacts-client.js +46 -0
  53. package/dist/lib/auth-mint.js +2 -2
  54. package/dist/lib/browser/runtime-state.d.ts +55 -0
  55. package/dist/lib/browser/runtime-state.js +99 -18
  56. package/dist/lib/browser/service.js +21 -1
  57. package/dist/lib/cli-resources.js +3 -1
  58. package/dist/lib/cloud/dispatch.js +1 -1
  59. package/dist/lib/cloudflare/creds.d.ts +10 -0
  60. package/dist/lib/cloudflare/creds.js +46 -0
  61. package/dist/lib/cloudflare/provision.d.ts +35 -0
  62. package/dist/lib/cloudflare/provision.js +144 -0
  63. package/dist/lib/computer/sessions-list.d.ts +55 -0
  64. package/dist/lib/computer/sessions-list.js +168 -1
  65. package/dist/lib/daemon/daemon.js +8 -1
  66. package/dist/lib/daemon/feed-stream-service.d.ts +23 -0
  67. package/dist/lib/daemon/feed-stream-service.js +40 -0
  68. package/dist/lib/daemon-services.d.ts +1 -1
  69. package/dist/lib/daemon-services.js +5 -0
  70. package/dist/lib/devices/connect.d.ts +49 -5
  71. package/dist/lib/devices/connect.js +169 -21
  72. package/dist/lib/feed/envelope.d.ts +79 -0
  73. package/dist/lib/feed/envelope.js +23 -0
  74. package/dist/lib/feed/events.d.ts +6 -0
  75. package/dist/lib/feed/events.js +8 -0
  76. package/dist/lib/feed/hub-server.d.ts +86 -0
  77. package/dist/lib/feed/hub-server.js +334 -0
  78. package/dist/lib/feed/hub.d.ts +95 -0
  79. package/dist/lib/feed/hub.js +255 -0
  80. package/dist/lib/feed/tool-activity.d.ts +108 -0
  81. package/dist/lib/feed/tool-activity.js +313 -0
  82. package/dist/lib/feed/tools.d.ts +198 -0
  83. package/dist/lib/feed/tools.js +265 -0
  84. package/dist/lib/feed/watch.d.ts +50 -50
  85. package/dist/lib/feed/watch.js +147 -16
  86. package/dist/lib/format.d.ts +1 -1
  87. package/dist/lib/format.js +1 -1
  88. package/dist/lib/git.d.ts +0 -16
  89. package/dist/lib/git.js +0 -58
  90. package/dist/lib/helper-versions.js +1 -1
  91. package/dist/lib/hosts/remote-cmd.d.ts +51 -1
  92. package/dist/lib/hosts/remote-cmd.js +125 -8
  93. package/dist/lib/hosts/remote-cmd.test-fixture.d.ts +2 -0
  94. package/dist/lib/hosts/remote-cmd.test-fixture.js +22 -0
  95. package/dist/lib/mcp.js +17 -11
  96. package/dist/lib/probe.d.ts +4 -1
  97. package/dist/lib/probe.js +5 -2
  98. package/dist/lib/pwsh.d.ts +33 -0
  99. package/dist/lib/pwsh.js +56 -0
  100. package/dist/lib/redact.d.ts +8 -0
  101. package/dist/lib/redact.js +11 -0
  102. package/dist/lib/refresh.d.ts +6 -2
  103. package/dist/lib/refresh.js +92 -72
  104. package/dist/lib/secrets-cli.d.ts +11 -0
  105. package/dist/lib/secrets-cli.js +30 -0
  106. package/dist/lib/secrets-client.js +3 -2
  107. package/dist/lib/session/detached.d.ts +7 -0
  108. package/dist/lib/session/detached.js +29 -0
  109. package/dist/lib/session/remote/peer-stream.d.ts +24 -2
  110. package/dist/lib/session/remote/peer-stream.js +33 -6
  111. package/dist/lib/session/sync/backend.d.ts +3 -3
  112. package/dist/lib/session/sync/backend.js +3 -3
  113. package/dist/lib/session/sync/provision.d.ts +1 -1
  114. package/dist/lib/session/sync/provision.js +2 -2
  115. package/dist/lib/sessions-client.js +0 -3
  116. package/dist/lib/setup-tool-install.d.ts +3 -0
  117. package/dist/lib/setup-tool-install.js +26 -0
  118. package/dist/lib/setup-tool-status.d.ts +22 -0
  119. package/dist/lib/setup-tool-status.js +215 -0
  120. package/dist/lib/share-runtime.d.ts +11 -0
  121. package/dist/lib/share-runtime.js +63 -0
  122. package/dist/lib/smart-launch.d.ts +1 -5
  123. package/dist/lib/smart-launch.js +3 -11
  124. package/dist/lib/ssh-exec.d.ts +44 -0
  125. package/dist/lib/ssh-exec.js +119 -0
  126. package/dist/lib/startup/command-registry.d.ts +6 -4
  127. package/dist/lib/startup/command-registry.js +10 -7
  128. package/dist/lib/state.js +2 -2
  129. package/dist/lib/storage/selection.d.ts +2 -2
  130. package/dist/lib/storage/selection.js +2 -2
  131. package/dist/lib/sync-umbrella.d.ts +5 -0
  132. package/dist/lib/sync-umbrella.js +18 -10
  133. package/dist/lib/traces/backend.d.ts +1 -2
  134. package/dist/lib/traces/backend.js +1 -2
  135. package/dist/lib/traces/provision.d.ts +1 -1
  136. package/dist/lib/traces/provision.js +2 -2
  137. package/dist/lib/types.d.ts +8 -6
  138. package/package.json +2 -3
  139. package/dist/commands/artifacts-setup.d.ts +0 -53
  140. package/dist/commands/artifacts-setup.js +0 -161
  141. package/dist/commands/artifacts.d.ts +0 -18
  142. package/dist/commands/artifacts.js +0 -58
  143. package/dist/commands/attach.d.ts +0 -12
  144. package/dist/commands/attach.js +0 -86
  145. package/dist/commands/auth-mint.d.ts +0 -12
  146. package/dist/commands/auth-mint.js +0 -108
  147. package/dist/commands/reconnect.d.ts +0 -46
  148. package/dist/commands/reconnect.js +0 -115
  149. package/dist/commands/share.d.ts +0 -293
  150. package/dist/commands/share.js +0 -1424
  151. package/dist/lib/share/analytics.d.ts +0 -13
  152. package/dist/lib/share/analytics.js +0 -45
  153. package/dist/lib/share/backend.d.ts +0 -120
  154. package/dist/lib/share/backend.js +0 -176
  155. package/dist/lib/share/capture.d.ts +0 -31
  156. package/dist/lib/share/capture.js +0 -174
  157. package/dist/lib/share/config.d.ts +0 -72
  158. package/dist/lib/share/config.js +0 -211
  159. package/dist/lib/share/delete.d.ts +0 -123
  160. package/dist/lib/share/delete.js +0 -173
  161. package/dist/lib/share/html.d.ts +0 -20
  162. package/dist/lib/share/html.js +0 -88
  163. package/dist/lib/share/http-error.d.ts +0 -53
  164. package/dist/lib/share/http-error.js +0 -65
  165. package/dist/lib/share/og.d.ts +0 -26
  166. package/dist/lib/share/og.js +0 -84
  167. package/dist/lib/share/provision.d.ts +0 -127
  168. package/dist/lib/share/provision.js +0 -285
  169. package/dist/lib/share/publish.d.ts +0 -379
  170. package/dist/lib/share/publish.js +0 -818
  171. package/dist/lib/share/worker-template.d.ts +0 -27
  172. package/dist/lib/share/worker-template.js +0 -2424
  173. package/dist/lib/storage/index.d.ts +0 -14
  174. package/dist/lib/storage/index.js +0 -14
  175. package/dist/lib/storage/visibility.d.ts +0 -82
  176. package/dist/lib/storage/visibility.js +0 -99
@@ -0,0 +1,255 @@
1
+ /**
2
+ * One fleet fan-out, many readers.
3
+ *
4
+ * THE COST THIS EXISTS TO AVOID. `watchFleetFeed` opens a persistent
5
+ * `ssh <peer> agents feed watch --json --local` per dialable device — that is
6
+ * correct, and it is also per CALLER. Every consumer that wanted the operator
7
+ * stream ran its own copy: the extension's leader child, a menu-bar helper, an
8
+ * operator's `agents feed watch --json`. Three readers on a thirteen-device
9
+ * fleet is thirty-nine long-lived ssh children carrying byte-identical NDJSON,
10
+ * three copies of the local activity cursor, and three independent backoff
11
+ * ladders that each re-dial the same offline box.
12
+ *
13
+ * WHAT REPLACES IT. The hub owns exactly ONE {@link watchFleetFeed} — so one ssh
14
+ * child per reachable peer, one collector, one backoff ladder — and broadcasts
15
+ * to every subscriber. The fan-out starts on the FIRST subscriber and stops on
16
+ * the LAST, so an idle box with no reader open holds no peer connections at all.
17
+ *
18
+ * A LATE SUBSCRIBER COSTS NOTHING. The hub keeps the per-scope row state the
19
+ * stream has delivered so far, so subscriber two is served a synthesized reset
20
+ * per scope out of that state — no second fan-out, no re-dial, no waiting for
21
+ * the peers to re-announce. Its `streamId`/`sequence` are its own and start at
22
+ * 1, which is exactly what the published contract ("order by streamId +
23
+ * sequence") lets a consumer rely on.
24
+ *
25
+ * RESET SEMANTICS ARE PRESERVED, NOT REINVENTED. A peer's reset replaces that
26
+ * ONE scope's rows in the held state and is forwarded verbatim; a peer going
27
+ * unavailable forwards the `scope` event and leaves its rows in place, so a
28
+ * reader that attaches while a box is offline still sees that box's last-known
29
+ * rows marked unavailable rather than an empty fleet.
30
+ */
31
+ import { machineId, normalizeHost } from '../machine-id.js';
32
+ import { FeedWatchState } from './envelope.js';
33
+ /** Activity events replayed to a late subscriber. The lane is a rolling view,
34
+ * so a bounded tail is the honest amount of history to hand over. */
35
+ export const HUB_ACTIVITY_REPLAY = 50;
36
+ function emptyScope() {
37
+ return { agents: new Map(), attention: new Map(), tools: new Map(), setup: [], capturedAt: 0 };
38
+ }
39
+ /**
40
+ * The per-scope row state the hub has observed. This is a projection of the
41
+ * events already delivered, never an independent gather: nothing here reads a
42
+ * file, runs a command, or dials a peer.
43
+ */
44
+ export class FeedHubState {
45
+ scopes = new Map();
46
+ activity = [];
47
+ scope(name) {
48
+ const key = normalizeHost(name);
49
+ let scope = this.scopes.get(key);
50
+ if (!scope) {
51
+ scope = emptyScope();
52
+ this.scopes.set(key, scope);
53
+ }
54
+ return scope;
55
+ }
56
+ apply(event) {
57
+ const scope = this.scope(event.scope);
58
+ switch (event.type) {
59
+ case 'reset':
60
+ // One scope only. A peer reconnecting must not erase another's rows.
61
+ scope.agents = new Map(event.agents.map((agent) => [agent.rowKey, agent]));
62
+ scope.attention = new Map(event.attention.map((item) => [item.key, item]));
63
+ scope.tools = new Map(event.tools.map((tool) => [tool.rowKey, tool]));
64
+ scope.setup = event.setup;
65
+ scope.capturedAt = event.capturedAt;
66
+ break;
67
+ case 'agent.upsert':
68
+ scope.agents.set(event.rowKey, event.agent);
69
+ break;
70
+ case 'agent.remove':
71
+ scope.agents.delete(event.rowKey);
72
+ break;
73
+ case 'attention.upsert':
74
+ scope.attention.set(event.rowKey, event.attention);
75
+ break;
76
+ case 'attention.remove':
77
+ scope.attention.delete(event.rowKey);
78
+ break;
79
+ case 'tool.upsert':
80
+ scope.tools.set(event.rowKey, event.tool);
81
+ break;
82
+ case 'tool.remove':
83
+ scope.tools.delete(event.rowKey);
84
+ break;
85
+ case 'setup.snapshot':
86
+ scope.setup = event.setup;
87
+ break;
88
+ case 'scope':
89
+ // Rows are deliberately RETAINED: transient fleet loss is not session
90
+ // death, the same rule `watchLocalSessions` states for its own scope.
91
+ scope.status = { status: event.status, ...(event.reason ? { reason: event.reason } : {}) };
92
+ break;
93
+ case 'activity.append':
94
+ this.activity.push(event.event);
95
+ if (this.activity.length > HUB_ACTIVITY_REPLAY)
96
+ this.activity.shift();
97
+ break;
98
+ case 'heartbeat': break;
99
+ }
100
+ }
101
+ /** The envelopes that bring a fresh subscriber to the current state. */
102
+ snapshot(state) {
103
+ const out = [];
104
+ for (const [name, scope] of this.scopes) {
105
+ out.push(state.emit({
106
+ type: 'reset', scope: name, capturedAt: scope.capturedAt || Date.now(),
107
+ agents: [...scope.agents.values()], attention: [...scope.attention.values()],
108
+ tools: [...scope.tools.values()], setup: scope.setup,
109
+ }));
110
+ if (scope.status)
111
+ out.push(state.emit({ type: 'scope', scope: name, capturedAt: Date.now(), ...scope.status }));
112
+ }
113
+ // Chronological, matching the order they were first delivered in.
114
+ for (const event of this.activity) {
115
+ out.push(state.emit({ type: 'activity.append', scope: normalizeHost(machineId()), event }));
116
+ }
117
+ return out;
118
+ }
119
+ /** Scopes the hub has seen. Observability + tests. */
120
+ get scopeNames() { return [...this.scopes.keys()]; }
121
+ }
122
+ /**
123
+ * The shared collector. Construct one per process; call {@link subscribe} per
124
+ * reader.
125
+ */
126
+ export class FeedHub {
127
+ options;
128
+ subscribers = new Set();
129
+ held = new FeedHubState();
130
+ controller = null;
131
+ running = null;
132
+ /**
133
+ * Bumped on every start/stop. A fan-out's completion handler only clears state
134
+ * when its own generation is still current, so a run winding down cannot clear
135
+ * a newer one's controller.
136
+ */
137
+ generation = 0;
138
+ /** The most recent fan-out failure, if the current generation hit one. */
139
+ lastFailure = null;
140
+ /**
141
+ * Called when the fan-out rejects. Without this the failure lived only in a
142
+ * dropped promise, so readers sat attached to a collector that had already died
143
+ * and saw an idle stream instead of an error. Settable so the socket server can
144
+ * attach after construction.
145
+ */
146
+ onFailure = null;
147
+ watch;
148
+ constructor(options) {
149
+ this.options = options;
150
+ this.watch = options.watch;
151
+ }
152
+ /** Readers currently attached. The fan-out runs iff this is > 0. */
153
+ get readerCount() { return this.subscribers.size; }
154
+ /** Is the single shared fan-out running right now? */
155
+ get active() { return this.controller !== null; }
156
+ /** The held per-scope state, for observability and tests. */
157
+ get state() { return this.held; }
158
+ /**
159
+ * Attach a reader. It is immediately served a snapshot of the held state, then
160
+ * every later event. Returns the detach function; the fan-out stops when the
161
+ * last reader detaches.
162
+ */
163
+ subscribe(emit) {
164
+ const subscriber = { emit, state: new FeedWatchState() };
165
+ this.subscribers.add(subscriber);
166
+ for (const event of this.held.snapshot(subscriber.state))
167
+ emit(event);
168
+ this.start();
169
+ let detached = false;
170
+ return () => {
171
+ if (detached)
172
+ return;
173
+ detached = true;
174
+ this.subscribers.delete(subscriber);
175
+ if (this.subscribers.size === 0)
176
+ this.stop();
177
+ };
178
+ }
179
+ /** Stop the fan-out and detach every reader. */
180
+ async close() {
181
+ this.subscribers.clear();
182
+ this.stop();
183
+ const running = this.running;
184
+ this.running = null;
185
+ if (running)
186
+ await running.catch(() => { });
187
+ }
188
+ /** Await the in-flight fan-out's teardown. Tests assert no overlap with it. */
189
+ async settled() {
190
+ await this.running?.catch(() => { });
191
+ }
192
+ /**
193
+ * Start the single fan-out, waiting for any previous one to finish first.
194
+ *
195
+ * The wait is the whole point. `stop()` aborts and returns immediately, but the
196
+ * fan-out it aborted is still tearing down ssh children. A reader that detaches
197
+ * and immediately reattaches — a VS Code window reloading, a menu-bar popover
198
+ * closing and reopening — therefore used to start a SECOND fan-out alongside
199
+ * the dying one: two ssh children per peer, two collectors, for as long as the
200
+ * overlap lasted. Serializing on the previous run makes that impossible.
201
+ */
202
+ start() {
203
+ if (this.controller)
204
+ return;
205
+ const generation = ++this.generation;
206
+ this.lastFailure = null;
207
+ const controller = new AbortController();
208
+ this.controller = controller;
209
+ const previous = this.running ?? Promise.resolve();
210
+ this.running = previous
211
+ .catch(() => { })
212
+ .then(() => {
213
+ // The readers may all have left while we waited for the old run to
214
+ // drain; starting then would dial every peer for nobody.
215
+ if (generation !== this.generation || controller.signal.aborted)
216
+ return;
217
+ return this.watch({
218
+ signal: controller.signal,
219
+ ...(this.options.reconnectMs !== undefined ? { reconnectMs: this.options.reconnectMs } : {}),
220
+ // Generation-guarded: an aborted fan-out can still emit while it drains
221
+ // (a peer's last buffered line, a pending promise resolving). Those
222
+ // envelopes describe the OLD subscription and must not reach the new
223
+ // generation's readers or mutate its held state.
224
+ emit: (event) => { if (generation === this.generation)
225
+ this.broadcast(event); },
226
+ });
227
+ })
228
+ .catch((error) => {
229
+ // Surfaced, never swallowed: a reader attached to a dead collector would
230
+ // otherwise be indistinguishable from a quiet fleet.
231
+ if (generation === this.generation) {
232
+ this.lastFailure = error instanceof Error ? error : new Error(String(error));
233
+ this.onFailure?.(this.lastFailure);
234
+ }
235
+ })
236
+ .finally(() => {
237
+ // Only clear if this is still the live run: a stop/start cycle may have
238
+ // replaced it, and clearing then would strand the newer controller.
239
+ if (generation === this.generation)
240
+ this.controller = null;
241
+ });
242
+ }
243
+ stop() {
244
+ this.generation += 1;
245
+ this.controller?.abort();
246
+ this.controller = null;
247
+ }
248
+ broadcast(event) {
249
+ this.held.apply(event);
250
+ const { v: _v, streamId: _streamId, sequence: _sequence, ...payload } = event;
251
+ for (const subscriber of this.subscribers) {
252
+ subscriber.emit(subscriber.state.emit(payload));
253
+ }
254
+ }
255
+ }
@@ -0,0 +1,108 @@
1
+ import { type BrowserSessionRow } from '../browser/sessions-list.js';
2
+ import { type ComputerRunRow } from '../computer/sessions-list.js';
3
+ import type { LiveBrowserTask } from './tools.js';
4
+ import { type ToolRow } from './tools.js';
5
+ /** Re-projection cadence used only while no directory watcher could arm. */
6
+ export declare const TOOL_SWEEP_MS = 5000;
7
+ /** What one re-projection changed. Empty on both sides means nothing moved. */
8
+ export interface ToolDiff {
9
+ upserts: ToolRow[];
10
+ removes: string[];
11
+ }
12
+ interface ToolSources {
13
+ browserRows?: () => BrowserSessionRow[];
14
+ computerRows?: () => ComputerRunRow[];
15
+ bindings?: () => Array<{
16
+ name: string;
17
+ device?: string;
18
+ profile?: string;
19
+ url?: string;
20
+ createdAt?: number;
21
+ sessionId?: string;
22
+ launchId?: string;
23
+ }>;
24
+ liveTasks?: () => LiveBrowserTask[];
25
+ }
26
+ /**
27
+ * One projection attempt. `complete` is false when any source threw.
28
+ *
29
+ * The flag is load-bearing, not diagnostic. A transient read failure — the
30
+ * browser rewriting `tasks.json`, a rotating ledger, an EMFILE — used to yield an
31
+ * EMPTY row list, which the differ then read as "every task closed" and published
32
+ * as a remove for every row. The operator watched their live tasks vanish and
33
+ * come back. An incomplete projection is not evidence of absence, so the caller
34
+ * keeps the state it already had.
35
+ */
36
+ export interface ToolSnapshot {
37
+ rows: ToolRow[];
38
+ complete: boolean;
39
+ }
40
+ /**
41
+ * Project every browser task and computer run this machine knows about into
42
+ * canonical tool rows, newest first. Impure by design — the three readers are
43
+ * injectable so a test drives real temp stores rather than a mocked service.
44
+ */
45
+ export declare function collectToolRows(scope: string, sources?: ToolSources): ToolSnapshot;
46
+ /**
47
+ * Holds the last projected row set and answers "what changed?".
48
+ *
49
+ * Row identity is the projection's own `rowKey`, so a browser task that gains a
50
+ * capture upserts under the same key, and a closed task — gone from both the
51
+ * index and the capture tree — comes back as a remove.
52
+ */
53
+ export declare class ToolRowSet {
54
+ private readonly rows;
55
+ /** The diff from the current set to `next`, and adopt `next` as current. */
56
+ diff(next: ToolRow[]): ToolDiff;
57
+ /** Adopt `rows` as the current set without emitting a diff (a reset). */
58
+ reset(rows: ToolRow[]): void;
59
+ }
60
+ interface ToolWatchOptions {
61
+ scope: string;
62
+ signal: AbortSignal;
63
+ /** Called with every non-empty diff. */
64
+ onDiff: (diff: ToolDiff) => void;
65
+ /** Re-projection cadence while no watcher is armed. */
66
+ sweepMs?: number;
67
+ /** Roots to watch (tests pass temp dirs). */
68
+ roots?: string[];
69
+ /** Row sources, forwarded to {@link collectToolRows}. */
70
+ sources?: ToolSources;
71
+ /** Seed the set so the first diff reports only later changes. */
72
+ initial?: ToolRow[];
73
+ }
74
+ /**
75
+ * The directory roots whose contents back the tool rows.
76
+ *
77
+ * The standalone computer ledger is its OWN root: the engine writes there
78
+ * directly, without going through agents-cli, so nothing under the event-ledger
79
+ * or browser roots changes when an operator runs `computer` by hand. Omitting it
80
+ * meant those actions were only ever noticed on a sweep triggered by unrelated
81
+ * activity.
82
+ */
83
+ export declare function toolWatchRoots(): string[];
84
+ /**
85
+ * Every live browser task on this machine, across every profile runtime dir.
86
+ *
87
+ * THROWS for the same reason {@link readLiveTasksFor} does. A runtime dir that
88
+ * does not exist means no browser has ever run here — genuinely no tasks. A
89
+ * readdir that fails for any other reason (EACCES, EMFILE) is a failure, and
90
+ * returning `[]` for it would tell the differ every task had closed.
91
+ */
92
+ export declare function readLiveBrowserTasks(root?: string): LiveBrowserTask[];
93
+ /**
94
+ * Watch the tool roots and report diffs until `signal` aborts.
95
+ *
96
+ * `armed()` reports whether every root currently has a live watcher. It is a
97
+ * FUNCTION, not a flag captured at setup: a watcher can die later (its directory
98
+ * is removed and recreated, an inotify limit is hit), and a frozen `armed: true`
99
+ * meant the tick kept short-circuiting on `!dirty` from a watcher that would
100
+ * never report again — changes were then missed permanently, with the handle
101
+ * still claiming to be armed. Each tick re-arms whatever is missing and sweeps
102
+ * until everything is watched again.
103
+ */
104
+ export declare function watchToolActivity(options: ToolWatchOptions): {
105
+ armed: () => boolean;
106
+ stop: () => void;
107
+ };
108
+ export {};
@@ -0,0 +1,313 @@
1
+ /**
2
+ * Event-driven tool-activity collector — the thing that makes the rows in
3
+ * `tools.ts` reach the feed stream without a poll.
4
+ *
5
+ * THE COST THIS EXISTS TO AVOID. A status surface that wants "which browser
6
+ * tasks and computer runs are there right now" has, until this module, one way
7
+ * to ask: run `agents browser sessions --json` and `agents computer sessions
8
+ * --json`, per device, on a timer. Two subprocesses per tool per device per
9
+ * tick, each paying a full CLI boot, to answer "nothing changed" almost every
10
+ * time. A menu bar at a one-minute cadence over a ten-device fleet is 1,200
11
+ * process spawns an hour for, typically, zero new rows.
12
+ *
13
+ * WHAT REPLACES IT. The two sources are files on the machine that owns them:
14
+ * the browser runtime tree (task index + capture dirs) and the event ledger
15
+ * that `computer.action` appends to. This collector watches those roots, and
16
+ * re-projects ONLY when one of them reports a change. A warm idle does no work
17
+ * at all: no directory read, no subprocess, no emission. When something does
18
+ * change it re-projects and emits the DIFF — the changed rows and the vanished
19
+ * row keys — never a full snapshot, so a single new screenshot costs one
20
+ * `tool.upsert`.
21
+ *
22
+ * WHEN THE WATCHERS CANNOT ARM (an unsupported filesystem, a root that does not
23
+ * exist yet) the collector says so on `armed` and re-projects on the bounded
24
+ * sweep cadence instead. That is a stated degradation, not a silent one: a
25
+ * caller can surface it, and the diff shape is identical either way.
26
+ */
27
+ import * as fs from 'node:fs';
28
+ import * as path from 'node:path';
29
+ import { getBrowserRuntimeDir } from '../state.js';
30
+ import { getEventsDir } from './events.js';
31
+ import { listTaskBindings } from '../browser/task-index.js';
32
+ import { buildBrowserSessionRows } from '../browser/sessions-list.js';
33
+ import { buildComputerSessionRows, standaloneComputerActionsDir } from '../computer/sessions-list.js';
34
+ import { boundBrowserRow, projectBrowserToolRow, projectComputerToolRow, sortToolRows } from './tools.js';
35
+ /** Re-projection cadence used only while no directory watcher could arm. */
36
+ export const TOOL_SWEEP_MS = 5_000;
37
+ /** Computer ledger rows read per projection. Bounds the newest-first window. */
38
+ const TOOL_COMPUTER_LIMIT = 500;
39
+ /**
40
+ * Project every browser task and computer run this machine knows about into
41
+ * canonical tool rows, newest first. Impure by design — the three readers are
42
+ * injectable so a test drives real temp stores rather than a mocked service.
43
+ */
44
+ export function collectToolRows(scope, sources = {}) {
45
+ let complete = true;
46
+ const read = (source, empty) => {
47
+ try {
48
+ return source();
49
+ }
50
+ catch {
51
+ complete = false;
52
+ return empty;
53
+ }
54
+ };
55
+ const bindings = new Map();
56
+ for (const binding of read(sources.bindings ?? listTaskBindings, []))
57
+ bindings.set(binding.name, binding);
58
+ const liveTasks = new Map();
59
+ for (const task of read(sources.liveTasks ?? (() => readLiveBrowserTasks()), []))
60
+ liveTasks.set(task.task, task);
61
+ const rows = [];
62
+ const browserRows = read(sources.browserRows ?? (() => buildBrowserSessionRows()), []);
63
+ const captured = new Set();
64
+ for (const row of browserRows) {
65
+ if (row.task)
66
+ captured.add(row.task);
67
+ rows.push(projectBrowserToolRow(scope, row, row.task ? bindings.get(row.task) : undefined, row.task ? liveTasks.get(row.task) : undefined));
68
+ }
69
+ // Neither the capture tree nor the task index alone answers "which tasks exist".
70
+ // `tasks.json` is the live authority (and the only source of tabs); the task
71
+ // index additionally routes a task whose browser runs on ANOTHER device, which
72
+ // has no local live record. A task bound a second ago is live and closable with
73
+ // no capture to its name, and deriving rows from captures alone hid exactly that.
74
+ for (const task of new Set([...liveTasks.keys(), ...bindings.keys()])) {
75
+ if (captured.has(task))
76
+ continue;
77
+ const binding = bindings.get(task);
78
+ const live = liveTasks.get(task);
79
+ rows.push(projectBrowserToolRow(scope, boundBrowserRow(task, live ?? binding ?? {}), binding, live));
80
+ }
81
+ const computerRows = read(sources.computerRows ?? (() => buildComputerSessionRows({ limit: TOOL_COMPUTER_LIMIT, observer: scope })), []);
82
+ for (const row of computerRows)
83
+ rows.push(projectComputerToolRow(scope, row));
84
+ return { rows: sortToolRows(rows), complete };
85
+ }
86
+ /**
87
+ * Holds the last projected row set and answers "what changed?".
88
+ *
89
+ * Row identity is the projection's own `rowKey`, so a browser task that gains a
90
+ * capture upserts under the same key, and a closed task — gone from both the
91
+ * index and the capture tree — comes back as a remove.
92
+ */
93
+ export class ToolRowSet {
94
+ rows = new Map();
95
+ /** The diff from the current set to `next`, and adopt `next` as current. */
96
+ diff(next) {
97
+ const upserts = [];
98
+ const seen = new Set();
99
+ for (const row of next) {
100
+ seen.add(row.rowKey);
101
+ const serialized = JSON.stringify(row);
102
+ if (this.rows.get(row.rowKey) === serialized)
103
+ continue;
104
+ this.rows.set(row.rowKey, serialized);
105
+ upserts.push(row);
106
+ }
107
+ const removes = [];
108
+ for (const key of [...this.rows.keys()]) {
109
+ if (seen.has(key))
110
+ continue;
111
+ this.rows.delete(key);
112
+ removes.push(key);
113
+ }
114
+ return { upserts, removes };
115
+ }
116
+ /** Adopt `rows` as the current set without emitting a diff (a reset). */
117
+ reset(rows) {
118
+ this.rows.clear();
119
+ for (const row of rows)
120
+ this.rows.set(row.rowKey, JSON.stringify(row));
121
+ }
122
+ }
123
+ /**
124
+ * The directory roots whose contents back the tool rows.
125
+ *
126
+ * The standalone computer ledger is its OWN root: the engine writes there
127
+ * directly, without going through agents-cli, so nothing under the event-ledger
128
+ * or browser roots changes when an operator runs `computer` by hand. Omitting it
129
+ * meant those actions were only ever noticed on a sweep triggered by unrelated
130
+ * activity.
131
+ */
132
+ export function toolWatchRoots() {
133
+ return [getBrowserRuntimeDir(), getEventsDir(), standaloneComputerActionsDir()];
134
+ }
135
+ /**
136
+ * One profile's live task records, with the tabs each task addresses.
137
+ *
138
+ * THROWS on a read it cannot trust. An absent `tasks.json` is the ordinary "no
139
+ * live browser on this profile" case and returns nothing — but EACCES, EMFILE, a
140
+ * truncated file or malformed JSON are failures, and swallowing them returned an
141
+ * empty list that is indistinguishable from "every task closed". The caller then
142
+ * published a remove for every live row. Failing loud here is what lets
143
+ * `collectToolRows` mark the projection incomplete and PRESERVE the rows it has.
144
+ */
145
+ function readLiveTasksFor(profileDir) {
146
+ const file = path.join(profileDir, 'tasks.json');
147
+ let raw;
148
+ try {
149
+ raw = fs.readFileSync(file, 'utf8');
150
+ }
151
+ catch (error) {
152
+ // Only "it isn't there" is benign. Anything else is a read we cannot trust.
153
+ if (error.code === 'ENOENT')
154
+ return [];
155
+ throw error;
156
+ }
157
+ let parsed;
158
+ try {
159
+ parsed = JSON.parse(raw);
160
+ }
161
+ catch (error) {
162
+ // A JSON error is NOT benign: the browser rewrites this file in place, so a
163
+ // parse failure usually means we caught a write in progress — and the tasks
164
+ // are still very much alive.
165
+ throw new Error(`unreadable live task state at ${file}: ${error.message}`);
166
+ }
167
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
168
+ throw new Error(`unexpected live task state at ${file}: expected an object of tasks`);
169
+ }
170
+ const out = [];
171
+ for (const [name, value] of Object.entries(parsed)) {
172
+ if (!value || typeof value !== 'object')
173
+ continue;
174
+ const record = value;
175
+ // `tabs` maps the task's SHORT id -> the engine's target id. The short id is
176
+ // what `browser tab focus` takes and what stays stable across a reconnect,
177
+ // so it is the id published; the target id is never surfaced.
178
+ const borrowed = new Set(Array.isArray(record.borrowedTabs) ? record.borrowedTabs.filter((id) => typeof id === 'string') : []);
179
+ const tabs = [];
180
+ if (record.tabs && typeof record.tabs === 'object') {
181
+ for (const id of Object.keys(record.tabs)) {
182
+ tabs.push({ id, ...(record.currentTabId === id ? { current: true } : {}), ...(borrowed.has(id) ? { borrowed: true } : {}) });
183
+ }
184
+ }
185
+ out.push({
186
+ task: typeof record.name === 'string' ? record.name : name,
187
+ ...(typeof record.profile === 'string' ? { profile: record.profile } : {}),
188
+ ...(typeof record.label === 'string' ? { label: record.label } : {}),
189
+ tabs,
190
+ // The real persisted schema is `createdAt` + `lastActionAt` (`browser/types.ts`
191
+ // `Task`). An earlier revision read `startedAt`, which the DTO at
192
+ // `service.ts` uses but `tasks.json` never carries — so every zero-capture
193
+ // task reported no start time at all and sorted to the bottom.
194
+ ...(typeof record.createdAt === 'number' ? { startedAtMs: record.createdAt } : {}),
195
+ // `lastActionAt` is refreshed by every task-scoped action, which makes it
196
+ // the honest freshness key for a task that has produced no capture yet.
197
+ // Tasks written before RUSH-2622 carry none; `createdAt` is the fallback the
198
+ // browser's own reader normalizes them to.
199
+ ...(typeof record.lastActionAt === 'number' ? { lastActionAtMs: record.lastActionAt }
200
+ : typeof record.createdAt === 'number' ? { lastActionAtMs: record.createdAt } : {}),
201
+ ...(typeof record.sessionId === 'string' ? { sessionId: record.sessionId } : {}),
202
+ ...(typeof record.launchId === 'string' ? { launchId: record.launchId } : {}),
203
+ ...(typeof record.actor === 'string' ? { actor: record.actor } : {}),
204
+ });
205
+ }
206
+ return out;
207
+ }
208
+ /**
209
+ * Every live browser task on this machine, across every profile runtime dir.
210
+ *
211
+ * THROWS for the same reason {@link readLiveTasksFor} does. A runtime dir that
212
+ * does not exist means no browser has ever run here — genuinely no tasks. A
213
+ * readdir that fails for any other reason (EACCES, EMFILE) is a failure, and
214
+ * returning `[]` for it would tell the differ every task had closed.
215
+ */
216
+ export function readLiveBrowserTasks(root = getBrowserRuntimeDir()) {
217
+ let entries;
218
+ try {
219
+ entries = fs.readdirSync(root, { withFileTypes: true });
220
+ }
221
+ catch (error) {
222
+ if (error.code === 'ENOENT')
223
+ return [];
224
+ throw error;
225
+ }
226
+ const out = [];
227
+ for (const entry of entries) {
228
+ if (!entry.isDirectory() || entry.name === 'sessions')
229
+ continue;
230
+ out.push(...readLiveTasksFor(path.join(root, entry.name)));
231
+ }
232
+ return out;
233
+ }
234
+ /**
235
+ * Watch the tool roots and report diffs until `signal` aborts.
236
+ *
237
+ * `armed()` reports whether every root currently has a live watcher. It is a
238
+ * FUNCTION, not a flag captured at setup: a watcher can die later (its directory
239
+ * is removed and recreated, an inotify limit is hit), and a frozen `armed: true`
240
+ * meant the tick kept short-circuiting on `!dirty` from a watcher that would
241
+ * never report again — changes were then missed permanently, with the handle
242
+ * still claiming to be armed. Each tick re-arms whatever is missing and sweeps
243
+ * until everything is watched again.
244
+ */
245
+ export function watchToolActivity(options) {
246
+ const roots = options.roots ?? toolWatchRoots();
247
+ const set = new ToolRowSet();
248
+ if (options.initial)
249
+ set.reset(options.initial);
250
+ let dirty = false;
251
+ let stopped = false;
252
+ /** One entry per root; `undefined` means that root needs re-arming. */
253
+ const watchers = new Map(roots.map((root) => [root, undefined]));
254
+ const armRoot = (root) => {
255
+ if (stopped || watchers.get(root))
256
+ return;
257
+ try {
258
+ // A root that does not exist yet (no browser has ever run here) cannot be
259
+ // watched, and would never be retried. Creating it arms the watcher now.
260
+ fs.mkdirSync(root, { recursive: true });
261
+ // Recursive: a capture lands in <root>/<profile>/sessions/<task>/, several
262
+ // levels below the root, and a non-recursive watch never sees it.
263
+ const watcher = fs.watch(root, { recursive: true }, () => { dirty = true; });
264
+ watcher.on('error', () => {
265
+ watcher.close();
266
+ // Release the slot AND mark dirty: the events this watcher dropped
267
+ // between failing and being replaced have to be picked up by a sweep,
268
+ // or a change that landed inside that gap is lost for good.
269
+ if (watchers.get(root) === watcher)
270
+ watchers.set(root, undefined);
271
+ dirty = true;
272
+ });
273
+ watchers.set(root, watcher);
274
+ }
275
+ catch { /* reported by armed(); the sweep covers it until it arms */ }
276
+ };
277
+ for (const root of roots)
278
+ armRoot(root);
279
+ const armed = () => [...watchers.values()].every((watcher) => watcher !== undefined);
280
+ const reproject = () => {
281
+ const snapshot = collectToolRows(options.scope, options.sources);
282
+ // An incomplete read is not evidence a task closed. Keep the rows we have and
283
+ // stay dirty so the next tick retries — publishing removes here is what made
284
+ // live rows flicker out on a transient failure.
285
+ if (!snapshot.complete) {
286
+ dirty = true;
287
+ return;
288
+ }
289
+ const diff = set.diff(snapshot.rows);
290
+ if (diff.upserts.length > 0 || diff.removes.length > 0)
291
+ options.onDiff(diff);
292
+ };
293
+ const timer = setInterval(() => {
294
+ for (const root of roots)
295
+ armRoot(root);
296
+ // Fully-armed watchers mean a tick with nothing reported does NOTHING — no
297
+ // directory read, no projection. That is the warm-idle guarantee, and it is
298
+ // conditional on every root actually being watched right now.
299
+ if (armed() && !dirty)
300
+ return;
301
+ dirty = false;
302
+ reproject();
303
+ }, options.sweepMs ?? TOOL_SWEEP_MS);
304
+ const stop = () => {
305
+ stopped = true;
306
+ clearInterval(timer);
307
+ for (const watcher of watchers.values())
308
+ watcher?.close();
309
+ watchers.clear();
310
+ };
311
+ options.signal.addEventListener('abort', stop, { once: true });
312
+ return { armed, stop };
313
+ }