@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,565 @@
1
+ /**
2
+ * ═══ AGENT PANEL v3: running one headless agent and reading what it said. ═══
3
+ *
4
+ * THIS FILE BELONGS TO AGENT PANEL v3. Nothing outside `src/panel3/` may
5
+ * import it.
6
+ *
7
+ * ---------------------------------------------------------------------------
8
+ * ═══ WHY v3 HAS ITS OWN SPAWN INSTEAD OF CALLING v1's. ═══
9
+ *
10
+ * `orchestrator.ts` already spawns headless agents, and reusing it was the first
11
+ * thing considered. It cannot be reused: `spawnAgent` takes a `WorkerMcpServer`,
12
+ * builds an inline MCP config for v1's tools server, seeds a per-run `CODEX_HOME`
13
+ * for the same server, resolves a checkout from a v1 todo row, and
14
+ * reports its outcome in v1's own vocabulary. Every one of those is v1's panel,
15
+ * and conventions.md names `orchestrator.ts` in the column v3 may not use — the
16
+ * rule that keeps v1 deletable.
17
+ *
18
+ * ═══ SO WHAT IS BORROWED IS THE PART THAT BELONGS TO NO GENERATION. ═══
19
+ * `agentPath`, `windowsSafeSpawn` and `scratchDir` are process primitives about
20
+ * running a child on this operating system, and re-deriving them here would
21
+ * re-derive their bugs: `win-shell.ts` alone holds three Windows rules that each
22
+ * shipped as a defect when a second call site missed one.
23
+ *
24
+ * ---------------------------------------------------------------------------
25
+ * ═══ NOTHING HERE STOPS AN AGENT. THERE IS NO CLOCK. ═══
26
+ *
27
+ * ux.md, in the mechanical column: "The user's Stop is absolute, and nothing
28
+ * else stops work. No clock, no silence timeout."
29
+ *
30
+ * This file held a ten-minute wall clock that killed the process and reported it
31
+ * as a failure. It was never a finding, never a ruling and never an exclusion in
32
+ * the plan, and it capped precisely the case the three-level design exists for:
33
+ * an epic that runs for hours. A worker eleven minutes into a build was killed
34
+ * mid-edit, the run was marked failed, and the person was told the work failed
35
+ * when nothing had gone wrong with it.
36
+ *
37
+ * ═══ AND REMOVING IT LOSES NOTHING, BECAUSE LIVENESS ALREADY HAS AN OWNER. ═══
38
+ * `recoverStranded` in `run.ts` asks the operating system whether each of this
39
+ * machine's runs still has a process, with a pid-null grace window and a
40
+ * pre-boot check, and ends or starts again the ones that do not. That is what a
41
+ * wedged process is caught by, and it is caught by being GONE rather than by
42
+ * being slow. A clock cannot tell the two apart, which is the whole reason
43
+ * ux.md rules it out.
44
+ *
45
+ * ---------------------------------------------------------------------------
46
+ * ═══ THE PROMPT GOES ON STDIN, ON EVERY PLATFORM. ═══
47
+ *
48
+ * `claude -p` with no prompt argument reads it from stdin. v1 only does that on
49
+ * Windows, where a command line cannot carry a newline at all; the reason it is
50
+ * unconditional here is the simpler one — the prompt is prose written by a user
51
+ * and it has no business on a command line on any platform. It keeps every
52
+ * quoting question, and the injection risk that comes with them, out of this
53
+ * file entirely.
54
+ *
55
+ * ---------------------------------------------------------------------------
56
+ * ═══ THE HARNESS'S OWN TOOLS ARE PART OF THE GRANT, AND THIS FILE DECIDES
57
+ * WHICH LEVELS GET THEM. ═══
58
+ *
59
+ * `tools.ts` serves the product's tools and deliberately serves no code tool at
60
+ * any level, because reading and editing code is something the harness already
61
+ * does far better than a re-implementation would. That leaves one question the
62
+ * tools server cannot answer: WHO IS ALLOWED TO USE THE HARNESS'S OWN.
63
+ *
64
+ * ux.md answers it in the mechanical column — "level 1 is never granted a code
65
+ * tool, so it cannot become the thing that does the work" — and `--tools` is
66
+ * where that becomes true of a real process. It names which of the harness's
67
+ * BUILT-IN tools exist at all, and `--tools ''` is none of them: a level 1 child
68
+ * starts with an empty built-in list and the product's tools beside it, so its
69
+ * tool list obeys the same absence-not-refusal rule the tools server does.
70
+ *
71
+ * ═══ `--allowedTools` ALONE WAS TRIED FIRST, AND IT IS NOT THE GUARANTEE. ═══
72
+ * v1's own comment says naming `mcp__ctrl-spc__*` and nothing else "silently
73
+ * removed everything else — Bash, WebFetch, Read, Write, Edit", and measured
74
+ * against claude 2.1.227 that is not what happens: the `init` event still listed
75
+ * `Read`, `Write`, `Edit`, `Bash` and `Task`, and a level 1 child asked to read a
76
+ * file did not say it had no way to; it said it needed permission to. That is a
77
+ * REFUSAL, and a refusal is the thing ux.md rules out — an agent that can see the
78
+ * tool spends its turn asking for it, and the level rule survives only as long as
79
+ * nobody approves. The two flags do different jobs and both are needed:
80
+ * **`--tools` decides what exists, `--allowedTools` decides what runs without
81
+ * somebody at a keyboard.**
82
+ *
83
+ * `--setting-sources ''` is the third piece and without it the others are
84
+ * theatre: the developer's own settings would otherwise load, bringing their
85
+ * personal allow list and hooks with them, and level 1's posture would depend on
86
+ * whose laptop it was running on.
87
+ *
88
+ * `--strict-mcp-config` was already here before there was a tools server, and it
89
+ * matters more now: without it the child inherits whatever MCP servers this
90
+ * machine happens to have registered, which on a developer's machine is v1 and
91
+ * v2's own tools server. A v3 run reaching v2's tools by accident is the
92
+ * coupling conventions.md exists to prevent, arrived at through a config file
93
+ * rather than an import.
94
+ *
95
+ * ---------------------------------------------------------------------------
96
+ * ═══ THE WORKING COPY IS A MACHINE-LOCAL FACT, AND IT IS THE `cwd`. ═══
97
+ *
98
+ * A level 2 agent reads and changes code, so it needs a real checkout. Nothing
99
+ * in v3 resolves one, and constraint 6 rules out the obvious place to put the
100
+ * answer: no absolute filesystem path may reach the database, so it cannot live
101
+ * on the card, on the run, or in the brief the run row stores.
102
+ *
103
+ * SO IT IS NEVER WRITTEN DOWN ANYWHERE THE PRODUCT CAN SEE. It is named by one
104
+ * environment variable on the machine running the daemon, read at the moment of
105
+ * a dispatch, and handed to the child as its `cwd`. The child works in it; the
106
+ * record says only that a run happened. That is the whole mechanism, and it is
107
+ * the smallest thing that is honest: a path passed to a process is not a path
108
+ * stored in a row.
109
+ *
110
+ * WHAT IT DOES NOT COVER, and each is a later slice's to answer: one machine has
111
+ * one working copy, not one per project or per codebase; parallel agents share
112
+ * it rather than getting a copy each, so two of them editing the same file is a
113
+ * collision nothing here prevents; and nothing branches, stashes or cleans it.
114
+ *
115
+ * ---------------------------------------------------------------------------
116
+ * WHAT THIS STILL DELIBERATELY DOES NOT DO.
117
+ *
118
+ * No codex, and no stream parsing. `prompt.ts` owns what an agent is told;
119
+ * this file's job is that a process at a given level starts in the right place
120
+ * with exactly the authority that level has.
121
+ */
122
+ import { spawn as spawnChild } from 'node:child_process';
123
+ import { randomUUID } from 'node:crypto';
124
+ import { agentPath } from '../agents.js';
125
+ import { ensureCodexRunHome, ensurePanel3CodexOwnerHome, removeCodexRunHome, } from '../codex-home.js';
126
+ import { windowsSafeSpawn } from '../win-shell.js';
127
+ const AGENT_VAR = 'CTRL_SPC_V3_AGENT';
128
+ /**
129
+ * The harness named on this machine, or the reason the name is not one.
130
+ *
131
+ * ═══ AN UNKNOWN NAME THROWS RATHER THAN FALLING BACK TO CLAUDE. ═══ Constraint
132
+ * 7: a failure stays a failure. Somebody who sets this to `claude-code` or
133
+ * `gpt` has said which harness they want, and quietly running the other one
134
+ * would produce a whole session of results attributed to a binary that never
135
+ * ran. `startAgent` turns this into "no agent was started", which is true.
136
+ */
137
+ export function harness(env = process.env) {
138
+ const named = (env[AGENT_VAR] ?? '').trim().toLowerCase();
139
+ if (named === '' || named === 'claude')
140
+ return 'claude';
141
+ if (named === 'codex')
142
+ return 'codex';
143
+ throw new Error(`${AGENT_VAR} names "${named}", which is not a harness this build can spawn. `
144
+ + 'It is `claude` or `codex`, or unset for claude.');
145
+ }
146
+ /** The MCP server name the child sees, so its tools are `mcp__ctrl-spc__…`.
147
+ * Paired with `--strict-mcp-config` it is the only server that exists in that
148
+ * process, so there is nothing for the name to collide with. */
149
+ const SERVER = 'ctrl-spc';
150
+ /** ═══ THE HARNESS'S CODE TOOLS, FOR THE LEVELS THAT DO THE WORK. ═══
151
+ *
152
+ * Read, search, edit, run. `Task` is deliberately NOT here: it spawns a
153
+ * subagent inside the harness, which is a dispatch the record never sees and a
154
+ * fourth level ux.md does not have. Depth stops at three, and it stops in the
155
+ * tools rather than in a sentence asking an agent not to. Codex's own
156
+ * subagent tool gets the same closing, in `codex-home.ts`'s per-run
157
+ * `[agents] enabled = false` — a reader of either file finds the other. */
158
+ const CODE_TOOLS = ['Read', 'Glob', 'Grep', 'Edit', 'Write', 'Bash'];
159
+ /** Which of the harness's built-ins EXIST for this level. Empty at level 1: not
160
+ * denied, not present. */
161
+ const builtIns = (level) => (level === 1 ? '' : CODE_TOOLS.join(','));
162
+ /** What runs without a human approving it: the product's tools, and the code
163
+ * tools at the levels that have any. */
164
+ const allowedTools = (level) => [`mcp__${SERVER}__*`, ...(level === 1 ? [] : CODE_TOOLS)].join(',');
165
+ /**
166
+ * The child's whole argument list, for one level, pointed at one run's tools.
167
+ *
168
+ * SEPARATE FROM THE SPAWN so that what a level is granted can be printed and
169
+ * checked without starting a process — which is how the allowlist is proved,
170
+ * and how it stays provable after this task.
171
+ */
172
+ export function agentArgs(level, toolsUrl, agent = harness(), platform = process.platform, ownerSession) {
173
+ if (agent === 'codex')
174
+ return codexArgs(level, toolsUrl, platform, ownerSession);
175
+ const session = ownerSession
176
+ ? ownerSession.resumeSessionId
177
+ ? ['--resume', ownerSession.resumeSessionId]
178
+ : ['--session-id', ownerSession.freshSessionId ?? randomUUID()]
179
+ : [];
180
+ return [
181
+ // `-p` with the prompt on stdin. See the header.
182
+ '-p',
183
+ '--mcp-config',
184
+ JSON.stringify({ mcpServers: { [SERVER]: { type: 'http', url: toolsUrl } } }),
185
+ '--strict-mcp-config',
186
+ '--setting-sources', '',
187
+ '--tools', builtIns(level),
188
+ '--allowedTools', allowedTools(level),
189
+ ...session,
190
+ /* STATED WHERE THERE IS SOMETHING TO STATE. `acceptEdits` grants file edits
191
+ to a headless process with nobody at a prompt to approve them, and it is
192
+ passed only to the levels that have a file tool to use it with: at level 1
193
+ it would describe an authority over a tool that is not there. */
194
+ ...(level === 1 ? [] : ['--permission-mode', 'acceptEdits']),
195
+ ];
196
+ }
197
+ /**
198
+ * ═══ THE SAME GRANT, SAID THE ONLY WAY CODEX OFFERS. ═══
199
+ *
200
+ * The claude argv above says four things: these tools exist, these run without
201
+ * anybody approving them, this one MCP server and no other, and none of the
202
+ * user's own settings. On macOS, three of those move into a per-run
203
+ * `$CODEX_HOME`. The current Windows Desktop runtime accepts the same isolation
204
+ * through `--ignore-user-config` and `-c`, while retaining its installed
205
+ * credential and sandbox state. Both paths are pinned below.
206
+ *
207
+ * ═══ THE ONE THING THAT CANNOT BE SAID, STATED RATHER THAN GLOSSED. ═══
208
+ * `--tools ''` gives a claude level 1 NO code tool at all — not denied, not
209
+ * present. Codex has no such switch: its shell is built in, `tools.shell` is not
210
+ * a configuration field (measured against codex-cli 0.147.0-alpha.6.5: "unknown
211
+ * configuration field `tools.shell`"), and there is no allow list. The closest
212
+ * honest thing is a READ-ONLY sandbox, which is what level 1 gets: it cannot
213
+ * change the machine it runs on, and it runs in the empty scratch directory the
214
+ * caller hands it. It can still read. That is a floor of the harness, it is
215
+ * recorded in status.md as one, and it changes nothing about the product's own
216
+ * tools — those come from the run row through the URL, identically for both.
217
+ *
218
+ * `--ephemeral` keeps the session transcript, which holds absolute local paths,
219
+ * off the disk entirely. `--ignore-rules` is the settings half of claude's
220
+ * `--setting-sources ''` for the execpolicy files that live outside the home.
221
+ * `--json` makes the answer an `agent_message` item read out of the stream
222
+ * rather than whatever prose happened to reach stdout (see `codexAnswer`).
223
+ */
224
+ function codexArgs(level, toolsUrl, platform, ownerSession) {
225
+ const resume = ownerSession?.resumeSessionId;
226
+ return [
227
+ 'exec',
228
+ ...(resume ? ['resume', resume, '-'] : []),
229
+ // The prompt arrives on stdin, exactly as it does for claude: `codex exec`
230
+ // reads it from there when no prompt argument is given.
231
+ '--json',
232
+ // The level 1 scratch directory is not a repository, and neither need a
233
+ // working copy be.
234
+ '--skip-git-repo-check',
235
+ ...(ownerSession ? [] : ['--ephemeral']),
236
+ '--ignore-rules',
237
+ /* The current Windows Desktop runtime can define the run's one MCP server
238
+ on argv. Keeping the machine's real CODEX_HOME lets its installed sandbox
239
+ helpers and credential work, while this flag keeps every user setting,
240
+ connector and MCP server out of the run. Other platforms retain the
241
+ proven per-run home below. */
242
+ ...(platform === 'win32' ? [
243
+ '--ignore-user-config',
244
+ '-c', 'model="gpt-5.5"',
245
+ '-c', 'features.apps=false',
246
+ '-c', 'features.multi_agent=false',
247
+ '-c', `mcp_servers.${SERVER}.url=${JSON.stringify(toolsUrl)}`,
248
+ '-c', `mcp_servers.${SERVER}.default_tools_approval_mode="approve"`,
249
+ '-c', 'windows.sandbox="unelevated"',
250
+ '-c', 'windows.sandbox_private_desktop=false',
251
+ ] : []),
252
+ /* THE POSTURE, STATED. `workspace-write` is codex's `acceptEdits`: file
253
+ edits without a human at a prompt, and deliberately not
254
+ `danger-full-access`. Level 1 has no file tool to use it with, so it gets
255
+ the read-only sandbox instead — see the block comment above for what that
256
+ does and does not buy. */
257
+ ...(resume
258
+ ? ['-c', `sandbox_mode=${JSON.stringify(level === 1 ? 'read-only' : 'workspace-write')}`]
259
+ : ['-s', level === 1 ? 'read-only' : 'workspace-write']),
260
+ /* The write sandbox sandboxes the NETWORK too, and an agent handed a
261
+ credential that can then reach nothing is a dead end. Only where there is
262
+ a write sandbox to say it about. */
263
+ ...(level === 1 ? [] : ['-c', 'sandbox_workspace_write.network_access=true']),
264
+ ];
265
+ }
266
+ /** ═══ ONE RUN, ONE DIRECTORY. ═══ `codex-home.ts` keys a home by the request
267
+ * it is working, because two runs sharing one directory is a race that ends
268
+ * with a live run's credential deleted from under it. v3's tools URL ends in
269
+ * the run id, which is exactly that key, and `codexRunHomePath` sanitises
270
+ * whatever it is handed before it becomes a directory name. */
271
+ function runKey(toolsUrl) {
272
+ const last = toolsUrl.split('/').pop();
273
+ return last && last !== '' ? last : null;
274
+ }
275
+ /**
276
+ * ═══ WHAT A CODEX RUN SAID, OUT OF THE STREAM RATHER THAN OFF STDOUT. ═══
277
+ *
278
+ * A claude answer under `-p` IS stdout. Codex's stdout under `--json` is its
279
+ * protocol: thread ids, tool calls with their arguments, reasoning items. Handed
280
+ * to a person as an answer it would be gibberish, and written to `panel3_turns`
281
+ * it would be gibberish carrying absolute local paths, so the answer is the text
282
+ * of the last completed `agent_message` and nothing else.
283
+ *
284
+ * ═══ A FAILED TURN BEATS A MESSAGE, EVEN THOUGH THE PROCESS EXITED 0. ═══ Codex
285
+ * can emit an `agent_message` and then a `turn.failed` and still exit cleanly.
286
+ * The exit code sees success, the message looks like an answer, and the card
287
+ * would read as answered by a turn the harness itself says did not finish. That
288
+ * is the forbidden state ux.md is about, so the failure wins wherever both are
289
+ * present.
290
+ */
291
+ export function codexAnswer(stdout) {
292
+ let text = null;
293
+ let failure = null;
294
+ for (const line of stdout.split('\n')) {
295
+ const trimmed = line.trim();
296
+ if (!trimmed || trimmed[0] !== '{')
297
+ continue;
298
+ let event;
299
+ try {
300
+ event = JSON.parse(trimmed);
301
+ }
302
+ catch {
303
+ // Not protocol. Codex prints nothing else on stdout under `--json`, and a
304
+ // line that is not JSON is not an answer either way.
305
+ continue;
306
+ }
307
+ if (event.type === 'item.completed' && event.item?.type === 'agent_message') {
308
+ if (typeof event.item.text === 'string')
309
+ text = event.item.text;
310
+ }
311
+ else if (event.type === 'turn.failed') {
312
+ const named = event.error?.message ?? event.message;
313
+ failure = typeof named === 'string' ? named : '';
314
+ }
315
+ }
316
+ if (failure !== null) {
317
+ return { ok: false, reason: `codex could not finish the turn${failure ? `: ${failure}` : ''}` };
318
+ }
319
+ if (text === null || text.trim() === '') {
320
+ return { ok: false, reason: 'codex exited 0 without saying anything to the person' };
321
+ }
322
+ return { ok: true, text: text.trim() };
323
+ }
324
+ /** Enough for any answer a person reads, and a ceiling so a runaway process
325
+ * cannot exhaust this daemon's memory. */
326
+ const MAX_OUTPUT_CHARS = 1_000_000;
327
+ /**
328
+ * ═══ WHY A PROCESS DID NOT START, WITHOUT SAYING WHERE THE BINARY IS. ═══
329
+ *
330
+ * Constraint 6: no absolute filesystem path reaches the database. Both ways a
331
+ * spawn can fail hand back a message that carries one — node's own spawn error
332
+ * reads `spawn /Users/…/claude ENOENT`, and an EACCES from the synchronous throw
333
+ * names the file too — and both of these become a run's `failed_because`, which
334
+ * `cs3 show` prints and a coordinator can repeat to the person.
335
+ *
336
+ * `workingCopy()` above already makes this argument for the checkout and the
337
+ * fix is the same one: the CODE is what a person acts on, it is not a path, and
338
+ * the machine's own sentence is left for whoever is reading stderr on that
339
+ * machine. Nothing is softened — it is still a failure, still says no agent
340
+ * started, and still says what the operating system called it.
341
+ */
342
+ const couldNotStart = (err, agent) => {
343
+ const code = err.code;
344
+ return `could not start ${agent} on this machine`
345
+ + (typeof code === 'string' ? `: ${code}` : '');
346
+ };
347
+ /** The last of a stream, for an error message that has to stay readable. */
348
+ function tail(text, chars = 500) {
349
+ const trimmed = text.trim();
350
+ if (trimmed === '')
351
+ return '';
352
+ return trimmed.length <= chars ? `: ${trimmed}` : `: ...${trimmed.slice(-chars)}`;
353
+ }
354
+ /**
355
+ * Start one agent on this machine, at one level, connected to one run's tools,
356
+ * in one directory.
357
+ *
358
+ * ═══ THE DIRECTORY IS THE CALLER'S TO CHOOSE, AND IT IS RESOLVED BEFORE THE
359
+ * RUN ROW EXISTS. ═══ Level 1 is given an empty scratch directory because it has
360
+ * no code tool to use one with; a dispatched agent is given the working copy.
361
+ * The caller resolves it first so that a machine with no working copy fails
362
+ * BEFORE anything is written, rather than leaving a run row for a process that
363
+ * was never started.
364
+ *
365
+ * ═══ THE LEVEL IS PASSED TO THE HARNESS AND NEVER TO THE AGENT. ═══ It decides
366
+ * argv and nothing else. What the agent may call it learns by being handed a
367
+ * tool list, and the tools server derives that list from the run id in
368
+ * `toolsUrl` rather than from anything said here — so the two halves of the
369
+ * grant cannot drift, because only one of them is ever told a number.
370
+ *
371
+ * ═══ IT STARTS SYNCHRONOUSLY AND HANDS BACK THE PID SEPARATELY. ═══ A single
372
+ * `Promise<Answer>` would hide the pid until the process had already finished,
373
+ * and by then recording it is pointless: the pid exists to answer "is this run's
374
+ * process still alive" after the daemon that started it has been killed. So the
375
+ * caller gets the pid immediately, writes it, and then waits.
376
+ */
377
+ export function startAgent(prompt, level, toolsUrl, cwd, ownerSession) {
378
+ const failed = (reason) => ({
379
+ pid: null,
380
+ session: Promise.resolve(null),
381
+ answered: Promise.resolve({ ok: false, reason }),
382
+ });
383
+ let agent;
384
+ try {
385
+ agent = harness();
386
+ }
387
+ catch (err) {
388
+ // A machine configured for a harness this build has never heard of. See
389
+ // `harness()`: running the other one instead is the lie, not the failure.
390
+ return failed(err.message);
391
+ }
392
+ const launchedOwnerSession = ownerSession && !ownerSession.resumeSessionId
393
+ ? { ...ownerSession, freshSessionId: randomUUID() }
394
+ : ownerSession;
395
+ const ARGS = agentArgs(level, toolsUrl, agent, process.platform, launchedOwnerSession);
396
+ const bin = agentPath(agent);
397
+ if (!bin) {
398
+ return failed(`${agent} is not installed on this machine`);
399
+ }
400
+ /* macOS uses the proven per-run home. Windows keeps its installed home so the
401
+ Desktop runtime's ACL-bound sandbox helpers remain valid; `codexArgs`
402
+ isolates its configuration and grants this run's one server on argv. */
403
+ const windowsCodex = agent === 'codex' && process.platform === 'win32';
404
+ const home = agent === 'codex' && !windowsCodex
405
+ ? ownerSession
406
+ ? ensurePanel3CodexOwnerHome({ url: toolsUrl }, ownerSession.ownerId)
407
+ // `false`: v3 closes codex's own subagent tool, the same depth-stops-at-
408
+ // three rule `CODE_TOOLS` enforces for claude below. v2's callers never
409
+ // pass this and keep today's behaviour; see `codex-home.ts`.
410
+ : ensureCodexRunHome({ url: toolsUrl }, null, runKey(toolsUrl), false)
411
+ : null;
412
+ if (agent === 'codex' && !windowsCodex && !home) {
413
+ return failed('codex is not signed in on this machine, so a run cannot be given this product\'s tools '
414
+ + 'and nothing else. Sign codex in on the machine running this, and start it again.');
415
+ }
416
+ const { args, shell } = windowsSafeSpawn(bin, ARGS);
417
+ let child;
418
+ try {
419
+ child = spawnChild(bin, args, {
420
+ // The repo-wide invariant for ANY child process (MEMORY: "Windows silence
421
+ // decision"). A user must never see a console flash.
422
+ windowsHide: true,
423
+ shell,
424
+ stdio: ['pipe', 'pipe', 'pipe'],
425
+ // NEVER the daemon's inherited cwd, which under a launchd login item is
426
+ // the filesystem root. The caller says where, and both answers are
427
+ // deliberate: an empty scratch directory, or the working copy.
428
+ cwd,
429
+ /* THE PARENT'S ENVIRONMENT, PLUS THE HOME. Codex needs PATH and HOME to
430
+ run at all, and `$CODEX_HOME` is the only place its MCP server can be
431
+ said. Claude is spawned with the environment untouched, exactly as
432
+ before. */
433
+ ...(home ? { env: { ...process.env, CODEX_HOME: home } } : {}),
434
+ });
435
+ }
436
+ catch (err) {
437
+ // NOT `err.message`, which names the binary's absolute path. See
438
+ // `couldNotStart`.
439
+ if (home && !ownerSession)
440
+ removeCodexRunHome(home);
441
+ return failed(couldNotStart(err, agent));
442
+ }
443
+ let resolveSession;
444
+ let sessionSettled = false;
445
+ const session = new Promise((resolve) => { resolveSession = resolve; });
446
+ const observeSession = (value) => {
447
+ if (sessionSettled)
448
+ return;
449
+ sessionSettled = true;
450
+ resolveSession(value);
451
+ };
452
+ if (!ownerSession)
453
+ observeSession(null);
454
+ else if (agent === 'claude') {
455
+ observeSession(launchedOwnerSession?.resumeSessionId ?? launchedOwnerSession?.freshSessionId ?? null);
456
+ }
457
+ const answered = new Promise((resolve) => {
458
+ let stdout = '';
459
+ let stderr = '';
460
+ let truncated = false;
461
+ let settled = false;
462
+ const collect = (into, chunk) => {
463
+ if (into.length >= MAX_OUTPUT_CHARS) {
464
+ truncated = true;
465
+ return into;
466
+ }
467
+ return into + chunk;
468
+ };
469
+ /* ═══ ONE ANSWER, WHICHEVER OF THE TWO EVENTS ARRIVES. ═══ `error` and
470
+ `close` can both fire for one child, and the second must not overwrite
471
+ what the first said. There is no timer to clear: see the header — nothing
472
+ in this file stops an agent, and the process runs until it exits or the
473
+ user stops it. */
474
+ const finish = (answer) => {
475
+ if (settled)
476
+ return;
477
+ settled = true;
478
+ /* THE CREDENTIAL COPY GOES WHEN THE RUN DOES. `codex-home.ts` calls this
479
+ the primary reclaim and the startup sweep the backstop; a home left
480
+ behind holds a copy of the user's codex credential. */
481
+ if (home && !ownerSession)
482
+ removeCodexRunHome(home);
483
+ if (!sessionSettled)
484
+ observeSession(null);
485
+ resolve(answer);
486
+ };
487
+ child.stdout?.on('data', (d) => {
488
+ stdout = collect(stdout, String(d));
489
+ if (ownerSession && agent === 'codex' && !sessionSettled) {
490
+ for (const line of stdout.split('\n')) {
491
+ try {
492
+ const event = JSON.parse(line);
493
+ if (event.type === 'thread.started' && typeof event.thread_id === 'string') {
494
+ const id = event.thread_id;
495
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id)) {
496
+ observeSession(id);
497
+ break;
498
+ }
499
+ }
500
+ }
501
+ catch {
502
+ // The final answer parser owns protocol validity. This observer only finds one event.
503
+ }
504
+ }
505
+ }
506
+ });
507
+ child.stderr?.on('data', (d) => { stderr = collect(stderr, String(d)); });
508
+ /* ENOENT and friends. The process never ran, and that is what is reported —
509
+ WITHOUT the message, which is where node puts the binary's absolute path.
510
+ The same failure as the throw above, arriving asynchronously. */
511
+ child.on('error', (err) => {
512
+ finish({ ok: false, reason: couldNotStart(err, agent) });
513
+ });
514
+ child.on('close', (code, signal) => {
515
+ /* ═══ THREE OUTCOMES, AND ONLY ONE OF THEM IS AN ANSWER. ═══ A non-zero
516
+ exit and an exit of zero that said nothing are both failures, and
517
+ neither may be returned as an empty answer: the daemon would write a
518
+ blank agent turn and the card would read as answered. That is the
519
+ forbidden state ux.md is about, reached by treating silence as
520
+ success. */
521
+ if (signal !== null) {
522
+ /* ═══ A SIGNALLED PROCESS HAS NO EXIT CODE, AND SAYING IT "EXITED NULL"
523
+ IS NOT TRUE. ═══ `code` is null exactly when a signal ended the
524
+ process, and this daemon is the only thing that signals one: the
525
+ person's Stop (`killStopped`) and the person's correction
526
+ (`redirectedProcess`). Both reach here, both are printed, and the
527
+ second one can reach `failed_because` and a person's card if the
528
+ respawn behind it is refused. Naming the signal is the whole fix. */
529
+ finish({ ok: false, reason: `${agent} was ended by ${signal}${tail(stderr) || tail(stdout)}` });
530
+ }
531
+ else if (code !== 0) {
532
+ /* STDOUT WHEN STDERR IS EMPTY, because `claude -p` prints its own
533
+ failure on stdout and exits non-zero having written nothing to
534
+ stderr. Reporting only "exited 1" would throw away the one sentence
535
+ that says what went wrong. */
536
+ finish({ ok: false, reason: `${agent} exited ${code}${tail(stderr) || tail(stdout)}` });
537
+ }
538
+ else if (stdout.trim() === '') {
539
+ finish({ ok: false, reason: `${agent} exited 0 and said nothing${tail(stderr)}` });
540
+ }
541
+ else if (agent === 'codex') {
542
+ /* The stream, not the buffer. See `codexAnswer`: stdout here is
543
+ protocol, and a failed turn that exited 0 is still a failure. */
544
+ finish(codexAnswer(stdout));
545
+ }
546
+ else {
547
+ const text = stdout.trim();
548
+ finish({
549
+ ok: true,
550
+ text: truncated ? `${text}\n\n[cut off at ${MAX_OUTPUT_CHARS} characters]` : text,
551
+ });
552
+ }
553
+ });
554
+ /* THE PROMPT, AND THEN END OF INPUT, because `claude -p` reads stdin to the
555
+ end before it starts, and `codex exec` reads it from there whenever no
556
+ prompt argument is given. The error handler is not swallowing a failure: a
557
+ child that exits before reading gives EPIPE here, and the outcome of that
558
+ child is reported by `close` above with its real exit code. Without the
559
+ handler the EPIPE is an unhandled stream error and takes the daemon down
560
+ with it. */
561
+ child.stdin?.on('error', () => { });
562
+ child.stdin?.end(prompt);
563
+ });
564
+ return { pid: child.pid ?? null, session, answered };
565
+ }