@aitne/daemon 0.1.2 → 0.1.4

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 (253) hide show
  1. package/LICENSE +21 -0
  2. package/dist/adapters/whatsapp-adapter.d.ts.map +1 -1
  3. package/dist/adapters/whatsapp-adapter.js +0 -1
  4. package/dist/adapters/whatsapp-adapter.js.map +1 -1
  5. package/dist/api/integration-route-gate.d.ts +15 -11
  6. package/dist/api/integration-route-gate.d.ts.map +1 -1
  7. package/dist/api/integration-route-gate.js +60 -23
  8. package/dist/api/integration-route-gate.js.map +1 -1
  9. package/dist/api/json-body.d.ts +22 -7
  10. package/dist/api/json-body.d.ts.map +1 -1
  11. package/dist/api/json-body.js +27 -8
  12. package/dist/api/json-body.js.map +1 -1
  13. package/dist/api/routes/agent.d.ts.map +1 -1
  14. package/dist/api/routes/agent.js +18 -0
  15. package/dist/api/routes/agent.js.map +1 -1
  16. package/dist/api/routes/backends.d.ts.map +1 -1
  17. package/dist/api/routes/backends.js +96 -1
  18. package/dist/api/routes/backends.js.map +1 -1
  19. package/dist/api/routes/books.js +1 -1
  20. package/dist/api/routes/books.js.map +1 -1
  21. package/dist/api/routes/context.d.ts.map +1 -1
  22. package/dist/api/routes/context.js +13 -1
  23. package/dist/api/routes/context.js.map +1 -1
  24. package/dist/api/routes/dashboard.d.ts.map +1 -1
  25. package/dist/api/routes/dashboard.js +75 -5
  26. package/dist/api/routes/dashboard.js.map +1 -1
  27. package/dist/api/routes/github.d.ts.map +1 -1
  28. package/dist/api/routes/github.js +38 -5
  29. package/dist/api/routes/github.js.map +1 -1
  30. package/dist/api/routes/integrations.d.ts +35 -6
  31. package/dist/api/routes/integrations.d.ts.map +1 -1
  32. package/dist/api/routes/integrations.js +191 -16
  33. package/dist/api/routes/integrations.js.map +1 -1
  34. package/dist/api/routes/mail.d.ts.map +1 -1
  35. package/dist/api/routes/mail.js +112 -46
  36. package/dist/api/routes/mail.js.map +1 -1
  37. package/dist/api/routes/observations.d.ts.map +1 -1
  38. package/dist/api/routes/observations.js +161 -8
  39. package/dist/api/routes/observations.js.map +1 -1
  40. package/dist/api/routes/setup-migrate.d.ts +9 -1
  41. package/dist/api/routes/setup-migrate.d.ts.map +1 -1
  42. package/dist/api/routes/setup-migrate.js +4 -2
  43. package/dist/api/routes/setup-migrate.js.map +1 -1
  44. package/dist/api/routes/skills.d.ts.map +1 -1
  45. package/dist/api/routes/skills.js +39 -1
  46. package/dist/api/routes/skills.js.map +1 -1
  47. package/dist/api/routes/voice.d.ts.map +1 -1
  48. package/dist/api/routes/voice.js +154 -14
  49. package/dist/api/routes/voice.js.map +1 -1
  50. package/dist/bootstrap/adapters.d.ts +109 -0
  51. package/dist/bootstrap/adapters.d.ts.map +1 -0
  52. package/dist/bootstrap/adapters.js +237 -0
  53. package/dist/bootstrap/adapters.js.map +1 -0
  54. package/dist/bootstrap/catchup.d.ts +23 -0
  55. package/dist/bootstrap/catchup.d.ts.map +1 -0
  56. package/dist/bootstrap/catchup.js +124 -0
  57. package/dist/bootstrap/catchup.js.map +1 -0
  58. package/dist/bootstrap/schedule-helpers.d.ts +18 -0
  59. package/dist/bootstrap/schedule-helpers.d.ts.map +1 -0
  60. package/dist/bootstrap/schedule-helpers.js +96 -0
  61. package/dist/bootstrap/schedule-helpers.js.map +1 -0
  62. package/dist/bootstrap/services.d.ts +60 -0
  63. package/dist/bootstrap/services.d.ts.map +1 -0
  64. package/dist/bootstrap/services.js +209 -0
  65. package/dist/bootstrap/services.js.map +1 -0
  66. package/dist/core/backends/backend-router.d.ts +23 -0
  67. package/dist/core/backends/backend-router.d.ts.map +1 -1
  68. package/dist/core/backends/backend-router.js +48 -3
  69. package/dist/core/backends/backend-router.js.map +1 -1
  70. package/dist/core/backends/claude-auth.d.ts +70 -0
  71. package/dist/core/backends/claude-auth.d.ts.map +1 -0
  72. package/dist/core/backends/claude-auth.js +198 -0
  73. package/dist/core/backends/claude-auth.js.map +1 -0
  74. package/dist/core/backends/claude-code-core.d.ts +47 -119
  75. package/dist/core/backends/claude-code-core.d.ts.map +1 -1
  76. package/dist/core/backends/claude-code-core.js +112 -1565
  77. package/dist/core/backends/claude-code-core.js.map +1 -1
  78. package/dist/core/backends/claude-delegated.d.ts +86 -0
  79. package/dist/core/backends/claude-delegated.d.ts.map +1 -0
  80. package/dist/core/backends/claude-delegated.js +801 -0
  81. package/dist/core/backends/claude-delegated.js.map +1 -0
  82. package/dist/core/backends/claude-errors.d.ts +39 -0
  83. package/dist/core/backends/claude-errors.d.ts.map +1 -0
  84. package/dist/core/backends/claude-errors.js +71 -0
  85. package/dist/core/backends/claude-errors.js.map +1 -0
  86. package/dist/core/backends/claude-probe.d.ts +103 -0
  87. package/dist/core/backends/claude-probe.d.ts.map +1 -0
  88. package/dist/core/backends/claude-probe.js +336 -0
  89. package/dist/core/backends/claude-probe.js.map +1 -0
  90. package/dist/core/backends/claude-tool-collection.d.ts +135 -0
  91. package/dist/core/backends/claude-tool-collection.d.ts.map +1 -0
  92. package/dist/core/backends/claude-tool-collection.js +831 -0
  93. package/dist/core/backends/claude-tool-collection.js.map +1 -0
  94. package/dist/core/backends/gemini-cli-core.d.ts +21 -0
  95. package/dist/core/backends/gemini-cli-core.d.ts.map +1 -1
  96. package/dist/core/backends/gemini-cli-core.js +84 -6
  97. package/dist/core/backends/gemini-cli-core.js.map +1 -1
  98. package/dist/core/backends/prompt-utils.d.ts +1 -0
  99. package/dist/core/backends/prompt-utils.d.ts.map +1 -1
  100. package/dist/core/backends/prompt-utils.js +60 -3
  101. package/dist/core/backends/prompt-utils.js.map +1 -1
  102. package/dist/core/context-builder.d.ts +36 -12
  103. package/dist/core/context-builder.d.ts.map +1 -1
  104. package/dist/core/context-builder.js +179 -89
  105. package/dist/core/context-builder.js.map +1 -1
  106. package/dist/core/dispatcher-date-utils.d.ts +49 -0
  107. package/dist/core/dispatcher-date-utils.d.ts.map +1 -0
  108. package/dist/core/dispatcher-date-utils.js +132 -0
  109. package/dist/core/dispatcher-date-utils.js.map +1 -0
  110. package/dist/core/dispatcher-error-handling.d.ts +159 -0
  111. package/dist/core/dispatcher-error-handling.d.ts.map +1 -0
  112. package/dist/core/dispatcher-error-handling.js +393 -0
  113. package/dist/core/dispatcher-error-handling.js.map +1 -0
  114. package/dist/core/dispatcher-hourly-check.d.ts +150 -0
  115. package/dist/core/dispatcher-hourly-check.d.ts.map +1 -0
  116. package/dist/core/dispatcher-hourly-check.js +665 -0
  117. package/dist/core/dispatcher-hourly-check.js.map +1 -0
  118. package/dist/core/dispatcher-message-handler.d.ts +170 -0
  119. package/dist/core/dispatcher-message-handler.d.ts.map +1 -0
  120. package/dist/core/dispatcher-message-handler.js +1054 -0
  121. package/dist/core/dispatcher-message-handler.js.map +1 -0
  122. package/dist/core/dispatcher-morning-routine.d.ts +169 -0
  123. package/dist/core/dispatcher-morning-routine.d.ts.map +1 -0
  124. package/dist/core/dispatcher-morning-routine.js +434 -0
  125. package/dist/core/dispatcher-morning-routine.js.map +1 -0
  126. package/dist/core/dispatcher-prompt.d.ts +107 -0
  127. package/dist/core/dispatcher-prompt.d.ts.map +1 -0
  128. package/dist/core/dispatcher-prompt.js +227 -0
  129. package/dist/core/dispatcher-prompt.js.map +1 -0
  130. package/dist/core/dispatcher-repository-helpers.d.ts +39 -0
  131. package/dist/core/dispatcher-repository-helpers.d.ts.map +1 -0
  132. package/dist/core/dispatcher-repository-helpers.js +86 -0
  133. package/dist/core/dispatcher-repository-helpers.js.map +1 -0
  134. package/dist/core/dispatcher-result-processor.d.ts +145 -0
  135. package/dist/core/dispatcher-result-processor.d.ts.map +1 -0
  136. package/dist/core/dispatcher-result-processor.js +414 -0
  137. package/dist/core/dispatcher-result-processor.js.map +1 -0
  138. package/dist/core/dispatcher-scheduled-tasks.d.ts +406 -0
  139. package/dist/core/dispatcher-scheduled-tasks.d.ts.map +1 -0
  140. package/dist/core/dispatcher-scheduled-tasks.js +998 -0
  141. package/dist/core/dispatcher-scheduled-tasks.js.map +1 -0
  142. package/dist/core/dispatcher-types.d.ts +296 -0
  143. package/dist/core/dispatcher-types.d.ts.map +1 -0
  144. package/dist/core/dispatcher-types.js +106 -0
  145. package/dist/core/dispatcher-types.js.map +1 -0
  146. package/dist/core/dispatcher.d.ts +86 -610
  147. package/dist/core/dispatcher.d.ts.map +1 -1
  148. package/dist/core/dispatcher.js +293 -3542
  149. package/dist/core/dispatcher.js.map +1 -1
  150. package/dist/core/integration-health.d.ts +18 -10
  151. package/dist/core/integration-health.d.ts.map +1 -1
  152. package/dist/core/integration-health.js +31 -1
  153. package/dist/core/integration-health.js.map +1 -1
  154. package/dist/core/integration-lifecycle.d.ts +65 -0
  155. package/dist/core/integration-lifecycle.d.ts.map +1 -1
  156. package/dist/core/integration-lifecycle.js +167 -16
  157. package/dist/core/integration-lifecycle.js.map +1 -1
  158. package/dist/core/integration-main-backend.d.ts +40 -0
  159. package/dist/core/integration-main-backend.d.ts.map +1 -1
  160. package/dist/core/integration-main-backend.js +89 -2
  161. package/dist/core/integration-main-backend.js.map +1 -1
  162. package/dist/core/management-md.d.ts +51 -17
  163. package/dist/core/management-md.d.ts.map +1 -1
  164. package/dist/core/management-md.js +233 -56
  165. package/dist/core/management-md.js.map +1 -1
  166. package/dist/core/output-language-policy.d.ts +74 -0
  167. package/dist/core/output-language-policy.d.ts.map +1 -0
  168. package/dist/core/output-language-policy.js +194 -0
  169. package/dist/core/output-language-policy.js.map +1 -0
  170. package/dist/core/prompts.d.ts +1 -0
  171. package/dist/core/prompts.d.ts.map +1 -1
  172. package/dist/core/prompts.js +121 -3
  173. package/dist/core/prompts.js.map +1 -1
  174. package/dist/core/repository-management-docs.d.ts +24 -0
  175. package/dist/core/repository-management-docs.d.ts.map +1 -1
  176. package/dist/core/repository-management-docs.js +210 -26
  177. package/dist/core/repository-management-docs.js.map +1 -1
  178. package/dist/core/routine-acquisition-plan.d.ts +131 -0
  179. package/dist/core/routine-acquisition-plan.d.ts.map +1 -0
  180. package/dist/core/routine-acquisition-plan.js +268 -0
  181. package/dist/core/routine-acquisition-plan.js.map +1 -0
  182. package/dist/core/routine-fetch-window-runner.d.ts +201 -0
  183. package/dist/core/routine-fetch-window-runner.d.ts.map +1 -0
  184. package/dist/core/routine-fetch-window-runner.js +661 -0
  185. package/dist/core/routine-fetch-window-runner.js.map +1 -0
  186. package/dist/core/routine-windows.d.ts +156 -0
  187. package/dist/core/routine-windows.d.ts.map +1 -0
  188. package/dist/core/routine-windows.js +330 -0
  189. package/dist/core/routine-windows.js.map +1 -0
  190. package/dist/core/skills-compiler.d.ts +11 -0
  191. package/dist/core/skills-compiler.d.ts.map +1 -1
  192. package/dist/core/skills-compiler.js +102 -13
  193. package/dist/core/skills-compiler.js.map +1 -1
  194. package/dist/core/skills-manifest.d.ts.map +1 -1
  195. package/dist/core/skills-manifest.js +26 -0
  196. package/dist/core/skills-manifest.js.map +1 -1
  197. package/dist/core/system-reset.d.ts.map +1 -1
  198. package/dist/core/system-reset.js +25 -2
  199. package/dist/core/system-reset.js.map +1 -1
  200. package/dist/db/observations.d.ts +45 -2
  201. package/dist/db/observations.d.ts.map +1 -1
  202. package/dist/db/observations.js +112 -14
  203. package/dist/db/observations.js.map +1 -1
  204. package/dist/db/schema.d.ts.map +1 -1
  205. package/dist/db/schema.js +13 -25
  206. package/dist/db/schema.js.map +1 -1
  207. package/dist/index.js +83 -610
  208. package/dist/index.js.map +1 -1
  209. package/dist/observers/delegated-sync-worker.d.ts +45 -2
  210. package/dist/observers/delegated-sync-worker.d.ts.map +1 -1
  211. package/dist/observers/delegated-sync-worker.js +71 -21
  212. package/dist/observers/delegated-sync-worker.js.map +1 -1
  213. package/dist/observers/mail-poller.d.ts +12 -5
  214. package/dist/observers/mail-poller.d.ts.map +1 -1
  215. package/dist/observers/mail-poller.js +36 -14
  216. package/dist/observers/mail-poller.js.map +1 -1
  217. package/dist/observers/manager.d.ts +37 -5
  218. package/dist/observers/manager.d.ts.map +1 -1
  219. package/dist/observers/manager.js +28 -10
  220. package/dist/observers/manager.js.map +1 -1
  221. package/dist/safety/risk-classifier.d.ts.map +1 -1
  222. package/dist/safety/risk-classifier.js +5 -0
  223. package/dist/safety/risk-classifier.js.map +1 -1
  224. package/dist/services/delegated-backend-invoker.d.ts +1 -51
  225. package/dist/services/delegated-backend-invoker.d.ts.map +1 -1
  226. package/dist/services/delegated-backend-invoker.js +41 -480
  227. package/dist/services/delegated-backend-invoker.js.map +1 -1
  228. package/dist/services/delegated-invoker-audit.d.ts +94 -0
  229. package/dist/services/delegated-invoker-audit.d.ts.map +1 -0
  230. package/dist/services/delegated-invoker-audit.js +238 -0
  231. package/dist/services/delegated-invoker-audit.js.map +1 -0
  232. package/dist/services/delegated-invoker-cache-hits.d.ts +34 -0
  233. package/dist/services/delegated-invoker-cache-hits.d.ts.map +1 -0
  234. package/dist/services/delegated-invoker-cache-hits.js +104 -0
  235. package/dist/services/delegated-invoker-cache-hits.js.map +1 -0
  236. package/dist/services/delegated-invoker-janitors.d.ts +28 -0
  237. package/dist/services/delegated-invoker-janitors.d.ts.map +1 -0
  238. package/dist/services/delegated-invoker-janitors.js +104 -0
  239. package/dist/services/delegated-invoker-janitors.js.map +1 -0
  240. package/dist/services/delegated-invoker-utils.d.ts +42 -0
  241. package/dist/services/delegated-invoker-utils.d.ts.map +1 -0
  242. package/dist/services/delegated-invoker-utils.js +100 -0
  243. package/dist/services/delegated-invoker-utils.js.map +1 -0
  244. package/dist/services/delegated-task-runtime.d.ts +1 -1
  245. package/dist/services/delegated-task-runtime.js +1 -1
  246. package/dist/services/integrations/snapshot-partitions.d.ts +5 -0
  247. package/dist/services/integrations/snapshot-partitions.d.ts.map +1 -1
  248. package/dist/services/integrations/snapshot-partitions.js +12 -0
  249. package/dist/services/integrations/snapshot-partitions.js.map +1 -1
  250. package/dist/services/voice/transcriber-impl.d.ts.map +1 -1
  251. package/dist/services/voice/transcriber-impl.js +46 -0
  252. package/dist/services/voice/transcriber-impl.js.map +1 -1
  253. package/package.json +12 -12
@@ -0,0 +1,1054 @@
1
+ /**
2
+ * `MessageHandler` — owns the dispatcher's reactive message-event path:
3
+ * the bang-command interceptor, the cross-platform setup lockout, the
4
+ * `/auth` command surface (`handleAuthCommand`), the resume-vs-fresh-
5
+ * execute decision, the user/assistant message persistence, and the
6
+ * STAGE-C DM freshness telemetry (`collectDmFreshnessTelemetry`).
7
+ *
8
+ * Extracted from `core/dispatcher.ts` as part of phase D-3 of
9
+ * `docs/design/appendices/file-split-plan.md`. Pattern B (stateful
10
+ * coordinator): the handler owns its own logic but borrows live
11
+ * accessors back into the dispatcher for state that is either lazily
12
+ * injected after the dispatcher is constructed (dashboard stream,
13
+ * attachment store, signal detector, docs-QA lookup, auth recovery /
14
+ * health monitor, bang-command registry) or that the dispatcher
15
+ * continues to own as a process-wide flag (`currentSetupMode`).
16
+ *
17
+ * Dispatcher entry points served:
18
+ * - `dispatch.handleMessage` (every owner DM / channel mention /
19
+ * dashboard chat / docs_qa turn) routes through `handle`;
20
+ * - `dispatcher.test.ts` reaches `handleAuthCommand` directly through
21
+ * a private-access cast — preserved as a shim on the dispatcher
22
+ * that forwards to this handler.
23
+ *
24
+ * Shared-state references held (live, not by-value):
25
+ * - `currentSetupMode` getter + `beginSetupMode` setter — the
26
+ * dispatcher owns the persisted-to-runtime_state flag; the handler
27
+ * reads the current value and triggers the same setter the
28
+ * dashboard wizard uses.
29
+ * - Lazy accessors (`getSignalDetector`, `getDashboardStream`,
30
+ * `getAttachmentStore`, `getDocsCitationLookup`,
31
+ * `getAuthRecovery`, `getAuthHealthMonitor`,
32
+ * `getBangCommandRegistry`) — each is null until `index.ts` finishes
33
+ * wiring; reading through the closure ensures the handler sees the
34
+ * current value on every call.
35
+ * - Method delegates (`lookupCustomBangCommandForEvent`,
36
+ * `getConfiguredServices`, `getActiveMailAccounts`,
37
+ * `readLastInsertedMessageId`) — these remain on the dispatcher
38
+ * for now; the handler invokes them via callbacks so the move
39
+ * stays a verbatim relocation.
40
+ *
41
+ * No behavior change. See §7 D-3 of file-split-plan.md for the staged
42
+ * "move now, refine later" plan.
43
+ */
44
+ import { existsSync } from "node:fs";
45
+ import { join } from "node:path";
46
+ import { formatSqliteDatetime, isDocsQAMessage, isMessageEvent, parseSqliteUtcMs, resolveProcessKey, } from "@aitne/shared";
47
+ import { getModelLabel } from "./backends/model-registry.js";
48
+ import { parseGeminiAuthCode } from "./backends/auth-recovery.js";
49
+ import { tryHandle as tryHandleBangCommand } from "./bang-commands/registry.js";
50
+ import { CUSTOM_BANG_COMMAND_SOURCE, createUserBangCommandEvent, resolveCommandSkillSlugs, } from "./bang-commands/user-commands.js";
51
+ import { logInvalidCitations, validateAndRewrite, } from "./docs/citation-validator.js";
52
+ import { countContextWritesInWindow, didRefetchTodayDuringTurn, matchesRecentActivityTrigger, } from "./dm-freshness-metrics.js";
53
+ import { ensureSessionWorkdir, getSessionWorkdirPath, syncAllUserSkills, } from "./workdir.js";
54
+ import { upsertOwnerChannel } from "../messaging/owner-channels.js";
55
+ import { readIntegrations } from "../db/integrations-store.js";
56
+ import { createLogger } from "../logging.js";
57
+ const logger = createLogger("dispatcher-message");
58
+ export class MessageHandler {
59
+ db;
60
+ config;
61
+ eventBus;
62
+ agentRouter;
63
+ contextBuilder;
64
+ notificationMgr;
65
+ sessionMgr;
66
+ messageRecorder;
67
+ audit;
68
+ prompt;
69
+ errorRouter;
70
+ resultProcessor;
71
+ getSignalDetector;
72
+ getDashboardStream;
73
+ getAttachmentStore;
74
+ getDocsCitationLookup;
75
+ getAuthRecovery;
76
+ getAuthHealthMonitor;
77
+ getBangCommandRegistry;
78
+ getCurrentSetupMode;
79
+ beginSetupMode;
80
+ lookupCustomBangCommandForEvent;
81
+ getConfiguredServices;
82
+ getActiveMailAccounts;
83
+ readLastInsertedMessageId;
84
+ constructor(deps) {
85
+ this.db = deps.db;
86
+ this.config = deps.config;
87
+ this.eventBus = deps.eventBus;
88
+ this.agentRouter = deps.agentRouter;
89
+ this.contextBuilder = deps.contextBuilder;
90
+ this.notificationMgr = deps.notificationMgr;
91
+ this.sessionMgr = deps.sessionMgr;
92
+ this.messageRecorder = deps.messageRecorder;
93
+ this.audit = deps.audit;
94
+ this.prompt = deps.prompt;
95
+ this.errorRouter = deps.errorRouter;
96
+ this.resultProcessor = deps.resultProcessor;
97
+ this.getSignalDetector = deps.getSignalDetector;
98
+ this.getDashboardStream = deps.getDashboardStream;
99
+ this.getAttachmentStore = deps.getAttachmentStore;
100
+ this.getDocsCitationLookup = deps.getDocsCitationLookup;
101
+ this.getAuthRecovery = deps.getAuthRecovery;
102
+ this.getAuthHealthMonitor = deps.getAuthHealthMonitor;
103
+ this.getBangCommandRegistry = deps.getBangCommandRegistry;
104
+ this.getCurrentSetupMode = deps.getCurrentSetupMode;
105
+ this.beginSetupMode = deps.beginSetupMode;
106
+ this.lookupCustomBangCommandForEvent = deps.lookupCustomBangCommandForEvent;
107
+ this.getConfiguredServices = deps.getConfiguredServices;
108
+ this.getActiveMailAccounts = deps.getActiveMailAccounts;
109
+ this.readLastInsertedMessageId = deps.readLastInsertedMessageId;
110
+ }
111
+ /**
112
+ * Phase 5 — intercept owner `/auth …` DMs before they reach the agent
113
+ * backend. Returns `true` when the DM was handled (caller must short-
114
+ * circuit), `false` to fall through to normal message processing.
115
+ *
116
+ * Verbatim move from `dispatcher.ts:handleAuthCommand` — no semantic
117
+ * change. See file-split-plan.md §7 D-3.
118
+ */
119
+ async handleAuthCommand(event) {
120
+ const authRecovery = this.getAuthRecovery();
121
+ const authHealthMonitor = this.getAuthHealthMonitor();
122
+ const text = event.content.trim().toLowerCase();
123
+ // `/auth status` — show current auth state
124
+ if (text === "/auth status") {
125
+ const summary = authHealthMonitor
126
+ ? authHealthMonitor.renderStatusSummary()
127
+ : "Check auth status on the dashboard or via `GET /api/backends`.";
128
+ await this.notificationMgr.send(summary, event);
129
+ return true;
130
+ }
131
+ // `/auth fix claude` — start Claude browser auth recovery (Phase 9)
132
+ if (text === "/auth fix claude") {
133
+ if (!authRecovery)
134
+ return false;
135
+ if (authRecovery.isRecoveryActive("claude")) {
136
+ const active = authRecovery.getActiveRecovery("claude");
137
+ await this.notificationMgr.send(`Claude auth recovery already in progress.\n` +
138
+ `URL: ${active?.authUrl}`, event);
139
+ return true;
140
+ }
141
+ try {
142
+ const recovery = await authRecovery.initiateClaudeAuth();
143
+ await this.notificationMgr.send(`Claude auth recovery started.\n` +
144
+ `Open the following URL in your browser to sign in:\n${recovery.authUrl}` +
145
+ `\n(timeout in ${recovery.expiresMinutes} min)`, event);
146
+ }
147
+ catch (err) {
148
+ const msg = err instanceof Error ? err.message : "Unknown error";
149
+ await this.notificationMgr.send(`Failed to start Claude auth recovery: ${msg}`, event);
150
+ }
151
+ return true;
152
+ }
153
+ // `/auth fix codex` — start Codex device auth recovery
154
+ if (text === "/auth fix codex") {
155
+ if (!authRecovery)
156
+ return false;
157
+ if (authRecovery.isRecoveryActive("codex")) {
158
+ const active = authRecovery.getActiveRecovery("codex");
159
+ await this.notificationMgr.send(`Codex auth recovery already in progress.\n` +
160
+ `URL: ${active?.authUrl}\nCode: ${active?.userCode}`, event);
161
+ return true;
162
+ }
163
+ try {
164
+ const recovery = await authRecovery.initiateCodexDeviceAuth();
165
+ // The recovery itself sends a notification with URL/code,
166
+ // but also reply directly to the DM for immediate feedback.
167
+ await this.notificationMgr.send(`Codex auth recovery started.\n` +
168
+ `Open ${recovery.authUrl} in your browser and enter code ${recovery.userCode}.` +
169
+ `\n(expires in ${recovery.expiresMinutes} min)`, event);
170
+ }
171
+ catch (err) {
172
+ const msg = err instanceof Error ? err.message : "Unknown error";
173
+ await this.notificationMgr.send(`Failed to start Codex auth recovery: ${msg}`, event);
174
+ }
175
+ return true;
176
+ }
177
+ // `/auth fix all` — recover all expired backends sequentially
178
+ if (text === "/auth fix all") {
179
+ if (!authRecovery || !authHealthMonitor)
180
+ return false;
181
+ const expired = authHealthMonitor.listExpiredBackends();
182
+ if (expired.length === 0) {
183
+ await this.notificationMgr.send("All backends are healthy. No recovery needed.", event);
184
+ return true;
185
+ }
186
+ const results = [];
187
+ for (const bid of expired) {
188
+ // Skip backends that already have an active recovery session
189
+ if (authRecovery.isRecoveryActive(bid)) {
190
+ results.push(`🔄 ${bid} — Recovery already in progress.`);
191
+ continue;
192
+ }
193
+ try {
194
+ if (bid === "claude") {
195
+ const recovery = await authRecovery.initiateClaudeAuth();
196
+ results.push(`✅ claude — Recovery started. Open the following URL in your browser to sign in:\n${recovery.authUrl}\n(timeout in ${recovery.expiresMinutes} min)`);
197
+ }
198
+ else if (bid === "codex") {
199
+ const recovery = await authRecovery.initiateCodexDeviceAuth();
200
+ results.push(`✅ codex — Recovery started. Open ${recovery.authUrl} in your browser and enter code ${recovery.userCode} (expires in ${recovery.expiresMinutes} min).`);
201
+ }
202
+ else if (bid === "gemini") {
203
+ const recovery = await authRecovery.initiateGeminiAuth();
204
+ results.push(`✅ gemini — Recovery started. Open the following URL in your browser and authenticate, then send the code here:\n${recovery.authUrl}\n(expires in ${recovery.expiresMinutes} min)`);
205
+ }
206
+ else {
207
+ results.push(`⚠️ ${bid} — No automated recovery available for this backend.`);
208
+ }
209
+ }
210
+ catch (err) {
211
+ const msg = err instanceof Error ? err.message : "Unknown error";
212
+ results.push(`❌ ${bid} — Failed to start recovery: ${msg}`);
213
+ }
214
+ }
215
+ const summary = authHealthMonitor.renderStatusSummary();
216
+ await this.notificationMgr.send(`Auth recovery results:\n\n${results.join("\n\n")}\n\n---\n${summary}`, event);
217
+ return true;
218
+ }
219
+ // `/auth fix gemini` — start Gemini OAuth recovery
220
+ if (text === "/auth fix gemini") {
221
+ if (!authRecovery)
222
+ return false;
223
+ if (authRecovery.isRecoveryActive("gemini")) {
224
+ const active = authRecovery.getActiveRecovery("gemini");
225
+ await this.notificationMgr.send(`Gemini auth recovery already in progress.\n` +
226
+ `Open the following URL in your browser to authenticate:\n${active?.authUrl}\n` +
227
+ `Then send the authorization code here.`, event);
228
+ return true;
229
+ }
230
+ try {
231
+ const recovery = await authRecovery.initiateGeminiAuth();
232
+ await this.notificationMgr.send(`Gemini auth recovery started.\n` +
233
+ `Open the following URL in your browser and sign in with your Google account:\n${recovery.authUrl}\n` +
234
+ `Then send the authorization code here.` +
235
+ `\n(expires in ${recovery.expiresMinutes} min)`, event);
236
+ }
237
+ catch (err) {
238
+ const msg = err instanceof Error ? err.message : "Unknown error";
239
+ await this.notificationMgr.send(`Failed to start Gemini auth recovery: ${msg}`, event);
240
+ }
241
+ return true;
242
+ }
243
+ // `/auth cancel` — cancel active recovery
244
+ if (text === "/auth cancel" || text.startsWith("/auth cancel ")) {
245
+ if (!authRecovery)
246
+ return false;
247
+ const parts = text.split(/\s+/);
248
+ const backendHint = parts[2];
249
+ // Cancel all active recoveries, or a specific one
250
+ let cancelled = false;
251
+ for (const bid of ["codex", "gemini", "claude"]) {
252
+ if (backendHint && bid !== backendHint)
253
+ continue;
254
+ if (authRecovery.cancelRecovery(bid))
255
+ cancelled = true;
256
+ }
257
+ await this.notificationMgr.send(cancelled ? "Auth recovery cancelled." : "No active auth recovery to cancel.", event);
258
+ return true;
259
+ }
260
+ // Not an auth command
261
+ return false;
262
+ }
263
+ /**
264
+ * Process a reactive message event end-to-end: bang commands, setup
265
+ * lockout, `/auth` interception, session resume/fresh-execute, message
266
+ * persistence, attachment plumbing, dashboard streaming, and the §4.5
267
+ * delegated-connector health DM.
268
+ *
269
+ * Verbatim move from `dispatcher.ts:handleMessage`. The dispatcher
270
+ * keeps a thin `handleMessage` shim that forwards here so private-
271
+ * access test casts continue to work.
272
+ */
273
+ async handle(event) {
274
+ // Bang-command interceptor — runs first so `!stop` / `!cost` / `!report`
275
+ // succeed even mid-setup, mid-auth-recovery, etc., and so non-bang DMs
276
+ // received while the agent is paused short-circuit before reaching the
277
+ // backend (I-3). See docs/design/backlog/messaging-bang-commands.md §6.2.
278
+ const bangCommandRegistry = this.getBangCommandRegistry();
279
+ if (bangCommandRegistry) {
280
+ const handled = await tryHandleBangCommand(bangCommandRegistry, {
281
+ event,
282
+ db: this.db,
283
+ config: this.config,
284
+ audit: this.audit,
285
+ rawSend: (text) => this.notificationMgr.send(text, event),
286
+ enqueueUserBangCommand: async (command, sourceEvent) => {
287
+ await this.eventBus.put(createUserBangCommandEvent(sourceEvent, command));
288
+ },
289
+ });
290
+ if (handled)
291
+ return;
292
+ }
293
+ // Cross-platform DM lockout during setup.
294
+ // The owner-DM scope is singular across platforms (Slack/Discord/Telegram/
295
+ // WhatsApp/dashboard all share one conversation_sessions row). While a
296
+ // dashboard setup conversation is in progress, a DM from any other
297
+ // platform would otherwise be routed through the active `setup.initial`
298
+ // / `setup.update` prompt — taking a Slack "ping" and feeding it to the
299
+ // rules-generator agent. Reject non-dashboard DMs with a fixed message
300
+ // so the user knows why we are stalling and where to finish setup.
301
+ // Dashboard messages are exempt so the user can still progress setup.
302
+ // Channel mentions (not DMs) are also exempt — they have their own
303
+ // session scope and do not interact with the owner-DM row.
304
+ //
305
+ // `let` (not `const`): the defensive-sync branch below calls
306
+ // `this.beginSetupMode(eventSetupMode)`, which mutates the dispatcher's
307
+ // live `currentSetupMode`. The original `dispatcher.handleMessage` read
308
+ // `this.currentSetupMode` afresh on every reference; the extraction
309
+ // captures it into a local for readability but must keep that local
310
+ // in sync with the live state so later checks (notably the §4.5
311
+ // connector-warnings consult below) see the post-sync value, not the
312
+ // pre-sync snapshot. Without the re-assignment, the warning consult
313
+ // would fire during a defensive-sync setup turn — a regression vs.
314
+ // the pre-D-3 behaviour.
315
+ let currentSetupMode = this.getCurrentSetupMode();
316
+ if (event.isDm &&
317
+ event.platform !== "dashboard" &&
318
+ currentSetupMode !== null) {
319
+ logger.info({ platform: event.platform, mode: currentSetupMode }, "Non-dashboard DM rejected — setup in progress");
320
+ this.audit.logSkip(event, "setup_in_progress", "reactive");
321
+ await this.notificationMgr.send("Setup is in progress. Please complete setup on the dashboard first, then try again.", event);
322
+ return;
323
+ }
324
+ // Phase 6 §5.2: intercept Google OAuth auth codes during pending Gemini
325
+ // recovery. Must come before `/auth` command check so the code isn't
326
+ // treated as an unknown command or routed to the agent backend.
327
+ const authRecovery = this.getAuthRecovery();
328
+ if (event.isDm && authRecovery?.isRecoveryActive("gemini")) {
329
+ const code = parseGeminiAuthCode(event.content);
330
+ if (code) {
331
+ try {
332
+ const result = await authRecovery.handleGeminiAuthCode(code);
333
+ const icon = result.ok ? "✅" : "❌";
334
+ await this.notificationMgr.send(`${icon} Gemini auth: ${result.detail}`, event);
335
+ }
336
+ catch (err) {
337
+ const msg = err instanceof Error ? err.message : "Unknown error";
338
+ await this.notificationMgr.send(`Failed to process Gemini auth code: ${msg}`, event);
339
+ }
340
+ return;
341
+ }
342
+ }
343
+ // Phase 5: intercept `/auth` commands before they reach the agent backend.
344
+ // Gated on DM + at least one auth subsystem being available (/auth status
345
+ // only needs the monitor; /auth fix needs the recovery manager).
346
+ if (event.isDm && (authRecovery || this.getAuthHealthMonitor())) {
347
+ const authResult = await this.handleAuthCommand(event);
348
+ if (authResult)
349
+ return;
350
+ }
351
+ // Check for explicit close command before processing.
352
+ // Use findActive (not getOrCreate) to avoid creating an orphan session.
353
+ if (this.sessionMgr.isCloseCommand(event.content)) {
354
+ const existing = await this.sessionMgr.findActive({
355
+ platform: event.platform,
356
+ channel: event.channel,
357
+ threadId: event.threadId,
358
+ isDm: event.isDm,
359
+ intent: event.intent,
360
+ });
361
+ if (existing) {
362
+ // recordMessage persists the row and touches
363
+ // last_message_at/message_count in a single transaction, so
364
+ // retention + dashboard sidebar stay consistent with the actual
365
+ // `messages` row count. closeSession then flips status.
366
+ this.messageRecorder.recordMessage({
367
+ sessionId: existing.id,
368
+ role: "user",
369
+ content: event.content,
370
+ platform: event.platform,
371
+ senderId: event.sender,
372
+ });
373
+ this.sessionMgr.closeSession(existing.id);
374
+ }
375
+ await this.notificationMgr.send("Session closed.", event);
376
+ return;
377
+ }
378
+ const replyActivity = await this.notificationMgr.beginReplyActivity(event);
379
+ let turnToken = null;
380
+ // STAGE-C-DM-FRESHNESS-PLAN §Task 4 — capture the turn-start reference
381
+ // BEFORE any context_write/context_read row could be written during
382
+ // this turn. Used as the upper bound when counting writes the agent
383
+ // missed pre-resume, and as the lower bound when detecting whether
384
+ // the agent issued a refetch during the current turn.
385
+ const turnStartedAtSqlite = formatSqliteDatetime(new Date());
386
+ try {
387
+ // Docs-QA traffic is a side-channel that must never participate in
388
+ // setup state. Two invariants enforced here:
389
+ // 1. A docs_qa event with a smuggled `data.setupMode` must NOT
390
+ // flip the dispatcher's global `currentSetupMode` — that would
391
+ // hijack subsequent owner DMs into the rules-generator agent.
392
+ // 2. A docs_qa event arriving while `currentSetupMode` is already
393
+ // set (operator opens Docs QA in another tab during setup)
394
+ // must still resolve via `dashboard.docs_qa` so TIER_LOCKED
395
+ // fires and the QA workdir/skill set is materialized — not the
396
+ // setup processKey/light tier/setup skill set. Without this
397
+ // gate, the §11.2 promptKey fix would load the QA prompt while
398
+ // the binding/workdir came from setup, producing an incoherent
399
+ // "QA prompt + setup tools" execution.
400
+ const eventSetupMode = event.data?.setupMode;
401
+ const isDocsQA = isDocsQAMessage(event);
402
+ if (eventSetupMode && currentSetupMode === null && !isDocsQA) {
403
+ // Defensive sync — normally `/setup/start` has already called
404
+ // beginSetupMode, but this keeps prompt selection consistent even if
405
+ // a future caller bypasses the helper and only sets event.data.
406
+ this.beginSetupMode(eventSetupMode);
407
+ // Mirror the just-applied mutation into the local so the
408
+ // §4.5 connector-warnings consult below observes the same
409
+ // value the dispatcher's `this.currentSetupMode` now holds.
410
+ currentSetupMode = eventSetupMode;
411
+ }
412
+ const setupMode = isDocsQA
413
+ ? null
414
+ : (eventSetupMode ?? currentSetupMode);
415
+ const processKey = setupMode === "initial" || setupMode === "update"
416
+ ? "setup"
417
+ : resolveProcessKey(event);
418
+ // Honor the dashboard chat model picker. MessageEvent.requestedModel
419
+ // and the (requestedBackendId, requestedModelId) pair are only
420
+ // populated by the dashboard adapter (see POST /chat/messages in
421
+ // api/routes/sse.ts); other platforms never set them. Defense-in-depth:
422
+ // even if a future adapter were to set them, we gate on platform here
423
+ // so Slack/Telegram/Discord/WhatsApp can never force a specific model
424
+ // through these fields. Setup mode also ignores them — setup runs on
425
+ // the configured setup process key regardless of the user's pick.
426
+ //
427
+ // When both the explicit (backendId, modelId) pair and the legacy
428
+ // requestedModel are set, the pair wins: it is the superset that
429
+ // supports all backends and models, not just Claude sonnet/opus.
430
+ const honorOverride = (event.platform === "dashboard" || event.source === CUSTOM_BANG_COMMAND_SOURCE)
431
+ && !setupMode;
432
+ const requestedTier = honorOverride && event.requestedModel
433
+ ? event.requestedModel === "sonnet"
434
+ ? "medium"
435
+ : "high"
436
+ : undefined;
437
+ const overrideBackendId = honorOverride && event.requestedBackendId && event.requestedModelId
438
+ ? event.requestedBackendId
439
+ : undefined;
440
+ const overrideModelId = honorOverride && event.requestedBackendId && event.requestedModelId
441
+ ? event.requestedModelId
442
+ : undefined;
443
+ const route = this.agentRouter.resolveBinding(event, {
444
+ processKey,
445
+ ...(requestedTier ? { requestedTier } : {}),
446
+ ...(overrideBackendId && overrideModelId
447
+ ? { requestedBackendId: overrideBackendId, requestedModelId: overrideModelId }
448
+ : {}),
449
+ });
450
+ const session = await this.sessionMgr.getOrCreate({
451
+ platform: event.platform,
452
+ channel: event.channel,
453
+ threadId: event.threadId,
454
+ isDm: event.isDm,
455
+ intent: event.intent,
456
+ requiredBackend: route.main.backendId,
457
+ requiredModel: route.main.modelId,
458
+ });
459
+ const forwardContextAvailable = this.resultProcessor.hasRecentProactiveForwardContext(event, session.id);
460
+ // Custom messaging bang command (`!commandname`): the owner's
461
+ // saved row carries an opt-in skill set + an optional custom
462
+ // profile body. We forward those to `ensureSessionWorkdir` as a
463
+ // re-materialize override so the agent runs with the row's
464
+ // configuration for THIS turn. The override forces re-write of
465
+ // CLAUDE.md / AGENTS.md / GEMINI.md and the skill dirs even when
466
+ // the workdir already exists (regular DMs share the same dir).
467
+ // The next regular DM turn detects the bang stamp file written
468
+ // by `ensureSessionWorkdir` and re-materializes back to manifest
469
+ // defaults — keeping `!cmd` configurations from leaking into a
470
+ // natural conversation that follows.
471
+ const customBangCommand = this.lookupCustomBangCommandForEvent(event);
472
+ const workdirOverride = customBangCommand
473
+ ? {
474
+ skillSlugs: [...resolveCommandSkillSlugs(customBangCommand)],
475
+ profileBody: customBangCommand.instructionMd,
476
+ }
477
+ : undefined;
478
+ // Skip the owner-channel pairing record for docs_qa: the QA panel
479
+ // is not a messaging-app surface and would otherwise clutter
480
+ // /connections/messaging with synthetic "dashboard" pairings.
481
+ //
482
+ // `pendingConnectorWarnings` is captured here so both the resume and
483
+ // fresh-execute branches below can call the §4.5 DM dispatch via
484
+ // `dispatchPendingConnectorHealth()` AFTER each branch's user-message
485
+ // recordMessage — the dispatch's persist must follow the user message
486
+ // in DB-timestamp order or the dashboard's chat_meta history reload
487
+ // reorders the bubbles.
488
+ let pendingConnectorWarnings = [];
489
+ const dispatchPendingConnectorHealth = () => {
490
+ if (pendingConnectorWarnings.length === 0)
491
+ return;
492
+ this.errorRouter.runDelegatedConnectorWarningDispatch(pendingConnectorWarnings, event, route.main.backendId, session.id);
493
+ };
494
+ if (event.isDm && !isDocsQAMessage(event)) {
495
+ upsertOwnerChannel(this.db, {
496
+ platform: event.platform,
497
+ senderId: event.sender,
498
+ channelId: event.channel,
499
+ metadata: { threadId: event.threadId },
500
+ touchInbound: true,
501
+ });
502
+ // DELEGATED-MODE-V2-DESIGN.md §4.5 — at every DM dispatch, consult
503
+ // the cached probe for delegated integrations whose effective
504
+ // backend matches the session backend. If the cached probe shows
505
+ // missing required capabilities (the wizard / a future periodic
506
+ // re-probe wrote `present=false`), fire a one-shot DM warning the
507
+ // owner that same-backend mode is non-functional. The helper
508
+ // dedupes via `runtime_state` so resume-vs-fresh-execute do not
509
+ // spam the user. Cheap, synchronous DB-only inspection — runs on
510
+ // the hot path so the warning lands before the agent's reply.
511
+ //
512
+ // Skipped while the dispatcher is in setup mode: the wizard's
513
+ // background `probeLive` call may have just landed a `present=false`
514
+ // row for a connector the user is in the middle of authorising, and
515
+ // a DM telling them to "Re-authorize from your … connector
516
+ // settings, then re-run the integration probe from the dashboard"
517
+ // is wrong-tense for the in-flight setup conversation. The §10
518
+ // post-setup sign-out scenario the check exists for fires correctly
519
+ // on the first DM after `clearSetupMode` runs.
520
+ //
521
+ // Two-phase: consult the cached probe NOW (synchronous DB read),
522
+ // but defer the actual DM dispatch + dashboard messages-table
523
+ // persist until both branches below have recorded the inbound user
524
+ // message. Otherwise the warning's persist row carries a
525
+ // CURRENT_TIMESTAMP that lands BEFORE the user-message row's, and
526
+ // the dashboard's chat_meta history reload re-orders the bubbles
527
+ // (warning above user) — a one-time UX flicker.
528
+ pendingConnectorWarnings =
529
+ currentSetupMode === null
530
+ ? this.errorRouter.consultDelegatedConnectorWarnings(route.main.backendId)
531
+ : [];
532
+ }
533
+ // `event.channel` is captured at the moment the user POSTed their
534
+ // message. If the tab navigates away and reconnects, the SSE route
535
+ // calls `rebindSessionChannel` to update `conversation_sessions.
536
+ // channel_id` to the new UUID — but our closure here still holds
537
+ // the old value. `resolveDashboardChannel` reads the live DB value
538
+ // on every send so stream/meta/info/error events reach whichever
539
+ // tab is currently connected for this session.
540
+ const resolveDashboardChannel = () => this.sessionMgr.getActiveChannelIdForSession(session.id) ?? event.channel;
541
+ // Send resolved model info + DB session ID to dashboard so the
542
+ // sidebar badge is accurate and the frontend can persist the session.
543
+ const dashboardStream = this.getDashboardStream();
544
+ if (event.platform === "dashboard" && dashboardStream?.sendSessionInfo) {
545
+ dashboardStream.sendSessionInfo(resolveDashboardChannel(), {
546
+ sessionId: session.id,
547
+ model: route.main.modelId,
548
+ backend: route.main.backendId,
549
+ modelLabel: getModelLabel(route.main.backendId, route.main.modelId),
550
+ });
551
+ }
552
+ // Feed user message to SignalDetector for implicit feedback
553
+ // detection. Docs-QA messages are docs lookups, not feedback
554
+ // signals, so they bypass the detector entirely.
555
+ if (!isDocsQAMessage(event)) {
556
+ this.getSignalDetector()?.onUserMessage({
557
+ platform: event.platform,
558
+ content: event.content,
559
+ });
560
+ }
561
+ // Create stream callbacks for dashboard events (real-time SSE text).
562
+ // Each callback re-resolves the channel on invocation so a user
563
+ // who navigates away and returns mid-execute still receives the
564
+ // tail of the stream on their new tab.
565
+ let didStream = false;
566
+ const streamCb = event.platform === "dashboard" && dashboardStream
567
+ ? {
568
+ onText: (text) => {
569
+ didStream = true;
570
+ dashboardStream.sendStreamChunk(resolveDashboardChannel(), text);
571
+ },
572
+ onEnd: () => {
573
+ dashboardStream.sendStreamEnd(resolveDashboardChannel());
574
+ },
575
+ }
576
+ : undefined;
577
+ // Chat-attachments Phase 1 — issue a per-turn capability token the
578
+ // agent's `attach` skill will present via `X-Turn-Token`. Valid only
579
+ // while this turn is running; always cleared in the outer `finally`
580
+ // below so leakage is bounded to the lifetime of the turn.
581
+ const attachmentStore = this.getAttachmentStore();
582
+ turnToken = attachmentStore
583
+ ? this.prompt.issueAttachmentTurnToken(session.id)
584
+ : null;
585
+ // Can we resume an existing SDK session?
586
+ // Resume whenever this conversation already has a stored SDK session.
587
+ // Never resume on the FIRST message of a new setup — event.data.setupMode means
588
+ // "start a new setup", not "continue an existing one".
589
+ //
590
+ // Also require the session's persistent workdir to exist on disk. If
591
+ // it was removed out of band (manual cleanup, stale-workdir scanner
592
+ // bug, disk failure), attempting to resume would land the SDK in a
593
+ // freshly-created empty directory with no CLAUDE.md / AGENTS.md /
594
+ // skills tree, producing confusing output. Fall back to the fresh-
595
+ // execute branch, which re-materializes the workdir via
596
+ // `ensureSessionWorkdir`.
597
+ const isNewSetupStart = !!event.data?.setupMode;
598
+ const existingSessionDirPresent = session.isActive
599
+ && existsSync(getSessionWorkdirPath(this.config.dataDir, session.id));
600
+ const canResume = session.isActive
601
+ && session.sessionId
602
+ && existingSessionDirPresent
603
+ && !isNewSetupStart;
604
+ if (session.isActive && session.sessionId && !existingSessionDirPresent) {
605
+ logger.warn({ sessionId: session.id }, "Session marked resumable but workdir missing — falling back to fresh execute");
606
+ }
607
+ let result;
608
+ let userMessageId = null;
609
+ // STAGE-C-DM-FRESHNESS-PLAN §Task 2 — `<turn_context>` is injected on
610
+ // resume only. The resume payload is the bare user-message text; the
611
+ // SDK's cached system prompt holds the original `<current_time>` and
612
+ // the snapshot anchored by `<today snapshot_at="...">` (Task 1), both
613
+ // frozen at session start. Without a per-turn fresh-clock anchor, the
614
+ // model cannot compute "how stale is my snapshot" and answers from
615
+ // an out-of-date view of `## Agent Log`. On the fresh-execute branch,
616
+ // the system prompt's `<current_time>` is built at the moment of
617
+ // dispatch — adding `<turn_context>` there would be redundant AND
618
+ // would diverge the prompt prefix per turn, defeating prompt caching.
619
+ // If a future change rebuilds `<today>` mid-session, this code must
620
+ // be revisited because `started_at` would no longer be the snapshot
621
+ // reference.
622
+ let resumeTurnContext = null;
623
+ let resumeSnapshotAgeMinutes = 0;
624
+ if (canResume) {
625
+ // ── Resume existing SDK session ──
626
+ const proactiveForwardContext = forwardContextAvailable
627
+ ? await this.contextBuilder.build(event)
628
+ : null;
629
+ const userMsgRecorded = this.messageRecorder.recordMessage({
630
+ sessionId: session.id,
631
+ role: "user",
632
+ content: event.content,
633
+ platform: event.platform,
634
+ senderId: event.sender,
635
+ });
636
+ if (userMsgRecorded) {
637
+ userMessageId = this.readLastInsertedMessageId(session.id);
638
+ }
639
+ // Compute the freshness anchors for this resumed turn. `started_at`
640
+ // is the moment `<today>` was captured (the fresh-execute branch
641
+ // builds the system prompt then). Reading from the session row
642
+ // (rather than the in-memory `session` value) keeps this side-
643
+ // effect-free: the row was just fetched by `getOrCreate` and is
644
+ // authoritative.
645
+ const turnNow = new Date();
646
+ const sessionTimingRow = this.db
647
+ .prepare(`SELECT started_at FROM conversation_sessions WHERE id = ?`)
648
+ .get(session.id);
649
+ const sessionStartedAtSqlite = sessionTimingRow?.started_at ?? null;
650
+ const sessionStartedAtMs = sessionStartedAtSqlite
651
+ ? parseSqliteUtcMs(sessionStartedAtSqlite)
652
+ : turnNow.getTime();
653
+ resumeSnapshotAgeMinutes = Math.max(0, Math.round((turnNow.getTime() - sessionStartedAtMs) / 60_000));
654
+ resumeTurnContext =
655
+ `<turn_context current_time="${turnNow.toISOString()}" `
656
+ + `snapshot_age_minutes="${resumeSnapshotAgeMinutes}" />`;
657
+ // §4.5 connector-health DM is dispatched AFTER recordMessage so the
658
+ // warning's messages-table row carries a strictly-later timestamp
659
+ // than the user message. See `consultDelegatedConnectorWarnings`.
660
+ dispatchPendingConnectorHealth();
661
+ const sessionDir = ensureSessionWorkdir(this.config.workspaceDir, this.config.dataDir, session.id, event.type, {
662
+ backendId: session.backend ?? "claude",
663
+ processKey: route.processKey,
664
+ configuredServices: this.getConfiguredServices(),
665
+ mailAccounts: this.getActiveMailAccounts(),
666
+ integrations: readIntegrations(this.db),
667
+ character: this.config.character,
668
+ ...(workdirOverride ? { override: workdirOverride } : {}),
669
+ });
670
+ // Sync user-authored skills into the workdir before resuming, so any
671
+ // skill added/edited/deleted via /api/skills since the last turn is
672
+ // visible to the SDK's `.claude/skills/` discovery. Cheap and idempotent.
673
+ syncAllUserSkills(sessionDir, join(this.config.dataDir, "skills"));
674
+ // Phase 1 — stage inbound attachments + bind rows + append
675
+ // bracketed prompt block. For resume we can't prepend to the
676
+ // task-flow template (there isn't one on this path), so the
677
+ // attachment block is appended to the user's message text. A
678
+ // Claude SDK `query()` call sees `prompt` as a single string, so
679
+ // this is the only surface available.
680
+ const resumeStaged = isMessageEvent(event)
681
+ ? this.prompt.stageInboundAttachments(event, sessionDir)
682
+ : [];
683
+ if (resumeStaged.length > 0 && userMessageId !== null && attachmentStore) {
684
+ attachmentStore.bindInbound({
685
+ attachmentIds: resumeStaged.map((r) => r.id),
686
+ sessionId: session.id,
687
+ messageId: userMessageId,
688
+ });
689
+ }
690
+ const resumeTranscripts = await this.prompt.transcribeAttachments(resumeStaged);
691
+ const resumeMessage = resumeStaged.length > 0
692
+ ? `${event.content}\n${this.prompt.buildAttachmentPromptBlock(resumeStaged, resumeTranscripts)}`
693
+ : event.content;
694
+ const resumeMessageWithForwardContext = proactiveForwardContext
695
+ ? `${resumeTurnContext}\n\n${proactiveForwardContext}\n\n<current_user_message>\n${resumeMessage}\n</current_user_message>`
696
+ : `${resumeTurnContext}\n\n${resumeMessage}`;
697
+ const resumeStagedForBackend = resumeStaged.length > 0
698
+ ? resumeStaged.map((row) => ({
699
+ id: row.id,
700
+ safeFilename: row.safeFilename,
701
+ mimeType: row.mimeType,
702
+ absolutePath: `${sessionDir}/_attachments/${row.safeFilename}`,
703
+ relativePath: `_attachments/${row.safeFilename}`,
704
+ }))
705
+ : [];
706
+ result = await this.errorRouter.executeWithRetry(() => this.agentRouter.executeResume({
707
+ backendId: session.backend ?? "claude",
708
+ sessionId: session.sessionId,
709
+ message: resumeMessageWithForwardContext,
710
+ modelId: route.main.modelId,
711
+ maxTurns: route.main.maxTurns,
712
+ maxBudgetUsd: route.main.maxBudgetUsd,
713
+ sessionDir,
714
+ sessionDbId: session.id,
715
+ eventCorrelationId: event.correlationId,
716
+ ...(turnToken ? { turnToken } : {}),
717
+ ...(resumeStagedForBackend.length > 0
718
+ ? { stagedAttachments: resumeStagedForBackend }
719
+ : {}),
720
+ }, streamCb), event);
721
+ }
722
+ else {
723
+ // ── Fresh execute ──
724
+ // Docs-QA branches FIRST. Without this gate, `event.isDm` would
725
+ // route the QA event into the generic DM task flow and the
726
+ // agent would run without the QA system prompt (citation
727
+ // enforcement, search budget, "no write tools"). The
728
+ // `dashboard.docs_qa` task flow lives at
729
+ // agent-assets/task-flows/dashboard.docs_qa.md.
730
+ const promptKey = isDocsQAMessage(event)
731
+ ? "dashboard.docs_qa"
732
+ : setupMode === "initial"
733
+ ? "setup.initial"
734
+ : setupMode === "update"
735
+ ? "setup.update"
736
+ : event.isDm && !session.isActive
737
+ ? "message.received.dm_first"
738
+ : event.isDm
739
+ ? "message.received.dm"
740
+ : event.type;
741
+ const context = await this.contextBuilder.build(event);
742
+ // Setup flows route through processKey="setup" for backend binding,
743
+ // but the workdir must materialize with the mode-specific processKey
744
+ // so `setup.update` doesn't inherit `setup.initial`'s skill set via
745
+ // PROCESS_TO_EVENT_TYPE["setup"]="setup.initial".
746
+ const workdirEventType = setupMode ? `setup.${setupMode}` : promptKey;
747
+ const workdirProcessKey = setupMode
748
+ ? `setup.${setupMode}`
749
+ : route.processKey;
750
+ const reassemblePrompt = (bid) => this.prompt.assemble(promptKey, route.processKey, bid);
751
+ const prompt = reassemblePrompt(route.main.backendId);
752
+ // DMs need persistent workdirs/session ids for real resume semantics.
753
+ // Channel/thread conversations only persist high-tier sessions.
754
+ const shouldPersistSessionState = event.isDm || route.resolvedTier === "high";
755
+ const sessionDir = shouldPersistSessionState
756
+ ? ensureSessionWorkdir(this.config.workspaceDir, this.config.dataDir, session.id, workdirEventType, {
757
+ backendId: route.main.backendId,
758
+ processKey: workdirProcessKey,
759
+ configuredServices: this.getConfiguredServices(),
760
+ mailAccounts: this.getActiveMailAccounts(),
761
+ integrations: readIntegrations(this.db),
762
+ character: this.config.character,
763
+ ...(workdirOverride ? { override: workdirOverride } : {}),
764
+ })
765
+ : undefined;
766
+ // Re-sync user skills on every Opus message. ensureSessionWorkdir is
767
+ // idempotent and skips the copy step on subsequent calls, so without
768
+ // this explicit sync a skill created mid-session (via POST /api/skills)
769
+ // would never reach the session's `.claude/skills/` tree and the SDK
770
+ // wouldn't discover it. The sync is a cheap diff operation backed by
771
+ // a manifest file inside the workdir.
772
+ if (sessionDir) {
773
+ syncAllUserSkills(sessionDir, join(this.config.dataDir, "skills"));
774
+ }
775
+ // Docs-QA sessions are stateless lookups (DOCS_QA_B7_DESIGN.md
776
+ // §11.6 — "QA panel state lives in React state, not the DB").
777
+ // After a docs_qa session reset (day boundary, model switch),
778
+ // session-manager's `requiresHistoryInjection` would still fire
779
+ // because prior messages exist in the docs_qa scope; without
780
+ // this gate they'd bleed back into the prompt as cross-session
781
+ // history, contradicting the stateless contract and silently
782
+ // ballooning the QA token budget across days.
783
+ const conversationHistory = session.requiresHistoryInjection && !isDocsQAMessage(event)
784
+ ? this.resultProcessor.buildCrossSessionConversationHistory(event)
785
+ : null;
786
+ // Record user message AFTER context/history build (avoids injecting
787
+ // the current turn into cross-session history) but BEFORE execute
788
+ // (ensures DB has the message even if execute crashes).
789
+ const freshUserMsgRecorded = this.messageRecorder.recordMessage({
790
+ sessionId: session.id,
791
+ role: "user",
792
+ content: event.content,
793
+ platform: event.platform,
794
+ senderId: event.sender,
795
+ });
796
+ if (freshUserMsgRecorded) {
797
+ userMessageId = this.readLastInsertedMessageId(session.id);
798
+ }
799
+ // §4.5 connector-health DM is dispatched AFTER recordMessage so the
800
+ // warning's messages-table row carries a strictly-later timestamp
801
+ // than the user message. See `consultDelegatedConnectorWarnings`.
802
+ dispatchPendingConnectorHealth();
803
+ // Phase 1 — stage inbound attachments + bind rows + append
804
+ // bracketed prompt block to the prompt body.
805
+ const freshStaged = isMessageEvent(event)
806
+ ? this.prompt.stageInboundAttachments(event, sessionDir)
807
+ : [];
808
+ if (freshStaged.length > 0 && userMessageId !== null && attachmentStore) {
809
+ attachmentStore.bindInbound({
810
+ attachmentIds: freshStaged.map((r) => r.id),
811
+ sessionId: session.id,
812
+ messageId: userMessageId,
813
+ });
814
+ }
815
+ const freshTranscripts = await this.prompt.transcribeAttachments(freshStaged);
816
+ const executePrompt = freshStaged.length > 0
817
+ ? `${prompt}\n${this.prompt.buildAttachmentPromptBlock(freshStaged, freshTranscripts)}`
818
+ : prompt;
819
+ // DMs should always persist backend sessions so same-session resume and
820
+ // dashboard history continue do not fall back to history reinjection.
821
+ const persistSession = shouldPersistSessionState;
822
+ const freshStagedForBackend = freshStaged.length > 0 && sessionDir
823
+ ? freshStaged.map((row) => ({
824
+ id: row.id,
825
+ safeFilename: row.safeFilename,
826
+ mimeType: row.mimeType,
827
+ absolutePath: `${sessionDir}/_attachments/${row.safeFilename}`,
828
+ relativePath: `_attachments/${row.safeFilename}`,
829
+ }))
830
+ : [];
831
+ result = await this.errorRouter.executeWithRetry(() => this.agentRouter.execute({
832
+ prompt: executePrompt,
833
+ context,
834
+ event,
835
+ processKey: setupMode === "initial" || setupMode === "update"
836
+ ? "setup"
837
+ : resolveProcessKey(event),
838
+ sessionDir,
839
+ sessionDbId: session.id,
840
+ persistSession,
841
+ conversationHistory: conversationHistory ?? undefined,
842
+ preResolvedBinding: route,
843
+ workdirEventType,
844
+ workdirProcessKey,
845
+ reassemblePrompt,
846
+ ...(turnToken ? { turnToken } : {}),
847
+ ...(freshStagedForBackend.length > 0
848
+ ? { stagedAttachments: freshStagedForBackend }
849
+ : {}),
850
+ }, streamCb), event);
851
+ // Store SDK sessionId for future resume, including normal owner DMs.
852
+ if (persistSession && result.sessionId) {
853
+ await this.sessionMgr.updateSession(session.id, result.sessionId, result.modelId ?? result.model, result.backendId);
854
+ }
855
+ else if (persistSession && !result.sessionId) {
856
+ // Successful DM/heavy execute, but the backend didn't emit a
857
+ // resumable session id (observed with certain Gemini CLI
858
+ // streams where the `init` event fired without `session_id`).
859
+ // The row keeps its previous `backend_session_id` (possibly
860
+ // NULL) and the next turn will fall through to fresh-execute
861
+ // + history injection — still resumable from the sidebar via
862
+ // the relaxed gate. Log so this stops being invisible.
863
+ logger.warn({
864
+ sessionId: session.id,
865
+ backend: result.backendId,
866
+ model: result.modelId ?? result.model,
867
+ }, "Execute completed without a backend session id — next resume will rebuild via history injection");
868
+ }
869
+ }
870
+ // Record assistant response. `recordMessage` also bumps the
871
+ // session's `last_message_at` and `message_count` in the same
872
+ // transaction, so nothing else needs to touch the session row here.
873
+ let assistantMessageId = null;
874
+ let assistantOutput = result.output.trim();
875
+ // Docs-QA persistence-side citation validator (DOCS_QA_B7_DESIGN.md
876
+ // §11.1). The streaming side runs in DocsQAAdapter.sendStreamChunk;
877
+ // this one-shot pass guarantees the persisted `messages.content`
878
+ // matches what the dashboard rendered on reload — without it, an
879
+ // invalid `[doc:slug]` token would be stripped from the SSE wire
880
+ // but reappear in history. Slug-missing tokens are also logged to
881
+ // `agent_actions(action_type='qa_invalid_citation')`.
882
+ const docsCitationLookup = this.getDocsCitationLookup();
883
+ if (isDocsQAMessage(event)
884
+ && docsCitationLookup
885
+ && assistantOutput.length > 0) {
886
+ const validation = validateAndRewrite(assistantOutput, docsCitationLookup);
887
+ assistantOutput = validation.text;
888
+ logInvalidCitations(this.db, validation, { sessionId: session.id });
889
+ }
890
+ if (assistantOutput.length > 0) {
891
+ const persisted = this.messageRecorder.recordMessage({
892
+ sessionId: session.id,
893
+ role: "assistant",
894
+ content: assistantOutput,
895
+ platform: event.platform,
896
+ backend: result.backendId,
897
+ modelId: result.modelId ?? result.model,
898
+ });
899
+ if (persisted) {
900
+ assistantMessageId = this.readLastInsertedMessageId(session.id);
901
+ if (forwardContextAvailable) {
902
+ this.resultProcessor.logProactiveForwardDisavowalIfMatched(session.id, assistantOutput);
903
+ }
904
+ }
905
+ if (!persisted && event.platform === "dashboard" && dashboardStream?.sendError) {
906
+ // The agent produced a response but we couldn't persist it. The
907
+ // dashboard tab has no other signal that the turn finished —
908
+ // without this inline surfacing the user would watch the reply
909
+ // stream in, then hit the 120s waiting timeout on refresh with
910
+ // no history row to reconcile against. Tell them directly.
911
+ dashboardStream.sendError(resolveDashboardChannel(), "The agent's reply could not be saved. Please try again.");
912
+ }
913
+ }
914
+ else {
915
+ // Agent returned no output — send error feedback so the user isn't left waiting
916
+ const errorMsg = "Could not generate a response. Please try again.";
917
+ logger.warn({ sessionId: session.id, isError: result.isError, stopReason: result.stopReason }, "Agent returned empty output for message event");
918
+ this.messageRecorder.recordMessage({
919
+ sessionId: session.id,
920
+ role: "assistant",
921
+ content: errorMsg,
922
+ platform: event.platform,
923
+ backend: result.backendId,
924
+ modelId: result.modelId ?? result.model,
925
+ });
926
+ // Send error to dashboard chat so the user sees it inline
927
+ if (event.platform === "dashboard" && dashboardStream?.sendError) {
928
+ dashboardStream.sendError(resolveDashboardChannel(), errorMsg);
929
+ }
930
+ await this.notificationMgr.send(errorMsg, event);
931
+ }
932
+ // Send message metadata to dashboard for per-message footer display.
933
+ // This is also the client's cue to refetch history after a mid-execute
934
+ // reconnect — the chunks that arrived before the user reopened the tab
935
+ // were dropped into the old channel, so the live messages state may be
936
+ // missing content that is already in the DB.
937
+ if (event.platform === "dashboard" && dashboardStream?.sendMessageMeta) {
938
+ dashboardStream.sendMessageMeta(resolveDashboardChannel(), {
939
+ backend: result.backendId,
940
+ model: result.modelId ?? result.model,
941
+ durationMs: result.durationMs,
942
+ costUsd: result.costUsd,
943
+ });
944
+ }
945
+ // Update session-level model info with actual execution result.
946
+ // This corrects the pre-execution estimate when fallback kicked in,
947
+ // and pushes the cumulative costUsd to the sidebar badge.
948
+ if (event.platform === "dashboard" && dashboardStream?.sendSessionInfo) {
949
+ const actualModel = result.modelId ?? result.model;
950
+ const actualBackend = result.backendId ?? route.main.backendId;
951
+ dashboardStream.sendSessionInfo(resolveDashboardChannel(), {
952
+ model: actualModel,
953
+ backend: actualBackend,
954
+ modelLabel: getModelLabel(actualBackend, actualModel),
955
+ costUsd: result.costUsd,
956
+ });
957
+ }
958
+ // Chat-attachments Phase 1 — collect outbound files the agent
959
+ // produced during this turn and deliver them via the originating
960
+ // adapter. Currently only the Dashboard adapter delivers outbound
961
+ // attachments on-wire; other platforms ignore the `attachments`
962
+ // field until Phase 2.
963
+ if (turnToken
964
+ && attachmentStore
965
+ && assistantMessageId !== null
966
+ && assistantOutput.length > 0) {
967
+ const outboundRows = attachmentStore.collectOutboundForTurn({
968
+ turnToken,
969
+ sessionId: session.id,
970
+ });
971
+ if (outboundRows.length > 0) {
972
+ for (const row of outboundRows) {
973
+ attachmentStore.bindOutboundToMessage(row.id, assistantMessageId);
974
+ }
975
+ if (event.platform === "dashboard" && dashboardStream?.sendAttachments) {
976
+ dashboardStream.sendAttachments(resolveDashboardChannel(), outboundRows.map((row) => ({
977
+ id: row.id,
978
+ originalFilename: row.originalFilename,
979
+ mimeType: row.mimeType,
980
+ sizeBytes: row.sizeBytes,
981
+ ...(row.caption ? { caption: row.caption } : {}),
982
+ })));
983
+ }
984
+ }
985
+ }
986
+ // STAGE-C-DM-FRESHNESS-PLAN §Task 4 — collect the per-turn DM
987
+ // freshness telemetry before notification + audit. Limited to DM
988
+ // events: the metric only makes sense for the resume-or-fresh-
989
+ // execute decision the message dispatch makes. We compute counts
990
+ // bounded by the captured `turnStartedAtSqlite` so writes the
991
+ // agent itself made during THIS turn are not folded back in.
992
+ const dmFreshness = event.isDm
993
+ ? this.collectDmFreshnessTelemetry({
994
+ sessionId: session.id,
995
+ canResume: Boolean(canResume),
996
+ resumeSnapshotAgeMinutes,
997
+ turnStartedAtSqlite,
998
+ userContent: event.content,
999
+ })
1000
+ : undefined;
1001
+ // Skip notification if we already streamed (avoids duplicate message)
1002
+ await this.resultProcessor.processResult(result, event, didStream, {
1003
+ originSessionId: session.id,
1004
+ ...(dmFreshness ? { dmFreshness } : {}),
1005
+ });
1006
+ }
1007
+ finally {
1008
+ // Always release the turn token, even on error paths. Any outbound
1009
+ // rows the agent posted that weren't collected above fall into the
1010
+ // orphan reaper's domain on the next daemon restart.
1011
+ if (turnToken) {
1012
+ this.prompt.releaseAttachmentTurnToken(turnToken);
1013
+ this.getAttachmentStore()?.releaseTurnToken(turnToken);
1014
+ }
1015
+ await replyActivity.stop();
1016
+ }
1017
+ }
1018
+ /**
1019
+ * STAGE-C-DM-FRESHNESS-PLAN §Task 4 — assemble the DM-only freshness
1020
+ * telemetry payload that gets persisted into `agent_actions.detail`.
1021
+ * Pulled into its own method so the message-dispatch path stays
1022
+ * readable and so unit tests can exercise the SQL aggregation in
1023
+ * isolation.
1024
+ *
1025
+ * Verbatim move from `dispatcher.ts:collectDmFreshnessTelemetry`.
1026
+ */
1027
+ collectDmFreshnessTelemetry(input) {
1028
+ const sessionRow = this.db
1029
+ .prepare(`SELECT started_at FROM conversation_sessions WHERE id = ?`)
1030
+ .get(input.sessionId);
1031
+ // Fall back to turnStart so a missing started_at yields zero counts
1032
+ // instead of poisoning the aggregation with a wide-open lower bound.
1033
+ const sessionStartedAtSqlite = sessionRow?.started_at ?? input.turnStartedAtSqlite;
1034
+ const writeCounts = countContextWritesInWindow(this.db, sessionStartedAtSqlite, input.turnStartedAtSqlite);
1035
+ // Bound the refetch window at "now" so a context_read that lands
1036
+ // AFTER this turn's executeWithRetry returns (e.g. from a future
1037
+ // parallel dispatcher, an unrelated routine, or a dashboard reload)
1038
+ // is not wrongly attributed to this turn.
1039
+ const turnEndSqlite = formatSqliteDatetime(new Date());
1040
+ const refetchedToday = didRefetchTodayDuringTurn(this.db, input.turnStartedAtSqlite, turnEndSqlite);
1041
+ return {
1042
+ resumed: input.canResume,
1043
+ // Fresh-execute branch sets resumeSnapshotAgeMinutes=0 by default;
1044
+ // that's the correct lag because the system prompt's <today> was
1045
+ // built at this very turn.
1046
+ agentLogLagMinutes: input.canResume ? input.resumeSnapshotAgeMinutes : 0,
1047
+ loudWritesSinceSessionStart: writeCounts.loud,
1048
+ quietWritesSinceSessionStart: writeCounts.quiet,
1049
+ refetchedToday,
1050
+ triggerMatched: matchesRecentActivityTrigger(input.userContent),
1051
+ };
1052
+ }
1053
+ }
1054
+ //# sourceMappingURL=dispatcher-message-handler.js.map