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
@@ -9,8 +9,18 @@
9
9
  */
10
10
 
11
11
  import { createHash, createHmac, timingSafeEqual } from "node:crypto";
12
+ import { isExplicitAgentSwitchTargets } from "#agents/explicit-agent-switch";
12
13
  import { NEGOTIUM_PORT, RUNTIME_MCP_SECRET } from "#platform/config";
13
- import { type AgentKind, isAgentKind, type PeerRuntimeBridgeContext } from "#types";
14
+ import { logger } from "#platform/logger";
15
+ import { isActorTopicScope } from "#runtime/actor-topic-scope";
16
+ import { isRemoteSessionGrant } from "#runtime/remote-session-grant";
17
+ import {
18
+ type ActorTopicScope,
19
+ type AgentKind,
20
+ isAgentKind,
21
+ type PeerRuntimeBridgeContext,
22
+ type RemoteSessionGrant,
23
+ } from "#types";
14
24
 
15
25
  export const RUNTIME_MCP_KEY = "runtime";
16
26
 
@@ -30,18 +40,80 @@ export type HostedMcpSurface = (typeof HOSTED_MCP_SURFACES)[number];
30
40
  const TOKEN_TTL_MS = 4 * 60 * 60 * 1000;
31
41
  const CLAUDE_MCP_TOOL_TIMEOUT_MS = 600_000;
32
42
 
43
+ /**
44
+ * Hard ceiling on the length of a built-in MCP URL (path plus `?token=...`).
45
+ *
46
+ * The signed per-turn token rides the URL query of an `http://127.0.0.1`
47
+ * request, and `Bun.serve` answers 431 once the request line is too long:
48
+ * measured on Bun 1.3.14 with a plain `fetch`, a 16,205-character URL is
49
+ * served (200) and a 16,305-character one is refused (431). The budget stays
50
+ * 1,869 characters under the last known-good length so the server, the
51
+ * agent's own client and a slightly different Bun release all have room.
52
+ *
53
+ * Real sizes (UUID ids, base64url growth of 4/3 on the JSON payload):
54
+ * - runtime token, no assertion, every optional field set (peer bridge,
55
+ * thread, 64-char title, workspace cwd, all three explicit-switch
56
+ * targets): ~1,150-character URL;
57
+ * - the same with the largest assertion `validateActorTopicScope` admits
58
+ * (8,159 bytes of JSON = 208 UUIDs): 12,052–12,055 characters, under this
59
+ * budget with ~2,280 characters to spare for an unusually long cwd or title;
60
+ * the hosted tokens (`buildHostedMcpSpec`) with the same assertion measure
61
+ * 12,100–12,118, so neither variant is the "small" one.
62
+ * The token has no free-text field: the user's prompt never rides it. It used
63
+ * to, and an uncapped prompt made the assertion cap meaningless (3,000 Korean
64
+ * characters on top of the maximal assertion gave a 24,009-character URL and
65
+ * 10,000 gave 52,009 — every built-in MCP unreachable for that turn), while a
66
+ * capped one broke `set_agent` for any request phrased after the cap. The
67
+ * prompt's only consumer was that gate, so the node now derives the gate's
68
+ * answer from the full prompt when it mints the token
69
+ * (`explicitAgentSwitchTargets`, at most three agent names) and signs that.
70
+ *
71
+ * Over budget, {@link buildRuntimeMcpSpec} throws
72
+ * {@link McpUrlBudgetExceededError} rather than minting a URL that will 431.
73
+ * Nothing is dropped or trimmed to fit: every remaining field either
74
+ * authorizes (ids, assertion, switch targets) or changes behaviour (thread,
75
+ * capabilities, bridge), and a token missing one would widen or alter the
76
+ * turn, not narrow it.
77
+ */
78
+ export const MCP_URL_BUDGET_CHARS = 14 * 1024;
79
+
80
+ export class McpUrlBudgetExceededError extends Error {
81
+ constructor(
82
+ readonly surface: string,
83
+ readonly urlLength: number,
84
+ ) {
85
+ super(
86
+ `built-in MCP URL for "${surface}" is ${urlLength} characters; the transport allows at most ${MCP_URL_BUDGET_CHARS}`,
87
+ );
88
+ this.name = "McpUrlBudgetExceededError";
89
+ }
90
+ }
91
+
33
92
  export interface RuntimeMcpContext {
34
93
  /** Canonical principal authorized against this node's topic roster. */
35
94
  userId: string;
36
95
  /** Product-side human actor when it differs from the execution principal. */
37
96
  actorUserId?: string;
97
+ /**
98
+ * Hub-asserted rooms `actorUserId` may reach on this node. Signed with the
99
+ * rest of the context and authoritative when present (no lineage is added);
100
+ * absent means the tools reach only the current room plus its own subagent
101
+ * lineage.
102
+ */
103
+ actorTopicScope?: ActorTopicScope;
38
104
  topicId: string;
39
105
  topicTitle: string;
40
106
  queryId?: string;
41
107
  cwd: string;
42
108
  agent: AgentKind;
43
109
  model?: string;
44
- currentUserPrompt?: string;
110
+ /**
111
+ * Agents the current user message explicitly asked to switch to, derived
112
+ * from the full prompt at mint time (`explicitAgentSwitchTargets`). Signed
113
+ * with the rest of the context, so the agent cannot grant itself a switch;
114
+ * absent or empty means `set_agent` is refused this turn.
115
+ */
116
+ explicitAgentSwitchTargets?: AgentKind[];
45
117
  autoContinue?: boolean;
46
118
  /** Capability minted by the adapter. Visual tools are absent unless true. */
47
119
  visualTools?: boolean;
@@ -62,6 +134,19 @@ export interface RuntimeMcpContext {
62
134
  /** Signed identity/capability context shared by hosted built-in MCP surfaces. */
63
135
  export interface HostedMcpContext {
64
136
  userId: string;
137
+ /** Product-side human actor when it differs from the execution principal. */
138
+ actorUserId?: string;
139
+ /** Hub-asserted rooms `actorUserId` may reach; see {@link RuntimeMcpContext}. */
140
+ actorTopicScope?: ActorTopicScope;
141
+ /**
142
+ * Hub-issued per-turn authority for remote (`node/topic`) session-comm.
143
+ * Carried by the `session-comm` audience only. Signed with the rest of the
144
+ * context so the agent cannot mint or swap one; the capability inside is a
145
+ * bearer for the hub, which the node cannot verify (it holds no key) and
146
+ * only ever decodes for the untrusted `e` expiry it uses to order grants
147
+ * when several requests fold into one turn.
148
+ */
149
+ remoteSession?: RemoteSessionGrant;
65
150
  topicTitle: string;
66
151
  topicId?: string;
67
152
  queryId?: string;
@@ -136,9 +221,11 @@ function isRuntimeMcpContext(value: unknown): value is RuntimeMcpContext {
136
221
  typeof ctx.agent === "string" &&
137
222
  isAgentKind(ctx.agent) &&
138
223
  (ctx.actorUserId === undefined || typeof ctx.actorUserId === "string") &&
224
+ (ctx.actorTopicScope === undefined || isActorTopicScope(ctx.actorTopicScope)) &&
139
225
  (ctx.queryId === undefined || typeof ctx.queryId === "string") &&
140
226
  (ctx.model === undefined || typeof ctx.model === "string") &&
141
- (ctx.currentUserPrompt === undefined || typeof ctx.currentUserPrompt === "string") &&
227
+ (ctx.explicitAgentSwitchTargets === undefined ||
228
+ isExplicitAgentSwitchTargets(ctx.explicitAgentSwitchTargets)) &&
142
229
  (ctx.autoContinue === undefined || typeof ctx.autoContinue === "boolean") &&
143
230
  (ctx.visualTools === undefined || typeof ctx.visualTools === "boolean") &&
144
231
  (ctx.fileDeliveryTools === undefined || typeof ctx.fileDeliveryTools === "boolean") &&
@@ -163,6 +250,9 @@ function isHostedMcpContext(value: unknown): value is HostedMcpContext {
163
250
  typeof ctx.cwd === "string" &&
164
251
  typeof ctx.agent === "string" &&
165
252
  isAgentKind(ctx.agent) &&
253
+ (ctx.actorUserId === undefined || typeof ctx.actorUserId === "string") &&
254
+ (ctx.actorTopicScope === undefined || isActorTopicScope(ctx.actorTopicScope)) &&
255
+ (ctx.remoteSession === undefined || isRemoteSessionGrant(ctx.remoteSession)) &&
166
256
  (ctx.topicId === undefined || typeof ctx.topicId === "string") &&
167
257
  (ctx.queryId === undefined || typeof ctx.queryId === "string") &&
168
258
  (ctx.wikiTopicId === undefined || typeof ctx.wikiTopicId === "string") &&
@@ -247,18 +337,35 @@ export function buildRuntimeMcpSpec(
247
337
  agent: AgentKind,
248
338
  ctx: RuntimeMcpContext,
249
339
  ): Record<string, unknown> {
250
- const token = issueRuntimeMcpToken(ctx);
251
340
  const base = `http://127.0.0.1:${runtimePort}${RUNTIME_MCP_BASE_PATH}`;
252
- const query = `token=${encodeURIComponent(token)}`;
253
- if (agent === "codex") return { url: `${base}/mcp?${query}` };
341
+ const query = `token=${encodeURIComponent(issueRuntimeMcpToken(ctx))}`;
342
+ const url = agent === "codex" ? `${base}/mcp?${query}` : `${base}/sse?${query}`;
343
+ assertMcpUrlWithinBudget(RUNTIME_MCP_KEY, url, ctx.topicId);
344
+ if (agent === "codex") return { url };
254
345
  return {
255
346
  type: "sse" as const,
256
- url: `${base}/sse?${query}`,
347
+ url,
257
348
  timeout: CLAUDE_MCP_TOOL_TIMEOUT_MS,
258
349
  ...(agent === "maestro" ? { lifecycle: "turn" as const } : {}),
259
350
  };
260
351
  }
261
352
 
353
+ /**
354
+ * Refuse loudly rather than hand the agent a URL the server will 431. This
355
+ * fails the turn's MCP setup, which is the safe outcome: the alternative —
356
+ * minting the token without the fields that do not fit — would silently
357
+ * widen the turn whenever the field that did not fit was the assertion.
358
+ */
359
+ function assertMcpUrlWithinBudget(surface: string, url: string, topicId: string | undefined) {
360
+ if (url.length <= MCP_URL_BUDGET_CHARS) return;
361
+ const error = new McpUrlBudgetExceededError(surface, url.length);
362
+ logger.error(
363
+ { topicId, surface, urlLength: url.length, budget: MCP_URL_BUDGET_CHARS },
364
+ "built-in MCP URL exceeds the transport budget; refusing to mint the token",
365
+ );
366
+ throw error;
367
+ }
368
+
262
369
  function hostedMcpCacheIdentity(surface: HostedMcpSurface, ctx: HostedMcpContext): string {
263
370
  let semanticContext: unknown;
264
371
  switch (surface) {
@@ -289,6 +396,14 @@ function hostedMcpCacheIdentity(surface: HostedMcpSurface, ctx: HostedMcpContext
289
396
  case "session-comm":
290
397
  semanticContext = {
291
398
  userId: ctx.userId,
399
+ // Who may reach what changes per turn (a different person, or a hub
400
+ // that has since changed a roster), so a cached server must not carry
401
+ // an earlier turn's reach into this one.
402
+ actorUserId: ctx.actorUserId ?? null,
403
+ actorTopicScope: ctx.actorTopicScope ?? null,
404
+ // The grant is per turn (bound to this turn's request id), so a
405
+ // server cached with one must not answer a later turn with it.
406
+ remoteSession: ctx.remoteSession ?? null,
292
407
  topicTitle: ctx.topicTitle,
293
408
  topicId: ctx.topicId ?? null,
294
409
  subagentParentTopicId: ctx.subagentParentTopicId ?? null,
@@ -319,13 +434,33 @@ export function buildHostedMcpSpec(
319
434
  const token = issueHostedMcpToken(surface, ctx);
320
435
  const base = `http://127.0.0.1:${runtimePort}${RUNTIME_MCP_BASE_PATH}/${surface}`;
321
436
  const query = `token=${encodeURIComponent(token)}`;
322
- if (agent === "codex") return { url: `${base}/mcp?${query}` };
437
+ const url = agent === "codex" ? `${base}/mcp?${query}` : `${base}/sse?${query}`;
438
+ // A hosted context has no free-text field either. Measured with the
439
+ // maximal assertion (208 UUIDs, 8,159 bytes) and every optional field set,
440
+ // the hosted URL is marginally *longer* than the runtime one — ~12,118
441
+ // characters for `session-comm`, 12,100–12,109 for the other surfaces,
442
+ // against 12,052–12,055 for the runtime token — because the surface name
443
+ // and lifecycle fields outweigh the runtime token's switch-target list.
444
+ // All stay ≥ 2,200 characters under budget. The session-comm token may
445
+ // additionally carry a remote-session grant. Measured in
446
+ // `hosted-runtime-spec.test.ts`: the hub's real grant (≈440-character
447
+ // capability, short hub URL) adds 682 characters — 12,800 beside the
448
+ // maximal assertion, ~1,500 to spare. A grant at both caps (512-char URL,
449
+ // 2 KiB capability) adds ≈3,475 and fits alone (4,689) but not beside the
450
+ // maximal assertion (15,593); the hard check below refuses that mint, so
451
+ // the caps bound the grant while the budget bounds the token — the largest
452
+ // capability that fits beside a maximal assertion is ≈1,590 characters.
453
+ // The check is the same on purpose, so a field added later cannot quietly
454
+ // push it past the transport.
455
+ assertMcpUrlWithinBudget(surface, url, ctx.topicId);
456
+ if (agent === "codex") return { url };
323
457
  const queryBound =
324
- surface === "session-comm" && (ctx.silent === true || ctx.peerBridge !== undefined);
458
+ surface === "session-comm" &&
459
+ (ctx.silent === true || ctx.peerBridge !== undefined || ctx.remoteSession !== undefined);
325
460
  const lifecycle = queryBound ? "turn" : surface === "session-comm" ? "session" : "process";
326
461
  return {
327
462
  type: "sse" as const,
328
- url: `${base}/sse?${query}`,
463
+ url,
329
464
  timeout: CLAUDE_MCP_TOOL_TIMEOUT_MS,
330
465
  ...(agent === "maestro"
331
466
  ? {
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Actor-facing rules shared by the two session-comm implementations — the
3
+ * hosted `default-host.ts` and the stdio `server.ts`. They used to apply the
4
+ * owner check and the peer rules independently and drifted (the stdio server
5
+ * lost the owner check on `abort_session`); one module keeps them identical.
6
+ */
7
+ import { actorOwnedTopicIds } from "#runtime/actor-topic-reach";
8
+ import type { ActorTopicScope, RemoteSessionGrant } from "#types";
9
+
10
+ export interface ActorTopicPolicyInput {
11
+ surface: string | undefined;
12
+ currentTopicId: string | undefined;
13
+ actorTopicScope: ActorTopicScope | undefined;
14
+ }
15
+
16
+ /**
17
+ * Whether the calling turn may stop work in `targetTopicId`, and the refusal
18
+ * to return when it may not. Stopping someone else's work is an owner's call:
19
+ * on `otium` that is the hub's owner assertion when the turn carries one, and
20
+ * only the current room's own subagent workers when it does not (a turn no
21
+ * person started). A room the actor can see but does not own answers as if
22
+ * it were not there, so the refusal reveals nothing the listing did not.
23
+ * Off `otium` the node's membership already decided; returns `null`.
24
+ */
25
+ export function abortTargetRefusal(
26
+ input: ActorTopicPolicyInput & { targetTopicId: string; to: string },
27
+ ): string | null {
28
+ const owned = actorOwnedTopicIds(input);
29
+ if (owned && !owned.has(input.targetTopicId)) return `Error: Session "${input.to}" not found.`;
30
+ return null;
31
+ }
32
+
33
+ /**
34
+ * Whether remote (`node/topic`) session-comm is available to this turn, and
35
+ * the refusal to return when it is not.
36
+ *
37
+ * A peer call carries the node's execution principal and, at most, the hub
38
+ * query id of a worker turn — never who spoke or which rooms they may reach.
39
+ * Off `otium` that is fine: the principal *is* the person. On `otium` the
40
+ * principal is the hub itself and sits in every room, so a peer call would be
41
+ * authorized as the hub, for anyone. There the only remote path is the hub
42
+ * itself: a turn the hub started for a person carries a per-turn
43
+ * {@link RemoteSessionGrant} and the remote branches go to the hub, which
44
+ * re-checks that person's membership on every call. Without a grant — an
45
+ * older hub, the feature off, or a turn no person started — the remote
46
+ * branches stay fail-closed: remote listings contribute nothing and remote
47
+ * tell/ask/abort refuse.
48
+ */
49
+ export function remotePeerRefusal(
50
+ surface: string | undefined,
51
+ remoteSession?: RemoteSessionGrant,
52
+ ): string | null {
53
+ if (surface !== "otium") return null;
54
+ if (remoteSession) return null;
55
+ return "Error: remote sessions (node/topic) cannot be reached from an Otium room: this turn carries no hub remote-session grant, so the call cannot be authorized for the person who spoke.";
56
+ }
57
+
58
+ /**
59
+ * Which transport the remote (`node/topic`) branch of a session-comm tool
60
+ * uses for this turn — the hub with the turn's grant, the legacy peer bridge
61
+ * off `otium`, or nothing (with the refusal to return).
62
+ */
63
+ export type RemoteSessionRoute =
64
+ | { kind: "hub"; grant: RemoteSessionGrant }
65
+ | { kind: "peer" }
66
+ | { kind: "refused"; error: string };
67
+
68
+ export function remoteSessionRoute(
69
+ surface: string | undefined,
70
+ remoteSession: RemoteSessionGrant | undefined,
71
+ ): RemoteSessionRoute {
72
+ if (surface === "otium" && remoteSession) return { kind: "hub", grant: remoteSession };
73
+ const refused = remotePeerRefusal(surface, remoteSession);
74
+ if (refused) return { kind: "refused", error: refused };
75
+ return { kind: "peer" };
76
+ }
77
+
78
+ /**
79
+ * Whether the session-comm catalog on this surface drops rooms that have no
80
+ * AI. On `otium` a human-only channel is never a target — it cannot be told,
81
+ * asked or aborted — so it is excluded at the catalog level rather than
82
+ * listed idle and refused later. Other surfaces keep listing them so the
83
+ * "has no AI agent" explanation stays reachable.
84
+ */
85
+ export function excludesAgentlessTargets(surface: string | undefined): boolean {
86
+ return surface === "otium";
87
+ }
@@ -1,8 +1,26 @@
1
- import type { AgentKind } from "#types";
1
+ import { parseActorTopicScope } from "#runtime/actor-topic-scope";
2
+ import { decodeRemoteSessionGrantArg } from "#runtime/remote-session-grant";
3
+ import type { ActorTopicScope, AgentKind, RemoteSessionGrant } from "#types";
2
4
  import { isAgentKind } from "#types";
3
5
 
4
6
  export interface SessionCommContext {
5
7
  userId: string;
8
+ /** Product-side human actor when it differs from the execution principal. */
9
+ actorUserId?: string;
10
+ /**
11
+ * Hub-asserted rooms the actor may reach. Only consulted on the `otium`
12
+ * surface, where this node holds no per-person membership. Authoritative
13
+ * when present (exactly the current room plus the asserted rooms, no
14
+ * lineage); absent there (a turn no person started) means the tools reach
15
+ * only the current room plus its own subagent lineage.
16
+ */
17
+ actorTopicScope?: ActorTopicScope;
18
+ /**
19
+ * Hub-issued per-turn authority for the remote (`node/topic`) branches on
20
+ * the `otium` surface. Opaque to the node; presented to the hub as a bearer.
21
+ * Absent means those branches are fail-closed there.
22
+ */
23
+ remoteSession?: RemoteSessionGrant;
6
24
  currentTopic: string;
7
25
  currentTopicId?: string;
8
26
  /** Thread the calling turn is answering inside, when it is answering in one. */
@@ -41,8 +59,31 @@ export function parseSessionCommContext(
41
59
  if (!Number.isInteger(depth) || depth < 0) {
42
60
  throw new Error(`Invalid --depth arg: ${depthValue}`);
43
61
  }
62
+ const scopeValue = value(args, "actor-topic-scope");
63
+ let actorTopicScope: ActorTopicScope | undefined;
64
+ if (scopeValue) {
65
+ let decoded: unknown;
66
+ try {
67
+ decoded = JSON.parse(Buffer.from(scopeValue, "base64url").toString("utf-8"));
68
+ } catch {
69
+ throw new Error("Invalid --actor-topic-scope arg");
70
+ }
71
+ const parsed = parseActorTopicScope(decoded);
72
+ if (!parsed) throw new Error("Invalid --actor-topic-scope arg");
73
+ actorTopicScope = parsed;
74
+ }
75
+ const grantValue = value(args, "remote-session-grant");
76
+ let remoteSession: RemoteSessionGrant | undefined;
77
+ if (grantValue) {
78
+ const decoded = decodeRemoteSessionGrantArg(grantValue);
79
+ if (!decoded) throw new Error("Invalid --remote-session-grant arg");
80
+ remoteSession = decoded;
81
+ }
44
82
  return {
45
83
  userId: value(args, "user-id") ?? defaults.userId,
84
+ actorUserId: value(args, "actor-user-id") || undefined,
85
+ ...(actorTopicScope ? { actorTopicScope } : {}),
86
+ ...(remoteSession ? { remoteSession } : {}),
46
87
  currentTopic: value(args, "topic") ?? "",
47
88
  currentTopicId: value(args, "topic-id") || undefined,
48
89
  currentThreadRootId: value(args, "thread-root-id") || undefined,
@@ -1,6 +1,18 @@
1
1
  import { basename, join } from "node:path";
2
2
  import type { SessionCommMcpHost, SessionCommMcpResult } from "#mcp/factories/session-comm";
3
+ import {
4
+ abortTargetRefusal,
5
+ excludesAgentlessTargets,
6
+ remoteSessionRoute,
7
+ } from "#mcp/session-comm/actor-policy";
3
8
  import type { SessionCommContext } from "#mcp/session-comm/context";
9
+ import {
10
+ hubRemoteAbort,
11
+ hubRemoteAsk,
12
+ hubRemotePeek,
13
+ hubRemoteSessions,
14
+ hubRemoteTell,
15
+ } from "#mcp/session-comm/hub-remote-session";
4
16
  import { forwardToPeer, peerSessionsForUser } from "#mcp/session-comm/peer-forward";
5
17
  import {
6
18
  canSubagentTellTarget,
@@ -13,6 +25,7 @@ import { logger } from "#platform/logger";
13
25
  import { OPTIONAL_FORUM_MCP_SERVERS, REQUIRED_FORUM_MCP_SERVERS } from "#platform/mcp-config";
14
26
  import { closeBrowserOwnerTabs } from "#platform/playwright/manager";
15
27
  import { deleteManagedBrowserProfile } from "#platform/playwright/profile-management";
28
+ import { actorReachableTopicIds } from "#runtime/actor-topic-reach";
16
29
  import { getRegisteredCronSession } from "#runtime/cron-sessions";
17
30
  import { sanitizeId } from "#security/sanitize";
18
31
  import { getApiTopicConfig, setApiTopicConfig } from "#storage/api-topic-config";
@@ -30,6 +43,7 @@ import {
30
43
  isTopicBrowserProfileOwner,
31
44
  listBrowserProfiles,
32
45
  } from "#storage/browser-profiles";
46
+ import { deleteRemoteSessionAsk, recordRemoteSessionAsk } from "#storage/remote-session";
33
47
  import {
34
48
  clearPendingAsk,
35
49
  createPendingAsk,
@@ -57,6 +71,14 @@ function currentTopic(context: SessionCommContext) {
57
71
  return topic;
58
72
  }
59
73
 
74
+ /** Surface of the room this turn runs in; the host default when unknown. */
75
+ function currentSurface(context: SessionCommContext) {
76
+ return (
77
+ (context.currentTopicId ? getTopic(context.currentTopicId) : null)?.surface ??
78
+ defaultTopicSurface()
79
+ );
80
+ }
81
+
60
82
  function targetCatalog(context: SessionCommContext) {
61
83
  const current = context.currentTopicId ? getTopic(context.currentTopicId) : null;
62
84
  const surface = current?.surface ?? defaultTopicSurface();
@@ -69,15 +91,28 @@ function targetCatalog(context: SessionCommContext) {
69
91
  : surface === "otium"
70
92
  ? defaultSurfaceScope()
71
93
  : null;
94
+ // On `otium` the roster check below matches the hub's execution principal
95
+ // (`local`), which sits in every hub-backed room, not the person who spoke.
96
+ // The hub's signed per-turn assertion is what says which of those rooms this
97
+ // actor is actually in, and it is the whole answer when present. Without one
98
+ // (a turn no person started) only the current room plus its own subagent
99
+ // lineage — direct parent, granted targets, own workers — is reachable.
100
+ const reachable = actorReachableTopicIds({
101
+ surface,
102
+ currentTopicId: context.currentTopicId,
103
+ actorTopicScope: context.actorTopicScope,
104
+ });
72
105
  return createSessionTargetCatalog({
73
106
  currentTopicId: context.currentTopicId,
74
107
  currentTopicName: context.currentTopic,
75
108
  currentSurface: surface,
109
+ excludeAgentless: excludesAgentlessTargets(surface),
76
110
  isAgent: isAgentKind,
77
111
  // Scoped in the store query, not after the fact.
78
112
  listRows: () =>
79
113
  listTopics({ surface, surfaceScope })
80
114
  .filter((topic) => topic.participants.some((p) => p.userId === context.userId))
115
+ .filter((topic) => !reachable || reachable.has(topic.id))
81
116
  .map((topic) => ({
82
117
  id: topic.id,
83
118
  title: topic.title,
@@ -104,6 +139,15 @@ function remoteTarget(context: SessionCommContext, to: string) {
104
139
  return { node: to.slice(0, slash), topic: to.slice(slash + 1) };
105
140
  }
106
141
 
142
+ /** Which transport serves `node/topic` targets for this turn (see `remoteSessionRoute`). */
143
+ function remoteRoute(context: SessionCommContext) {
144
+ return remoteSessionRoute(currentSurface(context), context.remoteSession);
145
+ }
146
+
147
+ function newRequestId(): string {
148
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
149
+ }
150
+
107
151
  function activeQuery(context: SessionCommContext, topicId: string, title: string) {
108
152
  const dir = join(USERS_LOG_DIR, context.userId, "active-queries");
109
153
  const candidates = [join(dir, `${sanitizeId(topicId)}.json`)];
@@ -148,7 +192,29 @@ export function createDefaultSessionCommMcpHost(): SessionCommMcpHost {
148
192
  ({ key, topic }) =>
149
193
  `- ${key}: ${topic.sessionId ? "active" : "fresh-start ready"}${topic.description ? `\n description: ${topic.description.slice(0, 80)}` : ""}`,
150
194
  );
151
- if (!identity.restricted) {
195
+ // Remote rooms are listed only where a remote call could be authorized
196
+ // for this turn (see `remoteSessionRoute`): through the hub with the
197
+ // turn's grant on Otium, through the peer bridge elsewhere, else not.
198
+ const route = identity.restricted ? null : remoteRoute(context);
199
+ if (route?.kind === "hub") {
200
+ const remote = await hubRemoteSessions(route.grant);
201
+ if (!remote.ok) {
202
+ entries.push(`- remote nodes: (${remote.error})`);
203
+ } else {
204
+ for (const node of remote.nodes) {
205
+ if (node.error) {
206
+ entries.push(`- ${node.node}/: (unreachable: ${node.error})`);
207
+ continue;
208
+ }
209
+ for (const session of node.sessions ?? []) {
210
+ if (!session.agent) continue;
211
+ entries.push(
212
+ `- ${node.node}/${session.name}: ${session.status === "active" ? "active" : "fresh-start ready"}${session.description ? `\n description: ${session.description.slice(0, 80)}` : ""}`,
213
+ );
214
+ }
215
+ }
216
+ }
217
+ } else if (route?.kind === "peer") {
152
218
  const peers = await peerSessionsForUser(
153
219
  context.userId,
154
220
  context.peerHostQueryId,
@@ -241,7 +307,7 @@ export function createDefaultSessionCommMcpHost(): SessionCommMcpHost {
241
307
  }
242
308
  },
243
309
 
244
- peekSession(context) {
310
+ async peekSession(context) {
245
311
  const targets = targetCatalog(context).listTargets();
246
312
  const running: string[] = [];
247
313
  const idle: string[] = [];
@@ -258,6 +324,39 @@ export function createDefaultSessionCommMcpHost(): SessionCommMcpHost {
258
324
  userId: context.userId,
259
325
  from: currentRef(context).key,
260
326
  });
327
+ // Remote rooms (v1): the hub's own active/ready view, no elapsed time
328
+ // or task text — the hub does not fan out to every node for a peek.
329
+ const remoteLines: string[] = [];
330
+ const route = remoteRoute(context);
331
+ if (route.kind === "hub") {
332
+ const remote = await hubRemotePeek(route.grant);
333
+ if (!remote.ok) {
334
+ remoteLines.push(`Remote: (${remote.error})`);
335
+ } else {
336
+ const remoteRunning: string[] = [];
337
+ const remoteIdle: string[] = [];
338
+ for (const node of remote.nodes) {
339
+ if (node.error) {
340
+ remoteLines.push(`Remote ${node.node}: (unreachable: ${node.error})`);
341
+ continue;
342
+ }
343
+ for (const session of node.sessions ?? []) {
344
+ // Same policy as the listing: a room with no AI is not a session.
345
+ if (session.agent === null) continue;
346
+ (session.status === "active" ? remoteRunning : remoteIdle).push(
347
+ `${node.node}/${session.name}`,
348
+ );
349
+ }
350
+ }
351
+ remoteLines.push(
352
+ `Remote running: ${remoteRunning.join(", ") || "none"}`,
353
+ `Remote idle: ${remoteIdle.join(", ") || "none"}`,
354
+ ...remote.pendingAsks.map(
355
+ (ask) => `Pending ${ask.to}: ${ask.status} (${ask.requestId})`,
356
+ ),
357
+ );
358
+ }
359
+ }
261
360
  return ok(
262
361
  [
263
362
  `Running: ${running.join(", ") || "none"}`,
@@ -265,6 +364,7 @@ export function createDefaultSessionCommMcpHost(): SessionCommMcpHost {
265
364
  ...pending.map(
266
365
  (ask) => `Pending ${ask.to}: ${describePendingAskState(ask.state)} (${ask.requestId})`,
267
366
  ),
367
+ ...remoteLines,
268
368
  ].join("\n"),
269
369
  );
270
370
  },
@@ -286,10 +386,49 @@ export function createDefaultSessionCommMcpHost(): SessionCommMcpHost {
286
386
  const clearAsk = () =>
287
387
  clearPendingAsk({ userId: context.userId, from: from.key, to, requestId });
288
388
  if (remote) {
389
+ const route = remoteRoute(context);
390
+ if (route.kind === "refused") {
391
+ clearAsk();
392
+ return error(route.error);
393
+ }
289
394
  if (!from.topicId) {
290
395
  clearAsk();
291
396
  return error("Error: current topic id is unavailable.");
292
397
  }
398
+ if (route.kind === "hub") {
399
+ // Durable caller record first: the hub's `ask-reply` delivery is
400
+ // routed back through it, possibly after this process restarted.
401
+ recordRemoteSessionAsk({
402
+ requestId,
403
+ callerTopicId: from.topicId,
404
+ userId: context.userId,
405
+ fromKey: from.key,
406
+ toKey: to,
407
+ ...(context.currentThreadRootId
408
+ ? { callerThreadRootId: context.currentThreadRootId }
409
+ : {}),
410
+ });
411
+ const sent = await hubRemoteAsk(route.grant, {
412
+ requestId,
413
+ to: remote,
414
+ message,
415
+ fromDepth: context.depth,
416
+ fromLabel: { key: from.key, title: from.title },
417
+ });
418
+ if (!sent.ok) {
419
+ if (sent.uncertain) {
420
+ // The hub may have forwarded it. Keep the pending marker so a
421
+ // late answer still lands; the ask TTL cleans up otherwise.
422
+ return ok(
423
+ `Ask sent to "${to}" (delivery unconfirmed: ${sent.error}). request_id: ${requestId}`,
424
+ );
425
+ }
426
+ clearAsk();
427
+ deleteRemoteSessionAsk(requestId);
428
+ return error(`Error: ${sent.error}`);
429
+ }
430
+ return ok(`Ask sent to "${to}". request_id: ${requestId}`);
431
+ }
293
432
  const result = await forwardToPeer({
294
433
  action: "ask",
295
434
  toNode: remote.node,
@@ -395,6 +534,12 @@ export function createDefaultSessionCommMcpHost(): SessionCommMcpHost {
395
534
  async abortSession(context, to) {
396
535
  const remote = remoteTarget(context, to);
397
536
  if (remote) {
537
+ const route = remoteRoute(context);
538
+ if (route.kind === "refused") return error(route.error);
539
+ if (route.kind === "hub") {
540
+ const sent = await hubRemoteAbort(route.grant, { requestId: newRequestId(), to: remote });
541
+ return sent.ok ? ok(`Abort sent to "${to}".`) : error(`Error: ${sent.error}`);
542
+ }
398
543
  const result = await forwardToPeer({
399
544
  action: "abort",
400
545
  toNode: remote.node,
@@ -409,6 +554,14 @@ export function createDefaultSessionCommMcpHost(): SessionCommMcpHost {
409
554
  const targetTopicId = validation.target.topicId;
410
555
  if (!targetTopicId) return error(`Error: "${to}" has no topic id.`);
411
556
  if (targetTopicId === context.currentTopicId) return error("Error: cannot abort self.");
557
+ const refused = abortTargetRefusal({
558
+ surface: currentSurface(context),
559
+ currentTopicId: context.currentTopicId,
560
+ actorTopicScope: context.actorTopicScope,
561
+ targetTopicId,
562
+ to,
563
+ });
564
+ if (refused) return error(refused);
412
565
  enqueueSessionInbox({
413
566
  userId: context.userId,
414
567
  topicId: targetTopicId,
@@ -426,6 +579,29 @@ export function createDefaultSessionCommMcpHost(): SessionCommMcpHost {
426
579
  const from = currentRef(context);
427
580
  const remote = remoteTarget(context, to);
428
581
  if (remote) {
582
+ const route = remoteRoute(context);
583
+ if (route.kind === "refused") return error(route.error);
584
+ if (route.kind === "hub") {
585
+ const requestId = newRequestId();
586
+ const sent = await hubRemoteTell(route.grant, {
587
+ requestId,
588
+ to: remote,
589
+ message,
590
+ depth: context.depth + 1,
591
+ fromLabel: { key: from.key, title: from.title },
592
+ });
593
+ if (!sent.ok) {
594
+ // A timed-out response may still have been delivered; report it
595
+ // as sent-but-unconfirmed rather than invite a duplicate.
596
+ if (sent.uncertain) {
597
+ return ok(
598
+ `Message sent to "${to}" (delivery unconfirmed: ${sent.error}). request_id: ${requestId}`,
599
+ );
600
+ }
601
+ return error(`Error: ${sent.error}`);
602
+ }
603
+ return ok(`Message sent to "${to}". request_id: ${requestId}`);
604
+ }
429
605
  const result = await forwardToPeer({
430
606
  action: "tell",
431
607
  toNode: remote.node,