@phnx-labs/agents-cli 1.20.64 → 1.20.66

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 (188) hide show
  1. package/CHANGELOG.md +51 -3
  2. package/README.md +156 -3
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/apply.d.ts +12 -0
  5. package/dist/commands/apply.js +274 -0
  6. package/dist/commands/browser.js +2 -2
  7. package/dist/commands/cloud.js +32 -2
  8. package/dist/commands/doctor.js +4 -1
  9. package/dist/commands/exec.d.ts +48 -0
  10. package/dist/commands/exec.js +159 -49
  11. package/dist/commands/feed.js +25 -11
  12. package/dist/commands/hosts.js +44 -6
  13. package/dist/commands/mcp.js +55 -5
  14. package/dist/commands/monitors.d.ts +12 -0
  15. package/dist/commands/monitors.js +748 -0
  16. package/dist/commands/output.js +2 -2
  17. package/dist/commands/plugins.js +28 -7
  18. package/dist/commands/routines.js +23 -2
  19. package/dist/commands/secrets.d.ts +16 -0
  20. package/dist/commands/secrets.js +215 -64
  21. package/dist/commands/serve.js +31 -0
  22. package/dist/commands/sessions-browser.d.ts +82 -0
  23. package/dist/commands/sessions-browser.js +320 -0
  24. package/dist/commands/sessions-export.js +8 -3
  25. package/dist/commands/sessions.d.ts +17 -0
  26. package/dist/commands/sessions.js +157 -10
  27. package/dist/commands/share.d.ts +2 -0
  28. package/dist/commands/share.js +150 -0
  29. package/dist/commands/ssh.js +54 -3
  30. package/dist/commands/versions.js +7 -3
  31. package/dist/commands/view.d.ts +26 -0
  32. package/dist/commands/view.js +32 -9
  33. package/dist/commands/webhook.js +10 -2
  34. package/dist/index.js +35 -14
  35. package/dist/lib/agents.d.ts +46 -0
  36. package/dist/lib/agents.js +121 -3
  37. package/dist/lib/auto-dispatch-provider.js +7 -2
  38. package/dist/lib/auto-dispatch.d.ts +3 -0
  39. package/dist/lib/auto-dispatch.js +3 -0
  40. package/dist/lib/browser/chrome.js +2 -2
  41. package/dist/lib/cloud/antigravity.js +2 -2
  42. package/dist/lib/cloud/host.d.ts +59 -0
  43. package/dist/lib/cloud/host.js +224 -0
  44. package/dist/lib/cloud/registry.js +4 -0
  45. package/dist/lib/cloud/types.d.ts +6 -4
  46. package/dist/lib/computer-rpc.js +3 -1
  47. package/dist/lib/crabbox/cli.js +5 -1
  48. package/dist/lib/crabbox/runtimes.js +11 -2
  49. package/dist/lib/daemon.d.ts +20 -4
  50. package/dist/lib/daemon.js +62 -19
  51. package/dist/lib/devices/connect.d.ts +18 -1
  52. package/dist/lib/devices/connect.js +10 -2
  53. package/dist/lib/devices/fleet.d.ts +3 -2
  54. package/dist/lib/devices/fleet.js +9 -0
  55. package/dist/lib/devices/known-hosts.d.ts +62 -0
  56. package/dist/lib/devices/known-hosts.js +137 -0
  57. package/dist/lib/devices/registry.d.ts +15 -0
  58. package/dist/lib/devices/registry.js +9 -0
  59. package/dist/lib/exec.d.ts +19 -2
  60. package/dist/lib/exec.js +41 -13
  61. package/dist/lib/fleet/apply.d.ts +63 -0
  62. package/dist/lib/fleet/apply.js +214 -0
  63. package/dist/lib/fleet/auth-sync.d.ts +67 -0
  64. package/dist/lib/fleet/auth-sync.js +142 -0
  65. package/dist/lib/fleet/manifest.d.ts +29 -0
  66. package/dist/lib/fleet/manifest.js +127 -0
  67. package/dist/lib/fleet/types.d.ts +129 -0
  68. package/dist/lib/fleet/types.js +13 -0
  69. package/dist/lib/git.d.ts +27 -0
  70. package/dist/lib/git.js +34 -2
  71. package/dist/lib/hosts/dispatch.d.ts +29 -8
  72. package/dist/lib/hosts/dispatch.js +66 -20
  73. package/dist/lib/hosts/passthrough.js +2 -0
  74. package/dist/lib/hosts/providers/devices.d.ts +27 -0
  75. package/dist/lib/hosts/providers/devices.js +98 -0
  76. package/dist/lib/hosts/registry.d.ts +10 -16
  77. package/dist/lib/hosts/registry.js +17 -50
  78. package/dist/lib/hosts/remote-cmd.d.ts +23 -0
  79. package/dist/lib/hosts/remote-cmd.js +79 -4
  80. package/dist/lib/hosts/run-target.d.ts +84 -0
  81. package/dist/lib/hosts/run-target.js +99 -0
  82. package/dist/lib/hosts/types.d.ts +23 -5
  83. package/dist/lib/hosts/types.js +22 -4
  84. package/dist/lib/linear-autoclose.d.ts +30 -0
  85. package/dist/lib/linear-autoclose.js +22 -0
  86. package/dist/lib/mcp.d.ts +27 -1
  87. package/dist/lib/mcp.js +126 -12
  88. package/dist/lib/monitors/config.d.ts +161 -0
  89. package/dist/lib/monitors/config.js +372 -0
  90. package/dist/lib/monitors/dispatch.d.ts +28 -0
  91. package/dist/lib/monitors/dispatch.js +91 -0
  92. package/dist/lib/monitors/engine.d.ts +61 -0
  93. package/dist/lib/monitors/engine.js +205 -0
  94. package/dist/lib/monitors/sources/command.d.ts +11 -0
  95. package/dist/lib/monitors/sources/command.js +31 -0
  96. package/dist/lib/monitors/sources/device.d.ts +13 -0
  97. package/dist/lib/monitors/sources/device.js +45 -0
  98. package/dist/lib/monitors/sources/file.d.ts +14 -0
  99. package/dist/lib/monitors/sources/file.js +57 -0
  100. package/dist/lib/monitors/sources/http.d.ts +10 -0
  101. package/dist/lib/monitors/sources/http.js +34 -0
  102. package/dist/lib/monitors/sources/index.d.ts +14 -0
  103. package/dist/lib/monitors/sources/index.js +31 -0
  104. package/dist/lib/monitors/sources/poll.d.ts +9 -0
  105. package/dist/lib/monitors/sources/poll.js +9 -0
  106. package/dist/lib/monitors/sources/types.d.ts +18 -0
  107. package/dist/lib/monitors/sources/types.js +9 -0
  108. package/dist/lib/monitors/sources/webhook.d.ts +23 -0
  109. package/dist/lib/monitors/sources/webhook.js +47 -0
  110. package/dist/lib/monitors/sources/ws.d.ts +14 -0
  111. package/dist/lib/monitors/sources/ws.js +45 -0
  112. package/dist/lib/monitors/state.d.ts +69 -0
  113. package/dist/lib/monitors/state.js +144 -0
  114. package/dist/lib/picker.d.ts +53 -0
  115. package/dist/lib/picker.js +214 -1
  116. package/dist/lib/platform/exec.d.ts +16 -0
  117. package/dist/lib/platform/exec.js +17 -0
  118. package/dist/lib/plugins.d.ts +31 -1
  119. package/dist/lib/plugins.js +175 -15
  120. package/dist/lib/redact.d.ts +14 -1
  121. package/dist/lib/redact.js +47 -1
  122. package/dist/lib/remote-agents-json.js +7 -1
  123. package/dist/lib/rotate.d.ts +6 -3
  124. package/dist/lib/rotate.js +0 -1
  125. package/dist/lib/routines.d.ts +16 -0
  126. package/dist/lib/routines.js +19 -0
  127. package/dist/lib/runner.d.ts +1 -0
  128. package/dist/lib/runner.js +102 -9
  129. package/dist/lib/secrets/agent.d.ts +83 -10
  130. package/dist/lib/secrets/agent.js +237 -70
  131. package/dist/lib/secrets/bundles.d.ts +26 -0
  132. package/dist/lib/secrets/bundles.js +59 -8
  133. package/dist/lib/secrets/filestore.d.ts +9 -0
  134. package/dist/lib/secrets/filestore.js +21 -8
  135. package/dist/lib/secrets/index.d.ts +7 -0
  136. package/dist/lib/secrets/index.js +26 -6
  137. package/dist/lib/secrets/mcp.js +4 -2
  138. package/dist/lib/secrets/remote.d.ts +17 -0
  139. package/dist/lib/secrets/remote.js +40 -0
  140. package/dist/lib/self-update.d.ts +20 -0
  141. package/dist/lib/self-update.js +54 -1
  142. package/dist/lib/serve/control.d.ts +95 -0
  143. package/dist/lib/serve/control.js +260 -0
  144. package/dist/lib/serve/server.d.ts +35 -1
  145. package/dist/lib/serve/server.js +106 -76
  146. package/dist/lib/serve/stream.d.ts +43 -0
  147. package/dist/lib/serve/stream.js +116 -0
  148. package/dist/lib/serve/token.d.ts +35 -0
  149. package/dist/lib/serve/token.js +85 -0
  150. package/dist/lib/session/bundle.d.ts +14 -0
  151. package/dist/lib/session/bundle.js +12 -1
  152. package/dist/lib/session/remote-list.js +5 -1
  153. package/dist/lib/session/state.d.ts +7 -25
  154. package/dist/lib/session/state.js +16 -6
  155. package/dist/lib/session/sync/config.js +8 -2
  156. package/dist/lib/session/types.d.ts +30 -0
  157. package/dist/lib/share/capture.d.ts +29 -0
  158. package/dist/lib/share/capture.js +140 -0
  159. package/dist/lib/share/config.d.ts +35 -0
  160. package/dist/lib/share/config.js +100 -0
  161. package/dist/lib/share/og.d.ts +25 -0
  162. package/dist/lib/share/og.js +84 -0
  163. package/dist/lib/share/provision.d.ts +10 -0
  164. package/dist/lib/share/provision.js +91 -0
  165. package/dist/lib/share/publish.d.ts +57 -0
  166. package/dist/lib/share/publish.js +145 -0
  167. package/dist/lib/share/worker-template.d.ts +2 -0
  168. package/dist/lib/share/worker-template.js +82 -0
  169. package/dist/lib/shims.d.ts +13 -0
  170. package/dist/lib/shims.js +42 -2
  171. package/dist/lib/ssh-exec.d.ts +24 -0
  172. package/dist/lib/ssh-exec.js +15 -3
  173. package/dist/lib/ssh-tunnel.d.ts +19 -1
  174. package/dist/lib/ssh-tunnel.js +86 -7
  175. package/dist/lib/startup/command-registry.d.ts +3 -0
  176. package/dist/lib/startup/command-registry.js +6 -0
  177. package/dist/lib/state.d.ts +5 -0
  178. package/dist/lib/state.js +12 -0
  179. package/dist/lib/tmux/session.d.ts +7 -0
  180. package/dist/lib/tmux/session.js +3 -1
  181. package/dist/lib/triggers/webhook.d.ts +18 -0
  182. package/dist/lib/triggers/webhook.js +105 -0
  183. package/dist/lib/types.d.ts +36 -1
  184. package/dist/lib/usage.js +7 -5
  185. package/dist/lib/versions.js +14 -11
  186. package/dist/lib/workflows.d.ts +20 -0
  187. package/dist/lib/workflows.js +24 -0
  188. package/package.json +2 -1
@@ -16,7 +16,7 @@ import { truncate, padRight } from '../lib/format.js';
16
16
  import ora from 'ora';
17
17
  import { SESSION_AGENTS } from '../lib/session/types.js';
18
18
  import { discoverArtifacts, readArtifact, resolveArtifact } from '../lib/session/artifacts.js';
19
- import { looksLikePath, toComparablePath, homeDir, needsWindowsShell, findExecutable } from '../lib/platform/index.js';
19
+ import { looksLikePath, toComparablePath, homeDir, needsWindowsShell, findExecutable, composeWin32CommandLine } from '../lib/platform/index.js';
20
20
  import { getActiveSessions } from '../lib/session/active.js';
21
21
  import { enumerateGhosttyTabs, assignGhosttyTabs } from '../lib/session/ghostty-tabs.js';
22
22
  import { mapPanesToTargets, listClients } from '../lib/tmux/session.js';
@@ -25,6 +25,7 @@ import { machineId, normalizeHost } from '../lib/session/sync/config.js';
25
25
  import { gatherRemoteActive, NO_FANOUT_ENV } from '../lib/session/remote-active.js';
26
26
  import { gatherRemoteList, runOnPeer } from '../lib/session/remote-list.js';
27
27
  import { stringWidth, truncateToWidth, padToWidth, terminalWidth } from '../lib/session/width.js';
28
+ import { inferSessionState } from '../lib/session/state.js';
28
29
  import { discoverSessions, countSessionsInScope, resolveSessionById, searchContentIndex, getSessionRoots } from '../lib/session/discover.js';
29
30
  import { filterTeamSessions } from '../lib/session/team-filter.js';
30
31
  import { parseSession } from '../lib/session/parse.js';
@@ -36,7 +37,7 @@ import { colorAgent, resolveAgentName } from '../lib/agents.js';
36
37
  import { fuzzyMatch, FUZZY_PRESETS } from '../lib/fuzzy.js';
37
38
  import { resolveVersionAliasLoose } from '../lib/versions.js';
38
39
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
39
- import { sessionPicker } from './sessions-picker.js';
40
+ import { sessionPicker, buildPreview } from './sessions-picker.js';
40
41
  import { setHelpSections } from '../lib/help.js';
41
42
  import { registerSessionsTailCommand } from './sessions-tail.js';
42
43
  import { registerSessionsSyncCommand } from './sessions-sync.js';
@@ -789,6 +790,57 @@ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
789
790
  function printCrossMachineTip() {
790
791
  console.log(chalk.gray("\nTip: include sessions from your other machines — register them with 'ag devices sync', then rerun. Use --local to skip."));
791
792
  }
793
+ /**
794
+ * True when the interactive session browser should open instead of a printed
795
+ * listing: a real TTY, no `--json`, and `--no-interactive` not set. The bare
796
+ * listing and `--active` both default to it; scripts/pipes/agents fall through
797
+ * to the existing printed/JSON paths.
798
+ */
799
+ function useInteractiveBrowser(options) {
800
+ return options.interactive !== false && !options.json && isInteractiveTerminal();
801
+ }
802
+ /** The canonical `ag sessions …` command for a set of flags — the twin of the
803
+ * browser's `y` hotkey (see --print-cmd). Normalizes to the stable flag form. */
804
+ function canonicalSessionsCommand(query, options) {
805
+ const a = ['sessions'];
806
+ if (options.active)
807
+ a.push('--active');
808
+ if (options.teams)
809
+ a.push('--teams');
810
+ if (options.agent)
811
+ a.push('-a', options.agent);
812
+ for (const h of options.host ?? [])
813
+ a.push('--device', h);
814
+ if (options.project)
815
+ a.push('--project', options.project);
816
+ if (options.all)
817
+ a.push('--all');
818
+ if (options.since)
819
+ a.push('--since', options.since);
820
+ if (options.until)
821
+ a.push('--until', options.until);
822
+ if (options.local)
823
+ a.push('--local');
824
+ if (options.waiting)
825
+ a.push('--waiting');
826
+ const q = (query ?? '').trim();
827
+ if (q)
828
+ a.push(JSON.stringify(q));
829
+ return 'ag ' + a.join(' ');
830
+ }
831
+ /** Resolve a session by id/query globally and print its compact preview (no pager).
832
+ * Backs `--preview` — the fast path for the "peek before resume" hot loop. */
833
+ async function renderSessionPreview(query, scope) {
834
+ const discovered = await discoverSessions({ all: true, cwd: process.cwd(), limit: 5000 });
835
+ const pool = applyScopeFilters(discovered, scope);
836
+ const matches = resolveSessionById(pool, query);
837
+ const session = (matches.length > 0 ? matches : filterSessionsByQuery(pool, query))[0];
838
+ if (!session) {
839
+ console.log(chalk.gray(`No session matches "${query}".`));
840
+ return;
841
+ }
842
+ console.log(buildPreview(session));
843
+ }
792
844
  /** Main action handler for `agents sessions`. Routes to picker, table, or single-session render. */
793
845
  async function sessionsAction(query, options) {
794
846
  // Explicit --query is interchangeable with the positional; it's how you search
@@ -801,6 +853,13 @@ async function sessionsAction(query, options) {
801
853
  if (options.device && options.device.length > 0) {
802
854
  options.host = [...(options.host ?? []), ...options.device];
803
855
  }
856
+ // --print-cmd: echo the canonical `ag sessions …` for the given flags and exit.
857
+ // The non-interactive twin of the browser's `y` hotkey — lets an agent compose
858
+ // (or a human copy) the exact command a view maps to.
859
+ if (options.printCmd) {
860
+ process.stdout.write(canonicalSessionsCommand(query, options) + '\n');
861
+ return;
862
+ }
804
863
  // --roots: emit the local session-scan directories, per agent, as JSON. A pure
805
864
  // machine-readable query (no listing/render) — external watchers (the Factory
806
865
  // extension's fs.watch) read it to track the same dirs the CLI scans, instead
@@ -829,7 +888,39 @@ async function sessionsAction(query, options) {
829
888
  }
830
889
  return;
831
890
  }
891
+ // --preview <id/query>: resolve one session and print its compact preview, then
892
+ // exit — checked before --active so `--active --preview <id>` peeks the id
893
+ // rather than being swallowed by the active listing.
894
+ if (options.preview) {
895
+ if (!query) {
896
+ console.error(chalk.red('--preview requires a session id or query.'));
897
+ process.exit(1);
898
+ }
899
+ await renderSessionPreview(query, { agent: options.agent, project: options.project });
900
+ return;
901
+ }
832
902
  if (options.active) {
903
+ // On a TTY (and not a scripting path), open the interactive browser seeded to
904
+ // running-only. --json / --waiting / --no-interactive / a peer fan-out keep the
905
+ // static dump untouched, so scripts and agents are unaffected. An explicit
906
+ // --since seeds the window; --until / --project (no browser field) or a
907
+ // multi-host scope fall through to the static dump that already honors them.
908
+ if (useInteractiveBrowser(options) &&
909
+ !options.waiting &&
910
+ !options.until &&
911
+ !options.project &&
912
+ !options.sort &&
913
+ (options.host?.length ?? 0) <= 1 &&
914
+ process.env.AGENTS_SESSIONS_LOCAL !== '1') {
915
+ const { runSessionBrowser, activeBrowserSeed } = await import('./sessions-browser.js');
916
+ await runSessionBrowser(activeBrowserSeed({
917
+ teams: options.teams,
918
+ agent: options.agent,
919
+ host: options.host,
920
+ since: options.since,
921
+ }), { local: options.local === true, hosts: options.host });
922
+ return;
923
+ }
833
924
  // AGENTS_SESSIONS_LOCAL is set by a parent fan-out invocation (see
834
925
  // remote-active.ts) so a peer answers for itself without recursing.
835
926
  const forceLocal = options.local === true || process.env.AGENTS_SESSIONS_LOCAL === '1';
@@ -843,6 +934,30 @@ async function sessionsAction(query, options) {
843
934
  await runCloudSessions(query, options);
844
935
  return;
845
936
  }
937
+ // Bare interactive listing → the interactive fleet browser (humans). A query,
938
+ // a render/filter flag, --flat/--tree, --json, --until, --project (a named-project
939
+ // filter the browser can't represent), or --no-interactive keep the existing
940
+ // printed/render paths (agents and scripts unaffected). An explicit --since seeds
941
+ // the browser's window so the flag is honored, not swallowed.
942
+ if (useInteractiveBrowser(options) &&
943
+ !query &&
944
+ !options.flat &&
945
+ !options.tree &&
946
+ !options.markdown &&
947
+ !options.until &&
948
+ !options.project &&
949
+ !options.sort &&
950
+ !options.artifacts &&
951
+ options.artifact === undefined) {
952
+ const { runSessionBrowser, bareBrowserSeed } = await import('./sessions-browser.js');
953
+ await runSessionBrowser(bareBrowserSeed({
954
+ teams: options.teams,
955
+ agent: options.agent,
956
+ all: options.all,
957
+ since: options.since,
958
+ }), { local: options.local === true, hosts: options.host });
959
+ return;
960
+ }
846
961
  let filterOpts;
847
962
  try {
848
963
  filterOpts = buildFilterOptions(options);
@@ -1323,9 +1438,9 @@ async function renderSession(session, mode, filters, options = {}) {
1323
1438
  return;
1324
1439
  }
1325
1440
  const spinner = ora(`Parsing ${session.agent} session...`).start();
1326
- let events = parseSession(session.filePath, session.agent);
1441
+ const parsedEvents = parseSession(session.filePath, session.agent);
1327
1442
  spinner.stop();
1328
- events = filterEvents(events, filters);
1443
+ let events = filterEvents(parsedEvents, filters);
1329
1444
  const agentColor = colorAgent(session.agent);
1330
1445
  console.log('');
1331
1446
  if (mode === 'summary') {
@@ -1367,7 +1482,11 @@ async function renderSession(session, mode, filters, options = {}) {
1367
1482
  // engine (plan text, PR, worktree, ticket). Pre-1.20.51 emitted a bare event
1368
1483
  // array; consumers that JSON.parse this now read `output.events` for the
1369
1484
  // array. See issue #743 (plan surfaced) and CHANGELOG for the shape change.
1370
- process.stdout.write(renderJson(events, session));
1485
+ // `todos` (RUSH-1503) is computed from the UNFILTERED transcript so the
1486
+ // checklist reflects true session state regardless of any `--include` filter;
1487
+ // it lets the Factory panel read the CLI's checklist instead of re-parsing.
1488
+ const todos = inferSessionState(parsedEvents, { cwd: session.cwd }).todos;
1489
+ process.stdout.write(renderJson(events, todos ? { ...session, todos } : session));
1371
1490
  }
1372
1491
  function renderTopicCell(label, topic, query, visibleWidth, paddedWidth) {
1373
1492
  const lbl = (label ?? '').trim();
@@ -1554,7 +1673,7 @@ function warnNoPeerTarget(machine, session) {
1554
1673
  console.log(chalk.yellow(`Session ${session.shortId} lives on ${machine}, which isn't a reachable device right now.`));
1555
1674
  console.log(chalk.gray(`Register/wake it (ag devices), or run there: agents ssh ${machine}`));
1556
1675
  }
1557
- async function handlePickedSession(picked) {
1676
+ export async function handlePickedSession(picked) {
1558
1677
  // A session on another machine is read/resumed ON that machine over SSH — its
1559
1678
  // transcript and agent binary live there. Both actions execute on the peer
1560
1679
  // (not a local `--host` hop, which would discover locally and dead-end for a
@@ -1613,11 +1732,35 @@ export async function resumeSessionInPlace(session) {
1613
1732
  }
1614
1733
  await spawnResumeCommand(resume, cwd);
1615
1734
  }
1735
+ /**
1736
+ * Map a resume argv to the spawn(command, args, {shell}) triple.
1737
+ *
1738
+ * On Windows the agent launcher is a `.cmd`/PATHEXT shim and needs shell:true.
1739
+ * With shell:true, Node concatenates args into the cmd.exe line unescaped
1740
+ * (DEP0190 + injection). A session.id derived from a filename can carry
1741
+ * metacharacters (`&|<>`); compose a fully-quoted line and pass an EMPTY args
1742
+ * array so cmd.exe cannot reparse them (RUSH-1753). See composeWin32CommandLine.
1743
+ *
1744
+ * `platform` is injectable so the win32 shell path is unit-testable on any host.
1745
+ */
1746
+ export function resumeSpawnInvocation(cmd, platform = process.platform) {
1747
+ const shell = needsWindowsShell(cmd[0], platform);
1748
+ if (shell) {
1749
+ return {
1750
+ command: composeWin32CommandLine(cmd[0], cmd.slice(1)),
1751
+ args: [],
1752
+ shell: true,
1753
+ };
1754
+ }
1755
+ return { command: cmd[0], args: cmd.slice(1), shell: false };
1756
+ }
1616
1757
  /**
1617
1758
  * Spawn a resume command as a foreground takeover (inherited stdio), resolving
1618
1759
  * when it exits. On Windows the agent launcher is a `.cmd`/PATHEXT shim that
1619
1760
  * `spawn` can't exec directly — a bare-name `shell:false` spawn throws
1620
1761
  * `EFTYPE`/`ENOENT` there — so we go through the shell via `needsWindowsShell`.
1762
+ * When shell:true, argv is composed via resumeSpawnInvocation (quoted line +
1763
+ * empty args) so an untrusted session.id cannot inject through cmd.exe.
1621
1764
  * The spawn is guarded because such a failure can be thrown synchronously;
1622
1765
  * without the guard it would surface under an unrelated "Failed to discover
1623
1766
  * sessions" catch upstream instead of a truthful launch error.
@@ -1626,10 +1769,11 @@ function spawnResumeCommand(cmd, cwd) {
1626
1769
  return new Promise((resolve) => {
1627
1770
  let child;
1628
1771
  try {
1629
- child = spawn(cmd[0], cmd.slice(1), {
1772
+ const { command, args, shell } = resumeSpawnInvocation(cmd);
1773
+ child = spawn(command, args, {
1630
1774
  cwd,
1631
1775
  stdio: 'inherit',
1632
- shell: needsWindowsShell(cmd[0]),
1776
+ shell,
1633
1777
  });
1634
1778
  }
1635
1779
  catch (err) {
@@ -2045,7 +2189,7 @@ export function registerSessionsCommands(program) {
2045
2189
  .option('--opencode', 'Shorthand for --agent opencode')
2046
2190
  .option('--all', 'Include sessions from every directory (not just current project)')
2047
2191
  .option('--teams', 'Include team-spawned sessions (hidden by default)')
2048
- .option('--project <name>', 'Filter by project name (searches across all directories)')
2192
+ .option('-p, --project <name>', 'Filter by project name (searches across all directories)')
2049
2193
  .option('--since <time>', 'Only sessions newer than this (e.g., 2h, 7d, 4w, or ISO date)')
2050
2194
  .option('--until <time>', 'Only sessions older than this (ISO timestamp)')
2051
2195
  .option('-n, --limit <n>', 'Maximum number of sessions to return', '50')
@@ -2069,7 +2213,10 @@ export function registerSessionsCommands(program) {
2069
2213
  .option('--cloud', 'Source sessions from Rush Cloud (captured runs) instead of local disk')
2070
2214
  .option('-H, --host <target...>', 'Run this query on remote machine(s) over SSH (host alias or user@host; repeatable)')
2071
2215
  .option('--device <target...>', 'Alias for --host (device alias from `agents devices`; repeatable)')
2072
- .option('--browser', 'List browser-profile captures (screenshots, PDFs, recordings, downloads) instead of agent transcripts — alias of `agents browser sessions`');
2216
+ .option('--browser', 'List browser-profile captures (screenshots, PDFs, recordings, downloads) instead of agent transcripts — alias of `agents browser sessions`')
2217
+ .option('--no-interactive', 'Print the listing instead of opening the interactive browser (default on a TTY for the bare listing and --active)')
2218
+ .option('--print-cmd', 'Print the canonical `ag sessions …` command for the given flags and exit (the twin of the browser’s `y` hotkey)')
2219
+ .option('--preview', 'With a session id/query: print a compact preview and exit (no pager)');
2073
2220
  setHelpSections(sessionsCmd, {
2074
2221
  examples: `
2075
2222
  # Search prior sessions in this project by topic, file path, or command
@@ -0,0 +1,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerShareCommands(program: Command): void;
@@ -0,0 +1,150 @@
1
+ // `agents share` — publish an HTML file to your own Cloudflare R2 behind a tiny
2
+ // Worker, and get a shareable link (~$0). See apps/cli/docs/share.md.
3
+ import { existsSync } from 'node:fs';
4
+ import chalk from 'chalk';
5
+ import { DEFAULT_BUCKET_NAME, DEFAULT_CF_BUNDLE, DEFAULT_WORKER_NAME, generateWriteToken, readCloudflareCreds, readShareConfig, storeWriteToken, writeShareConfig, } from '../lib/share/config.js';
6
+ import { addCustomDomain, createBucket, deployWorker, enableWorkersDev, findZoneId, } from '../lib/share/provision.js';
7
+ import { publishFile } from '../lib/share/publish.js';
8
+ import { renderWorkerScript } from '../lib/share/worker-template.js';
9
+ export function registerShareCommands(program) {
10
+ const shareCmd = program
11
+ .command('share')
12
+ .description('Publish an HTML file to your own Cloudflare R2 and get a shareable link (~$0).')
13
+ .argument('[file]', 'file to publish (HTML or any static asset)')
14
+ .option('--slug <slug>', 'custom URL slug (default: <project>-<feature>-<hash>)')
15
+ .option('--expire <spec>', 'auto-expire, e.g. 30d, 12h, or 2026-08-01')
16
+ .option('--no-cover', 'skip the OG preview image (HTML pages get one by default)')
17
+ .action(async (file, opts) => {
18
+ if (!file) {
19
+ shareCmd.help();
20
+ return;
21
+ }
22
+ if (!existsSync(file)) {
23
+ console.error(chalk.red(`No such file: ${file}`));
24
+ process.exitCode = 1;
25
+ return;
26
+ }
27
+ try {
28
+ const { url, expiresAt, coverUrl } = await publishFile(file, {
29
+ slug: opts.slug,
30
+ expire: opts.expire,
31
+ cover: opts.cover,
32
+ });
33
+ console.log(chalk.green(url));
34
+ if (coverUrl)
35
+ console.log(chalk.dim(` cover ${coverUrl}`));
36
+ if (expiresAt)
37
+ console.log(chalk.dim(` expires ${new Date(expiresAt).toLocaleString()}`));
38
+ }
39
+ catch (e) {
40
+ console.error(chalk.red(e.message));
41
+ process.exitCode = 1;
42
+ }
43
+ });
44
+ shareCmd
45
+ .command('setup')
46
+ .description('One-time: provision an R2 bucket + Worker on your Cloudflare and save the config.')
47
+ .option('--bundle <name>', 'secrets bundle holding the Cloudflare API token', DEFAULT_CF_BUNDLE)
48
+ .option('--worker <name>', 'Worker name', DEFAULT_WORKER_NAME)
49
+ .option('--bucket <name>', 'R2 bucket name', DEFAULT_BUCKET_NAME)
50
+ .option('--account <id>', 'Cloudflare account id (else read from the bundle / prompt)')
51
+ .option('--token <t>', 'Cloudflare API token (else read from the --bundle)')
52
+ .option('--domain <host>', 'also map a custom domain (e.g. share.agents-cli.sh) if the token owns the zone')
53
+ .action(async (opts) => {
54
+ try {
55
+ await runSetup(opts);
56
+ }
57
+ catch (e) {
58
+ console.error(chalk.red(e.message));
59
+ process.exitCode = 1;
60
+ }
61
+ });
62
+ shareCmd
63
+ .command('join')
64
+ .description('Use an existing share endpoint (no provisioning) — pass the base URL and paste the write token.')
65
+ .argument('<baseUrl>', 'base URL of the endpoint, e.g. https://share.agents-cli.sh')
66
+ .action(async (baseUrl) => {
67
+ try {
68
+ await runJoin(baseUrl);
69
+ }
70
+ catch (e) {
71
+ console.error(chalk.red(e.message));
72
+ process.exitCode = 1;
73
+ }
74
+ });
75
+ shareCmd
76
+ .command('status')
77
+ .description('Show the configured share endpoint.')
78
+ .action(() => {
79
+ const cfg = readShareConfig();
80
+ if (!cfg) {
81
+ console.log(chalk.dim("Not configured. Run 'agents share setup' or 'agents share join'."));
82
+ return;
83
+ }
84
+ console.log(`${chalk.bold('endpoint')} ${chalk.green(cfg.baseUrl)}`);
85
+ console.log(chalk.dim(`worker ${cfg.workerName} · bucket ${cfg.bucketName} · account ${cfg.accountId}`));
86
+ });
87
+ }
88
+ async function runSetup(opts) {
89
+ const { default: ora } = await import('ora');
90
+ const { input } = await import('@inquirer/prompts');
91
+ const { apiToken, accountId: acctFromBundle } = readCloudflareCreds(opts.bundle, {
92
+ apiToken: opts.token,
93
+ accountId: opts.account,
94
+ });
95
+ const accountId = opts.account || acctFromBundle || (await input({ message: 'Cloudflare account id' }));
96
+ if (!accountId)
97
+ throw new Error('A Cloudflare account id is required.');
98
+ const workerName = opts.worker;
99
+ const bucketName = opts.bucket;
100
+ const token = generateWriteToken();
101
+ const spin = ora('Provisioning on Cloudflare…').start();
102
+ try {
103
+ await createBucket(apiToken, accountId, bucketName);
104
+ spin.text = `R2 bucket '${bucketName}' ready`;
105
+ await deployWorker(apiToken, accountId, workerName, renderWorkerScript(), bucketName, token);
106
+ spin.text = `Worker '${workerName}' deployed`;
107
+ const subdomain = await enableWorkersDev(apiToken, accountId, workerName);
108
+ let baseUrl = `https://${workerName}.${subdomain}.workers.dev`;
109
+ let domain;
110
+ if (opts.domain) {
111
+ spin.text = `Mapping ${opts.domain}…`;
112
+ const zoneId = await findZoneId(apiToken, opts.domain);
113
+ if (zoneId) {
114
+ await addCustomDomain(apiToken, accountId, workerName, zoneId, opts.domain);
115
+ baseUrl = `https://${opts.domain}`;
116
+ domain = opts.domain;
117
+ }
118
+ else {
119
+ spin.warn(`Zone for ${opts.domain} not visible to this token — staying on workers.dev`);
120
+ }
121
+ }
122
+ spin.succeed('Provisioned');
123
+ const cfg = { baseUrl, accountId, workerName, bucketName, domain };
124
+ writeShareConfig(cfg);
125
+ storeWriteToken(token);
126
+ console.log(chalk.green(`\nShare endpoint ready → ${chalk.bold(baseUrl)}`));
127
+ console.log(chalk.dim('Publish with: ') + chalk.cyan('agents share <file>'));
128
+ console.log(chalk.dim(`Fleet: push the token with 'agents secrets export share --host <box>' and pull config with 'agents repo pull'.`));
129
+ }
130
+ catch (e) {
131
+ spin.fail('Provisioning failed');
132
+ throw e;
133
+ }
134
+ }
135
+ async function runJoin(baseUrl) {
136
+ const { password, input } = await import('@inquirer/prompts');
137
+ const clean = baseUrl.replace(/\/+$/, '');
138
+ const workerName = await input({ message: 'Worker name', default: DEFAULT_WORKER_NAME });
139
+ const bucketName = await input({ message: 'Bucket name', default: DEFAULT_BUCKET_NAME });
140
+ const accountId = await input({ message: 'Cloudflare account id' });
141
+ const token = await password({ message: 'Write token (from the endpoint owner)', mask: true });
142
+ if (!token)
143
+ throw new Error('A write token is required to join.');
144
+ const domain = clean.startsWith('https://') && !clean.includes('.workers.dev')
145
+ ? clean.replace(/^https:\/\//, '')
146
+ : undefined;
147
+ writeShareConfig({ baseUrl: clean, accountId, workerName, bucketName, domain });
148
+ storeWriteToken(token);
149
+ console.log(chalk.green(`Joined ${chalk.bold(clean)} — publish with `) + chalk.cyan('agents share <file>'));
150
+ }
@@ -15,9 +15,11 @@ import * as os from 'os';
15
15
  import * as path from 'path';
16
16
  import chalk from 'chalk';
17
17
  import ora from 'ora';
18
- import { readAndResolveBundleEnv } from '../lib/secrets/bundles.js';
18
+ import { readAndResolveBundleEnv, isHeadlessSecretsContext } from '../lib/secrets/bundles.js';
19
19
  import { machineId } from '../lib/session/sync/config.js';
20
20
  import { addIgnored, getDevice, loadDevices, loadIgnored, removeDevice, removeIgnored, upsertDevice, } from '../lib/devices/registry.js';
21
+ import { addControlToken } from '../lib/serve/token.js';
22
+ import { DEFAULT_SERVE_PORT } from '../lib/serve/server.js';
21
23
  import { nodeToDeviceInput, parseTailscaleStatus, tailscaleStatusJson, } from '../lib/devices/tailscale.js';
22
24
  import { localLoginUser, planDeviceReconciliation, runDeviceSync, withDefaultUser } from '../lib/devices/sync.js';
23
25
  import { resolveDeviceTarget, splitUserHost } from '../lib/devices/resolve-target.js';
@@ -25,6 +27,7 @@ import { clearPendingSentinel } from '../lib/devices/pending.js';
25
27
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
26
28
  import { hostNameFor, renderSshConfig } from '../lib/devices/ssh-config.js';
27
29
  import { ASKPASS_BUNDLE_ENV, ASKPASS_KEY_ENV, buildSshInvocation, writeAskpassShim, } from '../lib/devices/connect.js';
30
+ import { ensureManagedKnownHostsDir, isHostPinned } from '../lib/devices/known-hosts.js';
28
31
  import { planFleetTargets, runFleet, skipLabel, upgradeCommand, } from '../lib/devices/fleet.js';
29
32
  import { fleetCapacity, fmtBytes, headroom, probeFleetStats, } from '../lib/devices/health.js';
30
33
  /** One-line summary of a device for `list`. `isSelf` marks the machine this
@@ -400,6 +403,47 @@ Typical workflow:
400
403
  process.exit(1);
401
404
  }
402
405
  });
406
+ devicesCmd
407
+ .command('pair-ios [name]')
408
+ .description('Pair an iPhone/iPad cockpit (RUSH-1733): mint a control token for `agents serve --control` and mark the device control-only. The token is shown ONCE — enter it in the app. Run this on the anchor.')
409
+ .option('--port <n>', 'Anchor control port to advertise to the app', String(DEFAULT_SERVE_PORT))
410
+ .action(async (name, opts) => {
411
+ const label = (name || 'iphone').trim();
412
+ // Mint the bearer token — hash-only on disk, raw shown once (see serve/token.ts).
413
+ const { id, token } = addControlToken(label);
414
+ // If a device with this name is already registered (e.g. discovered over
415
+ // Tailscale as an `unknown`-platform node), mark it control-only so the
416
+ // fleet stops trying to dial it for sessions/placement.
417
+ let marked = false;
418
+ let unknownName = false;
419
+ if (name) {
420
+ const existing = await getDevice(name);
421
+ if (existing) {
422
+ await upsertDevice(name, { role: 'control' });
423
+ marked = true;
424
+ }
425
+ else {
426
+ unknownName = true;
427
+ }
428
+ }
429
+ const port = parseInt(opts.port ?? '', 10) || DEFAULT_SERVE_PORT;
430
+ console.log(chalk.green(`Paired cockpit '${label}'`) + chalk.gray(` (token id ${id})`));
431
+ if (marked)
432
+ console.log(chalk.gray(`Marked device '${name}' role=control — the fleet won't dial it.`));
433
+ if (unknownName) {
434
+ console.log(chalk.yellow(`Note: no registered device named '${name}' — token minted, but no device was marked role=control.`) +
435
+ chalk.gray(` Run \`agents devices sync\` first if this phone should appear in the fleet.`));
436
+ }
437
+ console.log();
438
+ console.log(chalk.bold('Control token (shown once — enter it in the app):'));
439
+ console.log(' ' + chalk.cyan(token));
440
+ console.log();
441
+ console.log('On the anchor, expose the control server on your tailnet:');
442
+ console.log(chalk.gray(` agents serve --control --bind <anchor-tailnet-ip> --port ${port}`));
443
+ console.log('Then point the app at:');
444
+ console.log(chalk.gray(` http://<anchor-tailnet-ip>:${port} (Bearer: the token above)`));
445
+ console.log(chalk.yellow('Keep the control server on the tailnet — never public Funnel.'));
446
+ });
403
447
  devicesCmd
404
448
  .command('rm <name>')
405
449
  .alias('remove')
@@ -515,7 +559,14 @@ secrets bundle via an askpass shim — the password never touches argv.
515
559
  }
516
560
  try {
517
561
  const shim = writeAskpassShim();
518
- const { args, env } = buildSshInvocation(device, cmd, shim);
562
+ // Pin the host key on first connect and verify strictly thereafter: the
563
+ // managed known_hosts store must exist before ssh writes the learned key
564
+ // into it, and a host already recorded there is checked with
565
+ // StrictHostKeyChecking=yes (RUSH-1767).
566
+ ensureManagedKnownHostsDir();
567
+ const addr = hostNameFor(device);
568
+ const pinned = addr ? isHostPinned(addr) : false;
569
+ const { args, env } = buildSshInvocation(device, cmd, shim, { pinned });
519
570
  const res = spawnSync('ssh', args, {
520
571
  stdio: 'inherit',
521
572
  env: { ...process.env, ...env },
@@ -544,7 +595,7 @@ async function runAskpass() {
544
595
  process.exit(1);
545
596
  }
546
597
  try {
547
- const { env } = readAndResolveBundleEnv(bundle, { caller: 'agents ssh' });
598
+ const { env } = readAndResolveBundleEnv(bundle, { caller: 'agents ssh', agentOnly: isHeadlessSecretsContext() });
548
599
  const value = env[key];
549
600
  if (value === undefined) {
550
601
  console.error(`askpass: key '${key}' not found in bundle '${bundle}'`);
@@ -4,7 +4,7 @@ import ora from 'ora';
4
4
  import * as fs from 'fs';
5
5
  import * as path from 'path';
6
6
  import { select, confirm, checkbox } from '@inquirer/prompts';
7
- import { AGENTS, ALL_AGENT_IDS, getAccountEmail, getAccountInfo, agentLabel, warnAgentDeprecated, isSelfUpdatingAgent, } from '../lib/agents.js';
7
+ import { AGENTS, ALL_AGENT_IDS, accountOrgBadge, getAccountEmail, getAccountInfo, agentLabel, warnAgentDeprecated, isSelfUpdatingAgent, } from '../lib/agents.js';
8
8
  import { formatUsageSummary, getUsageInfoForIdentity, getUsageInfoByIdentity, getUsageLookupKey, } from '../lib/usage.js';
9
9
  import { viewAction } from './view.js';
10
10
  import { readManifest, writeManifest, createDefaultManifest } from '../lib/manifest.js';
@@ -32,8 +32,12 @@ function fixSessionFilePaths(agent, version, oldVersionDir) {
32
32
  }
33
33
  function formatAccountHint(info, usage) {
34
34
  const parts = [];
35
- if (info.email)
36
- parts.push(info.email);
35
+ if (info.email) {
36
+ // Same-email accounts can live in different orgs (personal Max vs a Team
37
+ // seat) — the badge is what makes the picker disambiguate them.
38
+ const badge = accountOrgBadge(info);
39
+ parts.push(badge ? `${info.email} (${badge})` : info.email);
40
+ }
37
41
  const usageSummary = formatUsageSummary(info.plan, usage);
38
42
  if (usageSummary)
39
43
  parts.push(usageSummary);
@@ -7,8 +7,23 @@
7
7
  * rules, hooks, and promptcuts synced to that version.
8
8
  */
9
9
  import type { Command } from 'commander';
10
+ import type { AccountInfo } from '../lib/agents.js';
10
11
  import type { AgentId } from '../lib/types.js';
11
12
  import { type ProfileSummary } from '../lib/profiles.js';
13
+ /**
14
+ * Text for the account (email) column. Prefers the real email; otherwise, for a
15
+ * signed-in agent whose credential carries no email but does carry an opaque
16
+ * account id (Kimi's user_id), show `id:<user_id>` so distinct accounts read
17
+ * distinctly instead of a generic "signed in". Falls back to "signed in" when we
18
+ * have neither (Antigravity), and empty when signed out.
19
+ *
20
+ * When the account carries a Claude organizationType, the org badge is appended
21
+ * — "email (Turing Labs · Team)" — so two installs signed into the same email
22
+ * under different orgs (personal Max vs a Team seat) read distinctly. The
23
+ * suffix is plain text inside the label so the existing column-width pass
24
+ * measures and pads it for free.
25
+ */
26
+ export declare function accountColumnLabel(info?: Pick<AccountInfo, 'email' | 'accountId' | 'signedIn' | 'organizationType' | 'organizationName'> | null): string;
12
27
  type SyncState = 'synced' | 'new' | 'modified' | 'deleted';
13
28
  /** Per-section filter flags. When any are true, only those sections render. */
14
29
  export interface ViewSectionFilter {
@@ -32,6 +47,8 @@ export interface ViewJsonVersion {
32
47
  signedIn: boolean;
33
48
  email: string | null;
34
49
  accountId?: string | null;
50
+ organizationType?: string | null;
51
+ organizationName?: string | null;
35
52
  plan: string | null;
36
53
  usageStatus: 'available' | 'rate_limited' | 'out_of_credits' | null;
37
54
  overageCredits?: {
@@ -86,6 +103,15 @@ export declare function parseResourceSections(options: {
86
103
  resources?: string | boolean;
87
104
  detailed?: boolean;
88
105
  } & ViewSectionFilter, jsonMode: boolean): Set<ResourceSection>;
106
+ /**
107
+ * Identity key for duplicate-install detection. Prefers accountKey — which
108
+ * encodes account AND org — over the bare email: two installs can share an
109
+ * email yet belong to different orgs (a personal Max plan and a Team seat),
110
+ * and grouping those by email alone would propose pruning a live account.
111
+ * Falls back to the lowercased email for agents whose credentials expose no
112
+ * identity key. Null when there is no usable identity.
113
+ */
114
+ export declare function pruneGroupKey(info: Pick<AccountInfo, 'accountKey' | 'email'>): string | null;
89
115
  /**
90
116
  * Prune older installed versions that share an email with a newer installed
91
117
  * version. Keeps the highest semver per email, skips the global default (with