@phnx-labs/agents-cli 1.22.71 → 1.22.73

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 (51) hide show
  1. package/CHANGELOG.md +45 -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.js +6 -5
  12. package/dist/lib/actor.d.ts +36 -4
  13. package/dist/lib/actor.js +73 -9
  14. package/dist/lib/agent-spec/index.d.ts +4 -0
  15. package/dist/lib/agent-spec/index.js +5 -0
  16. package/dist/lib/agent-spec/materialize.d.ts +13 -0
  17. package/dist/lib/agent-spec/materialize.js +414 -0
  18. package/dist/lib/agent-spec/package-resolve.d.ts +12 -0
  19. package/dist/lib/agent-spec/package-resolve.js +274 -0
  20. package/dist/lib/agent-spec/package-schema.d.ts +5 -0
  21. package/dist/lib/agent-spec/package-schema.js +147 -0
  22. package/dist/lib/agent-spec/package-types.d.ts +121 -0
  23. package/dist/lib/agent-spec/package-types.js +11 -0
  24. package/dist/lib/daemon/usage-sync-service.d.ts +12 -5
  25. package/dist/lib/daemon/usage-sync-service.js +27 -5
  26. package/dist/lib/exec.js +3 -0
  27. package/dist/lib/fleet-shared-state.d.ts +30 -0
  28. package/dist/lib/fleet-shared-state.js +5 -0
  29. package/dist/lib/hooks/install.d.ts +31 -1
  30. package/dist/lib/hooks/install.js +44 -2
  31. package/dist/lib/mcp.d.ts +14 -2
  32. package/dist/lib/mcp.js +12 -2
  33. package/dist/lib/packages/output-home.d.ts +39 -0
  34. package/dist/lib/packages/output-home.js +203 -0
  35. package/dist/lib/paths.d.ts +9 -0
  36. package/dist/lib/paths.js +26 -0
  37. package/dist/lib/project-resources.js +14 -5
  38. package/dist/lib/session/active.d.ts +12 -0
  39. package/dist/lib/session/active.js +5 -1
  40. package/dist/lib/session/actor-sidecar.d.ts +7 -0
  41. package/dist/lib/session/actor-sidecar.js +2 -0
  42. package/dist/lib/session/db.d.ts +60 -1
  43. package/dist/lib/session/db.js +191 -6
  44. package/dist/lib/session/mirror.d.ts +67 -0
  45. package/dist/lib/session/mirror.js +158 -0
  46. package/dist/lib/session/types.d.ts +18 -0
  47. package/dist/lib/spinner.d.ts +39 -0
  48. package/dist/lib/spinner.js +41 -0
  49. package/dist/lib/startup/command-registry.js +1 -1
  50. package/dist/lib/types.d.ts +6 -0
  51. package/package.json +1 -1
@@ -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';
@@ -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(`
@@ -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
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Ctrl-C-safe spinner factory.
3
+ *
4
+ * `ora` defaults to `discardStdin: true`, which — on a TTY — flips stdin into
5
+ * raw mode for the lifetime of the spinner (via `stdin-discarder`). Raw mode
6
+ * disables the terminal's own ISIG translation, so a Ctrl-C keystroke no longer
7
+ * generates SIGINT; `stdin-discarder` tries to compensate by re-emitting SIGINT
8
+ * when it reads a 0x03 byte, but that read only happens when stdin is in
9
+ * *flowing* mode. At CLI startup a fresh `process.stdin` has `flowing === null`,
10
+ * so `stdin.isPaused()` returns false, the discarder skips its `stdin.resume()`,
11
+ * and its `prependListener('data')` never switches the stream to flowing —
12
+ * because Node's readable only auto-resumes for `on('data')`, not
13
+ * `prependListener('data')`. The net effect: while such a spinner is running,
14
+ * Ctrl-C is swallowed entirely — the terminal won't raise SIGINT and the
15
+ * discarder never sees the byte to re-raise it.
16
+ *
17
+ * That is invisible for a sub-second spinner, but any spinner wrapping a long
18
+ * idle wait — a cross-machine SSH fan-out, a fleet search — traps the user for
19
+ * the full duration (measured: `agents sessions --flat` could not be Ctrl-C'd
20
+ * for the entire ~12s+ sweep and had to be SIGKILLed). The global SIGINT handler
21
+ * in `index.ts` (`process.exit(130)`) is correct; it simply never fires because
22
+ * no SIGINT is ever raised.
23
+ *
24
+ * Setting `discardStdin: false` keeps the terminal in cooked mode, so Ctrl-C
25
+ * raises SIGINT normally (the terminal also SIGINTs the whole foreground process
26
+ * group, reaping the outstanding `ssh` children) and the global handler exits
27
+ * promptly. The only thing lost is cosmetic stray-keystroke discarding during
28
+ * the spin — a worthwhile trade for a spinner the user must be able to abort.
29
+ *
30
+ * Use this for any spinner that wraps a network / fleet / SSH wait. A truly
31
+ * instantaneous local spinner can still use `ora` directly, but preferring this
32
+ * everywhere is harmless.
33
+ */
34
+ import { type Options, type Ora } from 'ora';
35
+ /**
36
+ * Build an interruptible spinner — a drop-in for `ora(text)` that keeps Ctrl-C
37
+ * working. Returns an unstarted {@link Ora}; call `.start()` as usual.
38
+ */
39
+ export declare function interruptibleSpinner(text?: string, options?: Omit<Options, 'discardStdin'>): Ora;