@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.
- package/.claude/commands/init-agent.md +104 -0
- package/.claude/commands/init-maestro.md +1187 -0
- package/.claude/settings.json +161 -0
- package/.env.example +216 -0
- package/README.md +632 -0
- package/agents/browser-operator/agent.md +52 -0
- package/agents/calendar-ops/agent.md +50 -0
- package/agents/communications/agent.md +96 -0
- package/agents/decision-log/agent.md +65 -0
- package/agents/desktop-operator/agent.md +59 -0
- package/agents/gmail-operator/agent.md +62 -0
- package/agents/inbound-dispatcher/agent.md +66 -0
- package/agents/inbox-processor/agent.md +39 -0
- package/agents/pmo-execution/agent.md +60 -0
- package/agents/session-spawner/agent.md +64 -0
- package/agents/slack-operator/agent.md +60 -0
- package/agents/whatsapp-operator/agent.md +60 -0
- package/agents/workflow-automation/agent.md +61 -0
- package/archetypes/altitudes/c-suite.yaml +58 -0
- package/archetypes/altitudes/founder.yaml +68 -0
- package/archetypes/altitudes/senior-manager.yaml +63 -0
- package/archetypes/altitudes/svp.yaml +60 -0
- package/archetypes/altitudes/vp.yaml +50 -0
- package/archetypes/archetype.schema.json +77 -0
- package/archetypes/base.yaml +47 -0
- package/archetypes/capabilities/commercial-leader.yaml +159 -0
- package/archetypes/capabilities/compliance-officer.yaml +159 -0
- package/archetypes/capabilities/executive-operator.yaml +169 -0
- package/archetypes/capabilities/finance-leader.yaml +162 -0
- package/archetypes/capabilities/operations-leader.yaml +154 -0
- package/archetypes/capabilities/product-leader.yaml +148 -0
- package/archetypes/capabilities/technical-leader.yaml +146 -0
- package/archetypes/functions/commercial-leader.yaml +62 -0
- package/archetypes/functions/compliance-officer.yaml +64 -0
- package/archetypes/functions/executive-operator.yaml +70 -0
- package/archetypes/functions/finance-leader.yaml +70 -0
- package/archetypes/functions/operations-leader.yaml +62 -0
- package/archetypes/functions/product-leader.yaml +61 -0
- package/archetypes/functions/technical-leader.yaml +57 -0
- package/bin/cohort-mcp.mjs +81 -0
- package/bin/maestro.mjs +3516 -0
- package/bin/maestro.test.mjs +1015 -0
- package/desktop-control/README.md +56 -0
- package/desktop-control/app-profiles/gmail.yaml +120 -0
- package/desktop-control/app-profiles/slack.yaml +315 -0
- package/desktop-control/app-profiles/whatsapp.yaml +107 -0
- package/docs/architecture/agent-topology.md +2239 -0
- package/docs/architecture/archetype-agent-factory.md +110 -0
- package/docs/architecture/collective-memory-and-org-mesh.md +115 -0
- package/docs/architecture/continuous-monitoring.md +221 -0
- package/docs/architecture/mcp-capability-map.md +585 -0
- package/docs/architecture/system-architecture.md +1272 -0
- package/docs/company-context/README.md +40 -0
- package/docs/guides/agent-persona-setup.md +600 -0
- package/docs/guides/agents-observe-setup.md +64 -0
- package/docs/guides/billing-console-keys.md +88 -0
- package/docs/guides/ccxray-diagnostics.md +65 -0
- package/docs/guides/channel-bus.md +127 -0
- package/docs/guides/claude-mem-setup.md +79 -0
- package/docs/guides/claude-pace-setup.md +56 -0
- package/docs/guides/claudraband-sessions.md +98 -0
- package/docs/guides/clawteam-swarm.md +116 -0
- package/docs/guides/code-review-graph-setup.md +86 -0
- package/docs/guides/email-setup.md +431 -0
- package/docs/guides/mac-mini.md +119 -0
- package/docs/guides/media-generation-setup.md +349 -0
- package/docs/guides/model-routing.md +162 -0
- package/docs/guides/observability-otel.md +265 -0
- package/docs/guides/org-onboarding.md +132 -0
- package/docs/guides/outbound-governance-setup.md +437 -0
- package/docs/guides/pdf-generation-setup.md +315 -0
- package/docs/guides/poller-daemon-setup.md +563 -0
- package/docs/guides/rag-context-setup.md +459 -0
- package/docs/guides/self-optimization-pattern.md +82 -0
- package/docs/guides/setup-wizard.md +178 -0
- package/docs/guides/slack-setup.md +350 -0
- package/docs/guides/telegram-setup.md +227 -0
- package/docs/guides/twilio-subaccounts-setup.md +223 -0
- package/docs/guides/verification.md +128 -0
- package/docs/guides/voice-mode.md +188 -0
- package/docs/guides/voice-sms-setup.md +698 -0
- package/docs/guides/webhook-relay-setup.md +349 -0
- package/docs/guides/whatsapp-setup.md +288 -0
- package/docs/prompts/board-pack-cover-template.md +36 -0
- package/docs/prompts/decision-recommendation-template.md +88 -0
- package/docs/prompts/followup-message-template.md +141 -0
- package/docs/prompts/investor-letter-template.md +52 -0
- package/docs/prompts/morning-brief-template.md +82 -0
- package/docs/prompts/presentation-template.md +58 -0
- package/docs/prompts/weekly-strategic-memo-template.md +104 -0
- package/docs/research/hallucinated-tool-output-investigation.md +151 -0
- package/docs/runbooks/backup-restore.md +205 -0
- package/docs/runbooks/cohort-cutover.md +129 -0
- package/docs/runbooks/fleet-operations.md +200 -0
- package/docs/runbooks/incident-response.md +226 -0
- package/docs/runbooks/mac-mini-bootstrap.md +431 -0
- package/docs/runbooks/perpetual-operations.md +509 -0
- package/docs/runbooks/recovery-and-failover.md +260 -0
- package/framework-features.json +267 -0
- package/ingest/README.md +87 -0
- package/lib/action-executor.js +689 -0
- package/lib/action-executor.test.mjs +871 -0
- package/lib/agent-root.mjs +37 -0
- package/lib/archetype.mjs +236 -0
- package/lib/archetype.test.mjs +132 -0
- package/lib/autonomy.mjs +114 -0
- package/lib/autonomy.test.mjs +66 -0
- package/lib/backlog.mjs +358 -0
- package/lib/backlog.test.mjs +266 -0
- package/lib/budget-guard.mjs +279 -0
- package/lib/budget-guard.test.mjs +291 -0
- package/lib/cadence-bus-schedule.test.mjs +194 -0
- package/lib/cadence-bus.mjs +1120 -0
- package/lib/cadence-bus.test.mjs +720 -0
- package/lib/cadences.mjs +205 -0
- package/lib/cadences.test.mjs +125 -0
- package/lib/capability.mjs +154 -0
- package/lib/capability.test.mjs +78 -0
- package/lib/channels/base-adapter.mjs +719 -0
- package/lib/channels/base-adapter.test.mjs +590 -0
- package/lib/channels/channel.mjs +128 -0
- package/lib/channels/channels.test.mjs +371 -0
- package/lib/channels/contract.mjs +215 -0
- package/lib/channels/contract.test.mjs +137 -0
- package/lib/channels/conversation-resolver.mjs +95 -0
- package/lib/channels/gmail/adapter.mjs +87 -0
- package/lib/channels/inbox-item.mjs +255 -0
- package/lib/channels/inbox-item.test.mjs +335 -0
- package/lib/channels/index.mjs +94 -0
- package/lib/channels/orgmail/adapter.mjs +353 -0
- package/lib/channels/orgmail/adapter.test.mjs +311 -0
- package/lib/channels/pairing.mjs +363 -0
- package/lib/channels/pairing.test.mjs +270 -0
- package/lib/channels/registry.mjs +164 -0
- package/lib/channels/slack/adapter.mjs +317 -0
- package/lib/channels/slack-adapter.test.mjs +212 -0
- package/lib/channels/sms/adapter.mjs +43 -0
- package/lib/channels/telegram/adapter.mjs +432 -0
- package/lib/channels/telegram-adapter.test.mjs +306 -0
- package/lib/channels/voice/adapter.mjs +301 -0
- package/lib/channels/voice/adapter.test.mjs +278 -0
- package/lib/channels/whatsapp/adapter-baileys.mjs +587 -0
- package/lib/channels/whatsapp/adapter-baileys.test.mjs +359 -0
- package/lib/channels/whatsapp/adapter-twilio.mjs +65 -0
- package/lib/channels/whatsapp/baileys-typing.test.mjs +154 -0
- package/lib/charter.mjs +256 -0
- package/lib/charter.test.mjs +89 -0
- package/lib/claude-bin.mjs +134 -0
- package/lib/claude-bin.test.mjs +75 -0
- package/lib/collective/capture.mjs +185 -0
- package/lib/collective/capture.test.mjs +121 -0
- package/lib/collective/cards.mjs +201 -0
- package/lib/collective/cards.test.mjs +114 -0
- package/lib/collective/config.mjs +186 -0
- package/lib/collective/config.test.mjs +123 -0
- package/lib/collective/global-config.mjs +113 -0
- package/lib/collective/global-config.test.mjs +75 -0
- package/lib/collective/presence.mjs +201 -0
- package/lib/collective/presence.test.mjs +95 -0
- package/lib/collective/recall.mjs +215 -0
- package/lib/collective/recall.test.mjs +116 -0
- package/lib/comms/send-gate.mjs +554 -0
- package/lib/comms/send-gate.test.mjs +577 -0
- package/lib/comms.mjs +67 -0
- package/lib/comms.test.mjs +41 -0
- package/lib/diagnostics/alerts.mjs +424 -0
- package/lib/diagnostics/alerts.test.mjs +318 -0
- package/lib/diagnostics/backup-freshness.mjs +188 -0
- package/lib/diagnostics/backup-freshness.test.mjs +185 -0
- package/lib/diagnostics/counters.mjs +269 -0
- package/lib/diagnostics/counters.test.mjs +206 -0
- package/lib/diagnostics/events.mjs +188 -0
- package/lib/diagnostics/events.test.mjs +290 -0
- package/lib/diagnostics/otel.mjs +237 -0
- package/lib/diagnostics/otel.test.mjs +196 -0
- package/lib/diagnostics/trace.mjs +216 -0
- package/lib/diagnostics/trace.test.mjs +251 -0
- package/lib/env-compat.mjs +74 -0
- package/lib/env-compat.test.mjs +104 -0
- package/lib/feature-init.mjs +331 -0
- package/lib/fs-atomic.mjs +112 -0
- package/lib/fs-atomic.test.mjs +72 -0
- package/lib/fs-ownership.mjs +111 -0
- package/lib/fs-ownership.test.mjs +158 -0
- package/lib/hooks/bus.mjs +347 -0
- package/lib/hooks/bus.test.mjs +387 -0
- package/lib/index.js +16 -0
- package/lib/learning/config.mjs +106 -0
- package/lib/learning/config.test.mjs +75 -0
- package/lib/learning/counters.mjs +156 -0
- package/lib/learning/counters.test.mjs +69 -0
- package/lib/learning/curator-consolidate.test.mjs +238 -0
- package/lib/learning/curator.mjs +453 -0
- package/lib/learning/curator.test.mjs +106 -0
- package/lib/learning/log.mjs +40 -0
- package/lib/learning/reflect.mjs +534 -0
- package/lib/learning/reflect.test.mjs +0 -0
- package/lib/learning/session-index.mjs +352 -0
- package/lib/learning/session-index.test.mjs +125 -0
- package/lib/learning/skill-writer.mjs +474 -0
- package/lib/learning/skill-writer.test.mjs +210 -0
- package/lib/mcp/server.mjs +328 -0
- package/lib/mcp/server.test.mjs +400 -0
- package/lib/model-router/auth-profiles.mjs +758 -0
- package/lib/model-router/auth-profiles.test.mjs +580 -0
- package/lib/model-router/catalog/anthropic.yaml +153 -0
- package/lib/model-router/catalog/deepseek.yaml +86 -0
- package/lib/model-router/catalog/moonshot.yaml +81 -0
- package/lib/model-router/catalog/qwen.yaml +114 -0
- package/lib/model-router/catalog.mjs +925 -0
- package/lib/model-router/catalog.test.mjs +385 -0
- package/lib/model-router/economics.mjs +564 -0
- package/lib/model-router/economics.test.mjs +344 -0
- package/lib/model-router/failover.mjs +298 -0
- package/lib/model-router/failover.test.mjs +439 -0
- package/lib/model-router/health.mjs +453 -0
- package/lib/model-router/health.test.mjs +338 -0
- package/lib/model-router/integration-coverage.test.mjs +829 -0
- package/lib/model-router/integration.test.mjs +564 -0
- package/lib/model-router/ledger.mjs +402 -0
- package/lib/model-router/ledger.test.mjs +382 -0
- package/lib/model-router/llm-task.mjs +515 -0
- package/lib/model-router/llm-task.test.mjs +392 -0
- package/lib/model-router/org-credentials.mjs +260 -0
- package/lib/model-router/org-credentials.test.mjs +265 -0
- package/lib/model-router/pricing-refresh.mjs +463 -0
- package/lib/model-router/pricing-refresh.test.mjs +286 -0
- package/lib/model-router/reconcile.mjs +429 -0
- package/lib/model-router/reconcile.test.mjs +316 -0
- package/lib/model-router/repair.mjs +471 -0
- package/lib/model-router/repair.test.mjs +180 -0
- package/lib/model-router/resolve.mjs +1206 -0
- package/lib/model-router/spawn.mjs +497 -0
- package/lib/model-router/spawn.test.mjs +425 -0
- package/lib/model-router/taxonomy.mjs +893 -0
- package/lib/model-router/taxonomy.test.mjs +410 -0
- package/lib/model-router.mjs +677 -0
- package/lib/model-router.test.mjs +907 -0
- package/lib/org/activity.mjs +211 -0
- package/lib/org/activity.test.mjs +134 -0
- package/lib/org/approvals.mjs +448 -0
- package/lib/org/approvals.test.mjs +216 -0
- package/lib/org/awareness.mjs +222 -0
- package/lib/org/awareness.test.mjs +159 -0
- package/lib/org/board.mjs +229 -0
- package/lib/org/board.test.mjs +177 -0
- package/lib/org/bootstrap-context.mjs +169 -0
- package/lib/org/bootstrap-context.test.mjs +153 -0
- package/lib/org/client.mjs +1628 -0
- package/lib/org/client.test.mjs +1107 -0
- package/lib/org/cohort-client.mjs +67 -0
- package/lib/org/cohort-client.test.mjs +126 -0
- package/lib/org/cost-sync.mjs +227 -0
- package/lib/org/cost-sync.test.mjs +153 -0
- package/lib/org/doctor.mjs +212 -0
- package/lib/org/doctor.test.mjs +212 -0
- package/lib/org/handoff.mjs +293 -0
- package/lib/org/handoff.test.mjs +269 -0
- package/lib/org/integration-tools.mjs +182 -0
- package/lib/org/integration-tools.test.mjs +160 -0
- package/lib/org/keys.mjs +131 -0
- package/lib/org/keys.test.mjs +92 -0
- package/lib/org/knowledge.mjs +463 -0
- package/lib/org/knowledge.test.mjs +319 -0
- package/lib/org/leases.mjs +335 -0
- package/lib/org/leases.test.mjs +235 -0
- package/lib/org/mesh-integration.test.mjs +127 -0
- package/lib/org/mesh.mjs +459 -0
- package/lib/org/mesh.test.mjs +345 -0
- package/lib/org/messaging.mjs +503 -0
- package/lib/org/messaging.test.mjs +238 -0
- package/lib/org/policy.mjs +345 -0
- package/lib/org/policy.test.mjs +237 -0
- package/lib/org/protocol.checksum +1 -0
- package/lib/org/protocol.checksum.test.mjs +90 -0
- package/lib/org/protocol.mjs +967 -0
- package/lib/org/protocol.test.mjs +264 -0
- package/lib/org/registry.mjs +194 -0
- package/lib/org/registry.test.mjs +100 -0
- package/lib/org/tool-surface-integration.test.mjs +120 -0
- package/lib/org/tool-surface.mjs +2535 -0
- package/lib/org/tool-surface.test.mjs +589 -0
- package/lib/org/ui-parity.mjs +3236 -0
- package/lib/org/ui-parity.test.mjs +348 -0
- package/lib/org/verify.mjs +176 -0
- package/lib/org/verify.test.mjs +194 -0
- package/lib/rag/embed.mjs +188 -0
- package/lib/rag/indexer.mjs +425 -0
- package/lib/rag/rag.test.mjs +505 -0
- package/lib/rag/search.mjs +475 -0
- package/lib/rate-guard.mjs +246 -0
- package/lib/rate-guard.test.mjs +201 -0
- package/lib/render.mjs +112 -0
- package/lib/render.test.mjs +68 -0
- package/lib/resource-governor.mjs +297 -0
- package/lib/resource-governor.test.mjs +262 -0
- package/lib/scheduling/dynamic-jobs.mjs +675 -0
- package/lib/scheduling/dynamic-jobs.test.mjs +344 -0
- package/lib/scheduling/jitter.mjs +0 -0
- package/lib/scheduling/jitter.test.mjs +140 -0
- package/lib/secrets/broker.mjs +315 -0
- package/lib/secrets/broker.test.mjs +280 -0
- package/lib/secrets/providers.mjs +461 -0
- package/lib/secrets/providers.test.mjs +274 -0
- package/lib/security/audit-engine.mjs +684 -0
- package/lib/security/audit-engine.test.mjs +389 -0
- package/lib/security/coerce-args.mjs +552 -0
- package/lib/security/coerce-args.test.mjs +281 -0
- package/lib/security/dangerous-tools.mjs +97 -0
- package/lib/security/dangerous-tools.test.mjs +68 -0
- package/lib/security/external-content.mjs +145 -0
- package/lib/security/external-content.test.mjs +67 -0
- package/lib/security/redact.mjs +592 -0
- package/lib/security/redact.test.mjs +441 -0
- package/lib/security/secret-equal.mjs +73 -0
- package/lib/security/secret-equal.test.mjs +55 -0
- package/lib/session-permissions.mjs +101 -0
- package/lib/session-permissions.test.mjs +100 -0
- package/lib/setup/claude-probe.mjs +74 -0
- package/lib/setup/completeness.mjs +175 -0
- package/lib/setup/completeness.test.mjs +110 -0
- package/lib/setup/context-pack.mjs +173 -0
- package/lib/setup/context-pack.test.mjs +89 -0
- package/lib/setup/enrich.mjs +277 -0
- package/lib/setup/enrich.test.mjs +115 -0
- package/lib/setup/enroll-from-cohort.mjs +441 -0
- package/lib/setup/enroll-from-cohort.test.mjs +233 -0
- package/lib/setup/integration.test.mjs +162 -0
- package/lib/setup/io.mjs +360 -0
- package/lib/setup/io.test.mjs +77 -0
- package/lib/setup/run-generator.mjs +81 -0
- package/lib/setup/runner.mjs +244 -0
- package/lib/setup/runner.test.mjs +132 -0
- package/lib/setup/sections/comms.mjs +173 -0
- package/lib/setup/sections/company.mjs +120 -0
- package/lib/setup/sections/enrich.mjs +138 -0
- package/lib/setup/sections/identity.mjs +182 -0
- package/lib/setup/sections/identity.test.mjs +140 -0
- package/lib/setup/sections/learning.mjs +153 -0
- package/lib/setup/sections/learning.test.mjs +81 -0
- package/lib/setup/sections/messaging.mjs +219 -0
- package/lib/setup/sections/messaging.test.mjs +127 -0
- package/lib/setup/sections/model.mjs +102 -0
- package/lib/setup/sections/operating-model.mjs +78 -0
- package/lib/setup/sections/org.mjs +475 -0
- package/lib/setup/sections/org.test.mjs +313 -0
- package/lib/setup/sections/orgmail.mjs +173 -0
- package/lib/setup/sections/orgmail.test.mjs +118 -0
- package/lib/setup/sections/recovery.mjs +159 -0
- package/lib/setup/sections/recovery.test.mjs +98 -0
- package/lib/setup/sections/tools.mjs +132 -0
- package/lib/setup/sections/verify.mjs +97 -0
- package/lib/setup/sot.mjs +205 -0
- package/lib/setup/sot.test.mjs +81 -0
- package/lib/setup/state.mjs +151 -0
- package/lib/setup/state.test.mjs +92 -0
- package/lib/singleton.js +229 -0
- package/lib/singleton.test.mjs +135 -0
- package/lib/telemetry/alerts.mjs +216 -0
- package/lib/telemetry/alerts.test.mjs +109 -0
- package/lib/telemetry/collect.mjs +512 -0
- package/lib/telemetry/collect.test.mjs +202 -0
- package/lib/tool-definitions-integration.test.mjs +83 -0
- package/lib/tool-definitions.js +738 -0
- package/lib/tool-definitions.test.mjs +437 -0
- package/lib/util/fetch-timeout.mjs +136 -0
- package/lib/util/fetch-timeout.test.mjs +202 -0
- package/lib/util/reconnect.mjs +343 -0
- package/lib/util/reconnect.test.mjs +369 -0
- package/lib/util/unhandled.mjs +205 -0
- package/lib/util/unhandled.test.mjs +216 -0
- package/lib/voice/context-loader.mjs +466 -0
- package/lib/voice/index.mjs +100 -0
- package/lib/voice/openai-realtime.mjs +510 -0
- package/lib/voice/outbound.mjs +542 -0
- package/lib/voice/outbound.test.mjs +69 -0
- package/lib/voice/post-call-brief.mjs +428 -0
- package/lib/voice/provider.mjs +52 -0
- package/lib/voice/session-rotation.mjs +257 -0
- package/lib/voice/stt.mjs +161 -0
- package/lib/voice/stt.test.mjs +226 -0
- package/lib/voice/tool-bridge.mjs +370 -0
- package/lib/voice/tts.mjs +104 -0
- package/lib/voice/twilio-sip-bridge.mjs +288 -0
- package/lib/voice/voice.test.mjs +990 -0
- package/mcp/README.md +80 -0
- package/package.json +151 -0
- package/plugins/maestro-skills/plugin.json +139 -0
- package/plugins/maestro-skills/skills/agents-observe.md +110 -0
- package/plugins/maestro-skills/skills/board-deck.md +68 -0
- package/plugins/maestro-skills/skills/books-close.md +77 -0
- package/plugins/maestro-skills/skills/brand-steward.md +121 -0
- package/plugins/maestro-skills/skills/calendar-plan.md +57 -0
- package/plugins/maestro-skills/skills/call-working-sessions.md +124 -0
- package/plugins/maestro-skills/skills/ccxray-diagnostics.md +91 -0
- package/plugins/maestro-skills/skills/claude-pace.md +61 -0
- package/plugins/maestro-skills/skills/code-review-graph.md +99 -0
- package/plugins/maestro-skills/skills/crm-pipeline.md +65 -0
- package/plugins/maestro-skills/skills/decision-brief.md +89 -0
- package/plugins/maestro-skills/skills/directory-hygiene.md +125 -0
- package/plugins/maestro-skills/skills/draft-comms.md +84 -0
- package/plugins/maestro-skills/skills/evening-wrap.md +53 -0
- package/plugins/maestro-skills/skills/files-find.md +65 -0
- package/plugins/maestro-skills/skills/generative-ui.md +228 -0
- package/plugins/maestro-skills/skills/hiring-triage.md +74 -0
- package/plugins/maestro-skills/skills/inbox-triage.md +61 -0
- package/plugins/maestro-skills/skills/mail-triage.md +86 -0
- package/plugins/maestro-skills/skills/morning-brief.md +54 -0
- package/plugins/maestro-skills/skills/native-artifacts.md +157 -0
- package/plugins/maestro-skills/skills/org-board.md +133 -0
- package/plugins/maestro-skills/skills/org-credential.md +68 -0
- package/plugins/maestro-skills/skills/org-recall.md +81 -0
- package/plugins/maestro-skills/skills/pipeline-review.md +76 -0
- package/plugins/maestro-skills/skills/regulatory-status.md +81 -0
- package/plugins/maestro-skills/skills/router-why.md +78 -0
- package/plugins/maestro-skills/skills/schedule-meeting.md +91 -0
- package/plugins/maestro-skills/skills/session-search.md +71 -0
- package/plugins/maestro-skills/skills/set-reminder.md +93 -0
- package/plugins/maestro-skills/skills/slack-followup.md +64 -0
- package/plugins/maestro-skills/skills/team-activity.md +86 -0
- package/plugins/maestro-skills/skills/weekly-memo.md +70 -0
- package/policies/action-classification.yaml +114 -0
- package/policies/ai-disclosure.yaml +294 -0
- package/policies/communication-style.md +139 -0
- package/policies/information-barriers.yaml +118 -0
- package/policies/prompt-injection-defence.yaml +138 -0
- package/public/assets/icon-dark.png +0 -0
- package/public/assets/icon-dark.svg +9 -0
- package/public/assets/icon-light.svg +9 -0
- package/public/assets/logo-dark.svg +15 -0
- package/public/assets/logo-light.svg +15 -0
- package/scaffold/.mcp.json +7 -0
- package/scaffold/CLAUDE.md +368 -0
- package/scaffold/config/agent.json +55 -0
- package/scaffold/config/agent.ts +76 -0
- package/scaffold/config/agent.ts.example +89 -0
- package/scaffold/config/alerts.yaml +23 -0
- package/scaffold/config/allowlist.yaml.example +25 -0
- package/scaffold/config/caller-id-map.yaml +46 -0
- package/scaffold/config/collective.yaml +49 -0
- package/scaffold/config/company.json +20 -0
- package/scaffold/config/known-agents.json +6 -0
- package/scaffold/config/learning.yaml +55 -0
- package/scaffold/config/model-routing.yaml.example +104 -0
- package/scaffold/config/org.yaml +25 -0
- package/scaffold/config/orgmail.yaml.example +19 -0
- package/scaffold/config/recovery.yaml +72 -0
- package/scaffold/config/secrets.yaml +27 -0
- package/scaffold/config/slack.yaml.example +35 -0
- package/scaffold/config/telegram.yaml.example +38 -0
- package/scaffold/config/voice.yaml.example +89 -0
- package/scaffold/config/whatsapp.yaml.example +39 -0
- package/schedules/README.md +49 -0
- package/schedules/triggers/backlog-executor.md +102 -0
- package/schedules/triggers/brand-steward.md +72 -0
- package/schedules/triggers/daily-evening-wrap.md +159 -0
- package/schedules/triggers/daily-midday-sweep.md +58 -0
- package/schedules/triggers/daily-morning-brief.md +55 -0
- package/schedules/triggers/directory-hygiene.md +81 -0
- package/schedules/triggers/dynamic-jobs.md +40 -0
- package/schedules/triggers/inbox-processor.md +115 -0
- package/schedules/triggers/meeting-action-capture.md +60 -0
- package/schedules/triggers/meeting-prep.md +69 -0
- package/schedules/triggers/messaging-inbound.md +50 -0
- package/schedules/triggers/org-pulse.md +24 -0
- package/schedules/triggers/quarterly-self-assessment.md +54 -0
- package/schedules/triggers/weekly-engineering-health.md +37 -0
- package/schedules/triggers/weekly-execution.md +65 -0
- package/schedules/triggers/weekly-hiring.md +53 -0
- package/schedules/triggers/weekly-priorities.md +38 -0
- package/schedules/triggers/weekly-strategic-memo.md +124 -0
- package/scripts/archive-email.sh +55 -0
- package/scripts/cadence/cadence-status.mjs +36 -0
- package/scripts/cadence/enqueue-cadence-tick.mjs +174 -0
- package/scripts/cadence/enqueue-cadence-tick.test.mjs +187 -0
- package/scripts/cadence/launchd-cadence-wrapper.sh +85 -0
- package/scripts/cadence/launchd-cloud-relay-wrapper.sh +95 -0
- package/scripts/cadence/launchd-socket-mode-wrapper.sh +95 -0
- package/scripts/ci/check-docs-accuracy.mjs +493 -0
- package/scripts/ci/check-docs-accuracy.test.mjs +409 -0
- package/scripts/ci/check-exports-exist.mjs +140 -0
- package/scripts/ci/check-files-exist.mjs +107 -0
- package/scripts/ci/check-no-build-artifacts.mjs +111 -0
- package/scripts/ci/check-no-build-artifacts.test.mjs +71 -0
- package/scripts/ci/check-no-confidential.mjs +198 -0
- package/scripts/ci/check-no-conflict-markers.mjs +169 -0
- package/scripts/ci/check-no-residual-identity.mjs +163 -0
- package/scripts/ci/check-no-residual-identity.test.mjs +89 -0
- package/scripts/ci/check-tarball-fidelity.mjs +205 -0
- package/scripts/ci/check-unresolved-tokens.mjs +83 -0
- package/scripts/ci/check.mjs +109 -0
- package/scripts/ci/check.test.mjs +194 -0
- package/scripts/ci/run-coverage.mjs +82 -0
- package/scripts/ci/run-tests.mjs +71 -0
- package/scripts/cloud-relay/README.md +59 -0
- package/scripts/cloud-relay/index.mjs +233 -0
- package/scripts/cloud-relay/package.json +15 -0
- package/scripts/cloud-relay/railway.json +13 -0
- package/scripts/cloud-relay/voice/README.md +94 -0
- package/scripts/cloud-relay/voice/package-lock.json +39 -0
- package/scripts/cloud-relay/voice/package.json +16 -0
- package/scripts/cloud-relay/voice/railway.json +13 -0
- package/scripts/cloud-relay/voice/server.mjs +532 -0
- package/scripts/collective/hook-runner.mjs +211 -0
- package/scripts/collective/hook-runner.test.mjs +90 -0
- package/scripts/collective/org-pulse.mjs +72 -0
- package/scripts/collective/org-sync.mjs +61 -0
- package/scripts/collective/recall.mjs +45 -0
- package/scripts/collective/who.mjs +30 -0
- package/scripts/comms-monitor.sh +288 -0
- package/scripts/configure-whatsapp-sandbox.sh +201 -0
- package/scripts/continuous-monitor.sh +91 -0
- package/scripts/cost/fleet-digest.mjs +407 -0
- package/scripts/cost/fleet-digest.test.mjs +207 -0
- package/scripts/cost/track-claude-usage.mjs +169 -0
- package/scripts/daemon/agent-daemon.mjs +989 -0
- package/scripts/daemon/agent-daemon.test.mjs +525 -0
- package/scripts/daemon/cadence-consumer-governance.test.mjs +220 -0
- package/scripts/daemon/cadence-consumer.mjs +1080 -0
- package/scripts/daemon/cadence-consumer.test.mjs +770 -0
- package/scripts/daemon/cadence-handlers.mjs +1121 -0
- package/scripts/daemon/cadence-handlers.test.mjs +617 -0
- package/scripts/daemon/classifier.mjs +704 -0
- package/scripts/daemon/classifier.test.mjs +238 -0
- package/scripts/daemon/classify-kind.mjs +54 -0
- package/scripts/daemon/classify-kind.test.mjs +40 -0
- package/scripts/daemon/context-compiler.mjs +605 -0
- package/scripts/daemon/context-compiler.test.mjs +300 -0
- package/scripts/daemon/dispatcher-cooldown.test.mjs +122 -0
- package/scripts/daemon/dispatcher-governance.test.mjs +886 -0
- package/scripts/daemon/dispatcher.mjs +1516 -0
- package/scripts/daemon/health.mjs +72 -0
- package/scripts/daemon/inbox-deferral.mjs +210 -0
- package/scripts/daemon/inbox-deferral.test.mjs +242 -0
- package/scripts/daemon/integration.test.mjs +149 -0
- package/scripts/daemon/launchd-wrapper-generic.sh +96 -0
- package/scripts/daemon/launchd-wrapper-slack-events.sh +37 -0
- package/scripts/daemon/launchd-wrapper.sh +91 -0
- package/scripts/daemon/lib/session-router.mjs +274 -0
- package/scripts/daemon/lib/session-router.test.mjs +295 -0
- package/scripts/daemon/maestro-daemon.mjs +275 -0
- package/scripts/daemon/prompt-builder.mjs +685 -0
- package/scripts/daemon/prompt-builder.test.mjs +213 -0
- package/scripts/daemon/responder.mjs +854 -0
- package/scripts/daemon/session-lock.mjs +721 -0
- package/scripts/daemon/session-lock.test.mjs +252 -0
- package/scripts/daemon/session-outcomes.mjs +640 -0
- package/scripts/daemon/session-outcomes.test.mjs +533 -0
- package/scripts/daemon/typing-registry.mjs +90 -0
- package/scripts/daemon/typing-registry.test.mjs +77 -0
- package/scripts/daemon/voice-webhook-server.mjs +804 -0
- package/scripts/decisions/capture-decision.mjs +116 -0
- package/scripts/disclosure_assessment.py +873 -0
- package/scripts/disclosure_boundaries.py +562 -0
- package/scripts/email-signature-principal.html +52 -0
- package/scripts/email-signature.html +60 -0
- package/scripts/email_quote_thread.py +167 -0
- package/scripts/email_thread_dedup.py +362 -0
- package/scripts/emergency-stop.sh +81 -0
- package/scripts/healthcheck.sh +116 -0
- package/scripts/hooks/block-mcp-cohort-send.sh +15 -0
- package/scripts/hooks/block-mcp-slack-send.sh +7 -0
- package/scripts/hooks/post-action-log.sh +126 -0
- package/scripts/hooks/pre-send-audit.sh +174 -0
- package/scripts/hooks/pre-send-audit.test.mjs +215 -0
- package/scripts/hooks/session-end-log.sh +27 -0
- package/scripts/hooks/session-start-banner.sh +115 -0
- package/scripts/huddle/audio-bridge.mjs +664 -0
- package/scripts/huddle/boot-slack-cdp.sh +102 -0
- package/scripts/huddle/huddle-controller.mjs +942 -0
- package/scripts/huddle/huddle-server.mjs +1229 -0
- package/scripts/huddle/launch-slack.sh +232 -0
- package/scripts/huddle/openai-realtime-bridge.mjs +462 -0
- package/scripts/huddle/package-lock.json +62 -0
- package/scripts/huddle/package.json +22 -0
- package/scripts/huddle/setup-audio.sh +239 -0
- package/scripts/huddle/start-call.mjs +318 -0
- package/scripts/huddle/test-pipeline.mjs +263 -0
- package/scripts/learning/consolidate-skills.mjs +72 -0
- package/scripts/learning/session-search.mjs +125 -0
- package/scripts/llm_email_dedup.py +442 -0
- package/scripts/local-triggers/generate-plists.sh +432 -0
- package/scripts/local-triggers/generate-plists.test.mjs +413 -0
- package/scripts/local-triggers/install-all.sh +49 -0
- package/scripts/local-triggers/plists/.gitkeep +0 -0
- package/scripts/local-triggers/run-trigger.sh +63 -0
- package/scripts/local-triggers/templates/rag-reindex.plist.template +47 -0
- package/scripts/local-triggers/templates/voice-relay-poller.plist.template +54 -0
- package/scripts/local-triggers/templates/voice-tunnel.plist.template +55 -0
- package/scripts/local-triggers/templates/voice-webhook.plist.template +51 -0
- package/scripts/maintenance/backup-to-cloud.sh +124 -0
- package/scripts/maintenance/health-check.sh +377 -0
- package/scripts/media-generation/README.md +105 -0
- package/scripts/media-generation/gemini-image-client.mjs +173 -0
- package/scripts/media-generation/generate-assets.mjs +289 -0
- package/scripts/media-generation/veo-video-client.mjs +219 -0
- package/scripts/org/send-orgmail.mjs +227 -0
- package/scripts/outbound-dedup-cleanup.sh +43 -0
- package/scripts/outbound-dedup.sh +477 -0
- package/scripts/outbound_dedup.py +115 -0
- package/scripts/parse-voice-transcript.mjs +481 -0
- package/scripts/pdf-generation/README.md +63 -0
- package/scripts/pdf-generation/build-document.mjs +247 -0
- package/scripts/pdf-generation/templates/board-pack.latex +136 -0
- package/scripts/pdf-generation/templates/corporate-letter.latex +126 -0
- package/scripts/pdf-generation/templates/memo.latex +114 -0
- package/scripts/poll-slack-events.sh +35 -0
- package/scripts/poller/calendar-poller.mjs +12 -0
- package/scripts/poller/gmail-poller.mjs +192 -0
- package/scripts/poller/imap-client.mjs +289 -0
- package/scripts/poller/inbox-scan-poller.mjs +156 -0
- package/scripts/poller/inbox-scan-poller.test.mjs +231 -0
- package/scripts/poller/index.mjs +73 -0
- package/scripts/poller/intra-session-check.mjs +285 -0
- package/scripts/poller/lib/cloud-relay-dedup.mjs +88 -0
- package/scripts/poller/lib/cloud-relay-dedup.test.mjs +133 -0
- package/scripts/poller/lib/slash-command-handlers.mjs +177 -0
- package/scripts/poller/secondary-gmail-poller.mjs +132 -0
- package/scripts/poller/slack-cloud-relay-client.mjs +368 -0
- package/scripts/poller/slack-poller.mjs +854 -0
- package/scripts/poller/slack-socket-mode.mjs +917 -0
- package/scripts/poller/slack-socket-mode.test.mjs +753 -0
- package/scripts/poller/trigger.mjs +75 -0
- package/scripts/poller/utils.mjs +371 -0
- package/scripts/poller/voice-cloud-relay-client.mjs +179 -0
- package/scripts/poller/voice-poller.mjs +236 -0
- package/scripts/poller-launchd/install.sh +66 -0
- package/scripts/poller-launchd/poller.plist.template +40 -0
- package/scripts/poller-launchd/whatsapp-handler.plist.template +39 -0
- package/scripts/post-interaction-indexer.py +1598 -0
- package/scripts/pre-draft-context.py +994 -0
- package/scripts/pre_draft_lookup.py +258 -0
- package/scripts/rag/build-index.mjs +47 -0
- package/scripts/rag/ingest.mjs +111 -0
- package/scripts/rag/search.mjs +119 -0
- package/scripts/rag-indexer.py +629 -0
- package/scripts/restore-from-backup.sh +248 -0
- package/scripts/restore-from-backup.test.mjs +178 -0
- package/scripts/resume-operations.sh +80 -0
- package/scripts/search-secondary-inbox.py +181 -0
- package/scripts/secondary-inbox-poller.py +437 -0
- package/scripts/self-optimization/compute-metrics.py +398 -0
- package/scripts/send-email-as-principal.py +369 -0
- package/scripts/send-email-threaded.py +392 -0
- package/scripts/send-email-with-attachment.py +377 -0
- package/scripts/send-email.sh +131 -0
- package/scripts/send-sms.sh +175 -0
- package/scripts/send-whatsapp.sh +292 -0
- package/scripts/session-start.sh +106 -0
- package/scripts/setup/boot-claude-session.sh +94 -0
- package/scripts/setup/configure-macos.sh +674 -0
- package/scripts/setup/configure-twilio-sip-trunk.mjs +207 -0
- package/scripts/setup/configure-voice-tunnel.mjs +182 -0
- package/scripts/setup/generate-agent-env.mjs +92 -0
- package/scripts/setup/generate-agent-package-json.mjs +222 -0
- package/scripts/setup/generate-agent-package-json.test.mjs +143 -0
- package/scripts/setup/generate-autonomy.mjs +60 -0
- package/scripts/setup/generate-backlog.mjs +101 -0
- package/scripts/setup/generate-cadences.mjs +92 -0
- package/scripts/setup/generate-capability.mjs +162 -0
- package/scripts/setup/generate-charter.mjs +76 -0
- package/scripts/setup/generate-comms.mjs +59 -0
- package/scripts/setup/generate-company.mjs +92 -0
- package/scripts/setup/init-agent.sh +547 -0
- package/scripts/setup/init-agent.test.mjs +151 -0
- package/scripts/setup/init-archetype.mjs +74 -0
- package/scripts/setup/init-backup.mjs +54 -0
- package/scripts/setup/init-cadence-bus.mjs +60 -0
- package/scripts/setup/init-channel-bus.mjs +46 -0
- package/scripts/setup/init-cost-tracking.mjs +45 -0
- package/scripts/setup/init-decision-capture.mjs +66 -0
- package/scripts/setup/init-known-agents.mjs +57 -0
- package/scripts/setup/init-learning.mjs +70 -0
- package/scripts/setup/init-memory-executive.mjs +45 -0
- package/scripts/setup/init-model-router.mjs +124 -0
- package/scripts/setup/init-rag.mjs +174 -0
- package/scripts/setup/init-session-router.mjs +38 -0
- package/scripts/setup/init-slack-socket-mode.mjs +260 -0
- package/scripts/setup/init-telegram.mjs +165 -0
- package/scripts/setup/init-voice-realtime.mjs +204 -0
- package/scripts/setup/init-whatsapp-baileys.mjs +77 -0
- package/scripts/setup/install-dev-tools.sh +150 -0
- package/scripts/setup/lib/install-plist.mjs +95 -0
- package/scripts/setup/migrate-agent-to-sot.mjs +192 -0
- package/scripts/setup/render-environment-yaml.mjs +133 -0
- package/scripts/slack-events-ctl.sh +177 -0
- package/scripts/slack-events-server.mjs +1045 -0
- package/scripts/slack-react.mjs +89 -0
- package/scripts/slack-responded.sh +232 -0
- package/scripts/slack-send.sh +287 -0
- package/scripts/slack-typing.mjs +196 -0
- package/scripts/slack-upload-v2.py +95 -0
- package/scripts/sms-handler.mjs +450 -0
- package/scripts/spawn-session.sh +120 -0
- package/scripts/sync-protocol.mjs +217 -0
- package/scripts/system-verify.sh +184 -0
- package/scripts/test-email-thread-dedup.py +239 -0
- package/scripts/test-information-barriers.py +484 -0
- package/scripts/test-llm-email-dedup.py +251 -0
- package/scripts/test-pre-draft-integration.py +203 -0
- package/scripts/test-rag-phase2.sh +442 -0
- package/scripts/test-rag-search.sh +251 -0
- package/scripts/test-voice-parser.mjs +316 -0
- package/scripts/user-context-search.py +659 -0
- package/scripts/validate_outbound.py +1504 -0
- package/scripts/watchdog/ai.maestro.memory-watchdog.plist +41 -0
- package/scripts/watchdog/force-reboot.sh +157 -0
- package/scripts/watchdog/memory-watchdog.sh +473 -0
- package/scripts/whatsapp-handler.mjs +538 -0
- package/teams/desktop-operations.yaml +34 -0
- package/teams/executive-office.yaml +27 -0
- package/teams/legal-and-regulatory.yaml +24 -0
- package/teams/platform-and-engineering.yaml +23 -0
- package/teams/strategy-and-growth.yaml +29 -0
- package/workflows/continuous/backlog-executor.yaml +141 -0
- package/workflows/continuous/inbound-monitor.yaml +168 -0
- package/workflows/daily/applicant-triage.yaml +197 -0
- package/workflows/daily/comms-triage.yaml +80 -0
- package/workflows/daily/evening-wrap.yaml +105 -0
- package/workflows/daily/morning-brief.yaml +164 -0
- package/workflows/daily/slack-followup-sweep.yaml +87 -0
- package/workflows/event-driven/README.md +50 -0
- package/workflows/event-driven/agent-failure-investigation.yaml +137 -0
- package/workflows/event-driven/pr-review.yaml +107 -0
- package/workflows/monthly/board-readiness.yaml +76 -0
- package/workflows/quarterly/strategic-scenario-analysis.yaml +85 -0
- package/workflows/session-protocol.md +171 -0
- package/workflows/weekly/engineering-health.yaml +154 -0
- package/workflows/weekly/hiring-review.yaml +169 -0
- package/workflows/weekly/rollup-pipeline-review.yaml +76 -0
- 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
|
+
}
|