@oar-sh/oar 0.9.6 → 0.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/README.md +176 -22
  3. package/package.json +1 -1
  4. package/server/README.md +174 -7
  5. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
  6. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
  7. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
  8. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  9. package/server/claude-worker/claude-sdk-adapter.mjs +18 -2
  10. package/server/claude-worker/claude-session-process.mjs +33 -0
  11. package/server/claude-worker/claude-session-worker.mjs +6 -0
  12. package/server/db-schema.mjs +17 -0
  13. package/server/migrations/0006-workspace-root-settings.mjs +67 -0
  14. package/server/migrations/0007-claude-cloud.mjs +82 -0
  15. package/server/public/app/agent-sessions-settings-ui.js +197 -0
  16. package/server/public/app/agent-sessions-settings.mjs +134 -0
  17. package/server/public/app/api-client.js +143 -4
  18. package/server/public/app/attachments-view.js +20 -1
  19. package/server/public/app/attribution-control.mjs +46 -0
  20. package/server/public/app/bootstrap.js +344 -31
  21. package/server/public/app/claude-cloud-conversation-ui.js +110 -0
  22. package/server/public/app/claude-cloud-settings-ui.js +374 -0
  23. package/server/public/app/claude-cloud-ui.mjs +611 -0
  24. package/server/public/app/composer-control-state.mjs +11 -0
  25. package/server/public/app/context-menu.mjs +140 -0
  26. package/server/public/app/context-tier-options.mjs +4 -0
  27. package/server/public/app/conversation-origin-ui.js +27 -0
  28. package/server/public/app/conversation-provider-indicator.mjs +22 -4
  29. package/server/public/app/conversation-view.js +83 -4
  30. package/server/public/app/cwd-picker.js +12 -0
  31. package/server/public/app/journal-view.js +698 -28
  32. package/server/public/app/math-dollars.mjs +89 -0
  33. package/server/public/app/model-selector-options.mjs +1 -1
  34. package/server/public/app/plan-usage-view.mjs +36 -8
  35. package/server/public/app/relay-error-ctas.mjs +16 -2
  36. package/server/public/app/remote-relay-origin-view.mjs +120 -4
  37. package/server/public/app/router.js +49 -12
  38. package/server/public/app/settings-modal.js +33 -2
  39. package/server/public/app/settings-tabs.js +1 -1
  40. package/server/public/app/socket-handlers.js +51 -0
  41. package/server/public/index.html +381 -7
  42. package/server/relay-tools.md +13 -1
  43. package/server/repositories/remote-relay-repository.mjs +42 -1
  44. package/server/repositories/session-repository.mjs +47 -1
  45. package/server/routes/claude-cloud-routes.mjs +140 -0
  46. package/server/routes/git-routes.mjs +37 -0
  47. package/server/routes/messages-routes.mjs +120 -15
  48. package/server/routes/remote-relay-routes.mjs +15 -1
  49. package/server/routes/sessions-routes.mjs +502 -72
  50. package/server/server-runtime.mjs +258 -25
  51. package/server/services/claude-account-usage-service.mjs +176 -0
  52. package/server/services/claude-cloud-repo-service.mjs +408 -0
  53. package/server/services/claude-cloud-session-service.mjs +326 -0
  54. package/server/services/claude-cloud-settings-service.mjs +269 -0
  55. package/server/services/git-remote-service.mjs +115 -0
  56. package/server/services/plan-usage-claude-cloud.mjs +211 -0
  57. package/server/services/plan-usage-claude.mjs +202 -40
  58. package/server/services/plan-usage-contract.mjs +39 -3
  59. package/server/services/plan-usage-service.mjs +82 -2
  60. package/server/services/provider-reasoning-effort.mjs +3 -0
  61. package/server/services/remote-relay-caller-context.mjs +49 -10
  62. package/server/services/remote-relay-client.mjs +9 -0
  63. package/server/services/remote-relay-dispatcher.mjs +383 -35
  64. package/server/services/remote-relay-inbound.mjs +49 -2
  65. package/server/services/remote-relay-loopback.mjs +245 -0
  66. package/server/services/remote-relay-registry-service.mjs +52 -0
  67. package/server/services/session-worker-launch-service.mjs +100 -15
  68. package/server/services/session-worker-process-service.mjs +348 -129
  69. package/server/services/session-worker-stop-service.mjs +24 -13
  70. package/server/services/session-worker-supervisor-service.mjs +56 -1
  71. package/server/services/usage-limit-pause-service.mjs +3 -1
  72. package/server/services/worker-launch-note-service.mjs +151 -0
  73. package/shared/claude-attribution.mjs +117 -0
  74. package/shared/claude-cloud/api-client.mjs +809 -0
  75. package/shared/claude-cloud/base-url.mjs +47 -0
  76. package/shared/claude-cloud/credentials.mjs +221 -0
  77. package/shared/claude-cloud/repo-url.mjs +111 -0
  78. package/shared/provider-routing.mjs +4 -2
  79. package/shared/remote-relay-contract.mjs +153 -22
  80. package/shared/remote-relay-tool-core.mjs +11 -8
  81. package/shared/steer-settle-failure.mjs +1 -0
@@ -5,9 +5,12 @@ 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_CLOUD_PROVIDER,
8
9
  REMOTE_RELAY_EFFORT_PATTERN,
9
10
  REMOTE_RELAY_ERROR_CODES,
10
11
  REMOTE_RELAY_LIMITS,
12
+ REMOTE_RELAY_LOCAL_ALIAS,
13
+ REMOTE_RELAY_LOCAL_ID,
11
14
  REMOTE_RELAY_PROVIDERS,
12
15
  isRemoteRelayApprovalQuestion,
13
16
  isRemoteRelayPromptAction,
@@ -32,6 +35,19 @@ import { ANSWERED_ELSEWHERE_KIND } from '../../shared/steer-settle-markers.mjs';
32
35
  // → permission → hop limit → rate limit → approval (write actions in
33
36
  // ask/plan) → execute against the remote relay's existing API
34
37
  //
38
+ // The relay the agent runs on is a target too (the "local target", named by
39
+ // this relay's name or `this`), when its owner switched that on. It needs no
40
+ // pairing, mention, permission or hop; instead:
41
+ //
42
+ // setting on → no acting on the caller's own conversation → rate limit →
43
+ // create_session only: one level (a created session creates none), at most
44
+ // localActiveSessions created sessions at work, the user's Allow once per
45
+ // conversation → approval (ask/plan) → execute
46
+ //
47
+ // Its actions run through the same code as a paired relay's: `call()` hands
48
+ // them to the loopback client, which reaches this relay's own routes
49
+ // in-process (remote-relay-loopback.mjs).
50
+ //
35
51
  // The remote is reached only through the outbound client (auth, origin and
36
52
  // hop headers are its job). Reply detection reads the remote's own transcript:
37
53
  // the assistant row whose sourceMessageId is the message we queued, with the
@@ -73,7 +89,16 @@ const TITLE_MAX = 80;
73
89
 
74
90
  // The provider settings routes a relay serves; github (Copilot) has none with
75
91
  // models — its catalogue is /api/models plus /api/status defaultModel.
76
- const SETTINGS_PROVIDERS = Object.freeze(['claude', 'cursor', 'grok', 'openai']);
92
+ const SETTINGS_PROVIDERS = Object.freeze(['claude', 'cursor', 'grok', 'openai', 'claude-cloud']);
93
+ // A Claude Cloud session clones a GitHub repository instead of opening a
94
+ // folder: create_session takes `repo` and `branch` for it.
95
+ const CLOUD_PROVIDER = REMOTE_RELAY_CLOUD_PROVIDER;
96
+ const CLOUD_CREATE_NOTE = 'create_session needs repo (https://github.com/owner/repo or owner/repo) and takes branch instead of cwd. The session works on a fresh clone in a sandbox at Anthropic: write a self-contained prompt and name the branch to work on and to push.';
97
+ // POST /api/conversation/bootstrap refuses a cloud session with this code
98
+ // while the provider is switched off.
99
+ const CLOUD_DISABLED_CODE = 'claude_cloud_disabled';
100
+ // The actions an agent must not aim at the conversation it runs in.
101
+ const OWN_SESSION_ACTIONS = new Set(['send', 'stop', 'archive', 'wait']);
77
102
  // How /api/models `providersByModel` names each provider.
78
103
  const CATALOG_PROVIDER_LABELS = Object.freeze({
79
104
  github: 'github-copilot',
@@ -81,6 +106,7 @@ const CATALOG_PROVIDER_LABELS = Object.freeze({
81
106
  claude: 'claude',
82
107
  cursor: 'cursor',
83
108
  grok: 'grok',
109
+ 'claude-cloud': 'claude-cloud',
84
110
  });
85
111
  // Worker statuses that mean "working on something right now" (scope active).
86
112
  const WORKING_WORKER_STATUSES = new Set(['starting', 'processing']);
@@ -341,7 +367,7 @@ const PERMISSION_DESCRIPTIONS = Object.freeze({
341
367
  });
342
368
 
343
369
  function relayTarget(relay) {
344
- return { id: relay.id, name: relay.name, url: relay.url };
370
+ return { id: relay.id, name: relay.name, url: relay.url, ...(relay.local ? { local: true } : {}) };
345
371
  }
346
372
 
347
373
  /**
@@ -518,6 +544,12 @@ export function settleRemoteReply(messages, messageId) {
518
544
  export function createRemoteRelayDispatcher({
519
545
  registry,
520
546
  client,
547
+ // The local target's client (remote-relay-loopback.mjs): same `request` as
548
+ // `client`, answered by this relay's own routes. Without it the local target
549
+ // does not exist, whatever the setting says.
550
+ localClient = null,
551
+ // `{ enabled, maxWaitSeconds }`: the agent-sessions settings of this relay.
552
+ getAgentSessionsSettings = () => null,
521
553
  repository,
522
554
  getCallerContext = async () => null,
523
555
  requestApproval = async () => false,
@@ -539,6 +571,13 @@ export function createRemoteRelayDispatcher({
539
571
  // counted from the user's latest own message. Kept in memory: a restart
540
572
  // ends a loop of agents by itself.
541
573
  const agentPromptsByConversation = new Map();
574
+ // create_session calls on the local target that passed the cap and have not
575
+ // queued their prompt yet: they count toward it, so parallel calls cannot
576
+ // slip past together.
577
+ const localCreatesByConversation = new Map();
578
+ // The once-per-conversation approval card while it is open: parallel first
579
+ // creates share the one card and its answer.
580
+ const localApprovalsByConversation = new Map();
542
581
 
543
582
  // ─── Bookkeeping ───────────────────────────────────────────────────────────
544
583
 
@@ -586,6 +625,41 @@ export function createRemoteRelayDispatcher({
586
625
  return fresh;
587
626
  }
588
627
 
628
+ /** `{ enabled, maxWaitSeconds }`; off and the default wait when unreadable. */
629
+ function agentSessions() {
630
+ let settings = null;
631
+ try {
632
+ settings = getAgentSessionsSettings() || null;
633
+ } catch {}
634
+ const maxWaitSeconds = Number(settings?.maxWaitSeconds);
635
+ return {
636
+ enabled: settings?.enabled === true && typeof localClient?.request === 'function',
637
+ maxWaitSeconds: Number.isFinite(maxWaitSeconds) && maxWaitSeconds > 0
638
+ ? maxWaitSeconds
639
+ : REMOTE_RELAY_LIMITS.waitMaxSeconds,
640
+ };
641
+ }
642
+
643
+ /** This relay as a target: the shape of a registry entry, marked `local`. */
644
+ async function localTarget() {
645
+ const self = (await registry.selfIdentity?.()) || {};
646
+ return {
647
+ id: REMOTE_RELAY_LOCAL_ID,
648
+ relayId: toText(self.relayId) || null,
649
+ name: toText(self.name) || toText(await registry.selfName?.()) || REMOTE_RELAY_LOCAL_ALIAS,
650
+ url: toText(self.publicUrl),
651
+ version: toText(self.version) || null,
652
+ permission: 'full',
653
+ local: true,
654
+ };
655
+ }
656
+
657
+ function adjustLocalCreates(conversationId, delta) {
658
+ const next = (localCreatesByConversation.get(conversationId) || 0) + delta;
659
+ if (next > 0) localCreatesByConversation.set(conversationId, next);
660
+ else localCreatesByConversation.delete(conversationId);
661
+ }
662
+
589
663
  function adjustInflight(conversationId, delta) {
590
664
  const next = inflight(conversationId) + delta;
591
665
  if (next > 0) inflightByConversation.set(conversationId, next);
@@ -638,7 +712,7 @@ export function createRemoteRelayDispatcher({
638
712
  // ─── Remote calls ──────────────────────────────────────────────────────────
639
713
 
640
714
  function call(ctx, method, path, { query, body, timeoutMs } = {}) {
641
- return client.request(ctx.relay, method, path, {
715
+ return (ctx.relay.local ? localClient : client).request(ctx.relay, method, path, {
642
716
  ...(query ? { query: compactQuery(query) } : {}),
643
717
  ...(body !== undefined ? { body } : {}),
644
718
  ...(timeoutMs ? { timeoutMs } : {}),
@@ -696,6 +770,8 @@ export function createRemoteRelayDispatcher({
696
770
  provider: ctx.caller.provider,
697
771
  model: ctx.caller.model,
698
772
  hops: ctx.hops,
773
+ // The conversation named above lives on the relay that reads this.
774
+ ...(ctx.relay.local ? { local: true } : {}),
699
775
  });
700
776
  }
701
777
 
@@ -908,6 +984,9 @@ export function createRemoteRelayDispatcher({
908
984
  */
909
985
  function buildProviders(catalog) {
910
986
  return listProviders(catalog).map((entry) => {
987
+ // A cloud session takes no effort; without this its models would be
988
+ // listed with the Claude provider's efforts (the ids are the same).
989
+ if (entry.provider === CLOUD_PROVIDER) return entry;
911
990
  const efforts = groupEffortsByModel(catalog.models, entry.provider, entry.models);
912
991
  return efforts.length ? { ...entry, efforts } : entry;
913
992
  });
@@ -939,6 +1018,21 @@ export function createRemoteRelayDispatcher({
939
1018
  }
940
1019
  const settings = catalog.settings[provider];
941
1020
  if (!settings) return { provider, configured: false, defaultModel: null, models: [] };
1021
+ if (provider === CLOUD_PROVIDER) {
1022
+ // Its settings route has its own shape: a switch and a default model.
1023
+ // A relay that predates the provider answers 404 and lands above.
1024
+ const enabled = settings.enabled === true;
1025
+ const cloudDefault = toText(settings.defaultModel) || null;
1026
+ return {
1027
+ provider,
1028
+ configured: enabled,
1029
+ defaultModel: cloudDefault,
1030
+ models: enabled
1031
+ ? uniqueTexts([...(cloudDefault ? [cloudDefault] : []), ...(Array.isArray(settings.models) ? settings.models : [])])
1032
+ : [],
1033
+ ...(enabled ? { note: CLOUD_CREATE_NOTE } : {}),
1034
+ };
1035
+ }
942
1036
  // What POST /api/conversation/bootstrap requires, plus "not switched off".
943
1037
  const configured = settings.enabled === true
944
1038
  && (provider === 'claude' || provider === 'grok' || settings.configured === true);
@@ -970,12 +1064,18 @@ export function createRemoteRelayDispatcher({
970
1064
  return {
971
1065
  summary: `relay_info → ${ctx.relay.name}: OAR ${version || '?'}, providers ${configured.join(', ') || 'none'}`,
972
1066
  name: toText(identity?.name) || ctx.relay.name,
1067
+ ...(ctx.relay.local ? { self: true } : {}),
973
1068
  url: ctx.relay.url,
974
- openUrl: remoteConversationUrl(ctx.relay.url, ''),
1069
+ openUrl: remoteConversationUrl(ctx.relay.url, '') || null,
975
1070
  version,
976
1071
  platform: toText(status.platform) || toText(identity?.platform) || null,
977
1072
  protocol: remoteRelays ? nonNegativeInt(remoteRelays.protocol) : 0,
978
- acceptsAgentPrompts: remoteRelays ? remoteRelays.inbound !== false : null,
1073
+ // The inbound switch is about other relays' agents; this relay's own are
1074
+ // let in by the agent-sessions setting, which is on or this would not run.
1075
+ acceptsAgentPrompts: ctx.relay.local ? true : (remoteRelays ? remoteRelays.inbound !== false : null),
1076
+ ...(ctx.relay.local ? {
1077
+ sessionRules: `create_session: at most ${REMOTE_RELAY_LIMITS.localActiveSessions} sessions you started may work at the same time; a session an agent created cannot create sessions; sessions run on this relay's accounts.`,
1078
+ } : {}),
979
1079
  providers,
980
1080
  workspaces: {
981
1081
  default: toText(status.defaultSessionWorkspaceRootPath) || toText(status.workspaceRootPath) || null,
@@ -1000,9 +1100,11 @@ export function createRemoteRelayDispatcher({
1000
1100
  }
1001
1101
  }
1002
1102
 
1003
- function mapSession(row, workerIds) {
1103
+ function mapSession(row, workerIds, ctx = null) {
1004
1104
  const id = toText(row?.id);
1005
1105
  const via = toText(row?.origin?.relayName);
1106
+ // A session an agent of that relay created there, and from which session.
1107
+ const createdBy = row?.origin?.local === true ? toText(row.origin.conversationId) : '';
1006
1108
  return {
1007
1109
  id,
1008
1110
  title: toText(row?.title),
@@ -1013,6 +1115,8 @@ export function createRemoteRelayDispatcher({
1013
1115
  messageCount: nonNegativeInt(row?.messageCount),
1014
1116
  cwd: toText(row?.currentWorkspaceRootPath) || null,
1015
1117
  ...(via ? { via } : {}),
1118
+ ...(createdBy ? { createdBy } : {}),
1119
+ ...(createdBy && ctx?.relay?.local && createdBy === ctx.conversationId ? { startedByYou: true } : {}),
1016
1120
  };
1017
1121
  }
1018
1122
 
@@ -1046,7 +1150,7 @@ export function createRemoteRelayDispatcher({
1046
1150
  let consumed = 0;
1047
1151
  for (const row of rows) {
1048
1152
  consumed += 1;
1049
- if (keep(row)) sessions.push(mapSession(row, workerIds));
1153
+ if (keep(row)) sessions.push(mapSession(row, workerIds, ctx));
1050
1154
  if (sessions.length >= limit) break;
1051
1155
  }
1052
1156
  scanned += consumed;
@@ -1163,7 +1267,7 @@ export function createRemoteRelayDispatcher({
1163
1267
  cwd: toText(conversation?.currentWorkspaceRootPath) || null,
1164
1268
  archived: conversation?.archived === true,
1165
1269
  ...(via ? { via } : {}),
1166
- openUrl: remoteConversationUrl(ctx.relay.url, conversationId),
1270
+ openUrl: remoteConversationUrl(ctx.relay.url, conversationId) || null,
1167
1271
  },
1168
1272
  messages,
1169
1273
  ...(inFlight ? { inFlight } : {}),
@@ -1294,7 +1398,8 @@ export function createRemoteRelayDispatcher({
1294
1398
  }
1295
1399
 
1296
1400
  async function deliverAndWait(action, ctx, { session, sent, waitSeconds, extra: given = {} }) {
1297
- const openUrl = remoteConversationUrl(ctx.relay.url, session);
1401
+ // Null for this relay itself while it has no public URL: no link to offer.
1402
+ const openUrl = remoteConversationUrl(ctx.relay.url, session) || null;
1298
1403
  // `extra.note` (how the effort was settled) goes in front of the outcome's own.
1299
1404
  const { note: extraNote, ...extra } = given;
1300
1405
  const result = await deliverOutcome(action, ctx, { session, sent, waitSeconds, extra, openUrl });
@@ -1354,22 +1459,27 @@ export function createRemoteRelayDispatcher({
1354
1459
  const catalog = await loadCatalog(ctx);
1355
1460
  const providers = buildProviders(catalog);
1356
1461
  const configured = providers.filter((entry) => entry.configured).map((entry) => entry.provider);
1357
- const provider = args.provider || normalizeProvider(ctx.caller.provider) || 'github';
1462
+ const provider = createProvider(ctx.caller, args);
1463
+ const cloud = provider === CLOUD_PROVIDER;
1358
1464
  const entry = providers.find((candidate) => candidate.provider === provider);
1359
1465
  if (!entry?.configured) {
1360
1466
  throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.providerUnavailable,
1361
1467
  `Relay "${ctx.relay.name}" has no ${provider} provider set up. Providers there: ${configured.join(', ') || 'none'}. Pass provider to pick one.`,
1362
- { providers: configured });
1468
+ { providers: configured, ...(cloud && catalog.settings[CLOUD_PROVIDER] ? { remoteCode: CLOUD_DISABLED_CODE } : {}) });
1363
1469
  }
1364
1470
 
1365
1471
  let model = args.model || '';
1366
1472
  let modelSource = model ? 'requested' : '';
1367
1473
  let note = null;
1368
- if (!model) {
1474
+ // A cloud session without a requested model gets the Claude Cloud tab's
1475
+ // default, as New Chat does: the bootstrap picks it, nothing is mirrored.
1476
+ if (!model && cloud) {
1477
+ modelSource = 'remote default';
1478
+ } else if (!model) {
1369
1479
  model = matchOfferedModel(ctx.caller.model, entry.models);
1370
1480
  modelSource = model ? 'same as this session' : '';
1371
1481
  }
1372
- if (!model) {
1482
+ if (!model && !cloud) {
1373
1483
  model = entry.defaultModel || entry.models[0] || '';
1374
1484
  modelSource = 'remote default';
1375
1485
  if (toText(ctx.caller.model)) {
@@ -1383,7 +1493,10 @@ export function createRemoteRelayDispatcher({
1383
1493
  // The effort mirrors the caller like the model does: this session's when
1384
1494
  // the remote lists it for the model (or lists nothing), else the remote's.
1385
1495
  let choice = { effort: normalizeEffort(args.effort), source: EFFORT_SOURCES.requested, note: null };
1386
- if (!choice.effort) {
1496
+ if (!choice.effort && cloud) {
1497
+ // A cloud session takes no effort: none is mirrored, none is reported.
1498
+ choice = { effort: '', source: '', note: null };
1499
+ } else if (!choice.effort) {
1387
1500
  const callerEffort = normalizeEffort(ctx.caller.effort);
1388
1501
  const offered = catalogEffortsFor(catalog.models, provider, model);
1389
1502
  if (callerEffort && (!offered || offered.includes(callerEffort))) {
@@ -1407,6 +1520,9 @@ export function createRemoteRelayDispatcher({
1407
1520
  ...(effort ? { reasoningEffort: effort } : {}),
1408
1521
  title,
1409
1522
  ...(args.cwd ? { cwd: args.cwd } : {}),
1523
+ // What New Chat sends for a Claude Cloud conversation; the bootstrap
1524
+ // validates it and refuses with its own codes.
1525
+ ...(cloud ? { cloudSource: { repoUrl: args.repo, ...(args.branch ? { branch: args.branch } : {}) } } : {}),
1410
1526
  origin,
1411
1527
  },
1412
1528
  });
@@ -1424,7 +1540,8 @@ export function createRemoteRelayDispatcher({
1424
1540
  };
1425
1541
  bootstrap = await bootstrapWith('');
1426
1542
  }
1427
- choice = settleEffort(ctx, choice, bootstrap?.preferredReasoningEffort);
1543
+ // Nothing to settle for a cloud session that was asked for no effort.
1544
+ if (!cloud || choice.effort) choice = settleEffort(ctx, choice, bootstrap?.preferredReasoningEffort);
1428
1545
  const session = toText(bootstrap?.conversationId);
1429
1546
  if (!session) {
1430
1547
  throw new DispatchFailure(502, `${REMOTE_RELAY_ERROR_CODES.httpPrefix}502`,
@@ -1433,7 +1550,11 @@ export function createRemoteRelayDispatcher({
1433
1550
  const boundModel = toText(bootstrap?.selectedModel) || model;
1434
1551
  let sent;
1435
1552
  try {
1436
- ({ sent, choice } = await postPromptWithEffort(ctx, { session, text: args.text, origin, model: boundModel, mode }, choice));
1553
+ const posted = await postPromptWithEffort(ctx, { session, text: args.text, origin, model: boundModel, mode }, choice);
1554
+ sent = posted.sent;
1555
+ if (!cloud || choice.effort) choice = posted.choice;
1556
+ // From here the new session's queued turn counts toward the cap itself.
1557
+ ctx.onPromptQueued?.();
1437
1558
  } catch (error) {
1438
1559
  if (!choice.effort || !isEffortRejection(error)) throw error;
1439
1560
  // The session exists by now: say so, or the agent starts a second one.
@@ -1445,7 +1566,7 @@ export function createRemoteRelayDispatcher({
1445
1566
  session,
1446
1567
  ...(remoteCode ? { remoteCode } : {}),
1447
1568
  ...(supported.length ? { supportedEfforts: supported } : {}),
1448
- openUrl: remoteConversationUrl(ctx.relay.url, session),
1569
+ openUrl: remoteConversationUrl(ctx.relay.url, session) || null,
1449
1570
  note: 'Use send with this session and an effort it takes (or none); do not create another session.',
1450
1571
  });
1451
1572
  }
@@ -1459,6 +1580,7 @@ export function createRemoteRelayDispatcher({
1459
1580
  modelSource,
1460
1581
  mode: toText(bootstrap?.preferredRelayMode) || mode || null,
1461
1582
  title,
1583
+ ...(cloud ? { repo: toText(bootstrap?.cloud?.repoUrl) || args.repo, branch: toText(bootstrap?.cloud?.branch) || args.branch || null } : {}),
1462
1584
  ...(toText(bootstrap?.workspaceRootWarning) ? { cwdWarning: toText(bootstrap.workspaceRootWarning) } : {}),
1463
1585
  ...(note ? { modelNote: note } : {}),
1464
1586
  ...effortResult(choice),
@@ -1466,6 +1588,37 @@ export function createRemoteRelayDispatcher({
1466
1588
  });
1467
1589
  }
1468
1590
 
1591
+ /** The provider a new session gets: the one asked for, else the calling session's. */
1592
+ function createProvider(caller, args) {
1593
+ return args.provider || normalizeProvider(caller?.provider) || 'github';
1594
+ }
1595
+
1596
+ /**
1597
+ * What is wrong with a create_session call that only the calling session
1598
+ * reveals, or null. Checked before anyone is asked for approval: a card for
1599
+ * a call that cannot run would be a question about nothing.
1600
+ */
1601
+ function createSessionProblem(caller, args) {
1602
+ if (createProvider(caller, args) === CLOUD_PROVIDER && !args.repo) {
1603
+ // The provider came from the calling session (the validator requires
1604
+ // repo when the call names claude-cloud itself).
1605
+ return `This session's provider is ${CLOUD_PROVIDER}, which a new session gets by default, and a Claude Cloud session needs repo (https://github.com/owner/repo or owner/repo). Pass repo (and branch), or pass another provider (relay_info lists them).`;
1606
+ }
1607
+ return null;
1608
+ }
1609
+
1610
+ /** Whether `session` (a conversation or session id on this relay) is the caller's own conversation. */
1611
+ function isOwnConversation(ctx, session) {
1612
+ const id = toText(session);
1613
+ if (!id) return false;
1614
+ if (id === ctx.conversationId) return true;
1615
+ try {
1616
+ return toText(resolveConversationId(id)) === ctx.conversationId;
1617
+ } catch {
1618
+ return false;
1619
+ }
1620
+ }
1621
+
1469
1622
  /** The answer body the remote's answer route expects for this question. */
1470
1623
  function buildAnswer(question, args) {
1471
1624
  const fields = schemaFields(question.requestSchema);
@@ -1504,6 +1657,11 @@ export function createRemoteRelayDispatcher({
1504
1657
  `Question ${args.question_id} was not found on relay "${ctx.relay.name}".`);
1505
1658
  }
1506
1659
  const session = toText(question.conversationId) || null;
1660
+ if (ctx.relay.local && session && isOwnConversation(ctx, session)) {
1661
+ throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.ownSession,
1662
+ `Question ${args.question_id} belongs to this conversation itself; an agent cannot answer its own question. It is the user's to answer.`,
1663
+ { session });
1664
+ }
1507
1665
  if (isRemoteRelayApprovalQuestion(question)) {
1508
1666
  throw new DispatchFailure(403, REMOTE_RELAY_ERROR_CODES.forbidden,
1509
1667
  `Question ${args.question_id} is relay "${ctx.relay.name}"'s approval of its own agent's call to another relay; only its user can answer it.`,
@@ -1618,6 +1776,8 @@ export function createRemoteRelayDispatcher({
1618
1776
 
1619
1777
  async function listRelays(conversationId) {
1620
1778
  const relays = (await registry.list()) || [];
1779
+ const settings = agentSessions();
1780
+ const local = settings.enabled ? await localTarget() : null;
1621
1781
  const listed = relays.map((relay) => ({
1622
1782
  name: relay.name,
1623
1783
  url: relay.url,
@@ -1626,6 +1786,10 @@ export function createRemoteRelayDispatcher({
1626
1786
  permission: relay.permission,
1627
1787
  unlocked: relayAccess(conversationId, relay) !== null,
1628
1788
  }));
1789
+ // This relay itself comes first: no address, always online, never locked.
1790
+ if (local) {
1791
+ listed.unshift({ name: local.name, self: true, online: true, version: local.version, permission: 'full', unlocked: true });
1792
+ }
1629
1793
  const unlocked = listed.filter((relay) => relay.unlocked).length;
1630
1794
  return {
1631
1795
  status: 200,
@@ -1634,21 +1798,155 @@ export function createRemoteRelayDispatcher({
1634
1798
  summary: `list_relays: ${listed.length} relay${listed.length === 1 ? '' : 's'}, ${unlocked} unlocked`,
1635
1799
  self: { name: (await registry.selfName?.()) || null },
1636
1800
  relays: listed,
1637
- hint: 'The user unlocks a relay by mentioning @name in the chat; a relay whose agent wrote to this conversation is open as well.',
1801
+ limits: {
1802
+ maxWaitSeconds: settings.maxWaitSeconds,
1803
+ ...(local ? { maxActiveSessions: REMOTE_RELAY_LIMITS.localActiveSessions } : {}),
1804
+ },
1805
+ hint: 'The user unlocks a relay by mentioning @name in the chat; a relay whose agent wrote to this conversation is open as well.'
1806
+ + (local
1807
+ ? ` The relay marked self is the one you run on: name it (or "${REMOTE_RELAY_LOCAL_ALIAS}") to start and use sessions here, no mention needed. At most ${REMOTE_RELAY_LIMITS.localActiveSessions} sessions you started may work at the same time, and they run on this relay's accounts.`
1808
+ : ''),
1638
1809
  },
1639
1810
  };
1640
1811
  }
1641
1812
 
1642
- async function runRelayAction({ conversationId, action, args, req, signal }) {
1643
- const resolved = await registry.resolve(args.relay);
1644
- if (!resolved?.relay) {
1645
- const names = Array.isArray(resolved?.names) ? resolved.names : [];
1646
- const error = resolved?.error === 'ambiguous'
1647
- ? `"${args.relay}" matches several relays (${names.join(', ')}); use the full name.`
1648
- : `No paired relay is called "${args.relay}". Known relays: ${names.join(', ') || 'none'}.`;
1649
- return failure(404, REMOTE_RELAY_ERROR_CODES.unknown, error, { relays: names });
1813
+ /**
1814
+ * The relay a call names: `{ relay }` (a registry entry, or the local
1815
+ * target), or `{ failure }`. "this" always means the relay the agent runs
1816
+ * on; its name does too, unless a paired relay answers to that name.
1817
+ */
1818
+ async function resolveTarget(name) {
1819
+ const query = toText(name).replace(/^@/, '').toLowerCase();
1820
+ const settings = agentSessions();
1821
+ const alias = query === REMOTE_RELAY_LOCAL_ALIAS;
1822
+ const resolved = alias ? null : await registry.resolve(name);
1823
+ if (resolved?.relay) return { relay: resolved.relay };
1824
+ const selfName = toText(await registry.selfName?.());
1825
+ if (alias || (resolved?.error !== 'ambiguous' && selfName && selfName.toLowerCase() === query)) {
1826
+ if (!settings.enabled) {
1827
+ return {
1828
+ failure: failure(403, REMOTE_RELAY_ERROR_CODES.localDisabled,
1829
+ 'Agents may not start or use sessions on this relay: its setting "Agents may start and use sessions on this relay" (Settings → Relays → Agent sessions) is off. Ask the user to turn it on; do not retry until then.',
1830
+ { relay: selfName || REMOTE_RELAY_LOCAL_ALIAS }),
1831
+ };
1832
+ }
1833
+ if (!repository) {
1834
+ return {
1835
+ failure: failure(503, REMOTE_RELAY_ERROR_CODES.localDisabled,
1836
+ 'Sessions on this relay are not available to agents right now: its storage for them could not be opened (see the server log).',
1837
+ { relay: selfName || REMOTE_RELAY_LOCAL_ALIAS }),
1838
+ };
1839
+ }
1840
+ return { relay: await localTarget() };
1650
1841
  }
1651
- const relay = resolved.relay;
1842
+ const names = Array.isArray(resolved?.names) ? resolved.names : [];
1843
+ const error = resolved?.error === 'ambiguous'
1844
+ ? `"${name}" matches several relays (${names.join(', ')}); use the full name.`
1845
+ : `No paired relay is called "${name}". Known relays: ${names.join(', ') || 'none'}${settings.enabled ? `; this relay itself is "${selfName || REMOTE_RELAY_LOCAL_ALIAS}" (or "${REMOTE_RELAY_LOCAL_ALIAS}")` : ''}.`;
1846
+ return { failure: failure(404, REMOTE_RELAY_ERROR_CODES.unknown, error, { relays: names }) };
1847
+ }
1848
+
1849
+ /**
1850
+ * The user's Allow for this conversation's agent to start sessions on this
1851
+ * relay: remembered once given, asked with a card otherwise. Calls that
1852
+ * arrive while the card is open share it and its answer. Resolves
1853
+ * `{ approved:true, asked }` or `{ approved:false, code?, error? }`.
1854
+ */
1855
+ async function ensureLocalSessionApproval({ conversationId, caller, relay, action, args, signal }) {
1856
+ const remembered = () => {
1857
+ try {
1858
+ return repository.hasLocalSessionApproval(conversationId) === true;
1859
+ } catch {
1860
+ return false;
1861
+ }
1862
+ };
1863
+ if (remembered()) return { approved: true, asked: false };
1864
+ const open = localApprovalsByConversation.get(conversationId);
1865
+ if (open) {
1866
+ const shared = await open;
1867
+ return shared.approved ? { approved: true, asked: false } : shared;
1868
+ }
1869
+ const asking = (async () => {
1870
+ const decision = await requestApproval({
1871
+ conversationId,
1872
+ callerContext: caller,
1873
+ relay: relayTarget(relay),
1874
+ action,
1875
+ args,
1876
+ summary: summarizeRemoteRelayCall({ action, ...args, relay: relay.name }),
1877
+ signal,
1878
+ localSessions: true,
1879
+ });
1880
+ if (decision !== true && decision?.approved !== true) {
1881
+ return {
1882
+ approved: false,
1883
+ code: toText(decision?.code) || REMOTE_RELAY_ERROR_CODES.approvalDenied,
1884
+ error: toText(decision?.error)
1885
+ || 'The user did not allow this agent to start a session on this relay. Go on without it, or ask the user before trying again.',
1886
+ };
1887
+ }
1888
+ try {
1889
+ repository.recordLocalSessionApproval(conversationId, caller.processingRowId || null);
1890
+ } catch (error) {
1891
+ // Not remembered: the next create asks again.
1892
+ try {
1893
+ logger?.warn?.(`${LOG_PREFIX} local session approval not stored conv=${shortId(conversationId)}: ${toText(error?.message || error).slice(0, 200)}`);
1894
+ } catch {}
1895
+ }
1896
+ return { approved: true, asked: true };
1897
+ })();
1898
+ localApprovalsByConversation.set(conversationId, asking);
1899
+ try {
1900
+ return await asking;
1901
+ } finally {
1902
+ localApprovalsByConversation.delete(conversationId);
1903
+ }
1904
+ }
1905
+
1906
+ /**
1907
+ * The local target's own rules for create_session, before anyone is asked:
1908
+ * one level, and the cap. Null when the call may go on (the caller then
1909
+ * holds a place under the cap until its prompt is queued).
1910
+ */
1911
+ function refuseLocalCreate(conversationId, relay) {
1912
+ let origin = null;
1913
+ let active = [];
1914
+ try {
1915
+ origin = repository.getConversationOrigin(conversationId);
1916
+ active = repository.listActiveLocalSessions(conversationId);
1917
+ } catch (error) {
1918
+ try {
1919
+ logger?.warn?.(`${LOG_PREFIX} local session rules unreadable conv=${shortId(conversationId)}: ${toText(error?.message || error).slice(0, 200)}`);
1920
+ } catch {}
1921
+ return failure(503, REMOTE_RELAY_ERROR_CODES.localDisabled,
1922
+ 'Sessions on this relay are not available to agents right now: its storage for them could not be read (see the server log).',
1923
+ { relay: relay.name });
1924
+ }
1925
+ if (origin) {
1926
+ return failure(403, REMOTE_RELAY_ERROR_CODES.nestedSession,
1927
+ 'A session that an agent created cannot create sessions. Do the work in this session, or say in your reply what the agent that prompted you should start.',
1928
+ { relay: relay.name });
1929
+ }
1930
+ const limit = REMOTE_RELAY_LIMITS.localActiveSessions;
1931
+ const starting = localCreatesByConversation.get(conversationId) || 0;
1932
+ if (active.length + starting >= limit) {
1933
+ return failure(409, REMOTE_RELAY_ERROR_CODES.sessionLimit,
1934
+ `${limit} sessions you started are still working; wait for one to finish.`,
1935
+ {
1936
+ relay: relay.name,
1937
+ limit,
1938
+ sessions: active.slice(0, limit),
1939
+ note: 'Use wait (or read_session) on one of these sessions; create_session works again once one has no turn queued or running.',
1940
+ });
1941
+ }
1942
+ return null;
1943
+ }
1944
+
1945
+ async function runRelayAction({ conversationId, action, args, req, signal, waitNote }) {
1946
+ const target = await resolveTarget(args.relay);
1947
+ if (!target.relay) return target.failure;
1948
+ const relay = target.relay;
1949
+ const local = relay.local === true;
1652
1950
  const write = isRemoteRelayWriteAction(action);
1653
1951
  const finish = (result, session) => {
1654
1952
  if (write) {
@@ -1665,21 +1963,30 @@ export function createRemoteRelayDispatcher({
1665
1963
  return result;
1666
1964
  };
1667
1965
 
1668
- const access = relayAccess(conversationId, relay);
1966
+ // A paired relay is gated by the user's mention and its permission. The
1967
+ // local target has neither: the setting let the call in (resolveTarget).
1968
+ const access = local ? 'user' : relayAccess(conversationId, relay);
1669
1969
  if (!access) {
1670
1970
  return finish(failure(403, REMOTE_RELAY_ERROR_CODES.locked,
1671
1971
  `Relay "${relay.name}" is locked in this conversation. Ask the user to mention @${relay.name} (or its name) in a message to allow work there.`,
1672
1972
  { relay: relay.name }));
1673
1973
  }
1674
- if (!remoteRelayPermissionAllows(relay.permission, action)) {
1974
+ if (!local && !remoteRelayPermissionAllows(relay.permission, action)) {
1675
1975
  return finish(failure(403, REMOTE_RELAY_ERROR_CODES.forbidden,
1676
1976
  `Relay "${relay.name}" lets agents ${PERMISSION_DESCRIPTIONS[relay.permission] || 'do less'} (permission "${relay.permission}"); ${action} needs "${REMOTE_RELAY_ACTION_PERMISSION[action]}". The user can change this in Settings → Relays.`,
1677
1977
  { relay: relay.name, permission: relay.permission }));
1678
1978
  }
1979
+ if (local && OWN_SESSION_ACTIONS.has(action) && isOwnConversation({ conversationId }, args.session)) {
1980
+ return finish(failure(400, REMOTE_RELAY_ERROR_CODES.ownSession,
1981
+ `Session ${args.session} is the conversation you are running in: an agent cannot ${action} itself. Use the id of another session (list_sessions, or the one create_session returned).`,
1982
+ { relay: relay.name, session: args.session }));
1983
+ }
1679
1984
 
1680
1985
  const caller = { conversationId, ...((await getCallerContext(conversationId, req)) || {}) };
1681
1986
  caller.mode = toText(caller.mode).toLowerCase();
1682
- const hops = nonNegativeInt(caller.hops) + 1;
1987
+ // A call on this relay itself crosses no relay: it carries the hops of the
1988
+ // prompt it acts on unchanged.
1989
+ const hops = nonNegativeInt(caller.hops) + (local ? 0 : 1);
1683
1990
  // The hop limit stops a prompt from travelling on to a third relay. A
1684
1991
  // relay the user unlocked here stays in reach whatever prompts arrive,
1685
1992
  // the relay the prompts came from may always be answered, and reading
@@ -1700,11 +2007,42 @@ export function createRemoteRelayDispatcher({
1700
2007
  { relay: relay.name, retryAfterSeconds }));
1701
2008
  }
1702
2009
 
2010
+ if (action === 'create_session') {
2011
+ const problem = createSessionProblem(caller, args);
2012
+ if (problem) return finish(failure(400, REMOTE_RELAY_ERROR_CODES.invalidInput, problem, { relay: relay.name }));
2013
+ }
2014
+
2015
+ const localCreate = local && action === 'create_session';
2016
+ let holdsLocalCreate = false;
2017
+ const releaseLocalCreate = () => {
2018
+ if (!holdsLocalCreate) return;
2019
+ holdsLocalCreate = false;
2020
+ adjustLocalCreates(conversationId, -1);
2021
+ };
2022
+ if (localCreate) {
2023
+ const refusal = refuseLocalCreate(conversationId, relay);
2024
+ if (refusal) return finish(refusal);
2025
+ adjustLocalCreates(conversationId, 1);
2026
+ holdsLocalCreate = true;
2027
+ }
2028
+
1703
2029
  adjustInflight(conversationId, 1);
1704
2030
  try {
1705
2031
  const prompting = isRemoteRelayPromptAction(action);
1706
2032
  let asked = false;
1707
- if (prompting && agentPrompts(conversationId).count >= REMOTE_RELAY_LIMITS.agentPromptsBeforeAsking) {
2033
+ if (localCreate) {
2034
+ const decision = await ensureLocalSessionApproval({ conversationId, caller, relay, action, args, signal });
2035
+ if (!decision.approved) {
2036
+ return finish(failure(403, decision.code || REMOTE_RELAY_ERROR_CODES.approvalDenied, decision.error, { relay: relay.name }));
2037
+ }
2038
+ // The card this call raised showed its prompt: it is this call's
2039
+ // approval too, and the user has just looked at what the agent does.
2040
+ if (decision.asked) {
2041
+ asked = true;
2042
+ agentPrompts(conversationId).count = 0;
2043
+ }
2044
+ }
2045
+ if (!asked && prompting && agentPrompts(conversationId).count >= REMOTE_RELAY_LIMITS.agentPromptsBeforeAsking) {
1708
2046
  // Agents that prompt each other can go on without the user for good:
1709
2047
  // from time to time the user is asked, in any relay mode.
1710
2048
  const decision = await requestApproval({
@@ -1721,7 +2059,7 @@ export function createRemoteRelayDispatcher({
1721
2059
  if (!approved) {
1722
2060
  const code = toText(decision?.code) || REMOTE_RELAY_ERROR_CODES.approvalDenied;
1723
2061
  const error = toText(decision?.error)
1724
- || `The agent has sent ${agentPrompts(conversationId).count} prompts to other relays' agents since the user last wrote, and the user did not allow more for now.`;
2062
+ || `The agent has sent ${agentPrompts(conversationId).count} prompts to ${local ? 'other agents' : 'other relays\' agents'} since the user last wrote, and the user did not allow more for now.`;
1725
2063
  return finish(failure(403, code, error, { relay: relay.name }));
1726
2064
  }
1727
2065
  agentPrompts(conversationId).count = 0;
@@ -1741,15 +2079,16 @@ export function createRemoteRelayDispatcher({
1741
2079
  if (!approved) {
1742
2080
  const code = toText(decision?.code) || REMOTE_RELAY_ERROR_CODES.approvalDenied;
1743
2081
  const error = toText(decision?.error)
1744
- || `The user did not allow this ${action.replace(/_/g, ' ')} on relay "${relay.name}".`;
2082
+ || `The user did not allow this ${action.replace(/_/g, ' ')} on ${local ? 'this relay' : `relay "${relay.name}"`}.`;
1745
2083
  return finish(failure(403, code, error, { relay: relay.name }));
1746
2084
  }
1747
2085
  }
1748
2086
 
1749
- const ctx = { relay, caller, hops, conversationId, signal };
2087
+ const ctx = { relay, caller, hops, conversationId, signal, onPromptQueued: releaseLocalCreate };
1750
2088
  const body = await ACTIONS[action](ctx, args);
1751
2089
  if (prompting) agentPrompts(conversationId).count += 1;
1752
2090
  const { summary, ...rest } = body;
2091
+ const note = [rest.note, waitNote].map(toText).filter(Boolean).join(' ');
1753
2092
  return finish({
1754
2093
  status: 200,
1755
2094
  body: {
@@ -1757,6 +2096,7 @@ export function createRemoteRelayDispatcher({
1757
2096
  relay: relay.name,
1758
2097
  summary: summary || summarizeRemoteRelayCall({ action, ...args, relay: relay.name }),
1759
2098
  ...rest,
2099
+ ...(note ? { note } : {}),
1760
2100
  },
1761
2101
  });
1762
2102
  } catch (error) {
@@ -1766,6 +2106,7 @@ export function createRemoteRelayDispatcher({
1766
2106
  if (isRemoteRelayError(error)) return finish(mapRemoteError(error, relay, { action, args }));
1767
2107
  throw error;
1768
2108
  } finally {
2109
+ releaseLocalCreate();
1769
2110
  adjustInflight(conversationId, -1);
1770
2111
  }
1771
2112
  }
@@ -1776,7 +2117,10 @@ export function createRemoteRelayDispatcher({
1776
2117
  * the caller hangs up.
1777
2118
  */
1778
2119
  async function dispatch({ conversationId, action, args, req, signal } = {}) {
1779
- const validation = validateRemoteRelayToolInput({ ...(isPlainObject(args) ? args : {}), action });
2120
+ // wait_seconds is cut to this relay's "longest wait" setting, for every
2121
+ // target: the waiting is done here, by polling the target.
2122
+ const { maxWaitSeconds } = agentSessions();
2123
+ const validation = validateRemoteRelayToolInput({ ...(isPlainObject(args) ? args : {}), action }, { maxWaitSeconds });
1780
2124
  if (!validation.ok) return failure(400, validation.code, validation.error);
1781
2125
  const requestedConversationId = toText(conversationId);
1782
2126
  let callerConversationId = requestedConversationId;
@@ -1786,6 +2130,9 @@ export function createRemoteRelayDispatcher({
1786
2130
  if (!callerConversationId) {
1787
2131
  return failure(400, REMOTE_RELAY_ERROR_CODES.invalidInput, 'conversationId is required');
1788
2132
  }
2133
+ const waitNote = validation.waitLimitedFrom
2134
+ ? `wait_seconds ${validation.waitLimitedFrom} is more than this relay allows per call (${validation.args.wait_seconds}, set by its owner); it waited at most ${validation.args.wait_seconds} s.`
2135
+ : '';
1789
2136
  try {
1790
2137
  if (validation.action === 'list_relays') return await listRelays(callerConversationId);
1791
2138
  return await runRelayAction({
@@ -1794,6 +2141,7 @@ export function createRemoteRelayDispatcher({
1794
2141
  args: validation.args,
1795
2142
  req,
1796
2143
  signal,
2144
+ waitNote,
1797
2145
  });
1798
2146
  } catch (error) {
1799
2147
  try {