@phnx-labs/agents-cli 1.20.76 → 1.20.78
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 +264 -0
- package/README.md +2 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/doctor.d.ts +9 -0
- package/dist/commands/doctor.js +143 -9
- package/dist/commands/exec.js +87 -19
- package/dist/commands/repo.js +8 -0
- package/dist/commands/routines.js +275 -15
- package/dist/commands/secrets.js +65 -17
- package/dist/commands/sessions-browser.d.ts +45 -3
- package/dist/commands/sessions-browser.js +128 -12
- package/dist/commands/sessions-export.d.ts +3 -0
- package/dist/commands/sessions-export.js +13 -10
- package/dist/commands/sessions-picker.d.ts +28 -1
- package/dist/commands/sessions-picker.js +222 -15
- package/dist/commands/sessions.d.ts +117 -4
- package/dist/commands/sessions.js +350 -62
- package/dist/commands/ssh.d.ts +13 -0
- package/dist/commands/ssh.js +48 -3
- package/dist/index.js +5 -6
- package/dist/lib/activity.d.ts +5 -4
- package/dist/lib/activity.js +450 -36
- package/dist/lib/agents.d.ts +0 -21
- package/dist/lib/agents.js +0 -37
- package/dist/lib/auto-pull.d.ts +16 -8
- package/dist/lib/auto-pull.js +23 -28
- package/dist/lib/cloud/session-index.js +2 -2
- package/dist/lib/daemon.d.ts +9 -32
- package/dist/lib/daemon.js +70 -84
- package/dist/lib/devices/fleet-divergence.d.ts +101 -0
- package/dist/lib/devices/fleet-divergence.js +188 -0
- package/dist/lib/devices/fleet-inventory.d.ts +19 -0
- package/dist/lib/devices/fleet-inventory.js +57 -0
- package/dist/lib/devices/fleet.d.ts +16 -1
- package/dist/lib/devices/fleet.js +10 -2
- package/dist/lib/devices/health-report.d.ts +10 -2
- package/dist/lib/devices/health-report.js +32 -1
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/exec.d.ts +16 -0
- package/dist/lib/exec.js +27 -4
- package/dist/lib/feed.d.ts +7 -1
- package/dist/lib/feed.js +96 -6
- package/dist/lib/git.d.ts +13 -0
- package/dist/lib/git.js +102 -4
- package/dist/lib/heal.d.ts +7 -4
- package/dist/lib/heal.js +10 -22
- package/dist/lib/hosts/dispatch.d.ts +9 -0
- package/dist/lib/hosts/dispatch.js +24 -4
- package/dist/lib/hosts/passthrough.js +7 -2
- package/dist/lib/hosts/remote-cmd.d.ts +11 -0
- package/dist/lib/hosts/remote-cmd.js +33 -8
- package/dist/lib/hosts/remote-session-id.d.ts +45 -0
- package/dist/lib/hosts/remote-session-id.js +84 -0
- package/dist/lib/hosts/run-target.d.ts +4 -0
- package/dist/lib/hosts/run-target.js +5 -1
- package/dist/lib/hosts/session-index.js +3 -3
- package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +2 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
- package/dist/lib/menubar/install-menubar.d.ts +9 -0
- package/dist/lib/menubar/install-menubar.js +14 -0
- package/dist/lib/menubar/notify-desktop.d.ts +44 -0
- package/dist/lib/menubar/notify-desktop.js +78 -0
- package/dist/lib/overdue.d.ts +4 -5
- package/dist/lib/overdue.js +8 -41
- package/dist/lib/routine-notify.d.ts +76 -0
- package/dist/lib/routine-notify.js +190 -0
- package/dist/lib/routines-placement.d.ts +38 -0
- package/dist/lib/routines-placement.js +79 -0
- package/dist/lib/routines-project.d.ts +97 -0
- package/dist/lib/routines-project.js +349 -0
- package/dist/lib/routines.d.ts +68 -0
- package/dist/lib/routines.js +74 -7
- package/dist/lib/runner.d.ts +12 -2
- package/dist/lib/runner.js +156 -28
- package/dist/lib/sandbox.js +0 -6
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
- package/dist/lib/secrets/agent.d.ts +11 -4
- package/dist/lib/secrets/agent.js +46 -24
- package/dist/lib/secrets/bundles.d.ts +21 -7
- package/dist/lib/secrets/bundles.js +82 -38
- package/dist/lib/secrets/index.d.ts +11 -2
- package/dist/lib/secrets/index.js +21 -9
- package/dist/lib/secrets/scope.d.ts +26 -0
- package/dist/lib/secrets/scope.js +29 -0
- package/dist/lib/secrets/session-store.d.ts +16 -2
- package/dist/lib/secrets/session-store.js +118 -15
- package/dist/lib/secrets/unlock-hints.d.ts +27 -0
- package/dist/lib/secrets/unlock-hints.js +36 -0
- package/dist/lib/session/active.d.ts +17 -0
- package/dist/lib/session/active.js +105 -7
- package/dist/lib/session/db.d.ts +43 -0
- package/dist/lib/session/db.js +191 -6
- package/dist/lib/session/digest.js +126 -21
- package/dist/lib/session/discover.d.ts +28 -3
- package/dist/lib/session/discover.js +110 -14
- package/dist/lib/session/origin-machine.d.ts +18 -0
- package/dist/lib/session/origin-machine.js +34 -0
- package/dist/lib/session/parse.js +11 -2
- package/dist/lib/session/prompt.d.ts +7 -0
- package/dist/lib/session/prompt.js +37 -0
- package/dist/lib/session/provenance.d.ts +7 -0
- package/dist/lib/session/render.js +18 -16
- package/dist/lib/session/state.d.ts +5 -0
- package/dist/lib/session/state.js +94 -12
- package/dist/lib/session/sync/config.js +2 -2
- package/dist/lib/session/types.d.ts +25 -1
- package/dist/lib/session/types.js +8 -0
- package/dist/lib/smart-launch.d.ts +86 -0
- package/dist/lib/smart-launch.js +172 -0
- package/dist/lib/state.d.ts +7 -3
- package/dist/lib/state.js +20 -5
- package/dist/lib/types.d.ts +10 -0
- package/package.json +1 -1
|
@@ -26,20 +26,21 @@ import { gatherRemoteActive, NO_FANOUT_ENV } from '../lib/session/remote-active.
|
|
|
26
26
|
import { gatherRemoteList, runOnPeer } from '../lib/session/remote-list.js';
|
|
27
27
|
import { stringWidth, truncateToWidth, padToWidth, terminalWidth } from '../lib/session/width.js';
|
|
28
28
|
import { inferSessionState } from '../lib/session/state.js';
|
|
29
|
-
import { discoverSessions, countSessionsInScope, resolveSessionById, isCompleteSessionId, searchContentIndex, getSessionRoots } from '../lib/session/discover.js';
|
|
29
|
+
import { discoverSessions, countSessionsInScope, resolveSessionById, isCompleteSessionId, looksLikeSessionId, searchContentIndex, getSessionRoots } from '../lib/session/discover.js';
|
|
30
30
|
import { findSessionsById } from '../lib/session/db.js';
|
|
31
31
|
import { filterTeamSessions } from '../lib/session/team-filter.js';
|
|
32
32
|
import { parseSession } from '../lib/session/parse.js';
|
|
33
33
|
import { runRemoteSessions, buildForwardedArgs, ensureWholeIndex } from '../lib/session/remote.js';
|
|
34
34
|
import { formatRelativeTime } from '../lib/session/relative-time.js';
|
|
35
|
-
import { renderConversationMarkdown, renderSummary, renderSummaryHeader, computeSummaryStats, renderJson, filterEvents, parseRoleList } from '../lib/session/render.js';
|
|
35
|
+
import { renderConversationMarkdown, renderSummary, renderSummaryHeader, computeSummaryStats, renderJson, filterEvents, parseRoleList, linkUrl } from '../lib/session/render.js';
|
|
36
|
+
import { linearIssueUrl } from '../lib/session/linear.js';
|
|
36
37
|
import { renderMarkdown } from '../lib/markdown.js';
|
|
37
38
|
import { AGENTS, colorAgent, resolveAgentName } from '../lib/agents.js';
|
|
38
39
|
import { getShimsDir } from '../lib/state.js';
|
|
39
40
|
import { fuzzyMatch, FUZZY_PRESETS } from '../lib/fuzzy.js';
|
|
40
41
|
import { resolveVersionAliasLoose } from '../lib/versions.js';
|
|
41
42
|
import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
|
|
42
|
-
import { sessionPicker, buildPreview } from './sessions-picker.js';
|
|
43
|
+
import { sessionPicker, buildPreview, formatTodoCompact, githubRepoUrlFromCwd, } from './sessions-picker.js';
|
|
43
44
|
import { setHelpSections } from '../lib/help.js';
|
|
44
45
|
import { registerSessionsTailCommand } from './sessions-tail.js';
|
|
45
46
|
import { registerSessionsSyncCommand } from './sessions-sync.js';
|
|
@@ -226,15 +227,24 @@ export function cleanPreview(text) {
|
|
|
226
227
|
.trim();
|
|
227
228
|
}
|
|
228
229
|
/**
|
|
229
|
-
* Build the live description for an active session:
|
|
230
|
-
* preview (the latest turn),
|
|
230
|
+
* Build the live description for an active session: checklist progress (when
|
|
231
|
+
* present) plus the state engine's preview (the latest turn), a user label, or
|
|
232
|
+
* the first-prompt topic. Used by both the flat listing's `doing` cell and as
|
|
233
|
+
* the snippet half of the --active row (identity is layered on in printActiveRow).
|
|
234
|
+
*
|
|
235
|
+
* Covers every ActiveSession context: terminal (interactive), headless, teams,
|
|
236
|
+
* cloud, and sub-agent rows that share the same ActiveSession.todos field.
|
|
231
237
|
*/
|
|
232
238
|
function buildSessionDescription(s) {
|
|
239
|
+
const todo = formatTodoCompact(s.todos);
|
|
233
240
|
if (s.context === 'cloud') {
|
|
234
|
-
|
|
241
|
+
const base = s.preview || `${s.cloudProvider ?? ''}${s.cloudTaskId ? ` · ${s.cloudTaskId.slice(0, 12)}` : ''}`;
|
|
242
|
+
return cleanPreview([todo, base].filter(Boolean).join(' · '));
|
|
235
243
|
}
|
|
236
244
|
if (s.context === 'teams') {
|
|
237
245
|
const parts = [s.teamName];
|
|
246
|
+
if (todo)
|
|
247
|
+
parts.push(todo);
|
|
238
248
|
if (s.preview)
|
|
239
249
|
parts.push(s.preview);
|
|
240
250
|
else if (s.label)
|
|
@@ -243,8 +253,53 @@ function buildSessionDescription(s) {
|
|
|
243
253
|
parts.push(s.topic);
|
|
244
254
|
return cleanPreview(parts.filter(Boolean).join(' · '));
|
|
245
255
|
}
|
|
246
|
-
// Terminal or
|
|
247
|
-
|
|
256
|
+
// Terminal, headless, or sub-agent: todos + live preview, then label, then topic.
|
|
257
|
+
const base = s.preview || s.label || s.topic || '';
|
|
258
|
+
return cleanPreview([todo, base].filter(Boolean).join(' · '));
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Identity + checklist + live snippet for an --active / cross-machine row.
|
|
262
|
+
* Surfaces agent-adjacent identity the flat table already has (label, project)
|
|
263
|
+
* with a clickable project when a GitHub URL is resolvable, then the checklist
|
|
264
|
+
* tally and the latest-turn snippet.
|
|
265
|
+
*
|
|
266
|
+
* Free-text fields are cleaned individually; OSC 8 hyperlinks are applied
|
|
267
|
+
* *after* cleaning so `cleanPreview` does not strip the clickable targets
|
|
268
|
+
* (RUSH-2045 review). Ticket is clickable via {@link signalBadges}, not here,
|
|
269
|
+
* so the id is not printed twice.
|
|
270
|
+
*/
|
|
271
|
+
export function formatActiveRowDescription(s) {
|
|
272
|
+
const parts = [];
|
|
273
|
+
const pushText = (t) => {
|
|
274
|
+
const c = t ? cleanPreview(t) : '';
|
|
275
|
+
if (c)
|
|
276
|
+
parts.push(c);
|
|
277
|
+
};
|
|
278
|
+
if (s.context === 'teams' && s.teamName)
|
|
279
|
+
pushText(s.teamName);
|
|
280
|
+
if (s.label)
|
|
281
|
+
pushText(s.label);
|
|
282
|
+
// Project = basename(cwd), same derivation as serializeActiveSessionsForJson.
|
|
283
|
+
const project = s.cwd ? path.basename(s.cwd) : '';
|
|
284
|
+
if (project && project !== s.label && project !== s.teamName) {
|
|
285
|
+
const label = cleanPreview(project);
|
|
286
|
+
const repoUrl = githubRepoUrlFromCwd(s.cwd);
|
|
287
|
+
parts.push(repoUrl ? linkUrl(repoUrl, label) : label);
|
|
288
|
+
}
|
|
289
|
+
const todo = formatTodoCompact(s.todos);
|
|
290
|
+
if (todo)
|
|
291
|
+
parts.push(todo);
|
|
292
|
+
// Latest-turn snippet — avoid repeating label/topic when already used as identity.
|
|
293
|
+
if (s.context === 'cloud') {
|
|
294
|
+
pushText(s.preview || `${s.cloudProvider ?? ''}${s.cloudTaskId ? ` · ${s.cloudTaskId.slice(0, 12)}` : ''}`);
|
|
295
|
+
}
|
|
296
|
+
else if (s.preview) {
|
|
297
|
+
pushText(s.preview);
|
|
298
|
+
}
|
|
299
|
+
else if (!s.label && s.topic) {
|
|
300
|
+
pushText(s.topic);
|
|
301
|
+
}
|
|
302
|
+
return parts.filter(Boolean).join(' · ');
|
|
248
303
|
}
|
|
249
304
|
/** Short human word for a session's activity (falls back to the coarse status). */
|
|
250
305
|
function activityLabel(s) {
|
|
@@ -325,10 +380,15 @@ function signalBadges(s) {
|
|
|
325
380
|
parts.push(chalk.yellow('ask'));
|
|
326
381
|
else if (s.awaitingReason === 'permission')
|
|
327
382
|
parts.push(chalk.yellow('perm'));
|
|
328
|
-
if (s.ticket)
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
parts.push(chalk.
|
|
383
|
+
if (s.ticket) {
|
|
384
|
+
// Clickable when Linear workspace is resolvable (same helper as the picker header).
|
|
385
|
+
const url = linearIssueUrl(s.ticket.id);
|
|
386
|
+
parts.push(chalk.cyan(url ? linkUrl(url, s.ticket.id) : s.ticket.id));
|
|
387
|
+
}
|
|
388
|
+
if (s.pr) {
|
|
389
|
+
const label = `PR#${s.pr.number ?? '?'}`;
|
|
390
|
+
parts.push(chalk.blue(s.pr.url ? linkUrl(s.pr.url, label) : label));
|
|
391
|
+
}
|
|
332
392
|
if (s.worktree)
|
|
333
393
|
parts.push(chalk.magenta(`wt:${s.worktree.slug}`));
|
|
334
394
|
return parts.join(' ');
|
|
@@ -343,8 +403,10 @@ function signalBadges(s) {
|
|
|
343
403
|
function locatorBadge(s) {
|
|
344
404
|
const p = s.provenance;
|
|
345
405
|
const parts = [];
|
|
406
|
+
// An ssh-launched session shows where it was launched FROM when the client IP
|
|
407
|
+
// resolves to a registered device (`ssh←zion`); bare `ssh` when it doesn't.
|
|
346
408
|
if (p?.transport === 'ssh')
|
|
347
|
-
parts.push(chalk.red('ssh'));
|
|
409
|
+
parts.push(chalk.red(p.origin ? `ssh←${p.origin.device}` : 'ssh'));
|
|
348
410
|
if (p?.mux?.kind === 'tmux' && (s.tmuxTarget || p.mux.pane)) {
|
|
349
411
|
parts.push(chalk.green(s.tmuxTarget ?? p.mux.pane));
|
|
350
412
|
// For a tmux-hosted session, say which app+tab is looking at it right now
|
|
@@ -373,13 +435,17 @@ function locatorBadge(s) {
|
|
|
373
435
|
* terminal width so the row never wraps.
|
|
374
436
|
*/
|
|
375
437
|
function printActiveRow(s, indent) {
|
|
438
|
+
// shortId (8-char) · agent · host · status · badges · identity+todos+snippet
|
|
376
439
|
const idCol = chalk.dim(padToWidth((s.sessionId?.slice(0, 8)) ?? '-', 9));
|
|
377
440
|
const kindCol = colorAgent(s.kind)(padToWidth(truncateToWidth(s.kind, 8), 9));
|
|
378
441
|
const hostCol = chalk.gray(padToWidth(truncateToWidth(s.host ?? '-', 8), 9));
|
|
379
442
|
const statusCol = statusColor(s.status)(padToWidth(truncateToWidth(activityLabel(s), 8), 9));
|
|
380
443
|
const fork = s.pidCount && s.pidCount > 1 ? chalk.dim(`×${s.pidCount} `) : '';
|
|
381
444
|
const badges = (fork ? fork : '') + [signalBadges(s), locatorBadge(s)].filter(Boolean).join(' ');
|
|
382
|
-
|
|
445
|
+
// Identity (label/project/ticket clickable) + checklist + live snippet.
|
|
446
|
+
// Cross-machine rows use the same path — remote ActiveSession fields arrive
|
|
447
|
+
// via the SSH fan-out already populated (including todos when the peer has them).
|
|
448
|
+
const desc = formatActiveRowDescription(s) || '-';
|
|
383
449
|
// Fill the remaining width with the preview so nothing wraps under tmux/SSH.
|
|
384
450
|
const fixed = stringWidth(indent) + 9 + 9 + 9 + 9 + (badges ? stringWidth(badges) + 1 : 0);
|
|
385
451
|
const room = Math.max(12, terminalWidth() - fixed - 1);
|
|
@@ -747,15 +813,18 @@ export function remoteHostsToDial(hosts, self) {
|
|
|
747
813
|
return hosts.filter(h => hostToken(h) !== self);
|
|
748
814
|
}
|
|
749
815
|
/**
|
|
750
|
-
*
|
|
751
|
-
*
|
|
752
|
-
*
|
|
753
|
-
*
|
|
754
|
-
*
|
|
755
|
-
*
|
|
756
|
-
*
|
|
816
|
+
* The fleet-wide live-session set behind every `--active` surface. Local sessions
|
|
817
|
+
* come from `getActiveSessions()` and (unless `--local`) the registered online
|
|
818
|
+
* devices from `ag devices` are folded in over SSH. An explicit `--host`/`--device`
|
|
819
|
+
* list SCOPES the sweep to exactly those machines — the local machine is included
|
|
820
|
+
* only when it is itself named — so `--host` is a filter, not an addition.
|
|
821
|
+
*
|
|
822
|
+
* This is the single gather: the static renderer AND the interactive browser both
|
|
823
|
+
* call it, so the browser can never disagree with `--active --json` about which
|
|
824
|
+
* sessions are live (it used to call the local-only `getActiveSessions()` directly
|
|
825
|
+
* and silently hid every remote session).
|
|
757
826
|
*/
|
|
758
|
-
async function
|
|
827
|
+
export async function gatherActiveSessions(opts = {}) {
|
|
759
828
|
const self = machineId();
|
|
760
829
|
// An explicit --host/--device list scopes the view: seed local sessions only
|
|
761
830
|
// when no hosts are named, or when this machine is one of the named targets.
|
|
@@ -775,6 +844,17 @@ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
|
|
|
775
844
|
merged = dedupeByMachineSession([...local, ...remote.sessions]);
|
|
776
845
|
}
|
|
777
846
|
}
|
|
847
|
+
return { sessions: merged, remoteDeviceCount };
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* Render the unified active-session view, grouped by machine. Scoping and the
|
|
851
|
+
* fleet sweep live in {@link gatherActiveSessions}; this owns the presentation
|
|
852
|
+
* (the `--waiting` gate, JSON, and the grouped table). A tip is shown when there
|
|
853
|
+
* are no other machines to include.
|
|
854
|
+
*/
|
|
855
|
+
async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
|
|
856
|
+
const self = machineId();
|
|
857
|
+
const { sessions: merged, remoteDeviceCount } = await gatherActiveSessions(opts);
|
|
778
858
|
// --waiting: only sessions blocked on the user. Exits non-zero when any are
|
|
779
859
|
// present so a supervising agent or hook can poll it as a gate.
|
|
780
860
|
const sessions = waitingOnly ? merged.filter(s => s.status === 'input_required') : merged;
|
|
@@ -957,6 +1037,7 @@ async function sessionsAction(query, options) {
|
|
|
957
1037
|
agent: options.agent,
|
|
958
1038
|
host: options.host,
|
|
959
1039
|
since: options.since,
|
|
1040
|
+
all: options.all,
|
|
960
1041
|
}), { local: options.local === true, hosts: options.host });
|
|
961
1042
|
return;
|
|
962
1043
|
}
|
|
@@ -1038,7 +1119,7 @@ async function sessionsAction(query, options) {
|
|
|
1038
1119
|
// When the user explicitly asks to render (via mode flag), resolve the
|
|
1039
1120
|
// query globally so sessions outside the default cwd/30d window are found.
|
|
1040
1121
|
if (wantsRender && searchQuery) {
|
|
1041
|
-
await renderOneSession(searchQuery, mode, { agent: options.agent, project: options.project, filter: filterOpts, redact: options.redact });
|
|
1122
|
+
await renderOneSession(searchQuery, mode, { agent: options.agent, project: options.project, filter: filterOpts, redact: options.redact, local: options.local, hosts: options.host });
|
|
1042
1123
|
return;
|
|
1043
1124
|
}
|
|
1044
1125
|
// Interactive picker loads a deep pool but shows only recent sessions
|
|
@@ -1105,19 +1186,34 @@ async function sessionsAction(query, options) {
|
|
|
1105
1186
|
// Smart ID routing: a bare query that resolves to one session renders
|
|
1106
1187
|
// directly. If nothing matches in the scoped window and the query looks
|
|
1107
1188
|
// like a session ID, widen to global scope (incl. Claude /resume history).
|
|
1108
|
-
|
|
1189
|
+
//
|
|
1190
|
+
// Exception: a PEER answering a parent's `--json` locate sweep
|
|
1191
|
+
// (AGENTS_SESSIONS_LOCAL=1) must return the SessionMeta[] ROW for the id, not
|
|
1192
|
+
// render its transcript — the sweep parses an array (parseRemoteList). So when
|
|
1193
|
+
// we are that peer and --json is set, skip the single-session short-circuit and
|
|
1194
|
+
// fall through to the array-emitting --json block below.
|
|
1195
|
+
const answeringJsonSweep = options.json === true && process.env[NO_FANOUT_ENV] === '1';
|
|
1196
|
+
if (searchQuery && !answeringJsonSweep) {
|
|
1109
1197
|
const idMatches = resolveSessionById(sessions, searchQuery);
|
|
1110
1198
|
if (idMatches.length === 1) {
|
|
1111
1199
|
await renderSession(idMatches[0], mode, filterOpts, options);
|
|
1112
1200
|
return;
|
|
1113
1201
|
}
|
|
1114
1202
|
if (idMatches.length === 0 && looksLikeSessionId(searchQuery)) {
|
|
1115
|
-
await renderOneSession(searchQuery, mode, { agent: options.agent, project: options.project, filter: filterOpts, redact: options.redact });
|
|
1203
|
+
await renderOneSession(searchQuery, mode, { agent: options.agent, project: options.project, filter: filterOpts, redact: options.redact, local: options.local, hosts: options.host });
|
|
1116
1204
|
return;
|
|
1117
1205
|
}
|
|
1118
1206
|
}
|
|
1119
1207
|
if (options.json) {
|
|
1120
|
-
|
|
1208
|
+
// An id-shaped query resolves by id ONLY — the same rule the render path
|
|
1209
|
+
// uses (resolveSessionQuery). Without this a `--json <uuid>` (as issued by
|
|
1210
|
+
// the fleet locate sweep, which runs `sessions <uuid> --json --local` on
|
|
1211
|
+
// each peer) would fall to FTS content search and return every transcript
|
|
1212
|
+
// that merely MENTIONS the id, defeating exact remote resolution. A genuine
|
|
1213
|
+
// search phrase keeps the ranked metadata+content path.
|
|
1214
|
+
const filtered = searchQuery
|
|
1215
|
+
? resolveSessionQuery(sessions, searchQuery).matches
|
|
1216
|
+
: sessions;
|
|
1121
1217
|
process.stdout.write(serializeSessionsJson(filtered));
|
|
1122
1218
|
return;
|
|
1123
1219
|
}
|
|
@@ -1205,15 +1301,6 @@ async function sessionsAction(query, options) {
|
|
|
1205
1301
|
process.exit(1);
|
|
1206
1302
|
}
|
|
1207
1303
|
}
|
|
1208
|
-
/** Whether a query should route to the single-session render rather than the
|
|
1209
|
-
* listing. The hex-ish test catches a bare id prefix; `isCompleteSessionId`
|
|
1210
|
-
* additionally catches the prefixed whole ids (`session_…`, `ses_…`) that the
|
|
1211
|
-
* hex test rejects — without it those never reach the id-only resolution and
|
|
1212
|
-
* still content-search. */
|
|
1213
|
-
function looksLikeSessionId(query) {
|
|
1214
|
-
const trimmed = query.trim();
|
|
1215
|
-
return /^[0-9a-f-]{6,}$/i.test(trimmed) || isCompleteSessionId(trimmed);
|
|
1216
|
-
}
|
|
1217
1304
|
function teamTag(session) {
|
|
1218
1305
|
const origin = session.teamOrigin;
|
|
1219
1306
|
if (!origin)
|
|
@@ -1247,9 +1334,12 @@ function flatSessionRow(session, live, showTicket = false, cols = {}) {
|
|
|
1247
1334
|
const tag = originTag(session) || teamTag(session);
|
|
1248
1335
|
const label = session.label;
|
|
1249
1336
|
const { glyph, preview } = liveGlyphAndPreview(live);
|
|
1250
|
-
// A running session's live preview
|
|
1251
|
-
//
|
|
1252
|
-
|
|
1337
|
+
// A running session's live preview (via liveGlyphAndPreview → buildSessionDescription)
|
|
1338
|
+
// already folds in ActiveSession.todos. For resting / not-live rows, surface
|
|
1339
|
+
// SessionMeta.todos when the scan attached it.
|
|
1340
|
+
const restingTodo = !live ? formatTodoCompact(session.todos) : '';
|
|
1341
|
+
const topicBase = tag ? `${tag}${session.topic ?? ''}` : session.topic;
|
|
1342
|
+
const doing = [restingTodo, preview || topicBase].filter(Boolean).join(' · ') || undefined;
|
|
1253
1343
|
const wt = session.worktreeSlug ? chalk.magenta(`wt:${session.worktreeSlug}`) : '';
|
|
1254
1344
|
// The machine column only earns its width when the listing spans more than one
|
|
1255
1345
|
// box (i.e. the cross-machine fan-out folded remotes in) — same rule and
|
|
@@ -1285,7 +1375,11 @@ function treeSessionRow(session, live) {
|
|
|
1285
1375
|
const tag = originTag(session) || teamTag(session);
|
|
1286
1376
|
const label = session.label;
|
|
1287
1377
|
const { glyph, preview } = liveGlyphAndPreview(live);
|
|
1288
|
-
|
|
1378
|
+
// Match flatSessionRow: live preview already folds ActiveSession.todos; resting
|
|
1379
|
+
// rows surface SessionMeta.todos when present (RUSH-2045).
|
|
1380
|
+
const restingTodo = !live ? formatTodoCompact(session.todos) : '';
|
|
1381
|
+
const topicBase = preview || (tag ? `${tag}${session.topic ?? ''}` : session.topic);
|
|
1382
|
+
const topic = [restingTodo, topicBase].filter(Boolean).join(' · ') || '-';
|
|
1289
1383
|
const badges = signalBadges(metaSignals(session));
|
|
1290
1384
|
const badgeW = badges ? stringWidth(badges) + 1 : 0;
|
|
1291
1385
|
const head = label ? `${label} · ${topic}` : topic;
|
|
@@ -1652,10 +1746,34 @@ export function pickerColumnsFor(sessions) {
|
|
|
1652
1746
|
showTicket: sessions.some((s) => ticketLabel(s) !== ''),
|
|
1653
1747
|
};
|
|
1654
1748
|
}
|
|
1655
|
-
|
|
1749
|
+
/** Width of the host-program column (`tmux→ghostty` is the long realistic case). */
|
|
1750
|
+
const PICKER_HOST_W = 14;
|
|
1751
|
+
/**
|
|
1752
|
+
* Which program a live session is running in, for the picker's host column: the
|
|
1753
|
+
* immediate host app (`codium`, `ghostty`, `tmux`, `iterm`, …) and — when a tmux
|
|
1754
|
+
* session is currently being watched through a different app — the app it is
|
|
1755
|
+
* viewed in, as `tmux→ghostty`. A tmux session with no attached client stays a
|
|
1756
|
+
* bare `tmux`, which is exactly "running detached". Empty when the session has no
|
|
1757
|
+
* resolvable host (cloud rows, an unreadable process env).
|
|
1758
|
+
*/
|
|
1759
|
+
export function liveHostLabel(a) {
|
|
1760
|
+
if (!a?.host)
|
|
1761
|
+
return '';
|
|
1762
|
+
const viewer = a.viewingIn?.app;
|
|
1763
|
+
return viewer && viewer !== a.host ? `${a.host}→${viewer}` : a.host;
|
|
1764
|
+
}
|
|
1765
|
+
export function formatPickerLabel(s, query, cols = {}, ssh, host = '') {
|
|
1656
1766
|
const agentColor = colorAgent(s.agent);
|
|
1657
1767
|
const when = formatRelativeTime(s.lastActivity ?? s.timestamp);
|
|
1658
1768
|
const project = s.project || '-';
|
|
1769
|
+
// SSH-launch origin (live rows only): mirrors the flat listing's `ssh←<device>`
|
|
1770
|
+
// badge. Rendered as its OWN red segment before the topic cell — folding it into
|
|
1771
|
+
// the topic string loses the colour, because renderTopicCell strips ANSI and
|
|
1772
|
+
// re-whitens every slice. Its width is reserved from the topic budget below
|
|
1773
|
+
// (exactly like `wt`), so the fixed-width columns stay aligned.
|
|
1774
|
+
const sshPlain = ssh ? (ssh.device ? `ssh←${ssh.device} ` : 'ssh ') : '';
|
|
1775
|
+
const sshSeg = sshPlain ? chalk.red(sshPlain) : '';
|
|
1776
|
+
const sshW = sshPlain ? stringWidth(sshPlain) : 0;
|
|
1659
1777
|
const tag = originTag(s) || teamTag(s);
|
|
1660
1778
|
const label = s.label;
|
|
1661
1779
|
const topic = tag ? `${tag}${s.topic ?? ''}` : s.topic;
|
|
@@ -1669,19 +1787,31 @@ export function formatPickerLabel(s, query, cols = {}) {
|
|
|
1669
1787
|
const ticketCell = cols.showTicket
|
|
1670
1788
|
? chalk.blue(padRight(truncate(ticketLabel(s) || '-', TICKET_W), TICKET_W + 1))
|
|
1671
1789
|
: '';
|
|
1790
|
+
// Which terminal/editor the session runs in — a tab in Ghostty vs a VS Code
|
|
1791
|
+
// panel vs a detached tmux pane is the thing you need to know to go find it.
|
|
1792
|
+
const hostCell = cols.showHost
|
|
1793
|
+
? chalk.gray(padRight(truncate(host || '-', PICKER_HOST_W - 1), PICKER_HOST_W))
|
|
1794
|
+
: '';
|
|
1672
1795
|
// The picker prepends a gutter (cursor, plus a checkbox in multi-select mode);
|
|
1673
1796
|
// reserve it, plus the conditional columns, so the topic shrinks to fit and
|
|
1674
1797
|
// rows never wrap.
|
|
1675
1798
|
const gutter = cols.gutter ?? 2;
|
|
1676
1799
|
const machineColW = cols.showMachine ? machineW : 0;
|
|
1677
1800
|
const ticketW = cols.showTicket ? TICKET_W + 1 : 0;
|
|
1801
|
+
const hostW = cols.showHost ? PICKER_HOST_W : 0;
|
|
1678
1802
|
const wtW = wt ? stringWidth(wt) + 1 : 0;
|
|
1679
|
-
const topicW = Math.max(16, terminalWidth() - gutter - (10 + 9 + 8 + 16) - machineColW - ticketW - wtW - stringWidth(when) - 1);
|
|
1680
|
-
return (
|
|
1803
|
+
const topicW = Math.max(16, terminalWidth() - gutter - (10 + 9 + 8 + 16) - machineColW - hostW - ticketW - wtW - sshW - stringWidth(when) - 1);
|
|
1804
|
+
return (
|
|
1805
|
+
// Truncated, not just padded: an indexed shortId is always 8 chars, but a
|
|
1806
|
+
// live row with no session id is named by its pid or cloud task, which can
|
|
1807
|
+
// run past the column and shunt every later column out of alignment.
|
|
1808
|
+
chalk.white(padRight(truncate(s.shortId, 9), 10)) +
|
|
1681
1809
|
agentColor(padRight(truncate(s.agent, 8), 9)) +
|
|
1682
1810
|
chalk.yellow(padRight(truncate(versionStr, 7), 8)) +
|
|
1683
1811
|
machineCell +
|
|
1812
|
+
hostCell +
|
|
1684
1813
|
chalk.cyan(padRight(truncate(project, 14), 16)) +
|
|
1814
|
+
sshSeg +
|
|
1685
1815
|
renderTopicCell(label, topic, query, topicW, topicW) +
|
|
1686
1816
|
ticketCell +
|
|
1687
1817
|
(wt ? wt + ' ' : '') +
|
|
@@ -1745,7 +1875,25 @@ function warnNoPeerTarget(machine, session) {
|
|
|
1745
1875
|
console.log(chalk.yellow(`Session ${session.shortId} lives on ${machine}, which isn't a reachable device right now.`));
|
|
1746
1876
|
console.log(chalk.gray(`Register/wake it (ag devices), or run there: agents ssh ${machine}`));
|
|
1747
1877
|
}
|
|
1878
|
+
/**
|
|
1879
|
+
* Row-id prefix for a live session whose agent has not reported a session id yet
|
|
1880
|
+
* (a booting harness, a queued teammate). The browser lists these so nothing live
|
|
1881
|
+
* is hidden, but they address no transcript — {@link isIdlessLiveRow} is what the
|
|
1882
|
+
* pick handler checks before trying to read or resume one.
|
|
1883
|
+
*/
|
|
1884
|
+
export const LIVE_ROW_PREFIX = 'live:';
|
|
1885
|
+
export function isIdlessLiveRow(s) {
|
|
1886
|
+
return s.id.startsWith(LIVE_ROW_PREFIX);
|
|
1887
|
+
}
|
|
1748
1888
|
export async function handlePickedSession(picked) {
|
|
1889
|
+
// A live row with no session id has no transcript to open and no id to resume
|
|
1890
|
+
// by; say where the process is instead of dead-ending on an unreadable path.
|
|
1891
|
+
if (isIdlessLiveRow(picked.session)) {
|
|
1892
|
+
const where = picked.session.machine ? ` on ${picked.session.machine}` : '';
|
|
1893
|
+
console.log(chalk.yellow(`This session hasn't reported a session id yet — nothing to open${where}.`));
|
|
1894
|
+
console.log(chalk.gray(`Watch for it with: agents sessions --active${picked.session.machine ? ` --host ${picked.session.machine}` : ''}`));
|
|
1895
|
+
return;
|
|
1896
|
+
}
|
|
1749
1897
|
// A session on another machine is read/resumed ON that machine over SSH — its
|
|
1750
1898
|
// transcript and agent binary live there. Both actions execute on the peer
|
|
1751
1899
|
// (not a local `--host` hop, which would discover locally and dead-end for a
|
|
@@ -2062,9 +2210,10 @@ function formatSearchMessage(options) {
|
|
|
2062
2210
|
/**
|
|
2063
2211
|
* The single entry point for turning a `sessions <query>` argument into rows.
|
|
2064
2212
|
*
|
|
2065
|
-
*
|
|
2066
|
-
*
|
|
2067
|
-
*
|
|
2213
|
+
* An id-shaped query — a complete id OR a hex short-id/prefix (looksLikeSessionId)
|
|
2214
|
+
* — resolves by id alone, through the index, and never falls back to content
|
|
2215
|
+
* search (a bare id must not surface every transcript that merely mentions it).
|
|
2216
|
+
* A genuine search phrase keeps the ranked metadata+content search.
|
|
2068
2217
|
*/
|
|
2069
2218
|
export function resolveSessionQuery(pool, query) {
|
|
2070
2219
|
// Normalize ONCE here. isCompleteSessionId trims but resolveSessionById does
|
|
@@ -2076,12 +2225,16 @@ export function resolveSessionQuery(pool, query) {
|
|
|
2076
2225
|
const byIdMatches = resolveSessionById(pool, normalized);
|
|
2077
2226
|
if (byIdMatches.length > 0)
|
|
2078
2227
|
return { matches: byIdMatches, byId: true, completeId };
|
|
2079
|
-
if (
|
|
2080
|
-
//
|
|
2081
|
-
//
|
|
2082
|
-
//
|
|
2083
|
-
//
|
|
2084
|
-
//
|
|
2228
|
+
if (looksLikeSessionId(normalized)) {
|
|
2229
|
+
// Any id-shaped query — a complete id OR a bare hex short-id/prefix —
|
|
2230
|
+
// resolves by id ONLY, never by content. The pool is a minority of the
|
|
2231
|
+
// index (measured: 2,798 of 7,614 rows) because it re-reads live agent homes
|
|
2232
|
+
// and skips whole classes of indexed session, so a pool miss isn't absence:
|
|
2233
|
+
// ask the index directly, the same authoritative lookup `fork` and `exec`
|
|
2234
|
+
// use. And an id that resolves to nothing must report "no session with that
|
|
2235
|
+
// id" — NOT fall back to fuzzy content search. A short id like "d3470b57"
|
|
2236
|
+
// otherwise surfaces every transcript that merely MENTIONS the string (a
|
|
2237
|
+
// resume prompt echoes the parent id into the body of many later sessions).
|
|
2085
2238
|
return { matches: findSessionsById(normalized), byId: true, completeId };
|
|
2086
2239
|
}
|
|
2087
2240
|
return { matches: filterSessionsByQuery(pool, normalized), byId: false, completeId };
|
|
@@ -2237,7 +2390,7 @@ async function renderArtifactsGlobal(query, listAll, name, scope) {
|
|
|
2237
2390
|
const { matches: queryMatches, byId, completeId } = resolveSessionQuery(allSessions, query);
|
|
2238
2391
|
if (queryMatches.length === 0) {
|
|
2239
2392
|
spinner.stop();
|
|
2240
|
-
if (
|
|
2393
|
+
if (byId)
|
|
2241
2394
|
notFoundByIdMessage(query).forEach(l => console.error(l));
|
|
2242
2395
|
else
|
|
2243
2396
|
console.error(chalk.red(`No session found matching: ${query}`));
|
|
@@ -2281,11 +2434,14 @@ async function renderOneSession(query, mode, scope) {
|
|
|
2281
2434
|
let queryMatches = resolution.matches;
|
|
2282
2435
|
let byId = resolution.byId;
|
|
2283
2436
|
const completeId = resolution.completeId;
|
|
2284
|
-
// Widen to the transcript content index only for a genuine search phrase.
|
|
2285
|
-
//
|
|
2286
|
-
// session
|
|
2287
|
-
// <uuid>` render an
|
|
2288
|
-
|
|
2437
|
+
// Widen to the transcript content index only for a genuine search phrase.
|
|
2438
|
+
// ANY id-shaped query (a complete id OR a hex short-id/prefix) names a
|
|
2439
|
+
// specific session; widening could only surface a DIFFERENT session that
|
|
2440
|
+
// happens to MENTION the id — which is what made `sessions <uuid>` render an
|
|
2441
|
+
// unrelated transcript and `sessions <shortid>` list every session that
|
|
2442
|
+
// echoes the id in a resume prompt. Gate on looksLikeSessionId, not just
|
|
2443
|
+
// completeId, so a short id resolves to "no match" rather than fuzzy content.
|
|
2444
|
+
if (queryMatches.length === 0 && !looksLikeSessionId(query)) {
|
|
2289
2445
|
const contentResults = searchContentIndex(allSessions, query);
|
|
2290
2446
|
if (contentResults.size > 0) {
|
|
2291
2447
|
const matchedSessions = Array.from(contentResults.values())
|
|
@@ -2312,7 +2468,22 @@ async function renderOneSession(query, mode, scope) {
|
|
|
2312
2468
|
process.exit(1);
|
|
2313
2469
|
}
|
|
2314
2470
|
}
|
|
2315
|
-
else if (
|
|
2471
|
+
else if (byId) {
|
|
2472
|
+
// Not on this machine. A UUID names ONE session, so before giving up ask
|
|
2473
|
+
// the fleet — the session may live only on a peer (the whole point of
|
|
2474
|
+
// RUSH-2024). Skip the sweep when the caller pinned --local, or when we
|
|
2475
|
+
// are ourselves a peer answering a parent's sweep (AGENTS_SESSIONS_LOCAL),
|
|
2476
|
+
// so a locate never recurses. On a single remote hit we hand rendering to
|
|
2477
|
+
// that peer; a multi-host hit surfaces the conflict; a miss keeps the
|
|
2478
|
+
// local not-found message. No FTS fallback either way.
|
|
2479
|
+
if (shouldFanOutForId(query, scope.local)) {
|
|
2480
|
+
const outcome = await resolveSessionAcrossFleet(query, mode, scope.hosts);
|
|
2481
|
+
if (outcome === 'rendered')
|
|
2482
|
+
return;
|
|
2483
|
+
if (outcome === 'conflict')
|
|
2484
|
+
process.exit(1);
|
|
2485
|
+
// 'not-found' falls through to the local message below.
|
|
2486
|
+
}
|
|
2316
2487
|
notFoundByIdMessage(query).forEach(l => console.error(l));
|
|
2317
2488
|
process.exit(1);
|
|
2318
2489
|
}
|
|
@@ -2351,6 +2522,123 @@ async function renderOneSession(query, mode, scope) {
|
|
|
2351
2522
|
process.exit(1);
|
|
2352
2523
|
}
|
|
2353
2524
|
}
|
|
2525
|
+
/**
|
|
2526
|
+
* Whether a missed local id lookup should widen to a cross-machine sweep.
|
|
2527
|
+
*
|
|
2528
|
+
* Gate (all must hold):
|
|
2529
|
+
* - the query is id-shaped (`looksLikeSessionId`) — only an identifier resolves
|
|
2530
|
+
* across the fleet; a search phrase never does.
|
|
2531
|
+
* - not `--local` — the caller opted out of cross-machine lookup (deterministic
|
|
2532
|
+
* local behavior for scripts, RUSH-2024 acceptance: "--local still restricts").
|
|
2533
|
+
* - `AGENTS_SESSIONS_LOCAL` is unset — we are not ourselves a peer answering a
|
|
2534
|
+
* parent's sweep, so a locate can never recurse (RUSH-2024: avoid double-fan-out).
|
|
2535
|
+
*
|
|
2536
|
+
* Pure + exported so the gate is unit-tested without driving discovery / SSH.
|
|
2537
|
+
*/
|
|
2538
|
+
export function shouldFanOutForId(query, local) {
|
|
2539
|
+
if (local === true)
|
|
2540
|
+
return false;
|
|
2541
|
+
if (process.env[NO_FANOUT_ENV] === '1')
|
|
2542
|
+
return false;
|
|
2543
|
+
return looksLikeSessionId(query);
|
|
2544
|
+
}
|
|
2545
|
+
/** The render-mode flag a peer's `agents sessions <id>` must carry so the remote
|
|
2546
|
+
* summary matches the mode the user asked for. `summary` is the peer's default,
|
|
2547
|
+
* so it needs no flag; the others map 1:1 to a CLI flag. */
|
|
2548
|
+
function modeFlag(mode) {
|
|
2549
|
+
if (mode === 'markdown')
|
|
2550
|
+
return '--markdown';
|
|
2551
|
+
if (mode === 'json')
|
|
2552
|
+
return '--json';
|
|
2553
|
+
return undefined; // summary — the peer's default render
|
|
2554
|
+
}
|
|
2555
|
+
/** Group a fleet sweep's rows to the DISTINCT machines that hold the id. Each
|
|
2556
|
+
* peer answered `sessions <id> --json --local`, which (post-fix) id-resolves and
|
|
2557
|
+
* so returns the matching row(s); a peer with a synced MIRROR of the same id can
|
|
2558
|
+
* emit more than one row, so we keep the first per machine. Rows the peer somehow
|
|
2559
|
+
* returned that do NOT match the id (defensive against version skew) are dropped
|
|
2560
|
+
* so a stray content hit can never masquerade as an exact resolution. */
|
|
2561
|
+
export function fleetHitsById(rows, id) {
|
|
2562
|
+
const q = id.trim().toLowerCase();
|
|
2563
|
+
const byMachine = new Map();
|
|
2564
|
+
for (const s of rows) {
|
|
2565
|
+
const machine = s.machine;
|
|
2566
|
+
if (!machine)
|
|
2567
|
+
continue; // an untagged row can't be routed back to a peer
|
|
2568
|
+
if (s.id?.toLowerCase() !== q)
|
|
2569
|
+
continue; // exact id only — never a content hit
|
|
2570
|
+
if (!byMachine.has(machine))
|
|
2571
|
+
byMachine.set(machine, s);
|
|
2572
|
+
}
|
|
2573
|
+
return Array.from(byMachine.entries()).map(([machine, session]) => ({ machine, session }));
|
|
2574
|
+
}
|
|
2575
|
+
/**
|
|
2576
|
+
* Locate a full session id across the online fleet and render it from the machine
|
|
2577
|
+
* that holds it. The local disk already missed; this fans `sessions <id> --json
|
|
2578
|
+
* --all` out to every registered online peer (or the explicit `hosts` set),
|
|
2579
|
+
* groups the rows to distinct machines, then:
|
|
2580
|
+
*
|
|
2581
|
+
* - exactly one machine → delegate rendering to that peer via `runOnPeer`
|
|
2582
|
+
* (its transcript and agent binary live there — a local `--host` hop would
|
|
2583
|
+
* re-discover locally and dead-end), returning `'rendered'`.
|
|
2584
|
+
* - more than one machine → print the conflict with machine labels so the user
|
|
2585
|
+
* can disambiguate with `--device <host>`, returning `'conflict'`.
|
|
2586
|
+
* - none → `'not-found'`, letting the caller print the local
|
|
2587
|
+
* "no session on this machine" message.
|
|
2588
|
+
*
|
|
2589
|
+
* No fuzzy/content fallback: the sweep forwards a UUID, each peer id-resolves it,
|
|
2590
|
+
* and `fleetHitsById` drops anything that isn't an exact id match.
|
|
2591
|
+
*/
|
|
2592
|
+
export async function resolveSessionAcrossFleet(id, mode, hosts, deps = { gatherRemoteList, runOnPeer }) {
|
|
2593
|
+
const spinner = isInteractiveTerminal() ? ora('Searching the fleet...').start() : null;
|
|
2594
|
+
let hits;
|
|
2595
|
+
try {
|
|
2596
|
+
// Force whole-index scope (--all): the peer runs in its SSH-login home dir,
|
|
2597
|
+
// whose cwd would otherwise silently narrow the lookup and hide the row.
|
|
2598
|
+
// --json so each peer answers a parseable array; --local so it answers for
|
|
2599
|
+
// itself and never re-fans-out (belt-and-suspenders with the parent's
|
|
2600
|
+
// AGENTS_SESSIONS_LOCAL, which remote-list also sets on the peer).
|
|
2601
|
+
const forwarded = ['sessions', id, '--json', '--all', '--local'];
|
|
2602
|
+
const { sessions } = await deps.gatherRemoteList(forwarded, hosts);
|
|
2603
|
+
hits = fleetHitsById(sessions, id);
|
|
2604
|
+
}
|
|
2605
|
+
catch {
|
|
2606
|
+
// A fan-out failure is not an exact resolution — treat as not-found so the
|
|
2607
|
+
// caller prints the honest local message rather than a half-answer.
|
|
2608
|
+
hits = [];
|
|
2609
|
+
}
|
|
2610
|
+
finally {
|
|
2611
|
+
spinner?.stop();
|
|
2612
|
+
}
|
|
2613
|
+
if (hits.length === 0)
|
|
2614
|
+
return 'not-found';
|
|
2615
|
+
if (hits.length > 1) {
|
|
2616
|
+
console.error(chalk.red(`Session ${id} exists on multiple machines:`));
|
|
2617
|
+
for (const h of hits) {
|
|
2618
|
+
const s = h.session;
|
|
2619
|
+
const label = s.label ?? s.topic ?? '';
|
|
2620
|
+
console.error(chalk.cyan(` ${h.machine}`) + chalk.gray(` ${s.agent}${s.version ? ` ${s.version}` : ''} ${label}`));
|
|
2621
|
+
}
|
|
2622
|
+
console.error(chalk.gray(`Pick one with: agents sessions ${id} --device <host>`));
|
|
2623
|
+
return 'conflict';
|
|
2624
|
+
}
|
|
2625
|
+
const { machine } = hits[0];
|
|
2626
|
+
// Render the remote summary by re-running `sessions <id>` ON the peer. --local
|
|
2627
|
+
// keeps that render on the peer (it owns the transcript); the mode flag matches
|
|
2628
|
+
// the mode the user asked for. No TTY: a summary/markdown/json render is a
|
|
2629
|
+
// one-shot capture, not an interactive resume.
|
|
2630
|
+
const peerArgs = ['sessions', id, '--local'];
|
|
2631
|
+
const flag = modeFlag(mode);
|
|
2632
|
+
if (flag)
|
|
2633
|
+
peerArgs.push(flag);
|
|
2634
|
+
const result = await deps.runOnPeer(peerArgs, machine);
|
|
2635
|
+
if (result === 'no-target') {
|
|
2636
|
+
console.error(chalk.red(`Session ${id} is on ${machine}, but it is not a reachable registered device.`));
|
|
2637
|
+
console.error(chalk.gray('Register it with `agents devices` or run the command on that machine.'));
|
|
2638
|
+
return 'conflict'; // a definitive answer (found, un-renderable) — do NOT fall to the local not-found line
|
|
2639
|
+
}
|
|
2640
|
+
return 'rendered';
|
|
2641
|
+
}
|
|
2354
2642
|
/** Register the `agents sessions` command with all its options and help text. */
|
|
2355
2643
|
export function registerSessionsCommands(program) {
|
|
2356
2644
|
const sessionsCmd = program
|
|
@@ -2365,7 +2653,7 @@ export function registerSessionsCommands(program) {
|
|
|
2365
2653
|
.option('--antigravity', 'Shorthand for --agent antigravity')
|
|
2366
2654
|
.option('--grok', 'Shorthand for --agent grok')
|
|
2367
2655
|
.option('--opencode', 'Shorthand for --agent opencode')
|
|
2368
|
-
.option('--all', '
|
|
2656
|
+
.option('--all', 'Widen every non-status filter to "all": every directory (not just this project) and all time (no window cap). Status filters like --active still compose; -a/--device/--since still narrow their axis.')
|
|
2369
2657
|
.option('--unmanaged', "Also show sessions from your own ~/.<agent> installs (hidden once agents-cli manages that agent)")
|
|
2370
2658
|
.option('--teams', 'Include team-spawned sessions (hidden by default)')
|
|
2371
2659
|
.option('--routine', 'Show only sessions archived from routine runs')
|
|
@@ -2377,10 +2665,10 @@ export function registerSessionsCommands(program) {
|
|
|
2377
2665
|
.option('--markdown', 'Render the session as markdown (user, assistant, thinking, tool calls)')
|
|
2378
2666
|
.option('--no-redact', 'Disable default secret redaction in rendered session output (--markdown and --json)')
|
|
2379
2667
|
.option('--json', 'Output JSON (session list when browsing, event array when rendering one session)')
|
|
2380
|
-
.option('--include <roles>', 'Only include these roles (comma-separated): user, assistant, thinking, tools')
|
|
2668
|
+
.option('--include <roles>', 'Only include these roles (comma-separated): user, assistant, thinking, tools. "user" is genuine user turns only, not harness-injected scaffolding (bash-input, system-reminder)')
|
|
2381
2669
|
.option('--exclude <roles>', 'Exclude these roles (comma-separated): user, assistant, thinking, tools')
|
|
2382
|
-
.option('--first <n>', 'Keep only the first N turns (a turn starts at each user message)')
|
|
2383
|
-
.option('--last <n>', 'Keep only the last N turns (a turn starts at each user message)')
|
|
2670
|
+
.option('--first <n>', 'Keep only the first N turns (a turn starts at each genuine user message, not harness-injected scaffolding)')
|
|
2671
|
+
.option('--last <n>', 'Keep only the last N turns (a turn starts at each genuine user message, not harness-injected scaffolding)')
|
|
2384
2672
|
.option('--artifacts', 'List all files written or edited during a session')
|
|
2385
2673
|
.option('--artifact <name>', 'Read a specific artifact by filename or path (outputs to stdout)')
|
|
2386
2674
|
.option('--active', 'Show only sessions running right now across terminals, teams, cloud, and headless agents')
|