@platless/axom-cli 0.1.0 → 0.5.9

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 (281) hide show
  1. package/README.md +1 -1
  2. package/dist/authScopes.js +19 -0
  3. package/dist/axom-patch/cli/README.md +132 -0
  4. package/dist/axom-patch/cli/account-limits/README.md +27 -0
  5. package/dist/axom-patch/cli/account-limits/antigravity-proxy.mjs +47 -0
  6. package/dist/axom-patch/cli/account-limits/antigravity-read.mjs +90 -0
  7. package/dist/axom-patch/cli/account-limits/antigravity.mjs +46 -0
  8. package/dist/axom-patch/cli/account-limits/generate.py +120 -0
  9. package/dist/axom-patch/cli/account-limits/kimi-proxy.mjs +64 -0
  10. package/dist/axom-patch/cli/account-limits/normalize.mjs +151 -0
  11. package/dist/axom-patch/cli/claude-account-limits.json +37 -0
  12. package/dist/axom-patch/cli/codex-account-limits.json +37 -0
  13. package/dist/axom-patch/cli/commandcode-account-limits.json +30 -0
  14. package/dist/axom-patch/cli/nightly-task.md +152 -0
  15. package/dist/axom-patch/cli/pi-steering.json +37 -0
  16. package/dist/axom-patch/cli/pi-subagent-subwork.json +23 -0
  17. package/dist/axom-patch/cli/probes/claude-late-async-terminal.mjs +38 -0
  18. package/dist/axom-patch/cli/probes/pi-steering.mjs +80 -0
  19. package/dist/axom-patch/cli/probes/pi-subagent-subwork.mjs +211 -0
  20. package/dist/axom-patch/cli/probes/qoder-steering.mjs +89 -0
  21. package/dist/axom-patch/cli/probes/qoder-subagent-origin.mjs +61 -0
  22. package/dist/axom-patch/cli/probes/zcode-subagent-subwork.mjs +257 -0
  23. package/dist/axom-patch/cli/qoder-background-terminal.md +82 -0
  24. package/dist/axom-patch/cli/qoder-foreground-output.md +42 -0
  25. package/dist/axom-patch/cli/qoder-steering.json +52 -0
  26. package/dist/axom-patch/cli/qoder-steering.md +30 -0
  27. package/dist/axom-patch/cli/qoder-subagent-origin.json +73 -0
  28. package/dist/axom-patch/cli/qoder-subagent-subwork.md +73 -0
  29. package/dist/axom-patch/cli/runtime.json +63 -0
  30. package/dist/axom-patch/cli/steering/claude-steering-js.patch +89 -0
  31. package/dist/axom-patch/cli/steering/claude-steering-types.patch +28 -0
  32. package/dist/axom-patch/cli/steering/codex-steering.patch +57 -0
  33. package/dist/axom-patch/cli/steering/pi-steering.js +79 -0
  34. package/dist/axom-patch/cli/zcode-account-limits.json +31 -0
  35. package/dist/axom-patch/cli/zcode-models.json +38 -0
  36. package/dist/axom-patch/cli/zcode-subagent-subwork.json +73 -0
  37. package/dist/cli.js +36 -1
  38. package/dist/commands/auth.js +4 -13
  39. package/dist/commands/execute.js +4 -4
  40. package/dist/commands/record.js +6 -6
  41. package/dist/commands/struct.js +4 -4
  42. package/dist/commands/workstation.js +2 -2
  43. package/dist/credentials.js +2 -2
  44. package/dist/install/claude/commands.js +13 -13
  45. package/dist/keepers/acp/keeper.mjs +3083 -0
  46. package/dist/keepers/acp/manifest.json +9 -0
  47. package/dist/keepers/terminal/keeper.mjs +8469 -0
  48. package/dist/keepers/terminal/manifest.json +9 -0
  49. package/dist/keepers/terminal/node_modules/node-pty/lib/conpty_console_list_agent.js +25 -0
  50. package/dist/keepers/terminal/node_modules/node-pty/lib/eventEmitter2.js +47 -0
  51. package/dist/keepers/terminal/node_modules/node-pty/lib/index.js +52 -0
  52. package/dist/keepers/terminal/node_modules/node-pty/lib/interfaces.js +7 -0
  53. package/dist/keepers/terminal/node_modules/node-pty/lib/shared/conout.js +10 -0
  54. package/dist/keepers/terminal/node_modules/node-pty/lib/terminal.js +190 -0
  55. package/dist/keepers/terminal/node_modules/node-pty/lib/types.js +7 -0
  56. package/dist/keepers/terminal/node_modules/node-pty/lib/unixTerminal.js +351 -0
  57. package/dist/keepers/terminal/node_modules/node-pty/lib/utils.js +38 -0
  58. package/dist/keepers/terminal/node_modules/node-pty/lib/windowsConoutConnection.js +131 -0
  59. package/dist/keepers/terminal/node_modules/node-pty/lib/windowsPtyAgent.js +344 -0
  60. package/dist/keepers/terminal/node_modules/node-pty/lib/windowsTerminal.js +209 -0
  61. package/dist/keepers/terminal/node_modules/node-pty/lib/worker/conoutSocketWorker.js +22 -0
  62. package/dist/keepers/terminal/node_modules/node-pty/package.json +66 -0
  63. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-arm64/pty.node +0 -0
  64. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-arm64/spawn-helper +0 -0
  65. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-x64/pty.node +0 -0
  66. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-x64/spawn-helper +0 -0
  67. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/linux-arm64/pty.node +0 -0
  68. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/linux-x64/pty.node +0 -0
  69. package/dist/mcp.js +32 -14
  70. package/dist/runtime-deps/qoder-sdk/package-lock.json +1189 -0
  71. package/dist/runtime-deps/qoder-sdk/package.json +8 -0
  72. package/dist/test/keeperKit.js +67 -0
  73. package/dist/workstation/acp/acpAgents.js +2514 -681
  74. package/dist/workstation/acp/acpClient.js +95 -318
  75. package/dist/workstation/acp/activityBusy.js +43 -0
  76. package/dist/workstation/acp/activityReporting.js +54 -0
  77. package/dist/workstation/acp/adoption.js +28 -356
  78. package/dist/workstation/acp/authUrlSniffer.js +36 -0
  79. package/dist/workstation/acp/binaryInstall.js +100 -27
  80. package/dist/workstation/acp/builds/gc.js +245 -0
  81. package/dist/workstation/acp/builds/install/binary.js +28 -0
  82. package/dist/workstation/acp/builds/install/npm.js +235 -0
  83. package/dist/workstation/acp/builds/install/uv.js +254 -0
  84. package/dist/workstation/acp/builds/migrate.js +195 -0
  85. package/dist/workstation/acp/builds/model.js +128 -0
  86. package/dist/workstation/acp/builds/nodeGate.js +100 -0
  87. package/dist/workstation/acp/builds/store.js +352 -0
  88. package/dist/workstation/acp/commandTableProbe.js +68 -0
  89. package/dist/workstation/acp/configOptions.js +103 -1
  90. package/dist/workstation/acp/delegationMcp.js +24 -0
  91. package/dist/workstation/acp/envSecrets.js +27 -0
  92. package/dist/workstation/acp/initializeResponse.js +285 -0
  93. package/dist/workstation/acp/installErrors.js +12 -2
  94. package/dist/workstation/acp/installProbe.js +1 -1
  95. package/dist/workstation/acp/managedPatches.js +144 -0
  96. package/dist/workstation/acp/outbox.js +330 -0
  97. package/dist/workstation/acp/patchAssets.js +80 -0
  98. package/dist/workstation/acp/permission.js +335 -37
  99. package/dist/workstation/acp/processor/checkpoint.js +87 -0
  100. package/dist/workstation/acp/processor/commands.js +35 -0
  101. package/dist/workstation/acp/processor/control.js +355 -0
  102. package/dist/workstation/acp/processor/emit.js +26 -0
  103. package/dist/workstation/acp/processor/jsonrpc.js +172 -0
  104. package/dist/workstation/acp/processor/memoryLog.js +49 -0
  105. package/dist/workstation/acp/processor/origins.js +48 -0
  106. package/dist/workstation/acp/processor/output.js +67 -0
  107. package/dist/workstation/acp/processor/projection.js +42 -0
  108. package/dist/workstation/acp/processor/record.js +128 -0
  109. package/dist/workstation/acp/processor/runtime.js +181 -0
  110. package/dist/workstation/acp/processor/sdkSchemas.js +61 -0
  111. package/dist/workstation/acp/processor/session.js +525 -0
  112. package/dist/workstation/acp/processor/sideChannel.js +80 -0
  113. package/dist/workstation/acp/processor/state.js +74 -0
  114. package/dist/workstation/acp/processor/step.js +459 -0
  115. package/dist/workstation/acp/processor/test/fixtureLog.js +65 -0
  116. package/dist/workstation/acp/processor/test/harness.js +120 -0
  117. package/dist/workstation/acp/processor/test/largeIdEcho.js +50 -0
  118. package/dist/workstation/acp/processor/test/rawAgent.js +70 -0
  119. package/dist/workstation/acp/processor/transport/generations.js +76 -0
  120. package/dist/workstation/acp/processor/transport/keeper.js +130 -0
  121. package/dist/workstation/acp/processor/transport/local.js +269 -0
  122. package/dist/workstation/acp/processor/transport/types.js +78 -0
  123. package/dist/workstation/acp/processor/updates.js +364 -0
  124. package/dist/workstation/acp/processorSession.js +1512 -0
  125. package/dist/workstation/acp/qoderStartup.js +45 -0
  126. package/dist/workstation/acp/registry/catalog.js +385 -4
  127. package/dist/workstation/acp/registry/detect.js +55 -0
  128. package/dist/workstation/acp/registry/launch.js +61 -119
  129. package/dist/workstation/acp/sessionUpdateMapper.js +56 -27
  130. package/dist/workstation/acp/steering.js +58 -0
  131. package/dist/workstation/acp/switching.js +114 -0
  132. package/dist/workstation/acp/terminalLogin.js +171 -0
  133. package/dist/workstation/acp/terminalLoginSpec.js +41 -0
  134. package/dist/workstation/acp/test/adapterFixtures.js +256 -0
  135. package/dist/workstation/acp/test/daemonLife.js +106 -0
  136. package/dist/workstation/acp/test/fakeAgent.js +185 -9
  137. package/dist/workstation/acp/test/keeperWorld.js +267 -0
  138. package/dist/workstation/acp/test/memoryOutbox.js +55 -0
  139. package/dist/workstation/acp/test/raw/fixtures.js +196 -0
  140. package/dist/workstation/acp/test/raw/qoderCaptures.js +125 -0
  141. package/dist/workstation/acp/test/raw/sanitize.js +344 -0
  142. package/dist/workstation/acp/test/raw/sources.js +249 -0
  143. package/dist/workstation/acp/test/realAdapter.js +377 -0
  144. package/dist/workstation/acp/test/scriptAgent.js +206 -0
  145. package/dist/workstation/acp/test/sessionFrames.js +42 -0
  146. package/dist/workstation/acp/timedCommand.js +4 -2
  147. package/dist/workstation/acp/uvxInstall.js +72 -122
  148. package/dist/workstation/acp/validate.js +33 -5
  149. package/dist/workstation/agentFlow.js +103 -0
  150. package/dist/workstation/agentSessions.js +1686 -348
  151. package/dist/workstation/channels/agent.js +14 -6
  152. package/dist/workstation/channels/computer.js +203 -7
  153. package/dist/workstation/channels/computerExecs.js +200 -0
  154. package/dist/workstation/channels/management.js +86 -6
  155. package/dist/workstation/config.js +3 -1
  156. package/dist/workstation/connection/encryptedChannel.js +88 -21
  157. package/dist/workstation/daemon.js +309 -6
  158. package/dist/workstation/daemonctl.js +28 -2
  159. package/dist/workstation/desktop/binding.js +36 -0
  160. package/dist/workstation/desktop/bridge.js +212 -0
  161. package/dist/workstation/desktop/flow-control.js +52 -0
  162. package/dist/workstation/desktop/focused-text.js +130 -0
  163. package/dist/workstation/desktop/protocol.js +35 -0
  164. package/dist/workstation/exec/attach.js +126 -0
  165. package/dist/workstation/exec/command.js +40 -0
  166. package/dist/workstation/exec/durable.js +517 -0
  167. package/dist/workstation/exec/fallback.js +106 -0
  168. package/dist/workstation/exec/policy.js +837 -0
  169. package/dist/workstation/exec/shaping.js +211 -0
  170. package/dist/workstation/exec.js +3 -112
  171. package/dist/workstation/files/adapters/computerFileFrames.js +51 -0
  172. package/dist/workstation/files/adapters/toolRequests.js +121 -0
  173. package/dist/workstation/files/client.js +455 -0
  174. package/dist/workstation/files/errors.js +28 -0
  175. package/dist/workstation/files/fence.js +350 -0
  176. package/dist/workstation/files/find/fileIndex.js +153 -0
  177. package/dist/workstation/files/find/fuzzy.js +155 -0
  178. package/dist/workstation/files/host.js +122 -0
  179. package/dist/workstation/files/hostProtocol.js +2 -0
  180. package/dist/workstation/files/media/cache.js +620 -0
  181. package/dist/workstation/files/media/encoding.js +308 -0
  182. package/dist/workstation/files/media/estimator.js +100 -0
  183. package/dist/workstation/files/media/media.js +325 -0
  184. package/dist/workstation/files/media/packedAudio.js +23 -0
  185. package/dist/workstation/files/media/scheduler.js +551 -0
  186. package/dist/workstation/files/media/types.js +5 -0
  187. package/dist/workstation/files/scope.js +45 -0
  188. package/dist/workstation/files/structured.js +284 -0
  189. package/dist/workstation/files/text.js +356 -0
  190. package/dist/workstation/files/uploads.js +351 -0
  191. package/dist/workstation/journal.js +65 -236
  192. package/dist/workstation/keepers/acp/client.js +101 -0
  193. package/dist/workstation/keepers/acp/envelope.js +62 -0
  194. package/dist/workstation/keepers/acp/host.js +419 -0
  195. package/dist/workstation/keepers/acp/main.js +18 -0
  196. package/dist/workstation/keepers/acp/memoryStream.js +121 -0
  197. package/dist/workstation/keepers/acp/params.js +137 -0
  198. package/dist/workstation/keepers/acp/protocol.js +66 -0
  199. package/dist/workstation/keepers/acp/recover.js +120 -0
  200. package/dist/workstation/keepers/acp/session.js +584 -0
  201. package/dist/workstation/keepers/acp/stderr.js +154 -0
  202. package/dist/workstation/keepers/kit/activity.js +103 -0
  203. package/dist/workstation/keepers/kit/bootstrap.js +164 -0
  204. package/dist/workstation/keepers/kit/buildVersion.js +51 -0
  205. package/dist/workstation/keepers/kit/bundle.js +120 -0
  206. package/dist/workstation/keepers/kit/client.js +432 -0
  207. package/dist/workstation/keepers/kit/identity.js +21 -0
  208. package/dist/workstation/keepers/kit/ipc.js +112 -0
  209. package/dist/workstation/keepers/kit/launchConfig.js +8 -0
  210. package/dist/workstation/keepers/kit/launcher.js +249 -0
  211. package/dist/workstation/keepers/kit/lock.js +123 -0
  212. package/dist/workstation/keepers/kit/orphanSweep.js +86 -0
  213. package/dist/workstation/keepers/kit/paths.js +75 -0
  214. package/dist/workstation/keepers/kit/protocol.js +43 -0
  215. package/dist/workstation/keepers/kit/server.js +518 -0
  216. package/dist/workstation/keepers/kit/streamLog.js +697 -0
  217. package/dist/workstation/keepers/legacy.js +296 -0
  218. package/dist/workstation/keepers/policy.js +576 -0
  219. package/dist/workstation/keepers/stop.js +34 -0
  220. package/dist/workstation/keepers/terminal/client.js +213 -0
  221. package/dist/workstation/keepers/terminal/exec.js +981 -0
  222. package/dist/workstation/keepers/terminal/generations.js +129 -0
  223. package/dist/workstation/keepers/terminal/keeper.js +62 -0
  224. package/dist/workstation/keepers/terminal/main.js +43 -0
  225. package/dist/workstation/keepers/terminal/orphanSweep.js +3 -0
  226. package/dist/workstation/keepers/terminal/params.js +27 -0
  227. package/dist/workstation/keepers/terminal/protocol.js +56 -0
  228. package/dist/workstation/keepers/terminal/pty.js +330 -0
  229. package/dist/workstation/oauth/deviceAuthorization.js +21 -12
  230. package/dist/workstation/protocol/accountLimits.js +47 -0
  231. package/dist/workstation/protocol/agent.js +201 -8
  232. package/dist/workstation/protocol/common.js +36 -0
  233. package/dist/workstation/protocol/computer.js +107 -7
  234. package/dist/workstation/protocol/management.js +50 -2
  235. package/dist/workstation/qoderAdapter/backgroundProcess.js +118 -0
  236. package/dist/workstation/qoderAdapter/backgroundTerminal.js +193 -0
  237. package/dist/workstation/qoderAdapter/backgroundTerminalFold.js +372 -0
  238. package/dist/workstation/qoderAdapter/callbackContext.js +76 -0
  239. package/dist/workstation/qoderAdapter/completedContinuation.js +139 -0
  240. package/dist/workstation/qoderAdapter/contract.js +78 -0
  241. package/dist/workstation/qoderAdapter/foregroundOutput.js +36 -0
  242. package/dist/workstation/qoderAdapter/history.js +85 -0
  243. package/dist/workstation/qoderAdapter/identityIndex.js +191 -0
  244. package/dist/workstation/qoderAdapter/inputQueue.js +42 -0
  245. package/dist/workstation/qoderAdapter/main.js +95 -0
  246. package/dist/workstation/qoderAdapter/mapper.js +117 -0
  247. package/dist/workstation/qoderAdapter/observerPlugin.js +245 -0
  248. package/dist/workstation/qoderAdapter/observerReceiver.js +187 -0
  249. package/dist/workstation/qoderAdapter/permissions.js +61 -0
  250. package/dist/workstation/qoderAdapter/profile.js +93 -0
  251. package/dist/workstation/qoderAdapter/sdkDriver.js +48 -0
  252. package/dist/workstation/qoderAdapter/sdkRuntime.js +93 -0
  253. package/dist/workstation/qoderAdapter/sdkRuntimeInstall.js +145 -0
  254. package/dist/workstation/qoderAdapter/session.js +319 -0
  255. package/dist/workstation/qoderAdapter/subagentMeta.js +28 -0
  256. package/dist/workstation/qoderAdapter/subwork.js +85 -0
  257. package/dist/workstation/qoderAdapter/taskControl.js +38 -0
  258. package/dist/workstation/qoderAdapter/taskLedger.js +163 -0
  259. package/dist/workstation/qoderAdapter/types.js +1 -0
  260. package/dist/workstation/qoderAdapter/usage.js +37 -0
  261. package/dist/workstation/register.js +4 -0
  262. package/dist/workstation/systemMetrics.js +110 -1
  263. package/dist/workstation/tools/browser.js +394 -0
  264. package/dist/workstation/tools/client.js +277 -0
  265. package/dist/workstation/tools/info.js +50 -0
  266. package/dist/workstation/tools/keeperProcess.js +124 -0
  267. package/dist/workstation/tools/router.js +55 -0
  268. package/dist/workstation/tools/terminalRouter.js +222 -0
  269. package/dist/workstation/tools/validation.js +86 -0
  270. package/dist/workstation/update/coordinator.js +32 -0
  271. package/dist/workstation/update/npmInstaller.js +2 -0
  272. package/dist/workstation/update/ptyPreflight.js +45 -0
  273. package/dist/workstation/update/taskRegistry.js +141 -23
  274. package/dist/workstation/update/watchdog.js +66 -0
  275. package/package.json +20 -7
  276. package/skills/axom/SKILL.md +55 -17
  277. package/skills/axom-definition/SKILL.md +32 -32
  278. package/skills/axom-platform/SKILL.md +39 -39
  279. package/dist/workstation/acp/acpSession.js +0 -1047
  280. package/dist/workstation/acp/contract.js +0 -494
  281. package/dist/workstation/acp/draftInterceptor.js +0 -154
@@ -0,0 +1,351 @@
1
+ import { constants, lstatSync, linkSync, promises as fs, renameSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import { pinned } from './fence.js';
5
+ /** Staging entries and reserved names start with this; `files.list` / `files.find` hide them. */
6
+ export const TEMP_PREFIX = '.axom-upload-';
7
+ export const MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
8
+ export const MAX_UPLOADS = 8;
9
+ export const MAX_UPLOAD_CHUNKS = 16384;
10
+ export const UPLOAD_TTL_MS = 15 * 60_000;
11
+ const same = (stat, identity) => stat.dev === identity.dev && stat.ino === identity.ino;
12
+ /**
13
+ * Upload sessions: one opened temporary file per upload inside a private 0700 staging directory beneath a trusted root
14
+ * on the destination's filesystem, per-block SHA-256 receipts, a private manifest per upload so `recover()` can remove
15
+ * orphans after a crash, and a synchronous identity-checked commit (exclusive hard link, or rename for overwrite).
16
+ * Every method that names a destination runs inside the caller's `pinned()` section on the destination parent.
17
+ */
18
+ export class UploadStore {
19
+ roots;
20
+ ttl;
21
+ stateDir;
22
+ uploads = new Map();
23
+ sweeping;
24
+ constructor(roots, ttl = UPLOAD_TTL_MS, stateDir) {
25
+ this.roots = roots;
26
+ this.ttl = ttl;
27
+ this.stateDir = stateDir;
28
+ }
29
+ /** Creates the private manifest directory (0700, owned by this uid, not a symlink). */
30
+ static async prepareStateDir(stateDir) {
31
+ await fs.mkdir(stateDir, { recursive: true, mode: 0o700 });
32
+ const stat = await fs.lstat(stateDir);
33
+ if (!stat.isDirectory() || stat.isSymbolicLink() || (process.getuid && stat.uid !== process.getuid()))
34
+ throw new Error('Unsafe upload state directory');
35
+ await fs.chmod(stateDir, 0o700);
36
+ }
37
+ /** Uploads in flight (the daemon samples this as activity). */
38
+ get size() { return this.uploads.size; }
39
+ get(id) { return this.uploads.get(id); }
40
+ /** Looks an upload up for a client call and refreshes its idle timer. */
41
+ use(id) {
42
+ const upload = this.uploads.get(id);
43
+ if (!upload)
44
+ throw new Error('Upload not found or expired');
45
+ upload.touched = Date.now();
46
+ return upload;
47
+ }
48
+ /** Removes temporary files that a crashed process left behind, as recorded in the private manifests. */
49
+ async recover(resolve) {
50
+ if (!this.stateDir)
51
+ return;
52
+ for (const name of await fs.readdir(this.stateDir)) {
53
+ if (!/^[0-9a-f-]{36}\.json$/.test(name))
54
+ continue;
55
+ const manifest = path.join(this.stateDir, name);
56
+ try {
57
+ const stat = await fs.lstat(manifest);
58
+ if (!stat.isFile() || stat.size > 8192)
59
+ continue;
60
+ const value = JSON.parse(await fs.readFile(manifest, 'utf8'));
61
+ if (typeof value.dir !== 'string' || path.basename(value.dir) !== TEMP_PREFIX + name.slice(0, -5) || value.temp !== path.join(value.dir, 'payload'))
62
+ throw new Error('Invalid orphan upload');
63
+ await resolve(value.dir, false, true);
64
+ const directory = await fs.lstat(value.dir);
65
+ if (!directory.isDirectory() || !same(directory, value.dirIdentity))
66
+ throw new Error('Orphan upload directory changed');
67
+ await pinned(value.dir, async () => {
68
+ for (const entry of await fs.readdir('.')) {
69
+ if (entry !== 'payload' && !/^commit-[0-9a-f-]{36}$/.test(entry))
70
+ continue;
71
+ const item = await fs.lstat(entry);
72
+ if (item.isFile() && same(item, value.identity))
73
+ await fs.unlink(entry);
74
+ }
75
+ });
76
+ await fs.rmdir(value.dir);
77
+ }
78
+ catch { /* Replaced entries are never followed or deleted during cleanup. */ }
79
+ await fs.unlink(manifest).catch(() => { });
80
+ }
81
+ }
82
+ /** Staging names must live beneath a root protected from other-UID rename/write. The destination
83
+ * parent remains kernel-pinned; staging can be on any allowed root on that same filesystem. */
84
+ async stagingRoot(dev, extraRoot) {
85
+ const uid = process.getuid?.();
86
+ for (const root of [...this.roots, ...(extraRoot ? [extraRoot] : [])]) {
87
+ const rootStat = await fs.lstat(root);
88
+ if (!rootStat.isDirectory() || rootStat.dev !== dev || rootStat.uid !== uid || (rootStat.mode & 0o022))
89
+ continue;
90
+ let child = root;
91
+ let childStat = rootStat;
92
+ let trusted = true;
93
+ while (path.dirname(child) !== child) {
94
+ const parent = path.dirname(child);
95
+ const stat = await fs.lstat(parent);
96
+ if (!stat.isDirectory() || ((stat.mode & 0o022) && !((stat.mode & 0o1000) && childStat.uid === uid))) {
97
+ trusted = false;
98
+ break;
99
+ }
100
+ child = parent;
101
+ childStat = stat;
102
+ }
103
+ if (trusted)
104
+ return root;
105
+ }
106
+ throw new Error('Upload requires a trusted private staging root on the destination filesystem');
107
+ }
108
+ async verify(upload) {
109
+ const directory = await fs.lstat(upload.dir);
110
+ if (!directory.isDirectory() || !same(directory, upload.dirIdentity) || (directory.mode & 0o077))
111
+ throw new Error('Upload staging directory changed');
112
+ const opened = await upload.handle.stat();
113
+ const named = await fs.lstat(upload.temp);
114
+ if (!opened.isFile() || opened.size !== upload.size || !named.isFile() || !same(named, opened))
115
+ throw new Error('Upload temporary file changed');
116
+ for (const [offset, receipt] of upload.chunks) {
117
+ const data = Buffer.alloc(receipt.length);
118
+ let read = 0;
119
+ while (read < data.length) {
120
+ const result = await upload.handle.read(data, read, data.length - read, offset + read);
121
+ if (!result.bytesRead)
122
+ throw new Error('Upload integrity changed');
123
+ read += result.bytesRead;
124
+ }
125
+ if (createHash('sha256').update(data).digest('hex') !== receipt.digest)
126
+ throw new Error('Upload integrity changed');
127
+ }
128
+ const after = await upload.handle.stat();
129
+ if (after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs)
130
+ throw new Error('Upload integrity changed during verification');
131
+ return after;
132
+ }
133
+ /** Runs inside `pinned(dirname(target))`; `resolveTarget` is the caller's configured-roots resolution of the path. */
134
+ async begin(pin, resolveTarget, size, overwrite, filesystem = false, reportedPath) {
135
+ if (this.uploads.size >= MAX_UPLOADS)
136
+ throw new Error('Upload limit exceeded');
137
+ const target = await resolveTarget();
138
+ if (path.basename(target).startsWith(TEMP_PREFIX))
139
+ throw new Error('Reserved upload filename');
140
+ let existing;
141
+ try {
142
+ existing = await fs.lstat(pin.leaf(target));
143
+ }
144
+ catch (error) {
145
+ if (error.code !== 'ENOENT')
146
+ throw error;
147
+ }
148
+ if (existing && (overwrite !== true || !existing.isFile()))
149
+ throw new Error('Target already exists');
150
+ const inPlace = filesystem && !!existing && overwrite;
151
+ if (inPlace)
152
+ await fs.access(pin.leaf(target), constants.W_OK);
153
+ const id = randomUUID();
154
+ const parent = await fs.stat('.');
155
+ const root = await this.stagingRoot(inPlace ? (await fs.stat(this.roots[0])).dev : parent.dev, filesystem ? path.dirname(target) : undefined);
156
+ const dir = path.join(root, `${TEMP_PREFIX}${id}`);
157
+ const temp = path.join(dir, 'payload');
158
+ const manifest = this.stateDir ? path.join(this.stateDir, `${id}.json`) : undefined;
159
+ await fs.mkdir(dir, { mode: 0o700 });
160
+ let handle;
161
+ try {
162
+ handle = await fs.open(temp, constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
163
+ }
164
+ catch (error) {
165
+ await fs.rmdir(dir).catch(() => { });
166
+ throw error;
167
+ }
168
+ const directory = await fs.lstat(dir);
169
+ const inode = await handle.stat();
170
+ const dirIdentity = { dev: directory.dev, ino: directory.ino };
171
+ const identity = { dev: inode.dev, ino: inode.ino };
172
+ try {
173
+ if (manifest)
174
+ await fs.writeFile(manifest, JSON.stringify({ temp, dir, identity, dirIdentity }), { flag: 'wx', mode: 0o600 });
175
+ }
176
+ catch (error) {
177
+ await handle.close();
178
+ await fs.unlink(temp).catch(() => { });
179
+ await fs.rmdir(dir).catch(() => { });
180
+ throw error;
181
+ }
182
+ this.uploads.set(id, { id, manifest, target, temp, dir, dirIdentity, identity, handle, size, offset: 0, overwrite, inPlace, reportedPath: filesystem ? reportedPath : undefined, touched: Date.now(), parent: { dev: parent.dev, ino: parent.ino }, chunks: new Map(), commits: new Set() });
183
+ return { uploadId: id };
184
+ }
185
+ async chunk(upload, offset, bytes) {
186
+ const digest = createHash('sha256').update(bytes).digest('hex');
187
+ const receipt = upload.chunks.get(offset);
188
+ if (receipt && receipt.length === bytes.length && receipt.digest === digest)
189
+ return { offset: upload.offset };
190
+ if (upload.chunks.size >= MAX_UPLOAD_CHUNKS)
191
+ throw new Error('Upload chunk count limit exceeded');
192
+ if (offset !== upload.offset || !bytes.length || offset + bytes.length > upload.size)
193
+ throw new Error('Upload offset or size mismatch');
194
+ let written = 0;
195
+ while (written < bytes.length) {
196
+ const result = await upload.handle.write(bytes, written, bytes.length - written, offset + written);
197
+ if (!result.bytesWritten)
198
+ throw new Error('Upload write failed');
199
+ written += result.bytesWritten;
200
+ }
201
+ upload.chunks.set(offset, { length: bytes.length, digest });
202
+ upload.offset += bytes.length;
203
+ return { offset: upload.offset };
204
+ }
205
+ /** Runs inside `pinned(dirname(upload.target))`. */
206
+ async finish(pin, upload, resolve) {
207
+ if (upload.offset !== upload.size)
208
+ throw new Error('Upload size mismatch');
209
+ await resolve(upload.target, true, true);
210
+ const parent = await fs.stat('.');
211
+ if (parent.dev !== upload.parent.dev || parent.ino !== upload.parent.ino)
212
+ throw new Error('Upload parent changed');
213
+ await upload.handle.sync();
214
+ await this.verify(upload);
215
+ if (upload.inPlace) {
216
+ // Write through the existing inode: the kernel enforces file ACLs and retains ownership,
217
+ // permissions and hard links, including when the parent does not permit replacement.
218
+ const destination = await pin.regularHandle(upload.target, constants.O_WRONLY);
219
+ try {
220
+ const buffer = Buffer.alloc(64 * 1024);
221
+ let offset = 0;
222
+ while (offset < upload.size) {
223
+ const { bytesRead } = await upload.handle.read(buffer, 0, Math.min(buffer.length, upload.size - offset), offset);
224
+ if (!bytesRead)
225
+ throw new Error('Upload temporary file changed');
226
+ let written = 0;
227
+ while (written < bytesRead) {
228
+ const result = await destination.write(buffer, written, bytesRead - written, offset + written);
229
+ if (!result.bytesWritten)
230
+ throw new Error('Upload write failed');
231
+ written += result.bytesWritten;
232
+ }
233
+ offset += bytesRead;
234
+ }
235
+ await destination.truncate(upload.size);
236
+ await destination.sync();
237
+ }
238
+ finally {
239
+ await destination.close();
240
+ }
241
+ await this.clean(upload);
242
+ return { ok: true, path: upload.reportedPath ?? upload.target };
243
+ }
244
+ const commit = path.join(upload.dir, `commit-${randomUUID()}`);
245
+ upload.commits.add(commit);
246
+ await fs.link(upload.temp, commit);
247
+ // The link may have raced source replacement. Check the actual linked inode before ever
248
+ // touching the destination; the final source lives in a trusted 0700 directory.
249
+ const linked = await fs.lstat(commit);
250
+ const opened = await upload.handle.stat();
251
+ if (!linked.isFile() || !same(linked, opened))
252
+ throw new Error('Upload staging integrity changed');
253
+ const verified = await this.verify(upload);
254
+ // No asynchronous yield between the final identity guard and atomic commit. Other-UID
255
+ // processes cannot replace the private staging entry; unrestricted same-UID tampering is
256
+ // outside this boundary (that UID can also control the files host or ptrace its process).
257
+ const directory = lstatSync(upload.dir);
258
+ const source = lstatSync(commit);
259
+ const original = lstatSync(upload.temp);
260
+ if (!directory.isDirectory() || !same(directory, upload.dirIdentity) || (directory.mode & 0o077)
261
+ || !source.isFile() || !original.isFile() || !same(source, verified) || !same(original, verified)
262
+ || source.size !== upload.size || source.mtimeMs !== verified.mtimeMs || source.ctimeMs !== verified.ctimeMs)
263
+ throw new Error('Upload commit source changed');
264
+ if (upload.overwrite) {
265
+ try {
266
+ if (!lstatSync(pin.leaf(upload.target)).isFile())
267
+ throw new Error('Target is not a regular file');
268
+ }
269
+ catch (error) {
270
+ if (error.code !== 'ENOENT')
271
+ throw error;
272
+ }
273
+ renameSync(commit, pin.leaf(upload.target));
274
+ }
275
+ else
276
+ linkSync(commit, pin.leaf(upload.target));
277
+ await this.clean(upload);
278
+ return { ok: true, path: upload.reportedPath ?? upload.target };
279
+ }
280
+ async cancel(id) {
281
+ const upload = this.uploads.get(id);
282
+ if (upload)
283
+ await this.clean(upload);
284
+ return { ok: true };
285
+ }
286
+ async clean(upload) {
287
+ this.uploads.delete(upload.id);
288
+ await upload.handle.close().catch(() => { });
289
+ try {
290
+ const directory = await fs.lstat(upload.dir);
291
+ if (directory.isDirectory() && same(directory, upload.dirIdentity)) {
292
+ for (const item of [upload.temp, ...upload.commits]) {
293
+ try {
294
+ const stat = await fs.lstat(item);
295
+ if (stat.isFile() && same(stat, upload.identity))
296
+ await fs.unlink(item);
297
+ }
298
+ catch { /* Preserve substituted entries. */ }
299
+ }
300
+ await fs.rmdir(upload.dir).catch(() => { });
301
+ }
302
+ }
303
+ catch { /* Inaccessible private staging paths are not followed. */ }
304
+ if (upload.manifest)
305
+ await fs.unlink(upload.manifest).catch(() => { });
306
+ }
307
+ async abandon(upload) {
308
+ this.uploads.delete(upload.id);
309
+ await upload.handle.close().catch(() => { });
310
+ // Retain the private journal when its original parent cannot be safely resolved for cleanup.
311
+ // A later startup can retry; never remove a same-name file in a replacement directory.
312
+ }
313
+ /**
314
+ * Cleans (or, when its parent was replaced, abandons) every upload idle for the TTL. Sweeps never overlap: a caller
315
+ * that arrives while one runs (the periodic timer, or another request) waits for it and then sweeps again itself, so
316
+ * once `expire()` returns, everything expired at the call is gone from disk as well as from the table - as when the
317
+ * keeper queued every call.
318
+ */
319
+ async expire() {
320
+ while (this.sweeping)
321
+ await this.sweeping;
322
+ let done;
323
+ this.sweeping = new Promise(resolve => { done = resolve; });
324
+ try {
325
+ for (const upload of [...this.uploads.values()])
326
+ if (Date.now() - upload.touched >= this.ttl)
327
+ await this.retire(upload);
328
+ }
329
+ finally {
330
+ this.sweeping = undefined;
331
+ done();
332
+ }
333
+ }
334
+ async dispose() {
335
+ while (this.sweeping)
336
+ await this.sweeping;
337
+ for (const upload of [...this.uploads.values()])
338
+ await this.retire(upload);
339
+ }
340
+ async retire(upload) {
341
+ await pinned(path.dirname(upload.target), async () => {
342
+ // Another caller may have finished, cancelled or retired it while this one waited for the pin.
343
+ if (this.uploads.get(upload.id) !== upload)
344
+ return;
345
+ const stat = await fs.stat('.');
346
+ if (stat.dev !== upload.parent.dev || stat.ino !== upload.parent.ino)
347
+ throw new Error('Upload parent changed');
348
+ await this.clean(upload);
349
+ }).catch(() => this.uploads.get(upload.id) === upload ? this.abandon(upload) : undefined);
350
+ }
351
+ }
@@ -1,10 +1,21 @@
1
+ /**
2
+ * **Legacy, read-only. Remove in axom-cli 0.5.0.**
3
+ *
4
+ * Until Task 20 (spec 2026-10-11 §5.3) the daemon recorded every outbound `agent_event` / `agent_turn_done` of a session in
5
+ * `<configDir>/agents/sessions/<encodeURIComponent(sessionRef)>.jsonl` before sending it (`SessionJournal`), replayed the tail on
6
+ * reconnect and truncated it on `agent_event_ack`. That job moved to the transport's outbox stream (keeper-acp, or the local
7
+ * fallback's `<configDir>/agents/local/<sessionRef>/`), so nothing writes these files any more.
8
+ *
9
+ * What is left is reading the files an older CLI left behind: on the first start of a new CLI the manager migrates each one into
10
+ * a local outbox under the same ref (`AgentSessionManager.migrateLegacyJournals`) — offered as an orphan and resent on a reopen
11
+ * exactly as the journal was — and deletes the file. The release after the one that ships keepers drops this module (every
12
+ * daemon that could have left a journal has started a migrating CLI by then; a file still there is simply ignored).
13
+ *
14
+ * File layout: one `{seq, frame, src?}` JSON object per line; a torn trailing line (a crash mid-append) is skipped.
15
+ */
1
16
  import fs from 'node:fs/promises';
2
17
  import path from 'node:path';
3
- /** Default cap for one session's journal. Past this, the oldest half is dropped (see {@link SessionJournal.droppedOldest}). */
4
- export const DEFAULT_JOURNAL_MAX_BYTES = 64 * 1024 * 1024;
5
- function serializeEntry(entry) {
6
- return `${JSON.stringify({ seq: entry.seq, frame: entry.frame })}\n`;
7
- }
18
+ const SUFFIX = '.jsonl';
8
19
  function parseEntry(line) {
9
20
  try {
10
21
  const parsed = JSON.parse(line);
@@ -12,246 +23,64 @@ function parseEntry(line) {
12
23
  return null;
13
24
  if (typeof parsed.frame !== 'object' || parsed.frame === null)
14
25
  return null;
15
- return { seq: parsed.seq, frame: parsed.frame };
26
+ const src = parsed.src;
27
+ const valid = src !== null && typeof src === 'object' && Number.isSafeInteger(src.o) && Number.isSafeInteger(src.i) &&
28
+ src.o >= 0 && src.i >= 0;
29
+ return { seq: parsed.seq, frame: parsed.frame, ...(valid ? { src: { o: src.o, i: src.i } } : {}) };
16
30
  }
17
31
  catch {
18
32
  return null;
19
33
  }
20
34
  }
21
- function isEnoent(err) {
22
- return err?.code === 'ENOENT';
23
- }
24
- /**
25
- * Append-only record of everything one external agent session has streamed to the server, plus the
26
- * session's seq allocator.
27
- *
28
- * It exists because the tunnel is not reliable: the websocket can drop mid-turn and the daemon can
29
- * restart while the native agent keeps running. Every outbound `agent_event` / `agent_turn_done` is
30
- * written here *before* it is sent, so on reconnect the server can say "I have applied through N"
31
- * and get everything after N replayed verbatim. The server acks with `agent_event_ack`, which is
32
- * what keeps the file from growing without bound in the steady state.
33
- *
34
- * **Scope of the guarantee: process crash, not power loss.** Writes go through `appendFile` /
35
- * `writeFile` with no `fsync`, so an entry that has returned from {@link append} survives the daemon
36
- * being killed, but bytes still sitting in the OS page cache can be lost to a kernel panic or a
37
- * power cut. That is the right trade for streamed agent output — an fsync per event would dominate
38
- * the cost of the event — and the failure mode degrades to the same seq gap a dropped socket causes.
39
- *
40
- * The seq allocator lives here rather than in the manager for the daemon-restart case: seq must not
41
- * restart at 1 when the process comes back, or the server's `seq > bridgeSeq` de-duplication would
42
- * silently swallow the whole next turn. {@link init} recovers the high-water mark from disk *and*
43
- * takes the reopening server's watermark as a floor (the file can be empty precisely because
44
- * everything in it was acked), and {@link nextSeq} continues from there.
45
- *
46
- * File layout: `<dir>/<sessionRef>.jsonl`, one `{seq, frame}` JSON object per line. `dir` is the
47
- * directory that *directly* holds the files — `AgentSessionManager` passes its `sessionsDir`
48
- * (`<journalDir>/sessions`). The file is created on the first {@link append}, not by {@link init},
49
- * so a session that opens and is abandoned before producing anything leaves nothing behind.
50
- *
51
- * Every public method is serialized on an internal queue: a rewrite (ack truncation, overflow drop)
52
- * must never interleave with an append, and callers hand work in from unordered bridge callbacks.
53
- */
54
- export class SessionJournal {
55
- /** Absolute path of the `.jsonl`. Exposed for logging and tests; nothing else should read it directly. */
56
- filePath;
57
- dir;
58
- tmpPath;
59
- maxBytes;
60
- maxSeq = 0;
61
- /** Byte size of the file as of the last write; kept in memory so append does not stat every time. */
62
- bytes = 0;
63
- dropped = false;
64
- ready = false;
65
- queue = Promise.resolve();
66
- constructor(dir, sessionRef, maxBytes = DEFAULT_JOURNAL_MAX_BYTES) {
67
- this.dir = dir;
68
- // sessionRefs are server-supplied strings, not filenames: percent-encoding keeps '/' and '..'
69
- // from steering the write out of the journal directory, and keeps the mapping reversible.
70
- this.filePath = path.join(dir, `${encodeURIComponent(sessionRef)}.jsonl`);
71
- this.tmpPath = `${this.filePath}.tmp`;
72
- this.maxBytes = maxBytes;
73
- }
74
- /** True once an overflow has discarded entries. The manager turns this into a visible gap notice on resume. */
75
- get droppedOldest() {
76
- return this.dropped;
77
- }
78
- /** Highest seq ever handed out or recovered. `0` means nothing has been journaled for this session. */
79
- get lastSeq() {
80
- return this.maxSeq;
81
- }
82
- /**
83
- * Creates the journal directory and recovers `maxSeq` (and the byte size) from any existing file.
84
- * Must be awaited before {@link nextSeq} / {@link append}; both throw otherwise, because a missed
85
- * init would restart seq at 1 and desynchronize the server's watermark.
86
- *
87
- * `resumeFromSeq` is the server's watermark from the `agent_open` that is (re)creating this
88
- * session, and it is a **floor** under the recovered value — never a ceiling. The file alone is
89
- * not a sufficient source for the allocator: acked entries are truncated away, so between turns
90
- * the journal is legitimately empty. A daemon restart in that window recovers `maxSeq = 0` while
91
- * the server is still at N, `nextSeq()` starts back at 1, and every event the session produces
92
- * from then on is `<= lastSeenSeq` on the server — silently swallowed, no error, no recovery
93
- * path, the run hangs forever. Seeding from the watermark closes that hole without asking the
94
- * server to lower anything (its watermark is advance-only at the storage layer, deliberately).
95
- *
96
- * A trailing line torn by a crash mid-append is dropped and the file rewritten, so a later append
97
- * cannot glue new bytes onto half a record.
98
- */
99
- async init(resumeFromSeq = 0) {
100
- await this.serialize(async () => {
101
- await fs.mkdir(this.dir, { recursive: true });
102
- const { entries, bytes, clean } = await this.readEntries();
103
- const recovered = entries.reduce((max, entry) => Math.max(max, entry.seq), 0);
104
- this.maxSeq = Math.max(recovered, resumeFromSeq);
105
- this.bytes = clean ? bytes : await this.rewrite(entries);
106
- this.ready = true;
107
- });
108
- }
109
- /** Next seq for this session. Synchronous by design: bridge callbacks must number events in arrival order. */
110
- nextSeq() {
111
- this.requireReady();
112
- this.maxSeq += 1;
113
- return this.maxSeq;
114
- }
115
- /** Writes the entry to disk. Resolves only once the bytes are handed to the OS — callers send after this. */
116
- async append(entry) {
117
- await this.serialize(async () => {
118
- this.requireReady();
119
- const line = serializeEntry(entry);
120
- const size = Buffer.byteLength(line);
121
- if (this.bytes + size > this.maxBytes)
122
- await this.dropOldestHalf(size);
123
- await fs.appendFile(this.filePath, line);
124
- this.bytes += size;
125
- if (entry.seq > this.maxSeq)
126
- this.maxSeq = entry.seq;
127
- });
128
- }
129
- /**
130
- * Everything the server has not applied yet, in append order. `seq` is the server's watermark.
131
- *
132
- * This is the one method that parses the whole file, so it is O(file) in both time and peak
133
- * memory. That is affordable only because it runs on the reconnect path — once per `agent_open` —
134
- * and never on the append path. Keep it that way.
135
- */
136
- async entriesAfter(seq) {
137
- return this.serialize(async () => {
138
- const { entries } = await this.readEntries();
139
- return entries.filter((entry) => entry.seq > seq);
140
- });
141
- }
142
- /**
143
- * Forgets everything the server has acknowledged (`seq <= throughSeq`) by rewriting the file.
144
- * `maxSeq` is deliberately untouched: acking is not a reason to reissue seqs.
145
- */
146
- async truncateThrough(seq) {
147
- await this.serialize(async () => {
148
- if (seq <= 0)
149
- return;
150
- const { entries } = await this.readEntries();
151
- const kept = entries.filter((entry) => entry.seq > seq);
152
- if (kept.length === entries.length)
153
- return; // nothing acked that we still hold; don't churn the file
154
- this.bytes = await this.rewrite(kept);
155
- });
156
- }
157
- /** Removes the file for good (agent_close). The journal is unusable afterwards. */
158
- async destroy() {
159
- await this.serialize(async () => {
160
- await fs.rm(this.filePath, { force: true });
161
- await fs.rm(this.tmpPath, { force: true });
162
- this.bytes = 0;
163
- this.ready = false;
164
- });
165
- }
166
- // --- internals (never call from inside another serialized op except via the private helpers) ---
167
- requireReady() {
168
- if (!this.ready) {
169
- throw new Error(`SessionJournal ${this.filePath} used before init() (or after destroy())`);
170
- }
171
- }
172
- /**
173
- * Chains `op` after whatever is already queued — including a failed predecessor, so one bad write
174
- * does not wedge the session forever.
175
- */
176
- serialize(op) {
177
- const run = this.queue.then(op, op);
178
- this.queue = run.then(() => undefined, () => undefined);
179
- return run;
180
- }
181
- /** `clean: false` means at least one line failed to parse and the file should be rewritten. */
182
- async readEntries() {
183
- let text;
184
- try {
185
- text = await fs.readFile(this.filePath, 'utf8');
186
- }
187
- catch (err) {
188
- if (isEnoent(err))
189
- return { entries: [], bytes: 0, clean: true };
190
- throw err;
191
- }
192
- const entries = [];
193
- let clean = true;
194
- for (const line of text.split('\n')) {
195
- if (line.length === 0)
196
- continue;
197
- const entry = parseEntry(line);
198
- if (entry)
199
- entries.push(entry);
200
- else
201
- clean = false;
202
- }
203
- return { entries, bytes: Buffer.byteLength(text), clean };
204
- }
205
- /** Replaces the file with exactly `entries`, atomically. Returns the new byte size. */
206
- async rewrite(entries) {
207
- return this.rewriteRaw(Buffer.from(entries.map(serializeEntry).join('')));
35
+ const fileOf = (dir, sessionRef) => path.join(dir, `${encodeURIComponent(sessionRef)}${SUFFIX}`);
36
+ /** The refs of the journal files in `dir` (`<journalDir>/sessions`), sorted. A missing directory is none. */
37
+ export async function listLegacyJournalRefs(dir) {
38
+ let names;
39
+ try {
40
+ names = await fs.readdir(dir);
208
41
  }
209
- /** Replaces the file with `data`, atomically. Returns the new byte size. */
210
- async rewriteRaw(data) {
211
- await fs.writeFile(this.tmpPath, data);
212
- await fs.rename(this.tmpPath, this.filePath);
213
- return data.length;
42
+ catch (error) {
43
+ if (error?.code === 'ENOENT')
44
+ return [];
45
+ throw error;
214
46
  }
215
- /**
216
- * Makes room for `incoming` bytes by discarding the oldest half, repeating until it fits. Dropping
217
- * the *oldest* is the only safe direction: the newest output is what the user is watching, and the
218
- * server can be told about the hole (a synthetic gap event) but cannot invent the missing bytes.
219
- *
220
- * This runs on the **append path**, so it deliberately never parses or re-serializes a record. It
221
- * reads the file once as raw bytes, walks to a newline boundary past the half-way mark, and writes
222
- * back a slice of that same buffer. At the 64MB cap that is one 64MB read plus one ≤32MB write —
223
- * versus the several-hundred-MB transient and multi-second single-threaded stall an entry-level
224
- * round-trip would cost, on a daemon that is also serving `exec_request` on the same loop.
225
- *
226
- * Halving by *bytes* rather than by record count is the same policy in a cheaper coordinate: the
227
- * point is to reclaim about half the file in one pass so the trim is amortized over many appends.
228
- */
229
- async dropOldestHalf(incoming) {
230
- let buf;
47
+ const refs = [];
48
+ for (const name of names) {
49
+ if (!name.endsWith(SUFFIX))
50
+ continue;
231
51
  try {
232
- buf = await fs.readFile(this.filePath);
52
+ refs.push(decodeURIComponent(name.slice(0, -SUFFIX.length)));
233
53
  }
234
- catch (err) {
235
- if (isEnoent(err)) {
236
- this.bytes = 0; // tracked size was stale; nothing on disk to drop
237
- return;
238
- }
239
- throw err;
54
+ catch {
55
+ // Not a name a daemon wrote (`encodeURIComponent` never emits a malformed escape).
240
56
  }
241
- let start = 0;
242
- while (start < buf.length && buf.length - start + incoming > this.maxBytes) {
243
- // Cut at the first record boundary at or after the half-way point of what is left. Every line
244
- // ends in '\n', so the newline search can only land on a real boundary; if there is none left
245
- // the remainder is a single oversized record and we drop all of it.
246
- const half = start + Math.ceil((buf.length - start) / 2);
247
- const newline = buf.indexOf(0x0a, half);
248
- start = newline === -1 ? buf.length : newline + 1;
249
- }
250
- // Nothing to drop means a single record larger than the whole cap; write it anyway rather than
251
- // lose it, and let the next append trim back down.
252
- if (start === 0)
253
- return;
254
- this.dropped = true;
255
- this.bytes = await this.rewriteRaw(buf.subarray(start));
256
57
  }
58
+ return refs.sort();
59
+ }
60
+ /** Every readable entry of `sessionRef`'s journal, in file order (unreadable lines skipped). A missing file is empty. */
61
+ export async function readLegacyJournal(dir, sessionRef) {
62
+ let text;
63
+ try {
64
+ text = await fs.readFile(fileOf(dir, sessionRef), 'utf8');
65
+ }
66
+ catch (error) {
67
+ if (error?.code === 'ENOENT')
68
+ return [];
69
+ throw error;
70
+ }
71
+ const entries = [];
72
+ for (const line of text.split('\n')) {
73
+ if (line.length === 0)
74
+ continue;
75
+ const entry = parseEntry(line);
76
+ if (entry)
77
+ entries.push(entry);
78
+ }
79
+ return entries;
80
+ }
81
+ /** Deletes `sessionRef`'s journal (and the `.tmp` a rewrite may have left). */
82
+ export async function removeLegacyJournal(dir, sessionRef) {
83
+ const file = fileOf(dir, sessionRef);
84
+ await fs.rm(file, { force: true });
85
+ await fs.rm(`${file}.tmp`, { force: true });
257
86
  }