@phnx-labs/agents-cli 1.20.89 → 1.20.90

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 (58) hide show
  1. package/CHANGELOG.md +240 -0
  2. package/README.md +6 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/exec.js +7 -1
  5. package/dist/commands/harness.d.ts +27 -0
  6. package/dist/commands/harness.js +120 -13
  7. package/dist/commands/profiles.d.ts +3 -0
  8. package/dist/commands/profiles.js +1 -1
  9. package/dist/commands/routines.d.ts +19 -0
  10. package/dist/commands/routines.js +28 -6
  11. package/dist/commands/secrets.d.ts +10 -1
  12. package/dist/commands/secrets.js +18 -6
  13. package/dist/commands/sessions-browser.d.ts +4 -0
  14. package/dist/commands/sessions-browser.js +51 -9
  15. package/dist/commands/sessions-favorite.d.ts +20 -0
  16. package/dist/commands/sessions-favorite.js +120 -0
  17. package/dist/commands/sessions.d.ts +103 -20
  18. package/dist/commands/sessions.js +356 -62
  19. package/dist/commands/setup-secrets.d.ts +7 -0
  20. package/dist/commands/setup-secrets.js +12 -9
  21. package/dist/commands/versions.js +12 -4
  22. package/dist/commands/view.d.ts +14 -1
  23. package/dist/commands/view.js +103 -128
  24. package/dist/lib/agents.d.ts +4 -2
  25. package/dist/lib/agents.js +21 -6
  26. package/dist/lib/hosts/dispatch.js +19 -1
  27. package/dist/lib/hq/floor.js +12 -0
  28. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  29. package/dist/lib/picker.d.ts +27 -2
  30. package/dist/lib/picker.js +71 -7
  31. package/dist/lib/profiles.d.ts +48 -0
  32. package/dist/lib/profiles.js +67 -0
  33. package/dist/lib/rotate.d.ts +24 -2
  34. package/dist/lib/rotate.js +63 -6
  35. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  36. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  37. package/dist/lib/session/active.d.ts +109 -3
  38. package/dist/lib/session/active.js +269 -13
  39. package/dist/lib/session/db.d.ts +14 -0
  40. package/dist/lib/session/db.js +35 -0
  41. package/dist/lib/session/favorites.d.ts +39 -0
  42. package/dist/lib/session/favorites.js +101 -0
  43. package/dist/lib/session/host-link.d.ts +68 -0
  44. package/dist/lib/session/host-link.js +64 -0
  45. package/dist/lib/session/presence.d.ts +85 -0
  46. package/dist/lib/session/presence.js +150 -0
  47. package/dist/lib/session/remote-list.d.ts +10 -0
  48. package/dist/lib/session/remote-list.js +47 -9
  49. package/dist/lib/tmux/binary.d.ts +7 -0
  50. package/dist/lib/tmux/binary.js +11 -1
  51. package/dist/lib/types.d.ts +4 -3
  52. package/dist/lib/usage-backoff.d.ts +29 -0
  53. package/dist/lib/usage-backoff.js +165 -0
  54. package/dist/lib/usage.d.ts +112 -5
  55. package/dist/lib/usage.js +464 -46
  56. package/dist/lib/watchdog/runner.d.ts +13 -0
  57. package/dist/lib/watchdog/runner.js +16 -1
  58. package/package.json +1 -1
@@ -460,7 +460,7 @@ function compactRemaining(expiresAt) {
460
460
  return `${Math.round(hours / 24)}d`;
461
461
  }
462
462
  /** The POLICY column for `secrets list`: the prompt policy, plus a concise
463
- * state hint. `daily` shows `held Nh` when the secrets-agent is currently
463
+ * state hint. `hold` shows `held Nh` when the secrets-agent is currently
464
464
  * caching the bundle; `always` and `never` show whether they prompt. `held`
465
465
  * maps bundle name → expiry epoch-ms (from agentStatus()). */
466
466
  export function renderPolicyCol(b, held) {
@@ -472,6 +472,18 @@ export function renderPolicyCol(b, held) {
472
472
  const exp = held?.get(b.name);
473
473
  return exp ? chalk.green(`hold · held ${compactRemaining(exp)}`) : chalk.gray('hold');
474
474
  }
475
+ /** The hold-window line at the top of `secrets status`. Names the `hold` policy
476
+ * the window belongs to — the rename in #1604 left this surface still saying
477
+ * "daily", the one name the CLI no longer accepts in its own help. Pure so the
478
+ * vocabulary is pinned by a test rather than re-drifting on the next rename. */
479
+ export function renderHoldSummary(holdStr, configured) {
480
+ const source = configured ? ' (secrets.agent.holdMs)' : ' (default)';
481
+ return `hold: ${holdStr}${source} — a bundle on the hold policy prompts once, then stays silent for this long or until sleep/logout.`;
482
+ }
483
+ /** The empty-broker line under the hold summary. Named here, beside
484
+ * `renderHoldSummary`, for the same reason: it is the second line the rename
485
+ * left saying `daily`, and a test pins both. */
486
+ export const NO_BUNDLES_HELD_LINE = 'No bundles held. The next read of each hold-policy bundle will prompt once, then hold.';
475
487
  /** Human-readable hold window for `secrets status`. Sub-hour values render in
476
488
  * minutes (so a near-floor `holdMs` never shows a confusing "0 hours"), whole
477
489
  * hours up to 2 days, whole days beyond. Pure — unit-tested. */
@@ -805,7 +817,7 @@ export function registerSecretsCommands(program) {
805
817
  return;
806
818
  }
807
819
  const bundles = listBundles();
808
- // Cross-reference the secrets-agent so `daily` bundles that are currently
820
+ // Cross-reference the secrets-agent so `hold` bundles that are currently
809
821
  // held can show "· held Nh". Soft-fails to no hint if the broker is down.
810
822
  const held = new Map();
811
823
  if (process.platform === 'darwin') {
@@ -1174,7 +1186,7 @@ export function registerSecretsCommands(program) {
1174
1186
  const resolvedName = name ?? (await promptBundleName());
1175
1187
  validateBundleName(resolvedName);
1176
1188
  // Leave policy unset unless the user explicitly chose one, so the bundle
1177
- // inherits the configured default (`daily`) instead of being pinned.
1189
+ // inherits the configured default (`hold`) instead of being pinned.
1178
1190
  const policyOpt = opts.policy ?? opts.tier;
1179
1191
  const policy = policyOpt ? parsePolicyOpt(policyOpt) : undefined;
1180
1192
  const backend = opts.synced ? 'vault' : resolveBackendOpt(opts.backend);
@@ -2266,7 +2278,7 @@ Examples:
2266
2278
  (brokerUp
2267
2279
  ? chalk.green('running') + chalk.gray(isDaemonRunning() ? ' (hosted by the daemon)' : ' (standalone)')
2268
2280
  : chalk.yellow('not running — starts on demand, or run `agents secrets start` to bring the daemon up now')));
2269
- // Diagnostic: version skew is the top reason a `daily` bundle keeps
2281
+ // Diagnostic: version skew is the top reason a `hold` bundle keeps
2270
2282
  // re-prompting — a broker on an older build gets torn down when the CLI
2271
2283
  // version changes (e.g. `agents-cli-update`), wiping every held bundle.
2272
2284
  const onDisk = getCliVersionFresh();
@@ -2286,11 +2298,11 @@ Examples:
2286
2298
  catch {
2287
2299
  return false;
2288
2300
  } })();
2289
- console.log(chalk.gray(`hold: ${holdStr}${configured ? ' (secrets.agent.holdMs)' : ' (default)'} — a daily bundle prompts once, then stays silent for this long or until sleep/logout.`));
2301
+ console.log(chalk.gray(renderHoldSummary(holdStr, configured)));
2290
2302
  const entries = await agentStatus();
2291
2303
  const held = new Set(entries.map((e) => e.name));
2292
2304
  if (entries.length === 0) {
2293
- console.log(chalk.gray('No bundles held. The next read of each daily bundle will prompt once, then hold.'));
2305
+ console.log(chalk.gray(NO_BUNDLES_HELD_LINE));
2294
2306
  console.log(chalk.gray('Pre-warm now with: agents secrets unlock <bundle> (or --all)'));
2295
2307
  }
2296
2308
  else {
@@ -26,6 +26,8 @@ export interface BrowserFilter {
26
26
  device?: string;
27
27
  /** filter to one team's lineage, or all — the `T` key / `--in-team`. */
28
28
  team?: string;
29
+ /** favorited-only — the `f` key / `--favorites`. */
30
+ favorites: boolean;
29
31
  /** this-repo subtree vs every directory — the `P` key / `--all`. */
30
32
  projectScope: 'repo' | 'all';
31
33
  /** time window (undefined = all time) — the `W` key / `--since`. */
@@ -77,6 +79,7 @@ export declare function activeBrowserSeed(opts: {
77
79
  host?: string[];
78
80
  since?: string;
79
81
  all?: boolean;
82
+ favorites?: boolean;
80
83
  }): Partial<BrowserFilter>;
81
84
  /**
82
85
  * The initial filter for the bare interactive listing: current-repo subtree by
@@ -92,6 +95,7 @@ export declare function bareBrowserSeed(opts: {
92
95
  since?: string;
93
96
  host?: string[];
94
97
  inTeam?: string;
98
+ favorites?: boolean;
95
99
  }): Partial<BrowserFilter>;
96
100
  /**
97
101
  * A live session's stable row key: its session id when the agent reported one,
@@ -17,9 +17,10 @@ import { isSessionTrackedAgent } from '../lib/session/types.js';
17
17
  import { discoverSessions } from '../lib/session/discover.js';
18
18
  import { gatherRemoteList } from '../lib/session/remote-list.js';
19
19
  import { enrichTeamOrigins, safeTeamText } from '../lib/session/team-filter.js';
20
+ import { listFavorites, toggleFavorite } from '../lib/session/favorites.js';
20
21
  import { machineId, normalizeHost } from '../lib/session/sync/config.js';
21
22
  import { buildPreview } from './sessions-picker.js';
22
- import { formatPickerLabel, pickerColumnsFor, ticketLabel, mergeLocalFirst, gatherActiveSessions, liveHostLabel, LIVE_ROW_PREFIX, cleanPreview, handlePickedSession, shouldIncludeLocal, remoteHostsToDial, matchesTeam, } from './sessions.js';
23
+ import { formatPickerLabel, pickerColumnsFor, ticketLabel, mergeLocalFirst, gatherActiveSessions, liveHostLabel, LIVE_ROW_PREFIX, cleanPreview, handlePickedSession, shouldIncludeLocal, remoteHostsToDial, matchesTeam, formatLiveStatusHeadline, } from './sessions.js';
23
24
  /**
24
25
  * Complete a seed into the filter the picker actually runs on.
25
26
  *
@@ -37,6 +38,7 @@ export function buildInitialFilter(initial) {
37
38
  return {
38
39
  running: initial.running ?? false,
39
40
  teams: initial.teams ?? false,
41
+ favorites: initial.favorites ?? false,
40
42
  agent: initial.agent,
41
43
  device: initial.device,
42
44
  team: initial.team,
@@ -107,6 +109,8 @@ export function browserFilterToArgv(f, query = '') {
107
109
  a.push('--active');
108
110
  if (f.teams)
109
111
  a.push('--teams');
112
+ if (f.favorites)
113
+ a.push('--favorites');
110
114
  if (f.agent)
111
115
  a.push('-a', f.agent);
112
116
  if (f.device)
@@ -140,6 +144,7 @@ export function activeBrowserSeed(opts) {
140
144
  return {
141
145
  running: true,
142
146
  teams: !!opts.teams,
147
+ favorites: !!opts.favorites,
143
148
  agent: opts.agent,
144
149
  projectScope: 'all',
145
150
  device: normalizeDeviceSeed(opts.host?.[0]),
@@ -170,6 +175,7 @@ export function bareBrowserSeed(opts) {
170
175
  const wholeTeam = !!opts.inTeam;
171
176
  return {
172
177
  teams: !!opts.teams,
178
+ favorites: !!opts.favorites,
173
179
  agent: opts.agent,
174
180
  // The filter carries one device; seed it only when the scope names exactly
175
181
  // one, so a two-device scope isn't narrowed to the first of them.
@@ -359,9 +365,14 @@ export function shouldShowHostColumn(f, live, rows) {
359
365
  return false;
360
366
  return rows.some((r) => liveHostLabel(live.get(r.id)) !== '');
361
367
  }
362
- /** Apply the cheap in-memory filters (agent / device / project / running). */
363
- function applyFilters(rows, live, f, self) {
368
+ /** Apply the cheap in-memory filters (agent / device / project / running / favorites). */
369
+ function applyFilters(rows, live, f, self, favorites) {
364
370
  let out = rows;
371
+ // A projected live row is keyed by pid/task when it has no session id, and a
372
+ // favorite is always keyed by a real session id — so an id-less row can never
373
+ // be favorited and correctly drops out here.
374
+ if (f.favorites)
375
+ out = out.filter((r) => favorites.has(r.id));
365
376
  if (f.agent)
366
377
  out = out.filter((r) => r.agent === f.agent);
367
378
  if (f.device)
@@ -398,13 +409,15 @@ function headerFor(f) {
398
409
  bits.push('running');
399
410
  if (f.teams)
400
411
  bits.push('teams');
412
+ if (f.favorites)
413
+ bits.push('favorites');
401
414
  return bits.join(' · ');
402
415
  }
403
416
  function helpFor(_f, mode) {
404
417
  if (mode === 'search') {
405
418
  return 'type to filter · ↑↓ navigate · esc exit search · ⏎ resume';
406
419
  }
407
- return 's search · r running · c teams · t team · a agent · d device · p project · w window · tab preview · y copy-cmd · ⏎ resume · esc quit';
420
+ return 's search · r running · f favorites · * star · c teams · t team · a agent · d device · p project · w window · tab preview · y copy-cmd · ⏎ resume · esc quit';
408
421
  }
409
422
  /**
410
423
  * Launch the interactive session browser. `initial` seeds the filter (e.g.
@@ -432,6 +445,10 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
432
445
  // The live index is slow (a full ps/tmux scan) and only the running filter
433
446
  // needs it — fetch it once, lazily, the first time running is toggled on.
434
447
  let liveCache = null;
448
+ // Re-read every load (it's an mtime-memoized parse of one small file), so the
449
+ // `*` key's reload picks up the star it just wrote — and so does a favorite
450
+ // starred by another session on this machine.
451
+ let favorites = new Set();
435
452
  // Generation guard: two quick keypresses can start overlapping loads whose
436
453
  // SSH fan-outs settle out of order. dynamicPicker's own gen ref guards which
437
454
  // rows become `items`, but the shared closure state below (cols / cycle pools /
@@ -489,9 +506,14 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
489
506
  ...rows.map((r) => safeTeamText(r.spawnedTeam)),
490
507
  ...rows.map((r) => safeTeamText(r.teamOrigin?.team)),
491
508
  ]);
492
- const filtered = applyFilters(rows, live ?? new Map(), f, self);
509
+ favorites = listFavorites();
510
+ const filtered = applyFilters(rows, live ?? new Map(), f, self, favorites);
493
511
  cols = pickerColumnsFor(filtered);
494
512
  cols.showHost = shouldShowHostColumn(f, live, filtered);
513
+ // Status rides the same gate as the host column: both come from the live
514
+ // scan, so both belong to the running view and neither should widen a plain
515
+ // transcript listing that has no live rows to fill them.
516
+ cols.showStatus = !!f.running && !!live;
495
517
  return filtered;
496
518
  };
497
519
  const picked = await dynamicPicker({
@@ -499,9 +521,17 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
499
521
  initialFilter,
500
522
  load,
501
523
  keyFor: (s) => s.id,
502
- labelFor: (s, q) => formatPickerLabel(s, q, cols, sshOriginTagFor(liveCache, s.id), liveHostLabel(liveCache?.get(s.id))),
524
+ labelFor: (s, q) => formatPickerLabel(s, q, cols, sshOriginTagFor(liveCache, s.id), liveHostLabel(liveCache?.get(s.id)), favorites.has(s.id), liveCache?.get(s.id)),
503
525
  matches: sessionMatchesQuery,
504
- buildPreview,
526
+ // Lead the preview with the live status banner — the one place a `crashed` /
527
+ // `orphaned` session gets a sentence instead of a glyph. `buildPreview` is
528
+ // memoized per session, so the volatile live half is prepended here rather
529
+ // than baked into the cached body.
530
+ buildPreview: (s) => {
531
+ const headline = formatLiveStatusHeadline(liveCache?.get(s.id), favorites.has(s.id));
532
+ const body = buildPreview(s);
533
+ return headline ? `${headline}\n${body}` : body;
534
+ },
505
535
  headerFor: (f) => unreachable.length > 0
506
536
  ? `${headerFor(f)} · ${chalk.yellow(`${unreachable.join(', ')}: unreachable`)}`
507
537
  : headerFor(f),
@@ -511,6 +541,7 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
511
541
  loadingMessage: local ? 'Loading…' : 'Loading (reaching other machines)…',
512
542
  keyBindings: {
513
543
  r: (f) => ({ ...f, running: !f.running }),
544
+ f: (f) => ({ ...f, favorites: !f.favorites }),
514
545
  c: (f) => ({ ...f, teams: !f.teams }),
515
546
  a: (f) => ({ ...f, agent: cycle(f.agent, agentsInPool) }),
516
547
  d: (f) => ({ ...f, device: cycle(f.device, devicesInPool) }),
@@ -521,8 +552,19 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
521
552
  p: (f) => (hosts ? f : { ...f, projectScope: f.projectScope === 'repo' ? 'all' : 'repo' }),
522
553
  w: (f) => ({ ...f, window: cycleWindow(f.window) }),
523
554
  },
524
- onKey: (name, f, _active, query) => {
525
- if (name === 'y') {
555
+ onKey: (name, f, active, query) => {
556
+ if (name === '*') {
557
+ // Only a row with a real session id can be starred: a projected live row
558
+ // with no id is keyed by pid, which is gone the moment the process is.
559
+ if (!active || active.id.startsWith(LIVE_ROW_PREFIX))
560
+ return 'nothing to star on this row';
561
+ const on = toggleFavorite(active.id);
562
+ // reload so the row's star is repainted — labels are memoized per row.
563
+ return { flash: on ? `★ favorited ${active.shortId}` : `☆ unfavorited ${active.shortId}`, reload: true };
564
+ }
565
+ // Both cases: `hotkeyToken` hands `onKey` the literal character, and this
566
+ // key worked with caps lock on before it existed.
567
+ if (name === 'y' || name === 'Y') {
526
568
  // Thread the live search query so the copied command reproduces the
527
569
  // exact view — the human→agent bridge must include the search term.
528
570
  const cmd = 'ag ' + browserFilterToArgv(f, query).join(' ');
@@ -0,0 +1,20 @@
1
+ /**
2
+ * `agents sessions favorite` — the non-TTY half of the star.
3
+ *
4
+ * The `*` hotkey in the interactive browser is how a human stars a session; this
5
+ * is how a script, an agent, or a machine without a TTY does the same thing, and
6
+ * it is what makes the feature testable end to end without driving a terminal UI.
7
+ * Both write the one store in `lib/session/favorites.ts`.
8
+ */
9
+ import type { Command } from 'commander';
10
+ /**
11
+ * Resolve one user-typed id (usually the 8-char short id the listing prints) to
12
+ * a full session id. Ambiguity is an ERROR, not a silent first-match: starring
13
+ * the wrong session is invisible until the user wonders where their star went.
14
+ */
15
+ export declare function resolveFavoriteTarget(idQuery: string): {
16
+ id: string;
17
+ } | {
18
+ error: string;
19
+ };
20
+ export declare function registerSessionsFavoriteCommand(sessionsCmd: Command): void;
@@ -0,0 +1,120 @@
1
+ /**
2
+ * `agents sessions favorite` — the non-TTY half of the star.
3
+ *
4
+ * The `*` hotkey in the interactive browser is how a human stars a session; this
5
+ * is how a script, an agent, or a machine without a TTY does the same thing, and
6
+ * it is what makes the feature testable end to end without driving a terminal UI.
7
+ * Both write the one store in `lib/session/favorites.ts`.
8
+ */
9
+ import chalk from 'chalk';
10
+ import { setHelpSections } from '../lib/help.js';
11
+ import { findSessionsById } from '../lib/session/db.js';
12
+ import { isCompleteSessionId } from '../lib/session/discover.js';
13
+ import { isFavorite, listFavorites, setFavorite } from '../lib/session/favorites.js';
14
+ /**
15
+ * Resolve one user-typed id (usually the 8-char short id the listing prints) to
16
+ * a full session id. Ambiguity is an ERROR, not a silent first-match: starring
17
+ * the wrong session is invisible until the user wonders where their star went.
18
+ */
19
+ export function resolveFavoriteTarget(idQuery) {
20
+ const matches = findSessionsById(idQuery);
21
+ // A COMPLETE id needs no index entry: the id is the key the store is built on,
22
+ // and requiring a transcript row would refuse exactly the newest sessions — a
23
+ // live one that has not been indexed yet. The browser's `*` stars those from
24
+ // the live row, so demanding a DB hit here would make the two disagree.
25
+ if (matches.length === 0) {
26
+ return isCompleteSessionId(idQuery.trim())
27
+ ? { id: idQuery.trim() }
28
+ : { error: `No session matches "${idQuery}".` };
29
+ }
30
+ if (matches.length > 1) {
31
+ const ids = matches.slice(0, 5).map((m) => m.shortId).join(', ');
32
+ return { error: `"${idQuery}" matches ${matches.length} sessions (${ids}…) — use a longer id.` };
33
+ }
34
+ return { id: matches[0].id };
35
+ }
36
+ export function registerSessionsFavoriteCommand(sessionsCmd) {
37
+ const cmd = sessionsCmd
38
+ .command('favorite')
39
+ .argument('[ids...]', 'Session ids to star (full or short id prefix)')
40
+ .description('Star sessions so they are easy to find again — list them with --favorites, or `f` in the browser.')
41
+ .option('--remove', 'Unstar the given sessions instead of starring them')
42
+ .option('--list', 'List the starred sessions (the default when no ids are given)')
43
+ .option('--json', 'Output JSON');
44
+ setHelpSections(cmd, {
45
+ examples: `
46
+ # Star a session by its short id (the 8 chars the listing prints)
47
+ agents sessions favorite 26c27162
48
+
49
+ # See what is starred
50
+ agents sessions favorite --list
51
+
52
+ # Browse only the starred ones
53
+ agents sessions --favorites
54
+
55
+ # Unstar it again
56
+ agents sessions favorite 26c27162 --remove
57
+ `,
58
+ notes: `
59
+ In the interactive browser (\`agents sessions\`), \`*\` stars the highlighted
60
+ session and \`f\` filters the list down to the starred ones.
61
+
62
+ Stars live in ~/.agents/.history/favorites.json, keyed by session id, so
63
+ they survive a reindex of the session cache. They are per-machine: session
64
+ sync carries transcripts, not this file.
65
+ `,
66
+ });
67
+ cmd.action((ids, options, self) => {
68
+ // `--json` has to come from the merged view, not `options`. The parent
69
+ // `sessions` command declares `--json` AND takes a positional `[query]`, so
70
+ // commander keeps parsing parent-known options past the subcommand name and
71
+ // binds `--json` to the PARENT — `options.json` is silently undefined here
72
+ // while `--remove`/`--list` (unknown to the parent) arrive fine.
73
+ // `optsWithGlobals` is commander's own answer for reading an option a parent
74
+ // owns; it is still declared on this command so `--help` documents it.
75
+ const json = self.optsWithGlobals().json === true;
76
+ if (options.list || ids.length === 0) {
77
+ const starred = [...listFavorites()].sort();
78
+ if (json) {
79
+ process.stdout.write(JSON.stringify({ favorites: starred }, null, 2) + '\n');
80
+ return;
81
+ }
82
+ if (starred.length === 0) {
83
+ console.log(chalk.gray('No favorited sessions. Star one with `agents sessions favorite <id>`.'));
84
+ return;
85
+ }
86
+ for (const id of starred)
87
+ console.log(`${chalk.yellow('★')} ${id}`);
88
+ console.log(chalk.gray(`\n${starred.length} favorite${starred.length === 1 ? '' : 's'}.`));
89
+ return;
90
+ }
91
+ const on = !options.remove;
92
+ const results = [];
93
+ for (const idQuery of ids) {
94
+ const resolved = resolveFavoriteTarget(idQuery);
95
+ if ('error' in resolved) {
96
+ results.push({ query: idQuery, error: resolved.error });
97
+ continue;
98
+ }
99
+ // Unstarring something that was never starred, or starring it twice, is a
100
+ // no-op the store already short-circuits — report the resulting state.
101
+ setFavorite(resolved.id, on);
102
+ results.push({ query: idQuery, id: resolved.id, favorite: isFavorite(resolved.id) });
103
+ }
104
+ if (json) {
105
+ process.stdout.write(JSON.stringify({ results }, null, 2) + '\n');
106
+ }
107
+ else {
108
+ for (const r of results) {
109
+ if (r.error)
110
+ console.error(chalk.red(r.error));
111
+ else
112
+ console.log(`${r.favorite ? chalk.yellow('★ favorited') : chalk.gray('☆ unfavorited')} ${r.id}`);
113
+ }
114
+ }
115
+ // A failed lookup is a failed command — a script must not read "starred" from
116
+ // a zero exit when nothing was starred.
117
+ if (results.some((r) => r.error))
118
+ process.exitCode = 1;
119
+ });
120
+ }
@@ -1,4 +1,4 @@
1
- import type { Command } from 'commander';
1
+ import { type Command } from 'commander';
2
2
  import type { SessionAgentId, SessionMeta, ViewMode } from '../lib/session/types.js';
3
3
  import { type ActiveSession } from '../lib/session/active.js';
4
4
  import { gatherRemoteList, runOnPeer } from '../lib/session/remote-list.js';
@@ -17,6 +17,10 @@ interface SessionsOptions extends SessionFilterOptions {
17
17
  /** Also list sessions from the user's own unmanaged ~/.<agent> installs. */
18
18
  unmanaged?: boolean;
19
19
  query?: string;
20
+ /** Resolve one historical selector to metadata only (requires --json). */
21
+ resolve?: string;
22
+ /** Versioned internal peer protocol; old/unsafe peers must reject it. */
23
+ resolveSafeV1?: string;
20
24
  limit?: string;
21
25
  sort?: string;
22
26
  json?: boolean;
@@ -40,6 +44,8 @@ interface SessionsOptions extends SessionFilterOptions {
40
44
  flat?: boolean;
41
45
  /** With --active: show only sessions waiting on user input; exit 1 if any. */
42
46
  waiting?: boolean;
47
+ /** Show only favorited (starred) sessions — the `f` key's flag twin. */
48
+ favorites?: boolean;
43
49
  /** Enrich the listing with live glyphs/preview for running rows. Default on;
44
50
  * `--no-live` sets this false. Commander's `--no-` convention. */
45
51
  live?: boolean;
@@ -120,6 +126,29 @@ export declare function liveGlyphAndPreview(a: ActiveSession | undefined): {
120
126
  * exported for the row tests.
121
127
  */
122
128
  export declare function liveStatusWord(a: ActiveSession | undefined): string;
129
+ /**
130
+ * True when a session is blocked on a human — the `--waiting` contract.
131
+ *
132
+ * NOT `status === 'input_required'`. `foldHostLink` rewrites that status to
133
+ * `orphaned` when nothing is attached, and a session waiting on a question with
134
+ * NOBODY watching is the most acute case `--waiting` exists to surface, not one
135
+ * it should drop. The underlying `activity` is never rewritten, so it is the
136
+ * honest signal here.
137
+ *
138
+ * But `activity` is never rewritten for a DEAD session either: one that died
139
+ * mid-question keeps `waiting_input` forever, and answering it is not a thing a
140
+ * human can do — it needs a relaunch. `--waiting` is a scriptable gate ("does
141
+ * anything need me?"), so a corpse must not trip it.
142
+ *
143
+ * `closed` and `crashed` are unconditionally dead, so they are excluded outright.
144
+ * `abandoned` is NOT: it fires on transcript staleness before the liveness check,
145
+ * so it also covers the live-but-forgotten case — an interactive session that
146
+ * asked a question and sat untouched over a long weekend is still answerable, and
147
+ * is exactly what this gate exists for. It is excluded only when we positively
148
+ * know its process is gone; unknown liveness (an older peer, a row with no pid)
149
+ * stays excluded rather than inventing a human who can answer.
150
+ */
151
+ export declare function isAwaitingUser(s: ActiveSession): boolean;
123
152
  /**
124
153
  * The tracker/PR ref for a session's dedicated column: the ticket id when known,
125
154
  * else `PR#<n>`, else empty. Pulled out of the trailing badge blob so refs align
@@ -230,6 +259,10 @@ export declare function mergeLocalFirst(sessions: SessionMeta[], localMachine: s
230
259
  * `--json --host` remote fan-out so both emit byte-identical row shapes.
231
260
  */
232
261
  export declare function serializeSessionsJson(sessions: SessionMeta[]): string;
262
+ /** The intentionally small metadata contract emitted by `sessions --resolve`.
263
+ * Transcript locations, extracted plans, account data, costs, and other indexed
264
+ * payload stay local to the machine that owns them. */
265
+ export declare function serializeResolvedSessionsJson(sessions: SessionMeta[]): string;
233
266
  /**
234
267
  * Whether the local machine's sessions belong in an `--active` view. Local is
235
268
  * included by default; an explicit `--host`/`--device` list scopes the view to
@@ -281,6 +314,17 @@ export declare function isBareBrowserListing(options: SessionsOptions, query: st
281
314
  * `runSessionBrowser` picker cannot represent.
282
315
  */
283
316
  export declare function hasNoBrowserDisqualifyingFlags(options: SessionsOptions, query: string | undefined): boolean;
317
+ /**
318
+ * The one-line live status banner shown above a session preview: the glyph, the
319
+ * status word, and — when the session needs a human or has LOST one — a plain
320
+ * sentence saying so. Shared by `--preview` and the interactive browser's preview
321
+ * pane so both explain a state the same way.
322
+ *
323
+ * `crashed` and `orphaned` are the states a glyph alone cannot carry: nobody
324
+ * reads "orphan" and knows it means "still running in tmux with no window
325
+ * attached", so those two spell it out.
326
+ */
327
+ export declare function formatLiveStatusHeadline(live: ActiveSession | undefined, favorite?: boolean): string;
284
328
  /**
285
329
  * Whether a session belongs to `team`, from either end: it spawned the team, or
286
330
  * it is one of the team's teammates. Case-insensitive, matching the SQL
@@ -305,7 +349,7 @@ export declare function teamBadge(session: SessionMeta): {
305
349
  * (tracker/PR ref, pulled out of the badge blob so refs align) is only rendered
306
350
  * when `showTicket` — otherwise a listing with no refs would waste a column of
307
351
  * dashes and needlessly truncate the topic. Worktree stays a trailing badge. */
308
- export declare function flatSessionRow(session: SessionMeta, live?: ActiveSession, showTicket?: boolean, cols?: PickerColumns): string;
352
+ export declare function flatSessionRow(session: SessionMeta, live?: ActiveSession, showTicket?: boolean, cols?: PickerColumns, favorite?: boolean): string;
309
353
  /**
310
354
  * Group key for the overview: prefer the indexed project name; else fold the cwd
311
355
  * to its repo — a worktree (`.../<repo>/.agents/worktrees/<slug>`) folds to the
@@ -370,6 +414,19 @@ export interface PickerColumns {
370
414
  * off for a plain transcript listing, where no row has a host.
371
415
  */
372
416
  showHost?: boolean;
417
+ /**
418
+ * Render the favorite marker column. Like every other conditional column here,
419
+ * it earns its 2 cells only when some row in the pool is actually starred — a
420
+ * user who has never favorited anything pays nothing for the feature.
421
+ */
422
+ showFavorite?: boolean;
423
+ /**
424
+ * Render the live status column (`working` / `waiting` / `orphan` / `crashed`).
425
+ * Live-only, gated the same way as {@link showHost}: it comes from the
426
+ * active-session scan, so the running-filtered browser sets it and a plain
427
+ * transcript listing — where no row has a status — leaves it off.
428
+ */
429
+ showStatus?: boolean;
373
430
  /**
374
431
  * Cells the picker prepends before each row: 2 for the single-select cursor
375
432
  * ('> '), 6 for the multi-select cursor + checkbox ('> [x] '). Reserved from
@@ -399,7 +456,7 @@ export declare function pickerColumnsFor(sessions: SessionMeta[]): PickerColumns
399
456
  * resolvable host (cloud rows, an unreadable process env).
400
457
  */
401
458
  export declare function liveHostLabel(a: ActiveSession | undefined): string;
402
- export declare function formatPickerLabel(s: SessionMeta, query: string, cols?: PickerColumns, ssh?: SshOriginTag, host?: string): string;
459
+ export declare function formatPickerLabel(s: SessionMeta, query: string, cols?: PickerColumns, ssh?: SshOriginTag, host?: string, favorite?: boolean, live?: ActiveSession): string;
403
460
  /**
404
461
  * Pick a hint to show above the picker. Deterministic (keys off the pool size)
405
462
  * so it stays fixed across the picker's re-renders within a single run.
@@ -466,7 +523,9 @@ export interface SessionQueryResolution {
466
523
  * search (a bare id must not surface every transcript that merely mentions it).
467
524
  * A genuine search phrase keeps the ranked metadata+content search.
468
525
  */
469
- export declare function resolveSessionQuery(pool: SessionMeta[], query: string): SessionQueryResolution;
526
+ export declare function resolveSessionQuery(pool: SessionMeta[], query: string, options?: {
527
+ indexFallback?: boolean;
528
+ }): SessionQueryResolution;
470
529
  /** Filter and rank sessions by a multi-term search query across metadata and content. */
471
530
  export declare function filterSessionsByQuery(sessions: SessionMeta[], query: string | undefined): SessionMeta[];
472
531
  /**
@@ -489,36 +548,60 @@ export interface FleetResolveDeps {
489
548
  gatherRemoteList: typeof gatherRemoteList;
490
549
  runOnPeer: typeof runOnPeer;
491
550
  }
492
- /** One distinct machine that reported the id, plus its winning row. */
551
+ /** One distinct machine that reported a logical session, plus its winning row. */
493
552
  interface FleetHit {
494
553
  machine: string;
495
554
  session: SessionMeta;
496
555
  }
497
- /** Group a fleet sweep's rows to the DISTINCT machines that hold the id. Each
498
- * peer answered `sessions <id> --json --local`, which (post-fix) id-resolves and
499
- * so returns the matching row(s); a peer with a synced MIRROR of the same id can
500
- * emit more than one row, so we keep the first per machine. Rows the peer somehow
501
- * returned that do NOT match the id (defensive against version skew) are dropped
502
- * so a stray content hit can never masquerade as an exact resolution. */
503
- export declare function fleetHitsById(rows: SessionMeta[], id: string): FleetHit[];
504
- /**
505
- * Locate a full session id across the online fleet and render it from the machine
556
+ /** One logical session returned by the fleet, including every machine holding a copy. */
557
+ export interface FleetSessionCandidate {
558
+ id: string;
559
+ hits: FleetHit[];
560
+ }
561
+ export type MetadataResolveOutcome = {
562
+ kind: 'resolved';
563
+ session: SessionMeta;
564
+ } | {
565
+ kind: 'not-found';
566
+ } | {
567
+ kind: 'ambiguous';
568
+ candidates: FleetSessionCandidate[];
569
+ } | {
570
+ kind: 'partial';
571
+ failedPeers: string[];
572
+ };
573
+ /** Resolve a fleet sweep through the same canonical full-id / prefix resolver as
574
+ * local lookups, then group copies by logical session id. Synced mirrors of one
575
+ * session therefore stay one candidate even when several machines report them;
576
+ * distinct ids sharing a prefix remain distinct ambiguity candidates. */
577
+ export declare function fleetCandidatesByQuery(rows: SessionMeta[], query: string): FleetSessionCandidate[];
578
+ /** Fixed peer argv for the metadata resolver. Scope flags compose identically on
579
+ * every host; `--all` removes the SSH login cwd/time window, not agent/project filters. */
580
+ export declare function metadataResolveForwardedArgs(selector: string, scope: Pick<SessionFilterOptions, 'agent' | 'project'>): string[];
581
+ /** Resolution must fail closed when any selected peer did not answer. Choosing
582
+ * from a partial fleet can turn an unseen candidate into a false unique match. */
583
+ export declare function metadataResolveOutcome(localMatches: SessionMeta[], remote: {
584
+ sessions: SessionMeta[];
585
+ unreachable: string[];
586
+ }, selector: string): MetadataResolveOutcome;
587
+ /**
588
+ * Locate a full session id or short id prefix across the online fleet and render it from the machine
506
589
  * that holds it. The local disk already missed; this fans `sessions <id> --json
507
590
  * --all` out to every registered online peer (or the explicit `hosts` set),
508
591
  * groups the rows to distinct machines, then:
509
592
  *
510
- * - exactly one machine → delegate rendering to that peer via `runOnPeer`
593
+ * - exactly one logical session → delegate rendering to one peer via `runOnPeer`
511
594
  * (its transcript and agent binary live there — a local `--host` hop would
512
595
  * re-discover locally and dead-end), returning `'rendered'`.
513
- * - more than one machine → print the conflict with machine labels so the user
514
- * can disambiguate with `--device <host>`, returning `'conflict'`.
596
+ * - more than one logical session → print every full-id candidate with its
597
+ * machine labels, returning `'conflict'`.
515
598
  * - none → `'not-found'`, letting the caller print the local
516
599
  * "no session on this machine" message.
517
600
  *
518
- * No fuzzy/content fallback: the sweep forwards a UUID, each peer id-resolves it,
519
- * and `fleetHitsById` drops anything that isn't an exact id match.
601
+ * No fuzzy/content fallback: the sweep forwards the id selector and every result
602
+ * is resolved through `resolveSessionQuery`, the same id-only resolver used locally.
520
603
  */
521
- export declare function resolveSessionAcrossFleet(id: string, mode: ViewMode, hosts?: string[], deps?: FleetResolveDeps): Promise<'rendered' | 'conflict' | 'not-found'>;
604
+ export declare function resolveSessionAcrossFleet(query: string, mode: ViewMode, hosts?: string[], deps?: FleetResolveDeps): Promise<'rendered' | 'conflict' | 'not-found'>;
522
605
  /** Register the `agents sessions` command with all its options and help text. */
523
606
  export declare function registerSessionsCommands(program: Command): void;
524
607
  export {};