@phnx-labs/agents-cli 1.22.29 → 1.22.31
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 +88 -0
- package/README.md +44 -5
- package/dist/bin/agents +0 -0
- package/dist/commands/accounts.d.ts +13 -0
- package/dist/commands/accounts.js +32 -0
- package/dist/commands/daemon.d.ts +18 -0
- package/dist/commands/daemon.js +581 -0
- package/dist/commands/exec.js +66 -20
- package/dist/commands/focus.d.ts +4 -1
- package/dist/commands/focus.js +19 -4
- package/dist/commands/routines.js +29 -11
- package/dist/commands/secrets.d.ts +37 -0
- package/dist/commands/secrets.js +86 -105
- package/dist/commands/sessions-bookmark.d.ts +20 -0
- package/dist/commands/{sessions-favorite.js → sessions-bookmark.js} +42 -42
- package/dist/commands/sessions-browser.d.ts +10 -8
- package/dist/commands/sessions-browser.js +61 -32
- package/dist/commands/sessions-picker.d.ts +33 -1
- package/dist/commands/sessions-picker.js +102 -27
- package/dist/commands/sessions-stats.js +1 -1
- package/dist/commands/sessions.d.ts +21 -8
- package/dist/commands/sessions.js +328 -74
- package/dist/commands/view.d.ts +11 -0
- package/dist/commands/view.js +56 -29
- package/dist/index.js +37 -2
- package/dist/lib/account-labels.d.ts +24 -0
- package/dist/lib/account-labels.js +72 -0
- package/dist/lib/agents.d.ts +32 -1
- package/dist/lib/agents.js +96 -31
- package/dist/lib/daemon-health.d.ts +24 -0
- package/dist/lib/daemon-health.js +84 -0
- package/dist/lib/daemon-ticks.d.ts +81 -0
- package/dist/lib/daemon-ticks.js +190 -0
- package/dist/lib/daemon.d.ts +68 -18
- package/dist/lib/daemon.js +303 -338
- package/dist/lib/device-config.d.ts +10 -0
- package/dist/lib/device-config.js +27 -0
- package/dist/lib/exec.d.ts +27 -0
- package/dist/lib/exec.js +49 -2
- package/dist/lib/hosts/dispatch.d.ts +4 -0
- package/dist/lib/hosts/dispatch.js +4 -0
- package/dist/lib/hosts/remote-cmd.js +1 -0
- package/dist/lib/hosts/run-target.d.ts +1 -0
- package/dist/lib/hosts/run-target.js +1 -0
- package/dist/lib/import.js +7 -6
- package/dist/lib/memory-cache.d.ts +19 -0
- package/dist/lib/memory-cache.js +31 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/migrate.d.ts +1 -1
- package/dist/lib/migrate.js +13 -2
- package/dist/lib/picker.d.ts +6 -3
- package/dist/lib/picker.js +7 -2
- package/dist/lib/routine-activation.d.ts +2 -0
- package/dist/lib/routine-activation.js +16 -0
- package/dist/lib/runner.d.ts +18 -0
- package/dist/lib/runner.js +52 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/agent.d.ts +19 -1
- package/dist/lib/secrets/agent.js +32 -6
- package/dist/lib/secrets/scope.d.ts +3 -3
- package/dist/lib/secrets/scope.js +3 -3
- package/dist/lib/secrets/session-store.d.ts +0 -4
- package/dist/lib/secrets/session-store.js +0 -5
- package/dist/lib/session/{favorites.d.ts → bookmarks.d.ts} +15 -15
- package/dist/lib/session/{favorites.js → bookmarks.js} +23 -23
- package/dist/lib/session/db.d.ts +15 -0
- package/dist/lib/session/db.js +90 -15
- package/dist/lib/session/discover.js +91 -39
- package/dist/lib/session/parse.d.ts +63 -0
- package/dist/lib/session/parse.js +165 -20
- package/dist/lib/session/session-cache.d.ts +9 -6
- package/dist/lib/session/session-cache.js +23 -6
- package/dist/lib/shims.js +12 -0
- package/dist/lib/startup/command-registry.d.ts +15 -1
- package/dist/lib/startup/command-registry.js +49 -0
- package/dist/lib/usage-refresh.js +3 -2
- package/dist/lib/usage.d.ts +12 -10
- package/dist/lib/usage.js +63 -144
- package/package.json +4 -1
- package/dist/commands/sessions-favorite.d.ts +0 -20
|
@@ -19,10 +19,10 @@ import { gatherRemoteList } from '../lib/session/remote-list.js';
|
|
|
19
19
|
import { resolveVersionAliasLoose } from '../lib/versions.js';
|
|
20
20
|
import { AGENTS } from '../lib/agents.js';
|
|
21
21
|
import { enrichTeamOrigins, safeTeamText } from '../lib/session/team-filter.js';
|
|
22
|
-
import {
|
|
22
|
+
import { listBookmarks, toggleBookmark } from '../lib/session/bookmarks.js';
|
|
23
23
|
import { machineId, normalizeHost } from '../lib/session/sync/config.js';
|
|
24
24
|
import { buildPreview } from './sessions-picker.js';
|
|
25
|
-
import { formatPickerLabel, pickerColumnsFor, ticketLabel, mergeLocalFirst, gatherActiveSessions, liveHostLabel, LIVE_ROW_PREFIX, cleanPreview, handlePickedSession, shouldIncludeLocal, remoteHostsToDial, matchesTeam, formatLiveStatusHeadline, isRunningLiveSession, matchesLiveStatus, parseAgentFilter, } from './sessions.js';
|
|
25
|
+
import { formatPickerLabel, pickerColumnsFor, ticketLabel, mergeLocalFirst, gatherActiveSessions, liveHostLabel, LIVE_ROW_PREFIX, cleanPreview, handlePickedSession, shouldIncludeLocal, remoteHostsToDial, matchesTeam, formatLiveStatusHeadline, isRunningLiveSession, matchesLiveStatus, parseAgentFilter, resolveRoutineName, } from './sessions.js';
|
|
26
26
|
/**
|
|
27
27
|
* Complete a seed into the filter the picker actually runs on.
|
|
28
28
|
*
|
|
@@ -40,7 +40,7 @@ export function buildInitialFilter(initial) {
|
|
|
40
40
|
return {
|
|
41
41
|
running: initial.running ?? false,
|
|
42
42
|
teams: initial.teams ?? false,
|
|
43
|
-
|
|
43
|
+
bookmarks: initial.bookmarks ?? false,
|
|
44
44
|
agent: initial.agent,
|
|
45
45
|
device: initial.device,
|
|
46
46
|
team: initial.team,
|
|
@@ -128,8 +128,8 @@ export function browserFilterToArgv(f, query = '') {
|
|
|
128
128
|
a.push(`--${status === 'orphaned' ? 'orphan' : status}`);
|
|
129
129
|
if (f.teams)
|
|
130
130
|
a.push('--teams');
|
|
131
|
-
if (f.
|
|
132
|
-
a.push('--
|
|
131
|
+
if (f.bookmarks)
|
|
132
|
+
a.push('--bookmarks');
|
|
133
133
|
if (f.agent)
|
|
134
134
|
a.push('-a', f.agent);
|
|
135
135
|
if (f.device)
|
|
@@ -144,8 +144,11 @@ export function browserFilterToArgv(f, query = '') {
|
|
|
144
144
|
a.push('--until', f.until);
|
|
145
145
|
if (f.project)
|
|
146
146
|
a.push('--project', f.project);
|
|
147
|
-
if (f.routine)
|
|
147
|
+
if (f.routine) {
|
|
148
148
|
a.push('--routine');
|
|
149
|
+
if (typeof f.routine === 'string')
|
|
150
|
+
a.push(f.routine);
|
|
151
|
+
}
|
|
149
152
|
if (f.skill)
|
|
150
153
|
a.push('--skill', f.skill);
|
|
151
154
|
if (f.plugin)
|
|
@@ -179,7 +182,8 @@ export function activeBrowserSeed(opts) {
|
|
|
179
182
|
return {
|
|
180
183
|
running: true,
|
|
181
184
|
teams: !!opts.teams,
|
|
182
|
-
|
|
185
|
+
bookmarks: !!opts.bookmarks,
|
|
186
|
+
routine: opts.routine ?? false,
|
|
183
187
|
agent: opts.agent,
|
|
184
188
|
projectScope: 'all',
|
|
185
189
|
device: normalizeDeviceSeed(opts.host?.[0]),
|
|
@@ -211,7 +215,8 @@ export function bareBrowserSeed(opts) {
|
|
|
211
215
|
const wholeTeam = !!opts.inTeam;
|
|
212
216
|
return {
|
|
213
217
|
teams: !!opts.teams,
|
|
214
|
-
|
|
218
|
+
bookmarks: !!opts.bookmarks,
|
|
219
|
+
routine: opts.routine ?? false,
|
|
215
220
|
agent: opts.agent,
|
|
216
221
|
// The filter carries one device; seed it only when the scope names exactly
|
|
217
222
|
// one, so a two-device scope isn't narrowed to the first of them.
|
|
@@ -339,8 +344,11 @@ export function remotePoolArgs(f, fixedFilters) {
|
|
|
339
344
|
forwarded.push('--until', f.until);
|
|
340
345
|
if (f.project)
|
|
341
346
|
forwarded.push('--project', f.project);
|
|
342
|
-
if (f.routine)
|
|
347
|
+
if (f.routine) {
|
|
343
348
|
forwarded.push('--routine');
|
|
349
|
+
if (typeof f.routine === 'string')
|
|
350
|
+
forwarded.push(f.routine);
|
|
351
|
+
}
|
|
344
352
|
if (f.skill)
|
|
345
353
|
forwarded.push('--skill', f.skill);
|
|
346
354
|
if (f.plugin)
|
|
@@ -417,6 +425,8 @@ export function liveSessionToMeta(a, self) {
|
|
|
417
425
|
prNumber: a.pr?.number,
|
|
418
426
|
ticketId: a.ticket?.id,
|
|
419
427
|
worktreeSlug: a.worktree?.slug,
|
|
428
|
+
origin: a.origin,
|
|
429
|
+
routineName: a.routineName,
|
|
420
430
|
};
|
|
421
431
|
}
|
|
422
432
|
/**
|
|
@@ -453,14 +463,22 @@ export function shouldShowHostColumn(f, live, rows) {
|
|
|
453
463
|
return false;
|
|
454
464
|
return rows.some((r) => liveHostLabel(live.get(r.id)) !== '');
|
|
455
465
|
}
|
|
456
|
-
/** Apply the cheap in-memory filters (agent / device / project / running /
|
|
457
|
-
export function applyFilters(rows, live, f, self,
|
|
466
|
+
/** Apply the cheap in-memory filters (agent / device / project / running / bookmarks). */
|
|
467
|
+
export function applyFilters(rows, live, f, self, bookmarks) {
|
|
458
468
|
let out = rows;
|
|
459
469
|
// A projected live row is keyed by pid/task when it has no session id, and a
|
|
460
|
-
//
|
|
461
|
-
// be
|
|
462
|
-
if (f.
|
|
463
|
-
out = out.filter((r) =>
|
|
470
|
+
// A bookmark is always keyed by a real session id — so an id-less row can
|
|
471
|
+
// never be bookmarked and correctly drops out here.
|
|
472
|
+
if (f.bookmarks)
|
|
473
|
+
out = out.filter((r) => bookmarks.has(r.id));
|
|
474
|
+
if (f.routine) {
|
|
475
|
+
out = out.filter((r) => r.origin === 'routine' || !!r.routineName);
|
|
476
|
+
if (typeof f.routine === 'string') {
|
|
477
|
+
const routineNames = distinct(out.map((r) => r.routineName));
|
|
478
|
+
const selected = resolveRoutineName(f.routine, routineNames);
|
|
479
|
+
out = selected ? out.filter((r) => r.routineName === selected) : [];
|
|
480
|
+
}
|
|
481
|
+
}
|
|
464
482
|
if (f.agent) {
|
|
465
483
|
const { agent, version: rawVersion } = parseAgentFilter(f.agent);
|
|
466
484
|
const localVersion = agent && agent in AGENTS
|
|
@@ -527,7 +545,7 @@ export async function collectSessionCandidates(initial, opts = {}) {
|
|
|
527
545
|
const rows = filter.running || opts.includeLive
|
|
528
546
|
? mergeLiveIntoPool(pool.rows, liveById, self, includeUnindexedLive)
|
|
529
547
|
: pool.rows;
|
|
530
|
-
const sessions = applyFilters(rows, liveById, filter, self,
|
|
548
|
+
const sessions = applyFilters(rows, liveById, filter, self, listBookmarks());
|
|
531
549
|
return { sessions, liveById, self, unreachable: pool.unreachable };
|
|
532
550
|
}
|
|
533
551
|
/** Derive the SSH-launch origin tag for a picker row from the live index. Set
|
|
@@ -552,15 +570,17 @@ function headerFor(f) {
|
|
|
552
570
|
bits.push('running');
|
|
553
571
|
if (f.teams)
|
|
554
572
|
bits.push('teams');
|
|
555
|
-
if (f.
|
|
556
|
-
bits.push('
|
|
573
|
+
if (f.routine)
|
|
574
|
+
bits.push(`routine:${typeof f.routine === 'string' ? f.routine : 'all'}`);
|
|
575
|
+
if (f.bookmarks)
|
|
576
|
+
bits.push('bookmarks');
|
|
557
577
|
return bits.join(' · ');
|
|
558
578
|
}
|
|
559
579
|
function helpFor(_f, mode) {
|
|
560
580
|
if (mode === 'search') {
|
|
561
581
|
return 'type to filter · ↑↓ navigate · esc exit search · ⏎ resume';
|
|
562
582
|
}
|
|
563
|
-
return 's search · r running ·
|
|
583
|
+
return 's search · r running · b bookmarks · * bookmark · f focus · c teams · t team · a agent · d device · p project · w window · tab preview · y copy-cmd · ⏎ resume · esc quit';
|
|
564
584
|
}
|
|
565
585
|
/**
|
|
566
586
|
* Launch the interactive session browser. `initial` seeds the filter (e.g.
|
|
@@ -588,10 +608,11 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
|
|
|
588
608
|
// The live index is slow (a full ps/tmux scan) and only the running filter
|
|
589
609
|
// needs it — fetch it once, lazily, the first time running is toggled on.
|
|
590
610
|
let liveCache = null;
|
|
611
|
+
const liveFor = (id) => liveCache?.get(id);
|
|
591
612
|
// Re-read every load (it's an mtime-memoized parse of one small file), so the
|
|
592
|
-
// `*` key's reload picks up the
|
|
593
|
-
//
|
|
594
|
-
let
|
|
613
|
+
// `*` key's reload picks up the bookmark it just wrote — and so does one
|
|
614
|
+
// bookmarked by another session on this machine.
|
|
615
|
+
let bookmarks = new Set();
|
|
595
616
|
// Generation guard: two quick keypresses can start overlapping loads whose
|
|
596
617
|
// SSH fan-outs settle out of order. dynamicPicker's own gen ref guards which
|
|
597
618
|
// rows become `items`, but the shared closure state below (cols / cycle pools /
|
|
@@ -652,8 +673,8 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
|
|
|
652
673
|
...rows.map((r) => safeTeamText(r.spawnedTeam)),
|
|
653
674
|
...rows.map((r) => safeTeamText(r.teamOrigin?.team)),
|
|
654
675
|
]);
|
|
655
|
-
|
|
656
|
-
const filtered = applyFilters(rows, live ?? new Map(), f, self,
|
|
676
|
+
bookmarks = listBookmarks();
|
|
677
|
+
const filtered = applyFilters(rows, live ?? new Map(), f, self, bookmarks);
|
|
657
678
|
cols = pickerColumnsFor(filtered);
|
|
658
679
|
cols.showHost = shouldShowHostColumn(f, live, filtered);
|
|
659
680
|
// Status rides the same gate as the host column: both come from the live
|
|
@@ -667,14 +688,14 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
|
|
|
667
688
|
initialFilter,
|
|
668
689
|
load,
|
|
669
690
|
keyFor: (s) => s.id,
|
|
670
|
-
labelFor: (s, q) => formatPickerLabel(s, q, cols, sshOriginTagFor(liveCache, s.id), liveHostLabel(liveCache?.get(s.id)),
|
|
691
|
+
labelFor: (s, q) => formatPickerLabel(s, q, cols, sshOriginTagFor(liveCache, s.id), liveHostLabel(liveCache?.get(s.id)), bookmarks.has(s.id), liveCache?.get(s.id)),
|
|
671
692
|
matches: sessionMatchesQuery,
|
|
672
693
|
// Lead the preview with the live status banner — the one place a `crashed` /
|
|
673
694
|
// `orphaned` session gets a sentence instead of a glyph. `buildPreview` is
|
|
674
695
|
// memoized per session, so the volatile live half is prepended here rather
|
|
675
696
|
// than baked into the cached body.
|
|
676
697
|
buildPreview: (s) => {
|
|
677
|
-
const headline = formatLiveStatusHeadline(liveCache?.get(s.id),
|
|
698
|
+
const headline = formatLiveStatusHeadline(liveCache?.get(s.id), bookmarks.has(s.id));
|
|
678
699
|
const body = buildPreview(s);
|
|
679
700
|
return headline ? `${headline}\n${body}` : body;
|
|
680
701
|
},
|
|
@@ -685,9 +706,10 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
|
|
|
685
706
|
enterHint: 'resume',
|
|
686
707
|
emptyMessage: 'No sessions match this filter.',
|
|
687
708
|
loadingMessage: local ? 'Loading…' : 'Loading (reaching other machines)…',
|
|
709
|
+
submitKeys: { f: 'focus' },
|
|
688
710
|
keyBindings: {
|
|
689
711
|
r: (f) => ({ ...f, running: !f.running }),
|
|
690
|
-
|
|
712
|
+
b: (f) => ({ ...f, bookmarks: !f.bookmarks }),
|
|
691
713
|
c: (f) => ({ ...f, teams: !f.teams }),
|
|
692
714
|
a: (f) => ({ ...f, agent: cycle(f.agent, agentsInPool) }),
|
|
693
715
|
d: (f) => ({ ...f, device: cycle(f.device, devicesInPool) }),
|
|
@@ -700,13 +722,13 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
|
|
|
700
722
|
},
|
|
701
723
|
onKey: (name, f, active, query) => {
|
|
702
724
|
if (name === '*') {
|
|
703
|
-
// Only a row with a real session id can be
|
|
725
|
+
// Only a row with a real session id can be bookmarked: a projected live row
|
|
704
726
|
// with no id is keyed by pid, which is gone the moment the process is.
|
|
705
727
|
if (!active || active.id.startsWith(LIVE_ROW_PREFIX))
|
|
706
|
-
return 'nothing to
|
|
707
|
-
const on =
|
|
708
|
-
//
|
|
709
|
-
return { flash: on ? `★
|
|
728
|
+
return 'nothing to bookmark on this row';
|
|
729
|
+
const on = toggleBookmark(active.id);
|
|
730
|
+
// Reload so the row's bookmark glyph is repainted — labels are memoized per row.
|
|
731
|
+
return { flash: on ? `★ bookmarked ${active.shortId}` : `☆ unbookmarked ${active.shortId}`, reload: true };
|
|
710
732
|
}
|
|
711
733
|
// Both cases: `hotkeyToken` hands `onKey` the literal character, and this
|
|
712
734
|
// key worked with caps lock on before it existed.
|
|
@@ -722,5 +744,12 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
|
|
|
722
744
|
});
|
|
723
745
|
if (!picked)
|
|
724
746
|
return;
|
|
747
|
+
if (picked.action === 'focus') {
|
|
748
|
+
// `focus.ts` consumes this browser for its selector path, so import only
|
|
749
|
+
// after selection to keep the shared picker/focus dependency acyclic.
|
|
750
|
+
const { focusSelectedSession } = await import('./focus.js');
|
|
751
|
+
await focusSelectedSession(picked.item, liveFor(picked.item.id), self);
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
725
754
|
await handlePickedSession({ session: picked.item, action: 'resume' });
|
|
726
755
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { SessionEvent, SessionMeta, TodoProgress } from '../lib/session/types.js';
|
|
2
|
-
import { classifyFileChanges } from '../lib/session/digest.js';
|
|
2
|
+
import { classifyFileChanges, changeCounts, toolHistogram, detectTestResult } from '../lib/session/digest.js';
|
|
3
|
+
import { extractArtifacts, extractHooks, extractLinks, extractSkills } from '../lib/session/highlights.js';
|
|
3
4
|
/**
|
|
4
5
|
* Compact checklist tally for list rows and previews (RUSH-2045).
|
|
5
6
|
* Example: `✓6/8 · A5 wiring runner`. Empty string when there is no list.
|
|
@@ -31,6 +32,12 @@ export interface SessionPickerConfig {
|
|
|
31
32
|
/** Lines the caller printed above the prompt (hidden-session footer). */
|
|
32
33
|
linesAbovePrompt?: number;
|
|
33
34
|
}
|
|
35
|
+
export declare function clearPreviewMemoryCacheForTest(): void;
|
|
36
|
+
export declare function loadSessionPreviewDigest(session: SessionMeta): {
|
|
37
|
+
digest?: SessionPreviewDigest;
|
|
38
|
+
events: SessionEvent[];
|
|
39
|
+
error?: string;
|
|
40
|
+
};
|
|
34
41
|
/** Build a cached multi-line preview string for display in the session picker. */
|
|
35
42
|
export declare function buildPreview(session: SessionMeta): string;
|
|
36
43
|
/**
|
|
@@ -65,6 +72,31 @@ export declare function extractTiming(session: Pick<SessionMeta, 'timestamp' | '
|
|
|
65
72
|
lastActiveAgo?: string;
|
|
66
73
|
duration?: string;
|
|
67
74
|
};
|
|
75
|
+
export interface SessionPreviewDigest {
|
|
76
|
+
schemaVersion: 1;
|
|
77
|
+
firstUser: string;
|
|
78
|
+
lastAssistant: string;
|
|
79
|
+
filesRead: number;
|
|
80
|
+
toolCalls: number;
|
|
81
|
+
planFile: string;
|
|
82
|
+
todos?: TodoProgress;
|
|
83
|
+
subAgentCount: number;
|
|
84
|
+
toolTags: string[];
|
|
85
|
+
changes: ReturnType<typeof changeCounts>;
|
|
86
|
+
dirs: string[];
|
|
87
|
+
repos: string[];
|
|
88
|
+
artifacts: ReturnType<typeof extractArtifacts>;
|
|
89
|
+
skills: ReturnType<typeof extractSkills>;
|
|
90
|
+
plugins: string[];
|
|
91
|
+
hooks: ReturnType<typeof extractHooks>;
|
|
92
|
+
links: ReturnType<typeof extractLinks>;
|
|
93
|
+
errorCount: number;
|
|
94
|
+
firstError?: string;
|
|
95
|
+
toolHistogram: ReturnType<typeof toolHistogram>;
|
|
96
|
+
test: ReturnType<typeof detectTestResult>;
|
|
97
|
+
}
|
|
98
|
+
/** Fold a harness-normalized event stream into the stable preview data model. */
|
|
99
|
+
export declare function buildSessionPreviewDigest(events: SessionEvent[], session: SessionMeta): SessionPreviewDigest;
|
|
68
100
|
/**
|
|
69
101
|
* Unique directories the session touched, compact and human-readable.
|
|
70
102
|
* Prefer `session.recentDirectoriesTouched` — the scan records it on the row, so
|
|
@@ -17,8 +17,10 @@ import { linearIssueUrl } from '../lib/session/linear.js';
|
|
|
17
17
|
import { extractTodoProgress, WORKTREE_RE } from '../lib/session/state.js';
|
|
18
18
|
import { renderMarkdown } from '../lib/markdown.js';
|
|
19
19
|
import { itemPicker } from '../lib/picker.js';
|
|
20
|
+
import { createMemoryCache } from '../lib/memory-cache.js';
|
|
20
21
|
import { classifyFileChanges, changeCounts, toolHistogram, detectTestResult } from '../lib/session/digest.js';
|
|
21
22
|
import { extractArtifacts, extractHooks, extractLinks, extractRepos, extractSkills } from '../lib/session/highlights.js';
|
|
23
|
+
import { getSessionPlugins, readSessionPreviewCache, writeSessionPreviewCache } from '../lib/session/db.js';
|
|
22
24
|
/** A session whose transcript FILE is on another machine (folded in over the
|
|
23
25
|
* live cross-machine fan-out): its `filePath` is on that peer's disk, so the
|
|
24
26
|
* preview can't parse it locally — it shows metadata + a "resume there" note
|
|
@@ -100,11 +102,71 @@ function sanitizeMeta(s) {
|
|
|
100
102
|
todos,
|
|
101
103
|
};
|
|
102
104
|
}
|
|
103
|
-
const previewCache =
|
|
105
|
+
const previewCache = createMemoryCache({
|
|
106
|
+
max: 256,
|
|
107
|
+
ttlMs: 5 * 60_000,
|
|
108
|
+
});
|
|
109
|
+
function previewCacheKey(session, remote) {
|
|
110
|
+
let fileStamp = '';
|
|
111
|
+
if (!remote && session.filePath) {
|
|
112
|
+
try {
|
|
113
|
+
const stat = fs.statSync(session.filePath);
|
|
114
|
+
fileStamp = `${stat.mtimeMs}:${stat.size}`;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
fileStamp = 'missing';
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// The transcript stamp invalidates derived activity. Metadata that can change
|
|
121
|
+
// independently (label/ticket/PR/live scanner enrichment) also rides the key.
|
|
122
|
+
return JSON.stringify([
|
|
123
|
+
remote ?? '', session.id, fileStamp, session.lastActivity, session.label,
|
|
124
|
+
session.topic, session.ticketId, session.prUrl, session.messageCount,
|
|
125
|
+
session.tokenCount, session.model, session.todos, session.plan,
|
|
126
|
+
session.recentDirectoriesTouched, session.skillsUsed,
|
|
127
|
+
]);
|
|
128
|
+
}
|
|
129
|
+
export function clearPreviewMemoryCacheForTest() {
|
|
130
|
+
previewCache.clear();
|
|
131
|
+
}
|
|
132
|
+
export function loadSessionPreviewDigest(session) {
|
|
133
|
+
if (!session.filePath || !fs.existsSync(session.filePath))
|
|
134
|
+
return { events: [] };
|
|
135
|
+
const safe = sanitizeMeta(session);
|
|
136
|
+
let events = [];
|
|
137
|
+
let sourceStamp;
|
|
138
|
+
try {
|
|
139
|
+
sourceStamp = fs.statSync(session.filePath);
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
return { events, error: sanitizeForTerminal(err?.message ?? String(err)) };
|
|
143
|
+
}
|
|
144
|
+
let digest = readSessionPreviewCache(session.id, {
|
|
145
|
+
fileMtimeMs: sourceStamp.mtimeMs,
|
|
146
|
+
fileSize: sourceStamp.size,
|
|
147
|
+
});
|
|
148
|
+
if (!digest) {
|
|
149
|
+
try {
|
|
150
|
+
events = parseSession(session.filePath, session.agent);
|
|
151
|
+
digest = buildSessionPreviewDigest(events, safe);
|
|
152
|
+
writeSessionPreviewCache({
|
|
153
|
+
id: session.id,
|
|
154
|
+
fileMtimeMs: sourceStamp.mtimeMs,
|
|
155
|
+
fileSize: sourceStamp.size,
|
|
156
|
+
preview: digest,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
return { events, error: sanitizeForTerminal(err?.message ?? String(err)) };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
digest.plugins = getSessionPlugins(session.id);
|
|
164
|
+
return { digest, events };
|
|
165
|
+
}
|
|
104
166
|
/** Build a cached multi-line preview string for display in the session picker. */
|
|
105
167
|
export function buildPreview(session) {
|
|
106
168
|
const remote = transcriptOnPeerOf(session);
|
|
107
|
-
const cacheKey =
|
|
169
|
+
const cacheKey = previewCacheKey(session, remote);
|
|
108
170
|
const cached = previewCache.get(cacheKey);
|
|
109
171
|
if (cached)
|
|
110
172
|
return cached;
|
|
@@ -129,18 +191,11 @@ export function buildPreview(session) {
|
|
|
129
191
|
previewCache.set(cacheKey, output);
|
|
130
192
|
return output;
|
|
131
193
|
}
|
|
132
|
-
|
|
133
|
-
let parseError;
|
|
134
|
-
try {
|
|
135
|
-
events = parseSession(session.filePath, session.agent);
|
|
136
|
-
}
|
|
137
|
-
catch (err) {
|
|
138
|
-
parseError = sanitizeForTerminal(err?.message ?? String(err));
|
|
139
|
-
}
|
|
194
|
+
const { digest, events, error: parseError } = loadSessionPreviewDigest(session);
|
|
140
195
|
const header = formatHeader(safe, events);
|
|
141
196
|
const body = parseError
|
|
142
197
|
? ' ' + chalk.red(`Failed to parse session: ${parseError}`)
|
|
143
|
-
: formatCompactPreview(
|
|
198
|
+
: formatCompactPreview(digest, safe);
|
|
144
199
|
const output = [header, '', body].filter(Boolean).join('\n');
|
|
145
200
|
previewCache.set(cacheKey, output);
|
|
146
201
|
return output;
|
|
@@ -368,7 +423,8 @@ const LAST_RESPONSE_MAX_LINES = 15;
|
|
|
368
423
|
const LAST_RESPONSE_MAX_LINES_WITH_TODOS = 8;
|
|
369
424
|
const TODOS_MAX_ITEMS = 5;
|
|
370
425
|
const DIRS_TOUCHED_MAX = 5;
|
|
371
|
-
|
|
426
|
+
/** Fold a harness-normalized event stream into the stable preview data model. */
|
|
427
|
+
export function buildSessionPreviewDigest(events, session) {
|
|
372
428
|
let firstUser = '';
|
|
373
429
|
let lastAssistant = '';
|
|
374
430
|
const filesRead = new Set();
|
|
@@ -436,9 +492,35 @@ function formatCompactPreview(events, session) {
|
|
|
436
492
|
// checklist write); fall back to SessionMeta.todos for rows where the scan/
|
|
437
493
|
// fan-out attached progress but the event stream has no TodoWrite yet.
|
|
438
494
|
const todos = latestTodos ?? session.todos;
|
|
439
|
-
// Digest signals folded into the preview: change lifecycle, tool mix, tests.
|
|
440
495
|
const changes = classifyFileChanges(events);
|
|
441
496
|
const chg = changeCounts(changes);
|
|
497
|
+
const errorEvents = events.filter(e => e.type === 'error');
|
|
498
|
+
return {
|
|
499
|
+
schemaVersion: 1,
|
|
500
|
+
firstUser,
|
|
501
|
+
lastAssistant,
|
|
502
|
+
filesRead: filesRead.size,
|
|
503
|
+
toolCalls,
|
|
504
|
+
planFile,
|
|
505
|
+
todos,
|
|
506
|
+
subAgentCount,
|
|
507
|
+
toolTags: [...toolTags],
|
|
508
|
+
changes: chg,
|
|
509
|
+
dirs: directoriesTouched(session, events, changes),
|
|
510
|
+
repos: extractRepos(events, session.cwd),
|
|
511
|
+
artifacts: extractArtifacts(changes),
|
|
512
|
+
skills: extractSkills(events),
|
|
513
|
+
plugins: getSessionPlugins(session.id),
|
|
514
|
+
hooks: extractHooks(events),
|
|
515
|
+
links: extractLinks(events),
|
|
516
|
+
errorCount: errorEvents.length,
|
|
517
|
+
firstError: errorEvents[0]?.tool,
|
|
518
|
+
toolHistogram: toolHistogram(toolCounts, 4),
|
|
519
|
+
test: detectTestResult(events),
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
function formatCompactPreview(digest, session) {
|
|
523
|
+
const { firstUser, lastAssistant, filesRead, toolCalls, planFile, todos, subAgentCount, toolTags, changes: chg, dirs, repos, artifacts, skills, plugins, hooks, links, errorCount, firstError, toolHistogram: hist, test, } = digest;
|
|
442
524
|
const lines = [];
|
|
443
525
|
const termWidth = process.stdout.columns || 80;
|
|
444
526
|
// Originating user prompt (first non-system user turn).
|
|
@@ -464,12 +546,10 @@ function formatCompactPreview(events, session) {
|
|
|
464
546
|
// Recent activity = directories touched (not raw tool calls). Prefer a
|
|
465
547
|
// parser-supplied dirsTouched when present; else derive from event paths.
|
|
466
548
|
// Width-capped: a long Dirs line used to wrap and swamp the whole pane.
|
|
467
|
-
const dirs = directoriesTouched(session, events, changes);
|
|
468
549
|
if (dirs.length) {
|
|
469
550
|
lines.push(chalk.cyan('Dirs: ') + joinWidthCapped(dirs, termWidth - 12));
|
|
470
551
|
}
|
|
471
552
|
// Repos worked in (basename of each `.git` root under the touched paths).
|
|
472
|
-
const repos = extractRepos(events, session.cwd);
|
|
473
553
|
if (repos.length) {
|
|
474
554
|
lines.push(chalk.cyan('Repos: ') + chalk.white(repos.slice(0, 4).join(chalk.gray(' · '))));
|
|
475
555
|
}
|
|
@@ -487,8 +567,8 @@ function formatCompactPreview(events, session) {
|
|
|
487
567
|
].filter(Boolean).join(' ');
|
|
488
568
|
activity.push(`${parts} ${chalk.gray('changed')}`);
|
|
489
569
|
}
|
|
490
|
-
if (filesRead
|
|
491
|
-
activity.push(chalk.gray(`${filesRead
|
|
570
|
+
if (filesRead)
|
|
571
|
+
activity.push(chalk.gray(`${filesRead} read`));
|
|
492
572
|
if (toolCalls)
|
|
493
573
|
activity.push(chalk.gray(`${toolCalls} tool${toolCalls === 1 ? '' : 's'}`));
|
|
494
574
|
if (activity.length) {
|
|
@@ -496,38 +576,35 @@ function formatCompactPreview(events, session) {
|
|
|
496
576
|
}
|
|
497
577
|
// Documents the session produced (`.agents/artifacts|plans|reports`, other
|
|
498
578
|
// *.md/*.html creations) — the files a human browses later, named + clickable.
|
|
499
|
-
const artifacts = extractArtifacts(changes);
|
|
500
579
|
if (artifacts.length) {
|
|
501
580
|
const shown = artifacts.slice(0, 5).map(a => linkPath(a.path, a.basename));
|
|
502
581
|
const more = artifacts.length > 5 ? chalk.gray(` · +${artifacts.length - 5} more`) : '';
|
|
503
582
|
lines.push(chalk.cyan('Artifacts: ') + shown.join(chalk.gray(' · ')) + more);
|
|
504
583
|
}
|
|
505
584
|
// Skills invoked (plugin skills included — they ride the same Skill tool).
|
|
506
|
-
const skills = extractSkills(events);
|
|
507
585
|
if (skills.length) {
|
|
508
586
|
const shown = skills.slice(0, 5).map(s => chalk.white(s.name) + (s.count > 1 ? chalk.gray(` ×${s.count}`) : ''));
|
|
509
587
|
const more = skills.length > 5 ? chalk.gray(` · +${skills.length - 5} more`) : '';
|
|
510
588
|
lines.push(chalk.cyan('Skills: ') + shown.join(chalk.gray(' · ')) + more);
|
|
511
589
|
}
|
|
590
|
+
if (plugins.length) {
|
|
591
|
+
lines.push(chalk.cyan('Plugins: ') + chalk.white(plugins.slice(0, 5).join(chalk.gray(' · '))));
|
|
592
|
+
}
|
|
512
593
|
// Hooks fired (Claude transcripts record firings; other harnesses don't).
|
|
513
|
-
const hooks = extractHooks(events);
|
|
514
594
|
if (hooks.length) {
|
|
515
595
|
const shown = hooks.slice(0, 4).map(h => chalk.white(h.name) + (h.count > 1 ? chalk.gray(` ×${h.count}`) : '') + (h.failed ? chalk.red(` (${h.failed} failed)`) : ''));
|
|
516
596
|
const more = hooks.length > 4 ? chalk.gray(` · +${hooks.length - 4} more`) : '';
|
|
517
597
|
lines.push(chalk.cyan('Hooks: ') + shown.join(chalk.gray(' · ')) + more);
|
|
518
598
|
}
|
|
519
599
|
// Links mentioned in the conversation — clickable (OSC 8), tracker-classified.
|
|
520
|
-
const links = extractLinks(events);
|
|
521
600
|
if (links.length) {
|
|
522
601
|
const shown = links.slice(0, 5).map(l => chalk.blue(linkUrl(l.url, l.label)));
|
|
523
602
|
const more = links.length > 5 ? chalk.gray(` · +${links.length - 5} more`) : '';
|
|
524
603
|
lines.push(chalk.cyan('Links: ') + shown.join(chalk.gray(' · ')) + more);
|
|
525
604
|
}
|
|
526
605
|
// Error tally, mirroring the full summary's Errors section in one line.
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
const first = errorEvents[0].tool || 'unknown';
|
|
530
|
-
lines.push(chalk.cyan('Errors: ') + chalk.red(`${errorEvents.length} failure${errorEvents.length === 1 ? '' : 's'}`) + chalk.gray(` — first: ${first}`));
|
|
606
|
+
if (errorCount) {
|
|
607
|
+
lines.push(chalk.cyan('Errors: ') + chalk.red(`${errorCount} failure${errorCount === 1 ? '' : 's'}`) + chalk.gray(` — first: ${firstError || 'unknown'}`));
|
|
531
608
|
}
|
|
532
609
|
const metadata = [
|
|
533
610
|
...toolTags,
|
|
@@ -537,12 +614,10 @@ function formatCompactPreview(events, session) {
|
|
|
537
614
|
lines.push(chalk.cyan('Meta: ') + chalk.gray(metadata.join(' · ')));
|
|
538
615
|
}
|
|
539
616
|
// Tool mix (top 4) — what kind of work this was.
|
|
540
|
-
const hist = toolHistogram(toolCounts, 4);
|
|
541
617
|
if (hist.length) {
|
|
542
618
|
lines.push(chalk.cyan('Tools: ') + chalk.gray(hist.map(h => `${h.tool} ${h.count}`).join(' · ')));
|
|
543
619
|
}
|
|
544
620
|
// Last test/build verdict.
|
|
545
|
-
const test = detectTestResult(events);
|
|
546
621
|
if (test?.ok) {
|
|
547
622
|
const bits = [
|
|
548
623
|
test.passed !== undefined ? chalk.green(`${test.passed} pass`) : '',
|
|
@@ -95,7 +95,7 @@ async function statsAction(cmd) {
|
|
|
95
95
|
// The parent `sessions` command owns --agent/--project/--plugin/--since/--json
|
|
96
96
|
// and keeps parsing them past the subcommand name (it has a positional
|
|
97
97
|
// [query]), binding them to the PARENT — so read the merged view, not the
|
|
98
|
-
// action's own options. Same reason sessions-
|
|
98
|
+
// action's own options. Same reason sessions-bookmark.ts uses optsWithGlobals.
|
|
99
99
|
const opts = cmd.optsWithGlobals();
|
|
100
100
|
const kind = normalizeKind(opts.kind);
|
|
101
101
|
const sinceMs = opts.since ? parseTimeFilter(opts.since) : undefined;
|
|
@@ -7,6 +7,9 @@ import { type PickedSession } from './sessions-picker.js';
|
|
|
7
7
|
import { type ToolSearchEnvelope, type ToolProgramCountEnvelope } from '../lib/session/tool-index.js';
|
|
8
8
|
interface SessionFilterOptions {
|
|
9
9
|
agent?: string;
|
|
10
|
+
version?: string;
|
|
11
|
+
/** Internal Commander spelling after index.ts disambiguates root --version. */
|
|
12
|
+
sessionVersion?: string;
|
|
10
13
|
project?: string;
|
|
11
14
|
all?: boolean;
|
|
12
15
|
teams?: boolean;
|
|
@@ -56,8 +59,8 @@ interface SessionsOptions extends SessionFilterOptions {
|
|
|
56
59
|
abandoned?: boolean;
|
|
57
60
|
queued?: boolean;
|
|
58
61
|
unknown?: boolean;
|
|
59
|
-
/** Show only
|
|
60
|
-
|
|
62
|
+
/** Show only bookmarked sessions — the `b` key's flag twin. */
|
|
63
|
+
bookmarks?: boolean;
|
|
61
64
|
/** Enrich the listing with live glyphs/preview for running rows. Default on;
|
|
62
65
|
* `--no-live` sets this false. Commander's `--no-` convention. */
|
|
63
66
|
live?: boolean;
|
|
@@ -94,6 +97,12 @@ interface SessionsOptions extends SessionFilterOptions {
|
|
|
94
97
|
/** Only sessions that used a skill/command owned by this plugin (#12). */
|
|
95
98
|
plugin?: string;
|
|
96
99
|
}
|
|
100
|
+
type InstalledVersionsForAgent = (agent: SessionAgentId) => string[];
|
|
101
|
+
/**
|
|
102
|
+
* Treat a positional `agent@version` as a structured filter only when it names
|
|
103
|
+
* a real installed version. Everything else remains ordinary free-text search.
|
|
104
|
+
*/
|
|
105
|
+
export declare function parseInstalledAgentVersionQuery(query: string | undefined, installedVersions?: InstalledVersionsForAgent): string | undefined;
|
|
97
106
|
/**
|
|
98
107
|
* Strip terminal/harness noise from a preview so the column stays a single line
|
|
99
108
|
* of plain prose: OSC title escapes, CSI/SGR ANSI, and the harness wrapper tags
|
|
@@ -390,6 +399,8 @@ export declare function gatherActiveSessions(opts?: {
|
|
|
390
399
|
sessions: ActiveSession[];
|
|
391
400
|
remoteDeviceCount: number;
|
|
392
401
|
}>;
|
|
402
|
+
/** Apply the routine selector to enriched live rows for every non-browser active view. */
|
|
403
|
+
export declare function filterActiveSessionsByRoutine(sessions: ActiveSession[], routine: boolean | string | undefined): ActiveSession[];
|
|
393
404
|
export type LiveStatusFilter = 'working' | 'idle' | 'waiting' | 'orphaned' | 'crashed' | 'closed' | 'abandoned' | 'queued' | 'unknown';
|
|
394
405
|
/** Match the status words users see, preserving activity's richer working signal. */
|
|
395
406
|
export declare function matchesLiveStatus(session: ActiveSession, status: LiveStatusFilter): boolean;
|
|
@@ -431,6 +442,8 @@ export declare function renderSessionPreview(query: string, scope: {
|
|
|
431
442
|
agent?: string;
|
|
432
443
|
project?: string;
|
|
433
444
|
local?: boolean;
|
|
445
|
+
hosts?: string[];
|
|
446
|
+
json?: boolean;
|
|
434
447
|
}): Promise<void>;
|
|
435
448
|
/**
|
|
436
449
|
* The one-line live status banner shown above a session preview: the glyph, the
|
|
@@ -442,7 +455,7 @@ export declare function renderSessionPreview(query: string, scope: {
|
|
|
442
455
|
* reads "orphan" and knows it means "still running in tmux with no window
|
|
443
456
|
* attached", so those two spell it out.
|
|
444
457
|
*/
|
|
445
|
-
export declare function formatLiveStatusHeadline(live: ActiveSession | undefined,
|
|
458
|
+
export declare function formatLiveStatusHeadline(live: ActiveSession | undefined, bookmarked?: boolean): string;
|
|
446
459
|
/** Merge local and peer envelopes without changing the versioned JSON shape. */
|
|
447
460
|
export declare function mergeToolSearchEnvelopes(local: ToolSearchEnvelope, remotes: ToolSearchEnvelope[]): ToolSearchEnvelope;
|
|
448
461
|
export declare function mergeToolProgramCountEnvelopes(local: ToolProgramCountEnvelope, remotes: ToolProgramCountEnvelope[]): ToolProgramCountEnvelope;
|
|
@@ -476,7 +489,7 @@ export declare function teamBadge(session: SessionMeta): {
|
|
|
476
489
|
* (tracker/PR ref, pulled out of the badge blob so refs align) is only rendered
|
|
477
490
|
* when `showTicket` — otherwise a listing with no refs would waste a column of
|
|
478
491
|
* dashes and needlessly truncate the topic. Worktree stays a trailing badge. */
|
|
479
|
-
export declare function flatSessionRow(session: SessionMeta, live?: ActiveSession, showTicket?: boolean, cols?: PickerColumns,
|
|
492
|
+
export declare function flatSessionRow(session: SessionMeta, live?: ActiveSession, showTicket?: boolean, cols?: PickerColumns, bookmarked?: boolean): string;
|
|
480
493
|
/**
|
|
481
494
|
* Live-session index for enriching the default listing, or undefined when
|
|
482
495
|
* enrichment is off (`--no-live`) or irrelevant (`--json`, which serializes
|
|
@@ -560,11 +573,11 @@ export interface PickerColumns {
|
|
|
560
573
|
*/
|
|
561
574
|
showHost?: boolean;
|
|
562
575
|
/**
|
|
563
|
-
* Render the
|
|
576
|
+
* Render the bookmark marker column. Like every other conditional column here,
|
|
564
577
|
* it earns its 2 cells only when some row in the pool is actually starred — a
|
|
565
|
-
* user who has never
|
|
578
|
+
* user who has never bookmarked anything pays nothing for the feature.
|
|
566
579
|
*/
|
|
567
|
-
|
|
580
|
+
showBookmark?: boolean;
|
|
568
581
|
/**
|
|
569
582
|
* Render the live status column (`working` / `waiting` / `orphan` / `crashed`).
|
|
570
583
|
* Live-only, gated the same way as {@link showHost}: it comes from the
|
|
@@ -601,7 +614,7 @@ export declare function pickerColumnsFor(sessions: SessionMeta[]): PickerColumns
|
|
|
601
614
|
* resolvable host (cloud rows, an unreadable process env).
|
|
602
615
|
*/
|
|
603
616
|
export declare function liveHostLabel(a: ActiveSession | undefined): string;
|
|
604
|
-
export declare function formatPickerLabel(s: SessionMeta, query: string, cols?: PickerColumns, ssh?: SshOriginTag, host?: string,
|
|
617
|
+
export declare function formatPickerLabel(s: SessionMeta, query: string, cols?: PickerColumns, ssh?: SshOriginTag, host?: string, bookmarked?: boolean, live?: ActiveSession): string;
|
|
605
618
|
/**
|
|
606
619
|
* Pick a hint to show above the picker. Deterministic (keys off the pool size)
|
|
607
620
|
* so it stays fixed across the picker's re-renders within a single run.
|