@oar-sh/oar 0.9.5 → 0.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +12 -32
  2. package/.github/extensions/web-relay/skills/question-bridge.mjs +14 -39
  3. package/CHANGELOG.md +340 -0
  4. package/README.md +196 -26
  5. package/package.json +2 -1
  6. package/server/README.md +174 -7
  7. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
  8. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
  9. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
  10. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  11. package/server/claude-worker/claude-sdk-adapter.mjs +18 -2
  12. package/server/claude-worker/claude-session-process.mjs +151 -1
  13. package/server/claude-worker/claude-session-worker.mjs +6 -0
  14. package/server/claude-worker/claude-turn-failure.mjs +92 -0
  15. package/server/claude-worker/claude-turn-publisher.mjs +52 -3
  16. package/server/claude-worker/sdk-message-normalizer.mjs +13 -1
  17. package/server/copilot-worker/copilot-model-switch.mjs +29 -3
  18. package/server/copilot-worker/copilot-question-bridge.mjs +2 -4
  19. package/server/copilot-worker/copilot-runtime-tree.mjs +91 -0
  20. package/server/copilot-worker/copilot-sdk-adapter.mjs +48 -8
  21. package/server/copilot-worker/copilot-sdk-session-process.mjs +897 -166
  22. package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
  23. package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
  24. package/server/db-schema.mjs +24 -0
  25. package/server/grok-worker/acp-client.mjs +68 -12
  26. package/server/grok-worker/grok-sdk-adapter.mjs +4 -0
  27. package/server/grok-worker/grok-turn-runner.mjs +6 -2
  28. package/server/migrations/0006-workspace-root-settings.mjs +67 -0
  29. package/server/migrations/0007-claude-cloud.mjs +82 -0
  30. package/server/public/app/agent-sessions-settings-ui.js +197 -0
  31. package/server/public/app/agent-sessions-settings.mjs +134 -0
  32. package/server/public/app/api-client.js +160 -6
  33. package/server/public/app/attachments-view.js +20 -1
  34. package/server/public/app/attribution-control.mjs +46 -0
  35. package/server/public/app/bootstrap.js +347 -31
  36. package/server/public/app/claude-cloud-conversation-ui.js +110 -0
  37. package/server/public/app/claude-cloud-settings-ui.js +374 -0
  38. package/server/public/app/claude-cloud-ui.mjs +611 -0
  39. package/server/public/app/composer-control-state.mjs +15 -3
  40. package/server/public/app/context-menu.mjs +140 -0
  41. package/server/public/app/context-tier-options.mjs +4 -0
  42. package/server/public/app/conversation-origin-ui.js +27 -0
  43. package/server/public/app/conversation-provider-indicator.mjs +22 -4
  44. package/server/public/app/conversation-view.js +178 -8
  45. package/server/public/app/cwd-picker.js +12 -0
  46. package/server/public/app/journal-view.js +704 -28
  47. package/server/public/app/markdown-strikethrough.mjs +31 -0
  48. package/server/public/app/math-dollars.mjs +89 -0
  49. package/server/public/app/model-selector-options.mjs +1 -1
  50. package/server/public/app/plan-usage-view.mjs +36 -8
  51. package/server/public/app/relay-board-inline.mjs +70 -0
  52. package/server/public/app/relay-board-view.js +157 -61
  53. package/server/public/app/relay-error-ctas.mjs +28 -5
  54. package/server/public/app/remote-relay-origin-view.mjs +120 -4
  55. package/server/public/app/router.js +49 -12
  56. package/server/public/app/settings-modal.js +33 -2
  57. package/server/public/app/settings-tabs.js +1 -1
  58. package/server/public/app/socket-handlers.js +60 -0
  59. package/server/public/app/store.js +4 -1
  60. package/server/public/app/subagent-fold.mjs +61 -0
  61. package/server/public/app/usage-limit-ui.js +225 -0
  62. package/server/public/index.html +471 -8
  63. package/server/relay-tools.md +13 -1
  64. package/server/repositories/message-repository.mjs +10 -1
  65. package/server/repositories/question-repository.mjs +1 -0
  66. package/server/repositories/remote-relay-repository.mjs +72 -1
  67. package/server/repositories/session-repository.mjs +47 -1
  68. package/server/routes/claude-cloud-routes.mjs +140 -0
  69. package/server/routes/git-routes.mjs +37 -0
  70. package/server/routes/messages-routes.mjs +202 -26
  71. package/server/routes/relay-board-routes.mjs +56 -2
  72. package/server/routes/remote-relay-routes.mjs +15 -1
  73. package/server/routes/sessions-routes.mjs +538 -72
  74. package/server/server-runtime.mjs +291 -27
  75. package/server/services/claude-account-usage-service.mjs +176 -0
  76. package/server/services/claude-cloud-repo-service.mjs +408 -0
  77. package/server/services/claude-cloud-session-service.mjs +326 -0
  78. package/server/services/claude-cloud-settings-service.mjs +269 -0
  79. package/server/services/git-remote-service.mjs +115 -0
  80. package/server/services/plan-usage-claude-cloud.mjs +211 -0
  81. package/server/services/plan-usage-claude.mjs +202 -40
  82. package/server/services/plan-usage-contract.mjs +39 -3
  83. package/server/services/plan-usage-service.mjs +82 -2
  84. package/server/services/preview-proxy-server.mjs +64 -36
  85. package/server/services/process-list-service.mjs +122 -0
  86. package/server/services/provider-reasoning-effort.mjs +3 -0
  87. package/server/services/relay-console-log.mjs +199 -0
  88. package/server/services/remote-relay-caller-context.mjs +78 -19
  89. package/server/services/remote-relay-client.mjs +9 -0
  90. package/server/services/remote-relay-dispatcher.mjs +451 -39
  91. package/server/services/remote-relay-inbound.mjs +83 -1
  92. package/server/services/remote-relay-loopback.mjs +245 -0
  93. package/server/services/remote-relay-registry-service.mjs +52 -0
  94. package/server/services/session-worker-launch-service.mjs +100 -15
  95. package/server/services/session-worker-process-service.mjs +410 -138
  96. package/server/services/session-worker-registry-service.mjs +1 -1
  97. package/server/services/session-worker-stop-service.mjs +24 -13
  98. package/server/services/session-worker-supervisor-service.mjs +89 -2
  99. package/server/services/usage-limit-pause-service.mjs +443 -0
  100. package/server/services/worker-launch-note-service.mjs +151 -0
  101. package/shared/ask-user-bridge.mjs +14 -39
  102. package/shared/claude-attribution.mjs +117 -0
  103. package/shared/claude-cloud/api-client.mjs +809 -0
  104. package/shared/claude-cloud/base-url.mjs +47 -0
  105. package/shared/claude-cloud/credentials.mjs +221 -0
  106. package/shared/claude-cloud/repo-url.mjs +111 -0
  107. package/shared/claude-usage-limit.mjs +257 -0
  108. package/shared/control-poller.mjs +69 -20
  109. package/shared/failure-note-text.mjs +38 -0
  110. package/shared/provider-routing.mjs +4 -2
  111. package/shared/question-wait.mjs +108 -0
  112. package/shared/remote-relay-contract.mjs +167 -22
  113. package/shared/remote-relay-tool-core.mjs +11 -8
  114. package/shared/steer-settle-failure.mjs +17 -0
  115. package/shared/steer-settle-markers.mjs +8 -0
  116. package/shared/worker-runtime/process-tree.mjs +483 -0
  117. package/shared/worker-runtime/relay-errors.mjs +31 -0
  118. package/shared/worker-runtime/send-and-wait-errors.mjs +4 -4
  119. package/shared/worker-runtime/turn-liveness.mjs +249 -0
@@ -23,7 +23,10 @@ export function createControlPoller({
23
23
  onAbortSubagent = null,
24
24
  dbg = () => {},
25
25
  } = {}) {
26
- let active = null;
26
+ // The turns a Stop is polled for, oldest first, and whether the one loop
27
+ // that serves them all is running.
28
+ const registrations = new Set();
29
+ let polling = false;
27
30
 
28
31
  async function checkOnce({ queueMessageId, onAbortTurn }) {
29
32
  const ownerSessionId = String(sdkSessionId || '').trim();
@@ -81,37 +84,83 @@ export function createControlPoller({
81
84
  }
82
85
  }
83
86
 
84
- function start({ queueMessageId, onAbortTurn }) {
85
- stop();
86
- const state = { stopped: false };
87
- active = state;
88
- (async () => {
89
- while (!state.stopped) {
90
- await sleep(pollMs);
91
- if (state.stopped) return;
87
+ /**
88
+ * Hand a Stop to the turns that polled for it, newest first, until one of
89
+ * them takes it. The candidates were chosen before the request went out and
90
+ * are not re-checked here: the relay has claimed the control for this worker
91
+ * by now, so a turn that finished meanwhile still has to answer it.
92
+ */
93
+ async function offerAbort(candidates, control) {
94
+ let failure = null;
95
+ for (const registration of candidates) {
96
+ try {
97
+ await registration.onAbortTurn(control);
98
+ return;
99
+ } catch (error) {
100
+ failure = error;
101
+ }
102
+ }
103
+ throw failure || new Error('abort failed');
104
+ }
105
+
106
+ async function pollLoop() {
107
+ while (registrations.size) {
108
+ await sleep(pollMs);
109
+ // One request per row filter, however many turns share it, asked in the
110
+ // order of the newest turn that uses it.
111
+ const byFilter = new Map();
112
+ for (const registration of [...registrations].reverse()) {
113
+ const candidates = byFilter.get(registration.queueMessageId) || [];
114
+ candidates.push(registration);
115
+ byFilter.set(registration.queueMessageId, candidates);
116
+ }
117
+ for (const [queueMessageId, candidates] of byFilter) {
118
+ // Every turn behind this filter ended while an earlier one was asked.
119
+ if (candidates.every((registration) => registration.stopped)) continue;
92
120
  try {
93
- const aborted = await checkOnce({ queueMessageId, onAbortTurn });
94
- if (aborted) return;
121
+ await checkOnce({ queueMessageId, onAbortTurn: (control) => offerAbort(candidates, control) });
95
122
  } catch (error) {
96
123
  dbg('control poll failed', error?.message || String(error));
97
124
  }
98
125
  }
99
- })();
100
- return state;
126
+ }
127
+ polling = false;
128
+ }
129
+
130
+ /**
131
+ * Poll for a Stop on behalf of one turn, until `stop(handle)`. Turns overlap
132
+ * (a settled turn still waiting on a question card or publishing, beside the
133
+ * one that runs), so a new registration never displaces an older one, and a
134
+ * handled Stop does not end the polling: the Stop may have been for another
135
+ * turn's row, and the turns that remain still need theirs.
136
+ */
137
+ function start({ queueMessageId, onAbortTurn }) {
138
+ const registration = {
139
+ stopped: false,
140
+ queueMessageId: String(queueMessageId || ''),
141
+ onAbortTurn,
142
+ };
143
+ registrations.add(registration);
144
+ if (!polling) {
145
+ polling = true;
146
+ void pollLoop();
147
+ }
148
+ return registration;
101
149
  }
102
150
 
103
151
  function stop(handle) {
104
- // Handle-scoped stop: a caller finishing an old turn must only stop that
105
- // turn's poller. Without the scoping, a late finalize for a previous
106
- // context would kill the poller of the turn currently running — leaving
107
- // its Stop button dead. A bare stop() still stops whatever is active.
152
+ // Handle-scoped stop: a caller finishing a turn ends that turn's
153
+ // registration and no other. Without the scoping, a late finalize for a
154
+ // previous context would end the polling for the turn currently running,
155
+ // leaving its Stop button dead. A bare stop() ends every registration (the
156
+ // worker is shutting down).
108
157
  if (handle) {
109
158
  handle.stopped = true;
110
- if (active === handle) active = null;
159
+ registrations.delete(handle);
111
160
  return;
112
161
  }
113
- if (active) active.stopped = true;
114
- active = null;
162
+ for (const registration of registrations) registration.stopped = true;
163
+ registrations.clear();
115
164
  }
116
165
 
117
166
  return { start, stop, checkOnce };
@@ -0,0 +1,38 @@
1
+ // What makes a text a failure note of the relay.
2
+ //
3
+ // A failed turn is reported as a record. Where only text is there (a sender
4
+ // that reports a failure as text, a reply read from another relay), the note
5
+ // is known by its shape: one paragraph that leads up to "Error code:
6
+ // relay.<code>" within a few sentences. A reply that quotes or describes such
7
+ // a note, in a report on a failed test for example, is a reply.
8
+ //
9
+ // One rule for every reader: the relay's response route and the remote-relay
10
+ // dispatcher must not disagree about what a failure is.
11
+
12
+ export const FAILURE_NOTE_LEAD_MAX = 600;
13
+
14
+ const CODE_PATTERN = /error code:\s*(relay\.[a-z0-9-]+)/i;
15
+ // Somebody is talking about a note: the code stands in quotation marks, in a
16
+ // code span or a table cell, or the paragraph is a list item, a heading or a
17
+ // quotation.
18
+ const QUOTED_LEAD = /["“”'`|>]\s*$/;
19
+ const MARKUP_LEAD = /^\s*(?:#+|[>|*-]|\d+\.)\s/;
20
+
21
+ /**
22
+ * `{ stableCode, index, lead }` when `text` is a failure note, else null.
23
+ * `index` is where "Error code" begins, `lead` the text before it.
24
+ */
25
+ export function matchFailureNote(text) {
26
+ const raw = String(text ?? '').trim();
27
+ if (!raw) return null;
28
+ const match = CODE_PATTERN.exec(raw);
29
+ if (!match) return null;
30
+ const lead = raw.slice(0, match.index);
31
+ if (lead.length > FAILURE_NOTE_LEAD_MAX || /[\r\n]/.test(lead)) return null;
32
+ if (QUOTED_LEAD.test(lead) || MARKUP_LEAD.test(lead)) return null;
33
+ return { stableCode: match[1].toLowerCase(), index: match.index, lead };
34
+ }
35
+
36
+ export function isFailureNoteText(text) {
37
+ return matchFailureNote(text) !== null;
38
+ }
@@ -11,7 +11,9 @@
11
11
  */
12
12
  export const LEGACY_RELAY_PROVIDER_TYPES = Object.freeze(['github', 'openai']);
13
13
 
14
- export const SESSION_WORKER_PROVIDER_TYPES = Object.freeze(['claude', 'cursor', 'grok']);
14
+ // `claude-cloud` is its own provider, never an alias of `claude`: its worker
15
+ // drives a session in Anthropic's cloud and spawns no CLI.
16
+ export const SESSION_WORKER_PROVIDER_TYPES = Object.freeze(['claude', 'cursor', 'grok', 'claude-cloud']);
15
17
 
16
18
  export function isLegacyRelayProviderType(value) {
17
19
  const normalized = String(value || '').trim().toLowerCase();
@@ -23,7 +25,7 @@ export function isSessionWorkerProviderType(value) {
23
25
  return SESSION_WORKER_PROVIDER_TYPES.includes(String(value || '').trim().toLowerCase());
24
26
  }
25
27
 
26
- /** `'claude', 'cursor', 'grok'` — for interpolation into SQL NOT IN (...) lists. */
28
+ /** `'claude', 'cursor', 'grok', 'claude-cloud'` — for interpolation into SQL NOT IN (...) lists. */
27
29
  export function sessionWorkerProviderSqlList() {
28
30
  return SESSION_WORKER_PROVIDER_TYPES.map((provider) => `'${provider}'`).join(', ');
29
31
  }
@@ -0,0 +1,108 @@
1
+ // Waiting for the answer to a question card, and creating the card, through
2
+ // a relay that is briefly not there.
3
+ //
4
+ // A card may be open for hours and is polled every 1.5 s. One poll that failed
5
+ // — the relay restarting, a reset connection, a 502 from a proxy in front of
6
+ // it — used to end the wait with an error, and the callers turn an error into
7
+ // an answer: "continue with your best judgement" for a question, a rejection
8
+ // for an approval. The card itself stayed open, so the user later answered a
9
+ // question nobody was waiting on any more. The relay keeps its cards across a
10
+ // restart; the wait only has to outlast it.
11
+
12
+ import { QUESTION_TIMEOUT_CONTINUATION_TEXT } from './question-timeout.mjs';
13
+ import { isRelayUnreachableError, isTransientRelayError } from './worker-runtime/relay-errors.mjs';
14
+
15
+ const DEFAULT_POLL_MS = 1500;
16
+ /** The spacing between polls grows to this while the relay does not answer. */
17
+ const MAX_OUTAGE_POLL_MS = 10_000;
18
+ /** Attempts to create a card while the relay refuses connections (~16 s). */
19
+ const DEFAULT_CREATE_RETRY_DELAYS_MS = Object.freeze([500, 1_000, 2_000, 4_000, 8_000]);
20
+
21
+ function sleepDefault(ms) {
22
+ return new Promise((resolve) => setTimeout(resolve, ms));
23
+ }
24
+
25
+ /**
26
+ * Create a question card. Retried only while the relay refuses the
27
+ * connection: any other failure may have created the card, and a second
28
+ * request would put a second one in front of the user.
29
+ */
30
+ export async function createRelayQuestion({
31
+ api, payload, sleep = sleepDefault, retryDelaysMs = DEFAULT_CREATE_RETRY_DELAYS_MS, signal = null, dbg = () => {},
32
+ } = {}) {
33
+ for (let attempt = 0; ; attempt += 1) {
34
+ try {
35
+ const created = await api('POST', '/api/relay-question', payload);
36
+ const questionId = created?.question?.id;
37
+ if (!questionId) throw new Error('Relay question could not be created');
38
+ return questionId;
39
+ } catch (error) {
40
+ if (!isRelayUnreachableError(error) || attempt >= retryDelaysMs.length || signal?.aborted) throw error;
41
+ dbg('relay unreachable while creating a question; trying again', error?.message || String(error));
42
+ await sleep(retryDelaysMs[attempt]);
43
+ }
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Poll a card until it is answered, times out, is cancelled, or `signal`
49
+ * aborts the wait. Resolves `{ answer, structuredAnswer, timedOut, aborted? }`.
50
+ * Rejects only on the relay's own refusal (a 4xx, a card that does not exist).
51
+ */
52
+ export async function waitForRelayQuestion({
53
+ api,
54
+ questionId,
55
+ deadlineMs,
56
+ pollMs = DEFAULT_POLL_MS,
57
+ sleep = sleepDefault,
58
+ signal = null,
59
+ trimAnswer = true,
60
+ now = Date.now,
61
+ dbg = () => {},
62
+ } = {}) {
63
+ const started = now();
64
+ const expired = { answer: QUESTION_TIMEOUT_CONTINUATION_TEXT, structuredAnswer: null, timedOut: true };
65
+ let failedPolls = 0;
66
+ while (true) {
67
+ if (signal?.aborted) {
68
+ await api('POST', `/api/relay-question/${questionId}/timeout`, {}).catch(() => {});
69
+ return { ...expired, aborted: true };
70
+ }
71
+ let question = null;
72
+ let outage = null;
73
+ try {
74
+ ({ question } = await api('GET', `/api/relay-question/${questionId}`));
75
+ if (!question) throw Object.assign(new Error('Relay question missing'), { status: 404 });
76
+ } catch (error) {
77
+ if (!isTransientRelayError(error)) throw error;
78
+ outage = error;
79
+ }
80
+ if (outage) {
81
+ failedPolls += 1;
82
+ if (failedPolls === 1) dbg('relay did not answer the question poll; the card stays open', questionId, outage?.message || String(outage));
83
+ } else {
84
+ if (failedPolls) dbg('relay answers the question poll again', questionId, `after ${failedPolls} failed poll(s)`);
85
+ failedPolls = 0;
86
+ if (question.status === 'answered') {
87
+ const answer = String(question.answer || '');
88
+ return {
89
+ answer: trimAnswer ? answer.trim() : answer,
90
+ // The validated structured submission, when the card carried a
91
+ // `requestedSchema` and the relay stored one. Null otherwise — flat
92
+ // consumers read `answer` and never see a shape change.
93
+ structuredAnswer: question.structuredAnswer && typeof question.structuredAnswer === 'object'
94
+ && !Array.isArray(question.structuredAnswer)
95
+ ? question.structuredAnswer
96
+ : null,
97
+ timedOut: false,
98
+ };
99
+ }
100
+ if (question.status === 'timed_out' || question.status === 'cancelled') return { ...expired };
101
+ }
102
+ if (now() - started >= deadlineMs) {
103
+ await api('POST', `/api/relay-question/${questionId}/timeout`, {}).catch(() => {});
104
+ return { ...expired };
105
+ }
106
+ await sleep(failedPolls ? Math.min(MAX_OUTAGE_POLL_MS, pollMs * 2 ** Math.min(failedPolls, 6)) : pollMs);
107
+ }
108
+ }
@@ -7,9 +7,15 @@
7
7
  // provenance ("origin") shape a relay attaches when its agent prompts a remote
8
8
  // session, the prompt header line, and the URL policy for remote addresses.
9
9
  //
10
+ // Since agent sessions, the relay an agent runs on is a target as well (the
11
+ // "local target", addressed by this relay's name or `this`): the same tool
12
+ // creates and drives sessions on its own relay, behind a setting.
13
+ //
10
14
  // SDK-free and side-effect free: imported by the server, the session workers,
11
15
  // the stdio MCP server and (for the header/URL helpers) the browser.
12
16
 
17
+ import { isValidBranchName, normalizeGitHubRepoUrl } from './claude-cloud/repo-url.mjs';
18
+
13
19
  export const REMOTE_RELAY_TOOL_NAME = 'remote_relay';
14
20
 
15
21
  // Bumped when a relay starts relying on a newer inbound behaviour of its peers.
@@ -54,10 +60,13 @@ export const REMOTE_RELAY_ACTION_PERMISSION = Object.freeze({
54
60
  // the calling session runs in ask or plan mode.
55
61
  export const REMOTE_RELAY_WRITE_ACTIONS = Object.freeze(['send', 'create_session', 'answer_question', 'stop', 'archive']);
56
62
 
63
+ // The write actions that carry an agent's words to another relay's agent.
64
+ export const REMOTE_RELAY_PROMPT_ACTIONS = Object.freeze(['send', 'create_session', 'answer_question']);
65
+
57
66
  export const REMOTE_RELAY_APPROVAL_MODES = Object.freeze(['ask', 'plan']);
58
67
 
59
68
  export const REMOTE_RELAY_SESSION_SCOPES = Object.freeze(['active', 'recent', 'all']);
60
- export const REMOTE_RELAY_PROVIDERS = Object.freeze(['github', 'openai', 'claude', 'cursor', 'grok']);
69
+ export const REMOTE_RELAY_PROVIDERS = Object.freeze(['github', 'openai', 'claude', 'cursor', 'grok', 'claude-cloud']);
61
70
  export const REMOTE_RELAY_MODES = Object.freeze(['plan', 'ask', 'agent', 'autopilot']);
62
71
  export const REMOTE_RELAY_IF_BUSY = Object.freeze(['queue', 'fail']);
63
72
  // Reasoning efforts differ per provider and model (none, low, medium, high,
@@ -78,7 +87,16 @@ export const REMOTE_RELAY_RESULT_STATUSES = Object.freeze([
78
87
 
79
88
  export const REMOTE_RELAY_LIMITS = Object.freeze({
80
89
  waitDefaultSeconds: 120,
90
+ // The longest wait of one call is a setting of the relay ("Longest wait per
91
+ // tool call"): waitMaxSeconds is its default, the next three its range. The
92
+ // validator below clamps to the ceiling; the relay clamps to its setting.
81
93
  waitMaxSeconds: 600,
94
+ waitMaxFloorSeconds: 120,
95
+ waitMaxCeilingSeconds: 3600,
96
+ waitMaxStepSeconds: 60,
97
+ // Sessions one conversation's agent started on its own relay that may have
98
+ // a turn queued or running at the same time.
99
+ localActiveSessions: 4,
82
100
  listDefault: 20,
83
101
  listMax: 100,
84
102
  readDefault: 10,
@@ -87,7 +105,12 @@ export const REMOTE_RELAY_LIMITS = Object.freeze({
87
105
  maxCharsMax: 50000,
88
106
  perMessageChars: 4000,
89
107
  textMax: 100000,
108
+ // Only toward a relay the user did not unlock and that is not the one the
109
+ // prompt came from: a third relay.
90
110
  hopLimit: 2,
111
+ // Prompts a conversation's agent may send to other relays' agents without a
112
+ // message from the user in between, before the user is asked.
113
+ agentPromptsBeforeAsking: 30,
91
114
  callsPerMinute: 60,
92
115
  writesPerMinute: 10,
93
116
  healthIntervalMs: 60_000,
@@ -111,6 +134,15 @@ export const REMOTE_RELAY_ERROR_CODES = Object.freeze({
111
134
  providerUnavailable: 'REMOTE_RELAY_PROVIDER_UNAVAILABLE',
112
135
  busy: 'REMOTE_RELAY_BUSY',
113
136
  noTurn: 'REMOTE_RELAY_NO_ACTIVE_TURN',
137
+ // The local target (this relay itself):
138
+ // the setting "Agents may start and use sessions on this relay" is off,
139
+ localDisabled: 'REMOTE_RELAY_LOCAL_DISABLED',
140
+ // a session an agent created tried to create one itself,
141
+ nestedSession: 'REMOTE_RELAY_NESTED_SESSION',
142
+ // the conversation already has `localActiveSessions` created sessions at work,
143
+ sessionLimit: 'REMOTE_RELAY_SESSION_LIMIT',
144
+ // and an agent tried to prompt, stop, archive, wait for or answer itself.
145
+ ownSession: 'REMOTE_RELAY_OWN_SESSION',
114
146
  internal: 'REMOTE_RELAY_INTERNAL',
115
147
  // Prefix: the remote answered with this HTTP status, e.g. REMOTE_RELAY_HTTP_409.
116
148
  httpPrefix: 'REMOTE_RELAY_HTTP_',
@@ -123,6 +155,10 @@ export const REMOTE_RELAY_SETTING_KEYS = Object.freeze({
123
155
  relays: 'remote_relays',
124
156
  publicUrl: 'relay_public_url',
125
157
  inboundEnabled: 'remote_relay_inbound_enabled',
158
+ // Agent sessions: the local target's switch (off unless set) and the longest
159
+ // wait of one tool call, for local and paired targets alike.
160
+ agentSessionsEnabled: 'agent_sessions_enabled',
161
+ maxWaitSeconds: 'remote_relay_max_wait_seconds',
126
162
  });
127
163
 
128
164
  // Headers a relay adds to every call it forwards to a remote. The remote uses
@@ -134,20 +170,42 @@ export const REMOTE_RELAY_HEADERS = Object.freeze({
134
170
  });
135
171
 
136
172
  export const REMOTE_RELAY_SOCKET_EVENT = 'remote_relays_updated';
173
+ // Carries the payload of GET /api/settings/agent-sessions.
174
+ export const AGENT_SESSIONS_SOCKET_EVENT = 'agent_sessions_settings_updated';
175
+
176
+ // The local target: the alias that always names the relay the agent runs on,
177
+ // and the id it has wherever a paired relay has its registry id (the
178
+ // remembered approval is stored as an unlock under it).
179
+ export const REMOTE_RELAY_LOCAL_ALIAS = 'this';
180
+ export const REMOTE_RELAY_LOCAL_ID = 'self';
181
+ export const REMOTE_RELAY_CLOUD_PROVIDER = 'claude-cloud';
137
182
 
138
183
  export const REMOTE_RELAY_TOOL_DESCRIPTION =
139
- 'Work with sessions on other OAR relays this relay is paired with ("remote relays"): '
140
- + 'list the relays, list or read their sessions, prompt a session, start a new one, '
141
- + 'wait for its reply, and answer or stop it. Use it only when the user asks for work '
142
- + 'on another relay or mentions one. Start with {action:"list_relays"}. A relay stays '
143
- + 'locked until the user mentions it in this conversation (for example @name); a '
184
+ 'Work with sessions on OAR relays: the other relays this relay is paired with ("remote relays") '
185
+ + 'and, when its owner allows it, this relay itself. List the relays, list or read their '
186
+ + 'sessions, prompt a session, start a new one, wait for its reply, and answer or stop it. '
187
+ + 'Start with {action:"list_relays"}. A paired relay stays locked until '
188
+ + 'the user mentions it in this conversation (for example @name) or an agent on that '
189
+ + 'relay writes to it; from then on it stays open for the whole conversation. A '
144
190
  + 'locked relay refuses everything except list_relays, so ask the user to mention it. '
191
+ + 'Use a paired relay only when the user asks for work there or mentions it, or to answer '
192
+ + 'an agent on it that wrote to this conversation. '
193
+ + 'This relay is listed with self:true and is addressed by its name or as "this"; it needs '
194
+ + 'no mention. Use it to hand parts of your task to other sessions: create_session up to '
195
+ + `${REMOTE_RELAY_LIMITS.localActiveSessions} sessions that work at the same time (any provider relay_info lists: local ones `
196
+ + 'with cwd, provider "claude-cloud" with repo and an optional branch), then wait for and read '
197
+ + 'each one. The first create_session here shows the user an approval card once per '
198
+ + 'conversation. A session you created cannot create sessions itself, and every session '
199
+ + 'runs on the relay owner\'s accounts, so start only what the task needs. A new session '
200
+ + 'knows nothing of this conversation: write a self-contained prompt, and for '
201
+ + '"claude-cloud" (a sandbox with a fresh clone of the repository) name the branch to '
202
+ + 'work on and to push. '
145
203
  + 'send and create_session queue the prompt and wait up to wait_seconds (default 120, '
146
- + 'max 600) for the reply. If the remote turn is still running you get status '
204
+ + 'max set on the relay, default 600) for the reply. If the turn is still running you get status '
147
205
  + '"running" with progress: call {action:"wait", relay, session, message_id} to keep '
148
- + 'waiting. A remote agent that answers but keeps working in the background also comes '
206
+ + 'waiting. An agent that answers but keeps working in the background also comes '
149
207
  + 'back as "running", with its latest reply so far; "done" means it has finished. '
150
- + 'If a result lists pendingQuestions, the remote agent is waiting for an '
208
+ + 'If a result lists pendingQuestions, the other agent is waiting for an '
151
209
  + 'answer: ask the user (unless they told you to decide yourself) and pass the answer '
152
210
  + 'with answer_question. Write actions can first need the user\'s approval. Always '
153
211
  + 'tell the user which relay and session you worked on.';
@@ -158,21 +216,22 @@ export const REMOTE_RELAY_TOOL_INPUT_SCHEMA = Object.freeze({
158
216
  action: {
159
217
  type: 'string',
160
218
  enum: [...REMOTE_RELAY_ACTIONS],
161
- description: 'list_relays: paired relays and whether each is unlocked here. relay_info: providers, models, '
219
+ description: 'list_relays: the relays (this one included when allowed) and whether each is unlocked here. relay_info: providers, models, '
162
220
  + 'workspaces of a relay. list_sessions / read_session: discover and read sessions. send: prompt an '
163
221
  + 'existing session. create_session: start a new session with a first prompt. wait: keep waiting for '
164
222
  + 'a reply. answer_question: answer a question the remote agent asked. stop: stop the running turn. '
165
223
  + 'archive: archive a session.',
166
224
  },
167
- relay: { type: 'string', description: 'Relay name (or its host) from list_relays. Required except for list_relays.' },
168
- session: { type: 'string', description: 'Remote session (conversation) id from list_sessions or create_session.' },
169
- text: { type: 'string', description: 'send / create_session: the prompt for the remote agent.' },
225
+ relay: { type: 'string', description: 'Relay name (or its host) from list_relays, or "this" for the relay you run on. Required except for list_relays.' },
226
+ session: { type: 'string', description: 'Session (conversation) id from list_sessions or create_session.' },
227
+ text: { type: 'string', description: 'send / create_session: the prompt for the other agent.' },
170
228
  message_id: { type: 'string', description: 'wait: the message_id that send or create_session returned.' },
171
229
  wait_seconds: {
172
230
  type: 'integer',
173
231
  minimum: 0,
174
- maximum: REMOTE_RELAY_LIMITS.waitMaxSeconds,
175
- description: 'send / create_session / wait: how long to wait for the reply (default 120, 0 = do not wait).',
232
+ maximum: REMOTE_RELAY_LIMITS.waitMaxCeilingSeconds,
233
+ description: 'send / create_session / wait: how long to wait for the reply (default 120, 0 = do not wait; '
234
+ + 'the relay limits it to its own maximum, 600 unless its owner changed it).',
176
235
  },
177
236
  scope: {
178
237
  type: 'string',
@@ -201,7 +260,16 @@ export const REMOTE_RELAY_TOOL_INPUT_SCHEMA = Object.freeze({
201
260
  description: 'create_session: model id (default: this session\'s model when the remote offers it, else the remote\'s default). '
202
261
  + 'send: switch the remote session to this model (default: it keeps its own).',
203
262
  },
204
- cwd: { type: 'string', description: 'create_session: working directory on the remote relay (default: its default workspace).' },
263
+ cwd: { type: 'string', description: 'create_session: working directory on the target relay (default: its default workspace). Not for claude-cloud.' },
264
+ repo: {
265
+ type: 'string',
266
+ description: 'create_session with provider "claude-cloud" (required there, an error with any other provider): '
267
+ + 'the GitHub repository the cloud session clones, as https://github.com/owner/repo or owner/repo.',
268
+ },
269
+ branch: {
270
+ type: 'string',
271
+ description: 'create_session with provider "claude-cloud": the branch to check out (default: the repository\'s default branch).',
272
+ },
205
273
  mode: {
206
274
  type: 'string',
207
275
  enum: [...REMOTE_RELAY_MODES],
@@ -247,13 +315,46 @@ function invalid(error) {
247
315
 
248
316
  const SESSION_ACTIONS = new Set(['read_session', 'wait', 'send', 'stop', 'archive']);
249
317
 
318
+ /**
319
+ * The longest wait a relay may be set to allow, from whatever was stored or
320
+ * sent: a whole number of seconds inside the range, or the default.
321
+ */
322
+ export function normalizeRemoteRelayMaxWaitSeconds(value) {
323
+ const number = Number(value);
324
+ if (value === null || value === undefined || value === '' || !Number.isFinite(number)) {
325
+ return REMOTE_RELAY_LIMITS.waitMaxSeconds;
326
+ }
327
+ return Math.min(
328
+ REMOTE_RELAY_LIMITS.waitMaxCeilingSeconds,
329
+ Math.max(REMOTE_RELAY_LIMITS.waitMaxFloorSeconds, Math.trunc(number)),
330
+ );
331
+ }
332
+
333
+ /**
334
+ * The repository of a Claude Cloud session as an agent may write it: any form
335
+ * shared/claude-cloud/repo-url.mjs takes, or the bare `owner/repo`. Returns
336
+ * the canonical https URL, or '' when the text names no GitHub repository.
337
+ */
338
+ export function normalizeRemoteRelayRepo(value) {
339
+ const text = toText(value);
340
+ if (!text) return '';
341
+ const direct = normalizeGitHubRepoUrl(text);
342
+ if (direct) return direct.repoUrl;
343
+ if (!/^[^/\s:@]+\/[^/\s:@]+$/.test(text)) return '';
344
+ return normalizeGitHubRepoUrl(`https://github.com/${text}`)?.repoUrl || '';
345
+ }
346
+
250
347
  /**
251
348
  * Validates and normalises one tool call. Adapters call it for a crisp early
252
349
  * message; the relay calls it again authoritatively. Returns
253
350
  * `{ ok:true, action, args }` with only the fields the action uses, defaults
254
351
  * filled in and numbers clamped, or `{ ok:false, code, error }`.
352
+ *
353
+ * `maxWaitSeconds` is the relay's own setting; without it (an adapter, which
354
+ * does not know the setting) wait_seconds is clamped to the ceiling only.
355
+ * `waitLimitedFrom` is set on the result when the relay's setting cut the wait.
255
356
  */
256
- export function validateRemoteRelayToolInput(input = {}) {
357
+ export function validateRemoteRelayToolInput(input = {}, { maxWaitSeconds } = {}) {
257
358
  const action = toText(input?.action).toLowerCase();
258
359
  if (!REMOTE_RELAY_ACTIONS.includes(action)) {
259
360
  return invalid(`action must be one of: ${REMOTE_RELAY_ACTIONS.join(', ')}`);
@@ -271,12 +372,20 @@ export function validateRemoteRelayToolInput(input = {}) {
271
372
  args.session = session;
272
373
  }
273
374
 
274
- const waitSeconds = clampInteger(input?.wait_seconds, {
375
+ const waitCeiling = clampInteger(input?.wait_seconds, {
275
376
  min: 0,
276
- max: REMOTE_RELAY_LIMITS.waitMaxSeconds,
377
+ max: REMOTE_RELAY_LIMITS.waitMaxCeilingSeconds,
277
378
  fallback: REMOTE_RELAY_LIMITS.waitDefaultSeconds,
278
379
  });
279
- if (waitSeconds === null) return invalid('wait_seconds must be a number of seconds');
380
+ if (waitCeiling === null) return invalid('wait_seconds must be a number of seconds');
381
+ const waitMax = maxWaitSeconds === undefined
382
+ ? REMOTE_RELAY_LIMITS.waitMaxCeilingSeconds
383
+ : normalizeRemoteRelayMaxWaitSeconds(maxWaitSeconds);
384
+ const waitSeconds = Math.min(waitCeiling, waitMax);
385
+ // What the agent asked for, when the relay's setting allows less.
386
+ const waitLimited = waitSeconds < waitCeiling
387
+ ? { waitLimitedFrom: Math.trunc(Math.min(Number(input.wait_seconds), Number.MAX_SAFE_INTEGER)) }
388
+ : {};
280
389
 
281
390
  const optionalEnum = (key, allowed) => {
282
391
  const value = toText(input?.[key]).toLowerCase();
@@ -338,6 +447,29 @@ export function validateRemoteRelayToolInput(input = {}) {
338
447
  if (!providerCheck.ok) return providerCheck;
339
448
  optionalText('cwd', 1000);
340
449
  optionalText('title', 200);
450
+ // A Claude Cloud session clones a repository instead of opening a
451
+ // folder: repo and branch belong to that provider alone. A call that
452
+ // names no provider gets this session's, so "repo is missing" for a
453
+ // cloud caller is the relay's to say.
454
+ const repoText = toText(input?.repo);
455
+ const branchText = toText(input?.branch);
456
+ if (args.provider === REMOTE_RELAY_CLOUD_PROVIDER) {
457
+ if (!repoText) {
458
+ return invalid('create_session with provider "claude-cloud" needs repo (https://github.com/owner/repo or owner/repo)');
459
+ }
460
+ if (args.cwd) return invalid('cwd does not apply to provider "claude-cloud": it clones repo instead of opening a folder');
461
+ } else if (repoText || branchText) {
462
+ return invalid('repo and branch go with provider "claude-cloud" only; pass provider "claude-cloud", or cwd for a folder on the relay');
463
+ }
464
+ if (repoText) {
465
+ const repo = normalizeRemoteRelayRepo(repoText);
466
+ if (!repo) return invalid('repo must be a GitHub repository: https://github.com/owner/repo or owner/repo');
467
+ args.repo = repo;
468
+ }
469
+ if (branchText) {
470
+ if (!isValidBranchName(branchText)) return invalid(`branch "${branchText.slice(0, 80)}" is not a valid branch name`);
471
+ args.branch = branchText;
472
+ }
341
473
  }
342
474
  } else if (action === 'answer_question') {
343
475
  const questionId = toText(input?.question_id);
@@ -351,7 +483,8 @@ export function validateRemoteRelayToolInput(input = {}) {
351
483
  if (answer) args.answer = answer.slice(0, REMOTE_RELAY_LIMITS.textMax);
352
484
  if (choices.length) args.choices = choices;
353
485
  }
354
- return { ok: true, action, args };
486
+ const waits = Object.prototype.hasOwnProperty.call(args, 'wait_seconds');
487
+ return { ok: true, action, args, ...(waits ? waitLimited : {}) };
355
488
  }
356
489
 
357
490
  const PERMISSION_RANK = { read: 1, prompt: 2, full: 3 };
@@ -373,6 +506,10 @@ export function isRemoteRelayWriteAction(action) {
373
506
  return REMOTE_RELAY_WRITE_ACTIONS.includes(action);
374
507
  }
375
508
 
509
+ export function isRemoteRelayPromptAction(action) {
510
+ return REMOTE_RELAY_PROMPT_ACTIONS.includes(action);
511
+ }
512
+
376
513
  // ─── Approval cards ──────────────────────────────────────────────────────────
377
514
 
378
515
  // The Allow / Deny card a relay puts on its own turn before a write action on
@@ -403,7 +540,12 @@ const ORIGIN_CAPS = {
403
540
  /**
404
541
  * Sanitises an `origin` object received on /api/message or bootstrap (or built
405
542
  * locally before forwarding). Returns null when it does not describe an agent
406
- * on another relay. Every field is a capped string except `hops`.
543
+ * on a relay. Every field is a capped string except `hops` and `local`.
544
+ *
545
+ * `local: true` marks an agent of THIS relay (the local target): the
546
+ * conversation named by `conversationId` lives here. Only the in-process
547
+ * loopback may claim it; the inbound side drops it from anything that came in
548
+ * over HTTP (readRemoteRelayRequest).
407
549
  */
408
550
  export function normalizeRemoteRelayOrigin(raw) {
409
551
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
@@ -416,6 +558,7 @@ export function normalizeRemoteRelayOrigin(raw) {
416
558
  if (origin.relayUrl && !/^https?:\/\//i.test(origin.relayUrl)) origin.relayUrl = '';
417
559
  const hops = Number(raw.hops);
418
560
  origin.hops = Number.isInteger(hops) && hops >= 0 ? Math.min(hops, 10) : 1;
561
+ if (raw.local === true) origin.local = true;
419
562
  return origin;
420
563
  }
421
564
 
@@ -429,6 +572,8 @@ function quoteForHeader(value) {
429
572
  * message carries `origin` (the badge shows the same facts).
430
573
  */
431
574
  export function formatRemotePromptHeader(origin) {
575
+ // The same line for an agent of this relay (origin.local): REMOTE_PROMPT_HEADER_PATTERN
576
+ // and every reader that strips it stay as they are.
432
577
  const parts = [`[Remote prompt from an agent on relay "${quoteForHeader(origin?.relayName) || 'unknown'}"`];
433
578
  const title = quoteForHeader(origin?.conversationTitle);
434
579
  if (title) parts.push(`session "${title}"`);