@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
@@ -0,0 +1,1153 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { readBoundedFile } from './bounded-file.js';
5
+ import { collabBindingSchema } from '../schemas/collaboration.js';
6
+ import { isPathInside } from '../schemas/paths.js';
7
+ import { anchorTaskNamespace, inspectRecoveryProjection, readRecordSafe, verifyTaskNamespace, } from './collab-recovery-projection.js';
8
+ import { createHerdrTransport, reportedSessionIdentity, } from './collab-notify.js';
9
+ export const COLLAB_RECOVERY_EXECUTOR_SCHEMA = 'pappra/collab-recovery-executor/1';
10
+ const RESERVATION_SCHEMA = 'pappra/collab-recovery-reservation/2';
11
+ /** Upper bounds; the executor never loops unboundedly and never mutates the protected task record. */
12
+ const MAX_RETRIES_CEILING = 4;
13
+ const DEFAULT_RETRIES = 2;
14
+ const MAX_RESERVATION_BYTES = 64 * 1024;
15
+ const MAX_JOURNAL_ENTRIES = 32;
16
+ const MAX_RESUME_CLAIM_HOPS = 64;
17
+ const RESERVATION_DIRNAME = '.recovery';
18
+ const NOFOLLOW_WRITE_FLAGS = fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW;
19
+ const MAX_RECOVERY_BINDING_BYTES = 64 * 1024;
20
+ const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/;
21
+ /** Resolve only the exact task-local binding whose canonical file digest is pinned by the request. */
22
+ function readTaskBinding(taskRoot, taskId, self, expected) {
23
+ if (expected === null ||
24
+ expected === undefined ||
25
+ typeof expected.session_id !== 'string' ||
26
+ expected.session_id.length === 0 ||
27
+ expected.session_id !== self.session_id ||
28
+ self.harness !== expected.harness ||
29
+ typeof expected.cwd !== 'string' ||
30
+ !path.isAbsolute(expected.cwd) ||
31
+ typeof expected.harness !== 'string' ||
32
+ expected.harness.length === 0 ||
33
+ typeof expected.pane_id !== 'string' ||
34
+ typeof expected.target_agent !== 'string' ||
35
+ expected.target_agent.length === 0 ||
36
+ typeof expected.binding_ref !== 'string' ||
37
+ !path.isAbsolute(expected.binding_ref) ||
38
+ typeof expected.binding_digest !== 'string' ||
39
+ !SHA256_PATTERN.test(expected.binding_digest))
40
+ return null;
41
+ const file = path.resolve(expected.binding_ref);
42
+ let rootReal;
43
+ try {
44
+ rootReal = fs.realpathSync(taskRoot);
45
+ const fileReal = fs.realpathSync(file);
46
+ if (!isPathInside(path.resolve(taskRoot), file) ||
47
+ !isPathInside(rootReal, fileReal))
48
+ return null;
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ const loaded = readBoundedFile(file, MAX_RECOVERY_BINDING_BYTES);
54
+ if (!loaded.ok || loaded.digest !== expected.binding_digest)
55
+ return null;
56
+ let parsed;
57
+ try {
58
+ parsed = JSON.parse(loaded.text);
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ const validated = collabBindingSchema.safeParse(parsed);
64
+ if (!validated.success)
65
+ return null;
66
+ const binding = validated.data;
67
+ let bindingRoot;
68
+ try {
69
+ bindingRoot = fs.realpathSync(binding.task_root);
70
+ }
71
+ catch {
72
+ return null;
73
+ }
74
+ if (binding.task_id !== taskId ||
75
+ bindingRoot !== rootReal ||
76
+ binding.channel !== 'herdr')
77
+ return null;
78
+ const matches = binding.participants.filter((participant) => participant.agent_id === self.agent_id &&
79
+ participant.native_session_id === self.session_id &&
80
+ participant.harness === expected.harness &&
81
+ participant.cwd === expected.cwd &&
82
+ participant.pane_id === expected.pane_id &&
83
+ participant.herdr_agent === expected.target_agent);
84
+ if (matches.length !== 1)
85
+ return null;
86
+ const participant = matches[0];
87
+ return {
88
+ digest: loaded.digest,
89
+ participant: {
90
+ agent: participant.herdr_agent,
91
+ session_id: participant.native_session_id,
92
+ cwd: participant.cwd,
93
+ pane_id: participant.pane_id,
94
+ harness: participant.harness,
95
+ },
96
+ };
97
+ }
98
+ const CONTINUE_KINDS = new Set([
99
+ 'resume_work',
100
+ 'answer_supersedes_wait',
101
+ ]);
102
+ const REVIEW_KINDS = new Set([
103
+ 'progress_report_to_review',
104
+ ]);
105
+ function controllerKindFor(kind) {
106
+ if (CONTINUE_KINDS.has(kind))
107
+ return 'continue';
108
+ if (REVIEW_KINDS.has(kind))
109
+ return 'review';
110
+ return null;
111
+ }
112
+ function reservationDir(taskRoot) {
113
+ return path.join(taskRoot, RESERVATION_DIRNAME, 'reservations');
114
+ }
115
+ /** Derive the durable dedup key from the canonical cause identity (request id excluded). */
116
+ function causeKey(cause) {
117
+ const canonical = JSON.stringify({
118
+ task_id: cause.task_id,
119
+ role: cause.role,
120
+ actor: cause.actor,
121
+ owner_epoch: cause.owner_epoch,
122
+ action_key: cause.action_key,
123
+ subtask_id: cause.subtask_id,
124
+ attempt: cause.attempt,
125
+ input_revision: cause.input_revision,
126
+ report_digest: cause.report_digest,
127
+ });
128
+ return crypto.createHash('sha256').update(canonical).digest('hex');
129
+ }
130
+ function result(partial) {
131
+ return { schema: COLLAB_RECOVERY_EXECUTOR_SCHEMA, ...partial };
132
+ }
133
+ const BASE_LIMITS = [
134
+ 'durable_sidecar_reservation_only_no_protected_record_mutation',
135
+ 'submission_or_queue_acceptance_is_not_consumption_or_completion',
136
+ 'no_native_query_controller_injected_and_live_target_verified',
137
+ 'no_new_session_hidden_process_or_reentrant_wake_loop',
138
+ 'unknown_or_possible_effects_stay_actionable_unknown_never_blind_replay',
139
+ 'no_follow_inode_anchored_sidecars_no_os_atomic_or_exactly_once_claim',
140
+ ];
141
+ function boundRetries(value) {
142
+ if (value === undefined)
143
+ return DEFAULT_RETRIES;
144
+ if (!Number.isFinite(value) || !Number.isInteger(value))
145
+ return DEFAULT_RETRIES;
146
+ if (value < 0)
147
+ return 0;
148
+ if (value > MAX_RETRIES_CEILING)
149
+ return MAX_RETRIES_CEILING;
150
+ return value;
151
+ }
152
+ /** Find the single actionable recovery recommendation, or null when nothing is safe to act on. */
153
+ function selectActionable(projection) {
154
+ if (projection.coverage !== 'complete')
155
+ return null;
156
+ const candidates = projection.actions.filter((action) => action.status === 'pending' && controllerKindFor(action.kind) !== null);
157
+ if (candidates.length !== 1)
158
+ return null;
159
+ return candidates[0] ?? null;
160
+ }
161
+ function buildCause(request, projection, action, reason, ownerEpoch, inputRevision, actor, reportDigest) {
162
+ return {
163
+ reason,
164
+ task_id: projection.task_id,
165
+ role: request.policy.role,
166
+ actor,
167
+ action_key: action.key,
168
+ subtask_id: action.subtask_id,
169
+ attempt: action.attempt,
170
+ input_revision: inputRevision,
171
+ report_digest: reportDigest,
172
+ owner_epoch: ownerEpoch,
173
+ request_id: request.requestId,
174
+ is_recovery_wake: true,
175
+ };
176
+ }
177
+ /**
178
+ * Validate current authority and identity without querying a host. For a resume the acting `self` must
179
+ * match the expected session and the attempt's bound native session; a pending cancellation refuses. For a
180
+ * review the acting coordinator is validated by role, not by the completed writer's native binding.
181
+ */
182
+ function validateAuthority(request, record, action, kind, notes) {
183
+ const expected = request.expectedSession;
184
+ const participant = expected?.binding?.participant;
185
+ if (expected === undefined ||
186
+ expected === null ||
187
+ participant === undefined ||
188
+ participant === null ||
189
+ expected.session_id !== request.self.session_id ||
190
+ expected.binding_digest !== expected.binding?.digest ||
191
+ participant.agent !== request.self.agent_id ||
192
+ participant.session_id !== request.self.session_id ||
193
+ participant.cwd !== expected.cwd ||
194
+ participant.pane_id !== expected.pane_id ||
195
+ participant.harness !== expected.harness ||
196
+ expected.target_agent !== participant.agent) {
197
+ notes.push('complete task-bound native identity evidence is required; refusing to apply.');
198
+ return false;
199
+ }
200
+ if (action.subtask_id === null || action.attempt === null) {
201
+ notes.push('the recovery action has no task attempt association; refusing to apply.');
202
+ return false;
203
+ }
204
+ const attempt = record.attempts.find((candidate) => candidate.subtask_id === action.subtask_id &&
205
+ candidate.attempt === action.attempt);
206
+ if (!attempt) {
207
+ notes.push('the recovery attempt is not present in the current task record; refusing to apply.');
208
+ return false;
209
+ }
210
+ if (typeof attempt.cancel_requested_at === 'string') {
211
+ notes.push('the attempt has a pending cancellation; recovery respects the cancellation and does not apply.');
212
+ return false;
213
+ }
214
+ if (kind === 'continue') {
215
+ if (request.policy.role !== 'writer' ||
216
+ attempt.assignee.agent_id !== request.self.agent_id ||
217
+ attempt.assignee.session_id !== request.self.session_id ||
218
+ (attempt.native.session_id !== null &&
219
+ attempt.native.session_id !== request.self.session_id)) {
220
+ notes.push('the acting writer is not the current task assignee bound to this native session; refusing to apply.');
221
+ return false;
222
+ }
223
+ }
224
+ else if (request.policy.role !== 'coordinator' ||
225
+ record.coordinator?.agent_id !== request.self.agent_id ||
226
+ record.coordinator?.session_id !== request.self.session_id) {
227
+ notes.push('the acting session is not the current task coordinator; refusing review.');
228
+ return false;
229
+ }
230
+ return true;
231
+ }
232
+ function isJournalEntry(value) {
233
+ if (typeof value !== 'object' || value === null)
234
+ return false;
235
+ const entry = value;
236
+ return (typeof entry.seq === 'number' &&
237
+ typeof entry.intent_at === 'number' &&
238
+ (entry.disposition === null || typeof entry.disposition === 'string') &&
239
+ (entry.observation === null || typeof entry.observation === 'string') &&
240
+ (entry.outcome_at === null || typeof entry.outcome_at === 'number'));
241
+ }
242
+ function isCause(value) {
243
+ if (typeof value !== 'object' || value === null)
244
+ return false;
245
+ const cause = value;
246
+ const actor = cause.actor;
247
+ return (typeof cause.action_key === 'string' &&
248
+ typeof cause.role === 'string' &&
249
+ typeof cause.owner_epoch === 'number' &&
250
+ (cause.task_id === null || typeof cause.task_id === 'string') &&
251
+ (cause.subtask_id === null || typeof cause.subtask_id === 'string') &&
252
+ (cause.attempt === null || typeof cause.attempt === 'number') &&
253
+ (cause.input_revision === null ||
254
+ typeof cause.input_revision === 'string') &&
255
+ (cause.report_digest === null || typeof cause.report_digest === 'string') &&
256
+ typeof cause.request_id === 'string' &&
257
+ cause.is_recovery_wake === true &&
258
+ typeof actor === 'object' &&
259
+ actor !== null &&
260
+ typeof actor.agent_id === 'string' &&
261
+ typeof actor.session_id === 'string');
262
+ }
263
+ /** Parse the complete reservation/cause/journal/final schema; malformed/truncated data stays unknown. */
264
+ function parseReservation(raw) {
265
+ let parsed;
266
+ try {
267
+ parsed = JSON.parse(raw);
268
+ }
269
+ catch {
270
+ return null;
271
+ }
272
+ if (typeof parsed !== 'object' || parsed === null)
273
+ return null;
274
+ const file = parsed;
275
+ if (file.schema !== RESERVATION_SCHEMA)
276
+ return null;
277
+ if (typeof file.key !== 'string')
278
+ return null;
279
+ if (typeof file.reserved_at !== 'number')
280
+ return null;
281
+ if (file.owner_pid !== undefined &&
282
+ (!Number.isSafeInteger(file.owner_pid) || Number(file.owner_pid) <= 0))
283
+ return null;
284
+ if (!isCause(file.cause))
285
+ return null;
286
+ if (file.generation !== undefined &&
287
+ (!Number.isSafeInteger(file.generation) || Number(file.generation) < 0))
288
+ return null;
289
+ if (!Array.isArray(file.journal) || !file.journal.every(isJournalEntry))
290
+ return null;
291
+ if (file.final !== undefined) {
292
+ const final = file.final;
293
+ if (typeof final !== 'object' ||
294
+ final === null ||
295
+ typeof final.status !== 'string' ||
296
+ typeof final.recorded_at !== 'number')
297
+ return null;
298
+ }
299
+ return parsed;
300
+ }
301
+ function lstatId(target) {
302
+ try {
303
+ const stat = fs.lstatSync(target);
304
+ if (!stat.isDirectory())
305
+ return null;
306
+ return { dev: stat.dev, ino: stat.ino };
307
+ }
308
+ catch {
309
+ return null;
310
+ }
311
+ }
312
+ /**
313
+ * Create and no-follow-verify the sidecar directory chain inside the task root, pinning each component's
314
+ * inode. Every component must be a real directory (never a symlink) so a pre-created or swapped `.recovery`
315
+ * cannot redirect writes outside the task root.
316
+ */
317
+ function openSidecarDir(taskRoot) {
318
+ const recoveryDir = path.join(taskRoot, RESERVATION_DIRNAME);
319
+ const reservationsDir = path.join(recoveryDir, 'reservations');
320
+ for (const dir of [recoveryDir, reservationsDir]) {
321
+ if (lstatId(dir) === null) {
322
+ try {
323
+ fs.mkdirSync(dir);
324
+ }
325
+ catch {
326
+ return null;
327
+ }
328
+ }
329
+ }
330
+ const recovery = lstatId(recoveryDir);
331
+ const reservations = lstatId(reservationsDir);
332
+ if (recovery === null || reservations === null)
333
+ return null;
334
+ return { dir: reservationsDir, recovery, reservations };
335
+ }
336
+ /** Re-verify the sidecar directories still resolve to the pinned inodes and are not symlinks. */
337
+ function verifySidecar(anchor) {
338
+ const recovery = lstatId(path.join(anchor.dir, '..'));
339
+ const reservations = lstatId(anchor.dir);
340
+ return (recovery !== null &&
341
+ reservations !== null &&
342
+ recovery.dev === anchor.recovery.dev &&
343
+ recovery.ino === anchor.recovery.ino &&
344
+ reservations.dev === anchor.reservations.dev &&
345
+ reservations.ino === anchor.reservations.ino);
346
+ }
347
+ /** Exclusively create the reservation file (O_EXCL refuses an existing symlink) and pin its inode. */
348
+ function createReservationExclusive(file, anchor, body) {
349
+ if (!verifySidecar(anchor))
350
+ return { ok: false, reason: 'unsafe' };
351
+ let fd;
352
+ try {
353
+ fd = fs.openSync(file, 'wx');
354
+ }
355
+ catch (error) {
356
+ if (error.code === 'EEXIST')
357
+ return { ok: false, reason: 'exists' };
358
+ return { ok: false, reason: 'unsafe' };
359
+ }
360
+ try {
361
+ const stat = fs.fstatSync(fd);
362
+ fs.writeSync(fd, body);
363
+ fs.ftruncateSync(fd, Buffer.byteLength(body));
364
+ fs.fsyncSync(fd);
365
+ return { ok: true, id: { dev: stat.dev, ino: stat.ino } };
366
+ }
367
+ catch {
368
+ return { ok: false, reason: 'unsafe' };
369
+ }
370
+ finally {
371
+ try {
372
+ fs.closeSync(fd);
373
+ }
374
+ catch {
375
+ /* already closed */
376
+ }
377
+ }
378
+ }
379
+ /**
380
+ * Update the reservation through a no-follow descriptor whose inode must still match the pinned file, with
381
+ * the sidecar directories re-verified. A swapped symlink or replaced inode yields a conservative `false`
382
+ * and never follows the link or writes outside; nothing throws out of this path.
383
+ */
384
+ function safeUpdate(file, fileId, anchor, body) {
385
+ if (!verifySidecar(anchor))
386
+ return false;
387
+ let fd;
388
+ try {
389
+ fd = fs.openSync(file, NOFOLLOW_WRITE_FLAGS);
390
+ }
391
+ catch {
392
+ return false; // ELOOP for a symlink, ENOENT for a removed file, etc.
393
+ }
394
+ try {
395
+ const stat = fs.fstatSync(fd);
396
+ if (!stat.isFile() || stat.dev !== fileId.dev || stat.ino !== fileId.ino)
397
+ return false;
398
+ fs.writeSync(fd, body);
399
+ fs.ftruncateSync(fd, Buffer.byteLength(body));
400
+ fs.fsyncSync(fd);
401
+ return true;
402
+ }
403
+ catch {
404
+ return false;
405
+ }
406
+ finally {
407
+ try {
408
+ fs.closeSync(fd);
409
+ }
410
+ catch {
411
+ /* already closed */
412
+ }
413
+ }
414
+ }
415
+ /** Read and fully validate a reservation through a bounded no-follow regular-file read. */
416
+ function readReservation(file, anchor) {
417
+ if (!verifySidecar(anchor))
418
+ return null;
419
+ let raw;
420
+ try {
421
+ const stat = fs.lstatSync(file);
422
+ if (!stat.isFile() || stat.size > MAX_RESERVATION_BYTES)
423
+ return null;
424
+ raw = fs.readFileSync(file, 'utf8');
425
+ }
426
+ catch {
427
+ return null;
428
+ }
429
+ return parseReservation(raw);
430
+ }
431
+ function processExited(pid) {
432
+ if (pid === undefined)
433
+ return false;
434
+ try {
435
+ process.kill(pid, 0);
436
+ return false;
437
+ }
438
+ catch (error) {
439
+ return error.code === 'ESRCH';
440
+ }
441
+ }
442
+ /**
443
+ * Dead pre-dispatch claims are immutable. Contend on one deterministic successor
444
+ * instead of unlinking a pathname that another contender may already have replaced.
445
+ */
446
+ function claimResumeGeneration(reservationFile, anchor, existing) {
447
+ const base = `${reservationFile}.resume-${existing.generation ?? 0}.lock`;
448
+ let claimFile = base;
449
+ const snapshot = serialize(existing);
450
+ for (let hop = 0; hop < MAX_RESUME_CLAIM_HOPS; hop += 1) {
451
+ const current = readReservation(reservationFile, anchor);
452
+ if (current === null || serialize(current) !== snapshot)
453
+ return { ok: false, reason: 'exists' };
454
+ const claim = createReservationExclusive(claimFile, anchor, `${JSON.stringify({ owner_pid: process.pid, prior_owner_pid: existing.owner_pid, key: existing.key })}\n`);
455
+ if (claim.ok || claim.reason !== 'exists')
456
+ return claim;
457
+ let fd = null;
458
+ try {
459
+ fd = fs.openSync(claimFile, fs.constants.O_RDONLY |
460
+ fs.constants.O_NOFOLLOW |
461
+ fs.constants.O_NONBLOCK);
462
+ const stat = fs.fstatSync(fd);
463
+ if (!stat.isFile() || stat.size > 1024)
464
+ return { ok: false, reason: 'unsafe' };
465
+ const buffer = Buffer.alloc(1025);
466
+ const bytes = fs.readSync(fd, buffer, 0, buffer.length, 0);
467
+ if (bytes > 1024)
468
+ return { ok: false, reason: 'unsafe' };
469
+ const raw = buffer.subarray(0, bytes).toString('utf8');
470
+ const owner = JSON.parse(raw);
471
+ if (typeof owner !== 'object' ||
472
+ owner === null ||
473
+ !('owner_pid' in owner) ||
474
+ !('key' in owner) ||
475
+ owner.key !== existing.key ||
476
+ typeof owner.owner_pid !== 'number' ||
477
+ !Number.isSafeInteger(owner.owner_pid) ||
478
+ owner.owner_pid <= 0 ||
479
+ !processExited(owner.owner_pid))
480
+ return { ok: false, reason: 'exists' };
481
+ const after = fs.lstatSync(claimFile);
482
+ if (!after.isFile() || after.dev !== stat.dev || after.ino !== stat.ino)
483
+ return { ok: false, reason: 'unsafe' };
484
+ const boundary = crypto
485
+ .createHash('sha256')
486
+ .update(JSON.stringify([stat.dev, stat.ino, raw]))
487
+ .digest('hex');
488
+ claimFile = `${base}.retry-${boundary}`;
489
+ }
490
+ catch {
491
+ return { ok: false, reason: 'unsafe' };
492
+ }
493
+ finally {
494
+ if (fd !== null) {
495
+ try {
496
+ fs.closeSync(fd);
497
+ }
498
+ catch {
499
+ /* The claim remains conservative if descriptor cleanup fails. */
500
+ }
501
+ }
502
+ }
503
+ }
504
+ return { ok: false, reason: 'exists' };
505
+ }
506
+ function controllerClass(outcome) {
507
+ if (outcome.disposition === 'submitted')
508
+ return 'issued';
509
+ if (outcome.disposition === 'deferred')
510
+ return 'deferred';
511
+ if (outcome.disposition === 'refused')
512
+ return 'refused';
513
+ // Retry only on explicit supported evidence of known no effect.
514
+ if ((outcome.disposition === 'failed' ||
515
+ outcome.disposition === 'unavailable') &&
516
+ outcome.observation === 'none')
517
+ return 'retriable';
518
+ return 'unknown';
519
+ }
520
+ function statusForClass(klass) {
521
+ if (klass === 'issued')
522
+ return 'applied';
523
+ if (klass === 'deferred')
524
+ return 'deferred';
525
+ if (klass === 'refused')
526
+ return 'refused';
527
+ return 'unknown';
528
+ }
529
+ /**
530
+ * Execute one durable, idempotent recovery step. `plan` validates and reports the intended action with
531
+ * zero effect; `apply` reserves atomically by canonical cause through no-follow inode-anchored sidecars
532
+ * before issuing a single supported continuation/review wake through the injected (exact live-target
533
+ * verifying) controller. Authority, namespace, sidecar anchors and the live target are re-validated before
534
+ * every attempt and again inside the controller after its live lookup; only explicit known no-effect
535
+ * failures retry within a bounded budget, and a possible/ambiguous effect stops as unknown. Persistence
536
+ * failures become structured outcomes and never throw out of the host callback.
537
+ */
538
+ export async function executeRecovery(request, deps) {
539
+ try {
540
+ return await runRecovery(request, deps);
541
+ }
542
+ catch (error) {
543
+ return result({
544
+ ok: false,
545
+ status: 'unknown',
546
+ action: null,
547
+ cause: null,
548
+ reservation: null,
549
+ performed: null,
550
+ retries: 0,
551
+ notes: [
552
+ `recovery stopped on an unexpected error and is recorded as an actionable unknown (${error instanceof Error ? error.message : 'error'}).`,
553
+ ],
554
+ limits: BASE_LIMITS,
555
+ });
556
+ }
557
+ }
558
+ async function runRecovery(request, deps) {
559
+ const notes = [];
560
+ const maxRetries = boundRetries(request.maxRetries);
561
+ const now = deps.now ?? Date.now;
562
+ const sleep = deps.sleep ??
563
+ ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
564
+ if (request.mode !== 'plan' && request.mode !== 'apply')
565
+ return refusal('mode must be "plan" or "apply".');
566
+ if (typeof request.requestId !== 'string' || request.requestId.length === 0)
567
+ return refusal('a non-empty stable requestId is required.');
568
+ const projectionResult = await inspectRecoveryProjection({
569
+ taskRoot: request.taskRoot,
570
+ planFile: request.planFile,
571
+ self: request.self,
572
+ policy: request.policy,
573
+ });
574
+ if (!projectionResult.ok)
575
+ return refusal(`projection refused: ${projectionResult.message}`);
576
+ const projection = projectionResult.value;
577
+ const action = selectActionable(projection);
578
+ if (action === null)
579
+ return result({
580
+ ok: true,
581
+ status: 'noop',
582
+ action: null,
583
+ cause: null,
584
+ reservation: null,
585
+ performed: null,
586
+ retries: 0,
587
+ notes: projection.coverage !== 'complete'
588
+ ? ['projection coverage is incomplete; no action is safe to issue.']
589
+ : ['no single actionable recovery recommendation; nothing to do.'],
590
+ limits: [...BASE_LIMITS, ...projection.limits],
591
+ });
592
+ const controllerKind = controllerKindFor(action.kind);
593
+ const reason = controllerKind === 'review' ? 'progress_review' : 'resume_unfinished';
594
+ const reportDigest = action.evidence.find((item) => item.startsWith('report:')) ?? null;
595
+ const preRead = readRecordSafe(request.taskRoot);
596
+ const ownerEpoch = preRead.ok && preRead.record.coordinator
597
+ ? preRead.record.coordinator.owner_epoch
598
+ : 0;
599
+ const attemptForCause = preRead.ok && action.subtask_id !== null && action.attempt !== null
600
+ ? (preRead.record.attempts.find((candidate) => candidate.subtask_id === action.subtask_id &&
601
+ candidate.attempt === action.attempt) ?? null)
602
+ : null;
603
+ const inputRevision = attemptForCause ? attemptForCause.input_revision : null;
604
+ const actor = controllerKind === 'review' && preRead.ok && preRead.record.coordinator
605
+ ? {
606
+ agent_id: preRead.record.coordinator.agent_id,
607
+ session_id: preRead.record.coordinator.session_id,
608
+ }
609
+ : attemptForCause && attemptForCause.assignee
610
+ ? {
611
+ agent_id: attemptForCause.assignee.agent_id,
612
+ session_id: attemptForCause.assignee.session_id,
613
+ }
614
+ : {
615
+ agent_id: request.self.agent_id,
616
+ session_id: request.self.session_id,
617
+ };
618
+ const cause = buildCause(request, projection, action, reason, ownerEpoch, inputRevision, actor, reportDigest);
619
+ const key = causeKey(cause);
620
+ let applyRequest = request;
621
+ if (request.mode === 'apply') {
622
+ const taskBinding = readTaskBinding(request.taskRoot, projection.task_id ?? '', request.self, request.expectedSession);
623
+ if (taskBinding === null)
624
+ return refusal('apply requires an exact current-session identity and a matching digest-pinned task-local binding participant.');
625
+ applyRequest = {
626
+ ...request,
627
+ expectedSession: {
628
+ ...request.expectedSession,
629
+ binding: taskBinding,
630
+ },
631
+ };
632
+ }
633
+ if (request.mode === 'plan') {
634
+ const dir = reservationDir(request.taskRoot);
635
+ return result({
636
+ ok: true,
637
+ status: 'planned',
638
+ action,
639
+ cause,
640
+ reservation: {
641
+ key,
642
+ path: path.join(dir, `${key}.json`),
643
+ created: false,
644
+ outcome: null,
645
+ },
646
+ performed: null,
647
+ retries: 0,
648
+ notes: ['dry plan: no reservation written and no controller invoked.'],
649
+ limits: [...BASE_LIMITS, ...projection.limits],
650
+ });
651
+ }
652
+ // apply: retain the initial task namespace anchor across the whole operation.
653
+ const initialAnchor = anchorTaskNamespace(applyRequest.taskRoot);
654
+ if (!verifyTaskNamespace(initialAnchor))
655
+ return blocked(action, cause, 'the task namespace is unavailable or redirected; refusing to apply.', projection);
656
+ if (!preRead.ok)
657
+ return blocked(action, cause, `the task record could not be re-read before applying (${preRead.code}).`, projection);
658
+ if (!validateAuthority(applyRequest, preRead.record, action, controllerKind, notes))
659
+ return result({
660
+ ok: false,
661
+ status: 'refused',
662
+ action,
663
+ cause,
664
+ reservation: null,
665
+ performed: null,
666
+ retries: 0,
667
+ notes,
668
+ limits: [...BASE_LIMITS, ...projection.limits],
669
+ });
670
+ const sidecar = openSidecarDir(applyRequest.taskRoot);
671
+ if (sidecar === null)
672
+ return blocked(action, cause, 'the recovery sidecar directory is missing, not a directory, or redirected through a symlink; refusing to write or wake.', projection);
673
+ const file = path.join(sidecar.dir, `${key}.json`);
674
+ const firstBody = `${JSON.stringify({ schema: RESERVATION_SCHEMA, key, cause, reserved_at: now(), owner_pid: process.pid, journal: [] }, null, 2)}\n`;
675
+ const create = createReservationExclusive(file, sidecar, firstBody);
676
+ let resumed = null;
677
+ if (!create.ok) {
678
+ if (create.reason === 'unsafe')
679
+ return blocked(action, cause, 'the recovery sidecar was redirected or unwritable; refusing to write or wake.', projection, { key, path: file, created: false });
680
+ const existing = readReservation(file, sidecar);
681
+ if (existing === null)
682
+ return unknownState(action, cause, key, file, 'a reservation exists but is unreadable or malformed; the prior effect state is unknown. Not replaying.', projection);
683
+ const knownNoEffect = existing.key === key &&
684
+ existing.journal.every((entry, index, entries) => Number.isSafeInteger(entry.seq) &&
685
+ entry.seq >= 0 &&
686
+ (index === 0 || entry.seq === entries[index - 1].seq + 1) &&
687
+ entry.outcome_at !== null &&
688
+ entry.observation === 'none' &&
689
+ (entry.disposition === 'failed' ||
690
+ entry.disposition === 'unavailable' ||
691
+ entry.disposition === 'refused'));
692
+ const retryableFinal = knownNoEffect &&
693
+ existing.final?.observation === 'none' &&
694
+ (existing.final.status === 'blocked' ||
695
+ existing.final.status === 'unknown' ||
696
+ existing.final.status === 'refused');
697
+ if (existing.final && !retryableFinal)
698
+ return result({
699
+ ok: existing.final.status === 'applied' ||
700
+ existing.final.status === 'deferred',
701
+ status: existing.final.status === 'applied' ||
702
+ existing.final.status === 'deferred'
703
+ ? 'noop'
704
+ : 'unknown',
705
+ action,
706
+ cause,
707
+ reservation: {
708
+ key,
709
+ path: file,
710
+ created: false,
711
+ outcome: existing.final.status,
712
+ },
713
+ performed: null,
714
+ retries: 0,
715
+ notes: [
716
+ 'this cause has a recorded effect or ambiguous outcome; preserving that outcome without re-issuing. A possible effect requires supported observation before retry.',
717
+ ],
718
+ limits: [...BASE_LIMITS, ...projection.limits],
719
+ });
720
+ const previousExited = processExited(existing.owner_pid);
721
+ if (retryableFinal || (previousExited && knownNoEffect)) {
722
+ // Each persisted retry boundary has one exclusive successor. A crash after
723
+ // this claim remains ambiguous; the same boundary is never claimed twice.
724
+ const claim = claimResumeGeneration(file, sidecar, existing);
725
+ if (claim.ok) {
726
+ const stat = fs.lstatSync(file);
727
+ if (stat.isFile() && !stat.isSymbolicLink()) {
728
+ const current = readReservation(file, sidecar);
729
+ if (current && serialize(current) === serialize(existing)) {
730
+ existing.owner_pid = process.pid;
731
+ existing.generation = (existing.generation ?? 0) + 1;
732
+ delete existing.final;
733
+ const id = { dev: stat.dev, ino: stat.ino };
734
+ if (safeUpdate(file, id, sidecar, serialize(existing)))
735
+ resumed = { reservation: existing, id };
736
+ }
737
+ }
738
+ }
739
+ }
740
+ if (!resumed)
741
+ return unknownState(action, cause, key, file, 'exclusive continuation ownership could not be established: an incomplete intent, live or malformed owner, or retry-claim chain beyond the bounded inspection limit remains unknown. No wake was replayed; inspect the reservation and claims.', projection);
742
+ }
743
+ const fileId = resumed ? resumed.id : create.ok ? create.id : null;
744
+ if (!fileId)
745
+ throw new Error('recovery_reservation_identity_missing');
746
+ const reservation = resumed
747
+ ? resumed.reservation
748
+ : JSON.parse(firstBody);
749
+ const reservationCreated = resumed === null;
750
+ // Per-attempt dispatch loop: fresh authority + sidecar/live-target revalidation before every attempt.
751
+ let retries = 0;
752
+ let last = {
753
+ disposition: 'unknown',
754
+ detail: 'no attempt made',
755
+ observation: 'unknown',
756
+ };
757
+ for (let attempt = 0;; attempt += 1) {
758
+ if (attempt > 0)
759
+ await sleep(Math.min(2000, 100 * 2 ** (attempt - 1)));
760
+ const recheckBlock = await revalidateBeforeAttempt(applyRequest, action, controllerKind, initialAnchor, sidecar);
761
+ if (recheckBlock !== null)
762
+ return blockedAfterReserve(file, fileId, sidecar, reservation, now, action, cause, recheckBlock, projection, key, reservationCreated);
763
+ const entry = {
764
+ seq: (reservation.journal.at(-1)?.seq ?? -1) + 1,
765
+ intent_at: now(),
766
+ disposition: null,
767
+ observation: null,
768
+ outcome_at: null,
769
+ };
770
+ appendJournal(reservation, entry);
771
+ if (!safeUpdate(file, fileId, sidecar, serialize(reservation)))
772
+ return blocked(action, cause, 'the reservation file was redirected or replaced before dispatch; refusing to write or wake.', projection, { key, path: file, created: reservationCreated });
773
+ const target = {
774
+ agent_id: applyRequest.self.agent_id,
775
+ session_id: applyRequest.self.session_id,
776
+ harness: applyRequest.self.harness,
777
+ pane_id: applyRequest.expectedSession?.pane_id ?? null,
778
+ };
779
+ const confirmAuthority = async () => (await revalidateBeforeAttempt(applyRequest, action, controllerKind, initialAnchor, sidecar)) === null;
780
+ try {
781
+ last = await deps.controller.perform({
782
+ kind: controllerKind,
783
+ taskRoot: applyRequest.taskRoot,
784
+ target,
785
+ expected: applyRequest.expectedSession ?? null,
786
+ cause,
787
+ text: wakeText(controllerKind, action, cause),
788
+ confirmAuthority,
789
+ });
790
+ }
791
+ catch (error) {
792
+ last = {
793
+ disposition: 'failed',
794
+ detail: error instanceof Error ? error.message : 'controller threw',
795
+ observation: 'unknown', // a throw is a possible effect; never retried.
796
+ };
797
+ }
798
+ reservation.journal[reservation.journal.length - 1] = {
799
+ ...entry,
800
+ disposition: last.disposition,
801
+ observation: last.observation,
802
+ outcome_at: now(),
803
+ };
804
+ const outcomePersisted = safeUpdate(file, fileId, sidecar, serialize(reservation));
805
+ const klass = controllerClass(last);
806
+ if (klass !== 'retriable') {
807
+ const status = statusForClass(klass);
808
+ finalize(file, fileId, sidecar, reservation, now, status, last);
809
+ if (last.observation === 'queued')
810
+ notes.push('the wake was accepted into a queue; this is not consumption or task completion.');
811
+ if (klass === 'unknown')
812
+ notes.push('the dispatch result is a possible or ambiguous effect; it is recorded as an actionable unknown and not replayed.');
813
+ if (!outcomePersisted)
814
+ notes.push('the dispatch outcome could not be safely persisted (the sidecar path became unsafe); the effect is an actionable unknown.');
815
+ return result({
816
+ ok: outcomePersisted && (status === 'applied' || status === 'deferred'),
817
+ status: outcomePersisted ? status : 'unknown',
818
+ action,
819
+ cause,
820
+ reservation: {
821
+ key,
822
+ path: file,
823
+ created: reservationCreated,
824
+ outcome: outcomePersisted ? status : 'unknown',
825
+ },
826
+ performed: last,
827
+ retries,
828
+ notes,
829
+ limits: [...BASE_LIMITS, ...projection.limits],
830
+ });
831
+ }
832
+ if (attempt >= maxRetries) {
833
+ finalize(file, fileId, sidecar, reservation, now, 'unknown', last);
834
+ notes.push(`the continuation wake could not be delivered after ${retries} retries (${last.disposition}); persisted no-effect evidence permits a later bounded retry after the obstruction is resolved.`);
835
+ return result({
836
+ ok: false,
837
+ status: 'unknown',
838
+ action,
839
+ cause,
840
+ reservation: {
841
+ key,
842
+ path: file,
843
+ created: reservationCreated,
844
+ outcome: 'unknown',
845
+ },
846
+ performed: last,
847
+ retries,
848
+ notes,
849
+ limits: [...BASE_LIMITS, ...projection.limits],
850
+ });
851
+ }
852
+ retries += 1;
853
+ }
854
+ }
855
+ function serialize(reservation) {
856
+ return `${JSON.stringify(reservation, null, 2)}\n`;
857
+ }
858
+ function wakeText(kind, action, cause) {
859
+ return kind === 'review'
860
+ ? `[pappra-recovery] progress review for ${action.subtask_id ?? 'task'} attempt ${String(action.attempt ?? '-')}; do not rerun the writer. cause=${cause.action_key}`
861
+ : `[pappra-recovery] resume authorised work for ${action.subtask_id ?? 'task'} attempt ${String(action.attempt ?? '-')} on this session; this is a recovery wake, not a new instruction. cause=${cause.action_key}`;
862
+ }
863
+ /**
864
+ * Re-validate the namespace anchor, sidecar anchors, record, plan and authority immediately before an
865
+ * attempt. Returns a block reason when the snapshot or authority changed (for example a CANCEL admitted
866
+ * during backoff), or null when the same cause is still actionable.
867
+ */
868
+ async function revalidateBeforeAttempt(request, action, kind, initialAnchor, sidecar) {
869
+ if (!verifyTaskNamespace(initialAnchor))
870
+ return 'the task namespace changed or was redirected during recovery; stopping before the effect.';
871
+ if (!verifySidecar(sidecar))
872
+ return 'the recovery sidecar directory was redirected during recovery; stopping before the effect.';
873
+ const recheck = await inspectRecoveryProjection({
874
+ taskRoot: request.taskRoot,
875
+ planFile: request.planFile,
876
+ self: request.self,
877
+ policy: request.policy,
878
+ });
879
+ if (!recheck.ok)
880
+ return 'the projection became unavailable during recovery; stopping.';
881
+ const rechecked = selectActionable(recheck.value);
882
+ if (rechecked === null || rechecked.key !== action.key)
883
+ return 'the recovery recommendation changed during recovery; no wake was issued.';
884
+ const safe = readRecordSafe(request.taskRoot);
885
+ if (!safe.ok)
886
+ return 'the task record became unreadable during recovery; stopping.';
887
+ const currentBinding = readTaskBinding(request.taskRoot, safe.record.task_id, request.self, request.expectedSession);
888
+ if (currentBinding === null ||
889
+ currentBinding.digest !== request.expectedSession?.binding?.digest ||
890
+ JSON.stringify(currentBinding.participant) !==
891
+ JSON.stringify(request.expectedSession?.binding?.participant))
892
+ return 'the pinned task binding changed or no longer admits this session; stopping before the effect.';
893
+ const authorityNotes = [];
894
+ if (!validateAuthority(request, safe.record, action, kind, authorityNotes))
895
+ return authorityNotes[0] ?? 'authority changed during recovery; stopping.';
896
+ return null;
897
+ }
898
+ function appendJournal(reservation, entry) {
899
+ reservation.journal.push(entry);
900
+ if (reservation.journal.length > MAX_JOURNAL_ENTRIES)
901
+ reservation.journal.splice(0, reservation.journal.length - MAX_JOURNAL_ENTRIES);
902
+ }
903
+ function finalize(file, fileId, sidecar, reservation, now, status, outcome) {
904
+ reservation.final = {
905
+ status,
906
+ disposition: outcome.disposition,
907
+ observation: outcome.observation,
908
+ recorded_at: now(),
909
+ };
910
+ // Best-effort: only ever through the safe no-follow path; never through an unsafe path.
911
+ safeUpdate(file, fileId, sidecar, serialize(reservation));
912
+ }
913
+ function blockedAfterReserve(file, fileId, sidecar, reservation, now, action, cause, note, projection, key, created) {
914
+ reservation.final = {
915
+ status: 'blocked',
916
+ disposition: null,
917
+ observation: 'none',
918
+ recorded_at: now(),
919
+ };
920
+ safeUpdate(file, fileId, sidecar, serialize(reservation));
921
+ return blocked(action, cause, note, projection, {
922
+ key,
923
+ path: file,
924
+ created,
925
+ });
926
+ }
927
+ function refusal(note) {
928
+ return result({
929
+ ok: false,
930
+ status: 'refused',
931
+ action: null,
932
+ cause: null,
933
+ reservation: null,
934
+ performed: null,
935
+ retries: 0,
936
+ notes: [note],
937
+ limits: BASE_LIMITS,
938
+ });
939
+ }
940
+ function blocked(action, cause, note, projection, reservation = null) {
941
+ return result({
942
+ ok: false,
943
+ status: 'blocked',
944
+ action,
945
+ cause,
946
+ reservation: reservation ? { ...reservation, outcome: 'blocked' } : null,
947
+ performed: null,
948
+ retries: 0,
949
+ notes: [note],
950
+ limits: [...BASE_LIMITS, ...projection.limits],
951
+ });
952
+ }
953
+ function unknownState(action, cause, key, file, note, projection) {
954
+ return result({
955
+ ok: false,
956
+ status: 'unknown',
957
+ action,
958
+ cause,
959
+ reservation: { key, path: file, created: false, outcome: null },
960
+ performed: null,
961
+ retries: 0,
962
+ notes: [note],
963
+ limits: [...BASE_LIMITS, ...projection.limits],
964
+ });
965
+ }
966
+ /**
967
+ * Concrete controller for one visible Herdr target. Apply requires a complete native identity and a bounded,
968
+ * digest-pinned task-local binding that admits the acting participant and target. The live session identity,
969
+ * cwd, pane and harness must all match exactly. Task authority and the binding are re-read after the awaited
970
+ * live lookup and immediately before the prompt. It never spawns a process.
971
+ */
972
+ export function herdrRecoveryController(transport, target) {
973
+ return {
974
+ id: `herdr:${transport.id}`,
975
+ async perform(request) {
976
+ const expected = request.expected;
977
+ if (expected === null ||
978
+ expected === undefined ||
979
+ typeof expected.session_id !== 'string' ||
980
+ expected.session_id.length === 0 ||
981
+ typeof expected.cwd !== 'string' ||
982
+ typeof expected.harness !== 'string' ||
983
+ typeof expected.pane_id !== 'string' ||
984
+ expected.target_agent !== target.agent ||
985
+ typeof expected.binding_ref !== 'string' ||
986
+ typeof expected.binding_digest !== 'string' ||
987
+ request.cause.task_id === null ||
988
+ request.cause.actor.session_id !== expected.session_id ||
989
+ request.target.session_id !== expected.session_id ||
990
+ request.target.pane_id !== expected.pane_id ||
991
+ request.target.harness !== expected.harness ||
992
+ request.target.agent_id !== request.cause.actor.agent_id ||
993
+ typeof request.confirmAuthority !== 'function')
994
+ return {
995
+ disposition: 'refused',
996
+ detail: 'complete task-bound native identity and final authority evidence are required',
997
+ observation: 'none',
998
+ };
999
+ const actor = {
1000
+ agent_id: request.cause.actor.agent_id,
1001
+ session_id: request.cause.actor.session_id,
1002
+ harness: expected.harness,
1003
+ };
1004
+ const admitted = readTaskBinding(request.taskRoot, request.cause.task_id, actor, expected);
1005
+ if (admitted === null)
1006
+ return {
1007
+ disposition: 'refused',
1008
+ detail: 'the pinned task binding is missing, stale or does not admit the acting participant',
1009
+ observation: 'none',
1010
+ };
1011
+ const verifiedExpected = {
1012
+ ...expected,
1013
+ binding: admitted,
1014
+ };
1015
+ let listed;
1016
+ try {
1017
+ listed = await transport.list();
1018
+ }
1019
+ catch (error) {
1020
+ return {
1021
+ disposition: 'failed',
1022
+ detail: error instanceof Error ? error.message : 'list threw',
1023
+ observation: 'none',
1024
+ };
1025
+ }
1026
+ if (!listed.ok)
1027
+ return {
1028
+ disposition: listed.status,
1029
+ detail: listed.detail,
1030
+ observation: 'none',
1031
+ };
1032
+ const agent = listed.agents.find((item) => item.name === target.agent);
1033
+ if (!agent)
1034
+ return {
1035
+ disposition: 'refused',
1036
+ detail: `target agent ${target.agent} is not a live participant`,
1037
+ observation: 'none',
1038
+ };
1039
+ const reported = reportedSessionIdentity(agent.sessionKind, agent.sessionValue);
1040
+ if (reported.status !== 'match' ||
1041
+ reported.identity !== verifiedExpected.session_id)
1042
+ return {
1043
+ disposition: 'refused',
1044
+ detail: 'live target session identity does not exactly match the expected session',
1045
+ observation: 'none',
1046
+ };
1047
+ if (agent.cwd !== verifiedExpected.cwd)
1048
+ return {
1049
+ disposition: 'refused',
1050
+ detail: 'live target cwd is missing or does not match the expected cwd',
1051
+ observation: 'none',
1052
+ };
1053
+ if (agent.paneId !== verifiedExpected.pane_id)
1054
+ return {
1055
+ disposition: 'refused',
1056
+ detail: 'live target pane does not match the expected pane',
1057
+ observation: 'none',
1058
+ };
1059
+ if (agent.harness !== verifiedExpected.harness)
1060
+ return {
1061
+ disposition: 'refused',
1062
+ detail: 'live target harness does not match the expected harness',
1063
+ observation: 'none',
1064
+ };
1065
+ const bindingRefusal = verifyBinding(verifiedExpected, agent, reported.identity);
1066
+ if (bindingRefusal !== null)
1067
+ return {
1068
+ disposition: 'refused',
1069
+ detail: bindingRefusal,
1070
+ observation: 'none',
1071
+ };
1072
+ const currentBinding = readTaskBinding(request.taskRoot, request.cause.task_id, actor, expected);
1073
+ if (currentBinding === null ||
1074
+ currentBinding.digest !== admitted.digest ||
1075
+ JSON.stringify(currentBinding.participant) !==
1076
+ JSON.stringify(admitted.participant) ||
1077
+ !(await request.confirmAuthority()))
1078
+ return {
1079
+ disposition: 'refused',
1080
+ detail: 'task authority or the pinned binding changed between the live lookup and the prompt',
1081
+ observation: 'none',
1082
+ };
1083
+ let status;
1084
+ try {
1085
+ status = await transport.prompt(target.agent, request.text);
1086
+ }
1087
+ catch (error) {
1088
+ return {
1089
+ disposition: 'failed',
1090
+ detail: error instanceof Error ? error.message : 'prompt threw',
1091
+ observation: 'unknown',
1092
+ };
1093
+ }
1094
+ return mapPromptResult(status);
1095
+ },
1096
+ };
1097
+ }
1098
+ function verifyBinding(expected, agent, sessionId) {
1099
+ const participant = expected.binding?.participant;
1100
+ if (participant === undefined || participant === null)
1101
+ return 'the live target has no verified task-binding membership';
1102
+ if (agent.name !== participant.agent ||
1103
+ sessionId !== participant.session_id ||
1104
+ agent.cwd !== participant.cwd ||
1105
+ agent.paneId !== participant.pane_id ||
1106
+ agent.harness !== participant.harness)
1107
+ return 'the live participant does not match the canonical task binding';
1108
+ return null;
1109
+ }
1110
+ function mapPromptResult(status) {
1111
+ switch (status.status) {
1112
+ case 'submitted':
1113
+ return {
1114
+ disposition: 'submitted',
1115
+ detail: status.detail,
1116
+ observation: 'queued',
1117
+ };
1118
+ case 'blocked':
1119
+ return {
1120
+ disposition: 'deferred',
1121
+ detail: status.detail,
1122
+ observation: 'none',
1123
+ };
1124
+ case 'refused':
1125
+ return {
1126
+ disposition: 'refused',
1127
+ detail: status.detail,
1128
+ observation: 'none',
1129
+ };
1130
+ case 'unavailable':
1131
+ return {
1132
+ disposition: 'unavailable',
1133
+ detail: status.detail,
1134
+ observation: 'none',
1135
+ };
1136
+ case 'failed':
1137
+ return {
1138
+ disposition: 'failed',
1139
+ detail: status.detail,
1140
+ observation: 'unknown',
1141
+ };
1142
+ default:
1143
+ return {
1144
+ disposition: 'unknown',
1145
+ detail: status.detail,
1146
+ observation: 'unknown',
1147
+ };
1148
+ }
1149
+ }
1150
+ /** Build a Herdr-backed recovery controller from the ambient environment and a target agent. */
1151
+ export function createHerdrRecoveryController(options, target) {
1152
+ return herdrRecoveryController(createHerdrTransport(options), target);
1153
+ }