@oar-sh/oar 0.9.5 → 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 (119) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +12 -32
  2. package/.github/extensions/web-relay/skills/question-bridge.mjs +14 -39
  3. package/CHANGELOG.md +340 -0
  4. package/README.md +196 -26
  5. package/package.json +2 -1
  6. package/server/README.md +174 -7
  7. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
  8. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
  9. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
  10. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  11. package/server/claude-worker/claude-sdk-adapter.mjs +18 -2
  12. package/server/claude-worker/claude-session-process.mjs +151 -1
  13. package/server/claude-worker/claude-session-worker.mjs +6 -0
  14. package/server/claude-worker/claude-turn-failure.mjs +92 -0
  15. package/server/claude-worker/claude-turn-publisher.mjs +52 -3
  16. package/server/claude-worker/sdk-message-normalizer.mjs +13 -1
  17. package/server/copilot-worker/copilot-model-switch.mjs +29 -3
  18. package/server/copilot-worker/copilot-question-bridge.mjs +2 -4
  19. package/server/copilot-worker/copilot-runtime-tree.mjs +91 -0
  20. package/server/copilot-worker/copilot-sdk-adapter.mjs +48 -8
  21. package/server/copilot-worker/copilot-sdk-session-process.mjs +897 -166
  22. package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
  23. package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
  24. package/server/db-schema.mjs +24 -0
  25. package/server/grok-worker/acp-client.mjs +68 -12
  26. package/server/grok-worker/grok-sdk-adapter.mjs +4 -0
  27. package/server/grok-worker/grok-turn-runner.mjs +6 -2
  28. package/server/migrations/0006-workspace-root-settings.mjs +67 -0
  29. package/server/migrations/0007-claude-cloud.mjs +82 -0
  30. package/server/public/app/agent-sessions-settings-ui.js +197 -0
  31. package/server/public/app/agent-sessions-settings.mjs +134 -0
  32. package/server/public/app/api-client.js +160 -6
  33. package/server/public/app/attachments-view.js +20 -1
  34. package/server/public/app/attribution-control.mjs +46 -0
  35. package/server/public/app/bootstrap.js +347 -31
  36. package/server/public/app/claude-cloud-conversation-ui.js +110 -0
  37. package/server/public/app/claude-cloud-settings-ui.js +374 -0
  38. package/server/public/app/claude-cloud-ui.mjs +611 -0
  39. package/server/public/app/composer-control-state.mjs +15 -3
  40. package/server/public/app/context-menu.mjs +140 -0
  41. package/server/public/app/context-tier-options.mjs +4 -0
  42. package/server/public/app/conversation-origin-ui.js +27 -0
  43. package/server/public/app/conversation-provider-indicator.mjs +22 -4
  44. package/server/public/app/conversation-view.js +178 -8
  45. package/server/public/app/cwd-picker.js +12 -0
  46. package/server/public/app/journal-view.js +704 -28
  47. package/server/public/app/markdown-strikethrough.mjs +31 -0
  48. package/server/public/app/math-dollars.mjs +89 -0
  49. package/server/public/app/model-selector-options.mjs +1 -1
  50. package/server/public/app/plan-usage-view.mjs +36 -8
  51. package/server/public/app/relay-board-inline.mjs +70 -0
  52. package/server/public/app/relay-board-view.js +157 -61
  53. package/server/public/app/relay-error-ctas.mjs +28 -5
  54. package/server/public/app/remote-relay-origin-view.mjs +120 -4
  55. package/server/public/app/router.js +49 -12
  56. package/server/public/app/settings-modal.js +33 -2
  57. package/server/public/app/settings-tabs.js +1 -1
  58. package/server/public/app/socket-handlers.js +60 -0
  59. package/server/public/app/store.js +4 -1
  60. package/server/public/app/subagent-fold.mjs +61 -0
  61. package/server/public/app/usage-limit-ui.js +225 -0
  62. package/server/public/index.html +471 -8
  63. package/server/relay-tools.md +13 -1
  64. package/server/repositories/message-repository.mjs +10 -1
  65. package/server/repositories/question-repository.mjs +1 -0
  66. package/server/repositories/remote-relay-repository.mjs +72 -1
  67. package/server/repositories/session-repository.mjs +47 -1
  68. package/server/routes/claude-cloud-routes.mjs +140 -0
  69. package/server/routes/git-routes.mjs +37 -0
  70. package/server/routes/messages-routes.mjs +202 -26
  71. package/server/routes/relay-board-routes.mjs +56 -2
  72. package/server/routes/remote-relay-routes.mjs +15 -1
  73. package/server/routes/sessions-routes.mjs +538 -72
  74. package/server/server-runtime.mjs +291 -27
  75. package/server/services/claude-account-usage-service.mjs +176 -0
  76. package/server/services/claude-cloud-repo-service.mjs +408 -0
  77. package/server/services/claude-cloud-session-service.mjs +326 -0
  78. package/server/services/claude-cloud-settings-service.mjs +269 -0
  79. package/server/services/git-remote-service.mjs +115 -0
  80. package/server/services/plan-usage-claude-cloud.mjs +211 -0
  81. package/server/services/plan-usage-claude.mjs +202 -40
  82. package/server/services/plan-usage-contract.mjs +39 -3
  83. package/server/services/plan-usage-service.mjs +82 -2
  84. package/server/services/preview-proxy-server.mjs +64 -36
  85. package/server/services/process-list-service.mjs +122 -0
  86. package/server/services/provider-reasoning-effort.mjs +3 -0
  87. package/server/services/relay-console-log.mjs +199 -0
  88. package/server/services/remote-relay-caller-context.mjs +78 -19
  89. package/server/services/remote-relay-client.mjs +9 -0
  90. package/server/services/remote-relay-dispatcher.mjs +451 -39
  91. package/server/services/remote-relay-inbound.mjs +83 -1
  92. package/server/services/remote-relay-loopback.mjs +245 -0
  93. package/server/services/remote-relay-registry-service.mjs +52 -0
  94. package/server/services/session-worker-launch-service.mjs +100 -15
  95. package/server/services/session-worker-process-service.mjs +410 -138
  96. package/server/services/session-worker-registry-service.mjs +1 -1
  97. package/server/services/session-worker-stop-service.mjs +24 -13
  98. package/server/services/session-worker-supervisor-service.mjs +89 -2
  99. package/server/services/usage-limit-pause-service.mjs +443 -0
  100. package/server/services/worker-launch-note-service.mjs +151 -0
  101. package/shared/ask-user-bridge.mjs +14 -39
  102. package/shared/claude-attribution.mjs +117 -0
  103. package/shared/claude-cloud/api-client.mjs +809 -0
  104. package/shared/claude-cloud/base-url.mjs +47 -0
  105. package/shared/claude-cloud/credentials.mjs +221 -0
  106. package/shared/claude-cloud/repo-url.mjs +111 -0
  107. package/shared/claude-usage-limit.mjs +257 -0
  108. package/shared/control-poller.mjs +69 -20
  109. package/shared/failure-note-text.mjs +38 -0
  110. package/shared/provider-routing.mjs +4 -2
  111. package/shared/question-wait.mjs +108 -0
  112. package/shared/remote-relay-contract.mjs +167 -22
  113. package/shared/remote-relay-tool-core.mjs +11 -8
  114. package/shared/steer-settle-failure.mjs +17 -0
  115. package/shared/steer-settle-markers.mjs +8 -0
  116. package/shared/worker-runtime/process-tree.mjs +483 -0
  117. package/shared/worker-runtime/relay-errors.mjs +31 -0
  118. package/shared/worker-runtime/send-and-wait-errors.mjs +4 -4
  119. package/shared/worker-runtime/turn-liveness.mjs +249 -0
@@ -20,18 +20,22 @@ function toPidList(rows, extraPid) {
20
20
  .filter((pid) => Number.isInteger(pid) && pid > 0);
21
21
  }
22
22
 
23
- function enumeratePids({ sdkSessionId, worker, processInspector, platform }) {
23
+ // The process list is read off the relay's thread, fresh (a stop must see
24
+ // the children started a moment ago). Throws when the list could not be
25
+ // read: the caller must not pretend it stopped what it could not see.
26
+ async function enumeratePids({ sdkSessionId, worker, processInspector, platform }) {
24
27
  const rows = platform === 'win32'
25
- ? (
26
- processInspector?.findWindowsProcessTreeForSession?.(sdkSessionId)
27
- || processInspector?.findWindowsProcessesForSession?.(sdkSessionId)
28
- || processInspector?.findProcessesForSession?.(sdkSessionId)
29
- || []
30
- )
31
- : (processInspector?.findProcessesForSession?.(sdkSessionId) || []);
32
- return toPidList(rows, worker?.pid);
28
+ ? await processInspector?.findWindowsProcessTreeForSessionAsync?.(sdkSessionId, { fresh: true })
29
+ : await processInspector?.findProcessesForSessionAsync?.(sdkSessionId, { fresh: true });
30
+ return toPidList(rows || [], worker?.pid);
33
31
  }
34
32
 
33
+ async function stopWindowsPids(processInspector, pids) {
34
+ await processInspector?.stopWindowsPidsAsync?.(pids);
35
+ }
36
+
37
+ export const PROCESS_LIST_UNREADABLE = 'process-list-unreadable';
38
+
35
39
  export async function stopSessionWorkerProcesses({
36
40
  sdkSessionId,
37
41
  worker = null,
@@ -61,7 +65,14 @@ export async function stopSessionWorkerProcesses({
61
65
 
62
66
  if (!sid) return done({ ok: false, error: 'Missing session id' });
63
67
 
64
- const pids = enumeratePids({ sdkSessionId: sid, worker, processInspector, platform });
68
+ let pids;
69
+ try {
70
+ pids = await enumeratePids({ sdkSessionId: sid, worker, processInspector, platform });
71
+ } catch (error) {
72
+ // Nothing is stopped blind: the known pid alone would leave the tree
73
+ // running, and the caller would relaunch next to it.
74
+ return done({ ok: false, pids: [], remainingPids: [], error: `${PROCESS_LIST_UNREADABLE}: ${error?.message || error}` });
75
+ }
65
76
  if (!pids.length) return done({ pids: [] });
66
77
 
67
78
  const survivors = (list) => list.filter((pid) => isPidAliveImpl(pid));
@@ -80,7 +91,7 @@ export async function stopSessionWorkerProcesses({
80
91
  if (platform === 'win32') {
81
92
  // stopWindowsPids already walks the descendant tree leaf-first and uses
82
93
  // Stop-Process -Force, so there is no gentler first pass to attempt.
83
- processInspector?.stopWindowsPids?.(pids);
94
+ await stopWindowsPids(processInspector, pids);
84
95
  } else {
85
96
  for (const pid of pids) {
86
97
  try {
@@ -104,9 +115,9 @@ export async function stopSessionWorkerProcesses({
104
115
  try {
105
116
  if (platform === 'win32') {
106
117
  // Re-enumerate: children spawned after the first snapshot are invisible to it.
107
- const rediscovered = enumeratePids({ sdkSessionId: sid, worker, processInspector, platform });
118
+ const rediscovered = await enumeratePids({ sdkSessionId: sid, worker, processInspector, platform });
108
119
  escalationTargets = [...new Set([...remainingPids, ...rediscovered.filter((pid) => isPidAliveImpl(pid))])];
109
- processInspector?.stopWindowsPids?.(escalationTargets);
120
+ await stopWindowsPids(processInspector, escalationTargets);
110
121
  } else {
111
122
  for (const pid of escalationTargets) {
112
123
  try {
@@ -63,6 +63,19 @@ export function createSessionWorkerSupervisor({
63
63
  maxRestartRetries = 5,
64
64
  restartBackoffBaseMs = 1_000,
65
65
  restartBackoffMaxMs = 30_000,
66
+ // Exhausted is not for ever: what failed the launches can be gone — a
67
+ // process list the relay could not read for an hour was (2026-10-01) — and
68
+ // the pending work would otherwise wait for a hand-made launch or a relay
69
+ // restart. One more launch is allowed this long after the last failure; a
70
+ // failure exhausts the session again at once. 0 keeps it exhausted.
71
+ restartExhaustedRetryAfterMs = 60_000,
72
+ // ...and not for longer than this after the first exhaustion: then the
73
+ // session stays exhausted until a launch is asked for (the Retry button,
74
+ // the launch or kill route, a relay restart). 0 retries without end.
75
+ restartExhaustedRetryWindowMs = 10 * 60_000,
76
+ // Told about launch episodes, for the note in the conversation:
77
+ // ({ type: 'exhausted' | 'retry-failed' | 'stopped' | 'launched', sdkSessionId, lifecycle }).
78
+ onLaunchState = null,
66
79
  idleEvictionMs = 0,
67
80
  // Copilot-kind workers boot a full CLI (auth, model catalog, workspace scan)
68
81
  // before their extension can heartbeat; measured median is ~35s. A 30s
@@ -88,6 +101,8 @@ export function createSessionWorkerSupervisor({
88
101
  const retryLimit = Math.max(0, clampInt(maxRestartRetries, 5));
89
102
  const backoffBaseMs = Math.max(1, clampInt(restartBackoffBaseMs, 1_000));
90
103
  const backoffMaxMs = Math.max(backoffBaseMs, clampInt(restartBackoffMaxMs, 30_000));
104
+ const exhaustedRetryAfterMs = Math.max(0, clampInt(restartExhaustedRetryAfterMs, 60_000));
105
+ const exhaustedRetryWindowMs = Math.max(0, clampInt(restartExhaustedRetryWindowMs, 10 * 60_000));
91
106
  const idleTimeoutMs = clampInt(idleEvictionMs, 0);
92
107
  const heartbeatStaleAfterMs = Math.max(1_000, clampInt(heartbeatTimeoutMs, 90_000));
93
108
  const recoveryGraceMs = Math.max(0, clampInt(degradedRecoveryGraceMs, 10_000));
@@ -129,6 +144,10 @@ export function createSessionWorkerSupervisor({
129
144
  backoffMs: 0,
130
145
  nextRestartAtMs: null,
131
146
  restartExhausted: false,
147
+ // The launch episode: when the launches were first exhausted, and
148
+ // whether the relay has stopped trying again by itself.
149
+ exhaustedSinceMs: null,
150
+ retriesStopped: false,
132
151
  lastError: null,
133
152
  lastFailureAtMs: null,
134
153
  lastActivityAtMs: nowAtMs,
@@ -167,6 +186,11 @@ export function createSessionWorkerSupervisor({
167
186
  backoffMs: clampInt(lifecycle.backoffMs, 0),
168
187
  nextRestartAt: lifecycle.nextRestartAtMs ? new Date(lifecycle.nextRestartAtMs).toISOString() : null,
169
188
  restartExhausted: Boolean(lifecycle.restartExhausted),
189
+ exhaustedSince: lifecycle.exhaustedSinceMs ? new Date(lifecycle.exhaustedSinceMs).toISOString() : null,
190
+ retriesStopped: Boolean(lifecycle.retriesStopped),
191
+ retryAgainAt: lifecycle.restartExhausted && !lifecycle.retriesStopped && lifecycle.lastFailureAtMs && exhaustedRetryAfterMs > 0
192
+ ? new Date(lifecycle.lastFailureAtMs + exhaustedRetryAfterMs).toISOString()
193
+ : null,
170
194
  lastError: lifecycle.lastError || null,
171
195
  lastFailureAt: lifecycle.lastFailureAtMs ? new Date(lifecycle.lastFailureAtMs).toISOString() : null,
172
196
  lastActivityAt: lifecycle.lastActivityAtMs ? new Date(lifecycle.lastActivityAtMs).toISOString() : null,
@@ -189,6 +213,16 @@ export function createSessionWorkerSupervisor({
189
213
  };
190
214
  }
191
215
 
216
+ // The caller's listener must not break the supervisor.
217
+ function notifyLaunchState(type, sessionId) {
218
+ if (typeof onLaunchState !== 'function') return;
219
+ try {
220
+ onLaunchState({ type, sdkSessionId: sessionId, lifecycle: toLifecycleSnapshot(sessionId) });
221
+ } catch (error) {
222
+ try { log?.(`launch state listener failed (${type}, session=${String(sessionId).slice(0, 8)}): ${error?.message || error}`); } catch {}
223
+ }
224
+ }
225
+
192
226
  function touchActivity(sessionId, atMs = nowMs()) {
193
227
  return setLifecycle(sessionId, { lastActivityAtMs: atMs });
194
228
  }
@@ -394,16 +428,26 @@ export function createSessionWorkerSupervisor({
394
428
  const failedAtMs = nowMs();
395
429
 
396
430
  if (nextRetryCount > retryLimit) {
431
+ const firstExhaustion = !lifecycle.restartExhausted;
432
+ const exhaustedSinceMs = lifecycle.exhaustedSinceMs || failedAtMs;
433
+ const retriesStopped = exhaustedRetryAfterMs === 0
434
+ || (exhaustedRetryWindowMs > 0 && failedAtMs - exhaustedSinceMs >= exhaustedRetryWindowMs);
397
435
  const exhausted = setLifecycle(sessionId, {
398
436
  retryCount: nextRetryCount,
399
437
  backoffMs: 0,
400
438
  nextRestartAtMs: null,
401
439
  restartExhausted: true,
440
+ exhaustedSinceMs,
441
+ retriesStopped,
402
442
  lastError: message,
403
443
  lastFailureAtMs: failedAtMs,
404
444
  lastActivityAtMs: failedAtMs,
405
445
  });
406
446
  registerDegradedState(sessionId, 'restart-exhausted', { atMs: failedAtMs });
447
+ notifyLaunchState(
448
+ firstExhaustion ? 'exhausted' : (retriesStopped && !lifecycle.retriesStopped ? 'stopped' : 'retry-failed'),
449
+ sessionId,
450
+ );
407
451
  return { scheduled: false, exhausted: true, lifecycle: toLifecycleSnapshot(sessionId, exhausted) };
408
452
  }
409
453
 
@@ -432,6 +476,8 @@ export function createSessionWorkerSupervisor({
432
476
  backoffMs: 0,
433
477
  nextRestartAtMs: null,
434
478
  restartExhausted: false,
479
+ exhaustedSinceMs: null,
480
+ retriesStopped: false,
435
481
  lastError: null,
436
482
  lastFailureAtMs: null,
437
483
  lastActivityAtMs: nowAtMs,
@@ -454,7 +500,13 @@ export function createSessionWorkerSupervisor({
454
500
  const lifecycle = lifecycleBySession.get(sessionId);
455
501
  if (!lifecycle) return { allowed: true, reason: null, lifecycle: null };
456
502
  if (lifecycle.restartExhausted) {
457
- return { allowed: false, reason: 'restart-exhausted', lifecycle: toLifecycleSnapshot(sessionId, lifecycle) };
503
+ const failedAtMs = Number(lifecycle.lastFailureAtMs || 0);
504
+ const dueAgain = !lifecycle.retriesStopped
505
+ && exhaustedRetryAfterMs > 0 && failedAtMs > 0 && nowMs() - failedAtMs >= exhaustedRetryAfterMs;
506
+ if (!dueAgain) {
507
+ return { allowed: false, reason: 'restart-exhausted', lifecycle: toLifecycleSnapshot(sessionId, lifecycle) };
508
+ }
509
+ return { allowed: true, reason: null, lifecycle: toLifecycleSnapshot(sessionId, lifecycle) };
458
510
  }
459
511
  if (lifecycle.nextRestartAtMs && nowMs() < lifecycle.nextRestartAtMs) {
460
512
  return { allowed: false, reason: 'restart-delayed', lifecycle: toLifecycleSnapshot(sessionId, lifecycle) };
@@ -545,6 +597,7 @@ export function createSessionWorkerSupervisor({
545
597
  // backoff/exhausted lifecycle state was left behind by earlier failures.
546
598
  clearRestartSchedule(sessionId, { resetDegradedState: false });
547
599
  assessSessionHealth(sessionId, reusedWorker, { nowAtMs, questionPending: false });
600
+ notifyLaunchState('launched', sessionId);
548
601
  return { ok: true, reused: true, worker: reusedWorker, lifecycle: toLifecycleSnapshot(sessionId) };
549
602
  }
550
603
 
@@ -609,6 +662,7 @@ export function createSessionWorkerSupervisor({
609
662
  });
610
663
  clearRestartSchedule(sessionId);
611
664
  assessSessionHealth(sessionId, readyState, { questionPending: false });
665
+ notifyLaunchState('launched', sessionId);
612
666
  return { ok: true, reused: false, worker: readyState, lifecycle: toLifecycleSnapshot(sessionId) };
613
667
  }
614
668
  const simulatedReady = setWorkerState(sessionId, {
@@ -625,6 +679,7 @@ export function createSessionWorkerSupervisor({
625
679
  });
626
680
  clearRestartSchedule(sessionId);
627
681
  assessSessionHealth(sessionId, simulatedReady, { questionPending: false });
682
+ notifyLaunchState('launched', sessionId);
628
683
  return { ok: true, reused: false, worker: simulatedReady, lifecycle: toLifecycleSnapshot(sessionId) };
629
684
  } catch (error) {
630
685
  const scheduled = scheduleRestart(sessionId, error, { incrementRetry: true });
@@ -778,9 +833,41 @@ export function createSessionWorkerSupervisor({
778
833
  return evicted;
779
834
  }
780
835
 
836
+ /**
837
+ * An idle worker whose process is gone (killed from outside, crashed
838
+ * between turns) leaves the registry, so it is not listed as ready with a
839
+ * pid that no longer exists; the next message starts a new one either way.
840
+ * A worker that is processing, or that a question card waits on, is left
841
+ * to the stale-pid handling, which knows about the work that is open. A
842
+ * heartbeat inside the stale window outranks the pid: the pid on record
843
+ * can be a launcher's. A worker that was stopped from outside therefore
844
+ * stays listed until that window has passed (90 s by default); one that
845
+ * the relay's own kill route stopped leaves the list at once.
846
+ */
847
+ function retireDeadIdleWorkers({ keep = new Set() } = {}) {
848
+ const retired = [];
849
+ const heartbeatCutoff = nowMs() - heartbeatStaleAfterMs;
850
+ for (const worker of registry.listWorkers()) {
851
+ const sessionId = normalizeSessionId(worker.sdkSessionId);
852
+ if (!sessionId || worker.status !== 'ready') continue;
853
+ if (pendingStarts.has(sessionId) || keep.has(sessionId)) continue;
854
+ const pidProbe = checkPidLiveness(worker);
855
+ if (!pidProbe.checked || pidProbe.alive) continue;
856
+ const lifecycle = getOrCreateLifecycle(sessionId);
857
+ if (lifecycle.awaitingHeartbeat) continue;
858
+ if (lifecycle.lastHeartbeatAtMs && lifecycle.lastHeartbeatAtMs > heartbeatCutoff) continue;
859
+ if (!registry.removeWorker(sessionId)) continue;
860
+ lifecycleBySession.delete(sessionId);
861
+ emitMonitorLog(`worker ${worker.workerId || sessionId} was idle and its process (pid ${worker.pid}) is gone; no longer listed`);
862
+ retired.push({ sdkSessionId: sessionId, workerId: worker.workerId || null, pid: worker.pid });
863
+ }
864
+ return retired;
865
+ }
866
+
781
867
  function snapshot({ pendingQuestionSessionIds = null } = {}) {
782
- const workers = registry.listWorkers();
783
868
  const questionPendingSet = normalizePendingQuestionSessionIds(pendingQuestionSessionIds);
869
+ retireDeadIdleWorkers({ keep: questionPendingSet });
870
+ const workers = registry.listWorkers();
784
871
  const workerBySession = new Map();
785
872
  for (const worker of workers) {
786
873
  const sid = normalizeSessionId(worker?.sdkSessionId);