@oar-sh/oar 0.9.15 → 0.9.17

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 (109) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +14 -87
  2. package/CHANGELOG.md +305 -0
  3. package/README.md +12 -11
  4. package/package.json +2 -1
  5. package/server/claude-worker/claude-attachments.mjs +7 -4
  6. package/server/claude-worker/claude-remote-relay-tool.mjs +36 -1
  7. package/server/claude-worker/claude-sdk-adapter.mjs +167 -9
  8. package/server/claude-worker/claude-session-process.mjs +131 -33
  9. package/server/claude-worker/claude-session-worker-main.mjs +266 -0
  10. package/server/claude-worker/claude-session-worker.mjs +2 -253
  11. package/server/claude-worker/claude-turn-failure.mjs +8 -0
  12. package/server/claude-worker/claude-turn-publisher.mjs +282 -13
  13. package/server/claude-worker/claude-usage-totals.mjs +108 -0
  14. package/server/claude-worker/sdk-message-normalizer.mjs +55 -0
  15. package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
  16. package/server/copilot-worker/copilot-sdk-session-process.mjs +73 -7
  17. package/server/db-schema.mjs +115 -0
  18. package/server/deepseek-worker/deepseek-claude-backend.mjs +433 -0
  19. package/server/deepseek-worker/deepseek-session-worker.mjs +37 -0
  20. package/server/migrations/0019-relay-event-subscription-options.mjs +106 -0
  21. package/server/migrations/0020-conversation-may-create-sessions.mjs +67 -0
  22. package/server/migrations/0021-conversation-group-agent-marks.mjs +77 -0
  23. package/server/migrations/0022-relay-schedules.mjs +142 -0
  24. package/server/migrations/0023-relay-asks.mjs +141 -0
  25. package/server/migrations/0024-deepseek-usage-totals.mjs +151 -0
  26. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +179 -0
  27. package/server/migrations/0026-deepseek-usage-served-models.mjs +222 -0
  28. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +101 -0
  29. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +99 -0
  30. package/server/migrations/0029-deepseek-balance-rises.mjs +99 -0
  31. package/server/migrations/0030-deepseek-usage-actuals.mjs +123 -0
  32. package/server/public/app/api-client.js +53 -0
  33. package/server/public/app/ask-user-view.js +3 -0
  34. package/server/public/app/bootstrap.js +162 -16
  35. package/server/public/app/context-tier-options.mjs +4 -0
  36. package/server/public/app/context-usage-view.mjs +17 -0
  37. package/server/public/app/conversation-group-order.mjs +35 -0
  38. package/server/public/app/conversation-provider-indicator.mjs +7 -0
  39. package/server/public/app/conversation-view.js +2 -0
  40. package/server/public/app/deepseek-settings-ui.js +342 -0
  41. package/server/public/app/inbox-ui.js +367 -0
  42. package/server/public/app/inbox-view.mjs +370 -0
  43. package/server/public/app/journal-view.js +54 -5
  44. package/server/public/app/model-selector-options.mjs +11 -0
  45. package/server/public/app/model-vision-support.mjs +2 -0
  46. package/server/public/app/no-conversation-state.mjs +1 -1
  47. package/server/public/app/plan-usage-view.mjs +165 -13
  48. package/server/public/app/provider-readiness.mjs +4 -1
  49. package/server/public/app/relay-error-ctas.mjs +8 -0
  50. package/server/public/app/relay-event-subscriptions-ui.js +4 -2
  51. package/server/public/app/relay-event-view.mjs +33 -7
  52. package/server/public/app/relay-schedule-view.mjs +119 -0
  53. package/server/public/app/relay-schedules-ui.js +172 -0
  54. package/server/public/app/settings-modal.js +11 -0
  55. package/server/public/app/settings-tabs.js +1 -1
  56. package/server/public/app/socket-handlers.js +37 -3
  57. package/server/public/app/store.js +18 -5
  58. package/server/public/index.html +567 -35
  59. package/server/relay-tools.md +18 -3
  60. package/server/repositories/conversation-group-repository.mjs +110 -17
  61. package/server/repositories/remote-relay-repository.mjs +20 -2
  62. package/server/repositories/session-repository.mjs +7 -1
  63. package/server/routes/ask-user-routes.mjs +57 -3
  64. package/server/routes/cache-routes.mjs +6 -0
  65. package/server/routes/conversation-group-routes.mjs +19 -3
  66. package/server/routes/deepseek-usage-import-routes.mjs +72 -0
  67. package/server/routes/messages-routes.mjs +195 -18
  68. package/server/routes/relay-ask-routes.mjs +387 -0
  69. package/server/routes/relay-event-routes.mjs +3 -1
  70. package/server/routes/relay-schedule-routes.mjs +57 -0
  71. package/server/routes/sessions-routes.mjs +932 -97
  72. package/server/server-runtime.mjs +203 -11
  73. package/server/services/about-service.mjs +1 -0
  74. package/server/services/cache-rebuild-service.mjs +19 -2
  75. package/server/services/claude-session-leftovers.mjs +226 -0
  76. package/server/services/conversation-preferences-service.mjs +17 -0
  77. package/server/services/deepseek-balance-service.mjs +102 -0
  78. package/server/services/deepseek-settings-service.mjs +380 -0
  79. package/server/services/deepseek-usage-import.mjs +471 -0
  80. package/server/services/model-variant-catalog-service.mjs +1 -0
  81. package/server/services/plan-usage-contract.mjs +100 -5
  82. package/server/services/plan-usage-deepseek.mjs +931 -0
  83. package/server/services/plan-usage-service.mjs +431 -0
  84. package/server/services/provider-model-selection.mjs +1 -0
  85. package/server/services/provider-reasoning-effort.mjs +15 -2
  86. package/server/services/relay-ask-link-service.mjs +174 -0
  87. package/server/services/relay-ask-service.mjs +604 -0
  88. package/server/services/relay-event-service.mjs +589 -73
  89. package/server/services/relay-schedule-service.mjs +596 -0
  90. package/server/services/remote-relay-caller-context.mjs +89 -20
  91. package/server/services/remote-relay-dispatcher.mjs +958 -31
  92. package/server/services/session-worker-launch-service.mjs +233 -82
  93. package/server/services/worker-secret-redaction.mjs +318 -0
  94. package/shared/context-window-fallbacks.mjs +3 -0
  95. package/shared/deepseek-api.mjs +137 -0
  96. package/shared/deepseek-models.mjs +114 -0
  97. package/shared/deepseek-pricing.mjs +146 -0
  98. package/shared/host-claude-session-env.mjs +101 -0
  99. package/shared/model-id.mjs +7 -0
  100. package/shared/preview-tool-core.mjs +1 -1
  101. package/shared/provider-routing.mjs +2 -2
  102. package/shared/relay-asks.mjs +145 -0
  103. package/shared/relay-events.mjs +268 -18
  104. package/shared/relay-schedules.mjs +128 -0
  105. package/shared/remote-relay-contract.mjs +554 -26
  106. package/shared/remote-relay-tool-core.mjs +16 -5
  107. package/shared/steer-settle-failure.mjs +1 -0
  108. package/shared/worker-runtime/direct-openai-image.mjs +128 -0
  109. package/shared/worker-runtime/worker-log-file.mjs +60 -0
@@ -0,0 +1,266 @@
1
+ // The body of the Claude session worker (claude-session-worker.mjs) and of the
2
+ // DeepSeek one (../deepseek-worker/deepseek-session-worker.mjs): a
3
+ // per-conversation Node process that speaks the same relay contracts as the
4
+ // Copilot CLI workers (worker WebSocket, heartbeat, control polling, activity
5
+ // channels) and executes turns through the Claude Agent SDK. A `backend`
6
+ // (null for Claude) points the CLI elsewhere; see claude-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 { createControlPoller } from '../../shared/control-poller.mjs';
18
+ import { resolveDeliveredAutoCompactWindow } from '../../shared/auto-compact-window.mjs';
19
+ import { resolveDeliveredThinking } from '../../shared/claude-thinking.mjs';
20
+ import { resolveDeliveredAttribution } from '../../shared/claude-attribution.mjs';
21
+ import {
22
+ DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MS,
23
+ resolveDeliveredClaudeBackgroundCommandLimitMs,
24
+ } from '../../shared/claude-background-command-limit.mjs';
25
+ import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
26
+ import { createClaudeSessionRunner } from './claude-session-process.mjs';
27
+ import { createRunnerLinkBridge, failSettlingRowsOnShutdown } from './claude-worker-link-wiring.mjs';
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
+ export async function runClaudeSessionWorker({
41
+ // The log line prefix and the name the crash guard reports.
42
+ logTag = 'claude-worker',
43
+ workerName = 'claude-session-worker',
44
+ // The model of a session whose delivery names none.
45
+ defaultModel = String(process.env.CLAUDE_RELAY_MODEL || '').trim(),
46
+ // Where the CLI's turns go and what it reports; null is Anthropic's API
47
+ // through the host's Claude login.
48
+ backend = null,
49
+ } = {}) {
50
+ const dbg = (...parts) => {
51
+ const timestamp = new Date().toISOString();
52
+ console.log(`[${logTag} ${timestamp}]`, ...parts);
53
+ };
54
+ const sdkSessionId = parseSessionIdArg();
55
+ if (!sdkSessionId) {
56
+ console.error(`${workerName}: missing --session-id`);
57
+ process.exit(2);
58
+ }
59
+
60
+ const configPath = String(process.env.COPILOT_WEB_RELAY_CONFIG || '').trim()
61
+ || path.resolve(__dirname, '..', 'config.json');
62
+ const serverUrl = resolveRelayServerUrl({ configPath });
63
+ const token = loadTokenFromConfig(configPath);
64
+ const cwd = String(process.env.COPILOT_WORKSPACE_ROOT || '').trim() || process.cwd();
65
+ const pathToClaudeCodeExecutable = String(process.env.CLAUDE_CODE_EXECUTABLE || '').trim();
66
+ // How long background tasks alone may keep the CLI process alive (0 = no
67
+ // limit). Seeded from the environment; refreshed from every delivery payload
68
+ // so the settings slider applies without a worker restart.
69
+ let backgroundTaskTimeoutMs = Number(process.env.CLAUDE_RELAY_BACKGROUND_TASK_TIMEOUT_MS) || 0;
70
+ // Per-conversation auto-compact window (token count, null = Auto); arrives
71
+ // piggybacked on each queue delivery, like the timeouts above.
72
+ let autoCompactWindow = null;
73
+ // Per-conversation thinking control ({enabled: bool|null, display}); same
74
+ // piggyback. enabled null = host default; display defaults to 'summarized'
75
+ // (today's behavior — visible thought bubbles).
76
+ let thinking = resolveDeliveredThinking(null, null);
77
+ // What this session's commits and PRs say; same piggyback. null = the
78
+ // CLI's own attribution (vanilla).
79
+ let attribution = null;
80
+ // How long a backgrounded Bash command may run without its own timeout;
81
+ // same piggyback, applied when the CLI process is next spawned.
82
+ let backgroundCommandLimitMs = DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MS;
83
+
84
+ const api = createApiClient({
85
+ serverUrl,
86
+ token,
87
+ getHeaders: () => ({
88
+ 'X-Relay-Process-Pid': String(process.pid),
89
+ 'X-Relay-Parent-Pid': String(process.ppid),
90
+ 'X-Relay-Session-Id': sdkSessionId,
91
+ 'X-Relay-Conversation-Id': sdkSessionId,
92
+ }),
93
+ });
94
+
95
+ const controlPoller = createControlPoller({
96
+ api,
97
+ sdkSessionId,
98
+ abortAckNote: 'claude query aborted',
99
+ // Backgrounded subagents are SDK tasks; their relay run id is the
100
+ // spawning tool_use id, which maps to a stoppable task id. (Late-bound:
101
+ // controls only poll while a turn is live, well after turnRunner exists.)
102
+ onAbortSubagent: (subagentRunId) => turnRunner.stopBackgroundTaskByToolUseId(subagentRunId),
103
+ dbg,
104
+ });
105
+ const linkBridge = createRunnerLinkBridge();
106
+ // Late-bound: the heartbeat controller is built after the runner.
107
+ let heartbeat = null;
108
+ const turnRunner = createClaudeSessionRunner({
109
+ api,
110
+ sdkSessionId,
111
+ cwd,
112
+ defaultModel,
113
+ controlPoller,
114
+ pathToClaudeCodeExecutable,
115
+ backend,
116
+ idleShutdownMs: Number(process.env.CLAUDE_RELAY_IDLE_SHUTDOWN_MS) > 0
117
+ ? Number(process.env.CLAUDE_RELAY_IDLE_SHUTDOWN_MS)
118
+ : undefined,
119
+ // 0 disables the watchdog (matching the background-task timeout's
120
+ // 0 = no-limit convention); unset/invalid falls back to the default.
121
+ pendingDeliveredTimeoutMs: (() => {
122
+ const raw = String(process.env.CLAUDE_RELAY_PENDING_DELIVERED_TIMEOUT_MS || '').trim();
123
+ const parsed = Number(raw);
124
+ return raw !== '' && Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
125
+ })(),
126
+ getBackgroundTaskTimeoutMs: () => backgroundTaskTimeoutMs,
127
+ getAutoCompactWindow: () => autoCompactWindow,
128
+ getThinking: () => thinking,
129
+ getAttribution: () => attribution,
130
+ getBackgroundCommandLimitMs: () => backgroundCommandLimitMs,
131
+ // A readiness flip is a hold starting or ending (question card,
132
+ // compaction, a turn-opening delivery): the steering snapshot changed
133
+ // with it, so it goes out now rather than on the next 10 s heartbeat.
134
+ onBackgroundTaskSetStale: () => heartbeat?.requestPulse(),
135
+ onDeliveryReadinessChange: (ready) => {
136
+ linkBridge.onDeliveryReadinessChange(ready);
137
+ heartbeat?.requestPulse();
138
+ },
139
+ canHandBackHeldDelivery: () => linkBridge.canHandBackHeldDelivery(),
140
+ dbg,
141
+ });
142
+
143
+ let heartbeatTimer = null;
144
+ heartbeat = createHeartbeatController({
145
+ api,
146
+ pollMs: HEARTBEAT_MS,
147
+ getSessionReady: () => true,
148
+ getHeartbeatTimer: () => heartbeatTimer,
149
+ setHeartbeatTimer: (timer) => { heartbeatTimer = timer; },
150
+ getActiveQueueMessageId: () => turnRunner.getActiveQueueMessageId(),
151
+ // The runner reports { id, attemptId } entries; the heartbeat's claim
152
+ // payload is id-only (its String() coercion would mangle an object), while
153
+ // the crash guard below takes the entries whole so its requeues stay
154
+ // fenced to this attempt.
155
+ getActiveQueueMessageIds: () => turnRunner.getActiveQueueMessageIds().map((entry) => entry.id),
156
+ // Composer steering snapshot. Holds reach the relay within a pulse window
157
+ // (onDeliveryReadinessChange above) and a relay that just came up gets it
158
+ // on its first hello; the periodic beat only backstops the rest.
159
+ getSteeringState: () => turnRunner.steeringState(),
160
+ // Consumed rows the runner could not fail itself; the relay fails them.
161
+ getSettleFailed: () => turnRunner.getSettleFailed(),
162
+ onSettleFailedHandled: (ids) => turnRunner.acknowledgeSettleFailed(ids),
163
+ // The relay holds the task set in memory only: the digest of the live
164
+ // task ids rides every beat, and a relay that is out of step (it
165
+ // restarted, a post was lost) asks for the set again.
166
+ getBackgroundTaskIds: () => turnRunner.backgroundTaskIds(),
167
+ onResyncBackgroundTasks: () => turnRunner.resyncBackgroundTasks(),
168
+ onAnswered: () => turnRunner.flushKeptTaskSettles(),
169
+ });
170
+
171
+ const wsLink = createWorkerWebSocketLink({
172
+ serverUrl,
173
+ token,
174
+ dbg,
175
+ getSessionReady: () => true,
176
+ // Mid-turn steering: while a turn is running and the runner can absorb
177
+ // another message into it, the link keeps signalling readiness, so a
178
+ // message sent while Claude is thinking is pushed into the live turn
179
+ // instead of waiting for the result (docs/plans/2026-09-19-claude-mid-
180
+ // turn-steering.md).
181
+ getSteeringReady: () => turnRunner.canAcceptSteering(),
182
+ // A hold (open question card, compaction, adoption) suppresses readiness
183
+ // even between deliveries — a delivery drawn into it is only handed back.
184
+ getDeliveryHeld: () => turnRunner.isDeliveryHeld(),
185
+ // A relay that just (re)started knows nothing of this worker until it
186
+ // heartbeats; the composer's steering snapshot rides that beat, and so
187
+ // does the digest that makes the relay ask for the background tasks.
188
+ onServerHello: () => heartbeat.requestPulse(),
189
+ getSessionId: () => sdkSessionId,
190
+ getPid: () => process.pid,
191
+ onDeliver: async (pending, reason) => {
192
+ dbg('queue.deliver received', `reason=${reason}`, `msgId=${pending?.message?.id || 'none'}`);
193
+ const deliveredTimeout = Number(pending?.settings?.backgroundTaskTimeoutMs);
194
+ if (Number.isFinite(deliveredTimeout) && deliveredTimeout >= 0) {
195
+ backgroundTaskTimeoutMs = deliveredTimeout;
196
+ }
197
+ // Absent (an older relay) leaves the last known value alone; an explicit
198
+ // null is the user choosing Auto and must clear the pin.
199
+ autoCompactWindow = resolveDeliveredAutoCompactWindow(autoCompactWindow, pending?.settings);
200
+ // Same presence semantics as the window: absent keys (an older relay)
201
+ // keep the last known state; an explicit null on thinkingEnabled is the
202
+ // user choosing Host default.
203
+ thinking = resolveDeliveredThinking(thinking, pending?.settings);
204
+ attribution = resolveDeliveredAttribution(attribution, pending?.settings);
205
+ backgroundCommandLimitMs = resolveDeliveredClaudeBackgroundCommandLimitMs(backgroundCommandLimitMs, pending?.settings);
206
+ try {
207
+ return await turnRunner.handlePendingPayload(pending);
208
+ } catch (error) {
209
+ dbg('turn handling failed', error?.message || String(error));
210
+ return false;
211
+ }
212
+ },
213
+ onControl: async (control) => {
214
+ const type = String(control?.type || '').trim();
215
+ if (type === 'stop_background_task') {
216
+ const taskId = String(control?.taskId || '').trim();
217
+ dbg('stop background task control', taskId);
218
+ await turnRunner.stopBackgroundTask(taskId);
219
+ return;
220
+ }
221
+ if (type === 'task_output_snapshot') {
222
+ // A console opened on a shell task with no output file: the CLI's
223
+ // last 8 KiB goes to the relay, which serves it as a static tail.
224
+ const taskId = String(control?.taskId || '').trim();
225
+ dbg('task output snapshot control', taskId);
226
+ await turnRunner.snapshotTaskOutput(taskId);
227
+ return;
228
+ }
229
+ dbg('unknown worker control ignored', type || '(none)');
230
+ },
231
+ });
232
+ linkBridge.attach(wsLink);
233
+
234
+ let shuttingDown = false;
235
+ const shutdown = async (signal) => {
236
+ if (shuttingDown) return;
237
+ shuttingDown = true;
238
+ dbg(`shutting down (${signal})`);
239
+ try { wsLink.stop(); } catch {}
240
+ try { heartbeat.stopHeartbeat(); } catch {}
241
+ try { controlPoller.stop(); } catch {}
242
+ // Consumed steers still settling must fail, not be left for dead-worker
243
+ // recovery to requeue and run twice (same 2 s cap as the crash guard).
244
+ try {
245
+ const failed = await failSettlingRowsOnShutdown({ api, runner: turnRunner, timeoutMs: 2_000 });
246
+ if (failed) dbg(`failed ${failed} settling row(s) before exit`);
247
+ } catch {}
248
+ // Hard-close the persistent CLI process; a worker teardown cannot save
249
+ // its background tasks, and the orphan-notification replay on the next
250
+ // resume reports whatever they left behind.
251
+ try { turnRunner.shutdown({ graceful: false }); } catch {}
252
+ process.exit(0);
253
+ };
254
+ process.on('SIGTERM', () => { void shutdown('SIGTERM'); });
255
+ process.on('SIGINT', () => { void shutdown('SIGINT'); });
256
+ installWorkerCrashGuard({
257
+ api,
258
+ workerName,
259
+ getActiveQueueMessageIds: () => turnRunner.getActiveQueueMessageIds(),
260
+ onBeforeExit: () => { try { turnRunner.shutdown({ graceful: false }); } catch {} },
261
+ });
262
+
263
+ dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} cwd=${cwd} model=${defaultModel || 'default'}${backend ? ` backend=${backend.id}` : ''}`);
264
+ heartbeat.startHeartbeat();
265
+ wsLink.start();
266
+ }
@@ -3,265 +3,14 @@
3
3
  // relay contracts as the Copilot CLI workers (worker WebSocket, heartbeat,
4
4
  // control polling, activity channels) but executes turns through the Claude
5
5
  // Agent SDK using the host machine's logged-in Claude credentials.
6
- import path from 'path';
7
- import { fileURLToPath } from 'url';
8
-
9
- import {
10
- loadTokenFromConfig,
11
- resolveRelayServerUrl,
12
- } from '../../shared/worker-runtime/config-loader.mjs';
13
- import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
14
- import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
15
- import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
16
6
  import { installWorkerLogFile } from '../../shared/worker-runtime/worker-log-file.mjs';
17
- import { createControlPoller } from '../../shared/control-poller.mjs';
18
- import { resolveDeliveredAutoCompactWindow } from '../../shared/auto-compact-window.mjs';
19
- import { resolveDeliveredThinking } from '../../shared/claude-thinking.mjs';
20
- import { resolveDeliveredAttribution } from '../../shared/claude-attribution.mjs';
21
- import {
22
- DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MS,
23
- resolveDeliveredClaudeBackgroundCommandLimitMs,
24
- } from '../../shared/claude-background-command-limit.mjs';
25
- import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
26
- import { createClaudeSessionRunner } from './claude-session-process.mjs';
27
- import { createRunnerLinkBridge, failSettlingRowsOnShutdown } from './claude-worker-link-wiring.mjs';
7
+ import { runClaudeSessionWorker } from './claude-session-worker-main.mjs';
28
8
 
29
9
  // Windows console launch: the launcher names the worker log, this copies
30
10
  // stdout/stderr into it. A no-op wherever the launcher redirects instead.
31
11
  installWorkerLogFile();
32
12
 
33
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
34
- const HEARTBEAT_MS = 10_000;
35
-
36
- function parseSessionIdArg(argv = process.argv) {
37
- const index = argv.indexOf('--session-id');
38
- if (index !== -1 && argv[index + 1]) return String(argv[index + 1]).trim();
39
- const inline = argv.find((arg) => String(arg || '').startsWith('--session-id='));
40
- if (inline) return String(inline.split('=')[1] || '').trim();
41
- return String(process.env.SESSION_ID || '').trim();
42
- }
43
-
44
- function dbg(...parts) {
45
- const timestamp = new Date().toISOString();
46
- console.log(`[claude-worker ${timestamp}]`, ...parts);
47
- }
48
-
49
- async function main() {
50
- const sdkSessionId = parseSessionIdArg();
51
- if (!sdkSessionId) {
52
- console.error('claude-session-worker: missing --session-id');
53
- process.exit(2);
54
- }
55
-
56
- const configPath = String(process.env.COPILOT_WEB_RELAY_CONFIG || '').trim()
57
- || path.resolve(__dirname, '..', 'config.json');
58
- const serverUrl = resolveRelayServerUrl({ configPath });
59
- const token = loadTokenFromConfig(configPath);
60
- const cwd = String(process.env.COPILOT_WORKSPACE_ROOT || '').trim() || process.cwd();
61
- const defaultModel = String(process.env.CLAUDE_RELAY_MODEL || '').trim();
62
- const pathToClaudeCodeExecutable = String(process.env.CLAUDE_CODE_EXECUTABLE || '').trim();
63
- // How long background tasks alone may keep the CLI process alive (0 = no
64
- // limit). Seeded from the environment; refreshed from every delivery payload
65
- // so the settings slider applies without a worker restart.
66
- let backgroundTaskTimeoutMs = Number(process.env.CLAUDE_RELAY_BACKGROUND_TASK_TIMEOUT_MS) || 0;
67
- // Per-conversation auto-compact window (token count, null = Auto); arrives
68
- // piggybacked on each queue delivery, like the timeouts above.
69
- let autoCompactWindow = null;
70
- // Per-conversation thinking control ({enabled: bool|null, display}); same
71
- // piggyback. enabled null = host default; display defaults to 'summarized'
72
- // (today's behavior — visible thought bubbles).
73
- let thinking = resolveDeliveredThinking(null, null);
74
- // What this session's commits and PRs say; same piggyback. null = the
75
- // CLI's own attribution (vanilla).
76
- let attribution = null;
77
- // How long a backgrounded Bash command may run without its own timeout;
78
- // same piggyback, applied when the CLI process is next spawned.
79
- let backgroundCommandLimitMs = DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MS;
80
-
81
- const api = createApiClient({
82
- serverUrl,
83
- token,
84
- getHeaders: () => ({
85
- 'X-Relay-Process-Pid': String(process.pid),
86
- 'X-Relay-Parent-Pid': String(process.ppid),
87
- 'X-Relay-Session-Id': sdkSessionId,
88
- 'X-Relay-Conversation-Id': sdkSessionId,
89
- }),
90
- });
91
-
92
- const controlPoller = createControlPoller({
93
- api,
94
- sdkSessionId,
95
- abortAckNote: 'claude query aborted',
96
- // Backgrounded subagents are SDK tasks; their relay run id is the
97
- // spawning tool_use id, which maps to a stoppable task id. (Late-bound:
98
- // controls only poll while a turn is live, well after turnRunner exists.)
99
- onAbortSubagent: (subagentRunId) => turnRunner.stopBackgroundTaskByToolUseId(subagentRunId),
100
- dbg,
101
- });
102
- const linkBridge = createRunnerLinkBridge();
103
- // Late-bound: the heartbeat controller is built after the runner.
104
- let heartbeat = null;
105
- const turnRunner = createClaudeSessionRunner({
106
- api,
107
- sdkSessionId,
108
- cwd,
109
- defaultModel,
110
- controlPoller,
111
- pathToClaudeCodeExecutable,
112
- idleShutdownMs: Number(process.env.CLAUDE_RELAY_IDLE_SHUTDOWN_MS) > 0
113
- ? Number(process.env.CLAUDE_RELAY_IDLE_SHUTDOWN_MS)
114
- : undefined,
115
- // 0 disables the watchdog (matching the background-task timeout's
116
- // 0 = no-limit convention); unset/invalid falls back to the default.
117
- pendingDeliveredTimeoutMs: (() => {
118
- const raw = String(process.env.CLAUDE_RELAY_PENDING_DELIVERED_TIMEOUT_MS || '').trim();
119
- const parsed = Number(raw);
120
- return raw !== '' && Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
121
- })(),
122
- getBackgroundTaskTimeoutMs: () => backgroundTaskTimeoutMs,
123
- getAutoCompactWindow: () => autoCompactWindow,
124
- getThinking: () => thinking,
125
- getAttribution: () => attribution,
126
- getBackgroundCommandLimitMs: () => backgroundCommandLimitMs,
127
- // A readiness flip is a hold starting or ending (question card,
128
- // compaction, a turn-opening delivery): the steering snapshot changed
129
- // with it, so it goes out now rather than on the next 10 s heartbeat.
130
- onBackgroundTaskSetStale: () => heartbeat?.requestPulse(),
131
- onDeliveryReadinessChange: (ready) => {
132
- linkBridge.onDeliveryReadinessChange(ready);
133
- heartbeat?.requestPulse();
134
- },
135
- canHandBackHeldDelivery: () => linkBridge.canHandBackHeldDelivery(),
136
- dbg,
137
- });
138
-
139
- let heartbeatTimer = null;
140
- heartbeat = createHeartbeatController({
141
- api,
142
- pollMs: HEARTBEAT_MS,
143
- getSessionReady: () => true,
144
- getHeartbeatTimer: () => heartbeatTimer,
145
- setHeartbeatTimer: (timer) => { heartbeatTimer = timer; },
146
- getActiveQueueMessageId: () => turnRunner.getActiveQueueMessageId(),
147
- // The runner reports { id, attemptId } entries; the heartbeat's claim
148
- // payload is id-only (its String() coercion would mangle an object), while
149
- // the crash guard below takes the entries whole so its requeues stay
150
- // fenced to this attempt.
151
- getActiveQueueMessageIds: () => turnRunner.getActiveQueueMessageIds().map((entry) => entry.id),
152
- // Composer steering snapshot. Holds reach the relay within a pulse window
153
- // (onDeliveryReadinessChange above) and a relay that just came up gets it
154
- // on its first hello; the periodic beat only backstops the rest.
155
- getSteeringState: () => turnRunner.steeringState(),
156
- // Consumed rows the runner could not fail itself; the relay fails them.
157
- getSettleFailed: () => turnRunner.getSettleFailed(),
158
- onSettleFailedHandled: (ids) => turnRunner.acknowledgeSettleFailed(ids),
159
- // The relay holds the task set in memory only: the digest of the live
160
- // task ids rides every beat, and a relay that is out of step (it
161
- // restarted, a post was lost) asks for the set again.
162
- getBackgroundTaskIds: () => turnRunner.backgroundTaskIds(),
163
- onResyncBackgroundTasks: () => turnRunner.resyncBackgroundTasks(),
164
- onAnswered: () => turnRunner.flushKeptTaskSettles(),
165
- });
166
-
167
- const wsLink = createWorkerWebSocketLink({
168
- serverUrl,
169
- token,
170
- dbg,
171
- getSessionReady: () => true,
172
- // Mid-turn steering: while a turn is running and the runner can absorb
173
- // another message into it, the link keeps signalling readiness, so a
174
- // message sent while Claude is thinking is pushed into the live turn
175
- // instead of waiting for the result (docs/plans/2026-09-19-claude-mid-
176
- // turn-steering.md).
177
- getSteeringReady: () => turnRunner.canAcceptSteering(),
178
- // A hold (open question card, compaction, adoption) suppresses readiness
179
- // even between deliveries — a delivery drawn into it is only handed back.
180
- getDeliveryHeld: () => turnRunner.isDeliveryHeld(),
181
- // A relay that just (re)started knows nothing of this worker until it
182
- // heartbeats; the composer's steering snapshot rides that beat, and so
183
- // does the digest that makes the relay ask for the background tasks.
184
- onServerHello: () => heartbeat.requestPulse(),
185
- getSessionId: () => sdkSessionId,
186
- getPid: () => process.pid,
187
- onDeliver: async (pending, reason) => {
188
- dbg('queue.deliver received', `reason=${reason}`, `msgId=${pending?.message?.id || 'none'}`);
189
- const deliveredTimeout = Number(pending?.settings?.backgroundTaskTimeoutMs);
190
- if (Number.isFinite(deliveredTimeout) && deliveredTimeout >= 0) {
191
- backgroundTaskTimeoutMs = deliveredTimeout;
192
- }
193
- // Absent (an older relay) leaves the last known value alone; an explicit
194
- // null is the user choosing Auto and must clear the pin.
195
- autoCompactWindow = resolveDeliveredAutoCompactWindow(autoCompactWindow, pending?.settings);
196
- // Same presence semantics as the window: absent keys (an older relay)
197
- // keep the last known state; an explicit null on thinkingEnabled is the
198
- // user choosing Host default.
199
- thinking = resolveDeliveredThinking(thinking, pending?.settings);
200
- attribution = resolveDeliveredAttribution(attribution, pending?.settings);
201
- backgroundCommandLimitMs = resolveDeliveredClaudeBackgroundCommandLimitMs(backgroundCommandLimitMs, pending?.settings);
202
- try {
203
- return await turnRunner.handlePendingPayload(pending);
204
- } catch (error) {
205
- dbg('turn handling failed', error?.message || String(error));
206
- return false;
207
- }
208
- },
209
- onControl: async (control) => {
210
- const type = String(control?.type || '').trim();
211
- if (type === 'stop_background_task') {
212
- const taskId = String(control?.taskId || '').trim();
213
- dbg('stop background task control', taskId);
214
- await turnRunner.stopBackgroundTask(taskId);
215
- return;
216
- }
217
- if (type === 'task_output_snapshot') {
218
- // A console opened on a shell task with no output file: the CLI's
219
- // last 8 KiB goes to the relay, which serves it as a static tail.
220
- const taskId = String(control?.taskId || '').trim();
221
- dbg('task output snapshot control', taskId);
222
- await turnRunner.snapshotTaskOutput(taskId);
223
- return;
224
- }
225
- dbg('unknown worker control ignored', type || '(none)');
226
- },
227
- });
228
- linkBridge.attach(wsLink);
229
-
230
- let shuttingDown = false;
231
- const shutdown = async (signal) => {
232
- if (shuttingDown) return;
233
- shuttingDown = true;
234
- dbg(`shutting down (${signal})`);
235
- try { wsLink.stop(); } catch {}
236
- try { heartbeat.stopHeartbeat(); } catch {}
237
- try { controlPoller.stop(); } catch {}
238
- // Consumed steers still settling must fail, not be left for dead-worker
239
- // recovery to requeue and run twice (same 2 s cap as the crash guard).
240
- try {
241
- const failed = await failSettlingRowsOnShutdown({ api, runner: turnRunner, timeoutMs: 2_000 });
242
- if (failed) dbg(`failed ${failed} settling row(s) before exit`);
243
- } catch {}
244
- // Hard-close the persistent CLI process; a worker teardown cannot save
245
- // its background tasks, and the orphan-notification replay on the next
246
- // resume reports whatever they left behind.
247
- try { turnRunner.shutdown({ graceful: false }); } catch {}
248
- process.exit(0);
249
- };
250
- process.on('SIGTERM', () => { void shutdown('SIGTERM'); });
251
- process.on('SIGINT', () => { void shutdown('SIGINT'); });
252
- installWorkerCrashGuard({
253
- api,
254
- workerName: 'claude-session-worker',
255
- getActiveQueueMessageIds: () => turnRunner.getActiveQueueMessageIds(),
256
- onBeforeExit: () => { try { turnRunner.shutdown({ graceful: false }); } catch {} },
257
- });
258
-
259
- dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} cwd=${cwd} model=${defaultModel || 'default'}`);
260
- heartbeat.startHeartbeat();
261
- wsLink.start();
262
- }
263
-
264
- main().catch((error) => {
13
+ runClaudeSessionWorker().catch((error) => {
265
14
  console.error('claude-session-worker fatal:', error?.stack || error?.message || error);
266
15
  process.exit(1);
267
16
  });
@@ -49,6 +49,14 @@ export const CLAUDE_ACCOUNT_GUIDANCE =
49
49
  + 'another one. Then send the message again.';
50
50
  export const CLAUDE_MODEL_GUIDANCE = 'Choose another model for this conversation, then send the message again.';
51
51
 
52
+ // A spawn stopped because Claude Code did not take a backend's connection
53
+ // settings over its control channel (claude-sdk-adapter.mjs,
54
+ // `liveFlagSettings`): nothing was sent.
55
+ export const CLAUDE_LIVE_FLAG_SETTINGS_CODE = 'claude-live-flag-settings';
56
+ export const CLAUDE_LIVE_FLAG_SETTINGS_GUIDANCE =
57
+ 'Send the message again. If it fails again, the Claude Code the relay runs does not take these settings: '
58
+ + 'update the relay, and check its log for the reason.';
59
+
52
60
  function codeFromText(text) {
53
61
  if (ACCOUNT_ACCESS_TEXT.test(text)) return 'account-access';
54
62
  if (BILLING_TEXT.test(text)) return 'billing_error';