negotium 0.1.6 → 0.1.8

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 (256) hide show
  1. package/README.md +2 -2
  2. package/dist/chunk-0ay6cjdy.js +1331 -0
  3. package/dist/chunk-0ay6cjdy.js.map +16 -0
  4. package/dist/chunk-1k7t7bnx.js +97 -0
  5. package/dist/chunk-1k7t7bnx.js.map +10 -0
  6. package/dist/chunk-5ahkew9c.js +806 -0
  7. package/dist/chunk-5ahkew9c.js.map +17 -0
  8. package/dist/chunk-6cpn7ht2.js +287 -0
  9. package/dist/chunk-6cpn7ht2.js.map +13 -0
  10. package/dist/chunk-6kctn20e.js +3529 -0
  11. package/dist/chunk-6kctn20e.js.map +20 -0
  12. package/dist/chunk-7xqj7aex.js +77 -0
  13. package/dist/chunk-7xqj7aex.js.map +11 -0
  14. package/dist/chunk-9p0ww3tk.js +41 -0
  15. package/dist/chunk-9p0ww3tk.js.map +10 -0
  16. package/dist/chunk-a92a7nrk.js +252 -0
  17. package/dist/chunk-a92a7nrk.js.map +10 -0
  18. package/dist/chunk-c500xe9z.js +33 -0
  19. package/dist/chunk-c500xe9z.js.map +10 -0
  20. package/dist/chunk-cds1qxsf.js +1644 -0
  21. package/dist/chunk-cds1qxsf.js.map +17 -0
  22. package/dist/chunk-e4407g8x.js +274 -0
  23. package/dist/chunk-e4407g8x.js.map +10 -0
  24. package/dist/chunk-e5pbggsh.js +363 -0
  25. package/dist/chunk-e5pbggsh.js.map +11 -0
  26. package/dist/chunk-edb07sjp.js +567 -0
  27. package/dist/chunk-edb07sjp.js.map +14 -0
  28. package/dist/chunk-exd0v74a.js +3012 -0
  29. package/dist/chunk-exd0v74a.js.map +32 -0
  30. package/dist/chunk-fgrme9tr.js +4398 -0
  31. package/dist/chunk-fgrme9tr.js.map +47 -0
  32. package/dist/chunk-fka9eqe1.js +581 -0
  33. package/dist/chunk-fka9eqe1.js.map +11 -0
  34. package/dist/chunk-g82jygez.js +108 -0
  35. package/dist/chunk-g82jygez.js.map +10 -0
  36. package/dist/chunk-ghs2j1fr.js +312 -0
  37. package/dist/chunk-ghs2j1fr.js.map +10 -0
  38. package/dist/chunk-gmy11efb.js +125 -0
  39. package/dist/chunk-gmy11efb.js.map +10 -0
  40. package/dist/chunk-gxma4c70.js +110 -0
  41. package/dist/chunk-gxma4c70.js.map +10 -0
  42. package/dist/chunk-hkdqhm34.js +61 -0
  43. package/dist/chunk-hkdqhm34.js.map +10 -0
  44. package/dist/chunk-hpa0cpzh.js +134 -0
  45. package/dist/chunk-hpa0cpzh.js.map +10 -0
  46. package/dist/chunk-hwy3n31w.js +21 -0
  47. package/dist/chunk-hwy3n31w.js.map +9 -0
  48. package/dist/chunk-j2behpy7.js +214 -0
  49. package/dist/chunk-j2behpy7.js.map +10 -0
  50. package/dist/chunk-jc0at0tc.js +102 -0
  51. package/dist/chunk-jc0at0tc.js.map +10 -0
  52. package/dist/chunk-jkqq5ef6.js +243 -0
  53. package/dist/chunk-jkqq5ef6.js.map +11 -0
  54. package/dist/chunk-jzkw2f01.js +51 -0
  55. package/dist/chunk-jzkw2f01.js.map +10 -0
  56. package/dist/chunk-jzz91jwh.js +70 -0
  57. package/dist/chunk-jzz91jwh.js.map +10 -0
  58. package/dist/chunk-k9z47mab.js +247 -0
  59. package/dist/chunk-k9z47mab.js.map +12 -0
  60. package/dist/chunk-n17yxpa8.js +23 -0
  61. package/dist/chunk-n17yxpa8.js.map +10 -0
  62. package/dist/chunk-naa09ags.js +2143 -0
  63. package/dist/chunk-naa09ags.js.map +15 -0
  64. package/dist/chunk-ns3ke88n.js +142 -0
  65. package/dist/chunk-ns3ke88n.js.map +10 -0
  66. package/dist/chunk-p3cr2ejg.js +1626 -0
  67. package/dist/chunk-p3cr2ejg.js.map +15 -0
  68. package/dist/chunk-q5bcpf23.js +2925 -0
  69. package/dist/chunk-q5bcpf23.js.map +20 -0
  70. package/dist/chunk-rafc55bs.js +424 -0
  71. package/dist/chunk-rafc55bs.js.map +11 -0
  72. package/dist/chunk-rbrxjvbj.js +2830 -0
  73. package/dist/chunk-rbrxjvbj.js.map +23 -0
  74. package/dist/chunk-sa874j5c.js +9 -0
  75. package/dist/chunk-sa874j5c.js.map +10 -0
  76. package/dist/chunk-sgsz2fpq.js +289 -0
  77. package/dist/chunk-sgsz2fpq.js.map +10 -0
  78. package/dist/chunk-sm6fh5c5.js +569 -0
  79. package/dist/chunk-sm6fh5c5.js.map +14 -0
  80. package/dist/chunk-t9zcg8f1.js +499 -0
  81. package/dist/chunk-t9zcg8f1.js.map +13 -0
  82. package/dist/chunk-vp2db42d.js +121 -0
  83. package/dist/chunk-vp2db42d.js.map +10 -0
  84. package/dist/chunk-w13jjf4a.js +46 -0
  85. package/dist/chunk-w13jjf4a.js.map +10 -0
  86. package/dist/chunk-x1arr7ze.js +63 -0
  87. package/dist/chunk-x1arr7ze.js.map +10 -0
  88. package/dist/chunk-xfq68rch.js +583 -0
  89. package/dist/chunk-xfq68rch.js.map +11 -0
  90. package/dist/main.js +132 -20
  91. package/dist/main.js.map +4 -4
  92. package/dist/runtime/scripts/faster-whisper-wrapper.py +87 -0
  93. package/dist/runtime/scripts/mcp-patchright-http.mjs +349 -0
  94. package/dist/runtime/src/agents/api-topic-agent-switch.ts +178 -0
  95. package/dist/runtime/src/agents/archiver.ts +396 -0
  96. package/dist/runtime/src/agents/auth-check.ts +64 -0
  97. package/dist/runtime/src/agents/claude-provider.ts +737 -0
  98. package/dist/runtime/src/agents/claude-registry.ts +115 -0
  99. package/dist/runtime/src/agents/codex-native-multi-agent.ts +177 -0
  100. package/dist/runtime/src/agents/codex-provider.ts +785 -0
  101. package/dist/runtime/src/agents/codex-registry.ts +115 -0
  102. package/dist/runtime/src/agents/codex-tree-kill.ts +365 -0
  103. package/dist/runtime/src/agents/contracts.ts +69 -0
  104. package/dist/runtime/src/agents/deep-map.ts +13 -0
  105. package/dist/runtime/src/agents/execution-host.ts +91 -0
  106. package/dist/runtime/src/agents/fixtures/claude-attachments.jsonl +2 -0
  107. package/dist/runtime/src/agents/fixtures/codex-shell.jsonl +5 -0
  108. package/dist/runtime/src/agents/fork.ts +65 -0
  109. package/dist/runtime/src/agents/hosted-agent.ts +48 -0
  110. package/dist/runtime/src/agents/idle-archiver.ts +128 -0
  111. package/dist/runtime/src/agents/index.ts +205 -0
  112. package/dist/runtime/src/agents/maestro-provider.ts +206 -0
  113. package/dist/runtime/src/agents/maestro-registry.ts +31 -0
  114. package/dist/runtime/src/agents/mcp-tools/ask-user.ts +280 -0
  115. package/dist/runtime/src/agents/mcp-tools/common.ts +21 -0
  116. package/dist/runtime/src/agents/mcp-tools/self-config.ts +220 -0
  117. package/dist/runtime/src/agents/mcp-tools/spawn-subagent.ts +525 -0
  118. package/dist/runtime/src/agents/mcp-tools/visuals.ts +88 -0
  119. package/dist/runtime/src/agents/model-catalog.ts +151 -0
  120. package/dist/runtime/src/agents/registry.ts +24 -0
  121. package/dist/runtime/src/agents/rollout/claude.ts +331 -0
  122. package/dist/runtime/src/agents/rollout/codex.ts +569 -0
  123. package/dist/runtime/src/agents/rollout/shared.ts +205 -0
  124. package/dist/runtime/src/agents/self-config-core.ts +415 -0
  125. package/dist/runtime/src/agents/task-events.ts +44 -0
  126. package/dist/runtime/src/agents/tool-format.ts +352 -0
  127. package/dist/runtime/src/agents/topic-agent-switch.ts +193 -0
  128. package/dist/runtime/src/agents/topic-cleanup.ts +231 -0
  129. package/dist/runtime/src/agents/vault-tool-policy.ts +33 -0
  130. package/dist/runtime/src/application/execute-external-user-turn.ts +23 -0
  131. package/dist/runtime/src/application/submit-user-message.ts +47 -0
  132. package/dist/runtime/src/application/switch-topic-effort.ts +48 -0
  133. package/dist/runtime/src/application/switch-topic-model.ts +79 -0
  134. package/dist/runtime/src/application/topic-service.ts +139 -0
  135. package/dist/runtime/src/application/vault-command.ts +170 -0
  136. package/dist/runtime/src/bus.ts +319 -0
  137. package/dist/runtime/src/index.ts +333 -0
  138. package/dist/runtime/src/mcp/agent-health-server.ts +309 -0
  139. package/dist/runtime/src/mcp/background-bash-server.ts +346 -0
  140. package/dist/runtime/src/mcp/canonical-bridge-config.ts +84 -0
  141. package/dist/runtime/src/mcp/canonical-proxy-server.ts +171 -0
  142. package/dist/runtime/src/mcp/dm-ipc.ts +60 -0
  143. package/dist/runtime/src/mcp/mcp-helpers.ts +40 -0
  144. package/dist/runtime/src/mcp/paddleocr-server.ts +105 -0
  145. package/dist/runtime/src/mcp/peer-bridge.ts +114 -0
  146. package/dist/runtime/src/mcp/runtime-spec.ts +134 -0
  147. package/dist/runtime/src/mcp/session-comm/bridge-ipc-config.ts +27 -0
  148. package/dist/runtime/src/mcp/session-comm/peer-forward.ts +165 -0
  149. package/dist/runtime/src/mcp/session-comm/runtime.ts +115 -0
  150. package/dist/runtime/src/mcp/session-comm/server.ts +649 -0
  151. package/dist/runtime/src/mcp/session-comm/topic-config.ts +131 -0
  152. package/dist/runtime/src/mcp/session-comm/topics.ts +229 -0
  153. package/dist/runtime/src/mcp/stdio-protect.ts +84 -0
  154. package/dist/runtime/src/mcp/system-health-server.ts +130 -0
  155. package/dist/runtime/src/mcp/task-server.ts +173 -0
  156. package/dist/runtime/src/mcp/token-stats-server.ts +98 -0
  157. package/dist/runtime/src/mcp/vault-http.ts +219 -0
  158. package/dist/runtime/src/mcp/vault-run.ts +153 -0
  159. package/dist/runtime/src/mcp/vault-server.ts +104 -0
  160. package/dist/runtime/src/mcp/wiki-server.ts +755 -0
  161. package/dist/runtime/src/media/file-events.ts +33 -0
  162. package/dist/runtime/src/media/text-extractor.ts +312 -0
  163. package/dist/runtime/src/media/video.ts +92 -0
  164. package/dist/runtime/src/outbox/file-ops.ts +210 -0
  165. package/dist/runtime/src/outbox/index.ts +8 -0
  166. package/dist/runtime/src/outbox/utils.ts +51 -0
  167. package/dist/runtime/src/platform/background-bash/manager.ts +279 -0
  168. package/dist/runtime/src/platform/config.ts +371 -0
  169. package/dist/runtime/src/platform/constants.ts +18 -0
  170. package/dist/runtime/src/platform/debug-users.ts +14 -0
  171. package/dist/runtime/src/platform/delay.ts +1 -0
  172. package/dist/runtime/src/platform/error.ts +9 -0
  173. package/dist/runtime/src/platform/file-utils.ts +16 -0
  174. package/dist/runtime/src/platform/jsonl.ts +202 -0
  175. package/dist/runtime/src/platform/lifecycle.ts +174 -0
  176. package/dist/runtime/src/platform/logger.ts +31 -0
  177. package/dist/runtime/src/platform/maestro-bootstrap-env.ts +3 -0
  178. package/dist/runtime/src/platform/mcp-config.ts +865 -0
  179. package/dist/runtime/src/platform/modules.ts +145 -0
  180. package/dist/runtime/src/platform/node-plugins.ts +46 -0
  181. package/dist/runtime/src/platform/playwright/manager.ts +1233 -0
  182. package/dist/runtime/src/prompts/agents/wiki-archiver.md +261 -0
  183. package/dist/runtime/src/prompts/builders.ts +357 -0
  184. package/dist/runtime/src/prompts/sessions/channel-system.md +48 -0
  185. package/dist/runtime/src/prompts/sessions/manager-system.md +37 -0
  186. package/dist/runtime/src/prompts/sessions/topic-system.md +47 -0
  187. package/dist/runtime/src/prompts/sessions/visual-design.md +86 -0
  188. package/dist/runtime/src/query/active-rooms.ts +532 -0
  189. package/dist/runtime/src/query/control.ts +313 -0
  190. package/dist/runtime/src/query/index.ts +24 -0
  191. package/dist/runtime/src/query/session-inbox-cleanup.ts +44 -0
  192. package/dist/runtime/src/query/session-inbox-path.ts +42 -0
  193. package/dist/runtime/src/query/state.ts +94 -0
  194. package/dist/runtime/src/query/types.ts +83 -0
  195. package/dist/runtime/src/runtime/ask-callbacks.ts +153 -0
  196. package/dist/runtime/src/runtime/attachments.ts +151 -0
  197. package/dist/runtime/src/runtime/background-sessions.ts +83 -0
  198. package/dist/runtime/src/runtime/channel-context.ts +107 -0
  199. package/dist/runtime/src/runtime/errors.ts +69 -0
  200. package/dist/runtime/src/runtime/event-heartbeat.ts +77 -0
  201. package/dist/runtime/src/runtime/file-hooks.ts +81 -0
  202. package/dist/runtime/src/runtime/footer.ts +23 -0
  203. package/dist/runtime/src/runtime/inbox.ts +946 -0
  204. package/dist/runtime/src/runtime/tasks.ts +56 -0
  205. package/dist/runtime/src/runtime/topic-config.ts +29 -0
  206. package/dist/runtime/src/runtime/turn-runner.ts +2286 -0
  207. package/dist/runtime/src/runtime/usage-alert.ts +84 -0
  208. package/dist/runtime/src/runtime/visual-html.ts +109 -0
  209. package/dist/runtime/src/runtime/visual-store.ts +430 -0
  210. package/dist/runtime/src/runtime/visuals.ts +171 -0
  211. package/dist/runtime/src/security/sanitize.ts +27 -0
  212. package/dist/runtime/src/security/sensitive-path.ts +34 -0
  213. package/dist/runtime/src/storage/activity-log.ts +152 -0
  214. package/dist/runtime/src/storage/api-messages.ts +617 -0
  215. package/dist/runtime/src/storage/api-topic-brief.ts +127 -0
  216. package/dist/runtime/src/storage/api-topic-config.ts +192 -0
  217. package/dist/runtime/src/storage/api-topics.ts +844 -0
  218. package/dist/runtime/src/storage/app-settings.ts +44 -0
  219. package/dist/runtime/src/storage/browser-profiles.ts +133 -0
  220. package/dist/runtime/src/storage/conversations.ts +268 -0
  221. package/dist/runtime/src/storage/forum/agent-settings.ts +20 -0
  222. package/dist/runtime/src/storage/forum/index.ts +4 -0
  223. package/dist/runtime/src/storage/forum/repository.ts +283 -0
  224. package/dist/runtime/src/storage/forum/schema.ts +300 -0
  225. package/dist/runtime/src/storage/forum-db.ts +23 -0
  226. package/dist/runtime/src/storage/runtime-events.ts +130 -0
  227. package/dist/runtime/src/storage/runtime-leases.ts +189 -0
  228. package/dist/runtime/src/storage/runtime-process-leases.ts +216 -0
  229. package/dist/runtime/src/storage/runtime-topic-state.ts +148 -0
  230. package/dist/runtime/src/storage/runtime-turn-requests.ts +300 -0
  231. package/dist/runtime/src/storage/self-schedules.ts +298 -0
  232. package/dist/runtime/src/storage/session-asks.ts +347 -0
  233. package/dist/runtime/src/storage/sqlite.ts +129 -0
  234. package/dist/runtime/src/storage/tasks.ts +179 -0
  235. package/dist/runtime/src/storage/token-stats.ts +144 -0
  236. package/dist/runtime/src/storage/topic-archive-state.ts +59 -0
  237. package/dist/runtime/src/storage/topic-archive.ts +74 -0
  238. package/dist/runtime/src/storage/topic-settings.ts +67 -0
  239. package/dist/runtime/src/storage/topic-transcript.ts +98 -0
  240. package/dist/runtime/src/storage/vault-crypto.ts +65 -0
  241. package/dist/runtime/src/storage/vault.ts +225 -0
  242. package/dist/runtime/src/storage/wiki-summary-names.ts +33 -0
  243. package/dist/runtime/src/storage/wiki.ts +85 -0
  244. package/dist/runtime/src/topics/create.ts +123 -0
  245. package/dist/runtime/src/topics/derive.ts +398 -0
  246. package/dist/runtime/src/topics/lifecycle.ts +311 -0
  247. package/dist/runtime/src/topics/links.ts +7 -0
  248. package/dist/runtime/src/topics/personal-general.ts +65 -0
  249. package/dist/runtime/src/topics/session.ts +381 -0
  250. package/dist/runtime/src/types/api.ts +497 -0
  251. package/dist/runtime/src/types.ts +255 -0
  252. package/dist/runtime/src/version.ts +1 -0
  253. package/dist/runtime/tsconfig.json +13 -0
  254. package/package.json +16 -3
  255. package/dist/main.d.ts +0 -3
  256. package/dist/main.d.ts.map +0 -1
@@ -0,0 +1,946 @@
1
+ /**
2
+ * Session-inbox consumer — reads MCP-written JSONL inbox files and dispatches
3
+ * inter-session messages (ask/tell/abort) in the runtime process.
4
+ *
5
+ * Architecture:
6
+ * MCP tools (session-comm server.ts) → appendJsonlEntry → inbox JSONL
7
+ * Runtime host (this module) → drainOutboxFile → process entries
8
+ *
9
+ * Both converge on `triggerTopicAiTurn` from the turn runner.
10
+ *
11
+ * Port of otium's `query/session-inbox.ts` with every peer/placement branch
12
+ * removed (placed rooms, peer inject dispatch, remote ask replies): this node
13
+ * runs every turn locally, so asks route only to local caller topics.
14
+ */
15
+
16
+ import { createHash, randomUUID } from "node:crypto";
17
+ import { readdirSync, statSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ import { WsHub } from "#bus";
20
+ import { deliverPeerReply, type RemoteReplyRoute } from "#mcp/session-comm/peer-forward";
21
+ import { deleteProcessingFile, drainOutboxFile, parseOutboxLine } from "#outbox/file-ops";
22
+ import { debouncedFlush, FALLBACK_INTERVAL_MS, watchDir } from "#outbox/utils";
23
+ import { resolveTopicWorkspaceDir, SESSION_INBOX_DIR } from "#platform/config";
24
+ import { FROM_AUTO_CONTINUE, FROM_SELF_SCHEDULE } from "#platform/constants";
25
+ import { appendJsonlEntry, readJsonlLines } from "#platform/jsonl";
26
+ import { logger } from "#platform/logger";
27
+ import {
28
+ sessionInboxPath,
29
+ topicIdFromScheduledSessionInboxFileName,
30
+ topicIdFromSessionInboxFileName,
31
+ } from "#query/session-inbox-path";
32
+ import { AbortReason } from "#query/types";
33
+ import { appendApiMessage, getApiMessage } from "#storage/api-messages";
34
+ import { RUNTIME_INSTANCE_ID } from "#storage/runtime-leases";
35
+ import {
36
+ acquireRuntimeProcessLease,
37
+ PROCESS_LEASE_HEARTBEAT_MS,
38
+ type RuntimeProcessLeaseHandle,
39
+ } from "#storage/runtime-process-leases";
40
+ import {
41
+ claimNextDueSelfSchedule,
42
+ completeSelfSchedule,
43
+ heartbeatSelfScheduleClaim,
44
+ markSelfScheduleRunning,
45
+ releaseSelfScheduleClaim,
46
+ } from "#storage/self-schedules";
47
+ import { clearPendingAsk } from "#storage/session-asks";
48
+ import type { AgentKind } from "#types";
49
+
50
+ // ── Entry types (match MCP server.ts write format) ──────────────────
51
+
52
+ type SessionInboxEntry =
53
+ | { type: "abort"; timestamp: string }
54
+ | {
55
+ type: "ask";
56
+ requestId: string;
57
+ from: string;
58
+ fromTitle?: string;
59
+ fromTopicId?: string;
60
+ message: string;
61
+ contextId?: string;
62
+ fromDepth?: number;
63
+ remoteReply?: RemoteReplyRoute;
64
+ timestamp: string;
65
+ }
66
+ | {
67
+ type: "tell";
68
+ from: string;
69
+ fromTitle?: string;
70
+ fromTopicId?: string;
71
+ message: string;
72
+ depth: number;
73
+ requestId?: string;
74
+ silent?: boolean;
75
+ timestamp: string;
76
+ };
77
+
78
+ interface PendingAskScope {
79
+ userId: string;
80
+ }
81
+
82
+ type ScheduledSessionInboxEntry = Extract<SessionInboxEntry, { type: "tell" }> & {
83
+ deliverAt: string;
84
+ };
85
+
86
+ function scheduledEntryDeliveryTime(value: unknown): number | null {
87
+ if (!value || typeof value !== "object") return null;
88
+ const entry = value as Partial<ScheduledSessionInboxEntry>;
89
+ const deliverAt = typeof entry.deliverAt === "string" ? Date.parse(entry.deliverAt) : Number.NaN;
90
+ if (
91
+ entry.type !== "tell" ||
92
+ entry.from !== FROM_SELF_SCHEDULE ||
93
+ typeof entry.message !== "string" ||
94
+ !entry.message ||
95
+ typeof entry.requestId !== "string" ||
96
+ !entry.requestId ||
97
+ !Number.isFinite(deliverAt)
98
+ ) {
99
+ return null;
100
+ }
101
+ return deliverAt;
102
+ }
103
+
104
+ /**
105
+ * Avoid claiming and rewriting a future-only schedule file. Rewriting emits
106
+ * fs.watch events which would otherwise create a 200ms self-triggering flush
107
+ * loop until the earliest entry becomes due. A concurrent append is safe: it
108
+ * emits its own watch event and the fallback poll covers missed notifications.
109
+ */
110
+ function scheduledFileNeedsClaim(filePath: string, nowMs: number): boolean {
111
+ try {
112
+ return readJsonlLines(filePath).some((line) => {
113
+ try {
114
+ const deliverAt = scheduledEntryDeliveryTime(JSON.parse(line));
115
+ return deliverAt === null || deliverAt <= nowMs;
116
+ } catch {
117
+ return true;
118
+ }
119
+ });
120
+ } catch {
121
+ // The file may have moved between readdir and this preflight. Let the
122
+ // atomic claim path decide whether anything remains to process.
123
+ return true;
124
+ }
125
+ }
126
+
127
+ function pendingAskScope(userId: string): PendingAskScope {
128
+ return { userId };
129
+ }
130
+
131
+ function clearPendingAskForEntry(
132
+ scope: PendingAskScope,
133
+ entry: Extract<SessionInboxEntry, { type: "ask" }>,
134
+ topicName: string,
135
+ reason: string,
136
+ ): void {
137
+ if (entry.remoteReply) return;
138
+ if (!entry.from) return;
139
+ const cleared = clearPendingAsk({
140
+ userId: scope.userId,
141
+ from: entry.from,
142
+ to: topicName,
143
+ requestId: entry.requestId,
144
+ });
145
+ logger.info(
146
+ { from: entry.from, to: topicName, requestId: entry.requestId, cleared, reason },
147
+ "session-inbox: pending ask cleared",
148
+ );
149
+ }
150
+
151
+ function entryFromLabel(entry: { from: string; fromTitle?: string }): string {
152
+ return entry.fromTitle?.trim() || entry.from;
153
+ }
154
+
155
+ async function resolveEntryCallerTopic(
156
+ scope: PendingAskScope,
157
+ entry: Extract<SessionInboxEntry, { type: "ask" }>,
158
+ ) {
159
+ const { getTopic, getTopicByNameForUser } = await import("#storage/api-topics");
160
+ if (entry.fromTopicId) {
161
+ const byId = getTopic(entry.fromTopicId);
162
+ if (byId?.participants.some((p) => p.userId === scope.userId)) return byId;
163
+ }
164
+ return getTopicByNameForUser(entry.from, String(scope.userId));
165
+ }
166
+
167
+ function notifyCallerTopic(callerTopicId: string, targetLabel: string, message: string): void {
168
+ const msg = {
169
+ id: randomUUID(),
170
+ topicId: callerTopicId,
171
+ authorId: "system",
172
+ text: `[<- ${targetLabel}]\n${message}`,
173
+ createdAt: new Date().toISOString(),
174
+ };
175
+ appendApiMessage(msg, { notify: false });
176
+ WsHub.get().broadcastMessage(callerTopicId, msg);
177
+ }
178
+
179
+ function persistVisibleAskMessage(args: {
180
+ callerTopicId: string;
181
+ callerUserId: string;
182
+ targetLabel: string;
183
+ requestId: string;
184
+ message: string;
185
+ }): void {
186
+ const msg = {
187
+ id: `ask-${args.requestId}-sent`,
188
+ topicId: args.callerTopicId,
189
+ authorId: args.callerUserId,
190
+ text: `[Ask to **${args.targetLabel}**]\n\n${args.message}`,
191
+ createdAt: new Date().toISOString(),
192
+ };
193
+ try {
194
+ appendApiMessage(msg, { notify: false });
195
+ WsHub.get().broadcastMessage(args.callerTopicId, msg);
196
+ } catch (error) {
197
+ // Inbox files are at-least-once. The stable request-derived id makes a
198
+ // replay successful without duplicating the visible card.
199
+ if (!getApiMessage(args.callerTopicId, msg.id)) throw error;
200
+ }
201
+ }
202
+
203
+ async function notifyAskDrop(
204
+ scope: PendingAskScope,
205
+ entry: Extract<SessionInboxEntry, { type: "ask" }>,
206
+ targetLabel: string,
207
+ message: string,
208
+ ): Promise<void> {
209
+ if (!entry.from) return;
210
+ const callerTopic = await resolveEntryCallerTopic(scope, entry);
211
+ if (!callerTopic) {
212
+ logger.warn(
213
+ { from: entry.from, to: targetLabel, requestId: entry.requestId },
214
+ "session-inbox: failed to notify ask caller — caller topic not found",
215
+ );
216
+ return;
217
+ }
218
+ try {
219
+ notifyCallerTopic(callerTopic.id, targetLabel, message);
220
+ } catch (err) {
221
+ logger.warn(
222
+ { err, from: entry.from, to: targetLabel, requestId: entry.requestId },
223
+ "session-inbox: failed to notify ask caller",
224
+ );
225
+ }
226
+ }
227
+
228
+ function stableLegacyRequestId(prefix: string, parts: Array<string | number | undefined>): string {
229
+ const hash = createHash("sha256")
230
+ .update(parts.map((part) => String(part ?? "")).join("\0"))
231
+ .digest("hex")
232
+ .slice(0, 16);
233
+ return `${prefix}-${hash}`;
234
+ }
235
+
236
+ // ── Per-topic worker mutex ──────────────────────────────────────────
237
+
238
+ /**
239
+ * Key: `${userId}/${topicName}`. While a worker is draining a topic's inbox,
240
+ * the dispatcher skips it — same-topic entries must be serial to preserve
241
+ * order and avoid `drainOutboxFile` rename races.
242
+ */
243
+ const topicWorkerBusy = new Set<string>();
244
+
245
+ // ── Dispatcher ──────────────────────────────────────────────────────
246
+
247
+ /**
248
+ * Scan all user inbox dirs and kick a worker per topic. Returns immediately
249
+ * — workers run in the background.
250
+ *
251
+ * Triggered by fs.watch (200ms debounce) + {@link FALLBACK_INTERVAL_MS} fallback poll.
252
+ */
253
+ export async function flushSessionInbox() {
254
+ await dispatchDueSelfSchedules();
255
+ // Upgrade compatibility: schedules written by versions before the SQLite
256
+ // manager remain consumable from their legacy `.schedule` sidecars.
257
+ sweepScheduledSessionInbox();
258
+ let userDirs: string[];
259
+ try {
260
+ userDirs = readdirSync(SESSION_INBOX_DIR);
261
+ } catch {
262
+ // Dir doesn't exist yet — nothing to drain.
263
+ return;
264
+ }
265
+
266
+ for (const uid of userDirs) {
267
+ const userInboxDir = join(SESSION_INBOX_DIR, uid);
268
+ let entries: string[];
269
+ try {
270
+ entries = readdirSync(userInboxDir);
271
+ } catch {
272
+ continue;
273
+ }
274
+
275
+ for (const entry of entries) {
276
+ const entryPath = join(userInboxDir, entry);
277
+
278
+ let isDir = false;
279
+ try {
280
+ isDir = statSync(entryPath).isDirectory();
281
+ } catch {
282
+ continue;
283
+ }
284
+
285
+ if (!isDir && entry.endsWith(".jsonl")) {
286
+ // New files are keyed by an encoded canonical topic id. Keep reading
287
+ // legacy title-keyed files during rolling upgrades.
288
+ const topicId = topicIdFromSessionInboxFileName(entry);
289
+ const topicName = topicId ? "" : entry.replace(/\.jsonl$/, "");
290
+ const topicKey = `${uid}/${topicId ? `id:${topicId}` : topicName}`;
291
+ if (topicWorkerBusy.has(topicKey)) continue;
292
+
293
+ topicWorkerBusy.add(topicKey);
294
+ processTopicInbox({ topicKey, filePath: entryPath, userId: uid, topicId, topicName })
295
+ .catch((e) => logger.error({ err: e, topicKey }, "session-inbox: topic worker crashed"))
296
+ .finally(() => topicWorkerBusy.delete(topicKey));
297
+ }
298
+ }
299
+ }
300
+ }
301
+
302
+ type SelfScheduleTurnTrigger = typeof import("#runtime/turn-runner").triggerTopicAiTurn;
303
+
304
+ /**
305
+ * Claim and start due DB-backed self-schedules. A busy-topic race is removed
306
+ * from the process-local defer queue and returned to durable pending state;
307
+ * interrupted runs are retried after the topic is idle again.
308
+ */
309
+ export async function dispatchDueSelfSchedules(
310
+ nowMs = Date.now(),
311
+ triggerOverride?: SelfScheduleTurnTrigger,
312
+ ): Promise<number> {
313
+ const trigger = triggerOverride ?? (await import("#runtime/turn-runner")).triggerTopicAiTurn;
314
+ const { cancelDeferredInject } = await import("#query/active-rooms");
315
+ const { getTopic } = await import("#storage/api-topics");
316
+ const ownerId = `${RUNTIME_INSTANCE_ID}:self-schedule`;
317
+ let started = 0;
318
+
319
+ // Bound one sweep so a corrupt/hostile database cannot monopolize the inbox
320
+ // worker. The 5s fallback poll picks up any remaining due topics.
321
+ for (let index = 0; index < 100; index++) {
322
+ const schedule = claimNextDueSelfSchedule(ownerId, nowMs);
323
+ if (!schedule) break;
324
+ const topic = getTopic(schedule.topicId);
325
+ if (
326
+ !topic?.agent ||
327
+ !topic.participants.some((participant) => participant.userId === schedule.userId)
328
+ ) {
329
+ completeSelfSchedule(schedule.id, ownerId);
330
+ logger.warn(
331
+ { scheduleId: schedule.id, topicId: schedule.topicId, userId: schedule.userId },
332
+ "self-schedule: target topic unavailable, dropping",
333
+ );
334
+ continue;
335
+ }
336
+
337
+ let settled = false;
338
+ let claimHeartbeat: ReturnType<typeof setInterval> | null = null;
339
+ const stopClaimHeartbeat = () => {
340
+ if (!claimHeartbeat) return;
341
+ clearInterval(claimHeartbeat);
342
+ claimHeartbeat = null;
343
+ };
344
+ const fromLabel = "Scheduled self";
345
+ const queryId = trigger(
346
+ topic.id,
347
+ schedule.userId,
348
+ `[Tell from **${fromLabel}**]\n\n${schedule.message}`,
349
+ topic.agent,
350
+ {
351
+ origin: fromLabel,
352
+ requestId: schedule.id,
353
+ depth: 0,
354
+ from: fromLabel,
355
+ injectAuthorId: "system",
356
+ onSettled: (result) => {
357
+ settled = true;
358
+ stopClaimHeartbeat();
359
+ if (result.kind === "aborted" && result.abortReason === AbortReason.Internal) {
360
+ // User preemption requeues injects in memory. Remove that copy and
361
+ // restore the durable schedule instead; a newer pending schedule
362
+ // created by this run wins if one already exists.
363
+ cancelDeferredInject(topic.id, schedule.id);
364
+ releaseSelfScheduleClaim(schedule.id, ownerId);
365
+ return;
366
+ }
367
+ completeSelfSchedule(schedule.id, ownerId);
368
+ },
369
+ },
370
+ );
371
+
372
+ if (!queryId) {
373
+ if (!settled) {
374
+ // The room became busy between the DB idle check and turn claim.
375
+ cancelDeferredInject(topic.id, schedule.id);
376
+ releaseSelfScheduleClaim(schedule.id, ownerId);
377
+ }
378
+ // Avoid immediately reclaiming the same overdue schedule in this sweep.
379
+ break;
380
+ }
381
+ if (markSelfScheduleRunning(schedule.id, ownerId, queryId) && !settled) {
382
+ claimHeartbeat = setInterval(() => {
383
+ if (heartbeatSelfScheduleClaim(schedule.id, ownerId)) return;
384
+ stopClaimHeartbeat();
385
+ }, 1_000);
386
+ claimHeartbeat.unref?.();
387
+ }
388
+ started++;
389
+ }
390
+ return started;
391
+ }
392
+
393
+ /**
394
+ * Promote due schedule_self sidecars into the normal session inbox. Files are
395
+ * atomically claimed with the same crash-recovery primitive as every outbox;
396
+ * future entries are appended back and due entries retain their requestId.
397
+ */
398
+ export function sweepScheduledSessionInbox(nowMs = Date.now()): void {
399
+ let userDirs: string[];
400
+ try {
401
+ userDirs = readdirSync(SESSION_INBOX_DIR);
402
+ } catch {
403
+ return;
404
+ }
405
+
406
+ for (const userId of userDirs) {
407
+ const userInboxDir = join(SESSION_INBOX_DIR, userId);
408
+ let files: string[];
409
+ try {
410
+ files = readdirSync(userInboxDir);
411
+ } catch {
412
+ continue;
413
+ }
414
+ const scheduledFiles = new Set(
415
+ files
416
+ .filter((file) => file.endsWith(".schedule") || file.endsWith(".schedule.processing"))
417
+ .map((file) =>
418
+ file.endsWith(".processing") ? file.slice(0, -".processing".length) : file,
419
+ ),
420
+ );
421
+
422
+ for (const file of scheduledFiles) {
423
+ const topicId = topicIdFromScheduledSessionInboxFileName(file);
424
+ if (!topicId) continue;
425
+ const schedulePath = join(userInboxDir, file);
426
+ const hasProcessingClaim = files.includes(`${file}.processing`);
427
+ if (!hasProcessingClaim && !scheduledFileNeedsClaim(schedulePath, nowMs)) continue;
428
+ const drained = drainOutboxFile(schedulePath, "self-schedule");
429
+ if (!drained) continue;
430
+ const pending: ScheduledSessionInboxEntry[] = [];
431
+ const due: ScheduledSessionInboxEntry[] = [];
432
+
433
+ for (const line of drained.lines) {
434
+ const entry = parseOutboxLine<ScheduledSessionInboxEntry>(line, "self-schedule");
435
+ const deliverAt = scheduledEntryDeliveryTime(entry);
436
+ if (!entry || deliverAt === null) {
437
+ logger.warn({ userId, topicId }, "self-schedule: invalid entry dropped");
438
+ continue;
439
+ }
440
+ (deliverAt <= nowMs ? due : pending).push(entry);
441
+ }
442
+
443
+ for (const entry of due) {
444
+ const { deliverAt: _deliverAt, ...liveEntry } = entry;
445
+ appendJsonlEntry(sessionInboxPath(userId, topicId), liveEntry);
446
+ }
447
+ for (const entry of pending) appendJsonlEntry(schedulePath, entry);
448
+ deleteProcessingFile(drained.processingPath, "self-schedule", drained.lines.length);
449
+
450
+ if (due.length > 0) {
451
+ logger.info({ userId, topicId, count: due.length }, "self-schedule: due entries promoted");
452
+ }
453
+ }
454
+ }
455
+ }
456
+
457
+ // ── Topic worker ────────────────────────────────────────────────────
458
+
459
+ async function processTopicInbox(args: {
460
+ topicKey: string;
461
+ filePath: string;
462
+ userId: string;
463
+ topicId: string | null;
464
+ topicName: string;
465
+ }) {
466
+ // Lazy-import to avoid circular deps at module-load time.
467
+ const { getTopic, getTopicByNameForUser } = await import("#storage/api-topics");
468
+ const { topicKey, filePath, userId, topicId, topicName } = args;
469
+ const scope = pendingAskScope(userId);
470
+
471
+ while (true) {
472
+ const drained = drainOutboxFile(filePath, "session-inbox");
473
+ if (!drained) return;
474
+ const { lines, processingPath } = drained;
475
+ const seenRequestIds = new Set<string>();
476
+
477
+ for (const line of lines) {
478
+ const raw = parseOutboxLine<Record<string, unknown>>(line, "session-inbox");
479
+ if (!raw) continue;
480
+
481
+ // Normalize: Otium legacy compat (command/type-less → tell/ask).
482
+ let entry: SessionInboxEntry;
483
+ if (raw.type === "abort") {
484
+ entry = raw as SessionInboxEntry;
485
+ } else if (raw.command || raw.type === "command" || raw.type === "tell") {
486
+ entry = { ...raw, type: "tell" } as SessionInboxEntry;
487
+ } else {
488
+ entry = { ...raw, type: "ask" } as SessionInboxEntry;
489
+ }
490
+
491
+ if (!topicId && !topicName) {
492
+ logger.error({ filePath, topicKey }, "session-inbox: could not extract topic name");
493
+ continue;
494
+ }
495
+
496
+ // ── abort ──────────────────────────────────────────────────
497
+ if (entry.type === "abort") {
498
+ await handleAbortEntry(userId, topicName, topicId, entry.timestamp);
499
+ continue;
500
+ }
501
+
502
+ // ── validate from/message ──────────────────────────────────
503
+ if (!entry.from || !entry.message) {
504
+ logger.error({ entry }, "session-inbox: missing from/message, dropping");
505
+ // MCP writer clears its own pending ask on write failure;
506
+ // the inbox consumer only drops the entry.
507
+ if (entry.type === "ask") {
508
+ clearPendingAskForEntry(scope, entry, topicName, "invalid-entry");
509
+ logger.warn({ from: entry.from, to: topicName }, "session-inbox: dropping invalid ask");
510
+ await notifyAskDrop(scope, entry, topicName, "(error: invalid ask entry)");
511
+ }
512
+ continue;
513
+ }
514
+
515
+ if (entry.requestId) {
516
+ if (seenRequestIds.has(entry.requestId)) {
517
+ logger.info(
518
+ { topicKey, topicName, requestId: entry.requestId, from: entry.from },
519
+ "session-inbox: duplicate requestId in drained batch, skipping",
520
+ );
521
+ continue;
522
+ }
523
+ seenRequestIds.add(entry.requestId);
524
+ }
525
+
526
+ // Look up the target topic.
527
+ const byId = topicId ? getTopic(topicId) : null;
528
+ const topic = topicId
529
+ ? byId?.participants.some((participant) => participant.userId === userId)
530
+ ? byId
531
+ : null
532
+ : getTopicByNameForUser(topicName, userId);
533
+ if (!topic) {
534
+ logger.warn({ topicName, from: entry.from }, "session-inbox: topic not found, dropping");
535
+ if (entry.type === "ask") {
536
+ clearPendingAskForEntry(scope, entry, topicName, "target-topic-not-found");
537
+ await notifyAskDrop(
538
+ scope,
539
+ entry,
540
+ topicName,
541
+ `(error: target topic "${topicName}" was not found)`,
542
+ );
543
+ }
544
+ continue;
545
+ }
546
+
547
+ // The AI-enabled check: topics without agent can't run AI turns.
548
+ const isAiEnabled = Boolean(topic.agent?.trim());
549
+
550
+ // ── tell ───────────────────────────────────────────────────
551
+ if (entry.type === "tell") {
552
+ await handleTellEntry(topic, topic.title, entry, isAiEnabled, scope);
553
+ continue;
554
+ }
555
+
556
+ // ── ask ────────────────────────────────────────────────────
557
+ if (entry.type === "ask") {
558
+ await handleAskEntry(topic, topic.title, entry, isAiEnabled, scope);
559
+ }
560
+ }
561
+
562
+ // At-least-once: drop the claim only after the whole batch was handled.
563
+ // A crash mid-batch leaves the `.processing` claim on disk; the next
564
+ // drain's leftover-merge redelivers it.
565
+ deleteProcessingFile(processingPath, "session-inbox", lines.length);
566
+ }
567
+ }
568
+
569
+ // ── Entry handlers ──────────────────────────────────────────────────
570
+
571
+ async function handleAbortEntry(
572
+ userId: string,
573
+ topicName: string,
574
+ topicId: string | null,
575
+ issuedAt?: string,
576
+ ) {
577
+ const { getTopic, getTopicByNameForUser } = await import("#storage/api-topics");
578
+ const byId = topicId ? getTopic(topicId) : null;
579
+ const topic = topicId
580
+ ? byId?.participants.some((participant) => participant.userId === userId)
581
+ ? byId
582
+ : null
583
+ : getTopicByNameForUser(topicName, userId);
584
+ if (!topic) {
585
+ logger.warn({ topicName }, "session-inbox: abort for unknown topic, skipping");
586
+ return;
587
+ }
588
+
589
+ const { abortRoom, getRoomQuery, interSessionQueue } = await import("#query/active-rooms");
590
+
591
+ // The user's intent at abort time also covers work still waiting in the
592
+ // defer queue — otherwise the "aborted" room immediately restarts.
593
+ interSessionQueue.drop(topic.id);
594
+
595
+ // Stale-abort guard: the inbox is at-least-once, so an abort entry can be
596
+ // consumed AFTER a newer turn already claimed the room. Only abort turns
597
+ // that started before the abort was issued (60s skew allowance).
598
+ const issuedMs = issuedAt ? Date.parse(issuedAt) : Number.NaN;
599
+ const running = getRoomQuery(topic.id);
600
+ if (running?.startedAt && Number.isFinite(issuedMs) && running.startedAt > issuedMs + 60_000) {
601
+ logger.info(
602
+ { topicName, topicId: topic.id, startedAt: running.startedAt, issuedMs },
603
+ "session-inbox: stale abort ignored — turn started after abort was issued",
604
+ );
605
+ return;
606
+ }
607
+
608
+ // Abort the running query via the room-keyed registry (B from active-rooms.ts).
609
+ const aborted = abortRoom(topic.id);
610
+ logger.info({ topicName, topicId: topic.id, aborted }, "session-inbox: abort processed");
611
+ }
612
+
613
+ async function handleTellEntry(
614
+ topic: { id: string; title: string; agent?: string | null },
615
+ topicName: string,
616
+ entry: Extract<SessionInboxEntry, { type: "tell" }>,
617
+ isAiEnabled: boolean,
618
+ scope: PendingAskScope,
619
+ ) {
620
+ const { triggerTopicAiTurn } = await import("#runtime/turn-runner");
621
+ const fromLabel = entryFromLabel(entry);
622
+ const requestId =
623
+ entry.requestId ??
624
+ stableLegacyRequestId("legacy-tell", [topicName, entry.from, entry.message, entry.timestamp]);
625
+
626
+ if (!isAiEnabled) {
627
+ // AI-enabled targets are persisted by triggerTopicAiTurn below. Human-only
628
+ // targets still need a visible DB message even though no AI turn runs. A
629
+ // request-derived id makes crash replay converge on the same row.
630
+ const msgId = `tell-${requestId}`;
631
+ const now = new Date().toISOString();
632
+ const text = `[from ${fromLabel}]\n${entry.message}`;
633
+
634
+ try {
635
+ appendApiMessage({
636
+ id: msgId,
637
+ topicId: topic.id,
638
+ authorId: "system",
639
+ sourceAdapter: "session-comm",
640
+ text,
641
+ createdAt: now,
642
+ });
643
+ } catch (e) {
644
+ logger.error({ err: e, topicName }, "session-inbox: failed to persist tell message");
645
+ }
646
+ }
647
+
648
+ // Trigger AI turn (defers behind a running user turn via B's abort-on-new-message).
649
+ if (isAiEnabled) {
650
+ const isAutoContinue = entry.from === FROM_AUTO_CONTINUE;
651
+ const isHiddenContinue = isAutoContinue;
652
+ triggerTopicAiTurn(
653
+ topic.id,
654
+ String(scope.userId),
655
+ isHiddenContinue ? entry.message : `[Tell from **${fromLabel}**]\n\n${entry.message}`,
656
+ (topic.agent ?? undefined) as Parameters<typeof triggerTopicAiTurn>[3],
657
+ {
658
+ origin: fromLabel,
659
+ requestId,
660
+ depth: entry.depth,
661
+ from: fromLabel,
662
+ hideInjectMessage: isHiddenContinue,
663
+ injectAuthorId: "system",
664
+ injectSourceAdapter: "session-comm",
665
+ },
666
+ );
667
+ }
668
+
669
+ logger.info(
670
+ { topicName, from: entry.from, fromLabel, aiEnabled: isAiEnabled },
671
+ "session-inbox: tell delivered",
672
+ );
673
+ }
674
+
675
+ async function handleAskEntry(
676
+ topic: { id: string; title: string; agent?: string | null },
677
+ topicName: string,
678
+ entry: Extract<SessionInboxEntry, { type: "ask" }>,
679
+ isAiEnabled: boolean,
680
+ scope: PendingAskScope,
681
+ ) {
682
+ const { triggerTopicAiTurn } = await import("#runtime/turn-runner");
683
+ const { registerAskCallback } = await import("#runtime/ask-callbacks");
684
+ const fromLabel = entryFromLabel(entry);
685
+ const remoteReply = entry.remoteReply;
686
+
687
+ // Build the prompt with read-only instruction (mirrors Otium).
688
+ const prompt = `[ASK from ${fromLabel}]\n${entry.message}\n\n이건 ${fromLabel}이 당신의 context를 참조하려는 요청입니다 (READ-only).\n가지고 있는 정보를 그대로 공유하세요.\n출력 내용은 자동으로 "${fromLabel}" 세션에 돌아갑니다.`;
689
+
690
+ if (!isAiEnabled) {
691
+ logger.warn({ topicName, from: entry.from }, "session-inbox: ask to non-AI topic, dropping");
692
+ clearPendingAskForEntry(scope, entry, topicName, "target-ai-disabled");
693
+ if (remoteReply) {
694
+ await deliverPeerReply(
695
+ remoteReply,
696
+ topic.title,
697
+ `(error: target topic "${topicName}" has no AI)`,
698
+ "error",
699
+ );
700
+ } else {
701
+ await notifyAskDrop(
702
+ scope,
703
+ entry,
704
+ topicName,
705
+ `(error: target topic "${topicName}" has no AI)`,
706
+ );
707
+ }
708
+ return;
709
+ }
710
+
711
+ // Register the ask reply callback so the AI's response routes back to the caller.
712
+ // requestId is pre-generated by the MCP writer — we use it as-is.
713
+ const requestId = entry.requestId;
714
+
715
+ // Look up the caller topic to route the reply back.
716
+ const callerTopic = remoteReply ? null : await resolveEntryCallerTopic(scope, entry);
717
+ const callerTopicId = remoteReply?.topicId ?? callerTopic?.id;
718
+ if (!requestId || !callerTopicId) {
719
+ clearPendingAskForEntry(scope, entry, topicName, "caller-topic-not-found");
720
+ logger.warn(
721
+ { topicName, from: entry.from, fromTopicId: entry.fromTopicId, requestId, callerTopicId },
722
+ "session-inbox: ask cannot be routed back, dropping",
723
+ );
724
+ return;
725
+ }
726
+
727
+ if (!remoteReply) {
728
+ persistVisibleAskMessage({
729
+ callerTopicId,
730
+ callerUserId: String(scope.userId),
731
+ targetLabel: topic.title,
732
+ requestId,
733
+ message: entry.message,
734
+ });
735
+ }
736
+
737
+ const { getTopic, getTopicSessionId } = await import("#storage/api-topics");
738
+ const fullTopic = getTopic(topic.id);
739
+ const agentOverride = (fullTopic?.agent ?? topic.agent ?? undefined) as AgentKind | undefined;
740
+ const cwd = resolveTopicWorkspaceDir(topic.id);
741
+ if (!agentOverride) {
742
+ clearPendingAskForEntry(scope, entry, topicName, "target-agent-missing");
743
+ await notifyAskDrop(scope, entry, topicName, "(error: target topic has no agent)");
744
+ return;
745
+ }
746
+
747
+ // Delay the snapshot until the queued turn actually claims the target room.
748
+ // This ensures the fork includes any user turn that was ahead of the ask.
749
+ const prepareSession = async () => {
750
+ const { forkAgentSession } = await import("#agents/fork");
751
+ const { getRegistry } = await import("#agents/registry");
752
+ const { resolveModelForAgent } = await import("#agents/model-catalog");
753
+ const { getApiTopicConfig } = await import("#storage/api-topic-config");
754
+ const { readConversation } = await import("#storage/conversations");
755
+ const currentTopic = getTopic(topic.id);
756
+ const registry = getRegistry(agentOverride);
757
+ const topicConfig = getApiTopicConfig(topic.id);
758
+ const model = resolveModelForAgent(
759
+ agentOverride,
760
+ topicConfig?.model ?? currentTopic?.defaultModel,
761
+ registry,
762
+ );
763
+ const requestedEffort = topicConfig?.effort ?? currentTopic?.defaultEffort;
764
+ const effort =
765
+ requestedEffort && registry.validateEffort(requestedEffort)
766
+ ? requestedEffort
767
+ : registry.defaultEffort;
768
+ const parentSessionId = getTopicSessionId(topic.id);
769
+
770
+ if (parentSessionId) {
771
+ try {
772
+ return await forkAgentSession({
773
+ agent: agentOverride,
774
+ parentSessionId,
775
+ cwd,
776
+ userId: scope.userId,
777
+ topicName,
778
+ title: `ask: ${entry.from} -> ${topicName}`,
779
+ model,
780
+ ...(effort ? { effort } : {}),
781
+ });
782
+ } catch (err) {
783
+ // A stale provider session should not make ask_session unusable. The
784
+ // unified log is the durable source of truth and can seed a new fork.
785
+ logger.warn(
786
+ { err, topicName, from: entry.from, requestId, parentSessionId },
787
+ "session-inbox: native target fork failed; synthesizing from unified history",
788
+ );
789
+ }
790
+ }
791
+
792
+ const rollout = registry.writeRollout({
793
+ cwd,
794
+ entries: readConversation(scope.userId, topicName),
795
+ model,
796
+ ...(effort ? { effort } : {}),
797
+ });
798
+ return {
799
+ agent: agentOverride,
800
+ forkId: rollout.sessionId,
801
+ rolloutPath: rollout.rolloutPath,
802
+ };
803
+ };
804
+
805
+ // Trigger the AI turn. The onDispatched callback registers the reply route
806
+ // at the moment the turn actually starts (immediately, or after being deferred
807
+ // behind a running user turn via B's queue).
808
+ triggerTopicAiTurn(
809
+ topic.id,
810
+ String(scope.userId),
811
+ prompt,
812
+ (agentOverride ?? topic.agent ?? undefined) as Parameters<typeof triggerTopicAiTurn>[3],
813
+ {
814
+ origin: entry.from,
815
+ requestId,
816
+ contextId: entry.contextId,
817
+ depth: (entry.fromDepth ?? 0) + 1,
818
+ silent: true,
819
+ prepareSession,
820
+ cwd,
821
+ onSettled: (result) => {
822
+ if (result.queryId || result.kind !== "error") return;
823
+ clearPendingAskForEntry(scope, entry, topicName, "target-unavailable-before-dispatch");
824
+ if (remoteReply) {
825
+ void deliverPeerReply(
826
+ remoteReply,
827
+ topic.title,
828
+ `(error: target topic became unavailable before dispatch)`,
829
+ "error",
830
+ );
831
+ } else {
832
+ void notifyAskDrop(
833
+ scope,
834
+ entry,
835
+ topicName,
836
+ `(error: target topic became unavailable before dispatch)`,
837
+ );
838
+ }
839
+ },
840
+ onDispatched: (queryId: string) => {
841
+ registerAskCallback({
842
+ requestId,
843
+ contextId: entry.contextId,
844
+ callerTopicId,
845
+ callerUserId: String(scope.userId),
846
+ targetQueryId: queryId,
847
+ createdAt: Date.now(),
848
+ ...(remoteReply
849
+ ? { remoteReply }
850
+ : {
851
+ pendingAsk: {
852
+ userId: scope.userId,
853
+ from: entry.from,
854
+ to: topicName,
855
+ requestId,
856
+ },
857
+ }),
858
+ });
859
+ },
860
+ },
861
+ );
862
+
863
+ logger.info(
864
+ { topicName, from: entry.from, requestId, callerTopicId },
865
+ "session-inbox: ask delivered",
866
+ );
867
+ }
868
+
869
+ // ── Bootstrap ───────────────────────────────────────────────────────
870
+
871
+ let watcher: ReturnType<typeof watchDir> | null = null;
872
+ let fallbackTimer: ReturnType<typeof setInterval> | null = null;
873
+ let debouncedTrigger: (() => void) | null = null;
874
+ let leadershipTimer: ReturnType<typeof setInterval> | null = null;
875
+ let workerLease: RuntimeProcessLeaseHandle | null = null;
876
+ let workerStarted = false;
877
+
878
+ const SESSION_INBOX_PROCESS_ROLE = "worker:session-inbox";
879
+
880
+ function stopLeaderResources(): void {
881
+ if (watcher) {
882
+ watcher.close();
883
+ watcher = null;
884
+ }
885
+ if (fallbackTimer) {
886
+ clearInterval(fallbackTimer);
887
+ fallbackTimer = null;
888
+ }
889
+ debouncedTrigger = null;
890
+ }
891
+
892
+ function tryBecomeSessionInboxLeader(): void {
893
+ if (!workerStarted || workerLease) return;
894
+
895
+ let acquired: RuntimeProcessLeaseHandle | null = null;
896
+ acquired = acquireRuntimeProcessLease(SESSION_INBOX_PROCESS_ROLE, {
897
+ onLost: () => {
898
+ if (workerLease !== acquired) return;
899
+ workerLease = null;
900
+ stopLeaderResources();
901
+ logger.warn("session-inbox: worker leadership lost");
902
+ },
903
+ });
904
+ if (!acquired) return;
905
+
906
+ workerLease = acquired;
907
+ debouncedTrigger = debouncedFlush(flushSessionInbox, "session-inbox", 200);
908
+ watcher = watchDir(SESSION_INBOX_DIR, () => debouncedTrigger?.());
909
+ fallbackTimer = setInterval(() => debouncedTrigger?.(), FALLBACK_INTERVAL_MS);
910
+ fallbackTimer.unref?.();
911
+
912
+ logger.info(
913
+ { dir: SESSION_INBOX_DIR, role: SESSION_INBOX_PROCESS_ROLE },
914
+ "session-inbox: worker leadership acquired",
915
+ );
916
+ // Fire an initial flush to drain entries written before this leader started.
917
+ void flushSessionInbox();
918
+ }
919
+
920
+ /**
921
+ * Join the session-inbox worker election. Exactly one runtime process watches
922
+ * and drains the shared inbox; contenders take over when its process lease is
923
+ * released, expires, or belongs to a dead PID.
924
+ * Returns a cleanup function for graceful shutdown.
925
+ */
926
+ export function startSessionInboxWorker(): () => void {
927
+ if (workerStarted) return () => {};
928
+ workerStarted = true;
929
+
930
+ tryBecomeSessionInboxLeader();
931
+ leadershipTimer = setInterval(tryBecomeSessionInboxLeader, PROCESS_LEASE_HEARTBEAT_MS);
932
+ leadershipTimer.unref?.();
933
+
934
+ return () => {
935
+ if (!workerStarted) return;
936
+ workerStarted = false;
937
+ if (leadershipTimer) {
938
+ clearInterval(leadershipTimer);
939
+ leadershipTimer = null;
940
+ }
941
+ stopLeaderResources();
942
+ workerLease?.stop();
943
+ workerLease = null;
944
+ logger.info("session-inbox: worker stopped");
945
+ };
946
+ }