@oar-sh/oar 0.9.15 → 0.9.17

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 (109) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +14 -87
  2. package/CHANGELOG.md +305 -0
  3. package/README.md +12 -11
  4. package/package.json +2 -1
  5. package/server/claude-worker/claude-attachments.mjs +7 -4
  6. package/server/claude-worker/claude-remote-relay-tool.mjs +36 -1
  7. package/server/claude-worker/claude-sdk-adapter.mjs +167 -9
  8. package/server/claude-worker/claude-session-process.mjs +131 -33
  9. package/server/claude-worker/claude-session-worker-main.mjs +266 -0
  10. package/server/claude-worker/claude-session-worker.mjs +2 -253
  11. package/server/claude-worker/claude-turn-failure.mjs +8 -0
  12. package/server/claude-worker/claude-turn-publisher.mjs +282 -13
  13. package/server/claude-worker/claude-usage-totals.mjs +108 -0
  14. package/server/claude-worker/sdk-message-normalizer.mjs +55 -0
  15. package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
  16. package/server/copilot-worker/copilot-sdk-session-process.mjs +73 -7
  17. package/server/db-schema.mjs +115 -0
  18. package/server/deepseek-worker/deepseek-claude-backend.mjs +433 -0
  19. package/server/deepseek-worker/deepseek-session-worker.mjs +37 -0
  20. package/server/migrations/0019-relay-event-subscription-options.mjs +106 -0
  21. package/server/migrations/0020-conversation-may-create-sessions.mjs +67 -0
  22. package/server/migrations/0021-conversation-group-agent-marks.mjs +77 -0
  23. package/server/migrations/0022-relay-schedules.mjs +142 -0
  24. package/server/migrations/0023-relay-asks.mjs +141 -0
  25. package/server/migrations/0024-deepseek-usage-totals.mjs +151 -0
  26. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +179 -0
  27. package/server/migrations/0026-deepseek-usage-served-models.mjs +222 -0
  28. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +101 -0
  29. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +99 -0
  30. package/server/migrations/0029-deepseek-balance-rises.mjs +99 -0
  31. package/server/migrations/0030-deepseek-usage-actuals.mjs +123 -0
  32. package/server/public/app/api-client.js +53 -0
  33. package/server/public/app/ask-user-view.js +3 -0
  34. package/server/public/app/bootstrap.js +162 -16
  35. package/server/public/app/context-tier-options.mjs +4 -0
  36. package/server/public/app/context-usage-view.mjs +17 -0
  37. package/server/public/app/conversation-group-order.mjs +35 -0
  38. package/server/public/app/conversation-provider-indicator.mjs +7 -0
  39. package/server/public/app/conversation-view.js +2 -0
  40. package/server/public/app/deepseek-settings-ui.js +342 -0
  41. package/server/public/app/inbox-ui.js +367 -0
  42. package/server/public/app/inbox-view.mjs +370 -0
  43. package/server/public/app/journal-view.js +54 -5
  44. package/server/public/app/model-selector-options.mjs +11 -0
  45. package/server/public/app/model-vision-support.mjs +2 -0
  46. package/server/public/app/no-conversation-state.mjs +1 -1
  47. package/server/public/app/plan-usage-view.mjs +165 -13
  48. package/server/public/app/provider-readiness.mjs +4 -1
  49. package/server/public/app/relay-error-ctas.mjs +8 -0
  50. package/server/public/app/relay-event-subscriptions-ui.js +4 -2
  51. package/server/public/app/relay-event-view.mjs +33 -7
  52. package/server/public/app/relay-schedule-view.mjs +119 -0
  53. package/server/public/app/relay-schedules-ui.js +172 -0
  54. package/server/public/app/settings-modal.js +11 -0
  55. package/server/public/app/settings-tabs.js +1 -1
  56. package/server/public/app/socket-handlers.js +37 -3
  57. package/server/public/app/store.js +18 -5
  58. package/server/public/index.html +567 -35
  59. package/server/relay-tools.md +18 -3
  60. package/server/repositories/conversation-group-repository.mjs +110 -17
  61. package/server/repositories/remote-relay-repository.mjs +20 -2
  62. package/server/repositories/session-repository.mjs +7 -1
  63. package/server/routes/ask-user-routes.mjs +57 -3
  64. package/server/routes/cache-routes.mjs +6 -0
  65. package/server/routes/conversation-group-routes.mjs +19 -3
  66. package/server/routes/deepseek-usage-import-routes.mjs +72 -0
  67. package/server/routes/messages-routes.mjs +195 -18
  68. package/server/routes/relay-ask-routes.mjs +387 -0
  69. package/server/routes/relay-event-routes.mjs +3 -1
  70. package/server/routes/relay-schedule-routes.mjs +57 -0
  71. package/server/routes/sessions-routes.mjs +932 -97
  72. package/server/server-runtime.mjs +203 -11
  73. package/server/services/about-service.mjs +1 -0
  74. package/server/services/cache-rebuild-service.mjs +19 -2
  75. package/server/services/claude-session-leftovers.mjs +226 -0
  76. package/server/services/conversation-preferences-service.mjs +17 -0
  77. package/server/services/deepseek-balance-service.mjs +102 -0
  78. package/server/services/deepseek-settings-service.mjs +380 -0
  79. package/server/services/deepseek-usage-import.mjs +471 -0
  80. package/server/services/model-variant-catalog-service.mjs +1 -0
  81. package/server/services/plan-usage-contract.mjs +100 -5
  82. package/server/services/plan-usage-deepseek.mjs +931 -0
  83. package/server/services/plan-usage-service.mjs +431 -0
  84. package/server/services/provider-model-selection.mjs +1 -0
  85. package/server/services/provider-reasoning-effort.mjs +15 -2
  86. package/server/services/relay-ask-link-service.mjs +174 -0
  87. package/server/services/relay-ask-service.mjs +604 -0
  88. package/server/services/relay-event-service.mjs +589 -73
  89. package/server/services/relay-schedule-service.mjs +596 -0
  90. package/server/services/remote-relay-caller-context.mjs +89 -20
  91. package/server/services/remote-relay-dispatcher.mjs +958 -31
  92. package/server/services/session-worker-launch-service.mjs +233 -82
  93. package/server/services/worker-secret-redaction.mjs +318 -0
  94. package/shared/context-window-fallbacks.mjs +3 -0
  95. package/shared/deepseek-api.mjs +137 -0
  96. package/shared/deepseek-models.mjs +114 -0
  97. package/shared/deepseek-pricing.mjs +146 -0
  98. package/shared/host-claude-session-env.mjs +101 -0
  99. package/shared/model-id.mjs +7 -0
  100. package/shared/preview-tool-core.mjs +1 -1
  101. package/shared/provider-routing.mjs +2 -2
  102. package/shared/relay-asks.mjs +145 -0
  103. package/shared/relay-events.mjs +268 -18
  104. package/shared/relay-schedules.mjs +128 -0
  105. package/shared/remote-relay-contract.mjs +554 -26
  106. package/shared/remote-relay-tool-core.mjs +16 -5
  107. package/shared/steer-settle-failure.mjs +1 -0
  108. package/shared/worker-runtime/direct-openai-image.mjs +128 -0
  109. package/shared/worker-runtime/worker-log-file.mjs +60 -0
@@ -5,20 +5,25 @@ import { randomUUID as nodeRandomUUID } from 'crypto';
5
5
  import { isFailureNoteText } from '../../shared/failure-note-text.mjs';
6
6
  import {
7
7
  REMOTE_RELAY_APPROVAL_MODES,
8
+ REMOTE_RELAY_ASK_ACTIONS,
8
9
  REMOTE_RELAY_CLOUD_PROVIDER,
9
10
  REMOTE_RELAY_EFFORT_PATTERN,
10
11
  REMOTE_RELAY_ERROR_CODES,
12
+ REMOTE_RELAY_GROUP_LIMITS,
11
13
  REMOTE_RELAY_LIMITS,
12
14
  REMOTE_RELAY_LOCAL_ALIAS,
13
15
  REMOTE_RELAY_LOCAL_ID,
14
16
  REMOTE_RELAY_PROVIDERS,
17
+ REMOTE_RELAY_SESSION_SETTINGS,
18
+ formatSessionGroupList,
15
19
  isRemoteRelayApprovalQuestion,
16
20
  isRemoteRelayPromptAction,
17
21
  isRemoteRelayWriteAction,
18
22
  normalizeRemoteRelayOrigin,
19
23
  remoteConversationUrl,
24
+ remoteRelayActionPermission,
20
25
  remoteRelayPermissionAllows,
21
- REMOTE_RELAY_ACTION_PERMISSION,
26
+ resolveSessionGroupReference,
22
27
  stripRemotePromptHeader,
23
28
  summarizeRemoteRelayCall,
24
29
  validateRemoteRelayToolInput,
@@ -37,6 +42,8 @@ import {
37
42
  isMissingRelayEventRoute,
38
43
  relayWithoutCrossRelayEventsText,
39
44
  } from './relay-event-link-service.mjs';
45
+ import { RELAY_ASK_KIND, relayQuestionKind } from '../../shared/relay-asks.mjs';
46
+ import { RELAY_ASK_REMOTE_PATHS, isMissingRelayAskRoute, relayWithoutAsksText } from './relay-ask-link-service.mjs';
40
47
 
41
48
  // The one place a `remote_relay` tool call is decided and carried out (plan
42
49
  // §5.6). Every provider adapter ends up at POST /api/remote-relays/tool, whose
@@ -51,9 +58,16 @@ import {
51
58
  // pairing, mention, permission or hop; instead:
52
59
  //
53
60
  // setting on → no acting on the caller's own conversation → rate limit →
54
- // create_session only: one level (a created session creates none), at most
55
- // localActiveSessions created sessions at work, the user's Allow once per
56
- // conversation → approval (ask/plan) → execute
61
+ // create_session only: one level (a created session creates none, unless
62
+ // the owner allowed it for that session), at most localActiveSessions
63
+ // created sessions at work, the user's Allow once per conversation →
64
+ // approval (ask/plan) → execute
65
+ //
66
+ // update_session, on either kind of target: a session the calling
67
+ // conversation started may be changed; any other (the owner's, another
68
+ // agent's, the caller's creator) needs the owner's Allow once per calling
69
+ // conversation and target (remembered as an unlock), and the target holds to
70
+ // the same rule itself (its settings route refuses without owner_approved).
57
71
  //
58
72
  // Its actions run through the same code as a paired relay's: `call()` hands
59
73
  // them to the loopback client, which reaches this relay's own routes
@@ -100,7 +114,7 @@ const TITLE_MAX = 80;
100
114
 
101
115
  // The provider settings routes a relay serves; github (Copilot) has none with
102
116
  // models — its catalogue is /api/models plus /api/status defaultModel.
103
- const SETTINGS_PROVIDERS = Object.freeze(['claude', 'cursor', 'grok', 'openai', 'claude-cloud']);
117
+ const SETTINGS_PROVIDERS = Object.freeze(['claude', 'cursor', 'grok', 'openai', 'claude-cloud', 'deepseek']);
104
118
  // A Claude Cloud session clones a GitHub repository instead of opening a
105
119
  // folder: create_session takes `repo` and `branch` for it.
106
120
  const CLOUD_PROVIDER = REMOTE_RELAY_CLOUD_PROVIDER;
@@ -109,7 +123,15 @@ const CLOUD_CREATE_NOTE = 'create_session needs repo (https://github.com/owner/r
109
123
  // while the provider is switched off.
110
124
  const CLOUD_DISABLED_CODE = 'claude_cloud_disabled';
111
125
  // The actions an agent must not aim at the conversation it runs in.
112
- const OWN_SESSION_ACTIONS = new Set(['send', 'stop', 'archive', 'wait']);
126
+ const OWN_SESSION_ACTIONS = new Set(['send', 'stop', 'archive', 'wait', 'update_session']);
127
+ // The route update_session reaches on the target, and the codes it answers.
128
+ const SESSION_SETTINGS_SUFFIX = '/settings';
129
+ const SESSION_SETTINGS_CODES = Object.freeze({
130
+ notFound: 'SESSION_NOT_FOUND',
131
+ busy: 'SESSION_BUSY',
132
+ groupNotFound: 'SESSION_GROUP_NOT_FOUND',
133
+ groupAmbiguous: 'SESSION_GROUP_AMBIGUOUS',
134
+ });
113
135
  // How /api/models `providersByModel` names each provider.
114
136
  const CATALOG_PROVIDER_LABELS = Object.freeze({
115
137
  github: 'github-copilot',
@@ -118,6 +140,7 @@ const CATALOG_PROVIDER_LABELS = Object.freeze({
118
140
  cursor: 'cursor',
119
141
  grok: 'grok',
120
142
  'claude-cloud': 'claude-cloud',
143
+ deepseek: 'deepseek',
121
144
  });
122
145
  // Worker statuses that mean "working on something right now" (scope active).
123
146
  const WORKING_WORKER_STATUSES = new Set(['starting', 'processing']);
@@ -436,10 +459,26 @@ function relayTarget(relay) {
436
459
  function mapPendingQuestion(question) {
437
460
  if (!isPlainObject(question) || !toText(question.id)) return null;
438
461
  if (isRemoteRelayApprovalQuestion(question)) return null;
462
+ const kind = relayQuestionKind(question);
463
+ if (kind === RELAY_ASK_KIND) {
464
+ // A question that does not block: the user's to answer, the session works on.
465
+ return {
466
+ id: toText(question.id),
467
+ kind,
468
+ question: toText(question.question || question.prompt),
469
+ options: (Array.isArray(question.options) ? question.options : []).map((option) => toText(option)).filter(Boolean),
470
+ ...(toText(question.recommendation) ? { recommendation: toText(question.recommendation) } : {}),
471
+ ...(question.urgency === 'now' ? { urgency: 'now' } : {}),
472
+ ...(isPlainObject(question.askedBy) ? { askedBy: question.askedBy } : {}),
473
+ createdAt: question.createdAt || null,
474
+ note: 'Asked with ask: it does not block the session, and only the user answers it.',
475
+ };
476
+ }
439
477
  const context = isPlainObject(question.context) ? question.context : {};
440
478
  const fields = schemaFields(question.requestSchema);
441
479
  return {
442
480
  id: toText(question.id),
481
+ kind,
443
482
  question: toText(question.prompt),
444
483
  header: toText(context.header) || null,
445
484
  options: Array.isArray(question.choices) ? question.choices.map((choice) => toText(choice)).filter(Boolean) : [],
@@ -627,6 +666,15 @@ export function createRemoteRelayDispatcher({
627
666
  // The event link (relay-event-link-service.mjs): fetches the events of
628
667
  // subscriptions on paired relays. Without it those stay refused.
629
668
  relayEventLink = null,
669
+ // The relay schedule service (relay-schedule-service.mjs): schedule and its
670
+ // siblings. Without it those actions answer that the relay lacks them.
671
+ relaySchedules = null,
672
+ // The ask service (relay-ask-service.mjs): ask and its siblings, and the
673
+ // link that looks for the answers of asks put on paired relays
674
+ // (relay-ask-link-service.mjs). Without the service those actions answer
675
+ // that the relay lacks them.
676
+ relayAsks = null,
677
+ relayAskLink = null,
630
678
  } = {}) {
631
679
  const inflightByConversation = new Map();
632
680
  const callTimesByConversation = new Map();
@@ -644,6 +692,9 @@ export function createRemoteRelayDispatcher({
644
692
  const localApprovalsByConversation = new Map();
645
693
  // The same for the first subscribe of a conversation (by approval key).
646
694
  const watchApprovalsByKey = new Map();
695
+ // And for the first update_session of a conversation on a session the
696
+ // conversation did not start there (by approval key).
697
+ const sessionSettingsApprovalsByKey = new Map();
647
698
 
648
699
  // ─── Bookkeeping ───────────────────────────────────────────────────────────
649
700
 
@@ -691,6 +742,26 @@ export function createRemoteRelayDispatcher({
691
742
  return fresh;
692
743
  }
693
744
 
745
+ /**
746
+ * A schedule another conversation set comes due in a session: that is a
747
+ * prompt of the conversation that set it, counted by its loop guard like
748
+ * a send. False (the firing is held) once the guard wants the user first;
749
+ * it lets the firing go again after the user wrote in that conversation,
750
+ * or allowed its agent's next prompt on the card.
751
+ */
752
+ function admitScheduledFiring({ createdBy } = {}) {
753
+ const requested = toText(createdBy);
754
+ if (!requested) return false;
755
+ let creator = requested;
756
+ try {
757
+ creator = toText(resolveConversationId(requested)) || requested;
758
+ } catch {}
759
+ const kept = agentPrompts(creator);
760
+ if (kept.count >= REMOTE_RELAY_LIMITS.agentPromptsBeforeAsking) return false;
761
+ kept.count += 1;
762
+ return true;
763
+ }
764
+
694
765
  /** `{ enabled, maxWaitSeconds }`; off and the default wait when unreadable. */
695
766
  function agentSessions() {
696
767
  let settings = null;
@@ -790,14 +861,20 @@ export function createRemoteRelayDispatcher({
790
861
  return `/api/conversation/${encodeURIComponent(session)}${suffix}`;
791
862
  }
792
863
 
793
- async function loadPendingQuestions(ctx, conversationId) {
864
+ /**
865
+ * The questions a remote session has open. `includeAsks` (read_session):
866
+ * the ones that don't block as well (kind "ask"); a relay without them
867
+ * ignores it. The waits leave them out: the session works on, and only the
868
+ * user answers them.
869
+ */
870
+ async function loadPendingQuestions(ctx, conversationId, { includeAsks = false } = {}) {
794
871
  try {
795
872
  const response = await call(ctx, 'GET', '/api/relay-questions', {
796
- query: { status: 'pending', conversationId },
873
+ query: { status: 'pending', conversationId, ...(includeAsks ? { includeAsks: 1 } : {}) },
797
874
  });
798
875
  return (Array.isArray(response?.questions) ? response.questions : [])
799
876
  .map(mapPendingQuestion)
800
- .filter(Boolean);
877
+ .filter((question) => question && (includeAsks || question.kind !== RELAY_ASK_KIND));
801
878
  } catch {
802
879
  return [];
803
880
  }
@@ -1105,6 +1182,157 @@ export function createRemoteRelayDispatcher({
1105
1182
  });
1106
1183
  }
1107
1184
 
1185
+ // ─── Session groups (relay_info, create_session `group`) ───────────────────
1186
+
1187
+ // The target's read-only group list: id, name and member count only.
1188
+ const GROUPS_PATH = '/api/conversation-groups';
1189
+ // The target's bootstrap refuses a group with these codes.
1190
+ const BOOTSTRAP_GROUP_CODES = Object.freeze({
1191
+ notFound: 'SESSION_GROUP_NOT_FOUND',
1192
+ ambiguous: 'SESSION_GROUP_AMBIGUOUS',
1193
+ unavailable: 'SESSION_GROUPS_UNAVAILABLE',
1194
+ invalid: 'SESSION_GROUP_INVALID',
1195
+ });
1196
+
1197
+ function groupWhere(ctx) {
1198
+ return ctx.relay.local ? 'this relay' : `relay "${ctx.relay.name}"`;
1199
+ }
1200
+
1201
+ /** A text that starts with groupWhere(), as the start of a sentence. */
1202
+ function sentence(text) {
1203
+ return `${text.charAt(0).toUpperCase()}${text.slice(1)}`;
1204
+ }
1205
+
1206
+ function mapGroupList(response) {
1207
+ return (Array.isArray(response?.groups) ? response.groups : [])
1208
+ .filter(isPlainObject)
1209
+ .map((group) => ({ id: toText(group.id), name: clipLine(group.name, 80) || 'Group', memberCount: nonNegativeInt(group.memberCount) }))
1210
+ .filter((group) => group.id);
1211
+ }
1212
+
1213
+ /**
1214
+ * Why the target's group list could not be read: `{ status, code, text }`.
1215
+ * A relay without the list (404) predates groups on create_session.
1216
+ */
1217
+ function groupListProblem(ctx, error) {
1218
+ const C = REMOTE_RELAY_ERROR_CODES;
1219
+ const where = groupWhere(ctx);
1220
+ const code = toText(error?.code);
1221
+ const status = Number(error?.status) || 0;
1222
+ if (code === C.notFound || status === 404) {
1223
+ return { status: 400, code: C.unsupported, text: `The target relay doesn't support groups on create_session yet: ${where} runs an older OAR (its group list answered 404).` };
1224
+ }
1225
+ if (code === C.unauthorized || status === 401) {
1226
+ return { status: 502, code: C.unauthorized, text: `${where} rejected this relay's token when asked for its session groups (401).` };
1227
+ }
1228
+ if (status === 403 || code === C.inboundDisabled) {
1229
+ return { status: 403, code: code || `${C.httpPrefix}403`, text: `${where} refused to list its session groups (403).` };
1230
+ }
1231
+ if (status === 503 && toText(error?.remoteBody?.error).includes('Session groups are not available')) {
1232
+ return { status: 400, code: C.unsupported, text: `Session groups are not available on ${where} (its storage for them is missing).` };
1233
+ }
1234
+ if (code === C.offline) {
1235
+ const detail = toText(error?.detail) || 'no answer';
1236
+ return { status: 502, code: C.offline, text: `${where} did not answer when asked for its session groups (${detail}).` };
1237
+ }
1238
+ if (status >= 500) {
1239
+ return { status: 502, code: code || `${C.httpPrefix}${status}`, text: `${where} failed to list its session groups (HTTP ${status}).` };
1240
+ }
1241
+ return { status: 502, code: code || `${C.httpPrefix}502`, text: `${where} could not list its session groups (${toText(error?.message) || 'unexpected answer'}).` };
1242
+ }
1243
+
1244
+ /**
1245
+ * The refusal for a group the target does not have, or has several of by
1246
+ * that name. `total` is how many there are when `groups` came capped (the
1247
+ * target's own refusal).
1248
+ */
1249
+ function unknownGroupFailure(ctx, requested, found, groups, total = null) {
1250
+ const where = groupWhere(ctx);
1251
+ const asked = clipLine(requested, 80).replace(/"/g, "'");
1252
+ const ambiguous = found.status === 'ambiguous';
1253
+ const listed = ambiguous ? found.matches : groups;
1254
+ const max = REMOTE_RELAY_GROUP_LIMITS.refusalMax;
1255
+ const count = Number.isInteger(total) && total > listed.length ? total : listed.length;
1256
+ const names = formatSessionGroupList(listed, max, count);
1257
+ let text;
1258
+ if (ambiguous) text = `"${asked}" names several session groups on ${where}: ${names}. Pass the id.`;
1259
+ else if (groups.length) text = sentence(`${where} has no session group "${asked}". Groups there: ${names}.`);
1260
+ else text = sentence(`${where} has no session groups.`);
1261
+ return new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.unknownGroup, `${text} No session was created.`, {
1262
+ group: requested,
1263
+ groups: listed.slice(0, max).map((group) => ({ id: group.id, name: group.name })),
1264
+ ...(count > max ? { groupsTotal: count } : {}),
1265
+ note: groups.length
1266
+ ? 'Pass one of these ids or names (relay_info lists them), or leave group out. create_session takes an existing group only; update_session can put the session into a new group of that name afterwards. Renaming groups is the owner\'s.'
1267
+ : 'Leave group out; update_session can put the session into a new group of that name afterwards.',
1268
+ });
1269
+ }
1270
+
1271
+ /**
1272
+ * The group a create_session names, looked up on the target before anyone
1273
+ * is asked: `{ id, name }`. Any failure to read the target's groups refuses
1274
+ * the call (a DispatchFailure), as an unknown or ambiguous group does.
1275
+ */
1276
+ async function settleRequestedGroup(ctx, requested) {
1277
+ let response;
1278
+ try {
1279
+ response = await call(ctx, 'GET', GROUPS_PATH);
1280
+ } catch (error) {
1281
+ if (!isRemoteRelayError(error)) throw error;
1282
+ const problem = groupListProblem(ctx, error);
1283
+ throw new DispatchFailure(problem.status, problem.code, `${sentence(problem.text)} No session was created.`, {
1284
+ group: requested,
1285
+ note: 'Leave group out (the owner can group the session by hand), or try again once that relay lists its groups.',
1286
+ });
1287
+ }
1288
+ const groups = mapGroupList(response);
1289
+ const found = resolveSessionGroupReference(groups, requested);
1290
+ if (found.status === 'found') return { id: found.group.id, name: found.group.name };
1291
+ throw unknownGroupFailure(ctx, requested, found, groups);
1292
+ }
1293
+
1294
+ /** A bootstrap refusal of the group (it vanished after the lookup), as the agent reads it; else null. */
1295
+ function bootstrapGroupFailure(ctx, error, requested) {
1296
+ if (!requested || !isRemoteRelayError(error)) return null;
1297
+ const body = isPlainObject(error.remoteBody) ? error.remoteBody : null;
1298
+ const remoteCode = toText(body?.code);
1299
+ if (remoteCode === BOOTSTRAP_GROUP_CODES.notFound || remoteCode === BOOTSTRAP_GROUP_CODES.ambiguous) {
1300
+ const groups = mapGroupList(body);
1301
+ const found = remoteCode === BOOTSTRAP_GROUP_CODES.ambiguous ? { status: 'ambiguous', matches: groups } : { status: 'unknown' };
1302
+ const total = Number.isInteger(body?.groupsTotal) ? body.groupsTotal : null;
1303
+ return unknownGroupFailure(ctx, requested, found, groups, total);
1304
+ }
1305
+ if (remoteCode === BOOTSTRAP_GROUP_CODES.unavailable) {
1306
+ return new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.unsupported,
1307
+ `Session groups are not available on ${groupWhere(ctx)} (its storage for them is missing). No session was created.`, { group: requested });
1308
+ }
1309
+ if (remoteCode === BOOTSTRAP_GROUP_CODES.invalid) {
1310
+ return new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.invalidInput, `${toText(body?.error) || 'group is not valid'}. No session was created.`, { group: requested });
1311
+ }
1312
+ return null;
1313
+ }
1314
+
1315
+ /** The `group` of a bootstrap answer in list_sessions' shape, or null. */
1316
+ function bootstrapGroupField(value) {
1317
+ if (!isPlainObject(value) || !toText(value.id)) return null;
1318
+ return { id: toText(value.id), name: toText(value.name) || null, role: toText(value.role) === 'head' ? 'head' : 'member' };
1319
+ }
1320
+
1321
+ /** relay_info's `groups` (latest activity first, capped), or a note why there are none. */
1322
+ async function relayGroups(ctx) {
1323
+ try {
1324
+ const groups = mapGroupList(await call(ctx, 'GET', GROUPS_PATH));
1325
+ const max = REMOTE_RELAY_GROUP_LIMITS.listMax;
1326
+ return {
1327
+ groups: groups.slice(0, max),
1328
+ ...(groups.length > max ? { groupsTotal: groups.length } : {}),
1329
+ };
1330
+ } catch (error) {
1331
+ if (!isRemoteRelayError(error)) throw error;
1332
+ return { groups: null, groupsNote: `${sentence(groupListProblem(ctx, error).text)} create_session cannot take group there.` };
1333
+ }
1334
+ }
1335
+
1108
1336
  // ─── Actions ───────────────────────────────────────────────────────────────
1109
1337
 
1110
1338
  // Plan usage as the agent may see it: an allow-list of fields, so a field
@@ -1225,9 +1453,10 @@ export function createRemoteRelayDispatcher({
1225
1453
  }
1226
1454
 
1227
1455
  async function relayInfo(ctx) {
1228
- const [identity, catalog] = await Promise.all([
1456
+ const [identity, catalog, groups] = await Promise.all([
1229
1457
  call(ctx, 'GET', '/api/relay/identity').catch(() => null),
1230
1458
  loadCatalog(ctx),
1459
+ relayGroups(ctx),
1231
1460
  ]);
1232
1461
  const status = catalog.status;
1233
1462
  const providers = buildProviders(catalog);
@@ -1247,7 +1476,7 @@ export function createRemoteRelayDispatcher({
1247
1476
  // let in by the agent-sessions setting, which is on or this would not run.
1248
1477
  acceptsAgentPrompts: ctx.relay.local ? true : (remoteRelays ? remoteRelays.inbound !== false : null),
1249
1478
  ...(ctx.relay.local ? {
1250
- 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.`,
1479
+ 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 unless the owner allowed it for that session (mayCreateSessions); sessions run on this relay's accounts. update_session: sessions you started freely, any other (the owner's, another agent's, your creator's) after the owner's Allow (once per conversation).`,
1251
1480
  } : {}),
1252
1481
  providers,
1253
1482
  workspaces: {
@@ -1257,6 +1486,8 @@ export function createRemoteRelayDispatcher({
1257
1486
  },
1258
1487
  relayModes: Array.isArray(status.supportedRelayModes) ? status.supportedRelayModes : [],
1259
1488
  defaultRelayMode: toText(status.defaultRelayMode) || null,
1489
+ // The session groups a create_session there may name (`group`).
1490
+ ...groups,
1260
1491
  };
1261
1492
  }
1262
1493
 
@@ -1305,6 +1536,8 @@ export function createRemoteRelayDispatcher({
1305
1536
  ...(via ? { via } : {}),
1306
1537
  ...(createdBy ? { createdBy } : {}),
1307
1538
  ...(createdBy && ctx?.relay?.local && createdBy === ctx.conversationId ? { startedByYou: true } : {}),
1539
+ // The owner let this agent-started session's agents start sessions.
1540
+ ...(row?.mayCreateSessions === true ? { mayCreateSessions: true } : {}),
1308
1541
  // The session group it sits in (sessions an agent started here are
1309
1542
  // grouped under that agent's session; the owner can group by hand).
1310
1543
  ...sessionGroupField(row, groups),
@@ -1511,7 +1744,7 @@ export function createRemoteRelayDispatcher({
1511
1744
  query: { limit: args.last, beforeMessageId: before?.beforeMessageId, beforeTimestamp: before?.beforeTimestamp, groupMembers: 1 },
1512
1745
  });
1513
1746
  const conversationId = toText(conversation?.id) || args.session;
1514
- const pendingQuestions = await loadPendingQuestions(ctx, conversationId);
1747
+ const pendingQuestions = await loadPendingQuestions(ctx, conversationId, { includeAsks: true });
1515
1748
  const { messages, omitted } = shapeMessages(conversation?.messages, {
1516
1749
  maxChars: args.max_chars,
1517
1750
  includeActivity: args.include_activity,
@@ -1558,6 +1791,7 @@ export function createRemoteRelayDispatcher({
1558
1791
  active: conversation?.activeTurn === true || isBusyState(conversation?.sessionState),
1559
1792
  ...sessionActivityFields(conversation, { console: true }),
1560
1793
  ...(via ? { via } : {}),
1794
+ ...(conversation?.mayCreateSessions === true ? { mayCreateSessions: true } : {}),
1561
1795
  ...(group ? { group } : {}),
1562
1796
  openUrl: remoteConversationUrl(ctx.relay.url, conversationId) || null,
1563
1797
  },
@@ -1681,7 +1915,7 @@ export function createRemoteRelayDispatcher({
1681
1915
  `Message ${args.message_id} is not in session ${args.session} on relay "${ctx.relay.name}".`);
1682
1916
  }
1683
1917
  const session = toText(around?.id) || args.session;
1684
- const outcome = await waitForReply(ctx, { session, messageId: args.message_id, waitSeconds: args.wait_seconds });
1918
+ const outcome = await waitForOwnReply(ctx, { session, messageId: args.message_id, waitSeconds: args.wait_seconds });
1685
1919
  return { summary: outcomeSummary('wait', ctx, outcome), ...outcome };
1686
1920
  }
1687
1921
 
@@ -1804,14 +2038,48 @@ export function createRemoteRelayDispatcher({
1804
2038
  };
1805
2039
  return { summary: outcomeSummary(action, ctx, outcome), ...outcome, ...extra, openUrl };
1806
2040
  }
2041
+ noteOwnPrompt(ctx, session, sent.messageId);
1807
2042
  if (waitSeconds <= 0) {
1808
2043
  const outcome = { status: 'queued', session, message_id: sent.messageId };
1809
2044
  return { summary: outcomeSummary(action, ctx, outcome), ...outcome, ...extra, openUrl };
1810
2045
  }
1811
- const outcome = await waitForReply(ctx, { session, messageId: sent.messageId, waitSeconds });
2046
+ const outcome = await waitForOwnReply(ctx, { session, messageId: sent.messageId, waitSeconds });
1812
2047
  return { summary: outcomeSummary(action, ctx, outcome), ...outcome, ...extra, openUrl };
1813
2048
  }
1814
2049
 
2050
+ /**
2051
+ * The event service learns of this conversation's own prompts: a turn_end
2052
+ * of one whose finished reply it waited for is not delivered to it again.
2053
+ */
2054
+ function noteOwnPrompt(ctx, session, messageId) {
2055
+ try {
2056
+ relayEvents?.noteOwnPrompt?.({
2057
+ watcherConversationId: ctx.conversationId,
2058
+ // This relay's own sessions: the service's default.
2059
+ ...(ctx.relay.local ? {} : { relayId: ctx.relay.id }),
2060
+ sessionId: session,
2061
+ messageId,
2062
+ });
2063
+ } catch {}
2064
+ }
2065
+
2066
+ /** waitForReply, with that turn's turn_end held while it runs and dropped once the reply came. */
2067
+ async function waitForOwnReply(ctx, { session, messageId, waitSeconds }) {
2068
+ let token = null;
2069
+ try {
2070
+ token = relayEvents?.beginOwnWait?.({ watcherConversationId: ctx.conversationId, messageId, waitMs: waitSeconds * 1000 }) || null;
2071
+ } catch {}
2072
+ let outcome = null;
2073
+ try {
2074
+ outcome = await waitForReply(ctx, { session, messageId, waitSeconds });
2075
+ return outcome;
2076
+ } finally {
2077
+ try {
2078
+ relayEvents?.endOwnWait?.(token, { received: outcome?.status === 'done' && !!outcome?.reply });
2079
+ } catch {}
2080
+ }
2081
+ }
2082
+
1815
2083
  async function send(ctx, args) {
1816
2084
  const conversation = await call(ctx, 'GET', sessionPath(args.session), { query: { limit: 1 } });
1817
2085
  const session = toText(conversation?.id) || args.session;
@@ -1863,7 +2131,7 @@ export function createRemoteRelayDispatcher({
1863
2131
  * Claude alias resolved) and what the agent should know about it, the
1864
2132
  * model left unchecked for want of a list included.
1865
2133
  */
1866
- function settleRequestedModel(ctx, catalog, provider, requested, { creating, extraModels = [] }) {
2134
+ function settleRequestedModel(ctx, catalog, provider, requested, { creating, extraModels = [], updating = false }) {
1867
2135
  const entry = catalog ? listProviders(catalog).find((candidate) => candidate.provider === provider) : null;
1868
2136
  const offered = catalog ? offeredModelList(catalog, provider) : null;
1869
2137
  const check = checkRequestedProviderModel({
@@ -1879,7 +2147,7 @@ export function createRemoteRelayDispatcher({
1879
2147
  const label = providerModelLabel(provider);
1880
2148
  if (check.status === 'unknown') {
1881
2149
  throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.unknownModel,
1882
- `${unknownProviderModelText({ model: requested, provider, suggestions: check.suggestions, models: check.models, where })} ${creating ? 'No session was created.' : 'Nothing was sent.'}`,
2150
+ `${unknownProviderModelText({ model: requested, provider, suggestions: check.suggestions, models: check.models, where })} ${creating ? 'No session was created.' : (updating ? 'Nothing was changed.' : 'Nothing was sent.')}`,
1883
2151
  {
1884
2152
  provider,
1885
2153
  model: requested,
@@ -1891,7 +2159,7 @@ export function createRemoteRelayDispatcher({
1891
2159
  if (check.status === 'unchecked') {
1892
2160
  return {
1893
2161
  model: requested,
1894
- note: `Model '${requested}' could not be checked: ${label} ${where} has no model list yet (its model discovery has not run or failed), so ${creating ? 'the session was created' : 'the prompt was sent'} with it as asked.`,
2162
+ note: `Model '${requested}' could not be checked: ${label} ${where} has no model list yet (its model discovery has not run or failed), so ${creating ? 'the session was created with it' : (updating ? 'the session was set to it' : 'the prompt was sent with it')} as asked.`,
1895
2163
  };
1896
2164
  }
1897
2165
  const tierNote = check.tierDropped ? ` ${label} ${where} lists no "[1m]" id for it, so it runs with the standard context.` : '';
@@ -2008,8 +2276,13 @@ export function createRemoteRelayDispatcher({
2008
2276
  // What New Chat sends for a Claude Cloud conversation; the bootstrap
2009
2277
  // validates it and refuses with its own codes.
2010
2278
  ...(cloud ? { cloudSource: { repoUrl: args.repo, ...(args.branch ? { branch: args.branch } : {}) } } : {}),
2279
+ // The group the lookup found (its id), else the agent's words; the
2280
+ // target resolves it again and refuses before creating anything.
2281
+ ...(args.group ? { group: ctx.group?.id || args.group } : {}),
2011
2282
  origin,
2012
2283
  },
2284
+ }).catch((error) => {
2285
+ throw bootstrapGroupFailure(ctx, error, args.group) || error;
2013
2286
  });
2014
2287
  let bootstrap;
2015
2288
  try {
@@ -2032,6 +2305,16 @@ export function createRemoteRelayDispatcher({
2032
2305
  throw new DispatchFailure(502, `${REMOTE_RELAY_ERROR_CODES.httpPrefix}502`,
2033
2306
  `Relay "${ctx.relay.name}" did not return the new session's id.`);
2034
2307
  }
2308
+ // A relay that takes group answers the key (null outside a group); one
2309
+ // that predates it created the session and passed group over.
2310
+ const groupReported = isPlainObject(bootstrap) && Object.prototype.hasOwnProperty.call(bootstrap, 'group');
2311
+ const group = groupReported ? bootstrapGroupField(bootstrap.group) : null;
2312
+ let groupWarning = null;
2313
+ if (args.group && !groupReported) {
2314
+ groupWarning = `The session was created WITHOUT a group: ${groupWhere(ctx)} ignored group "${clipLine(args.group, 80)}" (its OAR is older and does not take group on create_session). Tell the user; the owner can move the session into the group by hand.`;
2315
+ } else if (args.group && (!group || (ctx.group?.id && group.id !== ctx.group.id))) {
2316
+ groupWarning = `The session was created, but not in group "${clipLine(args.group, 80)}": ${groupWhere(ctx)} put it ${group ? `in "${group.name || group.id}" (${group.id})` : 'in no group'}. Tell the user.`;
2317
+ }
2035
2318
  const boundModel = toText(bootstrap?.selectedModel) || model;
2036
2319
  // A relay that swapped the asked model for another (one that predates
2037
2320
  // these checks does for Copilot) is named, never passed over in silence.
@@ -2060,7 +2343,7 @@ export function createRemoteRelayDispatcher({
2060
2343
  note: 'Use send with this session and an effort it takes (or none); do not create another session.',
2061
2344
  });
2062
2345
  }
2063
- return deliverAndWait('create_session', ctx, {
2346
+ const result = await deliverAndWait('create_session', ctx, {
2064
2347
  session,
2065
2348
  sent,
2066
2349
  waitSeconds: args.wait_seconds,
@@ -2074,8 +2357,18 @@ export function createRemoteRelayDispatcher({
2074
2357
  ...(toText(bootstrap?.workspaceRootWarning) ? { cwdWarning: toText(bootstrap.workspaceRootWarning) } : {}),
2075
2358
  ...(note ? { modelNote: note } : {}),
2076
2359
  ...effortResult(choice),
2360
+ // The session group it went into, as list_sessions shows it.
2361
+ group,
2362
+ ...(groupWarning ? { groupWarning } : {}),
2077
2363
  },
2078
2364
  });
2365
+ if (!groupWarning) return result;
2366
+ // Never a plain success: the summary and the note say what is missing.
2367
+ return {
2368
+ ...result,
2369
+ summary: `${result.summary} (no group: ${groupReported ? 'not applied' : 'target too old'})`,
2370
+ note: [groupWarning, result.note].map(toText).filter(Boolean).join(' '),
2371
+ };
2079
2372
  }
2080
2373
 
2081
2374
  /** The provider a new session gets: the one asked for, else the calling session's. */
@@ -2164,6 +2457,11 @@ export function createRemoteRelayDispatcher({
2164
2457
  `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.`,
2165
2458
  { session });
2166
2459
  }
2460
+ if (toText(question.kind) === RELAY_ASK_KIND) {
2461
+ throw new DispatchFailure(403, REMOTE_RELAY_ERROR_CODES.forbidden,
2462
+ `Question ${args.question_id} was asked with ask: it is the user's to answer, in the Inbox or that session, not an agent's. Leave it to the user.`,
2463
+ { session });
2464
+ }
2167
2465
  if (toText(question.status) && toText(question.status) !== 'pending') {
2168
2466
  throw new DispatchFailure(409, `${REMOTE_RELAY_ERROR_CODES.httpPrefix}409`,
2169
2467
  `Question ${args.question_id} on relay "${ctx.relay.name}" is already ${toText(question.status).replace(/_/g, ' ')}.`,
@@ -2221,6 +2519,244 @@ export function createRemoteRelayDispatcher({
2221
2519
  };
2222
2520
  }
2223
2521
 
2522
+ // ─── update_session ────────────────────────────────────────────────────────
2523
+
2524
+ /** The settings of a call (`args` without relay and session), as the target's route takes them. */
2525
+ function requestedSettings(args) {
2526
+ const settings = {};
2527
+ for (const key of REMOTE_RELAY_SESSION_SETTINGS) {
2528
+ if (Object.prototype.hasOwnProperty.call(args, key)) settings[key] = args[key];
2529
+ }
2530
+ return settings;
2531
+ }
2532
+
2533
+ /**
2534
+ * A refusal of the target's settings route, as the agent reads it (a
2535
+ * DispatchFailure), or null for one mapRemoteError handles (offline, token,
2536
+ * inbound switch). A 404 without the route's own code is a relay that
2537
+ * predates update_session.
2538
+ */
2539
+ function sessionSettingsFailure(ctx, error, session) {
2540
+ if (!isRemoteRelayError(error)) return null;
2541
+ const C = REMOTE_RELAY_ERROR_CODES;
2542
+ const body = isPlainObject(error.remoteBody) ? error.remoteBody : {};
2543
+ const remoteCode = toText(body.code);
2544
+ const status = Number(error.status) || 0;
2545
+ const where = ctx.relay.local ? 'this relay' : `relay "${ctx.relay.name}"`;
2546
+ const text = toText(body.error) || toText(error.message);
2547
+ // What the target wrote before the step that failed.
2548
+ const changed = Array.isArray(body.changed) ? body.changed.map(toText).filter(Boolean) : [];
2549
+ const failed = (failureStatus, code, message, extra = {}) => new DispatchFailure(failureStatus, code, message, { session, ...extra, changed });
2550
+ if (remoteCode === 'PROCESS_LIST_UNREADABLE') {
2551
+ return failed(503, `${C.httpPrefix}503`, text, { remoteCode, note: 'That relay could not read its process list for a moment: try the change again shortly.' });
2552
+ }
2553
+ if (status === 404 || toText(error.code) === C.notFound) {
2554
+ if (remoteCode === SESSION_SETTINGS_CODES.notFound) {
2555
+ return new DispatchFailure(404, C.notFound, `Session ${session} was not found on ${where}.`, { session });
2556
+ }
2557
+ return new DispatchFailure(400, C.unsupported,
2558
+ `${sentence(where)} cannot change a session's settings for agents yet: it runs an older OAR (no update_session there). Nothing was changed.`, { session });
2559
+ }
2560
+ if (remoteCode === C.permission) {
2561
+ return failed(403, C.permission, `${text} Nothing was changed.`);
2562
+ }
2563
+ if (remoteCode === SESSION_SETTINGS_CODES.busy) {
2564
+ return failed(409, C.busy, text, { reason: toText(body.reason) || null });
2565
+ }
2566
+ if (remoteCode === SESSION_SETTINGS_CODES.groupNotFound || remoteCode === SESSION_SETTINGS_CODES.groupAmbiguous) {
2567
+ return failed(400, C.unknownGroup, text, {
2568
+ groups: mapGroupList(body).slice(0, REMOTE_RELAY_GROUP_LIMITS.refusalMax).map((group) => ({ id: group.id, name: group.name })),
2569
+ ...(Number.isInteger(body.groupsTotal) ? { groupsTotal: body.groupsTotal } : {}),
2570
+ note: 'Pass one of these ids, a name for a new group, or "none".',
2571
+ });
2572
+ }
2573
+ if (status === 403 && remoteCode && remoteCode !== C.inboundDisabled) {
2574
+ return failed(403, remoteCode, text || `${sentence(where)} refused the change.`);
2575
+ }
2576
+ if (status >= 400 && status < 500 && status !== 401 && remoteCode !== C.inboundDisabled) {
2577
+ return failed(status, status === 429 ? C.rateLimited : C.invalidInput, text || `${sentence(where)} refused the change.`, remoteCode ? { remoteCode } : {});
2578
+ }
2579
+ // A step that failed on that relay after an earlier one wrote (a CLI that
2580
+ // would not restart in the new folder): what was written comes along.
2581
+ if (status >= 500 && (changed.length || toText(body.error))) {
2582
+ return failed(502, `${C.httpPrefix}${status}`, text || `${sentence(where)} failed to make the change.`, remoteCode ? { remoteCode } : {});
2583
+ }
2584
+ return null;
2585
+ }
2586
+
2587
+ async function sessionSettingsCall(ctx, session, body) {
2588
+ try {
2589
+ // The origin names the calling conversation: the target changes freely
2590
+ // only a session that conversation started.
2591
+ const origin = await buildOrigin(ctx);
2592
+ return await call(ctx, 'PATCH', sessionPath(session, SESSION_SETTINGS_SUFFIX), { body: { ...body, origin }, timeoutMs: 60_000 });
2593
+ } catch (error) {
2594
+ throw sessionSettingsFailure(ctx, error, session) || error;
2595
+ }
2596
+ }
2597
+
2598
+ /**
2599
+ * What an update_session would change, checked before anyone is asked:
2600
+ * the model against the target's list (as send checks it), the effort
2601
+ * against what that model takes, then the target's own dry run (groups,
2602
+ * folder, its rule). Throws a DispatchFailure for anything refused.
2603
+ */
2604
+ async function planSessionUpdate(ctx, args) {
2605
+ const conversation = await call(ctx, 'GET', sessionPath(args.session), { query: { limit: 1 } });
2606
+ const session = toText(conversation?.id) || args.session;
2607
+ const runtime = isPlainObject(conversation?.runtimeSession) ? conversation.runtimeSession : {};
2608
+ const provider = toText(runtime.providerType).toLowerCase() || 'github';
2609
+ const where = `on relay "${ctx.relay.name}"`;
2610
+ const settings = requestedSettings(args);
2611
+ const notes = [];
2612
+ let catalog = null;
2613
+ if (settings.model || settings.effort) catalog = await loadCatalog(ctx).catch(() => null);
2614
+ const ownModel = toText(conversation?.preferredModel) || toText(runtime.providerModel) || toText(runtime.model);
2615
+ if (settings.model) {
2616
+ if (provider === 'grok' && ownModel && settings.model.toLowerCase() !== ownModel.toLowerCase()) {
2617
+ throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.invalidInput,
2618
+ `A Grok session keeps the model it was started with (${ownModel}); start a new session for another one. Nothing was changed.`, { session });
2619
+ }
2620
+ const settled = settleRequestedModel(ctx, catalog, provider, settings.model, {
2621
+ creating: false,
2622
+ updating: true,
2623
+ extraModels: [
2624
+ ...(Array.isArray(catalog?.settings?.[provider]?.availableModels) ? catalog.settings[provider].availableModels : []),
2625
+ runtime.providerModel,
2626
+ runtime.model,
2627
+ ],
2628
+ });
2629
+ settings.model = settled.model;
2630
+ if (settled.note) notes.push(settled.note);
2631
+ // A Copilot model named with its effort brings that effort.
2632
+ if (!settings.effort && normalizeEffort(settled.effort)) settings.effort = normalizeEffort(settled.effort);
2633
+ }
2634
+ if (settings.effort) {
2635
+ if (provider === CLOUD_PROVIDER) {
2636
+ throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.invalidInput,
2637
+ `A Claude Cloud session takes no reasoning effort. Nothing was changed.`, { session });
2638
+ }
2639
+ const model = settings.model || ownModel;
2640
+ const offered = catalog ? catalogEffortsFor(catalog.models, provider, model) : null;
2641
+ if (offered && offered.length && !offered.includes(settings.effort)) {
2642
+ throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.invalidInput,
2643
+ `${model || provider} ${where} does not take effort "${settings.effort}"${supportedEffortsText(offered)}. Nothing was changed.`,
2644
+ { session, supportedEfforts: offered });
2645
+ }
2646
+ if (!offered) notes.push(`Effort "${settings.effort}" could not be checked against ${model || provider} ${where} (no list of its efforts); it was set as asked.`);
2647
+ }
2648
+ const dry = await sessionSettingsCall(ctx, session, { settings, dry_run: true });
2649
+ if (!Array.isArray(dry?.changed)) {
2650
+ throw new DispatchFailure(502, `${REMOTE_RELAY_ERROR_CODES.httpPrefix}502`, `Relay "${ctx.relay.name}" did not answer the check of the change as expected. Nothing was changed.`, { session });
2651
+ }
2652
+ return {
2653
+ session,
2654
+ title: toText(conversation?.title) || null,
2655
+ provider,
2656
+ // Whether the owner must allow it: every session the calling
2657
+ // conversation did not start itself (the target decides).
2658
+ approvalNeeded: dry.approvalNeeded !== false,
2659
+ changed: dry.changed.map(toText).filter(Boolean),
2660
+ current: isPlainObject(dry.settings) ? dry.settings : null,
2661
+ group: isPlainObject(dry.group) ? dry.group : null,
2662
+ settings,
2663
+ notes: [...notes, ...(Array.isArray(dry.notes) ? dry.notes.map(toText).filter(Boolean) : [])],
2664
+ };
2665
+ }
2666
+
2667
+ /** The `settings` of an answer as the agent reads it: the target's, the group in list_sessions' shape. */
2668
+ function settingsResult(settings) {
2669
+ if (!isPlainObject(settings)) return null;
2670
+ return { ...settings, group: bootstrapGroupField(settings.group) };
2671
+ }
2672
+
2673
+ async function updateSession(ctx, args) {
2674
+ const plan = ctx.updatePlan || await planSessionUpdate(ctx, args);
2675
+ const openUrl = remoteConversationUrl(ctx.relay.url, plan.session) || null;
2676
+ const where = `${ctx.relay.name} session ${shortId(plan.session)}`;
2677
+ if (!plan.changed.length) {
2678
+ return {
2679
+ summary: `update_session → ${where}: nothing to change`,
2680
+ session: plan.session,
2681
+ settings: settingsResult(plan.current),
2682
+ changed: [],
2683
+ openUrl,
2684
+ note: [...plan.notes, 'The session already has these settings; nothing was changed.'].join(' '),
2685
+ };
2686
+ }
2687
+ const answer = await sessionSettingsCall(ctx, plan.session, { settings: plan.settings, owner_approved: plan.approvalNeeded });
2688
+ const changed = Array.isArray(answer?.changed) ? answer.changed.map(toText).filter(Boolean) : plan.changed;
2689
+ const notes = [...plan.notes.filter((note) => !(answer?.notes || []).includes(note)), ...(Array.isArray(answer?.notes) ? answer.notes.map(toText).filter(Boolean) : [])];
2690
+ if (changed.includes('mode')) {
2691
+ notes.push('This mode lasts until the next send that names another mode or comes from a caller in another mode: a send without mode passes the caller\'s own mode.');
2692
+ }
2693
+ return {
2694
+ summary: `update_session → ${where}: ${changed.join(', ') || 'nothing changed'}`,
2695
+ session: plan.session,
2696
+ settings: settingsResult(answer?.settings),
2697
+ changed,
2698
+ ...(typeof answer?.relaunched === 'boolean' ? { relaunched: answer.relaunched } : {}),
2699
+ ...(isPlainObject(answer?.groupCreated) ? { groupCreated: { id: toText(answer.groupCreated.id), name: toText(answer.groupCreated.name) || null } } : {}),
2700
+ openUrl,
2701
+ ...(notes.length ? { note: notes.join(' ') } : {}),
2702
+ };
2703
+ }
2704
+
2705
+ // The owner's Allow for a conversation to change sessions on a relay that it
2706
+ // did not start itself, stored as an unlock under `<relay id>:session-settings`.
2707
+ function sessionSettingsApprovalKey(relayId) {
2708
+ return `${toText(relayId) || REMOTE_RELAY_LOCAL_ID}:session-settings`;
2709
+ }
2710
+
2711
+ /**
2712
+ * Asked on the first update_session of a conversation that aims at a
2713
+ * session on that relay it did not start itself (the owner's, another
2714
+ * agent's, its own creator's); remembered once allowed.
2715
+ * Parallel calls share the open card. Resolves `{ approved, asked }` or
2716
+ * `{ approved:false, code, error }`.
2717
+ */
2718
+ async function ensureSessionSettingsApproval(ctx, plan, args) {
2719
+ const { conversationId } = ctx;
2720
+ const key = sessionSettingsApprovalKey(ctx.relay.id);
2721
+ if (hasApprovalKey(conversationId, key)) return { approved: true, asked: false };
2722
+ const openKey = `${conversationId}:${key}`;
2723
+ const open = sessionSettingsApprovalsByKey.get(openKey);
2724
+ if (open) {
2725
+ const shared = await open;
2726
+ return shared.approved ? { approved: true, asked: false } : shared;
2727
+ }
2728
+ const asking = (async () => {
2729
+ const decision = await requestApproval({
2730
+ conversationId,
2731
+ callerContext: ctx.caller,
2732
+ relay: relayTarget(ctx.relay),
2733
+ action: 'update_session',
2734
+ args,
2735
+ summary: summarizeRemoteRelayCall({ action: 'update_session', ...args, relay: ctx.relay.name }),
2736
+ signal: ctx.signal,
2737
+ sessionSettings: { session: plan.session, title: plan.title, changed: plan.changed },
2738
+ });
2739
+ if (decision !== true && decision?.approved !== true) {
2740
+ const where = ctx.relay.local ? 'this relay' : `relay "${ctx.relay.name}"`;
2741
+ return {
2742
+ approved: false,
2743
+ code: toText(decision?.code) === REMOTE_RELAY_ERROR_CODES.noTurn ? REMOTE_RELAY_ERROR_CODES.noTurn : REMOTE_RELAY_ERROR_CODES.permission,
2744
+ error: toText(decision?.error)
2745
+ ? `${toText(decision.error)} Session ${plan.session} on ${where} was not started by this conversation; its settings were not changed.`
2746
+ : `The owner did not allow this conversation to change sessions on ${where} that it did not start; session ${plan.session} was not changed. Sessions this conversation started there can still be changed.`,
2747
+ };
2748
+ }
2749
+ recordApprovalKeys(conversationId, [key], ctx.caller?.processingRowId);
2750
+ return { approved: true, asked: true };
2751
+ })();
2752
+ sessionSettingsApprovalsByKey.set(openKey, asking);
2753
+ try {
2754
+ return await asking;
2755
+ } finally {
2756
+ sessionSettingsApprovalsByKey.delete(openKey);
2757
+ }
2758
+ }
2759
+
2224
2760
  // ─── Event subscriptions ───────────────────────────────────────────────────
2225
2761
 
2226
2762
  const RELAY_EVENT_ERROR_STATUS = Object.freeze({
@@ -2287,7 +2823,7 @@ export function createRemoteRelayDispatcher({
2287
2823
  repository?.recordUnlock?.(conversationId, key, messageId || null);
2288
2824
  } catch (error) {
2289
2825
  try {
2290
- logger?.warn?.(`${LOG_PREFIX} watch approval not stored conv=${shortId(conversationId)}: ${toText(error?.message || error).slice(0, 200)}`);
2826
+ logger?.warn?.(`${LOG_PREFIX} approval ${key} not stored conv=${shortId(conversationId)}: ${toText(error?.message || error).slice(0, 200)}`);
2291
2827
  } catch {}
2292
2828
  }
2293
2829
  }
@@ -2336,6 +2872,15 @@ export function createRemoteRelayDispatcher({
2336
2872
  }
2337
2873
  }
2338
2874
 
2875
+ /** A subscribe's options as the approval card names them. */
2876
+ function watchOptions(args) {
2877
+ return {
2878
+ ...(args.digest_minutes ? { digestMinutes: args.digest_minutes } : {}),
2879
+ ...(args.quiet_hours ? { quietHours: args.quiet_hours } : {}),
2880
+ ...(args.exclude?.length ? { excludeCount: args.exclude.length } : {}),
2881
+ };
2882
+ }
2883
+
2339
2884
  async function subscribeAction(ctx, args) {
2340
2885
  if (!ctx.relay.local) return subscribeRemoteAction(ctx, args);
2341
2886
  const service = eventService(ctx);
@@ -2362,7 +2907,7 @@ export function createRemoteRelayDispatcher({
2362
2907
  if (all || ids.length) {
2363
2908
  const decision = await ensureWatchApproval(ctx, {
2364
2909
  all,
2365
- watch: { kind: 'subscribe', all, sessions: titles, events, once: args.once === true, heartbeatMinutes: args.heartbeat_minutes ?? null },
2910
+ watch: { kind: 'subscribe', all, sessions: titles, events, once: args.once === true, heartbeatMinutes: args.heartbeat_minutes ?? null, ...watchOptions(args) },
2366
2911
  });
2367
2912
  if (!decision.approved) throw new DispatchFailure(403, decision.code, decision.error);
2368
2913
  }
@@ -2375,6 +2920,9 @@ export function createRemoteRelayDispatcher({
2375
2920
  match: args.match || '',
2376
2921
  stalledMinutes: args.stalled_minutes ?? null,
2377
2922
  heartbeatMinutes: args.heartbeat_minutes ?? null,
2923
+ digestMinutes: args.digest_minutes ?? null,
2924
+ quietHours: args.quiet_hours ?? null,
2925
+ exclude: args.exclude ?? null,
2378
2926
  }));
2379
2927
  const what = all ? 'all sessions' : (ids.length ? `${ids.length} session${ids.length === 1 ? '' : 's'}` : 'relay_restarted');
2380
2928
  return {
@@ -2402,6 +2950,12 @@ export function createRemoteRelayDispatcher({
2402
2950
  const service = eventService(ctx);
2403
2951
  const all = args.scope === 'all';
2404
2952
  relayEventCall(() => service.checkWatcherCapacity(ctx.conversationId));
2953
+ if ((args.digest_minutes || args.quiet_hours || args.exclude?.length) && service.areOptionsAvailable?.() === false) {
2954
+ throw new DispatchFailure(503, REMOTE_RELAY_ERROR_CODES.unsupported,
2955
+ 'digest_minutes, quiet_hours and exclude are not available on this relay right now (see its server log).');
2956
+ }
2957
+ // Digest and quiet hours are applied here, where the events are
2958
+ // delivered; exclude is also the watched relay's (all_idle, stalled).
2405
2959
  const request = {
2406
2960
  watcher: { conversationId: ctx.conversationId, title: toText(ctx.caller?.title) || null },
2407
2961
  subscriber: { name: toText(await registry.selfName?.()) || null },
@@ -2412,6 +2966,7 @@ export function createRemoteRelayDispatcher({
2412
2966
  ...(args.match ? { match: args.match } : {}),
2413
2967
  ...(args.stalled_minutes !== undefined ? { stalled_minutes: args.stalled_minutes } : {}),
2414
2968
  ...(args.heartbeat_minutes !== undefined ? { heartbeat_minutes: args.heartbeat_minutes } : {}),
2969
+ ...(args.exclude?.length ? { exclude: args.exclude } : {}),
2415
2970
  };
2416
2971
  const checked = await relayEventRemoteCall(ctx, RELAY_EVENT_REMOTE_SUBSCRIBE_PATH, { ...request, dry_run: true });
2417
2972
  const preview = isPlainObject(checked?.subscription) ? checked.subscription : {};
@@ -2424,6 +2979,7 @@ export function createRemoteRelayDispatcher({
2424
2979
  events: Array.isArray(preview.events) ? preview.events : (args.events || [...RELAY_EVENT_DEFAULT_TYPES]),
2425
2980
  once: args.once === true,
2426
2981
  heartbeatMinutes: args.heartbeat_minutes ?? null,
2982
+ ...watchOptions(args),
2427
2983
  },
2428
2984
  });
2429
2985
  if (!decision.approved) throw new DispatchFailure(403, decision.code, decision.error);
@@ -2439,6 +2995,9 @@ export function createRemoteRelayDispatcher({
2439
2995
  relayId: ctx.relay.id,
2440
2996
  relayName: ctx.relay.name,
2441
2997
  remote,
2998
+ digestMinutes: args.digest_minutes ?? null,
2999
+ quietHours: args.quiet_hours ?? null,
3000
+ exclude: args.exclude ?? null,
2442
3001
  }));
2443
3002
  } catch (error) {
2444
3003
  // Not kept here: end it there too, so it does not wait for a fetch that never comes.
@@ -2550,7 +3109,266 @@ export function createRemoteRelayDispatcher({
2550
3109
  };
2551
3110
  }
2552
3111
 
3112
+ // ─── Schedules ─────────────────────────────────────────────────────────────
3113
+
3114
+ const RELAY_SCHEDULE_ERROR_STATUS = Object.freeze({
3115
+ invalid: [400, REMOTE_RELAY_ERROR_CODES.invalidInput],
3116
+ notFound: [404, REMOTE_RELAY_ERROR_CODES.notFound],
3117
+ limit: [409, REMOTE_RELAY_ERROR_CODES.scheduleLimit],
3118
+ unavailable: [503, REMOTE_RELAY_ERROR_CODES.unsupported],
3119
+ });
3120
+ const SCHEDULE_NOTE = 'At its time the relay queues the text into the session as a message that starts with "[relay schedule"; it is the relay\'s automated message, never the user\'s, and approves nothing. It survives relay restarts; unschedule with this schedule_id ends it.';
3121
+ const SCHEDULE_OTHER_NOTE = 'Each firing counts as one of your prompts to other agents: once you have sent as many as you may before the user writes, it waits (heldForApproval in list_schedules) until the user writes in this conversation.';
3122
+
3123
+ /** The service for a call on this relay; a paired relay keeps its own schedules. */
3124
+ function scheduleService(ctx) {
3125
+ if (!ctx.relay.local) {
3126
+ throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.unsupported,
3127
+ `Schedules are kept by the relay you run on: use relay "${REMOTE_RELAY_LOCAL_ALIAS}" (or leave relay out). For a session on relay "${ctx.relay.name}", ask an agent there to schedule it.`);
3128
+ }
3129
+ if (!relaySchedules?.isAvailable?.()) {
3130
+ throw new DispatchFailure(503, REMOTE_RELAY_ERROR_CODES.unsupported,
3131
+ 'Schedules are not available on this relay right now (see its server log).');
3132
+ }
3133
+ return relaySchedules;
3134
+ }
3135
+
3136
+ function relayScheduleCall(fn) {
3137
+ try {
3138
+ return fn();
3139
+ } catch (error) {
3140
+ const mapped = RELAY_SCHEDULE_ERROR_STATUS[toText(error?.code)];
3141
+ if (mapped) throw new DispatchFailure(mapped[0], mapped[1], toText(error.message), isPlainObject(error.extra) ? error.extra : {});
3142
+ throw error;
3143
+ }
3144
+ }
3145
+
3146
+ /** The conversation a schedule call names (`session`), as this relay knows it; the caller's own by default. */
3147
+ function scheduleSession(ctx, args) {
3148
+ if (!toText(args.session) || isOwnConversation(ctx, args.session)) return ctx.conversationId;
3149
+ try {
3150
+ return toText(resolveConversationId(args.session)) || toText(args.session);
3151
+ } catch {
3152
+ return toText(args.session);
3153
+ }
3154
+ }
3155
+
3156
+ /**
3157
+ * The provenance a schedule's messages carry, so that a schedule never
3158
+ * passes a prompt on with fewer hops than the turn that set it: for another
3159
+ * session the origin a send from this turn would carry; for the caller's
3160
+ * own conversation the origin of the prompt this turn acts on (none when it
3161
+ * acts on the user's words alone).
3162
+ */
3163
+ async function scheduleOrigin(ctx, session) {
3164
+ if (session !== ctx.conversationId) return buildOrigin(ctx);
3165
+ if (!(ctx.hops > 0)) return null;
3166
+ const acting = isPlainObject(ctx.caller?.origin) ? normalizeRemoteRelayOrigin({ ...ctx.caller.origin, hops: ctx.hops }) : null;
3167
+ return acting || buildOrigin(ctx);
3168
+ }
3169
+
3170
+ async function scheduleAction(ctx, args) {
3171
+ const service = scheduleService(ctx);
3172
+ const session = scheduleSession(ctx, args);
3173
+ const origin = await scheduleOrigin(ctx, session);
3174
+ const schedule = relayScheduleCall(() => service.create({
3175
+ conversationId: session,
3176
+ createdBy: ctx.conversationId,
3177
+ text: args.text,
3178
+ at: args.at ?? null,
3179
+ everyMinutes: args.every_minutes ?? null,
3180
+ once: args.once ?? null,
3181
+ title: args.title || '',
3182
+ origin,
3183
+ }));
3184
+ const when = schedule.everyMinutes
3185
+ ? `every ${schedule.everyMinutes} min${schedule.once ? ', once' : ''}`
3186
+ : 'once';
3187
+ const forOther = session !== ctx.conversationId;
3188
+ return {
3189
+ summary: `schedule → ${ctx.relay.name}${forOther ? ` session ${shortId(session)}` : ''}: ${when}, next ${schedule.nextFireAt} (${schedule.id})`,
3190
+ schedule_id: schedule.id,
3191
+ schedule,
3192
+ note: forOther ? `${SCHEDULE_NOTE} ${SCHEDULE_OTHER_NOTE}` : SCHEDULE_NOTE,
3193
+ };
3194
+ }
3195
+
3196
+ async function listSchedulesAction(ctx, args) {
3197
+ const service = scheduleService(ctx);
3198
+ const session = scheduleSession(ctx, args);
3199
+ const schedules = relayScheduleCall(() => service.list({ conversationId: session }));
3200
+ const active = schedules.filter((schedule) => !schedule.endedAt).length;
3201
+ return {
3202
+ summary: `list_schedules → ${ctx.relay.name}${session === ctx.conversationId ? '' : ` session ${shortId(session)}`}: ${active} active`,
3203
+ session,
3204
+ schedules,
3205
+ ...(schedules.length > active ? { note: 'Schedules with endedAt ended within the last hour (endReason says why).' } : {}),
3206
+ };
3207
+ }
3208
+
3209
+ async function unscheduleAction(ctx, args) {
3210
+ const service = scheduleService(ctx);
3211
+ const schedule = relayScheduleCall(() => service.unschedule({ scheduleId: args.schedule_id, byConversationId: ctx.conversationId }));
3212
+ return {
3213
+ summary: `unschedule → ${ctx.relay.name}: ${schedule.id} ended`,
3214
+ schedule,
3215
+ };
3216
+ }
3217
+
3218
+ // ─── Questions that don't block ────────────────────────────────────────────
3219
+
3220
+ const RELAY_ASK_ERROR_STATUS = Object.freeze({
3221
+ invalid: [400, REMOTE_RELAY_ERROR_CODES.invalidInput],
3222
+ notFound: [404, REMOTE_RELAY_ERROR_CODES.notFound],
3223
+ closed: [409, `${REMOTE_RELAY_ERROR_CODES.httpPrefix}409`],
3224
+ forbidden: [403, REMOTE_RELAY_ERROR_CODES.forbidden],
3225
+ limit: [409, REMOTE_RELAY_ERROR_CODES.questionLimit],
3226
+ unavailable: [503, REMOTE_RELAY_ERROR_CODES.unsupported],
3227
+ });
3228
+ const ASK_NOTE = 'The user sees it as a card in that session and in the Inbox. When they answer or dismiss it you get a relay event '
3229
+ + 'question_answered (question_id, answer, choice, dismissed, via: the relay it was answered on) without any subscription: a message that starts with "[relay event", '
3230
+ + 'automated, but its answer is the user\'s own reply to this question. Keep working on what does not depend on it and do not ask it '
3231
+ + 'again meanwhile; withdraw with this question_id takes it back.';
3232
+
3233
+ function askService() {
3234
+ if (!relayAsks?.isAvailable?.()) {
3235
+ throw new DispatchFailure(503, REMOTE_RELAY_ERROR_CODES.unsupported,
3236
+ 'Questions that don\'t block are not available on this relay right now (see its server log); use your own question tool instead.');
3237
+ }
3238
+ return relayAsks;
3239
+ }
3240
+
3241
+ function relayAskCall(fn) {
3242
+ try {
3243
+ return fn();
3244
+ } catch (error) {
3245
+ const mapped = RELAY_ASK_ERROR_STATUS[toText(error?.code)];
3246
+ if (mapped) throw new DispatchFailure(mapped[0], mapped[1], toText(error.message), isPlainObject(error.extra) ? error.extra : {});
3247
+ throw error;
3248
+ }
3249
+ }
3250
+
3251
+ /** The conversation an ask call names (`session`), as this relay knows it; the caller's own by default. */
3252
+ function askSession(ctx, args) {
3253
+ if (!toText(args.session) || isOwnConversation(ctx, args.session)) return ctx.conversationId;
3254
+ try {
3255
+ return toText(resolveConversationId(args.session)) || toText(args.session);
3256
+ } catch {
3257
+ return toText(args.session);
3258
+ }
3259
+ }
3260
+
3261
+ function askInput(args) {
3262
+ return {
3263
+ question: args.question,
3264
+ options: args.options || [],
3265
+ recommendation: args.recommendation || '',
3266
+ detail: args.detail || '',
3267
+ urgency: args.urgency || 'normal',
3268
+ };
3269
+ }
3270
+
3271
+ /** A call to a paired relay's ask routes; an older relay without them answers a clear error. */
3272
+ async function relayAskRemoteCall(ctx, path, body) {
3273
+ try {
3274
+ return await call(ctx, 'POST', path, { body });
3275
+ } catch (error) {
3276
+ if (isMissingRelayAskRoute(error)) {
3277
+ throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.unsupported, relayWithoutAsksText(ctx.relay.name));
3278
+ }
3279
+ throw error;
3280
+ }
3281
+ }
3282
+
3283
+ async function askAction(ctx, args) {
3284
+ const service = askService();
3285
+ if (!ctx.relay.local) {
3286
+ if (!toText(args.session)) {
3287
+ throw invalidInput(`ask on relay "${ctx.relay.name}" needs session (an id from list_sessions there): your own questions go to "${REMOTE_RELAY_LOCAL_ALIAS}".`);
3288
+ }
3289
+ const response = await relayAskRemoteCall(ctx, RELAY_ASK_REMOTE_PATHS.ask, {
3290
+ session: args.session,
3291
+ ...askInput(args),
3292
+ asker: {
3293
+ conversationId: ctx.conversationId,
3294
+ title: toText(ctx.caller?.title) || null,
3295
+ // Shown there when that relay has no name for this one (a pairing one way round).
3296
+ relayName: toText(await registry.selfName?.()) || null,
3297
+ },
3298
+ });
3299
+ const remote = isPlainObject(response?.ask) ? response.ask : null;
3300
+ if (!toText(remote?.id)) {
3301
+ throw new DispatchFailure(502, `${REMOTE_RELAY_ERROR_CODES.httpPrefix}502`, `Relay "${ctx.relay.name}" did not return the question.`);
3302
+ }
3303
+ const ask = relayAskCall(() => service.addMirror({ relayId: ctx.relay.id, createdBy: ctx.conversationId, remote }));
3304
+ try { relayAskLink?.noteAsked?.(ctx.relay.id); } catch {}
3305
+ return {
3306
+ summary: `ask → ${ctx.relay.name} session ${shortId(ask.session.id)}: question ${ask.id} pending`,
3307
+ question_id: ask.id,
3308
+ status: 'pending',
3309
+ question: ask,
3310
+ note: `${ASK_NOTE} This relay looks for the answer on relay "${ctx.relay.name}" every few seconds; the session there is told too.`,
3311
+ };
3312
+ }
3313
+ const session = askSession(ctx, args);
3314
+ const ask = relayAskCall(() => service.create({ conversationId: session, createdBy: ctx.conversationId, ...askInput(args) }));
3315
+ const forOther = session !== ctx.conversationId;
3316
+ return {
3317
+ summary: `ask → ${ctx.relay.name}${forOther ? ` session ${shortId(session)}` : ''}: question ${ask.id} pending${ask.urgency === 'now' ? ' (now)' : ''}`,
3318
+ question_id: ask.id,
3319
+ status: 'pending',
3320
+ question: ask,
3321
+ note: forOther ? `${ASK_NOTE} Session ${session} gets the answer too.` : ASK_NOTE,
3322
+ };
3323
+ }
3324
+
3325
+ async function listQuestionsAction(ctx, args) {
3326
+ const service = askService();
3327
+ if (!ctx.relay.local) {
3328
+ // This conversation's asks there, as this relay last learnt them: fetched now (briefly waited for).
3329
+ if (service.pendingMirrorIds(ctx.relay.id).length) await relayAskLink?.fetchNow?.(ctx.relay.id)?.catch?.(() => {});
3330
+ const asks = service.listOf(ctx.conversationId)
3331
+ .filter((ask) => ask.relayId === ctx.relay.id && (!toText(args.session) || ask.session.id === toText(args.session)));
3332
+ return {
3333
+ summary: `list_questions → ${ctx.relay.name}: ${asks.filter((ask) => ask.status === 'pending').length} pending`,
3334
+ questions: asks,
3335
+ note: 'These are the questions this conversation asked there. read_session there lists every pending question of a session, asks (kind ask) included.',
3336
+ };
3337
+ }
3338
+ const session = askSession(ctx, args);
3339
+ const asks = service.listOf(session).filter((ask) => !ask.relayId || session === ctx.conversationId);
3340
+ const pending = asks.filter((ask) => ask.status === 'pending').length;
3341
+ return {
3342
+ summary: `list_questions → ${ctx.relay.name}${session === ctx.conversationId ? '' : ` session ${shortId(session)}`}: ${pending} pending`,
3343
+ session,
3344
+ questions: asks,
3345
+ ...(asks.length > pending ? { note: 'Questions that are not pending were answered, dismissed or withdrawn within the last day.' } : {}),
3346
+ };
3347
+ }
3348
+
3349
+ async function withdrawAction(ctx, args) {
3350
+ const service = askService();
3351
+ if (!ctx.relay.local) {
3352
+ const mirror = service.getMirror(ctx.relay.id, args.question_id);
3353
+ if (!mirror || mirror.askedBy?.conversationId !== ctx.conversationId) {
3354
+ throw new DispatchFailure(404, REMOTE_RELAY_ERROR_CODES.notFound,
3355
+ `This conversation asked no question ${args.question_id} on relay "${ctx.relay.name}"; list_questions there shows the ones it did.`);
3356
+ }
3357
+ await relayAskRemoteCall(ctx, RELAY_ASK_REMOTE_PATHS.withdraw, { id: args.question_id });
3358
+ const ask = service.closeMirror(ctx.relay.id, args.question_id, 'withdrawn');
3359
+ return { summary: `withdraw → ${ctx.relay.name}: question ${args.question_id} withdrawn`, question: ask };
3360
+ }
3361
+ const ask = relayAskCall(() => service.withdraw(args.question_id, { byConversationId: ctx.conversationId }));
3362
+ return { summary: `withdraw → ${ctx.relay.name}: question ${ask.id} withdrawn`, question: ask };
3363
+ }
3364
+
2553
3365
  const ACTIONS = Object.freeze({
3366
+ ask: askAction,
3367
+ list_questions: listQuestionsAction,
3368
+ withdraw: withdrawAction,
3369
+ schedule: scheduleAction,
3370
+ list_schedules: listSchedulesAction,
3371
+ unschedule: unscheduleAction,
2554
3372
  subscribe: subscribeAction,
2555
3373
  unsubscribe: unsubscribeAction,
2556
3374
  list_subscriptions: listSubscriptionsAction,
@@ -2563,6 +3381,7 @@ export function createRemoteRelayDispatcher({
2563
3381
  wait: waitAction,
2564
3382
  send,
2565
3383
  create_session: createSession,
3384
+ update_session: updateSession,
2566
3385
  answer_question: answerQuestion,
2567
3386
  stop: stopTurn,
2568
3387
  archive: archiveSession,
@@ -2684,7 +3503,7 @@ export function createRemoteRelayDispatcher({
2684
3503
  * arrive while the card is open share it and its answer. Resolves
2685
3504
  * `{ approved:true, asked }` or `{ approved:false, code?, error? }`.
2686
3505
  */
2687
- async function ensureLocalSessionApproval({ conversationId, caller, relay, action, args, signal }) {
3506
+ async function ensureLocalSessionApproval({ conversationId, caller, relay, action, args, signal, group = null }) {
2688
3507
  const remembered = () => {
2689
3508
  try {
2690
3509
  return repository.hasLocalSessionApproval(conversationId) === true;
@@ -2708,6 +3527,7 @@ export function createRemoteRelayDispatcher({
2708
3527
  summary: summarizeRemoteRelayCall({ action, ...args, relay: relay.name }),
2709
3528
  signal,
2710
3529
  localSessions: true,
3530
+ ...(group ? { group } : {}),
2711
3531
  });
2712
3532
  if (decision !== true && decision?.approved !== true) {
2713
3533
  return {
@@ -2754,9 +3574,15 @@ export function createRemoteRelayDispatcher({
2754
3574
  'Sessions on this relay are not available to agents right now: its storage for them could not be read (see the server log).',
2755
3575
  { relay: relay.name });
2756
3576
  }
2757
- if (origin) {
3577
+ // One level, unless the owner allowed this session's agents to start
3578
+ // sessions themselves; the cap and the Allow below still apply.
3579
+ let ownerAllowed = false;
3580
+ try {
3581
+ ownerAllowed = repository.mayCreateSessions?.(conversationId) === true;
3582
+ } catch {}
3583
+ if (origin && !ownerAllowed) {
2758
3584
  return failure(403, REMOTE_RELAY_ERROR_CODES.nestedSession,
2759
- '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.',
3585
+ 'A session that an agent created cannot create sessions (unless the relay\'s owner allows it for this session). Do the work in this session, or say in your reply what the agent that prompted you should start.',
2760
3586
  { relay: relay.name });
2761
3587
  }
2762
3588
  const limit = REMOTE_RELAY_LIMITS.localActiveSessions;
@@ -2779,7 +3605,17 @@ export function createRemoteRelayDispatcher({
2779
3605
  if (!target.relay) return target.failure;
2780
3606
  const relay = target.relay;
2781
3607
  const local = relay.local === true;
2782
- const write = isRemoteRelayWriteAction(action);
3608
+ // A schedule for another session prompts it later: it is gated like a
3609
+ // send (approval in ask and plan mode, the write limit, the audit line).
3610
+ // One for the caller's own conversation prompts nobody else.
3611
+ const ownSchedule = action === 'schedule' && (!toText(args.session) || isOwnConversation({ conversationId }, args.session));
3612
+ // An ask on behalf of another session puts a card there and wakes it with
3613
+ // the answer: a write too (hop limit, approval in ask and plan mode, the
3614
+ // write limit, the audit line), but no prompt of an agent's: the loop
3615
+ // guard does not count it, the answer is the user's. An ask about the
3616
+ // caller's own conversation never gets here (isOwnAskCall).
3617
+ const behalfAsk = action === 'ask' && !!toText(args.session) && !isOwnConversation({ conversationId }, args.session);
3618
+ const write = isRemoteRelayWriteAction(action) || (action === 'schedule' && !ownSchedule) || behalfAsk;
2783
3619
  const finish = (result, session) => {
2784
3620
  if (write) {
2785
3621
  const ok = result.body?.ok === true;
@@ -2803,9 +3639,9 @@ export function createRemoteRelayDispatcher({
2803
3639
  `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.`,
2804
3640
  { relay: relay.name }));
2805
3641
  }
2806
- if (!local && !remoteRelayPermissionAllows(relay.permission, action)) {
3642
+ if (!local && !remoteRelayPermissionAllows(relay.permission, action, { ownSession: ownSchedule })) {
2807
3643
  return finish(failure(403, REMOTE_RELAY_ERROR_CODES.forbidden,
2808
- `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.`,
3644
+ `Relay "${relay.name}" lets agents ${PERMISSION_DESCRIPTIONS[relay.permission] || 'do less'} (permission "${relay.permission}"); ${action} needs "${remoteRelayActionPermission(action, { ownSession: ownSchedule })}". The user can change this in Settings → Relays.`,
2809
3645
  { relay: relay.name, permission: relay.permission }));
2810
3646
  }
2811
3647
  if (local && OWN_SESSION_ACTIONS.has(action) && isOwnConversation({ conversationId }, args.session)) {
@@ -2848,6 +3684,20 @@ export function createRemoteRelayDispatcher({
2848
3684
  const problem = createSessionProblem(caller, args);
2849
3685
  if (problem) return finish(failure(400, REMOTE_RELAY_ERROR_CODES.invalidInput, problem, { relay: relay.name }));
2850
3686
  }
3687
+ // The group a create_session names, looked up on the target before anyone
3688
+ // is asked, like the model: the card names it, and an unknown group (or a
3689
+ // target whose groups cannot be read) refuses the call, nothing created.
3690
+ let group = null;
3691
+ if (action === 'create_session' && args.group) {
3692
+ try {
3693
+ group = await settleRequestedGroup({ relay, caller, hops, conversationId, signal }, args.group);
3694
+ } catch (error) {
3695
+ if (error instanceof DispatchFailure) {
3696
+ return finish(failure(error.status, error.code, error.message, { relay: relay.name, ...error.extra }));
3697
+ }
3698
+ throw error;
3699
+ }
3700
+ }
2851
3701
  if ((action === 'create_session' || action === 'send') && args.model) {
2852
3702
  const early = { relay, caller, hops, conversationId, signal };
2853
3703
  try {
@@ -2872,6 +3722,21 @@ export function createRemoteRelayDispatcher({
2872
3722
  settledModel = null;
2873
3723
  }
2874
3724
  }
3725
+ // What an update_session would change, settled on the target before
3726
+ // anyone is asked: an unknown model or group, a busy session or a change
3727
+ // to nothing never raises a card.
3728
+ let updatePlan = null;
3729
+ if (action === 'update_session') {
3730
+ try {
3731
+ updatePlan = await planSessionUpdate({ relay, caller, hops, conversationId, signal }, args);
3732
+ } catch (error) {
3733
+ if (error instanceof DispatchFailure) {
3734
+ return finish(failure(error.status, error.code, error.message, { relay: relay.name, ...error.extra }));
3735
+ }
3736
+ if (isRemoteRelayError(error)) return finish(mapRemoteError(error, relay, { action, args }));
3737
+ throw error;
3738
+ }
3739
+ }
2875
3740
 
2876
3741
  const localCreate = local && action === 'create_session';
2877
3742
  let holdsLocalCreate = false;
@@ -2889,10 +3754,12 @@ export function createRemoteRelayDispatcher({
2889
3754
 
2890
3755
  adjustInflight(conversationId, 1);
2891
3756
  try {
2892
- const prompting = isRemoteRelayPromptAction(action);
3757
+ // A schedule for another session prompts it later: the loop guard counts
3758
+ // it now, like a send (and each of its firings once more, admitScheduledFiring).
3759
+ const prompting = isRemoteRelayPromptAction(action) || (action === 'schedule' && !ownSchedule);
2893
3760
  let asked = false;
2894
3761
  if (localCreate) {
2895
- const decision = await ensureLocalSessionApproval({ conversationId, caller, relay, action, args, signal });
3762
+ const decision = await ensureLocalSessionApproval({ conversationId, caller, relay, action, args, signal, group });
2896
3763
  if (!decision.approved) {
2897
3764
  return finish(failure(403, decision.code || REMOTE_RELAY_ERROR_CODES.approvalDenied, decision.error, { relay: relay.name }));
2898
3765
  }
@@ -2903,6 +3770,16 @@ export function createRemoteRelayDispatcher({
2903
3770
  agentPrompts(conversationId).count = 0;
2904
3771
  }
2905
3772
  }
3773
+ // A session this conversation did not start: the owner's Allow, once per conversation
3774
+ // and target. The card this call raised is this call's approval too.
3775
+ const updatesNothing = !!updatePlan && !updatePlan.changed.length;
3776
+ if (updatePlan && !updatesNothing && updatePlan.approvalNeeded) {
3777
+ const decision = await ensureSessionSettingsApproval({ relay, caller, conversationId, signal }, updatePlan, args);
3778
+ if (!decision.approved) {
3779
+ return finish(failure(403, decision.code, decision.error, { relay: relay.name, session: updatePlan.session }));
3780
+ }
3781
+ if (decision.asked) asked = true;
3782
+ }
2906
3783
  if (!asked && prompting && agentPrompts(conversationId).count >= REMOTE_RELAY_LIMITS.agentPromptsBeforeAsking) {
2907
3784
  // Agents that prompt each other can go on without the user for good:
2908
3785
  // from time to time the user is asked, in any relay mode.
@@ -2915,6 +3792,7 @@ export function createRemoteRelayDispatcher({
2915
3792
  summary: summarizeRemoteRelayCall({ action, ...args, relay: relay.name }),
2916
3793
  signal,
2917
3794
  agentPrompts: agentPrompts(conversationId).count,
3795
+ ...(group ? { group } : {}),
2918
3796
  });
2919
3797
  const approved = decision === true || decision?.approved === true;
2920
3798
  if (!approved) {
@@ -2926,7 +3804,7 @@ export function createRemoteRelayDispatcher({
2926
3804
  agentPrompts(conversationId).count = 0;
2927
3805
  asked = true;
2928
3806
  }
2929
- if (!asked && write && REMOTE_RELAY_APPROVAL_MODES.includes(caller.mode)) {
3807
+ if (!asked && write && !updatesNothing && REMOTE_RELAY_APPROVAL_MODES.includes(caller.mode)) {
2930
3808
  const decision = await requestApproval({
2931
3809
  conversationId,
2932
3810
  callerContext: caller,
@@ -2935,6 +3813,7 @@ export function createRemoteRelayDispatcher({
2935
3813
  args,
2936
3814
  summary: summarizeRemoteRelayCall({ action, ...args, relay: relay.name }),
2937
3815
  signal,
3816
+ ...(group ? { group } : {}),
2938
3817
  });
2939
3818
  const approved = decision === true || decision?.approved === true;
2940
3819
  if (!approved) {
@@ -2945,7 +3824,7 @@ export function createRemoteRelayDispatcher({
2945
3824
  }
2946
3825
  }
2947
3826
 
2948
- const ctx = { relay, caller, hops, conversationId, signal, onPromptQueued: releaseLocalCreate, ...(catalog ? { catalog } : {}), ...(settledModel ? { settledModel } : {}) };
3827
+ const ctx = { relay, caller, hops, conversationId, signal, onPromptQueued: releaseLocalCreate, ...(catalog ? { catalog } : {}), ...(settledModel ? { settledModel } : {}), ...(group ? { group } : {}), ...(updatePlan ? { updatePlan } : {}) };
2949
3828
  const body = await ACTIONS[action](ctx, args);
2950
3829
  if (prompting) agentPrompts(conversationId).count += 1;
2951
3830
  const { summary, ...rest } = body;
@@ -2972,6 +3851,43 @@ export function createRemoteRelayDispatcher({
2972
3851
  }
2973
3852
  }
2974
3853
 
3854
+ /**
3855
+ * Whether a call is an ask action about the caller's own conversation on
3856
+ * this relay: an ask or list_questions without another session, or a
3857
+ * withdraw here. Such a call touches nothing but the conversation's own
3858
+ * questions, so it needs neither the agent sessions setting nor a mention.
3859
+ */
3860
+ async function isOwnAskCall(conversationId, action, args) {
3861
+ if (!REMOTE_RELAY_ASK_ACTIONS.includes(action)) return false;
3862
+ const query = toText(args?.relay).replace(/^@/, '').toLowerCase();
3863
+ if (query !== REMOTE_RELAY_LOCAL_ALIAS) {
3864
+ const resolved = await registry.resolve(args.relay);
3865
+ if (resolved?.relay || resolved?.error === 'ambiguous') return false;
3866
+ const selfName = toText(await registry.selfName?.()).toLowerCase();
3867
+ if (!selfName || selfName !== query) return false;
3868
+ }
3869
+ return action === 'withdraw' || !toText(args.session) || isOwnConversation({ conversationId }, args.session);
3870
+ }
3871
+
3872
+ /** An ask action on the caller's own conversation (isOwnAskCall): the rate limit, then the action. */
3873
+ async function runOwnAskAction({ conversationId, action, args, req, signal }) {
3874
+ const relay = await localTarget();
3875
+ const retryAfterSeconds = consumeRateLimit(conversationId, false);
3876
+ if (retryAfterSeconds !== null) {
3877
+ return failure(429, REMOTE_RELAY_ERROR_CODES.rateLimited,
3878
+ `Too many remote relay calls from this conversation (at most ${REMOTE_RELAY_LIMITS.callsPerMinute} a minute). Try again in ${retryAfterSeconds} s.`,
3879
+ { relay: relay.name, retryAfterSeconds });
3880
+ }
3881
+ const caller = { conversationId, ...((await getCallerContext(conversationId, req)) || {}) };
3882
+ try {
3883
+ const { summary, ...rest } = await ACTIONS[action]({ relay, caller, hops: nonNegativeInt(caller.hops), conversationId, signal }, args);
3884
+ return { status: 200, body: { ok: true, relay: relay.name, summary, ...rest } };
3885
+ } catch (error) {
3886
+ if (error instanceof DispatchFailure) return failure(error.status, error.code, error.message, { relay: relay.name, ...error.extra });
3887
+ throw error;
3888
+ }
3889
+ }
3890
+
2975
3891
  /**
2976
3892
  * One tool call. Never throws: resolves to `{ status, body }` for the route
2977
3893
  * to send as is. `signal` (optional) aborts approval and reply waits when
@@ -2982,6 +3898,14 @@ export function createRemoteRelayDispatcher({
2982
3898
  // target: the waiting is done here, by polling the target.
2983
3899
  const { maxWaitSeconds } = agentSessions();
2984
3900
  const validation = validateRemoteRelayToolInput({ ...(isPlainObject(args) ? args : {}), action }, { maxWaitSeconds });
3901
+ const result = await runValidated({ conversationId, req, signal }, validation);
3902
+ // The arguments the action does not take, named on the answer (a call
3903
+ // that came through a worker's tool had them named there already).
3904
+ if (!validation.ignored?.length || !isPlainObject(result?.body)) return result;
3905
+ return { ...result, body: { ...result.body, ignored: validation.ignored } };
3906
+ }
3907
+
3908
+ async function runValidated({ conversationId, req, signal }, validation) {
2985
3909
  if (!validation.ok) return failure(400, validation.code, validation.error);
2986
3910
  const requestedConversationId = toText(conversationId);
2987
3911
  let callerConversationId = requestedConversationId;
@@ -2996,6 +3920,9 @@ export function createRemoteRelayDispatcher({
2996
3920
  : '';
2997
3921
  try {
2998
3922
  if (validation.action === 'list_relays') return await listRelays(callerConversationId);
3923
+ if (await isOwnAskCall(callerConversationId, validation.action, validation.args)) {
3924
+ return await runOwnAskAction({ conversationId: callerConversationId, action: validation.action, args: validation.args, req, signal });
3925
+ }
2999
3926
  return await runRelayAction({
3000
3927
  conversationId: callerConversationId,
3001
3928
  action: validation.action,
@@ -3012,5 +3939,5 @@ export function createRemoteRelayDispatcher({
3012
3939
  }
3013
3940
  }
3014
3941
 
3015
- return { dispatch, inflight };
3942
+ return { dispatch, inflight, admitScheduledFiring };
3016
3943
  }