@phnx-labs/agents-cli 1.22.45 → 1.22.47

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 (177) hide show
  1. package/CHANGELOG.md +303 -0
  2. package/LICENSE +102 -182
  3. package/README.md +54 -17
  4. package/dist/cli/command-registry.d.ts +2 -1
  5. package/dist/cli/command-registry.js +6 -2
  6. package/dist/commands/accounts.js +56 -12
  7. package/dist/commands/auth.d.ts +2 -0
  8. package/dist/commands/auth.js +229 -0
  9. package/dist/commands/browser-sessions-picker.js +9 -7
  10. package/dist/commands/browser.js +266 -15
  11. package/dist/commands/config.js +26 -4
  12. package/dist/commands/cost.d.ts +1 -1
  13. package/dist/commands/detach.d.ts +13 -0
  14. package/dist/commands/detach.js +11 -8
  15. package/dist/commands/exec.js +55 -38
  16. package/dist/commands/feed-watch.d.ts +2 -0
  17. package/dist/commands/feed-watch.js +28 -0
  18. package/dist/commands/feed.js +27 -0
  19. package/dist/commands/feedback.js +3 -19
  20. package/dist/commands/insights.d.ts +1 -1
  21. package/dist/commands/insights.js +2 -2
  22. package/dist/commands/lease.js +3 -3
  23. package/dist/commands/routines.js +1 -1
  24. package/dist/commands/sessions-picker-factory.d.ts +2 -1
  25. package/dist/commands/sessions-picker-factory.js +1 -1
  26. package/dist/commands/sessions-stop.d.ts +24 -0
  27. package/dist/commands/sessions-stop.js +66 -0
  28. package/dist/commands/sessions-trace.d.ts +82 -0
  29. package/dist/commands/sessions-trace.js +387 -0
  30. package/dist/commands/sessions.js +4 -0
  31. package/dist/commands/ssh.d.ts +15 -0
  32. package/dist/commands/ssh.js +194 -16
  33. package/dist/commands/teams.js +34 -2
  34. package/dist/commands/view.d.ts +2 -0
  35. package/dist/commands/view.js +19 -32
  36. package/dist/commands/watchdog.js +9 -14
  37. package/dist/lib/account-capabilities.d.ts +4 -0
  38. package/dist/lib/account-capabilities.js +13 -9
  39. package/dist/lib/account-registry.d.ts +2 -0
  40. package/dist/lib/account-registry.js +30 -2
  41. package/dist/lib/accounting/rotate.d.ts +9 -0
  42. package/dist/lib/accounting/rotate.js +12 -2
  43. package/dist/lib/accounting/usage.d.ts +92 -6
  44. package/dist/lib/accounting/usage.js +329 -75
  45. package/dist/lib/agent-modes.js +29 -1
  46. package/dist/lib/agent-spec/agents.d.ts +4 -3
  47. package/dist/lib/agent-spec/agents.js +20 -7
  48. package/dist/lib/analytics/mix-commands.d.ts +1 -1
  49. package/dist/lib/analytics/mix-commands.js +2 -2
  50. package/dist/lib/auth-health.d.ts +13 -3
  51. package/dist/lib/auth-health.js +50 -8
  52. package/dist/lib/browser/devices.d.ts +19 -0
  53. package/dist/lib/browser/devices.js +23 -0
  54. package/dist/lib/browser/ipc.d.ts +1 -1
  55. package/dist/lib/browser/ipc.js +33 -7
  56. package/dist/lib/browser/profiles.d.ts +106 -1
  57. package/dist/lib/browser/profiles.js +300 -17
  58. package/dist/lib/browser/remote-control.d.ts +26 -2
  59. package/dist/lib/browser/remote-control.js +33 -3
  60. package/dist/lib/browser/runtime-state.d.ts +9 -0
  61. package/dist/lib/browser/runtime-state.js +24 -8
  62. package/dist/lib/browser/service.d.ts +37 -0
  63. package/dist/lib/browser/service.js +78 -20
  64. package/dist/lib/browser/sessions-list.d.ts +1 -3
  65. package/dist/lib/browser/sessions-list.js +3 -16
  66. package/dist/lib/browser/types.d.ts +12 -1
  67. package/dist/lib/cli-resources.js +17 -15
  68. package/dist/lib/codex-policy.d.ts +11 -1
  69. package/dist/lib/codex-policy.js +20 -5
  70. package/dist/lib/config-keys.d.ts +4 -3
  71. package/dist/lib/config-keys.js +17 -7
  72. package/dist/lib/daemon/runner.js +15 -2
  73. package/dist/lib/daemon-ticks.js +2 -2
  74. package/dist/lib/device-config.d.ts +25 -8
  75. package/dist/lib/device-config.js +90 -17
  76. package/dist/lib/devices/config-migration.d.ts +7 -0
  77. package/dist/lib/devices/config-migration.js +67 -3
  78. package/dist/lib/devices/harness-inventory.js +20 -3
  79. package/dist/lib/devices/health.d.ts +27 -2
  80. package/dist/lib/devices/health.js +39 -9
  81. package/dist/lib/devices/interactive-host.d.ts +15 -0
  82. package/dist/lib/devices/interactive-host.js +53 -0
  83. package/dist/lib/devices/registry.d.ts +61 -4
  84. package/dist/lib/devices/registry.js +94 -38
  85. package/dist/lib/devices/stats-cache.d.ts +6 -0
  86. package/dist/lib/devices/stats-cache.js +10 -2
  87. package/dist/lib/exec.d.ts +25 -3
  88. package/dist/lib/exec.js +75 -22
  89. package/dist/lib/feed/answer.d.ts +29 -0
  90. package/dist/lib/feed/answer.js +145 -0
  91. package/dist/lib/feed/attention.d.ts +83 -0
  92. package/dist/lib/feed/attention.js +0 -0
  93. package/dist/lib/feed/feed.d.ts +109 -0
  94. package/dist/lib/feed/feed.js +154 -3
  95. package/dist/lib/feed/pr-status.d.ts +14 -0
  96. package/dist/lib/feed/pr-status.js +47 -0
  97. package/dist/lib/feed/watch.d.ts +59 -0
  98. package/dist/lib/feed/watch.js +180 -0
  99. package/dist/lib/fleet/capture.js +6 -0
  100. package/dist/lib/fleet/remote-login.js +8 -3
  101. package/dist/lib/fleet/types.d.ts +17 -0
  102. package/dist/lib/harness/adapter.d.ts +11 -0
  103. package/dist/lib/harness/adapters/claude.js +38 -24
  104. package/dist/lib/harness/adapters/codex.js +2 -4
  105. package/dist/lib/hooks/cache.js +8 -0
  106. package/dist/lib/hooks/match.d.ts +3 -0
  107. package/dist/lib/hooks/match.js +25 -0
  108. package/dist/lib/hosts/passthrough.js +16 -0
  109. package/dist/lib/hosts/registry.js +7 -0
  110. package/dist/lib/identity/client.d.ts +53 -0
  111. package/dist/lib/identity/client.js +106 -0
  112. package/dist/lib/identity/index.d.ts +115 -0
  113. package/dist/lib/identity/index.js +82 -0
  114. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  115. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  116. package/dist/lib/menubar/install-menubar.d.ts +13 -0
  117. package/dist/lib/menubar/install-menubar.js +26 -2
  118. package/dist/lib/open-url.d.ts +60 -2
  119. package/dist/lib/open-url.js +167 -12
  120. package/dist/lib/probe.d.ts +8 -0
  121. package/dist/lib/probe.js +105 -0
  122. package/dist/lib/refresh-coordinator.d.ts +1 -1
  123. package/dist/lib/refresh-coordinator.js +1 -1
  124. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  125. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  126. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  127. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  128. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  129. package/dist/lib/session/active.d.ts +67 -0
  130. package/dist/lib/session/active.js +69 -1
  131. package/dist/lib/session/db.js +4 -2
  132. package/dist/lib/session/discover.d.ts +2 -0
  133. package/dist/lib/session/discover.js +12 -6
  134. package/dist/lib/session/prompt.d.ts +35 -0
  135. package/dist/lib/session/prompt.js +56 -0
  136. package/dist/lib/session/remote/watch.d.ts +1 -1
  137. package/dist/lib/session/remote/watch.js +6 -1
  138. package/dist/lib/session/render.js +7 -3
  139. package/dist/lib/session/session-cache.js +5 -1
  140. package/dist/lib/session/trajectory-compare.d.ts +47 -0
  141. package/dist/lib/session/trajectory-compare.js +116 -0
  142. package/dist/lib/session/trajectory-html.d.ts +15 -0
  143. package/dist/lib/session/trajectory-html.js +750 -0
  144. package/dist/lib/session/trajectory-lineage.d.ts +92 -0
  145. package/dist/lib/session/trajectory-lineage.js +195 -0
  146. package/dist/lib/session/trajectory-text.d.ts +37 -0
  147. package/dist/lib/session/trajectory-text.js +277 -0
  148. package/dist/lib/session/trajectory.d.ts +102 -0
  149. package/dist/lib/session/trajectory.js +335 -0
  150. package/dist/lib/startup/command-registry.d.ts +5 -2
  151. package/dist/lib/startup/command-registry.js +8 -5
  152. package/dist/lib/state.d.ts +1 -1
  153. package/dist/lib/state.js +4 -2
  154. package/dist/lib/teams/agents.js +9 -0
  155. package/dist/lib/terminal/inject.d.ts +19 -0
  156. package/dist/lib/terminal/inject.js +19 -9
  157. package/dist/lib/tmux/session.d.ts +46 -13
  158. package/dist/lib/tmux/session.js +173 -27
  159. package/dist/lib/types.d.ts +16 -0
  160. package/dist/lib/usage-backoff.d.ts +2 -1
  161. package/dist/lib/usage-backoff.js +60 -19
  162. package/dist/lib/usage-refresh.d.ts +17 -3
  163. package/dist/lib/usage-refresh.js +57 -11
  164. package/dist/lib/view-types.d.ts +2 -2
  165. package/dist/lib/watchdog/log.d.ts +1 -1
  166. package/dist/lib/watchdog/log.js +1 -1
  167. package/dist/lib/watchdog/runner.d.ts +36 -45
  168. package/dist/lib/watchdog/runner.js +150 -119
  169. package/dist/lib/watchdog/service.d.ts +0 -1
  170. package/dist/lib/watchdog/service.js +0 -1
  171. package/dist/lib/watchdog/watchdog-agent.d.ts +38 -0
  172. package/dist/lib/watchdog/watchdog-agent.js +59 -0
  173. package/dist/lib/watchdog/watchdog.d.ts +11 -2
  174. package/dist/lib/watchdog/watchdog.js +37 -104
  175. package/package.json +2 -2
  176. package/dist/commands/usage.d.ts +0 -27
  177. package/dist/commands/usage.js +0 -96
@@ -14,9 +14,28 @@ import { resolveRunDefaults } from './run-defaults.js';
14
14
  export const MODE_DESCRIPTIONS = {
15
15
  plan: 'read-only investigation; no writes, no shell side-effects',
16
16
  edit: 'may edit files; prompts for shell / risky operations',
17
- auto: 'smart classifier auto-approves safe ops, prompts for risky',
17
+ auto: 'more autonomy than edit; the exact policy is per-harness (see notes)',
18
18
  skip: 'bypass every permission prompt (dangerously-skip-permissions)',
19
19
  };
20
+ /**
21
+ * What `auto` actually does, per harness. Two genuinely different mechanisms
22
+ * wear the same mode name, and conflating them is a safety error in both
23
+ * directions: telling a claude operator that auto never prompts invites an
24
+ * unattended run that stalls on a risky-operation gate, and telling a codex
25
+ * operator that auto prompts for risky work asserts a gate codex does not have.
26
+ * Absent entry = no extra note; the row's own text suffices.
27
+ */
28
+ const AUTO_SEMANTICS = {
29
+ // Classifier-style: still escalates. --permission-mode auto / --autopilot.
30
+ claude: 'a smart classifier auto-approves safe operations and still prompts for risky ones.',
31
+ copilot: 'a smart classifier auto-approves safe operations and still prompts for risky ones.',
32
+ // Approvals off, sandbox kept: never escalates. A denied command just fails.
33
+ codex: 'approval_policy=never over the same sandbox as edit — it never prompts, and a sandbox-denied command fails instead of raising an approval request.',
34
+ muse: 'runs --disable-approval, which turns approvals off while keeping the sandbox — like codex, it never prompts and a denied command fails.',
35
+ // Native autonomy dial at its top setting.
36
+ droid: 'runs --auto high, droid\'s full-autonomy setting.',
37
+ kimi: "runs kimi's native --auto.",
38
+ };
20
39
  /**
21
40
  * Build the permission-mode catalog for one harness.
22
41
  * `version` only affects the configured run.defaults lookup (modes themselves
@@ -42,6 +61,15 @@ export function getAgentModesCatalog(agent, version, cwd = process.cwd()) {
42
61
  if (unsupported.includes('auto')) {
43
62
  notes.push(`--mode auto degrades to edit on ${agent} (no native auto classifier).`);
44
63
  }
64
+ // MODE_DESCRIPTIONS is one flat Record<Mode, string> rendered for every agent,
65
+ // so it cannot name any single harness's mechanism without lying about the
66
+ // others: claude/copilot auto STILL PROMPTS for risky operations, while codex
67
+ // auto never prompts at all. The row states only what is true everywhere; the
68
+ // mechanism rides here, per harness.
69
+ const autoSemantics = AUTO_SEMANTICS[agent];
70
+ if (autoSemantics && supported.includes('auto')) {
71
+ notes.push(`${agent} --mode auto: ${autoSemantics}`);
72
+ }
45
73
  if (unsupported.includes('plan')) {
46
74
  notes.push(`--mode plan degrades to ${defaultMode} on ${agent} (no native read-only mode).`);
47
75
  }
@@ -552,9 +552,9 @@ export declare function resolveAgentName(input: string): AgentId | null;
552
552
  /** Check whether the input string matches any known agent name or alias. */
553
553
  export declare function isAgentName(input: string): boolean;
554
554
  /**
555
- * Split a CLI-provided `<agent>[@<version>]` spec into its bare agent id and
556
- * optional exact version token, the same way `agents run` does
557
- * (commands/exec.ts parses its positional with a plain `split('@')`) — NOT the
555
+ * Split a CLI-provided `<agent>[@<version>][#<label>]` spec into its bare agent
556
+ * id, optional exact version token, and optional account label, the same way
557
+ * `agents run` does — NOT the
558
558
  * agent-spec qualifier engine (`@latest`/`@all`), which enumerates installed
559
559
  * versions for read/diagnostic commands, not a single launch target. Returns an
560
560
  * error message rather than throwing so callers decide exit-vs-continue.
@@ -562,6 +562,7 @@ export declare function isAgentName(input: string): boolean;
562
562
  export declare function parseAgentVersionSpec(raw: string): {
563
563
  agent: AgentId;
564
564
  version?: string;
565
+ label?: string;
565
566
  } | {
566
567
  error: string;
567
568
  };
@@ -21,6 +21,7 @@ import chalk from 'chalk';
21
21
  import { execFileShellSpec } from '../platform/index.js';
22
22
  import { latestFileMtimeMs } from '../fs-walk.js';
23
23
  import { damerauLevenshtein } from '../fuzzy.js';
24
+ import { probeCapture } from '../probe.js';
24
25
  import { getCacheDir, getVersionsDir, getShimsDir, getHistoryDir, getCliVersionCachePath } from '../state.js';
25
26
  import { resolveVersion, getVersionHomePath, getBinaryPath } from '../installations/versions.js';
26
27
  import { supports } from '../capabilities.js';
@@ -297,7 +298,7 @@ export const AGENTS = {
297
298
  cloudProvider: 'codex',
298
299
  // Subagents: multi-agent plumbing since 0.117.0; custom agents as
299
300
  // ~/.codex/agents/*.toml (name, description, developer_instructions).
300
- capabilities: { hooks: { since: '0.116.0' }, mcp: true, mcpHttp: true, mcpHeaders: false, allowlist: { since: '0.138.0' }, skills: true, commands: { until: '0.117.0' }, plugins: { since: '0.128.0' }, subagents: { since: '0.117.0' }, rules: { file: 'AGENTS.md' }, workflows: false, memory: true, modes: ['plan', 'edit', 'skip'], interactiveRepl: true },
301
+ capabilities: { hooks: { since: '0.116.0' }, mcp: true, mcpHttp: true, mcpHeaders: false, allowlist: { since: '0.138.0' }, skills: true, commands: { until: '0.117.0' }, plugins: { since: '0.128.0' }, subagents: { since: '0.117.0' }, rules: { file: 'AGENTS.md' }, workflows: false, memory: true, modes: ['plan', 'edit', 'auto', 'skip'], interactiveRepl: true },
301
302
  },
302
303
  gemini: {
303
304
  id: 'gemini',
@@ -1194,7 +1195,11 @@ async function getCachedVersionForBinary(agentId, binaryPath) {
1194
1195
  const agent = AGENTS[agentId];
1195
1196
  let version = null;
1196
1197
  try {
1197
- const { stdout } = await execFileAsync(agent.cliCommand, ['--version'], { timeout: 3000 });
1198
+ // probeCapture, not bare execFileAsync: a probed harness can fork its own
1199
+ // children (copilot's platform-binary downloader), and a timeout kill of
1200
+ // the direct child would orphan them mid-write (RUSH-3028). The probe runs
1201
+ // in its own process group and the whole group is reaped on settle.
1202
+ const { stdout } = await probeCapture(agent.cliCommand, ['--version'], 3000);
1198
1203
  const versionRe = agent.versionStdoutMatch === 'openclaw'
1199
1204
  ? /openclaw\/(\d+\.\d+\.\d+)/
1200
1205
  : /(\d+\.\d+\.\d+)/;
@@ -3045,15 +3050,23 @@ export function isAgentName(input) {
3045
3050
  return resolveAgentName(input) !== null;
3046
3051
  }
3047
3052
  /**
3048
- * Split a CLI-provided `<agent>[@<version>]` spec into its bare agent id and
3049
- * optional exact version token, the same way `agents run` does
3050
- * (commands/exec.ts parses its positional with a plain `split('@')`) — NOT the
3053
+ * Split a CLI-provided `<agent>[@<version>][#<label>]` spec into its bare agent
3054
+ * id, optional exact version token, and optional account label, the same way
3055
+ * `agents run` does — NOT the
3051
3056
  * agent-spec qualifier engine (`@latest`/`@all`), which enumerates installed
3052
3057
  * versions for read/diagnostic commands, not a single launch target. Returns an
3053
3058
  * error message rather than throwing so callers decide exit-vs-continue.
3054
3059
  */
3055
3060
  export function parseAgentVersionSpec(raw) {
3056
- const parts = raw.split('@');
3061
+ const labelParts = raw.split('#');
3062
+ if (labelParts.length > 2) {
3063
+ return { error: `Invalid agent spec '${raw}': at most one '#label' is allowed` };
3064
+ }
3065
+ const [versionSpec, rawLabel] = labelParts;
3066
+ if (rawLabel !== undefined && (rawLabel === '' || !/^[a-zA-Z0-9][a-zA-Z0-9@._+-]*$/.test(rawLabel))) {
3067
+ return { error: `Invalid account label '${rawLabel}' in '${raw}'` };
3068
+ }
3069
+ const parts = versionSpec.split('@');
3057
3070
  if (parts.length > 2) {
3058
3071
  return { error: `Invalid agent spec '${raw}': at most one '@version' is allowed` };
3059
3072
  }
@@ -3065,7 +3078,7 @@ export function parseAgentVersionSpec(raw) {
3065
3078
  if (rawVersion !== undefined && (rawVersion === '' || !VERSION_RE.test(rawVersion))) {
3066
3079
  return { error: `Invalid version '${rawVersion}' in '${raw}'` };
3067
3080
  }
3068
- return { agent, ...(rawVersion ? { version: rawVersion } : {}) };
3081
+ return { agent, ...(rawVersion ? { version: rawVersion } : {}), ...(rawLabel ? { label: rawLabel } : {}) };
3069
3082
  }
3070
3083
  /**
3071
3084
  * Build the deprecation notice lines for an agent, or null if it isn't
@@ -5,7 +5,7 @@
5
5
  * peer of `agents insights` with overlapping "analytics" meaning, so agents and
6
6
  * humans kept picking the wrong verb. The cheap counter path (sessions index +
7
7
  * usage.db) still exists — it is now `agents insights mix` and the recipe
8
- * subcommands below. Latency stays on `agents perf`; quota on `agents usage`.
8
+ * subcommands below. Latency stays on `agents perf`; quota on `agents view`.
9
9
  *
10
10
  * Former top-level `agents trends` is gone. The nested spelling
11
11
  * `agents insights trends` is an alias of `agents insights mix`.
@@ -5,7 +5,7 @@
5
5
  * peer of `agents insights` with overlapping "analytics" meaning, so agents and
6
6
  * humans kept picking the wrong verb. The cheap counter path (sessions index +
7
7
  * usage.db) still exists — it is now `agents insights mix` and the recipe
8
- * subcommands below. Latency stays on `agents perf`; quota on `agents usage`.
8
+ * subcommands below. Latency stays on `agents perf`; quota on `agents view`.
9
9
  *
10
10
  * Former top-level `agents trends` is gone. The nested spelling
11
11
  * `agents insights trends` is an alias of `agents insights mix`.
@@ -97,7 +97,7 @@ export function registerMixCommands(parent) {
97
97
  Session recipes read sessions.db; resource recipes read ~/.agents/.history/analytics/usage.db.
98
98
  Empty recipes are skipped on the default mix board.
99
99
  This is the cheap counter path. Behavioural report (transcript content, account split)
100
- is bare \`agents insights\`. Latency is \`agents perf\`; quota is \`agents usage\`.
100
+ is bare \`agents insights\`. Latency is \`agents perf\`; quota is \`agents view\`.
101
101
  Skill/slash-command popularity is \`agents sessions stats\`.
102
102
  `,
103
103
  });
@@ -148,13 +148,21 @@ export declare function mergeAuthHealthEntries(current: Record<string, AuthHealt
148
148
  export declare function writeAuthHealthEntries(entries: Record<string, AuthHealth>): void;
149
149
  /**
150
150
  * Complete a live auth probe for one (agent, home). For claude/kimi/droid this
151
- * hits the provider; for everyone else it reports a best-effort local verdict
152
- * (`unverified` when a credential is present, `unconfigured` otherwise) never
153
- * masquerading as `live`.
151
+ * hits the provider unless the account's usage cache already holds a fresh
152
+ * successful fetch, which is the same authenticated request and proves the
153
+ * token live without spending a second one (RUSH-3036). For everyone else it
154
+ * reports a best-effort local verdict (`unverified` when a credential is
155
+ * present, `unconfigured` otherwise) — never masquerading as `live`.
154
156
  */
155
157
  export declare function probeAuthHealth(agent: AgentId, home: string | undefined, opts?: {
156
158
  cliVersion?: string | null;
157
159
  info?: AccountInfo | null;
160
+ /**
161
+ * Skip the derived-from-usage shortcut and fire a real network probe
162
+ * (RUSH-3036). Set by `agents devices ping [--strict]`, whose contract is
163
+ * a genuinely live request that surfaces `revoked` immediately.
164
+ */
165
+ forceLive?: boolean;
158
166
  }): Promise<AuthHealth>;
159
167
  /** One probed install on a host. */
160
168
  export interface AuthProbeRow {
@@ -215,6 +223,8 @@ export declare function groupFleetAuthInstalls<T extends FleetAuthInstall>(insta
215
223
  export declare function probeLocalFleetAuth(opts?: {
216
224
  cliVersion?: string | null;
217
225
  agents?: readonly AgentId[];
226
+ /** Fire real network probes even when fresh usage evidence exists (RUSH-3036) — the `devices ping [--strict]` contract. */
227
+ forceLive?: boolean;
218
228
  }): Promise<AuthProbeRow[]>;
219
229
  /** Persist a host's probed rows into the cache (keyed by host+agent+version). */
220
230
  export declare function writeFleetAuthRows(host: string, rows: AuthProbeRow[]): void;
@@ -20,7 +20,7 @@ import * as fs from 'fs';
20
20
  import * as path from 'path';
21
21
  import { ALL_AGENT_IDS, getAccountInfo } from './agents.js';
22
22
  import { getCacheDir } from './state.js';
23
- import { probeClaudeStatus, probeDroidStatus, probeKimiStatus, USAGE_HEADLESS_SCOPE_MARKER, } from './accounting/usage.js';
23
+ import { probeClaudeStatus, probeDroidStatus, probeKimiStatus, USAGE_HEADLESS_SCOPE_MARKER, readClaudeUsageCache, } from './accounting/usage.js';
24
24
  import { getVersionHomePath, listInstalledVersions } from './installations/versions.js';
25
25
  import { atomicWriteFileSync, ensureLockTarget, withFileLock } from './fs-atomic.js';
26
26
  /** Agents with a live network probe wired up today. The rest are best-effort. */
@@ -294,22 +294,64 @@ export function writeAuthHealthEntries(entries) {
294
294
  // ---------------------------------------------------------------------------
295
295
  // The probe (writer side)
296
296
  // ---------------------------------------------------------------------------
297
+ /**
298
+ * A usage snapshot this recent is live proof the account's shared setup-token
299
+ * works: the snapshot only exists because an authenticated `/oauth/usage`
300
+ * request succeeded. Matches the periodic tick's own probe window
301
+ * (AUTH_PROBE_MAX_AGE_MS in daemon-ticks.ts — not imported to avoid a cycle;
302
+ * a drift here only widens/narrows evidence freshness, never correctness).
303
+ */
304
+ const FRESH_USAGE_VERDICT_MAX_AGE_MS = 20 * 60_000;
305
+ /**
306
+ * A `live` verdict derived from the account's usage cache instead of a second
307
+ * network request (RUSH-3036). The auth probe and the usage fetch hit the SAME
308
+ * rate-limited endpoint with the SAME fleet-shared setup-token, so a fresh
309
+ * successful usage snapshot already proves everything the probe would: paying a
310
+ * second request per account per box was half the fleet's endpoint load.
311
+ *
312
+ * Two guards keep the evidence honest (both review findings on the first cut):
313
+ * the caller must assert this box holds a LOCAL credential for the account
314
+ * (`signedIn`) — a fleet-imported snapshot proves the shared token works, not
315
+ * that THIS box can authenticate, so an unsigned home never derives `live`;
316
+ * and a `forceLive` caller (`agents devices ping --strict`) skips derivation
317
+ * entirely, because its contract is a real request that can surface `revoked`
318
+ * within seconds, not minutes. Returns null when there is no admissible fresh
319
+ * evidence — the caller then live-probes as before.
320
+ */
321
+ function verdictFromFreshUsage(usageKey, signedIn, now) {
322
+ if (!usageKey || !signedIn)
323
+ return null;
324
+ const snapshot = readClaudeUsageCache(usageKey);
325
+ const capturedAt = snapshot?.capturedAt?.getTime();
326
+ if (!capturedAt || now - capturedAt >= FRESH_USAGE_VERDICT_MAX_AGE_MS)
327
+ return null;
328
+ const ageMin = Math.max(1, Math.round((now - capturedAt) / 60_000));
329
+ return { verdict: 'live', checkedAt: now, detail: `token proven live by a usage fetch ${ageMin}m ago` };
330
+ }
297
331
  /**
298
332
  * Complete a live auth probe for one (agent, home). For claude/kimi/droid this
299
- * hits the provider; for everyone else it reports a best-effort local verdict
300
- * (`unverified` when a credential is present, `unconfigured` otherwise) never
301
- * masquerading as `live`.
333
+ * hits the provider unless the account's usage cache already holds a fresh
334
+ * successful fetch, which is the same authenticated request and proves the
335
+ * token live without spending a second one (RUSH-3036). For everyone else it
336
+ * reports a best-effort local verdict (`unverified` when a credential is
337
+ * present, `unconfigured` otherwise) — never masquerading as `live`.
302
338
  */
303
339
  export async function probeAuthHealth(agent, home, opts) {
304
340
  const checkedAt = Date.now();
305
341
  if (LIVE_PROBE_AGENTS.has(agent)) {
342
+ const usageScope = opts?.info?.usageKey ?? null;
343
+ if (opts?.forceLive !== true) {
344
+ const derived = verdictFromFreshUsage(usageScope, opts?.info?.signedIn === true, checkedAt);
345
+ if (derived)
346
+ return derived;
347
+ }
306
348
  let probe;
307
349
  if (agent === 'claude')
308
- probe = await probeClaudeStatus(home, opts?.cliVersion);
350
+ probe = await probeClaudeStatus(home, opts?.cliVersion, usageScope);
309
351
  else if (agent === 'kimi')
310
- probe = await probeKimiStatus(home);
352
+ probe = await probeKimiStatus(home, usageScope);
311
353
  else
312
- probe = await probeDroidStatus(home);
354
+ probe = await probeDroidStatus(home, usageScope);
313
355
  return { verdict: verdictFromProbe(probe), checkedAt, detail: probeDetail(probe) };
314
356
  }
315
357
  const info = opts?.info !== undefined ? opts.info : await getAccountInfo(agent, home).catch(() => null);
@@ -384,7 +426,7 @@ export async function probeLocalFleetAuth(opts) {
384
426
  // accounts, so no same-account concurrency is left to trip the throttle.
385
427
  const perGroup = await Promise.all(groupFleetAuthInstalls(installs, (inst) => LIVE_PROBE_AGENTS.has(inst.agent)).map(async (group) => {
386
428
  const rep = group.probe;
387
- const health = await probeAuthHealth(rep.agent, rep.home, { cliVersion: opts?.cliVersion, info: rep.info });
429
+ const health = await probeAuthHealth(rep.agent, rep.home, { cliVersion: opts?.cliVersion, info: rep.info, forceLive: opts?.forceLive });
388
430
  health.account = authAccountLabel(rep.info);
389
431
  if (health.verdict === 'unconfigured')
390
432
  return [];
@@ -13,3 +13,22 @@ export declare const DEFAULT_VIEWPORT: {
13
13
  export declare const DEVICES: Record<string, DeviceDescriptor>;
14
14
  export declare function getDevice(name: string): DeviceDescriptor | undefined;
15
15
  export declare function listDevices(): string[];
16
+ /**
17
+ * Parse a `--window WxH` value. Returns null when the value is malformed, so
18
+ * callers emit their own actionable error.
19
+ *
20
+ * Shared by `profiles create` and `profiles edit` — one regex, one test, no
21
+ * drift between the two surfaces.
22
+ */
23
+ export declare function parseWindowSize(raw: string): {
24
+ width: number;
25
+ height: number;
26
+ } | null;
27
+ /**
28
+ * Parse a `--position X,Y` value. Negative coordinates are valid — a window may
29
+ * sit on a display left of or above the primary one.
30
+ */
31
+ export declare function parseWindowPosition(raw: string): {
32
+ x: number;
33
+ y: number;
34
+ } | null;
@@ -36,3 +36,26 @@ export function getDevice(name) {
36
36
  export function listDevices() {
37
37
  return Object.keys(DEVICES);
38
38
  }
39
+ /**
40
+ * Parse a `--window WxH` value. Returns null when the value is malformed, so
41
+ * callers emit their own actionable error.
42
+ *
43
+ * Shared by `profiles create` and `profiles edit` — one regex, one test, no
44
+ * drift between the two surfaces.
45
+ */
46
+ export function parseWindowSize(raw) {
47
+ const m = String(raw).match(/^(\d+)x(\d+)$/);
48
+ if (!m)
49
+ return null;
50
+ return { width: parseInt(m[1], 10), height: parseInt(m[2], 10) };
51
+ }
52
+ /**
53
+ * Parse a `--position X,Y` value. Negative coordinates are valid — a window may
54
+ * sit on a display left of or above the primary one.
55
+ */
56
+ export function parseWindowPosition(raw) {
57
+ const m = String(raw).match(/^(-?\d+),(-?\d+)$/);
58
+ if (!m)
59
+ return null;
60
+ return { x: parseInt(m[1], 10), y: parseInt(m[2], 10) };
61
+ }
@@ -96,7 +96,7 @@ export declare function sendIPCRequest(request: IPCRequest, opts?: IPCRequestOpt
96
96
  * Fill actor / launchId / sessionId from the calling process when the request
97
97
  * left them blank. Explicit values on the request always win.
98
98
  */
99
- export declare function stampCallerIdentity(request: IPCRequest): IPCRequest;
99
+ export declare function stampCallerIdentity(request: IPCRequest, env?: NodeJS.ProcessEnv): IPCRequest;
100
100
  /**
101
101
  * Open one connection that can serve many browser operations. This uses the
102
102
  * same daemon readiness and version-reconciliation path as sendIPCRequest;
@@ -8,6 +8,7 @@ import { startDaemon, stopDaemon } from '../daemon/daemon.js';
8
8
  import { getCliVersion } from '../version.js';
9
9
  import { compareVersions } from '../agent-spec/primitives.js';
10
10
  import { getDaemonLogPath } from '../daemon/daemon.js';
11
+ import { isFleetRemoteInvocation } from './remote-control.js';
11
12
  import { resolveCallerIdentity } from './caller-identity.js';
12
13
  import { actionable } from './service.js';
13
14
  /**
@@ -382,6 +383,7 @@ export class BrowserIPCServer {
382
383
  actor: request.actor,
383
384
  launchId: request.launchId,
384
385
  sessionId: request.sessionId,
386
+ fleetRemote: request.fleetRemote,
385
387
  createIfMissing,
386
388
  title: request.title,
387
389
  url: request.url,
@@ -421,6 +423,22 @@ export class BrowserIPCServer {
421
423
  case 'version': {
422
424
  return { ok: true, version: getCliVersion() };
423
425
  }
426
+ case 'show': {
427
+ // Task-less by design — see BrowserService.showUrl. Absent from both
428
+ // PAGE_CREATE_VERBS and PAGE_RESOLVE_VERBS, so bindTask never ran above
429
+ // and this request carries no task.
430
+ if (!request.url) {
431
+ return { ok: false, error: actionable('URL required.', 'Next: agents browser navigate <url>') };
432
+ }
433
+ if (!request.profile) {
434
+ return { ok: false, error: actionable('Profile required.', 'Next: agents browser profiles list') };
435
+ }
436
+ const shown = await this.service.showUrl(request.profile, request.url, {
437
+ fleetRemote: request.fleetRemote,
438
+ actor: request.actor,
439
+ });
440
+ return { ok: true, tabId: shown.tabId };
441
+ }
424
442
  case 'start': {
425
443
  if (!request.profile) {
426
444
  return { ok: false, error: actionable('Profile required.', 'Next: agents browser profiles list') };
@@ -434,6 +452,7 @@ export class BrowserIPCServer {
434
452
  actor: request.actor,
435
453
  launchId: request.launchId,
436
454
  sessionId: request.sessionId,
455
+ fleetRemote: request.fleetRemote,
437
456
  title: request.title,
438
457
  });
439
458
  return {
@@ -855,15 +874,22 @@ export async function sendIPCRequest(request, opts = {}) {
855
874
  * Fill actor / launchId / sessionId from the calling process when the request
856
875
  * left them blank. Explicit values on the request always win.
857
876
  */
858
- export function stampCallerIdentity(request) {
859
- if (request.actor && request.launchId && request.sessionId)
860
- return request;
877
+ export function stampCallerIdentity(request, env = process.env) {
878
+ // The consent marker is stamped FIRST, above the identity early-return: a
879
+ // request that already carries a full identity (every streamed request does)
880
+ // would otherwise skip stamping entirely and reach the daemon unmarked.
881
+ // OR-ed, never overwritten — a client may assert the marker but must not be
882
+ // able to clear one this environment sets.
883
+ const fleetRemote = isFleetRemoteInvocation(env) || request.fleetRemote === true;
884
+ const marked = request.fleetRemote === fleetRemote ? request : { ...request, fleetRemote };
885
+ if (marked.actor && marked.launchId && marked.sessionId)
886
+ return marked;
861
887
  const id = resolveCallerIdentity();
862
888
  return {
863
- ...request,
864
- actor: request.actor ?? id.actor,
865
- launchId: request.launchId ?? id.launchId,
866
- sessionId: request.sessionId ?? id.sessionId,
889
+ ...marked,
890
+ actor: marked.actor ?? id.actor,
891
+ launchId: marked.launchId ?? id.launchId,
892
+ sessionId: marked.sessionId ?? id.sessionId,
867
893
  };
868
894
  }
869
895
  /**
@@ -1,4 +1,4 @@
1
- import type { BrowserProfile } from './types.js';
1
+ import type { BrowserProfile, ProfileName } from './types.js';
2
2
  export type { BrowserProfile } from './types.js';
3
3
  /**
4
4
  * Name of the profile `ensureDefaultBrowserProfile` auto-detects and pins.
@@ -167,6 +167,20 @@ export declare function effectiveLocalPort(profile: BrowserProfile): number | un
167
167
  * skip remote profiles in this scan.
168
168
  */
169
169
  export declare function findFreeProfilePort(): Promise<number>;
170
+ /**
171
+ * Refuse a profile whose LOCAL port another profile already owns.
172
+ *
173
+ * Every CDP/SSH profile ends up listening on (or tunneling to) the same LOCAL
174
+ * port number as the one configured in the endpoint URL — SSH profiles reuse
175
+ * `?port=N` locally, so we no longer scope by host. Two profiles that would
176
+ * need the same local port can't both run at the same time.
177
+ *
178
+ * `opts.ignore` is the profile's own name on an edit. Without it every edit
179
+ * collides with itself, since the stored copy still owns the port being kept.
180
+ */
181
+ export declare function assertLocalPortFree(profile: BrowserProfile, opts?: {
182
+ ignore?: string;
183
+ }): void;
170
184
  /**
171
185
  * Create a profile. **It lands in this machine's own store by default** — pass
172
186
  * `{ fleet: true }` (the CLI's `--fleet`) to put it in the synced `agents.yaml`
@@ -188,6 +202,97 @@ export declare function createProfile(profile: BrowserProfile, opts?: {
188
202
  fleet?: boolean;
189
203
  }): Promise<void>;
190
204
  export declare function updateProfile(profile: BrowserProfile): Promise<void>;
205
+ /**
206
+ * Flag a fleet-synced profile whose contents only make sense on ONE machine.
207
+ *
208
+ * A `cdp://` endpoint on loopback always connects on the machine EVALUATING the
209
+ * profile, so a fleet-scope copy does not mean "that browser" — it means "port N
210
+ * on whichever box ran the command". The name then silently resolves to a
211
+ * different, usually logged-out browser on every other machine, which is the
212
+ * worst possible failure for a profile that carries live logins.
213
+ *
214
+ * Reported by `profiles doctor` (a failing `scope` check) and noted in the
215
+ * `kept` reason `profiles prune` prints for a fleet profile. The repair is
216
+ * {@link moveProfileScope}, never an automatic rewrite of the synced doc.
217
+ */
218
+ export declare function misfiledFleetProfile(profile: BrowserProfile, scope: ProfileScope): {
219
+ misfiled: false;
220
+ } | {
221
+ misfiled: true;
222
+ why: string;
223
+ };
224
+ /** Fields an existing profile may be edited in place.
225
+ *
226
+ * Deliberately a SUBSET of {@link BrowserProfile}. `name` and `browser` are
227
+ * identity, not settings: both key the on-disk runtime dir
228
+ * ({@link getProfileRuntimeDir}) and any live `<name>@<endpoint>` connection, so
229
+ * changing either orphans the cached browser data. Delete and recreate instead.
230
+ */
231
+ export type EditableProfileFields = Partial<Pick<BrowserProfile, 'description' | 'binary' | 'electron' | 'targetFilter' | 'endpoints' | 'chrome' | 'secrets' | 'viewport'>>;
232
+ export interface EditProfileResult {
233
+ profile: BrowserProfile;
234
+ scope: ProfileScope;
235
+ /** Field names that actually changed. Empty means the edit was a no-op. */
236
+ changed: string[];
237
+ /**
238
+ * Set when the write landed machine-locally while a stale fleet copy remains.
239
+ * Only reachable for {@link isMachineLocalProfile} names that an older version
240
+ * wrote into the fleet store: {@link updateProfile} pins those local, so this
241
+ * box sees the edit and every other box keeps the old value. Callers must say
242
+ * so rather than reporting a clean success.
243
+ */
244
+ fleetCopyLeftStale?: boolean;
245
+ }
246
+ /**
247
+ * Merge `patch` onto the stored profile and persist it in the store it already
248
+ * lives in (see {@link updateProfile} for the scope rules).
249
+ *
250
+ * Runs {@link createProfile}'s validations against the MERGED record, not just
251
+ * the patched fields — a binary edit re-resolves the browser path, a
252
+ * targetFilter edit re-checks the electron gate, and the local-port scan runs
253
+ * with this profile excluded so an unchanged port is not a self-collision.
254
+ */
255
+ export declare function editProfile(name: string, patch: EditableProfileFields): Promise<EditProfileResult>;
256
+ /**
257
+ * Throw if `name` cannot be used for a NEW profile.
258
+ *
259
+ * Shared by `profiles create` and {@link renameProfile} so the two cannot drift
260
+ * — the shape rule used to live inline in the command, which is how `rename`
261
+ * would have accepted a name `create` rejects.
262
+ *
263
+ * `default` is refused because it is the reserved ALIAS meaning "whatever
264
+ * profile this machine is configured to use" (RUSH-2709). A literal profile by
265
+ * that name makes `--profile default` mean two different things.
266
+ */
267
+ export declare function assertRegistrableProfileName(name: string): void;
268
+ /**
269
+ * Rename a profile, taking its on-disk state with it.
270
+ *
271
+ * `editProfile` deliberately refuses a name change, and this is why: the name
272
+ * keys the runtime dir ({@link getProfileRuntimeDir}), every fork/endpoint dir
273
+ * derived from it, and the `browser.profile` pointer. Delete-and-recreate — the
274
+ * only route before this — silently abandons the browser's `--user-data-dir`,
275
+ * which is where a profile's logins live. On a real agent browser that is
276
+ * gigabytes of session state and every account it has ever signed into.
277
+ *
278
+ * Refuses when the profile is in use: moving a `--user-data-dir` out from under
279
+ * a running browser corrupts it.
280
+ */
281
+ export declare function renameProfile(from: ProfileName, to: ProfileName): Promise<{
282
+ scope: ProfileScope;
283
+ movedDirs: string[];
284
+ repointedDefault: boolean;
285
+ repointedViewer: boolean;
286
+ /** Peers still pinning the OLD name, and which key each used. */
287
+ stalePins: Array<{
288
+ device: string;
289
+ key: 'browser.profile' | 'browser.viewer';
290
+ }>;
291
+ }>;
292
+ export declare function moveProfileScope(name: string, to: ProfileScope): Promise<{
293
+ from: ProfileScope;
294
+ to: ProfileScope;
295
+ }>;
191
296
  export declare function deleteProfile(name: string): Promise<void>;
192
297
  /**
193
298
  * Pad `text` to exactly `width` visible characters, truncating with an ellipsis