@ctrl-spc/cs 0.7.14 → 0.7.16

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.
@@ -1,3 +1,5 @@
1
+ import { nativeFailureKind, failureMessage } from '../failure-reason.js';
2
+ import { CodexHomeFailure } from '../codex-home.js';
1
3
  /**
2
4
  * ═══ AGENT PANEL v3: running one headless agent and reading what it said. ═══
3
5
  *
@@ -123,7 +125,7 @@ import { spawn as spawnChild } from 'node:child_process';
123
125
  import { randomUUID } from 'node:crypto';
124
126
  import { agentPath } from '../agents.js';
125
127
  import { ensureCodexRunHome, ensurePanel3CodexOwnerHome, removeCodexRunHome, } from '../codex-home.js';
126
- import { windowsSafeSpawn } from '../win-shell.js';
128
+ import { windowsSafeSpawn, spawnOwnedProcess, releaseOwnedProcess } from '../win-shell.js';
127
129
  import { modelChoiceRules } from './prompt.js';
128
130
  const AGENT_VAR = 'CTRL_SPC_V3_AGENT';
129
131
  /**
@@ -181,6 +183,7 @@ export function agentArgs(level, toolsUrl, agent = harness(), platform = process
181
183
  return [
182
184
  // `-p` with the prompt on stdin. See the header.
183
185
  '-p',
186
+ '--output-format', 'json',
184
187
  '--mcp-config',
185
188
  JSON.stringify({ mcpServers: { [SERVER]: { type: 'http', url: toolsUrl } } }),
186
189
  '--strict-mcp-config',
@@ -297,6 +300,9 @@ function runKey(toolsUrl) {
297
300
  * present.
298
301
  */
299
302
  export function codexAnswer(stdout, exitCode = 0, stderr = '') {
303
+ const failureKind = nativeFailureKind(stdout, stderr);
304
+ if (failureKind !== 'unknown')
305
+ return { ok: false, failureKind, retryable: failureKind === 'transient', reason: failureMessage('codex', failureKind) };
300
306
  let text = null;
301
307
  let failure = null;
302
308
  for (const line of stdout.split('\n')) {
@@ -341,16 +347,26 @@ export function codexAnswer(stdout, exitCode = 0, stderr = '') {
341
347
  }
342
348
  return { ok: true, text: text.trim() };
343
349
  }
344
- /** Claude prints provider/model rejections on stdout, even with a nonzero exit. */
350
+ /** The native result envelope distinguishes a provider failure from answer prose. */
345
351
  export function claudeAnswer(stdout, exitCode = 0, stderr = '') {
352
+ const failureKind = nativeFailureKind(stdout, stderr);
353
+ if (failureKind !== 'unknown')
354
+ return { ok: false, failureKind, retryable: failureKind === 'transient', reason: failureMessage('claude', failureKind) };
346
355
  if (/^\[claude-code:unrecognized_model\]/m.test(stdout + '\n' + stderr)) {
347
- return { ok: false, retryable: false, reason: 'Claude rejected the selected model. Choose an available model and retry.' };
356
+ return { ok: false, failureKind: 'invalid-model', retryable: false, reason: 'Claude rejected the selected model. Choose an available model and retry.' };
348
357
  }
349
358
  if (exitCode !== 0)
350
359
  return { ok: false, reason: `claude exited ${exitCode}${tail(stderr) || tail(stdout)}` };
351
360
  if (stdout.trim() === '')
352
361
  return { ok: false, reason: `claude exited 0 and said nothing${tail(stderr)}` };
353
- return { ok: true, text: stdout.trim() };
362
+ try {
363
+ const result = JSON.parse(stdout);
364
+ if (result?.type === 'result' && result.is_error === false && typeof result.result === 'string' && result.result.trim()) {
365
+ return { ok: true, text: result.result.trim() };
366
+ }
367
+ }
368
+ catch { /* A malformed native result is not a completed answer. */ }
369
+ return { ok: false, reason: 'Claude did not return a completed result.' };
354
370
  }
355
371
  /** Enough for any answer a person reads, and a ceiling so a runaway process
356
372
  * cannot exhaust this daemon's memory. */
@@ -405,11 +421,16 @@ function tail(text, chars = 500) {
405
421
  * process still alive" after the daemon that started it has been killed. So the
406
422
  * caller gets the pid immediately, writes it, and then waits.
407
423
  */
408
- export function startAgent(prompt, level, toolsUrl, cwd, ownerSession, settings = {}) {
409
- const failed = (reason) => ({
424
+ export function startAgent(prompt, level, toolsUrl, cwd, ownerSession, settings = {}, lifecycle) {
425
+ if (lifecycle?.interrupted())
426
+ return { pid: null, session: Promise.resolve(null),
427
+ answered: Promise.resolve({ ok: false, reason: 'Work was interrupted by the service command.' }),
428
+ interrupted: lifecycle.interrupted, completed: lifecycle.complete };
429
+ const failed = (reason, failureKind = 'unknown') => ({
410
430
  pid: null,
411
431
  session: Promise.resolve(null),
412
- answered: Promise.resolve({ ok: false, reason }),
432
+ answered: Promise.resolve({ ok: false, reason, failureKind, retryable: false }),
433
+ ...(lifecycle ? { interrupted: lifecycle.interrupted, completed: lifecycle.complete } : {}),
413
434
  });
414
435
  let agent;
415
436
  try {
@@ -429,7 +450,7 @@ export function startAgent(prompt, level, toolsUrl, cwd, ownerSession, settings
429
450
  prompt = `${modelChoiceRules(agent)}\n\n${prompt}`;
430
451
  const bin = agentPath(agent);
431
452
  if (!bin) {
432
- return failed(`${agent} is not installed on this machine`);
453
+ return failed(`${agent} is not installed on this machine`, 'missing-binary');
433
454
  }
434
455
  /* macOS uses the proven per-run home. Windows keeps its installed home so the
435
456
  Desktop runtime's ACL-bound sandbox helpers remain valid; `codexArgs`
@@ -443,17 +464,17 @@ export function startAgent(prompt, level, toolsUrl, cwd, ownerSession, settings
443
464
  // pass this and keep today's behaviour; see `codex-home.ts`.
444
465
  : ensureCodexRunHome({ url: toolsUrl }, null, runKey(toolsUrl), false)
445
466
  : null;
446
- if (agent === 'codex' && !windowsCodex && !home) {
447
- return failed('codex is not signed in on this machine, so a run cannot be given this product\'s tools '
448
- + 'and nothing else. Sign codex in on the machine running this, and start it again.');
449
- }
467
+ if (home instanceof CodexHomeFailure)
468
+ return failed(home.message, home.kind);
450
469
  const { args, shell } = windowsSafeSpawn(bin, ARGS);
451
470
  let child;
452
471
  try {
453
- child = spawnChild(bin, args, {
472
+ lifecycle?.beforeSpawn();
473
+ const options = {
454
474
  // The repo-wide invariant for ANY child process (MEMORY: "Windows silence
455
475
  // decision"). A user must never see a console flash.
456
476
  windowsHide: true,
477
+ detached: process.platform !== 'win32',
457
478
  shell,
458
479
  stdio: ['pipe', 'pipe', 'pipe'],
459
480
  // NEVER the daemon's inherited cwd, which under a launchd login item is
@@ -465,14 +486,17 @@ export function startAgent(prompt, level, toolsUrl, cwd, ownerSession, settings
465
486
  said. Claude is spawned with the environment untouched, exactly as
466
487
  before. */
467
488
  ...(home ? { env: { ...process.env, CODEX_HOME: home } } : {}),
468
- });
489
+ };
490
+ child = lifecycle
491
+ ? spawnOwnedProcess(bin, args, options, lifecycle.id)
492
+ : spawnChild(bin, args, options);
469
493
  }
470
494
  catch (err) {
471
495
  // NOT `err.message`, which names the binary's absolute path. See
472
496
  // `couldNotStart`.
473
497
  if (home && !ownerSession)
474
498
  removeCodexRunHome(home);
475
- return failed(couldNotStart(err, agent));
499
+ return failed(couldNotStart(err, agent), 'preparation-unavailable');
476
500
  }
477
501
  let resolveSession;
478
502
  let sessionSettled = false;
@@ -488,6 +512,7 @@ export function startAgent(prompt, level, toolsUrl, cwd, ownerSession, settings
488
512
  else if (agent === 'claude') {
489
513
  observeSession(launchedOwnerSession?.resumeSessionId ?? launchedOwnerSession?.freshSessionId ?? null);
490
514
  }
515
+ let registration = Promise.resolve();
491
516
  const answered = new Promise((resolve) => {
492
517
  let stdout = '';
493
518
  let stderr = '';
@@ -512,11 +537,32 @@ export function startAgent(prompt, level, toolsUrl, cwd, ownerSession, settings
512
537
  /* THE CREDENTIAL COPY GOES WHEN THE RUN DOES. `codex-home.ts` calls this
513
538
  the primary reclaim and the startup sweep the backstop; a home left
514
539
  behind holds a copy of the user's codex credential. */
515
- if (home && !ownerSession)
516
- removeCodexRunHome(home);
517
- if (!sessionSettled)
518
- observeSession(null);
519
- resolve(answer);
540
+ void (async () => {
541
+ await registration.catch(() => { });
542
+ if (lifecycle && child.pid) {
543
+ // A wrapper's close is not tree exit. Keep the run owned until the
544
+ // actual descendants end; lifecycle commands have their own deadline.
545
+ let warned = false;
546
+ while (true) {
547
+ try {
548
+ await lifecycle.exited();
549
+ break;
550
+ }
551
+ catch (error) {
552
+ if (!warned) {
553
+ console.warn(`Owned agent execution has not ended: ${error.message}`);
554
+ warned = true;
555
+ }
556
+ await new Promise((done) => setTimeout(done, 250));
557
+ }
558
+ }
559
+ }
560
+ if (home && !ownerSession)
561
+ removeCodexRunHome(home);
562
+ if (!sessionSettled)
563
+ observeSession(null);
564
+ resolve(answer);
565
+ })();
520
566
  };
521
567
  child.stdout?.on('data', (d) => {
522
568
  stdout = collect(stdout, String(d));
@@ -543,7 +589,7 @@ export function startAgent(prompt, level, toolsUrl, cwd, ownerSession, settings
543
589
  WITHOUT the message, which is where node puts the binary's absolute path.
544
590
  The same failure as the throw above, arriving asynchronously. */
545
591
  child.on('error', (err) => {
546
- finish({ ok: false, reason: couldNotStart(err, agent) });
592
+ finish({ ok: false, reason: couldNotStart(err, agent), failureKind: 'preparation-unavailable', retryable: false });
547
593
  });
548
594
  child.on('close', (code, signal) => {
549
595
  /* ═══ THREE OUTCOMES, AND ONLY ONE OF THEM IS AN ANSWER. ═══ A non-zero
@@ -582,7 +628,23 @@ export function startAgent(prompt, level, toolsUrl, cwd, ownerSession, settings
582
628
  handler the EPIPE is an unhandled stream error and takes the daemon down
583
629
  with it. */
584
630
  child.stdin?.on('error', () => { });
585
- child.stdin?.end(prompt);
631
+ if (lifecycle) {
632
+ registration = lifecycle.register(child, agent);
633
+ void registration.then(() => {
634
+ if (lifecycle.interrupted())
635
+ return;
636
+ releaseOwnedProcess(child);
637
+ child.stdin?.end(prompt);
638
+ }).catch((error) => {
639
+ // No prompt was delivered. The held native handle may be cancelled
640
+ // even when durable ownership registration itself failed.
641
+ child.stdin?.destroy();
642
+ child.kill('SIGKILL');
643
+ finish({ ok: false, reason: `The agent could not be safely registered: ${error.message}` });
644
+ });
645
+ }
646
+ else
647
+ child.stdin?.end(prompt);
586
648
  });
587
- return { pid: child.pid ?? null, session, answered };
649
+ return { pid: child.pid ?? null, session, answered, ...(lifecycle ? { interrupted: lifecycle.interrupted, completed: lifecycle.complete } : {}) };
588
650
  }
@@ -1,3 +1,4 @@
1
+ import { clientSessionCurrent } from '../supabase.js';
1
2
  import { createEpicHandler, createSprintHandler, listStructureHandler, updateStructureHandler, listStructureArtifactsHandler, getStructureArtifactHandler, searchAgentCardsHandler, createStructureArtifactHandler, updateStructureArtifactHandler } from '../product-tools.js';
2
3
  import { listArtifactFoldersHandler, setArtifactFolderHandler, workItemDependencyHandler } from '../product-tools.js';
3
4
  /**
@@ -3472,6 +3473,10 @@ export async function startToolsServer(client, dispatch, recoverLanding) {
3472
3473
  res.writeHead(status, { 'Content-Type': 'text/plain' }).end(why);
3473
3474
  };
3474
3475
  async function handle(req, res) {
3476
+ if (!clientSessionCurrent(client)) {
3477
+ fail(res, 401, 'This connection no longer owns the local sign-in.');
3478
+ return;
3479
+ }
3475
3480
  const url = new URL(req.url ?? '/', 'http://127.0.0.1');
3476
3481
  const parts = url.pathname.startsWith('/mcp/')
3477
3482
  ? url.pathname.slice('/mcp/'.length).split('/').filter(Boolean)
@@ -12,5 +12,8 @@ export function buildPresenceHeartbeatPayload(input, seenAt = new Date()) {
12
12
  // A beating heart is not stopped: clears the stamp a clean shutdown left,
13
13
  // so a machine that comes back reads online again.
14
14
  stopped_at: null,
15
+ session_state: 'authenticated',
16
+ session_observed_at: seenAt.toISOString(),
17
+ harness_auth: input.harnessAuth ?? {},
15
18
  };
16
19
  }