@phnx-labs/agents-cli 1.21.3 → 1.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +67 -0
- package/README.md +32 -3
- package/dist/bin/agents +0 -0
- package/dist/commands/computer-actions.js +1 -0
- package/dist/commands/exec.d.ts +27 -0
- package/dist/commands/exec.js +123 -6
- package/dist/commands/models.js +36 -1
- package/dist/commands/projects.js +22 -2
- package/dist/commands/sessions-backfill.d.ts +32 -0
- package/dist/commands/sessions-backfill.js +186 -0
- package/dist/commands/sessions.d.ts +17 -1
- package/dist/commands/sessions.js +317 -18
- package/dist/commands/teams.js +1 -1
- package/dist/commands/worktree.d.ts +3 -3
- package/dist/commands/worktree.js +35 -4
- package/dist/lib/daemon.d.ts +5 -1
- package/dist/lib/daemon.js +63 -14
- package/dist/lib/devices/resolve-target.d.ts +6 -0
- package/dist/lib/devices/resolve-target.js +9 -3
- package/dist/lib/exec.js +39 -8
- package/dist/lib/hosts/dispatch.d.ts +12 -0
- package/dist/lib/hosts/dispatch.js +23 -6
- package/dist/lib/hosts/reconnect.d.ts +38 -0
- package/dist/lib/hosts/reconnect.js +85 -4
- package/dist/lib/hosts/run-target.js +14 -2
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/model-tiers.d.ts +54 -0
- package/dist/lib/model-tiers.js +229 -0
- package/dist/lib/models.d.ts +3 -0
- package/dist/lib/models.js +44 -7
- package/dist/lib/pricing/prices.json +16 -1
- package/dist/lib/project-focus.d.ts +42 -0
- package/dist/lib/project-focus.js +80 -0
- package/dist/lib/project-schedule.d.ts +75 -0
- package/dist/lib/project-schedule.js +110 -0
- package/dist/lib/redact.d.ts +2 -0
- package/dist/lib/redact.js +22 -0
- package/dist/lib/remote-agents-json.d.ts +2 -0
- package/dist/lib/remote-agents-json.js +3 -3
- package/dist/lib/rotate.d.ts +84 -1
- package/dist/lib/rotate.js +155 -5
- package/dist/lib/runner.d.ts +4 -2
- package/dist/lib/runner.js +13 -4
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/session/bash-command.js +60 -9
- package/dist/lib/session/db.d.ts +7 -1
- package/dist/lib/session/db.js +301 -32
- package/dist/lib/session/discover.d.ts +40 -7
- package/dist/lib/session/discover.js +144 -83
- package/dist/lib/session/parse.d.ts +8 -1
- package/dist/lib/session/parse.js +83 -32
- package/dist/lib/session/remote-list.d.ts +71 -0
- package/dist/lib/session/remote-list.js +410 -2
- package/dist/lib/session/shell-programs.d.ts +15 -0
- package/dist/lib/session/shell-programs.js +359 -0
- package/dist/lib/session/tool-calls.d.ts +88 -0
- package/dist/lib/session/tool-calls.js +612 -0
- package/dist/lib/session/tool-index.d.ts +100 -0
- package/dist/lib/session/tool-index.js +773 -0
- package/dist/lib/session/tool-store.d.ts +15 -0
- package/dist/lib/session/tool-store.js +198 -0
- package/dist/lib/session/types.d.ts +7 -0
- package/dist/lib/state.d.ts +10 -1
- package/dist/lib/state.js +11 -2
- package/dist/lib/teams/remoteWorktree.d.ts +3 -4
- package/dist/lib/teams/remoteWorktree.js +3 -4
- package/dist/lib/teams/worktree.d.ts +11 -1
- package/dist/lib/teams/worktree.js +42 -4
- package/dist/lib/types.d.ts +17 -0
- package/dist/lib/types.js +17 -0
- package/package.json +3 -1
|
@@ -14,15 +14,72 @@
|
|
|
14
14
|
* never fatal — one asleep laptop must not blank the list.
|
|
15
15
|
*/
|
|
16
16
|
import { spawn } from 'child_process';
|
|
17
|
+
import { StringDecoder } from 'string_decoder';
|
|
17
18
|
import chalk from 'chalk';
|
|
18
19
|
import { SSH_OPTS, controlOpts, assertValidSshTarget, shellQuote } from '../ssh-exec.js';
|
|
19
20
|
import { sshTargetFor } from '../devices/connect.js';
|
|
20
|
-
import {
|
|
21
|
+
import { resolveExplicitTargetSet } from '../devices/resolve-target.js';
|
|
22
|
+
import { loadDevices, isControlDevice, isDialableDevice } from '../devices/registry.js';
|
|
21
23
|
import { remoteShellFor, buildWindowsAgentsCommand } from '../hosts/remote-cmd.js';
|
|
22
24
|
import { gatherRemoteAgentsJson } from '../remote-agents-json.js';
|
|
23
|
-
import { normalizeHost } from './sync/config.js';
|
|
25
|
+
import { machineId, normalizeHost } from './sync/config.js';
|
|
24
26
|
import { NO_FANOUT_ENV } from './remote-active.js';
|
|
25
27
|
import { terminalWidth } from './width.js';
|
|
28
|
+
import { sanitizeForTerminal } from '../redact.js';
|
|
29
|
+
import { mapBounded } from '../concurrency.js';
|
|
30
|
+
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';
|
|
31
|
+
import { TOOL_ERROR_OUTPUT_MAX_BYTES, TOOL_INPUT_MAX_BYTES, TOOL_SUCCESS_OUTPUT_MAX_BYTES, sanitizeToolEvidenceText, } from './tool-calls.js';
|
|
32
|
+
const REMOTE_TOOL_TIMEOUT_MS = 60_000;
|
|
33
|
+
export const REMOTE_STDOUT_MAX_BYTES = 16 * 1024 * 1024;
|
|
34
|
+
export const REMOTE_TOOL_AGGREGATE_MAX_BYTES = TOOL_QUERY_MAX_SERIALIZED_BYTES;
|
|
35
|
+
/** Preserve UTF-8 code points when SSH splits them across stdout chunks. */
|
|
36
|
+
export class RemoteUtf8Accumulator {
|
|
37
|
+
decoder = new StringDecoder('utf8');
|
|
38
|
+
value = '';
|
|
39
|
+
write(chunk) {
|
|
40
|
+
this.value += this.decoder.write(chunk);
|
|
41
|
+
}
|
|
42
|
+
end() {
|
|
43
|
+
this.value += this.decoder.end();
|
|
44
|
+
return this.value;
|
|
45
|
+
}
|
|
46
|
+
current() {
|
|
47
|
+
return this.value;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Claim received bytes against one fleet-query budget before retaining them. */
|
|
51
|
+
export function consumeRemoteToolByteBudget(budget, bytes) {
|
|
52
|
+
if (budget.exhausted || bytes > budget.remainingBytes) {
|
|
53
|
+
budget.remainingBytes = 0;
|
|
54
|
+
budget.exhausted = true;
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
budget.remainingBytes -= bytes;
|
|
58
|
+
if (budget.remainingBytes === 0)
|
|
59
|
+
budget.exhausted = true;
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
/** Charge sanitized, machine-stamped evidence because redaction may expand it. */
|
|
63
|
+
export function consumeParsedRemoteToolSearchBudget(budget, envelope) {
|
|
64
|
+
return consumeRemoteToolByteBudget(budget, serializedToolSearchEnvelopeBytes(envelope));
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The command run on each peer: answer for itself, as JSON, without recursing.
|
|
68
|
+
* `forwardedArgs` carry the caller's own query/filters (already including the
|
|
69
|
+
* leading `sessions` and a `--json`) so each peer returns a comparable slice.
|
|
70
|
+
* A Windows peer gets a PowerShell invocation (ssh lands in cmd.exe/PowerShell
|
|
71
|
+
* there, where `bash -lc` is not a command); every other OS keeps `bash -lc`.
|
|
72
|
+
*/
|
|
73
|
+
export function remoteListCommand(forwardedArgs, os) {
|
|
74
|
+
if (remoteShellFor(os) === 'powershell') {
|
|
75
|
+
return buildWindowsAgentsCommand({
|
|
76
|
+
args: forwardedArgs,
|
|
77
|
+
env: { [NO_FANOUT_ENV]: '1' },
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
const inner = [`${NO_FANOUT_ENV}=1`, 'agents', ...forwardedArgs].map((t, i) => i === 0 ? t : shellQuote(t)).join(' ');
|
|
81
|
+
return `bash -lc ${shellQuote(inner)}`;
|
|
82
|
+
}
|
|
26
83
|
/**
|
|
27
84
|
* Parse a peer's `sessions --json` stdout into `SessionMeta[]`, tagging each
|
|
28
85
|
* with `machine`. Defensive against version skew / partial output: non-JSON or
|
|
@@ -80,6 +137,60 @@ export function parseRemoteListPayload(stdout, machine, safeResolver = false) {
|
|
|
80
137
|
}
|
|
81
138
|
return { items: out, valid: true };
|
|
82
139
|
}
|
|
140
|
+
/** Run one remote `agents sessions … --json` and capture stdout. Resolves
|
|
141
|
+
* `{ code: null }` on spawn error or timeout (host treated as dead). */
|
|
142
|
+
export function sshCapture(target, remoteCmd, timeoutMs, aggregateBudget, options = {}) {
|
|
143
|
+
assertValidSshTarget(target);
|
|
144
|
+
return new Promise((resolve) => {
|
|
145
|
+
const args = [
|
|
146
|
+
...(options.hostKeyOpts ?? []),
|
|
147
|
+
...SSH_OPTS,
|
|
148
|
+
...(options.multiplex === false ? [] : controlOpts()),
|
|
149
|
+
...(options.port === undefined ? [] : ['-p', String(options.port)]),
|
|
150
|
+
target,
|
|
151
|
+
remoteCmd,
|
|
152
|
+
];
|
|
153
|
+
const child = spawn('ssh', args, { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
154
|
+
const decoded = new RemoteUtf8Accumulator();
|
|
155
|
+
let stdoutBytes = 0;
|
|
156
|
+
let aggregateBudgetExceeded = false;
|
|
157
|
+
let settled = false;
|
|
158
|
+
const done = (code) => {
|
|
159
|
+
if (settled)
|
|
160
|
+
return;
|
|
161
|
+
settled = true;
|
|
162
|
+
clearTimeout(timer);
|
|
163
|
+
const stdout = code === null ? decoded.current() : decoded.end();
|
|
164
|
+
resolve({ code, stdout, aggregateBudgetExceeded: aggregateBudgetExceeded || undefined });
|
|
165
|
+
};
|
|
166
|
+
const timer = setTimeout(() => { child.kill('SIGKILL'); done(null); }, timeoutMs);
|
|
167
|
+
child.stdout.on('data', (d) => {
|
|
168
|
+
if (stdoutBytes + d.byteLength > REMOTE_STDOUT_MAX_BYTES) {
|
|
169
|
+
child.kill('SIGKILL');
|
|
170
|
+
done(null);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (aggregateBudget && !consumeRemoteToolByteBudget(aggregateBudget, d.byteLength)) {
|
|
174
|
+
aggregateBudgetExceeded = true;
|
|
175
|
+
child.kill('SIGKILL');
|
|
176
|
+
done(null);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
stdoutBytes += d.byteLength;
|
|
180
|
+
decoded.write(d);
|
|
181
|
+
});
|
|
182
|
+
child.on('error', () => done(null));
|
|
183
|
+
child.on('close', (code) => done(code));
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
/** Keep browse and tool-search fan-out on the same automatic peer set. */
|
|
187
|
+
export function isAutomaticSessionPeer(d, self) {
|
|
188
|
+
if (!isDialableDevice(d))
|
|
189
|
+
return false;
|
|
190
|
+
if (normalizeHost(d.name) === self || isControlDevice(d))
|
|
191
|
+
return false;
|
|
192
|
+
return d.platform === 'windows' || d.platform === 'linux' || d.platform === 'macos';
|
|
193
|
+
}
|
|
83
194
|
/**
|
|
84
195
|
* Gather listing sessions from other machines. With an explicit `hosts` list
|
|
85
196
|
* (from `--host`), fan out to exactly those. Otherwise sweep the registered,
|
|
@@ -105,6 +216,303 @@ export async function gatherRemoteList(forwardedArgs, hosts) {
|
|
|
105
216
|
],
|
|
106
217
|
};
|
|
107
218
|
}
|
|
219
|
+
export function parseRemoteToolProgramCount(stdout, machine, expectedProgram) {
|
|
220
|
+
try {
|
|
221
|
+
const parsed = JSON.parse(stdout);
|
|
222
|
+
const query = parsed.query;
|
|
223
|
+
const coverage = parsed.coverage;
|
|
224
|
+
const totals = parsed.totals;
|
|
225
|
+
const machines = parsed.machines;
|
|
226
|
+
const coverageKeys = ['indexedFiles', 'indexedCalls', 'skippedFiles', 'limitedFiles', 'remainingFiles'];
|
|
227
|
+
const totalKeys = ['occurrences', 'toolCalls', 'sessions'];
|
|
228
|
+
if (parsed.schemaVersion !== 1 || parsed.kind !== 'tool-program-count'
|
|
229
|
+
|| !query || query.program !== expectedProgram || query.semantics !== 'static-program-occurrences-v1'
|
|
230
|
+
|| !coverage || typeof coverage.complete !== 'boolean'
|
|
231
|
+
|| coverageKeys.some((key) => !Number.isSafeInteger(coverage[key]) || coverage[key] < 0)
|
|
232
|
+
|| !totals || totalKeys.some((key) => !Number.isSafeInteger(totals[key]) || totals[key] < 0)
|
|
233
|
+
|| !Array.isArray(machines) || machines.length !== 1
|
|
234
|
+
|| boundedRemoteString(parsed.generatedAt, 128) === undefined) {
|
|
235
|
+
return { items: [], valid: false };
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
valid: true,
|
|
239
|
+
items: [{
|
|
240
|
+
machine,
|
|
241
|
+
envelope: {
|
|
242
|
+
schemaVersion: 1,
|
|
243
|
+
kind: 'tool-program-count',
|
|
244
|
+
generatedAt: parsed.generatedAt,
|
|
245
|
+
query: { program: expectedProgram, semantics: 'static-program-occurrences-v1' },
|
|
246
|
+
coverage: coverage,
|
|
247
|
+
totals: totals,
|
|
248
|
+
machines: [{
|
|
249
|
+
machine,
|
|
250
|
+
coverage: coverage,
|
|
251
|
+
totals: totals,
|
|
252
|
+
}],
|
|
253
|
+
},
|
|
254
|
+
}],
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
return { items: [], valid: false };
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
export async function gatherRemoteToolProgramCounts(forwardedArgs, hosts, expectedProgram) {
|
|
262
|
+
const result = await gatherRemoteAgentsJson({
|
|
263
|
+
args: forwardedArgs,
|
|
264
|
+
noFanoutEnv: NO_FANOUT_ENV,
|
|
265
|
+
hosts,
|
|
266
|
+
timeoutMs: REMOTE_TOOL_TIMEOUT_MS,
|
|
267
|
+
parse: (stdout, machine) => parseRemoteToolProgramCount(stdout, machine, expectedProgram),
|
|
268
|
+
});
|
|
269
|
+
return {
|
|
270
|
+
envelopes: result.items,
|
|
271
|
+
deviceCount: result.deviceCount,
|
|
272
|
+
unreachable: [
|
|
273
|
+
...(result.discoveryFailed ? ['device registry'] : []),
|
|
274
|
+
...result.skipped,
|
|
275
|
+
...result.parseFailed,
|
|
276
|
+
],
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
function boundedRemoteString(value, maxBytes) {
|
|
280
|
+
if (typeof value !== 'string' || Buffer.byteLength(value) > maxBytes)
|
|
281
|
+
return undefined;
|
|
282
|
+
return sanitizeToolEvidenceText(value, maxBytes);
|
|
283
|
+
}
|
|
284
|
+
function optionalRemoteString(value, maxBytes) {
|
|
285
|
+
if (value === undefined || value === null)
|
|
286
|
+
return undefined;
|
|
287
|
+
return boundedRemoteString(value, maxBytes) ?? null;
|
|
288
|
+
}
|
|
289
|
+
function parseRemoteCall(value) {
|
|
290
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
291
|
+
return undefined;
|
|
292
|
+
const call = value;
|
|
293
|
+
const id = boundedRemoteString(call.id, 512);
|
|
294
|
+
const timestamp = boundedRemoteString(call.timestamp, 128);
|
|
295
|
+
const tool = boundedRemoteString(call.tool, 512);
|
|
296
|
+
const input = boundedRemoteString(call.input, TOOL_INPUT_MAX_BYTES);
|
|
297
|
+
const sourceCallId = optionalRemoteString(call.sourceCallId, 512);
|
|
298
|
+
const output = optionalRemoteString(call.output, TOOL_SUCCESS_OUTPUT_MAX_BYTES);
|
|
299
|
+
const error = optionalRemoteString(call.error, TOOL_ERROR_OUTPUT_MAX_BYTES);
|
|
300
|
+
const errorCode = optionalRemoteString(call.errorCode, 512);
|
|
301
|
+
const parseError = optionalRemoteString(call.parseError, 1024);
|
|
302
|
+
if (call.programs !== undefined
|
|
303
|
+
&& (!Array.isArray(call.programs) || call.programs.length > 128))
|
|
304
|
+
return undefined;
|
|
305
|
+
const programs = Array.isArray(call.programs)
|
|
306
|
+
? call.programs.map((program) => boundedRemoteString(program, 512))
|
|
307
|
+
: [];
|
|
308
|
+
if (!Array.isArray(call.programOccurrences) || call.programOccurrences.length > 10_000)
|
|
309
|
+
return undefined;
|
|
310
|
+
const programOccurrences = call.programOccurrences.map((value) => {
|
|
311
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
312
|
+
return undefined;
|
|
313
|
+
const occurrence = value;
|
|
314
|
+
const program = boundedRemoteString(occurrence.program, 512);
|
|
315
|
+
if (!program || (occurrence.role !== 'wrapper' && occurrence.role !== 'effective'))
|
|
316
|
+
return undefined;
|
|
317
|
+
return { program, role: occurrence.role };
|
|
318
|
+
});
|
|
319
|
+
if (!id || !timestamp || !tool || input === undefined
|
|
320
|
+
|| !Number.isSafeInteger(call.ordinal) || call.ordinal < 0
|
|
321
|
+
|| !['ok', 'error', 'unknown'].includes(String(call.outcome))
|
|
322
|
+
|| sourceCallId === null || output === null || error === null || errorCode === null || parseError === null
|
|
323
|
+
|| programs.some((program) => program === undefined)
|
|
324
|
+
|| programOccurrences.some((occurrence) => occurrence === undefined))
|
|
325
|
+
return undefined;
|
|
326
|
+
for (const code of [call.exitCode, call.statusCode]) {
|
|
327
|
+
if (code !== undefined && (!Number.isSafeInteger(code) || code < 0))
|
|
328
|
+
return undefined;
|
|
329
|
+
}
|
|
330
|
+
return {
|
|
331
|
+
id,
|
|
332
|
+
ordinal: call.ordinal,
|
|
333
|
+
sourceCallId,
|
|
334
|
+
timestamp,
|
|
335
|
+
tool,
|
|
336
|
+
programs: programs,
|
|
337
|
+
programOccurrences: programOccurrences,
|
|
338
|
+
input,
|
|
339
|
+
outcome: call.outcome,
|
|
340
|
+
exitCode: call.exitCode,
|
|
341
|
+
statusCode: call.statusCode,
|
|
342
|
+
errorCode,
|
|
343
|
+
output,
|
|
344
|
+
error,
|
|
345
|
+
parseError,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
function parseRemoteToolSession(value, machine) {
|
|
349
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
350
|
+
return undefined;
|
|
351
|
+
const session = value;
|
|
352
|
+
const id = boundedRemoteString(session.id, 512);
|
|
353
|
+
const shortId = boundedRemoteString(session.shortId, 128);
|
|
354
|
+
const agent = boundedRemoteString(session.agent, 128);
|
|
355
|
+
const timestamp = boundedRemoteString(session.timestamp, 128);
|
|
356
|
+
const project = optionalRemoteString(session.project, 4096);
|
|
357
|
+
const cwd = optionalRemoteString(session.cwd, 4096);
|
|
358
|
+
const topic = optionalRemoteString(session.topic, 4096);
|
|
359
|
+
const label = optionalRemoteString(session.label, 4096);
|
|
360
|
+
const originMachine = optionalRemoteString(session.machine, 512);
|
|
361
|
+
const dialedMachine = boundedRemoteString(machine, 512);
|
|
362
|
+
if (!id || !shortId || !agent || !timestamp || !dialedMachine || originMachine === null
|
|
363
|
+
|| project === null || cwd === null || topic === null || label === null
|
|
364
|
+
|| !Array.isArray(session.calls) || session.calls.length > TOOL_QUERY_MAX_CALL_ROWS)
|
|
365
|
+
return undefined;
|
|
366
|
+
const calls = session.calls.map(parseRemoteCall);
|
|
367
|
+
if (calls.some((call) => call === undefined))
|
|
368
|
+
return undefined;
|
|
369
|
+
return {
|
|
370
|
+
id,
|
|
371
|
+
shortId,
|
|
372
|
+
agent,
|
|
373
|
+
machine: originMachine ?? dialedMachine,
|
|
374
|
+
timestamp,
|
|
375
|
+
project,
|
|
376
|
+
cwd,
|
|
377
|
+
topic,
|
|
378
|
+
label,
|
|
379
|
+
calls: calls,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
export function parseRemoteToolSearch(stdout, machine, expectedClauses) {
|
|
383
|
+
if (Buffer.byteLength(stdout) > REMOTE_STDOUT_MAX_BYTES)
|
|
384
|
+
return undefined;
|
|
385
|
+
try {
|
|
386
|
+
const parsed = JSON.parse(stdout);
|
|
387
|
+
const coverage = parsed?.coverage;
|
|
388
|
+
const query = parsed?.query;
|
|
389
|
+
if (parsed?.schemaVersion !== 1 || !Array.isArray(parsed.sessions)
|
|
390
|
+
|| parsed.sessions.length > TOOL_QUERY_MAX_RESULT_SESSIONS || !coverage || !query
|
|
391
|
+
|| !Array.isArray(query.clauses) || query.clauses.length > TOOL_QUERY_MAX_CLAUSES
|
|
392
|
+
|| query.clauses.some((clause) => boundedRemoteString(clause, TOOL_QUERY_MAX_CLAUSE_BYTES) === undefined))
|
|
393
|
+
return undefined;
|
|
394
|
+
let totalCalls = 0;
|
|
395
|
+
for (const sessionValue of parsed.sessions) {
|
|
396
|
+
if (!sessionValue || typeof sessionValue !== 'object' || Array.isArray(sessionValue))
|
|
397
|
+
return undefined;
|
|
398
|
+
const calls = sessionValue.calls;
|
|
399
|
+
if (!Array.isArray(calls) || calls.length > TOOL_QUERY_MAX_CALL_ROWS)
|
|
400
|
+
return undefined;
|
|
401
|
+
totalCalls += calls.length;
|
|
402
|
+
if (totalCalls > TOOL_QUERY_MAX_CALL_ROWS)
|
|
403
|
+
return undefined;
|
|
404
|
+
}
|
|
405
|
+
const clauses = query.clauses.map((clause) => sanitizeForTerminal(clause));
|
|
406
|
+
const expected = expectedClauses?.map((clause) => sanitizeForTerminal(clause));
|
|
407
|
+
if (expected && (clauses.length !== expected.length
|
|
408
|
+
|| clauses.some((clause, index) => clause !== expected[index])))
|
|
409
|
+
return undefined;
|
|
410
|
+
const coverageNumbers = ['indexedFiles', 'indexedCalls', 'skippedFiles', 'limitedFiles', 'remainingFiles'];
|
|
411
|
+
if (coverageNumbers.some((key) => !Number.isSafeInteger(coverage[key]) || coverage[key] < 0)
|
|
412
|
+
|| typeof coverage.complete !== 'boolean')
|
|
413
|
+
return undefined;
|
|
414
|
+
const sessions = parsed.sessions.map((session) => parseRemoteToolSession(session, machine));
|
|
415
|
+
const generatedAt = boundedRemoteString(parsed.generatedAt, 128);
|
|
416
|
+
if (!generatedAt || sessions.some((session) => session === undefined))
|
|
417
|
+
return undefined;
|
|
418
|
+
return {
|
|
419
|
+
schemaVersion: 1,
|
|
420
|
+
generatedAt,
|
|
421
|
+
query: { clauses },
|
|
422
|
+
coverage: {
|
|
423
|
+
indexedFiles: coverage.indexedFiles,
|
|
424
|
+
indexedCalls: coverage.indexedCalls,
|
|
425
|
+
skippedFiles: coverage.skippedFiles,
|
|
426
|
+
limitedFiles: coverage.limitedFiles,
|
|
427
|
+
remainingFiles: coverage.remainingFiles,
|
|
428
|
+
complete: coverage.complete,
|
|
429
|
+
},
|
|
430
|
+
sessions: sessions,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
return undefined;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Fleet sibling of {@link gatherRemoteList} for the versioned tool-search
|
|
439
|
+
* envelope. Each peer executes the same local-only query against its own
|
|
440
|
+
* SQLite index; only compact matches cross SSH.
|
|
441
|
+
*/
|
|
442
|
+
export async function gatherRemoteToolSearch(forwardedArgs, hosts, maxAggregateBytes = REMOTE_TOOL_AGGREGATE_MAX_BYTES, expectedClauses = []) {
|
|
443
|
+
const self = machineId();
|
|
444
|
+
const targets = [];
|
|
445
|
+
const unresolved = [];
|
|
446
|
+
if (hosts && hosts.length > 0) {
|
|
447
|
+
const resolved = await resolveExplicitTargetSet(hosts);
|
|
448
|
+
targets.push(...resolved.targets);
|
|
449
|
+
unresolved.push(...resolved.unresolved);
|
|
450
|
+
}
|
|
451
|
+
else {
|
|
452
|
+
let reg;
|
|
453
|
+
try {
|
|
454
|
+
reg = await loadDevices();
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
return { envelopes: [], deviceCount: 0, unreachable: ['device registry'], truncated: [] };
|
|
458
|
+
}
|
|
459
|
+
for (const d of Object.values(reg)) {
|
|
460
|
+
if (!isAutomaticSessionPeer(d, self))
|
|
461
|
+
continue;
|
|
462
|
+
try {
|
|
463
|
+
targets.push({ target: sshTargetFor(d), machine: normalizeHost(d.name), name: d.name, os: d.platform });
|
|
464
|
+
}
|
|
465
|
+
catch {
|
|
466
|
+
// A registered control record without a dialable address is not a query target.
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
const remainingBytes = Math.max(0, Math.min(REMOTE_TOOL_AGGREGATE_MAX_BYTES, maxAggregateBytes));
|
|
471
|
+
const aggregateBudget = {
|
|
472
|
+
remainingBytes,
|
|
473
|
+
exhausted: remainingBytes === 0,
|
|
474
|
+
};
|
|
475
|
+
const parsedBudget = {
|
|
476
|
+
remainingBytes,
|
|
477
|
+
exhausted: remainingBytes === 0,
|
|
478
|
+
};
|
|
479
|
+
const results = await mapBounded(targets, async (target) => {
|
|
480
|
+
if (aggregateBudget.exhausted)
|
|
481
|
+
return { target, truncated: target.name };
|
|
482
|
+
const capture = await sshCapture(target.target, remoteListCommand(forwardedArgs, target.os), REMOTE_TOOL_TIMEOUT_MS, aggregateBudget,
|
|
483
|
+
// A tool-index deadline must own the SSH connection. Killing a
|
|
484
|
+
// multiplexed child would leave its remote command running on the
|
|
485
|
+
// persistent control master.
|
|
486
|
+
{ multiplex: false });
|
|
487
|
+
if (capture.aggregateBudgetExceeded)
|
|
488
|
+
return { target, truncated: target.name };
|
|
489
|
+
if (capture.code !== 0)
|
|
490
|
+
return { target, unreachable: target.name };
|
|
491
|
+
const envelope = parseRemoteToolSearch(capture.stdout, target.machine, expectedClauses);
|
|
492
|
+
if (!envelope)
|
|
493
|
+
return { target, unreachable: target.name };
|
|
494
|
+
if (!consumeParsedRemoteToolSearchBudget(parsedBudget, envelope)) {
|
|
495
|
+
return { target, truncated: target.name };
|
|
496
|
+
}
|
|
497
|
+
return { target, envelope };
|
|
498
|
+
}, { concurrency: 6 });
|
|
499
|
+
for (const result of results) {
|
|
500
|
+
if (result.unreachable) {
|
|
501
|
+
process.stderr.write(chalk.gray(` ${result.unreachable}: unreachable, incompatible, or no agents CLI — skipped\n`));
|
|
502
|
+
}
|
|
503
|
+
if (result.truncated) {
|
|
504
|
+
process.stderr.write(chalk.gray(` ${result.truncated}: fleet tool-result budget exhausted — skipped\n`));
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return {
|
|
508
|
+
envelopes: results.flatMap((result) => result.envelope
|
|
509
|
+
? [{ machine: result.target.machine, envelope: result.envelope }]
|
|
510
|
+
: []),
|
|
511
|
+
deviceCount: targets.length,
|
|
512
|
+
unreachable: [...unresolved, ...results.map((result) => result.unreachable).filter((name) => !!name)],
|
|
513
|
+
truncated: results.map((result) => result.truncated).filter((name) => !!name),
|
|
514
|
+
};
|
|
515
|
+
}
|
|
108
516
|
/** Resolve a peer's SSH target (and OS) from the device registry by its
|
|
109
517
|
* normalized machine id — the same id the fan-out tags rows with. Returns
|
|
110
518
|
* undefined when no registered device with an address matches. */
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type Word } from 'unbash';
|
|
2
|
+
export interface ShellProgramExtraction {
|
|
3
|
+
programs: string[];
|
|
4
|
+
occurrences: ShellProgramOccurrence[];
|
|
5
|
+
diagnostics: string[];
|
|
6
|
+
}
|
|
7
|
+
export type ShellProgramRole = 'wrapper' | 'effective';
|
|
8
|
+
export interface ShellProgramOccurrence {
|
|
9
|
+
program: string;
|
|
10
|
+
role: ShellProgramRole;
|
|
11
|
+
}
|
|
12
|
+
/** Return a word only when Bash expansion cannot change its value. */
|
|
13
|
+
export declare function staticShellWord(word: Word | undefined): string | undefined;
|
|
14
|
+
/** Parse Bash without executing it and return every statically identifiable program. */
|
|
15
|
+
export declare function extractShellPrograms(source: string, depth?: number): ShellProgramExtraction;
|