@phnx-labs/agents-cli 1.22.103 → 1.22.104

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 (66) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +1 -1
  3. package/dist/commands/accounts.js +2 -2
  4. package/dist/commands/computer.d.ts +100 -79
  5. package/dist/commands/computer.js +290 -830
  6. package/dist/commands/setup-computer.js +20 -1
  7. package/dist/commands/setup-secrets.d.ts +2 -2
  8. package/dist/commands/setup-secrets.js +1 -1
  9. package/dist/commands/view.js +4 -6
  10. package/dist/lib/account-catalog.d.ts +22 -1
  11. package/dist/lib/account-catalog.js +72 -38
  12. package/dist/lib/accounting/usage.js +18 -14
  13. package/dist/lib/agent-spec/agents.d.ts +1 -0
  14. package/dist/lib/agent-spec/agents.js +1 -1
  15. package/dist/lib/browser/drivers/ssh.js +1 -1
  16. package/dist/lib/computer/context.d.ts +83 -0
  17. package/dist/lib/computer/context.js +91 -0
  18. package/dist/lib/computer/policy.d.ts +46 -0
  19. package/dist/lib/computer/policy.js +160 -0
  20. package/dist/lib/computer/record.d.ts +38 -0
  21. package/dist/lib/computer/record.js +86 -0
  22. package/dist/lib/computer/sessions-list.js +6 -6
  23. package/dist/lib/computer-client.d.ts +150 -0
  24. package/dist/lib/computer-client.js +222 -0
  25. package/dist/lib/exec.js +35 -1
  26. package/dist/lib/harness/adapters/claude.d.ts +37 -0
  27. package/dist/lib/harness/adapters/claude.js +69 -0
  28. package/dist/lib/helper-download.d.ts +1 -1
  29. package/dist/lib/helper-download.js +1 -1
  30. package/dist/lib/helper-versions.d.ts +9 -4
  31. package/dist/lib/helper-versions.js +8 -8
  32. package/dist/lib/installations/shims.js +8 -4
  33. package/dist/lib/menubar/download-menubar.d.ts +2 -1
  34. package/dist/lib/menubar/download-menubar.js +2 -1
  35. package/dist/lib/secrets-client.d.ts +3 -3
  36. package/dist/lib/secrets-client.js +15 -38
  37. package/dist/lib/session/db.js +1 -1
  38. package/dist/lib/sha256-asset.d.ts +2 -1
  39. package/dist/lib/sha256-asset.js +2 -1
  40. package/dist/lib/ssh-tunnel.d.ts +61 -0
  41. package/dist/lib/ssh-tunnel.js +105 -0
  42. package/dist/lib/summarizer/summarize.d.ts +2 -2
  43. package/dist/lib/summarizer/summarize.js +10 -3
  44. package/package.json +2 -3
  45. package/dist/commands/computer-actions.d.ts +0 -55
  46. package/dist/commands/computer-actions.js +0 -594
  47. package/dist/computer.d.ts +0 -2
  48. package/dist/computer.js +0 -7
  49. package/dist/lib/computer/actions.d.ts +0 -36
  50. package/dist/lib/computer/actions.js +0 -162
  51. package/dist/lib/computer/computer-rpc.d.ts +0 -39
  52. package/dist/lib/computer/computer-rpc.js +0 -447
  53. package/dist/lib/computer/des.d.ts +0 -1
  54. package/dist/lib/computer/des.js +0 -114
  55. package/dist/lib/computer/dispatch.d.ts +0 -10
  56. package/dist/lib/computer/dispatch.js +0 -133
  57. package/dist/lib/computer/download.d.ts +0 -54
  58. package/dist/lib/computer/download.js +0 -83
  59. package/dist/lib/computer/loop.d.ts +0 -62
  60. package/dist/lib/computer/loop.js +0 -98
  61. package/dist/lib/computer/model.d.ts +0 -44
  62. package/dist/lib/computer/model.js +0 -157
  63. package/dist/lib/computer/rfb-client.d.ts +0 -53
  64. package/dist/lib/computer/rfb-client.js +0 -562
  65. package/dist/lib/computer/ssh-tunnel.d.ts +0 -189
  66. package/dist/lib/computer/ssh-tunnel.js +0 -584
@@ -9,9 +9,10 @@
9
9
  * Idempotent: re-running re-installs the current helper and re-checks trust.
10
10
  */
11
11
  import os from 'node:os';
12
- import { execFileSync } from 'node:child_process';
12
+ import { execFileSync, spawnSync } from 'node:child_process';
13
13
  import chalk from 'chalk';
14
14
  import { installComputerHelperMacLocal, activateComputerHelperMacLocal, probeComputerTrust, } from './computer.js';
15
+ import { resolveComputerBin, ComputerClientError } from '../lib/computer-client.js';
15
16
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
16
17
  const ACCESSIBILITY_PANE = 'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility';
17
18
  const SCREEN_PANE = 'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture';
@@ -36,6 +37,24 @@ export async function runComputerWizard() {
36
37
  console.log(chalk.dim('On Windows, provision a remote host from a Mac/Linux box instead: `agents computer setup --device <device>`.'));
37
38
  return false;
38
39
  }
40
+ try {
41
+ resolveComputerBin();
42
+ }
43
+ catch (error) {
44
+ if (!(error instanceof ComputerClientError) || error.code !== 'COMPUTER_BIN_MISSING')
45
+ throw error;
46
+ console.log('Installing @phnx-labs/computer-cli@0.1.1…');
47
+ const installed = spawnSync('npm', ['install', '-g', '@phnx-labs/computer-cli@0.1.1'], { stdio: 'inherit' });
48
+ if (installed.status !== 0)
49
+ return false;
50
+ try {
51
+ resolveComputerBin();
52
+ }
53
+ catch {
54
+ console.error('Computer CLI installation did not produce an executable on PATH.');
55
+ return false;
56
+ }
57
+ }
39
58
  // 1. Download + verify + install the signed, notarized helper.
40
59
  console.log(chalk.bold('Installing the Agents Computer helper...'));
41
60
  try {
@@ -8,8 +8,8 @@
8
8
  * `npm i -g`. Users do not set extra env vars.
9
9
  */
10
10
  import type { Command } from 'commander';
11
- export declare const SECRETS_CLI_PACKAGE = "@phnx-labs/secrets-cli@0.1.2";
12
- export declare const INSTALL_HINT = "agents clis install secrets # or: npm i -g @phnx-labs/secrets-cli@0.1.2";
11
+ export declare const SECRETS_CLI_PACKAGE = "@phnx-labs/secrets-cli@0.1.4";
12
+ export declare const INSTALL_HINT = "agents clis install secrets # or: npm i -g @phnx-labs/secrets-cli@0.1.4";
13
13
  export declare function setupSecretsPrefsPath(): string;
14
14
  /** True when the standalone `secrets` executable resolves ($SECRETS_BIN or PATH). */
15
15
  export declare function isSecretsCliInstalled(): boolean;
@@ -14,7 +14,7 @@ import { spawnSync } from 'node:child_process';
14
14
  import { getHistoryDir } from '../lib/state.js';
15
15
  import { resolveSecretsBin, invocation, SecretsClientError, _resetSecretsClientForTest } from '../lib/secrets-client.js';
16
16
  import { installCli, resolveCliManifest } from '../lib/cli-resources.js';
17
- export const SECRETS_CLI_PACKAGE = '@phnx-labs/secrets-cli@0.1.2';
17
+ export const SECRETS_CLI_PACKAGE = '@phnx-labs/secrets-cli@0.1.4';
18
18
  export const INSTALL_HINT = `agents clis install secrets # or: npm i -g ${SECRETS_CLI_PACKAGE}`;
19
19
  export function setupSecretsPrefsPath() {
20
20
  return path.join(getHistoryDir(), 'setup', 'secrets.json');
@@ -31,7 +31,7 @@ import { isCapable } from '../lib/capabilities.js';
31
31
  import { discoverPlugins, pluginSupportsAgent } from '../lib/plugins/plugins.js';
32
32
  import { getAgentsDir, getUserAgentsDir, getEffectivePromptcutsPath, readMergedPromptcuts, readMeta } from '../lib/state.js';
33
33
  import { findNativeAccountByIdentity } from '../lib/account-registry.js';
34
- import { accountListJson, loadAccountCatalog, renderAccountRows, secretsUnavailableNote } from '../lib/account-catalog.js';
34
+ import { ACCOUNT_LISTING_LEGEND, accountListJson, loadAccountCatalog, renderAccountRows, secretsUnavailableNote } from '../lib/account-catalog.js';
35
35
  import { addSupported } from '../lib/accounts/add.js';
36
36
  import { readInstallation } from '../lib/installations/store.js';
37
37
  import { isAutoUpdateEnabledForAgent } from '../lib/installations/update-policy.js';
@@ -557,12 +557,10 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
557
557
  }
558
558
  if (filterAgentId) {
559
559
  console.log(chalk.gray(` Add an account: agents accounts add ${filterAgentId} [name]`));
560
- console.log(chalk.gray(' STATE: LIVE ready · LIMITED rate-limited · EXPIRED needs refresh · REVOKED needs login · UNVERIFIED unconfirmed · MISSING not provisioned · * stale usage\n'));
561
- }
562
- else {
563
- // Overview also renders account tables (footer:false) — explain STATE there too.
564
- console.log(chalk.gray(' STATE: LIVE ready · LIMITED rate-limited · EXPIRED needs refresh · REVOKED needs login · UNVERIFIED unconfirmed · MISSING not provisioned · * stale usage\n'));
565
560
  }
561
+ // Overview renders the same account rows with footer:false, so both paths
562
+ // need the legend the shared renderer would otherwise have printed.
563
+ console.log(chalk.gray(` ${ACCOUNT_LISTING_LEGEND}\n`));
566
564
  }
567
565
  if (versionManaged.length > 0 && viewOpts?.versions) {
568
566
  // Calculate column widths across all agents for alignment
@@ -201,8 +201,29 @@ export declare function loadAccountCatalog(): Promise<AccountCatalog>;
201
201
  */
202
202
  export declare function secretsUnavailableNote(catalog: Pick<AccountCatalog, 'secretsUnavailable'>): string | null;
203
203
  export declare function accountListJson(native: NativeAccountCatalogRow[], providers?: ProviderAccountCatalogRow[], harness?: AgentId): AccountListJson;
204
- export declare function whereText(row: NativeAccountCatalogRow, localDevice: string): string;
204
+ /**
205
+ * A state worth an operator's attention, rendered at the END of the row.
206
+ * `live`, `ready`, `unverified` and `per-device` are the ordinary cases: they
207
+ * were on every row of the old table and pushed it past the terminal width
208
+ * without telling anyone anything. They stay in `accounts list --fleet`,
209
+ * `accounts view <name>` and `--json`.
210
+ */
211
+ export declare function verdictNote(verdict: AccountVerdict): string | null;
212
+ /**
213
+ * Device coverage, but only when it SPLITS the fleet: some boxes can use the
214
+ * account and some cannot. Full coverage is the ordinary case, and "none of
215
+ * them" is already exactly what the row's state note says, so both render
216
+ * nothing — `accounts list --fleet` is the per-device table.
217
+ */
218
+ export declare function coverageNote(row: NativeAccountCatalogRow, localDevice: string): string | null;
205
219
  export declare const OVERVIEW_MAX_USAGE_WINDOWS = 2;
220
+ /**
221
+ * The one line under an account listing. It explains the two marks a row can
222
+ * carry and names where the columns the listing no longer prints — identity,
223
+ * per-device state — still live. Shared with `agents view` so the two surfaces
224
+ * cannot drift.
225
+ */
226
+ export declare const ACCOUNT_LISTING_LEGEND = "* stale usage \u00B7 a healthy account carries no state \u00B7 identity + per-box state: agents accounts list --fleet";
206
227
  /** Shared text renderer used by both `accounts list` and account-first `view`. */
207
228
  export declare function renderAccountRows(rows: NativeAccountCatalogRow[], opts: {
208
229
  heading?: boolean;
@@ -367,35 +367,55 @@ export function accountListJson(native, providers = [], harness) {
367
367
  ],
368
368
  };
369
369
  }
370
- function verdictText(verdict) {
370
+ /**
371
+ * A state worth an operator's attention, rendered at the END of the row.
372
+ * `live`, `ready`, `unverified` and `per-device` are the ordinary cases: they
373
+ * were on every row of the old table and pushed it past the terminal width
374
+ * without telling anyone anything. They stay in `accounts list --fleet`,
375
+ * `accounts view <name>` and `--json`.
376
+ */
377
+ export function verdictNote(verdict) {
371
378
  if (verdict === 'rate_limited')
372
- return 'LIMITED';
373
- return verdict.toUpperCase();
379
+ return chalk.yellow('rate-limited');
380
+ if (verdict === 'expired' || verdict === 'revoked' || verdict === 'missing') {
381
+ return chalk.red(verdict);
382
+ }
383
+ return null;
374
384
  }
375
- export function whereText(row, localDevice) {
385
+ /**
386
+ * Device coverage, but only when it SPLITS the fleet: some boxes can use the
387
+ * account and some cannot. Full coverage is the ordinary case, and "none of
388
+ * them" is already exactly what the row's state note says, so both render
389
+ * nothing — `accounts list --fleet` is the per-device table.
390
+ */
391
+ export function coverageNote(row, localDevice) {
376
392
  const names = [...new Set(row.devices.map((device) => device.device))];
377
393
  // Peers on an older release do not publish slot verdicts, so the only
378
- // observation is this box — say so rather than a coverage count.
394
+ // observation is this box — that is a gap in what we can see, not a gap in
395
+ // provisioning, so it is not an alarm.
379
396
  if (names.length === 1 && names[0] === localDevice)
380
- return 'this box';
381
- // Per-device accounts are provisioned per box; show which boxes are present
382
- // rather than a live/total fraction — the fraction would hide that each box
397
+ return null;
398
+ // Per-device accounts are provisioned per box; name the boxes it is NOT on
399
+ // rather than a usable/total fraction — the fraction would hide that each box
383
400
  // is its own account.
384
401
  if (row.provisioning === 'per-device') {
385
- const present = row.devices
386
- .filter((device) => device.verdict !== 'missing')
387
- .map((device) => device.device);
388
- return present.join(', ') || 'this box';
402
+ const absent = row.devices.filter((device) => device.verdict === 'missing').map((device) => device.device);
403
+ return absent.length > 0 && absent.length < row.devices.length ? `not on ${absent.join(', ')}` : null;
389
404
  }
390
405
  const provisioned = row.devices.filter((device) => device.verdict !== 'missing').length;
391
- if (provisioned === 0)
392
- return '—';
393
406
  const usable = row.devices.filter((device) => device.verdict === 'live' || device.verdict === 'rate_limited' || device.verdict === 'unverified').length;
394
- if (usable === provisioned)
395
- return `on ${usable} ${usable === 1 ? 'box' : 'boxes'}`;
396
- return `on ${usable} of ${provisioned} boxes`;
407
+ if (usable === 0 || usable === provisioned)
408
+ return null;
409
+ return `usable on ${usable} of ${provisioned} boxes`;
397
410
  }
398
411
  export const OVERVIEW_MAX_USAGE_WINDOWS = 2;
412
+ /**
413
+ * The one line under an account listing. It explains the two marks a row can
414
+ * carry and names where the columns the listing no longer prints — identity,
415
+ * per-device state — still live. Shared with `agents view` so the two surfaces
416
+ * cannot drift.
417
+ */
418
+ export const ACCOUNT_LISTING_LEGEND = '* stale usage · a healthy account carries no state · identity + per-box state: agents accounts list --fleet';
399
419
  function usageText(row, maxWindows) {
400
420
  if (row.usageSnapshot) {
401
421
  const usageInfo = { snapshot: row.usageSnapshot, error: row.usageError ?? null };
@@ -411,13 +431,35 @@ function usageText(row, maxWindows) {
411
431
  const percent = `${row.usage.usedPercent}%${row.usage.stale ? '*' : ''}`;
412
432
  return `${renderBar(row.usage.usedPercent, LISTING_USAGE_BAR_LEN)} ${percent}`;
413
433
  }
434
+ /**
435
+ * True only when the USAGE cell is certain to print a throttle marker, so the
436
+ * `rate-limited` note would say the same thing twice. Mirrors `usageText`
437
+ * branch for branch: with a snapshot, `formatUsageSummary` appends
438
+ * 'out of credits' or 'session-limited (…)' exactly for these `unavailable`
439
+ * reasons; without one, `usageText` prints 'limited' or 'no credits'. A verdict
440
+ * thrown by a maxed WINDOW is deliberately NOT here: the overview caps the cell
441
+ * at `OVERVIEW_MAX_USAGE_WINDOWS`, so the window that tripped it can be hidden
442
+ * behind the `+N` count with no color at all, and the note is the only signal.
443
+ */
444
+ function usageCellNamesThrottle(row) {
445
+ if (row.usageSnapshot) {
446
+ const unavailable = row.usageSnapshot.unavailable;
447
+ return unavailable?.reason === 'out_of_credits'
448
+ || (unavailable?.reason === 'session_limit' && !!unavailable.resetsAt);
449
+ }
450
+ return row.usage?.status === 'out_of_credits'
451
+ || (row.usage?.status === 'rate_limited' && (row.usage.usedPercent === null || row.usage.usedPercent === undefined));
452
+ }
414
453
  function nativeLine(row, localDevice, maxWindows) {
415
454
  return {
416
- name: row.name ?? 'unnamed',
417
- identityLabel: row.identityLabel,
418
- verdict: row.verdict,
419
- where: whereText(row, localDevice),
420
- fix: row.fix,
455
+ // A discovered login nobody has named is still identified by what it is:
456
+ // the row no longer carries an IDENTITY column, so the identity IS the name.
457
+ name: row.name ?? row.identityLabel,
458
+ notes: [
459
+ usageCellNamesThrottle(row) && row.verdict === 'rate_limited' ? null : verdictNote(row.verdict),
460
+ coverageNote(row, localDevice),
461
+ row.fix ? chalk.gray(`fix: ${row.fix}`) : null,
462
+ ].filter((note) => !!note),
421
463
  usage: usageText(row, maxWindows),
422
464
  isDefault: row.isDefault,
423
465
  };
@@ -425,32 +467,27 @@ function nativeLine(row, localDevice, maxWindows) {
425
467
  function providerLine(row, harness) {
426
468
  return {
427
469
  name: row.name,
428
- identityLabel: row.identityLabel,
429
- verdict: row.verdict,
430
- where: '—',
431
- fix: row.fix,
470
+ notes: [
471
+ verdictNote(row.verdict),
472
+ row.fix ? chalk.gray(`fix: ${row.fix}`) : null,
473
+ ].filter((note) => !!note),
432
474
  usage: '',
433
475
  isDefault: harness ? row.defaultFor.includes(harness) : false,
434
476
  };
435
477
  }
436
- function formatListingLine(line, nameW, identityW, stateW, whereW, usageW) {
478
+ function formatListingLine(line, nameW, usageW) {
437
479
  const marker = line.isDefault ? '*' : ' ';
438
- const fix = line.fix ? `fix: ${line.fix}` : '';
439
480
  return (` ${chalk.green(marker)} ${chalk.cyan(line.name.padEnd(nameW))} `
440
- + `${line.identityLabel.padEnd(identityW)} `
441
- + `${verdictText(line.verdict).padEnd(stateW)} `
442
- + `${line.where.padEnd(whereW)} `
443
481
  + `${padToWidth(line.usage, usageW)} `
444
- + `${fix ? chalk.gray(fix) : ''}`).trimEnd();
482
+ + line.notes.join(chalk.gray(' · '))).trimEnd();
445
483
  }
446
484
  function pushGroup(out, title, lines, widths, harnessHeadings) {
447
485
  if (lines.length === 0)
448
486
  return;
449
487
  if (harnessHeadings)
450
488
  out.push(chalk.bold(title));
451
- out.push(chalk.gray(` ${'ACCOUNT'.padEnd(widths.nameW + 2)}${'IDENTITY'.padEnd(widths.identityW + 2)}${'STATE'.padEnd(widths.stateW + 2)}${'WHERE'.padEnd(widths.whereW + 2)}${'USAGE'.padEnd(widths.usageW + 2)}FIX`));
452
489
  for (const line of lines) {
453
- out.push(formatListingLine(line, widths.nameW, widths.identityW, widths.stateW, widths.whereW, widths.usageW));
490
+ out.push(formatListingLine(line, widths.nameW, widths.usageW));
454
491
  }
455
492
  out.push('');
456
493
  }
@@ -501,9 +538,6 @@ export function renderAccountRows(rows, opts) {
501
538
  const allLines = [...grouped.values()].flat().concat(orphans);
502
539
  const widths = {
503
540
  nameW: Math.max(7, ...allLines.map((line) => line.name.length)),
504
- identityW: Math.max(8, ...allLines.map((line) => line.identityLabel.length)),
505
- stateW: Math.max(5, ...allLines.map((line) => verdictText(line.verdict).length)),
506
- whereW: Math.max(5, ...allLines.map((line) => line.where.length)),
507
541
  usageW: Math.max(5, ...allLines.map((line) => stringWidth(line.usage))),
508
542
  };
509
543
  for (const [harness, lines] of grouped) {
@@ -519,7 +553,7 @@ export function renderAccountRows(rows, opts) {
519
553
  const count = visibleNative.filter((row) => !!row.fix).length
520
554
  + visibleProviders.filter((row) => !!row.fix).length;
521
555
  out.push(chalk.gray(`${count} accounts need you · add: agents accounts add <harness>`));
522
- out.push(chalk.gray('STATE: LIVE ready · LIMITED rate-limited · EXPIRED needs refresh · REVOKED needs login · UNVERIFIED unconfirmed · MISSING not provisioned · * stale usage'));
556
+ out.push(chalk.gray(ACCOUNT_LISTING_LEGEND));
523
557
  }
524
558
  return out.join('\n').trimEnd();
525
559
  }
@@ -624,12 +624,13 @@ export function formatUsageSummary(plan, snapshot, planWidth = 3, opts) {
624
624
  parts.push(chalk.gray(plan.padEnd(planWidth)));
625
625
  }
626
626
  if (snapshot) {
627
- if (snapshot.unavailable?.reason === 'out_of_credits') {
628
- parts.push(chalk.red('out of credits'));
629
- }
630
- else if (snapshot.unavailable?.reason === 'session_limit' && snapshot.unavailable.resetsAt) {
631
- parts.push(chalk.yellow(`session-limited (${formatResetHint(snapshot.unavailable.resetsAt)})`));
632
- }
627
+ // A blocking marker renders AFTER the bars: leading it pushed every gauge
628
+ // out of its column, so one throttled account misaligned the whole table.
629
+ const blocked = snapshot.unavailable?.reason === 'out_of_credits'
630
+ ? chalk.red('out of credits')
631
+ : snapshot.unavailable?.reason === 'session_limit' && snapshot.unavailable.resetsAt
632
+ ? chalk.yellow(`session-limited (${formatResetHint(snapshot.unavailable.resetsAt)})`)
633
+ : null;
633
634
  // Compact rows show BLOCKING windows — the same set
634
635
  // deriveUsageStatusFromSnapshot uses for the rate-limited badge — so an
635
636
  // account throttled by its month window (Droid meters on 5h/week/month)
@@ -698,6 +699,8 @@ export function formatUsageSummary(plan, snapshot, planWidth = 3, opts) {
698
699
  else if (opts?.unverified) {
699
700
  parts.push(chalk.yellow('unverified'));
700
701
  }
702
+ if (blocked)
703
+ parts.push(blocked);
701
704
  }
702
705
  else if (opts?.headless) {
703
706
  // No bars at all: still name the scope gap so "usage pending" is not
@@ -2357,7 +2360,7 @@ function formatAgeShort(diffMs) {
2357
2360
  * Staleness suffix for a last-known window the freshness gate dropped. A window
2358
2361
  * whose reset/period boundary passed while the sample itself is still inside its
2359
2362
  * `windowMinutes` rolled OVER — the number describes a period that is done, so
2360
- * name it ("period ended 1h ago", e.g. Grok's weekly billing period). A window
2363
+ * name it ("period ended 1h", e.g. Grok's weekly billing period). A window
2361
2364
  * that aged past its own `windowMinutes` (Claude's 5h session read never
2362
2365
  * refreshed in time) is a stale sample of a still-rolling window, so report the
2363
2366
  * capture age ("6h old"). Falls back to the reset age, then a bare "stale".
@@ -2368,25 +2371,26 @@ function formatStaleWindowSuffix(window, capturedAt, now) {
2368
2371
  window.windowMinutes !== null &&
2369
2372
  capturedAt.getTime() + window.windowMinutes * 60 * 1000 <= now.getTime();
2370
2373
  if (resetPassed && !captureExpired) {
2371
- return `stale (period ended ${formatAgeShort(now.getTime() - window.resetsAt.getTime())} ago)`;
2374
+ return `period ended ${formatAgeShort(now.getTime() - window.resetsAt.getTime())}`;
2372
2375
  }
2373
2376
  if (capturedAt)
2374
2377
  return `${formatAgeShort(now.getTime() - capturedAt.getTime())} old`;
2375
2378
  if (resetPassed)
2376
- return `stale (period ended ${formatAgeShort(now.getTime() - window.resetsAt.getTime())} ago)`;
2379
+ return `period ended ${formatAgeShort(now.getTime() - window.resetsAt.getTime())}`;
2377
2380
  return 'stale';
2378
2381
  }
2379
2382
  /**
2380
- * Render a dropped-but-last-known window as "S: ▍░░░░ 30% · 6h old": the gauge
2381
- * and percentage exactly as a live bar, then a dim staleness suffix so the
2382
- * number is always visible and unmistakably not current. VIEW-ONLY — these
2383
- * windows are never in `snapshot.windows`, so routing never sees them.
2383
+ * Render a dropped-but-last-known window as "S: ▍░░░░ 30%* (6h old)": the gauge
2384
+ * and percentage exactly as a live bar, then the `*` stale marker the listing
2385
+ * legend explains and a dim age, so the number is always visible and
2386
+ * unmistakably not current. VIEW-ONLY — these windows are never in
2387
+ * `snapshot.windows`, so routing never sees them.
2384
2388
  */
2385
2389
  function renderStaleUsageWindow(window, capturedAt, shortLabel, now) {
2386
2390
  const bar = renderCompactUsageBar(window.usedPercent);
2387
2391
  const pct = colorUsage(`${Math.round(window.usedPercent)}%`, window.usedPercent);
2388
2392
  const suffix = formatStaleWindowSuffix(window, capturedAt, now);
2389
- return `${chalk.gray(`${shortLabel}:`)} ${bar} ${pct} ${chalk.dim(`· ${suffix}`)}`;
2393
+ return `${chalk.gray(`${shortLabel}:`)} ${bar} ${pct}${chalk.dim('*')} ${chalk.dim(`(${suffix})`)}`;
2390
2394
  }
2391
2395
  /** Format a reset timestamp as a human-readable relative or absolute time. */
2392
2396
  function formatResetAt(date) {
@@ -24,6 +24,7 @@ export declare const GEMINI_HOOKS_MIN_VERSION = "0.26.0";
24
24
  * section, even though the user had nothing to import.
25
25
  */
26
26
  interface NativeBinaryResolutionOptions {
27
+ accept?: (candidate: string) => boolean;
27
28
  shimsDir?: string;
28
29
  historyDir?: string;
29
30
  }
@@ -134,7 +134,7 @@ export function findInPath(command, options = {}) {
134
134
  if (process.platform !== 'win32')
135
135
  fs.accessSync(full, fs.constants.X_OK);
136
136
  const native = resolveNativeBinaryPath(command, full, options);
137
- if (native)
137
+ if (native && (!options.accept || options.accept(native)))
138
138
  return native;
139
139
  }
140
140
  catch {
@@ -11,7 +11,7 @@ export { shellQuote };
11
11
  // The `ssh -L` tunnel spawn is shared with `agents computer --device`; it lives in
12
12
  // the single ssh-tunnel helper. Calling it with no options preserves this
13
13
  // driver's original foreground, stderr-captured behavior exactly.
14
- import { startSSHTunnel } from '../../computer/ssh-tunnel.js';
14
+ import { startSSHTunnel } from '../../ssh-tunnel.js';
15
15
  import { encodePwshBase64 } from '../../pwsh.js';
16
16
  /** Lifecycle for a registry-driven tunnel vs a profile the daemon launched. */
17
17
  export function persistRemoteLifecycle(persistRemote) {
@@ -0,0 +1,83 @@
1
+ /**
2
+ * context.ts — everything agents-cli knows that the standalone `computer`
3
+ * engine cannot work out for itself, serialized as one JSON object onto fd 3.
4
+ *
5
+ * This is the whole contract of the consumer half, and it is deliberately four
6
+ * fields wide. The engine accepts exactly this shape:
7
+ *
8
+ * version `1`. The engine matches on it; a field added later must be
9
+ * optional so an older engine keeps working.
10
+ * permissions which apps are allowed, derived from `Computer(<bundle-id>)`
11
+ * rules in the agents permissions resource layer. The engine
12
+ * would have to re-learn resource layering to compute this.
13
+ * peers which executables the daemon accepts a connection from.
14
+ * target the `--device <name>` target, resolved against the fleet:
15
+ * devices registry, ssh identity, platform. The engine matches
16
+ * its own `--device <alias>` against this instead of reading a
17
+ * registry it does not have. Absent for a local invocation.
18
+ * session who is acting — actor id and agent session — so an action
19
+ * lands in the right session history.
20
+ *
21
+ * WHAT IS DELIBERATELY NOT HERE. The transport (`COMPUTER_HELPER_TCP`,
22
+ * `COMPUTER_HELPER_VNC`, `COMPUTER_HELPER_SOCKET`), the remote helper's auth
23
+ * token, and the policy-file paths are the ENGINE's: it provisions the Windows
24
+ * helper, mints and stores the token, opens the `ssh -L` tunnel and hydrates its
25
+ * own transport from the state it wrote. agents-cli publishing a loopback
26
+ * endpoint here (or on `COMPUTER_HELPER_TCP`) would be a second, drifting copy
27
+ * of that answer — and a copy without the token, which the daemon rejects with
28
+ * `auth_failed`. Service-manager safety (launchd/systemd registration under a
29
+ * redirected HOME) is likewise the standalone's own: it inherits `HOME` and
30
+ * `AGENTS_REAL_HOME` and renders its own manifest, so agents-cli neither
31
+ * computes a label nor issues a verdict for it.
32
+ *
33
+ * The context is PUSHED (written and closed) rather than exposed as a callback,
34
+ * so the engine never re-enters agents-cli and there is exactly one direction of
35
+ * dependency.
36
+ */
37
+ /** A `--device <name>` target, resolved against the fleet. */
38
+ export interface ComputerTargetContext {
39
+ /** The device name as the user typed it. */
40
+ alias: string;
41
+ /** `user@host`, already validated against ssh option injection. */
42
+ host: string;
43
+ user: string;
44
+ /** Bare host — the ssh-config Host name or address, without the user. */
45
+ hostname: string;
46
+ platform: string;
47
+ /** Per-device ssh identity flags, in argv order. Possibly empty. */
48
+ sshArgs: string[];
49
+ }
50
+ /** Who is acting, so the engine can stamp the action it reports back. */
51
+ export interface ComputerSessionContext {
52
+ sessionId?: string;
53
+ launchId?: string;
54
+ actor: string;
55
+ }
56
+ export interface ComputerContext {
57
+ version: 1;
58
+ permissions?: {
59
+ allow: string[];
60
+ };
61
+ peers: {
62
+ allow: string[];
63
+ };
64
+ target?: ComputerTargetContext;
65
+ session: ComputerSessionContext;
66
+ }
67
+ export interface BuildContextOptions {
68
+ /** `--device <name>`, if given. */
69
+ device?: string;
70
+ /** Direct host targeting, which bypasses fleet resolution. */
71
+ host?: string;
72
+ /** Resolved path of the standalone executable, for the peer allow list. */
73
+ computerBin?: string;
74
+ }
75
+ /**
76
+ * Build the context handed to the engine on fd 3.
77
+ *
78
+ * `device` resolution goes through the shared fleet resolver and keeps the
79
+ * Windows expectation the computer subsystem has always enforced — a
80
+ * `--device` pointing at a Mac gets the same refusal as before, from the fleet
81
+ * layer that can actually see the device's platform.
82
+ */
83
+ export declare function buildComputerContext(opts?: BuildContextOptions): Promise<ComputerContext>;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * context.ts — everything agents-cli knows that the standalone `computer`
3
+ * engine cannot work out for itself, serialized as one JSON object onto fd 3.
4
+ *
5
+ * This is the whole contract of the consumer half, and it is deliberately four
6
+ * fields wide. The engine accepts exactly this shape:
7
+ *
8
+ * version `1`. The engine matches on it; a field added later must be
9
+ * optional so an older engine keeps working.
10
+ * permissions which apps are allowed, derived from `Computer(<bundle-id>)`
11
+ * rules in the agents permissions resource layer. The engine
12
+ * would have to re-learn resource layering to compute this.
13
+ * peers which executables the daemon accepts a connection from.
14
+ * target the `--device <name>` target, resolved against the fleet:
15
+ * devices registry, ssh identity, platform. The engine matches
16
+ * its own `--device <alias>` against this instead of reading a
17
+ * registry it does not have. Absent for a local invocation.
18
+ * session who is acting — actor id and agent session — so an action
19
+ * lands in the right session history.
20
+ *
21
+ * WHAT IS DELIBERATELY NOT HERE. The transport (`COMPUTER_HELPER_TCP`,
22
+ * `COMPUTER_HELPER_VNC`, `COMPUTER_HELPER_SOCKET`), the remote helper's auth
23
+ * token, and the policy-file paths are the ENGINE's: it provisions the Windows
24
+ * helper, mints and stores the token, opens the `ssh -L` tunnel and hydrates its
25
+ * own transport from the state it wrote. agents-cli publishing a loopback
26
+ * endpoint here (or on `COMPUTER_HELPER_TCP`) would be a second, drifting copy
27
+ * of that answer — and a copy without the token, which the daemon rejects with
28
+ * `auth_failed`. Service-manager safety (launchd/systemd registration under a
29
+ * redirected HOME) is likewise the standalone's own: it inherits `HOME` and
30
+ * `AGENTS_REAL_HOME` and renders its own manifest, so agents-cli neither
31
+ * computes a label nor issues a verdict for it.
32
+ *
33
+ * The context is PUSHED (written and closed) rather than exposed as a callback,
34
+ * so the engine never re-enters agents-cli and there is exactly one direction of
35
+ * dependency.
36
+ */
37
+ import { resolveActor } from '../actor.js';
38
+ import { loadComputerAllowList, loadDefaultPeers } from './policy.js';
39
+ import { resolveRemoteDevice } from '../ssh-tunnel.js';
40
+ /**
41
+ * Which agent session is acting. Same precedence the admission cache used
42
+ * before the extraction — the harness-native id first, then agents' own.
43
+ */
44
+ function agentSessionId(env = process.env) {
45
+ return env.CODEX_THREAD_ID
46
+ || env.CLAUDE_CODE_SESSION_ID
47
+ || env.CLAUDE_SESSION_ID
48
+ || env.AGENTS_SESSION_ID
49
+ || env.AGENT_SESSION_ID
50
+ || env.AGENTS_RUN_ID
51
+ || undefined;
52
+ }
53
+ /**
54
+ * Build the context handed to the engine on fd 3.
55
+ *
56
+ * `device` resolution goes through the shared fleet resolver and keeps the
57
+ * Windows expectation the computer subsystem has always enforced — a
58
+ * `--device` pointing at a Mac gets the same refusal as before, from the fleet
59
+ * layer that can actually see the device's platform.
60
+ */
61
+ export async function buildComputerContext(opts = {}) {
62
+ let target;
63
+ if (opts.device) {
64
+ const resolved = await resolveRemoteDevice(opts.device, {
65
+ expectPlatform: 'windows',
66
+ forWhat: '`agents computer --device` drives the Windows computer-helper daemon, so it',
67
+ });
68
+ target = {
69
+ alias: opts.device,
70
+ host: resolved.target,
71
+ user: resolved.user,
72
+ hostname: resolved.host,
73
+ platform: resolved.device.platform,
74
+ sshArgs: resolved.identityArgs,
75
+ };
76
+ }
77
+ return {
78
+ version: 1,
79
+ ...(!opts.device && !opts.host && !process.env.COMPUTER_HELPER_TCP && !process.env.COMPUTER_HELPER_VNC
80
+ ? { permissions: { allow: loadComputerAllowList() } } : {}),
81
+ peers: { allow: loadDefaultPeers({ computerBin: opts.computerBin }) },
82
+ // Spread rather than assigned: a local invocation must not ship a `target`
83
+ // key at all, so the engine never has to distinguish absent from null.
84
+ ...(target ? { target } : {}),
85
+ session: {
86
+ sessionId: agentSessionId(),
87
+ launchId: process.env.AGENT_LAUNCH_ID,
88
+ actor: resolveActor().id,
89
+ },
90
+ };
91
+ }
@@ -0,0 +1,46 @@
1
+ /** Resolve Agents permission groups and caller identities for the standalone
2
+ * engine. The engine alone writes helper policy and peer files. */
3
+ export declare function loadComputerAllowList(): string[];
4
+ /**
5
+ * Default peer set: the standalone `computer` executable, this `agents` CLI's
6
+ * own runtime, plus Rush.app if it's installed. realpath() the symlink chain so
7
+ * we record the on-disk path the helper will see via proc_pidpath, not the shim
8
+ * path.
9
+ *
10
+ * The standalone's path is the one that changed with PHNX-4075: the daemon's
11
+ * caller is now the engine process, not this CLI. `agents`' own execPath stays
12
+ * on the list because the engine may be a `.js` bin run through this same
13
+ * runtime (`invocation()` in computer-client.ts), in which case proc_pidpath
14
+ * still reports the runtime.
15
+ *
16
+ * Why path-based instead of codesign-team-id? The agents CLI is unsigned
17
+ * today (npm distribution), and even if we sign Rush.app the team-id
18
+ * check would need a separate roundtrip. Path is concrete and fast; the
19
+ * daemon already runs as the user so anyone who can swap a binary at
20
+ * these paths can do worse via other means.
21
+ */
22
+ export declare function loadDefaultPeers(opts?: {
23
+ computerBin?: string;
24
+ }): string[];
25
+ /**
26
+ * Parse a `host:port` VNC endpoint, defaulting the port to 5901. Pure.
27
+ *
28
+ * Kept on the consumer side because the `--vnc` FLAG is parsed here — the
29
+ * platform gate has to know whether a remote desktop was named before the
30
+ * engine is ever spawned (see `shouldBlockOffPlatform`). The RFB protocol
31
+ * implementation itself went to the engine.
32
+ */
33
+ export declare function parseVncEndpoint(raw: string | undefined): {
34
+ host: string;
35
+ port: number;
36
+ } | null;
37
+ export declare function resolveTcpEndpoint(): {
38
+ host: string;
39
+ port: number;
40
+ token: string | null;
41
+ } | null;
42
+ export declare function resolveVncEndpoint(): {
43
+ host: string;
44
+ port: number;
45
+ password: string;
46
+ } | null;