@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
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env node
2
+ // Claude Cloud session worker: a per-conversation Node process that speaks
3
+ // the same relay contracts as the Claude/Cursor/Grok workers, but runs no
4
+ // agent itself. The agent and its clone live in a Claude Code cloud session;
5
+ // this process sends the user's messages there and follows the session's
6
+ // event stream (claude-cloud-session-process.mjs).
7
+ import path from 'path';
8
+ import { fileURLToPath } from 'url';
9
+
10
+ import {
11
+ loadTokenFromConfig,
12
+ resolveRelayServerUrl,
13
+ } from '../../shared/worker-runtime/config-loader.mjs';
14
+ import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
15
+ import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
16
+ import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
17
+ import { installWorkerLogFile } from '../../shared/worker-runtime/worker-log-file.mjs';
18
+ import { createControlPoller } from '../../shared/control-poller.mjs';
19
+ import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
20
+ import { createClaudeCloudCredentials } from '../../shared/claude-cloud/credentials.mjs';
21
+ import { createClaudeCloudClient } from '../../shared/claude-cloud/api-client.mjs';
22
+ import { resolveClaudeCloudBaseUrl } from '../../shared/claude-cloud/base-url.mjs';
23
+ import { createClaudeCloudSessionRunner } from './claude-cloud-session-process.mjs';
24
+
25
+ // Windows console launch: the launcher names the worker log, this copies
26
+ // stdout/stderr into it. A no-op wherever the launcher redirects instead.
27
+ installWorkerLogFile();
28
+
29
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
30
+ const HEARTBEAT_MS = 10_000;
31
+
32
+ function parseSessionIdArg(argv = process.argv) {
33
+ const index = argv.indexOf('--session-id');
34
+ if (index !== -1 && argv[index + 1]) return String(argv[index + 1]).trim();
35
+ const inline = argv.find((arg) => String(arg || '').startsWith('--session-id='));
36
+ if (inline) return String(inline.split('=')[1] || '').trim();
37
+ return String(process.env.SESSION_ID || '').trim();
38
+ }
39
+
40
+ function dbg(...parts) {
41
+ const timestamp = new Date().toISOString();
42
+ console.log(`[claude-cloud-worker ${timestamp}]`, ...parts);
43
+ }
44
+
45
+ async function main() {
46
+ const sdkSessionId = parseSessionIdArg();
47
+ if (!sdkSessionId) {
48
+ console.error('claude-cloud-session-worker: missing --session-id');
49
+ process.exit(2);
50
+ }
51
+
52
+ const configPath = String(process.env.COPILOT_WEB_RELAY_CONFIG || '').trim()
53
+ || path.resolve(__dirname, '..', 'config.json');
54
+ const serverUrl = resolveRelayServerUrl({ configPath });
55
+ const token = loadTokenFromConfig(configPath);
56
+ const defaultModel = String(process.env.CLAUDE_CLOUD_RELAY_MODEL || '').trim();
57
+
58
+ const api = createApiClient({
59
+ serverUrl,
60
+ token,
61
+ getHeaders: () => ({
62
+ 'X-Relay-Process-Pid': String(process.pid),
63
+ 'X-Relay-Parent-Pid': String(process.ppid),
64
+ 'X-Relay-Session-Id': sdkSessionId,
65
+ 'X-Relay-Conversation-Id': sdkSessionId,
66
+ }),
67
+ });
68
+
69
+ // The Claude CLI's login, read by the client's own module (the credentials
70
+ // file, or CLAUDE_CODE_OAUTH_TOKEN where the relay runs on one): this
71
+ // process never logs it. A login that has run out is nudged through the
72
+ // relay, which can run the CLI (`claude auth status` refreshes a login
73
+ // whose refresh token is still good); the file is read again afterwards.
74
+ // The base URL is the Anthropic API unless a test names a fake one on this
75
+ // machine (base-url.mjs takes nothing else).
76
+ const cloud = createClaudeCloudClient({
77
+ credentials: createClaudeCloudCredentials({
78
+ nudge: async () => {
79
+ const answer = await api('POST', '/api/claude-cloud/login-nudge', {});
80
+ dbg('login nudge', answer?.nudged ? 'ran' : 'skipped', answer?.expired ? 'still expired' : `expires ${answer?.expiresAt || '?'}`);
81
+ },
82
+ }),
83
+ baseUrl: resolveClaudeCloudBaseUrl(process.env),
84
+ });
85
+
86
+ const controlPoller = createControlPoller({
87
+ api,
88
+ sdkSessionId,
89
+ abortAckNote: 'cloud turn interrupted',
90
+ dbg,
91
+ });
92
+ const runner = createClaudeCloudSessionRunner({
93
+ api,
94
+ cloud,
95
+ sdkSessionId,
96
+ defaultModel,
97
+ controlPoller,
98
+ dbg,
99
+ });
100
+
101
+ let heartbeatTimer = null;
102
+ const heartbeat = createHeartbeatController({
103
+ api,
104
+ pollMs: HEARTBEAT_MS,
105
+ getSessionReady: () => true,
106
+ getHeartbeatTimer: () => heartbeatTimer,
107
+ setHeartbeatTimer: (timer) => { heartbeatTimer = timer; },
108
+ getActiveQueueMessageId: () => runner.getActiveQueueMessageId(),
109
+ });
110
+
111
+ const wsLink = createWorkerWebSocketLink({
112
+ serverUrl,
113
+ token,
114
+ dbg,
115
+ getSessionReady: () => true,
116
+ getSessionId: () => sdkSessionId,
117
+ getPid: () => process.pid,
118
+ onDeliver: async (pending, reason) => {
119
+ dbg('queue.deliver received', `reason=${reason}`, `msgId=${pending?.message?.id || 'none'}`);
120
+ try {
121
+ return await runner.handlePendingPayload(pending);
122
+ } catch (error) {
123
+ dbg('turn handling failed', error?.message || String(error));
124
+ return false;
125
+ }
126
+ },
127
+ });
128
+
129
+ const shutdown = async (signal) => {
130
+ dbg(`shutting down (${signal})`);
131
+ try { wsLink.stop(); } catch {}
132
+ try { heartbeat.stopHeartbeat(); } catch {}
133
+ try { controlPoller.stop(); } catch {}
134
+ // The cloud session is left as it is: a turn in flight keeps running
135
+ // there, and the next worker finds it through the stored sequence number.
136
+ try { await runner.dispose(); } catch {}
137
+ process.exit(0);
138
+ };
139
+ process.on('SIGTERM', () => { shutdown('SIGTERM'); });
140
+ process.on('SIGINT', () => { shutdown('SIGINT'); });
141
+ installWorkerCrashGuard({
142
+ api,
143
+ workerName: 'claude-cloud-session-worker',
144
+ getActiveQueueMessageIds: () => [runner.getActiveQueueAttempt()],
145
+ });
146
+
147
+ dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} model=${defaultModel || 'default'}`);
148
+ heartbeat.startHeartbeat();
149
+ wsLink.start();
150
+ }
151
+
152
+ main().catch((error) => {
153
+ console.error('claude-cloud-session-worker fatal:', error?.stack || error?.message || error);
154
+ process.exit(1);
155
+ });
@@ -62,6 +62,8 @@ export const REMOTE_RELAY_TOOL_ZOD_SHAPE = {
62
62
  provider: choice('provider', REMOTE_RELAY_PROVIDERS),
63
63
  model: text('model'),
64
64
  cwd: text('cwd'),
65
+ repo: text('repo'),
66
+ branch: text('branch'),
65
67
  mode: choice('mode', REMOTE_RELAY_MODES),
66
68
  effort: text('effort'),
67
69
  title: text('title'),
@@ -111,7 +111,7 @@ export function normalizeAutoCompactWindow(value) {
111
111
  * spreads of `settings` in the options literal would silently clobber each
112
112
  * other. Returns null when nothing needs to be set.
113
113
  */
114
- export function claudeSpawnSettings({ ultracode = false, autoCompactWindow = null, thinkingEnabled = null } = {}) {
114
+ export function claudeSpawnSettings({ ultracode = false, autoCompactWindow = null, thinkingEnabled = null, attribution = null } = {}) {
115
115
  const window = normalizeAutoCompactWindow(autoCompactWindow);
116
116
  const thinking = strictThinkingEnabled(thinkingEnabled);
117
117
  const settings = {
@@ -122,10 +122,25 @@ export function claudeSpawnSettings({ ultracode = false, autoCompactWindow = nul
122
122
  // present; the omit path exists for direct callers that mean to say
123
123
  // nothing.
124
124
  ...(thinking !== null ? { alwaysThinkingEnabled: thinking } : {}),
125
+ // What commits and PRs say (shared/claude-attribution.mjs). null is
126
+ // vanilla: the key is left out and the CLI uses its own.
127
+ ...(attribution && typeof attribution === 'object' ? { attribution } : {}),
125
128
  };
126
129
  return Object.keys(settings).length ? settings : null;
127
130
  }
128
131
 
132
+ /**
133
+ * Live twin of the attribution spawn setting, for a value (OAR or off). A
134
+ * running CLI takes a new text through the flag layer (it arrives as a fresh
135
+ * system-reminder on the next user turn) but cannot UNSET a value it was
136
+ * spawned with — `{}`, `null` and `true` all leave the spawn-time text in
137
+ * force (wire-logged probe, 2026-10-01). Back to vanilla therefore means a
138
+ * fresh process without the key; the session runner does that.
139
+ */
140
+ export function claudeAttributionFlagSettings(attribution) {
141
+ return { attribution: attribution && typeof attribution === 'object' ? attribution : { commit: '', pr: '', sessionUrl: false } };
142
+ }
143
+
129
144
  export function permissionModeForRelayMode(relayMode) {
130
145
  const mode = String(relayMode || 'agent').trim().toLowerCase();
131
146
  if (mode === 'plan') return 'plan';
@@ -228,6 +243,7 @@ export function startClaudeSession({
228
243
  reasoningEffort = '',
229
244
  autoCompactWindow = null,
230
245
  thinkingEnabled = null,
246
+ attribution = null,
231
247
  thinkingDisplay = '',
232
248
  abortController,
233
249
  canUseTool,
@@ -248,7 +264,7 @@ export function startClaudeSession({
248
264
  // only way to hand a fresh CLI the session-scoped ultracode flag, the
249
265
  // auto-compact window and the thinking pin, and all three share one
250
266
  // `settings` object.
251
- const spawnSettings = claudeSpawnSettings({ ultracode, autoCompactWindow, thinkingEnabled });
267
+ const spawnSettings = claudeSpawnSettings({ ultracode, autoCompactWindow, thinkingEnabled, attribution });
252
268
  // Relay tools need the worker's authenticated API helper; a caller that
253
269
  // passes none (tests, probes) gets a session without them.
254
270
  const relayMcpServer = typeof api === 'function'
@@ -21,12 +21,14 @@ import {
21
21
  normalizeClaudeEffort,
22
22
  claudeUltracodeFlagSettings,
23
23
  claudeAutoCompactFlagSettings,
24
+ claudeAttributionFlagSettings,
24
25
  claudeThinkingFlagSettings,
25
26
  applyThinkingDisplay,
26
27
  normalizeAutoCompactWindow,
27
28
  permissionModeForRelayMode,
28
29
  } from './claude-sdk-adapter.mjs';
29
30
  import { parseThinkingDisplay, parseThinkingEnabled } from '../../shared/claude-thinking.mjs';
31
+ import { sameClaudeAttribution } from '../../shared/claude-attribution.mjs';
30
32
  import { relocateClaudeTranscriptForCwd } from './claude-transcript-relocator.mjs';
31
33
  import {
32
34
  attemptFields,
@@ -37,6 +39,7 @@ import {
37
39
  import { createAskUserBridge } from '../../shared/ask-user-bridge.mjs';
38
40
  import { createRemoteRelayToolGate } from '../../shared/remote-relay-tool-core.mjs';
39
41
  import { buildRelayStopFailure } from '../../shared/relay-stop-failure.mjs';
42
+ import { createTurnLiveness, resolveTurnStallWindows } from '../../shared/worker-runtime/turn-liveness.mjs';
40
43
  import {
41
44
  ANSWERED_ELSEWHERE_KIND,
42
45
  DELIVERY_ANSWERED_ELSEWHERE_TEXT,
@@ -49,6 +52,7 @@ import {
49
52
  stripSlashCommandGuardContent,
50
53
  withSlashCommandGuardContent,
51
54
  } from '../../shared/slash-command-guard.mjs';
55
+ import { createUsageLimitTracker } from '../../shared/claude-usage-limit.mjs';
52
56
 
53
57
  /**
54
58
  * Which system-prompt append a relay mode gets (claude-sdk-adapter's
@@ -446,11 +450,19 @@ export function createClaudeSessionRunner({
446
450
  // per turn rather than captured, so a slider change picked up on the next
447
451
  // delivery reaches a process that is already running.
448
452
  getAutoCompactWindow = () => null,
453
+ // What this session's commits say (shared/claude-attribution.mjs); null
454
+ // leaves the CLI's own attribution. Piggybacked on deliveries like the
455
+ // window above.
456
+ getAttribution = () => null,
449
457
  // The per-conversation thinking state ({enabled, display}); read per turn
450
458
  // like the window, so a settings change reaches a running process on its
451
459
  // next delivery.
452
460
  getThinking = () => ({ enabled: null, display: '' }),
453
461
  lifecyclePollMs = 5_000,
462
+ // How long a RUNNING turn may go without a message from the CLI before
463
+ // that is written to the log. Nothing is failed over it: see
464
+ // `noteQuietTurn`. `{ model, tool }` in ms; 0 for either turns it off.
465
+ quietTurnWindowsMs = undefined,
454
466
  // A settled task's continuation normally begins within ~1s; when nothing
455
467
  // arrives inside this window the notification was silent (skip_transcript)
456
468
  // and must stop pinning the process.
@@ -546,6 +558,9 @@ export function createClaudeSessionRunner({
546
558
  // fixed per process, and the first delivery can beat the startup GET.
547
559
  const remoteRelayGate = remoteRelayToolGate || createRemoteRelayToolGate({ api });
548
560
  void remoteRelayGate.refresh();
561
+ // The subscription's usage limit as the CLI reports it. Runner-level: the
562
+ // limit is the account's, not the CLI process's.
563
+ const usageLimit = createUsageLimitTracker();
549
564
  const resolveWorkflowSessionDir = resolveWorkflowSessionDirImpl || createDefaultWorkflowSessionDirResolver();
550
565
  let claudeNativeSessionId = '';
551
566
  let proc = null;
@@ -2475,6 +2490,90 @@ export function createClaudeSessionRunner({
2475
2490
  return 'swallow';
2476
2491
  }
2477
2492
 
2493
+ // ---------------------------------------------------------------------------
2494
+ // A running turn that went quiet
2495
+ //
2496
+ // The other workers fail a turn whose runtime has been silent for too long
2497
+ // (shared/worker-runtime/turn-liveness.mjs). This one only WRITES IT DOWN.
2498
+ // What the CLI sends is known (SDK 0.3.283, measured 2026-09-28): a
2499
+ // `tool_progress` heartbeat every 30 s while a tool runs in silence, for the
2500
+ // Agent call as a whole when the tool runs inside a subagent; a
2501
+ // `thinking_tokens` or stream frame about every second while the model
2502
+ // works. What is not known is how long its silent stretches get in real
2503
+ // sessions: a compaction (133 s seen), the backoff of an API retry, a
2504
+ // request it is waiting on. Failing a turn here also means killing the CLI
2505
+ // and its background tasks. So the windows are generous, the known silent
2506
+ // stretches are holds, and a turn that outlasts them is logged with what
2507
+ // the CLI was doing — the measurements a failing watchdog would need.
2508
+
2509
+ const quietWindows = (() => {
2510
+ const windows = resolveTurnStallWindows({
2511
+ // The CLI gives up on a silent model stream by itself after 300 s and
2512
+ // says so (`api_retry`); a window of the same length would race it.
2513
+ modelMs: quietTurnWindowsMs?.model ?? process.env.OAR_TURN_STALL_MODEL_MS ?? 600_000,
2514
+ toolMs: quietTurnWindowsMs?.tool,
2515
+ });
2516
+ return { idle: 0, model: windows.model, tool: windows.tool };
2517
+ })();
2518
+
2519
+ function observeQuietTurn(sdkMessage) {
2520
+ const liveness = proc?.liveness;
2521
+ if (!liveness) return;
2522
+ const type = String(sdkMessage?.type || '');
2523
+ const agent = String(sdkMessage?.parent_tool_use_id || '').trim() || 'root';
2524
+ if (proc.quietLoggedAt) {
2525
+ dbg(`the CLI spoke again after ${Math.round(liveness.quietMs() / 1000)}s of silence`, type);
2526
+ proc.quietLoggedAt = 0;
2527
+ }
2528
+ liveness.traffic(type === 'system' ? `system/${sdkMessage?.subtype || ''}` : type);
2529
+ const blocks = Array.isArray(sdkMessage?.message?.content) ? sdkMessage.message.content : [];
2530
+ if (type === 'assistant') {
2531
+ for (const block of blocks) {
2532
+ if (block?.type === 'tool_use' && block.id) liveness.begin('tool', `${agent}:${block.id}`, block.name);
2533
+ }
2534
+ } else if (type === 'user') {
2535
+ for (const block of blocks) {
2536
+ if (block?.type !== 'tool_result' || !block.tool_use_id) continue;
2537
+ liveness.end('tool', `${agent}:${block.tool_use_id}`);
2538
+ // The result of an Agent call: whatever that agent had open ended with it.
2539
+ liveness.endAgent(String(block.tool_use_id));
2540
+ }
2541
+ } else if (type === 'stream_event') {
2542
+ const event = String(sdkMessage?.event?.type || '');
2543
+ if (event === 'message_start') liveness.begin('model', agent);
2544
+ else if (event === 'message_stop') liveness.end('model', agent);
2545
+ } else if (type === 'result') {
2546
+ liveness.reset();
2547
+ }
2548
+ }
2549
+
2550
+ /**
2551
+ * Log a running turn the CLI has said nothing in for longer than its phase
2552
+ * allows. Once per silence, and never while the silence has a known reason.
2553
+ */
2554
+ function noteQuietTurn() {
2555
+ const liveness = proc?.liveness;
2556
+ const ctx = proc?.activeCtx;
2557
+ if (!liveness || !ctx || ctx.finalized || proc.closing || proc.quietLoggedAt) return;
2558
+ // A human is deciding, a compaction is running, the relay is slow to take
2559
+ // a publish (the stream is not read meanwhile), or the turn is being
2560
+ // stopped: silence with a reason.
2561
+ if (proc.pendingControlRequests > 0 || isCompacting() || proc.dispatching > 0 || ctx.interrupted) {
2562
+ liveness.traffic('held');
2563
+ return;
2564
+ }
2565
+ // Inside a turn the CLI is waiting for the model unless a tool is open.
2566
+ const phase = liveness.phase() === 'tool' ? 'tool' : 'model';
2567
+ const limitMs = quietWindows[phase];
2568
+ if (!(limitMs > 0) || liveness.quietMs() < limitMs) return;
2569
+ proc.quietLoggedAt = Date.now();
2570
+ dbg(
2571
+ `running turn quiet for ${Math.round(liveness.quietMs() / 1000)}s; left running (the quiet-turn watchdog only observes)`,
2572
+ ctx.message?.id || '(continuation)',
2573
+ liveness.describe(),
2574
+ );
2575
+ }
2576
+
2478
2577
  async function onSdkMessage(processRef, sdkMessage) {
2479
2578
  // A superseded process (push-race respawn, mode-recycle timeout) may still
2480
2579
  // be draining its stream; its late messages must not mutate the state of
@@ -2482,6 +2581,21 @@ export function createClaudeSessionRunner({
2482
2581
  // cleanupProcess when the old stream ends.
2483
2582
  if (!proc || proc !== processRef) return;
2484
2583
  proc.lastEventAt = Date.now();
2584
+ observeQuietTurn(sdkMessage);
2585
+ // Counted for as long as this message is being handled: the stream is
2586
+ // not read meanwhile, so the silence is this worker's, not the CLI's.
2587
+ processRef.dispatching += 1;
2588
+ try {
2589
+ await handleSdkMessage(sdkMessage);
2590
+ } finally {
2591
+ processRef.dispatching -= 1;
2592
+ if (proc === processRef) proc.liveness.traffic('handled');
2593
+ }
2594
+ }
2595
+
2596
+ async function handleSdkMessage(sdkMessage) {
2597
+ const usageLimitReport = usageLimit.observe(sdkMessage);
2598
+ if (usageLimitReport) publisher.publishUsageLimitReport({ conversationId: sdkSessionId, sdkSessionId, report: usageLimitReport });
2485
2599
  const suppressed = maybeSuppressStopFollowUp(sdkMessage);
2486
2600
  if (suppressed === 'swallow') return;
2487
2601
  observeProcessLevel(sdkMessage);
@@ -2502,6 +2616,8 @@ export function createClaudeSessionRunner({
2502
2616
  if (!ctx) return;
2503
2617
  const actions = ctx.normalizer.normalize(sdkMessage);
2504
2618
  for (const action of actions) {
2619
+ // Decided here, while the reports the turn ran under are the latest.
2620
+ if (action.channel === 'result') action.payload.usageLimit = usageLimit.classifyResult(sdkMessage);
2505
2621
  await dispatchToContext(ctx, action);
2506
2622
  }
2507
2623
  }
@@ -2917,6 +3033,7 @@ export function createClaudeSessionRunner({
2917
3033
  if (proc.stopFollowUp && !proc.suppressingTurn && !proc.pendingDelivered.some((entry) => entry.stoppedWithTurn)) {
2918
3034
  proc.stopFollowUp = null;
2919
3035
  }
3036
+ noteQuietTurn();
2920
3037
  reapSilentProvisionalAdoption(now);
2921
3038
  // Before the 5-minute watchdog: a folded steered entry settles as merged
2922
3039
  // within the short fold grace instead of failing over.
@@ -3030,6 +3147,7 @@ export function createClaudeSessionRunner({
3030
3147
  await publisher.publishTurnException({
3031
3148
  message: ctx.message,
3032
3149
  errorText: String(streamError?.message || streamError || 'unknown error'),
3150
+ usageLimit: usageLimit.classifyException(streamError),
3033
3151
  }).catch(() => {});
3034
3152
  }
3035
3153
  continue;
@@ -3058,6 +3176,7 @@ export function createClaudeSessionRunner({
3058
3176
  const thinkingState = getThinking() || {};
3059
3177
  const thinkingEnabled = parseThinkingEnabled(thinkingState.enabled);
3060
3178
  const thinkingDisplay = parseThinkingDisplay(thinkingState.display);
3179
+ const attribution = getAttribution() || null;
3061
3180
  const abortController = new AbortController();
3062
3181
  const processRef = {
3063
3182
  turn: null,
@@ -3066,6 +3185,7 @@ export function createClaudeSessionRunner({
3066
3185
  relayMode,
3067
3186
  effort,
3068
3187
  autoCompactWindow,
3188
+ attribution,
3069
3189
  // Seeded from what the process is SPAWNED with, so the adapt diff is
3070
3190
  // against reality rather than against a delivered setting the CLI may
3071
3191
  // never have honored.
@@ -3132,6 +3252,11 @@ export function createClaudeSessionRunner({
3132
3252
  settleSeqAtContinuationOpen: 0,
3133
3253
  pendingControlRequests: 0,
3134
3254
  lastEventAt: Date.now(),
3255
+ // The quiet-turn log (see noteQuietTurn): what the CLI has in flight,
3256
+ // messages being handled right now, and when the silence was logged.
3257
+ liveness: createTurnLiveness({ windows: quietWindows }),
3258
+ dispatching: 0,
3259
+ quietLoggedAt: 0,
3135
3260
  heldForTasksSince: 0,
3136
3261
  taskSettledAt: 0,
3137
3262
  // Bumped by every task-settle signal; the Stop guard compares it.
@@ -3218,6 +3343,7 @@ export function createClaudeSessionRunner({
3218
3343
  autoCompactWindow,
3219
3344
  thinkingEnabled,
3220
3345
  thinkingDisplay,
3346
+ attribution,
3221
3347
  abortController,
3222
3348
  canUseTool,
3223
3349
  pathToClaudeCodeExecutable,
@@ -3326,6 +3452,30 @@ export function createClaudeSessionRunner({
3326
3452
  });
3327
3453
  proc.autoCompactWindow = autoCompactWindow;
3328
3454
  }
3455
+ // Attribution follows the window's pattern: a changed value (a settings
3456
+ // change, a model switch that renames the trailer) reaches the live CLI
3457
+ // through the flag layer, before the turn that may commit.
3458
+ const attribution = getAttribution() || null;
3459
+ if (!sameClaudeAttribution(attribution, proc.attribution)) {
3460
+ if (attribution === null) {
3461
+ // Back to the CLI's own attribution. A value the process was spawned
3462
+ // with cannot be unset through the flag layer (probe 2026-10-01), so
3463
+ // the honest way is a fresh process without the key — taken when
3464
+ // nothing lives in the old one; a busy process keeps the old text and
3465
+ // the next delivery tries again.
3466
+ if (!hasLiveWork() && !proc.liveTasks.size) {
3467
+ gracefulShutdown('attribution-vanilla');
3468
+ await drainClosingProcess(10_000);
3469
+ return null;
3470
+ }
3471
+ } else {
3472
+ expectSettingsReinit();
3473
+ await Promise.resolve(proc.turn.applyFlagSettings?.(claudeAttributionFlagSettings(attribution))).catch((error) => {
3474
+ dbg('applyFlagSettings attribution failed', error?.message || String(error));
3475
+ });
3476
+ proc.attribution = attribution;
3477
+ }
3478
+ }
3329
3479
  // Thinking is NOT a symmetric copy of the window block: the probe
3330
3480
  // (2026-08-26, docs/plans/claude-thinking-control.md) showed the two
3331
3481
  // directions of the on/off axis are not equally reachable mid-session.
@@ -3631,7 +3781,7 @@ export function createClaudeSessionRunner({
3631
3781
  settingsWindowOpen = false;
3632
3782
  const errorText = String(error?.message || error || 'unknown error');
3633
3783
  dbg('claude turn failed', message.id, errorText);
3634
- await publisher.publishTurnException({ message, errorText });
3784
+ await publisher.publishTurnException({ message, errorText, usageLimit: usageLimit.classifyException(error) });
3635
3785
  return true;
3636
3786
  } finally {
3637
3787
  endAdmission();
@@ -17,6 +17,7 @@ import { installWorkerLogFile } from '../../shared/worker-runtime/worker-log-fil
17
17
  import { createControlPoller } from '../../shared/control-poller.mjs';
18
18
  import { resolveDeliveredAutoCompactWindow } from '../../shared/auto-compact-window.mjs';
19
19
  import { resolveDeliveredThinking } from '../../shared/claude-thinking.mjs';
20
+ import { resolveDeliveredAttribution } from '../../shared/claude-attribution.mjs';
20
21
  import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
21
22
  import { createClaudeSessionRunner } from './claude-session-process.mjs';
22
23
  import { createRunnerLinkBridge, failSettlingRowsOnShutdown } from './claude-worker-link-wiring.mjs';
@@ -66,6 +67,9 @@ async function main() {
66
67
  // piggyback. enabled null = host default; display defaults to 'summarized'
67
68
  // (today's behavior — visible thought bubbles).
68
69
  let thinking = resolveDeliveredThinking(null, null);
70
+ // What this session's commits and PRs say; same piggyback. null = the
71
+ // CLI's own attribution (vanilla).
72
+ let attribution = null;
69
73
 
70
74
  const api = createApiClient({
71
75
  serverUrl,
@@ -111,6 +115,7 @@ async function main() {
111
115
  getBackgroundTaskTimeoutMs: () => backgroundTaskTimeoutMs,
112
116
  getAutoCompactWindow: () => autoCompactWindow,
113
117
  getThinking: () => thinking,
118
+ getAttribution: () => attribution,
114
119
  // A readiness flip is a hold starting or ending (question card,
115
120
  // compaction, a turn-opening delivery): the steering snapshot changed
116
121
  // with it, so it goes out now rather than on the next 10 s heartbeat.
@@ -176,6 +181,7 @@ async function main() {
176
181
  // keep the last known state; an explicit null on thinkingEnabled is the
177
182
  // user choosing Host default.
178
183
  thinking = resolveDeliveredThinking(thinking, pending?.settings);
184
+ attribution = resolveDeliveredAttribution(attribution, pending?.settings);
179
185
  try {
180
186
  return await turnRunner.handlePendingPayload(pending);
181
187
  } catch (error) {
@@ -0,0 +1,92 @@
1
+ // Why a Claude turn failed: the code the relay prints, and the advice that
2
+ // fits the failure.
3
+ //
4
+ // A turn the API refused ends with a `result` of subtype 'success' and
5
+ // `is_error: true`: the subtype says how the turn ended, not whether it
6
+ // worked, so it names nothing. What names the failure, in this order:
7
+ //
8
+ // 1. the `error` field of the turn's last assistant message (the SDK's
9
+ // SDKAssistantMessageError: 'billing_error', 'oauth_org_not_allowed', …);
10
+ // 2. the `api_error_status` of the result;
11
+ // 3. the words of the refusal, for an account failure that came with
12
+ // neither;
13
+ // 4. a subtype other than 'success' ('error_max_turns', …);
14
+ // 5. the `terminal_reason` of the result.
15
+ //
16
+ // The relay prints `claude.<code>` as `relay.claude-<code>`, dashes for
17
+ // underscores.
18
+
19
+ // The user has to sign in again: a retry cannot succeed before that.
20
+ const SIGN_IN_ERRORS = new Set(['authentication_failed']);
21
+ // The account itself refuses: billing, or access its organisation withholds.
22
+ const ACCOUNT_ERRORS = new Set([
23
+ 'billing_error',
24
+ 'oauth_org_not_allowed',
25
+ 'account_on_hold',
26
+ 'verification_required',
27
+ 'account-access',
28
+ ]);
29
+
30
+ const STATUS_CODES = new Map([
31
+ [401, 'authentication_failed'],
32
+ [402, 'billing_error'],
33
+ [429, 'rate_limit'],
34
+ [529, 'overloaded'],
35
+ ]);
36
+
37
+ // Checked before the sign-in words: the organisation's refusal mentions an
38
+ // API key too.
39
+ const ACCOUNT_ACCESS_TEXT = /organi[sz]ation has disabled|subscription access|does not have access to claude/i;
40
+ const BILLING_TEXT = /credit balance|billing|payment/i;
41
+ const SIGN_IN_TEXT = /failed to authenticate|authentication_error|invalid api key|not logged in|please run \/login/i;
42
+
43
+ export const CLAUDE_SIGN_IN_GUIDANCE =
44
+ 'Sign in to Claude again in Settings → Providers → Claude → Relogin (or run `claude` on the relay host), '
45
+ + 'then send the message again.';
46
+ export const CLAUDE_ACCOUNT_GUIDANCE =
47
+ 'Check the Claude account the relay host is signed in to: its subscription, its payment method, and whether '
48
+ + 'its organisation allows Claude Code. Settings → Providers → Claude shows the account and signs in to '
49
+ + 'another one. Then send the message again.';
50
+ export const CLAUDE_MODEL_GUIDANCE = 'Choose another model for this conversation, then send the message again.';
51
+
52
+ function codeFromText(text) {
53
+ if (ACCOUNT_ACCESS_TEXT.test(text)) return 'account-access';
54
+ if (BILLING_TEXT.test(text)) return 'billing_error';
55
+ if (SIGN_IN_TEXT.test(text)) return 'authentication_failed';
56
+ return null;
57
+ }
58
+
59
+ /** The advice for a code, or null where the relay's own default fits. */
60
+ export function claudeFailureGuidance(code) {
61
+ const token = String(code || '').trim().toLowerCase();
62
+ if (SIGN_IN_ERRORS.has(token)) return CLAUDE_SIGN_IN_GUIDANCE;
63
+ if (ACCOUNT_ERRORS.has(token)) return CLAUDE_ACCOUNT_GUIDANCE;
64
+ if (token === 'model_not_found') return CLAUDE_MODEL_GUIDANCE;
65
+ return null;
66
+ }
67
+
68
+ /**
69
+ * `result` is the normalized result payload of the turn (sdk-message-
70
+ * normalizer): `{ text, subtype, assistantError, apiErrorStatus,
71
+ * terminalReason }`. Returns `{ code, stableCode, guidance }`; `guidance` is
72
+ * null where the relay's default fits.
73
+ */
74
+ export function classifyClaudeResultFailure(result = {}) {
75
+ const subtype = String(result?.subtype || '').trim();
76
+ const assistantError = String(result?.assistantError || '').trim().toLowerCase();
77
+ const status = Number(result?.apiErrorStatus);
78
+ const hasStatus = Number.isFinite(status) && status >= 400;
79
+ const terminalReason = String(result?.terminalReason || '').trim().toLowerCase();
80
+
81
+ let code = null;
82
+ if (assistantError && assistantError !== 'unknown') code = assistantError;
83
+ else if (hasStatus && STATUS_CODES.has(status)) code = STATUS_CODES.get(status);
84
+ else code = codeFromText(String(result?.text || ''));
85
+
86
+ if (!code && subtype && subtype !== 'success') code = subtype;
87
+ if (!code && (hasStatus || assistantError === 'unknown' || terminalReason === 'api_error')) code = 'api-error';
88
+ if (!code && terminalReason && terminalReason !== 'completed') code = terminalReason;
89
+ if (!code) code = 'turn-error';
90
+
91
+ return { code, stableCode: `claude.${code}`, guidance: claudeFailureGuidance(code) };
92
+ }