@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
@@ -28,7 +28,7 @@ const DB_PATH = getSessionsDbPath();
28
28
  /** Current schema version; bumped when migrations are added. Exported so tests
29
29
  * assert against the constant instead of hardcoding a number that every bump
30
30
  * then has to chase (docs/sessions.md calls the constant the source of truth). */
31
- export const SCHEMA_VERSION = 45;
31
+ export const SCHEMA_VERSION = 47;
32
32
  /**
33
33
  * Bump to force the content extractor (assistant-answer text, alongside the
34
34
  * user-prompt text every harness already accumulates) to re-derive on every
@@ -135,6 +135,7 @@ CREATE TABLE IF NOT EXISTS sessions (
135
135
  linear_project_url TEXT,
136
136
  actor TEXT,
137
137
  initiated_by TEXT,
138
+ phoenix_id TEXT,
138
139
  used_browser INTEGER,
139
140
  used_computer INTEGER,
140
141
  -- Epoch ms of the first time a previously-scanned transcript was confirmed
@@ -143,7 +144,14 @@ CREATE TABLE IF NOT EXISTS sessions (
143
144
  -- DB and flagged, instead of being dropped when the file vanishes. A row whose
144
145
  -- file is missing but which has NO cached content is a phantom (a stale/moved
145
146
  -- file_path), never stamped, still suppressed — see querySessions.
146
- archived_at INTEGER
147
+ archived_at INTEGER,
148
+ -- Epoch ms this row was last written from a PEER's synced session mirror
149
+ -- (PHNX-3792), NULL for a genuine local/host-dispatch row. Non-NULL marks a
150
+ -- row created/enriched purely from a fleet-synced digest so it can be pruned
151
+ -- by age without touching real rows; mirror_source names the publishing
152
+ -- device. A row keeps a NULL mirror_synced_at once it gains a real transcript.
153
+ mirror_synced_at INTEGER,
154
+ mirror_source TEXT
147
155
  );
148
156
  CREATE INDEX IF NOT EXISTS idx_sessions_timestamp ON sessions(timestamp DESC);
149
157
  CREATE INDEX IF NOT EXISTS idx_sessions_cwd ON sessions(cwd);
@@ -151,7 +159,8 @@ CREATE INDEX IF NOT EXISTS idx_sessions_agent ON sessions(agent);
151
159
  CREATE INDEX IF NOT EXISTS idx_sessions_file_path ON sessions(file_path);
152
160
  CREATE INDEX IF NOT EXISTS idx_sessions_short_id ON sessions(short_id);
153
161
  -- idx_sessions_machine_ts / idx_sessions_agent_ts are created after migration
154
- -- v17 guarantees the machine column exists (same pattern as last_activity).
162
+ -- v17 guarantees the machine column exists (same pattern as last_activity);
163
+ -- idx_sessions_mirror_synced likewise waits for migration v46's column add.
155
164
 
156
165
  CREATE VIRTUAL TABLE IF NOT EXISTS session_text USING fts5(
157
166
  session_id UNINDEXED,
@@ -1247,6 +1256,29 @@ function migrateSchema(db, fromVersion) {
1247
1256
  if (!cols.has('first_user_message'))
1248
1257
  db.exec(`ALTER TABLE sessions ADD COLUMN first_user_message TEXT`);
1249
1258
  }
1259
+ if (fromVersion < 46) {
1260
+ // v45 -> v46: mirror provenance for fleet-synced peer session digests
1261
+ // (PHNX-3792). Additive columns, no ledger wipe: they are written only by the
1262
+ // session-mirror consume path (never a transcript scan), so re-parsing every
1263
+ // indexed transcript would be pure churn — a genuine local row keeps them NULL.
1264
+ const cols = new Set(db.prepare(`PRAGMA table_info(sessions)`).all().map((c) => c.name));
1265
+ if (!cols.has('mirror_synced_at'))
1266
+ db.exec(`ALTER TABLE sessions ADD COLUMN mirror_synced_at INTEGER`);
1267
+ if (!cols.has('mirror_source'))
1268
+ db.exec(`ALTER TABLE sessions ADD COLUMN mirror_source TEXT`);
1269
+ // The idx_sessions_mirror_synced index is created unconditionally after this
1270
+ // block (fresh DBs skip migrations), alongside idx_sessions_last_activity.
1271
+ }
1272
+ if (fromVersion < 47) {
1273
+ // v46 -> v47: carry the actor's Phoenix id (PHNX-3798). Additive, write-once
1274
+ // launch metadata joined from the actor sidecar — never transcript-derived,
1275
+ // exactly like actor/initiated_by (v19) — so there is no ledger wipe: a rescan
1276
+ // can't backfill it and forcing a full re-parse would be pure churn. Existing
1277
+ // rows stay NULL until the sidecar-join fills them (the ON CONFLICT COALESCEs).
1278
+ const cols = new Set(db.prepare(`PRAGMA table_info(sessions)`).all().map((c) => c.name));
1279
+ if (!cols.has('phoenix_id'))
1280
+ db.exec(`ALTER TABLE sessions ADD COLUMN phoenix_id TEXT`);
1281
+ }
1250
1282
  }
1251
1283
  /**
1252
1284
  * Stamp `account_key` / `account_org` / `account` on every Claude row from its
@@ -1322,6 +1354,10 @@ export function getDB() {
1322
1354
  // DB would fail the index build on a column it doesn't have yet.
1323
1355
  db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_last_activity ON sessions(last_activity DESC)`);
1324
1356
  db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_origin ON sessions(origin)`);
1357
+ // Same fresh-vs-migrated rule: the column is guaranteed above (fresh from
1358
+ // CREATE TABLE, existing from migration v46), so index the mirror pruner's
1359
+ // scan column here rather than in SCHEMA (PHNX-3792).
1360
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_mirror_synced ON sessions(mirror_synced_at)`);
1325
1361
  db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_routine_run_id ON sessions(routine_run_id)`);
1326
1362
  // Account attribution repair. Two ways a Claude row ends up wrong even at v33:
1327
1363
  // an older CLI (whose INSERT does not name the column) writes NULL, and a DB
@@ -1796,7 +1832,7 @@ const upsertSessionStmt = (db) => db.prepare(`
1796
1832
  pr_url, pr_number, worktree_slug, ticket_id, spawned_team,
1797
1833
  sub_agent_count, background_shell_count, plan, todos,
1798
1834
  recent_directories_touched, linear_project, linear_project_url, machine,
1799
- actor, initiated_by, used_browser, used_computer
1835
+ actor, initiated_by, phoenix_id, used_browser, used_computer
1800
1836
  ) VALUES (
1801
1837
  @id, @short_id, @agent, @harness, @origin, @routine_name, @routine_run_id,
1802
1838
  @version, @account, @account_key, @account_org, @mode, @timestamp, @last_activity,
@@ -1807,7 +1843,7 @@ const upsertSessionStmt = (db) => db.prepare(`
1807
1843
  @pr_url, @pr_number, @worktree_slug, @ticket_id, @spawned_team,
1808
1844
  @sub_agent_count, @background_shell_count, @plan, @todos,
1809
1845
  @recent_directories_touched, @linear_project, @linear_project_url, @machine,
1810
- @actor, @initiated_by, @used_browser, @used_computer
1846
+ @actor, @initiated_by, @phoenix_id, @used_browser, @used_computer
1811
1847
  )
1812
1848
  ON CONFLICT(id) DO UPDATE SET
1813
1849
  short_id = excluded.short_id,
@@ -1891,7 +1927,28 @@ const upsertSessionStmt = (db) => db.prepare(`
1891
1927
  -- the actor sidecar landed — once the sidecar-join finally provides one. Plain
1892
1928
  -- exclusion locked those rows to NULL forever (RUSH-2018/2019 fix).
1893
1929
  actor = COALESCE(sessions.actor, excluded.actor),
1894
- initiated_by = COALESCE(sessions.initiated_by, excluded.initiated_by)
1930
+ initiated_by = COALESCE(sessions.initiated_by, excluded.initiated_by),
1931
+ -- Phoenix id rides the same write-once join as actor/initiated_by: a rescan
1932
+ -- carries none (excluded.phoenix_id is NULL -> stored value wins), but a row
1933
+ -- indexed NULL-first backfills once the sidecar-join finally provides one.
1934
+ phoenix_id = COALESCE(sessions.phoenix_id, excluded.phoenix_id),
1935
+ -- A genuine local transcript write (the scanner always carries a non-empty
1936
+ -- file_path) reclaims a row that was first seeded as a peer mirror: clear the
1937
+ -- mirror provenance so pruneMirrorSessions (which deletes mirror_synced_at IS
1938
+ -- NOT NULL rows past the age cutoff) can never delete real local content, and
1939
+ -- so queryLocalOriginSessionsForMirror (mirror_synced_at IS NULL) re-publishes
1940
+ -- it. Without this the stamp set by upsertMirrorSession survived a later real
1941
+ -- scan, since an omitted column keeps its prior value on upsert (PHNX-3792).
1942
+ -- An empty-file write (a host-dispatch stub) is NOT a real transcript, so it
1943
+ -- leaves the stamp intact — matching the mirror guard's own file_path test.
1944
+ mirror_synced_at = CASE
1945
+ WHEN excluded.file_path IS NOT NULL AND excluded.file_path <> '' THEN NULL
1946
+ ELSE sessions.mirror_synced_at
1947
+ END,
1948
+ mirror_source = CASE
1949
+ WHEN excluded.file_path IS NOT NULL AND excluded.file_path <> '' THEN NULL
1950
+ ELSE sessions.mirror_source
1951
+ END
1895
1952
  `);
1896
1953
  /**
1897
1954
  * Did this session emit at least one browser/computer-automation event?
@@ -2159,6 +2216,7 @@ export function upsertSession(meta, content, scan, assistantContent = '') {
2159
2216
  machine: resolveMachine(meta),
2160
2217
  actor: meta.actor ?? actorRec?.actor ?? null,
2161
2218
  initiated_by: meta.initiatedBy ?? actorRec?.initiatedBy ?? null,
2219
+ phoenix_id: meta.phoenixId ?? actorRec?.phoenixId ?? null,
2162
2220
  used_browser: toolUsage.usedBrowser ? 1 : 0,
2163
2221
  used_computer: toolUsage.usedComputer ? 1 : 0,
2164
2222
  };
@@ -2398,6 +2456,7 @@ export function upsertSessionsBatch(entries) {
2398
2456
  machine: resolveMachine(meta),
2399
2457
  actor: meta.actor ?? actorIndex.get(meta.id)?.actor ?? null,
2400
2458
  initiated_by: meta.initiatedBy ?? actorIndex.get(meta.id)?.initiatedBy ?? null,
2459
+ phoenix_id: meta.phoenixId ?? actorIndex.get(meta.id)?.phoenixId ?? null,
2401
2460
  used_browser: toolUsage.usedBrowser ? 1 : 0,
2402
2461
  used_computer: toolUsage.usedComputer ? 1 : 0,
2403
2462
  };
@@ -2633,6 +2692,7 @@ function rowToMeta(row) {
2633
2692
  // Narrow the free-text column to the known kinds; an unexpected value maps
2634
2693
  // to undefined rather than being asserted as a valid kind.
2635
2694
  initiatedBy: row.initiated_by === 'human' || row.initiated_by === 'agent' ? row.initiated_by : undefined,
2695
+ phoenixId: row.phoenix_id ?? undefined,
2636
2696
  // NULL = never computed by this scanner (legacy row) — leave undefined so
2637
2697
  // the sessions picker knows to fall back to the transcript-regex detection
2638
2698
  // instead of trusting a false "never used browser/computer".
@@ -2643,6 +2703,8 @@ function rowToMeta(row) {
2643
2703
  // flagged, never dropped (RUSH-2436). NULL leaves both undefined (live row).
2644
2704
  archivedAt: row.archived_at ?? undefined,
2645
2705
  archived: row.archived_at != null ? true : undefined,
2706
+ mirrorSyncedAt: row.mirror_synced_at ?? undefined,
2707
+ mirrorSource: row.mirror_source ?? undefined,
2646
2708
  };
2647
2709
  }
2648
2710
  function isSessionRunMode(value) {
@@ -3278,6 +3340,129 @@ export function readArchivedSessionPreview(id) {
3278
3340
  return undefined;
3279
3341
  }
3280
3342
  }
3343
+ /**
3344
+ * The most-recently-active sessions whose transcript is genuinely local to this
3345
+ * box — a real `file_path`, not a peer mirror — for publishing to the fleet
3346
+ * session mirror (PHNX-3792). Bounded and team-origin-excluded so the published
3347
+ * payload stays the size a picker would show. Never returns a row this box is
3348
+ * itself mirroring from a peer (`mirror_synced_at IS NULL`).
3349
+ */
3350
+ export function queryLocalOriginSessionsForMirror(self, limit) {
3351
+ const rows = getDB().prepare(`
3352
+ SELECT id, short_id, agent, version, machine, cwd, topic, first_user_message,
3353
+ label, last_activity, timestamp, ticket_id, pr_url
3354
+ FROM sessions
3355
+ WHERE mirror_synced_at IS NULL
3356
+ AND file_path IS NOT NULL AND file_path <> ''
3357
+ AND machine = ?
3358
+ AND is_team_origin = 0
3359
+ AND (topic IS NOT NULL OR first_user_message IS NOT NULL OR label IS NOT NULL)
3360
+ ORDER BY last_activity DESC, timestamp DESC
3361
+ LIMIT ?
3362
+ `).all(self, limit);
3363
+ return rows.map((r) => ({
3364
+ id: r.id, shortId: r.short_id, agent: r.agent, version: r.version, machine: r.machine,
3365
+ cwd: r.cwd, topic: r.topic, firstUserMessage: r.first_user_message, label: r.label,
3366
+ lastActivity: r.last_activity, timestamp: r.timestamp, ticketId: r.ticket_id, prUrl: r.pr_url,
3367
+ }));
3368
+ }
3369
+ /**
3370
+ * Write one peer session's digest into the local `sessions` index as a mirror
3371
+ * row (PHNX-3792), so the picker/list/focus render its topic/preview inline
3372
+ * with no per-row SSH. The upsert is GUARDED: it never overwrites a genuine
3373
+ * local transcript row (`file_path` non-empty and not itself a mirror), so a
3374
+ * mirror can only create a new peer-only row or enrich an existing empty-file
3375
+ * host-dispatch stub / older mirror. Returns true when a row was written (the
3376
+ * caller then persists the matching preview digest); false when a real row was
3377
+ * protected. `machine` carries the publisher's recorded execution host so the
3378
+ * row collapses with the live fan-out row under the same `machine:id` key.
3379
+ */
3380
+ export function upsertMirrorSession(row, source, syncedAt) {
3381
+ const db = getDB();
3382
+ const lastActivity = row.lastActivity ?? row.timestamp;
3383
+ const result = db.prepare(`
3384
+ INSERT INTO sessions (
3385
+ id, short_id, agent, origin, version, timestamp, last_activity, cwd,
3386
+ topic, first_user_message, label, message_count, file_path, scanned_at,
3387
+ is_team_origin, ticket_id, pr_url, machine, mirror_synced_at, mirror_source
3388
+ ) VALUES (
3389
+ @id, @short_id, @agent, 'cli', @version, @timestamp, @last_activity, @cwd,
3390
+ @topic, @first_user, @label, NULL, '', @scanned_at,
3391
+ 0, @ticket_id, @pr_url, @machine, @synced_at, @source
3392
+ )
3393
+ ON CONFLICT(id) DO UPDATE SET
3394
+ short_id = excluded.short_id,
3395
+ agent = excluded.agent,
3396
+ version = COALESCE(excluded.version, sessions.version),
3397
+ timestamp = excluded.timestamp,
3398
+ last_activity = excluded.last_activity,
3399
+ cwd = COALESCE(excluded.cwd, sessions.cwd),
3400
+ topic = COALESCE(excluded.topic, sessions.topic),
3401
+ first_user_message = COALESCE(excluded.first_user_message, sessions.first_user_message),
3402
+ -- The peer is authoritative for the session's real name, so OVERWRITE (not
3403
+ -- COALESCE) the label: a host-dispatch stub carries the [host/peer]
3404
+ -- placeholder, and coalescing would keep it forever — the exact bare-row
3405
+ -- symptom this feature fixes. A null peer label clears it so the list falls
3406
+ -- back to the synced topic.
3407
+ label = excluded.label,
3408
+ ticket_id = COALESCE(excluded.ticket_id, sessions.ticket_id),
3409
+ pr_url = COALESCE(excluded.pr_url, sessions.pr_url),
3410
+ machine = excluded.machine,
3411
+ mirror_synced_at = excluded.mirror_synced_at,
3412
+ mirror_source = excluded.mirror_source
3413
+ WHERE sessions.file_path IS NULL OR sessions.file_path = '' OR sessions.mirror_synced_at IS NOT NULL
3414
+ `).run({
3415
+ id: row.id,
3416
+ short_id: row.shortId,
3417
+ agent: row.agent,
3418
+ version: row.version ?? null,
3419
+ timestamp: row.timestamp,
3420
+ last_activity: lastActivity,
3421
+ cwd: row.cwd ?? null,
3422
+ topic: row.topic ?? null,
3423
+ first_user: row.firstUser ?? null,
3424
+ label: row.label ?? null,
3425
+ ticket_id: row.ticketId ?? null,
3426
+ pr_url: row.prUrl ?? null,
3427
+ machine: row.machine,
3428
+ scanned_at: syncedAt,
3429
+ synced_at: syncedAt,
3430
+ source,
3431
+ });
3432
+ if (result.changes === 0)
3433
+ return false;
3434
+ // Keep the mirror row searchable by topic + first user turn, like a local row.
3435
+ db.prepare(`DELETE FROM session_text WHERE session_id = ?`).run(row.id);
3436
+ db.prepare(`
3437
+ INSERT INTO session_text (session_id, label, topic, project, content, assistant)
3438
+ VALUES (?, ?, ?, '', ?, '')
3439
+ `).run(row.id, row.label ?? '', row.topic ?? '', row.firstUser ?? '');
3440
+ return true;
3441
+ }
3442
+ /**
3443
+ * Drop peer mirror rows (and their cached digests) whose last sync predates the
3444
+ * cutoff — the size ceiling / staleness pruner for the fleet session mirror
3445
+ * (PHNX-3792). Only ever touches mirror rows (`mirror_synced_at IS NOT NULL`),
3446
+ * never a genuine local or host-dispatch row. Returns the number of rows pruned.
3447
+ */
3448
+ export function pruneMirrorSessions(cutoffMs) {
3449
+ const db = getDB();
3450
+ const stale = db.prepare(`SELECT id FROM sessions WHERE mirror_synced_at IS NOT NULL AND mirror_synced_at < ?`).all(cutoffMs);
3451
+ if (stale.length === 0)
3452
+ return 0;
3453
+ const delRow = db.prepare(`DELETE FROM sessions WHERE id = ?`);
3454
+ const delText = db.prepare(`DELETE FROM session_text WHERE session_id = ?`);
3455
+ const delPreview = db.prepare(`DELETE FROM session_preview_cache WHERE session_id = ?`);
3456
+ const txn = db.transaction(() => {
3457
+ for (const { id } of stale) {
3458
+ delRow.run(id);
3459
+ delText.run(id);
3460
+ delPreview.run(id);
3461
+ }
3462
+ });
3463
+ txn();
3464
+ return stale.length;
3465
+ }
3281
3466
  /** Plugin provenance already indexed for resources used by one session. */
3282
3467
  export function getSessionPlugins(id) {
3283
3468
  const rows = getDB().prepare(`
@@ -13,3 +13,27 @@ export declare function activeSessionToSessionMeta(active: ActiveSession, self:
13
13
  * (no id, non-agent kind) are dropped. Order follows the input.
14
14
  */
15
15
  export declare function liveSessionMetas(active: ActiveSession[], self: string, nowMs: number): SessionMeta[];
16
+ /**
17
+ * Which box the AGENT of each live session executes on, keyed by lowercased id,
18
+ * as recovered from the fleet-active snapshot (`agents sessions --active`'s
19
+ * cross-machine merge). A dispatcher that launched a session running on a peer
20
+ * has a live shim row locally whose `machine` self-defaulted to itself — the
21
+ * launch process IS here, the agent is not (PHNX-3890). The fleet snapshot
22
+ * already reconciled that against the peer's own self-report, so it is the one
23
+ * place that knows the true execution host without a synced index row (which is
24
+ * why `foldExecutionMachine` can't recover it: there is no local index row to
25
+ * join). Only rows the fleet attributes to a real machine are included.
26
+ */
27
+ export declare function fleetExecutionMachineById(fleet: ActiveSession[]): Map<string, string>;
28
+ /**
29
+ * Correct a live `SessionMeta` candidate's machine to its true EXECUTION host
30
+ * using the fleet-active attribution (PHNX-3890). Only a **self-attributed,
31
+ * transcript-less** row is a candidate for correction — the launcher-shim shape
32
+ * a dispatcher holds for a session whose agent runs on a peer, which is
33
+ * indistinguishable from a genuinely-local just-started session by its local
34
+ * fields alone. A row already attributed to another box, or one carrying a
35
+ * transcript path, is left untouched. A corrected row is stamped `_remote` so
36
+ * the read-vs-resume router (`transcriptOnPeerOf`) sends the preview to the
37
+ * owning peer instead of dead-ending on the local "not indexed here" stub.
38
+ */
39
+ export declare function reconcileLiveMetaMachine(metas: SessionMeta[], fleetExecutionMachine: Map<string, string>, self: string): SessionMeta[];
@@ -76,3 +76,58 @@ export function liveSessionMetas(active, self, nowMs) {
76
76
  }
77
77
  return out;
78
78
  }
79
+ /**
80
+ * Which box the AGENT of each live session executes on, keyed by lowercased id,
81
+ * as recovered from the fleet-active snapshot (`agents sessions --active`'s
82
+ * cross-machine merge). A dispatcher that launched a session running on a peer
83
+ * has a live shim row locally whose `machine` self-defaulted to itself — the
84
+ * launch process IS here, the agent is not (PHNX-3890). The fleet snapshot
85
+ * already reconciled that against the peer's own self-report, so it is the one
86
+ * place that knows the true execution host without a synced index row (which is
87
+ * why `foldExecutionMachine` can't recover it: there is no local index row to
88
+ * join). Only rows the fleet attributes to a real machine are included.
89
+ */
90
+ export function fleetExecutionMachineById(fleet) {
91
+ const byId = new Map();
92
+ for (const s of fleet) {
93
+ // The AGENT machine is `machine` (where the transcript/harness lives), NOT
94
+ // `offloadedFrom` (where the launcher shim runs) — reading follows the
95
+ // transcript owner, so a would-be reader must reach `machine`.
96
+ if (!s.sessionId || !s.machine)
97
+ continue;
98
+ byId.set(s.sessionId.toLowerCase(), s.machine);
99
+ }
100
+ return byId;
101
+ }
102
+ /**
103
+ * Correct a live `SessionMeta` candidate's machine to its true EXECUTION host
104
+ * using the fleet-active attribution (PHNX-3890). Only a **self-attributed,
105
+ * transcript-less** row is a candidate for correction — the launcher-shim shape
106
+ * a dispatcher holds for a session whose agent runs on a peer, which is
107
+ * indistinguishable from a genuinely-local just-started session by its local
108
+ * fields alone. A row already attributed to another box, or one carrying a
109
+ * transcript path, is left untouched. A corrected row is stamped `_remote` so
110
+ * the read-vs-resume router (`transcriptOnPeerOf`) sends the preview to the
111
+ * owning peer instead of dead-ending on the local "not indexed here" stub.
112
+ */
113
+ export function reconcileLiveMetaMachine(metas, fleetExecutionMachine, self) {
114
+ return metas.map(meta => {
115
+ // Already attributed elsewhere, or locally readable — not a self-default.
116
+ if (meta.machine && meta.machine !== self)
117
+ return meta;
118
+ if (meta.filePath)
119
+ return meta;
120
+ const exec = fleetExecutionMachine.get(meta.id.toLowerCase());
121
+ // Only a PEER attribution is positive information. A snapshot entry that
122
+ // names THIS box cannot be trusted as confirmation, because the snapshot is
123
+ // a merge that INCLUDES this box's own rows — so for exactly the session
124
+ // shape being corrected here (no index row to fold from), a `self` entry may
125
+ // be nothing but an echo of the self-default above, recorded while the
126
+ // owning peer had not reported yet or was unreachable during that gather.
127
+ // Treating it as proof would skip the fan-out and dead-end on the local stub
128
+ // for a session genuinely running elsewhere — the PHNX-3890 bug itself.
129
+ if (!exec || exec === self)
130
+ return meta;
131
+ return { ...meta, machine: exec, _remote: true };
132
+ });
133
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Fleet distribution of lightweight per-session preview/metadata (PHNX-3792).
3
+ *
4
+ * On the interactive/personal device, a session that originated on another host
5
+ * used to render as a bare `[host/<peer>]` row with no topic, and its preview
6
+ * pane fetched the peer's digest LIVE over SSH per row — slow, and blank when
7
+ * the peer is asleep. This module mirrors each box's own session digests into
8
+ * its conflict-free `~/.agents/devices/<device>/daemon-state.json`, which the
9
+ * daemon's existing bounded Git transport (`fleet-shared-repo-sync.ts`) already
10
+ * delivers fleet-wide with no operator step. The consuming device folds peer
11
+ * digests into its local `sessions` index as mirror rows, so the picker/list/
12
+ * focus read a LOCAL row instead of dialing the peer. No transcript is shipped —
13
+ * only topic/label, a first-user-message snippet, last-activity, agent+version,
14
+ * cwd, ticket, and PR — and the mirror is bounded and pruned by age.
15
+ *
16
+ * Direction is deliberately the inverse of usage-sync: EVERY box publishes its
17
+ * own local sessions (workers are exactly where the remote sessions the personal
18
+ * box lacks previews for are created), and every box EXCEPT a marked worker folds
19
+ * peers' digests in (the picker is an interactive surface). A worker skips the
20
+ * consume so its DB is not written with rows it never renders.
21
+ */
22
+ import { type ConfiguredDeviceRole } from '../device-config.js';
23
+ /** Cap on sessions published per device — the payload stays what a picker shows. */
24
+ export declare const SESSION_MIRROR_MAX_ROWS = 200;
25
+ /** First-user-message snippet ceiling; the full turn stays on the owning box. */
26
+ export declare const SESSION_MIRROR_SNIPPET_MAX = 280;
27
+ /** Mirror rows older than this since their last sync are pruned (staleness/size ceiling). */
28
+ export declare const SESSION_MIRROR_MAX_AGE_MS: number;
29
+ export interface PublishSessionMirrorOptions {
30
+ userAgentsDir?: string;
31
+ device?: string;
32
+ limit?: number;
33
+ }
34
+ export interface PublishSessionMirrorResult {
35
+ published: boolean;
36
+ changed: boolean;
37
+ count: number;
38
+ skipped: string | null;
39
+ error: string | null;
40
+ path: string | null;
41
+ }
42
+ /** Publish this device's most-recent local sessions into its owned shared-state file. */
43
+ export declare function publishSessionMirrorToSharedStore(options?: PublishSessionMirrorOptions): Promise<PublishSessionMirrorResult>;
44
+ export interface ConsumeSessionMirrorOptions {
45
+ userAgentsDir?: string;
46
+ device?: string;
47
+ role?: ConfiguredDeviceRole;
48
+ now?: number;
49
+ maxAgeMs?: number;
50
+ }
51
+ export interface ConsumeSessionMirrorResult {
52
+ sources: string[];
53
+ merged: number;
54
+ pruned: number;
55
+ skipped: string | null;
56
+ errors: Array<{
57
+ device: string;
58
+ message: string;
59
+ }>;
60
+ }
61
+ /**
62
+ * Fold peers' published session digests into this box's local `sessions` index
63
+ * as mirror rows, then prune stale ones. Reads only the local user-repo checkout
64
+ * (the daemon's Git transport already delivered peers' files) — no network. A
65
+ * marked worker skips: the mirror feeds an interactive picker it never renders.
66
+ */
67
+ export declare function consumeSessionMirrorFromSharedStore(options?: ConsumeSessionMirrorOptions): ConsumeSessionMirrorResult;
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Fleet distribution of lightweight per-session preview/metadata (PHNX-3792).
3
+ *
4
+ * On the interactive/personal device, a session that originated on another host
5
+ * used to render as a bare `[host/<peer>]` row with no topic, and its preview
6
+ * pane fetched the peer's digest LIVE over SSH per row — slow, and blank when
7
+ * the peer is asleep. This module mirrors each box's own session digests into
8
+ * its conflict-free `~/.agents/devices/<device>/daemon-state.json`, which the
9
+ * daemon's existing bounded Git transport (`fleet-shared-repo-sync.ts`) already
10
+ * delivers fleet-wide with no operator step. The consuming device folds peer
11
+ * digests into its local `sessions` index as mirror rows, so the picker/list/
12
+ * focus read a LOCAL row instead of dialing the peer. No transcript is shipped —
13
+ * only topic/label, a first-user-message snippet, last-activity, agent+version,
14
+ * cwd, ticket, and PR — and the mirror is bounded and pruned by age.
15
+ *
16
+ * Direction is deliberately the inverse of usage-sync: EVERY box publishes its
17
+ * own local sessions (workers are exactly where the remote sessions the personal
18
+ * box lacks previews for are created), and every box EXCEPT a marked worker folds
19
+ * peers' digests in (the picker is an interactive surface). A worker skips the
20
+ * consume so its DB is not written with rows it never renders.
21
+ */
22
+ import { selfConfiguredDeviceRole } from '../device-config.js';
23
+ import { readFleetSharedDeviceStates, updateFleetSharedDeviceStateAsync, } from '../fleet-shared-state.js';
24
+ import { getUserAgentsDir } from '../state.js';
25
+ import { machineId, normalizeHost } from './sync/config.js';
26
+ import { pruneMirrorSessions, queryLocalOriginSessionsForMirror, upsertMirrorSession, } from './db.js';
27
+ /** Cap on sessions published per device — the payload stays what a picker shows. */
28
+ export const SESSION_MIRROR_MAX_ROWS = 200;
29
+ /** First-user-message snippet ceiling; the full turn stays on the owning box. */
30
+ export const SESSION_MIRROR_SNIPPET_MAX = 280;
31
+ /** Mirror rows older than this since their last sync are pruned (staleness/size ceiling). */
32
+ export const SESSION_MIRROR_MAX_AGE_MS = 14 * 24 * 60 * 60_000;
33
+ function snippet(value, max = SESSION_MIRROR_SNIPPET_MAX) {
34
+ if (!value)
35
+ return undefined;
36
+ const trimmed = value.replace(/\s+/g, ' ').trim();
37
+ if (!trimmed)
38
+ return undefined;
39
+ return trimmed.length > max ? `${trimmed.slice(0, max - 1)}…` : trimmed;
40
+ }
41
+ /** Publish this device's most-recent local sessions into its owned shared-state file. */
42
+ export async function publishSessionMirrorToSharedStore(options = {}) {
43
+ const result = {
44
+ published: false, changed: false, count: 0, skipped: null, error: null, path: null,
45
+ };
46
+ const device = options.device ?? machineId();
47
+ const self = normalizeHost(device);
48
+ const limit = options.limit ?? SESSION_MIRROR_MAX_ROWS;
49
+ const capturedAt = Date.now();
50
+ const sources = queryLocalOriginSessionsForMirror(self, limit);
51
+ const rows = sources.map((s) => ({
52
+ id: s.id,
53
+ shortId: s.shortId,
54
+ agent: s.agent,
55
+ ...(s.version ? { version: s.version } : {}),
56
+ machine: s.machine?.trim() || self,
57
+ ...(s.cwd ? { cwd: s.cwd } : {}),
58
+ ...(snippet(s.topic, 200) ? { topic: snippet(s.topic, 200) } : {}),
59
+ ...(s.label ? { label: s.label } : {}),
60
+ ...(snippet(s.firstUserMessage) ? { firstUser: snippet(s.firstUserMessage) } : {}),
61
+ ...(s.lastActivity ? { lastActivity: s.lastActivity } : {}),
62
+ timestamp: s.timestamp,
63
+ ...(s.ticketId ? { ticketId: s.ticketId } : {}),
64
+ ...(s.prUrl ? { prUrl: s.prUrl } : {}),
65
+ capturedAt,
66
+ }));
67
+ try {
68
+ const write = await updateFleetSharedDeviceStateAsync(device, { sessions: { rows } }, options.userAgentsDir ?? getUserAgentsDir());
69
+ result.published = true;
70
+ result.changed = write.changed;
71
+ result.count = rows.length;
72
+ result.path = write.path;
73
+ }
74
+ catch (err) {
75
+ result.error = err.message;
76
+ }
77
+ return result;
78
+ }
79
+ function isString(v) {
80
+ return typeof v === 'string' && v.length > 0;
81
+ }
82
+ /**
83
+ * Validate one untrusted peer-supplied row into a DB upsert (or null when it is
84
+ * missing a load-bearing field). Terminal-escape scrubbing is NOT done here —
85
+ * the render path (`sanitizeMeta` in sessions-picker) scrubs every meta string
86
+ * before it reaches a TTY, exactly as it does for live fan-out rows — this only
87
+ * enforces shape and bounds so a malformed/hostile peer can't poison the index.
88
+ */
89
+ function toUpsert(raw) {
90
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
91
+ return null;
92
+ const r = raw;
93
+ if (!isString(r.id) || !isString(r.agent) || !isString(r.machine))
94
+ return null;
95
+ const timestamp = isString(r.timestamp) ? r.timestamp : (isString(r.lastActivity) ? r.lastActivity : null);
96
+ if (!timestamp)
97
+ return null;
98
+ const shortId = isString(r.shortId) ? r.shortId : r.id.slice(0, 8);
99
+ const cap = (v, max) => isString(v) ? (v.length > max ? v.slice(0, max) : v) : undefined;
100
+ return {
101
+ id: r.id,
102
+ shortId,
103
+ agent: r.agent,
104
+ version: cap(r.version, 64),
105
+ machine: r.machine,
106
+ cwd: cap(r.cwd, 1024),
107
+ topic: cap(r.topic, 400),
108
+ firstUser: cap(r.firstUser, SESSION_MIRROR_SNIPPET_MAX),
109
+ label: cap(r.label, 400),
110
+ lastActivity: isString(r.lastActivity) ? r.lastActivity : undefined,
111
+ timestamp,
112
+ ticketId: cap(r.ticketId, 64),
113
+ prUrl: cap(r.prUrl, 512),
114
+ };
115
+ }
116
+ /**
117
+ * Fold peers' published session digests into this box's local `sessions` index
118
+ * as mirror rows, then prune stale ones. Reads only the local user-repo checkout
119
+ * (the daemon's Git transport already delivered peers' files) — no network. A
120
+ * marked worker skips: the mirror feeds an interactive picker it never renders.
121
+ */
122
+ export function consumeSessionMirrorFromSharedStore(options = {}) {
123
+ const result = { sources: [], merged: 0, pruned: 0, skipped: null, errors: [] };
124
+ const role = options.role ?? selfConfiguredDeviceRole();
125
+ const now = options.now ?? Date.now();
126
+ if (role === 'worker') {
127
+ // A worker never renders the picker, so it does not consume peers' digests.
128
+ // But if this box was previously a non-worker it may still hold mirror rows,
129
+ // which it will never refresh or render — prune them all now (cutoff = now
130
+ // drops every row synced before this tick) rather than leave a demoted box's
131
+ // index permanently bloated with stale peer rows (PHNX-3792).
132
+ result.pruned = pruneMirrorSessions(now);
133
+ result.skipped = 'this device is a worker; the session mirror feeds the interactive picker';
134
+ return result;
135
+ }
136
+ const read = readFleetSharedDeviceStates(options.userAgentsDir ?? getUserAgentsDir());
137
+ result.errors.push(...read.errors);
138
+ const self = normalizeHost(options.device ?? machineId());
139
+ for (const state of read.states) {
140
+ if (normalizeHost(state.device) === self || !state.sessions?.rows)
141
+ continue;
142
+ let mergedForDevice = 0;
143
+ for (const raw of state.sessions.rows) {
144
+ const row = toUpsert(raw);
145
+ if (!row)
146
+ continue;
147
+ if (upsertMirrorSession(row, state.device, now))
148
+ mergedForDevice++;
149
+ }
150
+ if (mergedForDevice > 0) {
151
+ result.sources.push(state.device);
152
+ result.merged += mergedForDevice;
153
+ }
154
+ }
155
+ result.sources.sort();
156
+ result.pruned = pruneMirrorSessions(now - (options.maxAgeMs ?? SESSION_MIRROR_MAX_AGE_MS));
157
+ return result;
158
+ }
@@ -364,6 +364,15 @@ export interface SessionMeta {
364
364
  * split as `AGENTS_ACTOR` / `AGENTS_ACTOR_KIND` on the exec env. RUSH-2018.
365
365
  */
366
366
  initiatedBy?: 'human' | 'agent';
367
+ /**
368
+ * Phoenix id of the actor who initiated this session (`resolveActor().phoenixId`)
369
+ * — the tailnet human's stable Phoenix identity, resolved from the `actors:` map
370
+ * at spawn. Pairs with {@link actor}, persisted write-once at session creation
371
+ * and joined from the actor sidecar at scan time (kept out of the DB upsert's
372
+ * ON CONFLICT set), so `agents sessions --json` can surface the Phoenix id.
373
+ * Undefined for rows created before Phoenix-id stamping. PHNX-3798.
374
+ */
375
+ phoenixId?: string;
367
376
  /**
368
377
  * True only for rows pulled from another machine over the live cross-machine
369
378
  * fan-out (`remote-list.ts`) — their transcript is on that peer's disk, so
@@ -373,6 +382,15 @@ export interface SessionMeta {
373
382
  * `parseRemoteList`; transient (never persisted, stripped from --json).
374
383
  */
375
384
  _remote?: boolean;
385
+ /**
386
+ * Epoch ms this row was last written from a PEER's fleet-synced session mirror
387
+ * (PHNX-3792); absent for a genuine local or host-dispatch row. When set, the
388
+ * picker renders the peer session's topic/preview INLINE from this mirror
389
+ * (no per-row SSH) and stamps a "synced <ago>" / stale marker. `mirrorSource`
390
+ * names the publishing device. Backed by the `mirror_synced_at` column.
391
+ */
392
+ mirrorSyncedAt?: number;
393
+ mirrorSource?: string;
376
394
  /**
377
395
  * True when the transcript file is gone from disk but the session's user turns
378
396
  * still live in the local DB (session_text), so the row is served and rendered