@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
@@ -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,207 @@ 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
+ //
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).
74
+ const WINDOWS_PROCESS_SNAPSHOT_SCRIPT = [
75
+ '$list = Get-CimInstance Win32_Process | ForEach-Object {',
76
+ ' [pscustomobject]@{',
77
+ ' processId = [int]$_.ProcessId;',
78
+ ' parentProcessId = [int]$_.ParentProcessId;',
79
+ ' name = [string]$_.Name;',
80
+ ' commandLine = [string]$_.CommandLine;',
81
+ ' createdAt = $(if ($_.CreationDate) { [int64](($_.CreationDate.ToUniversalTime() - [datetime]"1970-01-01").TotalMilliseconds) } else { [int64]0 });',
82
+ ' }',
83
+ '};',
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()',
89
+ ].join(' ');
90
+
91
+ /** How long the process list may take when a worker waits for it. */
92
+ const WINDOWS_PROCESS_SNAPSHOT_TIMEOUT_MS = 10_000;
93
+
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) {
238
+ const text = String(output || '').trim();
239
+ if (!text) return [];
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
+ }
254
+ return Array.isArray(parsed) ? parsed : [parsed];
255
+ }
256
+
57
257
  function looksLikeCopilotWorkerProcess(proc) {
58
258
  const name = String(proc?.name || '').trim().toLowerCase();
59
259
  const cmd = String(proc?.commandLine || '').trim().toLowerCase();
@@ -94,153 +294,195 @@ function looksLikeCopilotWorkerProcess(proc) {
94
294
  export function createSessionWorkerProcessInspector({
95
295
  platform = process.platform,
96
296
  execFileSyncImpl = execFileSync,
297
+ execFileImpl = execFile,
298
+ now = () => Date.now(),
299
+ snapshotCacheMs = SNAPSHOT_CACHE_MS,
300
+ slowReadMs = SLOW_READ_MS,
301
+ logger = null,
97
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
+
98
312
  function getPosixProcessSnapshot() {
99
313
  if (platform === 'win32') return [];
100
314
  const output = execFileSyncImpl('ps', ['-eo', 'pid=,ppid=,comm=,args=', '-ww'], {
101
315
  stdio: ['ignore', 'pipe', 'ignore'],
102
316
  });
103
- const text = String(output || '').trim();
104
- if (!text) return [];
105
- return text
106
- .split(/\r?\n/)
107
- .map((line) => {
108
- const match = String(line || '').match(/^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/);
109
- if (!match) return null;
110
- return {
111
- processId: Number.parseInt(match[1], 10),
112
- parentProcessId: Number.parseInt(match[2], 10),
113
- name: String(match[3] || ''),
114
- commandLine: String(match[4] || ''),
115
- };
116
- })
117
- .filter(Boolean);
317
+ return parsePosixProcessSnapshot(output);
118
318
  }
119
319
 
120
320
  function getWindowsProcessSnapshot() {
121
321
  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], {
322
+ const output = execFileSyncImpl('powershell.exe', ['-NoProfile', '-Command', WINDOWS_PROCESS_SNAPSHOT_SCRIPT], {
134
323
  stdio: ['ignore', 'pipe', 'ignore'],
324
+ windowsHide: true,
325
+ maxBuffer: WINDOWS_PROCESS_SNAPSHOT_MAX_BUFFER,
135
326
  });
136
- const text = String(output || '').trim();
137
- if (!text) return [];
138
- const parsed = JSON.parse(text);
139
- return Array.isArray(parsed) ? parsed : [parsed];
140
- }
141
-
142
- function isWindowsSessionMatch(proc, target, targetPattern) {
143
- if (!proc?.processId) return false;
144
- if (!looksLikeCopilotWorkerProcess(proc)) return false;
145
- const parsedSessionId = parseSessionIdFromCommandLine(proc.commandLine);
146
- if (parsedSessionId) return parsedSessionId === target;
147
- return Boolean(targetPattern?.test(proc.commandLine));
327
+ return parseWindowsProcessSnapshot(output);
148
328
  }
149
329
 
150
330
  function findWindowsProcessesForSession(targetSessionId) {
151
- const target = normalizeSessionId(targetSessionId);
152
- if (platform !== 'win32' || !target) return [];
153
- const targetPattern = buildSessionArgPattern(target, { includeResume: false });
154
- return getWindowsProcessSnapshot()
155
- .map(normalizeWindowsProcess)
156
- .filter((proc) => proc.processId)
157
- .filter((proc) => isWindowsSessionMatch(proc, target, targetPattern))
158
- .sort((left, right) => {
159
- const scoreDelta = scoreWindowsWorkerCandidate(right) - scoreWindowsWorkerCandidate(left);
160
- if (scoreDelta !== 0) return scoreDelta;
161
- return Number(right.processId || 0) - Number(left.processId || 0);
162
- });
331
+ if (platform !== 'win32' || !normalizeSessionId(targetSessionId)) return [];
332
+ return selectWindowsProcessesForSession(getWindowsProcessSnapshot(), targetSessionId);
163
333
  }
164
334
 
165
335
  function findWindowsProcessForSession(targetSessionId) {
166
- const candidates = findWindowsProcessesForSession(targetSessionId);
167
- return candidates.find((proc) => !isWindowsWrapperProcess(proc)) || null;
336
+ return firstNonWrapper(findWindowsProcessesForSession(targetSessionId));
168
337
  }
169
338
 
170
339
  function findWindowsProcessTreeForSession(targetSessionId) {
171
- const target = normalizeSessionId(targetSessionId);
172
- if (platform !== 'win32' || !target) return [];
173
- const targetPattern = buildSessionArgPattern(target, { includeResume: false });
174
- const snapshot = getWindowsProcessSnapshot()
175
- .map(normalizeWindowsProcess)
176
- .filter((proc) => proc.processId);
177
- 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
- }
340
+ if (platform !== 'win32' || !normalizeSessionId(targetSessionId)) return [];
341
+ return selectWindowsProcessTreeForSession(getWindowsProcessSnapshot(), targetSessionId);
342
+ }
185
343
 
186
- const related = new Map();
187
- const addProcess = (proc) => {
188
- if (proc?.processId) related.set(proc.processId, proc);
189
- };
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
- }
196
- };
197
- const addWrapperAncestors = (proc) => {
198
- let current = proc;
199
- const seen = new Set();
200
- while (current?.parentProcessId && !seen.has(current.parentProcessId)) {
201
- seen.add(current.parentProcessId);
202
- const parent = byPid.get(current.parentProcessId);
203
- if (!parent || !isWindowsWrapperProcess(parent)) return;
204
- addProcess(parent);
205
- current = parent;
206
- }
207
- };
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
+ }
208
358
 
209
- for (const proc of snapshot.filter((candidate) => isWindowsSessionMatch(candidate, target, targetPattern))) {
210
- addProcess(proc);
211
- addDescendants(proc.processId);
212
- addWrapperAncestors(proc);
359
+ function findProcessForSession(targetSessionId) {
360
+ return platform === 'win32'
361
+ ? findWindowsProcessForSession(targetSessionId)
362
+ : findPosixProcessForSession(targetSessionId);
363
+ }
364
+
365
+ // ─── Asynchronous readers ─────────────────────────────────────────────────
366
+
367
+ /**
368
+ * The win32 list without holding the caller's thread for it: a session
369
+ * worker reads it while it serves a conversation, and PowerShell takes about
370
+ * a second to start. Rejects when the list could not be read. Raw: no
371
+ * dedupe, no cache (see readProcessSnapshot for those).
372
+ */
373
+ function readWindowsProcessSnapshot() {
374
+ if (platform !== 'win32') return Promise.resolve([]);
375
+ return new Promise((resolve, reject) => {
376
+ execFileImpl(
377
+ 'powershell.exe',
378
+ ['-NoProfile', '-Command', WINDOWS_PROCESS_SNAPSHOT_SCRIPT],
379
+ { windowsHide: true, timeout: WINDOWS_PROCESS_SNAPSHOT_TIMEOUT_MS, maxBuffer: WINDOWS_PROCESS_SNAPSHOT_MAX_BUFFER },
380
+ (error, stdout) => {
381
+ if (error) {
382
+ reject(error);
383
+ return;
384
+ }
385
+ try {
386
+ resolve(parseWindowsProcessSnapshot(stdout).map(normalizeWindowsProcess).filter((proc) => proc.processId));
387
+ } catch (parseError) {
388
+ reject(parseError);
389
+ }
390
+ },
391
+ );
392
+ });
393
+ }
394
+
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
+ });
415
+ }
416
+
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 };
423
+
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,
431
+ };
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 {}
213
434
  }
435
+ }
214
436
 
215
- return Array.from(related.values()).sort((left, right) => {
216
- const scoreDelta = scoreWindowsWorkerCandidate(right) - scoreWindowsWorkerCandidate(left);
217
- if (scoreDelta !== 0) return scoreDelta;
218
- 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;
219
462
  });
463
+ return inFlightRead;
220
464
  }
221
465
 
222
- function findPosixProcessesForSession(targetSessionId) {
223
- const target = normalizeSessionId(targetSessionId);
224
- if (platform === 'win32' || !target) return [];
225
- const targetPattern = buildSessionArgPattern(target, { includeResume: true });
226
- return getPosixProcessSnapshot()
227
- .map((proc) => ({
228
- processId: Number.isInteger(Number(proc?.processId)) ? Number(proc.processId) : null,
229
- parentProcessId: Number.isInteger(Number(proc?.parentProcessId)) ? Number(proc.parentProcessId) : null,
230
- name: String(proc?.name || ''),
231
- commandLine: String(proc?.commandLine || ''),
232
- }))
233
- .filter((proc) => proc.processId)
234
- .filter((proc) => looksLikeCopilotWorkerProcess(proc))
235
- .filter((proc) => {
236
- const parsedSessionId = parseSessionIdFromCommandLine(proc.commandLine);
237
- if (parsedSessionId) return parsedSessionId === target;
238
- return targetPattern?.test(proc.commandLine);
239
- });
466
+ function getLastRead() {
467
+ return { ...lastRead };
240
468
  }
241
469
 
242
- function findPosixProcessForSession(targetSessionId) {
243
- 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);
244
486
  }
245
487
 
246
488
  function parsePositiveInt(value) {
@@ -248,29 +490,31 @@ export function createSessionWorkerProcessInspector({
248
490
  return Number.isInteger(num) && num > 0 ? num : null;
249
491
  }
250
492
 
251
- function stopWindowsPids(pids) {
252
- const ids = Array.from(new Set(
253
- (Array.isArray(pids) ? pids : [pids])
254
- .map((value) => parsePositiveInt(value))
255
- .filter(Boolean),
256
- ));
257
- if (!ids.length) return [];
258
- const script = [
493
+ function buildStopWindowsPidsScript(ids) {
494
+ return [
259
495
  '$ErrorActionPreference = "Continue"',
260
496
  '$ids = @(' + ids.join(',') + ')',
261
497
  'try {',
262
- ' $snapshot = @(Get-CimInstance Win32_Process -ErrorAction Stop | ForEach-Object { [pscustomobject]@{ processId = [int]$_.ProcessId; parentProcessId = [int]$_.ParentProcessId } })',
498
+ // A child is a process started after its parent: the pid of an ended
499
+ // process is handed out again, and the worker of another session must
500
+ // not be taken for a child because of it.
501
+ ' $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
502
  '} catch {',
264
503
  ' Write-Error $_',
265
504
  ' exit 2',
266
505
  '}',
267
506
  '$targets = [System.Collections.Generic.HashSet[int]]::new()',
268
507
  'foreach ($id in $ids) { [void]$targets.Add([int]$id) }',
508
+ '$createdAt = @{}',
509
+ 'foreach ($proc in $snapshot) { $createdAt[[int]$proc.processId] = [int64]$proc.createdAt }',
269
510
  '$changed = $true',
270
511
  'while ($changed) {',
271
512
  ' $changed = $false',
272
513
  ' foreach ($proc in $snapshot) {',
273
514
  ' if ($targets.Contains([int]$proc.parentProcessId) -and -not $targets.Contains([int]$proc.processId)) {',
515
+ ' if ($proc.worker) { continue }',
516
+ ' $parentCreatedAt = [int64]$createdAt[[int]$proc.parentProcessId]',
517
+ ' if ($parentCreatedAt -gt 0 -and [int64]$proc.createdAt -gt 0 -and [int64]$proc.createdAt -lt $parentCreatedAt) { continue }',
274
518
  ' [void]$targets.Add([int]$proc.processId)',
275
519
  ' $changed = $true',
276
520
  ' }',
@@ -284,22 +528,43 @@ export function createSessionWorkerProcessInspector({
284
528
  '}',
285
529
  'exit 0',
286
530
  ].join('; ');
287
- 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)], {
288
545
  stdio: ['ignore', 'ignore', 'ignore'],
289
546
  });
290
547
  return ids;
291
548
  }
292
549
 
293
- function findProcessesForSession(targetSessionId) {
294
- return platform === 'win32'
295
- ? findWindowsProcessesForSession(targetSessionId)
296
- : findPosixProcessesForSession(targetSessionId);
297
- }
298
-
299
- function findProcessForSession(targetSessionId) {
300
- return platform === 'win32'
301
- ? findWindowsProcessForSession(targetSessionId)
302
- : 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
+ });
303
568
  }
304
569
 
305
570
  return {
@@ -314,6 +579,13 @@ export function createSessionWorkerProcessInspector({
314
579
  findWindowsProcessForSession,
315
580
  findWindowsProcessTreeForSession,
316
581
  getWindowsProcessSnapshot,
582
+ readWindowsProcessSnapshot,
583
+ readProcessSnapshot,
584
+ getLastRead,
585
+ findProcessForSessionAsync,
586
+ findProcessesForSessionAsync,
587
+ findWindowsProcessTreeForSessionAsync,
317
588
  stopWindowsPids,
589
+ stopWindowsPidsAsync,
318
590
  };
319
591
  }
@@ -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