@oar-sh/oar 0.9.6 → 0.9.7

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 (81) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/README.md +176 -22
  3. package/package.json +1 -1
  4. package/server/README.md +174 -7
  5. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
  6. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
  7. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
  8. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  9. package/server/claude-worker/claude-sdk-adapter.mjs +18 -2
  10. package/server/claude-worker/claude-session-process.mjs +33 -0
  11. package/server/claude-worker/claude-session-worker.mjs +6 -0
  12. package/server/db-schema.mjs +17 -0
  13. package/server/migrations/0006-workspace-root-settings.mjs +67 -0
  14. package/server/migrations/0007-claude-cloud.mjs +82 -0
  15. package/server/public/app/agent-sessions-settings-ui.js +197 -0
  16. package/server/public/app/agent-sessions-settings.mjs +134 -0
  17. package/server/public/app/api-client.js +143 -4
  18. package/server/public/app/attachments-view.js +20 -1
  19. package/server/public/app/attribution-control.mjs +46 -0
  20. package/server/public/app/bootstrap.js +344 -31
  21. package/server/public/app/claude-cloud-conversation-ui.js +110 -0
  22. package/server/public/app/claude-cloud-settings-ui.js +374 -0
  23. package/server/public/app/claude-cloud-ui.mjs +611 -0
  24. package/server/public/app/composer-control-state.mjs +11 -0
  25. package/server/public/app/context-menu.mjs +140 -0
  26. package/server/public/app/context-tier-options.mjs +4 -0
  27. package/server/public/app/conversation-origin-ui.js +27 -0
  28. package/server/public/app/conversation-provider-indicator.mjs +22 -4
  29. package/server/public/app/conversation-view.js +83 -4
  30. package/server/public/app/cwd-picker.js +12 -0
  31. package/server/public/app/journal-view.js +698 -28
  32. package/server/public/app/math-dollars.mjs +89 -0
  33. package/server/public/app/model-selector-options.mjs +1 -1
  34. package/server/public/app/plan-usage-view.mjs +36 -8
  35. package/server/public/app/relay-error-ctas.mjs +16 -2
  36. package/server/public/app/remote-relay-origin-view.mjs +120 -4
  37. package/server/public/app/router.js +49 -12
  38. package/server/public/app/settings-modal.js +33 -2
  39. package/server/public/app/settings-tabs.js +1 -1
  40. package/server/public/app/socket-handlers.js +51 -0
  41. package/server/public/index.html +381 -7
  42. package/server/relay-tools.md +13 -1
  43. package/server/repositories/remote-relay-repository.mjs +42 -1
  44. package/server/repositories/session-repository.mjs +47 -1
  45. package/server/routes/claude-cloud-routes.mjs +140 -0
  46. package/server/routes/git-routes.mjs +37 -0
  47. package/server/routes/messages-routes.mjs +120 -15
  48. package/server/routes/remote-relay-routes.mjs +15 -1
  49. package/server/routes/sessions-routes.mjs +502 -72
  50. package/server/server-runtime.mjs +258 -25
  51. package/server/services/claude-account-usage-service.mjs +176 -0
  52. package/server/services/claude-cloud-repo-service.mjs +408 -0
  53. package/server/services/claude-cloud-session-service.mjs +326 -0
  54. package/server/services/claude-cloud-settings-service.mjs +269 -0
  55. package/server/services/git-remote-service.mjs +115 -0
  56. package/server/services/plan-usage-claude-cloud.mjs +211 -0
  57. package/server/services/plan-usage-claude.mjs +202 -40
  58. package/server/services/plan-usage-contract.mjs +39 -3
  59. package/server/services/plan-usage-service.mjs +82 -2
  60. package/server/services/provider-reasoning-effort.mjs +3 -0
  61. package/server/services/remote-relay-caller-context.mjs +49 -10
  62. package/server/services/remote-relay-client.mjs +9 -0
  63. package/server/services/remote-relay-dispatcher.mjs +383 -35
  64. package/server/services/remote-relay-inbound.mjs +49 -2
  65. package/server/services/remote-relay-loopback.mjs +245 -0
  66. package/server/services/remote-relay-registry-service.mjs +52 -0
  67. package/server/services/session-worker-launch-service.mjs +100 -15
  68. package/server/services/session-worker-process-service.mjs +348 -129
  69. package/server/services/session-worker-stop-service.mjs +24 -13
  70. package/server/services/session-worker-supervisor-service.mjs +56 -1
  71. package/server/services/usage-limit-pause-service.mjs +3 -1
  72. package/server/services/worker-launch-note-service.mjs +151 -0
  73. package/shared/claude-attribution.mjs +117 -0
  74. package/shared/claude-cloud/api-client.mjs +809 -0
  75. package/shared/claude-cloud/base-url.mjs +47 -0
  76. package/shared/claude-cloud/credentials.mjs +221 -0
  77. package/shared/claude-cloud/repo-url.mjs +111 -0
  78. package/shared/provider-routing.mjs +4 -2
  79. package/shared/remote-relay-contract.mjs +153 -22
  80. package/shared/remote-relay-tool-core.mjs +11 -8
  81. package/shared/steer-settle-failure.mjs +1 -0
@@ -39,10 +39,12 @@ import { registerAskUserRoutes } from './routes/ask-user-routes.mjs';
39
39
  import { registerRelayBoardRoutes } from './routes/relay-board-routes.mjs';
40
40
  import { registerCacheRoutes } from './routes/cache-routes.mjs';
41
41
  import { registerGitRoutes } from './routes/git-routes.mjs';
42
+ import { registerClaudeCloudRoutes } from './routes/claude-cloud-routes.mjs';
42
43
  import { registerPreviewRoutes } from './routes/preview-routes.mjs';
43
44
  import { registerRemoteRelayRoutes } from './routes/remote-relay-routes.mjs';
44
45
  import { createRemoteRelayRepository } from './repositories/remote-relay-repository.mjs';
45
46
  import { createRemoteRelayClient } from './services/remote-relay-client.mjs';
47
+ import { createRemoteRelayLoopbackClient } from './services/remote-relay-loopback.mjs';
46
48
  import { createRemoteRelayRegistry } from './services/remote-relay-registry-service.mjs';
47
49
  import { createRemoteRelayPairing } from './services/remote-relay-pairing.mjs';
48
50
  import { createRemoteRelayCallerContext } from './services/remote-relay-caller-context.mjs';
@@ -50,6 +52,14 @@ import { createRemoteRelayDispatcher } from './services/remote-relay-dispatcher.
50
52
  import { createDeleteArchiveService } from './services/delete-archive-service.mjs';
51
53
  import { createStatusEventService } from './services/status-event-service.mjs';
52
54
  import { createClaudeAuthService } from './services/claude-auth-service.mjs';
55
+ import { createClaudeCloudCredentials } from '../shared/claude-cloud/credentials.mjs';
56
+ import { createClaudeCloudClient } from '../shared/claude-cloud/api-client.mjs';
57
+ import { resolveClaudeCloudBaseUrl } from '../shared/claude-cloud/base-url.mjs';
58
+ import { createClaudeCloudSettingsService } from './services/claude-cloud-settings-service.mjs';
59
+ import { createClaudeCloudSessionService } from './services/claude-cloud-session-service.mjs';
60
+ import { createClaudeCloudRepoService, listRecentCloudSourcesFromStatements } from './services/claude-cloud-repo-service.mjs';
61
+ import { createClaudeAccountUsageService } from './services/claude-account-usage-service.mjs';
62
+ import { createGitRemoteService } from './services/git-remote-service.mjs';
53
63
  import { createCliInstallService, writeCliBinariesToConfigFile } from './services/cli-install-service.mjs';
54
64
  import { createGrokAuthService } from './services/grok-auth-service.mjs';
55
65
  import { sweepUnreferencedUploads, UNREFERENCED_UPLOADS_QUERY } from './services/upload-sweep.mjs';
@@ -57,6 +67,8 @@ import webpush from 'web-push';
57
67
  import { createPushDispatchService, ensurePushVapidKeys } from './services/push-dispatch-service.mjs';
58
68
  import { createHostSuspendService } from './services/host-suspend-service.mjs';
59
69
  import { USAGE_LIMIT_ACCOUNT_SOCKET_EVENT, createUsageLimitPauseService } from './services/usage-limit-pause-service.mjs';
70
+ import { buildClaudeAttributionSettings, normalizeClaudeAttributionMode, resolveClaudeAttributionMode, DEFAULT_CLAUDE_ATTRIBUTION_MODE } from '../shared/claude-attribution.mjs';
71
+ import { createWorkerLaunchNoteService } from './services/worker-launch-note-service.mjs';
60
72
  import { createGitHubCiActivityService } from './services/github-ci-activity-service.mjs';
61
73
  import { runHostSuspendToRam } from './services/host-suspend-command.mjs';
62
74
  import { createActiveDeviceTracker } from './services/push-active-device-service.mjs';
@@ -93,7 +105,7 @@ import { createRelayCliLauncherService } from './services/relay-cli-launcher-ser
93
105
  import { createSessionWorkerRegistry } from './services/session-worker-registry-service.mjs';
94
106
  import { createSessionWorkerSupervisor } from './services/session-worker-supervisor-service.mjs';
95
107
  import { createSessionWorkerProcessInspector } from './services/session-worker-process-service.mjs';
96
- import { applyClaudeProviderEnvironment, applyCopilotSdkProviderEnvironment, applyCursorProviderEnvironment, applyGrokProviderEnvironment, applyOpenAIProviderEnvironment, copilotSdkEngineUnavailableReason, killTmuxSession, launchSessionCli } from './services/session-worker-launch-service.mjs';
108
+ import { applyClaudeCloudProviderEnvironment, applyClaudeProviderEnvironment, applyCopilotSdkProviderEnvironment, applyCursorProviderEnvironment, applyGrokProviderEnvironment, applyOpenAIProviderEnvironment, copilotSdkEngineUnavailableReason, killTmuxSession, launchSessionCli } from './services/session-worker-launch-service.mjs';
97
109
  import { createSshTunnelManager } from './services/ssh-tunnel-manager-service.mjs';
98
110
  import { createCloudflaredTunnelManager } from './services/cloudflared-tunnel-service.mjs';
99
111
  import {
@@ -442,6 +454,7 @@ function getClaudeProviderSettings() {
442
454
  enabledModels: models,
443
455
  effortsByModel,
444
456
  modelsDiscovered,
457
+ attributionMode: normalizeClaudeAttributionMode(readAppSettingValue(CLAUDE_ATTRIBUTION_SETTING_KEY)) || DEFAULT_CLAUDE_ATTRIBUTION_MODE,
445
458
  providerType: 'claude',
446
459
  };
447
460
  }
@@ -460,10 +473,19 @@ function setClaudeProviderSettings(update = {}) {
460
473
  if (!isSafeClaudeModelId(normalizedModel)) {
461
474
  return { ok: false, error: 'Invalid Claude model ID' };
462
475
  }
476
+ const attributionMode = payload.attributionMode === undefined
477
+ ? undefined
478
+ : normalizeClaudeAttributionMode(payload.attributionMode);
479
+ if (attributionMode === null) {
480
+ return { ok: false, error: 'Invalid Claude attribution mode' };
481
+ }
463
482
  const nowIso = new Date().toISOString();
464
483
  const nextEnabled = typeof enabled === 'boolean' ? enabled : existing.enabled;
465
484
  stmts.upsertAppSetting.run(CLAUDE_ENABLED_SETTING_KEY, nextEnabled ? 'true' : 'false', nowIso);
466
485
  stmts.upsertAppSetting.run(CLAUDE_MODEL_SETTING_KEY, normalizedModel, nowIso);
486
+ if (attributionMode) {
487
+ stmts.upsertAppSetting.run(CLAUDE_ATTRIBUTION_SETTING_KEY, attributionMode, nowIso);
488
+ }
467
489
  if (Array.isArray(models)) {
468
490
  const normalizedModels = models
469
491
  .map((value) => String(value || '').trim())
@@ -492,9 +514,38 @@ function setClaudeProviderSettings(update = {}) {
492
514
  model: current.model,
493
515
  models: current.models,
494
516
  availableModels: current.availableModels,
517
+ attributionMode: current.attributionMode,
495
518
  };
496
519
  }
497
520
 
521
+ /** The folder override of a workspace root, or null (inherit); tolerant of a missing table. */
522
+ function workspaceRootAttributionMode(rootPath) {
523
+ const root = String(rootPath || '').trim();
524
+ if (!root || typeof stmts?.getWorkspaceRootSettings?.get !== 'function') return null;
525
+ try {
526
+ return normalizeClaudeAttributionMode(stmts.getWorkspaceRootSettings.get(normalizeWorkspaceRootKey(root))?.attribution_mode);
527
+ } catch {
528
+ return null;
529
+ }
530
+ }
531
+
532
+ /**
533
+ * What a Claude session's commits and PRs say: the provider setting, the
534
+ * folder override of the conversation's workspace root on top, and the
535
+ * model's name in the trailer. null = the CLI's own attribution.
536
+ */
537
+ function claudeAttributionForConversation(conv, modelId, { relayFolderFallback = true } = {}) {
538
+ // A Claude Cloud chat works in a sandbox, not in the relay's folder: only
539
+ // the folder it was started from (if any) can speak for it.
540
+ const root = String(conv?.runtime_workspace_root_path || conv?.configured_workspace_root_path || '').trim()
541
+ || (relayFolderFallback ? currentWorkspaceRootPath() : '');
542
+ const mode = resolveClaudeAttributionMode({
543
+ providerMode: getClaudeProviderSettings().attributionMode,
544
+ folderMode: workspaceRootAttributionMode(root),
545
+ });
546
+ return buildClaudeAttributionSettings({ mode, modelId });
547
+ }
548
+
498
549
  const CLAUDE_MODEL_DISCOVERY_TIMEOUT_MS = 20_000;
499
550
 
500
551
  // Discover the models the Claude Agent SDK can serve by asking a short-lived
@@ -1301,6 +1352,9 @@ const CLAUDE_MODEL_SETTING_KEY = 'claude_model';
1301
1352
  const CLAUDE_MODELS_SETTING_KEY = 'claude_models';
1302
1353
  const CLAUDE_ENABLED_MODELS_SETTING_KEY = 'claude_enabled_models';
1303
1354
  const CLAUDE_MODEL_EFFORTS_SETTING_KEY = 'claude_model_efforts';
1355
+ // oar | vanilla | off — what commits and PRs made in Claude sessions say
1356
+ // (shared/claude-attribution.mjs); absent means oar.
1357
+ const CLAUDE_ATTRIBUTION_SETTING_KEY = 'claude_attribution_mode';
1304
1358
  // The set of levels the SDK itself can report/accept as EffortLevel values.
1305
1359
  // Discovery results are filtered through this, so it must NOT contain the
1306
1360
  // relay's derived 'ultracode' sentinel — supportedModels() never reports it
@@ -2482,6 +2536,59 @@ const claudeAuthService = createClaudeAuthService();
2482
2536
  // group.
2483
2537
  const grokAuthService = createGrokAuthService();
2484
2538
 
2539
+ // ─── Claude Cloud (provider `claude-cloud`) ───────────────────────────────────
2540
+ // One credentials reader and one cloud client for the whole relay: the
2541
+ // settings tab (environments), and the archive of a deleted or archived
2542
+ // conversation's cloud session. The reader hands the Claude CLI's login to
2543
+ // the client only; nothing here stores, logs or forwards it, and the workers
2544
+ // read the credentials file themselves (the launch service hands a cloud
2545
+ // worker the login only where the relay itself runs on an environment token,
2546
+ // and then through the secret file, never a plain launch environment).
2547
+ // An expired login is nudged by letting the CLI look at its own status.
2548
+ const claudeCloudCredentials = createClaudeCloudCredentials({
2549
+ nudge: async () => { await claudeAuthService.getStatus({ force: true }); },
2550
+ });
2551
+ // The Anthropic API, unless OAR_CLAUDE_CLOUD_API_BASE_URL names a fake one on
2552
+ // this machine (tests): base-url.mjs ignores every host that is not loopback.
2553
+ const claudeCloudClient = createClaudeCloudClient({
2554
+ credentials: claudeCloudCredentials,
2555
+ baseUrl: resolveClaudeCloudBaseUrl(process.env),
2556
+ });
2557
+ const claudeCloudSettingsService = createClaudeCloudSettingsService({
2558
+ readSetting: readAppSettingValue,
2559
+ writeSetting: (key, value) => stmts.upsertAppSetting.run(key, String(value), new Date().toISOString()),
2560
+ deleteSetting: (key) => stmts.deleteAppSetting.run(key),
2561
+ getClaudeProviderSettings,
2562
+ claudeAuthService,
2563
+ credentials: claudeCloudCredentials,
2564
+ cloud: claudeCloudClient,
2565
+ // A closure: the socket server is created further down the file.
2566
+ emit: (event, payload) => io.emit(event, payload),
2567
+ });
2568
+ function getClaudeCloudProviderSettings() {
2569
+ return claudeCloudSettingsService.getSettings();
2570
+ }
2571
+ const claudeCloudSessionService = createClaudeCloudSessionService({
2572
+ stmts,
2573
+ getCloudClient: () => claudeCloudClient,
2574
+ emit: (event, payload) => io.emit(event, payload),
2575
+ });
2576
+ // The suggestions of the New Chat repository and branch fields: Anthropic's
2577
+ // list of what the Claude GitHub app can reach (while the provider is on),
2578
+ // the repositories of earlier cloud chats, and `git ls-remote` on the host.
2579
+ const claudeCloudRepoService = createClaudeCloudRepoService({
2580
+ cloud: claudeCloudClient,
2581
+ isEnabled: () => claudeCloudSettingsService.getSettings().enabled === true,
2582
+ listRecentCloudSources: () => listRecentCloudSourcesFromStatements(stmts),
2583
+ });
2584
+ // The account's live usage for the Check Usage modal. The Claude Cloud switch
2585
+ // is the consent to use the Claude CLI's login, so nothing is read while it
2586
+ // is off.
2587
+ const claudeAccountUsageService = createClaudeAccountUsageService({
2588
+ cloudClient: claudeCloudClient,
2589
+ isEnabled: () => claudeCloudSettingsService.getSettings().enabled === true,
2590
+ });
2591
+
2485
2592
  // ─── Provider CLI install / binary binding ────────────────────────────────────
2486
2593
  // A resolved CLI path is host state, not conversation state, so it lives beside
2487
2594
  // the port and the token in config.json rather than in the app_settings table
@@ -2584,6 +2691,8 @@ const sessionWorkerRegistry = createSessionWorkerRegistry();
2584
2691
  const sessionWorkerProcessInspector = createSessionWorkerProcessInspector({
2585
2692
  platform: process.platform,
2586
2693
  execFileSyncImpl: execFileSync,
2694
+ execFileImpl: execFile,
2695
+ logger: console,
2587
2696
  });
2588
2697
  function buildSessionWorkerLaunchEnv() {
2589
2698
  const normalizePathValue = (value) => {
@@ -2700,7 +2809,18 @@ function buildSessionWorkerLaunchEnvForSession(targetSessionId) {
2700
2809
  const providerType = String(runtimeSession?.provider_type || 'github').trim().toLowerCase();
2701
2810
  // Clear every provider's variables first (the appliers' kind-guarded deletes
2702
2811
  // keep the chained clears order-independent), then apply only the bound one.
2703
- const cleared = applyCopilotSdkProviderEnvironment(applyGrokProviderEnvironment(applyCursorProviderEnvironment(applyClaudeProviderEnvironment(applyOpenAIProviderEnvironment(sessionWorkerLaunchEnv)))));
2812
+ const cleared = applyClaudeCloudProviderEnvironment(applyCopilotSdkProviderEnvironment(applyGrokProviderEnvironment(applyCursorProviderEnvironment(applyClaudeProviderEnvironment(applyOpenAIProviderEnvironment(sessionWorkerLaunchEnv))))));
2813
+ // A Claude Cloud conversation gets its own worker and nothing else: it must
2814
+ // never reach the Copilot CLI fall-through at the end of this function. The
2815
+ // environment names the worker and the model, no credential.
2816
+ if (providerType === 'claude-cloud') {
2817
+ const cloudSettings = getClaudeCloudProviderSettings();
2818
+ const cloudModel = String(runtimeSession?.provider_model || runtimeSession?.model || cloudSettings.defaultModel).trim();
2819
+ return applyClaudeCloudProviderEnvironment(cleared, {
2820
+ enabled: true,
2821
+ model: cloudModel,
2822
+ });
2823
+ }
2704
2824
  if (providerType === 'claude') {
2705
2825
  const claudeSettings = getClaudeProviderSettings();
2706
2826
  const claudeModel = String(runtimeSession?.provider_model || runtimeSession?.model || claudeSettings.model).trim();
@@ -2773,8 +2893,18 @@ async function spawnSessionWorkerCli(targetSessionId, { allowProcessReuse = true
2773
2893
  if (!normalizedTargetSessionId) {
2774
2894
  throw new Error('missing-target-session-id');
2775
2895
  }
2896
+ // An unreadable process list must not stop every launch on the relay
2897
+ // (2026-10-01: a control byte in one command line did, for an hour).
2898
+ const warnProcessListUnreadable = (error) => {
2899
+ console.warn(`${runtimeLogPrefix()}worker launcher: process list unreadable, launching session=${normalizedTargetSessionId.slice(0, 8)} without the reuse check: ${error?.message || error}`);
2900
+ };
2776
2901
  if (allowProcessReuse) {
2777
- const liveWorker = sessionWorkerProcessInspector.findProcessForSession(normalizedTargetSessionId);
2902
+ let liveWorker = null;
2903
+ try {
2904
+ liveWorker = await sessionWorkerProcessInspector.findProcessForSessionAsync(normalizedTargetSessionId);
2905
+ } catch (error) {
2906
+ warnProcessListUnreadable(error);
2907
+ }
2778
2908
  if (liveWorker?.processId) {
2779
2909
  const workerId = `worker-${normalizedTargetSessionId.slice(0, 8)}`;
2780
2910
  console.log(`${runtimeLogPrefix()}worker launcher: reused ${workerId} session=${normalizedTargetSessionId.slice(0, 8)} pid=${liveWorker.processId}`);
@@ -2792,6 +2922,7 @@ async function spawnSessionWorkerCli(targetSessionId, { allowProcessReuse = true
2792
2922
  execFileSyncImpl: execFileSync,
2793
2923
  processInspector: sessionWorkerProcessInspector,
2794
2924
  allowProcessReuse,
2925
+ onProcessListError: warnProcessListUnreadable,
2795
2926
  });
2796
2927
  const workerPid = Number.isInteger(Number(launched?.pid)) ? Number(launched.pid) : null;
2797
2928
  const workerId = `worker-${normalizedTargetSessionId.slice(0, 8)}`;
@@ -2804,11 +2935,26 @@ async function spawnSessionWorkerCli(targetSessionId, { allowProcessReuse = true
2804
2935
  console.log(`${runtimeLogPrefix()}worker launcher: ${launchVerb} ${workerId} session=${normalizedTargetSessionId.slice(0, 8)} pid=${workerPid || 'none'} mode=${launchMode}${tmuxSessionName ? ` tmux=${tmuxSessionName}` : ''}`);
2805
2936
  return { workerId, pid: workerPid, reused: launched?.reused === true };
2806
2937
  }
2938
+ // The note in the conversation while a session's worker cannot be started
2939
+ // (worker-launch-note-service.mjs). `io` exists only later in boot; the
2940
+ // supervisor reports launch episodes at run time, never during boot.
2941
+ const workerLaunchNoteService = createWorkerLaunchNoteService({
2942
+ db,
2943
+ stmts,
2944
+ emit: (event, payload) => io.emit(event, payload),
2945
+ resolveConversationId: (sdkSessionId) => (
2946
+ stmts.getConvBySdkSessionId?.get?.(sdkSessionId)?.id
2947
+ || sessionWorkerRegistry.getWorker(sdkSessionId)?.conversationId
2948
+ || null
2949
+ ),
2950
+ logger: console,
2951
+ });
2807
2952
  const sessionWorkerSupervisor = createSessionWorkerSupervisor({
2808
2953
  registry: sessionWorkerRegistry,
2809
2954
  spawnWorker: async (sdkSessionId, options = {}) => spawnSessionWorkerCli(sdkSessionId, options),
2810
2955
  diagnosticPlanReference: () => path.join(currentWorkspaceRootPath(), '.cursor', 'plans', 'worker-startup-monitoring-plan.md'),
2811
2956
  log: (message) => console.warn(`${runtimeLogPrefix()}${message}`),
2957
+ onLaunchState: (event) => workerLaunchNoteService.handle(event),
2812
2958
  });
2813
2959
  // Feature flags moved from config.json into app_settings; adopt any legacy
2814
2960
  // `features` key once, then resolve the boot snapshot (defaults < stored rows
@@ -2876,14 +3022,11 @@ async function stopSessionWorkerForProviderRebind(sdkSessionId) {
2876
3022
  const currentWorker = sessionWorkerRegistry?.getWorker?.(sessionId) || null;
2877
3023
  sessionWorkerSupervisor?.markKilled?.(sessionId);
2878
3024
  const cancelledStart = await sessionWorkerSupervisor?.cancelPendingStart?.(sessionId, { wait: true });
3025
+ // Read off the thread; an unreadable list throws, and this caller treats
3026
+ // that as fatal (a rebind next to a process it could not see is worse).
2879
3027
  const discoveredProcesses = process.platform === 'win32'
2880
- ? (
2881
- sessionWorkerProcessInspector?.findWindowsProcessTreeForSession?.(sessionId)
2882
- || sessionWorkerProcessInspector?.findWindowsProcessesForSession?.(sessionId)
2883
- || sessionWorkerProcessInspector?.findProcessesForSession?.(sessionId)
2884
- || []
2885
- )
2886
- : (sessionWorkerProcessInspector?.findProcessesForSession?.(sessionId) || []);
3028
+ ? await sessionWorkerProcessInspector.findWindowsProcessTreeForSessionAsync(sessionId, { fresh: true })
3029
+ : await sessionWorkerProcessInspector.findProcessesForSessionAsync(sessionId, { fresh: true });
2887
3030
  const currentWorkerPid = Number(currentWorker?.pid);
2888
3031
  const hadActiveWorker = discoveredProcesses.some((entry) => isProcessAlive(Number(entry?.processId)))
2889
3032
  || (Number.isInteger(currentWorkerPid) && currentWorkerPid > 0 && isProcessAlive(currentWorkerPid))
@@ -3186,6 +3329,26 @@ function cancelRelayShutdown({ requestedBy = 'localhost-api' } = {}) {
3186
3329
  return { cancelled: true, dropped, ...relayShutdownStatePayload() };
3187
3330
  }
3188
3331
 
3332
+ const countPendingRowsByOwnerSession = db.prepare(`
3333
+ SELECT owner_sdk_session_id AS sdk_session_id, COUNT(*) AS n
3334
+ FROM queue
3335
+ WHERE status = 'pending' AND owner_sdk_session_id IS NOT NULL AND owner_sdk_session_id != ''
3336
+ GROUP BY owner_sdk_session_id
3337
+ `);
3338
+
3339
+ function countPendingRowsOfStoppedSessions() {
3340
+ try {
3341
+ let stalled = 0;
3342
+ for (const row of countPendingRowsByOwnerSession.all()) {
3343
+ const lifecycle = sessionWorkerSupervisor?.getLifecycleState?.(String(row.sdk_session_id || '').trim());
3344
+ if (lifecycle?.retriesStopped === true) stalled += Number(row.n || 0);
3345
+ }
3346
+ return stalled;
3347
+ } catch {
3348
+ return 0;
3349
+ }
3350
+ }
3351
+
3189
3352
  function requestRelayShutdown({ reason = 'manual-request', requestedBy = 'localhost-api', restart = false } = {}) {
3190
3353
  const safeReason = String(reason || 'manual-request').trim().slice(0, 140) || 'manual-request';
3191
3354
  const safeRequestedBy = String(requestedBy || 'localhost-api').trim().slice(0, 80) || 'localhost-api';
@@ -3222,7 +3385,11 @@ function requestRelayShutdown({ reason = 'manual-request', requestedBy = 'localh
3222
3385
  const tryShutdownWhenIdle = () => {
3223
3386
  if (runtimeShutdownStarted) return;
3224
3387
  const counts = queueCounts();
3225
- const queueFree = (counts.pendingCount + counts.processingCount + counts.parkedCount) === 0;
3388
+ // A row queued for a session whose launches the relay has stopped
3389
+ // retrying makes no progress until somebody presses Retry; it must not
3390
+ // hold the restart (which, fresh, is itself the next launch attempt).
3391
+ const stalledPending = countPendingRowsOfStoppedSessions();
3392
+ const queueFree = (counts.pendingCount - stalledPending + counts.processingCount + counts.parkedCount) <= 0;
3226
3393
  if (!queueFree) return;
3227
3394
  const requestInfo = pendingRelayShutdownRequest;
3228
3395
  pendingRelayShutdownRequest = null;
@@ -5858,6 +6025,7 @@ async function requestSessionWorkerSocketDelivery({ sessionId, pid, reason = 'wo
5858
6025
  defaultRelayMode: DEFAULT_RELAY_MODE,
5859
6026
  defaultModel: DEFAULT_MODEL,
5860
6027
  getCursorProviderSettings,
6028
+ getClaudeCloudProviderSettings,
5861
6029
  });
5862
6030
  if (out?.ownerSessionId) {
5863
6031
  const worker = sessionWorkerRegistry?.getWorker?.(out.ownerSessionId) || null;
@@ -5897,6 +6065,19 @@ async function requestSessionWorkerSocketDelivery({ sessionId, pid, reason = 'wo
5897
6065
  // keys are always present here.
5898
6066
  thinkingEnabled: parseThinkingEnabled(conv?.thinking_enabled),
5899
6067
  thinkingDisplay: parseThinkingDisplay(conv?.thinking_display),
6068
+ // What this session's commits and PRs say (Claude and Claude
6069
+ // Cloud): the finished settings object, or null for the CLI's own
6070
+ // attribution. Always present on such a delivery, so the worker can
6071
+ // tell "left as it was" (absent) from "vanilla" (null).
6072
+ ...((() => {
6073
+ const deliveredProvider = String(out.providerType || '').trim().toLowerCase();
6074
+ if (deliveredProvider !== 'claude' && deliveredProvider !== 'claude-cloud') return {};
6075
+ return {
6076
+ attribution: claudeAttributionForConversation(conv, out.providerModel || out.model, {
6077
+ relayFolderFallback: deliveredProvider === 'claude',
6078
+ }),
6079
+ };
6080
+ })()),
5900
6081
  };
5901
6082
  })()),
5902
6083
  },
@@ -5955,19 +6136,21 @@ async function recoverUndeliveredSessionWorkerMessage({ pending = null, sessionI
5955
6136
 
5956
6137
  const WORKER_DEATH_GRACE_MS = 15_000;
5957
6138
 
5958
- function isWorkerSessionProcessAlive(sessionId, hintPid = null) {
6139
+ async function isWorkerSessionProcessAlive(sessionId, hintPid = null) {
5959
6140
  // A merely-retained socket is not proof of life: a dead worker's socket
5960
6141
  // lingers until its close event lands, and counting it stalled recovery.
5961
6142
  // The socket only counts with a recent heartbeat or a PID that probes
5962
6143
  // alive; otherwise fall through to the process probes below.
5963
6144
  if (sessionWorkerWebSocketService?.hasLiveWorkerSocket?.(sessionId)) return true;
5964
- try {
5965
- if (sessionWorkerProcessInspector?.findProcessForSession?.(sessionId)) return true;
5966
- } catch {}
6145
+ // The pids first: they cost nothing. The process list last: on win32 it is
6146
+ // a PowerShell run (off the thread, shared and cached by the inspector).
5967
6147
  const registryPid = Number(sessionWorkerRegistry?.getWorker?.(sessionId)?.pid || 0);
5968
6148
  if (registryPid > 0 && isProcessAlive(registryPid)) return true;
5969
6149
  const pid = Number(hintPid || 0);
5970
6150
  if (pid > 0 && isProcessAlive(pid)) return true;
6151
+ try {
6152
+ if (await sessionWorkerProcessInspector?.findProcessForSessionAsync?.(sessionId)) return true;
6153
+ } catch {}
5971
6154
  return false;
5972
6155
  }
5973
6156
 
@@ -6035,10 +6218,10 @@ function handleWorkerSocketClosed({ sessionId, pid = null } = {}) {
6035
6218
  // when it owned no rows (the hold can outlive them, e.g. a continuation's).
6036
6219
  const heldSteering = Boolean(sessionWorkerRegistry?.getWorker?.(sid)?.steering);
6037
6220
  if (!owned.length && !heldSteering) return;
6038
- const timer = setTimeout(() => {
6221
+ const timer = setTimeout(async () => {
6039
6222
  try {
6040
6223
  if (runtimeShutdownStarted) return;
6041
- if (isWorkerSessionProcessAlive(sid, pid)) return; // reconnect or socket flap
6224
+ if (await isWorkerSessionProcessAlive(sid, pid)) return; // reconnect or socket flap
6042
6225
  sessionWorkerRegistry?.clearSteering?.(sid);
6043
6226
  const stillOwned = stmts.listProcessingRowsForOwner?.all?.(sid) || [];
6044
6227
  if (!stillOwned.length) return;
@@ -6051,15 +6234,28 @@ function handleWorkerSocketClosed({ sessionId, pid = null } = {}) {
6051
6234
  timer.unref?.();
6052
6235
  }
6053
6236
 
6054
- function sweepDeadWorkerProcessingRows() {
6237
+ // One sweep at a time: a slow process list must not pile sweeps up.
6238
+ let deadWorkerSweepInFlight = false;
6239
+ async function sweepDeadWorkerProcessingRows() {
6055
6240
  if (featureFlags?.SESSION_WORKER_ROUTING_ENABLED !== true) return 0;
6056
6241
  if (runtimeShutdownStarted) return 0;
6242
+ if (deadWorkerSweepInFlight) return 0;
6243
+ deadWorkerSweepInFlight = true;
6244
+ try {
6245
+ return await sweepDeadWorkerProcessingRowsOnce();
6246
+ } finally {
6247
+ deadWorkerSweepInFlight = false;
6248
+ }
6249
+ }
6250
+
6251
+ async function sweepDeadWorkerProcessingRowsOnce() {
6057
6252
  const owners = stmts.listProcessingOwnerSessionIds?.all?.(25) || [];
6058
6253
  let recovered = 0;
6059
6254
  for (const owner of owners) {
6060
6255
  const sid = String(owner?.sdk_session_id || '').trim();
6061
6256
  if (!sid) continue;
6062
- if (isWorkerSessionProcessAlive(sid)) continue;
6257
+ // The owners share one list: the inspector caches it for a moment.
6258
+ if (await isWorkerSessionProcessAlive(sid)) continue;
6063
6259
  console.warn(`${runtimeLogPrefix()}WORKER DEAD session=${sid.slice(0, 8)} trigger=sweep — recovering owned turns`);
6064
6260
  recovered += recoverDeadWorkerProcessingRows(sid, 'sweep');
6065
6261
  }
@@ -6083,7 +6279,11 @@ const sessionWorkerWebSocketService = createSessionWorkerWebSocketService({
6083
6279
  pollIntervalMs: 500,
6084
6280
  logger: console,
6085
6281
  });
6086
- runtimeTimers.deadWorkerSweep = setInterval(sweepDeadWorkerProcessingRows, 30_000);
6282
+ runtimeTimers.deadWorkerSweep = setInterval(() => {
6283
+ sweepDeadWorkerProcessingRows().catch((error) => {
6284
+ console.warn(`${runtimeLogPrefix()}WORKER SWEEP failed: ${error?.message || error}`);
6285
+ });
6286
+ }, 30_000);
6087
6287
  if (typeof runtimeTimers.deadWorkerSweep.unref === 'function') runtimeTimers.deadWorkerSweep.unref();
6088
6288
  const tmuxInspectorAccessPolicy = createTmuxInspectorAccessPolicy({
6089
6289
  sessionWorkerRegistry,
@@ -6227,6 +6427,8 @@ const windowsBootAutostartService = createWindowsBootAutostartService({
6227
6427
  });
6228
6428
 
6229
6429
  const gitChangesService = createGitChangesService();
6430
+ // GET /api/git/remote: where a folder lives on GitHub (New Chat, Claude Cloud).
6431
+ const gitRemoteService = createGitRemoteService({ gitChangesService });
6230
6432
 
6231
6433
  // ─── Remote Relays ────────────────────────────────────────────────────────────
6232
6434
  // Other OAR relays this one is paired with, so its agents can work there. Built
@@ -6292,9 +6494,18 @@ const remoteRelayCallerContext = createRemoteRelayCallerContext({
6292
6494
  uuid: uuidv4,
6293
6495
  logger: console,
6294
6496
  });
6497
+ // Agent sessions: this relay as a target of its own agents. Their calls reach
6498
+ // the routes below in-process, the way a paired relay's reach them over HTTP.
6499
+ const remoteRelayLoopbackClient = createRemoteRelayLoopbackClient({
6500
+ handle: (req, res) => app(req, res),
6501
+ getOwnRelayId: () => remoteRelayRegistry.instanceId(),
6502
+ getOwnToken: () => config.authToken,
6503
+ });
6295
6504
  const remoteRelayDispatcher = createRemoteRelayDispatcher({
6296
6505
  registry: remoteRelayRegistry,
6297
6506
  client: remoteRelayClient,
6507
+ localClient: remoteRelayLoopbackClient,
6508
+ getAgentSessionsSettings: () => remoteRelayRegistry.getAgentSessionsSettings(),
6298
6509
  repository: remoteRelayRepository,
6299
6510
  getCallerContext: (conversationId) => remoteRelayCallerContext.getCallerContext(conversationId),
6300
6511
  requestApproval: (request) => remoteRelayCallerContext.requestApproval(request),
@@ -6306,6 +6517,7 @@ const remoteRelayDispatcher = createRemoteRelayDispatcher({
6306
6517
  const sharedRouteDeps = {
6307
6518
  auth,
6308
6519
  gitChangesService,
6520
+ gitRemoteService,
6309
6521
  currentWorkspaceRootPath,
6310
6522
  io,
6311
6523
  db,
@@ -6368,6 +6580,12 @@ const sharedRouteDeps = {
6368
6580
  getGrokProviderSettings,
6369
6581
  setGrokProviderSettings,
6370
6582
  refreshGrokProviderModels,
6583
+ getClaudeCloudProviderSettings,
6584
+ claudeCloudSettingsService,
6585
+ claudeCloudSessionService,
6586
+ claudeCloudRepoService,
6587
+ // The login's state for the workers' nudge route (never the token).
6588
+ claudeCloudLoginStatus: { describe: () => claudeCloudCredentials.describe() },
6371
6589
  reconcileUnstartedConversationProviders,
6372
6590
  rebindUnstartedOpenAIConversationModel,
6373
6591
  getOrCreateConversation,
@@ -6455,6 +6673,7 @@ const sharedRouteDeps = {
6455
6673
  getCursorDashboardTokenSettings,
6456
6674
  setCursorDashboardTokenSettings,
6457
6675
  planUsageService,
6676
+ claudeAccountUsageService,
6458
6677
  getCursorPlanAllowanceSettings,
6459
6678
  setCursorPlanAllowanceSettings,
6460
6679
  getGrokPlanAllowanceSettings,
@@ -6513,6 +6732,7 @@ registerAskUserRoutes(app, sharedRouteDeps);
6513
6732
  registerRelayBoardRoutes(app, sharedRouteDeps);
6514
6733
  registerCacheRoutes(app, sharedRouteDeps);
6515
6734
  registerGitRoutes(app, sharedRouteDeps);
6735
+ registerClaudeCloudRoutes(app, sharedRouteDeps);
6516
6736
  registerPushRoutes(app, {
6517
6737
  auth,
6518
6738
  pushDispatchService,
@@ -6665,7 +6885,16 @@ function auth(req, res, next) {
6665
6885
  function getOrCreateConversation(id, firstLine) {
6666
6886
  const now = new Date().toISOString();
6667
6887
  stmts.insertConv.run(id, (firstLine || 'Untitled').slice(0, 80), now, now);
6668
- return stmts.getConv.get(id);
6888
+ const conv = stmts.getConv.get(id);
6889
+ // A message into an archived conversation (an agent's, through a paired
6890
+ // relay, say) brings it back to the live list: its worker starts again
6891
+ // for the delivery, and an archived chat must not work unseen.
6892
+ if (conv && Number(conv.archived || 0) === 1 && typeof stmts.unarchiveConv?.run === 'function') {
6893
+ stmts.unarchiveConv.run(now, id);
6894
+ io.emit('conversation_archived', { conversationId: id, archived: false });
6895
+ return stmts.getConv.get(id);
6896
+ }
6897
+ return conv;
6669
6898
  }
6670
6899
 
6671
6900
  function ensureRuntimeSessionBinding(
@@ -6715,10 +6944,14 @@ function ensureRuntimeSessionBinding(
6715
6944
  ? 'cursor'
6716
6945
  : (normalizedRequestedProviderType === 'grok'
6717
6946
  ? 'grok'
6718
- : (normalizedRequestedProviderType === 'github'
6719
- ? 'github'
6720
- : (openAISettings?.enabled ? 'openai' : 'github')))));
6721
- const resolvedProviderModel = (resolvedProviderType === 'openai' || resolvedProviderType === 'claude' || resolvedProviderType === 'cursor' || resolvedProviderType === 'grok')
6947
+ // Without this entry a `claude-cloud` request would fall to the
6948
+ // default below and bind a GitHub (Copilot CLI) session.
6949
+ : (normalizedRequestedProviderType === 'claude-cloud'
6950
+ ? 'claude-cloud'
6951
+ : (normalizedRequestedProviderType === 'github'
6952
+ ? 'github'
6953
+ : (openAISettings?.enabled ? 'openai' : 'github'))))));
6954
+ const resolvedProviderModel = (resolvedProviderType === 'openai' || resolvedProviderType === 'claude' || resolvedProviderType === 'cursor' || resolvedProviderType === 'grok' || resolvedProviderType === 'claude-cloud')
6722
6955
  ? (String(providerModel || normalizedModel || '').trim() || null)
6723
6956
  : null;
6724
6957
  try {