@phnx-labs/agents-cli 1.20.89 → 1.20.90
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +240 -0
- package/README.md +6 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/exec.js +7 -1
- package/dist/commands/harness.d.ts +27 -0
- package/dist/commands/harness.js +120 -13
- 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 +28 -6
- package/dist/commands/secrets.d.ts +10 -1
- package/dist/commands/secrets.js +18 -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 +103 -20
- package/dist/commands/sessions.js +356 -62
- 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/lib/agents.d.ts +4 -2
- package/dist/lib/agents.js +21 -6
- 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/picker.d.ts +27 -2
- package/dist/lib/picker.js +71 -7
- package/dist/lib/profiles.d.ts +48 -0
- package/dist/lib/profiles.js +67 -0
- package/dist/lib/rotate.d.ts +24 -2
- package/dist/lib/rotate.js +63 -6
- 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/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-list.d.ts +10 -0
- package/dist/lib/session/remote-list.js +47 -9
- package/dist/lib/tmux/binary.d.ts +7 -0
- package/dist/lib/tmux/binary.js +11 -1
- 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';
|
|
@@ -27,7 +28,7 @@ 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,
|
|
@@ -745,6 +803,25 @@ export function serializeSessionsJson(sessions) {
|
|
|
745
803
|
});
|
|
746
804
|
return JSON.stringify(serializable, null, 2) + '\n';
|
|
747
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
|
+
}
|
|
748
825
|
/**
|
|
749
826
|
* `agents sessions --json --host <h>` — fan the RECENT (non-active) listing out
|
|
750
827
|
* to the named host(s) and emit ONE clean merged `SessionMeta[]` JSON array,
|
|
@@ -777,9 +854,15 @@ function groupTally(sessions) {
|
|
|
777
854
|
const running = sessions.filter(s => s.status === 'running').length;
|
|
778
855
|
const idle = sessions.filter(s => s.status === 'idle').length;
|
|
779
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.
|
|
780
859
|
const queued = sessions.filter(s => s.status === 'queued').length;
|
|
781
860
|
const closed = sessions.filter(s => s.status === 'closed').length;
|
|
782
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;
|
|
783
866
|
const unknown = sessions.filter(s => s.status === 'unknown').length;
|
|
784
867
|
const parts = [];
|
|
785
868
|
if (running)
|
|
@@ -794,6 +877,10 @@ function groupTally(sessions) {
|
|
|
794
877
|
parts.push(`${closed} closed`);
|
|
795
878
|
if (abandoned)
|
|
796
879
|
parts.push(`${abandoned} abandoned`);
|
|
880
|
+
if (orphaned)
|
|
881
|
+
parts.push(`${orphaned} orphaned`);
|
|
882
|
+
if (crashed)
|
|
883
|
+
parts.push(`${crashed} crashed`);
|
|
797
884
|
if (unknown)
|
|
798
885
|
parts.push(`${unknown} unknown`);
|
|
799
886
|
return parts.join(' · ');
|
|
@@ -981,10 +1068,19 @@ export async function gatherActiveSessions(opts = {}) {
|
|
|
981
1068
|
*/
|
|
982
1069
|
async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
|
|
983
1070
|
const self = machineId();
|
|
984
|
-
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;
|
|
985
1081
|
// --waiting: only sessions blocked on the user. Exits non-zero when any are
|
|
986
1082
|
// present so a supervising agent or hook can poll it as a gate.
|
|
987
|
-
const sessions = waitingOnly ? merged.filter(
|
|
1083
|
+
const sessions = waitingOnly ? merged.filter(isAwaitingUser) : merged;
|
|
988
1084
|
if (asJson) {
|
|
989
1085
|
// Resolve who is watching each local tmux pane before serializing: `viewingIn`
|
|
990
1086
|
// is how a consumer distinguishes a session someone is looking at from one
|
|
@@ -1111,6 +1207,8 @@ function canonicalSessionsCommand(query, options) {
|
|
|
1111
1207
|
a.push('--local');
|
|
1112
1208
|
if (options.waiting)
|
|
1113
1209
|
a.push('--waiting');
|
|
1210
|
+
if (options.favorites)
|
|
1211
|
+
a.push('--favorites');
|
|
1114
1212
|
const q = (query ?? '').trim();
|
|
1115
1213
|
if (q)
|
|
1116
1214
|
a.push(JSON.stringify(q));
|
|
@@ -1139,16 +1237,48 @@ async function renderSessionPreview(query, scope) {
|
|
|
1139
1237
|
live = indexActiveBySessionId(await getActiveSessions()).get(session.id);
|
|
1140
1238
|
}
|
|
1141
1239
|
catch { /* plain preview on any probe failure */ }
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
const needsYou = live.status === 'input_required' || live.activity === 'waiting_input';
|
|
1146
|
-
const reason = live.awaitingReason ? ` (${live.awaitingReason.replace('_', ' ')})` : '';
|
|
1147
|
-
const suffix = needsYou ? chalk.yellow(` ← needs you${reason}`) : '';
|
|
1148
|
-
console.log(`${glyph} ${statusColor(live.status)(word)}${suffix}`);
|
|
1149
|
-
}
|
|
1240
|
+
const headline = formatLiveStatusHeadline(live, isFavorite(session.id));
|
|
1241
|
+
if (headline)
|
|
1242
|
+
console.log(headline);
|
|
1150
1243
|
console.log(buildPreview(session));
|
|
1151
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
|
+
}
|
|
1152
1282
|
/** Main action handler for `agents sessions`. Routes to picker, table, or single-session render. */
|
|
1153
1283
|
async function sessionsAction(query, options,
|
|
1154
1284
|
/**
|
|
@@ -1182,6 +1312,39 @@ limitSource) {
|
|
|
1182
1312
|
process.stdout.write(JSON.stringify(getSessionRoots(), null, 2) + '\n');
|
|
1183
1313
|
return;
|
|
1184
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
|
+
}
|
|
1185
1348
|
// --host WITHOUT --active. `--json` fans the recent listing out and emits ONE
|
|
1186
1349
|
// clean merged SessionMeta[] array (same shape as the local --json path), for
|
|
1187
1350
|
// scripts/extensions that JSON.parse a remote's history. Without --json it
|
|
@@ -1256,6 +1419,7 @@ limitSource) {
|
|
|
1256
1419
|
host: options.host,
|
|
1257
1420
|
since: options.since,
|
|
1258
1421
|
all: options.all,
|
|
1422
|
+
favorites: options.favorites,
|
|
1259
1423
|
}), { local: options.local === true, hosts: options.host });
|
|
1260
1424
|
return;
|
|
1261
1425
|
}
|
|
@@ -1265,6 +1429,7 @@ limitSource) {
|
|
|
1265
1429
|
await renderActiveSessions(options.json === true, options.waiting === true, {
|
|
1266
1430
|
local: forceLocal,
|
|
1267
1431
|
hosts: options.host,
|
|
1432
|
+
favoritesOnly: options.favorites === true,
|
|
1268
1433
|
});
|
|
1269
1434
|
return;
|
|
1270
1435
|
}
|
|
@@ -1286,6 +1451,7 @@ limitSource) {
|
|
|
1286
1451
|
since: options.since,
|
|
1287
1452
|
host: options.host,
|
|
1288
1453
|
inTeam: options.inTeam,
|
|
1454
|
+
favorites: options.favorites,
|
|
1289
1455
|
}), { local: options.local === true, hosts: options.host });
|
|
1290
1456
|
return;
|
|
1291
1457
|
}
|
|
@@ -1410,6 +1576,12 @@ limitSource) {
|
|
|
1410
1576
|
// read. Match either, after that pass has populated `teamOrigin`.
|
|
1411
1577
|
if (options.inTeam)
|
|
1412
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
|
+
}
|
|
1413
1585
|
// Under --in-team the visible list is one team, so the whole-index team-origin
|
|
1414
1586
|
// count would be a non-sequitur next to it.
|
|
1415
1587
|
const hiddenCount = options.teams || options.inTeam
|
|
@@ -1602,7 +1774,7 @@ function metaSignals(s) {
|
|
|
1602
1774
|
* (tracker/PR ref, pulled out of the badge blob so refs align) is only rendered
|
|
1603
1775
|
* when `showTicket` — otherwise a listing with no refs would waste a column of
|
|
1604
1776
|
* dashes and needlessly truncate the topic. Worktree stays a trailing badge. */
|
|
1605
|
-
export function flatSessionRow(session, live, showTicket = false, cols = {}) {
|
|
1777
|
+
export function flatSessionRow(session, live, showTicket = false, cols = {}, favorite = false) {
|
|
1606
1778
|
const agentColor = colorAgent(session.agent);
|
|
1607
1779
|
const when = formatRelativeTime(session.lastActivity ?? session.timestamp);
|
|
1608
1780
|
const project = session.project || '-';
|
|
@@ -1638,13 +1810,17 @@ export function flatSessionRow(session, live, showTicket = false, cols = {}) {
|
|
|
1638
1810
|
const wtW = wt ? stringWidth(wt) + 1 : 0;
|
|
1639
1811
|
const width = terminalWidth();
|
|
1640
1812
|
const requestedModelW = cols.showModel ? (cols.modelWidth ?? PICKER_MODEL_MAX) : 0;
|
|
1641
|
-
|
|
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;
|
|
1642
1817
|
const modelSlack = width - fixedW - 16;
|
|
1643
1818
|
const modelW = requestedModelW <= modelSlack
|
|
1644
1819
|
? requestedModelW
|
|
1645
1820
|
: modelSlack >= PICKER_MODEL_MIN ? modelSlack : 0;
|
|
1646
1821
|
const topicW = Math.max(16, width - fixedW - modelW);
|
|
1647
|
-
return (
|
|
1822
|
+
return (favCell +
|
|
1823
|
+
chalk.white(padToWidth(truncateToWidth(session.shortId, 9), 10)) +
|
|
1648
1824
|
agentColor(padToWidth(truncateToWidth(session.agent, 8), 9)) +
|
|
1649
1825
|
chalk.yellow(padToWidth(truncateToWidth(session.version || '-', 7), 8)) +
|
|
1650
1826
|
(modelW ? chalk.yellow(padToWidth(truncateToWidth(modelLabel(session.model), modelW - 1), modelW)) : '') +
|
|
@@ -1818,8 +1994,10 @@ function printSessionTable(sessions, hiddenCount = 0, tree = false, liveIndex) {
|
|
|
1818
1994
|
// column (and its compact labels) is computed the same way the picker does it.
|
|
1819
1995
|
const showTicket = sessions.some((s) => ticketLabel(s) !== '');
|
|
1820
1996
|
const cols = pickerColumnsFor(sessions);
|
|
1821
|
-
|
|
1822
|
-
|
|
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
|
+
}
|
|
1823
2001
|
const countLine = `${sessions.length} session${sessions.length === 1 ? '' : 's'}.`;
|
|
1824
2002
|
console.log(chalk.gray(`\n${countLine}`));
|
|
1825
2003
|
if (hiddenCount > 0) {
|
|
@@ -2049,6 +2227,11 @@ export function pickerColumnsFor(sessions) {
|
|
|
2049
2227
|
showModel: sessions.some((s) => !!s.model),
|
|
2050
2228
|
modelWidth: modelColumnWidth(sessions),
|
|
2051
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
|
+
})(),
|
|
2052
2235
|
};
|
|
2053
2236
|
}
|
|
2054
2237
|
/** Width of the host-program column (`tmux→ghostty` is the long realistic case). */
|
|
@@ -2067,7 +2250,7 @@ export function liveHostLabel(a) {
|
|
|
2067
2250
|
const viewer = a.viewingIn?.app;
|
|
2068
2251
|
return viewer && viewer !== a.host ? `${a.host}→${viewer}` : a.host;
|
|
2069
2252
|
}
|
|
2070
|
-
export function formatPickerLabel(s, query, cols = {}, ssh, host = '') {
|
|
2253
|
+
export function formatPickerLabel(s, query, cols = {}, ssh, host = '', favorite = false, live) {
|
|
2071
2254
|
const agentColor = colorAgent(s.agent);
|
|
2072
2255
|
const when = formatRelativeTime(s.lastActivity ?? s.timestamp);
|
|
2073
2256
|
const project = s.project || '-';
|
|
@@ -2109,17 +2292,32 @@ export function formatPickerLabel(s, query, cols = {}, ssh, host = '') {
|
|
|
2109
2292
|
const ticketW = cols.showTicket ? TICKET_W + 1 : 0;
|
|
2110
2293
|
const hostW = cols.showHost ? PICKER_HOST_W : 0;
|
|
2111
2294
|
const wtW = wt ? stringWidth(wt) + 1 : 0;
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
//
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
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)) +
|
|
2118
2315
|
agentColor(padRight(truncate(s.agent, 8), 9)) +
|
|
2119
2316
|
chalk.yellow(padRight(truncate(versionStr, 7), 8)) +
|
|
2120
2317
|
machineCell +
|
|
2121
2318
|
hostCell +
|
|
2122
2319
|
chalk.cyan(padRight(truncate(project, 14), 16)) +
|
|
2320
|
+
statusCell +
|
|
2123
2321
|
sshSeg +
|
|
2124
2322
|
teamSeg +
|
|
2125
2323
|
renderTopicCell(label, topic, query, topicW, topicW) +
|
|
@@ -2526,7 +2724,7 @@ function formatSearchMessage(options) {
|
|
|
2526
2724
|
* search (a bare id must not surface every transcript that merely mentions it).
|
|
2527
2725
|
* A genuine search phrase keeps the ranked metadata+content search.
|
|
2528
2726
|
*/
|
|
2529
|
-
export function resolveSessionQuery(pool, query) {
|
|
2727
|
+
export function resolveSessionQuery(pool, query, options = {}) {
|
|
2530
2728
|
// Normalize ONCE here. isCompleteSessionId trims but resolveSessionById does
|
|
2531
2729
|
// not, so a padded id ("<uuid> ", e.g. pasted from a terminal) would classify
|
|
2532
2730
|
// as complete and then miss the id lookup — reporting a session that IS on
|
|
@@ -2546,7 +2744,8 @@ export function resolveSessionQuery(pool, query) {
|
|
|
2546
2744
|
// id" — NOT fall back to fuzzy content search. A short id like "d3470b57"
|
|
2547
2745
|
// otherwise surfaces every transcript that merely MENTIONS the string (a
|
|
2548
2746
|
// resume prompt echoes the parent id into the body of many later sessions).
|
|
2549
|
-
|
|
2747
|
+
const matches = options.indexFallback === false ? [] : findSessionsById(normalized);
|
|
2748
|
+
return { matches, byId: true, completeId };
|
|
2550
2749
|
}
|
|
2551
2750
|
return { matches: filterSessionsByQuery(pool, normalized), byId: false, completeId };
|
|
2552
2751
|
}
|
|
@@ -2863,88 +3062,176 @@ function modeFlag(mode) {
|
|
|
2863
3062
|
return '--json';
|
|
2864
3063
|
return undefined; // summary — the peer's default render
|
|
2865
3064
|
}
|
|
2866
|
-
/**
|
|
2867
|
-
*
|
|
2868
|
-
*
|
|
2869
|
-
*
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
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;
|
|
2877
3080
|
if (!machine)
|
|
2878
3081
|
continue; // an untagged row can't be routed back to a peer
|
|
2879
|
-
|
|
2880
|
-
|
|
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
|
+
}
|
|
2881
3088
|
if (!byMachine.has(machine))
|
|
2882
|
-
byMachine.set(machine,
|
|
3089
|
+
byMachine.set(machine, session);
|
|
2883
3090
|
}
|
|
2884
|
-
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]));
|
|
2885
3170
|
}
|
|
2886
3171
|
/**
|
|
2887
|
-
* 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
|
|
2888
3173
|
* that holds it. The local disk already missed; this fans `sessions <id> --json
|
|
2889
3174
|
* --all` out to every registered online peer (or the explicit `hosts` set),
|
|
2890
3175
|
* groups the rows to distinct machines, then:
|
|
2891
3176
|
*
|
|
2892
|
-
* - exactly one
|
|
3177
|
+
* - exactly one logical session → delegate rendering to one peer via `runOnPeer`
|
|
2893
3178
|
* (its transcript and agent binary live there — a local `--host` hop would
|
|
2894
3179
|
* re-discover locally and dead-end), returning `'rendered'`.
|
|
2895
|
-
* - more than one
|
|
2896
|
-
*
|
|
3180
|
+
* - more than one logical session → print every full-id candidate with its
|
|
3181
|
+
* machine labels, returning `'conflict'`.
|
|
2897
3182
|
* - none → `'not-found'`, letting the caller print the local
|
|
2898
3183
|
* "no session on this machine" message.
|
|
2899
3184
|
*
|
|
2900
|
-
* No fuzzy/content fallback: the sweep forwards
|
|
2901
|
-
*
|
|
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.
|
|
2902
3187
|
*/
|
|
2903
|
-
export async function resolveSessionAcrossFleet(
|
|
3188
|
+
export async function resolveSessionAcrossFleet(query, mode, hosts, deps = { gatherRemoteList, runOnPeer }) {
|
|
2904
3189
|
const spinner = isInteractiveTerminal() ? ora('Searching the fleet...').start() : null;
|
|
2905
|
-
let
|
|
3190
|
+
let candidates;
|
|
2906
3191
|
try {
|
|
2907
3192
|
// Force whole-index scope (--all): the peer runs in its SSH-login home dir,
|
|
2908
3193
|
// whose cwd would otherwise silently narrow the lookup and hide the row.
|
|
2909
3194
|
// --json so each peer answers a parseable array; --local so it answers for
|
|
2910
3195
|
// itself and never re-fans-out (belt-and-suspenders with the parent's
|
|
2911
3196
|
// AGENTS_SESSIONS_LOCAL, which remote-list also sets on the peer).
|
|
2912
|
-
const forwarded = ['sessions',
|
|
3197
|
+
const forwarded = ['sessions', query, '--json', '--all', '--local'];
|
|
2913
3198
|
const { sessions } = await deps.gatherRemoteList(forwarded, hosts);
|
|
2914
|
-
|
|
3199
|
+
candidates = fleetCandidatesByQuery(sessions, query);
|
|
2915
3200
|
}
|
|
2916
3201
|
catch {
|
|
2917
3202
|
// A fan-out failure is not an exact resolution — treat as not-found so the
|
|
2918
3203
|
// caller prints the honest local message rather than a half-answer.
|
|
2919
|
-
|
|
3204
|
+
candidates = [];
|
|
2920
3205
|
}
|
|
2921
3206
|
finally {
|
|
2922
3207
|
spinner?.stop();
|
|
2923
3208
|
}
|
|
2924
|
-
if (
|
|
3209
|
+
if (candidates.length === 0)
|
|
2925
3210
|
return 'not-found';
|
|
2926
|
-
if (
|
|
2927
|
-
console.error(chalk.red(`
|
|
2928
|
-
for (const
|
|
2929
|
-
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;
|
|
2930
3215
|
const label = s.label ?? s.topic ?? '';
|
|
2931
|
-
|
|
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}`));
|
|
2932
3218
|
}
|
|
2933
|
-
console.error(chalk.gray(
|
|
3219
|
+
console.error(chalk.gray('Pass a longer ID to narrow it down.'));
|
|
2934
3220
|
return 'conflict';
|
|
2935
3221
|
}
|
|
2936
|
-
const
|
|
3222
|
+
const candidate = candidates[0];
|
|
3223
|
+
const { machine } = candidate.hits[0];
|
|
2937
3224
|
// Render the remote summary by re-running `sessions <id>` ON the peer. --local
|
|
2938
3225
|
// keeps that render on the peer (it owns the transcript); the mode flag matches
|
|
2939
3226
|
// the mode the user asked for. No TTY: a summary/markdown/json render is a
|
|
2940
3227
|
// one-shot capture, not an interactive resume.
|
|
2941
|
-
const peerArgs = ['sessions', id, '--local'];
|
|
3228
|
+
const peerArgs = ['sessions', candidate.id, '--local'];
|
|
2942
3229
|
const flag = modeFlag(mode);
|
|
2943
3230
|
if (flag)
|
|
2944
3231
|
peerArgs.push(flag);
|
|
2945
3232
|
const result = await deps.runOnPeer(peerArgs, machine);
|
|
2946
3233
|
if (result === 'no-target') {
|
|
2947
|
-
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.`));
|
|
2948
3235
|
console.error(chalk.gray('Register it with `agents devices` or run the command on that machine.'));
|
|
2949
3236
|
return 'conflict'; // a definitive answer (found, un-renderable) — do NOT fall to the local not-found line
|
|
2950
3237
|
}
|
|
@@ -2956,6 +3243,8 @@ export function registerSessionsCommands(program) {
|
|
|
2956
3243
|
.command('sessions')
|
|
2957
3244
|
.argument('[query]', 'Session ID, search query, or path (., ../, /path) to filter by project')
|
|
2958
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())
|
|
2959
3248
|
.description('Find, browse, and read agent conversation transcripts across Claude, Codex, Gemini, and OpenCode.')
|
|
2960
3249
|
.option('-a, --agent <agent>', 'Filter by agent type and version (e.g., claude, codex@0.116.0)')
|
|
2961
3250
|
.option('--claude', 'Shorthand for --agent claude')
|
|
@@ -2987,6 +3276,7 @@ export function registerSessionsCommands(program) {
|
|
|
2987
3276
|
.option('--roots', 'With --json: emit the on-disk directories scanned for session transcripts, per agent (for external watchers)')
|
|
2988
3277
|
.option('--local', 'Only this machine — skip the cross-machine SSH fan-out (default listing and --active)')
|
|
2989
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>`')
|
|
2990
3280
|
.option('--tree', 'Group the listing by directory; drops the id/version columns for readability')
|
|
2991
3281
|
.option('--flat', 'Plain flat table (one row per session) instead of the grouped project overview')
|
|
2992
3282
|
.option('--no-live', 'Do not enrich the listing with live status/preview for running sessions')
|
|
@@ -3029,6 +3319,9 @@ export function registerSessionsCommands(program) {
|
|
|
3029
3319
|
# Export for analysis
|
|
3030
3320
|
agents sessions --since 30d --limit 200 --json > sessions.json
|
|
3031
3321
|
|
|
3322
|
+
# Resolve one historical selector to metadata only, across the fleet
|
|
3323
|
+
agents sessions --resolve d3470b57 --json
|
|
3324
|
+
|
|
3032
3325
|
# Search another machine's sessions live over SSH (no sync needed)
|
|
3033
3326
|
agents sessions "auth bug" --last 3 --host yosemite-s1
|
|
3034
3327
|
|
|
@@ -3058,6 +3351,7 @@ export function registerSessionsCommands(program) {
|
|
|
3058
3351
|
registerSessionsTailCommand(sessionsCmd);
|
|
3059
3352
|
registerSessionsSyncCommand(sessionsCmd);
|
|
3060
3353
|
registerSessionsResumeCommand(sessionsCmd);
|
|
3354
|
+
registerSessionsFavoriteCommand(sessionsCmd);
|
|
3061
3355
|
registerGoCommand(sessionsCmd);
|
|
3062
3356
|
registerFocusCommand(sessionsCmd);
|
|
3063
3357
|
registerDetachCommand(sessionsCmd);
|