@cohortapp/agent-sdk 2.3.0

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 (731) hide show
  1. package/.claude/commands/init-agent.md +104 -0
  2. package/.claude/commands/init-maestro.md +1187 -0
  3. package/.claude/settings.json +161 -0
  4. package/.env.example +216 -0
  5. package/README.md +632 -0
  6. package/agents/browser-operator/agent.md +52 -0
  7. package/agents/calendar-ops/agent.md +50 -0
  8. package/agents/communications/agent.md +96 -0
  9. package/agents/decision-log/agent.md +65 -0
  10. package/agents/desktop-operator/agent.md +59 -0
  11. package/agents/gmail-operator/agent.md +62 -0
  12. package/agents/inbound-dispatcher/agent.md +66 -0
  13. package/agents/inbox-processor/agent.md +39 -0
  14. package/agents/pmo-execution/agent.md +60 -0
  15. package/agents/session-spawner/agent.md +64 -0
  16. package/agents/slack-operator/agent.md +60 -0
  17. package/agents/whatsapp-operator/agent.md +60 -0
  18. package/agents/workflow-automation/agent.md +61 -0
  19. package/archetypes/altitudes/c-suite.yaml +58 -0
  20. package/archetypes/altitudes/founder.yaml +68 -0
  21. package/archetypes/altitudes/senior-manager.yaml +63 -0
  22. package/archetypes/altitudes/svp.yaml +60 -0
  23. package/archetypes/altitudes/vp.yaml +50 -0
  24. package/archetypes/archetype.schema.json +77 -0
  25. package/archetypes/base.yaml +47 -0
  26. package/archetypes/capabilities/commercial-leader.yaml +159 -0
  27. package/archetypes/capabilities/compliance-officer.yaml +159 -0
  28. package/archetypes/capabilities/executive-operator.yaml +169 -0
  29. package/archetypes/capabilities/finance-leader.yaml +162 -0
  30. package/archetypes/capabilities/operations-leader.yaml +154 -0
  31. package/archetypes/capabilities/product-leader.yaml +148 -0
  32. package/archetypes/capabilities/technical-leader.yaml +146 -0
  33. package/archetypes/functions/commercial-leader.yaml +62 -0
  34. package/archetypes/functions/compliance-officer.yaml +64 -0
  35. package/archetypes/functions/executive-operator.yaml +70 -0
  36. package/archetypes/functions/finance-leader.yaml +70 -0
  37. package/archetypes/functions/operations-leader.yaml +62 -0
  38. package/archetypes/functions/product-leader.yaml +61 -0
  39. package/archetypes/functions/technical-leader.yaml +57 -0
  40. package/bin/cohort-mcp.mjs +81 -0
  41. package/bin/maestro.mjs +3516 -0
  42. package/bin/maestro.test.mjs +1015 -0
  43. package/desktop-control/README.md +56 -0
  44. package/desktop-control/app-profiles/gmail.yaml +120 -0
  45. package/desktop-control/app-profiles/slack.yaml +315 -0
  46. package/desktop-control/app-profiles/whatsapp.yaml +107 -0
  47. package/docs/architecture/agent-topology.md +2239 -0
  48. package/docs/architecture/archetype-agent-factory.md +110 -0
  49. package/docs/architecture/collective-memory-and-org-mesh.md +115 -0
  50. package/docs/architecture/continuous-monitoring.md +221 -0
  51. package/docs/architecture/mcp-capability-map.md +585 -0
  52. package/docs/architecture/system-architecture.md +1272 -0
  53. package/docs/company-context/README.md +40 -0
  54. package/docs/guides/agent-persona-setup.md +600 -0
  55. package/docs/guides/agents-observe-setup.md +64 -0
  56. package/docs/guides/billing-console-keys.md +88 -0
  57. package/docs/guides/ccxray-diagnostics.md +65 -0
  58. package/docs/guides/channel-bus.md +127 -0
  59. package/docs/guides/claude-mem-setup.md +79 -0
  60. package/docs/guides/claude-pace-setup.md +56 -0
  61. package/docs/guides/claudraband-sessions.md +98 -0
  62. package/docs/guides/clawteam-swarm.md +116 -0
  63. package/docs/guides/code-review-graph-setup.md +86 -0
  64. package/docs/guides/email-setup.md +431 -0
  65. package/docs/guides/mac-mini.md +119 -0
  66. package/docs/guides/media-generation-setup.md +349 -0
  67. package/docs/guides/model-routing.md +162 -0
  68. package/docs/guides/observability-otel.md +265 -0
  69. package/docs/guides/org-onboarding.md +132 -0
  70. package/docs/guides/outbound-governance-setup.md +437 -0
  71. package/docs/guides/pdf-generation-setup.md +315 -0
  72. package/docs/guides/poller-daemon-setup.md +563 -0
  73. package/docs/guides/rag-context-setup.md +459 -0
  74. package/docs/guides/self-optimization-pattern.md +82 -0
  75. package/docs/guides/setup-wizard.md +178 -0
  76. package/docs/guides/slack-setup.md +350 -0
  77. package/docs/guides/telegram-setup.md +227 -0
  78. package/docs/guides/twilio-subaccounts-setup.md +223 -0
  79. package/docs/guides/verification.md +128 -0
  80. package/docs/guides/voice-mode.md +188 -0
  81. package/docs/guides/voice-sms-setup.md +698 -0
  82. package/docs/guides/webhook-relay-setup.md +349 -0
  83. package/docs/guides/whatsapp-setup.md +288 -0
  84. package/docs/prompts/board-pack-cover-template.md +36 -0
  85. package/docs/prompts/decision-recommendation-template.md +88 -0
  86. package/docs/prompts/followup-message-template.md +141 -0
  87. package/docs/prompts/investor-letter-template.md +52 -0
  88. package/docs/prompts/morning-brief-template.md +82 -0
  89. package/docs/prompts/presentation-template.md +58 -0
  90. package/docs/prompts/weekly-strategic-memo-template.md +104 -0
  91. package/docs/research/hallucinated-tool-output-investigation.md +151 -0
  92. package/docs/runbooks/backup-restore.md +205 -0
  93. package/docs/runbooks/cohort-cutover.md +129 -0
  94. package/docs/runbooks/fleet-operations.md +200 -0
  95. package/docs/runbooks/incident-response.md +226 -0
  96. package/docs/runbooks/mac-mini-bootstrap.md +431 -0
  97. package/docs/runbooks/perpetual-operations.md +509 -0
  98. package/docs/runbooks/recovery-and-failover.md +260 -0
  99. package/framework-features.json +267 -0
  100. package/ingest/README.md +87 -0
  101. package/lib/action-executor.js +689 -0
  102. package/lib/action-executor.test.mjs +871 -0
  103. package/lib/agent-root.mjs +37 -0
  104. package/lib/archetype.mjs +236 -0
  105. package/lib/archetype.test.mjs +132 -0
  106. package/lib/autonomy.mjs +114 -0
  107. package/lib/autonomy.test.mjs +66 -0
  108. package/lib/backlog.mjs +358 -0
  109. package/lib/backlog.test.mjs +266 -0
  110. package/lib/budget-guard.mjs +279 -0
  111. package/lib/budget-guard.test.mjs +291 -0
  112. package/lib/cadence-bus-schedule.test.mjs +194 -0
  113. package/lib/cadence-bus.mjs +1120 -0
  114. package/lib/cadence-bus.test.mjs +720 -0
  115. package/lib/cadences.mjs +205 -0
  116. package/lib/cadences.test.mjs +125 -0
  117. package/lib/capability.mjs +154 -0
  118. package/lib/capability.test.mjs +78 -0
  119. package/lib/channels/base-adapter.mjs +719 -0
  120. package/lib/channels/base-adapter.test.mjs +590 -0
  121. package/lib/channels/channel.mjs +128 -0
  122. package/lib/channels/channels.test.mjs +371 -0
  123. package/lib/channels/contract.mjs +215 -0
  124. package/lib/channels/contract.test.mjs +137 -0
  125. package/lib/channels/conversation-resolver.mjs +95 -0
  126. package/lib/channels/gmail/adapter.mjs +87 -0
  127. package/lib/channels/inbox-item.mjs +255 -0
  128. package/lib/channels/inbox-item.test.mjs +335 -0
  129. package/lib/channels/index.mjs +94 -0
  130. package/lib/channels/orgmail/adapter.mjs +353 -0
  131. package/lib/channels/orgmail/adapter.test.mjs +311 -0
  132. package/lib/channels/pairing.mjs +363 -0
  133. package/lib/channels/pairing.test.mjs +270 -0
  134. package/lib/channels/registry.mjs +164 -0
  135. package/lib/channels/slack/adapter.mjs +317 -0
  136. package/lib/channels/slack-adapter.test.mjs +212 -0
  137. package/lib/channels/sms/adapter.mjs +43 -0
  138. package/lib/channels/telegram/adapter.mjs +432 -0
  139. package/lib/channels/telegram-adapter.test.mjs +306 -0
  140. package/lib/channels/voice/adapter.mjs +301 -0
  141. package/lib/channels/voice/adapter.test.mjs +278 -0
  142. package/lib/channels/whatsapp/adapter-baileys.mjs +587 -0
  143. package/lib/channels/whatsapp/adapter-baileys.test.mjs +359 -0
  144. package/lib/channels/whatsapp/adapter-twilio.mjs +65 -0
  145. package/lib/channels/whatsapp/baileys-typing.test.mjs +154 -0
  146. package/lib/charter.mjs +256 -0
  147. package/lib/charter.test.mjs +89 -0
  148. package/lib/claude-bin.mjs +134 -0
  149. package/lib/claude-bin.test.mjs +75 -0
  150. package/lib/collective/capture.mjs +185 -0
  151. package/lib/collective/capture.test.mjs +121 -0
  152. package/lib/collective/cards.mjs +201 -0
  153. package/lib/collective/cards.test.mjs +114 -0
  154. package/lib/collective/config.mjs +186 -0
  155. package/lib/collective/config.test.mjs +123 -0
  156. package/lib/collective/global-config.mjs +113 -0
  157. package/lib/collective/global-config.test.mjs +75 -0
  158. package/lib/collective/presence.mjs +201 -0
  159. package/lib/collective/presence.test.mjs +95 -0
  160. package/lib/collective/recall.mjs +215 -0
  161. package/lib/collective/recall.test.mjs +116 -0
  162. package/lib/comms/send-gate.mjs +554 -0
  163. package/lib/comms/send-gate.test.mjs +577 -0
  164. package/lib/comms.mjs +67 -0
  165. package/lib/comms.test.mjs +41 -0
  166. package/lib/diagnostics/alerts.mjs +424 -0
  167. package/lib/diagnostics/alerts.test.mjs +318 -0
  168. package/lib/diagnostics/backup-freshness.mjs +188 -0
  169. package/lib/diagnostics/backup-freshness.test.mjs +185 -0
  170. package/lib/diagnostics/counters.mjs +269 -0
  171. package/lib/diagnostics/counters.test.mjs +206 -0
  172. package/lib/diagnostics/events.mjs +188 -0
  173. package/lib/diagnostics/events.test.mjs +290 -0
  174. package/lib/diagnostics/otel.mjs +237 -0
  175. package/lib/diagnostics/otel.test.mjs +196 -0
  176. package/lib/diagnostics/trace.mjs +216 -0
  177. package/lib/diagnostics/trace.test.mjs +251 -0
  178. package/lib/env-compat.mjs +74 -0
  179. package/lib/env-compat.test.mjs +104 -0
  180. package/lib/feature-init.mjs +331 -0
  181. package/lib/fs-atomic.mjs +112 -0
  182. package/lib/fs-atomic.test.mjs +72 -0
  183. package/lib/fs-ownership.mjs +111 -0
  184. package/lib/fs-ownership.test.mjs +158 -0
  185. package/lib/hooks/bus.mjs +347 -0
  186. package/lib/hooks/bus.test.mjs +387 -0
  187. package/lib/index.js +16 -0
  188. package/lib/learning/config.mjs +106 -0
  189. package/lib/learning/config.test.mjs +75 -0
  190. package/lib/learning/counters.mjs +156 -0
  191. package/lib/learning/counters.test.mjs +69 -0
  192. package/lib/learning/curator-consolidate.test.mjs +238 -0
  193. package/lib/learning/curator.mjs +453 -0
  194. package/lib/learning/curator.test.mjs +106 -0
  195. package/lib/learning/log.mjs +40 -0
  196. package/lib/learning/reflect.mjs +534 -0
  197. package/lib/learning/reflect.test.mjs +0 -0
  198. package/lib/learning/session-index.mjs +352 -0
  199. package/lib/learning/session-index.test.mjs +125 -0
  200. package/lib/learning/skill-writer.mjs +474 -0
  201. package/lib/learning/skill-writer.test.mjs +210 -0
  202. package/lib/mcp/server.mjs +328 -0
  203. package/lib/mcp/server.test.mjs +400 -0
  204. package/lib/model-router/auth-profiles.mjs +758 -0
  205. package/lib/model-router/auth-profiles.test.mjs +580 -0
  206. package/lib/model-router/catalog/anthropic.yaml +153 -0
  207. package/lib/model-router/catalog/deepseek.yaml +86 -0
  208. package/lib/model-router/catalog/moonshot.yaml +81 -0
  209. package/lib/model-router/catalog/qwen.yaml +114 -0
  210. package/lib/model-router/catalog.mjs +925 -0
  211. package/lib/model-router/catalog.test.mjs +385 -0
  212. package/lib/model-router/economics.mjs +564 -0
  213. package/lib/model-router/economics.test.mjs +344 -0
  214. package/lib/model-router/failover.mjs +298 -0
  215. package/lib/model-router/failover.test.mjs +439 -0
  216. package/lib/model-router/health.mjs +453 -0
  217. package/lib/model-router/health.test.mjs +338 -0
  218. package/lib/model-router/integration-coverage.test.mjs +829 -0
  219. package/lib/model-router/integration.test.mjs +564 -0
  220. package/lib/model-router/ledger.mjs +402 -0
  221. package/lib/model-router/ledger.test.mjs +382 -0
  222. package/lib/model-router/llm-task.mjs +515 -0
  223. package/lib/model-router/llm-task.test.mjs +392 -0
  224. package/lib/model-router/org-credentials.mjs +260 -0
  225. package/lib/model-router/org-credentials.test.mjs +265 -0
  226. package/lib/model-router/pricing-refresh.mjs +463 -0
  227. package/lib/model-router/pricing-refresh.test.mjs +286 -0
  228. package/lib/model-router/reconcile.mjs +429 -0
  229. package/lib/model-router/reconcile.test.mjs +316 -0
  230. package/lib/model-router/repair.mjs +471 -0
  231. package/lib/model-router/repair.test.mjs +180 -0
  232. package/lib/model-router/resolve.mjs +1206 -0
  233. package/lib/model-router/spawn.mjs +497 -0
  234. package/lib/model-router/spawn.test.mjs +425 -0
  235. package/lib/model-router/taxonomy.mjs +893 -0
  236. package/lib/model-router/taxonomy.test.mjs +410 -0
  237. package/lib/model-router.mjs +677 -0
  238. package/lib/model-router.test.mjs +907 -0
  239. package/lib/org/activity.mjs +211 -0
  240. package/lib/org/activity.test.mjs +134 -0
  241. package/lib/org/approvals.mjs +448 -0
  242. package/lib/org/approvals.test.mjs +216 -0
  243. package/lib/org/awareness.mjs +222 -0
  244. package/lib/org/awareness.test.mjs +159 -0
  245. package/lib/org/board.mjs +229 -0
  246. package/lib/org/board.test.mjs +177 -0
  247. package/lib/org/bootstrap-context.mjs +169 -0
  248. package/lib/org/bootstrap-context.test.mjs +153 -0
  249. package/lib/org/client.mjs +1628 -0
  250. package/lib/org/client.test.mjs +1107 -0
  251. package/lib/org/cohort-client.mjs +67 -0
  252. package/lib/org/cohort-client.test.mjs +126 -0
  253. package/lib/org/cost-sync.mjs +227 -0
  254. package/lib/org/cost-sync.test.mjs +153 -0
  255. package/lib/org/doctor.mjs +212 -0
  256. package/lib/org/doctor.test.mjs +212 -0
  257. package/lib/org/handoff.mjs +293 -0
  258. package/lib/org/handoff.test.mjs +269 -0
  259. package/lib/org/integration-tools.mjs +182 -0
  260. package/lib/org/integration-tools.test.mjs +160 -0
  261. package/lib/org/keys.mjs +131 -0
  262. package/lib/org/keys.test.mjs +92 -0
  263. package/lib/org/knowledge.mjs +463 -0
  264. package/lib/org/knowledge.test.mjs +319 -0
  265. package/lib/org/leases.mjs +335 -0
  266. package/lib/org/leases.test.mjs +235 -0
  267. package/lib/org/mesh-integration.test.mjs +127 -0
  268. package/lib/org/mesh.mjs +459 -0
  269. package/lib/org/mesh.test.mjs +345 -0
  270. package/lib/org/messaging.mjs +503 -0
  271. package/lib/org/messaging.test.mjs +238 -0
  272. package/lib/org/policy.mjs +345 -0
  273. package/lib/org/policy.test.mjs +237 -0
  274. package/lib/org/protocol.checksum +1 -0
  275. package/lib/org/protocol.checksum.test.mjs +90 -0
  276. package/lib/org/protocol.mjs +967 -0
  277. package/lib/org/protocol.test.mjs +264 -0
  278. package/lib/org/registry.mjs +194 -0
  279. package/lib/org/registry.test.mjs +100 -0
  280. package/lib/org/tool-surface-integration.test.mjs +120 -0
  281. package/lib/org/tool-surface.mjs +2535 -0
  282. package/lib/org/tool-surface.test.mjs +589 -0
  283. package/lib/org/ui-parity.mjs +3236 -0
  284. package/lib/org/ui-parity.test.mjs +348 -0
  285. package/lib/org/verify.mjs +176 -0
  286. package/lib/org/verify.test.mjs +194 -0
  287. package/lib/rag/embed.mjs +188 -0
  288. package/lib/rag/indexer.mjs +425 -0
  289. package/lib/rag/rag.test.mjs +505 -0
  290. package/lib/rag/search.mjs +475 -0
  291. package/lib/rate-guard.mjs +246 -0
  292. package/lib/rate-guard.test.mjs +201 -0
  293. package/lib/render.mjs +112 -0
  294. package/lib/render.test.mjs +68 -0
  295. package/lib/resource-governor.mjs +297 -0
  296. package/lib/resource-governor.test.mjs +262 -0
  297. package/lib/scheduling/dynamic-jobs.mjs +675 -0
  298. package/lib/scheduling/dynamic-jobs.test.mjs +344 -0
  299. package/lib/scheduling/jitter.mjs +0 -0
  300. package/lib/scheduling/jitter.test.mjs +140 -0
  301. package/lib/secrets/broker.mjs +315 -0
  302. package/lib/secrets/broker.test.mjs +280 -0
  303. package/lib/secrets/providers.mjs +461 -0
  304. package/lib/secrets/providers.test.mjs +274 -0
  305. package/lib/security/audit-engine.mjs +684 -0
  306. package/lib/security/audit-engine.test.mjs +389 -0
  307. package/lib/security/coerce-args.mjs +552 -0
  308. package/lib/security/coerce-args.test.mjs +281 -0
  309. package/lib/security/dangerous-tools.mjs +97 -0
  310. package/lib/security/dangerous-tools.test.mjs +68 -0
  311. package/lib/security/external-content.mjs +145 -0
  312. package/lib/security/external-content.test.mjs +67 -0
  313. package/lib/security/redact.mjs +592 -0
  314. package/lib/security/redact.test.mjs +441 -0
  315. package/lib/security/secret-equal.mjs +73 -0
  316. package/lib/security/secret-equal.test.mjs +55 -0
  317. package/lib/session-permissions.mjs +101 -0
  318. package/lib/session-permissions.test.mjs +100 -0
  319. package/lib/setup/claude-probe.mjs +74 -0
  320. package/lib/setup/completeness.mjs +175 -0
  321. package/lib/setup/completeness.test.mjs +110 -0
  322. package/lib/setup/context-pack.mjs +173 -0
  323. package/lib/setup/context-pack.test.mjs +89 -0
  324. package/lib/setup/enrich.mjs +277 -0
  325. package/lib/setup/enrich.test.mjs +115 -0
  326. package/lib/setup/enroll-from-cohort.mjs +441 -0
  327. package/lib/setup/enroll-from-cohort.test.mjs +233 -0
  328. package/lib/setup/integration.test.mjs +162 -0
  329. package/lib/setup/io.mjs +360 -0
  330. package/lib/setup/io.test.mjs +77 -0
  331. package/lib/setup/run-generator.mjs +81 -0
  332. package/lib/setup/runner.mjs +244 -0
  333. package/lib/setup/runner.test.mjs +132 -0
  334. package/lib/setup/sections/comms.mjs +173 -0
  335. package/lib/setup/sections/company.mjs +120 -0
  336. package/lib/setup/sections/enrich.mjs +138 -0
  337. package/lib/setup/sections/identity.mjs +182 -0
  338. package/lib/setup/sections/identity.test.mjs +140 -0
  339. package/lib/setup/sections/learning.mjs +153 -0
  340. package/lib/setup/sections/learning.test.mjs +81 -0
  341. package/lib/setup/sections/messaging.mjs +219 -0
  342. package/lib/setup/sections/messaging.test.mjs +127 -0
  343. package/lib/setup/sections/model.mjs +102 -0
  344. package/lib/setup/sections/operating-model.mjs +78 -0
  345. package/lib/setup/sections/org.mjs +475 -0
  346. package/lib/setup/sections/org.test.mjs +313 -0
  347. package/lib/setup/sections/orgmail.mjs +173 -0
  348. package/lib/setup/sections/orgmail.test.mjs +118 -0
  349. package/lib/setup/sections/recovery.mjs +159 -0
  350. package/lib/setup/sections/recovery.test.mjs +98 -0
  351. package/lib/setup/sections/tools.mjs +132 -0
  352. package/lib/setup/sections/verify.mjs +97 -0
  353. package/lib/setup/sot.mjs +205 -0
  354. package/lib/setup/sot.test.mjs +81 -0
  355. package/lib/setup/state.mjs +151 -0
  356. package/lib/setup/state.test.mjs +92 -0
  357. package/lib/singleton.js +229 -0
  358. package/lib/singleton.test.mjs +135 -0
  359. package/lib/telemetry/alerts.mjs +216 -0
  360. package/lib/telemetry/alerts.test.mjs +109 -0
  361. package/lib/telemetry/collect.mjs +512 -0
  362. package/lib/telemetry/collect.test.mjs +202 -0
  363. package/lib/tool-definitions-integration.test.mjs +83 -0
  364. package/lib/tool-definitions.js +738 -0
  365. package/lib/tool-definitions.test.mjs +437 -0
  366. package/lib/util/fetch-timeout.mjs +136 -0
  367. package/lib/util/fetch-timeout.test.mjs +202 -0
  368. package/lib/util/reconnect.mjs +343 -0
  369. package/lib/util/reconnect.test.mjs +369 -0
  370. package/lib/util/unhandled.mjs +205 -0
  371. package/lib/util/unhandled.test.mjs +216 -0
  372. package/lib/voice/context-loader.mjs +466 -0
  373. package/lib/voice/index.mjs +100 -0
  374. package/lib/voice/openai-realtime.mjs +510 -0
  375. package/lib/voice/outbound.mjs +542 -0
  376. package/lib/voice/outbound.test.mjs +69 -0
  377. package/lib/voice/post-call-brief.mjs +428 -0
  378. package/lib/voice/provider.mjs +52 -0
  379. package/lib/voice/session-rotation.mjs +257 -0
  380. package/lib/voice/stt.mjs +161 -0
  381. package/lib/voice/stt.test.mjs +226 -0
  382. package/lib/voice/tool-bridge.mjs +370 -0
  383. package/lib/voice/tts.mjs +104 -0
  384. package/lib/voice/twilio-sip-bridge.mjs +288 -0
  385. package/lib/voice/voice.test.mjs +990 -0
  386. package/mcp/README.md +80 -0
  387. package/package.json +151 -0
  388. package/plugins/maestro-skills/plugin.json +139 -0
  389. package/plugins/maestro-skills/skills/agents-observe.md +110 -0
  390. package/plugins/maestro-skills/skills/board-deck.md +68 -0
  391. package/plugins/maestro-skills/skills/books-close.md +77 -0
  392. package/plugins/maestro-skills/skills/brand-steward.md +121 -0
  393. package/plugins/maestro-skills/skills/calendar-plan.md +57 -0
  394. package/plugins/maestro-skills/skills/call-working-sessions.md +124 -0
  395. package/plugins/maestro-skills/skills/ccxray-diagnostics.md +91 -0
  396. package/plugins/maestro-skills/skills/claude-pace.md +61 -0
  397. package/plugins/maestro-skills/skills/code-review-graph.md +99 -0
  398. package/plugins/maestro-skills/skills/crm-pipeline.md +65 -0
  399. package/plugins/maestro-skills/skills/decision-brief.md +89 -0
  400. package/plugins/maestro-skills/skills/directory-hygiene.md +125 -0
  401. package/plugins/maestro-skills/skills/draft-comms.md +84 -0
  402. package/plugins/maestro-skills/skills/evening-wrap.md +53 -0
  403. package/plugins/maestro-skills/skills/files-find.md +65 -0
  404. package/plugins/maestro-skills/skills/generative-ui.md +228 -0
  405. package/plugins/maestro-skills/skills/hiring-triage.md +74 -0
  406. package/plugins/maestro-skills/skills/inbox-triage.md +61 -0
  407. package/plugins/maestro-skills/skills/mail-triage.md +86 -0
  408. package/plugins/maestro-skills/skills/morning-brief.md +54 -0
  409. package/plugins/maestro-skills/skills/native-artifacts.md +157 -0
  410. package/plugins/maestro-skills/skills/org-board.md +133 -0
  411. package/plugins/maestro-skills/skills/org-credential.md +68 -0
  412. package/plugins/maestro-skills/skills/org-recall.md +81 -0
  413. package/plugins/maestro-skills/skills/pipeline-review.md +76 -0
  414. package/plugins/maestro-skills/skills/regulatory-status.md +81 -0
  415. package/plugins/maestro-skills/skills/router-why.md +78 -0
  416. package/plugins/maestro-skills/skills/schedule-meeting.md +91 -0
  417. package/plugins/maestro-skills/skills/session-search.md +71 -0
  418. package/plugins/maestro-skills/skills/set-reminder.md +93 -0
  419. package/plugins/maestro-skills/skills/slack-followup.md +64 -0
  420. package/plugins/maestro-skills/skills/team-activity.md +86 -0
  421. package/plugins/maestro-skills/skills/weekly-memo.md +70 -0
  422. package/policies/action-classification.yaml +114 -0
  423. package/policies/ai-disclosure.yaml +294 -0
  424. package/policies/communication-style.md +139 -0
  425. package/policies/information-barriers.yaml +118 -0
  426. package/policies/prompt-injection-defence.yaml +138 -0
  427. package/public/assets/icon-dark.png +0 -0
  428. package/public/assets/icon-dark.svg +9 -0
  429. package/public/assets/icon-light.svg +9 -0
  430. package/public/assets/logo-dark.svg +15 -0
  431. package/public/assets/logo-light.svg +15 -0
  432. package/scaffold/.mcp.json +7 -0
  433. package/scaffold/CLAUDE.md +368 -0
  434. package/scaffold/config/agent.json +55 -0
  435. package/scaffold/config/agent.ts +76 -0
  436. package/scaffold/config/agent.ts.example +89 -0
  437. package/scaffold/config/alerts.yaml +23 -0
  438. package/scaffold/config/allowlist.yaml.example +25 -0
  439. package/scaffold/config/caller-id-map.yaml +46 -0
  440. package/scaffold/config/collective.yaml +49 -0
  441. package/scaffold/config/company.json +20 -0
  442. package/scaffold/config/known-agents.json +6 -0
  443. package/scaffold/config/learning.yaml +55 -0
  444. package/scaffold/config/model-routing.yaml.example +104 -0
  445. package/scaffold/config/org.yaml +25 -0
  446. package/scaffold/config/orgmail.yaml.example +19 -0
  447. package/scaffold/config/recovery.yaml +72 -0
  448. package/scaffold/config/secrets.yaml +27 -0
  449. package/scaffold/config/slack.yaml.example +35 -0
  450. package/scaffold/config/telegram.yaml.example +38 -0
  451. package/scaffold/config/voice.yaml.example +89 -0
  452. package/scaffold/config/whatsapp.yaml.example +39 -0
  453. package/schedules/README.md +49 -0
  454. package/schedules/triggers/backlog-executor.md +102 -0
  455. package/schedules/triggers/brand-steward.md +72 -0
  456. package/schedules/triggers/daily-evening-wrap.md +159 -0
  457. package/schedules/triggers/daily-midday-sweep.md +58 -0
  458. package/schedules/triggers/daily-morning-brief.md +55 -0
  459. package/schedules/triggers/directory-hygiene.md +81 -0
  460. package/schedules/triggers/dynamic-jobs.md +40 -0
  461. package/schedules/triggers/inbox-processor.md +115 -0
  462. package/schedules/triggers/meeting-action-capture.md +60 -0
  463. package/schedules/triggers/meeting-prep.md +69 -0
  464. package/schedules/triggers/messaging-inbound.md +50 -0
  465. package/schedules/triggers/org-pulse.md +24 -0
  466. package/schedules/triggers/quarterly-self-assessment.md +54 -0
  467. package/schedules/triggers/weekly-engineering-health.md +37 -0
  468. package/schedules/triggers/weekly-execution.md +65 -0
  469. package/schedules/triggers/weekly-hiring.md +53 -0
  470. package/schedules/triggers/weekly-priorities.md +38 -0
  471. package/schedules/triggers/weekly-strategic-memo.md +124 -0
  472. package/scripts/archive-email.sh +55 -0
  473. package/scripts/cadence/cadence-status.mjs +36 -0
  474. package/scripts/cadence/enqueue-cadence-tick.mjs +174 -0
  475. package/scripts/cadence/enqueue-cadence-tick.test.mjs +187 -0
  476. package/scripts/cadence/launchd-cadence-wrapper.sh +85 -0
  477. package/scripts/cadence/launchd-cloud-relay-wrapper.sh +95 -0
  478. package/scripts/cadence/launchd-socket-mode-wrapper.sh +95 -0
  479. package/scripts/ci/check-docs-accuracy.mjs +493 -0
  480. package/scripts/ci/check-docs-accuracy.test.mjs +409 -0
  481. package/scripts/ci/check-exports-exist.mjs +140 -0
  482. package/scripts/ci/check-files-exist.mjs +107 -0
  483. package/scripts/ci/check-no-build-artifacts.mjs +111 -0
  484. package/scripts/ci/check-no-build-artifacts.test.mjs +71 -0
  485. package/scripts/ci/check-no-confidential.mjs +198 -0
  486. package/scripts/ci/check-no-conflict-markers.mjs +169 -0
  487. package/scripts/ci/check-no-residual-identity.mjs +163 -0
  488. package/scripts/ci/check-no-residual-identity.test.mjs +89 -0
  489. package/scripts/ci/check-tarball-fidelity.mjs +205 -0
  490. package/scripts/ci/check-unresolved-tokens.mjs +83 -0
  491. package/scripts/ci/check.mjs +109 -0
  492. package/scripts/ci/check.test.mjs +194 -0
  493. package/scripts/ci/run-coverage.mjs +82 -0
  494. package/scripts/ci/run-tests.mjs +71 -0
  495. package/scripts/cloud-relay/README.md +59 -0
  496. package/scripts/cloud-relay/index.mjs +233 -0
  497. package/scripts/cloud-relay/package.json +15 -0
  498. package/scripts/cloud-relay/railway.json +13 -0
  499. package/scripts/cloud-relay/voice/README.md +94 -0
  500. package/scripts/cloud-relay/voice/package-lock.json +39 -0
  501. package/scripts/cloud-relay/voice/package.json +16 -0
  502. package/scripts/cloud-relay/voice/railway.json +13 -0
  503. package/scripts/cloud-relay/voice/server.mjs +532 -0
  504. package/scripts/collective/hook-runner.mjs +211 -0
  505. package/scripts/collective/hook-runner.test.mjs +90 -0
  506. package/scripts/collective/org-pulse.mjs +72 -0
  507. package/scripts/collective/org-sync.mjs +61 -0
  508. package/scripts/collective/recall.mjs +45 -0
  509. package/scripts/collective/who.mjs +30 -0
  510. package/scripts/comms-monitor.sh +288 -0
  511. package/scripts/configure-whatsapp-sandbox.sh +201 -0
  512. package/scripts/continuous-monitor.sh +91 -0
  513. package/scripts/cost/fleet-digest.mjs +407 -0
  514. package/scripts/cost/fleet-digest.test.mjs +207 -0
  515. package/scripts/cost/track-claude-usage.mjs +169 -0
  516. package/scripts/daemon/agent-daemon.mjs +989 -0
  517. package/scripts/daemon/agent-daemon.test.mjs +525 -0
  518. package/scripts/daemon/cadence-consumer-governance.test.mjs +220 -0
  519. package/scripts/daemon/cadence-consumer.mjs +1080 -0
  520. package/scripts/daemon/cadence-consumer.test.mjs +770 -0
  521. package/scripts/daemon/cadence-handlers.mjs +1121 -0
  522. package/scripts/daemon/cadence-handlers.test.mjs +617 -0
  523. package/scripts/daemon/classifier.mjs +704 -0
  524. package/scripts/daemon/classifier.test.mjs +238 -0
  525. package/scripts/daemon/classify-kind.mjs +54 -0
  526. package/scripts/daemon/classify-kind.test.mjs +40 -0
  527. package/scripts/daemon/context-compiler.mjs +605 -0
  528. package/scripts/daemon/context-compiler.test.mjs +300 -0
  529. package/scripts/daemon/dispatcher-cooldown.test.mjs +122 -0
  530. package/scripts/daemon/dispatcher-governance.test.mjs +886 -0
  531. package/scripts/daemon/dispatcher.mjs +1516 -0
  532. package/scripts/daemon/health.mjs +72 -0
  533. package/scripts/daemon/inbox-deferral.mjs +210 -0
  534. package/scripts/daemon/inbox-deferral.test.mjs +242 -0
  535. package/scripts/daemon/integration.test.mjs +149 -0
  536. package/scripts/daemon/launchd-wrapper-generic.sh +96 -0
  537. package/scripts/daemon/launchd-wrapper-slack-events.sh +37 -0
  538. package/scripts/daemon/launchd-wrapper.sh +91 -0
  539. package/scripts/daemon/lib/session-router.mjs +274 -0
  540. package/scripts/daemon/lib/session-router.test.mjs +295 -0
  541. package/scripts/daemon/maestro-daemon.mjs +275 -0
  542. package/scripts/daemon/prompt-builder.mjs +685 -0
  543. package/scripts/daemon/prompt-builder.test.mjs +213 -0
  544. package/scripts/daemon/responder.mjs +854 -0
  545. package/scripts/daemon/session-lock.mjs +721 -0
  546. package/scripts/daemon/session-lock.test.mjs +252 -0
  547. package/scripts/daemon/session-outcomes.mjs +640 -0
  548. package/scripts/daemon/session-outcomes.test.mjs +533 -0
  549. package/scripts/daemon/typing-registry.mjs +90 -0
  550. package/scripts/daemon/typing-registry.test.mjs +77 -0
  551. package/scripts/daemon/voice-webhook-server.mjs +804 -0
  552. package/scripts/decisions/capture-decision.mjs +116 -0
  553. package/scripts/disclosure_assessment.py +873 -0
  554. package/scripts/disclosure_boundaries.py +562 -0
  555. package/scripts/email-signature-principal.html +52 -0
  556. package/scripts/email-signature.html +60 -0
  557. package/scripts/email_quote_thread.py +167 -0
  558. package/scripts/email_thread_dedup.py +362 -0
  559. package/scripts/emergency-stop.sh +81 -0
  560. package/scripts/healthcheck.sh +116 -0
  561. package/scripts/hooks/block-mcp-cohort-send.sh +15 -0
  562. package/scripts/hooks/block-mcp-slack-send.sh +7 -0
  563. package/scripts/hooks/post-action-log.sh +126 -0
  564. package/scripts/hooks/pre-send-audit.sh +174 -0
  565. package/scripts/hooks/pre-send-audit.test.mjs +215 -0
  566. package/scripts/hooks/session-end-log.sh +27 -0
  567. package/scripts/hooks/session-start-banner.sh +115 -0
  568. package/scripts/huddle/audio-bridge.mjs +664 -0
  569. package/scripts/huddle/boot-slack-cdp.sh +102 -0
  570. package/scripts/huddle/huddle-controller.mjs +942 -0
  571. package/scripts/huddle/huddle-server.mjs +1229 -0
  572. package/scripts/huddle/launch-slack.sh +232 -0
  573. package/scripts/huddle/openai-realtime-bridge.mjs +462 -0
  574. package/scripts/huddle/package-lock.json +62 -0
  575. package/scripts/huddle/package.json +22 -0
  576. package/scripts/huddle/setup-audio.sh +239 -0
  577. package/scripts/huddle/start-call.mjs +318 -0
  578. package/scripts/huddle/test-pipeline.mjs +263 -0
  579. package/scripts/learning/consolidate-skills.mjs +72 -0
  580. package/scripts/learning/session-search.mjs +125 -0
  581. package/scripts/llm_email_dedup.py +442 -0
  582. package/scripts/local-triggers/generate-plists.sh +432 -0
  583. package/scripts/local-triggers/generate-plists.test.mjs +413 -0
  584. package/scripts/local-triggers/install-all.sh +49 -0
  585. package/scripts/local-triggers/plists/.gitkeep +0 -0
  586. package/scripts/local-triggers/run-trigger.sh +63 -0
  587. package/scripts/local-triggers/templates/rag-reindex.plist.template +47 -0
  588. package/scripts/local-triggers/templates/voice-relay-poller.plist.template +54 -0
  589. package/scripts/local-triggers/templates/voice-tunnel.plist.template +55 -0
  590. package/scripts/local-triggers/templates/voice-webhook.plist.template +51 -0
  591. package/scripts/maintenance/backup-to-cloud.sh +124 -0
  592. package/scripts/maintenance/health-check.sh +377 -0
  593. package/scripts/media-generation/README.md +105 -0
  594. package/scripts/media-generation/gemini-image-client.mjs +173 -0
  595. package/scripts/media-generation/generate-assets.mjs +289 -0
  596. package/scripts/media-generation/veo-video-client.mjs +219 -0
  597. package/scripts/org/send-orgmail.mjs +227 -0
  598. package/scripts/outbound-dedup-cleanup.sh +43 -0
  599. package/scripts/outbound-dedup.sh +477 -0
  600. package/scripts/outbound_dedup.py +115 -0
  601. package/scripts/parse-voice-transcript.mjs +481 -0
  602. package/scripts/pdf-generation/README.md +63 -0
  603. package/scripts/pdf-generation/build-document.mjs +247 -0
  604. package/scripts/pdf-generation/templates/board-pack.latex +136 -0
  605. package/scripts/pdf-generation/templates/corporate-letter.latex +126 -0
  606. package/scripts/pdf-generation/templates/memo.latex +114 -0
  607. package/scripts/poll-slack-events.sh +35 -0
  608. package/scripts/poller/calendar-poller.mjs +12 -0
  609. package/scripts/poller/gmail-poller.mjs +192 -0
  610. package/scripts/poller/imap-client.mjs +289 -0
  611. package/scripts/poller/inbox-scan-poller.mjs +156 -0
  612. package/scripts/poller/inbox-scan-poller.test.mjs +231 -0
  613. package/scripts/poller/index.mjs +73 -0
  614. package/scripts/poller/intra-session-check.mjs +285 -0
  615. package/scripts/poller/lib/cloud-relay-dedup.mjs +88 -0
  616. package/scripts/poller/lib/cloud-relay-dedup.test.mjs +133 -0
  617. package/scripts/poller/lib/slash-command-handlers.mjs +177 -0
  618. package/scripts/poller/secondary-gmail-poller.mjs +132 -0
  619. package/scripts/poller/slack-cloud-relay-client.mjs +368 -0
  620. package/scripts/poller/slack-poller.mjs +854 -0
  621. package/scripts/poller/slack-socket-mode.mjs +917 -0
  622. package/scripts/poller/slack-socket-mode.test.mjs +753 -0
  623. package/scripts/poller/trigger.mjs +75 -0
  624. package/scripts/poller/utils.mjs +371 -0
  625. package/scripts/poller/voice-cloud-relay-client.mjs +179 -0
  626. package/scripts/poller/voice-poller.mjs +236 -0
  627. package/scripts/poller-launchd/install.sh +66 -0
  628. package/scripts/poller-launchd/poller.plist.template +40 -0
  629. package/scripts/poller-launchd/whatsapp-handler.plist.template +39 -0
  630. package/scripts/post-interaction-indexer.py +1598 -0
  631. package/scripts/pre-draft-context.py +994 -0
  632. package/scripts/pre_draft_lookup.py +258 -0
  633. package/scripts/rag/build-index.mjs +47 -0
  634. package/scripts/rag/ingest.mjs +111 -0
  635. package/scripts/rag/search.mjs +119 -0
  636. package/scripts/rag-indexer.py +629 -0
  637. package/scripts/restore-from-backup.sh +248 -0
  638. package/scripts/restore-from-backup.test.mjs +178 -0
  639. package/scripts/resume-operations.sh +80 -0
  640. package/scripts/search-secondary-inbox.py +181 -0
  641. package/scripts/secondary-inbox-poller.py +437 -0
  642. package/scripts/self-optimization/compute-metrics.py +398 -0
  643. package/scripts/send-email-as-principal.py +369 -0
  644. package/scripts/send-email-threaded.py +392 -0
  645. package/scripts/send-email-with-attachment.py +377 -0
  646. package/scripts/send-email.sh +131 -0
  647. package/scripts/send-sms.sh +175 -0
  648. package/scripts/send-whatsapp.sh +292 -0
  649. package/scripts/session-start.sh +106 -0
  650. package/scripts/setup/boot-claude-session.sh +94 -0
  651. package/scripts/setup/configure-macos.sh +674 -0
  652. package/scripts/setup/configure-twilio-sip-trunk.mjs +207 -0
  653. package/scripts/setup/configure-voice-tunnel.mjs +182 -0
  654. package/scripts/setup/generate-agent-env.mjs +92 -0
  655. package/scripts/setup/generate-agent-package-json.mjs +222 -0
  656. package/scripts/setup/generate-agent-package-json.test.mjs +143 -0
  657. package/scripts/setup/generate-autonomy.mjs +60 -0
  658. package/scripts/setup/generate-backlog.mjs +101 -0
  659. package/scripts/setup/generate-cadences.mjs +92 -0
  660. package/scripts/setup/generate-capability.mjs +162 -0
  661. package/scripts/setup/generate-charter.mjs +76 -0
  662. package/scripts/setup/generate-comms.mjs +59 -0
  663. package/scripts/setup/generate-company.mjs +92 -0
  664. package/scripts/setup/init-agent.sh +547 -0
  665. package/scripts/setup/init-agent.test.mjs +151 -0
  666. package/scripts/setup/init-archetype.mjs +74 -0
  667. package/scripts/setup/init-backup.mjs +54 -0
  668. package/scripts/setup/init-cadence-bus.mjs +60 -0
  669. package/scripts/setup/init-channel-bus.mjs +46 -0
  670. package/scripts/setup/init-cost-tracking.mjs +45 -0
  671. package/scripts/setup/init-decision-capture.mjs +66 -0
  672. package/scripts/setup/init-known-agents.mjs +57 -0
  673. package/scripts/setup/init-learning.mjs +70 -0
  674. package/scripts/setup/init-memory-executive.mjs +45 -0
  675. package/scripts/setup/init-model-router.mjs +124 -0
  676. package/scripts/setup/init-rag.mjs +174 -0
  677. package/scripts/setup/init-session-router.mjs +38 -0
  678. package/scripts/setup/init-slack-socket-mode.mjs +260 -0
  679. package/scripts/setup/init-telegram.mjs +165 -0
  680. package/scripts/setup/init-voice-realtime.mjs +204 -0
  681. package/scripts/setup/init-whatsapp-baileys.mjs +77 -0
  682. package/scripts/setup/install-dev-tools.sh +150 -0
  683. package/scripts/setup/lib/install-plist.mjs +95 -0
  684. package/scripts/setup/migrate-agent-to-sot.mjs +192 -0
  685. package/scripts/setup/render-environment-yaml.mjs +133 -0
  686. package/scripts/slack-events-ctl.sh +177 -0
  687. package/scripts/slack-events-server.mjs +1045 -0
  688. package/scripts/slack-react.mjs +89 -0
  689. package/scripts/slack-responded.sh +232 -0
  690. package/scripts/slack-send.sh +287 -0
  691. package/scripts/slack-typing.mjs +196 -0
  692. package/scripts/slack-upload-v2.py +95 -0
  693. package/scripts/sms-handler.mjs +450 -0
  694. package/scripts/spawn-session.sh +120 -0
  695. package/scripts/sync-protocol.mjs +217 -0
  696. package/scripts/system-verify.sh +184 -0
  697. package/scripts/test-email-thread-dedup.py +239 -0
  698. package/scripts/test-information-barriers.py +484 -0
  699. package/scripts/test-llm-email-dedup.py +251 -0
  700. package/scripts/test-pre-draft-integration.py +203 -0
  701. package/scripts/test-rag-phase2.sh +442 -0
  702. package/scripts/test-rag-search.sh +251 -0
  703. package/scripts/test-voice-parser.mjs +316 -0
  704. package/scripts/user-context-search.py +659 -0
  705. package/scripts/validate_outbound.py +1504 -0
  706. package/scripts/watchdog/ai.maestro.memory-watchdog.plist +41 -0
  707. package/scripts/watchdog/force-reboot.sh +157 -0
  708. package/scripts/watchdog/memory-watchdog.sh +473 -0
  709. package/scripts/whatsapp-handler.mjs +538 -0
  710. package/teams/desktop-operations.yaml +34 -0
  711. package/teams/executive-office.yaml +27 -0
  712. package/teams/legal-and-regulatory.yaml +24 -0
  713. package/teams/platform-and-engineering.yaml +23 -0
  714. package/teams/strategy-and-growth.yaml +29 -0
  715. package/workflows/continuous/backlog-executor.yaml +141 -0
  716. package/workflows/continuous/inbound-monitor.yaml +168 -0
  717. package/workflows/daily/applicant-triage.yaml +197 -0
  718. package/workflows/daily/comms-triage.yaml +80 -0
  719. package/workflows/daily/evening-wrap.yaml +105 -0
  720. package/workflows/daily/morning-brief.yaml +164 -0
  721. package/workflows/daily/slack-followup-sweep.yaml +87 -0
  722. package/workflows/event-driven/README.md +50 -0
  723. package/workflows/event-driven/agent-failure-investigation.yaml +137 -0
  724. package/workflows/event-driven/pr-review.yaml +107 -0
  725. package/workflows/monthly/board-readiness.yaml +76 -0
  726. package/workflows/quarterly/strategic-scenario-analysis.yaml +85 -0
  727. package/workflows/session-protocol.md +171 -0
  728. package/workflows/weekly/engineering-health.yaml +154 -0
  729. package/workflows/weekly/hiring-review.yaml +169 -0
  730. package/workflows/weekly/rollup-pipeline-review.yaml +76 -0
  731. package/workflows/weekly/strategic-memo.yaml +79 -0
@@ -0,0 +1,721 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * session-lock.mjs — File-based session locks and sent-message registry
4
+ *
5
+ * Replaces the in-memory recentlyProcessed Map in the agent daemon with
6
+ * persistent, atomic file locks that survive daemon restarts and prevent
7
+ * race conditions between concurrent polls.
8
+ *
9
+ * Also provides a sent-message registry to prevent duplicate sends even
10
+ * when locking fails (belt + suspenders).
11
+ */
12
+
13
+ import {
14
+ writeFileSync,
15
+ readFileSync,
16
+ unlinkSync,
17
+ readdirSync,
18
+ appendFileSync,
19
+ mkdirSync,
20
+ existsSync,
21
+ } from "fs";
22
+ import { join } from "path";
23
+
24
+ // Allow test override of state directory
25
+ const AGENT_REPO_DIR = process.env.AGENT_DIR || join(new URL(".", import.meta.url).pathname, "../..");
26
+ const SESSION_DIR = process.env.__TEST_SESSION_DIR || join(AGENT_REPO_DIR, "state", "sessions");
27
+ const LOCKS_DIR = join(SESSION_DIR, "locks");
28
+ const REGISTRY_PATH = join(SESSION_DIR, "sent-registry.jsonl");
29
+
30
+ const STALE_LOCK_MS = 2 * 60 * 60 * 1000; // 2 hours — must exceed longest session timeout (Opus backlog = 12h, but 2h covers all inbox sessions and prevents re-dispatch of the same message across daemon restarts)
31
+ const THREAD_LOCK_MS = 60 * 60 * 1000; // 60 min — stale fallback; normally released explicitly by dispatcher on session close
32
+ const DM_CHANNEL_LOCK_MS = 60 * 60 * 1000; // 60 min — must match THREAD_LOCK_MS; 10 min was too short (Opus sessions take up to 45 min, and the lock expired mid-session causing triple-dispatch of CEO messages)
33
+ const SENT_DEDUP_WINDOW_MS = 30 * 60 * 1000; // 30 min (was 10 min — still too short; CEO brief incident showed re-sends at 8-min intervals bypassing the window)
34
+ const SENT_SUBSTANTIVE_WINDOW_MS = 2 * 60 * 1000; // 2 min for same-person substantive
35
+ const HOLDING_DM_DEDUP_WINDOW_MS = 5 * 60 * 1000; // 5 min — max 1 holding note per DM channel per 5-min window
36
+ const MAX_REGISTRY_ENTRIES = 500; // Keep registry file manageable
37
+
38
+ // Ensure directories exist
39
+ mkdirSync(LOCKS_DIR, { recursive: true });
40
+
41
+ /**
42
+ * Sanitise an item ID for use as a filename.
43
+ * Replaces all non-alphanumeric characters with hyphens.
44
+ */
45
+ export function sanitiseItemId(itemId) {
46
+ return itemId.replace(/[^a-zA-Z0-9]/g, "-");
47
+ }
48
+
49
+ /**
50
+ * Attempt to acquire a lock for an item.
51
+ * Uses exclusive file creation (wx flag) for atomicity.
52
+ *
53
+ * @param {string} itemId - Raw item ID (will be sanitised)
54
+ * @param {object} metadata - { sender, channel, sessionId? }
55
+ * @returns {{ acquired: boolean, existing?: object }}
56
+ */
57
+ export function acquireLock(itemId, metadata = {}) {
58
+ const safeId = sanitiseItemId(itemId);
59
+ const lockPath = join(LOCKS_DIR, `${safeId}.lock`);
60
+
61
+ // Check for existing lock
62
+ if (existsSync(lockPath)) {
63
+ try {
64
+ const existing = JSON.parse(readFileSync(lockPath, "utf-8"));
65
+ const age = Date.now() - new Date(existing.acquiredAt).getTime();
66
+
67
+ // If lock is fresh (< STALE_LOCK_MS), reject
68
+ if (age < STALE_LOCK_MS) {
69
+ return { acquired: false, existing };
70
+ }
71
+ // Stale lock — fall through to overwrite
72
+ } catch {
73
+ // Corrupted lock file — overwrite
74
+ }
75
+ }
76
+
77
+ const lockData = {
78
+ sessionId: metadata.sessionId || `pending-${Date.now()}`,
79
+ itemId,
80
+ channel: metadata.channel || null,
81
+ sender: metadata.sender || null,
82
+ acquiredAt: new Date().toISOString(),
83
+ holdingSent: false,
84
+ substantiveSent: false,
85
+ };
86
+
87
+ try {
88
+ // Try exclusive create first (atomic)
89
+ writeFileSync(lockPath, JSON.stringify(lockData, null, 2), { flag: "wx" });
90
+ return { acquired: true };
91
+ } catch (err) {
92
+ if (err.code === "EEXIST") {
93
+ // Another process beat us — check if it's stale
94
+ try {
95
+ const existing = JSON.parse(readFileSync(lockPath, "utf-8"));
96
+ const age = Date.now() - new Date(existing.acquiredAt).getTime();
97
+ if (age >= STALE_LOCK_MS) {
98
+ // Stale — overwrite (non-atomic but acceptable for stale locks)
99
+ writeFileSync(lockPath, JSON.stringify(lockData, null, 2));
100
+ return { acquired: true };
101
+ }
102
+ return { acquired: false, existing };
103
+ } catch {
104
+ return { acquired: false };
105
+ }
106
+ }
107
+ throw err;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Release a lock for an item.
113
+ * @param {string} itemId - Raw item ID (will be sanitised)
114
+ */
115
+ export function releaseLock(itemId) {
116
+ const safeId = sanitiseItemId(itemId);
117
+ const lockPath = join(LOCKS_DIR, `${safeId}.lock`);
118
+ try {
119
+ unlinkSync(lockPath);
120
+ } catch {
121
+ // Lock already released or never existed — fine
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Read the lock data for an item without modifying it.
127
+ * @param {string} itemId - Raw item ID (will be sanitised)
128
+ * @returns {object|null} Lock data or null if no lock
129
+ */
130
+ export function readLock(itemId) {
131
+ const safeId = sanitiseItemId(itemId);
132
+ const lockPath = join(LOCKS_DIR, `${safeId}.lock`);
133
+ try {
134
+ return JSON.parse(readFileSync(lockPath, "utf-8"));
135
+ } catch {
136
+ return null;
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Update fields on an existing lock.
142
+ * @param {string} itemId - Raw item ID
143
+ * @param {object} updates - Fields to merge (e.g., { holdingSent: true })
144
+ */
145
+ export function updateLock(itemId, updates) {
146
+ const safeId = sanitiseItemId(itemId);
147
+ const lockPath = join(LOCKS_DIR, `${safeId}.lock`);
148
+ try {
149
+ const existing = JSON.parse(readFileSync(lockPath, "utf-8"));
150
+ const updated = { ...existing, ...updates };
151
+ writeFileSync(lockPath, JSON.stringify(updated, null, 2));
152
+ } catch {
153
+ // Lock doesn't exist — no-op
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Scan and remove stale locks (older than STALE_LOCK_MS).
159
+ * Call on daemon startup to clean up after crashes.
160
+ * @returns {number} Number of stale locks cleared
161
+ */
162
+ export function scanStaleLocks() {
163
+ let cleared = 0;
164
+ try {
165
+ const files = readdirSync(LOCKS_DIR).filter((f) => f.endsWith(".lock"));
166
+ for (const file of files) {
167
+ const lockPath = join(LOCKS_DIR, file);
168
+ try {
169
+ const data = JSON.parse(readFileSync(lockPath, "utf-8"));
170
+ const age = Date.now() - new Date(data.acquiredAt).getTime();
171
+ if (age >= STALE_LOCK_MS) {
172
+ unlinkSync(lockPath);
173
+ cleared++;
174
+ }
175
+ } catch {
176
+ // Corrupted lock — remove it
177
+ try { unlinkSync(lockPath); cleared++; } catch {}
178
+ }
179
+ }
180
+ } catch {
181
+ // LOCKS_DIR doesn't exist yet — nothing to scan
182
+ }
183
+ return cleared;
184
+ }
185
+
186
+ /**
187
+ * Register that a message was sent.
188
+ * @param {string} channel - Slack channel ID or email address
189
+ * @param {string|null} threadTs - Thread timestamp (null for non-threaded)
190
+ * @param {string} type - "holding" | "quick_reply" | "substantive"
191
+ * @param {string} sessionId - Session that sent it
192
+ * @param {string} textPreview - First ~100 chars of the message
193
+ */
194
+ export function registerSent(channel, threadTs, type, sessionId, textPreview) {
195
+ const entry = {
196
+ timestamp: new Date().toISOString(),
197
+ channel,
198
+ threadTs: threadTs || null,
199
+ type,
200
+ sessionId,
201
+ textPreview: (textPreview || "").substring(0, 100),
202
+ };
203
+ try {
204
+ appendFileSync(REGISTRY_PATH, JSON.stringify(entry) + "\n");
205
+ } catch {
206
+ // Registry dir might not exist yet in tests
207
+ mkdirSync(SESSION_DIR, { recursive: true });
208
+ appendFileSync(REGISTRY_PATH, JSON.stringify(entry) + "\n");
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Check if a similar message was recently sent to the same channel/thread.
214
+ * @param {string} channel
215
+ * @param {string|null} threadTs
216
+ * @param {string} type - "holding" | "quick_reply" | "substantive"
217
+ * @returns {{ allowed: boolean, reason?: string }}
218
+ */
219
+ export function checkRecentlySent(channel, threadTs, type) {
220
+ let entries = [];
221
+ try {
222
+ const raw = readFileSync(REGISTRY_PATH, "utf-8").trim();
223
+ if (!raw) return { allowed: true };
224
+ const lines = raw.split("\n").filter(Boolean);
225
+ // Only check recent entries (last MAX_REGISTRY_ENTRIES lines)
226
+ const recent = lines.slice(-MAX_REGISTRY_ENTRIES);
227
+ entries = recent.map((line) => {
228
+ try { return JSON.parse(line); } catch { return null; }
229
+ }).filter(Boolean);
230
+ } catch {
231
+ // No registry file yet — nothing sent
232
+ return { allowed: true };
233
+ }
234
+
235
+ const now = Date.now();
236
+ const isDmChannel = channel.startsWith("D");
237
+
238
+ // Rule 0a: DM holding-note dedup — max 1 holding note per DM channel per
239
+ // 5-minute window, regardless of thread. Prevents the CEO from receiving
240
+ // 3 holding notes when multiple sessions pick up the same request.
241
+ if (isDmChannel && type === "holding") {
242
+ const holdingMatch = entries.find((e) => {
243
+ const age = now - new Date(e.timestamp).getTime();
244
+ return e.channel === channel
245
+ && e.type === "holding"
246
+ && age < HOLDING_DM_DEDUP_WINDOW_MS;
247
+ });
248
+ if (holdingMatch) {
249
+ return { allowed: false, reason: `dm-holding-dedup: holding note already sent to this DM ${Math.round((now - new Date(holdingMatch.timestamp).getTime()) / 1000)}s ago (max 1 per ${HOLDING_DM_DEDUP_WINDOW_MS / 1000}s)` };
250
+ }
251
+ }
252
+
253
+ // Rule 0b: DM channel-level dedup — in DMs, user sees thread replies and
254
+ // main-feed messages in a single stream. A message sent to thread X and
255
+ // a message sent to the main DM feed are BOTH visible to the user, so
256
+ // treat the entire DM channel as one dedup group regardless of threadTs.
257
+ // This prevents the bug where a single message discovered via two paths
258
+ // (thread reply + DM channel message) each triggers independent sends.
259
+ if (isDmChannel) {
260
+ const dmMatch = entries.find((e) => {
261
+ const age = now - new Date(e.timestamp).getTime();
262
+ return e.channel === channel
263
+ && e.type === type
264
+ && age < SENT_DEDUP_WINDOW_MS;
265
+ });
266
+ if (dmMatch) {
267
+ return { allowed: false, reason: `dm-channel-dedup: ${type} already sent to this DM ${Math.round((now - new Date(dmMatch.timestamp).getTime()) / 1000)}s ago (thread ${dmMatch.threadTs || "main"})` };
268
+ }
269
+ }
270
+
271
+ // Rule 1: Same channel + thread + type within dedup window → BLOCK
272
+ const exactMatch = entries.find((e) => {
273
+ const age = now - new Date(e.timestamp).getTime();
274
+ return e.channel === channel
275
+ && e.threadTs === (threadTs || null)
276
+ && e.type === type
277
+ && age < SENT_DEDUP_WINDOW_MS;
278
+ });
279
+ if (exactMatch) {
280
+ return { allowed: false, reason: `duplicate: same channel+thread+type sent ${Math.round((now - new Date(exactMatch.timestamp).getTime()) / 1000)}s ago` };
281
+ }
282
+
283
+ // Rule 2: Same channel + thread (any type) within dedup window → BLOCK
284
+ // Prevents cascading responses when the same thread is re-ingested
285
+ // across multiple poll cycles with different message types.
286
+ if (threadTs) {
287
+ const threadMatch = entries.find((e) => {
288
+ const age = now - new Date(e.timestamp).getTime();
289
+ return e.channel === channel
290
+ && e.threadTs === threadTs
291
+ && age < SENT_DEDUP_WINDOW_MS;
292
+ });
293
+ if (threadMatch) {
294
+ return { allowed: false, reason: `thread cooldown: ${threadMatch.type} sent to same thread ${Math.round((now - new Date(threadMatch.timestamp).getTime()) / 1000)}s ago` };
295
+ }
296
+ }
297
+
298
+ // Rule 3: Same channel + substantive within 2 min (any thread) → BLOCK
299
+ if (type === "substantive") {
300
+ const rapidMatch = entries.find((e) => {
301
+ const age = now - new Date(e.timestamp).getTime();
302
+ return e.channel === channel
303
+ && e.type === "substantive"
304
+ && age < SENT_SUBSTANTIVE_WINDOW_MS;
305
+ });
306
+ if (rapidMatch) {
307
+ return { allowed: false, reason: `rapid: substantive message to same channel sent ${Math.round((now - new Date(rapidMatch.timestamp).getTime()) / 1000)}s ago` };
308
+ }
309
+ }
310
+
311
+ return { allowed: true };
312
+ }
313
+
314
+ /**
315
+ * Check if a session was already dispatched for the same thread recently.
316
+ * Prevents multiple sessions from responding to the same thread when
317
+ * several messages arrive in quick succession.
318
+ *
319
+ * For DM channel locks (threadTs = "dm-channel"), uses a shorter timeout
320
+ * (10 min) since DMs are individual messages, not persistent threads.
321
+ *
322
+ * @param {string} channel - Slack channel ID
323
+ * @param {string} threadTs - Thread parent timestamp, or "dm-channel" for non-threaded DMs
324
+ * @returns {{ allowed: boolean, reason?: string }}
325
+ */
326
+ export function acquireThreadLock(channel, threadTs) {
327
+ if (!channel || !threadTs) return { allowed: true };
328
+
329
+ // DM CHANNEL NORMALIZATION: In DMs (channel starts with "D"), always use
330
+ // "dm-channel" as the lock key regardless of the actual threadTs. This
331
+ // ensures only ONE session handles a DM at a time — whether the message
332
+ // arrived as a thread reply or a main-feed message, the user sees both
333
+ // in the same conversation. Without this, the same message discovered
334
+ // via two paths (thread reply + DM channel) gets two separate locks.
335
+ if (channel.startsWith("D")) {
336
+ threadTs = "dm-channel";
337
+ }
338
+
339
+ // Use shorter lock for DM channels vs actual threads
340
+ const isDmLock = threadTs === "dm-channel";
341
+ const lockTimeout = isDmLock ? DM_CHANNEL_LOCK_MS : THREAD_LOCK_MS;
342
+ const lockLabel = isDmLock ? "DM channel" : "thread";
343
+
344
+ const safeKey = sanitiseItemId(`thread-${channel}-${threadTs}`);
345
+ const lockPath = join(LOCKS_DIR, `${safeKey}.lock`);
346
+
347
+ if (existsSync(lockPath)) {
348
+ try {
349
+ const existing = JSON.parse(readFileSync(lockPath, "utf-8"));
350
+ const age = Date.now() - new Date(existing.acquiredAt).getTime();
351
+ if (age < lockTimeout) {
352
+ return {
353
+ allowed: false,
354
+ reason: `${lockLabel} already dispatched ${Math.round(age / 1000)}s ago by ${existing.sessionId}`,
355
+ };
356
+ }
357
+ // Stale — fall through to overwrite
358
+ } catch {
359
+ // Corrupted — fall through to overwrite
360
+ }
361
+ }
362
+
363
+ const lockData = {
364
+ sessionId: `thread-${Date.now()}`,
365
+ channel,
366
+ threadTs,
367
+ acquiredAt: new Date().toISOString(),
368
+ };
369
+
370
+ try {
371
+ writeFileSync(lockPath, JSON.stringify(lockData, null, 2), { flag: "wx" });
372
+ return { allowed: true };
373
+ } catch (err) {
374
+ if (err.code === "EEXIST") {
375
+ try {
376
+ const existing = JSON.parse(readFileSync(lockPath, "utf-8"));
377
+ const age = Date.now() - new Date(existing.acquiredAt).getTime();
378
+ if (age >= lockTimeout) {
379
+ writeFileSync(lockPath, JSON.stringify(lockData, null, 2));
380
+ return { allowed: true };
381
+ }
382
+ return {
383
+ allowed: false,
384
+ reason: `${lockLabel} already dispatched ${Math.round(age / 1000)}s ago`,
385
+ };
386
+ } catch {
387
+ return { allowed: false, reason: "thread lock contention" };
388
+ }
389
+ }
390
+ throw err;
391
+ }
392
+ }
393
+
394
+ // ---------------------------------------------------------------------------
395
+ // Request claiming — prevents multiple sessions from processing the same
396
+ // inbound request (e.g., "email me the brief" picked up by inbox poller
397
+ // AND backlog sweep, or by consecutive poll cycles).
398
+ // ---------------------------------------------------------------------------
399
+
400
+ const REQUEST_CLAIM_DIR = join(SESSION_DIR, "request-claims");
401
+ const REQUEST_CLAIM_TTL_MS = 30 * 60 * 1000; // 30 min — request claim expires after 30 min
402
+
403
+ // Ensure directory exists
404
+ mkdirSync(REQUEST_CLAIM_DIR, { recursive: true });
405
+
406
+ /**
407
+ * Generate a normalized claim key from request attributes.
408
+ * Normalizes subject/content to catch semantically-equivalent requests.
409
+ *
410
+ * @param {object} attrs - { recipient, subject, action_type }
411
+ * recipient: email address or Slack channel
412
+ * subject: email subject or brief description of request
413
+ * action_type: "email_brief", "send_email", "respond", etc.
414
+ * @returns {string} Sanitized claim key
415
+ */
416
+ export function requestClaimKey(attrs) {
417
+ const { recipient = "", subject = "", action_type = "" } = attrs;
418
+ // Normalize: lowercase, strip dates/times, strip Re:/Fwd:, collapse whitespace
419
+ const normRecipient = recipient.toLowerCase().trim();
420
+ const normSubject = subject.toLowerCase()
421
+ .replace(/^(re|fwd|fw):\s*/gi, "")
422
+ .replace(/\d{1,2}:\d{2}(:\d{2})?/g, "") // strip times
423
+ .replace(/(mon|tue|wed|thu|fri|sat|sun)\w*/gi, "") // strip day names
424
+ .replace(/\d{1,2}\s+(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\w*\s+\d{4}/gi, "") // strip dates
425
+ .replace(/\d{4}-\d{2}-\d{2}/g, "") // strip ISO dates
426
+ .replace(/\s+/g, " ")
427
+ .trim();
428
+ const raw = `${action_type}|${normRecipient}|${normSubject}`;
429
+ return sanitiseItemId(`req-${raw}`).substring(0, 120);
430
+ }
431
+
432
+ /**
433
+ * Attempt to claim an inbound request so only one session processes it.
434
+ *
435
+ * @param {object} attrs - { recipient, subject, action_type }
436
+ * @param {string} sessionId - Session claiming the request
437
+ * @returns {{ claimed: boolean, reason?: string, existing?: object }}
438
+ */
439
+ export function claimRequest(attrs, sessionId = "") {
440
+ const key = requestClaimKey(attrs);
441
+ const claimPath = join(REQUEST_CLAIM_DIR, `${key}.claim`);
442
+
443
+ if (!sessionId) sessionId = `claim-${Date.now()}`;
444
+
445
+ // Check for existing claim
446
+ if (existsSync(claimPath)) {
447
+ try {
448
+ const existing = JSON.parse(readFileSync(claimPath, "utf-8"));
449
+ const age = Date.now() - new Date(existing.claimedAt).getTime();
450
+ if (age < REQUEST_CLAIM_TTL_MS) {
451
+ return { claimed: false, reason: `request already claimed by ${existing.sessionId} ${Math.round(age / 1000)}s ago`, existing };
452
+ }
453
+ // Stale — fall through to overwrite
454
+ } catch {
455
+ // Corrupted — fall through
456
+ }
457
+ }
458
+
459
+ const claimData = {
460
+ sessionId,
461
+ attrs,
462
+ claimedAt: new Date().toISOString(),
463
+ };
464
+
465
+ try {
466
+ writeFileSync(claimPath, JSON.stringify(claimData, null, 2), { flag: "wx" });
467
+ return { claimed: true };
468
+ } catch (err) {
469
+ if (err.code === "EEXIST") {
470
+ try {
471
+ const existing = JSON.parse(readFileSync(claimPath, "utf-8"));
472
+ const age = Date.now() - new Date(existing.claimedAt).getTime();
473
+ if (age >= REQUEST_CLAIM_TTL_MS) {
474
+ writeFileSync(claimPath, JSON.stringify(claimData, null, 2));
475
+ return { claimed: true };
476
+ }
477
+ return { claimed: false, reason: `request claim race lost to ${existing.sessionId}`, existing };
478
+ } catch {
479
+ return { claimed: false, reason: "request claim contention" };
480
+ }
481
+ }
482
+ // Fail-open: if we can't write the claim, allow processing
483
+ return { claimed: true };
484
+ }
485
+ }
486
+
487
+ /**
488
+ * Release a request claim (e.g., after session completes or fails).
489
+ *
490
+ * @param {object} attrs - Same attrs used to claim
491
+ */
492
+ export function releaseRequestClaim(attrs) {
493
+ const key = requestClaimKey(attrs);
494
+ const claimPath = join(REQUEST_CLAIM_DIR, `${key}.claim`);
495
+ try {
496
+ unlinkSync(claimPath);
497
+ } catch {
498
+ // Already released or never existed — fine
499
+ }
500
+ }
501
+
502
+ /**
503
+ * Release a thread lock after the session handling this thread completes.
504
+ * This allows new messages in the thread to be processed by subsequent polls.
505
+ *
506
+ * @param {string} channel - Slack channel ID
507
+ * @param {string} threadTs - Thread parent timestamp
508
+ */
509
+ export function releaseThreadLock(channel, threadTs) {
510
+ if (!channel) return;
511
+ // Match normalization in acquireThreadLock — DMs always use "dm-channel"
512
+ // regardless of whether threadTs is set. This MUST happen before the
513
+ // empty-threadTs check, otherwise DM channel locks never get released
514
+ // and every DM after the first gets dropped for the full 60min TTL
515
+ // (observed in ravi-ai on 2026-05-12: CEO's follow-up DM 23min after
516
+ // the first was skipped with "thread_dedup: DM channel already
517
+ // dispatched 1200s ago").
518
+ if (channel.startsWith("D")) threadTs = "dm-channel";
519
+ if (!threadTs) return; // non-DM channel without a thread — no lock to release
520
+ const safeKey = sanitiseItemId(`thread-${channel}-${threadTs}`);
521
+ const lockPath = join(LOCKS_DIR, `${safeKey}.lock`);
522
+ try {
523
+ unlinkSync(lockPath);
524
+ } catch {
525
+ // Lock already released or never existed — fine
526
+ }
527
+ }
528
+
529
+ // ---------------------------------------------------------------------------
530
+ // Item claims — prevents multiple parallel backlog sessions from picking up
531
+ // the same queue item simultaneously. Designed per:
532
+ // outputs/research/2026-04-18-session-coordination-design.md
533
+ // ---------------------------------------------------------------------------
534
+
535
+ const ITEM_CLAIM_DIR = join(AGENT_REPO_DIR, "state", "locks", "item-claims");
536
+ const DEFAULT_ITEM_CLAIM_TTL_MIN = 30; // Default TTL in minutes
537
+
538
+ // Ensure directory exists
539
+ mkdirSync(ITEM_CLAIM_DIR, { recursive: true });
540
+
541
+ /**
542
+ * Check if a process is still running (macOS-compatible).
543
+ * Uses kill(pid, 0) — signal 0 checks existence without sending a signal.
544
+ *
545
+ * @param {number} pid - Process ID to check
546
+ * @returns {boolean} true if process is running
547
+ */
548
+ function isProcessRunning(pid) {
549
+ try {
550
+ process.kill(pid, 0); // Signal 0 = existence check
551
+ return true;
552
+ } catch (err) {
553
+ return err.code === "EPERM"; // Process exists but we lack permission
554
+ }
555
+ }
556
+
557
+ /**
558
+ * Attempt to claim a queue item so only one session processes it.
559
+ * Uses exclusive file creation (wx flag) for POSIX-atomic acquisition.
560
+ *
561
+ * Fail-open design: claim system failures never block work. The worst case
562
+ * is duplicate work (the status quo), not blocked work.
563
+ *
564
+ * @param {string} itemId - Queue item ID (e.g. "ib-20260407-001b")
565
+ * @param {object} metadata - { session_id, agent_description, ttl_minutes, source, queue_file, pid }
566
+ * @returns {{ claimed: boolean, reason?: string, holder?: string }}
567
+ */
568
+ export function claimItem(itemId, metadata = {}) {
569
+ const safeId = sanitiseItemId(itemId);
570
+ const claimPath = join(ITEM_CLAIM_DIR, `${safeId}.claim`);
571
+ const ttlMinutes = metadata.ttl_minutes || DEFAULT_ITEM_CLAIM_TTL_MIN;
572
+
573
+ // Check for existing claim
574
+ if (existsSync(claimPath)) {
575
+ try {
576
+ const existing = JSON.parse(readFileSync(claimPath, "utf-8"));
577
+ const ageMinutes = (Date.now() - new Date(existing.claimed_at).getTime()) / 60000;
578
+
579
+ if (ageMinutes < (existing.ttl_minutes || DEFAULT_ITEM_CLAIM_TTL_MIN)) {
580
+ // Claim is within TTL — check if the holding process is actually alive
581
+ if (existing.pid && isProcessRunning(existing.pid)) {
582
+ return { claimed: false, reason: "active_claim", holder: existing.session_id };
583
+ }
584
+ // Process is dead but claim not expired — override (orphaned claim)
585
+ console.log(`[session-lock] Overriding orphaned item claim for ${itemId}, pid ${existing.pid} is dead`);
586
+ // Fall through to write new claim
587
+ } else {
588
+ // TTL expired — override
589
+ console.log(`[session-lock] Overriding expired item claim for ${itemId}, age ${ageMinutes.toFixed(1)}m > ttl ${existing.ttl_minutes || DEFAULT_ITEM_CLAIM_TTL_MIN}m`);
590
+ // Fall through to write new claim
591
+ }
592
+ } catch {
593
+ // Corrupted claim file — treat as no existing claim (fail-open)
594
+ }
595
+ }
596
+
597
+ const claimData = {
598
+ session_id: metadata.session_id || `claim-${Date.now()}`,
599
+ claimed_at: new Date().toISOString(),
600
+ agent_description: metadata.agent_description || "",
601
+ ttl_minutes: ttlMinutes,
602
+ source: metadata.source || "backlog",
603
+ queue_file: metadata.queue_file || "",
604
+ pid: metadata.pid || process.pid,
605
+ };
606
+
607
+ try {
608
+ // Atomic exclusive create — identical pattern to acquireLock (line 90)
609
+ writeFileSync(claimPath, JSON.stringify(claimData, null, 2), { flag: "wx" });
610
+ return { claimed: true };
611
+ } catch (err) {
612
+ if (err.code === "EEXIST") {
613
+ // Race condition — another process won the claim
614
+ try {
615
+ const existing = JSON.parse(readFileSync(claimPath, "utf-8"));
616
+ const ageMinutes = (Date.now() - new Date(existing.claimed_at).getTime()) / 60000;
617
+ if (ageMinutes >= (existing.ttl_minutes || DEFAULT_ITEM_CLAIM_TTL_MIN)) {
618
+ // Stale — overwrite (non-atomic but acceptable)
619
+ writeFileSync(claimPath, JSON.stringify(claimData, null, 2));
620
+ return { claimed: true };
621
+ }
622
+ return { claimed: false, reason: "race_lost", holder: existing.session_id };
623
+ } catch {
624
+ return { claimed: false, reason: "race_lost" };
625
+ }
626
+ }
627
+ // Fail-open: if we can't write the claim for any other reason, allow processing
628
+ console.warn(`[session-lock] Item claim write failed for ${itemId} (fail-open): ${err.message}`);
629
+ return { claimed: true };
630
+ }
631
+ }
632
+
633
+ /**
634
+ * Release a claim for a queue item (on session completion or error).
635
+ *
636
+ * @param {string} itemId - Queue item ID
637
+ */
638
+ export function releaseItemClaim(itemId) {
639
+ const safeId = sanitiseItemId(itemId);
640
+ const claimPath = join(ITEM_CLAIM_DIR, `${safeId}.claim`);
641
+ try {
642
+ unlinkSync(claimPath);
643
+ } catch {
644
+ // Claim already released, expired, or never existed — fine
645
+ }
646
+ }
647
+
648
+ /**
649
+ * Check if a queue item has an active (non-expired, live-process) claim.
650
+ * Used by sweepBacklog to skip items already being worked on.
651
+ *
652
+ * @param {string} itemId - Queue item ID
653
+ * @returns {boolean} true if there is an active claim on this item
654
+ */
655
+ export function hasActiveClaim(itemId) {
656
+ const safeId = sanitiseItemId(itemId);
657
+ const claimPath = join(ITEM_CLAIM_DIR, `${safeId}.claim`);
658
+
659
+ try {
660
+ if (!existsSync(claimPath)) return false;
661
+
662
+ const claim = JSON.parse(readFileSync(claimPath, "utf-8"));
663
+ const ageMinutes = (Date.now() - new Date(claim.claimed_at).getTime()) / 60000;
664
+ const ttl = claim.ttl_minutes || DEFAULT_ITEM_CLAIM_TTL_MIN;
665
+
666
+ // Expired claim — not active
667
+ if (ageMinutes >= ttl) return false;
668
+
669
+ // If PID is recorded and process is dead — not active (orphaned)
670
+ if (claim.pid && !isProcessRunning(claim.pid)) return false;
671
+
672
+ return true;
673
+ } catch {
674
+ // Fail-open: if we can't read/parse the claim, treat as no claim
675
+ return false;
676
+ }
677
+ }
678
+
679
+ /**
680
+ * Sweep stale item claims. Called periodically from the daemon health interval.
681
+ *
682
+ * Two-tier cleanup:
683
+ * - Past 2x TTL: unconditionally remove (well past expiry)
684
+ * - Past 1x TTL but within 2x: remove only if PID is dead (orphaned)
685
+ *
686
+ * @returns {number} Number of stale claims removed
687
+ */
688
+ export function sweepStaleItemClaims() {
689
+ let swept = 0;
690
+ try {
691
+ const files = readdirSync(ITEM_CLAIM_DIR).filter((f) => f.endsWith(".claim"));
692
+ for (const file of files) {
693
+ const claimPath = join(ITEM_CLAIM_DIR, file);
694
+ try {
695
+ const claim = JSON.parse(readFileSync(claimPath, "utf-8"));
696
+ const ageMinutes = (Date.now() - new Date(claim.claimed_at).getTime()) / 60000;
697
+ const ttl = claim.ttl_minutes || DEFAULT_ITEM_CLAIM_TTL_MIN;
698
+
699
+ if (ageMinutes > ttl * 2) {
700
+ // Well past TTL — remove unconditionally
701
+ unlinkSync(claimPath);
702
+ swept++;
703
+ console.log(`[session-lock] Swept stale item claim: ${claim.session_id}, age ${ageMinutes.toFixed(1)}m (2x TTL=${ttl * 2}m)`);
704
+ } else if (ageMinutes > ttl) {
705
+ // Past TTL but within 2x — check if PID is dead
706
+ if (claim.pid && !isProcessRunning(claim.pid)) {
707
+ unlinkSync(claimPath);
708
+ swept++;
709
+ console.log(`[session-lock] Swept orphaned item claim: ${claim.session_id}, pid ${claim.pid} dead`);
710
+ }
711
+ }
712
+ } catch {
713
+ // Corrupted claim file — remove it
714
+ try { unlinkSync(claimPath); swept++; } catch {}
715
+ }
716
+ }
717
+ } catch {
718
+ // ITEM_CLAIM_DIR doesn't exist or unreadable — nothing to sweep
719
+ }
720
+ return swept;
721
+ }