@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.
@@ -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
- '-c', 'features.multi_agent=false',
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
- '-s', level === 1 ? 'read-only' : 'workspace-write',
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
- * `cs3 show` prints and a coordinator can repeat to the person.
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 ARGS = agentArgs(level, toolsUrl, agent);
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
- // `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)
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) => { stdout = collect(stdout, String(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 (code !== 0) {
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
  }