@phnx-labs/agents-cli 1.20.34 → 1.20.36
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 +113 -0
- package/LICENSE +185 -21
- package/README.md +13 -5
- package/dist/commands/audit.d.ts +14 -0
- package/dist/commands/audit.js +68 -0
- package/dist/commands/browser.js +82 -8
- package/dist/commands/check.d.ts +15 -0
- package/dist/commands/check.js +84 -0
- package/dist/commands/cloud.js +143 -3
- package/dist/commands/computer-actions.d.ts +15 -0
- package/dist/commands/computer-actions.js +120 -22
- package/dist/commands/computer.js +81 -0
- package/dist/commands/cost.js +17 -12
- package/dist/commands/daemon.js +4 -1
- package/dist/commands/doctor.d.ts +1 -0
- package/dist/commands/doctor.js +54 -97
- package/dist/commands/events.d.ts +16 -0
- package/dist/commands/events.js +173 -0
- package/dist/commands/exec.d.ts +19 -0
- package/dist/commands/exec.js +283 -12
- package/dist/commands/hosts.js +37 -29
- package/dist/commands/inspect.d.ts +2 -0
- package/dist/commands/inspect.js +62 -16
- package/dist/commands/lock.d.ts +12 -0
- package/dist/commands/lock.js +70 -0
- package/dist/commands/logs.d.ts +17 -0
- package/dist/commands/logs.js +139 -0
- package/dist/commands/message.d.ts +15 -0
- package/dist/commands/message.js +56 -0
- package/dist/commands/models.d.ts +3 -0
- package/dist/commands/models.js +27 -8
- package/dist/commands/permissions.js +9 -2
- package/dist/commands/repo.d.ts +34 -0
- package/dist/commands/repo.js +243 -65
- package/dist/commands/resource-view.d.ts +20 -0
- package/dist/commands/resource-view.js +90 -28
- package/dist/commands/routines.js +101 -5
- package/dist/commands/secrets-migrate.js +106 -57
- package/dist/commands/secrets.d.ts +31 -18
- package/dist/commands/secrets.js +196 -91
- package/dist/commands/serve.d.ts +10 -0
- package/dist/commands/serve.js +37 -0
- package/dist/commands/sessions-inject.d.ts +14 -0
- package/dist/commands/sessions-inject.js +111 -0
- package/dist/commands/sessions-picker.d.ts +2 -0
- package/dist/commands/sessions-picker.js +24 -3
- package/dist/commands/sessions-resume.d.ts +2 -0
- package/dist/commands/sessions-resume.js +221 -0
- package/dist/commands/sessions-tail.d.ts +10 -0
- package/dist/commands/sessions-tail.js +11 -0
- package/dist/commands/sessions.d.ts +143 -1
- package/dist/commands/sessions.js +628 -80
- package/dist/commands/setup.js +4 -2
- package/dist/commands/sync.d.ts +3 -1
- package/dist/commands/sync.js +156 -4
- package/dist/commands/teams.js +217 -0
- package/dist/commands/versions.js +2 -4
- package/dist/commands/view.d.ts +3 -0
- package/dist/commands/view.js +19 -8
- package/dist/commands/wallet.d.ts +6 -0
- package/dist/commands/wallet.js +22 -5
- package/dist/commands/watchdog.d.ts +18 -0
- package/dist/commands/watchdog.js +238 -0
- package/dist/index.js +82 -32
- package/dist/lib/agents.js +30 -9
- package/dist/lib/audit/log.d.ts +92 -0
- package/dist/lib/audit/log.js +177 -0
- package/dist/lib/auto-pull.js +2 -1
- package/dist/lib/browser/cdp.js +5 -1
- package/dist/lib/browser/chrome.d.ts +10 -0
- package/dist/lib/browser/chrome.js +18 -7
- package/dist/lib/browser/drivers/ssh.d.ts +8 -0
- package/dist/lib/browser/drivers/ssh.js +77 -16
- package/dist/lib/browser/har.d.ts +84 -0
- package/dist/lib/browser/har.js +77 -0
- package/dist/lib/browser/ipc.d.ts +3 -0
- package/dist/lib/browser/ipc.js +37 -12
- package/dist/lib/browser/profiles.d.ts +1 -1
- package/dist/lib/browser/profiles.js +8 -10
- package/dist/lib/browser/refs.d.ts +65 -0
- package/dist/lib/browser/refs.js +73 -1
- package/dist/lib/browser/runtime-state.js +1 -0
- package/dist/lib/browser/service.d.ts +38 -2
- package/dist/lib/browser/service.js +183 -23
- package/dist/lib/browser/types.d.ts +14 -1
- package/dist/lib/budget/live-cloud.d.ts +42 -0
- package/dist/lib/budget/live-cloud.js +79 -0
- package/dist/lib/budget/live-team.d.ts +31 -0
- package/dist/lib/budget/live-team.js +115 -0
- package/dist/lib/cloud/codex.js +4 -0
- package/dist/lib/cloud/rush.d.ts +12 -1
- package/dist/lib/cloud/rush.js +13 -3
- package/dist/lib/cloud/types.d.ts +9 -0
- package/dist/lib/computer/dispatch.d.ts +8 -0
- package/dist/lib/computer/dispatch.js +125 -0
- package/dist/lib/computer/loop.d.ts +62 -0
- package/dist/lib/computer/loop.js +98 -0
- package/dist/lib/computer/model.d.ts +44 -0
- package/dist/lib/computer/model.js +157 -0
- package/dist/lib/concurrency.d.ts +19 -0
- package/dist/lib/concurrency.js +33 -0
- package/dist/lib/daemon.d.ts +92 -0
- package/dist/lib/daemon.js +222 -18
- package/dist/lib/devices/connect.d.ts +3 -2
- package/dist/lib/devices/connect.js +5 -3
- package/dist/lib/devices/registry.d.ts +7 -0
- package/dist/lib/devices/registry.js +24 -0
- package/dist/lib/devices/tailscale.js +1 -1
- package/dist/lib/drift.d.ts +52 -0
- package/dist/lib/drift.js +112 -0
- package/dist/lib/events.d.ts +10 -3
- package/dist/lib/events.js +101 -24
- package/dist/lib/exec.d.ts +17 -0
- package/dist/lib/exec.js +100 -12
- package/dist/lib/git.d.ts +27 -0
- package/dist/lib/git.js +56 -1
- package/dist/lib/hooks/cache.d.ts +6 -0
- package/dist/lib/hooks/cache.js +54 -12
- package/dist/lib/hooks.d.ts +27 -0
- package/dist/lib/hooks.js +135 -10
- package/dist/lib/hosts/dispatch.d.ts +15 -0
- package/dist/lib/hosts/dispatch.js +45 -13
- package/dist/lib/hosts/logs.d.ts +16 -0
- package/dist/lib/hosts/logs.js +74 -0
- package/dist/lib/hosts/option.js +1 -1
- package/dist/lib/hosts/passthrough.js +3 -1
- package/dist/lib/hosts/progress.d.ts +66 -0
- package/dist/lib/hosts/progress.js +125 -17
- package/dist/lib/hosts/ready.d.ts +51 -7
- package/dist/lib/hosts/ready.js +98 -25
- package/dist/lib/hosts/reconcile.d.ts +53 -0
- package/dist/lib/hosts/reconcile.js +81 -0
- package/dist/lib/hosts/registry.d.ts +19 -2
- package/dist/lib/hosts/registry.js +58 -2
- package/dist/lib/hosts/remote-cmd.d.ts +62 -1
- package/dist/lib/hosts/remote-cmd.js +70 -1
- package/dist/lib/hosts/remote-os.d.ts +17 -0
- package/dist/lib/hosts/remote-os.js +30 -0
- package/dist/lib/hosts/session-index.d.ts +34 -0
- package/dist/lib/hosts/session-index.js +56 -0
- package/dist/lib/hosts/tasks.d.ts +22 -0
- package/dist/lib/hosts/tasks.js +29 -0
- package/dist/lib/lock.d.ts +93 -0
- package/dist/lib/lock.js +207 -0
- package/dist/lib/loop.js +16 -1
- package/dist/lib/machine-id.d.ts +21 -0
- package/dist/lib/machine-id.js +26 -0
- package/dist/lib/mailbox-target.d.ts +36 -0
- package/dist/lib/mailbox-target.js +45 -0
- package/dist/lib/mailbox.d.ts +47 -0
- package/dist/lib/mailbox.js +194 -0
- package/dist/lib/mcp.d.ts +5 -0
- package/dist/lib/mcp.js +24 -8
- package/dist/lib/migrate.d.ts +19 -0
- package/dist/lib/migrate.js +134 -26
- package/dist/lib/overdue.js +3 -0
- package/dist/lib/permissions.d.ts +4 -0
- package/dist/lib/permissions.js +35 -0
- package/dist/lib/picker.d.ts +26 -0
- package/dist/lib/picker.js +127 -0
- package/dist/lib/platform/exec.d.ts +46 -0
- package/dist/lib/platform/exec.js +74 -0
- package/dist/lib/platform/process.d.ts +31 -0
- package/dist/lib/platform/process.js +34 -1
- package/dist/lib/platform/winpath.js +2 -0
- package/dist/lib/plugin-marketplace.d.ts +30 -0
- package/dist/lib/plugin-marketplace.js +215 -2
- package/dist/lib/plugins.d.ts +5 -0
- package/dist/lib/plugins.js +61 -10
- package/dist/lib/profiles.d.ts +25 -0
- package/dist/lib/profiles.js +22 -6
- package/dist/lib/pty-client.js +2 -1
- package/dist/lib/rotate.d.ts +61 -0
- package/dist/lib/rotate.js +52 -0
- package/dist/lib/routines.d.ts +40 -2
- package/dist/lib/routines.js +66 -8
- package/dist/lib/runner.d.ts +11 -2
- package/dist/lib/runner.js +49 -7
- package/dist/lib/scheduler.js +6 -1
- package/dist/lib/secrets/bundles.d.ts +60 -4
- package/dist/lib/secrets/bundles.js +131 -12
- package/dist/lib/secrets/filestore.d.ts +3 -0
- package/dist/lib/secrets/filestore.js +42 -16
- package/dist/lib/secrets/index.d.ts +43 -2
- package/dist/lib/secrets/index.js +102 -3
- package/dist/lib/secrets/mcp.d.ts +93 -0
- package/dist/lib/secrets/mcp.js +205 -0
- package/dist/lib/secrets/remote.js +12 -5
- package/dist/lib/secrets/sync.js +83 -4
- package/dist/lib/secrets/windows.js +14 -3
- package/dist/lib/serve/data.d.ts +81 -0
- package/dist/lib/serve/data.js +91 -0
- package/dist/lib/serve/page.d.ts +7 -0
- package/dist/lib/serve/page.js +140 -0
- package/dist/lib/serve/server.d.ts +46 -0
- package/dist/lib/serve/server.js +115 -0
- package/dist/lib/session/active.d.ts +61 -0
- package/dist/lib/session/active.js +196 -17
- package/dist/lib/session/discover.d.ts +37 -0
- package/dist/lib/session/discover.js +204 -27
- package/dist/lib/session/inject.d.ts +18 -0
- package/dist/lib/session/inject.js +21 -0
- package/dist/lib/session/parse.d.ts +6 -0
- package/dist/lib/session/parse.js +286 -2
- package/dist/lib/session/pid-registry.d.ts +22 -0
- package/dist/lib/session/pid-registry.js +104 -0
- package/dist/lib/session/provenance.d.ts +14 -2
- package/dist/lib/session/provenance.js +39 -8
- package/dist/lib/session/remote-active.d.ts +26 -0
- package/dist/lib/session/remote-active.js +153 -0
- package/dist/lib/session/remote-list.d.ts +51 -0
- package/dist/lib/session/remote-list.js +213 -0
- package/dist/lib/session/remote.d.ts +7 -1
- package/dist/lib/session/remote.js +18 -8
- package/dist/lib/session/render.js +1 -1
- package/dist/lib/session/sync/config.d.ts +1 -7
- package/dist/lib/session/sync/config.js +4 -11
- package/dist/lib/session/types.d.ts +18 -1
- package/dist/lib/session/types.js +1 -1
- package/dist/lib/shims.d.ts +44 -6
- package/dist/lib/shims.js +143 -57
- package/dist/lib/ssh-exec.d.ts +26 -3
- package/dist/lib/ssh-exec.js +47 -3
- package/dist/lib/ssh-tunnel.d.ts +24 -5
- package/dist/lib/ssh-tunnel.js +62 -63
- package/dist/lib/staleness/writers/hooks.js +1 -1
- package/dist/lib/startup/command-registry.d.ts +8 -0
- package/dist/lib/startup/command-registry.js +17 -1
- package/dist/lib/state.d.ts +13 -0
- package/dist/lib/state.js +103 -9
- package/dist/lib/sync-umbrella.d.ts +14 -7
- package/dist/lib/sync-umbrella.js +17 -9
- package/dist/lib/teams/forEach.d.ts +110 -0
- package/dist/lib/teams/forEach.js +186 -0
- package/dist/lib/teams/index.d.ts +1 -0
- package/dist/lib/teams/index.js +1 -0
- package/dist/lib/teams/pr-watch.d.ts +226 -0
- package/dist/lib/teams/pr-watch.js +371 -0
- package/dist/lib/teams/registry.js +25 -9
- package/dist/lib/teams/supervisor.d.ts +14 -1
- package/dist/lib/teams/supervisor.js +19 -0
- package/dist/lib/teams/worktree.d.ts +9 -0
- package/dist/lib/teams/worktree.js +32 -0
- package/dist/lib/terminal/backends/ghostty.d.ts +6 -0
- package/dist/lib/terminal/backends/ghostty.js +69 -0
- package/dist/lib/terminal/backends/index.d.ts +19 -0
- package/dist/lib/terminal/backends/index.js +31 -0
- package/dist/lib/terminal/backends/iterm.d.ts +6 -0
- package/dist/lib/terminal/backends/iterm.js +62 -0
- package/dist/lib/terminal/backends/tmux.d.ts +14 -0
- package/dist/lib/terminal/backends/tmux.js +23 -0
- package/dist/lib/terminal/backends/vscodium-agent.d.ts +31 -0
- package/dist/lib/terminal/backends/vscodium-agent.js +72 -0
- package/dist/lib/terminal/engine.d.ts +39 -0
- package/dist/lib/terminal/engine.js +54 -0
- package/dist/lib/terminal/index.d.ts +17 -0
- package/dist/lib/terminal/index.js +8 -0
- package/dist/lib/terminal/inject.d.ts +204 -0
- package/dist/lib/terminal/inject.js +247 -0
- package/dist/lib/terminal/policy.d.ts +11 -0
- package/dist/lib/terminal/policy.js +11 -0
- package/dist/lib/terminal/quote.d.ts +11 -0
- package/dist/lib/terminal/quote.js +13 -0
- package/dist/lib/terminal/resolve.d.ts +64 -0
- package/dist/lib/terminal/resolve.js +90 -0
- package/dist/lib/terminal/shell.d.ts +6 -0
- package/dist/lib/terminal/shell.js +23 -0
- package/dist/lib/terminal/transport.d.ts +15 -0
- package/dist/lib/terminal/transport.js +40 -0
- package/dist/lib/terminal/types.d.ts +59 -0
- package/dist/lib/terminal/types.js +13 -0
- package/dist/lib/triggers/webhook.d.ts +85 -0
- package/dist/lib/triggers/webhook.js +141 -0
- package/dist/lib/types.d.ts +7 -0
- package/dist/lib/versions.d.ts +30 -3
- package/dist/lib/versions.js +129 -18
- package/dist/lib/watchdog/index.d.ts +3 -0
- package/dist/lib/watchdog/index.js +5 -0
- package/dist/lib/watchdog/read.d.ts +35 -0
- package/dist/lib/watchdog/read.js +149 -0
- package/dist/lib/watchdog/runner.d.ts +127 -0
- package/dist/lib/watchdog/runner.js +322 -0
- package/dist/lib/watchdog/watchdog.d.ts +40 -0
- package/dist/lib/watchdog/watchdog.js +166 -0
- package/dist/lib/watchdog/watchdogTail.d.ts +5 -0
- package/dist/lib/watchdog/watchdogTail.js +154 -0
- package/dist/lib/whats-new.d.ts +9 -0
- package/dist/lib/whats-new.js +35 -0
- package/dist/lib/workflows.d.ts +166 -0
- package/dist/lib/workflows.js +193 -0
- package/package.json +5 -4
|
@@ -15,13 +15,16 @@ import chalk from 'chalk';
|
|
|
15
15
|
import ora from 'ora';
|
|
16
16
|
import { SESSION_AGENTS } from '../lib/session/types.js';
|
|
17
17
|
import { discoverArtifacts, readArtifact, resolveArtifact } from '../lib/session/artifacts.js';
|
|
18
|
-
import { looksLikePath, toComparablePath, homeDir } from '../lib/platform/index.js';
|
|
18
|
+
import { looksLikePath, toComparablePath, homeDir, needsWindowsShell, findExecutable } from '../lib/platform/index.js';
|
|
19
19
|
import { getActiveSessions } from '../lib/session/active.js';
|
|
20
|
+
import { machineId, normalizeHost } from '../lib/session/sync/config.js';
|
|
21
|
+
import { gatherRemoteActive, NO_FANOUT_ENV } from '../lib/session/remote-active.js';
|
|
22
|
+
import { gatherRemoteList, runOnPeer } from '../lib/session/remote-list.js';
|
|
20
23
|
import { stringWidth, truncateToWidth, padToWidth, terminalWidth } from '../lib/session/width.js';
|
|
21
24
|
import { discoverSessions, countSessionsInScope, resolveSessionById, searchContentIndex } from '../lib/session/discover.js';
|
|
22
25
|
import { filterTeamSessions } from '../lib/session/team-filter.js';
|
|
23
26
|
import { parseSession } from '../lib/session/parse.js';
|
|
24
|
-
import { runRemoteSessions } from '../lib/session/remote.js';
|
|
27
|
+
import { runRemoteSessions, buildForwardedArgs } from '../lib/session/remote.js';
|
|
25
28
|
import { formatRelativeTime } from '../lib/session/relative-time.js';
|
|
26
29
|
import { renderConversationMarkdown, renderSummary, renderSummaryHeader, computeSummaryStats, renderJson, filterEvents, parseRoleList } from '../lib/session/render.js';
|
|
27
30
|
import { renderMarkdown } from '../lib/markdown.js';
|
|
@@ -33,6 +36,8 @@ import { sessionPicker } from './sessions-picker.js';
|
|
|
33
36
|
import { setHelpSections } from '../lib/help.js';
|
|
34
37
|
import { registerSessionsTailCommand } from './sessions-tail.js';
|
|
35
38
|
import { registerSessionsSyncCommand } from './sessions-sync.js';
|
|
39
|
+
import { registerSessionsResumeCommand } from './sessions-resume.js';
|
|
40
|
+
import { registerSessionsInjectCommand } from './sessions-inject.js';
|
|
36
41
|
const SESSION_AGENT_FILTER_HELP = `Filter by agent, e.g. claude, codex, claude@2.0.65`;
|
|
37
42
|
const CLAUDE_RESUME_MATCH_WINDOW_MS = 10 * 60_000;
|
|
38
43
|
const LOAD_VERBS = ['Loading', 'Scanning', 'Gathering', 'Indexing', 'Reading'];
|
|
@@ -75,6 +80,12 @@ function createScanProgressTracker(verbs, suffix, spinner) {
|
|
|
75
80
|
}
|
|
76
81
|
const PICKER_RECENT_COUNT = 15;
|
|
77
82
|
const PICKER_POOL_LIMIT = 200;
|
|
83
|
+
// The grouped default view ("overview"): fetch a generous recency-ordered pool
|
|
84
|
+
// for accurate per-project totals, show each project's most-recent rows grouped
|
|
85
|
+
// by project, newest-active project first.
|
|
86
|
+
const OVERVIEW_ROWS_PER_PROJECT = 5; // recent rows shown per project before "· N more"
|
|
87
|
+
const OVERVIEW_POOL_LIMIT = 1000; // fetch cap — accurate per-project totals up to this
|
|
88
|
+
const OVERVIEW_MAX_PROJECTS = 12; // project groups shown before "+N more projects"
|
|
78
89
|
/**
|
|
79
90
|
* Resolve a path-like query to an absolute directory path.
|
|
80
91
|
*/
|
|
@@ -194,6 +205,43 @@ function activityLabel(s) {
|
|
|
194
205
|
return 'idle';
|
|
195
206
|
return s.status === 'input_required' ? 'waiting' : s.status;
|
|
196
207
|
}
|
|
208
|
+
/**
|
|
209
|
+
* Index live sessions by their full session UUID so a historical `SessionMeta`
|
|
210
|
+
* row (`meta.id`) can be matched to the session that is still running now.
|
|
211
|
+
* Rows without a sessionId (some cloud/headless probes) are skipped — they
|
|
212
|
+
* can't be correlated back to a transcript on disk.
|
|
213
|
+
*/
|
|
214
|
+
export function indexActiveBySessionId(active) {
|
|
215
|
+
const byId = new Map();
|
|
216
|
+
for (const a of active) {
|
|
217
|
+
if (a.sessionId)
|
|
218
|
+
byId.set(a.sessionId, a);
|
|
219
|
+
}
|
|
220
|
+
return byId;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* The live decoration for a listing row: a status glyph and the latest-turn
|
|
224
|
+
* preview, when the session is still running. `●` running / `◐` waiting on the
|
|
225
|
+
* user / `○` idle, colored by the same `statusColor` the --active view uses.
|
|
226
|
+
* Returns empty strings when there is no live match, so callers render the
|
|
227
|
+
* plain historical row unchanged.
|
|
228
|
+
*/
|
|
229
|
+
export function liveGlyphAndPreview(a) {
|
|
230
|
+
if (!a)
|
|
231
|
+
return { glyph: '', preview: '' };
|
|
232
|
+
const waiting = a.status === 'input_required' || a.activity === 'waiting_input';
|
|
233
|
+
const running = a.status === 'running' || a.activity === 'working';
|
|
234
|
+
const shape = waiting ? '◐' : running ? '●' : '○';
|
|
235
|
+
return { glyph: statusColor(a.status)(shape), preview: buildSessionDescription(a) };
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* The tracker/PR ref for a session's dedicated column: the ticket id when known,
|
|
239
|
+
* else `PR#<n>`, else empty. Pulled out of the trailing badge blob so refs align
|
|
240
|
+
* into a scannable column instead of jamming against a truncated topic.
|
|
241
|
+
*/
|
|
242
|
+
export function ticketLabel(s) {
|
|
243
|
+
return s.ticketId ?? (s.prNumber ? `PR#${s.prNumber}` : '');
|
|
244
|
+
}
|
|
197
245
|
/**
|
|
198
246
|
* Compact, colour-coded badges for the durable/awaiting signals. Text-only (no
|
|
199
247
|
* emoji, per repo convention): `plan` / `ask` / `perm` for why it's waiting,
|
|
@@ -318,48 +366,183 @@ export function groupActiveSessions(sessions) {
|
|
|
318
366
|
});
|
|
319
367
|
return { workspaces };
|
|
320
368
|
}
|
|
321
|
-
/**
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
369
|
+
/**
|
|
370
|
+
* The machine a session belongs to: an explicit tag (set when merging
|
|
371
|
+
* cross-machine results) wins; else the process's provenance host (normalized
|
|
372
|
+
* to the same id form); else the local machine. Never keys off `ActiveSession.host`
|
|
373
|
+
* — that is the terminal *app* (code/tmux), not the computer.
|
|
374
|
+
*/
|
|
375
|
+
function machineKeyFor(s, localMachine) {
|
|
376
|
+
if (s.machine)
|
|
377
|
+
return s.machine;
|
|
378
|
+
if (s.provenance?.host)
|
|
379
|
+
return normalizeHost(s.provenance.host);
|
|
380
|
+
return localMachine;
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Group active sessions by machine, then delegate each machine's sessions to the
|
|
384
|
+
* existing workspace/window grouping. Local machine is pinned first and flagged;
|
|
385
|
+
* the rest sort by session count descending, then name. Pure — `localMachine` is
|
|
386
|
+
* injected so the function stays testable without reading os.hostname().
|
|
387
|
+
*/
|
|
388
|
+
export function groupSessionsByMachine(sessions, localMachine) {
|
|
389
|
+
const byMachine = new Map();
|
|
390
|
+
for (const s of sessions) {
|
|
391
|
+
const key = machineKeyFor(s, localMachine);
|
|
392
|
+
(byMachine.get(key) ?? byMachine.set(key, []).get(key)).push(s);
|
|
393
|
+
}
|
|
394
|
+
const keys = Array.from(byMachine.keys()).sort((a, b) => {
|
|
395
|
+
if (a === localMachine)
|
|
396
|
+
return -1;
|
|
397
|
+
if (b === localMachine)
|
|
398
|
+
return 1;
|
|
399
|
+
const ac = byMachine.get(a).length, bc = byMachine.get(b).length;
|
|
400
|
+
if (ac !== bc)
|
|
401
|
+
return bc - ac;
|
|
402
|
+
return a.localeCompare(b);
|
|
403
|
+
});
|
|
404
|
+
const machines = keys.map((machine) => ({
|
|
405
|
+
machine,
|
|
406
|
+
isLocal: machine === localMachine,
|
|
407
|
+
total: byMachine.get(machine).length,
|
|
408
|
+
layout: groupActiveSessions(byMachine.get(machine)),
|
|
409
|
+
}));
|
|
410
|
+
return { machines };
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Collapse duplicate sessions after a cross-machine merge. Two rows collapse
|
|
414
|
+
* only when they share both machine and session UUID (the same host listed
|
|
415
|
+
* twice, or a local/remote overlap); rows without a sessionId can't be
|
|
416
|
+
* correlated, so they're all kept.
|
|
417
|
+
*/
|
|
418
|
+
export function dedupeByMachineSession(sessions) {
|
|
419
|
+
const seen = new Set();
|
|
420
|
+
const out = [];
|
|
421
|
+
for (const s of sessions) {
|
|
422
|
+
if (!s.sessionId) {
|
|
423
|
+
out.push(s);
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
const key = `${s.machine ?? ''}:${s.sessionId}`;
|
|
427
|
+
if (seen.has(key))
|
|
428
|
+
continue;
|
|
429
|
+
seen.add(key);
|
|
430
|
+
out.push(s);
|
|
338
431
|
}
|
|
339
|
-
|
|
432
|
+
return out;
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Order a merged listing so the local machine's sessions come first, then each
|
|
436
|
+
* remote machine as a contiguous block (more sessions first, then name), with
|
|
437
|
+
* every machine keeping its incoming order (timestamp) within the block. Also
|
|
438
|
+
* dedupes: a session present both locally (a synced mirror copy) and via live
|
|
439
|
+
* fan-out collapses to one, keyed by machine + session id. Rows are keyed by
|
|
440
|
+
* `machine` (discover tags local rows with the local id; fan-out tags remote
|
|
441
|
+
* rows with the peer id) falling back to `localMachine` when untagged. Pure —
|
|
442
|
+
* `localMachine` is injected so the ordering is testable without os.hostname().
|
|
443
|
+
*/
|
|
444
|
+
export function mergeLocalFirst(sessions, localMachine) {
|
|
445
|
+
const byMachine = new Map();
|
|
446
|
+
const seen = new Set();
|
|
447
|
+
for (const s of sessions) {
|
|
448
|
+
const machine = s.machine || localMachine;
|
|
449
|
+
if (s.id) {
|
|
450
|
+
const dedupeKey = `${machine}:${s.id}`;
|
|
451
|
+
if (seen.has(dedupeKey))
|
|
452
|
+
continue;
|
|
453
|
+
seen.add(dedupeKey);
|
|
454
|
+
}
|
|
455
|
+
(byMachine.get(machine) ?? byMachine.set(machine, []).get(machine)).push(s);
|
|
456
|
+
}
|
|
457
|
+
const keys = Array.from(byMachine.keys()).sort((a, b) => {
|
|
458
|
+
if (a === localMachine)
|
|
459
|
+
return -1;
|
|
460
|
+
if (b === localMachine)
|
|
461
|
+
return 1;
|
|
462
|
+
const ac = byMachine.get(a).length, bc = byMachine.get(b).length;
|
|
463
|
+
if (ac !== bc)
|
|
464
|
+
return bc - ac;
|
|
465
|
+
return a.localeCompare(b);
|
|
466
|
+
});
|
|
467
|
+
return keys.flatMap((k) => byMachine.get(k));
|
|
468
|
+
}
|
|
469
|
+
/** Print one machine's workspace tree, indented under its machine header. */
|
|
470
|
+
function renderWorkspaceLayout(layout, base) {
|
|
340
471
|
let first = true;
|
|
341
472
|
for (const ws of layout.workspaces) {
|
|
342
473
|
if (!first)
|
|
343
474
|
console.log();
|
|
344
475
|
first = false;
|
|
345
|
-
// Print workspace header
|
|
346
476
|
const header = ws.key === '__cloud__'
|
|
347
477
|
? chalk.magenta.bold('cloud')
|
|
348
478
|
: ws.key === '__unknown__'
|
|
349
479
|
? chalk.gray.bold('unknown')
|
|
350
480
|
: chalk.cyan.bold(shortCwd(ws.key));
|
|
351
|
-
console.log(`${header} ${chalk.gray(`(${ws.total})`)}`);
|
|
481
|
+
console.log(`${base}${header} ${chalk.gray(`(${ws.total})`)}`);
|
|
352
482
|
for (const win of ws.windows) {
|
|
353
483
|
// Host is per-process, but every terminal in the same IDE window shares
|
|
354
484
|
// an ancestor — take the first non-empty host as the window's label.
|
|
355
485
|
const host = win.sessions.find((s) => s.host)?.host ?? 'terminal';
|
|
356
486
|
const winHeader = `${chalk.gray(host)} ${chalk.gray('·')} ${chalk.gray(shortWindowLabel(win.windowId))} ${chalk.gray(`(${win.sessions.length})`)}`;
|
|
357
|
-
console.log(' ' + winHeader);
|
|
487
|
+
console.log(base + ' ' + winHeader);
|
|
358
488
|
for (const s of win.sessions)
|
|
359
|
-
printActiveRow(s, ' ');
|
|
489
|
+
printActiveRow(s, base + ' ');
|
|
360
490
|
}
|
|
361
491
|
for (const s of ws.flat)
|
|
362
|
-
printActiveRow(s, ' ');
|
|
492
|
+
printActiveRow(s, base + ' ');
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
/** Machine header: `▸ <name> ← this machine` for the local box (cyan), matching
|
|
496
|
+
* the `ag devices list` treatment; a plain `▸ <name>` for remotes. */
|
|
497
|
+
function printMachineHeader(mg) {
|
|
498
|
+
const marker = mg.isLocal ? chalk.cyan('▸ ') : chalk.gray('▸ ');
|
|
499
|
+
const name = mg.isLocal ? chalk.bold.cyan(mg.machine) : chalk.bold(mg.machine);
|
|
500
|
+
const here = mg.isLocal ? chalk.cyan(' ← this machine') : '';
|
|
501
|
+
console.log(`${marker}${name} ${chalk.gray(`(${mg.total})`)}${here}`);
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Render the unified active-session view, grouped by machine. Local sessions
|
|
505
|
+
* come from `getActiveSessions()`; unless `--local`, sessions from other
|
|
506
|
+
* machines are folded in over SSH (explicit `--host` targets, else the
|
|
507
|
+
* registered online devices from `ag devices`). A tip is shown when there are
|
|
508
|
+
* no other machines to include.
|
|
509
|
+
*/
|
|
510
|
+
async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
|
|
511
|
+
const self = machineId();
|
|
512
|
+
const local = await getActiveSessions();
|
|
513
|
+
for (const s of local)
|
|
514
|
+
if (!s.machine)
|
|
515
|
+
s.machine = self;
|
|
516
|
+
let remoteDeviceCount = 0;
|
|
517
|
+
let merged = local;
|
|
518
|
+
if (!opts.local) {
|
|
519
|
+
const remote = await gatherRemoteActive(opts.hosts);
|
|
520
|
+
remoteDeviceCount = remote.deviceCount;
|
|
521
|
+
merged = dedupeByMachineSession([...local, ...remote.sessions]);
|
|
522
|
+
}
|
|
523
|
+
// --waiting: only sessions blocked on the user. Exits non-zero when any are
|
|
524
|
+
// present so a supervising agent or hook can poll it as a gate.
|
|
525
|
+
const sessions = waitingOnly ? merged.filter(s => s.status === 'input_required') : merged;
|
|
526
|
+
if (asJson) {
|
|
527
|
+
process.stdout.write(JSON.stringify(sessions, null, 2) + '\n');
|
|
528
|
+
if (waitingOnly && sessions.length > 0)
|
|
529
|
+
process.exitCode = 1;
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
if (sessions.length === 0) {
|
|
533
|
+
console.log(chalk.gray(waitingOnly ? 'No sessions waiting on input.' : 'No active agent sessions.'));
|
|
534
|
+
if (!opts.local && !opts.hosts?.length && remoteDeviceCount === 0)
|
|
535
|
+
printCrossMachineTip();
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
const grouped = groupSessionsByMachine(sessions, self);
|
|
539
|
+
let firstMachine = true;
|
|
540
|
+
for (const mg of grouped.machines) {
|
|
541
|
+
if (!firstMachine)
|
|
542
|
+
console.log();
|
|
543
|
+
firstMachine = false;
|
|
544
|
+
printMachineHeader(mg);
|
|
545
|
+
renderWorkspaceLayout(mg.layout, ' ');
|
|
363
546
|
}
|
|
364
547
|
const runningCount = sessions.filter(s => s.status === 'running').length;
|
|
365
548
|
const idleCount = sessions.filter(s => s.status === 'idle').length;
|
|
@@ -371,14 +554,25 @@ async function renderActiveSessions(asJson, waitingOnly = false) {
|
|
|
371
554
|
parts.push(`${idleCount} idle`);
|
|
372
555
|
if (queuedCount > 0)
|
|
373
556
|
parts.push(`${queuedCount} queued`);
|
|
374
|
-
|
|
557
|
+
const machineWord = grouped.machines.length === 1 ? 'machine' : 'machines';
|
|
558
|
+
console.log(chalk.gray(`\n${sessions.length} active (${parts.join(', ')}) across ${grouped.machines.length} ${machineWord}.`));
|
|
559
|
+
// Tip only when nothing else could be included and the user didn't opt out.
|
|
560
|
+
if (!opts.local && !opts.hosts?.length && remoteDeviceCount === 0)
|
|
561
|
+
printCrossMachineTip();
|
|
375
562
|
// Scriptable gate: a non-zero exit when anything is waiting on the user.
|
|
376
563
|
if (waitingOnly && sessions.length > 0)
|
|
377
564
|
process.exitCode = 1;
|
|
378
565
|
}
|
|
566
|
+
/** Nudge shown when `--active` has no other machines to fold in. */
|
|
567
|
+
function printCrossMachineTip() {
|
|
568
|
+
console.log(chalk.gray("\nTip: include sessions from your other machines — register them with 'ag devices sync', then rerun. Use --local to skip."));
|
|
569
|
+
}
|
|
379
570
|
/** Main action handler for `agents sessions`. Routes to picker, table, or single-session render. */
|
|
380
571
|
async function sessionsAction(query, options) {
|
|
381
|
-
|
|
572
|
+
// --host WITHOUT --active keeps the legacy per-host stream (each remote's raw
|
|
573
|
+
// stdout under a `── host ──` banner). With --active, the hosts are folded
|
|
574
|
+
// into the merged machine-grouped view instead (handled below).
|
|
575
|
+
if (options.host && options.host.length > 0 && !options.active) {
|
|
382
576
|
try {
|
|
383
577
|
runRemoteSessions(options.host);
|
|
384
578
|
}
|
|
@@ -389,7 +583,13 @@ async function sessionsAction(query, options) {
|
|
|
389
583
|
return;
|
|
390
584
|
}
|
|
391
585
|
if (options.active) {
|
|
392
|
-
|
|
586
|
+
// AGENTS_SESSIONS_LOCAL is set by a parent fan-out invocation (see
|
|
587
|
+
// remote-active.ts) so a peer answers for itself without recursing.
|
|
588
|
+
const forceLocal = options.local === true || process.env.AGENTS_SESSIONS_LOCAL === '1';
|
|
589
|
+
await renderActiveSessions(options.json === true, options.waiting === true, {
|
|
590
|
+
local: forceLocal,
|
|
591
|
+
hosts: options.host,
|
|
592
|
+
});
|
|
393
593
|
return;
|
|
394
594
|
}
|
|
395
595
|
if (options.cloud) {
|
|
@@ -442,8 +642,18 @@ async function sessionsAction(query, options) {
|
|
|
442
642
|
// Interactive picker loads a deep pool but shows only recent sessions
|
|
443
643
|
// until the user starts typing. Non-interactive/JSON uses the explicit limit.
|
|
444
644
|
const isInteractive = !options.json && isInteractiveTerminal();
|
|
445
|
-
|
|
446
|
-
|
|
645
|
+
// The grouped project overview is the default for a bare interactive listing:
|
|
646
|
+
// no query, no path drill-in, not explicitly --flat/--tree. It drops the silent
|
|
647
|
+
// cwd-scope + 50-cap + 30-day window that hide most of a large index.
|
|
648
|
+
const wantsOverview = isInteractive && !searchQuery && !pathFilter && !options.flat && !options.tree;
|
|
649
|
+
const limit = wantsOverview
|
|
650
|
+
? OVERVIEW_POOL_LIMIT
|
|
651
|
+
: parseInt(options.limit || (isInteractive ? String(PICKER_POOL_LIMIT) : '50'), 10);
|
|
652
|
+
// Overview: recency order across the whole index, no default window; an explicit
|
|
653
|
+
// --since still narrows. Non-overview keeps the prior interactive-30d default.
|
|
654
|
+
const since = wantsOverview
|
|
655
|
+
? options.since
|
|
656
|
+
: (options.since ?? (isInteractive && !options.all ? '30d' : undefined));
|
|
447
657
|
const spinner = options.json ? null : ora().start();
|
|
448
658
|
const tracker = createScanProgressTracker(LOAD_VERBS, 'sessions', spinner);
|
|
449
659
|
try {
|
|
@@ -456,7 +666,7 @@ async function sessionsAction(query, options) {
|
|
|
456
666
|
const scope = {
|
|
457
667
|
agent,
|
|
458
668
|
version,
|
|
459
|
-
all: pathFilter ? undefined : options.all,
|
|
669
|
+
all: pathFilter ? undefined : (wantsOverview ? true : options.all),
|
|
460
670
|
cwd: process.cwd(),
|
|
461
671
|
cwdPrefix: pathFilter,
|
|
462
672
|
project: options.project,
|
|
@@ -500,12 +710,42 @@ async function sessionsAction(query, options) {
|
|
|
500
710
|
if (options.json) {
|
|
501
711
|
const filtered = searchQuery ? filterSessionsByQuery(sessions, searchQuery) : sessions;
|
|
502
712
|
const serializable = filtered.map(s => {
|
|
503
|
-
const { _matchedTerms, _bm25Score, ...rest } = s;
|
|
713
|
+
const { _matchedTerms, _bm25Score, _remote, ...rest } = s;
|
|
504
714
|
return rest;
|
|
505
715
|
});
|
|
506
716
|
process.stdout.write(JSON.stringify(serializable, null, 2) + '\n');
|
|
507
717
|
return;
|
|
508
718
|
}
|
|
719
|
+
// Cross-machine fan-out: unless --local (or we ARE a peer answering a
|
|
720
|
+
// parent's sweep), fold in other online machines' sessions live over SSH so
|
|
721
|
+
// the list spans the fleet without any sync — each remote row carries the
|
|
722
|
+
// machine it came from, and the picker/table label + group by it. Only the
|
|
723
|
+
// interactive picker and the printed table get this; --json and single-id
|
|
724
|
+
// resolution above stay local (a peer answers for itself; scripts get a
|
|
725
|
+
// deterministic local slice). Best-effort: a fan-out failure leaves the
|
|
726
|
+
// local list intact rather than erroring the whole command.
|
|
727
|
+
const forceLocal = options.local === true || process.env[NO_FANOUT_ENV] === '1';
|
|
728
|
+
if (!forceLocal) {
|
|
729
|
+
// Pass the hosts set so a variadic `--host a b` never leaks a host as a
|
|
730
|
+
// query (defensive: the --host-without-active early return above already
|
|
731
|
+
// means we only get here in auto-discovery mode, with no --host in argv).
|
|
732
|
+
const forwarded = buildForwardedArgs(process.argv, new Set(options.host ?? []));
|
|
733
|
+
if (!forwarded.includes('--json'))
|
|
734
|
+
forwarded.push('--json');
|
|
735
|
+
const fanSpinner = isInteractiveTerminal() ? ora('Reaching other machines...').start() : null;
|
|
736
|
+
try {
|
|
737
|
+
const { sessions: remoteSessions } = await gatherRemoteList(forwarded, options.host);
|
|
738
|
+
if (remoteSessions.length > 0) {
|
|
739
|
+
sessions = mergeLocalFirst([...sessions, ...remoteSessions], machineId());
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
catch {
|
|
743
|
+
// fan-out is an enrichment, never a hard dependency
|
|
744
|
+
}
|
|
745
|
+
finally {
|
|
746
|
+
fanSpinner?.stop();
|
|
747
|
+
}
|
|
748
|
+
}
|
|
509
749
|
if (sessions.length === 0) {
|
|
510
750
|
if (pathFilter) {
|
|
511
751
|
console.log(chalk.gray(`No sessions found for ${pathFilter}.`));
|
|
@@ -518,9 +758,19 @@ async function sessionsAction(query, options) {
|
|
|
518
758
|
}
|
|
519
759
|
return;
|
|
520
760
|
}
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
|
|
761
|
+
// The grouped project overview is the bare interactive default: a scannable
|
|
762
|
+
// dashboard of the whole fleet grouped by project, newest-active first.
|
|
763
|
+
// Interact/resume via `agents sessions <project>` or `agents sessions resume`.
|
|
764
|
+
if (wantsOverview) {
|
|
765
|
+
const liveIndex = await maybeLiveIndex(options);
|
|
766
|
+
// Per-project row cap is fixed (--limit carries a default of 50 and drives
|
|
767
|
+
// the fetch pool, not the display); `--all` expands every group instead.
|
|
768
|
+
printSessionOverview(sessions, hiddenCount, liveIndex, { perProjectCap: OVERVIEW_ROWS_PER_PROJECT, expand: !!options.all });
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
// --tree / --flat are printed listings, not an interactive pick — render them
|
|
772
|
+
// directly even in a TTY. A search query keeps the interactive picker.
|
|
773
|
+
if (isInteractiveTerminal() && !options.tree && !options.flat) {
|
|
524
774
|
const message = pathFilter
|
|
525
775
|
? `Search sessions (${path.basename(pathFilter)}):`
|
|
526
776
|
: formatSearchMessage(options);
|
|
@@ -531,9 +781,10 @@ async function sessionsAction(query, options) {
|
|
|
531
781
|
}
|
|
532
782
|
return;
|
|
533
783
|
}
|
|
534
|
-
// Non-interactive fallback (piped output)
|
|
784
|
+
// Non-interactive fallback (piped output) or --flat/--tree.
|
|
535
785
|
const filtered = searchQuery ? filterSessionsByQuery(sessions, searchQuery) : sessions;
|
|
536
|
-
|
|
786
|
+
const liveIndex = await maybeLiveIndex(options);
|
|
787
|
+
printSessionTable(filtered, hiddenCount, options.tree === true, liveIndex);
|
|
537
788
|
}
|
|
538
789
|
catch (err) {
|
|
539
790
|
tracker.stop();
|
|
@@ -560,45 +811,164 @@ function metaSignals(s) {
|
|
|
560
811
|
ticket: s.ticketId ? { id: s.ticketId } : undefined,
|
|
561
812
|
};
|
|
562
813
|
}
|
|
563
|
-
/** One flat table row:
|
|
564
|
-
|
|
814
|
+
/** One flat table row:
|
|
815
|
+
* shortId · agent · version · project · [glyph] label·doing · [ticket] · [wt] · time
|
|
816
|
+
* `doing` is the live preview when running, else the topic. The `ticket` column
|
|
817
|
+
* (tracker/PR ref, pulled out of the badge blob so refs align) is only rendered
|
|
818
|
+
* when `showTicket` — otherwise a listing with no refs would waste a column of
|
|
819
|
+
* dashes and needlessly truncate the topic. Worktree stays a trailing badge. */
|
|
820
|
+
function flatSessionRow(session, live, showTicket = false, cols = {}) {
|
|
565
821
|
const agentColor = colorAgent(session.agent);
|
|
566
822
|
const when = formatRelativeTime(session.timestamp);
|
|
567
823
|
const project = session.project || '-';
|
|
568
824
|
const tag = teamTag(session);
|
|
569
825
|
const label = session.label;
|
|
570
|
-
const
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
const
|
|
574
|
-
const
|
|
826
|
+
const { glyph, preview } = liveGlyphAndPreview(live);
|
|
827
|
+
// A running session's live preview says what the agent is doing now; a
|
|
828
|
+
// resting one falls back to its opening topic.
|
|
829
|
+
const doing = preview || (tag ? `${tag}${session.topic ?? ''}` : session.topic);
|
|
830
|
+
const wt = session.worktreeSlug ? chalk.magenta(`wt:${session.worktreeSlug}`) : '';
|
|
831
|
+
// The machine column only earns its width when the listing spans more than one
|
|
832
|
+
// box (i.e. the cross-machine fan-out folded remotes in) — same rule and
|
|
833
|
+
// pool-derived width as the picker.
|
|
834
|
+
const machineColW = cols.machineWidth ?? PICKER_MACHINE_W;
|
|
835
|
+
const machineCell = cols.showMachine
|
|
836
|
+
? chalk.gray(padToWidth(truncateToWidth((cols.machineLabel?.(session.machine ?? '') ?? session.machine ?? '') || '-', machineColW - 1), machineColW))
|
|
837
|
+
: '';
|
|
838
|
+
const TICKET_W = 10;
|
|
839
|
+
const ticketCell = showTicket
|
|
840
|
+
? chalk.blue(padToWidth(truncateToWidth(ticketLabel(session) || '-', TICKET_W), TICKET_W + 1))
|
|
841
|
+
: '';
|
|
842
|
+
const glyphW = glyph ? 2 : 0;
|
|
843
|
+
const machineW = cols.showMachine ? machineColW : 0;
|
|
844
|
+
const ticketW = showTicket ? TICKET_W + 1 : 0;
|
|
845
|
+
const wtW = wt ? stringWidth(wt) + 1 : 0;
|
|
846
|
+
const topicW = Math.max(16, terminalWidth() - (10 + 9 + 8 + 16) - glyphW - machineW - ticketW - wtW - stringWidth(when) - 1);
|
|
575
847
|
return (chalk.white(padToWidth(truncateToWidth(session.shortId, 9), 10)) +
|
|
576
848
|
agentColor(padToWidth(truncateToWidth(session.agent, 8), 9)) +
|
|
577
|
-
chalk.yellow(padToWidth(truncateToWidth(
|
|
849
|
+
chalk.yellow(padToWidth(truncateToWidth(session.version || '-', 7), 8)) +
|
|
850
|
+
machineCell +
|
|
578
851
|
chalk.cyan(padToWidth(truncateToWidth(project, 14), 16)) +
|
|
579
|
-
|
|
580
|
-
(
|
|
852
|
+
(glyph ? glyph + ' ' : '') +
|
|
853
|
+
renderTopicCell(label, doing, '', topicW, topicW) +
|
|
854
|
+
ticketCell +
|
|
855
|
+
(wt ? wt + ' ' : '') +
|
|
581
856
|
chalk.gray(when));
|
|
582
857
|
}
|
|
583
858
|
/** One tree-mode row (grouped under a dir header): id · agent · badges · topic · time. No version/project column. */
|
|
584
|
-
function treeSessionRow(session) {
|
|
859
|
+
function treeSessionRow(session, live) {
|
|
585
860
|
const agentColor = colorAgent(session.agent);
|
|
586
861
|
const when = formatRelativeTime(session.timestamp);
|
|
587
862
|
const tag = teamTag(session);
|
|
588
863
|
const label = session.label;
|
|
589
|
-
const
|
|
864
|
+
const { glyph, preview } = liveGlyphAndPreview(live);
|
|
865
|
+
const topic = (preview || (tag ? `${tag}${session.topic ?? ''}` : session.topic)) || '-';
|
|
590
866
|
const badges = signalBadges(metaSignals(session));
|
|
591
867
|
const badgeW = badges ? stringWidth(badges) + 1 : 0;
|
|
592
868
|
const head = label ? `${label} · ${topic}` : topic;
|
|
593
|
-
const
|
|
869
|
+
const glyphW = glyph ? 2 : 0;
|
|
870
|
+
const topicW = Math.max(12, terminalWidth() - (2 + 9 + 8) - glyphW - badgeW - stringWidth(when) - 1);
|
|
594
871
|
return (' ' +
|
|
595
872
|
chalk.dim(padToWidth(session.shortId, 9)) +
|
|
596
873
|
agentColor(padToWidth(truncateToWidth(session.agent, 7), 8)) +
|
|
597
874
|
(badges ? badges + ' ' : '') +
|
|
875
|
+
(glyph ? glyph + ' ' : '') +
|
|
598
876
|
padToWidth(chalk.white(truncateToWidth(head, topicW)), topicW) +
|
|
599
877
|
' ' + chalk.gray(when));
|
|
600
878
|
}
|
|
601
|
-
|
|
879
|
+
/**
|
|
880
|
+
* Live-session index for enriching the default listing, or undefined when
|
|
881
|
+
* enrichment is off (`--no-live`) or irrelevant (`--json`, which serializes
|
|
882
|
+
* SessionMeta). Full detection (incl. the headless `ps` scan) is deliberate:
|
|
883
|
+
* bare-CLI and tmux agents are the common case here, and skipping them would
|
|
884
|
+
* leave the glyph almost never showing. The listing is a one-shot user action,
|
|
885
|
+
* not a hot loop, so the `ps`/`lsof` cost is acceptable; `--no-live` is the
|
|
886
|
+
* escape hatch. Never throws — a probe failure just yields a plain listing.
|
|
887
|
+
*/
|
|
888
|
+
async function maybeLiveIndex(options) {
|
|
889
|
+
if (options.live === false || options.json)
|
|
890
|
+
return undefined;
|
|
891
|
+
try {
|
|
892
|
+
return indexActiveBySessionId(await getActiveSessions());
|
|
893
|
+
}
|
|
894
|
+
catch {
|
|
895
|
+
return undefined;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Group key for the overview: prefer the indexed project name; else fold the cwd
|
|
900
|
+
* to its repo — a worktree (`.../<repo>/.agents/worktrees/<slug>`) folds to the
|
|
901
|
+
* repo, and a monorepo subdir falls back to its leaf dir basename. Pure.
|
|
902
|
+
*/
|
|
903
|
+
export function overviewProjectKey(s) {
|
|
904
|
+
if (s.project && s.project.trim())
|
|
905
|
+
return s.project.trim();
|
|
906
|
+
const cwd = (s.cwd ?? '').replace(/\/+$/, '');
|
|
907
|
+
if (!cwd)
|
|
908
|
+
return '(no project)';
|
|
909
|
+
const wt = cwd.match(/\/([^/]+)\/\.agents\/worktrees\//);
|
|
910
|
+
if (wt)
|
|
911
|
+
return wt[1];
|
|
912
|
+
const parts = cwd.split('/');
|
|
913
|
+
return parts[parts.length - 1] || cwd;
|
|
914
|
+
}
|
|
915
|
+
/**
|
|
916
|
+
* Turn a recency-descending pool into project groups: each group shows its
|
|
917
|
+
* `perProjectCap` most-recent sessions (the rest become `· N more`), and groups
|
|
918
|
+
* are ordered by their most-recent session so the newest-active project leads.
|
|
919
|
+
* `perProjectCap = Infinity` expands every group. Pure — unit-tested.
|
|
920
|
+
*/
|
|
921
|
+
export function buildOverviewGroups(pool, perProjectCap) {
|
|
922
|
+
const byKey = new Map();
|
|
923
|
+
for (const s of pool) {
|
|
924
|
+
const k = overviewProjectKey(s);
|
|
925
|
+
(byKey.get(k) ?? byKey.set(k, []).get(k)).push(s);
|
|
926
|
+
}
|
|
927
|
+
const cap = Math.max(1, perProjectCap);
|
|
928
|
+
const groups = [];
|
|
929
|
+
for (const [key, rows] of byKey) {
|
|
930
|
+
const shown = rows.slice(0, cap); // rows are recency-desc (pool was sorted)
|
|
931
|
+
groups.push({ key, total: rows.length, shown, more: rows.length - shown.length, maxTs: rows[0].timestamp });
|
|
932
|
+
}
|
|
933
|
+
groups.sort((a, b) => (a.maxTs < b.maxTs ? 1 : a.maxTs > b.maxTs ? -1 : a.key.localeCompare(b.key)));
|
|
934
|
+
return { groups, projectCount: byKey.size };
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* The grouped project overview — the bare interactive default. Shows the latest
|
|
938
|
+
* sessions grouped under their project, newest-active project first, with a
|
|
939
|
+
* `· N more` per project and a `+N more projects` when the list is capped.
|
|
940
|
+
*/
|
|
941
|
+
function printSessionOverview(pool, hiddenCount, liveIndex, opts) {
|
|
942
|
+
const { groups } = buildOverviewGroups(pool, opts.expand ? Infinity : opts.perProjectCap);
|
|
943
|
+
const shownGroups = opts.expand ? groups : groups.slice(0, OVERVIEW_MAX_PROJECTS);
|
|
944
|
+
const hiddenProjects = groups.length - shownGroups.length;
|
|
945
|
+
const total = pool.length;
|
|
946
|
+
const projWord = groups.length === 1 ? 'project' : 'projects';
|
|
947
|
+
console.log(chalk.gray(`${total} session${total === 1 ? '' : 's'} · ${groups.length} ${projWord} · recent activity\n`));
|
|
948
|
+
let first = true;
|
|
949
|
+
for (const g of shownGroups) {
|
|
950
|
+
if (!first)
|
|
951
|
+
console.log();
|
|
952
|
+
first = false;
|
|
953
|
+
const { glyph } = liveGlyphAndPreview(liveIndex?.get(g.shown[0].id));
|
|
954
|
+
const head = `${chalk.cyan('▸')} ${chalk.cyan.bold(g.key)} ${chalk.gray(String(g.total))}` +
|
|
955
|
+
`${glyph ? ' ' + glyph : ''} ${chalk.gray(formatRelativeTime(g.maxTs))}`;
|
|
956
|
+
console.log(head);
|
|
957
|
+
for (const s of g.shown)
|
|
958
|
+
console.log(treeSessionRow(s, liveIndex?.get(s.id)));
|
|
959
|
+
if (g.more > 0)
|
|
960
|
+
console.log(' ' + chalk.gray(`· ${g.more} more`));
|
|
961
|
+
}
|
|
962
|
+
console.log();
|
|
963
|
+
const parts = [chalk.gray('newest first')];
|
|
964
|
+
if (hiddenProjects > 0)
|
|
965
|
+
parts.push(chalk.gray(`+${hiddenProjects} more project${hiddenProjects === 1 ? '' : 's'} · agents sessions --all`));
|
|
966
|
+
parts.push(chalk.gray('agents sessions <project> to drill in · --flat for the plain list'));
|
|
967
|
+
console.log(parts.join(chalk.gray(' · ')));
|
|
968
|
+
if (hiddenCount > 0)
|
|
969
|
+
console.log(chalk.gray(formatTeamHiddenFooter(hiddenCount)));
|
|
970
|
+
}
|
|
971
|
+
function printSessionTable(sessions, hiddenCount = 0, tree = false, liveIndex) {
|
|
602
972
|
if (tree) {
|
|
603
973
|
// Group by directory; drop the id/version columns from view. The short id
|
|
604
974
|
// stays as each row's leading handle (the address to read/resume it).
|
|
@@ -619,7 +989,7 @@ function printSessionTable(sessions, hiddenCount = 0, tree = false) {
|
|
|
619
989
|
const group = byDir.get(key);
|
|
620
990
|
console.log(`${chalk.cyan.bold(shortCwd(key))} ${chalk.gray(`(${group.length})`)}`);
|
|
621
991
|
for (const s of group)
|
|
622
|
-
console.log(treeSessionRow(s));
|
|
992
|
+
console.log(treeSessionRow(s, liveIndex?.get(s.id)));
|
|
623
993
|
}
|
|
624
994
|
const dirWord = keys.length === 1 ? 'directory' : 'directories';
|
|
625
995
|
console.log(chalk.gray(`\n${sessions.length} session${sessions.length === 1 ? '' : 's'} across ${keys.length} ${dirWord}.`));
|
|
@@ -627,8 +997,13 @@ function printSessionTable(sessions, hiddenCount = 0, tree = false) {
|
|
|
627
997
|
console.log(chalk.gray(formatTeamHiddenFooter(hiddenCount)));
|
|
628
998
|
return;
|
|
629
999
|
}
|
|
1000
|
+
// Only show the ticket column when at least one row carries a ref — otherwise
|
|
1001
|
+
// it's a column of dashes that steals width from every topic. The machine
|
|
1002
|
+
// column (and its compact labels) is computed the same way the picker does it.
|
|
1003
|
+
const showTicket = sessions.some((s) => ticketLabel(s) !== '');
|
|
1004
|
+
const cols = pickerColumnsFor(sessions);
|
|
630
1005
|
for (const session of sessions)
|
|
631
|
-
console.log(flatSessionRow(session));
|
|
1006
|
+
console.log(flatSessionRow(session, liveIndex?.get(session.id), showTicket, cols));
|
|
632
1007
|
const countLine = `${sessions.length} session${sessions.length === 1 ? '' : 's'}.`;
|
|
633
1008
|
console.log(chalk.gray(`\n${countLine}`));
|
|
634
1009
|
if (hiddenCount > 0) {
|
|
@@ -677,6 +1052,14 @@ function resolveViewMode(options, filters) {
|
|
|
677
1052
|
return 'markdown';
|
|
678
1053
|
return 'summary';
|
|
679
1054
|
}
|
|
1055
|
+
/**
|
|
1056
|
+
* Render a session's full transcript to stdout — the non-follow view behind
|
|
1057
|
+
* `agents logs <sessionId>`. Reuses the same markdown renderer as
|
|
1058
|
+
* `agents sessions <id> --markdown`.
|
|
1059
|
+
*/
|
|
1060
|
+
export async function renderSessionLog(session) {
|
|
1061
|
+
await renderSession(session, 'markdown', {});
|
|
1062
|
+
}
|
|
680
1063
|
async function renderSession(session, mode, filters, options = {}) {
|
|
681
1064
|
// OpenCode stores sessions in SQLite; filePath is "db_path#session_id"
|
|
682
1065
|
const realPath = session.filePath.split('#')[0];
|
|
@@ -777,7 +1160,58 @@ function renderTopicCell(label, topic, query, visibleWidth, paddedWidth) {
|
|
|
777
1160
|
}
|
|
778
1161
|
return out + padding;
|
|
779
1162
|
}
|
|
780
|
-
|
|
1163
|
+
/** Fallback machine-column width when a pool-derived width isn't supplied.
|
|
1164
|
+
* `pickerColumnsFor` normally computes `machineWidth` sized to the actual
|
|
1165
|
+
* hostnames, floored/capped by these bounds so common ids like `yosemite-s0`
|
|
1166
|
+
* (11) fit whole while a pathological hostname can't devour the topic column. */
|
|
1167
|
+
const PICKER_MACHINE_W = 11;
|
|
1168
|
+
const PICKER_MACHINE_MIN = 8;
|
|
1169
|
+
const PICKER_MACHINE_MAX = 18;
|
|
1170
|
+
/** Column width that shows every compacted hostname in `machines` whole (one
|
|
1171
|
+
* trailing space for separation), bounded by MIN/MAX. */
|
|
1172
|
+
function machineColumnWidth(machines, label) {
|
|
1173
|
+
const widest = machines.reduce((w, m) => Math.max(w, stringWidth(label(m))), 0);
|
|
1174
|
+
return Math.min(PICKER_MACHINE_MAX, Math.max(PICKER_MACHINE_MIN, widest + 1));
|
|
1175
|
+
}
|
|
1176
|
+
/**
|
|
1177
|
+
* Compact display form for machine ids: strip the longest shared dash-delimited
|
|
1178
|
+
* prefix so "yosemite-s0"/"yosemite-s1" read as "s0"/"s1" while unrelated ids
|
|
1179
|
+
* ("zion", "mac-mini") stay whole. Stripping a *common* prefix can't collide,
|
|
1180
|
+
* and at least one segment is always kept.
|
|
1181
|
+
*/
|
|
1182
|
+
export function machineLabeler(machines) {
|
|
1183
|
+
const uniq = [...new Set(machines.filter(Boolean))];
|
|
1184
|
+
if (uniq.length < 2)
|
|
1185
|
+
return (m) => m;
|
|
1186
|
+
const parts = uniq.map((m) => m.split('-'));
|
|
1187
|
+
const min = Math.min(...parts.map((p) => p.length));
|
|
1188
|
+
let shared = 0;
|
|
1189
|
+
while (shared < min - 1 && parts.every((p) => p[shared] === parts[0][shared]))
|
|
1190
|
+
shared++;
|
|
1191
|
+
if (shared === 0)
|
|
1192
|
+
return (m) => m;
|
|
1193
|
+
return (m) => {
|
|
1194
|
+
const p = m.split('-');
|
|
1195
|
+
return p.length > shared ? p.slice(shared).join('-') : m;
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
/**
|
|
1199
|
+
* Column flags for a picker, computed once over the whole pool so every row
|
|
1200
|
+
* aligns: the machine column only earns its width when the listing spans more
|
|
1201
|
+
* than one box, the ticket column only when some row carries a PR/ticket ref.
|
|
1202
|
+
*/
|
|
1203
|
+
export function pickerColumnsFor(sessions) {
|
|
1204
|
+
const machines = sessions.map((s) => s.machine).filter((m) => !!m);
|
|
1205
|
+
const distinct = [...new Set(machines)];
|
|
1206
|
+
const machineLabel = machineLabeler(machines);
|
|
1207
|
+
return {
|
|
1208
|
+
showMachine: distinct.length > 1,
|
|
1209
|
+
machineLabel,
|
|
1210
|
+
machineWidth: machineColumnWidth(distinct, machineLabel),
|
|
1211
|
+
showTicket: sessions.some((s) => ticketLabel(s) !== ''),
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
export function formatPickerLabel(s, query, cols = {}) {
|
|
781
1215
|
const agentColor = colorAgent(s.agent);
|
|
782
1216
|
const when = formatRelativeTime(s.timestamp);
|
|
783
1217
|
const project = s.project || '-';
|
|
@@ -785,20 +1219,56 @@ function formatPickerLabel(s, query) {
|
|
|
785
1219
|
const label = s.label;
|
|
786
1220
|
const topic = tag ? `${tag}${s.topic ?? ''}` : s.topic;
|
|
787
1221
|
const versionStr = s.version || '-';
|
|
1222
|
+
const wt = s.worktreeSlug ? chalk.magenta(`wt:${s.worktreeSlug}`) : '';
|
|
1223
|
+
const machineW = cols.machineWidth ?? PICKER_MACHINE_W;
|
|
1224
|
+
const machineCell = cols.showMachine
|
|
1225
|
+
? chalk.gray(padRight(truncate((cols.machineLabel?.(s.machine ?? '') ?? s.machine ?? '') || '-', machineW - 1), machineW))
|
|
1226
|
+
: '';
|
|
1227
|
+
const TICKET_W = 10;
|
|
1228
|
+
const ticketCell = cols.showTicket
|
|
1229
|
+
? chalk.blue(padRight(truncate(ticketLabel(s) || '-', TICKET_W), TICKET_W + 1))
|
|
1230
|
+
: '';
|
|
1231
|
+
// The picker prepends a gutter (cursor, plus a checkbox in multi-select mode);
|
|
1232
|
+
// reserve it, plus the conditional columns, so the topic shrinks to fit and
|
|
1233
|
+
// rows never wrap.
|
|
1234
|
+
const gutter = cols.gutter ?? 2;
|
|
1235
|
+
const machineColW = cols.showMachine ? machineW : 0;
|
|
1236
|
+
const ticketW = cols.showTicket ? TICKET_W + 1 : 0;
|
|
1237
|
+
const wtW = wt ? stringWidth(wt) + 1 : 0;
|
|
1238
|
+
const topicW = Math.max(16, terminalWidth() - gutter - (10 + 9 + 8 + 16) - machineColW - ticketW - wtW - stringWidth(when) - 1);
|
|
788
1239
|
return (chalk.white(padRight(s.shortId, 10)) +
|
|
789
1240
|
agentColor(padRight(truncate(s.agent, 8), 9)) +
|
|
790
1241
|
chalk.yellow(padRight(truncate(versionStr, 7), 8)) +
|
|
1242
|
+
machineCell +
|
|
791
1243
|
chalk.cyan(padRight(truncate(project, 14), 16)) +
|
|
792
|
-
renderTopicCell(label, topic, query,
|
|
1244
|
+
renderTopicCell(label, topic, query, topicW, topicW) +
|
|
1245
|
+
ticketCell +
|
|
1246
|
+
(wt ? wt + ' ' : '') +
|
|
793
1247
|
chalk.gray(when));
|
|
794
1248
|
}
|
|
1249
|
+
/** Hints rotated above the picker so the flags/features stay discoverable. */
|
|
1250
|
+
const PICKER_TIPS = [
|
|
1251
|
+
'Tip: narrow with -a/--agent (e.g. -a codex), or --project <name> for another folder.',
|
|
1252
|
+
"Tip: --all searches every directory; -H/--host <machine> folds in another box's sessions.",
|
|
1253
|
+
'Tip: just type to fuzzy-search prompts and responses; press space to preview a session.',
|
|
1254
|
+
'Tip: --since 2d / --until <date> bound the time window; pass a session id to open it directly.',
|
|
1255
|
+
];
|
|
1256
|
+
/**
|
|
1257
|
+
* Pick a hint to show above the picker. Deterministic (keys off the pool size)
|
|
1258
|
+
* so it stays fixed across the picker's re-renders within a single run.
|
|
1259
|
+
*/
|
|
1260
|
+
export function formatPickerTip(sessions) {
|
|
1261
|
+
return chalk.gray(PICKER_TIPS[sessions.length % PICKER_TIPS.length]);
|
|
1262
|
+
}
|
|
795
1263
|
export async function pickSessionInteractive(sessions, message = 'Search sessions:', initialSearch, hiddenCount = 0) {
|
|
796
1264
|
if (hiddenCount > 0) {
|
|
797
1265
|
console.log(chalk.gray(formatTeamHiddenFooter(hiddenCount)));
|
|
798
1266
|
}
|
|
1267
|
+
const cols = pickerColumnsFor(sessions);
|
|
799
1268
|
try {
|
|
800
1269
|
return await sessionPicker({
|
|
801
1270
|
message,
|
|
1271
|
+
subtitle: formatPickerTip(sessions),
|
|
802
1272
|
sessions,
|
|
803
1273
|
filter: (query) => {
|
|
804
1274
|
// No query: show the full pool (picker viewport still paginates via pageSize).
|
|
@@ -807,7 +1277,7 @@ export async function pickSessionInteractive(sessions, message = 'Search session
|
|
|
807
1277
|
return sessions;
|
|
808
1278
|
return filterSessionsByQuery(sessions, query);
|
|
809
1279
|
},
|
|
810
|
-
labelFor: (s, query) => formatPickerLabel(s, query),
|
|
1280
|
+
labelFor: (s, query) => formatPickerLabel(s, query, cols),
|
|
811
1281
|
pageSize: PICKER_RECENT_COUNT,
|
|
812
1282
|
initialSearch,
|
|
813
1283
|
});
|
|
@@ -818,41 +1288,108 @@ export async function pickSessionInteractive(sessions, message = 'Search session
|
|
|
818
1288
|
throw err;
|
|
819
1289
|
}
|
|
820
1290
|
}
|
|
1291
|
+
/**
|
|
1292
|
+
* The machine a picked session lives on when its transcript is on that peer's
|
|
1293
|
+
* disk (folded in over the live fan-out), else undefined. Keys off `_remote`,
|
|
1294
|
+
* NOT `machine !== local`: a synced mirror is machine-tagged too, but its file
|
|
1295
|
+
* is a local mirror path, so it must be read/resumed locally like any other.
|
|
1296
|
+
*/
|
|
1297
|
+
function remoteMachineOf(session) {
|
|
1298
|
+
return session._remote ? session.machine : undefined;
|
|
1299
|
+
}
|
|
1300
|
+
/** True when the peer wasn't a dialable device; prints one clear line so a
|
|
1301
|
+
* remote pick never dead-ends silently. */
|
|
1302
|
+
function warnNoPeerTarget(machine, session) {
|
|
1303
|
+
console.log(chalk.yellow(`Session ${session.shortId} lives on ${machine}, which isn't a reachable device right now.`));
|
|
1304
|
+
console.log(chalk.gray(`Register/wake it (ag devices), or run there: agents ssh ${machine}`));
|
|
1305
|
+
}
|
|
821
1306
|
async function handlePickedSession(picked) {
|
|
1307
|
+
// A session on another machine is read/resumed ON that machine over SSH — its
|
|
1308
|
+
// transcript and agent binary live there. Both actions execute on the peer
|
|
1309
|
+
// (not a local `--host` hop, which would discover locally and dead-end for a
|
|
1310
|
+
// session that exists only on the peer).
|
|
1311
|
+
const remote = remoteMachineOf(picked.session);
|
|
1312
|
+
if (remote) {
|
|
1313
|
+
if (picked.action === 'view') {
|
|
1314
|
+
const rc = await runOnPeer(['sessions', picked.session.shortId, '--markdown'], remote);
|
|
1315
|
+
if (rc === 'no-target')
|
|
1316
|
+
warnNoPeerTarget(remote, picked.session);
|
|
1317
|
+
}
|
|
1318
|
+
else {
|
|
1319
|
+
console.log(chalk.gray(`Resuming ${picked.session.shortId} on ${remote} over SSH...`));
|
|
1320
|
+
const rc = await runOnPeer(['sessions', 'resume', picked.session.shortId], remote, { tty: true });
|
|
1321
|
+
if (rc === 'no-target')
|
|
1322
|
+
warnNoPeerTarget(remote, picked.session);
|
|
1323
|
+
}
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
822
1326
|
if (picked.action === 'view') {
|
|
823
1327
|
await renderSession(picked.session, 'summary', {});
|
|
824
1328
|
return;
|
|
825
1329
|
}
|
|
826
|
-
|
|
827
|
-
|
|
1330
|
+
await resumeSessionInPlace(picked.session);
|
|
1331
|
+
}
|
|
1332
|
+
/**
|
|
1333
|
+
* Resume a session in the current terminal — a foreground takeover of this
|
|
1334
|
+
* process. Used by the single-select picker and by `sessions resume` when the
|
|
1335
|
+
* chosen destination is "in place" (unknown emulator / off-macOS, single pick).
|
|
1336
|
+
* Falls back to the current version via `/continue` when the version-pinned
|
|
1337
|
+
* binary is missing (ENOENT).
|
|
1338
|
+
*/
|
|
1339
|
+
export async function resumeSessionInPlace(session) {
|
|
1340
|
+
const cwd = session.cwd && fs.existsSync(session.cwd)
|
|
1341
|
+
? session.cwd
|
|
828
1342
|
: process.cwd();
|
|
829
|
-
const resume = buildResumeCommand(
|
|
1343
|
+
const resume = buildResumeCommand(session);
|
|
830
1344
|
if (!resume) {
|
|
831
|
-
console.log(chalk.yellow(`Resume is not supported for ${
|
|
832
|
-
await renderSession(
|
|
1345
|
+
console.log(chalk.yellow(`Resume is not supported for ${session.agent} sessions yet. Showing summary instead.`));
|
|
1346
|
+
await renderSession(session, 'summary', {});
|
|
833
1347
|
return;
|
|
834
1348
|
}
|
|
835
1349
|
console.log(chalk.gray(`Resuming: ${resume.join(' ')} (cwd: ${cwd})`));
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
1350
|
+
// Resolve the (possibly version-pinned) launcher up front. On Windows the
|
|
1351
|
+
// agent shim is a `.cmd`/`.ps1` and, under the shell needed to run it (see
|
|
1352
|
+
// spawnResumeCommand), a missing command exits non-zero rather than emitting
|
|
1353
|
+
// an ENOENT `error` event — so detect a removed version here instead of
|
|
1354
|
+
// relying on that event, keeping the /continue fallback working on every OS.
|
|
1355
|
+
if (!findExecutable(resume[0]) && session.version) {
|
|
1356
|
+
const fallback = buildFallbackCommand(session);
|
|
1357
|
+
if (fallback) {
|
|
1358
|
+
console.log(chalk.gray(`Version ${session.version} is not installed. Falling back to current version via /continue...`));
|
|
1359
|
+
await spawnResumeCommand(fallback, cwd);
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
await spawnResumeCommand(resume, cwd);
|
|
1364
|
+
}
|
|
1365
|
+
/**
|
|
1366
|
+
* Spawn a resume command as a foreground takeover (inherited stdio), resolving
|
|
1367
|
+
* when it exits. On Windows the agent launcher is a `.cmd`/PATHEXT shim that
|
|
1368
|
+
* `spawn` can't exec directly — a bare-name `shell:false` spawn throws
|
|
1369
|
+
* `EFTYPE`/`ENOENT` there — so we go through the shell via `needsWindowsShell`.
|
|
1370
|
+
* The spawn is guarded because such a failure can be thrown synchronously;
|
|
1371
|
+
* without the guard it would surface under an unrelated "Failed to discover
|
|
1372
|
+
* sessions" catch upstream instead of a truthful launch error.
|
|
1373
|
+
*/
|
|
1374
|
+
function spawnResumeCommand(cmd, cwd) {
|
|
1375
|
+
return new Promise((resolve) => {
|
|
1376
|
+
let child;
|
|
1377
|
+
try {
|
|
1378
|
+
child = spawn(cmd[0], cmd.slice(1), {
|
|
1379
|
+
cwd,
|
|
1380
|
+
stdio: 'inherit',
|
|
1381
|
+
shell: needsWindowsShell(cmd[0]),
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1384
|
+
catch (err) {
|
|
1385
|
+
console.error(chalk.red(`Failed to launch ${cmd[0]}: ${err.message}`));
|
|
1386
|
+
resolve();
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
842
1389
|
child.on('error', (err) => {
|
|
843
|
-
|
|
844
|
-
const fallback = buildFallbackCommand(picked.session);
|
|
845
|
-
if (fallback) {
|
|
846
|
-
console.log(chalk.gray(`Version ${picked.session.version} is not installed. Falling back to current version via /continue...`));
|
|
847
|
-
const fb = spawn(fallback[0], fallback.slice(1), { cwd, stdio: 'inherit', shell: false });
|
|
848
|
-
fb.on('error', (e) => { console.error(chalk.red(`Failed: ${e.message}`)); resolve(); });
|
|
849
|
-
fb.on('close', () => resolve());
|
|
850
|
-
return;
|
|
851
|
-
}
|
|
852
|
-
}
|
|
853
|
-
console.error(chalk.red(`Failed to launch ${resume[0]}: ${err.message}`));
|
|
1390
|
+
console.error(chalk.red(`Failed to launch ${cmd[0]}: ${err.message}`));
|
|
854
1391
|
if (err.code === 'ENOENT') {
|
|
855
|
-
console.error(chalk.gray(`Make sure '${
|
|
1392
|
+
console.error(chalk.gray(`Make sure '${cmd[0]}' is on your PATH.`));
|
|
856
1393
|
}
|
|
857
1394
|
resolve();
|
|
858
1395
|
});
|
|
@@ -883,6 +1420,7 @@ export function buildResumeCommand(session) {
|
|
|
883
1420
|
case 'opencode':
|
|
884
1421
|
return ['opencode', '--session', session.id];
|
|
885
1422
|
case 'gemini':
|
|
1423
|
+
case 'antigravity':
|
|
886
1424
|
case 'openclaw':
|
|
887
1425
|
case 'rush':
|
|
888
1426
|
case 'hermes':
|
|
@@ -971,7 +1509,7 @@ async function runCloudSessions(query, options) {
|
|
|
971
1509
|
// Ensure the SessionMeta points at the local cache path for renderSession.
|
|
972
1510
|
await renderSession({ ...meta, filePath: cachedPath }, mode, filterOpts, options);
|
|
973
1511
|
}
|
|
974
|
-
function parseAgentFilter(agentName) {
|
|
1512
|
+
export function parseAgentFilter(agentName) {
|
|
975
1513
|
if (!agentName)
|
|
976
1514
|
return {};
|
|
977
1515
|
const [name, version] = agentName.split('@', 2);
|
|
@@ -1264,8 +1802,11 @@ export function registerSessionsCommands(program) {
|
|
|
1264
1802
|
.option('--artifacts', 'List all files written or edited during a session')
|
|
1265
1803
|
.option('--artifact <name>', 'Read a specific artifact by filename or path (outputs to stdout)')
|
|
1266
1804
|
.option('--active', 'Show only sessions running right now across terminals, teams, cloud, and headless agents')
|
|
1805
|
+
.option('--local', 'Only this machine — skip the cross-machine SSH fan-out (default listing and --active)')
|
|
1267
1806
|
.option('--waiting', 'With --active: show only sessions waiting on your input (exits non-zero if any)')
|
|
1268
1807
|
.option('--tree', 'Group the listing by directory; drops the id/version columns for readability')
|
|
1808
|
+
.option('--flat', 'Plain flat table (one row per session) instead of the grouped project overview')
|
|
1809
|
+
.option('--no-live', 'Do not enrich the listing with live status/preview for running sessions')
|
|
1269
1810
|
.option('--cloud', 'Source sessions from Rush Cloud (captured runs) instead of local disk')
|
|
1270
1811
|
.option('-H, --host <target...>', 'Run this query on remote machine(s) over SSH (host alias or user@host; repeatable)');
|
|
1271
1812
|
setHelpSections(sessionsCmd, {
|
|
@@ -1282,6 +1823,10 @@ export function registerSessionsCommands(program) {
|
|
|
1282
1823
|
# Show only what's running right now (terminals, teams, cloud, headless)
|
|
1283
1824
|
agents sessions --active
|
|
1284
1825
|
|
|
1826
|
+
# The interactive list folds in other online machines automatically,
|
|
1827
|
+
# labelled by host with this machine first. Stay local with --local:
|
|
1828
|
+
agents sessions --local
|
|
1829
|
+
|
|
1285
1830
|
# Search across every directory, not just this project
|
|
1286
1831
|
agents sessions "topic" --all
|
|
1287
1832
|
|
|
@@ -1295,6 +1840,7 @@ export function registerSessionsCommands(program) {
|
|
|
1295
1840
|
agents sessions --all "deploy script" --host box-a --host box-b
|
|
1296
1841
|
`,
|
|
1297
1842
|
notes: `
|
|
1843
|
+
- The interactive listing folds in your other online machines automatically (live over SSH, no sync) — each row is labelled by host, this machine first. Use --local to skip the fan-out; --json and single-id lookups stay local.
|
|
1298
1844
|
- --host runs the query on the remote's own index over SSH (host alias or user@host); repeat or pass several to fan out. SSH access is the only auth.
|
|
1299
1845
|
- --include and --exclude are mutually exclusive.
|
|
1300
1846
|
- --first and --last are mutually exclusive.
|
|
@@ -1308,6 +1854,8 @@ export function registerSessionsCommands(program) {
|
|
|
1308
1854
|
});
|
|
1309
1855
|
registerSessionsTailCommand(sessionsCmd);
|
|
1310
1856
|
registerSessionsSyncCommand(sessionsCmd);
|
|
1857
|
+
registerSessionsResumeCommand(sessionsCmd);
|
|
1858
|
+
registerSessionsInjectCommand(sessionsCmd);
|
|
1311
1859
|
}
|
|
1312
1860
|
function formatNoSessionsMessage(showAll, project) {
|
|
1313
1861
|
const projectQuery = project?.trim();
|