@phnx-labs/agents-cli 1.20.74 → 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 (231) hide show
  1. package/CHANGELOG.md +1055 -0
  2. package/README.md +36 -10
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/apply.js +21 -3
  5. package/dist/commands/attach.d.ts +10 -0
  6. package/dist/commands/attach.js +41 -0
  7. package/dist/commands/computer.js +3 -3
  8. package/dist/commands/detach-core.d.ts +46 -0
  9. package/dist/commands/detach-core.js +61 -0
  10. package/dist/commands/detach.d.ts +5 -0
  11. package/dist/commands/detach.js +171 -0
  12. package/dist/commands/doctor.js +18 -0
  13. package/dist/commands/exec.js +83 -13
  14. package/dist/commands/feed.d.ts +7 -1
  15. package/dist/commands/feed.js +47 -4
  16. package/dist/commands/go.d.ts +9 -2
  17. package/dist/commands/go.js +16 -6
  18. package/dist/commands/harness.d.ts +14 -0
  19. package/dist/commands/harness.js +145 -0
  20. package/dist/commands/import.js +38 -8
  21. package/dist/commands/inspect.js +5 -2
  22. package/dist/commands/profiles.d.ts +20 -0
  23. package/dist/commands/profiles.js +68 -21
  24. package/dist/commands/repo.js +14 -0
  25. package/dist/commands/routines.js +286 -15
  26. package/dist/commands/secrets.js +102 -37
  27. package/dist/commands/sessions-browser.js +12 -2
  28. package/dist/commands/sessions-export.js +19 -5
  29. package/dist/commands/sessions-migrate.d.ts +29 -0
  30. package/dist/commands/sessions-migrate.js +596 -0
  31. package/dist/commands/sessions-picker.d.ts +14 -1
  32. package/dist/commands/sessions-picker.js +184 -16
  33. package/dist/commands/sessions.d.ts +56 -14
  34. package/dist/commands/sessions.js +322 -62
  35. package/dist/commands/setup-computer.js +2 -2
  36. package/dist/commands/ssh.d.ts +13 -0
  37. package/dist/commands/ssh.js +110 -37
  38. package/dist/commands/status.js +10 -2
  39. package/dist/commands/versions.js +15 -6
  40. package/dist/commands/view.d.ts +5 -0
  41. package/dist/commands/view.js +24 -4
  42. package/dist/commands/watchdog.d.ts +7 -2
  43. package/dist/commands/watchdog.js +73 -57
  44. package/dist/index.js +59 -7
  45. package/dist/lib/activity.d.ts +17 -6
  46. package/dist/lib/activity.js +466 -40
  47. package/dist/lib/actor.d.ts +50 -0
  48. package/dist/lib/actor.js +166 -0
  49. package/dist/lib/agent-spec/provider.js +2 -1
  50. package/dist/lib/agent-spec/resolve.js +19 -5
  51. package/dist/lib/agent-spec/types.d.ts +9 -1
  52. package/dist/lib/agents.d.ts +21 -0
  53. package/dist/lib/agents.js +70 -6
  54. package/dist/lib/cloud/codex.d.ts +2 -0
  55. package/dist/lib/cloud/codex.js +14 -3
  56. package/dist/lib/cloud/session-index.d.ts +32 -0
  57. package/dist/lib/cloud/session-index.js +58 -0
  58. package/dist/lib/cloud/store.d.ts +7 -0
  59. package/dist/lib/cloud/store.js +25 -0
  60. package/dist/lib/config-transfer.js +4 -0
  61. package/dist/lib/daemon.d.ts +10 -1
  62. package/dist/lib/daemon.js +114 -11
  63. package/dist/lib/devices/connect.d.ts +15 -1
  64. package/dist/lib/devices/connect.js +15 -1
  65. package/dist/lib/devices/fleet.d.ts +37 -1
  66. package/dist/lib/devices/fleet.js +36 -2
  67. package/dist/lib/devices/health-report.d.ts +38 -0
  68. package/dist/lib/devices/health-report.js +214 -0
  69. package/dist/lib/devices/health.js +4 -1
  70. package/dist/lib/devices/reachability.d.ts +31 -0
  71. package/dist/lib/devices/reachability.js +40 -0
  72. package/dist/lib/devices/registry.d.ts +33 -0
  73. package/dist/lib/devices/registry.js +37 -0
  74. package/dist/lib/devices/resolve-target.d.ts +15 -27
  75. package/dist/lib/devices/resolve-target.js +63 -102
  76. package/dist/lib/devices/sync.d.ts +18 -0
  77. package/dist/lib/devices/sync.js +23 -1
  78. package/dist/lib/devices/tailscale.d.ts +3 -0
  79. package/dist/lib/devices/tailscale.js +1 -0
  80. package/dist/lib/events.d.ts +1 -1
  81. package/dist/lib/exec.d.ts +82 -0
  82. package/dist/lib/exec.js +218 -6
  83. package/dist/lib/feed-post.d.ts +63 -0
  84. package/dist/lib/feed-post.js +204 -0
  85. package/dist/lib/feed.d.ts +7 -1
  86. package/dist/lib/feed.js +96 -6
  87. package/dist/lib/fleet/apply.d.ts +32 -2
  88. package/dist/lib/fleet/apply.js +97 -10
  89. package/dist/lib/fleet/types.d.ts +11 -0
  90. package/dist/lib/fs-walk.d.ts +13 -0
  91. package/dist/lib/fs-walk.js +16 -7
  92. package/dist/lib/heal.d.ts +7 -4
  93. package/dist/lib/heal.js +10 -22
  94. package/dist/lib/hooks.d.ts +1 -0
  95. package/dist/lib/hooks.js +156 -0
  96. package/dist/lib/hosts/dispatch.d.ts +16 -0
  97. package/dist/lib/hosts/dispatch.js +26 -4
  98. package/dist/lib/hosts/passthrough.js +9 -2
  99. package/dist/lib/hosts/reconnect.d.ts +78 -0
  100. package/dist/lib/hosts/reconnect.js +127 -0
  101. package/dist/lib/hosts/registry.d.ts +75 -14
  102. package/dist/lib/hosts/registry.js +205 -30
  103. package/dist/lib/hosts/remote-cmd.d.ts +11 -0
  104. package/dist/lib/hosts/remote-cmd.js +32 -8
  105. package/dist/lib/hosts/remote-session-id.d.ts +45 -0
  106. package/dist/lib/hosts/remote-session-id.js +84 -0
  107. package/dist/lib/hosts/run-target.d.ts +17 -5
  108. package/dist/lib/hosts/run-target.js +30 -10
  109. package/dist/lib/hosts/session-index.d.ts +18 -1
  110. package/dist/lib/hosts/session-index.js +40 -5
  111. package/dist/lib/hosts/session-marker.d.ts +33 -0
  112. package/dist/lib/hosts/session-marker.js +51 -0
  113. package/dist/lib/hosts/tasks.d.ts +8 -4
  114. package/dist/lib/import.d.ts +2 -0
  115. package/dist/lib/import.js +35 -2
  116. package/dist/lib/menubar/install-menubar.d.ts +29 -0
  117. package/dist/lib/menubar/install-menubar.js +59 -1
  118. package/dist/lib/menubar/notify-desktop.d.ts +44 -0
  119. package/dist/lib/menubar/notify-desktop.js +78 -0
  120. package/dist/lib/migrate.d.ts +16 -0
  121. package/dist/lib/migrate.js +36 -0
  122. package/dist/lib/models.d.ts +27 -0
  123. package/dist/lib/models.js +54 -1
  124. package/dist/lib/overdue.d.ts +6 -2
  125. package/dist/lib/overdue.js +10 -36
  126. package/dist/lib/picker.js +4 -1
  127. package/dist/lib/platform/process.d.ts +17 -0
  128. package/dist/lib/platform/process.js +70 -0
  129. package/dist/lib/profiles-presets.js +9 -7
  130. package/dist/lib/profiles.d.ts +31 -0
  131. package/dist/lib/profiles.js +70 -0
  132. package/dist/lib/pty-server.d.ts +2 -10
  133. package/dist/lib/pty-server.js +4 -38
  134. package/dist/lib/registry.d.ts +1 -1
  135. package/dist/lib/registry.js +48 -8
  136. package/dist/lib/rotate.d.ts +18 -0
  137. package/dist/lib/rotate.js +28 -0
  138. package/dist/lib/routine-notify.d.ts +76 -0
  139. package/dist/lib/routine-notify.js +190 -0
  140. package/dist/lib/routines-placement.d.ts +38 -0
  141. package/dist/lib/routines-placement.js +79 -0
  142. package/dist/lib/routines-project.d.ts +97 -0
  143. package/dist/lib/routines-project.js +349 -0
  144. package/dist/lib/routines.d.ts +94 -0
  145. package/dist/lib/routines.js +93 -9
  146. package/dist/lib/runner.d.ts +12 -2
  147. package/dist/lib/runner.js +242 -20
  148. package/dist/lib/sandbox.js +10 -0
  149. package/dist/lib/secrets/account-token.d.ts +20 -0
  150. package/dist/lib/secrets/account-token.js +64 -0
  151. package/dist/lib/secrets/agent.d.ts +74 -4
  152. package/dist/lib/secrets/agent.js +181 -107
  153. package/dist/lib/secrets/bundles.d.ts +54 -6
  154. package/dist/lib/secrets/bundles.js +230 -34
  155. package/dist/lib/secrets/index.d.ts +26 -3
  156. package/dist/lib/secrets/index.js +42 -12
  157. package/dist/lib/secrets/rc-hygiene.d.ts +55 -0
  158. package/dist/lib/secrets/rc-hygiene.js +154 -0
  159. package/dist/lib/secrets/remote.d.ts +7 -4
  160. package/dist/lib/secrets/remote.js +7 -4
  161. package/dist/lib/secrets/session-store.d.ts +6 -2
  162. package/dist/lib/secrets/session-store.js +65 -15
  163. package/dist/lib/secrets/sync-commands.d.ts +21 -0
  164. package/dist/lib/secrets/sync-commands.js +21 -0
  165. package/dist/lib/secrets/unlock-hints.d.ts +27 -0
  166. package/dist/lib/secrets/unlock-hints.js +36 -0
  167. package/dist/lib/self-update.d.ts +23 -2
  168. package/dist/lib/self-update.js +86 -5
  169. package/dist/lib/session/active.d.ts +124 -7
  170. package/dist/lib/session/active.js +390 -56
  171. package/dist/lib/session/cloud.js +2 -1
  172. package/dist/lib/session/db.d.ts +54 -0
  173. package/dist/lib/session/db.js +320 -19
  174. package/dist/lib/session/detached.d.ts +30 -0
  175. package/dist/lib/session/detached.js +92 -0
  176. package/dist/lib/session/discover.d.ts +478 -3
  177. package/dist/lib/session/discover.js +1740 -472
  178. package/dist/lib/session/fork.js +2 -1
  179. package/dist/lib/session/hook-sessions.d.ts +43 -0
  180. package/dist/lib/session/hook-sessions.js +135 -0
  181. package/dist/lib/session/linear.d.ts +6 -0
  182. package/dist/lib/session/linear.js +55 -0
  183. package/dist/lib/session/migrate-targets.d.ts +65 -0
  184. package/dist/lib/session/migrate-targets.js +94 -0
  185. package/dist/lib/session/migrations.d.ts +37 -0
  186. package/dist/lib/session/migrations.js +60 -0
  187. package/dist/lib/session/parse.d.ts +18 -0
  188. package/dist/lib/session/parse.js +141 -32
  189. package/dist/lib/session/pid-registry.d.ts +34 -2
  190. package/dist/lib/session/pid-registry.js +49 -2
  191. package/dist/lib/session/prompt.d.ts +7 -0
  192. package/dist/lib/session/prompt.js +37 -0
  193. package/dist/lib/session/provenance.d.ts +7 -0
  194. package/dist/lib/session/render.d.ts +7 -2
  195. package/dist/lib/session/render.js +40 -26
  196. package/dist/lib/session/short-id.d.ts +17 -0
  197. package/dist/lib/session/short-id.js +20 -0
  198. package/dist/lib/session/state.d.ts +4 -0
  199. package/dist/lib/session/state.js +99 -13
  200. package/dist/lib/session/types.d.ts +25 -1
  201. package/dist/lib/session/types.js +8 -0
  202. package/dist/lib/shims.d.ts +1 -1
  203. package/dist/lib/shims.js +20 -6
  204. package/dist/lib/sqlite.d.ts +3 -2
  205. package/dist/lib/sqlite.js +27 -4
  206. package/dist/lib/staleness/detectors/commands.js +1 -1
  207. package/dist/lib/staleness/detectors/workflows.js +13 -2
  208. package/dist/lib/staleness/writers/commands.js +7 -7
  209. package/dist/lib/staleness/writers/workflows.d.ts +4 -2
  210. package/dist/lib/startup/command-registry.d.ts +1 -0
  211. package/dist/lib/startup/command-registry.js +3 -0
  212. package/dist/lib/state.d.ts +8 -4
  213. package/dist/lib/state.js +21 -37
  214. package/dist/lib/teams/agents.d.ts +19 -10
  215. package/dist/lib/teams/agents.js +40 -39
  216. package/dist/lib/types.d.ts +47 -2
  217. package/dist/lib/usage.d.ts +33 -1
  218. package/dist/lib/usage.js +172 -12
  219. package/dist/lib/versions.d.ts +9 -2
  220. package/dist/lib/versions.js +37 -8
  221. package/dist/lib/watchdog/log.d.ts +43 -0
  222. package/dist/lib/watchdog/log.js +69 -0
  223. package/dist/lib/watchdog/routine.d.ts +44 -0
  224. package/dist/lib/watchdog/routine.js +69 -0
  225. package/dist/lib/watchdog/runner.d.ts +51 -7
  226. package/dist/lib/watchdog/runner.js +239 -64
  227. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  228. package/dist/lib/watchdog/watchdog.js +31 -16
  229. package/dist/lib/workflows.d.ts +16 -0
  230. package/dist/lib/workflows.js +110 -1
  231. package/package.json +1 -1
@@ -13,6 +13,7 @@ import * as fs from 'fs';
13
13
  import * as path from 'path';
14
14
  import * as os from 'os';
15
15
  import * as yaml from 'yaml';
16
+ import { deriveShortId } from './short-id.js';
16
17
  import { getCacheDir } from '../state.js';
17
18
  const PROXY_BASE = process.env.RUSH_PROXY_BASE ?? 'https://api.prix.dev';
18
19
  const USER_YAML = path.join(os.homedir(), '.rush', 'user.yaml');
@@ -94,7 +95,7 @@ export async function discoverCloudSessions(options) {
94
95
  const filePath = cachePathForExecution(id, agent);
95
96
  out.push({
96
97
  id,
97
- shortId: id.slice(0, 8),
98
+ shortId: deriveShortId(id),
98
99
  agent,
99
100
  timestamp,
100
101
  project,
@@ -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 {
@@ -109,6 +113,29 @@ export declare function getScanStampByPath(filePath: string): ScanStamp | null;
109
113
  * This is the fast path used by the incremental scanner — avoids N+1 queries.
110
114
  */
111
115
  export declare function getScanStampsForPaths(filePaths: string[]): Map<string, ScanStamp>;
116
+ /**
117
+ * A file's persisted resumable-parse continuation, read back from scan_ledger.
118
+ * `parserState` is the serialized {@link ClaudeParserState} JSON blob (offset +
119
+ * accumulator snapshot); `contentText` is the accumulated user doc. Both are
120
+ * written by the Claude scan (B-2) and consumed on the next scan to decide
121
+ * full-vs-incremental and to hydrate the resume.
122
+ */
123
+ export interface ParserStateRow {
124
+ parserState: string | null;
125
+ contentText: string | null;
126
+ fileMtimeMs: number;
127
+ fileSize: number;
128
+ scannedAt: number;
129
+ }
130
+ /**
131
+ * Bulk-load the resumable-parse continuation (parser_state + content_text) plus
132
+ * the stamp for a set of file paths in a single chunked query. Mirrors
133
+ * {@link getScanStampsForPaths}: keys by canonical path and fans results back to
134
+ * every original alias so callers can `.get(filePath)` with the path they passed.
135
+ * The Claude incremental scan uses this to fetch each changed file's prior
136
+ * continuation without an N+1 of {@link getScanStampByPath}.
137
+ */
138
+ export declare function getParserStatesForPaths(filePaths: string[]): Map<string, ParserStateRow>;
112
139
  /**
113
140
  * Record scan stamps for files we've looked at. Covers both files that produced
114
141
  * a session and files we looked at but chose not to index (e.g. malformed).
@@ -117,6 +144,29 @@ export declare function recordScans(entries: Array<{
117
144
  filePath: string;
118
145
  scan: ScanStamp;
119
146
  }>): void;
147
+ /** Snapshot of a leaf transcript directory used to detect create/delete/rename. */
148
+ export interface DirStamp {
149
+ dirMtimeMs: number;
150
+ entryCount: number;
151
+ }
152
+ /**
153
+ * Bulk-load the dir ledger for a set of leaf directories in a single SQL query.
154
+ * Mirrors {@link getScanStampsForPaths}: keys by canonical path (so a dir
155
+ * reachable via a symlinked version home and its realpath collapse to one row)
156
+ * and fans the result back out to every original alias so callers can
157
+ * `.get(dirPath)` with the path they passed in.
158
+ */
159
+ export declare function getDirLedgerForPaths(dirs: string[]): Map<string, DirStamp>;
160
+ /**
161
+ * Upsert dir-scan stamps. Recorded after a full readdir of a leaf transcript
162
+ * dir so the next scan can skip that dir when its (mtime, entry_count) is
163
+ * unchanged. Mirrors {@link recordScans}.
164
+ */
165
+ export declare function recordDirScans(entries: Array<{
166
+ dirPath: string;
167
+ dirMtimeMs: number;
168
+ entryCount: number;
169
+ }>): void;
120
170
  /**
121
171
  * Upsert a session row and replace its FTS5 content in a single transaction.
122
172
  * `content` is the tokenizable user-prompt text; pass '' to leave the row unsearchable.
@@ -127,6 +177,8 @@ export declare function upsertSessionsBatch(entries: Array<{
127
177
  meta: SessionMeta;
128
178
  content: string;
129
179
  scan?: ScanStamp;
180
+ parserState?: string;
181
+ contentText?: string;
130
182
  }>): void;
131
183
  /**
132
184
  * Sync labels for a set of sessions. For each id in the map, if the stored
@@ -167,6 +219,8 @@ export declare function isSessionActivityFresh(row: {
167
219
  timestamp: string;
168
220
  file_mtime_ms: number | null;
169
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;
170
224
  /**
171
225
  * Newest indexed session file for an agent working in `cwd`. Lets the live
172
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 = 13;
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);
@@ -99,6 +105,27 @@ CREATE TABLE IF NOT EXISTS scan_ledger (
99
105
  file_path TEXT PRIMARY KEY,
100
106
  file_mtime_ms INTEGER NOT NULL,
101
107
  file_size INTEGER NOT NULL,
108
+ scanned_at INTEGER NOT NULL,
109
+ -- Resumable-parse cursor + continuation (B-1). parser_state is a JSON
110
+ -- ClaudeParserState blob (offset + accumulator snapshot) so a scan can pick
111
+ -- up where the last one stopped; content_text caches the accumulated user
112
+ -- doc so detectTicket + FTS can rebuild on append without re-reading the file.
113
+ -- Written by B-2; B-1 only defines + round-trips them.
114
+ parser_state TEXT,
115
+ content_text TEXT
116
+ );
117
+
118
+ -- Tracks the mtime + entry-count of every LEAF directory that directly holds
119
+ -- transcripts (a Claude project dir, a Gemini chats dir). A dir's mtime bumps
120
+ -- on create/delete/rename of its entries but NOT on an in-place append, so a
121
+ -- match here means the dir gained/lost/renamed no files: we can skip the
122
+ -- readdir + per-file stat and serve unchanged files from the DB (append-safety
123
+ -- is preserved by re-stat'ing only the "hot set" — see discover.ts). Keyed by
124
+ -- canonicalLedgerKey, same as scan_ledger.
125
+ CREATE TABLE IF NOT EXISTS dir_ledger (
126
+ dir_path TEXT PRIMARY KEY,
127
+ dir_mtime_ms INTEGER NOT NULL,
128
+ entry_count INTEGER NOT NULL,
102
129
  scanned_at INTEGER NOT NULL
103
130
  );
104
131
  `;
@@ -252,6 +279,61 @@ function migrateSchema(db, fromVersion) {
252
279
  db.exec(`UPDATE sessions SET origin = 'cli' WHERE origin IS NULL OR origin = ''`);
253
280
  db.exec(`DELETE FROM scan_ledger;`);
254
281
  }
282
+ if (fromVersion < 14) {
283
+ // v13 → v14: the discovery scan now short-circuits the readdir + per-file
284
+ // stat of leaf transcript dirs whose (mtime, entry_count) is unchanged,
285
+ // caching that snapshot in the new `dir_ledger` table. Create it, and clear
286
+ // scan_ledger so the first post-upgrade scan does a clean full walk — that
287
+ // walk seeds BOTH ledgers correctly, so a cold/empty dir_ledger degrades to
288
+ // today's full behavior.
289
+ db.exec(`
290
+ CREATE TABLE IF NOT EXISTS dir_ledger (
291
+ dir_path TEXT PRIMARY KEY,
292
+ dir_mtime_ms INTEGER NOT NULL,
293
+ entry_count INTEGER NOT NULL,
294
+ scanned_at INTEGER NOT NULL
295
+ );
296
+ DELETE FROM scan_ledger;
297
+ `);
298
+ }
299
+ if (fromVersion < 15) {
300
+ // v14 → v15: the Claude scan becomes resumable (B-1). scan_ledger gains a
301
+ // `parser_state` continuation blob (offset + accumulator snapshot) and a
302
+ // `content_text` cache of the accumulated user doc. Add both columns, then
303
+ // clear scan_ledger so the first post-upgrade scan does a clean full walk
304
+ // that reseeds the cursor from byte 0.
305
+ const cols = db.prepare(`PRAGMA table_info(scan_ledger)`).all();
306
+ if (!cols.some(c => c.name === 'parser_state'))
307
+ db.exec(`ALTER TABLE scan_ledger ADD COLUMN parser_state TEXT`);
308
+ if (!cols.some(c => c.name === 'content_text'))
309
+ db.exec(`ALTER TABLE scan_ledger ADD COLUMN content_text TEXT`);
310
+ db.exec(`DELETE FROM scan_ledger;`);
311
+ }
312
+ if (fromVersion < 16) {
313
+ // v15 → v16: repair rows poisoned by the empty-shortId bug (now fixed at the
314
+ // source in deriveShortId). A session id that was only a known prefix — a bare
315
+ // `session_` dir, an id of exactly `api-` or `ses_` — derived to '' via
316
+ // `id.replace(prefix, '').slice(0, 8)`, passed the `short_id TEXT NOT NULL`
317
+ // column (empty string is not NULL), yet matched nothing in the
318
+ // `short_id LIKE ?` picker lookups. The parser no longer produces '', but
319
+ // existing rows don't self-heal: an empty short_id is not re-parsed unless its
320
+ // file changes, and an orphaned row (file gone) never re-parses at all. Repair
321
+ // in place — `substr(id, 1, 8)` is non-empty because `id` is the non-empty
322
+ // primary key — so every corrupt row becomes addressable. No rescan needed.
323
+ db.exec(`UPDATE sessions SET short_id = substr(id, 1, 8) WHERE short_id IS NULL OR short_id = ''`);
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
+ }
255
337
  }
256
338
  /** Open (or return the cached) sessions database, applying migrations as needed. */
257
339
  export function getDB() {
@@ -310,6 +392,11 @@ export function closeDB() {
310
392
  if (dbInstance) {
311
393
  dbInstance.close();
312
394
  dbInstance = null;
395
+ // Closing the connection finalizes every prepared statement it owns. Drop
396
+ // the cached upsert/FTS statements too, so the next getDB() rebuilds them
397
+ // against the fresh connection instead of re-running a finalized statement
398
+ // (which throws "statement has been finalized" on the first upsert).
399
+ cachedStmts = {};
313
400
  }
314
401
  }
315
402
  // ---------------------------------------------------------------------------
@@ -440,6 +527,55 @@ export function getScanStampsForPaths(filePaths) {
440
527
  }
441
528
  return result;
442
529
  }
530
+ /**
531
+ * Bulk-load the resumable-parse continuation (parser_state + content_text) plus
532
+ * the stamp for a set of file paths in a single chunked query. Mirrors
533
+ * {@link getScanStampsForPaths}: keys by canonical path and fans results back to
534
+ * every original alias so callers can `.get(filePath)` with the path they passed.
535
+ * The Claude incremental scan uses this to fetch each changed file's prior
536
+ * continuation without an N+1 of {@link getScanStampByPath}.
537
+ */
538
+ export function getParserStatesForPaths(filePaths) {
539
+ const result = new Map();
540
+ if (filePaths.length === 0)
541
+ return result;
542
+ const db = getDB();
543
+ const canonicalToOriginals = new Map();
544
+ for (const fp of filePaths) {
545
+ const canonical = canonicalLedgerKey(fp);
546
+ const aliases = canonicalToOriginals.get(canonical);
547
+ if (aliases)
548
+ aliases.push(fp);
549
+ else
550
+ canonicalToOriginals.set(canonical, [fp]);
551
+ }
552
+ const canonicalKeys = [...canonicalToOriginals.keys()];
553
+ const CHUNK = 500;
554
+ for (let i = 0; i < canonicalKeys.length; i += CHUNK) {
555
+ const chunk = canonicalKeys.slice(i, i + CHUNK);
556
+ const placeholders = chunk.map(() => '?').join(',');
557
+ const rows = db
558
+ .prepare(`
559
+ SELECT file_path, file_mtime_ms, file_size, scanned_at, parser_state, content_text
560
+ FROM scan_ledger
561
+ WHERE file_path IN (${placeholders})
562
+ `)
563
+ .all(...chunk);
564
+ for (const row of rows) {
565
+ const state = {
566
+ parserState: row.parser_state,
567
+ contentText: row.content_text,
568
+ fileMtimeMs: row.file_mtime_ms,
569
+ fileSize: row.file_size,
570
+ scannedAt: row.scanned_at,
571
+ };
572
+ for (const original of canonicalToOriginals.get(row.file_path) || []) {
573
+ result.set(original, state);
574
+ }
575
+ }
576
+ }
577
+ return result;
578
+ }
443
579
  /**
444
580
  * Record scan stamps for files we've looked at. Covers both files that produced
445
581
  * a session and files we looked at but chose not to index (e.g. malformed).
@@ -464,6 +600,73 @@ export function recordScans(entries) {
464
600
  });
465
601
  txn(entries);
466
602
  }
603
+ /**
604
+ * Bulk-load the dir ledger for a set of leaf directories in a single SQL query.
605
+ * Mirrors {@link getScanStampsForPaths}: keys by canonical path (so a dir
606
+ * reachable via a symlinked version home and its realpath collapse to one row)
607
+ * and fans the result back out to every original alias so callers can
608
+ * `.get(dirPath)` with the path they passed in.
609
+ */
610
+ export function getDirLedgerForPaths(dirs) {
611
+ const result = new Map();
612
+ if (dirs.length === 0)
613
+ return result;
614
+ const db = getDB();
615
+ const canonicalToOriginals = new Map();
616
+ for (const d of dirs) {
617
+ const canonical = canonicalLedgerKey(d);
618
+ const aliases = canonicalToOriginals.get(canonical);
619
+ if (aliases)
620
+ aliases.push(d);
621
+ else
622
+ canonicalToOriginals.set(canonical, [d]);
623
+ }
624
+ const canonicalKeys = [...canonicalToOriginals.keys()];
625
+ const CHUNK = 500;
626
+ for (let i = 0; i < canonicalKeys.length; i += CHUNK) {
627
+ const chunk = canonicalKeys.slice(i, i + CHUNK);
628
+ const placeholders = chunk.map(() => '?').join(',');
629
+ const rows = db
630
+ .prepare(`
631
+ SELECT dir_path, dir_mtime_ms, entry_count
632
+ FROM dir_ledger
633
+ WHERE dir_path IN (${placeholders})
634
+ `)
635
+ .all(...chunk);
636
+ for (const row of rows) {
637
+ const stamp = { dirMtimeMs: row.dir_mtime_ms, entryCount: row.entry_count };
638
+ for (const original of canonicalToOriginals.get(row.dir_path) || []) {
639
+ result.set(original, stamp);
640
+ }
641
+ }
642
+ }
643
+ return result;
644
+ }
645
+ /**
646
+ * Upsert dir-scan stamps. Recorded after a full readdir of a leaf transcript
647
+ * dir so the next scan can skip that dir when its (mtime, entry_count) is
648
+ * unchanged. Mirrors {@link recordScans}.
649
+ */
650
+ export function recordDirScans(entries) {
651
+ if (entries.length === 0)
652
+ return;
653
+ const db = getDB();
654
+ const stmt = db.prepare(`
655
+ INSERT INTO dir_ledger (dir_path, dir_mtime_ms, entry_count, scanned_at)
656
+ VALUES (?, ?, ?, ?)
657
+ ON CONFLICT(dir_path) DO UPDATE SET
658
+ dir_mtime_ms = excluded.dir_mtime_ms,
659
+ entry_count = excluded.entry_count,
660
+ scanned_at = excluded.scanned_at
661
+ `);
662
+ const now = Date.now();
663
+ const txn = db.transaction((items) => {
664
+ for (const { dirPath, dirMtimeMs, entryCount } of items) {
665
+ stmt.run(canonicalLedgerKey(dirPath), dirMtimeMs, entryCount, now);
666
+ }
667
+ });
668
+ txn(entries);
669
+ }
467
670
  const upsertSessionStmt = (db) => db.prepare(`
468
671
  INSERT INTO sessions (
469
672
  id, short_id, agent, origin, routine_name, routine_run_id,
@@ -471,14 +674,16 @@ const upsertSessionStmt = (db) => db.prepare(`
471
674
  project, cwd, git_branch, topic, label, message_count, token_count,
472
675
  output_tokens, cost_usd, duration_ms,
473
676
  file_path, file_mtime_ms, file_size, scanned_at, is_team_origin,
474
- 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
475
679
  ) VALUES (
476
680
  @id, @short_id, @agent, @origin, @routine_name, @routine_run_id,
477
681
  @version, @account, @timestamp, @last_activity,
478
682
  @project, @cwd, @git_branch, @topic, @label, @message_count, @token_count,
479
683
  @output_tokens, @cost_usd, @duration_ms,
480
684
  @file_path, @file_mtime_ms, @file_size, @scanned_at, @is_team_origin,
481
- @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
482
687
  )
483
688
  ON CONFLICT(id) DO UPDATE SET
484
689
  short_id = excluded.short_id,
@@ -494,7 +699,15 @@ const upsertSessionStmt = (db) => db.prepare(`
494
699
  cwd = excluded.cwd,
495
700
  git_branch = excluded.git_branch,
496
701
  topic = excluded.topic,
497
- label = excluded.label,
702
+ -- Never let an empty/placeholder incoming label clobber a good stored one.
703
+ -- A real incoming label (non-empty after trim) still wins; a blank one keeps
704
+ -- the label seeded by --name (seedLabelsFromNames) or refined by an agent
705
+ -- title / rename (syncLabels). A bare rescan carries no label, so it must
706
+ -- preserve, not erase, the good one already stored.
707
+ label = CASE
708
+ WHEN excluded.label IS NULL OR trim(excluded.label) = '' THEN sessions.label
709
+ ELSE excluded.label
710
+ END,
498
711
  message_count = excluded.message_count,
499
712
  token_count = excluded.token_count,
500
713
  output_tokens = excluded.output_tokens,
@@ -509,10 +722,40 @@ const upsertSessionStmt = (db) => db.prepare(`
509
722
  pr_number = excluded.pr_number,
510
723
  worktree_slug = excluded.worktree_slug,
511
724
  ticket_id = excluded.ticket_id,
512
- 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
513
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
+ }
514
753
  const deleteTextStmt = (db) => db.prepare(`DELETE FROM session_text WHERE session_id = ?`);
515
754
  const insertTextStmt = (db) => db.prepare(`INSERT INTO session_text (session_id, label, topic, project, content) VALUES (?, ?, ?, ?, ?)`);
755
+ // Read back the label the upsert actually stored (which may be the preserved
756
+ // one, not the incoming blank) so the FTS label column stays consistent with
757
+ // sessions.label after a bare rescan.
758
+ const readLabelStmt = (db) => db.prepare(`SELECT label FROM sessions WHERE id = ?`);
516
759
  let cachedStmts = {};
517
760
  function stmts(db) {
518
761
  if (!cachedStmts.upsert) {
@@ -520,17 +763,29 @@ function stmts(db) {
520
763
  upsert: upsertSessionStmt(db),
521
764
  delText: deleteTextStmt(db),
522
765
  insText: insertTextStmt(db),
766
+ readLabel: readLabelStmt(db),
523
767
  };
524
768
  }
525
769
  return cachedStmts;
526
770
  }
771
+ /**
772
+ * Return the label stored for a session, as text for the FTS index (never NULL).
773
+ * Called inside the upsert transaction, AFTER the row upsert, so it reflects the
774
+ * preserve-non-empty-label rule in the ON CONFLICT clause rather than the raw
775
+ * incoming label.
776
+ */
777
+ function storedFtsLabel(readLabel, id) {
778
+ const row = readLabel.get(id);
779
+ return row?.label ?? '';
780
+ }
527
781
  /**
528
782
  * Upsert a session row and replace its FTS5 content in a single transaction.
529
783
  * `content` is the tokenizable user-prompt text; pass '' to leave the row unsearchable.
530
784
  */
531
785
  export function upsertSession(meta, content, scan) {
786
+ meta = enrichCachedSessionMeta(meta);
532
787
  const db = getDB();
533
- const { upsert, delText, insText } = stmts(db);
788
+ const { upsert, delText, insText, readLabel } = stmts(db);
534
789
  const row = {
535
790
  id: meta.id,
536
791
  short_id: meta.shortId,
@@ -562,11 +817,18 @@ export function upsertSession(meta, content, scan) {
562
817
  worktree_slug: meta.worktreeSlug ?? null,
563
818
  ticket_id: meta.ticketId ?? null,
564
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,
565
824
  };
566
825
  const txn = db.transaction(() => {
567
826
  upsert.run(row);
568
827
  delText.run(meta.id);
569
- insText.run(meta.id, meta.label ?? '', meta.topic ?? '', meta.project ?? '', content ?? '');
828
+ insText.run(meta.id,
829
+ // Use the label the upsert actually stored (preserve-non-empty rule),
830
+ // not the raw incoming one, so FTS label ranking survives a bare rescan.
831
+ storedFtsLabel(readLabel, meta.id), meta.topic ?? '', meta.project ?? '', content ?? '');
570
832
  });
571
833
  txn();
572
834
  }
@@ -575,15 +837,22 @@ export function upsertSessionsBatch(entries) {
575
837
  if (entries.length === 0)
576
838
  return;
577
839
  const db = getDB();
578
- const { upsert, delText, insText } = stmts(db);
840
+ const { upsert, delText, insText, readLabel } = stmts(db);
579
841
  const now = Date.now();
842
+ // Persist the Claude resumable-parse continuation (parser_state + content_text)
843
+ // alongside the stamp. On a full/incremental Claude parse the caller passes the
844
+ // serialized newState + accumulated user doc so the NEXT scan can resume from
845
+ // the persisted offset (B-2). Other scanners pass neither, leaving both columns
846
+ // NULL exactly as before — their ledger rows are unaffected.
580
847
  const ledger = db.prepare(`
581
- INSERT INTO scan_ledger (file_path, file_mtime_ms, file_size, scanned_at)
582
- VALUES (?, ?, ?, ?)
848
+ INSERT INTO scan_ledger (file_path, file_mtime_ms, file_size, scanned_at, parser_state, content_text)
849
+ VALUES (?, ?, ?, ?, ?, ?)
583
850
  ON CONFLICT(file_path) DO UPDATE SET
584
851
  file_mtime_ms = excluded.file_mtime_ms,
585
852
  file_size = excluded.file_size,
586
- scanned_at = excluded.scanned_at
853
+ scanned_at = excluded.scanned_at,
854
+ parser_state = excluded.parser_state,
855
+ content_text = excluded.content_text
587
856
  `);
588
857
  // Build a lookup from canonical file path → entry, used inside the write
589
858
  // transaction to re-check the ledger AFTER acquiring the lock. When a
@@ -594,6 +863,9 @@ export function upsertSessionsBatch(entries) {
594
863
  const byPath = new Map(entries
595
864
  .filter(e => e.scan && e.meta.filePath)
596
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) });
597
869
  const txn = db.transaction((items) => {
598
870
  // Re-read the ledger now that we hold the write lock. Any file committed
599
871
  // by a concurrent process since our pre-scan is visible here.
@@ -613,7 +885,7 @@ export function upsertSessionsBatch(entries) {
613
885
  }
614
886
  }
615
887
  }
616
- for (const { meta, content, scan } of items) {
888
+ for (const { meta, content, scan, parserState, contentText } of items) {
617
889
  if (alreadyIndexed.has(meta.id))
618
890
  continue;
619
891
  // Per-row guard: one malformed session (e.g. a required field that resolves to
@@ -655,11 +927,18 @@ export function upsertSessionsBatch(entries) {
655
927
  worktree_slug: meta.worktreeSlug ?? null,
656
928
  ticket_id: meta.ticketId ?? null,
657
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,
658
934
  });
659
935
  delText.run(meta.id);
660
- insText.run(meta.id, meta.label ?? '', meta.topic ?? '', meta.project ?? '', content ?? '');
936
+ insText.run(meta.id,
937
+ // Mirror upsertSession: index the label the upsert actually stored
938
+ // (preserve-non-empty rule), not the raw incoming one.
939
+ storedFtsLabel(readLabel, meta.id), meta.topic ?? '', meta.project ?? '', content ?? '');
661
940
  if (scan && meta.filePath) {
662
- ledger.run(canonicalLedgerKey(meta.filePath), scan.fileMtimeMs, scan.fileSize, now);
941
+ ledger.run(canonicalLedgerKey(meta.filePath), scan.fileMtimeMs, scan.fileSize, now, parserState ?? null, contentText ?? null);
663
942
  }
664
943
  }
665
944
  catch (err) {
@@ -669,7 +948,7 @@ export function upsertSessionsBatch(entries) {
669
948
  }
670
949
  }
671
950
  });
672
- txn(entries);
951
+ txn(enrichedEntries);
673
952
  }
674
953
  /**
675
954
  * Sync labels for a set of sessions. For each id in the map, if the stored
@@ -832,8 +1111,22 @@ function rowToMeta(row) {
832
1111
  worktreeSlug: row.worktree_slug ?? undefined,
833
1112
  ticketId: row.ticket_id ?? undefined,
834
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,
835
1118
  };
836
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
+ }
837
1130
  /**
838
1131
  * The recency signal used to sort and label the listing: last-message time when
839
1132
  * a parser computed it (`meta.lastActivity` from `lastTsMs`), else the file's
@@ -854,6 +1147,11 @@ export function isSessionActivityFresh(row, maxAgeMs, nowMs) {
854
1147
  const activityMs = Number.isFinite(parsedActivityMs) ? parsedActivityMs : row.file_mtime_ms ?? undefined;
855
1148
  return activityMs != null && nowMs - activityMs <= maxAgeMs;
856
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
+ }
857
1155
  /**
858
1156
  * Newest indexed session file for an agent working in `cwd`. Lets the live
859
1157
  * `--active` scanner locate a Codex transcript (whose files are date-partitioned,
@@ -909,9 +1207,12 @@ function buildSessionWhere(options) {
909
1207
  params.push(options.cwd);
910
1208
  }
911
1209
  if (options.cwdPrefix) {
912
- // Stored cwd uses the host path separator (normalizeCwd → path.resolve), so
913
- // the subdir wildcard must too — a hardcoded '/' never matches a Windows
914
- // `C:\a\b` subpath and the listing comes back empty.
1210
+ // A LOCAL stored cwd uses the host path separator (normalizeCwd runs
1211
+ // path.normalize on it), so the subdir wildcard must too — a hardcoded '/'
1212
+ // never matches a Windows `C:\a\b` subpath and the listing comes back empty.
1213
+ // A cwd recorded on another machine keeps its own separators, so this
1214
+ // wildcard does not match foreign subpaths; both sides go through
1215
+ // normalizeCwd, so the exact `cwd = ?` comparison still holds for them.
915
1216
  where.push('(cwd = ? OR cwd LIKE ?)');
916
1217
  params.push(options.cwdPrefix, options.cwdPrefix + path.sep + '%');
917
1218
  }
@@ -0,0 +1,30 @@
1
+ /** A session's foreground/background presence. */
2
+ export type Presence = 'attached' | 'background' | 'parked';
3
+ export interface DetachRecord {
4
+ sessionId: string;
5
+ agent: string;
6
+ cwd?: string;
7
+ /** pid of the detached headless continuation `agents sessions detach` spawned. */
8
+ headlessPid: number;
9
+ /**
10
+ * Start-time fingerprint of {@link headlessPid} at spawn, so a liveness check
11
+ * survives PID reuse: the pid is only "our" continuation if it still occupies
12
+ * the process we launched. Null when the platform capture failed.
13
+ */
14
+ headlessStartTime: string | null;
15
+ detachedAtMs: number;
16
+ }
17
+ export declare function writeDetachRecord(rec: DetachRecord): void;
18
+ export declare function readDetachRecord(sessionId: string): DetachRecord | undefined;
19
+ export declare function clearDetachRecord(sessionId: string): void;
20
+ export declare function listDetachRecords(): DetachRecord[];
21
+ /** True while the recorded headless continuation is still the live process we spawned. */
22
+ export declare function isHeadlessAlive(rec: DetachRecord): boolean;
23
+ /**
24
+ * Presence for a session id from the detach store alone:
25
+ * - no record -> undefined (caller decides: `attached` for a live
26
+ * interactive row, nothing for cloud/team rows)
27
+ * - record + pid alive -> `background` (headless continuation running)
28
+ * - record + pid exited -> `parked` (the run finished; transcript is durable)
29
+ */
30
+ export declare function presenceFromStore(sessionId: string): Presence | undefined;