@phnx-labs/agents-cli 1.20.36 → 1.20.37
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/dist/commands/sessions-sync.d.ts +3 -0
- package/dist/commands/sessions-sync.js +44 -4
- package/dist/commands/sessions.d.ts +7 -0
- package/dist/commands/sessions.js +147 -35
- package/dist/lib/daemon.js +4 -2
- package/dist/lib/devices/resolve-target.d.ts +24 -0
- package/dist/lib/devices/resolve-target.js +80 -0
- package/dist/lib/session/active.d.ts +19 -0
- package/dist/lib/session/active.js +10 -4
- package/dist/lib/session/db.d.ts +2 -1
- package/dist/lib/session/db.js +41 -5
- package/dist/lib/session/discover.d.ts +2 -0
- package/dist/lib/session/discover.js +16 -1
- package/dist/lib/session/ghostty-tabs.d.ts +33 -0
- package/dist/lib/session/ghostty-tabs.js +126 -0
- package/dist/lib/session/relative-time.js +6 -2
- package/dist/lib/session/remote-active.js +4 -14
- package/dist/lib/session/remote-list.js +4 -12
- package/dist/lib/session/remote.js +4 -2
- package/dist/lib/session/sync/config.d.ts +13 -0
- package/dist/lib/session/sync/config.js +56 -0
- package/dist/lib/session/types.d.ts +6 -0
- package/dist/lib/sync-umbrella.js +4 -4
- package/dist/lib/tmux/session.d.ts +10 -0
- package/dist/lib/tmux/session.js +31 -0
- package/package.json +1 -1
|
@@ -7,6 +7,9 @@ import type { Command } from 'commander';
|
|
|
7
7
|
interface SyncCmdOptions {
|
|
8
8
|
verbose?: boolean;
|
|
9
9
|
json?: boolean;
|
|
10
|
+
enable?: boolean;
|
|
11
|
+
disable?: boolean;
|
|
12
|
+
status?: boolean;
|
|
10
13
|
}
|
|
11
14
|
export declare function runSessionsSync(options: SyncCmdOptions): Promise<void>;
|
|
12
15
|
export declare function registerSessionsSyncCommand(sessionsCmd: Command): void;
|
|
@@ -5,9 +5,34 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import chalk from 'chalk';
|
|
7
7
|
import { setHelpSections } from '../lib/help.js';
|
|
8
|
-
import { isSyncConfigured, SYNC_BUNDLE } from '../lib/session/sync/config.js';
|
|
8
|
+
import { isSyncConfigured, isSyncEnabled, setSyncEnabled, SYNC_BUNDLE, } from '../lib/session/sync/config.js';
|
|
9
9
|
import { syncSessions } from '../lib/session/sync/sync.js';
|
|
10
10
|
export async function runSessionsSync(options) {
|
|
11
|
+
// Toggle / status actions short-circuit before any network cycle.
|
|
12
|
+
if (options.disable) {
|
|
13
|
+
setSyncEnabled(false);
|
|
14
|
+
console.log(chalk.yellow('Automatic session sync disabled') +
|
|
15
|
+
chalk.dim(' — the daemon stops pushing/pulling within ~90s. Re-enable: agents sessions sync --enable'));
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (options.enable) {
|
|
19
|
+
setSyncEnabled(true);
|
|
20
|
+
console.log(chalk.green('Automatic session sync enabled') + chalk.dim(' — the daemon resumes on its next cycle.'));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (options.status) {
|
|
24
|
+
const enabled = isSyncEnabled();
|
|
25
|
+
const configured = isSyncConfigured();
|
|
26
|
+
if (options.json) {
|
|
27
|
+
console.log(JSON.stringify({ enabled, configured }, null, 2));
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
console.log(`automatic sync: ${enabled ? chalk.green('enabled') : chalk.yellow('disabled')}` +
|
|
31
|
+
chalk.dim(' · ') +
|
|
32
|
+
`credentials: ${configured ? chalk.green('configured') : chalk.yellow(`missing (${SYNC_BUNDLE})`)}`);
|
|
33
|
+
}
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
11
36
|
if (!isSyncConfigured()) {
|
|
12
37
|
console.error(chalk.red(`Sessions sync is not configured.`) +
|
|
13
38
|
`\nAdd R2 credentials to the '${SYNC_BUNDLE}' bundle:\n` +
|
|
@@ -51,7 +76,10 @@ export function registerSessionsSyncCommand(sessionsCmd) {
|
|
|
51
76
|
.command('sync')
|
|
52
77
|
.description('Sync session transcripts across machines via R2 (CRDT merge). Claude and Codex.')
|
|
53
78
|
.option('-v, --verbose', 'Log each pushed and pulled session')
|
|
54
|
-
.option('--json', 'Output the sync result as JSON')
|
|
79
|
+
.option('--json', 'Output the sync result as JSON')
|
|
80
|
+
.option('--enable', 'Turn ON automatic background sync on this machine (persisted)')
|
|
81
|
+
.option('--disable', 'Turn OFF automatic background sync on this machine (persisted)')
|
|
82
|
+
.option('--status', 'Show whether automatic sync is enabled and configured');
|
|
55
83
|
setHelpSections(syncCmd, {
|
|
56
84
|
examples: `
|
|
57
85
|
# One sync cycle (push local changes, pull + merge from other machines)
|
|
@@ -59,15 +87,27 @@ export function registerSessionsSyncCommand(sessionsCmd) {
|
|
|
59
87
|
|
|
60
88
|
# See exactly what moved
|
|
61
89
|
agents sessions sync --verbose
|
|
90
|
+
|
|
91
|
+
# Stop this machine's daemon from auto-syncing (prefer on-demand --host reads)
|
|
92
|
+
agents sessions sync --disable
|
|
93
|
+
|
|
94
|
+
# Check the current switch + credential state
|
|
95
|
+
agents sessions sync --status
|
|
62
96
|
`,
|
|
63
97
|
notes: `
|
|
64
98
|
- Credentials come from the '${SYNC_BUNDLE}' secrets bundle (R2 S3 API, read+write).
|
|
65
99
|
- Each machine writes only its own prefix; conflicts are impossible by construction.
|
|
66
100
|
- The daemon runs this automatically (~90s); this command forces an immediate cycle.
|
|
67
101
|
- Sessions present locally always win; synced-in copies fill in other machines' sessions.
|
|
102
|
+
- --disable/--enable persist a machine-local switch (~/.agents/.history) that gates the
|
|
103
|
+
daemon's automatic sync; a bare 'agents sessions sync' still forces a manual cycle.
|
|
104
|
+
The AGENTS_SESSIONS_SYNC env var (on/off) overrides the switch for one invocation.
|
|
68
105
|
`,
|
|
69
106
|
});
|
|
70
|
-
|
|
71
|
-
|
|
107
|
+
// `--json` is also declared on the parent `sessions` command, so a bare
|
|
108
|
+
// `options` arg would miss it (Commander binds the shared flag to the parent).
|
|
109
|
+
// optsWithGlobals() merges ancestor + local options so --json resolves here.
|
|
110
|
+
syncCmd.action(async (_options, cmd) => {
|
|
111
|
+
await runSessionsSync(cmd.optsWithGlobals());
|
|
72
112
|
});
|
|
73
113
|
}
|
|
@@ -2,6 +2,13 @@ import type { Command } from 'commander';
|
|
|
2
2
|
import type { SessionAgentId, SessionMeta } from '../lib/session/types.js';
|
|
3
3
|
import { type ActiveSession } from '../lib/session/active.js';
|
|
4
4
|
import { type PickedSession } from './sessions-picker.js';
|
|
5
|
+
/**
|
|
6
|
+
* Strip terminal/harness noise from a preview so the column stays a single line
|
|
7
|
+
* of plain prose: OSC title escapes, CSI/SGR ANSI, and the harness wrapper tags
|
|
8
|
+
* (`<local-command-stdout>`, `<task-notification>`, `<command-*>`) that leak from
|
|
9
|
+
* a captured transcript tail. Collapses runs of whitespace.
|
|
10
|
+
*/
|
|
11
|
+
export declare function cleanPreview(text: string): string;
|
|
5
12
|
/**
|
|
6
13
|
* Index live sessions by their full session UUID so a historical `SessionMeta`
|
|
7
14
|
* row (`meta.id`) can be matched to the session that is still running now.
|
|
@@ -17,6 +17,8 @@ import { SESSION_AGENTS } from '../lib/session/types.js';
|
|
|
17
17
|
import { discoverArtifacts, readArtifact, resolveArtifact } from '../lib/session/artifacts.js';
|
|
18
18
|
import { looksLikePath, toComparablePath, homeDir, needsWindowsShell, findExecutable } from '../lib/platform/index.js';
|
|
19
19
|
import { getActiveSessions } from '../lib/session/active.js';
|
|
20
|
+
import { enumerateGhosttyTabs, assignGhosttyTabs } from '../lib/session/ghostty-tabs.js';
|
|
21
|
+
import { mapPanesToTargets } from '../lib/tmux/session.js';
|
|
20
22
|
import { machineId, normalizeHost } from '../lib/session/sync/config.js';
|
|
21
23
|
import { gatherRemoteActive, NO_FANOUT_ENV } from '../lib/session/remote-active.js';
|
|
22
24
|
import { gatherRemoteList, runOnPeer } from '../lib/session/remote-list.js';
|
|
@@ -39,6 +41,24 @@ import { registerSessionsSyncCommand } from './sessions-sync.js';
|
|
|
39
41
|
import { registerSessionsResumeCommand } from './sessions-resume.js';
|
|
40
42
|
import { registerSessionsInjectCommand } from './sessions-inject.js';
|
|
41
43
|
const SESSION_AGENT_FILTER_HELP = `Filter by agent, e.g. claude, codex, claude@2.0.65`;
|
|
44
|
+
/**
|
|
45
|
+
* The prioritized harnesses that get a boolean shorthand flag (e.g. `--claude`
|
|
46
|
+
* === `--agent claude`). The rest stay reachable via `--agent <name>`, which
|
|
47
|
+
* also carries version pins like `codex@0.116.0`.
|
|
48
|
+
*/
|
|
49
|
+
const AGENT_SHORTHANDS = ['claude', 'codex', 'kimi', 'antigravity', 'grok', 'opencode'];
|
|
50
|
+
/**
|
|
51
|
+
* Resolve a per-agent shorthand (`--claude`, `--kimi`, …) into `options.agent`.
|
|
52
|
+
* An explicit `--agent` wins; if two shorthands are passed we take the first and
|
|
53
|
+
* ignore the rest (commander gives no ordering, so this is a best-effort alias).
|
|
54
|
+
*/
|
|
55
|
+
function applyAgentShorthands(options) {
|
|
56
|
+
if (options.agent)
|
|
57
|
+
return;
|
|
58
|
+
const hit = AGENT_SHORTHANDS.find((name) => options[name] === true);
|
|
59
|
+
if (hit)
|
|
60
|
+
options.agent = hit;
|
|
61
|
+
}
|
|
42
62
|
const CLAUDE_RESUME_MATCH_WINDOW_MS = 10 * 60_000;
|
|
43
63
|
const LOAD_VERBS = ['Loading', 'Scanning', 'Gathering', 'Indexing', 'Reading'];
|
|
44
64
|
const FIND_VERBS = ['Finding', 'Searching', 'Locating', 'Matching'];
|
|
@@ -174,13 +194,28 @@ function formatStartedAt(startedAtMs) {
|
|
|
174
194
|
return '-';
|
|
175
195
|
return formatRelativeTime(new Date(startedAtMs).toISOString());
|
|
176
196
|
}
|
|
197
|
+
/**
|
|
198
|
+
* Strip terminal/harness noise from a preview so the column stays a single line
|
|
199
|
+
* of plain prose: OSC title escapes, CSI/SGR ANSI, and the harness wrapper tags
|
|
200
|
+
* (`<local-command-stdout>`, `<task-notification>`, `<command-*>`) that leak from
|
|
201
|
+
* a captured transcript tail. Collapses runs of whitespace.
|
|
202
|
+
*/
|
|
203
|
+
export function cleanPreview(text) {
|
|
204
|
+
// eslint-disable-next-line no-control-regex
|
|
205
|
+
return text
|
|
206
|
+
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') // OSC (title) sequences
|
|
207
|
+
.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '') // CSI / SGR ANSI
|
|
208
|
+
.replace(/<\/?(?:local-command-stdout|command-name|command-message|command-args|task-notification|system-reminder)>/g, '')
|
|
209
|
+
.replace(/\s+/g, ' ')
|
|
210
|
+
.trim();
|
|
211
|
+
}
|
|
177
212
|
/**
|
|
178
213
|
* Build the live description for an active session: prefer the state engine's
|
|
179
214
|
* preview (the latest turn), then a user label, then the first-prompt topic.
|
|
180
215
|
*/
|
|
181
216
|
function buildSessionDescription(s) {
|
|
182
217
|
if (s.context === 'cloud') {
|
|
183
|
-
return s.preview || `${s.cloudProvider ?? ''}${s.cloudTaskId ? ` · ${s.cloudTaskId.slice(0, 12)}` : ''}
|
|
218
|
+
return cleanPreview(s.preview || `${s.cloudProvider ?? ''}${s.cloudTaskId ? ` · ${s.cloudTaskId.slice(0, 12)}` : ''}`);
|
|
184
219
|
}
|
|
185
220
|
if (s.context === 'teams') {
|
|
186
221
|
const parts = [s.teamName];
|
|
@@ -190,10 +225,10 @@ function buildSessionDescription(s) {
|
|
|
190
225
|
parts.push(s.label);
|
|
191
226
|
else if (s.topic)
|
|
192
227
|
parts.push(s.topic);
|
|
193
|
-
return parts.filter(Boolean).join(' · ');
|
|
228
|
+
return cleanPreview(parts.filter(Boolean).join(' · '));
|
|
194
229
|
}
|
|
195
230
|
// Terminal or headless: prefer the live preview, then label, then topic.
|
|
196
|
-
return s.preview || s.label || s.topic || '';
|
|
231
|
+
return cleanPreview(s.preview || s.label || s.topic || '');
|
|
197
232
|
}
|
|
198
233
|
/** Short human word for a session's activity (falls back to the coarse status). */
|
|
199
234
|
function activityLabel(s) {
|
|
@@ -264,20 +299,25 @@ function signalBadges(s) {
|
|
|
264
299
|
return parts.join(' ');
|
|
265
300
|
}
|
|
266
301
|
/**
|
|
267
|
-
* Compact
|
|
268
|
-
* `ssh` flags a remote host
|
|
269
|
-
*
|
|
302
|
+
* Compact locator badge: how to JUMP to the session, not what it's doing.
|
|
303
|
+
* `ssh` flags a remote host. For tmux, prefer the resolved `session:window.pane`
|
|
304
|
+
* (a real `tmux attach -t <session:window>` target) over the raw `%pane` id. For
|
|
305
|
+
* a local Ghostty session we know the tab, show `tab N`. Local, unlocatable
|
|
306
|
+
* sessions add nothing (the common case).
|
|
270
307
|
*/
|
|
271
|
-
function
|
|
272
|
-
|
|
273
|
-
return '';
|
|
308
|
+
function locatorBadge(s) {
|
|
309
|
+
const p = s.provenance;
|
|
274
310
|
const parts = [];
|
|
275
|
-
if (p
|
|
311
|
+
if (p?.transport === 'ssh')
|
|
276
312
|
parts.push(chalk.red('ssh'));
|
|
277
|
-
if (p
|
|
278
|
-
parts.push(chalk.green(
|
|
279
|
-
|
|
313
|
+
if (p?.mux?.kind === 'tmux' && (s.tmuxTarget || p.mux.pane)) {
|
|
314
|
+
parts.push(chalk.green(s.tmuxTarget ?? p.mux.pane));
|
|
315
|
+
}
|
|
316
|
+
else if (p?.mux?.kind === 'screen') {
|
|
280
317
|
parts.push(chalk.green('screen'));
|
|
318
|
+
}
|
|
319
|
+
if (s.ghosttyTab != null)
|
|
320
|
+
parts.push(chalk.green(`tab ${s.ghosttyTab}`));
|
|
281
321
|
return parts.join(' ');
|
|
282
322
|
}
|
|
283
323
|
/**
|
|
@@ -293,7 +333,7 @@ function printActiveRow(s, indent) {
|
|
|
293
333
|
const hostCol = chalk.gray(padToWidth(truncateToWidth(s.host ?? '-', 8), 9));
|
|
294
334
|
const statusCol = statusColor(s.status)(padToWidth(truncateToWidth(activityLabel(s), 8), 9));
|
|
295
335
|
const fork = s.pidCount && s.pidCount > 1 ? chalk.dim(`×${s.pidCount} `) : '';
|
|
296
|
-
const badges = (fork ? fork : '') + [signalBadges(s),
|
|
336
|
+
const badges = (fork ? fork : '') + [signalBadges(s), locatorBadge(s)].filter(Boolean).join(' ');
|
|
297
337
|
const desc = buildSessionDescription(s) || '-';
|
|
298
338
|
// Fill the remaining width with the preview so nothing wraps under tmux/SSH.
|
|
299
339
|
const fixed = stringWidth(indent) + 9 + 9 + 9 + 9 + (badges ? stringWidth(badges) + 1 : 0);
|
|
@@ -466,6 +506,27 @@ export function mergeLocalFirst(sessions, localMachine) {
|
|
|
466
506
|
});
|
|
467
507
|
return keys.flatMap((k) => byMachine.get(k));
|
|
468
508
|
}
|
|
509
|
+
/**
|
|
510
|
+
* `running N · idle N · waiting N · queued N` for a bucket of sessions (zero
|
|
511
|
+
* buckets omitted). Same bucketing as the grand-total summary so per-group
|
|
512
|
+
* counts reconcile with the `(total)` beside the header. Empty when nothing.
|
|
513
|
+
*/
|
|
514
|
+
function groupTally(sessions) {
|
|
515
|
+
const running = sessions.filter(s => s.status === 'running').length;
|
|
516
|
+
const idle = sessions.filter(s => s.status === 'idle').length;
|
|
517
|
+
const waiting = sessions.filter(s => s.status === 'input_required').length;
|
|
518
|
+
const queued = sessions.filter(s => s.status === 'queued').length;
|
|
519
|
+
const parts = [];
|
|
520
|
+
if (running)
|
|
521
|
+
parts.push(`${running} running`);
|
|
522
|
+
if (idle)
|
|
523
|
+
parts.push(`${idle} idle`);
|
|
524
|
+
if (waiting)
|
|
525
|
+
parts.push(`${waiting} waiting`);
|
|
526
|
+
if (queued)
|
|
527
|
+
parts.push(`${queued} queued`);
|
|
528
|
+
return parts.join(' · ');
|
|
529
|
+
}
|
|
469
530
|
/** Print one machine's workspace tree, indented under its machine header. */
|
|
470
531
|
function renderWorkspaceLayout(layout, base) {
|
|
471
532
|
let first = true;
|
|
@@ -478,7 +539,9 @@ function renderWorkspaceLayout(layout, base) {
|
|
|
478
539
|
: ws.key === '__unknown__'
|
|
479
540
|
? chalk.gray.bold('unknown')
|
|
480
541
|
: chalk.cyan.bold(shortCwd(ws.key));
|
|
481
|
-
|
|
542
|
+
const wsSessions = [...ws.windows.flatMap(w => w.sessions), ...ws.flat];
|
|
543
|
+
const tally = groupTally(wsSessions);
|
|
544
|
+
console.log(`${base}${header} ${chalk.gray(`(${ws.total})`)}${tally ? chalk.gray(` ${tally}`) : ''}`);
|
|
482
545
|
for (const win of ws.windows) {
|
|
483
546
|
// Host is per-process, but every terminal in the same IDE window shares
|
|
484
547
|
// an ancestor — take the first non-empty host as the window's label.
|
|
@@ -500,6 +563,43 @@ function printMachineHeader(mg) {
|
|
|
500
563
|
const here = mg.isLocal ? chalk.cyan(' ← this machine') : '';
|
|
501
564
|
console.log(`${marker}${name} ${chalk.gray(`(${mg.total})`)}${here}`);
|
|
502
565
|
}
|
|
566
|
+
/**
|
|
567
|
+
* Attach display-only jump locators onto LOCAL sessions: the Ghostty tab number
|
|
568
|
+
* (one batched read-only osascript, only when a local ghostty session exists)
|
|
569
|
+
* and the tmux `session:window.pane` target (one `list-panes -a` per socket).
|
|
570
|
+
* Every step is best-effort and swallowed — a failure just leaves the raw pane
|
|
571
|
+
* id / no tab number, and the rows render as before. Mutates the sessions.
|
|
572
|
+
*/
|
|
573
|
+
async function enrichLocalLocators(local) {
|
|
574
|
+
// Ghostty tab numbers.
|
|
575
|
+
try {
|
|
576
|
+
const ghostty = local.filter(s => s.host === 'ghostty' && s.provenance?.transport !== 'ssh');
|
|
577
|
+
if (ghostty.length > 0) {
|
|
578
|
+
const surfaces = await enumerateGhosttyTabs();
|
|
579
|
+
for (const [sess, tab] of assignGhosttyTabs(ghostty, surfaces))
|
|
580
|
+
sess.ghosttyTab = tab;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
catch { /* non-fatal */ }
|
|
584
|
+
// tmux attach targets, one batched query per distinct socket.
|
|
585
|
+
try {
|
|
586
|
+
const tmux = local.filter(s => s.provenance?.mux?.kind === 'tmux' && s.provenance.mux.pane);
|
|
587
|
+
const sockets = new Set(tmux.map(s => s.provenance.mux.socket));
|
|
588
|
+
for (const socket of sockets) {
|
|
589
|
+
const paneMap = await mapPanesToTargets(socket);
|
|
590
|
+
if (paneMap.size === 0)
|
|
591
|
+
continue;
|
|
592
|
+
for (const s of tmux) {
|
|
593
|
+
if (s.provenance.mux.socket !== socket)
|
|
594
|
+
continue;
|
|
595
|
+
const target = paneMap.get(s.provenance.mux.pane);
|
|
596
|
+
if (target)
|
|
597
|
+
s.tmuxTarget = target;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
catch { /* non-fatal */ }
|
|
602
|
+
}
|
|
503
603
|
/**
|
|
504
604
|
* Render the unified active-session view, grouped by machine. Local sessions
|
|
505
605
|
* come from `getActiveSessions()`; unless `--local`, sessions from other
|
|
@@ -535,6 +635,10 @@ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
|
|
|
535
635
|
printCrossMachineTip();
|
|
536
636
|
return;
|
|
537
637
|
}
|
|
638
|
+
// Enrich LOCAL sessions with jump locators (display-only, after the --json /
|
|
639
|
+
// --waiting gates so scriptable output stays osascript-free). Remote sessions
|
|
640
|
+
// keep their raw pane id — their tmux/Ghostty live on the other machine.
|
|
641
|
+
await enrichLocalLocators(sessions.filter(s => !s.machine || s.machine === self));
|
|
538
642
|
const grouped = groupSessionsByMachine(sessions, self);
|
|
539
643
|
let firstMachine = true;
|
|
540
644
|
for (const mg of grouped.machines) {
|
|
@@ -544,16 +648,7 @@ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
|
|
|
544
648
|
printMachineHeader(mg);
|
|
545
649
|
renderWorkspaceLayout(mg.layout, ' ');
|
|
546
650
|
}
|
|
547
|
-
const
|
|
548
|
-
const idleCount = sessions.filter(s => s.status === 'idle').length;
|
|
549
|
-
const queuedCount = sessions.filter(s => s.status === 'queued' || s.status === 'input_required').length;
|
|
550
|
-
const parts = [];
|
|
551
|
-
if (runningCount > 0)
|
|
552
|
-
parts.push(`${runningCount} running`);
|
|
553
|
-
if (idleCount > 0)
|
|
554
|
-
parts.push(`${idleCount} idle`);
|
|
555
|
-
if (queuedCount > 0)
|
|
556
|
-
parts.push(`${queuedCount} queued`);
|
|
651
|
+
const parts = groupTally(sessions).split(' · ').filter(Boolean);
|
|
557
652
|
const machineWord = grouped.machines.length === 1 ? 'machine' : 'machines';
|
|
558
653
|
console.log(chalk.gray(`\n${sessions.length} active (${parts.join(', ')}) across ${grouped.machines.length} ${machineWord}.`));
|
|
559
654
|
// Tip only when nothing else could be included and the user didn't opt out.
|
|
@@ -569,6 +664,13 @@ function printCrossMachineTip() {
|
|
|
569
664
|
}
|
|
570
665
|
/** Main action handler for `agents sessions`. Routes to picker, table, or single-session render. */
|
|
571
666
|
async function sessionsAction(query, options) {
|
|
667
|
+
// Normalize convenience flags before any routing reads them: per-agent
|
|
668
|
+
// shorthands fold into --agent, and --device is an alias for --host (both
|
|
669
|
+
// resolve against the same device registry).
|
|
670
|
+
applyAgentShorthands(options);
|
|
671
|
+
if (options.device && options.device.length > 0) {
|
|
672
|
+
options.host = [...(options.host ?? []), ...options.device];
|
|
673
|
+
}
|
|
572
674
|
// --host WITHOUT --active keeps the legacy per-host stream (each remote's raw
|
|
573
675
|
// stdout under a `── host ──` banner). With --active, the hosts are folded
|
|
574
676
|
// into the merged machine-grouped view instead (handled below).
|
|
@@ -666,9 +768,12 @@ async function sessionsAction(query, options) {
|
|
|
666
768
|
const scope = {
|
|
667
769
|
agent,
|
|
668
770
|
version,
|
|
669
|
-
all: pathFilter ? undefined :
|
|
771
|
+
all: pathFilter ? undefined : options.all,
|
|
670
772
|
cwd: process.cwd(),
|
|
671
|
-
|
|
773
|
+
// Default overview scopes to the current repo SUBTREE (prefix match), so a
|
|
774
|
+
// monorepo shows its sub-projects grouped instead of collapsing to the one
|
|
775
|
+
// exact-cwd project. `--all` clears the prefix and spans the whole index.
|
|
776
|
+
cwdPrefix: pathFilter ?? (wantsOverview && !options.all ? process.cwd() : undefined),
|
|
672
777
|
project: options.project,
|
|
673
778
|
since,
|
|
674
779
|
until: options.until,
|
|
@@ -819,7 +924,7 @@ function metaSignals(s) {
|
|
|
819
924
|
* dashes and needlessly truncate the topic. Worktree stays a trailing badge. */
|
|
820
925
|
function flatSessionRow(session, live, showTicket = false, cols = {}) {
|
|
821
926
|
const agentColor = colorAgent(session.agent);
|
|
822
|
-
const when = formatRelativeTime(session.timestamp);
|
|
927
|
+
const when = formatRelativeTime(session.lastActivity ?? session.timestamp);
|
|
823
928
|
const project = session.project || '-';
|
|
824
929
|
const tag = teamTag(session);
|
|
825
930
|
const label = session.label;
|
|
@@ -858,7 +963,7 @@ function flatSessionRow(session, live, showTicket = false, cols = {}) {
|
|
|
858
963
|
/** One tree-mode row (grouped under a dir header): id · agent · badges · topic · time. No version/project column. */
|
|
859
964
|
function treeSessionRow(session, live) {
|
|
860
965
|
const agentColor = colorAgent(session.agent);
|
|
861
|
-
const when = formatRelativeTime(session.timestamp);
|
|
966
|
+
const when = formatRelativeTime(session.lastActivity ?? session.timestamp);
|
|
862
967
|
const tag = teamTag(session);
|
|
863
968
|
const label = session.label;
|
|
864
969
|
const { glyph, preview } = liveGlyphAndPreview(live);
|
|
@@ -928,7 +1033,7 @@ export function buildOverviewGroups(pool, perProjectCap) {
|
|
|
928
1033
|
const groups = [];
|
|
929
1034
|
for (const [key, rows] of byKey) {
|
|
930
1035
|
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 });
|
|
1036
|
+
groups.push({ key, total: rows.length, shown, more: rows.length - shown.length, maxTs: rows[0].lastActivity ?? rows[0].timestamp });
|
|
932
1037
|
}
|
|
933
1038
|
groups.sort((a, b) => (a.maxTs < b.maxTs ? 1 : a.maxTs > b.maxTs ? -1 : a.key.localeCompare(b.key)));
|
|
934
1039
|
return { groups, projectCount: byKey.size };
|
|
@@ -960,10 +1065,10 @@ function printSessionOverview(pool, hiddenCount, liveIndex, opts) {
|
|
|
960
1065
|
console.log(' ' + chalk.gray(`· ${g.more} more`));
|
|
961
1066
|
}
|
|
962
1067
|
console.log();
|
|
963
|
-
const parts = [chalk.gray('newest first')];
|
|
1068
|
+
const parts = [chalk.gray('newest first (by last activity)')];
|
|
964
1069
|
if (hiddenProjects > 0)
|
|
965
|
-
parts.push(chalk.gray(`+${hiddenProjects} more project${hiddenProjects === 1 ? '' : 's'}
|
|
966
|
-
parts.push(chalk.gray('agents sessions <project> to drill in · --flat for the plain list'));
|
|
1070
|
+
parts.push(chalk.gray(`+${hiddenProjects} more project${hiddenProjects === 1 ? '' : 's'}`));
|
|
1071
|
+
parts.push(chalk.gray('agents sessions --all spans every project on disk · <project> to drill in · --flat for the plain list'));
|
|
967
1072
|
console.log(parts.join(chalk.gray(' · ')));
|
|
968
1073
|
if (hiddenCount > 0)
|
|
969
1074
|
console.log(chalk.gray(formatTeamHiddenFooter(hiddenCount)));
|
|
@@ -1213,7 +1318,7 @@ export function pickerColumnsFor(sessions) {
|
|
|
1213
1318
|
}
|
|
1214
1319
|
export function formatPickerLabel(s, query, cols = {}) {
|
|
1215
1320
|
const agentColor = colorAgent(s.agent);
|
|
1216
|
-
const when = formatRelativeTime(s.timestamp);
|
|
1321
|
+
const when = formatRelativeTime(s.lastActivity ?? s.timestamp);
|
|
1217
1322
|
const project = s.project || '-';
|
|
1218
1323
|
const tag = teamTag(s);
|
|
1219
1324
|
const label = s.label;
|
|
@@ -1785,6 +1890,12 @@ export function registerSessionsCommands(program) {
|
|
|
1785
1890
|
.argument('[query]', 'Session ID, search query, or path (., ../, /path) to filter by project')
|
|
1786
1891
|
.description('Find, browse, and read agent conversation transcripts across Claude, Codex, Gemini, and OpenCode.')
|
|
1787
1892
|
.option('-a, --agent <agent>', 'Filter by agent type and version (e.g., claude, codex@0.116.0)')
|
|
1893
|
+
.option('--claude', 'Shorthand for --agent claude')
|
|
1894
|
+
.option('--codex', 'Shorthand for --agent codex')
|
|
1895
|
+
.option('--kimi', 'Shorthand for --agent kimi')
|
|
1896
|
+
.option('--antigravity', 'Shorthand for --agent antigravity')
|
|
1897
|
+
.option('--grok', 'Shorthand for --agent grok')
|
|
1898
|
+
.option('--opencode', 'Shorthand for --agent opencode')
|
|
1788
1899
|
.option('--all', 'Include sessions from every directory (not just current project)')
|
|
1789
1900
|
.option('--teams', 'Include team-spawned sessions (hidden by default)')
|
|
1790
1901
|
.option('--project <name>', 'Filter by project name (searches across all directories)')
|
|
@@ -1808,7 +1919,8 @@ export function registerSessionsCommands(program) {
|
|
|
1808
1919
|
.option('--flat', 'Plain flat table (one row per session) instead of the grouped project overview')
|
|
1809
1920
|
.option('--no-live', 'Do not enrich the listing with live status/preview for running sessions')
|
|
1810
1921
|
.option('--cloud', 'Source sessions from Rush Cloud (captured runs) instead of local disk')
|
|
1811
|
-
.option('-H, --host <target...>', 'Run this query on remote machine(s) over SSH (host alias or user@host; repeatable)')
|
|
1922
|
+
.option('-H, --host <target...>', 'Run this query on remote machine(s) over SSH (host alias or user@host; repeatable)')
|
|
1923
|
+
.option('--device <target...>', 'Alias for --host (device alias from `agents devices`; repeatable)');
|
|
1812
1924
|
setHelpSections(sessionsCmd, {
|
|
1813
1925
|
examples: `
|
|
1814
1926
|
# Search prior sessions in this project by topic, file path, or command
|
package/dist/lib/daemon.js
CHANGED
|
@@ -343,8 +343,10 @@ export async function runDaemon() {
|
|
|
343
343
|
return;
|
|
344
344
|
syncing = true;
|
|
345
345
|
try {
|
|
346
|
-
const { isSyncConfigured } = await import('./session/sync/config.js');
|
|
347
|
-
|
|
346
|
+
const { isSyncConfigured, isSyncEnabled } = await import('./session/sync/config.js');
|
|
347
|
+
// isSyncEnabled() first: a machine the operator turned off must skip the
|
|
348
|
+
// keychain read entirely, not just the network cycle.
|
|
349
|
+
if (!isSyncEnabled() || !isSyncConfigured())
|
|
348
350
|
return;
|
|
349
351
|
const { syncSessions } = await import('./session/sync/sync.js');
|
|
350
352
|
const r = await syncSessions();
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type DeviceRegistry } from './registry.js';
|
|
2
|
+
/** A dialable peer: the ssh target, the machine id used to tag its rows, a
|
|
3
|
+
* display name, and the OS family that picks the remote shell dialect. */
|
|
4
|
+
export interface ResolvedSshTarget {
|
|
5
|
+
target: string;
|
|
6
|
+
machine: string;
|
|
7
|
+
name: string;
|
|
8
|
+
os?: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Resolve one `--host`/`--device` token to a concrete ssh target through the
|
|
12
|
+
* registry. Registry hit → the device's real address + platform (so the machine
|
|
13
|
+
* id, route, and OS all match the auto-discovery sweep). Miss → a literal
|
|
14
|
+
* `user@host` fallback, its OS taken from the host overlay if enrolled. Returns
|
|
15
|
+
* undefined only when the token fails the shared ssh-target injection guard.
|
|
16
|
+
*/
|
|
17
|
+
export declare function resolveSshTarget(token: string, reg: DeviceRegistry): ResolvedSshTarget | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* Resolve an explicit `--host`/`--device` list to dialable targets, reading the
|
|
20
|
+
* registry once. A token that fails the injection guard is skipped with a
|
|
21
|
+
* stderr note (never fatal — one bad token must not blank the fan-out). Shared
|
|
22
|
+
* by every cross-machine fan-out so they can never diverge onto two routes.
|
|
23
|
+
*/
|
|
24
|
+
export declare function resolveExplicitTargets(hosts: string[]): Promise<ResolvedSshTarget[]>;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place a `--host` / `--device` token becomes a real ssh target.
|
|
3
|
+
*
|
|
4
|
+
* A device and a host are the same thing addressed two ways, so resolution must
|
|
5
|
+
* go through the device registry — the single source of truth. A token that
|
|
6
|
+
* names a registered device dials that device's real address (its Tailscale
|
|
7
|
+
* dnsName/ip + user, via `sshTargetFor`), *identical* to the auto-discovery
|
|
8
|
+
* sweep and `agents ssh`. Before this module, the explicit `--host` fan-out
|
|
9
|
+
* instead passed the bare token straight to `ssh`, so `--host yosemite-s0`
|
|
10
|
+
* dialed whatever `~/.ssh/config`/LAN DNS resolved `yosemite-s0` to — a
|
|
11
|
+
* different route than the sweep's `yosemite-s0.<tailnet>.ts.net`. That
|
|
12
|
+
* divergence broke ControlMaster socket reuse (different target → different
|
|
13
|
+
* `%C` hash → a cold dial every time) and could read a perfectly reachable box
|
|
14
|
+
* as "unreachable" when only the non-Tailscale route was down.
|
|
15
|
+
*
|
|
16
|
+
* A raw `user@host` that matches no registered device falls back to a literal
|
|
17
|
+
* target so ad-hoc boxes still work.
|
|
18
|
+
*/
|
|
19
|
+
import chalk from 'chalk';
|
|
20
|
+
import { assertValidSshTarget } from '../ssh-exec.js';
|
|
21
|
+
import { normalizeHost } from '../machine-id.js';
|
|
22
|
+
import { resolveRemoteOsSync } from '../hosts/remote-os.js';
|
|
23
|
+
import { sshTargetFor } from './connect.js';
|
|
24
|
+
import { loadDevices } from './registry.js';
|
|
25
|
+
/**
|
|
26
|
+
* Resolve one `--host`/`--device` token to a concrete ssh target through the
|
|
27
|
+
* registry. Registry hit → the device's real address + platform (so the machine
|
|
28
|
+
* id, route, and OS all match the auto-discovery sweep). Miss → a literal
|
|
29
|
+
* `user@host` fallback, its OS taken from the host overlay if enrolled. Returns
|
|
30
|
+
* undefined only when the token fails the shared ssh-target injection guard.
|
|
31
|
+
*/
|
|
32
|
+
export function resolveSshTarget(token, reg) {
|
|
33
|
+
try {
|
|
34
|
+
assertValidSshTarget(token);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
const bare = token.split('@').pop() || token;
|
|
40
|
+
// An explicit `user@host` names an exact account/target — honour it literally.
|
|
41
|
+
// A bare alias (`yosemite-s0`) resolves through the registry to the device's
|
|
42
|
+
// real address, so it never diverges from the auto-discovery sweep.
|
|
43
|
+
const device = token.includes('@')
|
|
44
|
+
? undefined
|
|
45
|
+
: reg[token] ?? Object.values(reg).find((d) => normalizeHost(d.name) === normalizeHost(bare));
|
|
46
|
+
if (device) {
|
|
47
|
+
try {
|
|
48
|
+
return { target: sshTargetFor(device), machine: normalizeHost(device.name), name: device.name, os: device.platform };
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// Registered but has no address to dial — fall through to the literal token.
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { target: token, machine: normalizeHost(bare), name: token, os: resolveRemoteOsSync(token) };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Resolve an explicit `--host`/`--device` list to dialable targets, reading the
|
|
58
|
+
* registry once. A token that fails the injection guard is skipped with a
|
|
59
|
+
* stderr note (never fatal — one bad token must not blank the fan-out). Shared
|
|
60
|
+
* by every cross-machine fan-out so they can never diverge onto two routes.
|
|
61
|
+
*/
|
|
62
|
+
export async function resolveExplicitTargets(hosts) {
|
|
63
|
+
let reg;
|
|
64
|
+
try {
|
|
65
|
+
reg = await loadDevices();
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
reg = {};
|
|
69
|
+
}
|
|
70
|
+
const out = [];
|
|
71
|
+
for (const h of hosts) {
|
|
72
|
+
const resolved = resolveSshTarget(h, reg);
|
|
73
|
+
if (!resolved) {
|
|
74
|
+
process.stderr.write(chalk.gray(` ${h}: not a valid ssh target — skipped\n`));
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
out.push(resolved);
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
@@ -66,6 +66,24 @@ export interface ActiveSession {
|
|
|
66
66
|
* two windows have the same cwd open. Only populated for `terminal` context.
|
|
67
67
|
*/
|
|
68
68
|
windowId?: string;
|
|
69
|
+
/**
|
|
70
|
+
* Controlling TTY of the agent process (e.g. 'ttys003'), from the `ps -A`
|
|
71
|
+
* read. macOS/Linux terminal sessions only; '??'/none normalized to undefined.
|
|
72
|
+
* A disambiguation bridge (and the basis for future terminal addressing).
|
|
73
|
+
*/
|
|
74
|
+
tty?: string;
|
|
75
|
+
/**
|
|
76
|
+
* Ghostty tab index (1-based) the session is shown in, when it can be matched
|
|
77
|
+
* to a Ghostty surface by working directory (+ title). Transient, populated by
|
|
78
|
+
* the renderer just before printing — NOT part of the pure discovery path.
|
|
79
|
+
*/
|
|
80
|
+
ghosttyTab?: number;
|
|
81
|
+
/**
|
|
82
|
+
* Resolved tmux attach target (`session:window.pane`) for a tmux-hosted local
|
|
83
|
+
* session, from the pane id via `mapPanesToTargets`. Transient, renderer-set
|
|
84
|
+
* (after the --json/--waiting gates) — NOT emitted on the discovery path.
|
|
85
|
+
*/
|
|
86
|
+
tmuxTarget?: string;
|
|
69
87
|
}
|
|
70
88
|
export interface ActiveQueryOptions {
|
|
71
89
|
/** Skip the `ps` scan for ad-hoc headless agents. */
|
|
@@ -80,6 +98,7 @@ export declare function listCloudActive(): ActiveSession[];
|
|
|
80
98
|
interface ProcRow {
|
|
81
99
|
pid: number;
|
|
82
100
|
ppid: number;
|
|
101
|
+
tty?: string;
|
|
83
102
|
comm: string;
|
|
84
103
|
kind?: string;
|
|
85
104
|
}
|
|
@@ -336,6 +336,7 @@ export async function listTerminalsActive() {
|
|
|
336
336
|
context: 'terminal',
|
|
337
337
|
kind: t.kind,
|
|
338
338
|
host: detectHost(t.pid, procByPid),
|
|
339
|
+
tty: procByPid.get(t.pid)?.tty,
|
|
339
340
|
pid: t.pid,
|
|
340
341
|
sessionId: t.sessionId ?? sessionIdFromFile(sessionFile),
|
|
341
342
|
cwd: t.cwd ?? undefined,
|
|
@@ -406,22 +407,26 @@ async function readProcessTable() {
|
|
|
406
407
|
return readProcessTableWin32();
|
|
407
408
|
let out;
|
|
408
409
|
try {
|
|
409
|
-
({ stdout: out } = await execFileAsync('ps', ['-A', '-o', 'pid=,ppid=,comm='], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }));
|
|
410
|
+
({ stdout: out } = await execFileAsync('ps', ['-A', '-o', 'pid=,ppid=,tty=,comm='], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }));
|
|
410
411
|
}
|
|
411
412
|
catch {
|
|
412
413
|
return [];
|
|
413
414
|
}
|
|
414
415
|
const rows = [];
|
|
415
416
|
for (const line of out.split('\n')) {
|
|
416
|
-
|
|
417
|
+
// pid ppid tty comm — tty is a single token ('ttys003', 's003', or '??'/'?'
|
|
418
|
+
// for none); comm stays last so it may contain spaces.
|
|
419
|
+
const m = line.trim().match(/^(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/);
|
|
417
420
|
if (!m)
|
|
418
421
|
continue;
|
|
419
422
|
const pid = parseInt(m[1], 10);
|
|
420
423
|
const ppid = parseInt(m[2], 10);
|
|
421
424
|
if (!Number.isFinite(pid) || !Number.isFinite(ppid))
|
|
422
425
|
continue;
|
|
423
|
-
const
|
|
424
|
-
|
|
426
|
+
const ttyRaw = m[3];
|
|
427
|
+
const tty = ttyRaw === '??' || ttyRaw === '?' || ttyRaw === '-' ? undefined : ttyRaw;
|
|
428
|
+
const commRaw = m[4].trim();
|
|
429
|
+
rows.push({ pid, ppid, tty, comm: commRaw, kind: agentKindFromComm(commRaw) });
|
|
425
430
|
}
|
|
426
431
|
return rows;
|
|
427
432
|
}
|
|
@@ -684,6 +689,7 @@ export async function listUnattributedActive(attributed) {
|
|
|
684
689
|
context,
|
|
685
690
|
kind,
|
|
686
691
|
host,
|
|
692
|
+
tty: procByPid.get(pid)?.tty,
|
|
687
693
|
pid,
|
|
688
694
|
cwd,
|
|
689
695
|
sessionId: entry?.sessionId ?? sessionIdFromFile(sessionFile),
|
package/dist/lib/session/db.d.ts
CHANGED
|
@@ -16,6 +16,7 @@ export interface SessionRow {
|
|
|
16
16
|
version: string | null;
|
|
17
17
|
account: string | null;
|
|
18
18
|
timestamp: string;
|
|
19
|
+
last_activity: string | null;
|
|
19
20
|
project: string | null;
|
|
20
21
|
cwd: string | null;
|
|
21
22
|
git_branch: string | null;
|
|
@@ -144,7 +145,7 @@ export declare function syncTopics(topicMap: Map<string, string>): number;
|
|
|
144
145
|
* session hasn't been scanned yet — the caller degrades to no live state.
|
|
145
146
|
*/
|
|
146
147
|
export declare function latestSessionFileForCwd(agent: SessionAgentId, cwd: string): string | undefined;
|
|
147
|
-
/** Query sessions from the database, applying filters and ordering by
|
|
148
|
+
/** Query sessions from the database, applying filters and ordering by last-activity descending (default). */
|
|
148
149
|
export declare function querySessions(options?: QueryOptions): SessionMeta[];
|
|
149
150
|
/** Count sessions matching the given filter options. */
|
|
150
151
|
export declare function countSessions(options?: QueryOptions): number;
|