@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,1208 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { isSafeMessageId } from '../collab/envelope.js';
6
+ import { notificationsDir } from '../collab/store.js';
7
+ import { collabBindingSchema, } from '../schemas/collaboration.js';
8
+ import { isPathInside } from '../schemas/paths.js';
9
+ import { observePath, resolveRealpath } from '../infra/fs.js';
10
+ import { errorMessage } from '../schemas/outcome.js';
11
+ import { routeExclusionFor } from './collab-route.js';
12
+ import { resolveTaskManagedPi } from './managed-pi-native-observation.js';
13
+ import { requestManagedPiControl } from '../hosts/pi/managed-session.js';
14
+ import { assertMessageTaskCurrent, deliveryPathFor, openMessageTask, readDeliveryRecord, readRegisteredMessageSafe, } from './collab-messages.js';
15
+ /**
16
+ * Explicit one-shot notification of an already registered message.
17
+ *
18
+ * Notification is a separate transport operation: persistence happens first and
19
+ * is never implied by it. A successful `herdr agent prompt` means the pointer was
20
+ * submitted, not that the recipient consumed it or that any work was accepted.
21
+ */
22
+ const MAX_BINDING_BYTES = 64 * 1024;
23
+ const MAX_ATTEMPTS = 3;
24
+ const CALLER_TIMEOUT_MS = 10_000;
25
+ const HERDR_PANE_ID = /^[A-Za-z0-9]+:p[A-Za-z0-9]+$/;
26
+ const LIST_TIMEOUT_MS = 15_000;
27
+ const PROMPT_TIMEOUT_MS = 20_000;
28
+ const MAX_OUTPUT_BYTES = 256 * 1024;
29
+ function runCommand(command, args, timeoutMs) {
30
+ return new Promise((resolve) => {
31
+ let settled = false;
32
+ let stdout = '';
33
+ let stderr = '';
34
+ let timedOut = false;
35
+ let child;
36
+ const finish = (result) => {
37
+ if (settled)
38
+ return;
39
+ settled = true;
40
+ resolve(result);
41
+ };
42
+ try {
43
+ child = spawn(command, [...args], {
44
+ shell: false,
45
+ stdio: ['ignore', 'pipe', 'pipe'],
46
+ windowsHide: true,
47
+ });
48
+ }
49
+ catch (error) {
50
+ finish({
51
+ spawned: false,
52
+ code: null,
53
+ signal: null,
54
+ stdout: '',
55
+ stderr: '',
56
+ timedOut: false,
57
+ spawnError: errorMessage(error),
58
+ });
59
+ return;
60
+ }
61
+ const timer = setTimeout(() => {
62
+ timedOut = true;
63
+ child.kill('SIGKILL');
64
+ }, timeoutMs);
65
+ child.stdout?.on('data', (chunk) => {
66
+ if (stdout.length < MAX_OUTPUT_BYTES)
67
+ stdout += chunk.toString('utf8');
68
+ });
69
+ child.stderr?.on('data', (chunk) => {
70
+ if (stderr.length < MAX_OUTPUT_BYTES)
71
+ stderr += chunk.toString('utf8');
72
+ });
73
+ child.on('error', (error) => {
74
+ clearTimeout(timer);
75
+ finish({
76
+ spawned: false,
77
+ code: null,
78
+ signal: null,
79
+ stdout,
80
+ stderr,
81
+ timedOut,
82
+ spawnError: error.message,
83
+ });
84
+ });
85
+ child.on('close', (code, signal) => {
86
+ clearTimeout(timer);
87
+ finish({
88
+ spawned: true,
89
+ code,
90
+ signal,
91
+ stdout,
92
+ stderr,
93
+ timedOut,
94
+ spawnError: null,
95
+ });
96
+ });
97
+ });
98
+ }
99
+ /** Shared bounded Herdr command runner for additive bootstrap transport operations. */
100
+ export const runHerdrCommand = runCommand;
101
+ function parsedJson(text) {
102
+ const trimmed = text.trim();
103
+ if (trimmed === '')
104
+ return null;
105
+ try {
106
+ return JSON.parse(trimmed);
107
+ }
108
+ catch {
109
+ return null;
110
+ }
111
+ }
112
+ function record(value) {
113
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
114
+ ? value
115
+ : null;
116
+ }
117
+ function nested(value, key) {
118
+ const outer = record(value);
119
+ return outer === null ? undefined : outer[key];
120
+ }
121
+ function textAt(value, key) {
122
+ const candidate = nested(value, key);
123
+ return typeof candidate === 'string' ? candidate : null;
124
+ }
125
+ function agentErrorCode(value) {
126
+ const error = nested(value, 'error') ?? nested(nested(value, 'result'), 'error');
127
+ if (typeof error === 'string')
128
+ return error;
129
+ const code = nested(error, 'code');
130
+ return typeof code === 'string' ? code : null;
131
+ }
132
+ function agentErrorMessage(value) {
133
+ const error = nested(value, 'error') ?? nested(nested(value, 'result'), 'error');
134
+ if (typeof error === 'string')
135
+ return error;
136
+ const message = nested(error, 'message');
137
+ return typeof message === 'string' ? message : null;
138
+ }
139
+ /**
140
+ * Recognized pre-submission refusals. Only codes the installed Herdr CLI is
141
+ * documented/observed to return before accepting a prompt are definite; an
142
+ * unknown code (today or in a future version) stays unknown because a parseable
143
+ * error envelope does not prove the command stopped before submitting.
144
+ */
145
+ const RECOGNIZED_REFUSALS = new Set(['agent_not_found']);
146
+ function refusalStatus(code) {
147
+ if (code === 'agent_blocked') {
148
+ return {
149
+ status: 'blocked',
150
+ detail: 'the target agent is blocked; a blocked native UI is never auto-answered',
151
+ };
152
+ }
153
+ if (code === 'timeout' || code === 'agent_prompt_stalled') {
154
+ return {
155
+ status: 'unknown',
156
+ detail: `Herdr reported ${code}; submission may have happened`,
157
+ };
158
+ }
159
+ if (RECOGNIZED_REFUSALS.has(code)) {
160
+ return { status: 'refused', detail: `Herdr refused the prompt: ${code}` };
161
+ }
162
+ return {
163
+ status: 'unknown',
164
+ detail: `Herdr returned an unrecognized error code ${code}; submission may have happened`,
165
+ };
166
+ }
167
+ function sessionField(value) {
168
+ return {
169
+ kind: textAt(value, 'kind'),
170
+ value: textAt(value, 'value'),
171
+ };
172
+ }
173
+ function callerObservation(pane, source) {
174
+ const paneId = textAt(pane, 'pane_id');
175
+ if (paneId === null)
176
+ return null;
177
+ const session = sessionField(nested(pane, 'agent_session'));
178
+ return {
179
+ paneId,
180
+ workspaceId: textAt(pane, 'workspace_id'),
181
+ tabId: textAt(pane, 'tab_id'),
182
+ harness: textAt(pane, 'agent'),
183
+ cwd: textAt(pane, 'cwd'),
184
+ sessionKind: session.kind,
185
+ sessionValue: session.value,
186
+ source,
187
+ };
188
+ }
189
+ /** Default transport: the installed Herdr CLI, argv only, never `--wait`. */
190
+ export function createHerdrTransport(options = {}) {
191
+ const env = options.env ?? process.env;
192
+ const run = options.run ?? runCommand;
193
+ const nativeCaller = async (caller) => {
194
+ if (!options.taskRoot ||
195
+ caller.sessionValue !== null ||
196
+ (caller.harness !== null && caller.harness !== 'pi'))
197
+ return caller;
198
+ const observed = await resolveTaskManagedPi(options.taskRoot, caller, {
199
+ run,
200
+ });
201
+ return observed
202
+ ? {
203
+ ...caller,
204
+ harness: 'pi',
205
+ sessionKind: 'id',
206
+ sessionValue: observed.sessionId,
207
+ nativeIdentityProvenance: observed.provenance,
208
+ }
209
+ : caller;
210
+ };
211
+ const bin = typeof env['HERDR_BIN_PATH'] === 'string' &&
212
+ env['HERDR_BIN_PATH'].trim() !== ''
213
+ ? env['HERDR_BIN_PATH']
214
+ : 'herdr';
215
+ return {
216
+ id: 'herdr',
217
+ async verifyCaller(options = {}) {
218
+ if (env['HERDR_ENV'] !== '1') {
219
+ return {
220
+ ok: false,
221
+ status: 'unavailable',
222
+ code: 'not_in_herdr',
223
+ detail: 'HERDR_ENV is not 1: this process is not inside a verified Herdr caller',
224
+ };
225
+ }
226
+ const explicit = options.paneId;
227
+ if (explicit !== undefined) {
228
+ if (!HERDR_PANE_ID.test(explicit)) {
229
+ return {
230
+ ok: false,
231
+ status: 'unverified',
232
+ code: 'caller_pane_invalid',
233
+ detail: `Invalid caller pane id ${explicit}; expected a value such as w3:p1`,
234
+ };
235
+ }
236
+ const resolved = await run(bin, ['pane', 'current', '--pane', explicit], CALLER_TIMEOUT_MS);
237
+ if (!resolved.spawned) {
238
+ return {
239
+ ok: false,
240
+ status: 'unavailable',
241
+ code: 'herdr_not_callable',
242
+ detail: resolved.spawnError ?? 'Herdr is not callable',
243
+ };
244
+ }
245
+ if (resolved.timedOut) {
246
+ return {
247
+ ok: false,
248
+ status: 'unverified',
249
+ code: 'caller_resolution_timeout',
250
+ detail: 'Herdr caller pane resolution timed out; no focused pane was used',
251
+ };
252
+ }
253
+ if (resolved.code !== 0) {
254
+ return {
255
+ ok: false,
256
+ status: 'unverified',
257
+ code: 'caller_pane_unavailable',
258
+ detail: `Herdr could not resolve the explicit caller pane ${explicit} (exit ${String(resolved.code)})`,
259
+ };
260
+ }
261
+ const payload = parsedJson(resolved.stdout);
262
+ const caller = callerObservation(nested(nested(payload, 'result'), 'pane'), 'explicit');
263
+ if (caller === null) {
264
+ return {
265
+ ok: false,
266
+ status: 'unverified',
267
+ code: 'caller_unresolved',
268
+ detail: 'Herdr did not report the explicit caller pane',
269
+ };
270
+ }
271
+ if (caller.paneId !== explicit) {
272
+ return {
273
+ ok: false,
274
+ status: 'unverified',
275
+ code: 'caller_pane_mismatch',
276
+ detail: `Herdr reported pane ${caller.paneId} instead of the requested ${explicit}`,
277
+ };
278
+ }
279
+ return { ok: true, caller: await nativeCaller(caller) };
280
+ }
281
+ const envPane = env['HERDR_PANE_ID'];
282
+ if (typeof envPane !== 'string' || envPane.trim() === '') {
283
+ return {
284
+ ok: false,
285
+ status: 'unverified',
286
+ code: 'caller_context_missing',
287
+ detail: 'Inherited HERDR_PANE_ID is missing; pass --caller-pane <id> from herdr pane list instead of relying on focus',
288
+ };
289
+ }
290
+ if (!HERDR_PANE_ID.test(envPane)) {
291
+ return {
292
+ ok: false,
293
+ status: 'unverified',
294
+ code: 'caller_context_invalid',
295
+ detail: `Inherited HERDR_PANE_ID is not a pane id: ${envPane}`,
296
+ };
297
+ }
298
+ const resolved = await run(bin, ['pane', 'current', '--current'], CALLER_TIMEOUT_MS);
299
+ if (!resolved.spawned) {
300
+ return {
301
+ ok: false,
302
+ status: 'unavailable',
303
+ code: 'herdr_not_callable',
304
+ detail: resolved.spawnError ?? 'Herdr is not callable',
305
+ };
306
+ }
307
+ if (resolved.timedOut) {
308
+ return {
309
+ ok: false,
310
+ status: 'unverified',
311
+ code: 'caller_resolution_timeout',
312
+ detail: 'Herdr caller resolution timed out; no focused pane was used',
313
+ };
314
+ }
315
+ if (resolved.code !== 0) {
316
+ return {
317
+ ok: false,
318
+ status: 'unverified',
319
+ code: 'caller_unresolved',
320
+ detail: `Herdr caller resolution failed (exit ${String(resolved.code)}); pass --caller-pane from herdr pane list if the inherited pane is stale`,
321
+ };
322
+ }
323
+ const payload = parsedJson(resolved.stdout);
324
+ const caller = callerObservation(nested(nested(payload, 'result'), 'pane'), 'current');
325
+ if (caller === null) {
326
+ return {
327
+ ok: false,
328
+ status: 'unverified',
329
+ code: 'caller_unresolved',
330
+ detail: 'Herdr did not report a current caller pane',
331
+ };
332
+ }
333
+ if (caller.paneId !== envPane) {
334
+ return {
335
+ ok: false,
336
+ status: 'unverified',
337
+ code: 'caller_env_mismatch',
338
+ detail: `HERDR_PANE_ID ${envPane} does not match the resolved caller pane ${caller.paneId}`,
339
+ };
340
+ }
341
+ const envWorkspace = env['HERDR_WORKSPACE_ID'];
342
+ if (typeof envWorkspace === 'string' &&
343
+ caller.workspaceId !== null &&
344
+ envWorkspace !== caller.workspaceId) {
345
+ return {
346
+ ok: false,
347
+ status: 'unverified',
348
+ code: 'caller_env_mismatch',
349
+ detail: `HERDR_WORKSPACE_ID ${envWorkspace} does not match the resolved caller workspace`,
350
+ };
351
+ }
352
+ return { ok: true, caller: await nativeCaller(caller) };
353
+ },
354
+ async list() {
355
+ const result = await run(bin, ['agent', 'list'], LIST_TIMEOUT_MS);
356
+ if (!result.spawned) {
357
+ return {
358
+ ok: false,
359
+ status: 'unavailable',
360
+ detail: result.spawnError ?? 'Herdr is not callable',
361
+ };
362
+ }
363
+ if (result.timedOut || result.code !== 0) {
364
+ return {
365
+ ok: false,
366
+ status: 'failed',
367
+ detail: `Herdr agent list failed (exit ${String(result.code)}${result.timedOut ? ', timed out' : ''})`,
368
+ };
369
+ }
370
+ const payload = parsedJson(result.stdout);
371
+ const agents = nested(nested(payload, 'result'), 'agents');
372
+ if (!Array.isArray(agents)) {
373
+ return {
374
+ ok: false,
375
+ status: 'failed',
376
+ detail: 'Herdr agent list did not return an agent array',
377
+ };
378
+ }
379
+ const observations = [];
380
+ for (const entry of agents) {
381
+ const item = record(entry);
382
+ const harness = item?.['agent'];
383
+ if (item === null || typeof harness !== 'string') {
384
+ return {
385
+ ok: false,
386
+ status: 'failed',
387
+ detail: 'Herdr agent list returned a malformed agent entry',
388
+ };
389
+ }
390
+ const session = sessionField(item['agent_session']);
391
+ observations.push({
392
+ name: typeof item['name'] === 'string' ? item['name'] : null,
393
+ harness,
394
+ paneId: typeof item['pane_id'] === 'string' ? item['pane_id'] : null,
395
+ cwd: typeof item['cwd'] === 'string' ? item['cwd'] : null,
396
+ sessionValue: session.value,
397
+ sessionKind: session.kind,
398
+ status: typeof item['agent_status'] === 'string'
399
+ ? item['agent_status']
400
+ : 'unknown',
401
+ focused: item['focused'] === true,
402
+ });
403
+ }
404
+ if (options.taskRoot) {
405
+ for (let index = 0; index < observations.length; index++) {
406
+ const entry = observations[index];
407
+ if (entry.harness !== 'pi' || entry.sessionValue !== null)
408
+ continue;
409
+ const observed = await resolveTaskManagedPi(options.taskRoot, entry, {
410
+ run,
411
+ });
412
+ if (observed)
413
+ observations[index] = {
414
+ ...entry,
415
+ sessionKind: 'id',
416
+ sessionValue: observed.sessionId,
417
+ nativeIdentityProvenance: observed.provenance,
418
+ };
419
+ }
420
+ }
421
+ return { ok: true, agents: observations };
422
+ },
423
+ async prompt(agent, text, registered) {
424
+ if (options.taskRoot && registered) {
425
+ const listed = await this.list();
426
+ if (!listed.ok)
427
+ return {
428
+ status: 'refused',
429
+ detail: 'Cannot revalidate native delivery target.',
430
+ };
431
+ const matches = listed.agents.filter((entry) => entry.name === agent);
432
+ if (matches.length !== 1)
433
+ return {
434
+ status: 'refused',
435
+ detail: 'Native delivery target is ambiguous or absent.',
436
+ };
437
+ const target = matches[0];
438
+ if (target.nativeIdentityProvenance === 'owned-pi-sdk-control') {
439
+ const owned = await resolveTaskManagedPi(options.taskRoot, target, {
440
+ run,
441
+ });
442
+ if (!owned ||
443
+ registered.taskRoot !== options.taskRoot ||
444
+ owned.sessionId !== registered.sessionId)
445
+ return {
446
+ status: 'refused',
447
+ detail: 'Owned native delivery identity changed.',
448
+ };
449
+ try {
450
+ const response = await requestManagedPiControl(owned.descriptorFile, 'deliver', {
451
+ task_root: registered.taskRoot,
452
+ message_id: registered.messageId,
453
+ digest: registered.digest,
454
+ expected_session_id: registered.sessionId,
455
+ });
456
+ return response.ok === true
457
+ ? {
458
+ status: 'submitted',
459
+ detail: 'Registered pointer admitted by the owned native SDK; model completion is unobserved.',
460
+ }
461
+ : {
462
+ status: 'refused',
463
+ detail: 'Owned native controller refused registered delivery.',
464
+ };
465
+ }
466
+ catch {
467
+ return {
468
+ status: 'unknown',
469
+ detail: 'Owned native delivery returned no terminal admission result.',
470
+ };
471
+ }
472
+ }
473
+ }
474
+ const result = await run(bin, ['agent', 'prompt', agent, text], PROMPT_TIMEOUT_MS);
475
+ if (!result.spawned) {
476
+ return {
477
+ status: 'unavailable',
478
+ detail: result.spawnError ?? 'Herdr is not callable',
479
+ };
480
+ }
481
+ const payload = parsedJson(result.stdout);
482
+ const type = nested(nested(payload, 'result'), 'type') ?? nested(payload, 'type');
483
+ const code = agentErrorCode(payload);
484
+ if (code !== null) {
485
+ const mapped = refusalStatus(code);
486
+ return {
487
+ status: mapped.status,
488
+ detail: agentErrorMessage(payload) === null
489
+ ? mapped.detail
490
+ : `${mapped.detail}: ${agentErrorMessage(payload)}`,
491
+ };
492
+ }
493
+ if (result.timedOut || result.signal !== null) {
494
+ return {
495
+ status: 'unknown',
496
+ detail: 'Herdr prompt did not return a terminal result; submission may have happened',
497
+ };
498
+ }
499
+ if (result.code === 0 && type === 'agent_prompted') {
500
+ return { status: 'submitted', detail: null };
501
+ }
502
+ // A spawned process that exits without a recognized envelope cannot prove
503
+ // whether the prompt was submitted. Report unknown, never a definite failure.
504
+ return {
505
+ status: 'unknown',
506
+ detail: `Herdr exited ${String(result.code)} without a recognized prompt result; submission may have happened`,
507
+ };
508
+ },
509
+ };
510
+ }
511
+ /**
512
+ * Derives the native identity Herdr reports. A path is not matched by substring:
513
+ * Pi session files end with `_<session-id>.jsonl`, so only that final segment is
514
+ * the native identity. Other shapes stay unknown unless Herdr reports an id.
515
+ */
516
+ export function reportedSessionIdentity(kind, value) {
517
+ if (value === null || kind === null) {
518
+ return { status: 'unknown', identity: null };
519
+ }
520
+ if (kind === 'path') {
521
+ const base = path.basename(value);
522
+ const stem = base.endsWith('.jsonl')
523
+ ? base.slice(0, -'.jsonl'.length)
524
+ : base;
525
+ const match = /_([^_/\\]+)$/.exec(stem);
526
+ return {
527
+ status: 'match',
528
+ identity: match === null ? stem : match[1],
529
+ };
530
+ }
531
+ if (kind === 'id') {
532
+ return { status: 'match', identity: value };
533
+ }
534
+ return { status: 'unknown', identity: null };
535
+ }
536
+ function nowIso(clock) {
537
+ return new Date(clock ?? Date.now()).toISOString();
538
+ }
539
+ function controlFree(value) {
540
+ return !/[\u0000-\u001f\u007f]/.test(value);
541
+ }
542
+ function failure(code, message, nextAction) {
543
+ return { ok: false, code, message, nextAction };
544
+ }
545
+ function notificationRouteFailure(record, message) {
546
+ for (const [side, identity] of [
547
+ ['sender', message.from],
548
+ ['recipient', message.to],
549
+ ]) {
550
+ const route = routeExclusionFor(record, identity);
551
+ if (route.state === 'active')
552
+ continue;
553
+ if (route.state === 'excluded')
554
+ return failure(`${side}_route_excluded`, `Message ${side} ${identity.agent_id} is an archived old native identity of succession ${route.exclusion === null ? '<unknown>' : route.exclusion.handoff_id}`, 'Notify the current successor identity; retained history is never re-routed.');
555
+ return failure('route_exclusion_unknown', `Message ${side} route cannot be proven for ${identity.agent_id}: ${route.conflict ?? 'contradictory succession'}`, 'Repair the succession record; an unproven identity is never notified.');
556
+ }
557
+ return null;
558
+ }
559
+ async function loadBinding(file, taskRoot, taskId) {
560
+ if (!path.isAbsolute(file)) {
561
+ return failure('binding_file_required', `--binding requires an absolute file: ${file}`, 'Write the binding JSON explicitly and pass its absolute path.');
562
+ }
563
+ const absolute = path.resolve(file);
564
+ let stat;
565
+ try {
566
+ stat = fs.lstatSync(absolute);
567
+ }
568
+ catch (error) {
569
+ return failure('binding_unreadable', `Cannot read binding ${absolute}: ${errorMessage(error)}`, 'Write the binding next to the task; nothing was notified.');
570
+ }
571
+ if (stat.isSymbolicLink() || !stat.isFile()) {
572
+ return failure('binding_unsafe', `Binding must be a regular file, not a symlink: ${absolute}`, 'Write the binding to a regular file; links are not followed.');
573
+ }
574
+ if (stat.size > MAX_BINDING_BYTES) {
575
+ return failure('binding_too_large', `Binding exceeds ${MAX_BINDING_BYTES} bytes: ${absolute}`, 'Keep routing metadata small.');
576
+ }
577
+ let parsed;
578
+ try {
579
+ parsed = JSON.parse(fs.readFileSync(absolute, 'utf8'));
580
+ }
581
+ catch (error) {
582
+ return failure('binding_invalid', `Binding is not valid JSON: ${errorMessage(error)}`, 'Correct the binding; nothing was notified.');
583
+ }
584
+ const validated = collabBindingSchema.safeParse(parsed);
585
+ if (!validated.success) {
586
+ return failure('binding_invalid', `Binding failed validation: ${validated.error.issues[0]?.message ?? 'invalid'}`, 'Use pappra/collab-binding/1; nothing was notified.');
587
+ }
588
+ const binding = validated.data;
589
+ if (binding.task_id !== taskId) {
590
+ return failure('binding_task_mismatch', `Binding task_id ${binding.task_id} does not match record ${taskId}`, 'Bind only the exact task; routing metadata is not authority.');
591
+ }
592
+ const realBindingRoot = await resolveRealpath(binding.task_root);
593
+ if (realBindingRoot === null || realBindingRoot !== taskRoot) {
594
+ return failure('binding_root_mismatch', `Binding task_root does not resolve to ${taskRoot}`, 'Bind the canonical task root; a moved binding must be revalidated.');
595
+ }
596
+ const ids = new Set();
597
+ const names = new Set();
598
+ for (const participant of binding.participants) {
599
+ if (ids.has(participant.agent_id) || names.has(participant.herdr_agent)) {
600
+ return failure('binding_ambiguous', 'Binding repeats an agent id or Herdr agent name', 'One participant per identity; ambiguous routing is refused.');
601
+ }
602
+ ids.add(participant.agent_id);
603
+ names.add(participant.herdr_agent);
604
+ }
605
+ return { ok: true, value: binding };
606
+ }
607
+ function sameCwd(left, right) {
608
+ return path.resolve(left) === path.resolve(right);
609
+ }
610
+ export function buildNotificationText(input) {
611
+ return `COLLAB_MESSAGE task=${input.taskId} type=${input.type} message=${input.messagePath} digest=${input.digest}`;
612
+ }
613
+ function notificationsDirectorySyncOk(taskRoot, directory) {
614
+ try {
615
+ const real = fs.realpathSync(directory);
616
+ return real === directory && isPathInside(taskRoot, real);
617
+ }
618
+ catch {
619
+ return false;
620
+ }
621
+ }
622
+ /**
623
+ * Ensures the delivery-metadata directory is a real directory inside the task.
624
+ * The task namespace is revalidated before anything is created.
625
+ */
626
+ async function ensureNotificationsDirectory(opened) {
627
+ const directory = notificationsDir(opened.taskRoot);
628
+ const observation = await observePath(directory).catch((error) => failure('delivery_state_unwritable', `Cannot inspect ${directory}: ${errorMessage(error)}`, 'Inspect task permissions; nothing was notified.'));
629
+ if ('ok' in observation)
630
+ return observation;
631
+ if (observation.kind !== 'missing') {
632
+ if (observation.kind !== 'directory' ||
633
+ !notificationsDirectorySyncOk(opened.taskRoot, directory)) {
634
+ return failure('delivery_state_unsafe', `Delivery metadata path is not a real directory inside the task: ${directory}`, 'Inspect or remove that path; Pappra does not write delivery state through it.');
635
+ }
636
+ return { ok: true, value: directory };
637
+ }
638
+ const changed = await assertMessageTaskCurrent(opened);
639
+ if (changed !== null)
640
+ return changed;
641
+ try {
642
+ fs.mkdirSync(directory, { mode: 0o700 });
643
+ }
644
+ catch (error) {
645
+ return failure('delivery_state_unwritable', `Cannot create ${directory}: ${errorMessage(error)}`, 'Inspect task permissions; nothing was notified.');
646
+ }
647
+ const created = await observePath(directory).catch((error) => failure('delivery_state_unwritable', `Cannot inspect ${directory}: ${errorMessage(error)}`, 'Inspect task permissions; nothing was notified.'));
648
+ if ('ok' in created)
649
+ return created;
650
+ if (created.kind !== 'directory' ||
651
+ !notificationsDirectorySyncOk(opened.taskRoot, directory)) {
652
+ return failure('delivery_state_unsafe', `Delivery metadata directory is redirected: ${directory}`, 'Restore a real notifications directory inside the task root.');
653
+ }
654
+ return { ok: true, value: directory };
655
+ }
656
+ function writeDelivery(taskRoot, directory, file, delivery) {
657
+ if (!notificationsDirectorySyncOk(taskRoot, directory)) {
658
+ throw new Error(`Delivery metadata directory is unsafe: ${directory}`);
659
+ }
660
+ const temporary = path.join(directory, `${path.basename(file)}.tmp-${process.pid}-${Date.now()}`);
661
+ const handle = fs.openSync(temporary, 'wx', 0o600);
662
+ try {
663
+ fs.writeSync(handle, `${JSON.stringify(delivery, null, 2)}\n`);
664
+ fs.fsyncSync(handle);
665
+ }
666
+ finally {
667
+ fs.closeSync(handle);
668
+ }
669
+ fs.renameSync(temporary, file);
670
+ }
671
+ function lockPathFor(deliveryFile) {
672
+ return `${deliveryFile}.lock`;
673
+ }
674
+ function acquireDeliveryLock(lockPath) {
675
+ try {
676
+ fs.mkdirSync(lockPath, { mode: 0o700 });
677
+ return 'acquired';
678
+ }
679
+ catch (error) {
680
+ const code = typeof error === 'object' && error !== null && 'code' in error
681
+ ? error.code
682
+ : null;
683
+ return code === 'EEXIST' ? 'busy' : 'failed';
684
+ }
685
+ }
686
+ function releaseDeliveryLock(lockPath) {
687
+ try {
688
+ fs.rmdirSync(lockPath);
689
+ }
690
+ catch {
691
+ // A residual lock stays visible evidence; it is never reclaimed by age.
692
+ }
693
+ }
694
+ function deliveryDocument(input) {
695
+ return {
696
+ schema: 'pappra/collab-delivery/1',
697
+ message_id: input.message.message_id,
698
+ message_path: input.messagePath,
699
+ message_digest: input.messageDigest,
700
+ task_id: input.message.task_id,
701
+ task_root: input.taskRoot,
702
+ recipient: input.recipient,
703
+ target: {
704
+ herdr_agent: input.target.herdr_agent,
705
+ pane_id: input.target.pane_id,
706
+ native_session: input.target.native_session,
707
+ ...(input.target.native_session_kind === null
708
+ ? {}
709
+ : { native_session_kind: input.target.native_session_kind }),
710
+ },
711
+ status: input.status,
712
+ attempts: [...input.attempts],
713
+ caller: {
714
+ source: input.caller.source,
715
+ pane_id: input.caller.paneId,
716
+ harness: input.caller.harness,
717
+ cwd: input.caller.cwd,
718
+ observed_at: input.caller.observedAt,
719
+ native_session: input.caller.nativeSession,
720
+ native_session_kind: input.caller.nativeSessionKind,
721
+ process_affiliation: input.caller.processAffiliation,
722
+ },
723
+ created_at: input.createdAt,
724
+ updated_at: nowIso(input.clock),
725
+ };
726
+ }
727
+ function resultOf(base, fields) {
728
+ return {
729
+ status: fields.status,
730
+ messageId: base.message.message_id,
731
+ messagePath: base.messagePath,
732
+ messageDigest: base.digest,
733
+ taskId: base.taskId,
734
+ recipient: base.recipient,
735
+ target: fields.target,
736
+ caller: fields.caller,
737
+ transport: fields.transport,
738
+ deliveryPath: fields.deliveryPath,
739
+ attempts: fields.attempts,
740
+ priorStatus: fields.priorStatus,
741
+ detail: fields.detail,
742
+ consumed: false,
743
+ taskAccepted: false,
744
+ };
745
+ }
746
+ /**
747
+ * Verifies one bound participant against live Herdr observations. Sender and
748
+ * recipient use distinct codes because the two sides carry different obligations.
749
+ */
750
+ function verifyLiveParticipant(role, participant, expectedSessionId, agents) {
751
+ const codes = role === 'sender'
752
+ ? {
753
+ notFound: 'sender_target_not_found',
754
+ ambiguous: 'sender_target_ambiguous',
755
+ pane: 'sender_pane_mismatch',
756
+ harness: 'sender_harness_mismatch',
757
+ cwd: 'sender_cwd_mismatch',
758
+ session: 'sender_session_mismatch',
759
+ }
760
+ : {
761
+ notFound: 'target_not_found',
762
+ ambiguous: 'target_ambiguous',
763
+ pane: 'target_pane_mismatch',
764
+ harness: 'target_harness_mismatch',
765
+ cwd: 'target_cwd_mismatch',
766
+ session: 'target_session_mismatch',
767
+ };
768
+ const candidates = agents.filter((item) => item.name === participant.herdr_agent);
769
+ if (candidates.length === 0) {
770
+ return failure(`${codes.notFound}`, `Herdr has no agent named ${participant.herdr_agent}`, 'Inspect herdr agent list; notification never targets a focused or default pane.');
771
+ }
772
+ if (candidates.length > 1) {
773
+ return failure(`${codes.ambiguous}`, `Herdr reports more than one agent named ${participant.herdr_agent}`, 'Resolve the duplicate name explicitly; ambiguous routing is refused.');
774
+ }
775
+ const live = candidates[0];
776
+ if (live.paneId !== participant.pane_id) {
777
+ return failure(`${codes.pane}`, `Herdr reports pane ${String(live.paneId)} for ${participant.herdr_agent}, binding says ${participant.pane_id}`, 'Revalidate the binding; a stale pane is never used.');
778
+ }
779
+ if (live.harness !== participant.harness) {
780
+ return failure(`${codes.harness}`, `Herdr reports ${live.harness} for ${participant.herdr_agent}, binding says ${participant.harness}`, 'Correct the binding; harness identity must match.');
781
+ }
782
+ if (live.cwd === null || !sameCwd(live.cwd, participant.cwd)) {
783
+ return failure(`${codes.cwd}`, `Herdr reports cwd ${String(live.cwd)} for ${participant.herdr_agent}, binding says ${participant.cwd}`, 'Correct the binding; cwd identity must match before notification.');
784
+ }
785
+ // An explicit message session is always compared when live evidence exists,
786
+ // even if the binding leaves native_session_id null.
787
+ const reported = reportedSessionIdentity(live.sessionKind, live.sessionValue);
788
+ if (reported.status === 'match') {
789
+ if (reported.identity !== expectedSessionId) {
790
+ return failure(`${codes.session}`, `Herdr reports native session ${String(reported.identity)} for ${participant.herdr_agent}, message targets ${expectedSessionId}`, 'Revalidate the binding and message session; mismatched evidence is never notified.');
791
+ }
792
+ return { ok: true, value: { live, session: 'match' } };
793
+ }
794
+ return { ok: true, value: { live, session: 'unknown' } };
795
+ }
796
+ /**
797
+ * Verifies the caller and recipient against live Herdr metadata and, with apply,
798
+ * sends exactly one pointer-only prompt. Repeating a notification reports the
799
+ * recorded outcome; an in-flight or crashed reservation is never overwritten.
800
+ */
801
+ export async function notifyRegisteredMessage(options) {
802
+ const opened = await openMessageTask(options.taskRoot);
803
+ if (!opened.ok)
804
+ return opened;
805
+ const { taskRoot, record } = opened.value;
806
+ if (!isSafeMessageId(options.messageId)) {
807
+ return failure('message_id_invalid', `Unsafe message id: ${options.messageId}`, 'Use a registered message id from collab message list.');
808
+ }
809
+ const registered = await readRegisteredMessageSafe(opened.value, options.messageId);
810
+ if (!registered.ok)
811
+ return registered;
812
+ const message = registered.value.message;
813
+ const messagePath = registered.value.file;
814
+ const digest = registered.value.digest;
815
+ if (!controlFree(messagePath) || !controlFree(digest)) {
816
+ return failure('message_pointer_unsafe', 'Message path or digest contains control characters', 'Rename the task root; pointers are sent as one plain line.');
817
+ }
818
+ if (message.task_id !== record.task_id) {
819
+ return failure('message_task_mismatch', 'Registered message does not belong to this task record', 'Inspect the task record and message; nothing was notified.');
820
+ }
821
+ if (typeof message.to.agent_id !== 'string' ||
822
+ typeof message.to.harness !== 'string') {
823
+ return failure('message_recipient_missing', 'Registered message has no explicit recipient', 'Persist a message with an explicit to identity.');
824
+ }
825
+ const binding = await loadBinding(options.bindingFile, taskRoot, record.task_id);
826
+ if (!binding.ok)
827
+ return binding;
828
+ const sender = binding.value.participants.find((item) => item.agent_id === message.from.agent_id);
829
+ if (sender === undefined) {
830
+ return failure('binding_sender_missing', `Binding has no participant for sender ${message.from.agent_id}`, 'Bind both the sender and recipient; routing metadata must describe this message.');
831
+ }
832
+ if (sender.harness !== message.from.harness) {
833
+ return failure('binding_sender_mismatch', `Binding harness ${sender.harness} does not match message sender ${String(message.from.harness)}`, 'Correct the binding; sender harness must match the message.');
834
+ }
835
+ if (sender.native_session_id !== null &&
836
+ sender.native_session_id !== message.from.session_id) {
837
+ return failure('binding_sender_session_mismatch', 'Binding native_session_id does not match the message sender session', 'Bind the exact sender session; sessions are never inferred from names.');
838
+ }
839
+ const participant = binding.value.participants.find((item) => item.agent_id === message.to.agent_id);
840
+ if (participant === undefined) {
841
+ return failure('binding_recipient_missing', `Binding has no participant for ${message.to.agent_id}`, 'Add the exact protocol recipient to the binding; nothing was notified.');
842
+ }
843
+ if (participant.harness !== message.to.harness) {
844
+ return failure('binding_harness_mismatch', `Binding harness ${participant.harness} does not match message recipient ${message.to.harness}`, 'Correct the binding; routing metadata must describe this recipient exactly.');
845
+ }
846
+ if (participant.native_session_id !== null &&
847
+ participant.native_session_id !== message.to.session_id) {
848
+ return failure('binding_session_mismatch', 'Binding native_session_id does not match the message recipient session', 'Bind the exact protocol session; sessions are never inferred from names.');
849
+ }
850
+ // An archived old native identity is history, never a current route. The succession
851
+ // check is pure current-record evaluation: it happens before any caller or live Herdr
852
+ // query and before any send, and it never rewrites the message or delivery record.
853
+ const initialRouteFailure = notificationRouteFailure(record, message);
854
+ if (initialRouteFailure !== null)
855
+ return initialRouteFailure;
856
+ // Record files legitimately change inode on any atomic task commit. Recheck
857
+ // the selected message and participants, then evaluate routes from a fresh
858
+ // record, rather than treating an unrelated commit as namespace replacement.
859
+ const assertNotificationCurrent = async () => {
860
+ const changed = await assertMessageTaskCurrent(opened.value);
861
+ if (changed !== null)
862
+ return changed;
863
+ const currentBinding = await loadBinding(options.bindingFile, taskRoot, record.task_id);
864
+ if (!currentBinding.ok)
865
+ return currentBinding;
866
+ for (const expected of [sender, participant]) {
867
+ const current = currentBinding.value.participants.find((item) => item.agent_id === expected.agent_id);
868
+ if (JSON.stringify(current) !== JSON.stringify(expected)) {
869
+ return failure('binding_participant_changed', `Notification binding changed for ${expected.agent_id}`, 'Revalidate the selected sender and recipient bindings; nothing was notified.');
870
+ }
871
+ }
872
+ const reread = await readRegisteredMessageSafe(opened.value, message.message_id);
873
+ if (!reread.ok)
874
+ return reread;
875
+ if (reread.value.digest !== digest) {
876
+ return failure('message_registration_changed', 'Registered message changed while preparing the notification', 'Inspect the message file; nothing was notified.');
877
+ }
878
+ const current = await openMessageTask(taskRoot);
879
+ if (!current.ok)
880
+ return current;
881
+ return notificationRouteFailure(current.value.record, message);
882
+ };
883
+ const base = {
884
+ message,
885
+ messagePath,
886
+ digest,
887
+ taskId: record.task_id,
888
+ recipient: {
889
+ agent_id: message.to.agent_id,
890
+ harness: message.to.harness,
891
+ },
892
+ };
893
+ const plannedTarget = {
894
+ herdr_agent: participant.herdr_agent,
895
+ pane_id: participant.pane_id,
896
+ harness: participant.harness,
897
+ cwd: participant.cwd,
898
+ native_session: 'unknown',
899
+ native_session_kind: null,
900
+ };
901
+ if (!options.apply) {
902
+ const existing = readDeliveryRecord(taskRoot, message.message_id);
903
+ return {
904
+ ok: true,
905
+ value: resultOf(base, {
906
+ status: 'planned',
907
+ target: plannedTarget,
908
+ caller: null,
909
+ transport: 'none',
910
+ deliveryPath: null,
911
+ attempts: existing?.attempts.length ?? 0,
912
+ priorStatus: existing?.status ?? null,
913
+ detail: 'Plan only: task, message, sender and recipient bindings validated; caller and live Herdr metadata were not queried.',
914
+ }),
915
+ };
916
+ }
917
+ const transport = options.transport ?? createHerdrTransport({ taskRoot });
918
+ const verifiedCaller = await transport.verifyCaller(options.callerPane === undefined || options.callerPane === null
919
+ ? {}
920
+ : { paneId: options.callerPane });
921
+ if (!verifiedCaller.ok) {
922
+ const status = verifiedCaller.status === 'unavailable'
923
+ ? 'herdr_unavailable'
924
+ : 'herdr_caller_unverified';
925
+ return {
926
+ ok: true,
927
+ value: resultOf(base, {
928
+ status,
929
+ target: null,
930
+ caller: null,
931
+ transport: 'none',
932
+ deliveryPath: null,
933
+ attempts: 0,
934
+ priorStatus: null,
935
+ detail: `${verifiedCaller.code}: ${verifiedCaller.detail}`,
936
+ }),
937
+ };
938
+ }
939
+ const caller = verifiedCaller.caller;
940
+ const agents = await transport.list();
941
+ if (!agents.ok) {
942
+ return {
943
+ ok: true,
944
+ value: resultOf(base, {
945
+ status: agents.status === 'unavailable' ? 'herdr_unavailable' : 'failed',
946
+ target: null,
947
+ caller: null,
948
+ transport: 'herdr',
949
+ deliveryPath: null,
950
+ attempts: 0,
951
+ priorStatus: null,
952
+ detail: agents.detail,
953
+ }),
954
+ };
955
+ }
956
+ // Async seams end here: revalidate the task namespace and the registered bytes
957
+ // before any mutation or native submission.
958
+ const changed = await assertNotificationCurrent();
959
+ if (changed !== null)
960
+ return changed;
961
+ // The selected routing candidate must be the sender's bound pane. This is a
962
+ // task-bound routing check, not process authentication.
963
+ if (caller.paneId !== sender.pane_id) {
964
+ return failure('caller_pane_mismatch', `Selected caller pane ${caller.paneId} does not match the sender binding pane ${sender.pane_id}`, 'Pass the sender pane explicitly with --caller-pane, or correct the binding; nothing was notified.');
965
+ }
966
+ const senderCheck = verifyLiveParticipant('sender', sender, message.from.session_id, agents.agents);
967
+ if (!senderCheck.ok)
968
+ return senderCheck;
969
+ const recipientCheck = verifyLiveParticipant('recipient', participant, message.to.session_id, agents.agents);
970
+ if (!recipientCheck.ok)
971
+ return recipientCheck;
972
+ const callerEvidence = {
973
+ source: caller.source,
974
+ paneId: caller.paneId,
975
+ harness: caller.harness,
976
+ cwd: caller.cwd,
977
+ observedAt: nowIso(options.clock),
978
+ nativeSession: senderCheck.value.session,
979
+ nativeSessionKind: senderCheck.value.live.sessionKind,
980
+ processAffiliation: 'not-established',
981
+ };
982
+ const target = {
983
+ ...plannedTarget,
984
+ native_session: recipientCheck.value.session,
985
+ native_session_kind: recipientCheck.value.live.sessionKind,
986
+ };
987
+ const directory = await ensureNotificationsDirectory(opened.value);
988
+ if (!directory.ok)
989
+ return directory;
990
+ const deliveryFile = deliveryPathFor(taskRoot, message.message_id);
991
+ const lockPath = lockPathFor(deliveryFile);
992
+ const existing = readDeliveryRecord(taskRoot, message.message_id);
993
+ if (fs.existsSync(deliveryFile) && existing === null) {
994
+ return failure('delivery_state_invalid', `Delivery metadata exists but is not a valid pappra/collab-delivery/1 document: ${deliveryFile}`, 'Inspect or remove that file explicitly; Pappra does not overwrite unreadable delivery evidence.');
995
+ }
996
+ const lock = acquireDeliveryLock(lockPath);
997
+ if (lock === 'busy') {
998
+ return {
999
+ ok: true,
1000
+ value: resultOf(base, {
1001
+ status: 'in_flight_unknown',
1002
+ target,
1003
+ caller: callerEvidence,
1004
+ transport: 'herdr',
1005
+ deliveryPath: deliveryFile,
1006
+ attempts: existing?.attempts.length ?? 0,
1007
+ priorStatus: existing?.status ?? null,
1008
+ detail: 'Another notification for this message is in progress; no new wakeup was sent.',
1009
+ }),
1010
+ };
1011
+ }
1012
+ if (lock === 'failed') {
1013
+ return failure('delivery_state_unwritable', `Cannot reserve delivery metadata: ${lockPath}`, 'Inspect task permissions; nothing was notified.');
1014
+ }
1015
+ let reserved;
1016
+ try {
1017
+ const current = readDeliveryRecord(taskRoot, message.message_id);
1018
+ if (current !== null && current.status === 'submitting') {
1019
+ return {
1020
+ ok: true,
1021
+ value: resultOf(base, {
1022
+ status: 'in_flight_unknown',
1023
+ target,
1024
+ caller: callerEvidence,
1025
+ transport: 'herdr',
1026
+ deliveryPath: deliveryFile,
1027
+ attempts: current.attempts.length,
1028
+ priorStatus: current.status,
1029
+ detail: 'A previous notification is still reserved or its outcome is unknown; it is never resent or reclaimed by age.',
1030
+ }),
1031
+ };
1032
+ }
1033
+ if (current !== null && !options.retry) {
1034
+ return {
1035
+ ok: true,
1036
+ value: resultOf(base, {
1037
+ status: 'already_recorded',
1038
+ target,
1039
+ caller: callerEvidence,
1040
+ transport: 'herdr',
1041
+ deliveryPath: deliveryFile,
1042
+ attempts: current.attempts.length,
1043
+ priorStatus: current.status,
1044
+ detail: 'A notification outcome is already recorded for this message; no new wakeup was sent.',
1045
+ }),
1046
+ };
1047
+ }
1048
+ const attempts = current?.attempts ?? [];
1049
+ if (attempts.length >= MAX_ATTEMPTS) {
1050
+ return failure('retry_limit', `Notification attempts reached the limit of ${MAX_ATTEMPTS}`, 'Resolve the transport problem explicitly; automatic resend is never performed.');
1051
+ }
1052
+ const createdAt = current?.created_at ?? nowIso(options.clock);
1053
+ const reservationId = randomUUID();
1054
+ reserved = deliveryDocument({
1055
+ message,
1056
+ messagePath,
1057
+ messageDigest: digest,
1058
+ taskRoot,
1059
+ recipient: base.recipient,
1060
+ target,
1061
+ caller: callerEvidence,
1062
+ status: 'submitting',
1063
+ attempts: [
1064
+ ...attempts,
1065
+ {
1066
+ at: nowIso(options.clock),
1067
+ status: 'submitting',
1068
+ detail: null,
1069
+ reservation_id: reservationId,
1070
+ },
1071
+ ],
1072
+ createdAt,
1073
+ clock: options.clock,
1074
+ });
1075
+ writeDelivery(taskRoot, directory.value, deliveryFile, reserved);
1076
+ reservations.set(deliveryFile, reservationId);
1077
+ }
1078
+ catch (error) {
1079
+ return failure('delivery_state_unwritable', `Cannot record delivery reservation: ${errorMessage(error)}`, 'Inspect task permissions; nothing was notified.');
1080
+ }
1081
+ finally {
1082
+ releaseDeliveryLock(lockPath);
1083
+ }
1084
+ const afterReservation = await assertNotificationCurrent();
1085
+ if (afterReservation !== null) {
1086
+ finalizeDelivery({
1087
+ taskRoot,
1088
+ directory: directory.value,
1089
+ deliveryFile,
1090
+ status: 'failed',
1091
+ detail: `Notification changed before submission: ${afterReservation.code}`,
1092
+ clock: options.clock,
1093
+ });
1094
+ return afterReservation;
1095
+ }
1096
+ const text = buildNotificationText({
1097
+ taskId: record.task_id,
1098
+ type: message.type,
1099
+ messagePath,
1100
+ digest,
1101
+ });
1102
+ let result;
1103
+ try {
1104
+ result = await transport.prompt(participant.herdr_agent, text, {
1105
+ taskRoot,
1106
+ messageId: message.message_id,
1107
+ digest,
1108
+ sessionId: message.to.session_id,
1109
+ });
1110
+ }
1111
+ catch (error) {
1112
+ result = {
1113
+ status: 'unknown',
1114
+ detail: `Transport failed after the reservation: ${errorMessage(error)}`,
1115
+ };
1116
+ }
1117
+ // A spawn failure after the reservation means nothing was submitted; record it as
1118
+ // `failed`, never as a delivered notification. Ambiguous outcomes stay `unknown`.
1119
+ const status = result.status === 'unavailable' ? 'failed' : result.status;
1120
+ const finalized = finalizeDelivery({
1121
+ taskRoot,
1122
+ directory: directory.value,
1123
+ deliveryFile,
1124
+ status,
1125
+ detail: result.status === 'unavailable'
1126
+ ? `Herdr was not callable; nothing was submitted${result.detail === null ? '' : `: ${result.detail}`}`
1127
+ : result.detail,
1128
+ clock: options.clock,
1129
+ });
1130
+ if (finalized === null) {
1131
+ return {
1132
+ ok: true,
1133
+ value: resultOf(base, {
1134
+ status: 'unknown',
1135
+ target,
1136
+ caller: callerEvidence,
1137
+ transport: 'herdr',
1138
+ deliveryPath: deliveryFile,
1139
+ attempts: reserved.attempts.length,
1140
+ priorStatus: 'submitting',
1141
+ detail: 'The prompt outcome could not be recorded against the reservation; inspect delivery evidence before retrying.',
1142
+ }),
1143
+ };
1144
+ }
1145
+ return {
1146
+ ok: true,
1147
+ value: resultOf(base, {
1148
+ status,
1149
+ target,
1150
+ caller: callerEvidence,
1151
+ transport: 'herdr',
1152
+ deliveryPath: deliveryFile,
1153
+ attempts: finalized.attempts.length,
1154
+ priorStatus: existing?.status ?? null,
1155
+ detail: result.status === 'unavailable'
1156
+ ? `Herdr was not callable; nothing was submitted${result.detail === null ? '' : `: ${result.detail}`}`
1157
+ : result.detail,
1158
+ }),
1159
+ };
1160
+ }
1161
+ /** Reservation ids are process-local routing for the final update. */
1162
+ const reservations = new Map();
1163
+ /**
1164
+ * Updates only this invocation's reserved attempt. A changed reservation, a lost
1165
+ * attempt or an unreadable record is never overwritten; the caller reports an
1166
+ * unknown outcome instead.
1167
+ */
1168
+ function finalizeDelivery(input) {
1169
+ const reservationId = reservations.get(input.deliveryFile);
1170
+ if (reservationId === undefined)
1171
+ return null;
1172
+ const lockPath = lockPathFor(input.deliveryFile);
1173
+ const lock = acquireDeliveryLock(lockPath);
1174
+ if (lock !== 'acquired')
1175
+ return null;
1176
+ try {
1177
+ const current = readDeliveryRecord(input.taskRoot, path.basename(input.deliveryFile, '.json'));
1178
+ if (current === null)
1179
+ return null;
1180
+ const index = current.attempts.findIndex((attempt) => attempt.reservation_id === reservationId);
1181
+ if (index < 0)
1182
+ return null;
1183
+ const attempts = current.attempts.map((attempt, position) => position === index
1184
+ ? {
1185
+ ...attempt,
1186
+ status: input.status,
1187
+ detail: input.detail,
1188
+ }
1189
+ : attempt);
1190
+ const next = {
1191
+ ...current,
1192
+ status: input.status,
1193
+ attempts,
1194
+ updated_at: nowIso(input.clock),
1195
+ };
1196
+ try {
1197
+ writeDelivery(input.taskRoot, input.directory, input.deliveryFile, next);
1198
+ }
1199
+ catch {
1200
+ return null;
1201
+ }
1202
+ reservations.delete(input.deliveryFile);
1203
+ return next;
1204
+ }
1205
+ finally {
1206
+ releaseDeliveryLock(lockPath);
1207
+ }
1208
+ }