@oar-sh/oar 0.9.6 → 0.9.8
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/CHANGELOG.md +285 -0
- package/README.md +222 -40
- package/bin/oar.js +320 -18
- package/package.json +1 -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 -13
- package/server/claude-worker/claude-session-process.mjs +225 -24
- package/server/claude-worker/claude-session-worker.mjs +6 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +28 -0
- package/server/db-schema.mjs +17 -0
- package/server/migrations/0006-workspace-root-settings.mjs +67 -0
- package/server/migrations/0007-claude-cloud.mjs +82 -0
- package/server/public/app/activity-replay-state.mjs +68 -5
- 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 +149 -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 +468 -88
- 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 +11 -0
- package/server/public/app/context-menu.mjs +140 -0
- package/server/public/app/context-tier-options.mjs +4 -0
- package/server/public/app/context-usage-view.mjs +127 -8
- 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 +145 -11
- package/server/public/app/cwd-picker.js +12 -0
- package/server/public/app/journal-view.js +698 -28
- 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/pwa-install.js +5 -0
- package/server/public/app/relay-error-ctas.mjs +16 -2
- package/server/public/app/remote-relay-origin-view.mjs +120 -4
- package/server/public/app/router.js +49 -12
- package/server/public/app/scrollbar-hover.mjs +78 -0
- package/server/public/app/settings-modal.js +37 -3
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +53 -1
- package/server/public/app/store.js +26 -0
- package/server/public/app/transcript-separators.mjs +48 -11
- package/server/public/app-icon-192.png +0 -0
- package/server/public/app-icon-512.png +0 -0
- package/server/public/app-icon-light-192.png +0 -0
- package/server/public/app-icon-light-512.png +0 -0
- package/server/public/app-icon-light-maskable-512.png +0 -0
- package/server/public/app-icon-light.svg +4 -0
- package/server/public/app-icon-maskable-512.png +0 -0
- package/server/public/app-icon.svg +3 -3
- package/server/public/favicon.ico +0 -0
- package/server/public/index.html +564 -20
- package/server/public/manifest.webmanifest +9 -3
- package/server/public/sw.js +6 -1
- package/server/relay-tools.md +13 -1
- package/server/repositories/remote-relay-repository.mjs +42 -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 +123 -16
- package/server/routes/remote-relay-routes.mjs +15 -1
- package/server/routes/sessions-routes.mjs +517 -74
- package/server/server-runtime.mjs +268 -33
- 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/claude-context-usage.mjs +51 -3
- package/server/services/git-remote-service.mjs +115 -0
- package/server/services/oar-cli-helpers.mjs +67 -1
- 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/provider-reasoning-effort.mjs +3 -0
- package/server/services/pwa-manifest-theme.mjs +30 -0
- package/server/services/remote-relay-caller-context.mjs +49 -10
- package/server/services/remote-relay-client.mjs +9 -0
- package/server/services/remote-relay-dispatcher.mjs +383 -35
- package/server/services/remote-relay-inbound.mjs +49 -2
- 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 +348 -129
- package/server/services/session-worker-registry-service.mjs +18 -1
- package/server/services/session-worker-stop-service.mjs +24 -13
- package/server/services/session-worker-supervisor-service.mjs +56 -1
- package/server/services/update-install-service.mjs +9 -3
- package/server/services/usage-limit-pause-service.mjs +3 -1
- package/server/services/worker-launch-note-service.mjs +151 -0
- 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/provider-routing.mjs +4 -2
- package/shared/remote-relay-contract.mjs +153 -22
- package/shared/remote-relay-tool-core.mjs +11 -8
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
189
|
-
if (
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
.
|
|
222
|
-
|
|
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
|
-
|
|
240
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
|
267
|
-
|
|
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
|
|
287
|
-
|
|
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
|
|
296
|
-
|
|
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
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
}
|
|
@@ -82,6 +82,19 @@ function normalizeSteeringCancellableIds(raw) {
|
|
|
82
82
|
return ids;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
// The Claude worker's running CLI: the compaction window it was spawned with
|
|
86
|
+
// (a token count, null = Auto) and whether background work holds it, which
|
|
87
|
+
// blocks the respawn that applies a changed window. Null when no CLI runs or
|
|
88
|
+
// the worker does not report it.
|
|
89
|
+
function normalizeSteeringAutoCompactWindow(raw) {
|
|
90
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
91
|
+
const active = Number(raw.active);
|
|
92
|
+
return {
|
|
93
|
+
active: Number.isFinite(active) && active > 0 ? Math.round(active) : null,
|
|
94
|
+
backgroundWork: raw.backgroundWork === true,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
85
98
|
// The worker's composer-facing steering snapshot, published on its heartbeat.
|
|
86
99
|
// Callers follow the registry convention of spreading the existing entry into
|
|
87
100
|
// upserts, so an update without `steering` keeps the last known snapshot.
|
|
@@ -98,7 +111,7 @@ export function normalizeWorkerSteeringSnapshot(raw) {
|
|
|
98
111
|
if (!raw || typeof raw !== 'object') return null;
|
|
99
112
|
const holdReason = String(raw.holdReason || '').trim().toLowerCase();
|
|
100
113
|
const messageId = String(raw.messageId || '').trim().slice(0, STEERING_MESSAGE_ID_MAX_LENGTH);
|
|
101
|
-
|
|
114
|
+
const snapshot = {
|
|
102
115
|
turnActive: raw.turnActive === true,
|
|
103
116
|
canSteer: raw.canSteer === true,
|
|
104
117
|
holdReason: STEERING_HOLD_REASONS.has(holdReason) ? holdReason : null,
|
|
@@ -106,6 +119,10 @@ export function normalizeWorkerSteeringSnapshot(raw) {
|
|
|
106
119
|
supported: raw.supported === true,
|
|
107
120
|
cancellableIds: normalizeSteeringCancellableIds(raw.cancellableIds),
|
|
108
121
|
};
|
|
122
|
+
// Only a snapshot that reports a running CLI carries the key.
|
|
123
|
+
const autoCompactWindow = normalizeSteeringAutoCompactWindow(raw.autoCompactWindow);
|
|
124
|
+
if (autoCompactWindow) snapshot.autoCompactWindow = autoCompactWindow;
|
|
125
|
+
return snapshot;
|
|
109
126
|
}
|
|
110
127
|
|
|
111
128
|
function sanitizeState(raw = null) {
|