@phnx-labs/agents-cli 1.20.89 → 1.20.91

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.
Files changed (114) hide show
  1. package/CHANGELOG.md +361 -0
  2. package/README.md +6 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/exec.js +7 -1
  5. package/dist/commands/feed.js +77 -4
  6. package/dist/commands/harness.d.ts +27 -0
  7. package/dist/commands/harness.js +120 -13
  8. package/dist/commands/hooks.js +22 -6
  9. package/dist/commands/perf.d.ts +14 -0
  10. package/dist/commands/perf.js +221 -0
  11. package/dist/commands/profiles.d.ts +3 -0
  12. package/dist/commands/profiles.js +1 -1
  13. package/dist/commands/routines.d.ts +19 -0
  14. package/dist/commands/routines.js +58 -30
  15. package/dist/commands/secrets.d.ts +52 -4
  16. package/dist/commands/secrets.js +234 -37
  17. package/dist/commands/send.d.ts +5 -1
  18. package/dist/commands/send.js +1 -1
  19. package/dist/commands/sessions-browser.d.ts +4 -0
  20. package/dist/commands/sessions-browser.js +51 -9
  21. package/dist/commands/sessions-favorite.d.ts +20 -0
  22. package/dist/commands/sessions-favorite.js +120 -0
  23. package/dist/commands/sessions-picker.js +70 -1
  24. package/dist/commands/sessions.d.ts +103 -20
  25. package/dist/commands/sessions.js +356 -62
  26. package/dist/commands/setup-secrets.d.ts +7 -0
  27. package/dist/commands/setup-secrets.js +12 -9
  28. package/dist/commands/versions.js +12 -4
  29. package/dist/commands/view.d.ts +14 -1
  30. package/dist/commands/view.js +103 -128
  31. package/dist/index.js +18 -3
  32. package/dist/lib/activity.d.ts +11 -1
  33. package/dist/lib/activity.js +1 -0
  34. package/dist/lib/agents.d.ts +4 -2
  35. package/dist/lib/agents.js +21 -6
  36. package/dist/lib/catchup.d.ts +105 -0
  37. package/dist/lib/catchup.js +160 -0
  38. package/dist/lib/channels/providers/desktop.d.ts +49 -0
  39. package/dist/lib/channels/providers/desktop.js +132 -0
  40. package/dist/lib/channels/providers/index.js +2 -0
  41. package/dist/lib/daemon.js +74 -13
  42. package/dist/lib/events.d.ts +12 -0
  43. package/dist/lib/events.js +122 -9
  44. package/dist/lib/exec.js +10 -0
  45. package/dist/lib/feed-broadcast.d.ts +47 -0
  46. package/dist/lib/feed-broadcast.js +65 -1
  47. package/dist/lib/feed-post.d.ts +10 -0
  48. package/dist/lib/feed-post.js +1 -1
  49. package/dist/lib/feed.d.ts +47 -1
  50. package/dist/lib/feed.js +38 -0
  51. package/dist/lib/hooks/cache.d.ts +2 -0
  52. package/dist/lib/hooks/cache.js +24 -4
  53. package/dist/lib/hosts/dispatch.js +19 -1
  54. package/dist/lib/hq/floor.js +12 -0
  55. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  56. package/dist/lib/overdue.d.ts +14 -0
  57. package/dist/lib/overdue.js +37 -1
  58. package/dist/lib/perf/db.d.ts +25 -0
  59. package/dist/lib/perf/db.js +290 -0
  60. package/dist/lib/perf/spool.d.ts +18 -0
  61. package/dist/lib/perf/spool.js +79 -0
  62. package/dist/lib/perf/types.d.ts +45 -0
  63. package/dist/lib/perf/types.js +2 -0
  64. package/dist/lib/picker.d.ts +27 -2
  65. package/dist/lib/picker.js +71 -7
  66. package/dist/lib/profiles.d.ts +48 -0
  67. package/dist/lib/profiles.js +67 -0
  68. package/dist/lib/rotate.d.ts +24 -2
  69. package/dist/lib/rotate.js +63 -6
  70. package/dist/lib/routines-project.js +6 -0
  71. package/dist/lib/routines.d.ts +30 -1
  72. package/dist/lib/routines.js +11 -0
  73. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  74. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  75. package/dist/lib/secrets/list-filter.d.ts +94 -0
  76. package/dist/lib/secrets/list-filter.js +245 -0
  77. package/dist/lib/session/active.d.ts +109 -3
  78. package/dist/lib/session/active.js +269 -13
  79. package/dist/lib/session/db.d.ts +14 -0
  80. package/dist/lib/session/db.js +35 -0
  81. package/dist/lib/session/digest.d.ts +7 -0
  82. package/dist/lib/session/digest.js +29 -1
  83. package/dist/lib/session/discover.d.ts +1 -2
  84. package/dist/lib/session/discover.js +7 -24
  85. package/dist/lib/session/favorites.d.ts +39 -0
  86. package/dist/lib/session/favorites.js +101 -0
  87. package/dist/lib/session/highlights.d.ts +82 -0
  88. package/dist/lib/session/highlights.js +251 -0
  89. package/dist/lib/session/host-link.d.ts +68 -0
  90. package/dist/lib/session/host-link.js +64 -0
  91. package/dist/lib/session/parse.js +23 -1
  92. package/dist/lib/session/presence.d.ts +85 -0
  93. package/dist/lib/session/presence.js +150 -0
  94. package/dist/lib/session/relative-time.d.ts +14 -0
  95. package/dist/lib/session/relative-time.js +36 -0
  96. package/dist/lib/session/remote-list.d.ts +10 -0
  97. package/dist/lib/session/remote-list.js +47 -9
  98. package/dist/lib/session/render.d.ts +7 -0
  99. package/dist/lib/session/render.js +87 -17
  100. package/dist/lib/session/types.d.ts +4 -1
  101. package/dist/lib/startup/command-registry.d.ts +1 -0
  102. package/dist/lib/startup/command-registry.js +2 -0
  103. package/dist/lib/state.d.ts +9 -0
  104. package/dist/lib/state.js +11 -0
  105. package/dist/lib/tmux/binary.d.ts +7 -0
  106. package/dist/lib/tmux/binary.js +11 -1
  107. package/dist/lib/types.d.ts +4 -3
  108. package/dist/lib/usage-backoff.d.ts +29 -0
  109. package/dist/lib/usage-backoff.js +165 -0
  110. package/dist/lib/usage.d.ts +112 -5
  111. package/dist/lib/usage.js +464 -46
  112. package/dist/lib/watchdog/runner.d.ts +13 -0
  113. package/dist/lib/watchdog/runner.js +16 -1
  114. package/package.json +3 -1
@@ -0,0 +1,85 @@
1
+ export type PresenceStatus = 'connected' | 'disconnected';
2
+ export type PresenceLocation = 'local' | 'ssh';
3
+ /** A session as OBSERVED by one tick's active scan — the pure reconcile input. */
4
+ export interface ObservedSession {
5
+ sessionId: string;
6
+ agent: string;
7
+ /** `local` when the session runs on this box, `ssh` when it's on a peer. */
8
+ location: PresenceLocation;
9
+ /** The device/host the session runs on (this box's hostname, or the peer). */
10
+ device: string;
11
+ /** Transport the session's provenance reports (`local` | `ssh` | ...). */
12
+ transport: string;
13
+ /** True for an interactive (terminal/tmux) session — a drop is reconnect-worthy;
14
+ * false for a headless remote continuation, where a drop means keep-alive. */
15
+ interactive: boolean;
16
+ }
17
+ export interface PresenceRecord {
18
+ sessionId: string;
19
+ agent: string;
20
+ location: PresenceLocation;
21
+ device: string;
22
+ transport: string;
23
+ interactive: boolean;
24
+ /** ms of the last tick this session was observed active. */
25
+ lastSeenMs: number;
26
+ status: PresenceStatus;
27
+ }
28
+ /** What the tick should do about a session that just flipped `disconnected`. */
29
+ export type PresenceAction = 'reconnect-nudge' | 'keep-alive' | 'none';
30
+ export interface PresenceTransition {
31
+ record: PresenceRecord;
32
+ from: PresenceStatus;
33
+ to: PresenceStatus;
34
+ action: PresenceAction;
35
+ }
36
+ /** A `disconnected` record older than this is pruned — the session is gone for
37
+ * good, not merely between clients. Kept generous so a long reconnect window
38
+ * (the reconnect loop's own bounded backoff) never drops a record mid-recovery. */
39
+ export declare const PRESENCE_TTL_MS: number;
40
+ /** `presence.json` under the watchdog state dir. `dir` is the watchdog state dir
41
+ * (the tick passes its own, overridable in tests); default is the real one. */
42
+ export declare function presenceFilePath(dir?: string): string;
43
+ export declare function loadPresence(dir?: string): Record<string, PresenceRecord>;
44
+ export declare function savePresence(map: Record<string, PresenceRecord>, dir?: string): void;
45
+ /** The subset of an `ActiveSession` the presence adapter reads (structural, so
46
+ * this module doesn't import the heavy active-session graph). */
47
+ export interface ActiveSessionLike {
48
+ sessionId?: string;
49
+ kind: string;
50
+ /** 'terminal' (interactive) | 'headless' | 'teams' | 'cloud' | ... */
51
+ context: string;
52
+ /** Set for a peer session by the fleet fan-out (`gatherRemoteActive`). */
53
+ machine?: string;
54
+ provenance?: {
55
+ transport?: string;
56
+ };
57
+ }
58
+ /**
59
+ * Map this tick's active sessions to presence observations. A session on a peer
60
+ * carries `machine` (from the fleet fan-out) → `location: 'ssh'`; a local one →
61
+ * `'local'`. `interactive` is true only for a `terminal` context (a tmux/terminal
62
+ * session whose drop is reconnect-worthy) — headless/teams/cloud are not.
63
+ * Sessions with no id are skipped (they can't be tracked or addressed).
64
+ */
65
+ export declare function observedFromActive(sessions: ActiveSessionLike[], selfHost?: string): ObservedSession[];
66
+ /** The action for a session that just flipped to `disconnected`: an interactive
67
+ * session wants a reconnect nudge; a headless remote wants keep-alive; a local
68
+ * headless that vanished is simply gone (no action). */
69
+ export declare function actionFor(record: PresenceRecord): PresenceAction;
70
+ /**
71
+ * Pure state machine. Given the prior store, the sessions observed THIS tick, and
72
+ * `nowMs`, return the next store plus the set of status transitions.
73
+ *
74
+ * - observed now -> `connected`, lastSeen = now (record refreshed)
75
+ * - tracked but absent now -> `disconnected` (lastSeen kept from prior tick)
76
+ * - `disconnected` older than TTL -> pruned (gone for good)
77
+ *
78
+ * `transitions` carries only sessions whose status actually changed, each with
79
+ * the action the tick should take — so the caller never re-nudges a session that
80
+ * was already disconnected last tick.
81
+ */
82
+ export declare function reconcilePresence(prev: Record<string, PresenceRecord>, observed: ObservedSession[], nowMs: number): {
83
+ next: Record<string, PresenceRecord>;
84
+ transitions: PresenceTransition[];
85
+ };
@@ -0,0 +1,150 @@
1
+ /**
2
+ * RUSH-2007 Layer C — per-session presence tracking, folded into the `agents
3
+ * watchdog` tick (NOT a revived daemon).
4
+ *
5
+ * Each tick the watchdog observes the active sessions across the fleet
6
+ * (`gatherRemoteActive` + the local scan). This module persists one record per
7
+ * session — `{location, device, transport, lastSeen, status}` — and DERIVES
8
+ * `connected` / `disconnected` by diffing consecutive observations: a session
9
+ * present this tick is `connected`; one that was tracked but is now absent (its
10
+ * peer went unreachable, or the interactive client dropped) flips to
11
+ * `disconnected` while its record — and the transition — is surfaced so the tick
12
+ * can act (an interactive drop is a reconnect-nudge candidate; a headless remote
13
+ * is a keep-alive). The store is honest across a crash: it only reflects what the
14
+ * last scan actually saw, never an asserted state.
15
+ *
16
+ * Store: `~/.agents/.cache/state/watchdog/presence.json` — sibling of the tick's
17
+ * existing `nudges/flags/last-tick` files. Best-effort: an unreadable/corrupt
18
+ * file degrades to an empty store, never throws into the tick loop.
19
+ */
20
+ import fs from 'node:fs';
21
+ import os from 'node:os';
22
+ import path from 'node:path';
23
+ import { getRuntimeStateDir } from '../state.js';
24
+ /** A `disconnected` record older than this is pruned — the session is gone for
25
+ * good, not merely between clients. Kept generous so a long reconnect window
26
+ * (the reconnect loop's own bounded backoff) never drops a record mid-recovery. */
27
+ export const PRESENCE_TTL_MS = 30 * 60_000; // 30 minutes
28
+ /** `presence.json` under the watchdog state dir. `dir` is the watchdog state dir
29
+ * (the tick passes its own, overridable in tests); default is the real one. */
30
+ export function presenceFilePath(dir) {
31
+ return path.join(dir ?? path.join(getRuntimeStateDir(), 'watchdog'), 'presence.json');
32
+ }
33
+ export function loadPresence(dir) {
34
+ let raw;
35
+ try {
36
+ raw = fs.readFileSync(presenceFilePath(dir), 'utf8');
37
+ }
38
+ catch {
39
+ return {};
40
+ }
41
+ try {
42
+ const parsed = JSON.parse(raw);
43
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
44
+ return parsed;
45
+ }
46
+ }
47
+ catch {
48
+ /* corrupt — degrade to empty, never throw into the tick */
49
+ }
50
+ return {};
51
+ }
52
+ export function savePresence(map, dir) {
53
+ try {
54
+ const file = presenceFilePath(dir);
55
+ fs.mkdirSync(path.dirname(file), { recursive: true });
56
+ fs.writeFileSync(file, JSON.stringify(map), 'utf8');
57
+ }
58
+ catch {
59
+ /* best-effort; a failed write just re-derives next tick */
60
+ }
61
+ }
62
+ /**
63
+ * Map this tick's active sessions to presence observations. A session on a peer
64
+ * carries `machine` (from the fleet fan-out) → `location: 'ssh'`; a local one →
65
+ * `'local'`. `interactive` is true only for a `terminal` context (a tmux/terminal
66
+ * session whose drop is reconnect-worthy) — headless/teams/cloud are not.
67
+ * Sessions with no id are skipped (they can't be tracked or addressed).
68
+ */
69
+ export function observedFromActive(sessions, selfHost = os.hostname()) {
70
+ const out = [];
71
+ for (const s of sessions) {
72
+ if (!s.sessionId)
73
+ continue;
74
+ const location = s.machine ? 'ssh' : 'local';
75
+ out.push({
76
+ sessionId: s.sessionId,
77
+ agent: s.kind,
78
+ location,
79
+ device: s.machine ?? selfHost,
80
+ transport: s.provenance?.transport ?? location,
81
+ interactive: s.context === 'terminal',
82
+ });
83
+ }
84
+ return out;
85
+ }
86
+ /** The action for a session that just flipped to `disconnected`: an interactive
87
+ * session wants a reconnect nudge; a headless remote wants keep-alive; a local
88
+ * headless that vanished is simply gone (no action). */
89
+ export function actionFor(record) {
90
+ if (record.status !== 'disconnected')
91
+ return 'none';
92
+ if (record.interactive)
93
+ return 'reconnect-nudge';
94
+ if (record.location === 'ssh')
95
+ return 'keep-alive';
96
+ return 'none';
97
+ }
98
+ /**
99
+ * Pure state machine. Given the prior store, the sessions observed THIS tick, and
100
+ * `nowMs`, return the next store plus the set of status transitions.
101
+ *
102
+ * - observed now -> `connected`, lastSeen = now (record refreshed)
103
+ * - tracked but absent now -> `disconnected` (lastSeen kept from prior tick)
104
+ * - `disconnected` older than TTL -> pruned (gone for good)
105
+ *
106
+ * `transitions` carries only sessions whose status actually changed, each with
107
+ * the action the tick should take — so the caller never re-nudges a session that
108
+ * was already disconnected last tick.
109
+ */
110
+ export function reconcilePresence(prev, observed, nowMs) {
111
+ const next = {};
112
+ const transitions = [];
113
+ const observedIds = new Set(observed.map((o) => o.sessionId));
114
+ // 1. Everything observed this tick is connected.
115
+ for (const o of observed) {
116
+ const before = prev[o.sessionId];
117
+ const record = {
118
+ sessionId: o.sessionId,
119
+ agent: o.agent,
120
+ location: o.location,
121
+ device: o.device,
122
+ transport: o.transport,
123
+ interactive: o.interactive,
124
+ lastSeenMs: nowMs,
125
+ status: 'connected',
126
+ };
127
+ next[o.sessionId] = record;
128
+ if (before && before.status === 'disconnected') {
129
+ transitions.push({ record, from: 'disconnected', to: 'connected', action: 'none' });
130
+ }
131
+ }
132
+ // 2. Tracked-but-absent sessions flip to disconnected (or prune past TTL).
133
+ for (const [id, before] of Object.entries(prev)) {
134
+ if (observedIds.has(id))
135
+ continue;
136
+ if (nowMs - before.lastSeenMs > PRESENCE_TTL_MS)
137
+ continue; // prune: gone for good
138
+ const record = { ...before, status: 'disconnected' };
139
+ next[id] = record;
140
+ if (before.status === 'connected') {
141
+ transitions.push({
142
+ record,
143
+ from: 'connected',
144
+ to: 'disconnected',
145
+ action: actionFor(record),
146
+ });
147
+ }
148
+ }
149
+ return { next, transitions };
150
+ }
@@ -5,3 +5,17 @@
5
5
  * `remote.ts`, so a back-import would cycle.
6
6
  */
7
7
  export declare function formatRelativeTime(isoTimestamp: string): string;
8
+ /**
9
+ * Parse a time filter string (relative like '7d' or an ISO timestamp) into epoch
10
+ * milliseconds. Backs `--since` on `sessions`, `cost`, `teams`, and `output`, and
11
+ * `--unused` on `secrets list`.
12
+ *
13
+ * It lives here, in a module with no imports, rather than in `discover.ts` where
14
+ * it started: `discover.ts` loads `../sqlite.js`, so importing this one function
15
+ * from there pulls `node:sqlite` into the caller's module graph and Node prints
16
+ * `ExperimentalWarning: SQLite …` on stderr. That is invisible in a command that
17
+ * already touches the session DB, and a broken contract in one that doesn't —
18
+ * `monitors --json` asserts a clean stderr. `discover.ts` re-exports it, so
19
+ * existing importers are unaffected.
20
+ */
21
+ export declare function parseTimeFilter(input: string): number;
@@ -30,3 +30,39 @@ export function formatRelativeTime(isoTimestamp) {
30
30
  ? label
31
31
  : `${label} '${String(d.getFullYear()).slice(-2)}`;
32
32
  }
33
+ /**
34
+ * Parse a time filter string (relative like '7d' or an ISO timestamp) into epoch
35
+ * milliseconds. Backs `--since` on `sessions`, `cost`, `teams`, and `output`, and
36
+ * `--unused` on `secrets list`.
37
+ *
38
+ * It lives here, in a module with no imports, rather than in `discover.ts` where
39
+ * it started: `discover.ts` loads `../sqlite.js`, so importing this one function
40
+ * from there pulls `node:sqlite` into the caller's module graph and Node prints
41
+ * `ExperimentalWarning: SQLite …` on stderr. That is invisible in a command that
42
+ * already touches the session DB, and a broken contract in one that doesn't —
43
+ * `monitors --json` asserts a clean stderr. `discover.ts` re-exports it, so
44
+ * existing importers are unaffected.
45
+ */
46
+ export function parseTimeFilter(input) {
47
+ // Units: m=minute, h=hour, d=day, w=week, mo=month(30d), y=year(365d). `mo`
48
+ // must precede the single-letter alternatives so "1mo" isn't read as "1m"+"o".
49
+ const relativeMatch = input.match(/^(\d+)(mo|[mhdwy])$/i);
50
+ if (relativeMatch) {
51
+ const value = parseInt(relativeMatch[1], 10);
52
+ const unit = relativeMatch[2].toLowerCase();
53
+ if (unit === 'm')
54
+ return Date.now() - value * 60_000;
55
+ if (unit === 'h')
56
+ return Date.now() - value * 3_600_000;
57
+ if (unit === 'd')
58
+ return Date.now() - value * 86_400_000;
59
+ if (unit === 'w')
60
+ return Date.now() - value * 7 * 86_400_000;
61
+ if (unit === 'mo')
62
+ return Date.now() - value * 30 * 86_400_000;
63
+ if (unit === 'y')
64
+ return Date.now() - value * 365 * 86_400_000;
65
+ }
66
+ const ts = new Date(input).getTime();
67
+ return Number.isNaN(ts) ? 0 : ts;
68
+ }
@@ -16,6 +16,16 @@ export declare function remoteListCommand(forwardedArgs: string[], os?: string):
16
16
  * testing without a live tailnet.
17
17
  */
18
18
  export declare function parseRemoteList(stdout: string, machine: string): SessionMeta[];
19
+ export declare function parseRemoteListPayload(stdout: string, machine: string, safeResolver?: boolean): {
20
+ sessions: SessionMeta[];
21
+ valid: boolean;
22
+ };
23
+ /** Convert one completed peer process into the exact aggregation result. This
24
+ * is the production parent/peer seam and is exercised with real child output. */
25
+ export declare function remoteListCaptureResult(code: number | null, stdout: string, machine: string, display: string, safeResolver?: boolean): {
26
+ sessions: SessionMeta[];
27
+ unreachable?: string;
28
+ };
19
29
  export interface RemoteListResult {
20
30
  sessions: SessionMeta[];
21
31
  /** How many peer machines we attempted to reach (drives the empty-fleet tip). */
@@ -61,15 +61,52 @@ export function parseRemoteList(stdout, machine) {
61
61
  }
62
62
  if (!Array.isArray(parsed))
63
63
  return [];
64
+ return parsed.flatMap((value) => value && typeof value === 'object' && !Array.isArray(value)
65
+ ? [{ ...value, machine, _remote: true }]
66
+ : []);
67
+ }
68
+ /** Strict parser used at the live peer boundary. A successful process with
69
+ * malformed/non-array JSON is an incomplete source, not an empty machine. */
70
+ const SAFE_RESOLVER_KEYS = new Set([
71
+ 'id', 'shortId', 'agent', 'origin', 'timestamp', 'lastActivity', 'project',
72
+ 'version', 'label', 'topic', 'machine',
73
+ ]);
74
+ function isSafeResolverRow(value) {
75
+ if (typeof value.id !== 'string' || typeof value.shortId !== 'string'
76
+ || typeof value.agent !== 'string' || typeof value.timestamp !== 'string')
77
+ return false;
78
+ return Object.keys(value).every(key => SAFE_RESOLVER_KEYS.has(key));
79
+ }
80
+ export function parseRemoteListPayload(stdout, machine, safeResolver = false) {
81
+ let parsed;
82
+ try {
83
+ parsed = JSON.parse(stdout);
84
+ }
85
+ catch {
86
+ return { sessions: [], valid: false };
87
+ }
88
+ if (!Array.isArray(parsed))
89
+ return { sessions: [], valid: false };
64
90
  const out = [];
65
91
  for (const x of parsed) {
66
- if (x && typeof x === 'object' && !Array.isArray(x)) {
67
- // `_remote` marks these as living on the peer's disk (not a local mirror),
68
- // so the picker routes read/resume back over SSH instead of the local FS.
69
- out.push({ ...x, machine, _remote: true });
92
+ if (!x || typeof x !== 'object' || Array.isArray(x))
93
+ return { sessions: [], valid: false };
94
+ if (safeResolver && !isSafeResolverRow(x)) {
95
+ return { sessions: [], valid: false };
70
96
  }
97
+ // `_remote` marks these as living on the peer's disk (not a local mirror),
98
+ // so the picker routes read/resume back over SSH instead of the local FS.
99
+ out.push({ ...x, machine, _remote: true });
71
100
  }
72
- return out;
101
+ return { sessions: out, valid: true };
102
+ }
103
+ /** Convert one completed peer process into the exact aggregation result. This
104
+ * is the production parent/peer seam and is exercised with real child output. */
105
+ export function remoteListCaptureResult(code, stdout, machine, display, safeResolver = false) {
106
+ if (code !== 0)
107
+ return { sessions: [], unreachable: display };
108
+ const parsed = parseRemoteListPayload(stdout, machine, safeResolver);
109
+ return parsed.valid ? { sessions: parsed.sessions } : { sessions: [], unreachable: display };
73
110
  }
74
111
  /** Run one remote `agents sessions … --json` and capture stdout. Resolves
75
112
  * `{ code: null }` on spawn error or timeout (host treated as dead). */
@@ -95,11 +132,12 @@ function sshCapture(target, remoteCmd, timeoutMs) {
95
132
  }
96
133
  async function fetchByTarget(target, machine, display, forwardedArgs, os) {
97
134
  const { code, stdout } = await sshCapture(target, remoteListCommand(forwardedArgs, os), REMOTE_TIMEOUT_MS);
98
- if (code !== 0) {
135
+ const safeResolver = forwardedArgs.includes('--resolve-safe-v1');
136
+ const result = remoteListCaptureResult(code, stdout, machine, display, safeResolver);
137
+ if (result.unreachable) {
99
138
  process.stderr.write(chalk.gray(` ${display}: unreachable or no agents CLI — skipped\n`));
100
- return { sessions: [], unreachable: display };
101
139
  }
102
- return { sessions: parseRemoteList(stdout, machine) };
140
+ return result;
103
141
  }
104
142
  /**
105
143
  * Gather listing sessions from other machines. With an explicit `hosts` list
@@ -122,7 +160,7 @@ export async function gatherRemoteList(forwardedArgs, hosts) {
122
160
  reg = await loadDevices();
123
161
  }
124
162
  catch {
125
- return { sessions: [], deviceCount: 0, unreachable: [] };
163
+ return { sessions: [], deviceCount: 0, unreachable: ['device registry'] };
126
164
  }
127
165
  for (const d of Object.values(reg)) {
128
166
  if (d.tailscale?.online !== true)
@@ -11,6 +11,13 @@ import type { SessionEvent, SessionMeta } from './types.js';
11
11
  * Return absPath relative to cwd; fall back to ~/… then absolute.
12
12
  */
13
13
  export declare function relativeToCwd(absPath: string, cwd?: string): string;
14
+ /**
15
+ * Display form for a touched path: cwd-relative first; then collapse any
16
+ * `.agents/worktrees/<slug>` segment (in-cwd OR outside) to `⧉ <slug>/…` so
17
+ * group labels stay on one line instead of `~/src/…/.agents/worktrees/<slug>/…`;
18
+ * else home-collapse.
19
+ */
20
+ export declare function displayPath(absPath: string, cwd?: string): string;
14
21
  /**
15
22
  * Wrap a filesystem path label in an OSC 8 `file://` hyperlink when the terminal
16
23
  * supports it. Degrades to the plain label otherwise.
@@ -13,6 +13,7 @@ import { cleanSessionPrompt, extractSessionTopic } from './prompt.js';
13
13
  import { renderMarkdown } from '../markdown.js';
14
14
  import { redactSecrets } from '../redact.js';
15
15
  import { classifyFileChanges, changeCounts, toolHistogram, detectTestResult } from './digest.js';
16
+ import { extractArtifacts, extractHooks, extractLinks, extractSkills } from './highlights.js';
16
17
  import { extractTodoProgressFromEvents } from './state.js';
17
18
  // ── Path helpers ──────────────────────────────────────────────────────────────
18
19
  /**
@@ -29,6 +30,31 @@ export function relativeToCwd(absPath, cwd) {
29
30
  }
30
31
  return absPath;
31
32
  }
33
+ /**
34
+ * Display form for a touched path: cwd-relative first; then collapse any
35
+ * `.agents/worktrees/<slug>` segment (in-cwd OR outside) to `⧉ <slug>/…` so
36
+ * group labels stay on one line instead of `~/src/…/.agents/worktrees/<slug>/…`;
37
+ * else home-collapse.
38
+ */
39
+ export function displayPath(absPath, cwd) {
40
+ const rel = relativeToCwd(absPath, cwd);
41
+ const norm = rel.replace(/\\/g, '/');
42
+ const wt = norm.match(/(^|\/)\.agents\/worktrees\/([^/]+)/);
43
+ if (wt) {
44
+ const after = norm.slice(norm.indexOf(wt[0]) + wt[0].length).replace(/^\//, '');
45
+ return after ? `⧉ ${wt[2]}/${after}` : `⧉ ${wt[2]}`;
46
+ }
47
+ return rel;
48
+ }
49
+ /** One checklist line with a status marker: `[x] done` / `[>] doing` / `[ ] todo`. */
50
+ function renderTodoMarker(item) {
51
+ const text = item.content;
52
+ if (item.status === 'completed')
53
+ return chalk.green('[x]') + ' ' + chalk.gray(text);
54
+ if (item.status === 'in_progress')
55
+ return chalk.yellow('[>]') + ' ' + chalk.white(text);
56
+ return chalk.gray('[ ]') + ' ' + chalk.white(text);
57
+ }
32
58
  /** Best-effort feature-detect for OSC 8 hyperlink support in the current TTY. */
33
59
  function supportsHyperlinks() {
34
60
  return Boolean(process.stdout.isTTY &&
@@ -387,11 +413,12 @@ function pickDistinct(samples, max) {
387
413
  return result.length > 0 ? result : samples.slice(0, max);
388
414
  }
389
415
  // ── File grouping ─────────────────────────────────────────────────────────────
390
- /** Group file paths by their parent directory, relative to cwd. */
416
+ /** Group file paths by their parent directory, in display form (cwd-relative,
417
+ * worktree-collapsed, home-collapsed). */
391
418
  function groupByParentDir(paths, cwd) {
392
419
  const groups = new Map();
393
420
  for (const p of paths) {
394
- const rel = relativeToCwd(p, cwd);
421
+ const rel = displayPath(p, cwd);
395
422
  const slashIdx = rel.lastIndexOf('/');
396
423
  const dir = slashIdx >= 0 ? rel.slice(0, slashIdx) : '.';
397
424
  const base = slashIdx >= 0 ? rel.slice(slashIdx + 1) : rel;
@@ -454,17 +481,17 @@ const OP_MARK = { created: '+', modified: '~', deleted: '−' };
454
481
  * create/modify/delete lifecycle, plus a `+N ~N −N` summary. Replaces the old
455
482
  * flat "Modified" list. Returns true if anything was rendered.
456
483
  */
457
- function renderChangesSection(lines, events, cwd) {
484
+ function renderChangesSection(lines, allChanges, cwd) {
458
485
  // In-project changes only; edits outside cwd (e.g. /tmp) keep their own
459
486
  // "External edits" section so they don't clutter the project's changeset.
460
487
  const inCwd = (p) => !cwd || !p.startsWith('/') || p.startsWith(cwd + '/');
461
- const changes = classifyFileChanges(events).filter(ch => inCwd(ch.path));
488
+ const changes = allChanges.filter(ch => inCwd(ch.path));
462
489
  if (changes.length === 0)
463
490
  return false;
464
491
  const c = changeCounts(changes);
465
492
  const opByRel = new Map();
466
493
  for (const ch of changes)
467
- opByRel.set(relativeToCwd(ch.path, cwd), ch.op);
494
+ opByRel.set(displayPath(ch.path, cwd), ch.op);
468
495
  const summary = [
469
496
  c.created ? chalk.green(`+${c.created}`) : '',
470
497
  c.modified ? chalk.yellow(`~${c.modified}`) : '',
@@ -528,8 +555,8 @@ export function renderSummary(events, cwd) {
528
555
  const filesModifiedExternal = new Set();
529
556
  // Commands with timestamps
530
557
  const cmdList = [];
531
- // Plan items
532
- const todoItems = extractTodoProgressFromEvents(events)?.items.map(item => item.content) ?? [];
558
+ // Plan items (checklist entries keep their status for [x]/[>]/[ ] markers)
559
+ const todoItems = extractTodoProgressFromEvents(events)?.items ?? [];
533
560
  let exitPlanContent = null;
534
561
  let planFilePath = null;
535
562
  // Subagent spawns
@@ -559,7 +586,7 @@ export function renderSummary(events, cwd) {
559
586
  }
560
587
  else {
561
588
  (isInsideCwd(p) || !cwd ? filesModifiedAbs : filesModifiedExternal).add(p);
562
- recentActivity.push({ kind: 'edit', label: relativeToCwd(p, cwd), ts, absPath: p });
589
+ recentActivity.push({ kind: 'edit', label: displayPath(p, cwd), ts, absPath: p });
563
590
  }
564
591
  }
565
592
  }
@@ -624,11 +651,11 @@ export function renderSummary(events, cwd) {
624
651
  filesReadAbs.delete(p);
625
652
  for (const p of filesModifiedExternal)
626
653
  filesReadAbs.delete(p);
627
- // Build abs→rel mapping for linkPath
654
+ // Build abs→display mapping for linkPath
628
655
  const buildAbsMap = (absSet) => {
629
656
  const m = new Map();
630
657
  for (const abs of absSet) {
631
- const rel = relativeToCwd(abs, cwd);
658
+ const rel = displayPath(abs, cwd);
632
659
  m.set(rel, abs);
633
660
  }
634
661
  return m;
@@ -668,22 +695,27 @@ export function renderSummary(events, cwd) {
668
695
  }
669
696
  lines.push('');
670
697
  }
671
- // 3. Plan
698
+ // 3. Plan — the plan document (ExitPlanMode text / plan file) AND the live
699
+ // checklist. Both render: the checklist used to be hidden whenever plan text
700
+ // existed, which read as "this session had no todos".
672
701
  if (todoItems.length > 0 || exitPlanContent || planFilePath) {
673
702
  lines.push(chalk.bold('Plan'));
674
703
  if (planFilePath) {
675
704
  const home = process.env.HOME ?? '';
676
- const displayPath = home && planFilePath.startsWith(home) ? planFilePath.replace(home, '~') : planFilePath;
677
- lines.push(' ' + chalk.cyan(displayPath));
705
+ const planPathDisplay = home && planFilePath.startsWith(home) ? planFilePath.replace(home, '~') : planFilePath;
706
+ lines.push(' ' + chalk.cyan(planPathDisplay));
678
707
  }
679
708
  if (exitPlanContent) {
680
709
  const planLines = exitPlanContent.split('\n').slice(0, 10);
681
710
  for (const l of planLines)
682
711
  lines.push(' ' + l);
683
712
  }
684
- else if (todoItems.length > 0) {
713
+ if (todoItems.length > 0) {
685
714
  for (const item of todoItems.slice(0, 20)) {
686
- lines.push(' · ' + item);
715
+ lines.push(' ' + renderTodoMarker(item));
716
+ }
717
+ if (todoItems.length > 20) {
718
+ lines.push(' ' + chalk.gray(`… (${todoItems.length - 20} more)`));
687
719
  }
688
720
  }
689
721
  lines.push('');
@@ -698,6 +730,30 @@ export function renderSummary(events, cwd) {
698
730
  }
699
731
  lines.push('');
700
732
  }
733
+ // 4b. Highlights — what the session USED (skills, hooks) and the references
734
+ // it mentioned (links). Shared extraction with the picker preview so the two
735
+ // renders never disagree.
736
+ const skills = extractSkills(events);
737
+ if (skills.length > 0) {
738
+ const shown = skills.map(s => chalk.white(s.name) + (s.count > 1 ? chalk.gray(` ×${s.count}`) : ''));
739
+ lines.push(chalk.bold('Skills') + chalk.gray(` (${skills.length})`) + ' ' + shown.join(chalk.gray(' · ')));
740
+ lines.push('');
741
+ }
742
+ const hooks = extractHooks(events);
743
+ if (hooks.length > 0) {
744
+ const shown = hooks.map(h => chalk.white(h.name) + (h.count > 1 ? chalk.gray(` ×${h.count}`) : '') + (h.failed ? chalk.red(` (${h.failed} failed)`) : ''));
745
+ lines.push(chalk.bold('Hooks') + chalk.gray(` (${hooks.length})`) + ' ' + shown.join(chalk.gray(' · ')));
746
+ lines.push('');
747
+ }
748
+ const links = extractLinks(events);
749
+ if (links.length > 0) {
750
+ // Width-capped like the picker's Dirs line: a link-heavy session must not
751
+ // wrap the summary pane.
752
+ const shown = links.slice(0, 6).map(l => chalk.blue(linkUrl(l.url, l.label)));
753
+ const more = links.length > 6 ? chalk.gray(` · +${links.length - 6} more`) : '';
754
+ lines.push(chalk.bold('Links') + chalk.gray(` (${links.length})`) + ' ' + shown.join(chalk.gray(' · ')) + more);
755
+ lines.push('');
756
+ }
701
757
  // 5. Errors (moved up from the bottom: it describes failed attempts, not the
702
758
  // session's final state. Sitting at the bottom previously made early errors
703
759
  // look recent, which confused readers.)
@@ -713,8 +769,22 @@ export function renderSummary(events, cwd) {
713
769
  lines.push('');
714
770
  }
715
771
  // 6. Changes — files grouped by directory with create/modify/delete lifecycle
716
- // (replaces the old flat "Modified" + "External edits" lists).
717
- renderChangesSection(lines, events, cwd);
772
+ // (replaces the old flat "Modified" + "External edits" lists). Classified
773
+ // once here and shared with the Artifacts section below.
774
+ const allChanges = classifyFileChanges(events);
775
+ renderChangesSection(lines, allChanges, cwd);
776
+ // 6a. Artifacts — documents the session PRODUCED (`.agents/artifacts|plans|
777
+ // reports`, other *.md/*.html creations), named and clickable. These drown in
778
+ // the Changeset's source churn, so they get their own section.
779
+ const artifacts = extractArtifacts(allChanges);
780
+ if (artifacts.length > 0) {
781
+ lines.push(chalk.bold('Artifacts') + chalk.gray(` (${artifacts.length})`));
782
+ for (const a of artifacts) {
783
+ const tag = a.bucket === 'docs' ? '' : chalk.gray(` (${a.bucket})`);
784
+ lines.push(' ' + chalk.green('+') + ' ' + chalk.cyan(linkPath(a.path, a.basename)) + tag);
785
+ }
786
+ lines.push('');
787
+ }
718
788
  // 6b. Catch-up signals: last test/build verdict, then the tool histogram.
719
789
  renderTestsLine(lines, events);
720
790
  renderToolsSection(lines, computeSummaryStats(events));
@@ -18,7 +18,7 @@ export declare const SESSION_AGENTS: SessionAgentId[];
18
18
  export declare function isSessionTrackedAgent(agent: string): agent is SessionAgentId;
19
19
  /** A single normalized event within a session (message, tool call, thinking, etc.). */
20
20
  export interface SessionEvent {
21
- type: 'message' | 'tool_use' | 'tool_result' | 'thinking' | 'error' | 'init' | 'result' | 'usage' | 'attachment';
21
+ type: 'message' | 'tool_use' | 'tool_result' | 'thinking' | 'error' | 'init' | 'result' | 'usage' | 'attachment' | 'hook';
22
22
  agent: SessionAgentId;
23
23
  timestamp: string;
24
24
  role?: 'user' | 'assistant';
@@ -49,6 +49,9 @@ export interface SessionEvent {
49
49
  name?: string;
50
50
  mediaType?: string;
51
51
  sizeBytes?: number;
52
+ hookName?: string;
53
+ /** Lifecycle event the hook fired on (SessionStart, PreToolUse, …). */
54
+ hookEvent?: string;
52
55
  }
53
56
  /** A displayable file attachment discovered in a session transcript. */
54
57
  export interface SessionAttachment {
@@ -70,6 +70,7 @@ export declare const loadDrive: ModuleLoader;
70
70
  export declare const loadFactory: ModuleLoader;
71
71
  export declare const loadUsage: ModuleLoader;
72
72
  export declare const loadCost: ModuleLoader;
73
+ export declare const loadPerf: ModuleLoader;
73
74
  export declare const loadOutput: ModuleLoader;
74
75
  export declare const loadBudget: ModuleLoader;
75
76
  export declare const loadAlias: ModuleLoader;
@@ -48,6 +48,7 @@ export const loadDrive = async () => (await import('../../commands/drive.js')).r
48
48
  export const loadFactory = async () => (await import('../../commands/factory.js')).registerFactoryCommands;
49
49
  export const loadUsage = async () => (await import('../../commands/usage.js')).registerUsageCommand;
50
50
  export const loadCost = async () => (await import('../../commands/cost.js')).registerCostCommand;
51
+ export const loadPerf = async () => (await import('../../commands/perf.js')).registerPerfCommand;
51
52
  export const loadOutput = async () => (await import('../../commands/output.js')).registerOutputCommand;
52
53
  export const loadBudget = async () => (await import('../../commands/budget.js')).registerBudgetCommand;
53
54
  export const loadAlias = async () => (await import('../../commands/alias.js')).registerAliasCommand;
@@ -162,6 +163,7 @@ export const COMMAND_LOADERS = {
162
163
  factory: [loadFactory],
163
164
  usage: [loadUsage],
164
165
  cost: [loadCost],
166
+ perf: [loadPerf],
165
167
  output: [loadOutput],
166
168
  budget: [loadBudget],
167
169
  alias: [loadAlias],