@oar-sh/oar 0.9.14 → 0.9.16

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