@bakapiano/ccsm 0.22.3 → 0.22.5
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/CLAUDE.md +538 -538
- package/README.md +189 -189
- package/bin/ccsm.js +235 -235
- package/lib/cliActivity.js +139 -139
- package/lib/codexSeed.js +183 -183
- package/lib/config.js +274 -274
- package/lib/devices.js +229 -229
- package/lib/folders.js +124 -124
- package/lib/localCliSessions.js +519 -519
- package/lib/persistedSessions.js +129 -129
- package/lib/tunnel.js +621 -621
- package/lib/webTerminal.js +225 -225
- package/lib/workspace.js +233 -233
- package/package.json +57 -57
- package/public/css/base.css +99 -99
- package/public/css/cards.css +183 -183
- package/public/css/feedback.css +504 -504
- package/public/css/forms.css +453 -453
- package/public/css/layout.css +176 -176
- package/public/css/modal.css +190 -190
- package/public/css/responsive.css +176 -176
- package/public/css/sidebar.css +707 -707
- package/public/css/terminals.css +645 -543
- package/public/css/tokens.css +81 -81
- package/public/css/wco.css +196 -196
- package/public/css/widgets.css +2725 -2725
- package/public/index.html +152 -152
- package/public/js/api.js +371 -371
- package/public/js/backend.js +149 -149
- package/public/js/components/App.js +73 -73
- package/public/js/components/DirectoryPicker.js +203 -203
- package/public/js/components/EntityFormModal.js +153 -153
- package/public/js/components/Modal.js +57 -57
- package/public/js/components/OfflineBanner.js +67 -67
- package/public/js/components/PageTitleBar.js +13 -13
- package/public/js/components/PendingApprovalOverlay.js +128 -128
- package/public/js/components/Picker.js +179 -179
- package/public/js/components/Popover.js +55 -55
- package/public/js/components/RestartOverlay.js +36 -36
- package/public/js/components/Sidebar.js +380 -380
- package/public/js/components/TerminalInstance.js +159 -22
- package/public/js/components/TerminalResizeDebouncer.js +126 -0
- package/public/js/components/TerminalView.js +15 -2
- package/public/js/components/XtermTerminal.js +74 -15
- package/public/js/components/useDragSort.js +67 -67
- package/public/js/dialog.js +67 -67
- package/public/js/icons.js +212 -212
- package/public/js/main.js +296 -296
- package/public/js/pages/AboutPage.js +90 -90
- package/public/js/pages/ConfigurePage.js +713 -713
- package/public/js/pages/LaunchPage.js +421 -421
- package/public/js/pages/RemotePage.js +743 -743
- package/public/js/pages/SessionsPage.js +199 -80
- package/public/js/state.js +335 -335
- package/public/manifest.webmanifest +25 -0
- package/public/setup/index.html +567 -0
- package/scripts/dev.js +149 -149
- package/scripts/install.js +153 -153
- package/scripts/restart-helper.js +96 -96
- package/scripts/upgrade-helper.js +687 -687
- package/server.js +1807 -1807
package/lib/cliActivity.js
CHANGED
|
@@ -1,139 +1,139 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
// Detect whether each running CLI session is "working" (actively writing
|
|
4
|
-
// to its transcript) or "idle" (waiting on user input). We poll the
|
|
5
|
-
// transcript file's mtime on each /api/sessions request: if it moved
|
|
6
|
-
// since the previous probe, the CLI is writing → working. If it hasn't
|
|
7
|
-
// moved within WORKING_WINDOW_MS, idle.
|
|
8
|
-
//
|
|
9
|
-
// Transcript paths per CLI:
|
|
10
|
-
// claude → ~/.claude/projects/<slug>/<cliSessionId>.jsonl
|
|
11
|
-
// codex → <CODEX_HOME>/sessions/YYYY/MM/DD/rollout-*-<id>.jsonl
|
|
12
|
-
// copilot → ~/.copilot/session-state/<cliSessionId>/
|
|
13
|
-
//
|
|
14
|
-
// Resolution is cached forever per ccsm session id — once we've found
|
|
15
|
-
// the file, subsequent probes are a single fs.stat().
|
|
16
|
-
|
|
17
|
-
const fs = require('node:fs/promises');
|
|
18
|
-
const path = require('node:path');
|
|
19
|
-
const os = require('node:os');
|
|
20
|
-
|
|
21
|
-
// 8s window is comfortably above the 5s frontend poll cadence — if a CLI
|
|
22
|
-
// wrote anything within the last 8s we still call it working when the
|
|
23
|
-
// next refresh lands.
|
|
24
|
-
const WORKING_WINDOW_MS = 8000;
|
|
25
|
-
|
|
26
|
-
// sessionId → { resolvedPath, lastMtimeMs, lastChangedAt }
|
|
27
|
-
const state = new Map();
|
|
28
|
-
|
|
29
|
-
async function fileExists(p) {
|
|
30
|
-
try { await fs.access(p); return true; }
|
|
31
|
-
catch { return false; }
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
async function resolveClaude(id) {
|
|
35
|
-
const root = path.join(os.homedir(), '.claude', 'projects');
|
|
36
|
-
let dirs;
|
|
37
|
-
try { dirs = await fs.readdir(root); } catch { return null; }
|
|
38
|
-
for (const d of dirs) {
|
|
39
|
-
const p = path.join(root, d, `${id}.jsonl`);
|
|
40
|
-
if (await fileExists(p)) return p;
|
|
41
|
-
}
|
|
42
|
-
return null;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
async function resolveCodex(id, cliCfg) {
|
|
46
|
-
let home = null;
|
|
47
|
-
try {
|
|
48
|
-
const { probeCodexHome } = require('./codexSeed');
|
|
49
|
-
home = await probeCodexHome({ command: cliCfg.command, shell: cliCfg.shell });
|
|
50
|
-
} catch { /* probe is best-effort */ }
|
|
51
|
-
if (!home) home = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
52
|
-
const root = path.join(home, 'sessions');
|
|
53
|
-
const suffix = `-${id}.jsonl`;
|
|
54
|
-
async function walk(dir, depth) {
|
|
55
|
-
if (depth > 4) return null;
|
|
56
|
-
let entries;
|
|
57
|
-
try { entries = await fs.readdir(dir, { withFileTypes: true }); }
|
|
58
|
-
catch { return null; }
|
|
59
|
-
for (const e of entries) {
|
|
60
|
-
const p = path.join(dir, e.name);
|
|
61
|
-
if (e.isDirectory()) {
|
|
62
|
-
const r = await walk(p, depth + 1);
|
|
63
|
-
if (r) return r;
|
|
64
|
-
} else if (e.isFile() && e.name.endsWith(suffix)) {
|
|
65
|
-
return p;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
return null;
|
|
69
|
-
}
|
|
70
|
-
return walk(root, 0);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
async function resolveCopilot(id) {
|
|
74
|
-
const p = path.join(os.homedir(), '.copilot', 'session-state', id);
|
|
75
|
-
if (await fileExists(p)) return p;
|
|
76
|
-
return null;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
async function resolveTranscript(record, cliCfg) {
|
|
80
|
-
if (!record.cliSessionId || !cliCfg) return null;
|
|
81
|
-
switch (cliCfg.type) {
|
|
82
|
-
case 'claude': return resolveClaude(record.cliSessionId);
|
|
83
|
-
case 'codex': return resolveCodex(record.cliSessionId, cliCfg);
|
|
84
|
-
case 'copilot': return resolveCopilot(record.cliSessionId);
|
|
85
|
-
default: return null;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// Returns 'working' | 'idle' | 'unknown' for a single record.
|
|
90
|
-
async function probeActivity(record, cliCfg) {
|
|
91
|
-
let s = state.get(record.id);
|
|
92
|
-
if (!s) {
|
|
93
|
-
s = { resolvedPath: null, lastMtimeMs: 0, lastChangedAt: 0, lastOutputAt: 0 };
|
|
94
|
-
state.set(record.id, s);
|
|
95
|
-
}
|
|
96
|
-
// PTY output (CLI is streaming text — thinking spinners, token output,
|
|
97
|
-
// status lines) is the strongest signal that the CLI is working. It's
|
|
98
|
-
// ALSO the only signal we have when the transcript file isn't being
|
|
99
|
-
// updated — claude/codex buffer reasoning + tool results for tens of
|
|
100
|
-
// seconds before flushing a turn, so mtime alone reports "idle"
|
|
101
|
-
// through long thinking phases. Check PTY first; short-circuit if the
|
|
102
|
-
// CLI is clearly active, skipping the fs.stat below.
|
|
103
|
-
const now = Date.now();
|
|
104
|
-
if (s.lastOutputAt && (now - s.lastOutputAt) < WORKING_WINDOW_MS) {
|
|
105
|
-
return 'working';
|
|
106
|
-
}
|
|
107
|
-
if (!s.resolvedPath) {
|
|
108
|
-
s.resolvedPath = await resolveTranscript(record, cliCfg);
|
|
109
|
-
if (!s.resolvedPath) return 'unknown';
|
|
110
|
-
}
|
|
111
|
-
let mtimeMs;
|
|
112
|
-
try { mtimeMs = (await fs.stat(s.resolvedPath)).mtimeMs; }
|
|
113
|
-
catch {
|
|
114
|
-
// File disappeared (rollover, manual delete) — drop the cache so we
|
|
115
|
-
// re-resolve on the next probe.
|
|
116
|
-
s.resolvedPath = null;
|
|
117
|
-
return 'unknown';
|
|
118
|
-
}
|
|
119
|
-
if (mtimeMs !== s.lastMtimeMs) {
|
|
120
|
-
s.lastMtimeMs = mtimeMs;
|
|
121
|
-
s.lastChangedAt = now;
|
|
122
|
-
}
|
|
123
|
-
return (now - s.lastChangedAt) < WORKING_WINDOW_MS ? 'working' : 'idle';
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// Called from server.js's spawnCliSession onData hook. Cheap (timestamp
|
|
127
|
-
// write); bound by how often the PTY emits, which is fine.
|
|
128
|
-
function noteOutput(sessionId) {
|
|
129
|
-
let s = state.get(sessionId);
|
|
130
|
-
if (!s) {
|
|
131
|
-
s = { resolvedPath: null, lastMtimeMs: 0, lastChangedAt: 0, lastOutputAt: 0 };
|
|
132
|
-
state.set(sessionId, s);
|
|
133
|
-
}
|
|
134
|
-
s.lastOutputAt = Date.now();
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function releaseSession(sessionId) { state.delete(sessionId); }
|
|
138
|
-
|
|
139
|
-
module.exports = { probeActivity, noteOutput, releaseSession };
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Detect whether each running CLI session is "working" (actively writing
|
|
4
|
+
// to its transcript) or "idle" (waiting on user input). We poll the
|
|
5
|
+
// transcript file's mtime on each /api/sessions request: if it moved
|
|
6
|
+
// since the previous probe, the CLI is writing → working. If it hasn't
|
|
7
|
+
// moved within WORKING_WINDOW_MS, idle.
|
|
8
|
+
//
|
|
9
|
+
// Transcript paths per CLI:
|
|
10
|
+
// claude → ~/.claude/projects/<slug>/<cliSessionId>.jsonl
|
|
11
|
+
// codex → <CODEX_HOME>/sessions/YYYY/MM/DD/rollout-*-<id>.jsonl
|
|
12
|
+
// copilot → ~/.copilot/session-state/<cliSessionId>/
|
|
13
|
+
//
|
|
14
|
+
// Resolution is cached forever per ccsm session id — once we've found
|
|
15
|
+
// the file, subsequent probes are a single fs.stat().
|
|
16
|
+
|
|
17
|
+
const fs = require('node:fs/promises');
|
|
18
|
+
const path = require('node:path');
|
|
19
|
+
const os = require('node:os');
|
|
20
|
+
|
|
21
|
+
// 8s window is comfortably above the 5s frontend poll cadence — if a CLI
|
|
22
|
+
// wrote anything within the last 8s we still call it working when the
|
|
23
|
+
// next refresh lands.
|
|
24
|
+
const WORKING_WINDOW_MS = 8000;
|
|
25
|
+
|
|
26
|
+
// sessionId → { resolvedPath, lastMtimeMs, lastChangedAt }
|
|
27
|
+
const state = new Map();
|
|
28
|
+
|
|
29
|
+
async function fileExists(p) {
|
|
30
|
+
try { await fs.access(p); return true; }
|
|
31
|
+
catch { return false; }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function resolveClaude(id) {
|
|
35
|
+
const root = path.join(os.homedir(), '.claude', 'projects');
|
|
36
|
+
let dirs;
|
|
37
|
+
try { dirs = await fs.readdir(root); } catch { return null; }
|
|
38
|
+
for (const d of dirs) {
|
|
39
|
+
const p = path.join(root, d, `${id}.jsonl`);
|
|
40
|
+
if (await fileExists(p)) return p;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function resolveCodex(id, cliCfg) {
|
|
46
|
+
let home = null;
|
|
47
|
+
try {
|
|
48
|
+
const { probeCodexHome } = require('./codexSeed');
|
|
49
|
+
home = await probeCodexHome({ command: cliCfg.command, shell: cliCfg.shell });
|
|
50
|
+
} catch { /* probe is best-effort */ }
|
|
51
|
+
if (!home) home = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
52
|
+
const root = path.join(home, 'sessions');
|
|
53
|
+
const suffix = `-${id}.jsonl`;
|
|
54
|
+
async function walk(dir, depth) {
|
|
55
|
+
if (depth > 4) return null;
|
|
56
|
+
let entries;
|
|
57
|
+
try { entries = await fs.readdir(dir, { withFileTypes: true }); }
|
|
58
|
+
catch { return null; }
|
|
59
|
+
for (const e of entries) {
|
|
60
|
+
const p = path.join(dir, e.name);
|
|
61
|
+
if (e.isDirectory()) {
|
|
62
|
+
const r = await walk(p, depth + 1);
|
|
63
|
+
if (r) return r;
|
|
64
|
+
} else if (e.isFile() && e.name.endsWith(suffix)) {
|
|
65
|
+
return p;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
return walk(root, 0);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function resolveCopilot(id) {
|
|
74
|
+
const p = path.join(os.homedir(), '.copilot', 'session-state', id);
|
|
75
|
+
if (await fileExists(p)) return p;
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function resolveTranscript(record, cliCfg) {
|
|
80
|
+
if (!record.cliSessionId || !cliCfg) return null;
|
|
81
|
+
switch (cliCfg.type) {
|
|
82
|
+
case 'claude': return resolveClaude(record.cliSessionId);
|
|
83
|
+
case 'codex': return resolveCodex(record.cliSessionId, cliCfg);
|
|
84
|
+
case 'copilot': return resolveCopilot(record.cliSessionId);
|
|
85
|
+
default: return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Returns 'working' | 'idle' | 'unknown' for a single record.
|
|
90
|
+
async function probeActivity(record, cliCfg) {
|
|
91
|
+
let s = state.get(record.id);
|
|
92
|
+
if (!s) {
|
|
93
|
+
s = { resolvedPath: null, lastMtimeMs: 0, lastChangedAt: 0, lastOutputAt: 0 };
|
|
94
|
+
state.set(record.id, s);
|
|
95
|
+
}
|
|
96
|
+
// PTY output (CLI is streaming text — thinking spinners, token output,
|
|
97
|
+
// status lines) is the strongest signal that the CLI is working. It's
|
|
98
|
+
// ALSO the only signal we have when the transcript file isn't being
|
|
99
|
+
// updated — claude/codex buffer reasoning + tool results for tens of
|
|
100
|
+
// seconds before flushing a turn, so mtime alone reports "idle"
|
|
101
|
+
// through long thinking phases. Check PTY first; short-circuit if the
|
|
102
|
+
// CLI is clearly active, skipping the fs.stat below.
|
|
103
|
+
const now = Date.now();
|
|
104
|
+
if (s.lastOutputAt && (now - s.lastOutputAt) < WORKING_WINDOW_MS) {
|
|
105
|
+
return 'working';
|
|
106
|
+
}
|
|
107
|
+
if (!s.resolvedPath) {
|
|
108
|
+
s.resolvedPath = await resolveTranscript(record, cliCfg);
|
|
109
|
+
if (!s.resolvedPath) return 'unknown';
|
|
110
|
+
}
|
|
111
|
+
let mtimeMs;
|
|
112
|
+
try { mtimeMs = (await fs.stat(s.resolvedPath)).mtimeMs; }
|
|
113
|
+
catch {
|
|
114
|
+
// File disappeared (rollover, manual delete) — drop the cache so we
|
|
115
|
+
// re-resolve on the next probe.
|
|
116
|
+
s.resolvedPath = null;
|
|
117
|
+
return 'unknown';
|
|
118
|
+
}
|
|
119
|
+
if (mtimeMs !== s.lastMtimeMs) {
|
|
120
|
+
s.lastMtimeMs = mtimeMs;
|
|
121
|
+
s.lastChangedAt = now;
|
|
122
|
+
}
|
|
123
|
+
return (now - s.lastChangedAt) < WORKING_WINDOW_MS ? 'working' : 'idle';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Called from server.js's spawnCliSession onData hook. Cheap (timestamp
|
|
127
|
+
// write); bound by how often the PTY emits, which is fine.
|
|
128
|
+
function noteOutput(sessionId) {
|
|
129
|
+
let s = state.get(sessionId);
|
|
130
|
+
if (!s) {
|
|
131
|
+
s = { resolvedPath: null, lastMtimeMs: 0, lastChangedAt: 0, lastOutputAt: 0 };
|
|
132
|
+
state.set(sessionId, s);
|
|
133
|
+
}
|
|
134
|
+
s.lastOutputAt = Date.now();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function releaseSession(sessionId) { state.delete(sessionId); }
|
|
138
|
+
|
|
139
|
+
module.exports = { probeActivity, noteOutput, releaseSession };
|
package/lib/codexSeed.js
CHANGED
|
@@ -1,183 +1,183 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
// Seed a fake codex rollout file so `codex resume <uuid>` works from the
|
|
4
|
-
// VERY FIRST launch — the same trick claude/copilot's `--session-id` flag
|
|
5
|
-
// gives us natively. codex has no equivalent flag; its only "set the id"
|
|
6
|
-
// surface is `resume <SESSION_ID>` against a file that already exists on
|
|
7
|
-
// disk. We pre-write that file with one `session_meta` line carrying the
|
|
8
|
-
// id + cwd ccsm pre-assigned, then spawn `codex resume <id>`. Codex picks
|
|
9
|
-
// up our seed and appends its actual conversation events to it.
|
|
10
|
-
//
|
|
11
|
-
// Path layout (matches codex's own scheme):
|
|
12
|
-
// ~/.codex/sessions/YYYY/MM/DD/rollout-<iso-ts>-<uuid>.jsonl
|
|
13
|
-
//
|
|
14
|
-
// Filename timestamp uses dashes-only (codex's convention), but it's
|
|
15
|
-
// purely cosmetic — codex looks up sessions by UUID, not filename.
|
|
16
|
-
//
|
|
17
|
-
// CODEX_HOME resolution. Some wrappers relocate CODEX_HOME to a
|
|
18
|
-
// non-default dir (e.g. %LOCALAPPDATA%\<wrapper>\codex-home) so the seed has
|
|
19
|
-
// to land there or `resume <id>` won't find it. We probe by running
|
|
20
|
-
// `<cli.command> doctor` once per (command, shell) pair and parsing the
|
|
21
|
-
// "CODEX_HOME ... (dir)" line out of its output. Cached for the life of
|
|
22
|
-
// the process.
|
|
23
|
-
|
|
24
|
-
const fs = require('node:fs/promises');
|
|
25
|
-
const path = require('node:path');
|
|
26
|
-
const os = require('node:os');
|
|
27
|
-
const { execFile } = require('node:child_process');
|
|
28
|
-
const { spawnEnv } = require('./winPath');
|
|
29
|
-
|
|
30
|
-
function isoForFilename(d = new Date()) {
|
|
31
|
-
// 2026-05-25T15:39:11 → 2026-05-25T15-39-11 (codex strips ms + colons)
|
|
32
|
-
return d.toISOString().replace(/\.\d+Z$/, '').replace(/:/g, '-');
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// command+shell → CODEX_HOME (or null if probe failed / not detected).
|
|
36
|
-
// Module-scope so we probe at most once per (command, shell) per server.
|
|
37
|
-
const codexHomeCache = new Map();
|
|
38
|
-
function cacheKey(command, shell) { return `${shell || 'direct'}|${command}`; }
|
|
39
|
-
|
|
40
|
-
function execWithTimeout(exe, args, { timeoutMs = 8000 } = {}) {
|
|
41
|
-
return new Promise((resolve) => {
|
|
42
|
-
execFile(exe, args, {
|
|
43
|
-
windowsHide: true,
|
|
44
|
-
timeout: timeoutMs,
|
|
45
|
-
maxBuffer: 1024 * 1024,
|
|
46
|
-
// Use the registry-merged user PATH so wrapper commands resolve
|
|
47
|
-
// even when the long-running server inherited a stale PATH at boot.
|
|
48
|
-
env: spawnEnv(),
|
|
49
|
-
}, (err, stdout, stderr) => {
|
|
50
|
-
resolve({ err, stdout: String(stdout || ''), stderr: String(stderr || '') });
|
|
51
|
-
});
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// Pull CODEX_HOME out of a wrapper's `doctor` (or `--version`) output. Two
|
|
56
|
-
// shapes appear, and we must handle BOTH:
|
|
57
|
-
// 1. Diagnostic table: `CODEX_HOME <path> (dir)` — only when `doctor`
|
|
58
|
-
// fully succeeds. Variable whitespace; the `(dir)`/`(file)` suffix marks
|
|
59
|
-
// the path end.
|
|
60
|
-
// 2. Wrapper banner: `CODEX_HOME=<path>` — printed on EVERY invocation.
|
|
61
|
-
// Critical because in ccsm's non-interactive spawn `doctor` often exits
|
|
62
|
-
// non-zero (skipping the table) yet still prints this banner line, so it's
|
|
63
|
-
// the reliable source. Without it the probe returns null and the seed
|
|
64
|
-
// lands in ~/.codex instead of the wrapper's relocated home, breaking
|
|
65
|
-
// `resume <id>` ("No saved session found with ID …").
|
|
66
|
-
// Some wrappers colour the label (`\x1b[7mCODEX_HOME\x1b[0m`); strip ANSI first.
|
|
67
|
-
function parseCodexHomeFromDoctor(text) {
|
|
68
|
-
if (!text) return null;
|
|
69
|
-
const clean = String(text).replace(/\x1b\[[0-9;]*m/g, '');
|
|
70
|
-
let m = clean.match(/\bCODEX_HOME\s+(.+?)\s*\((?:dir|file)\)/); // table form
|
|
71
|
-
if (!m) m = clean.match(/\bCODEX_HOME=(.+?)\s*$/m); // banner form
|
|
72
|
-
if (!m) return null;
|
|
73
|
-
const p = m[1].trim();
|
|
74
|
-
return p || null;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Build the [exe, args] needed to run `<cli.command> doctor` honouring
|
|
78
|
-
// the same shell-wrapping rules webTerminal uses. Mirrors the relevant
|
|
79
|
-
// bits of server.js' resolveCommand — kept local so this module doesn't
|
|
80
|
-
// drag a dependency on server.js.
|
|
81
|
-
function buildDoctorInvocation(command, shell) {
|
|
82
|
-
const cmd = String(command || '').replace(/^\.[\\/]/, '');
|
|
83
|
-
if (!cmd) return null;
|
|
84
|
-
if (shell === 'pwsh') {
|
|
85
|
-
return {
|
|
86
|
-
exe: 'pwsh.exe',
|
|
87
|
-
args: ['-NoLogo', '-NonInteractive', '-Command', `& { ${cmd} doctor }`],
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
if (shell === 'cmd') {
|
|
91
|
-
return {
|
|
92
|
-
exe: process.env.ComSpec || 'cmd.exe',
|
|
93
|
-
args: ['/d', '/s', '/c', `${cmd} doctor`],
|
|
94
|
-
};
|
|
95
|
-
}
|
|
96
|
-
// direct
|
|
97
|
-
if (path.isAbsolute(cmd)) {
|
|
98
|
-
const ext = path.extname(cmd).toLowerCase();
|
|
99
|
-
if (ext === '.cmd' || ext === '.bat') {
|
|
100
|
-
return { exe: process.env.ComSpec || 'cmd.exe', args: ['/d', '/s', '/c', `"${cmd}" doctor`] };
|
|
101
|
-
}
|
|
102
|
-
if (ext === '.ps1') {
|
|
103
|
-
return { exe: 'powershell.exe', args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', cmd, 'doctor'] };
|
|
104
|
-
}
|
|
105
|
-
return { exe: cmd, args: ['doctor'] };
|
|
106
|
-
}
|
|
107
|
-
// bare name on direct → defer to cmd.exe so Windows resolves via PATH
|
|
108
|
-
return { exe: process.env.ComSpec || 'cmd.exe', args: ['/d', '/s', '/c', `${cmd} doctor`] };
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
async function probeCodexHome({ command, shell }) {
|
|
112
|
-
const key = cacheKey(command, shell);
|
|
113
|
-
if (codexHomeCache.has(key)) return codexHomeCache.get(key);
|
|
114
|
-
const inv = buildDoctorInvocation(command, shell);
|
|
115
|
-
if (!inv) { codexHomeCache.set(key, null); return null; }
|
|
116
|
-
const { stdout, stderr } = await execWithTimeout(inv.exe, inv.args);
|
|
117
|
-
// Some wrappers print their banner to stderr; doctor itself prints
|
|
118
|
-
// the CODEX_HOME line to stdout. Search both to be safe.
|
|
119
|
-
const home = parseCodexHomeFromDoctor(stdout) || parseCodexHomeFromDoctor(stderr);
|
|
120
|
-
codexHomeCache.set(key, home);
|
|
121
|
-
return home;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
async function seedCodexSession({ id, cwd, cli }) {
|
|
125
|
-
if (!id || !cwd) throw new Error('seedCodexSession: id and cwd required');
|
|
126
|
-
// Resolution order:
|
|
127
|
-
// 1. `<cli.command> doctor` probe (handles wrappers that
|
|
128
|
-
// relocate CODEX_HOME)
|
|
129
|
-
// 2. process.env.CODEX_HOME (global override)
|
|
130
|
-
// 3. ~/.codex (codex's own default)
|
|
131
|
-
let home = null;
|
|
132
|
-
if (cli?.command) {
|
|
133
|
-
try { home = await probeCodexHome({ command: cli.command, shell: cli.shell }); }
|
|
134
|
-
catch (_) { /* probe is best-effort */ }
|
|
135
|
-
}
|
|
136
|
-
if (!home) home = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
137
|
-
|
|
138
|
-
const now = new Date();
|
|
139
|
-
const yyyy = String(now.getUTCFullYear());
|
|
140
|
-
const mm = String(now.getUTCMonth() + 1).padStart(2, '0');
|
|
141
|
-
const dd = String(now.getUTCDate()).padStart(2, '0');
|
|
142
|
-
const dir = path.join(home, 'sessions', yyyy, mm, dd);
|
|
143
|
-
await fs.mkdir(dir, { recursive: true });
|
|
144
|
-
const file = path.join(dir, `rollout-${isoForFilename(now)}-${id}.jsonl`);
|
|
145
|
-
const meta = {
|
|
146
|
-
timestamp: now.toISOString(),
|
|
147
|
-
type: 'session_meta',
|
|
148
|
-
payload: {
|
|
149
|
-
id,
|
|
150
|
-
timestamp: now.toISOString(),
|
|
151
|
-
cwd,
|
|
152
|
-
originator: 'ccsm',
|
|
153
|
-
cli_version: '0.0.0',
|
|
154
|
-
source: 'ccsm-seed',
|
|
155
|
-
},
|
|
156
|
-
};
|
|
157
|
-
await fs.writeFile(file, JSON.stringify(meta) + '\n', 'utf8');
|
|
158
|
-
return file;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// Copy ccsm's bundled light codex syntax theme into the codex home's themes/
|
|
162
|
-
// dir so `-c tui.theme=ccsm-light` resolves. This theme carries light
|
|
163
|
-
// markup.inserted/deleted backgrounds, which at true-color level override
|
|
164
|
-
// codex's diff palette — the only way to get a LIGHT diff on Windows, where
|
|
165
|
-
// codex's own background detection (default_bg()) is compiled out and always
|
|
166
|
-
// falls back to a dark diff. Idempotent (writes only when missing/changed).
|
|
167
|
-
async function ensureCodexLightTheme(home) {
|
|
168
|
-
if (!home) return false;
|
|
169
|
-
const src = path.join(__dirname, 'codexThemes', 'ccsm-light.tmTheme');
|
|
170
|
-
const dstDir = path.join(home, 'themes');
|
|
171
|
-
const dst = path.join(dstDir, 'ccsm-light.tmTheme');
|
|
172
|
-
try {
|
|
173
|
-
const content = await fs.readFile(src, 'utf8');
|
|
174
|
-
await fs.mkdir(dstDir, { recursive: true });
|
|
175
|
-
let existing = null;
|
|
176
|
-
try { existing = await fs.readFile(dst, 'utf8'); } catch {}
|
|
177
|
-
if (existing !== content) await fs.writeFile(dst, content, 'utf8');
|
|
178
|
-
return true;
|
|
179
|
-
} catch { return false; }
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
module.exports = { seedCodexSession, probeCodexHome, parseCodexHomeFromDoctor, ensureCodexLightTheme };
|
|
183
|
-
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Seed a fake codex rollout file so `codex resume <uuid>` works from the
|
|
4
|
+
// VERY FIRST launch — the same trick claude/copilot's `--session-id` flag
|
|
5
|
+
// gives us natively. codex has no equivalent flag; its only "set the id"
|
|
6
|
+
// surface is `resume <SESSION_ID>` against a file that already exists on
|
|
7
|
+
// disk. We pre-write that file with one `session_meta` line carrying the
|
|
8
|
+
// id + cwd ccsm pre-assigned, then spawn `codex resume <id>`. Codex picks
|
|
9
|
+
// up our seed and appends its actual conversation events to it.
|
|
10
|
+
//
|
|
11
|
+
// Path layout (matches codex's own scheme):
|
|
12
|
+
// ~/.codex/sessions/YYYY/MM/DD/rollout-<iso-ts>-<uuid>.jsonl
|
|
13
|
+
//
|
|
14
|
+
// Filename timestamp uses dashes-only (codex's convention), but it's
|
|
15
|
+
// purely cosmetic — codex looks up sessions by UUID, not filename.
|
|
16
|
+
//
|
|
17
|
+
// CODEX_HOME resolution. Some wrappers relocate CODEX_HOME to a
|
|
18
|
+
// non-default dir (e.g. %LOCALAPPDATA%\<wrapper>\codex-home) so the seed has
|
|
19
|
+
// to land there or `resume <id>` won't find it. We probe by running
|
|
20
|
+
// `<cli.command> doctor` once per (command, shell) pair and parsing the
|
|
21
|
+
// "CODEX_HOME ... (dir)" line out of its output. Cached for the life of
|
|
22
|
+
// the process.
|
|
23
|
+
|
|
24
|
+
const fs = require('node:fs/promises');
|
|
25
|
+
const path = require('node:path');
|
|
26
|
+
const os = require('node:os');
|
|
27
|
+
const { execFile } = require('node:child_process');
|
|
28
|
+
const { spawnEnv } = require('./winPath');
|
|
29
|
+
|
|
30
|
+
function isoForFilename(d = new Date()) {
|
|
31
|
+
// 2026-05-25T15:39:11 → 2026-05-25T15-39-11 (codex strips ms + colons)
|
|
32
|
+
return d.toISOString().replace(/\.\d+Z$/, '').replace(/:/g, '-');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// command+shell → CODEX_HOME (or null if probe failed / not detected).
|
|
36
|
+
// Module-scope so we probe at most once per (command, shell) per server.
|
|
37
|
+
const codexHomeCache = new Map();
|
|
38
|
+
function cacheKey(command, shell) { return `${shell || 'direct'}|${command}`; }
|
|
39
|
+
|
|
40
|
+
function execWithTimeout(exe, args, { timeoutMs = 8000 } = {}) {
|
|
41
|
+
return new Promise((resolve) => {
|
|
42
|
+
execFile(exe, args, {
|
|
43
|
+
windowsHide: true,
|
|
44
|
+
timeout: timeoutMs,
|
|
45
|
+
maxBuffer: 1024 * 1024,
|
|
46
|
+
// Use the registry-merged user PATH so wrapper commands resolve
|
|
47
|
+
// even when the long-running server inherited a stale PATH at boot.
|
|
48
|
+
env: spawnEnv(),
|
|
49
|
+
}, (err, stdout, stderr) => {
|
|
50
|
+
resolve({ err, stdout: String(stdout || ''), stderr: String(stderr || '') });
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Pull CODEX_HOME out of a wrapper's `doctor` (or `--version`) output. Two
|
|
56
|
+
// shapes appear, and we must handle BOTH:
|
|
57
|
+
// 1. Diagnostic table: `CODEX_HOME <path> (dir)` — only when `doctor`
|
|
58
|
+
// fully succeeds. Variable whitespace; the `(dir)`/`(file)` suffix marks
|
|
59
|
+
// the path end.
|
|
60
|
+
// 2. Wrapper banner: `CODEX_HOME=<path>` — printed on EVERY invocation.
|
|
61
|
+
// Critical because in ccsm's non-interactive spawn `doctor` often exits
|
|
62
|
+
// non-zero (skipping the table) yet still prints this banner line, so it's
|
|
63
|
+
// the reliable source. Without it the probe returns null and the seed
|
|
64
|
+
// lands in ~/.codex instead of the wrapper's relocated home, breaking
|
|
65
|
+
// `resume <id>` ("No saved session found with ID …").
|
|
66
|
+
// Some wrappers colour the label (`\x1b[7mCODEX_HOME\x1b[0m`); strip ANSI first.
|
|
67
|
+
function parseCodexHomeFromDoctor(text) {
|
|
68
|
+
if (!text) return null;
|
|
69
|
+
const clean = String(text).replace(/\x1b\[[0-9;]*m/g, '');
|
|
70
|
+
let m = clean.match(/\bCODEX_HOME\s+(.+?)\s*\((?:dir|file)\)/); // table form
|
|
71
|
+
if (!m) m = clean.match(/\bCODEX_HOME=(.+?)\s*$/m); // banner form
|
|
72
|
+
if (!m) return null;
|
|
73
|
+
const p = m[1].trim();
|
|
74
|
+
return p || null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Build the [exe, args] needed to run `<cli.command> doctor` honouring
|
|
78
|
+
// the same shell-wrapping rules webTerminal uses. Mirrors the relevant
|
|
79
|
+
// bits of server.js' resolveCommand — kept local so this module doesn't
|
|
80
|
+
// drag a dependency on server.js.
|
|
81
|
+
function buildDoctorInvocation(command, shell) {
|
|
82
|
+
const cmd = String(command || '').replace(/^\.[\\/]/, '');
|
|
83
|
+
if (!cmd) return null;
|
|
84
|
+
if (shell === 'pwsh') {
|
|
85
|
+
return {
|
|
86
|
+
exe: 'pwsh.exe',
|
|
87
|
+
args: ['-NoLogo', '-NonInteractive', '-Command', `& { ${cmd} doctor }`],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
if (shell === 'cmd') {
|
|
91
|
+
return {
|
|
92
|
+
exe: process.env.ComSpec || 'cmd.exe',
|
|
93
|
+
args: ['/d', '/s', '/c', `${cmd} doctor`],
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
// direct
|
|
97
|
+
if (path.isAbsolute(cmd)) {
|
|
98
|
+
const ext = path.extname(cmd).toLowerCase();
|
|
99
|
+
if (ext === '.cmd' || ext === '.bat') {
|
|
100
|
+
return { exe: process.env.ComSpec || 'cmd.exe', args: ['/d', '/s', '/c', `"${cmd}" doctor`] };
|
|
101
|
+
}
|
|
102
|
+
if (ext === '.ps1') {
|
|
103
|
+
return { exe: 'powershell.exe', args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', cmd, 'doctor'] };
|
|
104
|
+
}
|
|
105
|
+
return { exe: cmd, args: ['doctor'] };
|
|
106
|
+
}
|
|
107
|
+
// bare name on direct → defer to cmd.exe so Windows resolves via PATH
|
|
108
|
+
return { exe: process.env.ComSpec || 'cmd.exe', args: ['/d', '/s', '/c', `${cmd} doctor`] };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function probeCodexHome({ command, shell }) {
|
|
112
|
+
const key = cacheKey(command, shell);
|
|
113
|
+
if (codexHomeCache.has(key)) return codexHomeCache.get(key);
|
|
114
|
+
const inv = buildDoctorInvocation(command, shell);
|
|
115
|
+
if (!inv) { codexHomeCache.set(key, null); return null; }
|
|
116
|
+
const { stdout, stderr } = await execWithTimeout(inv.exe, inv.args);
|
|
117
|
+
// Some wrappers print their banner to stderr; doctor itself prints
|
|
118
|
+
// the CODEX_HOME line to stdout. Search both to be safe.
|
|
119
|
+
const home = parseCodexHomeFromDoctor(stdout) || parseCodexHomeFromDoctor(stderr);
|
|
120
|
+
codexHomeCache.set(key, home);
|
|
121
|
+
return home;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function seedCodexSession({ id, cwd, cli }) {
|
|
125
|
+
if (!id || !cwd) throw new Error('seedCodexSession: id and cwd required');
|
|
126
|
+
// Resolution order:
|
|
127
|
+
// 1. `<cli.command> doctor` probe (handles wrappers that
|
|
128
|
+
// relocate CODEX_HOME)
|
|
129
|
+
// 2. process.env.CODEX_HOME (global override)
|
|
130
|
+
// 3. ~/.codex (codex's own default)
|
|
131
|
+
let home = null;
|
|
132
|
+
if (cli?.command) {
|
|
133
|
+
try { home = await probeCodexHome({ command: cli.command, shell: cli.shell }); }
|
|
134
|
+
catch (_) { /* probe is best-effort */ }
|
|
135
|
+
}
|
|
136
|
+
if (!home) home = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
137
|
+
|
|
138
|
+
const now = new Date();
|
|
139
|
+
const yyyy = String(now.getUTCFullYear());
|
|
140
|
+
const mm = String(now.getUTCMonth() + 1).padStart(2, '0');
|
|
141
|
+
const dd = String(now.getUTCDate()).padStart(2, '0');
|
|
142
|
+
const dir = path.join(home, 'sessions', yyyy, mm, dd);
|
|
143
|
+
await fs.mkdir(dir, { recursive: true });
|
|
144
|
+
const file = path.join(dir, `rollout-${isoForFilename(now)}-${id}.jsonl`);
|
|
145
|
+
const meta = {
|
|
146
|
+
timestamp: now.toISOString(),
|
|
147
|
+
type: 'session_meta',
|
|
148
|
+
payload: {
|
|
149
|
+
id,
|
|
150
|
+
timestamp: now.toISOString(),
|
|
151
|
+
cwd,
|
|
152
|
+
originator: 'ccsm',
|
|
153
|
+
cli_version: '0.0.0',
|
|
154
|
+
source: 'ccsm-seed',
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
await fs.writeFile(file, JSON.stringify(meta) + '\n', 'utf8');
|
|
158
|
+
return file;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Copy ccsm's bundled light codex syntax theme into the codex home's themes/
|
|
162
|
+
// dir so `-c tui.theme=ccsm-light` resolves. This theme carries light
|
|
163
|
+
// markup.inserted/deleted backgrounds, which at true-color level override
|
|
164
|
+
// codex's diff palette — the only way to get a LIGHT diff on Windows, where
|
|
165
|
+
// codex's own background detection (default_bg()) is compiled out and always
|
|
166
|
+
// falls back to a dark diff. Idempotent (writes only when missing/changed).
|
|
167
|
+
async function ensureCodexLightTheme(home) {
|
|
168
|
+
if (!home) return false;
|
|
169
|
+
const src = path.join(__dirname, 'codexThemes', 'ccsm-light.tmTheme');
|
|
170
|
+
const dstDir = path.join(home, 'themes');
|
|
171
|
+
const dst = path.join(dstDir, 'ccsm-light.tmTheme');
|
|
172
|
+
try {
|
|
173
|
+
const content = await fs.readFile(src, 'utf8');
|
|
174
|
+
await fs.mkdir(dstDir, { recursive: true });
|
|
175
|
+
let existing = null;
|
|
176
|
+
try { existing = await fs.readFile(dst, 'utf8'); } catch {}
|
|
177
|
+
if (existing !== content) await fs.writeFile(dst, content, 'utf8');
|
|
178
|
+
return true;
|
|
179
|
+
} catch { return false; }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
module.exports = { seedCodexSession, probeCodexHome, parseCodexHomeFromDoctor, ensureCodexLightTheme };
|
|
183
|
+
|