@phnx-labs/agents-cli 1.20.36 → 1.20.38
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/computer-actions.d.ts +10 -0
- package/dist/commands/computer-actions.js +47 -17
- package/dist/commands/doctor.js +48 -1
- package/dist/commands/go.d.ts +28 -0
- package/dist/commands/go.js +238 -0
- package/dist/commands/sessions-picker.d.ts +2 -0
- package/dist/commands/sessions-picker.js +10 -1
- package/dist/commands/sessions-sync.d.ts +3 -0
- package/dist/commands/sessions-sync.js +44 -4
- package/dist/commands/sessions.d.ts +8 -1
- package/dist/commands/sessions.js +155 -36
- package/dist/index.js +59 -68
- 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 +25 -0
- package/dist/lib/session/active.js +11 -5
- 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/shims.d.ts +65 -1
- package/dist/lib/shims.js +237 -20
- 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
|
@@ -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';
|
|
@@ -37,8 +39,27 @@ import { setHelpSections } from '../lib/help.js';
|
|
|
37
39
|
import { registerSessionsTailCommand } from './sessions-tail.js';
|
|
38
40
|
import { registerSessionsSyncCommand } from './sessions-sync.js';
|
|
39
41
|
import { registerSessionsResumeCommand } from './sessions-resume.js';
|
|
42
|
+
import { registerGoCommand } from './go.js';
|
|
40
43
|
import { registerSessionsInjectCommand } from './sessions-inject.js';
|
|
41
44
|
const SESSION_AGENT_FILTER_HELP = `Filter by agent, e.g. claude, codex, claude@2.0.65`;
|
|
45
|
+
/**
|
|
46
|
+
* The prioritized harnesses that get a boolean shorthand flag (e.g. `--claude`
|
|
47
|
+
* === `--agent claude`). The rest stay reachable via `--agent <name>`, which
|
|
48
|
+
* also carries version pins like `codex@0.116.0`.
|
|
49
|
+
*/
|
|
50
|
+
const AGENT_SHORTHANDS = ['claude', 'codex', 'kimi', 'antigravity', 'grok', 'opencode'];
|
|
51
|
+
/**
|
|
52
|
+
* Resolve a per-agent shorthand (`--claude`, `--kimi`, …) into `options.agent`.
|
|
53
|
+
* An explicit `--agent` wins; if two shorthands are passed we take the first and
|
|
54
|
+
* ignore the rest (commander gives no ordering, so this is a best-effort alias).
|
|
55
|
+
*/
|
|
56
|
+
function applyAgentShorthands(options) {
|
|
57
|
+
if (options.agent)
|
|
58
|
+
return;
|
|
59
|
+
const hit = AGENT_SHORTHANDS.find((name) => options[name] === true);
|
|
60
|
+
if (hit)
|
|
61
|
+
options.agent = hit;
|
|
62
|
+
}
|
|
42
63
|
const CLAUDE_RESUME_MATCH_WINDOW_MS = 10 * 60_000;
|
|
43
64
|
const LOAD_VERBS = ['Loading', 'Scanning', 'Gathering', 'Indexing', 'Reading'];
|
|
44
65
|
const FIND_VERBS = ['Finding', 'Searching', 'Locating', 'Matching'];
|
|
@@ -174,13 +195,28 @@ function formatStartedAt(startedAtMs) {
|
|
|
174
195
|
return '-';
|
|
175
196
|
return formatRelativeTime(new Date(startedAtMs).toISOString());
|
|
176
197
|
}
|
|
198
|
+
/**
|
|
199
|
+
* Strip terminal/harness noise from a preview so the column stays a single line
|
|
200
|
+
* of plain prose: OSC title escapes, CSI/SGR ANSI, and the harness wrapper tags
|
|
201
|
+
* (`<local-command-stdout>`, `<task-notification>`, `<command-*>`) that leak from
|
|
202
|
+
* a captured transcript tail. Collapses runs of whitespace.
|
|
203
|
+
*/
|
|
204
|
+
export function cleanPreview(text) {
|
|
205
|
+
// eslint-disable-next-line no-control-regex
|
|
206
|
+
return text
|
|
207
|
+
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') // OSC (title) sequences
|
|
208
|
+
.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '') // CSI / SGR ANSI
|
|
209
|
+
.replace(/<\/?(?:local-command-stdout|command-name|command-message|command-args|task-notification|system-reminder)>/g, '')
|
|
210
|
+
.replace(/\s+/g, ' ')
|
|
211
|
+
.trim();
|
|
212
|
+
}
|
|
177
213
|
/**
|
|
178
214
|
* Build the live description for an active session: prefer the state engine's
|
|
179
215
|
* preview (the latest turn), then a user label, then the first-prompt topic.
|
|
180
216
|
*/
|
|
181
217
|
function buildSessionDescription(s) {
|
|
182
218
|
if (s.context === 'cloud') {
|
|
183
|
-
return s.preview || `${s.cloudProvider ?? ''}${s.cloudTaskId ? ` · ${s.cloudTaskId.slice(0, 12)}` : ''}
|
|
219
|
+
return cleanPreview(s.preview || `${s.cloudProvider ?? ''}${s.cloudTaskId ? ` · ${s.cloudTaskId.slice(0, 12)}` : ''}`);
|
|
184
220
|
}
|
|
185
221
|
if (s.context === 'teams') {
|
|
186
222
|
const parts = [s.teamName];
|
|
@@ -190,10 +226,10 @@ function buildSessionDescription(s) {
|
|
|
190
226
|
parts.push(s.label);
|
|
191
227
|
else if (s.topic)
|
|
192
228
|
parts.push(s.topic);
|
|
193
|
-
return parts.filter(Boolean).join(' · ');
|
|
229
|
+
return cleanPreview(parts.filter(Boolean).join(' · '));
|
|
194
230
|
}
|
|
195
231
|
// Terminal or headless: prefer the live preview, then label, then topic.
|
|
196
|
-
return s.preview || s.label || s.topic || '';
|
|
232
|
+
return cleanPreview(s.preview || s.label || s.topic || '');
|
|
197
233
|
}
|
|
198
234
|
/** Short human word for a session's activity (falls back to the coarse status). */
|
|
199
235
|
function activityLabel(s) {
|
|
@@ -264,20 +300,25 @@ function signalBadges(s) {
|
|
|
264
300
|
return parts.join(' ');
|
|
265
301
|
}
|
|
266
302
|
/**
|
|
267
|
-
* Compact
|
|
268
|
-
* `ssh` flags a remote host
|
|
269
|
-
*
|
|
303
|
+
* Compact locator badge: how to JUMP to the session, not what it's doing.
|
|
304
|
+
* `ssh` flags a remote host. For tmux, prefer the resolved `session:window.pane`
|
|
305
|
+
* (a real `tmux attach -t <session:window>` target) over the raw `%pane` id. For
|
|
306
|
+
* a local Ghostty session we know the tab, show `tab N`. Local, unlocatable
|
|
307
|
+
* sessions add nothing (the common case).
|
|
270
308
|
*/
|
|
271
|
-
function
|
|
272
|
-
|
|
273
|
-
return '';
|
|
309
|
+
function locatorBadge(s) {
|
|
310
|
+
const p = s.provenance;
|
|
274
311
|
const parts = [];
|
|
275
|
-
if (p
|
|
312
|
+
if (p?.transport === 'ssh')
|
|
276
313
|
parts.push(chalk.red('ssh'));
|
|
277
|
-
if (p
|
|
278
|
-
parts.push(chalk.green(
|
|
279
|
-
|
|
314
|
+
if (p?.mux?.kind === 'tmux' && (s.tmuxTarget || p.mux.pane)) {
|
|
315
|
+
parts.push(chalk.green(s.tmuxTarget ?? p.mux.pane));
|
|
316
|
+
}
|
|
317
|
+
else if (p?.mux?.kind === 'screen') {
|
|
280
318
|
parts.push(chalk.green('screen'));
|
|
319
|
+
}
|
|
320
|
+
if (s.ghosttyTab != null)
|
|
321
|
+
parts.push(chalk.green(`tab ${s.ghosttyTab}`));
|
|
281
322
|
return parts.join(' ');
|
|
282
323
|
}
|
|
283
324
|
/**
|
|
@@ -293,7 +334,7 @@ function printActiveRow(s, indent) {
|
|
|
293
334
|
const hostCol = chalk.gray(padToWidth(truncateToWidth(s.host ?? '-', 8), 9));
|
|
294
335
|
const statusCol = statusColor(s.status)(padToWidth(truncateToWidth(activityLabel(s), 8), 9));
|
|
295
336
|
const fork = s.pidCount && s.pidCount > 1 ? chalk.dim(`×${s.pidCount} `) : '';
|
|
296
|
-
const badges = (fork ? fork : '') + [signalBadges(s),
|
|
337
|
+
const badges = (fork ? fork : '') + [signalBadges(s), locatorBadge(s)].filter(Boolean).join(' ');
|
|
297
338
|
const desc = buildSessionDescription(s) || '-';
|
|
298
339
|
// Fill the remaining width with the preview so nothing wraps under tmux/SSH.
|
|
299
340
|
const fixed = stringWidth(indent) + 9 + 9 + 9 + 9 + (badges ? stringWidth(badges) + 1 : 0);
|
|
@@ -466,6 +507,27 @@ export function mergeLocalFirst(sessions, localMachine) {
|
|
|
466
507
|
});
|
|
467
508
|
return keys.flatMap((k) => byMachine.get(k));
|
|
468
509
|
}
|
|
510
|
+
/**
|
|
511
|
+
* `running N · idle N · waiting N · queued N` for a bucket of sessions (zero
|
|
512
|
+
* buckets omitted). Same bucketing as the grand-total summary so per-group
|
|
513
|
+
* counts reconcile with the `(total)` beside the header. Empty when nothing.
|
|
514
|
+
*/
|
|
515
|
+
function groupTally(sessions) {
|
|
516
|
+
const running = sessions.filter(s => s.status === 'running').length;
|
|
517
|
+
const idle = sessions.filter(s => s.status === 'idle').length;
|
|
518
|
+
const waiting = sessions.filter(s => s.status === 'input_required').length;
|
|
519
|
+
const queued = sessions.filter(s => s.status === 'queued').length;
|
|
520
|
+
const parts = [];
|
|
521
|
+
if (running)
|
|
522
|
+
parts.push(`${running} running`);
|
|
523
|
+
if (idle)
|
|
524
|
+
parts.push(`${idle} idle`);
|
|
525
|
+
if (waiting)
|
|
526
|
+
parts.push(`${waiting} waiting`);
|
|
527
|
+
if (queued)
|
|
528
|
+
parts.push(`${queued} queued`);
|
|
529
|
+
return parts.join(' · ');
|
|
530
|
+
}
|
|
469
531
|
/** Print one machine's workspace tree, indented under its machine header. */
|
|
470
532
|
function renderWorkspaceLayout(layout, base) {
|
|
471
533
|
let first = true;
|
|
@@ -478,7 +540,9 @@ function renderWorkspaceLayout(layout, base) {
|
|
|
478
540
|
: ws.key === '__unknown__'
|
|
479
541
|
? chalk.gray.bold('unknown')
|
|
480
542
|
: chalk.cyan.bold(shortCwd(ws.key));
|
|
481
|
-
|
|
543
|
+
const wsSessions = [...ws.windows.flatMap(w => w.sessions), ...ws.flat];
|
|
544
|
+
const tally = groupTally(wsSessions);
|
|
545
|
+
console.log(`${base}${header} ${chalk.gray(`(${ws.total})`)}${tally ? chalk.gray(` ${tally}`) : ''}`);
|
|
482
546
|
for (const win of ws.windows) {
|
|
483
547
|
// Host is per-process, but every terminal in the same IDE window shares
|
|
484
548
|
// an ancestor — take the first non-empty host as the window's label.
|
|
@@ -500,6 +564,43 @@ function printMachineHeader(mg) {
|
|
|
500
564
|
const here = mg.isLocal ? chalk.cyan(' ← this machine') : '';
|
|
501
565
|
console.log(`${marker}${name} ${chalk.gray(`(${mg.total})`)}${here}`);
|
|
502
566
|
}
|
|
567
|
+
/**
|
|
568
|
+
* Attach display-only jump locators onto LOCAL sessions: the Ghostty tab number
|
|
569
|
+
* (one batched read-only osascript, only when a local ghostty session exists)
|
|
570
|
+
* and the tmux `session:window.pane` target (one `list-panes -a` per socket).
|
|
571
|
+
* Every step is best-effort and swallowed — a failure just leaves the raw pane
|
|
572
|
+
* id / no tab number, and the rows render as before. Mutates the sessions.
|
|
573
|
+
*/
|
|
574
|
+
async function enrichLocalLocators(local) {
|
|
575
|
+
// Ghostty tab numbers.
|
|
576
|
+
try {
|
|
577
|
+
const ghostty = local.filter(s => s.host === 'ghostty' && s.provenance?.transport !== 'ssh');
|
|
578
|
+
if (ghostty.length > 0) {
|
|
579
|
+
const surfaces = await enumerateGhosttyTabs();
|
|
580
|
+
for (const [sess, tab] of assignGhosttyTabs(ghostty, surfaces))
|
|
581
|
+
sess.ghosttyTab = tab;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
catch { /* non-fatal */ }
|
|
585
|
+
// tmux attach targets, one batched query per distinct socket.
|
|
586
|
+
try {
|
|
587
|
+
const tmux = local.filter(s => s.provenance?.mux?.kind === 'tmux' && s.provenance.mux.pane);
|
|
588
|
+
const sockets = new Set(tmux.map(s => s.provenance.mux.socket));
|
|
589
|
+
for (const socket of sockets) {
|
|
590
|
+
const paneMap = await mapPanesToTargets(socket);
|
|
591
|
+
if (paneMap.size === 0)
|
|
592
|
+
continue;
|
|
593
|
+
for (const s of tmux) {
|
|
594
|
+
if (s.provenance.mux.socket !== socket)
|
|
595
|
+
continue;
|
|
596
|
+
const target = paneMap.get(s.provenance.mux.pane);
|
|
597
|
+
if (target)
|
|
598
|
+
s.tmuxTarget = target;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
catch { /* non-fatal */ }
|
|
603
|
+
}
|
|
503
604
|
/**
|
|
504
605
|
* Render the unified active-session view, grouped by machine. Local sessions
|
|
505
606
|
* come from `getActiveSessions()`; unless `--local`, sessions from other
|
|
@@ -535,6 +636,10 @@ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
|
|
|
535
636
|
printCrossMachineTip();
|
|
536
637
|
return;
|
|
537
638
|
}
|
|
639
|
+
// Enrich LOCAL sessions with jump locators (display-only, after the --json /
|
|
640
|
+
// --waiting gates so scriptable output stays osascript-free). Remote sessions
|
|
641
|
+
// keep their raw pane id — their tmux/Ghostty live on the other machine.
|
|
642
|
+
await enrichLocalLocators(sessions.filter(s => !s.machine || s.machine === self));
|
|
538
643
|
const grouped = groupSessionsByMachine(sessions, self);
|
|
539
644
|
let firstMachine = true;
|
|
540
645
|
for (const mg of grouped.machines) {
|
|
@@ -544,16 +649,7 @@ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
|
|
|
544
649
|
printMachineHeader(mg);
|
|
545
650
|
renderWorkspaceLayout(mg.layout, ' ');
|
|
546
651
|
}
|
|
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`);
|
|
652
|
+
const parts = groupTally(sessions).split(' · ').filter(Boolean);
|
|
557
653
|
const machineWord = grouped.machines.length === 1 ? 'machine' : 'machines';
|
|
558
654
|
console.log(chalk.gray(`\n${sessions.length} active (${parts.join(', ')}) across ${grouped.machines.length} ${machineWord}.`));
|
|
559
655
|
// Tip only when nothing else could be included and the user didn't opt out.
|
|
@@ -569,6 +665,16 @@ function printCrossMachineTip() {
|
|
|
569
665
|
}
|
|
570
666
|
/** Main action handler for `agents sessions`. Routes to picker, table, or single-session render. */
|
|
571
667
|
async function sessionsAction(query, options) {
|
|
668
|
+
// Explicit --query is interchangeable with the positional; it's how you search
|
|
669
|
+
// for text that collides with a subcommand name (e.g. `sessions --query go`).
|
|
670
|
+
query = query ?? options.query;
|
|
671
|
+
// Normalize convenience flags before any routing reads them: per-agent
|
|
672
|
+
// shorthands fold into --agent, and --device is an alias for --host (both
|
|
673
|
+
// resolve against the same device registry).
|
|
674
|
+
applyAgentShorthands(options);
|
|
675
|
+
if (options.device && options.device.length > 0) {
|
|
676
|
+
options.host = [...(options.host ?? []), ...options.device];
|
|
677
|
+
}
|
|
572
678
|
// --host WITHOUT --active keeps the legacy per-host stream (each remote's raw
|
|
573
679
|
// stdout under a `── host ──` banner). With --active, the hosts are folded
|
|
574
680
|
// into the merged machine-grouped view instead (handled below).
|
|
@@ -666,9 +772,12 @@ async function sessionsAction(query, options) {
|
|
|
666
772
|
const scope = {
|
|
667
773
|
agent,
|
|
668
774
|
version,
|
|
669
|
-
all: pathFilter ? undefined :
|
|
775
|
+
all: pathFilter ? undefined : options.all,
|
|
670
776
|
cwd: process.cwd(),
|
|
671
|
-
|
|
777
|
+
// Default overview scopes to the current repo SUBTREE (prefix match), so a
|
|
778
|
+
// monorepo shows its sub-projects grouped instead of collapsing to the one
|
|
779
|
+
// exact-cwd project. `--all` clears the prefix and spans the whole index.
|
|
780
|
+
cwdPrefix: pathFilter ?? (wantsOverview && !options.all ? process.cwd() : undefined),
|
|
672
781
|
project: options.project,
|
|
673
782
|
since,
|
|
674
783
|
until: options.until,
|
|
@@ -819,7 +928,7 @@ function metaSignals(s) {
|
|
|
819
928
|
* dashes and needlessly truncate the topic. Worktree stays a trailing badge. */
|
|
820
929
|
function flatSessionRow(session, live, showTicket = false, cols = {}) {
|
|
821
930
|
const agentColor = colorAgent(session.agent);
|
|
822
|
-
const when = formatRelativeTime(session.timestamp);
|
|
931
|
+
const when = formatRelativeTime(session.lastActivity ?? session.timestamp);
|
|
823
932
|
const project = session.project || '-';
|
|
824
933
|
const tag = teamTag(session);
|
|
825
934
|
const label = session.label;
|
|
@@ -858,7 +967,7 @@ function flatSessionRow(session, live, showTicket = false, cols = {}) {
|
|
|
858
967
|
/** One tree-mode row (grouped under a dir header): id · agent · badges · topic · time. No version/project column. */
|
|
859
968
|
function treeSessionRow(session, live) {
|
|
860
969
|
const agentColor = colorAgent(session.agent);
|
|
861
|
-
const when = formatRelativeTime(session.timestamp);
|
|
970
|
+
const when = formatRelativeTime(session.lastActivity ?? session.timestamp);
|
|
862
971
|
const tag = teamTag(session);
|
|
863
972
|
const label = session.label;
|
|
864
973
|
const { glyph, preview } = liveGlyphAndPreview(live);
|
|
@@ -928,7 +1037,7 @@ export function buildOverviewGroups(pool, perProjectCap) {
|
|
|
928
1037
|
const groups = [];
|
|
929
1038
|
for (const [key, rows] of byKey) {
|
|
930
1039
|
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 });
|
|
1040
|
+
groups.push({ key, total: rows.length, shown, more: rows.length - shown.length, maxTs: rows[0].lastActivity ?? rows[0].timestamp });
|
|
932
1041
|
}
|
|
933
1042
|
groups.sort((a, b) => (a.maxTs < b.maxTs ? 1 : a.maxTs > b.maxTs ? -1 : a.key.localeCompare(b.key)));
|
|
934
1043
|
return { groups, projectCount: byKey.size };
|
|
@@ -960,10 +1069,10 @@ function printSessionOverview(pool, hiddenCount, liveIndex, opts) {
|
|
|
960
1069
|
console.log(' ' + chalk.gray(`· ${g.more} more`));
|
|
961
1070
|
}
|
|
962
1071
|
console.log();
|
|
963
|
-
const parts = [chalk.gray('newest first')];
|
|
1072
|
+
const parts = [chalk.gray('newest first (by last activity)')];
|
|
964
1073
|
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'));
|
|
1074
|
+
parts.push(chalk.gray(`+${hiddenProjects} more project${hiddenProjects === 1 ? '' : 's'}`));
|
|
1075
|
+
parts.push(chalk.gray('agents sessions --all spans every project on disk · <project> to drill in · --flat for the plain list'));
|
|
967
1076
|
console.log(parts.join(chalk.gray(' · ')));
|
|
968
1077
|
if (hiddenCount > 0)
|
|
969
1078
|
console.log(chalk.gray(formatTeamHiddenFooter(hiddenCount)));
|
|
@@ -1213,7 +1322,7 @@ export function pickerColumnsFor(sessions) {
|
|
|
1213
1322
|
}
|
|
1214
1323
|
export function formatPickerLabel(s, query, cols = {}) {
|
|
1215
1324
|
const agentColor = colorAgent(s.agent);
|
|
1216
|
-
const when = formatRelativeTime(s.timestamp);
|
|
1325
|
+
const when = formatRelativeTime(s.lastActivity ?? s.timestamp);
|
|
1217
1326
|
const project = s.project || '-';
|
|
1218
1327
|
const tag = teamTag(s);
|
|
1219
1328
|
const label = s.label;
|
|
@@ -1260,7 +1369,7 @@ const PICKER_TIPS = [
|
|
|
1260
1369
|
export function formatPickerTip(sessions) {
|
|
1261
1370
|
return chalk.gray(PICKER_TIPS[sessions.length % PICKER_TIPS.length]);
|
|
1262
1371
|
}
|
|
1263
|
-
export async function pickSessionInteractive(sessions, message = 'Search sessions:', initialSearch, hiddenCount = 0) {
|
|
1372
|
+
export async function pickSessionInteractive(sessions, message = 'Search sessions:', initialSearch, hiddenCount = 0, enterHint) {
|
|
1264
1373
|
if (hiddenCount > 0) {
|
|
1265
1374
|
console.log(chalk.gray(formatTeamHiddenFooter(hiddenCount)));
|
|
1266
1375
|
}
|
|
@@ -1280,6 +1389,7 @@ export async function pickSessionInteractive(sessions, message = 'Search session
|
|
|
1280
1389
|
labelFor: (s, query) => formatPickerLabel(s, query, cols),
|
|
1281
1390
|
pageSize: PICKER_RECENT_COUNT,
|
|
1282
1391
|
initialSearch,
|
|
1392
|
+
enterHint,
|
|
1283
1393
|
});
|
|
1284
1394
|
}
|
|
1285
1395
|
catch (err) {
|
|
@@ -1783,8 +1893,15 @@ export function registerSessionsCommands(program) {
|
|
|
1783
1893
|
const sessionsCmd = program
|
|
1784
1894
|
.command('sessions')
|
|
1785
1895
|
.argument('[query]', 'Session ID, search query, or path (., ../, /path) to filter by project')
|
|
1896
|
+
.option('--query <text>', 'Search text — use when the term collides with a subcommand name (e.g. "go")')
|
|
1786
1897
|
.description('Find, browse, and read agent conversation transcripts across Claude, Codex, Gemini, and OpenCode.')
|
|
1787
1898
|
.option('-a, --agent <agent>', 'Filter by agent type and version (e.g., claude, codex@0.116.0)')
|
|
1899
|
+
.option('--claude', 'Shorthand for --agent claude')
|
|
1900
|
+
.option('--codex', 'Shorthand for --agent codex')
|
|
1901
|
+
.option('--kimi', 'Shorthand for --agent kimi')
|
|
1902
|
+
.option('--antigravity', 'Shorthand for --agent antigravity')
|
|
1903
|
+
.option('--grok', 'Shorthand for --agent grok')
|
|
1904
|
+
.option('--opencode', 'Shorthand for --agent opencode')
|
|
1788
1905
|
.option('--all', 'Include sessions from every directory (not just current project)')
|
|
1789
1906
|
.option('--teams', 'Include team-spawned sessions (hidden by default)')
|
|
1790
1907
|
.option('--project <name>', 'Filter by project name (searches across all directories)')
|
|
@@ -1808,7 +1925,8 @@ export function registerSessionsCommands(program) {
|
|
|
1808
1925
|
.option('--flat', 'Plain flat table (one row per session) instead of the grouped project overview')
|
|
1809
1926
|
.option('--no-live', 'Do not enrich the listing with live status/preview for running sessions')
|
|
1810
1927
|
.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)')
|
|
1928
|
+
.option('-H, --host <target...>', 'Run this query on remote machine(s) over SSH (host alias or user@host; repeatable)')
|
|
1929
|
+
.option('--device <target...>', 'Alias for --host (device alias from `agents devices`; repeatable)');
|
|
1812
1930
|
setHelpSections(sessionsCmd, {
|
|
1813
1931
|
examples: `
|
|
1814
1932
|
# Search prior sessions in this project by topic, file path, or command
|
|
@@ -1855,6 +1973,7 @@ export function registerSessionsCommands(program) {
|
|
|
1855
1973
|
registerSessionsTailCommand(sessionsCmd);
|
|
1856
1974
|
registerSessionsSyncCommand(sessionsCmd);
|
|
1857
1975
|
registerSessionsResumeCommand(sessionsCmd);
|
|
1976
|
+
registerGoCommand(sessionsCmd);
|
|
1858
1977
|
registerSessionsInjectCommand(sessionsCmd);
|
|
1859
1978
|
}
|
|
1860
1979
|
function formatNoSessionsMessage(showAll, project) {
|
package/dist/index.js
CHANGED
|
@@ -483,7 +483,7 @@ async function maybeBootstrapShimIntegration(requestedCommand, helpOrVersionRequ
|
|
|
483
483
|
const { confirm } = await import('@inquirer/prompts');
|
|
484
484
|
const { AGENTS } = await import('./lib/agents.js');
|
|
485
485
|
const { getGlobalDefault, listInstalledVersions } = await import('./lib/versions.js');
|
|
486
|
-
const { addShimsToPath, ensureShimCurrent, ensureVersionedAliasCurrent, getPathShadowingExecutable, getPathSetupInstructions, getShimsDir, isShimsInPath, listAgentsWithInstalledVersions, removeLegacyUserShim, } = await import('./lib/shims.js');
|
|
486
|
+
const { addShimsToPath, adoptShadowingLauncher, ensureShimCurrent, ensureVersionedAliasCurrent, getPathShadowingExecutable, getPathSetupInstructions, getShimsDir, isShimsInPath, listAgentsWithInstalledVersions, removeLegacyUserShim, } = await import('./lib/shims.js');
|
|
487
487
|
const installedAgents = listAgentsWithInstalledVersions();
|
|
488
488
|
if (installedAgents.length === 0) {
|
|
489
489
|
return;
|
|
@@ -519,87 +519,78 @@ async function maybeBootstrapShimIntegration(requestedCommand, helpOrVersionRequ
|
|
|
519
519
|
return;
|
|
520
520
|
}
|
|
521
521
|
const defaultAgents = installedAgents.filter((agent) => getGlobalDefault(agent));
|
|
522
|
+
// Auto-adopt any harness launcher that shadows our shim. PATH-order repair
|
|
523
|
+
// (below) cannot win against `~/.local/bin` — it's prepended in .zshenv for
|
|
524
|
+
// every shell while our prepend only lands in .zshrc — so for symlink
|
|
525
|
+
// launchers we *become* the launcher instead. Detection keys on the launcher
|
|
526
|
+
// symlink EXISTING (via adoptShadowingLauncher's own fallback), not on this
|
|
527
|
+
// shell's PATH order, so it also heals the GUI/non-interactive shadow an
|
|
528
|
+
// interactive run can't see. Reversible; only ever rewrites a symlink.
|
|
529
|
+
for (const agent of defaultAgents) {
|
|
530
|
+
const result = adoptShadowingLauncher(agent);
|
|
531
|
+
if (result.adopted) {
|
|
532
|
+
console.log(chalk.green(`Adopted ${AGENTS[agent].cliCommand} launcher (${result.launcher}) — version management now wins regardless of PATH order.`));
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
// Recompute AFTER adoption so anything we just took over drops out. What
|
|
536
|
+
// remains is a real binary we deliberately don't touch (adoption is
|
|
537
|
+
// symlink-only) — those get an honest one-time note, never a looping prompt.
|
|
522
538
|
const shadowed = defaultAgents
|
|
523
539
|
.map((agent) => ({ agent, shadowedBy: getPathShadowingExecutable(agent) }))
|
|
524
540
|
.filter((item) => Boolean(item.shadowedBy));
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
// contradicted the comment below and surfaced false positives (e.g. an
|
|
530
|
-
// earlier `alias codex=...` cancelled by a later `unalias codex` was
|
|
531
|
-
// reported because the detector did a static rc-file regex).
|
|
532
|
-
if (shadowed.length === 0 && isShimsInPath()) {
|
|
541
|
+
// After adoption, the only things left are (a) real-binary shadows we won't
|
|
542
|
+
// touch, and (b) a genuinely missing PATH entry. Nothing else needs the user.
|
|
543
|
+
const pathMissing = !isShimsInPath();
|
|
544
|
+
if (shadowed.length === 0 && !pathMissing) {
|
|
533
545
|
return;
|
|
534
546
|
}
|
|
535
|
-
// Suppress repeated
|
|
536
|
-
//
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
// PPID changes and the prompt is allowed again.
|
|
547
|
+
// Suppress repeated notices within the same shell. A successful rc-file edit
|
|
548
|
+
// doesn't reload the parent shell, so the next invocation re-fires detection.
|
|
549
|
+
// The sentinel survives only as long as the parent shell process — a new
|
|
550
|
+
// terminal (new PPID) is allowed to surface it again.
|
|
540
551
|
const sentinelPath = path.join(os.tmpdir(), `agents-shim-prompted-${process.ppid}`);
|
|
541
552
|
if (fs.existsSync(sentinelPath)) {
|
|
542
553
|
return;
|
|
543
554
|
}
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
default: true,
|
|
557
|
-
});
|
|
558
|
-
if (!shouldRepair) {
|
|
559
|
-
console.log(chalk.yellow('Shim integration still needs attention.'));
|
|
560
|
-
console.log(chalk.gray(getPathSetupInstructions()));
|
|
561
|
-
try {
|
|
562
|
-
fs.writeFileSync(sentinelPath, '1');
|
|
563
|
-
}
|
|
564
|
-
catch { /* best-effort */ }
|
|
565
|
-
return;
|
|
566
|
-
}
|
|
567
|
-
const pathResult = addShimsToPath();
|
|
568
|
-
if (!pathResult.success) {
|
|
569
|
-
console.log(chalk.yellow('Could not repair shim PATH setup automatically.'));
|
|
570
|
-
console.log(chalk.gray(pathResult.error || getPathSetupInstructions()));
|
|
571
|
-
// Write the sentinel even on failure — otherwise an unwritable rc file
|
|
572
|
-
// re-prompts every invocation in the same shell. The user opens a new
|
|
573
|
-
// terminal (new PPID) to retry.
|
|
574
|
-
try {
|
|
575
|
-
fs.writeFileSync(sentinelPath, '1');
|
|
576
|
-
}
|
|
577
|
-
catch { /* best-effort */ }
|
|
578
|
-
return;
|
|
555
|
+
// Real-binary shadows: adoption is symlink-only (we never rename a real native
|
|
556
|
+
// binary), and `addShimsToPath` provably can't outrank an early-PATH dir like
|
|
557
|
+
// ~/.local/bin across zsh's whole sourcing chain. So DON'T offer a "Repair?"
|
|
558
|
+
// prompt here — that was the infinite-loop bug (Yes was always a no-op).
|
|
559
|
+
// Inform once and point at the real levers.
|
|
560
|
+
if (shadowed.length > 0) {
|
|
561
|
+
const targets = shadowed
|
|
562
|
+
.map(({ agent, shadowedBy }) => ` ${AGENTS[agent].cliCommand}: ${shadowedBy}`)
|
|
563
|
+
.join('\n');
|
|
564
|
+
console.log(chalk.yellow('These agent commands run a native binary instead of the version-managed shim:'));
|
|
565
|
+
console.log(chalk.gray(targets));
|
|
566
|
+
console.log(chalk.gray(`It's a real binary (not a symlink), so agents-cli won't move it. To hand it to agents-cli, remove/reorder it, or put ${getShimsDir()} earlier in PATH.`));
|
|
579
567
|
}
|
|
580
|
-
//
|
|
581
|
-
//
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
.join('\n');
|
|
590
|
-
console.log(chalk.yellow('Repair could not change anything — the shim is shadowed by another binary on PATH:'));
|
|
591
|
-
console.log(chalk.gray(targets));
|
|
592
|
-
console.log(chalk.gray(`Fix it by removing or reordering that binary, or making sure ${getShimsDir()} appears earlier in PATH than its parent dir.`));
|
|
568
|
+
// Genuinely-missing PATH entry is the one thing addShimsToPath actually fixes,
|
|
569
|
+
// so it's the only case that still earns an interactive prompt.
|
|
570
|
+
if (pathMissing) {
|
|
571
|
+
const shouldRepair = await confirm({
|
|
572
|
+
message: 'Add the agents-cli shims directory to your PATH now?',
|
|
573
|
+
default: true,
|
|
574
|
+
});
|
|
575
|
+
if (!shouldRepair) {
|
|
576
|
+
console.log(chalk.gray(getPathSetupInstructions()));
|
|
593
577
|
}
|
|
594
578
|
else {
|
|
595
|
-
|
|
596
|
-
|
|
579
|
+
const pathResult = addShimsToPath();
|
|
580
|
+
if (!pathResult.success) {
|
|
581
|
+
console.log(chalk.yellow('Could not update PATH automatically.'));
|
|
582
|
+
console.log(chalk.gray(pathResult.error || getPathSetupInstructions()));
|
|
583
|
+
}
|
|
584
|
+
else if (pathResult.alreadyPresent) {
|
|
585
|
+
console.log(chalk.yellow(`Shim PATH entry is already in ~/${pathResult.rcFile} — this shell just needs to reload it.`));
|
|
586
|
+
console.log(chalk.gray(`Run: source ~/${pathResult.rcFile} (or open a new terminal)`));
|
|
587
|
+
}
|
|
588
|
+
else {
|
|
589
|
+
console.log(chalk.green(`Added shims to PATH in ~/${pathResult.rcFile}`));
|
|
590
|
+
console.log(chalk.gray(getPathSetupInstructions()));
|
|
591
|
+
}
|
|
597
592
|
}
|
|
598
593
|
}
|
|
599
|
-
else {
|
|
600
|
-
console.log(chalk.green(`Repaired shim PATH setup in ~/${pathResult.rcFile}`));
|
|
601
|
-
console.log(chalk.gray(getPathSetupInstructions()));
|
|
602
|
-
}
|
|
603
594
|
try {
|
|
604
595
|
fs.writeFileSync(sentinelPath, '1');
|
|
605
596
|
}
|
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
|
+
}
|