@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.
- package/.github/extensions/web-relay/polling/polling-loop.mjs +14 -87
- package/CHANGELOG.md +305 -0
- package/README.md +12 -11
- package/package.json +2 -1
- package/server/claude-worker/claude-attachments.mjs +7 -4
- package/server/claude-worker/claude-remote-relay-tool.mjs +36 -1
- package/server/claude-worker/claude-sdk-adapter.mjs +167 -9
- package/server/claude-worker/claude-session-process.mjs +131 -33
- package/server/claude-worker/claude-session-worker-main.mjs +266 -0
- package/server/claude-worker/claude-session-worker.mjs +2 -253
- package/server/claude-worker/claude-turn-failure.mjs +8 -0
- package/server/claude-worker/claude-turn-publisher.mjs +282 -13
- package/server/claude-worker/claude-usage-totals.mjs +108 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +55 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
- package/server/copilot-worker/copilot-sdk-session-process.mjs +73 -7
- package/server/db-schema.mjs +115 -0
- package/server/deepseek-worker/deepseek-claude-backend.mjs +433 -0
- package/server/deepseek-worker/deepseek-session-worker.mjs +37 -0
- package/server/migrations/0019-relay-event-subscription-options.mjs +106 -0
- package/server/migrations/0020-conversation-may-create-sessions.mjs +67 -0
- package/server/migrations/0021-conversation-group-agent-marks.mjs +77 -0
- package/server/migrations/0022-relay-schedules.mjs +142 -0
- package/server/migrations/0023-relay-asks.mjs +141 -0
- package/server/migrations/0024-deepseek-usage-totals.mjs +151 -0
- package/server/migrations/0025-deepseek-usage-requests-daily.mjs +179 -0
- package/server/migrations/0026-deepseek-usage-served-models.mjs +222 -0
- package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +101 -0
- package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +99 -0
- package/server/migrations/0029-deepseek-balance-rises.mjs +99 -0
- package/server/migrations/0030-deepseek-usage-actuals.mjs +123 -0
- package/server/public/app/api-client.js +53 -0
- package/server/public/app/ask-user-view.js +3 -0
- package/server/public/app/bootstrap.js +162 -16
- package/server/public/app/context-tier-options.mjs +4 -0
- package/server/public/app/context-usage-view.mjs +17 -0
- package/server/public/app/conversation-group-order.mjs +35 -0
- package/server/public/app/conversation-provider-indicator.mjs +7 -0
- package/server/public/app/conversation-view.js +2 -0
- package/server/public/app/deepseek-settings-ui.js +342 -0
- package/server/public/app/inbox-ui.js +367 -0
- package/server/public/app/inbox-view.mjs +370 -0
- package/server/public/app/journal-view.js +54 -5
- package/server/public/app/model-selector-options.mjs +11 -0
- package/server/public/app/model-vision-support.mjs +2 -0
- package/server/public/app/no-conversation-state.mjs +1 -1
- package/server/public/app/plan-usage-view.mjs +165 -13
- package/server/public/app/provider-readiness.mjs +4 -1
- package/server/public/app/relay-error-ctas.mjs +8 -0
- package/server/public/app/relay-event-subscriptions-ui.js +4 -2
- package/server/public/app/relay-event-view.mjs +33 -7
- package/server/public/app/relay-schedule-view.mjs +119 -0
- package/server/public/app/relay-schedules-ui.js +172 -0
- package/server/public/app/settings-modal.js +11 -0
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +37 -3
- package/server/public/app/store.js +18 -5
- package/server/public/index.html +567 -35
- package/server/relay-tools.md +18 -3
- package/server/repositories/conversation-group-repository.mjs +110 -17
- package/server/repositories/remote-relay-repository.mjs +20 -2
- package/server/repositories/session-repository.mjs +7 -1
- package/server/routes/ask-user-routes.mjs +57 -3
- package/server/routes/cache-routes.mjs +6 -0
- package/server/routes/conversation-group-routes.mjs +19 -3
- package/server/routes/deepseek-usage-import-routes.mjs +72 -0
- package/server/routes/messages-routes.mjs +195 -18
- package/server/routes/relay-ask-routes.mjs +387 -0
- package/server/routes/relay-event-routes.mjs +3 -1
- package/server/routes/relay-schedule-routes.mjs +57 -0
- package/server/routes/sessions-routes.mjs +932 -97
- package/server/server-runtime.mjs +203 -11
- package/server/services/about-service.mjs +1 -0
- package/server/services/cache-rebuild-service.mjs +19 -2
- package/server/services/claude-session-leftovers.mjs +226 -0
- package/server/services/conversation-preferences-service.mjs +17 -0
- package/server/services/deepseek-balance-service.mjs +102 -0
- package/server/services/deepseek-settings-service.mjs +380 -0
- package/server/services/deepseek-usage-import.mjs +471 -0
- package/server/services/model-variant-catalog-service.mjs +1 -0
- package/server/services/plan-usage-contract.mjs +100 -5
- package/server/services/plan-usage-deepseek.mjs +931 -0
- package/server/services/plan-usage-service.mjs +431 -0
- package/server/services/provider-model-selection.mjs +1 -0
- package/server/services/provider-reasoning-effort.mjs +15 -2
- package/server/services/relay-ask-link-service.mjs +174 -0
- package/server/services/relay-ask-service.mjs +604 -0
- package/server/services/relay-event-service.mjs +589 -73
- package/server/services/relay-schedule-service.mjs +596 -0
- package/server/services/remote-relay-caller-context.mjs +89 -20
- package/server/services/remote-relay-dispatcher.mjs +958 -31
- package/server/services/session-worker-launch-service.mjs +233 -82
- package/server/services/worker-secret-redaction.mjs +318 -0
- package/shared/context-window-fallbacks.mjs +3 -0
- package/shared/deepseek-api.mjs +137 -0
- package/shared/deepseek-models.mjs +114 -0
- package/shared/deepseek-pricing.mjs +146 -0
- package/shared/host-claude-session-env.mjs +101 -0
- package/shared/model-id.mjs +7 -0
- package/shared/preview-tool-core.mjs +1 -1
- package/shared/provider-routing.mjs +2 -2
- package/shared/relay-asks.mjs +145 -0
- package/shared/relay-events.mjs +268 -18
- package/shared/relay-schedules.mjs +128 -0
- package/shared/remote-relay-contract.mjs +554 -26
- package/shared/remote-relay-tool-core.mjs +16 -5
- package/shared/steer-settle-failure.mjs +1 -0
- package/shared/worker-runtime/direct-openai-image.mjs +128 -0
- 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 {
|
|
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
|
-
|
|
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';
|