@phnx-labs/agents-cli 1.22.118 → 1.22.119

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.
@@ -411,6 +411,37 @@ function projectRootsAbs(defs) {
411
411
  }
412
412
  return out;
413
413
  }
414
+ /**
415
+ * The repository paths a project claims, as `{ slug, prefix }` with `prefix`
416
+ * repo-relative and `dir/` shaped — the same ownership {@link projectRootsAbs}
417
+ * applies to a session's cwd, expressed against the repository instead of a
418
+ * checkout: a `defaultPath` narrowed under `root` claims that subtree of the
419
+ * primary `repo`, and a `repos[]` entry with a `subpath` claims that subpath. A
420
+ * project with no narrowed claim on a repository claims all of it and yields no
421
+ * row for it. Two cases of {@link projectRootsAbs} have no repository form and
422
+ * yield nothing here: a `defaultPath` outside `root` (no repo-relative path),
423
+ * and the weak root of a narrowed project (it only takes what nobody claims,
424
+ * which for a PR is the `repo-wide` scope).
425
+ */
426
+ export function repoPathClaims(def) {
427
+ const out = [];
428
+ const add = (slug, rel) => {
429
+ const trimmed = rel.split(path.sep).join('/').replace(/^\.\/+/, '').replace(/\/+$/, '');
430
+ if (trimmed && trimmed !== '.')
431
+ out.push({ slug, prefix: `${trimmed}/` });
432
+ };
433
+ if (def.repo && def.root && def.defaultPath) {
434
+ const rootAbs = path.resolve(expandLocalHome(def.root));
435
+ const defaultAbs = path.resolve(expandLocalHome(def.defaultPath));
436
+ if (isUnder(defaultAbs, rootAbs))
437
+ add(def.repo, path.relative(rootAbs, defaultAbs));
438
+ }
439
+ for (const r of def.repos ?? []) {
440
+ if (r.slug && r.subpath)
441
+ add(r.slug, r.subpath);
442
+ }
443
+ return out;
444
+ }
414
445
  /** True when `child` is `parent` or nested under it (path-segment aware). */
415
446
  function isUnder(child, parent) {
416
447
  if (child === parent)
@@ -52,10 +52,10 @@ export declare function attributedSetLostPids(prev: Set<number>, next: Set<numbe
52
52
  * process table. `alive` receives the row's `startedAtMs` so callers can apply
53
53
  * the same pid-reuse guard {@link isPidAlive} uses elsewhere.
54
54
  */
55
- export declare function filterCachedUnattributed(sessions: ActiveSession[], attributed: Set<number>, alive: (pid: number, startedAtMs?: number) => boolean): ActiveSession[];
55
+ export declare function filterCachedUnattributed(sessions: ActiveSession[], attributed: Set<number>, alive: (pid: number, startedAtMs?: number) => boolean, ppidMap?: Map<number, number>): ActiveSession[];
56
56
  type ActiveContext = 'terminal' | 'teams' | 'cloud' | 'headless';
57
57
  /** The SessionMeta fields the live-row backfill reads — the enrichment a running process cannot report. */
58
- export type BackfillMeta = Pick<SessionMeta, 'version' | 'account' | 'timestamp' | 'label' | 'firstUserMessage' | 'lastUserMessage' | 'generatedTitle' | 'ticketId' | 'prUrl' | 'prNumber' | 'origin' | 'routineName' | 'harness' | 'tokenCount' | 'durationMs' | 'subAgentCount' | 'lastActivity'>;
58
+ export type BackfillMeta = Pick<SessionMeta, 'version' | 'account' | 'accountId' | 'accountKey' | 'timestamp' | 'label' | 'firstUserMessage' | 'lastUserMessage' | 'generatedTitle' | 'ticketId' | 'prUrl' | 'prNumber' | 'origin' | 'routineName' | 'harness' | 'tokenCount' | 'durationMs' | 'subAgentCount' | 'lastActivity'>;
59
59
  /**
60
60
  * Fold index-only enrichment onto live rows, then RE-DERIVE the recap for every
61
61
  * row it touched. The re-derive is load-bearing: `foldRecap` runs inside
@@ -65,6 +65,8 @@ export type BackfillMeta = Pick<SessionMeta, 'version' | 'account' | 'timestamp'
65
65
  * backfill call sites is what keeps them from drifting apart.
66
66
  */
67
67
  export declare function backfillActiveRowsFromMeta(sessions: ActiveSession[], metaById: Map<string, BackfillMeta>): void;
68
+ /** Resolve a display name only when the indexed identity names one registered slot. */
69
+ export declare function sessionAccountLabel(kind: string, session: Pick<SessionMeta, 'accountId' | 'accountKey' | 'account'>, accounts?: import("../account-registry.js").NativeAccount[]): string | undefined;
68
70
  export declare function backfillActiveRowsFromIndex(sessions: ActiveSession[]): void;
69
71
  export declare function isRunningLiveSession(s: ActiveSession): boolean;
70
72
  export declare function activeSessionProjectKey(s: Pick<ActiveSession, 'cwd' | 'context'>): string;
@@ -319,6 +321,13 @@ export interface ActiveSession {
319
321
  * `accountKey`.
320
322
  */
321
323
  account?: string;
324
+ /**
325
+ * Human account-slot name (e.g. `gmail` in `claude#gmail`), display-only.
326
+ * Backfilled alongside {@link account} from indexed accountId/accountKey/email
327
+ * and the account registry. Unknown or ambiguous slots stay undefined.
328
+ * Never group on this label; use the index's `accountKey`.
329
+ */
330
+ accountLabel?: string;
322
331
  /**
323
332
  * Last-activity epoch — the transcript's last write (mtime). Distinct from
324
333
  * {@link startedAtMs} (session START): a session begun 3h ago but last touched
@@ -486,7 +495,7 @@ export interface ActiveSession {
486
495
  };
487
496
  /**
488
497
  * The editor tab that launched this agent (`AGENT_TERMINAL_ID`), from the pid
489
- * registry. This is the one identifier that survives an SSH hop AND a session
498
+ * registry or the published terminal entry. This is the one identifier that survives an SSH hop AND a session
490
499
  * rotation: a Factory tab offloaded to a device has no local process to inspect,
491
500
  * and its spawn-time session id goes stale the moment the agent moves to another
492
501
  * session (`/clear`, exit-and-rerun), so `--active --device <device>` joined on
@@ -494,6 +503,8 @@ export interface ActiveSession {
494
503
  * did not inherit a terminal id.
495
504
  */
496
505
  terminalId?: string;
506
+ /** Published editor tab index, retained across agent restarts. */
507
+ tabIndex?: number;
497
508
  /**
498
509
  * The launch id (`AGENT_LAUNCH_ID`) the CLI stamps on every agent at spawn — a
499
510
  * stable UUID that is identical locally and across an SSH hop and survives a
@@ -501,9 +512,8 @@ export interface ActiveSession {
501
512
  * non-Claude harnesses only mint after boot) it exists from the first tick, so
502
513
  * it is the join key a client uses to re-identify a session on the watch stream.
503
514
  * Populated wherever the by-pid launch registry resolves — reliably for
504
- * `agents run`-launched processes; still absent for editor-launched terminals
505
- * whose shell pid does not line up with the agent-pid registry today (the same
506
- * limitation the `readPidSessionEntry` call in `listTerminalsActive` documents).
515
+ * `agents run`-launched processes and editor terminals with a recorded live
516
+ * agent descendant.
507
517
  */
508
518
  launchId?: string;
509
519
  /**
@@ -750,6 +760,7 @@ interface ProcRow {
750
760
  tty?: string;
751
761
  comm: string;
752
762
  kind?: string;
763
+ startTime?: string;
753
764
  }
754
765
  /** Parse `ConvertTo-Csv` output of Win32_Process rows. Exported for tests. */
755
766
  export declare function parseWin32ProcessCsv(out: string): ProcRow[];
@@ -24,7 +24,9 @@ import { execFile, execFileSync } from 'child_process';
24
24
  import { promisify } from 'util';
25
25
  import { listActiveTasks } from '../cloud/store.js';
26
26
  import { AgentManager } from '../teams/agents.js';
27
- import { getTerminalsDir } from '../state.js';
27
+ import { getTerminalsDir, readMeta } from '../state.js';
28
+ import { listNativeAccounts } from '../account-registry.js';
29
+ import { parseNativeIdentityKey } from '../native-accounts.js';
28
30
  import { readLivePidSessionEntry as readPidSessionEntry, listPidSessionEntries, prunePidSessionRegistry, sessionIdFromLivePid, isSessionIdShape, } from './pid-registry.js';
29
31
  import { readSessionActorRecord, writeSessionAliasRecord } from './actor-sidecar.js';
30
32
  import { loadHookSessionIndex, resolveHookSessionRecord, readStateSessionRecord } from './hook-sessions.js';
@@ -129,11 +131,11 @@ export function attributedSetLostPids(prev, next) {
129
131
  * process table. `alive` receives the row's `startedAtMs` so callers can apply
130
132
  * the same pid-reuse guard {@link isPidAlive} uses elsewhere.
131
133
  */
132
- export function filterCachedUnattributed(sessions, attributed, alive) {
134
+ export function filterCachedUnattributed(sessions, attributed, alive, ppidMap) {
133
135
  return sessions.filter((s) => {
134
136
  if (s.pid == null)
135
137
  return false;
136
- if (attributed.has(s.pid))
138
+ if (attributed.has(s.pid) || (ppidMap && hasAttributedAncestor(s.pid, ppidMap, attributed)))
137
139
  return false;
138
140
  return alive(s.pid, s.startedAtMs);
139
141
  });
@@ -147,6 +149,7 @@ export function filterCachedUnattributed(sessions, attributed, alive) {
147
149
  * backfill call sites is what keeps them from drifting apart.
148
150
  */
149
151
  export function backfillActiveRowsFromMeta(sessions, metaById) {
152
+ const accounts = listNativeAccounts(readMeta());
150
153
  for (const s of sessions) {
151
154
  if (!s.sessionId)
152
155
  continue;
@@ -157,6 +160,7 @@ export function backfillActiveRowsFromMeta(sessions, metaById) {
157
160
  s.version = m.version;
158
161
  if (!s.account && m.account)
159
162
  s.account = m.account;
163
+ s.accountLabel ??= sessionAccountLabel(s.kind, m, accounts);
160
164
  if (!s.label && m.label)
161
165
  s.label = m.label;
162
166
  if (!s.firstUserMessage && m.firstUserMessage)
@@ -196,6 +200,30 @@ export function backfillActiveRowsFromMeta(sessions, metaById) {
196
200
  applyRecap(s);
197
201
  }
198
202
  }
203
+ /** Resolve a display name only when the indexed identity names one registered slot. */
204
+ export function sessionAccountLabel(kind, session, accounts = listNativeAccounts(readMeta())) {
205
+ const matches = accounts.filter(account => {
206
+ if (account.agent !== kind)
207
+ return false;
208
+ if (session.accountId)
209
+ return account.id === session.accountId;
210
+ if (session.accountKey && !session.accountKey.startsWith('unattributed:')) {
211
+ // Claude's index groups quota by org; the registry names a login within it.
212
+ const indexed = parseNativeIdentityKey(account.agent, session.accountKey);
213
+ const emailMatches = !!session.account && account.identityLabel?.toLowerCase() === session.account.toLowerCase();
214
+ if (!indexed || (!indexed.account && !emailMatches))
215
+ return false;
216
+ if (account.identityKey === session.accountKey)
217
+ return true;
218
+ const registered = parseNativeIdentityKey(account.agent, account.identityKey);
219
+ return !!registered
220
+ && Object.entries(indexed).every(([key, value]) => registered[key] === value)
221
+ && (!session.account || emailMatches);
222
+ }
223
+ return !!session.account && account.identityLabel?.toLowerCase() === session.account.toLowerCase();
224
+ });
225
+ return matches.length === 1 ? matches[0].name : undefined;
226
+ }
199
227
  function loadBackfillMetaFor(sessions) {
200
228
  const byId = new Map();
201
229
  try {
@@ -1062,49 +1090,48 @@ export async function listTerminalsActive() {
1062
1090
  const procByPid = new Map();
1063
1091
  for (const r of await readProcessTable())
1064
1092
  procByPid.set(r.pid, r);
1093
+ const children = childrenByParent(new Map([...procByPid.values()].map(r => [r.pid, r.ppid])));
1065
1094
  // Build label map from Claude's sessions/*.json for /rename support
1066
1095
  const labelMap = buildClaudeLabelMap();
1067
1096
  // Run-name handles (`agents run --name`) keyed by session id, for the same
1068
1097
  // sessionId → handle resolution as labels.
1069
1098
  const runNameMap = buildRunNameMap();
1070
1099
  return entries.map((t) => {
1071
- // The id cached in live-terminals.json goes stale when Claude rotates its
1072
- // transcript uuid on resume/compact, so it often no longer matches any
1073
- // <id>.jsonl. When the pid registry knows this pid's current id, prefer it —
1074
- // the same source the headless path uses. NOTE: live-terminals.json stores the
1075
- // SHELL pid, while the by-pid registry is keyed by the AGENT pid, so for
1076
- // editor-launched terminals this lookup returns undefined today and we fall
1077
- // back to the stale cached id — the duplicate-card fix comes from
1078
- // pickSessionFile no longer borrowing a sibling, not from this lookup. Kept as
1079
- // a forward-looking hook for the cases where the pid does line up.
1080
- const pidEntry = readPidSessionEntry(t.pid);
1100
+ // Keep the shell pid for tab ownership and headless suppression; the live
1101
+ // agent below it owns the session after an exit-and-rerun in the same tab.
1102
+ const directEntry = readPidSessionEntry(t.pid, procByPid.get(t.pid)?.startTime);
1103
+ const candidate = directEntry?.sessionId ? directEntry
1104
+ : (!t.pidDead ? terminalDescendantEntry(t.pid, procByPid, children) : undefined) ?? directEntry;
1105
+ const pidEntry = candidate && (!isSessionTrackedAgent(t.kind) || candidate.agent === t.kind) ? candidate : undefined;
1081
1106
  const resolvedId = pidEntry?.sessionId ?? t.sessionId;
1082
- const sessionFile = findSessionFileForKind(t.kind, t.cwd ?? undefined, resolvedId);
1083
- // Prefer label from live terminal, fall back to Claude's session label
1084
- const label = t.label ?? (t.sessionId ? labelMap.get(t.sessionId) : undefined) ?? undefined;
1107
+ const cwd = pidEntry?.cwd ?? t.cwd ?? undefined;
1108
+ const sessionKind = pidEntry?.agent ?? t.kind;
1109
+ const sessionFile = findSessionFileForKind(sessionKind, cwd, resolvedId);
1110
+ const label = t.label ?? (resolvedId ? labelMap.get(resolvedId) : undefined) ?? undefined;
1085
1111
  // Durable run name from `agents run --name`, resolved by the run's session id.
1086
1112
  const name = resolvedId ? runNameMap.get(resolvedId) ?? undefined : undefined;
1087
1113
  // Extract topic from session file (first meaningful user message)
1088
1114
  const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined;
1089
1115
  const pidAlive = isPidAlive(t.pid, t.startedAtMs);
1090
- const { state, tokPerSec } = computeLiveSignals(t.kind, sessionFile, t.cwd ?? undefined, pidAlive);
1116
+ const { state, tokPerSec } = computeLiveSignals(sessionKind, sessionFile, cwd, pidAlive);
1091
1117
  return applyState({
1092
1118
  context: 'terminal',
1093
- kind: t.kind,
1119
+ kind: sessionKind,
1094
1120
  harness: pidEntry?.harness,
1095
1121
  host: detectHost(t.pid, procByPid),
1096
1122
  tty: procByPid.get(t.pid)?.tty,
1097
1123
  pid: t.pid,
1098
- sessionId: t.sessionId ?? sessionIdFromFile(sessionFile),
1124
+ sessionId: resolvedId ?? sessionIdFromFile(sessionFile),
1099
1125
  launchId: pidEntry?.launchId,
1100
- terminalId: pidEntry?.terminalId,
1101
- cwd: t.cwd ?? undefined,
1126
+ terminalId: pidEntry?.terminalId ?? t.terminalId,
1127
+ tabIndex: t.tabIndex,
1128
+ cwd,
1102
1129
  label,
1103
1130
  name,
1104
1131
  topic,
1105
1132
  tokPerSec,
1106
1133
  sessionFile,
1107
- startedAtMs: t.startedAtMs,
1134
+ startedAtMs: pidEntry?.startedAtMs ?? t.startedAtMs,
1108
1135
  lastActivityMs: sessionFileTimes(sessionFile).mtimeMs,
1109
1136
  windowId: t.windowId,
1110
1137
  windowHeartbeatMs: t.windowHeartbeatMs,
@@ -1183,16 +1210,16 @@ async function readProcessTableLive() {
1183
1210
  return readProcessTableWin32();
1184
1211
  let out;
1185
1212
  try {
1186
- ({ stdout: out } = await execFileAsync('ps', ['-A', '-o', 'pid=,ppid=,tty=,comm='], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, timeout: PS_SNAPSHOT_TIMEOUT_MS }));
1213
+ ({ stdout: out } = await execFileAsync('ps', ['-A', '-o', 'pid=,ppid=,tty=,lstart=,comm='], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, timeout: PS_SNAPSHOT_TIMEOUT_MS }));
1187
1214
  }
1188
1215
  catch {
1189
1216
  return [];
1190
1217
  }
1191
1218
  const rows = [];
1192
1219
  for (const line of out.split('\n')) {
1193
- // pid ppid tty comm — tty is a single token ('ttys003', 's003', or '??'/'?'
1220
+ // pid ppid tty lstart comm — tty is a single token ('ttys003', 's003', or '??'/'?'
1194
1221
  // for none); comm stays last so it may contain spaces.
1195
- const m = line.trim().match(/^(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/);
1222
+ const m = line.trim().match(/^(\d+)\s+(\d+)\s+(\S+)\s+(\S+\s+\S+\s+\d+\s+\S+\s+\d+)\s+(.+)$/);
1196
1223
  if (!m)
1197
1224
  continue;
1198
1225
  const pid = parseInt(m[1], 10);
@@ -1201,8 +1228,8 @@ async function readProcessTableLive() {
1201
1228
  continue;
1202
1229
  const ttyRaw = m[3];
1203
1230
  const tty = ttyRaw === '??' || ttyRaw === '?' || ttyRaw === '-' ? undefined : ttyRaw;
1204
- const commRaw = m[4].trim();
1205
- rows.push({ pid, ppid, tty, comm: commRaw, kind: agentKindFromComm(commRaw) });
1231
+ const commRaw = m[5].trim();
1232
+ rows.push({ pid, ppid, tty, startTime: m[4], comm: commRaw, kind: agentKindFromComm(commRaw) });
1206
1233
  }
1207
1234
  return rows;
1208
1235
  }
@@ -1240,6 +1267,50 @@ export function parseWin32ProcessCsv(out) {
1240
1267
  }
1241
1268
  return rows;
1242
1269
  }
1270
+ function childrenByParent(ppidMap) {
1271
+ const children = new Map();
1272
+ for (const [pid, ppid] of ppidMap) {
1273
+ const siblings = children.get(ppid);
1274
+ if (siblings)
1275
+ siblings.push(pid);
1276
+ else
1277
+ children.set(ppid, [pid]);
1278
+ }
1279
+ return children;
1280
+ }
1281
+ function terminalDescendantEntry(pid, processes, children) {
1282
+ // Windows only proves pidExists: a recycled PID can be a live shell descendant.
1283
+ // Retain the published identity until that platform can prove process starts.
1284
+ if (process.platform === 'win32')
1285
+ return undefined;
1286
+ let pending = children.get(pid) ?? [];
1287
+ const seen = new Set([pid]);
1288
+ while (pending.length) {
1289
+ const next = [];
1290
+ let recordedAgent = false;
1291
+ let newest;
1292
+ for (const child of pending) {
1293
+ if (seen.has(child))
1294
+ continue;
1295
+ seen.add(child);
1296
+ const entry = readPidSessionEntry(child, processes.get(child)?.startTime);
1297
+ if (!entry || !isSessionTrackedAgent(entry.agent)) {
1298
+ next.push(...(children.get(child) ?? []));
1299
+ continue;
1300
+ }
1301
+ // A recorded agent is a boundary even before its session ID is known:
1302
+ // its tools may launch agents of their own, which never own this tab.
1303
+ recordedAgent = true;
1304
+ if (entry.sessionId && (!newest || entry.startedAtMs > newest.startedAtMs
1305
+ || (entry.startedAtMs === newest.startedAtMs && entry.pid > newest.pid)))
1306
+ newest = entry;
1307
+ }
1308
+ if (recordedAgent)
1309
+ return newest;
1310
+ pending = next;
1311
+ }
1312
+ return undefined;
1313
+ }
1243
1314
  /**
1244
1315
  * True when any ancestor in pid's parent chain is a known attributed PID.
1245
1316
  * VS Code / Cursor terminals store the *shell* PID in live-terminals.json,
@@ -1456,11 +1527,11 @@ export async function listUnattributedActive(attributed) {
1456
1527
  if (unattributedCache &&
1457
1528
  now - unattributedCache.at < UNATTRIBUTED_RESCAN_MS &&
1458
1529
  !attributedSetLostPids(unattributedCache.attributed, attributed)) {
1459
- return filterCachedUnattributed(unattributedCache.sessions, attributed, isPidAlive);
1530
+ return filterCachedUnattributed(unattributedCache.sessions, attributed, isPidAlive, unattributedCache.ppidMap);
1460
1531
  }
1461
- const out = await listUnattributedActiveLive(attributed);
1462
- unattributedCache = { at: now, attributed: new Set(attributed), sessions: out };
1463
- return out;
1532
+ const result = await listUnattributedActiveLive(attributed);
1533
+ unattributedCache = { at: now, attributed: new Set(attributed), ...result };
1534
+ return result.sessions;
1464
1535
  }
1465
1536
  /** Unthrottled headless scan (live process table + cwd probes). */
1466
1537
  async function listUnattributedActiveLive(attributed) {
@@ -1495,28 +1566,13 @@ async function listUnattributedActiveLive(attributed) {
1495
1566
  // lacks an exact launch-time id, so an all-Claude set does neither. The ~3s
1496
1567
  // poll must not re-read the dir (or re-invert the map) per candidate.
1497
1568
  let hookIndex;
1498
- let childrenByParent;
1569
+ let children;
1499
1570
  // Durable `agents run --name` handles keyed by session id — the same source the
1500
1571
  // terminal path uses to name a row. Headless agents have no live-terminals
1501
1572
  // label and no /rename, so without this a `--name`d headless run would surface
1502
1573
  // with only a topic and no tab title. Built once per scan.
1503
1574
  const runNameMap = buildRunNameMap();
1504
- const ensureChildren = () => {
1505
- if (childrenByParent)
1506
- return childrenByParent;
1507
- const m = new Map();
1508
- // The hook records under the agent pid; a wrapper/shell pid we recorded has
1509
- // the agent as a child, so we resolve via a recorded pid's immediate children.
1510
- for (const [childPid, parentPid] of ppidMap) {
1511
- const arr = m.get(parentPid);
1512
- if (arr)
1513
- arr.push(childPid);
1514
- else
1515
- m.set(parentPid, [childPid]);
1516
- }
1517
- childrenByParent = m;
1518
- return m;
1519
- };
1575
+ const ensureChildren = () => children ??= childrenByParent(ppidMap);
1520
1576
  const out = [];
1521
1577
  for (let i = 0; i < kept.length; i++) {
1522
1578
  const { pid, kind } = kept[i];
@@ -1603,7 +1659,7 @@ async function listUnattributedActiveLive(attributed) {
1603
1659
  }
1604
1660
  // Housekeeping: drop registry files for pids that have since died.
1605
1661
  prunePidSessionRegistry(isPidAlive);
1606
- return out;
1662
+ return { sessions: out, ppidMap };
1607
1663
  }
1608
1664
  /**
1609
1665
  * Attribute a single tmux pane to the agent actually running in it.
@@ -58,8 +58,10 @@ export interface PidSessionEntry {
58
58
  startTime?: string;
59
59
  };
60
60
  }
61
+ /** ps lstart fields can differ in padding or date formatting between invocations. */
62
+ export declare function processStartTimesMatch(recorded: string | undefined, observed: string | undefined): boolean | undefined;
61
63
  /** Read-only identity proof for exact joins. Unknown is never a positive match. */
62
- export declare function pidSessionEntryMatchesLiveProcess(entry: PidSessionEntry): boolean | undefined;
64
+ export declare function pidSessionEntryMatchesLiveProcess(entry: PidSessionEntry, startTime?: string): boolean | undefined;
63
65
  export declare function isSessionIdShape(value: string): boolean;
64
66
  export declare function extractSessionIdArg(args: string[]): string | undefined;
65
67
  /**
@@ -86,8 +88,9 @@ export declare function sessionIdFromLivePid(pid: number): string | undefined;
86
88
  export declare function writePidSessionEntry(entry: PidSessionEntry): void;
87
89
  /** Look up a live pid's recorded session. Returns undefined if absent/corrupt. */
88
90
  export declare function readPidSessionEntry(pid: number): PidSessionEntry | undefined;
89
- /** Live consumers require identity proof; raw history is not a PID binding. */
90
- export declare function readLivePidSessionEntry(pid: number): PidSessionEntry | undefined;
91
+ /** Live consumers require identity proof; raw history is not a PID binding.
92
+ * A shared process snapshot may supply lstart to avoid a per-pid ps probe. */
93
+ export declare function readLivePidSessionEntry(pid: number, startTime?: string): PidSessionEntry | undefined;
91
94
  /**
92
95
  * Every recorded launch, one entry per live-or-dead pid. Used to index launches
93
96
  * by their `tmuxPane` so the authoritative tmux source can attribute a pane it did
@@ -55,16 +55,25 @@ function darwinStartTime(pid) {
55
55
  return undefined;
56
56
  }
57
57
  }
58
+ function processStartTimeMs(value) {
59
+ return typeof value === 'string' ? Date.parse(value.trim().replace(/\s+/g, ' ')) : NaN;
60
+ }
61
+ /** ps lstart fields can differ in padding or date formatting between invocations. */
62
+ export function processStartTimesMatch(recorded, observed) {
63
+ const recordedMs = processStartTimeMs(recorded);
64
+ const observedMs = processStartTimeMs(observed);
65
+ return Number.isFinite(recordedMs) && Number.isFinite(observedMs) ? recordedMs === observedMs : undefined;
66
+ }
58
67
  /** Read-only identity proof for exact joins. Unknown is never a positive match. */
59
- export function pidSessionEntryMatchesLiveProcess(entry) {
68
+ export function pidSessionEntryMatchesLiveProcess(entry, startTime) {
60
69
  if (!Number.isInteger(entry.pid) || entry.pid < 1)
61
70
  return undefined;
62
71
  if (process.platform === 'darwin') {
63
72
  const exists = pidExists(entry.pid);
64
73
  if (exists !== true)
65
74
  return exists;
66
- const start = darwinStartTime(entry.pid);
67
- return start && entry.processIdentity?.startTime ? start === entry.processIdentity.startTime : undefined;
75
+ const start = startTime ?? darwinStartTime(entry.pid);
76
+ return processStartTimesMatch(entry.processIdentity?.startTime, start);
68
77
  }
69
78
  if (process.platform !== 'linux')
70
79
  return undefined;
@@ -233,8 +242,9 @@ export function readPidSessionEntry(pid) {
233
242
  }
234
243
  return undefined;
235
244
  }
236
- /** Live consumers require identity proof; raw history is not a PID binding. */
237
- export function readLivePidSessionEntry(pid) {
245
+ /** Live consumers require identity proof; raw history is not a PID binding.
246
+ * A shared process snapshot may supply lstart to avoid a per-pid ps probe. */
247
+ export function readLivePidSessionEntry(pid, startTime) {
238
248
  const entry = readPidSessionEntry(pid);
239
249
  if (!entry || !hostProcessView())
240
250
  return undefined;
@@ -244,8 +254,8 @@ export function readLivePidSessionEntry(pid) {
244
254
  // Upgrade a native legacy launch only after the home namespace is proven,
245
255
  // and the actual process start excludes a recycled PID. Hook timestamps may
246
256
  // be later than process start, but cannot predate this process incarnation.
247
- const start = darwinStartTime(pid);
248
- const startMs = start ? Date.parse(start) : NaN;
257
+ const start = startTime ?? darwinStartTime(pid);
258
+ const startMs = processStartTimeMs(start);
249
259
  if (Number.isFinite(startMs) && entry.startedAtMs >= startMs - 1000 && entry.startedAtMs <= Date.now()) {
250
260
  const scope = hostProcessView();
251
261
  entry.processIdentity = process.platform === 'linux'
@@ -257,7 +267,7 @@ export function readLivePidSessionEntry(pid) {
257
267
  catch { /* read remains useful */ }
258
268
  }
259
269
  }
260
- return pidSessionEntryMatchesLiveProcess(entry) === true ? entry : undefined;
270
+ return pidSessionEntryMatchesLiveProcess(entry, startTime) === true ? entry : undefined;
261
271
  }
262
272
  /**
263
273
  * Every recorded launch, one entry per live-or-dead pid. Used to index launches
@@ -88,7 +88,7 @@ export declare function sessionWatchRowKey(scope: string, row: ActiveSession): s
88
88
  export declare function toSessionWatchRow(scope: string, row: ActiveSession): SessionWatchRow;
89
89
  /** Project one durable indexed session into the same canonical watch contract
90
90
  * as live sessions. This is the only history backfill consumed by AGI EXT. */
91
- export declare function toPreviousSessionWatchRow(scope: string, session: SessionMeta): SessionWatchRow;
91
+ export declare function toPreviousSessionWatchRow(scope: string, session: SessionMeta, accounts?: import("../../account-registry.js").NativeAccount[]): SessionWatchRow;
92
92
  /** Stream-local sequencer and convergent row diff. */
93
93
  export declare class SessionWatchState {
94
94
  readonly streamId: string;
@@ -8,7 +8,9 @@ import { machineId, normalizeHost } from '../../machine-id.js';
8
8
  import { shellQuote } from '../../ssh-exec.js';
9
9
  import { streamFromPeer } from './peer-stream.js';
10
10
  import { buildWindowsAgentsCommand, remoteShellFor } from '../../hosts/remote-cmd.js';
11
- import { isReapableOrphan } from '../active.js';
11
+ import { isReapableOrphan, sessionAccountLabel } from '../active.js';
12
+ import { listNativeAccounts } from '../../account-registry.js';
13
+ import { readMeta } from '../../state.js';
12
14
  import { querySessions, readSessionSummaryAny, readSessionTimelineAny } from '../db.js';
13
15
  import { linearIssueUrl } from '../linear.js';
14
16
  import { sessionAgentSupportsResume } from '../recovery.js';
@@ -79,7 +81,7 @@ function previousRowTitle(session, foldedHeadline) {
79
81
  }
80
82
  /** Project one durable indexed session into the same canonical watch contract
81
83
  * as live sessions. This is the only history backfill consumed by AGI EXT. */
82
- export function toPreviousSessionWatchRow(scope, session) {
84
+ export function toPreviousSessionWatchRow(scope, session, accounts = listNativeAccounts(readMeta())) {
83
85
  const resumable = sessionAgentSupportsResume(session.agent);
84
86
  const sourceDevice = normalizeHost(session.machine ?? scope);
85
87
  const startedAtMs = epochMs(session.timestamp);
@@ -125,6 +127,7 @@ export function toPreviousSessionWatchRow(scope, session) {
125
127
  ...(session.firstUserMessage ? { firstUserMessage: session.firstUserMessage } : {}),
126
128
  ...(session.version ? { version: session.version } : {}),
127
129
  ...(session.account ? { account: session.account } : {}),
130
+ accountLabel: sessionAccountLabel(session.agent, session, accounts),
128
131
  ...(session.prUrl ? { pr: { url: session.prUrl, number: session.prNumber } } : {}),
129
132
  ...(worktree ? { worktree } : {}),
130
133
  ...(session.gitBranch ? { branch: session.gitBranch } : {}),
@@ -226,10 +229,11 @@ export class SessionWatchState {
226
229
  const liveRows = sourceRows.map((row) => toSessionWatchRow(scope, row));
227
230
  const sourceIds = new Set(liveRows.map((row) => row.sessionId).filter((id) => Boolean(id)));
228
231
  const rows = new Map(liveRows.map((row) => [row.rowKey, row]));
232
+ const accounts = indexedRows.length ? listNativeAccounts(readMeta()) : [];
229
233
  for (const indexed of indexedRows) {
230
234
  if (sourceIds.has(indexed.id))
231
235
  continue;
232
- const row = toPreviousSessionWatchRow(scope, indexed);
236
+ const row = toPreviousSessionWatchRow(scope, indexed, accounts);
233
237
  rows.set(row.rowKey, row);
234
238
  }
235
239
  this.prunePrevious(scope, rows);
@@ -199,6 +199,12 @@ export interface AgentConfig {
199
199
  * without this flag only see plugin skills through the flattened copies.
200
200
  */
201
201
  nativePluginSkills?: boolean;
202
+ /**
203
+ * Directories under the version home's `skills/` that the harness itself
204
+ * writes (Claude Code's `synced` bucket). The sync never placed them, so the
205
+ * full-sync orphan sweep and skill cleanup must never remove them.
206
+ */
207
+ ownedSkillDirs?: readonly string[];
202
208
  /**
203
209
  * This agent's *own* cloud backend. `agents cloud run --agent <id>` routes
204
210
  * here when no `--provider` is given (precedence: --provider > this >
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.22.118",
3
+ "version": "1.22.119",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",