@phnx-labs/agents-cli 1.22.109 → 1.22.110

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 (64) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/dist/bootstrap.js +3 -2
  3. package/dist/commands/browser-sessions-picker.js +2 -1
  4. package/dist/commands/computer-sessions-picker.js +2 -1
  5. package/dist/commands/cost.js +2 -1
  6. package/dist/commands/fork.js +6 -4
  7. package/dist/commands/logs.js +2 -1
  8. package/dist/commands/sessions-backfill.d.ts +30 -0
  9. package/dist/commands/sessions-backfill.js +72 -0
  10. package/dist/commands/sessions-inject.d.ts +3 -3
  11. package/dist/commands/sessions-inject.js +5 -11
  12. package/dist/commands/sessions-picker.js +10 -7
  13. package/dist/commands/sessions-resume.d.ts +3 -1
  14. package/dist/commands/sessions-resume.js +32 -3
  15. package/dist/commands/sessions.js +44 -24
  16. package/dist/commands/utils.d.ts +9 -0
  17. package/dist/commands/utils.js +18 -0
  18. package/dist/commands/watchdog.js +4 -1
  19. package/dist/lib/accounting/rotate.d.ts +9 -0
  20. package/dist/lib/accounting/rotate.js +17 -1
  21. package/dist/lib/accounting/usage-sync.d.ts +22 -0
  22. package/dist/lib/accounting/usage-sync.js +70 -6
  23. package/dist/lib/accounting/usage.d.ts +32 -0
  24. package/dist/lib/accounting/usage.js +37 -2
  25. package/dist/lib/auth-health.js +19 -0
  26. package/dist/lib/claude-statusline.js +5 -0
  27. package/dist/lib/computer/sessions-list.js +2 -1
  28. package/dist/lib/daemon/auth-sync-service.d.ts +2 -0
  29. package/dist/lib/daemon/auth-sync-service.js +2 -2
  30. package/dist/lib/daemon/daemon.js +7 -0
  31. package/dist/lib/daemon/session-title-service.d.ts +41 -0
  32. package/dist/lib/daemon/session-title-service.js +76 -0
  33. package/dist/lib/daemon/usage-sync-service.d.ts +9 -1
  34. package/dist/lib/daemon/usage-sync-service.js +10 -2
  35. package/dist/lib/daemon-services.d.ts +1 -1
  36. package/dist/lib/daemon-services.js +5 -0
  37. package/dist/lib/daemon-ticks.js +6 -0
  38. package/dist/lib/fleet-shared-state.d.ts +2 -0
  39. package/dist/lib/hooks/install.js +14 -7
  40. package/dist/lib/mailbox-target.js +2 -1
  41. package/dist/lib/session/active.d.ts +93 -16
  42. package/dist/lib/session/active.js +80 -14
  43. package/dist/lib/session/db.d.ts +44 -1
  44. package/dist/lib/session/db.js +122 -17
  45. package/dist/lib/session/fork.d.ts +10 -2
  46. package/dist/lib/session/fork.js +11 -2
  47. package/dist/lib/session/live-metadata.js +6 -0
  48. package/dist/lib/session/mirror.d.ts +3 -2
  49. package/dist/lib/session/mirror.js +5 -2
  50. package/dist/lib/session/remote/watch.js +18 -8
  51. package/dist/lib/session/title.d.ts +256 -0
  52. package/dist/lib/session/title.js +312 -0
  53. package/dist/lib/session/tool-index.d.ts +5 -0
  54. package/dist/lib/session/tool-index.js +1 -0
  55. package/dist/lib/session/types.d.ts +11 -0
  56. package/dist/lib/startup/root-command.d.ts +2 -0
  57. package/dist/lib/startup/root-command.js +22 -0
  58. package/dist/lib/traces/sync.js +1 -0
  59. package/dist/lib/usage-refresh.d.ts +58 -7
  60. package/dist/lib/usage-refresh.js +145 -23
  61. package/dist/lib/watchdog/runner.d.ts +3 -0
  62. package/dist/lib/watchdog/runner.js +1 -0
  63. package/dist/session-tracker/dist/install-hook.js +4 -4
  64. package/package.json +1 -1
@@ -14,7 +14,7 @@ import { type TimelineState } from './timeline.js';
14
14
  /** Current schema version; bumped when migrations are added. Exported so tests
15
15
  * assert against the constant instead of hardcoding a number that every bump
16
16
  * then has to chase (docs/sessions.md calls the constant the source of truth). */
17
- export declare const SCHEMA_VERSION = 49;
17
+ export declare const SCHEMA_VERSION = 50;
18
18
  /**
19
19
  * Bump to force the content extractor (assistant-answer text, alongside the
20
20
  * user-prompt text every harness already accumulates) to re-derive on every
@@ -530,6 +530,8 @@ export interface LocalMirrorSource {
530
530
  topic: string | null;
531
531
  firstUserMessage: string | null;
532
532
  label: string | null;
533
+ /** The daemon-generated headline (PHNX-3797), so a peer row shows the same title. */
534
+ generatedTitle: string | null;
533
535
  lastActivity: string | null;
534
536
  timestamp: string;
535
537
  ticketId: string | null;
@@ -558,6 +560,8 @@ export interface MirrorSessionUpsert {
558
560
  topic?: string | null;
559
561
  firstUser?: string | null;
560
562
  label?: string | null;
563
+ /** The publisher's daemon-generated headline (PHNX-3797); undefined when it has none yet. */
564
+ generatedTitle?: string | null;
561
565
  lastActivity?: string | null;
562
566
  timestamp: string;
563
567
  ticketId?: string | null;
@@ -579,6 +583,45 @@ export interface MirrorSessionUpsert {
579
583
  * row collapses with the live fan-out row under the same `machine:id` key.
580
584
  */
581
585
  export declare function upsertMirrorSession(row: MirrorSessionUpsert, source: string, syncedAt: number): boolean;
586
+ /** One indexed session the daemon titler may generate a headline for (PHNX-3797). */
587
+ export interface SessionTitleCandidateRow {
588
+ id: string;
589
+ agent: string;
590
+ cwd: string | null;
591
+ project: string | null;
592
+ topic: string | null;
593
+ firstUserMessage: string | null;
594
+ label: string | null;
595
+ ticketId: string | null;
596
+ gitBranch: string | null;
597
+ generatedTitle: string | null;
598
+ generatedTitleKey: string | null;
599
+ }
600
+ /**
601
+ * The most-recently-active LOCAL sessions the titler may consider (PHNX-3797).
602
+ *
603
+ * Deliberately not "every untitled row": the caller decides which of these still
604
+ * needs work by comparing each row's stored `generatedTitleKey` against the key
605
+ * of its current user text, so a first-user-message change re-titles without a
606
+ * second stored copy of that text. Rows excluded here can never be titled:
607
+ * - a peer MIRROR row — its owning box generates its title and publishes it;
608
+ * - a row with no user text at all — there is nothing user-anchored to title;
609
+ * - a row that already carries a `/rename`-style `label`, which outranks a
610
+ * generated title in the ladder, so generating one would buy nothing.
611
+ * `sinceMs` bounds the work to sessions recent enough to still be shown.
612
+ */
613
+ export declare function querySessionTitleCandidates(limit: number, opts?: {
614
+ sinceMs?: number;
615
+ id?: string;
616
+ }): SessionTitleCandidateRow[];
617
+ /**
618
+ * Persist one generated title against the source key it was derived from
619
+ * (PHNX-3797). `sourceKey` is what makes generation once-per-session: the next
620
+ * sweep sees a stored key equal to the row's current user text and skips it,
621
+ * and regenerates only when that text changed. Never touches a mirror row —
622
+ * the publishing box owns its own sessions' titles.
623
+ */
624
+ export declare function setSessionGeneratedTitle(id: string, title: string, sourceKey: string, now?: number): boolean;
582
625
  /**
583
626
  * Drop peer mirror rows (and their cached digests) whose last sync predates the
584
627
  * cutoff — the size ceiling / staleness pruner for the fleet session mirror
@@ -29,7 +29,7 @@ const DB_PATH = getSessionsDbPath();
29
29
  /** Current schema version; bumped when migrations are added. Exported so tests
30
30
  * assert against the constant instead of hardcoding a number that every bump
31
31
  * then has to chase (docs/sessions.md calls the constant the source of truth). */
32
- export const SCHEMA_VERSION = 49;
32
+ export const SCHEMA_VERSION = 50;
33
33
  /**
34
34
  * Bump to force the content extractor (assistant-answer text, alongside the
35
35
  * user-prompt text every harness already accumulates) to re-derive on every
@@ -154,7 +154,18 @@ CREATE TABLE IF NOT EXISTS sessions (
154
154
  -- by age without touching real rows; mirror_source names the publishing
155
155
  -- device. A row keeps a NULL mirror_synced_at once it gains a real transcript.
156
156
  mirror_synced_at INTEGER,
157
- mirror_source TEXT
157
+ mirror_source TEXT,
158
+ -- Daemon-generated session TITLE (PHNX-3797): a short technical label for what
159
+ -- the session worked on, produced once by the session-title daemon service and
160
+ -- read by every headline surface. Deliberately NOT transcript-derived, so the
161
+ -- scanner's upsert omits these columns entirely (an omitted column keeps its
162
+ -- prior value) and only the titler writes them. generated_title_key is the
163
+ -- hash of the user text the title was derived from, so the titler can tell
164
+ -- "already titled" from "the first user message changed"; generated_title_at
165
+ -- is when it was produced.
166
+ generated_title TEXT,
167
+ generated_title_key TEXT,
168
+ generated_title_at INTEGER
158
169
  );
159
170
  CREATE INDEX IF NOT EXISTS idx_sessions_timestamp ON sessions(timestamp DESC);
160
171
  CREATE INDEX IF NOT EXISTS idx_sessions_cwd ON sessions(cwd);
@@ -1310,10 +1321,15 @@ function migrateSchema(db, fromVersion) {
1310
1321
  // The idx_sessions_mirror_synced index is created unconditionally after this
1311
1322
  // block (fresh DBs skip migrations), alongside idx_sessions_last_activity.
1312
1323
  }
1313
- if (fromVersion < 49) {
1314
- const cols = new Set(db.prepare(`PRAGMA table_info(sessions)`).all().map(c => c.name));
1315
- if (!cols.has('account_id'))
1316
- db.exec(`ALTER TABLE sessions ADD COLUMN account_id TEXT`);
1324
+ if (fromVersion < 47) {
1325
+ // v46 -> v47: carry the actor's Phoenix id (PHNX-3798). Additive, write-once
1326
+ // launch metadata joined from the actor sidecar — never transcript-derived,
1327
+ // exactly like actor/initiated_by (v19) — so there is no ledger wipe: a rescan
1328
+ // can't backfill it and forcing a full re-parse would be pure churn. Existing
1329
+ // rows stay NULL until the sidecar-join fills them (the ON CONFLICT COALESCEs).
1330
+ const cols = new Set(db.prepare(`PRAGMA table_info(sessions)`).all().map((c) => c.name));
1331
+ if (!cols.has('phoenix_id'))
1332
+ db.exec(`ALTER TABLE sessions ADD COLUMN phoenix_id TEXT`);
1317
1333
  }
1318
1334
  if (fromVersion < 48) {
1319
1335
  // v47 -> v48: retain the LATEST genuine user turn beside the first
@@ -1334,15 +1350,26 @@ function migrateSchema(db, fromVersion) {
1334
1350
  if (!cols.has('last_user_message'))
1335
1351
  db.exec(`ALTER TABLE sessions ADD COLUMN last_user_message TEXT`);
1336
1352
  }
1337
- if (fromVersion < 47) {
1338
- // v46 -> v47: carry the actor's Phoenix id (PHNX-3798). Additive, write-once
1339
- // launch metadata joined from the actor sidecar — never transcript-derived,
1340
- // exactly like actor/initiated_by (v19) — so there is no ledger wipe: a rescan
1341
- // can't backfill it and forcing a full re-parse would be pure churn. Existing
1342
- // rows stay NULL until the sidecar-join fills them (the ON CONFLICT COALESCEs).
1353
+ if (fromVersion < 49) {
1354
+ // v48 -> v49: account-first storage (PHNX-3940).
1355
+ const cols = new Set(db.prepare(`PRAGMA table_info(sessions)`).all().map(c => c.name));
1356
+ if (!cols.has('account_id'))
1357
+ db.exec(`ALTER TABLE sessions ADD COLUMN account_id TEXT`);
1358
+ }
1359
+ if (fromVersion < 50) {
1360
+ // v49 -> v50: the daemon-generated session title (PHNX-3797). Additive
1361
+ // columns, no ledger wipe: nothing in a transcript produces them — the
1362
+ // session-title service writes them and the scanner's upsert never names
1363
+ // them — so re-parsing every indexed transcript would be pure churn. Rows
1364
+ // stay NULL until the titler reaches them, which the recap ladder handles by
1365
+ // falling back to the user's own first message.
1343
1366
  const cols = new Set(db.prepare(`PRAGMA table_info(sessions)`).all().map((c) => c.name));
1344
- if (!cols.has('phoenix_id'))
1345
- db.exec(`ALTER TABLE sessions ADD COLUMN phoenix_id TEXT`);
1367
+ if (!cols.has('generated_title'))
1368
+ db.exec(`ALTER TABLE sessions ADD COLUMN generated_title TEXT`);
1369
+ if (!cols.has('generated_title_key'))
1370
+ db.exec(`ALTER TABLE sessions ADD COLUMN generated_title_key TEXT`);
1371
+ if (!cols.has('generated_title_at'))
1372
+ db.exec(`ALTER TABLE sessions ADD COLUMN generated_title_at INTEGER`);
1346
1373
  }
1347
1374
  }
1348
1375
  /**
@@ -2753,6 +2780,7 @@ function rowToMeta(row) {
2753
2780
  topic: row.topic ?? undefined,
2754
2781
  firstUserMessage: row.first_user_message ?? undefined,
2755
2782
  lastUserMessage: row.last_user_message ?? undefined,
2783
+ generatedTitle: row.generated_title ?? undefined,
2756
2784
  label: row.label ?? undefined,
2757
2785
  isTeamOrigin: row.is_team_origin === 1,
2758
2786
  prUrl: row.pr_url ?? undefined,
@@ -3552,7 +3580,7 @@ export function writeSessionTimeline(entry) {
3552
3580
  export function queryLocalOriginSessionsForMirror(self, limit) {
3553
3581
  const rows = getDB().prepare(`
3554
3582
  SELECT id, short_id, agent, version, machine, cwd, topic, first_user_message,
3555
- label, last_activity, timestamp, ticket_id, pr_url
3583
+ label, generated_title, last_activity, timestamp, ticket_id, pr_url
3556
3584
  FROM sessions
3557
3585
  WHERE mirror_synced_at IS NULL
3558
3586
  AND file_path IS NOT NULL AND file_path <> ''
@@ -3565,6 +3593,7 @@ export function queryLocalOriginSessionsForMirror(self, limit) {
3565
3593
  return rows.map((r) => ({
3566
3594
  id: r.id, shortId: r.short_id, agent: r.agent, version: r.version, machine: r.machine,
3567
3595
  cwd: r.cwd, topic: r.topic, firstUserMessage: r.first_user_message, label: r.label,
3596
+ generatedTitle: r.generated_title,
3568
3597
  lastActivity: r.last_activity, timestamp: r.timestamp, ticketId: r.ticket_id, prUrl: r.pr_url,
3569
3598
  // Ride the daemon-computed summary alongside the digest so a peer renders it
3570
3599
  // without a transcript (PHNX-3939); only a summarized session carries one.
@@ -3589,11 +3618,11 @@ export function upsertMirrorSession(row, source, syncedAt) {
3589
3618
  const result = db.prepare(`
3590
3619
  INSERT INTO sessions (
3591
3620
  id, short_id, agent, origin, version, timestamp, last_activity, cwd,
3592
- topic, first_user_message, label, message_count, file_path, scanned_at,
3621
+ topic, first_user_message, label, generated_title, message_count, file_path, scanned_at,
3593
3622
  is_team_origin, ticket_id, pr_url, machine, mirror_synced_at, mirror_source
3594
3623
  ) VALUES (
3595
3624
  @id, @short_id, @agent, 'cli', @version, @timestamp, @last_activity, @cwd,
3596
- @topic, @first_user, @label, NULL, '', @scanned_at,
3625
+ @topic, @first_user, @label, @generated_title, NULL, '', @scanned_at,
3597
3626
  0, @ticket_id, @pr_url, @machine, @synced_at, @source
3598
3627
  )
3599
3628
  ON CONFLICT(id) DO UPDATE SET
@@ -3611,6 +3640,12 @@ export function upsertMirrorSession(row, source, syncedAt) {
3611
3640
  -- symptom this feature fixes. A null peer label clears it so the list falls
3612
3641
  -- back to the synced topic.
3613
3642
  label = excluded.label,
3643
+ -- Same rule for the generated headline (PHNX-3797): the publishing box owns
3644
+ -- its sessions' titles, and this box's titler never generates for a mirror
3645
+ -- row, so the peer's value wins outright and a peer that has not titled the
3646
+ -- session yet clears it (the ladder then falls back to the synced first
3647
+ -- user message, never to an agent line).
3648
+ generated_title = excluded.generated_title,
3614
3649
  ticket_id = COALESCE(excluded.ticket_id, sessions.ticket_id),
3615
3650
  pr_url = COALESCE(excluded.pr_url, sessions.pr_url),
3616
3651
  machine = excluded.machine,
@@ -3628,6 +3663,7 @@ export function upsertMirrorSession(row, source, syncedAt) {
3628
3663
  topic: row.topic ?? null,
3629
3664
  first_user: row.firstUser ?? null,
3630
3665
  label: row.label ?? null,
3666
+ generated_title: row.generatedTitle ?? null,
3631
3667
  ticket_id: row.ticketId ?? null,
3632
3668
  pr_url: row.prUrl ?? null,
3633
3669
  machine: row.machine,
@@ -3665,6 +3701,75 @@ export function upsertMirrorSession(row, source, syncedAt) {
3665
3701
  }
3666
3702
  return true;
3667
3703
  }
3704
+ /**
3705
+ * The most-recently-active LOCAL sessions the titler may consider (PHNX-3797).
3706
+ *
3707
+ * Deliberately not "every untitled row": the caller decides which of these still
3708
+ * needs work by comparing each row's stored `generatedTitleKey` against the key
3709
+ * of its current user text, so a first-user-message change re-titles without a
3710
+ * second stored copy of that text. Rows excluded here can never be titled:
3711
+ * - a peer MIRROR row — its owning box generates its title and publishes it;
3712
+ * - a row with no user text at all — there is nothing user-anchored to title;
3713
+ * - a row that already carries a `/rename`-style `label`, which outranks a
3714
+ * generated title in the ladder, so generating one would buy nothing.
3715
+ * `sinceMs` bounds the work to sessions recent enough to still be shown.
3716
+ */
3717
+ export function querySessionTitleCandidates(limit, opts = {}) {
3718
+ const clauses = [
3719
+ `mirror_synced_at IS NULL`,
3720
+ `(first_user_message IS NOT NULL OR topic IS NOT NULL)`,
3721
+ ];
3722
+ const params = [];
3723
+ if (opts.id) {
3724
+ clauses.push(`(id = ? OR short_id = ?)`);
3725
+ params.push(opts.id, opts.id);
3726
+ }
3727
+ else {
3728
+ // An explicitly requested session is titled even when it carries a label or
3729
+ // is older than the window; the periodic sweep honours both bounds.
3730
+ clauses.push(`(label IS NULL OR trim(label) = '')`);
3731
+ if (opts.sinceMs != null) {
3732
+ clauses.push(`COALESCE(last_activity, timestamp) >= ?`);
3733
+ params.push(new Date(opts.sinceMs).toISOString());
3734
+ }
3735
+ }
3736
+ const rows = getDB().prepare(`
3737
+ SELECT id, agent, cwd, project, topic, first_user_message, label, ticket_id,
3738
+ git_branch, generated_title, generated_title_key
3739
+ FROM sessions
3740
+ WHERE ${clauses.join(' AND ')}
3741
+ ORDER BY COALESCE(last_activity, timestamp) DESC
3742
+ LIMIT ?
3743
+ `).all(...params, limit);
3744
+ return rows.map((r) => ({
3745
+ id: r.id,
3746
+ agent: r.agent,
3747
+ cwd: r.cwd,
3748
+ project: r.project,
3749
+ topic: r.topic,
3750
+ firstUserMessage: r.first_user_message,
3751
+ label: r.label,
3752
+ ticketId: r.ticket_id,
3753
+ gitBranch: r.git_branch,
3754
+ generatedTitle: r.generated_title,
3755
+ generatedTitleKey: r.generated_title_key,
3756
+ }));
3757
+ }
3758
+ /**
3759
+ * Persist one generated title against the source key it was derived from
3760
+ * (PHNX-3797). `sourceKey` is what makes generation once-per-session: the next
3761
+ * sweep sees a stored key equal to the row's current user text and skips it,
3762
+ * and regenerates only when that text changed. Never touches a mirror row —
3763
+ * the publishing box owns its own sessions' titles.
3764
+ */
3765
+ export function setSessionGeneratedTitle(id, title, sourceKey, now = Date.now()) {
3766
+ const result = getDB().prepare(`
3767
+ UPDATE sessions
3768
+ SET generated_title = ?, generated_title_key = ?, generated_title_at = ?
3769
+ WHERE id = ? AND mirror_synced_at IS NULL
3770
+ `).run(title, sourceKey, now, id);
3771
+ return result.changes > 0;
3772
+ }
3668
3773
  /**
3669
3774
  * Drop peer mirror rows (and their cached digests) whose last sync predates the
3670
3775
  * cutoff — the size ceiling / staleness pruner for the fleet session mirror
@@ -40,8 +40,16 @@ export interface ForkRecapInput {
40
40
  /** Changed-files tally so far. */
41
41
  changes?: ForkRecapChanges;
42
42
  }
43
- /** Resolve the human display label the caller passes in from a raw SessionMeta. */
44
- export declare function forkLabelFor(session: Pick<SessionMeta, 'label' | 'topic' | 'shortId'>): string;
43
+ /**
44
+ * Resolve the human display label the caller passes in from a raw SessionMeta.
45
+ *
46
+ * Pass the session WHOLE. A caller that re-lists fields into an object literal
47
+ * can legally omit the optional `generatedTitle` — the type system does not
48
+ * flag a missing optional property — and the headline then silently degrades to
49
+ * `topic`. That is what the fork call site did (PHNX-3797); the rebuilt-literal
50
+ * shape is caught by `title.ladder-completeness.test.ts`.
51
+ */
52
+ export declare function forkLabelFor(session: Pick<SessionMeta, 'label' | 'generatedTitle' | 'topic' | 'shortId'>): string;
45
53
  /**
46
54
  * Build the recap-seed prompt handed to the forked sibling as its opening input.
47
55
  *
@@ -1,3 +1,4 @@
1
+ import { sessionHeadline } from './title.js';
1
2
  /** Longest last-assistant excerpt carried into the seed — enough to convey intent
2
3
  * without pasting a wall of text (decision: Recap, not Full digest). */
3
4
  const LAST_LINE_CAP = 400;
@@ -9,9 +10,17 @@ function trimLastLine(text) {
9
10
  return collapsed;
10
11
  return `${collapsed.slice(0, LAST_LINE_CAP).trimEnd()}…`;
11
12
  }
12
- /** Resolve the human display label the caller passes in from a raw SessionMeta. */
13
+ /**
14
+ * Resolve the human display label the caller passes in from a raw SessionMeta.
15
+ *
16
+ * Pass the session WHOLE. A caller that re-lists fields into an object literal
17
+ * can legally omit the optional `generatedTitle` — the type system does not
18
+ * flag a missing optional property — and the headline then silently degrades to
19
+ * `topic`. That is what the fork call site did (PHNX-3797); the rebuilt-literal
20
+ * shape is caught by `title.ladder-completeness.test.ts`.
21
+ */
13
22
  export function forkLabelFor(session) {
14
- return session.label || session.topic || session.shortId;
23
+ return sessionHeadline(session) || session.shortId;
15
24
  }
16
25
  /**
17
26
  * Build the recap-seed prompt handed to the forked sibling as its opening input.
@@ -48,6 +48,12 @@ export function activeSessionToSessionMeta(active, self, nowMs) {
48
48
  cwd: active.cwd,
49
49
  project: active.project ?? undefined,
50
50
  label: active.label,
51
+ // Carried even though this projection only runs for a session with NO indexed
52
+ // row (so the value is `undefined` today): a hand-built SessionMeta that
53
+ // omits the rung is exactly the shape that silently degrades the headline the
54
+ // moment its reachability changes (PHNX-3797). Neither guard can see it here
55
+ // — it calls no ladder, and its fields sit on separate lines.
56
+ generatedTitle: active.generatedTitle,
51
57
  topic: active.topic,
52
58
  firstUserMessage: active.firstUserMessage,
53
59
  version: active.version,
@@ -10,8 +10,9 @@
10
10
  * delivers fleet-wide with no operator step. The consuming device folds peer
11
11
  * digests into its local `sessions` index as mirror rows, so the picker/list/
12
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.
13
+ * only topic/label, the peer's daemon-generated title (PHNX-3797), a
14
+ * first-user-message snippet, last-activity, agent+version, cwd, ticket, and
15
+ * PR — and the mirror is bounded and pruned by age.
15
16
  *
16
17
  * Direction is deliberately the inverse of usage-sync: EVERY box publishes its
17
18
  * own local sessions (workers are exactly where the remote sessions the personal
@@ -10,8 +10,9 @@
10
10
  * delivers fleet-wide with no operator step. The consuming device folds peer
11
11
  * digests into its local `sessions` index as mirror rows, so the picker/list/
12
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.
13
+ * only topic/label, the peer's daemon-generated title (PHNX-3797), a
14
+ * first-user-message snippet, last-activity, agent+version, cwd, ticket, and
15
+ * PR — and the mirror is bounded and pruned by age.
15
16
  *
16
17
  * Direction is deliberately the inverse of usage-sync: EVERY box publishes its
17
18
  * own local sessions (workers are exactly where the remote sessions the personal
@@ -94,6 +95,7 @@ export async function publishSessionMirrorToSharedStore(options = {}) {
94
95
  ...(s.cwd ? { cwd: s.cwd } : {}),
95
96
  ...(snippet(s.topic, 200) ? { topic: snippet(s.topic, 200) } : {}),
96
97
  ...(s.label ? { label: s.label } : {}),
98
+ ...(snippet(s.generatedTitle, 200) ? { title: snippet(s.generatedTitle, 200) } : {}),
97
99
  ...(snippet(s.firstUserMessage) ? { firstUser: snippet(s.firstUserMessage) } : {}),
98
100
  ...(s.lastActivity ? { lastActivity: s.lastActivity } : {}),
99
101
  timestamp: s.timestamp,
@@ -171,6 +173,7 @@ function toUpsert(raw) {
171
173
  topic: cap(r.topic, 400),
172
174
  firstUser: cap(r.firstUser, SESSION_MIRROR_SNIPPET_MAX),
173
175
  label: cap(r.label, 400),
176
+ generatedTitle: cap(r.title, 200),
174
177
  lastActivity: isString(r.lastActivity) ? r.lastActivity : undefined,
175
178
  timestamp,
176
179
  ticketId: cap(r.ticketId, 64),
@@ -60,6 +60,16 @@ function epochMs(value) {
60
60
  export function previousSessionWatchRowKey(scope, sessionId) {
61
61
  return createHash('sha256').update(`${scope}\0previous\0${sessionId}`).digest('base64url').slice(0, 22);
62
62
  }
63
+ /**
64
+ * The headline for a durable history row, on the same ladder a live row uses
65
+ * ({@link deriveSessionRecap}): an explicit `/rename` label, then the
66
+ * daemon-generated title (PHNX-3797), then the daemon-folded request headline
67
+ * (`request.headline` — the user's own sentence with attachment noise pulled
68
+ * out, PHNX-3939) when the transcript cache has one, else the raw `topic`.
69
+ */
70
+ function previousRowTitle(session, foldedHeadline) {
71
+ return session.label || session.generatedTitle || foldedHeadline || session.topic || undefined;
72
+ }
63
73
  /** Project one durable indexed session into the same canonical watch contract
64
74
  * as live sessions. This is the only history backfill consumed by AGI EXT. */
65
75
  export function toPreviousSessionWatchRow(scope, session) {
@@ -86,6 +96,7 @@ export function toPreviousSessionWatchRow(scope, session) {
86
96
  // transcript-keyed cache the live merge reads (PHNX-3939), so a closed session
87
97
  // still shows the request it was given and what the agent did — no re-parse.
88
98
  const folded = readSessionTimelineAny(session.id);
99
+ const title = previousRowTitle(session, folded?.request?.headline);
89
100
  return {
90
101
  context: 'recent',
91
102
  kind: session.agent,
@@ -93,14 +104,13 @@ export function toPreviousSessionWatchRow(scope, session) {
93
104
  sessionId: session.id,
94
105
  ...(session.cwd ? { cwd: session.cwd } : {}),
95
106
  ...(session.project ? { project: session.project } : {}),
96
- // `request.headline` is the user's own sentence with the attachment noise
97
- // pulled out, so it beats the raw `topic` for the row title — a `/model` echo
98
- // or a skill body can no longer become a session's name (PHNX-3939).
99
- ...(session.label
100
- ? { label: session.label, title: session.label }
101
- : folded?.request?.headline
102
- ? { title: folded.request.headline }
103
- : session.topic ? { title: session.topic } : {}),
107
+ // Same headline ladder as a live row (`deriveSessionRecap`, PHNX-3797):
108
+ // `/rename` label → the daemon-generated title → `request.headline` (the
109
+ // user's own sentence with attachment noise pulled out, PHNX-3939) →
110
+ // the raw `topic`. See {@link previousRowTitle}.
111
+ ...(session.label ? { label: session.label } : {}),
112
+ ...(session.generatedTitle ? { generatedTitle: session.generatedTitle } : {}),
113
+ ...(title ? { title } : {}),
104
114
  ...(session.topic ? { topic: session.topic } : {}),
105
115
  ...(session.firstUserMessage ? { firstUserMessage: session.firstUserMessage } : {}),
106
116
  ...(session.version ? { version: session.version } : {}),