@phnx-labs/agents-cli 1.22.43 → 1.22.45

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 (164) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +14 -12
  3. package/dist/bootstrap.js +3 -6
  4. package/dist/cli/command-registry.d.ts +0 -7
  5. package/dist/cli/command-registry.js +1 -21
  6. package/dist/commands/accounts.d.ts +1 -9
  7. package/dist/commands/accounts.js +12 -90
  8. package/dist/commands/apply.d.ts +7 -10
  9. package/dist/commands/apply.js +15 -35
  10. package/dist/commands/artifacts.js +4 -5
  11. package/dist/commands/audit.d.ts +6 -6
  12. package/dist/commands/audit.js +12 -12
  13. package/dist/commands/beta.d.ts +7 -1
  14. package/dist/commands/beta.js +16 -9
  15. package/dist/commands/commands.js +1 -1
  16. package/dist/commands/doctor.js +1 -4
  17. package/dist/commands/events.d.ts +2 -2
  18. package/dist/commands/events.js +6 -5
  19. package/dist/commands/exec.d.ts +1 -1
  20. package/dist/commands/exec.js +8 -4
  21. package/dist/commands/factory.d.ts +1 -1
  22. package/dist/commands/fleet-capture.d.ts +1 -1
  23. package/dist/commands/fleet-capture.js +4 -5
  24. package/dist/commands/harness.js +1 -49
  25. package/dist/commands/hooks.js +1 -1
  26. package/dist/commands/insights.d.ts +1 -1
  27. package/dist/commands/insights.js +85 -156
  28. package/dist/commands/menubar.js +58 -1
  29. package/dist/commands/output.js +2 -2
  30. package/dist/commands/prune.js +5 -4
  31. package/dist/commands/routines.test-fixture.d.ts +86 -0
  32. package/dist/commands/routines.test-fixture.js +345 -0
  33. package/dist/commands/sessions.test-fixture.d.ts +24 -0
  34. package/dist/commands/sessions.test-fixture.js +224 -0
  35. package/dist/commands/setup.js +5 -1
  36. package/dist/commands/share.d.ts +9 -10
  37. package/dist/commands/share.js +68 -50
  38. package/dist/commands/skills.js +1 -1
  39. package/dist/commands/ssh.js +15 -62
  40. package/dist/commands/subagents.js +1 -1
  41. package/dist/commands/view.js +5 -2
  42. package/dist/lib/analytics/mix-commands.d.ts +6 -28
  43. package/dist/lib/analytics/mix-commands.js +14 -40
  44. package/dist/lib/audit/log.d.ts +2 -2
  45. package/dist/lib/audit/log.js +2 -2
  46. package/dist/lib/beta.js +1 -1
  47. package/dist/lib/browser/domain-skills.d.ts +26 -6
  48. package/dist/lib/browser/domain-skills.js +81 -43
  49. package/dist/lib/daemon/daemon.js +11 -0
  50. package/dist/lib/daemon/daemon.test-fixture.d.ts +24 -0
  51. package/dist/lib/daemon/daemon.test-fixture.js +71 -0
  52. package/dist/lib/daemon-ticks.d.ts +38 -1
  53. package/dist/lib/daemon-ticks.js +52 -5
  54. package/dist/lib/device-config.js +1 -2
  55. package/dist/lib/devices/discovery-policy.d.ts +3 -3
  56. package/dist/lib/devices/discovery-policy.js +3 -3
  57. package/dist/lib/devices/fleet.d.ts +4 -8
  58. package/dist/lib/devices/fleet.js +3 -15
  59. package/dist/lib/devices/health-report.js +3 -3
  60. package/dist/lib/devices/pool.d.ts +0 -6
  61. package/dist/lib/devices/pool.js +0 -6
  62. package/dist/lib/devices/registry.d.ts +0 -15
  63. package/dist/lib/devices/registry.js +0 -9
  64. package/dist/lib/devices/stats-cache.d.ts +12 -0
  65. package/dist/lib/devices/stats-cache.js +24 -5
  66. package/dist/lib/fleet/capture.d.ts +1 -1
  67. package/dist/lib/fleet/manifest.js +1 -1
  68. package/dist/lib/fleet/remote-login.d.ts +2 -2
  69. package/dist/lib/fleet/remote-login.js +3 -4
  70. package/dist/lib/hosts/dispatch.d.ts +2 -0
  71. package/dist/lib/hosts/dispatch.js +8 -1
  72. package/dist/lib/hosts/passthrough.js +0 -1
  73. package/dist/lib/hosts/providers/devices.js +1 -13
  74. package/dist/lib/hosts/ready.d.ts +12 -3
  75. package/dist/lib/hosts/ready.js +27 -12
  76. package/dist/lib/hosts/registry.d.ts +4 -5
  77. package/dist/lib/hosts/registry.js +3 -7
  78. package/dist/lib/installations/versions.js +21 -2
  79. package/dist/lib/menubar/install-menubar.d.ts +109 -0
  80. package/dist/lib/menubar/install-menubar.js +199 -4
  81. package/dist/lib/remote-agents-json.js +1 -7
  82. package/dist/lib/routines-placement.d.ts +1 -1
  83. package/dist/lib/routines-placement.js +1 -1
  84. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  85. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +0 -2
  86. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  87. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +1 -13
  88. package/dist/lib/session/active.d.ts +4 -16
  89. package/dist/lib/session/active.js +2 -6
  90. package/dist/lib/session/db.d.ts +13 -98
  91. package/dist/lib/session/db.js +4 -11
  92. package/dist/lib/session/discover.d.ts +5 -81
  93. package/dist/lib/session/discover.js +5 -5
  94. package/dist/lib/session/remote/remote-bundle.d.ts +12 -0
  95. package/dist/lib/session/remote/remote-bundle.js +61 -0
  96. package/dist/lib/session/remote/remote-list.d.ts +148 -0
  97. package/dist/lib/session/remote/remote-list.js +607 -0
  98. package/dist/lib/session/remote/remote.d.ts +132 -0
  99. package/dist/lib/session/remote/remote.js +314 -0
  100. package/dist/lib/session/remote/watch.d.ts +101 -0
  101. package/dist/lib/session/remote/watch.js +241 -0
  102. package/dist/lib/session/remote-bundle.d.ts +1 -12
  103. package/dist/lib/session/remote-bundle.js +3 -61
  104. package/dist/lib/session/remote-list.d.ts +1 -148
  105. package/dist/lib/session/remote-list.js +3 -607
  106. package/dist/lib/session/remote.d.ts +1 -132
  107. package/dist/lib/session/remote.js +3 -314
  108. package/dist/lib/session/watch.d.ts +1 -101
  109. package/dist/lib/session/watch.js +3 -242
  110. package/dist/lib/share/config.d.ts +12 -2
  111. package/dist/lib/share/config.js +47 -11
  112. package/dist/lib/share/delete.js +1 -1
  113. package/dist/lib/share/publish.js +1 -1
  114. package/dist/lib/share/worker-template.js +2 -2
  115. package/dist/lib/smart-launch.d.ts +4 -4
  116. package/dist/lib/smart-launch.js +8 -18
  117. package/dist/lib/staleness/index.d.ts +3 -1
  118. package/dist/lib/staleness/index.js +21 -1
  119. package/dist/lib/staleness/types.d.ts +11 -0
  120. package/dist/lib/staleness/writers/commands.js +13 -4
  121. package/dist/lib/staleness/writers/hooks.js +3 -1
  122. package/dist/lib/staleness/writers/mcp.d.ts +0 -7
  123. package/dist/lib/staleness/writers/mcp.js +16 -1
  124. package/dist/lib/staleness/writers/rules.js +1 -1
  125. package/dist/lib/staleness/writers/skills.js +3 -1
  126. package/dist/lib/staleness/writers/subagents.d.ts +0 -9
  127. package/dist/lib/staleness/writers/subagents.js +18 -1
  128. package/dist/lib/staleness/writers/types.d.ts +10 -0
  129. package/dist/lib/startup/command-registry.d.ts +8 -1
  130. package/dist/lib/startup/command-registry.js +21 -6
  131. package/dist/lib/startup/root-command.d.ts +18 -1
  132. package/dist/lib/startup/root-command.js +18 -1
  133. package/dist/lib/state.js +7 -0
  134. package/dist/lib/teams/placement-probe.js +27 -12
  135. package/dist/lib/teams/scheduler.d.ts +3 -1
  136. package/dist/lib/types.d.ts +1 -1
  137. package/dist/lib/view-types.d.ts +3 -0
  138. package/package.json +2 -3
  139. package/dist/bin/agents +0 -0
  140. package/dist/commands/auth.d.ts +0 -9
  141. package/dist/commands/auth.js +0 -108
  142. package/dist/commands/org.d.ts +0 -6
  143. package/dist/commands/org.js +0 -175
  144. package/dist/commands/serve.d.ts +0 -10
  145. package/dist/commands/serve.js +0 -68
  146. package/dist/commands/trends.d.ts +0 -10
  147. package/dist/commands/trends.js +0 -12
  148. package/dist/lib/entitlement.d.ts +0 -31
  149. package/dist/lib/entitlement.js +0 -137
  150. package/dist/lib/prix-account.d.ts +0 -158
  151. package/dist/lib/prix-account.js +0 -214
  152. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  153. package/dist/lib/serve/control.d.ts +0 -95
  154. package/dist/lib/serve/control.js +0 -260
  155. package/dist/lib/serve/data.d.ts +0 -81
  156. package/dist/lib/serve/data.js +0 -91
  157. package/dist/lib/serve/page.d.ts +0 -7
  158. package/dist/lib/serve/page.js +0 -140
  159. package/dist/lib/serve/server.d.ts +0 -80
  160. package/dist/lib/serve/server.js +0 -145
  161. package/dist/lib/serve/stream.d.ts +0 -43
  162. package/dist/lib/serve/stream.js +0 -116
  163. package/dist/lib/serve/token.d.ts +0 -35
  164. package/dist/lib/serve/token.js +0 -85
@@ -1,607 +1,3 @@
1
- /**
2
- * Cross-machine fan-out for the default `agents sessions` listing.
3
- *
4
- * `discoverSessions()` only scans the local disk. To browse the whole fleet in
5
- * one list — without syncing anything — we run `agents sessions <same query>
6
- * --json` on each peer over SSH and merge the parsed `SessionMeta[]`, tagging
7
- * every row with the machine it came from so the picker/table can label and
8
- * group by computer.
9
- *
10
- * This is the browse-listing sibling of `remote-active.ts` (which fans out
11
- * `--active`): same transport, same device set, same recursion guard. The peer
12
- * runs with `AGENTS_SESSIONS_LOCAL=1` so it answers only for itself and the
13
- * sweep never recurses. A dead or slow host is skipped with a stderr note,
14
- * never fatal — one asleep laptop must not blank the list.
15
- */
16
- import { spawn } from 'child_process';
17
- import chalk from 'chalk';
18
- import { SSH_OPTS, controlOpts, assertValidSshTarget, shellQuote, REMOTE_STDOUT_MAX_BYTES, RemoteUtf8Accumulator, } from '../ssh-exec.js';
19
- import { sshTargetFor } from '../devices/connect.js';
20
- import { resolveExplicitTargetSet } from '../devices/resolve-target.js';
21
- import { loadDevices, isControlDevice, isDialableDevice } from '../devices/registry.js';
22
- import { remoteShellFor, buildWindowsAgentsCommand, stripClixml } from '../hosts/remote-cmd.js';
23
- import { gatherRemoteAgentsJson } from '../remote-agents-json.js';
24
- import { machineId, normalizeHost } from './sync/config.js';
25
- import { NO_FANOUT_ENV } from './remote-active.js';
26
- import { terminalWidth } from '../text/width.js';
27
- import { sanitizeForTerminal } from '../redact.js';
28
- import { mapBounded } from '../concurrency.js';
29
- import { TOOL_QUERY_MAX_CLAUSE_BYTES, TOOL_QUERY_MAX_CALL_ROWS, TOOL_QUERY_MAX_CLAUSES, TOOL_QUERY_MAX_RESULT_SESSIONS, TOOL_QUERY_MAX_SERIALIZED_BYTES, serializedToolSearchEnvelopeBytes, } from './tool-index.js';
30
- import { TOOL_ERROR_OUTPUT_MAX_BYTES, TOOL_INPUT_MAX_BYTES, TOOL_SUCCESS_OUTPUT_MAX_BYTES, sanitizeToolEvidenceText, } from './tool-calls.js';
31
- const REMOTE_TOOL_TIMEOUT_MS = 60_000;
32
- // The per-peer stdout ceiling and the UTF-8-safe accumulator live in ssh-exec.ts
33
- // (the shared SSH transport both this reader and the top-level `remote-agents-json`
34
- // fan-out import), so the bound is defined once. Re-exported here for the existing
35
- // consumers/tests that reach them through this module.
36
- export { REMOTE_STDOUT_MAX_BYTES, RemoteUtf8Accumulator } from '../ssh-exec.js';
37
- export const REMOTE_TOOL_AGGREGATE_MAX_BYTES = TOOL_QUERY_MAX_SERIALIZED_BYTES;
38
- /** Claim received bytes against one fleet-query budget before retaining them. */
39
- export function consumeRemoteToolByteBudget(budget, bytes) {
40
- if (budget.exhausted || bytes > budget.remainingBytes) {
41
- budget.remainingBytes = 0;
42
- budget.exhausted = true;
43
- return false;
44
- }
45
- budget.remainingBytes -= bytes;
46
- if (budget.remainingBytes === 0)
47
- budget.exhausted = true;
48
- return true;
49
- }
50
- /** Charge sanitized, machine-stamped evidence because redaction may expand it. */
51
- export function consumeParsedRemoteToolSearchBudget(budget, envelope) {
52
- return consumeRemoteToolByteBudget(budget, serializedToolSearchEnvelopeBytes(envelope));
53
- }
54
- /**
55
- * The command run on each peer: answer for itself, as JSON, without recursing.
56
- * `forwardedArgs` carry the caller's own query/filters (already including the
57
- * leading `sessions` and a `--json`) so each peer returns a comparable slice.
58
- * A Windows peer gets a PowerShell invocation (ssh lands in cmd.exe/PowerShell
59
- * there, where `bash -lc` is not a command); every other OS keeps `bash -lc`.
60
- */
61
- export function remoteListCommand(forwardedArgs, os) {
62
- if (remoteShellFor(os) === 'powershell') {
63
- return buildWindowsAgentsCommand({
64
- args: forwardedArgs,
65
- env: { [NO_FANOUT_ENV]: '1' },
66
- });
67
- }
68
- const inner = [`${NO_FANOUT_ENV}=1`, 'agents', ...forwardedArgs].map((t, i) => i === 0 ? t : shellQuote(t)).join(' ');
69
- return `bash -lc ${shellQuote(inner)}`;
70
- }
71
- /**
72
- * Parse a peer's `sessions --json` stdout into `SessionMeta[]`, tagging each
73
- * with `machine`. Defensive against version skew / partial output: non-JSON or
74
- * a non-array yields `[]`, and non-object entries are dropped rather than
75
- * throwing. The `machine` we dialed always wins over any value the peer set on
76
- * its own rows, so grouping keys off the computer we asked. Exported for unit
77
- * testing without a live tailnet.
78
- */
79
- export function parseRemoteList(stdout, machine) {
80
- let parsed;
81
- try {
82
- parsed = JSON.parse(stripClixml(stdout));
83
- }
84
- catch {
85
- return [];
86
- }
87
- if (!Array.isArray(parsed))
88
- return [];
89
- return parsed.flatMap((value) => value && typeof value === 'object' && !Array.isArray(value)
90
- ? [{ ...value, machine, _remote: true }]
91
- : []);
92
- }
93
- /** Strict parser used at the live peer boundary. A successful process with
94
- * malformed/non-array JSON is an incomplete source, not an empty machine. */
95
- const SAFE_RESOLVER_KEYS = new Set([
96
- 'id', 'shortId', 'agent', 'origin', 'timestamp', 'lastActivity', 'project',
97
- 'version', 'label', 'topic', 'machine',
98
- ]);
99
- function isSafeResolverRow(value) {
100
- if (typeof value.id !== 'string' || typeof value.shortId !== 'string'
101
- || typeof value.agent !== 'string' || typeof value.timestamp !== 'string')
102
- return false;
103
- return Object.keys(value).every(key => SAFE_RESOLVER_KEYS.has(key));
104
- }
105
- export function parseRemoteListPayload(stdout, machine, safeResolver = false) {
106
- let parsed;
107
- try {
108
- parsed = JSON.parse(stripClixml(stdout));
109
- }
110
- catch {
111
- return { items: [], valid: false };
112
- }
113
- if (!Array.isArray(parsed))
114
- return { items: [], valid: false };
115
- const out = [];
116
- for (const x of parsed) {
117
- if (!x || typeof x !== 'object' || Array.isArray(x))
118
- return { items: [], valid: false };
119
- if (safeResolver && !isSafeResolverRow(x)) {
120
- return { items: [], valid: false };
121
- }
122
- // `_remote` marks these as living on the peer's disk (not a local mirror),
123
- // so the picker routes read/resume back over SSH instead of the local FS.
124
- out.push({ ...x, machine, _remote: true });
125
- }
126
- return { items: out, valid: true };
127
- }
128
- /** Run one remote `agents sessions … --json` and capture stdout. Resolves
129
- * `{ code: null }` on spawn error or timeout (host treated as dead). */
130
- export function sshCapture(target, remoteCmd, timeoutMs, aggregateBudget, options = {}) {
131
- assertValidSshTarget(target);
132
- return new Promise((resolve) => {
133
- const args = [
134
- ...(options.hostKeyOpts ?? []),
135
- ...SSH_OPTS,
136
- ...(options.multiplex === false ? [] : controlOpts()),
137
- ...(options.port === undefined ? [] : ['-p', String(options.port)]),
138
- target,
139
- remoteCmd,
140
- ];
141
- const child = spawn('ssh', args, { stdio: ['ignore', 'pipe', 'ignore'] });
142
- const decoded = new RemoteUtf8Accumulator();
143
- let stdoutBytes = 0;
144
- let aggregateBudgetExceeded = false;
145
- let settled = false;
146
- const done = (code) => {
147
- if (settled)
148
- return;
149
- settled = true;
150
- clearTimeout(timer);
151
- const stdout = code === null ? decoded.current() : decoded.end();
152
- resolve({ code, stdout, aggregateBudgetExceeded: aggregateBudgetExceeded || undefined });
153
- };
154
- const timer = setTimeout(() => { child.kill('SIGKILL'); done(null); }, timeoutMs);
155
- child.stdout.on('data', (d) => {
156
- if (stdoutBytes + d.byteLength > REMOTE_STDOUT_MAX_BYTES) {
157
- child.kill('SIGKILL');
158
- done(null);
159
- return;
160
- }
161
- if (aggregateBudget && !consumeRemoteToolByteBudget(aggregateBudget, d.byteLength)) {
162
- aggregateBudgetExceeded = true;
163
- child.kill('SIGKILL');
164
- done(null);
165
- return;
166
- }
167
- stdoutBytes += d.byteLength;
168
- decoded.write(d);
169
- });
170
- child.on('error', () => done(null));
171
- child.on('close', (code) => done(code));
172
- });
173
- }
174
- /** Keep browse and tool-search fan-out on the same automatic peer set. */
175
- export function isAutomaticSessionPeer(d, self) {
176
- if (!isDialableDevice(d))
177
- return false;
178
- if (normalizeHost(d.name) === self || isControlDevice(d))
179
- return false;
180
- return d.platform === 'windows' || d.platform === 'linux' || d.platform === 'macos';
181
- }
182
- export async function gatherRemoteList(forwardedArgs, hosts, opts) {
183
- const safeResolver = forwardedArgs.includes('--resolve-safe-v1');
184
- const result = await gatherRemoteAgentsJson({
185
- args: forwardedArgs,
186
- noFanoutEnv: NO_FANOUT_ENV,
187
- hosts,
188
- timeoutMs: opts?.timeoutMs,
189
- earlyExit: opts?.isDefinitive ? { isDefinitive: opts.isDefinitive } : undefined,
190
- parse: (stdout, machine) => parseRemoteListPayload(stdout, machine, safeResolver),
191
- });
192
- return {
193
- sessions: result.items,
194
- deviceCount: result.deviceCount,
195
- unreachable: [
196
- ...(result.discoveryFailed ? ['device registry'] : []),
197
- ...result.skipped,
198
- ...result.parseFailed,
199
- ],
200
- };
201
- }
202
- export function parseRemoteToolProgramCount(stdout, machine, expectedProgram) {
203
- try {
204
- const parsed = JSON.parse(stdout);
205
- const query = parsed.query;
206
- const coverage = parsed.coverage;
207
- const totals = parsed.totals;
208
- const machines = parsed.machines;
209
- const coverageKeys = ['indexedFiles', 'indexedCalls', 'skippedFiles', 'limitedFiles', 'remainingFiles'];
210
- const totalKeys = ['occurrences', 'toolCalls', 'sessions'];
211
- if (parsed.schemaVersion !== 1 || parsed.kind !== 'tool-program-count'
212
- || !query || query.program !== expectedProgram || query.semantics !== 'static-program-occurrences-v1'
213
- || !coverage || typeof coverage.complete !== 'boolean'
214
- || coverageKeys.some((key) => !Number.isSafeInteger(coverage[key]) || coverage[key] < 0)
215
- || !totals || totalKeys.some((key) => !Number.isSafeInteger(totals[key]) || totals[key] < 0)
216
- || !Array.isArray(machines) || machines.length !== 1
217
- || boundedRemoteString(parsed.generatedAt, 128) === undefined) {
218
- return { items: [], valid: false };
219
- }
220
- return {
221
- valid: true,
222
- items: [{
223
- machine,
224
- envelope: {
225
- schemaVersion: 1,
226
- kind: 'tool-program-count',
227
- generatedAt: parsed.generatedAt,
228
- query: { program: expectedProgram, semantics: 'static-program-occurrences-v1' },
229
- coverage: coverage,
230
- totals: totals,
231
- machines: [{
232
- machine,
233
- coverage: coverage,
234
- totals: totals,
235
- }],
236
- },
237
- }],
238
- };
239
- }
240
- catch {
241
- return { items: [], valid: false };
242
- }
243
- }
244
- export async function gatherRemoteToolProgramCounts(forwardedArgs, hosts, expectedProgram) {
245
- const result = await gatherRemoteAgentsJson({
246
- args: forwardedArgs,
247
- noFanoutEnv: NO_FANOUT_ENV,
248
- hosts,
249
- timeoutMs: REMOTE_TOOL_TIMEOUT_MS,
250
- parse: (stdout, machine) => parseRemoteToolProgramCount(stdout, machine, expectedProgram),
251
- });
252
- return {
253
- envelopes: result.items,
254
- deviceCount: result.deviceCount,
255
- unreachable: [
256
- ...(result.discoveryFailed ? ['device registry'] : []),
257
- ...result.skipped,
258
- ...result.parseFailed,
259
- ],
260
- };
261
- }
262
- function boundedRemoteString(value, maxBytes) {
263
- if (typeof value !== 'string' || Buffer.byteLength(value) > maxBytes)
264
- return undefined;
265
- return sanitizeToolEvidenceText(value, maxBytes);
266
- }
267
- function optionalRemoteString(value, maxBytes) {
268
- if (value === undefined || value === null)
269
- return undefined;
270
- return boundedRemoteString(value, maxBytes) ?? null;
271
- }
272
- function parseRemoteCall(value) {
273
- if (!value || typeof value !== 'object' || Array.isArray(value))
274
- return undefined;
275
- const call = value;
276
- const id = boundedRemoteString(call.id, 512);
277
- const timestamp = boundedRemoteString(call.timestamp, 128);
278
- const tool = boundedRemoteString(call.tool, 512);
279
- const input = boundedRemoteString(call.input, TOOL_INPUT_MAX_BYTES);
280
- const sourceCallId = optionalRemoteString(call.sourceCallId, 512);
281
- const output = optionalRemoteString(call.output, TOOL_SUCCESS_OUTPUT_MAX_BYTES);
282
- const error = optionalRemoteString(call.error, TOOL_ERROR_OUTPUT_MAX_BYTES);
283
- const errorCode = optionalRemoteString(call.errorCode, 512);
284
- const parseError = optionalRemoteString(call.parseError, 1024);
285
- if (call.programs !== undefined
286
- && (!Array.isArray(call.programs) || call.programs.length > 128))
287
- return undefined;
288
- const programs = Array.isArray(call.programs)
289
- ? call.programs.map((program) => boundedRemoteString(program, 512))
290
- : [];
291
- if (!Array.isArray(call.programOccurrences) || call.programOccurrences.length > 10_000)
292
- return undefined;
293
- const programOccurrences = call.programOccurrences.map((value) => {
294
- if (!value || typeof value !== 'object' || Array.isArray(value))
295
- return undefined;
296
- const occurrence = value;
297
- const program = boundedRemoteString(occurrence.program, 512);
298
- if (!program || (occurrence.role !== 'wrapper' && occurrence.role !== 'effective'))
299
- return undefined;
300
- return { program, role: occurrence.role };
301
- });
302
- if (!id || !timestamp || !tool || input === undefined
303
- || !Number.isSafeInteger(call.ordinal) || call.ordinal < 0
304
- || !['ok', 'error', 'unknown'].includes(String(call.outcome))
305
- || sourceCallId === null || output === null || error === null || errorCode === null || parseError === null
306
- || programs.some((program) => program === undefined)
307
- || programOccurrences.some((occurrence) => occurrence === undefined))
308
- return undefined;
309
- for (const code of [call.exitCode, call.statusCode]) {
310
- if (code !== undefined && (!Number.isSafeInteger(code) || code < 0))
311
- return undefined;
312
- }
313
- return {
314
- id,
315
- ordinal: call.ordinal,
316
- sourceCallId,
317
- timestamp,
318
- tool,
319
- programs: programs,
320
- programOccurrences: programOccurrences,
321
- input,
322
- outcome: call.outcome,
323
- exitCode: call.exitCode,
324
- statusCode: call.statusCode,
325
- errorCode,
326
- output,
327
- error,
328
- parseError,
329
- };
330
- }
331
- function parseRemoteToolSession(value, machine) {
332
- if (!value || typeof value !== 'object' || Array.isArray(value))
333
- return undefined;
334
- const session = value;
335
- const id = boundedRemoteString(session.id, 512);
336
- const shortId = boundedRemoteString(session.shortId, 128);
337
- const agent = boundedRemoteString(session.agent, 128);
338
- const timestamp = boundedRemoteString(session.timestamp, 128);
339
- const project = optionalRemoteString(session.project, 4096);
340
- const cwd = optionalRemoteString(session.cwd, 4096);
341
- const topic = optionalRemoteString(session.topic, 4096);
342
- const label = optionalRemoteString(session.label, 4096);
343
- const originMachine = optionalRemoteString(session.machine, 512);
344
- const dialedMachine = boundedRemoteString(machine, 512);
345
- if (!id || !shortId || !agent || !timestamp || !dialedMachine || originMachine === null
346
- || project === null || cwd === null || topic === null || label === null
347
- || !Array.isArray(session.calls) || session.calls.length > TOOL_QUERY_MAX_CALL_ROWS)
348
- return undefined;
349
- const calls = session.calls.map(parseRemoteCall);
350
- if (calls.some((call) => call === undefined))
351
- return undefined;
352
- return {
353
- id,
354
- shortId,
355
- agent,
356
- machine: originMachine ?? dialedMachine,
357
- timestamp,
358
- project,
359
- cwd,
360
- topic,
361
- label,
362
- calls: calls,
363
- };
364
- }
365
- export function parseRemoteToolSearch(stdout, machine, expectedClauses) {
366
- // The fleet tool-search fan-out reads a raw sshCapture (not the stripped
367
- // gatherRemoteAgentsJson wrapper), so a Windows peer's PowerShell CLIXML banner
368
- // must be removed here too or the box reads as "no envelope" (RUSH-2286).
369
- stdout = stripClixml(stdout);
370
- if (Buffer.byteLength(stdout) > REMOTE_STDOUT_MAX_BYTES)
371
- return undefined;
372
- try {
373
- const parsed = JSON.parse(stdout);
374
- const coverage = parsed?.coverage;
375
- const query = parsed?.query;
376
- if (parsed?.schemaVersion !== 1 || !Array.isArray(parsed.sessions)
377
- || parsed.sessions.length > TOOL_QUERY_MAX_RESULT_SESSIONS || !coverage || !query
378
- || !Array.isArray(query.clauses) || query.clauses.length > TOOL_QUERY_MAX_CLAUSES
379
- || query.clauses.some((clause) => boundedRemoteString(clause, TOOL_QUERY_MAX_CLAUSE_BYTES) === undefined))
380
- return undefined;
381
- let totalCalls = 0;
382
- for (const sessionValue of parsed.sessions) {
383
- if (!sessionValue || typeof sessionValue !== 'object' || Array.isArray(sessionValue))
384
- return undefined;
385
- const calls = sessionValue.calls;
386
- if (!Array.isArray(calls) || calls.length > TOOL_QUERY_MAX_CALL_ROWS)
387
- return undefined;
388
- totalCalls += calls.length;
389
- if (totalCalls > TOOL_QUERY_MAX_CALL_ROWS)
390
- return undefined;
391
- }
392
- const clauses = query.clauses.map((clause) => sanitizeForTerminal(clause));
393
- const expected = expectedClauses?.map((clause) => sanitizeForTerminal(clause));
394
- if (expected && (clauses.length !== expected.length
395
- || clauses.some((clause, index) => clause !== expected[index])))
396
- return undefined;
397
- const coverageNumbers = ['indexedFiles', 'indexedCalls', 'skippedFiles', 'limitedFiles', 'remainingFiles'];
398
- if (coverageNumbers.some((key) => !Number.isSafeInteger(coverage[key]) || coverage[key] < 0)
399
- || typeof coverage.complete !== 'boolean')
400
- return undefined;
401
- const sessions = parsed.sessions.map((session) => parseRemoteToolSession(session, machine));
402
- const generatedAt = boundedRemoteString(parsed.generatedAt, 128);
403
- if (!generatedAt || sessions.some((session) => session === undefined))
404
- return undefined;
405
- return {
406
- schemaVersion: 1,
407
- generatedAt,
408
- query: { clauses },
409
- coverage: {
410
- indexedFiles: coverage.indexedFiles,
411
- indexedCalls: coverage.indexedCalls,
412
- skippedFiles: coverage.skippedFiles,
413
- limitedFiles: coverage.limitedFiles,
414
- remainingFiles: coverage.remainingFiles,
415
- complete: coverage.complete,
416
- },
417
- sessions: sessions,
418
- };
419
- }
420
- catch {
421
- return undefined;
422
- }
423
- }
424
- /**
425
- * Fleet sibling of {@link gatherRemoteList} for the versioned tool-search
426
- * envelope. Each peer executes the same local-only query against its own
427
- * SQLite index; only compact matches cross SSH.
428
- */
429
- export async function gatherRemoteToolSearch(forwardedArgs, hosts, maxAggregateBytes = REMOTE_TOOL_AGGREGATE_MAX_BYTES, expectedClauses = []) {
430
- const self = machineId();
431
- const targets = [];
432
- const unresolved = [];
433
- if (hosts && hosts.length > 0) {
434
- const resolved = await resolveExplicitTargetSet(hosts);
435
- targets.push(...resolved.targets);
436
- unresolved.push(...resolved.unresolved);
437
- }
438
- else {
439
- let reg;
440
- try {
441
- reg = await loadDevices();
442
- }
443
- catch {
444
- return { envelopes: [], deviceCount: 0, unreachable: ['device registry'], truncated: [] };
445
- }
446
- for (const d of Object.values(reg)) {
447
- if (!isAutomaticSessionPeer(d, self))
448
- continue;
449
- try {
450
- targets.push({ target: sshTargetFor(d), machine: normalizeHost(d.name), name: d.name, os: d.platform });
451
- }
452
- catch {
453
- // A registered control record without a dialable address is not a query target.
454
- }
455
- }
456
- }
457
- const remainingBytes = Math.max(0, Math.min(REMOTE_TOOL_AGGREGATE_MAX_BYTES, maxAggregateBytes));
458
- const aggregateBudget = {
459
- remainingBytes,
460
- exhausted: remainingBytes === 0,
461
- };
462
- const parsedBudget = {
463
- remainingBytes,
464
- exhausted: remainingBytes === 0,
465
- };
466
- const results = await mapBounded(targets, async (target) => {
467
- if (aggregateBudget.exhausted)
468
- return { target, truncated: target.name };
469
- const capture = await sshCapture(target.target, remoteListCommand(forwardedArgs, target.os), REMOTE_TOOL_TIMEOUT_MS, aggregateBudget,
470
- // A tool-index deadline must own the SSH connection. Killing a
471
- // multiplexed child would leave its remote command running on the
472
- // persistent control master.
473
- { multiplex: false });
474
- if (capture.aggregateBudgetExceeded)
475
- return { target, truncated: target.name };
476
- if (capture.code !== 0)
477
- return { target, unreachable: target.name };
478
- const envelope = parseRemoteToolSearch(capture.stdout, target.machine, expectedClauses);
479
- if (!envelope)
480
- return { target, unreachable: target.name };
481
- if (!consumeParsedRemoteToolSearchBudget(parsedBudget, envelope)) {
482
- return { target, truncated: target.name };
483
- }
484
- return { target, envelope };
485
- }, { concurrency: 6 });
486
- for (const result of results) {
487
- if (result.unreachable) {
488
- process.stderr.write(chalk.gray(` ${result.unreachable}: unreachable, incompatible, or no agents CLI — skipped\n`));
489
- }
490
- if (result.truncated) {
491
- process.stderr.write(chalk.gray(` ${result.truncated}: fleet tool-result budget exhausted — skipped\n`));
492
- }
493
- }
494
- return {
495
- envelopes: results.flatMap((result) => result.envelope
496
- ? [{ machine: result.target.machine, envelope: result.envelope }]
497
- : []),
498
- deviceCount: targets.length,
499
- unreachable: [...unresolved, ...results.map((result) => result.unreachable).filter((name) => !!name)],
500
- truncated: results.map((result) => result.truncated).filter((name) => !!name),
501
- };
502
- }
503
- /** Resolve a peer's SSH target (and OS) from the device registry by its
504
- * normalized machine id — the same id the fan-out tags rows with. Returns
505
- * undefined when no registered device with an address matches. */
506
- export async function resolvePeerTarget(machine) {
507
- let reg;
508
- try {
509
- reg = await loadDevices();
510
- }
511
- catch {
512
- return undefined;
513
- }
514
- for (const d of Object.values(reg)) {
515
- if (normalizeHost(d.name) !== machine)
516
- continue;
517
- try {
518
- return { target: sshTargetFor(d), os: d.platform };
519
- }
520
- catch {
521
- return undefined; // matched the machine, but it has no address to dial
522
- }
523
- }
524
- return undefined;
525
- }
526
- /** Interactive ceiling for a picker-driven peer preview fetch. Tighter than the
527
- * fan-out's 60s: the user is arrowing through rows, and a peer that can't
528
- * answer a one-session digest in this window should degrade to the metadata
529
- * card rather than hold a "fetching…" pane open. */
530
- const PEER_PREVIEW_TIMEOUT_MS = 15_000;
531
- /**
532
- * Fetch one remote session's preview digest from its owning peer — the data
533
- * behind the picker pane for a `_remote` row, whose transcript file this
534
- * machine cannot parse. Runs the peer's own `agents sessions preview <id>
535
- * --local --json` (the same envelope `agents sessions preview` already
536
- * delegates to for a remote id) and returns its `preview` object, verbatim and
537
- * UNSANITIZED — the caller owns scrubbing peer-supplied strings before any of
538
- * them reach a TTY. Undefined on every failure: unregistered machine, SSH
539
- * error, timeout, version-skewed peer with no `--json` preview envelope.
540
- */
541
- export async function fetchPeerPreviewDigest(sessionId, machine, timeoutMs = PEER_PREVIEW_TIMEOUT_MS) {
542
- const peer = await resolvePeerTarget(machine);
543
- if (!peer)
544
- return undefined;
545
- const cmd = remoteListCommand(['sessions', 'preview', sessionId, '--local', '--json'], peer.os);
546
- const capture = await sshCapture(peer.target, cmd, timeoutMs);
547
- if (capture.code !== 0)
548
- return undefined;
549
- let parsed;
550
- try {
551
- parsed = JSON.parse(stripClixml(capture.stdout));
552
- }
553
- catch {
554
- return undefined;
555
- }
556
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
557
- return undefined;
558
- const preview = parsed.preview;
559
- if (!preview || typeof preview !== 'object' || Array.isArray(preview))
560
- return undefined;
561
- return preview;
562
- }
563
- /**
564
- * Run `agents <args>` ON a peer over SSH, attached to this terminal (inherited
565
- * stdio). `args` is the full arg vector after the binary — callers pass e.g.
566
- * `['sessions', id, '--markdown']` or `['sessions', 'resume', id]`. Used when a
567
- * picked session lives on another machine: its transcript and agent binary are
568
- * there, so both reading (no TTY) and resuming (TTY) must execute on the peer —
569
- * not via a local `--device` hop, which would discover locally and dead-end for a
570
- * session that exists only on the peer. Resolves 'no-target' when the machine
571
- * isn't a dialable registered device; the caller surfaces a clear message.
572
- *
573
- * `opts.env` adds variables to the remote command. It deliberately does NOT
574
- * carry `AGENTS_FLEET_REMOTE` the way the `--device` passthrough does: that marker
575
- * gates consent-sensitive actions on the far side
576
- * (lib/browser/remote-control.ts), and a resumed agent is a long-lived session
577
- * that would inherit it for its whole life — `agents browser start` inside it
578
- * would then be refused as a cross-machine drive. A one-shot `--device` command
579
- * can carry the marker; a session cannot.
580
- */
581
- export async function runOnPeer(args, machine, opts = {}) {
582
- const peer = await resolvePeerTarget(machine);
583
- if (!peer)
584
- return 'no-target';
585
- assertValidSshTarget(peer.target); // registry-sourced, but validate like the fan-out does
586
- const cols = terminalWidth();
587
- const env = { ...(cols > 0 ? { COLUMNS: String(cols) } : {}), ...opts.env };
588
- const assignments = Object.entries(env).map(([k, v]) => `${k}=${shellQuote(v)}`);
589
- const remoteCmd = remoteShellFor(peer.os) === 'powershell'
590
- ? buildWindowsAgentsCommand({ args, env: assignments.length ? env : undefined })
591
- : `bash -lc ${shellQuote(assignments.concat(['agents', ...args].map(shellQuote)).join(' '))}`;
592
- const sshArgs = [...SSH_OPTS, ...controlOpts()];
593
- if (opts.tty)
594
- sshArgs.push('-tt'); // force a PTY so the resumed agent is interactive
595
- sshArgs.push(peer.target, remoteCmd);
596
- return new Promise((resolve) => {
597
- const child = spawn('ssh', sshArgs, { stdio: 'inherit' });
598
- // ssh prints its own connection errors to the inherited stderr; a spawn
599
- // failure (e.g. ssh not on PATH) has no such output, so name it. Either way
600
- // we resolve once it settles so the picker flow completes.
601
- child.on('error', (err) => {
602
- process.stderr.write(chalk.red(`Failed to reach ${machine}: ${err?.message ?? 'ssh failed to launch'}\n`));
603
- resolve('ok');
604
- });
605
- child.on('close', () => resolve('ok'));
606
- });
607
- }
1
+ // Moved to ./remote/remote-list.ts (move 4 of the core-module refactor).
2
+ // Kept as a re-export shim so existing importers don't break.
3
+ export * from './remote/remote-list.js';