@phnx-labs/agents-cli 1.22.82 → 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 (48) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/commands/accounts.js +34 -6
  3. package/dist/commands/sessions-trace.d.ts +11 -0
  4. package/dist/commands/sessions-trace.js +71 -0
  5. package/dist/commands/view.js +3 -6
  6. package/dist/lib/account-registry.d.ts +40 -3
  7. package/dist/lib/account-registry.js +89 -18
  8. package/dist/lib/accounts/connect.d.ts +6 -0
  9. package/dist/lib/accounts/connect.js +70 -0
  10. package/dist/lib/claude-statusline.d.ts +13 -5
  11. package/dist/lib/claude-statusline.js +19 -6
  12. package/dist/lib/daemon-ticks.js +23 -2
  13. package/dist/lib/devices/fleet-inventory.js +3 -4
  14. package/dist/lib/devices/harness-inventory.js +4 -5
  15. package/dist/lib/feed/activity-stream.d.ts +60 -0
  16. package/dist/lib/feed/activity-stream.js +271 -0
  17. package/dist/lib/feed/activity.d.ts +7 -0
  18. package/dist/lib/feed/activity.js +103 -13
  19. package/dist/lib/feed/watch.d.ts +9 -3
  20. package/dist/lib/feed/watch.js +72 -36
  21. package/dist/lib/fleet-shared-state.d.ts +6 -0
  22. package/dist/lib/session/active.d.ts +38 -4
  23. package/dist/lib/session/active.js +36 -4
  24. package/dist/lib/session/bash-command.js +10 -0
  25. package/dist/lib/session/db.d.ts +47 -2
  26. package/dist/lib/session/db.js +131 -4
  27. package/dist/lib/session/mirror.d.ts +5 -0
  28. package/dist/lib/session/mirror.js +166 -0
  29. package/dist/lib/session/parse.d.ts +49 -0
  30. package/dist/lib/session/parse.js +324 -30
  31. package/dist/lib/session/prompt.d.ts +93 -2
  32. package/dist/lib/session/prompt.js +266 -15
  33. package/dist/lib/session/remote/peer-stream.d.ts +47 -0
  34. package/dist/lib/session/remote/peer-stream.js +142 -0
  35. package/dist/lib/session/remote/watch.d.ts +1 -1
  36. package/dist/lib/session/remote/watch.js +41 -42
  37. package/dist/lib/session/session-cache.d.ts +9 -0
  38. package/dist/lib/session/session-cache.js +40 -2
  39. package/dist/lib/session/timeline-pass.d.ts +129 -0
  40. package/dist/lib/session/timeline-pass.js +323 -0
  41. package/dist/lib/session/timeline.d.ts +182 -0
  42. package/dist/lib/session/timeline.js +636 -0
  43. package/dist/lib/session/types.d.ts +155 -1
  44. package/dist/lib/summarizer/pass.d.ts +2 -0
  45. package/dist/lib/summarizer/pass.js +14 -1
  46. package/dist/lib/summarizer/summarize.d.ts +7 -0
  47. package/dist/lib/summarizer/summarize.js +3 -0
  48. package/package.json +1 -1
@@ -119,7 +119,11 @@ export function appendActivityEvent(event, root) {
119
119
  };
120
120
  fs.appendFileSync(activityPath(dir, event.sessionId), `${JSON.stringify(record)}\n`, { mode: 0o644 });
121
121
  }
122
- function parseLine(line) {
122
+ /**
123
+ * Parse one activity log line. Tolerant by design: a blank, corrupt, or
124
+ * half-written line is skipped rather than failing the whole read.
125
+ */
126
+ export function parseActivityLine(line) {
123
127
  const trimmed = line.trim();
124
128
  if (!trimmed)
125
129
  return undefined;
@@ -160,6 +164,8 @@ function parseLine(line) {
160
164
  return undefined; // skip corrupt / partial lines (fail-open reader)
161
165
  }
162
166
  }
167
+ /** Per-session byte budget for a bounded tail read of an activity log. */
168
+ export const ACTIVITY_TAIL_BYTES = 256 * 1024;
163
169
  /** Read the tail of a file as UTF-8, bounded to the last `maxBytes`. */
164
170
  function readTail(file, maxBytes) {
165
171
  let fd;
@@ -181,24 +187,105 @@ function readTail(file, maxBytes) {
181
187
  return text;
182
188
  }
183
189
  catch {
184
- return '';
190
+ return undefined;
185
191
  }
186
192
  finally {
187
193
  if (fd !== undefined)
188
194
  fs.closeSync(fd);
189
195
  }
190
196
  }
191
- /** Read all events for one session (bounded tail). */
192
- export function readSessionActivity(sessionId, root, maxBytes = 256 * 1024) {
193
- const dir = root ?? getActivityDir();
194
- const text = readTail(activityPath(dir, sessionId), maxBytes);
195
- const out = [];
197
+ // Process-local tail cache: bounded independently of the number of historical
198
+ // sessions or directories a long-lived consumer visits. Never expose cached
199
+ // objects: callers enrich and mutate returned events (including nested values).
200
+ const ACTIVITY_CACHE_BYTES = 32 * 1024 * 1024;
201
+ const ACTIVITY_CACHE_FILES = 1024;
202
+ const activityTails = new Map();
203
+ let activityTailBytes = 0;
204
+ function forgetActivityTail(key) {
205
+ const old = activityTails.get(key);
206
+ if (old)
207
+ activityTailBytes -= old.weight;
208
+ activityTails.delete(key);
209
+ }
210
+ function activityStamp(file) {
211
+ // ctime catches same-size rewrites even when a producer restores mtime;
212
+ // device/inode catches atomic replacement. Nanoseconds avoid rounding away
213
+ // changes made between consecutive polls on high-resolution filesystems.
214
+ const st = fs.statSync(file, { bigint: true });
215
+ return `${st.dev}:${st.ino}:${st.size}:${st.mtimeNs}:${st.ctimeNs}`;
216
+ }
217
+ function readActivityTail(file, maxBytes, sinceMs = -Infinity) {
218
+ const key = `${path.resolve(file)}\0${maxBytes}`;
219
+ let stamp;
220
+ try {
221
+ stamp = activityStamp(file);
222
+ }
223
+ catch {
224
+ forgetActivityTail(key);
225
+ return { stamp: '', events: [], newestMs: -Infinity, weight: 0 };
226
+ }
227
+ const cached = activityTails.get(key);
228
+ if (cached?.stamp === stamp && (cached.events || cached.newestMs < sinceMs)) {
229
+ activityTails.delete(key);
230
+ activityTails.set(key, cached);
231
+ return cached;
232
+ }
233
+ forgetActivityTail(key);
234
+ const text = readTail(file, maxBytes);
235
+ // Transient I/O failure is not an empty file snapshot; retry next read.
236
+ if (text === undefined)
237
+ return { stamp, events: [], newestMs: -Infinity, weight: 0 };
238
+ const events = [];
239
+ let newestMs = -Infinity;
196
240
  for (const line of text.split('\n')) {
197
- const ev = parseLine(line);
198
- if (ev)
199
- out.push(ev);
241
+ const event = parseActivityLine(line);
242
+ if (!event)
243
+ continue;
244
+ events.push(event);
245
+ try {
246
+ const ms = Date.parse(event.ts);
247
+ if (Number.isFinite(ms))
248
+ newestMs = Math.max(newestMs, ms);
249
+ }
250
+ catch {
251
+ // The tolerant parser historically accepts truthy non-string values.
252
+ // Do not introduce a conversion error before the caller's event filters
253
+ // (or into session reads, which never converted timestamps at all).
254
+ newestMs = Infinity;
255
+ }
200
256
  }
201
- return out;
257
+ // Bound retained source-derived strings, objects, and entry overhead. Large
258
+ // one-off tails still read normally, but cannot displace the entire cache.
259
+ const weight = text.length * 2 + events.length * 1024 + key.length * 2 + 256;
260
+ const result = { stamp, events, newestMs, weight };
261
+ try {
262
+ // A writer racing the read must not pin a mixed snapshot as unchanged.
263
+ if (activityStamp(file) === stamp) {
264
+ if (activityTails.size >= ACTIVITY_CACHE_FILES)
265
+ forgetActivityTail(activityTails.keys().next().value);
266
+ const summaryWeight = key.length * 2 + 256;
267
+ const retained = weight <= ACTIVITY_CACHE_BYTES / 2 ? { ...result } : { ...result, events: undefined, weight: summaryWeight };
268
+ // Retain timestamp summaries even when parsed tails exceed the budget:
269
+ // otherwise a large historical corpus thrashes on every cursor poll.
270
+ for (const [oldKey, old] of activityTails) {
271
+ if (activityTailBytes + retained.weight <= ACTIVITY_CACHE_BYTES)
272
+ break;
273
+ const summary = oldKey.length * 2 + 256;
274
+ activityTailBytes -= old.weight - summary;
275
+ old.events = undefined;
276
+ old.weight = summary;
277
+ }
278
+ activityTails.set(key, retained);
279
+ activityTailBytes += retained.weight;
280
+ }
281
+ }
282
+ catch { /* Deleted during the read: return this snapshot without caching. */ }
283
+ return result;
284
+ }
285
+ /** Read all events for one session (bounded tail). */
286
+ export function readSessionActivity(sessionId, root, maxBytes = ACTIVITY_TAIL_BYTES) {
287
+ const dir = root ?? getActivityDir();
288
+ return structuredClone(readActivityTail(activityPath(dir, sessionId), maxBytes).events ?? []);
202
289
  }
203
290
  /** List session ids that have an activity log. */
204
291
  export function listActivitySessions(root) {
@@ -220,7 +307,10 @@ export function readRecentActivity(opts = {}) {
220
307
  const wanted = opts.events && opts.events.length > 0 ? new Set(opts.events) : null;
221
308
  const all = [];
222
309
  for (const sessionId of listActivitySessions(dir)) {
223
- for (const ev of readSessionActivity(sessionId, dir, opts.maxBytesPerSession)) {
310
+ const tail = readActivityTail(activityPath(dir, sessionId), opts.maxBytesPerSession ?? ACTIVITY_TAIL_BYTES, sinceMs);
311
+ if (tail.newestMs < sinceMs)
312
+ continue;
313
+ for (const ev of tail.events ?? []) {
224
314
  if (wanted && !wanted.has(ev.event))
225
315
  continue;
226
316
  if (opts.tier && ev.tier !== opts.tier)
@@ -231,7 +321,7 @@ export function readRecentActivity(opts = {}) {
231
321
  }
232
322
  }
233
323
  all.sort((a, b) => Date.parse(b.ts) - Date.parse(a.ts));
234
- return typeof opts.limit === 'number' ? all.slice(0, opts.limit) : all;
324
+ return structuredClone(typeof opts.limit === 'number' ? all.slice(0, opts.limit) : all);
235
325
  }
236
326
  /**
237
327
  * Split a chronological event list into individual milestones plus a
@@ -1,4 +1,4 @@
1
- import { type SessionWatchEnvelope, type SessionWatchRow, type SessionWatchScopeStatus } from '../session/watch.js';
1
+ import { type SessionWatchEnvelope, type SessionWatchRow, type SessionWatchScopeStatus, type WatchLocalOptions } from '../session/watch.js';
2
2
  import { type AttentionItem } from './attention.js';
3
3
  import { type ActivityEvent } from './activity.js';
4
4
  export declare const FEED_WATCH_VERSION: 1;
@@ -48,12 +48,18 @@ export declare class FeedWatchState {
48
48
  emit(event: FeedWatchPayload): FeedWatchEnvelope;
49
49
  }
50
50
  export declare function projectSessionEnvelope(event: SessionWatchEnvelope, state: FeedWatchState): Promise<FeedWatchEnvelope[]>;
51
- export declare function watchLocalFeed(options: {
51
+ export interface WatchLocalFeedOptions {
52
52
  scope: string;
53
53
  signal: AbortSignal;
54
54
  emit: (event: FeedWatchEnvelope) => void;
55
+ /** Activity drain cadence. */
55
56
  activityPollMs?: number;
56
- }): Promise<void>;
57
+ /** Attention reconcile cadence when nothing has announced a change. */
58
+ reconcileMs?: number;
59
+ /** Session-watch inputs, forwarded verbatim to {@link watchLocalSessions}. */
60
+ sessions?: Pick<WatchLocalOptions, 'readCache' | 'readPrevious' | 'journalPath' | 'journalPollMs' | 'heartbeatMs'>;
61
+ }
62
+ export declare function watchLocalFeed(options: WatchLocalFeedOptions): Promise<void>;
57
63
  export declare function watchFleetFeed(options: {
58
64
  signal: AbortSignal;
59
65
  emit: (event: FeedWatchEnvelope) => void;
@@ -1,16 +1,17 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { spawn } from 'node:child_process';
3
- import { createInterface } from 'node:readline';
2
+ import * as fs from 'node:fs';
3
+ import * as path from 'node:path';
4
4
  import { loadDevices, isDialableDevice } from '../devices/registry.js';
5
- import { deviceIdentityArgs, sshTargetFor } from '../devices/connect.js';
6
5
  import { machineId, normalizeHost } from '../machine-id.js';
7
- import { SSH_OPTS, controlOpts, shellQuote } from '../ssh-exec.js';
6
+ import { shellQuote } from '../ssh-exec.js';
8
7
  import { buildWindowsAgentsCommand, remoteShellFor } from '../hosts/remote-cmd.js';
8
+ import { getFeedDir } from '../state.js';
9
+ import { streamFromPeer } from '../session/remote/peer-stream.js';
9
10
  import { watchLocalSessions } from '../session/watch.js';
10
11
  import { readBlock, readResolution, blockIdForSession } from './feed.js';
11
12
  import { reconcileAttention } from './attention.js';
12
- import { readRecentActivity } from './activity.js';
13
- import { readPullRequestStatus } from './pr-status.js';
13
+ import { ActivityStream } from './activity-stream.js';
14
+ import { PR_STATUS_TTL_MS, readPullRequestStatus } from './pr-status.js';
14
15
  export const FEED_WATCH_VERSION = 1;
15
16
  export class FeedWatchState {
16
17
  streamId;
@@ -64,8 +65,37 @@ export async function projectSessionEnvelope(event, state) {
64
65
  return [state.emit({ type: 'scope', capturedAt: event.capturedAt, scope: event.scope, status: event.status, ...(event.reason ? { reason: event.reason } : {}) })];
65
66
  return [state.emit({ type: 'heartbeat', capturedAt: event.capturedAt, scope: event.scope })];
66
67
  }
68
+ /**
69
+ * Watch the feed dirs that can change attention without a session event: an
70
+ * `agents feed post --blocked` writes a block, `feed answer` writes a
71
+ * resolution. Both are external processes, so nothing on the session stream
72
+ * announces them — this is what lets the reconcile pass be event-driven instead
73
+ * of a poll over every row twice a second.
74
+ */
75
+ function watchAttentionStores(onChange) {
76
+ const feedDir = getFeedDir();
77
+ const watchers = [];
78
+ for (const dir of [feedDir, path.join(feedDir, 'resolutions')]) {
79
+ try {
80
+ const watcher = fs.watch(dir, () => onChange());
81
+ // A directory that disappears must not take the watcher process down; the
82
+ // PR-status cadence below still reconciles on its own timer.
83
+ watcher.on('error', () => watcher.close());
84
+ watchers.push(watcher);
85
+ }
86
+ catch { /* the dir appears with the first block; the timed pass covers it */ }
87
+ }
88
+ return () => { for (const watcher of watchers)
89
+ watcher.close(); };
90
+ }
67
91
  export async function watchLocalFeed(options) {
68
92
  const state = new FeedWatchState();
93
+ // The stream's opening scan registers every log past its own bytes, so the
94
+ // cursor has to be taken *after* it: a record appended while the scan was
95
+ // walking the directory is unreadable from the byte cursor, and only a
96
+ // timestamp taken after the scan classifies it as history rather than
97
+ // dropping it from a window the caller believes was covered.
98
+ const activity = new ActivityStream();
69
99
  let activityCursor = Date.now();
70
100
  const agents = new Map();
71
101
  const attention = new Map();
@@ -82,20 +112,34 @@ export async function watchLocalFeed(options) {
82
112
  : state.emit({ type: 'attention.remove', scope: options.scope, rowKey }));
83
113
  }
84
114
  };
115
+ // Attention is reconciled when something can actually have changed it — a row
116
+ // moved, a block/resolution file was written — or when the PR-status TTL has
117
+ // expired and the cached verdicts are stale. Re-running it every 500 ms cost
118
+ // two file reads per row per tick and changed nothing.
119
+ const reconcileMs = options.reconcileMs ?? PR_STATUS_TTL_MS;
120
+ let attentionDirty = true;
121
+ let lastReconcileMs = 0;
122
+ const markAttentionDirty = () => { attentionDirty = true; };
123
+ const stopAttentionWatch = watchAttentionStores(markAttentionDirty);
85
124
  const activityTimer = setInterval(() => {
86
125
  pending = pending.then(async () => {
87
- const events = readRecentActivity({ sinceMs: activityCursor + 1 }).reverse();
126
+ const nowMs = Date.now();
127
+ const events = activity.read(activityCursor + 1, nowMs).reverse();
88
128
  for (const event of events) {
89
129
  activityCursor = Math.max(activityCursor, Date.parse(event.ts));
90
130
  options.emit(state.emit({ type: 'activity.append', scope: options.scope, event }));
91
131
  }
132
+ if (!attentionDirty && nowMs - lastReconcileMs < reconcileMs)
133
+ return;
134
+ attentionDirty = false;
135
+ lastReconcileMs = nowMs;
92
136
  await reconcileRows();
93
137
  });
94
138
  }, options.activityPollMs ?? 500);
95
- const stopActivity = () => clearInterval(activityTimer);
139
+ const stopActivity = () => { clearInterval(activityTimer); stopAttentionWatch(); activity.close(); };
96
140
  options.signal.addEventListener('abort', stopActivity, { once: true });
97
141
  try {
98
- await watchLocalSessions({ scope: options.scope, signal: options.signal, emit: (event) => {
142
+ await watchLocalSessions({ ...options.sessions, scope: options.scope, signal: options.signal, emit: (event) => {
99
143
  if (event.type === 'reset') {
100
144
  agents.clear();
101
145
  for (const row of event.rows)
@@ -156,35 +200,27 @@ export async function watchFleetFeed(options) {
156
200
  }
157
201
  const self = machineId();
158
202
  const peers = Object.values(devices).filter((device) => isDialableDevice(device) && normalizeHost(device.name) !== self && ['windows', 'linux', 'macos'].includes(device.platform));
159
- const tasks = peers.map(async (device) => {
203
+ const tasks = peers.map((device) => {
160
204
  const scope = normalizeHost(device.name);
161
- while (!options.signal.aborted) {
162
- let target;
163
- try {
164
- target = sshTargetFor(device);
165
- }
166
- catch (error) {
167
- options.emit(coordinator.emit({ type: 'scope', capturedAt: Date.now(), scope, status: 'unavailable', reason: String(error) }));
168
- break;
169
- }
170
- const child = spawn('ssh', [...SSH_OPTS, ...controlOpts(), ...deviceIdentityArgs(device), target, remoteFeedWatchCommand(device.platform)], { stdio: ['ignore', 'pipe', 'ignore'] });
171
- const stop = () => child.kill('SIGTERM');
172
- options.signal.addEventListener('abort', stop, { once: true });
173
- const reader = createInterface({ input: child.stdout });
174
- reader.on('line', (line) => { try {
175
- const event = JSON.parse(line);
176
- if (event.v === 1)
205
+ return streamFromPeer({
206
+ device,
207
+ signal: options.signal,
208
+ command: remoteFeedWatchCommand(device.platform),
209
+ backoffBaseMs: options.reconnectMs,
210
+ onLine: (line) => {
211
+ try {
212
+ const event = JSON.parse(line);
213
+ if (event.v !== 1)
214
+ return false;
177
215
  forward(event);
178
- }
179
- catch { /* protocol only */ } });
180
- const code = await new Promise((resolve) => { child.once('error', () => resolve(null)); child.once('close', resolve); });
181
- reader.close();
182
- options.signal.removeEventListener('abort', stop);
183
- if (options.signal.aborted)
184
- break;
185
- options.emit(coordinator.emit({ type: 'scope', capturedAt: Date.now(), scope, status: 'unavailable', reason: code == null ? 'ssh failed' : `ssh exited ${code}` }));
186
- await new Promise((resolve) => { const timer = setTimeout(resolve, options.reconnectMs ?? 2_000); options.signal.addEventListener('abort', () => { clearTimeout(timer); resolve(); }, { once: true }); });
187
- }
216
+ return true;
217
+ }
218
+ catch {
219
+ return false; /* protocol only */
220
+ }
221
+ },
222
+ onUnavailable: (reason) => options.emit(coordinator.emit({ type: 'scope', capturedAt: Date.now(), scope, status: 'unavailable', reason })),
223
+ });
188
224
  });
189
225
  await Promise.all([local, ...tasks]);
190
226
  }
@@ -34,6 +34,12 @@ export interface SessionMirrorRow {
34
34
  summaryChecklist?: import('./session/types.js').SessionChecklistItem[];
35
35
  /** Lifecycle of the daemon-computed summary (PHNX-3939). */
36
36
  summaryState?: import('./session/types.js').SummaryState;
37
+ /** Tidied latest user turn, so a peer row shows what the agent was asked (PHNX-3939). */
38
+ request?: import('./session/types.js').SessionRequest;
39
+ /** Bounded narration-anchored steps, so a peer row shows what the agent did. */
40
+ timeline?: import('./session/types.js').SessionTimeline;
41
+ /** Bounded file-change list for the peer row. */
42
+ files?: import('./session/types.js').SessionFiles;
37
43
  capturedAt: number;
38
44
  }
39
45
  export interface FleetSharedDeviceState {
@@ -3,7 +3,7 @@ import { type PidSessionEntry } from './pid-registry.js';
3
3
  import { type HookSessionIndex } from './hook-sessions.js';
4
4
  import { type UserPromptKind } from './prompt.js';
5
5
  import { type SessionState, type SessionActivity, type AwaitingReason, type StructuredQuestion, type TodoProgress, type DetectedPr, type DetectedWorktree, type DetectedTicket } from './state.js';
6
- import { type SessionAgentId, type SessionAttachment, type SessionMeta } from './types.js';
6
+ import { type SessionAgentId, type SessionAttachment, type SessionFiles, type SessionMeta, type SessionRequest, type SessionTimeline } from './types.js';
7
7
  import { type SessionProvenance } from './provenance.js';
8
8
  import { type DeviceRegistry } from '../devices/registry.js';
9
9
  import { type Presence } from './detached.js';
@@ -55,7 +55,7 @@ export declare function attributedSetLostPids(prev: Set<number>, next: Set<numbe
55
55
  export declare function filterCachedUnattributed(sessions: ActiveSession[], attributed: Set<number>, alive: (pid: number, startedAtMs?: number) => boolean): ActiveSession[];
56
56
  type ActiveContext = 'terminal' | 'teams' | 'cloud' | 'headless';
57
57
  /** The SessionMeta fields the live-row backfill reads — the enrichment a running process cannot report. */
58
- export type BackfillMeta = Pick<SessionMeta, 'version' | 'account' | 'timestamp' | 'label' | 'firstUserMessage' | 'ticketId' | 'prUrl' | 'prNumber' | 'origin' | 'routineName' | 'harness' | 'tokenCount' | 'durationMs' | 'subAgentCount'>;
58
+ export type BackfillMeta = Pick<SessionMeta, 'version' | 'account' | 'timestamp' | 'label' | 'firstUserMessage' | 'lastUserMessage' | 'ticketId' | 'prUrl' | 'prNumber' | 'origin' | 'routineName' | 'harness' | 'tokenCount' | 'durationMs' | 'subAgentCount'>;
59
59
  export declare function backfillActiveRowsFromMeta(sessions: ActiveSession[], metaById: Map<string, BackfillMeta>): void;
60
60
  export declare function backfillActiveRowsFromIndex(sessions: ActiveSession[]): void;
61
61
  export declare function isRunningLiveSession(s: ActiveSession): boolean;
@@ -140,6 +140,13 @@ export interface ActiveSession {
140
140
  topic?: string;
141
141
  /** Full, cleaned first genuine user turn, backfilled from the session index. */
142
142
  firstUserMessage?: string;
143
+ /**
144
+ * Full, cleaned LATEST genuine user turn, backfilled from the session index
145
+ * beside {@link firstUserMessage}. This is what {@link deriveSessionRecap}
146
+ * classifies — a `/continue`d or redirected session's real request is the last
147
+ * thing the user said, not the first (PHNX-3939).
148
+ */
149
+ lastUserMessage?: string;
143
150
  /**
144
151
  * The row's shown title — WHAT the session is, best-source-wins (RUSH-3011).
145
152
  * The ladder ({@link deriveSessionRecap}): a `/rename` or harness `label` →
@@ -207,6 +214,22 @@ export interface ActiveSession {
207
214
  spawnedTeam?: string;
208
215
  /** Files/screenshots attached to the session prompt. */
209
216
  attachments?: SessionAttachment[];
217
+ /**
218
+ * The session's operative request: the LATEST genuine user turn, tidied but
219
+ * never rewritten (PHNX-3939) — prose separated from screenshot paths, clip
220
+ * references, `@dir` mentions and pasted terminal echo. Folded by the daemon
221
+ * timeline pass and merged from the `session_timelines` cache; the sidebar's
222
+ * Request card reads it straight off the row.
223
+ */
224
+ request?: SessionRequest;
225
+ /**
226
+ * Narration-anchored steps — what the agent has been doing, in its own words
227
+ * (PHNX-3939). Last 8 in full plus a fold of everything older. Produced by the
228
+ * daemon's timeline pass, never on the request path.
229
+ */
230
+ timeline?: SessionTimeline;
231
+ /** Files the session created, modified or deleted; ≤ 8 rows plus a total. */
232
+ files?: SessionFiles;
210
233
  /** Total tokens and elapsed duration from the indexed transcript. */
211
234
  tokenCount?: number;
212
235
  durationMs?: number;
@@ -906,13 +929,24 @@ export declare function sessionProcessIsLocal(s: Pick<ActiveSession, 'machine' |
906
929
  */
907
930
  export declare function sessionProcessHost(s: Pick<ActiveSession, 'machine' | 'offloadedFrom'>, self: string): string | undefined;
908
931
  export declare function foldHostLink(rows: ActiveSession[]): void;
909
- /** Labels win, then the last assistant line, then the first-prompt topic. */
910
- export declare function deriveSessionRecap(row: Pick<ActiveSession, 'label' | 'topic' | 'tail'>): {
932
+ /**
933
+ * Labels win, then the last assistant line, then the prompt.
934
+ *
935
+ * The prompt rung classifies the RAW latest genuine user turn, not the
936
+ * already-collapsed `topic` (PHNX-3939). Classifying `topic` was how a `/model`
937
+ * echo or a skill body became a session's displayed title: `extractSessionTopic`
938
+ * accepted text `cleanFirstUserMessage` rejected, and the recap then dressed up
939
+ * that scaffolding as the user's words. It also never saw the row's attachments,
940
+ * so an image-only turn could not be recognized. A row that has already been
941
+ * merged with a daemon-folded {@link ActiveSession.request} uses it directly.
942
+ */
943
+ export declare function deriveSessionRecap(row: Pick<ActiveSession, 'label' | 'topic' | 'tail' | 'firstUserMessage' | 'lastUserMessage' | 'attachments' | 'request'>): {
911
944
  title?: string;
912
945
  recapSource?: RecapSource;
913
946
  userPromptClean?: string;
914
947
  userPromptKind?: UserPromptKind;
915
948
  lastAgentLine?: string;
949
+ request?: SessionRequest;
916
950
  };
917
951
  /** Fold the recap ladder onto every row (see {@link deriveSessionRecap}). */
918
952
  export declare function foldRecap(rows: ActiveSession[]): void;
@@ -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