@ctrl-spc/cs 0.6.0 → 0.7.0
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/dist/agents.js +70 -5
- package/dist/autostart.js +15 -1
- package/dist/browser.js +386 -0
- package/dist/codebases.js +15 -0
- package/dist/codex-home.js +501 -0
- package/dist/companion.js +9 -12
- package/dist/config.js +23 -0
- package/dist/daemon.js +33 -3
- package/dist/env.js +42 -0
- package/dist/failure-reason.js +98 -0
- package/dist/index.js +1 -1
- package/dist/mcp.js +7627 -298
- package/dist/orchestrator.js +6011 -0
- package/dist/panel3/answer.js +166 -0
- package/dist/panel3/checkout.js +29 -0
- package/dist/panel3/cli.js +83 -0
- package/dist/panel3/client.js +181 -0
- package/dist/panel3/coordinator.js +18 -0
- package/dist/panel3/presence.js +162 -0
- package/dist/panel3/prompt.js +677 -0
- package/dist/panel3/run.js +1988 -0
- package/dist/panel3/say.js +262 -0
- package/dist/panel3/secrets.js +98 -0
- package/dist/panel3/show.js +996 -0
- package/dist/panel3/spawn.js +503 -0
- package/dist/panel3/tools.js +1601 -0
- package/dist/presence.js +178 -6
- package/dist/win-shell.js +162 -0
- package/dist/work-context.js +1484 -0
- package/package.json +3 -2
package/dist/presence.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { platform } from 'node:os';
|
|
2
2
|
import { getClient } from './supabase.js';
|
|
3
|
-
import { getMachineIdentity, supersededMachineIds, clearSupersededMachineIds } from './config.js';
|
|
3
|
+
import { getMachineIdentity, mcpToken, supersededMachineIds, clearSupersededMachineIds } from './config.js';
|
|
4
4
|
import { detectAgents } from './agents.js';
|
|
5
5
|
import { startToolsServer, stopToolsServer, toolsServerStatus, registerWithClaude, registerWithCodex, unregisterFromClaude, unregisterFromCodex, agentRegStatus, heartbeatOpenSessions, setToolsClient, } from './mcp.js';
|
|
6
|
-
import { HEARTBEAT_INTERVAL_MS, COMMAND_POLL_INTERVAL_MS } from './env.js';
|
|
6
|
+
import { HEARTBEAT_INTERVAL_MS, COMMAND_POLL_INTERVAL_MS, ORCHESTRATOR_POLL_INTERVAL_MS } from './env.js';
|
|
7
7
|
import { buildPresenceHeartbeatPayload } from './presence-heartbeat.js';
|
|
8
|
+
import { createListenerState, orchestratorTick, reapDeadWorkers, recoverStrandedWorkers, takeRunMessages, liveAgents, } from './orchestrator.js';
|
|
9
|
+
/* 18c Slice 8 — the on-disk half of the crash recovery beside it. */
|
|
10
|
+
import { sweepStrandedCodexHomes } from './codex-home.js';
|
|
8
11
|
let presence = null;
|
|
9
12
|
/** In-flight guard: startPresence yields to the event loop (network setSession)
|
|
10
13
|
* before `presence` is assigned, so a plain `if (presence)` check lets two
|
|
@@ -39,6 +42,41 @@ async function heartbeat(p) {
|
|
|
39
42
|
// heartbeat 401ing forever (the CLI-v1 stale-token root cause). No-op unless
|
|
40
43
|
// the tools server is running.
|
|
41
44
|
setToolsClient(p.client);
|
|
45
|
+
/* ═══ !Cleanup PHASE 0 (I1) — RE-RESOLVE WHO WE ARE, NOT JUST THE TOKEN.
|
|
46
|
+
THE WEDGE THIS FIXES, read off this machine's own 6.1 MB log: an
|
|
47
|
+
unbroken wall of
|
|
48
|
+
|
|
49
|
+
heartbeat failed, will retry: new row violates row-level security
|
|
50
|
+
policy for table "cliv2_agents"
|
|
51
|
+
|
|
52
|
+
and presence stayed dead until the daemon was killed by hand.
|
|
53
|
+
|
|
54
|
+
`p.userId` is resolved ONCE, at startPresence, and the payload sends it
|
|
55
|
+
EXPLICITLY while the table's policy is `with check (auth.uid() =
|
|
56
|
+
user_id)`. The recovery above rebuilt the CLIENT from disk but left
|
|
57
|
+
`p.userId` alone — so the moment `session.json` came to hold a different
|
|
58
|
+
account (a second install on this box signing in; a demo lane; a
|
|
59
|
+
re-login as someone else), every heartbeat sent one user's id under
|
|
60
|
+
another user's token and the database correctly refused it. Rebuilding
|
|
61
|
+
the client changed nothing, because the disk session was not the stale
|
|
62
|
+
half. It could never recover on its own: the retry re-sent the same
|
|
63
|
+
disagreement, several times a minute, forever.
|
|
64
|
+
|
|
65
|
+
Evidence it really happened here rather than in theory: `cliv2_agents`
|
|
66
|
+
holds TWO rows for this machine_id under two different user_ids, one of
|
|
67
|
+
them stuck at the epoch.
|
|
68
|
+
|
|
69
|
+
THE TOKEN IS THE AUTHORITY. `auth.uid()` is what the database will
|
|
70
|
+
believe whatever this process thinks, so the id is taken FROM the
|
|
71
|
+
rebuilt client rather than held against it. Warned rather than silent:
|
|
72
|
+
a daemon that starts heartbeating as a different user has had the
|
|
73
|
+
machine change owner underneath it, and that is worth a line. */
|
|
74
|
+
const { data } = await p.client.auth.getUser();
|
|
75
|
+
const currentUserId = data.user?.id;
|
|
76
|
+
if (currentUserId && currentUserId !== p.userId) {
|
|
77
|
+
console.warn('the signed-in account changed underneath this daemon — heartbeating as the account now on disk');
|
|
78
|
+
p.userId = currentUserId;
|
|
79
|
+
}
|
|
42
80
|
}
|
|
43
81
|
catch { /* stay down until the next tick */ }
|
|
44
82
|
}
|
|
@@ -115,12 +153,45 @@ async function pollCommands(p) {
|
|
|
115
153
|
console.warn(`command poll failed, will retry: ${err.message}`);
|
|
116
154
|
}
|
|
117
155
|
}
|
|
156
|
+
/**
|
|
157
|
+
* One turn of the orchestrator listener (feature 16, Slice 3). All the logic is
|
|
158
|
+
* in orchestrator.ts as a pure, dependency-injected function; this is only the
|
|
159
|
+
* glue that hands it live process state, exactly as `heartbeat` hands
|
|
160
|
+
* `buildPresenceHeartbeatPayload` its inputs. Nothing here can throw —
|
|
161
|
+
* `orchestratorTick` catches everything internally — so `void`-ing it into an
|
|
162
|
+
* interval is safe.
|
|
163
|
+
*
|
|
164
|
+
* `liveAgents()` rather than `p.agents`: `p.agents` is a snapshot from
|
|
165
|
+
* `startPresence`, and a user who installs Claude AFTER `cs start` would
|
|
166
|
+
* otherwise never satisfy the designation's agent half until they restarted the
|
|
167
|
+
* daemon. Same reasoning as the heartbeat's per-tick `detectAgents()`.
|
|
168
|
+
*/
|
|
169
|
+
async function pollOrchestrator(p) {
|
|
170
|
+
/* !Cleanup Phase 0 (I5c) — WHICH CTRL+SPC SERVER THE WORKER MAY REACH.
|
|
171
|
+
Read here, per tick, for the same reason `liveAgents()` is: the tools server
|
|
172
|
+
can come up after the first tick, and a snapshot taken at `startPresence`
|
|
173
|
+
would leave every worker for the rest of the session with no tools. Null
|
|
174
|
+
while it is down, which dispatches the worker with none rather than letting
|
|
175
|
+
it inherit the user's own agent config — where a second CTRL+SPC server
|
|
176
|
+
pointed at a different account was found registered and listening. */
|
|
177
|
+
const server = toolsServerStatus();
|
|
178
|
+
await orchestratorTick({
|
|
179
|
+
client: p.client,
|
|
180
|
+
userId: p.userId,
|
|
181
|
+
machineId: p.identity.id,
|
|
182
|
+
machineName: p.identity.name,
|
|
183
|
+
agents: liveAgents(),
|
|
184
|
+
state: p.listener,
|
|
185
|
+
mcpServer: server.running ? { port: server.port, token: mcpToken() } : null,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
118
188
|
/** Come online. Throws NotLoggedIn (from getClient) if no session — callers in
|
|
119
189
|
* the companion guard for that; the terminal daemon lets it surface. No-op if
|
|
120
190
|
* already running. */
|
|
121
191
|
export async function startPresence() {
|
|
122
|
-
if (presence)
|
|
123
|
-
return { machineName: presence.identity.name, agents: presence.agents };
|
|
192
|
+
if (presence) {
|
|
193
|
+
return { machineName: presence.identity.name, agents: presence.agents, client: presence.client };
|
|
194
|
+
}
|
|
124
195
|
if (starting)
|
|
125
196
|
return starting;
|
|
126
197
|
starting = (async () => {
|
|
@@ -139,14 +210,107 @@ export async function startPresence() {
|
|
|
139
210
|
platform: platform(),
|
|
140
211
|
hb: setInterval(() => { }, HEARTBEAT_INTERVAL_MS),
|
|
141
212
|
cp: setInterval(() => { }, COMMAND_POLL_INTERVAL_MS),
|
|
213
|
+
ot: setInterval(() => { }, ORCHESTRATOR_POLL_INTERVAL_MS),
|
|
214
|
+
listener: createListenerState(),
|
|
142
215
|
};
|
|
143
216
|
clearInterval(p.hb);
|
|
144
217
|
clearInterval(p.cp);
|
|
218
|
+
clearInterval(p.ot);
|
|
145
219
|
presence = p;
|
|
146
220
|
await heartbeat(p);
|
|
147
221
|
await cleanupSupersededRows(p);
|
|
148
|
-
|
|
222
|
+
/* 16c Slice 1 — reclaim what a previous life stranded, BEFORE the first
|
|
223
|
+
tick. A killed process runs no `catch` and no `finally`, so a daemon that
|
|
224
|
+
died mid-run leaves a claimed todo and a live worker row; neither
|
|
225
|
+
self-heals, and the worker's unique index would refuse the retry. Scoped
|
|
226
|
+
to this machine's own id: if this daemon is starting, nothing it started
|
|
227
|
+
is still running. */
|
|
228
|
+
await recoverStrandedWorkers(p.client, identity.id);
|
|
229
|
+
/* 18c SLICE 8 — AND RECLAIM WHAT A PREVIOUS LIFE LEFT ON DISK. A codex worker
|
|
230
|
+
runs against a per-run `$CODEX_HOME` seeded with a COPY OF THE USER'S
|
|
231
|
+
CREDENTIAL, swept when the run's child closes. A killed process closes no
|
|
232
|
+
child: SIGKILL, a panic, an OS restart, launchd restarting a crashed
|
|
233
|
+
daemon, and this daemon's own shutdown (which calls `process.exit(0)`
|
|
234
|
+
without awaiting in-flight spawns) all leave the directory behind, and
|
|
235
|
+
nothing else would ever look at it again.
|
|
236
|
+
|
|
237
|
+
IT DOES *NOT* BORROW THE RECOVERY ABOVE'S SAFETY ARGUMENT, and this comment
|
|
238
|
+
used to say it did. "If this daemon is starting, nothing it started is
|
|
239
|
+
still running" holds for `recoverStrandedWorkers`, which is scoped by
|
|
240
|
+
`machine_id` to rows THIS install owns on a server. It is FALSE for a
|
|
241
|
+
shared local directory: TWO independent processes reach this line — the
|
|
242
|
+
terminal daemon (`cs start`) and the companion (`cs open`, and again after
|
|
243
|
+
browser sign-in) — the daemon has no cross-process guard, and both resolve
|
|
244
|
+
the same `configDir()`. Acting on that borrowed argument, this swept the
|
|
245
|
+
whole root and deleted a LIVE worker's home when `cs start` ran while the
|
|
246
|
+
companion was mid-run.
|
|
247
|
+
|
|
248
|
+
So the sweep is scoped by the OWNING PROCESS instead: each home is stamped
|
|
249
|
+
with the pid that built it and only an ownerless one is removed. The full
|
|
250
|
+
argument, including why an age-based scope was measured and rejected, is
|
|
251
|
+
in `sweepStrandedCodexHomes`. Do not re-widen this to the root. */
|
|
252
|
+
sweepStrandedCodexHomes();
|
|
253
|
+
p.hb = setInterval(() => {
|
|
254
|
+
/* ═══ 18k SLICE 7 — TAKE THE MESSAGES, AWAITED AFTER THE HEARTBEAT. ═══
|
|
255
|
+
|
|
256
|
+
ON THE HEARTBEAT AND NOT THE TICK. `orchestratorTick` returns 'busy' at
|
|
257
|
+
its concurrency cap, so a busy daemon would not look at the queue for
|
|
258
|
+
hours; and the designation read lives INSIDE `runTick`, after the slot
|
|
259
|
+
is taken, so hoisting the take in there would break the atomicity that
|
|
260
|
+
placement protects. Work that must run at the cap goes here — the
|
|
261
|
+
liveness watch below records the same reason.
|
|
262
|
+
|
|
263
|
+
SEQUENCED AFTER `heartbeat(p)`, NOT DISPATCHED BESIDE IT, and that is
|
|
264
|
+
not tidiness. `heartbeat()` can REASSIGN `p.userId` when the account on
|
|
265
|
+
disk changes, because the token is the authority (see its comment, and
|
|
266
|
+
the wedge it records). A take dispatched in the same synchronous
|
|
267
|
+
callback as `void heartbeat(p)` would capture the PRE-reconciliation
|
|
268
|
+
`p.userId` for its designation read while the RPC scopes itself by
|
|
269
|
+
`auth.uid()` from the token: two different users, one deciding whether
|
|
270
|
+
to take and the other deciding what.
|
|
271
|
+
|
|
272
|
+
PRESENCE IS STILL WRITTEN EVEN IF THE TAKE THROWS, guaranteed by
|
|
273
|
+
placement rather than by a promise: `heartbeat` has already returned
|
|
274
|
+
before the take is called, so nothing the take does can reach the
|
|
275
|
+
upsert. `takeRunMessages` also never throws, matching the rule for
|
|
276
|
+
anything on an interval here. */
|
|
277
|
+
void (async () => {
|
|
278
|
+
await heartbeat(p);
|
|
279
|
+
/* 18k SLICE 10b — THE ANSWERER NOW HOLDS THE WORKER'S TOOLSET, so it
|
|
280
|
+
needs the same server the tick hands a dispatched worker. Resolved
|
|
281
|
+
INSIDE the callback rather than captured once, for the reason
|
|
282
|
+
`pollOrchestrator` resolves its own per tick: the tools server can
|
|
283
|
+
come up after the daemon does, and a snapshot taken at start would
|
|
284
|
+
leave every answer for the rest of the session with no tools. Null
|
|
285
|
+
while it is down, and `takeRunMessages` then answers nothing rather
|
|
286
|
+
than starting an agent that could read none of the work. */
|
|
287
|
+
const server = toolsServerStatus();
|
|
288
|
+
void takeRunMessages(p.client, p.userId, p.identity.id, liveAgents(), p.listener, console.log, console.warn, server.running ? { port: server.port, token: mcpToken() } : null);
|
|
289
|
+
})();
|
|
290
|
+
/* !Cleanup Phase 6b (I43) — THE LIVENESS WATCH, on the heartbeat rather
|
|
291
|
+
than the orchestrator tick.
|
|
292
|
+
|
|
293
|
+
It cannot live on the tick: `orchestratorTick` returns early on
|
|
294
|
+
`state.busy`, so while a run is in flight it does nothing at all — which
|
|
295
|
+
is exactly the window in which a worker can die. A watch hosted there
|
|
296
|
+
could never observe the thing it exists to observe.
|
|
297
|
+
|
|
298
|
+
Scoped to this machine's own workers and gated on BOTH a stale
|
|
299
|
+
`updated_at` and a genuinely dead process, so an agent inside a long
|
|
300
|
+
tool call is never reaped for being quiet. */
|
|
301
|
+
void reapDeadWorkers(p.client, p.identity.id, p.listener.livePids);
|
|
302
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
149
303
|
p.cp = setInterval(() => void pollCommands(p), COMMAND_POLL_INTERVAL_MS);
|
|
304
|
+
p.ot = setInterval(() => void pollOrchestrator(p), ORCHESTRATOR_POLL_INTERVAL_MS);
|
|
305
|
+
// Kick one tick immediately so the daemon prints its "Orchestrator: …" line
|
|
306
|
+
// at startup (ux.md transcript states A, B and C all show it in the opening
|
|
307
|
+
// block) rather than up to a poll interval later. FIRE-AND-FORGET, NOT
|
|
308
|
+
// awaited: that first tick can claim a todo and then sit inside a headless
|
|
309
|
+
// agent run for minutes, and awaiting it here would hold `startPresence`
|
|
310
|
+
// open — freezing `cs start` before it prints "Online", and freezing the
|
|
311
|
+
// companion's sign-in, which is the same class of bug mcp.ts's async
|
|
312
|
+
// registration fixed. Cannot throw (orchestratorTick catches everything).
|
|
313
|
+
void pollOrchestrator(p);
|
|
150
314
|
// Bring up the local ctrl-spc MCP tools server and register it into Claude
|
|
151
315
|
// so any agent run on this machine has the tools with zero setup. Strictly
|
|
152
316
|
// best-effort: a tools-server or registration failure must never break the
|
|
@@ -161,7 +325,7 @@ export async function startPresence() {
|
|
|
161
325
|
catch (err) {
|
|
162
326
|
console.warn(`Agent tools server did not start: ${err.message}`);
|
|
163
327
|
}
|
|
164
|
-
return { machineName: identity.name, agents };
|
|
328
|
+
return { machineName: identity.name, agents, client };
|
|
165
329
|
})();
|
|
166
330
|
try {
|
|
167
331
|
return await starting;
|
|
@@ -181,6 +345,14 @@ export async function stopPresence({ markOffline = true, unregister = false } =
|
|
|
181
345
|
presence = null;
|
|
182
346
|
clearInterval(p.hb);
|
|
183
347
|
clearInterval(p.cp);
|
|
348
|
+
// The orchestrator listener stops with the presence. A surviving interval
|
|
349
|
+
// would keep claiming and answering todos for a user who just signed out,
|
|
350
|
+
// using a client whose session is about to be deleted. An agent already
|
|
351
|
+
// spawned is left to finish — killing it mid-run would abandon real work and
|
|
352
|
+
// leave the todo claimed with no answer, which is the outcome the release
|
|
353
|
+
// path exists to avoid; its answer write is the last thing it does, and the
|
|
354
|
+
// ListenerState dies with `p` so nothing restarts on top of it.
|
|
355
|
+
clearInterval(p.ot);
|
|
184
356
|
// On logout (unregister), scrub the ctrl-spc entry from each detected agent's
|
|
185
357
|
// config so a logged-out machine leaves no dead server that would read "failed
|
|
186
358
|
// to connect" on the next agent run. Fire-and-forget best-effort — never blocks
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 18a SLICE 7 — RUNNING AN npm CLI ON WINDOWS.
|
|
3
|
+
*
|
|
4
|
+
* Every agent this product shells out to is installed by npm, which on Windows
|
|
5
|
+
* means a `.cmd` shim rather than an executable. Three facts follow, and the
|
|
6
|
+
* first two shipped as defects because each was fixed in one place and not the
|
|
7
|
+
* other. The third is Slice 8's, and it is the same shape: a Windows rule about
|
|
8
|
+
* child processes that one call site had and its sibling did not.
|
|
9
|
+
*
|
|
10
|
+
* 1. `spawn`/`execFile` REFUSE to run a `.cmd` or `.bat` without a shell.
|
|
11
|
+
* Node 20 hardened this (CVE-2024-27980) and the refusal is
|
|
12
|
+
* `spawn EINVAL` — which is exactly the line the daemon printed on every
|
|
13
|
+
* start while registering its tools: "Could not register ctrl-spc tools
|
|
14
|
+
* with claude: spawn EINVAL". The orchestrator had learned this and passed
|
|
15
|
+
* `shell`; the MCP registration had not.
|
|
16
|
+
*
|
|
17
|
+
* 2. ONCE A SHELL IS INVOLVED, THE ARGUMENTS MUST BE ESCAPED. Node joins argv
|
|
18
|
+
* with spaces and hands it to `cmd.exe` unquoted, so anything with a space
|
|
19
|
+
* is split and anything with `&`, `|`, `>` or `%` is ACTED ON. The
|
|
20
|
+
* registration URL carries `?token=…`, and `?` and `&` are both
|
|
21
|
+
* metacharacters.
|
|
22
|
+
*
|
|
23
|
+
* 3. THE SHELL PUTS A PROCESS BETWEEN US AND THE AGENT, AND KILLING IS NOT
|
|
24
|
+
* TRANSITIVE. `spawn(shim, { shell: true })` runs `cmd.exe /c claude.cmd`,
|
|
25
|
+
* so the handle we hold is cmd.exe's and the agent is its grandchild.
|
|
26
|
+
* `child.kill()` on Windows is `TerminateProcess` on that one handle, and
|
|
27
|
+
* Windows has no process groups to carry it further. See `killTree`.
|
|
28
|
+
*
|
|
29
|
+
* This module is where all three live, so a further caller cannot take one and
|
|
30
|
+
* miss the others.
|
|
31
|
+
*/
|
|
32
|
+
import { spawn } from 'node:child_process';
|
|
33
|
+
/** Does this binary have to go through `cmd.exe` to run at all? Scoped to the
|
|
34
|
+
* binary rather than to the platform: a real `.exe` on Windows runs directly,
|
|
35
|
+
* and putting it through a shell would re-parse its argv for nothing. */
|
|
36
|
+
export function needsShell(bin) {
|
|
37
|
+
if (process.platform !== 'win32')
|
|
38
|
+
return false;
|
|
39
|
+
return /\.(cmd|bat)$/i.test(bin);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Quote one argument so `cmd.exe` passes it through untouched.
|
|
43
|
+
*
|
|
44
|
+
* THE ALGORITHM IS qntm's, by way of cross-spawn, and it is copied rather than
|
|
45
|
+
* invented because inventing it is how the defect above happened. Two passes of
|
|
46
|
+
* metacharacter escaping, deliberately: `cmd.exe` processes the line once for
|
|
47
|
+
* the shim and the shim's own `cmd.exe` processes it again, which is why
|
|
48
|
+
* cross-spawn double-escapes for `.cmd` and `.bat` specifically.
|
|
49
|
+
*
|
|
50
|
+
* CHOSEN BY MEASUREMENT, NOT BY READING. Four candidate encodings were run on
|
|
51
|
+
* the Windows box through the SAME path the product uses (a `.cmd` shim
|
|
52
|
+
* forwarding `%*`, spawned with `shell: true`), against eleven arguments
|
|
53
|
+
* including the adversarial ones. What ships is the only one that returned all
|
|
54
|
+
* eleven verbatim:
|
|
55
|
+
*
|
|
56
|
+
* raw (what shipped) 1/11 words split, `%PATH%` expanded INTO the argument,
|
|
57
|
+
* `>` redirected, and `" & echo … & "` RAN.
|
|
58
|
+
* quote only 9/11 still expands `%PATH%`, injection still ran.
|
|
59
|
+
* carets once 10/11 injection still ran.
|
|
60
|
+
* carets twice 11/11 everything verbatim, nothing executed.
|
|
61
|
+
*
|
|
62
|
+
* The `%PATH%` row is not merely cosmetic: an expansion puts this machine's
|
|
63
|
+
* absolute paths into the value, and AGENTS.md's hard rule is that an absolute
|
|
64
|
+
* local path never leaves the machine.
|
|
65
|
+
*
|
|
66
|
+
* IT CANNOT CARRY A NEWLINE, and nothing here can fix that: `^` plus a newline
|
|
67
|
+
* is cmd's line continuation, which swallows it. Anything multi-line has to
|
|
68
|
+
* travel by another channel — see the prompt, which goes on stdin.
|
|
69
|
+
*/
|
|
70
|
+
export function quoteForCmd(arg) {
|
|
71
|
+
/* Backslashes before a quote are doubled and the quote escaped; backslashes
|
|
72
|
+
at the end are doubled, because the closing quote is about to follow them.
|
|
73
|
+
Every other backslash is literal. */
|
|
74
|
+
let out = arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, '$1$1');
|
|
75
|
+
out = `"${out}"`;
|
|
76
|
+
const meta = /([()\][%!^"`<>&|;,\s*?])/g;
|
|
77
|
+
return out.replace(meta, '^$1').replace(meta, '^$1');
|
|
78
|
+
}
|
|
79
|
+
/** The options every child process on Windows needs, and the argv it needs them
|
|
80
|
+
* with. Returned together so a caller cannot take the shell and forget the
|
|
81
|
+
* quoting, which is the mistake this module exists to make impossible. */
|
|
82
|
+
export function windowsSafeSpawn(bin, args) {
|
|
83
|
+
const shell = needsShell(bin);
|
|
84
|
+
return { args: shell ? args.map(quoteForCmd) : args, shell };
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* 18a SLICE 8 (I38). END THE AGENT, NOT THE WRAPPER.
|
|
88
|
+
*
|
|
89
|
+
* `child.kill('SIGKILL')` is what Stop and the elapsed-time bound both used, and
|
|
90
|
+
* on Windows it ends ONE process. Rule 3 above means that process is `cmd.exe`,
|
|
91
|
+
* so the run settled as stopped, its slot was freed and its card read Stopped
|
|
92
|
+
* while the agent carried on editing files and spending tokens with nothing on
|
|
93
|
+
* any screen saying so. MEASURED on the Windows box before this was written, on
|
|
94
|
+
* the same path the product uses (a `.cmd` shim, `shell: true`): the agent was
|
|
95
|
+
* still in `tasklist` after the kill.
|
|
96
|
+
*
|
|
97
|
+
* `taskkill /T` walks the tree DOWNWARD from the pid it is given and ends every
|
|
98
|
+
* descendant. Also measured, on the same fixture: the agent went, and every
|
|
99
|
+
* other `node.exe` on the machine survived, INCLUDING THE DAEMON THAT SPAWNED
|
|
100
|
+
* THE WRAPPER. That direction is the whole safety argument. The daemon is the
|
|
101
|
+
* wrapper's parent, never its descendant, so it can never be in the set. (The
|
|
102
|
+
* blunt instrument, `taskkill /IM node.exe`, does not have that property. It was
|
|
103
|
+
* used once, by hand, while proving this, and it killed the user's daemon.)
|
|
104
|
+
*
|
|
105
|
+
* `/F` because SIGTERM's Windows analogue is not reliably honoured by a headless
|
|
106
|
+
* agent mid-tool-call, which is the same reason the POSIX side sends SIGKILL:
|
|
107
|
+
* I17's requirement is that the child is REALLY gone.
|
|
108
|
+
*
|
|
109
|
+
* NOTHING HERE THROWS AND NOTHING IS AWAITED. Both callers are inside a poll
|
|
110
|
+
* that must not be stalled by a kill, and the run's own `close` event is what
|
|
111
|
+
* actually reports the outcome. `taskkill` exits non-zero when the process is
|
|
112
|
+
* already gone, which is not an error here: the caller wanted it gone.
|
|
113
|
+
*/
|
|
114
|
+
export function killTree(child, { platform = process.platform, spawnProcess = spawn } = {}) {
|
|
115
|
+
/* Everywhere else `spawn` hands argv to the kernel with no shell in between,
|
|
116
|
+
so the handle IS the agent and SIGKILL reaches it. */
|
|
117
|
+
const direct = () => {
|
|
118
|
+
try {
|
|
119
|
+
child.kill('SIGKILL');
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
/* Already gone. `close` still fires and reports the outcome. */
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
if (platform !== 'win32' || typeof child.pid !== 'number')
|
|
126
|
+
return direct();
|
|
127
|
+
try {
|
|
128
|
+
const killer = spawnProcess('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], {
|
|
129
|
+
// MEMORY: "Windows silence decision". No console may EVER flash, and this
|
|
130
|
+
// one fires while the user is watching the panel.
|
|
131
|
+
windowsHide: true,
|
|
132
|
+
stdio: 'ignore',
|
|
133
|
+
});
|
|
134
|
+
/* THE FALLBACK MATTERS. If `taskkill.exe` cannot run at all, doing nothing
|
|
135
|
+
would mean Stop does nothing, which is worse than today: today at least
|
|
136
|
+
the wrapper dies and the request settles. So take the wrapper. */
|
|
137
|
+
killer.on('error', direct);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
direct();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/** Is a process with this pid alive? `kill(pid, 0)` signals nothing and only
|
|
144
|
+
* tests for existence. EPERM means it exists and belongs to someone else,
|
|
145
|
+
* which still answers "alive", so only ESRCH ("no such process") is death.
|
|
146
|
+
*
|
|
147
|
+
* 18c SLICE 9 MOVED IT HERE, from orchestrator.ts, and the move is structural
|
|
148
|
+
* rather than tidying. `recoverStrandedSessions` (mcp.ts) needs the same check,
|
|
149
|
+
* and orchestrator.ts imports mcp.ts, so leaving it there would have made a
|
|
150
|
+
* cycle. This module is the right home anyway: it is where the process-control
|
|
151
|
+
* primitives already live (`killTree`, `windowsSafeSpawn`) and it imports
|
|
152
|
+
* nothing of the project's own, so anything may depend on it. Re-exported from
|
|
153
|
+
* orchestrator.ts so every existing caller and test is unaffected. */
|
|
154
|
+
export function processIsAlive(pid) {
|
|
155
|
+
try {
|
|
156
|
+
process.kill(pid, 0);
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
return err.code === 'EPERM';
|
|
161
|
+
}
|
|
162
|
+
}
|