@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.
Files changed (136) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +14 -87
  2. package/.github/extensions/web-relay/skills/tool-activity.mjs +5 -4
  3. package/CHANGELOG.md +412 -0
  4. package/README.md +15 -13
  5. package/package.json +2 -1
  6. package/server/claude-api-worker/claude-api-backend.mjs +345 -0
  7. package/server/claude-api-worker/claude-api-session-worker.mjs +29 -0
  8. package/server/claude-worker/claude-attachments.mjs +7 -4
  9. package/server/claude-worker/claude-cli-pinned-env.mjs +150 -0
  10. package/server/claude-worker/claude-remote-relay-tool.mjs +3 -3
  11. package/server/claude-worker/claude-sdk-adapter.mjs +199 -15
  12. package/server/claude-worker/claude-session-process.mjs +276 -33
  13. package/server/claude-worker/claude-session-worker-main.mjs +269 -0
  14. package/server/claude-worker/claude-session-worker.mjs +2 -253
  15. package/server/claude-worker/claude-subscription-key-check.mjs +64 -0
  16. package/server/claude-worker/claude-turn-failure.mjs +8 -0
  17. package/server/claude-worker/claude-turn-publisher.mjs +394 -13
  18. package/server/claude-worker/claude-usage-totals.mjs +161 -0
  19. package/server/claude-worker/sdk-message-normalizer.mjs +56 -1
  20. package/server/copilot-worker/copilot-remote-relay-tool.mjs +2 -2
  21. package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
  22. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +1 -1
  23. package/server/copilot-worker/copilot-sdk-session-process.mjs +80 -14
  24. package/server/cursor-worker/cursor-remote-relay-tool.mjs +3 -3
  25. package/server/cursor-worker/cursor-turn-runner.mjs +4 -4
  26. package/server/cursor-worker/sdk-message-normalizer.mjs +2 -2
  27. package/server/db-schema.mjs +96 -1
  28. package/server/deepseek-worker/deepseek-claude-backend.mjs +317 -0
  29. package/server/deepseek-worker/deepseek-session-worker.mjs +37 -0
  30. package/server/grok-worker/acp-client.mjs +1 -1
  31. package/server/grok-worker/grok-turn-runner.mjs +4 -4
  32. package/server/grok-worker/sdk-message-normalizer.mjs +1 -1
  33. package/server/mcp/oar-mcp-server.mjs +11 -5
  34. package/server/migrations/0024-deepseek-usage-totals.mjs +151 -0
  35. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +179 -0
  36. package/server/migrations/0026-deepseek-usage-served-models.mjs +222 -0
  37. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +101 -0
  38. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +99 -0
  39. package/server/migrations/0029-deepseek-balance-rises.mjs +99 -0
  40. package/server/migrations/0030-deepseek-usage-actuals.mjs +123 -0
  41. package/server/migrations/0031-queue-provider.mjs +143 -0
  42. package/server/migrations/0032-runtime-session-billing-switch.mjs +94 -0
  43. package/server/migrations/0033-claude-api-usage-checkpoints.mjs +138 -0
  44. package/server/migrations/0034-claude-api-usage-totals.mjs +112 -0
  45. package/server/public/app/activity-replay-state.mjs +4 -3
  46. package/server/public/app/agent-sessions-settings-ui.js +1 -1
  47. package/server/public/app/agent-sessions-settings.mjs +1 -1
  48. package/server/public/app/api-client.js +105 -0
  49. package/server/public/app/bootstrap.js +243 -29
  50. package/server/public/app/claude-api-settings-ui.js +362 -0
  51. package/server/public/app/claude-billing-toggle-ui.js +224 -0
  52. package/server/public/app/claude-billing-toggle.mjs +112 -0
  53. package/server/public/app/context-tier-options.mjs +10 -2
  54. package/server/public/app/context-usage-view.mjs +1 -1
  55. package/server/public/app/conversation-provider-indicator.mjs +18 -0
  56. package/server/public/app/conversation-view.js +31 -3
  57. package/server/public/app/deepseek-settings-ui.js +342 -0
  58. package/server/public/app/inbox-ui.js +14 -3
  59. package/server/public/app/inbox-view.mjs +1 -1
  60. package/server/public/app/journal-view.js +156 -11
  61. package/server/public/app/model-selector-options.mjs +28 -1
  62. package/server/public/app/model-vision-support.mjs +2 -0
  63. package/server/public/app/new-conversation-model-choice.mjs +21 -0
  64. package/server/public/app/no-conversation-state.mjs +1 -1
  65. package/server/public/app/plan-usage-view.mjs +165 -13
  66. package/server/public/app/provider-readiness.mjs +8 -2
  67. package/server/public/app/relay-error-ctas.mjs +58 -0
  68. package/server/public/app/relay-schedules-ui.js +1 -1
  69. package/server/public/app/settings-modal.js +22 -0
  70. package/server/public/app/settings-tabs.js +1 -1
  71. package/server/public/app/socket-handlers.js +28 -1
  72. package/server/public/app/tool-activity-emoji.mjs +4 -3
  73. package/server/public/app/usage-limit-ui.js +136 -5
  74. package/server/public/index.html +596 -35
  75. package/server/relay-tools.md +4 -4
  76. package/server/repositories/conversation-group-repository.mjs +14 -7
  77. package/server/repositories/message-repository.mjs +7 -0
  78. package/server/repositories/session-repository.mjs +8 -1
  79. package/server/routes/ask-user-routes.mjs +1 -1
  80. package/server/routes/conversation-group-routes.mjs +3 -2
  81. package/server/routes/deepseek-usage-import-routes.mjs +72 -0
  82. package/server/routes/messages-routes.mjs +831 -36
  83. package/server/routes/relay-ask-routes.mjs +3 -3
  84. package/server/routes/relay-event-routes.mjs +2 -2
  85. package/server/routes/relay-schedule-routes.mjs +1 -1
  86. package/server/routes/remote-relay-routes.mjs +3 -3
  87. package/server/routes/sessions-routes.mjs +670 -63
  88. package/server/server-runtime.mjs +476 -26
  89. package/server/services/about-service.mjs +2 -0
  90. package/server/services/background-work-view.mjs +2 -1
  91. package/server/services/claude-api-settings-service.mjs +575 -0
  92. package/server/services/claude-api-usage-service.mjs +361 -0
  93. package/server/services/claude-billing-swap-service.mjs +274 -0
  94. package/server/services/claude-billing-switch-service.mjs +343 -0
  95. package/server/services/claude-session-leftovers.mjs +226 -0
  96. package/server/services/deepseek-balance-service.mjs +102 -0
  97. package/server/services/deepseek-settings-service.mjs +380 -0
  98. package/server/services/deepseek-usage-import.mjs +471 -0
  99. package/server/services/model-variant-catalog-service.mjs +1 -0
  100. package/server/services/plan-usage-claude-api.mjs +445 -0
  101. package/server/services/plan-usage-contract.mjs +100 -5
  102. package/server/services/plan-usage-deepseek.mjs +931 -0
  103. package/server/services/plan-usage-service.mjs +436 -0
  104. package/server/services/provider-model-selection.mjs +4 -2
  105. package/server/services/provider-reasoning-effort.mjs +24 -2
  106. package/server/services/relay-ask-service.mjs +1 -1
  107. package/server/services/relay-event-service.mjs +3 -2
  108. package/server/services/relay-schedule-service.mjs +1 -1
  109. package/server/services/remote-relay-caller-context.mjs +7 -0
  110. package/server/services/remote-relay-dispatcher.mjs +84 -14
  111. package/server/services/remote-relay-registry-service.mjs +1 -1
  112. package/server/services/session-worker-launch-service.mjs +294 -84
  113. package/server/services/session-worker-registry-service.mjs +28 -1
  114. package/server/services/session-worker-websocket-service.mjs +17 -0
  115. package/server/services/usage-limit-pause-service.mjs +55 -4
  116. package/server/services/worker-secret-redaction.mjs +320 -0
  117. package/shared/anthropic-api.mjs +202 -0
  118. package/shared/context-window-fallbacks.mjs +3 -0
  119. package/shared/deepseek-api.mjs +137 -0
  120. package/shared/deepseek-models.mjs +114 -0
  121. package/shared/deepseek-pricing.mjs +146 -0
  122. package/shared/host-claude-session-env.mjs +101 -0
  123. package/shared/model-id.mjs +7 -0
  124. package/shared/preview-tool-core.mjs +1 -1
  125. package/shared/provider-routing.mjs +5 -3
  126. package/shared/relay-asks.mjs +11 -5
  127. package/shared/relay-events.mjs +2 -2
  128. package/shared/relay-schedules.mjs +2 -2
  129. package/shared/remote-relay-contract.mjs +29 -9
  130. package/shared/remote-relay-tool-core.mjs +23 -6
  131. package/shared/steer-settle-failure.mjs +2 -0
  132. package/shared/worker-runtime/api-client.mjs +2 -2
  133. package/shared/worker-runtime/direct-openai-image.mjs +128 -0
  134. package/shared/worker-runtime/heartbeat.mjs +5 -0
  135. package/shared/worker-runtime/worker-log-file.mjs +60 -0
  136. 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 remote_relay wait.
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(['COPILOT_PROVIDER_API_KEY', 'CURSOR_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN']);
605
-
606
- // Secrets that are not every worker's. The two API keys are put into the
607
- // launch environment by their own provider's applier and cleared for every
608
- // other one, so they need no entry. The Claude login token is different: it
609
- // sits in the relay's own environment (a relay run on CLAUDE_CODE_OAUTH_TOKEN
610
- // instead of the CLI's credentials file), so it would otherwise travel with
611
- // every launch. Only the Claude Cloud worker gets it: its cloud client reads
612
- // the login from there exactly as the relay's does (credentials.mjs).
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
- for (const key of [
719
- 'COPILOT_ALLOW_ALL',
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
- const launchSessionEnv = {
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 (remote_relay + preview) the
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
- // What the secret file carries is in no environment tmux is started with
1011
- // (a tmux server started by this call would keep it as its global one).
1012
- for (const key of workerSecretEnvVarsFor(launchSessionEnv)) delete tmuxEnv[key];
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
- if (existing && isWorkerReplacedOrGone(existing, next)) next.steering = null;
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'));