@ctrl-spc/cs 0.7.0 → 0.7.2
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/README.md +1 -1
- package/dist/codex-home.js +72 -7
- package/dist/config.js +17 -0
- package/dist/index.js +93 -12
- package/dist/mcp.js +15 -6
- package/dist/panel3/answer.js +12 -12
- package/dist/panel3/checkout.js +522 -2
- package/dist/panel3/cli.js +49 -62
- package/dist/panel3/client.js +53 -16
- package/dist/panel3/presence.js +1 -1
- package/dist/panel3/prompt.js +473 -62
- package/dist/panel3/run.js +1206 -111
- package/dist/panel3/say.js +10 -10
- package/dist/panel3/session.js +128 -0
- package/dist/panel3/show.js +197 -23
- package/dist/panel3/spawn.js +85 -20
- package/dist/panel3/tools.js +677 -51
- package/dist/presence.js +8 -0
- package/dist/skills.js +165 -0
- package/dist/workflows.js +68 -0
- package/package.json +2 -3
package/dist/panel3/spawn.js
CHANGED
|
@@ -120,8 +120,9 @@
|
|
|
120
120
|
* with exactly the authority that level has.
|
|
121
121
|
*/
|
|
122
122
|
import { spawn as spawnChild } from 'node:child_process';
|
|
123
|
+
import { randomUUID } from 'node:crypto';
|
|
123
124
|
import { agentPath } from '../agents.js';
|
|
124
|
-
import { ensureCodexRunHome, removeCodexRunHome } from '../codex-home.js';
|
|
125
|
+
import { ensureCodexRunHome, ensurePanel3CodexOwnerHome, removeCodexRunHome, } from '../codex-home.js';
|
|
125
126
|
import { windowsSafeSpawn } from '../win-shell.js';
|
|
126
127
|
const AGENT_VAR = 'CTRL_SPC_V3_AGENT';
|
|
127
128
|
/**
|
|
@@ -168,9 +169,14 @@ const allowedTools = (level) => [`mcp__${SERVER}__*`, ...(level === 1 ? [] : COD
|
|
|
168
169
|
* checked without starting a process — which is how the allowlist is proved,
|
|
169
170
|
* and how it stays provable after this task.
|
|
170
171
|
*/
|
|
171
|
-
export function agentArgs(level, toolsUrl, agent = harness(), platform = process.platform) {
|
|
172
|
+
export function agentArgs(level, toolsUrl, agent = harness(), platform = process.platform, ownerSession) {
|
|
172
173
|
if (agent === 'codex')
|
|
173
|
-
return codexArgs(level, toolsUrl, platform);
|
|
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
|
+
: [];
|
|
174
180
|
return [
|
|
175
181
|
// `-p` with the prompt on stdin. See the header.
|
|
176
182
|
'-p',
|
|
@@ -180,6 +186,7 @@ export function agentArgs(level, toolsUrl, agent = harness(), platform = process
|
|
|
180
186
|
'--setting-sources', '',
|
|
181
187
|
'--tools', builtIns(level),
|
|
182
188
|
'--allowedTools', allowedTools(level),
|
|
189
|
+
...session,
|
|
183
190
|
/* STATED WHERE THERE IS SOMETHING TO STATE. `acceptEdits` grants file edits
|
|
184
191
|
to a headless process with nobody at a prompt to approve them, and it is
|
|
185
192
|
passed only to the levels that have a file tool to use it with: at level 1
|
|
@@ -214,16 +221,18 @@ export function agentArgs(level, toolsUrl, agent = harness(), platform = process
|
|
|
214
221
|
* `--json` makes the answer an `agent_message` item read out of the stream
|
|
215
222
|
* rather than whatever prose happened to reach stdout (see `codexAnswer`).
|
|
216
223
|
*/
|
|
217
|
-
function codexArgs(level, toolsUrl, platform) {
|
|
224
|
+
function codexArgs(level, toolsUrl, platform, ownerSession) {
|
|
225
|
+
const resume = ownerSession?.resumeSessionId;
|
|
218
226
|
return [
|
|
219
227
|
'exec',
|
|
228
|
+
...(resume ? ['resume', resume, '-'] : []),
|
|
220
229
|
// The prompt arrives on stdin, exactly as it does for claude: `codex exec`
|
|
221
230
|
// reads it from there when no prompt argument is given.
|
|
222
231
|
'--json',
|
|
223
232
|
// The level 1 scratch directory is not a repository, and neither need a
|
|
224
233
|
// working copy be.
|
|
225
234
|
'--skip-git-repo-check',
|
|
226
|
-
'--ephemeral',
|
|
235
|
+
...(ownerSession ? [] : ['--ephemeral']),
|
|
227
236
|
'--ignore-rules',
|
|
228
237
|
/* The current Windows Desktop runtime can define the run's one MCP server
|
|
229
238
|
on argv. Keeping the machine's real CODEX_HOME lets its installed sandbox
|
|
@@ -234,7 +243,10 @@ function codexArgs(level, toolsUrl, platform) {
|
|
|
234
243
|
'--ignore-user-config',
|
|
235
244
|
'-c', 'model="gpt-5.5"',
|
|
236
245
|
'-c', 'features.apps=false',
|
|
237
|
-
|
|
246
|
+
// The same closing `codex-home.ts` writes into the per-run config, and
|
|
247
|
+
// for the same measured reason: `features.multi_agent=false` parses and
|
|
248
|
+
// leaves the tool reachable.
|
|
249
|
+
'-c', 'agents.enabled=false',
|
|
238
250
|
'-c', `mcp_servers.${SERVER}.url=${JSON.stringify(toolsUrl)}`,
|
|
239
251
|
'-c', `mcp_servers.${SERVER}.default_tools_approval_mode="approve"`,
|
|
240
252
|
'-c', 'windows.sandbox="unelevated"',
|
|
@@ -245,7 +257,9 @@ function codexArgs(level, toolsUrl, platform) {
|
|
|
245
257
|
`danger-full-access`. Level 1 has no file tool to use it with, so it gets
|
|
246
258
|
the read-only sandbox instead — see the block comment above for what that
|
|
247
259
|
does and does not buy. */
|
|
248
|
-
|
|
260
|
+
...(resume
|
|
261
|
+
? ['-c', `sandbox_mode=${JSON.stringify(level === 1 ? 'read-only' : 'workspace-write')}`]
|
|
262
|
+
: ['-s', level === 1 ? 'read-only' : 'workspace-write']),
|
|
249
263
|
/* The write sandbox sandboxes the NETWORK too, and an agent handed a
|
|
250
264
|
credential that can then reach nothing is a dead end. Only where there is
|
|
251
265
|
a write sandbox to say it about. */
|
|
@@ -320,7 +334,7 @@ const MAX_OUTPUT_CHARS = 1_000_000;
|
|
|
320
334
|
* spawn can fail hand back a message that carries one — node's own spawn error
|
|
321
335
|
* reads `spawn /Users/…/claude ENOENT`, and an EACCES from the synchronous throw
|
|
322
336
|
* names the file too — and both of these become a run's `failed_because`, which
|
|
323
|
-
* `
|
|
337
|
+
* `cs show` prints and a coordinator can repeat to the person.
|
|
324
338
|
*
|
|
325
339
|
* `workingCopy()` above already makes this argument for the checkout and the
|
|
326
340
|
* fix is the same one: the CODE is what a person acts on, it is not a path, and
|
|
@@ -363,9 +377,10 @@ function tail(text, chars = 500) {
|
|
|
363
377
|
* process still alive" after the daemon that started it has been killed. So the
|
|
364
378
|
* caller gets the pid immediately, writes it, and then waits.
|
|
365
379
|
*/
|
|
366
|
-
export function startAgent(prompt, level, toolsUrl, cwd) {
|
|
380
|
+
export function startAgent(prompt, level, toolsUrl, cwd, ownerSession) {
|
|
367
381
|
const failed = (reason) => ({
|
|
368
382
|
pid: null,
|
|
383
|
+
session: Promise.resolve(null),
|
|
369
384
|
answered: Promise.resolve({ ok: false, reason }),
|
|
370
385
|
});
|
|
371
386
|
let agent;
|
|
@@ -377,7 +392,10 @@ export function startAgent(prompt, level, toolsUrl, cwd) {
|
|
|
377
392
|
// `harness()`: running the other one instead is the lie, not the failure.
|
|
378
393
|
return failed(err.message);
|
|
379
394
|
}
|
|
380
|
-
const
|
|
395
|
+
const launchedOwnerSession = ownerSession && !ownerSession.resumeSessionId
|
|
396
|
+
? { ...ownerSession, freshSessionId: randomUUID() }
|
|
397
|
+
: ownerSession;
|
|
398
|
+
const ARGS = agentArgs(level, toolsUrl, agent, process.platform, launchedOwnerSession);
|
|
381
399
|
const bin = agentPath(agent);
|
|
382
400
|
if (!bin) {
|
|
383
401
|
return failed(`${agent} is not installed on this machine`);
|
|
@@ -387,10 +405,12 @@ export function startAgent(prompt, level, toolsUrl, cwd) {
|
|
|
387
405
|
isolates its configuration and grants this run's one server on argv. */
|
|
388
406
|
const windowsCodex = agent === 'codex' && process.platform === 'win32';
|
|
389
407
|
const home = agent === 'codex' && !windowsCodex
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
408
|
+
? ownerSession
|
|
409
|
+
? ensurePanel3CodexOwnerHome({ url: toolsUrl }, ownerSession.ownerId)
|
|
410
|
+
// `false`: v3 closes codex's own subagent tool, the same depth-stops-at-
|
|
411
|
+
// three rule `CODE_TOOLS` enforces for claude below. v2's callers never
|
|
412
|
+
// pass this and keep today's behaviour; see `codex-home.ts`.
|
|
413
|
+
: ensureCodexRunHome({ url: toolsUrl }, null, runKey(toolsUrl), false)
|
|
394
414
|
: null;
|
|
395
415
|
if (agent === 'codex' && !windowsCodex && !home) {
|
|
396
416
|
return failed('codex is not signed in on this machine, so a run cannot be given this product\'s tools '
|
|
@@ -419,10 +439,24 @@ export function startAgent(prompt, level, toolsUrl, cwd) {
|
|
|
419
439
|
catch (err) {
|
|
420
440
|
// NOT `err.message`, which names the binary's absolute path. See
|
|
421
441
|
// `couldNotStart`.
|
|
422
|
-
if (home)
|
|
442
|
+
if (home && !ownerSession)
|
|
423
443
|
removeCodexRunHome(home);
|
|
424
444
|
return failed(couldNotStart(err, agent));
|
|
425
445
|
}
|
|
446
|
+
let resolveSession;
|
|
447
|
+
let sessionSettled = false;
|
|
448
|
+
const session = new Promise((resolve) => { resolveSession = resolve; });
|
|
449
|
+
const observeSession = (value) => {
|
|
450
|
+
if (sessionSettled)
|
|
451
|
+
return;
|
|
452
|
+
sessionSettled = true;
|
|
453
|
+
resolveSession(value);
|
|
454
|
+
};
|
|
455
|
+
if (!ownerSession)
|
|
456
|
+
observeSession(null);
|
|
457
|
+
else if (agent === 'claude') {
|
|
458
|
+
observeSession(launchedOwnerSession?.resumeSessionId ?? launchedOwnerSession?.freshSessionId ?? null);
|
|
459
|
+
}
|
|
426
460
|
const answered = new Promise((resolve) => {
|
|
427
461
|
let stdout = '';
|
|
428
462
|
let stderr = '';
|
|
@@ -447,11 +481,32 @@ export function startAgent(prompt, level, toolsUrl, cwd) {
|
|
|
447
481
|
/* THE CREDENTIAL COPY GOES WHEN THE RUN DOES. `codex-home.ts` calls this
|
|
448
482
|
the primary reclaim and the startup sweep the backstop; a home left
|
|
449
483
|
behind holds a copy of the user's codex credential. */
|
|
450
|
-
if (home)
|
|
484
|
+
if (home && !ownerSession)
|
|
451
485
|
removeCodexRunHome(home);
|
|
486
|
+
if (!sessionSettled)
|
|
487
|
+
observeSession(null);
|
|
452
488
|
resolve(answer);
|
|
453
489
|
};
|
|
454
|
-
child.stdout?.on('data', (d) => {
|
|
490
|
+
child.stdout?.on('data', (d) => {
|
|
491
|
+
stdout = collect(stdout, String(d));
|
|
492
|
+
if (ownerSession && agent === 'codex' && !sessionSettled) {
|
|
493
|
+
for (const line of stdout.split('\n')) {
|
|
494
|
+
try {
|
|
495
|
+
const event = JSON.parse(line);
|
|
496
|
+
if (event.type === 'thread.started' && typeof event.thread_id === 'string') {
|
|
497
|
+
const id = event.thread_id;
|
|
498
|
+
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)) {
|
|
499
|
+
observeSession(id);
|
|
500
|
+
break;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
catch {
|
|
505
|
+
// The final answer parser owns protocol validity. This observer only finds one event.
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
});
|
|
455
510
|
child.stderr?.on('data', (d) => { stderr = collect(stderr, String(d)); });
|
|
456
511
|
/* ENOENT and friends. The process never ran, and that is what is reported —
|
|
457
512
|
WITHOUT the message, which is where node puts the binary's absolute path.
|
|
@@ -459,14 +514,24 @@ export function startAgent(prompt, level, toolsUrl, cwd) {
|
|
|
459
514
|
child.on('error', (err) => {
|
|
460
515
|
finish({ ok: false, reason: couldNotStart(err, agent) });
|
|
461
516
|
});
|
|
462
|
-
child.on('close', (code) => {
|
|
517
|
+
child.on('close', (code, signal) => {
|
|
463
518
|
/* ═══ THREE OUTCOMES, AND ONLY ONE OF THEM IS AN ANSWER. ═══ A non-zero
|
|
464
519
|
exit and an exit of zero that said nothing are both failures, and
|
|
465
520
|
neither may be returned as an empty answer: the daemon would write a
|
|
466
521
|
blank agent turn and the card would read as answered. That is the
|
|
467
522
|
forbidden state ux.md is about, reached by treating silence as
|
|
468
523
|
success. */
|
|
469
|
-
if (
|
|
524
|
+
if (signal !== null) {
|
|
525
|
+
/* ═══ A SIGNALLED PROCESS HAS NO EXIT CODE, AND SAYING IT "EXITED NULL"
|
|
526
|
+
IS NOT TRUE. ═══ `code` is null exactly when a signal ended the
|
|
527
|
+
process, and this daemon is the only thing that signals one: the
|
|
528
|
+
person's Stop (`killStopped`) and the person's correction
|
|
529
|
+
(`redirectedProcess`). Both reach here, both are printed, and the
|
|
530
|
+
second one can reach `failed_because` and a person's card if the
|
|
531
|
+
respawn behind it is refused. Naming the signal is the whole fix. */
|
|
532
|
+
finish({ ok: false, reason: `${agent} was ended by ${signal}${tail(stderr) || tail(stdout)}` });
|
|
533
|
+
}
|
|
534
|
+
else if (code !== 0) {
|
|
470
535
|
/* STDOUT WHEN STDERR IS EMPTY, because `claude -p` prints its own
|
|
471
536
|
failure on stdout and exits non-zero having written nothing to
|
|
472
537
|
stderr. Reporting only "exited 1" would throw away the one sentence
|
|
@@ -499,5 +564,5 @@ export function startAgent(prompt, level, toolsUrl, cwd) {
|
|
|
499
564
|
child.stdin?.on('error', () => { });
|
|
500
565
|
child.stdin?.end(prompt);
|
|
501
566
|
});
|
|
502
|
-
return { pid: child.pid ?? null, answered };
|
|
567
|
+
return { pid: child.pid ?? null, session, answered };
|
|
503
568
|
}
|