@phnx-labs/agents-cli 1.20.76 → 1.20.77

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 (85) hide show
  1. package/CHANGELOG.md +162 -0
  2. package/dist/bin/agents +0 -0
  3. package/dist/commands/exec.js +58 -16
  4. package/dist/commands/routines.js +271 -13
  5. package/dist/commands/secrets.js +59 -17
  6. package/dist/commands/sessions-browser.js +11 -1
  7. package/dist/commands/sessions-picker.d.ts +14 -1
  8. package/dist/commands/sessions-picker.js +168 -15
  9. package/dist/commands/sessions.d.ts +23 -4
  10. package/dist/commands/sessions.js +128 -38
  11. package/dist/commands/ssh.d.ts +13 -0
  12. package/dist/commands/ssh.js +39 -2
  13. package/dist/index.js +2 -1
  14. package/dist/lib/activity.d.ts +5 -4
  15. package/dist/lib/activity.js +450 -36
  16. package/dist/lib/cloud/session-index.js +2 -2
  17. package/dist/lib/daemon.d.ts +10 -1
  18. package/dist/lib/daemon.js +99 -12
  19. package/dist/lib/devices/fleet.d.ts +16 -1
  20. package/dist/lib/devices/fleet.js +10 -2
  21. package/dist/lib/events.d.ts +1 -1
  22. package/dist/lib/exec.d.ts +16 -0
  23. package/dist/lib/exec.js +27 -4
  24. package/dist/lib/feed.d.ts +7 -1
  25. package/dist/lib/feed.js +96 -6
  26. package/dist/lib/heal.d.ts +7 -4
  27. package/dist/lib/heal.js +10 -22
  28. package/dist/lib/hosts/dispatch.d.ts +9 -0
  29. package/dist/lib/hosts/dispatch.js +24 -4
  30. package/dist/lib/hosts/passthrough.js +7 -2
  31. package/dist/lib/hosts/remote-cmd.d.ts +11 -0
  32. package/dist/lib/hosts/remote-cmd.js +31 -8
  33. package/dist/lib/hosts/remote-session-id.d.ts +45 -0
  34. package/dist/lib/hosts/remote-session-id.js +84 -0
  35. package/dist/lib/hosts/run-target.d.ts +4 -0
  36. package/dist/lib/hosts/run-target.js +5 -1
  37. package/dist/lib/hosts/session-index.js +3 -3
  38. package/dist/lib/menubar/install-menubar.d.ts +9 -0
  39. package/dist/lib/menubar/install-menubar.js +14 -0
  40. package/dist/lib/menubar/notify-desktop.d.ts +44 -0
  41. package/dist/lib/menubar/notify-desktop.js +78 -0
  42. package/dist/lib/overdue.d.ts +4 -5
  43. package/dist/lib/overdue.js +8 -41
  44. package/dist/lib/routine-notify.d.ts +76 -0
  45. package/dist/lib/routine-notify.js +190 -0
  46. package/dist/lib/routines-placement.d.ts +38 -0
  47. package/dist/lib/routines-placement.js +79 -0
  48. package/dist/lib/routines-project.d.ts +97 -0
  49. package/dist/lib/routines-project.js +349 -0
  50. package/dist/lib/routines.d.ts +68 -0
  51. package/dist/lib/routines.js +74 -7
  52. package/dist/lib/runner.d.ts +12 -2
  53. package/dist/lib/runner.js +150 -25
  54. package/dist/lib/sandbox.js +10 -0
  55. package/dist/lib/secrets/account-token.d.ts +20 -0
  56. package/dist/lib/secrets/account-token.js +64 -0
  57. package/dist/lib/secrets/agent.d.ts +9 -4
  58. package/dist/lib/secrets/agent.js +30 -20
  59. package/dist/lib/secrets/bundles.d.ts +20 -6
  60. package/dist/lib/secrets/bundles.js +70 -34
  61. package/dist/lib/secrets/index.d.ts +11 -2
  62. package/dist/lib/secrets/index.js +21 -9
  63. package/dist/lib/secrets/session-store.d.ts +6 -2
  64. package/dist/lib/secrets/session-store.js +65 -15
  65. package/dist/lib/secrets/unlock-hints.d.ts +27 -0
  66. package/dist/lib/secrets/unlock-hints.js +36 -0
  67. package/dist/lib/session/active.d.ts +10 -0
  68. package/dist/lib/session/active.js +67 -4
  69. package/dist/lib/session/db.d.ts +6 -0
  70. package/dist/lib/session/db.js +83 -6
  71. package/dist/lib/session/discover.d.ts +13 -1
  72. package/dist/lib/session/discover.js +91 -3
  73. package/dist/lib/session/parse.js +11 -2
  74. package/dist/lib/session/prompt.d.ts +7 -0
  75. package/dist/lib/session/prompt.js +37 -0
  76. package/dist/lib/session/provenance.d.ts +7 -0
  77. package/dist/lib/session/render.js +18 -16
  78. package/dist/lib/session/state.d.ts +4 -0
  79. package/dist/lib/session/state.js +93 -11
  80. package/dist/lib/session/types.d.ts +25 -1
  81. package/dist/lib/session/types.js +8 -0
  82. package/dist/lib/state.d.ts +7 -3
  83. package/dist/lib/state.js +20 -5
  84. package/dist/lib/types.d.ts +10 -0
  85. package/package.json +1 -1
@@ -40,6 +40,10 @@ export interface SessionRow {
40
40
  worktree_slug: string | null;
41
41
  ticket_id: string | null;
42
42
  plan: string | null;
43
+ todos: string | null;
44
+ recent_directories_touched: string | null;
45
+ linear_project: string | null;
46
+ linear_project_url: string | null;
43
47
  }
44
48
  /** File stat snapshot used to detect changes between scan runs. */
45
49
  export interface ScanStamp {
@@ -215,6 +219,8 @@ export declare function isSessionActivityFresh(row: {
215
219
  timestamp: string;
216
220
  file_mtime_ms: number | null;
217
221
  }, maxAgeMs: number, nowMs: number): boolean;
222
+ /** Persist a lazily resolved Linear project without reparsing the transcript. */
223
+ export declare function cacheLinearProject(sessionId: string, project: string, projectUrl: string): void;
218
224
  /**
219
225
  * Newest indexed session file for an agent working in `cwd`. Lets the live
220
226
  * `--active` scanner locate a Codex transcript (whose files are date-partitioned,
@@ -9,11 +9,13 @@
9
9
  import * as fs from 'fs';
10
10
  import * as path from 'path';
11
11
  import Database from '../sqlite.js';
12
+ import { parseSession } from './parse.js';
13
+ import { extractRecentDirectoriesTouched, extractTodoProgressFromEvents } from './state.js';
12
14
  import { getSessionsDir, getSessionsDbPath } from '../state.js';
13
15
  const SESSIONS_DIR = getSessionsDir();
14
16
  const DB_PATH = getSessionsDbPath();
15
17
  /** Current schema version; bumped when migrations are added. */
16
- const SCHEMA_VERSION = 16;
18
+ const SCHEMA_VERSION = 17;
17
19
  /**
18
20
  * Canonicalize a file path for use as a scan_ledger key. The same physical
19
21
  * session file is reachable via multiple aliases — `~/.claude/projects/x.jsonl`
@@ -69,7 +71,11 @@ CREATE TABLE IF NOT EXISTS sessions (
69
71
  pr_number INTEGER,
70
72
  worktree_slug TEXT,
71
73
  ticket_id TEXT,
72
- plan TEXT
74
+ plan TEXT,
75
+ todos TEXT,
76
+ recent_directories_touched TEXT,
77
+ linear_project TEXT,
78
+ linear_project_url TEXT
73
79
  );
74
80
  CREATE INDEX IF NOT EXISTS idx_sessions_timestamp ON sessions(timestamp DESC);
75
81
  CREATE INDEX IF NOT EXISTS idx_sessions_cwd ON sessions(cwd);
@@ -316,6 +322,18 @@ function migrateSchema(db, fromVersion) {
316
322
  // primary key — so every corrupt row becomes addressable. No rescan needed.
317
323
  db.exec(`UPDATE sessions SET short_id = substr(id, 1, 8) WHERE short_id IS NULL OR short_id = ''`);
318
324
  }
325
+ if (fromVersion < 17) {
326
+ const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
327
+ if (!cols.some(c => c.name === 'todos'))
328
+ db.exec(`ALTER TABLE sessions ADD COLUMN todos TEXT`);
329
+ if (!cols.some(c => c.name === 'recent_directories_touched'))
330
+ db.exec(`ALTER TABLE sessions ADD COLUMN recent_directories_touched TEXT`);
331
+ if (!cols.some(c => c.name === 'linear_project'))
332
+ db.exec(`ALTER TABLE sessions ADD COLUMN linear_project TEXT`);
333
+ if (!cols.some(c => c.name === 'linear_project_url'))
334
+ db.exec(`ALTER TABLE sessions ADD COLUMN linear_project_url TEXT`);
335
+ db.exec(`DELETE FROM scan_ledger; DELETE FROM dir_ledger;`);
336
+ }
319
337
  }
320
338
  /** Open (or return the cached) sessions database, applying migrations as needed. */
321
339
  export function getDB() {
@@ -656,14 +674,16 @@ const upsertSessionStmt = (db) => db.prepare(`
656
674
  project, cwd, git_branch, topic, label, message_count, token_count,
657
675
  output_tokens, cost_usd, duration_ms,
658
676
  file_path, file_mtime_ms, file_size, scanned_at, is_team_origin,
659
- pr_url, pr_number, worktree_slug, ticket_id, plan
677
+ pr_url, pr_number, worktree_slug, ticket_id, plan, todos,
678
+ recent_directories_touched, linear_project, linear_project_url
660
679
  ) VALUES (
661
680
  @id, @short_id, @agent, @origin, @routine_name, @routine_run_id,
662
681
  @version, @account, @timestamp, @last_activity,
663
682
  @project, @cwd, @git_branch, @topic, @label, @message_count, @token_count,
664
683
  @output_tokens, @cost_usd, @duration_ms,
665
684
  @file_path, @file_mtime_ms, @file_size, @scanned_at, @is_team_origin,
666
- @pr_url, @pr_number, @worktree_slug, @ticket_id, @plan
685
+ @pr_url, @pr_number, @worktree_slug, @ticket_id, @plan, @todos,
686
+ @recent_directories_touched, @linear_project, @linear_project_url
667
687
  )
668
688
  ON CONFLICT(id) DO UPDATE SET
669
689
  short_id = excluded.short_id,
@@ -702,8 +722,34 @@ const upsertSessionStmt = (db) => db.prepare(`
702
722
  pr_number = excluded.pr_number,
703
723
  worktree_slug = excluded.worktree_slug,
704
724
  ticket_id = excluded.ticket_id,
705
- plan = excluded.plan
725
+ plan = excluded.plan,
726
+ todos = excluded.todos,
727
+ recent_directories_touched = excluded.recent_directories_touched,
728
+ linear_project = CASE
729
+ WHEN excluded.ticket_id IS NOT sessions.ticket_id THEN excluded.linear_project
730
+ ELSE COALESCE(excluded.linear_project, sessions.linear_project)
731
+ END,
732
+ linear_project_url = CASE
733
+ WHEN excluded.ticket_id IS NOT sessions.ticket_id THEN excluded.linear_project_url
734
+ ELSE COALESCE(excluded.linear_project_url, sessions.linear_project_url)
735
+ END
706
736
  `);
737
+ function enrichCachedSessionMeta(meta) {
738
+ if (!meta.filePath)
739
+ return meta;
740
+ try {
741
+ const events = parseSession(meta.filePath, meta.agent);
742
+ return {
743
+ ...meta,
744
+ todos: extractTodoProgressFromEvents(events),
745
+ recentDirectoriesTouched: extractRecentDirectoriesTouched(events, meta.cwd),
746
+ };
747
+ }
748
+ catch {
749
+ // Synthetic/cloud rows can intentionally name a transcript that is not local.
750
+ return meta;
751
+ }
752
+ }
707
753
  const deleteTextStmt = (db) => db.prepare(`DELETE FROM session_text WHERE session_id = ?`);
708
754
  const insertTextStmt = (db) => db.prepare(`INSERT INTO session_text (session_id, label, topic, project, content) VALUES (?, ?, ?, ?, ?)`);
709
755
  // Read back the label the upsert actually stored (which may be the preserved
@@ -737,6 +783,7 @@ function storedFtsLabel(readLabel, id) {
737
783
  * `content` is the tokenizable user-prompt text; pass '' to leave the row unsearchable.
738
784
  */
739
785
  export function upsertSession(meta, content, scan) {
786
+ meta = enrichCachedSessionMeta(meta);
740
787
  const db = getDB();
741
788
  const { upsert, delText, insText, readLabel } = stmts(db);
742
789
  const row = {
@@ -770,6 +817,10 @@ export function upsertSession(meta, content, scan) {
770
817
  worktree_slug: meta.worktreeSlug ?? null,
771
818
  ticket_id: meta.ticketId ?? null,
772
819
  plan: meta.plan ?? null,
820
+ todos: meta.todos ? JSON.stringify(meta.todos) : null,
821
+ recent_directories_touched: meta.recentDirectoriesTouched ? JSON.stringify(meta.recentDirectoriesTouched) : null,
822
+ linear_project: meta.linearProject ?? null,
823
+ linear_project_url: meta.linearProjectUrl ?? null,
773
824
  };
774
825
  const txn = db.transaction(() => {
775
826
  upsert.run(row);
@@ -812,6 +863,9 @@ export function upsertSessionsBatch(entries) {
812
863
  const byPath = new Map(entries
813
864
  .filter(e => e.scan && e.meta.filePath)
814
865
  .map(e => [canonicalLedgerKey(e.meta.filePath), e]));
866
+ const enrichedEntries = entries.map(entry => entry.meta.agent === 'claude' || entry.meta.agent === 'codex'
867
+ ? entry
868
+ : { ...entry, meta: enrichCachedSessionMeta(entry.meta) });
815
869
  const txn = db.transaction((items) => {
816
870
  // Re-read the ledger now that we hold the write lock. Any file committed
817
871
  // by a concurrent process since our pre-scan is visible here.
@@ -873,6 +927,10 @@ export function upsertSessionsBatch(entries) {
873
927
  worktree_slug: meta.worktreeSlug ?? null,
874
928
  ticket_id: meta.ticketId ?? null,
875
929
  plan: meta.plan ?? null,
930
+ todos: meta.todos ? JSON.stringify(meta.todos) : null,
931
+ recent_directories_touched: meta.recentDirectoriesTouched ? JSON.stringify(meta.recentDirectoriesTouched) : null,
932
+ linear_project: meta.linearProject ?? null,
933
+ linear_project_url: meta.linearProjectUrl ?? null,
876
934
  });
877
935
  delText.run(meta.id);
878
936
  insText.run(meta.id,
@@ -890,7 +948,7 @@ export function upsertSessionsBatch(entries) {
890
948
  }
891
949
  }
892
950
  });
893
- txn(entries);
951
+ txn(enrichedEntries);
894
952
  }
895
953
  /**
896
954
  * Sync labels for a set of sessions. For each id in the map, if the stored
@@ -1053,8 +1111,22 @@ function rowToMeta(row) {
1053
1111
  worktreeSlug: row.worktree_slug ?? undefined,
1054
1112
  ticketId: row.ticket_id ?? undefined,
1055
1113
  plan: row.plan ?? undefined,
1114
+ todos: parseJsonColumn(row.todos),
1115
+ recentDirectoriesTouched: parseJsonColumn(row.recent_directories_touched),
1116
+ linearProject: row.linear_project ?? undefined,
1117
+ linearProjectUrl: row.linear_project_url ?? undefined,
1056
1118
  };
1057
1119
  }
1120
+ function parseJsonColumn(value) {
1121
+ if (!value)
1122
+ return undefined;
1123
+ try {
1124
+ return JSON.parse(value);
1125
+ }
1126
+ catch {
1127
+ return undefined;
1128
+ }
1129
+ }
1058
1130
  /**
1059
1131
  * The recency signal used to sort and label the listing: last-message time when
1060
1132
  * a parser computed it (`meta.lastActivity` from `lastTsMs`), else the file's
@@ -1075,6 +1147,11 @@ export function isSessionActivityFresh(row, maxAgeMs, nowMs) {
1075
1147
  const activityMs = Number.isFinite(parsedActivityMs) ? parsedActivityMs : row.file_mtime_ms ?? undefined;
1076
1148
  return activityMs != null && nowMs - activityMs <= maxAgeMs;
1077
1149
  }
1150
+ /** Persist a lazily resolved Linear project without reparsing the transcript. */
1151
+ export function cacheLinearProject(sessionId, project, projectUrl) {
1152
+ getDB().prepare(`UPDATE sessions SET linear_project = ?, linear_project_url = ? WHERE id = ?`)
1153
+ .run(project, projectUrl, sessionId);
1154
+ }
1078
1155
  /**
1079
1156
  * Newest indexed session file for an agent working in `cwd`. Lets the live
1080
1157
  * `--active` scanner locate a Codex transcript (whose files are date-partitioned,
@@ -6,7 +6,7 @@
6
6
  * the last run are re-parsed. All metadata is upserted into the sessions DB so
7
7
  * subsequent queries are served entirely from the cache.
8
8
  */
9
- import type { SessionAgentId, SessionMeta } from './types.js';
9
+ import type { SessionAgentId, SessionEvent, SessionMeta, TodoProgress } from './types.js';
10
10
  import { type ScanStamp } from './db.js';
11
11
  /** Options controlling which sessions to discover and how to report progress. */
12
12
  export interface DiscoverOptions {
@@ -82,6 +82,8 @@ interface ClaudeSessionScan {
82
82
  spawnedTeam?: string;
83
83
  /** Plan markdown from the last ExitPlanMode tool call (Claude sessions only). */
84
84
  plan?: string;
85
+ todos?: TodoProgress;
86
+ recentDirectoriesTouched?: string[];
85
87
  }
86
88
  /** Lightweight metadata extracted from a Codex JSONL file during incremental scan. */
87
89
  interface CodexSessionScan {
@@ -105,6 +107,8 @@ interface CodexSessionScan {
105
107
  ticketId?: string;
106
108
  createdTickets?: string[];
107
109
  spawnedTeam?: string;
110
+ todos?: TodoProgress;
111
+ recentDirectoriesTouched?: string[];
108
112
  }
109
113
  /**
110
114
  * Discover sessions. Scans only files whose (mtime, size) have changed since
@@ -369,6 +373,8 @@ export interface ClaudeParseState {
369
373
  pendingTicketTools: Set<string>;
370
374
  spawnedTeam?: string;
371
375
  plan?: string;
376
+ checklistEvents: SessionEvent[];
377
+ recentDirectoriesTouched: string[];
372
378
  }
373
379
  /** Zero-value accumulator for a fresh (from-byte-0) Claude parse. */
374
380
  export declare function initClaudeParseState(): ClaudeParseState;
@@ -427,6 +433,8 @@ export interface ClaudeParserState {
427
433
  spawnedTeam?: string;
428
434
  ticketId?: string;
429
435
  contentText?: string;
436
+ checklistEvents: SessionEvent[];
437
+ recentDirectoriesTouched: string[];
430
438
  }
431
439
  /**
432
440
  * Snapshot a live {@link ClaudeParseState} into its serializable form at
@@ -500,6 +508,8 @@ export interface CodexParseState {
500
508
  createdTickets: Set<string>;
501
509
  pendingTicketTools: Set<string>;
502
510
  spawnedTeam?: string;
511
+ checklistEvents: SessionEvent[];
512
+ recentDirectoriesTouched: string[];
503
513
  }
504
514
  /** Zero-value accumulator for a fresh (from-byte-0) Codex parse. */
505
515
  export declare function initCodexParseState(): CodexParseState;
@@ -549,6 +559,8 @@ export interface CodexParserState {
549
559
  spawnedTeam?: string;
550
560
  ticketId?: string;
551
561
  contentText?: string;
562
+ checklistEvents: SessionEvent[];
563
+ recentDirectoriesTouched: string[];
552
564
  }
553
565
  /**
554
566
  * Snapshot a live {@link CodexParseState} into its serializable form at `offset`
@@ -24,12 +24,13 @@ import { SESSION_AGENTS } from './types.js';
24
24
  import { deriveShortId } from './short-id.js';
25
25
  import { extractSessionTopic } from './prompt.js';
26
26
  import { parseAntigravity } from './parse.js';
27
- import { extractPrUrl, detectWorktree, detectTicket, isPrCreateCommand, detectSpawnedTeam, isTicketCreateTool, extractCreatedTicket } from './state.js';
27
+ import { extractPrUrl, detectWorktree, detectTicket, isPrCreateCommand, detectSpawnedTeam, isTicketCreateTool, extractCreatedTicket, extractRecentDirectoriesTouched, extractTodoProgressFromEvents } from './state.js';
28
28
  import { costOfUsage } from '../pricing/index.js';
29
29
  import { machineId } from './sync/config.js';
30
30
  import { mapBounded } from '../concurrency.js';
31
- import { getDB, getScanStampByPath, getScanStampsForPaths, getParserStatesForPaths, getDirLedgerForPaths, recordDirScans, recordScans, syncLabels, seedLabelsFromNames, syncTopics, upsertSessionsBatch, querySessions, countSessions, ftsSearch, tryClaimScan, releaseScan, } from './db.js';
31
+ import { getDB, getScanStampByPath, getScanStampsForPaths, getParserStatesForPaths, getDirLedgerForPaths, recordDirScans, recordScans, syncLabels, seedLabelsFromNames, syncTopics, upsertSessionsBatch, querySessions, countSessions, ftsSearch, tryClaimScan, releaseScan, cacheLinearProject, } from './db.js';
32
32
  import { buildRunNameMap } from './run-names.js';
33
+ import { resolveLinearApiKey } from '../auto-dispatch-linear.js';
33
34
  const HOME = os.homedir();
34
35
  // Versions can live under either repo: the user repo (current canonical
35
36
  // location, ~/.agents/.history/versions/) or the system repo (legacy / npm-shipped,
@@ -99,10 +100,49 @@ export async function discoverSessions(options) {
99
100
  }
100
101
  }
101
102
  const sessions = querySessions(buildQueryOptions(options, agents, { includeLimit: true }));
103
+ await resolveLinearProjects(sessions);
102
104
  for (const s of sessions)
103
105
  s.machine = machineForSessionFile(s.filePath, s.agent);
104
106
  return scopeToManaged(sessions, agents, options);
105
107
  }
108
+ const linearProjectCache = new Map();
109
+ async function resolveLinearProjects(sessions) {
110
+ const apiKey = resolveLinearApiKey();
111
+ if (!apiKey)
112
+ return;
113
+ await Promise.all(sessions.map(async (session) => {
114
+ if (!session.ticketId || session.linearProject)
115
+ return;
116
+ let project = linearProjectCache.get(session.ticketId);
117
+ if (project === undefined) {
118
+ try {
119
+ const response = await fetch('https://api.linear.app/graphql', {
120
+ method: 'POST',
121
+ signal: AbortSignal.timeout(3_000),
122
+ headers: { Authorization: apiKey, 'Content-Type': 'application/json' },
123
+ body: JSON.stringify({
124
+ query: `query($id:String!){ issue(id:$id){ project{ name url } } }`,
125
+ variables: { id: session.ticketId },
126
+ }),
127
+ });
128
+ if (!response.ok)
129
+ throw new Error(String(response.status));
130
+ const body = await response.json();
131
+ const node = body.data?.issue?.project;
132
+ project = node?.name && node?.url ? { name: node.name, url: node.url } : null;
133
+ }
134
+ catch {
135
+ project = null;
136
+ }
137
+ linearProjectCache.set(session.ticketId, project);
138
+ }
139
+ if (!project)
140
+ return;
141
+ session.linearProject = project.name;
142
+ session.linearProjectUrl = project.url;
143
+ cacheLinearProject(session.id, project.name, project.url);
144
+ }));
145
+ }
106
146
  /**
107
147
  * Drop unmanaged rows for agents that HAVE managed versions.
108
148
  *
@@ -968,6 +1008,8 @@ async function readClaudeMeta(filePath, sessionId, scanStamp, priorRow, account,
968
1008
  createdTickets: scan.createdTickets,
969
1009
  spawnedTeam: scan.spawnedTeam,
970
1010
  plan: scan.plan,
1011
+ todos: scan.todos,
1012
+ recentDirectoriesTouched: scan.recentDirectoriesTouched,
971
1013
  };
972
1014
  }
973
1015
  else {
@@ -995,6 +1037,8 @@ async function readClaudeMeta(filePath, sessionId, scanStamp, priorRow, account,
995
1037
  createdTickets: scan.createdTickets,
996
1038
  spawnedTeam: scan.spawnedTeam,
997
1039
  plan: scan.plan,
1040
+ todos: scan.todos,
1041
+ recentDirectoriesTouched: scan.recentDirectoriesTouched,
998
1042
  };
999
1043
  }
1000
1044
  return {
@@ -1288,6 +1332,8 @@ export async function readCodexMeta(filePath, resolveAccount, currentVersion, sc
1288
1332
  ticketId: scan.ticketId,
1289
1333
  createdTickets: scan.createdTickets,
1290
1334
  spawnedTeam: scan.spawnedTeam,
1335
+ todos: scan.todos,
1336
+ recentDirectoriesTouched: scan.recentDirectoriesTouched,
1291
1337
  };
1292
1338
  return {
1293
1339
  meta,
@@ -2370,8 +2416,27 @@ export function initClaudeParseState() {
2370
2416
  pendingTicketTools: new Set(),
2371
2417
  spawnedTeam: undefined,
2372
2418
  plan: undefined,
2419
+ checklistEvents: [],
2420
+ recentDirectoriesTouched: [],
2373
2421
  };
2374
2422
  }
2423
+ const CHECKLIST_TOOLS = new Set(['TodoWrite', 'todo_write', 'update_plan', 'TaskCreate', 'TaskUpdate']);
2424
+ const DIRECTORY_TOOLS = new Set(['Edit', 'Write', 'edit_file', 'write_file', 'create_file', 'edit', 'write', 'Bash', 'exec_command', 'run_shell_command', 'shell', 'Execute']);
2425
+ function foldDerivedToolState(state, event) {
2426
+ if (CHECKLIST_TOOLS.has(event.tool ?? ''))
2427
+ state.checklistEvents.push(event);
2428
+ if (!DIRECTORY_TOOLS.has(event.tool ?? ''))
2429
+ return;
2430
+ const next = extractRecentDirectoriesTouched([event], state.cwd);
2431
+ for (const dir of next ?? []) {
2432
+ const old = state.recentDirectoriesTouched.indexOf(dir);
2433
+ if (old >= 0)
2434
+ state.recentDirectoriesTouched.splice(old, 1);
2435
+ state.recentDirectoriesTouched.push(dir);
2436
+ }
2437
+ if (state.recentDirectoriesTouched.length > 10)
2438
+ state.recentDirectoriesTouched.splice(0, state.recentDirectoriesTouched.length - 10);
2439
+ }
2375
2440
  /**
2376
2441
  * Fold one parsed transcript line into the accumulator. This is the exact loop
2377
2442
  * body {@link scanClaudeSession} used to run inline — extracted verbatim,
@@ -2407,6 +2472,10 @@ export function applyClaudeLine(state, parsed) {
2407
2472
  if (p)
2408
2473
  state.plan = b.input.plan;
2409
2474
  }
2475
+ foldDerivedToolState(state, {
2476
+ type: 'tool_use', agent: 'claude', timestamp: parsed.timestamp || '', tool: b?.name, args: b?.input || {},
2477
+ path: b?.input?.file_path || b?.input?.path, command: b?.input?.command,
2478
+ });
2410
2479
  }
2411
2480
  }
2412
2481
  if (state.pendingTicketTools.size > 0 && parsed.type === 'user' && Array.isArray(parsed.message?.content)) {
@@ -2558,6 +2627,8 @@ export function finalizeClaudeScan(state) {
2558
2627
  createdTickets: state.createdTickets.size > 0 ? [...state.createdTickets] : undefined,
2559
2628
  spawnedTeam: state.spawnedTeam,
2560
2629
  plan: state.plan,
2630
+ todos: extractTodoProgressFromEvents(state.checklistEvents),
2631
+ recentDirectoriesTouched: state.recentDirectoriesTouched.length ? state.recentDirectoriesTouched : undefined,
2561
2632
  };
2562
2633
  }
2563
2634
  /** Stream a Claude JSONL file and extract scan-level metadata (timestamp, cwd, topic, tokens). */
@@ -2632,6 +2703,8 @@ export function serializeClaudeParserState(state, offset) {
2632
2703
  // not be persisted.
2633
2704
  ticketId: ticket?.id,
2634
2705
  contentText: state.userTexts.length > 0 ? state.userTexts.join('\n') : undefined,
2706
+ checklistEvents: state.checklistEvents,
2707
+ recentDirectoriesTouched: state.recentDirectoriesTouched,
2635
2708
  };
2636
2709
  }
2637
2710
  /**
@@ -2684,6 +2757,8 @@ export function hydrateClaudeParseState(prior) {
2684
2757
  pendingTicketTools: new Set(prior.pendingTicketTools),
2685
2758
  spawnedTeam: prior.spawnedTeam,
2686
2759
  plan: prior.plan,
2760
+ checklistEvents: prior.checklistEvents ?? [],
2761
+ recentDirectoriesTouched: prior.recentDirectoriesTouched ?? [],
2687
2762
  };
2688
2763
  }
2689
2764
  /**
@@ -2883,6 +2958,8 @@ export function initCodexParseState() {
2883
2958
  createdTickets: new Set(),
2884
2959
  pendingTicketTools: new Set(),
2885
2960
  spawnedTeam: undefined,
2961
+ checklistEvents: [],
2962
+ recentDirectoriesTouched: [],
2886
2963
  };
2887
2964
  }
2888
2965
  /**
@@ -2898,11 +2975,16 @@ export function applyCodexLine(state, parsed) {
2898
2975
  const p = parsed.payload || {};
2899
2976
  if (p.type === 'function_call') {
2900
2977
  let cmd = '';
2978
+ let args = {};
2901
2979
  try {
2902
- const args = typeof p.arguments === 'string' ? JSON.parse(p.arguments) : (p.arguments || {});
2980
+ args = typeof p.arguments === 'string' ? JSON.parse(p.arguments) : (p.arguments || {});
2903
2981
  cmd = String(args.command || args.cmd || '');
2904
2982
  }
2905
2983
  catch { /* non-JSON args */ }
2984
+ foldDerivedToolState(state, {
2985
+ type: 'tool_use', agent: 'codex', timestamp: parsed.timestamp || '', tool: p.name, args,
2986
+ path: args.file_path || args.path, command: cmd || undefined,
2987
+ });
2906
2988
  if (!state.prUrl && !state.sawPrCreate && isPrCreateCommand(cmd))
2907
2989
  state.sawPrCreate = true;
2908
2990
  if (!state.spawnedTeam) {
@@ -3024,6 +3106,8 @@ export function finalizeCodexScan(state) {
3024
3106
  ticketId: ticket?.id,
3025
3107
  createdTickets: state.createdTickets.size > 0 ? [...state.createdTickets] : undefined,
3026
3108
  spawnedTeam: state.spawnedTeam,
3109
+ todos: extractTodoProgressFromEvents(state.checklistEvents),
3110
+ recentDirectoriesTouched: state.recentDirectoriesTouched.length ? state.recentDirectoriesTouched : undefined,
3027
3111
  };
3028
3112
  }
3029
3113
  /** Stream a Codex JSONL file and extract scan-level metadata (session ID, cwd, topic, tokens). */
@@ -3085,6 +3169,8 @@ export function serializeCodexParserState(state, offset) {
3085
3169
  // be persisted.
3086
3170
  ticketId: ticket?.id,
3087
3171
  contentText: state.userTexts.length > 0 ? state.userTexts.join('\n') : undefined,
3172
+ checklistEvents: state.checklistEvents,
3173
+ recentDirectoriesTouched: state.recentDirectoriesTouched,
3088
3174
  };
3089
3175
  }
3090
3176
  /**
@@ -3118,6 +3204,8 @@ export function hydrateCodexParseState(prior) {
3118
3204
  createdTickets: new Set(prior.createdTickets),
3119
3205
  pendingTicketTools: new Set(prior.pendingTicketTools),
3120
3206
  spawnedTeam: prior.spawnedTeam,
3207
+ checklistEvents: prior.checklistEvents ?? [],
3208
+ recentDirectoriesTouched: prior.recentDirectoriesTouched ?? [],
3121
3209
  };
3122
3210
  }
3123
3211
  /**
@@ -9,6 +9,7 @@ import * as fs from 'fs';
9
9
  import { truncate } from '../format.js';
10
10
  import * as path from 'path';
11
11
  import Database from '../sqlite.js';
12
+ import { isSyntheticUserMessage } from './prompt.js';
12
13
  /**
13
14
  * Largest session file we will load into memory. Above this we throw a clean
14
15
  * error instead of OOMing or hitting V8's ERR_STRING_TOO_LONG. Aligns with
@@ -156,9 +157,17 @@ export function parseSession(filePath, agent) {
156
157
  }
157
158
  // Chokepoint: every string field that originated in an untrusted session
158
159
  // file gets stripped of terminal escapes here, so renderers downstream can
159
- // safely splat values into chalk/console output.
160
- for (const e of events)
160
+ // safely splat values into chalk/console output. Same pass flags
161
+ // harness-injected `role=user` scaffolding (Claude `<bash-input>`/`<bash-stdout>`
162
+ // from `!`-prefix runs, `<system-reminder>`, etc.) as `_synthetic` so turn
163
+ // slicing and `--include user` count only genuine user intent — one place,
164
+ // every harness, instead of per-consumer regex.
165
+ for (const e of events) {
161
166
  sanitizeEvent(e);
167
+ if (e.type === 'message' && e.role === 'user' && isSyntheticUserMessage(e.content)) {
168
+ e._synthetic = true;
169
+ }
170
+ }
162
171
  return events;
163
172
  }
164
173
  /** Infer the agent type from a session file path using known directory conventions. */
@@ -5,6 +5,13 @@
5
5
  * wrapper tags, team-spawn boilerplate) so that only the real user intent
6
6
  * remains. Used by the session picker to show a human-readable topic line.
7
7
  */
8
+ /**
9
+ * True when a `role=user` message is harness-injected scaffolding, not a genuine
10
+ * user turn. Text-based so it is cross-harness by construction (the markers are
11
+ * the same wrappers across Claude/Codex/etc.); harnesses that already drop their
12
+ * own scaffolding at parse time simply never reach here.
13
+ */
14
+ export declare function isSyntheticUserMessage(raw: string | undefined): boolean;
8
15
  export declare const HEADLESS_PLAN_MODE_PREFIX = "You are running in HEADLESS PLAN MODE.";
9
16
  /** Strip framework noise from a raw session prompt, returning only meaningful user text. */
10
17
  export declare function cleanSessionPrompt(raw: string): string;
@@ -26,6 +26,43 @@ const NOISE_LINE_PATTERNS = [
26
26
  ];
27
27
  /** XML tag prefix used by local-command messages. */
28
28
  const LOCAL_COMMAND_PREFIX = '<local-command-caveat>';
29
+ /**
30
+ * Patterns that mark a whole `role=user` message as harness-injected scaffolding
31
+ * rather than a genuine user turn: the `!`-prefix shell echo and its output,
32
+ * slash-command wrappers, injected reminders / task-notifications, the local
33
+ * caveat, the interrupt marker, skill bodies, and hook feedback. This is the
34
+ * single cross-harness list — `isSyntheticUserMessage` is consumed by the parser
35
+ * (`parseSession`) to flag such events `_synthetic`, so `--include user` and the
36
+ * `--first`/`--last` turn split see only real intent, and every consumer (the
37
+ * session picker, `session-recall`, external hooks) gets clean user turns without
38
+ * re-implementing the stripping. Extend this list, not each caller.
39
+ */
40
+ const SYNTHETIC_USER_MESSAGE_PATTERNS = [
41
+ /^\s*<\/?(?:bash-input|bash-stdout|bash-stderr)>/i,
42
+ /^\s*<\/?(?:command-name|command-message|command-args|command-contents)>/i,
43
+ /^\s*<\/?(?:local-command-stdout|local-command-stderr|local-command-caveat)>/i,
44
+ /^\s*<\/?system-reminder>/i,
45
+ /^\s*<\/?task-notification>/i,
46
+ /^\s*<\/?user-prompt-submit-hook>/i,
47
+ /^\s*<\/?persisted-output>/i,
48
+ /^\s*<permissions instructions>/i,
49
+ /^\s*<collaboration_mode>/i,
50
+ /^\s*Caveat: The messages below were generated by the user while running/i,
51
+ /^\s*\[Request interrupted/i,
52
+ /^\s*Base directory for this skill:/i,
53
+ /^\s*[A-Za-z][A-Za-z-]* hook feedback:/,
54
+ ];
55
+ /**
56
+ * True when a `role=user` message is harness-injected scaffolding, not a genuine
57
+ * user turn. Text-based so it is cross-harness by construction (the markers are
58
+ * the same wrappers across Claude/Codex/etc.); harnesses that already drop their
59
+ * own scaffolding at parse time simply never reach here.
60
+ */
61
+ export function isSyntheticUserMessage(raw) {
62
+ if (!raw)
63
+ return false;
64
+ return SYNTHETIC_USER_MESSAGE_PATTERNS.some(pattern => pattern.test(raw));
65
+ }
29
66
  // Prefix prepended to every Claude-in-plan-mode team spawn prompt.
30
67
  // Ends at a blank line before the real user task.
31
68
  export const HEADLESS_PLAN_MODE_PREFIX = 'You are running in HEADLESS PLAN MODE.';
@@ -37,6 +37,13 @@ export interface SessionProvenance {
37
37
  mux?: MuxLocation;
38
38
  /** Whether an existing rail can type back into this session (see module doc). */
39
39
  reply: ReplyRail;
40
+ /** The initiating device, resolved at query time from ssh.clientIp against the
41
+ * device registry (ssh transport only). Answers "who launched this and from
42
+ * where" without scraping ps/who/tailscale. */
43
+ origin?: {
44
+ device: string;
45
+ user?: string;
46
+ };
40
47
  }
41
48
  /** Env vars that carry provenance. Kept small so the macOS `ps` scan stays cheap. */
42
49
  export declare const PROVENANCE_ENV_KEYS: readonly ["SSH_CONNECTION", "SSH_TTY", "TMUX", "TMUX_PANE", "TERM_PROGRAM", "STY", "ITERM_SESSION_ID"];
@@ -13,6 +13,7 @@ import { cleanSessionPrompt, extractSessionTopic } from './prompt.js';
13
13
  import { renderMarkdown } from '../markdown.js';
14
14
  import { redactSecrets } from '../redact.js';
15
15
  import { classifyFileChanges, changeCounts, toolHistogram, detectTestResult } from './digest.js';
16
+ import { extractTodoProgressFromEvents } from './state.js';
16
17
  // ── Path helpers ──────────────────────────────────────────────────────────────
17
18
  /**
18
19
  * Return absPath relative to cwd; fall back to ~/… then absolute.
@@ -528,7 +529,7 @@ export function renderSummary(events, cwd) {
528
529
  // Commands with timestamps
529
530
  const cmdList = [];
530
531
  // Plan items
531
- const todoItems = [];
532
+ const todoItems = extractTodoProgressFromEvents(events)?.items.map(item => item.content) ?? [];
532
533
  let exitPlanContent = null;
533
534
  let planFilePath = null;
534
535
  // Subagent spawns
@@ -569,19 +570,6 @@ export function renderSummary(events, cwd) {
569
570
  recentActivity.push({ kind: 'cmd', label: cmd, ts });
570
571
  }
571
572
  }
572
- // Plan items: TodoWrite items + TaskCreate descriptions (project's task tracker)
573
- if (tool === 'TodoWrite' && Array.isArray(args.todos)) {
574
- for (const item of args.todos) {
575
- const text = item.content || item.text || String(item);
576
- if (text && !todoItems.includes(text))
577
- todoItems.push(text);
578
- }
579
- }
580
- if (tool === 'TaskCreate' && (args.description || args.prompt)) {
581
- const text = String(args.description || args.prompt || '').slice(0, 140);
582
- if (text && !todoItems.includes(text))
583
- todoItems.push(text);
584
- }
585
573
  if (tool === 'ExitPlanMode') {
586
574
  exitPlanContent = args.result || args.plan || args.content || null;
587
575
  }
@@ -796,6 +784,9 @@ export function parseRoleList(raw, flag) {
796
784
  return parts;
797
785
  }
798
786
  function roleOfEvent(e) {
787
+ // Synthetic scaffolding still maps to 'user' here so `--exclude user` drops it
788
+ // like any other user-role event (pre-flag behavior). `--include user`'s
789
+ // "genuine intent only" carve-out lives in applyRoleFilter, not here.
799
790
  if (e.type === 'message' && e.role === 'user')
800
791
  return 'user';
801
792
  if (e.type === 'message' && e.role === 'assistant')
@@ -817,7 +808,14 @@ function applyRoleFilter(events, opts) {
817
808
  const set = new Set(opts.include);
818
809
  return events.filter(e => {
819
810
  const role = roleOfEvent(e);
820
- return role !== null && set.has(role);
811
+ if (role === null)
812
+ return false;
813
+ // `--include user` means genuine user intent, so drop harness-injected
814
+ // `_synthetic` scaffolding (bash-input, system-reminder) even though it
815
+ // carries role=user. `--exclude user` still drops it via roleOfEvent.
816
+ if (role === 'user' && e._synthetic)
817
+ return false;
818
+ return set.has(role);
821
819
  });
822
820
  }
823
821
  if (opts.exclude && opts.exclude.length > 0) {
@@ -845,7 +843,11 @@ function applyTurnSlice(events, opts) {
845
843
  if (!Number.isFinite(n) || n <= 0) {
846
844
  throw new Error(`Turn count must be a positive integer, got ${n}`);
847
845
  }
848
- const isTurnStart = (e) => e.type === 'message' && e.role === 'user';
846
+ // A turn starts at a GENUINE user message harness-injected `_synthetic`
847
+ // scaffolding (`<bash-input>`/`<bash-stdout>`, etc.) does not start a turn,
848
+ // so `--first N` / `--last N` count real asks, not the jump command that
849
+ // opened the session and its shell output.
850
+ const isTurnStart = (e) => e.type === 'message' && e.role === 'user' && !e._synthetic;
849
851
  const turnStartIdx = [];
850
852
  for (let i = 0; i < events.length; i++)
851
853
  if (isTurnStart(events[i]))
@@ -126,6 +126,10 @@ export interface StateContext {
126
126
  * list, so a session with no plan carries no `todos` field.
127
127
  */
128
128
  export declare function extractTodoProgress(args?: Record<string, any>): TodoProgress | undefined;
129
+ /** Fold snapshot checklist tools and Claude TaskCreate/TaskUpdate event logs. */
130
+ export declare function extractTodoProgressFromEvents(events: SessionEvent[]): TodoProgress | undefined;
131
+ /** Derive a recency-ordered, de-duplicated list of directories touched by tools. */
132
+ export declare function extractRecentDirectoriesTouched(events: SessionEvent[], cwd?: string): string[] | undefined;
129
133
  /** Detect a worktree from the session cwd, per the `.agents/worktrees/<slug>/` convention. */
130
134
  export declare function detectWorktree(cwd?: string, branch?: string): DetectedWorktree | undefined;
131
135
  /** Detect a tracker ticket from free text (prompt/topic) then a branch name. */