@oar-sh/oar 0.9.6 → 0.9.8

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