@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
@@ -63,6 +63,19 @@ export function createSessionWorkerSupervisor({
63
63
  maxRestartRetries = 5,
64
64
  restartBackoffBaseMs = 1_000,
65
65
  restartBackoffMaxMs = 30_000,
66
+ // Exhausted is not for ever: what failed the launches can be gone — a
67
+ // process list the relay could not read for an hour was (2026-10-01) — and
68
+ // the pending work would otherwise wait for a hand-made launch or a relay
69
+ // restart. One more launch is allowed this long after the last failure; a
70
+ // failure exhausts the session again at once. 0 keeps it exhausted.
71
+ restartExhaustedRetryAfterMs = 60_000,
72
+ // ...and not for longer than this after the first exhaustion: then the
73
+ // session stays exhausted until a launch is asked for (the Retry button,
74
+ // the launch or kill route, a relay restart). 0 retries without end.
75
+ restartExhaustedRetryWindowMs = 10 * 60_000,
76
+ // Told about launch episodes, for the note in the conversation:
77
+ // ({ type: 'exhausted' | 'retry-failed' | 'stopped' | 'launched', sdkSessionId, lifecycle }).
78
+ onLaunchState = null,
66
79
  idleEvictionMs = 0,
67
80
  // Copilot-kind workers boot a full CLI (auth, model catalog, workspace scan)
68
81
  // before their extension can heartbeat; measured median is ~35s. A 30s
@@ -88,6 +101,8 @@ export function createSessionWorkerSupervisor({
88
101
  const retryLimit = Math.max(0, clampInt(maxRestartRetries, 5));
89
102
  const backoffBaseMs = Math.max(1, clampInt(restartBackoffBaseMs, 1_000));
90
103
  const backoffMaxMs = Math.max(backoffBaseMs, clampInt(restartBackoffMaxMs, 30_000));
104
+ const exhaustedRetryAfterMs = Math.max(0, clampInt(restartExhaustedRetryAfterMs, 60_000));
105
+ const exhaustedRetryWindowMs = Math.max(0, clampInt(restartExhaustedRetryWindowMs, 10 * 60_000));
91
106
  const idleTimeoutMs = clampInt(idleEvictionMs, 0);
92
107
  const heartbeatStaleAfterMs = Math.max(1_000, clampInt(heartbeatTimeoutMs, 90_000));
93
108
  const recoveryGraceMs = Math.max(0, clampInt(degradedRecoveryGraceMs, 10_000));
@@ -129,6 +144,10 @@ export function createSessionWorkerSupervisor({
129
144
  backoffMs: 0,
130
145
  nextRestartAtMs: null,
131
146
  restartExhausted: false,
147
+ // The launch episode: when the launches were first exhausted, and
148
+ // whether the relay has stopped trying again by itself.
149
+ exhaustedSinceMs: null,
150
+ retriesStopped: false,
132
151
  lastError: null,
133
152
  lastFailureAtMs: null,
134
153
  lastActivityAtMs: nowAtMs,
@@ -167,6 +186,11 @@ export function createSessionWorkerSupervisor({
167
186
  backoffMs: clampInt(lifecycle.backoffMs, 0),
168
187
  nextRestartAt: lifecycle.nextRestartAtMs ? new Date(lifecycle.nextRestartAtMs).toISOString() : null,
169
188
  restartExhausted: Boolean(lifecycle.restartExhausted),
189
+ exhaustedSince: lifecycle.exhaustedSinceMs ? new Date(lifecycle.exhaustedSinceMs).toISOString() : null,
190
+ retriesStopped: Boolean(lifecycle.retriesStopped),
191
+ retryAgainAt: lifecycle.restartExhausted && !lifecycle.retriesStopped && lifecycle.lastFailureAtMs && exhaustedRetryAfterMs > 0
192
+ ? new Date(lifecycle.lastFailureAtMs + exhaustedRetryAfterMs).toISOString()
193
+ : null,
170
194
  lastError: lifecycle.lastError || null,
171
195
  lastFailureAt: lifecycle.lastFailureAtMs ? new Date(lifecycle.lastFailureAtMs).toISOString() : null,
172
196
  lastActivityAt: lifecycle.lastActivityAtMs ? new Date(lifecycle.lastActivityAtMs).toISOString() : null,
@@ -189,6 +213,16 @@ export function createSessionWorkerSupervisor({
189
213
  };
190
214
  }
191
215
 
216
+ // The caller's listener must not break the supervisor.
217
+ function notifyLaunchState(type, sessionId) {
218
+ if (typeof onLaunchState !== 'function') return;
219
+ try {
220
+ onLaunchState({ type, sdkSessionId: sessionId, lifecycle: toLifecycleSnapshot(sessionId) });
221
+ } catch (error) {
222
+ try { log?.(`launch state listener failed (${type}, session=${String(sessionId).slice(0, 8)}): ${error?.message || error}`); } catch {}
223
+ }
224
+ }
225
+
192
226
  function touchActivity(sessionId, atMs = nowMs()) {
193
227
  return setLifecycle(sessionId, { lastActivityAtMs: atMs });
194
228
  }
@@ -394,16 +428,26 @@ export function createSessionWorkerSupervisor({
394
428
  const failedAtMs = nowMs();
395
429
 
396
430
  if (nextRetryCount > retryLimit) {
431
+ const firstExhaustion = !lifecycle.restartExhausted;
432
+ const exhaustedSinceMs = lifecycle.exhaustedSinceMs || failedAtMs;
433
+ const retriesStopped = exhaustedRetryAfterMs === 0
434
+ || (exhaustedRetryWindowMs > 0 && failedAtMs - exhaustedSinceMs >= exhaustedRetryWindowMs);
397
435
  const exhausted = setLifecycle(sessionId, {
398
436
  retryCount: nextRetryCount,
399
437
  backoffMs: 0,
400
438
  nextRestartAtMs: null,
401
439
  restartExhausted: true,
440
+ exhaustedSinceMs,
441
+ retriesStopped,
402
442
  lastError: message,
403
443
  lastFailureAtMs: failedAtMs,
404
444
  lastActivityAtMs: failedAtMs,
405
445
  });
406
446
  registerDegradedState(sessionId, 'restart-exhausted', { atMs: failedAtMs });
447
+ notifyLaunchState(
448
+ firstExhaustion ? 'exhausted' : (retriesStopped && !lifecycle.retriesStopped ? 'stopped' : 'retry-failed'),
449
+ sessionId,
450
+ );
407
451
  return { scheduled: false, exhausted: true, lifecycle: toLifecycleSnapshot(sessionId, exhausted) };
408
452
  }
409
453
 
@@ -432,6 +476,8 @@ export function createSessionWorkerSupervisor({
432
476
  backoffMs: 0,
433
477
  nextRestartAtMs: null,
434
478
  restartExhausted: false,
479
+ exhaustedSinceMs: null,
480
+ retriesStopped: false,
435
481
  lastError: null,
436
482
  lastFailureAtMs: null,
437
483
  lastActivityAtMs: nowAtMs,
@@ -454,7 +500,13 @@ export function createSessionWorkerSupervisor({
454
500
  const lifecycle = lifecycleBySession.get(sessionId);
455
501
  if (!lifecycle) return { allowed: true, reason: null, lifecycle: null };
456
502
  if (lifecycle.restartExhausted) {
457
- return { allowed: false, reason: 'restart-exhausted', lifecycle: toLifecycleSnapshot(sessionId, lifecycle) };
503
+ const failedAtMs = Number(lifecycle.lastFailureAtMs || 0);
504
+ const dueAgain = !lifecycle.retriesStopped
505
+ && exhaustedRetryAfterMs > 0 && failedAtMs > 0 && nowMs() - failedAtMs >= exhaustedRetryAfterMs;
506
+ if (!dueAgain) {
507
+ return { allowed: false, reason: 'restart-exhausted', lifecycle: toLifecycleSnapshot(sessionId, lifecycle) };
508
+ }
509
+ return { allowed: true, reason: null, lifecycle: toLifecycleSnapshot(sessionId, lifecycle) };
458
510
  }
459
511
  if (lifecycle.nextRestartAtMs && nowMs() < lifecycle.nextRestartAtMs) {
460
512
  return { allowed: false, reason: 'restart-delayed', lifecycle: toLifecycleSnapshot(sessionId, lifecycle) };
@@ -545,6 +597,7 @@ export function createSessionWorkerSupervisor({
545
597
  // backoff/exhausted lifecycle state was left behind by earlier failures.
546
598
  clearRestartSchedule(sessionId, { resetDegradedState: false });
547
599
  assessSessionHealth(sessionId, reusedWorker, { nowAtMs, questionPending: false });
600
+ notifyLaunchState('launched', sessionId);
548
601
  return { ok: true, reused: true, worker: reusedWorker, lifecycle: toLifecycleSnapshot(sessionId) };
549
602
  }
550
603
 
@@ -609,6 +662,7 @@ export function createSessionWorkerSupervisor({
609
662
  });
610
663
  clearRestartSchedule(sessionId);
611
664
  assessSessionHealth(sessionId, readyState, { questionPending: false });
665
+ notifyLaunchState('launched', sessionId);
612
666
  return { ok: true, reused: false, worker: readyState, lifecycle: toLifecycleSnapshot(sessionId) };
613
667
  }
614
668
  const simulatedReady = setWorkerState(sessionId, {
@@ -625,6 +679,7 @@ export function createSessionWorkerSupervisor({
625
679
  });
626
680
  clearRestartSchedule(sessionId);
627
681
  assessSessionHealth(sessionId, simulatedReady, { questionPending: false });
682
+ notifyLaunchState('launched', sessionId);
628
683
  return { ok: true, reused: false, worker: simulatedReady, lifecycle: toLifecycleSnapshot(sessionId) };
629
684
  } catch (error) {
630
685
  const scheduled = scheduleRestart(sessionId, error, { incrementRetry: true });
@@ -177,7 +177,9 @@ export function createUsageLimitPauseService({
177
177
  */
178
178
  function planPause({ terminalError, conversationId, providerType, queueRow = null, partialText = '' } = {}) {
179
179
  if (!isUsageLimitTerminalError(terminalError)) return null;
180
- if (String(providerType || '').trim().toLowerCase() !== 'claude') return null;
180
+ // Claude sessions, local and in the cloud: both run Claude Code on the
181
+ // same account and report the same limit.
182
+ if (!['claude', 'claude-cloud'].includes(String(providerType || '').trim().toLowerCase())) return null;
181
183
  if (!String(conversationId || '').trim()) return null;
182
184
  const at = now();
183
185
  const rateLimitType = String(terminalError.rateLimitType || '').trim() || null;
@@ -0,0 +1,151 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The note in the conversation when a session's worker cannot be started.
5
+ *
6
+ * Until 2026-10-01 a session whose launches were exhausted showed a yellow
7
+ * dot and nothing else: the message stayed queued, nothing said why, and
8
+ * nothing tried again. The supervisor now retries for a while and tells this
9
+ * service about the episode; the service keeps ONE relay-authored note per
10
+ * episode in the conversation and updates it in place — exhausted, each
11
+ * failed retry, stopped (with a Retry button in the client), started.
12
+ *
13
+ * The note is an assistant message of kind `worker-launch-failed`,
14
+ * `worker-launch-stopped` or `worker-launch-started`; the queued message is
15
+ * left queued throughout, so a launch that works delivers it.
16
+ */
17
+
18
+ import { randomUUID } from 'node:crypto';
19
+
20
+ export const WORKER_LAUNCH_NOTE_KINDS = Object.freeze({
21
+ failed: 'worker-launch-failed',
22
+ stopped: 'worker-launch-stopped',
23
+ started: 'worker-launch-started',
24
+ });
25
+
26
+ const CAUSE_MAX_CHARS = 160;
27
+
28
+ function clipCause(text) {
29
+ const cause = String(text || '').replace(/\s+/g, ' ').trim();
30
+ if (!cause) return 'unknown';
31
+ return cause.length > CAUSE_MAX_CHARS ? `${cause.slice(0, CAUSE_MAX_CHARS - 1)}…` : cause;
32
+ }
33
+
34
+ function defaultFormatTime(iso) {
35
+ const ms = Date.parse(String(iso || ''));
36
+ if (!Number.isFinite(ms)) return 'now';
37
+ return `${new Date(ms).toISOString().slice(11, 16)} UTC`;
38
+ }
39
+
40
+ function minutesOf(ms) {
41
+ const minutes = Math.max(1, Math.round(Number(ms || 0) / 60_000));
42
+ return `${minutes} minute${minutes === 1 ? '' : 's'}`;
43
+ }
44
+
45
+ /** The text of the note for an episode state; exported for the tests and the client copy. */
46
+ export function buildWorkerLaunchNoteText({ type, lifecycle = {}, retryEveryMs = 60_000, retryWindowMs = 10 * 60_000, formatTime = defaultFormatTime } = {}) {
47
+ const tries = Math.max(0, Number(lifecycle?.retryCount || 0));
48
+ const triesText = `${tries} ${tries === 1 ? 'try' : 'tries'}`;
49
+ const cause = clipCause(lifecycle?.lastError);
50
+ if (type === 'launched') {
51
+ return `✅ The worker for this conversation started at ${formatTime(lifecycle?.launchAt || new Date().toISOString())}. The queued message is being delivered.`;
52
+ }
53
+ if (type === 'stopped') {
54
+ return `⛔ The worker for this conversation could not be started; the relay stopped trying after ${minutesOf(retryWindowMs)} (${triesText}). Cause: ${cause}. Your message stays queued — press Retry once the cause is fixed.`;
55
+ }
56
+ const until = lifecycle?.exhaustedSince && retryWindowMs > 0
57
+ ? ` until ${formatTime(new Date(Date.parse(lifecycle.exhaustedSince) + retryWindowMs).toISOString())}`
58
+ : '';
59
+ const last = type === 'retry-failed' && lifecycle?.lastFailureAt ? `, last at ${formatTime(lifecycle.lastFailureAt)}` : '';
60
+ return `⚠️ The worker for this conversation could not be started (${triesText}${last}). Cause: ${cause}. Your message stays queued; the relay tries again every ${minutesOf(retryEveryMs)}${until}.`;
61
+ }
62
+
63
+ export function createWorkerLaunchNoteService({
64
+ db,
65
+ stmts,
66
+ emit,
67
+ resolveConversationId,
68
+ retryEveryMs = 60_000,
69
+ retryWindowMs = 10 * 60_000,
70
+ now = () => Date.now(),
71
+ formatTime = defaultFormatTime,
72
+ logger = console,
73
+ } = {}) {
74
+ if (!db || !stmts) throw new Error('createWorkerLaunchNoteService requires db and stmts');
75
+ const updateNote = db.prepare('UPDATE messages SET text = ?, kind = ? WHERE id = ?');
76
+ // One open episode per session: the note's id and where it is.
77
+ const episodes = new Map();
78
+
79
+ function kindFor(type) {
80
+ if (type === 'launched') return WORKER_LAUNCH_NOTE_KINDS.started;
81
+ if (type === 'stopped') return WORKER_LAUNCH_NOTE_KINDS.stopped;
82
+ return WORKER_LAUNCH_NOTE_KINDS.failed;
83
+ }
84
+
85
+ function conversationFor(sdkSessionId) {
86
+ try {
87
+ return String(resolveConversationId?.(sdkSessionId) || '').trim() || null;
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ function messagePayload({ messageId, text, kind, timestamp }) {
94
+ return { id: messageId, role: 'assistant', text, kind, model: null, mode: null, attachments: [], timestamp };
95
+ }
96
+
97
+ function open({ sdkSessionId, conversationId, type, lifecycle }) {
98
+ const at = new Date(now()).toISOString();
99
+ const messageId = randomUUID();
100
+ const kind = kindFor(type);
101
+ const text = buildWorkerLaunchNoteText({ type, lifecycle, retryEveryMs, retryWindowMs, formatTime });
102
+ const write = db.transaction(() => {
103
+ stmts.insertMsg.run(messageId, conversationId, 'assistant', text, null, null, null, at, null, null, null);
104
+ stmts.setMessageKind?.run?.(kind, messageId);
105
+ stmts.updateConvTime?.run?.(at, conversationId);
106
+ });
107
+ write();
108
+ episodes.set(sdkSessionId, { messageId, conversationId, timestamp: at });
109
+ try { emit('assistant_message', { conversationId, messageId, message: messagePayload({ messageId, text, kind, timestamp: at }) }); } catch {}
110
+ try { logger.log?.(`[worker-launch] conv=${conversationId.slice(0, 8)} note opened (${type}): ${clipCause(lifecycle?.lastError)}`); } catch {}
111
+ }
112
+
113
+ function update(episode, sdkSessionId, { type, lifecycle }) {
114
+ const kind = kindFor(type);
115
+ const text = buildWorkerLaunchNoteText({ type, lifecycle, retryEveryMs, retryWindowMs, formatTime });
116
+ updateNote.run(text, kind, episode.messageId);
117
+ try {
118
+ emit('message_updated', {
119
+ conversationId: episode.conversationId,
120
+ messageId: episode.messageId,
121
+ message: messagePayload({ messageId: episode.messageId, text, kind, timestamp: episode.timestamp }),
122
+ });
123
+ } catch {}
124
+ if (type === 'launched') {
125
+ episodes.delete(sdkSessionId);
126
+ try { logger.log?.(`[worker-launch] conv=${episode.conversationId.slice(0, 8)} worker started, note closed`); } catch {}
127
+ }
128
+ }
129
+
130
+ /** The supervisor's onLaunchState listener. */
131
+ function handle({ type, sdkSessionId, lifecycle = null } = {}) {
132
+ const sid = String(sdkSessionId || '').trim();
133
+ if (!sid || !['exhausted', 'retry-failed', 'stopped', 'launched'].includes(type)) return false;
134
+ const episode = episodes.get(sid) || null;
135
+ if (episode) {
136
+ update(episode, sid, { type, lifecycle });
137
+ return true;
138
+ }
139
+ // A launch that works without an open note is the ordinary case.
140
+ if (type === 'launched') return false;
141
+ const conversationId = conversationFor(sid);
142
+ if (!conversationId) return false;
143
+ open({ sdkSessionId: sid, conversationId, type, lifecycle });
144
+ return true;
145
+ }
146
+
147
+ return {
148
+ handle,
149
+ openEpisode: (sdkSessionId) => episodes.get(String(sdkSessionId || '').trim()) || null,
150
+ };
151
+ }
@@ -0,0 +1,117 @@
1
+ // Commit and pull-request attribution for Claude sessions.
2
+ //
3
+ // Claude Code appends its own trailer to every commit an agent makes
4
+ // ("Co-Authored-By: Claude … <noreply@anthropic.com>") and a line to every PR
5
+ // body. The CLI lets a settings object replace both (`Settings.attribution`,
6
+ // SDK 0.3.x), and OAR uses that so commits made through the relay carry one
7
+ // identity whatever model wrote them:
8
+ //
9
+ // Co-authored-by: Open Agent Relay (Claude Fable 5.1) <no-reply@oar.sh>
10
+ //
11
+ // Three modes: `oar` (the default), `vanilla` (the CLI's own attribution),
12
+ // `off` (none). The relay resolves the mode (provider setting, later a
13
+ // per-folder override) and the model label, and hands the finished settings
14
+ // object to the worker with each delivery; the worker applies it at spawn and
15
+ // when it changes. Shared by both sides so they build the same object.
16
+
17
+ export const CLAUDE_ATTRIBUTION_MODES = Object.freeze(['oar', 'vanilla', 'off']);
18
+ export const DEFAULT_CLAUDE_ATTRIBUTION_MODE = 'oar';
19
+
20
+ export const OAR_ATTRIBUTION_NAME = 'Open Agent Relay';
21
+ export const OAR_ATTRIBUTION_EMAIL = 'no-reply@oar.sh';
22
+ export const OAR_PR_ATTRIBUTION = '🤖 Generated with [Open Agent Relay](https://oar.sh)';
23
+
24
+ /** A mode string, or null for anything that is not one. */
25
+ export function normalizeClaudeAttributionMode(value) {
26
+ const mode = String(value ?? '').trim().toLowerCase();
27
+ return CLAUDE_ATTRIBUTION_MODES.includes(mode) ? mode : null;
28
+ }
29
+
30
+ /** The provider mode unless a folder override says otherwise; never null. */
31
+ export function resolveClaudeAttributionMode({ providerMode = null, folderMode = null } = {}) {
32
+ return normalizeClaudeAttributionMode(folderMode)
33
+ || normalizeClaudeAttributionMode(providerMode)
34
+ || DEFAULT_CLAUDE_ATTRIBUTION_MODE;
35
+ }
36
+
37
+ const FAMILY_LABELS = Object.freeze({
38
+ fable: 'Fable',
39
+ opus: 'Opus',
40
+ sonnet: 'Sonnet',
41
+ haiku: 'Haiku',
42
+ mythos: 'Mythos',
43
+ });
44
+
45
+ /**
46
+ * "claude-fable-5-1[1m]" → "Claude Fable 5.1"; "claude-haiku-4-5-20251001" →
47
+ * "Claude Haiku 4.5". The context-tier suffix and a date stamp are not part
48
+ * of the model's name. An id the pattern does not know is used as it is.
49
+ */
50
+ export function claudeAttributionModelLabel(modelId) {
51
+ const raw = String(modelId || '').trim();
52
+ if (!raw) return 'Claude';
53
+ const id = raw.replace(/\[[^\]]*\]\s*$/, '').trim();
54
+ const parts = id.toLowerCase().split('-').filter(Boolean);
55
+ if (parts[0] !== 'claude' || parts.length < 2) return id || raw;
56
+ const family = FAMILY_LABELS[parts[1]];
57
+ if (!family) return id;
58
+ const version = parts.slice(2)
59
+ .filter((part) => /^\d+$/.test(part) && part.length < 8)
60
+ .join('.');
61
+ return version ? `Claude ${family} ${version}` : `Claude ${family}`;
62
+ }
63
+
64
+ export function claudeAttributionTrailer(modelLabel) {
65
+ const label = String(modelLabel || '').trim() || 'Claude';
66
+ return `Co-authored-by: ${OAR_ATTRIBUTION_NAME} (${label}) <${OAR_ATTRIBUTION_EMAIL}>`;
67
+ }
68
+
69
+ /**
70
+ * The `Settings.attribution` object for a mode and model: the OAR identity,
71
+ * nothing at all (`off`), or null for `vanilla` (the key is left to the CLI).
72
+ * Always the object form: older CLIs reject `true`/`false` here.
73
+ */
74
+ export function buildClaudeAttributionSettings({ mode, modelId = '', modelLabel = '' } = {}) {
75
+ const resolved = normalizeClaudeAttributionMode(mode) || DEFAULT_CLAUDE_ATTRIBUTION_MODE;
76
+ if (resolved === 'vanilla') return null;
77
+ if (resolved === 'off') return { commit: '', pr: '', sessionUrl: false };
78
+ const label = String(modelLabel || '').trim() || claudeAttributionModelLabel(modelId);
79
+ return {
80
+ commit: claudeAttributionTrailer(label),
81
+ pr: OAR_PR_ATTRIBUTION,
82
+ // A relay session link in a public history: never.
83
+ sessionUrl: false,
84
+ };
85
+ }
86
+
87
+ function normalizeAttributionObject(value) {
88
+ if (value === null) return null;
89
+ if (!value || typeof value !== 'object') return undefined;
90
+ return {
91
+ commit: String(value.commit ?? ''),
92
+ pr: String(value.pr ?? ''),
93
+ sessionUrl: value.sessionUrl === true,
94
+ };
95
+ }
96
+
97
+ /**
98
+ * What a delivery's `settings` does to the worker's attribution — the twin
99
+ * of resolveDeliveredThinking: an absent key (an older relay) keeps the last
100
+ * known value; a present key replaces it, and null means vanilla.
101
+ */
102
+ export function resolveDeliveredAttribution(current, settings) {
103
+ const base = normalizeAttributionObject(current);
104
+ const kept = base === undefined ? null : base;
105
+ if (!settings || typeof settings !== 'object') return kept;
106
+ if (!Object.prototype.hasOwnProperty.call(settings, 'attribution')) return kept;
107
+ const next = normalizeAttributionObject(settings.attribution);
108
+ return next === undefined ? kept : next;
109
+ }
110
+
111
+ /** Two attribution values mean the same thing (both vanilla, or equal fields). */
112
+ export function sameClaudeAttribution(left, right) {
113
+ const a = normalizeAttributionObject(left) ?? null;
114
+ const b = normalizeAttributionObject(right) ?? null;
115
+ if (a === null || b === null) return a === b;
116
+ return a.commit === b.commit && a.pr === b.pr && a.sessionUrl === b.sessionUrl;
117
+ }