@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.
- package/CHANGELOG.md +240 -0
- package/README.md +6 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/exec.js +7 -1
- package/dist/commands/harness.d.ts +27 -0
- package/dist/commands/harness.js +120 -13
- package/dist/commands/profiles.d.ts +3 -0
- package/dist/commands/profiles.js +1 -1
- package/dist/commands/routines.d.ts +19 -0
- package/dist/commands/routines.js +28 -6
- package/dist/commands/secrets.d.ts +10 -1
- package/dist/commands/secrets.js +18 -6
- package/dist/commands/sessions-browser.d.ts +4 -0
- package/dist/commands/sessions-browser.js +51 -9
- package/dist/commands/sessions-favorite.d.ts +20 -0
- package/dist/commands/sessions-favorite.js +120 -0
- package/dist/commands/sessions.d.ts +103 -20
- package/dist/commands/sessions.js +356 -62
- package/dist/commands/setup-secrets.d.ts +7 -0
- package/dist/commands/setup-secrets.js +12 -9
- package/dist/commands/versions.js +12 -4
- package/dist/commands/view.d.ts +14 -1
- package/dist/commands/view.js +103 -128
- package/dist/lib/agents.d.ts +4 -2
- package/dist/lib/agents.js +21 -6
- package/dist/lib/hosts/dispatch.js +19 -1
- package/dist/lib/hq/floor.js +12 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/picker.d.ts +27 -2
- package/dist/lib/picker.js +71 -7
- package/dist/lib/profiles.d.ts +48 -0
- package/dist/lib/profiles.js +67 -0
- package/dist/lib/rotate.d.ts +24 -2
- package/dist/lib/rotate.js +63 -6
- 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/session/active.d.ts +109 -3
- package/dist/lib/session/active.js +269 -13
- package/dist/lib/session/db.d.ts +14 -0
- package/dist/lib/session/db.js +35 -0
- package/dist/lib/session/favorites.d.ts +39 -0
- package/dist/lib/session/favorites.js +101 -0
- package/dist/lib/session/host-link.d.ts +68 -0
- package/dist/lib/session/host-link.js +64 -0
- package/dist/lib/session/presence.d.ts +85 -0
- package/dist/lib/session/presence.js +150 -0
- package/dist/lib/session/remote-list.d.ts +10 -0
- package/dist/lib/session/remote-list.js +47 -9
- package/dist/lib/tmux/binary.d.ts +7 -0
- package/dist/lib/tmux/binary.js +11 -1
- package/dist/lib/types.d.ts +4 -3
- package/dist/lib/usage-backoff.d.ts +29 -0
- package/dist/lib/usage-backoff.js +165 -0
- package/dist/lib/usage.d.ts +112 -5
- package/dist/lib/usage.js +464 -46
- package/dist/lib/watchdog/runner.d.ts +13 -0
- package/dist/lib/watchdog/runner.js +16 -1
- 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'
|
|
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
|
-
/**
|
|
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
|
|
321
|
+
if (!e?.sessionId)
|
|
229
322
|
continue;
|
|
230
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/lib/session/db.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/session/db.js
CHANGED
|
@@ -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
|