@cmetech/otto 1.1.1 → 1.2.5

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 (433) hide show
  1. package/dist/coworker/persona-commands.d.ts +1 -0
  2. package/dist/coworker/persona-commands.js +5 -0
  3. package/dist/coworker/persona-commands.test.d.ts +1 -0
  4. package/dist/coworker/persona-commands.test.js +45 -0
  5. package/dist/resources/.managed-resources-content-hash +1 -1
  6. package/dist/resources/extensions/coworker-artifacts/artifacts-command.js +31 -0
  7. package/dist/resources/extensions/coworker-artifacts/artifacts-singleton.js +17 -0
  8. package/dist/resources/extensions/coworker-artifacts/extension-manifest.json +13 -0
  9. package/dist/resources/extensions/coworker-artifacts/index.js +125 -0
  10. package/dist/resources/extensions/coworker-artifacts/list-tool.js +27 -0
  11. package/dist/resources/extensions/coworker-artifacts/open-tool.js +25 -0
  12. package/dist/resources/extensions/coworker-memory/extension-manifest.json +13 -0
  13. package/dist/resources/extensions/coworker-memory/index.js +219 -0
  14. package/dist/resources/extensions/coworker-memory/memorize-tool.js +10 -0
  15. package/dist/resources/extensions/coworker-memory/memory-command.js +157 -0
  16. package/dist/resources/extensions/coworker-memory/memory-singleton.js +55 -0
  17. package/dist/resources/extensions/coworker-memory/recall-tool.js +18 -0
  18. package/dist/resources/extensions/coworker-memory/session-hooks.js +45 -0
  19. package/dist/resources/extensions/coworker-scratchpad/attach-banners.js +53 -0
  20. package/dist/resources/extensions/coworker-scratchpad/extension-manifest.json +13 -0
  21. package/dist/resources/extensions/coworker-scratchpad/format-age.js +9 -0
  22. package/dist/resources/extensions/coworker-scratchpad/helpers.js +38 -0
  23. package/dist/resources/extensions/coworker-scratchpad/index.js +199 -0
  24. package/dist/resources/extensions/coworker-scratchpad/mime-bundle.js +20 -0
  25. package/dist/resources/extensions/coworker-scratchpad/scratchpad-tool.js +118 -0
  26. package/dist/resources/extensions/coworker-scratchpad/session-sidecar.js +60 -0
  27. package/dist/resources/extensions/coworker-scratchpad/sp-command.js +597 -0
  28. package/dist/resources/extensions/coworker-scratchpad/workspace-pointer.js +41 -0
  29. package/dist/resources/extensions/coworker-scratchpad/workspace-root.js +17 -0
  30. package/dist/resources/extensions/coworker-vault/audit-command.js +35 -0
  31. package/dist/resources/extensions/coworker-vault/connect-command.js +42 -0
  32. package/dist/resources/extensions/coworker-vault/datasource-command.js +50 -0
  33. package/dist/resources/extensions/coworker-vault/extension-manifest.json +12 -0
  34. package/dist/resources/extensions/coworker-vault/index.js +171 -0
  35. package/dist/resources/extensions/coworker-vault/test-helpers.js +86 -0
  36. package/dist/resources/extensions/coworker-vault/vault-singleton.js +24 -0
  37. package/dist/resources/extensions/otto/commands/release-notes/_data.js +83 -0
  38. package/dist/resources/extensions/otto/commands/release-notes/command.js +15 -4
  39. package/dist/resources/extensions/otto/index.js +31 -6
  40. package/dist/resources/extensions/shared/coworker-paths.js +8 -0
  41. package/dist/resources/extensions/slash-commands/{audit.js → audit-codebase.js} +4 -4
  42. package/dist/resources/extensions/slash-commands/extension-manifest.json +1 -1
  43. package/dist/resources/extensions/slash-commands/index.js +2 -2
  44. package/dist/resources/extensions/subagent/index.js +8 -1
  45. package/dist/resources/extensions/subagent/launch.js +37 -5
  46. package/dist/resources/extensions/subagent/run-store.js +1 -0
  47. package/dist/resources/extensions/workflow/bootstrap/register-extension.js +2 -0
  48. package/dist/resources/extensions/workflow/bootstrap/register-hooks.js +10 -0
  49. package/dist/resources/extensions/workflow/health-widget-core.js +1 -1
  50. package/dist/resources/extensions/workflow/persona-status.js +87 -0
  51. package/package.json +26 -10
  52. package/packages/contracts/package.json +1 -1
  53. package/packages/coworker-artifacts/dist/artifact-store.d.ts +25 -0
  54. package/packages/coworker-artifacts/dist/artifact-store.js +187 -0
  55. package/packages/coworker-artifacts/dist/dir-snapshot.d.ts +7 -0
  56. package/packages/coworker-artifacts/dist/dir-snapshot.js +54 -0
  57. package/packages/coworker-artifacts/dist/errors.d.ts +18 -0
  58. package/packages/coworker-artifacts/dist/errors.js +37 -0
  59. package/packages/coworker-artifacts/dist/index.d.ts +7 -0
  60. package/packages/coworker-artifacts/dist/index.js +7 -0
  61. package/packages/coworker-artifacts/dist/readme-renderer.d.ts +5 -0
  62. package/packages/coworker-artifacts/dist/readme-renderer.js +47 -0
  63. package/packages/coworker-artifacts/dist/resolve-uri.d.ts +3 -0
  64. package/packages/coworker-artifacts/dist/resolve-uri.js +29 -0
  65. package/packages/coworker-artifacts/dist/slug.d.ts +4 -0
  66. package/packages/coworker-artifacts/dist/slug.js +32 -0
  67. package/packages/coworker-artifacts/dist/types.d.ts +52 -0
  68. package/packages/coworker-artifacts/dist/types.js +1 -0
  69. package/packages/coworker-artifacts/package.json +20 -0
  70. package/packages/coworker-artifacts/src/artifact-store.test.ts +188 -0
  71. package/packages/coworker-artifacts/src/artifact-store.ts +206 -0
  72. package/packages/coworker-artifacts/src/artifacts-integration.test.ts +109 -0
  73. package/packages/coworker-artifacts/src/dir-snapshot.test.ts +71 -0
  74. package/packages/coworker-artifacts/src/dir-snapshot.ts +52 -0
  75. package/packages/coworker-artifacts/src/errors.test.ts +37 -0
  76. package/packages/coworker-artifacts/src/errors.ts +28 -0
  77. package/packages/coworker-artifacts/src/index.test.ts +22 -0
  78. package/packages/coworker-artifacts/src/index.ts +7 -0
  79. package/packages/coworker-artifacts/src/readme-renderer.test.ts +72 -0
  80. package/packages/coworker-artifacts/src/readme-renderer.ts +56 -0
  81. package/packages/coworker-artifacts/src/resolve-uri.test.ts +46 -0
  82. package/packages/coworker-artifacts/src/resolve-uri.ts +29 -0
  83. package/packages/coworker-artifacts/src/slug.test.ts +47 -0
  84. package/packages/coworker-artifacts/src/slug.ts +31 -0
  85. package/packages/coworker-artifacts/src/types.ts +61 -0
  86. package/packages/coworker-artifacts/tsconfig.json +15 -0
  87. package/packages/coworker-artifacts/tsconfig.publish.json +4 -0
  88. package/packages/coworker-memory/dist/context-injection.d.ts +9 -0
  89. package/packages/coworker-memory/dist/context-injection.js +41 -0
  90. package/packages/coworker-memory/dist/errors.d.ts +25 -0
  91. package/packages/coworker-memory/dist/errors.js +51 -0
  92. package/packages/coworker-memory/dist/index.d.ts +12 -0
  93. package/packages/coworker-memory/dist/index.js +12 -0
  94. package/packages/coworker-memory/dist/layer-a-store.d.ts +16 -0
  95. package/packages/coworker-memory/dist/layer-a-store.js +78 -0
  96. package/packages/coworker-memory/dist/local-sqlite-backend.d.ts +28 -0
  97. package/packages/coworker-memory/dist/local-sqlite-backend.js +167 -0
  98. package/packages/coworker-memory/dist/memory-backend.d.ts +14 -0
  99. package/packages/coworker-memory/dist/memory-backend.js +1 -0
  100. package/packages/coworker-memory/dist/memory-recorder.d.ts +50 -0
  101. package/packages/coworker-memory/dist/memory-recorder.js +69 -0
  102. package/packages/coworker-memory/dist/migrations/001-init.sql +38 -0
  103. package/packages/coworker-memory/dist/migrations/002-artifact-kind.sql +50 -0
  104. package/packages/coworker-memory/dist/paste-detector.d.ts +5 -0
  105. package/packages/coworker-memory/dist/paste-detector.js +14 -0
  106. package/packages/coworker-memory/dist/persona-seed.d.ts +10 -0
  107. package/packages/coworker-memory/dist/persona-seed.js +38 -0
  108. package/packages/coworker-memory/dist/recall-formatter.d.ts +2 -0
  109. package/packages/coworker-memory/dist/recall-formatter.js +14 -0
  110. package/packages/coworker-memory/dist/scope-resolver.d.ts +9 -0
  111. package/packages/coworker-memory/dist/scope-resolver.js +10 -0
  112. package/packages/coworker-memory/dist/types.d.ts +51 -0
  113. package/packages/coworker-memory/dist/types.js +2 -0
  114. package/packages/coworker-memory/dist/workspace-id.d.ts +3 -0
  115. package/packages/coworker-memory/dist/workspace-id.js +54 -0
  116. package/packages/coworker-memory/package.json +35 -0
  117. package/packages/coworker-memory/src/activator-integration.test.ts +141 -0
  118. package/packages/coworker-memory/src/context-injection.test.ts +72 -0
  119. package/packages/coworker-memory/src/context-injection.ts +57 -0
  120. package/packages/coworker-memory/src/errors.test.ts +45 -0
  121. package/packages/coworker-memory/src/errors.ts +42 -0
  122. package/packages/coworker-memory/src/index.test.ts +21 -0
  123. package/packages/coworker-memory/src/index.ts +12 -0
  124. package/packages/coworker-memory/src/layer-a-store.test.ts +85 -0
  125. package/packages/coworker-memory/src/layer-a-store.ts +88 -0
  126. package/packages/coworker-memory/src/local-sqlite-backend.test.ts +110 -0
  127. package/packages/coworker-memory/src/local-sqlite-backend.ts +185 -0
  128. package/packages/coworker-memory/src/memory-backend.ts +10 -0
  129. package/packages/coworker-memory/src/memory-integration.test.ts +89 -0
  130. package/packages/coworker-memory/src/memory-recorder.test.ts +101 -0
  131. package/packages/coworker-memory/src/memory-recorder.ts +95 -0
  132. package/packages/coworker-memory/src/migrations/001-init.sql +38 -0
  133. package/packages/coworker-memory/src/migrations/002-artifact-kind.sql +50 -0
  134. package/packages/coworker-memory/src/paste-detector.test.ts +23 -0
  135. package/packages/coworker-memory/src/paste-detector.ts +18 -0
  136. package/packages/coworker-memory/src/persona-seed.test.ts +57 -0
  137. package/packages/coworker-memory/src/persona-seed.ts +46 -0
  138. package/packages/coworker-memory/src/recall-formatter.test.ts +34 -0
  139. package/packages/coworker-memory/src/recall-formatter.ts +15 -0
  140. package/packages/coworker-memory/src/scope-resolver.test.ts +23 -0
  141. package/packages/coworker-memory/src/scope-resolver.ts +18 -0
  142. package/packages/coworker-memory/src/types.ts +61 -0
  143. package/packages/coworker-memory/src/workspace-id.test.ts +48 -0
  144. package/packages/coworker-memory/src/workspace-id.ts +56 -0
  145. package/packages/coworker-memory/tsconfig.json +15 -0
  146. package/packages/coworker-memory/tsconfig.publish.json +4 -0
  147. package/packages/coworker-persona/dist/commands.d.ts +7 -0
  148. package/packages/coworker-persona/dist/commands.js +35 -0
  149. package/packages/coworker-persona/dist/defaults/manifest.yaml +12 -0
  150. package/packages/coworker-persona/dist/defaults/steering/identity.md +3 -0
  151. package/packages/coworker-persona/dist/index.d.ts +3 -0
  152. package/packages/coworker-persona/dist/index.js +3 -0
  153. package/packages/coworker-persona/dist/manifest.d.ts +24 -0
  154. package/packages/coworker-persona/dist/manifest.js +21 -0
  155. package/packages/coworker-persona/dist/registry.d.ts +22 -0
  156. package/packages/coworker-persona/dist/registry.js +142 -0
  157. package/packages/coworker-persona/package.json +28 -0
  158. package/packages/coworker-persona/scripts/copy-defaults.cjs +17 -0
  159. package/packages/coworker-persona/src/commands.ts +47 -0
  160. package/packages/coworker-persona/src/defaults/manifest.yaml +12 -0
  161. package/packages/coworker-persona/src/defaults/steering/identity.md +3 -0
  162. package/packages/coworker-persona/src/index.ts +3 -0
  163. package/packages/coworker-persona/src/manifest.test.ts +67 -0
  164. package/packages/coworker-persona/src/manifest.ts +49 -0
  165. package/packages/coworker-persona/src/registry.test.ts +89 -0
  166. package/packages/coworker-persona/src/registry.ts +147 -0
  167. package/packages/coworker-persona/tsconfig.json +15 -0
  168. package/packages/coworker-persona/tsconfig.publish.json +4 -0
  169. package/packages/coworker-scratchpad/dist/cell-archive.d.ts +39 -0
  170. package/packages/coworker-scratchpad/dist/cell-archive.js +77 -0
  171. package/packages/coworker-scratchpad/dist/cell-tree.d.ts +14 -0
  172. package/packages/coworker-scratchpad/dist/cell-tree.js +72 -0
  173. package/packages/coworker-scratchpad/dist/child-process-runtime.d.ts +129 -0
  174. package/packages/coworker-scratchpad/dist/child-process-runtime.js +427 -0
  175. package/packages/coworker-scratchpad/dist/collector-registry.d.ts +12 -0
  176. package/packages/coworker-scratchpad/dist/collector-registry.js +29 -0
  177. package/packages/coworker-scratchpad/dist/detect-kind.d.ts +3 -0
  178. package/packages/coworker-scratchpad/dist/detect-kind.js +19 -0
  179. package/packages/coworker-scratchpad/dist/file-collector.d.ts +15 -0
  180. package/packages/coworker-scratchpad/dist/file-collector.js +99 -0
  181. package/packages/coworker-scratchpad/dist/index.d.ts +13 -0
  182. package/packages/coworker-scratchpad/dist/index.js +13 -0
  183. package/packages/coworker-scratchpad/dist/kernel-bindings.d.ts +49 -0
  184. package/packages/coworker-scratchpad/dist/kernel-bindings.js +220 -0
  185. package/packages/coworker-scratchpad/dist/kernel-entry.d.ts +1 -0
  186. package/packages/coworker-scratchpad/dist/kernel-entry.js +355 -0
  187. package/packages/coworker-scratchpad/dist/kernel-protocol.d.ts +171 -0
  188. package/packages/coworker-scratchpad/dist/kernel-protocol.js +48 -0
  189. package/packages/coworker-scratchpad/dist/kernel-spawn.d.ts +3 -0
  190. package/packages/coworker-scratchpad/dist/kernel-spawn.js +54 -0
  191. package/packages/coworker-scratchpad/dist/namespace-codec.d.ts +22 -0
  192. package/packages/coworker-scratchpad/dist/namespace-codec.js +61 -0
  193. package/packages/coworker-scratchpad/dist/scratchpad-lock.d.ts +24 -0
  194. package/packages/coworker-scratchpad/dist/scratchpad-lock.js +86 -0
  195. package/packages/coworker-scratchpad/dist/scratchpad-manager.d.ts +193 -0
  196. package/packages/coworker-scratchpad/dist/scratchpad-manager.js +866 -0
  197. package/packages/coworker-scratchpad/dist/staleness-banner.d.ts +12 -0
  198. package/packages/coworker-scratchpad/dist/staleness-banner.js +27 -0
  199. package/packages/coworker-scratchpad/package.json +31 -0
  200. package/packages/coworker-scratchpad/src/cell-archive.test.ts +150 -0
  201. package/packages/coworker-scratchpad/src/cell-archive.ts +97 -0
  202. package/packages/coworker-scratchpad/src/cell-tree.test.ts +105 -0
  203. package/packages/coworker-scratchpad/src/cell-tree.ts +90 -0
  204. package/packages/coworker-scratchpad/src/child-process-runtime.test.ts +413 -0
  205. package/packages/coworker-scratchpad/src/child-process-runtime.ts +493 -0
  206. package/packages/coworker-scratchpad/src/collector-registry.test.ts +69 -0
  207. package/packages/coworker-scratchpad/src/collector-registry.ts +33 -0
  208. package/packages/coworker-scratchpad/src/detect-kind.test.ts +33 -0
  209. package/packages/coworker-scratchpad/src/detect-kind.ts +22 -0
  210. package/packages/coworker-scratchpad/src/file-collector.test.ts +109 -0
  211. package/packages/coworker-scratchpad/src/file-collector.ts +114 -0
  212. package/packages/coworker-scratchpad/src/index.ts +74 -0
  213. package/packages/coworker-scratchpad/src/kernel-bindings.test.ts +188 -0
  214. package/packages/coworker-scratchpad/src/kernel-bindings.ts +279 -0
  215. package/packages/coworker-scratchpad/src/kernel-entry.test.ts +123 -0
  216. package/packages/coworker-scratchpad/src/kernel-entry.ts +390 -0
  217. package/packages/coworker-scratchpad/src/kernel-protocol.test.ts +105 -0
  218. package/packages/coworker-scratchpad/src/kernel-protocol.ts +230 -0
  219. package/packages/coworker-scratchpad/src/kernel-spawn.test.ts +60 -0
  220. package/packages/coworker-scratchpad/src/kernel-spawn.ts +54 -0
  221. package/packages/coworker-scratchpad/src/namespace-codec.test.ts +102 -0
  222. package/packages/coworker-scratchpad/src/namespace-codec.ts +90 -0
  223. package/packages/coworker-scratchpad/src/scratchpad-lock.test.ts +98 -0
  224. package/packages/coworker-scratchpad/src/scratchpad-lock.ts +102 -0
  225. package/packages/coworker-scratchpad/src/scratchpad-manager.test.ts +1343 -0
  226. package/packages/coworker-scratchpad/src/scratchpad-manager.ts +891 -0
  227. package/packages/coworker-scratchpad/src/staleness-banner.test.ts +53 -0
  228. package/packages/coworker-scratchpad/src/staleness-banner.ts +33 -0
  229. package/packages/coworker-scratchpad/src/vault-integration.test.ts +221 -0
  230. package/packages/coworker-scratchpad/tsconfig.json +15 -0
  231. package/packages/coworker-scratchpad/tsconfig.publish.json +4 -0
  232. package/packages/coworker-types/dist/artifacts.d.ts +31 -0
  233. package/packages/coworker-types/dist/artifacts.js +2 -0
  234. package/packages/coworker-types/dist/contracts.d.ts +32 -0
  235. package/packages/coworker-types/dist/contracts.js +1 -0
  236. package/packages/coworker-types/dist/index.d.ts +5 -0
  237. package/packages/coworker-types/dist/index.js +5 -0
  238. package/packages/coworker-types/dist/memory.d.ts +61 -0
  239. package/packages/coworker-types/dist/memory.js +3 -0
  240. package/packages/coworker-types/dist/scratchpad.d.ts +43 -0
  241. package/packages/coworker-types/dist/scratchpad.js +2 -0
  242. package/packages/coworker-types/dist/vault.d.ts +34 -0
  243. package/packages/coworker-types/dist/vault.js +2 -0
  244. package/packages/coworker-types/package.json +24 -0
  245. package/packages/coworker-types/src/artifacts.test.ts +52 -0
  246. package/packages/coworker-types/src/artifacts.ts +35 -0
  247. package/packages/coworker-types/src/contracts.test.ts +43 -0
  248. package/packages/coworker-types/src/contracts.ts +36 -0
  249. package/packages/coworker-types/src/index.ts +5 -0
  250. package/packages/coworker-types/src/memory.test.ts +50 -0
  251. package/packages/coworker-types/src/memory.ts +79 -0
  252. package/packages/coworker-types/src/scratchpad.test.ts +46 -0
  253. package/packages/coworker-types/src/scratchpad.ts +51 -0
  254. package/packages/coworker-types/src/smoke.test.ts +34 -0
  255. package/packages/coworker-types/src/vault.test.ts +49 -0
  256. package/packages/coworker-types/src/vault.ts +40 -0
  257. package/packages/coworker-types/tsconfig.json +15 -0
  258. package/packages/coworker-types/tsconfig.publish.json +4 -0
  259. package/packages/coworker-utils/dist/audit-log.d.ts +34 -0
  260. package/packages/coworker-utils/dist/audit-log.js +88 -0
  261. package/packages/coworker-utils/dist/index.d.ts +6 -0
  262. package/packages/coworker-utils/dist/index.js +6 -0
  263. package/packages/coworker-utils/dist/lease.d.ts +7 -0
  264. package/packages/coworker-utils/dist/lease.js +67 -0
  265. package/packages/coworker-utils/dist/logger.d.ts +13 -0
  266. package/packages/coworker-utils/dist/logger.js +26 -0
  267. package/packages/coworker-utils/dist/migration-runner.d.ts +7 -0
  268. package/packages/coworker-utils/dist/migration-runner.js +36 -0
  269. package/packages/coworker-utils/dist/ndjson-channel.d.ts +3 -0
  270. package/packages/coworker-utils/dist/ndjson-channel.js +38 -0
  271. package/packages/coworker-utils/dist/secret-scanner.d.ts +10 -0
  272. package/packages/coworker-utils/dist/secret-scanner.js +42 -0
  273. package/packages/coworker-utils/package.json +24 -0
  274. package/packages/coworker-utils/src/audit-log.test.ts +140 -0
  275. package/packages/coworker-utils/src/audit-log.ts +107 -0
  276. package/packages/coworker-utils/src/index.ts +6 -0
  277. package/packages/coworker-utils/src/lease.test.ts +64 -0
  278. package/packages/coworker-utils/src/lease.ts +76 -0
  279. package/packages/coworker-utils/src/logger.test.ts +50 -0
  280. package/packages/coworker-utils/src/logger.ts +45 -0
  281. package/packages/coworker-utils/src/migration-runner.test.ts +65 -0
  282. package/packages/coworker-utils/src/migration-runner.ts +50 -0
  283. package/packages/coworker-utils/src/ndjson-channel.test.ts +76 -0
  284. package/packages/coworker-utils/src/ndjson-channel.ts +41 -0
  285. package/packages/coworker-utils/src/secret-scanner.test.ts +61 -0
  286. package/packages/coworker-utils/src/secret-scanner.ts +56 -0
  287. package/packages/coworker-utils/tsconfig.json +15 -0
  288. package/packages/coworker-utils/tsconfig.publish.json +4 -0
  289. package/packages/coworker-vault/dist/data-vault.d.ts +41 -0
  290. package/packages/coworker-vault/dist/data-vault.js +223 -0
  291. package/packages/coworker-vault/dist/engine-registry.d.ts +34 -0
  292. package/packages/coworker-vault/dist/engine-registry.js +90 -0
  293. package/packages/coworker-vault/dist/engines/jira.yaml +17 -0
  294. package/packages/coworker-vault/dist/errors.d.ts +28 -0
  295. package/packages/coworker-vault/dist/errors.js +57 -0
  296. package/packages/coworker-vault/dist/index.d.ts +6 -0
  297. package/packages/coworker-vault/dist/index.js +6 -0
  298. package/packages/coworker-vault/dist/injector.d.ts +19 -0
  299. package/packages/coworker-vault/dist/injector.js +77 -0
  300. package/packages/coworker-vault/dist/types.d.ts +28 -0
  301. package/packages/coworker-vault/dist/types.js +1 -0
  302. package/packages/coworker-vault/dist/vault-keep.d.ts +4 -0
  303. package/packages/coworker-vault/dist/vault-keep.js +21 -0
  304. package/packages/coworker-vault/package.json +29 -0
  305. package/packages/coworker-vault/src/data-vault.test.ts +199 -0
  306. package/packages/coworker-vault/src/data-vault.ts +257 -0
  307. package/packages/coworker-vault/src/engine-registry.test.ts +120 -0
  308. package/packages/coworker-vault/src/engine-registry.ts +107 -0
  309. package/packages/coworker-vault/src/engines/jira.yaml +17 -0
  310. package/packages/coworker-vault/src/errors.test.ts +58 -0
  311. package/packages/coworker-vault/src/errors.ts +50 -0
  312. package/packages/coworker-vault/src/index.test.ts +24 -0
  313. package/packages/coworker-vault/src/index.ts +6 -0
  314. package/packages/coworker-vault/src/injector.test.ts +109 -0
  315. package/packages/coworker-vault/src/injector.ts +98 -0
  316. package/packages/coworker-vault/src/types.ts +33 -0
  317. package/packages/coworker-vault/src/vault-keep.test.ts +49 -0
  318. package/packages/coworker-vault/src/vault-keep.ts +31 -0
  319. package/packages/coworker-vault/tsconfig.json +15 -0
  320. package/packages/coworker-vault/tsconfig.publish.json +4 -0
  321. package/packages/daemon/package.json +3 -3
  322. package/packages/mcp-server/package.json +3 -3
  323. package/packages/mcp-server/tsconfig.tsbuildinfo +1 -1
  324. package/packages/native/package.json +1 -1
  325. package/packages/native/tsconfig.tsbuildinfo +1 -1
  326. package/packages/pi-agent-core/package.json +1 -1
  327. package/packages/pi-agent-core/tsconfig.tsbuildinfo +1 -1
  328. package/packages/pi-ai/package.json +1 -1
  329. package/packages/pi-ai/tsconfig.tsbuildinfo +1 -1
  330. package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts +6 -1
  331. package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts.map +1 -1
  332. package/packages/pi-coding-agent/dist/core/extensions/runner.js +22 -3
  333. package/packages/pi-coding-agent/dist/core/extensions/runner.js.map +1 -1
  334. package/packages/pi-coding-agent/dist/core/resolve-config-value.test.js +11 -0
  335. package/packages/pi-coding-agent/dist/core/resolve-config-value.test.js.map +1 -1
  336. package/packages/pi-coding-agent/dist/modes/rpc/raw-stdout.d.ts +47 -0
  337. package/packages/pi-coding-agent/dist/modes/rpc/raw-stdout.d.ts.map +1 -0
  338. package/packages/pi-coding-agent/dist/modes/rpc/raw-stdout.js +107 -0
  339. package/packages/pi-coding-agent/dist/modes/rpc/raw-stdout.js.map +1 -0
  340. package/packages/pi-coding-agent/dist/modes/rpc/raw-stdout.regression.test.d.ts +19 -0
  341. package/packages/pi-coding-agent/dist/modes/rpc/raw-stdout.regression.test.d.ts.map +1 -0
  342. package/packages/pi-coding-agent/dist/modes/rpc/raw-stdout.regression.test.js +121 -0
  343. package/packages/pi-coding-agent/dist/modes/rpc/raw-stdout.regression.test.js.map +1 -0
  344. package/packages/pi-coding-agent/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  345. package/packages/pi-coding-agent/dist/modes/rpc/rpc-mode.js +17 -1
  346. package/packages/pi-coding-agent/dist/modes/rpc/rpc-mode.js.map +1 -1
  347. package/packages/pi-coding-agent/package.json +2 -2
  348. package/packages/pi-coding-agent/src/core/extensions/runner.ts +22 -3
  349. package/packages/pi-coding-agent/src/core/resolve-config-value.test.ts +11 -0
  350. package/packages/pi-coding-agent/src/modes/rpc/raw-stdout.regression.test.ts +129 -0
  351. package/packages/pi-coding-agent/src/modes/rpc/raw-stdout.ts +117 -0
  352. package/packages/pi-coding-agent/src/modes/rpc/rpc-mode.ts +18 -1
  353. package/packages/pi-coding-agent/tsconfig.tsbuildinfo +1 -1
  354. package/packages/pi-tui/package.json +1 -1
  355. package/packages/pi-tui/tsconfig.tsbuildinfo +1 -1
  356. package/packages/rpc-client/package.json +2 -2
  357. package/packages/rpc-client/tsconfig.tsbuildinfo +1 -1
  358. package/pkg/package.json +1 -1
  359. package/scripts/install.js +6 -5
  360. package/src/resources/extensions/coworker-artifacts/artifacts-command.test.ts +54 -0
  361. package/src/resources/extensions/coworker-artifacts/artifacts-command.ts +43 -0
  362. package/src/resources/extensions/coworker-artifacts/artifacts-singleton.test.ts +25 -0
  363. package/src/resources/extensions/coworker-artifacts/artifacts-singleton.ts +29 -0
  364. package/src/resources/extensions/coworker-artifacts/extension-manifest.json +13 -0
  365. package/src/resources/extensions/coworker-artifacts/index.test.ts +46 -0
  366. package/src/resources/extensions/coworker-artifacts/index.ts +154 -0
  367. package/src/resources/extensions/coworker-artifacts/list-tool.test.ts +29 -0
  368. package/src/resources/extensions/coworker-artifacts/list-tool.ts +53 -0
  369. package/src/resources/extensions/coworker-artifacts/open-tool.test.ts +30 -0
  370. package/src/resources/extensions/coworker-artifacts/open-tool.ts +43 -0
  371. package/src/resources/extensions/coworker-memory/extension-manifest.json +13 -0
  372. package/src/resources/extensions/coworker-memory/index.test.ts +137 -0
  373. package/src/resources/extensions/coworker-memory/index.ts +257 -0
  374. package/src/resources/extensions/coworker-memory/memorize-tool.test.ts +41 -0
  375. package/src/resources/extensions/coworker-memory/memorize-tool.ts +20 -0
  376. package/src/resources/extensions/coworker-memory/memory-command.test.ts +134 -0
  377. package/src/resources/extensions/coworker-memory/memory-command.ts +131 -0
  378. package/src/resources/extensions/coworker-memory/memory-singleton.test.ts +41 -0
  379. package/src/resources/extensions/coworker-memory/memory-singleton.ts +89 -0
  380. package/src/resources/extensions/coworker-memory/recall-tool.test.ts +50 -0
  381. package/src/resources/extensions/coworker-memory/recall-tool.ts +35 -0
  382. package/src/resources/extensions/coworker-memory/session-hooks.test.ts +77 -0
  383. package/src/resources/extensions/coworker-memory/session-hooks.ts +61 -0
  384. package/src/resources/extensions/coworker-scratchpad/attach-banners.test.ts +124 -0
  385. package/src/resources/extensions/coworker-scratchpad/attach-banners.ts +67 -0
  386. package/src/resources/extensions/coworker-scratchpad/extension-manifest.json +13 -0
  387. package/src/resources/extensions/coworker-scratchpad/format-age.test.ts +30 -0
  388. package/src/resources/extensions/coworker-scratchpad/format-age.ts +6 -0
  389. package/src/resources/extensions/coworker-scratchpad/helpers.test.ts +93 -0
  390. package/src/resources/extensions/coworker-scratchpad/helpers.ts +42 -0
  391. package/src/resources/extensions/coworker-scratchpad/index.test.ts +514 -0
  392. package/src/resources/extensions/coworker-scratchpad/index.ts +207 -0
  393. package/src/resources/extensions/coworker-scratchpad/mime-bundle.test.ts +61 -0
  394. package/src/resources/extensions/coworker-scratchpad/mime-bundle.ts +23 -0
  395. package/src/resources/extensions/coworker-scratchpad/scratchpad-tool.test.ts +137 -0
  396. package/src/resources/extensions/coworker-scratchpad/scratchpad-tool.ts +165 -0
  397. package/src/resources/extensions/coworker-scratchpad/session-sidecar.test.ts +133 -0
  398. package/src/resources/extensions/coworker-scratchpad/session-sidecar.ts +68 -0
  399. package/src/resources/extensions/coworker-scratchpad/sp-command.test.ts +836 -0
  400. package/src/resources/extensions/coworker-scratchpad/sp-command.ts +602 -0
  401. package/src/resources/extensions/coworker-scratchpad/workspace-pointer.test.ts +74 -0
  402. package/src/resources/extensions/coworker-scratchpad/workspace-pointer.ts +55 -0
  403. package/src/resources/extensions/coworker-scratchpad/workspace-root.test.ts +51 -0
  404. package/src/resources/extensions/coworker-scratchpad/workspace-root.ts +16 -0
  405. package/src/resources/extensions/coworker-vault/audit-command.test.ts +109 -0
  406. package/src/resources/extensions/coworker-vault/audit-command.ts +56 -0
  407. package/src/resources/extensions/coworker-vault/connect-command.test.ts +103 -0
  408. package/src/resources/extensions/coworker-vault/connect-command.ts +69 -0
  409. package/src/resources/extensions/coworker-vault/datasource-command.test.ts +80 -0
  410. package/src/resources/extensions/coworker-vault/datasource-command.ts +81 -0
  411. package/src/resources/extensions/coworker-vault/extension-manifest.json +12 -0
  412. package/src/resources/extensions/coworker-vault/index.test.ts +82 -0
  413. package/src/resources/extensions/coworker-vault/index.ts +181 -0
  414. package/src/resources/extensions/coworker-vault/test-helpers.ts +120 -0
  415. package/src/resources/extensions/coworker-vault/vault-singleton.test.ts +27 -0
  416. package/src/resources/extensions/coworker-vault/vault-singleton.ts +40 -0
  417. package/src/resources/extensions/otto/commands/release-notes/_data.ts +97 -0
  418. package/src/resources/extensions/otto/commands/release-notes/command.ts +16 -3
  419. package/src/resources/extensions/otto/index.ts +29 -6
  420. package/src/resources/extensions/shared/coworker-paths.test.ts +40 -0
  421. package/src/resources/extensions/shared/coworker-paths.ts +10 -0
  422. package/src/resources/extensions/slash-commands/{audit.ts → audit-codebase.ts} +4 -4
  423. package/src/resources/extensions/slash-commands/extension-manifest.json +1 -1
  424. package/src/resources/extensions/slash-commands/index.ts +2 -2
  425. package/src/resources/extensions/subagent/index.ts +9 -0
  426. package/src/resources/extensions/subagent/launch.test.ts +97 -0
  427. package/src/resources/extensions/subagent/launch.ts +42 -5
  428. package/src/resources/extensions/subagent/run-store.ts +3 -1
  429. package/src/resources/extensions/workflow/bootstrap/register-extension.ts +2 -0
  430. package/src/resources/extensions/workflow/bootstrap/register-hooks.ts +10 -0
  431. package/src/resources/extensions/workflow/health-widget-core.ts +1 -1
  432. package/src/resources/extensions/workflow/persona-status.ts +109 -0
  433. package/src/resources/extensions/workflow/tests/auto-recovery.test.ts +34 -0
@@ -0,0 +1,891 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import type { CredentialInjector } from '@otto/coworker-vault';
5
+ import type { AuditLog } from '@otto/coworker-utils';
6
+ import type { ArtifactKind, ArtifactStore } from '@otto/coworker-artifacts';
7
+ import { ChildProcessRuntime, type ChildProcessRuntimeOptions } from './child-process-runtime.js';
8
+ import { acquireLock, releaseLock, type LockInfo } from './scratchpad-lock.js';
9
+ import { CellArchive, type CellEntry } from './cell-archive.js';
10
+ import { projectTree, validateLeafId } from './cell-tree.js';
11
+ import { redactForJournal } from './kernel-bindings.js';
12
+ import type { ArtifactCreateDrawer, DataLoadDrawer, RecoveryNote, SnapshotResult } from './kernel-protocol.js';
13
+
14
+ type RecoveryNoteEntry = RecoveryNote & { at: string };
15
+
16
+ export class ForkKernelHangError extends Error {
17
+ constructor(public readonly srcName: string, public readonly pid: number) {
18
+ super(`fork: source kernel for '${srcName}' (pid ${pid}) did not exit after SIGTERM + SIGKILL. Destination may be partially populated; clean up with /sp remove <dst>.`);
19
+ this.name = 'ForkKernelHangError';
20
+ }
21
+ }
22
+
23
+ export interface ScratchpadManagerOptions {
24
+ workspace: string;
25
+ root?: string;
26
+ maxLiveKernels?: number;
27
+ idleMs?: number;
28
+ sweepIntervalMs?: number;
29
+ now?: () => number;
30
+ runtimeOptions?: Omit<ChildProcessRuntimeOptions, 'workspace'>;
31
+ sessionId?: string;
32
+ forkExitTimeoutMs?: number;
33
+ /**
34
+ * Phase 2 Task 13: optional vault credential injector. When provided, each
35
+ * spawned ChildProcessRuntime receives the injector + the scratchpad's
36
+ * meta.bindings list. Absent => runtime spawns with no OTTO_DS_* env vars.
37
+ */
38
+ injector?: CredentialInjector;
39
+ /**
40
+ * Phase 2 Task 14: optional audit sink for SecretScanner redactions on
41
+ * cell-output journal writes. When provided, every cell run's stdout is
42
+ * scanned BEFORE archive.append and emits one `producer: 'secret-scanner'`
43
+ * record per hit. Absent => redaction is a no-op (backward compat).
44
+ *
45
+ * Wiring contract: the caller is expected to pass the SAME AuditLog instance
46
+ * held by the CredentialInjector so secret-scanner records appear alongside
47
+ * vault inject/inject-skipped records in a single audit stream.
48
+ */
49
+ audit?: AuditLog;
50
+ /**
51
+ * Phase 3 Task 19: cross-pillar hook for the memory pillar's MemoryRecorder.
52
+ * Invoked once per `otto.collectors.open(...).load()` call inside a cell,
53
+ * with the kernel's `DataLoadDrawer` and the scratchpad name that produced it.
54
+ * The manager fans this through every spawn of every scratchpad (the callback
55
+ * is closure-bound to the name at spawn time, so multi-scratchpad sessions
56
+ * route loads to the correct room). Absent => data_load events are dropped.
57
+ */
58
+ onDataLoad?: (drawer: DataLoadDrawer, scratchpadName: string) => void;
59
+ /**
60
+ * Phase 4 Task 10: cross-pillar hook for the memory pillar's MemoryRecorder.
61
+ * Invoked once per `otto.artifact.create(...)` call inside a cell, with the
62
+ * kernel's `ArtifactCreateDrawer` and the scratchpad name that produced it.
63
+ * Closure-bound to the name at spawn time (same pattern as onDataLoad), so
64
+ * multi-scratchpad sessions route artifact events to the correct room.
65
+ * Absent => artifact_create events are dropped (memory stays silent).
66
+ */
67
+ onArtifactCreate?: (drawer: ArtifactCreateDrawer, scratchpadName: string) => void;
68
+ /**
69
+ * Phase 4 Task 10: lazy accessor for the ArtifactStore. The manager calls
70
+ * this each time it services an artifact RPC so the resolution sees the
71
+ * current extension activation state (callers can flip the store on/off as
72
+ * the artifacts extension activates/deactivates). When this returns null the
73
+ * RPC fails fast with "artifacts unavailable" — the kernel surfaces the
74
+ * error to the cell. Absent => RPC always fails (no artifact store wired).
75
+ */
76
+ getArtifactStore?: () => ArtifactStore | null;
77
+ }
78
+
79
+ export interface AttachOptions {
80
+ forceTakeover?: boolean;
81
+ takeoverReason?: string;
82
+ /**
83
+ * Phase 2 Task 12: optional list of binding ids (e.g. ['jira:prod']) to record
84
+ * in meta.json on first create. Subsequent meta writes preserve whatever's on
85
+ * disk via prevExtras — once persisted, bindings survive every other meta-write
86
+ * path. Ignored on re-attach if meta.json already has a bindings field.
87
+ */
88
+ bindings?: string[];
89
+ }
90
+
91
+ export interface ScratchpadInfo {
92
+ name: string;
93
+ live: boolean;
94
+ lastUsedAt: number;
95
+ hasActiveCell: boolean; // Task D: true iff warm AND a cell is currently executing
96
+ }
97
+
98
+ interface Entry {
99
+ runtime: ChildProcessRuntime | null; // null when cold (evicted, lock retained)
100
+ lock: LockInfo;
101
+ lastUsedAt: number;
102
+ archive: CellArchive;
103
+ kernelAtCellId: number | null; // 1g2: cell id at which the in-VM kernel state was last mutated
104
+ }
105
+
106
+ const DEFAULT_MAX_LIVE = 8;
107
+ const DEFAULT_IDLE_MS = 600_000;
108
+ const DEFAULT_SWEEP_MS = 30_000;
109
+ const META_SCHEMA_VERSION = 4;
110
+ const MAX_RECOVERY_NOTES = 20;
111
+ const FORK_EXIT_TIMEOUT_MS = 5000;
112
+
113
+ function raceWithTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
114
+ return new Promise<T>((resolve, reject) => {
115
+ let settled = false;
116
+ const timer = setTimeout(() => {
117
+ if (settled) return;
118
+ settled = true;
119
+ reject(new Error(`timeout: ${label}`));
120
+ }, ms);
121
+ timer.unref();
122
+ p.then(
123
+ (v) => { if (settled) return; settled = true; clearTimeout(timer); resolve(v); },
124
+ (e) => { if (settled) return; settled = true; clearTimeout(timer); reject(e); },
125
+ );
126
+ });
127
+ }
128
+
129
+ export class ScratchpadManager {
130
+ protected readonly entries = new Map<string, Entry>();
131
+ protected readonly workspace: string;
132
+ protected readonly root: string;
133
+ protected readonly maxLive: number;
134
+ protected readonly idleMs: number;
135
+ protected readonly sessionId: string | undefined;
136
+ protected readonly now: () => number;
137
+ protected readonly runtimeOptions: Omit<ChildProcessRuntimeOptions, 'workspace'>;
138
+ protected readonly forkExitTimeoutMs: number;
139
+ protected readonly injector: CredentialInjector | undefined;
140
+ protected readonly audit: AuditLog | undefined;
141
+ protected readonly onDataLoad: ((drawer: DataLoadDrawer, scratchpadName: string) => void) | undefined;
142
+ protected readonly onArtifactCreate: ((drawer: ArtifactCreateDrawer, scratchpadName: string) => void) | undefined;
143
+ protected readonly getArtifactStore: (() => ArtifactStore | null) | undefined;
144
+ protected disposed = false;
145
+ private sweepTimer: NodeJS.Timeout | null = null;
146
+
147
+ constructor(options: ScratchpadManagerOptions) {
148
+ this.workspace = options.workspace;
149
+ this.root = options.root ?? join(homedir(), '.otto', 'scratchpads');
150
+ this.maxLive = options.maxLiveKernels ?? DEFAULT_MAX_LIVE;
151
+ this.idleMs = options.idleMs ?? DEFAULT_IDLE_MS;
152
+ this.now = options.now ?? Date.now;
153
+ this.runtimeOptions = options.runtimeOptions ?? {};
154
+ this.sessionId = options.sessionId;
155
+ this.forkExitTimeoutMs = options.forkExitTimeoutMs ?? FORK_EXIT_TIMEOUT_MS;
156
+ this.injector = options.injector;
157
+ this.audit = options.audit;
158
+ this.onDataLoad = options.onDataLoad;
159
+ this.onArtifactCreate = options.onArtifactCreate;
160
+ this.getArtifactStore = options.getArtifactStore;
161
+ this.sweepTimer = setInterval(() => { void this.evictIdle(); }, options.sweepIntervalMs ?? DEFAULT_SWEEP_MS);
162
+ this.sweepTimer.unref();
163
+ }
164
+
165
+ protected dirFor(name: string): string {
166
+ return join(this.root, name);
167
+ }
168
+
169
+ private metaPath(name: string): string {
170
+ return join(this.dirFor(name), 'meta.json');
171
+ }
172
+
173
+ private existsOnDisk(name: string): boolean {
174
+ return existsSync(this.metaPath(name));
175
+ }
176
+
177
+ private payloadSize(dir: string): number {
178
+ let total = 0;
179
+ for (const f of ['kernel.db', 'kernel.db.wal', 'namespace.json', 'cells.jsonl']) {
180
+ try {
181
+ total += statSync(join(dir, f)).size;
182
+ } catch {
183
+ // not present -> skip (no-op contribution)
184
+ }
185
+ }
186
+ return total;
187
+ }
188
+
189
+ private writeMetaAtomic(path: string, payload: unknown): void {
190
+ const tmp = `${path}.tmp`;
191
+ writeFileSync(tmp, JSON.stringify(payload, null, 2));
192
+ renameSync(tmp, path);
193
+ }
194
+
195
+ private writeMeta(name: string, initialBindings?: string[]): void {
196
+ const dir = this.dirFor(name);
197
+ const path = this.metaPath(name);
198
+ mkdirSync(dir, { recursive: true });
199
+ const nowIso = new Date(this.now()).toISOString();
200
+ let created_at = nowIso;
201
+ let attached_sessions: string[] = [];
202
+ // Phase 2 Task 12: bindings persistence + v3→v4 migration.
203
+ // - On first write (no prev), use the passed-in initialBindings (default []).
204
+ // - On subsequent writes, preserve whatever's on disk (migrating v3 → []).
205
+ let bindings: string[] = Array.isArray(initialBindings) ? [...initialBindings] : [];
206
+ const prevExtras: Record<string, unknown> = {};
207
+ if (existsSync(path)) {
208
+ try {
209
+ const prev = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>;
210
+ if (typeof prev.created_at === 'string') created_at = prev.created_at;
211
+ if (Array.isArray(prev.attached_sessions)) attached_sessions = prev.attached_sessions as string[];
212
+ // v3 → v4 migration: bindings field is missing on v3; default to [].
213
+ bindings = Array.isArray(prev.bindings) ? (prev.bindings as string[]) : [];
214
+ for (const k of [
215
+ 'last_snapshot_cell_id', 'last_snapshot_at', 'namespace_skipped', 'recovery_notes',
216
+ 'cell_leaf_id', 'kernel_at_cell_id', 'recovery_notes_seen_at',
217
+ ]) {
218
+ if (k in prev) prevExtras[k] = prev[k];
219
+ }
220
+ if (Array.isArray(prevExtras.recovery_notes)) {
221
+ const rn = prevExtras.recovery_notes as unknown[];
222
+ prevExtras.recovery_notes = rn.slice(Math.max(0, rn.length - MAX_RECOVERY_NOTES));
223
+ }
224
+ } catch {
225
+ // corrupt meta -> drop extras + rewrite fresh
226
+ }
227
+ }
228
+ if (this.sessionId && !attached_sessions.includes(this.sessionId)) {
229
+ attached_sessions.push(this.sessionId);
230
+ }
231
+ const archive = this.entries.get(name)?.archive;
232
+ if (archive && archive.leafId !== null) {
233
+ prevExtras.cell_leaf_id = archive.leafId;
234
+ }
235
+ const liveEntry = this.entries.get(name);
236
+ if (liveEntry && liveEntry.kernelAtCellId !== null) {
237
+ prevExtras.kernel_at_cell_id = liveEntry.kernelAtCellId;
238
+ }
239
+ const meta = {
240
+ name,
241
+ created_at,
242
+ last_used: nowIso,
243
+ attached_sessions,
244
+ bindings,
245
+ size_bytes: this.payloadSize(dir),
246
+ schema_version: META_SCHEMA_VERSION,
247
+ ...prevExtras,
248
+ kernel_db: { present: existsSync(join(dir, 'kernel.db')), path: 'kernel.db' },
249
+ namespace: { present: existsSync(join(dir, 'namespace.json')), schema_version: 1 },
250
+ };
251
+ this.writeMetaAtomic(path, meta);
252
+ }
253
+
254
+ private appendRecoveryNotes(name: string, notes: RecoveryNote[]): void {
255
+ if (notes.length === 0) return;
256
+ const path = this.metaPath(name);
257
+ if (!existsSync(path)) return; // no meta yet; nothing to attach notes to
258
+ let cur: Record<string, unknown> = {};
259
+ try {
260
+ cur = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>;
261
+ } catch {
262
+ // corrupt meta -> do NOT rewrite as a fragment that destroys other fields.
263
+ // The next successful writeMeta call will re-establish a coherent shape.
264
+ return;
265
+ }
266
+ const prior = Array.isArray(cur.recovery_notes) ? (cur.recovery_notes as RecoveryNoteEntry[]) : [];
267
+ const stamped: RecoveryNoteEntry[] = notes.map((n) => ({ at: new Date(this.now()).toISOString(), ...n }));
268
+ const merged = [...prior, ...stamped];
269
+ cur.recovery_notes = merged.slice(Math.max(0, merged.length - MAX_RECOVERY_NOTES));
270
+ this.writeMetaAtomic(path, cur);
271
+ }
272
+
273
+ private applySnapshotToMeta(name: string, entry: Entry, res: Extract<SnapshotResult, { ok: true }>): void {
274
+ const path = this.metaPath(name);
275
+ if (!existsSync(path)) return;
276
+ let cur: Record<string, unknown> = {};
277
+ try {
278
+ cur = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>;
279
+ } catch {
280
+ return;
281
+ }
282
+ cur.last_snapshot_cell_id = entry.archive.lastId;
283
+ cur.last_snapshot_at = res.snapshotted_at;
284
+ cur.namespace_skipped = res.skipped;
285
+ cur.namespace = { present: true, schema_version: 1 };
286
+ cur.kernel_db = { present: existsSync(join(this.dirFor(name), 'kernel.db')), path: 'kernel.db' };
287
+ this.writeMetaAtomic(path, cur);
288
+ }
289
+
290
+ private async snapshotThenDispose(name: string, entry: Entry): Promise<void> {
291
+ const rt = entry.runtime;
292
+ if (!rt) return;
293
+ if (rt.hasActiveCell) {
294
+ // An active cell would block the snapshot indefinitely until cellTimeoutMs fires
295
+ // (kernel processes one NDJSON frame at a time). Skip the snapshot and dispose
296
+ // straight away; the next attach will see cells-since-snapshot divergence.
297
+ this.appendRecoveryNotes(name, [{ kind: 'snapshot-failed', message: 'skipped: active cell would block snapshot' }]);
298
+ await rt.dispose();
299
+ if (entry.runtime === rt) entry.runtime = null;
300
+ return;
301
+ }
302
+ const res = await rt.snapshot();
303
+ if (res.ok) {
304
+ this.applySnapshotToMeta(name, entry, res);
305
+ } else {
306
+ this.appendRecoveryNotes(name, [{ kind: 'snapshot-failed', message: res.error.message }]);
307
+ }
308
+ await rt.dispose();
309
+ // Only null the field if no concurrent caller has already replaced or cleared it.
310
+ if (entry.runtime === rt) entry.runtime = null;
311
+ }
312
+
313
+ private ingestRecoveryNotesOnAttach(name: string, entry: Entry): void {
314
+ const notes: RecoveryNote[] = [...entry.runtime!.recoveryNotes];
315
+ // Divergence: compare archive.lastId to last_snapshot_cell_id on disk.
316
+ const path = this.metaPath(name);
317
+ if (existsSync(path)) {
318
+ try {
319
+ const cur = JSON.parse(readFileSync(path, 'utf8')) as { last_snapshot_cell_id?: unknown };
320
+ const last = cur.last_snapshot_cell_id;
321
+ const archiveId = entry.archive.lastId;
322
+ if (typeof last === 'number' && typeof archiveId === 'number' && archiveId > last) {
323
+ notes.push({ kind: 'cells-since-snapshot', n: archiveId - last });
324
+ }
325
+ } catch {
326
+ // ignore; covered by the namespace-corrupt note path
327
+ }
328
+ }
329
+ this.appendRecoveryNotes(name, notes);
330
+ }
331
+
332
+ private restoreLeafOnAttach(name: string, entry: Entry): void {
333
+ const path = this.metaPath(name);
334
+ if (!existsSync(path)) return;
335
+ try {
336
+ const cur = JSON.parse(readFileSync(path, 'utf8')) as { cell_leaf_id?: unknown };
337
+ const persisted = cur.cell_leaf_id;
338
+ if (typeof persisted === 'number' && entry.archive.leafId !== persisted) {
339
+ entry.archive.setLeaf(persisted);
340
+ }
341
+ } catch {
342
+ // ignore; leaf falls back to file-max (the constructor's default).
343
+ }
344
+ }
345
+
346
+ private restoreKernelAtCellIdOnAttach(name: string, entry: Entry): void {
347
+ // Cold restore: kernel was hydrated from namespace.json, which was written at
348
+ // last_snapshot_cell_id. That's where the in-VM state lives.
349
+ const path = this.metaPath(name);
350
+ if (!existsSync(path)) {
351
+ entry.kernelAtCellId = null;
352
+ return;
353
+ }
354
+ try {
355
+ const cur = JSON.parse(readFileSync(path, 'utf8')) as { last_snapshot_cell_id?: unknown };
356
+ const last = cur.last_snapshot_cell_id;
357
+ entry.kernelAtCellId = typeof last === 'number' ? last : null;
358
+ } catch {
359
+ entry.kernelAtCellId = null;
360
+ }
361
+ }
362
+
363
+ private warmCount(): number {
364
+ let n = 0;
365
+ for (const e of this.entries.values()) if (e.runtime !== null) n++;
366
+ return n;
367
+ }
368
+
369
+ /**
370
+ * Phase 2 Task 13: read meta.bindings from disk so each fresh spawn picks up
371
+ * the current binding set (bindings can change between attaches via /sp use).
372
+ * Returns [] if meta.json doesn't exist or doesn't have a v4 bindings array.
373
+ *
374
+ * Phase 2 Task 16: also surfaced as a public read for /sp list rendering and
375
+ * staleness-banner emission. Stays a thin read; callers that need to mutate
376
+ * use addBinding / removeBinding (which atomically RMW meta.json).
377
+ */
378
+ readBindings(name: string): string[] {
379
+ const path = this.metaPath(name);
380
+ if (!existsSync(path)) return [];
381
+ try {
382
+ const cur = JSON.parse(readFileSync(path, 'utf8')) as { bindings?: unknown };
383
+ return Array.isArray(cur.bindings) ? (cur.bindings as string[]) : [];
384
+ } catch {
385
+ return [];
386
+ }
387
+ }
388
+
389
+ /**
390
+ * Phase 2 Task 16: append a binding ref (e.g. 'jira:prod') to meta.bindings.
391
+ * Idempotent — adding a ref already in the list is a no-op (the meta.json
392
+ * write still happens so callers can detect "added" vs "noop" only via the
393
+ * returned tuple). Atomically rewrites meta.json via writeMetaAtomic, so
394
+ * concurrent writers cannot interleave. Caller is responsible for validating
395
+ * `ref` (sp-command uses LocalDataVault.parseRef before invoking).
396
+ */
397
+ async addBinding(name: string, ref: string): Promise<{ added: boolean }> {
398
+ this.assertNotDisposed();
399
+ if (!this.existsOnDisk(name)) throw new Error(`scratchpad not found: ${name}`);
400
+ const path = this.metaPath(name);
401
+ let cur: Record<string, unknown> = {};
402
+ try { cur = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>; } catch { /* corrupt -> overwrite */ }
403
+ const bindings = Array.isArray(cur.bindings) ? [...(cur.bindings as string[])] : [];
404
+ if (bindings.includes(ref)) {
405
+ return { added: false };
406
+ }
407
+ bindings.push(ref);
408
+ cur.bindings = bindings;
409
+ if (typeof cur.schema_version !== 'number') cur.schema_version = META_SCHEMA_VERSION;
410
+ this.writeMetaAtomic(path, cur);
411
+ return { added: true };
412
+ }
413
+
414
+ /**
415
+ * Phase 2 Task 16: remove a binding ref from meta.bindings. Returns whether
416
+ * a removal happened so callers can emit "no such binding" if needed.
417
+ */
418
+ async removeBinding(name: string, ref: string): Promise<{ removed: boolean }> {
419
+ this.assertNotDisposed();
420
+ if (!this.existsOnDisk(name)) throw new Error(`scratchpad not found: ${name}`);
421
+ const path = this.metaPath(name);
422
+ let cur: Record<string, unknown> = {};
423
+ try { cur = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>; } catch { /* corrupt -> overwrite */ }
424
+ const bindings = Array.isArray(cur.bindings) ? [...(cur.bindings as string[])] : [];
425
+ const idx = bindings.indexOf(ref);
426
+ if (idx < 0) {
427
+ return { removed: false };
428
+ }
429
+ bindings.splice(idx, 1);
430
+ cur.bindings = bindings;
431
+ if (typeof cur.schema_version !== 'number') cur.schema_version = META_SCHEMA_VERSION;
432
+ this.writeMetaAtomic(path, cur);
433
+ return { removed: true };
434
+ }
435
+
436
+ private async spawnRuntime(name: string): Promise<ChildProcessRuntime> {
437
+ // Phase 3 Task 19: bridge the manager-level onDataLoad (which receives
438
+ // the scratchpad name) to the runtime-level onDataLoad (which doesn't).
439
+ // Closure-bound to `name` here so each spawned runtime tags its drawers
440
+ // with the correct scratchpad even when the manager is shared. If both
441
+ // the manager and runtimeOptions supply an onDataLoad, the manager's
442
+ // wins — runtimeOptions.onDataLoad never had a way to know the name.
443
+ const fanout = this.onDataLoad;
444
+ const onDataLoad = fanout
445
+ ? (drawer: DataLoadDrawer): void => fanout(drawer, name)
446
+ : this.runtimeOptions.onDataLoad;
447
+ // Phase 4 Task 10: mirror the onDataLoad fan-out pattern for artifact_create.
448
+ // Closure-binds the scratchpad name so multi-pad sessions tag drawers correctly.
449
+ const fanArtifactCreate = this.onArtifactCreate;
450
+ const onArtifactCreate = fanArtifactCreate
451
+ ? (drawer: ArtifactCreateDrawer): void => fanArtifactCreate(drawer, name)
452
+ : this.runtimeOptions.onArtifactCreate;
453
+ // Phase 4 Task 10: parent-side RPC handlers backed by the ArtifactStore.
454
+ // Both handlers look up the store lazily through getArtifactStore() so the
455
+ // host can toggle availability without re-spawning the kernel. When the
456
+ // store is unavailable we throw — the runtime converts the throw into an
457
+ // `ok:false` response frame so the awaiting cell rejects cleanly.
458
+ const getStore = this.getArtifactStore;
459
+ const handleArtifactCreate = getStore
460
+ ? async (req: { kind: string; name: string }): Promise<{ slug: string; uri: string; primary_path: string }> => {
461
+ const store = getStore();
462
+ if (!store) throw new Error('artifacts unavailable');
463
+ const handle = await store.create(req.kind as ArtifactKind, req.name);
464
+ return {
465
+ slug: handle.slug,
466
+ uri: handle.uri,
467
+ primary_path: handle.primaryPath,
468
+ };
469
+ }
470
+ : this.runtimeOptions.handleArtifactCreate;
471
+ const handleArtifactUpdate = getStore
472
+ ? async (req: { slug: string; files: Array<{ path: string; content: string }> }): Promise<{ files_touched: string[] }> => {
473
+ const store = getStore();
474
+ if (!store) throw new Error('artifacts unavailable');
475
+ const handle = await store.get(req.slug);
476
+ if (!handle) throw new Error(`unknown artifact: ${req.slug}`);
477
+ return await store.update(handle, req.files);
478
+ }
479
+ : this.runtimeOptions.handleArtifactUpdate;
480
+ const rt = new ChildProcessRuntime({
481
+ workspace: this.workspace,
482
+ scratchpadDir: this.dirFor(name),
483
+ ...this.runtimeOptions,
484
+ onDataLoad,
485
+ onArtifactCreate,
486
+ handleArtifactCreate,
487
+ handleArtifactUpdate,
488
+ // Phase 2 Task 13: env-injection wiring. Injector + bindings are read
489
+ // fresh on every spawn so cold-restarts and re-attaches see the latest
490
+ // bindings list. scratchpadName + sessionId stamp the injector's audit
491
+ // records so /audit can attribute each inject to a scratchpad+session.
492
+ injector: this.injector,
493
+ bindings: this.readBindings(name),
494
+ scratchpadName: name,
495
+ sessionId: this.sessionId ?? '',
496
+ });
497
+ await rt.start();
498
+ return rt;
499
+ }
500
+
501
+ private async evictLruIfNeeded(): Promise<void> {
502
+ while (this.warmCount() >= this.maxLive) {
503
+ let victim: Entry | null = null;
504
+ for (const e of this.entries.values()) {
505
+ if (e.runtime === null) continue; // already cold
506
+ if (e.runtime.hasActiveCell) continue; // never evict a busy kernel
507
+ if (victim === null || e.lastUsedAt < victim.lastUsedAt) victim = e;
508
+ }
509
+ if (victim === null) break; // every warm kernel is busy; pool may momentarily exceed (documented)
510
+ // The map key for the LRU victim is needed for snapshotThenDispose; find it now.
511
+ let victimName: string | null = null;
512
+ for (const [n, e] of this.entries) { if (e === victim) { victimName = n; break; } }
513
+ if (victimName === null) break; // defensive; should be impossible
514
+ await this.snapshotThenDispose(victimName, victim);
515
+ }
516
+ }
517
+
518
+ async create(name: string, opts: AttachOptions = {}): Promise<ChildProcessRuntime> {
519
+ this.assertNotDisposed();
520
+ if (this.entries.has(name) || this.existsOnDisk(name)) {
521
+ throw new Error(`scratchpad ${name} already exists`);
522
+ }
523
+ return this.attachUnmanaged(name, opts);
524
+ }
525
+
526
+ async getOrAttach(name: string, opts: AttachOptions = {}): Promise<ChildProcessRuntime> {
527
+ this.assertNotDisposed();
528
+ const existing = this.entries.get(name);
529
+ if (existing) {
530
+ existing.lastUsedAt = this.now();
531
+ if (existing.runtime) return existing.runtime;
532
+ await this.evictLruIfNeeded();
533
+ existing.runtime = await this.spawnRuntime(name); // cold -> warm; namespace restored from disk (1d2)
534
+ this.ingestRecoveryNotesOnAttach(name, existing);
535
+ this.restoreLeafOnAttach(name, existing);
536
+ this.restoreKernelAtCellIdOnAttach(name, existing);
537
+ return existing.runtime;
538
+ }
539
+ return this.attachUnmanaged(name, opts);
540
+ }
541
+
542
+ async runCell(name: string, code: string, opts: AttachOptions = {}): Promise<{ value: unknown; stdout: string }> {
543
+ this.assertNotDisposed();
544
+ const runtime = await this.getOrAttach(name, opts);
545
+ const entry = this.entries.get(name)!;
546
+ entry.lastUsedAt = this.now();
547
+ // Phase 2 Task 14: forecast the id the archive will assign to this cell so
548
+ // any secret-scanner audit records carry the same cell_id the journal entry
549
+ // will get. archive.lastId may be null for an empty archive (id 1 is next).
550
+ const nextCellId = (entry.archive.lastId ?? 0) + 1;
551
+ try {
552
+ const result = await runtime.runCell(code);
553
+ // Phase 2 Task 14: live TUI output is UPSTREAM — `result` flows back to
554
+ // the tool unchanged for display. Only the journal copy of stdout is
555
+ // passed through redactForJournal. When no audit is plumbed, redaction
556
+ // is a pass-through (backward compat).
557
+ const journalStdout = this.redactStdout(result.stdout, name, nextCellId);
558
+ entry.archive.append({ code, ok: true, value: result.value, stdout: journalStdout });
559
+ entry.kernelAtCellId = entry.archive.lastId;
560
+ this.writeMeta(name);
561
+ return result;
562
+ } catch (err) {
563
+ const e = err as Error;
564
+ try {
565
+ // Phase 2 Task 14: redact the error message too — cell exceptions can
566
+ // embed user data in `e.message`. stdout is empty in this branch.
567
+ const journalErrMsg = this.redactStdout(e.message, name, nextCellId);
568
+ entry.archive.append({ code, ok: false, error: { name: e.name, message: journalErrMsg }, stdout: '' });
569
+ entry.kernelAtCellId = entry.archive.lastId;
570
+ this.writeMeta(name);
571
+ } catch {
572
+ // recording the failure must never mask the original cell error
573
+ }
574
+ throw err;
575
+ }
576
+ }
577
+
578
+ /**
579
+ * Phase 2 Task 14: redact known-secret patterns from a cell-output string
580
+ * before journaling. No-op (pass-through) when no AuditLog is configured —
581
+ * the manager was constructed in test/legacy mode without vault wiring.
582
+ */
583
+ private redactStdout(raw: string, scratchpadName: string, cellId: number): string {
584
+ if (!this.audit) return raw;
585
+ return redactForJournal(raw, {
586
+ audit: this.audit,
587
+ sessionId: this.sessionId ?? '',
588
+ scratchpadName,
589
+ pid: process.pid,
590
+ cellId: String(cellId),
591
+ });
592
+ }
593
+
594
+ /**
595
+ * Task D: Release a warm kernel's process+memory while preserving on-disk state
596
+ * (kernel.db, namespace.json, cells.jsonl, meta.json, lock.json). Cold-restart
597
+ * happens on the next attach.
598
+ *
599
+ * Without --force: refuses if a cell is mid-execution. With --force: cancels the
600
+ * active cell via runtime.cancel() (SIGINT → SIGTERM → SIGKILL escalation handled
601
+ * internally by ChildProcessRuntime). Post-cancel the kernel is dead, so we skip
602
+ * the snapshot — the next attach replays from cells.jsonl.
603
+ */
604
+ async evict(name: string, opts: { force?: boolean } = {}): Promise<{ interrupted: boolean }> {
605
+ this.assertNotDisposed();
606
+ const entry = this.entries.get(name);
607
+ if (!entry || !entry.runtime) {
608
+ throw new Error(`scratchpad ${name} is not warm (already cold)`);
609
+ }
610
+ if (entry.runtime.hasActiveCell) {
611
+ if (!opts.force) {
612
+ throw new Error(`cannot evict ${name}: cell is running (use --force to interrupt)`);
613
+ }
614
+ // --force: cancel via existing SIGINT → SIGTERM → SIGKILL escalation in
615
+ // ChildProcessRuntime.cancel(). After cancel resolves the runtime is dead,
616
+ // so snapshotThenDispose would fail. Skip the snapshot and flip the entry
617
+ // to cold (runtime=null) like snapshotThenDispose would have. The session
618
+ // lock remains held so /sp list still shows the (cold) entry and the next
619
+ // attach cold-restarts from cells.jsonl.
620
+ const rt = entry.runtime;
621
+ await rt.cancel();
622
+ try { await rt.dispose(); } catch { /* already dead — best-effort cleanup */ }
623
+ if (entry.runtime === rt) entry.runtime = null;
624
+ return { interrupted: true };
625
+ }
626
+ await this.snapshotThenDispose(name, entry);
627
+ return { interrupted: false };
628
+ }
629
+
630
+ async setLeaf(name: string, id: number): Promise<void> {
631
+ this.assertNotDisposed();
632
+ // Verify the scratchpad exists on disk (works for both warm and cold).
633
+ if (!this.existsOnDisk(name)) throw new Error(`scratchpad not found: ${name}`);
634
+ // Build a tree from the on-disk cells.jsonl so validation works even when cold.
635
+ const cells: CellEntry[] = [];
636
+ const cellsPath = join(this.dirFor(name), 'cells.jsonl');
637
+ if (existsSync(cellsPath)) {
638
+ for (const line of readFileSync(cellsPath, 'utf8').split('\n')) {
639
+ if (!line.trim()) continue;
640
+ try {
641
+ const obj = JSON.parse(line) as { id?: unknown };
642
+ if (typeof obj.id === 'number') cells.push(obj as CellEntry);
643
+ } catch {
644
+ // header or trailing-corrupt line -> skip
645
+ }
646
+ }
647
+ }
648
+ const tree = projectTree(cells);
649
+ validateLeafId(tree, id);
650
+ // Warm path: update the live archive too.
651
+ const entry = this.entries.get(name);
652
+ if (entry) entry.archive.setLeaf(id);
653
+ // Direct meta update so cold scratchpads persist the leaf.
654
+ const path = this.metaPath(name);
655
+ let cur: Record<string, unknown> = {};
656
+ try { cur = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>; } catch { /* fall through */ }
657
+ cur.cell_leaf_id = id;
658
+ // Phase 2 Task 12: bumping schema_version here doubles as a migration step
659
+ // for cold-only flows; ensure bindings exists so the v4 invariant holds.
660
+ if (!Array.isArray(cur.bindings)) cur.bindings = [];
661
+ cur.schema_version = META_SCHEMA_VERSION;
662
+ this.writeMetaAtomic(path, cur);
663
+ }
664
+
665
+ async fork(srcName: string, dstName: string): Promise<void> {
666
+ this.assertNotDisposed();
667
+ if (this.entries.has(dstName) || this.existsOnDisk(dstName)) {
668
+ throw new Error(`scratchpad ${dstName} already exists`);
669
+ }
670
+ if (!this.existsOnDisk(srcName)) {
671
+ throw new Error(`scratchpad not found: ${srcName}`);
672
+ }
673
+ // Auto-evict src to release the DuckDB kernel.db handle before we copy.
674
+ // Capture the raw child process reference before disposal so we can await its
675
+ // full exit — ensuring DuckDB flushes and closes kernel.db before copyFileSync.
676
+ const srcEntry = this.entries.get(srcName);
677
+ if (srcEntry && srcEntry.runtime) {
678
+ const rawChild = (srcEntry.runtime as unknown as { child: import('node:child_process').ChildProcess | null }).child;
679
+ await this.snapshotThenDispose(srcName, srcEntry);
680
+ if (rawChild && rawChild.exitCode === null) {
681
+ const exitPromise = new Promise<void>((resolve) => rawChild.once('exit', () => resolve()));
682
+ try {
683
+ await raceWithTimeout(exitPromise, this.forkExitTimeoutMs, 'exit-after-SIGTERM');
684
+ } catch {
685
+ rawChild.kill('SIGKILL');
686
+ const exitPromise2 = new Promise<void>((resolve) => rawChild.once('exit', () => resolve()));
687
+ try {
688
+ await raceWithTimeout(exitPromise2, this.forkExitTimeoutMs, 'exit-after-SIGKILL');
689
+ } catch {
690
+ throw new ForkKernelHangError(srcName, rawChild.pid ?? -1);
691
+ }
692
+ }
693
+ }
694
+ }
695
+ const srcDir = this.dirFor(srcName);
696
+ const dstDir = this.dirFor(dstName);
697
+ mkdirSync(dstDir, { recursive: true });
698
+ for (const file of ['kernel.db', 'kernel.db.wal', 'namespace.json', 'cells.jsonl']) {
699
+ if (existsSync(join(srcDir, file))) {
700
+ copyFileSync(join(srcDir, file), join(dstDir, file));
701
+ }
702
+ }
703
+ // Build dst meta inheriting selected fields from src.
704
+ let srcMeta: Record<string, unknown> = {};
705
+ try { srcMeta = JSON.parse(readFileSync(join(srcDir, 'meta.json'), 'utf8')) as Record<string, unknown>; } catch { /* leave empty */ }
706
+ const nowIso = new Date(this.now()).toISOString();
707
+ const dstMeta = {
708
+ name: dstName,
709
+ created_at: nowIso,
710
+ last_used: nowIso,
711
+ attached_sessions: this.sessionId ? [this.sessionId] : [],
712
+ // Phase 2 Task 16: fork inherits src's bindings so the forked scratchpad
713
+ // spawns its kernel with the same OTTO_DS_* env block. Users can
714
+ // /sp unuse on dst afterwards if they want a different binding shape.
715
+ bindings: Array.isArray(srcMeta.bindings) ? [...(srcMeta.bindings as string[])] : [],
716
+ size_bytes: this.payloadSize(dstDir),
717
+ schema_version: META_SCHEMA_VERSION,
718
+ cell_leaf_id: typeof srcMeta.cell_leaf_id === 'number' ? srcMeta.cell_leaf_id : null,
719
+ last_snapshot_cell_id: typeof srcMeta.last_snapshot_cell_id === 'number' ? srcMeta.last_snapshot_cell_id : null,
720
+ last_snapshot_at: typeof srcMeta.last_snapshot_at === 'string' ? srcMeta.last_snapshot_at : null,
721
+ kernel_at_cell_id: typeof srcMeta.kernel_at_cell_id === 'number'
722
+ ? srcMeta.kernel_at_cell_id
723
+ : (typeof srcMeta.last_snapshot_cell_id === 'number' ? srcMeta.last_snapshot_cell_id : null),
724
+ namespace_skipped: [],
725
+ recovery_notes: [],
726
+ kernel_db: { present: existsSync(join(dstDir, 'kernel.db')), path: 'kernel.db' },
727
+ namespace: { present: existsSync(join(dstDir, 'namespace.json')), schema_version: 1 },
728
+ };
729
+ this.writeMetaAtomic(join(dstDir, 'meta.json'), dstMeta);
730
+ // Claim the new scratchpad for this session by acquiring its lock and registering
731
+ // a cold entry so getOrAttach can re-warm without re-acquiring the lock.
732
+ const dstLock = acquireLock(dstDir, { now: this.now });
733
+ const dstEntry: Entry = {
734
+ runtime: null,
735
+ lock: dstLock,
736
+ lastUsedAt: this.now(),
737
+ archive: new CellArchive(dstDir, this.now),
738
+ kernelAtCellId: dstMeta.kernel_at_cell_id,
739
+ };
740
+ this.entries.set(dstName, dstEntry);
741
+ }
742
+
743
+ async clearHistory(name: string): Promise<void> {
744
+ this.assertNotDisposed();
745
+ const entry = this.entries.get(name);
746
+ if (entry?.runtime?.hasActiveCell) {
747
+ throw new Error('cannot clear history while a cell is running');
748
+ }
749
+ if (entry?.archive) {
750
+ entry.archive.reset();
751
+ entry.kernelAtCellId = null;
752
+ } else {
753
+ // Cold path: construct a temp archive solely to reuse its truncation logic.
754
+ const tmpArchive = new CellArchive(this.dirFor(name), this.now);
755
+ tmpArchive.reset();
756
+ }
757
+ // Direct meta read-modify-write; we explicitly do NOT route through writeMeta
758
+ // because writeMeta pulls cell_leaf_id from the live archive — which is exactly
759
+ // what we just nulled, but writeMeta would also re-add this.sessionId, which
760
+ // we want preserved untouched here. Safer to read+merge+write directly.
761
+ const path = this.metaPath(name);
762
+ if (existsSync(path)) {
763
+ let cur: Record<string, unknown> = {};
764
+ try { cur = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>; } catch { /* drop */ }
765
+ cur.cell_leaf_id = null;
766
+ cur.last_snapshot_cell_id = null;
767
+ cur.last_snapshot_at = null;
768
+ cur.kernel_at_cell_id = null;
769
+ this.writeMetaAtomic(path, cur);
770
+ }
771
+ }
772
+
773
+ async save(name: string): Promise<void> {
774
+ this.assertNotDisposed();
775
+ const entry = this.entries.get(name);
776
+ if (!entry || !entry.runtime) {
777
+ throw new Error(`scratchpad ${name} is not warm — nothing to save`);
778
+ }
779
+ if (entry.runtime.hasActiveCell) {
780
+ this.appendRecoveryNotes(name, [{ kind: 'snapshot-failed', message: 'active cell' }]);
781
+ throw new Error('cannot save while a cell is running');
782
+ }
783
+ const res = await entry.runtime.snapshot();
784
+ if (res.ok) {
785
+ this.applySnapshotToMeta(name, entry, res);
786
+ } else {
787
+ this.appendRecoveryNotes(name, [{ kind: 'snapshot-failed', message: res.error.message }]);
788
+ throw new Error(`save failed: ${res.error.message}`);
789
+ }
790
+ }
791
+
792
+ async detach(name: string, sessionId: string): Promise<void> {
793
+ this.assertNotDisposed();
794
+ const path = this.metaPath(name);
795
+ if (!existsSync(path)) return;
796
+ let cur: Record<string, unknown> = {};
797
+ try { cur = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>; } catch { return; }
798
+ const arr = Array.isArray(cur.attached_sessions) ? (cur.attached_sessions as string[]) : [];
799
+ const idx = arr.indexOf(sessionId);
800
+ if (idx >= 0) {
801
+ cur.attached_sessions = [...arr.slice(0, idx), ...arr.slice(idx + 1)];
802
+ this.writeMetaAtomic(path, cur);
803
+ }
804
+ // Runtime explicitly NOT disposed. Pool LRU/idle eviction owns cleanup.
805
+ }
806
+
807
+ async markRecoveryNotesSeen(name: string): Promise<void> {
808
+ this.assertNotDisposed();
809
+ const path = this.metaPath(name);
810
+ if (!existsSync(path)) return;
811
+ let cur: Record<string, unknown> = {};
812
+ try { cur = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>; } catch { return; }
813
+ cur.recovery_notes_seen_at = new Date(this.now()).toISOString();
814
+ this.writeMetaAtomic(path, cur);
815
+ }
816
+
817
+ private async attachUnmanaged(name: string, opts: AttachOptions): Promise<ChildProcessRuntime> {
818
+ const dir = this.dirFor(name);
819
+ const lock = acquireLock(dir, {
820
+ forceTakeover: opts.forceTakeover,
821
+ takeoverReason: opts.takeoverReason,
822
+ now: this.now,
823
+ });
824
+ // Phase 2 Task 12: opts.bindings is only honored on first create; on re-attach
825
+ // the on-disk bindings field wins via prevExtras in writeMeta.
826
+ this.writeMeta(name, opts.bindings);
827
+ await this.evictLruIfNeeded();
828
+ let runtime: ChildProcessRuntime;
829
+ try {
830
+ runtime = await this.spawnRuntime(name);
831
+ } catch (err) {
832
+ releaseLock(dir); // don't leak the lock if spawn fails
833
+ throw err;
834
+ }
835
+ this.writeMeta(name); // refresh: kernel.db is now on disk; payloadSize + kernel_db.present become accurate (Task E / Issue #2)
836
+ const entry: Entry = { runtime, lock, lastUsedAt: this.now(), archive: new CellArchive(dir, this.now), kernelAtCellId: null };
837
+ this.entries.set(name, entry);
838
+ this.ingestRecoveryNotesOnAttach(name, entry);
839
+ this.restoreLeafOnAttach(name, entry);
840
+ this.restoreKernelAtCellIdOnAttach(name, entry);
841
+ return runtime;
842
+ }
843
+
844
+ list(): ScratchpadInfo[] {
845
+ return [...this.entries].map(([name, e]) => ({
846
+ name,
847
+ live: e.runtime !== null,
848
+ lastUsedAt: e.lastUsedAt,
849
+ hasActiveCell: e.runtime?.hasActiveCell ?? false,
850
+ }));
851
+ }
852
+
853
+ async remove(name: string): Promise<void> {
854
+ const entry = this.entries.get(name);
855
+ if (entry) {
856
+ await entry.runtime?.dispose();
857
+ this.entries.delete(name);
858
+ }
859
+ rmSync(this.dirFor(name), { recursive: true, force: true }); // deletes lock.json + meta.json
860
+ }
861
+
862
+ async evictIdle(): Promise<void> {
863
+ if (this.disposed) return;
864
+ const cutoff = this.now() - this.idleMs;
865
+ for (const e of this.entries.values()) {
866
+ if (e.runtime === null) continue;
867
+ if (e.runtime.hasActiveCell) continue; // never evict a busy kernel
868
+ if (e.lastUsedAt <= cutoff) {
869
+ // Find the name for this entry to feed snapshotThenDispose.
870
+ let entryName: string | null = null;
871
+ for (const [n, ent] of this.entries) { if (ent === e) { entryName = n; break; } }
872
+ if (entryName !== null) await this.snapshotThenDispose(entryName, e);
873
+ }
874
+ }
875
+ }
876
+
877
+ async disposeAll(): Promise<void> {
878
+ if (this.disposed) return;
879
+ this.disposed = true;
880
+ if (this.sweepTimer) { clearInterval(this.sweepTimer); this.sweepTimer = null; }
881
+ for (const [name, e] of this.entries) {
882
+ if (e.runtime) await this.snapshotThenDispose(name, e);
883
+ releaseLock(this.dirFor(name)); // release lock; leave meta.json (durable)
884
+ }
885
+ this.entries.clear();
886
+ }
887
+
888
+ protected assertNotDisposed(): void {
889
+ if (this.disposed) throw new Error('scratchpad manager disposed');
890
+ }
891
+ }