@phnx-labs/agents-cli 1.22.72 → 1.22.74

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 (61) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +2 -0
  3. package/dist/cli/command-registry.d.ts +1 -1
  4. package/dist/cli/command-registry.js +2 -1
  5. package/dist/commands/packages-materialize.d.ts +17 -0
  6. package/dist/commands/packages-materialize.js +93 -0
  7. package/dist/commands/packages.d.ts +6 -4
  8. package/dist/commands/packages.js +8 -4
  9. package/dist/commands/repo.js +8 -8
  10. package/dist/commands/sessions-picker.js +38 -7
  11. package/dist/commands/sessions.d.ts +50 -1
  12. package/dist/commands/sessions.js +85 -10
  13. package/dist/lib/actor.d.ts +36 -4
  14. package/dist/lib/actor.js +73 -9
  15. package/dist/lib/agent-spec/index.d.ts +4 -0
  16. package/dist/lib/agent-spec/index.js +5 -0
  17. package/dist/lib/agent-spec/materialize.d.ts +13 -0
  18. package/dist/lib/agent-spec/materialize.js +414 -0
  19. package/dist/lib/agent-spec/package-resolve.d.ts +12 -0
  20. package/dist/lib/agent-spec/package-resolve.js +274 -0
  21. package/dist/lib/agent-spec/package-schema.d.ts +5 -0
  22. package/dist/lib/agent-spec/package-schema.js +147 -0
  23. package/dist/lib/agent-spec/package-types.d.ts +121 -0
  24. package/dist/lib/agent-spec/package-types.js +11 -0
  25. package/dist/lib/cloud/rush.d.ts +1 -1
  26. package/dist/lib/cloud/rush.js +2 -2
  27. package/dist/lib/daemon/usage-sync-service.d.ts +12 -5
  28. package/dist/lib/daemon/usage-sync-service.js +27 -5
  29. package/dist/lib/exec.js +3 -0
  30. package/dist/lib/fleet-shared-state.d.ts +30 -0
  31. package/dist/lib/fleet-shared-state.js +5 -0
  32. package/dist/lib/hooks/install.d.ts +31 -1
  33. package/dist/lib/hooks/install.js +44 -2
  34. package/dist/lib/mcp.d.ts +14 -2
  35. package/dist/lib/mcp.js +12 -2
  36. package/dist/lib/packages/output-home.d.ts +39 -0
  37. package/dist/lib/packages/output-home.js +203 -0
  38. package/dist/lib/paths.d.ts +9 -0
  39. package/dist/lib/paths.js +26 -0
  40. package/dist/lib/project-resources.d.ts +6 -2
  41. package/dist/lib/project-resources.js +133 -44
  42. package/dist/lib/rush-session.d.ts +10 -2
  43. package/dist/lib/rush-session.js +12 -3
  44. package/dist/lib/secrets/drivers/rush.js +1 -1
  45. package/dist/lib/session/active.d.ts +12 -0
  46. package/dist/lib/session/active.js +5 -1
  47. package/dist/lib/session/actor-sidecar.d.ts +7 -0
  48. package/dist/lib/session/actor-sidecar.js +2 -0
  49. package/dist/lib/session/cloud.js +1 -1
  50. package/dist/lib/session/db.d.ts +60 -1
  51. package/dist/lib/session/db.js +191 -6
  52. package/dist/lib/session/live-metadata.d.ts +24 -0
  53. package/dist/lib/session/live-metadata.js +55 -0
  54. package/dist/lib/session/mirror.d.ts +67 -0
  55. package/dist/lib/session/mirror.js +158 -0
  56. package/dist/lib/session/types.d.ts +18 -0
  57. package/dist/lib/spinner.d.ts +39 -0
  58. package/dist/lib/spinner.js +41 -0
  59. package/dist/lib/startup/command-registry.js +1 -1
  60. package/dist/lib/types.d.ts +6 -0
  61. package/package.json +1 -1
@@ -1,3 +1,4 @@
1
+ import { execFileSync } from 'child_process';
1
2
  import * as fs from 'fs';
2
3
  import * as path from 'path';
3
4
  import { AGENTS, agentConfigDirName, isAgentHardDeprecated } from './agents.js';
@@ -39,10 +40,14 @@ export function syncProjectResourcesToAgent(agent, version, projectAgentsDir) {
39
40
  // .agents/{commands,skills,…}, refreshed on every launch. Left untracked it
40
41
  // dirties `git status` and can block `git merge`/`checkout` when a stray
41
42
  // commit of the same path collides. So the generator owns its ignore rule:
42
- // reconcile a per-agent marker block in <projectRoot>/.gitignore listing
43
- // exactly the paths it manages. Passing the manifest set (empty when a sync
44
- // clears a harness) also prunes the block. See PHNX-3717.
45
- reconcileProjectGitignore(projectRoot, agent, agentRoot, Array.from(next).sort());
43
+ // reconcile a per-agent marker block listing exactly the paths it manages.
44
+ // That block lives in `.git/info/exclude` — git's per-clone, uncommitted
45
+ // ignore file — NOT the tracked `.gitignore`: these entries are never
46
+ // committed upstream, so writing them into `.gitignore` left every launch
47
+ // with a permanent `M .gitignore` that blocked `git pull` (PHNX-3718).
48
+ // Passing the manifest set (empty when a sync clears a harness) also prunes
49
+ // the block. See PHNX-3717 for the self-managed-ignore feature.
50
+ reconcileManagedIgnore(projectRoot, agent, agentRoot, Array.from(next).sort());
46
51
  }
47
52
  return result;
48
53
  }
@@ -75,7 +80,11 @@ function gitignoreMarkers(agent) {
75
80
  }
76
81
  /**
77
82
  * Turn the manifest's managed paths (relative to agentRoot) into anchored,
78
- * POSIX, projectRoot-relative `.gitignore` entries. Two guards keep it honest:
83
+ * POSIX, `referenceRoot`-relative ignore entries. `referenceRoot` is the
84
+ * directory the anchored `/…` patterns resolve against — the git worktree root
85
+ * for a `.git/info/exclude` block, since git anchors info/exclude patterns at
86
+ * the top of the working tree (not at the harness dir). Two guards keep it
87
+ * honest:
79
88
  * - drop any path that escapes the harness config dir (e.g. grok writes
80
89
  * commands back into the tracked `.agents/` tree via a `../` subdir —
81
90
  * ignoring that would hide tracked source; separate bug, PHNX-3718);
@@ -84,7 +93,7 @@ function gitignoreMarkers(agent) {
84
93
  * these never masks a hand-authored or committed file (e.g. a repo that
85
94
  * commits its own `.claude/CLAUDE.md` keeps it — it is not in the manifest).
86
95
  */
87
- export function managedGitignoreEntries(agentRoot, projectRoot, managed) {
96
+ export function managedGitignoreEntries(agentRoot, referenceRoot, managed) {
88
97
  const root = path.resolve(agentRoot);
89
98
  const entries = new Set();
90
99
  for (const rel of managed) {
@@ -93,31 +102,63 @@ export function managedGitignoreEntries(agentRoot, projectRoot, managed) {
93
102
  const abs = path.resolve(agentRoot, rel);
94
103
  if (abs !== root && !abs.startsWith(root + path.sep))
95
104
  continue;
96
- const fromProject = toPosixRel(path.relative(projectRoot, abs));
97
- if (!fromProject || fromProject === '..' || fromProject.startsWith('../'))
105
+ const fromRoot = toPosixRel(path.relative(referenceRoot, abs));
106
+ if (!fromRoot || fromRoot === '..' || fromRoot.startsWith('../'))
98
107
  continue;
99
- entries.add('/' + fromProject);
108
+ entries.add('/' + fromRoot);
100
109
  }
101
110
  return Array.from(entries).sort();
102
111
  }
103
- /** True when `dir` is inside a git working tree — walks up to the filesystem
104
- * root looking for a `.git` entry. `projectRoot` (the parent of the resolved
105
- * `.agents/` dir) is not guaranteed to be the git root: a monorepo subdir can
106
- * carry its own `.agents/` while `.git` lives several levels up. A root-only
107
- * check would silently no-op the whole feature there. */
108
- function isInsideGitRepo(dir) {
109
- let cur = path.resolve(dir);
110
- for (;;) {
111
- if (pathExists(path.join(cur, '.git')))
112
- return true;
113
- const parent = path.dirname(cur);
114
- if (parent === cur)
115
- return false;
116
- cur = parent;
112
+ /**
113
+ * Ask git where the local, per-clone ignore file lives and where the worktree
114
+ * top is, resolved robustly for every layout by delegating to git itself:
115
+ * - normal repo → `<root>/.git/info/exclude`;
116
+ * - monorepo subdir → the same file even when `.git` is several levels up
117
+ * (`projectRoot`, the parent of `.agents/`, is not the git root);
118
+ * - linked worktree / submodule → `.git` is a FILE (`gitdir: …`), and
119
+ * `--git-path info/exclude` resolves to the shared COMMON dir so the block
120
+ * applies across every worktree.
121
+ * `--path-format=absolute` forces absolute paths regardless of the `-C` cwd.
122
+ * One `git rev-parse` yields both paths (exclude path first, worktree root
123
+ * second), so the launch path spawns git ONCE, not twice.
124
+ * Returns null when `dir` is not inside a git repo (git exits non-zero), which
125
+ * fails the feature open (no-op) exactly like the old in-tree check did.
126
+ */
127
+ function resolveGitExcludeTarget(dir) {
128
+ try {
129
+ const out = execFileSync('git', ['-C', dir, 'rev-parse', '--path-format=absolute', '--git-path', 'info/exclude', '--show-toplevel'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
130
+ const [excludePath, worktreeRoot] = out.split('\n').map((l) => l.trim());
131
+ if (!excludePath || !worktreeRoot)
132
+ return null;
133
+ // Fail open on anything that isn't a clean pair of ABSOLUTE paths. A git
134
+ // older than 2.31 (predates `--path-format`) echoes the unrecognized flag
135
+ // back on stdout instead of erroring, which would otherwise shift the parse
136
+ // and have mkdirSync create a stray `--path-format=absolute` dir. The
137
+ // absolute-path check turns that into a clean no-op.
138
+ if (!path.isAbsolute(excludePath) || !path.isAbsolute(worktreeRoot))
139
+ return null;
140
+ return { excludePath, worktreeRoot };
141
+ }
142
+ catch {
143
+ return null;
144
+ }
145
+ }
146
+ /** True when git tracks `absPath` in the repo `dir` sits in. */
147
+ function isTrackedByGit(dir, absPath) {
148
+ try {
149
+ execFileSync('git', ['-C', dir, 'ls-files', '--error-unmatch', '--', absPath], {
150
+ stdio: ['ignore', 'ignore', 'ignore'],
151
+ });
152
+ return true;
153
+ }
154
+ catch {
155
+ return false;
117
156
  }
118
157
  }
119
158
  /**
120
- * Apply this agent's managed block to `.gitignore` content, IN PLACE.
159
+ * Apply this agent's managed block to ignore-file content, IN PLACE — used both
160
+ * to write the block into `.git/info/exclude` and to strip a leftover block from
161
+ * a legacy tracked `.gitignore` (entries `[]` prunes).
121
162
  *
122
163
  * In-place replacement (not strip-then-append) is load-bearing: appending would
123
164
  * move this agent's block behind every other agent's block on each resync, so in
@@ -141,9 +182,11 @@ function applyManagedBlock(content, begin, end, entries) {
141
182
  if (entries.length > 0) {
142
183
  return [...lines.slice(0, bi), begin, ...entries, end, ...lines.slice(ei + 1)].join('\n');
143
184
  }
144
- // Prune the block, tidying the blank lines that hugged it. Vestigial on the
145
- // reconcileProjectGitignore path (its entries always include the manifest,
146
- // so entries.length is never 0 there) — kept for a direct caller/unit test.
185
+ // Prune the block, tidying the blank lines that hugged it. Unreached on the
186
+ // reconcileManagedIgnore write path (its entries always include the manifest,
187
+ // so entries.length is never 0 there), but the load-bearing case for
188
+ // stripLegacyManagedGitignoreBlock, which calls with entries=[] to migrate a
189
+ // leftover block out of the tracked .gitignore.
147
190
  const before = lines.slice(0, bi);
148
191
  const after = lines.slice(ei + 1);
149
192
  while (before.length && before[before.length - 1].trim() === '')
@@ -160,30 +203,42 @@ function applyManagedBlock(content, begin, end, entries) {
160
203
  return body.length > 0 ? `${body}\n\n${block}\n` : `${block}\n`;
161
204
  }
162
205
  /**
163
- * Reconcile a per-agent managed block in `<projectRoot>/.gitignore` so the
164
- * generated per-harness resource dir never shows as untracked dirt. Idempotent
165
- * and convergent: replaces the block in place and writes only when the content
166
- * actually changes, so the launch hot path does not churn the file (or its
167
- * watchers) every run — even in a project synced by several harnesses. When a
168
- * sync clears a harness's resources the block does not vanish: it shrinks to the
169
- * lone `.agents-managed.json` entry (that file still sits in the harness dir and
170
- * must stay ignored), so the block is only ever fully pruned by hand, never via
171
- * this call path. Never creates a `.gitignore` outside a git working tree.
206
+ * Reconcile a per-agent managed block in `.git/info/exclude` so the generated
207
+ * per-harness resource dir never shows as untracked dirt — WITHOUT dirtying the
208
+ * tracked `.gitignore`. Idempotent and convergent: replaces the block in place
209
+ * and writes only when the content actually changes, so the launch hot path does
210
+ * not churn the file (or its watchers) every run — even in a project synced by
211
+ * several harnesses. When a sync clears a harness's resources the block does not
212
+ * vanish: it shrinks to the lone `.agents-managed.json` entry (that file still
213
+ * sits in the harness dir and must stay ignored), so the block is only ever
214
+ * fully pruned by hand, never via this call path. Fails open (no-op) outside a
215
+ * git working tree.
216
+ *
217
+ * Also self-heals repos dirtied by the previous behavior: PHNX-3717 wrote these
218
+ * blocks into `<projectRoot>/.gitignore`, which is never committed upstream, so
219
+ * every launch left a permanent `M .gitignore` that blocked `git pull`
220
+ * (PHNX-3718). `stripLegacyManagedGitignoreBlock` removes this agent's leftover
221
+ * block from that tracked file on the next launch, cleaning the diff instead of
222
+ * stranding it.
172
223
  */
173
- function reconcileProjectGitignore(projectRoot, agent, agentRoot, managed) {
174
- const gitignorePath = path.join(projectRoot, '.gitignore');
175
- if (!isInsideGitRepo(projectRoot) && !pathExists(gitignorePath))
176
- return;
224
+ function reconcileManagedIgnore(projectRoot, agent, agentRoot, managed) {
225
+ // Migrate away from the old tracked-.gitignore location first, so an already
226
+ // dirtied repo cleans itself even if git resolution below fails.
227
+ stripLegacyManagedGitignoreBlock(projectRoot, agent);
228
+ const target = resolveGitExcludeTarget(projectRoot);
229
+ if (!target)
230
+ return; // not a git repo — fail open
177
231
  const { begin, end } = gitignoreMarkers(agent);
178
232
  // Ignore the manifest marker file too, not just the synced resources: the
179
233
  // sync always writes `<agentRoot>/.agents-managed.json`, so without this the
180
234
  // harness dir still shows as untracked in `git status` on the strength of that
181
235
  // one file (defeating the whole point). It lives at agentRoot, so it resolves
182
- // through the same anchoring + escape guard as any managed path.
183
- const entries = managedGitignoreEntries(agentRoot, projectRoot, [MANIFEST_FILE, ...managed]);
236
+ // through the same anchoring + escape guard as any managed path. Anchored to
237
+ // the worktree root, since info/exclude patterns resolve against the tree top.
238
+ const entries = managedGitignoreEntries(agentRoot, target.worktreeRoot, [MANIFEST_FILE, ...managed]);
184
239
  let original = '';
185
240
  try {
186
- original = fs.readFileSync(gitignorePath, 'utf-8');
241
+ original = fs.readFileSync(target.excludePath, 'utf-8');
187
242
  }
188
243
  catch {
189
244
  original = '';
@@ -191,8 +246,42 @@ function reconcileProjectGitignore(projectRoot, agent, agentRoot, managed) {
191
246
  const next = applyManagedBlock(original, begin, end, entries);
192
247
  if (next === null || next === original)
193
248
  return;
194
- const tmp = gitignorePath + '.tmp';
249
+ fs.mkdirSync(path.dirname(target.excludePath), { recursive: true }); // create info/ if missing
250
+ const tmp = target.excludePath + '.tmp';
195
251
  fs.writeFileSync(tmp, next);
252
+ fs.renameSync(tmp, target.excludePath);
253
+ }
254
+ /**
255
+ * Remove this agent's leftover managed block from a tracked `<projectRoot>/
256
+ * .gitignore` written by the pre-PHNX-3718 behavior. Strips ONLY the fenced
257
+ * block (leaving every hand-written rule untouched), never creates the file,
258
+ * and never touches a `.gitignore` that carries no block of ours. If stripping
259
+ * empties a file we created (its only content was our block), the empty file is
260
+ * removed when git does not track it — an empty untracked `.gitignore` would
261
+ * still read as `?? .gitignore` dirt, the very thing this migration clears.
262
+ */
263
+ function stripLegacyManagedGitignoreBlock(projectRoot, agent) {
264
+ const gitignorePath = path.join(projectRoot, '.gitignore');
265
+ let original;
266
+ try {
267
+ original = fs.readFileSync(gitignorePath, 'utf-8');
268
+ }
269
+ catch {
270
+ return; // no .gitignore — nothing to migrate
271
+ }
272
+ const { begin, end } = gitignoreMarkers(agent);
273
+ // Empty entries → applyManagedBlock prunes the block; returns `original`
274
+ // unchanged when there is no block, or null on an orphaned begin marker
275
+ // (which we refuse to touch rather than truncate the user's rules).
276
+ const stripped = applyManagedBlock(original, begin, end, []);
277
+ if (stripped === null || stripped === original)
278
+ return;
279
+ if (stripped === '' && !isTrackedByGit(projectRoot, gitignorePath)) {
280
+ removePath(gitignorePath);
281
+ return;
282
+ }
283
+ const tmp = gitignorePath + '.tmp';
284
+ fs.writeFileSync(tmp, stripped);
196
285
  fs.renameSync(tmp, gitignorePath);
197
286
  }
198
287
  function removeManagedPath(agentRoot, rel) {
@@ -2,8 +2,16 @@
2
2
  * Rush session freshness — the ONE judgment shared by every consumer of
3
3
  * ~/.rush/user.yaml (cloud dispatch, cloud session source, secrets sync driver).
4
4
  *
5
- * A Rush session stores `expires_at` as Unix seconds. Two values mean "never
6
- * expires" and MUST NOT be read as an absolute timestamp:
5
+ * A Rush session stores `expires_at` as Unix MILLISECONDS — the same unit
6
+ * rush/cli writes and reads it in (`sessionTokenFresh` in auth_reader.go compares
7
+ * against `time.Now()...UnixMilli()`), and the same unit the JWT `exp` claim maps
8
+ * to (`exp * 1000`). So freshness must compare it against `Date.now()` (ms), NOT
9
+ * `Date.now() / 1000` (seconds). Comparing ms against seconds was PHNX-3805: a
10
+ * long-expired session (e.g. `expires_at: 1788157222000`) read as `1.788e12 <=
11
+ * 1.788e9` → always false → never expired, so `agents cloud providers` reported
12
+ * Rush `available: true` on a dead session and every dispatch failed with a 401.
13
+ *
14
+ * Two values mean "never expires" and MUST NOT be read as an absolute timestamp:
7
15
  * - `0` — an opaque Phoenix `pid_` bearer written by `rush login`;
8
16
  * `0` = non-expiring by contract (mirrors rush/cli's isFresh,
9
17
  * the same fix RUSH-1310 landed for the daemon-less freshness
@@ -2,8 +2,16 @@
2
2
  * Rush session freshness — the ONE judgment shared by every consumer of
3
3
  * ~/.rush/user.yaml (cloud dispatch, cloud session source, secrets sync driver).
4
4
  *
5
- * A Rush session stores `expires_at` as Unix seconds. Two values mean "never
6
- * expires" and MUST NOT be read as an absolute timestamp:
5
+ * A Rush session stores `expires_at` as Unix MILLISECONDS — the same unit
6
+ * rush/cli writes and reads it in (`sessionTokenFresh` in auth_reader.go compares
7
+ * against `time.Now()...UnixMilli()`), and the same unit the JWT `exp` claim maps
8
+ * to (`exp * 1000`). So freshness must compare it against `Date.now()` (ms), NOT
9
+ * `Date.now() / 1000` (seconds). Comparing ms against seconds was PHNX-3805: a
10
+ * long-expired session (e.g. `expires_at: 1788157222000`) read as `1.788e12 <=
11
+ * 1.788e9` → always false → never expired, so `agents cloud providers` reported
12
+ * Rush `available: true` on a dead session and every dispatch failed with a 401.
13
+ *
14
+ * Two values mean "never expires" and MUST NOT be read as an absolute timestamp:
7
15
  * - `0` — an opaque Phoenix `pid_` bearer written by `rush login`;
8
16
  * `0` = non-expiring by contract (mirrors rush/cli's isFresh,
9
17
  * the same fix RUSH-1310 landed for the daemon-less freshness
@@ -20,5 +28,6 @@ export function isRushSessionExpired(expiresAt) {
20
28
  // `0` (non-expiring pid_ bearer) and a missing value are never expired.
21
29
  if (typeof expiresAt !== 'number' || expiresAt === 0)
22
30
  return false;
23
- return expiresAt <= Date.now() / 1000;
31
+ // expires_at is Unix milliseconds — compare ms to ms.
32
+ return expiresAt <= Date.now();
24
33
  }
@@ -29,7 +29,7 @@ function readRushToken() {
29
29
  }
30
30
  const expiresAt = data.session?.expires_at;
31
31
  if (isRushSessionExpired(expiresAt)) {
32
- const expiredAt = new Date(expiresAt * 1000).toISOString();
32
+ const expiredAt = new Date(expiresAt).toISOString();
33
33
  throw new Error(`Rush session expired at ${expiredAt}. Run \`rush login\` to refresh.`);
34
34
  }
35
35
  return token;
@@ -402,6 +402,18 @@ export interface ActiveSession {
402
402
  * did not inherit a terminal id.
403
403
  */
404
404
  terminalId?: string;
405
+ /**
406
+ * The launch id (`AGENT_LAUNCH_ID`) the CLI stamps on every agent at spawn — a
407
+ * stable UUID that is identical locally and across an SSH hop and survives a
408
+ * session-id rotation (`/clear`, exit-and-rerun). Unlike `sessionId` (which the
409
+ * non-Claude harnesses only mint after boot) it exists from the first tick, so
410
+ * it is the join key a client uses to re-identify a session on the watch stream.
411
+ * Populated wherever the by-pid launch registry resolves — reliably for
412
+ * `agents run`-launched processes; still absent for editor-launched terminals
413
+ * whose shell pid does not line up with the agent-pid registry today (the same
414
+ * limitation the `readPidSessionEntry` call in `listTerminalsActive` documents).
415
+ */
416
+ launchId?: string;
405
417
  /**
406
418
  * tmux pane id (`%N`) when this row was discovered via the tmux source AND its
407
419
  * session id could not be resolved (a born-unidentifiable non-Claude pane). It
@@ -1059,6 +1059,8 @@ export async function listTerminalsActive() {
1059
1059
  tty: procByPid.get(t.pid)?.tty,
1060
1060
  pid: t.pid,
1061
1061
  sessionId: t.sessionId ?? sessionIdFromFile(sessionFile),
1062
+ launchId: pidEntry?.launchId,
1063
+ terminalId: pidEntry?.terminalId,
1062
1064
  cwd: t.cwd ?? undefined,
1063
1065
  label,
1064
1066
  name,
@@ -1558,7 +1560,8 @@ async function listUnattributedActiveLive(attributed) {
1558
1560
  lastActivityMs: mtimeMs,
1559
1561
  pidCount: 1 + (foldedByRoot.get(pid) ?? 0),
1560
1562
  owner: resolveOwner(entry?.actor, resolvedId),
1561
- terminalId: entry?.terminalId,
1563
+ launchId: entry?.launchId ?? hookRec?.launch_id,
1564
+ terminalId: entry?.terminalId ?? hookRec?.terminal_id,
1562
1565
  }, state, sessionFile, true));
1563
1566
  }
1564
1567
  // Housekeeping: drop registry files for pids that have since died.
@@ -1773,6 +1776,7 @@ export async function listTmuxAgentSessions() {
1773
1776
  // Factory / --active join key: AGENT_TERMINAL_ID stamped on the launch
1774
1777
  // registry and preserved by SessionStart. Without this, Grok/Codex tmux
1775
1778
  // panes never surface terminalId even when by-pid has it (RUSH-2192).
1779
+ launchId: liveEntry?.launchId,
1776
1780
  terminalId: liveEntry?.terminalId,
1777
1781
  // An id-less pane keys its dedupe on the unique pane, so two anonymous
1778
1782
  // co-located panes stay two rows instead of folding into one.
@@ -5,6 +5,13 @@ export interface SessionActorRecord {
5
5
  actor?: string;
6
6
  /** Actor kind (`resolveActor().kind`). */
7
7
  initiatedBy?: 'human' | 'agent';
8
+ /**
9
+ * Phoenix id of the responsible actor (`resolveActor().phoenixId`) — the
10
+ * tailnet human's stable Phoenix identity, resolved from the `actors:` map at
11
+ * spawn (PHNX-3798). Pairs with {@link actor}: joined onto the session index at
12
+ * scan time so a durable listing can surface the Phoenix id, not just the email.
13
+ */
14
+ phoenixId?: string;
8
15
  /** Effective permissions mode used by the launcher. */
9
16
  mode?: SessionRunMode;
10
17
  /**
@@ -39,6 +39,7 @@ function isSafeAlias(alias) {
39
39
  }
40
40
  function hasRecordData(record) {
41
41
  return typeof record.actor === 'string'
42
+ || typeof record.phoenixId === 'string'
42
43
  || typeof record.mode === 'string'
43
44
  || typeof record.version === 'string'
44
45
  || typeof record.harness === 'string'
@@ -84,6 +85,7 @@ export function writeSessionAliasRecord(sessionId, alias) {
84
85
  sessionId,
85
86
  actor: previous?.actor,
86
87
  initiatedBy: previous?.initiatedBy,
88
+ phoenixId: previous?.phoenixId,
87
89
  mode: previous?.mode,
88
90
  version: previous?.version,
89
91
  harness: previous?.harness,
@@ -32,7 +32,7 @@ function readToken() {
32
32
  }
33
33
  const expiresAt = data.session?.expires_at;
34
34
  if (isRushSessionExpired(expiresAt)) {
35
- const expiredAt = new Date(expiresAt * 1000).toISOString();
35
+ const expiredAt = new Date(expiresAt).toISOString();
36
36
  throw new Error(`Rush session expired at ${expiredAt}. Run \`rush login\` to refresh.`);
37
37
  }
38
38
  return token;
@@ -13,7 +13,7 @@ import { type ToolScanResumePoint } from './tool-store.js';
13
13
  /** Current schema version; bumped when migrations are added. Exported so tests
14
14
  * assert against the constant instead of hardcoding a number that every bump
15
15
  * then has to chase (docs/sessions.md calls the constant the source of truth). */
16
- export declare const SCHEMA_VERSION = 45;
16
+ export declare const SCHEMA_VERSION = 47;
17
17
  /**
18
18
  * Bump to force the content extractor (assistant-answer text, alongside the
19
19
  * user-prompt text every harness already accumulates) to re-derive on every
@@ -443,6 +443,65 @@ export declare function readSessionContent(id: string): string | undefined;
443
443
  * survives for this session.
444
444
  */
445
445
  export declare function readArchivedSessionPreview<T>(id: string): T | undefined;
446
+ /** A local-origin session, projected to the compact fields the fleet mirror publishes. */
447
+ export interface LocalMirrorSource {
448
+ id: string;
449
+ shortId: string;
450
+ agent: string;
451
+ version: string | null;
452
+ machine: string | null;
453
+ cwd: string | null;
454
+ topic: string | null;
455
+ firstUserMessage: string | null;
456
+ label: string | null;
457
+ lastActivity: string | null;
458
+ timestamp: string;
459
+ ticketId: string | null;
460
+ prUrl: string | null;
461
+ }
462
+ /**
463
+ * The most-recently-active sessions whose transcript is genuinely local to this
464
+ * box — a real `file_path`, not a peer mirror — for publishing to the fleet
465
+ * session mirror (PHNX-3792). Bounded and team-origin-excluded so the published
466
+ * payload stays the size a picker would show. Never returns a row this box is
467
+ * itself mirroring from a peer (`mirror_synced_at IS NULL`).
468
+ */
469
+ export declare function queryLocalOriginSessionsForMirror(self: string, limit: number): LocalMirrorSource[];
470
+ /** One peer session digest to write into this box's local mirror. */
471
+ export interface MirrorSessionUpsert {
472
+ id: string;
473
+ shortId: string;
474
+ agent: string;
475
+ version?: string | null;
476
+ machine: string;
477
+ cwd?: string | null;
478
+ topic?: string | null;
479
+ firstUser?: string | null;
480
+ label?: string | null;
481
+ lastActivity?: string | null;
482
+ timestamp: string;
483
+ ticketId?: string | null;
484
+ prUrl?: string | null;
485
+ }
486
+ /**
487
+ * Write one peer session's digest into the local `sessions` index as a mirror
488
+ * row (PHNX-3792), so the picker/list/focus render its topic/preview inline
489
+ * with no per-row SSH. The upsert is GUARDED: it never overwrites a genuine
490
+ * local transcript row (`file_path` non-empty and not itself a mirror), so a
491
+ * mirror can only create a new peer-only row or enrich an existing empty-file
492
+ * host-dispatch stub / older mirror. Returns true when a row was written (the
493
+ * caller then persists the matching preview digest); false when a real row was
494
+ * protected. `machine` carries the publisher's recorded execution host so the
495
+ * row collapses with the live fan-out row under the same `machine:id` key.
496
+ */
497
+ export declare function upsertMirrorSession(row: MirrorSessionUpsert, source: string, syncedAt: number): boolean;
498
+ /**
499
+ * Drop peer mirror rows (and their cached digests) whose last sync predates the
500
+ * cutoff — the size ceiling / staleness pruner for the fleet session mirror
501
+ * (PHNX-3792). Only ever touches mirror rows (`mirror_synced_at IS NOT NULL`),
502
+ * never a genuine local or host-dispatch row. Returns the number of rows pruned.
503
+ */
504
+ export declare function pruneMirrorSessions(cutoffMs: number): number;
446
505
  /** Plugin provenance already indexed for resources used by one session. */
447
506
  export declare function getSessionPlugins(id: string): string[];
448
507
  export type UsageRollupGroup = 'agent' | 'project' | 'day' | 'account';