@oar-sh/oar 0.9.5 → 0.9.7

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 (119) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +12 -32
  2. package/.github/extensions/web-relay/skills/question-bridge.mjs +14 -39
  3. package/CHANGELOG.md +340 -0
  4. package/README.md +196 -26
  5. package/package.json +2 -1
  6. package/server/README.md +174 -7
  7. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
  8. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
  9. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
  10. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  11. package/server/claude-worker/claude-sdk-adapter.mjs +18 -2
  12. package/server/claude-worker/claude-session-process.mjs +151 -1
  13. package/server/claude-worker/claude-session-worker.mjs +6 -0
  14. package/server/claude-worker/claude-turn-failure.mjs +92 -0
  15. package/server/claude-worker/claude-turn-publisher.mjs +52 -3
  16. package/server/claude-worker/sdk-message-normalizer.mjs +13 -1
  17. package/server/copilot-worker/copilot-model-switch.mjs +29 -3
  18. package/server/copilot-worker/copilot-question-bridge.mjs +2 -4
  19. package/server/copilot-worker/copilot-runtime-tree.mjs +91 -0
  20. package/server/copilot-worker/copilot-sdk-adapter.mjs +48 -8
  21. package/server/copilot-worker/copilot-sdk-session-process.mjs +897 -166
  22. package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
  23. package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
  24. package/server/db-schema.mjs +24 -0
  25. package/server/grok-worker/acp-client.mjs +68 -12
  26. package/server/grok-worker/grok-sdk-adapter.mjs +4 -0
  27. package/server/grok-worker/grok-turn-runner.mjs +6 -2
  28. package/server/migrations/0006-workspace-root-settings.mjs +67 -0
  29. package/server/migrations/0007-claude-cloud.mjs +82 -0
  30. package/server/public/app/agent-sessions-settings-ui.js +197 -0
  31. package/server/public/app/agent-sessions-settings.mjs +134 -0
  32. package/server/public/app/api-client.js +160 -6
  33. package/server/public/app/attachments-view.js +20 -1
  34. package/server/public/app/attribution-control.mjs +46 -0
  35. package/server/public/app/bootstrap.js +347 -31
  36. package/server/public/app/claude-cloud-conversation-ui.js +110 -0
  37. package/server/public/app/claude-cloud-settings-ui.js +374 -0
  38. package/server/public/app/claude-cloud-ui.mjs +611 -0
  39. package/server/public/app/composer-control-state.mjs +15 -3
  40. package/server/public/app/context-menu.mjs +140 -0
  41. package/server/public/app/context-tier-options.mjs +4 -0
  42. package/server/public/app/conversation-origin-ui.js +27 -0
  43. package/server/public/app/conversation-provider-indicator.mjs +22 -4
  44. package/server/public/app/conversation-view.js +178 -8
  45. package/server/public/app/cwd-picker.js +12 -0
  46. package/server/public/app/journal-view.js +704 -28
  47. package/server/public/app/markdown-strikethrough.mjs +31 -0
  48. package/server/public/app/math-dollars.mjs +89 -0
  49. package/server/public/app/model-selector-options.mjs +1 -1
  50. package/server/public/app/plan-usage-view.mjs +36 -8
  51. package/server/public/app/relay-board-inline.mjs +70 -0
  52. package/server/public/app/relay-board-view.js +157 -61
  53. package/server/public/app/relay-error-ctas.mjs +28 -5
  54. package/server/public/app/remote-relay-origin-view.mjs +120 -4
  55. package/server/public/app/router.js +49 -12
  56. package/server/public/app/settings-modal.js +33 -2
  57. package/server/public/app/settings-tabs.js +1 -1
  58. package/server/public/app/socket-handlers.js +60 -0
  59. package/server/public/app/store.js +4 -1
  60. package/server/public/app/subagent-fold.mjs +61 -0
  61. package/server/public/app/usage-limit-ui.js +225 -0
  62. package/server/public/index.html +471 -8
  63. package/server/relay-tools.md +13 -1
  64. package/server/repositories/message-repository.mjs +10 -1
  65. package/server/repositories/question-repository.mjs +1 -0
  66. package/server/repositories/remote-relay-repository.mjs +72 -1
  67. package/server/repositories/session-repository.mjs +47 -1
  68. package/server/routes/claude-cloud-routes.mjs +140 -0
  69. package/server/routes/git-routes.mjs +37 -0
  70. package/server/routes/messages-routes.mjs +202 -26
  71. package/server/routes/relay-board-routes.mjs +56 -2
  72. package/server/routes/remote-relay-routes.mjs +15 -1
  73. package/server/routes/sessions-routes.mjs +538 -72
  74. package/server/server-runtime.mjs +291 -27
  75. package/server/services/claude-account-usage-service.mjs +176 -0
  76. package/server/services/claude-cloud-repo-service.mjs +408 -0
  77. package/server/services/claude-cloud-session-service.mjs +326 -0
  78. package/server/services/claude-cloud-settings-service.mjs +269 -0
  79. package/server/services/git-remote-service.mjs +115 -0
  80. package/server/services/plan-usage-claude-cloud.mjs +211 -0
  81. package/server/services/plan-usage-claude.mjs +202 -40
  82. package/server/services/plan-usage-contract.mjs +39 -3
  83. package/server/services/plan-usage-service.mjs +82 -2
  84. package/server/services/preview-proxy-server.mjs +64 -36
  85. package/server/services/process-list-service.mjs +122 -0
  86. package/server/services/provider-reasoning-effort.mjs +3 -0
  87. package/server/services/relay-console-log.mjs +199 -0
  88. package/server/services/remote-relay-caller-context.mjs +78 -19
  89. package/server/services/remote-relay-client.mjs +9 -0
  90. package/server/services/remote-relay-dispatcher.mjs +451 -39
  91. package/server/services/remote-relay-inbound.mjs +83 -1
  92. package/server/services/remote-relay-loopback.mjs +245 -0
  93. package/server/services/remote-relay-registry-service.mjs +52 -0
  94. package/server/services/session-worker-launch-service.mjs +100 -15
  95. package/server/services/session-worker-process-service.mjs +410 -138
  96. package/server/services/session-worker-registry-service.mjs +1 -1
  97. package/server/services/session-worker-stop-service.mjs +24 -13
  98. package/server/services/session-worker-supervisor-service.mjs +89 -2
  99. package/server/services/usage-limit-pause-service.mjs +443 -0
  100. package/server/services/worker-launch-note-service.mjs +151 -0
  101. package/shared/ask-user-bridge.mjs +14 -39
  102. package/shared/claude-attribution.mjs +117 -0
  103. package/shared/claude-cloud/api-client.mjs +809 -0
  104. package/shared/claude-cloud/base-url.mjs +47 -0
  105. package/shared/claude-cloud/credentials.mjs +221 -0
  106. package/shared/claude-cloud/repo-url.mjs +111 -0
  107. package/shared/claude-usage-limit.mjs +257 -0
  108. package/shared/control-poller.mjs +69 -20
  109. package/shared/failure-note-text.mjs +38 -0
  110. package/shared/provider-routing.mjs +4 -2
  111. package/shared/question-wait.mjs +108 -0
  112. package/shared/remote-relay-contract.mjs +167 -22
  113. package/shared/remote-relay-tool-core.mjs +11 -8
  114. package/shared/steer-settle-failure.mjs +17 -0
  115. package/shared/steer-settle-markers.mjs +8 -0
  116. package/shared/worker-runtime/process-tree.mjs +483 -0
  117. package/shared/worker-runtime/relay-errors.mjs +31 -0
  118. package/shared/worker-runtime/send-and-wait-errors.mjs +4 -4
  119. package/shared/worker-runtime/turn-liveness.mjs +249 -0
@@ -2,13 +2,18 @@
2
2
 
3
3
  import { randomUUID as nodeRandomUUID } from 'crypto';
4
4
 
5
+ import { isFailureNoteText } from '../../shared/failure-note-text.mjs';
5
6
  import {
6
7
  REMOTE_RELAY_APPROVAL_MODES,
8
+ REMOTE_RELAY_CLOUD_PROVIDER,
7
9
  REMOTE_RELAY_EFFORT_PATTERN,
8
10
  REMOTE_RELAY_ERROR_CODES,
9
11
  REMOTE_RELAY_LIMITS,
12
+ REMOTE_RELAY_LOCAL_ALIAS,
13
+ REMOTE_RELAY_LOCAL_ID,
10
14
  REMOTE_RELAY_PROVIDERS,
11
15
  isRemoteRelayApprovalQuestion,
16
+ isRemoteRelayPromptAction,
12
17
  isRemoteRelayWriteAction,
13
18
  normalizeRemoteRelayOrigin,
14
19
  remoteConversationUrl,
@@ -30,6 +35,19 @@ import { ANSWERED_ELSEWHERE_KIND } from '../../shared/steer-settle-markers.mjs';
30
35
  // → permission → hop limit → rate limit → approval (write actions in
31
36
  // ask/plan) → execute against the remote relay's existing API
32
37
  //
38
+ // The relay the agent runs on is a target too (the "local target", named by
39
+ // this relay's name or `this`), when its owner switched that on. It needs no
40
+ // pairing, mention, permission or hop; instead:
41
+ //
42
+ // setting on → no acting on the caller's own conversation → rate limit →
43
+ // create_session only: one level (a created session creates none), at most
44
+ // localActiveSessions created sessions at work, the user's Allow once per
45
+ // conversation → approval (ask/plan) → execute
46
+ //
47
+ // Its actions run through the same code as a paired relay's: `call()` hands
48
+ // them to the loopback client, which reaches this relay's own routes
49
+ // in-process (remote-relay-loopback.mjs).
50
+ //
33
51
  // The remote is reached only through the outbound client (auth, origin and
34
52
  // hop headers are its job). Reply detection reads the remote's own transcript:
35
53
  // the assistant row whose sourceMessageId is the message we queued, with the
@@ -71,7 +89,16 @@ const TITLE_MAX = 80;
71
89
 
72
90
  // The provider settings routes a relay serves; github (Copilot) has none with
73
91
  // models — its catalogue is /api/models plus /api/status defaultModel.
74
- const SETTINGS_PROVIDERS = Object.freeze(['claude', 'cursor', 'grok', 'openai']);
92
+ const SETTINGS_PROVIDERS = Object.freeze(['claude', 'cursor', 'grok', 'openai', 'claude-cloud']);
93
+ // A Claude Cloud session clones a GitHub repository instead of opening a
94
+ // folder: create_session takes `repo` and `branch` for it.
95
+ const CLOUD_PROVIDER = REMOTE_RELAY_CLOUD_PROVIDER;
96
+ const CLOUD_CREATE_NOTE = 'create_session needs repo (https://github.com/owner/repo or owner/repo) and takes branch instead of cwd. The session works on a fresh clone in a sandbox at Anthropic: write a self-contained prompt and name the branch to work on and to push.';
97
+ // POST /api/conversation/bootstrap refuses a cloud session with this code
98
+ // while the provider is switched off.
99
+ const CLOUD_DISABLED_CODE = 'claude_cloud_disabled';
100
+ // The actions an agent must not aim at the conversation it runs in.
101
+ const OWN_SESSION_ACTIONS = new Set(['send', 'stop', 'archive', 'wait']);
75
102
  // How /api/models `providersByModel` names each provider.
76
103
  const CATALOG_PROVIDER_LABELS = Object.freeze({
77
104
  github: 'github-copilot',
@@ -79,6 +106,7 @@ const CATALOG_PROVIDER_LABELS = Object.freeze({
79
106
  claude: 'claude',
80
107
  cursor: 'cursor',
81
108
  grok: 'grok',
109
+ 'claude-cloud': 'claude-cloud',
82
110
  });
83
111
  // Worker statuses that mean "working on something right now" (scope active).
84
112
  const WORKING_WORKER_STATUSES = new Set(['starting', 'processing']);
@@ -90,7 +118,6 @@ const WORKING_WORKER_STATUSES = new Set(['starting', 'processing']);
90
118
  // marker counts only there: a normal reply that quotes a relay error further
91
119
  // down is a reply, not a failure.
92
120
  const FAILURE_TEXT_PATTERNS = Object.freeze([
93
- /^(?:(?!\n[ \t]*\r?\n)[\s\S]){0,600}?\berror code:\s*relay\.[a-z0-9-]+\.(?=\s|$)/i,
94
121
  /^relay recovery limit reached after \d+ attempts/i,
95
122
  /^relay timeout after \d+ attempts/i,
96
123
  ]);
@@ -184,7 +211,7 @@ function compactQuery(query = {}) {
184
211
 
185
212
  function looksLikeFailureText(text) {
186
213
  const value = toText(text);
187
- return FAILURE_TEXT_PATTERNS.some((pattern) => pattern.test(value));
214
+ return isFailureNoteText(value) || FAILURE_TEXT_PATTERNS.some((pattern) => pattern.test(value));
188
215
  }
189
216
 
190
217
  function normalizeProvider(value) {
@@ -340,7 +367,7 @@ const PERMISSION_DESCRIPTIONS = Object.freeze({
340
367
  });
341
368
 
342
369
  function relayTarget(relay) {
343
- return { id: relay.id, name: relay.name, url: relay.url };
370
+ return { id: relay.id, name: relay.name, url: relay.url, ...(relay.local ? { local: true } : {}) };
344
371
  }
345
372
 
346
373
  /**
@@ -517,6 +544,12 @@ export function settleRemoteReply(messages, messageId) {
517
544
  export function createRemoteRelayDispatcher({
518
545
  registry,
519
546
  client,
547
+ // The local target's client (remote-relay-loopback.mjs): same `request` as
548
+ // `client`, answered by this relay's own routes. Without it the local target
549
+ // does not exist, whatever the setting says.
550
+ localClient = null,
551
+ // `{ enabled, maxWaitSeconds }`: the agent-sessions settings of this relay.
552
+ getAgentSessionsSettings = () => null,
520
553
  repository,
521
554
  getCallerContext = async () => null,
522
555
  requestApproval = async () => false,
@@ -534,6 +567,17 @@ export function createRemoteRelayDispatcher({
534
567
  const inflightByConversation = new Map();
535
568
  const callTimesByConversation = new Map();
536
569
  const writeTimesByConversation = new Map();
570
+ // The loop guard: prompts sent to other relays' agents per conversation,
571
+ // counted from the user's latest own message. Kept in memory: a restart
572
+ // ends a loop of agents by itself.
573
+ const agentPromptsByConversation = new Map();
574
+ // create_session calls on the local target that passed the cap and have not
575
+ // queued their prompt yet: they count toward it, so parallel calls cannot
576
+ // slip past together.
577
+ const localCreatesByConversation = new Map();
578
+ // The once-per-conversation approval card while it is open: parallel first
579
+ // creates share the one card and its answer.
580
+ const localApprovalsByConversation = new Map();
537
581
 
538
582
  // ─── Bookkeeping ───────────────────────────────────────────────────────────
539
583
 
@@ -556,6 +600,66 @@ export function createRemoteRelayDispatcher({
556
600
  }
557
601
  }
558
602
 
603
+ /**
604
+ * Why a relay is open to a conversation: 'user' (the user mentioned it),
605
+ * 'contact' (an agent on that relay wrote to the conversation, so it may be
606
+ * answered), or null (locked).
607
+ */
608
+ function relayAccess(conversationId, relay) {
609
+ if (isUnlocked(conversationId, relay?.id)) return 'user';
610
+ try {
611
+ if (toText(relay?.relayId) && repository?.hasContactFrom?.(conversationId, relay.relayId)) return 'contact';
612
+ } catch {}
613
+ return null;
614
+ }
615
+
616
+ function agentPrompts(conversationId) {
617
+ let since = null;
618
+ try {
619
+ since = repository?.latestHumanMessageId?.(conversationId) || null;
620
+ } catch {}
621
+ const kept = agentPromptsByConversation.get(conversationId);
622
+ if (kept && kept.since === since) return kept;
623
+ const fresh = { since, count: 0 };
624
+ agentPromptsByConversation.set(conversationId, fresh);
625
+ return fresh;
626
+ }
627
+
628
+ /** `{ enabled, maxWaitSeconds }`; off and the default wait when unreadable. */
629
+ function agentSessions() {
630
+ let settings = null;
631
+ try {
632
+ settings = getAgentSessionsSettings() || null;
633
+ } catch {}
634
+ const maxWaitSeconds = Number(settings?.maxWaitSeconds);
635
+ return {
636
+ enabled: settings?.enabled === true && typeof localClient?.request === 'function',
637
+ maxWaitSeconds: Number.isFinite(maxWaitSeconds) && maxWaitSeconds > 0
638
+ ? maxWaitSeconds
639
+ : REMOTE_RELAY_LIMITS.waitMaxSeconds,
640
+ };
641
+ }
642
+
643
+ /** This relay as a target: the shape of a registry entry, marked `local`. */
644
+ async function localTarget() {
645
+ const self = (await registry.selfIdentity?.()) || {};
646
+ return {
647
+ id: REMOTE_RELAY_LOCAL_ID,
648
+ relayId: toText(self.relayId) || null,
649
+ name: toText(self.name) || toText(await registry.selfName?.()) || REMOTE_RELAY_LOCAL_ALIAS,
650
+ url: toText(self.publicUrl),
651
+ version: toText(self.version) || null,
652
+ permission: 'full',
653
+ local: true,
654
+ };
655
+ }
656
+
657
+ function adjustLocalCreates(conversationId, delta) {
658
+ const next = (localCreatesByConversation.get(conversationId) || 0) + delta;
659
+ if (next > 0) localCreatesByConversation.set(conversationId, next);
660
+ else localCreatesByConversation.delete(conversationId);
661
+ }
662
+
559
663
  function adjustInflight(conversationId, delta) {
560
664
  const next = inflight(conversationId) + delta;
561
665
  if (next > 0) inflightByConversation.set(conversationId, next);
@@ -608,7 +712,7 @@ export function createRemoteRelayDispatcher({
608
712
  // ─── Remote calls ──────────────────────────────────────────────────────────
609
713
 
610
714
  function call(ctx, method, path, { query, body, timeoutMs } = {}) {
611
- return client.request(ctx.relay, method, path, {
715
+ return (ctx.relay.local ? localClient : client).request(ctx.relay, method, path, {
612
716
  ...(query ? { query: compactQuery(query) } : {}),
613
717
  ...(body !== undefined ? { body } : {}),
614
718
  ...(timeoutMs ? { timeoutMs } : {}),
@@ -666,6 +770,8 @@ export function createRemoteRelayDispatcher({
666
770
  provider: ctx.caller.provider,
667
771
  model: ctx.caller.model,
668
772
  hops: ctx.hops,
773
+ // The conversation named above lives on the relay that reads this.
774
+ ...(ctx.relay.local ? { local: true } : {}),
669
775
  });
670
776
  }
671
777
 
@@ -878,6 +984,9 @@ export function createRemoteRelayDispatcher({
878
984
  */
879
985
  function buildProviders(catalog) {
880
986
  return listProviders(catalog).map((entry) => {
987
+ // A cloud session takes no effort; without this its models would be
988
+ // listed with the Claude provider's efforts (the ids are the same).
989
+ if (entry.provider === CLOUD_PROVIDER) return entry;
881
990
  const efforts = groupEffortsByModel(catalog.models, entry.provider, entry.models);
882
991
  return efforts.length ? { ...entry, efforts } : entry;
883
992
  });
@@ -909,6 +1018,21 @@ export function createRemoteRelayDispatcher({
909
1018
  }
910
1019
  const settings = catalog.settings[provider];
911
1020
  if (!settings) return { provider, configured: false, defaultModel: null, models: [] };
1021
+ if (provider === CLOUD_PROVIDER) {
1022
+ // Its settings route has its own shape: a switch and a default model.
1023
+ // A relay that predates the provider answers 404 and lands above.
1024
+ const enabled = settings.enabled === true;
1025
+ const cloudDefault = toText(settings.defaultModel) || null;
1026
+ return {
1027
+ provider,
1028
+ configured: enabled,
1029
+ defaultModel: cloudDefault,
1030
+ models: enabled
1031
+ ? uniqueTexts([...(cloudDefault ? [cloudDefault] : []), ...(Array.isArray(settings.models) ? settings.models : [])])
1032
+ : [],
1033
+ ...(enabled ? { note: CLOUD_CREATE_NOTE } : {}),
1034
+ };
1035
+ }
912
1036
  // What POST /api/conversation/bootstrap requires, plus "not switched off".
913
1037
  const configured = settings.enabled === true
914
1038
  && (provider === 'claude' || provider === 'grok' || settings.configured === true);
@@ -940,12 +1064,18 @@ export function createRemoteRelayDispatcher({
940
1064
  return {
941
1065
  summary: `relay_info → ${ctx.relay.name}: OAR ${version || '?'}, providers ${configured.join(', ') || 'none'}`,
942
1066
  name: toText(identity?.name) || ctx.relay.name,
1067
+ ...(ctx.relay.local ? { self: true } : {}),
943
1068
  url: ctx.relay.url,
944
- openUrl: remoteConversationUrl(ctx.relay.url, ''),
1069
+ openUrl: remoteConversationUrl(ctx.relay.url, '') || null,
945
1070
  version,
946
1071
  platform: toText(status.platform) || toText(identity?.platform) || null,
947
1072
  protocol: remoteRelays ? nonNegativeInt(remoteRelays.protocol) : 0,
948
- acceptsAgentPrompts: remoteRelays ? remoteRelays.inbound !== false : null,
1073
+ // The inbound switch is about other relays' agents; this relay's own are
1074
+ // let in by the agent-sessions setting, which is on or this would not run.
1075
+ acceptsAgentPrompts: ctx.relay.local ? true : (remoteRelays ? remoteRelays.inbound !== false : null),
1076
+ ...(ctx.relay.local ? {
1077
+ sessionRules: `create_session: at most ${REMOTE_RELAY_LIMITS.localActiveSessions} sessions you started may work at the same time; a session an agent created cannot create sessions; sessions run on this relay's accounts.`,
1078
+ } : {}),
949
1079
  providers,
950
1080
  workspaces: {
951
1081
  default: toText(status.defaultSessionWorkspaceRootPath) || toText(status.workspaceRootPath) || null,
@@ -970,9 +1100,11 @@ export function createRemoteRelayDispatcher({
970
1100
  }
971
1101
  }
972
1102
 
973
- function mapSession(row, workerIds) {
1103
+ function mapSession(row, workerIds, ctx = null) {
974
1104
  const id = toText(row?.id);
975
1105
  const via = toText(row?.origin?.relayName);
1106
+ // A session an agent of that relay created there, and from which session.
1107
+ const createdBy = row?.origin?.local === true ? toText(row.origin.conversationId) : '';
976
1108
  return {
977
1109
  id,
978
1110
  title: toText(row?.title),
@@ -983,6 +1115,8 @@ export function createRemoteRelayDispatcher({
983
1115
  messageCount: nonNegativeInt(row?.messageCount),
984
1116
  cwd: toText(row?.currentWorkspaceRootPath) || null,
985
1117
  ...(via ? { via } : {}),
1118
+ ...(createdBy ? { createdBy } : {}),
1119
+ ...(createdBy && ctx?.relay?.local && createdBy === ctx.conversationId ? { startedByYou: true } : {}),
986
1120
  };
987
1121
  }
988
1122
 
@@ -1016,7 +1150,7 @@ export function createRemoteRelayDispatcher({
1016
1150
  let consumed = 0;
1017
1151
  for (const row of rows) {
1018
1152
  consumed += 1;
1019
- if (keep(row)) sessions.push(mapSession(row, workerIds));
1153
+ if (keep(row)) sessions.push(mapSession(row, workerIds, ctx));
1020
1154
  if (sessions.length >= limit) break;
1021
1155
  }
1022
1156
  scanned += consumed;
@@ -1133,7 +1267,7 @@ export function createRemoteRelayDispatcher({
1133
1267
  cwd: toText(conversation?.currentWorkspaceRootPath) || null,
1134
1268
  archived: conversation?.archived === true,
1135
1269
  ...(via ? { via } : {}),
1136
- openUrl: remoteConversationUrl(ctx.relay.url, conversationId),
1270
+ openUrl: remoteConversationUrl(ctx.relay.url, conversationId) || null,
1137
1271
  },
1138
1272
  messages,
1139
1273
  ...(inFlight ? { inFlight } : {}),
@@ -1264,7 +1398,8 @@ export function createRemoteRelayDispatcher({
1264
1398
  }
1265
1399
 
1266
1400
  async function deliverAndWait(action, ctx, { session, sent, waitSeconds, extra: given = {} }) {
1267
- const openUrl = remoteConversationUrl(ctx.relay.url, session);
1401
+ // Null for this relay itself while it has no public URL: no link to offer.
1402
+ const openUrl = remoteConversationUrl(ctx.relay.url, session) || null;
1268
1403
  // `extra.note` (how the effort was settled) goes in front of the outcome's own.
1269
1404
  const { note: extraNote, ...extra } = given;
1270
1405
  const result = await deliverOutcome(action, ctx, { session, sent, waitSeconds, extra, openUrl });
@@ -1324,22 +1459,27 @@ export function createRemoteRelayDispatcher({
1324
1459
  const catalog = await loadCatalog(ctx);
1325
1460
  const providers = buildProviders(catalog);
1326
1461
  const configured = providers.filter((entry) => entry.configured).map((entry) => entry.provider);
1327
- const provider = args.provider || normalizeProvider(ctx.caller.provider) || 'github';
1462
+ const provider = createProvider(ctx.caller, args);
1463
+ const cloud = provider === CLOUD_PROVIDER;
1328
1464
  const entry = providers.find((candidate) => candidate.provider === provider);
1329
1465
  if (!entry?.configured) {
1330
1466
  throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.providerUnavailable,
1331
1467
  `Relay "${ctx.relay.name}" has no ${provider} provider set up. Providers there: ${configured.join(', ') || 'none'}. Pass provider to pick one.`,
1332
- { providers: configured });
1468
+ { providers: configured, ...(cloud && catalog.settings[CLOUD_PROVIDER] ? { remoteCode: CLOUD_DISABLED_CODE } : {}) });
1333
1469
  }
1334
1470
 
1335
1471
  let model = args.model || '';
1336
1472
  let modelSource = model ? 'requested' : '';
1337
1473
  let note = null;
1338
- if (!model) {
1474
+ // A cloud session without a requested model gets the Claude Cloud tab's
1475
+ // default, as New Chat does: the bootstrap picks it, nothing is mirrored.
1476
+ if (!model && cloud) {
1477
+ modelSource = 'remote default';
1478
+ } else if (!model) {
1339
1479
  model = matchOfferedModel(ctx.caller.model, entry.models);
1340
1480
  modelSource = model ? 'same as this session' : '';
1341
1481
  }
1342
- if (!model) {
1482
+ if (!model && !cloud) {
1343
1483
  model = entry.defaultModel || entry.models[0] || '';
1344
1484
  modelSource = 'remote default';
1345
1485
  if (toText(ctx.caller.model)) {
@@ -1353,7 +1493,10 @@ export function createRemoteRelayDispatcher({
1353
1493
  // The effort mirrors the caller like the model does: this session's when
1354
1494
  // the remote lists it for the model (or lists nothing), else the remote's.
1355
1495
  let choice = { effort: normalizeEffort(args.effort), source: EFFORT_SOURCES.requested, note: null };
1356
- if (!choice.effort) {
1496
+ if (!choice.effort && cloud) {
1497
+ // A cloud session takes no effort: none is mirrored, none is reported.
1498
+ choice = { effort: '', source: '', note: null };
1499
+ } else if (!choice.effort) {
1357
1500
  const callerEffort = normalizeEffort(ctx.caller.effort);
1358
1501
  const offered = catalogEffortsFor(catalog.models, provider, model);
1359
1502
  if (callerEffort && (!offered || offered.includes(callerEffort))) {
@@ -1377,6 +1520,9 @@ export function createRemoteRelayDispatcher({
1377
1520
  ...(effort ? { reasoningEffort: effort } : {}),
1378
1521
  title,
1379
1522
  ...(args.cwd ? { cwd: args.cwd } : {}),
1523
+ // What New Chat sends for a Claude Cloud conversation; the bootstrap
1524
+ // validates it and refuses with its own codes.
1525
+ ...(cloud ? { cloudSource: { repoUrl: args.repo, ...(args.branch ? { branch: args.branch } : {}) } } : {}),
1380
1526
  origin,
1381
1527
  },
1382
1528
  });
@@ -1394,7 +1540,8 @@ export function createRemoteRelayDispatcher({
1394
1540
  };
1395
1541
  bootstrap = await bootstrapWith('');
1396
1542
  }
1397
- choice = settleEffort(ctx, choice, bootstrap?.preferredReasoningEffort);
1543
+ // Nothing to settle for a cloud session that was asked for no effort.
1544
+ if (!cloud || choice.effort) choice = settleEffort(ctx, choice, bootstrap?.preferredReasoningEffort);
1398
1545
  const session = toText(bootstrap?.conversationId);
1399
1546
  if (!session) {
1400
1547
  throw new DispatchFailure(502, `${REMOTE_RELAY_ERROR_CODES.httpPrefix}502`,
@@ -1403,7 +1550,11 @@ export function createRemoteRelayDispatcher({
1403
1550
  const boundModel = toText(bootstrap?.selectedModel) || model;
1404
1551
  let sent;
1405
1552
  try {
1406
- ({ sent, choice } = await postPromptWithEffort(ctx, { session, text: args.text, origin, model: boundModel, mode }, choice));
1553
+ const posted = await postPromptWithEffort(ctx, { session, text: args.text, origin, model: boundModel, mode }, choice);
1554
+ sent = posted.sent;
1555
+ if (!cloud || choice.effort) choice = posted.choice;
1556
+ // From here the new session's queued turn counts toward the cap itself.
1557
+ ctx.onPromptQueued?.();
1407
1558
  } catch (error) {
1408
1559
  if (!choice.effort || !isEffortRejection(error)) throw error;
1409
1560
  // The session exists by now: say so, or the agent starts a second one.
@@ -1415,7 +1566,7 @@ export function createRemoteRelayDispatcher({
1415
1566
  session,
1416
1567
  ...(remoteCode ? { remoteCode } : {}),
1417
1568
  ...(supported.length ? { supportedEfforts: supported } : {}),
1418
- openUrl: remoteConversationUrl(ctx.relay.url, session),
1569
+ openUrl: remoteConversationUrl(ctx.relay.url, session) || null,
1419
1570
  note: 'Use send with this session and an effort it takes (or none); do not create another session.',
1420
1571
  });
1421
1572
  }
@@ -1429,6 +1580,7 @@ export function createRemoteRelayDispatcher({
1429
1580
  modelSource,
1430
1581
  mode: toText(bootstrap?.preferredRelayMode) || mode || null,
1431
1582
  title,
1583
+ ...(cloud ? { repo: toText(bootstrap?.cloud?.repoUrl) || args.repo, branch: toText(bootstrap?.cloud?.branch) || args.branch || null } : {}),
1432
1584
  ...(toText(bootstrap?.workspaceRootWarning) ? { cwdWarning: toText(bootstrap.workspaceRootWarning) } : {}),
1433
1585
  ...(note ? { modelNote: note } : {}),
1434
1586
  ...effortResult(choice),
@@ -1436,6 +1588,37 @@ export function createRemoteRelayDispatcher({
1436
1588
  });
1437
1589
  }
1438
1590
 
1591
+ /** The provider a new session gets: the one asked for, else the calling session's. */
1592
+ function createProvider(caller, args) {
1593
+ return args.provider || normalizeProvider(caller?.provider) || 'github';
1594
+ }
1595
+
1596
+ /**
1597
+ * What is wrong with a create_session call that only the calling session
1598
+ * reveals, or null. Checked before anyone is asked for approval: a card for
1599
+ * a call that cannot run would be a question about nothing.
1600
+ */
1601
+ function createSessionProblem(caller, args) {
1602
+ if (createProvider(caller, args) === CLOUD_PROVIDER && !args.repo) {
1603
+ // The provider came from the calling session (the validator requires
1604
+ // repo when the call names claude-cloud itself).
1605
+ return `This session's provider is ${CLOUD_PROVIDER}, which a new session gets by default, and a Claude Cloud session needs repo (https://github.com/owner/repo or owner/repo). Pass repo (and branch), or pass another provider (relay_info lists them).`;
1606
+ }
1607
+ return null;
1608
+ }
1609
+
1610
+ /** Whether `session` (a conversation or session id on this relay) is the caller's own conversation. */
1611
+ function isOwnConversation(ctx, session) {
1612
+ const id = toText(session);
1613
+ if (!id) return false;
1614
+ if (id === ctx.conversationId) return true;
1615
+ try {
1616
+ return toText(resolveConversationId(id)) === ctx.conversationId;
1617
+ } catch {
1618
+ return false;
1619
+ }
1620
+ }
1621
+
1439
1622
  /** The answer body the remote's answer route expects for this question. */
1440
1623
  function buildAnswer(question, args) {
1441
1624
  const fields = schemaFields(question.requestSchema);
@@ -1474,6 +1657,11 @@ export function createRemoteRelayDispatcher({
1474
1657
  `Question ${args.question_id} was not found on relay "${ctx.relay.name}".`);
1475
1658
  }
1476
1659
  const session = toText(question.conversationId) || null;
1660
+ if (ctx.relay.local && session && isOwnConversation(ctx, session)) {
1661
+ throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.ownSession,
1662
+ `Question ${args.question_id} belongs to this conversation itself; an agent cannot answer its own question. It is the user's to answer.`,
1663
+ { session });
1664
+ }
1477
1665
  if (isRemoteRelayApprovalQuestion(question)) {
1478
1666
  throw new DispatchFailure(403, REMOTE_RELAY_ERROR_CODES.forbidden,
1479
1667
  `Question ${args.question_id} is relay "${ctx.relay.name}"'s approval of its own agent's call to another relay; only its user can answer it.`,
@@ -1588,14 +1776,20 @@ export function createRemoteRelayDispatcher({
1588
1776
 
1589
1777
  async function listRelays(conversationId) {
1590
1778
  const relays = (await registry.list()) || [];
1779
+ const settings = agentSessions();
1780
+ const local = settings.enabled ? await localTarget() : null;
1591
1781
  const listed = relays.map((relay) => ({
1592
1782
  name: relay.name,
1593
1783
  url: relay.url,
1594
1784
  online: toText(relay.lastStatus).toLowerCase() === 'online',
1595
1785
  version: relay.version || null,
1596
1786
  permission: relay.permission,
1597
- unlocked: isUnlocked(conversationId, relay.id),
1787
+ unlocked: relayAccess(conversationId, relay) !== null,
1598
1788
  }));
1789
+ // This relay itself comes first: no address, always online, never locked.
1790
+ if (local) {
1791
+ listed.unshift({ name: local.name, self: true, online: true, version: local.version, permission: 'full', unlocked: true });
1792
+ }
1599
1793
  const unlocked = listed.filter((relay) => relay.unlocked).length;
1600
1794
  return {
1601
1795
  status: 200,
@@ -1604,21 +1798,155 @@ export function createRemoteRelayDispatcher({
1604
1798
  summary: `list_relays: ${listed.length} relay${listed.length === 1 ? '' : 's'}, ${unlocked} unlocked`,
1605
1799
  self: { name: (await registry.selfName?.()) || null },
1606
1800
  relays: listed,
1607
- hint: 'Mention @name in the chat to unlock a relay.',
1801
+ limits: {
1802
+ maxWaitSeconds: settings.maxWaitSeconds,
1803
+ ...(local ? { maxActiveSessions: REMOTE_RELAY_LIMITS.localActiveSessions } : {}),
1804
+ },
1805
+ hint: 'The user unlocks a relay by mentioning @name in the chat; a relay whose agent wrote to this conversation is open as well.'
1806
+ + (local
1807
+ ? ` The relay marked self is the one you run on: name it (or "${REMOTE_RELAY_LOCAL_ALIAS}") to start and use sessions here, no mention needed. At most ${REMOTE_RELAY_LIMITS.localActiveSessions} sessions you started may work at the same time, and they run on this relay's accounts.`
1808
+ : ''),
1608
1809
  },
1609
1810
  };
1610
1811
  }
1611
1812
 
1612
- async function runRelayAction({ conversationId, action, args, req, signal }) {
1613
- const resolved = await registry.resolve(args.relay);
1614
- if (!resolved?.relay) {
1615
- const names = Array.isArray(resolved?.names) ? resolved.names : [];
1616
- const error = resolved?.error === 'ambiguous'
1617
- ? `"${args.relay}" matches several relays (${names.join(', ')}); use the full name.`
1618
- : `No paired relay is called "${args.relay}". Known relays: ${names.join(', ') || 'none'}.`;
1619
- return failure(404, REMOTE_RELAY_ERROR_CODES.unknown, error, { relays: names });
1813
+ /**
1814
+ * The relay a call names: `{ relay }` (a registry entry, or the local
1815
+ * target), or `{ failure }`. "this" always means the relay the agent runs
1816
+ * on; its name does too, unless a paired relay answers to that name.
1817
+ */
1818
+ async function resolveTarget(name) {
1819
+ const query = toText(name).replace(/^@/, '').toLowerCase();
1820
+ const settings = agentSessions();
1821
+ const alias = query === REMOTE_RELAY_LOCAL_ALIAS;
1822
+ const resolved = alias ? null : await registry.resolve(name);
1823
+ if (resolved?.relay) return { relay: resolved.relay };
1824
+ const selfName = toText(await registry.selfName?.());
1825
+ if (alias || (resolved?.error !== 'ambiguous' && selfName && selfName.toLowerCase() === query)) {
1826
+ if (!settings.enabled) {
1827
+ return {
1828
+ failure: failure(403, REMOTE_RELAY_ERROR_CODES.localDisabled,
1829
+ 'Agents may not start or use sessions on this relay: its setting "Agents may start and use sessions on this relay" (Settings → Relays → Agent sessions) is off. Ask the user to turn it on; do not retry until then.',
1830
+ { relay: selfName || REMOTE_RELAY_LOCAL_ALIAS }),
1831
+ };
1832
+ }
1833
+ if (!repository) {
1834
+ return {
1835
+ failure: failure(503, REMOTE_RELAY_ERROR_CODES.localDisabled,
1836
+ 'Sessions on this relay are not available to agents right now: its storage for them could not be opened (see the server log).',
1837
+ { relay: selfName || REMOTE_RELAY_LOCAL_ALIAS }),
1838
+ };
1839
+ }
1840
+ return { relay: await localTarget() };
1841
+ }
1842
+ const names = Array.isArray(resolved?.names) ? resolved.names : [];
1843
+ const error = resolved?.error === 'ambiguous'
1844
+ ? `"${name}" matches several relays (${names.join(', ')}); use the full name.`
1845
+ : `No paired relay is called "${name}". Known relays: ${names.join(', ') || 'none'}${settings.enabled ? `; this relay itself is "${selfName || REMOTE_RELAY_LOCAL_ALIAS}" (or "${REMOTE_RELAY_LOCAL_ALIAS}")` : ''}.`;
1846
+ return { failure: failure(404, REMOTE_RELAY_ERROR_CODES.unknown, error, { relays: names }) };
1847
+ }
1848
+
1849
+ /**
1850
+ * The user's Allow for this conversation's agent to start sessions on this
1851
+ * relay: remembered once given, asked with a card otherwise. Calls that
1852
+ * arrive while the card is open share it and its answer. Resolves
1853
+ * `{ approved:true, asked }` or `{ approved:false, code?, error? }`.
1854
+ */
1855
+ async function ensureLocalSessionApproval({ conversationId, caller, relay, action, args, signal }) {
1856
+ const remembered = () => {
1857
+ try {
1858
+ return repository.hasLocalSessionApproval(conversationId) === true;
1859
+ } catch {
1860
+ return false;
1861
+ }
1862
+ };
1863
+ if (remembered()) return { approved: true, asked: false };
1864
+ const open = localApprovalsByConversation.get(conversationId);
1865
+ if (open) {
1866
+ const shared = await open;
1867
+ return shared.approved ? { approved: true, asked: false } : shared;
1868
+ }
1869
+ const asking = (async () => {
1870
+ const decision = await requestApproval({
1871
+ conversationId,
1872
+ callerContext: caller,
1873
+ relay: relayTarget(relay),
1874
+ action,
1875
+ args,
1876
+ summary: summarizeRemoteRelayCall({ action, ...args, relay: relay.name }),
1877
+ signal,
1878
+ localSessions: true,
1879
+ });
1880
+ if (decision !== true && decision?.approved !== true) {
1881
+ return {
1882
+ approved: false,
1883
+ code: toText(decision?.code) || REMOTE_RELAY_ERROR_CODES.approvalDenied,
1884
+ error: toText(decision?.error)
1885
+ || 'The user did not allow this agent to start a session on this relay. Go on without it, or ask the user before trying again.',
1886
+ };
1887
+ }
1888
+ try {
1889
+ repository.recordLocalSessionApproval(conversationId, caller.processingRowId || null);
1890
+ } catch (error) {
1891
+ // Not remembered: the next create asks again.
1892
+ try {
1893
+ logger?.warn?.(`${LOG_PREFIX} local session approval not stored conv=${shortId(conversationId)}: ${toText(error?.message || error).slice(0, 200)}`);
1894
+ } catch {}
1895
+ }
1896
+ return { approved: true, asked: true };
1897
+ })();
1898
+ localApprovalsByConversation.set(conversationId, asking);
1899
+ try {
1900
+ return await asking;
1901
+ } finally {
1902
+ localApprovalsByConversation.delete(conversationId);
1903
+ }
1904
+ }
1905
+
1906
+ /**
1907
+ * The local target's own rules for create_session, before anyone is asked:
1908
+ * one level, and the cap. Null when the call may go on (the caller then
1909
+ * holds a place under the cap until its prompt is queued).
1910
+ */
1911
+ function refuseLocalCreate(conversationId, relay) {
1912
+ let origin = null;
1913
+ let active = [];
1914
+ try {
1915
+ origin = repository.getConversationOrigin(conversationId);
1916
+ active = repository.listActiveLocalSessions(conversationId);
1917
+ } catch (error) {
1918
+ try {
1919
+ logger?.warn?.(`${LOG_PREFIX} local session rules unreadable conv=${shortId(conversationId)}: ${toText(error?.message || error).slice(0, 200)}`);
1920
+ } catch {}
1921
+ return failure(503, REMOTE_RELAY_ERROR_CODES.localDisabled,
1922
+ 'Sessions on this relay are not available to agents right now: its storage for them could not be read (see the server log).',
1923
+ { relay: relay.name });
1620
1924
  }
1621
- const relay = resolved.relay;
1925
+ if (origin) {
1926
+ return failure(403, REMOTE_RELAY_ERROR_CODES.nestedSession,
1927
+ 'A session that an agent created cannot create sessions. Do the work in this session, or say in your reply what the agent that prompted you should start.',
1928
+ { relay: relay.name });
1929
+ }
1930
+ const limit = REMOTE_RELAY_LIMITS.localActiveSessions;
1931
+ const starting = localCreatesByConversation.get(conversationId) || 0;
1932
+ if (active.length + starting >= limit) {
1933
+ return failure(409, REMOTE_RELAY_ERROR_CODES.sessionLimit,
1934
+ `${limit} sessions you started are still working; wait for one to finish.`,
1935
+ {
1936
+ relay: relay.name,
1937
+ limit,
1938
+ sessions: active.slice(0, limit),
1939
+ note: 'Use wait (or read_session) on one of these sessions; create_session works again once one has no turn queued or running.',
1940
+ });
1941
+ }
1942
+ return null;
1943
+ }
1944
+
1945
+ async function runRelayAction({ conversationId, action, args, req, signal, waitNote }) {
1946
+ const target = await resolveTarget(args.relay);
1947
+ if (!target.relay) return target.failure;
1948
+ const relay = target.relay;
1949
+ const local = relay.local === true;
1622
1950
  const write = isRemoteRelayWriteAction(action);
1623
1951
  const finish = (result, session) => {
1624
1952
  if (write) {
@@ -1635,23 +1963,40 @@ export function createRemoteRelayDispatcher({
1635
1963
  return result;
1636
1964
  };
1637
1965
 
1638
- if (!isUnlocked(conversationId, relay.id)) {
1966
+ // A paired relay is gated by the user's mention and its permission. The
1967
+ // local target has neither: the setting let the call in (resolveTarget).
1968
+ const access = local ? 'user' : relayAccess(conversationId, relay);
1969
+ if (!access) {
1639
1970
  return finish(failure(403, REMOTE_RELAY_ERROR_CODES.locked,
1640
1971
  `Relay "${relay.name}" is locked in this conversation. Ask the user to mention @${relay.name} (or its name) in a message to allow work there.`,
1641
1972
  { relay: relay.name }));
1642
1973
  }
1643
- if (!remoteRelayPermissionAllows(relay.permission, action)) {
1974
+ if (!local && !remoteRelayPermissionAllows(relay.permission, action)) {
1644
1975
  return finish(failure(403, REMOTE_RELAY_ERROR_CODES.forbidden,
1645
1976
  `Relay "${relay.name}" lets agents ${PERMISSION_DESCRIPTIONS[relay.permission] || 'do less'} (permission "${relay.permission}"); ${action} needs "${REMOTE_RELAY_ACTION_PERMISSION[action]}". The user can change this in Settings → Relays.`,
1646
1977
  { relay: relay.name, permission: relay.permission }));
1647
1978
  }
1979
+ if (local && OWN_SESSION_ACTIONS.has(action) && isOwnConversation({ conversationId }, args.session)) {
1980
+ return finish(failure(400, REMOTE_RELAY_ERROR_CODES.ownSession,
1981
+ `Session ${args.session} is the conversation you are running in: an agent cannot ${action} itself. Use the id of another session (list_sessions, or the one create_session returned).`,
1982
+ { relay: relay.name, session: args.session }));
1983
+ }
1648
1984
 
1649
1985
  const caller = { conversationId, ...((await getCallerContext(conversationId, req)) || {}) };
1650
1986
  caller.mode = toText(caller.mode).toLowerCase();
1651
- const hops = nonNegativeInt(caller.hops) + 1;
1652
- if (hops > REMOTE_RELAY_LIMITS.hopLimit) {
1987
+ // A call on this relay itself crosses no relay: it carries the hops of the
1988
+ // prompt it acts on unchanged.
1989
+ const hops = nonNegativeInt(caller.hops) + (local ? 0 : 1);
1990
+ // The hop limit stops a prompt from travelling on to a third relay. A
1991
+ // relay the user unlocked here stays in reach whatever prompts arrive,
1992
+ // the relay the prompts came from may always be answered, and reading
1993
+ // carries nothing on.
1994
+ const origins = Array.isArray(caller.originRelayIds) ? caller.originRelayIds.map(toText) : [];
1995
+ const answering = !!toText(relay.relayId) && origins.length > 0
1996
+ && origins.every((id) => id === toText(relay.relayId));
1997
+ if (write && access !== 'user' && !answering && hops > REMOTE_RELAY_LIMITS.hopLimit) {
1653
1998
  return finish(failure(403, REMOTE_RELAY_ERROR_CODES.hopLimit,
1654
- `This turn was started by another relay's agent (hop ${hops - 1}); forwarding it again would pass the limit of ${REMOTE_RELAY_LIMITS.hopLimit} hops.`,
1999
+ `This turn acts on a prompt from another relay's agent (hop ${hops - 1}); passing it on to relay "${relay.name}" would pass the limit of ${REMOTE_RELAY_LIMITS.hopLimit} hops. The user can allow it by mentioning @${relay.name} in this conversation.`,
1655
2000
  { relay: relay.name }));
1656
2001
  }
1657
2002
 
@@ -1662,9 +2007,65 @@ export function createRemoteRelayDispatcher({
1662
2007
  { relay: relay.name, retryAfterSeconds }));
1663
2008
  }
1664
2009
 
2010
+ if (action === 'create_session') {
2011
+ const problem = createSessionProblem(caller, args);
2012
+ if (problem) return finish(failure(400, REMOTE_RELAY_ERROR_CODES.invalidInput, problem, { relay: relay.name }));
2013
+ }
2014
+
2015
+ const localCreate = local && action === 'create_session';
2016
+ let holdsLocalCreate = false;
2017
+ const releaseLocalCreate = () => {
2018
+ if (!holdsLocalCreate) return;
2019
+ holdsLocalCreate = false;
2020
+ adjustLocalCreates(conversationId, -1);
2021
+ };
2022
+ if (localCreate) {
2023
+ const refusal = refuseLocalCreate(conversationId, relay);
2024
+ if (refusal) return finish(refusal);
2025
+ adjustLocalCreates(conversationId, 1);
2026
+ holdsLocalCreate = true;
2027
+ }
2028
+
1665
2029
  adjustInflight(conversationId, 1);
1666
2030
  try {
1667
- if (write && REMOTE_RELAY_APPROVAL_MODES.includes(caller.mode)) {
2031
+ const prompting = isRemoteRelayPromptAction(action);
2032
+ let asked = false;
2033
+ if (localCreate) {
2034
+ const decision = await ensureLocalSessionApproval({ conversationId, caller, relay, action, args, signal });
2035
+ if (!decision.approved) {
2036
+ return finish(failure(403, decision.code || REMOTE_RELAY_ERROR_CODES.approvalDenied, decision.error, { relay: relay.name }));
2037
+ }
2038
+ // The card this call raised showed its prompt: it is this call's
2039
+ // approval too, and the user has just looked at what the agent does.
2040
+ if (decision.asked) {
2041
+ asked = true;
2042
+ agentPrompts(conversationId).count = 0;
2043
+ }
2044
+ }
2045
+ if (!asked && prompting && agentPrompts(conversationId).count >= REMOTE_RELAY_LIMITS.agentPromptsBeforeAsking) {
2046
+ // Agents that prompt each other can go on without the user for good:
2047
+ // from time to time the user is asked, in any relay mode.
2048
+ const decision = await requestApproval({
2049
+ conversationId,
2050
+ callerContext: caller,
2051
+ relay: relayTarget(relay),
2052
+ action,
2053
+ args,
2054
+ summary: summarizeRemoteRelayCall({ action, ...args, relay: relay.name }),
2055
+ signal,
2056
+ agentPrompts: agentPrompts(conversationId).count,
2057
+ });
2058
+ const approved = decision === true || decision?.approved === true;
2059
+ if (!approved) {
2060
+ const code = toText(decision?.code) || REMOTE_RELAY_ERROR_CODES.approvalDenied;
2061
+ const error = toText(decision?.error)
2062
+ || `The agent has sent ${agentPrompts(conversationId).count} prompts to ${local ? 'other agents' : 'other relays\' agents'} since the user last wrote, and the user did not allow more for now.`;
2063
+ return finish(failure(403, code, error, { relay: relay.name }));
2064
+ }
2065
+ agentPrompts(conversationId).count = 0;
2066
+ asked = true;
2067
+ }
2068
+ if (!asked && write && REMOTE_RELAY_APPROVAL_MODES.includes(caller.mode)) {
1668
2069
  const decision = await requestApproval({
1669
2070
  conversationId,
1670
2071
  callerContext: caller,
@@ -1678,14 +2079,16 @@ export function createRemoteRelayDispatcher({
1678
2079
  if (!approved) {
1679
2080
  const code = toText(decision?.code) || REMOTE_RELAY_ERROR_CODES.approvalDenied;
1680
2081
  const error = toText(decision?.error)
1681
- || `The user did not allow this ${action.replace(/_/g, ' ')} on relay "${relay.name}".`;
2082
+ || `The user did not allow this ${action.replace(/_/g, ' ')} on ${local ? 'this relay' : `relay "${relay.name}"`}.`;
1682
2083
  return finish(failure(403, code, error, { relay: relay.name }));
1683
2084
  }
1684
2085
  }
1685
2086
 
1686
- const ctx = { relay, caller, hops, conversationId, signal };
2087
+ const ctx = { relay, caller, hops, conversationId, signal, onPromptQueued: releaseLocalCreate };
1687
2088
  const body = await ACTIONS[action](ctx, args);
2089
+ if (prompting) agentPrompts(conversationId).count += 1;
1688
2090
  const { summary, ...rest } = body;
2091
+ const note = [rest.note, waitNote].map(toText).filter(Boolean).join(' ');
1689
2092
  return finish({
1690
2093
  status: 200,
1691
2094
  body: {
@@ -1693,6 +2096,7 @@ export function createRemoteRelayDispatcher({
1693
2096
  relay: relay.name,
1694
2097
  summary: summary || summarizeRemoteRelayCall({ action, ...args, relay: relay.name }),
1695
2098
  ...rest,
2099
+ ...(note ? { note } : {}),
1696
2100
  },
1697
2101
  });
1698
2102
  } catch (error) {
@@ -1702,6 +2106,7 @@ export function createRemoteRelayDispatcher({
1702
2106
  if (isRemoteRelayError(error)) return finish(mapRemoteError(error, relay, { action, args }));
1703
2107
  throw error;
1704
2108
  } finally {
2109
+ releaseLocalCreate();
1705
2110
  adjustInflight(conversationId, -1);
1706
2111
  }
1707
2112
  }
@@ -1712,7 +2117,10 @@ export function createRemoteRelayDispatcher({
1712
2117
  * the caller hangs up.
1713
2118
  */
1714
2119
  async function dispatch({ conversationId, action, args, req, signal } = {}) {
1715
- const validation = validateRemoteRelayToolInput({ ...(isPlainObject(args) ? args : {}), action });
2120
+ // wait_seconds is cut to this relay's "longest wait" setting, for every
2121
+ // target: the waiting is done here, by polling the target.
2122
+ const { maxWaitSeconds } = agentSessions();
2123
+ const validation = validateRemoteRelayToolInput({ ...(isPlainObject(args) ? args : {}), action }, { maxWaitSeconds });
1716
2124
  if (!validation.ok) return failure(400, validation.code, validation.error);
1717
2125
  const requestedConversationId = toText(conversationId);
1718
2126
  let callerConversationId = requestedConversationId;
@@ -1722,6 +2130,9 @@ export function createRemoteRelayDispatcher({
1722
2130
  if (!callerConversationId) {
1723
2131
  return failure(400, REMOTE_RELAY_ERROR_CODES.invalidInput, 'conversationId is required');
1724
2132
  }
2133
+ const waitNote = validation.waitLimitedFrom
2134
+ ? `wait_seconds ${validation.waitLimitedFrom} is more than this relay allows per call (${validation.args.wait_seconds}, set by its owner); it waited at most ${validation.args.wait_seconds} s.`
2135
+ : '';
1725
2136
  try {
1726
2137
  if (validation.action === 'list_relays') return await listRelays(callerConversationId);
1727
2138
  return await runRelayAction({
@@ -1730,6 +2141,7 @@ export function createRemoteRelayDispatcher({
1730
2141
  args: validation.args,
1731
2142
  req,
1732
2143
  signal,
2144
+ waitNote,
1733
2145
  });
1734
2146
  } catch (error) {
1735
2147
  try {