@phnx-labs/agents-cli 1.20.89 → 1.20.90

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 (58) hide show
  1. package/CHANGELOG.md +240 -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/harness.d.ts +27 -0
  6. package/dist/commands/harness.js +120 -13
  7. package/dist/commands/profiles.d.ts +3 -0
  8. package/dist/commands/profiles.js +1 -1
  9. package/dist/commands/routines.d.ts +19 -0
  10. package/dist/commands/routines.js +28 -6
  11. package/dist/commands/secrets.d.ts +10 -1
  12. package/dist/commands/secrets.js +18 -6
  13. package/dist/commands/sessions-browser.d.ts +4 -0
  14. package/dist/commands/sessions-browser.js +51 -9
  15. package/dist/commands/sessions-favorite.d.ts +20 -0
  16. package/dist/commands/sessions-favorite.js +120 -0
  17. package/dist/commands/sessions.d.ts +103 -20
  18. package/dist/commands/sessions.js +356 -62
  19. package/dist/commands/setup-secrets.d.ts +7 -0
  20. package/dist/commands/setup-secrets.js +12 -9
  21. package/dist/commands/versions.js +12 -4
  22. package/dist/commands/view.d.ts +14 -1
  23. package/dist/commands/view.js +103 -128
  24. package/dist/lib/agents.d.ts +4 -2
  25. package/dist/lib/agents.js +21 -6
  26. package/dist/lib/hosts/dispatch.js +19 -1
  27. package/dist/lib/hq/floor.js +12 -0
  28. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  29. package/dist/lib/picker.d.ts +27 -2
  30. package/dist/lib/picker.js +71 -7
  31. package/dist/lib/profiles.d.ts +48 -0
  32. package/dist/lib/profiles.js +67 -0
  33. package/dist/lib/rotate.d.ts +24 -2
  34. package/dist/lib/rotate.js +63 -6
  35. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  36. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  37. package/dist/lib/session/active.d.ts +109 -3
  38. package/dist/lib/session/active.js +269 -13
  39. package/dist/lib/session/db.d.ts +14 -0
  40. package/dist/lib/session/db.js +35 -0
  41. package/dist/lib/session/favorites.d.ts +39 -0
  42. package/dist/lib/session/favorites.js +101 -0
  43. package/dist/lib/session/host-link.d.ts +68 -0
  44. package/dist/lib/session/host-link.js +64 -0
  45. package/dist/lib/session/presence.d.ts +85 -0
  46. package/dist/lib/session/presence.js +150 -0
  47. package/dist/lib/session/remote-list.d.ts +10 -0
  48. package/dist/lib/session/remote-list.js +47 -9
  49. package/dist/lib/tmux/binary.d.ts +7 -0
  50. package/dist/lib/tmux/binary.js +11 -1
  51. package/dist/lib/types.d.ts +4 -3
  52. package/dist/lib/usage-backoff.d.ts +29 -0
  53. package/dist/lib/usage-backoff.js +165 -0
  54. package/dist/lib/usage.d.ts +112 -5
  55. package/dist/lib/usage.js +464 -46
  56. package/dist/lib/watchdog/runner.d.ts +13 -0
  57. package/dist/lib/watchdog/runner.js +16 -1
  58. package/package.json +1 -1
@@ -6,6 +6,7 @@ import { type SessionAttachment } from './types.js';
6
6
  import { type SessionProvenance } from './provenance.js';
7
7
  import { type DeviceRegistry } from '../devices/registry.js';
8
8
  import { type Presence } from './detached.js';
9
+ import { type HostLink } from './host-link.js';
9
10
  /**
10
11
  * The owner (actor id) to show for a session in `--active`. Prefers the actor
11
12
  * recorded on the live-attribution source (the pid registry / teammate record),
@@ -44,7 +45,11 @@ export type ActiveContext = 'terminal' | 'teams' | 'cloud' | 'headless';
44
45
  * antigravity) gets a real working/waiting/idle from its own parser — see
45
46
  * {@link computeLiveSignals}, {@link lifecycleStatus} and {@link resolveFallbackStatus}.
46
47
  */
47
- export type ActiveStatus = 'running' | 'idle' | 'queued' | 'input_required' | 'closed' | 'abandoned' | 'unknown';
48
+ export type ActiveStatus = 'running' | 'idle' | 'queued' | 'input_required' | 'closed' | 'abandoned'
49
+ /** Alive, but no client is attached — the host window died and the agent outlived it. */
50
+ | 'orphaned'
51
+ /** The host window died and took the agent with it — an unclean exit, not a normal close. */
52
+ | 'crashed' | 'unknown';
48
53
  export interface ActiveSession {
49
54
  context: ActiveContext;
50
55
  kind: string;
@@ -122,6 +127,35 @@ export interface ActiveSession {
122
127
  * from the detach store — never asserted by a source.
123
128
  */
124
129
  presence?: Presence;
130
+ /**
131
+ * Whether anything is still on the other end of this session — folded on at the
132
+ * end of {@link getActiveSessions} by {@link foldHostLink} from the raw signals
133
+ * below, never asserted by a source. Drives the `orphaned` / `crashed` statuses.
134
+ */
135
+ hostLink?: HostLink;
136
+ /**
137
+ * Whether this session's process was alive at scan time — the boolean
138
+ * {@link applyState} already computes, kept rather than thrown away.
139
+ *
140
+ * `status` cannot stand in for it. `abandoned` fires on transcript staleness
141
+ * BEFORE the liveness check, so it covers a live-but-stuck process as well as
142
+ * a long-dead one; only `closed`/`crashed` are unconditionally dead. A consumer
143
+ * that must tell "still there, just quiet" from "gone" needs this, not the
144
+ * status. Absent from cloud rows (no pid) and from a peer running an older CLI.
145
+ */
146
+ pidAlive?: boolean;
147
+ /**
148
+ * Clients attached to this session's tmux session (`#{session_attached}`), for
149
+ * a tmux-hosted row. Absent — NOT zero — when the session is not tmux-hosted:
150
+ * zero means "tmux says nobody is looking", absent means "we cannot tell".
151
+ */
152
+ tmuxClients?: number;
153
+ /**
154
+ * When the owning IDE window last refreshed its slice of the live-terminals
155
+ * registry. Absent for a session no IDE window owns. A stale value means that
156
+ * window is gone — see {@link HOST_HEARTBEAT_STALE_MS}.
157
+ */
158
+ windowHeartbeatMs?: number;
125
159
  /** How many live PIDs resolve to this same session (subagents/forks). 1 unless collapsed. */
126
160
  pidCount?: number;
127
161
  /**
@@ -208,6 +242,24 @@ export interface ActiveSession {
208
242
  app: string;
209
243
  tab?: number;
210
244
  };
245
+ /**
246
+ * The editor tab that launched this agent (`AGENT_TERMINAL_ID`), from the pid
247
+ * registry. This is the one identifier that survives an SSH hop AND a session
248
+ * rotation: a Factory tab offloaded to a device has no local process to inspect,
249
+ * and its spawn-time session id goes stale the moment the agent moves to another
250
+ * session (`/clear`, exit-and-rerun), so `--active --host <device>` joined on
251
+ * this is how that tab re-identifies its own session. Absent for any launch that
252
+ * did not inherit a terminal id.
253
+ */
254
+ terminalId?: string;
255
+ /**
256
+ * tmux pane id (`%N`) when this row was discovered via the tmux source AND its
257
+ * session id could not be resolved (a born-unidentifiable non-Claude pane). It
258
+ * is the dedupe key for such id-less rows, so two anonymous panes in the same
259
+ * cwd render as two distinct rows instead of collapsing onto each other. Unset
260
+ * once a session id resolves (the id is the identity then).
261
+ */
262
+ paneId?: string;
211
263
  }
212
264
  export declare function activeStatusFromCloudStatus(status: CloudTaskStatus): ActiveStatus;
213
265
  export interface ActiveQueryOptions {
@@ -238,6 +290,22 @@ export declare const ABANDONED_STALE_MS: number;
238
290
  * `.exe` suffix (`claude.exe`), so basename + suffix-strip before the lookup.
239
291
  */
240
292
  export declare function agentKindFromComm(commRaw: string): string | undefined;
293
+ /** Agent kind from an `ag-<agent>-<shortid>` tmux session name, else undefined. */
294
+ export declare function agentKindFromName(sessName: string): string | undefined;
295
+ /** The 8-char session-id prefix from an `ag-<agent>-<shortid>` name, else undefined. */
296
+ export declare function shortIdFromName(sessName: string): string | undefined;
297
+ /**
298
+ * Map every `ag-<agent>-<shortid>` tmux session name to its full session UUID in
299
+ * ONE batched DB lookup. The live scan calls this once per poll (not per pane),
300
+ * then resolvePaneIdentity reads the map — the recovery that makes a detached
301
+ * agent findable by `focus <id>` even when its durable identity records are gone.
302
+ * `findSessionsByShortIds` is injected so this stays unit-testable without a DB.
303
+ */
304
+ export declare function resolveNamesToSessionIds(sessionNames: string[], deps: {
305
+ findSessionsByShortIds: (shortIds: string[]) => Map<string, {
306
+ id: string;
307
+ }>;
308
+ }): Map<string, string>;
241
309
  /**
242
310
  * True when `pid` names a live process AND — when a session's recorded
243
311
  * `startedAtMs` is supplied — that process is plausibly the SAME one, not a later
@@ -446,11 +514,11 @@ export interface PaneIdentity {
446
514
  * `source: 'teams'` panes are skipped — teammates are surfaced by listTeamsActive.
447
515
  * Pure so it is unit-tested without tmux.
448
516
  */
449
- export declare function resolvePaneIdentity(pane: string, meta: {
517
+ export declare function resolvePaneIdentity(pane: string, sessName: string, meta: {
450
518
  labels?: Record<string, string>;
451
519
  source?: string;
452
520
  pane?: string;
453
- } | null, liveEntry: PidSessionEntry | undefined, getHookIndex: () => HookSessionIndex): PaneIdentity | undefined;
521
+ } | null, liveEntry: PidSessionEntry | undefined, getHookIndex: () => HookSessionIndex, nameToFullId: Map<string, string>): PaneIdentity | undefined;
454
522
  /**
455
523
  * Agents hosted in the shared-socket tmux server — the authoritative source for
456
524
  * tmux-hosted interactive spawns (see src/lib/exec.ts `runInTmux`). Enumerates
@@ -471,6 +539,44 @@ export declare function listTmuxAgentSessions(): Promise<ActiveSession[]>;
471
539
  * terminal/headless row for the same session id.
472
540
  */
473
541
  export declare function getActiveSessions(opts?: ActiveQueryOptions): Promise<ActiveSession[]>;
542
+ /**
543
+ * Fold tmux's attached-client count onto every tmux-hosted row.
544
+ *
545
+ * Keyed off `provenance.mux` — which {@link enrichProvenance} has already stamped
546
+ * on any row whose process env names a tmux pane — rather than off
547
+ * {@link listTmuxAgentSessions}. That source only emits a row when it can resolve
548
+ * the pane's agent IDENTITY (launch registry or session meta), and on a machine
549
+ * where neither resolves it emits nothing at all while the same sessions still
550
+ * arrive through the terminal/headless sources carrying full tmux provenance.
551
+ * Hanging the client count off the identity-resolving source would have made the
552
+ * whole orphan signal silently dead on exactly those machines.
553
+ *
554
+ * One `list-panes` per distinct socket, and only when some row is tmux-hosted —
555
+ * a fleet with no tmux pays nothing. A query failure leaves the count undefined,
556
+ * which the classifier reads as "cannot tell", never as a false zero.
557
+ */
558
+ export declare function foldTmuxClients(rows: ActiveSession[]): Promise<void>;
559
+ /**
560
+ * Fold the host link onto each row and, where it changes the answer, onto the
561
+ * status. Runs AFTER {@link foldPresence}, because a deliberately backgrounded
562
+ * session (`presence` `background`/`parked`) is supposed to have no client and
563
+ * must not be reported as an orphan.
564
+ *
565
+ * Precedence is deliberate, and the two new statuses slot in where they add
566
+ * information rather than destroy it:
567
+ *
568
+ * - `abandoned` wins outright. A days-stale session is already dangling; that
569
+ * it also lost its window is not the headline, and it keeps a crashed row
570
+ * from lingering as an alert forever.
571
+ * - `crashed` REPLACES `closed`. Both mean the process is gone, but `closed`
572
+ * reads as a normal exit; `crashed` says the host window went down with it
573
+ * and never cleaned up.
574
+ * - `orphaned` replaces only `idle` / `input_required`. A session still WORKING
575
+ * with nobody watching is a normal headless run, and flagging every one would
576
+ * bury the real signal. A session sitting idle — or worse, waiting on a
577
+ * question — with no client attached is the stranded case: nobody is coming.
578
+ */
579
+ export declare function foldHostLink(rows: ActiveSession[]): void;
474
580
  /**
475
581
  * Resolve each teams row's `orchestratorLabel` from the orchestrator's own row,
476
582
  * when that orchestrator session is itself in the active set (it usually is — the
@@ -29,7 +29,7 @@ import { readSessionActorRecord } from './actor-sidecar.js';
29
29
  import { loadHookSessionIndex, resolveHookSessionRecord, readStateSessionRecord } from './hook-sessions.js';
30
30
  import { buildClaudeLabelMap, getAgentSessionDirs } from './discover.js';
31
31
  import { buildRunNameMap } from './run-names.js';
32
- import { latestSessionFileForCwd } from './db.js';
32
+ import { latestSessionFileForCwd, findSessionsByShortIds } from './db.js';
33
33
  import { extractSessionTopic } from './prompt.js';
34
34
  import { readSessionTailWithRaw } from './tail.js';
35
35
  import { parseSession } from './parse.js';
@@ -39,6 +39,7 @@ import { isSessionTrackedAgent } from './types.js';
39
39
  import { detectProvenance } from './provenance.js';
40
40
  import { loadDevices } from '../devices/registry.js';
41
41
  import { presenceFromStore } from './detached.js';
42
+ import { classifyHostLink, HOST_HEARTBEAT_STALE_MS } from './host-link.js';
42
43
  import { mapBounded } from '../concurrency.js';
43
44
  const execFileAsync = promisify(execFile);
44
45
  /**
@@ -88,6 +89,12 @@ const LIVE_TERMINALS_FILE = path.join(getTerminalsDir(), 'live-terminals.json');
88
89
  * healthy session writes several times a minute.
89
90
  */
90
91
  const ACTIVE_MTIME_WINDOW_MS = 2 * 60_000;
92
+ /**
93
+ * Bound on the tmux `list-panes` call in {@link listTmuxAgentSessions}. A wedged
94
+ * tmux server would otherwise hang the whole `--active` scan (the other sources
95
+ * can't run past it); on timeout the tmux source degrades to empty.
96
+ */
97
+ const TMUX_LIST_PANES_TIMEOUT_MS = 5_000;
91
98
  /**
92
99
  * A live process can only borrow an indexed session file if that transcript
93
100
  * has been touched recently enough to plausibly belong to the process. This is
@@ -106,6 +113,25 @@ export const ACTIVE_SESSION_STALE_MS = 24 * 60 * 60_000;
106
113
  * concern) — this is the lifecycle threshold, not the freshness window.
107
114
  */
108
115
  export const ABANDONED_STALE_MS = 2 * 24 * 60 * 60_000;
116
+ /**
117
+ * Field separator for every `tmux list-panes -F` query here.
118
+ *
119
+ * NOT a tab. tmux sanitizes non-printable characters out of format output (3.6a
120
+ * rewrites a literal tab — and any non-ASCII sentinel — to `_`), so a
121
+ * tab-separated format comes back as one unsplittable field.
122
+ * {@link listTmuxAgentSessions} split on `\t`, so on such a tmux every line
123
+ * failed its `sessName` guard and the function returned ZERO rows, silently
124
+ * losing the authoritative tmux source (exact `%pane`, real identities) on every
125
+ * box running a recent tmux.
126
+ *
127
+ * `:` specifically, and not some other printable: tmux itself replaces `:` (and
128
+ * `.`) in a session name with `_`, so the separator provably cannot occur inside
129
+ * the one free-text field that is not last. A path CAN contain `:`, which is why
130
+ * `pane_current_path` is queried last and its tail rejoined rather than
131
+ * destructured. A separator that a session name may contain — `|`, say — would
132
+ * just reintroduce the same class of bug with a lower probability.
133
+ */
134
+ const TMUX_FIELD_SEP = ':';
109
135
  /** Executables we recognize as agent CLIs when scanning the process table. */
110
136
  const AGENT_CLI_NAMES = {
111
137
  claude: 'claude',
@@ -137,6 +163,56 @@ export function agentKindFromComm(commRaw) {
137
163
  const key = stripped === base ? base : stripped.toLowerCase();
138
164
  return AGENT_CLI_NAMES[key];
139
165
  }
166
+ /**
167
+ * A tmux agent session name is `ag-<agent>-<shortid>` (see src/lib/exec.ts
168
+ * `runInTmux`), where `<agent>` is the agent kind passed to `agents run` (may
169
+ * contain a hyphen, e.g. `cursor-agent`) and `<shortid>` is the first 8 hex chars
170
+ * of the session UUID. Anchored on the 8-hex suffix so the agent part is split
171
+ * unambiguously. The agent part is NOT cross-checked against AGENT_CLI_NAMES (that
172
+ * map is the narrower ps-scan comm set): the panes live on the agent-only socket,
173
+ * and validating there would silently drop grok/kimi/antigravity — the exact
174
+ * harness-parity gap we are fixing.
175
+ */
176
+ const AG_NAME_RE = /^ag-([a-z][a-z0-9-]*?)-([0-9a-f]{8})$/i;
177
+ /** Agent kind from an `ag-<agent>-<shortid>` tmux session name, else undefined. */
178
+ export function agentKindFromName(sessName) {
179
+ const m = AG_NAME_RE.exec(sessName);
180
+ return m ? m[1].toLowerCase() : undefined;
181
+ }
182
+ /** The 8-char session-id prefix from an `ag-<agent>-<shortid>` name, else undefined. */
183
+ export function shortIdFromName(sessName) {
184
+ const m = AG_NAME_RE.exec(sessName);
185
+ return m ? m[2].toLowerCase() : undefined;
186
+ }
187
+ /**
188
+ * Map every `ag-<agent>-<shortid>` tmux session name to its full session UUID in
189
+ * ONE batched DB lookup. The live scan calls this once per poll (not per pane),
190
+ * then resolvePaneIdentity reads the map — the recovery that makes a detached
191
+ * agent findable by `focus <id>` even when its durable identity records are gone.
192
+ * `findSessionsByShortIds` is injected so this stays unit-testable without a DB.
193
+ */
194
+ export function resolveNamesToSessionIds(sessionNames, deps) {
195
+ const shortIdToNames = new Map();
196
+ for (const name of sessionNames) {
197
+ const short = shortIdFromName(name);
198
+ if (!short)
199
+ continue;
200
+ const arr = shortIdToNames.get(short);
201
+ if (arr)
202
+ arr.push(name);
203
+ else
204
+ shortIdToNames.set(short, [name]);
205
+ }
206
+ const out = new Map();
207
+ if (shortIdToNames.size === 0)
208
+ return out;
209
+ const metas = deps.findSessionsByShortIds([...shortIdToNames.keys()]);
210
+ for (const [short, meta] of metas) {
211
+ for (const name of shortIdToNames.get(short) ?? [])
212
+ out.set(name, meta.id);
213
+ }
214
+ return out;
215
+ }
140
216
  /**
141
217
  * A process that began more than this long AFTER a session's recorded
142
218
  * `startedAtMs` cannot be that session's process — the OS handed its pid to
@@ -204,7 +280,20 @@ export function isPidAlive(pid, startedAtMs) {
204
280
  }
205
281
  return true;
206
282
  }
207
- /** Read the live-terminals registry, dedupe by sessionId, keep only pid-alive entries. */
283
+ /**
284
+ * Read the live-terminals registry, dedupe by sessionId.
285
+ *
286
+ * A pid-alive entry is a live session. A pid-DEAD entry is normally noise — a
287
+ * terminal that closed a moment ago, before its window republished — and is
288
+ * dropped. But a dead pid whose owning window ALSO stopped republishing is the
289
+ * signature of a crash: the window went down hard and never ran the teardown that
290
+ * would have removed this entry. Those are KEPT, so the session reaches the
291
+ * listing at all — it used to vanish outright, a VS Code crash simply erasing its
292
+ * agents from `--active`. Such a row arrives as `closed` (dead pid) carrying the
293
+ * stale `windowHeartbeatMs`, which is what {@link foldHostLink} promotes to
294
+ * `crashed`. `pidDead` is local to the dedupe below: a live entry must win a dead
295
+ * one for the same session.
296
+ */
208
297
  function readLiveTerminals() {
209
298
  let raw;
210
299
  try {
@@ -222,12 +311,27 @@ function readLiveTerminals() {
222
311
  }
223
312
  if (!parsed || typeof parsed !== 'object')
224
313
  return [];
314
+ const now = Date.now();
225
315
  const merged = new Map();
226
316
  for (const [windowId, slice] of Object.entries(parsed)) {
317
+ const at = Date.parse(slice?.at ?? '');
318
+ const windowHeartbeatMs = Number.isFinite(at) ? at : undefined;
319
+ const windowGone = windowHeartbeatMs !== undefined && now - windowHeartbeatMs >= HOST_HEARTBEAT_STALE_MS;
227
320
  for (const e of (slice?.entries ?? [])) {
228
- if (!e?.sessionId || !isPidAlive(e.pid, e.startedAtMs))
321
+ if (!e?.sessionId)
229
322
  continue;
230
- merged.set(e.sessionId, { ...e, windowId });
323
+ const alive = isPidAlive(e.pid, e.startedAtMs);
324
+ // Dead pid + a window still republishing = an ordinary close mid-debounce.
325
+ // Dead pid + a window that stopped republishing = the crash we must report.
326
+ if (!alive && !windowGone)
327
+ continue;
328
+ const entry = { ...e, windowId, windowHeartbeatMs, pidDead: !alive };
329
+ // A live entry always wins a dead one for the same session (the agent was
330
+ // relaunched into a new window while the crashed window's slice lingers).
331
+ const prev = merged.get(e.sessionId);
332
+ if (prev && !prev.pidDead && !alive)
333
+ continue;
334
+ merged.set(e.sessionId, entry);
231
335
  }
232
336
  }
233
337
  return Array.from(merged.values());
@@ -476,7 +580,7 @@ function statusFromActivity(activity) {
476
580
  */
477
581
  function applyState(base, state, fallbackFile, pidAlive) {
478
582
  if (!state)
479
- return { ...base, status: resolveFallbackStatus(fallbackFile, pidAlive) };
583
+ return { ...base, pidAlive, status: resolveFallbackStatus(fallbackFile, pidAlive) };
480
584
  // Lifecycle (closed/abandoned) is computed from PID + mtime and OVERRIDES the
481
585
  // activity-derived status: a dead or days-stale process is closed/abandoned no
482
586
  // matter what its last parsed transcript turn looked like (a dead session whose
@@ -485,6 +589,7 @@ function applyState(base, state, fallbackFile, pidAlive) {
485
589
  const life = lifecycleStatus(pidAlive, base.lastActivityMs ?? sessionFileTimes(fallbackFile).mtimeMs);
486
590
  return {
487
591
  ...base,
592
+ pidAlive,
488
593
  status: life ?? statusFromActivity(state.activity),
489
594
  activity: state.activity,
490
595
  awaitingReason: state.awaitingReason,
@@ -684,6 +789,7 @@ export async function listTerminalsActive() {
684
789
  startedAtMs: t.startedAtMs,
685
790
  lastActivityMs: sessionFileTimes(sessionFile).mtimeMs,
686
791
  windowId: t.windowId,
792
+ windowHeartbeatMs: t.windowHeartbeatMs,
687
793
  owner: resolveOwner(pidEntry?.actor, resolvedId),
688
794
  }, state, sessionFile, pidAlive);
689
795
  });
@@ -1116,6 +1222,7 @@ export async function listUnattributedActive(attributed) {
1116
1222
  lastActivityMs: mtimeMs,
1117
1223
  pidCount: 1 + (foldedByRoot.get(pid) ?? 0),
1118
1224
  owner: resolveOwner(entry?.actor, resolvedId),
1225
+ terminalId: entry?.terminalId,
1119
1226
  }, state, sessionFile, true));
1120
1227
  }
1121
1228
  // Housekeeping: drop registry files for pids that have since died.
@@ -1140,26 +1247,41 @@ export async function listUnattributedActive(attributed) {
1140
1247
  * `source: 'teams'` panes are skipped — teammates are surfaced by listTeamsActive.
1141
1248
  * Pure so it is unit-tested without tmux.
1142
1249
  */
1143
- export function resolvePaneIdentity(pane, meta, liveEntry, getHookIndex) {
1250
+ export function resolvePaneIdentity(pane, sessName, meta, liveEntry, getHookIndex, nameToFullId) {
1144
1251
  if (meta?.source === 'teams')
1145
1252
  return undefined;
1253
+ // The tmux session name encodes the agent kind (100% of ag-* panes) and, for a
1254
+ // spawn whose id was known at creation (Claude), the session-id prefix — already
1255
+ // resolved to a full UUID in the batch map. It is the last-resort id source when
1256
+ // every durable record is missing (the common fleet case: meta/pid-reg/hook all
1257
+ // ~3% populated), and would otherwise leave the pane id-less and mis-collapsed.
1258
+ const nameAgent = agentKindFromName(sessName);
1259
+ const nameSessionId = nameToFullId.get(sessName);
1146
1260
  if (liveEntry) {
1147
1261
  // Exact id: the id recorded at launch (Claude), else the agent's own
1148
1262
  // SessionStart hook joined by launchId/terminalId (non-Claude, or agents we
1149
- // didn't launch) — kind-guarded against a stale reused-pid file.
1263
+ // didn't launch) — kind-guarded against a stale reused-pid file — else the id
1264
+ // carried in the pane's own tmux name.
1150
1265
  const sessionId = liveEntry.sessionId
1151
1266
  ?? resolveHookSessionRecord(getHookIndex(), {
1152
1267
  pid: liveEntry.pid,
1153
1268
  kind: liveEntry.agent,
1154
1269
  launchId: liveEntry.launchId,
1155
1270
  terminalId: liveEntry.terminalId,
1156
- })?.session_id;
1271
+ })?.session_id
1272
+ ?? nameSessionId;
1157
1273
  return { agent: liveEntry.agent, sessionId, pid: liveEntry.pid };
1158
1274
  }
1275
+ // No live-registry entry. Session-meta labels are the wrapped-origin fallback;
1276
+ // prefer them, then fall back to the name so a pane with neither a registry
1277
+ // entry nor meta labels still resolves (agent from the name, id from the batch
1278
+ // map when present) instead of being dropped and mis-attributed by the ps-scan.
1159
1279
  const agent = meta?.labels?.agent;
1160
1280
  const sessionId = meta?.labels?.sessionId;
1161
1281
  if (agent && sessionId && (meta?.pane == null || meta.pane === pane))
1162
1282
  return { agent, sessionId };
1283
+ if (nameAgent)
1284
+ return { agent: nameAgent, sessionId: nameSessionId };
1163
1285
  return undefined;
1164
1286
  }
1165
1287
  /**
@@ -1184,8 +1306,12 @@ export async function listTmuxAgentSessions() {
1184
1306
  try {
1185
1307
  res = await runTmux({
1186
1308
  socket,
1187
- args: ['list-panes', '-a', '-F', '#{pane_id}\t#{session_name}\t#{pane_pid}\t#{pane_current_path}'],
1309
+ args: ['list-panes', '-a', '-F', ['#{pane_id}', '#{session_name}', '#{pane_pid}', '#{pane_current_path}'].join(TMUX_FIELD_SEP)],
1188
1310
  throwOnError: false,
1311
+ // A wedged tmux server must not hang the whole active-session scan. The
1312
+ // catch below turns a timeout into an empty tmux source (the other sources
1313
+ // still report) rather than a frozen `agents sessions --active`.
1314
+ timeoutMs: TMUX_LIST_PANES_TIMEOUT_MS,
1189
1315
  });
1190
1316
  }
1191
1317
  catch {
@@ -1208,17 +1334,29 @@ export async function listTmuxAgentSessions() {
1208
1334
  // (a non-Claude split) and needs the SessionStart-hook join.
1209
1335
  let hookIndex;
1210
1336
  const getHookIndex = () => (hookIndex ??= loadHookSessionIndex());
1337
+ // Resolve every `ag-<agent>-<shortid>` pane name to its full session UUID in one
1338
+ // batched DB round-trip, so resolvePaneIdentity can recover the id straight from
1339
+ // the pane name — the signal present on 100% of ag-* panes when the durable
1340
+ // identity stores are empty.
1341
+ const nameToFullId = resolveNamesToSessionIds(res.stdout.split('\n').map((l) => l.split(TMUX_FIELD_SEP)[1]).filter((n) => !!n), { findSessionsByShortIds });
1211
1342
  const out = [];
1212
1343
  const seen = new Set();
1213
1344
  for (const line of res.stdout.split('\n')) {
1214
1345
  if (!line.trim())
1215
1346
  continue;
1216
- const [pane, sessName, pidRaw, curPath] = line.split('\t');
1347
+ // The path is the LAST field, so rejoin its tail: a directory containing the
1348
+ // separator must not truncate it (the earlier fields cannot contain one).
1349
+ const parts = line.split(TMUX_FIELD_SEP);
1350
+ const [pane, sessName, pidRaw] = parts;
1351
+ const curPath = parts.slice(3).join(TMUX_FIELD_SEP);
1217
1352
  if (!pane || !sessName)
1218
1353
  continue;
1219
1354
  const meta = readSessionMeta(sessName);
1220
1355
  const liveEntry = liveByPane.get(pane);
1221
- let id = resolvePaneIdentity(pane, meta, liveEntry, getHookIndex);
1356
+ let id = resolvePaneIdentity(pane, sessName, meta, liveEntry, getHookIndex, nameToFullId);
1357
+ // Only a genuinely foreign pane — no live entry, no meta labels, and not one
1358
+ // of our `ag-*` names — is dropped now; every agent pane survives to be either
1359
+ // id-resolved or emitted as its own distinct id-less row.
1222
1360
  if (!id)
1223
1361
  continue;
1224
1362
  // RUSH-2007 Layer A: a non-Claude tmux session whose id resolved via neither the
@@ -1247,7 +1385,11 @@ export async function listTmuxAgentSessions() {
1247
1385
  // does NOT also surface this agent as a duplicate headless row.
1248
1386
  const pid = id.pid ?? (parseInt(pidRaw, 10) || undefined);
1249
1387
  const cwd = liveEntry?.cwd ?? meta?.cwd ?? (curPath || undefined);
1250
- const sessionFile = findSessionFileForKind(id.agent, cwd, id.sessionId);
1388
+ // Only resolve a transcript when we KNOW the session id. With no id,
1389
+ // findSessionFileForKind falls back to the newest .jsonl in the cwd — which
1390
+ // collapses every co-located pane onto one stranger's transcript (the ×N-badge
1391
+ // bug). Refuse to guess: an id-less pane surfaces as its own row instead.
1392
+ const sessionFile = id.sessionId ? findSessionFileForKind(id.agent, cwd, id.sessionId) : undefined;
1251
1393
  const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined;
1252
1394
  const pidAlive = pid ? isPidAlive(pid, liveEntry?.startedAtMs) : true;
1253
1395
  const { state, tokPerSec } = computeLiveSignals(id.agent, sessionFile, cwd, pidAlive);
@@ -1279,6 +1421,9 @@ export async function listTmuxAgentSessions() {
1279
1421
  lastActivityMs: mtimeMs,
1280
1422
  provenance,
1281
1423
  owner: resolveOwner(liveEntry?.actor, id.sessionId ?? sessionIdFromFile(sessionFile)),
1424
+ // An id-less pane keys its dedupe on the unique pane, so two anonymous
1425
+ // co-located panes stay two rows instead of folding into one.
1426
+ paneId: id.sessionId ?? sessionIdFromFile(sessionFile) ? undefined : pane,
1282
1427
  }, state, sessionFile, pidAlive));
1283
1428
  }
1284
1429
  return out;
@@ -1312,9 +1457,120 @@ export async function getActiveSessions(opts = {}) {
1312
1457
  await enrichProvenance(merged);
1313
1458
  await resolveOrigins(merged);
1314
1459
  foldPresence(merged);
1460
+ await foldTmuxClients(merged);
1461
+ foldHostLink(merged);
1315
1462
  annotateOrchestratorLabels(merged);
1316
1463
  return merged;
1317
1464
  }
1465
+ /**
1466
+ * Fold tmux's attached-client count onto every tmux-hosted row.
1467
+ *
1468
+ * Keyed off `provenance.mux` — which {@link enrichProvenance} has already stamped
1469
+ * on any row whose process env names a tmux pane — rather than off
1470
+ * {@link listTmuxAgentSessions}. That source only emits a row when it can resolve
1471
+ * the pane's agent IDENTITY (launch registry or session meta), and on a machine
1472
+ * where neither resolves it emits nothing at all while the same sessions still
1473
+ * arrive through the terminal/headless sources carrying full tmux provenance.
1474
+ * Hanging the client count off the identity-resolving source would have made the
1475
+ * whole orphan signal silently dead on exactly those machines.
1476
+ *
1477
+ * One `list-panes` per distinct socket, and only when some row is tmux-hosted —
1478
+ * a fleet with no tmux pays nothing. A query failure leaves the count undefined,
1479
+ * which the classifier reads as "cannot tell", never as a false zero.
1480
+ */
1481
+ export async function foldTmuxClients(rows) {
1482
+ const tmuxRows = rows.filter((s) => s.provenance?.mux?.kind === 'tmux' && s.provenance.mux.pane);
1483
+ if (tmuxRows.length === 0)
1484
+ return;
1485
+ const { runTmux } = await import('../tmux/binary.js');
1486
+ const sockets = new Set(tmuxRows.map((s) => s.provenance.mux.socket));
1487
+ for (const socket of sockets) {
1488
+ const byPane = new Map();
1489
+ try {
1490
+ const res = await runTmux({
1491
+ socket,
1492
+ args: ['list-panes', '-a', '-F', `#{pane_id}${TMUX_FIELD_SEP}#{session_attached}`],
1493
+ throwOnError: false,
1494
+ });
1495
+ if (res.code !== 0)
1496
+ continue;
1497
+ for (const line of res.stdout.split('\n')) {
1498
+ const [pane, attached] = line.split(TMUX_FIELD_SEP);
1499
+ if (!pane)
1500
+ continue;
1501
+ const n = parseInt(attached ?? '', 10);
1502
+ // A tmux too old to report `session_attached` yields NaN — leave the pane
1503
+ // unmapped so it stays "cannot tell" rather than becoming a false zero.
1504
+ if (Number.isFinite(n))
1505
+ byPane.set(pane, n);
1506
+ }
1507
+ }
1508
+ catch {
1509
+ continue; // best-effort: no count is honest, a guessed count is not
1510
+ }
1511
+ for (const s of tmuxRows) {
1512
+ if (s.provenance.mux.socket !== socket)
1513
+ continue;
1514
+ const n = byPane.get(s.provenance.mux.pane);
1515
+ if (n !== undefined)
1516
+ s.tmuxClients = n;
1517
+ }
1518
+ }
1519
+ }
1520
+ /**
1521
+ * Fold the host link onto each row and, where it changes the answer, onto the
1522
+ * status. Runs AFTER {@link foldPresence}, because a deliberately backgrounded
1523
+ * session (`presence` `background`/`parked`) is supposed to have no client and
1524
+ * must not be reported as an orphan.
1525
+ *
1526
+ * Precedence is deliberate, and the two new statuses slot in where they add
1527
+ * information rather than destroy it:
1528
+ *
1529
+ * - `abandoned` wins outright. A days-stale session is already dangling; that
1530
+ * it also lost its window is not the headline, and it keeps a crashed row
1531
+ * from lingering as an alert forever.
1532
+ * - `crashed` REPLACES `closed`. Both mean the process is gone, but `closed`
1533
+ * reads as a normal exit; `crashed` says the host window went down with it
1534
+ * and never cleaned up.
1535
+ * - `orphaned` replaces only `idle` / `input_required`. A session still WORKING
1536
+ * with nobody watching is a normal headless run, and flagging every one would
1537
+ * bury the real signal. A session sitting idle — or worse, waiting on a
1538
+ * question — with no client attached is the stranded case: nobody is coming.
1539
+ */
1540
+ export function foldHostLink(rows) {
1541
+ for (const s of rows) {
1542
+ // Cloud tasks have no local pid, window, or tmux server; there is no host
1543
+ // link to classify and no honest answer to give.
1544
+ if (s.context === 'cloud')
1545
+ continue;
1546
+ // A days-stale row is already `abandoned`; whether its window is also gone
1547
+ // adds nothing, and its pid liveness is genuinely unknown from the status
1548
+ // (abandoned outranks closed), so claim no host link rather than guess one.
1549
+ if (s.status === 'abandoned')
1550
+ continue;
1551
+ // `closed` IS the dead-pid status — `lifecycleStatus` assigns it from
1552
+ // `!pidAlive` and nothing else — so the status is the pid answer here.
1553
+ const link = classifyHostLink({
1554
+ pidAlive: s.status !== 'closed',
1555
+ windowHeartbeatMs: s.windowHeartbeatMs,
1556
+ tmuxClients: s.tmuxClients,
1557
+ deliberatelyDetached: s.presence === 'background' || s.presence === 'parked',
1558
+ });
1559
+ s.hostLink = link;
1560
+ // `foldPresence` gives every terminal row a DERIVED `attached` — "a live
1561
+ // interactive TUI you're watching" — which is exactly the claim a lost host
1562
+ // disproves. A stored record never yields `attached` (it is background or
1563
+ // parked), so clearing only that value drops the derived lie and leaves a
1564
+ // real detach record untouched.
1565
+ if (link !== 'connected' && s.presence === 'attached')
1566
+ s.presence = undefined;
1567
+ if (link === 'host-gone' && s.status === 'closed')
1568
+ s.status = 'crashed';
1569
+ else if (link === 'no-client' && (s.status === 'idle' || s.status === 'input_required')) {
1570
+ s.status = 'orphaned';
1571
+ }
1572
+ }
1573
+ }
1318
1574
  /**
1319
1575
  * Resolve each teams row's `orchestratorLabel` from the orchestrator's own row,
1320
1576
  * when that orchestrator session is itself in the active set (it usually is — the
@@ -1450,7 +1706,7 @@ export function dedupeBySession(sessions) {
1450
1706
  const out = [];
1451
1707
  const byKey = new Map();
1452
1708
  for (const s of sessions) {
1453
- const key = s.sessionId || s.sessionFile || s.cloudTaskId || s.agentId || anonymousWorkerKey(s);
1709
+ const key = s.sessionId || s.sessionFile || s.cloudTaskId || s.agentId || s.paneId || anonymousWorkerKey(s);
1454
1710
  if (!key) {
1455
1711
  out.push(s);
1456
1712
  continue;
@@ -341,6 +341,20 @@ export declare function getSessionById(id: string): SessionMeta | null;
341
341
  * ambiguous prefix disambiguates against the caller's context.
342
342
  */
343
343
  export declare function findSessionsById(idQuery: string, scope?: Pick<QueryOptions, 'agent' | 'version' | 'cwd' | 'project'>): SessionMeta[];
344
+ /**
345
+ * Batch-resolve many 8-char short ids to their sessions in ONE indexed query.
346
+ * The live-scan path (listTmuxAgentSessions) turns every `ag-<agent>-<shortid>`
347
+ * tmux pane name back into a full session id this way, so it pays a single
348
+ * `short_id IN (…)` round-trip per scan instead of N per-pane lookups.
349
+ *
350
+ * Returns a map keyed by short_id (lowercased). Short ids are the first 8 chars
351
+ * of the lowercase session UUID (deriveShortId), so a lowercased `IN` matches and
352
+ * still uses idx_sessions_short_id. When several sessions share a short id — only
353
+ * time-ordered ids (ULID/UUIDv7) ever collide; random UUIDv4 short ids are unique
354
+ * in practice — the most-recently-active one wins (the caller can further
355
+ * disambiguate by cwd).
356
+ */
357
+ export declare function findSessionsByShortIds(shortIds: string[]): Map<string, SessionMeta>;
344
358
  /** A single full-text search result with ranking score. */
345
359
  export interface FtsHit {
346
360
  sessionId: string;
@@ -1567,6 +1567,41 @@ export function findSessionsById(idQuery, scope = {}) {
1567
1567
  return exact;
1568
1568
  return querySessions({ ...scope, idPrefix: q });
1569
1569
  }
1570
+ /**
1571
+ * Batch-resolve many 8-char short ids to their sessions in ONE indexed query.
1572
+ * The live-scan path (listTmuxAgentSessions) turns every `ag-<agent>-<shortid>`
1573
+ * tmux pane name back into a full session id this way, so it pays a single
1574
+ * `short_id IN (…)` round-trip per scan instead of N per-pane lookups.
1575
+ *
1576
+ * Returns a map keyed by short_id (lowercased). Short ids are the first 8 chars
1577
+ * of the lowercase session UUID (deriveShortId), so a lowercased `IN` matches and
1578
+ * still uses idx_sessions_short_id. When several sessions share a short id — only
1579
+ * time-ordered ids (ULID/UUIDv7) ever collide; random UUIDv4 short ids are unique
1580
+ * in practice — the most-recently-active one wins (the caller can further
1581
+ * disambiguate by cwd).
1582
+ */
1583
+ export function findSessionsByShortIds(shortIds) {
1584
+ const out = new Map();
1585
+ const uniq = [...new Set(shortIds.map((s) => s.trim().toLowerCase()).filter(Boolean))];
1586
+ if (uniq.length === 0)
1587
+ return out;
1588
+ const db = getDB();
1589
+ const CHUNK = 500; // stay well under SQLite's default 999-variable limit
1590
+ for (let i = 0; i < uniq.length; i += CHUNK) {
1591
+ const batch = uniq.slice(i, i + CHUNK);
1592
+ const placeholders = batch.map(() => '?').join(',');
1593
+ // timestamp ASC so a later (newer) row overwrites an earlier one per short_id.
1594
+ const rows = db
1595
+ .prepare(`SELECT * FROM sessions WHERE short_id IN (${placeholders}) ORDER BY timestamp ASC`)
1596
+ .all(...batch);
1597
+ for (const row of rows) {
1598
+ const key = (row.short_id ?? '').toLowerCase();
1599
+ if (key)
1600
+ out.set(key, rowToMeta(row));
1601
+ }
1602
+ }
1603
+ return out;
1604
+ }
1570
1605
  /**
1571
1606
  * Escape a raw user query into a safe FTS5 MATCH expression.
1572
1607
  * Splits on non-word characters, keeps tokens >= 2 chars, and OR-joins