@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.
- package/dist/codex-home.js +62 -1
- package/dist/panel3/prompt.js +329 -61
- package/dist/panel3/run.js +584 -56
- package/dist/panel3/session.js +128 -0
- package/dist/panel3/show.js +2 -1
- package/dist/panel3/spawn.js +80 -18
- package/dist/panel3/tools.js +353 -48
- package/dist/workflows.js +68 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/panel3/show.js
CHANGED
|
@@ -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,
|
|
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,
|
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
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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) => {
|
|
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 (
|
|
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
|
}
|