@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
@@ -58,6 +58,10 @@
58
58
  * `sessionId` doesn't resolve (a rotated/unindexed session) but `launchId`
59
59
  * still does via the more authoritative pid registry.
60
60
  */
61
+ import * as fs from 'node:fs';
62
+ import * as path from 'node:path';
63
+ import { getCacheDir } from '../state.js';
64
+ import { machineId } from '../machine-id.js';
61
65
  import { query, truncate } from '../feed/events.js';
62
66
  import { formatRelativeTime } from '../session/relative-time.js';
63
67
  import { getSessionById, listComputerSessionRecords, pruneToolSessions } from '../session/db.js';
@@ -70,6 +74,21 @@ export const TASK_PREVIEW_MAX_CHARS = 200;
70
74
  * already reads newest-first and stops at this cutoff mid-scan, so raising
71
75
  * it only ever costs as much as the history actually holds. */
72
76
  const DEFAULT_ACTION_LIMIT = 5000;
77
+ /** A capture record, accepted only when the producer gave a real path + name. */
78
+ function parseActionCapture(value) {
79
+ if (!value || typeof value !== 'object')
80
+ return undefined;
81
+ const record = value;
82
+ if (typeof record.path !== 'string' || !record.path)
83
+ return undefined;
84
+ const name = typeof record.name === 'string' && record.name ? record.name : path.basename(record.path);
85
+ return {
86
+ path: record.path,
87
+ kind: 'screenshot',
88
+ name,
89
+ ...(typeof record.bytes === 'number' ? { bytes: record.bytes } : {}),
90
+ };
91
+ }
73
92
  function recordToAction(r) {
74
93
  if (typeof r.command !== 'string' || typeof r.pid !== 'number')
75
94
  return null;
@@ -91,8 +110,153 @@ function recordToAction(r) {
91
110
  agent: typeof r.agent === 'string' ? r.agent : undefined,
92
111
  machineId: typeof r.machineId === 'string' ? r.machineId : undefined,
93
112
  hostname: typeof r.hostname === 'string' ? r.hostname : undefined,
113
+ capture: parseActionCapture(r.capture),
94
114
  };
95
115
  }
116
+ /**
117
+ * The standalone engine's OWN action ledger.
118
+ *
119
+ * `agents computer` is a thin consumer of the standalone `computer` engine
120
+ * (PHNX-4075), and that engine ALWAYS appends every action it performs to
121
+ * `<cache>/computer/actions/<day>.jsonl` — independently of whether agents-cli
122
+ * was in the call at all. So a `computer` command the operator ran directly
123
+ * appears ONLY here: it never reached `recordComputerAction`, so it is in
124
+ * neither the feed event ledger nor `computer_sessions`. Reading only those two
125
+ * meant the actions an operator actually performed were invisible to every
126
+ * agents-cli surface.
127
+ */
128
+ export function standaloneComputerActionsDir() {
129
+ return path.join(getCacheDir(), 'computer', 'actions');
130
+ }
131
+ /**
132
+ * One line of the standalone ledger as a {@link ComputerAction}.
133
+ *
134
+ * The on-disk record is the same `computer.action` event the engine reports on
135
+ * fd 4 — the contract `computer-client.ts` `ComputerActionEvent` already
136
+ * describes and `recordComputerAction` already consumes — so this maps the same
137
+ * fields rather than inventing a second vocabulary. A record missing the two
138
+ * fields that make it an action at all (`command`, a parseable timestamp) is
139
+ * skipped, never defaulted: the file is plain JSONL another process may be
140
+ * mid-append to.
141
+ */
142
+ function standaloneLineToAction(line, observer) {
143
+ let parsed;
144
+ try {
145
+ parsed = JSON.parse(line);
146
+ }
147
+ catch {
148
+ return null;
149
+ }
150
+ if (!parsed || typeof parsed !== 'object')
151
+ return null;
152
+ const record = parsed;
153
+ const verb = typeof record.command === 'string' ? record.command : undefined;
154
+ const ts = typeof record.ts === 'string' ? record.ts : undefined;
155
+ if (!verb || !ts)
156
+ return null;
157
+ const tsMs = Date.parse(ts);
158
+ if (Number.isNaN(tsMs))
159
+ return null;
160
+ const text = (key) => (typeof record[key] === 'string' ? record[key] : undefined);
161
+ const num = (key) => (typeof record[key] === 'number' ? record[key] : undefined);
162
+ // The producer identifies the DRIVEN host only for a remote run, and emits no
163
+ // `hostname`/`machineId` of its own at all. Defaulting those to the observing
164
+ // machine HERE, at the source, is what keeps a direct local action attributable:
165
+ // without it `groupIntoComputerRuns` fell back to `machine: 'unknown'`, the row's
166
+ // device read `unknown`, and every locally-run `computer` action vanished the
167
+ // moment a consumer filtered by device. The observer IS the machine that ran it
168
+ // — this ledger is per-machine by construction, written by the engine on the box
169
+ // it ran on — so the fallback is a fact, not a guess. An explicit `host` still
170
+ // wins, because that names a genuinely different driven machine.
171
+ return {
172
+ verb, ts, tsMs,
173
+ // The engine's pid is its own; a record without one still groups by
174
+ // invocationId, which is the identity that actually matters here.
175
+ pid: num('pid') ?? 0,
176
+ invocationId: text('invocationId'),
177
+ targetPid: num('targetPid'),
178
+ bundle: text('bundle'),
179
+ host: text('host'),
180
+ task: text('task'),
181
+ sessionId: text('sessionId'),
182
+ launchId: text('launchId'),
183
+ agent: text('agent'),
184
+ machineId: text('machineId'),
185
+ hostname: text('hostname') ?? observer,
186
+ capture: parseActionCapture(record.capture),
187
+ };
188
+ }
189
+ /**
190
+ * Read the standalone ledger, newest day first, bounded by `limit` actions.
191
+ *
192
+ * Bounded by construction: days are read newest-first and reading stops as soon
193
+ * as the budget is met, so a box with months of history costs the same as one
194
+ * with a day of it.
195
+ */
196
+ export function listStandaloneComputerActions(opts = {}) {
197
+ const dir = opts.dir ?? standaloneComputerActionsDir();
198
+ const limit = opts.limit ?? DEFAULT_ACTION_LIMIT;
199
+ // This ledger is per-machine by construction, so "who ran it" is this machine
200
+ // unless the record names a driven remote host.
201
+ const observer = opts.observer ?? machineId();
202
+ let days;
203
+ try {
204
+ days = fs.readdirSync(dir).filter((name) => name.endsWith('.jsonl')).sort().reverse();
205
+ }
206
+ catch {
207
+ return []; // The engine has never run here, or is not installed.
208
+ }
209
+ const out = [];
210
+ for (const day of days) {
211
+ if (out.length >= limit)
212
+ break;
213
+ let lines;
214
+ try {
215
+ lines = fs.readFileSync(path.join(dir, day), 'utf8').split('\n');
216
+ }
217
+ catch {
218
+ continue; /* rotated or removed mid-read */
219
+ }
220
+ // Newest last within a day, and the budget favours the newest actions.
221
+ for (let index = lines.length - 1; index >= 0 && out.length < limit; index--) {
222
+ const line = lines[index];
223
+ if (!line)
224
+ continue;
225
+ const action = standaloneLineToAction(line, observer);
226
+ if (action)
227
+ out.push(action);
228
+ }
229
+ }
230
+ out.sort((a, b) => b.tsMs - a.tsMs);
231
+ return out;
232
+ }
233
+ /**
234
+ * Union the two ledgers, preferring the standalone record for any run present in
235
+ * both.
236
+ *
237
+ * Dedupe keys on `invocationId`, NOT on a timestamp or pid. When a command is
238
+ * forwarded through `agents computer`, BOTH stores receive it — and the
239
+ * forwarding rewrites `ts` and `pid` on the way through, so the two copies of one
240
+ * action do not agree on either. `invocationId` is minted by the engine and
241
+ * echoed unchanged, which makes it the only field that identifies the same run in
242
+ * both files. A legacy feed record with no invocationId cannot be matched to
243
+ * anything, so it is kept: dropping it would lose history the standalone ledger
244
+ * never had.
245
+ */
246
+ export function mergeComputerActionSources(standalone, legacy) {
247
+ const standaloneRuns = new Set();
248
+ for (const action of standalone)
249
+ if (action.invocationId)
250
+ standaloneRuns.add(action.invocationId);
251
+ const merged = [...standalone];
252
+ for (const action of legacy) {
253
+ if (action.invocationId && standaloneRuns.has(action.invocationId))
254
+ continue;
255
+ merged.push(action);
256
+ }
257
+ merged.sort((a, b) => b.tsMs - a.tsMs);
258
+ return merged;
259
+ }
96
260
  /** Read `computer.action` events straight from the durable event ledger,
97
261
  * newest first, bounded by `limit`. Malformed/legacy records (missing
98
262
  * `command` or `pid`, or an unparseable `ts`) are skipped, never thrown —
@@ -240,7 +404,10 @@ function appendPrunedRunsFromDb(rows, limit) {
240
404
  * flat/`--json` printer's) data source. `machine` narrows to rows whose
241
405
  * invoking hostname, machineId, or `--device` target contains the substring. */
242
406
  export function buildComputerSessionRows(opts = {}) {
243
- const actions = listComputerActions({ limit: opts.limit });
407
+ const actions = mergeComputerActionSources(
408
+ // `observer` is threaded from the caller's scope so a row's device and the
409
+ // scope that reported it can never name the same box differently.
410
+ listStandaloneComputerActions({ limit: opts.limit, ...(opts.observer ? { observer: opts.observer } : {}) }), listComputerActions({ limit: opts.limit }));
244
411
  const index = buildLaunchSessionIndex();
245
412
  const rows = groupIntoComputerRuns(actions, (sessionId) => getSessionById(sessionId), (launchId) => resolveLaunchSession(index, launchId));
246
413
  // Retention runs here, on the listing path, never on the action hot path:
@@ -878,7 +878,7 @@ export async function runDaemon() {
878
878
  assertTestDaemonHome();
879
879
  // Lifecycle readers and launchers do not run services. Load their code only
880
880
  // in the daemon process, before it claims or publishes lifecycle state.
881
- const [{ BrowserService }, { SessionIndexService }, { SessionSummarizerService }, { SessionTitleService }, { MonitorEngineService }, { AccountUsageService, AccountAuthService }, { CatchupService }, { BrowserIPCService }, { WatchdogService }, { DeviceProbeService }, { SelfHealService }, { SelfUpdateService }, { HarnessUpdateService }, { AuthSyncService }, { UsageSyncService }, { StateDirCheckService }, { SessionStateService }, { AttentionNotifyService }, { WebhookReceiverService }, { HeartbeatService }, { TmuxReapService }, { BrowserTaskReapService },] = await Promise.all([
881
+ const [{ BrowserService }, { SessionIndexService }, { SessionSummarizerService }, { SessionTitleService }, { MonitorEngineService }, { AccountUsageService, AccountAuthService }, { CatchupService }, { BrowserIPCService }, { WatchdogService }, { DeviceProbeService }, { SelfHealService }, { SelfUpdateService }, { HarnessUpdateService }, { AuthSyncService }, { UsageSyncService }, { StateDirCheckService }, { SessionStateService }, { FeedStreamService }, { AttentionNotifyService }, { WebhookReceiverService }, { HeartbeatService }, { TmuxReapService }, { BrowserTaskReapService },] = await Promise.all([
882
882
  import('../browser/service.js'),
883
883
  import('./session-index-service.js'),
884
884
  import('./session-summarizer-service.js'),
@@ -896,6 +896,7 @@ export async function runDaemon() {
896
896
  import('./usage-sync-service.js'),
897
897
  import('./state-dir-check-service.js'),
898
898
  import('./session-state-service.js'),
899
+ import('./feed-stream-service.js'),
899
900
  import('./attention-notify-service.js'),
900
901
  import('./webhook-receiver-service.js'),
901
902
  import('./heartbeat-service.js'),
@@ -1021,6 +1022,12 @@ export async function runDaemon() {
1021
1022
  }
1022
1023
  else
1023
1024
  log('INFO', 'Live session-state service disabled');
1025
+ // The shared feed fan-out. Registered even when disabled at boot so a later
1026
+ // `agents daemon services enable feed-stream` brings it up over SIGHUP, the
1027
+ // same live-transition shape browser IPC uses.
1028
+ supervisor.register(new FeedStreamService(), { enabled: isEnabled('feed-stream') });
1029
+ if (!isEnabled('feed-stream'))
1030
+ log('INFO', 'Shared feed stream service disabled');
1024
1031
  const monitorEngineSvc = new MonitorEngineService();
1025
1032
  if (isEnabled('monitors'))
1026
1033
  supervisor.register(monitorEngineSvc);
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The shared feed collector's lifecycle as a `DaemonService`.
3
+ *
4
+ * WHY THE DAEMON OWNS IT. Dialing every peer over ssh and holding those
5
+ * connections open is an ACTION on the fleet, so by the repo's one-scheduler /
6
+ * one-executor rule it belongs to the daemon and not to a UI surface. Before
7
+ * this service, every consumer of `agents feed watch --json` ran its own
8
+ * `watchFleetFeed`, which is N ssh children per peer for N readers. The daemon
9
+ * runs one, and the readers attach over a socket (`feed/hub-server.ts`).
10
+ *
11
+ * The server binds on start, but the fan-out itself is DEMAND-GATED by the hub:
12
+ * with no client connected there is no `watchFleetFeed`, so an idle box holds no
13
+ * peer connections — the same idle-cost rule `SessionStateService` follows for
14
+ * its gather.
15
+ */
16
+ import { BaseDaemonService, type DaemonContext } from './service.js';
17
+ import type { DaemonServiceId } from '../daemon-services.js';
18
+ export declare class FeedStreamService extends BaseDaemonService {
19
+ readonly id: DaemonServiceId;
20
+ private server;
21
+ protected onStart(ctx: DaemonContext): Promise<void>;
22
+ protected onStop(): Promise<void>;
23
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The shared feed collector's lifecycle as a `DaemonService`.
3
+ *
4
+ * WHY THE DAEMON OWNS IT. Dialing every peer over ssh and holding those
5
+ * connections open is an ACTION on the fleet, so by the repo's one-scheduler /
6
+ * one-executor rule it belongs to the daemon and not to a UI surface. Before
7
+ * this service, every consumer of `agents feed watch --json` ran its own
8
+ * `watchFleetFeed`, which is N ssh children per peer for N readers. The daemon
9
+ * runs one, and the readers attach over a socket (`feed/hub-server.ts`).
10
+ *
11
+ * The server binds on start, but the fan-out itself is DEMAND-GATED by the hub:
12
+ * with no client connected there is no `watchFleetFeed`, so an idle box holds no
13
+ * peer connections — the same idle-cost rule `SessionStateService` follows for
14
+ * its gather.
15
+ */
16
+ import { BaseDaemonService } from './service.js';
17
+ import { FeedHub } from '../feed/hub.js';
18
+ import { FeedHubServer } from '../feed/hub-server.js';
19
+ import { sharedLocalFeedHub, watchFleetFeed } from '../feed/watch.js';
20
+ export class FeedStreamService extends BaseDaemonService {
21
+ id = 'feed-stream';
22
+ server = null;
23
+ async onStart(ctx) {
24
+ // Two collectors, one socket. The fleet hub holds one ssh child per peer plus
25
+ // this box's rows; the local hub is this box ONLY and cannot fan out, so a
26
+ // reader that wants just this machine never causes a peer dial. They are
27
+ // separate hubs rather than one filtered stream because demand must be
28
+ // separate too: a local-only reader must not start the fleet fan-out.
29
+ const fleet = new FeedHub({ watch: watchFleetFeed });
30
+ this.server = new FeedHubServer(fleet, undefined, sharedLocalFeedHub());
31
+ await this.server.start();
32
+ ctx.log('INFO', 'Feed stream hub listening (fan-out starts on first reader)');
33
+ }
34
+ async onStop() {
35
+ const server = this.server;
36
+ this.server = null;
37
+ if (server)
38
+ await server.stop();
39
+ }
40
+ }
@@ -7,7 +7,7 @@
7
7
  * enabled without pulling in the whole daemon lifecycle.
8
8
  */
9
9
  /** Every service the daemon can host. IDs are kebab-case and stable. */
10
- export type DaemonServiceId = 'scheduler' | 'catchup' | 'monitors' | 'browser-ipc' | 'webhook-receiver' | 'self-heal' | 'self-update' | 'account-state' | 'account-auth' | 'watchdog' | 'device-probe' | 'state-dir-check' | 'session-index' | 'session-title' | 'auth-sync' | 'usage-sync' | 'daemon-heartbeat' | 'tmux-reap' | 'browser-task-reap' | 'session-state' | 'session-summarizer' | 'attention-notify' | 'harness-update';
10
+ export type DaemonServiceId = 'scheduler' | 'catchup' | 'monitors' | 'browser-ipc' | 'webhook-receiver' | 'self-heal' | 'self-update' | 'account-state' | 'account-auth' | 'watchdog' | 'device-probe' | 'state-dir-check' | 'session-index' | 'session-title' | 'auth-sync' | 'usage-sync' | 'daemon-heartbeat' | 'tmux-reap' | 'browser-task-reap' | 'session-state' | 'feed-stream' | 'session-summarizer' | 'attention-notify' | 'harness-update';
11
11
  /** Human-readable metadata for each service. */
12
12
  interface DaemonServiceDef {
13
13
  id: DaemonServiceId;
@@ -92,6 +92,11 @@ export const DAEMON_SERVICES = [
92
92
  title: 'Live session state',
93
93
  description: 'Publishes this host\'s active session metadata for sessions watch and fleet consumers.',
94
94
  },
95
+ {
96
+ id: 'feed-stream',
97
+ title: 'Shared feed stream',
98
+ description: 'Owns the one fleet feed fan-out and serves it to every reader over a socket, so N consumers cost one ssh per peer instead of N.',
99
+ },
95
100
  {
96
101
  id: 'session-index',
97
102
  title: 'Session-index warm',
@@ -30,13 +30,43 @@ export declare function sshTargetFor(device: DeviceProfile): string;
30
30
  */
31
31
  export declare function fleetDialTarget(device: DeviceProfile): string;
32
32
  /**
33
- * Wrap a remote command for the device's shell. Windows devices speak
34
- * PowerShell, so a bare command is run through `powershell -NoProfile
35
- * -EncodedCommand`; POSIX devices get the command verbatim (the remote login
36
- * shell parses it). Returns undefined when no command was given (interactive
33
+ * Render `cmd` as the single command string ssh sends to the peer.
34
+ *
35
+ * Windows devices speak PowerShell, so the result is run through
36
+ * `powershell -NoProfile -EncodedCommand`; POSIX devices get it as the remote
37
+ * login shell sees it. Returns undefined when no command was given (interactive
37
38
  * login).
39
+ *
40
+ * TWO MODES, and the difference is load-bearing rather than a convenience.
41
+ *
42
+ * The default joins the tokens RAW. That is not a bug to be tidied away: every
43
+ * existing caller of `agents ssh` relies on the remote shell interpreting what it
44
+ * is handed — `agents ssh box 'bash -lc "cd x && make"'` arrives as ONE token
45
+ * whose pipeline, globs and redirections the peer's shell must expand. Quoting
46
+ * that would ship the whole line as a literal argument and break it.
47
+ *
48
+ * `{ argv: true }` is for a caller that genuinely holds an argv ARRAY and needs
49
+ * each element delivered as exactly one token. Joining those raw destroys any
50
+ * token containing a space or a metacharacter — `['--title', 'two words']`
51
+ * arrives as three tokens, and `'a & b'` arrives as a backgrounded command — which
52
+ * is what a native client hitting this path actually hit.
53
+ *
54
+ * So fidelity is opt-in at the call site that knows which shape it has, and the
55
+ * quoting itself reuses the canonical helpers (`shellQuote`, {@link pwshQuote})
56
+ * rather than introducing a third escaping scheme.
38
57
  */
39
- export declare function wrapRemoteCommand(device: DeviceProfile, cmd: string[]): string | undefined;
58
+ export declare function wrapRemoteCommand(device: DeviceProfile, cmd: string[], opts?: {
59
+ argv?: boolean;
60
+ prelude?: string[];
61
+ }): string | undefined;
62
+ /**
63
+ * Single-quote one token for PowerShell's OWN parser. Inside a single-quoted pwsh
64
+ * string the only special character is `'`, escaped by doubling.
65
+ *
66
+ * This is correct for a value PowerShell itself consumes, and NOT sufficient for
67
+ * an argument handed on to a native program — see the Win32 note below.
68
+ */
69
+ export declare function pwshQuote(token: string): string;
40
70
  /**
41
71
  * True when `cmd` is a browser drive: `agents browser …`, `ag browser …`, or
42
72
  * the standalone `browser` binary (`cli/package.json` `bin.browser` →
@@ -65,6 +95,19 @@ export declare function isAgentsBrowserDrive(cmd: string[]): boolean;
65
95
  * fan-out stamps the marker before {@link buildSshInvocation}) is left unchanged
66
96
  * so nothing is doubled.
67
97
  */
98
+ /**
99
+ * The provenance prefix as READY SHELL SYNTAX for the device's shell.
100
+ *
101
+ * These tokens are already quoted/escaped for their target shell — a POSIX
102
+ * `K=V` pair is `shellQuote`d here, and a PowerShell assignment is a complete
103
+ * statement with its own doubled quotes. That matters because argv mode quotes
104
+ * every token it is handed: quoting THESE again turns
105
+ * `'AGENTS_ACTOR=Some Name'` into `''\''AGENTS_ACTOR=Some Name'\'''` and turns a
106
+ * pwsh assignment into an inert string literal. So the prelude is composed
107
+ * SEPARATELY from the caller's argv rather than concatenated into it, and this is
108
+ * the single definition both paths use.
109
+ */
110
+ export declare function fleetRemotePrelude(device: Pick<DeviceProfile, 'shell'>, provenanceEnv?: Record<string, string>): string[];
68
111
  export declare function markFleetRemote(cmd: string[], device: Pick<DeviceProfile, 'shell'>, provenanceEnv?: Record<string, string>): string[];
69
112
  /**
70
113
  * Build the remote command that starts an INTERACTIVE LOGIN shell inside a
@@ -136,6 +179,7 @@ export declare function deviceIdentityArgs(device: DeviceProfile): string[];
136
179
  export declare function buildSshInvocation(device: DeviceProfile, cmd: string[], askpassShimPath: string, hostKey?: SshHostKeyOptions, opts?: {
137
180
  agentOnly?: boolean;
138
181
  interactiveCwd?: string;
182
+ argv?: boolean;
139
183
  }): {
140
184
  args: string[];
141
185
  env: Record<string, string>;
@@ -18,12 +18,14 @@ import * as path from 'path';
18
18
  import { assertValidSshTarget, shellQuote } from '../ssh-exec.js';
19
19
  import { resolveActor, actorEnv } from '../actor.js';
20
20
  import { getCliLaunch } from '../cli-entry.js';
21
- import { encodePwshBase64 } from '../pwsh.js';
21
+ import { encodePwshBase64, pwshLiteral, pwshNativeExecStatements } from '../pwsh.js';
22
+ import { quoteWin32ExecArg } from '../platform/exec.js';
22
23
  import { homeRemainder, remoteCdPrefix } from '../project-root.js';
23
24
  import { getCacheDir } from '../state.js';
24
25
  import { hostKeyCheckingOpts } from './known-hosts.js';
25
26
  import { hostNameFor } from './ssh-config.js';
26
27
  import { resolveDeviceProfile } from './resolve-profile.js';
28
+ import { renderPowershellCommand, windowsAgentsInvocation } from '../hosts/remote-cmd.js';
27
29
  /** Env var the askpass shim reads to know which bundle holds the password. */
28
30
  export const ASKPASS_BUNDLE_ENV = 'AGENTS_SSH_BUNDLE';
29
31
  /** Env var the askpass shim reads to know which key in the bundle is the password. */
@@ -72,20 +74,139 @@ export function fleetDialTarget(device) {
72
74
  }
73
75
  }
74
76
  /**
75
- * Wrap a remote command for the device's shell. Windows devices speak
76
- * PowerShell, so a bare command is run through `powershell -NoProfile
77
- * -EncodedCommand`; POSIX devices get the command verbatim (the remote login
78
- * shell parses it). Returns undefined when no command was given (interactive
77
+ * Render `cmd` as the single command string ssh sends to the peer.
78
+ *
79
+ * Windows devices speak PowerShell, so the result is run through
80
+ * `powershell -NoProfile -EncodedCommand`; POSIX devices get it as the remote
81
+ * login shell sees it. Returns undefined when no command was given (interactive
79
82
  * login).
83
+ *
84
+ * TWO MODES, and the difference is load-bearing rather than a convenience.
85
+ *
86
+ * The default joins the tokens RAW. That is not a bug to be tidied away: every
87
+ * existing caller of `agents ssh` relies on the remote shell interpreting what it
88
+ * is handed — `agents ssh box 'bash -lc "cd x && make"'` arrives as ONE token
89
+ * whose pipeline, globs and redirections the peer's shell must expand. Quoting
90
+ * that would ship the whole line as a literal argument and break it.
91
+ *
92
+ * `{ argv: true }` is for a caller that genuinely holds an argv ARRAY and needs
93
+ * each element delivered as exactly one token. Joining those raw destroys any
94
+ * token containing a space or a metacharacter — `['--title', 'two words']`
95
+ * arrives as three tokens, and `'a & b'` arrives as a backgrounded command — which
96
+ * is what a native client hitting this path actually hit.
97
+ *
98
+ * So fidelity is opt-in at the call site that knows which shape it has, and the
99
+ * quoting itself reuses the canonical helpers (`shellQuote`, {@link pwshQuote})
100
+ * rather than introducing a third escaping scheme.
80
101
  */
81
- export function wrapRemoteCommand(device, cmd) {
102
+ export function wrapRemoteCommand(device, cmd, opts = {}) {
82
103
  if (cmd.length === 0)
83
104
  return undefined;
84
- const joined = cmd.join(' ');
105
+ const prelude = opts.prelude ?? [];
106
+ let script;
107
+ if (opts.argv) {
108
+ // Quote EACH caller token so the peer receives it byte-for-byte, then prefix
109
+ // the prelude VERBATIM — it is already shell syntax and re-quoting it would
110
+ // break it (see `fleetRemotePrelude`).
111
+ if (device.shell === 'powershell') {
112
+ script = pwshExactArgvScript(cmd, prelude);
113
+ }
114
+ else {
115
+ script = [...prelude, ...cmd.map(shellQuote)].join(' ');
116
+ }
117
+ }
118
+ else {
119
+ // The default joins raw, which is what lets a caller hand the remote shell
120
+ // something to interpret. See the docblock above for why both must exist.
121
+ script = [...prelude, ...cmd].join(' ');
122
+ }
85
123
  if (device.shell === 'powershell') {
86
- return `powershell -NoProfile -EncodedCommand ${encodePwshBase64(joined)}`;
124
+ // Same renderer the Windows `agents` launcher uses, so a long script gets the
125
+ // compressed representation here too rather than only on that path. The
126
+ // interactive login route (`buildInteractiveShellCommand`, -NoExit) is
127
+ // deliberately left alone: it must stay an interactive session.
128
+ return renderPowershellCommand(script);
87
129
  }
88
- return joined;
130
+ return script;
131
+ }
132
+ /**
133
+ * Single-quote one token for PowerShell's OWN parser. Inside a single-quoted pwsh
134
+ * string the only special character is `'`, escaped by doubling.
135
+ *
136
+ * This is correct for a value PowerShell itself consumes, and NOT sufficient for
137
+ * an argument handed on to a native program — see the Win32 note below.
138
+ */
139
+ export function pwshQuote(token) {
140
+ return `'${token.replace(/'/g, "''")}'`;
141
+ }
142
+ /**
143
+ * Emit a PowerShell script that runs `cmd` with EXACT argv, for either kind of
144
+ * target a Windows peer can name.
145
+ *
146
+ * Windows has no argv array: a process receives ONE string and splits it itself.
147
+ * PowerShell 5.1 rebuilds that string when it invokes a native program, and its
148
+ * serializer is lossy — measured on a real peer, an EMPTY argument is dropped and
149
+ * an embedded `"` is discarded, so the callee's argv silently shifts.
150
+ *
151
+ * The obvious fix, the `--%` stop-parsing token, is NOT used, because measurement
152
+ * killed it three ways:
153
+ * - it only applies to a NATIVE command, and `agents` on Windows resolves to
154
+ * `agents.ps1`, so a script target received `--%` as a literal argument and
155
+ * the whole remainder as one string;
156
+ * - a token containing a NEWLINE terminates the directive, producing a parser
157
+ * error;
158
+ * - it performs cmd-style `%VAR%` expansion, so a literal `%PATH%` became six
159
+ * arguments — the exact opposite of exact argv.
160
+ *
161
+ * So the script branches on what the peer's own command discovery finds:
162
+ *
163
+ * - **native executable** — launched through `System.Diagnostics.Process` with a
164
+ * pre-built `Arguments` string escaped by {@link quoteWin32ExecArg}. .NET hands
165
+ * that string to `CreateProcess` essentially verbatim, so the child's
166
+ * `CommandLineToArgvW` reconstructs the tokens exactly; no shell sees it, so
167
+ * no `%VAR%` expansion and no newline sensitivity. `UseShellExecute = $false`
168
+ * with no redirection leaves the child on the inherited handles, which is what
169
+ * lets a binary stdout stream through unchanged.
170
+ * - **anything else** (a `.ps1`/`.cmd` launcher, a function, a cmdlet, an alias)
171
+ * — invoked with a splatted PowerShell array. That is an in-process call, so
172
+ * the native serializer is never involved and every token survives as itself.
173
+ *
174
+ * The exit code is propagated in both branches; a script launcher that sets no
175
+ * `$LASTEXITCODE` is left alone rather than forced to 0.
176
+ */
177
+ function pwshExactArgvScript(cmd, prelude) {
178
+ // The Agents CLI gets the canonical launcher, not the generic dispatch. On
179
+ // Windows `agents` is an npm `agents.ps1` whose own body splats `$args` into
180
+ // native node.exe — the PowerShell 5.1 lossy path — so even a perfectly
181
+ // splatted call into that script loses an embedded quote one layer deeper.
182
+ // `windowsAgentsInvocation` resolves the package's declared entry and runs it
183
+ // directly, which is the only way the real Agents parser sees exact tokens.
184
+ const bin = cmd[0];
185
+ if (bin === 'agents' || bin === 'ag') {
186
+ // `windowsAgentsInvocation` leaves the child's code in `$zq`.
187
+ return [...prelude, windowsAgentsInvocation(cmd.slice(1), bin), 'exit $zq'].join('\n');
188
+ }
189
+ const program = pwshQuote(cmd[0]);
190
+ const rest = cmd.slice(1);
191
+ const splat = rest.length > 0 ? `@(${rest.map(pwshQuote).join(', ')})` : '@()';
192
+ return [
193
+ ...prelude,
194
+ `$ErrorActionPreference='Stop'`,
195
+ `$__c = Get-Command -Name ${program} -ErrorAction Stop`,
196
+ `if ($__c.CommandType -eq 'Application') {`,
197
+ // One emitter for the .NET native-exec block, shared with the Windows
198
+ // `agents` launcher in `hosts/remote-cmd.ts`; a second copy would drift.
199
+ ...pwshNativeExecStatements('$__c.Source', pwshLiteral(rest.map(quoteWin32ExecArg).join(' '))).map((line) => ` ${line}`),
200
+ // `pwshNativeExecStatements` names the process handle `$zp`.
201
+ ` exit $zp.ExitCode`,
202
+ `}`,
203
+ // `@__a` SPLATS the array into separate arguments. `& $__c @(...)` on an
204
+ // array LITERAL does not splat — it passes one array-valued argument, which
205
+ // a real peer reported back as every token collapsed into one.
206
+ `$__a = ${splat}`,
207
+ `& $__c @__a`,
208
+ `if ($null -ne $LASTEXITCODE) { exit $LASTEXITCODE }`,
209
+ ].join('\n');
89
210
  }
90
211
  /**
91
212
  * True when `cmd` is a browser drive: `agents browser …`, `ag browser …`, or
@@ -123,22 +244,43 @@ export function isAgentsBrowserDrive(cmd) {
123
244
  * fan-out stamps the marker before {@link buildSshInvocation}) is left unchanged
124
245
  * so nothing is doubled.
125
246
  */
126
- export function markFleetRemote(cmd, device, provenanceEnv = actorEnv(resolveActor())) {
247
+ /**
248
+ * The provenance prefix as READY SHELL SYNTAX for the device's shell.
249
+ *
250
+ * These tokens are already quoted/escaped for their target shell — a POSIX
251
+ * `K=V` pair is `shellQuote`d here, and a PowerShell assignment is a complete
252
+ * statement with its own doubled quotes. That matters because argv mode quotes
253
+ * every token it is handed: quoting THESE again turns
254
+ * `'AGENTS_ACTOR=Some Name'` into `''\''AGENTS_ACTOR=Some Name'\'''` and turns a
255
+ * pwsh assignment into an inert string literal. So the prelude is composed
256
+ * SEPARATELY from the caller's argv rather than concatenated into it, and this is
257
+ * the single definition both paths use.
258
+ */
259
+ export function fleetRemotePrelude(device, provenanceEnv = actorEnv(resolveActor())) {
127
260
  if (device.shell === 'powershell') {
128
- // Exact-match guard, symmetric with the POSIX branch below: the marker token
129
- // is always this literal, so `startsWith` would only loosen it for no gain.
130
- if (cmd[0] === `$env:AGENTS_FLEET_REMOTE='1';`)
131
- return cmd;
132
- const prelude = [
261
+ return [
133
262
  `$env:AGENTS_FLEET_REMOTE='1';`,
134
263
  ...Object.entries(provenanceEnv).map(([k, v]) => `$env:${k}='${v.replace(/'/g, "''")}';`),
135
264
  ];
136
- return [...prelude, ...cmd];
137
265
  }
138
- if (cmd[0] === 'env' && cmd[1] === 'AGENTS_FLEET_REMOTE=1')
266
+ return [
267
+ 'env',
268
+ 'AGENTS_FLEET_REMOTE=1',
269
+ ...Object.entries(provenanceEnv).map(([k, v]) => shellQuote(`${k}=${v}`)),
270
+ ];
271
+ }
272
+ /** True when `cmd` already carries the prelude {@link fleetRemotePrelude} emits. */
273
+ function alreadyMarked(cmd, device) {
274
+ if (device.shell === 'powershell')
275
+ return cmd[0] === `$env:AGENTS_FLEET_REMOTE='1';`;
276
+ return cmd[0] === 'env' && cmd[1] === 'AGENTS_FLEET_REMOTE=1';
277
+ }
278
+ export function markFleetRemote(cmd, device, provenanceEnv = actorEnv(resolveActor())) {
279
+ // Exact-match guard: the marker token is always this literal, so `startsWith`
280
+ // would only loosen it for no gain.
281
+ if (alreadyMarked(cmd, device))
139
282
  return cmd;
140
- const actorTokens = Object.entries(provenanceEnv).map(([k, v]) => shellQuote(`${k}=${v}`));
141
- return ['env', 'AGENTS_FLEET_REMOTE=1', ...actorTokens, ...cmd];
283
+ return [...fleetRemotePrelude(device, provenanceEnv), ...cmd];
142
284
  }
143
285
  /**
144
286
  * Build the remote command that starts an INTERACTIVE LOGIN shell inside a
@@ -229,10 +371,16 @@ export function buildSshInvocation(device, cmd, askpassShimPath, hostKey = {}, o
229
371
  // Stamp the consent marker on the REMOTE command, not the local ssh env:
230
372
  // SSH_ASKPASS lives on this side; AGENTS_FLEET_REMOTE must be visible to the
231
373
  // process that runs on the peer.
232
- const remoteCmd = !interactive && isAgentsBrowserDrive(cmd) ? markFleetRemote(cmd, device) : cmd;
374
+ // Provenance is composed as a PRELUDE, not prepended to the argv array. In argv
375
+ // mode every token handed to `wrapRemoteCommand` is quoted, and the prelude is
376
+ // already shell syntax — mixing them in meant the actor pairs were quoted twice
377
+ // (breaking any value with a space or a quote) and the pwsh assignments became
378
+ // inert string literals.
379
+ const needsProvenance = !interactive && isAgentsBrowserDrive(cmd) && !alreadyMarked(cmd, device);
380
+ const prelude = needsProvenance ? fleetRemotePrelude(device) : [];
233
381
  const remote = interactive
234
382
  ? buildInteractiveShellCommand(device, opts.interactiveCwd)
235
- : wrapRemoteCommand(device, remoteCmd);
383
+ : wrapRemoteCommand(device, cmd, { ...(opts.argv ? { argv: true } : {}), ...(prelude.length > 0 ? { prelude } : {}) });
236
384
  const env = {};
237
385
  const args = [
238
386
  ...hostKeyCheckingOpts(hostKey.pinned ?? false, hostKey.knownHostsFile),