@ctrl-spc/cs 0.6.0 → 0.7.1

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.
@@ -0,0 +1,562 @@
1
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync, } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { configDir } from './config.js';
6
+ /**
7
+ * 18c SLICE 8 — A CODEX WORKER IS ISOLATED LIKE A CLAUDE WORKER.
8
+ *
9
+ * THE GAP THIS CLOSES, measured on this Mac rather than reasoned about. A codex
10
+ * worker was spawned with `['exec', '--skip-git-repo-check']` and nothing else,
11
+ * so it inherited the developer's ENTIRE agent configuration. Asked to list the
12
+ * tools it could call, a plain `codex exec` on this machine answered with
13
+ * `mcp__supabase_main__execute_sql`, `mcp__postman__*`, `mcp__filesystem__*`,
14
+ * `mcp__memory__*`, `mcp__netlify__*`, `mcp__node_repl__*`, `mcp__moflo__*` and
15
+ * `mcp__super_harness__*` — eight servers this product never granted, one of
16
+ * them holding a live service-role token for the very database the product
17
+ * writes to. Asked whether it had been given an instruction file from outside
18
+ * the working directory, it quoted `~/.codex/AGENTS.md` back verbatim.
19
+ *
20
+ * That is the same hole `--strict-mcp-config` and `--setting-sources ''` close
21
+ * for claude, and "nothing was created" cannot be said honestly about a run
22
+ * configured by something the product never granted.
23
+ *
24
+ * ═══ WHY A SEEDED HOME, AND NOT ANY OF THE FOUR THINGS TRIED BEFORE ═══
25
+ *
26
+ * Codex offered no argv for an inline MCP config in the Mac runtime this module
27
+ * serves (codex-cli 0.147.0-alpha.6.5), and four mechanisms were measured as
28
+ * failing there. None of them is retried here:
29
+ *
30
+ * * `-c mcp_servers."ctrl-spc".url=...` — `-c` cannot CREATE or override an
31
+ * MCP server in this build. Re-measured for this slice with
32
+ * `--ignore-user-config -c 'mcp_servers.ctrl-spc={url=...}'`: the run
33
+ * completed and answered the tool-list question with `[]`. The override is
34
+ * accepted on the command line and produces no server.
35
+ * * `--ignore-user-config` — drops this product's own `[mcp_servers.ctrl-spc]`
36
+ * along with the user's, leaving zero ctrl-spc tools.
37
+ * * a per-run `CODEX_HOME` holding ONLY a one-server config.toml — hangs,
38
+ * because codex will not start against a home with none of its own state.
39
+ * * a `-c` override plus a `--mcp-config` file (Slice 5's Windows walk) — hung
40
+ * twice, and testing wiring no user has is not a test.
41
+ *
42
+ * The newer Windows Desktop runtime now supports creating the server through
43
+ * `-c`. Panel v3 uses that platform-specific route in `panel3/spawn.ts`, keeping
44
+ * the installed home because Windows sandbox setup is bound to its ACL state.
45
+ *
46
+ * THE THIRD ONE WAS ALMOST RIGHT, AND ITS DIAGNOSIS NAMED THE FIX. "Codex will
47
+ * not start against a home with none of its own state" is a statement about what
48
+ * the home is MISSING, not about per-run homes being impossible. The one piece
49
+ * of state codex cannot manufacture is the credential: `codex exec --help` says
50
+ * of `--ignore-user-config` that "auth still uses `CODEX_HOME`", which is the
51
+ * binary telling us where it insists on reading auth from. Everything else in
52
+ * that directory — the model cache, the session store, the sqlite state files,
53
+ * its own `.system` skills — codex creates for itself on first run.
54
+ *
55
+ * So the home is SEEDED with exactly one file copied from the user's own
56
+ * (`auth.json`) and one file this product writes (`config.toml`). Measured on
57
+ * this Mac: the run completes, it does not hang, and the tool list comes back as
58
+ * this product's tools and NOTHING else. The `~/.codex/AGENTS.md` that a plain
59
+ * run quoted verbatim is not seen, and none of the user's personal skills under
60
+ * `~/.codex/skills` is present — only the `.system` skills codex installs into
61
+ * any home for itself.
62
+ *
63
+ * ═══ THE ONE THING THIS DOES NOT COVER, STATED RATHER THAN GLOSSED ═══
64
+ *
65
+ * `~/.agents/skills` is a SECOND skill root, resolved from the user's HOME
66
+ * rather than from `$CODEX_HOME`, and nothing reaches it: not the per-run home,
67
+ * not `--ignore-user-config`, and not `[features] skill_search = false` (all
68
+ * three measured). An isolated run is therefore still handed a CATALOG naming
69
+ * the skills there — on this Mac, `ctrl-spc` (this product's own) and
70
+ * `impeccable`.
71
+ *
72
+ * WHY THAT IS RECORDED AND NOT PAPERED OVER. It is a list of names and paths
73
+ * rather than injected content: the run is not given those files' contents, is
74
+ * not instructed by them, and reads one only if it chooses to. The isolation
75
+ * that Story 6 asks for — the tool surface, the instruction file, the permission
76
+ * posture — holds, and the acceptance run confirmed all three. But "the tool
77
+ * surface is exactly this product's" and "nothing of the user's is visible" are
78
+ * different claims, and only the first is true. The honest fix is a `HOME`
79
+ * override, which is not taken because `HOME` is where codex also finds its
80
+ * credential, its cache and the user's shell environment, and breaking those to
81
+ * hide two skill names would trade a real capability for a cosmetic one.
82
+ *
83
+ * ═══ THE SECOND THING THE HOME BUYS, WHICH IS WORTH AS MUCH ═══
84
+ *
85
+ * A per-run home means a per-run `config.toml`, and a per-run `config.toml`
86
+ * means the server URL is OURS TO WRITE. That is the per-run channel codex was
87
+ * said not to have: `todo_id` goes on the URL exactly as `workerMcpConfig` puts
88
+ * it there for claude, so a codex tool call names its own request instead of the
89
+ * machine's oldest live worker. Measured with two codex runs started at the same
90
+ * second against one server: the ALPHA run's tool call arrived carrying
91
+ * `todo_id=RUN-ALPHA` and the BETA run's carried `todo_id=RUN-BETA`. That is the
92
+ * precise limitation `machineRunTodoId` documents as unfixable and hands to this
93
+ * slice.
94
+ *
95
+ * ═══ WHY NOT ONE SHARED ISOLATED HOME ═══
96
+ *
97
+ * Because the URL in it is per-run. Two concurrent runs sharing one home would
98
+ * race on the same `config.toml` and one would read the other's request id,
99
+ * which is the bug this is fixing wearing a different hat.
100
+ *
101
+ * ═══ WHAT A LEFTOVER HOME ACTUALLY COSTS, MEASURED ═══
102
+ *
103
+ * An earlier version of this comment called a leftover home "inert (two small
104
+ * files plus codex's own cache)". **That was measurably wrong**, and gate A
105
+ * caught it. After ONE real run the directory is 32 MB and contains, besides the
106
+ * credential copy, `sessions/**\/rollout-*.jsonl` — codex's full session
107
+ * transcript, which holds the entire conversation and absolute local paths.
108
+ *
109
+ * Two things follow, and both are done rather than noted. `--ephemeral` is on
110
+ * the argv (`codex exec --help`: "Run without persisting session files to
111
+ * disk"), measured to leave zero rollout files while the answer is still
112
+ * captured, so the transcript is never written in the first place. And the sweep
113
+ * cannot be the only thing standing between a crash and a stranded credential,
114
+ * so `sweepStrandedCodexHomes` runs at daemon startup beside the rest of the
115
+ * crash recovery.
116
+ */
117
+ /** The file naming the process that built a home and is responsible for it.
118
+ * Read by the startup sweep to decide whether the home still has an owner. */
119
+ const OWNER_FILE = 'ctrl-spc-owner.json';
120
+ /** The parent of every per-run home. Homes under it are swept individually, and
121
+ * only when their owning process is gone — see `sweepStrandedCodexHomes`. */
122
+ export function codexHomesRoot() {
123
+ return join(configDir(), 'codex-homes');
124
+ }
125
+ /** Persistent isolated homes belong only to panel3 conversation owners. They
126
+ * are separate from `codexHomesRoot()` so the per-process PID sweep can keep
127
+ * its existing contract without deleting a resumable owner conversation. */
128
+ export function panel3CodexOwnerHomesRoot() {
129
+ return join(configDir(), 'panel3-codex-owner-homes');
130
+ }
131
+ export function panel3CodexOwnerHomePath(ownerRunId) {
132
+ const key = ownerRunId.replace(/[^a-zA-Z0-9-]/g, '_');
133
+ return join(panel3CodexOwnerHomesRoot(), key);
134
+ }
135
+ /** Where this run's throwaway codex home lives. Inside the product's own config
136
+ * dir, beside `scratch/`, so it is the user's own directory and it is obvious
137
+ * what wrote it. Keyed by request so two concurrent runs never share one. */
138
+ export function codexRunHomePath(runTodoId) {
139
+ /* THE KEY IS SANITISED, NOT TRUSTED. A request id is a uuid today and nothing
140
+ in the type says so, and this string becomes a DIRECTORY NAME that a
141
+ credential is copied into. Anything outside the safe set becomes `_`, and
142
+ the dot is then handled separately: allowing `.` keeps a uuid readable but
143
+ would let `..` through as a whole path segment, which `join` would resolve
144
+ into a parent. A leading dot cannot survive, so `..` cannot either.
145
+
146
+ A RUN WITH NO REQUEST STILL GETS ITS OWN HOME, and this used to be a lie.
147
+ The key was the constant `'no-request'`, so every concurrent run without a
148
+ request shared ONE directory — precisely the race the paragraph above cites
149
+ as the reason for keying by request at all, reintroduced by the fallback
150
+ (gate A). There is nothing stable to key on in that case, so a random
151
+ suffix is the honest answer: uniqueness is what the key is for, and
152
+ readability is worth nothing for a directory that has no request to be read
153
+ against. */
154
+ const key = runTodoId
155
+ ? runTodoId.replace(/[^a-zA-Z0-9._-]/g, '_').replace(/^\.+/, '_')
156
+ : `no-request-${randomUUID()}`;
157
+ return join(codexHomesRoot(), key);
158
+ }
159
+ /** The user's own codex home, resolved exactly as codex resolves it. */
160
+ export function userCodexHome() {
161
+ return process.env.CODEX_HOME || join(homedir(), '.codex');
162
+ }
163
+ /** The URL a run's `config.toml` points at, however the caller said it. */
164
+ function mcpUrl(server, runTodoId) {
165
+ return 'url' in server ? server.url : codexWorkerMcpUrl(server, runTodoId);
166
+ }
167
+ /**
168
+ * The `config.toml` this product hands a codex worker. ONE server, named exactly
169
+ * as the tools and the guidance name it, and nothing else.
170
+ *
171
+ * `default_tools_approval_mode = "approve"` is not optional and is not a
172
+ * loosening. Without it headless `codex exec` CANCELS every MCP tool call
173
+ * client-side, with a zero-second duration and nothing in any server log
174
+ * (.bugs/20260808-codex-mcp-tools-cancelled). `ensureCodexMcpApproval` in mcp.ts
175
+ * exists to keep putting this key back into the USER's config after every
176
+ * `codex mcp add` destroys it; here the file is ours and never rewritten, so the
177
+ * key simply stays.
178
+ *
179
+ * `features.apps = false` is the last of the inherited surface, and it is not
180
+ * config-file state — it comes from the signed-in ChatGPT account. Measured: a
181
+ * seeded home WITHOUT this line still listed `mcp__codex_apps__github_*`,
182
+ * `mcp__codex_apps__codex_document_control_*` and the rest, so a worker could
183
+ * open pull requests on the user's GitHub through an account connector the
184
+ * product never granted. WITH it, the tool list is this product's tools and
185
+ * nothing else. It is disabled rather than allow-listed because codex offers no
186
+ * per-server allow list; `false` is the only knob, and an allow list is what
187
+ * `--strict-mcp-config` + `--allowedTools` amount to for claude.
188
+ */
189
+ export function codexRunConfigToml(server, runTodoId, runtimePlatform = process.platform,
190
+ /* ═══ CODEX'S OWN SUBAGENT TOOL, WHICH v3 CLOSES AND v2 DOES NOT. ═══
191
+ *
192
+ * v3 enforces "depth stops at three" in the TOOL SURFACE, never in a prompt.
193
+ * Claude's half of that is `CODE_TOOLS` excluding `Task` in
194
+ * `panel3/spawn.ts`. Codex's own subagent tool was left open, and a level 2
195
+ * codex worker reached for it instead of this product's `dispatch` tool;
196
+ * no level 3 run was ever started through the record.
197
+ *
198
+ * Codex 0.148 made `multi_agent` the stable feature switch and changed
199
+ * `[agents]` into a role table. The old `[agents] enabled = false` now makes
200
+ * the current Desktop runtime reject the whole config before a run starts.
201
+ * `features.multi_agent = false` is therefore the surface-level absence rule
202
+ * on the current runtime, matching the documented configuration contract.
203
+ *
204
+ * v2's claude workers are deliberately still granted `Task` (see
205
+ * `orchestrator.ts`), so this is an EXPLICIT argument rather than a change to
206
+ * what v2 gets: the default keeps today's behaviour (subagents allowed), and
207
+ * only v3's `startAgent` passes it off. Never inferred from `server`'s shape:
208
+ * v2 and v3 both pass `{ url }` here, so the shape cannot tell them apart.
209
+ */
210
+ subagentsEnabled = true, persistentPanelOwner = false) {
211
+ const lines = [
212
+ '# Written by CTRL+SPC for ONE codex run. Not the user\'s config; never read',
213
+ '# by anything but the worker this was written for.',
214
+ /* The Windows Desktop bundle can lag the account's server-selected default
215
+ model. When that default requires a newer client, every headless worker
216
+ exits before its first action. Pin the current broadly-supported model
217
+ only for that bundled runtime; other platforms keep Codex's own model
218
+ selection. */
219
+ ...(runtimePlatform === 'win32' ? ['model = "gpt-5.5"', ''] : []),
220
+ ...(persistentPanelOwner ? [
221
+ 'sandbox_mode = "workspace-write"',
222
+ '',
223
+ '[sandbox_workspace_write]',
224
+ 'network_access = true',
225
+ '',
226
+ ] : []),
227
+ '[features]',
228
+ /* The account's own connectors, which no config.toml grants and no argv
229
+ removes. See the block comment above. */
230
+ 'apps = false',
231
+ ...(!subagentsEnabled ? ['multi_agent = false'] : []),
232
+ '',
233
+ ];
234
+ /* Native Windows must name its sandbox implementation. Without this block,
235
+ codex silently resolves a requested workspace-write run as read-only and
236
+ refuses every network command. `unelevated` is the documented fallback for
237
+ a headless process: `elevated` opens a UAC consent dialog that a daemon has
238
+ nobody present to approve. This block is Windows-only. macOS keeps its
239
+ existing Seatbelt configuration exactly. */
240
+ if (runtimePlatform === 'win32') {
241
+ lines.push('[windows]', 'sandbox = "unelevated"',
242
+ /* 0.148 defaults both Windows sandboxes onto a private desktop. A
243
+ daemon-spawned headless worker has no desktop handshake to complete,
244
+ so its command runner waits on the pipe until the turn fails. The
245
+ documented compatibility mode keeps the same sandbox boundaries and
246
+ runs the command on Winsta0\\Default instead. */
247
+ 'sandbox_private_desktop = false', '');
248
+ }
249
+ /* No server means the tools server is not running, and the worker then runs
250
+ with NO ctrl-spc tools rather than with whatever the user's config happens
251
+ to hold — the same honest-absence rule `headlessAgentArgs` already applies
252
+ to claude's `--mcp-config`. Writing no `[mcp_servers]` block at all is what
253
+ makes that true: the home has no other source of servers. */
254
+ if (server) {
255
+ const url = mcpUrl(server, runTodoId);
256
+ lines.push('[mcp_servers.ctrl-spc]', `url = ${JSON.stringify(url)}`,
257
+ /* See the block comment above: without this every tool call is cancelled
258
+ client-side before it leaves the worker. */
259
+ 'default_tools_approval_mode = "approve"', '');
260
+ }
261
+ return lines.join('\n');
262
+ }
263
+ /**
264
+ * The URL a codex worker reaches this server on. Deliberately the SAME shape
265
+ * `workerMcpUrl` builds for claude — token, then `todo_id` when there is a
266
+ * request — because the server reads the query string the same way for both and
267
+ * two spellings of one URL is how they drift apart.
268
+ *
269
+ * Kept here rather than imported from orchestrator.ts to avoid a cycle; the pair
270
+ * is pinned identical by a test rather than by hope.
271
+ */
272
+ export function codexWorkerMcpUrl(server, runTodoId) {
273
+ const query = runTodoId
274
+ ? `token=${server.token}&todo_id=${encodeURIComponent(runTodoId)}`
275
+ : `token=${server.token}`;
276
+ return `http://127.0.0.1:${server.port}/mcp?${query}`;
277
+ }
278
+ /**
279
+ * Build this run's isolated codex home and return its path, or null when it
280
+ * cannot be built.
281
+ *
282
+ * NULL IS A REAL ANSWER AND THE CALLER MUST HONOUR IT. The one thing that can
283
+ * fail is the credential copy: no `auth.json` in the user's home means the user
284
+ * has never signed codex in, and a home without it is the case that HANGS. So a
285
+ * missing credential returns null and the caller runs codex the old way — an
286
+ * un-isolated run that works beats a hung one that looks safe, and a run that
287
+ * hangs is indistinguishable from a slow one for thirty minutes.
288
+ *
289
+ * IT IS BUILT FROM EMPTY EVERY RUN, not merely overwritten. The URL carries the
290
+ * request id and the token can rotate, so a stale `config.toml` would point a
291
+ * worker at the wrong request or a dead port. Overwriting the two files was not
292
+ * enough: a RETRY of the same request resolves the same key, so the previous
293
+ * attempt's session state, sqlite files and caches were inherited by the run
294
+ * meant to be a fresh start (gate A). The directory is removed first, so "this
295
+ * run's home" means what it says. The expensive parts are codex's own and it
296
+ * recreates them; the two files this writes are under a kilobyte.
297
+ */
298
+ export function ensureCodexRunHome(server, runTodoId,
299
+ /* ═══ 18d SLICE 4 — THE DIRECTORY KEY, WHEN IT MUST DIFFER FROM THE REQUEST.
300
+ ═══
301
+
302
+ The home was keyed by request id alone, which is right while one request
303
+ means one run. A HANDOFF breaks that: the continuation carries the SAME
304
+ request id, so it resolved to the same directory as the run it is replacing
305
+ — and this function starts by `rmSync`-ing the directory. Two ways to lose:
306
+ the continuation deletes the outgoing run's home from under it, or the
307
+ outgoing run's `removeCodexRunHome` deletes the continuation's.
308
+
309
+ The caller passes a per-DISPATCH key, so two runs of one request never share
310
+ a directory. `runTodoId` still goes into the config's MCP url unchanged,
311
+ because that is the request the worker is genuinely working. */
312
+ homeKey = runTodoId,
313
+ /* ═══ THE SUBAGENT SWITCH, DEFAULTED TO v2's BEHAVIOUR. ═══ Passed straight
314
+ through to `codexRunConfigToml` (see its comment for the measurement
315
+ table). Defaults `true` so v2's callers, which never pass this, are
316
+ byte-identical to before; only v3's `startAgent` passes `false`. */
317
+ subagentsEnabled = true, runtimePlatform = process.platform) {
318
+ const source = join(userCodexHome(), 'auth.json');
319
+ /* No credential, no isolated home. See the block comment above: this is the
320
+ exact shape that hangs, and hanging is worse than inheriting. */
321
+ if (!existsSync(source))
322
+ return null;
323
+ const home = codexRunHomePath(homeKey);
324
+ try {
325
+ /* FROM EMPTY. See above: a retry of the same request would otherwise start
326
+ inside the failed attempt's leftovers. */
327
+ rmSync(home, { recursive: true, force: true });
328
+ /* 0o700 UNCONDITIONALLY. The files inside are written 0600, but `auth.json`
329
+ arrives through `copyFileSync`, which PRESERVES THE SOURCE MODE — so the
330
+ credential copy is only as private as the original happened to be. The
331
+ directory being owner-only is what makes that irrelevant, and it is set
332
+ explicitly rather than left to the umask (gate A: the homes were 0755).
333
+ `mode` is ignored by `mkdirSync` when the directory already exists, which
334
+ is why the `chmodSync` below is not redundant. */
335
+ mkdirSync(home, { recursive: true, mode: 0o700 });
336
+ chmodSync(home, 0o700);
337
+ /* COPIED, NOT SYMLINKED. Codex rewrites `auth.json` when it refreshes a
338
+ token, and a symlink would make a throwaway home's refresh land in the
339
+ user's real one — a per-run directory that writes back into `~/.codex` is
340
+ not isolated in the direction that matters. */
341
+ copyFileSync(source, join(home, 'auth.json'));
342
+ writeFileSync(join(home, 'config.toml'), codexRunConfigToml(server, runTodoId, runtimePlatform, subagentsEnabled), { mode: 0o600 });
343
+ /* WHO OWNS THIS HOME, so the startup sweep can tell a stranded directory
344
+ from one a LIVE run is using. Written FIRST-ish and always, because the
345
+ sweep treats an ownerless home as strandable: the only way to produce one
346
+ is a crash between the `mkdirSync` above and this line, which is a couple
347
+ of syscalls with no child spawned yet, so there is nothing live to lose.
348
+ Two processes share this root (the terminal daemon and the companion) and
349
+ neither can see the other's children, so the pid is the only thing that
350
+ crosses the gap. See `sweepStrandedCodexHomes`. */
351
+ writeFileSync(join(home, OWNER_FILE), JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }), { mode: 0o600 });
352
+ return home;
353
+ }
354
+ catch {
355
+ /* Same reasoning as the missing credential: an isolated home we cannot write
356
+ is not a reason to fail the user's run. */
357
+ return null;
358
+ }
359
+ }
360
+ /**
361
+ * Create or refresh the isolated home for one durable panel3 Level 2 owner.
362
+ * Unlike `ensureCodexRunHome`, this never removes the directory: Codex's native
363
+ * session files inside it are the state a later activation resumes. Only the
364
+ * credential copy and current sealed MCP configuration are refreshed.
365
+ */
366
+ export function ensurePanel3CodexOwnerHome(server, ownerRunId, runtimePlatform = process.platform) {
367
+ const source = join(userCodexHome(), 'auth.json');
368
+ if (!existsSync(source))
369
+ return null;
370
+ const home = panel3CodexOwnerHomePath(ownerRunId);
371
+ try {
372
+ mkdirSync(home, { recursive: true, mode: 0o700 });
373
+ chmodSync(home, 0o700);
374
+ copyFileSync(source, join(home, 'auth.json'));
375
+ writeFileSync(join(home, 'config.toml'), codexRunConfigToml(server, null, runtimePlatform, false, true), { mode: 0o600 });
376
+ return home;
377
+ }
378
+ catch {
379
+ return null;
380
+ }
381
+ }
382
+ /** Product-owned persistent state only. Windows owners use the installed Codex
383
+ * home, so this path is normally absent there and removal remains harmless. */
384
+ export function removePanel3CodexOwnerHome(ownerRunId) {
385
+ try {
386
+ rmSync(panel3CodexOwnerHomePath(ownerRunId), { recursive: true, force: true });
387
+ }
388
+ catch {
389
+ // Reconciliation retries. Cleanup must never turn a completed turn into a failure.
390
+ }
391
+ }
392
+ /** Valid directory names only; callers reconcile each id against the signed-in
393
+ * owner row before retaining it. */
394
+ export function listPanel3CodexOwnerHomeIds() {
395
+ try {
396
+ return readdirSync(panel3CodexOwnerHomesRoot(), { withFileTypes: true })
397
+ .filter((entry) => entry.isDirectory())
398
+ .map((entry) => entry.name);
399
+ }
400
+ catch {
401
+ return [];
402
+ }
403
+ }
404
+ /**
405
+ * Delete a run's home once the run is over.
406
+ *
407
+ * IT TAKES THE PATH, NOT THE REQUEST ID, and that is load-bearing rather than
408
+ * stylistic. `codexRunHomePath(null)` now mints a RANDOM key so that concurrent
409
+ * requestless runs cannot share a directory, so recomputing the path here would
410
+ * delete a directory that never existed and leave the real one behind forever.
411
+ * The caller holds the path `ensureCodexRunHome` returned; it passes that.
412
+ *
413
+ * IT IS NOT MERE TIDINESS, and the comment here used to say it was. A leftover
414
+ * home is 32 MB after one run and holds the credential copy plus (before
415
+ * `--ephemeral`) the full session transcript with absolute local paths. This is
416
+ * the primary reclaim; `sweepStrandedCodexHomes` is the backstop for the paths
417
+ * that never reach it.
418
+ *
419
+ * Best-effort in the sense that it never throws: a run that has already produced
420
+ * the user's answer must not fail because a directory would not delete.
421
+ */
422
+ export function removeCodexRunHome(home) {
423
+ try {
424
+ rmSync(home, { recursive: true, force: true });
425
+ }
426
+ catch {
427
+ /* Never allowed to affect a run that has already ended. The startup sweep is
428
+ what catches whatever this could not remove. */
429
+ }
430
+ }
431
+ /**
432
+ * Remove per-run codex homes whose OWNING PROCESS IS GONE.
433
+ *
434
+ * WHY A STARTUP SWEEP EXISTS AT ALL (gate A, blocker 3). The per-run sweep runs
435
+ * when a run ends, and there are ways a run does not end: SIGKILL, a panic, an
436
+ * OS restart, launchd restarting a crashed daemon, and `daemon.ts`'s own
437
+ * shutdown, which calls `process.exit(0)` without awaiting in-flight spawns.
438
+ * Every one strands a directory holding a copy of the user's credential, and
439
+ * nothing else in the product would ever look at it again.
440
+ *
441
+ * ═══ WHY IT IS NOT WHOLESALE, WHICH IS WHAT IT USED TO BE ═══
442
+ *
443
+ * The first version deleted the whole root, borrowing `recoverStrandedWorkers`'s
444
+ * argument: "if this daemon is starting, nothing it started is still running".
445
+ * **That argument does not transfer, and the re-review proved the harm.** That
446
+ * function is scoped by `machine_id` to rows this daemon owns on a SERVER; this
447
+ * is an unscoped `rmSync` of a SHARED LOCAL directory. TWO INDEPENDENT PROCESSES
448
+ * call `startPresence()`: the terminal daemon (`cs start` → `runDaemon`) and the
449
+ * companion (`cs open`, again after browser sign-in). The companion has a port
450
+ * guard; the daemon has NO cross-process guard at all. Both resolve the same
451
+ * `configDir()`, so `cs start` while the companion is mid-run — or launchd
452
+ * restarting a crashed daemon while the companion survives — deleted a LIVE
453
+ * worker's `$CODEX_HOME` underneath it. That is precisely the harm the `settle`
454
+ * fix removed, reintroduced at a new trigger.
455
+ *
456
+ * ═══ WHY THE OWNER PID, AND NOT AGE ═══
457
+ *
458
+ * Age was the obvious candidate and it is NOT SAFE AT ANY THRESHOLD I could
459
+ * defend, which was measured rather than assumed. During a live 40-second codex
460
+ * run, sampled every five seconds: the home directory's own mtime went stale
461
+ * almost immediately (codex writes to files INSIDE it, which does not touch the
462
+ * parent's mtime), and the newest mtime ANYWHERE IN THE TREE still reached ~29
463
+ * seconds while the run was plainly alive, because codex holds sqlite handles
464
+ * open without flushing. Mtime measures "when codex last flushed", not "is this
465
+ * run alive". A thinking agent mid-tool-call is silent BY DESIGN here — the
466
+ * elapsed-time kill was retired for exactly that reason (user ruling
467
+ * 2026-08-06, "no agent that is actively working should ever be killed
468
+ * automatically") — so any age threshold is a bet against a long tool call.
469
+ *
470
+ * The owner pid asks the real question instead. `processIsAlive` is the same
471
+ * check `reapDeadWorkers` already trusts to decide a worker really died, and it
472
+ * treats EPERM as alive, so a home owned by the OTHER process (a different uid,
473
+ * or simply not ours to signal) is correctly read as still owned.
474
+ *
475
+ * A RECYCLED PID CAN ONLY MAKE THIS TOO CAUTIOUS, NEVER TOO EAGER. If a dead
476
+ * owner's pid has been reused by something unrelated, the home is kept and
477
+ * reclaimed on a later start; the failure direction is a leftover directory, not
478
+ * a deleted live one. That is the right way round for a directory holding a
479
+ * credential in use.
480
+ *
481
+ * A HOME WITH NO OWNER FILE IS SWEPT, and there are TWO independent reasons that
482
+ * is safe rather than one. Within a build, an unstamped home can only come from
483
+ * a crash between `mkdirSync` and the owner stamp, which is a window of two
484
+ * syscalls with no child spawned yet, so there is nothing live to protect.
485
+ * Across builds, the usual worry would be a home written by an OLDER version
486
+ * that did not stamp — and there is no such version: this module was created on
487
+ * the branch that also introduced the stamp, and has never been merged or
488
+ * released, so no build exists anywhere that can produce a live unstamped home.
489
+ *
490
+ * Best-effort and never throws: a daemon must start even if a directory will
491
+ * not delete. A home that survives is retried on the next start, and the run
492
+ * that reuses its key rebuilds it from empty anyway.
493
+ */
494
+ export function sweepStrandedCodexHomes(
495
+ /** Injected so a test can drive the liveness decision without real processes.
496
+ * Defaults to the same check `reapDeadWorkers` uses. */
497
+ isAlive = defaultProcessIsAlive) {
498
+ let entries;
499
+ try {
500
+ entries = readdirSync(codexHomesRoot());
501
+ }
502
+ catch {
503
+ /* No root yet (the common case on a first start), or unreadable. Nothing to
504
+ do either way, and startup must not be blocked. */
505
+ return;
506
+ }
507
+ for (const entry of entries) {
508
+ const home = join(codexHomesRoot(), entry);
509
+ try {
510
+ if (codexRunHomeHasLiveOwner(home, isAlive))
511
+ continue;
512
+ rmSync(home, { recursive: true, force: true });
513
+ }
514
+ catch {
515
+ /* One home that will not delete must not stop the others, and must not
516
+ stop the daemon starting. Retried next start. */
517
+ }
518
+ }
519
+ }
520
+ /**
521
+ * Does this home still belong to a running process? Anything unreadable or
522
+ * unparseable counts as ownerless — see the sweep's comment on the two-syscall
523
+ * window that is the only way to produce one.
524
+ *
525
+ * `pid > 0` IS LOAD-BEARING, NOT A PLAUSIBILITY CHECK, and it was written as one
526
+ * before the gate pointed this out. `process.kill(0, 0)` does not ask "is pid 0
527
+ * alive": POSIX reads pid 0 as THE CALLER'S OWN PROCESS GROUP, and `kill(-N, 0)`
528
+ * targets process GROUP N. Both SUCCEED — measured on this machine — so without
529
+ * this a home stamped `{"pid":0}` would read as owned forever and could never be
530
+ * reclaimed. The guard keeps a corrupt or truncated stamp from reaching `kill`
531
+ * with a value that means something entirely different from "this process".
532
+ *
533
+ * `Number.isInteger` is belt-and-braces by comparison and is kept as such: node
534
+ * throws `ERR_INVALID_ARG_TYPE` on `1.5` and `NaN`, which `defaultProcessIsAlive`
535
+ * already turns into "dead" via its non-EPERM catch. It is here so the intent is
536
+ * stated at the point of the decision rather than resting on the catch, and a
537
+ * mutation removing it survives the suite for that reason.
538
+ */
539
+ function codexRunHomeHasLiveOwner(home, isAlive) {
540
+ let pid;
541
+ try {
542
+ pid = JSON.parse(readFileSync(join(home, OWNER_FILE), 'utf8')).pid;
543
+ }
544
+ catch {
545
+ return false;
546
+ }
547
+ return typeof pid === 'number' && Number.isInteger(pid) && pid > 0 && isAlive(pid);
548
+ }
549
+ /** `process.kill(pid, 0)` signals nothing and only tests for existence. EPERM
550
+ * means it exists and belongs to someone else, which still answers "alive" —
551
+ * so only ESRCH ("no such process") is death. Duplicated from
552
+ * `orchestrator.processIsAlive` rather than imported, because orchestrator.ts
553
+ * imports THIS module and the cycle would be the only thing gained. */
554
+ function defaultProcessIsAlive(pid) {
555
+ try {
556
+ process.kill(pid, 0);
557
+ return true;
558
+ }
559
+ catch (err) {
560
+ return err.code === 'EPERM';
561
+ }
562
+ }
package/dist/companion.js CHANGED
@@ -6,7 +6,7 @@ import { companionToken, getMachineIdentity, readSession, clearSession, readCode
6
6
  import { getClient, signIn, NotLoggedIn } from './supabase.js';
7
7
  import { startPresence, stopPresence } from './presence.js';
8
8
  import { detectAgents } from './agents.js';
9
- import { toolsServerStatus, agentRegStatus, badgeReason } from './mcp.js';
9
+ import { agentToolsBadgeState } from './mcp.js';
10
10
  import { loadProjects, saveMapping } from './projects.js';
11
11
  import { listCodebases, addCodebase, reportLocated, removeCodebase, NotHostedRemoteError } from './codebases.js';
12
12
  import { hostedRemoteIdentity } from './git-remote.js';
@@ -156,18 +156,15 @@ function str(value) {
156
156
  }
157
157
  /** The passive "Agent tools" badge state for the companion (feature 05). Phase 3
158
158
  * gives it an honest lifecycle — connecting / connected / couldn't-connect — on
159
- * top of the signed-out and no-agent off-states. The branching itself lives in
160
- * the pure `badgeReason` (mcp.ts) so it's unit-testable; this only gathers the
161
- * live signals: whether we're signed in, which supported agents are installed,
162
- * whether the tools server is up, and each agent's registration status. */
159
+ * top of the signed-out and no-agent off-states. The branching lives in
160
+ * `agentToolsBadgeState` (mcp.ts, unit-testable): the pure `badgeReason` over
161
+ * this process's live signals, plus when THIS process doesn't own the tools
162
+ * port (bug 20260730 split-brain) a cached /health probe and the on-disk
163
+ * agent registrations, so a healthy server owned by another cs process never
164
+ * reads as "failed". This only gathers signed-in + installed agents. */
163
165
  function agentToolsState(signedIn) {
164
166
  const installed = detectAgents().filter((a) => a === 'claude' || a === 'codex');
165
- return badgeReason({
166
- signedIn,
167
- installed,
168
- serverRunning: toolsServerStatus().running,
169
- status: agentRegStatus(),
170
- });
167
+ return agentToolsBadgeState({ signedIn, installed });
171
168
  }
172
169
  async function handle(req, res, token) {
173
170
  if (!allowedHost(req.headers.host)) {
@@ -221,7 +218,7 @@ async function handleApi(req, res, path, query) {
221
218
  machineName: machine.name,
222
219
  platform: process.platform,
223
220
  version: VERSION,
224
- agentTools: agentToolsState(email !== null),
221
+ agentTools: await agentToolsState(email !== null),
225
222
  });
226
223
  return;
227
224
  }
package/dist/config.js CHANGED
@@ -10,6 +10,29 @@ export function configDir() {
10
10
  function filePath(name) {
11
11
  return join(configDir(), name);
12
12
  }
13
+ /**
14
+ * 18b SLICE 2 — WHERE A RUN THAT NEEDS NO REPOSITORY STARTS.
15
+ *
16
+ * NEVER THE FILESYSTEM ROOT, and never `$HOME`. Passing no cwd to `spawn` means
17
+ * "inherit the parent's", and the parent is a launchd login item whose cwd is
18
+ * `/` — so a write-capable agent answering "what is on this project's board"
19
+ * started at the root of the user's disk. Nothing in that run needs a directory
20
+ * at all, which is exactly why it should be given one that contains nothing.
21
+ *
22
+ * An empty directory inside the config dir is the cheapest honest answer: it
23
+ * exists (so `spawn` cannot fail on chdir), it is the user's own, it is empty
24
+ * (so a stray relative write lands somewhere inert and visible rather than in a
25
+ * real tree), and it needs no cleanup between runs.
26
+ *
27
+ * ponytail: one shared directory, not one per run. Per-run temp dirs would need
28
+ * a lifecycle nothing here has; if two concurrent no-checkout runs ever need to
29
+ * be isolated from each other, mkdtemp per run is the upgrade.
30
+ */
31
+ export function scratchDir() {
32
+ const path = join(configDir(), 'scratch');
33
+ mkdirSync(path, { recursive: true });
34
+ return path;
35
+ }
13
36
  function writeJson(name, value) {
14
37
  mkdirSync(configDir(), { recursive: true });
15
38
  const path = filePath(name);