@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.
- package/dist/agents.js +70 -5
- package/dist/autostart.js +15 -1
- package/dist/browser.js +386 -0
- package/dist/codebases.js +15 -0
- package/dist/codex-home.js +562 -0
- package/dist/companion.js +9 -12
- package/dist/config.js +23 -0
- package/dist/daemon.js +33 -3
- package/dist/env.js +42 -0
- package/dist/failure-reason.js +98 -0
- package/dist/index.js +1 -1
- package/dist/mcp.js +7627 -298
- package/dist/orchestrator.js +6011 -0
- package/dist/panel3/answer.js +166 -0
- package/dist/panel3/checkout.js +29 -0
- package/dist/panel3/cli.js +83 -0
- package/dist/panel3/client.js +181 -0
- package/dist/panel3/coordinator.js +18 -0
- package/dist/panel3/presence.js +162 -0
- package/dist/panel3/prompt.js +945 -0
- package/dist/panel3/run.js +2516 -0
- package/dist/panel3/say.js +262 -0
- package/dist/panel3/secrets.js +98 -0
- package/dist/panel3/session.js +128 -0
- package/dist/panel3/show.js +997 -0
- package/dist/panel3/spawn.js +565 -0
- package/dist/panel3/tools.js +1906 -0
- package/dist/presence.js +178 -6
- package/dist/win-shell.js +162 -0
- package/dist/work-context.js +1484 -0
- package/dist/workflows.js +68 -0
- package/package.json +3 -2
|
@@ -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
|
+
}
|