@phnx-labs/agents-cli 1.22.22 → 1.22.24
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 +402 -0
- package/README.md +13 -6
- package/dist/bin/agents +0 -0
- package/dist/commands/attach.d.ts +2 -0
- package/dist/commands/attach.js +32 -7
- package/dist/commands/defaults.js +2 -0
- package/dist/commands/doctor.js +20 -7
- package/dist/commands/exec.js +100 -34
- package/dist/commands/feed.d.ts +18 -0
- package/dist/commands/feed.js +44 -1
- package/dist/commands/focus.d.ts +108 -5
- package/dist/commands/focus.js +455 -32
- package/dist/commands/fork.d.ts +20 -2
- package/dist/commands/fork.js +91 -64
- package/dist/commands/go.d.ts +39 -1
- package/dist/commands/go.js +112 -7
- package/dist/commands/harness-wizard.d.ts +206 -0
- package/dist/commands/harness-wizard.js +403 -0
- package/dist/commands/harness.d.ts +12 -0
- package/dist/commands/harness.js +97 -101
- package/dist/commands/insights.js +6 -2
- package/dist/commands/inspect.js +39 -5
- package/dist/commands/menubar.js +6 -1
- package/dist/commands/models.js +1 -0
- package/dist/commands/modes.d.ts +12 -0
- package/dist/commands/modes.js +147 -0
- package/dist/commands/resume.js +10 -3
- package/dist/commands/secrets-sync.js +11 -13
- package/dist/commands/secrets.d.ts +2 -0
- package/dist/commands/secrets.js +79 -47
- package/dist/commands/sessions-browser.d.ts +35 -0
- package/dist/commands/sessions-browser.js +140 -14
- package/dist/commands/sessions-resume.d.ts +23 -3
- package/dist/commands/sessions-resume.js +73 -19
- package/dist/commands/sessions.d.ts +91 -5
- package/dist/commands/sessions.js +359 -125
- package/dist/commands/setup-secrets.js +1 -1
- package/dist/commands/sync.js +246 -42
- package/dist/commands/view.js +2 -0
- package/dist/commands/watchdog.js +13 -2
- package/dist/index.js +2 -1
- package/dist/lib/agent-modes.d.ts +49 -0
- package/dist/lib/agent-modes.js +70 -0
- package/dist/lib/agents.d.ts +1 -1
- package/dist/lib/agents.js +132 -0
- package/dist/lib/codex-policy.d.ts +17 -0
- package/dist/lib/codex-policy.js +48 -0
- package/dist/lib/crabbox/lease.d.ts +25 -0
- package/dist/lib/crabbox/lease.js +62 -0
- package/dist/lib/daemon.js +70 -0
- package/dist/lib/doctor-diff.d.ts +3 -0
- package/dist/lib/doctor-diff.js +15 -13
- package/dist/lib/event-stream.d.ts +3 -1
- package/dist/lib/event-stream.js +14 -1
- package/dist/lib/exec.d.ts +16 -0
- package/dist/lib/exec.js +175 -62
- package/dist/lib/feed-broadcast.d.ts +1 -20
- package/dist/lib/feed-broadcast.js +31 -1
- package/dist/lib/hooks/cache.js +36 -3
- package/dist/lib/hooks.d.ts +19 -7
- package/dist/lib/hooks.js +100 -40
- package/dist/lib/hosts/session-index.d.ts +4 -0
- package/dist/lib/hosts/session-index.js +7 -0
- package/dist/lib/manifest.d.ts +12 -2
- package/dist/lib/manifest.js +60 -5
- package/dist/lib/mcp.js +44 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/install-menubar.d.ts +52 -2
- package/dist/lib/menubar/install-menubar.js +128 -6
- package/dist/lib/models.d.ts +0 -5
- package/dist/lib/models.js +48 -0
- package/dist/lib/plugin-marketplace.js +9 -0
- package/dist/lib/pricing/prices.json +119 -92
- package/dist/lib/pricing/table.js +13 -0
- package/dist/lib/refresh.d.ts +5 -0
- package/dist/lib/refresh.js +37 -33
- package/dist/lib/remote-agents-json.d.ts +29 -1
- package/dist/lib/remote-agents-json.js +47 -10
- package/dist/lib/resource-inventory.d.ts +79 -0
- package/dist/lib/resource-inventory.js +122 -0
- package/dist/lib/resources/mcp.js +2 -0
- package/dist/lib/resources/permissions.js +3 -0
- package/dist/lib/resources/types.d.ts +2 -1
- package/dist/lib/resources.js +8 -5
- package/dist/lib/run-defaults.d.ts +2 -0
- package/dist/lib/run-defaults.js +23 -2
- package/dist/lib/runner.js +50 -36
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/bundles.js +157 -65
- package/dist/lib/secrets/filestore.d.ts +5 -3
- package/dist/lib/secrets/filestore.js +12 -8
- package/dist/lib/secrets/index.d.ts +15 -1
- package/dist/lib/secrets/index.js +148 -53
- package/dist/lib/secrets/reaper.d.ts +97 -0
- package/dist/lib/secrets/reaper.js +219 -0
- package/dist/lib/secrets/remote.d.ts +29 -0
- package/dist/lib/secrets/remote.js +37 -1
- package/dist/lib/secrets/sync-passphrase.d.ts +27 -0
- package/dist/lib/secrets/sync-passphrase.js +78 -0
- package/dist/lib/session/active.d.ts +36 -1
- package/dist/lib/session/active.js +60 -19
- package/dist/lib/session/actor-sidecar.d.ts +14 -0
- package/dist/lib/session/actor-sidecar.js +67 -4
- package/dist/lib/session/db.d.ts +1 -1
- package/dist/lib/session/db.js +32 -1
- package/dist/lib/session/discover.js +168 -0
- package/dist/lib/session/parse.d.ts +10 -0
- package/dist/lib/session/parse.js +98 -0
- package/dist/lib/session/recovery.d.ts +37 -0
- package/dist/lib/session/recovery.js +95 -0
- package/dist/lib/session/remote-list.d.ts +10 -1
- package/dist/lib/session/remote-list.js +2 -8
- package/dist/lib/session/remote.d.ts +57 -3
- package/dist/lib/session/remote.js +90 -26
- package/dist/lib/session/resume-command.d.ts +6 -0
- package/dist/lib/session/resume-command.js +8 -0
- package/dist/lib/session/session-cache.d.ts +173 -0
- package/dist/lib/session/session-cache.js +399 -0
- package/dist/lib/session/types.d.ts +1 -1
- package/dist/lib/session/types.js +1 -1
- package/dist/lib/session/width.d.ts +1 -1
- package/dist/lib/session/width.js +12 -2
- package/dist/lib/shims.d.ts +2 -2
- package/dist/lib/shims.js +62 -8
- package/dist/lib/staleness/writers/hooks.js +1 -1
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/sync-umbrella.d.ts +5 -0
- package/dist/lib/sync-umbrella.js +5 -4
- package/dist/lib/tmux/session.d.ts +4 -2
- package/dist/lib/tmux/session.js +5 -5
- package/dist/lib/types.d.ts +3 -1
- package/dist/lib/usage.d.ts +13 -0
- package/dist/lib/usage.js +215 -0
- package/dist/lib/versions.js +13 -2
- package/package.json +1 -1
|
@@ -20,34 +20,39 @@ import { listProjectDefs, resolveProjectNameForCwd } from '../lib/projects.js';
|
|
|
20
20
|
import ora from 'ora';
|
|
21
21
|
import { SESSION_AGENTS } from '../lib/session/types.js';
|
|
22
22
|
import { discoverArtifacts, readArtifact, resolveArtifact } from '../lib/session/artifacts.js';
|
|
23
|
-
import { looksLikePath, toComparablePath, homeDir, needsWindowsShell,
|
|
23
|
+
import { looksLikePath, toComparablePath, homeDir, needsWindowsShell, composeWin32CommandLine } from '../lib/platform/index.js';
|
|
24
24
|
import { getActiveSessions } from '../lib/session/active.js';
|
|
25
25
|
import { enumerateGhosttyTabs, assignGhosttyTabs } from '../lib/session/ghostty-tabs.js';
|
|
26
26
|
import { mapPanesToTargets, listClients } from '../lib/tmux/session.js';
|
|
27
27
|
import { resolveViewingIn, viewingInLabel } from '../lib/session/viewing-in.js';
|
|
28
28
|
import { machineId, normalizeHost } from '../lib/session/sync/config.js';
|
|
29
29
|
import { gatherRemoteActive, NO_FANOUT_ENV } from '../lib/session/remote-active.js';
|
|
30
|
+
import { loadFleetActiveSessions, loadLocalActiveSessions, } from '../lib/session/session-cache.js';
|
|
30
31
|
import { gatherRemoteList, gatherRemoteToolProgramCounts, gatherRemoteToolSearch, runOnPeer } from '../lib/session/remote-list.js';
|
|
31
32
|
import { stringWidth, truncateToWidth, padToWidth, terminalWidth } from '../lib/session/width.js';
|
|
32
33
|
import { inferSessionState } from '../lib/session/state.js';
|
|
33
34
|
import { discoverSessions, queryIndexedSessions, countSessionsInScope, resolveSessionById, isCompleteSessionId, looksLikeSessionId, searchContentIndex, getSessionRoots, scopeToManaged } from '../lib/session/discover.js';
|
|
34
|
-
import { findSessionsById, querySessions } from '../lib/session/db.js';
|
|
35
|
+
import { findSessionsById, querySessions, getSessionById } from '../lib/session/db.js';
|
|
35
36
|
import { filterTeamSessions, safeTeamText } from '../lib/session/team-filter.js';
|
|
36
37
|
import { parseSession } from '../lib/session/parse.js';
|
|
37
38
|
import { runRemoteSessions, buildForwardedArgs, ensureWholeIndex } from '../lib/session/remote.js';
|
|
38
|
-
import { formatRelativeTime, sessionAgeParts } from '../lib/session/relative-time.js';
|
|
39
|
+
import { formatRelativeTime, formatCompactAge, sessionAgeParts } from '../lib/session/relative-time.js';
|
|
39
40
|
import { renderConversationMarkdown, renderSummary, renderSummaryHeader, computeSummaryStats, renderJson, filterEvents, parseRoleList, linkPath, linkUrl, shortenModel } from '../lib/session/render.js';
|
|
40
41
|
import { linearIssueUrl } from '../lib/session/linear.js';
|
|
41
42
|
import { renderMarkdown } from '../lib/markdown.js';
|
|
42
43
|
import { AGENTS, colorAgent, resolveAgentName } from '../lib/agents.js';
|
|
43
44
|
import { getShimsDir } from '../lib/state.js';
|
|
44
45
|
import { fuzzyMatch, FUZZY_PRESETS } from '../lib/fuzzy.js';
|
|
46
|
+
import { resolveSessionAlias } from '../lib/session/actor-sidecar.js';
|
|
45
47
|
import { resolveVersionAliasLoose } from '../lib/versions.js';
|
|
48
|
+
import { getAgentsInvocation } from '../lib/daemon.js';
|
|
49
|
+
import { sessionRecoveryRunArgs } from '../lib/session/recovery.js';
|
|
46
50
|
import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
|
|
47
51
|
import { sessionPicker, buildPreview, formatTodoCompact, githubRepoUrlFromCwd, } from './sessions-picker.js';
|
|
48
52
|
import { setHelpSections } from '../lib/help.js';
|
|
49
53
|
import { registerSessionsTailCommand } from './sessions-tail.js';
|
|
50
54
|
import { registerSessionsResumeCommand } from './sessions-resume.js';
|
|
55
|
+
import { registerSessionsForkCommand } from './fork.js';
|
|
51
56
|
import { registerSessionsFavoriteCommand } from './sessions-favorite.js';
|
|
52
57
|
import { isFavorite, listFavorites } from '../lib/session/favorites.js';
|
|
53
58
|
import { registerGoCommand } from './go.js';
|
|
@@ -378,6 +383,58 @@ export function indexActiveBySessionId(active) {
|
|
|
378
383
|
}
|
|
379
384
|
return byId;
|
|
380
385
|
}
|
|
386
|
+
/**
|
|
387
|
+
* Backfill display-only fields onto live rows from the indexed SessionMeta, by
|
|
388
|
+
* full session id (RUSH-2205). A running process reports no agent version, and a
|
|
389
|
+
* live orphan row usually carries no ticket/PR/label/start-time; the historical
|
|
390
|
+
* index does. Only a field the live row LACKS is filled — the live signal always
|
|
391
|
+
* wins when present. Pure (no I/O) so the join is unit-tested against fixtures;
|
|
392
|
+
* the DB read that builds `metaById` is the caller's concern.
|
|
393
|
+
*/
|
|
394
|
+
export function backfillActiveRowsFromMeta(sessions, metaById) {
|
|
395
|
+
for (const s of sessions) {
|
|
396
|
+
if (!s.sessionId)
|
|
397
|
+
continue;
|
|
398
|
+
const m = metaById.get(s.sessionId);
|
|
399
|
+
if (!m)
|
|
400
|
+
continue;
|
|
401
|
+
if (!s.version && m.version)
|
|
402
|
+
s.version = m.version;
|
|
403
|
+
if (!s.label && m.label)
|
|
404
|
+
s.label = m.label;
|
|
405
|
+
if (!s.ticket && m.ticketId)
|
|
406
|
+
s.ticket = { id: m.ticketId, url: linearIssueUrl(m.ticketId) };
|
|
407
|
+
if (!s.pr && m.prUrl)
|
|
408
|
+
s.pr = { url: m.prUrl, number: m.prNumber };
|
|
409
|
+
if (!s.startedAtMs && m.timestamp) {
|
|
410
|
+
const ts = new Date(m.timestamp).getTime();
|
|
411
|
+
if (!Number.isNaN(ts))
|
|
412
|
+
s.startedAtMs = ts;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Build the id→meta index the live-row backfill needs, reading the historical
|
|
418
|
+
* session DB by full id. Best-effort: a missing or locked DB yields an empty map
|
|
419
|
+
* so the live view still renders (mirrors {@link maybeLiveIndex}). Deduplicates
|
|
420
|
+
* ids so N rows in one session cost one query.
|
|
421
|
+
*/
|
|
422
|
+
function loadBackfillMetaFor(sessions) {
|
|
423
|
+
const byId = new Map();
|
|
424
|
+
try {
|
|
425
|
+
for (const s of sessions) {
|
|
426
|
+
if (!s.sessionId || byId.has(s.sessionId))
|
|
427
|
+
continue;
|
|
428
|
+
const m = getSessionById(s.sessionId);
|
|
429
|
+
if (m)
|
|
430
|
+
byId.set(s.sessionId, m);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
/* enrichment is best-effort — an unavailable DB leaves rows un-backfilled */
|
|
435
|
+
}
|
|
436
|
+
return byId;
|
|
437
|
+
}
|
|
381
438
|
/**
|
|
382
439
|
* The live decoration for a listing row: a status glyph and the latest-turn
|
|
383
440
|
* preview, when the session is still running. `●` running / `◐` waiting on the
|
|
@@ -582,30 +639,109 @@ function locatorBadge(s) {
|
|
|
582
639
|
return parts.join(' ');
|
|
583
640
|
}
|
|
584
641
|
/**
|
|
585
|
-
*
|
|
586
|
-
*
|
|
587
|
-
*
|
|
588
|
-
*
|
|
589
|
-
*
|
|
642
|
+
* The `created X · idle Y` time cell for a live row (RUSH-2205). `created` is the
|
|
643
|
+
* age of the session start ({@link ActiveSession.startedAtMs}); `idle` is the age
|
|
644
|
+
* of the last transcript write ({@link ActiveSession.lastActivityMs}) — i.e. how
|
|
645
|
+
* long it has been quiet. Compact ("6d", "3h", "now") so the row stays width-safe;
|
|
646
|
+
* either half is omitted when its epoch is unknown.
|
|
590
647
|
*/
|
|
591
|
-
function
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
648
|
+
function activeTimeCell(s) {
|
|
649
|
+
const parts = [];
|
|
650
|
+
if (s.startedAtMs)
|
|
651
|
+
parts.push(`created ${formatCompactAge(new Date(s.startedAtMs).toISOString())}`);
|
|
652
|
+
if (s.lastActivityMs)
|
|
653
|
+
parts.push(`idle ${formatCompactAge(new Date(s.lastActivityMs).toISOString())}`);
|
|
654
|
+
return parts.join(' · ');
|
|
655
|
+
}
|
|
656
|
+
/** Column widths for line 1 of an active row (id · agent · version · status · owner). */
|
|
657
|
+
const ROW_ID_W = 9;
|
|
658
|
+
const ROW_AGENT_W = 8;
|
|
659
|
+
const ROW_VERSION_W = 8;
|
|
660
|
+
const ROW_STATUS_W = 9;
|
|
661
|
+
const ROW_OWNER_W = 9;
|
|
662
|
+
/**
|
|
663
|
+
* Fit pre-styled content (which may carry SGR colour AND OSC-8 hyperlinks — the
|
|
664
|
+
* clickable ticket/PR/project badges) into `room` display cells. Returns it raw
|
|
665
|
+
* when it already fits, preserving colour and clickable links; otherwise falls
|
|
666
|
+
* back to a width-safe truncation. `truncateToWidth` strips OSC-8 before cutting
|
|
667
|
+
* (see width.ts), so a too-narrow cell drops the hyperlink cleanly rather than
|
|
668
|
+
* slicing a hyperlink escape in half and corrupting the terminal (RUSH-2205
|
|
669
|
+
* review). Never run pre-linked content through `truncateToWidth` directly — that
|
|
670
|
+
* strips its links even when it fits; go through this helper.
|
|
671
|
+
*/
|
|
672
|
+
function fitCell(content, room) {
|
|
673
|
+
if (room <= 0)
|
|
674
|
+
return '';
|
|
675
|
+
return stringWidth(content) <= room ? content : truncateToWidth(content, room);
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Build the (one or two) rendered lines for a single active-session row.
|
|
679
|
+
* Indent is the leading whitespace (2 spaces for flat groups, 4 inside a window
|
|
680
|
+
* sub-group). Sized to `termW` so no line ever wraps under tmux/SSH (RUSH-2205):
|
|
681
|
+
*
|
|
682
|
+
* line 1: id · agent version · status · owner · created X · idle Y · ticket/PR
|
|
683
|
+
* line 2: └ label/topic (+ checklist) · jump locator (ssh/tmux/detached)
|
|
684
|
+
*
|
|
685
|
+
* The label/topic gets its own line so it is no longer buried in a truncated grey
|
|
686
|
+
* snippet, and the actionable ticket/PR badges ride line 1. Version and the
|
|
687
|
+
* ticket/PR/label are backfilled onto the {@link ActiveSession} from the indexed
|
|
688
|
+
* SessionMeta before this renders (a live process reports none of them). Pure +
|
|
689
|
+
* exported so the row layout is unit-tested for content and width without a
|
|
690
|
+
* captured stdout.
|
|
691
|
+
*/
|
|
692
|
+
export function renderActiveRowLines(s, indent, termW) {
|
|
693
|
+
const idCol = chalk.dim(padToWidth((s.sessionId?.slice(0, 8)) ?? '-', ROW_ID_W));
|
|
694
|
+
const kindCol = colorAgent(s.kind)(padToWidth(truncateToWidth(s.kind, ROW_AGENT_W), ROW_AGENT_W + 1));
|
|
695
|
+
const versionCol = chalk.gray(padToWidth(truncateToWidth(s.version ?? '', ROW_VERSION_W), ROW_VERSION_W + 1));
|
|
696
|
+
const statusCol = statusColor(s.status)(padToWidth(truncateToWidth(activityLabel(s), ROW_STATUS_W - 1), ROW_STATUS_W));
|
|
697
|
+
const ownerCol = chalk.cyan(padToWidth(truncateToWidth(ownerLabel(s), ROW_OWNER_W - 1), ROW_OWNER_W));
|
|
698
|
+
const fixedCols = idCol + kindCol + versionCol + statusCol + ownerCol;
|
|
699
|
+
const fixedW = stringWidth(indent) + ROW_ID_W + (ROW_AGENT_W + 1) + (ROW_VERSION_W + 1) + ROW_STATUS_W + ROW_OWNER_W;
|
|
700
|
+
const remaining = Math.max(0, termW - fixedW - 1);
|
|
701
|
+
// Line 1 right side: created/idle time + actionable badges (fork count,
|
|
702
|
+
// plan/ask/perm, ticket, PR, worktree). Badges are the jump-to-work signal, so
|
|
703
|
+
// they win the width fight — fitCell keeps them raw+clickable when they fit and
|
|
704
|
+
// drops the link cleanly when narrow; the time cell takes whatever is left.
|
|
598
705
|
const fork = s.pidCount && s.pidCount > 1 ? chalk.dim(`×${s.pidCount} `) : '';
|
|
599
|
-
const
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
706
|
+
const badgesCell = fitCell(fork + signalBadges(s), remaining);
|
|
707
|
+
const badgesW = stringWidth(badgesCell);
|
|
708
|
+
const timeRoom = Math.max(0, remaining - (badgesW ? badgesW + 2 : 0));
|
|
709
|
+
const timeCell = chalk.gray(truncateToWidth(activeTimeCell(s), timeRoom));
|
|
710
|
+
let right = timeCell;
|
|
711
|
+
if (badgesCell)
|
|
712
|
+
right += (stringWidth(timeCell) ? ' ' : '') + badgesCell;
|
|
713
|
+
// right's visible width is <= remaining by construction, so the assembled line
|
|
714
|
+
// is <= termW-1 whenever the fixed columns fit; the clamp only bites a terminal
|
|
715
|
+
// narrower than the fixed columns (where right is empty — no link to corrupt).
|
|
716
|
+
let line1 = indent + fixedCols + right;
|
|
717
|
+
if (stringWidth(line1) > termW)
|
|
718
|
+
line1 = truncateToWidth(line1, termW);
|
|
719
|
+
const lines = [line1];
|
|
720
|
+
// Line 2: label/topic + checklist (the identity, no longer buried) then the
|
|
721
|
+
// jump locator. Skipped entirely when there is nothing to say. desc may carry a
|
|
722
|
+
// clickable project link, so it also goes through fitCell.
|
|
723
|
+
const desc = formatActiveRowDescription(s);
|
|
724
|
+
const loc = locatorBadge(s);
|
|
725
|
+
if (!desc && !loc)
|
|
726
|
+
return lines;
|
|
727
|
+
const contIndent = indent + ' '.repeat(ROW_ID_W);
|
|
728
|
+
const room2 = Math.max(0, termW - stringWidth(contIndent) - 2);
|
|
729
|
+
const locCell = fitCell(loc, room2);
|
|
730
|
+
const locW = stringWidth(locCell);
|
|
731
|
+
const descRoom = Math.max(0, room2 - (locW ? locW + 2 : 0));
|
|
732
|
+
const descCell = chalk.white(fitCell(desc || '-', descRoom));
|
|
733
|
+
let line2 = contIndent + chalk.dim('└ ') + descCell;
|
|
734
|
+
if (locCell)
|
|
735
|
+
line2 += ' ' + locCell;
|
|
736
|
+
if (stringWidth(line2) > termW)
|
|
737
|
+
line2 = truncateToWidth(line2, termW);
|
|
738
|
+
lines.push(line2);
|
|
739
|
+
return lines;
|
|
740
|
+
}
|
|
741
|
+
/** Render a single agent-session row inside an already-printed group header. */
|
|
742
|
+
function printActiveRow(s, indent) {
|
|
743
|
+
for (const line of renderActiveRowLines(s, indent, terminalWidth()))
|
|
744
|
+
console.log(line);
|
|
609
745
|
}
|
|
610
746
|
/**
|
|
611
747
|
* Compact owner display for the `--active` owner column: the local-part of a
|
|
@@ -1048,8 +1184,48 @@ export function remoteHostsToDial(hosts, self) {
|
|
|
1048
1184
|
* call it, so the browser can never disagree with `--active --json` about which
|
|
1049
1185
|
* sessions are live (it used to call the local-only `getActiveSessions()` directly
|
|
1050
1186
|
* and silently hid every remote session).
|
|
1187
|
+
*
|
|
1188
|
+
* RUSH-2062: default path is cache-first against the daemon-warmed shared
|
|
1189
|
+
* snapshot (`session-cache.ts`). Menubar / Factory / watchdog / CLI share one
|
|
1190
|
+
* warm result instead of each re-running the full SSH fan-out. `forceRefresh`
|
|
1191
|
+
* (or `AGENTS_SESSIONS_FORCE_REFRESH=1`) re-gathers live; scoped `--host` lists
|
|
1192
|
+
* always gather live so a filter never returns a wrong unscoped snapshot.
|
|
1051
1193
|
*/
|
|
1052
1194
|
export async function gatherActiveSessions(opts = {}) {
|
|
1195
|
+
const forceRefresh = opts.forceRefresh === true
|
|
1196
|
+
|| process.env.AGENTS_SESSIONS_FORCE_REFRESH === '1';
|
|
1197
|
+
// Scoped host lists are not represented in the unscoped fleet/local snapshot
|
|
1198
|
+
// — always gather live so a filter cannot return the wrong set.
|
|
1199
|
+
const scoped = (opts.hosts?.length ?? 0) > 0;
|
|
1200
|
+
if (opts.local && !scoped) {
|
|
1201
|
+
const loaded = await loadLocalActiveSessions({
|
|
1202
|
+
forceRefresh,
|
|
1203
|
+
gather: async () => {
|
|
1204
|
+
const rows = await getActiveSessions({ localOnly: true });
|
|
1205
|
+
const self = machineId();
|
|
1206
|
+
for (const s of rows)
|
|
1207
|
+
if (!s.machine)
|
|
1208
|
+
s.machine = self;
|
|
1209
|
+
return rows;
|
|
1210
|
+
},
|
|
1211
|
+
});
|
|
1212
|
+
return { sessions: loaded.sessions, remoteDeviceCount: 0 };
|
|
1213
|
+
}
|
|
1214
|
+
if (!opts.local && !scoped) {
|
|
1215
|
+
const loaded = await loadFleetActiveSessions({
|
|
1216
|
+
forceRefresh,
|
|
1217
|
+
gather: () => gatherActiveSessionsLive({ local: false }),
|
|
1218
|
+
});
|
|
1219
|
+
return { sessions: loaded.sessions, remoteDeviceCount: loaded.remoteDeviceCount };
|
|
1220
|
+
}
|
|
1221
|
+
return gatherActiveSessionsLive(opts);
|
|
1222
|
+
}
|
|
1223
|
+
/**
|
|
1224
|
+
* Live (uncached) gather — the work the daemon / force-refresh path pays once
|
|
1225
|
+
* so other surfaces can share the snapshot. Kept separate so the cache layer
|
|
1226
|
+
* never recursively re-enters itself.
|
|
1227
|
+
*/
|
|
1228
|
+
async function gatherActiveSessionsLive(opts = {}) {
|
|
1053
1229
|
const self = machineId();
|
|
1054
1230
|
// An explicit --host/--device list scopes the view: seed local sessions only
|
|
1055
1231
|
// when no hosts are named, or when this machine is one of the named targets.
|
|
@@ -1057,7 +1233,9 @@ export async function gatherActiveSessions(opts = {}) {
|
|
|
1057
1233
|
// remote-host teammate over ssh even for this machine's OWN local gather —
|
|
1058
1234
|
// "this machine only" must mean zero ssh, not just "skip the cross-machine
|
|
1059
1235
|
// device fan-out below".
|
|
1060
|
-
const local = shouldIncludeLocal(opts.hosts, self)
|
|
1236
|
+
const local = shouldIncludeLocal(opts.hosts, self)
|
|
1237
|
+
? await getActiveSessions({ localOnly: opts.local })
|
|
1238
|
+
: [];
|
|
1061
1239
|
for (const s of local)
|
|
1062
1240
|
if (!s.machine)
|
|
1063
1241
|
s.machine = self;
|
|
@@ -1099,6 +1277,12 @@ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
|
|
|
1099
1277
|
? merged.filter((session) => opts.statuses.some((status) => matchesLiveStatus(session, status)))
|
|
1100
1278
|
: merged;
|
|
1101
1279
|
const sessions = statusFiltered;
|
|
1280
|
+
// Backfill agent version + ticket/PR/label/created onto the live rows from the
|
|
1281
|
+
// historical index (RUSH-2205) — a running process reports none of these, and
|
|
1282
|
+
// an orphan row usually lacks them. Done before both the JSON and human paths
|
|
1283
|
+
// so every consumer (incl. the SSH fan-out's remote --json) sees enriched rows;
|
|
1284
|
+
// transcripts sync across the fleet, so a remote row resolves from the local DB.
|
|
1285
|
+
backfillActiveRowsFromMeta(sessions, loadBackfillMetaFor(sessions));
|
|
1102
1286
|
if (asJson) {
|
|
1103
1287
|
// Resolve who is watching each local tmux pane before serializing: `viewingIn`
|
|
1104
1288
|
// is how a consumer distinguishes a session someone is looking at from one
|
|
@@ -1542,8 +1726,13 @@ limitSource) {
|
|
|
1542
1726
|
// shorthands fold into --agent, and --device is an alias for --host (both
|
|
1543
1727
|
// resolve against the same device registry).
|
|
1544
1728
|
applyAgentShorthands(options);
|
|
1545
|
-
|
|
1546
|
-
|
|
1729
|
+
// --device / --devices both alias --host. A bare `all` / `fleet` sentinel means
|
|
1730
|
+
// "search every peer" — which is already the default — so it resolves to no
|
|
1731
|
+
// explicit host set rather than erroring on a device literally named "all".
|
|
1732
|
+
const deviceTargets = [...(options.device ?? []), ...(options.devices ?? [])]
|
|
1733
|
+
.filter(t => t.toLowerCase() !== 'all' && t.toLowerCase() !== 'fleet');
|
|
1734
|
+
if (deviceTargets.length > 0) {
|
|
1735
|
+
options.host = [...(options.host ?? []), ...deviceTargets];
|
|
1547
1736
|
}
|
|
1548
1737
|
// --print-cmd: echo the canonical `ag sessions …` for the given flags and exit.
|
|
1549
1738
|
// The non-interactive twin of the browser's `y` hotkey — lets an agent compose
|
|
@@ -2830,34 +3019,23 @@ export async function resumeSessionInPlace(session) {
|
|
|
2830
3019
|
const cwd = session.cwd && fs.existsSync(session.cwd)
|
|
2831
3020
|
? session.cwd
|
|
2832
3021
|
: process.cwd();
|
|
2833
|
-
const resume =
|
|
2834
|
-
if (!resume) {
|
|
2835
|
-
console.log(chalk.yellow(`Resume is not supported for ${session.agent} sessions yet. Showing summary instead.`));
|
|
2836
|
-
await renderSession(session, 'summary', {});
|
|
2837
|
-
return;
|
|
2838
|
-
}
|
|
3022
|
+
const resume = buildSessionRecoveryCommand(session);
|
|
2839
3023
|
console.log(chalk.gray(`Resuming: ${resume.join(' ')} (cwd: ${cwd})`));
|
|
2840
|
-
// Resolve the (possibly version-pinned) launcher up front. On Windows the
|
|
2841
|
-
// agent shim is a `.cmd`/`.ps1` and, under the shell needed to run it (see
|
|
2842
|
-
// spawnResumeCommand), a missing command exits non-zero rather than emitting
|
|
2843
|
-
// an ENOENT `error` event — so detect a removed version here instead of
|
|
2844
|
-
// relying on that event, keeping the fallback working on every OS.
|
|
2845
|
-
// `resume[0]` is an absolute alias path when one exists on disk, so only a bare
|
|
2846
|
-
// name still needs a PATH lookup. Checking existsSync first is what keeps an
|
|
2847
|
-
// isolated install (shims deliberately off PATH) out of the fallback.
|
|
2848
|
-
const launcherFound = path.isAbsolute(resume[0])
|
|
2849
|
-
? fs.existsSync(resume[0])
|
|
2850
|
-
: !!findExecutable(resume[0]);
|
|
2851
|
-
if (!launcherFound && session.version) {
|
|
2852
|
-
const fallback = buildFallbackCommand(session);
|
|
2853
|
-
if (fallback) {
|
|
2854
|
-
console.log(chalk.gray(`Version ${session.version} is not installed. Resuming with the current version instead...`));
|
|
2855
|
-
await spawnResumeCommand(fallback, cwd);
|
|
2856
|
-
return;
|
|
2857
|
-
}
|
|
2858
|
-
}
|
|
2859
3024
|
await spawnResumeCommand(resume, cwd);
|
|
2860
3025
|
}
|
|
3026
|
+
/**
|
|
3027
|
+
* Relaunch this CLI's one recovery path. The owning device resolves account
|
|
3028
|
+
* health and either performs exact-home native resume or same-harness
|
|
3029
|
+
* `/continue`; callers never guess which version home can see the transcript.
|
|
3030
|
+
* `portable` is for an SSH/terminal-engine command that executes on a peer.
|
|
3031
|
+
*/
|
|
3032
|
+
export function buildSessionRecoveryCommand(session, portable = false) {
|
|
3033
|
+
const args = sessionRecoveryRunArgs(session);
|
|
3034
|
+
if (portable)
|
|
3035
|
+
return ['agents', ...args];
|
|
3036
|
+
const invocation = getAgentsInvocation(args);
|
|
3037
|
+
return [invocation.command, ...invocation.args];
|
|
3038
|
+
}
|
|
2861
3039
|
/**
|
|
2862
3040
|
* Map a resume argv to the spawn(command, args, {shell}) triple.
|
|
2863
3041
|
*
|
|
@@ -2925,8 +3103,6 @@ function spawnResumeCommand(cmd, cwd) {
|
|
|
2925
3103
|
* isolated HOME where the JSONL was written — regardless of which version is
|
|
2926
3104
|
* currently the default. Falls back to the bare shim when version is unknown.
|
|
2927
3105
|
*
|
|
2928
|
-
* If the versioned binary is missing (version was removed), the ENOENT
|
|
2929
|
-
* handler in handlePickedSession retries via buildFallbackCommand.
|
|
2930
3106
|
*/
|
|
2931
3107
|
/**
|
|
2932
3108
|
* The agent's own resume invocation, given whichever launcher we resolved.
|
|
@@ -2939,6 +3115,8 @@ function resumeArgv(agent, id, launcher) {
|
|
|
2939
3115
|
case 'claude': return [launcher, '--resume', id];
|
|
2940
3116
|
case 'codex': return [launcher, 'resume', id];
|
|
2941
3117
|
case 'opencode': return [launcher, '--session', id];
|
|
3118
|
+
// Muse interactive resume is a subcommand: `muse resume <uuid>`.
|
|
3119
|
+
case 'muse': return [launcher, 'resume', id];
|
|
2942
3120
|
default: return null;
|
|
2943
3121
|
}
|
|
2944
3122
|
}
|
|
@@ -2967,7 +3145,8 @@ export function buildResumeCommand(session) {
|
|
|
2967
3145
|
case 'opencode':
|
|
2968
3146
|
return resumeArgv('opencode', session.id, 'opencode');
|
|
2969
3147
|
case 'claude':
|
|
2970
|
-
case 'codex':
|
|
3148
|
+
case 'codex':
|
|
3149
|
+
case 'muse': {
|
|
2971
3150
|
const cli = AGENTS[session.agent]?.cliCommand ?? session.agent;
|
|
2972
3151
|
if (session.version) {
|
|
2973
3152
|
const alias = versionedAliasIfPresent(session.agent, session.version);
|
|
@@ -2990,19 +3169,6 @@ export function buildResumeCommand(session) {
|
|
|
2990
3169
|
return null;
|
|
2991
3170
|
}
|
|
2992
3171
|
}
|
|
2993
|
-
/**
|
|
2994
|
-
* Fallback when the pinned version really is gone: the same resume invocation
|
|
2995
|
-
* against the current version.
|
|
2996
|
-
*
|
|
2997
|
-
* This used to spawn `<cli> "/continue <id>"`, feeding a slash command into the
|
|
2998
|
-
* TUI as a prompt. Neither CLI has `/continue` — codex documents `/resume` — so
|
|
2999
|
-
* the agent received an unrecognised command and the session was not resumed at
|
|
3000
|
-
* all. Reusing resumeArgv keeps the two paths from drifting apart again.
|
|
3001
|
-
*/
|
|
3002
|
-
function buildFallbackCommand(session) {
|
|
3003
|
-
const cli = AGENTS[session.agent]?.cliCommand ?? session.agent;
|
|
3004
|
-
return resumeArgv(session.agent, session.id, cli);
|
|
3005
|
-
}
|
|
3006
3172
|
// ---------------------------------------------------------------------------
|
|
3007
3173
|
// Cloud session source (--cloud)
|
|
3008
3174
|
// ---------------------------------------------------------------------------
|
|
@@ -3073,25 +3239,24 @@ async function runCloudSessions(query, options) {
|
|
|
3073
3239
|
// Ensure the SessionMeta points at the local cache path for renderSession.
|
|
3074
3240
|
await renderSession({ ...meta, filePath: cachedPath }, mode, filterOpts, options);
|
|
3075
3241
|
}
|
|
3242
|
+
/** Resolve the harness portion of a sessions selector with the CLI's canonical
|
|
3243
|
+
* alias and single-typo rules. Kept pure so focus and the browser cannot drift. */
|
|
3244
|
+
export function resolveSessionAgentName(name) {
|
|
3245
|
+
const normalized = name.toLowerCase();
|
|
3246
|
+
if (SESSION_AGENTS.includes(normalized)) {
|
|
3247
|
+
return normalized;
|
|
3248
|
+
}
|
|
3249
|
+
const resolved = resolveAgentName(normalized);
|
|
3250
|
+
if (resolved && SESSION_AGENTS.includes(resolved)) {
|
|
3251
|
+
return resolved;
|
|
3252
|
+
}
|
|
3253
|
+
return fuzzyMatch(normalized, SESSION_AGENTS, FUZZY_PRESETS.agents);
|
|
3254
|
+
}
|
|
3076
3255
|
export function parseAgentFilter(agentName) {
|
|
3077
3256
|
if (!agentName)
|
|
3078
3257
|
return {};
|
|
3079
3258
|
const [name, version] = agentName.split('@', 2);
|
|
3080
|
-
|
|
3081
|
-
? name
|
|
3082
|
-
: null;
|
|
3083
|
-
if (!agent) {
|
|
3084
|
-
// Aliases and single-typo corrections (cladue -> claude). SESSION_AGENTS
|
|
3085
|
-
// includes ids (rush, hermes) that resolveAgentName doesn't know, so fall
|
|
3086
|
-
// back to fuzzy-matching the session list directly.
|
|
3087
|
-
const resolved = resolveAgentName(name);
|
|
3088
|
-
if (resolved && SESSION_AGENTS.includes(resolved)) {
|
|
3089
|
-
agent = resolved;
|
|
3090
|
-
}
|
|
3091
|
-
else {
|
|
3092
|
-
agent = fuzzyMatch(name, SESSION_AGENTS, FUZZY_PRESETS.agents);
|
|
3093
|
-
}
|
|
3094
|
-
}
|
|
3259
|
+
const agent = resolveSessionAgentName(name);
|
|
3095
3260
|
if (!agent) {
|
|
3096
3261
|
console.error(chalk.red(`Unknown agent: ${name}. Use: ${SESSION_AGENTS.join(', ')}`));
|
|
3097
3262
|
process.exit(1);
|
|
@@ -3151,13 +3316,31 @@ function ambiguityHint(byId, completeId) {
|
|
|
3151
3316
|
: 'That matched on text, not an id. Pass a session id, or narrow the search.';
|
|
3152
3317
|
}
|
|
3153
3318
|
/** Explain a complete-id miss, which no local rephrasing can fix. Echoes the
|
|
3154
|
-
* normalized id so a pasted, padded argument doesn't produce an unrunnable hint.
|
|
3319
|
+
* normalized id so a pasted, padded argument doesn't produce an unrunnable hint.
|
|
3320
|
+
* Used only where NO fleet sweep ran (a `--local` lookup, an `--artifacts`
|
|
3321
|
+
* listing, or a peer answering a parent's sweep) — the fleet-swept miss uses
|
|
3322
|
+
* {@link fleetNotFoundMessage}. Never tells the user to pass `--device`: fleet
|
|
3323
|
+
* search is already the default, so a device flag can't find what the id missed. */
|
|
3155
3324
|
function notFoundByIdMessage(query) {
|
|
3325
|
+
return [chalk.red(`No session with id ${query.trim()} on this machine.`)];
|
|
3326
|
+
}
|
|
3327
|
+
/** Honest result of a fleet sweep that found nothing: it says what the sweep
|
|
3328
|
+
* actually did — how many peers answered, which were unreachable — instead of
|
|
3329
|
+
* dead-ending on "search with --device <host>" after the search already ran. */
|
|
3330
|
+
export function fleetNotFoundMessage(query, deviceCount, unreachable) {
|
|
3156
3331
|
const id = query.trim();
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3332
|
+
if (deviceCount === 0) {
|
|
3333
|
+
return [
|
|
3334
|
+
chalk.red(`No session with id ${id} on this machine.`),
|
|
3335
|
+
chalk.gray('No other reachable devices to search.'),
|
|
3336
|
+
];
|
|
3337
|
+
}
|
|
3338
|
+
const searched = `${deviceCount} device${deviceCount === 1 ? '' : 's'}`;
|
|
3339
|
+
const lines = [chalk.red(`No session with id ${id} on this machine or ${searched} searched.`)];
|
|
3340
|
+
if (unreachable.length > 0) {
|
|
3341
|
+
lines.push(chalk.gray(`Unreachable (not searched): ${unreachable.join(', ')}`));
|
|
3342
|
+
}
|
|
3343
|
+
return lines;
|
|
3161
3344
|
}
|
|
3162
3345
|
/** Filter and rank sessions by a multi-term search query across metadata and content. */
|
|
3163
3346
|
export function filterSessionsByQuery(sessions, query) {
|
|
@@ -3380,11 +3563,15 @@ async function renderOneSession(query, mode, scope) {
|
|
|
3380
3563
|
// local not-found message. No FTS fallback either way.
|
|
3381
3564
|
if (shouldFanOutForId(query, scope.local)) {
|
|
3382
3565
|
const outcome = await resolveSessionAcrossFleet(query, mode, scope.hosts);
|
|
3383
|
-
if (outcome === 'rendered')
|
|
3566
|
+
if (outcome.kind === 'rendered')
|
|
3384
3567
|
return;
|
|
3385
|
-
if (outcome === 'conflict')
|
|
3568
|
+
if (outcome.kind === 'conflict')
|
|
3386
3569
|
process.exit(1);
|
|
3387
|
-
//
|
|
3570
|
+
// The fleet sweep already ran and found nothing — report what actually
|
|
3571
|
+
// happened, never "search with --device <host>" (fleet search is the
|
|
3572
|
+
// default, so a device flag would only re-run the same miss).
|
|
3573
|
+
fleetNotFoundMessage(query, outcome.deviceCount, outcome.unreachable).forEach(l => console.error(l));
|
|
3574
|
+
process.exit(1);
|
|
3388
3575
|
}
|
|
3389
3576
|
notFoundByIdMessage(query).forEach(l => console.error(l));
|
|
3390
3577
|
process.exit(1);
|
|
@@ -3455,16 +3642,41 @@ function modeFlag(mode) {
|
|
|
3455
3642
|
return undefined; // summary — the peer's default render
|
|
3456
3643
|
}
|
|
3457
3644
|
const FULL_SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
3645
|
+
/**
|
|
3646
|
+
* A match unique enough to resolve on the FIRST peer that returns it and cancel
|
|
3647
|
+
* the rest of the fleet sweep: a full session UUID, which is globally unique.
|
|
3648
|
+
*
|
|
3649
|
+
* Only a full UUID qualifies. A session **label** is deliberately NOT definitive
|
|
3650
|
+
* here even on an exact match: labels are free-form and can collide, so a
|
|
3651
|
+
* distinct session may carry the same label on a peer that has not answered yet
|
|
3652
|
+
* — early-exiting on the first would silently, and nondeterministically, resume
|
|
3653
|
+
* the wrong session (whichever peer replied first). Labels therefore stay
|
|
3654
|
+
* all-settle so a cross-machine label conflict surfaces as an ambiguity, and a
|
|
3655
|
+
* short-id PREFIX stays all-settle for the same reason (RUSH-2203).
|
|
3656
|
+
*/
|
|
3657
|
+
export function isDefinitiveMatch(session, selector) {
|
|
3658
|
+
return FULL_SESSION_ID_RE.test(selector) && session.id.toLowerCase() === selector.trim().toLowerCase();
|
|
3659
|
+
}
|
|
3660
|
+
/**
|
|
3661
|
+
* Whether a selector may enable early-exit on the cancellable fan-out — only a
|
|
3662
|
+
* full UUID, which is globally unique so the first hit is the only hit. Labels,
|
|
3663
|
+
* keywords, and short-id prefixes stay all-settle: their uniqueness (or
|
|
3664
|
+
* conflict) is only knowable once every peer has answered. See
|
|
3665
|
+
* {@link isDefinitiveMatch}.
|
|
3666
|
+
*/
|
|
3667
|
+
export function selectorAllowsEarlyExit(selector) {
|
|
3668
|
+
return FULL_SESSION_ID_RE.test(selector);
|
|
3669
|
+
}
|
|
3458
3670
|
/** Resolve a fleet sweep through the same canonical full-id / prefix resolver as
|
|
3459
3671
|
* local lookups, then group copies by logical session id. Synced mirrors of one
|
|
3460
3672
|
* session therefore stay one candidate even when several machines report them;
|
|
3461
3673
|
* distinct ids sharing a prefix remain distinct ambiguity candidates. */
|
|
3462
|
-
export function fleetCandidatesByQuery(rows, query) {
|
|
3674
|
+
export function fleetCandidatesByQuery(rows, query, trustResolvedRows = false) {
|
|
3463
3675
|
// An id selector must still be prefix-filtered defensively against older peers
|
|
3464
3676
|
// returning content mentioners. Keyword rows, however, were already matched by
|
|
3465
3677
|
// each peer's own FTS index; the parent does not own that transcript/index and
|
|
3466
3678
|
// must not re-run metadata-only filtering that could discard a content hit.
|
|
3467
|
-
const matched = looksLikeSessionId(query)
|
|
3679
|
+
const matched = !trustResolvedRows && looksLikeSessionId(query)
|
|
3468
3680
|
? resolveSessionQuery(rows, query, { indexFallback: false }).matches
|
|
3469
3681
|
: rows;
|
|
3470
3682
|
const byId = new Map();
|
|
@@ -3488,6 +3700,14 @@ export function fleetCandidatesByQuery(rows, query) {
|
|
|
3488
3700
|
}
|
|
3489
3701
|
/** Resolve through the canonical metadata+content union used by keyword search. */
|
|
3490
3702
|
function resolveIndexedMetadataRows(indexed, selector) {
|
|
3703
|
+
const alias = resolveSessionAlias(selector);
|
|
3704
|
+
if (alias.kind === 'resolved') {
|
|
3705
|
+
return resolveSessionQuery(indexed, alias.sessionId, { indexFallback: false }).matches;
|
|
3706
|
+
}
|
|
3707
|
+
if (alias.kind === 'ambiguous') {
|
|
3708
|
+
const ids = new Set(alias.sessionIds.map(id => id.toLowerCase()));
|
|
3709
|
+
return indexed.filter(session => ids.has(session.id.toLowerCase()));
|
|
3710
|
+
}
|
|
3491
3711
|
return resolveSessionQuery(indexed, selector, { indexFallback: false }).matches;
|
|
3492
3712
|
}
|
|
3493
3713
|
/** Fixed peer argv for the metadata resolver. Scope flags compose identically on
|
|
@@ -3513,7 +3733,18 @@ export function toolSearchForwardedArgs(argv, hosts) {
|
|
|
3513
3733
|
* A full UUID is globally unique, so one exact hit is sufficient even when an
|
|
3514
3734
|
* unrelated registered device is offline. */
|
|
3515
3735
|
export function metadataResolveOutcome(localMatches, remote, selector) {
|
|
3516
|
-
|
|
3736
|
+
// Every peer answered through --resolve-safe-v1, so its native-id row is
|
|
3737
|
+
// already the result of resolving the original selector. Re-filtering by the
|
|
3738
|
+
// selector would discard alias suffixes such as c1f3d813 because the native
|
|
3739
|
+
// UUID intentionally has a different prefix.
|
|
3740
|
+
const candidates = fleetCandidatesByQuery([...localMatches, ...remote.sessions], selector, true);
|
|
3741
|
+
// A full UUID is globally unique, so one exact hit resolves even when an
|
|
3742
|
+
// unrelated registered device is offline. A label is NOT globally unique — a
|
|
3743
|
+
// distinct session may carry the same label on an unreachable peer — so it
|
|
3744
|
+
// stays fail-closed: a unique label auto-resumes only once every peer has
|
|
3745
|
+
// answered (candidates.length === 1 below), and an unreachable peer forces
|
|
3746
|
+
// `partial` rather than guessing. (RUSH-2203: early-exit is UUID-only for the
|
|
3747
|
+
// same reason — see isDefinitiveMatch.)
|
|
3517
3748
|
if (FULL_SESSION_ID_RE.test(selector) && candidates.length === 1 && candidates[0].id.toLowerCase() === selector.toLowerCase()) {
|
|
3518
3749
|
return { kind: 'resolved', session: candidates[0].hits[0].session };
|
|
3519
3750
|
}
|
|
@@ -3533,6 +3764,12 @@ export async function resolveSessionMetadataValue(selector, scope = {}, deps = {
|
|
|
3533
3764
|
const indexed = includeLocal ? applyScopeFilters(querySessions(), scope) : [];
|
|
3534
3765
|
const localMatches = resolveIndexedMetadataRows(indexed, selector)
|
|
3535
3766
|
.map(session => ({ ...session, machine: session.machine || localMachine }));
|
|
3767
|
+
// A full-UUID local hit resolves with ZERO SSH: a UUID is globally unique, so
|
|
3768
|
+
// finding it locally is the whole answer and no peer is dialed. (A label is
|
|
3769
|
+
// NOT resolved local-only here — it can collide with a same-label session on a
|
|
3770
|
+
// peer, so it must consult the fleet; see isDefinitiveMatch, RUSH-2203.) The
|
|
3771
|
+
// local index lookup is synchronous and completes before any fan-out spawns,
|
|
3772
|
+
// so this local hit never waits on a peer.
|
|
3536
3773
|
if (FULL_SESSION_ID_RE.test(selector)) {
|
|
3537
3774
|
const localOutcome = metadataResolveOutcome(localMatches, { sessions: [], unreachable: [] }, selector);
|
|
3538
3775
|
if (localOutcome.kind === 'resolved')
|
|
@@ -3542,7 +3779,13 @@ export async function resolveSessionMetadataValue(selector, scope = {}, deps = {
|
|
|
3542
3779
|
return metadataResolveOutcome(localMatches, { sessions: [], unreachable: [] }, selector);
|
|
3543
3780
|
try {
|
|
3544
3781
|
const forwarded = metadataResolveForwardedArgs(selector, scope);
|
|
3545
|
-
|
|
3782
|
+
// Definitive lookups (full UUID / exact label) opt into early-exit: the
|
|
3783
|
+
// first peer holding the match resolves the sweep and SIGTERMs the rest, so
|
|
3784
|
+
// a fast peer's hit is not bounded by the slowest/unreachable peer. Short-id
|
|
3785
|
+
// prefixes stay all-settle (ambiguity is only known once every peer answers).
|
|
3786
|
+
const remote = await deps.gatherRemoteList(forwarded, scope.hosts, selectorAllowsEarlyExit(selector)
|
|
3787
|
+
? { isDefinitive: (session) => isDefinitiveMatch(session, selector) }
|
|
3788
|
+
: undefined);
|
|
3546
3789
|
return metadataResolveOutcome(localMatches, remote, selector);
|
|
3547
3790
|
}
|
|
3548
3791
|
catch (error) {
|
|
@@ -3586,26 +3829,11 @@ async function resolveSessionMetadata(selector, scope, deps = { gatherRemoteList
|
|
|
3586
3829
|
}
|
|
3587
3830
|
process.stdout.write(serializeResolvedSessionsJson([outcome.session]));
|
|
3588
3831
|
}
|
|
3589
|
-
/**
|
|
3590
|
-
* Locate a full session id or short id prefix across the online fleet and render it from the machine
|
|
3591
|
-
* that holds it. The local disk already missed; this fans `sessions <id> --json
|
|
3592
|
-
* --all` out to every registered online peer (or the explicit `hosts` set),
|
|
3593
|
-
* groups the rows to distinct machines, then:
|
|
3594
|
-
*
|
|
3595
|
-
* - exactly one logical session → delegate rendering to one peer via `runOnPeer`
|
|
3596
|
-
* (its transcript and agent binary live there — a local `--host` hop would
|
|
3597
|
-
* re-discover locally and dead-end), returning `'rendered'`.
|
|
3598
|
-
* - more than one logical session → print every full-id candidate with its
|
|
3599
|
-
* machine labels, returning `'conflict'`.
|
|
3600
|
-
* - none → `'not-found'`, letting the caller print the local
|
|
3601
|
-
* "no session on this machine" message.
|
|
3602
|
-
*
|
|
3603
|
-
* No fuzzy/content fallback: the sweep forwards the id selector and every result
|
|
3604
|
-
* is resolved through `resolveSessionQuery`, the same id-only resolver used locally.
|
|
3605
|
-
*/
|
|
3606
3832
|
export async function resolveSessionAcrossFleet(query, mode, hosts, deps = { gatherRemoteList, runOnPeer }) {
|
|
3607
3833
|
const spinner = isInteractiveTerminal() ? ora('Searching the fleet...').start() : null;
|
|
3608
|
-
let candidates;
|
|
3834
|
+
let candidates = [];
|
|
3835
|
+
let deviceCount = 0;
|
|
3836
|
+
let unreachable = [];
|
|
3609
3837
|
try {
|
|
3610
3838
|
// Force whole-index scope (--all): the peer runs in its SSH-login home dir,
|
|
3611
3839
|
// whose cwd would otherwise silently narrow the lookup and hide the row.
|
|
@@ -3613,19 +3841,23 @@ export async function resolveSessionAcrossFleet(query, mode, hosts, deps = { gat
|
|
|
3613
3841
|
// itself and never re-fans-out (belt-and-suspenders with the parent's
|
|
3614
3842
|
// AGENTS_SESSIONS_LOCAL, which remote-list also sets on the peer).
|
|
3615
3843
|
const forwarded = ['sessions', query, '--json', '--all', '--local'];
|
|
3616
|
-
const
|
|
3617
|
-
|
|
3844
|
+
const remote = await deps.gatherRemoteList(forwarded, hosts, selectorAllowsEarlyExit(query)
|
|
3845
|
+
? { isDefinitive: (session) => isDefinitiveMatch(session, query) }
|
|
3846
|
+
: undefined);
|
|
3847
|
+
candidates = fleetCandidatesByQuery(remote.sessions, query);
|
|
3848
|
+
deviceCount = remote.deviceCount;
|
|
3849
|
+
unreachable = remote.unreachable;
|
|
3618
3850
|
}
|
|
3619
3851
|
catch {
|
|
3620
3852
|
// A fan-out failure is not an exact resolution — treat as not-found so the
|
|
3621
|
-
// caller prints the honest
|
|
3853
|
+
// caller prints the honest message rather than a half-answer.
|
|
3622
3854
|
candidates = [];
|
|
3623
3855
|
}
|
|
3624
3856
|
finally {
|
|
3625
3857
|
spinner?.stop();
|
|
3626
3858
|
}
|
|
3627
3859
|
if (candidates.length === 0)
|
|
3628
|
-
return 'not-found';
|
|
3860
|
+
return { kind: 'not-found', deviceCount, unreachable };
|
|
3629
3861
|
if (candidates.length > 1) {
|
|
3630
3862
|
console.error(chalk.red(`Multiple sessions match "${query}" across the fleet:`));
|
|
3631
3863
|
for (const candidate of candidates) {
|
|
@@ -3635,7 +3867,7 @@ export async function resolveSessionAcrossFleet(query, mode, hosts, deps = { gat
|
|
|
3635
3867
|
console.error(chalk.cyan(` ${s.shortId} ${s.id}`) + chalk.gray(` ${machines} ${s.agent}${s.version ? ` ${s.version}` : ''} ${label}`));
|
|
3636
3868
|
}
|
|
3637
3869
|
console.error(chalk.gray('Pass a longer ID to narrow it down.'));
|
|
3638
|
-
return 'conflict';
|
|
3870
|
+
return { kind: 'conflict' };
|
|
3639
3871
|
}
|
|
3640
3872
|
const candidate = candidates[0];
|
|
3641
3873
|
const { machine } = candidate.hits[0];
|
|
@@ -3651,9 +3883,9 @@ export async function resolveSessionAcrossFleet(query, mode, hosts, deps = { gat
|
|
|
3651
3883
|
if (result === 'no-target') {
|
|
3652
3884
|
console.error(chalk.red(`Session ${candidate.id} is on ${machine}, but it is not a reachable registered device.`));
|
|
3653
3885
|
console.error(chalk.gray('Register it with `agents devices` or run the command on that machine.'));
|
|
3654
|
-
return 'conflict'; // a definitive answer (found, un-renderable) — do NOT fall to the local not-found line
|
|
3886
|
+
return { kind: 'conflict' }; // a definitive answer (found, un-renderable) — do NOT fall to the local not-found line
|
|
3655
3887
|
}
|
|
3656
|
-
return 'rendered';
|
|
3888
|
+
return { kind: 'rendered' };
|
|
3657
3889
|
}
|
|
3658
3890
|
/** Register the `agents sessions` command with all its options and help text. */
|
|
3659
3891
|
export function registerSessionsCommands(program) {
|
|
@@ -3673,7 +3905,7 @@ export function registerSessionsCommands(program) {
|
|
|
3673
3905
|
.option('--opencode', 'Shorthand for --agent opencode')
|
|
3674
3906
|
.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.')
|
|
3675
3907
|
.option('--unmanaged', "Also show sessions from your own ~/.<agent> installs (hidden once agents-cli manages that agent)")
|
|
3676
|
-
.option('--teams', 'Include team-spawned sessions (hidden by default)')
|
|
3908
|
+
.option('--team, --teams', 'Include team-spawned sessions (hidden by default)')
|
|
3677
3909
|
.option('--in-team <name>', "Only this team: the session that spawned it plus (with --teams) its teammates. Spans every directory and all time, since a team's worktrees and history sit outside the default window.")
|
|
3678
3910
|
.option('--routine', 'Show only sessions archived from routine runs')
|
|
3679
3911
|
.option('-p, --project <name>', 'Filter by project name (searches across all directories)')
|
|
@@ -3711,7 +3943,8 @@ export function registerSessionsCommands(program) {
|
|
|
3711
3943
|
.option('--no-live', 'Do not enrich the listing with live status/preview for running sessions')
|
|
3712
3944
|
.option('--cloud', 'Source sessions from Rush Cloud (captured runs) instead of local disk')
|
|
3713
3945
|
.option('-H, --host <target...>', 'Run this query on remote machine(s) over SSH (host alias or user@host; repeatable)')
|
|
3714
|
-
.option('--device <target...>', 'Alias for --host (device alias from `agents devices`; repeatable)')
|
|
3946
|
+
.option('--device <target...>', 'Alias for --host (device alias from `agents devices`; repeatable). `--device all` searches the whole fleet (the default).')
|
|
3947
|
+
.addOption(new Option('--devices <target...>', 'Plural alias for --device (accepts `all`/`fleet`).').hideHelp())
|
|
3715
3948
|
.option('--fleet', 'With --include tools: query every registered online compute device and merge compact matches')
|
|
3716
3949
|
.option('--count', 'With one program:<name> tool query: count static occurrences, containing calls, and sessions')
|
|
3717
3950
|
.option('--browser', 'List browser-profile captures (screenshots, PDFs, recordings, downloads) instead of agent transcripts — alias of `agents browser sessions`')
|
|
@@ -3739,7 +3972,7 @@ export function registerSessionsCommands(program) {
|
|
|
3739
3972
|
agents sessions --crashed
|
|
3740
3973
|
|
|
3741
3974
|
# --- Session lifecycle (one verb per intent) ---
|
|
3742
|
-
#
|
|
3975
|
+
# Focus a session (attach its living terminal, or recover an ended one)
|
|
3743
3976
|
agents sessions focus a1b2c3d4
|
|
3744
3977
|
# Attach only — never fork a copy (old: sessions go)
|
|
3745
3978
|
agents sessions focus a1b2c3d4 --attach-only
|
|
@@ -3791,7 +4024,7 @@ export function registerSessionsCommands(program) {
|
|
|
3791
4024
|
`,
|
|
3792
4025
|
notes: `
|
|
3793
4026
|
Session lifecycle (pick one verb — they are not synonyms):
|
|
3794
|
-
focus [
|
|
4027
|
+
focus [selector] attach a living pane, or recover on the origin device
|
|
3795
4028
|
focus [id] --attach-only attach only; never fork (replaces sessions go)
|
|
3796
4029
|
detach <id> interactive → headless continuation
|
|
3797
4030
|
attach <id> headless → interactive in this terminal
|
|
@@ -3823,6 +4056,7 @@ export function registerSessionsCommands(program) {
|
|
|
3823
4056
|
});
|
|
3824
4057
|
registerSessionsTailCommand(sessionsCmd);
|
|
3825
4058
|
registerSessionsResumeCommand(sessionsCmd);
|
|
4059
|
+
registerSessionsForkCommand(sessionsCmd);
|
|
3826
4060
|
registerSessionsFavoriteCommand(sessionsCmd);
|
|
3827
4061
|
registerGoCommand(sessionsCmd);
|
|
3828
4062
|
registerFocusCommand(sessionsCmd);
|