negotium 0.1.7 → 0.1.9

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 (270) hide show
  1. package/README.md +26 -2
  2. package/dist/canonical-mcp-bridge.js +14 -0
  3. package/dist/canonical-mcp-bridge.js.map +9 -0
  4. package/dist/chunk-10ct8814.js +429 -0
  5. package/dist/chunk-10ct8814.js.map +11 -0
  6. package/dist/chunk-18fzftjf.js +214 -0
  7. package/dist/chunk-18fzftjf.js.map +10 -0
  8. package/dist/chunk-1k7t7bnx.js +97 -0
  9. package/dist/chunk-1k7t7bnx.js.map +10 -0
  10. package/dist/chunk-1msje2zf.js +23 -0
  11. package/dist/chunk-1msje2zf.js.map +10 -0
  12. package/dist/chunk-2dgkah1z.js +706 -0
  13. package/dist/chunk-2dgkah1z.js.map +14 -0
  14. package/dist/chunk-3t4dtm26.js +61 -0
  15. package/dist/chunk-3t4dtm26.js.map +10 -0
  16. package/dist/chunk-3vn2vnj1.js +51 -0
  17. package/dist/chunk-3vn2vnj1.js.map +10 -0
  18. package/dist/chunk-6hknphtc.js +2054 -0
  19. package/dist/chunk-6hknphtc.js.map +29 -0
  20. package/dist/chunk-6tfzpcvk.js +2149 -0
  21. package/dist/chunk-6tfzpcvk.js.map +15 -0
  22. package/dist/chunk-8g0c4as0.js +23 -0
  23. package/dist/chunk-8g0c4as0.js.map +10 -0
  24. package/dist/chunk-9spx3h9n.js +279 -0
  25. package/dist/chunk-9spx3h9n.js.map +10 -0
  26. package/dist/chunk-c500xe9z.js +33 -0
  27. package/dist/chunk-c500xe9z.js.map +10 -0
  28. package/dist/chunk-e9b2xpff.js +147 -0
  29. package/dist/chunk-e9b2xpff.js.map +10 -0
  30. package/dist/chunk-edb07sjp.js +567 -0
  31. package/dist/chunk-edb07sjp.js.map +14 -0
  32. package/dist/chunk-eexqsee7.js +368 -0
  33. package/dist/chunk-eexqsee7.js.map +11 -0
  34. package/dist/chunk-efyxk97v.js +500 -0
  35. package/dist/chunk-efyxk97v.js.map +13 -0
  36. package/dist/chunk-epqxwkvh.js +3534 -0
  37. package/dist/chunk-epqxwkvh.js.map +20 -0
  38. package/dist/chunk-f1pzh3xh.js +115 -0
  39. package/dist/chunk-f1pzh3xh.js.map +10 -0
  40. package/dist/chunk-f5xgprg1.js +59 -0
  41. package/dist/chunk-f5xgprg1.js.map +10 -0
  42. package/dist/chunk-f97qybwk.js +584 -0
  43. package/dist/chunk-f97qybwk.js.map +11 -0
  44. package/dist/chunk-fvc7ncxd.js +252 -0
  45. package/dist/chunk-fvc7ncxd.js.map +10 -0
  46. package/dist/chunk-g7pvfj5e.js +574 -0
  47. package/dist/chunk-g7pvfj5e.js.map +14 -0
  48. package/dist/chunk-h8vmb9gf.js +315 -0
  49. package/dist/chunk-h8vmb9gf.js.map +10 -0
  50. package/dist/chunk-hgw7n8cm.js +43 -0
  51. package/dist/chunk-hgw7n8cm.js.map +10 -0
  52. package/dist/chunk-hsq7j9jk.js +126 -0
  53. package/dist/chunk-hsq7j9jk.js.map +10 -0
  54. package/dist/chunk-hwy3n31w.js +21 -0
  55. package/dist/chunk-hwy3n31w.js.map +9 -0
  56. package/dist/chunk-j0kjscb7.js +53 -0
  57. package/dist/chunk-j0kjscb7.js.map +10 -0
  58. package/dist/chunk-jzz91jwh.js +70 -0
  59. package/dist/chunk-jzz91jwh.js.map +10 -0
  60. package/dist/chunk-k9z47mab.js +247 -0
  61. package/dist/chunk-k9z47mab.js.map +12 -0
  62. package/dist/chunk-kbm35t7w.js +1331 -0
  63. package/dist/chunk-kbm35t7w.js.map +16 -0
  64. package/dist/chunk-m4veth0c.js +1649 -0
  65. package/dist/chunk-m4veth0c.js.map +17 -0
  66. package/dist/chunk-mhty3nna.js +150 -0
  67. package/dist/chunk-mhty3nna.js.map +11 -0
  68. package/dist/chunk-mmgh670x.js +248 -0
  69. package/dist/chunk-mmgh670x.js.map +11 -0
  70. package/dist/chunk-n0kk5xbq.js +2358 -0
  71. package/dist/chunk-n0kk5xbq.js.map +26 -0
  72. package/dist/chunk-nmv41qx9.js +2847 -0
  73. package/dist/chunk-nmv41qx9.js.map +23 -0
  74. package/dist/chunk-nqdp52aj.js +113 -0
  75. package/dist/chunk-nqdp52aj.js.map +12 -0
  76. package/dist/chunk-ns2m25dz.js +139 -0
  77. package/dist/chunk-ns2m25dz.js.map +10 -0
  78. package/dist/chunk-pkdmypdd.js +147 -0
  79. package/dist/chunk-pkdmypdd.js.map +11 -0
  80. package/dist/chunk-rmyfmrrr.js +1634 -0
  81. package/dist/chunk-rmyfmrrr.js.map +15 -0
  82. package/dist/chunk-sa874j5c.js +9 -0
  83. package/dist/chunk-sa874j5c.js.map +10 -0
  84. package/dist/chunk-sgsz2fpq.js +289 -0
  85. package/dist/chunk-sgsz2fpq.js.map +10 -0
  86. package/dist/chunk-sn51wnf1.js +3020 -0
  87. package/dist/chunk-sn51wnf1.js.map +32 -0
  88. package/dist/chunk-sqhr1a7e.js +587 -0
  89. package/dist/chunk-sqhr1a7e.js.map +11 -0
  90. package/dist/chunk-tmj5r2zv.js +2933 -0
  91. package/dist/chunk-tmj5r2zv.js.map +20 -0
  92. package/dist/chunk-vefj3880.js +56 -0
  93. package/dist/chunk-vefj3880.js.map +10 -0
  94. package/dist/chunk-vp2db42d.js +121 -0
  95. package/dist/chunk-vp2db42d.js.map +10 -0
  96. package/dist/chunk-wry79q55.js +108 -0
  97. package/dist/chunk-wry79q55.js.map +10 -0
  98. package/dist/chunk-x1arr7ze.js +63 -0
  99. package/dist/chunk-x1arr7ze.js.map +10 -0
  100. package/dist/chunk-z2xy5n2q.js +103 -0
  101. package/dist/chunk-z2xy5n2q.js.map +10 -0
  102. package/dist/hosted-agent.js +53 -0
  103. package/dist/hosted-agent.js.map +10 -0
  104. package/dist/main.js +132 -20
  105. package/dist/main.js.map +4 -4
  106. package/dist/runtime/scripts/faster-whisper-wrapper.py +87 -0
  107. package/dist/runtime/scripts/mcp-patchright-http.mjs +349 -0
  108. package/dist/runtime/src/agents/api-topic-agent-switch.ts +178 -0
  109. package/dist/runtime/src/agents/archiver.ts +396 -0
  110. package/dist/runtime/src/agents/auth-check.ts +64 -0
  111. package/dist/runtime/src/agents/claude-provider.ts +737 -0
  112. package/dist/runtime/src/agents/claude-registry.ts +115 -0
  113. package/dist/runtime/src/agents/codex-native-multi-agent.ts +177 -0
  114. package/dist/runtime/src/agents/codex-provider.ts +785 -0
  115. package/dist/runtime/src/agents/codex-registry.ts +115 -0
  116. package/dist/runtime/src/agents/codex-tree-kill.ts +365 -0
  117. package/dist/runtime/src/agents/contracts.ts +69 -0
  118. package/dist/runtime/src/agents/deep-map.ts +13 -0
  119. package/dist/runtime/src/agents/execution-host.ts +91 -0
  120. package/dist/runtime/src/agents/fixtures/claude-attachments.jsonl +2 -0
  121. package/dist/runtime/src/agents/fixtures/codex-shell.jsonl +5 -0
  122. package/dist/runtime/src/agents/fork.ts +65 -0
  123. package/dist/runtime/src/agents/hosted-agent.ts +49 -0
  124. package/dist/runtime/src/agents/idle-archiver.ts +128 -0
  125. package/dist/runtime/src/agents/index.ts +205 -0
  126. package/dist/runtime/src/agents/maestro-provider.ts +206 -0
  127. package/dist/runtime/src/agents/maestro-registry.ts +31 -0
  128. package/dist/runtime/src/agents/mcp-tools/ask-user.ts +280 -0
  129. package/dist/runtime/src/agents/mcp-tools/common.ts +21 -0
  130. package/dist/runtime/src/agents/mcp-tools/self-config.ts +220 -0
  131. package/dist/runtime/src/agents/mcp-tools/spawn-subagent.ts +525 -0
  132. package/dist/runtime/src/agents/mcp-tools/visuals.ts +88 -0
  133. package/dist/runtime/src/agents/model-catalog.ts +151 -0
  134. package/dist/runtime/src/agents/registry.ts +24 -0
  135. package/dist/runtime/src/agents/rollout/claude.ts +331 -0
  136. package/dist/runtime/src/agents/rollout/codex.ts +569 -0
  137. package/dist/runtime/src/agents/rollout/shared.ts +205 -0
  138. package/dist/runtime/src/agents/self-config-core.ts +415 -0
  139. package/dist/runtime/src/agents/task-events.ts +44 -0
  140. package/dist/runtime/src/agents/tool-format.ts +352 -0
  141. package/dist/runtime/src/agents/topic-agent-switch.ts +193 -0
  142. package/dist/runtime/src/agents/topic-cleanup.ts +231 -0
  143. package/dist/runtime/src/agents/vault-tool-policy.ts +33 -0
  144. package/dist/runtime/src/application/execute-external-user-turn.ts +23 -0
  145. package/dist/runtime/src/application/submit-user-message.ts +50 -0
  146. package/dist/runtime/src/application/switch-topic-effort.ts +48 -0
  147. package/dist/runtime/src/application/switch-topic-model.ts +79 -0
  148. package/dist/runtime/src/application/topic-service.ts +139 -0
  149. package/dist/runtime/src/application/vault-command.ts +170 -0
  150. package/dist/runtime/src/bus.ts +319 -0
  151. package/dist/runtime/src/index.ts +333 -0
  152. package/dist/runtime/src/mcp/agent-health-server.ts +309 -0
  153. package/dist/runtime/src/mcp/background-bash-server.ts +346 -0
  154. package/dist/runtime/src/mcp/canonical-bridge-config.ts +84 -0
  155. package/dist/runtime/src/mcp/canonical-proxy-server.ts +171 -0
  156. package/dist/runtime/src/mcp/dm-ipc.ts +60 -0
  157. package/dist/runtime/src/mcp/mcp-helpers.ts +40 -0
  158. package/dist/runtime/src/mcp/paddleocr-server.ts +105 -0
  159. package/dist/runtime/src/mcp/peer-bridge.ts +114 -0
  160. package/dist/runtime/src/mcp/runtime-spec.ts +137 -0
  161. package/dist/runtime/src/mcp/session-comm/bridge-ipc-config.ts +27 -0
  162. package/dist/runtime/src/mcp/session-comm/peer-forward.ts +165 -0
  163. package/dist/runtime/src/mcp/session-comm/runtime.ts +115 -0
  164. package/dist/runtime/src/mcp/session-comm/server.ts +649 -0
  165. package/dist/runtime/src/mcp/session-comm/topic-config.ts +131 -0
  166. package/dist/runtime/src/mcp/session-comm/topics.ts +229 -0
  167. package/dist/runtime/src/mcp/stdio-protect.ts +84 -0
  168. package/dist/runtime/src/mcp/system-health-server.ts +130 -0
  169. package/dist/runtime/src/mcp/task-server.ts +173 -0
  170. package/dist/runtime/src/mcp/token-stats-server.ts +98 -0
  171. package/dist/runtime/src/mcp/vault-http.ts +219 -0
  172. package/dist/runtime/src/mcp/vault-run.ts +153 -0
  173. package/dist/runtime/src/mcp/vault-server.ts +104 -0
  174. package/dist/runtime/src/mcp/wiki-server.ts +755 -0
  175. package/dist/runtime/src/media/file-events.ts +33 -0
  176. package/dist/runtime/src/media/text-extractor.ts +312 -0
  177. package/dist/runtime/src/media/video.ts +92 -0
  178. package/dist/runtime/src/outbox/file-ops.ts +210 -0
  179. package/dist/runtime/src/outbox/index.ts +8 -0
  180. package/dist/runtime/src/outbox/utils.ts +51 -0
  181. package/dist/runtime/src/platform/background-bash/manager.ts +279 -0
  182. package/dist/runtime/src/platform/config.ts +371 -0
  183. package/dist/runtime/src/platform/constants.ts +18 -0
  184. package/dist/runtime/src/platform/debug-users.ts +14 -0
  185. package/dist/runtime/src/platform/delay.ts +1 -0
  186. package/dist/runtime/src/platform/error.ts +9 -0
  187. package/dist/runtime/src/platform/file-utils.ts +16 -0
  188. package/dist/runtime/src/platform/jsonl.ts +202 -0
  189. package/dist/runtime/src/platform/lifecycle.ts +174 -0
  190. package/dist/runtime/src/platform/logger.ts +31 -0
  191. package/dist/runtime/src/platform/maestro-bootstrap-env.ts +3 -0
  192. package/dist/runtime/src/platform/mcp-config.ts +878 -0
  193. package/dist/runtime/src/platform/modules.ts +145 -0
  194. package/dist/runtime/src/platform/node-plugins.ts +46 -0
  195. package/dist/runtime/src/platform/playwright/manager.ts +1233 -0
  196. package/dist/runtime/src/prompts/agents/wiki-archiver.md +261 -0
  197. package/dist/runtime/src/prompts/builders.ts +368 -0
  198. package/dist/runtime/src/prompts/sessions/channel-system.md +48 -0
  199. package/dist/runtime/src/prompts/sessions/manager-system.md +37 -0
  200. package/dist/runtime/src/prompts/sessions/topic-system.md +47 -0
  201. package/dist/runtime/src/prompts/sessions/visual-design.md +86 -0
  202. package/dist/runtime/src/query/active-rooms.ts +534 -0
  203. package/dist/runtime/src/query/control.ts +313 -0
  204. package/dist/runtime/src/query/index.ts +24 -0
  205. package/dist/runtime/src/query/session-inbox-cleanup.ts +44 -0
  206. package/dist/runtime/src/query/session-inbox-path.ts +42 -0
  207. package/dist/runtime/src/query/state.ts +94 -0
  208. package/dist/runtime/src/query/types.ts +83 -0
  209. package/dist/runtime/src/runtime/ask-callbacks.ts +153 -0
  210. package/dist/runtime/src/runtime/attachments.ts +151 -0
  211. package/dist/runtime/src/runtime/background-sessions.ts +83 -0
  212. package/dist/runtime/src/runtime/channel-context.ts +107 -0
  213. package/dist/runtime/src/runtime/errors.ts +69 -0
  214. package/dist/runtime/src/runtime/event-heartbeat.ts +77 -0
  215. package/dist/runtime/src/runtime/file-hooks.ts +81 -0
  216. package/dist/runtime/src/runtime/footer.ts +23 -0
  217. package/dist/runtime/src/runtime/inbox.ts +946 -0
  218. package/dist/runtime/src/runtime/tasks.ts +56 -0
  219. package/dist/runtime/src/runtime/topic-config.ts +29 -0
  220. package/dist/runtime/src/runtime/turn-runner.ts +2298 -0
  221. package/dist/runtime/src/runtime/usage-alert.ts +84 -0
  222. package/dist/runtime/src/runtime/visual-html.ts +109 -0
  223. package/dist/runtime/src/runtime/visual-store.ts +430 -0
  224. package/dist/runtime/src/runtime/visuals.ts +171 -0
  225. package/dist/runtime/src/security/sanitize.ts +27 -0
  226. package/dist/runtime/src/security/sensitive-path.ts +34 -0
  227. package/dist/runtime/src/storage/activity-log.ts +152 -0
  228. package/dist/runtime/src/storage/api-messages.ts +617 -0
  229. package/dist/runtime/src/storage/api-topic-brief.ts +127 -0
  230. package/dist/runtime/src/storage/api-topic-config.ts +192 -0
  231. package/dist/runtime/src/storage/api-topics.ts +844 -0
  232. package/dist/runtime/src/storage/app-settings.ts +44 -0
  233. package/dist/runtime/src/storage/browser-profiles.ts +133 -0
  234. package/dist/runtime/src/storage/conversations.ts +268 -0
  235. package/dist/runtime/src/storage/forum/agent-settings.ts +20 -0
  236. package/dist/runtime/src/storage/forum/index.ts +4 -0
  237. package/dist/runtime/src/storage/forum/repository.ts +283 -0
  238. package/dist/runtime/src/storage/forum/schema.ts +300 -0
  239. package/dist/runtime/src/storage/forum-db.ts +23 -0
  240. package/dist/runtime/src/storage/runtime-events.ts +130 -0
  241. package/dist/runtime/src/storage/runtime-leases.ts +189 -0
  242. package/dist/runtime/src/storage/runtime-process-leases.ts +216 -0
  243. package/dist/runtime/src/storage/runtime-topic-state.ts +148 -0
  244. package/dist/runtime/src/storage/runtime-turn-requests.ts +301 -0
  245. package/dist/runtime/src/storage/self-schedules.ts +298 -0
  246. package/dist/runtime/src/storage/session-asks.ts +347 -0
  247. package/dist/runtime/src/storage/sqlite.ts +129 -0
  248. package/dist/runtime/src/storage/tasks.ts +179 -0
  249. package/dist/runtime/src/storage/token-stats.ts +144 -0
  250. package/dist/runtime/src/storage/topic-archive-state.ts +59 -0
  251. package/dist/runtime/src/storage/topic-archive.ts +74 -0
  252. package/dist/runtime/src/storage/topic-settings.ts +67 -0
  253. package/dist/runtime/src/storage/topic-transcript.ts +98 -0
  254. package/dist/runtime/src/storage/vault-crypto.ts +65 -0
  255. package/dist/runtime/src/storage/vault.ts +225 -0
  256. package/dist/runtime/src/storage/wiki-summary-names.ts +33 -0
  257. package/dist/runtime/src/storage/wiki.ts +85 -0
  258. package/dist/runtime/src/topics/create.ts +123 -0
  259. package/dist/runtime/src/topics/derive.ts +398 -0
  260. package/dist/runtime/src/topics/lifecycle.ts +311 -0
  261. package/dist/runtime/src/topics/links.ts +7 -0
  262. package/dist/runtime/src/topics/personal-general.ts +65 -0
  263. package/dist/runtime/src/topics/session.ts +381 -0
  264. package/dist/runtime/src/types/api.ts +497 -0
  265. package/dist/runtime/src/types.ts +257 -0
  266. package/dist/runtime/src/version.ts +1 -0
  267. package/dist/runtime/tsconfig.json +13 -0
  268. package/package.json +28 -3
  269. package/dist/main.d.ts +0 -3
  270. package/dist/main.d.ts.map +0 -1
@@ -0,0 +1,313 @@
1
+ import { getRegistry } from "#agents/registry";
2
+ import { SESSION_WORKSPACE_DIR } from "#platform/config";
3
+ import { delay } from "#platform/delay";
4
+ import { logger } from "#platform/logger";
5
+ import { readConversation } from "#storage/conversations";
6
+ import { getTopicAgent } from "#storage/forum/agent-settings";
7
+ import { clearDmSessionId, clearSessionForTopic } from "#storage/forum/index";
8
+ import { type AskReplySource, markPendingAskSources } from "#storage/session-asks";
9
+ import { AbortReason, type HandleAgentQueryParams } from "./types";
10
+
11
+ export function topicQueryKey(userId: number, topicName: string): string {
12
+ return `${userId}:${topicName}`;
13
+ }
14
+
15
+ // --- Active queries (userId:topicName → abort control) ---
16
+
17
+ export interface ActiveQueryControl {
18
+ abortReason: AbortReason;
19
+ userId: number;
20
+ abortController: AbortController;
21
+ params?: HandleAgentQueryParams;
22
+ // Playwright instance this query is using. Set inside handleAgentQuery
23
+ // right after `ensurePlaywright` resolves so the playwright manager's
24
+ // crash callback can find which queries are tool-blocked on a dead MCP
25
+ // and abort them — without this the SDK waits indefinitely for a tool
26
+ // result the MCP will never deliver (observed 22-min stalls).
27
+ instanceKey?: string;
28
+ }
29
+
30
+ export const activeQueries = new Map<string, ActiveQueryControl>();
31
+
32
+ // --- Inter-session queue: defers inject requests while a query is running ---
33
+
34
+ class SessionInjectQueue {
35
+ private queue = new Map<string, HandleAgentQueryParams[]>();
36
+
37
+ /** Enqueue params if requestId is set and not a duplicate. Returns true if enqueued. */
38
+ enqueue(queryKey: string, params: HandleAgentQueryParams, logReason: string): boolean {
39
+ if (!params.requestId) {
40
+ // Missing requestId means the caller forgot to thread it through — this
41
+ // is a code bug, not a runtime condition. The inject will be silently
42
+ // dropped, so escalate to `error` so it surfaces in monitoring instead
43
+ // of hiding in warn-level noise.
44
+ logger.error(
45
+ { queryKey, from: params.from, logReason },
46
+ "BUG: session inject reached enqueue without requestId — DROPPING inject",
47
+ );
48
+ return false;
49
+ }
50
+ const q = this.queue.get(queryKey) ?? [];
51
+ if (q.some((p) => p.requestId === params.requestId)) {
52
+ // Intentional dedup (e.g. retry-after-restart, replayed inbox entry).
53
+ // Surface it so operators can distinguish a legit dedup from a buggy
54
+ // collision when investigating a missing reply.
55
+ logger.info(
56
+ { queryKey, requestId: params.requestId, from: params.from },
57
+ "session inject deduped (existing entry with same requestId)",
58
+ );
59
+ return false;
60
+ }
61
+ q.push(params);
62
+ this.queue.set(queryKey, q);
63
+ markPendingAskSources({
64
+ userId: params.userId,
65
+ callerTopic: params.topicName,
66
+ sources: params.askReplySources,
67
+ state: "queued_for_caller",
68
+ });
69
+ logger.info({ queryKey, from: params.from, requestId: params.requestId }, logReason);
70
+ return true;
71
+ }
72
+
73
+ /**
74
+ * True when an entry with this requestId is still queued under the key.
75
+ * Used by `handleAgentQuery`'s finally to detect the abort-requeue case:
76
+ * the dying invocation's params (and its forkHandle) were re-enqueued for a
77
+ * later dequeue, so resources tied to them must not be cleaned up yet.
78
+ */
79
+ hasRequest(queryKey: string, requestId: string): boolean {
80
+ return this.queue.get(queryKey)?.some((p) => p.requestId === requestId) ?? false;
81
+ }
82
+
83
+ /** Remove and return the next queued params for this key, or undefined if empty. */
84
+ dequeueNext(queryKey: string): HandleAgentQueryParams | undefined {
85
+ const q = this.queue.get(queryKey);
86
+ if (!q?.length) return undefined;
87
+ const next = q.shift()!;
88
+ if (q.length === 0) this.queue.delete(queryKey);
89
+ return next;
90
+ }
91
+
92
+ /**
93
+ * Remove and return the longest mergeable PREFIX of the queue as a single
94
+ * entry, or undefined if empty. Multiple prompts are joined so they land in
95
+ * one turn instead of chaining N turns for N queued injects.
96
+ *
97
+ * Caller-bound ask_session replies (`askReplySources`) are safe to merge
98
+ * even when they came from different target topics: the merged turn is just
99
+ * more context for the same caller topic. Other inter-session work remains
100
+ * conservative: same `from`, same `silent` flag, same `sessionId`.
101
+ */
102
+ dequeueAll(queryKey: string): HandleAgentQueryParams | undefined {
103
+ const q = this.queue.get(queryKey);
104
+ if (!q?.length) return undefined;
105
+ const base = q[0];
106
+ const isAskReplyInject = (p: HandleAgentQueryParams) =>
107
+ !p.silent && Boolean(p.askReplySources?.length);
108
+ const baseIsAskReplyInject = isAskReplyInject(base);
109
+ const mergeable = (p: HandleAgentQueryParams) =>
110
+ baseIsAskReplyInject
111
+ ? isAskReplyInject(p)
112
+ : !isAskReplyInject(p) &&
113
+ p.from === base.from &&
114
+ (p.silent ?? false) === (base.silent ?? false) &&
115
+ p.sessionId === base.sessionId;
116
+ let take = 1;
117
+ while (take < q.length && mergeable(q[take])) take++;
118
+ const batch = q.splice(0, take);
119
+ if (q.length === 0) this.queue.delete(queryKey);
120
+ if (batch.length === 1) return batch[0];
121
+ const sources = batch.flatMap((p): AskReplySource[] => {
122
+ if (p.askReplySources?.length) return p.askReplySources;
123
+ if (!p.requestId || !p.from) return [];
124
+ return [{ from: p.from, requestId: p.requestId, contextId: p.contextId }];
125
+ });
126
+ const depths = batch
127
+ .map((p) => p.depth)
128
+ .filter((depth): depth is number => typeof depth === "number");
129
+ return {
130
+ ...base,
131
+ prompt: batch.map((p) => p.prompt).join("\n\n"),
132
+ from: baseIsAskReplyInject
133
+ ? [...new Set(batch.map((p) => p.from).filter(Boolean))].join(", ")
134
+ : base.from,
135
+ depth: depths.length ? Math.min(...depths) : base.depth,
136
+ requestId: `merged-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
137
+ // Same-caller batches share one context; only drop it when the batch
138
+ // disagrees (a merged turn must not append to the wrong context).
139
+ contextId: batch.every((p) => p.contextId === base.contextId) ? base.contextId : undefined,
140
+ askReplySources: sources,
141
+ };
142
+ }
143
+
144
+ /**
145
+ * All non-empty queue keys belonging to a user (queryKey starts with
146
+ * `${userId}:`). Used by `handleAgentQuery`'s finally to drain other
147
+ * topics' queued inter-session injects when the per-user concurrency cap
148
+ * frees up.
149
+ */
150
+ keysForUser(userId: number): string[] {
151
+ const prefix = `${userId}:`;
152
+ const result: string[] = [];
153
+ for (const key of this.queue.keys()) {
154
+ if (key.startsWith(prefix)) result.push(key);
155
+ }
156
+ return result;
157
+ }
158
+ }
159
+
160
+ export const interSessionQueue = new SessionInjectQueue();
161
+
162
+ /**
163
+ * Abort the in-flight query (if any) for `userId:topicName` and wait for
164
+ * `handleAgentQuery`'s finally block to clean up before returning.
165
+ *
166
+ * Used by topic lifecycle (delete/reset) so the dying query's tail writes
167
+ * (`setSessionForTopic`, `appendConversationEvent`, `recordUsage`) don't race
168
+ * against row removal — without this wait, those writes silently target a
169
+ * row that no longer exists, leaving orphan log files and zero-row UPDATEs.
170
+ *
171
+ * Returns true when the query exited cleanly (or none was running). Returns
172
+ * false on timeout — caller may still proceed but should treat the topic as
173
+ * potentially having stragglers.
174
+ */
175
+ export async function abortAndWaitForQuery(
176
+ userId: number,
177
+ topicName: string,
178
+ reason: AbortReason = AbortReason.External,
179
+ timeoutMs = 5000,
180
+ ): Promise<boolean> {
181
+ const queryKey = topicQueryKey(userId, topicName);
182
+ const running = activeQueries.get(queryKey);
183
+ if (!running) return true;
184
+ running.abortReason = reason;
185
+ running.abortController.abort();
186
+ const start = Date.now();
187
+ while (activeQueries.get(queryKey) === running) {
188
+ if (Date.now() - start > timeoutMs) {
189
+ logger.warn(
190
+ { userId, topicName, timeoutMs },
191
+ "abortAndWaitForQuery: timeout — proceeding without confirmation",
192
+ );
193
+ return false;
194
+ }
195
+ await delay(50);
196
+ }
197
+ return true;
198
+ }
199
+
200
+ // --- Initializing topics: blocks message handling during /new reset ---
201
+
202
+ export const initializingTopics = new Set<string>();
203
+
204
+ // --- DM session retry guard: prevents duplicate retries on concurrent expiry ---
205
+
206
+ const dmRetryingUsers = new Set<number>();
207
+ export const deleteDmRetrying = (userId: number) => dmRetryingUsers.delete(userId);
208
+
209
+ // SDK does not expose a typed session-expiry error; match by message
210
+ const SESSION_EXPIRED_MSG = "No conversation found with session ID";
211
+
212
+ /**
213
+ * Result of detecting (and acting on) a session-expiry error.
214
+ *
215
+ * - `{ type: "dm-retry" }`: DM expiry — caller retries with `sessionId: null`.
216
+ * - `{ type: "forum-retry", sessionId: null }`: forum expiry where reconstruct
217
+ * was not attempted or failed — caller retries with a FRESH session and the
218
+ * DB session_id has been cleared. Context is lost.
219
+ * - `{ type: "forum-retry", sessionId: "<id>" }`: forum expiry where the
220
+ * native SDK rollout has been REBUILT from the unified conversation log at
221
+ * the SAME path the SDK expects. Caller retries with the preserved
222
+ * sessionId. Full context (1500+ entries) is restored. DB session_id is
223
+ * left untouched.
224
+ */
225
+ export type SessionExpiryResult =
226
+ | { type: "dm-retry" }
227
+ | { type: "forum-retry"; sessionId: string | null };
228
+
229
+ /**
230
+ * Try to rebuild the native SDK rollout file for the topic from the unified
231
+ * conversation log. Returns true on success — the rollout is written at the
232
+ * exact path the SDK looks for and the DB sessionId remains valid.
233
+ *
234
+ * Falls through to `false` silently when there are no entries to encode or
235
+ * the registry's `writeRollout` throws (malformed entries, disk error, etc.)
236
+ * so the caller can fall back to a fresh-session retry.
237
+ */
238
+ function tryReconstructForumRollout(
239
+ params: HandleAgentQueryParams,
240
+ userId: number,
241
+ topicName: string,
242
+ ): boolean {
243
+ const sessionId = params.sessionId;
244
+ if (!sessionId) return false;
245
+ try {
246
+ const entries = readConversation(userId, topicName);
247
+ if (entries.length === 0) return false;
248
+ const cwd = SESSION_WORKSPACE_DIR;
249
+ const agent = getTopicAgent(userId, topicName);
250
+ const result = getRegistry(agent).writeRollout({
251
+ cwd,
252
+ entries,
253
+ reuseSessionId: sessionId,
254
+ ...(params.model ? { model: params.model } : {}),
255
+ ...(params.effort ? { effort: params.effort } : {}),
256
+ });
257
+ logger.info(
258
+ {
259
+ userId,
260
+ topicName,
261
+ agent,
262
+ sessionId: result.sessionId,
263
+ rolloutPath: result.rolloutPath,
264
+ entries: entries.length,
265
+ },
266
+ "Forum session expired — rollout reconstructed from unified log, sessionId preserved",
267
+ );
268
+ return true;
269
+ } catch (err) {
270
+ logger.warn(
271
+ { err, userId, topicName, sessionId },
272
+ "Forum session reconstruct failed — falling back to fresh session",
273
+ );
274
+ return false;
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Detects a session-expiry error and either rebuilds the rollout (preferred,
280
+ * keeps context) or clears the stale session (fallback, loses context).
281
+ * Returns the retry result, or null if not an expiry (or already retried once).
282
+ */
283
+ export function detectSessionExpiry(
284
+ params: HandleAgentQueryParams,
285
+ errMsg: string,
286
+ userId: number,
287
+ topicName: string,
288
+ ): SessionExpiryResult | null {
289
+ if (params._sessionRetried || !errMsg.includes(SESSION_EXPIRED_MSG)) return null;
290
+
291
+ if (params.sessionType === "dm") {
292
+ if (!dmRetryingUsers.has(userId)) {
293
+ clearDmSessionId(userId);
294
+ dmRetryingUsers.add(userId);
295
+ logger.info({ userId }, "DM session expired, cleared and will retry");
296
+ return { type: "dm-retry" };
297
+ }
298
+ return null;
299
+ }
300
+
301
+ // Forum path: try to rebuild the rollout from the unified log so the SDK
302
+ // can resume on the SAME sessionId with full context. Only clear when that
303
+ // path is unavailable (no entries, encoder error, missing sessionId).
304
+ if (tryReconstructForumRollout(params, userId, topicName)) {
305
+ return { type: "forum-retry", sessionId: params.sessionId ?? null };
306
+ }
307
+ clearSessionForTopic(userId, topicName);
308
+ logger.info(
309
+ { userId, topicName },
310
+ "Forum session expired, cleared and will retry with fresh session",
311
+ );
312
+ return { type: "forum-retry", sessionId: null };
313
+ }
@@ -0,0 +1,24 @@
1
+ // Live query-control exports.
2
+
3
+ export type {
4
+ DeferredInject,
5
+ NewQueryDecision,
6
+ RoomQueryControl,
7
+ } from "./active-rooms";
8
+ export {
9
+ abortAllRooms,
10
+ abortRoom,
11
+ cancelDeferredInject,
12
+ clearRoomQuery,
13
+ decideNewQuery,
14
+ deferInject,
15
+ getRoomQuery,
16
+ InterSessionQueue,
17
+ interSessionQueue,
18
+ isUserOrigin,
19
+ setRoomQuery,
20
+ takeDeferredInject,
21
+ wsAbortReason,
22
+ } from "./active-rooms";
23
+ export type { HandleAgentQueryOutcome, HandleAgentQueryParams } from "./types";
24
+ export { AbortReason } from "./types";
@@ -0,0 +1,44 @@
1
+ import { unlinkSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ import { SESSION_INBOX_DIR } from "#platform/config";
4
+ import { logger } from "#platform/logger";
5
+ import { scheduledSessionInboxPath, sessionInboxPath } from "#query/session-inbox-path";
6
+
7
+ /**
8
+ * Delete pending, scheduled, and crash-claimed inbox files for a topic.
9
+ * Canonical files are id-keyed; title-keyed candidates cover rolling upgrades.
10
+ */
11
+ export function cleanupSessionInboxFiles(
12
+ userId: string,
13
+ topicId: string,
14
+ legacyTopicTitle?: string,
15
+ ): number {
16
+ const live = sessionInboxPath(userId, topicId);
17
+ const scheduled = scheduledSessionInboxPath(userId, topicId);
18
+ const candidates = new Set([live, `${live}.processing`, scheduled, `${scheduled}.processing`]);
19
+
20
+ if (
21
+ legacyTopicTitle &&
22
+ legacyTopicTitle !== "." &&
23
+ legacyTopicTitle !== ".." &&
24
+ basename(legacyTopicTitle) === legacyTopicTitle
25
+ ) {
26
+ const legacyBase = join(SESSION_INBOX_DIR, userId, legacyTopicTitle);
27
+ for (const suffix of [".jsonl", ".jsonl.processing", ".schedule", ".schedule.processing"]) {
28
+ candidates.add(`${legacyBase}${suffix}`);
29
+ }
30
+ }
31
+
32
+ let deleted = 0;
33
+ for (const path of candidates) {
34
+ try {
35
+ unlinkSync(path);
36
+ deleted++;
37
+ } catch (err) {
38
+ if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") {
39
+ logger.warn({ err, path, topicId, userId }, "topic cleanup: inbox unlink failed");
40
+ }
41
+ }
42
+ }
43
+ return deleted;
44
+ }
@@ -0,0 +1,42 @@
1
+ import { join } from "node:path";
2
+ import { SESSION_INBOX_DIR } from "#platform/config";
3
+
4
+ const TOPIC_ID_FILE_PREFIX = "topic-id-";
5
+ const JSONL_SUFFIX = ".jsonl";
6
+ const SCHEDULE_SUFFIX = ".schedule";
7
+
8
+ /** Build a flat inbox path from the canonical topic id, never its title. */
9
+ export function sessionInboxPath(userId: string, topicId: string): string {
10
+ const key = Buffer.from(topicId, "utf8").toString("base64url");
11
+ return join(SESSION_INBOX_DIR, userId, `${TOPIC_ID_FILE_PREFIX}${key}${JSONL_SUFFIX}`);
12
+ }
13
+
14
+ /** Durable sidecar consumed by the session-inbox worker once deliverAt passes. */
15
+ export function scheduledSessionInboxPath(userId: string, topicId: string): string {
16
+ const key = Buffer.from(topicId, "utf8").toString("base64url");
17
+ return join(SESSION_INBOX_DIR, userId, `${TOPIC_ID_FILE_PREFIX}${key}${SCHEDULE_SUFFIX}`);
18
+ }
19
+
20
+ function decodeTopicIdFileName(fileName: string, suffix: string): string | null {
21
+ if (!fileName.startsWith(TOPIC_ID_FILE_PREFIX) || !fileName.endsWith(suffix)) return null;
22
+ const encoded = fileName.slice(TOPIC_ID_FILE_PREFIX.length, -suffix.length);
23
+ if (!encoded || !/^[A-Za-z0-9_-]+$/.test(encoded)) return null;
24
+ try {
25
+ return Buffer.from(encoded, "base64url").toString("utf8") || null;
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ /** Null denotes a legacy `{topicTitle}.jsonl` filename from an older runtime. */
32
+ export function topicIdFromSessionInboxFileName(fileName: string): string | null {
33
+ return decodeTopicIdFileName(fileName, JSONL_SUFFIX);
34
+ }
35
+
36
+ /** Accept both a live .schedule file and a crash-residue .schedule.processing claim. */
37
+ export function topicIdFromScheduledSessionInboxFileName(fileName: string): string | null {
38
+ const base = fileName.endsWith(".processing")
39
+ ? fileName.slice(0, -".processing".length)
40
+ : fileName;
41
+ return decodeTopicIdFileName(base, SCHEDULE_SUFFIX);
42
+ }
@@ -0,0 +1,94 @@
1
+ import { mkdirSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { ACTIVE_QUERY_STALE_MS, USERS_LOG_DIR } from "#platform/config";
4
+ import { readJsonFile } from "#platform/jsonl";
5
+ import { logger } from "#platform/logger";
6
+ import type { QueryState } from "#types";
7
+
8
+ type QueryStateUserId = number | string;
9
+
10
+ function queryStateDirPath(userId: QueryStateUserId): string {
11
+ return join(USERS_LOG_DIR, String(userId), "active-queries");
12
+ }
13
+
14
+ function queryStateFile(userId: QueryStateUserId, topicName: string): string {
15
+ const name = `${topicName}.json`;
16
+ return join(queryStateDirPath(userId), name);
17
+ }
18
+
19
+ export function writeQueryState(userId: QueryStateUserId, topicName: string, task?: string) {
20
+ const dir = queryStateDirPath(userId);
21
+ mkdirSync(dir, { recursive: true });
22
+ const state: QueryState = { since: new Date().toISOString() };
23
+ if (task) state.task = [...task.replace(/\n+/g, " ").trim()].slice(0, 100).join("");
24
+ const target = queryStateFile(userId, topicName);
25
+ const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
26
+ writeFileSync(tmp, JSON.stringify(state));
27
+ renameSync(tmp, target);
28
+ }
29
+
30
+ export function clearQueryState(userId: QueryStateUserId, topicName: string) {
31
+ try {
32
+ unlinkSync(queryStateFile(userId, topicName));
33
+ } catch (e) {
34
+ if ((e as NodeJS.ErrnoException)?.code !== "ENOENT") {
35
+ logger.warn({ err: e, userId, topicName }, "Failed to clear query state file");
36
+ }
37
+ }
38
+ }
39
+
40
+ export function cleanStaleQueryStates(allowedUserIds: Set<number>) {
41
+ const now = Date.now();
42
+ try {
43
+ const userDirs = readdirSync(USERS_LOG_DIR, { withFileTypes: true });
44
+ for (const dir of userDirs) {
45
+ if (!dir.isDirectory()) continue;
46
+ const userId = Number(dir.name);
47
+ if (!Number.isFinite(userId) || !allowedUserIds.has(userId)) continue;
48
+ const stateDir = join(USERS_LOG_DIR, dir.name, "active-queries");
49
+ let files: string[];
50
+ try {
51
+ files = readdirSync(stateDir);
52
+ } catch (e) {
53
+ logger.warn({ err: e, stateDir }, "cleanStaleQueryStates: failed to read state dir");
54
+ continue;
55
+ }
56
+ for (const file of files) {
57
+ const filePath = join(stateDir, file);
58
+ try {
59
+ if (file.endsWith(".lock") || file.endsWith(".tmp")) {
60
+ try {
61
+ unlinkSync(filePath);
62
+ } catch (e) {
63
+ logger.warn(
64
+ { err: e, filePath },
65
+ "cleanStaleQueryStates: failed to remove legacy file",
66
+ );
67
+ }
68
+ logger.info({ filePath }, "Removed legacy lock/tmp file");
69
+ continue;
70
+ }
71
+ if (file.endsWith(".json")) {
72
+ const state = readJsonFile<QueryState>(filePath);
73
+ const stale = !state || now - new Date(state.since).getTime() > ACTIVE_QUERY_STALE_MS;
74
+ if (stale) {
75
+ try {
76
+ unlinkSync(filePath);
77
+ } catch (e) {
78
+ logger.warn(
79
+ { err: e, filePath },
80
+ "cleanStaleQueryStates: failed to remove stale state file",
81
+ );
82
+ }
83
+ logger.info({ filePath }, "Removed stale query state file");
84
+ }
85
+ }
86
+ } catch {
87
+ /* ignore individual file errors */
88
+ }
89
+ }
90
+ }
91
+ } catch (e) {
92
+ logger.warn({ err: e }, "cleanStaleQueryStates: failed");
93
+ }
94
+ }
@@ -0,0 +1,83 @@
1
+ import type { ForkHandle } from "#agents/fork";
2
+ import type { AskReplySource } from "#storage/session-asks";
3
+ import type { AgentKind, EffortLevel } from "#types";
4
+
5
+ /**
6
+ * What `handleAgentQuery` did with the request. Callers that own resources
7
+ * tied to the params (e.g. an ask-fork rollout) use this to decide cleanup:
8
+ * "ran"/"queued" transfer ownership to the query layer, "dropped" returns it.
9
+ */
10
+ export type HandleAgentQueryOutcome = "ran" | "queued" | "dropped";
11
+
12
+ // --- Parameter interfaces ---
13
+
14
+ interface BaseQueryParams {
15
+ chatId: number;
16
+ userId: number;
17
+ topicName: string;
18
+ sessionId: string | null;
19
+ prompt: string;
20
+ }
21
+
22
+ interface OutputParams {
23
+ messageThreadId?: number;
24
+ systemPrompt: string;
25
+ cwd?: string;
26
+ sessionType?: "dm" | "forum" | "ephemeral" | "manager" | "cron";
27
+ model?: string;
28
+ silent?: boolean;
29
+ effort?: EffortLevel;
30
+ /** Override the topic-resolved agent backend. Used by callers like the
31
+ * archiver that run on ephemeral topics and need an explicit provider. */
32
+ agent?: AgentKind;
33
+ }
34
+
35
+ interface InterSessionParams {
36
+ from?: string; // "user" or sender session name
37
+ depth?: number; // 0 = from user, 1+ = via tell_session
38
+ // For silent ask-reply forks: caller's depth at ask time. When the fork's
39
+ // reply is injected back to the caller, the caller resumes at this depth
40
+ // so tell chain caps remain accurate across ask hops.
41
+ fromDepth?: number;
42
+ requestId?: string; // unique ID for dedup in interSessionQueue
43
+ contextId?: string; // context ID for multi-turn inter-session conversations
44
+ /**
45
+ * Ask-fork rollout backing this inject's `sessionId`. Ownership travels
46
+ * WITH the params: while the inject sits in `interSessionQueue` the rollout
47
+ * must stay on disk (deleting it breaks the dequeued query's resume), and
48
+ * `handleAgentQuery`'s finally removes it once the consuming query actually
49
+ * finished (and no retry/requeue still references it).
50
+ */
51
+ forkHandle?: ForkHandle;
52
+ /** Original ask_session replies represented by this inject, preserved across dequeueAll merges. */
53
+ askReplySources?: AskReplySource[];
54
+ agents?: Record<
55
+ string,
56
+ {
57
+ description: string;
58
+ prompt: string;
59
+ model?: string;
60
+ tools?: string[];
61
+ maxTurns?: number;
62
+ effort?: EffortLevel | number;
63
+ }
64
+ >;
65
+ }
66
+
67
+ interface RetryGuardParams {
68
+ _sessionRetried?: boolean; // guard against infinite retry on session expiry
69
+ }
70
+
71
+ export interface HandleAgentQueryParams
72
+ extends BaseQueryParams,
73
+ OutputParams,
74
+ InterSessionParams,
75
+ RetryGuardParams {}
76
+
77
+ // --- Abort reason ---
78
+
79
+ export enum AbortReason {
80
+ None = "none",
81
+ Internal = "internal", // replaced by a newer query on the same topic
82
+ External = "external", // /abort command or abort_session via session-inbox
83
+ }