@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.
- package/.github/extensions/web-relay/polling/polling-loop.mjs +12 -32
- package/.github/extensions/web-relay/skills/question-bridge.mjs +14 -39
- package/CHANGELOG.md +340 -0
- package/README.md +196 -26
- package/package.json +2 -1
- package/server/README.md +174 -7
- package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
- package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
- package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +18 -2
- package/server/claude-worker/claude-session-process.mjs +151 -1
- package/server/claude-worker/claude-session-worker.mjs +6 -0
- package/server/claude-worker/claude-turn-failure.mjs +92 -0
- package/server/claude-worker/claude-turn-publisher.mjs +52 -3
- package/server/claude-worker/sdk-message-normalizer.mjs +13 -1
- package/server/copilot-worker/copilot-model-switch.mjs +29 -3
- package/server/copilot-worker/copilot-question-bridge.mjs +2 -4
- package/server/copilot-worker/copilot-runtime-tree.mjs +91 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +48 -8
- package/server/copilot-worker/copilot-sdk-session-process.mjs +897 -166
- package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
- package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
- package/server/db-schema.mjs +24 -0
- package/server/grok-worker/acp-client.mjs +68 -12
- package/server/grok-worker/grok-sdk-adapter.mjs +4 -0
- package/server/grok-worker/grok-turn-runner.mjs +6 -2
- package/server/migrations/0006-workspace-root-settings.mjs +67 -0
- package/server/migrations/0007-claude-cloud.mjs +82 -0
- package/server/public/app/agent-sessions-settings-ui.js +197 -0
- package/server/public/app/agent-sessions-settings.mjs +134 -0
- package/server/public/app/api-client.js +160 -6
- package/server/public/app/attachments-view.js +20 -1
- package/server/public/app/attribution-control.mjs +46 -0
- package/server/public/app/bootstrap.js +347 -31
- package/server/public/app/claude-cloud-conversation-ui.js +110 -0
- package/server/public/app/claude-cloud-settings-ui.js +374 -0
- package/server/public/app/claude-cloud-ui.mjs +611 -0
- package/server/public/app/composer-control-state.mjs +15 -3
- package/server/public/app/context-menu.mjs +140 -0
- package/server/public/app/context-tier-options.mjs +4 -0
- package/server/public/app/conversation-origin-ui.js +27 -0
- package/server/public/app/conversation-provider-indicator.mjs +22 -4
- package/server/public/app/conversation-view.js +178 -8
- package/server/public/app/cwd-picker.js +12 -0
- package/server/public/app/journal-view.js +704 -28
- package/server/public/app/markdown-strikethrough.mjs +31 -0
- package/server/public/app/math-dollars.mjs +89 -0
- package/server/public/app/model-selector-options.mjs +1 -1
- package/server/public/app/plan-usage-view.mjs +36 -8
- package/server/public/app/relay-board-inline.mjs +70 -0
- package/server/public/app/relay-board-view.js +157 -61
- package/server/public/app/relay-error-ctas.mjs +28 -5
- package/server/public/app/remote-relay-origin-view.mjs +120 -4
- package/server/public/app/router.js +49 -12
- package/server/public/app/settings-modal.js +33 -2
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +60 -0
- package/server/public/app/store.js +4 -1
- package/server/public/app/subagent-fold.mjs +61 -0
- package/server/public/app/usage-limit-ui.js +225 -0
- package/server/public/index.html +471 -8
- package/server/relay-tools.md +13 -1
- package/server/repositories/message-repository.mjs +10 -1
- package/server/repositories/question-repository.mjs +1 -0
- package/server/repositories/remote-relay-repository.mjs +72 -1
- package/server/repositories/session-repository.mjs +47 -1
- package/server/routes/claude-cloud-routes.mjs +140 -0
- package/server/routes/git-routes.mjs +37 -0
- package/server/routes/messages-routes.mjs +202 -26
- package/server/routes/relay-board-routes.mjs +56 -2
- package/server/routes/remote-relay-routes.mjs +15 -1
- package/server/routes/sessions-routes.mjs +538 -72
- package/server/server-runtime.mjs +291 -27
- package/server/services/claude-account-usage-service.mjs +176 -0
- package/server/services/claude-cloud-repo-service.mjs +408 -0
- package/server/services/claude-cloud-session-service.mjs +326 -0
- package/server/services/claude-cloud-settings-service.mjs +269 -0
- package/server/services/git-remote-service.mjs +115 -0
- package/server/services/plan-usage-claude-cloud.mjs +211 -0
- package/server/services/plan-usage-claude.mjs +202 -40
- package/server/services/plan-usage-contract.mjs +39 -3
- package/server/services/plan-usage-service.mjs +82 -2
- package/server/services/preview-proxy-server.mjs +64 -36
- package/server/services/process-list-service.mjs +122 -0
- package/server/services/provider-reasoning-effort.mjs +3 -0
- package/server/services/relay-console-log.mjs +199 -0
- package/server/services/remote-relay-caller-context.mjs +78 -19
- package/server/services/remote-relay-client.mjs +9 -0
- package/server/services/remote-relay-dispatcher.mjs +451 -39
- package/server/services/remote-relay-inbound.mjs +83 -1
- package/server/services/remote-relay-loopback.mjs +245 -0
- package/server/services/remote-relay-registry-service.mjs +52 -0
- package/server/services/session-worker-launch-service.mjs +100 -15
- package/server/services/session-worker-process-service.mjs +410 -138
- package/server/services/session-worker-registry-service.mjs +1 -1
- package/server/services/session-worker-stop-service.mjs +24 -13
- package/server/services/session-worker-supervisor-service.mjs +89 -2
- package/server/services/usage-limit-pause-service.mjs +443 -0
- package/server/services/worker-launch-note-service.mjs +151 -0
- package/shared/ask-user-bridge.mjs +14 -39
- package/shared/claude-attribution.mjs +117 -0
- package/shared/claude-cloud/api-client.mjs +809 -0
- package/shared/claude-cloud/base-url.mjs +47 -0
- package/shared/claude-cloud/credentials.mjs +221 -0
- package/shared/claude-cloud/repo-url.mjs +111 -0
- package/shared/claude-usage-limit.mjs +257 -0
- package/shared/control-poller.mjs +69 -20
- package/shared/failure-note-text.mjs +38 -0
- package/shared/provider-routing.mjs +4 -2
- package/shared/question-wait.mjs +108 -0
- package/shared/remote-relay-contract.mjs +167 -22
- package/shared/remote-relay-tool-core.mjs +11 -8
- package/shared/steer-settle-failure.mjs +17 -0
- package/shared/steer-settle-markers.mjs +8 -0
- package/shared/worker-runtime/process-tree.mjs +483 -0
- package/shared/worker-runtime/relay-errors.mjs +31 -0
- package/shared/worker-runtime/send-and-wait-errors.mjs +4 -4
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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
|
-
|
|
167
|
-
return candidates.find((proc) => !isWindowsWrapperProcess(proc)) || null;
|
|
336
|
+
return firstNonWrapper(findWindowsProcessesForSession(targetSessionId));
|
|
168
337
|
}
|
|
169
338
|
|
|
170
339
|
function findWindowsProcessTreeForSession(targetSessionId) {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
223
|
-
|
|
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
|
|
243
|
-
|
|
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
|
|
252
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
|