@phyzess/pappra 0.1.0-beta.13 → 0.1.0-beta.18

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 (357) hide show
  1. package/README.md +134 -15
  2. package/assets/integrations/skills/pappra/SKILL.md +28 -7
  3. package/assets/integrations/skills/pappra/references/collaboration.md +633 -0
  4. package/assets/integrations/skills/pappra/references/environment.md +360 -0
  5. package/assets/integrations/skills/pappra/references/hosts.md +20 -0
  6. package/assets/integrations/skills/pappra/references/operations.md +36 -5
  7. package/dist/adapters/pi/recovery-extension.d.ts +65 -0
  8. package/dist/adapters/pi/recovery-extension.js +238 -0
  9. package/dist/adapters/pi/session-transition-extension.d.ts +30 -0
  10. package/dist/adapters/pi/session-transition-extension.js +265 -0
  11. package/dist/app/automatic-handoff-events.d.ts +26 -0
  12. package/dist/app/automatic-handoff-events.js +134 -0
  13. package/dist/app/bounded-file.d.ts +2 -0
  14. package/dist/app/bounded-file.js +2 -0
  15. package/dist/app/capability-evidence/claude.d.ts +37 -0
  16. package/dist/app/capability-evidence/claude.js +206 -0
  17. package/dist/app/capability-evidence/codex.d.ts +35 -0
  18. package/dist/app/capability-evidence/codex.js +193 -0
  19. package/dist/app/capability-evidence/index.d.ts +33 -0
  20. package/dist/app/capability-evidence/index.js +117 -0
  21. package/dist/app/capability-evidence/pi.d.ts +73 -0
  22. package/dist/app/capability-evidence/pi.js +669 -0
  23. package/dist/app/capability-evidence/shared.d.ts +69 -0
  24. package/dist/app/capability-evidence/shared.js +195 -0
  25. package/dist/app/collab-auto-handoff.d.ts +55 -0
  26. package/dist/app/collab-auto-handoff.js +384 -0
  27. package/dist/app/collab-bindings.d.ts +57 -0
  28. package/dist/app/collab-bindings.js +1006 -0
  29. package/dist/app/collab-bootstrap-transport.d.ts +163 -0
  30. package/dist/app/collab-bootstrap-transport.js +769 -0
  31. package/dist/app/collab-bootstrap.d.ts +137 -0
  32. package/dist/app/collab-bootstrap.js +3455 -0
  33. package/dist/app/collab-handoff.d.ts +12 -0
  34. package/dist/app/collab-handoff.js +511 -0
  35. package/dist/app/collab-messages.d.ts +2 -0
  36. package/dist/app/collab-messages.js +2 -0
  37. package/dist/app/collab-notify.d.ts +156 -0
  38. package/dist/app/collab-notify.js +1208 -0
  39. package/dist/app/collab-preflight.d.ts +34 -0
  40. package/dist/app/collab-preflight.js +96 -0
  41. package/dist/app/collab-recovery-executor.d.ts +161 -0
  42. package/dist/app/collab-recovery-executor.js +1153 -0
  43. package/dist/app/collab-recovery-projection.d.ts +103 -0
  44. package/dist/app/collab-recovery-projection.js +659 -0
  45. package/dist/app/collab-recovery.d.ts +25 -0
  46. package/dist/app/collab-recovery.js +429 -0
  47. package/dist/app/collab-resources.d.ts +16 -0
  48. package/dist/app/collab-resources.js +278 -0
  49. package/dist/app/collab-role-events.d.ts +22 -0
  50. package/dist/app/collab-role-events.js +329 -0
  51. package/dist/app/collab-role-orchestration.d.ts +53 -0
  52. package/dist/app/collab-role-orchestration.js +601 -0
  53. package/dist/app/collab-role-plan.d.ts +81 -0
  54. package/dist/app/collab-role-plan.js +504 -0
  55. package/dist/app/collab-role-repair.d.ts +109 -0
  56. package/dist/app/collab-role-repair.js +821 -0
  57. package/dist/app/collab-rollover.d.ts +263 -0
  58. package/dist/app/collab-rollover.js +1112 -0
  59. package/dist/app/collab-route.d.ts +64 -0
  60. package/dist/app/collab-route.js +240 -0
  61. package/dist/app/collab-session-transition.d.ts +221 -0
  62. package/dist/app/collab-session-transition.js +3334 -0
  63. package/dist/app/collab-state.d.ts +7 -0
  64. package/dist/app/collab-state.js +162 -0
  65. package/dist/app/collab-task-store.d.ts +22 -0
  66. package/dist/app/collab-task-store.js +204 -0
  67. package/dist/app/collab-work-execution.d.ts +127 -0
  68. package/dist/app/collab-work-execution.js +1027 -0
  69. package/dist/app/collab-work-plan.d.ts +87 -0
  70. package/dist/app/collab-work-plan.js +437 -0
  71. package/dist/app/collaboration.d.ts +8 -2
  72. package/dist/app/collaboration.js +101 -12
  73. package/dist/app/doctor.js +108 -5
  74. package/dist/app/host-mcp.js +34 -8
  75. package/dist/app/host-probe.js +4 -4
  76. package/dist/app/init.d.ts +58 -4
  77. package/dist/app/init.js +768 -155
  78. package/dist/app/managed-pi-bootstrap-admission.d.ts +14 -0
  79. package/dist/app/managed-pi-bootstrap-admission.js +104 -0
  80. package/dist/app/managed-pi-bootstrap-refresh.d.ts +10 -0
  81. package/dist/app/managed-pi-bootstrap-refresh.js +78 -0
  82. package/dist/app/managed-pi-delivery.d.ts +8 -0
  83. package/dist/app/managed-pi-delivery.js +110 -0
  84. package/dist/app/managed-pi-handoff-receiver.d.ts +15 -0
  85. package/dist/app/managed-pi-handoff-receiver.js +263 -0
  86. package/dist/app/managed-pi-handoff-reference.d.ts +3 -0
  87. package/dist/app/managed-pi-handoff-reference.js +60 -0
  88. package/dist/app/managed-pi-herdr.d.ts +21 -0
  89. package/dist/app/managed-pi-herdr.js +185 -0
  90. package/dist/app/managed-pi-initial-admission.d.ts +28 -0
  91. package/dist/app/managed-pi-initial-admission.js +416 -0
  92. package/dist/app/managed-pi-initial-launch.d.ts +5 -0
  93. package/dist/app/managed-pi-initial-launch.js +61 -0
  94. package/dist/app/managed-pi-native-observation.d.ts +36 -0
  95. package/dist/app/managed-pi-native-observation.js +191 -0
  96. package/dist/app/managed-pi-readiness.d.ts +29 -0
  97. package/dist/app/managed-pi-readiness.js +288 -0
  98. package/dist/app/managed-pi-repair-stop.d.ts +21 -0
  99. package/dist/app/managed-pi-repair-stop.js +371 -0
  100. package/dist/app/managed-pi-resume-admission.d.ts +32 -0
  101. package/dist/app/managed-pi-resume-admission.js +618 -0
  102. package/dist/app/managed-pi-retirement.d.ts +28 -0
  103. package/dist/app/managed-pi-retirement.js +112 -0
  104. package/dist/app/managed-pi-sdk-control.d.ts +7 -0
  105. package/dist/app/managed-pi-sdk-control.js +169 -0
  106. package/dist/app/managed-pi-sdk-runtime.d.ts +3 -0
  107. package/dist/app/managed-pi-sdk-runtime.js +483 -0
  108. package/dist/app/managed-pi-source-admission.d.ts +14 -0
  109. package/dist/app/managed-pi-source-admission.js +323 -0
  110. package/dist/app/managed-pi-transition-host.d.ts +36 -0
  111. package/dist/app/managed-pi-transition-host.js +292 -0
  112. package/dist/app/managed-pi.d.ts +54 -0
  113. package/dist/app/managed-pi.js +248 -0
  114. package/dist/app/shell-readiness.d.ts +66 -0
  115. package/dist/app/shell-readiness.js +368 -0
  116. package/dist/app/task-pin.d.ts +2 -23
  117. package/dist/app/task-pin.js +2 -113
  118. package/dist/cli/commands/collaboration-binding.d.ts +13 -0
  119. package/dist/cli/commands/collaboration-binding.js +118 -0
  120. package/dist/cli/commands/collaboration-bootstrap.d.ts +10 -0
  121. package/dist/cli/commands/collaboration-bootstrap.js +84 -0
  122. package/dist/cli/commands/collaboration-capabilities.d.ts +13 -0
  123. package/dist/cli/commands/collaboration-capabilities.js +127 -0
  124. package/dist/cli/commands/collaboration-handoff.d.ts +13 -0
  125. package/dist/cli/commands/collaboration-handoff.js +102 -0
  126. package/dist/cli/commands/collaboration-message.d.ts +17 -0
  127. package/dist/cli/commands/collaboration-message.js +177 -0
  128. package/dist/cli/commands/collaboration-plan.d.ts +8 -0
  129. package/dist/cli/commands/collaboration-plan.js +108 -0
  130. package/dist/cli/commands/collaboration-preflight.d.ts +10 -0
  131. package/dist/cli/commands/collaboration-preflight.js +167 -0
  132. package/dist/cli/commands/collaboration-recovery-run.d.ts +46 -0
  133. package/dist/cli/commands/collaboration-recovery-run.js +224 -0
  134. package/dist/cli/commands/collaboration-recovery.d.ts +11 -0
  135. package/dist/cli/commands/collaboration-recovery.js +104 -0
  136. package/dist/cli/commands/collaboration-resources.d.ts +11 -0
  137. package/dist/cli/commands/collaboration-resources.js +96 -0
  138. package/dist/cli/commands/collaboration-role-plan.d.ts +9 -0
  139. package/dist/cli/commands/collaboration-role-plan.js +69 -0
  140. package/dist/cli/commands/collaboration-role-run.d.ts +11 -0
  141. package/dist/cli/commands/collaboration-role-run.js +128 -0
  142. package/dist/cli/commands/collaboration-rollover.d.ts +10 -0
  143. package/dist/cli/commands/collaboration-rollover.js +103 -0
  144. package/dist/cli/commands/collaboration-state.d.ts +10 -0
  145. package/dist/cli/commands/collaboration-state.js +96 -0
  146. package/dist/cli/commands/collaboration-transition.d.ts +10 -0
  147. package/dist/cli/commands/collaboration-transition.js +75 -0
  148. package/dist/cli/commands/collaboration-work-run.d.ts +34 -0
  149. package/dist/cli/commands/collaboration-work-run.js +178 -0
  150. package/dist/cli/commands/collaboration.d.ts +4 -0
  151. package/dist/cli/commands/collaboration.js +39 -4
  152. package/dist/cli/commands/continuity.js +2 -6
  153. package/dist/cli/commands/docs.d.ts +2 -0
  154. package/dist/cli/commands/docs.js +89 -15
  155. package/dist/cli/commands/host.d.ts +7 -1
  156. package/dist/cli/commands/host.js +122 -7
  157. package/dist/cli/commands/init.d.ts +20 -3
  158. package/dist/cli/commands/init.js +897 -232
  159. package/dist/cli/commands/mcp.d.ts +1 -0
  160. package/dist/cli/commands/mcp.js +11 -5
  161. package/dist/cli/commands/repair.d.ts +12 -0
  162. package/dist/cli/commands/repair.js +41 -0
  163. package/dist/cli/commands/runtime.d.ts +1 -1
  164. package/dist/cli/commands/runtime.js +139 -38
  165. package/dist/cli/commands/session.d.ts +7 -0
  166. package/dist/cli/commands/session.js +82 -0
  167. package/dist/cli/commands/task.js +19 -5
  168. package/dist/cli/run.js +866 -24
  169. package/dist/code/identity.d.ts +7 -0
  170. package/dist/code/identity.js +7 -3
  171. package/dist/code/registration.js +7 -0
  172. package/dist/code/service.d.ts +9 -0
  173. package/dist/code/service.js +16 -3
  174. package/dist/code/text.js +19 -7
  175. package/dist/collab/admission.d.ts +91 -66
  176. package/dist/collab/admission.js +21 -6
  177. package/dist/collab/canonical.d.ts +1 -1
  178. package/dist/collab/canonical.js +44 -3
  179. package/dist/collab/envelope.d.ts +2 -2
  180. package/dist/collab/envelope.js +12 -12
  181. package/dist/collab/first-work.d.ts +3 -0
  182. package/dist/collab/first-work.js +15 -0
  183. package/dist/collab/handoff.d.ts +16 -0
  184. package/dist/collab/handoff.js +39 -5
  185. package/dist/collab/index.d.ts +97 -1
  186. package/dist/collab/index.js +9 -1
  187. package/dist/collab/messages.js +126 -3
  188. package/dist/collab/persisted-messages.d.ts +123 -0
  189. package/dist/collab/persisted-messages.js +496 -0
  190. package/dist/collab/preflight.d.ts +1 -1
  191. package/dist/collab/resources.d.ts +13 -3
  192. package/dist/collab/resources.js +37 -6
  193. package/dist/collab/rollover.d.ts +311 -0
  194. package/dist/collab/rollover.js +1024 -0
  195. package/dist/collab/store.d.ts +1 -0
  196. package/dist/collab/store.js +1 -4
  197. package/dist/collab/types.d.ts +2 -354
  198. package/dist/collab/validation.d.ts +2 -10
  199. package/dist/collab/validation.js +2 -303
  200. package/dist/collab/work-plan.d.ts +105 -0
  201. package/dist/collab/work-plan.js +843 -0
  202. package/dist/continuity/context.d.ts +11 -2
  203. package/dist/continuity/context.js +100 -13
  204. package/dist/continuity/handoff-bundle.d.ts +296 -0
  205. package/dist/continuity/handoff-bundle.js +477 -0
  206. package/dist/continuity/index.d.ts +2 -0
  207. package/dist/continuity/index.js +1 -0
  208. package/dist/continuity/paths.js +10 -2
  209. package/dist/continuity/service.js +91 -15
  210. package/dist/continuity/task-pin.d.ts +24 -0
  211. package/dist/continuity/task-pin.js +590 -0
  212. package/dist/doctor.d.ts +1 -0
  213. package/dist/doctor.js +6 -0
  214. package/dist/experimental/index.d.ts +13 -0
  215. package/dist/experimental/index.js +25 -0
  216. package/dist/experimental/pi.d.ts +4 -0
  217. package/dist/experimental/pi.js +12 -0
  218. package/dist/hosts/claude/native.js +1 -1
  219. package/dist/hosts/codex/native.js +1 -1
  220. package/dist/hosts/codex/protocol.js +1 -1
  221. package/dist/hosts/integration/bootstrap-config.d.ts +58 -0
  222. package/dist/hosts/integration/bootstrap-config.js +254 -0
  223. package/dist/hosts/integration/bundle-export.d.ts +44 -0
  224. package/dist/hosts/integration/bundle-export.js +366 -3
  225. package/dist/hosts/integration/bundle.js +3 -0
  226. package/dist/hosts/integration/hook.js +36 -9
  227. package/dist/hosts/integration/pappra-bootstrap-extension.mjs +80 -0
  228. package/dist/hosts/integration/pappra-extension-runtime.mjs +59 -24631
  229. package/dist/hosts/integration/pappra-hook.mjs +61 -24749
  230. package/dist/hosts/integration/pappra-recovery-extension.mjs +259 -0
  231. package/dist/hosts/integration/pappra-session-transition-extension.mjs +276 -0
  232. package/dist/hosts/integration/pi-bootstrap-extension.d.ts +36 -0
  233. package/dist/hosts/integration/pi-bootstrap-extension.js +101 -0
  234. package/dist/hosts/integration/pi-bootstrap-guard.d.ts +125 -0
  235. package/dist/hosts/integration/pi-bootstrap-guard.js +438 -0
  236. package/dist/hosts/integration/pi-bootstrap-runtime.d.ts +63 -0
  237. package/dist/hosts/integration/pi-bootstrap-runtime.js +223 -0
  238. package/dist/hosts/integration/pi-native-events.d.ts +112 -0
  239. package/dist/hosts/integration/pi-native-events.js +348 -0
  240. package/dist/hosts/integration/pi-rollover-extension.d.ts +166 -0
  241. package/dist/hosts/integration/pi-rollover-extension.js +431 -0
  242. package/dist/hosts/integration/recovery-config.d.ts +48 -0
  243. package/dist/hosts/integration/recovery-config.js +117 -0
  244. package/dist/hosts/integration/service.d.ts +6 -1
  245. package/dist/hosts/integration/service.js +485 -35
  246. package/dist/hosts/integration/support.d.ts +7 -0
  247. package/dist/hosts/integration/support.js +9 -1
  248. package/dist/hosts/integration/templates.d.ts +3 -0
  249. package/dist/hosts/integration/templates.js +26 -3
  250. package/dist/hosts/pi/managed-host-identity.d.ts +2 -0
  251. package/dist/hosts/pi/managed-host-identity.js +40 -0
  252. package/dist/hosts/pi/managed-provider-diagnostics.d.ts +6 -0
  253. package/dist/hosts/pi/managed-provider-diagnostics.js +184 -0
  254. package/dist/hosts/pi/managed-sdk-session.d.ts +335 -0
  255. package/dist/hosts/pi/managed-sdk-session.js +1639 -0
  256. package/dist/hosts/pi/managed-session-extension.d.ts +20 -0
  257. package/dist/hosts/pi/managed-session-extension.js +84 -0
  258. package/dist/hosts/pi/managed-session.d.ts +177 -0
  259. package/dist/hosts/pi/managed-session.js +1557 -0
  260. package/dist/hosts/pi/protocol.js +1 -1
  261. package/dist/hosts/pi/query-result.js +1 -1
  262. package/dist/hosts/pi/resume-capability.d.ts +41 -0
  263. package/dist/hosts/pi/resume-capability.js +33 -0
  264. package/dist/hosts/pi/rpc.d.ts +182 -134
  265. package/dist/hosts/pi/rpc.js +230 -37
  266. package/dist/hosts/pilot.d.ts +97 -74
  267. package/dist/hosts/pilot.js +5 -1
  268. package/dist/hosts/version.d.ts +3 -0
  269. package/dist/hosts/version.js +18 -0
  270. package/dist/index.d.ts +0 -1
  271. package/dist/index.js +0 -1
  272. package/dist/infra/bounded-file.d.ts +19 -0
  273. package/dist/infra/bounded-file.js +66 -0
  274. package/dist/infra/fs.js +10 -2
  275. package/dist/infra/git.d.ts +29 -0
  276. package/dist/infra/git.js +45 -9
  277. package/dist/infra/json-property.d.ts +2 -0
  278. package/dist/infra/json-property.js +69 -0
  279. package/dist/infra/json.d.ts +4 -0
  280. package/dist/infra/json.js +24 -0
  281. package/dist/infra/owned-process-group.js +1 -1
  282. package/dist/infra/private-lock.d.ts +16 -0
  283. package/dist/infra/private-lock.js +148 -0
  284. package/dist/infra/private-state.d.ts +7 -0
  285. package/dist/infra/private-state.js +146 -0
  286. package/dist/infra/process-identity.d.ts +12 -0
  287. package/dist/infra/process-identity.js +142 -0
  288. package/dist/infra/validation.d.ts +5 -0
  289. package/dist/infra/validation.js +15 -0
  290. package/dist/init.d.ts +1 -0
  291. package/dist/init.js +6 -0
  292. package/dist/install/apply.d.ts +9 -1
  293. package/dist/install/apply.js +176 -12
  294. package/dist/install/dependencies.d.ts +38 -0
  295. package/dist/install/dependencies.js +282 -0
  296. package/dist/install/ownership.js +30 -1
  297. package/dist/install/references.d.ts +31 -0
  298. package/dist/install/references.js +208 -0
  299. package/dist/install/repair.d.ts +37 -0
  300. package/dist/install/repair.js +798 -0
  301. package/dist/install/reset-local.d.ts +16 -0
  302. package/dist/install/reset-local.js +304 -0
  303. package/dist/install/rollback.d.ts +3 -0
  304. package/dist/install/rollback.js +9 -3
  305. package/dist/install/runtime.d.ts +10 -6
  306. package/dist/install/runtime.js +299 -59
  307. package/dist/install/source-fingerprint.d.ts +2 -0
  308. package/dist/install/source-fingerprint.js +103 -0
  309. package/dist/knowledge/catalog.d.ts +1 -0
  310. package/dist/knowledge/catalog.js +143 -10
  311. package/dist/knowledge/journal.js +17 -7
  312. package/dist/knowledge/paths.js +2 -1
  313. package/dist/knowledge/refs.js +4 -0
  314. package/dist/knowledge/register-batch.d.ts +4 -0
  315. package/dist/knowledge/register-batch.js +225 -0
  316. package/dist/knowledge/relocate.d.ts +5 -0
  317. package/dist/knowledge/relocate.js +92 -0
  318. package/dist/knowledge/scan.js +103 -47
  319. package/dist/knowledge/service.d.ts +3 -0
  320. package/dist/knowledge/service.js +33 -1
  321. package/dist/knowledge/write.d.ts +4 -1
  322. package/dist/knowledge/write.js +1 -1
  323. package/dist/mcp/server.d.ts +4 -2
  324. package/dist/mcp/server.js +87 -136
  325. package/dist/schemas/collaboration-state-validation.d.ts +6 -0
  326. package/dist/schemas/collaboration-state-validation.js +342 -0
  327. package/dist/schemas/collaboration-state.d.ts +436 -0
  328. package/dist/schemas/collaboration-state.js +1 -0
  329. package/dist/schemas/collaboration.d.ts +335 -0
  330. package/dist/schemas/collaboration.js +272 -0
  331. package/dist/schemas/continuity.d.ts +8 -0
  332. package/dist/schemas/continuity.js +13 -3
  333. package/dist/schemas/doctor.d.ts +1 -0
  334. package/dist/schemas/doctor.js +1 -0
  335. package/dist/schemas/document.d.ts +61 -0
  336. package/dist/schemas/document.js +9 -0
  337. package/dist/schemas/errors.d.ts +1 -0
  338. package/dist/schemas/errors.js +1 -0
  339. package/dist/schemas/host.d.ts +24 -0
  340. package/dist/schemas/host.js +19 -0
  341. package/dist/schemas/install.d.ts +8 -2
  342. package/dist/schemas/install.js +11 -1
  343. package/dist/schemas/managed-pi-execution.d.ts +16 -0
  344. package/dist/schemas/managed-pi-execution.js +2 -0
  345. package/dist/schemas/outcome.d.ts +9 -5
  346. package/dist/workspace/config-file.js +1 -3
  347. package/dist/workspace/documents.js +12 -2
  348. package/dist/workspace/repositories.d.ts +7 -0
  349. package/dist/workspace/repositories.js +14 -5
  350. package/package.json +35 -35
  351. package/skills/pappra/SKILL.md +28 -7
  352. package/skills/pappra/references/collaboration.md +633 -0
  353. package/skills/pappra/references/environment.md +360 -0
  354. package/skills/pappra/references/hosts.md +20 -0
  355. package/skills/pappra/references/operations.md +36 -5
  356. package/dist/app/collab-from.d.ts +0 -35
  357. package/dist/app/collab-from.js +0 -84
@@ -4,14 +4,19 @@ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { createDiagnostic } from '../../schemas/diagnostics.js';
6
6
  import { diagnosticCodes } from '../../schemas/errors.js';
7
- import { hostCapabilitiesSchema, hostInstallManifestSchema, hostIntegrationPlanSchema, hostIntegrationResultSchema, hostNameSchema, hostVerificationSchema, } from '../../schemas/host.js';
7
+ import { hostCapabilitiesSchema, hostInstallManifestSchema, hostIntegrationPlanSchema, hostIntegrationResultSchema, hostNameSchema, hostVerificationSchema, hostUninstallResultSchema, } from '../../schemas/host.js';
8
8
  import { observePath, readFileBounded, sha256Digest } from '../../infra/fs.js';
9
+ import { ensurePrivateState } from '../../infra/private-state.js';
10
+ import { updateJsonProperty } from '../../infra/json-property.js';
11
+ import { captureProcessIdentity } from '../../infra/process-identity.js';
12
+ import { inspectPrivateLock, publishPrivateLock, reclaimDeadPrivateLock, } from '../../infra/private-lock.js';
9
13
  import { isPathInside } from '../../schemas/paths.js';
10
- import { ensureStatePathBoundary } from '../../install/ownership.js';
14
+ import { ensureStatePathBoundary, removeOwnedFile, } from '../../install/ownership.js';
11
15
  import { getPackageInfo } from '../../package-info.js';
12
16
  import { detectPassiveHosts } from '../detect.js';
17
+ import { observeHostVersion } from '../version.js';
13
18
  import { installPathForBundleFile, loadHostBundle } from './bundle.js';
14
- import { hostConfigRelativePath, renderHookConfigPatch } from './templates.js';
19
+ import { hostConfigRelativePath, isManagedHookCommand, MIN_HOOK_TIMEOUT_SECONDS, renderHookConfigPatch, } from './templates.js';
15
20
  import { HOST_PLUGIN_HOOKS, HOST_SUPPORT, isSupportedVersion, nativeEventFor, surfaceFor, } from './support.js';
16
21
  const LIFECYCLE_EVENTS = [
17
22
  'startup',
@@ -199,22 +204,27 @@ function managedGroupHasUserEdits(group, commands) {
199
204
  const hookValues = group.hooks;
200
205
  if (!Array.isArray(hookValues))
201
206
  return false;
202
- const hasManaged = hookValues.some((hook) => isExactManagedHook(hook, commands));
207
+ const hasManaged = hookValues.some((hook) => isManagedHook(hook, commands));
203
208
  if (!hasManaged)
204
209
  return false;
205
210
  const extraGroupKeys = Object.keys(group).filter((key) => key !== 'hooks');
206
211
  if (extraGroupKeys.length > 0)
207
212
  return true;
208
213
  return hookValues.some((hook) => {
209
- if (!isExactManagedHook(hook, commands))
214
+ if (!isManagedHook(hook, commands))
210
215
  return false;
211
216
  const keys = Object.keys(hook);
217
+ const validTimeout = typeof hook.timeout === 'number' &&
218
+ Number.isSafeInteger(hook.timeout) &&
219
+ hook.timeout >= MIN_HOOK_TIMEOUT_SECONDS;
220
+ const legacyDefault = hook.timeout === 1 &&
221
+ typeof hook.command === 'string' &&
222
+ !commands.has(hook.command);
212
223
  return (keys.some((key) => key !== 'type' && key !== 'command' && key !== 'timeout') ||
213
- !('timeout' in hook) ||
214
- hook.timeout !== 1);
224
+ (!validTimeout && !legacyDefault));
215
225
  });
216
226
  }
217
- function isExactManagedHook(value, commands) {
227
+ function isManagedHook(value, commands) {
218
228
  return (typeof value === 'object' &&
219
229
  value !== null &&
220
230
  !Array.isArray(value) &&
@@ -222,7 +232,7 @@ function isExactManagedHook(value, commands) {
222
232
  value.type === 'command' &&
223
233
  'command' in value &&
224
234
  typeof value.command === 'string' &&
225
- commands.has(value.command));
235
+ isManagedHookCommand(value.command, commands));
226
236
  }
227
237
  function managedConfig(host, existing, hookExpression, path) {
228
238
  let base = {};
@@ -256,6 +266,7 @@ function managedConfig(host, existing, hookExpression, path) {
256
266
  const groups = current === undefined ? [] : current;
257
267
  const commands = managedHookCommands(entries);
258
268
  const preserved = [];
269
+ let managedTimeout = MIN_HOOK_TIMEOUT_SECONDS;
259
270
  for (const group of groups) {
260
271
  if (typeof group !== 'object' ||
261
272
  group === null ||
@@ -268,27 +279,108 @@ function managedConfig(host, existing, hookExpression, path) {
268
279
  if (managedGroupHasUserEdits(group, commands)) {
269
280
  return {
270
281
  ok: false,
271
- diagnostic: diagnostic(diagnosticCodes.host_install_conflict, `Managed host hook entry was edited by the user: ${path}`, path, 'Review the edited matcher, timeout, or handler fields explicitly; Pappra preserves the edited group and does not reinstall it.'),
282
+ diagnostic: diagnostic(diagnosticCodes.host_install_conflict, `Managed host hook entry was edited by the user: ${path}`, path, `Review the matcher and handler fields, and use an integer timeout of at least ${MIN_HOOK_TIMEOUT_SECONDS} seconds. Pappra preserves invalid or unsupported edits.`),
272
283
  };
273
284
  }
274
- const remaining = group.hooks.filter((hook) => !isExactManagedHook(hook, commands));
285
+ if (!group.hooks.some((hook) => isManagedHook(hook, commands))) {
286
+ preserved.push(group);
287
+ continue;
288
+ }
289
+ for (const hook of group.hooks) {
290
+ if (isManagedHook(hook, commands) &&
291
+ typeof hook === 'object' &&
292
+ hook !== null &&
293
+ 'timeout' in hook &&
294
+ typeof hook.timeout === 'number') {
295
+ managedTimeout = Math.max(managedTimeout, hook.timeout);
296
+ }
297
+ }
298
+ const remaining = group.hooks.filter((hook) => !isManagedHook(hook, commands));
275
299
  if (remaining.length > 0) {
276
300
  preserved.push({ ...group, hooks: remaining });
277
301
  }
278
302
  }
279
- nextHooks[event] = [...preserved, ...entries];
303
+ nextHooks[event] = [
304
+ ...preserved,
305
+ ...entries.map((entry) => ({
306
+ ...entry,
307
+ hooks: entry.hooks.map((hook) => ({
308
+ ...hook,
309
+ timeout: managedTimeout,
310
+ })),
311
+ })),
312
+ ];
280
313
  }
281
314
  const next = { ...base, hooks: nextHooks };
282
315
  return {
283
316
  ok: true,
284
- bytes: Buffer.from(`${JSON.stringify(next, null, 2)}\n`),
317
+ bytes: updateJsonProperty(existing, 'hooks', next['hooks']),
318
+ };
319
+ }
320
+ function removeManagedConfig(host, existing, expression, file) {
321
+ const parsed = parseJsonObject(existing, file);
322
+ if (!parsed.ok)
323
+ return parsed;
324
+ const hooks = parsed.value['hooks'];
325
+ if (hooks === undefined)
326
+ return { ok: true, bytes: existing };
327
+ if (typeof hooks !== 'object' || hooks === null || Array.isArray(hooks))
328
+ return {
329
+ ok: false,
330
+ diagnostic: diagnostic(diagnosticCodes.host_config_invalid, 'Host hooks must be an object.', file, 'Inspect the configuration; no hook was removed.'),
331
+ };
332
+ const next = { ...hooks };
333
+ for (const [event, entries] of Object.entries(renderHookConfigPatch(host, expression).hooks)) {
334
+ const groups = next[event];
335
+ if (groups === undefined)
336
+ continue;
337
+ if (!Array.isArray(groups))
338
+ return {
339
+ ok: false,
340
+ diagnostic: diagnostic(diagnosticCodes.host_config_invalid, 'Host event hooks must be an array.', file, 'Inspect the configuration; no hook was removed.'),
341
+ };
342
+ const commands = managedHookCommands(entries);
343
+ const kept = [];
344
+ for (const value of groups) {
345
+ if (typeof value !== 'object' ||
346
+ value === null ||
347
+ Array.isArray(value) ||
348
+ !('hooks' in value) ||
349
+ !Array.isArray(value.hooks)) {
350
+ kept.push(value);
351
+ continue;
352
+ }
353
+ if (managedGroupHasUserEdits(value, commands))
354
+ return {
355
+ ok: false,
356
+ diagnostic: diagnostic(diagnosticCodes.host_install_conflict, 'An edited managed hook was preserved.', file, 'Review the edited hook explicitly before uninstalling its assets.'),
357
+ };
358
+ const remaining = value.hooks.filter((hook) => !isManagedHook(hook, commands));
359
+ if (remaining.length === value.hooks.length)
360
+ kept.push(value);
361
+ else if (remaining.length > 0)
362
+ kept.push({ ...value, hooks: remaining });
363
+ }
364
+ if (kept.length > 0)
365
+ next[event] = kept;
366
+ else
367
+ delete next[event];
368
+ }
369
+ return {
370
+ ok: true,
371
+ bytes: JSON.stringify(next) === JSON.stringify(hooks)
372
+ ? existing
373
+ : updateJsonProperty(existing, 'hooks', next),
285
374
  };
286
375
  }
287
376
  function validateManifestIdentity(manifest, host, hostRoot, configPath, hookExpression, migrate) {
377
+ const portable = host === 'pi' ||
378
+ (manifest.runtimeEntry === hookExpression &&
379
+ hookExpression.startsWith('${CLAUDE_PROJECT_DIR}/'));
288
380
  if (manifest.host !== host ||
289
- resolve(manifest.hostRoot) !== hostRoot ||
381
+ (!portable && resolve(manifest.hostRoot) !== hostRoot) ||
290
382
  manifest.configPath !== configPath ||
291
- (!migrate && manifest.runtimeEntry !== hookExpression)) {
383
+ (!migrate && host !== 'pi' && manifest.runtimeEntry !== hookExpression)) {
292
384
  return diagnostic(diagnosticCodes.host_install_conflict, `Host manifest identity does not match the selected target: ${hostRoot}`, join(hostRoot, BUNDLE_FILE), 'Inspect the manifest and target; Pappra does not adopt a foreign installation.');
293
385
  }
294
386
  const seen = new Set();
@@ -326,13 +418,20 @@ function configHasExpectedHooks(host, bytes, hookExpression, path) {
326
418
  !Array.isArray(group.hooks)) {
327
419
  continue;
328
420
  }
421
+ if (managedGroupHasUserEdits(group, commands))
422
+ return false;
329
423
  for (const hook of group.hooks) {
330
- if (!isExactManagedHook(hook, commands))
424
+ if (!isManagedHook(hook, commands))
331
425
  continue;
332
426
  if (typeof hook === 'object' &&
333
427
  hook !== null &&
334
428
  'command' in hook &&
335
429
  typeof hook.command === 'string') {
430
+ // A stale runner or duplicate entry requires reinstalling, while
431
+ // unrelated user configuration has no bearing on managed wiring.
432
+ if (!commands.has(hook.command) || found.has(hook.command)) {
433
+ return false;
434
+ }
336
435
  found.add(hook.command);
337
436
  }
338
437
  }
@@ -651,6 +750,11 @@ async function writeAtomic(path, bytes, options) {
651
750
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
652
751
  const handle = await open(temporary, 'wx', 0o600);
653
752
  try {
753
+ if (options.replace) {
754
+ const before = await observePath(path);
755
+ if (before.kind === 'file' && before.stats)
756
+ await handle.chmod(before.stats.mode & 0o777);
757
+ }
654
758
  const stats = await handle.stat();
655
759
  if (!stats.isFile())
656
760
  throw new Error('temporary is not a regular file');
@@ -734,7 +838,77 @@ async function writeAtomic(path, bytes, options) {
734
838
  };
735
839
  }
736
840
  }
737
- async function acquireHostInstallLock(hostRoot) {
841
+ /** CLI force is an explicit stop-all-host-writers assertion, not asset ownership. */
842
+ async function quarantineUnknownHostLock(workspaceRoot, hostRoot) {
843
+ const file = join(hostRoot, '.pappra-host.lock');
844
+ if (await assertSafeTarget(hostRoot, file))
845
+ return null;
846
+ const owner = await inspectPrivateLock(file);
847
+ if (owner.state !== 'unknown')
848
+ return null;
849
+ const observed = await observePath(file);
850
+ const bytes = await readFileBounded(file, 64 * 1024);
851
+ if (observed.kind !== 'file' || !observed.stats || !bytes.ok)
852
+ return null;
853
+ const privateState = await ensurePrivateState(workspaceRoot);
854
+ if (!privateState.ok)
855
+ return null;
856
+ const directory = join(workspaceRoot, '.pappra', 'local', 'host-lock-archive');
857
+ const id = randomUUID();
858
+ const archive = join(directory, `${id}.lock`);
859
+ if (!(await ensureStatePathBoundary(workspaceRoot, archive)).ok)
860
+ return null;
861
+ await mkdir(directory, { recursive: true, mode: 0o700 });
862
+ const saved = await writeAtomic(archive, bytes.value.bytes, {
863
+ replace: false,
864
+ beforeDigest: null,
865
+ root: directory,
866
+ });
867
+ if (!saved.ok)
868
+ return null;
869
+ const receiptFile = `${archive}.receipt.json`;
870
+ const receipt = Buffer.from(`${JSON.stringify({
871
+ schema: 'pappra/host-lock-quarantine/1',
872
+ source: file,
873
+ archive,
874
+ digest: bytes.value.digest,
875
+ identity: { dev: observed.stats.dev, ino: observed.stats.ino },
876
+ owner: 'unknown',
877
+ operator_assertion: { all_host_writers_stopped: true },
878
+ copy_fsynced_before_removal: true,
879
+ created_at: now(),
880
+ }, null, 2)}\n`);
881
+ if (!(await writeAtomic(receiptFile, receipt, {
882
+ replace: false,
883
+ beforeDigest: null,
884
+ root: directory,
885
+ })).ok)
886
+ return null;
887
+ const archiveDirectory = await open(directory, 'r');
888
+ try {
889
+ await archiveDirectory.sync();
890
+ }
891
+ finally {
892
+ await archiveDirectory.close();
893
+ }
894
+ const freshOwner = await inspectPrivateLock(file);
895
+ if (freshOwner.state !== 'unknown')
896
+ return null;
897
+ const removed = await removeOwnedFile(hostRoot, {
898
+ path: '.pappra-host.lock',
899
+ installId: id,
900
+ operation: 'create',
901
+ beforeDigest: null,
902
+ afterDigest: bytes.value.digest,
903
+ backupRef: null,
904
+ backupDigest: null,
905
+ identity: { dev: observed.stats.dev, ino: observed.stats.ino },
906
+ owner: 'pappra',
907
+ verifiedAt: now(),
908
+ });
909
+ return removed.ok ? archive : null;
910
+ }
911
+ async function acquireHostInstallLock(hostRoot, host) {
738
912
  const path = join(hostRoot, '.pappra-host.lock');
739
913
  const safe = await assertSafeTarget(hostRoot, path);
740
914
  if (safe !== null)
@@ -749,21 +923,36 @@ async function acquireHostInstallLock(hostRoot) {
749
923
  };
750
924
  }
751
925
  try {
752
- const token = randomUUID();
753
926
  const rootReal = await realpath(hostRoot);
754
- const handle = await open(path, 'wx', 0o600);
927
+ const token = `${JSON.stringify({ schema: 'pappra/host-install-lock/1', token: randomUUID(), pid: process.pid, process_identity: captureProcessIdentity() ?? undefined })}\n`;
928
+ const revalidate = async () => {
929
+ if ((await realpath(hostRoot)) !== rootReal ||
930
+ (await assertSafeTarget(hostRoot, path)) !== null)
931
+ throw new Error('Host lock namespace changed.');
932
+ };
755
933
  try {
756
- const stats = await handle.stat();
757
- await handle.writeFile(token);
758
- await handle.sync();
759
- return {
760
- ok: true,
761
- lock: { path, token, dev: stats.dev, ino: stats.ino, rootReal },
762
- };
934
+ await publishPrivateLock(path, Buffer.from(token), revalidate);
763
935
  }
764
- finally {
765
- await handle.close().catch(() => undefined);
936
+ catch (error) {
937
+ if (error.code !== 'EEXIST' ||
938
+ (await inspectPrivateLock(path)).state !== 'dead')
939
+ throw error;
940
+ await reclaimDeadPrivateLock(path, revalidate);
941
+ await publishPrivateLock(path, Buffer.from(token), revalidate);
766
942
  }
943
+ const observed = await observePath(path);
944
+ if (observed.kind !== 'file' || !observed.stats)
945
+ throw new Error('Host lock identity unavailable.');
946
+ return {
947
+ ok: true,
948
+ lock: {
949
+ path,
950
+ token,
951
+ dev: observed.stats.dev,
952
+ ino: observed.stats.ino,
953
+ rootReal,
954
+ },
955
+ };
767
956
  }
768
957
  catch (error) {
769
958
  const code = typeof error === 'object' &&
@@ -776,7 +965,7 @@ async function acquireHostInstallLock(hostRoot) {
776
965
  ok: false,
777
966
  diagnostic: diagnostic(code === 'EEXIST'
778
967
  ? diagnosticCodes.host_install_conflict
779
- : diagnosticCodes.host_install_partial, `Cannot acquire host install lock ${path}: ${error instanceof Error ? error.message : String(error)}`, path, 'Inspect the lock; stale locks require explicit manual resolution.'),
968
+ : diagnosticCodes.host_install_partial, `Cannot acquire host install lock ${path}: ${error instanceof Error ? error.message : String(error)}`, path, `A live or unverified owner is preserved. Stop all writers for this host root, then use pappra host uninstall --host ${host} --host-root ${JSON.stringify(hostRoot)} --force --apply to quarantine an unknown legacy lock. Verified live owners are never overridden.`),
780
969
  };
781
970
  }
782
971
  }
@@ -819,6 +1008,7 @@ export class HostIntegrationService {
819
1008
  #hostVersion;
820
1009
  #selectedHosts;
821
1010
  #observedAt;
1011
+ #versionProbe;
822
1012
  constructor(options) {
823
1013
  this.#root = resolve(options.root);
824
1014
  this.#homeDir = resolve(options.homeDir);
@@ -827,6 +1017,9 @@ export class HostIntegrationService {
827
1017
  this.#hostVersion = options.hostVersion ?? null;
828
1018
  this.#selectedHosts = options.selectedHosts ?? ['pi', 'codex', 'claude'];
829
1019
  this.#observedAt = options.observedAt ?? now();
1020
+ this.#versionProbe =
1021
+ options.versionProbe ??
1022
+ ((host) => observeHostVersion(host, this.#pathEnv));
830
1023
  }
831
1024
  detect() {
832
1025
  const observations = detectPassiveHosts({
@@ -870,7 +1063,7 @@ export class HostIntegrationService {
870
1063
  async plan(host, options = {}) {
871
1064
  const hostRoot = resolve(options.hostRoot ?? join(this.#root, '.pappra', 'local', 'hosts', host));
872
1065
  const hookSource = resolve(options.runtimeEntry ?? this.#runtimeEntry);
873
- const version = options.hostVersion ?? this.#hostVersion;
1066
+ const version = this.#versionProbe(host);
874
1067
  if (!isSupportedVersion(host, version)) {
875
1068
  throw new Error(`Host ${host} version ${version ?? '(unknown)'} is outside the pinned support matrix.`);
876
1069
  }
@@ -910,7 +1103,7 @@ export class HostIntegrationService {
910
1103
  try {
911
1104
  const hostRoot = resolve(options.hostRoot ?? join(this.#root, '.pappra', 'local', 'hosts', host));
912
1105
  const hookSource = resolve(options.runtimeEntry ?? this.#runtimeEntry);
913
- const version = options.hostVersion ?? this.#hostVersion;
1106
+ const version = this.#versionProbe(host);
914
1107
  if (!isSupportedVersion(host, version)) {
915
1108
  return hostIntegrationResultSchema.parse({
916
1109
  schema: 'pappra/host-integration/1',
@@ -980,7 +1173,24 @@ export class HostIntegrationService {
980
1173
  const applied = [];
981
1174
  const residualPaths = [];
982
1175
  const diagnostics = [];
983
- const lockResult = await acquireHostInstallLock(plan.hostRoot);
1176
+ if (isPathInside(join(this.#root, '.pappra', 'local'), plan.hostRoot)) {
1177
+ const privateState = await ensurePrivateState(this.#root);
1178
+ if (!privateState.ok) {
1179
+ return hostIntegrationResultSchema.parse({
1180
+ schema: 'pappra/host-integration/1',
1181
+ ok: false,
1182
+ status: 'failed',
1183
+ plan,
1184
+ appliedFiles: [],
1185
+ residualPaths: [],
1186
+ diagnostics: [
1187
+ diagnostic(privateState.code, privateState.message, this.#root, privateState.nextAction ??
1188
+ 'Protect private workspace state before installing the host.'),
1189
+ ],
1190
+ });
1191
+ }
1192
+ }
1193
+ const lockResult = await acquireHostInstallLock(plan.hostRoot, host);
984
1194
  if (!lockResult.ok) {
985
1195
  return hostIntegrationResultSchema.parse({
986
1196
  schema: 'pappra/host-integration/1',
@@ -1131,7 +1341,7 @@ export class HostIntegrationService {
1131
1341
  }
1132
1342
  async verify(host, options = {}) {
1133
1343
  const hostRoot = resolve(options.hostRoot ?? join(this.#root, '.pappra', 'local', 'hosts', host));
1134
- const version = options.hostVersion ?? this.#hostVersion;
1344
+ const version = this.#versionProbe(host);
1135
1345
  const capabilities = this.describe(host);
1136
1346
  const checks = [];
1137
1347
  if (!isSupportedVersion(host, version)) {
@@ -1323,6 +1533,10 @@ export class HostIntegrationService {
1323
1533
  }
1324
1534
  let status = 'verified';
1325
1535
  for (const file of manifest.files) {
1536
+ // Native settings are shared with the user. Their complete digest is a
1537
+ // write-plan pin, not an ownership claim over unrelated configuration.
1538
+ if (file.path === manifest.configPath)
1539
+ continue;
1326
1540
  const path = join(hostRoot, ...file.path.split('/'));
1327
1541
  const digest = await fileDigest(path);
1328
1542
  if (digest !== file.digest) {
@@ -1345,13 +1559,12 @@ export class HostIntegrationService {
1345
1559
  const configPath = join(hostRoot, ...manifest.configPath.split('/'));
1346
1560
  const config = await readFileBounded(configPath, DEFAULT_MAX_CONFIG_BYTES);
1347
1561
  if (!config.ok ||
1348
- manifestFiles.get(manifest.configPath) !== config.value.digest ||
1349
1562
  !configHasExpectedHooks(host === 'codex' ? 'codex' : 'claude', config.value.bytes, target.expression, configPath)) {
1350
1563
  status = 'conflict';
1351
1564
  checks.push({
1352
1565
  id: 'host.config',
1353
1566
  status: 'error',
1354
- detail: 'Host config is missing, differs from its manifest digest, or lacks the exact expected Pappra entries.',
1567
+ detail: 'Host config is missing or its managed Pappra hook entries changed, are duplicated, or are incomplete.',
1355
1568
  });
1356
1569
  }
1357
1570
  else {
@@ -1380,6 +1593,243 @@ export class HostIntegrationService {
1380
1593
  residualPaths: [],
1381
1594
  });
1382
1595
  }
1596
+ async uninstall(host, options) {
1597
+ const hostRoot = resolve(options.hostRoot ?? join(this.#root, '.pappra', 'local', 'hosts', host));
1598
+ const plannedFiles = [];
1599
+ const removedFiles = [];
1600
+ const retainedPaths = [];
1601
+ const quarantinedLocks = [];
1602
+ const diagnostics = [];
1603
+ const result = (status, ok) => {
1604
+ if (!ok && diagnostics.length === 0)
1605
+ diagnostics.push(diagnostic(diagnosticCodes.host_install_conflict, 'Host uninstall preserved edited, unsafe or unreadable content.', retainedPaths[0] ?? hostRoot, 'Inspect the reported preserved paths; no unknown user content is removed automatically.'));
1606
+ return hostUninstallResultSchema.parse({
1607
+ schema: 'pappra/host-uninstall/1',
1608
+ host,
1609
+ hostRoot,
1610
+ ok,
1611
+ status,
1612
+ plannedFiles,
1613
+ removedFiles,
1614
+ retainedPaths: [...new Set(retainedPaths)],
1615
+ quarantinedLocks,
1616
+ diagnostics: diagnostics.slice(0, 32),
1617
+ });
1618
+ };
1619
+ const manifestPath = join(hostRoot, BUNDLE_FILE);
1620
+ const boundary = await assertSafeTarget(hostRoot, manifestPath);
1621
+ if (boundary) {
1622
+ diagnostics.push(boundary);
1623
+ return result('conflict', false);
1624
+ }
1625
+ const lockPath = join(hostRoot, '.pappra-host.lock');
1626
+ const lockOwner = await inspectPrivateLock(lockPath);
1627
+ const lockFile = await observePath(lockPath);
1628
+ if (lockOwner.state === 'alive' ||
1629
+ (lockFile.kind !== 'missing' &&
1630
+ lockOwner.state === 'unknown' &&
1631
+ !options.force)) {
1632
+ retainedPaths.push(lockPath);
1633
+ diagnostics.push(diagnostic(diagnosticCodes.host_install_conflict, lockOwner.state === 'alive'
1634
+ ? 'A verified live host writer holds this lock; --force cannot override it.'
1635
+ : 'The host lock owner is unknown; it was preserved.', lockPath, `Stop all host writers, then use pappra host uninstall --host ${host} --host-root ${JSON.stringify(hostRoot)} --force --apply to archive an unknown lock. --force asserts all host writers are stopped and never permits deleting edited assets.`));
1636
+ return result('conflict', false);
1637
+ }
1638
+ if (lockFile.kind !== 'missing' &&
1639
+ lockOwner.state === 'unknown' &&
1640
+ options.force) {
1641
+ plannedFiles.push('.pappra-host.lock');
1642
+ if (options.apply) {
1643
+ const archived = await quarantineUnknownHostLock(this.#root, hostRoot).catch(() => null);
1644
+ if (!archived) {
1645
+ retainedPaths.push(lockPath);
1646
+ return result('conflict', false);
1647
+ }
1648
+ quarantinedLocks.push(archived);
1649
+ removedFiles.push('.pappra-host.lock');
1650
+ }
1651
+ }
1652
+ const manifestRead = await readFileBounded(manifestPath, 256 * 1024);
1653
+ if (!manifestRead.ok) {
1654
+ if (manifestRead.code === diagnosticCodes.fileNotFound)
1655
+ return result(removedFiles.length > 0
1656
+ ? 'removed'
1657
+ : plannedFiles.length > 0
1658
+ ? 'planned'
1659
+ : 'unchanged', true);
1660
+ retainedPaths.push(manifestPath);
1661
+ return result('conflict', false);
1662
+ }
1663
+ const manifest = await this.readManifest(hostRoot);
1664
+ const expression = hookTarget(host, this.#root, hostRoot, this.#runtimeEntry).expression;
1665
+ if (!manifest) {
1666
+ retainedPaths.push(manifestPath);
1667
+ return result('conflict', false);
1668
+ }
1669
+ const identity = validateManifestIdentity(manifest, host, hostRoot, hostConfigRelativePath(host), expression, false);
1670
+ if (identity) {
1671
+ diagnostics.push(identity);
1672
+ return result('conflict', false);
1673
+ }
1674
+ let payload;
1675
+ try {
1676
+ payload = await loadHostBundle(host, getPackageInfo().version, {
1677
+ hookExpression: expression,
1678
+ });
1679
+ }
1680
+ catch {
1681
+ retainedPaths.push(manifestPath);
1682
+ return result('failed', false);
1683
+ }
1684
+ const allowed = new Set([...payload.contents.keys()].map((file) => installPathForBundleFile(host, file)));
1685
+ const configRelative = hostConfigRelativePath(host);
1686
+ let config = null;
1687
+ if (host !== 'pi' && configRelative) {
1688
+ const file = join(hostRoot, configRelative);
1689
+ const unsafe = await assertSafeTarget(hostRoot, file);
1690
+ if (unsafe) {
1691
+ diagnostics.push(unsafe);
1692
+ retainedPaths.push(file);
1693
+ return result('conflict', false);
1694
+ }
1695
+ const read = await readFileBounded(file, DEFAULT_MAX_CONFIG_BYTES);
1696
+ if (read.ok) {
1697
+ const patch = removeManagedConfig(host, read.value.bytes, expression, file);
1698
+ if (!patch.ok) {
1699
+ diagnostics.push(patch.diagnostic);
1700
+ retainedPaths.push(file);
1701
+ return result('conflict', false);
1702
+ }
1703
+ if (!patch.bytes.equals(read.value.bytes)) {
1704
+ plannedFiles.push(configRelative);
1705
+ config = {
1706
+ path: file,
1707
+ before: read.value.digest,
1708
+ bytes: patch.bytes,
1709
+ };
1710
+ }
1711
+ }
1712
+ else if (read.code !== diagnosticCodes.fileNotFound) {
1713
+ retainedPaths.push(file);
1714
+ return result('conflict', false);
1715
+ }
1716
+ }
1717
+ const candidates = [];
1718
+ for (const entry of manifest.files) {
1719
+ if (entry.path === configRelative)
1720
+ continue;
1721
+ const file = join(hostRoot, ...entry.path.split('/'));
1722
+ if (!allowed.has(entry.path)) {
1723
+ retainedPaths.push(file);
1724
+ continue;
1725
+ }
1726
+ const unsafe = await assertSafeTarget(hostRoot, file);
1727
+ if (unsafe) {
1728
+ diagnostics.push(unsafe);
1729
+ retainedPaths.push(file);
1730
+ continue;
1731
+ }
1732
+ const read = await readFileBounded(file, 8 * 1024 * 1024);
1733
+ if (!read.ok && read.code === diagnosticCodes.fileNotFound)
1734
+ continue;
1735
+ if (!read.ok || read.value.digest !== entry.digest) {
1736
+ retainedPaths.push(file);
1737
+ continue;
1738
+ }
1739
+ plannedFiles.push(entry.path);
1740
+ candidates.push({ path: entry.path, digest: entry.digest });
1741
+ }
1742
+ if (host === 'pi' &&
1743
+ retainedPaths.some((file) => isPathInside(join(hostRoot, 'extensions'), file))) {
1744
+ // An edited entrypoint may still import its runtime companion.
1745
+ for (let index = candidates.length - 1; index >= 0; index -= 1) {
1746
+ const entry = candidates[index];
1747
+ if (!entry.path.startsWith('extensions/'))
1748
+ continue;
1749
+ retainedPaths.push(join(hostRoot, ...entry.path.split('/')));
1750
+ plannedFiles.splice(plannedFiles.indexOf(entry.path), 1);
1751
+ candidates.splice(index, 1);
1752
+ }
1753
+ }
1754
+ if (retainedPaths.length === 0)
1755
+ plannedFiles.push(BUNDLE_FILE);
1756
+ if (!options.apply)
1757
+ return result(retainedPaths.length ? 'partial' : 'planned', retainedPaths.length === 0);
1758
+ const lock = await acquireHostInstallLock(hostRoot, host);
1759
+ if (!lock.ok) {
1760
+ diagnostics.push(lock.diagnostic);
1761
+ return result('conflict', false);
1762
+ }
1763
+ let status = 'removed';
1764
+ try {
1765
+ const freshManifest = await readFileBounded(manifestPath, 256 * 1024);
1766
+ if (!freshManifest.ok ||
1767
+ freshManifest.value.digest !== manifestRead.value.digest) {
1768
+ retainedPaths.push(manifestPath);
1769
+ status = 'conflict';
1770
+ }
1771
+ else {
1772
+ if (config) {
1773
+ const written = await writeAtomic(config.path, config.bytes, {
1774
+ replace: true,
1775
+ beforeDigest: config.before,
1776
+ root: hostRoot,
1777
+ });
1778
+ if (!written.ok) {
1779
+ diagnostics.push(written.diagnostic);
1780
+ retainedPaths.push(config.path);
1781
+ status = 'conflict';
1782
+ }
1783
+ }
1784
+ const remove = async (entry) => {
1785
+ const file = join(hostRoot, ...entry.path.split('/'));
1786
+ const observed = await observePath(file);
1787
+ if (observed.kind === 'missing')
1788
+ return;
1789
+ if (observed.kind !== 'file' || !observed.stats) {
1790
+ retainedPaths.push(file);
1791
+ return;
1792
+ }
1793
+ const removed = await removeOwnedFile(hostRoot, {
1794
+ path: entry.path,
1795
+ installId: randomUUID(),
1796
+ operation: 'create',
1797
+ beforeDigest: null,
1798
+ afterDigest: entry.digest,
1799
+ backupRef: null,
1800
+ backupDigest: null,
1801
+ identity: { dev: observed.stats.dev, ino: observed.stats.ino },
1802
+ owner: 'pappra',
1803
+ verifiedAt: now(),
1804
+ });
1805
+ if (removed.ok)
1806
+ removedFiles.push(entry.path);
1807
+ else
1808
+ retainedPaths.push(file);
1809
+ };
1810
+ if (status !== 'conflict') {
1811
+ for (const entry of candidates)
1812
+ await remove(entry);
1813
+ if (retainedPaths.length === 0)
1814
+ await remove({
1815
+ path: BUNDLE_FILE,
1816
+ digest: manifestRead.value.digest,
1817
+ });
1818
+ }
1819
+ }
1820
+ }
1821
+ catch {
1822
+ status = 'failed';
1823
+ retainedPaths.push(manifestPath);
1824
+ }
1825
+ finally {
1826
+ if (!(await releaseHostInstallLock(lock.lock)))
1827
+ retainedPaths.push(lock.lock.path);
1828
+ }
1829
+ if (status === 'removed' && retainedPaths.length > 0)
1830
+ status = 'partial';
1831
+ return result(status, status === 'removed');
1832
+ }
1383
1833
  async readManifest(hostRoot) {
1384
1834
  const path = join(hostRoot, BUNDLE_FILE);
1385
1835
  const safe = await assertSafeTarget(hostRoot, path);