@ctrl-spc/cs 0.7.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.
@@ -0,0 +1,128 @@
1
+ import { chmodSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { join } from 'node:path';
4
+ import { configDir } from '../config.js';
5
+ export const OWNER_SESSION_GRACE_MS = 30_000;
6
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
7
+ export const validSessionUuid = (value) => typeof value === 'string' && UUID.test(value);
8
+ export function ownerSessionsRoot() {
9
+ return join(configDir(), 'panel3-sessions');
10
+ }
11
+ export function ownerSessionPath(ownerId) {
12
+ if (!validSessionUuid(ownerId))
13
+ throw new Error('an owner session requires a valid run id');
14
+ return join(ownerSessionsRoot(), `${ownerId}.json`);
15
+ }
16
+ function validRecord(value, ownerId) {
17
+ if (!value || typeof value !== 'object')
18
+ return false;
19
+ const row = value;
20
+ return row.ownerId === ownerId
21
+ && (row.harness === 'claude' || row.harness === 'codex')
22
+ && validSessionUuid(row.nativeSessionId)
23
+ && validSessionUuid(row.processToken)
24
+ && (row.state === 'pending' || row.state === 'established')
25
+ && typeof row.updatedAt === 'string'
26
+ && Number.isFinite(new Date(row.updatedAt).getTime());
27
+ }
28
+ /** Invalid local data is never handed to a harness as a resume selector. */
29
+ export function readOwnerSession(ownerId) {
30
+ let path;
31
+ try {
32
+ path = ownerSessionPath(ownerId);
33
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
34
+ if (validRecord(parsed, ownerId))
35
+ return parsed;
36
+ }
37
+ catch {
38
+ // Missing and malformed are both absence to the caller. Malformed is removed below.
39
+ try {
40
+ path = ownerSessionPath(ownerId);
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
46
+ try {
47
+ rmSync(path, { force: true });
48
+ }
49
+ catch { /* reconciliation retries */ }
50
+ return null;
51
+ }
52
+ /** One file per owner prevents two daemons updating different owners from
53
+ * losing each other's mappings. Same-directory rename makes each record whole
54
+ * or absent across a crash. */
55
+ export function writeOwnerSession(record) {
56
+ const stored = { ...record, updatedAt: record.updatedAt ?? new Date().toISOString() };
57
+ if (!validRecord(stored, stored.ownerId))
58
+ throw new Error('invalid owner session record');
59
+ const root = ownerSessionsRoot();
60
+ mkdirSync(root, { recursive: true, mode: 0o700 });
61
+ chmodSync(root, 0o700);
62
+ const path = ownerSessionPath(record.ownerId);
63
+ const temporary = join(root, `.${record.ownerId}.${randomUUID()}.tmp`);
64
+ try {
65
+ writeFileSync(temporary, `${JSON.stringify(stored)}\n`, { mode: 0o600 });
66
+ renameSync(temporary, path);
67
+ }
68
+ finally {
69
+ try {
70
+ rmSync(temporary, { force: true });
71
+ }
72
+ catch { /* a later reconciliation can remove it */ }
73
+ }
74
+ }
75
+ export function establishOwnerSession(ownerId, processToken) {
76
+ const record = readOwnerSession(ownerId);
77
+ if (!record || record.state !== 'pending' || record.processToken !== processToken)
78
+ return false;
79
+ writeOwnerSession({ ...record, state: 'established', updatedAt: new Date().toISOString() });
80
+ return true;
81
+ }
82
+ export function removeOwnerSession(ownerId) {
83
+ try {
84
+ rmSync(ownerSessionPath(ownerId), { force: true });
85
+ }
86
+ catch { /* reconciliation retries */ }
87
+ }
88
+ export function removeOwnerSessionIfToken(ownerId, processToken) {
89
+ const record = readOwnerSession(ownerId);
90
+ if (!record || record.processToken !== processToken)
91
+ return false;
92
+ removeOwnerSession(ownerId);
93
+ return true;
94
+ }
95
+ /** Names, not contents. Malformed names and temp remnants are removed here and
96
+ * never become database selectors. */
97
+ export function listOwnerSessionIds(now = Date.now()) {
98
+ let entries;
99
+ try {
100
+ entries = readdirSync(ownerSessionsRoot());
101
+ }
102
+ catch {
103
+ return [];
104
+ }
105
+ const ids = [];
106
+ for (const entry of entries) {
107
+ const match = /^([0-9a-f-]+)\.json$/i.exec(entry);
108
+ if (match && validSessionUuid(match[1])) {
109
+ ids.push(match[1]);
110
+ continue;
111
+ }
112
+ const temporary = /^\.([0-9a-f-]+)\.([0-9a-f-]+)\.tmp$/i.exec(entry);
113
+ if (temporary && validSessionUuid(temporary[1]) && validSessionUuid(temporary[2])) {
114
+ try {
115
+ if (now - statSync(join(ownerSessionsRoot(), entry)).mtimeMs < OWNER_SESSION_GRACE_MS)
116
+ continue;
117
+ }
118
+ catch {
119
+ continue;
120
+ }
121
+ }
122
+ try {
123
+ rmSync(join(ownerSessionsRoot(), entry), { recursive: true, force: true });
124
+ }
125
+ catch { /* retry later */ }
126
+ }
127
+ return ids;
128
+ }
@@ -61,7 +61,8 @@ export async function withAskContent(client, asks) {
61
61
  if (ids.length === 0)
62
62
  return asks;
63
63
  const decisions = await read(client.from('decisions')
64
- .select('id, question, answer, category, context, answer_mode, options, selected_options, answer_note')
64
+ .select('id, question, answer, category, context, answer_mode, options, selected_options, '
65
+ + 'answer_note, related_artifact_id, related_artifact_revision')
65
66
  .in('id', ids), 'decisions');
66
67
  /* THE CONTENT ONLY. The decision's own `id` is dropped here rather than
67
68
  spread and overwritten: the ask row's id is what every caller answers,
@@ -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
@@ -245,7 +254,9 @@ function codexArgs(level, toolsUrl, platform) {
245
254
  `danger-full-access`. Level 1 has no file tool to use it with, so it gets
246
255
  the read-only sandbox instead — see the block comment above for what that
247
256
  does and does not buy. */
248
- '-s', level === 1 ? 'read-only' : 'workspace-write',
257
+ ...(resume
258
+ ? ['-c', `sandbox_mode=${JSON.stringify(level === 1 ? 'read-only' : 'workspace-write')}`]
259
+ : ['-s', level === 1 ? 'read-only' : 'workspace-write']),
249
260
  /* The write sandbox sandboxes the NETWORK too, and an agent handed a
250
261
  credential that can then reach nothing is a dead end. Only where there is
251
262
  a write sandbox to say it about. */
@@ -363,9 +374,10 @@ function tail(text, chars = 500) {
363
374
  * process still alive" after the daemon that started it has been killed. So the
364
375
  * caller gets the pid immediately, writes it, and then waits.
365
376
  */
366
- export function startAgent(prompt, level, toolsUrl, cwd) {
377
+ export function startAgent(prompt, level, toolsUrl, cwd, ownerSession) {
367
378
  const failed = (reason) => ({
368
379
  pid: null,
380
+ session: Promise.resolve(null),
369
381
  answered: Promise.resolve({ ok: false, reason }),
370
382
  });
371
383
  let agent;
@@ -377,7 +389,10 @@ export function startAgent(prompt, level, toolsUrl, cwd) {
377
389
  // `harness()`: running the other one instead is the lie, not the failure.
378
390
  return failed(err.message);
379
391
  }
380
- const ARGS = agentArgs(level, toolsUrl, agent);
392
+ const launchedOwnerSession = ownerSession && !ownerSession.resumeSessionId
393
+ ? { ...ownerSession, freshSessionId: randomUUID() }
394
+ : ownerSession;
395
+ const ARGS = agentArgs(level, toolsUrl, agent, process.platform, launchedOwnerSession);
381
396
  const bin = agentPath(agent);
382
397
  if (!bin) {
383
398
  return failed(`${agent} is not installed on this machine`);
@@ -387,10 +402,12 @@ export function startAgent(prompt, level, toolsUrl, cwd) {
387
402
  isolates its configuration and grants this run's one server on argv. */
388
403
  const windowsCodex = agent === 'codex' && process.platform === 'win32';
389
404
  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)
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)
394
411
  : null;
395
412
  if (agent === 'codex' && !windowsCodex && !home) {
396
413
  return failed('codex is not signed in on this machine, so a run cannot be given this product\'s tools '
@@ -419,10 +436,24 @@ export function startAgent(prompt, level, toolsUrl, cwd) {
419
436
  catch (err) {
420
437
  // NOT `err.message`, which names the binary's absolute path. See
421
438
  // `couldNotStart`.
422
- if (home)
439
+ if (home && !ownerSession)
423
440
  removeCodexRunHome(home);
424
441
  return failed(couldNotStart(err, agent));
425
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
+ }
426
457
  const answered = new Promise((resolve) => {
427
458
  let stdout = '';
428
459
  let stderr = '';
@@ -447,11 +478,32 @@ export function startAgent(prompt, level, toolsUrl, cwd) {
447
478
  /* THE CREDENTIAL COPY GOES WHEN THE RUN DOES. `codex-home.ts` calls this
448
479
  the primary reclaim and the startup sweep the backstop; a home left
449
480
  behind holds a copy of the user's codex credential. */
450
- if (home)
481
+ if (home && !ownerSession)
451
482
  removeCodexRunHome(home);
483
+ if (!sessionSettled)
484
+ observeSession(null);
452
485
  resolve(answer);
453
486
  };
454
- child.stdout?.on('data', (d) => { stdout = collect(stdout, String(d)); });
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
+ });
455
507
  child.stderr?.on('data', (d) => { stderr = collect(stderr, String(d)); });
456
508
  /* ENOENT and friends. The process never ran, and that is what is reported —
457
509
  WITHOUT the message, which is where node puts the binary's absolute path.
@@ -459,14 +511,24 @@ export function startAgent(prompt, level, toolsUrl, cwd) {
459
511
  child.on('error', (err) => {
460
512
  finish({ ok: false, reason: couldNotStart(err, agent) });
461
513
  });
462
- child.on('close', (code) => {
514
+ child.on('close', (code, signal) => {
463
515
  /* ═══ THREE OUTCOMES, AND ONLY ONE OF THEM IS AN ANSWER. ═══ A non-zero
464
516
  exit and an exit of zero that said nothing are both failures, and
465
517
  neither may be returned as an empty answer: the daemon would write a
466
518
  blank agent turn and the card would read as answered. That is the
467
519
  forbidden state ux.md is about, reached by treating silence as
468
520
  success. */
469
- if (code !== 0) {
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) {
470
532
  /* STDOUT WHEN STDERR IS EMPTY, because `claude -p` prints its own
471
533
  failure on stdout and exits non-zero having written nothing to
472
534
  stderr. Reporting only "exited 1" would throw away the one sentence
@@ -499,5 +561,5 @@ export function startAgent(prompt, level, toolsUrl, cwd) {
499
561
  child.stdin?.on('error', () => { });
500
562
  child.stdin?.end(prompt);
501
563
  });
502
- return { pid: child.pid ?? null, answered };
564
+ return { pid: child.pid ?? null, session, answered };
503
565
  }