@oar-sh/oar 0.9.15 → 0.9.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +14 -87
  2. package/CHANGELOG.md +305 -0
  3. package/README.md +12 -11
  4. package/package.json +2 -1
  5. package/server/claude-worker/claude-attachments.mjs +7 -4
  6. package/server/claude-worker/claude-remote-relay-tool.mjs +36 -1
  7. package/server/claude-worker/claude-sdk-adapter.mjs +167 -9
  8. package/server/claude-worker/claude-session-process.mjs +131 -33
  9. package/server/claude-worker/claude-session-worker-main.mjs +266 -0
  10. package/server/claude-worker/claude-session-worker.mjs +2 -253
  11. package/server/claude-worker/claude-turn-failure.mjs +8 -0
  12. package/server/claude-worker/claude-turn-publisher.mjs +282 -13
  13. package/server/claude-worker/claude-usage-totals.mjs +108 -0
  14. package/server/claude-worker/sdk-message-normalizer.mjs +55 -0
  15. package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
  16. package/server/copilot-worker/copilot-sdk-session-process.mjs +73 -7
  17. package/server/db-schema.mjs +115 -0
  18. package/server/deepseek-worker/deepseek-claude-backend.mjs +433 -0
  19. package/server/deepseek-worker/deepseek-session-worker.mjs +37 -0
  20. package/server/migrations/0019-relay-event-subscription-options.mjs +106 -0
  21. package/server/migrations/0020-conversation-may-create-sessions.mjs +67 -0
  22. package/server/migrations/0021-conversation-group-agent-marks.mjs +77 -0
  23. package/server/migrations/0022-relay-schedules.mjs +142 -0
  24. package/server/migrations/0023-relay-asks.mjs +141 -0
  25. package/server/migrations/0024-deepseek-usage-totals.mjs +151 -0
  26. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +179 -0
  27. package/server/migrations/0026-deepseek-usage-served-models.mjs +222 -0
  28. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +101 -0
  29. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +99 -0
  30. package/server/migrations/0029-deepseek-balance-rises.mjs +99 -0
  31. package/server/migrations/0030-deepseek-usage-actuals.mjs +123 -0
  32. package/server/public/app/api-client.js +53 -0
  33. package/server/public/app/ask-user-view.js +3 -0
  34. package/server/public/app/bootstrap.js +162 -16
  35. package/server/public/app/context-tier-options.mjs +4 -0
  36. package/server/public/app/context-usage-view.mjs +17 -0
  37. package/server/public/app/conversation-group-order.mjs +35 -0
  38. package/server/public/app/conversation-provider-indicator.mjs +7 -0
  39. package/server/public/app/conversation-view.js +2 -0
  40. package/server/public/app/deepseek-settings-ui.js +342 -0
  41. package/server/public/app/inbox-ui.js +367 -0
  42. package/server/public/app/inbox-view.mjs +370 -0
  43. package/server/public/app/journal-view.js +54 -5
  44. package/server/public/app/model-selector-options.mjs +11 -0
  45. package/server/public/app/model-vision-support.mjs +2 -0
  46. package/server/public/app/no-conversation-state.mjs +1 -1
  47. package/server/public/app/plan-usage-view.mjs +165 -13
  48. package/server/public/app/provider-readiness.mjs +4 -1
  49. package/server/public/app/relay-error-ctas.mjs +8 -0
  50. package/server/public/app/relay-event-subscriptions-ui.js +4 -2
  51. package/server/public/app/relay-event-view.mjs +33 -7
  52. package/server/public/app/relay-schedule-view.mjs +119 -0
  53. package/server/public/app/relay-schedules-ui.js +172 -0
  54. package/server/public/app/settings-modal.js +11 -0
  55. package/server/public/app/settings-tabs.js +1 -1
  56. package/server/public/app/socket-handlers.js +37 -3
  57. package/server/public/app/store.js +18 -5
  58. package/server/public/index.html +567 -35
  59. package/server/relay-tools.md +18 -3
  60. package/server/repositories/conversation-group-repository.mjs +110 -17
  61. package/server/repositories/remote-relay-repository.mjs +20 -2
  62. package/server/repositories/session-repository.mjs +7 -1
  63. package/server/routes/ask-user-routes.mjs +57 -3
  64. package/server/routes/cache-routes.mjs +6 -0
  65. package/server/routes/conversation-group-routes.mjs +19 -3
  66. package/server/routes/deepseek-usage-import-routes.mjs +72 -0
  67. package/server/routes/messages-routes.mjs +195 -18
  68. package/server/routes/relay-ask-routes.mjs +387 -0
  69. package/server/routes/relay-event-routes.mjs +3 -1
  70. package/server/routes/relay-schedule-routes.mjs +57 -0
  71. package/server/routes/sessions-routes.mjs +932 -97
  72. package/server/server-runtime.mjs +203 -11
  73. package/server/services/about-service.mjs +1 -0
  74. package/server/services/cache-rebuild-service.mjs +19 -2
  75. package/server/services/claude-session-leftovers.mjs +226 -0
  76. package/server/services/conversation-preferences-service.mjs +17 -0
  77. package/server/services/deepseek-balance-service.mjs +102 -0
  78. package/server/services/deepseek-settings-service.mjs +380 -0
  79. package/server/services/deepseek-usage-import.mjs +471 -0
  80. package/server/services/model-variant-catalog-service.mjs +1 -0
  81. package/server/services/plan-usage-contract.mjs +100 -5
  82. package/server/services/plan-usage-deepseek.mjs +931 -0
  83. package/server/services/plan-usage-service.mjs +431 -0
  84. package/server/services/provider-model-selection.mjs +1 -0
  85. package/server/services/provider-reasoning-effort.mjs +15 -2
  86. package/server/services/relay-ask-link-service.mjs +174 -0
  87. package/server/services/relay-ask-service.mjs +604 -0
  88. package/server/services/relay-event-service.mjs +589 -73
  89. package/server/services/relay-schedule-service.mjs +596 -0
  90. package/server/services/remote-relay-caller-context.mjs +89 -20
  91. package/server/services/remote-relay-dispatcher.mjs +958 -31
  92. package/server/services/session-worker-launch-service.mjs +233 -82
  93. package/server/services/worker-secret-redaction.mjs +318 -0
  94. package/shared/context-window-fallbacks.mjs +3 -0
  95. package/shared/deepseek-api.mjs +137 -0
  96. package/shared/deepseek-models.mjs +114 -0
  97. package/shared/deepseek-pricing.mjs +146 -0
  98. package/shared/host-claude-session-env.mjs +101 -0
  99. package/shared/model-id.mjs +7 -0
  100. package/shared/preview-tool-core.mjs +1 -1
  101. package/shared/provider-routing.mjs +2 -2
  102. package/shared/relay-asks.mjs +145 -0
  103. package/shared/relay-events.mjs +268 -18
  104. package/shared/relay-schedules.mjs +128 -0
  105. package/shared/remote-relay-contract.mjs +554 -26
  106. package/shared/remote-relay-tool-core.mjs +16 -5
  107. package/shared/steer-settle-failure.mjs +1 -0
  108. package/shared/worker-runtime/direct-openai-image.mjs +128 -0
  109. package/shared/worker-runtime/worker-log-file.mjs +60 -0
@@ -53,7 +53,7 @@ import {
53
53
  STEER_STOPPED_TEXT,
54
54
  } from '../../shared/steer-settle-markers.mjs';
55
55
  import { stripSteerNote, withSteerNoteContent } from '../../shared/steer-note.mjs';
56
- import { isRelayEventDelivery } from '../../shared/relay-events.mjs';
56
+ import { isRelayAutomatedDelivery } from '../../shared/relay-events.mjs';
57
57
  import {
58
58
  stripSlashCommandGuard,
59
59
  stripSlashCommandGuardContent,
@@ -532,6 +532,9 @@ export function createClaudeSessionRunner({
532
532
  // by the first turn to open regardless.
533
533
  compactReplayAdoptionMs = 60_000,
534
534
  continuationRetryDelayMs = 500,
535
+ // How long a delivered turn that opens over a finished continuation waits
536
+ // for that continuation's row to register, so its answer publishes first.
537
+ continuationRegistrationWaitMs = 10_000,
535
538
  // Backoff between attempts to save a consumed steer's settle marker (~31 s
536
539
  // in all); after the last one the row fails terminally instead.
537
540
  settleRetryDelaysMs = [1_000, 2_000, 4_000, 8_000, 8_000, 8_000],
@@ -574,13 +577,30 @@ export function createClaudeSessionRunner({
574
577
  // Records every sub-agent's frames as its timeline, run-scoped (see
575
578
  // subagent-timeline-recorder.mjs). Injectable for tests.
576
579
  subagentTimelineRecorder = null,
580
+ // Another endpoint for the CLI (../deepseek-worker/deepseek-claude-backend.mjs):
581
+ // its spawn environment, model ids and effort, and what it reports. null is
582
+ // Claude through the host's login.
583
+ backend = null,
577
584
  dbg = () => {},
578
585
  } = {}) {
586
+ // Neither Claude's plan windows nor its usage limit exist on another backend.
587
+ const readPlanUsageForTurn = backend && !backend.reportsPlanUsage ? async () => null : readPlanUsageImpl;
579
588
  const timelineRecorder = subagentTimelineRecorder || createSubagentTimelineRecorder({ api, conversationId: sdkSessionId, dbg });
580
589
  const publisher = createClaudeTurnPublisher({
581
590
  api,
582
591
  dbg,
583
592
  takeWorkflowRuns: () => drainSettledWorkflowRuns(),
593
+ ...(backend
594
+ ? {
595
+ describeFailure: backend.describeFailure,
596
+ runtimeLabel: backend.label || 'DeepSeek',
597
+ failureCodePrefix: backend.id || 'deepseek',
598
+ reportsPlanUsage: backend.reportsPlanUsage !== false,
599
+ usageRoute: backend.usageRoute || null,
600
+ usageModelKey: backend.relayModel,
601
+ relayModel: (value) => (value ? backend.relayModel(value) : value),
602
+ }
603
+ : {}),
584
604
  });
585
605
  // `remote_relay` is registered only while this relay has paired remotes.
586
606
  // Asked once when the worker starts, and again whenever a CLI process winds
@@ -591,7 +611,9 @@ export function createClaudeSessionRunner({
591
611
  void remoteRelayGate.refresh();
592
612
  // The subscription's usage limit as the CLI reports it. Runner-level: the
593
613
  // limit is the account's, not the CLI process's.
594
- const usageLimit = createUsageLimitTracker();
614
+ const usageLimit = backend && !backend.reportsUsageLimit
615
+ ? { observe: () => null, classifyResult: () => null, classifyException: () => null }
616
+ : createUsageLimitTracker();
595
617
  const resolveWorkflowSessionDir = resolveWorkflowSessionDirImpl || createDefaultWorkflowSessionDirResolver();
596
618
  let claudeNativeSessionId = '';
597
619
  let proc = null;
@@ -657,13 +679,19 @@ export function createClaudeSessionRunner({
657
679
  // Row id → the latest in-flight /api/queue-consumed POST touching it.
658
680
  const consumedMarkChains = new Map();
659
681
 
660
- async function persistNativeSessionId(conversationId, sessionId) {
682
+ /**
683
+ * `cliStartedAt` (ms) is a backend CLI's spawn time, sent at its first
684
+ * init even when the session id is the known one: the relay records each
685
+ * CLI start of a DeepSeek session to find its shell snapshot on delete.
686
+ */
687
+ async function persistNativeSessionId(conversationId, sessionId, { cliStartedAt = null } = {}) {
661
688
  const normalized = String(sessionId || '').trim();
662
- if (!normalized || normalized === claudeNativeSessionId) return;
689
+ if (!normalized || (normalized === claudeNativeSessionId && !cliStartedAt)) return;
663
690
  try {
664
691
  await api('POST', '/api/claude-native-session', {
665
692
  conversationId,
666
693
  claudeNativeSessionId: normalized,
694
+ ...(cliStartedAt ? { cliStartedAt } : {}),
667
695
  });
668
696
  // Only cache after the server accepted it, so a failed persist is
669
697
  // retried on the next init — resume across worker restarts depends on
@@ -678,7 +706,8 @@ export function createClaudeSessionRunner({
678
706
  // Per-message model wins so the composer can switch Claude models between
679
707
  // turns; the conversation's provider model and worker default are fallbacks.
680
708
  const requestedModel = String(message.model || '').trim();
681
- const perTurnModel = requestedModel.toLowerCase() !== 'auto' && requestedModel.toLowerCase().startsWith('claude-')
709
+ const ownModel = backend ? backend.isModel(requestedModel) : requestedModel.toLowerCase().startsWith('claude-');
710
+ const perTurnModel = requestedModel.toLowerCase() !== 'auto' && ownModel
682
711
  ? requestedModel
683
712
  : '';
684
713
  return perTurnModel
@@ -813,6 +842,12 @@ export function createClaudeSessionRunner({
813
842
  modelUsage: null,
814
843
  },
815
844
  planBoardPosted: false,
845
+ // The CLI process the turn ran in, as its usage report names it: taken
846
+ // from the process here and again when the context is made active, and
847
+ // read from the context at finalize — a late continuation flush
848
+ // finalizes after the process died, when `proc` is null or a newer one.
849
+ runId: proc?.runId || '',
850
+ resumedSessionId: proc?.resumedSessionId || '',
816
851
  // Set only on a context attached by post-compaction adoption, and only
817
852
  // until that turn produces output — see handBackAdoptedContext.
818
853
  adoptedFromCompaction: false,
@@ -846,6 +881,8 @@ export function createClaudeSessionRunner({
846
881
 
847
882
  function activateContext(ctx) {
848
883
  proc.activeCtx = ctx;
884
+ ctx.runId = proc.runId || '';
885
+ ctx.resumedSessionId = proc.resumedSessionId || '';
849
886
  proc.lastBoundary = null;
850
887
  proc.continuationInitPending = false;
851
888
  proc.notificationPendingAt = 0;
@@ -1173,7 +1210,7 @@ export function createClaudeSessionRunner({
1173
1210
  }
1174
1211
 
1175
1212
  /** Wait until a continuation context has its relay queue row (or gave up). */
1176
- async function waitForContextRegistration(ctx, timeoutMs = 10_000) {
1213
+ async function waitForContextRegistration(ctx, timeoutMs = continuationRegistrationWaitMs) {
1177
1214
  const deadline = Date.now() + timeoutMs;
1178
1215
  while (!ctx.registered && !ctx.discarded && Date.now() < deadline) {
1179
1216
  await new Promise((resolve) => setTimeout(resolve, 50));
@@ -1432,9 +1469,13 @@ export function createClaudeSessionRunner({
1432
1469
  ctx.finalized = true;
1433
1470
  controlPoller?.stop?.(ctx.controlState);
1434
1471
  // A late continuation flush can land after the process died; usage reads
1435
- // and model fallbacks must survive that.
1472
+ // and model fallbacks must survive that. The process the report names is
1473
+ // the context's own (set when it was made active), never the one that
1474
+ // happens to be current now.
1436
1475
  const turnRef = proc?.turn || null;
1437
1476
  const procModel = proc?.model || null;
1477
+ const runId = ctx.runId || '';
1478
+ const resumedSessionId = ctx.resumedSessionId || '';
1438
1479
  // Steers pushed into this turn and still unsettled: the CLI has their
1439
1480
  // prompts, so the relay marks them consumed with this turn's commit and
1440
1481
  // never re-runs them even if this worker dies before settling them.
@@ -1461,12 +1502,12 @@ export function createClaudeSessionRunner({
1461
1502
  await publisher.publishFinalStream(ctx.message, ctx.state.lastStreamedText);
1462
1503
  const [contextUsage, planUsage] = await Promise.all([
1463
1504
  readContextUsageImpl(turnRef, dbg),
1464
- readPlanUsageImpl(turnRef, dbg),
1505
+ readPlanUsageForTurn(turnRef, dbg),
1465
1506
  ]);
1466
1507
  ctx.state.contextUsage = contextUsage;
1467
1508
  ctx.state.planUsage = planUsage;
1468
1509
  await publisher.publishContextUsage({ message: ctx.message, state: ctx.state, model: procModel, sdkSessionId });
1469
- await publisher.publishPlanUsage({ message: ctx.message, state: ctx.state, sdkSessionId });
1510
+ await publisher.publishPlanUsage({ message: ctx.message, state: ctx.state, sdkSessionId, runId, resumedSessionId });
1470
1511
  })().catch((error) => dbg('stopped-turn publish failed', error?.message || String(error)));
1471
1512
  lingerStoppedRow(ctx, publishChain);
1472
1513
  closeContext(ctx, true);
@@ -1478,13 +1519,13 @@ export function createClaudeSessionRunner({
1478
1519
  // indicator updates with each reply.
1479
1520
  const [contextUsage, planUsage] = await Promise.all([
1480
1521
  readContextUsageImpl(turnRef, dbg),
1481
- readPlanUsageImpl(turnRef, dbg),
1522
+ readPlanUsageForTurn(turnRef, dbg),
1482
1523
  ]);
1483
1524
  ctx.state.contextUsage = contextUsage;
1484
1525
  ctx.state.planUsage = planUsage;
1485
1526
  const responseModel = ctx.state.responseModel || procModel || null;
1486
1527
  await publisher.publishContextUsage({ message: ctx.message, state: ctx.state, model: procModel, sdkSessionId });
1487
- await publisher.publishPlanUsage({ message: ctx.message, state: ctx.state, sdkSessionId });
1528
+ await publisher.publishPlanUsage({ message: ctx.message, state: ctx.state, sdkSessionId, runId, resumedSessionId });
1488
1529
 
1489
1530
  if (ctx.state.result?.isError) {
1490
1531
  await publisher.publishErrorResult({ message: ctx.message, state: ctx.state, responseModel, consumedSteerIds });
@@ -1511,10 +1552,17 @@ export function createClaudeSessionRunner({
1511
1552
  }
1512
1553
  // The transport is gone, so nothing new can be read — but usage already
1513
1554
  // captured on this context (a result seen before the stream died) still
1514
- // reaches the relay. Best-effort by definition of this path.
1555
+ // reaches the relay, under the process the context ran in. Best-effort by
1556
+ // definition of this path.
1515
1557
  if (ctx.state.contextUsage || ctx.state.planUsage) {
1516
1558
  await publisher.publishContextUsage({ message: ctx.message, state: ctx.state, model, sdkSessionId }).catch(() => {});
1517
- await publisher.publishPlanUsage({ message: ctx.message, state: ctx.state, sdkSessionId }).catch(() => {});
1559
+ await publisher.publishPlanUsage({
1560
+ message: ctx.message,
1561
+ state: ctx.state,
1562
+ sdkSessionId,
1563
+ runId: ctx.runId || '',
1564
+ resumedSessionId: ctx.resumedSessionId || '',
1565
+ }).catch(() => {});
1518
1566
  }
1519
1567
  if (ctx.interrupted || aborted) {
1520
1568
  await publisher.publishFinalStream(ctx.message, ctx.state.lastStreamedText);
@@ -1769,7 +1817,13 @@ export function createClaudeSessionRunner({
1769
1817
  const agentLike = SUBAGENT_TASK_TYPES.has(String(task.taskType || ''));
1770
1818
  const pinnedModel = (task.toolUseId && processRef.subagentSpawns.get(task.toolUseId)) || '';
1771
1819
  const activity = (task.toolUseId && processRef.subagentActivity.get(task.toolUseId)) || {};
1772
- const model = agentLike ? (activity.model || pinnedModel || processRef.initModel || null) : null;
1820
+ const knownModel = agentLike ? (activity.model || pinnedModel || processRef.initModel || null) : null;
1821
+ // A backend's CLI names a sub-agent's model in Claude's terms (the
1822
+ // spawn's "opus", a Claude id of a CLI without the alias pins): the
1823
+ // badge shows the backend's model that runs it.
1824
+ const model = knownModel && backend?.subagentModel
1825
+ ? (backend.subagentModel(knownModel, processRef.initModel || processRef.model) || knownModel)
1826
+ : knownModel;
1773
1827
  return {
1774
1828
  taskId,
1775
1829
  taskType: task.taskType,
@@ -2393,7 +2447,9 @@ export function createClaudeSessionRunner({
2393
2447
  // init lands, and skipping that init settled the message with a pointer
2394
2448
  // and dropped its real answer.
2395
2449
  if (!firstInit && proc.pendingDelivered.length) turnStartedForWaitingDeliveries();
2396
- persistNativeSessionId(sdkSessionId, sdkMessage.session_id).catch(() => {});
2450
+ persistNativeSessionId(sdkSessionId, sdkMessage.session_id, {
2451
+ cliStartedAt: backend && firstInit ? proc.spawnedAt : null,
2452
+ }).catch(() => {});
2397
2453
  // Tracked on the process too (not only through the persist round-trip):
2398
2454
  // the workflow poller needs the native id to find the session dir even
2399
2455
  // when the persist call is still in flight or failed.
@@ -2402,7 +2458,9 @@ export function createClaudeSessionRunner({
2402
2458
  // normalizer never sees it — capture the CLI's actual model here and
2403
2459
  // seed it into every context at activation. Without this, a composer
2404
2460
  // set to `auto` published responses with model: null.
2405
- proc.initModel = String(sdkMessage.model || '').trim() || proc.initModel;
2461
+ // A backend's CLI names the model in its own terms ("deepseek-flash[1m]").
2462
+ const initModel = String(sdkMessage.model || '').trim();
2463
+ proc.initModel = (backend && initModel ? backend.relayModel(initModel) : initModel) || proc.initModel;
2406
2464
  // A fresh init with no active turn AND nothing queued to deliver is the CLI
2407
2465
  // opening a turn on its own — a background-task continuation ("you will be
2408
2466
  // notified"). The real SDK does NOT emit a user-message replay for these
@@ -2541,7 +2599,8 @@ export function createClaudeSessionRunner({
2541
2599
  // the composer pinned a model (adaptProcess repins via setModel). An
2542
2600
  // `auto` composer keeps the CLI's own behavior — only the recorded
2543
2601
  // model follows the switch.
2544
- const fallbackModel = String(sdkMessage.fallbackModel || '').trim();
2602
+ const reportedFallback = String(sdkMessage.fallbackModel || '').trim();
2603
+ const fallbackModel = backend && reportedFallback ? backend.relayModel(reportedFallback) : reportedFallback;
2545
2604
  if (fallbackModel) {
2546
2605
  proc.initModel = fallbackModel;
2547
2606
  if (String(sdkMessage.scope || '').trim().toLowerCase() === 'session') {
@@ -3522,10 +3581,7 @@ export function createClaudeSessionRunner({
3522
3581
  }
3523
3582
  continue;
3524
3583
  }
3525
- await finalizeContextOnStreamEnd(ctx, {
3526
- aborted: processRef.aborted,
3527
- model: processRef.model,
3528
- }).catch(() => {});
3584
+ await finalizeContextOnStreamEnd(ctx, { aborted: processRef.aborted, model: processRef.model }).catch(() => {});
3529
3585
  }
3530
3586
  for (const entry of stoppedSteers) {
3531
3587
  await settleFoldedSteeredEntry(entry).catch((error) => {
@@ -3546,10 +3602,12 @@ export function createClaudeSessionRunner({
3546
3602
  if (missingFolder) throw missingFolder;
3547
3603
  const relayMode = message.relayMode || 'agent';
3548
3604
  const model = resolvePerTurnModel(message);
3549
- const effort = normalizeClaudeEffort(message.reasoningEffort);
3605
+ // A backend's effort is its own (DeepSeek: low/high/max), and so is its
3606
+ // thinking switch (DeepSeek: always on).
3607
+ const effort = backend ? backend.effortFor(message.reasoningEffort) : normalizeClaudeEffort(message.reasoningEffort);
3550
3608
  const autoCompactWindow = normalizeAutoCompactWindow(getAutoCompactWindow());
3551
3609
  const thinkingState = getThinking() || {};
3552
- const thinkingEnabled = parseThinkingEnabled(thinkingState.enabled);
3610
+ const thinkingEnabled = backend ? backend.thinkingEnabled(effort) : parseThinkingEnabled(thinkingState.enabled);
3553
3611
  const thinkingDisplay = parseThinkingDisplay(thinkingState.display);
3554
3612
  const attribution = getAttribution() || null;
3555
3613
  const backgroundCommandLimitMs = getBackgroundCommandLimitMs() ?? null;
@@ -3557,6 +3615,13 @@ export function createClaudeSessionRunner({
3557
3615
  const processRef = {
3558
3616
  turn: null,
3559
3617
  abortController,
3618
+ // Names this CLI process in its usage reports: the relay tells a
3619
+ // report that fell behind one of the same process from a process
3620
+ // that started counting over.
3621
+ runId: randomUUID(),
3622
+ // When the CLI was spawned, reported with its first init (see
3623
+ // persistNativeSessionId).
3624
+ spawnedAt: Date.now(),
3560
3625
  model,
3561
3626
  relayMode,
3562
3627
  effort,
@@ -3574,6 +3639,8 @@ export function createClaudeSessionRunner({
3574
3639
  thinkingBudget: null,
3575
3640
  appendClass: modeAppendClass(relayMode),
3576
3641
  permissionMode: permissionModeForRelayMode(relayMode),
3642
+ // What only a fresh CLI of the backend takes (adaptProcess compares it).
3643
+ spawnKey: backend ? backend.spawnKey(message, { model }) : '',
3577
3644
  liveTasks: new Map(),
3578
3645
  knownTasks: new Set(),
3579
3646
  // taskId → the row of a task that left the live set, until its
@@ -3602,6 +3669,8 @@ export function createClaudeSessionRunner({
3602
3669
  // from `resume`, updated on init) — the workflow poller's key into
3603
3670
  // `~/.claude/projects`.
3604
3671
  nativeSessionId: '',
3672
+ // The session the process was spawned to resume ('' for a fresh one).
3673
+ resumedSessionId: '',
3605
3674
  // tool_use id → explicitly pinned model of a subagent spawn block; the
3606
3675
  // task panel's model column resolves through this at publish time.
3607
3676
  subagentSpawns: new Map(),
@@ -3721,6 +3790,10 @@ export function createClaudeSessionRunner({
3721
3790
 
3722
3791
  const resume = String(resumeOverride || message.claudeNativeSessionId || claudeNativeSessionId || '').trim();
3723
3792
  processRef.nativeSessionId = resume;
3793
+ // The session this process continues, for its usage reports: the CLI's
3794
+ // first result then carries that session's earlier totals (see
3795
+ // claude-turn-publisher.mjs, runningCounts).
3796
+ processRef.resumedSessionId = resume;
3724
3797
  // The CLI resolves `resume` inside the project directory for *this* CWD, so
3725
3798
  // a session whose workspace root changed has to bring its transcript along
3726
3799
  // or every turn from here on fails with "No conversation found".
@@ -3752,6 +3825,7 @@ export function createClaudeSessionRunner({
3752
3825
  || '',
3753
3826
  ),
3754
3827
  remoteRelayTool: remoteRelayGate.isEnabled(),
3828
+ backend,
3755
3829
  dbg,
3756
3830
  });
3757
3831
  processRef.lifecycleTimer = setInterval(() => evaluateLifecycle(), lifecyclePollMs);
@@ -3773,6 +3847,11 @@ export function createClaudeSessionRunner({
3773
3847
  try {
3774
3848
  processRef.turn.endInput?.();
3775
3849
  } catch {}
3850
+ // The CLI refused a backend's live flag settings (its key and
3851
+ // endpoint pins, claude-sdk-adapter.mjs): nothing was sent, and the
3852
+ // SDK aborted the query, which reads like a stop. The open turns fail
3853
+ // with that note instead.
3854
+ if (!streamError && processRef.turn?.liveFlagSettingsError) streamError = processRef.turn.liveFlagSettingsError;
3776
3855
  await cleanupProcess(processRef, streamError);
3777
3856
  }
3778
3857
  })();
@@ -3821,7 +3900,18 @@ export function createClaudeSessionRunner({
3821
3900
  async function adaptProcess(message, delivery = {}) {
3822
3901
  const relayMode = message.relayMode || 'agent';
3823
3902
  const model = resolvePerTurnModel(message);
3824
- const effort = normalizeClaudeEffort(message.reasoningEffort);
3903
+ const effort = backend ? backend.effortFor(message.reasoningEffort) : normalizeClaudeEffort(message.reasoningEffort);
3904
+
3905
+ // A backend's effort, and the model its CLI pins Claude Code's aliases
3906
+ // to, are read by the CLI at spawn only: a change takes a fresh process
3907
+ // on the same session, when nothing lives in the old one. Busy, the
3908
+ // delivery runs on the old process (setModel below) and the next
3909
+ // turn-opening one tries again.
3910
+ if (backend && backend.spawnKey(message, { model }) !== proc.spawnKey && canReleaseForRespawn()) {
3911
+ gracefulShutdown('backend-spawn');
3912
+ await drainClosingProcess(10_000);
3913
+ return null;
3914
+ }
3825
3915
 
3826
3916
  // A mode change that would alter the spawn-time system prompt append gets
3827
3917
  // a fresh process — but only when nothing lives in the old one.
@@ -3874,13 +3964,14 @@ export function createClaudeSessionRunner({
3874
3964
  const repinAfterFallback = Boolean(proc.modelFallback) && Boolean(model);
3875
3965
  if (model !== proc.model || repinAfterFallback) {
3876
3966
  expectSettingsReinit();
3877
- await Promise.resolve(proc.turn.setModel?.(model || undefined)).catch((error) => {
3967
+ const cliModel = backend && model ? backend.cliModel(model) : model;
3968
+ await Promise.resolve(proc.turn.setModel?.(cliModel || undefined)).catch((error) => {
3878
3969
  dbg('setModel failed', error?.message || String(error));
3879
3970
  });
3880
3971
  proc.model = model;
3881
3972
  if (model) proc.modelFallback = '';
3882
3973
  }
3883
- if (effort !== proc.effort) {
3974
+ if (!backend && effort !== proc.effort) {
3884
3975
  // 'ultracode' is not an effortLevel the CLI accepts — it maps to the
3885
3976
  // session-scoped ultracode/enableWorkflows flags (with the effort pinned
3886
3977
  // to the xhigh the flag implies); every other change clears those flags
@@ -3922,7 +4013,8 @@ export function createClaudeSessionRunner({
3922
4013
  const thinkingEnabled = parseThinkingEnabled(thinkingState.enabled);
3923
4014
  const thinkingDisplay = parseThinkingDisplay(thinkingState.display);
3924
4015
  let reassertDisplay = false;
3925
- if (thinkingEnabled !== proc.thinkingEnabled) {
4016
+ // A backend's thinking is fixed at spawn (above).
4017
+ if (!backend && thinkingEnabled !== proc.thinkingEnabled) {
3926
4018
  if (thinkingEnabled === true) {
3927
4019
  // Enabling applies live. The display MUST be re-sent afterwards, in
3928
4020
  // this order: a session spawned with thinking off carries no display
@@ -4175,14 +4267,20 @@ export function createClaudeSessionRunner({
4175
4267
  // seconds (transcript load + first request); say so instead of
4176
4268
  // showing bare dots. Posted once here — not in spawnProcess — so a
4177
4269
  // push-race respawn cannot duplicate the line.
4270
+ // In a DeepSeek chat the CLI is Claude Code talking to DeepSeek; the
4271
+ // line names the backend so the wait is not read as a Claude turn.
4272
+ const startingWhat = backend ? `Claude Code for ${backend.label || 'DeepSeek'}` : 'the Claude CLI';
4178
4273
  publisher.postActivity(message, (respawnResume || message.claudeNativeSessionId || claudeNativeSessionId)
4179
- ? 'Starting the Claude CLI (cold start — resuming the session transcript)…'
4180
- : 'Starting the Claude CLI (cold start)…');
4274
+ ? `Starting ${startingWhat} (cold start — resuming the session transcript)…`
4275
+ : `Starting ${startingWhat} (cold start)…`);
4181
4276
  }
4182
4277
  // Released for the window, then joined a process another delivery
4183
4278
  // spawned in the meantime.
4184
4279
  noteWindowRespawn();
4185
- const content = buildClaudeUserContent(message);
4280
+ // A model that takes no image input gets every image by its path.
4281
+ const content = buildClaudeUserContent(message, {
4282
+ embedImages: backend ? backend.acceptsImages(resolvePerTurnModel(message)) : true,
4283
+ });
4186
4284
  for (let attempt = 0; attempt < 2; attempt += 1) {
4187
4285
  const procRef = proc;
4188
4286
  const ctx = createDeliveredContext(message);
@@ -4209,9 +4307,9 @@ export function createClaudeSessionRunner({
4209
4307
  // noted steer no longer starts with "/" and is pushed as noted. The
4210
4308
  // replay signatures strip the guard too, so expectedText stays the
4211
4309
  // user's own text here as well.
4212
- // Nor into an event message the relay wrote: the note speaks as the
4213
- // user, the event must not (shared/relay-events.mjs).
4214
- const pushedContent = withSlashCommandGuardContent(steered && procRef.activeCtx?.kind !== 'continuation' && !isRelayEventDelivery(message)
4310
+ // Nor into a message the relay wrote (an event, a scheduled prompt):
4311
+ // the note speaks as the user, that message must not (shared/relay-events.mjs).
4312
+ const pushedContent = withSlashCommandGuardContent(steered && procRef.activeCtx?.kind !== 'continuation' && !isRelayAutomatedDelivery(message)
4215
4313
  ? withSteerNoteContent(content)
4216
4314
  : content);
4217
4315
  procRef.pendingDelivered.push({ ctx, expectedText: contentText(content), pushedAt: Date.now(), steered });