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,532 @@
1
+ /**
2
+ * Room-keyed in-flight query registry + abort-on-new-message priority +
3
+ * inter-session inject queue (requestId dedup + mergeable-prefix merging).
4
+ *
5
+ * ActiveQueries + SessionInjectQueue semantics, adapted to Otium's REST/WS
6
+ * model where the "room" is a `topicId` and the live runner is
7
+ * `api/routes/ai.ts`.
8
+ *
9
+ * Invariant: AT MOST ONE in-flight AI turn per room (topicId). A new turn
10
+ * arriving on a busy room is resolved by origin priority:
11
+ * - incoming user, running user → abort running (superseded), replace
12
+ * - incoming user, running inject → re-queue running inject, abort, replace
13
+ * - incoming inject (any running) → defer incoming (the user keeps priority)
14
+ */
15
+
16
+ import type { ForkHandle } from "#agents/fork";
17
+ import { logger } from "#platform/logger";
18
+ import {
19
+ claimRuntimeTurnLease,
20
+ getRuntimeTurnLease,
21
+ heartbeatRuntimeTurnLease,
22
+ listRuntimeTurnLeases,
23
+ RUNTIME_INSTANCE_ID,
24
+ type RuntimeTurnLease,
25
+ releaseRuntimeTurnLease,
26
+ requestRuntimeTurnAbort,
27
+ } from "#storage/runtime-leases";
28
+ import type { AskReplySource } from "#storage/session-asks";
29
+ import type { AgentKind, PeerRuntimeBridgeContext } from "#types";
30
+ import { AbortReason } from "./types";
31
+
32
+ /** Lazily create an isolated provider session immediately before execution. */
33
+ export type PrepareInjectSession = () => Promise<ForkHandle>;
34
+
35
+ /** A turn started by a session-inject, replayed when its room frees up. */
36
+ export interface DeferredInject {
37
+ topicId: string;
38
+ /** Topic execution epoch captured when this work was accepted. */
39
+ runtimeEpoch?: number;
40
+ userId: string;
41
+ prompt: string;
42
+ /** Inject source — the topic name/id this inject came from (never "user"). */
43
+ origin: string;
44
+ /** Inter-session request id for dedup. */
45
+ requestId?: string;
46
+ /** Nesting depth (starts at 1 for the first cross-topic hop). */
47
+ depth?: number;
48
+ /** Whether this inject is silent (injected but hidden from the user). */
49
+ silent?: boolean;
50
+ /** Context chain id for continuing an inter-session conversation. */
51
+ contextId?: string;
52
+ /** Agent override for injected turns that must resume a provider-specific session. */
53
+ agentOverride?: AgentKind;
54
+ /** Model override for specialized internal turns such as cron. */
55
+ modelOverride?: string;
56
+ /** Effort override for specialized internal turns such as cron. */
57
+ effortOverride?: import("#types").EffortLevel;
58
+ /** SDK-native session/thread id to resume for a forked injected turn. */
59
+ sessionId?: string | null;
60
+ /** Explicit provider-session ownership boundary. */
61
+ sessionScope?: "topic" | "isolated";
62
+ /** Rollout file backing a synthetic/native fork; cleaned when the turn finishes. */
63
+ forkHandle?: ForkHandle;
64
+ /** Build a fresh isolated session at dispatch time (used by ask_session). */
65
+ prepareSession?: PrepareInjectSession;
66
+ /** Working directory override for provider-native resumed sessions. */
67
+ cwd?: string;
68
+ /** Conversation/session namespace override (cron jobs keep isolated rollouts). */
69
+ sessionName?: string;
70
+ /** MCP/tool scope override for a specialized internal turn. */
71
+ sessionType?: "dm" | "forum" | "ephemeral" | "manager" | "cron";
72
+ /** Optional session owner; when present the topic's main session is not replaced. */
73
+ onSessionId?: (sessionId: string) => void;
74
+ /** Clear an externally-owned session after an unrecoverable expiry. */
75
+ onSessionReset?: () => void;
76
+ /** Rebuild a provider-native rollout from the shared conversation log when needed. */
77
+ bridgeSessionFromHistory?: boolean;
78
+ /** Final result hook for optional modules that own an internal turn. */
79
+ onSettled?: (result: {
80
+ queryId: string;
81
+ kind: "completed" | "aborted" | "error";
82
+ abortReason?: AbortReason;
83
+ error?: string;
84
+ }) => void;
85
+ /** Canonical hub identity for a placed-room runtime turn. */
86
+ peerBridge?: PeerRuntimeBridgeContext;
87
+ /** True after a session-expired recovery retry has already been attempted. */
88
+ _sessionRetried?: boolean;
89
+ /** Original ask_session replies represented by this inject, preserved across dequeueAll merges. */
90
+ askReplySources?: AskReplySource[];
91
+ /** Source marker forwarded to startAiTurn (e.g. FROM_AUTO_CONTINUE). */
92
+ from?: string;
93
+ /** Fired with the AI turn's queryId once the deferred turn is dispatched. */
94
+ onDispatched?: (queryId: string) => void;
95
+ }
96
+
97
+ export interface RoomQueryControl {
98
+ topicId: string;
99
+ queryId: string;
100
+ /** "user" for a human message; otherwise the inject source topic name. */
101
+ origin: string;
102
+ /** Prompt currently being processed. User-turn preemption merges this with the new prompt. */
103
+ prompt: string;
104
+ /** Attachment ids currently being processed. User-turn preemption carries them forward. */
105
+ attachments?: string[];
106
+ /** Provider session id this turn resumed from. Superseding user turns restart from this base. */
107
+ sessionId?: string | null;
108
+ abortController: AbortController;
109
+ abortReason: AbortReason;
110
+ /** Epoch ms when this turn claimed the room (stale-abort guard). */
111
+ startedAt?: number;
112
+ /**
113
+ * Set when this turn was started by a session-inject — the params needed to
114
+ * re-enqueue it if a user message preempts it mid-flight. undefined for user
115
+ * turns (a preempted user turn is simply discarded).
116
+ */
117
+ injectParams?: DeferredInject;
118
+ /** True only after this process successfully queued a local replay. */
119
+ injectRequeued?: boolean;
120
+ }
121
+
122
+ // ── Room-keyed in-flight registry ──────────────────────────────────────
123
+
124
+ const activeByRoom = new Map<string, RoomQueryControl>();
125
+ const leaseMonitors = new Map<string, ReturnType<typeof setInterval>>();
126
+
127
+ export function getRoomQuery(topicId: string): RoomQueryControl | undefined {
128
+ return activeByRoom.get(topicId);
129
+ }
130
+
131
+ /** Topic ids and query ids with an in-flight turn in this or another runtime process. */
132
+ export function listRunningTopicQueries(): Map<string, string> {
133
+ const queries = new Map(
134
+ [...activeByRoom.values()].map((control) => [control.topicId, control.queryId]),
135
+ );
136
+ for (const lease of listRuntimeTurnLeases()) {
137
+ if (!queries.has(lease.topicId)) queries.set(lease.topicId, lease.queryId);
138
+ }
139
+ return queries;
140
+ }
141
+
142
+ export function listRunningTopicIds(): Set<string> {
143
+ return new Set(listRunningTopicQueries().keys());
144
+ }
145
+
146
+ /** Single-topic status check for callers that do not need a list snapshot. */
147
+ export function isTopicRunning(topicId: string): boolean {
148
+ return activeByRoom.has(topicId) || getRuntimeTurnLease(topicId) !== null;
149
+ }
150
+
151
+ export function getRoomQueryStatus(topicId: string, queryId: string): "running" | "not_found" {
152
+ if (activeByRoom.get(topicId)?.queryId === queryId) return "running";
153
+ return getRuntimeTurnLease(topicId)?.queryId === queryId ? "running" : "not_found";
154
+ }
155
+
156
+ export function setRoomQuery(control: RoomQueryControl): boolean {
157
+ control.startedAt ??= Date.now();
158
+ const claimed = claimRuntimeTurnLease({
159
+ topicId: control.topicId,
160
+ queryId: control.queryId,
161
+ origin: control.origin,
162
+ });
163
+ if (!claimed) return false;
164
+
165
+ const previousMonitor = leaseMonitors.get(control.topicId);
166
+ if (previousMonitor) clearInterval(previousMonitor);
167
+ activeByRoom.set(control.topicId, control);
168
+ const monitor = setInterval(() => {
169
+ const current = activeByRoom.get(control.topicId);
170
+ if (!current || current.queryId !== control.queryId) {
171
+ clearInterval(monitor);
172
+ if (leaseMonitors.get(control.topicId) === monitor) {
173
+ leaseMonitors.delete(control.topicId);
174
+ }
175
+ return;
176
+ }
177
+ const heartbeat = heartbeatRuntimeTurnLease(control.topicId, control.queryId);
178
+ if (!heartbeat.owned) {
179
+ control.abortReason = AbortReason.External;
180
+ control.abortController.abort();
181
+ return;
182
+ }
183
+ if (heartbeat.abortRequested && !control.abortController.signal.aborted) {
184
+ control.abortReason =
185
+ heartbeat.abortReason === "internal" ? AbortReason.Internal : AbortReason.External;
186
+ control.abortController.abort();
187
+ }
188
+ }, 1_000);
189
+ monitor.unref?.();
190
+ leaseMonitors.set(control.topicId, monitor);
191
+ return true;
192
+ }
193
+
194
+ /**
195
+ * Delete the room's in-flight entry, but ONLY if it is still this query — a
196
+ * newer turn may have already replaced it (abort-and-replace), and the dying
197
+ * turn's cleanup must not clobber its successor. Mirrors Otium's
198
+ * "Only delete our own entry" guard.
199
+ */
200
+ export function clearRoomQuery(topicId: string, queryId: string): void {
201
+ const cur = activeByRoom.get(topicId);
202
+ if (cur && cur.queryId === queryId) {
203
+ activeByRoom.delete(topicId);
204
+ const monitor = leaseMonitors.get(topicId);
205
+ if (monitor) clearInterval(monitor);
206
+ leaseMonitors.delete(topicId);
207
+ }
208
+ releaseRuntimeTurnLease(topicId, queryId);
209
+ }
210
+
211
+ /** Abort the currently-running turn in a room, if any. Cleanup is still owned by
212
+ * the streaming turn's finally block so deferred injects drain in order. */
213
+ export function abortRoom(topicId: string, reason: AbortReason = AbortReason.External): boolean {
214
+ const running = activeByRoom.get(topicId);
215
+ if (running) {
216
+ running.abortReason = reason;
217
+ running.abortController.abort();
218
+ requestRuntimeTurnAbort(topicId, reason === AbortReason.Internal ? "internal" : "external");
219
+ return true;
220
+ }
221
+ return requestRuntimeTurnAbort(
222
+ topicId,
223
+ reason === AbortReason.Internal ? "internal" : "external",
224
+ );
225
+ }
226
+
227
+ /** Abort every in-flight provider turn before the node tears down its resources. */
228
+ export function abortAllRooms(reason: AbortReason = AbortReason.External): number {
229
+ let aborted = 0;
230
+ for (const running of activeByRoom.values()) {
231
+ running.abortReason = reason;
232
+ if (!running.abortController.signal.aborted) {
233
+ running.abortController.abort();
234
+ aborted++;
235
+ }
236
+ }
237
+ return aborted;
238
+ }
239
+
240
+ export function isUserOrigin(origin: string | undefined): boolean {
241
+ return !origin || origin === "user";
242
+ }
243
+
244
+ // ── Priority decision ──────────────────────────────────────────────────
245
+
246
+ export type NewQueryDecision =
247
+ | { action: "proceed" }
248
+ | { action: "abort-replace"; running: RoomQueryControl }
249
+ | { action: "defer" }
250
+ | { action: "remote-defer"; running: RuntimeTurnLease }
251
+ | { action: "remote-abort-wait"; running: RuntimeTurnLease };
252
+
253
+ /**
254
+ * Decide what to do when a new turn (with `incomingOrigin`) arrives on a room.
255
+ * Mirrors the abort-on-new-message priority used by live topic turns.
256
+ */
257
+ export function decideNewQuery(topicId: string, incomingOrigin: string): NewQueryDecision {
258
+ const running = activeByRoom.get(topicId);
259
+ if (!running) {
260
+ const remote = getRuntimeTurnLease(topicId);
261
+ if (!remote || remote.ownerId === RUNTIME_INSTANCE_ID) return { action: "proceed" };
262
+ return isUserOrigin(incomingOrigin)
263
+ ? { action: "remote-abort-wait", running: remote }
264
+ : { action: "remote-defer", running: remote };
265
+ }
266
+ // Session-injects never preempt — the user keeps priority. Defer it (whether
267
+ // the running turn is a user turn or another inject).
268
+ if (!isUserOrigin(incomingOrigin)) return { action: "defer" };
269
+ // Incoming is a user message → preempt the running turn and take its slot.
270
+ return { action: "abort-replace", running };
271
+ }
272
+
273
+ // ── Session-inject queue (inter-session) ───────────────────────────────
274
+ //
275
+ // Session-inject queue for cross-topic ask/tell messages.
276
+ // Key differences: Otium doesn't have per-user concurrency caps, so the key
277
+ // is just the `topicId` (room).
278
+ // `markPendingAskSources` is handled by the MCP layer; the queue here only
279
+ // manages in-memory defer/replay.
280
+
281
+ export class InterSessionQueue {
282
+ private queue = new Map<
283
+ string,
284
+ { items: DeferredInject[]; head: number; requestIds: Set<string> }
285
+ >();
286
+
287
+ private compactOrDelete(
288
+ topicId: string,
289
+ bucket: { items: DeferredInject[]; head: number; requestIds: Set<string> },
290
+ ): void {
291
+ if (bucket.head >= bucket.items.length) {
292
+ this.queue.delete(topicId);
293
+ return;
294
+ }
295
+ // Keep dequeue O(1) on the hot path; compact only when the reclaimed
296
+ // prefix is large enough to justify copying the remaining references.
297
+ if (bucket.head >= 64 && bucket.head * 2 >= bucket.items.length) {
298
+ bucket.items = bucket.items.slice(bucket.head);
299
+ bucket.head = 0;
300
+ }
301
+ }
302
+
303
+ /** Enqueue inject if requestId is set and not a duplicate. Returns true if enqueued. */
304
+ enqueue(topicId: string, inject: DeferredInject): boolean {
305
+ if (!inject.requestId) {
306
+ logger.error(
307
+ { topicId, origin: inject.origin },
308
+ "BUG: session inject reached enqueue without requestId — DROPPING inject",
309
+ );
310
+ return false;
311
+ }
312
+ let bucket = this.queue.get(topicId);
313
+ if (!bucket) {
314
+ bucket = { items: [], head: 0, requestIds: new Set<string>() };
315
+ this.queue.set(topicId, bucket);
316
+ }
317
+ if (bucket.requestIds.has(inject.requestId)) {
318
+ logger.info(
319
+ { topicId, requestId: inject.requestId, origin: inject.origin },
320
+ "session inject deduped (existing entry with same requestId)",
321
+ );
322
+ return false;
323
+ }
324
+ bucket.items.push(inject);
325
+ bucket.requestIds.add(inject.requestId);
326
+ logger.info(
327
+ { topicId, origin: inject.origin, requestId: inject.requestId, depth: inject.depth },
328
+ "session inject enqueued",
329
+ );
330
+ return true;
331
+ }
332
+
333
+ /** True when an entry with this requestId is still queued for this topic. */
334
+ hasRequest(topicId: string, requestId: string): boolean {
335
+ return this.queue.get(topicId)?.requestIds.has(requestId) ?? false;
336
+ }
337
+
338
+ /** Remove and return the next queued inject for this topic, or undefined if empty. */
339
+ dequeueNext(topicId: string): DeferredInject | undefined {
340
+ const bucket = this.queue.get(topicId);
341
+ if (!bucket || bucket.head >= bucket.items.length) return undefined;
342
+ const next = bucket.items[bucket.head++];
343
+ if (next.requestId) bucket.requestIds.delete(next.requestId);
344
+ this.compactOrDelete(topicId, bucket);
345
+ return next;
346
+ }
347
+
348
+ /**
349
+ * Remove and return the longest mergeable PREFIX of the queue as a single
350
+ * entry, or undefined if empty. Multiple prompts are joined so they land in
351
+ * one turn instead of chaining N turns for N queued injects.
352
+ *
353
+ * Merge rule (mirrors Otium): caller-bound ask_session replies can merge
354
+ * even when they came from different target topics; ordinary inter-session
355
+ * injects remain conservative and require the same origin/silent/session
356
+ * shape. `onDispatched` callbacks from each merged entry are composed.
357
+ */
358
+ dequeueAll(topicId: string): DeferredInject | undefined {
359
+ const bucket = this.queue.get(topicId);
360
+ if (!bucket || bucket.head >= bucket.items.length) return undefined;
361
+ const base = bucket.items[bucket.head];
362
+ const isAskReplyInject = (e: DeferredInject) => !e.silent && Boolean(e.askReplySources?.length);
363
+ const baseIsAskReplyInject = isAskReplyInject(base);
364
+ const mergeable = (e: DeferredInject) =>
365
+ (baseIsAskReplyInject
366
+ ? isAskReplyInject(e)
367
+ : !isAskReplyInject(e) &&
368
+ e.origin === base.origin &&
369
+ (e.silent ?? false) === (base.silent ?? false)) &&
370
+ (e.onDispatched !== undefined) === (base.onDispatched !== undefined) &&
371
+ (e.agentOverride ?? null) === (base.agentOverride ?? null) &&
372
+ (e.modelOverride ?? null) === (base.modelOverride ?? null) &&
373
+ (e.effortOverride ?? null) === (base.effortOverride ?? null) &&
374
+ (e.runtimeEpoch ?? 0) === (base.runtimeEpoch ?? 0) &&
375
+ (e.sessionId ?? null) === (base.sessionId ?? null) &&
376
+ (e.forkHandle?.forkId ?? null) === (base.forkHandle?.forkId ?? null) &&
377
+ (e.prepareSession ?? null) === (base.prepareSession ?? null) &&
378
+ (e.cwd ?? null) === (base.cwd ?? null) &&
379
+ (e.sessionName ?? null) === (base.sessionName ?? null) &&
380
+ (e.sessionType ?? null) === (base.sessionType ?? null) &&
381
+ (e.onSessionId ?? null) === (base.onSessionId ?? null) &&
382
+ (e.onSessionReset ?? null) === (base.onSessionReset ?? null) &&
383
+ (e.bridgeSessionFromHistory ?? false) === (base.bridgeSessionFromHistory ?? false) &&
384
+ (e.onSettled ?? null) === (base.onSettled ?? null);
385
+ let end = bucket.head + 1;
386
+ while (end < bucket.items.length && mergeable(bucket.items[end])) end++;
387
+ const batch = bucket.items.slice(bucket.head, end);
388
+ bucket.head = end;
389
+ for (const entry of batch) {
390
+ if (entry.requestId) bucket.requestIds.delete(entry.requestId);
391
+ }
392
+ this.compactOrDelete(topicId, bucket);
393
+ if (batch.length === 1) return batch[0];
394
+
395
+ const callbacks = batch
396
+ .map((e) => e.onDispatched)
397
+ .filter((cb): cb is NonNullable<typeof cb> => !!cb);
398
+ const askReplySources = batch.flatMap((e): AskReplySource[] => {
399
+ if (e.askReplySources?.length) return e.askReplySources;
400
+ if (!e.requestId || !e.origin) return [];
401
+ return [{ from: e.origin, requestId: e.requestId, contextId: e.contextId }];
402
+ });
403
+ const depths = batch
404
+ .map((e) => e.depth)
405
+ .filter((depth): depth is number => typeof depth === "number");
406
+ return {
407
+ ...base,
408
+ prompt: batch.map((e) => e.prompt).join("\n\n"),
409
+ origin: baseIsAskReplyInject
410
+ ? [...new Set(batch.map((e) => e.origin).filter(Boolean))].join(", ")
411
+ : base.origin,
412
+ depth: depths.length ? Math.min(...depths) : base.depth,
413
+ requestId: `merged-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
414
+ // Same-origin batches share one context; only drop it when the batch
415
+ // disagrees (a merged turn must not append to the wrong context).
416
+ contextId: batch.every((e) => e.contextId === base.contextId) ? base.contextId : undefined,
417
+ askReplySources,
418
+ onDispatched: callbacks.length
419
+ ? (queryId: string) => {
420
+ callbacks.forEach((cb) => {
421
+ cb(queryId);
422
+ });
423
+ }
424
+ : undefined,
425
+ };
426
+ }
427
+
428
+ /** All non-empty queue keys for this topic room. */
429
+ keysForRoom(): string[] {
430
+ return Array.from(this.queue.keys());
431
+ }
432
+
433
+ /** Drop all queued injects for a topic (e.g. after room abort). */
434
+ drop(topicId: string): void {
435
+ this.queue.delete(topicId);
436
+ }
437
+
438
+ /** Number of queued injects for a topic. */
439
+ size(topicId: string): number {
440
+ const bucket = this.queue.get(topicId);
441
+ return bucket ? bucket.items.length - bucket.head : 0;
442
+ }
443
+
444
+ /** Remove one queued inject by request id. Used by bounded background queues. */
445
+ remove(topicId: string, requestId: string): DeferredInject | undefined {
446
+ const bucket = this.queue.get(topicId);
447
+ if (!bucket?.requestIds.has(requestId)) return undefined;
448
+ const index = bucket.items.findIndex(
449
+ (entry, entryIndex) => entryIndex >= bucket.head && entry.requestId === requestId,
450
+ );
451
+ if (index < 0) return undefined;
452
+ const [removed] = bucket.items.splice(index, 1);
453
+ bucket.requestIds.delete(requestId);
454
+ this.compactOrDelete(topicId, bucket);
455
+ return removed;
456
+ }
457
+ }
458
+
459
+ export interface DeferredInjectBatcherOptions {
460
+ queue: InterSessionQueue;
461
+ delayMs: number;
462
+ isBusy: (topicId: string) => boolean;
463
+ dispatch: (inject: DeferredInject) => void;
464
+ schedule?: (callback: () => void, delayMs: number) => unknown;
465
+ }
466
+
467
+ /**
468
+ * Gives caller-bound ask replies a short window to collect before dispatch.
469
+ * `dequeueAll()` only merges entries that are already queued, so the first
470
+ * reply must pass through this gate too when its caller room is idle.
471
+ */
472
+ export class DeferredInjectBatcher {
473
+ private readonly timers = new Map<string, unknown>();
474
+ private readonly scheduleTimer: (callback: () => void, delayMs: number) => unknown;
475
+
476
+ constructor(private readonly options: DeferredInjectBatcherOptions) {
477
+ this.scheduleTimer = options.schedule ?? ((callback, delayMs) => setTimeout(callback, delayMs));
478
+ }
479
+
480
+ enqueue(inject: DeferredInject): boolean {
481
+ const queued = this.options.queue.enqueue(inject.topicId, inject);
482
+ if (!queued) return false;
483
+ if (this.timers.has(inject.topicId)) return true;
484
+
485
+ const timer = this.scheduleTimer(() => {
486
+ this.timers.delete(inject.topicId);
487
+ // A busy room owns the drain in its turn-finally path. Leaving the
488
+ // entries queued avoids a second dispatcher racing that owner.
489
+ if (this.options.isBusy(inject.topicId)) return;
490
+ const batch = this.options.queue.dequeueAll(inject.topicId);
491
+ if (batch) this.options.dispatch(batch);
492
+ }, this.options.delayMs);
493
+ this.timers.set(inject.topicId, timer);
494
+ return true;
495
+ }
496
+
497
+ isScheduled(topicId: string): boolean {
498
+ return this.timers.has(topicId);
499
+ }
500
+ }
501
+
502
+ export const interSessionQueue = new InterSessionQueue();
503
+
504
+ // ── Defer / drain helpers (thin wrappers around InterSessionQueue) ────
505
+
506
+ /**
507
+ * Enqueue a deferred inject. With the full queue, this also deduplicates by
508
+ * requestId.
509
+ */
510
+ export function deferInject(inject: DeferredInject): boolean {
511
+ return interSessionQueue.enqueue(inject.topicId, inject);
512
+ }
513
+
514
+ /**
515
+ * Remove and return the longest mergeable batch of deferred injects for a room,
516
+ * or undefined if empty.
517
+ */
518
+ export function takeDeferredInject(topicId: string): DeferredInject | undefined {
519
+ return interSessionQueue.dequeueAll(topicId);
520
+ }
521
+
522
+ /** Cancel one deferred inject before it claims the room. */
523
+ export function cancelDeferredInject(topicId: string, requestId: string): boolean {
524
+ return interSessionQueue.remove(topicId, requestId) !== undefined;
525
+ }
526
+
527
+ // ── WS discriminator ───────────────────────────────────────────────────
528
+
529
+ /** Map the internal AbortReason → the WS `ai_aborted` discriminator. */
530
+ export function wsAbortReason(reason: AbortReason): "superseded" | "stopped" {
531
+ return reason === AbortReason.Internal ? "superseded" : "stopped";
532
+ }