negotium 0.19.2 → 0.20.1

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 (107) hide show
  1. package/dist/agent-helpers.js +1147 -213
  2. package/dist/agent-helpers.js.map +28 -20
  3. package/dist/background-bash.js +2 -2
  4. package/dist/background-bash.js.map +3 -3
  5. package/dist/browser-runtime.js +2 -2
  6. package/dist/browser-runtime.js.map +3 -3
  7. package/dist/{chunk-aqqmzmft.js → chunk-143j4nxd.js} +1 -1
  8. package/dist/{chunk-55dd0xw7.js → chunk-myhmb8tn.js} +3 -3
  9. package/dist/{chunk-55dd0xw7.js.map → chunk-myhmb8tn.js.map} +3 -3
  10. package/dist/{chunk-cv7vdsrn.js → chunk-t7vs5k9t.js} +287 -10
  11. package/dist/{chunk-cv7vdsrn.js.map → chunk-t7vs5k9t.js.map} +11 -8
  12. package/dist/{chunk-exqz8bez.js → chunk-wpp1x0tx.js} +3 -3
  13. package/dist/{chunk-exqz8bez.js.map → chunk-wpp1x0tx.js.map} +2 -2
  14. package/dist/{chunk-kkftbxh2.js → chunk-zkyp090x.js} +1 -1
  15. package/dist/hosted-agent.js +290 -12
  16. package/dist/hosted-agent.js.map +10 -7
  17. package/dist/main.js +5019 -3134
  18. package/dist/main.js.map +51 -40
  19. package/dist/mcp-factories.js +1167 -163
  20. package/dist/mcp-factories.js.map +28 -20
  21. package/dist/media.js +2 -2
  22. package/dist/media.js.map +3 -3
  23. package/dist/prompts.js +2 -2
  24. package/dist/prompts.js.map +3 -3
  25. package/dist/query-runtime.js +2 -2
  26. package/dist/query-runtime.js.map +5 -5
  27. package/dist/registry.js +3 -3
  28. package/dist/rollout.js +2 -2
  29. package/dist/runtime/src/agents/explicit-agent-switch.ts +101 -0
  30. package/dist/runtime/src/agents/mcp-tools/spawn-subagent.ts +126 -43
  31. package/dist/runtime/src/agents/public-helpers.ts +5 -0
  32. package/dist/runtime/src/agents/self-config-core.ts +28 -58
  33. package/dist/runtime/src/application/submit-runtime-gateway-turn.ts +34 -8
  34. package/dist/runtime/src/index.ts +75 -0
  35. package/dist/runtime/src/mcp/peer-bridge.ts +6 -1
  36. package/dist/runtime/src/mcp/runtime-spec.ts +145 -10
  37. package/dist/runtime/src/mcp/session-comm/actor-policy.ts +87 -0
  38. package/dist/runtime/src/mcp/session-comm/context.ts +42 -1
  39. package/dist/runtime/src/mcp/session-comm/default-host.ts +178 -2
  40. package/dist/runtime/src/mcp/session-comm/hub-remote-session.ts +553 -0
  41. package/dist/runtime/src/mcp/session-comm/limits.ts +8 -0
  42. package/dist/runtime/src/mcp/session-comm/peer-forward.ts +78 -3
  43. package/dist/runtime/src/mcp/session-comm/server.ts +167 -4
  44. package/dist/runtime/src/mcp/session-comm/topic-catalog.ts +7 -0
  45. package/dist/runtime/src/mcp/session-comm/topics.ts +32 -10
  46. package/dist/runtime/src/mcp-runtime-host.ts +2 -1
  47. package/dist/runtime/src/node-host.ts +26 -0
  48. package/dist/runtime/src/platform/config.ts +1 -1
  49. package/dist/runtime/src/platform/mcp-config.ts +56 -2
  50. package/dist/runtime/src/query/active-rooms.ts +6 -0
  51. package/dist/runtime/src/runtime/actor-topic-reach.ts +85 -0
  52. package/dist/runtime/src/runtime/actor-topic-scope.ts +175 -0
  53. package/dist/runtime/src/runtime/ask-callbacks.ts +73 -20
  54. package/dist/runtime/src/runtime/bashrs-completions.ts +19 -1
  55. package/dist/runtime/src/runtime/gateway-capabilities.ts +22 -0
  56. package/dist/runtime/src/runtime/remote-session-grant.ts +221 -0
  57. package/dist/runtime/src/runtime/remote-session-inbox.ts +476 -0
  58. package/dist/runtime/src/runtime/remote-session-reply-outbox.ts +156 -0
  59. package/dist/runtime/src/runtime/turn-runner.ts +157 -33
  60. package/dist/runtime/src/runtime-gateway.ts +20 -0
  61. package/dist/runtime/src/storage/api-topics.ts +22 -3
  62. package/dist/runtime/src/storage/remote-session.ts +526 -0
  63. package/dist/runtime/src/storage/runtime-turn-requests.ts +75 -2
  64. package/dist/runtime/src/topics/derive.ts +3 -0
  65. package/dist/runtime/src/types/api.ts +6 -0
  66. package/dist/runtime/src/types.ts +42 -0
  67. package/dist/runtime/src/version.ts +1 -1
  68. package/dist/runtime-gateway.js.map +2 -2
  69. package/dist/runtime-helpers.js +13 -3
  70. package/dist/runtime-helpers.js.map +5 -5
  71. package/dist/storage.js +16 -4
  72. package/dist/storage.js.map +4 -4
  73. package/dist/types/packages/core/src/agents/explicit-agent-switch.d.ts +26 -0
  74. package/dist/types/packages/core/src/agents/mcp-tools/spawn-subagent.d.ts +12 -2
  75. package/dist/types/packages/core/src/agents/public-helpers.d.ts +1 -0
  76. package/dist/types/packages/core/src/agents/self-config-core.d.ts +15 -1
  77. package/dist/types/packages/core/src/mcp/peer-bridge.d.ts +6 -1
  78. package/dist/types/packages/core/src/mcp/runtime-spec.d.ts +69 -2
  79. package/dist/types/packages/core/src/mcp/session-comm/context.d.ts +17 -1
  80. package/dist/types/packages/core/src/mcp/session-comm/hub-remote-session.d.ts +149 -0
  81. package/dist/types/packages/core/src/mcp/session-comm/limits.d.ts +8 -0
  82. package/dist/types/packages/core/src/mcp/session-comm/peer-forward.d.ts +23 -2
  83. package/dist/types/packages/core/src/mcp/session-comm/topic-catalog.d.ts +6 -0
  84. package/dist/types/packages/core/src/platform/config.d.ts +1 -1
  85. package/dist/types/packages/core/src/platform/mcp-config.d.ts +10 -1
  86. package/dist/types/packages/core/src/query/active-rooms.d.ts +6 -0
  87. package/dist/types/packages/core/src/runtime/actor-topic-reach.d.ts +49 -0
  88. package/dist/types/packages/core/src/runtime/actor-topic-scope.d.ts +91 -0
  89. package/dist/types/packages/core/src/runtime/ask-callbacks.d.ts +8 -2
  90. package/dist/types/packages/core/src/runtime/remote-session-grant.d.ts +91 -0
  91. package/dist/types/packages/core/src/runtime/remote-session-inbox.d.ts +83 -0
  92. package/dist/types/packages/core/src/runtime/remote-session-reply-outbox.d.ts +37 -0
  93. package/dist/types/packages/core/src/runtime/turn-runner.d.ts +22 -3
  94. package/dist/types/packages/core/src/runtime-gateway.d.ts +20 -0
  95. package/dist/types/packages/core/src/storage/api-topics.d.ts +3 -0
  96. package/dist/types/packages/core/src/storage/remote-session.d.ts +135 -0
  97. package/dist/types/packages/core/src/storage/runtime-turn-requests.d.ts +12 -1
  98. package/dist/types/packages/core/src/topics/derive.d.ts +2 -0
  99. package/dist/types/packages/core/src/types/api.d.ts +6 -0
  100. package/dist/types/packages/core/src/types.d.ts +40 -0
  101. package/dist/types/packages/core/src/version.d.ts +1 -1
  102. package/dist/vault.js +2 -2
  103. package/dist/vault.js.map +3 -3
  104. package/install-browser-rs.mjs +4 -4
  105. package/package.json +1 -1
  106. /package/dist/{chunk-aqqmzmft.js.map → chunk-143j4nxd.js.map} +0 -0
  107. /package/dist/{chunk-kkftbxh2.js.map → chunk-zkyp090x.js.map} +0 -0
@@ -0,0 +1,85 @@
1
+ import {
2
+ getTopic,
3
+ listSubagentChildTopicIds,
4
+ listSubagentTellTargetIds,
5
+ } from "#storage/api-topics";
6
+ import type { ActorTopicScope } from "#types";
7
+
8
+ /**
9
+ * Rooms a turn may reach because of lineage this node itself recorded, not
10
+ * because a person is in them: a subagent's direct parent and the targets an
11
+ * ancestor granted it, and a room's own subagent workers.
12
+ *
13
+ * Lineage stands in for an assertion only when there is none — the turns that
14
+ * manage subagents (the spawn, the report back, the follow-up) are turns no
15
+ * person started, they carry no `actorTopicScope`, and cutting them off would
16
+ * break delegation the moment it began. A turn that *does* carry an assertion
17
+ * never gets lineage on top of it: the hub mirrors a subagent room with its
18
+ * parent's roster, so an owner of the parent is asserted as owner of the child
19
+ * and needs no exception, while a member who is not the owner — or a person
20
+ * speaking from a subagent room an ancestor granted rooms outside that
21
+ * person's own — must not inherit authority the hub did not assert.
22
+ * `canSubagentTellTarget` still applies its own rules on top (status-only,
23
+ * grants).
24
+ */
25
+ export function subagentLineageTopicIds(currentTopicId: string | undefined): {
26
+ reachable: Set<string>;
27
+ children: Set<string>;
28
+ } {
29
+ const reachable = new Set<string>();
30
+ const children = new Set<string>();
31
+ if (!currentTopicId) return { reachable, children };
32
+ const current = getTopic(currentTopicId);
33
+ if (current?.isSubagent && current.parentTopicId) {
34
+ reachable.add(current.parentTopicId);
35
+ for (const id of listSubagentTellTargetIds(currentTopicId)) reachable.add(id);
36
+ }
37
+ for (const id of listSubagentChildTopicIds(currentTopicId)) {
38
+ reachable.add(id);
39
+ children.add(id);
40
+ }
41
+ return { reachable, children };
42
+ }
43
+
44
+ /**
45
+ * The rooms cross-room tools may name for this turn.
46
+ *
47
+ * Off the `otium` surface the node owns membership, so nothing narrows the
48
+ * caller's own rooms. On `otium` the hub does, and its assertion is
49
+ * authoritative: with one, exactly the current room plus the asserted visible
50
+ * rooms are reachable — no lineage. Without one (an older hub, or a turn no
51
+ * person started) only the current room and its subagent lineage are
52
+ * reachable (fail-closed).
53
+ */
54
+ export function actorReachableTopicIds(input: {
55
+ surface: string | undefined;
56
+ currentTopicId: string | undefined;
57
+ actorTopicScope: ActorTopicScope | undefined;
58
+ }): ReadonlySet<string> | null {
59
+ if (input.surface !== "otium") return null;
60
+ const ids = new Set<string>();
61
+ if (input.currentTopicId) ids.add(input.currentTopicId);
62
+ if (input.actorTopicScope) {
63
+ for (const id of input.actorTopicScope.visibleNodeTopicIds) ids.add(id);
64
+ return ids;
65
+ }
66
+ for (const id of subagentLineageTopicIds(input.currentTopicId).reachable) ids.add(id);
67
+ return ids;
68
+ }
69
+
70
+ /**
71
+ * Rooms the actor may abort/restart/delete. With an assertion: exactly the
72
+ * rooms the hub says the actor owns (a parent's subagent workers are among
73
+ * them only if the hub asserted so). Without one: the current room's own
74
+ * subagent workers (a room controls what it delegated), nothing else. Same
75
+ * surface rule as {@link actorReachableTopicIds}.
76
+ */
77
+ export function actorOwnedTopicIds(input: {
78
+ surface: string | undefined;
79
+ currentTopicId?: string | undefined;
80
+ actorTopicScope: ActorTopicScope | undefined;
81
+ }): ReadonlySet<string> | null {
82
+ if (input.surface !== "otium") return null;
83
+ if (input.actorTopicScope) return new Set(input.actorTopicScope.ownedNodeTopicIds);
84
+ return subagentLineageTopicIds(input.currentTopicId).children;
85
+ }
@@ -0,0 +1,175 @@
1
+ import type { ActorTopicScope } from "#types";
2
+
3
+ /**
4
+ * Hard caps on a hub-supplied room assertion.
5
+ *
6
+ * The assertion is not stored and forgotten: it rides the signed per-turn MCP
7
+ * token, which every hosted built-in MCP server receives in the URL query
8
+ * (`?token=`) of an `http://127.0.0.1` request, and — with
9
+ * `NEGOTIUM_BUILTIN_MCP_TRANSPORT=stdio` — one base64url argv entry of the
10
+ * session-comm child. The transport, not the database, is the tight bound:
11
+ *
12
+ * - `Bun.serve` answers 431 once the request line is too long (Bun 1.3.14:
13
+ * a 16,205-character URL is served, 16,305 is refused). These caps alone
14
+ * do not bound the URL — the token carries other fields — so the whole URL
15
+ * is budgeted at `MCP_URL_BUDGET_CHARS` (14 KiB) in `runtime-spec.ts`,
16
+ * where the numbers live. The 8 KiB assertion below becomes 10,924
17
+ * base64url characters at most; with every other runtime-token field at
18
+ * its largest (the prompt itself is not in the token — only the derived
19
+ * `explicitAgentSwitchTargets`, at most three agent names) the URL is
20
+ * ≤ ~13.4 KB.
21
+ * - argv: `encodeActorTopicScopeArg` is the assertion alone, ≤ 10,924
22
+ * characters, far below Linux `MAX_ARG_STRLEN` (128 KiB) for one argument
23
+ * and macOS `ARG_MAX` (1 MiB) overall. The encoder re-validates before it
24
+ * encodes, so an in-process caller that bypassed the gateway parser cannot
25
+ * put an unbounded object into argv either.
26
+ *
27
+ * With UUID room ids (39 bytes each serialized) the byte cap admits roughly
28
+ * 208 ids across both lists; the per-list count cap bounds the work done on
29
+ * an assertion before its size is known, and the id length cap keeps a single
30
+ * id from being the whole budget. Raising these means moving the assertion
31
+ * out of the token (server-side per-turn context), not editing the numbers.
32
+ */
33
+ export const ACTOR_TOPIC_SCOPE_LIMITS = Object.freeze({
34
+ /** Ids per list, counted before de-duplication. */
35
+ maxIdsPerList: 200,
36
+ /** Characters per id, after trimming. */
37
+ maxIdLength: 128,
38
+ /** UTF-8 bytes of the normalized assertion as JSON. */
39
+ maxSerializedBytes: 8 * 1024,
40
+ });
41
+
42
+ const SCOPE_KEYS = ["visibleNodeTopicIds", "ownedNodeTopicIds"] as const;
43
+
44
+ export type ActorTopicScopeValidation =
45
+ | { ok: true; scope: ActorTopicScope | undefined }
46
+ | { ok: false; error: string };
47
+
48
+ function idList(value: unknown, name: string): string[] | string {
49
+ if (!Array.isArray(value)) return `${name} must be an array of topic ids`;
50
+ if (value.length > ACTOR_TOPIC_SCOPE_LIMITS.maxIdsPerList) {
51
+ return `${name} has ${value.length} ids; at most ${ACTOR_TOPIC_SCOPE_LIMITS.maxIdsPerList} are allowed`;
52
+ }
53
+ const ids = new Set<string>();
54
+ for (const item of value) {
55
+ if (typeof item !== "string") return `${name} must contain only strings`;
56
+ const id = item.trim();
57
+ if (!id) return `${name} must not contain empty ids`;
58
+ if (id.length > ACTOR_TOPIC_SCOPE_LIMITS.maxIdLength) {
59
+ return `${name} contains an id longer than ${ACTOR_TOPIC_SCOPE_LIMITS.maxIdLength} characters`;
60
+ }
61
+ ids.add(id);
62
+ }
63
+ return [...ids];
64
+ }
65
+
66
+ /**
67
+ * Validate a hub-supplied room assertion and say why it failed.
68
+ *
69
+ * `undefined` in means `undefined` out (no assertion, fail-closed downstream);
70
+ * anything else must be exactly `{ visibleNodeTopicIds, ownedNodeTopicIds }`
71
+ * — a plain object with those two own keys and nothing else (so a stray
72
+ * `__proto__` or `constructor` key is a rejection, not a property), each a
73
+ * list of non-empty strings within {@link ACTOR_TOPIC_SCOPE_LIMITS} — or it is
74
+ * rejected, so a malformed assertion can never be read as "everything".
75
+ */
76
+ export function validateActorTopicScope(value: unknown): ActorTopicScopeValidation {
77
+ if (value === undefined) return { ok: true, scope: undefined };
78
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
79
+ return {
80
+ ok: false,
81
+ error:
82
+ "actorTopicScope must be { visibleNodeTopicIds: string[], ownedNodeTopicIds: string[] }",
83
+ };
84
+ }
85
+ const proto = Object.getPrototypeOf(value);
86
+ if (proto !== Object.prototype && proto !== null) {
87
+ return { ok: false, error: "actorTopicScope must be a plain object" };
88
+ }
89
+ const keys = Object.keys(value);
90
+ const unknown = keys.filter((key) => !(SCOPE_KEYS as readonly string[]).includes(key));
91
+ if (unknown.length || keys.length !== SCOPE_KEYS.length) {
92
+ return {
93
+ ok: false,
94
+ error: unknown.length
95
+ ? `actorTopicScope has unexpected key(s): ${unknown.join(", ")}`
96
+ : "actorTopicScope must have both visibleNodeTopicIds and ownedNodeTopicIds",
97
+ };
98
+ }
99
+ const record = value as Record<string, unknown>;
100
+ const visible = idList(record.visibleNodeTopicIds, "actorTopicScope.visibleNodeTopicIds");
101
+ if (typeof visible === "string") return { ok: false, error: visible };
102
+ const owned = idList(record.ownedNodeTopicIds, "actorTopicScope.ownedNodeTopicIds");
103
+ if (typeof owned === "string") return { ok: false, error: owned };
104
+ const scope: ActorTopicScope = { visibleNodeTopicIds: visible, ownedNodeTopicIds: owned };
105
+ const bytes = Buffer.byteLength(JSON.stringify(scope), "utf-8");
106
+ if (bytes > ACTOR_TOPIC_SCOPE_LIMITS.maxSerializedBytes) {
107
+ return {
108
+ ok: false,
109
+ error: `actorTopicScope is ${bytes} bytes serialized; at most ${ACTOR_TOPIC_SCOPE_LIMITS.maxSerializedBytes} are allowed`,
110
+ };
111
+ }
112
+ return { ok: true, scope };
113
+ }
114
+
115
+ /**
116
+ * Validate a hub-supplied room assertion.
117
+ *
118
+ * `undefined` in means `undefined` out (no assertion, fail-closed downstream);
119
+ * anything else must pass {@link validateActorTopicScope} or it is rejected
120
+ * with `null`. Every path an assertion can enter by — the gateway body, the
121
+ * signed MCP tokens, the stdio argv — goes through this one parser, so the
122
+ * limits cannot drift between them.
123
+ */
124
+ export function parseActorTopicScope(value: unknown): ActorTopicScope | undefined | null {
125
+ const validated = validateActorTopicScope(value);
126
+ return validated.ok ? validated.scope : null;
127
+ }
128
+
129
+ export function isActorTopicScope(value: unknown): value is ActorTopicScope {
130
+ return parseActorTopicScope(value) != null;
131
+ }
132
+
133
+ /**
134
+ * Read a validated assertion out of a persisted or signed context. Anything
135
+ * that does not validate collapses to "no assertion" rather than throwing, so
136
+ * a stale row cannot break a turn — it only narrows it to what a turn without
137
+ * an assertion reaches: the current room plus its own subagent lineage.
138
+ */
139
+ export function actorTopicScopeFrom(value: unknown): ActorTopicScope | undefined {
140
+ return parseActorTopicScope(value) ?? undefined;
141
+ }
142
+
143
+ /**
144
+ * The strictest assertion two requests agree on: an id is kept only when both
145
+ * assert it, and a request without an assertion leaves the result without one
146
+ * (fail-closed). Used when several pending requests of one actor fold into a
147
+ * single turn, so the merged turn never reaches a room one of them could not.
148
+ */
149
+ export function intersectActorTopicScopes(
150
+ scopes: ReadonlyArray<ActorTopicScope | undefined>,
151
+ ): ActorTopicScope | undefined {
152
+ if (scopes.length === 0 || scopes.some((scope) => scope === undefined)) return undefined;
153
+ const [first, ...rest] = scopes as ActorTopicScope[];
154
+ const keep = (key: keyof ActorTopicScope) =>
155
+ first[key].filter((id) => rest.every((scope) => scope[key].includes(id)));
156
+ return {
157
+ visibleNodeTopicIds: keep("visibleNodeTopicIds"),
158
+ ownedNodeTopicIds: keep("ownedNodeTopicIds"),
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Argv-safe encoding for the stdio session-comm server (`--actor-topic-scope=`).
164
+ * Bounded by {@link ACTOR_TOPIC_SCOPE_LIMITS}: 8,192 bytes of JSON become at
165
+ * most 10,924 base64url characters, see there. The input is put through
166
+ * {@link validateActorTopicScope} first and an invalid or oversized assertion
167
+ * throws — it is never truncated (which would narrow the turn silently) nor
168
+ * passed on (which would let a typed in-process caller overflow argv).
169
+ */
170
+ export function encodeActorTopicScopeArg(scope: ActorTopicScope): string {
171
+ const validated = validateActorTopicScope(scope);
172
+ if (!validated.ok) throw new Error(validated.error);
173
+ if (!validated.scope) throw new Error("actorTopicScope is required");
174
+ return Buffer.from(JSON.stringify(validated.scope), "utf-8").toString("base64url");
175
+ }
@@ -9,7 +9,12 @@
9
9
  * (the ask/tell REST routes stayed in the host).
10
10
  */
11
11
 
12
- import { deliverPeerReply, type RemoteReplyRoute } from "#mcp/session-comm/peer-forward";
12
+ import {
13
+ deliverPeerReply,
14
+ isHubRemoteReplyRoute,
15
+ parseHubRemoteReplyRoute,
16
+ type RemoteReplyRoute,
17
+ } from "#mcp/session-comm/peer-forward";
13
18
  import { db } from "#storage/forum-db";
14
19
  import { PENDING_ASK_TTL_MS, type PendingAskUserId } from "#storage/session-asks";
15
20
  import { registerStorageSchemaInitializer } from "#storage/storage-host";
@@ -31,6 +36,13 @@ registerStorageSchemaInitializer((database) => {
31
36
  created_at INTEGER NOT NULL
32
37
  )
33
38
  `);
39
+ // Hub-routed asks keep their whole route here; the legacy columns are
40
+ // blank for them. Added after the table existed, so guarded.
41
+ try {
42
+ database.exec("ALTER TABLE remote_ask_callbacks ADD COLUMN route_json TEXT");
43
+ } catch {
44
+ // Column already present.
45
+ }
34
46
  });
35
47
 
36
48
  interface PendingAskIdentity {
@@ -65,25 +77,29 @@ const MAX_ASK_AGE_MS = PENDING_ASK_TTL_MS;
65
77
  export function registerAskCallback(entry: AskPending): void {
66
78
  pendingAsks.set(entry.targetQueryId, entry);
67
79
  if (entry.remoteReply) {
80
+ const route = entry.remoteReply;
81
+ const hub = isHubRemoteReplyRoute(route);
68
82
  db.query(
69
83
  `INSERT INTO remote_ask_callbacks
70
- (target_query_id, request_id, node_name, node_cell_id, topic_id, user_id, created_at)
71
- VALUES (?, ?, ?, ?, ?, ?, ?)
84
+ (target_query_id, request_id, node_name, node_cell_id, topic_id, user_id, created_at, route_json)
85
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
72
86
  ON CONFLICT(target_query_id) DO UPDATE SET
73
87
  request_id = excluded.request_id,
74
88
  node_name = excluded.node_name,
75
89
  node_cell_id = excluded.node_cell_id,
76
90
  topic_id = excluded.topic_id,
77
91
  user_id = excluded.user_id,
78
- created_at = excluded.created_at`,
92
+ created_at = excluded.created_at,
93
+ route_json = excluded.route_json`,
79
94
  ).run(
80
95
  entry.targetQueryId,
81
- entry.remoteReply.requestId,
82
- entry.remoteReply.nodeName,
83
- entry.remoteReply.nodeCellId,
84
- entry.remoteReply.topicId,
85
- entry.remoteReply.userId,
96
+ route.requestId,
97
+ route.nodeName,
98
+ hub ? "" : route.nodeCellId,
99
+ route.topicId,
100
+ hub ? "" : route.userId,
86
101
  entry.createdAt,
102
+ hub ? JSON.stringify(route) : null,
87
103
  );
88
104
  }
89
105
  }
@@ -99,10 +115,23 @@ export function resolveAskCallback(queryId: string): AskPending | null {
99
115
  entry.timedOut = true;
100
116
  }
101
117
  pendingAsks.delete(queryId);
102
- db.query("DELETE FROM remote_ask_callbacks WHERE target_query_id = ?").run(queryId);
118
+ // A hub-routed callback keeps its durable row until the answer is in the
119
+ // reply outbox: `deliverHubRemoteReply` deletes it in the same transaction
120
+ // as the outbox insert, so a crash between resolve and enqueue still leaves
121
+ // the row for the startup pass to fail explicitly instead of losing both.
122
+ if (!entry.remoteReply || !isHubRemoteReplyRoute(entry.remoteReply)) {
123
+ db.query("DELETE FROM remote_ask_callbacks WHERE target_query_id = ?").run(queryId);
124
+ }
103
125
  return entry;
104
126
  }
105
127
 
128
+ /** The hub-routed answer is durably queued: the callback row has served its purpose. */
129
+ export function deleteHubRemoteAskCallbackRow(requestId: string): void {
130
+ db.query("DELETE FROM remote_ask_callbacks WHERE request_id = ? AND route_json IS NOT NULL").run(
131
+ requestId,
132
+ );
133
+ }
134
+
106
135
  /** Clean up stale asks that outlived their TTL (periodic, lightweight). */
107
136
  export function purgeStaleAsks(now: number = Date.now()): void {
108
137
  for (const [id, entry] of pendingAsks) {
@@ -130,28 +159,52 @@ interface DurableRemoteAskCallbackRow {
130
159
  node_cell_id: string;
131
160
  topic_id: string;
132
161
  user_id: string;
162
+ route_json: string | null;
163
+ }
164
+
165
+ function durableRoute(row: DurableRemoteAskCallbackRow): RemoteReplyRoute | null {
166
+ if (row.route_json) {
167
+ try {
168
+ return parseHubRemoteReplyRoute(JSON.parse(row.route_json));
169
+ } catch {
170
+ return null;
171
+ }
172
+ }
173
+ return {
174
+ nodeName: row.node_name,
175
+ nodeCellId: row.node_cell_id,
176
+ topicId: row.topic_id,
177
+ userId: row.user_id,
178
+ requestId: row.request_id,
179
+ };
133
180
  }
134
181
 
135
182
  /** Fail remote asks whose target turn was interrupted by the previous process.
136
- * Call once during startup after the peer session bridge is registered. */
137
- export async function failInterruptedRemoteAskCallbacks(): Promise<number> {
183
+ * Call once during startup after the peer session bridge is registered.
184
+ * `routes: "hub"` limits the pass to hub-routed asks, which need no adapter
185
+ * bridge and are therefore failed by the node itself at startup. */
186
+ export async function failInterruptedRemoteAskCallbacks(
187
+ options: { routes?: "all" | "hub" } = {},
188
+ ): Promise<number> {
138
189
  const rows = db
139
190
  .query(
140
- `SELECT target_query_id, request_id, node_name, node_cell_id, topic_id, user_id
191
+ `SELECT target_query_id, request_id, node_name, node_cell_id, topic_id, user_id, route_json
141
192
  FROM remote_ask_callbacks
142
193
  ORDER BY created_at ASC`,
143
194
  )
144
195
  .all() as DurableRemoteAskCallbackRow[];
145
196
  let failed = 0;
146
197
  for (const row of rows) {
198
+ const route = durableRoute(row);
199
+ if (!route) {
200
+ db.query("DELETE FROM remote_ask_callbacks WHERE target_query_id = ?").run(
201
+ row.target_query_id,
202
+ );
203
+ continue;
204
+ }
205
+ if (options.routes === "hub" && !isHubRemoteReplyRoute(route)) continue;
147
206
  const delivered = await deliverPeerReply(
148
- {
149
- nodeName: row.node_name,
150
- nodeCellId: row.node_cell_id,
151
- topicId: row.topic_id,
152
- userId: row.user_id,
153
- requestId: row.request_id,
154
- },
207
+ route,
155
208
  "peer",
156
209
  "The remote worker restarted before this ask completed. Please retry the request.",
157
210
  "error",
@@ -20,6 +20,7 @@
20
20
 
21
21
  import {
22
22
  closeSync,
23
+ existsSync,
23
24
  openSync,
24
25
  readdirSync,
25
26
  readFileSync,
@@ -236,6 +237,11 @@ export async function flushBashrsCompletions(): Promise<void> {
236
237
  continue;
237
238
  }
238
239
 
240
+ // Another consumer of the same spill dir (a node and an embedding host can
241
+ // both hold leadership, each under its own lease store) may have finished
242
+ // this job between our read and now. Nothing left to deliver.
243
+ if (!existsSync(resultPath)) continue;
244
+
239
245
  try {
240
246
  // The stable bash_id is carried through so at-least-once delivery on
241
247
  // either side — this watcher crashing before the rename, or the sink's
@@ -246,7 +252,19 @@ export async function flushBashrsCompletions(): Promise<void> {
246
252
  bashId: result.bash_id,
247
253
  message: buildMessage(dir, result),
248
254
  });
249
- renameSync(resultPath, marker);
255
+ try {
256
+ renameSync(resultPath, marker);
257
+ } catch (renameErr) {
258
+ // ENOENT here means a concurrent consumer already marked the job
259
+ // delivered (the sink collapses the duplicate by bash_id). That is the
260
+ // success state, not a failure to retry.
261
+ if ((renameErr as NodeJS.ErrnoException).code !== "ENOENT") throw renameErr;
262
+ logger.debug(
263
+ { bashId: result.bash_id },
264
+ "bashrs-completions: already delivered by another consumer",
265
+ );
266
+ continue;
267
+ }
250
268
  logger.info(
251
269
  { bashId: result.bash_id, userId: parsed.userId, topicId: parsed.topicId },
252
270
  "bashrs-completions: delivered",
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Extra `GET /health` capability strings contributed by whatever is loaded
3
+ * alongside the node — an adapter that forwards a new gateway route over its
4
+ * relay, for instance. The core route list is fixed in `control.ts`; this
5
+ * registry lets a host advertise a capability only when the piece that makes
6
+ * it true is actually present, so a hub can tell "the node supports it" from
7
+ * "the node *and* its relay support it".
8
+ */
9
+ const registered = new Set<string>();
10
+
11
+ export function registerRuntimeGatewayCapability(name: string): () => void {
12
+ const capability = name.trim();
13
+ if (!capability) throw new Error("capability name is required");
14
+ registered.add(capability);
15
+ return () => {
16
+ registered.delete(capability);
17
+ };
18
+ }
19
+
20
+ export function listRuntimeGatewayCapabilities(): string[] {
21
+ return [...registered].sort();
22
+ }