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
@@ -51,6 +51,7 @@ import {
51
51
  } from "#query/active-rooms";
52
52
  import { writeQueryState } from "#query/state";
53
53
  import { AbortReason } from "#query/types";
54
+ import { actorTopicScopeFrom } from "#runtime/actor-topic-scope";
54
55
  import {
55
56
  materializePromptAttachments,
56
57
  promptWithAttachments,
@@ -60,6 +61,7 @@ import { buildMentionOnlyChannelPrompt } from "#runtime/channel-context";
60
61
  import { classifyAgentError, stringifyError } from "#runtime/errors";
61
62
  import { withTurnSilenceHeartbeat } from "#runtime/event-heartbeat";
62
63
  import { trackPlaywrightTurn, untrackPlaywrightTurn } from "#runtime/playwright-turn-abort";
64
+ import { remoteSessionGrantFrom } from "#runtime/remote-session-grant";
63
65
  import { getTopicConfig } from "#runtime/topic-config";
64
66
  import { runTurnEventStream, type StreamAgentOutcome } from "#runtime/turn-event-stream";
65
67
  import {
@@ -87,6 +89,7 @@ import {
87
89
  import { getGlobalAiName } from "#storage/app-settings";
88
90
  import { isTopicBrowserProfileOwner } from "#storage/browser-profiles";
89
91
  import { appendConversationEvent, readConversation } from "#storage/conversations";
92
+ import { db } from "#storage/forum-db";
90
93
  import {
91
94
  getRuntimeTurnLease,
92
95
  RUNTIME_INSTANCE_ID,
@@ -96,6 +99,7 @@ import { getRuntimeTopicEpoch, isRuntimeTopicMaintenance } from "#storage/runtim
96
99
  import {
97
100
  claimNextRuntimeUserTurnRequest,
98
101
  completeRuntimeUserTurnRequest,
102
+ getActiveRuntimeUserTurnRequest,
99
103
  markRuntimeUserTurnMessagesLogged,
100
104
  markRuntimeUserTurnRunning,
101
105
  mergeRuntimeUserTurnRequest,
@@ -113,7 +117,14 @@ import {
113
117
  wikiSummaryFilename,
114
118
  } from "#storage/wiki-summary-names";
115
119
  import { getTopics } from "#topics/derive";
116
- import type { AgentKind, EffortLevel, PeerRuntimeBridgeContext, UnifiedEvent } from "#types";
120
+ import type {
121
+ ActorTopicScope,
122
+ AgentKind,
123
+ EffortLevel,
124
+ PeerRuntimeBridgeContext,
125
+ RemoteSessionGrant,
126
+ UnifiedEvent,
127
+ } from "#types";
117
128
  import type { MessageDto, TopicDto } from "#types/api";
118
129
 
119
130
  // 분해된 헬퍼 모듈 재노출 — 기존 @/api/routes/ai 소비자(테스트 포함) 경로 유지
@@ -439,7 +450,10 @@ async function markPendingAskFile(
439
450
  }
440
451
 
441
452
  /** Caller-side injection of an ask reply — delivers the target AI's answer (or
442
- * error) back into the caller topic, triggering its AI when one is invited. */
453
+ * error) back into the caller topic, triggering its AI when one is invited.
454
+ * `options.onRecorded` runs inside the same storage transaction as the durable
455
+ * room record (the appended reply message), so a caller that must consume its
456
+ * own bookkeeping exactly when the answer is durable can do so atomically. */
443
457
  export async function deliverAskCallbackToCaller(
444
458
  pending: AskPendingFileRef & {
445
459
  requestId: string;
@@ -451,28 +465,36 @@ export async function deliverAskCallbackToCaller(
451
465
  sourceLabel: string,
452
466
  body: string,
453
467
  kind: "reply" | "error",
468
+ options: { onRecorded?: () => void } = {},
454
469
  ): Promise<boolean> {
455
470
  if (pending.remoteReply) {
456
471
  const { deliverPeerReply } = await import("#mcp/session-comm/peer-forward");
457
- return deliverPeerReply(pending.remoteReply, sourceLabel, body, kind);
472
+ const delivered = await deliverPeerReply(pending.remoteReply, sourceLabel, body, kind);
473
+ if (delivered) options.onRecorded?.();
474
+ return delivered;
458
475
  }
459
476
  const callerTopic = getTopic(pending.callerTopicId);
460
477
  const heading = kind === "error" ? `Error from ${sourceLabel}` : `Reply from ${sourceLabel}`;
461
478
  const prompt = `[${heading}]\n\n${body}`;
462
-
463
- await markPendingAskFile(pending, "reply_ready");
464
-
465
- if (!callerTopic?.agent) {
466
- try {
479
+ const record = (agentType?: AgentKind | null) =>
480
+ db.transaction(() => {
467
481
  appendAskReplyMessage(
468
482
  pending.callerTopicId,
469
483
  prompt,
470
484
  sourceLabel,
471
485
  body,
472
486
  kind,
473
- undefined,
487
+ agentType ?? undefined,
474
488
  pending.callerThreadRootId,
475
489
  );
490
+ options.onRecorded?.();
491
+ })();
492
+
493
+ await markPendingAskFile(pending, "reply_ready");
494
+
495
+ if (!callerTopic?.agent) {
496
+ try {
497
+ record(undefined);
476
498
  await clearPendingAskFile(pending);
477
499
  return true;
478
500
  } catch (err) {
@@ -513,22 +535,36 @@ export async function deliverAskCallbackToCaller(
513
535
  if (queued) {
514
536
  // Keep individual replies visible in topic history; only the model-facing
515
537
  // caller turn is coalesced into one prompt.
516
- appendAskReplyMessage(
517
- pending.callerTopicId,
518
- prompt,
519
- sourceLabel,
520
- body,
521
- kind,
522
- callerTopic.agent,
523
- pending.callerThreadRootId,
524
- );
538
+ try {
539
+ record(callerTopic.agent);
540
+ } catch (err) {
541
+ // The durable record is what makes this answer real — it is also the
542
+ // transaction that consumes the caller's bookkeeping (`onRecorded`).
543
+ // Without it the queued entry is the answer's ONLY trace, in memory, in
544
+ // a process that just failed a write: leaving it there makes the sender's
545
+ // retry hit the dedupe branch below and settle as if the answer had
546
+ // landed, so a crash loses it for good. Drop the entry instead and fail,
547
+ // so the retry runs the whole delivery again from a clean slate.
548
+ interSessionQueue.remove(pending.callerTopicId, pending.requestId);
549
+ logger.warn(
550
+ { err, requestId: pending.requestId, callerTopicId: pending.callerTopicId },
551
+ "sessions: ask callback record failed after enqueue; queue entry withdrawn",
552
+ );
553
+ return false;
554
+ }
525
555
  await markPendingAskFile(pending, "queued_for_caller");
526
556
  return true;
527
557
  }
528
558
 
529
559
  // A replay can encounter the same request while its original callback is
530
560
  // still queued. That request is already owned; do not append it twice.
561
+ // `onRecorded` still has to run atomically: it is an ask row deletion plus a
562
+ // claim completion, and a crash between the two would leave a completed
563
+ // claim with the ask still pending (or the reverse).
531
564
  if (interSessionQueue.hasRequest(pending.callerTopicId, pending.requestId)) {
565
+ db.transaction(() => {
566
+ options.onRecorded?.();
567
+ })();
532
568
  await markPendingAskFile(pending, "queued_for_caller");
533
569
  return true;
534
570
  }
@@ -537,15 +573,7 @@ export async function deliverAskCallbackToCaller(
537
573
  { requestId: pending.requestId, callerTopicId: pending.callerTopicId, source: sourceLabel },
538
574
  "sessions: ask callback could not enter caller batch; appending direct fallback",
539
575
  );
540
- appendAskReplyMessage(
541
- pending.callerTopicId,
542
- prompt,
543
- sourceLabel,
544
- body,
545
- kind,
546
- callerTopic.agent,
547
- pending.callerThreadRootId,
548
- );
576
+ record(callerTopic.agent);
549
577
  await clearPendingAskFile(pending);
550
578
  return true;
551
579
  }
@@ -663,6 +691,10 @@ export interface AiTurnExecutionOptions {
663
691
  origin?: string;
664
692
  /** Product actor identity when the node executes as a shared principal. */
665
693
  actorUserId?: string;
694
+ /** Host-asserted rooms the actor may reach; absent confines cross-room tools. */
695
+ actorTopicScope?: ActorTopicScope;
696
+ /** Hub-issued per-turn remote session-comm grant; absent keeps it fail-closed. */
697
+ remoteSession?: RemoteSessionGrant;
666
698
  /** Origin node for transcript echo suppression. */
667
699
  sourceNode?: string;
668
700
  /** Answer inside this thread instead of the room's main flow (S-13). */
@@ -789,6 +821,39 @@ export interface TriggerTopicAiTurnOptions extends AiTurnExecutionOptions {
789
821
 
790
822
  const remoteInjectWaiters = new Map<string, ReturnType<typeof setInterval>>();
791
823
 
824
+ /**
825
+ * Whether a new human message comes from the person a running user turn is
826
+ * answering. Only that person may steer the turn (abort it and fold the new
827
+ * message into a resumed batch); anyone else's message queues behind it. The
828
+ * comparison mirrors the durable merge rule: two turns without a recorded
829
+ * actor are one conversation, but an unrecorded actor never matches a named
830
+ * one — the conservative reading, since the alternative is folding a
831
+ * stranger's message into a turn whose author is unknown.
832
+ */
833
+ export function sameTurnActor(
834
+ runningActorUserId: string | undefined,
835
+ incomingActorUserId: string | undefined,
836
+ ): boolean {
837
+ return (runningActorUserId ?? null) === (incomingActorUserId ?? null);
838
+ }
839
+
840
+ /**
841
+ * Whether the durable request another worker holds for `topicId` belongs to
842
+ * the same actor as an incoming user turn. `ownRequestIds` are the incoming
843
+ * turn's own durable rows, which the lookup must not mistake for the remote
844
+ * turn. No visible active request means the remote turn's actor is unknown,
845
+ * which is never "the same person".
846
+ */
847
+ function remoteTurnHasSameActor(
848
+ topicId: string,
849
+ incomingActorUserId: string | undefined,
850
+ ownRequestIds: readonly string[],
851
+ ): boolean {
852
+ const active = getActiveRuntimeUserTurnRequest(topicId);
853
+ if (!active || ownRequestIds.includes(active.requestId)) return false;
854
+ return sameTurnActor(active.execution?.actorUserId, incomingActorUserId);
855
+ }
856
+
792
857
  export function mergeSupersedingUserTurn(
793
858
  running: Pick<RoomQueryControl, "prompt" | "userMessages" | "attachments" | "sessionId">,
794
859
  incoming: { prompt: string; userMessages?: UserTurnEnvelope[]; attachments?: string[] },
@@ -825,6 +890,8 @@ function serializableUserTurnExecution(params: StartAiTurnParams): RuntimeUserTu
825
890
  runtimeEpoch: params._runtimeEpoch ?? getRuntimeTopicEpoch(params.topic.id),
826
891
  sourceRequestId: params.requestId,
827
892
  actorUserId: params.actorUserId,
893
+ actorTopicScope: params.actorTopicScope,
894
+ remoteSession: params.remoteSession,
828
895
  agentOverride: params.agentOverride,
829
896
  modelOverride: params.modelOverride,
830
897
  effortOverride: params.effortOverride,
@@ -921,6 +988,8 @@ async function drainOneDurableUserTurn(): Promise<void> {
921
988
  topic,
922
989
  userId: request.userId,
923
990
  actorUserId: execution?.actorUserId,
991
+ actorTopicScope: actorTopicScopeFrom(execution?.actorTopicScope),
992
+ remoteSession: remoteSessionGrantFrom(execution?.remoteSession),
924
993
  vaultUserId: execution?.vaultUserId,
925
994
  prompt: request.prompt,
926
995
  _userMessages: request.userMessages,
@@ -1169,7 +1238,11 @@ export function startAiTurn(params: StartAiTurnParams): string | null {
1169
1238
  return null;
1170
1239
  }
1171
1240
  if (decision.action === "remote-abort-wait") {
1172
- requestRuntimeTurnAbort(topicId, "internal");
1241
+ // Only the same person's follow-up steers the turn the other process is
1242
+ // running; anyone else's message just waits for the lease to be released.
1243
+ if (remoteTurnHasSameActor(topicId, params.actorUserId, durableRequestIds)) {
1244
+ requestRuntimeTurnAbort(topicId, "internal");
1245
+ }
1173
1246
  const queuedQueryId = waitToStartRemoteUserTurn(
1174
1247
  {
1175
1248
  ...params,
@@ -1193,6 +1266,33 @@ export function startAiTurn(params: StartAiTurnParams): string | null {
1193
1266
  }
1194
1267
  if (decision.action === "abort-replace") {
1195
1268
  const running = decision.running;
1269
+ // Another person's running answer is not this message's to interrupt. The
1270
+ // message keeps its own durable row (the merge never folds across actors)
1271
+ // and the worker claims it once the running turn releases the room. A
1272
+ // running session-inject is still preempted by any human message.
1273
+ if (isUserOrigin(running.origin) && !sameTurnActor(running.actorUserId, params.actorUserId)) {
1274
+ const queuedQueryId = waitToStartRemoteUserTurn(
1275
+ {
1276
+ ...params,
1277
+ topic,
1278
+ prompt,
1279
+ attachments,
1280
+ sessionId,
1281
+ _userMessages: userMessages,
1282
+ _conversationPrompts: conversationPrompts,
1283
+ _loggedUserMessageCount: loggedUserMessageCount,
1284
+ _durableRequestIds: durableRequestIds,
1285
+ _runtimeEpoch: runtimeEpoch,
1286
+ },
1287
+ queryId,
1288
+ );
1289
+ announceQueuedUserTurn({ ...params, topic }, queuedQueryId);
1290
+ logger.info(
1291
+ { topicId, queryId: queuedQueryId, runningQueryId: running.queryId },
1292
+ "ai: user turn from another actor queued behind the running turn",
1293
+ );
1294
+ return queuedQueryId;
1295
+ }
1196
1296
  let omitRequestIds: string[] | undefined;
1197
1297
  if (isUserOrigin(running.origin)) {
1198
1298
  if (running.providerTurnContentObserved && running.sessionId) {
@@ -1278,6 +1378,9 @@ export function startAiTurn(params: StartAiTurnParams): string | null {
1278
1378
  origin,
1279
1379
  prompt,
1280
1380
  userMessages,
1381
+ ...(isUserOrigin(origin) && params.actorUserId !== undefined
1382
+ ? { actorUserId: params.actorUserId }
1383
+ : {}),
1281
1384
  durableRequestIds,
1282
1385
  attachments,
1283
1386
  sessionId,
@@ -1329,7 +1432,9 @@ export function startAiTurn(params: StartAiTurnParams): string | null {
1329
1432
  return rejectIsolatedTurnBeforeDispatch("failed to claim isolated turn slot");
1330
1433
  }
1331
1434
  if (isUserOrigin(origin)) {
1332
- requestRuntimeTurnAbort(topicId, "internal");
1435
+ if (remoteTurnHasSameActor(topicId, params.actorUserId, durableRequestIds)) {
1436
+ requestRuntimeTurnAbort(topicId, "internal");
1437
+ }
1333
1438
  const queuedQueryId = waitToStartRemoteUserTurn(
1334
1439
  {
1335
1440
  ...params,
@@ -1708,6 +1813,8 @@ export function startAiTurn(params: StartAiTurnParams): string | null {
1708
1813
  sessionId,
1709
1814
  userId,
1710
1815
  actorUserId: params.actorUserId,
1816
+ actorTopicScope: params.actorTopicScope,
1817
+ remoteSession: params.remoteSession,
1711
1818
  vaultUserId,
1712
1819
  session: sessionName,
1713
1820
  sessionType: sessionType ?? (isManager ? "manager" : "forum"),
@@ -1804,12 +1911,14 @@ export function startAiTurn(params: StartAiTurnParams): string | null {
1804
1911
  "Provider session expired and conversation history could not be rebuilt; the existing session was preserved for retry.",
1805
1912
  };
1806
1913
  } else {
1807
- if (!silent) WsHub.get().broadcastAborted(topicId, queryId, "stopped");
1914
+ // No terminal event, and the same queryId carries over (see the
1915
+ // `_queryId` note on the empty-response retry below): this is one
1916
+ // logical turn recovering internally, not a turn that ended.
1808
1917
  const retrySessionId = retry.sessionId;
1809
1918
  logger.info(
1810
1919
  {
1811
1920
  topicId,
1812
- prevQueryId: queryId,
1921
+ queryId,
1813
1922
  agent: agentKind,
1814
1923
  retrySessionId: retrySessionId ? retrySessionId.slice(0, 8) : null,
1815
1924
  },
@@ -1824,6 +1933,9 @@ export function startAiTurn(params: StartAiTurnParams): string | null {
1824
1933
  startAiTurn({
1825
1934
  topic,
1826
1935
  userId,
1936
+ actorUserId: params.actorUserId,
1937
+ actorTopicScope: params.actorTopicScope,
1938
+ remoteSession: params.remoteSession,
1827
1939
  vaultUserId,
1828
1940
  prompt,
1829
1941
  _userMessages: userMessages,
@@ -1862,6 +1974,7 @@ export function startAiTurn(params: StartAiTurnParams): string | null {
1862
1974
  askReplySources,
1863
1975
  _runtimeEpoch: runtimeEpoch,
1864
1976
  _sessionRetried: true,
1977
+ _queryId: queryId,
1865
1978
  });
1866
1979
  return;
1867
1980
  }
@@ -1878,14 +1991,24 @@ export function startAiTurn(params: StartAiTurnParams): string | null {
1878
1991
  // the CLI self-interrupts within ~1-2s with a zero-usage "success".
1879
1992
  // A same-session retry has reliably produced a real reply in every
1880
1993
  // case seen so far, so retry once before surfacing a failure.
1881
- if (!silent) WsHub.get().broadcastAborted(topicId, queryId, "stopped");
1994
+ // Deliberately NO terminal event here, and the retry keeps this
1995
+ // queryId. An internal retry is invisible bookkeeping: the turn the
1996
+ // caller asked for is still running. Emitting `ai_aborted` made a
1997
+ // host treat the turn as over — the hub revokes the turn's remote
1998
+ // session-comm capability on any terminal event for its queryId, so
1999
+ // the retried attempt found its own remote tools answering 401 —
2000
+ // and a fresh queryId would leave the original one with no terminal
2001
+ // event at all, stranding that capability for its whole ceiling.
1882
2002
  logger.info(
1883
- { topicId, prevQueryId: queryId, agent: agentKind },
2003
+ { topicId, queryId, agent: agentKind },
1884
2004
  "ai: retrying query after empty provider response",
1885
2005
  );
1886
2006
  startAiTurn({
1887
2007
  topic,
1888
2008
  userId,
2009
+ actorUserId: params.actorUserId,
2010
+ actorTopicScope: params.actorTopicScope,
2011
+ remoteSession: params.remoteSession,
1889
2012
  vaultUserId,
1890
2013
  prompt,
1891
2014
  _userMessages: userMessages,
@@ -1922,6 +2045,7 @@ export function startAiTurn(params: StartAiTurnParams): string | null {
1922
2045
  _runtimeEpoch: runtimeEpoch,
1923
2046
  _sessionRetried: sessionRetried,
1924
2047
  _emptyResponseRetried: true,
2048
+ _queryId: queryId,
1925
2049
  });
1926
2050
  return;
1927
2051
  }
@@ -1,3 +1,4 @@
1
+ import type { ActorTopicScope, RemoteSessionGrant } from "#types";
1
2
  import type { MessageDto } from "#types/api";
2
3
 
3
4
  export const RUNTIME_GATEWAY_VERSION = 1 as const;
@@ -114,6 +115,25 @@ export interface RuntimeGatewayTurnInput {
114
115
  userId: string;
115
116
  /** Product-side actor, when it differs from the execution principal. */
116
117
  actorUserId?: string;
118
+ /**
119
+ * The node topic ids `actorUserId` may reach on the receiving node, as the
120
+ * host's membership store sees it. Omitted by hosts that predate it; the
121
+ * node then confines the turn's cross-room tools to the current room.
122
+ */
123
+ actorTopicScope?: ActorTopicScope;
124
+ /**
125
+ * Hub-issued per-turn authority for remote (`node/topic`) session-comm:
126
+ * `{ hubUrl, capability }`. `hubUrl` must be a loopback `http://` or any
127
+ * `https://` origin (≤ 512 chars); `capability` an opaque `rsc1.` bearer
128
+ * (≤ 2 KiB) the node presents back to the hub and cannot verify — it holds
129
+ * no key. It does base64-decode the payload's `e` (expiry) for one purpose,
130
+ * ordering grants when several requests fold into one turn
131
+ * (`remoteSessionGrantExpiry`); that value is untrusted and takes part in no
132
+ * security decision. Omitted
133
+ * by hosts that predate it or have the feature off; the node then keeps
134
+ * remote session-comm fail-closed on the `otium` surface. Malformed → 400.
135
+ */
136
+ remoteSession?: RemoteSessionGrant;
117
137
  actorLabel?: string;
118
138
  vaultUserId?: string;
119
139
  /** Adapter provenance recorded on the canonical message. */
@@ -454,6 +454,9 @@ function initializeApiTopicsSchema(): void {
454
454
  if (!tableColumns("api_topics").has("subagent_report_mode")) {
455
455
  db.exec("ALTER TABLE api_topics ADD COLUMN subagent_report_mode TEXT NOT NULL DEFAULT 'auto'");
456
456
  }
457
+ if (!tableColumns("api_topics").has("derived_by_user_id")) {
458
+ db.exec("ALTER TABLE api_topics ADD COLUMN derived_by_user_id TEXT");
459
+ }
457
460
  if (!tableColumns("api_topics").has("memory_topic_id")) {
458
461
  db.exec("ALTER TABLE api_topics ADD COLUMN memory_topic_id TEXT");
459
462
  }
@@ -632,6 +635,7 @@ export interface TopicRow {
632
635
  is_fork: number;
633
636
  is_subagent: number;
634
637
  subagent_report_mode: string | null;
638
+ derived_by_user_id: string | null;
635
639
  visibility: string | null;
636
640
  surface: string | null;
637
641
  surface_scope: string | null;
@@ -716,6 +720,7 @@ function rowToDto(
716
720
  memoryTopicId: r.memory_topic_id ?? undefined,
717
721
  memoryKey: r.memory_key ?? undefined,
718
722
  isFork: r.is_fork !== 0,
723
+ ...(r.derived_by_user_id ? { derivedByUserId: r.derived_by_user_id } : {}),
719
724
  ...(r.is_subagent !== 0 ? { isSubagent: true } : {}),
720
725
  ...(r.is_subagent !== 0
721
726
  ? {
@@ -859,8 +864,8 @@ export function upsertTopic(t: TopicDto): void {
859
864
  `INSERT INTO api_topics
860
865
  (id,title,kind,description,agent,base_model,base_effort,response_policy,
861
866
  created_at,last_message_at,parent_topic_id,memory_topic_id,memory_key,is_fork,is_subagent,visibility,surface,
862
- surface_scope,subagent_report_mode)
863
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
867
+ surface_scope,subagent_report_mode,derived_by_user_id)
868
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
864
869
  ON CONFLICT(id) DO UPDATE SET
865
870
  title = excluded.title,
866
871
  kind = excluded.kind,
@@ -882,7 +887,10 @@ export function upsertTopic(t: TopicDto): void {
882
887
  -- assignment: an update may fill in a scope that was unknown when the
883
888
  -- room was created, but may never move a room to another workspace.
884
889
  surface_scope = COALESCE(api_topics.surface_scope, excluded.surface_scope),
885
- subagent_report_mode = excluded.subagent_report_mode`,
890
+ subagent_report_mode = excluded.subagent_report_mode,
891
+ -- Who asked for the derive is a fact about creation; a later update
892
+ -- that omits it must not erase it.
893
+ derived_by_user_id = COALESCE(excluded.derived_by_user_id, api_topics.derived_by_user_id)`,
886
894
  ).run(
887
895
  t.id,
888
896
  t.title,
@@ -907,6 +915,7 @@ export function upsertTopic(t: TopicDto): void {
907
915
  normalizeTopicSurface(t.surface ?? defaultTopicSurface()),
908
916
  surfaceScopeForWrite(t),
909
917
  t.subagentReportMode ?? "auto",
918
+ t.derivedByUserId ?? null,
910
919
  );
911
920
  db.query("DELETE FROM topic_members WHERE topic_id = ?").run(t.id);
912
921
  for (const participant of t.participants) {
@@ -1305,6 +1314,16 @@ export function revokeSubagentTellTarget(subagentTopicId: string, targetTopicId:
1305
1314
  * Direct children move to the deleted topic's parent (or become roots) rather
1306
1315
  * than retaining a dangling parent id that would break memory-origin lookup.
1307
1316
  */
1317
+ /** Ids of the subagent worker rooms spawned directly from `parentTopicId`. */
1318
+ export function listSubagentChildTopicIds(parentTopicId: string): string[] {
1319
+ return db
1320
+ .query<{ id: string }, string>(
1321
+ "SELECT id FROM api_topics WHERE parent_topic_id = ? AND is_subagent = 1 ORDER BY created_at",
1322
+ )
1323
+ .all(parentTopicId)
1324
+ .map((row) => row.id);
1325
+ }
1326
+
1308
1327
  export function reparentTopicChildren(
1309
1328
  deletedTopicId: string,
1310
1329
  replacementParentTopicId: string | null,