@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.
- package/LICENSE +21 -0
- package/dist/adapters/whatsapp-adapter.d.ts.map +1 -1
- package/dist/adapters/whatsapp-adapter.js +0 -1
- package/dist/adapters/whatsapp-adapter.js.map +1 -1
- package/dist/api/integration-route-gate.d.ts +15 -11
- package/dist/api/integration-route-gate.d.ts.map +1 -1
- package/dist/api/integration-route-gate.js +60 -23
- package/dist/api/integration-route-gate.js.map +1 -1
- package/dist/api/json-body.d.ts +22 -7
- package/dist/api/json-body.d.ts.map +1 -1
- package/dist/api/json-body.js +27 -8
- package/dist/api/json-body.js.map +1 -1
- package/dist/api/routes/agent.d.ts.map +1 -1
- package/dist/api/routes/agent.js +18 -0
- package/dist/api/routes/agent.js.map +1 -1
- package/dist/api/routes/backends.d.ts.map +1 -1
- package/dist/api/routes/backends.js +96 -1
- package/dist/api/routes/backends.js.map +1 -1
- package/dist/api/routes/books.js +1 -1
- package/dist/api/routes/books.js.map +1 -1
- package/dist/api/routes/context.d.ts.map +1 -1
- package/dist/api/routes/context.js +13 -1
- package/dist/api/routes/context.js.map +1 -1
- package/dist/api/routes/dashboard.d.ts.map +1 -1
- package/dist/api/routes/dashboard.js +75 -5
- package/dist/api/routes/dashboard.js.map +1 -1
- package/dist/api/routes/github.d.ts.map +1 -1
- package/dist/api/routes/github.js +38 -5
- package/dist/api/routes/github.js.map +1 -1
- package/dist/api/routes/integrations.d.ts +35 -6
- package/dist/api/routes/integrations.d.ts.map +1 -1
- package/dist/api/routes/integrations.js +191 -16
- package/dist/api/routes/integrations.js.map +1 -1
- package/dist/api/routes/mail.d.ts.map +1 -1
- package/dist/api/routes/mail.js +112 -46
- package/dist/api/routes/mail.js.map +1 -1
- package/dist/api/routes/observations.d.ts.map +1 -1
- package/dist/api/routes/observations.js +161 -8
- package/dist/api/routes/observations.js.map +1 -1
- package/dist/api/routes/setup-migrate.d.ts +9 -1
- package/dist/api/routes/setup-migrate.d.ts.map +1 -1
- package/dist/api/routes/setup-migrate.js +4 -2
- package/dist/api/routes/setup-migrate.js.map +1 -1
- package/dist/api/routes/skills.d.ts.map +1 -1
- package/dist/api/routes/skills.js +39 -1
- package/dist/api/routes/skills.js.map +1 -1
- package/dist/api/routes/voice.d.ts.map +1 -1
- package/dist/api/routes/voice.js +154 -14
- package/dist/api/routes/voice.js.map +1 -1
- package/dist/bootstrap/adapters.d.ts +109 -0
- package/dist/bootstrap/adapters.d.ts.map +1 -0
- package/dist/bootstrap/adapters.js +237 -0
- package/dist/bootstrap/adapters.js.map +1 -0
- package/dist/bootstrap/catchup.d.ts +23 -0
- package/dist/bootstrap/catchup.d.ts.map +1 -0
- package/dist/bootstrap/catchup.js +124 -0
- package/dist/bootstrap/catchup.js.map +1 -0
- package/dist/bootstrap/schedule-helpers.d.ts +18 -0
- package/dist/bootstrap/schedule-helpers.d.ts.map +1 -0
- package/dist/bootstrap/schedule-helpers.js +96 -0
- package/dist/bootstrap/schedule-helpers.js.map +1 -0
- package/dist/bootstrap/services.d.ts +60 -0
- package/dist/bootstrap/services.d.ts.map +1 -0
- package/dist/bootstrap/services.js +209 -0
- package/dist/bootstrap/services.js.map +1 -0
- package/dist/core/backends/backend-router.d.ts +23 -0
- package/dist/core/backends/backend-router.d.ts.map +1 -1
- package/dist/core/backends/backend-router.js +48 -3
- package/dist/core/backends/backend-router.js.map +1 -1
- package/dist/core/backends/claude-auth.d.ts +70 -0
- package/dist/core/backends/claude-auth.d.ts.map +1 -0
- package/dist/core/backends/claude-auth.js +198 -0
- package/dist/core/backends/claude-auth.js.map +1 -0
- package/dist/core/backends/claude-code-core.d.ts +47 -119
- package/dist/core/backends/claude-code-core.d.ts.map +1 -1
- package/dist/core/backends/claude-code-core.js +112 -1565
- package/dist/core/backends/claude-code-core.js.map +1 -1
- package/dist/core/backends/claude-delegated.d.ts +86 -0
- package/dist/core/backends/claude-delegated.d.ts.map +1 -0
- package/dist/core/backends/claude-delegated.js +801 -0
- package/dist/core/backends/claude-delegated.js.map +1 -0
- package/dist/core/backends/claude-errors.d.ts +39 -0
- package/dist/core/backends/claude-errors.d.ts.map +1 -0
- package/dist/core/backends/claude-errors.js +71 -0
- package/dist/core/backends/claude-errors.js.map +1 -0
- package/dist/core/backends/claude-probe.d.ts +103 -0
- package/dist/core/backends/claude-probe.d.ts.map +1 -0
- package/dist/core/backends/claude-probe.js +336 -0
- package/dist/core/backends/claude-probe.js.map +1 -0
- package/dist/core/backends/claude-tool-collection.d.ts +135 -0
- package/dist/core/backends/claude-tool-collection.d.ts.map +1 -0
- package/dist/core/backends/claude-tool-collection.js +831 -0
- package/dist/core/backends/claude-tool-collection.js.map +1 -0
- package/dist/core/backends/gemini-cli-core.d.ts +21 -0
- package/dist/core/backends/gemini-cli-core.d.ts.map +1 -1
- package/dist/core/backends/gemini-cli-core.js +84 -6
- package/dist/core/backends/gemini-cli-core.js.map +1 -1
- package/dist/core/backends/prompt-utils.d.ts +1 -0
- package/dist/core/backends/prompt-utils.d.ts.map +1 -1
- package/dist/core/backends/prompt-utils.js +60 -3
- package/dist/core/backends/prompt-utils.js.map +1 -1
- package/dist/core/context-builder.d.ts +36 -12
- package/dist/core/context-builder.d.ts.map +1 -1
- package/dist/core/context-builder.js +179 -89
- package/dist/core/context-builder.js.map +1 -1
- package/dist/core/dispatcher-date-utils.d.ts +49 -0
- package/dist/core/dispatcher-date-utils.d.ts.map +1 -0
- package/dist/core/dispatcher-date-utils.js +132 -0
- package/dist/core/dispatcher-date-utils.js.map +1 -0
- package/dist/core/dispatcher-error-handling.d.ts +159 -0
- package/dist/core/dispatcher-error-handling.d.ts.map +1 -0
- package/dist/core/dispatcher-error-handling.js +393 -0
- package/dist/core/dispatcher-error-handling.js.map +1 -0
- package/dist/core/dispatcher-hourly-check.d.ts +150 -0
- package/dist/core/dispatcher-hourly-check.d.ts.map +1 -0
- package/dist/core/dispatcher-hourly-check.js +665 -0
- package/dist/core/dispatcher-hourly-check.js.map +1 -0
- package/dist/core/dispatcher-message-handler.d.ts +170 -0
- package/dist/core/dispatcher-message-handler.d.ts.map +1 -0
- package/dist/core/dispatcher-message-handler.js +1054 -0
- package/dist/core/dispatcher-message-handler.js.map +1 -0
- package/dist/core/dispatcher-morning-routine.d.ts +169 -0
- package/dist/core/dispatcher-morning-routine.d.ts.map +1 -0
- package/dist/core/dispatcher-morning-routine.js +434 -0
- package/dist/core/dispatcher-morning-routine.js.map +1 -0
- package/dist/core/dispatcher-prompt.d.ts +107 -0
- package/dist/core/dispatcher-prompt.d.ts.map +1 -0
- package/dist/core/dispatcher-prompt.js +227 -0
- package/dist/core/dispatcher-prompt.js.map +1 -0
- package/dist/core/dispatcher-repository-helpers.d.ts +39 -0
- package/dist/core/dispatcher-repository-helpers.d.ts.map +1 -0
- package/dist/core/dispatcher-repository-helpers.js +86 -0
- package/dist/core/dispatcher-repository-helpers.js.map +1 -0
- package/dist/core/dispatcher-result-processor.d.ts +145 -0
- package/dist/core/dispatcher-result-processor.d.ts.map +1 -0
- package/dist/core/dispatcher-result-processor.js +414 -0
- package/dist/core/dispatcher-result-processor.js.map +1 -0
- package/dist/core/dispatcher-scheduled-tasks.d.ts +406 -0
- package/dist/core/dispatcher-scheduled-tasks.d.ts.map +1 -0
- package/dist/core/dispatcher-scheduled-tasks.js +998 -0
- package/dist/core/dispatcher-scheduled-tasks.js.map +1 -0
- package/dist/core/dispatcher-types.d.ts +296 -0
- package/dist/core/dispatcher-types.d.ts.map +1 -0
- package/dist/core/dispatcher-types.js +106 -0
- package/dist/core/dispatcher-types.js.map +1 -0
- package/dist/core/dispatcher.d.ts +86 -610
- package/dist/core/dispatcher.d.ts.map +1 -1
- package/dist/core/dispatcher.js +293 -3542
- package/dist/core/dispatcher.js.map +1 -1
- package/dist/core/integration-health.d.ts +18 -10
- package/dist/core/integration-health.d.ts.map +1 -1
- package/dist/core/integration-health.js +31 -1
- package/dist/core/integration-health.js.map +1 -1
- package/dist/core/integration-lifecycle.d.ts +65 -0
- package/dist/core/integration-lifecycle.d.ts.map +1 -1
- package/dist/core/integration-lifecycle.js +167 -16
- package/dist/core/integration-lifecycle.js.map +1 -1
- package/dist/core/integration-main-backend.d.ts +40 -0
- package/dist/core/integration-main-backend.d.ts.map +1 -1
- package/dist/core/integration-main-backend.js +89 -2
- package/dist/core/integration-main-backend.js.map +1 -1
- package/dist/core/management-md.d.ts +51 -17
- package/dist/core/management-md.d.ts.map +1 -1
- package/dist/core/management-md.js +233 -56
- package/dist/core/management-md.js.map +1 -1
- package/dist/core/output-language-policy.d.ts +74 -0
- package/dist/core/output-language-policy.d.ts.map +1 -0
- package/dist/core/output-language-policy.js +194 -0
- package/dist/core/output-language-policy.js.map +1 -0
- package/dist/core/prompts.d.ts +1 -0
- package/dist/core/prompts.d.ts.map +1 -1
- package/dist/core/prompts.js +121 -3
- package/dist/core/prompts.js.map +1 -1
- package/dist/core/repository-management-docs.d.ts +24 -0
- package/dist/core/repository-management-docs.d.ts.map +1 -1
- package/dist/core/repository-management-docs.js +210 -26
- package/dist/core/repository-management-docs.js.map +1 -1
- package/dist/core/routine-acquisition-plan.d.ts +131 -0
- package/dist/core/routine-acquisition-plan.d.ts.map +1 -0
- package/dist/core/routine-acquisition-plan.js +268 -0
- package/dist/core/routine-acquisition-plan.js.map +1 -0
- package/dist/core/routine-fetch-window-runner.d.ts +201 -0
- package/dist/core/routine-fetch-window-runner.d.ts.map +1 -0
- package/dist/core/routine-fetch-window-runner.js +661 -0
- package/dist/core/routine-fetch-window-runner.js.map +1 -0
- package/dist/core/routine-windows.d.ts +156 -0
- package/dist/core/routine-windows.d.ts.map +1 -0
- package/dist/core/routine-windows.js +330 -0
- package/dist/core/routine-windows.js.map +1 -0
- package/dist/core/skills-compiler.d.ts +11 -0
- package/dist/core/skills-compiler.d.ts.map +1 -1
- package/dist/core/skills-compiler.js +102 -13
- package/dist/core/skills-compiler.js.map +1 -1
- package/dist/core/skills-manifest.d.ts.map +1 -1
- package/dist/core/skills-manifest.js +26 -0
- package/dist/core/skills-manifest.js.map +1 -1
- package/dist/core/system-reset.d.ts.map +1 -1
- package/dist/core/system-reset.js +25 -2
- package/dist/core/system-reset.js.map +1 -1
- package/dist/db/observations.d.ts +45 -2
- package/dist/db/observations.d.ts.map +1 -1
- package/dist/db/observations.js +112 -14
- package/dist/db/observations.js.map +1 -1
- package/dist/db/schema.d.ts.map +1 -1
- package/dist/db/schema.js +13 -25
- package/dist/db/schema.js.map +1 -1
- package/dist/index.js +83 -610
- package/dist/index.js.map +1 -1
- package/dist/observers/delegated-sync-worker.d.ts +45 -2
- package/dist/observers/delegated-sync-worker.d.ts.map +1 -1
- package/dist/observers/delegated-sync-worker.js +71 -21
- package/dist/observers/delegated-sync-worker.js.map +1 -1
- package/dist/observers/mail-poller.d.ts +12 -5
- package/dist/observers/mail-poller.d.ts.map +1 -1
- package/dist/observers/mail-poller.js +36 -14
- package/dist/observers/mail-poller.js.map +1 -1
- package/dist/observers/manager.d.ts +37 -5
- package/dist/observers/manager.d.ts.map +1 -1
- package/dist/observers/manager.js +28 -10
- package/dist/observers/manager.js.map +1 -1
- package/dist/safety/risk-classifier.d.ts.map +1 -1
- package/dist/safety/risk-classifier.js +5 -0
- package/dist/safety/risk-classifier.js.map +1 -1
- package/dist/services/delegated-backend-invoker.d.ts +1 -51
- package/dist/services/delegated-backend-invoker.d.ts.map +1 -1
- package/dist/services/delegated-backend-invoker.js +41 -480
- package/dist/services/delegated-backend-invoker.js.map +1 -1
- package/dist/services/delegated-invoker-audit.d.ts +94 -0
- package/dist/services/delegated-invoker-audit.d.ts.map +1 -0
- package/dist/services/delegated-invoker-audit.js +238 -0
- package/dist/services/delegated-invoker-audit.js.map +1 -0
- package/dist/services/delegated-invoker-cache-hits.d.ts +34 -0
- package/dist/services/delegated-invoker-cache-hits.d.ts.map +1 -0
- package/dist/services/delegated-invoker-cache-hits.js +104 -0
- package/dist/services/delegated-invoker-cache-hits.js.map +1 -0
- package/dist/services/delegated-invoker-janitors.d.ts +28 -0
- package/dist/services/delegated-invoker-janitors.d.ts.map +1 -0
- package/dist/services/delegated-invoker-janitors.js +104 -0
- package/dist/services/delegated-invoker-janitors.js.map +1 -0
- package/dist/services/delegated-invoker-utils.d.ts +42 -0
- package/dist/services/delegated-invoker-utils.d.ts.map +1 -0
- package/dist/services/delegated-invoker-utils.js +100 -0
- package/dist/services/delegated-invoker-utils.js.map +1 -0
- package/dist/services/delegated-task-runtime.d.ts +1 -1
- package/dist/services/delegated-task-runtime.js +1 -1
- package/dist/services/integrations/snapshot-partitions.d.ts +5 -0
- package/dist/services/integrations/snapshot-partitions.d.ts.map +1 -1
- package/dist/services/integrations/snapshot-partitions.js +12 -0
- package/dist/services/integrations/snapshot-partitions.js.map +1 -1
- package/dist/services/voice/transcriber-impl.d.ts.map +1 -1
- package/dist/services/voice/transcriber-impl.js +46 -0
- package/dist/services/voice/transcriber-impl.js.map +1 -1
- 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
|