@ctrl-spc/cs 0.5.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.
@@ -0,0 +1,503 @@
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 { agentPath } from '../agents.js';
124
+ import { ensureCodexRunHome, removeCodexRunHome } from '../codex-home.js';
125
+ import { windowsSafeSpawn } from '../win-shell.js';
126
+ const AGENT_VAR = 'CTRL_SPC_V3_AGENT';
127
+ /**
128
+ * The harness named on this machine, or the reason the name is not one.
129
+ *
130
+ * ═══ AN UNKNOWN NAME THROWS RATHER THAN FALLING BACK TO CLAUDE. ═══ Constraint
131
+ * 7: a failure stays a failure. Somebody who sets this to `claude-code` or
132
+ * `gpt` has said which harness they want, and quietly running the other one
133
+ * would produce a whole session of results attributed to a binary that never
134
+ * ran. `startAgent` turns this into "no agent was started", which is true.
135
+ */
136
+ export function harness(env = process.env) {
137
+ const named = (env[AGENT_VAR] ?? '').trim().toLowerCase();
138
+ if (named === '' || named === 'claude')
139
+ return 'claude';
140
+ if (named === 'codex')
141
+ return 'codex';
142
+ throw new Error(`${AGENT_VAR} names "${named}", which is not a harness this build can spawn. `
143
+ + 'It is `claude` or `codex`, or unset for claude.');
144
+ }
145
+ /** The MCP server name the child sees, so its tools are `mcp__ctrl-spc__…`.
146
+ * Paired with `--strict-mcp-config` it is the only server that exists in that
147
+ * process, so there is nothing for the name to collide with. */
148
+ const SERVER = 'ctrl-spc';
149
+ /** ═══ THE HARNESS'S CODE TOOLS, FOR THE LEVELS THAT DO THE WORK. ═══
150
+ *
151
+ * Read, search, edit, run. `Task` is deliberately NOT here: it spawns a
152
+ * subagent inside the harness, which is a dispatch the record never sees and a
153
+ * fourth level ux.md does not have. Depth stops at three, and it stops in the
154
+ * tools rather than in a sentence asking an agent not to. Codex's own
155
+ * subagent tool gets the same closing, in `codex-home.ts`'s per-run
156
+ * `[agents] enabled = false` — a reader of either file finds the other. */
157
+ const CODE_TOOLS = ['Read', 'Glob', 'Grep', 'Edit', 'Write', 'Bash'];
158
+ /** Which of the harness's built-ins EXIST for this level. Empty at level 1: not
159
+ * denied, not present. */
160
+ const builtIns = (level) => (level === 1 ? '' : CODE_TOOLS.join(','));
161
+ /** What runs without a human approving it: the product's tools, and the code
162
+ * tools at the levels that have any. */
163
+ const allowedTools = (level) => [`mcp__${SERVER}__*`, ...(level === 1 ? [] : CODE_TOOLS)].join(',');
164
+ /**
165
+ * The child's whole argument list, for one level, pointed at one run's tools.
166
+ *
167
+ * SEPARATE FROM THE SPAWN so that what a level is granted can be printed and
168
+ * checked without starting a process — which is how the allowlist is proved,
169
+ * and how it stays provable after this task.
170
+ */
171
+ export function agentArgs(level, toolsUrl, agent = harness(), platform = process.platform) {
172
+ if (agent === 'codex')
173
+ return codexArgs(level, toolsUrl, platform);
174
+ return [
175
+ // `-p` with the prompt on stdin. See the header.
176
+ '-p',
177
+ '--mcp-config',
178
+ JSON.stringify({ mcpServers: { [SERVER]: { type: 'http', url: toolsUrl } } }),
179
+ '--strict-mcp-config',
180
+ '--setting-sources', '',
181
+ '--tools', builtIns(level),
182
+ '--allowedTools', allowedTools(level),
183
+ /* STATED WHERE THERE IS SOMETHING TO STATE. `acceptEdits` grants file edits
184
+ to a headless process with nobody at a prompt to approve them, and it is
185
+ passed only to the levels that have a file tool to use it with: at level 1
186
+ it would describe an authority over a tool that is not there. */
187
+ ...(level === 1 ? [] : ['--permission-mode', 'acceptEdits']),
188
+ ];
189
+ }
190
+ /**
191
+ * ═══ THE SAME GRANT, SAID THE ONLY WAY CODEX OFFERS. ═══
192
+ *
193
+ * The claude argv above says four things: these tools exist, these run without
194
+ * anybody approving them, this one MCP server and no other, and none of the
195
+ * user's own settings. On macOS, three of those move into a per-run
196
+ * `$CODEX_HOME`. The current Windows Desktop runtime accepts the same isolation
197
+ * through `--ignore-user-config` and `-c`, while retaining its installed
198
+ * credential and sandbox state. Both paths are pinned below.
199
+ *
200
+ * ═══ THE ONE THING THAT CANNOT BE SAID, STATED RATHER THAN GLOSSED. ═══
201
+ * `--tools ''` gives a claude level 1 NO code tool at all — not denied, not
202
+ * present. Codex has no such switch: its shell is built in, `tools.shell` is not
203
+ * a configuration field (measured against codex-cli 0.147.0-alpha.6.5: "unknown
204
+ * configuration field `tools.shell`"), and there is no allow list. The closest
205
+ * honest thing is a READ-ONLY sandbox, which is what level 1 gets: it cannot
206
+ * change the machine it runs on, and it runs in the empty scratch directory the
207
+ * caller hands it. It can still read. That is a floor of the harness, it is
208
+ * recorded in status.md as one, and it changes nothing about the product's own
209
+ * tools — those come from the run row through the URL, identically for both.
210
+ *
211
+ * `--ephemeral` keeps the session transcript, which holds absolute local paths,
212
+ * off the disk entirely. `--ignore-rules` is the settings half of claude's
213
+ * `--setting-sources ''` for the execpolicy files that live outside the home.
214
+ * `--json` makes the answer an `agent_message` item read out of the stream
215
+ * rather than whatever prose happened to reach stdout (see `codexAnswer`).
216
+ */
217
+ function codexArgs(level, toolsUrl, platform) {
218
+ return [
219
+ 'exec',
220
+ // The prompt arrives on stdin, exactly as it does for claude: `codex exec`
221
+ // reads it from there when no prompt argument is given.
222
+ '--json',
223
+ // The level 1 scratch directory is not a repository, and neither need a
224
+ // working copy be.
225
+ '--skip-git-repo-check',
226
+ '--ephemeral',
227
+ '--ignore-rules',
228
+ /* The current Windows Desktop runtime can define the run's one MCP server
229
+ on argv. Keeping the machine's real CODEX_HOME lets its installed sandbox
230
+ helpers and credential work, while this flag keeps every user setting,
231
+ connector and MCP server out of the run. Other platforms retain the
232
+ proven per-run home below. */
233
+ ...(platform === 'win32' ? [
234
+ '--ignore-user-config',
235
+ '-c', 'model="gpt-5.5"',
236
+ '-c', 'features.apps=false',
237
+ '-c', 'features.multi_agent=false',
238
+ '-c', `mcp_servers.${SERVER}.url=${JSON.stringify(toolsUrl)}`,
239
+ '-c', `mcp_servers.${SERVER}.default_tools_approval_mode="approve"`,
240
+ '-c', 'windows.sandbox="unelevated"',
241
+ '-c', 'windows.sandbox_private_desktop=false',
242
+ ] : []),
243
+ /* THE POSTURE, STATED. `workspace-write` is codex's `acceptEdits`: file
244
+ edits without a human at a prompt, and deliberately not
245
+ `danger-full-access`. Level 1 has no file tool to use it with, so it gets
246
+ the read-only sandbox instead — see the block comment above for what that
247
+ does and does not buy. */
248
+ '-s', level === 1 ? 'read-only' : 'workspace-write',
249
+ /* The write sandbox sandboxes the NETWORK too, and an agent handed a
250
+ credential that can then reach nothing is a dead end. Only where there is
251
+ a write sandbox to say it about. */
252
+ ...(level === 1 ? [] : ['-c', 'sandbox_workspace_write.network_access=true']),
253
+ ];
254
+ }
255
+ /** ═══ ONE RUN, ONE DIRECTORY. ═══ `codex-home.ts` keys a home by the request
256
+ * it is working, because two runs sharing one directory is a race that ends
257
+ * with a live run's credential deleted from under it. v3's tools URL ends in
258
+ * the run id, which is exactly that key, and `codexRunHomePath` sanitises
259
+ * whatever it is handed before it becomes a directory name. */
260
+ function runKey(toolsUrl) {
261
+ const last = toolsUrl.split('/').pop();
262
+ return last && last !== '' ? last : null;
263
+ }
264
+ /**
265
+ * ═══ WHAT A CODEX RUN SAID, OUT OF THE STREAM RATHER THAN OFF STDOUT. ═══
266
+ *
267
+ * A claude answer under `-p` IS stdout. Codex's stdout under `--json` is its
268
+ * protocol: thread ids, tool calls with their arguments, reasoning items. Handed
269
+ * to a person as an answer it would be gibberish, and written to `panel3_turns`
270
+ * it would be gibberish carrying absolute local paths, so the answer is the text
271
+ * of the last completed `agent_message` and nothing else.
272
+ *
273
+ * ═══ A FAILED TURN BEATS A MESSAGE, EVEN THOUGH THE PROCESS EXITED 0. ═══ Codex
274
+ * can emit an `agent_message` and then a `turn.failed` and still exit cleanly.
275
+ * The exit code sees success, the message looks like an answer, and the card
276
+ * would read as answered by a turn the harness itself says did not finish. That
277
+ * is the forbidden state ux.md is about, so the failure wins wherever both are
278
+ * present.
279
+ */
280
+ export function codexAnswer(stdout) {
281
+ let text = null;
282
+ let failure = null;
283
+ for (const line of stdout.split('\n')) {
284
+ const trimmed = line.trim();
285
+ if (!trimmed || trimmed[0] !== '{')
286
+ continue;
287
+ let event;
288
+ try {
289
+ event = JSON.parse(trimmed);
290
+ }
291
+ catch {
292
+ // Not protocol. Codex prints nothing else on stdout under `--json`, and a
293
+ // line that is not JSON is not an answer either way.
294
+ continue;
295
+ }
296
+ if (event.type === 'item.completed' && event.item?.type === 'agent_message') {
297
+ if (typeof event.item.text === 'string')
298
+ text = event.item.text;
299
+ }
300
+ else if (event.type === 'turn.failed') {
301
+ const named = event.error?.message ?? event.message;
302
+ failure = typeof named === 'string' ? named : '';
303
+ }
304
+ }
305
+ if (failure !== null) {
306
+ return { ok: false, reason: `codex could not finish the turn${failure ? `: ${failure}` : ''}` };
307
+ }
308
+ if (text === null || text.trim() === '') {
309
+ return { ok: false, reason: 'codex exited 0 without saying anything to the person' };
310
+ }
311
+ return { ok: true, text: text.trim() };
312
+ }
313
+ /** Enough for any answer a person reads, and a ceiling so a runaway process
314
+ * cannot exhaust this daemon's memory. */
315
+ const MAX_OUTPUT_CHARS = 1_000_000;
316
+ /**
317
+ * ═══ WHY A PROCESS DID NOT START, WITHOUT SAYING WHERE THE BINARY IS. ═══
318
+ *
319
+ * Constraint 6: no absolute filesystem path reaches the database. Both ways a
320
+ * spawn can fail hand back a message that carries one — node's own spawn error
321
+ * reads `spawn /Users/…/claude ENOENT`, and an EACCES from the synchronous throw
322
+ * names the file too — and both of these become a run's `failed_because`, which
323
+ * `cs3 show` prints and a coordinator can repeat to the person.
324
+ *
325
+ * `workingCopy()` above already makes this argument for the checkout and the
326
+ * fix is the same one: the CODE is what a person acts on, it is not a path, and
327
+ * the machine's own sentence is left for whoever is reading stderr on that
328
+ * machine. Nothing is softened — it is still a failure, still says no agent
329
+ * started, and still says what the operating system called it.
330
+ */
331
+ const couldNotStart = (err, agent) => {
332
+ const code = err.code;
333
+ return `could not start ${agent} on this machine`
334
+ + (typeof code === 'string' ? `: ${code}` : '');
335
+ };
336
+ /** The last of a stream, for an error message that has to stay readable. */
337
+ function tail(text, chars = 500) {
338
+ const trimmed = text.trim();
339
+ if (trimmed === '')
340
+ return '';
341
+ return trimmed.length <= chars ? `: ${trimmed}` : `: ...${trimmed.slice(-chars)}`;
342
+ }
343
+ /**
344
+ * Start one agent on this machine, at one level, connected to one run's tools,
345
+ * in one directory.
346
+ *
347
+ * ═══ THE DIRECTORY IS THE CALLER'S TO CHOOSE, AND IT IS RESOLVED BEFORE THE
348
+ * RUN ROW EXISTS. ═══ Level 1 is given an empty scratch directory because it has
349
+ * no code tool to use one with; a dispatched agent is given the working copy.
350
+ * The caller resolves it first so that a machine with no working copy fails
351
+ * BEFORE anything is written, rather than leaving a run row for a process that
352
+ * was never started.
353
+ *
354
+ * ═══ THE LEVEL IS PASSED TO THE HARNESS AND NEVER TO THE AGENT. ═══ It decides
355
+ * argv and nothing else. What the agent may call it learns by being handed a
356
+ * tool list, and the tools server derives that list from the run id in
357
+ * `toolsUrl` rather than from anything said here — so the two halves of the
358
+ * grant cannot drift, because only one of them is ever told a number.
359
+ *
360
+ * ═══ IT STARTS SYNCHRONOUSLY AND HANDS BACK THE PID SEPARATELY. ═══ A single
361
+ * `Promise<Answer>` would hide the pid until the process had already finished,
362
+ * and by then recording it is pointless: the pid exists to answer "is this run's
363
+ * process still alive" after the daemon that started it has been killed. So the
364
+ * caller gets the pid immediately, writes it, and then waits.
365
+ */
366
+ export function startAgent(prompt, level, toolsUrl, cwd) {
367
+ const failed = (reason) => ({
368
+ pid: null,
369
+ answered: Promise.resolve({ ok: false, reason }),
370
+ });
371
+ let agent;
372
+ try {
373
+ agent = harness();
374
+ }
375
+ catch (err) {
376
+ // A machine configured for a harness this build has never heard of. See
377
+ // `harness()`: running the other one instead is the lie, not the failure.
378
+ return failed(err.message);
379
+ }
380
+ const ARGS = agentArgs(level, toolsUrl, agent);
381
+ const bin = agentPath(agent);
382
+ if (!bin) {
383
+ return failed(`${agent} is not installed on this machine`);
384
+ }
385
+ /* macOS uses the proven per-run home. Windows keeps its installed home so the
386
+ Desktop runtime's ACL-bound sandbox helpers remain valid; `codexArgs`
387
+ isolates its configuration and grants this run's one server on argv. */
388
+ const windowsCodex = agent === 'codex' && process.platform === 'win32';
389
+ const home = agent === 'codex' && !windowsCodex
390
+ // `false`: v3 closes codex's own subagent tool, the same depth-stops-at-
391
+ // three rule `CODE_TOOLS` enforces for claude below. v2's callers never
392
+ // pass this and keep today's behaviour; see `codex-home.ts`.
393
+ ? ensureCodexRunHome({ url: toolsUrl }, null, runKey(toolsUrl), false)
394
+ : null;
395
+ if (agent === 'codex' && !windowsCodex && !home) {
396
+ return failed('codex is not signed in on this machine, so a run cannot be given this product\'s tools '
397
+ + 'and nothing else. Sign codex in on the machine running this, and start it again.');
398
+ }
399
+ const { args, shell } = windowsSafeSpawn(bin, ARGS);
400
+ let child;
401
+ try {
402
+ child = spawnChild(bin, args, {
403
+ // The repo-wide invariant for ANY child process (MEMORY: "Windows silence
404
+ // decision"). A user must never see a console flash.
405
+ windowsHide: true,
406
+ shell,
407
+ stdio: ['pipe', 'pipe', 'pipe'],
408
+ // NEVER the daemon's inherited cwd, which under a launchd login item is
409
+ // the filesystem root. The caller says where, and both answers are
410
+ // deliberate: an empty scratch directory, or the working copy.
411
+ cwd,
412
+ /* THE PARENT'S ENVIRONMENT, PLUS THE HOME. Codex needs PATH and HOME to
413
+ run at all, and `$CODEX_HOME` is the only place its MCP server can be
414
+ said. Claude is spawned with the environment untouched, exactly as
415
+ before. */
416
+ ...(home ? { env: { ...process.env, CODEX_HOME: home } } : {}),
417
+ });
418
+ }
419
+ catch (err) {
420
+ // NOT `err.message`, which names the binary's absolute path. See
421
+ // `couldNotStart`.
422
+ if (home)
423
+ removeCodexRunHome(home);
424
+ return failed(couldNotStart(err, agent));
425
+ }
426
+ const answered = new Promise((resolve) => {
427
+ let stdout = '';
428
+ let stderr = '';
429
+ let truncated = false;
430
+ let settled = false;
431
+ const collect = (into, chunk) => {
432
+ if (into.length >= MAX_OUTPUT_CHARS) {
433
+ truncated = true;
434
+ return into;
435
+ }
436
+ return into + chunk;
437
+ };
438
+ /* ═══ ONE ANSWER, WHICHEVER OF THE TWO EVENTS ARRIVES. ═══ `error` and
439
+ `close` can both fire for one child, and the second must not overwrite
440
+ what the first said. There is no timer to clear: see the header — nothing
441
+ in this file stops an agent, and the process runs until it exits or the
442
+ user stops it. */
443
+ const finish = (answer) => {
444
+ if (settled)
445
+ return;
446
+ settled = true;
447
+ /* THE CREDENTIAL COPY GOES WHEN THE RUN DOES. `codex-home.ts` calls this
448
+ the primary reclaim and the startup sweep the backstop; a home left
449
+ behind holds a copy of the user's codex credential. */
450
+ if (home)
451
+ removeCodexRunHome(home);
452
+ resolve(answer);
453
+ };
454
+ child.stdout?.on('data', (d) => { stdout = collect(stdout, String(d)); });
455
+ child.stderr?.on('data', (d) => { stderr = collect(stderr, String(d)); });
456
+ /* ENOENT and friends. The process never ran, and that is what is reported —
457
+ WITHOUT the message, which is where node puts the binary's absolute path.
458
+ The same failure as the throw above, arriving asynchronously. */
459
+ child.on('error', (err) => {
460
+ finish({ ok: false, reason: couldNotStart(err, agent) });
461
+ });
462
+ child.on('close', (code) => {
463
+ /* ═══ THREE OUTCOMES, AND ONLY ONE OF THEM IS AN ANSWER. ═══ A non-zero
464
+ exit and an exit of zero that said nothing are both failures, and
465
+ neither may be returned as an empty answer: the daemon would write a
466
+ blank agent turn and the card would read as answered. That is the
467
+ forbidden state ux.md is about, reached by treating silence as
468
+ success. */
469
+ if (code !== 0) {
470
+ /* STDOUT WHEN STDERR IS EMPTY, because `claude -p` prints its own
471
+ failure on stdout and exits non-zero having written nothing to
472
+ stderr. Reporting only "exited 1" would throw away the one sentence
473
+ that says what went wrong. */
474
+ finish({ ok: false, reason: `${agent} exited ${code}${tail(stderr) || tail(stdout)}` });
475
+ }
476
+ else if (stdout.trim() === '') {
477
+ finish({ ok: false, reason: `${agent} exited 0 and said nothing${tail(stderr)}` });
478
+ }
479
+ else if (agent === 'codex') {
480
+ /* The stream, not the buffer. See `codexAnswer`: stdout here is
481
+ protocol, and a failed turn that exited 0 is still a failure. */
482
+ finish(codexAnswer(stdout));
483
+ }
484
+ else {
485
+ const text = stdout.trim();
486
+ finish({
487
+ ok: true,
488
+ text: truncated ? `${text}\n\n[cut off at ${MAX_OUTPUT_CHARS} characters]` : text,
489
+ });
490
+ }
491
+ });
492
+ /* THE PROMPT, AND THEN END OF INPUT, because `claude -p` reads stdin to the
493
+ end before it starts, and `codex exec` reads it from there whenever no
494
+ prompt argument is given. The error handler is not swallowing a failure: a
495
+ child that exits before reading gives EPIPE here, and the outcome of that
496
+ child is reported by `close` above with its real exit code. Without the
497
+ handler the EPIPE is an unhandled stream error and takes the daemon down
498
+ with it. */
499
+ child.stdin?.on('error', () => { });
500
+ child.stdin?.end(prompt);
501
+ });
502
+ return { pid: child.pid ?? null, answered };
503
+ }