@phnx-labs/agents-cli 1.22.83 → 1.22.84

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 (41) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/commands/accounts.js +1 -1
  3. package/dist/commands/sessions-trace.d.ts +11 -0
  4. package/dist/commands/sessions-trace.js +71 -0
  5. package/dist/lib/accounts/connect.d.ts +6 -0
  6. package/dist/lib/accounts/connect.js +70 -0
  7. package/dist/lib/daemon-ticks.js +23 -2
  8. package/dist/lib/feed/activity-stream.d.ts +60 -0
  9. package/dist/lib/feed/activity-stream.js +271 -0
  10. package/dist/lib/feed/activity.d.ts +7 -0
  11. package/dist/lib/feed/activity.js +10 -4
  12. package/dist/lib/feed/watch.d.ts +9 -3
  13. package/dist/lib/feed/watch.js +72 -36
  14. package/dist/lib/fleet-shared-state.d.ts +6 -0
  15. package/dist/lib/session/active.d.ts +38 -4
  16. package/dist/lib/session/active.js +36 -4
  17. package/dist/lib/session/bash-command.js +10 -0
  18. package/dist/lib/session/db.d.ts +47 -2
  19. package/dist/lib/session/db.js +131 -4
  20. package/dist/lib/session/mirror.d.ts +5 -0
  21. package/dist/lib/session/mirror.js +166 -0
  22. package/dist/lib/session/parse.d.ts +49 -0
  23. package/dist/lib/session/parse.js +324 -30
  24. package/dist/lib/session/prompt.d.ts +93 -2
  25. package/dist/lib/session/prompt.js +266 -15
  26. package/dist/lib/session/remote/peer-stream.d.ts +47 -0
  27. package/dist/lib/session/remote/peer-stream.js +142 -0
  28. package/dist/lib/session/remote/watch.d.ts +1 -1
  29. package/dist/lib/session/remote/watch.js +41 -42
  30. package/dist/lib/session/session-cache.d.ts +9 -0
  31. package/dist/lib/session/session-cache.js +40 -2
  32. package/dist/lib/session/timeline-pass.d.ts +129 -0
  33. package/dist/lib/session/timeline-pass.js +323 -0
  34. package/dist/lib/session/timeline.d.ts +182 -0
  35. package/dist/lib/session/timeline.js +636 -0
  36. package/dist/lib/session/types.d.ts +155 -1
  37. package/dist/lib/summarizer/pass.d.ts +2 -0
  38. package/dist/lib/summarizer/pass.js +14 -1
  39. package/dist/lib/summarizer/summarize.d.ts +7 -0
  40. package/dist/lib/summarizer/summarize.js +3 -0
  41. package/package.json +1 -1
@@ -30,7 +30,7 @@ import { loadHookSessionIndex, resolveHookSessionRecord, readStateSessionRecord
30
30
  import { buildClaudeLabelMap, getAgentSessionDirs } from './discover.js';
31
31
  import { buildRunNameMap } from './run-names.js';
32
32
  import { latestSessionFileForCwd, findSessionsByShortIds, findSessionMachinesByIds, getSessionById } from './db.js';
33
- import { extractSessionTopic, classifyUserPrompt } from './prompt.js';
33
+ import { extractSessionTopic, classifyUserPrompt, tidyRequest } from './prompt.js';
34
34
  import { readSessionTailWithRaw } from './tail.js';
35
35
  import { parseSession } from './parse.js';
36
36
  import { computeTokPerSec } from './throughput.js';
@@ -151,6 +151,8 @@ export function backfillActiveRowsFromMeta(sessions, metaById) {
151
151
  s.label = m.label;
152
152
  if (!s.firstUserMessage && m.firstUserMessage)
153
153
  s.firstUserMessage = m.firstUserMessage;
154
+ if (!s.lastUserMessage && m.lastUserMessage)
155
+ s.lastUserMessage = m.lastUserMessage;
154
156
  if (!s.ticket && m.ticketId)
155
157
  s.ticket = { id: m.ticketId, url: linearIssueUrl(m.ticketId) };
156
158
  if (!s.pr && m.prUrl)
@@ -2101,10 +2103,31 @@ function recapLine(s, max = 120) {
2101
2103
  return undefined;
2102
2104
  return t.length > max ? t.slice(0, max - 1).trimEnd() + '…' : t;
2103
2105
  }
2104
- /** Labels win, then the last assistant line, then the first-prompt topic. */
2106
+ /**
2107
+ * Labels win, then the last assistant line, then the prompt.
2108
+ *
2109
+ * The prompt rung classifies the RAW latest genuine user turn, not the
2110
+ * already-collapsed `topic` (PHNX-3939). Classifying `topic` was how a `/model`
2111
+ * echo or a skill body became a session's displayed title: `extractSessionTopic`
2112
+ * accepted text `cleanFirstUserMessage` rejected, and the recap then dressed up
2113
+ * that scaffolding as the user's words. It also never saw the row's attachments,
2114
+ * so an image-only turn could not be recognized. A row that has already been
2115
+ * merged with a daemon-folded {@link ActiveSession.request} uses it directly.
2116
+ */
2105
2117
  export function deriveSessionRecap(row) {
2106
2118
  const lastAgentLine = recapLine(row.tail?.length ? row.tail[row.tail.length - 1] : undefined);
2107
- const { clean: userPromptClean, kind: userPromptKind } = classifyUserPrompt(row.topic ?? '');
2119
+ const raw = row.lastUserMessage ?? row.firstUserMessage ?? row.topic ?? '';
2120
+ // No `turns` here on purpose: this tidy sees ONE turn, not the transcript, so
2121
+ // it cannot count them. The daemon fold sets it (see SessionRequest.turns).
2122
+ const tidied = row.request ?? tidyRequest(raw, { attachments: row.attachments });
2123
+ // No genuine turn to show (a synthetic-only transcript, or nothing indexed
2124
+ // yet): fall back to the legacy classifier over whatever `topic` holds rather
2125
+ // than showing an empty prompt rung.
2126
+ const fallback = tidied ? undefined : classifyUserPrompt(row.topic ?? '', {
2127
+ hasImageAttachment: row.attachments?.some((a) => a.mediaType?.startsWith('image/')),
2128
+ });
2129
+ const userPromptClean = tidied?.headline ?? fallback?.clean;
2130
+ const userPromptKind = tidied?.kind ?? fallback?.kind;
2108
2131
  const label = recapLine(row.label);
2109
2132
  const prompt = recapLine(userPromptClean || row.topic);
2110
2133
  let title;
@@ -2121,7 +2144,14 @@ export function deriveSessionRecap(row) {
2121
2144
  title = prompt;
2122
2145
  recapSource = 'prompt';
2123
2146
  }
2124
- return { title, recapSource, userPromptClean: recapLine(userPromptClean), userPromptKind, lastAgentLine };
2147
+ return {
2148
+ title,
2149
+ recapSource,
2150
+ userPromptClean: recapLine(userPromptClean),
2151
+ userPromptKind,
2152
+ lastAgentLine,
2153
+ ...(tidied ? { request: tidied } : {}),
2154
+ };
2125
2155
  }
2126
2156
  /** Fold the recap ladder onto every row (see {@link deriveSessionRecap}). */
2127
2157
  export function foldRecap(rows) {
@@ -2132,6 +2162,8 @@ export function foldRecap(rows) {
2132
2162
  s.userPromptClean = recap.userPromptClean;
2133
2163
  s.userPromptKind = recap.userPromptKind;
2134
2164
  s.lastAgentLine = recap.lastAgentLine;
2165
+ if (recap.request)
2166
+ s.request = recap.request;
2135
2167
  }
2136
2168
  }
2137
2169
  /**
@@ -456,6 +456,16 @@ export function detectBashMilestone(command) {
456
456
  if (lower.includes('pr merge'))
457
457
  return { event: 'pr.merged', detail: 'gh pr merge' };
458
458
  }
459
+ // The repo's own toolchain: spawning a team and rendering an artifact are the
460
+ // two things an operator looks for in a session's history alongside a commit
461
+ // or a PR, and both are shell-driven (PHNX-3939).
462
+ if (info.tool === 'agents' && info.subcommand === 'teams'
463
+ && /\bteams\s+(create|add|start)\b/.test(lower)) {
464
+ return { event: 'team.spawned', detail: 'agents teams' };
465
+ }
466
+ if (/\bartifacts\s+render\b/.test(lower)) {
467
+ return { event: 'artifact.rendered', detail: 'artifacts render' };
468
+ }
459
469
  return null;
460
470
  }
461
471
  /**
@@ -7,13 +7,14 @@
7
7
  * BM25 ranking, and label-first search for /rename'd sessions.
8
8
  */
9
9
  import Database from '../sqlite.js';
10
- import type { SessionAgentId, SessionCheckpoint, SessionChecklistItem, SessionEvent, SessionMeta, SummaryState } from './types.js';
10
+ import type { SessionAgentId, SessionCheckpoint, SessionChecklistItem, SessionEvent, SessionFiles, SessionMeta, SessionRequest, SessionTimeline, SummaryState } from './types.js';
11
11
  import { type IndexedToolCall } from './tool-calls.js';
12
12
  import { type ToolScanResumePoint } from './tool-store.js';
13
+ import { type TimelineState } from './timeline.js';
13
14
  /** Current schema version; bumped when migrations are added. Exported so tests
14
15
  * assert against the constant instead of hardcoding a number that every bump
15
16
  * then has to chase (docs/sessions.md calls the constant the source of truth). */
16
- export declare const SCHEMA_VERSION = 47;
17
+ export declare const SCHEMA_VERSION = 48;
17
18
  /**
18
19
  * Bump to force the content extractor (assistant-answer text, alongside the
19
20
  * user-prompt text every harness already accumulates) to re-derive on every
@@ -478,6 +479,46 @@ export declare function writeSessionSummary(entry: {
478
479
  fileSize: number | null;
479
480
  summary: SessionSummaryEntry;
480
481
  }): void;
482
+ /**
483
+ * The daemon-computed timeline stored in `session_timelines` (PHNX-3939): the
484
+ * bounded projection a row merges, plus the request and files derived in the
485
+ * same fold.
486
+ */
487
+ export interface SessionTimelineProjection {
488
+ timeline: SessionTimeline;
489
+ request?: SessionRequest;
490
+ files?: SessionFiles;
491
+ }
492
+ /** One folded timeline, as the pass produces it for writing. */
493
+ export interface SessionTimelineEntry extends SessionTimelineProjection {
494
+ state: TimelineState;
495
+ }
496
+ /** One cached row as READ back: the folded entry plus when it was folded. */
497
+ export interface SessionTimelineCacheRow extends SessionTimelineEntry {
498
+ /** Epoch ms this row was folded — the pass's re-parse rate limit reads it. */
499
+ computedAt: number;
500
+ }
501
+ /**
502
+ * Read the projection AND the resume state for one session, whatever the
503
+ * transcript stamp — the daemon pass's read, which needs the state to continue
504
+ * from and re-stats the file itself.
505
+ */
506
+ export declare function readSessionTimelineEntry(id: string): SessionTimelineCacheRow | undefined;
507
+ /**
508
+ * Read only the bounded projection for one session — the display merge, on the
509
+ * read path. Deliberately does NOT parse the resume state: a live row needs the
510
+ * 8 steps and the request, never the fold's bookkeeping.
511
+ */
512
+ export declare function readSessionTimelineAny(id: string): SessionTimelineProjection | undefined;
513
+ /** Persist a folded timeline against the exact transcript bytes it was folded to. */
514
+ export declare function writeSessionTimeline(entry: {
515
+ id: string;
516
+ fileMtimeMs: number | null;
517
+ fileSize: number | null;
518
+ timeline: SessionTimelineEntry;
519
+ /** Fold time. Injected by the pass so its re-parse interval is testable without the wall clock. */
520
+ computedAtMs?: number;
521
+ }): void;
481
522
  /** A local-origin session, projected to the compact fields the fleet mirror publishes. */
482
523
  export interface LocalMirrorSource {
483
524
  id: string;
@@ -495,6 +536,8 @@ export interface LocalMirrorSource {
495
536
  prUrl: string | null;
496
537
  /** Daemon-computed summary (PHNX-3939), so peers carry it without a transcript. */
497
538
  summary: SessionSummaryEntry | null;
539
+ /** Daemon-folded request/timeline/files (PHNX-3939), same reason as {@link summary}. */
540
+ timeline: SessionTimelineProjection | null;
498
541
  }
499
542
  /**
500
543
  * The most-recently-active sessions whose transcript is genuinely local to this
@@ -521,6 +564,8 @@ export interface MirrorSessionUpsert {
521
564
  prUrl?: string | null;
522
565
  /** Daemon-computed summary carried from the publishing peer (PHNX-3939). */
523
566
  summary?: SessionSummaryEntry | null;
567
+ /** Daemon-folded request/timeline/files carried from the publishing peer (PHNX-3939). */
568
+ timeline?: SessionTimelineProjection | null;
524
569
  }
525
570
  /**
526
571
  * Write one peer session's digest into the local `sessions` index as a mirror
@@ -22,13 +22,14 @@ import { extractBackgroundShells, extractSkills, extractSlashCommands, harnessTr
22
22
  import { resolveResource } from '../resources.js';
23
23
  import { discoverPlugins } from '../plugins/plugins.js';
24
24
  import { machineId } from '../machine-id.js';
25
- import { firstUserMessageFromEvents } from './prompt.js';
25
+ import { firstUserMessageFromEvents, lastUserMessageFromEvents } from './prompt.js';
26
+ import { emptyTimelineState, TIMELINE_EXTRACTOR_VERSION } from './timeline.js';
26
27
  const SESSIONS_DIR = getSessionsDir();
27
28
  const DB_PATH = getSessionsDbPath();
28
29
  /** Current schema version; bumped when migrations are added. Exported so tests
29
30
  * assert against the constant instead of hardcoding a number that every bump
30
31
  * then has to chase (docs/sessions.md calls the constant the source of truth). */
31
- export const SCHEMA_VERSION = 47;
32
+ export const SCHEMA_VERSION = 48;
32
33
  /**
33
34
  * Bump to force the content extractor (assistant-answer text, alongside the
34
35
  * user-prompt text every harness already accumulates) to re-derive on every
@@ -103,6 +104,7 @@ CREATE TABLE IF NOT EXISTS sessions (
103
104
  git_branch TEXT,
104
105
  topic TEXT,
105
106
  first_user_message TEXT,
107
+ last_user_message TEXT,
106
108
  label TEXT,
107
109
  message_count INTEGER,
108
110
  token_count INTEGER,
@@ -420,6 +422,27 @@ CREATE TABLE IF NOT EXISTS session_summaries (
420
422
  summary_json TEXT NOT NULL
421
423
  );
422
424
 
425
+ -- Daemon-computed narration-anchored timeline (PHNX-3939). Same lazy,
426
+ -- stamp-validated cache shape as session_summaries, with one addition: the fold
427
+ -- is INCREMENTAL, so the row carries both the bounded projection the display
428
+ -- path reads and the resume state the pass folds the next appended bytes onto.
429
+ --
430
+ -- Two columns rather than one on purpose. projection_json is small and bounded
431
+ -- (8 steps + counters + the tidied request + 8 file rows) and is what a session
432
+ -- row merges on the read path; state_json holds the whole TimelineState (byte
433
+ -- offset, open step, pending call ids, per-path file ledger) and is read ONLY by
434
+ -- the daemon pass. Folding one blob would make every row merge parse the resume
435
+ -- state it has no use for.
436
+ CREATE TABLE IF NOT EXISTS session_timelines (
437
+ session_id TEXT PRIMARY KEY,
438
+ file_mtime_ms INTEGER,
439
+ file_size INTEGER,
440
+ extractor_version INTEGER NOT NULL,
441
+ computed_at INTEGER NOT NULL,
442
+ projection_json TEXT NOT NULL,
443
+ state_json TEXT NOT NULL
444
+ );
445
+
423
446
  -- Durable metadata for one browser task (RUSH-2549). The browser daemon's
424
447
  -- tasks.json is LIVE state: saveTaskState writes the in-memory task map, so
425
448
  -- stopping a task drops its entry and a daemon restart empties the file. That is
@@ -1286,6 +1309,25 @@ function migrateSchema(db, fromVersion) {
1286
1309
  // The idx_sessions_mirror_synced index is created unconditionally after this
1287
1310
  // block (fresh DBs skip migrations), alongside idx_sessions_last_activity.
1288
1311
  }
1312
+ if (fromVersion < 48) {
1313
+ // v47 -> v48: retain the LATEST genuine user turn beside the first
1314
+ // (PHNX-3939). For a /continue, a redirect, or an interrupted-and-restated
1315
+ // session the operative request is the last thing the user said, and the row
1316
+ // title / sidebar Request card show that.
1317
+ //
1318
+ // Deliberately WITHOUT a CONTENT_INDEX_VERSION bump, unlike the v45
1319
+ // first_user_message column. That bump forces a re-parse of every indexed
1320
+ // transcript on the next scan; measured on this fleet it pinned the daemon
1321
+ // long enough to blow the session-index and session-state tick deadlines and
1322
+ // park both services. It also buys nothing here: the value can only CHANGE
1323
+ // when the transcript changes, so the ordinary incremental scan fills it in
1324
+ // for exactly the sessions where it matters, and the sidebar's request comes
1325
+ // from the daemon-folded `session_timelines` projection rather than this
1326
+ // column. An old row simply keeps NULL until its next write.
1327
+ const cols = new Set(db.prepare(`PRAGMA table_info(sessions)`).all().map((c) => c.name));
1328
+ if (!cols.has('last_user_message'))
1329
+ db.exec(`ALTER TABLE sessions ADD COLUMN last_user_message TEXT`);
1330
+ }
1289
1331
  if (fromVersion < 47) {
1290
1332
  // v46 -> v47: carry the actor's Phoenix id (PHNX-3798). Additive, write-once
1291
1333
  // launch metadata joined from the actor sidecar — never transcript-derived,
@@ -1842,7 +1884,7 @@ const upsertSessionStmt = (db) => db.prepare(`
1842
1884
  INSERT INTO sessions (
1843
1885
  id, short_id, agent, harness, origin, routine_name, routine_run_id,
1844
1886
  version, account, account_key, account_org, mode, timestamp, last_activity,
1845
- project, cwd, git_branch, topic, first_user_message, label, message_count, token_count,
1887
+ project, cwd, git_branch, topic, first_user_message, last_user_message, label, message_count, token_count,
1846
1888
  output_tokens, input_tokens, cache_read_tokens, cache_write_tokens,
1847
1889
  cost_usd, cost_usd_nocache, duration_ms, model, tool_call_count,
1848
1890
  file_path, file_mtime_ms, file_size, scanned_at, is_team_origin,
@@ -1853,7 +1895,7 @@ const upsertSessionStmt = (db) => db.prepare(`
1853
1895
  ) VALUES (
1854
1896
  @id, @short_id, @agent, @harness, @origin, @routine_name, @routine_run_id,
1855
1897
  @version, @account, @account_key, @account_org, @mode, @timestamp, @last_activity,
1856
- @project, @cwd, @git_branch, @topic, @first_user_message, @label, @message_count, @token_count,
1898
+ @project, @cwd, @git_branch, @topic, @first_user_message, @last_user_message, @label, @message_count, @token_count,
1857
1899
  @output_tokens, @input_tokens, @cache_read_tokens, @cache_write_tokens,
1858
1900
  @cost_usd, @cost_usd_nocache, @duration_ms, @model, @tool_call_count,
1859
1901
  @file_path, @file_mtime_ms, @file_size, @scanned_at, @is_team_origin,
@@ -1891,6 +1933,10 @@ const upsertSessionStmt = (db) => db.prepare(`
1891
1933
  git_branch = excluded.git_branch,
1892
1934
  topic = excluded.topic,
1893
1935
  first_user_message = COALESCE(excluded.first_user_message, sessions.first_user_message),
1936
+ -- Unlike the first turn, the LATEST one moves: a new turn must replace the
1937
+ -- stored value, so this is excluded-wins with a COALESCE only to keep a
1938
+ -- known value when a rescan cannot re-derive one.
1939
+ last_user_message = COALESCE(excluded.last_user_message, sessions.last_user_message),
1894
1940
  -- Never let an empty/placeholder incoming label clobber a good stored one.
1895
1941
  -- A real incoming label (non-empty after trim) still wins; a blank one keeps
1896
1942
  -- the label seeded by --name (seedLabelsFromNames) or refined by an agent
@@ -2110,6 +2156,7 @@ function enrichMetaFromEvents(meta, events) {
2110
2156
  return {
2111
2157
  ...meta,
2112
2158
  firstUserMessage: meta.firstUserMessage ?? firstUserMessageFromEvents(events),
2159
+ lastUserMessage: meta.lastUserMessage ?? lastUserMessageFromEvents(events),
2113
2160
  todos: extractTodoProgressFromEvents(events),
2114
2161
  recentDirectoriesTouched: extractRecentDirectoriesTouched(events, meta.cwd),
2115
2162
  ...fanOutCounts(events, meta.agent),
@@ -2201,6 +2248,7 @@ export function upsertSession(meta, content, scan, assistantContent = '') {
2201
2248
  git_branch: meta.gitBranch ?? null,
2202
2249
  topic: meta.topic ?? null,
2203
2250
  first_user_message: meta.firstUserMessage ?? null,
2251
+ last_user_message: meta.lastUserMessage ?? null,
2204
2252
  label: meta.label ?? null,
2205
2253
  message_count: meta.messageCount ?? null,
2206
2254
  token_count: meta.tokenCount ?? null,
@@ -2441,6 +2489,7 @@ export function upsertSessionsBatch(entries) {
2441
2489
  git_branch: meta.gitBranch ?? null,
2442
2490
  topic: meta.topic ?? null,
2443
2491
  first_user_message: meta.firstUserMessage ?? null,
2492
+ last_user_message: meta.lastUserMessage ?? null,
2444
2493
  label: meta.label ?? null,
2445
2494
  message_count: meta.messageCount ?? null,
2446
2495
  token_count: meta.tokenCount ?? null,
@@ -2690,6 +2739,7 @@ function rowToMeta(row) {
2690
2739
  mode: isSessionRunMode(row.mode) ? row.mode : undefined,
2691
2740
  topic: row.topic ?? undefined,
2692
2741
  firstUserMessage: row.first_user_message ?? undefined,
2742
+ lastUserMessage: row.last_user_message ?? undefined,
2693
2743
  label: row.label ?? undefined,
2694
2744
  isTeamOrigin: row.is_team_origin === 1,
2695
2745
  prUrl: row.pr_url ?? undefined,
@@ -3417,6 +3467,68 @@ export function writeSessionSummary(entry) {
3417
3467
  summary_json = excluded.summary_json
3418
3468
  `).run(entry.id, entry.fileMtimeMs, entry.fileSize, SESSION_SUMMARY_EXTRACTOR_VERSION, Date.now(), JSON.stringify(entry.summary));
3419
3469
  }
3470
+ function parseTimelineProjection(json) {
3471
+ try {
3472
+ return JSON.parse(json);
3473
+ }
3474
+ catch {
3475
+ return undefined;
3476
+ }
3477
+ }
3478
+ /**
3479
+ * Read the projection AND the resume state for one session, whatever the
3480
+ * transcript stamp — the daemon pass's read, which needs the state to continue
3481
+ * from and re-stats the file itself.
3482
+ */
3483
+ export function readSessionTimelineEntry(id) {
3484
+ const row = getDB().prepare(`
3485
+ SELECT projection_json AS projectionJson, state_json AS stateJson, computed_at AS computedAt
3486
+ FROM session_timelines
3487
+ WHERE session_id = ? AND extractor_version = ?
3488
+ `).get(id, TIMELINE_EXTRACTOR_VERSION);
3489
+ if (!row)
3490
+ return undefined;
3491
+ const projection = parseTimelineProjection(row.projectionJson);
3492
+ if (!projection)
3493
+ return undefined;
3494
+ try {
3495
+ return { ...projection, state: JSON.parse(row.stateJson), computedAt: row.computedAt };
3496
+ }
3497
+ catch {
3498
+ return undefined;
3499
+ }
3500
+ }
3501
+ /**
3502
+ * Read only the bounded projection for one session — the display merge, on the
3503
+ * read path. Deliberately does NOT parse the resume state: a live row needs the
3504
+ * 8 steps and the request, never the fold's bookkeeping.
3505
+ */
3506
+ export function readSessionTimelineAny(id) {
3507
+ const row = getDB().prepare(`
3508
+ SELECT projection_json AS projectionJson
3509
+ FROM session_timelines
3510
+ WHERE session_id = ? AND extractor_version = ?
3511
+ `).get(id, TIMELINE_EXTRACTOR_VERSION);
3512
+ if (!row)
3513
+ return undefined;
3514
+ return parseTimelineProjection(row.projectionJson);
3515
+ }
3516
+ /** Persist a folded timeline against the exact transcript bytes it was folded to. */
3517
+ export function writeSessionTimeline(entry) {
3518
+ const { state, ...projection } = entry.timeline;
3519
+ getDB().prepare(`
3520
+ INSERT INTO session_timelines
3521
+ (session_id, file_mtime_ms, file_size, extractor_version, computed_at, projection_json, state_json)
3522
+ VALUES (?, ?, ?, ?, ?, ?, ?)
3523
+ ON CONFLICT(session_id) DO UPDATE SET
3524
+ file_mtime_ms = excluded.file_mtime_ms,
3525
+ file_size = excluded.file_size,
3526
+ extractor_version = excluded.extractor_version,
3527
+ computed_at = excluded.computed_at,
3528
+ projection_json = excluded.projection_json,
3529
+ state_json = excluded.state_json
3530
+ `).run(entry.id, entry.fileMtimeMs, entry.fileSize, TIMELINE_EXTRACTOR_VERSION, entry.computedAtMs ?? Date.now(), JSON.stringify(projection), JSON.stringify(state));
3531
+ }
3420
3532
  /**
3421
3533
  * The most-recently-active sessions whose transcript is genuinely local to this
3422
3534
  * box — a real `file_path`, not a peer mirror — for publishing to the fleet
@@ -3444,6 +3556,7 @@ export function queryLocalOriginSessionsForMirror(self, limit) {
3444
3556
  // Ride the daemon-computed summary alongside the digest so a peer renders it
3445
3557
  // without a transcript (PHNX-3939); only a summarized session carries one.
3446
3558
  summary: readSessionSummaryAny(r.id) ?? null,
3559
+ timeline: readSessionTimelineAny(r.id) ?? null,
3447
3560
  }));
3448
3561
  }
3449
3562
  /**
@@ -3525,6 +3638,18 @@ export function upsertMirrorSession(row, source, syncedAt) {
3525
3638
  if (row.summary) {
3526
3639
  writeSessionSummary({ id: row.id, fileMtimeMs: null, fileSize: null, summary: row.summary });
3527
3640
  }
3641
+ // Same for the peer's folded timeline (PHNX-3939): the projection is what a row
3642
+ // renders, and the resume state is deliberately EMPTY — this box never folds a
3643
+ // peer's transcript (it does not have one), so storing a foreign byte offset
3644
+ // would be a resume point into a file that is not here.
3645
+ if (row.timeline) {
3646
+ writeSessionTimeline({
3647
+ id: row.id,
3648
+ fileMtimeMs: null,
3649
+ fileSize: null,
3650
+ timeline: { ...row.timeline, state: emptyTimelineState() },
3651
+ });
3652
+ }
3528
3653
  return true;
3529
3654
  }
3530
3655
  /**
@@ -3542,12 +3667,14 @@ export function pruneMirrorSessions(cutoffMs) {
3542
3667
  const delText = db.prepare(`DELETE FROM session_text WHERE session_id = ?`);
3543
3668
  const delPreview = db.prepare(`DELETE FROM session_preview_cache WHERE session_id = ?`);
3544
3669
  const delSummary = db.prepare(`DELETE FROM session_summaries WHERE session_id = ?`);
3670
+ const delTimeline = db.prepare(`DELETE FROM session_timelines WHERE session_id = ?`);
3545
3671
  const txn = db.transaction(() => {
3546
3672
  for (const { id } of stale) {
3547
3673
  delRow.run(id);
3548
3674
  delText.run(id);
3549
3675
  delPreview.run(id);
3550
3676
  delSummary.run(id);
3677
+ delTimeline.run(id);
3551
3678
  }
3552
3679
  });
3553
3680
  txn();
@@ -26,6 +26,11 @@ export declare const SESSION_MIRROR_MAX_ROWS = 200;
26
26
  export declare const SESSION_MIRROR_SNIPPET_MAX = 280;
27
27
  /** Mirror rows older than this since their last sync are pruned (staleness/size ceiling). */
28
28
  export declare const SESSION_MIRROR_MAX_AGE_MS: number;
29
+ /** Caps for the timeline block on a published mirror row. */
30
+ export declare const SESSION_MIRROR_MAX_STEPS = 8;
31
+ export declare const SESSION_MIRROR_STEP_TEXT_MAX = 160;
32
+ export declare const SESSION_MIRROR_REQUEST_MAX = 2000;
33
+ export declare const SESSION_MIRROR_MAX_FILES = 8;
29
34
  export interface PublishSessionMirrorOptions {
30
35
  userAgentsDir?: string;
31
36
  device?: string;
@@ -30,6 +30,43 @@ export const SESSION_MIRROR_MAX_ROWS = 200;
30
30
  export const SESSION_MIRROR_SNIPPET_MAX = 280;
31
31
  /** Mirror rows older than this since their last sync are pruned (staleness/size ceiling). */
32
32
  export const SESSION_MIRROR_MAX_AGE_MS = 14 * 24 * 60 * 60_000;
33
+ /** Caps for the timeline block on a published mirror row. */
34
+ export const SESSION_MIRROR_MAX_STEPS = 8;
35
+ export const SESSION_MIRROR_STEP_TEXT_MAX = 160;
36
+ export const SESSION_MIRROR_REQUEST_MAX = 2_000;
37
+ export const SESSION_MIRROR_MAX_FILES = 8;
38
+ function cap(value, max) {
39
+ return value.length > max ? value.slice(0, max) : value;
40
+ }
41
+ /** Bound the tidied request: prose capped, chips capped, never re-derived. */
42
+ function boundedRequest(request) {
43
+ return {
44
+ ...request,
45
+ text: cap(request.text, SESSION_MIRROR_REQUEST_MAX),
46
+ headline: cap(request.headline, SESSION_MIRROR_STEP_TEXT_MAX),
47
+ ...(request.command ? { command: cap(request.command, 120) } : {}),
48
+ attachments: request.attachments.slice(0, SESSION_MIRROR_MAX_FILES)
49
+ .map((a) => ({ kind: a.kind, name: cap(a.name, 120) })),
50
+ };
51
+ }
52
+ /** Bound the step list. Counters are kept exact — only text is truncated. */
53
+ function boundedTimeline(timeline) {
54
+ const steps = timeline.steps.slice(-SESSION_MIRROR_MAX_STEPS).map((step) => ({
55
+ ...step,
56
+ text: cap(step.text, SESSION_MIRROR_STEP_TEXT_MAX),
57
+ ...(step.now ? { now: cap(step.now, SESSION_MIRROR_STEP_TEXT_MAX) } : {}),
58
+ ...(step.marks ? { marks: step.marks.slice(0, 4).map((mark) => cap(mark, 40)) } : {}),
59
+ }));
60
+ return { ...timeline, steps, ...(timeline.reason ? { reason: cap(timeline.reason, 200) } : {}) };
61
+ }
62
+ /** Bound the file rows; `total` still reports the real count. */
63
+ function boundedFiles(files) {
64
+ return {
65
+ ...files,
66
+ changes: files.changes.slice(0, SESSION_MIRROR_MAX_FILES)
67
+ .map((change) => ({ ...change, path: cap(change.path, 400) })),
68
+ };
69
+ }
33
70
  function snippet(value, max = SESSION_MIRROR_SNIPPET_MAX) {
34
71
  if (!value)
35
72
  return undefined;
@@ -83,6 +120,12 @@ export async function publishSessionMirrorToSharedStore(options = {}) {
83
120
  }
84
121
  : {}),
85
122
  ...(s.summary?.summaryState ? { summaryState: s.summary.summaryState } : {}),
123
+ // Same bound-on-publish discipline as the summary above: the fleet state file
124
+ // is git-synced, so the request text, the step list, and the file rows are
125
+ // capped here rather than shipped raw (PHNX-3939).
126
+ ...(s.timeline?.request ? { request: boundedRequest(s.timeline.request) } : {}),
127
+ ...(s.timeline?.timeline ? { timeline: boundedTimeline(s.timeline.timeline) } : {}),
128
+ ...(s.timeline?.files ? { files: boundedFiles(s.timeline.files) } : {}),
86
129
  capturedAt,
87
130
  }));
88
131
  try {
@@ -133,8 +176,131 @@ function toUpsert(raw) {
133
176
  ticketId: cap(r.ticketId, 64),
134
177
  prUrl: cap(r.prUrl, 512),
135
178
  summary: toMirrorSummary(r),
179
+ timeline: toMirrorTimeline(r),
136
180
  };
137
181
  }
182
+ /**
183
+ * Validate an untrusted peer's published request/timeline/files into the bounded
184
+ * projection the local cache stores (PHNX-3939). Bounds mirror the publish side,
185
+ * so a hostile or oversized peer cannot bloat this box's cache. Only a row that
186
+ * carries a well-formed timeline is kept; a malformed one is dropped whole
187
+ * rather than partially trusted.
188
+ */
189
+ function toMirrorTimeline(r) {
190
+ const timeline = toMirrorTimelineBlock(r.timeline);
191
+ if (!timeline)
192
+ return undefined;
193
+ return {
194
+ timeline,
195
+ ...(toMirrorRequest(r.request) ? { request: toMirrorRequest(r.request) } : {}),
196
+ ...(toMirrorFiles(r.files) ? { files: toMirrorFiles(r.files) } : {}),
197
+ };
198
+ }
199
+ function isRecord(v) {
200
+ return Boolean(v) && typeof v === 'object' && !Array.isArray(v);
201
+ }
202
+ function count(v) {
203
+ return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? Math.floor(v) : 0;
204
+ }
205
+ /** The verb keys a peer's `mix` may carry — anything else is dropped, not trusted. */
206
+ const MIRROR_VERB_CLASSES = [
207
+ 'read', 'edit', 'run', 'git', 'test', 'browser', 'agent', 'other',
208
+ ];
209
+ /** A peer's per-verb breakdown, whitelisted by key and clamped by value. */
210
+ function toMirrorMix(raw) {
211
+ if (!isRecord(raw))
212
+ return undefined;
213
+ const mix = {};
214
+ for (const verb of MIRROR_VERB_CLASSES) {
215
+ const n = count(raw[verb]);
216
+ if (n > 0)
217
+ mix[verb] = n;
218
+ }
219
+ return Object.keys(mix).length ? mix : undefined;
220
+ }
221
+ function toMirrorTimelineBlock(raw) {
222
+ if (!isRecord(raw))
223
+ return undefined;
224
+ const state = raw.state;
225
+ if (state !== 'ready' && state !== 'partial' && state !== 'unavailable')
226
+ return undefined;
227
+ const steps = (Array.isArray(raw.steps) ? raw.steps : [])
228
+ .filter(isRecord)
229
+ .filter((step) => isString(step.text) && isString(step.at))
230
+ .slice(0, SESSION_MIRROR_MAX_STEPS)
231
+ .map((step) => ({
232
+ text: String(step.text).slice(0, SESSION_MIRROR_STEP_TEXT_MAX),
233
+ at: String(step.at).slice(0, 40),
234
+ ...(isString(step.endedAt) ? { endedAt: step.endedAt.slice(0, 40) } : {}),
235
+ source: (step.source === 'thinking' || step.source === 'derived' || step.source === 'user'
236
+ ? step.source : 'narration'),
237
+ tools: count(step.tools),
238
+ failed: count(step.failed),
239
+ blocked: count(step.blocked),
240
+ ...(isString(step.now) ? { now: step.now.slice(0, SESSION_MIRROR_STEP_TEXT_MAX) } : {}),
241
+ // `mix` and `live` are what the sidebar renders a step WITH ("6 run ·
242
+ // 2 blocked", the amber now-line). Dropping them here made a remote row
243
+ // disagree with the local one about what a live step even is, while `now`
244
+ // survived — so they ride through, whitelisted like every other field.
245
+ ...(step.live === true ? { live: true } : {}),
246
+ ...(() => {
247
+ const mix = toMirrorMix(step.mix);
248
+ return mix ? { mix } : {};
249
+ })(),
250
+ ...(Array.isArray(step.marks)
251
+ ? { marks: step.marks.filter(isString).slice(0, 4).map((mark) => mark.slice(0, 40)) }
252
+ : {}),
253
+ }));
254
+ const earlier = isRecord(raw.earlier) ? raw.earlier : {};
255
+ return {
256
+ steps,
257
+ earlier: { steps: count(earlier.steps), tools: count(earlier.tools), failed: count(earlier.failed) },
258
+ tools: count(raw.tools),
259
+ failed: count(raw.failed),
260
+ blocked: count(raw.blocked),
261
+ spanMs: count(raw.spanMs),
262
+ state,
263
+ ...(isString(raw.reason) ? { reason: raw.reason.slice(0, 200) } : {}),
264
+ };
265
+ }
266
+ function toMirrorRequest(raw) {
267
+ if (!isRecord(raw) || !isString(raw.headline))
268
+ return undefined;
269
+ const kind = raw.kind;
270
+ return {
271
+ text: isString(raw.text) ? raw.text.slice(0, SESSION_MIRROR_REQUEST_MAX) : '',
272
+ headline: raw.headline.slice(0, SESSION_MIRROR_STEP_TEXT_MAX),
273
+ kind: kind === 'image' || kind === 'command' || kind === 'skill' ? kind : 'text',
274
+ ...(isString(raw.command) ? { command: raw.command.slice(0, 120) } : {}),
275
+ attachments: (Array.isArray(raw.attachments) ? raw.attachments : [])
276
+ .filter(isRecord)
277
+ .filter((a) => isString(a.name))
278
+ .slice(0, SESSION_MIRROR_MAX_FILES)
279
+ .map((a) => ({
280
+ kind: a.kind === 'image' || a.kind === 'dir' ? a.kind : 'file',
281
+ name: String(a.name).slice(0, 120),
282
+ })),
283
+ pastedLines: count(raw.pastedLines),
284
+ ...(typeof raw.turns === 'number' ? { turns: count(raw.turns) } : {}),
285
+ };
286
+ }
287
+ function toMirrorFiles(raw) {
288
+ if (!isRecord(raw))
289
+ return undefined;
290
+ const changes = (Array.isArray(raw.changes) ? raw.changes : [])
291
+ .filter(isRecord)
292
+ .filter((change) => isString(change.path))
293
+ .slice(0, SESSION_MIRROR_MAX_FILES)
294
+ .map((change) => ({
295
+ path: String(change.path).slice(0, 400),
296
+ op: (change.op === 'created' || change.op === 'deleted' ? change.op : 'modified'),
297
+ edits: count(change.edits),
298
+ at: isString(change.at) ? change.at.slice(0, 40) : '',
299
+ }));
300
+ if (!changes.length)
301
+ return undefined;
302
+ return { changes, total: count(raw.total) || changes.length, source: raw.source === 'harness' ? 'harness' : 'tools' };
303
+ }
138
304
  /** One published summary state, validated back into the union or undefined. */
139
305
  function toSummaryState(v) {
140
306
  return v === 'pending' || v === 'ready' || v === 'skipped' ? v : undefined;