@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
|
@@ -14,6 +14,11 @@ import {
|
|
|
14
14
|
isOarMcpServerDisabled,
|
|
15
15
|
} from '../mcp/oar-mcp-server.mjs';
|
|
16
16
|
import { WORKER_LOG_FILE_ENV } from '../../shared/worker-runtime/worker-log-file.mjs';
|
|
17
|
+
import {
|
|
18
|
+
HOST_CLAUDE_SESSION_ENV_VARS,
|
|
19
|
+
hostClaudeSessionUnsetStatement,
|
|
20
|
+
withoutHostClaudeSessionVariables,
|
|
21
|
+
} from '../../shared/host-claude-session-env.mjs';
|
|
17
22
|
import { missingWorkingDirectoryError } from '../../shared/worker-runtime/working-directory.mjs';
|
|
18
23
|
import {
|
|
19
24
|
describeSpawnEnvironment,
|
|
@@ -270,6 +275,70 @@ export function applyClaudeCloudProviderEnvironment(env = {}, {
|
|
|
270
275
|
return next;
|
|
271
276
|
}
|
|
272
277
|
|
|
278
|
+
/**
|
|
279
|
+
* DeepSeek: the Claude worker's runner against DeepSeek's Anthropic-compatible
|
|
280
|
+
* endpoint (server/deepseek-worker/). The key travels as DEEPSEEK_API_KEY,
|
|
281
|
+
* through the secret file under tmux, and becomes the CLI's bearer token
|
|
282
|
+
* only inside its spawn environment (deepseek-claude-backend.mjs). The worker
|
|
283
|
+
* keeps its Claude Code state in a directory of its own, which holds no
|
|
284
|
+
* Claude login.
|
|
285
|
+
*/
|
|
286
|
+
export function applyDeepSeekProviderEnvironment(env = {}, {
|
|
287
|
+
enabled = false,
|
|
288
|
+
model = '',
|
|
289
|
+
apiKey = '',
|
|
290
|
+
baseUrl = '',
|
|
291
|
+
configDir = '',
|
|
292
|
+
} = {}) {
|
|
293
|
+
const next = { ...env };
|
|
294
|
+
if (resolveWorkerKind(next) === 'deepseek') delete next.COPILOT_WEB_RELAY_WORKER_KIND;
|
|
295
|
+
delete next.DEEPSEEK_RELAY_MODEL;
|
|
296
|
+
delete next.DEEPSEEK_API_KEY;
|
|
297
|
+
delete next.DEEPSEEK_BASE_URL;
|
|
298
|
+
delete next.DEEPSEEK_CLAUDE_CONFIG_DIR;
|
|
299
|
+
if (!enabled) return next;
|
|
300
|
+
const normalizedApiKey = normalizeText(apiKey);
|
|
301
|
+
if (!normalizedApiKey) throw new Error('deepseek-api-key-not-configured');
|
|
302
|
+
const normalizedConfigDir = normalizeText(configDir);
|
|
303
|
+
if (!normalizedConfigDir) throw new Error('deepseek-config-dir-not-configured');
|
|
304
|
+
next.COPILOT_WEB_RELAY_WORKER_KIND = 'deepseek';
|
|
305
|
+
next.DEEPSEEK_API_KEY = normalizedApiKey;
|
|
306
|
+
next.DEEPSEEK_CLAUDE_CONFIG_DIR = path.resolve(normalizedConfigDir);
|
|
307
|
+
const normalizedModel = normalizeText(model);
|
|
308
|
+
if (normalizedModel) next.DEEPSEEK_RELAY_MODEL = normalizedModel;
|
|
309
|
+
const normalizedBaseUrl = normalizeText(baseUrl);
|
|
310
|
+
if (normalizedBaseUrl) next.DEEPSEEK_BASE_URL = normalizedBaseUrl;
|
|
311
|
+
return next;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Claude API: the Claude worker's runner on an API key from the Claude
|
|
316
|
+
* Console instead of the host's subscription login
|
|
317
|
+
* (server/claude-api-worker/). The key travels as OAR_CLAUDE_API_KEY, through
|
|
318
|
+
* the secret file under tmux, and becomes the CLI's ANTHROPIC_API_KEY only
|
|
319
|
+
* inside its spawn environment (claude-api-backend.mjs). The worker shares the
|
|
320
|
+
* host's Claude Code state directory with the Claude provider: transcripts,
|
|
321
|
+
* resume and task output are found where a Claude chat's are.
|
|
322
|
+
*/
|
|
323
|
+
export function applyClaudeApiProviderEnvironment(env = {}, {
|
|
324
|
+
enabled = false,
|
|
325
|
+
model = '',
|
|
326
|
+
apiKey = '',
|
|
327
|
+
} = {}) {
|
|
328
|
+
const next = { ...env };
|
|
329
|
+
if (resolveWorkerKind(next) === 'claude-api') delete next.COPILOT_WEB_RELAY_WORKER_KIND;
|
|
330
|
+
delete next.CLAUDE_API_RELAY_MODEL;
|
|
331
|
+
delete next.OAR_CLAUDE_API_KEY;
|
|
332
|
+
if (!enabled) return next;
|
|
333
|
+
const normalizedApiKey = normalizeText(apiKey);
|
|
334
|
+
if (!normalizedApiKey) throw new Error('claude-api-key-not-configured');
|
|
335
|
+
next.COPILOT_WEB_RELAY_WORKER_KIND = 'claude-api';
|
|
336
|
+
next.OAR_CLAUDE_API_KEY = normalizedApiKey;
|
|
337
|
+
const normalizedModel = normalizeText(model);
|
|
338
|
+
if (normalizedModel) next.CLAUDE_API_RELAY_MODEL = normalizedModel;
|
|
339
|
+
return next;
|
|
340
|
+
}
|
|
341
|
+
|
|
273
342
|
/**
|
|
274
343
|
* The Copilot SDK engine: the same `github`/`openai` providers the extension
|
|
275
344
|
* serves, run as a plain Node worker against the CLI's headless runtime.
|
|
@@ -359,6 +428,14 @@ export function isClaudeCloudWorkerEnvironment(env = {}) {
|
|
|
359
428
|
return resolveWorkerKind(env) === 'claude-cloud';
|
|
360
429
|
}
|
|
361
430
|
|
|
431
|
+
export function isDeepSeekWorkerEnvironment(env = {}) {
|
|
432
|
+
return resolveWorkerKind(env) === 'deepseek';
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export function isClaudeApiWorkerEnvironment(env = {}) {
|
|
436
|
+
return resolveWorkerKind(env) === 'claude-api';
|
|
437
|
+
}
|
|
438
|
+
|
|
362
439
|
export function isCopilotSdkWorkerEnvironment(env = {}) {
|
|
363
440
|
return resolveWorkerKind(env) === 'copilot-sdk';
|
|
364
441
|
}
|
|
@@ -403,6 +480,26 @@ export function resolveClaudeCloudWorkerScriptPath(env = {}) {
|
|
|
403
480
|
return path.join(process.cwd(), 'server', 'claude-cloud-worker', 'claude-cloud-session-worker.mjs');
|
|
404
481
|
}
|
|
405
482
|
|
|
483
|
+
export function resolveDeepSeekWorkerScriptPath(env = {}) {
|
|
484
|
+
const explicit = normalizeText(env?.COPILOT_WEB_RELAY_DEEPSEEK_WORKER_PATH);
|
|
485
|
+
if (explicit) return explicit;
|
|
486
|
+
const repoRoot = normalizeText(env?.COPILOT_WEB_RELAY_ROOT);
|
|
487
|
+
if (repoRoot) return path.join(repoRoot, 'server', 'deepseek-worker', 'deepseek-session-worker.mjs');
|
|
488
|
+
const serverDir = normalizeText(env?.COPILOT_WEB_RELAY_SERVER_DIR);
|
|
489
|
+
if (serverDir) return path.join(serverDir, 'deepseek-worker', 'deepseek-session-worker.mjs');
|
|
490
|
+
return path.join(process.cwd(), 'server', 'deepseek-worker', 'deepseek-session-worker.mjs');
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export function resolveClaudeApiWorkerScriptPath(env = {}) {
|
|
494
|
+
const explicit = normalizeText(env?.COPILOT_WEB_RELAY_CLAUDE_API_WORKER_PATH);
|
|
495
|
+
if (explicit) return explicit;
|
|
496
|
+
const repoRoot = normalizeText(env?.COPILOT_WEB_RELAY_ROOT);
|
|
497
|
+
if (repoRoot) return path.join(repoRoot, 'server', 'claude-api-worker', 'claude-api-session-worker.mjs');
|
|
498
|
+
const serverDir = normalizeText(env?.COPILOT_WEB_RELAY_SERVER_DIR);
|
|
499
|
+
if (serverDir) return path.join(serverDir, 'claude-api-worker', 'claude-api-session-worker.mjs');
|
|
500
|
+
return path.join(process.cwd(), 'server', 'claude-api-worker', 'claude-api-session-worker.mjs');
|
|
501
|
+
}
|
|
502
|
+
|
|
406
503
|
export function resolveCopilotSdkWorkerScriptPath(env = {}) {
|
|
407
504
|
const explicit = normalizeText(env?.COPILOT_WEB_RELAY_COPILOT_SDK_WORKER_PATH);
|
|
408
505
|
if (explicit) return explicit;
|
|
@@ -431,6 +528,11 @@ const NODE_WORKER_DESCRIPTORS = Object.freeze({
|
|
|
431
528
|
resolveScriptPath: resolveCopilotSdkWorkerScriptPath,
|
|
432
529
|
windowsTitle: 'Copilot SDK Worker',
|
|
433
530
|
}),
|
|
531
|
+
deepseek: Object.freeze({ resolveScriptPath: resolveDeepSeekWorkerScriptPath, windowsTitle: 'DeepSeek Worker' }),
|
|
532
|
+
'claude-api': Object.freeze({
|
|
533
|
+
resolveScriptPath: resolveClaudeApiWorkerScriptPath,
|
|
534
|
+
windowsTitle: 'Claude API Worker',
|
|
535
|
+
}),
|
|
434
536
|
});
|
|
435
537
|
|
|
436
538
|
function resolveNodeWorkerDescriptor(env = {}) {
|
|
@@ -469,7 +571,7 @@ function buildPosixWorkerLaunchCommand(targetSessionId, env = {}, extraCliArgs =
|
|
|
469
571
|
// Server entry shape (the CLI's own `copilot mcp add` writes the same):
|
|
470
572
|
// `{ type: 'local', command, args, env, tools: ['*'], timeout }`. `timeout` is
|
|
471
573
|
// the per-call limit in ms; without it the CLI cuts a tool call off at 180 s,
|
|
472
|
-
// shorter than one
|
|
574
|
+
// shorter than one relay_tool wait.
|
|
473
575
|
|
|
474
576
|
export function resolveOarMcpServerScriptPath(env = {}, { pathImpl = path } = {}) {
|
|
475
577
|
const repoRoot = normalizeText(env?.COPILOT_WEB_RELAY_ROOT);
|
|
@@ -601,17 +703,49 @@ export function getTmuxPanePid(sessionName, {
|
|
|
601
703
|
}
|
|
602
704
|
}
|
|
603
705
|
|
|
604
|
-
export const WORKER_SECRET_ENV_VARS = Object.freeze([
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
706
|
+
export const WORKER_SECRET_ENV_VARS = Object.freeze([
|
|
707
|
+
'COPILOT_PROVIDER_API_KEY',
|
|
708
|
+
'CURSOR_API_KEY',
|
|
709
|
+
'CLAUDE_CODE_OAUTH_TOKEN',
|
|
710
|
+
'DEEPSEEK_API_KEY',
|
|
711
|
+
'OAR_CLAUDE_API_KEY',
|
|
712
|
+
'ANTHROPIC_API_KEY',
|
|
713
|
+
'ANTHROPIC_AUTH_TOKEN',
|
|
714
|
+
]);
|
|
715
|
+
|
|
716
|
+
// Secrets that are not every worker's: each entry names the kinds that get
|
|
717
|
+
// it, and every other kind has it taken out of its launch environment on
|
|
718
|
+
// every path (withoutForeignWorkerSecrets), and out of the tmux shell
|
|
719
|
+
// (buildTmuxWorkerShellCommand). A secret with no entry is every kind's.
|
|
720
|
+
//
|
|
721
|
+
// - The two API keys without an entry are put into the launch environment by
|
|
722
|
+
// their own provider's applier and cleared for every other one.
|
|
723
|
+
// - The Claude login token sits in the relay's own environment (a relay run on
|
|
724
|
+
// CLAUDE_CODE_OAUTH_TOKEN instead of the CLI's credentials file), so it
|
|
725
|
+
// would otherwise travel with every launch. Only the two workers of the
|
|
726
|
+
// host's own Claude account get it: the Claude Cloud worker's client reads
|
|
727
|
+
// the login from there exactly as the relay's does (credentials.mjs), and
|
|
728
|
+
// the Claude worker's CLI prefers it to the credentials file.
|
|
729
|
+
// - The DeepSeek key is put there by its own applier, but the same name could
|
|
730
|
+
// also sit in the relay's environment (a shell that exports it); it is the
|
|
731
|
+
// DeepSeek worker's alone either way.
|
|
732
|
+
// - The Claude API key is the Claude API worker's alone, the same way. It
|
|
733
|
+
// travels under a name of the relay's (OAR_CLAUDE_API_KEY), never as
|
|
734
|
+
// ANTHROPIC_API_KEY: the worker's CLI is the only process that gets it under
|
|
735
|
+
// that name (claude-api-backend.mjs), and a host's ANTHROPIC_API_KEY stays
|
|
736
|
+
// what it is below, no worker's.
|
|
737
|
+
// - A host's ANTHROPIC_API_KEY and ANTHROPIC_AUTH_TOKEN are no worker's (an
|
|
738
|
+
// empty list): Claude Code prefers either to the Claude login, so a Claude
|
|
739
|
+
// chat would run, and be billed, on a key nobody chose for it, and every
|
|
740
|
+
// other kind has no use for them. A key a provider saves in the relay's
|
|
741
|
+
// settings goes to its own kind under a name of its own, with an entry
|
|
742
|
+
// here naming that kind alone.
|
|
613
743
|
const WORKER_SECRET_ENV_VAR_KINDS = Object.freeze({
|
|
614
|
-
CLAUDE_CODE_OAUTH_TOKEN: Object.freeze(['claude-cloud']),
|
|
744
|
+
CLAUDE_CODE_OAUTH_TOKEN: Object.freeze(['claude', 'claude-cloud']),
|
|
745
|
+
DEEPSEEK_API_KEY: Object.freeze(['deepseek']),
|
|
746
|
+
OAR_CLAUDE_API_KEY: Object.freeze(['claude-api']),
|
|
747
|
+
ANTHROPIC_API_KEY: Object.freeze([]),
|
|
748
|
+
ANTHROPIC_AUTH_TOKEN: Object.freeze([]),
|
|
615
749
|
});
|
|
616
750
|
|
|
617
751
|
/**
|
|
@@ -627,6 +761,29 @@ export function workerSecretEnvVarsFor(env = {}) {
|
|
|
627
761
|
});
|
|
628
762
|
}
|
|
629
763
|
|
|
764
|
+
/**
|
|
765
|
+
* The launch environment without the secrets that are another kind's. They
|
|
766
|
+
* would otherwise reach the worker on the detached and Windows paths, which
|
|
767
|
+
* hand the environment over as it is, and under tmux through the environment
|
|
768
|
+
* a tmux server started by the launch keeps as its global one.
|
|
769
|
+
*/
|
|
770
|
+
export function withoutForeignWorkerSecrets(env = {}) {
|
|
771
|
+
const own = new Set(workerSecretEnvVarsFor(env));
|
|
772
|
+
const next = { ...env };
|
|
773
|
+
for (const key of WORKER_SECRET_ENV_VARS) {
|
|
774
|
+
if (!own.has(key)) delete next[key];
|
|
775
|
+
}
|
|
776
|
+
return next;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// The host Claude Code session's variables (a relay started from inside a
|
|
780
|
+
// Claude Code session) are dropped from every launch path, for every kind:
|
|
781
|
+
// the names and families are in shared/host-claude-session-env.mjs, with
|
|
782
|
+
// what stays and why. Under tmux the worker's command unsets them again, by
|
|
783
|
+
// family too, from whatever the tmux server's environment holds
|
|
784
|
+
// (buildTmuxWorkerShellCommand).
|
|
785
|
+
export { HOST_CLAUDE_SESSION_ENV_VARS, withoutHostClaudeSessionVariables };
|
|
786
|
+
|
|
630
787
|
export function createWorkerSecretEnvFile(env = {}, {
|
|
631
788
|
fsImpl = fs,
|
|
632
789
|
tempRoot = os.tmpdir(),
|
|
@@ -697,6 +854,90 @@ export function prepareWorkerLogFile(targetSessionId, launchEnv = {}, {
|
|
|
697
854
|
}
|
|
698
855
|
}
|
|
699
856
|
|
|
857
|
+
// What a tmux worker is handed as `export`s in its own shell command. These
|
|
858
|
+
// are the values of one launch (the kind of worker, its model, its folder),
|
|
859
|
+
// so they never go into the environment tmux itself is started with: a tmux
|
|
860
|
+
// server started by a launch keeps that as its global environment, and every
|
|
861
|
+
// later session on the server would inherit the first launch's values where
|
|
862
|
+
// its own command exports none (launchSessionCli takes them out).
|
|
863
|
+
export const TMUX_WORKER_EXPORTED_ENV_VARS = Object.freeze([
|
|
864
|
+
'COPILOT_ALLOW_ALL',
|
|
865
|
+
'GITHUB_COPILOT_PROMPT_MODE_EXTENSIONS',
|
|
866
|
+
'COPILOT_WEB_RELAY_ROOT',
|
|
867
|
+
'COPILOT_WEB_RELAY_SERVER_DIR',
|
|
868
|
+
'COPILOT_WEB_RELAY_CONFIG',
|
|
869
|
+
'COPILOT_WEB_RELAY_TOOLS',
|
|
870
|
+
'COPILOT_WEB_RELAY_LOG_DIR',
|
|
871
|
+
// The Cursor worker keeps its agent stores in the relay's data dir
|
|
872
|
+
// (applyCursorProviderEnvironment).
|
|
873
|
+
'COPILOT_WEB_RELAY_DATA_DIR',
|
|
874
|
+
'COPILOT_WEB_RELAY_CLI_EXECUTABLE',
|
|
875
|
+
'COPILOT_WEB_RELAY_EXTENSION_BOOTSTRAP_PATH',
|
|
876
|
+
'COPILOT_SDK_PATH',
|
|
877
|
+
'EXTENSION_PATH',
|
|
878
|
+
'COPILOT_PROVIDER_TYPE',
|
|
879
|
+
'COPILOT_PROVIDER_BASE_URL',
|
|
880
|
+
'COPILOT_PROVIDER_WIRE_API',
|
|
881
|
+
'COPILOT_MODEL',
|
|
882
|
+
'COPILOT_WEB_RELAY_WORKER_KIND',
|
|
883
|
+
// The SDK worker's own model and BYOK context overrides. `COPILOT_MODEL`
|
|
884
|
+
// above is the extension engine's model variable and is read by the CLI's
|
|
885
|
+
// startup layer; the SDK worker takes its default model from
|
|
886
|
+
// `COPILOT_RELAY_MODEL`, matching every other Node worker's
|
|
887
|
+
// `*_RELAY_MODEL`. Without it here the model would silently drop to the
|
|
888
|
+
// runtime default on the tmux path while working on the detached path,
|
|
889
|
+
// which inherits the whole environment.
|
|
890
|
+
'COPILOT_RELAY_MODEL',
|
|
891
|
+
'COPILOT_PROVIDER_MAX_PROMPT_TOKENS',
|
|
892
|
+
'COPILOT_PROVIDER_MAX_OUTPUT_TOKENS',
|
|
893
|
+
// Without this the tmux path would fall back to ~/.claude while the relay
|
|
894
|
+
// (and the detached spawn path, which inherits the full environment) uses
|
|
895
|
+
// the override — worker and relay would read different config roots.
|
|
896
|
+
'CLAUDE_CONFIG_DIR',
|
|
897
|
+
// Same divergence, one variable over: the relay binds a resolved `claude`
|
|
898
|
+
// binary onto its own environment (cli-install-service.mjs §4.3), and the
|
|
899
|
+
// detached path inherits it for free. Without it here a bound binary would
|
|
900
|
+
// be silently dropped on the tmux path and the worker would fall back to
|
|
901
|
+
// whatever `claude` PATH happens to resolve — exactly the failure mode
|
|
902
|
+
// CLAUDE_CONFIG_DIR above was added for. GROK_CLI_COMMAND, its Grok
|
|
903
|
+
// counterpart, has always been on this list.
|
|
904
|
+
'CLAUDE_CODE_EXECUTABLE',
|
|
905
|
+
'CLAUDE_RELAY_MODEL',
|
|
906
|
+
// The Claude Cloud worker's model. Its login is not listed here: the
|
|
907
|
+
// worker reads the CLI's credentials file (CLAUDE_CONFIG_DIR above tells
|
|
908
|
+
// it where), and a relay-level CLAUDE_CODE_OAUTH_TOKEN is a secret
|
|
909
|
+
// (workerSecretEnvVarsFor), so it comes through the secret file.
|
|
910
|
+
'CLAUDE_CLOUD_RELAY_MODEL',
|
|
911
|
+
// Tests point the relay's cloud client at a fake API on this machine;
|
|
912
|
+
// the worker's client has to follow, or the relay would talk to the fake
|
|
913
|
+
// and its worker to the real API. Not a secret, and harmless as a value:
|
|
914
|
+
// shared/claude-cloud/base-url.mjs ignores everything but a loopback URL.
|
|
915
|
+
'OAR_CLAUDE_CLOUD_API_BASE_URL',
|
|
916
|
+
'CURSOR_RELAY_MODEL',
|
|
917
|
+
'CURSOR_AGENT_STORE_DIR',
|
|
918
|
+
// The DeepSeek worker's model, endpoint and Claude Code state directory.
|
|
919
|
+
// Its key is a secret (workerSecretEnvVarsFor) and comes through the file.
|
|
920
|
+
'DEEPSEEK_RELAY_MODEL',
|
|
921
|
+
'DEEPSEEK_BASE_URL',
|
|
922
|
+
'DEEPSEEK_CLAUDE_CONFIG_DIR',
|
|
923
|
+
// The Claude API worker's model; its key comes through the file like
|
|
924
|
+
// DeepSeek's. Tests point the worker's CLI at a fake Anthropic API on this
|
|
925
|
+
// machine the way they point the cloud client (shared/anthropic-api.mjs
|
|
926
|
+
// takes a loopback URL only, and so does the worker).
|
|
927
|
+
'CLAUDE_API_RELAY_MODEL',
|
|
928
|
+
'OAR_ANTHROPIC_API_BASE_URL',
|
|
929
|
+
'GROK_RELAY_MODEL',
|
|
930
|
+
'GROK_CLI_COMMAND',
|
|
931
|
+
'GROK_ALWAYS_APPROVE',
|
|
932
|
+
// The OAR MCP server kill switch (the Grok worker reads it too), and the
|
|
933
|
+
// extension engine's "it is attached" marker.
|
|
934
|
+
'OAR_MCP_SERVER',
|
|
935
|
+
'OAR_MCP_SERVER_ATTACHED',
|
|
936
|
+
'SESSION_ID',
|
|
937
|
+
'COPILOT_WORKSPACE_ROOT',
|
|
938
|
+
'INIT_CWD',
|
|
939
|
+
]);
|
|
940
|
+
|
|
700
941
|
export function buildTmuxWorkerShellCommand(targetSessionId, env = {}, {
|
|
701
942
|
secretEnvFilePath = '',
|
|
702
943
|
workerLogPath = '',
|
|
@@ -715,80 +956,41 @@ export function buildTmuxWorkerShellCommand(targetSessionId, env = {}, {
|
|
|
715
956
|
throw new Error('worker-secret-env-file-required');
|
|
716
957
|
}
|
|
717
958
|
const exports = [];
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
'GITHUB_COPILOT_PROMPT_MODE_EXTENSIONS',
|
|
721
|
-
'COPILOT_WEB_RELAY_ROOT',
|
|
722
|
-
'COPILOT_WEB_RELAY_SERVER_DIR',
|
|
723
|
-
'COPILOT_WEB_RELAY_CONFIG',
|
|
724
|
-
'COPILOT_WEB_RELAY_TOOLS',
|
|
725
|
-
'COPILOT_WEB_RELAY_LOG_DIR',
|
|
726
|
-
// The Cursor worker keeps its agent stores in the relay's data dir
|
|
727
|
-
// (applyCursorProviderEnvironment).
|
|
728
|
-
'COPILOT_WEB_RELAY_DATA_DIR',
|
|
729
|
-
'COPILOT_WEB_RELAY_CLI_EXECUTABLE',
|
|
730
|
-
'COPILOT_WEB_RELAY_EXTENSION_BOOTSTRAP_PATH',
|
|
731
|
-
'COPILOT_SDK_PATH',
|
|
732
|
-
'EXTENSION_PATH',
|
|
733
|
-
'COPILOT_PROVIDER_TYPE',
|
|
734
|
-
'COPILOT_PROVIDER_BASE_URL',
|
|
735
|
-
'COPILOT_PROVIDER_WIRE_API',
|
|
736
|
-
'COPILOT_MODEL',
|
|
737
|
-
'COPILOT_WEB_RELAY_WORKER_KIND',
|
|
738
|
-
// The SDK worker's own model and BYOK context overrides. `COPILOT_MODEL`
|
|
739
|
-
// above is the extension engine's model variable and is read by the CLI's
|
|
740
|
-
// startup layer; the SDK worker takes its default model from
|
|
741
|
-
// `COPILOT_RELAY_MODEL`, matching every other Node worker's
|
|
742
|
-
// `*_RELAY_MODEL`. Without it here the model would silently drop to the
|
|
743
|
-
// runtime default on the tmux path while working on the detached path,
|
|
744
|
-
// which inherits the whole environment.
|
|
745
|
-
'COPILOT_RELAY_MODEL',
|
|
746
|
-
'COPILOT_PROVIDER_MAX_PROMPT_TOKENS',
|
|
747
|
-
'COPILOT_PROVIDER_MAX_OUTPUT_TOKENS',
|
|
748
|
-
// Without this the tmux path would fall back to ~/.claude while the relay
|
|
749
|
-
// (and the detached spawn path, which inherits the full environment) uses
|
|
750
|
-
// the override — worker and relay would read different config roots.
|
|
751
|
-
'CLAUDE_CONFIG_DIR',
|
|
752
|
-
// Same divergence, one variable over: the relay binds a resolved `claude`
|
|
753
|
-
// binary onto its own environment (cli-install-service.mjs §4.3), and the
|
|
754
|
-
// detached path inherits it for free. Without it here a bound binary would
|
|
755
|
-
// be silently dropped on the tmux path and the worker would fall back to
|
|
756
|
-
// whatever `claude` PATH happens to resolve — exactly the failure mode
|
|
757
|
-
// CLAUDE_CONFIG_DIR above was added for. GROK_CLI_COMMAND, its Grok
|
|
758
|
-
// counterpart, has always been on this list.
|
|
759
|
-
'CLAUDE_CODE_EXECUTABLE',
|
|
760
|
-
'CLAUDE_RELAY_MODEL',
|
|
761
|
-
// The Claude Cloud worker's model. Its login is not listed here: the
|
|
762
|
-
// worker reads the CLI's credentials file (CLAUDE_CONFIG_DIR above tells
|
|
763
|
-
// it where), and a relay-level CLAUDE_CODE_OAUTH_TOKEN is a secret
|
|
764
|
-
// (workerSecretEnvVarsFor), so it comes through the secret file.
|
|
765
|
-
'CLAUDE_CLOUD_RELAY_MODEL',
|
|
766
|
-
// Tests point the relay's cloud client at a fake API on this machine;
|
|
767
|
-
// the worker's client has to follow, or the relay would talk to the fake
|
|
768
|
-
// and its worker to the real API. Not a secret, and harmless as a value:
|
|
769
|
-
// shared/claude-cloud/base-url.mjs ignores everything but a loopback URL.
|
|
770
|
-
'OAR_CLAUDE_CLOUD_API_BASE_URL',
|
|
771
|
-
'CURSOR_RELAY_MODEL',
|
|
772
|
-
'CURSOR_AGENT_STORE_DIR',
|
|
773
|
-
'GROK_RELAY_MODEL',
|
|
774
|
-
'GROK_CLI_COMMAND',
|
|
775
|
-
'GROK_ALWAYS_APPROVE',
|
|
776
|
-
// The OAR MCP server kill switch (the Grok worker reads it too), and the
|
|
777
|
-
// extension engine's "it is attached" marker.
|
|
778
|
-
'OAR_MCP_SERVER',
|
|
779
|
-
'OAR_MCP_SERVER_ATTACHED',
|
|
780
|
-
'SESSION_ID',
|
|
781
|
-
'COPILOT_WORKSPACE_ROOT',
|
|
782
|
-
'INIT_CWD',
|
|
783
|
-
]) {
|
|
959
|
+
const exported = new Set();
|
|
960
|
+
for (const key of TMUX_WORKER_EXPORTED_ENV_VARS) {
|
|
784
961
|
const value = String(launchEnv?.[key] || '').trim();
|
|
785
962
|
if (!value) continue;
|
|
786
963
|
exports.push(`${key}=${shellQuote(value)}`);
|
|
964
|
+
exported.add(key);
|
|
787
965
|
}
|
|
966
|
+
// A new tmux session starts from the server's global environment, which is
|
|
967
|
+
// the environment the server was started with: tmux takes the environment
|
|
968
|
+
// of `new-session` only when it starts the server. So the cleaning of that
|
|
969
|
+
// environment (launchSessionCli) reaches a worker only on the first launch;
|
|
970
|
+
// a server that was started from inside a Claude Code session, or by an
|
|
971
|
+
// older relay, hands every later worker the host session's variables and
|
|
972
|
+
// the first launch's values. The command therefore unsets, before anything
|
|
973
|
+
// else, the host session's variables and every per-launch variable this
|
|
974
|
+
// launch exports no value for (unset of an absent name is a no-op in sh).
|
|
975
|
+
// With them the worker log file a relay started from inside a worker
|
|
976
|
+
// session inherited, and every secret this launch carries no value for —
|
|
977
|
+
// other kinds' and its own alike — which a tmux server started from a shell
|
|
978
|
+
// that exports them keeps: names only. The worker's own secrets come
|
|
979
|
+
// through the secret file alone, sourced after the unset, so a key the
|
|
980
|
+
// launch does carry is restored from there. A second statement then unsets
|
|
981
|
+
// every variable of the host session's families the shell holds, names a
|
|
982
|
+
// fixed list would miss included.
|
|
983
|
+
const ownSecrets = new Set(workerSecretEnvVarsFor(launchEnv));
|
|
984
|
+
const unset = [
|
|
985
|
+
...HOST_CLAUDE_SESSION_ENV_VARS,
|
|
986
|
+
WORKER_LOG_FILE_ENV,
|
|
987
|
+
...TMUX_WORKER_EXPORTED_ENV_VARS.filter((key) => !exported.has(key)),
|
|
988
|
+
...WORKER_SECRET_ENV_VARS.filter((key) => !ownSecrets.has(key) || !String(launchEnv?.[key] ?? '').trim()),
|
|
989
|
+
];
|
|
788
990
|
// `sh -lc` builds PATH from the login profile, not from the relay's; the
|
|
789
991
|
// relay's Node folder goes at its end when it is not on it (see
|
|
790
992
|
// appendNodeDirectoryToPath).
|
|
791
|
-
const statements = exports.map((entry) => `export ${entry};`);
|
|
993
|
+
const statements = [`unset ${unset.join(' ')};`, hostClaudeSessionUnsetStatement(), ...exports.map((entry) => `export ${entry};`)];
|
|
792
994
|
const nodeDirectory = resolveWorkerNodeDirectory(launchEnv);
|
|
793
995
|
if (nodeDirectory) {
|
|
794
996
|
const quotedDir = shellQuote(nodeDirectory);
|
|
@@ -971,10 +1173,13 @@ export async function launchSessionCli({
|
|
|
971
1173
|
workspaceRoot: launchWorkspaceRoot,
|
|
972
1174
|
env,
|
|
973
1175
|
});
|
|
974
|
-
|
|
1176
|
+
// Every path below starts from this environment, so a secret that is not
|
|
1177
|
+
// this kind's, and the host Claude Code session's variables, are gone
|
|
1178
|
+
// before any of them sees it.
|
|
1179
|
+
const launchSessionEnv = withoutHostClaudeSessionVariables(withoutForeignWorkerSecrets({
|
|
975
1180
|
...appendNodeDirectoryToPath(launchEnv, resolveWorkerNodeDirectory(launchEnv, { platform }), { platform }),
|
|
976
1181
|
SESSION_ID: target,
|
|
977
|
-
};
|
|
1182
|
+
}));
|
|
978
1183
|
const explain = (message) => {
|
|
979
1184
|
try { log?.(`worker launcher: session=${target.slice(0, 8)} ${message}`); } catch {}
|
|
980
1185
|
};
|
|
@@ -982,7 +1187,7 @@ export async function launchSessionCli({
|
|
|
982
1187
|
// Set per launch below, never inherited: a relay started from inside a
|
|
983
1188
|
// worker session would otherwise hand its workers that session's log.
|
|
984
1189
|
delete launchSessionEnv[WORKER_LOG_FILE_ENV];
|
|
985
|
-
// The extension engine gets the OAR MCP server (
|
|
1190
|
+
// The extension engine gets the OAR MCP server (relay_tool + preview) the
|
|
986
1191
|
// node workers carry in-process; every launch path appends the same args.
|
|
987
1192
|
const extensionCliArgs = resolveNodeWorkerDescriptor(launchSessionEnv)
|
|
988
1193
|
? []
|
|
@@ -1007,9 +1212,14 @@ export async function launchSessionCli({
|
|
|
1007
1212
|
const tmuxEnv = { ...launchSessionEnv };
|
|
1008
1213
|
delete tmuxEnv.TMUX;
|
|
1009
1214
|
delete tmuxEnv.TMUX_PANE;
|
|
1010
|
-
//
|
|
1011
|
-
//
|
|
1012
|
-
|
|
1215
|
+
// No secret is in the environment tmux is started with (a tmux server
|
|
1216
|
+
// started by this call would keep it as its global one): this kind's go
|
|
1217
|
+
// through the secret file, the others are already gone. Nor is anything
|
|
1218
|
+
// of this launch alone (worker kind, model, folder): the shell command
|
|
1219
|
+
// exports those per session.
|
|
1220
|
+
for (const key of WORKER_SECRET_ENV_VARS) delete tmuxEnv[key];
|
|
1221
|
+
for (const key of TMUX_WORKER_EXPORTED_ENV_VARS) delete tmuxEnv[key];
|
|
1222
|
+
delete tmuxEnv.PWD;
|
|
1013
1223
|
if (allowProcessReuse) {
|
|
1014
1224
|
const existingPanePid = getTmuxPanePid(sessionName, { execFileSyncImpl });
|
|
1015
1225
|
if (existingPanePid && isPidAlive(existingPanePid)) {
|
|
@@ -61,6 +61,16 @@ function normalizeStatus(value) {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
const STEERING_HOLD_REASONS = new Set(['question', 'compaction', 'adoption', 'delivery', 'tool', 'other']);
|
|
64
|
+
// What keeps a Claude worker's CLI from being released for a billing swap.
|
|
65
|
+
const STEERING_RELEASE_HOLDS = new Set(['question', 'compaction', 'turn', 'background']);
|
|
66
|
+
const WORKER_KIND_PATTERN = /^[a-z][a-z0-9-]{0,31}$/;
|
|
67
|
+
|
|
68
|
+
// The kind a worker says it is ("claude", "claude-api", "deepseek", ...):
|
|
69
|
+
// lower case, short, or null when it did not say.
|
|
70
|
+
export function normalizeWorkerKind(value) {
|
|
71
|
+
const kind = String(value || '').trim().toLowerCase();
|
|
72
|
+
return WORKER_KIND_PATTERN.test(kind) ? kind : null;
|
|
73
|
+
}
|
|
64
74
|
const STEERING_MESSAGE_ID_MAX_LENGTH = 64;
|
|
65
75
|
const STEERING_CANCELLABLE_IDS_MAX = 50;
|
|
66
76
|
|
|
@@ -122,6 +132,15 @@ export function normalizeWorkerSteeringSnapshot(raw) {
|
|
|
122
132
|
// Only a snapshot that reports a running CLI carries the key.
|
|
123
133
|
const autoCompactWindow = normalizeSteeringAutoCompactWindow(raw.autoCompactWindow);
|
|
124
134
|
if (autoCompactWindow) snapshot.autoCompactWindow = autoCompactWindow;
|
|
135
|
+
// Whether the relay may replace the worker now (a Claude billing swap), and
|
|
136
|
+
// what holds it when not. Only a worker that reports it carries the keys.
|
|
137
|
+
if (typeof raw.releasable === 'boolean') {
|
|
138
|
+
snapshot.releasable = raw.releasable;
|
|
139
|
+
const hold = String(raw.releaseHold || '').trim().toLowerCase();
|
|
140
|
+
snapshot.releaseHold = !raw.releasable && STEERING_RELEASE_HOLDS.has(hold) ? hold : null;
|
|
141
|
+
const tasks = Number(raw.backgroundTaskCount);
|
|
142
|
+
snapshot.backgroundTaskCount = Number.isInteger(tasks) && tasks > 0 ? Math.min(tasks, 999) : 0;
|
|
143
|
+
}
|
|
125
144
|
return snapshot;
|
|
126
145
|
}
|
|
127
146
|
|
|
@@ -140,6 +159,7 @@ function sanitizeState(raw = null) {
|
|
|
140
159
|
retryCount: toNonNegativeInt(base.retryCount, 0),
|
|
141
160
|
lastError: String(base.lastError || '').trim() || null,
|
|
142
161
|
steering: normalizeWorkerSteeringSnapshot(base.steering),
|
|
162
|
+
kind: normalizeWorkerKind(base.kind),
|
|
143
163
|
updatedAt: normalizeTimestamp(base.updatedAt, nowIso),
|
|
144
164
|
createdAt,
|
|
145
165
|
};
|
|
@@ -244,7 +264,14 @@ export function createSessionWorkerRegistry() {
|
|
|
244
264
|
// the old entry into every upsert, so without this a crashed worker's
|
|
245
265
|
// hold outlived it and the composer showed it until a replacement
|
|
246
266
|
// heartbeated — indefinitely, if none ever did.
|
|
247
|
-
|
|
267
|
+
// The kind is the same: a process's own word. An upsert that does not
|
|
268
|
+
// state it keeps the known one, unless the process it described is gone.
|
|
269
|
+
const statesKind = Object.prototype.hasOwnProperty.call(rawState, 'kind');
|
|
270
|
+
if (!statesKind) next.kind = existing?.kind || null;
|
|
271
|
+
if (existing && isWorkerReplacedOrGone(existing, next)) {
|
|
272
|
+
next.steering = null;
|
|
273
|
+
if (!statesKind) next.kind = null;
|
|
274
|
+
}
|
|
248
275
|
clearIndexesForSession(existing);
|
|
249
276
|
bySession.set(state.sdkSessionId, next);
|
|
250
277
|
updateIndexesForSession(next);
|
|
@@ -108,6 +108,9 @@ export function createSessionWorkerWebSocketService({
|
|
|
108
108
|
onDeliverySendFailed = async () => {},
|
|
109
109
|
onWorkerSocketClosed = async () => {},
|
|
110
110
|
requestWork = async () => null,
|
|
111
|
+
// A worker stated its kind ("claude", "claude-api", ...) in a hello or a
|
|
112
|
+
// ready: (sessionId, kind, pid). The registry keeps it.
|
|
113
|
+
onWorkerKind = () => {},
|
|
111
114
|
// Liveness probe for a socket's worker process. A killed worker's socket
|
|
112
115
|
// survives its process by an event-loop turn (the kill path blocks the loop,
|
|
113
116
|
// so this service's timer runs before the socket close lands), and asking for
|
|
@@ -233,6 +236,15 @@ export function createSessionWorkerWebSocketService({
|
|
|
233
236
|
}
|
|
234
237
|
}
|
|
235
238
|
|
|
239
|
+
// The kind a hello or ready states, kept on the socket (a process does not
|
|
240
|
+
// change kind) and reported once per change.
|
|
241
|
+
function noteWorkerKind(meta, payload) {
|
|
242
|
+
const kind = String(payload?.kind || '').trim().toLowerCase();
|
|
243
|
+
if (!/^[a-z][a-z0-9-]{0,31}$/.test(kind) || meta.workerKind === kind) return;
|
|
244
|
+
meta.workerKind = kind;
|
|
245
|
+
try { onWorkerKind(meta.sessionId, kind, meta.pid); } catch {}
|
|
246
|
+
}
|
|
247
|
+
|
|
236
248
|
function noteHeartbeat(meta, reason = 'worker-message') {
|
|
237
249
|
if (!meta?.sessionId) return;
|
|
238
250
|
try {
|
|
@@ -258,6 +270,9 @@ export function createSessionWorkerWebSocketService({
|
|
|
258
270
|
sessionId: meta.sessionId,
|
|
259
271
|
pid: meta.pid,
|
|
260
272
|
reason,
|
|
273
|
+
// What this socket's worker said it is: a Claude chat's row goes only
|
|
274
|
+
// to a worker of its billing.
|
|
275
|
+
workerKind: meta.workerKind || null,
|
|
261
276
|
});
|
|
262
277
|
touchCli();
|
|
263
278
|
if (!pending || typeof pending !== 'object') return false;
|
|
@@ -404,6 +419,7 @@ export function createSessionWorkerWebSocketService({
|
|
|
404
419
|
if (payload.type === 'worker.hello') {
|
|
405
420
|
if (!bindSocketIdentity(ws, meta, payload)) return;
|
|
406
421
|
meta.lastSeenAtMs = nowMsSafe();
|
|
422
|
+
noteWorkerKind(meta, payload);
|
|
407
423
|
// A worker holding deliveries (open question card, compaction) binds
|
|
408
424
|
// its identity with hello but must not be handed work.
|
|
409
425
|
meta.ready = payload.ready !== false;
|
|
@@ -423,6 +439,7 @@ export function createSessionWorkerWebSocketService({
|
|
|
423
439
|
if (payload.type === 'worker.ready') {
|
|
424
440
|
if (!bindSocketIdentity(ws, meta, payload)) return;
|
|
425
441
|
meta.lastSeenAtMs = nowMsSafe();
|
|
442
|
+
noteWorkerKind(meta, payload);
|
|
426
443
|
meta.ready = true;
|
|
427
444
|
meta.lastReadyAt = nowIso();
|
|
428
445
|
noteHeartbeat(meta, String(payload.reason || 'worker-ready'));
|