@oar-sh/oar 0.9.16 → 0.9.18
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/.github/extensions/web-relay/skills/tool-activity.mjs +5 -4
- package/CHANGELOG.md +412 -0
- package/README.md +15 -13
- package/package.json +2 -1
- package/server/claude-api-worker/claude-api-backend.mjs +345 -0
- package/server/claude-api-worker/claude-api-session-worker.mjs +29 -0
- package/server/claude-worker/claude-attachments.mjs +7 -4
- package/server/claude-worker/claude-cli-pinned-env.mjs +150 -0
- package/server/claude-worker/claude-remote-relay-tool.mjs +3 -3
- package/server/claude-worker/claude-sdk-adapter.mjs +199 -15
- package/server/claude-worker/claude-session-process.mjs +276 -33
- package/server/claude-worker/claude-session-worker-main.mjs +269 -0
- package/server/claude-worker/claude-session-worker.mjs +2 -253
- package/server/claude-worker/claude-subscription-key-check.mjs +64 -0
- package/server/claude-worker/claude-turn-failure.mjs +8 -0
- package/server/claude-worker/claude-turn-publisher.mjs +394 -13
- package/server/claude-worker/claude-usage-totals.mjs +161 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +56 -1
- package/server/copilot-worker/copilot-remote-relay-tool.mjs +2 -2
- package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +1 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +80 -14
- package/server/cursor-worker/cursor-remote-relay-tool.mjs +3 -3
- package/server/cursor-worker/cursor-turn-runner.mjs +4 -4
- package/server/cursor-worker/sdk-message-normalizer.mjs +2 -2
- package/server/db-schema.mjs +96 -1
- package/server/deepseek-worker/deepseek-claude-backend.mjs +317 -0
- package/server/deepseek-worker/deepseek-session-worker.mjs +37 -0
- package/server/grok-worker/acp-client.mjs +1 -1
- package/server/grok-worker/grok-turn-runner.mjs +4 -4
- package/server/grok-worker/sdk-message-normalizer.mjs +1 -1
- package/server/mcp/oar-mcp-server.mjs +11 -5
- 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/migrations/0031-queue-provider.mjs +143 -0
- package/server/migrations/0032-runtime-session-billing-switch.mjs +94 -0
- package/server/migrations/0033-claude-api-usage-checkpoints.mjs +138 -0
- package/server/migrations/0034-claude-api-usage-totals.mjs +112 -0
- package/server/public/app/activity-replay-state.mjs +4 -3
- package/server/public/app/agent-sessions-settings-ui.js +1 -1
- package/server/public/app/agent-sessions-settings.mjs +1 -1
- package/server/public/app/api-client.js +105 -0
- package/server/public/app/bootstrap.js +243 -29
- package/server/public/app/claude-api-settings-ui.js +362 -0
- package/server/public/app/claude-billing-toggle-ui.js +224 -0
- package/server/public/app/claude-billing-toggle.mjs +112 -0
- package/server/public/app/context-tier-options.mjs +10 -2
- package/server/public/app/context-usage-view.mjs +1 -1
- package/server/public/app/conversation-provider-indicator.mjs +18 -0
- package/server/public/app/conversation-view.js +31 -3
- package/server/public/app/deepseek-settings-ui.js +342 -0
- package/server/public/app/inbox-ui.js +14 -3
- package/server/public/app/inbox-view.mjs +1 -1
- package/server/public/app/journal-view.js +156 -11
- package/server/public/app/model-selector-options.mjs +28 -1
- package/server/public/app/model-vision-support.mjs +2 -0
- package/server/public/app/new-conversation-model-choice.mjs +21 -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 +8 -2
- package/server/public/app/relay-error-ctas.mjs +58 -0
- package/server/public/app/relay-schedules-ui.js +1 -1
- package/server/public/app/settings-modal.js +22 -0
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +28 -1
- package/server/public/app/tool-activity-emoji.mjs +4 -3
- package/server/public/app/usage-limit-ui.js +136 -5
- package/server/public/index.html +596 -35
- package/server/relay-tools.md +4 -4
- package/server/repositories/conversation-group-repository.mjs +14 -7
- package/server/repositories/message-repository.mjs +7 -0
- package/server/repositories/session-repository.mjs +8 -1
- package/server/routes/ask-user-routes.mjs +1 -1
- package/server/routes/conversation-group-routes.mjs +3 -2
- package/server/routes/deepseek-usage-import-routes.mjs +72 -0
- package/server/routes/messages-routes.mjs +831 -36
- package/server/routes/relay-ask-routes.mjs +3 -3
- package/server/routes/relay-event-routes.mjs +2 -2
- package/server/routes/relay-schedule-routes.mjs +1 -1
- package/server/routes/remote-relay-routes.mjs +3 -3
- package/server/routes/sessions-routes.mjs +670 -63
- package/server/server-runtime.mjs +476 -26
- package/server/services/about-service.mjs +2 -0
- package/server/services/background-work-view.mjs +2 -1
- package/server/services/claude-api-settings-service.mjs +575 -0
- package/server/services/claude-api-usage-service.mjs +361 -0
- package/server/services/claude-billing-swap-service.mjs +274 -0
- package/server/services/claude-billing-switch-service.mjs +343 -0
- package/server/services/claude-session-leftovers.mjs +226 -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-claude-api.mjs +445 -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 +436 -0
- package/server/services/provider-model-selection.mjs +4 -2
- package/server/services/provider-reasoning-effort.mjs +24 -2
- package/server/services/relay-ask-service.mjs +1 -1
- package/server/services/relay-event-service.mjs +3 -2
- package/server/services/relay-schedule-service.mjs +1 -1
- package/server/services/remote-relay-caller-context.mjs +7 -0
- package/server/services/remote-relay-dispatcher.mjs +84 -14
- package/server/services/remote-relay-registry-service.mjs +1 -1
- package/server/services/session-worker-launch-service.mjs +294 -84
- package/server/services/session-worker-registry-service.mjs +28 -1
- package/server/services/session-worker-websocket-service.mjs +17 -0
- package/server/services/usage-limit-pause-service.mjs +55 -4
- package/server/services/worker-secret-redaction.mjs +320 -0
- package/shared/anthropic-api.mjs +202 -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 +5 -3
- package/shared/relay-asks.mjs +11 -5
- package/shared/relay-events.mjs +2 -2
- package/shared/relay-schedules.mjs +2 -2
- package/shared/remote-relay-contract.mjs +29 -9
- package/shared/remote-relay-tool-core.mjs +23 -6
- package/shared/steer-settle-failure.mjs +2 -0
- package/shared/worker-runtime/api-client.mjs +2 -2
- package/shared/worker-runtime/direct-openai-image.mjs +128 -0
- package/shared/worker-runtime/heartbeat.mjs +5 -0
- package/shared/worker-runtime/worker-log-file.mjs +60 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +6 -0
|
@@ -0,0 +1,269 @@
|
|
|
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
|
+
// What this worker runs on: the relay hands it only rows of that billing.
|
|
147
|
+
workerKind: turnRunner.workerKind,
|
|
148
|
+
pollMs: HEARTBEAT_MS,
|
|
149
|
+
getSessionReady: () => true,
|
|
150
|
+
getHeartbeatTimer: () => heartbeatTimer,
|
|
151
|
+
setHeartbeatTimer: (timer) => { heartbeatTimer = timer; },
|
|
152
|
+
getActiveQueueMessageId: () => turnRunner.getActiveQueueMessageId(),
|
|
153
|
+
// The runner reports { id, attemptId } entries; the heartbeat's claim
|
|
154
|
+
// payload is id-only (its String() coercion would mangle an object), while
|
|
155
|
+
// the crash guard below takes the entries whole so its requeues stay
|
|
156
|
+
// fenced to this attempt.
|
|
157
|
+
getActiveQueueMessageIds: () => turnRunner.getActiveQueueMessageIds().map((entry) => entry.id),
|
|
158
|
+
// Composer steering snapshot. Holds reach the relay within a pulse window
|
|
159
|
+
// (onDeliveryReadinessChange above) and a relay that just came up gets it
|
|
160
|
+
// on its first hello; the periodic beat only backstops the rest.
|
|
161
|
+
getSteeringState: () => turnRunner.steeringState(),
|
|
162
|
+
// Consumed rows the runner could not fail itself; the relay fails them.
|
|
163
|
+
getSettleFailed: () => turnRunner.getSettleFailed(),
|
|
164
|
+
onSettleFailedHandled: (ids) => turnRunner.acknowledgeSettleFailed(ids),
|
|
165
|
+
// The relay holds the task set in memory only: the digest of the live
|
|
166
|
+
// task ids rides every beat, and a relay that is out of step (it
|
|
167
|
+
// restarted, a post was lost) asks for the set again.
|
|
168
|
+
getBackgroundTaskIds: () => turnRunner.backgroundTaskIds(),
|
|
169
|
+
onResyncBackgroundTasks: () => turnRunner.resyncBackgroundTasks(),
|
|
170
|
+
onAnswered: () => turnRunner.flushKeptTaskSettles(),
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const wsLink = createWorkerWebSocketLink({
|
|
174
|
+
serverUrl,
|
|
175
|
+
token,
|
|
176
|
+
dbg,
|
|
177
|
+
getSessionReady: () => true,
|
|
178
|
+
// Mid-turn steering: while a turn is running and the runner can absorb
|
|
179
|
+
// another message into it, the link keeps signalling readiness, so a
|
|
180
|
+
// message sent while Claude is thinking is pushed into the live turn
|
|
181
|
+
// instead of waiting for the result (docs/plans/2026-09-19-claude-mid-
|
|
182
|
+
// turn-steering.md).
|
|
183
|
+
getSteeringReady: () => turnRunner.canAcceptSteering(),
|
|
184
|
+
// A hold (open question card, compaction, adoption) suppresses readiness
|
|
185
|
+
// even between deliveries — a delivery drawn into it is only handed back.
|
|
186
|
+
getDeliveryHeld: () => turnRunner.isDeliveryHeld(),
|
|
187
|
+
// A relay that just (re)started knows nothing of this worker until it
|
|
188
|
+
// heartbeats; the composer's steering snapshot rides that beat, and so
|
|
189
|
+
// does the digest that makes the relay ask for the background tasks.
|
|
190
|
+
onServerHello: () => heartbeat.requestPulse(),
|
|
191
|
+
getSessionId: () => sdkSessionId,
|
|
192
|
+
getPid: () => process.pid,
|
|
193
|
+
workerKind: turnRunner.workerKind,
|
|
194
|
+
onDeliver: async (pending, reason) => {
|
|
195
|
+
dbg('queue.deliver received', `reason=${reason}`, `msgId=${pending?.message?.id || 'none'}`);
|
|
196
|
+
const deliveredTimeout = Number(pending?.settings?.backgroundTaskTimeoutMs);
|
|
197
|
+
if (Number.isFinite(deliveredTimeout) && deliveredTimeout >= 0) {
|
|
198
|
+
backgroundTaskTimeoutMs = deliveredTimeout;
|
|
199
|
+
}
|
|
200
|
+
// Absent (an older relay) leaves the last known value alone; an explicit
|
|
201
|
+
// null is the user choosing Auto and must clear the pin.
|
|
202
|
+
autoCompactWindow = resolveDeliveredAutoCompactWindow(autoCompactWindow, pending?.settings);
|
|
203
|
+
// Same presence semantics as the window: absent keys (an older relay)
|
|
204
|
+
// keep the last known state; an explicit null on thinkingEnabled is the
|
|
205
|
+
// user choosing Host default.
|
|
206
|
+
thinking = resolveDeliveredThinking(thinking, pending?.settings);
|
|
207
|
+
attribution = resolveDeliveredAttribution(attribution, pending?.settings);
|
|
208
|
+
backgroundCommandLimitMs = resolveDeliveredClaudeBackgroundCommandLimitMs(backgroundCommandLimitMs, pending?.settings);
|
|
209
|
+
try {
|
|
210
|
+
return await turnRunner.handlePendingPayload(pending);
|
|
211
|
+
} catch (error) {
|
|
212
|
+
dbg('turn handling failed', error?.message || String(error));
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
onControl: async (control) => {
|
|
217
|
+
const type = String(control?.type || '').trim();
|
|
218
|
+
if (type === 'stop_background_task') {
|
|
219
|
+
const taskId = String(control?.taskId || '').trim();
|
|
220
|
+
dbg('stop background task control', taskId);
|
|
221
|
+
await turnRunner.stopBackgroundTask(taskId);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (type === 'task_output_snapshot') {
|
|
225
|
+
// A console opened on a shell task with no output file: the CLI's
|
|
226
|
+
// last 8 KiB goes to the relay, which serves it as a static tail.
|
|
227
|
+
const taskId = String(control?.taskId || '').trim();
|
|
228
|
+
dbg('task output snapshot control', taskId);
|
|
229
|
+
await turnRunner.snapshotTaskOutput(taskId);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
dbg('unknown worker control ignored', type || '(none)');
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
linkBridge.attach(wsLink);
|
|
236
|
+
|
|
237
|
+
let shuttingDown = false;
|
|
238
|
+
const shutdown = async (signal) => {
|
|
239
|
+
if (shuttingDown) return;
|
|
240
|
+
shuttingDown = true;
|
|
241
|
+
dbg(`shutting down (${signal})`);
|
|
242
|
+
try { wsLink.stop(); } catch {}
|
|
243
|
+
try { heartbeat.stopHeartbeat(); } catch {}
|
|
244
|
+
try { controlPoller.stop(); } catch {}
|
|
245
|
+
// Consumed steers still settling must fail, not be left for dead-worker
|
|
246
|
+
// recovery to requeue and run twice (same 2 s cap as the crash guard).
|
|
247
|
+
try {
|
|
248
|
+
const failed = await failSettlingRowsOnShutdown({ api, runner: turnRunner, timeoutMs: 2_000 });
|
|
249
|
+
if (failed) dbg(`failed ${failed} settling row(s) before exit`);
|
|
250
|
+
} catch {}
|
|
251
|
+
// Hard-close the persistent CLI process; a worker teardown cannot save
|
|
252
|
+
// its background tasks, and the orphan-notification replay on the next
|
|
253
|
+
// resume reports whatever they left behind.
|
|
254
|
+
try { turnRunner.shutdown({ graceful: false }); } catch {}
|
|
255
|
+
process.exit(0);
|
|
256
|
+
};
|
|
257
|
+
process.on('SIGTERM', () => { void shutdown('SIGTERM'); });
|
|
258
|
+
process.on('SIGINT', () => { void shutdown('SIGINT'); });
|
|
259
|
+
installWorkerCrashGuard({
|
|
260
|
+
api,
|
|
261
|
+
workerName,
|
|
262
|
+
getActiveQueueMessageIds: () => turnRunner.getActiveQueueMessageIds(),
|
|
263
|
+
onBeforeExit: () => { try { turnRunner.shutdown({ graceful: false }); } catch {} },
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} cwd=${cwd} model=${defaultModel || 'default'}${backend ? ` backend=${backend.id}` : ''}`);
|
|
267
|
+
heartbeat.startHeartbeat();
|
|
268
|
+
wsLink.start();
|
|
269
|
+
}
|
|
@@ -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
|
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// The subscription side of the Claude billing check (plan decision 4).
|
|
2
|
+
//
|
|
3
|
+
// A Claude chat on the host's subscription must never run on an API key
|
|
4
|
+
// without being asked: the key's owner would pay. The worker strips every
|
|
5
|
+
// Anthropic credential from the CLI's environment (WP0), but a settings
|
|
6
|
+
// file's `env.ANTHROPIC_API_KEY`, an `apiKeyHelper` or a Console `/login`
|
|
7
|
+
// can still hand Claude Code a key. Its `system/init` names where the
|
|
8
|
+
// credential came from (`apiKeySource`); a turn that comes up on an API key
|
|
9
|
+
// is stopped there, as the Claude API worker stops one that comes up on
|
|
10
|
+
// anything but its key (claude-api-backend.mjs, checkClaudeApiInit). At most
|
|
11
|
+
// the one request that may beat the init has gone out (decision 24).
|
|
12
|
+
|
|
13
|
+
export const CLAUDE_SUBSCRIPTION_KEY_SOURCE_CODE = 'subscription-key-source';
|
|
14
|
+
|
|
15
|
+
// The sources that bill an API key, not the subscription: a key in the
|
|
16
|
+
// environment (a settings file's env block), a helper that prints one, and
|
|
17
|
+
// the key a Console login keeps. Every other source (`none`, `oauth`, ...)
|
|
18
|
+
// lets the turn go on; one the relay does not know is logged.
|
|
19
|
+
export const API_BILLED_KEY_SOURCES = Object.freeze(['ANTHROPIC_API_KEY', 'apiKeyHelper', '/login managed key']);
|
|
20
|
+
|
|
21
|
+
const KNOWN_KEY_SOURCES = Object.freeze([
|
|
22
|
+
...API_BILLED_KEY_SOURCES,
|
|
23
|
+
'none',
|
|
24
|
+
'user',
|
|
25
|
+
'project',
|
|
26
|
+
'org',
|
|
27
|
+
'temporary',
|
|
28
|
+
'oauth',
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
const GUIDANCE = 'Remove ANTHROPIC_API_KEY from the env of the host\'s Claude Code settings (~/.claude/settings.json, '
|
|
32
|
+
+ 'or the folder\'s .claude/settings*.json), remove an apiKeyHelper, or sign the Console login out, then send the '
|
|
33
|
+
+ 'message again. To run this chat on the API key saved in Settings, switch its billing to API key.';
|
|
34
|
+
|
|
35
|
+
/** Whether a source the CLI named is one the relay knows. */
|
|
36
|
+
export function isKnownApiKeySource(value) {
|
|
37
|
+
return KNOWN_KEY_SOURCES.includes(String(value ?? ''));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Null when the CLI's `system/init` came up without an API key, else the
|
|
42
|
+
* Error that stops the turn. `afterTraffic`: the turn had already talked to
|
|
43
|
+
* the API, so one request may have gone out on that key.
|
|
44
|
+
*/
|
|
45
|
+
export function checkClaudeSubscriptionInit(sdkMessage, { afterTraffic = false } = {}) {
|
|
46
|
+
if (!sdkMessage || sdkMessage.type !== 'system' || sdkMessage.subtype !== 'init') return null;
|
|
47
|
+
const source = typeof sdkMessage.apiKeySource === 'string' ? sdkMessage.apiKeySource : '';
|
|
48
|
+
if (!API_BILLED_KEY_SOURCES.includes(source)) return null;
|
|
49
|
+
const error = new Error(
|
|
50
|
+
`Claude Code took an API key for this subscription turn (source "${source}"), so the turn was stopped: `
|
|
51
|
+
+ 'it would have been billed to that key, not the subscription.'
|
|
52
|
+
+ (afterTraffic ? ' At most one request may have gone out on that key.' : ''),
|
|
53
|
+
);
|
|
54
|
+
error.code = CLAUDE_SUBSCRIPTION_KEY_SOURCE_CODE;
|
|
55
|
+
error.apiKeySource = source;
|
|
56
|
+
error.guidance = GUIDANCE;
|
|
57
|
+
return error;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `{ code, guidance }` for a turn the check stopped, else null (the publisher's describeFailure). */
|
|
61
|
+
export function describeClaudeSubscriptionFailure(_errorText, { error = null } = {}) {
|
|
62
|
+
if (error?.code !== CLAUDE_SUBSCRIPTION_KEY_SOURCE_CODE) return null;
|
|
63
|
+
return { code: CLAUDE_SUBSCRIPTION_KEY_SOURCE_CODE, guidance: error.guidance || GUIDANCE };
|
|
64
|
+
}
|
|
@@ -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';
|