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.
- package/dist/agent-helpers.js +1146 -212
- package/dist/agent-helpers.js.map +27 -19
- package/dist/background-bash.js.map +1 -1
- package/dist/browser-runtime.js.map +1 -1
- package/dist/{chunk-kkftbxh2.js → chunk-7he9nzfa.js} +1 -1
- package/dist/{chunk-55dd0xw7.js → chunk-ad6xqvqy.js} +2 -2
- package/dist/{chunk-55dd0xw7.js.map → chunk-ad6xqvqy.js.map} +2 -2
- package/dist/{chunk-exqz8bez.js → chunk-hh3f0yr7.js} +3 -3
- package/dist/{chunk-exqz8bez.js.map → chunk-hh3f0yr7.js.map} +2 -2
- package/dist/{chunk-aqqmzmft.js → chunk-nm59q3em.js} +1 -1
- package/dist/{chunk-cv7vdsrn.js → chunk-w3c6fc0p.js} +286 -9
- package/dist/{chunk-cv7vdsrn.js.map → chunk-w3c6fc0p.js.map} +10 -7
- package/dist/hosted-agent.js +290 -12
- package/dist/hosted-agent.js.map +10 -7
- package/dist/main.js +4599 -2724
- package/dist/main.js.map +49 -38
- package/dist/mcp-factories.js +1166 -162
- package/dist/mcp-factories.js.map +27 -19
- package/dist/media.js.map +1 -1
- package/dist/prompts.js.map +1 -1
- package/dist/query-runtime.js.map +3 -3
- package/dist/registry.js +3 -3
- package/dist/rollout.js +2 -2
- package/dist/runtime/src/agents/explicit-agent-switch.ts +101 -0
- package/dist/runtime/src/agents/mcp-tools/spawn-subagent.ts +126 -43
- package/dist/runtime/src/agents/public-helpers.ts +5 -0
- package/dist/runtime/src/agents/self-config-core.ts +28 -58
- package/dist/runtime/src/application/submit-runtime-gateway-turn.ts +34 -8
- package/dist/runtime/src/index.ts +75 -0
- package/dist/runtime/src/mcp/peer-bridge.ts +6 -1
- package/dist/runtime/src/mcp/runtime-spec.ts +145 -10
- package/dist/runtime/src/mcp/session-comm/actor-policy.ts +87 -0
- package/dist/runtime/src/mcp/session-comm/context.ts +42 -1
- package/dist/runtime/src/mcp/session-comm/default-host.ts +178 -2
- package/dist/runtime/src/mcp/session-comm/hub-remote-session.ts +553 -0
- package/dist/runtime/src/mcp/session-comm/limits.ts +8 -0
- package/dist/runtime/src/mcp/session-comm/peer-forward.ts +78 -3
- package/dist/runtime/src/mcp/session-comm/server.ts +167 -4
- package/dist/runtime/src/mcp/session-comm/topic-catalog.ts +7 -0
- package/dist/runtime/src/mcp/session-comm/topics.ts +32 -10
- package/dist/runtime/src/mcp-runtime-host.ts +2 -1
- package/dist/runtime/src/node-host.ts +26 -0
- package/dist/runtime/src/platform/mcp-config.ts +56 -2
- package/dist/runtime/src/query/active-rooms.ts +6 -0
- package/dist/runtime/src/runtime/actor-topic-reach.ts +85 -0
- package/dist/runtime/src/runtime/actor-topic-scope.ts +175 -0
- package/dist/runtime/src/runtime/ask-callbacks.ts +73 -20
- package/dist/runtime/src/runtime/gateway-capabilities.ts +22 -0
- package/dist/runtime/src/runtime/remote-session-grant.ts +221 -0
- package/dist/runtime/src/runtime/remote-session-inbox.ts +476 -0
- package/dist/runtime/src/runtime/remote-session-reply-outbox.ts +156 -0
- package/dist/runtime/src/runtime/turn-runner.ts +157 -33
- package/dist/runtime/src/runtime-gateway.ts +20 -0
- package/dist/runtime/src/storage/api-topics.ts +22 -3
- package/dist/runtime/src/storage/remote-session.ts +526 -0
- package/dist/runtime/src/storage/runtime-turn-requests.ts +75 -2
- package/dist/runtime/src/topics/derive.ts +3 -0
- package/dist/runtime/src/types/api.ts +6 -0
- package/dist/runtime/src/types.ts +42 -0
- package/dist/runtime/src/version.ts +1 -1
- package/dist/runtime-gateway.js.map +2 -2
- package/dist/runtime-helpers.js.map +1 -1
- package/dist/storage.js +16 -4
- package/dist/storage.js.map +4 -4
- package/dist/types/packages/core/src/agents/explicit-agent-switch.d.ts +26 -0
- package/dist/types/packages/core/src/agents/mcp-tools/spawn-subagent.d.ts +12 -2
- package/dist/types/packages/core/src/agents/public-helpers.d.ts +1 -0
- package/dist/types/packages/core/src/agents/self-config-core.d.ts +15 -1
- package/dist/types/packages/core/src/mcp/peer-bridge.d.ts +6 -1
- package/dist/types/packages/core/src/mcp/runtime-spec.d.ts +69 -2
- package/dist/types/packages/core/src/mcp/session-comm/context.d.ts +17 -1
- package/dist/types/packages/core/src/mcp/session-comm/hub-remote-session.d.ts +149 -0
- package/dist/types/packages/core/src/mcp/session-comm/limits.d.ts +8 -0
- package/dist/types/packages/core/src/mcp/session-comm/peer-forward.d.ts +23 -2
- package/dist/types/packages/core/src/mcp/session-comm/topic-catalog.d.ts +6 -0
- package/dist/types/packages/core/src/platform/mcp-config.d.ts +10 -1
- package/dist/types/packages/core/src/query/active-rooms.d.ts +6 -0
- package/dist/types/packages/core/src/runtime/actor-topic-reach.d.ts +49 -0
- package/dist/types/packages/core/src/runtime/actor-topic-scope.d.ts +91 -0
- package/dist/types/packages/core/src/runtime/ask-callbacks.d.ts +8 -2
- package/dist/types/packages/core/src/runtime/remote-session-grant.d.ts +91 -0
- package/dist/types/packages/core/src/runtime/remote-session-inbox.d.ts +83 -0
- package/dist/types/packages/core/src/runtime/remote-session-reply-outbox.d.ts +37 -0
- package/dist/types/packages/core/src/runtime/turn-runner.d.ts +22 -3
- package/dist/types/packages/core/src/runtime-gateway.d.ts +20 -0
- package/dist/types/packages/core/src/storage/api-topics.d.ts +3 -0
- package/dist/types/packages/core/src/storage/remote-session.d.ts +135 -0
- package/dist/types/packages/core/src/storage/runtime-turn-requests.d.ts +12 -1
- package/dist/types/packages/core/src/topics/derive.d.ts +2 -0
- package/dist/types/packages/core/src/types/api.d.ts +6 -0
- package/dist/types/packages/core/src/types.d.ts +40 -0
- package/dist/types/packages/core/src/version.d.ts +1 -1
- package/dist/vault.js.map +1 -1
- package/package.json +1 -1
- /package/dist/{chunk-kkftbxh2.js.map → chunk-7he9nzfa.js.map} +0 -0
- /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 {
|
|
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
|
-
|
|
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.
|
|
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(
|
|
253
|
-
|
|
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
|
|
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
|
-
|
|
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" &&
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|