negotium 0.19.2 → 0.20.0

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 (96) hide show
  1. package/dist/agent-helpers.js +1146 -212
  2. package/dist/agent-helpers.js.map +27 -19
  3. package/dist/background-bash.js.map +1 -1
  4. package/dist/browser-runtime.js.map +1 -1
  5. package/dist/{chunk-kkftbxh2.js → chunk-7he9nzfa.js} +1 -1
  6. package/dist/{chunk-55dd0xw7.js → chunk-ad6xqvqy.js} +2 -2
  7. package/dist/{chunk-55dd0xw7.js.map → chunk-ad6xqvqy.js.map} +2 -2
  8. package/dist/{chunk-exqz8bez.js → chunk-hh3f0yr7.js} +3 -3
  9. package/dist/{chunk-exqz8bez.js.map → chunk-hh3f0yr7.js.map} +2 -2
  10. package/dist/{chunk-aqqmzmft.js → chunk-nm59q3em.js} +1 -1
  11. package/dist/{chunk-cv7vdsrn.js → chunk-w3c6fc0p.js} +286 -9
  12. package/dist/{chunk-cv7vdsrn.js.map → chunk-w3c6fc0p.js.map} +10 -7
  13. package/dist/hosted-agent.js +290 -12
  14. package/dist/hosted-agent.js.map +10 -7
  15. package/dist/main.js +4599 -2724
  16. package/dist/main.js.map +49 -38
  17. package/dist/mcp-factories.js +1166 -162
  18. package/dist/mcp-factories.js.map +27 -19
  19. package/dist/media.js.map +1 -1
  20. package/dist/prompts.js.map +1 -1
  21. package/dist/query-runtime.js.map +3 -3
  22. package/dist/registry.js +3 -3
  23. package/dist/rollout.js +2 -2
  24. package/dist/runtime/src/agents/explicit-agent-switch.ts +101 -0
  25. package/dist/runtime/src/agents/mcp-tools/spawn-subagent.ts +126 -43
  26. package/dist/runtime/src/agents/public-helpers.ts +5 -0
  27. package/dist/runtime/src/agents/self-config-core.ts +28 -58
  28. package/dist/runtime/src/application/submit-runtime-gateway-turn.ts +34 -8
  29. package/dist/runtime/src/index.ts +75 -0
  30. package/dist/runtime/src/mcp/peer-bridge.ts +6 -1
  31. package/dist/runtime/src/mcp/runtime-spec.ts +145 -10
  32. package/dist/runtime/src/mcp/session-comm/actor-policy.ts +87 -0
  33. package/dist/runtime/src/mcp/session-comm/context.ts +42 -1
  34. package/dist/runtime/src/mcp/session-comm/default-host.ts +178 -2
  35. package/dist/runtime/src/mcp/session-comm/hub-remote-session.ts +553 -0
  36. package/dist/runtime/src/mcp/session-comm/limits.ts +8 -0
  37. package/dist/runtime/src/mcp/session-comm/peer-forward.ts +78 -3
  38. package/dist/runtime/src/mcp/session-comm/server.ts +167 -4
  39. package/dist/runtime/src/mcp/session-comm/topic-catalog.ts +7 -0
  40. package/dist/runtime/src/mcp/session-comm/topics.ts +32 -10
  41. package/dist/runtime/src/mcp-runtime-host.ts +2 -1
  42. package/dist/runtime/src/node-host.ts +26 -0
  43. package/dist/runtime/src/platform/mcp-config.ts +56 -2
  44. package/dist/runtime/src/query/active-rooms.ts +6 -0
  45. package/dist/runtime/src/runtime/actor-topic-reach.ts +85 -0
  46. package/dist/runtime/src/runtime/actor-topic-scope.ts +175 -0
  47. package/dist/runtime/src/runtime/ask-callbacks.ts +73 -20
  48. package/dist/runtime/src/runtime/gateway-capabilities.ts +22 -0
  49. package/dist/runtime/src/runtime/remote-session-grant.ts +221 -0
  50. package/dist/runtime/src/runtime/remote-session-inbox.ts +476 -0
  51. package/dist/runtime/src/runtime/remote-session-reply-outbox.ts +156 -0
  52. package/dist/runtime/src/runtime/turn-runner.ts +157 -33
  53. package/dist/runtime/src/runtime-gateway.ts +20 -0
  54. package/dist/runtime/src/storage/api-topics.ts +22 -3
  55. package/dist/runtime/src/storage/remote-session.ts +526 -0
  56. package/dist/runtime/src/storage/runtime-turn-requests.ts +75 -2
  57. package/dist/runtime/src/topics/derive.ts +3 -0
  58. package/dist/runtime/src/types/api.ts +6 -0
  59. package/dist/runtime/src/types.ts +42 -0
  60. package/dist/runtime/src/version.ts +1 -1
  61. package/dist/runtime-gateway.js.map +2 -2
  62. package/dist/runtime-helpers.js.map +1 -1
  63. package/dist/storage.js +16 -4
  64. package/dist/storage.js.map +4 -4
  65. package/dist/types/packages/core/src/agents/explicit-agent-switch.d.ts +26 -0
  66. package/dist/types/packages/core/src/agents/mcp-tools/spawn-subagent.d.ts +12 -2
  67. package/dist/types/packages/core/src/agents/public-helpers.d.ts +1 -0
  68. package/dist/types/packages/core/src/agents/self-config-core.d.ts +15 -1
  69. package/dist/types/packages/core/src/mcp/peer-bridge.d.ts +6 -1
  70. package/dist/types/packages/core/src/mcp/runtime-spec.d.ts +69 -2
  71. package/dist/types/packages/core/src/mcp/session-comm/context.d.ts +17 -1
  72. package/dist/types/packages/core/src/mcp/session-comm/hub-remote-session.d.ts +149 -0
  73. package/dist/types/packages/core/src/mcp/session-comm/limits.d.ts +8 -0
  74. package/dist/types/packages/core/src/mcp/session-comm/peer-forward.d.ts +23 -2
  75. package/dist/types/packages/core/src/mcp/session-comm/topic-catalog.d.ts +6 -0
  76. package/dist/types/packages/core/src/platform/mcp-config.d.ts +10 -1
  77. package/dist/types/packages/core/src/query/active-rooms.d.ts +6 -0
  78. package/dist/types/packages/core/src/runtime/actor-topic-reach.d.ts +49 -0
  79. package/dist/types/packages/core/src/runtime/actor-topic-scope.d.ts +91 -0
  80. package/dist/types/packages/core/src/runtime/ask-callbacks.d.ts +8 -2
  81. package/dist/types/packages/core/src/runtime/remote-session-grant.d.ts +91 -0
  82. package/dist/types/packages/core/src/runtime/remote-session-inbox.d.ts +83 -0
  83. package/dist/types/packages/core/src/runtime/remote-session-reply-outbox.d.ts +37 -0
  84. package/dist/types/packages/core/src/runtime/turn-runner.d.ts +22 -3
  85. package/dist/types/packages/core/src/runtime-gateway.d.ts +20 -0
  86. package/dist/types/packages/core/src/storage/api-topics.d.ts +3 -0
  87. package/dist/types/packages/core/src/storage/remote-session.d.ts +135 -0
  88. package/dist/types/packages/core/src/storage/runtime-turn-requests.d.ts +12 -1
  89. package/dist/types/packages/core/src/topics/derive.d.ts +2 -0
  90. package/dist/types/packages/core/src/types/api.d.ts +6 -0
  91. package/dist/types/packages/core/src/types.d.ts +40 -0
  92. package/dist/types/packages/core/src/version.d.ts +1 -1
  93. package/dist/vault.js.map +1 -1
  94. package/package.json +1 -1
  95. /package/dist/{chunk-kkftbxh2.js.map → chunk-7he9nzfa.js.map} +0 -0
  96. /package/dist/{chunk-aqqmzmft.js.map → chunk-nm59q3em.js.map} +0 -0
@@ -21,6 +21,7 @@ import {
21
21
  isTopicBrowserProfileOwner,
22
22
  listBrowserProfiles,
23
23
  } from "#storage/browser-profiles";
24
+ import { deleteRemoteSessionAsk, recordRemoteSessionAsk } from "#storage/remote-session";
24
25
  import {
25
26
  clearPendingAsk,
26
27
  createPendingAsk,
@@ -29,6 +30,14 @@ import {
29
30
  } from "#storage/session-asks";
30
31
  import { enqueueSessionInbox } from "#storage/session-inbox";
31
32
  import { connectStdio, mcpError, mcpOk } from "../mcp-helpers";
33
+ import { abortTargetRefusal, remoteSessionRoute } from "./actor-policy";
34
+ import {
35
+ hubRemoteAbort,
36
+ hubRemoteAsk,
37
+ hubRemotePeek,
38
+ hubRemoteSessions,
39
+ hubRemoteTell,
40
+ } from "./hub-remote-session";
32
41
  import { forwardToPeer, peerSessionsForUser } from "./peer-forward";
33
42
  import {
34
43
  cronSessionId,
@@ -40,6 +49,7 @@ import {
40
49
  MAX_MESSAGE_LENGTH,
41
50
  MAX_TELL_DEPTH,
42
51
  peerHostQueryId,
52
+ sessionCommContext,
43
53
  subagentParentTopicId,
44
54
  userId,
45
55
  } from "./runtime";
@@ -51,6 +61,7 @@ import {
51
61
  setMcpConfig,
52
62
  } from "./topic-config";
53
63
  import {
64
+ currentSessionSurface,
54
65
  getTopicsForUser,
55
66
  listSessionTargetsForUser,
56
67
  type QueryState,
@@ -78,6 +89,11 @@ function remotePeerTarget(to: string): PeerTarget | null {
78
89
  return { node: to.slice(0, slash), topic: to.slice(slash + 1) };
79
90
  }
80
91
 
92
+ /** Which transport serves `node/topic` targets for this turn (see `remoteSessionRoute`). */
93
+ function currentRemoteRoute() {
94
+ return remoteSessionRoute(currentSessionSurface(), sessionCommContext.remoteSession);
95
+ }
96
+
81
97
  // --- MCP Server ---
82
98
 
83
99
  const server = new McpServer({
@@ -166,11 +182,39 @@ server.tool(
166
182
 
167
183
  // Multi-node: append each peer node's rooms, addressed as "node/topic".
168
184
  // Disabled or unattached nodes answer with an error — skip silently so
169
- // single-node behavior is untouched.
185
+ // single-node behavior is untouched. On Otium the remote rooms are the
186
+ // hub's answer for this turn's grant, and none without one (see
187
+ // `remoteSessionRoute`).
170
188
  const remoteSections: string[] = [];
171
- const peers = currentSubagentRestricted
172
- ? { ok: true as const, nodes: [] }
173
- : await peerSessionsForUser(userId, peerHostQueryId || undefined, currentTopicId);
189
+ const route = currentSubagentRestricted ? null : currentRemoteRoute();
190
+ if (route?.kind === "hub") {
191
+ const remote = await hubRemoteSessions(route.grant);
192
+ if (!remote.ok) {
193
+ remoteSections.push(`\nRemote nodes: (${remote.error})`);
194
+ } else {
195
+ for (const node of remote.nodes) {
196
+ if (!node.node) continue;
197
+ if (node.error) {
198
+ remoteSections.push(`\nNode ${node.node}: (unreachable: ${node.error})`);
199
+ continue;
200
+ }
201
+ const lines = (node.sessions ?? [])
202
+ .filter((session) => Boolean(session.agent))
203
+ .map((s) => {
204
+ const status = s.status === "active" ? "active" : "fresh-start ready";
205
+ const desc = s.description ? ` — ${s.description.slice(0, 60)}` : "";
206
+ return `- ${node.node}/${s.name}: ${status}${desc}`;
207
+ });
208
+ remoteSections.push(
209
+ `\nNode ${node.node}:\n${lines.join("\n") || " (no rooms for this user)"}`,
210
+ );
211
+ }
212
+ }
213
+ }
214
+ const peers =
215
+ route?.kind !== "peer"
216
+ ? { ok: true as const, nodes: [] }
217
+ : await peerSessionsForUser(userId, peerHostQueryId || undefined, currentTopicId);
174
218
  if (peers.ok && peers.nodes) {
175
219
  for (const node of peers.nodes) {
176
220
  if (!node.node) continue;
@@ -409,6 +453,46 @@ server.tool(
409
453
  ),
410
454
  );
411
455
  }
456
+ // Remote rooms (v1): the hub's active/ready view only — no elapsed time
457
+ // or task text, since the hub does not fan out to every node for a peek.
458
+ const route = currentSubagentRestricted ? null : currentRemoteRoute();
459
+ if (route?.kind === "hub") {
460
+ const remote = await hubRemotePeek(route.grant);
461
+ if (!remote.ok) {
462
+ lines.push(``, `원격 노드: (${remote.error})`);
463
+ } else {
464
+ const remoteRunning: string[] = [];
465
+ const remoteIdle: string[] = [];
466
+ const unreachable: string[] = [];
467
+ for (const node of remote.nodes) {
468
+ if (node.error) {
469
+ unreachable.push(` ${node.node}: (unreachable: ${node.error})`);
470
+ continue;
471
+ }
472
+ for (const session of node.sessions ?? []) {
473
+ // Same policy as the listing: a room with no AI is not a session.
474
+ if (session.agent === null) continue;
475
+ (session.status === "active" ? remoteRunning : remoteIdle).push(
476
+ `${node.node}/${session.name}`,
477
+ );
478
+ }
479
+ }
480
+ lines.push(
481
+ ``,
482
+ `원격 실행 중 (${remoteRunning.length}): ${remoteRunning.join(", ") || "없음"}`,
483
+ `원격 유휴 (${remoteIdle.length}): ${remoteIdle.join(", ") || "없음"}`,
484
+ ...unreachable,
485
+ );
486
+ if (remote.pendingAsks.length) {
487
+ lines.push(
488
+ `내 원격 ask_session 대기:`,
489
+ ...remote.pendingAsks.map(
490
+ (ask) => ` ${ask.to}: ${ask.status} (request_id: ${ask.requestId})`,
491
+ ),
492
+ );
493
+ }
494
+ }
495
+ }
412
496
  return mcpOk(lines.join("\n"));
413
497
  },
414
498
  );
@@ -456,6 +540,8 @@ if (!isReplyOnly) {
456
540
  // Remote target ("node/topic") — hand to the runtime's peer forwarder.
457
541
  const remote = remotePeerTarget(to);
458
542
  if (remote) {
543
+ const route = currentRemoteRoute();
544
+ if (route.kind === "refused") return mcpError(route.error);
459
545
  const fromRef = currentTopicRef();
460
546
  if (!fromRef.topicId) {
461
547
  return mcpError(
@@ -472,6 +558,40 @@ if (!isReplyOnly) {
472
558
  `"${to}"에 이미 진행 중인 ask_session 요청이 있습니다: ${detail}. 응답이 이 세션에 자동으로 돌아올 때까지 기다리세요.`,
473
559
  );
474
560
  }
561
+ if (route.kind === "hub") {
562
+ // Durable caller record first: the hub's `ask-reply` delivery is
563
+ // routed back through it, possibly after this process restarted.
564
+ recordRemoteSessionAsk({
565
+ requestId,
566
+ callerTopicId: fromRef.topicId,
567
+ userId,
568
+ fromKey: fromRef.key,
569
+ toKey: to,
570
+ ...(sessionCommContext.currentThreadRootId
571
+ ? { callerThreadRootId: sessionCommContext.currentThreadRootId }
572
+ : {}),
573
+ });
574
+ const sent = await hubRemoteAsk(route.grant, {
575
+ requestId,
576
+ to: remote,
577
+ message,
578
+ fromDepth: currentDepth,
579
+ fromLabel: { key: fromRef.key, title: fromRef.title },
580
+ });
581
+ if (!sent.ok) {
582
+ if (sent.uncertain) {
583
+ return mcpOk(
584
+ `"${to}" 세션(노드 ${remote.node})에 참조 요청을 보냈지만 hub의 확인을 받지 못했습니다 (${sent.error}).\n\nrequest_id: ${requestId}\n\n응답이 도착하면 '[Reply from ${remote.node}/${remote.topic}]' 형식으로 이 세션에 돌아옵니다. 같은 요청으로 ask_session을 재호출하지 마세요.`,
585
+ );
586
+ }
587
+ clearPendingAsk({ userId, from: fromRef.key, to, requestId });
588
+ deleteRemoteSessionAsk(requestId);
589
+ return mcpError(`Error: "${to}" 원격 세션에 전송 실패: ${sent.error}`);
590
+ }
591
+ return mcpOk(
592
+ `"${to}" 세션(노드 ${remote.node})에 참조 요청을 보냈습니다.\n\nrequest_id: ${requestId}\n\n응답은 '[Reply from ${remote.node}/${remote.topic}]' 형식으로 이 세션에 자동으로 돌아옵니다. 응답이 도착할 때까지 같은 요청으로 ask_session을 재호출하지 마세요.`,
593
+ );
594
+ }
475
595
  const result = await forwardToPeer({
476
596
  action: "ask",
477
597
  toNode: remote.node,
@@ -631,6 +751,16 @@ if (!isReplyOnly) {
631
751
  async ({ to }) => {
632
752
  const remote = remotePeerTarget(to);
633
753
  if (remote) {
754
+ const route = currentRemoteRoute();
755
+ if (route.kind === "refused") return mcpError(route.error);
756
+ if (route.kind === "hub") {
757
+ const sent = await hubRemoteAbort(route.grant, {
758
+ requestId: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
759
+ to: remote,
760
+ });
761
+ if (!sent.ok) return mcpError(`Error: "${to}" 원격 abort 실패: ${sent.error}`);
762
+ return mcpOk(`"${to}" 세션(노드 ${remote.node})에 abort 신호를 보냈습니다.`);
763
+ }
634
764
  const result = await forwardToPeer({
635
765
  action: "abort",
636
766
  toNode: remote.node,
@@ -656,6 +786,15 @@ if (!isReplyOnly) {
656
786
  if (!targetTopicId) {
657
787
  return mcpError(`Error: "${to}" 세션의 토픽 ID를 찾을 수 없습니다.`);
658
788
  }
789
+ // Same owner rule as the hosted host, from the same module.
790
+ const refused = abortTargetRefusal({
791
+ surface: currentSessionSurface(),
792
+ currentTopicId: currentTopicId || undefined,
793
+ actorTopicScope: sessionCommContext.actorTopicScope,
794
+ targetTopicId,
795
+ to,
796
+ });
797
+ if (refused) return mcpError(refused);
659
798
  // Send query abort signal via inbox
660
799
  enqueueSessionInbox({
661
800
  userId,
@@ -705,8 +844,32 @@ if (!isReplyOnly) {
705
844
  if (currentSubagentRestricted) {
706
845
  return mcpError("Error: subagent tell_session cannot target remote topics.");
707
846
  }
847
+ const route = currentRemoteRoute();
848
+ if (route.kind === "refused") return mcpError(route.error);
708
849
  const fromRef = currentTopicRef();
709
850
  const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
851
+ if (route.kind === "hub") {
852
+ const sent = await hubRemoteTell(route.grant, {
853
+ requestId,
854
+ to: remote,
855
+ message,
856
+ depth: currentDepth + 1,
857
+ fromLabel: { key: fromRef.key, title: fromRef.title },
858
+ });
859
+ if (!sent.ok) {
860
+ // A timed-out response may still have been delivered; say so
861
+ // rather than invite a duplicate.
862
+ if (sent.uncertain) {
863
+ return mcpOk(
864
+ `"${to}" 토픽(노드 ${remote.node})에 메시지를 보냈지만 hub의 확인을 받지 못했습니다 (${sent.error}).\n\nrequest_id: ${requestId}\n\n같은 메시지를 다시 보내지 마세요.`,
865
+ );
866
+ }
867
+ return mcpError(`Error: "${to}" 원격 세션에 전송 실패: ${sent.error}`);
868
+ }
869
+ return mcpOk(
870
+ `"${to}" 토픽(노드 ${remote.node})에 메시지를 전달했습니다.\n\nrequest_id: ${requestId}\n\n결과는 해당 노드의 "${remote.topic}" 대화 기록에 남습니다.`,
871
+ );
872
+ }
710
873
  const result = await forwardToPeer({
711
874
  action: "tell",
712
875
  toNode: remote.node,
@@ -43,6 +43,12 @@ export interface SessionTargetCatalogHost<TAgent extends string = string> {
43
43
  * surface: terminal with terminal, telegram with telegram, otium with otium.
44
44
  */
45
45
  readonly currentSurface?: string;
46
+ /**
47
+ * Drop rows with no AI agent entirely instead of listing them as targets
48
+ * that every messaging tool then refuses. The current room and manager rooms
49
+ * are always dropped; this adds AI-off rooms for surfaces that want it.
50
+ */
51
+ readonly excludeAgentless?: boolean;
46
52
  readonly isAgent: (value: string | null) => value is TAgent;
47
53
  }
48
54
 
@@ -64,6 +70,7 @@ export function createSessionTargetCatalog<TAgent extends string = string>(
64
70
  const eligibleRows = listRows().filter(
65
71
  (row) =>
66
72
  row.kind !== "manager" &&
73
+ (!host.excludeAgentless || isAgent(row.agent)) &&
67
74
  // Rows predating the surface column report null and stay addressable
68
75
  // from the local surface rather than vanishing mid-migration.
69
76
  (!currentSurface || (row.surface ?? currentSurface) === currentSurface),
@@ -8,17 +8,20 @@
8
8
  import { existsSync, readFileSync } from "node:fs";
9
9
  import { join } from "node:path";
10
10
  import { sessionInboxPath } from "#query/session-inbox-path";
11
+ import { actorReachableTopicIds } from "#runtime/actor-topic-reach";
11
12
  import { sanitizeTopicName } from "#security/sanitize";
12
13
  // NOTE: see `runtime.ts` — import these from `@/types` directly to keep
13
14
  // `maestro-agent-sdk` (whose `bootstrapHostPath()` prints to stdout) out of
14
15
  // this stdio MCP server's import graph.
15
16
  import { type AgentKind, isAgentKind, type QueryState } from "#types";
17
+ import { excludesAgentlessTargets } from "./actor-policy";
16
18
  import {
17
19
  currentTopic,
18
20
  currentTopicId,
19
21
  PLAYWRIGHT_PORTS_DIR,
20
22
  parseJsonField,
21
23
  SESSIONS_DB,
24
+ sessionCommContext,
22
25
  userId,
23
26
  withDb,
24
27
  } from "./runtime";
@@ -171,23 +174,42 @@ function currentSessionPlacement(): CurrentPlacement {
171
174
  return cachedSessionPlacement;
172
175
  }
173
176
 
177
+ /** Surface of the room this server serves (undefined when it cannot be read). */
178
+ export function currentSessionSurface(): string | undefined {
179
+ return currentSessionPlacement().surface;
180
+ }
181
+
174
182
  const sessionTargetCatalog = createSessionTargetCatalog<AgentKind>({
175
183
  currentTopicId,
176
184
  currentTopicName: currentTopic,
177
185
  get currentSurface() {
178
186
  return currentSessionPlacement().surface;
179
187
  },
188
+ get excludeAgentless() {
189
+ return excludesAgentlessTargets(currentSessionPlacement().surface);
190
+ },
180
191
  isAgent: isAgentKind,
181
- listRows: () =>
182
- sessionTargetRows().map((row) => ({
183
- id: row.id,
184
- title: row.title,
185
- kind: row.kind,
186
- agent: row.agent,
187
- sessionId: row.session_id,
188
- description: row.description,
189
- surface: row.surface,
190
- })),
192
+ listRows: () => {
193
+ // On `otium` the roster join above matches the hub's execution principal,
194
+ // not the person who spoke, so the hub's per-turn assertion decides which
195
+ // of those rooms this actor may actually name. Fail-closed without one.
196
+ const reachable = actorReachableTopicIds({
197
+ surface: currentSessionPlacement().surface,
198
+ currentTopicId: currentTopicId || undefined,
199
+ actorTopicScope: sessionCommContext.actorTopicScope,
200
+ });
201
+ return sessionTargetRows()
202
+ .filter((row) => !reachable || reachable.has(row.id))
203
+ .map((row) => ({
204
+ id: row.id,
205
+ title: row.title,
206
+ kind: row.kind,
207
+ agent: row.agent,
208
+ sessionId: row.session_id,
209
+ description: row.description,
210
+ surface: row.surface,
211
+ }));
212
+ },
191
213
  });
192
214
 
193
215
  export function getMcpUserConfig(): McpUserConfig | null {
@@ -39,6 +39,7 @@ export { OPTIONAL_FORUM_MCP_SERVERS, REQUIRED_FORUM_MCP_SERVERS } from "#platfor
39
39
  export { isInsideDir } from "#platform/paths";
40
40
  export { abortRoom, getRoomQuery } from "#query/active-rooms";
41
41
  export { sessionInboxPath } from "#query/session-inbox-path";
42
+ export { actorOwnedTopicIds, actorReachableTopicIds } from "#runtime/actor-topic-reach";
42
43
  export { prepareDeliveryAck } from "#runtime/delivery-ack";
43
44
  export { storeLocalFileAsUpload } from "#runtime/file-hooks";
44
45
  export {
@@ -70,6 +71,6 @@ export {
70
71
  TopicCleanupRequiredError,
71
72
  } from "#topics/lifecycle";
72
73
  export { restartTopicSession } from "#topics/session";
73
- export type { EffortLevel } from "#types";
74
+ export type { ActorTopicScope, EffortLevel, RemoteSessionGrant } from "#types";
74
75
  export { EFFORT_VALUES } from "#types";
75
76
  export type { MessageDto, TopicDto, TopicSurface } from "#types/api";
@@ -30,6 +30,7 @@ export {
30
30
  asUserId,
31
31
  } from "#identifiers";
32
32
  export { setRuntimeMcpPort } from "#mcp/runtime-spec";
33
+ export { MAX_PEER_MESSAGE_LENGTH } from "#mcp/session-comm/limits";
33
34
  export { killAllBgBash } from "#platform/background-bash/manager";
34
35
  export {
35
36
  DATA_DIR,
@@ -65,6 +66,8 @@ export {
65
66
  reapPlaywrightOrphans as reapOrphanBrowsers,
66
67
  } from "#platform/playwright/manager";
67
68
  export { abortAllRooms, listRunningTopicQueries } from "#query/active-rooms";
69
+ export { parseActorTopicScope, validateActorTopicScope } from "#runtime/actor-topic-scope";
70
+ export { failInterruptedRemoteAskCallbacks } from "#runtime/ask-callbacks";
68
71
  export { listBackgroundSessionsForUser } from "#runtime/background-sessions";
69
72
  export type {
70
73
  BashrsCompletion,
@@ -77,7 +80,22 @@ export {
77
80
  } from "#runtime/bashrs-completions";
78
81
  export type { FileHooks, UploadAccess } from "#runtime/file-hooks";
79
82
  export { setFileHooks } from "#runtime/file-hooks";
83
+ export {
84
+ listRuntimeGatewayCapabilities,
85
+ registerRuntimeGatewayCapability,
86
+ } from "#runtime/gateway-capabilities";
80
87
  export { startSessionInboxWorker } from "#runtime/inbox";
88
+ export { validateRemoteSessionGrant } from "#runtime/remote-session-grant";
89
+ export {
90
+ deliverRemoteSessionInbox,
91
+ parseRemoteSessionInboxDelivery,
92
+ RemoteSessionInboxError,
93
+ recoverRemoteSessionInbox,
94
+ } from "#runtime/remote-session-inbox";
95
+ export {
96
+ runRemoteSessionMaintenance,
97
+ startRemoteSessionReplyOutboxWorker,
98
+ } from "#runtime/remote-session-reply-outbox";
81
99
  export { startAiTurn, startDurableTurnRequestWorker } from "#runtime/turn-runner";
82
100
  export type { PortableTopicVisual } from "#runtime/visual-store";
83
101
  export { getPortableTopicVisual } from "#runtime/visual-store";
@@ -99,6 +117,14 @@ export {
99
117
  export { getTopic, upsertTopic } from "#storage/api-topics";
100
118
  export { getGlobalAiName, setGlobalAiName } from "#storage/app-settings";
101
119
  export { readDecisions, writeDecisionGraphSvg } from "#storage/decisions";
120
+ export {
121
+ claimRemoteSessionInbox,
122
+ purgeRemoteSessionInboxClaims,
123
+ purgeStaleRemoteSessionAsks,
124
+ releaseRemoteSessionInboxClaim,
125
+ remoteSessionPayloadHash,
126
+ takeRemoteSessionAsk,
127
+ } from "#storage/remote-session";
102
128
  export type { StoredRuntimeEvent } from "#storage/runtime-events";
103
129
  export {
104
130
  earliestRuntimeEventSeq,
@@ -1,6 +1,7 @@
1
1
  import { accessSync, constants as fsConstants } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
+ import { explicitAgentSwitchTargets } from "#agents/explicit-agent-switch";
4
5
 
5
6
  import { canonicalMcpBridgeEnv } from "#mcp/canonical-bridge-config";
6
7
  import {
@@ -38,10 +39,18 @@ import {
38
39
  buildPlaywrightMcpTransport,
39
40
  CODEX_BROWSER_CAPABILITY_ENV,
40
41
  } from "#platform/playwright/mcp-transport";
42
+ import { encodeActorTopicScopeArg } from "#runtime/actor-topic-scope";
41
43
  import { getRegisteredCronSession } from "#runtime/cron-sessions";
44
+ import { encodeRemoteSessionGrantArg } from "#runtime/remote-session-grant";
42
45
  import type { HostMcpServerSpec } from "#runtime-gateway";
43
46
  import { getTopic } from "#storage/api-topics";
44
- import type { AgentKind, AgentQueryOptions, PeerRuntimeBridgeContext } from "#types";
47
+ import type {
48
+ ActorTopicScope,
49
+ AgentKind,
50
+ AgentQueryOptions,
51
+ PeerRuntimeBridgeContext,
52
+ RemoteSessionGrant,
53
+ } from "#types";
45
54
 
46
55
  export type { RuntimeMcpScope } from "#platform/mcp-catalog-policy";
47
56
 
@@ -92,6 +101,14 @@ function buildBuiltinMcpServer(
92
101
  const agent = ctx.agent ?? FALLBACK_AGENT;
93
102
  return buildHostedMcpSpec(agent, surface, {
94
103
  userId: ctx.userId,
104
+ ...(ctx.actorUserId ? { actorUserId: ctx.actorUserId } : {}),
105
+ ...(ctx.actorTopicScope ? { actorTopicScope: ctx.actorTopicScope } : {}),
106
+ // Only the session-comm surface addresses remote rooms; the other hosted
107
+ // tokens (and the runtime token) never carry the grant, so it costs them
108
+ // no URL budget.
109
+ ...(surface === "session-comm" && ctx.remoteSession
110
+ ? { remoteSession: ctx.remoteSession }
111
+ : {}),
95
112
  topicTitle: ctx.session,
96
113
  ...(ctx.topicId ? { topicId: ctx.topicId } : {}),
97
114
  ...(ctx.queryId ? { queryId: ctx.queryId } : {}),
@@ -221,6 +238,10 @@ export interface RuntimeMcpBuildContext {
221
238
  userId: string;
222
239
  /** Product actor identity when it differs from the execution principal. */
223
240
  actorUserId?: string;
241
+ /** Hub-asserted rooms the product actor may reach; forwarded into signed tokens. */
242
+ actorTopicScope?: ActorTopicScope;
243
+ /** Hub-issued per-turn remote session-comm grant; session-comm only. */
244
+ remoteSession?: RemoteSessionGrant;
224
245
  /** Vault namespace when credentials belong to a different principal. */
225
246
  vaultUserId?: string;
226
247
  /** "dm" for DM scope, topic/session name for forum/fork. */
@@ -375,6 +396,7 @@ const MCP_CATALOG: Record<string, RuntimeMcpCatalogEntry> = {
375
396
  build({
376
397
  userId,
377
398
  actorUserId,
399
+ actorTopicScope,
378
400
  session,
379
401
  topicId,
380
402
  queryId,
@@ -389,6 +411,7 @@ const MCP_CATALOG: Record<string, RuntimeMcpCatalogEntry> = {
389
411
  peerBridge,
390
412
  }) {
391
413
  if (!topicId || !agent) return null;
414
+ const switchTargets = explicitAgentSwitchTargets(currentUserPrompt);
392
415
  return buildRuntimeMcpSpec(agent, {
393
416
  // Runtime tools mutate node-owned state, so authorization stays bound
394
417
  // to the canonical execution principal. Keep the product-side human
@@ -396,13 +419,17 @@ const MCP_CATALOG: Record<string, RuntimeMcpCatalogEntry> = {
396
419
  // that appears in this node's topic participant roster.
397
420
  userId,
398
421
  ...(actorUserId ? { actorUserId } : {}),
422
+ ...(actorTopicScope ? { actorTopicScope } : {}),
399
423
  topicId,
400
424
  topicTitle: session,
401
425
  queryId,
402
426
  cwd: cwd ?? resolveTopicWorkspaceDir(topicId),
403
427
  agent,
404
428
  model,
405
- currentUserPrompt,
429
+ // Derived here, from the full prompt, so the signed token carries the
430
+ // gate's answer (bounded) instead of the prompt (unbounded); see
431
+ // `explicitAgentSwitchTargets`.
432
+ ...(switchTargets.length ? { explicitAgentSwitchTargets: switchTargets } : {}),
406
433
  autoContinue,
407
434
  visualTools,
408
435
  fileDeliveryTools,
@@ -484,6 +511,9 @@ const MCP_CATALOG: Record<string, RuntimeMcpCatalogEntry> = {
484
511
  build(ctx) {
485
512
  const {
486
513
  userId,
514
+ actorUserId,
515
+ actorTopicScope,
516
+ remoteSession,
487
517
  session,
488
518
  topicId,
489
519
  subagentParentTopicId,
@@ -499,6 +529,13 @@ const MCP_CATALOG: Record<string, RuntimeMcpCatalogEntry> = {
499
529
  : undefined;
500
530
  const args = [
501
531
  `--user-id=${userId}`,
532
+ ...(actorUserId ? [`--actor-user-id=${actorUserId}`] : []),
533
+ ...(actorTopicScope
534
+ ? [`--actor-topic-scope=${encodeActorTopicScopeArg(actorTopicScope)}`]
535
+ : []),
536
+ ...(remoteSession
537
+ ? [`--remote-session-grant=${encodeRemoteSessionGrantArg(remoteSession)}`]
538
+ : []),
502
539
  `--topic=${session}`,
503
540
  ...(topicId ? [`--topic-id=${topicId}`] : []),
504
541
  ...(subagentParentTopicId ? [`--subagent-parent-topic-id=${subagentParentTopicId}`] : []),
@@ -925,6 +962,9 @@ export function getDmMcpServers(opts: {
925
962
  */
926
963
  export function getManagerMcpServers(opts: {
927
964
  userId: string;
965
+ actorUserId?: string;
966
+ actorTopicScope?: ActorTopicScope;
967
+ remoteSession?: RemoteSessionGrant;
928
968
  session?: string;
929
969
  topicId?: string;
930
970
  queryId?: string;
@@ -945,6 +985,9 @@ export function getManagerMcpServers(opts: {
945
985
  const topicId = opts.topicId;
946
986
  return buildScope("manager", {
947
987
  userId: opts.userId,
988
+ actorUserId: opts.actorUserId,
989
+ actorTopicScope: opts.actorTopicScope,
990
+ remoteSession: opts.remoteSession,
948
991
  session: opts.session ?? "General",
949
992
  topicId,
950
993
  queryId: opts.queryId,
@@ -970,6 +1013,8 @@ export function getManagerMcpServers(opts: {
970
1013
  export function getForumMcpServers(opts: {
971
1014
  userId: string;
972
1015
  actorUserId?: string;
1016
+ actorTopicScope?: ActorTopicScope;
1017
+ remoteSession?: RemoteSessionGrant;
973
1018
  vaultUserId?: string;
974
1019
  session: string;
975
1020
  topicId?: string;
@@ -996,6 +1041,8 @@ export function getForumMcpServers(opts: {
996
1041
  const {
997
1042
  userId,
998
1043
  actorUserId,
1044
+ actorTopicScope,
1045
+ remoteSession,
999
1046
  vaultUserId,
1000
1047
  session,
1001
1048
  topicId,
@@ -1033,6 +1080,8 @@ export function getForumMcpServers(opts: {
1033
1080
  {
1034
1081
  userId,
1035
1082
  actorUserId,
1083
+ actorTopicScope,
1084
+ remoteSession,
1036
1085
  vaultUserId,
1037
1086
  session,
1038
1087
  topicId,
@@ -1146,6 +1195,9 @@ export function getMcpServersForQuery(opts: AgentQueryOptions): Record<string, u
1146
1195
  mergeHostMcpServers(
1147
1196
  getManagerMcpServers({
1148
1197
  userId: opts.userId || "local",
1198
+ actorUserId: opts.actorUserId,
1199
+ actorTopicScope: opts.actorTopicScope,
1200
+ remoteSession: opts.remoteSession,
1149
1201
  session: opts.session,
1150
1202
  topicId: opts.topicId,
1151
1203
  queryId: opts.queryId,
@@ -1169,6 +1221,8 @@ export function getMcpServersForQuery(opts: AgentQueryOptions): Record<string, u
1169
1221
  getForumMcpServers({
1170
1222
  userId: opts.userId || "local",
1171
1223
  actorUserId: opts.actorUserId,
1224
+ actorTopicScope: opts.actorTopicScope,
1225
+ remoteSession: opts.remoteSession,
1172
1226
  vaultUserId: opts.vaultUserId,
1173
1227
  session: opts.session || "default",
1174
1228
  topicId: opts.topicId,
@@ -120,6 +120,12 @@ export interface RoomQueryControl {
120
120
  prompt: string;
121
121
  /** Original user submissions represented by prompt, in arrival order. */
122
122
  userMessages?: UserTurnEnvelope[];
123
+ /**
124
+ * Product actor a user turn speaks for, when recorded. Decides whether a
125
+ * later human message may steer this turn: only the same actor's follow-up
126
+ * aborts and folds into it; anyone else's waits its turn in the queue.
127
+ */
128
+ actorUserId?: string;
123
129
  /** Durable request rows whose messages are already represented by userMessages. */
124
130
  durableRequestIds?: string[];
125
131
  /** Attachment ids currently being processed. User-turn preemption carries them forward. */