@phigon/observer 8.1.0 → 8.3.0

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 (309) hide show
  1. package/README.md +244 -18
  2. package/dist/bin/guardian-runner-darwin-amd64 +0 -0
  3. package/dist/bin/guardian-runner-darwin-arm64 +0 -0
  4. package/dist/bin/guardian-runner-linux-amd64 +0 -0
  5. package/dist/bin/guardian-runner-linux-arm64 +0 -0
  6. package/dist/bin/guardian-runner-windows-amd64.exe +0 -0
  7. package/dist/bin/guardian-runner-windows-arm64.exe +0 -0
  8. package/dist/src/abortable-delay.d.ts +10 -0
  9. package/dist/src/abortable-delay.d.ts.map +1 -0
  10. package/dist/src/abortable-delay.js +21 -0
  11. package/dist/src/abortable-delay.js.map +1 -0
  12. package/dist/src/action-policy.d.ts.map +1 -1
  13. package/dist/src/action-policy.js +4 -2
  14. package/dist/src/action-policy.js.map +1 -1
  15. package/dist/src/activity/adapters/claude.d.ts.map +1 -1
  16. package/dist/src/activity/adapters/claude.js +28 -2
  17. package/dist/src/activity/adapters/claude.js.map +1 -1
  18. package/dist/src/activity/adapters/cline-task.d.ts +7 -0
  19. package/dist/src/activity/adapters/cline-task.d.ts.map +1 -0
  20. package/dist/src/activity/adapters/cline-task.js +197 -0
  21. package/dist/src/activity/adapters/cline-task.js.map +1 -0
  22. package/dist/src/activity/adapters/cline.d.ts +3 -0
  23. package/dist/src/activity/adapters/cline.d.ts.map +1 -0
  24. package/dist/src/activity/adapters/cline.js +102 -0
  25. package/dist/src/activity/adapters/cline.js.map +1 -0
  26. package/dist/src/activity/adapters/codex.d.ts.map +1 -1
  27. package/dist/src/activity/adapters/codex.js +76 -0
  28. package/dist/src/activity/adapters/codex.js.map +1 -1
  29. package/dist/src/activity/adapters/copilot-chat.d.ts +3 -0
  30. package/dist/src/activity/adapters/copilot-chat.d.ts.map +1 -0
  31. package/dist/src/activity/adapters/copilot-chat.js +176 -0
  32. package/dist/src/activity/adapters/copilot-chat.js.map +1 -0
  33. package/dist/src/activity/adapters/copilot.d.ts +2 -1
  34. package/dist/src/activity/adapters/copilot.d.ts.map +1 -1
  35. package/dist/src/activity/adapters/copilot.js +173 -2
  36. package/dist/src/activity/adapters/copilot.js.map +1 -1
  37. package/dist/src/activity/adapters/cursor.d.ts +3 -0
  38. package/dist/src/activity/adapters/cursor.d.ts.map +1 -0
  39. package/dist/src/activity/adapters/cursor.js +86 -0
  40. package/dist/src/activity/adapters/cursor.js.map +1 -0
  41. package/dist/src/activity/adapters/factory-droid.d.ts +3 -0
  42. package/dist/src/activity/adapters/factory-droid.d.ts.map +1 -0
  43. package/dist/src/activity/adapters/factory-droid.js +126 -0
  44. package/dist/src/activity/adapters/factory-droid.js.map +1 -0
  45. package/dist/src/activity/adapters/gemini.d.ts +3 -0
  46. package/dist/src/activity/adapters/gemini.d.ts.map +1 -0
  47. package/dist/src/activity/adapters/gemini.js +177 -0
  48. package/dist/src/activity/adapters/gemini.js.map +1 -0
  49. package/dist/src/activity/adapters/goose.d.ts +3 -0
  50. package/dist/src/activity/adapters/goose.d.ts.map +1 -0
  51. package/dist/src/activity/adapters/goose.js +116 -0
  52. package/dist/src/activity/adapters/goose.js.map +1 -0
  53. package/dist/src/activity/adapters/kiro.d.ts +3 -0
  54. package/dist/src/activity/adapters/kiro.d.ts.map +1 -0
  55. package/dist/src/activity/adapters/kiro.js +47 -0
  56. package/dist/src/activity/adapters/kiro.js.map +1 -0
  57. package/dist/src/activity/adapters/openai-compatible.d.ts.map +1 -1
  58. package/dist/src/activity/adapters/openai-compatible.js +9 -1
  59. package/dist/src/activity/adapters/openai-compatible.js.map +1 -1
  60. package/dist/src/activity/adapters/opencode.d.ts.map +1 -1
  61. package/dist/src/activity/adapters/opencode.js +108 -1
  62. package/dist/src/activity/adapters/opencode.js.map +1 -1
  63. package/dist/src/activity/adapters/zed.d.ts +3 -0
  64. package/dist/src/activity/adapters/zed.d.ts.map +1 -0
  65. package/dist/src/activity/adapters/zed.js +134 -0
  66. package/dist/src/activity/adapters/zed.js.map +1 -0
  67. package/dist/src/activity/collector.d.ts +43 -1
  68. package/dist/src/activity/collector.d.ts.map +1 -1
  69. package/dist/src/activity/collector.js +490 -77
  70. package/dist/src/activity/collector.js.map +1 -1
  71. package/dist/src/activity/goose-sqlite-source.d.ts +33 -0
  72. package/dist/src/activity/goose-sqlite-source.d.ts.map +1 -0
  73. package/dist/src/activity/goose-sqlite-source.js +274 -0
  74. package/dist/src/activity/goose-sqlite-source.js.map +1 -0
  75. package/dist/src/activity/index.d.ts +3 -0
  76. package/dist/src/activity/index.d.ts.map +1 -1
  77. package/dist/src/activity/index.js +3 -0
  78. package/dist/src/activity/index.js.map +1 -1
  79. package/dist/src/activity/ingest.d.ts +9 -0
  80. package/dist/src/activity/ingest.d.ts.map +1 -1
  81. package/dist/src/activity/ingest.js +94 -4
  82. package/dist/src/activity/ingest.js.map +1 -1
  83. package/dist/src/activity/json-document-source.d.ts +151 -0
  84. package/dist/src/activity/json-document-source.d.ts.map +1 -0
  85. package/dist/src/activity/json-document-source.js +586 -0
  86. package/dist/src/activity/json-document-source.js.map +1 -0
  87. package/dist/src/activity/native-capabilities.d.ts.map +1 -1
  88. package/dist/src/activity/native-capabilities.js +8 -12
  89. package/dist/src/activity/native-capabilities.js.map +1 -1
  90. package/dist/src/activity/normalize.d.ts.map +1 -1
  91. package/dist/src/activity/normalize.js +64 -6
  92. package/dist/src/activity/normalize.js.map +1 -1
  93. package/dist/src/activity/opencode-message-tables.d.ts +87 -0
  94. package/dist/src/activity/opencode-message-tables.d.ts.map +1 -0
  95. package/dist/src/activity/opencode-message-tables.js +546 -0
  96. package/dist/src/activity/opencode-message-tables.js.map +1 -0
  97. package/dist/src/activity/opencode-sqlite-source.d.ts +7 -0
  98. package/dist/src/activity/opencode-sqlite-source.d.ts.map +1 -1
  99. package/dist/src/activity/opencode-sqlite-source.js +154 -63
  100. package/dist/src/activity/opencode-sqlite-source.js.map +1 -1
  101. package/dist/src/activity/source-reader.d.ts +60 -4
  102. package/dist/src/activity/source-reader.d.ts.map +1 -1
  103. package/dist/src/activity/source-reader.js +593 -75
  104. package/dist/src/activity/source-reader.js.map +1 -1
  105. package/dist/src/activity/sqlite-connections.d.ts +77 -0
  106. package/dist/src/activity/sqlite-connections.d.ts.map +1 -0
  107. package/dist/src/activity/sqlite-connections.js +220 -0
  108. package/dist/src/activity/sqlite-connections.js.map +1 -0
  109. package/dist/src/activity/zed-sqlite-source.d.ts +65 -0
  110. package/dist/src/activity/zed-sqlite-source.d.ts.map +1 -0
  111. package/dist/src/activity/zed-sqlite-source.js +547 -0
  112. package/dist/src/activity/zed-sqlite-source.js.map +1 -0
  113. package/dist/src/agent-action-executor.d.ts +18 -1
  114. package/dist/src/agent-action-executor.d.ts.map +1 -1
  115. package/dist/src/agent-action-executor.js +113 -58
  116. package/dist/src/agent-action-executor.js.map +1 -1
  117. package/dist/src/agentic-guardian.d.ts.map +1 -1
  118. package/dist/src/agentic-guardian.js +6 -5
  119. package/dist/src/agentic-guardian.js.map +1 -1
  120. package/dist/src/atomic-replace.d.ts +7 -0
  121. package/dist/src/atomic-replace.d.ts.map +1 -1
  122. package/dist/src/atomic-replace.js +28 -3
  123. package/dist/src/atomic-replace.js.map +1 -1
  124. package/dist/src/bridge-journal.d.ts +32 -0
  125. package/dist/src/bridge-journal.d.ts.map +1 -0
  126. package/dist/src/bridge-journal.js +113 -0
  127. package/dist/src/bridge-journal.js.map +1 -0
  128. package/dist/src/cli.d.ts +7 -1
  129. package/dist/src/cli.d.ts.map +1 -1
  130. package/dist/src/cli.js +534 -50
  131. package/dist/src/cli.js.map +1 -1
  132. package/dist/src/collection-notice.d.ts +34 -0
  133. package/dist/src/collection-notice.d.ts.map +1 -0
  134. package/dist/src/collection-notice.js +73 -0
  135. package/dist/src/collection-notice.js.map +1 -0
  136. package/dist/src/config.d.ts +39 -0
  137. package/dist/src/config.d.ts.map +1 -1
  138. package/dist/src/config.js +187 -36
  139. package/dist/src/config.js.map +1 -1
  140. package/dist/src/connection-config.d.ts.map +1 -1
  141. package/dist/src/connection-config.js +5 -0
  142. package/dist/src/connection-config.js.map +1 -1
  143. package/dist/src/content-redaction.d.ts +17 -1
  144. package/dist/src/content-redaction.d.ts.map +1 -1
  145. package/dist/src/content-redaction.js +51 -3
  146. package/dist/src/content-redaction.js.map +1 -1
  147. package/dist/src/credential-store.d.ts +20 -1
  148. package/dist/src/credential-store.d.ts.map +1 -1
  149. package/dist/src/credential-store.js +29 -4
  150. package/dist/src/credential-store.js.map +1 -1
  151. package/dist/src/custom-agent-registry.d.ts.map +1 -1
  152. package/dist/src/custom-agent-registry.js +2 -1
  153. package/dist/src/custom-agent-registry.js.map +1 -1
  154. package/dist/src/discovery/adapters/generic.d.ts.map +1 -1
  155. package/dist/src/discovery/adapters/generic.js +160 -2
  156. package/dist/src/discovery/adapters/generic.js.map +1 -1
  157. package/dist/src/discovery/engine.d.ts +7 -1
  158. package/dist/src/discovery/engine.d.ts.map +1 -1
  159. package/dist/src/discovery/engine.js +21 -16
  160. package/dist/src/discovery/engine.js.map +1 -1
  161. package/dist/src/discovery/known-agent.d.ts +7 -0
  162. package/dist/src/discovery/known-agent.d.ts.map +1 -1
  163. package/dist/src/discovery/known-agent.js +9 -4
  164. package/dist/src/discovery/known-agent.js.map +1 -1
  165. package/dist/src/discovery/system-evidence.d.ts +40 -1
  166. package/dist/src/discovery/system-evidence.d.ts.map +1 -1
  167. package/dist/src/discovery/system-evidence.js +189 -16
  168. package/dist/src/discovery/system-evidence.js.map +1 -1
  169. package/dist/src/doctor-check.d.ts +51 -0
  170. package/dist/src/doctor-check.d.ts.map +1 -0
  171. package/dist/src/doctor-check.js +174 -0
  172. package/dist/src/doctor-check.js.map +1 -0
  173. package/dist/src/enroll.d.ts +86 -0
  174. package/dist/src/enroll.d.ts.map +1 -0
  175. package/dist/src/enroll.js +245 -0
  176. package/dist/src/enroll.js.map +1 -0
  177. package/dist/src/entry.d.ts +3 -0
  178. package/dist/src/entry.d.ts.map +1 -0
  179. package/dist/src/entry.js +14 -0
  180. package/dist/src/entry.js.map +1 -0
  181. package/dist/src/event-queue.d.ts +13 -1
  182. package/dist/src/event-queue.d.ts.map +1 -1
  183. package/dist/src/event-queue.js +137 -42
  184. package/dist/src/event-queue.js.map +1 -1
  185. package/dist/src/event-spool.d.ts +61 -3
  186. package/dist/src/event-spool.d.ts.map +1 -1
  187. package/dist/src/event-spool.js +341 -44
  188. package/dist/src/event-spool.js.map +1 -1
  189. package/dist/src/generated/phigon/v1/identity_pb.d.ts +7 -0
  190. package/dist/src/generated/phigon/v1/identity_pb.d.ts.map +1 -1
  191. package/dist/src/generated/phigon/v1/identity_pb.js +1 -1
  192. package/dist/src/generated/phigon/v1/identity_pb.js.map +1 -1
  193. package/dist/src/guardian-containment.d.ts +2 -6
  194. package/dist/src/guardian-containment.d.ts.map +1 -1
  195. package/dist/src/guardian-containment.js +12 -63
  196. package/dist/src/guardian-containment.js.map +1 -1
  197. package/dist/src/guardian-decision-journal.d.ts.map +1 -1
  198. package/dist/src/guardian-decision-journal.js +4 -19
  199. package/dist/src/guardian-decision-journal.js.map +1 -1
  200. package/dist/src/guardian-managed-exec.d.ts.map +1 -1
  201. package/dist/src/guardian-managed-exec.js +6 -18
  202. package/dist/src/guardian-managed-exec.js.map +1 -1
  203. package/dist/src/guardian-model-bridge.d.ts.map +1 -1
  204. package/dist/src/guardian-model-bridge.js +2 -1
  205. package/dist/src/guardian-model-bridge.js.map +1 -1
  206. package/dist/src/guardian-native.d.ts +0 -1
  207. package/dist/src/guardian-native.d.ts.map +1 -1
  208. package/dist/src/guardian-native.js +0 -6
  209. package/dist/src/guardian-native.js.map +1 -1
  210. package/dist/src/guardian-process-monitor.d.ts +4 -0
  211. package/dist/src/guardian-process-monitor.d.ts.map +1 -1
  212. package/dist/src/guardian-process-monitor.js +82 -26
  213. package/dist/src/guardian-process-monitor.js.map +1 -1
  214. package/dist/src/health.d.ts +19 -1
  215. package/dist/src/health.d.ts.map +1 -1
  216. package/dist/src/health.js +22 -1
  217. package/dist/src/health.js.map +1 -1
  218. package/dist/src/historical-identity-store.d.ts +8 -1
  219. package/dist/src/historical-identity-store.d.ts.map +1 -1
  220. package/dist/src/historical-identity-store.js +28 -10
  221. package/dist/src/historical-identity-store.js.map +1 -1
  222. package/dist/src/index.d.ts +2 -0
  223. package/dist/src/index.d.ts.map +1 -1
  224. package/dist/src/index.js +2 -0
  225. package/dist/src/index.js.map +1 -1
  226. package/dist/src/installation.d.ts +2 -0
  227. package/dist/src/installation.d.ts.map +1 -1
  228. package/dist/src/installation.js +6 -3
  229. package/dist/src/installation.js.map +1 -1
  230. package/dist/src/instance-lock.d.ts +45 -0
  231. package/dist/src/instance-lock.d.ts.map +1 -0
  232. package/dist/src/instance-lock.js +243 -0
  233. package/dist/src/instance-lock.js.map +1 -0
  234. package/dist/src/machine-command-executor.d.ts +22 -7
  235. package/dist/src/machine-command-executor.d.ts.map +1 -1
  236. package/dist/src/machine-command-executor.js +80 -19
  237. package/dist/src/machine-command-executor.js.map +1 -1
  238. package/dist/src/node-version.d.ts +8 -0
  239. package/dist/src/node-version.d.ts.map +1 -0
  240. package/dist/src/node-version.js +17 -0
  241. package/dist/src/node-version.js.map +1 -0
  242. package/dist/src/observer-log.d.ts +50 -0
  243. package/dist/src/observer-log.d.ts.map +1 -0
  244. package/dist/src/observer-log.js +141 -0
  245. package/dist/src/observer-log.js.map +1 -0
  246. package/dist/src/pair.d.ts +68 -1
  247. package/dist/src/pair.d.ts.map +1 -1
  248. package/dist/src/pair.js +204 -65
  249. package/dist/src/pair.js.map +1 -1
  250. package/dist/src/private-directory.d.ts +10 -0
  251. package/dist/src/private-directory.d.ts.map +1 -0
  252. package/dist/src/private-directory.js +66 -0
  253. package/dist/src/private-directory.js.map +1 -0
  254. package/dist/src/process-tree.d.ts +73 -0
  255. package/dist/src/process-tree.d.ts.map +1 -0
  256. package/dist/src/process-tree.js +256 -0
  257. package/dist/src/process-tree.js.map +1 -0
  258. package/dist/src/proxy.d.ts +68 -0
  259. package/dist/src/proxy.d.ts.map +1 -0
  260. package/dist/src/proxy.js +209 -0
  261. package/dist/src/proxy.js.map +1 -0
  262. package/dist/src/purge.d.ts +17 -0
  263. package/dist/src/purge.d.ts.map +1 -0
  264. package/dist/src/purge.js +84 -0
  265. package/dist/src/purge.js.map +1 -0
  266. package/dist/src/remove-machine.d.ts +5 -1
  267. package/dist/src/remove-machine.d.ts.map +1 -1
  268. package/dist/src/remove-machine.js +28 -14
  269. package/dist/src/remove-machine.js.map +1 -1
  270. package/dist/src/server-authority.d.ts +29 -0
  271. package/dist/src/server-authority.d.ts.map +1 -0
  272. package/dist/src/server-authority.js +106 -0
  273. package/dist/src/server-authority.js.map +1 -0
  274. package/dist/src/server-clock.d.ts +32 -0
  275. package/dist/src/server-clock.d.ts.map +1 -0
  276. package/dist/src/server-clock.js +72 -0
  277. package/dist/src/server-clock.js.map +1 -0
  278. package/dist/src/service-command.d.ts +20 -0
  279. package/dist/src/service-command.d.ts.map +1 -0
  280. package/dist/src/service-command.js +136 -0
  281. package/dist/src/service-command.js.map +1 -0
  282. package/dist/src/service.d.ts +98 -0
  283. package/dist/src/service.d.ts.map +1 -0
  284. package/dist/src/service.js +472 -0
  285. package/dist/src/service.js.map +1 -0
  286. package/dist/src/stream-client.d.ts +21 -1
  287. package/dist/src/stream-client.d.ts.map +1 -1
  288. package/dist/src/stream-client.js +137 -24
  289. package/dist/src/stream-client.js.map +1 -1
  290. package/dist/src/stream-state.d.ts +2 -0
  291. package/dist/src/stream-state.d.ts.map +1 -1
  292. package/dist/src/stream-state.js +5 -2
  293. package/dist/src/stream-state.js.map +1 -1
  294. package/dist/src/system-inspection.d.ts +78 -0
  295. package/dist/src/system-inspection.d.ts.map +1 -0
  296. package/dist/src/system-inspection.js +281 -0
  297. package/dist/src/system-inspection.js.map +1 -0
  298. package/dist/src/update.d.ts.map +1 -1
  299. package/dist/src/update.js +9 -3
  300. package/dist/src/update.js.map +1 -1
  301. package/dist/src/user-notices.d.ts +64 -0
  302. package/dist/src/user-notices.d.ts.map +1 -0
  303. package/dist/src/user-notices.js +141 -0
  304. package/dist/src/user-notices.js.map +1 -0
  305. package/package.json +7 -7
  306. package/dist/src/windows-process.d.ts +0 -7
  307. package/dist/src/windows-process.d.ts.map +0 -1
  308. package/dist/src/windows-process.js +0 -18
  309. package/dist/src/windows-process.js.map +0 -1
@@ -1,16 +1,93 @@
1
+ import { makeDirectoryPrivate } from "../private-directory.js";
1
2
  import { createHash, randomUUID } from "node:crypto";
2
3
  import { chmod, mkdir, open, readFile, readdir, realpath, stat, unlink, writeFile, } from "node:fs/promises";
3
4
  import { homedir } from "node:os";
4
5
  import { basename, join, relative, resolve, sep } from "node:path";
5
6
  import { atomicReplace } from "../atomic-replace.js";
7
+ import { DirectoryListingCache, scanJSONDocumentSource, startedAfter, } from "./json-document-source.js";
8
+ import { SQLiteConnections } from "./sqlite-connections.js";
6
9
  const maxSourceFiles = 256;
7
10
  const historicalFilesPerScan = 16;
8
11
  const historicalRecordsPerScan = 4;
9
12
  const maxDirectoryDepth = 8;
10
13
  const maxReadBytesPerFile = 8 << 20;
11
14
  const defaultInitialReplayBytes = 256 << 10;
12
- const maxLineBytes = 1 << 20;
15
+ // Normal read window. A longer complete record widens the read up to the line
16
+ // limit; the line limit stays below the per-file read limit.
17
+ const readWindowBytes = 1 << 20;
18
+ const maxLineBytes = 6 << 20;
13
19
  const maxCursorEntries = 16_384;
20
+ // A source or file that fails is skipped for a while, twice as long after
21
+ // each further failure, so one broken agent folder or database cannot stop
22
+ // the others or be retried on every scan.
23
+ const failureRetryBaseMs = 5_000;
24
+ const failureRetryMaxMs = 5 * 60_000;
25
+ const maxTrackedFailures = 256;
26
+ /**
27
+ * Source cursors in least-recently-used order, so a full store evicts the
28
+ * cursors of sessions nobody reads any more instead of live ones (an
29
+ * evicted live cursor is re-baselined after a restart, which skips what
30
+ * was written while the observer was stopped). Reads are only recorded and
31
+ * applied by `settle`, so iterating the map never observes reordering.
32
+ */
33
+ class RecentCursors extends Map {
34
+ #used = new Set();
35
+ // Values from before `track`, for each cursor changed since.
36
+ #journal;
37
+ get(key) {
38
+ const value = super.get(key);
39
+ if (value !== undefined)
40
+ this.#used.add(key);
41
+ return value;
42
+ }
43
+ set(key, value) {
44
+ this.#remember(key);
45
+ super.delete(key);
46
+ return super.set(key, value);
47
+ }
48
+ delete(key) {
49
+ this.#remember(key);
50
+ return super.delete(key);
51
+ }
52
+ /** Start remembering the cursors a source changes. */
53
+ track() {
54
+ this.#journal = new Map();
55
+ }
56
+ /** Stop remembering; with `rollback`, undo every change since `track`. */
57
+ untrack(rollback) {
58
+ const journal = this.#journal;
59
+ this.#journal = undefined;
60
+ if (!rollback || journal === undefined)
61
+ return;
62
+ for (const [key, value] of journal) {
63
+ if (value === undefined)
64
+ super.delete(key);
65
+ else
66
+ super.set(key, value);
67
+ }
68
+ }
69
+ #remember(key) {
70
+ if (this.#journal !== undefined && !this.#journal.has(key)) {
71
+ this.#journal.set(key, super.get(key));
72
+ }
73
+ }
74
+ /** Move cursors read since the last call to the recent end, then bound. */
75
+ settle(limit) {
76
+ for (const key of this.#used) {
77
+ const value = super.get(key);
78
+ if (value === undefined)
79
+ continue;
80
+ super.delete(key);
81
+ super.set(key, value);
82
+ }
83
+ this.#used.clear();
84
+ for (const key of super.keys()) {
85
+ if (this.size <= limit)
86
+ break;
87
+ super.delete(key);
88
+ }
89
+ }
90
+ }
14
91
  export class ActivityCursorStore {
15
92
  #dataDirectory;
16
93
  #path;
@@ -25,7 +102,7 @@ export class ActivityCursorStore {
25
102
  }
26
103
  catch (error) {
27
104
  if (isNodeError(error) && error.code === "ENOENT")
28
- return new Map();
105
+ return { cursors: new Map() };
29
106
  throw error;
30
107
  }
31
108
  let value;
@@ -42,7 +119,7 @@ export class ActivityCursorStore {
42
119
  const recovered = new Map();
43
120
  await this.save(recovered);
44
121
  process.emitWarning("Saved activity checkpoints were unreadable. Available source history will be rescanned using stable record IDs.", { code: "PHIGON_CURSOR_RECOVERY" });
45
- return recovered;
122
+ return { cursors: recovered };
46
123
  }
47
124
  if (typeof value !== "object" ||
48
125
  value === null ||
@@ -53,7 +130,8 @@ export class ActivityCursorStore {
53
130
  value.files === null) {
54
131
  throw new Error("Activity cursor state is invalid.");
55
132
  }
56
- const entries = Object.entries(value.files).slice(0, maxCursorEntries);
133
+ // Saved oldest-first (see save); keep the most recently used cursors.
134
+ const entries = Object.entries(value.files).slice(-maxCursorEntries);
57
135
  const result = new Map();
58
136
  for (const [key, offset] of entries) {
59
137
  if (/^[a-f0-9]{64}$/u.test(key) &&
@@ -63,15 +141,24 @@ export class ActivityCursorStore {
63
141
  result.set(key, offset);
64
142
  }
65
143
  }
66
- return result;
144
+ const scannedAt = "scanned_at" in value && typeof value.scanned_at === "string"
145
+ ? Date.parse(value.scanned_at)
146
+ : Number.NaN;
147
+ return Number.isFinite(scannedAt)
148
+ ? { cursors: result, scannedAt }
149
+ : { cursors: result };
67
150
  }
68
- async save(cursors) {
151
+ async save(cursors, scannedAt) {
69
152
  await mkdir(this.#dataDirectory, { recursive: true, mode: 0o700 });
70
- await chmod(this.#dataDirectory, 0o700);
71
- const files = Object.fromEntries([...cursors.entries()]
72
- .slice(-maxCursorEntries)
73
- .sort(([left], [right]) => left.localeCompare(right)));
74
- const state = { version: 1, files };
153
+ await makeDirectoryPrivate(this.#dataDirectory);
154
+ // Keep the map's order (least recently used first) so a restarted
155
+ // reader still knows which cursors are stale.
156
+ const files = Object.fromEntries([...cursors.entries()].slice(-maxCursorEntries));
157
+ const state = {
158
+ version: 1,
159
+ files,
160
+ ...(scannedAt === undefined ? {} : { scanned_at: new Date(scannedAt).toISOString() }),
161
+ };
75
162
  const temporary = `${this.#path}.${process.pid}.${randomUUID()}.tmp`;
76
163
  try {
77
164
  await writeFile(temporary, `${JSON.stringify(state)}\n`, {
@@ -101,84 +188,120 @@ export class JSONLSourceReader {
101
188
  #initialReplaySessions;
102
189
  #mode;
103
190
  #cursors;
191
+ // The cursor state that is already durable. An idle scan produces an
192
+ // identical checkpoint every tick; rewriting and flushing it would cost a
193
+ // synchronous disk write per scan interval without changing anything.
194
+ #savedCursors;
195
+ #savedScannedAt;
196
+ // When the scan behind the saved cursors started, as loaded at startup.
197
+ // Undefined on the first start ever. A file without a cursor that began
198
+ // after it was created while the observer was stopped.
199
+ #lastRunAt;
200
+ // The cursors as the previous scan left them, which is where the next
201
+ // scan starts. Kept so a position costs no copy on every scan.
202
+ #scanStart;
104
203
  #initialScanComplete = false;
105
204
  #generation = 0;
106
205
  #committedGeneration = 0;
107
206
  #sourceCache;
207
+ // Canonical paths from the previous listing, keyed by resolved root and
208
+ // listed path. Holds only files seen in the latest listing.
209
+ #canonicalPaths = new Map();
108
210
  #completedHistoryFiles = new Map();
109
- constructor(dataDirectory, replayOnFirstStart = false, initialReplayBytes = defaultInitialReplayBytes, initialReplaySessions = 1, mode = "live") {
211
+ #completedHistoryDatabases = new Map();
212
+ // Cursors that stopped inside a record above the line limit. The next read
213
+ // skips to that record's end instead of parsing its tail as a new record.
214
+ #oversizedContinuations = new Set();
215
+ // Live database connections kept between scans, so an unchanged database
216
+ // is recognized without reopening it and rerunning every session query.
217
+ #openCodeConnections = new SQLiteConnections();
218
+ // Folder listings for JSON-document agents, reused while folders are unchanged.
219
+ #documentListings = new DirectoryListingCache();
220
+ #gooseConnections = new SQLiteConnections();
221
+ #zedConnections = new SQLiteConnections();
222
+ #failures = new Map();
223
+ // Live sources that were skipped or failed while their first scan would
224
+ // have baselined them. Their first successful scan baselines instead, so
225
+ // an old archive is not read as new work once the source recovers.
226
+ #unbaselinedSources = new Set();
227
+ #clock;
228
+ constructor(dataDirectory, replayOnFirstStart = false, initialReplayBytes = defaultInitialReplayBytes, initialReplaySessions = 1, mode = "live", clock = Date.now) {
110
229
  this.#cursorStore = new ActivityCursorStore(dataDirectory);
111
230
  this.#replayOnFirstStart = replayOnFirstStart;
112
231
  this.#initialReplayBytes = Math.min(maxReadBytesPerFile, Math.max(1, initialReplayBytes));
113
232
  this.#initialReplaySessions = Math.min(maxSourceFiles, Math.max(1, initialReplaySessions));
114
233
  this.#mode = mode;
234
+ this.#clock = clock;
115
235
  }
116
236
  async scan(sources, maxRecords) {
117
- this.#cursors ??= await this.#cursorStore.load();
237
+ if (this.#cursors === undefined) {
238
+ const loaded = await this.#cursorStore.load();
239
+ this.#cursors = new RecentCursors();
240
+ for (const [key, offset] of loaded.cursors)
241
+ this.#cursors.set(key, offset);
242
+ this.#savedCursors = loaded.cursors;
243
+ this.#savedScannedAt = loaded.scannedAt;
244
+ this.#lastRunAt = loaded.scannedAt;
245
+ this.#scanStart = loaded.cursors;
246
+ }
247
+ const scannedAt = this.#clock();
118
248
  const records = [];
119
249
  let fileCount = 0;
120
250
  let malformed = 0;
121
251
  let oversized = 0;
122
252
  let candidates = [];
253
+ const initialScan = !this.#initialScanComplete;
254
+ // Files of sources that missed their baseline, baselined on this scan.
255
+ const baselinePaths = new Set();
123
256
  const cacheKey = JSON.stringify(sources);
124
257
  if (this.#mode === "history" && this.#sourceCache?.key === cacheKey && Date.now() - this.#sourceCache.at < 5_000) {
125
258
  candidates = this.#sourceCache.files;
126
259
  }
127
260
  else {
261
+ const canonicalPaths = new Map();
128
262
  for (const source of sources) {
129
- if (source.format === "opencode-sqlite")
263
+ if (source.format !== undefined && source.format !== "jsonl")
130
264
  continue;
131
- for (const configuredRoot of source.roots) {
132
- const root = expandedActivityRoot(configuredRoot);
133
- const rootFiles = await listJSONLFiles(root);
134
- for (const file of rootFiles) {
135
- let canonicalPath;
136
- try {
137
- canonicalPath = await realpath(file.path);
138
- }
139
- catch (error) {
140
- if (isTransientSourceError(error))
141
- continue;
142
- throw error;
143
- }
144
- candidates.push({
145
- adapterId: source.adapterId,
146
- ...(source.sourceDisplayName === undefined
147
- ? {}
148
- : { sourceDisplayName: source.sourceDisplayName }),
149
- root,
150
- path: canonicalPath,
151
- modifiedMs: file.modifiedMs,
152
- });
153
- if (candidates.length > maxCursorEntries * 2) {
154
- candidates = reconciledSourceFiles(candidates);
155
- candidates.sort(recentFileFirst);
156
- candidates.length = maxCursorEntries;
157
- }
265
+ const key = sourceKey(source);
266
+ const sourceInitial = initialScan || this.#unbaselinedSources.has(key);
267
+ const listed = await this.#isolated(key, source.adapterId, sourceInitial, () => this.#listJSONLSource(source, canonicalPaths));
268
+ if (listed === undefined)
269
+ continue;
270
+ for (const file of listed) {
271
+ candidates.push(file);
272
+ if (sourceInitial && !initialScan)
273
+ baselinePaths.add(file.path);
274
+ if (candidates.length > maxCursorEntries * 2) {
275
+ candidates = reconciledSourceFiles(candidates);
276
+ candidates.sort(recentFileFirst);
277
+ candidates.length = maxCursorEntries;
158
278
  }
159
279
  }
160
280
  }
161
281
  this.#sourceCache = { key: cacheKey, at: Date.now(), files: candidates };
282
+ this.#canonicalPaths = canonicalPaths;
162
283
  }
163
284
  const recentCandidates = reconciledSourceFiles(candidates)
164
285
  .sort(recentFileFirst)
165
286
  .slice(0, maxCursorEntries);
166
- const initialScan = !this.#initialScanComplete;
167
287
  const selected = this.#mode === "history"
168
288
  ? await this.#pendingHistoryFiles(recentCandidates)
169
289
  : recentCandidates.slice(0, maxSourceFiles);
170
290
  const replayFiles = initialScan && this.#replayOnFirstStart
171
291
  ? newestFilesPerAdapter(selected, this.#initialReplaySessions)
172
292
  : new Set();
173
- if (initialScan && this.#mode === "live") {
293
+ if (this.#mode === "live" && (initialScan || baselinePaths.size > 0)) {
174
294
  for (const file of [...recentCandidates].reverse()) {
295
+ if (!initialScan && !baselinePaths.has(file.path))
296
+ continue;
175
297
  if (!replayFiles.has(file.path)) {
176
298
  try {
177
299
  await this.#baselineFile(file.root, file.path);
178
300
  }
179
301
  catch (error) {
180
- if (!isTransientSourceError(error))
181
- throw error;
302
+ if (!isTransientSourceError(error)) {
303
+ this.#recordFailure(fileKey(file.path), file.adapterId, error);
304
+ }
182
305
  }
183
306
  }
184
307
  }
@@ -190,18 +313,30 @@ export class JSONLSourceReader {
190
313
  const reservedDatabaseRecords = sources.some((source) => source.format === "opencode-sqlite")
191
314
  ? Math.floor(remainingRecords / 2)
192
315
  : 0;
316
+ const rootRealPaths = new Map();
193
317
  for (const file of selected) {
194
318
  if (remainingRecords <= reservedDatabaseRecords)
195
319
  break;
320
+ if (this.#unchangedLiveFile(file)) {
321
+ fileCount++;
322
+ continue;
323
+ }
324
+ if (this.#skipping(fileKey(file.path)))
325
+ continue;
196
326
  let result;
197
327
  try {
198
- result = await this.#readFile(file.adapterId, file.root, file.path, replayFiles.has(file.path), file.sourceDisplayName, remainingRecords - reservedDatabaseRecords);
328
+ const rootReal = rootRealPaths.get(file.root) ??
329
+ await realpath(file.root);
330
+ rootRealPaths.set(file.root, rootReal);
331
+ result = await this.#readFile(file.adapterId, rootReal, file.path, replayFiles.has(file.path), file.sourceDisplayName, remainingRecords - reservedDatabaseRecords);
199
332
  }
200
333
  catch (error) {
201
- if (isTransientSourceError(error))
202
- continue;
203
- throw error;
334
+ if (!isTransientSourceError(error)) {
335
+ this.#recordFailure(fileKey(file.path), file.adapterId, error);
336
+ }
337
+ continue;
204
338
  }
339
+ this.#failures.delete(fileKey(file.path));
205
340
  fileCount++;
206
341
  records.push(...result.records);
207
342
  malformed += result.malformed;
@@ -213,6 +348,105 @@ export class JSONLSourceReader {
213
348
  this.#completedHistoryFiles.set(file.path, file.modifiedMs);
214
349
  }
215
350
  }
351
+ let documentsComplete = true;
352
+ let documentFiles = 0;
353
+ const cursors = this.#cursors;
354
+ for (const source of sources) {
355
+ if (source.format !== "json-messages")
356
+ continue;
357
+ const key = sourceKey(source);
358
+ const sourceInitial = initialScan || this.#unbaselinedSources.has(key);
359
+ const documents = await this.#isolated(key, source.adapterId, sourceInitial, () => scanJSONDocumentSource({
360
+ adapterId: source.adapterId,
361
+ roots: source.roots.map(expandedActivityRoot),
362
+ pathPattern: source.pathPattern ?? "\\.json$",
363
+ messagesField: source.messagesField ?? "messages",
364
+ ...source.document,
365
+ }, {
366
+ cursors,
367
+ listings: this.#documentListings,
368
+ initialScan: sourceInitial,
369
+ replayOnFirstStart: this.#replayOnFirstStart,
370
+ replaySessions: this.#initialReplaySessions,
371
+ maxReplayRecords: Math.max(32, Math.floor(this.#initialReplayBytes / 1_024)),
372
+ maxRecords: Math.max(0, remainingRecords - reservedDatabaseRecords),
373
+ historical: this.#mode === "history",
374
+ ...(this.#lastRunAt === undefined ? {} : { newSince: this.#lastRunAt }),
375
+ }));
376
+ if (documents === undefined)
377
+ continue;
378
+ records.push(...documents.records);
379
+ fileCount += documents.files;
380
+ malformed += documents.malformed;
381
+ oversized += documents.oversized;
382
+ documentFiles += documents.files;
383
+ remainingRecords -= documents.records.length;
384
+ documentsComplete = documentsComplete && documents.complete;
385
+ }
386
+ for (const source of sources) {
387
+ if (source.format !== "goose-sqlite")
388
+ continue;
389
+ if (remainingRecords <= reservedDatabaseRecords) {
390
+ documentsComplete = false;
391
+ break;
392
+ }
393
+ // Loaded on demand for the same reason as the OpenCode source below.
394
+ const { scanGooseSQLiteSources } = await import("./goose-sqlite-source.js");
395
+ const key = sourceKey(source);
396
+ const sourceInitial = initialScan || this.#unbaselinedSources.has(key);
397
+ const goose = await this.#isolated(key, source.adapterId, sourceInitial, () => scanGooseSQLiteSources({
398
+ adapterId: source.adapterId,
399
+ roots: source.roots.map(expandedActivityRoot),
400
+ cursors,
401
+ initialScan: sourceInitial,
402
+ replayOnFirstStart: this.#replayOnFirstStart,
403
+ maxReplayRecords: Math.max(32, Math.floor(this.#initialReplayBytes / 1_024)),
404
+ maxRecords: remainingRecords - reservedDatabaseRecords,
405
+ historical: this.#mode === "history",
406
+ ...(this.#mode === "live" ? { connections: this.#gooseConnections } : { completedHistory: this.#completedHistoryDatabases }),
407
+ }));
408
+ if (goose === undefined)
409
+ continue;
410
+ records.push(...goose.records);
411
+ fileCount += goose.files;
412
+ malformed += goose.malformed;
413
+ oversized += goose.oversized;
414
+ documentFiles += goose.files;
415
+ remainingRecords -= goose.records.length;
416
+ documentsComplete = documentsComplete && goose.complete;
417
+ }
418
+ for (const source of sources) {
419
+ if (source.format !== "zed-sqlite")
420
+ continue;
421
+ if (remainingRecords <= reservedDatabaseRecords) {
422
+ documentsComplete = false;
423
+ break;
424
+ }
425
+ const { scanZedSQLiteSources } = await import("./zed-sqlite-source.js");
426
+ const key = sourceKey(source);
427
+ const sourceInitial = initialScan || this.#unbaselinedSources.has(key);
428
+ const zed = await this.#isolated(key, source.adapterId, sourceInitial, () => scanZedSQLiteSources({
429
+ adapterId: source.adapterId,
430
+ roots: source.roots.map(expandedActivityRoot),
431
+ cursors,
432
+ initialScan: sourceInitial,
433
+ replayOnFirstStart: this.#replayOnFirstStart,
434
+ replaySessions: this.#initialReplaySessions,
435
+ maxReplayRecords: Math.max(32, Math.floor(this.#initialReplayBytes / 1_024)),
436
+ maxRecords: remainingRecords - reservedDatabaseRecords,
437
+ historical: this.#mode === "history",
438
+ ...(this.#mode === "live" ? { connections: this.#zedConnections } : { completedHistory: this.#completedHistoryDatabases }),
439
+ }));
440
+ if (zed === undefined)
441
+ continue;
442
+ records.push(...zed.records);
443
+ fileCount += zed.files;
444
+ malformed += zed.malformed;
445
+ oversized += zed.oversized;
446
+ documentFiles += zed.files;
447
+ remainingRecords -= zed.records.length;
448
+ documentsComplete = documentsComplete && zed.complete;
449
+ }
216
450
  let sqliteComplete = true;
217
451
  let sqliteFiles = 0;
218
452
  for (const source of sources) {
@@ -226,15 +460,20 @@ export class JSONLSourceReader {
226
460
  // when an OpenCode data source is actually scanned so commands such as
227
461
  // pair, doctor, and help stay warning-free.
228
462
  const { scanOpenCodeSQLiteSources } = await import("./opencode-sqlite-source.js");
229
- const sqlite = await scanOpenCodeSQLiteSources({
463
+ const key = sourceKey(source);
464
+ const sourceInitial = initialScan || this.#unbaselinedSources.has(key);
465
+ const sqlite = await this.#isolated(key, source.adapterId, sourceInitial, () => scanOpenCodeSQLiteSources({
230
466
  adapterId: source.adapterId,
231
467
  roots: source.roots.map(expandedActivityRoot),
232
- cursors: this.#cursors,
233
- initialScan,
468
+ cursors,
469
+ initialScan: sourceInitial,
234
470
  replayOnFirstStart: this.#replayOnFirstStart,
235
471
  maxReplayRecords: Math.min(remainingRecords, Math.max(this.#mode === "history" ? 1 : 32, Math.floor(this.#initialReplayBytes / 1_024))),
236
472
  historical: this.#mode === "history",
237
- });
473
+ ...(this.#mode === "live" ? { connections: this.#openCodeConnections } : { completedHistory: this.#completedHistoryDatabases }),
474
+ }));
475
+ if (sqlite === undefined)
476
+ continue;
238
477
  records.push(...sqlite.records);
239
478
  fileCount += sqlite.files;
240
479
  malformed += sqlite.malformed;
@@ -243,23 +482,29 @@ export class JSONLSourceReader {
243
482
  remainingRecords -= sqlite.records.length;
244
483
  sqliteComplete = sqliteComplete && sqlite.complete;
245
484
  }
485
+ this.#openCodeConnections.retain(configuredRoots(sources, "opencode-sqlite"));
486
+ this.#gooseConnections.retain(configuredRoots(sources, "goose-sqlite"));
487
+ this.#zedConnections.retain(configuredRoots(sources, "zed-sqlite"));
246
488
  this.#initialScanComplete = true;
489
+ this.#cursors.settle(maxCursorEntries);
247
490
  const checkpoint = Object.freeze({
248
491
  generation: ++this.#generation,
249
492
  cursors: new Map(this.#cursors),
493
+ scannedAt,
250
494
  });
495
+ this.#scanStart = checkpoint.cursors;
251
496
  const jsonlComplete = this.#mode !== "history" || selected.length === 0;
252
497
  return {
253
498
  records,
254
499
  metrics: {
255
500
  files: fileCount,
256
- discoveredFiles: recentCandidates.length + sqliteFiles,
501
+ discoveredFiles: recentCandidates.length + sqliteFiles + documentFiles,
257
502
  records: records.length,
258
503
  malformed,
259
504
  oversized,
260
505
  },
261
506
  checkpoint,
262
- complete: jsonlComplete && sqliteComplete,
507
+ complete: jsonlComplete && sqliteComplete && documentsComplete,
263
508
  };
264
509
  }
265
510
  async commit(checkpoint) {
@@ -268,11 +513,135 @@ export class JSONLSourceReader {
268
513
  if (checkpoint.generation > this.#generation) {
269
514
  throw new Error("Activity source checkpoint is invalid.");
270
515
  }
271
- await this.#cursorStore.save(checkpoint.cursors);
516
+ // Saved once more when the time is missing, so the next start knows
517
+ // when this one ran. Later idle scans keep the time of the last change:
518
+ // a file read since then would have changed the cursors.
519
+ if (this.#savedCursors === undefined ||
520
+ this.#savedScannedAt === undefined ||
521
+ !sameCursors(checkpoint.cursors, this.#savedCursors)) {
522
+ await this.#cursorStore.save(checkpoint.cursors, checkpoint.scannedAt);
523
+ this.#savedCursors = checkpoint.cursors;
524
+ this.#savedScannedAt = checkpoint.scannedAt;
525
+ }
272
526
  this.#committedGeneration = checkpoint.generation;
273
527
  }
274
- async #readFile(adapterId, root, file, initialReplay, sourceDisplayName, maxRecords = Number.MAX_SAFE_INTEGER) {
275
- const rootReal = await realpath(root);
528
+ /**
529
+ * Where the next scan starts. A scan moves the cursors past every record
530
+ * it returns, before the caller delivers them; `rewind` puts the reader
531
+ * back here when they could not be delivered, so the next scan returns
532
+ * the same records instead of skipping them.
533
+ */
534
+ position() {
535
+ return {
536
+ cursors: this.#scanStart,
537
+ oversizedContinuations: new Set(this.#oversizedContinuations),
538
+ completedHistoryFiles: new Map(this.#completedHistoryFiles),
539
+ completedHistoryDatabases: new Map(this.#completedHistoryDatabases),
540
+ initialScanComplete: this.#initialScanComplete,
541
+ unbaselinedSources: new Set(this.#unbaselinedSources),
542
+ };
543
+ }
544
+ rewind(position) {
545
+ if (position.cursors === undefined) {
546
+ // Taken before the first scan: reload what is durable.
547
+ this.#cursors = undefined;
548
+ }
549
+ else {
550
+ this.#cursors = new RecentCursors();
551
+ for (const [key, offset] of position.cursors) {
552
+ this.#cursors.set(key, offset);
553
+ }
554
+ }
555
+ this.#scanStart = position.cursors;
556
+ this.#oversizedContinuations = new Set(position.oversizedContinuations);
557
+ this.#completedHistoryFiles = new Map(position.completedHistoryFiles);
558
+ this.#completedHistoryDatabases = new Map(position.completedHistoryDatabases);
559
+ this.#initialScanComplete = position.initialScanComplete;
560
+ this.#unbaselinedSources = new Set(position.unbaselinedSources);
561
+ }
562
+ /** Sources and files that failed and are skipped until `retryAt`. */
563
+ sourceFailures() {
564
+ return [...this.#failures.values()];
565
+ }
566
+ /**
567
+ * Run one source's scan so that its failure cannot fail the others. A
568
+ * failed source is recorded, skipped until its retry time, and its cursor
569
+ * changes are undone so none of its records is skipped. Returns undefined
570
+ * when the source was skipped or failed.
571
+ */
572
+ async #isolated(key, adapterId, baselines, scan) {
573
+ const remember = (missed) => {
574
+ if (this.#mode !== "live")
575
+ return;
576
+ if (missed && baselines)
577
+ this.#unbaselinedSources.add(key);
578
+ else if (!missed)
579
+ this.#unbaselinedSources.delete(key);
580
+ };
581
+ if (this.#skipping(key)) {
582
+ remember(true);
583
+ return undefined;
584
+ }
585
+ this.#cursors?.track();
586
+ try {
587
+ const result = await scan();
588
+ this.#cursors?.untrack(false);
589
+ this.#failures.delete(key);
590
+ remember(false);
591
+ return result;
592
+ }
593
+ catch (error) {
594
+ this.#cursors?.untrack(true);
595
+ this.#recordFailure(key, adapterId, error);
596
+ remember(true);
597
+ return undefined;
598
+ }
599
+ }
600
+ #skipping(key) {
601
+ const failure = this.#failures.get(key);
602
+ return failure !== undefined && this.#clock() < failure.retryAt.getTime();
603
+ }
604
+ #recordFailure(key, adapterId, error) {
605
+ const failures = (this.#failures.get(key)?.failures ?? 0) + 1;
606
+ const now = this.#clock();
607
+ this.#failures.delete(key);
608
+ this.#failures.set(key, Object.freeze({
609
+ adapterId,
610
+ failures,
611
+ message: (error instanceof Error ? error.message : String(error))
612
+ .replace(/\s+/gu, " ")
613
+ .slice(0, 200),
614
+ failedAt: new Date(now),
615
+ retryAt: new Date(now + Math.min(failureRetryMaxMs, failureRetryBaseMs * 2 ** Math.min(failures - 1, 16))),
616
+ }));
617
+ for (const oldest of this.#failures.keys()) {
618
+ if (this.#failures.size <= maxTrackedFailures)
619
+ break;
620
+ this.#failures.delete(oldest);
621
+ }
622
+ }
623
+ /**
624
+ * Close the database connections kept between scans. A later scan opens
625
+ * them again, so this only releases files, for example before shutdown.
626
+ */
627
+ close() {
628
+ this.#openCodeConnections.closeAll();
629
+ this.#gooseConnections.closeAll();
630
+ this.#zedConnections.closeAll();
631
+ }
632
+ /**
633
+ * A live file whose listed size equals its cursor has nothing to read:
634
+ * reading it would resolve, stat and find zero bytes. Its identity comes
635
+ * from the listing done in the same scan, so growth is never missed.
636
+ */
637
+ #unchangedLiveFile(file) {
638
+ if (this.#mode !== "live" || file.identity === undefined)
639
+ return false;
640
+ const cursorId = fileCursorId(file.path, file.identity);
641
+ return (this.#cursors?.get(cursorId) === file.identity.size &&
642
+ !this.#oversizedContinuations.has(cursorId));
643
+ }
644
+ async #readFile(adapterId, rootReal, file, initialReplay, sourceDisplayName, maxRecords = Number.MAX_SAFE_INTEGER) {
276
645
  const fileReal = await realpath(file);
277
646
  if (!isWithin(rootReal, fileReal)) {
278
647
  return { records: [], malformed: 0, oversized: 0, processedRecords: 0 };
@@ -293,34 +662,50 @@ export class JSONLSourceReader {
293
662
  (initialReplay ? this.#initialReplayBytes : maxReadBytesPerFile)));
294
663
  if (metadata.size < offset)
295
664
  offset = 0;
296
- const length = Math.min(metadata.size - offset, maxReadBytesPerFile, Math.max(maxLineBytes + 1, Math.min(1_024, maxRecords) * 16_384));
665
+ let length = Math.min(metadata.size - offset, maxReadBytesPerFile, Math.max(readWindowBytes + 1, Math.min(1_024, maxRecords) * 16_384));
297
666
  if (length <= 0) {
298
667
  this.#setCursor(cursorId, legacyCursorId, metadata.size);
668
+ this.#oversizedContinuations.delete(cursorId);
299
669
  return { records: [], malformed: 0, oversized: 0, processedRecords: 0 };
300
670
  }
301
- const handle = await open(fileReal, "r");
302
- let content;
303
- try {
304
- content = Buffer.alloc(length);
305
- const read = await handle.read(content, 0, length, offset);
306
- content = content.subarray(0, read.bytesRead);
307
- }
308
- finally {
309
- await handle.close();
671
+ // Starting mid-file, read one byte early: if it is a newline the first
672
+ // record starts exactly at the offset and must not be skipped as partial.
673
+ // A cursor left inside an oversized record also resumes after its end.
674
+ const continuingOversized = this.#oversizedContinuations.has(cursorId);
675
+ const resync = (knownOffset === undefined && offset > 0) || continuingOversized;
676
+ if (resync) {
677
+ offset -= 1;
678
+ length += 1;
310
679
  }
311
- if (knownOffset === undefined && offset > 0) {
680
+ let content = await readRange(fileReal, offset, length);
681
+ if (resync) {
312
682
  const firstNewline = content.indexOf(0x0a);
313
683
  if (firstNewline < 0) {
314
684
  this.#setCursor(cursorId, legacyCursorId, offset + content.length);
315
- return { records: [], malformed: 0, oversized: 1, processedRecords: 1 };
685
+ this.#rememberOversizedContinuation(cursorId);
686
+ return continuingOversized
687
+ ? { records: [], malformed: 0, oversized: 0, processedRecords: 1 }
688
+ : { records: [], malformed: 0, oversized: 1, processedRecords: 1 };
316
689
  }
690
+ this.#oversizedContinuations.delete(cursorId);
317
691
  offset += firstNewline + 1;
318
692
  content = content.subarray(firstNewline + 1);
319
693
  }
320
- const lastNewline = content.lastIndexOf(0x0a);
694
+ let lastNewline = content.lastIndexOf(0x0a);
695
+ if (lastNewline < 0) {
696
+ // One complete record can be larger than the normal read window, for
697
+ // example a tool result or a pasted screenshot. Widen once, bounded by
698
+ // the record limit, instead of discarding it as oversized.
699
+ const widened = Math.min(metadata.size - offset, maxLineBytes + 1);
700
+ if (widened > content.length) {
701
+ content = await readRange(fileReal, offset, widened);
702
+ lastNewline = content.lastIndexOf(0x0a);
703
+ }
704
+ }
321
705
  if (lastNewline < 0) {
322
706
  if (content.length > maxLineBytes) {
323
707
  this.#setCursor(cursorId, legacyCursorId, offset + content.length);
708
+ this.#rememberOversizedContinuation(cursorId);
324
709
  return { records: [], malformed: 0, oversized: 1, processedRecords: 1 };
325
710
  }
326
711
  return { records: [], malformed: 0, oversized: 0, processedRecords: 0 };
@@ -376,11 +761,71 @@ export class JSONLSourceReader {
376
761
  this.#setCursor(cursorId, legacyCursorId, offset + processedThrough);
377
762
  return { records, malformed, oversized, processedRecords };
378
763
  }
764
+ /** The files of one JSONL source, with their canonical paths. */
765
+ async #listJSONLSource(source, canonicalPaths) {
766
+ let files = [];
767
+ const pathPattern = source.pathPattern === undefined
768
+ ? undefined
769
+ : new RegExp(source.pathPattern, "u");
770
+ for (const configuredRoot of source.roots) {
771
+ const root = expandedActivityRoot(configuredRoot);
772
+ const rootFiles = await listJSONLFiles(root);
773
+ let rootReal;
774
+ for (const file of rootFiles) {
775
+ if (pathPattern !== undefined &&
776
+ !pathPattern.test(relative(root, file.path).split(sep).join("/"))) {
777
+ continue;
778
+ }
779
+ let canonicalPath;
780
+ try {
781
+ // The listing never follows links, so a listed file keeps its
782
+ // canonical path while its root resolves to the same place and
783
+ // the file keeps its identity. Resolve each file only once.
784
+ rootReal ??= await realpath(root);
785
+ const key = `${rootReal}\u0000${file.path}`;
786
+ const identity = fileIdentityKey(file.identity);
787
+ const known = this.#canonicalPaths.get(key);
788
+ canonicalPath =
789
+ known !== undefined && identity !== undefined &&
790
+ known.identity === identity
791
+ ? known.path
792
+ : await realpath(file.path);
793
+ if (identity !== undefined) {
794
+ canonicalPaths.set(key, { path: canonicalPath, identity });
795
+ }
796
+ }
797
+ catch (error) {
798
+ if (isTransientSourceError(error))
799
+ continue;
800
+ throw error;
801
+ }
802
+ files.push({
803
+ adapterId: source.adapterId,
804
+ ...(source.sourceDisplayName === undefined
805
+ ? {}
806
+ : { sourceDisplayName: source.sourceDisplayName }),
807
+ root,
808
+ path: canonicalPath,
809
+ modifiedMs: file.modifiedMs,
810
+ identity: file.identity,
811
+ });
812
+ if (files.length > maxCursorEntries * 2) {
813
+ files = reconciledSourceFiles(files);
814
+ files.sort(recentFileFirst);
815
+ files.length = maxCursorEntries;
816
+ }
817
+ }
818
+ }
819
+ return files;
820
+ }
379
821
  async #pendingHistoryFiles(files) {
380
822
  const pending = [];
381
823
  for (const file of files) {
382
824
  if (this.#completedHistoryFiles.get(file.path) === file.modifiedMs)
383
825
  continue;
826
+ // A failing file is retried later without holding the import open.
827
+ if (this.#skipping(fileKey(file.path)))
828
+ continue;
384
829
  try {
385
830
  const metadata = await stat(file.path);
386
831
  const offset = this.#cursors?.get(fileCursorId(file.path, metadata)) ??
@@ -395,12 +840,21 @@ export class JSONLSourceReader {
395
840
  }
396
841
  }
397
842
  catch (error) {
398
- if (!isTransientSourceError(error))
399
- throw error;
843
+ if (!isTransientSourceError(error)) {
844
+ this.#recordFailure(fileKey(file.path), file.adapterId, error);
845
+ }
400
846
  }
401
847
  }
402
848
  return pending;
403
849
  }
850
+ #rememberOversizedContinuation(cursorId) {
851
+ this.#oversizedContinuations.add(cursorId);
852
+ if (this.#oversizedContinuations.size <= maxSourceFiles)
853
+ return;
854
+ const oldest = this.#oversizedContinuations.values().next().value;
855
+ if (oldest !== undefined)
856
+ this.#oversizedContinuations.delete(oldest);
857
+ }
404
858
  #setCursor(cursorId, legacyCursorId, offset) {
405
859
  this.#cursors?.set(cursorId, offset);
406
860
  if (cursorId !== legacyCursorId) {
@@ -417,7 +871,11 @@ export class JSONLSourceReader {
417
871
  return;
418
872
  if (this.#cursors?.has(fileCursorId(fileReal, metadata)) || this.#cursors?.has(digest(fileReal)))
419
873
  return;
420
- this.#setCursor(fileCursorId(fileReal, metadata), digest(fileReal), metadata.size);
874
+ // A session that began while the observer was stopped is read from its
875
+ // start; any other file without a cursor starts at its end, as before.
876
+ this.#setCursor(fileCursorId(fileReal, metadata), digest(fileReal), this.#lastRunAt !== undefined && startedAfter(metadata, this.#lastRunAt)
877
+ ? 0
878
+ : metadata.size);
421
879
  }
422
880
  }
423
881
  export function expandedActivityRoot(root) {
@@ -470,7 +928,16 @@ async function listJSONLFiles(root) {
470
928
  basename(entry.name).toLowerCase().endsWith(".jsonl")) {
471
929
  try {
472
930
  const metadata = await stat(path);
473
- result.push({ path, modifiedMs: metadata.mtimeMs });
931
+ result.push({
932
+ path,
933
+ modifiedMs: metadata.mtimeMs,
934
+ identity: {
935
+ size: metadata.size,
936
+ dev: metadata.dev,
937
+ ino: metadata.ino,
938
+ birthtimeMs: metadata.birthtimeMs,
939
+ },
940
+ });
474
941
  if (result.length > maxCursorEntries * 2) {
475
942
  result.sort(recentFileFirst);
476
943
  result.length = maxCursorEntries;
@@ -491,6 +958,17 @@ async function listJSONLFiles(root) {
491
958
  await walk(root, 0);
492
959
  return result.sort(recentFileFirst).slice(0, maxCursorEntries);
493
960
  }
961
+ async function readRange(path, position, length) {
962
+ const handle = await open(path, "r");
963
+ try {
964
+ const content = Buffer.alloc(length);
965
+ const read = await handle.read(content, 0, length, position);
966
+ return content.subarray(0, read.bytesRead);
967
+ }
968
+ finally {
969
+ await handle.close();
970
+ }
971
+ }
494
972
  function recentFileFirst(left, right) {
495
973
  return (right.modifiedMs - left.modifiedMs || left.path.localeCompare(right.path));
496
974
  }
@@ -546,6 +1024,9 @@ function reconciledSourceFile(claims) {
546
1024
  root: representative.root,
547
1025
  path: representative.path,
548
1026
  modifiedMs: Math.max(...ordered.map((claim) => claim.modifiedMs)),
1027
+ ...(representative.identity === undefined
1028
+ ? {}
1029
+ : { identity: representative.identity }),
549
1030
  };
550
1031
  }
551
1032
  function compareSourceClaim(left, right) {
@@ -553,6 +1034,24 @@ function compareSourceClaim(left, right) {
553
1034
  (left.sourceDisplayName ?? "").localeCompare(right.sourceDisplayName ?? "") ||
554
1035
  left.root.localeCompare(right.root);
555
1036
  }
1037
+ function fileIdentityKey(identity) {
1038
+ if (identity === undefined || (identity.ino <= 0 && identity.dev <= 0)) {
1039
+ return undefined;
1040
+ }
1041
+ return `${identity.dev}:${identity.ino}:${identity.birthtimeMs}`;
1042
+ }
1043
+ /**
1044
+ * Whether saved `cursors` are at the end of the file at `path`, so every
1045
+ * record in it was read. A file that grew or was replaced since does not
1046
+ * count.
1047
+ */
1048
+ export async function fileReadThrough(path, cursors) {
1049
+ const fileReal = await realpath(path);
1050
+ const metadata = await stat(fileReal);
1051
+ const offset = cursors.get(fileCursorId(fileReal, metadata)) ??
1052
+ cursors.get(digest(fileReal));
1053
+ return offset === metadata.size;
1054
+ }
556
1055
  function fileCursorId(path, metadata) {
557
1056
  const identity = metadata.ino > 0 || metadata.dev > 0
558
1057
  ? `inode:${metadata.dev}:${metadata.ino}:${metadata.birthtimeMs}`
@@ -573,6 +1072,24 @@ function newestFilesPerAdapter(files, limit) {
573
1072
  }
574
1073
  return selected;
575
1074
  }
1075
+ function configuredRoots(sources, format) {
1076
+ return new Set(sources.flatMap((source) => source.format === format ? source.roots.map(expandedActivityRoot) : []));
1077
+ }
1078
+ function sourceKey(source) {
1079
+ return JSON.stringify([source.format ?? "jsonl", source.adapterId, source.roots, source.pathPattern ?? ""]);
1080
+ }
1081
+ function fileKey(path) {
1082
+ return `file\u0000${path}`;
1083
+ }
1084
+ function sameCursors(left, right) {
1085
+ if (left.size !== right.size)
1086
+ return false;
1087
+ for (const [key, offset] of left) {
1088
+ if (right.get(key) !== offset)
1089
+ return false;
1090
+ }
1091
+ return true;
1092
+ }
576
1093
  function isWithin(root, candidate) {
577
1094
  const path = relative(resolve(root), resolve(candidate));
578
1095
  return path === "" || (!path.startsWith(`..${sep}`) && path !== "..");
@@ -589,6 +1106,7 @@ function isTransientSourceError(error) {
589
1106
  error.code === "EACCES" ||
590
1107
  error.code === "EPERM" ||
591
1108
  error.code === "ENOTDIR" ||
1109
+ error.code === "EBUSY" ||
592
1110
  error.code === "ESTALE"));
593
1111
  }
594
1112
  //# sourceMappingURL=source-reader.js.map