@phnx-labs/agents-cli 1.20.88 → 1.20.90
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +503 -0
- package/README.md +15 -1
- package/dist/bin/agents +0 -0
- package/dist/commands/commands.js +7 -7
- package/dist/commands/exec.js +7 -1
- package/dist/commands/factory.js +26 -2
- package/dist/commands/funnel.js +16 -1
- package/dist/commands/harness.d.ts +27 -0
- package/dist/commands/harness.js +120 -13
- package/dist/commands/menubar.js +117 -34
- package/dist/commands/profiles.d.ts +3 -0
- package/dist/commands/profiles.js +1 -1
- package/dist/commands/routines.d.ts +19 -0
- package/dist/commands/routines.js +51 -7
- package/dist/commands/secrets-rotate-passphrase.d.ts +17 -0
- package/dist/commands/secrets-rotate-passphrase.js +96 -0
- package/dist/commands/secrets.d.ts +10 -1
- package/dist/commands/secrets.js +20 -6
- package/dist/commands/sessions-browser.d.ts +4 -0
- package/dist/commands/sessions-browser.js +51 -9
- package/dist/commands/sessions-favorite.d.ts +20 -0
- package/dist/commands/sessions-favorite.js +120 -0
- package/dist/commands/sessions.d.ts +110 -21
- package/dist/commands/sessions.js +395 -74
- package/dist/commands/setup-secrets.d.ts +7 -0
- package/dist/commands/setup-secrets.js +12 -9
- package/dist/commands/versions.js +12 -4
- package/dist/commands/view.d.ts +14 -1
- package/dist/commands/view.js +103 -128
- package/dist/commands/webhook.js +7 -2
- package/dist/lib/agents.d.ts +4 -2
- package/dist/lib/agents.js +21 -6
- package/dist/lib/commands.js +9 -1
- package/dist/lib/daemon.d.ts +29 -0
- package/dist/lib/daemon.js +58 -4
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/factory/snapshot.d.ts +78 -0
- package/dist/lib/factory/snapshot.js +209 -0
- package/dist/lib/fs-atomic.d.ts +14 -1
- package/dist/lib/fs-atomic.js +35 -3
- package/dist/lib/funnel.d.ts +1 -0
- package/dist/lib/funnel.js +8 -0
- package/dist/lib/hosts/dispatch.js +19 -1
- package/dist/lib/hq/floor.js +12 -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 +2 -2
- package/dist/lib/menubar/install-menubar.d.ts +53 -2
- package/dist/lib/menubar/install-menubar.js +183 -28
- package/dist/lib/picker.d.ts +27 -2
- package/dist/lib/picker.js +71 -7
- package/dist/lib/platform/process.d.ts +2 -0
- package/dist/lib/platform/process.js +5 -3
- package/dist/lib/profiles.d.ts +48 -0
- package/dist/lib/profiles.js +67 -0
- package/dist/lib/resources.d.ts +8 -0
- package/dist/lib/resources.js +34 -1
- package/dist/lib/rotate.d.ts +24 -2
- package/dist/lib/rotate.js +63 -6
- package/dist/lib/routines-placement.d.ts +2 -1
- package/dist/lib/routines-placement.js +8 -4
- package/dist/lib/routines.d.ts +57 -1
- package/dist/lib/routines.js +74 -1
- package/dist/lib/runner.d.ts +2 -0
- package/dist/lib/runner.js +21 -8
- 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 +9 -34
- package/dist/lib/secrets/filestore.d.ts +152 -34
- package/dist/lib/secrets/filestore.js +676 -123
- package/dist/lib/session/active.d.ts +109 -3
- package/dist/lib/session/active.js +269 -13
- package/dist/lib/session/db.d.ts +14 -0
- package/dist/lib/session/db.js +35 -0
- package/dist/lib/session/favorites.d.ts +39 -0
- package/dist/lib/session/favorites.js +101 -0
- package/dist/lib/session/host-link.d.ts +68 -0
- package/dist/lib/session/host-link.js +64 -0
- package/dist/lib/session/presence.d.ts +85 -0
- package/dist/lib/session/presence.js +150 -0
- package/dist/lib/session/remote-active.d.ts +4 -1
- package/dist/lib/session/remote-active.js +8 -2
- package/dist/lib/session/remote-list.d.ts +10 -0
- package/dist/lib/session/remote-list.js +47 -9
- package/dist/lib/session/viewing-in.d.ts +31 -0
- package/dist/lib/session/viewing-in.js +47 -0
- package/dist/lib/state.d.ts +17 -0
- package/dist/lib/state.js +30 -2
- package/dist/lib/tmux/binary.d.ts +7 -0
- package/dist/lib/tmux/binary.js +11 -1
- package/dist/lib/triggers/handlers.d.ts +95 -0
- package/dist/lib/triggers/handlers.js +384 -0
- package/dist/lib/triggers/webhook.d.ts +10 -2
- package/dist/lib/triggers/webhook.js +65 -11
- package/dist/lib/types.d.ts +4 -3
- package/dist/lib/usage-backoff.d.ts +29 -0
- package/dist/lib/usage-backoff.js +165 -0
- package/dist/lib/usage.d.ts +112 -5
- package/dist/lib/usage.js +464 -46
- package/dist/lib/watchdog/runner.d.ts +13 -0
- package/dist/lib/watchdog/runner.js +16 -1
- package/package.json +1 -1
|
@@ -11,6 +11,7 @@ import * as fs from 'fs';
|
|
|
11
11
|
import * as os from 'os';
|
|
12
12
|
import * as path from 'path';
|
|
13
13
|
import { spawn } from 'child_process';
|
|
14
|
+
import { Option } from 'commander';
|
|
14
15
|
import chalk from 'chalk';
|
|
15
16
|
import { truncate, padRight } from '../lib/format.js';
|
|
16
17
|
import ora from 'ora';
|
|
@@ -20,14 +21,14 @@ import { looksLikePath, toComparablePath, homeDir, needsWindowsShell, findExecut
|
|
|
20
21
|
import { getActiveSessions } from '../lib/session/active.js';
|
|
21
22
|
import { enumerateGhosttyTabs, assignGhosttyTabs } from '../lib/session/ghostty-tabs.js';
|
|
22
23
|
import { mapPanesToTargets, listClients } from '../lib/tmux/session.js';
|
|
23
|
-
import { resolveViewingIn } from '../lib/session/viewing-in.js';
|
|
24
|
+
import { resolveViewingIn, viewingInLabel } from '../lib/session/viewing-in.js';
|
|
24
25
|
import { machineId, normalizeHost } from '../lib/session/sync/config.js';
|
|
25
26
|
import { gatherRemoteActive, NO_FANOUT_ENV } from '../lib/session/remote-active.js';
|
|
26
27
|
import { gatherRemoteList, runOnPeer } from '../lib/session/remote-list.js';
|
|
27
28
|
import { stringWidth, truncateToWidth, padToWidth, terminalWidth } from '../lib/session/width.js';
|
|
28
29
|
import { inferSessionState } from '../lib/session/state.js';
|
|
29
30
|
import { discoverSessions, countSessionsInScope, resolveSessionById, isCompleteSessionId, looksLikeSessionId, searchContentIndex, getSessionRoots } from '../lib/session/discover.js';
|
|
30
|
-
import { findSessionsById } from '../lib/session/db.js';
|
|
31
|
+
import { findSessionsById, querySessions } from '../lib/session/db.js';
|
|
31
32
|
import { filterTeamSessions, safeTeamText } from '../lib/session/team-filter.js';
|
|
32
33
|
import { parseSession } from '../lib/session/parse.js';
|
|
33
34
|
import { runRemoteSessions, buildForwardedArgs, ensureWholeIndex } from '../lib/session/remote.js';
|
|
@@ -45,6 +46,8 @@ import { setHelpSections } from '../lib/help.js';
|
|
|
45
46
|
import { registerSessionsTailCommand } from './sessions-tail.js';
|
|
46
47
|
import { registerSessionsSyncCommand } from './sessions-sync.js';
|
|
47
48
|
import { registerSessionsResumeCommand } from './sessions-resume.js';
|
|
49
|
+
import { registerSessionsFavoriteCommand } from './sessions-favorite.js';
|
|
50
|
+
import { isFavorite, listFavorites } from '../lib/session/favorites.js';
|
|
48
51
|
import { registerGoCommand } from './go.js';
|
|
49
52
|
import { registerFocusCommand } from './focus.js';
|
|
50
53
|
import { registerDetachCommand } from './detach.js';
|
|
@@ -195,6 +198,12 @@ function statusColor(status) {
|
|
|
195
198
|
case 'closed': return chalk.dim;
|
|
196
199
|
// Days-stale / dangling: red so a session nobody is driving stands out.
|
|
197
200
|
case 'abandoned': return chalk.red;
|
|
201
|
+
// The host window died and took the agent with it — an unclean exit, not the
|
|
202
|
+
// dimmed `closed` of a normal one. Red-bright so a crash reads as an event.
|
|
203
|
+
case 'crashed': return chalk.redBright;
|
|
204
|
+
// Alive with nobody attached. Yellow, like `input_required`: both mean the
|
|
205
|
+
// session is stuck waiting on a human, and this one has no human to wait for.
|
|
206
|
+
case 'orphaned': return chalk.yellow;
|
|
198
207
|
// Alive but un-introspectable (a harness whose transcript we can't parse).
|
|
199
208
|
// Magenta so it never reads as the gray "idle" it used to be faked as.
|
|
200
209
|
case 'unknown': return chalk.magenta;
|
|
@@ -329,8 +338,14 @@ export function formatActiveRowDescription(s) {
|
|
|
329
338
|
function activityLabel(s) {
|
|
330
339
|
// Lifecycle status wins over any residual parsed activity — a dead/dangling
|
|
331
340
|
// session must read `closed`/`abandoned`, not the `idle` its stale tail infers.
|
|
341
|
+
// `crashed`/`orphaned` are the same kind of claim about the session as a whole,
|
|
342
|
+
// and both outrank the `idle` its last parsed turn would otherwise show.
|
|
332
343
|
if (s.status === 'closed' || s.status === 'abandoned')
|
|
333
344
|
return s.status;
|
|
345
|
+
if (s.status === 'crashed')
|
|
346
|
+
return 'crashed';
|
|
347
|
+
if (s.status === 'orphaned')
|
|
348
|
+
return 'orphan';
|
|
334
349
|
if (s.activity === 'waiting_input')
|
|
335
350
|
return 'waiting';
|
|
336
351
|
if (s.activity === 'working')
|
|
@@ -367,10 +382,17 @@ export function liveGlyphAndPreview(a) {
|
|
|
367
382
|
// so an opaque harness is never mistaken for a finished one. `⊘` = abandoned /
|
|
368
383
|
// dangling; `×` = closed (dead pid) — both distinct from the `○` idle a live,
|
|
369
384
|
// stopped session shows, so a gone session never masquerades as a resting one.
|
|
385
|
+
// `✗` = crashed (died WITH its host window, uncleanly) — deliberately louder
|
|
386
|
+
// than the `×` of a clean close. `◍` = orphaned (alive, nothing attached): a
|
|
387
|
+
// filled ring, so it reads as "still burning" unlike the hollow `○` idle.
|
|
370
388
|
if (a.status === 'abandoned')
|
|
371
389
|
return { glyph: statusColor(a.status)('⊘'), preview: buildSessionDescription(a) };
|
|
372
390
|
if (a.status === 'closed')
|
|
373
391
|
return { glyph: statusColor(a.status)('×'), preview: buildSessionDescription(a) };
|
|
392
|
+
if (a.status === 'crashed')
|
|
393
|
+
return { glyph: statusColor(a.status)('✗'), preview: buildSessionDescription(a) };
|
|
394
|
+
if (a.status === 'orphaned')
|
|
395
|
+
return { glyph: statusColor(a.status)('◍'), preview: buildSessionDescription(a) };
|
|
374
396
|
const waiting = a.status === 'input_required' || a.activity === 'waiting_input';
|
|
375
397
|
const running = a.status === 'running' || a.activity === 'working';
|
|
376
398
|
const unknown = a.status === 'unknown';
|
|
@@ -394,6 +416,11 @@ export function liveStatusWord(a) {
|
|
|
394
416
|
// Lifecycle status is definitive — surface it ahead of any parsed activity.
|
|
395
417
|
if (a.status === 'closed' || a.status === 'abandoned')
|
|
396
418
|
return a.status;
|
|
419
|
+
// Same rank: a lost host is a fact about the session, not about its last turn.
|
|
420
|
+
if (a.status === 'crashed')
|
|
421
|
+
return 'crashed';
|
|
422
|
+
if (a.status === 'orphaned')
|
|
423
|
+
return 'orphan';
|
|
397
424
|
if (a.status === 'input_required' || a.activity === 'waiting_input')
|
|
398
425
|
return 'waiting';
|
|
399
426
|
if (a.status === 'running' || a.activity === 'working')
|
|
@@ -404,12 +431,43 @@ export function liveStatusWord(a) {
|
|
|
404
431
|
return 'queued';
|
|
405
432
|
return '';
|
|
406
433
|
}
|
|
434
|
+
/**
|
|
435
|
+
* True when a session is blocked on a human — the `--waiting` contract.
|
|
436
|
+
*
|
|
437
|
+
* NOT `status === 'input_required'`. `foldHostLink` rewrites that status to
|
|
438
|
+
* `orphaned` when nothing is attached, and a session waiting on a question with
|
|
439
|
+
* NOBODY watching is the most acute case `--waiting` exists to surface, not one
|
|
440
|
+
* it should drop. The underlying `activity` is never rewritten, so it is the
|
|
441
|
+
* honest signal here.
|
|
442
|
+
*
|
|
443
|
+
* But `activity` is never rewritten for a DEAD session either: one that died
|
|
444
|
+
* mid-question keeps `waiting_input` forever, and answering it is not a thing a
|
|
445
|
+
* human can do — it needs a relaunch. `--waiting` is a scriptable gate ("does
|
|
446
|
+
* anything need me?"), so a corpse must not trip it.
|
|
447
|
+
*
|
|
448
|
+
* `closed` and `crashed` are unconditionally dead, so they are excluded outright.
|
|
449
|
+
* `abandoned` is NOT: it fires on transcript staleness before the liveness check,
|
|
450
|
+
* so it also covers the live-but-forgotten case — an interactive session that
|
|
451
|
+
* asked a question and sat untouched over a long weekend is still answerable, and
|
|
452
|
+
* is exactly what this gate exists for. It is excluded only when we positively
|
|
453
|
+
* know its process is gone; unknown liveness (an older peer, a row with no pid)
|
|
454
|
+
* stays excluded rather than inventing a human who can answer.
|
|
455
|
+
*/
|
|
456
|
+
export function isAwaitingUser(s) {
|
|
457
|
+
if (s.status === 'crashed' || s.status === 'closed')
|
|
458
|
+
return false;
|
|
459
|
+
if (s.status === 'abandoned' && s.pidAlive !== true)
|
|
460
|
+
return false;
|
|
461
|
+
return s.status === 'input_required' || s.activity === 'waiting_input';
|
|
462
|
+
}
|
|
463
|
+
/** Width of the live status column — `crashed` is the longest word it renders. */
|
|
464
|
+
const LIVE_STATUS_W = 8;
|
|
407
465
|
/** The colored, space-padded status cell for a listing row (empty when not live). */
|
|
408
466
|
function liveStatusCell(live) {
|
|
409
467
|
const word = liveStatusWord(live);
|
|
410
468
|
if (!word || !live)
|
|
411
469
|
return { cell: '', width: 0 };
|
|
412
|
-
return { cell: statusColor(live.status)(padToWidth(word,
|
|
470
|
+
return { cell: statusColor(live.status)(padToWidth(word, LIVE_STATUS_W)), width: LIVE_STATUS_W };
|
|
413
471
|
}
|
|
414
472
|
/**
|
|
415
473
|
* The tracker/PR ref for a session's dedicated column: the ticket id when known,
|
|
@@ -442,6 +500,11 @@ function modelLabel(model) {
|
|
|
442
500
|
* (null when unknown) alongside the raw fields, so every active row is joinable.
|
|
443
501
|
* `project` uses the same derivation SessionMeta does — basename(cwd) (see
|
|
444
502
|
* discover.ts) — so the active view and the history view join identically.
|
|
503
|
+
*
|
|
504
|
+
* `viewingIn` flattens to the same display string the row renderer prints —
|
|
505
|
+
* `'codium tab 3'` / `'detached'` / null — so a consumer can tell a watched
|
|
506
|
+
* session from an orphaned one (its terminal died, the agent is still running)
|
|
507
|
+
* without re-implementing the tmux client lookup.
|
|
445
508
|
*/
|
|
446
509
|
export function serializeActiveSessionsForJson(sessions) {
|
|
447
510
|
return sessions.map((s) => ({
|
|
@@ -449,6 +512,7 @@ export function serializeActiveSessionsForJson(sessions) {
|
|
|
449
512
|
ticketId: s.ticket?.id ?? null,
|
|
450
513
|
project: s.cwd ? path.basename(s.cwd) : null,
|
|
451
514
|
prLink: s.pr?.url ?? null,
|
|
515
|
+
viewingIn: viewingInLabel(s) ?? null,
|
|
452
516
|
}));
|
|
453
517
|
}
|
|
454
518
|
/**
|
|
@@ -496,13 +560,9 @@ function locatorBadge(s) {
|
|
|
496
560
|
// For a tmux-hosted session, say which app+tab is looking at it right now
|
|
497
561
|
// (or that it's running detached). Only meaningful for tmux (the pane is the
|
|
498
562
|
// durable handle; the viewer is transient).
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
parts.push(chalk.gray(`viewing in ${
|
|
502
|
-
}
|
|
503
|
-
else {
|
|
504
|
-
parts.push(chalk.gray('detached'));
|
|
505
|
-
}
|
|
563
|
+
const label = viewingInLabel(s);
|
|
564
|
+
if (label)
|
|
565
|
+
parts.push(chalk.gray(label === 'detached' ? label : `viewing in ${label}`));
|
|
506
566
|
}
|
|
507
567
|
else if (p?.mux?.kind === 'screen') {
|
|
508
568
|
parts.push(chalk.green('screen'));
|
|
@@ -743,6 +803,25 @@ export function serializeSessionsJson(sessions) {
|
|
|
743
803
|
});
|
|
744
804
|
return JSON.stringify(serializable, null, 2) + '\n';
|
|
745
805
|
}
|
|
806
|
+
/** The intentionally small metadata contract emitted by `sessions --resolve`.
|
|
807
|
+
* Transcript locations, extracted plans, account data, costs, and other indexed
|
|
808
|
+
* payload stay local to the machine that owns them. */
|
|
809
|
+
export function serializeResolvedSessionsJson(sessions) {
|
|
810
|
+
const safe = sessions.map((session) => ({
|
|
811
|
+
id: session.id,
|
|
812
|
+
shortId: session.shortId,
|
|
813
|
+
agent: session.agent,
|
|
814
|
+
origin: session.origin,
|
|
815
|
+
timestamp: session.timestamp,
|
|
816
|
+
lastActivity: session.lastActivity,
|
|
817
|
+
project: session.project,
|
|
818
|
+
version: session.version,
|
|
819
|
+
label: session.label,
|
|
820
|
+
topic: session.topic,
|
|
821
|
+
machine: session.machine,
|
|
822
|
+
}));
|
|
823
|
+
return JSON.stringify(safe, null, 2) + '\n';
|
|
824
|
+
}
|
|
746
825
|
/**
|
|
747
826
|
* `agents sessions --json --host <h>` — fan the RECENT (non-active) listing out
|
|
748
827
|
* to the named host(s) and emit ONE clean merged `SessionMeta[]` JSON array,
|
|
@@ -775,9 +854,15 @@ function groupTally(sessions) {
|
|
|
775
854
|
const running = sessions.filter(s => s.status === 'running').length;
|
|
776
855
|
const idle = sessions.filter(s => s.status === 'idle').length;
|
|
777
856
|
const waiting = sessions.filter(s => s.status === 'input_required').length;
|
|
857
|
+
// Counted by status, deliberately: an orphaned session gets its own bucket
|
|
858
|
+
// below, so counting it here too would double-count it in the tally.
|
|
778
859
|
const queued = sessions.filter(s => s.status === 'queued').length;
|
|
779
860
|
const closed = sessions.filter(s => s.status === 'closed').length;
|
|
780
861
|
const abandoned = sessions.filter(s => s.status === 'abandoned').length;
|
|
862
|
+
// Without these two the tally silently loses rows: every status must have a
|
|
863
|
+
// bucket or "N active (…)" stops adding up to what the list shows.
|
|
864
|
+
const orphaned = sessions.filter(s => s.status === 'orphaned').length;
|
|
865
|
+
const crashed = sessions.filter(s => s.status === 'crashed').length;
|
|
781
866
|
const unknown = sessions.filter(s => s.status === 'unknown').length;
|
|
782
867
|
const parts = [];
|
|
783
868
|
if (running)
|
|
@@ -792,6 +877,10 @@ function groupTally(sessions) {
|
|
|
792
877
|
parts.push(`${closed} closed`);
|
|
793
878
|
if (abandoned)
|
|
794
879
|
parts.push(`${abandoned} abandoned`);
|
|
880
|
+
if (orphaned)
|
|
881
|
+
parts.push(`${orphaned} orphaned`);
|
|
882
|
+
if (crashed)
|
|
883
|
+
parts.push(`${crashed} crashed`);
|
|
795
884
|
if (unknown)
|
|
796
885
|
parts.push(`${unknown} unknown`);
|
|
797
886
|
return parts.join(' · ');
|
|
@@ -860,13 +949,33 @@ async function enrichLocalLocators(local) {
|
|
|
860
949
|
}
|
|
861
950
|
}
|
|
862
951
|
catch { /* non-fatal */ }
|
|
863
|
-
//
|
|
952
|
+
// One Ghostty enumeration shared across every socket's viewing-in resolve
|
|
953
|
+
// (a tmux client can be attached from a Ghostty tab).
|
|
954
|
+
await enrichTmuxLocators(local, await enumerateGhosttyTabsQuietly());
|
|
955
|
+
}
|
|
956
|
+
/** {@link enumerateGhosttyTabs}, best-effort — an osascript failure yields no surfaces. */
|
|
957
|
+
async function enumerateGhosttyTabsQuietly() {
|
|
958
|
+
try {
|
|
959
|
+
return await enumerateGhosttyTabs();
|
|
960
|
+
}
|
|
961
|
+
catch {
|
|
962
|
+
return [];
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* The tmux half of {@link enrichLocalLocators}: the `session:window.pane` attach
|
|
967
|
+
* target and "viewing in <app> tab N" / detached, one batched query per socket.
|
|
968
|
+
*
|
|
969
|
+
* Split out because it is the only locator the `--json` path can afford. It costs
|
|
970
|
+
* tmux queries and a `ps` read — no osascript — so scriptable output stays cheap
|
|
971
|
+
* while still answering the question a consumer actually needs: is anyone looking
|
|
972
|
+
* at this session, or is it running orphaned? Without `surfaces`, a Ghostty-attached
|
|
973
|
+
* client still resolves as attached, just without its tab number.
|
|
974
|
+
*/
|
|
975
|
+
async function enrichTmuxLocators(local, surfaces = []) {
|
|
864
976
|
try {
|
|
865
977
|
const tmux = local.filter(s => s.provenance?.mux?.kind === 'tmux' && s.provenance.mux.pane);
|
|
866
978
|
if (tmux.length > 0) {
|
|
867
|
-
// One Ghostty enumeration shared across every socket's viewing-in resolve
|
|
868
|
-
// (a tmux client can be attached from a Ghostty tab).
|
|
869
|
-
const surfaces = await enumerateGhosttyTabs();
|
|
870
979
|
const sockets = new Set(tmux.map(s => s.provenance.mux.socket));
|
|
871
980
|
for (const socket of sockets) {
|
|
872
981
|
const paneMap = await mapPanesToTargets(socket);
|
|
@@ -959,11 +1068,25 @@ export async function gatherActiveSessions(opts = {}) {
|
|
|
959
1068
|
*/
|
|
960
1069
|
async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
|
|
961
1070
|
const self = machineId();
|
|
962
|
-
const
|
|
1071
|
+
const gathered = await gatherActiveSessions(opts);
|
|
1072
|
+
const { remoteDeviceCount } = gathered;
|
|
1073
|
+
// --favorites narrows the live view too. Applied HERE, not only in the
|
|
1074
|
+
// browser: the browser is skipped for --json, --waiting, a pipe, a multi-host
|
|
1075
|
+
// scope, and an SSH-fanout peer, and the flag silently did nothing on every
|
|
1076
|
+
// one of those paths — including `--active --favorites --json`, which is
|
|
1077
|
+
// exactly what the browser's own `y` copy-cmd hands to an agent.
|
|
1078
|
+
const merged = opts.favoritesOnly
|
|
1079
|
+
? gathered.sessions.filter((s) => !!s.sessionId && listFavorites().has(s.sessionId))
|
|
1080
|
+
: gathered.sessions;
|
|
963
1081
|
// --waiting: only sessions blocked on the user. Exits non-zero when any are
|
|
964
1082
|
// present so a supervising agent or hook can poll it as a gate.
|
|
965
|
-
const sessions = waitingOnly ? merged.filter(
|
|
1083
|
+
const sessions = waitingOnly ? merged.filter(isAwaitingUser) : merged;
|
|
966
1084
|
if (asJson) {
|
|
1085
|
+
// Resolve who is watching each local tmux pane before serializing: `viewingIn`
|
|
1086
|
+
// is how a consumer distinguishes a session someone is looking at from one
|
|
1087
|
+
// running orphaned after its terminal died. tmux-only (no osascript) so the
|
|
1088
|
+
// scriptable path stays cheap — see enrichTmuxLocators.
|
|
1089
|
+
await enrichTmuxLocators(sessions.filter(s => !s.machine || s.machine === self));
|
|
967
1090
|
process.stdout.write(JSON.stringify(serializeActiveSessionsForJson(sessions), null, 2) + '\n');
|
|
968
1091
|
if (waitingOnly && sessions.length > 0)
|
|
969
1092
|
process.exitCode = 1;
|
|
@@ -1084,6 +1207,8 @@ function canonicalSessionsCommand(query, options) {
|
|
|
1084
1207
|
a.push('--local');
|
|
1085
1208
|
if (options.waiting)
|
|
1086
1209
|
a.push('--waiting');
|
|
1210
|
+
if (options.favorites)
|
|
1211
|
+
a.push('--favorites');
|
|
1087
1212
|
const q = (query ?? '').trim();
|
|
1088
1213
|
if (q)
|
|
1089
1214
|
a.push(JSON.stringify(q));
|
|
@@ -1112,16 +1237,48 @@ async function renderSessionPreview(query, scope) {
|
|
|
1112
1237
|
live = indexActiveBySessionId(await getActiveSessions()).get(session.id);
|
|
1113
1238
|
}
|
|
1114
1239
|
catch { /* plain preview on any probe failure */ }
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
const needsYou = live.status === 'input_required' || live.activity === 'waiting_input';
|
|
1119
|
-
const reason = live.awaitingReason ? ` (${live.awaitingReason.replace('_', ' ')})` : '';
|
|
1120
|
-
const suffix = needsYou ? chalk.yellow(` ← needs you${reason}`) : '';
|
|
1121
|
-
console.log(`${glyph} ${statusColor(live.status)(word)}${suffix}`);
|
|
1122
|
-
}
|
|
1240
|
+
const headline = formatLiveStatusHeadline(live, isFavorite(session.id));
|
|
1241
|
+
if (headline)
|
|
1242
|
+
console.log(headline);
|
|
1123
1243
|
console.log(buildPreview(session));
|
|
1124
1244
|
}
|
|
1245
|
+
/**
|
|
1246
|
+
* The one-line live status banner shown above a session preview: the glyph, the
|
|
1247
|
+
* status word, and — when the session needs a human or has LOST one — a plain
|
|
1248
|
+
* sentence saying so. Shared by `--preview` and the interactive browser's preview
|
|
1249
|
+
* pane so both explain a state the same way.
|
|
1250
|
+
*
|
|
1251
|
+
* `crashed` and `orphaned` are the states a glyph alone cannot carry: nobody
|
|
1252
|
+
* reads "orphan" and knows it means "still running in tmux with no window
|
|
1253
|
+
* attached", so those two spell it out.
|
|
1254
|
+
*/
|
|
1255
|
+
export function formatLiveStatusHeadline(live, favorite = false) {
|
|
1256
|
+
const star = favorite ? chalk.yellow('★ ') : '';
|
|
1257
|
+
// With no live row there is no status to lead with, so the star has to say
|
|
1258
|
+
// what it means on its own — a bare `★` above a preview reads as noise.
|
|
1259
|
+
if (!live)
|
|
1260
|
+
return favorite ? chalk.yellow('★ favorited') : '';
|
|
1261
|
+
const { glyph } = liveGlyphAndPreview(live);
|
|
1262
|
+
const word = liveStatusWord(live) || live.status;
|
|
1263
|
+
// One definition, shared with `--waiting`. A second local copy drifted: the
|
|
1264
|
+
// preview said "needs you" for a dead session that `--waiting` had (rightly)
|
|
1265
|
+
// stopped counting, so the human and the script disagreed about the same row.
|
|
1266
|
+
const needsYou = isAwaitingUser(live);
|
|
1267
|
+
const reason = live.awaitingReason ? ` (${live.awaitingReason.replace('_', ' ')})` : '';
|
|
1268
|
+
let suffix = needsYou ? chalk.yellow(` ← needs you${reason}`) : '';
|
|
1269
|
+
if (live.status === 'crashed') {
|
|
1270
|
+
suffix = chalk.redBright(' ← the host app or connection went away and took the agent with it');
|
|
1271
|
+
}
|
|
1272
|
+
else if (live.status === 'orphaned') {
|
|
1273
|
+
// Keep the needs-you half. A session sitting on a real question with nobody
|
|
1274
|
+
// attached is strictly worse than either fact alone, and replacing the
|
|
1275
|
+
// question with the generic orphan line undersells exactly that case.
|
|
1276
|
+
suffix = needsYou
|
|
1277
|
+
? chalk.yellow(` ← waiting on you${reason}, and no client is attached to answer it`)
|
|
1278
|
+
: chalk.yellow(' ← still running, but no client is attached — nothing is showing it');
|
|
1279
|
+
}
|
|
1280
|
+
return `${star}${glyph} ${statusColor(live.status)(word)}${suffix}`;
|
|
1281
|
+
}
|
|
1125
1282
|
/** Main action handler for `agents sessions`. Routes to picker, table, or single-session render. */
|
|
1126
1283
|
async function sessionsAction(query, options,
|
|
1127
1284
|
/**
|
|
@@ -1155,6 +1312,39 @@ limitSource) {
|
|
|
1155
1312
|
process.stdout.write(JSON.stringify(getSessionRoots(), null, 2) + '\n');
|
|
1156
1313
|
return;
|
|
1157
1314
|
}
|
|
1315
|
+
// --resolve is the metadata-only contract for downstream context workflows.
|
|
1316
|
+
// It reads indexed SessionMeta rows and never calls renderSession, buildPreview,
|
|
1317
|
+
// parseSession, or any other transcript renderer/parser.
|
|
1318
|
+
if (options.resolve !== undefined || options.resolveSafeV1 !== undefined) {
|
|
1319
|
+
if (options.resolveSafeV1 !== undefined && process.env[NO_FANOUT_ENV] !== '1') {
|
|
1320
|
+
console.error(chalk.red('--resolve-safe-v1 is an internal fleet protocol.'));
|
|
1321
|
+
process.exit(1);
|
|
1322
|
+
}
|
|
1323
|
+
if (!options.json) {
|
|
1324
|
+
console.error(chalk.red('--resolve requires --json.'));
|
|
1325
|
+
process.exit(1);
|
|
1326
|
+
}
|
|
1327
|
+
const selector = (options.resolveSafeV1 ?? options.resolve ?? '').trim();
|
|
1328
|
+
if (!selector) {
|
|
1329
|
+
console.error(chalk.red('--resolve requires a non-empty selector.'));
|
|
1330
|
+
process.exit(1);
|
|
1331
|
+
}
|
|
1332
|
+
if (query) {
|
|
1333
|
+
console.error(chalk.red('Pass the selector to --resolve, not as a positional query.'));
|
|
1334
|
+
process.exit(1);
|
|
1335
|
+
}
|
|
1336
|
+
if (options.local === true && options.host && !shouldIncludeLocal(options.host, machineId())) {
|
|
1337
|
+
console.error(chalk.red('--local and --device name opposite scopes: --local skips the SSH fan-out that --device needs.'));
|
|
1338
|
+
process.exit(1);
|
|
1339
|
+
}
|
|
1340
|
+
await resolveSessionMetadata(selector, {
|
|
1341
|
+
agent: options.agent,
|
|
1342
|
+
project: options.project,
|
|
1343
|
+
local: options.local,
|
|
1344
|
+
hosts: options.host,
|
|
1345
|
+
});
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1158
1348
|
// --host WITHOUT --active. `--json` fans the recent listing out and emits ONE
|
|
1159
1349
|
// clean merged SessionMeta[] array (same shape as the local --json path), for
|
|
1160
1350
|
// scripts/extensions that JSON.parse a remote's history. Without --json it
|
|
@@ -1229,6 +1419,7 @@ limitSource) {
|
|
|
1229
1419
|
host: options.host,
|
|
1230
1420
|
since: options.since,
|
|
1231
1421
|
all: options.all,
|
|
1422
|
+
favorites: options.favorites,
|
|
1232
1423
|
}), { local: options.local === true, hosts: options.host });
|
|
1233
1424
|
return;
|
|
1234
1425
|
}
|
|
@@ -1238,6 +1429,7 @@ limitSource) {
|
|
|
1238
1429
|
await renderActiveSessions(options.json === true, options.waiting === true, {
|
|
1239
1430
|
local: forceLocal,
|
|
1240
1431
|
hosts: options.host,
|
|
1432
|
+
favoritesOnly: options.favorites === true,
|
|
1241
1433
|
});
|
|
1242
1434
|
return;
|
|
1243
1435
|
}
|
|
@@ -1259,6 +1451,7 @@ limitSource) {
|
|
|
1259
1451
|
since: options.since,
|
|
1260
1452
|
host: options.host,
|
|
1261
1453
|
inTeam: options.inTeam,
|
|
1454
|
+
favorites: options.favorites,
|
|
1262
1455
|
}), { local: options.local === true, hosts: options.host });
|
|
1263
1456
|
return;
|
|
1264
1457
|
}
|
|
@@ -1383,6 +1576,12 @@ limitSource) {
|
|
|
1383
1576
|
// read. Match either, after that pass has populated `teamOrigin`.
|
|
1384
1577
|
if (options.inTeam)
|
|
1385
1578
|
sessions = sessions.filter((s) => matchesTeam(s, options.inTeam));
|
|
1579
|
+
// --favorites narrows to the starred set. Applied here, before the JSON
|
|
1580
|
+
// emit, so `--favorites --json` is the machine-readable twin of the `f` key.
|
|
1581
|
+
if (options.favorites) {
|
|
1582
|
+
const starred = listFavorites();
|
|
1583
|
+
sessions = sessions.filter((s) => starred.has(s.id));
|
|
1584
|
+
}
|
|
1386
1585
|
// Under --in-team the visible list is one team, so the whole-index team-origin
|
|
1387
1586
|
// count would be a non-sequitur next to it.
|
|
1388
1587
|
const hiddenCount = options.teams || options.inTeam
|
|
@@ -1575,7 +1774,7 @@ function metaSignals(s) {
|
|
|
1575
1774
|
* (tracker/PR ref, pulled out of the badge blob so refs align) is only rendered
|
|
1576
1775
|
* when `showTicket` — otherwise a listing with no refs would waste a column of
|
|
1577
1776
|
* dashes and needlessly truncate the topic. Worktree stays a trailing badge. */
|
|
1578
|
-
export function flatSessionRow(session, live, showTicket = false, cols = {}) {
|
|
1777
|
+
export function flatSessionRow(session, live, showTicket = false, cols = {}, favorite = false) {
|
|
1579
1778
|
const agentColor = colorAgent(session.agent);
|
|
1580
1779
|
const when = formatRelativeTime(session.lastActivity ?? session.timestamp);
|
|
1581
1780
|
const project = session.project || '-';
|
|
@@ -1611,13 +1810,17 @@ export function flatSessionRow(session, live, showTicket = false, cols = {}) {
|
|
|
1611
1810
|
const wtW = wt ? stringWidth(wt) + 1 : 0;
|
|
1612
1811
|
const width = terminalWidth();
|
|
1613
1812
|
const requestedModelW = cols.showModel ? (cols.modelWidth ?? PICKER_MODEL_MAX) : 0;
|
|
1614
|
-
|
|
1813
|
+
// Same conditional 2 cells as the picker's marker, for the same reason.
|
|
1814
|
+
const favW = cols.showFavorite ? 2 : 0;
|
|
1815
|
+
const favCell = cols.showFavorite ? (favorite ? chalk.yellow('★ ') : ' ') : '';
|
|
1816
|
+
const fixedW = favW + (10 + 9 + 8 + 16) + glyphW + statusW + machineW + ticketW + wtW + team.width + stringWidth(when) + 1;
|
|
1615
1817
|
const modelSlack = width - fixedW - 16;
|
|
1616
1818
|
const modelW = requestedModelW <= modelSlack
|
|
1617
1819
|
? requestedModelW
|
|
1618
1820
|
: modelSlack >= PICKER_MODEL_MIN ? modelSlack : 0;
|
|
1619
1821
|
const topicW = Math.max(16, width - fixedW - modelW);
|
|
1620
|
-
return (
|
|
1822
|
+
return (favCell +
|
|
1823
|
+
chalk.white(padToWidth(truncateToWidth(session.shortId, 9), 10)) +
|
|
1621
1824
|
agentColor(padToWidth(truncateToWidth(session.agent, 8), 9)) +
|
|
1622
1825
|
chalk.yellow(padToWidth(truncateToWidth(session.version || '-', 7), 8)) +
|
|
1623
1826
|
(modelW ? chalk.yellow(padToWidth(truncateToWidth(modelLabel(session.model), modelW - 1), modelW)) : '') +
|
|
@@ -1791,8 +1994,10 @@ function printSessionTable(sessions, hiddenCount = 0, tree = false, liveIndex) {
|
|
|
1791
1994
|
// column (and its compact labels) is computed the same way the picker does it.
|
|
1792
1995
|
const showTicket = sessions.some((s) => ticketLabel(s) !== '');
|
|
1793
1996
|
const cols = pickerColumnsFor(sessions);
|
|
1794
|
-
|
|
1795
|
-
|
|
1997
|
+
const favorites = listFavorites();
|
|
1998
|
+
for (const session of sessions) {
|
|
1999
|
+
console.log(flatSessionRow(session, liveIndex?.get(session.id), showTicket, cols, favorites.has(session.id)));
|
|
2000
|
+
}
|
|
1796
2001
|
const countLine = `${sessions.length} session${sessions.length === 1 ? '' : 's'}.`;
|
|
1797
2002
|
console.log(chalk.gray(`\n${countLine}`));
|
|
1798
2003
|
if (hiddenCount > 0) {
|
|
@@ -2022,6 +2227,11 @@ export function pickerColumnsFor(sessions) {
|
|
|
2022
2227
|
showModel: sessions.some((s) => !!s.model),
|
|
2023
2228
|
modelWidth: modelColumnWidth(sessions),
|
|
2024
2229
|
showTicket: sessions.some((s) => ticketLabel(s) !== ''),
|
|
2230
|
+
showFavorite: (() => {
|
|
2231
|
+
// One read of the store per pool, not one per row.
|
|
2232
|
+
const starred = listFavorites();
|
|
2233
|
+
return starred.size > 0 && sessions.some((s) => starred.has(s.id));
|
|
2234
|
+
})(),
|
|
2025
2235
|
};
|
|
2026
2236
|
}
|
|
2027
2237
|
/** Width of the host-program column (`tmux→ghostty` is the long realistic case). */
|
|
@@ -2040,7 +2250,7 @@ export function liveHostLabel(a) {
|
|
|
2040
2250
|
const viewer = a.viewingIn?.app;
|
|
2041
2251
|
return viewer && viewer !== a.host ? `${a.host}→${viewer}` : a.host;
|
|
2042
2252
|
}
|
|
2043
|
-
export function formatPickerLabel(s, query, cols = {}, ssh, host = '') {
|
|
2253
|
+
export function formatPickerLabel(s, query, cols = {}, ssh, host = '', favorite = false, live) {
|
|
2044
2254
|
const agentColor = colorAgent(s.agent);
|
|
2045
2255
|
const when = formatRelativeTime(s.lastActivity ?? s.timestamp);
|
|
2046
2256
|
const project = s.project || '-';
|
|
@@ -2082,17 +2292,32 @@ export function formatPickerLabel(s, query, cols = {}, ssh, host = '') {
|
|
|
2082
2292
|
const ticketW = cols.showTicket ? TICKET_W + 1 : 0;
|
|
2083
2293
|
const hostW = cols.showHost ? PICKER_HOST_W : 0;
|
|
2084
2294
|
const wtW = wt ? stringWidth(wt) + 1 : 0;
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
//
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2295
|
+
// Within a pool that HAS starred rows the marker holds its 2 cells whether this
|
|
2296
|
+
// row is starred or not, so the columns after it never jog; a pool with none
|
|
2297
|
+
// drops the column entirely (`showFavorite`) and costs nothing.
|
|
2298
|
+
const favW = cols.showFavorite ? 2 : 0;
|
|
2299
|
+
const favCell = cols.showFavorite ? (favorite ? chalk.yellow('★ ') : ' ') : '';
|
|
2300
|
+
// The same status word the flat listing shows, so a session that is `orphan`
|
|
2301
|
+
// or `crashed` reads that way in the browser too — not only in its preview.
|
|
2302
|
+
// Constant width whenever the column is on. `liveStatusCell` already pads its
|
|
2303
|
+
// word to LIVE_STATUS_W but returns an EMPTY cell (width 0) for a row with no
|
|
2304
|
+
// live match — blanks fill in for those, so the topic column does not jog on
|
|
2305
|
+
// exactly the rows that are not running.
|
|
2306
|
+
const status = cols.showStatus ? liveStatusCell(live) : { cell: '', width: 0 };
|
|
2307
|
+
const statusW = cols.showStatus ? LIVE_STATUS_W : 0;
|
|
2308
|
+
const statusCell = cols.showStatus ? (status.cell || ' '.repeat(LIVE_STATUS_W)) : '';
|
|
2309
|
+
const topicW = Math.max(16, terminalWidth() - gutter - favW - statusW - (10 + 9 + 8 + 16) - machineColW - hostW - ticketW - wtW - sshW - team.width - stringWidth(when) - 1);
|
|
2310
|
+
return (favCell +
|
|
2311
|
+
// Truncated, not just padded: an indexed shortId is always 8 chars, but a
|
|
2312
|
+
// live row with no session id is named by its pid or cloud task, which can
|
|
2313
|
+
// run past the column and shunt every later column out of alignment.
|
|
2314
|
+
chalk.white(padRight(truncate(s.shortId, 9), 10)) +
|
|
2091
2315
|
agentColor(padRight(truncate(s.agent, 8), 9)) +
|
|
2092
2316
|
chalk.yellow(padRight(truncate(versionStr, 7), 8)) +
|
|
2093
2317
|
machineCell +
|
|
2094
2318
|
hostCell +
|
|
2095
2319
|
chalk.cyan(padRight(truncate(project, 14), 16)) +
|
|
2320
|
+
statusCell +
|
|
2096
2321
|
sshSeg +
|
|
2097
2322
|
teamSeg +
|
|
2098
2323
|
renderTopicCell(label, topic, query, topicW, topicW) +
|
|
@@ -2499,7 +2724,7 @@ function formatSearchMessage(options) {
|
|
|
2499
2724
|
* search (a bare id must not surface every transcript that merely mentions it).
|
|
2500
2725
|
* A genuine search phrase keeps the ranked metadata+content search.
|
|
2501
2726
|
*/
|
|
2502
|
-
export function resolveSessionQuery(pool, query) {
|
|
2727
|
+
export function resolveSessionQuery(pool, query, options = {}) {
|
|
2503
2728
|
// Normalize ONCE here. isCompleteSessionId trims but resolveSessionById does
|
|
2504
2729
|
// not, so a padded id ("<uuid> ", e.g. pasted from a terminal) would classify
|
|
2505
2730
|
// as complete and then miss the id lookup — reporting a session that IS on
|
|
@@ -2519,7 +2744,8 @@ export function resolveSessionQuery(pool, query) {
|
|
|
2519
2744
|
// id" — NOT fall back to fuzzy content search. A short id like "d3470b57"
|
|
2520
2745
|
// otherwise surfaces every transcript that merely MENTIONS the string (a
|
|
2521
2746
|
// resume prompt echoes the parent id into the body of many later sessions).
|
|
2522
|
-
|
|
2747
|
+
const matches = options.indexFallback === false ? [] : findSessionsById(normalized);
|
|
2748
|
+
return { matches, byId: true, completeId };
|
|
2523
2749
|
}
|
|
2524
2750
|
return { matches: filterSessionsByQuery(pool, normalized), byId: false, completeId };
|
|
2525
2751
|
}
|
|
@@ -2836,88 +3062,176 @@ function modeFlag(mode) {
|
|
|
2836
3062
|
return '--json';
|
|
2837
3063
|
return undefined; // summary — the peer's default render
|
|
2838
3064
|
}
|
|
2839
|
-
/**
|
|
2840
|
-
*
|
|
2841
|
-
*
|
|
2842
|
-
*
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
3065
|
+
/** Resolve a fleet sweep through the same canonical full-id / prefix resolver as
|
|
3066
|
+
* local lookups, then group copies by logical session id. Synced mirrors of one
|
|
3067
|
+
* session therefore stay one candidate even when several machines report them;
|
|
3068
|
+
* distinct ids sharing a prefix remain distinct ambiguity candidates. */
|
|
3069
|
+
export function fleetCandidatesByQuery(rows, query) {
|
|
3070
|
+
// An id selector must still be prefix-filtered defensively against older peers
|
|
3071
|
+
// returning content mentioners. Keyword rows, however, were already matched by
|
|
3072
|
+
// each peer's own FTS index; the parent does not own that transcript/index and
|
|
3073
|
+
// must not re-run metadata-only filtering that could discard a content hit.
|
|
3074
|
+
const matched = looksLikeSessionId(query)
|
|
3075
|
+
? resolveSessionQuery(rows, query, { indexFallback: false }).matches
|
|
3076
|
+
: rows;
|
|
3077
|
+
const byId = new Map();
|
|
3078
|
+
for (const session of matched) {
|
|
3079
|
+
const machine = session.machine;
|
|
2850
3080
|
if (!machine)
|
|
2851
3081
|
continue; // an untagged row can't be routed back to a peer
|
|
2852
|
-
|
|
2853
|
-
|
|
3082
|
+
const logicalId = session.id.toLowerCase();
|
|
3083
|
+
let byMachine = byId.get(logicalId);
|
|
3084
|
+
if (!byMachine) {
|
|
3085
|
+
byMachine = new Map();
|
|
3086
|
+
byId.set(logicalId, byMachine);
|
|
3087
|
+
}
|
|
2854
3088
|
if (!byMachine.has(machine))
|
|
2855
|
-
byMachine.set(machine,
|
|
3089
|
+
byMachine.set(machine, session);
|
|
2856
3090
|
}
|
|
2857
|
-
return Array.from(
|
|
3091
|
+
return Array.from(byId.values()).map(byMachine => {
|
|
3092
|
+
const hits = Array.from(byMachine.entries()).map(([machine, session]) => ({ machine, session }));
|
|
3093
|
+
return { id: hits[0].session.id, hits };
|
|
3094
|
+
});
|
|
3095
|
+
}
|
|
3096
|
+
/** Resolve through the canonical metadata+content union used by keyword search. */
|
|
3097
|
+
function resolveIndexedMetadataRows(indexed, selector) {
|
|
3098
|
+
return resolveSessionQuery(indexed, selector, { indexFallback: false }).matches;
|
|
3099
|
+
}
|
|
3100
|
+
/** Fixed peer argv for the metadata resolver. Scope flags compose identically on
|
|
3101
|
+
* every host; `--all` removes the SSH login cwd/time window, not agent/project filters. */
|
|
3102
|
+
export function metadataResolveForwardedArgs(selector, scope) {
|
|
3103
|
+
const args = ['sessions', '--resolve-safe-v1', selector, '--json', '--all', '--local'];
|
|
3104
|
+
if (scope.agent)
|
|
3105
|
+
args.push('--agent', scope.agent);
|
|
3106
|
+
if (scope.project)
|
|
3107
|
+
args.push('--project', scope.project);
|
|
3108
|
+
return args;
|
|
3109
|
+
}
|
|
3110
|
+
/** Resolution must fail closed when any selected peer did not answer. Choosing
|
|
3111
|
+
* from a partial fleet can turn an unseen candidate into a false unique match. */
|
|
3112
|
+
export function metadataResolveOutcome(localMatches, remote, selector) {
|
|
3113
|
+
if (remote.unreachable.length > 0) {
|
|
3114
|
+
return { kind: 'partial', failedPeers: remote.unreachable };
|
|
3115
|
+
}
|
|
3116
|
+
const candidates = fleetCandidatesByQuery([...localMatches, ...remote.sessions], selector);
|
|
3117
|
+
if (candidates.length === 0)
|
|
3118
|
+
return { kind: 'not-found' };
|
|
3119
|
+
if (candidates.length > 1)
|
|
3120
|
+
return { kind: 'ambiguous', candidates };
|
|
3121
|
+
return { kind: 'resolved', session: candidates[0].hits[0].session };
|
|
3122
|
+
}
|
|
3123
|
+
/** Resolve one selector to indexed metadata across the fleet without reading or
|
|
3124
|
+
* rendering transcript events. A peer answering the parent sweep returns every
|
|
3125
|
+
* local candidate; the parent performs the one logical-session uniqueness gate. */
|
|
3126
|
+
async function resolveSessionMetadata(selector, scope, deps = { gatherRemoteList }) {
|
|
3127
|
+
const localMachine = machineId();
|
|
3128
|
+
const includeLocal = !scope.hosts?.length || shouldIncludeLocal(scope.hosts, localMachine);
|
|
3129
|
+
const indexed = includeLocal ? applyScopeFilters(querySessions(), scope) : [];
|
|
3130
|
+
const localMatches = resolveIndexedMetadataRows(indexed, selector)
|
|
3131
|
+
.map(session => ({ ...session, machine: session.machine || localMachine }));
|
|
3132
|
+
// A peer is already inside gatherRemoteList. Return all local candidates so
|
|
3133
|
+
// the parent can distinguish a fleet-wide unique match from an ambiguity.
|
|
3134
|
+
if (process.env[NO_FANOUT_ENV] === '1') {
|
|
3135
|
+
process.stdout.write(serializeResolvedSessionsJson(localMatches));
|
|
3136
|
+
return;
|
|
3137
|
+
}
|
|
3138
|
+
let remoteResult = { sessions: [], unreachable: [] };
|
|
3139
|
+
if (scope.local !== true) {
|
|
3140
|
+
try {
|
|
3141
|
+
const forwarded = metadataResolveForwardedArgs(selector, scope);
|
|
3142
|
+
const result = await deps.gatherRemoteList(forwarded, scope.hosts);
|
|
3143
|
+
remoteResult = { sessions: result.sessions, unreachable: result.unreachable };
|
|
3144
|
+
}
|
|
3145
|
+
catch (error) {
|
|
3146
|
+
remoteResult = { sessions: [], unreachable: [error?.message ?? 'fleet fan-out'] };
|
|
3147
|
+
}
|
|
3148
|
+
}
|
|
3149
|
+
const outcome = metadataResolveOutcome(localMatches, remoteResult, selector);
|
|
3150
|
+
if (outcome.kind === 'partial') {
|
|
3151
|
+
console.error(chalk.red(`Partial session resolution: ${outcome.failedPeers.join(', ')} did not answer.`));
|
|
3152
|
+
console.error(chalk.gray('No unique/no-match decision was made. Upgrade or reconnect every peer, then retry.'));
|
|
3153
|
+
process.exit(2);
|
|
3154
|
+
}
|
|
3155
|
+
if (outcome.kind === 'not-found') {
|
|
3156
|
+
console.error(chalk.red(`No session found matching: ${selector}`));
|
|
3157
|
+
process.exit(1);
|
|
3158
|
+
}
|
|
3159
|
+
if (outcome.kind === 'ambiguous') {
|
|
3160
|
+
console.error(chalk.red(`Multiple sessions match "${selector}" across the fleet:`));
|
|
3161
|
+
for (const candidate of outcome.candidates) {
|
|
3162
|
+
const session = candidate.hits[0].session;
|
|
3163
|
+
const machines = candidate.hits.map(hit => hit.machine).join(', ');
|
|
3164
|
+
console.error(chalk.cyan(` ${session.shortId} ${session.id}`) + chalk.gray(` ${machines} ${session.label ?? session.topic ?? ''}`));
|
|
3165
|
+
}
|
|
3166
|
+
console.error(chalk.gray(looksLikeSessionId(selector) ? 'Pass a longer ID to narrow it down.' : 'Narrow the keywords to one session.'));
|
|
3167
|
+
process.exit(1);
|
|
3168
|
+
}
|
|
3169
|
+
process.stdout.write(serializeResolvedSessionsJson([outcome.session]));
|
|
2858
3170
|
}
|
|
2859
3171
|
/**
|
|
2860
|
-
* Locate a full session id across the online fleet and render it from the machine
|
|
3172
|
+
* Locate a full session id or short id prefix across the online fleet and render it from the machine
|
|
2861
3173
|
* that holds it. The local disk already missed; this fans `sessions <id> --json
|
|
2862
3174
|
* --all` out to every registered online peer (or the explicit `hosts` set),
|
|
2863
3175
|
* groups the rows to distinct machines, then:
|
|
2864
3176
|
*
|
|
2865
|
-
* - exactly one
|
|
3177
|
+
* - exactly one logical session → delegate rendering to one peer via `runOnPeer`
|
|
2866
3178
|
* (its transcript and agent binary live there — a local `--host` hop would
|
|
2867
3179
|
* re-discover locally and dead-end), returning `'rendered'`.
|
|
2868
|
-
* - more than one
|
|
2869
|
-
*
|
|
3180
|
+
* - more than one logical session → print every full-id candidate with its
|
|
3181
|
+
* machine labels, returning `'conflict'`.
|
|
2870
3182
|
* - none → `'not-found'`, letting the caller print the local
|
|
2871
3183
|
* "no session on this machine" message.
|
|
2872
3184
|
*
|
|
2873
|
-
* No fuzzy/content fallback: the sweep forwards
|
|
2874
|
-
*
|
|
3185
|
+
* No fuzzy/content fallback: the sweep forwards the id selector and every result
|
|
3186
|
+
* is resolved through `resolveSessionQuery`, the same id-only resolver used locally.
|
|
2875
3187
|
*/
|
|
2876
|
-
export async function resolveSessionAcrossFleet(
|
|
3188
|
+
export async function resolveSessionAcrossFleet(query, mode, hosts, deps = { gatherRemoteList, runOnPeer }) {
|
|
2877
3189
|
const spinner = isInteractiveTerminal() ? ora('Searching the fleet...').start() : null;
|
|
2878
|
-
let
|
|
3190
|
+
let candidates;
|
|
2879
3191
|
try {
|
|
2880
3192
|
// Force whole-index scope (--all): the peer runs in its SSH-login home dir,
|
|
2881
3193
|
// whose cwd would otherwise silently narrow the lookup and hide the row.
|
|
2882
3194
|
// --json so each peer answers a parseable array; --local so it answers for
|
|
2883
3195
|
// itself and never re-fans-out (belt-and-suspenders with the parent's
|
|
2884
3196
|
// AGENTS_SESSIONS_LOCAL, which remote-list also sets on the peer).
|
|
2885
|
-
const forwarded = ['sessions',
|
|
3197
|
+
const forwarded = ['sessions', query, '--json', '--all', '--local'];
|
|
2886
3198
|
const { sessions } = await deps.gatherRemoteList(forwarded, hosts);
|
|
2887
|
-
|
|
3199
|
+
candidates = fleetCandidatesByQuery(sessions, query);
|
|
2888
3200
|
}
|
|
2889
3201
|
catch {
|
|
2890
3202
|
// A fan-out failure is not an exact resolution — treat as not-found so the
|
|
2891
3203
|
// caller prints the honest local message rather than a half-answer.
|
|
2892
|
-
|
|
3204
|
+
candidates = [];
|
|
2893
3205
|
}
|
|
2894
3206
|
finally {
|
|
2895
3207
|
spinner?.stop();
|
|
2896
3208
|
}
|
|
2897
|
-
if (
|
|
3209
|
+
if (candidates.length === 0)
|
|
2898
3210
|
return 'not-found';
|
|
2899
|
-
if (
|
|
2900
|
-
console.error(chalk.red(`
|
|
2901
|
-
for (const
|
|
2902
|
-
const s =
|
|
3211
|
+
if (candidates.length > 1) {
|
|
3212
|
+
console.error(chalk.red(`Multiple sessions match "${query}" across the fleet:`));
|
|
3213
|
+
for (const candidate of candidates) {
|
|
3214
|
+
const s = candidate.hits[0].session;
|
|
2903
3215
|
const label = s.label ?? s.topic ?? '';
|
|
2904
|
-
|
|
3216
|
+
const machines = candidate.hits.map(hit => hit.machine).join(', ');
|
|
3217
|
+
console.error(chalk.cyan(` ${s.shortId} ${s.id}`) + chalk.gray(` ${machines} ${s.agent}${s.version ? ` ${s.version}` : ''} ${label}`));
|
|
2905
3218
|
}
|
|
2906
|
-
console.error(chalk.gray(
|
|
3219
|
+
console.error(chalk.gray('Pass a longer ID to narrow it down.'));
|
|
2907
3220
|
return 'conflict';
|
|
2908
3221
|
}
|
|
2909
|
-
const
|
|
3222
|
+
const candidate = candidates[0];
|
|
3223
|
+
const { machine } = candidate.hits[0];
|
|
2910
3224
|
// Render the remote summary by re-running `sessions <id>` ON the peer. --local
|
|
2911
3225
|
// keeps that render on the peer (it owns the transcript); the mode flag matches
|
|
2912
3226
|
// the mode the user asked for. No TTY: a summary/markdown/json render is a
|
|
2913
3227
|
// one-shot capture, not an interactive resume.
|
|
2914
|
-
const peerArgs = ['sessions', id, '--local'];
|
|
3228
|
+
const peerArgs = ['sessions', candidate.id, '--local'];
|
|
2915
3229
|
const flag = modeFlag(mode);
|
|
2916
3230
|
if (flag)
|
|
2917
3231
|
peerArgs.push(flag);
|
|
2918
3232
|
const result = await deps.runOnPeer(peerArgs, machine);
|
|
2919
3233
|
if (result === 'no-target') {
|
|
2920
|
-
console.error(chalk.red(`Session ${id} is on ${machine}, but it is not a reachable registered device.`));
|
|
3234
|
+
console.error(chalk.red(`Session ${candidate.id} is on ${machine}, but it is not a reachable registered device.`));
|
|
2921
3235
|
console.error(chalk.gray('Register it with `agents devices` or run the command on that machine.'));
|
|
2922
3236
|
return 'conflict'; // a definitive answer (found, un-renderable) — do NOT fall to the local not-found line
|
|
2923
3237
|
}
|
|
@@ -2929,6 +3243,8 @@ export function registerSessionsCommands(program) {
|
|
|
2929
3243
|
.command('sessions')
|
|
2930
3244
|
.argument('[query]', 'Session ID, search query, or path (., ../, /path) to filter by project')
|
|
2931
3245
|
.option('--query <text>', 'Search text — use when the term collides with a subcommand name (e.g. "go")')
|
|
3246
|
+
.option('--resolve <selector>', 'Resolve one full ID, unique prefix, or keyword query to safe session metadata (requires --json; searches the fleet unless --local)')
|
|
3247
|
+
.addOption(new Option('--resolve-safe-v1 <selector>').hideHelp())
|
|
2932
3248
|
.description('Find, browse, and read agent conversation transcripts across Claude, Codex, Gemini, and OpenCode.')
|
|
2933
3249
|
.option('-a, --agent <agent>', 'Filter by agent type and version (e.g., claude, codex@0.116.0)')
|
|
2934
3250
|
.option('--claude', 'Shorthand for --agent claude')
|
|
@@ -2960,6 +3276,7 @@ export function registerSessionsCommands(program) {
|
|
|
2960
3276
|
.option('--roots', 'With --json: emit the on-disk directories scanned for session transcripts, per agent (for external watchers)')
|
|
2961
3277
|
.option('--local', 'Only this machine — skip the cross-machine SSH fan-out (default listing and --active)')
|
|
2962
3278
|
.option('--waiting', 'With --active: show only sessions waiting on your input (exits non-zero if any)')
|
|
3279
|
+
.option('--favorites', 'Show only favorited (starred) sessions — star them with `*` in the browser or `agents sessions favorite <id>`')
|
|
2963
3280
|
.option('--tree', 'Group the listing by directory; drops the id/version columns for readability')
|
|
2964
3281
|
.option('--flat', 'Plain flat table (one row per session) instead of the grouped project overview')
|
|
2965
3282
|
.option('--no-live', 'Do not enrich the listing with live status/preview for running sessions')
|
|
@@ -3002,6 +3319,9 @@ export function registerSessionsCommands(program) {
|
|
|
3002
3319
|
# Export for analysis
|
|
3003
3320
|
agents sessions --since 30d --limit 200 --json > sessions.json
|
|
3004
3321
|
|
|
3322
|
+
# Resolve one historical selector to metadata only, across the fleet
|
|
3323
|
+
agents sessions --resolve d3470b57 --json
|
|
3324
|
+
|
|
3005
3325
|
# Search another machine's sessions live over SSH (no sync needed)
|
|
3006
3326
|
agents sessions "auth bug" --last 3 --host yosemite-s1
|
|
3007
3327
|
|
|
@@ -3031,6 +3351,7 @@ export function registerSessionsCommands(program) {
|
|
|
3031
3351
|
registerSessionsTailCommand(sessionsCmd);
|
|
3032
3352
|
registerSessionsSyncCommand(sessionsCmd);
|
|
3033
3353
|
registerSessionsResumeCommand(sessionsCmd);
|
|
3354
|
+
registerSessionsFavoriteCommand(sessionsCmd);
|
|
3034
3355
|
registerGoCommand(sessionsCmd);
|
|
3035
3356
|
registerFocusCommand(sessionsCmd);
|
|
3036
3357
|
registerDetachCommand(sessionsCmd);
|