@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
@@ -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.
@@ -60,7 +66,7 @@ export const REMOTE_RELAY_PROMPT_ACTIONS = Object.freeze(['send', 'create_sessio
60
66
  export const REMOTE_RELAY_APPROVAL_MODES = Object.freeze(['ask', 'plan']);
61
67
 
62
68
  export const REMOTE_RELAY_SESSION_SCOPES = Object.freeze(['active', 'recent', 'all']);
63
- 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']);
64
70
  export const REMOTE_RELAY_MODES = Object.freeze(['plan', 'ask', 'agent', 'autopilot']);
65
71
  export const REMOTE_RELAY_IF_BUSY = Object.freeze(['queue', 'fail']);
66
72
  // Reasoning efforts differ per provider and model (none, low, medium, high,
@@ -81,7 +87,16 @@ export const REMOTE_RELAY_RESULT_STATUSES = Object.freeze([
81
87
 
82
88
  export const REMOTE_RELAY_LIMITS = Object.freeze({
83
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.
84
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,
85
100
  listDefault: 20,
86
101
  listMax: 100,
87
102
  readDefault: 10,
@@ -119,6 +134,15 @@ export const REMOTE_RELAY_ERROR_CODES = Object.freeze({
119
134
  providerUnavailable: 'REMOTE_RELAY_PROVIDER_UNAVAILABLE',
120
135
  busy: 'REMOTE_RELAY_BUSY',
121
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',
122
146
  internal: 'REMOTE_RELAY_INTERNAL',
123
147
  // Prefix: the remote answered with this HTTP status, e.g. REMOTE_RELAY_HTTP_409.
124
148
  httpPrefix: 'REMOTE_RELAY_HTTP_',
@@ -131,6 +155,10 @@ export const REMOTE_RELAY_SETTING_KEYS = Object.freeze({
131
155
  relays: 'remote_relays',
132
156
  publicUrl: 'relay_public_url',
133
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',
134
162
  });
135
163
 
136
164
  // Headers a relay adds to every call it forwards to a remote. The remote uses
@@ -142,22 +170,42 @@ export const REMOTE_RELAY_HEADERS = Object.freeze({
142
170
  });
143
171
 
144
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';
145
182
 
146
183
  export const REMOTE_RELAY_TOOL_DESCRIPTION =
147
- 'Work with sessions on other OAR relays this relay is paired with ("remote relays"): '
148
- + 'list the relays, list or read their sessions, prompt a session, start a new one, '
149
- + 'wait for its reply, and answer or stop it. Use it only when the user asks for work '
150
- + 'on another relay or mentions one, or to answer an agent on another relay that wrote '
151
- + 'to this conversation. Start with {action:"list_relays"}. A relay stays locked until '
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 '
152
188
  + 'the user mentions it in this conversation (for example @name) or an agent on that '
153
189
  + 'relay writes to it; from then on it stays open for the whole conversation. A '
154
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. '
155
203
  + 'send and create_session queue the prompt and wait up to wait_seconds (default 120, '
156
- + '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 '
157
205
  + '"running" with progress: call {action:"wait", relay, session, message_id} to keep '
158
- + '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 '
159
207
  + 'back as "running", with its latest reply so far; "done" means it has finished. '
160
- + '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 '
161
209
  + 'answer: ask the user (unless they told you to decide yourself) and pass the answer '
162
210
  + 'with answer_question. Write actions can first need the user\'s approval. Always '
163
211
  + 'tell the user which relay and session you worked on.';
@@ -168,21 +216,22 @@ export const REMOTE_RELAY_TOOL_INPUT_SCHEMA = Object.freeze({
168
216
  action: {
169
217
  type: 'string',
170
218
  enum: [...REMOTE_RELAY_ACTIONS],
171
- 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, '
172
220
  + 'workspaces of a relay. list_sessions / read_session: discover and read sessions. send: prompt an '
173
221
  + 'existing session. create_session: start a new session with a first prompt. wait: keep waiting for '
174
222
  + 'a reply. answer_question: answer a question the remote agent asked. stop: stop the running turn. '
175
223
  + 'archive: archive a session.',
176
224
  },
177
- relay: { type: 'string', description: 'Relay name (or its host) from list_relays. Required except for list_relays.' },
178
- session: { type: 'string', description: 'Remote session (conversation) id from list_sessions or create_session.' },
179
- 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.' },
180
228
  message_id: { type: 'string', description: 'wait: the message_id that send or create_session returned.' },
181
229
  wait_seconds: {
182
230
  type: 'integer',
183
231
  minimum: 0,
184
- maximum: REMOTE_RELAY_LIMITS.waitMaxSeconds,
185
- 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).',
186
235
  },
187
236
  scope: {
188
237
  type: 'string',
@@ -211,7 +260,16 @@ export const REMOTE_RELAY_TOOL_INPUT_SCHEMA = Object.freeze({
211
260
  description: 'create_session: model id (default: this session\'s model when the remote offers it, else the remote\'s default). '
212
261
  + 'send: switch the remote session to this model (default: it keeps its own).',
213
262
  },
214
- 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
+ },
215
273
  mode: {
216
274
  type: 'string',
217
275
  enum: [...REMOTE_RELAY_MODES],
@@ -257,13 +315,46 @@ function invalid(error) {
257
315
 
258
316
  const SESSION_ACTIONS = new Set(['read_session', 'wait', 'send', 'stop', 'archive']);
259
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
+
260
347
  /**
261
348
  * Validates and normalises one tool call. Adapters call it for a crisp early
262
349
  * message; the relay calls it again authoritatively. Returns
263
350
  * `{ ok:true, action, args }` with only the fields the action uses, defaults
264
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.
265
356
  */
266
- export function validateRemoteRelayToolInput(input = {}) {
357
+ export function validateRemoteRelayToolInput(input = {}, { maxWaitSeconds } = {}) {
267
358
  const action = toText(input?.action).toLowerCase();
268
359
  if (!REMOTE_RELAY_ACTIONS.includes(action)) {
269
360
  return invalid(`action must be one of: ${REMOTE_RELAY_ACTIONS.join(', ')}`);
@@ -281,12 +372,20 @@ export function validateRemoteRelayToolInput(input = {}) {
281
372
  args.session = session;
282
373
  }
283
374
 
284
- const waitSeconds = clampInteger(input?.wait_seconds, {
375
+ const waitCeiling = clampInteger(input?.wait_seconds, {
285
376
  min: 0,
286
- max: REMOTE_RELAY_LIMITS.waitMaxSeconds,
377
+ max: REMOTE_RELAY_LIMITS.waitMaxCeilingSeconds,
287
378
  fallback: REMOTE_RELAY_LIMITS.waitDefaultSeconds,
288
379
  });
289
- 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
+ : {};
290
389
 
291
390
  const optionalEnum = (key, allowed) => {
292
391
  const value = toText(input?.[key]).toLowerCase();
@@ -348,6 +447,29 @@ export function validateRemoteRelayToolInput(input = {}) {
348
447
  if (!providerCheck.ok) return providerCheck;
349
448
  optionalText('cwd', 1000);
350
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
+ }
351
473
  }
352
474
  } else if (action === 'answer_question') {
353
475
  const questionId = toText(input?.question_id);
@@ -361,7 +483,8 @@ export function validateRemoteRelayToolInput(input = {}) {
361
483
  if (answer) args.answer = answer.slice(0, REMOTE_RELAY_LIMITS.textMax);
362
484
  if (choices.length) args.choices = choices;
363
485
  }
364
- return { ok: true, action, args };
486
+ const waits = Object.prototype.hasOwnProperty.call(args, 'wait_seconds');
487
+ return { ok: true, action, args, ...(waits ? waitLimited : {}) };
365
488
  }
366
489
 
367
490
  const PERMISSION_RANK = { read: 1, prompt: 2, full: 3 };
@@ -417,7 +540,12 @@ const ORIGIN_CAPS = {
417
540
  /**
418
541
  * Sanitises an `origin` object received on /api/message or bootstrap (or built
419
542
  * locally before forwarding). Returns null when it does not describe an agent
420
- * 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).
421
549
  */
422
550
  export function normalizeRemoteRelayOrigin(raw) {
423
551
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
@@ -430,6 +558,7 @@ export function normalizeRemoteRelayOrigin(raw) {
430
558
  if (origin.relayUrl && !/^https?:\/\//i.test(origin.relayUrl)) origin.relayUrl = '';
431
559
  const hops = Number(raw.hops);
432
560
  origin.hops = Number.isInteger(hops) && hops >= 0 ? Math.min(hops, 10) : 1;
561
+ if (raw.local === true) origin.local = true;
433
562
  return origin;
434
563
  }
435
564
 
@@ -443,6 +572,8 @@ function quoteForHeader(value) {
443
572
  * message carries `origin` (the badge shows the same facts).
444
573
  */
445
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.
446
577
  const parts = [`[Remote prompt from an agent on relay "${quoteForHeader(origin?.relayName) || 'unknown'}"`];
447
578
  const title = quoteForHeader(origin?.conversationTitle);
448
579
  if (title) parts.push(`session "${title}"`);
@@ -41,7 +41,8 @@ export function cloneRemoteRelayToolInputSchema() {
41
41
  return JSON.parse(JSON.stringify(REMOTE_RELAY_TOOL_INPUT_SCHEMA));
42
42
  }
43
43
 
44
- // How many remotes this relay knows (`{ count }`): the registration decision.
44
+ // How many remotes this relay knows and whether its own sessions are open to
45
+ // agents (`{ count, localEnabled }`): the registration decision.
45
46
  export const REMOTE_RELAY_SUMMARY_PATH = '/api/remote-relays/summary';
46
47
  // Calls of one conversation the relay is still working on (`{ inflight }`):
47
48
  // the Grok inactivity hold, which cannot see the tool call itself.
@@ -62,9 +63,9 @@ export const REMOTE_RELAY_CANCELLED = 'REMOTE_RELAY_CANCELLED';
62
63
  export const REMOTE_RELAY_DECISION_TIMEOUT_MS = 5_000;
63
64
 
64
65
  // A hard ceiling for one tool call on the providers that impose their own
65
- // (MCP clients). The relay already bounds the wait (wait_seconds ≤ 600) but not
66
- // the time an approval card may sit unanswered, so this only has to outlast a
67
- // human, never a remote turn.
66
+ // (MCP clients). The relay already bounds the wait (wait_seconds up to its
67
+ // "longest wait" setting, at most an hour) but not the time an approval card
68
+ // may sit unanswered, so this only has to outlast a human, never a turn.
68
69
  export const REMOTE_RELAY_TOOL_CALL_TIMEOUT_MS = 8 * 60 * 60_000;
69
70
 
70
71
  function toText(value) {
@@ -146,8 +147,9 @@ export function remoteRelayCancelledResult() {
146
147
  * contract as executePreviewTool.
147
148
  *
148
149
  * The call goes out as a long call (`{ longCall: true }`): no client-side
149
- * timeout at all, because the relay bounds the wait itself (wait_seconds plus
150
- * approval time) and fetch's 300 s header limit would cut both short.
150
+ * timeout at all, because the relay bounds the wait itself (wait_seconds,
151
+ * cut to the relay's own maximum, plus approval time) and fetch's 300 s
152
+ * header limit would cut both short.
151
153
  * `signal` (the provider's cancellation) is the only way to end it early.
152
154
  */
153
155
  export async function executeRemoteRelayTool(input, { api, conversationId = '', signal = null } = {}) {
@@ -176,7 +178,8 @@ export async function executeRemoteRelayTool(input, { api, conversationId = '',
176
178
  }
177
179
 
178
180
  /**
179
- * Whether a worker should register the tool: at least one remote is paired.
181
+ * Whether a worker should register the tool: at least one remote is paired,
182
+ * or this relay lets agents start and use sessions on it (the local target).
180
183
  * Fails closed — any error, a malformed answer or a slow relay leaves the tool
181
184
  * out, and the next worker start asks again.
182
185
  */
@@ -188,7 +191,7 @@ export async function shouldRegisterRemoteRelayTool({ api, timeoutMs = REMOTE_RE
188
191
  timeoutMs,
189
192
  'remote relay summary',
190
193
  );
191
- return Number(response?.count) > 0;
194
+ return Number(response?.count) > 0 || response?.localEnabled === true;
192
195
  } catch {
193
196
  return false;
194
197
  }
@@ -84,6 +84,7 @@ export function steerAgentLabelForProvider(providerType) {
84
84
  case 'openai': return 'Copilot';
85
85
  case 'cursor': return 'Cursor';
86
86
  case 'grok': return 'Grok';
87
+ case 'claude-cloud': return 'Claude Cloud';
87
88
  default: return DEFAULT_STEER_AGENT_LABEL;
88
89
  }
89
90
  }