@phnx-labs/agents-cli 1.20.29 → 1.20.31

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 (58) hide show
  1. package/dist/commands/computer-actions.js +6 -2
  2. package/dist/commands/computer.d.ts +12 -0
  3. package/dist/commands/computer.js +88 -13
  4. package/dist/commands/inspect.js +1 -1
  5. package/dist/commands/models.js +8 -2
  6. package/dist/commands/sessions-picker.js +35 -10
  7. package/dist/commands/sessions.js +164 -44
  8. package/dist/commands/setup.js +8 -0
  9. package/dist/commands/ssh.js +123 -15
  10. package/dist/commands/sync.js +70 -14
  11. package/dist/lib/agents.d.ts +0 -4
  12. package/dist/lib/agents.js +122 -22
  13. package/dist/lib/browser/drivers/ssh.js +4 -35
  14. package/dist/lib/computer-rpc.d.ts +6 -1
  15. package/dist/lib/computer-rpc.js +86 -3
  16. package/dist/lib/devices/registry.d.ts +11 -0
  17. package/dist/lib/devices/registry.js +53 -1
  18. package/dist/lib/devices/sync.d.ts +42 -0
  19. package/dist/lib/devices/sync.js +85 -0
  20. package/dist/lib/exec.js +14 -0
  21. package/dist/lib/models.js +138 -5
  22. package/dist/lib/runner.js +7 -7
  23. package/dist/lib/session/active.d.ts +15 -0
  24. package/dist/lib/session/active.js +108 -19
  25. package/dist/lib/session/cloud.js +2 -0
  26. package/dist/lib/session/db.d.ts +11 -0
  27. package/dist/lib/session/db.js +62 -5
  28. package/dist/lib/session/digest.d.ts +50 -0
  29. package/dist/lib/session/digest.js +170 -0
  30. package/dist/lib/session/discover.d.ts +5 -0
  31. package/dist/lib/session/discover.js +81 -0
  32. package/dist/lib/session/parse.d.ts +15 -0
  33. package/dist/lib/session/parse.js +22 -2
  34. package/dist/lib/session/remote.d.ts +1 -1
  35. package/dist/lib/session/remote.js +8 -3
  36. package/dist/lib/session/render.d.ts +2 -0
  37. package/dist/lib/session/render.js +83 -10
  38. package/dist/lib/session/state.d.ts +82 -0
  39. package/dist/lib/session/state.js +221 -0
  40. package/dist/lib/session/tail.d.ts +18 -0
  41. package/dist/lib/session/tail.js +57 -0
  42. package/dist/lib/session/types.d.ts +9 -0
  43. package/dist/lib/session/width.d.ts +29 -0
  44. package/dist/lib/session/width.js +91 -0
  45. package/dist/lib/shims.d.ts +17 -1
  46. package/dist/lib/shims.js +130 -6
  47. package/dist/lib/ssh-tunnel.d.ts +127 -0
  48. package/dist/lib/ssh-tunnel.js +346 -0
  49. package/dist/lib/state.d.ts +4 -0
  50. package/dist/lib/state.js +19 -1
  51. package/dist/lib/sync-umbrella.d.ts +5 -0
  52. package/dist/lib/sync-umbrella.js +10 -0
  53. package/dist/lib/teams/agents.d.ts +11 -1
  54. package/dist/lib/teams/agents.js +16 -2
  55. package/dist/lib/types.d.ts +1 -0
  56. package/dist/lib/versions.d.ts +19 -0
  57. package/dist/lib/versions.js +84 -24
  58. package/package.json +1 -1
@@ -25,7 +25,10 @@ import { listActiveTasks } from '../cloud/store.js';
25
25
  import { AgentManager } from '../teams/agents.js';
26
26
  import { getTerminalsDir } from '../state.js';
27
27
  import { buildClaudeLabelMap } from './discover.js';
28
+ import { latestSessionFileForCwd } from './db.js';
28
29
  import { extractSessionTopic } from './prompt.js';
30
+ import { readSessionTail } from './tail.js';
31
+ import { inferSessionState } from './state.js';
29
32
  const execFileAsync = promisify(execFile);
30
33
  const HOME = os.homedir();
31
34
  const LIVE_TERMINALS_FILE = path.join(getTerminalsDir(), 'live-terminals.json');
@@ -42,6 +45,7 @@ const AGENT_CLI_NAMES = {
42
45
  gemini: 'gemini',
43
46
  'cursor-agent': 'cursor',
44
47
  opencode: 'opencode',
48
+ droid: 'droid',
45
49
  };
46
50
  function isPidAlive(pid) {
47
51
  if (!pid || pid < 1)
@@ -128,6 +132,66 @@ function classifyActivity(sessionFile) {
128
132
  return 'running';
129
133
  }
130
134
  }
135
+ /**
136
+ * Locate the live transcript for an agent process. Claude files are keyed by
137
+ * cwd (+ optional session uuid); Codex files are date-partitioned, so we resolve
138
+ * the newest indexed Codex session for the cwd instead.
139
+ */
140
+ function findSessionFileForKind(kind, cwd, sessionId) {
141
+ if (!cwd)
142
+ return undefined;
143
+ if (kind === 'claude')
144
+ return findClaudeSessionFile(cwd, sessionId);
145
+ if (kind === 'codex')
146
+ return latestSessionFileForCwd('codex', cwd);
147
+ return undefined;
148
+ }
149
+ /** Recover the session UUID from a transcript filename (Claude `<uuid>.jsonl`, Codex `rollout-…-<uuid>.jsonl`). */
150
+ const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
151
+ function sessionIdFromFile(file) {
152
+ if (!file)
153
+ return undefined;
154
+ return path.basename(file).match(UUID_RE)?.[0];
155
+ }
156
+ /** Infer live state from a session file's tail (Claude/Codex). Undefined when unreadable. */
157
+ function computeLiveState(kind, sessionFile, cwd, pidAlive) {
158
+ if (!sessionFile)
159
+ return undefined;
160
+ const agent = kind === 'codex' ? 'codex' : 'claude';
161
+ const events = readSessionTail(sessionFile, agent);
162
+ if (events.length === 0)
163
+ return undefined;
164
+ let mtimeMs;
165
+ try {
166
+ mtimeMs = fs.statSync(sessionFile).mtimeMs;
167
+ }
168
+ catch { /* vanished between calls */ }
169
+ return inferSessionState(events, { cwd, pidAlive, mtimeMs, activeWindowMs: ACTIVE_MTIME_WINDOW_MS });
170
+ }
171
+ /** Map inferred activity onto the coarse ActiveStatus used by the renderer and counts. */
172
+ function statusFromActivity(activity) {
173
+ return activity === 'working' ? 'running' : activity === 'waiting_input' ? 'input_required' : 'idle';
174
+ }
175
+ /**
176
+ * Fold a computed SessionState onto an active-session row: rich status +
177
+ * preview + PR/worktree/ticket badges. With no state (unreadable/non-Claude/
178
+ * Codex file) it degrades to the mtime-only classification.
179
+ */
180
+ function applyState(base, state, fallbackFile) {
181
+ if (!state)
182
+ return { ...base, status: classifyActivity(fallbackFile) };
183
+ return {
184
+ ...base,
185
+ status: statusFromActivity(state.activity),
186
+ activity: state.activity,
187
+ awaitingReason: state.awaitingReason,
188
+ // Prefer the live preview (latest turn); keep the first-prompt topic as a fallback.
189
+ preview: state.preview ?? base.preview,
190
+ pr: state.pr,
191
+ worktree: state.worktree,
192
+ ticket: state.ticket,
193
+ };
194
+ }
131
195
  /**
132
196
  * Extract the first user message's content from a Claude JSONL file.
133
197
  * Reads only the first ~50 lines for speed, since the user message is
@@ -207,24 +271,22 @@ export async function listTeamsActive() {
207
271
  const running = await mgr.listRunning();
208
272
  return running.map((a) => {
209
273
  const sessionId = a.parentSessionId ?? a.remoteSessionId ?? undefined;
210
- const sessionFile = a.agentType === 'claude' && a.cwd
211
- ? findClaudeSessionFile(a.cwd, sessionId ?? undefined)
212
- : undefined;
274
+ const sessionFile = findSessionFileForKind(a.agentType, a.cwd ?? undefined, sessionId ?? undefined);
213
275
  const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined;
214
- return {
276
+ const state = computeLiveState(a.agentType, sessionFile, a.cwd ?? undefined, a.pid ? isPidAlive(a.pid) : true);
277
+ return applyState({
215
278
  context: 'teams',
216
279
  kind: a.agentType,
217
280
  pid: a.pid ?? undefined,
218
- sessionId,
281
+ sessionId: sessionId ?? sessionIdFromFile(sessionFile),
219
282
  cwd: a.cwd ?? undefined,
220
283
  label: a.name ?? undefined,
221
284
  topic,
222
285
  sessionFile,
223
286
  startedAtMs: a.startedAt.getTime(),
224
- status: classifyActivity(sessionFile),
225
287
  teamName: a.taskName,
226
288
  agentId: a.agentId,
227
- };
289
+ }, state, sessionFile);
228
290
  });
229
291
  }
230
292
  /** Live editor-terminal agents across every IDE window. */
@@ -240,27 +302,25 @@ export async function listTerminalsActive() {
240
302
  // Build label map from Claude's sessions/*.json for /rename support
241
303
  const labelMap = buildClaudeLabelMap();
242
304
  return entries.map((t) => {
243
- const sessionFile = t.kind === 'claude' && t.cwd
244
- ? findClaudeSessionFile(t.cwd, t.sessionId)
245
- : undefined;
305
+ const sessionFile = findSessionFileForKind(t.kind, t.cwd ?? undefined, t.sessionId);
246
306
  // Prefer label from live terminal, fall back to Claude's session label
247
307
  const label = t.label ?? (t.sessionId ? labelMap.get(t.sessionId) : undefined) ?? undefined;
248
308
  // Extract topic from session file (first meaningful user message)
249
309
  const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined;
250
- return {
310
+ const state = computeLiveState(t.kind, sessionFile, t.cwd ?? undefined, isPidAlive(t.pid));
311
+ return applyState({
251
312
  context: 'terminal',
252
313
  kind: t.kind,
253
314
  host: detectHost(t.pid, procByPid),
254
315
  pid: t.pid,
255
- sessionId: t.sessionId,
316
+ sessionId: t.sessionId ?? sessionIdFromFile(sessionFile),
256
317
  cwd: t.cwd ?? undefined,
257
318
  label,
258
319
  topic,
259
320
  sessionFile,
260
321
  startedAtMs: t.startedAtMs,
261
- status: classifyActivity(sessionFile),
262
322
  windowId: t.windowId,
263
- };
323
+ }, state, sessionFile);
264
324
  });
265
325
  }
266
326
  /** Cloud tasks still in a non-terminal state. `tasks.db` may not exist; that's fine. */
@@ -439,20 +499,21 @@ export async function listUnattributedActive(attributed) {
439
499
  for (let i = 0; i < candidates.length; i++) {
440
500
  const { pid, kind } = candidates[i];
441
501
  const cwd = cwds[i];
442
- const sessionFile = kind === 'claude' && cwd ? findClaudeSessionFile(cwd) : undefined;
502
+ const sessionFile = findSessionFileForKind(kind, cwd);
443
503
  const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined;
444
504
  const host = detectHost(pid, procByPid);
445
505
  const context = host && UI_HOSTS.has(host) ? 'terminal' : 'headless';
446
- out.push({
506
+ const state = computeLiveState(kind, sessionFile, cwd, true);
507
+ out.push(applyState({
447
508
  context,
448
509
  kind,
449
510
  host,
450
511
  pid,
451
512
  cwd,
513
+ sessionId: sessionIdFromFile(sessionFile),
452
514
  topic,
453
515
  sessionFile,
454
- status: classifyActivity(sessionFile),
455
- });
516
+ }, state, sessionFile));
456
517
  }
457
518
  return out;
458
519
  }
@@ -475,5 +536,33 @@ export async function getActiveSessions(opts = {}) {
475
536
  if (s.pid)
476
537
  knownPids.add(s.pid);
477
538
  const unattributed = opts.skipHeadless ? [] : await listUnattributedActive(knownPids);
478
- return [...teams, ...terminals, ...cloud, ...unattributed];
539
+ return dedupeBySession([...teams, ...terminals, ...cloud, ...unattributed]);
540
+ }
541
+ /**
542
+ * Collapse rows that resolve to the *same* session — a session with many
543
+ * subagent/fork PIDs (all matched to one transcript file) would otherwise print
544
+ * dozens of identical rows. Keyed by session id (falling back to the file), the
545
+ * first row wins and carries a `pidCount`. Rows with no session identity (cloud,
546
+ * unresolved headless) pass through untouched.
547
+ */
548
+ function dedupeBySession(sessions) {
549
+ const out = [];
550
+ const byKey = new Map();
551
+ for (const s of sessions) {
552
+ const key = s.sessionId || s.sessionFile;
553
+ if (!key) {
554
+ out.push(s);
555
+ continue;
556
+ }
557
+ const existing = byKey.get(key);
558
+ if (existing) {
559
+ existing.pidCount = (existing.pidCount ?? 1) + 1;
560
+ }
561
+ else {
562
+ s.pidCount = 1;
563
+ byKey.set(key, s);
564
+ out.push(s);
565
+ }
566
+ }
567
+ return out;
479
568
  }
@@ -44,6 +44,8 @@ function agentToFormat(agent) {
44
44
  return 'codex';
45
45
  if (agent === 'rush')
46
46
  return 'rush';
47
+ if (agent === 'droid')
48
+ return 'droid';
47
49
  return null;
48
50
  }
49
51
  export function assertContained(candidate, rootDir) {
@@ -30,6 +30,10 @@ export interface SessionRow {
30
30
  file_size: number | null;
31
31
  scanned_at: number | null;
32
32
  is_team_origin: number;
33
+ pr_url: string | null;
34
+ pr_number: number | null;
35
+ worktree_slug: string | null;
36
+ ticket_id: string | null;
33
37
  }
34
38
  /** File stat snapshot used to detect changes between scan runs. */
35
39
  export interface ScanStamp {
@@ -133,6 +137,13 @@ export declare function syncLabels(labelMap: Map<string, string | null>): number
133
137
  * Returns the number of rows updated.
134
138
  */
135
139
  export declare function syncTopics(topicMap: Map<string, string>): number;
140
+ /**
141
+ * Newest indexed session file for an agent working in `cwd`. Lets the live
142
+ * `--active` scanner locate a Codex transcript (whose files are date-partitioned,
143
+ * not cwd-keyed like Claude's) by reusing the index. Returns undefined if the
144
+ * session hasn't been scanned yet — the caller degrades to no live state.
145
+ */
146
+ export declare function latestSessionFileForCwd(agent: SessionAgentId, cwd: string): string | undefined;
136
147
  /** Query sessions from the database, applying filters and ordering by timestamp descending. */
137
148
  export declare function querySessions(options?: QueryOptions): SessionMeta[];
138
149
  /** Count sessions matching the given filter options. */
@@ -13,7 +13,7 @@ import { getSessionsDir, getSessionsDbPath } from '../state.js';
13
13
  const SESSIONS_DIR = getSessionsDir();
14
14
  const DB_PATH = getSessionsDbPath();
15
15
  /** Current schema version; bumped when migrations are added. */
16
- const SCHEMA_VERSION = 6;
16
+ const SCHEMA_VERSION = 7;
17
17
  /**
18
18
  * Canonicalize a file path for use as a scan_ledger key. The same physical
19
19
  * session file is reachable via multiple aliases — `~/.claude/projects/x.jsonl`
@@ -59,7 +59,11 @@ CREATE TABLE IF NOT EXISTS sessions (
59
59
  file_mtime_ms INTEGER,
60
60
  file_size INTEGER,
61
61
  scanned_at INTEGER,
62
- is_team_origin INTEGER DEFAULT 0
62
+ is_team_origin INTEGER DEFAULT 0,
63
+ pr_url TEXT,
64
+ pr_number INTEGER,
65
+ worktree_slug TEXT,
66
+ ticket_id TEXT
63
67
  );
64
68
  CREATE INDEX IF NOT EXISTS idx_sessions_timestamp ON sessions(timestamp DESC);
65
69
  CREATE INDEX IF NOT EXISTS idx_sessions_cwd ON sessions(cwd);
@@ -157,6 +161,21 @@ function migrateSchema(db, fromVersion) {
157
161
  }
158
162
  db.exec(`DELETE FROM scan_ledger;`);
159
163
  }
164
+ if (fromVersion < 7) {
165
+ // v6 → v7: the session-state engine now persists durable signals (PR opened,
166
+ // worktree, tracker ticket) at scan time. Add the columns and force a full
167
+ // rescan so every existing session gets them populated.
168
+ const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
169
+ if (!cols.some(c => c.name === 'pr_url'))
170
+ db.exec(`ALTER TABLE sessions ADD COLUMN pr_url TEXT`);
171
+ if (!cols.some(c => c.name === 'pr_number'))
172
+ db.exec(`ALTER TABLE sessions ADD COLUMN pr_number INTEGER`);
173
+ if (!cols.some(c => c.name === 'worktree_slug'))
174
+ db.exec(`ALTER TABLE sessions ADD COLUMN worktree_slug TEXT`);
175
+ if (!cols.some(c => c.name === 'ticket_id'))
176
+ db.exec(`ALTER TABLE sessions ADD COLUMN ticket_id TEXT`);
177
+ db.exec(`DELETE FROM scan_ledger;`);
178
+ }
160
179
  }
161
180
  /** Open (or return the cached) sessions database, applying migrations as needed. */
162
181
  export function getDB() {
@@ -367,12 +386,14 @@ const upsertSessionStmt = (db) => db.prepare(`
367
386
  id, short_id, agent, version, account, timestamp,
368
387
  project, cwd, git_branch, topic, label, message_count, token_count,
369
388
  cost_usd, duration_ms,
370
- file_path, file_mtime_ms, file_size, scanned_at, is_team_origin
389
+ file_path, file_mtime_ms, file_size, scanned_at, is_team_origin,
390
+ pr_url, pr_number, worktree_slug, ticket_id
371
391
  ) VALUES (
372
392
  @id, @short_id, @agent, @version, @account, @timestamp,
373
393
  @project, @cwd, @git_branch, @topic, @label, @message_count, @token_count,
374
394
  @cost_usd, @duration_ms,
375
- @file_path, @file_mtime_ms, @file_size, @scanned_at, @is_team_origin
395
+ @file_path, @file_mtime_ms, @file_size, @scanned_at, @is_team_origin,
396
+ @pr_url, @pr_number, @worktree_slug, @ticket_id
376
397
  )
377
398
  ON CONFLICT(id) DO UPDATE SET
378
399
  short_id = excluded.short_id,
@@ -393,7 +414,11 @@ const upsertSessionStmt = (db) => db.prepare(`
393
414
  file_mtime_ms = excluded.file_mtime_ms,
394
415
  file_size = excluded.file_size,
395
416
  scanned_at = excluded.scanned_at,
396
- is_team_origin = excluded.is_team_origin
417
+ is_team_origin = excluded.is_team_origin,
418
+ pr_url = excluded.pr_url,
419
+ pr_number = excluded.pr_number,
420
+ worktree_slug = excluded.worktree_slug,
421
+ ticket_id = excluded.ticket_id
397
422
  `);
398
423
  const deleteTextStmt = (db) => db.prepare(`DELETE FROM session_text WHERE session_id = ?`);
399
424
  const insertTextStmt = (db) => db.prepare(`INSERT INTO session_text (session_id, label, topic, project, content) VALUES (?, ?, ?, ?, ?)`);
@@ -436,6 +461,10 @@ export function upsertSession(meta, content, scan) {
436
461
  file_size: scan?.fileSize ?? null,
437
462
  scanned_at: Date.now(),
438
463
  is_team_origin: meta.isTeamOrigin ? 1 : 0,
464
+ pr_url: meta.prUrl ?? null,
465
+ pr_number: meta.prNumber ?? null,
466
+ worktree_slug: meta.worktreeSlug ?? null,
467
+ ticket_id: meta.ticketId ?? null,
439
468
  };
440
469
  const txn = db.transaction(() => {
441
470
  upsert.run(row);
@@ -511,6 +540,10 @@ export function upsertSessionsBatch(entries) {
511
540
  file_size: scan?.fileSize ?? null,
512
541
  scanned_at: now,
513
542
  is_team_origin: meta.isTeamOrigin ? 1 : 0,
543
+ pr_url: meta.prUrl ?? null,
544
+ pr_number: meta.prNumber ?? null,
545
+ worktree_slug: meta.worktreeSlug ?? null,
546
+ ticket_id: meta.ticketId ?? null,
514
547
  });
515
548
  delText.run(meta.id);
516
549
  insText.run(meta.id, meta.label ?? '', meta.topic ?? '', meta.project ?? '', content ?? '');
@@ -621,8 +654,32 @@ function rowToMeta(row) {
621
654
  topic: row.topic ?? undefined,
622
655
  label: row.label ?? undefined,
623
656
  isTeamOrigin: row.is_team_origin === 1,
657
+ prUrl: row.pr_url ?? undefined,
658
+ prNumber: row.pr_number ?? undefined,
659
+ worktreeSlug: row.worktree_slug ?? undefined,
660
+ ticketId: row.ticket_id ?? undefined,
624
661
  };
625
662
  }
663
+ /**
664
+ * Newest indexed session file for an agent working in `cwd`. Lets the live
665
+ * `--active` scanner locate a Codex transcript (whose files are date-partitioned,
666
+ * not cwd-keyed like Claude's) by reusing the index. Returns undefined if the
667
+ * session hasn't been scanned yet — the caller degrades to no live state.
668
+ */
669
+ export function latestSessionFileForCwd(agent, cwd) {
670
+ if (!cwd)
671
+ return undefined;
672
+ let normalized = cwd;
673
+ try {
674
+ normalized = fs.realpathSync(cwd);
675
+ }
676
+ catch { /* use as-is */ }
677
+ const db = getDB();
678
+ const row = db
679
+ .prepare(`SELECT file_path FROM sessions WHERE agent = ? AND cwd = ? ORDER BY timestamp DESC LIMIT 1`)
680
+ .get(agent, normalized);
681
+ return row?.file_path;
682
+ }
626
683
  /** Build a parameterized WHERE clause from query options. */
627
684
  function buildSessionWhere(options) {
628
685
  const where = [];
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Catch-up digest extractors.
3
+ *
4
+ * Pure functions that turn a session's events into the signals a developer needs
5
+ * to reload a task fast when switching between many agents: which files changed
6
+ * and how (created / modified / deleted), which tools dominated the work, and the
7
+ * last test/build result. Consumed by the single-session view and the picker
8
+ * preview. No I/O — fully unit-testable.
9
+ */
10
+ import type { SessionEvent } from './types.js';
11
+ export type FileOp = 'created' | 'modified' | 'deleted';
12
+ export interface FileChange {
13
+ path: string;
14
+ op: FileOp;
15
+ }
16
+ /** Extract file paths deleted by a shell command (rm / git rm / unlink). Conservative. */
17
+ export declare function extractDeletedPaths(command: string): string[];
18
+ /**
19
+ * Classify every touched file as created / modified / deleted from the event
20
+ * stream. Heuristics: a Write to a path never previously Read and not seen
21
+ * before is a *creation*; an Edit (or a Write to a known/read path) is a
22
+ * *modification*; a path in an `rm`/`git rm` command is a *deletion* (and wins
23
+ * over create/modify — a created-then-deleted file nets to gone). Plan files
24
+ * (`.claude/plans/*.md`) are excluded; they're surfaced by detectPlan.
25
+ */
26
+ export declare function classifyFileChanges(events: SessionEvent[]): FileChange[];
27
+ /** Net change summary: counts per op. */
28
+ export declare function changeCounts(changes: FileChange[]): {
29
+ created: number;
30
+ modified: number;
31
+ deleted: number;
32
+ };
33
+ /** Tool histogram sorted highest-first, capped to `top` entries. */
34
+ export declare function toolHistogram(toolCounts: Record<string, number>, top?: number): Array<{
35
+ tool: string;
36
+ count: number;
37
+ }>;
38
+ export interface TestResult {
39
+ runner: string;
40
+ passed?: number;
41
+ failed?: number;
42
+ /** True when we could parse a pass/fail verdict. */
43
+ ok: boolean;
44
+ ts: number;
45
+ }
46
+ /**
47
+ * The most recent test/build run and its verdict. Correlates a runner command
48
+ * (tool_use) with the next tool_result's output. Returns undefined if none ran.
49
+ */
50
+ export declare function detectTestResult(events: SessionEvent[]): TestResult | undefined;
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Catch-up digest extractors.
3
+ *
4
+ * Pure functions that turn a session's events into the signals a developer needs
5
+ * to reload a task fast when switching between many agents: which files changed
6
+ * and how (created / modified / deleted), which tools dominated the work, and the
7
+ * last test/build result. Consumed by the single-session view and the picker
8
+ * preview. No I/O — fully unit-testable.
9
+ */
10
+ // Tool vocab mirrors parse.ts / render.ts so classification matches what those
11
+ // modules already recognize across Claude/Codex/others.
12
+ const READ_TOOLS = new Set(['Read', 'read_file', 'view_file', 'cat_file', 'get_file']);
13
+ const WRITE_TOOLS = new Set(['Write', 'write_file', 'create_file']);
14
+ const EDIT_TOOLS = new Set(['Edit', 'edit_file', 'replace', 'patch', 'MultiEdit', 'apply_patch']);
15
+ /** Extract file paths deleted by a shell command (rm / git rm / unlink). Conservative. */
16
+ export function extractDeletedPaths(command) {
17
+ const out = [];
18
+ // Split on && ; | to inspect each simple command separately.
19
+ for (const seg of command.split(/&&|\|\||;|\|/)) {
20
+ const m = seg.trim().match(/^(?:sudo\s+)?(?:git\s+rm|rm|unlink)\s+(.+)$/);
21
+ if (!m)
22
+ continue;
23
+ for (const tok of m[1].split(/\s+/)) {
24
+ if (tok.startsWith('-'))
25
+ continue; // flags (-r, -f, --force)
26
+ if (/[*?{}]/.test(tok))
27
+ continue; // globs — too imprecise to attribute
28
+ out.push(tok.replace(/^['"]|['"]$/g, '')); // unquote
29
+ }
30
+ }
31
+ return out;
32
+ }
33
+ /**
34
+ * Classify every touched file as created / modified / deleted from the event
35
+ * stream. Heuristics: a Write to a path never previously Read and not seen
36
+ * before is a *creation*; an Edit (or a Write to a known/read path) is a
37
+ * *modification*; a path in an `rm`/`git rm` command is a *deletion* (and wins
38
+ * over create/modify — a created-then-deleted file nets to gone). Plan files
39
+ * (`.claude/plans/*.md`) are excluded; they're surfaced by detectPlan.
40
+ */
41
+ export function classifyFileChanges(events) {
42
+ const readBefore = new Set();
43
+ const created = new Set();
44
+ const modified = new Set();
45
+ const deleted = new Set();
46
+ const seen = new Set();
47
+ for (const e of events) {
48
+ if (e.type !== 'tool_use' || e._local)
49
+ continue;
50
+ if (e.command)
51
+ for (const d of extractDeletedPaths(e.command))
52
+ deleted.add(d);
53
+ const tool = e.tool || '';
54
+ const args = e.args || {};
55
+ const p = e.path || args.file_path || args.path || '';
56
+ if (!p)
57
+ continue;
58
+ if (p.includes('.claude/plans/') && p.endsWith('.md'))
59
+ continue;
60
+ if (READ_TOOLS.has(tool)) {
61
+ readBefore.add(p);
62
+ }
63
+ else if (WRITE_TOOLS.has(tool)) {
64
+ if (!seen.has(p) && !readBefore.has(p))
65
+ created.add(p);
66
+ else
67
+ modified.add(p);
68
+ seen.add(p);
69
+ deleted.delete(p); // a write after a delete recreates the file
70
+ }
71
+ else if (EDIT_TOOLS.has(tool)) {
72
+ modified.add(p);
73
+ seen.add(p);
74
+ deleted.delete(p);
75
+ }
76
+ }
77
+ const out = [];
78
+ for (const p of created)
79
+ if (!deleted.has(p))
80
+ out.push({ path: p, op: 'created' });
81
+ for (const p of modified)
82
+ if (!created.has(p) && !deleted.has(p))
83
+ out.push({ path: p, op: 'modified' });
84
+ for (const p of deleted)
85
+ out.push({ path: p, op: 'deleted' });
86
+ return out;
87
+ }
88
+ /** Net change summary: counts per op. */
89
+ export function changeCounts(changes) {
90
+ const c = { created: 0, modified: 0, deleted: 0 };
91
+ for (const ch of changes)
92
+ c[ch.op]++;
93
+ return c;
94
+ }
95
+ /** Tool histogram sorted highest-first, capped to `top` entries. */
96
+ export function toolHistogram(toolCounts, top = 8) {
97
+ return Object.entries(toolCounts)
98
+ .map(([tool, count]) => ({ tool, count }))
99
+ .sort((a, b) => b.count - a.count || a.tool.localeCompare(b.tool))
100
+ .slice(0, top);
101
+ }
102
+ /** Recognized test/build runners → the label we show. */
103
+ const TEST_RUNNERS = [
104
+ { re: /\b((?:bun|npm|yarn|pnpm)\s+(?:run\s+)?test|vitest|jest)\b/, label: 'tests' },
105
+ { re: /\bpytest\b/, label: 'pytest' },
106
+ { re: /\bgo\s+test\b/, label: 'go test' },
107
+ { re: /\bcargo\s+test\b/, label: 'cargo test' },
108
+ { re: /\b(tsc|tsc\s+--noEmit)\b/, label: 'tsc' },
109
+ ];
110
+ /** Parse pass/fail counts from common runner output. */
111
+ function parseTestOutput(runner, output) {
112
+ // vitest/jest/bun: "N passed", "N failed"; pytest: "N passed, N failed".
113
+ // Take the LAST occurrence — runners print a per-file line first, then the
114
+ // authoritative aggregate ("Tests 4 failed | 294 passed") at the end.
115
+ const lastNum = (re) => {
116
+ let m;
117
+ let val;
118
+ const g = new RegExp(re.source, 'gi');
119
+ while ((m = g.exec(output)) !== null)
120
+ val = +m[1];
121
+ return val;
122
+ };
123
+ const passed = lastNum(/(\d+)\s+pass(?:ed)?/);
124
+ const failed = lastNum(/(\d+)\s+fail(?:ed|ures?)?/);
125
+ if (passed !== undefined || failed !== undefined) {
126
+ return { passed, failed, ok: true };
127
+ }
128
+ // tsc: no news is good news; "error TSxxxx" means failure.
129
+ if (runner === 'tsc') {
130
+ const errs = output.match(/error\s+TS\d+/gi);
131
+ return { failed: errs ? errs.length : 0, ok: true };
132
+ }
133
+ // go test: no pass/fail counts — uses `--- PASS/FAIL:` lines and an ok/FAIL
134
+ // summary. Count the per-test markers; fall back to the summary verdict.
135
+ if (runner === 'go test') {
136
+ const passCount = (output.match(/---\s+PASS/gi) || []).length;
137
+ const failCount = (output.match(/---\s+FAIL/gi) || []).length;
138
+ const sawFail = failCount > 0 || /(^|\s)FAIL($|\s)/.test(output);
139
+ const sawOk = /(^|\s)(ok|PASS)($|\s)/.test(output);
140
+ if (sawFail || sawOk) {
141
+ return { passed: passCount || undefined, failed: sawFail ? failCount || 1 : 0, ok: true };
142
+ }
143
+ }
144
+ return { ok: false };
145
+ }
146
+ /**
147
+ * The most recent test/build run and its verdict. Correlates a runner command
148
+ * (tool_use) with the next tool_result's output. Returns undefined if none ran.
149
+ */
150
+ export function detectTestResult(events) {
151
+ let pending = null;
152
+ let last;
153
+ for (const e of events) {
154
+ const ts = new Date(e.timestamp).getTime() || 0;
155
+ if (e.type === 'tool_use' && e.command) {
156
+ const hit = TEST_RUNNERS.find(r => r.re.test(e.command));
157
+ pending = hit ? { runner: hit.label, ts } : pending;
158
+ }
159
+ else if (e.type === 'tool_result' && pending) {
160
+ const parsed = parseTestOutput(pending.runner, e.output || '');
161
+ last = { runner: pending.runner, ts: pending.ts, ...parsed };
162
+ pending = null;
163
+ }
164
+ else if (e.type === 'error' && pending) {
165
+ last = { runner: pending.runner, ts: pending.ts, ok: true, failed: 1 };
166
+ pending = null;
167
+ }
168
+ }
169
+ return last;
170
+ }
@@ -57,6 +57,11 @@ interface ClaudeSessionScan {
57
57
  entrypoint?: string;
58
58
  /** Concatenated user message text, ready to hand to FTS5. */
59
59
  contentText?: string;
60
+ /** Durable state signals persisted to the index by the session-state engine. */
61
+ prUrl?: string;
62
+ prNumber?: number;
63
+ worktreeSlug?: string;
64
+ ticketId?: string;
60
65
  }
61
66
  /**
62
67
  * Discover sessions. Scans only files whose (mtime, size) have changed since