@tenex-chat/backend 0.9.1

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 (427) hide show
  1. package/README.md +194 -0
  2. package/dist/backend-wrapper.cjs +3 -0
  3. package/dist/src/index.js +331928 -0
  4. package/package.json +103 -0
  5. package/src/agents/AgentRegistry.ts +418 -0
  6. package/src/agents/AgentStorage.ts +1133 -0
  7. package/src/agents/ConfigResolver.ts +229 -0
  8. package/src/agents/agent-installer.ts +236 -0
  9. package/src/agents/agent-loader.ts +241 -0
  10. package/src/agents/constants.ts +82 -0
  11. package/src/agents/errors.ts +48 -0
  12. package/src/agents/execution/AgentExecutor.ts +561 -0
  13. package/src/agents/execution/ExecutionContextFactory.ts +112 -0
  14. package/src/agents/execution/MessageCompiler.ts +597 -0
  15. package/src/agents/execution/MessageSyncer.ts +100 -0
  16. package/src/agents/execution/PostCompletionChecker.ts +278 -0
  17. package/src/agents/execution/ProgressMonitor.ts +50 -0
  18. package/src/agents/execution/RALResolver.ts +177 -0
  19. package/src/agents/execution/SessionManager.ts +181 -0
  20. package/src/agents/execution/StreamCallbacks.ts +312 -0
  21. package/src/agents/execution/StreamExecutionHandler.ts +579 -0
  22. package/src/agents/execution/StreamSetup.ts +313 -0
  23. package/src/agents/execution/ToolEventHandlers.ts +239 -0
  24. package/src/agents/execution/ToolExecutionTracker.ts +498 -0
  25. package/src/agents/execution/ToolResultUtils.ts +97 -0
  26. package/src/agents/execution/ToolSupervisionWrapper.ts +174 -0
  27. package/src/agents/execution/constants.ts +16 -0
  28. package/src/agents/execution/index.ts +3 -0
  29. package/src/agents/execution/types.ts +96 -0
  30. package/src/agents/execution/utils.ts +26 -0
  31. package/src/agents/index.ts +4 -0
  32. package/src/agents/script-installer.ts +266 -0
  33. package/src/agents/supervision/SupervisorLLMService.ts +253 -0
  34. package/src/agents/supervision/SupervisorOrchestrator.ts +471 -0
  35. package/src/agents/supervision/heuristics/ConsecutiveToolsWithoutTodoHeuristic.ts +73 -0
  36. package/src/agents/supervision/heuristics/DelegationClaimHeuristic.ts +80 -0
  37. package/src/agents/supervision/heuristics/HeuristicRegistry.ts +114 -0
  38. package/src/agents/supervision/heuristics/PendingTodosHeuristic.ts +93 -0
  39. package/src/agents/supervision/heuristics/SilentAgentHeuristic.ts +54 -0
  40. package/src/agents/supervision/heuristics/index.ts +5 -0
  41. package/src/agents/supervision/index.ts +28 -0
  42. package/src/agents/supervision/registerHeuristics.ts +110 -0
  43. package/src/agents/supervision/supervisionHealthCheck.ts +123 -0
  44. package/src/agents/supervision/types.ts +171 -0
  45. package/src/agents/tool-names.ts +46 -0
  46. package/src/agents/tool-normalization.ts +184 -0
  47. package/src/agents/types/index.ts +2 -0
  48. package/src/agents/types/runtime.ts +74 -0
  49. package/src/agents/types/storage.ts +145 -0
  50. package/src/commands/agent/import/index.ts +6 -0
  51. package/src/commands/agent/import/openclaw-distiller.ts +57 -0
  52. package/src/commands/agent/import/openclaw-reader.ts +141 -0
  53. package/src/commands/agent/import/openclaw.ts +154 -0
  54. package/src/commands/agent/index.ts +6 -0
  55. package/src/commands/agent.ts +215 -0
  56. package/src/commands/daemon.ts +198 -0
  57. package/src/commands/doctor.ts +134 -0
  58. package/src/commands/setup/embed.ts +228 -0
  59. package/src/commands/setup/global-system-prompt.ts +223 -0
  60. package/src/commands/setup/image.ts +179 -0
  61. package/src/commands/setup/index.ts +16 -0
  62. package/src/commands/setup/interactive.ts +95 -0
  63. package/src/commands/setup/llm.ts +38 -0
  64. package/src/commands/setup/onboarding.ts +294 -0
  65. package/src/commands/setup/providers.ts +27 -0
  66. package/src/constants.ts +34 -0
  67. package/src/conversations/ConversationDiskReader.ts +148 -0
  68. package/src/conversations/ConversationRegistry.ts +728 -0
  69. package/src/conversations/ConversationStore.ts +868 -0
  70. package/src/conversations/MessageBuilder.ts +866 -0
  71. package/src/conversations/executionTime.ts +62 -0
  72. package/src/conversations/formatters/DelegationXmlFormatter.ts +64 -0
  73. package/src/conversations/formatters/ThreadedConversationFormatter.ts +303 -0
  74. package/src/conversations/formatters/index.ts +9 -0
  75. package/src/conversations/formatters/utils/MessageFormatter.ts +46 -0
  76. package/src/conversations/formatters/utils/TimestampFormatter.ts +56 -0
  77. package/src/conversations/formatters/utils/TreeBuilder.ts +131 -0
  78. package/src/conversations/formatters/utils/TreeRenderer.ts +49 -0
  79. package/src/conversations/index.ts +2 -0
  80. package/src/conversations/persistence/ToolMessageStorage.ts +143 -0
  81. package/src/conversations/search/ConversationIndexManager.ts +393 -0
  82. package/src/conversations/search/QueryParser.ts +114 -0
  83. package/src/conversations/search/SearchEngine.ts +175 -0
  84. package/src/conversations/search/SnippetExtractor.ts +345 -0
  85. package/src/conversations/search/embeddings/ConversationEmbeddingService.ts +484 -0
  86. package/src/conversations/search/embeddings/ConversationIndexingJob.ts +320 -0
  87. package/src/conversations/search/embeddings/IndexingStateManager.ts +338 -0
  88. package/src/conversations/search/embeddings/index.ts +18 -0
  89. package/src/conversations/search/index.ts +49 -0
  90. package/src/conversations/search/types.ts +124 -0
  91. package/src/conversations/services/CategoryManager.ts +160 -0
  92. package/src/conversations/services/ConversationResolver.ts +296 -0
  93. package/src/conversations/services/ConversationSummarizer.ts +234 -0
  94. package/src/conversations/services/MetadataDebounceManager.ts +188 -0
  95. package/src/conversations/services/index.ts +2 -0
  96. package/src/conversations/types.ts +148 -0
  97. package/src/conversations/utils/content-utils.ts +69 -0
  98. package/src/conversations/utils/image-placeholder.ts +281 -0
  99. package/src/conversations/utils/image-url-utils.ts +171 -0
  100. package/src/conversations/utils/multimodal-content.ts +90 -0
  101. package/src/conversations/utils/tool-result-truncator.ts +159 -0
  102. package/src/daemon/Daemon.ts +1883 -0
  103. package/src/daemon/ProjectRuntime.ts +657 -0
  104. package/src/daemon/RestartState.ts +152 -0
  105. package/src/daemon/RuntimeLifecycle.ts +268 -0
  106. package/src/daemon/SubscriptionManager.ts +305 -0
  107. package/src/daemon/UnixSocketTransport.ts +318 -0
  108. package/src/daemon/filters/SubscriptionFilterBuilder.ts +119 -0
  109. package/src/daemon/index.ts +9 -0
  110. package/src/daemon/routing/DaemonRouter.ts +491 -0
  111. package/src/daemon/types.ts +150 -0
  112. package/src/daemon/utils/routing-log.ts +76 -0
  113. package/src/daemon/utils/telemetry.ts +173 -0
  114. package/src/event-handler/agentDeletion.ts +383 -0
  115. package/src/event-handler/index.ts +749 -0
  116. package/src/event-handler/newConversation.ts +165 -0
  117. package/src/event-handler/project.ts +166 -0
  118. package/src/event-handler/reply.ts +18 -0
  119. package/src/events/NDKAgentDefinition.ts +292 -0
  120. package/src/events/NDKAgentLesson.ts +106 -0
  121. package/src/events/NDKEventMetadata.ts +34 -0
  122. package/src/events/NDKMCPTool.ts +60 -0
  123. package/src/events/NDKProjectStatus.ts +384 -0
  124. package/src/events/index.ts +4 -0
  125. package/src/index.ts +126 -0
  126. package/src/lib/agent-home.ts +334 -0
  127. package/src/lib/error-formatter.ts +200 -0
  128. package/src/lib/fs/filesystem.ts +128 -0
  129. package/src/lib/fs/index.ts +1 -0
  130. package/src/lib/json-parser.ts +30 -0
  131. package/src/lib/string.ts +15 -0
  132. package/src/lib/time.ts +74 -0
  133. package/src/llm/ChunkHandler.ts +277 -0
  134. package/src/llm/FinishHandler.ts +250 -0
  135. package/src/llm/LLMConfigEditor.ts +154 -0
  136. package/src/llm/LLMServiceFactory.ts +230 -0
  137. package/src/llm/MessageProcessor.ts +90 -0
  138. package/src/llm/RecordingState.ts +37 -0
  139. package/src/llm/StreamPublisher.ts +40 -0
  140. package/src/llm/TracingUtils.ts +77 -0
  141. package/src/llm/chunk-validators.ts +57 -0
  142. package/src/llm/constants.ts +6 -0
  143. package/src/llm/index.ts +12 -0
  144. package/src/llm/meta/MetaModelResolver.ts +352 -0
  145. package/src/llm/meta/index.ts +11 -0
  146. package/src/llm/middleware/flight-recorder.ts +188 -0
  147. package/src/llm/providers/MockProvider.ts +332 -0
  148. package/src/llm/providers/agent/ClaudeCodeProvider.ts +343 -0
  149. package/src/llm/providers/agent/ClaudeCodeToolsAdapter.ts +203 -0
  150. package/src/llm/providers/agent/CodexAppServerProvider.ts +214 -0
  151. package/src/llm/providers/agent/CodexAppServerToolsAdapter.ts +91 -0
  152. package/src/llm/providers/agent/index.ts +10 -0
  153. package/src/llm/providers/base/AgentProvider.ts +107 -0
  154. package/src/llm/providers/base/BaseProvider.ts +114 -0
  155. package/src/llm/providers/base/StandardProvider.ts +38 -0
  156. package/src/llm/providers/base/index.ts +9 -0
  157. package/src/llm/providers/index.ts +106 -0
  158. package/src/llm/providers/key-manager.ts +238 -0
  159. package/src/llm/providers/ollama-models.ts +105 -0
  160. package/src/llm/providers/openrouter-models.ts +102 -0
  161. package/src/llm/providers/provider-ids.ts +18 -0
  162. package/src/llm/providers/registry/ProviderRegistry.ts +414 -0
  163. package/src/llm/providers/registry/index.ts +7 -0
  164. package/src/llm/providers/standard/AnthropicProvider.ts +71 -0
  165. package/src/llm/providers/standard/OllamaProvider.ts +59 -0
  166. package/src/llm/providers/standard/OpenAIProvider.ts +44 -0
  167. package/src/llm/providers/standard/OpenRouterProvider.ts +103 -0
  168. package/src/llm/providers/standard/index.ts +10 -0
  169. package/src/llm/providers/types.ts +194 -0
  170. package/src/llm/providers/usage-metadata.ts +78 -0
  171. package/src/llm/service.ts +713 -0
  172. package/src/llm/types.ts +167 -0
  173. package/src/llm/utils/ConfigurationManager.ts +650 -0
  174. package/src/llm/utils/ConfigurationTester.ts +229 -0
  175. package/src/llm/utils/ModelSelector.ts +212 -0
  176. package/src/llm/utils/ProviderConfigUI.ts +177 -0
  177. package/src/llm/utils/claudeCodePromptCompiler.ts +141 -0
  178. package/src/llm/utils/codex-models.ts +53 -0
  179. package/src/llm/utils/context-window-cache.ts +30 -0
  180. package/src/llm/utils/models-dev-cache.ts +267 -0
  181. package/src/llm/utils/provider-setup.ts +50 -0
  182. package/src/llm/utils/tool-errors.ts +78 -0
  183. package/src/llm/utils/usage.ts +74 -0
  184. package/src/logging/EventRoutingLogger.ts +205 -0
  185. package/src/nostr/AgentEventDecoder.ts +357 -0
  186. package/src/nostr/AgentEventEncoder.ts +677 -0
  187. package/src/nostr/AgentProfilePublisher.ts +657 -0
  188. package/src/nostr/AgentPublisher.ts +437 -0
  189. package/src/nostr/BlossomService.ts +226 -0
  190. package/src/nostr/InterventionPublisher.ts +132 -0
  191. package/src/nostr/TagExtractor.ts +228 -0
  192. package/src/nostr/collectEvents.ts +83 -0
  193. package/src/nostr/constants.ts +38 -0
  194. package/src/nostr/encryption.ts +26 -0
  195. package/src/nostr/index.ts +31 -0
  196. package/src/nostr/keys.ts +17 -0
  197. package/src/nostr/kinds.ts +37 -0
  198. package/src/nostr/ndkClient.ts +72 -0
  199. package/src/nostr/relays.ts +43 -0
  200. package/src/nostr/trace-context.ts +39 -0
  201. package/src/nostr/types.ts +227 -0
  202. package/src/nostr/utils.ts +84 -0
  203. package/src/prompts/core/FragmentRegistry.ts +30 -0
  204. package/src/prompts/core/PromptBuilder.ts +98 -0
  205. package/src/prompts/core/index.ts +3 -0
  206. package/src/prompts/core/types.ts +13 -0
  207. package/src/prompts/fragments/00-global-system-prompt.ts +44 -0
  208. package/src/prompts/fragments/01-agent-identity.ts +69 -0
  209. package/src/prompts/fragments/02-agent-home-directory.ts +114 -0
  210. package/src/prompts/fragments/03-system-reminders-explanation.ts +14 -0
  211. package/src/prompts/fragments/04-relay-configuration.ts +38 -0
  212. package/src/prompts/fragments/05-delegation-chain.ts +45 -0
  213. package/src/prompts/fragments/06-agent-todos.ts +74 -0
  214. package/src/prompts/fragments/06-todo-usage-guidance.ts +34 -0
  215. package/src/prompts/fragments/07-meta-project-context.ts +234 -0
  216. package/src/prompts/fragments/08-active-conversations.ts +382 -0
  217. package/src/prompts/fragments/09-recent-conversations.ts +153 -0
  218. package/src/prompts/fragments/10-referenced-article.ts +21 -0
  219. package/src/prompts/fragments/11-nudges.ts +134 -0
  220. package/src/prompts/fragments/12-skills.ts +127 -0
  221. package/src/prompts/fragments/13-available-nudges.ts +122 -0
  222. package/src/prompts/fragments/15-available-agents.ts +53 -0
  223. package/src/prompts/fragments/16-stay-in-your-lane.ts +41 -0
  224. package/src/prompts/fragments/17-todo-before-delegation.ts +39 -0
  225. package/src/prompts/fragments/20-voice-mode.ts +62 -0
  226. package/src/prompts/fragments/22-scheduled-tasks.ts +175 -0
  227. package/src/prompts/fragments/24-retrieved-lessons.ts +26 -0
  228. package/src/prompts/fragments/25-rag-instructions.ts +333 -0
  229. package/src/prompts/fragments/26-mcp-resources.ts +237 -0
  230. package/src/prompts/fragments/27-memorized-reports.ts +77 -0
  231. package/src/prompts/fragments/28-agent-directed-monitoring.ts +32 -0
  232. package/src/prompts/fragments/29-rag-collections.ts +50 -0
  233. package/src/prompts/fragments/30-worktree-context.ts +98 -0
  234. package/src/prompts/fragments/31-agents-md-guidance.ts +96 -0
  235. package/src/prompts/fragments/32-process-metrics.ts +72 -0
  236. package/src/prompts/fragments/debug-mode.ts +48 -0
  237. package/src/prompts/fragments/delegation-completion.ts +44 -0
  238. package/src/prompts/fragments/index.ts +91 -0
  239. package/src/prompts/index.ts +21 -0
  240. package/src/prompts/utils/systemPromptBuilder.ts +777 -0
  241. package/src/scripts/migrate-prefix-index.ts +157 -0
  242. package/src/services/AgentDefinitionMonitor.ts +701 -0
  243. package/src/services/ConfigService.ts +723 -0
  244. package/src/services/CooldownRegistry.ts +199 -0
  245. package/src/services/LLMOperationsRegistry.ts +424 -0
  246. package/src/services/OwnerAgentListService.ts +354 -0
  247. package/src/services/PubkeyService.ts +308 -0
  248. package/src/services/agents/AgentMetadataStore.ts +72 -0
  249. package/src/services/agents/AgentResolution.ts +59 -0
  250. package/src/services/agents/EscalationService.ts +281 -0
  251. package/src/services/agents/NDKAgentDiscovery.ts +95 -0
  252. package/src/services/agents/index.ts +7 -0
  253. package/src/services/agents-md/AgentsMdService.ts +184 -0
  254. package/src/services/agents-md/SystemReminderInjector.ts +238 -0
  255. package/src/services/agents-md/index.ts +11 -0
  256. package/src/services/apns/APNsClient.ts +203 -0
  257. package/src/services/apns/APNsService.ts +358 -0
  258. package/src/services/apns/index.ts +11 -0
  259. package/src/services/apns/types.ts +80 -0
  260. package/src/services/compression/CompressionService.ts +445 -0
  261. package/src/services/compression/compression-schema.ts +28 -0
  262. package/src/services/compression/compression-types.ts +74 -0
  263. package/src/services/compression/compression-utils.ts +587 -0
  264. package/src/services/config/types.ts +394 -0
  265. package/src/services/dispatch/AgentDispatchService.ts +937 -0
  266. package/src/services/dispatch/AgentRouter.ts +181 -0
  267. package/src/services/dispatch/DelegationCompletionHandler.ts +232 -0
  268. package/src/services/embedding/EmbeddingProvider.ts +188 -0
  269. package/src/services/embedding/index.ts +5 -0
  270. package/src/services/event-context/EventContextService.ts +108 -0
  271. package/src/services/event-context/index.ts +2 -0
  272. package/src/services/heuristics/ContextBuilder.ts +106 -0
  273. package/src/services/heuristics/HeuristicEngine.ts +200 -0
  274. package/src/services/heuristics/formatters.ts +58 -0
  275. package/src/services/heuristics/index.ts +12 -0
  276. package/src/services/heuristics/rules/index.ts +25 -0
  277. package/src/services/heuristics/rules/todoBeforeDelegation.ts +69 -0
  278. package/src/services/heuristics/rules/todoReminderOnToolUse.ts +63 -0
  279. package/src/services/heuristics/types.ts +144 -0
  280. package/src/services/image/ImageGenerationService.ts +389 -0
  281. package/src/services/image/index.ts +12 -0
  282. package/src/services/intervention/InterventionService.ts +1352 -0
  283. package/src/services/intervention/index.ts +7 -0
  284. package/src/services/mcp/MCPManager.ts +683 -0
  285. package/src/services/mcp/McpNotificationDelivery.ts +139 -0
  286. package/src/services/mcp/McpSubscriptionService.ts +653 -0
  287. package/src/services/mcp/mcpInstaller.ts +130 -0
  288. package/src/services/nip46/Nip46SigningLog.ts +81 -0
  289. package/src/services/nip46/Nip46SigningService.ts +467 -0
  290. package/src/services/nip46/index.ts +4 -0
  291. package/src/services/nudge/NudgeService.ts +224 -0
  292. package/src/services/nudge/NudgeWhitelistService.ts +382 -0
  293. package/src/services/nudge/index.ts +5 -0
  294. package/src/services/nudge/types.ts +83 -0
  295. package/src/services/projects/ProjectContext.ts +672 -0
  296. package/src/services/projects/ProjectContextStore.ts +102 -0
  297. package/src/services/projects/index.ts +6 -0
  298. package/src/services/prompt-compiler/index.ts +15 -0
  299. package/src/services/prompt-compiler/prompt-compiler-service.ts +1143 -0
  300. package/src/services/pubkey-gate/PubkeyGateService.ts +93 -0
  301. package/src/services/pubkey-gate/index.ts +1 -0
  302. package/src/services/rag/EmbeddingProviderFactory.ts +292 -0
  303. package/src/services/rag/LanceDBMaintenanceService.ts +211 -0
  304. package/src/services/rag/RAGDatabaseService.ts +173 -0
  305. package/src/services/rag/RAGOperations.ts +682 -0
  306. package/src/services/rag/RAGService.ts +240 -0
  307. package/src/services/rag/RagSubscriptionService.ts +618 -0
  308. package/src/services/rag/rag-utils.ts +174 -0
  309. package/src/services/ral/PendingDelegationsRegistry.ts +168 -0
  310. package/src/services/ral/RALRegistry.ts +2782 -0
  311. package/src/services/ral/index.ts +4 -0
  312. package/src/services/ral/types.ts +292 -0
  313. package/src/services/reports/LocalReportStore.ts +380 -0
  314. package/src/services/reports/ReportEmbeddingService.ts +430 -0
  315. package/src/services/reports/ReportService.ts +440 -0
  316. package/src/services/reports/articleUtils.ts +52 -0
  317. package/src/services/reports/index.ts +7 -0
  318. package/src/services/scheduling/SchedulerService.ts +1057 -0
  319. package/src/services/scheduling/errors.ts +14 -0
  320. package/src/services/scheduling/index.ts +7 -0
  321. package/src/services/scheduling/utils.ts +77 -0
  322. package/src/services/search/SearchProviderRegistry.ts +78 -0
  323. package/src/services/search/UnifiedSearchService.ts +218 -0
  324. package/src/services/search/index.ts +47 -0
  325. package/src/services/search/projectFilter.ts +22 -0
  326. package/src/services/search/providers/ConversationSearchProvider.ts +48 -0
  327. package/src/services/search/providers/LessonSearchProvider.ts +75 -0
  328. package/src/services/search/providers/ReportSearchProvider.ts +49 -0
  329. package/src/services/search/types.ts +144 -0
  330. package/src/services/skill/SkillService.ts +482 -0
  331. package/src/services/skill/index.ts +2 -0
  332. package/src/services/skill/types.ts +70 -0
  333. package/src/services/status/OperationsStatusService.ts +276 -0
  334. package/src/services/status/ProjectStatusService.ts +522 -0
  335. package/src/services/status/index.ts +11 -0
  336. package/src/services/storage/PrefixKVStore.ts +242 -0
  337. package/src/services/storage/index.ts +1 -0
  338. package/src/services/system-reminder/SystemReminderUtils.ts +96 -0
  339. package/src/services/system-reminder/index.ts +7 -0
  340. package/src/services/trust-pubkeys/TrustPubkeyService.ts +325 -0
  341. package/src/services/trust-pubkeys/index.ts +2 -0
  342. package/src/telemetry/ConversationSpanManager.ts +111 -0
  343. package/src/telemetry/EventLoopMonitor.ts +206 -0
  344. package/src/telemetry/LLMSpanRegistry.ts +20 -0
  345. package/src/telemetry/NostrSpanProcessor.ts +89 -0
  346. package/src/telemetry/ToolCallSpanProcessor.ts +66 -0
  347. package/src/telemetry/diagnostics.ts +27 -0
  348. package/src/telemetry/setup.ts +120 -0
  349. package/src/tools/implementations/agents_discover.ts +121 -0
  350. package/src/tools/implementations/agents_hire.ts +127 -0
  351. package/src/tools/implementations/agents_list.ts +96 -0
  352. package/src/tools/implementations/agents_publish.ts +611 -0
  353. package/src/tools/implementations/agents_read.ts +173 -0
  354. package/src/tools/implementations/agents_write.ts +200 -0
  355. package/src/tools/implementations/ask.ts +411 -0
  356. package/src/tools/implementations/change_model.ts +141 -0
  357. package/src/tools/implementations/conversation_get.ts +661 -0
  358. package/src/tools/implementations/conversation_list.ts +377 -0
  359. package/src/tools/implementations/conversation_search.ts +370 -0
  360. package/src/tools/implementations/delegate.ts +327 -0
  361. package/src/tools/implementations/delegate_crossproject.ts +209 -0
  362. package/src/tools/implementations/delegate_followup.ts +300 -0
  363. package/src/tools/implementations/fs_edit.ts +162 -0
  364. package/src/tools/implementations/fs_glob.ts +182 -0
  365. package/src/tools/implementations/fs_grep.ts +513 -0
  366. package/src/tools/implementations/fs_read.ts +332 -0
  367. package/src/tools/implementations/fs_write.ts +113 -0
  368. package/src/tools/implementations/generate_image.ts +259 -0
  369. package/src/tools/implementations/home_fs.ts +515 -0
  370. package/src/tools/implementations/kill.ts +651 -0
  371. package/src/tools/implementations/learn.ts +166 -0
  372. package/src/tools/implementations/lesson-formatter.ts +38 -0
  373. package/src/tools/implementations/lesson_delete.ts +164 -0
  374. package/src/tools/implementations/lesson_get.ts +105 -0
  375. package/src/tools/implementations/lessons_list.ts +153 -0
  376. package/src/tools/implementations/mcp_resource_read.ts +161 -0
  377. package/src/tools/implementations/mcp_subscribe.ts +158 -0
  378. package/src/tools/implementations/mcp_subscription_stop.ts +85 -0
  379. package/src/tools/implementations/nostr_fetch.ts +149 -0
  380. package/src/tools/implementations/nostr_publish_as_user.ts +353 -0
  381. package/src/tools/implementations/project_list.ts +146 -0
  382. package/src/tools/implementations/rag_add_documents.ts +573 -0
  383. package/src/tools/implementations/rag_create_collection.ts +65 -0
  384. package/src/tools/implementations/rag_delete_collection.ts +68 -0
  385. package/src/tools/implementations/rag_list_collections.ts +77 -0
  386. package/src/tools/implementations/rag_query.ts +107 -0
  387. package/src/tools/implementations/rag_subscription_create.ts +105 -0
  388. package/src/tools/implementations/rag_subscription_delete.ts +80 -0
  389. package/src/tools/implementations/rag_subscription_get.ts +123 -0
  390. package/src/tools/implementations/rag_subscription_list.ts +128 -0
  391. package/src/tools/implementations/report_delete.ts +79 -0
  392. package/src/tools/implementations/report_read.ts +160 -0
  393. package/src/tools/implementations/report_write.ts +278 -0
  394. package/src/tools/implementations/reports_list.ts +77 -0
  395. package/src/tools/implementations/schedule_task.ts +104 -0
  396. package/src/tools/implementations/schedule_task_cancel.ts +62 -0
  397. package/src/tools/implementations/schedule_task_once.ts +128 -0
  398. package/src/tools/implementations/schedule_tasks_list.ts +79 -0
  399. package/src/tools/implementations/search.ts +160 -0
  400. package/src/tools/implementations/shell.ts +553 -0
  401. package/src/tools/implementations/todo.ts +260 -0
  402. package/src/tools/implementations/upload_blob.ts +381 -0
  403. package/src/tools/implementations/web_fetch.ts +153 -0
  404. package/src/tools/implementations/web_search.ts +250 -0
  405. package/src/tools/registry.ts +670 -0
  406. package/src/tools/types.ts +177 -0
  407. package/src/tools/utils.ts +256 -0
  408. package/src/types/event-ids.ts +320 -0
  409. package/src/types/index.ts +46 -0
  410. package/src/utils/agentFetcher.ts +107 -0
  411. package/src/utils/cli-error.ts +29 -0
  412. package/src/utils/conversation-id.ts +27 -0
  413. package/src/utils/conversation-utils.ts +1 -0
  414. package/src/utils/delegation-chain.ts +357 -0
  415. package/src/utils/error-handler.ts +42 -0
  416. package/src/utils/git/gitignore.ts +69 -0
  417. package/src/utils/git/index.ts +2 -0
  418. package/src/utils/git/initializeGitRepo.ts +204 -0
  419. package/src/utils/git/worktree.ts +260 -0
  420. package/src/utils/lessonFormatter.ts +70 -0
  421. package/src/utils/lessonTrust.ts +24 -0
  422. package/src/utils/lockfile.ts +123 -0
  423. package/src/utils/logger.ts +149 -0
  424. package/src/utils/nostr-entity-parser.ts +365 -0
  425. package/src/utils/process.ts +49 -0
  426. package/src/wrapper.ts +262 -0
  427. package/tsconfig.json +41 -0
@@ -0,0 +1,1352 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { InterventionPublisher } from "@/nostr/InterventionPublisher";
4
+ import { config } from "@/services/ConfigService";
5
+ import { PubkeyService } from "@/services/PubkeyService";
6
+ import { getTrustPubkeyService } from "@/services/trust-pubkeys/TrustPubkeyService";
7
+ import { logger } from "@/utils/logger";
8
+ import { trace } from "@opentelemetry/api";
9
+
10
+ /** Default timeout for user response: 5 minutes */
11
+ const DEFAULT_TIMEOUT_MS = 300_000;
12
+
13
+ /** Default conversation inactivity timeout: 2 minutes (in seconds) */
14
+ const DEFAULT_CONVERSATION_INACTIVITY_TIMEOUT_SECONDS = 120;
15
+
16
+ /** Default retry interval for failed publish attempts: 30 seconds */
17
+ const DEFAULT_RETRY_INTERVAL_MS = 30_000;
18
+
19
+ /** Maximum retry attempts before giving up */
20
+ const MAX_RETRY_ATTEMPTS = 5;
21
+
22
+ /** TTL for notified conversation entries: 24 hours */
23
+ const NOTIFIED_TTL_MS = 24 * 60 * 60 * 1000;
24
+
25
+ /**
26
+ * Result of attempting to resolve an agent for a project.
27
+ */
28
+ export type AgentResolutionResult =
29
+ | { status: "resolved"; pubkey: string }
30
+ | { status: "runtime_unavailable" } // Transient: runtime not active yet
31
+ | { status: "agent_not_found" }; // Permanent: agent slug doesn't exist
32
+
33
+ /**
34
+ * Function type for resolving an agent slug to a pubkey for a given project.
35
+ * Returns resolution result indicating success, transient failure, or permanent failure.
36
+ *
37
+ * This abstraction allows InterventionService (Layer 3) to resolve agents
38
+ * without directly depending on @/daemon (Layer 4).
39
+ */
40
+ export type AgentResolverFn = (projectId: string, agentSlug: string) => AgentResolutionResult;
41
+
42
+ /**
43
+ * Function type for checking if a conversation has active outgoing delegations.
44
+ * Returns true if there are pending delegations that haven't completed yet.
45
+ *
46
+ * This abstraction allows InterventionService (Layer 3) to check delegation state
47
+ * without directly depending on RALRegistry internals.
48
+ *
49
+ * @param agentPubkey - The pubkey of the agent that completed
50
+ * @param conversationId - The conversation where the agent completed
51
+ * @returns true if there are active outgoing delegations
52
+ */
53
+ export type ActiveDelegationCheckerFn = (agentPubkey: string, conversationId: string) => boolean;
54
+
55
+ /**
56
+ * Represents a pending intervention - an agent completed work
57
+ * and we're waiting for the user to respond.
58
+ */
59
+ export interface PendingIntervention {
60
+ conversationId: string;
61
+ completedAt: number; // timestamp of completion event (ms)
62
+ agentPubkey: string; // completing agent
63
+ userPubkey: string; // root event author (who we're waiting on)
64
+ projectId: string; // for state scoping (replaces projectPubkey)
65
+ retryCount?: number; // number of retry attempts for failed publishes
66
+ }
67
+
68
+ /**
69
+ * Entry tracking a conversation that has already been notified.
70
+ * Used to prevent duplicate notifications on event re-delivery.
71
+ */
72
+ interface NotifiedEntry {
73
+ conversationId: string;
74
+ notifiedAt: number; // timestamp (ms)
75
+ }
76
+
77
+ /**
78
+ * Persisted state for InterventionService.
79
+ * Stored in ~/.tenex/intervention_state_<projectId>.json (project-scoped)
80
+ */
81
+ interface InterventionState {
82
+ pending: PendingIntervention[];
83
+ notified?: NotifiedEntry[];
84
+ }
85
+
86
+ /**
87
+ * Pending state write operation for serialization
88
+ */
89
+ interface WriteOperation {
90
+ resolve: () => void;
91
+ reject: (error: Error) => void;
92
+ }
93
+
94
+ /**
95
+ * InterventionService monitors for agent work completions and triggers
96
+ * a human-replica review if the user doesn't respond within the configured timeout.
97
+ *
98
+ * Completion detection:
99
+ * - An event is considered a "completion" when a kind:1 event from an agent
100
+ * p-tags the user who authored the root event of the conversation.
101
+ *
102
+ * User response detection:
103
+ * - Only counts if the response is AFTER the completion timestamp.
104
+ * - Cancels the pending intervention timer.
105
+ *
106
+ * When timeout expires:
107
+ * - Publishes a review request event to the configured intervention agent.
108
+ */
109
+ export class InterventionService {
110
+ private static instance: InterventionService | null = null;
111
+
112
+ private publisher: InterventionPublisher;
113
+ private pendingInterventions: Map<string, PendingIntervention> = new Map();
114
+ private timers: Map<string, NodeJS.Timeout> = new Map();
115
+ private configDir: string;
116
+ private currentProjectId: string | null = null;
117
+
118
+ // Tracks conversations that have already been notified (prevents duplicates)
119
+ private notifiedConversations: Map<string, number> = new Map();
120
+
121
+ // Guards against concurrent triggerIntervention() calls for the same conversation
122
+ private triggeringConversations: Set<string> = new Set();
123
+
124
+ // Agent slug for resolution (resolved per-project at trigger time)
125
+ private interventionAgentSlug: string | null = null;
126
+
127
+ // Injected resolver function for Layer 3/4 decoupling
128
+ private agentResolver: AgentResolverFn | null = null;
129
+
130
+ // Injected checker for active delegations (to prevent premature intervention notifications)
131
+ private activeDelegationChecker: ActiveDelegationCheckerFn | null = null;
132
+
133
+ private timeoutMs: number = DEFAULT_TIMEOUT_MS;
134
+ private conversationInactivityTimeoutSeconds: number = DEFAULT_CONVERSATION_INACTIVITY_TIMEOUT_SECONDS;
135
+ private enabled = false;
136
+ private initialized = false;
137
+
138
+ // Serialized write queue for state persistence
139
+ private writeQueue: WriteOperation[] = [];
140
+ private isWriting = false;
141
+
142
+ // Guards against loadState() racing with onAgentCompletion()
143
+ private stateLoadPromise: Promise<void> | null = null;
144
+ // Pending completion operations queued during state load
145
+ private pendingCompletionOps: Array<() => void> = [];
146
+
147
+ private constructor() {
148
+ this.publisher = new InterventionPublisher();
149
+ this.configDir = config.getConfigPath();
150
+ }
151
+
152
+ /**
153
+ * Set the agent resolver function.
154
+ * This allows Layer 4 (daemon) to inject its resolver without
155
+ * creating a compile-time dependency from Layer 3 to Layer 4.
156
+ *
157
+ * Must be called before processing any completions.
158
+ * Typically called during daemon initialization.
159
+ */
160
+ public setAgentResolver(resolver: AgentResolverFn): void {
161
+ this.agentResolver = resolver;
162
+ }
163
+
164
+ /**
165
+ * Set the active delegation checker function.
166
+ * This allows checking if a conversation has outgoing delegations that are still running.
167
+ *
168
+ * When an agent completes work but has active delegations, we should NOT trigger
169
+ * an intervention notification - the delegation tree is still in progress.
170
+ *
171
+ * @param checker - Function that checks for active delegations
172
+ */
173
+ public setActiveDelegationChecker(checker: ActiveDelegationCheckerFn): void {
174
+ this.activeDelegationChecker = checker;
175
+ }
176
+
177
+ /**
178
+ * Get the state file path for a given project.
179
+ * State files are scoped by project ID.
180
+ */
181
+ private getStateFilePath(projectId: string): string {
182
+ return path.join(this.configDir, `intervention_state_${projectId}.json`);
183
+ }
184
+
185
+ /**
186
+ * Get the singleton instance of InterventionService.
187
+ */
188
+ public static getInstance(): InterventionService {
189
+ if (!InterventionService.instance) {
190
+ InterventionService.instance = new InterventionService();
191
+ }
192
+ return InterventionService.instance;
193
+ }
194
+
195
+ /**
196
+ * Reset the singleton instance (useful for testing).
197
+ */
198
+ public static async resetInstance(): Promise<void> {
199
+ if (InterventionService.instance) {
200
+ await InterventionService.instance.shutdown();
201
+ }
202
+ InterventionService.instance = null;
203
+ }
204
+
205
+ /**
206
+ * Initialize the service.
207
+ * - Loads configuration
208
+ * - Stores the agent slug for lazy resolution (resolved on first completion event)
209
+ * - Note: State loading is deferred until setProject() is called
210
+ */
211
+ public async initialize(): Promise<void> {
212
+ if (this.initialized) {
213
+ logger.warn("InterventionService already initialized");
214
+ return;
215
+ }
216
+
217
+ const tenexConfig = config.getConfig();
218
+ const interventionConfig = tenexConfig.intervention;
219
+
220
+ // Check if intervention is enabled
221
+ if (!interventionConfig?.enabled) {
222
+ logger.debug("InterventionService disabled (intervention.enabled is false or not set)");
223
+ this.enabled = false;
224
+ this.initialized = true;
225
+ return;
226
+ }
227
+
228
+ // Agent slug is required if enabled
229
+ const agentSlug = interventionConfig.agent?.trim();
230
+ if (!agentSlug) {
231
+ logger.error("InterventionService enabled but no agent slug configured (intervention.agent)");
232
+ this.enabled = false;
233
+ this.initialized = true;
234
+ return;
235
+ }
236
+
237
+ // Store the trimmed slug for lazy resolution (don't resolve yet - ProjectContext may not exist)
238
+ this.interventionAgentSlug = agentSlug;
239
+ this.timeoutMs = interventionConfig.timeout ?? DEFAULT_TIMEOUT_MS;
240
+
241
+ // Clamp negative values to 0 and warn
242
+ const rawInactivityTimeout = interventionConfig.conversationInactivityTimeoutSeconds ?? DEFAULT_CONVERSATION_INACTIVITY_TIMEOUT_SECONDS;
243
+ if (rawInactivityTimeout < 0) {
244
+ logger.warn("InterventionService: conversationInactivityTimeoutSeconds is negative, clamping to 0", {
245
+ configuredValue: rawInactivityTimeout,
246
+ });
247
+ this.conversationInactivityTimeoutSeconds = 0;
248
+ } else {
249
+ this.conversationInactivityTimeoutSeconds = rawInactivityTimeout;
250
+ }
251
+ this.enabled = true;
252
+
253
+ // Initialize the publisher
254
+ await this.publisher.initialize();
255
+
256
+ // Ensure config directory exists
257
+ await fs.mkdir(this.configDir, { recursive: true });
258
+
259
+ this.initialized = true;
260
+
261
+ logger.info("InterventionService initialized (agent resolution deferred)", {
262
+ agentSlug,
263
+ timeoutMs: this.timeoutMs,
264
+ conversationInactivityTimeoutSeconds: this.conversationInactivityTimeoutSeconds,
265
+ });
266
+
267
+ trace.getActiveSpan()?.addEvent("intervention.service_initialized", {
268
+ "intervention.agent_slug": agentSlug,
269
+ "intervention.timeout_ms": this.timeoutMs,
270
+ "intervention.conversation_inactivity_timeout_seconds": this.conversationInactivityTimeoutSeconds,
271
+ "intervention.resolution_deferred": true,
272
+ });
273
+ }
274
+
275
+ /**
276
+ * Set the current project and load its state.
277
+ * Must be called before the service will process events.
278
+ * This is called when a project context becomes available.
279
+ */
280
+ public async setProject(projectId: string): Promise<void> {
281
+ if (!this.enabled) {
282
+ return;
283
+ }
284
+
285
+ // If switching projects, save current state first
286
+ if (this.currentProjectId && this.currentProjectId !== projectId) {
287
+ await this.flushWriteQueue();
288
+ }
289
+
290
+ if (this.currentProjectId === projectId) {
291
+ if (this.stateLoadPromise) {
292
+ await this.stateLoadPromise;
293
+ }
294
+
295
+ logger.debug("InterventionService project set", {
296
+ projectId: projectId.substring(0, 12),
297
+ pendingCount: this.pendingInterventions.size,
298
+ });
299
+ return;
300
+ }
301
+
302
+ this.currentProjectId = projectId;
303
+ this.beginStateLoad(projectId);
304
+ if (this.stateLoadPromise) {
305
+ await this.stateLoadPromise;
306
+ }
307
+
308
+ logger.debug("InterventionService project set", {
309
+ projectId: projectId.substring(0, 12),
310
+ pendingCount: this.pendingInterventions.size,
311
+ });
312
+ }
313
+
314
+ /**
315
+ * Begin loading project state if no load is already in progress.
316
+ * Ensures queued completion operations are flushed after load completes.
317
+ */
318
+ private beginStateLoad(projectId: string): void {
319
+ if (this.stateLoadPromise) {
320
+ return;
321
+ }
322
+
323
+ this.stateLoadPromise = this.loadState(projectId)
324
+ .then(() => {
325
+ this.setupCatchUpTimers();
326
+ this.flushPendingCompletionOps();
327
+ })
328
+ .finally(() => {
329
+ this.stateLoadPromise = null;
330
+ });
331
+ }
332
+
333
+ /**
334
+ * Resolve the agent slug to a pubkey for a specific project.
335
+ * Called at trigger time using the target project's agent registry.
336
+ * Returns a resolution result indicating success, transient failure, or permanent failure.
337
+ *
338
+ * This method resolves the agent fresh each time, using the project's
339
+ * own agent registry. This ensures interventions target the correct
340
+ * agent even when different projects have different agent configurations.
341
+ *
342
+ * @param projectId - The project ID to resolve the agent for
343
+ */
344
+ private resolveAgentPubkeyForProject(projectId: string): AgentResolutionResult {
345
+ if (!this.interventionAgentSlug) {
346
+ return { status: "agent_not_found" };
347
+ }
348
+
349
+ if (!this.agentResolver) {
350
+ logger.warn("InterventionService: no agent resolver configured, cannot resolve agent", {
351
+ projectId: projectId.substring(0, 12),
352
+ slug: this.interventionAgentSlug,
353
+ });
354
+ return { status: "runtime_unavailable" };
355
+ }
356
+
357
+ // Wrap resolver call in try/catch to handle exceptions from the injected resolver
358
+ let result: AgentResolutionResult;
359
+ try {
360
+ result = this.agentResolver(projectId, this.interventionAgentSlug);
361
+ } catch (error) {
362
+ logger.error("InterventionService: agent resolver threw an exception", {
363
+ projectId: projectId.substring(0, 12),
364
+ slug: this.interventionAgentSlug,
365
+ error: error instanceof Error ? error.message : String(error),
366
+ });
367
+ // Map exception to transient failure - runtime may be in a bad state
368
+ return { status: "runtime_unavailable" };
369
+ }
370
+
371
+ if (result.status === "resolved") {
372
+ logger.debug("InterventionService: resolved agent for project", {
373
+ projectId: projectId.substring(0, 12),
374
+ slug: this.interventionAgentSlug,
375
+ pubkey: result.pubkey.substring(0, 8),
376
+ });
377
+ } else if (result.status === "runtime_unavailable") {
378
+ logger.warn("InterventionService: runtime temporarily unavailable for agent resolution", {
379
+ projectId: projectId.substring(0, 12),
380
+ slug: this.interventionAgentSlug,
381
+ });
382
+ } else {
383
+ logger.warn("InterventionService: agent slug not found in project", {
384
+ projectId: projectId.substring(0, 12),
385
+ slug: this.interventionAgentSlug,
386
+ });
387
+ }
388
+
389
+ return result;
390
+ }
391
+
392
+ /**
393
+ * Check if the service is enabled and ready.
394
+ */
395
+ public isEnabled(): boolean {
396
+ return this.enabled && this.initialized;
397
+ }
398
+
399
+ /**
400
+ * Called when an agent completes work on a conversation.
401
+ *
402
+ * Completion is detected when:
403
+ * - Event is kind:1
404
+ * - Event author is an agent (not a whitelisted user)
405
+ * - Event p-tags a whitelisted pubkey
406
+ * - That whitelisted pubkey is the author of the root event
407
+ *
408
+ * If the user was recently active in the conversation (within conversationInactivityTimeoutSeconds),
409
+ * the intervention is skipped entirely. This prevents interventions when the user is
410
+ * actively engaged in the conversation.
411
+ *
412
+ * @param conversationId - The conversation ID
413
+ * @param completedAt - Timestamp of the completion event (ms)
414
+ * @param agentPubkey - Pubkey of the completing agent
415
+ * @param userPubkey - Pubkey of the root event author
416
+ * @param projectId - Project ID for state scoping
417
+ * @param lastUserMessageTime - Timestamp of the last user message in the conversation (ms, optional)
418
+ */
419
+ public onAgentCompletion(
420
+ conversationId: string,
421
+ completedAt: number,
422
+ agentPubkey: string,
423
+ userPubkey: string,
424
+ projectId: string,
425
+ lastUserMessageTime?: number
426
+ ): void {
427
+ if (!this.isEnabled()) {
428
+ return;
429
+ }
430
+
431
+ // Distinguish between transient (runtime unavailable) and permanent (agent not found) failures
432
+ const resolution = this.resolveAgentPubkeyForProject(projectId);
433
+
434
+ if (resolution.status === "agent_not_found") {
435
+ // Permanent failure: agent slug doesn't exist in project
436
+ logger.warn("InterventionService: skipping completion, agent not found in project", {
437
+ projectId: projectId.substring(0, 12),
438
+ slug: this.interventionAgentSlug,
439
+ });
440
+ return;
441
+ }
442
+
443
+ // Skip if the completing agent IS the intervention agent (prevents feedback loop)
444
+ if (resolution.status === "resolved" && agentPubkey === resolution.pubkey) {
445
+ logger.debug("InterventionService: skipping intervention, completing agent is the intervention agent", {
446
+ conversationId: conversationId.substring(0, 12),
447
+ agentPubkey: agentPubkey.substring(0, 8),
448
+ });
449
+
450
+ trace.getActiveSpan()?.addEvent("intervention.skipped_intervention_agent_completion", {
451
+ "conversation.id": conversationId,
452
+ "agent.pubkey": agentPubkey.substring(0, 8),
453
+ });
454
+
455
+ return;
456
+ }
457
+
458
+ // Note: For "runtime_unavailable" (transient), we proceed with queuing.
459
+ // The timer will attempt resolution again at trigger time.
460
+ // This handles startup/restart scenarios where runtime is briefly unavailable.
461
+
462
+ // CRITICAL: Only trigger interventions for whitelisted user pubkeys
463
+ // Skip if the "user" is actually an agent (agent-to-agent completion)
464
+ const trustService = getTrustPubkeyService();
465
+ const trustResult = trustService.isTrustedSync(userPubkey);
466
+
467
+ if (!trustResult.trusted || trustResult.reason !== "whitelisted") {
468
+ // The "user" is not a whitelisted human user - it's an agent or unknown
469
+ logger.debug("InterventionService: skipping intervention, user is not whitelisted", {
470
+ conversationId: conversationId.substring(0, 12),
471
+ userPubkey: userPubkey.substring(0, 8),
472
+ trustReason: trustResult.reason ?? "not-trusted",
473
+ });
474
+
475
+ trace.getActiveSpan()?.addEvent("intervention.skipped_not_whitelisted_user", {
476
+ "conversation.id": conversationId,
477
+ "user.pubkey": userPubkey.substring(0, 8),
478
+ "trust.reason": trustResult.reason ?? "not-trusted",
479
+ });
480
+
481
+ return;
482
+ }
483
+
484
+ // Check conversation inactivity: if user was recently active, skip intervention
485
+ if (lastUserMessageTime !== undefined && this.conversationInactivityTimeoutSeconds > 0) {
486
+ const timeSinceLastUserMessageMs = completedAt - lastUserMessageTime;
487
+ const thresholdMs = this.conversationInactivityTimeoutSeconds * 1000;
488
+
489
+ if (timeSinceLastUserMessageMs < thresholdMs) {
490
+ logger.debug("InterventionService: skipping intervention, user was recently active", {
491
+ conversationId: conversationId.substring(0, 12),
492
+ timeSinceLastUserMessageMs,
493
+ thresholdMs,
494
+ lastUserMessageTime,
495
+ completedAt,
496
+ });
497
+
498
+ trace.getActiveSpan()?.addEvent("intervention.skipped_recent_user_activity", {
499
+ "conversation.id": conversationId,
500
+ "time_since_last_user_message_ms": timeSinceLastUserMessageMs,
501
+ "threshold_ms": thresholdMs,
502
+ });
503
+
504
+ return;
505
+ }
506
+ }
507
+
508
+ // Skip if agent has active outgoing delegations (work not yet complete)
509
+ if (this.activeDelegationChecker) {
510
+ const hasActiveDelegations = this.activeDelegationChecker(agentPubkey, conversationId);
511
+ if (hasActiveDelegations) {
512
+ logger.debug("InterventionService: skipping intervention, agent has active delegations", {
513
+ conversationId: conversationId.substring(0, 12),
514
+ agentPubkey: agentPubkey.substring(0, 8),
515
+ });
516
+
517
+ trace.getActiveSpan()?.addEvent("intervention.skipped_active_delegations", {
518
+ "conversation.id": conversationId,
519
+ "agent.pubkey": agentPubkey.substring(0, 8),
520
+ });
521
+
522
+ return;
523
+ }
524
+ }
525
+
526
+ // Ensure project is set
527
+ if (!this.currentProjectId) {
528
+ this.currentProjectId = projectId;
529
+ // Note: setProject() should have been called first in normal operation.
530
+ // If we reach here, load state and queue this completion to run after.
531
+ this.beginStateLoad(projectId);
532
+ } else if (this.currentProjectId !== projectId) {
533
+ // Different project - this shouldn't happen in normal operation
534
+ logger.warn("InterventionService: completion from different project", {
535
+ expected: this.currentProjectId.substring(0, 12),
536
+ actual: projectId.substring(0, 12),
537
+ });
538
+ }
539
+
540
+ // If state is still loading, queue this operation to run after
541
+ if (this.stateLoadPromise) {
542
+ this.pendingCompletionOps.push(() => {
543
+ this.addPendingIntervention(conversationId, completedAt, agentPubkey, userPubkey, projectId);
544
+ });
545
+ return;
546
+ }
547
+
548
+ this.addPendingIntervention(conversationId, completedAt, agentPubkey, userPubkey, projectId);
549
+ }
550
+
551
+ /**
552
+ * Add a pending intervention entry.
553
+ * Extracted to support queuing during state load.
554
+ */
555
+ private addPendingIntervention(
556
+ conversationId: string,
557
+ completedAt: number,
558
+ agentPubkey: string,
559
+ userPubkey: string,
560
+ projectId: string
561
+ ): void {
562
+ // Prune stale entries from notifiedConversations (prevents unbounded growth
563
+ // and ensures entries older than TTL no longer block new notifications)
564
+ this.pruneStaleNotifications();
565
+
566
+ // Check if this conversation was already notified (deduplication guard)
567
+ const notifiedAt = this.notifiedConversations.get(conversationId);
568
+ if (notifiedAt !== undefined) {
569
+ logger.debug("InterventionService: skipping already-notified conversation", {
570
+ conversationId: conversationId.substring(0, 12),
571
+ notifiedAt,
572
+ });
573
+
574
+ trace.getActiveSpan()?.addEvent("intervention.skipped_already_notified", {
575
+ "conversation.id": conversationId,
576
+ "notified_at": notifiedAt,
577
+ });
578
+
579
+ return;
580
+ }
581
+
582
+ // Check if we already have a pending intervention for this conversation
583
+ const existing = this.pendingInterventions.get(conversationId);
584
+ if (existing) {
585
+ logger.debug("Updating existing pending intervention", {
586
+ conversationId: conversationId.substring(0, 12),
587
+ previousCompletedAt: existing.completedAt,
588
+ newCompletedAt: completedAt,
589
+ });
590
+ // Clear the old timer
591
+ this.clearTimer(conversationId);
592
+ }
593
+
594
+ const pending: PendingIntervention = {
595
+ conversationId,
596
+ completedAt,
597
+ agentPubkey,
598
+ userPubkey,
599
+ projectId,
600
+ retryCount: 0,
601
+ };
602
+
603
+ this.pendingInterventions.set(conversationId, pending);
604
+ this.startTimer(pending);
605
+ this.saveState();
606
+
607
+ logger.info("Agent completion detected, starting intervention timer", {
608
+ conversationId: conversationId.substring(0, 12),
609
+ agentPubkey: agentPubkey.substring(0, 8),
610
+ userPubkey: userPubkey.substring(0, 8),
611
+ timeoutMs: this.timeoutMs,
612
+ });
613
+
614
+ trace.getActiveSpan()?.addEvent("intervention.timer_started", {
615
+ "conversation.id": conversationId,
616
+ "agent.pubkey": agentPubkey.substring(0, 8),
617
+ "user.pubkey": userPubkey.substring(0, 8),
618
+ "timeout.ms": this.timeoutMs,
619
+ });
620
+ }
621
+
622
+ /**
623
+ * Flush queued completion operations after state load completes.
624
+ */
625
+ private flushPendingCompletionOps(): void {
626
+ const ops = this.pendingCompletionOps;
627
+ this.pendingCompletionOps = [];
628
+ for (const op of ops) {
629
+ op();
630
+ }
631
+ }
632
+
633
+ /**
634
+ * Called when a user responds in a conversation.
635
+ *
636
+ * Only cancels the timer if:
637
+ * 1. The response is AFTER the completion timestamp
638
+ * 2. The response is BEFORE the timeout window expires
639
+ *
640
+ * This prevents event loop delays from allowing late responses to cancel timers.
641
+ *
642
+ * @param conversationId - The conversation ID
643
+ * @param responseAt - Timestamp of the user response (ms)
644
+ * @param userPubkey - Pubkey of the responding user
645
+ */
646
+ public onUserResponse(
647
+ conversationId: string,
648
+ responseAt: number,
649
+ userPubkey: string
650
+ ): void {
651
+ if (!this.isEnabled()) {
652
+ return;
653
+ }
654
+
655
+ // If state is still loading, queue this operation to run after
656
+ // This prevents race conditions where a response arrives during state load
657
+ // and fails to properly cancel a pending intervention
658
+ if (this.stateLoadPromise) {
659
+ this.pendingCompletionOps.push(() => {
660
+ this.processUserResponse(conversationId, responseAt, userPubkey);
661
+ });
662
+ return;
663
+ }
664
+
665
+ this.processUserResponse(conversationId, responseAt, userPubkey);
666
+ }
667
+
668
+ /**
669
+ * Process a user response after ensuring state is loaded.
670
+ * Extracted to support queuing during state load.
671
+ */
672
+ private processUserResponse(
673
+ conversationId: string,
674
+ responseAt: number,
675
+ userPubkey: string
676
+ ): void {
677
+ const pending = this.pendingInterventions.get(conversationId);
678
+ if (!pending) {
679
+ // No pending intervention for this conversation
680
+ return;
681
+ }
682
+
683
+ // Only cancel if response is AFTER completion
684
+ if (responseAt <= pending.completedAt) {
685
+ logger.debug("User response before completion, not cancelling timer", {
686
+ conversationId: conversationId.substring(0, 12),
687
+ responseAt,
688
+ completedAt: pending.completedAt,
689
+ });
690
+ return;
691
+ }
692
+
693
+ // Verify response is strictly BEFORE the timeout window expires
694
+ // Responses at exactly completedAt + timeoutMs do NOT cancel (strict "before" semantics)
695
+ const timeoutExpiry = pending.completedAt + this.timeoutMs;
696
+ if (responseAt >= timeoutExpiry) {
697
+ logger.debug("User response at or after timeout window, not cancelling timer", {
698
+ conversationId: conversationId.substring(0, 12),
699
+ responseAt,
700
+ timeoutExpiry,
701
+ delayMs: responseAt - timeoutExpiry,
702
+ });
703
+ return;
704
+ }
705
+
706
+ // Verify it's the same user we're waiting on
707
+ if (userPubkey !== pending.userPubkey) {
708
+ logger.debug("Response from different user, not cancelling timer", {
709
+ conversationId: conversationId.substring(0, 12),
710
+ responsePubkey: userPubkey.substring(0, 8),
711
+ expectedPubkey: pending.userPubkey.substring(0, 8),
712
+ });
713
+ return;
714
+ }
715
+
716
+ // Cancel the timer - user responded within the timeout window
717
+ this.clearTimer(conversationId);
718
+ this.pendingInterventions.delete(conversationId);
719
+ this.saveState();
720
+
721
+ logger.info("User responded, cancelled intervention timer", {
722
+ conversationId: conversationId.substring(0, 12),
723
+ userPubkey: userPubkey.substring(0, 8),
724
+ responseDelayMs: responseAt - pending.completedAt,
725
+ });
726
+
727
+ trace.getActiveSpan()?.addEvent("intervention.timer_cancelled", {
728
+ "conversation.id": conversationId,
729
+ "user.pubkey": userPubkey.substring(0, 8),
730
+ "response.delay_ms": responseAt - pending.completedAt,
731
+ });
732
+ }
733
+
734
+ /**
735
+ * Trigger an intervention for a pending conversation.
736
+ * Called when the timer expires.
737
+ * Includes retry logic with exponential backoff on publish failure
738
+ * and transient runtime unavailability.
739
+ * Guarded against concurrent execution for the same conversationId.
740
+ */
741
+ private async triggerIntervention(pending: PendingIntervention): Promise<void> {
742
+ // Guard against stale timers firing after a project switch.
743
+ // If the pending intervention belongs to a different project than what's
744
+ // currently loaded, it's a leftover timer that should be silently discarded.
745
+ if (pending.projectId !== this.currentProjectId) {
746
+ logger.debug("InterventionService: stale timer from different project, skipping", {
747
+ conversationId: pending.conversationId.substring(0, 12),
748
+ pendingProjectId: pending.projectId.substring(0, 12),
749
+ currentProjectId: this.currentProjectId?.substring(0, 12) ?? "none",
750
+ });
751
+ return;
752
+ }
753
+
754
+ // Guard against concurrent triggerIntervention for the same conversation
755
+ if (this.triggeringConversations.has(pending.conversationId)) {
756
+ logger.debug("InterventionService: triggerIntervention already in progress, skipping", {
757
+ conversationId: pending.conversationId.substring(0, 12),
758
+ });
759
+ return;
760
+ }
761
+
762
+ // Check if already notified (race between timer expiry and re-delivered event)
763
+ // Also verify TTL — expired entries should not block triggers
764
+ const notifiedAt = this.notifiedConversations.get(pending.conversationId);
765
+ if (notifiedAt !== undefined && (Date.now() - notifiedAt) < NOTIFIED_TTL_MS) {
766
+ logger.debug("InterventionService: conversation already notified, skipping trigger", {
767
+ conversationId: pending.conversationId.substring(0, 12),
768
+ });
769
+ this.pendingInterventions.delete(pending.conversationId);
770
+ this.saveState();
771
+ return;
772
+ }
773
+ // If the entry existed but was stale, remove it so we proceed with the trigger
774
+ if (notifiedAt !== undefined) {
775
+ this.notifiedConversations.delete(pending.conversationId);
776
+ }
777
+
778
+ this.triggeringConversations.add(pending.conversationId);
779
+
780
+ try {
781
+ await this.executeTrigger(pending);
782
+ } finally {
783
+ this.triggeringConversations.delete(pending.conversationId);
784
+ }
785
+ }
786
+
787
+ /**
788
+ * Execute the actual trigger logic (separated for concurrency guard).
789
+ */
790
+ private async executeTrigger(pending: PendingIntervention): Promise<void> {
791
+ // Resolve the intervention agent pubkey for this specific project
792
+ // This ensures we target the correct agent even when different projects
793
+ // have different agent configurations
794
+ const resolution = this.resolveAgentPubkeyForProject(pending.projectId);
795
+
796
+ if (resolution.status === "runtime_unavailable") {
797
+ // Transient failure: runtime temporarily unavailable
798
+ // Schedule a retry with backoff
799
+ const retryCount = pending.retryCount ?? 0;
800
+
801
+ if (retryCount < MAX_RETRY_ATTEMPTS) {
802
+ pending.retryCount = retryCount + 1;
803
+ this.pendingInterventions.set(pending.conversationId, pending);
804
+ this.saveState();
805
+
806
+ const backoffMs = DEFAULT_RETRY_INTERVAL_MS * Math.pow(2, retryCount);
807
+ this.scheduleRetry(pending, backoffMs);
808
+
809
+ logger.info("Runtime unavailable, scheduled retry for intervention", {
810
+ conversationId: pending.conversationId.substring(0, 12),
811
+ projectId: pending.projectId.substring(0, 12),
812
+ retryCount: pending.retryCount,
813
+ nextRetryMs: backoffMs,
814
+ });
815
+
816
+ trace.getActiveSpan()?.addEvent("intervention.retry_scheduled_runtime_unavailable", {
817
+ "conversation.id": pending.conversationId,
818
+ "project.id": pending.projectId.substring(0, 12),
819
+ "retry_count": pending.retryCount,
820
+ "next_retry_ms": backoffMs,
821
+ });
822
+ } else {
823
+ logger.error("Max retry attempts reached for intervention (runtime unavailable)", {
824
+ conversationId: pending.conversationId.substring(0, 12),
825
+ projectId: pending.projectId.substring(0, 12),
826
+ maxRetries: MAX_RETRY_ATTEMPTS,
827
+ });
828
+ // Remove from pending - we've exhausted retries
829
+ this.pendingInterventions.delete(pending.conversationId);
830
+ this.saveState();
831
+ }
832
+ return;
833
+ }
834
+
835
+ if (resolution.status === "agent_not_found") {
836
+ logger.error("Cannot trigger intervention: agent not found in project", {
837
+ projectId: pending.projectId.substring(0, 12),
838
+ slug: this.interventionAgentSlug,
839
+ conversationId: pending.conversationId.substring(0, 12),
840
+ });
841
+ // Permanent failure - remove from pending
842
+ this.pendingInterventions.delete(pending.conversationId);
843
+ this.saveState();
844
+ return;
845
+ }
846
+
847
+ const interventionAgentPubkey = resolution.pubkey;
848
+
849
+ const retryCount = pending.retryCount ?? 0;
850
+
851
+ logger.info("Triggering intervention review", {
852
+ conversationId: pending.conversationId.substring(0, 12),
853
+ userPubkey: pending.userPubkey.substring(0, 8),
854
+ agentPubkey: pending.agentPubkey.substring(0, 8),
855
+ interventionAgentPubkey: interventionAgentPubkey.substring(0, 8),
856
+ projectId: pending.projectId.substring(0, 12),
857
+ timeElapsedMs: Date.now() - pending.completedAt,
858
+ retryCount,
859
+ });
860
+
861
+ trace.getActiveSpan()?.addEvent("intervention.triggered", {
862
+ "conversation.id": pending.conversationId,
863
+ "user.pubkey": pending.userPubkey.substring(0, 8),
864
+ "agent.pubkey": pending.agentPubkey.substring(0, 8),
865
+ "intervention_agent.pubkey": interventionAgentPubkey.substring(0, 8),
866
+ "project.id": pending.projectId.substring(0, 12),
867
+ "time_elapsed_ms": Date.now() - pending.completedAt,
868
+ "retry_count": retryCount,
869
+ });
870
+
871
+ try {
872
+ // Resolve human-readable names before calling the publisher
873
+ // This keeps name resolution in the services layer, avoiding circular dependencies
874
+ const pubkeyService = PubkeyService.getInstance();
875
+ const userName = pubkeyService.getNameSync(pending.userPubkey);
876
+ const agentName = pubkeyService.getNameSync(pending.agentPubkey);
877
+
878
+ const eventId = await this.publisher.publishReviewRequest(
879
+ interventionAgentPubkey,
880
+ pending.conversationId,
881
+ userName,
882
+ agentName
883
+ );
884
+
885
+ logger.info("Intervention review request published", {
886
+ eventId: eventId.substring(0, 8),
887
+ conversationId: pending.conversationId.substring(0, 12),
888
+ });
889
+
890
+ // Record as notified to prevent duplicate notifications
891
+ this.notifiedConversations.set(pending.conversationId, Date.now());
892
+
893
+ // Remove from pending after successful publish
894
+ this.pendingInterventions.delete(pending.conversationId);
895
+ this.saveState();
896
+ } catch (error) {
897
+ logger.error("Failed to publish intervention review request", {
898
+ error,
899
+ conversationId: pending.conversationId.substring(0, 12),
900
+ retryCount,
901
+ });
902
+
903
+ // Retry logic with backoff
904
+ if (retryCount < MAX_RETRY_ATTEMPTS) {
905
+ // Update retry count
906
+ pending.retryCount = retryCount + 1;
907
+ this.pendingInterventions.set(pending.conversationId, pending);
908
+ this.saveState();
909
+
910
+ // Re-arm timer with exponential backoff
911
+ const backoffMs = DEFAULT_RETRY_INTERVAL_MS * Math.pow(2, retryCount);
912
+ this.scheduleRetry(pending, backoffMs);
913
+
914
+ logger.info("Scheduled retry for failed intervention", {
915
+ conversationId: pending.conversationId.substring(0, 12),
916
+ retryCount: pending.retryCount,
917
+ nextRetryMs: backoffMs,
918
+ });
919
+ } else {
920
+ logger.error("Max retry attempts reached for intervention", {
921
+ conversationId: pending.conversationId.substring(0, 12),
922
+ maxRetries: MAX_RETRY_ATTEMPTS,
923
+ });
924
+ // Remove from pending - we've exhausted retries
925
+ this.pendingInterventions.delete(pending.conversationId);
926
+ this.saveState();
927
+ }
928
+ }
929
+ }
930
+
931
+ /**
932
+ * Schedule a retry for a failed intervention publish.
933
+ */
934
+ private scheduleRetry(pending: PendingIntervention, delayMs: number): void {
935
+ const timer = setTimeout(() => {
936
+ this.timers.delete(pending.conversationId);
937
+ this.triggerIntervention(pending);
938
+ }, delayMs);
939
+
940
+ this.timers.set(pending.conversationId, timer);
941
+
942
+ logger.debug("Intervention retry scheduled", {
943
+ conversationId: pending.conversationId.substring(0, 12),
944
+ delayMs,
945
+ });
946
+ }
947
+
948
+ /**
949
+ * Start a timer for a pending intervention.
950
+ */
951
+ private startTimer(pending: PendingIntervention): void {
952
+ const now = Date.now();
953
+ const elapsed = now - pending.completedAt;
954
+ const remaining = Math.max(0, this.timeoutMs - elapsed);
955
+
956
+ if (remaining === 0) {
957
+ // Timer already expired, trigger immediately
958
+ this.triggerIntervention(pending);
959
+ return;
960
+ }
961
+
962
+ const timer = setTimeout(() => {
963
+ this.timers.delete(pending.conversationId);
964
+ this.triggerIntervention(pending);
965
+ }, remaining);
966
+
967
+ this.timers.set(pending.conversationId, timer);
968
+
969
+ logger.debug("Intervention timer started", {
970
+ conversationId: pending.conversationId.substring(0, 12),
971
+ remainingMs: remaining,
972
+ });
973
+ }
974
+
975
+ /**
976
+ * Clear a timer for a conversation.
977
+ */
978
+ private clearTimer(conversationId: string): void {
979
+ const timer = this.timers.get(conversationId);
980
+ if (timer) {
981
+ clearTimeout(timer);
982
+ this.timers.delete(conversationId);
983
+ }
984
+ }
985
+
986
+ /**
987
+ * Setup catch-up timers for pending interventions loaded from state.
988
+ */
989
+ private setupCatchUpTimers(): void {
990
+ for (const pending of this.pendingInterventions.values()) {
991
+ this.startTimer(pending);
992
+ }
993
+ }
994
+
995
+ /**
996
+ * Load persisted state from disk for the given project.
997
+ */
998
+ private async loadState(projectId: string): Promise<void> {
999
+ const stateFilePath = this.getStateFilePath(projectId);
1000
+
1001
+ // Cancel all active timers from the previous project before clearing state.
1002
+ // Without this, timers from a previous project continue running and fire
1003
+ // against the new project's data, causing duplicate notifications and
1004
+ // cross-project state corruption.
1005
+ for (const timer of this.timers.values()) {
1006
+ clearTimeout(timer);
1007
+ }
1008
+ this.timers.clear();
1009
+
1010
+ // Clear all project-scoped state before loading new project
1011
+ // (must happen unconditionally, even if state file doesn't exist)
1012
+ this.pendingInterventions.clear();
1013
+ this.notifiedConversations.clear();
1014
+ this.triggeringConversations.clear();
1015
+
1016
+ try {
1017
+ const data = await fs.readFile(stateFilePath, "utf-8");
1018
+ const state = JSON.parse(data) as InterventionState;
1019
+
1020
+ for (const pending of state.pending) {
1021
+ // Migrate old projectPubkey field to projectId if present
1022
+ if ("projectPubkey" in pending && !pending.projectId) {
1023
+ (pending as PendingIntervention).projectId = projectId;
1024
+ }
1025
+ this.pendingInterventions.set(pending.conversationId, pending);
1026
+ }
1027
+
1028
+ // Load notified conversations, evicting entries older than 24h
1029
+ if (state.notified) {
1030
+ const cutoff = Date.now() - NOTIFIED_TTL_MS;
1031
+ for (const entry of state.notified) {
1032
+ if (entry.notifiedAt >= cutoff) {
1033
+ this.notifiedConversations.set(entry.conversationId, entry.notifiedAt);
1034
+ }
1035
+ }
1036
+ }
1037
+
1038
+ logger.debug("Loaded intervention state", {
1039
+ projectId: projectId.substring(0, 12),
1040
+ pendingCount: this.pendingInterventions.size,
1041
+ notifiedCount: this.notifiedConversations.size,
1042
+ });
1043
+
1044
+ trace.getActiveSpan()?.addEvent("intervention.state_loaded", {
1045
+ "intervention.project_id": projectId.substring(0, 12),
1046
+ "intervention.pending_count": this.pendingInterventions.size,
1047
+ "intervention.notified_count": this.notifiedConversations.size,
1048
+ });
1049
+ } catch (error: unknown) {
1050
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
1051
+ // No existing file, starting fresh - this is expected
1052
+ logger.debug("No existing intervention state file, starting fresh", {
1053
+ projectId: projectId.substring(0, 12),
1054
+ });
1055
+ } else {
1056
+ logger.error("Failed to load intervention state:", error);
1057
+ }
1058
+ }
1059
+ }
1060
+
1061
+ /**
1062
+ * Save state to disk atomically using a write queue.
1063
+ * Writes are serialized to prevent race conditions from back-to-back saves.
1064
+ */
1065
+ private saveState(): void {
1066
+ if (!this.currentProjectId) {
1067
+ logger.warn("Cannot save state: no project ID set");
1068
+ return;
1069
+ }
1070
+
1071
+ // Queue the write operation (we don't await it - fire-and-forget for normal operation)
1072
+ this.writeQueue.push({
1073
+ resolve: () => { /* resolved when write completes */ },
1074
+ reject: () => { /* errors are logged in processWriteQueue */ },
1075
+ });
1076
+
1077
+ // Start processing if not already
1078
+ if (!this.isWriting) {
1079
+ this.processWriteQueue();
1080
+ }
1081
+ }
1082
+
1083
+ /**
1084
+ * Process the write queue, serializing all state writes.
1085
+ */
1086
+ private async processWriteQueue(): Promise<void> {
1087
+ if (this.isWriting || this.writeQueue.length === 0) {
1088
+ return;
1089
+ }
1090
+
1091
+ this.isWriting = true;
1092
+
1093
+ while (this.writeQueue.length > 0) {
1094
+ // Take all pending operations and coalesce them into one write
1095
+ const operations = [...this.writeQueue];
1096
+ this.writeQueue = [];
1097
+
1098
+ try {
1099
+ await this.writeStateAtomically();
1100
+
1101
+ // Resolve all coalesced operations
1102
+ for (const op of operations) {
1103
+ op.resolve();
1104
+ }
1105
+ } catch (error) {
1106
+ // Reject all coalesced operations
1107
+ for (const op of operations) {
1108
+ op.reject(error instanceof Error ? error : new Error(String(error)));
1109
+ }
1110
+ }
1111
+ }
1112
+
1113
+ this.isWriting = false;
1114
+ }
1115
+
1116
+ /**
1117
+ * Remove entries from notifiedConversations that are older than NOTIFIED_TTL_MS.
1118
+ * Prevents unbounded map growth and ensures stale entries stop blocking notifications.
1119
+ */
1120
+ private pruneStaleNotifications(): void {
1121
+ const cutoff = Date.now() - NOTIFIED_TTL_MS;
1122
+ for (const [conversationId, notifiedAt] of this.notifiedConversations) {
1123
+ if (notifiedAt < cutoff) {
1124
+ this.notifiedConversations.delete(conversationId);
1125
+ }
1126
+ }
1127
+ }
1128
+
1129
+ /**
1130
+ * Atomically write state to disk using temp file and rename.
1131
+ */
1132
+ private async writeStateAtomically(): Promise<void> {
1133
+ if (!this.currentProjectId) {
1134
+ return;
1135
+ }
1136
+
1137
+ const stateFilePath = this.getStateFilePath(this.currentProjectId);
1138
+ const tempFilePath = `${stateFilePath}.tmp.${Date.now()}`;
1139
+
1140
+ try {
1141
+ // Evict expired notified entries from in-memory map and serialize valid ones
1142
+ this.pruneStaleNotifications();
1143
+ const notifiedEntries: NotifiedEntry[] = [];
1144
+ for (const [conversationId, notifiedAt] of this.notifiedConversations) {
1145
+ notifiedEntries.push({ conversationId, notifiedAt });
1146
+ }
1147
+
1148
+ const state: InterventionState = {
1149
+ pending: Array.from(this.pendingInterventions.values()),
1150
+ notified: notifiedEntries.length > 0 ? notifiedEntries : undefined,
1151
+ };
1152
+
1153
+ // Ensure directory exists before writing
1154
+ try {
1155
+ await fs.mkdir(path.dirname(stateFilePath), { recursive: true });
1156
+ } catch (mkdirError: unknown) {
1157
+ // Directory may have been deleted (e.g., during test cleanup)
1158
+ if (mkdirError && typeof mkdirError === "object" && "code" in mkdirError) {
1159
+ const code = (mkdirError as { code: string }).code;
1160
+ if (code === "ENOENT" || code === "EINVAL") {
1161
+ logger.debug("State directory unavailable, skipping write");
1162
+ return;
1163
+ }
1164
+ }
1165
+ throw mkdirError;
1166
+ }
1167
+
1168
+ // Write to temp file first
1169
+ await fs.writeFile(tempFilePath, JSON.stringify(state, null, 2));
1170
+
1171
+ // Atomic rename
1172
+ await fs.rename(tempFilePath, stateFilePath);
1173
+
1174
+ logger.debug("Saved intervention state atomically", {
1175
+ projectId: this.currentProjectId.substring(0, 12),
1176
+ pendingCount: state.pending.length,
1177
+ });
1178
+ } catch (error: unknown) {
1179
+ // If directory was deleted during write, log and continue
1180
+ if (error && typeof error === "object" && "code" in error) {
1181
+ const code = (error as { code: string }).code;
1182
+ if (code === "ENOENT" || code === "EINVAL") {
1183
+ logger.debug("State write failed (directory unavailable), skipping");
1184
+ return;
1185
+ }
1186
+ }
1187
+
1188
+ logger.error("Failed to save intervention state:", error);
1189
+
1190
+ // Clean up temp file if it exists
1191
+ try {
1192
+ await fs.unlink(tempFilePath);
1193
+ } catch {
1194
+ // Ignore cleanup errors
1195
+ }
1196
+
1197
+ throw error;
1198
+ }
1199
+ }
1200
+
1201
+ /**
1202
+ * Flush the write queue, waiting for all pending writes to complete.
1203
+ * Useful for shutdown and project switch.
1204
+ */
1205
+ private async flushWriteQueue(): Promise<void> {
1206
+ // Force a final write if there's any pending data
1207
+ if (this.pendingInterventions.size > 0 && this.currentProjectId) {
1208
+ await this.writeStateAtomically();
1209
+ }
1210
+
1211
+ // Wait for queue to drain
1212
+ while (this.writeQueue.length > 0 || this.isWriting) {
1213
+ await new Promise(resolve => setTimeout(resolve, 10));
1214
+ }
1215
+ }
1216
+
1217
+ /**
1218
+ * Shutdown the service.
1219
+ * - Clears all timers
1220
+ * - Flushes pending state writes
1221
+ */
1222
+ public async shutdown(): Promise<void> {
1223
+ logger.info("InterventionService shutting down", {
1224
+ pendingCount: this.pendingInterventions.size,
1225
+ timerCount: this.timers.size,
1226
+ });
1227
+
1228
+ trace.getActiveSpan()?.addEvent("intervention.shutting_down", {
1229
+ "intervention.pending_count": this.pendingInterventions.size,
1230
+ "intervention.timer_count": this.timers.size,
1231
+ });
1232
+
1233
+ // Clear all timers
1234
+ for (const timer of this.timers.values()) {
1235
+ clearTimeout(timer);
1236
+ }
1237
+ this.timers.clear();
1238
+
1239
+ // Flush pending state writes
1240
+ try {
1241
+ await this.flushWriteQueue();
1242
+ } catch (error) {
1243
+ logger.error("Failed to flush state on shutdown:", error);
1244
+ }
1245
+
1246
+ this.initialized = false;
1247
+ this.enabled = false;
1248
+
1249
+ trace.getActiveSpan()?.addEvent("intervention.shutdown_complete");
1250
+ }
1251
+
1252
+ /**
1253
+ * Get pending interventions count (for diagnostics).
1254
+ */
1255
+ public getPendingCount(): number {
1256
+ return this.pendingInterventions.size;
1257
+ }
1258
+
1259
+ /**
1260
+ * Get count of already-notified conversations (for diagnostics).
1261
+ */
1262
+ public getNotifiedCount(): number {
1263
+ return this.notifiedConversations.size;
1264
+ }
1265
+
1266
+ /**
1267
+ * Check if a conversation has been notified (for testing).
1268
+ */
1269
+ public isNotified(conversationId: string): boolean {
1270
+ return this.notifiedConversations.has(conversationId);
1271
+ }
1272
+
1273
+ /**
1274
+ * Get a pending intervention by conversation ID (for testing).
1275
+ */
1276
+ public getPending(conversationId: string): PendingIntervention | undefined {
1277
+ return this.pendingInterventions.get(conversationId);
1278
+ }
1279
+
1280
+ /**
1281
+ * Get the current timeout value in milliseconds (for testing).
1282
+ */
1283
+ public getTimeoutMs(): number {
1284
+ return this.timeoutMs;
1285
+ }
1286
+
1287
+ /**
1288
+ * Get the conversation inactivity timeout in seconds (for testing).
1289
+ */
1290
+ public getConversationInactivityTimeoutSeconds(): number {
1291
+ return this.conversationInactivityTimeoutSeconds;
1292
+ }
1293
+
1294
+ /**
1295
+ * Get the current project ID (for testing).
1296
+ */
1297
+ public getCurrentProjectId(): string | null {
1298
+ return this.currentProjectId;
1299
+ }
1300
+
1301
+ /**
1302
+ * Force agent resolution for testing purposes.
1303
+ * Resolves the intervention agent for a specific project.
1304
+ *
1305
+ * @param projectId - The project ID to resolve the agent for
1306
+ * @returns The resolution result
1307
+ */
1308
+ public forceAgentResolution(projectId: string): AgentResolutionResult {
1309
+ return this.resolveAgentPubkeyForProject(projectId);
1310
+ }
1311
+
1312
+ /**
1313
+ * Wait for all pending writes to complete (for testing).
1314
+ */
1315
+ public async waitForWrites(): Promise<void> {
1316
+ await this.flushWriteQueue();
1317
+ }
1318
+
1319
+ /**
1320
+ * Manually mark a conversation as notified with a given timestamp (for testing).
1321
+ * Allows tests to inject stale entries to verify pruning behavior.
1322
+ */
1323
+ public setNotifiedForTesting(conversationId: string, notifiedAt: number): void {
1324
+ this.notifiedConversations.set(conversationId, notifiedAt);
1325
+ }
1326
+
1327
+ /**
1328
+ * Check if a conversation ID is in the triggeringConversations set (for testing).
1329
+ */
1330
+ public isTriggering(conversationId: string): boolean {
1331
+ return this.triggeringConversations.has(conversationId);
1332
+ }
1333
+
1334
+ /**
1335
+ * Add a conversation to the triggeringConversations set (for testing).
1336
+ */
1337
+ public setTriggeringForTesting(conversationId: string): void {
1338
+ this.triggeringConversations.add(conversationId);
1339
+ }
1340
+
1341
+ /**
1342
+ * Wait for pending state load and queued operations to complete (for testing).
1343
+ * This ensures all deferred completion operations have been processed.
1344
+ */
1345
+ public async waitForPendingOps(): Promise<void> {
1346
+ if (this.stateLoadPromise) {
1347
+ await this.stateLoadPromise;
1348
+ }
1349
+ // Give microtasks a chance to flush
1350
+ await Promise.resolve();
1351
+ }
1352
+ }