@phnx-labs/agents-cli 1.20.65 → 1.20.67

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 (59) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +119 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/exec.d.ts +48 -0
  5. package/dist/commands/exec.js +71 -0
  6. package/dist/commands/monitors.js +8 -0
  7. package/dist/commands/plugins.js +28 -7
  8. package/dist/commands/sessions-browser.d.ts +82 -0
  9. package/dist/commands/sessions-browser.js +320 -0
  10. package/dist/commands/sessions.d.ts +1 -0
  11. package/dist/commands/sessions.js +121 -4
  12. package/dist/commands/share.d.ts +2 -0
  13. package/dist/commands/share.js +150 -0
  14. package/dist/commands/ssh.js +9 -1
  15. package/dist/commands/teams.js +1 -1
  16. package/dist/index.js +2 -1
  17. package/dist/lib/agents.d.ts +28 -0
  18. package/dist/lib/agents.js +76 -0
  19. package/dist/lib/devices/connect.d.ts +18 -1
  20. package/dist/lib/devices/connect.js +10 -2
  21. package/dist/lib/devices/known-hosts.d.ts +62 -0
  22. package/dist/lib/devices/known-hosts.js +137 -0
  23. package/dist/lib/exec.d.ts +15 -0
  24. package/dist/lib/exec.js +31 -6
  25. package/dist/lib/hosts/dispatch.js +20 -2
  26. package/dist/lib/hosts/remote-cmd.js +8 -4
  27. package/dist/lib/monitors/engine.js +4 -0
  28. package/dist/lib/monitors/sources/device.js +13 -3
  29. package/dist/lib/picker.d.ts +53 -0
  30. package/dist/lib/picker.js +214 -1
  31. package/dist/lib/plugins.d.ts +31 -1
  32. package/dist/lib/plugins.js +74 -13
  33. package/dist/lib/runner.js +6 -7
  34. package/dist/lib/secrets/agent.d.ts +35 -0
  35. package/dist/lib/secrets/agent.js +114 -55
  36. package/dist/lib/secrets/filestore.d.ts +9 -0
  37. package/dist/lib/secrets/filestore.js +21 -8
  38. package/dist/lib/secrets/index.d.ts +7 -0
  39. package/dist/lib/secrets/index.js +26 -6
  40. package/dist/lib/share/capture.d.ts +29 -0
  41. package/dist/lib/share/capture.js +140 -0
  42. package/dist/lib/share/config.d.ts +35 -0
  43. package/dist/lib/share/config.js +100 -0
  44. package/dist/lib/share/og.d.ts +25 -0
  45. package/dist/lib/share/og.js +84 -0
  46. package/dist/lib/share/provision.d.ts +10 -0
  47. package/dist/lib/share/provision.js +91 -0
  48. package/dist/lib/share/publish.d.ts +57 -0
  49. package/dist/lib/share/publish.js +145 -0
  50. package/dist/lib/share/worker-template.d.ts +2 -0
  51. package/dist/lib/share/worker-template.js +82 -0
  52. package/dist/lib/shims.d.ts +13 -0
  53. package/dist/lib/shims.js +42 -2
  54. package/dist/lib/ssh-exec.d.ts +24 -0
  55. package/dist/lib/ssh-exec.js +15 -3
  56. package/dist/lib/startup/command-registry.d.ts +1 -0
  57. package/dist/lib/startup/command-registry.js +2 -0
  58. package/dist/lib/types.d.ts +20 -0
  59. package/package.json +1 -1
@@ -0,0 +1,320 @@
1
+ /**
2
+ * Interactive fleet-wide session browser — the human front-end of `agents sessions`.
3
+ *
4
+ * One canonical filter state (device / agent / project / window / running / teams),
5
+ * driven by single-key hotkeys, re-pulling live over the same fleet fan-out the flag
6
+ * surface uses. The identical state is expressible as flags (the agent front-end);
7
+ * `y` / `--print-cmd` round-trips a hand-built view into a copy-pasteable command.
8
+ *
9
+ * Built on {@link dynamicPicker}; every data source (discover, fleet, live index,
10
+ * preview, resume dispatch) is reused from the existing sessions plumbing.
11
+ */
12
+ import { spawnSync } from 'child_process';
13
+ import { dynamicPicker } from '../lib/picker.js';
14
+ import { getActiveSessions } from '../lib/session/active.js';
15
+ import { discoverSessions } from '../lib/session/discover.js';
16
+ import { gatherRemoteList } from '../lib/session/remote-list.js';
17
+ import { machineId, normalizeHost } from '../lib/session/sync/config.js';
18
+ import { buildPreview } from './sessions-picker.js';
19
+ import { formatPickerLabel, pickerColumnsFor, ticketLabel, mergeLocalFirst, indexActiveBySessionId, handlePickedSession, shouldIncludeLocal, remoteHostsToDial, } from './sessions.js';
20
+ /** Ordered window cycle for the `W` key. `undefined` = all time. */
21
+ const WINDOW_CYCLE = [undefined, '1d', '7d', '30d'];
22
+ /** Return the next value in `[undefined, ...options]`, wrapping. */
23
+ export function cycle(current, options) {
24
+ const ring = [undefined, ...options];
25
+ const idx = ring.findIndex((v) => v === current);
26
+ return ring[(idx + 1) % ring.length];
27
+ }
28
+ export function cycleWindow(current) {
29
+ const idx = WINDOW_CYCLE.findIndex((v) => v === current);
30
+ return WINDOW_CYCLE[(idx + 1) % WINDOW_CYCLE.length];
31
+ }
32
+ function distinct(values) {
33
+ return [...new Set(values.filter((v) => !!v))].sort();
34
+ }
35
+ /**
36
+ * Cheap client-side match for the `S` search — a plain substring test over a
37
+ * row's visible fields. Deliberately NOT the FTS `filterSessionsByQuery`: that
38
+ * runs a content-index scan per call, which is fine once over a pool but a
39
+ * CPU sink when a picker calls it per-row on every keystroke.
40
+ */
41
+ export function sessionMatchesQuery(s, query) {
42
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
43
+ if (terms.length === 0)
44
+ return true;
45
+ const hay = [
46
+ s.shortId,
47
+ s.agent,
48
+ s.project,
49
+ s.cwd,
50
+ s.topic,
51
+ s.label,
52
+ ticketLabel(s),
53
+ s.machine,
54
+ ]
55
+ .filter(Boolean)
56
+ .join(' ')
57
+ .toLowerCase();
58
+ return terms.every((t) => hay.includes(t));
59
+ }
60
+ /**
61
+ * The canonical `ag sessions …` command for a filter state (+ optional search) —
62
+ * the agent-facing twin of the interactive view. Shared by the `y` hotkey and
63
+ * `--print-cmd`.
64
+ */
65
+ export function browserFilterToArgv(f, query = '') {
66
+ const a = ['sessions'];
67
+ if (f.running)
68
+ a.push('--active');
69
+ if (f.teams)
70
+ a.push('--teams');
71
+ if (f.agent)
72
+ a.push('-a', f.agent);
73
+ if (f.device)
74
+ a.push('--device', f.device);
75
+ if (f.projectScope === 'all')
76
+ a.push('--all');
77
+ if (f.window)
78
+ a.push('--since', f.window);
79
+ const q = query.trim();
80
+ if (q)
81
+ a.push(JSON.stringify(q));
82
+ return a;
83
+ }
84
+ /** Normalize a `--host`/`--device` token (`alias`, `user@host`, `host.domain`) to
85
+ * the canonical machine id the rows carry in `.machine`, so a flag seed matches
86
+ * (the `d` hotkey already cycles canonical ids). Mirrors sessions.ts `hostToken`. */
87
+ export function normalizeDeviceSeed(host) {
88
+ if (!host)
89
+ return undefined;
90
+ return normalizeHost(host.split('@').pop() || host);
91
+ }
92
+ /**
93
+ * The initial filter for the `--active` browser: fleet-wide (matches the static
94
+ * `renderActiveSessions`, which has no project scoping — the `p` hotkey narrows to
95
+ * this repo), running-only, with the device seed normalized and `--since` seeding
96
+ * the window. Pure, so the routing call site is unit-testable.
97
+ */
98
+ export function activeBrowserSeed(opts) {
99
+ return {
100
+ running: true,
101
+ teams: !!opts.teams,
102
+ agent: opts.agent,
103
+ projectScope: 'all',
104
+ device: normalizeDeviceSeed(opts.host?.[0]),
105
+ window: opts.since ?? '30d',
106
+ };
107
+ }
108
+ /**
109
+ * The initial filter for the bare interactive listing: current-repo subtree by
110
+ * default (matches the static overview's cwd scoping), `--all` widens to every
111
+ * directory, `--since` seeds the window.
112
+ */
113
+ export function bareBrowserSeed(opts) {
114
+ return {
115
+ teams: !!opts.teams,
116
+ agent: opts.agent,
117
+ projectScope: opts.all ? 'all' : 'repo',
118
+ window: opts.since ?? '30d',
119
+ };
120
+ }
121
+ /** Copy text to the OS clipboard (best-effort; silently no-ops if unavailable). */
122
+ function copyToClipboard(text) {
123
+ const candidates = process.platform === 'darwin'
124
+ ? [['pbcopy', []]]
125
+ : [
126
+ ['wl-copy', []],
127
+ ['xclip', ['-selection', 'clipboard']],
128
+ ['xsel', ['--clipboard', '--input']],
129
+ ];
130
+ for (const [cmd, args] of candidates) {
131
+ try {
132
+ const res = spawnSync(cmd, args, { input: text });
133
+ if (res.status === 0)
134
+ return true;
135
+ }
136
+ catch {
137
+ // try the next candidate
138
+ }
139
+ }
140
+ return false;
141
+ }
142
+ /** The transcript pool: the local index + (unless --local) a live fleet fan-out.
143
+ * The live index is fetched separately/lazily — it's the slow part and only the
144
+ * running filter needs it, so a bare browse stays instant.
145
+ *
146
+ * `hosts` is the explicit `--host`/`--device` scope (if any): it restricts which
147
+ * peers are dialed and whether local is included, honoring the flag's "scope,
148
+ * not add" contract instead of always sweeping the whole fleet. */
149
+ async function fetchRawPool(f, self, local, hosts) {
150
+ const since = f.window;
151
+ // Local pool: wide (every directory) — device/agent/project are applied in
152
+ // memory so a hotkey toggle is instant and doesn't re-hit the disk. Skipped
153
+ // when an explicit host scope excludes this machine.
154
+ let rows = shouldIncludeLocal(hosts, self)
155
+ ? await discoverSessions({
156
+ all: true,
157
+ cwd: process.cwd(),
158
+ since,
159
+ excludeTeamOrigin: !f.teams,
160
+ limit: 500,
161
+ sortBy: 'timestamp',
162
+ })
163
+ : [];
164
+ // Fleet: fold in peers' own indexes over SSH (no sync), same as the flag path.
165
+ // Skipped under --local. An explicit --host/--device scopes exactly which peers
166
+ // are dialed (undefined = sweep every online device). Best-effort — a fan-out
167
+ // failure leaves the local list intact.
168
+ if (!local) {
169
+ try {
170
+ const forwarded = ['sessions', '--all', '--json', '--limit', '500'];
171
+ if (since)
172
+ forwarded.push('--since', since);
173
+ if (f.teams)
174
+ forwarded.push('--teams');
175
+ const { sessions: remote } = await gatherRemoteList(forwarded, remoteHostsToDial(hosts, self));
176
+ if (remote.length > 0)
177
+ rows = mergeLocalFirst([...rows, ...remote], self);
178
+ }
179
+ catch {
180
+ // enrichment, never a hard dependency
181
+ }
182
+ }
183
+ return { key: `${since ?? 'all'}|${f.teams}`, rows };
184
+ }
185
+ /** Apply the cheap in-memory filters (agent / device / project / running). */
186
+ function applyFilters(rows, live, f, self) {
187
+ let out = rows;
188
+ if (f.agent)
189
+ out = out.filter((r) => r.agent === f.agent);
190
+ if (f.device)
191
+ out = out.filter((r) => (r.machine ?? self) === f.device);
192
+ if (f.projectScope === 'repo') {
193
+ const cwd = process.cwd();
194
+ out = out.filter((r) => !!r.cwd && (r.cwd === cwd || r.cwd.startsWith(cwd + '/')));
195
+ }
196
+ if (f.running)
197
+ out = out.filter((r) => live.has(r.id));
198
+ return out;
199
+ }
200
+ function headerFor(f) {
201
+ const bits = [
202
+ `device:${f.device ?? 'all'}`,
203
+ `agent:${f.agent ?? 'all'}`,
204
+ f.projectScope === 'repo' ? 'this repo' : 'all dirs',
205
+ `window:${f.window ?? 'all'}`,
206
+ ];
207
+ if (f.running)
208
+ bits.push('running');
209
+ if (f.teams)
210
+ bits.push('teams');
211
+ return bits.join(' · ');
212
+ }
213
+ function helpFor(_f, mode) {
214
+ if (mode === 'search') {
215
+ return 'type to filter · ↑↓ navigate · esc exit search · ⏎ resume';
216
+ }
217
+ return 's search · r running · c teams · a agent · d device · p project · w window · y copy-cmd · ⏎ resume · esc quit';
218
+ }
219
+ /**
220
+ * Launch the interactive session browser. `initial` seeds the filter (e.g.
221
+ * `{ running: true }` for `--active`). Resolves after the user resumes a session
222
+ * or cancels — the picked row is dispatched through the shared resume/focus path.
223
+ */
224
+ export async function runSessionBrowser(initial = {}, opts = {}) {
225
+ const self = machineId();
226
+ const local = opts.local ?? false;
227
+ const hosts = opts.hosts && opts.hosts.length > 0 ? opts.hosts : undefined;
228
+ // Updated after each load so the A/D cycles range over what's actually present.
229
+ let agentsInPool = [];
230
+ let devicesInPool = [];
231
+ let cols = {};
232
+ // Cache the transcript fetch, keyed by (window, teams); agent/device/project/
233
+ // running are applied in memory so their hotkeys don't re-fan-out the fleet.
234
+ let rawCache = null;
235
+ // The live index is slow (a full ps/tmux scan) and only the running filter
236
+ // needs it — fetch it once, lazily, the first time running is toggled on.
237
+ let liveCache = null;
238
+ // Generation guard: two quick keypresses can start overlapping loads whose
239
+ // SSH fan-outs settle out of order. dynamicPicker's own gen ref guards which
240
+ // rows become `items`, but the shared closure state below (cols / cycle pools /
241
+ // caches) is a side channel it can't see — so a stale load must never commit
242
+ // it. We compute into locals and only write the shared state as the latest load.
243
+ let loadGen = 0;
244
+ const initialFilter = {
245
+ running: initial.running ?? false,
246
+ teams: initial.teams ?? false,
247
+ agent: initial.agent,
248
+ device: initial.device,
249
+ projectScope: initial.projectScope ?? 'repo',
250
+ window: 'window' in initial ? initial.window : '30d',
251
+ };
252
+ const load = async (f) => {
253
+ const myGen = ++loadGen;
254
+ const key = `${f.window ?? 'all'}|${f.teams}`;
255
+ let pool = rawCache && rawCache.key === key ? rawCache : null;
256
+ if (!pool) {
257
+ const fetched = await fetchRawPool(f, self, local, hosts);
258
+ if (myGen !== loadGen)
259
+ return []; // superseded — don't touch shared state
260
+ pool = fetched;
261
+ }
262
+ // Only pay for the live scan when the running filter needs it.
263
+ let live = liveCache;
264
+ if (f.running && !live) {
265
+ try {
266
+ live = indexActiveBySessionId(await getActiveSessions());
267
+ }
268
+ catch {
269
+ live = new Map();
270
+ }
271
+ if (myGen !== loadGen)
272
+ return [];
273
+ }
274
+ // Latest load — commit shared state atomically (no await past this point, so
275
+ // no newer load can interleave between these writes).
276
+ rawCache = pool;
277
+ if (live)
278
+ liveCache = live;
279
+ agentsInPool = distinct(pool.rows.map((r) => r.agent));
280
+ devicesInPool = distinct(pool.rows.map((r) => r.machine ?? self));
281
+ const filtered = applyFilters(pool.rows, live ?? new Map(), f, self);
282
+ cols = pickerColumnsFor(filtered);
283
+ return filtered;
284
+ };
285
+ const picked = await dynamicPicker({
286
+ message: 'Sessions',
287
+ initialFilter,
288
+ load,
289
+ keyFor: (s) => s.id,
290
+ labelFor: (s, q) => formatPickerLabel(s, q, cols),
291
+ matches: sessionMatchesQuery,
292
+ buildPreview,
293
+ headerFor,
294
+ helpFor,
295
+ enterHint: 'resume',
296
+ emptyMessage: 'No sessions match this filter.',
297
+ loadingMessage: local ? 'Loading…' : 'Loading (reaching other machines)…',
298
+ keyBindings: {
299
+ r: (f) => ({ ...f, running: !f.running }),
300
+ c: (f) => ({ ...f, teams: !f.teams }),
301
+ a: (f) => ({ ...f, agent: cycle(f.agent, agentsInPool) }),
302
+ d: (f) => ({ ...f, device: cycle(f.device, devicesInPool) }),
303
+ p: (f) => ({ ...f, projectScope: f.projectScope === 'repo' ? 'all' : 'repo' }),
304
+ w: (f) => ({ ...f, window: cycleWindow(f.window) }),
305
+ },
306
+ onKey: (name, f, _active, query) => {
307
+ if (name === 'y') {
308
+ // Thread the live search query so the copied command reproduces the
309
+ // exact view — the human→agent bridge must include the search term.
310
+ const cmd = 'ag ' + browserFilterToArgv(f, query).join(' ');
311
+ const ok = copyToClipboard(cmd);
312
+ return ok ? `copied: ${cmd}` : cmd;
313
+ }
314
+ return undefined;
315
+ },
316
+ });
317
+ if (!picked)
318
+ return;
319
+ await handlePickedSession({ session: picked.item, action: 'resume' });
320
+ }
@@ -193,6 +193,7 @@ export declare function formatPickerLabel(s: SessionMeta, query: string, cols?:
193
193
  */
194
194
  export declare function formatPickerTip(sessions: SessionMeta[]): string;
195
195
  export declare function pickSessionInteractive(sessions: SessionMeta[], message?: string, initialSearch?: string, hiddenCount?: number, enterHint?: string): Promise<PickedSession | null>;
196
+ export declare function handlePickedSession(picked: PickedSession): Promise<void>;
196
197
  /**
197
198
  * Resume a session in the current terminal — a foreground takeover of this
198
199
  * process. Used by the single-select picker and by `sessions resume` when the
@@ -37,7 +37,7 @@ import { colorAgent, resolveAgentName } from '../lib/agents.js';
37
37
  import { fuzzyMatch, FUZZY_PRESETS } from '../lib/fuzzy.js';
38
38
  import { resolveVersionAliasLoose } from '../lib/versions.js';
39
39
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
40
- import { sessionPicker } from './sessions-picker.js';
40
+ import { sessionPicker, buildPreview } from './sessions-picker.js';
41
41
  import { setHelpSections } from '../lib/help.js';
42
42
  import { registerSessionsTailCommand } from './sessions-tail.js';
43
43
  import { registerSessionsSyncCommand } from './sessions-sync.js';
@@ -790,6 +790,57 @@ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
790
790
  function printCrossMachineTip() {
791
791
  console.log(chalk.gray("\nTip: include sessions from your other machines — register them with 'ag devices sync', then rerun. Use --local to skip."));
792
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
+ }
793
844
  /** Main action handler for `agents sessions`. Routes to picker, table, or single-session render. */
794
845
  async function sessionsAction(query, options) {
795
846
  // Explicit --query is interchangeable with the positional; it's how you search
@@ -802,6 +853,13 @@ async function sessionsAction(query, options) {
802
853
  if (options.device && options.device.length > 0) {
803
854
  options.host = [...(options.host ?? []), ...options.device];
804
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
+ }
805
863
  // --roots: emit the local session-scan directories, per agent, as JSON. A pure
806
864
  // machine-readable query (no listing/render) — external watchers (the Factory
807
865
  // extension's fs.watch) read it to track the same dirs the CLI scans, instead
@@ -830,7 +888,39 @@ async function sessionsAction(query, options) {
830
888
  }
831
889
  return;
832
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
+ }
833
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
+ }
834
924
  // AGENTS_SESSIONS_LOCAL is set by a parent fan-out invocation (see
835
925
  // remote-active.ts) so a peer answers for itself without recursing.
836
926
  const forceLocal = options.local === true || process.env.AGENTS_SESSIONS_LOCAL === '1';
@@ -844,6 +934,30 @@ async function sessionsAction(query, options) {
844
934
  await runCloudSessions(query, options);
845
935
  return;
846
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
+ }
847
961
  let filterOpts;
848
962
  try {
849
963
  filterOpts = buildFilterOptions(options);
@@ -1559,7 +1673,7 @@ function warnNoPeerTarget(machine, session) {
1559
1673
  console.log(chalk.yellow(`Session ${session.shortId} lives on ${machine}, which isn't a reachable device right now.`));
1560
1674
  console.log(chalk.gray(`Register/wake it (ag devices), or run there: agents ssh ${machine}`));
1561
1675
  }
1562
- async function handlePickedSession(picked) {
1676
+ export async function handlePickedSession(picked) {
1563
1677
  // A session on another machine is read/resumed ON that machine over SSH — its
1564
1678
  // transcript and agent binary live there. Both actions execute on the peer
1565
1679
  // (not a local `--host` hop, which would discover locally and dead-end for a
@@ -2075,7 +2189,7 @@ export function registerSessionsCommands(program) {
2075
2189
  .option('--opencode', 'Shorthand for --agent opencode')
2076
2190
  .option('--all', 'Include sessions from every directory (not just current project)')
2077
2191
  .option('--teams', 'Include team-spawned sessions (hidden by default)')
2078
- .option('--project <name>', 'Filter by project name (searches across all directories)')
2192
+ .option('-p, --project <name>', 'Filter by project name (searches across all directories)')
2079
2193
  .option('--since <time>', 'Only sessions newer than this (e.g., 2h, 7d, 4w, or ISO date)')
2080
2194
  .option('--until <time>', 'Only sessions older than this (ISO timestamp)')
2081
2195
  .option('-n, --limit <n>', 'Maximum number of sessions to return', '50')
@@ -2099,7 +2213,10 @@ export function registerSessionsCommands(program) {
2099
2213
  .option('--cloud', 'Source sessions from Rush Cloud (captured runs) instead of local disk')
2100
2214
  .option('-H, --host <target...>', 'Run this query on remote machine(s) over SSH (host alias or user@host; repeatable)')
2101
2215
  .option('--device <target...>', 'Alias for --host (device alias from `agents devices`; repeatable)')
2102
- .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)');
2103
2220
  setHelpSections(sessionsCmd, {
2104
2221
  examples: `
2105
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
+ }