@phnx-labs/agents-cli 1.20.82 → 1.20.84

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.
@@ -13,7 +13,7 @@ import * as yaml from 'yaml';
13
13
  import { isDaemonRunning, signalDaemonReload, startDaemon, stopDaemon, readDaemonLog, getDaemonStatus, } from '../lib/daemon.js';
14
14
  import { resolveAgentName, isAgentHardDeprecated, hardDeprecationError } from '../lib/agents.js';
15
15
  import { humanizeCron, humanizeNextRun, formatRepoLink, REPO_DISPLAY_MAX } from '../lib/routines-format.js';
16
- import { listJobs as listAllJobs, deleteJob, readJob, validateJob, writeJob, setJobEnabled, listRuns, getLatestRun, getRunDir, getJobPath, parseAtTime, jobRunsOnThisDevice, checkJobDeviceEligibility, normalizeTriggerEvent, parseHostStrategy, resolveHostStrategy, placementRequiresFiringPin, HOST_STRATEGIES, } from '../lib/routines.js';
16
+ import { listJobs as listAllJobs, deleteJob, readJob, validateJob, writeJob, setJobEnabled, listRuns, getLatestRun, getRunDir, getJobPath, parseAtTime, hasCompletedOneShotRun, isOneShotLikeSchedule, isOneShotRoutine, isPastOneShotRoutine, jobRunsOnThisDevice, checkJobDeviceEligibility, normalizeTriggerEvent, parseHostStrategy, resolveHostStrategy, placementRequiresFiringPin, HOST_STRATEGIES, } from '../lib/routines.js';
17
17
  import { discoverProjectRoutinesAt, enableProjectRoutines, disableProjectRoutines, syncProjectRoutines, syncAllProjectRoutines, listEnabledProjectRoots, resolveProjectRoot, displayProjectPath, listProjectRoutineFiles, } from '../lib/routines-project.js';
18
18
  import { fireWebhookJobs, matchJobsToWebhook } from '../lib/triggers/webhook.js';
19
19
  import { getRoutinesDir } from '../lib/state.js';
@@ -24,7 +24,7 @@ import { JobScheduler } from '../lib/scheduler.js';
24
24
  import { detectOverdueJobs } from '../lib/overdue.js';
25
25
  import { isInteractiveTerminal, requireInteractiveSelection } from './utils.js';
26
26
  import { setHelpSections } from '../lib/help.js';
27
- import { loadDevices } from '../lib/devices/registry.js';
27
+ import { loadDevices, loadDevicesSync } from '../lib/devices/registry.js';
28
28
  import { machineId, normalizeHost } from '../lib/machine-id.js';
29
29
  import { addHostOption } from '../lib/hosts/option.js';
30
30
  /**
@@ -75,6 +75,164 @@ function fireConditionLabel(job) {
75
75
  }
76
76
  return '-';
77
77
  }
78
+ function scheduleLabel(job) {
79
+ let label = fireConditionLabel(job);
80
+ if (isOneShotRoutine(job))
81
+ label = `${label} (one-shot)`;
82
+ if (job.endAt) {
83
+ const end = new Date(job.endAt);
84
+ const endLabel = Number.isFinite(end.getTime())
85
+ ? end.toLocaleDateString()
86
+ : job.endAt;
87
+ label = `${label} (until ${endLabel})`;
88
+ }
89
+ return label;
90
+ }
91
+ function nextRunForDisplay(job, scheduler) {
92
+ if (isPastOneShotRoutine(job))
93
+ return null;
94
+ return scheduler.getNextRun(job.name);
95
+ }
96
+ function nextRunLabel(job, scheduler, now) {
97
+ if (isPastOneShotRoutine(job))
98
+ return 'expired';
99
+ return humanizeNextRun(scheduler.getNextRun(job.name) ?? null, now, job.timezone);
100
+ }
101
+ function deviceStateLabel(name, registry) {
102
+ const profile = registry[normalizeHost(name)] ?? registry[name];
103
+ if (!profile)
104
+ return 'unknown';
105
+ if (profile.reachability?.reachable === false)
106
+ return 'offline';
107
+ if (profile.reachability?.reachable === true)
108
+ return 'online';
109
+ if (profile.tailscale?.online === false)
110
+ return 'offline';
111
+ if (profile.tailscale?.online === true)
112
+ return 'online';
113
+ return 'unknown';
114
+ }
115
+ function titleWithDeviceState(title, name, registry) {
116
+ const state = deviceStateLabel(name, registry);
117
+ return state && state !== 'online' ? `${title} (${state})` : title;
118
+ }
119
+ function placementTag(job) {
120
+ const strategy = resolveHostStrategy(job);
121
+ if (strategy === 'local')
122
+ return job.host ? `->${job.host}` : '';
123
+ if (strategy === 'host')
124
+ return `->${job.host ?? '?'}`;
125
+ if (strategy === 'fleet')
126
+ return '->fleet';
127
+ return '->cloud';
128
+ }
129
+ function deviceLabel(job, width) {
130
+ const full = [job.devices?.join(',') ?? '', placementTag(job)]
131
+ .filter(Boolean)
132
+ .join(' ');
133
+ const raw = full.length === 0 ? 'all' : full;
134
+ const display = width && raw.length > width
135
+ ? raw.slice(0, width - 1) + '…'
136
+ : raw;
137
+ return { raw, display, dim: full.length === 0 || !jobRunsOnThisDevice(job) };
138
+ }
139
+ export function groupRoutineJobsByDevice(jobs, registry, self = machineId()) {
140
+ const groups = new Map();
141
+ const add = (key, title, job) => {
142
+ const existing = groups.get(key);
143
+ if (existing) {
144
+ existing.jobs.push(job);
145
+ return;
146
+ }
147
+ groups.set(key, { key, title, jobs: [job] });
148
+ };
149
+ for (const job of jobs) {
150
+ const strategy = resolveHostStrategy(job);
151
+ if (strategy === 'cloud') {
152
+ add('cloud', 'Cloud', job);
153
+ continue;
154
+ }
155
+ if (strategy === 'fleet') {
156
+ add('fleet', 'Fleet-wide', job);
157
+ continue;
158
+ }
159
+ if (strategy === 'host') {
160
+ const host = job.host ?? 'unknown-host';
161
+ add(`host:${normalizeHost(host)}`, titleWithDeviceState(`Host: ${host}`, host, registry), job);
162
+ continue;
163
+ }
164
+ const devices = job.devices ?? [];
165
+ if (devices.length === 0) {
166
+ add('fleet', 'Fleet-wide', job);
167
+ continue;
168
+ }
169
+ for (const device of devices) {
170
+ const normalized = normalizeHost(device);
171
+ if (normalized === self) {
172
+ add('this-machine', `This machine (${self})`, job);
173
+ }
174
+ else {
175
+ add(`device:${normalized}`, titleWithDeviceState(`Device: ${normalized}`, normalized, registry), job);
176
+ }
177
+ }
178
+ }
179
+ const order = (group) => {
180
+ if (group.key === 'this-machine')
181
+ return 0;
182
+ if (group.key === 'fleet')
183
+ return 1;
184
+ if (group.key === 'cloud')
185
+ return 2;
186
+ if (group.key.startsWith('device:'))
187
+ return 3;
188
+ if (group.key.startsWith('host:'))
189
+ return 4;
190
+ return 5;
191
+ };
192
+ return [...groups.values()].sort((a, b) => order(a) - order(b) || a.title.localeCompare(b.title));
193
+ }
194
+ function renderRoutineRows({ jobs, scheduler, overdueSet, link, now }) {
195
+ const NAME_W = 24;
196
+ const AGENT_W = 10;
197
+ const REPO_W = REPO_DISPLAY_MAX;
198
+ const DEVICE_W = 22;
199
+ const SCHED_W = 34;
200
+ const ENABLED_W = 10;
201
+ const NEXT_W = 22;
202
+ const header = ` ${'Name'.padEnd(NAME_W)} ${'Agent'.padEnd(AGENT_W)} ${'Repo'.padEnd(REPO_W)} ${'Devices'.padEnd(DEVICE_W)} ${'Schedule'.padEnd(SCHED_W)} ${'Enabled'.padEnd(ENABLED_W)} ${'Next Run'.padEnd(NEXT_W)} Last Status`;
203
+ console.log(chalk.gray(header));
204
+ console.log(chalk.gray(' ' + '-'.repeat(NAME_W + AGENT_W + REPO_W + DEVICE_W + SCHED_W + ENABLED_W + NEXT_W + 20)));
205
+ for (const job of jobs) {
206
+ const nextStr = nextRunLabel(job, scheduler, now);
207
+ const schedStr = scheduleLabel(job);
208
+ const latestRun = getLatestRun(job.name);
209
+ const lastStatus = latestRun?.status || '-';
210
+ const sourceRepo = job.source?.repo ?? job.repo;
211
+ const sourceLabel = sourceRepo
212
+ ? (job.source?.branch ? `${sourceRepo}@${job.source.branch}` : sourceRepo)
213
+ : null;
214
+ const repoInfo = formatRepoLink(sourceLabel ?? job.repo);
215
+ const repoCell = link(repoInfo.display, repoInfo.href);
216
+ const repoPadding = Math.max(0, REPO_W - repoInfo.display.length);
217
+ const enabledStr = job.enabled ? chalk.green('yes') : chalk.gray('no');
218
+ const enabledWord = job.enabled ? 'yes' : 'no';
219
+ const enabledPad = Math.max(0, ENABLED_W - enabledWord.length);
220
+ const device = deviceLabel(job, DEVICE_W);
221
+ const deviceCell = device.dim ? chalk.gray(device.display) : device.display;
222
+ const devicePad = Math.max(0, DEVICE_W - device.display.length);
223
+ const statusColor = lastStatus === 'completed' ? chalk.green
224
+ : lastStatus === 'failed' ? chalk.red
225
+ : lastStatus === 'timeout' ? chalk.yellow
226
+ : chalk.gray;
227
+ const overdueTag = overdueSet.has(job.name) ? chalk.yellow(' (overdue)') : '';
228
+ const agentLabelPadded = job.command
229
+ ? chalk.magenta('command'.padEnd(10))
230
+ : job.workflow
231
+ ? chalk.magenta(`wf:${job.workflow}`.padEnd(10))
232
+ : (job.agent || '').padEnd(10);
233
+ console.log(` ${chalk.cyan(job.name.padEnd(NAME_W))} ${agentLabelPadded} ${repoCell}${' '.repeat(repoPadding)} ${deviceCell}${' '.repeat(devicePad)} ${schedStr.padEnd(SCHED_W)} ${enabledStr}${' '.repeat(enabledPad)} ${chalk.gray(nextStr.padEnd(NEXT_W))} ${statusColor(lastStatus)}${overdueTag}`);
234
+ }
235
+ }
78
236
  function parseRoutineTrigger(options) {
79
237
  const raw = typeof options.on === 'string' ? options.on : undefined;
80
238
  if (!raw)
@@ -308,7 +466,13 @@ export function registerRoutinesCommands(program) {
308
466
  .command('list')
309
467
  .description('See all scheduled jobs, when they run next, and their last execution status')
310
468
  .option('--json', 'Emit machine-readable JSON instead of the table (used by the menu bar helper)')
469
+ .option('--group-by <field>', 'Group table output by device (default for terminal output)')
470
+ .option('--flat', 'Print the legacy flat table instead of grouped sections')
311
471
  .action((options) => {
472
+ if (options.groupBy && options.groupBy !== 'device') {
473
+ console.error(chalk.red(`Unsupported --group-by '${options.groupBy}'. Use: device`));
474
+ process.exit(1);
475
+ }
312
476
  try {
313
477
  monitorRunningJobs();
314
478
  }
@@ -339,7 +503,6 @@ export function registerRoutinesCommands(program) {
339
503
  if (options.json) {
340
504
  const nowJson = new Date();
341
505
  const payload = jobs.map((job) => {
342
- const nextRun = scheduler.getNextRun(job.name);
343
506
  const latestRun = getLatestRun(job.name);
344
507
  return {
345
508
  name: job.name,
@@ -357,11 +520,14 @@ export function registerRoutinesCommands(program) {
357
520
  source: job.source ?? null,
358
521
  sourceRepo: job.source?.repo ?? job.repo ?? null,
359
522
  sourceBranch: job.source?.branch ?? null,
523
+ runOnce: Boolean(job.runOnce),
524
+ oneShot: isOneShotRoutine(job),
525
+ expired: isPastOneShotRoutine(job, nowJson),
360
526
  runsHere: jobRunsOnThisDevice(job),
361
527
  enabled: job.enabled,
362
528
  overdue: overdueSet.has(job.name),
363
- nextRun: nextRun ? nextRun.toISOString() : null,
364
- nextRunHuman: humanizeNextRun(nextRun ?? null, nowJson, job.timezone),
529
+ nextRun: nextRunForDisplay(job, scheduler)?.toISOString() ?? null,
530
+ nextRunHuman: nextRunLabel(job, scheduler, nowJson),
365
531
  lastStatus: latestRun?.status ?? null,
366
532
  exitCode: latestRun?.exitCode ?? null,
367
533
  failureReason: latestRun?.errorMessage ?? null,
@@ -379,76 +545,21 @@ export function registerRoutinesCommands(program) {
379
545
  // contains raw ESC ] 8 ;; ... BEL escape sequences.
380
546
  const link = (label, url) => url && process.stdout.isTTY ? `\x1b]8;;${url}\x07${label}\x1b]8;;\x07` : label;
381
547
  const now = new Date();
382
- const NAME_W = 24;
383
- const AGENT_W = 10;
384
- const REPO_W = REPO_DISPLAY_MAX;
385
- const DEVICE_W = 22;
386
- const SCHED_W = 22;
387
- const ENABLED_W = 10;
388
- const NEXT_W = 22;
389
- const header = ` ${'Name'.padEnd(NAME_W)} ${'Agent'.padEnd(AGENT_W)} ${'Repo'.padEnd(REPO_W)} ${'Devices'.padEnd(DEVICE_W)} ${'Schedule'.padEnd(SCHED_W)} ${'Enabled'.padEnd(ENABLED_W)} ${'Next Run'.padEnd(NEXT_W)} Last Status`;
390
- console.log(chalk.gray(header));
391
- console.log(chalk.gray(' ' + '-'.repeat(NAME_W + AGENT_W + REPO_W + DEVICE_W + SCHED_W + ENABLED_W + NEXT_W + 20)));
392
- for (const job of jobs) {
393
- const nextRun = scheduler.getNextRun(job.name);
394
- const nextStr = humanizeNextRun(nextRun ?? null, now, job.timezone);
395
- let schedStr = fireConditionLabel(job);
396
- if (job.endAt) {
397
- const end = new Date(job.endAt);
398
- const endLabel = Number.isFinite(end.getTime())
399
- ? end.toLocaleDateString()
400
- : job.endAt;
401
- schedStr = `${schedStr} (until ${endLabel})`;
402
- }
403
- const latestRun = getLatestRun(job.name);
404
- const lastStatus = latestRun?.status || '-';
405
- // Prefer project-source repo (with optional @branch) over bare job.repo.
406
- const sourceRepo = job.source?.repo ?? job.repo;
407
- const sourceLabel = sourceRepo
408
- ? (job.source?.branch ? `${sourceRepo}@${job.source.branch}` : sourceRepo)
409
- : null;
410
- const repoInfo = formatRepoLink(sourceLabel ?? job.repo);
411
- const repoCell = link(repoInfo.display, repoInfo.href);
412
- // Pad based on the display string, not the raw cell (which may include escape codes).
413
- const repoPadding = Math.max(0, REPO_W - repoInfo.display.length);
414
- const enabledStr = job.enabled ? chalk.green('yes') : chalk.gray('no');
415
- // chalk adds escape codes; pad the raw word and let chalk wrap it.
416
- const enabledWord = job.enabled ? 'yes' : 'no';
417
- const enabledPad = Math.max(0, ENABLED_W - enabledWord.length);
418
- // Placement rides in the Devices cell: eligibility →execution strategy.
419
- const strategy = resolveHostStrategy(job);
420
- const placementTag = strategy === 'local'
421
- ? (job.host ? `→${job.host}` : '')
422
- : strategy === 'host'
423
- ? `→${job.host ?? '?'}`
424
- : strategy === 'fleet'
425
- ? '→fleet'
426
- : '→cloud';
427
- const deviceFull = [job.devices?.join(',') ?? '', placementTag]
428
- .filter(Boolean)
429
- .join(' ');
430
- const deviceWord = deviceFull.length === 0
431
- ? 'all'
432
- : deviceFull.length > DEVICE_W
433
- ? deviceFull.slice(0, DEVICE_W - 1) + '…'
434
- : deviceFull;
435
- const deviceCell = deviceFull.length === 0
436
- ? chalk.gray('all')
437
- : jobRunsOnThisDevice(job)
438
- ? deviceWord
439
- : chalk.gray(deviceWord);
440
- const devicePad = Math.max(0, DEVICE_W - deviceWord.length);
441
- const statusColor = lastStatus === 'completed' ? chalk.green
442
- : lastStatus === 'failed' ? chalk.red
443
- : lastStatus === 'timeout' ? chalk.yellow
444
- : chalk.gray;
445
- const overdueTag = overdueSet.has(job.name) ? chalk.yellow(' (overdue)') : '';
446
- const agentLabelPadded = job.command
447
- ? chalk.magenta('command'.padEnd(10))
448
- : job.workflow
449
- ? chalk.magenta(`wf:${job.workflow}`.padEnd(10))
450
- : (job.agent || '').padEnd(10);
451
- console.log(` ${chalk.cyan(job.name.padEnd(NAME_W))} ${agentLabelPadded} ${repoCell}${' '.repeat(repoPadding)} ${deviceCell}${' '.repeat(devicePad)} ${schedStr.padEnd(SCHED_W)} ${enabledStr}${' '.repeat(enabledPad)} ${chalk.gray(nextStr.padEnd(NEXT_W))} ${statusColor(lastStatus)}${overdueTag}`);
548
+ if (options.flat) {
549
+ renderRoutineRows({ jobs, scheduler, overdueSet, link, now });
550
+ }
551
+ else {
552
+ let registry = {};
553
+ try {
554
+ registry = loadDevicesSync();
555
+ }
556
+ catch (err) {
557
+ console.error(chalk.yellow(`Could not read device registry: ${err.message}`));
558
+ }
559
+ for (const group of groupRoutineJobsByDevice(jobs, registry)) {
560
+ console.log(chalk.bold(`\n${group.title}`));
561
+ renderRoutineRows({ jobs: group.jobs, scheduler, overdueSet, link, now });
562
+ }
452
563
  }
453
564
  if (overdueSet.size > 0) {
454
565
  console.log();
@@ -520,6 +631,10 @@ export function registerRoutinesCommands(program) {
520
631
  schedule = parsed.schedule;
521
632
  runOnce = parsed.runOnce;
522
633
  }
634
+ if (!options.at && isOneShotLikeSchedule(schedule)) {
635
+ runOnce = true;
636
+ console.error(chalk.yellow(`Schedule "${schedule}" pins minute, hour, day, and month; treating it as one-shot. Prefer --at for one-time routines.`));
637
+ }
523
638
  if (!schedule && !trigger) {
524
639
  console.error(chalk.red('Schedule or trigger is required (use --schedule, --at, or --on)'));
525
640
  process.exit(1);
@@ -661,6 +776,10 @@ export function registerRoutinesCommands(program) {
661
776
  enabled: true,
662
777
  ...parsed,
663
778
  };
779
+ if (isOneShotLikeSchedule(config.schedule)) {
780
+ config.runOnce = true;
781
+ console.error(chalk.yellow(`Schedule "${config.schedule}" pins minute, hour, day, and month; treating it as one-shot. Prefer --at for one-time routines.`));
782
+ }
664
783
  // Same duplicate-fire guard as --placement/--run-on: off-box placement
665
784
  // with no eligibility pin would fire from every daemon in the fleet.
666
785
  const fileStrategy = resolveHostStrategy(config);
@@ -688,6 +807,41 @@ export function registerRoutinesCommands(program) {
688
807
  ensureSchedulerRunning();
689
808
  }
690
809
  });
810
+ routinesCmd
811
+ .command('cleanup')
812
+ .description('Remove expired one-shot routines that already fired and still have a user-layer YAML file.')
813
+ .option('--dry-run', 'Show routines that would be removed without deleting files')
814
+ .action((options) => {
815
+ const jobs = listAllJobs()
816
+ .filter((job) => getJobPath(job.name) !== null)
817
+ .filter((job) => hasCompletedOneShotRun(job));
818
+ if (jobs.length === 0) {
819
+ console.log(chalk.gray('No completed expired one-shot routines to clean up.'));
820
+ return;
821
+ }
822
+ if (options.dryRun) {
823
+ console.log(chalk.bold('Expired one-shot routines eligible for cleanup\n'));
824
+ for (const job of jobs) {
825
+ console.log(` ${chalk.cyan(job.name)} ${chalk.gray(scheduleLabel(job))}`);
826
+ }
827
+ console.log(chalk.gray(`\nDry run. Remove with: agents routines cleanup`));
828
+ return;
829
+ }
830
+ let removed = 0;
831
+ for (const job of jobs) {
832
+ if (deleteJob(job.name)) {
833
+ removed++;
834
+ console.log(chalk.green(`Removed ${job.name}`));
835
+ }
836
+ }
837
+ console.log(chalk.gray(`Cleaned up ${removed} expired one-shot routine(s).`));
838
+ try {
839
+ signalDaemonReload();
840
+ }
841
+ catch {
842
+ // The daemon may not be running; the next start will read the cleaned directory.
843
+ }
844
+ });
691
845
  routinesCmd
692
846
  .command('remove [name]')
693
847
  .description('Delete a routine. Stops scheduling future runs; past execution logs remain on disk.')
@@ -3,6 +3,66 @@ import type { SessionAgentId, SessionMeta, ViewMode } from '../lib/session/types
3
3
  import { type ActiveSession } from '../lib/session/active.js';
4
4
  import { gatherRemoteList, runOnPeer } from '../lib/session/remote-list.js';
5
5
  import { type PickedSession } from './sessions-picker.js';
6
+ interface SessionFilterOptions {
7
+ agent?: string;
8
+ project?: string;
9
+ all?: boolean;
10
+ teams?: boolean;
11
+ routine?: boolean;
12
+ since?: string;
13
+ until?: string;
14
+ }
15
+ interface SessionsOptions extends SessionFilterOptions {
16
+ /** Also list sessions from the user's own unmanaged ~/.<agent> installs. */
17
+ unmanaged?: boolean;
18
+ query?: string;
19
+ limit?: string;
20
+ sort?: string;
21
+ json?: boolean;
22
+ markdown?: boolean;
23
+ /** Commander populates this from `--no-redact`: true by default, false when the flag is passed. */
24
+ redact?: boolean;
25
+ include?: string;
26
+ exclude?: string;
27
+ first?: string;
28
+ last?: string;
29
+ artifacts?: boolean;
30
+ artifact?: string;
31
+ active?: boolean;
32
+ /** Emit the on-disk session-scan directories (requires --json); for watchers. */
33
+ roots?: boolean;
34
+ cloud?: boolean;
35
+ host?: string[];
36
+ /** Group the listing by directory and drop the id/version columns. */
37
+ tree?: boolean;
38
+ /** Force the plain flat table instead of the grouped default overview. */
39
+ flat?: boolean;
40
+ /** With --active: show only sessions waiting on user input; exit 1 if any. */
41
+ waiting?: boolean;
42
+ /** Enrich the listing with live glyphs/preview for running rows. Default on;
43
+ * `--no-live` sets this false. Commander's `--no-` convention. */
44
+ live?: boolean;
45
+ /** Force local-only: skip the cross-machine SSH fan-out (both the default
46
+ * listing and --active). */
47
+ local?: boolean;
48
+ /** --device <target...> — alias for --host; resolves against the device registry. */
49
+ device?: string[];
50
+ /** Per-agent shorthands: aliases for `--agent <name>` (prioritized harnesses). */
51
+ claude?: boolean;
52
+ codex?: boolean;
53
+ kimi?: boolean;
54
+ antigravity?: boolean;
55
+ grok?: boolean;
56
+ opencode?: boolean;
57
+ /** Force the printed listing even on a TTY. Commander's `--no-` convention:
58
+ * `--no-interactive` sets this false, opting out of the interactive browser. */
59
+ interactive?: boolean;
60
+ /** Print the canonical `ag sessions …` command for the given flags and exit —
61
+ * the non-interactive twin of the browser's `y` hotkey. */
62
+ printCmd?: boolean;
63
+ /** Print a compact preview of the matched session and exit (no pager). */
64
+ preview?: boolean;
65
+ }
6
66
  /**
7
67
  * Strip terminal/harness noise from a preview so the column stays a single line
8
68
  * of plain prose: OSC title escapes, CSI/SGR ANSI, and the harness wrapper tags
@@ -10,6 +70,16 @@ import { type PickedSession } from './sessions-picker.js';
10
70
  * a captured transcript tail. Collapses runs of whitespace.
11
71
  */
12
72
  export declare function cleanPreview(text: string): string;
73
+ /**
74
+ * Build the live description for an active session: checklist progress (when
75
+ * present) plus the state engine's preview (the latest turn), a user label, or
76
+ * the first-prompt topic. Used by both the flat listing's `doing` cell and as
77
+ * the snippet half of the --active row (identity is layered on in printActiveRow).
78
+ *
79
+ * Covers every ActiveSession context: terminal (interactive), headless, teams,
80
+ * cloud, and sub-agent rows that share the same ActiveSession.todos field.
81
+ */
82
+ export declare function buildSessionDescription(s: ActiveSession): string;
13
83
  /**
14
84
  * Identity + checklist + live snippet for an --active / cross-machine row.
15
85
  * Surfaces agent-adjacent identity the flat table already has (label, project)
@@ -190,6 +260,20 @@ export declare function gatherActiveSessions(opts?: {
190
260
  sessions: ActiveSession[];
191
261
  remoteDeviceCount: number;
192
262
  }>;
263
+ /**
264
+ * A bare interactive fleet listing — no query, no render/filter flag — that the
265
+ * `runSessionBrowser` picker can represent. The single predicate shared by the
266
+ * bare-browser branch and the `--host` early-return guard so they can't drift:
267
+ * when this holds, an explicit `--host`/`--device` scope is folded into the
268
+ * browser (preview-rich, selectable) instead of the legacy per-host raw stream.
269
+ */
270
+ export declare function isBareBrowserListing(options: SessionsOptions, query: string | undefined): boolean;
271
+ /**
272
+ * Pure flag-gate half of {@link isBareBrowserListing} (TTY-independent, so it is
273
+ * unit-testable): true when no query, render, or filter flag is present that the
274
+ * `runSessionBrowser` picker cannot represent.
275
+ */
276
+ export declare function hasNoBrowserDisqualifyingFlags(options: SessionsOptions, query: string | undefined): boolean;
193
277
  /** One flat table row:
194
278
  * shortId · agent · version · model · project · [glyph] label·doing · [ticket] · [wt] · time
195
279
  * `doing` is the live preview when running, else the topic. The `ticket` column
@@ -240,22 +240,33 @@ export function cleanPreview(text) {
240
240
  * Covers every ActiveSession context: terminal (interactive), headless, teams,
241
241
  * cloud, and sub-agent rows that share the same ActiveSession.todos field.
242
242
  */
243
- function buildSessionDescription(s) {
243
+ export function buildSessionDescription(s) {
244
244
  const todo = formatTodoCompact(s.todos);
245
245
  if (s.context === 'cloud') {
246
246
  const base = s.preview || `${s.cloudProvider ?? ''}${s.cloudTaskId ? ` · ${s.cloudTaskId.slice(0, 12)}` : ''}`;
247
247
  return cleanPreview([todo, base].filter(Boolean).join(' · '));
248
248
  }
249
249
  if (s.context === 'teams') {
250
+ // A teams row identifies its TEAM, then the teammate within it, then who
251
+ // spun it up, then what it's working on — so several teams from one
252
+ // orchestrator stay distinct and each shows its target, not just its slug.
250
253
  const parts = [s.teamName];
254
+ // Teammate name (distinct from the team slug) — which member this row is.
255
+ if (s.label && s.label !== s.teamName)
256
+ parts.push(s.label);
257
+ // Lineage: which orchestrator spun up this team. Prefer the resolved label,
258
+ // else the short session id, so "by whom" is answerable at a glance.
259
+ const orch = s.orchestratorLabel || (s.orchestratorSessionId ? s.orchestratorSessionId.slice(0, 8) : '');
260
+ if (orch)
261
+ parts.push(`by ${orch}`);
251
262
  if (todo)
252
263
  parts.push(todo);
253
- if (s.preview)
254
- parts.push(s.preview);
255
- else if (s.label)
256
- parts.push(s.label);
257
- else if (s.topic)
258
- parts.push(s.topic);
264
+ // Target: the live latest turn if working, else the assigned mission (the
265
+ // team's task/target, shown even before the teammate has a transcript), else
266
+ // the transcript topic.
267
+ const target = s.preview || s.assignedTask || s.topic;
268
+ if (target)
269
+ parts.push(target);
259
270
  return cleanPreview(parts.filter(Boolean).join(' · '));
260
271
  }
261
272
  // Terminal, headless, or sub-agent: todos + live preview, then label, then topic.
@@ -997,6 +1008,33 @@ function printCrossMachineTip() {
997
1008
  function useInteractiveBrowser(options) {
998
1009
  return options.interactive !== false && !options.json && isInteractiveTerminal();
999
1010
  }
1011
+ /**
1012
+ * A bare interactive fleet listing — no query, no render/filter flag — that the
1013
+ * `runSessionBrowser` picker can represent. The single predicate shared by the
1014
+ * bare-browser branch and the `--host` early-return guard so they can't drift:
1015
+ * when this holds, an explicit `--host`/`--device` scope is folded into the
1016
+ * browser (preview-rich, selectable) instead of the legacy per-host raw stream.
1017
+ */
1018
+ export function isBareBrowserListing(options, query) {
1019
+ return useInteractiveBrowser(options) && hasNoBrowserDisqualifyingFlags(options, query);
1020
+ }
1021
+ /**
1022
+ * Pure flag-gate half of {@link isBareBrowserListing} (TTY-independent, so it is
1023
+ * unit-testable): true when no query, render, or filter flag is present that the
1024
+ * `runSessionBrowser` picker cannot represent.
1025
+ */
1026
+ export function hasNoBrowserDisqualifyingFlags(options, query) {
1027
+ return (!query &&
1028
+ !options.routine &&
1029
+ !options.flat &&
1030
+ !options.tree &&
1031
+ !options.markdown &&
1032
+ !options.until &&
1033
+ !options.project &&
1034
+ !options.sort &&
1035
+ !options.artifacts &&
1036
+ options.artifact === undefined);
1037
+ }
1000
1038
  /** The canonical `ag sessions …` command for a set of flags — the twin of the
1001
1039
  * browser's `y` hotkey (see --print-cmd). Normalizes to the stable flag form. */
1002
1040
  function canonicalSessionsCommand(query, options) {
@@ -1099,14 +1137,21 @@ async function sessionsAction(query, options) {
1099
1137
  await runRemoteSessionsJson(options.host);
1100
1138
  return;
1101
1139
  }
1102
- try {
1103
- runRemoteSessions(options.host);
1104
- }
1105
- catch (err) {
1106
- console.error(chalk.red(err.message));
1107
- process.exit(1);
1140
+ // A bare interactive `--host`/`--device <box>` listing falls through to the
1141
+ // fleet browser below, which folds the named host(s) into the same merged,
1142
+ // preview-rich, selectable view as the local listing (via gatherRemoteList).
1143
+ // A query, a render/filter flag, or a non-interactive caller keeps the legacy
1144
+ // per-host raw stream under a `── host ──` banner.
1145
+ if (!isBareBrowserListing(options, query)) {
1146
+ try {
1147
+ runRemoteSessions(options.host);
1148
+ }
1149
+ catch (err) {
1150
+ console.error(chalk.red(err.message));
1151
+ process.exit(1);
1152
+ }
1153
+ return;
1108
1154
  }
1109
- return;
1110
1155
  }
1111
1156
  // --preview <id/query>: resolve one session and print its compact preview, then
1112
1157
  // exit — checked before --active so `--active --preview <id>` peeks the id
@@ -1160,17 +1205,7 @@ async function sessionsAction(query, options) {
1160
1205
  // filter the browser can't represent), or --no-interactive keep the existing
1161
1206
  // printed/render paths (agents and scripts unaffected). An explicit --since seeds
1162
1207
  // the browser's window so the flag is honored, not swallowed.
1163
- if (useInteractiveBrowser(options) &&
1164
- !query &&
1165
- !options.routine &&
1166
- !options.flat &&
1167
- !options.tree &&
1168
- !options.markdown &&
1169
- !options.until &&
1170
- !options.project &&
1171
- !options.sort &&
1172
- !options.artifacts &&
1173
- options.artifact === undefined) {
1208
+ if (isBareBrowserListing(options, query)) {
1174
1209
  const { runSessionBrowser, bareBrowserSeed } = await import('./sessions-browser.js');
1175
1210
  await runSessionBrowser(bareBrowserSeed({
1176
1211
  teams: options.teams,