@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
@@ -63,6 +63,14 @@ function isRelaySessionWorker(proc) {
63
63
  }
64
64
 
65
65
  // Every process with its parent and the time it was created.
66
+ //
67
+ // The JSON is written to stdout as UTF-8 bytes, not through PowerShell's
68
+ // console encoding. Under the OEM code page the relay's console has (437 or
69
+ // 850), .NET's best-fit encoder turns characters such as → • ↑ ▲ § into the
70
+ // control bytes whose glyphs they are in that code page (0x1A, 0x07, 0x18,
71
+ // 0x1E, 0x15), and an agent's command line that carries one of them — a
72
+ // `bash -c "echo → next"` — breaks the JSON of the whole list: no worker
73
+ // could start on a relay for an hour (2026-10-01).
66
74
  const WINDOWS_PROCESS_SNAPSHOT_SCRIPT = [
67
75
  '$list = Get-CimInstance Win32_Process | ForEach-Object {',
68
76
  ' [pscustomobject]@{',
@@ -73,16 +81,176 @@ const WINDOWS_PROCESS_SNAPSHOT_SCRIPT = [
73
81
  ' createdAt = $(if ($_.CreationDate) { [int64](($_.CreationDate.ToUniversalTime() - [datetime]"1970-01-01").TotalMilliseconds) } else { [int64]0 });',
74
82
  ' }',
75
83
  '};',
76
- '$list | ConvertTo-Json -Depth 3 -Compress',
84
+ '$json = [string]($list | ConvertTo-Json -Depth 3 -Compress);',
85
+ '$bytes = [System.Text.Encoding]::UTF8.GetBytes($json);',
86
+ '$stdout = [Console]::OpenStandardOutput();',
87
+ '$stdout.Write($bytes, 0, $bytes.Length);',
88
+ '$stdout.Flush()',
77
89
  ].join(' ');
78
90
 
79
91
  /** How long the process list may take when a worker waits for it. */
80
92
  const WINDOWS_PROCESS_SNAPSHOT_TIMEOUT_MS = 10_000;
81
93
 
82
- function parseWindowsProcessSnapshot(output) {
94
+ // The list of every process with its full command line; 150 KB on a busy
95
+ // desktop. Node's default of 1 MiB would end the read with ENOBUFS.
96
+ const WINDOWS_PROCESS_SNAPSHOT_MAX_BUFFER = 64 * 1024 * 1024;
97
+
98
+ export const WINDOWS_PROCESS_SNAPSHOT_UNREADABLE = 'windows-process-snapshot-unreadable';
99
+
100
+ // Callers that arrive within this window after a read share its result; the
101
+ // launch pid poll asks for a fresh one (it waits for a process that did not
102
+ // exist a moment ago). Reading the list costs a PowerShell start (0.5-1.5 s
103
+ // idle, seconds under load); a burst of launches or kills must not pay it
104
+ // once per caller.
105
+ const SNAPSHOT_CACHE_MS = 1_500;
106
+ // A read slower than this is logged: the box is loaded, and every launch and
107
+ // kill waits for it.
108
+ const SLOW_READ_MS = 2_000;
109
+ // Stop-Process over a whole tree; generous, so a loaded box is not told
110
+ // "not stopped" while PowerShell still works.
111
+ const WINDOWS_STOP_TIMEOUT_MS = 30_000;
112
+
113
+ function parsePosixProcessSnapshot(output) {
114
+ const text = String(output || '').trim();
115
+ if (!text) return [];
116
+ return text
117
+ .split(/\r?\n/)
118
+ .map((line) => {
119
+ const match = String(line || '').match(/^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/);
120
+ if (!match) return null;
121
+ return {
122
+ processId: Number.parseInt(match[1], 10),
123
+ parentProcessId: Number.parseInt(match[2], 10),
124
+ name: String(match[3] || ''),
125
+ commandLine: String(match[4] || ''),
126
+ };
127
+ })
128
+ .filter(Boolean);
129
+ }
130
+
131
+ function sortWindowsCandidates(list) {
132
+ return list.sort((left, right) => {
133
+ const scoreDelta = scoreWindowsWorkerCandidate(right) - scoreWindowsWorkerCandidate(left);
134
+ if (scoreDelta !== 0) return scoreDelta;
135
+ return Number(right.processId || 0) - Number(left.processId || 0);
136
+ });
137
+ }
138
+
139
+ function isWindowsSessionMatch(proc, target, targetPattern) {
140
+ if (!proc?.processId) return false;
141
+ if (!looksLikeCopilotWorkerProcess(proc)) return false;
142
+ const parsedSessionId = parseSessionIdFromCommandLine(proc.commandLine);
143
+ if (parsedSessionId) return parsedSessionId === target;
144
+ return Boolean(targetPattern?.test(proc.commandLine));
145
+ }
146
+
147
+ // ─── Matchers over a snapshot that is already read ───────────────────────────
148
+ // Shared by the synchronous finders (the legacy extension launcher) and the
149
+ // asynchronous ones (everything else), so both see the same processes.
150
+
151
+ /** The session's worker candidates on win32, best first (wrappers last). */
152
+ export function selectWindowsProcessesForSession(snapshot, targetSessionId) {
153
+ const target = normalizeSessionId(targetSessionId);
154
+ if (!target) return [];
155
+ const targetPattern = buildSessionArgPattern(target, { includeResume: false });
156
+ return sortWindowsCandidates(
157
+ (Array.isArray(snapshot) ? snapshot : [])
158
+ .map(normalizeWindowsProcess)
159
+ .filter((proc) => proc.processId)
160
+ .filter((proc) => isWindowsSessionMatch(proc, target, targetPattern)),
161
+ );
162
+ }
163
+
164
+ /** The session's processes on win32 with their descendants and wrapper ancestors: what a kill must stop. */
165
+ export function selectWindowsProcessTreeForSession(snapshot, targetSessionId) {
166
+ const target = normalizeSessionId(targetSessionId);
167
+ if (!target) return [];
168
+ const targetPattern = buildSessionArgPattern(target, { includeResume: false });
169
+ const processes = (Array.isArray(snapshot) ? snapshot : [])
170
+ .map(normalizeWindowsProcess)
171
+ .filter((proc) => proc.processId);
172
+ const byPid = new Map(processes.map((proc) => [proc.processId, proc]));
173
+
174
+ const related = new Map();
175
+ const addProcess = (proc) => {
176
+ if (proc?.processId) related.set(proc.processId, proc);
177
+ };
178
+ const addDescendants = (proc) => {
179
+ const found = collectProcessDescendants(processes, proc, {
180
+ accept: (child) => {
181
+ if (related.has(child.processId)) return false;
182
+ // The worker of another session is never part of this one's tree.
183
+ return !(isRelaySessionWorker(child) && !isWindowsSessionMatch(child, target, targetPattern));
184
+ },
185
+ });
186
+ for (const child of found) addProcess(child);
187
+ };
188
+ const addWrapperAncestors = (proc) => {
189
+ let current = proc;
190
+ const seen = new Set();
191
+ while (current?.parentProcessId && !seen.has(current.parentProcessId)) {
192
+ seen.add(current.parentProcessId);
193
+ const parent = byPid.get(current.parentProcessId);
194
+ if (!parent || !isWindowsWrapperProcess(parent)) return;
195
+ // Windows hands a pid out again: see `isChildProcessOf`.
196
+ if (!isChildProcessOf(current, parent)) return;
197
+ addProcess(parent);
198
+ current = parent;
199
+ }
200
+ };
201
+
202
+ for (const proc of processes.filter((candidate) => isWindowsSessionMatch(candidate, target, targetPattern))) {
203
+ addProcess(proc);
204
+ addDescendants(proc);
205
+ addWrapperAncestors(proc);
206
+ }
207
+
208
+ return sortWindowsCandidates(Array.from(related.values()));
209
+ }
210
+
211
+ /** The session's processes on POSIX (the tmux pane's children carry the id too). */
212
+ export function selectPosixProcessesForSession(snapshot, targetSessionId) {
213
+ const target = normalizeSessionId(targetSessionId);
214
+ if (!target) return [];
215
+ const targetPattern = buildSessionArgPattern(target, { includeResume: true });
216
+ return (Array.isArray(snapshot) ? snapshot : [])
217
+ .map((proc) => ({
218
+ processId: Number.isInteger(Number(proc?.processId)) ? Number(proc.processId) : null,
219
+ parentProcessId: Number.isInteger(Number(proc?.parentProcessId)) ? Number(proc.parentProcessId) : null,
220
+ name: String(proc?.name || ''),
221
+ commandLine: String(proc?.commandLine || ''),
222
+ }))
223
+ .filter((proc) => proc.processId)
224
+ .filter((proc) => looksLikeCopilotWorkerProcess(proc))
225
+ .filter((proc) => {
226
+ const parsedSessionId = parseSessionIdFromCommandLine(proc.commandLine);
227
+ if (parsedSessionId) return parsedSessionId === target;
228
+ return targetPattern?.test(proc.commandLine);
229
+ });
230
+ }
231
+
232
+ function firstNonWrapper(candidates) {
233
+ return candidates.find((proc) => !isWindowsWrapperProcess(proc)) || null;
234
+ }
235
+
236
+
237
+ export function parseWindowsProcessSnapshot(output) {
83
238
  const text = String(output || '').trim();
84
239
  if (!text) return [];
85
- const parsed = JSON.parse(text);
240
+ let parsed;
241
+ try {
242
+ parsed = JSON.parse(text);
243
+ } catch (error) {
244
+ // A raw control character inside a string (an encoding that is not the
245
+ // UTF-8 the script writes, or a PowerShell that is not 5.1) is blanked
246
+ // and the list read once more: a command line matters here only for the
247
+ // session id and the worker markers it carries.
248
+ try {
249
+ parsed = JSON.parse(text.replace(/[\x00-\x1f]/g, ' '));
250
+ } catch {
251
+ throw new SyntaxError(`${WINDOWS_PROCESS_SNAPSHOT_UNREADABLE}: ${String(error?.message || error)}`);
252
+ }
253
+ }
86
254
  return Array.isArray(parsed) ? parsed : [parsed];
87
255
  }
88
256
 
@@ -127,41 +295,80 @@ export function createSessionWorkerProcessInspector({
127
295
  platform = process.platform,
128
296
  execFileSyncImpl = execFileSync,
129
297
  execFileImpl = execFile,
298
+ now = () => Date.now(),
299
+ snapshotCacheMs = SNAPSHOT_CACHE_MS,
300
+ slowReadMs = SLOW_READ_MS,
301
+ logger = null,
130
302
  } = {}) {
303
+ const cacheMs = Math.max(0, Number(snapshotCacheMs) || 0);
304
+ const slowMs = Math.max(0, Number(slowReadMs) || 0);
305
+
306
+ // ─── Synchronous readers ──────────────────────────────────────────────────
307
+ // They hold the relay's thread for the whole PowerShell run (0.5-1.5 s idle,
308
+ // seconds under load) and stay only for the legacy extension launcher
309
+ // (relay-cli-launcher-service.mjs), which goes with the extension. Every
310
+ // other caller uses the asynchronous finders below.
311
+
131
312
  function getPosixProcessSnapshot() {
132
313
  if (platform === 'win32') return [];
133
314
  const output = execFileSyncImpl('ps', ['-eo', 'pid=,ppid=,comm=,args=', '-ww'], {
134
315
  stdio: ['ignore', 'pipe', 'ignore'],
135
316
  });
136
- const text = String(output || '').trim();
137
- if (!text) return [];
138
- return text
139
- .split(/\r?\n/)
140
- .map((line) => {
141
- const match = String(line || '').match(/^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/);
142
- if (!match) return null;
143
- return {
144
- processId: Number.parseInt(match[1], 10),
145
- parentProcessId: Number.parseInt(match[2], 10),
146
- name: String(match[3] || ''),
147
- commandLine: String(match[4] || ''),
148
- };
149
- })
150
- .filter(Boolean);
317
+ return parsePosixProcessSnapshot(output);
151
318
  }
152
319
 
153
320
  function getWindowsProcessSnapshot() {
154
321
  if (platform !== 'win32') return [];
155
322
  const output = execFileSyncImpl('powershell.exe', ['-NoProfile', '-Command', WINDOWS_PROCESS_SNAPSHOT_SCRIPT], {
156
323
  stdio: ['ignore', 'pipe', 'ignore'],
324
+ windowsHide: true,
325
+ maxBuffer: WINDOWS_PROCESS_SNAPSHOT_MAX_BUFFER,
157
326
  });
158
327
  return parseWindowsProcessSnapshot(output);
159
328
  }
160
329
 
330
+ function findWindowsProcessesForSession(targetSessionId) {
331
+ if (platform !== 'win32' || !normalizeSessionId(targetSessionId)) return [];
332
+ return selectWindowsProcessesForSession(getWindowsProcessSnapshot(), targetSessionId);
333
+ }
334
+
335
+ function findWindowsProcessForSession(targetSessionId) {
336
+ return firstNonWrapper(findWindowsProcessesForSession(targetSessionId));
337
+ }
338
+
339
+ function findWindowsProcessTreeForSession(targetSessionId) {
340
+ if (platform !== 'win32' || !normalizeSessionId(targetSessionId)) return [];
341
+ return selectWindowsProcessTreeForSession(getWindowsProcessSnapshot(), targetSessionId);
342
+ }
343
+
344
+ function findPosixProcessesForSession(targetSessionId) {
345
+ if (platform === 'win32' || !normalizeSessionId(targetSessionId)) return [];
346
+ return selectPosixProcessesForSession(getPosixProcessSnapshot(), targetSessionId);
347
+ }
348
+
349
+ function findPosixProcessForSession(targetSessionId) {
350
+ return findPosixProcessesForSession(targetSessionId)[0] || null;
351
+ }
352
+
353
+ function findProcessesForSession(targetSessionId) {
354
+ return platform === 'win32'
355
+ ? findWindowsProcessesForSession(targetSessionId)
356
+ : findPosixProcessesForSession(targetSessionId);
357
+ }
358
+
359
+ function findProcessForSession(targetSessionId) {
360
+ return platform === 'win32'
361
+ ? findWindowsProcessForSession(targetSessionId)
362
+ : findPosixProcessForSession(targetSessionId);
363
+ }
364
+
365
+ // ─── Asynchronous readers ─────────────────────────────────────────────────
366
+
161
367
  /**
162
- * The same list without holding the caller's thread for it: a session
368
+ * The win32 list without holding the caller's thread for it: a session
163
369
  * 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.
370
+ * a second to start. Rejects when the list could not be read. Raw: no
371
+ * dedupe, no cache (see readProcessSnapshot for those).
165
372
  */
166
373
  function readWindowsProcessSnapshot() {
167
374
  if (platform !== 'win32') return Promise.resolve([]);
@@ -169,7 +376,7 @@ export function createSessionWorkerProcessInspector({
169
376
  execFileImpl(
170
377
  'powershell.exe',
171
378
  ['-NoProfile', '-Command', WINDOWS_PROCESS_SNAPSHOT_SCRIPT],
172
- { windowsHide: true, timeout: WINDOWS_PROCESS_SNAPSHOT_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024 },
379
+ { windowsHide: true, timeout: WINDOWS_PROCESS_SNAPSHOT_TIMEOUT_MS, maxBuffer: WINDOWS_PROCESS_SNAPSHOT_MAX_BUFFER },
173
380
  (error, stdout) => {
174
381
  if (error) {
175
382
  reject(error);
@@ -185,106 +392,97 @@ export function createSessionWorkerProcessInspector({
185
392
  });
186
393
  }
187
394
 
188
- function isWindowsSessionMatch(proc, target, targetPattern) {
189
- if (!proc?.processId) return false;
190
- if (!looksLikeCopilotWorkerProcess(proc)) return false;
191
- const parsedSessionId = parseSessionIdFromCommandLine(proc.commandLine);
192
- if (parsedSessionId) return parsedSessionId === target;
193
- return Boolean(targetPattern?.test(proc.commandLine));
194
- }
195
-
196
- function findWindowsProcessesForSession(targetSessionId) {
197
- const target = normalizeSessionId(targetSessionId);
198
- if (platform !== 'win32' || !target) return [];
199
- const targetPattern = buildSessionArgPattern(target, { includeResume: false });
200
- return getWindowsProcessSnapshot()
201
- .map(normalizeWindowsProcess)
202
- .filter((proc) => proc.processId)
203
- .filter((proc) => isWindowsSessionMatch(proc, target, targetPattern))
204
- .sort((left, right) => {
205
- const scoreDelta = scoreWindowsWorkerCandidate(right) - scoreWindowsWorkerCandidate(left);
206
- if (scoreDelta !== 0) return scoreDelta;
207
- return Number(right.processId || 0) - Number(left.processId || 0);
208
- });
395
+ function readPosixProcessSnapshot() {
396
+ if (platform === 'win32') return Promise.resolve([]);
397
+ return new Promise((resolve, reject) => {
398
+ execFileImpl(
399
+ 'ps',
400
+ ['-eo', 'pid=,ppid=,comm=,args=', '-ww'],
401
+ { maxBuffer: WINDOWS_PROCESS_SNAPSHOT_MAX_BUFFER },
402
+ (error, stdout) => {
403
+ if (error) {
404
+ reject(error);
405
+ return;
406
+ }
407
+ try {
408
+ resolve(parsePosixProcessSnapshot(stdout));
409
+ } catch (parseError) {
410
+ reject(parseError);
411
+ }
412
+ },
413
+ );
414
+ });
209
415
  }
210
416
 
211
- function findWindowsProcessForSession(targetSessionId) {
212
- const candidates = findWindowsProcessesForSession(targetSessionId);
213
- return candidates.find((proc) => !isWindowsWrapperProcess(proc)) || null;
214
- }
417
+ // One read at a time; callers that arrive while it runs share it, and
418
+ // callers within the cache window after it share its result. What the
419
+ // last read did is kept for /api/status and the log.
420
+ let inFlightRead = null;
421
+ let cachedSnapshot = null;
422
+ let lastRead = { at: null, durationMs: null, processes: null, error: null };
215
423
 
216
- function findWindowsProcessTreeForSession(targetSessionId) {
217
- const target = normalizeSessionId(targetSessionId);
218
- if (platform !== 'win32' || !target) return [];
219
- const targetPattern = buildSessionArgPattern(target, { includeResume: false });
220
- const snapshot = getWindowsProcessSnapshot()
221
- .map(normalizeWindowsProcess)
222
- .filter((proc) => proc.processId);
223
- const byPid = new Map(snapshot.map((proc) => [proc.processId, proc]));
224
-
225
- const related = new Map();
226
- const addProcess = (proc) => {
227
- if (proc?.processId) related.set(proc.processId, proc);
228
- };
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);
424
+ function recordRead({ startedAtMs, processes = null, error = null }) {
425
+ const durationMs = Math.max(0, now() - startedAtMs);
426
+ lastRead = {
427
+ at: new Date(now()).toISOString(),
428
+ durationMs,
429
+ processes: Array.isArray(processes) ? processes.length : null,
430
+ error: error ? String(error?.message || error) : null,
238
431
  };
239
- const addWrapperAncestors = (proc) => {
240
- let current = proc;
241
- const seen = new Set();
242
- while (current?.parentProcessId && !seen.has(current.parentProcessId)) {
243
- seen.add(current.parentProcessId);
244
- const parent = byPid.get(current.parentProcessId);
245
- if (!parent || !isWindowsWrapperProcess(parent)) return;
246
- // Windows hands a pid out again: see `isChildProcessOf`.
247
- if (!isChildProcessOf(current, parent)) return;
248
- addProcess(parent);
249
- current = parent;
250
- }
251
- };
252
-
253
- for (const proc of snapshot.filter((candidate) => isWindowsSessionMatch(candidate, target, targetPattern))) {
254
- addProcess(proc);
255
- addDescendants(proc);
256
- addWrapperAncestors(proc);
432
+ if (slowMs > 0 && durationMs >= slowMs) {
433
+ try { logger?.warn?.(`process list: the read took ${durationMs} ms${Array.isArray(processes) ? ` for ${processes.length} processes` : ''}${error ? ` and failed: ${lastRead.error}` : ''}`); } catch {}
257
434
  }
435
+ }
258
436
 
259
- return Array.from(related.values()).sort((left, right) => {
260
- const scoreDelta = scoreWindowsWorkerCandidate(right) - scoreWindowsWorkerCandidate(left);
261
- if (scoreDelta !== 0) return scoreDelta;
262
- return Number(right.processId || 0) - Number(left.processId || 0);
437
+ /**
438
+ * The process list, read off the thread. `fresh: true` skips the cache (not
439
+ * a read that is already running: joining it is still a read that started
440
+ * after the caller's spawn only if the caller waited — the launch poll
441
+ * retries, so joining is fine). Rejects when the list could not be read.
442
+ */
443
+ function readProcessSnapshot({ fresh = false } = {}) {
444
+ if (!fresh && cachedSnapshot && cacheMs > 0 && now() - cachedSnapshot.atMs <= cacheMs) {
445
+ return Promise.resolve(cachedSnapshot.processes);
446
+ }
447
+ if (inFlightRead) return inFlightRead;
448
+ const startedAtMs = now();
449
+ const read = platform === 'win32' ? readWindowsProcessSnapshot() : readPosixProcessSnapshot();
450
+ inFlightRead = read.then(
451
+ (processes) => {
452
+ cachedSnapshot = { atMs: now(), processes };
453
+ recordRead({ startedAtMs, processes });
454
+ return processes;
455
+ },
456
+ (error) => {
457
+ recordRead({ startedAtMs, error });
458
+ throw error;
459
+ },
460
+ ).finally(() => {
461
+ inFlightRead = null;
263
462
  });
463
+ return inFlightRead;
264
464
  }
265
465
 
266
- function findPosixProcessesForSession(targetSessionId) {
267
- const target = normalizeSessionId(targetSessionId);
268
- if (platform === 'win32' || !target) return [];
269
- const targetPattern = buildSessionArgPattern(target, { includeResume: true });
270
- return getPosixProcessSnapshot()
271
- .map((proc) => ({
272
- processId: Number.isInteger(Number(proc?.processId)) ? Number(proc.processId) : null,
273
- parentProcessId: Number.isInteger(Number(proc?.parentProcessId)) ? Number(proc.parentProcessId) : null,
274
- name: String(proc?.name || ''),
275
- commandLine: String(proc?.commandLine || ''),
276
- }))
277
- .filter((proc) => proc.processId)
278
- .filter((proc) => looksLikeCopilotWorkerProcess(proc))
279
- .filter((proc) => {
280
- const parsedSessionId = parseSessionIdFromCommandLine(proc.commandLine);
281
- if (parsedSessionId) return parsedSessionId === target;
282
- return targetPattern?.test(proc.commandLine);
283
- });
466
+ function getLastRead() {
467
+ return { ...lastRead };
284
468
  }
285
469
 
286
- function findPosixProcessForSession(targetSessionId) {
287
- return findPosixProcessesForSession(targetSessionId)[0] || null;
470
+ async function findProcessesForSessionAsync(targetSessionId, options = {}) {
471
+ if (!normalizeSessionId(targetSessionId)) return [];
472
+ const snapshot = await readProcessSnapshot(options);
473
+ return platform === 'win32'
474
+ ? selectWindowsProcessesForSession(snapshot, targetSessionId)
475
+ : selectPosixProcessesForSession(snapshot, targetSessionId);
476
+ }
477
+
478
+ async function findProcessForSessionAsync(targetSessionId, options = {}) {
479
+ const candidates = await findProcessesForSessionAsync(targetSessionId, options);
480
+ return platform === 'win32' ? firstNonWrapper(candidates) : (candidates[0] || null);
481
+ }
482
+
483
+ async function findWindowsProcessTreeForSessionAsync(targetSessionId, options = {}) {
484
+ if (platform !== 'win32' || !normalizeSessionId(targetSessionId)) return [];
485
+ return selectWindowsProcessTreeForSession(await readProcessSnapshot(options), targetSessionId);
288
486
  }
289
487
 
290
488
  function parsePositiveInt(value) {
@@ -292,14 +490,8 @@ export function createSessionWorkerProcessInspector({
292
490
  return Number.isInteger(num) && num > 0 ? num : null;
293
491
  }
294
492
 
295
- function stopWindowsPids(pids) {
296
- const ids = Array.from(new Set(
297
- (Array.isArray(pids) ? pids : [pids])
298
- .map((value) => parsePositiveInt(value))
299
- .filter(Boolean),
300
- ));
301
- if (!ids.length) return [];
302
- const script = [
493
+ function buildStopWindowsPidsScript(ids) {
494
+ return [
303
495
  '$ErrorActionPreference = "Continue"',
304
496
  '$ids = @(' + ids.join(',') + ')',
305
497
  'try {',
@@ -336,22 +528,43 @@ export function createSessionWorkerProcessInspector({
336
528
  '}',
337
529
  'exit 0',
338
530
  ].join('; ');
339
- execFileSyncImpl('powershell.exe', ['-NoProfile', '-Command', script], {
531
+ }
532
+
533
+ function uniquePids(pids) {
534
+ return Array.from(new Set(
535
+ (Array.isArray(pids) ? pids : [pids])
536
+ .map((value) => parsePositiveInt(value))
537
+ .filter(Boolean),
538
+ ));
539
+ }
540
+
541
+ function stopWindowsPids(pids) {
542
+ const ids = uniquePids(pids);
543
+ if (!ids.length) return [];
544
+ execFileSyncImpl('powershell.exe', ['-NoProfile', '-Command', buildStopWindowsPidsScript(ids)], {
340
545
  stdio: ['ignore', 'ignore', 'ignore'],
341
546
  });
342
547
  return ids;
343
548
  }
344
549
 
345
- function findProcessesForSession(targetSessionId) {
346
- return platform === 'win32'
347
- ? findWindowsProcessesForSession(targetSessionId)
348
- : findPosixProcessesForSession(targetSessionId);
349
- }
350
-
351
- function findProcessForSession(targetSessionId) {
352
- return platform === 'win32'
353
- ? findWindowsProcessForSession(targetSessionId)
354
- : findPosixProcessForSession(targetSessionId);
550
+ /** stopWindowsPids off the thread. Resolves to the pids asked for; rejects when PowerShell failed or timed out. */
551
+ function stopWindowsPidsAsync(pids) {
552
+ const ids = uniquePids(pids);
553
+ if (!ids.length) return Promise.resolve([]);
554
+ return new Promise((resolve, reject) => {
555
+ execFileImpl(
556
+ 'powershell.exe',
557
+ ['-NoProfile', '-Command', buildStopWindowsPidsScript(ids)],
558
+ { windowsHide: true, timeout: WINDOWS_STOP_TIMEOUT_MS, maxBuffer: WINDOWS_PROCESS_SNAPSHOT_MAX_BUFFER },
559
+ (error) => {
560
+ if (error) {
561
+ reject(error);
562
+ return;
563
+ }
564
+ resolve(ids);
565
+ },
566
+ );
567
+ });
355
568
  }
356
569
 
357
570
  return {
@@ -367,6 +580,12 @@ export function createSessionWorkerProcessInspector({
367
580
  findWindowsProcessTreeForSession,
368
581
  getWindowsProcessSnapshot,
369
582
  readWindowsProcessSnapshot,
583
+ readProcessSnapshot,
584
+ getLastRead,
585
+ findProcessForSessionAsync,
586
+ findProcessesForSessionAsync,
587
+ findWindowsProcessTreeForSessionAsync,
370
588
  stopWindowsPids,
589
+ stopWindowsPidsAsync,
371
590
  };
372
591
  }
@@ -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 {