@phnx-labs/agents-cli 1.20.77 → 1.20.82
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.
- package/CHANGELOG.md +589 -0
- package/README.md +27 -25
- package/dist/bin/agents +0 -0
- package/dist/commands/activity.d.ts +9 -0
- package/dist/commands/activity.js +153 -24
- package/dist/commands/apply.js +11 -4
- package/dist/commands/cli.js +1 -1
- package/dist/commands/commands.d.ts +1 -1
- package/dist/commands/commands.js +7 -6
- package/dist/commands/defaults.js +3 -11
- package/dist/commands/doctor.d.ts +74 -0
- package/dist/commands/doctor.js +718 -40
- package/dist/commands/events.js +1 -1
- package/dist/commands/exec.js +53 -8
- package/dist/commands/feed.d.ts +4 -0
- package/dist/commands/feed.js +87 -11
- package/dist/commands/harness.js +2 -2
- package/dist/commands/hooks.js +1 -1
- package/dist/commands/import.js +8 -4
- package/dist/commands/logs.js +6 -0
- package/dist/commands/models.js +1 -1
- package/dist/commands/packages.js +6 -6
- package/dist/commands/permissions.js +1 -1
- package/dist/commands/profiles.js +1 -1
- package/dist/commands/pull.d.ts +1 -1
- package/dist/commands/pull.js +4 -4
- package/dist/commands/repo.d.ts +78 -0
- package/dist/commands/repo.js +195 -11
- package/dist/commands/routines.js +16 -4
- package/dist/commands/rules.js +13 -12
- package/dist/commands/secrets.d.ts +3 -1
- package/dist/commands/secrets.js +108 -34
- package/dist/commands/sessions-browser.d.ts +45 -3
- package/dist/commands/sessions-browser.js +118 -12
- package/dist/commands/sessions-export.d.ts +3 -0
- package/dist/commands/sessions-export.js +13 -10
- package/dist/commands/sessions-inject.d.ts +7 -4
- package/dist/commands/sessions-inject.js +18 -18
- package/dist/commands/sessions-picker.d.ts +15 -1
- package/dist/commands/sessions-picker.js +90 -7
- package/dist/commands/sessions-resume.d.ts +16 -0
- package/dist/commands/sessions-resume.js +14 -13
- package/dist/commands/sessions.d.ts +126 -1
- package/dist/commands/sessions.js +365 -42
- package/dist/commands/set.d.ts +15 -0
- package/dist/commands/set.js +79 -0
- package/dist/commands/setup-fleet.d.ts +21 -0
- package/dist/commands/setup-fleet.js +201 -0
- package/dist/commands/setup-secrets.d.ts +20 -0
- package/dist/commands/setup-secrets.js +223 -0
- package/dist/commands/setup.js +17 -3
- package/dist/commands/skills.js +1 -1
- package/dist/commands/ssh.js +9 -1
- package/dist/commands/sync.js +7 -2
- package/dist/commands/teams-picker.js +0 -1
- package/dist/commands/teams.d.ts +33 -1
- package/dist/commands/teams.js +208 -44
- package/dist/commands/versions.js +10 -5
- package/dist/commands/view.d.ts +1 -0
- package/dist/commands/view.js +11 -0
- package/dist/index.js +8 -13
- package/dist/lib/acp/harnesses.js +0 -7
- package/dist/lib/activity.d.ts +156 -0
- package/dist/lib/activity.js +282 -0
- package/dist/lib/agents.d.ts +9 -23
- package/dist/lib/agents.js +72 -100
- package/dist/lib/auto-pull.d.ts +16 -8
- package/dist/lib/auto-pull.js +23 -28
- package/dist/lib/browser/service.js +3 -0
- package/dist/lib/browser/types.d.ts +7 -0
- package/dist/lib/capabilities.js +6 -2
- package/dist/lib/crabbox/lease.js +2 -2
- package/dist/lib/crabbox/setup-copy.d.ts +4 -4
- package/dist/lib/crabbox/setup-copy.js +4 -4
- package/dist/lib/daemon.d.ts +26 -40
- package/dist/lib/daemon.js +63 -123
- package/dist/lib/devices/fleet-divergence.d.ts +101 -0
- package/dist/lib/devices/fleet-divergence.js +188 -0
- package/dist/lib/devices/fleet-inventory.d.ts +19 -0
- package/dist/lib/devices/fleet-inventory.js +57 -0
- package/dist/lib/devices/fleet.d.ts +1 -1
- package/dist/lib/devices/fleet.js +1 -1
- package/dist/lib/devices/health-report.d.ts +10 -2
- package/dist/lib/devices/health-report.js +32 -1
- package/dist/lib/doctor-diff.d.ts +20 -0
- package/dist/lib/doctor-diff.js +6 -1
- package/dist/lib/drift.d.ts +31 -11
- package/dist/lib/drift.js +58 -7
- package/dist/lib/events.d.ts +8 -1
- package/dist/lib/events.js +10 -1
- package/dist/lib/exec.js +35 -8
- package/dist/lib/feed-post.d.ts +28 -1
- package/dist/lib/feed-post.js +110 -2
- package/dist/lib/fleet/apply.js +8 -1
- package/dist/lib/fleet/auth-sync.d.ts +18 -1
- package/dist/lib/fleet/auth-sync.js +25 -11
- package/dist/lib/fleet/remote-login.js +5 -0
- package/dist/lib/git.d.ts +27 -14
- package/dist/lib/git.js +138 -43
- package/dist/lib/hooks.d.ts +42 -0
- package/dist/lib/hooks.js +137 -93
- package/dist/lib/hosts/dispatch.d.ts +28 -1
- package/dist/lib/hosts/dispatch.js +33 -5
- package/dist/lib/hosts/option.js +2 -2
- package/dist/lib/hosts/passthrough.d.ts +22 -1
- package/dist/lib/hosts/passthrough.js +238 -13
- package/dist/lib/hosts/remote-cmd.js +2 -0
- package/dist/lib/hosts/run-target.d.ts +2 -0
- package/dist/lib/hosts/run-target.js +1 -0
- package/dist/lib/hq/floor.d.ts +1 -1
- package/dist/lib/hq/floor.js +6 -1
- package/dist/lib/mcp.js +3 -71
- package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +2 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
- package/dist/lib/menubar/notify-desktop.d.ts +15 -0
- package/dist/lib/menubar/notify-desktop.js +41 -5
- package/dist/lib/merged-resources.d.ts +11 -0
- package/dist/{commands/resources.js → lib/merged-resources.js} +11 -19
- package/dist/lib/permissions.d.ts +0 -32
- package/dist/lib/permissions.js +4 -157
- package/dist/lib/picker.js +1 -1
- package/dist/lib/platform/process.d.ts +16 -5
- package/dist/lib/platform/process.js +54 -0
- package/dist/lib/plugins.d.ts +0 -8
- package/dist/lib/plugins.js +4 -110
- package/dist/lib/project-resources.js +4 -1
- package/dist/lib/refresh.d.ts +5 -3
- package/dist/lib/refresh.js +7 -5
- package/dist/lib/resources/commands.js +4 -1
- package/dist/lib/resources/mcp.js +0 -4
- package/dist/lib/resources/permissions.d.ts +1 -1
- package/dist/lib/resources/permissions.js +1 -5
- package/dist/lib/resources/rules.js +4 -1
- package/dist/lib/resources/skills.js +4 -1
- package/dist/lib/resources/subagents.js +4 -0
- package/dist/lib/resources/types.d.ts +1 -1
- package/dist/lib/routines.d.ts +20 -0
- package/dist/lib/routines.js +6 -0
- package/dist/lib/run-defaults.d.ts +1 -0
- package/dist/lib/run-defaults.js +9 -0
- package/dist/lib/runner.d.ts +0 -12
- package/dist/lib/runner.js +71 -29
- package/dist/lib/sandbox.js +0 -16
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
- package/dist/lib/secrets/agent.d.ts +18 -0
- package/dist/lib/secrets/agent.js +131 -17
- package/dist/lib/secrets/audit.d.ts +46 -0
- package/dist/lib/secrets/audit.js +56 -0
- package/dist/lib/secrets/bundles.d.ts +12 -9
- package/dist/lib/secrets/bundles.js +47 -31
- package/dist/lib/secrets/index.d.ts +2 -1
- package/dist/lib/secrets/index.js +8 -2
- package/dist/lib/secrets/remote.d.ts +75 -2
- package/dist/lib/secrets/remote.js +139 -5
- package/dist/lib/secrets/scope.d.ts +26 -0
- package/dist/lib/secrets/scope.js +29 -0
- package/dist/lib/secrets/session-store.d.ts +10 -0
- package/dist/lib/secrets/session-store.js +64 -11
- package/dist/lib/secrets/sync.js +3 -3
- package/dist/lib/session/active.d.ts +117 -26
- package/dist/lib/session/active.js +199 -53
- package/dist/lib/session/actor-sidecar.d.ts +23 -0
- package/dist/lib/session/actor-sidecar.js +101 -0
- package/dist/lib/session/db.d.ts +41 -1
- package/dist/lib/session/db.js +173 -8
- package/dist/lib/session/digest.js +126 -21
- package/dist/lib/session/discover.d.ts +19 -2
- package/dist/lib/session/discover.js +32 -11
- package/dist/lib/session/hook-sessions.d.ts +9 -0
- package/dist/lib/session/hook-sessions.js +57 -8
- package/dist/lib/session/origin-machine.d.ts +18 -0
- package/dist/lib/session/origin-machine.js +34 -0
- package/dist/lib/session/pid-registry.d.ts +13 -0
- package/dist/lib/session/render.d.ts +2 -0
- package/dist/lib/session/render.js +1 -1
- package/dist/lib/session/state.d.ts +1 -0
- package/dist/lib/session/state.js +1 -1
- package/dist/lib/session/sync/config.js +2 -2
- package/dist/lib/session/types.d.ts +16 -0
- package/dist/lib/smart-launch.d.ts +86 -0
- package/dist/lib/smart-launch.js +172 -0
- package/dist/lib/staleness/detectors/commands.js +2 -2
- package/dist/lib/staleness/detectors/permissions.js +1 -45
- package/dist/lib/staleness/writers/commands.js +4 -5
- package/dist/lib/staleness/writers/hooks.js +1 -1
- package/dist/lib/startup/command-registry.d.ts +1 -2
- package/dist/lib/startup/command-registry.js +2 -4
- package/dist/lib/subagents-registry.js +5 -12
- package/dist/lib/subagents.d.ts +0 -10
- package/dist/lib/subagents.js +0 -12
- package/dist/lib/teams/agents.d.ts +0 -2
- package/dist/lib/teams/agents.js +10 -27
- package/dist/lib/teams/index.d.ts +1 -1
- package/dist/lib/teams/index.js +1 -1
- package/dist/lib/types.d.ts +17 -9
- package/dist/lib/usage.d.ts +28 -5
- package/dist/lib/usage.js +271 -8
- package/dist/lib/versions.js +7 -1
- package/dist/lib/watchdog/watchdog.d.ts +1 -1
- package/dist/lib/watchdog/watchdog.js +25 -13
- package/package.json +1 -2
- package/dist/commands/check.d.ts +0 -15
- package/dist/commands/check.js +0 -180
- package/dist/commands/resources.d.ts +0 -5
- package/dist/lib/secrets/account-token.d.ts +0 -20
- package/dist/lib/secrets/account-token.js +0 -64
|
@@ -26,13 +26,13 @@ import { gatherRemoteActive, NO_FANOUT_ENV } from '../lib/session/remote-active.
|
|
|
26
26
|
import { gatherRemoteList, runOnPeer } from '../lib/session/remote-list.js';
|
|
27
27
|
import { stringWidth, truncateToWidth, padToWidth, terminalWidth } from '../lib/session/width.js';
|
|
28
28
|
import { inferSessionState } from '../lib/session/state.js';
|
|
29
|
-
import { discoverSessions, countSessionsInScope, resolveSessionById, isCompleteSessionId, searchContentIndex, getSessionRoots } from '../lib/session/discover.js';
|
|
29
|
+
import { discoverSessions, countSessionsInScope, resolveSessionById, isCompleteSessionId, looksLikeSessionId, searchContentIndex, getSessionRoots } from '../lib/session/discover.js';
|
|
30
30
|
import { findSessionsById } from '../lib/session/db.js';
|
|
31
31
|
import { filterTeamSessions } from '../lib/session/team-filter.js';
|
|
32
32
|
import { parseSession } from '../lib/session/parse.js';
|
|
33
33
|
import { runRemoteSessions, buildForwardedArgs, ensureWholeIndex } from '../lib/session/remote.js';
|
|
34
34
|
import { formatRelativeTime } from '../lib/session/relative-time.js';
|
|
35
|
-
import { renderConversationMarkdown, renderSummary, renderSummaryHeader, computeSummaryStats, renderJson, filterEvents, parseRoleList, linkUrl } from '../lib/session/render.js';
|
|
35
|
+
import { renderConversationMarkdown, renderSummary, renderSummaryHeader, computeSummaryStats, renderJson, filterEvents, parseRoleList, linkPath, linkUrl, shortenModel } from '../lib/session/render.js';
|
|
36
36
|
import { linearIssueUrl } from '../lib/session/linear.js';
|
|
37
37
|
import { renderMarkdown } from '../lib/markdown.js';
|
|
38
38
|
import { AGENTS, colorAgent, resolveAgentName } from '../lib/agents.js';
|
|
@@ -182,6 +182,11 @@ function statusColor(status) {
|
|
|
182
182
|
case 'idle': return chalk.gray;
|
|
183
183
|
case 'queued': return chalk.blue;
|
|
184
184
|
case 'input_required': return chalk.yellow;
|
|
185
|
+
// Dead process: dimmed so it recedes from the live rows without being mistaken
|
|
186
|
+
// for the gray `idle` (which reads as "done, waiting for you" — a live state).
|
|
187
|
+
case 'closed': return chalk.dim;
|
|
188
|
+
// Days-stale / dangling: red so a session nobody is driving stands out.
|
|
189
|
+
case 'abandoned': return chalk.red;
|
|
185
190
|
// Alive but un-introspectable (a harness whose transcript we can't parse).
|
|
186
191
|
// Magenta so it never reads as the gray "idle" it used to be faked as.
|
|
187
192
|
case 'unknown': return chalk.magenta;
|
|
@@ -303,6 +308,10 @@ export function formatActiveRowDescription(s) {
|
|
|
303
308
|
}
|
|
304
309
|
/** Short human word for a session's activity (falls back to the coarse status). */
|
|
305
310
|
function activityLabel(s) {
|
|
311
|
+
// Lifecycle status wins over any residual parsed activity — a dead/dangling
|
|
312
|
+
// session must read `closed`/`abandoned`, not the `idle` its stale tail infers.
|
|
313
|
+
if (s.status === 'closed' || s.status === 'abandoned')
|
|
314
|
+
return s.status;
|
|
306
315
|
if (s.activity === 'waiting_input')
|
|
307
316
|
return 'waiting';
|
|
308
317
|
if (s.activity === 'working')
|
|
@@ -335,14 +344,54 @@ export function indexActiveBySessionId(active) {
|
|
|
335
344
|
export function liveGlyphAndPreview(a) {
|
|
336
345
|
if (!a)
|
|
337
346
|
return { glyph: '', preview: '' };
|
|
347
|
+
// `◌` (dotted) = alive but un-introspectable — visually distinct from `○` idle
|
|
348
|
+
// so an opaque harness is never mistaken for a finished one. `⊘` = abandoned /
|
|
349
|
+
// dangling; `×` = closed (dead pid) — both distinct from the `○` idle a live,
|
|
350
|
+
// stopped session shows, so a gone session never masquerades as a resting one.
|
|
351
|
+
if (a.status === 'abandoned')
|
|
352
|
+
return { glyph: statusColor(a.status)('⊘'), preview: buildSessionDescription(a) };
|
|
353
|
+
if (a.status === 'closed')
|
|
354
|
+
return { glyph: statusColor(a.status)('×'), preview: buildSessionDescription(a) };
|
|
338
355
|
const waiting = a.status === 'input_required' || a.activity === 'waiting_input';
|
|
339
356
|
const running = a.status === 'running' || a.activity === 'working';
|
|
340
|
-
// `◌` (dotted) = alive but un-introspectable — visually distinct from `○` idle
|
|
341
|
-
// so an opaque harness is never mistaken for a finished one.
|
|
342
357
|
const unknown = a.status === 'unknown';
|
|
343
|
-
const shape = waiting ? '◐'
|
|
358
|
+
const shape = waiting ? '◐'
|
|
359
|
+
: running ? '●'
|
|
360
|
+
: unknown ? '◌'
|
|
361
|
+
: '○';
|
|
344
362
|
return { glyph: statusColor(a.status)(shape), preview: buildSessionDescription(a) };
|
|
345
363
|
}
|
|
364
|
+
/**
|
|
365
|
+
* The one-word live status for a listing row — `working` / `waiting` / `idle`,
|
|
366
|
+
* the same three states the `--active` column shows, so the default list is no
|
|
367
|
+
* longer just a glyph. `waiting` is the actionable "needs you" case (a question /
|
|
368
|
+
* permission / plan-review), kept distinct from `idle` (stopped) and `working`.
|
|
369
|
+
* Empty for a not-live row, and for the rare no-signal `unknown`. Pure +
|
|
370
|
+
* exported for the row tests.
|
|
371
|
+
*/
|
|
372
|
+
export function liveStatusWord(a) {
|
|
373
|
+
if (!a)
|
|
374
|
+
return '';
|
|
375
|
+
// Lifecycle status is definitive — surface it ahead of any parsed activity.
|
|
376
|
+
if (a.status === 'closed' || a.status === 'abandoned')
|
|
377
|
+
return a.status;
|
|
378
|
+
if (a.status === 'input_required' || a.activity === 'waiting_input')
|
|
379
|
+
return 'waiting';
|
|
380
|
+
if (a.status === 'running' || a.activity === 'working')
|
|
381
|
+
return 'working';
|
|
382
|
+
if (a.status === 'idle' || a.activity === 'idle')
|
|
383
|
+
return 'idle';
|
|
384
|
+
if (a.status === 'queued')
|
|
385
|
+
return 'queued';
|
|
386
|
+
return '';
|
|
387
|
+
}
|
|
388
|
+
/** The colored, space-padded status cell for a listing row (empty when not live). */
|
|
389
|
+
function liveStatusCell(live) {
|
|
390
|
+
const word = liveStatusWord(live);
|
|
391
|
+
if (!word || !live)
|
|
392
|
+
return { cell: '', width: 0 };
|
|
393
|
+
return { cell: statusColor(live.status)(padToWidth(word, 8)), width: 8 };
|
|
394
|
+
}
|
|
346
395
|
/**
|
|
347
396
|
* The tracker/PR ref for a session's dedicated column: the ticket id when known,
|
|
348
397
|
* else `PR#<n>`, else empty. Pulled out of the trailing badge blob so refs align
|
|
@@ -351,6 +400,21 @@ export function liveGlyphAndPreview(a) {
|
|
|
351
400
|
export function ticketLabel(s) {
|
|
352
401
|
return s.ticketId ?? (s.prNumber ? `PR#${s.prNumber}` : '');
|
|
353
402
|
}
|
|
403
|
+
function ticketUrl(s) {
|
|
404
|
+
if (s.ticketId)
|
|
405
|
+
return linearIssueUrl(s.ticketId);
|
|
406
|
+
return s.prNumber ? s.prUrl : undefined;
|
|
407
|
+
}
|
|
408
|
+
export function linkTicketCell(s, label) {
|
|
409
|
+
const url = ticketUrl(s);
|
|
410
|
+
return url && label.trim() !== '-' ? linkUrl(url, label) : label;
|
|
411
|
+
}
|
|
412
|
+
export function linkCwdCell(s, label) {
|
|
413
|
+
return s.cwd && !s._remote ? linkPath(s.cwd, label) : label;
|
|
414
|
+
}
|
|
415
|
+
function modelLabel(model) {
|
|
416
|
+
return model ? shortenModel(model) : '-';
|
|
417
|
+
}
|
|
354
418
|
/**
|
|
355
419
|
* The row shape `agents sessions --active --json` emits. RUSH-1981: a watcher
|
|
356
420
|
* joins active sessions on ticketId + project, but the raw ActiveSession nests
|
|
@@ -365,6 +429,7 @@ export function serializeActiveSessionsForJson(sessions) {
|
|
|
365
429
|
...s,
|
|
366
430
|
ticketId: s.ticket?.id ?? null,
|
|
367
431
|
project: s.cwd ? path.basename(s.cwd) : null,
|
|
432
|
+
prLink: s.pr?.url ?? null,
|
|
368
433
|
}));
|
|
369
434
|
}
|
|
370
435
|
/**
|
|
@@ -435,11 +500,12 @@ function locatorBadge(s) {
|
|
|
435
500
|
* terminal width so the row never wraps.
|
|
436
501
|
*/
|
|
437
502
|
function printActiveRow(s, indent) {
|
|
438
|
-
// shortId (8-char) · agent · host · status · badges · identity+todos+snippet
|
|
503
|
+
// shortId (8-char) · agent · host · status · owner · badges · identity+todos+snippet
|
|
439
504
|
const idCol = chalk.dim(padToWidth((s.sessionId?.slice(0, 8)) ?? '-', 9));
|
|
440
505
|
const kindCol = colorAgent(s.kind)(padToWidth(truncateToWidth(s.kind, 8), 9));
|
|
441
506
|
const hostCol = chalk.gray(padToWidth(truncateToWidth(s.host ?? '-', 8), 9));
|
|
442
507
|
const statusCol = statusColor(s.status)(padToWidth(truncateToWidth(activityLabel(s), 8), 9));
|
|
508
|
+
const ownerCol = chalk.cyan(padToWidth(truncateToWidth(ownerLabel(s), 8), 9));
|
|
443
509
|
const fork = s.pidCount && s.pidCount > 1 ? chalk.dim(`×${s.pidCount} `) : '';
|
|
444
510
|
const badges = (fork ? fork : '') + [signalBadges(s), locatorBadge(s)].filter(Boolean).join(' ');
|
|
445
511
|
// Identity (label/project/ticket clickable) + checklist + live snippet.
|
|
@@ -447,10 +513,24 @@ function printActiveRow(s, indent) {
|
|
|
447
513
|
// via the SSH fan-out already populated (including todos when the peer has them).
|
|
448
514
|
const desc = formatActiveRowDescription(s) || '-';
|
|
449
515
|
// Fill the remaining width with the preview so nothing wraps under tmux/SSH.
|
|
450
|
-
const fixed = stringWidth(indent) + 9 + 9 + 9 + 9 + (badges ? stringWidth(badges) + 1 : 0);
|
|
516
|
+
const fixed = stringWidth(indent) + 9 + 9 + 9 + 9 + 9 + (badges ? stringWidth(badges) + 1 : 0);
|
|
451
517
|
const room = Math.max(12, terminalWidth() - fixed - 1);
|
|
452
518
|
const descCol = chalk.white(truncateToWidth(desc, room));
|
|
453
|
-
console.log(indent + idCol + kindCol + hostCol + statusCol + (badges ? badges + ' ' : '') + descCol);
|
|
519
|
+
console.log(indent + idCol + kindCol + hostCol + statusCol + ownerCol + (badges ? badges + ' ' : '') + descCol);
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Compact owner display for the `--active` owner column: the local-part of a
|
|
523
|
+
* resolved actor email/login (`muqsit@getrush.ai` -> `muqsit`), the id as-is
|
|
524
|
+
* when it has no `@`, and `-` when the actor is unresolved (`UNRESOLVED@<host>`)
|
|
525
|
+
* or absent (a launch predating actor stamping). Honest by design — an
|
|
526
|
+
* unresolved local run shows no owner rather than inventing one.
|
|
527
|
+
*/
|
|
528
|
+
export function ownerLabel(s) {
|
|
529
|
+
const owner = s.owner;
|
|
530
|
+
if (!owner || owner.startsWith('UNRESOLVED@'))
|
|
531
|
+
return '-';
|
|
532
|
+
const at = owner.indexOf('@');
|
|
533
|
+
return at > 0 ? owner.slice(0, at) : owner;
|
|
454
534
|
}
|
|
455
535
|
/**
|
|
456
536
|
* Short label for an IDE window. The slice key in live-terminals.json is
|
|
@@ -666,8 +746,8 @@ async function runRemoteSessionsJson(hosts) {
|
|
|
666
746
|
process.stdout.write(serializeSessionsJson(sessions));
|
|
667
747
|
}
|
|
668
748
|
/**
|
|
669
|
-
* `running N · idle N · waiting N · queued N ·
|
|
670
|
-
* sessions (zero buckets omitted). Same bucketing as the
|
|
749
|
+
* `running N · idle N · waiting N · queued N · closed N · abandoned N · unknown N`
|
|
750
|
+
* for a bucket of sessions (zero buckets omitted). Same bucketing as the summary so
|
|
671
751
|
* per-group counts reconcile with the `(total)` beside the header — the `unknown`
|
|
672
752
|
* bucket is what keeps an alive-but-opaque row from silently vanishing from the
|
|
673
753
|
* tally. Empty when nothing.
|
|
@@ -677,6 +757,8 @@ function groupTally(sessions) {
|
|
|
677
757
|
const idle = sessions.filter(s => s.status === 'idle').length;
|
|
678
758
|
const waiting = sessions.filter(s => s.status === 'input_required').length;
|
|
679
759
|
const queued = sessions.filter(s => s.status === 'queued').length;
|
|
760
|
+
const closed = sessions.filter(s => s.status === 'closed').length;
|
|
761
|
+
const abandoned = sessions.filter(s => s.status === 'abandoned').length;
|
|
680
762
|
const unknown = sessions.filter(s => s.status === 'unknown').length;
|
|
681
763
|
const parts = [];
|
|
682
764
|
if (running)
|
|
@@ -687,6 +769,10 @@ function groupTally(sessions) {
|
|
|
687
769
|
parts.push(`${waiting} waiting`);
|
|
688
770
|
if (queued)
|
|
689
771
|
parts.push(`${queued} queued`);
|
|
772
|
+
if (closed)
|
|
773
|
+
parts.push(`${closed} closed`);
|
|
774
|
+
if (abandoned)
|
|
775
|
+
parts.push(`${abandoned} abandoned`);
|
|
690
776
|
if (unknown)
|
|
691
777
|
parts.push(`${unknown} unknown`);
|
|
692
778
|
return parts.join(' · ');
|
|
@@ -813,15 +899,18 @@ export function remoteHostsToDial(hosts, self) {
|
|
|
813
899
|
return hosts.filter(h => hostToken(h) !== self);
|
|
814
900
|
}
|
|
815
901
|
/**
|
|
816
|
-
*
|
|
817
|
-
*
|
|
818
|
-
*
|
|
819
|
-
*
|
|
820
|
-
*
|
|
821
|
-
*
|
|
822
|
-
*
|
|
902
|
+
* The fleet-wide live-session set behind every `--active` surface. Local sessions
|
|
903
|
+
* come from `getActiveSessions()` and (unless `--local`) the registered online
|
|
904
|
+
* devices from `ag devices` are folded in over SSH. An explicit `--host`/`--device`
|
|
905
|
+
* list SCOPES the sweep to exactly those machines — the local machine is included
|
|
906
|
+
* only when it is itself named — so `--host` is a filter, not an addition.
|
|
907
|
+
*
|
|
908
|
+
* This is the single gather: the static renderer AND the interactive browser both
|
|
909
|
+
* call it, so the browser can never disagree with `--active --json` about which
|
|
910
|
+
* sessions are live (it used to call the local-only `getActiveSessions()` directly
|
|
911
|
+
* and silently hid every remote session).
|
|
823
912
|
*/
|
|
824
|
-
async function
|
|
913
|
+
export async function gatherActiveSessions(opts = {}) {
|
|
825
914
|
const self = machineId();
|
|
826
915
|
// An explicit --host/--device list scopes the view: seed local sessions only
|
|
827
916
|
// when no hosts are named, or when this machine is one of the named targets.
|
|
@@ -841,6 +930,17 @@ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
|
|
|
841
930
|
merged = dedupeByMachineSession([...local, ...remote.sessions]);
|
|
842
931
|
}
|
|
843
932
|
}
|
|
933
|
+
return { sessions: merged, remoteDeviceCount };
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* Render the unified active-session view, grouped by machine. Scoping and the
|
|
937
|
+
* fleet sweep live in {@link gatherActiveSessions}; this owns the presentation
|
|
938
|
+
* (the `--waiting` gate, JSON, and the grouped table). A tip is shown when there
|
|
939
|
+
* are no other machines to include.
|
|
940
|
+
*/
|
|
941
|
+
async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
|
|
942
|
+
const self = machineId();
|
|
943
|
+
const { sessions: merged, remoteDeviceCount } = await gatherActiveSessions(opts);
|
|
844
944
|
// --waiting: only sessions blocked on the user. Exits non-zero when any are
|
|
845
945
|
// present so a supervising agent or hook can poll it as a gate.
|
|
846
946
|
const sessions = waitingOnly ? merged.filter(s => s.status === 'input_required') : merged;
|
|
@@ -944,6 +1044,21 @@ async function renderSessionPreview(query, scope) {
|
|
|
944
1044
|
console.log(chalk.gray(`No session matches "${query}".`));
|
|
945
1045
|
return;
|
|
946
1046
|
}
|
|
1047
|
+
// Lead with the live status when the session is still running, so the preview
|
|
1048
|
+
// says working / waiting / idle up front — not just the historical transcript.
|
|
1049
|
+
let live;
|
|
1050
|
+
try {
|
|
1051
|
+
live = indexActiveBySessionId(await getActiveSessions()).get(session.id);
|
|
1052
|
+
}
|
|
1053
|
+
catch { /* plain preview on any probe failure */ }
|
|
1054
|
+
if (live) {
|
|
1055
|
+
const { glyph } = liveGlyphAndPreview(live);
|
|
1056
|
+
const word = liveStatusWord(live) || live.status;
|
|
1057
|
+
const needsYou = live.status === 'input_required' || live.activity === 'waiting_input';
|
|
1058
|
+
const reason = live.awaitingReason ? ` (${live.awaitingReason.replace('_', ' ')})` : '';
|
|
1059
|
+
const suffix = needsYou ? chalk.yellow(` ← needs you${reason}`) : '';
|
|
1060
|
+
console.log(`${glyph} ${statusColor(live.status)(word)}${suffix}`);
|
|
1061
|
+
}
|
|
947
1062
|
console.log(buildPreview(session));
|
|
948
1063
|
}
|
|
949
1064
|
/** Main action handler for `agents sessions`. Routes to picker, table, or single-session render. */
|
|
@@ -1023,6 +1138,7 @@ async function sessionsAction(query, options) {
|
|
|
1023
1138
|
agent: options.agent,
|
|
1024
1139
|
host: options.host,
|
|
1025
1140
|
since: options.since,
|
|
1141
|
+
all: options.all,
|
|
1026
1142
|
}), { local: options.local === true, hosts: options.host });
|
|
1027
1143
|
return;
|
|
1028
1144
|
}
|
|
@@ -1104,7 +1220,7 @@ async function sessionsAction(query, options) {
|
|
|
1104
1220
|
// When the user explicitly asks to render (via mode flag), resolve the
|
|
1105
1221
|
// query globally so sessions outside the default cwd/30d window are found.
|
|
1106
1222
|
if (wantsRender && searchQuery) {
|
|
1107
|
-
await renderOneSession(searchQuery, mode, { agent: options.agent, project: options.project, filter: filterOpts, redact: options.redact });
|
|
1223
|
+
await renderOneSession(searchQuery, mode, { agent: options.agent, project: options.project, filter: filterOpts, redact: options.redact, local: options.local, hosts: options.host });
|
|
1108
1224
|
return;
|
|
1109
1225
|
}
|
|
1110
1226
|
// Interactive picker loads a deep pool but shows only recent sessions
|
|
@@ -1171,19 +1287,34 @@ async function sessionsAction(query, options) {
|
|
|
1171
1287
|
// Smart ID routing: a bare query that resolves to one session renders
|
|
1172
1288
|
// directly. If nothing matches in the scoped window and the query looks
|
|
1173
1289
|
// like a session ID, widen to global scope (incl. Claude /resume history).
|
|
1174
|
-
|
|
1290
|
+
//
|
|
1291
|
+
// Exception: a PEER answering a parent's `--json` locate sweep
|
|
1292
|
+
// (AGENTS_SESSIONS_LOCAL=1) must return the SessionMeta[] ROW for the id, not
|
|
1293
|
+
// render its transcript — the sweep parses an array (parseRemoteList). So when
|
|
1294
|
+
// we are that peer and --json is set, skip the single-session short-circuit and
|
|
1295
|
+
// fall through to the array-emitting --json block below.
|
|
1296
|
+
const answeringJsonSweep = options.json === true && process.env[NO_FANOUT_ENV] === '1';
|
|
1297
|
+
if (searchQuery && !answeringJsonSweep) {
|
|
1175
1298
|
const idMatches = resolveSessionById(sessions, searchQuery);
|
|
1176
1299
|
if (idMatches.length === 1) {
|
|
1177
1300
|
await renderSession(idMatches[0], mode, filterOpts, options);
|
|
1178
1301
|
return;
|
|
1179
1302
|
}
|
|
1180
1303
|
if (idMatches.length === 0 && looksLikeSessionId(searchQuery)) {
|
|
1181
|
-
await renderOneSession(searchQuery, mode, { agent: options.agent, project: options.project, filter: filterOpts, redact: options.redact });
|
|
1304
|
+
await renderOneSession(searchQuery, mode, { agent: options.agent, project: options.project, filter: filterOpts, redact: options.redact, local: options.local, hosts: options.host });
|
|
1182
1305
|
return;
|
|
1183
1306
|
}
|
|
1184
1307
|
}
|
|
1185
1308
|
if (options.json) {
|
|
1186
|
-
|
|
1309
|
+
// An id-shaped query resolves by id ONLY — the same rule the render path
|
|
1310
|
+
// uses (resolveSessionQuery). Without this a `--json <uuid>` (as issued by
|
|
1311
|
+
// the fleet locate sweep, which runs `sessions <uuid> --json --local` on
|
|
1312
|
+
// each peer) would fall to FTS content search and return every transcript
|
|
1313
|
+
// that merely MENTIONS the id, defeating exact remote resolution. A genuine
|
|
1314
|
+
// search phrase keeps the ranked metadata+content path.
|
|
1315
|
+
const filtered = searchQuery
|
|
1316
|
+
? resolveSessionQuery(sessions, searchQuery).matches
|
|
1317
|
+
: sessions;
|
|
1187
1318
|
process.stdout.write(serializeSessionsJson(filtered));
|
|
1188
1319
|
return;
|
|
1189
1320
|
}
|
|
@@ -1271,15 +1402,6 @@ async function sessionsAction(query, options) {
|
|
|
1271
1402
|
process.exit(1);
|
|
1272
1403
|
}
|
|
1273
1404
|
}
|
|
1274
|
-
/** Whether a query should route to the single-session render rather than the
|
|
1275
|
-
* listing. The hex-ish test catches a bare id prefix; `isCompleteSessionId`
|
|
1276
|
-
* additionally catches the prefixed whole ids (`session_…`, `ses_…`) that the
|
|
1277
|
-
* hex test rejects — without it those never reach the id-only resolution and
|
|
1278
|
-
* still content-search. */
|
|
1279
|
-
function looksLikeSessionId(query) {
|
|
1280
|
-
const trimmed = query.trim();
|
|
1281
|
-
return /^[0-9a-f-]{6,}$/i.test(trimmed) || isCompleteSessionId(trimmed);
|
|
1282
|
-
}
|
|
1283
1405
|
function teamTag(session) {
|
|
1284
1406
|
const origin = session.teamOrigin;
|
|
1285
1407
|
if (!origin)
|
|
@@ -1301,12 +1423,12 @@ function metaSignals(s) {
|
|
|
1301
1423
|
};
|
|
1302
1424
|
}
|
|
1303
1425
|
/** One flat table row:
|
|
1304
|
-
* shortId · agent · version · project · [glyph] label·doing · [ticket] · [wt] · time
|
|
1426
|
+
* shortId · agent · version · model · project · [glyph] label·doing · [ticket] · [wt] · time
|
|
1305
1427
|
* `doing` is the live preview when running, else the topic. The `ticket` column
|
|
1306
1428
|
* (tracker/PR ref, pulled out of the badge blob so refs align) is only rendered
|
|
1307
1429
|
* when `showTicket` — otherwise a listing with no refs would waste a column of
|
|
1308
1430
|
* dashes and needlessly truncate the topic. Worktree stays a trailing badge. */
|
|
1309
|
-
function flatSessionRow(session, live, showTicket = false, cols = {}) {
|
|
1431
|
+
export function flatSessionRow(session, live, showTicket = false, cols = {}) {
|
|
1310
1432
|
const agentColor = colorAgent(session.agent);
|
|
1311
1433
|
const when = formatRelativeTime(session.lastActivity ?? session.timestamp);
|
|
1312
1434
|
const project = session.project || '-';
|
|
@@ -1329,19 +1451,31 @@ function flatSessionRow(session, live, showTicket = false, cols = {}) {
|
|
|
1329
1451
|
: '';
|
|
1330
1452
|
const TICKET_W = 10;
|
|
1331
1453
|
const ticketCell = showTicket
|
|
1332
|
-
? chalk.blue(padToWidth(truncateToWidth(ticketLabel(session) || '-', TICKET_W), TICKET_W + 1))
|
|
1454
|
+
? chalk.blue(linkTicketCell(session, padToWidth(truncateToWidth(ticketLabel(session) || '-', TICKET_W), TICKET_W + 1)))
|
|
1333
1455
|
: '';
|
|
1456
|
+
// Live status word (working / waiting / idle) next to the glyph — the default
|
|
1457
|
+
// list is no longer a bare glyph. Empty (zero width) for resting rows.
|
|
1458
|
+
const { cell: statusCell, width: statusW } = liveStatusCell(live);
|
|
1334
1459
|
const glyphW = glyph ? 2 : 0;
|
|
1335
1460
|
const machineW = cols.showMachine ? machineColW : 0;
|
|
1336
1461
|
const ticketW = showTicket ? TICKET_W + 1 : 0;
|
|
1337
1462
|
const wtW = wt ? stringWidth(wt) + 1 : 0;
|
|
1338
|
-
const
|
|
1463
|
+
const width = terminalWidth();
|
|
1464
|
+
const requestedModelW = cols.showModel ? (cols.modelWidth ?? PICKER_MODEL_MAX) : 0;
|
|
1465
|
+
const fixedW = (10 + 9 + 8 + 16) + glyphW + statusW + machineW + ticketW + wtW + stringWidth(when) + 1;
|
|
1466
|
+
const modelSlack = width - fixedW - 16;
|
|
1467
|
+
const modelW = requestedModelW <= modelSlack
|
|
1468
|
+
? requestedModelW
|
|
1469
|
+
: modelSlack >= PICKER_MODEL_MIN ? modelSlack : 0;
|
|
1470
|
+
const topicW = Math.max(16, width - fixedW - modelW);
|
|
1339
1471
|
return (chalk.white(padToWidth(truncateToWidth(session.shortId, 9), 10)) +
|
|
1340
1472
|
agentColor(padToWidth(truncateToWidth(session.agent, 8), 9)) +
|
|
1341
1473
|
chalk.yellow(padToWidth(truncateToWidth(session.version || '-', 7), 8)) +
|
|
1474
|
+
(modelW ? chalk.yellow(padToWidth(truncateToWidth(modelLabel(session.model), modelW - 1), modelW)) : '') +
|
|
1342
1475
|
machineCell +
|
|
1343
|
-
chalk.cyan(padToWidth(truncateToWidth(project, 14), 16)) +
|
|
1476
|
+
chalk.cyan(linkCwdCell(session, padToWidth(truncateToWidth(project, 14), 16))) +
|
|
1344
1477
|
(glyph ? glyph + ' ' : '') +
|
|
1478
|
+
statusCell +
|
|
1345
1479
|
renderTopicCell(label, doing, '', topicW, topicW) +
|
|
1346
1480
|
ticketCell +
|
|
1347
1481
|
(wt ? wt + ' ' : '') +
|
|
@@ -1362,13 +1496,15 @@ function treeSessionRow(session, live) {
|
|
|
1362
1496
|
const badges = signalBadges(metaSignals(session));
|
|
1363
1497
|
const badgeW = badges ? stringWidth(badges) + 1 : 0;
|
|
1364
1498
|
const head = label ? `${label} · ${topic}` : topic;
|
|
1499
|
+
const { cell: statusCell, width: statusW } = liveStatusCell(live);
|
|
1365
1500
|
const glyphW = glyph ? 2 : 0;
|
|
1366
|
-
const topicW = Math.max(12, terminalWidth() - (2 + 9 + 8) - glyphW - badgeW - stringWidth(when) - 1);
|
|
1501
|
+
const topicW = Math.max(12, terminalWidth() - (2 + 9 + 8) - glyphW - statusW - badgeW - stringWidth(when) - 1);
|
|
1367
1502
|
return (' ' +
|
|
1368
1503
|
chalk.dim(padToWidth(session.shortId, 9)) +
|
|
1369
1504
|
agentColor(padToWidth(truncateToWidth(session.agent, 7), 8)) +
|
|
1370
1505
|
(badges ? badges + ' ' : '') +
|
|
1371
1506
|
(glyph ? glyph + ' ' : '') +
|
|
1507
|
+
statusCell +
|
|
1372
1508
|
padToWidth(chalk.white(truncateToWidth(head, topicW)), topicW) +
|
|
1373
1509
|
' ' + chalk.gray(when));
|
|
1374
1510
|
}
|
|
@@ -1485,7 +1621,9 @@ function printSessionTable(sessions, hiddenCount = 0, tree = false, liveIndex) {
|
|
|
1485
1621
|
console.log();
|
|
1486
1622
|
first = false;
|
|
1487
1623
|
const group = byDir.get(key);
|
|
1488
|
-
|
|
1624
|
+
const cwd = group.find((s) => s.cwd && !s._remote)?.cwd;
|
|
1625
|
+
const header = cwd ? linkPath(cwd, shortCwd(key)) : shortCwd(key);
|
|
1626
|
+
console.log(`${chalk.cyan.bold(header)} ${chalk.gray(`(${group.length})`)}`);
|
|
1489
1627
|
for (const s of group)
|
|
1490
1628
|
console.log(treeSessionRow(s, liveIndex?.get(s.id)));
|
|
1491
1629
|
}
|
|
@@ -1681,12 +1819,18 @@ function renderTopicCell(label, topic, query, visibleWidth, paddedWidth) {
|
|
|
1681
1819
|
const PICKER_MACHINE_W = 11;
|
|
1682
1820
|
const PICKER_MACHINE_MIN = 8;
|
|
1683
1821
|
const PICKER_MACHINE_MAX = 18;
|
|
1822
|
+
const PICKER_MODEL_MIN = 6;
|
|
1823
|
+
const PICKER_MODEL_MAX = 13;
|
|
1684
1824
|
/** Column width that shows every compacted hostname in `machines` whole (one
|
|
1685
1825
|
* trailing space for separation), bounded by MIN/MAX. */
|
|
1686
1826
|
function machineColumnWidth(machines, label) {
|
|
1687
1827
|
const widest = machines.reduce((w, m) => Math.max(w, stringWidth(label(m))), 0);
|
|
1688
1828
|
return Math.min(PICKER_MACHINE_MAX, Math.max(PICKER_MACHINE_MIN, widest + 1));
|
|
1689
1829
|
}
|
|
1830
|
+
function modelColumnWidth(sessions) {
|
|
1831
|
+
const widest = sessions.reduce((width, session) => (Math.max(width, session.model ? stringWidth(modelLabel(session.model)) : 0)), 0);
|
|
1832
|
+
return Math.min(PICKER_MODEL_MAX, Math.max(PICKER_MODEL_MIN, widest + 1));
|
|
1833
|
+
}
|
|
1690
1834
|
/**
|
|
1691
1835
|
* Compact display form for machine ids: strip the longest shared dash-delimited
|
|
1692
1836
|
* prefix so "yosemite-s0"/"yosemite-s1" read as "s0"/"s1" while unrelated ids
|
|
@@ -1722,10 +1866,28 @@ export function pickerColumnsFor(sessions) {
|
|
|
1722
1866
|
showMachine: distinct.length > 1,
|
|
1723
1867
|
machineLabel,
|
|
1724
1868
|
machineWidth: machineColumnWidth(distinct, machineLabel),
|
|
1869
|
+
showModel: sessions.some((s) => !!s.model),
|
|
1870
|
+
modelWidth: modelColumnWidth(sessions),
|
|
1725
1871
|
showTicket: sessions.some((s) => ticketLabel(s) !== ''),
|
|
1726
1872
|
};
|
|
1727
1873
|
}
|
|
1728
|
-
|
|
1874
|
+
/** Width of the host-program column (`tmux→ghostty` is the long realistic case). */
|
|
1875
|
+
const PICKER_HOST_W = 14;
|
|
1876
|
+
/**
|
|
1877
|
+
* Which program a live session is running in, for the picker's host column: the
|
|
1878
|
+
* immediate host app (`codium`, `ghostty`, `tmux`, `iterm`, …) and — when a tmux
|
|
1879
|
+
* session is currently being watched through a different app — the app it is
|
|
1880
|
+
* viewed in, as `tmux→ghostty`. A tmux session with no attached client stays a
|
|
1881
|
+
* bare `tmux`, which is exactly "running detached". Empty when the session has no
|
|
1882
|
+
* resolvable host (cloud rows, an unreadable process env).
|
|
1883
|
+
*/
|
|
1884
|
+
export function liveHostLabel(a) {
|
|
1885
|
+
if (!a?.host)
|
|
1886
|
+
return '';
|
|
1887
|
+
const viewer = a.viewingIn?.app;
|
|
1888
|
+
return viewer && viewer !== a.host ? `${a.host}→${viewer}` : a.host;
|
|
1889
|
+
}
|
|
1890
|
+
export function formatPickerLabel(s, query, cols = {}, ssh, host = '') {
|
|
1729
1891
|
const agentColor = colorAgent(s.agent);
|
|
1730
1892
|
const when = formatRelativeTime(s.lastActivity ?? s.timestamp);
|
|
1731
1893
|
const project = s.project || '-';
|
|
@@ -1750,18 +1912,29 @@ export function formatPickerLabel(s, query, cols = {}, ssh) {
|
|
|
1750
1912
|
const ticketCell = cols.showTicket
|
|
1751
1913
|
? chalk.blue(padRight(truncate(ticketLabel(s) || '-', TICKET_W), TICKET_W + 1))
|
|
1752
1914
|
: '';
|
|
1915
|
+
// Which terminal/editor the session runs in — a tab in Ghostty vs a VS Code
|
|
1916
|
+
// panel vs a detached tmux pane is the thing you need to know to go find it.
|
|
1917
|
+
const hostCell = cols.showHost
|
|
1918
|
+
? chalk.gray(padRight(truncate(host || '-', PICKER_HOST_W - 1), PICKER_HOST_W))
|
|
1919
|
+
: '';
|
|
1753
1920
|
// The picker prepends a gutter (cursor, plus a checkbox in multi-select mode);
|
|
1754
1921
|
// reserve it, plus the conditional columns, so the topic shrinks to fit and
|
|
1755
1922
|
// rows never wrap.
|
|
1756
1923
|
const gutter = cols.gutter ?? 2;
|
|
1757
1924
|
const machineColW = cols.showMachine ? machineW : 0;
|
|
1758
1925
|
const ticketW = cols.showTicket ? TICKET_W + 1 : 0;
|
|
1926
|
+
const hostW = cols.showHost ? PICKER_HOST_W : 0;
|
|
1759
1927
|
const wtW = wt ? stringWidth(wt) + 1 : 0;
|
|
1760
|
-
const topicW = Math.max(16, terminalWidth() - gutter - (10 + 9 + 8 + 16) - machineColW - ticketW - wtW - sshW - stringWidth(when) - 1);
|
|
1761
|
-
return (
|
|
1928
|
+
const topicW = Math.max(16, terminalWidth() - gutter - (10 + 9 + 8 + 16) - machineColW - hostW - ticketW - wtW - sshW - stringWidth(when) - 1);
|
|
1929
|
+
return (
|
|
1930
|
+
// Truncated, not just padded: an indexed shortId is always 8 chars, but a
|
|
1931
|
+
// live row with no session id is named by its pid or cloud task, which can
|
|
1932
|
+
// run past the column and shunt every later column out of alignment.
|
|
1933
|
+
chalk.white(padRight(truncate(s.shortId, 9), 10)) +
|
|
1762
1934
|
agentColor(padRight(truncate(s.agent, 8), 9)) +
|
|
1763
1935
|
chalk.yellow(padRight(truncate(versionStr, 7), 8)) +
|
|
1764
1936
|
machineCell +
|
|
1937
|
+
hostCell +
|
|
1765
1938
|
chalk.cyan(padRight(truncate(project, 14), 16)) +
|
|
1766
1939
|
sshSeg +
|
|
1767
1940
|
renderTopicCell(label, topic, query, topicW, topicW) +
|
|
@@ -1827,7 +2000,25 @@ function warnNoPeerTarget(machine, session) {
|
|
|
1827
2000
|
console.log(chalk.yellow(`Session ${session.shortId} lives on ${machine}, which isn't a reachable device right now.`));
|
|
1828
2001
|
console.log(chalk.gray(`Register/wake it (ag devices), or run there: agents ssh ${machine}`));
|
|
1829
2002
|
}
|
|
2003
|
+
/**
|
|
2004
|
+
* Row-id prefix for a live session whose agent has not reported a session id yet
|
|
2005
|
+
* (a booting harness, a queued teammate). The browser lists these so nothing live
|
|
2006
|
+
* is hidden, but they address no transcript — {@link isIdlessLiveRow} is what the
|
|
2007
|
+
* pick handler checks before trying to read or resume one.
|
|
2008
|
+
*/
|
|
2009
|
+
export const LIVE_ROW_PREFIX = 'live:';
|
|
2010
|
+
export function isIdlessLiveRow(s) {
|
|
2011
|
+
return s.id.startsWith(LIVE_ROW_PREFIX);
|
|
2012
|
+
}
|
|
1830
2013
|
export async function handlePickedSession(picked) {
|
|
2014
|
+
// A live row with no session id has no transcript to open and no id to resume
|
|
2015
|
+
// by; say where the process is instead of dead-ending on an unreadable path.
|
|
2016
|
+
if (isIdlessLiveRow(picked.session)) {
|
|
2017
|
+
const where = picked.session.machine ? ` on ${picked.session.machine}` : '';
|
|
2018
|
+
console.log(chalk.yellow(`This session hasn't reported a session id yet — nothing to open${where}.`));
|
|
2019
|
+
console.log(chalk.gray(`Watch for it with: agents sessions --active${picked.session.machine ? ` --host ${picked.session.machine}` : ''}`));
|
|
2020
|
+
return;
|
|
2021
|
+
}
|
|
1831
2022
|
// A session on another machine is read/resumed ON that machine over SSH — its
|
|
1832
2023
|
// transcript and agent binary live there. Both actions execute on the peer
|
|
1833
2024
|
// (not a local `--host` hop, which would discover locally and dead-end for a
|
|
@@ -2403,6 +2594,21 @@ async function renderOneSession(query, mode, scope) {
|
|
|
2403
2594
|
}
|
|
2404
2595
|
}
|
|
2405
2596
|
else if (byId) {
|
|
2597
|
+
// Not on this machine. A UUID names ONE session, so before giving up ask
|
|
2598
|
+
// the fleet — the session may live only on a peer (the whole point of
|
|
2599
|
+
// RUSH-2024). Skip the sweep when the caller pinned --local, or when we
|
|
2600
|
+
// are ourselves a peer answering a parent's sweep (AGENTS_SESSIONS_LOCAL),
|
|
2601
|
+
// so a locate never recurses. On a single remote hit we hand rendering to
|
|
2602
|
+
// that peer; a multi-host hit surfaces the conflict; a miss keeps the
|
|
2603
|
+
// local not-found message. No FTS fallback either way.
|
|
2604
|
+
if (shouldFanOutForId(query, scope.local)) {
|
|
2605
|
+
const outcome = await resolveSessionAcrossFleet(query, mode, scope.hosts);
|
|
2606
|
+
if (outcome === 'rendered')
|
|
2607
|
+
return;
|
|
2608
|
+
if (outcome === 'conflict')
|
|
2609
|
+
process.exit(1);
|
|
2610
|
+
// 'not-found' falls through to the local message below.
|
|
2611
|
+
}
|
|
2406
2612
|
notFoundByIdMessage(query).forEach(l => console.error(l));
|
|
2407
2613
|
process.exit(1);
|
|
2408
2614
|
}
|
|
@@ -2441,6 +2647,123 @@ async function renderOneSession(query, mode, scope) {
|
|
|
2441
2647
|
process.exit(1);
|
|
2442
2648
|
}
|
|
2443
2649
|
}
|
|
2650
|
+
/**
|
|
2651
|
+
* Whether a missed local id lookup should widen to a cross-machine sweep.
|
|
2652
|
+
*
|
|
2653
|
+
* Gate (all must hold):
|
|
2654
|
+
* - the query is id-shaped (`looksLikeSessionId`) — only an identifier resolves
|
|
2655
|
+
* across the fleet; a search phrase never does.
|
|
2656
|
+
* - not `--local` — the caller opted out of cross-machine lookup (deterministic
|
|
2657
|
+
* local behavior for scripts, RUSH-2024 acceptance: "--local still restricts").
|
|
2658
|
+
* - `AGENTS_SESSIONS_LOCAL` is unset — we are not ourselves a peer answering a
|
|
2659
|
+
* parent's sweep, so a locate can never recurse (RUSH-2024: avoid double-fan-out).
|
|
2660
|
+
*
|
|
2661
|
+
* Pure + exported so the gate is unit-tested without driving discovery / SSH.
|
|
2662
|
+
*/
|
|
2663
|
+
export function shouldFanOutForId(query, local) {
|
|
2664
|
+
if (local === true)
|
|
2665
|
+
return false;
|
|
2666
|
+
if (process.env[NO_FANOUT_ENV] === '1')
|
|
2667
|
+
return false;
|
|
2668
|
+
return looksLikeSessionId(query);
|
|
2669
|
+
}
|
|
2670
|
+
/** The render-mode flag a peer's `agents sessions <id>` must carry so the remote
|
|
2671
|
+
* summary matches the mode the user asked for. `summary` is the peer's default,
|
|
2672
|
+
* so it needs no flag; the others map 1:1 to a CLI flag. */
|
|
2673
|
+
function modeFlag(mode) {
|
|
2674
|
+
if (mode === 'markdown')
|
|
2675
|
+
return '--markdown';
|
|
2676
|
+
if (mode === 'json')
|
|
2677
|
+
return '--json';
|
|
2678
|
+
return undefined; // summary — the peer's default render
|
|
2679
|
+
}
|
|
2680
|
+
/** Group a fleet sweep's rows to the DISTINCT machines that hold the id. Each
|
|
2681
|
+
* peer answered `sessions <id> --json --local`, which (post-fix) id-resolves and
|
|
2682
|
+
* so returns the matching row(s); a peer with a synced MIRROR of the same id can
|
|
2683
|
+
* emit more than one row, so we keep the first per machine. Rows the peer somehow
|
|
2684
|
+
* returned that do NOT match the id (defensive against version skew) are dropped
|
|
2685
|
+
* so a stray content hit can never masquerade as an exact resolution. */
|
|
2686
|
+
export function fleetHitsById(rows, id) {
|
|
2687
|
+
const q = id.trim().toLowerCase();
|
|
2688
|
+
const byMachine = new Map();
|
|
2689
|
+
for (const s of rows) {
|
|
2690
|
+
const machine = s.machine;
|
|
2691
|
+
if (!machine)
|
|
2692
|
+
continue; // an untagged row can't be routed back to a peer
|
|
2693
|
+
if (s.id?.toLowerCase() !== q)
|
|
2694
|
+
continue; // exact id only — never a content hit
|
|
2695
|
+
if (!byMachine.has(machine))
|
|
2696
|
+
byMachine.set(machine, s);
|
|
2697
|
+
}
|
|
2698
|
+
return Array.from(byMachine.entries()).map(([machine, session]) => ({ machine, session }));
|
|
2699
|
+
}
|
|
2700
|
+
/**
|
|
2701
|
+
* Locate a full session id across the online fleet and render it from the machine
|
|
2702
|
+
* that holds it. The local disk already missed; this fans `sessions <id> --json
|
|
2703
|
+
* --all` out to every registered online peer (or the explicit `hosts` set),
|
|
2704
|
+
* groups the rows to distinct machines, then:
|
|
2705
|
+
*
|
|
2706
|
+
* - exactly one machine → delegate rendering to that peer via `runOnPeer`
|
|
2707
|
+
* (its transcript and agent binary live there — a local `--host` hop would
|
|
2708
|
+
* re-discover locally and dead-end), returning `'rendered'`.
|
|
2709
|
+
* - more than one machine → print the conflict with machine labels so the user
|
|
2710
|
+
* can disambiguate with `--device <host>`, returning `'conflict'`.
|
|
2711
|
+
* - none → `'not-found'`, letting the caller print the local
|
|
2712
|
+
* "no session on this machine" message.
|
|
2713
|
+
*
|
|
2714
|
+
* No fuzzy/content fallback: the sweep forwards a UUID, each peer id-resolves it,
|
|
2715
|
+
* and `fleetHitsById` drops anything that isn't an exact id match.
|
|
2716
|
+
*/
|
|
2717
|
+
export async function resolveSessionAcrossFleet(id, mode, hosts, deps = { gatherRemoteList, runOnPeer }) {
|
|
2718
|
+
const spinner = isInteractiveTerminal() ? ora('Searching the fleet...').start() : null;
|
|
2719
|
+
let hits;
|
|
2720
|
+
try {
|
|
2721
|
+
// Force whole-index scope (--all): the peer runs in its SSH-login home dir,
|
|
2722
|
+
// whose cwd would otherwise silently narrow the lookup and hide the row.
|
|
2723
|
+
// --json so each peer answers a parseable array; --local so it answers for
|
|
2724
|
+
// itself and never re-fans-out (belt-and-suspenders with the parent's
|
|
2725
|
+
// AGENTS_SESSIONS_LOCAL, which remote-list also sets on the peer).
|
|
2726
|
+
const forwarded = ['sessions', id, '--json', '--all', '--local'];
|
|
2727
|
+
const { sessions } = await deps.gatherRemoteList(forwarded, hosts);
|
|
2728
|
+
hits = fleetHitsById(sessions, id);
|
|
2729
|
+
}
|
|
2730
|
+
catch {
|
|
2731
|
+
// A fan-out failure is not an exact resolution — treat as not-found so the
|
|
2732
|
+
// caller prints the honest local message rather than a half-answer.
|
|
2733
|
+
hits = [];
|
|
2734
|
+
}
|
|
2735
|
+
finally {
|
|
2736
|
+
spinner?.stop();
|
|
2737
|
+
}
|
|
2738
|
+
if (hits.length === 0)
|
|
2739
|
+
return 'not-found';
|
|
2740
|
+
if (hits.length > 1) {
|
|
2741
|
+
console.error(chalk.red(`Session ${id} exists on multiple machines:`));
|
|
2742
|
+
for (const h of hits) {
|
|
2743
|
+
const s = h.session;
|
|
2744
|
+
const label = s.label ?? s.topic ?? '';
|
|
2745
|
+
console.error(chalk.cyan(` ${h.machine}`) + chalk.gray(` ${s.agent}${s.version ? ` ${s.version}` : ''} ${label}`));
|
|
2746
|
+
}
|
|
2747
|
+
console.error(chalk.gray(`Pick one with: agents sessions ${id} --device <host>`));
|
|
2748
|
+
return 'conflict';
|
|
2749
|
+
}
|
|
2750
|
+
const { machine } = hits[0];
|
|
2751
|
+
// Render the remote summary by re-running `sessions <id>` ON the peer. --local
|
|
2752
|
+
// keeps that render on the peer (it owns the transcript); the mode flag matches
|
|
2753
|
+
// the mode the user asked for. No TTY: a summary/markdown/json render is a
|
|
2754
|
+
// one-shot capture, not an interactive resume.
|
|
2755
|
+
const peerArgs = ['sessions', id, '--local'];
|
|
2756
|
+
const flag = modeFlag(mode);
|
|
2757
|
+
if (flag)
|
|
2758
|
+
peerArgs.push(flag);
|
|
2759
|
+
const result = await deps.runOnPeer(peerArgs, machine);
|
|
2760
|
+
if (result === 'no-target') {
|
|
2761
|
+
console.error(chalk.red(`Session ${id} is on ${machine}, but it is not a reachable registered device.`));
|
|
2762
|
+
console.error(chalk.gray('Register it with `agents devices` or run the command on that machine.'));
|
|
2763
|
+
return 'conflict'; // a definitive answer (found, un-renderable) — do NOT fall to the local not-found line
|
|
2764
|
+
}
|
|
2765
|
+
return 'rendered';
|
|
2766
|
+
}
|
|
2444
2767
|
/** Register the `agents sessions` command with all its options and help text. */
|
|
2445
2768
|
export function registerSessionsCommands(program) {
|
|
2446
2769
|
const sessionsCmd = program
|
|
@@ -2455,7 +2778,7 @@ export function registerSessionsCommands(program) {
|
|
|
2455
2778
|
.option('--antigravity', 'Shorthand for --agent antigravity')
|
|
2456
2779
|
.option('--grok', 'Shorthand for --agent grok')
|
|
2457
2780
|
.option('--opencode', 'Shorthand for --agent opencode')
|
|
2458
|
-
.option('--all', '
|
|
2781
|
+
.option('--all', 'Widen every non-status filter to "all": every directory (not just this project) and all time (no window cap). Status filters like --active still compose; -a/--device/--since still narrow their axis.')
|
|
2459
2782
|
.option('--unmanaged', "Also show sessions from your own ~/.<agent> installs (hidden once agents-cli manages that agent)")
|
|
2460
2783
|
.option('--teams', 'Include team-spawned sessions (hidden by default)')
|
|
2461
2784
|
.option('--routine', 'Show only sessions archived from routine runs')
|