@oar-sh/oar 0.9.5 → 0.9.6

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 (63) 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 +168 -0
  4. package/README.md +20 -4
  5. package/package.json +2 -1
  6. package/server/claude-worker/claude-session-process.mjs +118 -1
  7. package/server/claude-worker/claude-turn-failure.mjs +92 -0
  8. package/server/claude-worker/claude-turn-publisher.mjs +52 -3
  9. package/server/claude-worker/sdk-message-normalizer.mjs +13 -1
  10. package/server/copilot-worker/copilot-model-switch.mjs +29 -3
  11. package/server/copilot-worker/copilot-question-bridge.mjs +2 -4
  12. package/server/copilot-worker/copilot-runtime-tree.mjs +91 -0
  13. package/server/copilot-worker/copilot-sdk-adapter.mjs +48 -8
  14. package/server/copilot-worker/copilot-sdk-session-process.mjs +897 -166
  15. package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
  16. package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
  17. package/server/db-schema.mjs +7 -0
  18. package/server/grok-worker/acp-client.mjs +68 -12
  19. package/server/grok-worker/grok-sdk-adapter.mjs +4 -0
  20. package/server/grok-worker/grok-turn-runner.mjs +6 -2
  21. package/server/public/app/api-client.js +17 -2
  22. package/server/public/app/bootstrap.js +3 -0
  23. package/server/public/app/composer-control-state.mjs +4 -3
  24. package/server/public/app/conversation-view.js +95 -4
  25. package/server/public/app/journal-view.js +6 -0
  26. package/server/public/app/markdown-strikethrough.mjs +31 -0
  27. package/server/public/app/relay-board-inline.mjs +70 -0
  28. package/server/public/app/relay-board-view.js +157 -61
  29. package/server/public/app/relay-error-ctas.mjs +12 -3
  30. package/server/public/app/socket-handlers.js +9 -0
  31. package/server/public/app/store.js +4 -1
  32. package/server/public/app/subagent-fold.mjs +61 -0
  33. package/server/public/app/usage-limit-ui.js +225 -0
  34. package/server/public/index.html +90 -1
  35. package/server/repositories/message-repository.mjs +10 -1
  36. package/server/repositories/question-repository.mjs +1 -0
  37. package/server/repositories/remote-relay-repository.mjs +30 -0
  38. package/server/routes/messages-routes.mjs +85 -14
  39. package/server/routes/relay-board-routes.mjs +56 -2
  40. package/server/routes/sessions-routes.mjs +36 -0
  41. package/server/server-runtime.mjs +33 -2
  42. package/server/services/preview-proxy-server.mjs +64 -36
  43. package/server/services/process-list-service.mjs +122 -0
  44. package/server/services/relay-console-log.mjs +199 -0
  45. package/server/services/remote-relay-caller-context.mjs +37 -17
  46. package/server/services/remote-relay-dispatcher.mjs +72 -8
  47. package/server/services/remote-relay-inbound.mjs +35 -0
  48. package/server/services/session-worker-process-service.mjs +85 -32
  49. package/server/services/session-worker-registry-service.mjs +1 -1
  50. package/server/services/session-worker-supervisor-service.mjs +33 -1
  51. package/server/services/usage-limit-pause-service.mjs +441 -0
  52. package/shared/ask-user-bridge.mjs +14 -39
  53. package/shared/claude-usage-limit.mjs +257 -0
  54. package/shared/control-poller.mjs +69 -20
  55. package/shared/failure-note-text.mjs +38 -0
  56. package/shared/question-wait.mjs +108 -0
  57. package/shared/remote-relay-contract.mjs +16 -2
  58. package/shared/steer-settle-failure.mjs +16 -0
  59. package/shared/steer-settle-markers.mjs +8 -0
  60. package/shared/worker-runtime/process-tree.mjs +483 -0
  61. package/shared/worker-runtime/relay-errors.mjs +31 -0
  62. package/shared/worker-runtime/send-and-wait-errors.mjs +4 -4
  63. package/shared/worker-runtime/turn-liveness.mjs +249 -0
@@ -1,6 +1,8 @@
1
1
  'use strict';
2
2
 
3
- import { execFileSync } from 'child_process';
3
+ import { execFile, execFileSync } from 'child_process';
4
+
5
+ import { collectProcessDescendants, isChildProcessOf } from '../../shared/worker-runtime/process-tree.mjs';
4
6
 
5
7
  function normalizeSessionId(value) {
6
8
  const text = String(value || '').trim();
@@ -51,9 +53,39 @@ function normalizeWindowsProcess(proc) {
51
53
  parentProcessId: Number.isInteger(Number(proc?.parentProcessId)) ? Number(proc.parentProcessId) : null,
52
54
  name: String(proc?.name || ''),
53
55
  commandLine: String(proc?.commandLine || ''),
56
+ createdAt: Number(proc?.createdAt) > 0 ? Number(proc.createdAt) : 0,
54
57
  };
55
58
  }
56
59
 
60
+ // The relay's own session workers: `node <kind>-session-worker.mjs --session-id <id>`.
61
+ function isRelaySessionWorker(proc) {
62
+ return String(proc?.commandLine || '').toLowerCase().includes('-session-worker');
63
+ }
64
+
65
+ // Every process with its parent and the time it was created.
66
+ const WINDOWS_PROCESS_SNAPSHOT_SCRIPT = [
67
+ '$list = Get-CimInstance Win32_Process | ForEach-Object {',
68
+ ' [pscustomobject]@{',
69
+ ' processId = [int]$_.ProcessId;',
70
+ ' parentProcessId = [int]$_.ParentProcessId;',
71
+ ' name = [string]$_.Name;',
72
+ ' commandLine = [string]$_.CommandLine;',
73
+ ' createdAt = $(if ($_.CreationDate) { [int64](($_.CreationDate.ToUniversalTime() - [datetime]"1970-01-01").TotalMilliseconds) } else { [int64]0 });',
74
+ ' }',
75
+ '};',
76
+ '$list | ConvertTo-Json -Depth 3 -Compress',
77
+ ].join(' ');
78
+
79
+ /** How long the process list may take when a worker waits for it. */
80
+ const WINDOWS_PROCESS_SNAPSHOT_TIMEOUT_MS = 10_000;
81
+
82
+ function parseWindowsProcessSnapshot(output) {
83
+ const text = String(output || '').trim();
84
+ if (!text) return [];
85
+ const parsed = JSON.parse(text);
86
+ return Array.isArray(parsed) ? parsed : [parsed];
87
+ }
88
+
57
89
  function looksLikeCopilotWorkerProcess(proc) {
58
90
  const name = String(proc?.name || '').trim().toLowerCase();
59
91
  const cmd = String(proc?.commandLine || '').trim().toLowerCase();
@@ -94,6 +126,7 @@ function looksLikeCopilotWorkerProcess(proc) {
94
126
  export function createSessionWorkerProcessInspector({
95
127
  platform = process.platform,
96
128
  execFileSyncImpl = execFileSync,
129
+ execFileImpl = execFile,
97
130
  } = {}) {
98
131
  function getPosixProcessSnapshot() {
99
132
  if (platform === 'win32') return [];
@@ -119,24 +152,37 @@ export function createSessionWorkerProcessInspector({
119
152
 
120
153
  function getWindowsProcessSnapshot() {
121
154
  if (platform !== 'win32') return [];
122
- const script = [
123
- '$list = Get-CimInstance Win32_Process | ForEach-Object {',
124
- ' [pscustomobject]@{',
125
- ' processId = [int]$_.ProcessId;',
126
- ' parentProcessId = [int]$_.ParentProcessId;',
127
- ' name = [string]$_.Name;',
128
- ' commandLine = [string]$_.CommandLine;',
129
- ' }',
130
- '};',
131
- '$list | ConvertTo-Json -Depth 3 -Compress',
132
- ].join(' ');
133
- const output = execFileSyncImpl('powershell.exe', ['-NoProfile', '-Command', script], {
155
+ const output = execFileSyncImpl('powershell.exe', ['-NoProfile', '-Command', WINDOWS_PROCESS_SNAPSHOT_SCRIPT], {
134
156
  stdio: ['ignore', 'pipe', 'ignore'],
135
157
  });
136
- const text = String(output || '').trim();
137
- if (!text) return [];
138
- const parsed = JSON.parse(text);
139
- return Array.isArray(parsed) ? parsed : [parsed];
158
+ return parseWindowsProcessSnapshot(output);
159
+ }
160
+
161
+ /**
162
+ * The same list without holding the caller's thread for it: a session
163
+ * worker reads it while it serves a conversation, and PowerShell takes about
164
+ * a second to start. Rejects when the list could not be read.
165
+ */
166
+ function readWindowsProcessSnapshot() {
167
+ if (platform !== 'win32') return Promise.resolve([]);
168
+ return new Promise((resolve, reject) => {
169
+ execFileImpl(
170
+ 'powershell.exe',
171
+ ['-NoProfile', '-Command', WINDOWS_PROCESS_SNAPSHOT_SCRIPT],
172
+ { windowsHide: true, timeout: WINDOWS_PROCESS_SNAPSHOT_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024 },
173
+ (error, stdout) => {
174
+ if (error) {
175
+ reject(error);
176
+ return;
177
+ }
178
+ try {
179
+ resolve(parseWindowsProcessSnapshot(stdout).map(normalizeWindowsProcess).filter((proc) => proc.processId));
180
+ } catch (parseError) {
181
+ reject(parseError);
182
+ }
183
+ },
184
+ );
185
+ });
140
186
  }
141
187
 
142
188
  function isWindowsSessionMatch(proc, target, targetPattern) {
@@ -175,24 +221,20 @@ export function createSessionWorkerProcessInspector({
175
221
  .map(normalizeWindowsProcess)
176
222
  .filter((proc) => proc.processId);
177
223
  const byPid = new Map(snapshot.map((proc) => [proc.processId, proc]));
178
- const childrenByParent = new Map();
179
- for (const proc of snapshot) {
180
- if (!proc.parentProcessId) continue;
181
- const children = childrenByParent.get(proc.parentProcessId) || [];
182
- children.push(proc);
183
- childrenByParent.set(proc.parentProcessId, children);
184
- }
185
224
 
186
225
  const related = new Map();
187
226
  const addProcess = (proc) => {
188
227
  if (proc?.processId) related.set(proc.processId, proc);
189
228
  };
190
- const addDescendants = (pid) => {
191
- for (const child of childrenByParent.get(pid) || []) {
192
- if (related.has(child.processId)) continue;
193
- addProcess(child);
194
- addDescendants(child.processId);
195
- }
229
+ const addDescendants = (proc) => {
230
+ const found = collectProcessDescendants(snapshot, proc, {
231
+ accept: (child) => {
232
+ if (related.has(child.processId)) return false;
233
+ // The worker of another session is never part of this one's tree.
234
+ return !(isRelaySessionWorker(child) && !isWindowsSessionMatch(child, target, targetPattern));
235
+ },
236
+ });
237
+ for (const child of found) addProcess(child);
196
238
  };
197
239
  const addWrapperAncestors = (proc) => {
198
240
  let current = proc;
@@ -201,6 +243,8 @@ export function createSessionWorkerProcessInspector({
201
243
  seen.add(current.parentProcessId);
202
244
  const parent = byPid.get(current.parentProcessId);
203
245
  if (!parent || !isWindowsWrapperProcess(parent)) return;
246
+ // Windows hands a pid out again: see `isChildProcessOf`.
247
+ if (!isChildProcessOf(current, parent)) return;
204
248
  addProcess(parent);
205
249
  current = parent;
206
250
  }
@@ -208,7 +252,7 @@ export function createSessionWorkerProcessInspector({
208
252
 
209
253
  for (const proc of snapshot.filter((candidate) => isWindowsSessionMatch(candidate, target, targetPattern))) {
210
254
  addProcess(proc);
211
- addDescendants(proc.processId);
255
+ addDescendants(proc);
212
256
  addWrapperAncestors(proc);
213
257
  }
214
258
 
@@ -259,18 +303,26 @@ export function createSessionWorkerProcessInspector({
259
303
  '$ErrorActionPreference = "Continue"',
260
304
  '$ids = @(' + ids.join(',') + ')',
261
305
  'try {',
262
- ' $snapshot = @(Get-CimInstance Win32_Process -ErrorAction Stop | ForEach-Object { [pscustomobject]@{ processId = [int]$_.ProcessId; parentProcessId = [int]$_.ParentProcessId } })',
306
+ // A child is a process started after its parent: the pid of an ended
307
+ // process is handed out again, and the worker of another session must
308
+ // not be taken for a child because of it.
309
+ ' $snapshot = @(Get-CimInstance Win32_Process -ErrorAction Stop | ForEach-Object { [pscustomobject]@{ processId = [int]$_.ProcessId; parentProcessId = [int]$_.ParentProcessId; createdAt = $(if ($_.CreationDate) { [int64](($_.CreationDate.ToUniversalTime() - [datetime]"1970-01-01").TotalMilliseconds) } else { [int64]0 }); worker = ([string]$_.CommandLine -like "*-session-worker*") } })',
263
310
  '} catch {',
264
311
  ' Write-Error $_',
265
312
  ' exit 2',
266
313
  '}',
267
314
  '$targets = [System.Collections.Generic.HashSet[int]]::new()',
268
315
  'foreach ($id in $ids) { [void]$targets.Add([int]$id) }',
316
+ '$createdAt = @{}',
317
+ 'foreach ($proc in $snapshot) { $createdAt[[int]$proc.processId] = [int64]$proc.createdAt }',
269
318
  '$changed = $true',
270
319
  'while ($changed) {',
271
320
  ' $changed = $false',
272
321
  ' foreach ($proc in $snapshot) {',
273
322
  ' if ($targets.Contains([int]$proc.parentProcessId) -and -not $targets.Contains([int]$proc.processId)) {',
323
+ ' if ($proc.worker) { continue }',
324
+ ' $parentCreatedAt = [int64]$createdAt[[int]$proc.parentProcessId]',
325
+ ' if ($parentCreatedAt -gt 0 -and [int64]$proc.createdAt -gt 0 -and [int64]$proc.createdAt -lt $parentCreatedAt) { continue }',
274
326
  ' [void]$targets.Add([int]$proc.processId)',
275
327
  ' $changed = $true',
276
328
  ' }',
@@ -314,6 +366,7 @@ export function createSessionWorkerProcessInspector({
314
366
  findWindowsProcessForSession,
315
367
  findWindowsProcessTreeForSession,
316
368
  getWindowsProcessSnapshot,
369
+ readWindowsProcessSnapshot,
317
370
  stopWindowsPids,
318
371
  };
319
372
  }
@@ -60,7 +60,7 @@ function normalizeStatus(value) {
60
60
  return ALLOWED_STATUSES.has(mapped) ? mapped : 'new';
61
61
  }
62
62
 
63
- const STEERING_HOLD_REASONS = new Set(['question', 'compaction', 'adoption', 'delivery', 'other']);
63
+ const STEERING_HOLD_REASONS = new Set(['question', 'compaction', 'adoption', 'delivery', 'tool', 'other']);
64
64
  const STEERING_MESSAGE_ID_MAX_LENGTH = 64;
65
65
  const STEERING_CANCELLABLE_IDS_MAX = 50;
66
66
 
@@ -778,9 +778,41 @@ export function createSessionWorkerSupervisor({
778
778
  return evicted;
779
779
  }
780
780
 
781
+ /**
782
+ * An idle worker whose process is gone (killed from outside, crashed
783
+ * between turns) leaves the registry, so it is not listed as ready with a
784
+ * pid that no longer exists; the next message starts a new one either way.
785
+ * A worker that is processing, or that a question card waits on, is left
786
+ * to the stale-pid handling, which knows about the work that is open. A
787
+ * heartbeat inside the stale window outranks the pid: the pid on record
788
+ * can be a launcher's. A worker that was stopped from outside therefore
789
+ * stays listed until that window has passed (90 s by default); one that
790
+ * the relay's own kill route stopped leaves the list at once.
791
+ */
792
+ function retireDeadIdleWorkers({ keep = new Set() } = {}) {
793
+ const retired = [];
794
+ const heartbeatCutoff = nowMs() - heartbeatStaleAfterMs;
795
+ for (const worker of registry.listWorkers()) {
796
+ const sessionId = normalizeSessionId(worker.sdkSessionId);
797
+ if (!sessionId || worker.status !== 'ready') continue;
798
+ if (pendingStarts.has(sessionId) || keep.has(sessionId)) continue;
799
+ const pidProbe = checkPidLiveness(worker);
800
+ if (!pidProbe.checked || pidProbe.alive) continue;
801
+ const lifecycle = getOrCreateLifecycle(sessionId);
802
+ if (lifecycle.awaitingHeartbeat) continue;
803
+ if (lifecycle.lastHeartbeatAtMs && lifecycle.lastHeartbeatAtMs > heartbeatCutoff) continue;
804
+ if (!registry.removeWorker(sessionId)) continue;
805
+ lifecycleBySession.delete(sessionId);
806
+ emitMonitorLog(`worker ${worker.workerId || sessionId} was idle and its process (pid ${worker.pid}) is gone; no longer listed`);
807
+ retired.push({ sdkSessionId: sessionId, workerId: worker.workerId || null, pid: worker.pid });
808
+ }
809
+ return retired;
810
+ }
811
+
781
812
  function snapshot({ pendingQuestionSessionIds = null } = {}) {
782
- const workers = registry.listWorkers();
783
813
  const questionPendingSet = normalizePendingQuestionSessionIds(pendingQuestionSessionIds);
814
+ retireDeadIdleWorkers({ keep: questionPendingSet });
815
+ const workers = registry.listWorkers();
784
816
  const workerBySession = new Map();
785
817
  for (const worker of workers) {
786
818
  const sid = normalizeSessionId(worker?.sdkSessionId);