@bakapiano/ccsm 0.14.0 → 0.15.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/CLAUDE.md +474 -475
- package/README.md +189 -190
- package/bin/ccsm.js +194 -194
- package/lib/cliActivity.js +118 -0
- package/lib/codexSeed.js +147 -0
- package/lib/config.js +205 -188
- package/lib/folders.js +105 -105
- package/lib/localCliSessions.js +489 -489
- package/lib/persistedSessions.js +144 -142
- package/lib/webTerminal.js +224 -224
- package/lib/workspace.js +230 -230
- package/package.json +57 -57
- package/public/css/base.css +99 -99
- package/public/css/cards.css +183 -183
- package/public/css/feedback.css +303 -303
- package/public/css/forms.css +405 -405
- package/public/css/layout.css +160 -160
- package/public/css/modal.css +190 -190
- package/public/css/responsive.css +10 -10
- package/public/css/sidebar.css +613 -608
- package/public/css/terminals.css +294 -294
- package/public/css/tokens.css +81 -81
- package/public/css/wco.css +98 -98
- package/public/css/widgets.css +1628 -1628
- package/public/index.html +111 -105
- package/public/js/api.js +296 -280
- package/public/js/components/AdoptModal.js +343 -343
- package/public/js/components/App.js +35 -35
- package/public/js/components/DirectoryPicker.js +203 -203
- package/public/js/components/EntityFormModal.js +141 -141
- package/public/js/components/Modal.js +51 -51
- package/public/js/components/OfflineBanner.js +93 -93
- package/public/js/components/PageTitleBar.js +13 -13
- package/public/js/components/Picker.js +179 -179
- package/public/js/components/Popover.js +55 -55
- package/public/js/components/Sidebar.js +299 -299
- package/public/js/components/TerminalView.js +314 -314
- package/public/js/components/useDragSort.js +67 -67
- package/public/js/dialog.js +67 -67
- package/public/js/icons.js +177 -177
- package/public/js/main.js +132 -132
- package/public/js/pages/AboutPage.js +173 -165
- package/public/js/pages/ConfigurePage.js +513 -475
- package/public/js/pages/LaunchPage.js +369 -369
- package/public/js/pages/SessionsPage.js +101 -97
- package/public/js/state.js +231 -231
- package/scripts/dev.js +44 -11
- package/scripts/install.js +158 -158
- package/scripts/restart-helper.js +96 -0
- package/scripts/upgrade-helper.js +6 -1
- package/server.js +1282 -1254
- package/lib/cliSessionWatcher.js +0 -275
- package/public/manifest.webmanifest +0 -15
package/bin/ccsm.js
CHANGED
|
@@ -1,194 +1,194 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
'use strict';
|
|
3
|
-
|
|
4
|
-
// ccsm launcher · entry point for `ccsm` / `npx @bakapiano/ccsm`.
|
|
5
|
-
//
|
|
6
|
-
// Two modes by how it's invoked:
|
|
7
|
-
//
|
|
8
|
-
// plain `ccsm` → start backend if not running, open a browser
|
|
9
|
-
// window pointing at it. Terminal returns to a
|
|
10
|
-
// prompt immediately (detached).
|
|
11
|
-
//
|
|
12
|
-
// `ccsm ccsm://<action>` → fired by Windows when the user clicks a
|
|
13
|
-
// ccsm:// link (PWA offline banner). Same
|
|
14
|
-
// backend startup as above, but DO NOT spawn
|
|
15
|
-
// an extra browser — the PWA window that
|
|
16
|
-
// triggered the click is already open and
|
|
17
|
-
// will reconnect as soon as the backend
|
|
18
|
-
// becomes reachable.
|
|
19
|
-
//
|
|
20
|
-
// In both modes, if a server is already running we just ping it. New
|
|
21
|
-
// browser window opens only in the plain-`ccsm` case.
|
|
22
|
-
|
|
23
|
-
const path = require('node:path');
|
|
24
|
-
const fs = require('node:fs');
|
|
25
|
-
const os = require('node:os');
|
|
26
|
-
const http = require('node:http');
|
|
27
|
-
const { spawn } = require('node:child_process');
|
|
28
|
-
|
|
29
|
-
const SERVER = path.join(__dirname, '..', 'server.js');
|
|
30
|
-
const HOME = process.env.CCSM_HOME || path.join(os.homedir(), '.ccsm');
|
|
31
|
-
const LOG = path.join(HOME, 'server.log');
|
|
32
|
-
|
|
33
|
-
function loadPreferredPort() {
|
|
34
|
-
try {
|
|
35
|
-
const cfg = JSON.parse(fs.readFileSync(path.join(HOME, 'config.json'), 'utf8'));
|
|
36
|
-
return Number(cfg.port) || 7777;
|
|
37
|
-
} catch {
|
|
38
|
-
return 7777;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function probe(port, timeoutMs = 800) {
|
|
43
|
-
return new Promise((resolve) => {
|
|
44
|
-
const req = http.get(`http://localhost:${port}/api/health`, { timeout: timeoutMs }, (res) => {
|
|
45
|
-
let body = '';
|
|
46
|
-
res.on('data', (c) => body += c);
|
|
47
|
-
res.on('end', () => {
|
|
48
|
-
try {
|
|
49
|
-
const j = JSON.parse(body);
|
|
50
|
-
resolve(j && j.name === '@bakapiano/ccsm' ? j : null);
|
|
51
|
-
} catch { resolve(null); }
|
|
52
|
-
});
|
|
53
|
-
});
|
|
54
|
-
req.on('error', () => resolve(null));
|
|
55
|
-
req.on('timeout', () => { req.destroy(); resolve(null); });
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function post(port, pathname, timeoutMs = 2000) {
|
|
60
|
-
return new Promise((resolve) => {
|
|
61
|
-
const req = http.request({
|
|
62
|
-
hostname: 'localhost', port, path: pathname, method: 'POST',
|
|
63
|
-
headers: { 'Content-Type': 'application/json', 'Content-Length': 2 },
|
|
64
|
-
timeout: timeoutMs,
|
|
65
|
-
}, (res) => {
|
|
66
|
-
res.resume();
|
|
67
|
-
res.on('end', () => resolve(res.statusCode < 300));
|
|
68
|
-
});
|
|
69
|
-
req.on('error', () => resolve(false));
|
|
70
|
-
req.on('timeout', () => { req.destroy(); resolve(false); });
|
|
71
|
-
req.write('{}');
|
|
72
|
-
req.end();
|
|
73
|
-
});
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// Detect ccsm:// protocol invocation. Windows runs us as
|
|
77
|
-
// `ccsm.cmd ccsm://start` when the user clicks a protocol link.
|
|
78
|
-
// argv layout: [node, ccsm.js, "ccsm://..."]
|
|
79
|
-
function parseProtocolArg() {
|
|
80
|
-
const a = process.argv[2];
|
|
81
|
-
if (!a || !/^ccsm:\/\//i.test(a)) return null;
|
|
82
|
-
try {
|
|
83
|
-
// Normalise: ccsm://start or ccsm://start?foo=bar
|
|
84
|
-
const u = new URL(a);
|
|
85
|
-
// host is the action (`start`, `restart`, ...); empty host means
|
|
86
|
-
// the URL was `ccsm:start` or `ccsm:///action`
|
|
87
|
-
const action = (u.hostname || u.pathname.replace(/^\/+/, '').split('/')[0] || '').toLowerCase();
|
|
88
|
-
return { action, raw: a };
|
|
89
|
-
} catch {
|
|
90
|
-
return { action: '', raw: a };
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Compare what's running with what's installed. Returns true if they
|
|
95
|
-
// match (or running is unknown). False means we should restart so the
|
|
96
|
-
// new code takes over after an `npm i -g @bakapiano/ccsm@latest`.
|
|
97
|
-
function isSameVersion(running) {
|
|
98
|
-
try {
|
|
99
|
-
const installed = require('../package.json').version;
|
|
100
|
-
return running.version === installed;
|
|
101
|
-
} catch { return true; }
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
(async () => {
|
|
105
|
-
const protocol = parseProtocolArg();
|
|
106
|
-
const SILENT = !!protocol; // ccsm:// invocations should not open a new browser
|
|
107
|
-
const port = loadPreferredPort();
|
|
108
|
-
|
|
109
|
-
// Case 1: existing instance on the preferred port
|
|
110
|
-
let existing = await probe(port);
|
|
111
|
-
|
|
112
|
-
// If an old version is running, ask it to shut down so the freshly
|
|
113
|
-
// installed code can take over. The launcher then falls through to
|
|
114
|
-
// Case 2 and spawns the new server itself.
|
|
115
|
-
if (existing && !isSameVersion(existing)) {
|
|
116
|
-
const installed = require('../package.json').version;
|
|
117
|
-
console.log(`ccsm upgrading · running v${existing.version} → installed v${installed}`);
|
|
118
|
-
await post(port, '/api/shutdown');
|
|
119
|
-
// Wait for the old process to actually exit so its port frees up.
|
|
120
|
-
for (let i = 0; i < 30; i++) {
|
|
121
|
-
await new Promise((r) => setTimeout(r, 200));
|
|
122
|
-
if (!(await probe(port, 200))) { existing = null; break; }
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
if (existing) {
|
|
127
|
-
if (!SILENT) {
|
|
128
|
-
const opened = await post(port, '/api/spawn-browser');
|
|
129
|
-
console.log(`ccsm already running · v${existing.version} · http://localhost:${port}`);
|
|
130
|
-
if (!opened) console.log('(could not open a new window — server might be busy)');
|
|
131
|
-
} else {
|
|
132
|
-
console.log(`ccsm already running · ${protocol.raw}`);
|
|
133
|
-
}
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// Case 2: spawn detached server
|
|
138
|
-
fs.mkdirSync(HOME, { recursive: true });
|
|
139
|
-
const out = fs.openSync(LOG, 'a');
|
|
140
|
-
fs.writeSync(out, `\n[${new Date().toISOString()}] ccsm starting (protocol=${protocol?.raw || '-'})...\n`);
|
|
141
|
-
|
|
142
|
-
const child = spawn(process.execPath, [SERVER], {
|
|
143
|
-
detached: true,
|
|
144
|
-
stdio: ['ignore', out, out],
|
|
145
|
-
windowsHide: true,
|
|
146
|
-
env: {
|
|
147
|
-
...process.env,
|
|
148
|
-
CCSM_LAUNCHER: '1',
|
|
149
|
-
// Suppress the server's own auto-spawn of a browser when this launch
|
|
150
|
-
// came from a ccsm:// click — the PWA window that fired it is the
|
|
151
|
-
// browser, and a second window would just be noise.
|
|
152
|
-
...(SILENT ? { CCSM_NO_BROWSER: '1' } : {}),
|
|
153
|
-
},
|
|
154
|
-
});
|
|
155
|
-
child.unref();
|
|
156
|
-
|
|
157
|
-
// Poll /api/health for up to ~10s. Once it answers we know the server
|
|
158
|
-
// is fully booted (port is bound, config loaded, snapshot loop running).
|
|
159
|
-
// The actual port may differ from the preferred one if it was taken,
|
|
160
|
-
// so on each iteration we re-probe the preferred port first, then fall
|
|
161
|
-
// back to scanning preferred+1..preferred+9.
|
|
162
|
-
const portsToTry = [port, ...Array.from({ length: 9 }, (_, i) => port + i + 1)];
|
|
163
|
-
let actualPort = null;
|
|
164
|
-
let ready = null;
|
|
165
|
-
outer:
|
|
166
|
-
for (let i = 0; i < 50; i++) {
|
|
167
|
-
await new Promise((r) => setTimeout(r, 200));
|
|
168
|
-
for (const p of portsToTry) {
|
|
169
|
-
const r = await probe(p, 300);
|
|
170
|
-
if (r) { ready = r; actualPort = p; break outer; }
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
if (!ready) {
|
|
174
|
-
console.error(`ccsm server did not come up in 10s. Check ${LOG}`);
|
|
175
|
-
process.exit(1);
|
|
176
|
-
}
|
|
177
|
-
console.log(`ccsm started · v${ready.version}`);
|
|
178
|
-
console.log(`backend: http://localhost:${actualPort}${actualPort !== port ? ` (preferred ${port} was taken)` : ''}`);
|
|
179
|
-
console.log(`frontend: https://bakapiano.github.io/ccsm/v1/`);
|
|
180
|
-
console.log(`logs: ${LOG}`);
|
|
181
|
-
|
|
182
|
-
// First-run hint — printed once, then a marker file makes us quiet.
|
|
183
|
-
const firstRunMark = path.join(HOME, '.first-run-shown');
|
|
184
|
-
if (!fs.existsSync(firstRunMark)) {
|
|
185
|
-
try { fs.writeFileSync(firstRunMark, new Date().toISOString()); } catch {}
|
|
186
|
-
console.log('');
|
|
187
|
-
console.log('First run · ccsm is now running in the background.');
|
|
188
|
-
console.log('Open the frontend URL above, click "Install ccsm" in your browser');
|
|
189
|
-
console.log('to install it as a PWA so the icon launches directly into the app.');
|
|
190
|
-
}
|
|
191
|
-
})().catch((err) => {
|
|
192
|
-
console.error('ccsm launcher failed:', err);
|
|
193
|
-
process.exit(1);
|
|
194
|
-
});
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// ccsm launcher · entry point for `ccsm` / `npx @bakapiano/ccsm`.
|
|
5
|
+
//
|
|
6
|
+
// Two modes by how it's invoked:
|
|
7
|
+
//
|
|
8
|
+
// plain `ccsm` → start backend if not running, open a browser
|
|
9
|
+
// window pointing at it. Terminal returns to a
|
|
10
|
+
// prompt immediately (detached).
|
|
11
|
+
//
|
|
12
|
+
// `ccsm ccsm://<action>` → fired by Windows when the user clicks a
|
|
13
|
+
// ccsm:// link (PWA offline banner). Same
|
|
14
|
+
// backend startup as above, but DO NOT spawn
|
|
15
|
+
// an extra browser — the PWA window that
|
|
16
|
+
// triggered the click is already open and
|
|
17
|
+
// will reconnect as soon as the backend
|
|
18
|
+
// becomes reachable.
|
|
19
|
+
//
|
|
20
|
+
// In both modes, if a server is already running we just ping it. New
|
|
21
|
+
// browser window opens only in the plain-`ccsm` case.
|
|
22
|
+
|
|
23
|
+
const path = require('node:path');
|
|
24
|
+
const fs = require('node:fs');
|
|
25
|
+
const os = require('node:os');
|
|
26
|
+
const http = require('node:http');
|
|
27
|
+
const { spawn } = require('node:child_process');
|
|
28
|
+
|
|
29
|
+
const SERVER = path.join(__dirname, '..', 'server.js');
|
|
30
|
+
const HOME = process.env.CCSM_HOME || path.join(os.homedir(), '.ccsm');
|
|
31
|
+
const LOG = path.join(HOME, 'server.log');
|
|
32
|
+
|
|
33
|
+
function loadPreferredPort() {
|
|
34
|
+
try {
|
|
35
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(HOME, 'config.json'), 'utf8'));
|
|
36
|
+
return Number(cfg.port) || 7777;
|
|
37
|
+
} catch {
|
|
38
|
+
return 7777;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function probe(port, timeoutMs = 800) {
|
|
43
|
+
return new Promise((resolve) => {
|
|
44
|
+
const req = http.get(`http://localhost:${port}/api/health`, { timeout: timeoutMs }, (res) => {
|
|
45
|
+
let body = '';
|
|
46
|
+
res.on('data', (c) => body += c);
|
|
47
|
+
res.on('end', () => {
|
|
48
|
+
try {
|
|
49
|
+
const j = JSON.parse(body);
|
|
50
|
+
resolve(j && j.name === '@bakapiano/ccsm' ? j : null);
|
|
51
|
+
} catch { resolve(null); }
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
req.on('error', () => resolve(null));
|
|
55
|
+
req.on('timeout', () => { req.destroy(); resolve(null); });
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function post(port, pathname, timeoutMs = 2000) {
|
|
60
|
+
return new Promise((resolve) => {
|
|
61
|
+
const req = http.request({
|
|
62
|
+
hostname: 'localhost', port, path: pathname, method: 'POST',
|
|
63
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': 2 },
|
|
64
|
+
timeout: timeoutMs,
|
|
65
|
+
}, (res) => {
|
|
66
|
+
res.resume();
|
|
67
|
+
res.on('end', () => resolve(res.statusCode < 300));
|
|
68
|
+
});
|
|
69
|
+
req.on('error', () => resolve(false));
|
|
70
|
+
req.on('timeout', () => { req.destroy(); resolve(false); });
|
|
71
|
+
req.write('{}');
|
|
72
|
+
req.end();
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Detect ccsm:// protocol invocation. Windows runs us as
|
|
77
|
+
// `ccsm.cmd ccsm://start` when the user clicks a protocol link.
|
|
78
|
+
// argv layout: [node, ccsm.js, "ccsm://..."]
|
|
79
|
+
function parseProtocolArg() {
|
|
80
|
+
const a = process.argv[2];
|
|
81
|
+
if (!a || !/^ccsm:\/\//i.test(a)) return null;
|
|
82
|
+
try {
|
|
83
|
+
// Normalise: ccsm://start or ccsm://start?foo=bar
|
|
84
|
+
const u = new URL(a);
|
|
85
|
+
// host is the action (`start`, `restart`, ...); empty host means
|
|
86
|
+
// the URL was `ccsm:start` or `ccsm:///action`
|
|
87
|
+
const action = (u.hostname || u.pathname.replace(/^\/+/, '').split('/')[0] || '').toLowerCase();
|
|
88
|
+
return { action, raw: a };
|
|
89
|
+
} catch {
|
|
90
|
+
return { action: '', raw: a };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Compare what's running with what's installed. Returns true if they
|
|
95
|
+
// match (or running is unknown). False means we should restart so the
|
|
96
|
+
// new code takes over after an `npm i -g @bakapiano/ccsm@latest`.
|
|
97
|
+
function isSameVersion(running) {
|
|
98
|
+
try {
|
|
99
|
+
const installed = require('../package.json').version;
|
|
100
|
+
return running.version === installed;
|
|
101
|
+
} catch { return true; }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
(async () => {
|
|
105
|
+
const protocol = parseProtocolArg();
|
|
106
|
+
const SILENT = !!protocol; // ccsm:// invocations should not open a new browser
|
|
107
|
+
const port = loadPreferredPort();
|
|
108
|
+
|
|
109
|
+
// Case 1: existing instance on the preferred port
|
|
110
|
+
let existing = await probe(port);
|
|
111
|
+
|
|
112
|
+
// If an old version is running, ask it to shut down so the freshly
|
|
113
|
+
// installed code can take over. The launcher then falls through to
|
|
114
|
+
// Case 2 and spawns the new server itself.
|
|
115
|
+
if (existing && !isSameVersion(existing)) {
|
|
116
|
+
const installed = require('../package.json').version;
|
|
117
|
+
console.log(`ccsm upgrading · running v${existing.version} → installed v${installed}`);
|
|
118
|
+
await post(port, '/api/shutdown');
|
|
119
|
+
// Wait for the old process to actually exit so its port frees up.
|
|
120
|
+
for (let i = 0; i < 30; i++) {
|
|
121
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
122
|
+
if (!(await probe(port, 200))) { existing = null; break; }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (existing) {
|
|
127
|
+
if (!SILENT) {
|
|
128
|
+
const opened = await post(port, '/api/spawn-browser');
|
|
129
|
+
console.log(`ccsm already running · v${existing.version} · http://localhost:${port}`);
|
|
130
|
+
if (!opened) console.log('(could not open a new window — server might be busy)');
|
|
131
|
+
} else {
|
|
132
|
+
console.log(`ccsm already running · ${protocol.raw}`);
|
|
133
|
+
}
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Case 2: spawn detached server
|
|
138
|
+
fs.mkdirSync(HOME, { recursive: true });
|
|
139
|
+
const out = fs.openSync(LOG, 'a');
|
|
140
|
+
fs.writeSync(out, `\n[${new Date().toISOString()}] ccsm starting (protocol=${protocol?.raw || '-'})...\n`);
|
|
141
|
+
|
|
142
|
+
const child = spawn(process.execPath, [SERVER], {
|
|
143
|
+
detached: true,
|
|
144
|
+
stdio: ['ignore', out, out],
|
|
145
|
+
windowsHide: true,
|
|
146
|
+
env: {
|
|
147
|
+
...process.env,
|
|
148
|
+
CCSM_LAUNCHER: '1',
|
|
149
|
+
// Suppress the server's own auto-spawn of a browser when this launch
|
|
150
|
+
// came from a ccsm:// click — the PWA window that fired it is the
|
|
151
|
+
// browser, and a second window would just be noise.
|
|
152
|
+
...(SILENT ? { CCSM_NO_BROWSER: '1' } : {}),
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
child.unref();
|
|
156
|
+
|
|
157
|
+
// Poll /api/health for up to ~10s. Once it answers we know the server
|
|
158
|
+
// is fully booted (port is bound, config loaded, snapshot loop running).
|
|
159
|
+
// The actual port may differ from the preferred one if it was taken,
|
|
160
|
+
// so on each iteration we re-probe the preferred port first, then fall
|
|
161
|
+
// back to scanning preferred+1..preferred+9.
|
|
162
|
+
const portsToTry = [port, ...Array.from({ length: 9 }, (_, i) => port + i + 1)];
|
|
163
|
+
let actualPort = null;
|
|
164
|
+
let ready = null;
|
|
165
|
+
outer:
|
|
166
|
+
for (let i = 0; i < 50; i++) {
|
|
167
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
168
|
+
for (const p of portsToTry) {
|
|
169
|
+
const r = await probe(p, 300);
|
|
170
|
+
if (r) { ready = r; actualPort = p; break outer; }
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (!ready) {
|
|
174
|
+
console.error(`ccsm server did not come up in 10s. Check ${LOG}`);
|
|
175
|
+
process.exit(1);
|
|
176
|
+
}
|
|
177
|
+
console.log(`ccsm started · v${ready.version}`);
|
|
178
|
+
console.log(`backend: http://localhost:${actualPort}${actualPort !== port ? ` (preferred ${port} was taken)` : ''}`);
|
|
179
|
+
console.log(`frontend: https://bakapiano.github.io/ccsm/v1/`);
|
|
180
|
+
console.log(`logs: ${LOG}`);
|
|
181
|
+
|
|
182
|
+
// First-run hint — printed once, then a marker file makes us quiet.
|
|
183
|
+
const firstRunMark = path.join(HOME, '.first-run-shown');
|
|
184
|
+
if (!fs.existsSync(firstRunMark)) {
|
|
185
|
+
try { fs.writeFileSync(firstRunMark, new Date().toISOString()); } catch {}
|
|
186
|
+
console.log('');
|
|
187
|
+
console.log('First run · ccsm is now running in the background.');
|
|
188
|
+
console.log('Open the frontend URL above, click "Install ccsm" in your browser');
|
|
189
|
+
console.log('to install it as a PWA so the icon launches directly into the app.');
|
|
190
|
+
}
|
|
191
|
+
})().catch((err) => {
|
|
192
|
+
console.error('ccsm launcher failed:', err);
|
|
193
|
+
process.exit(1);
|
|
194
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
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 };
|
|
94
|
+
state.set(record.id, s);
|
|
95
|
+
}
|
|
96
|
+
if (!s.resolvedPath) {
|
|
97
|
+
s.resolvedPath = await resolveTranscript(record, cliCfg);
|
|
98
|
+
if (!s.resolvedPath) return 'unknown';
|
|
99
|
+
}
|
|
100
|
+
let mtimeMs;
|
|
101
|
+
try { mtimeMs = (await fs.stat(s.resolvedPath)).mtimeMs; }
|
|
102
|
+
catch {
|
|
103
|
+
// File disappeared (rollover, manual delete) — drop the cache so we
|
|
104
|
+
// re-resolve on the next probe.
|
|
105
|
+
s.resolvedPath = null;
|
|
106
|
+
return 'unknown';
|
|
107
|
+
}
|
|
108
|
+
const now = Date.now();
|
|
109
|
+
if (mtimeMs !== s.lastMtimeMs) {
|
|
110
|
+
s.lastMtimeMs = mtimeMs;
|
|
111
|
+
s.lastChangedAt = now;
|
|
112
|
+
}
|
|
113
|
+
return (now - s.lastChangedAt) < WORKING_WINDOW_MS ? 'working' : 'idle';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function releaseSession(sessionId) { state.delete(sessionId); }
|
|
117
|
+
|
|
118
|
+
module.exports = { probeActivity, releaseSession };
|
package/lib/codexSeed.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
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. Wrappers like `cxp` relocate CODEX_HOME to a
|
|
18
|
+
// non-default dir (e.g. %LOCALAPPDATA%\gc2cc\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
|
+
|
|
29
|
+
function isoForFilename(d = new Date()) {
|
|
30
|
+
// 2026-05-25T15:39:11 → 2026-05-25T15-39-11 (codex strips ms + colons)
|
|
31
|
+
return d.toISOString().replace(/\.\d+Z$/, '').replace(/:/g, '-');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// command+shell → CODEX_HOME (or null if probe failed / not detected).
|
|
35
|
+
// Module-scope so we probe at most once per (command, shell) per server.
|
|
36
|
+
const codexHomeCache = new Map();
|
|
37
|
+
function cacheKey(command, shell) { return `${shell || 'direct'}|${command}`; }
|
|
38
|
+
|
|
39
|
+
function execWithTimeout(exe, args, { timeoutMs = 8000 } = {}) {
|
|
40
|
+
return new Promise((resolve) => {
|
|
41
|
+
execFile(exe, args, {
|
|
42
|
+
windowsHide: true,
|
|
43
|
+
timeout: timeoutMs,
|
|
44
|
+
maxBuffer: 1024 * 1024,
|
|
45
|
+
}, (err, stdout, stderr) => {
|
|
46
|
+
resolve({ err, stdout: String(stdout || ''), stderr: String(stderr || '') });
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Parse `CODEX_HOME <path> (dir)` out of `codex doctor` output. Codex
|
|
52
|
+
// formats it with variable whitespace; the `(dir)` / `(file)` suffix is
|
|
53
|
+
// the easiest anchor to identify the path end.
|
|
54
|
+
function parseCodexHomeFromDoctor(text) {
|
|
55
|
+
if (!text) return null;
|
|
56
|
+
const m = text.match(/\bCODEX_HOME\s+(.+?)\s*\((?:dir|file)\)/);
|
|
57
|
+
if (!m) return null;
|
|
58
|
+
const p = m[1].trim();
|
|
59
|
+
return p || null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Build the [exe, args] needed to run `<cli.command> doctor` honouring
|
|
63
|
+
// the same shell-wrapping rules webTerminal uses. Mirrors the relevant
|
|
64
|
+
// bits of server.js' resolveCommand — kept local so this module doesn't
|
|
65
|
+
// drag a dependency on server.js.
|
|
66
|
+
function buildDoctorInvocation(command, shell) {
|
|
67
|
+
const cmd = String(command || '').replace(/^\.[\\/]/, '');
|
|
68
|
+
if (!cmd) return null;
|
|
69
|
+
if (shell === 'pwsh') {
|
|
70
|
+
return {
|
|
71
|
+
exe: 'pwsh.exe',
|
|
72
|
+
args: ['-NoLogo', '-NonInteractive', '-Command', `& { ${cmd} doctor }`],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (shell === 'cmd') {
|
|
76
|
+
return {
|
|
77
|
+
exe: process.env.ComSpec || 'cmd.exe',
|
|
78
|
+
args: ['/d', '/s', '/c', `${cmd} doctor`],
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
// direct
|
|
82
|
+
if (path.isAbsolute(cmd)) {
|
|
83
|
+
const ext = path.extname(cmd).toLowerCase();
|
|
84
|
+
if (ext === '.cmd' || ext === '.bat') {
|
|
85
|
+
return { exe: process.env.ComSpec || 'cmd.exe', args: ['/d', '/s', '/c', `"${cmd}" doctor`] };
|
|
86
|
+
}
|
|
87
|
+
if (ext === '.ps1') {
|
|
88
|
+
return { exe: 'powershell.exe', args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', cmd, 'doctor'] };
|
|
89
|
+
}
|
|
90
|
+
return { exe: cmd, args: ['doctor'] };
|
|
91
|
+
}
|
|
92
|
+
// bare name on direct → defer to cmd.exe so Windows resolves via PATH
|
|
93
|
+
return { exe: process.env.ComSpec || 'cmd.exe', args: ['/d', '/s', '/c', `${cmd} doctor`] };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function probeCodexHome({ command, shell }) {
|
|
97
|
+
const key = cacheKey(command, shell);
|
|
98
|
+
if (codexHomeCache.has(key)) return codexHomeCache.get(key);
|
|
99
|
+
const inv = buildDoctorInvocation(command, shell);
|
|
100
|
+
if (!inv) { codexHomeCache.set(key, null); return null; }
|
|
101
|
+
const { stdout, stderr } = await execWithTimeout(inv.exe, inv.args);
|
|
102
|
+
// Wrappers like cxp print their banner to stderr; doctor itself prints
|
|
103
|
+
// the CODEX_HOME line to stdout. Search both to be safe.
|
|
104
|
+
const home = parseCodexHomeFromDoctor(stdout) || parseCodexHomeFromDoctor(stderr);
|
|
105
|
+
codexHomeCache.set(key, home);
|
|
106
|
+
return home;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function seedCodexSession({ id, cwd, cli }) {
|
|
110
|
+
if (!id || !cwd) throw new Error('seedCodexSession: id and cwd required');
|
|
111
|
+
// Resolution order:
|
|
112
|
+
// 1. `<cli.command> doctor` probe (handles wrappers like cxp that
|
|
113
|
+
// relocate CODEX_HOME)
|
|
114
|
+
// 2. process.env.CODEX_HOME (global override)
|
|
115
|
+
// 3. ~/.codex (codex's own default)
|
|
116
|
+
let home = null;
|
|
117
|
+
if (cli?.command) {
|
|
118
|
+
try { home = await probeCodexHome({ command: cli.command, shell: cli.shell }); }
|
|
119
|
+
catch (_) { /* probe is best-effort */ }
|
|
120
|
+
}
|
|
121
|
+
if (!home) home = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
122
|
+
|
|
123
|
+
const now = new Date();
|
|
124
|
+
const yyyy = String(now.getUTCFullYear());
|
|
125
|
+
const mm = String(now.getUTCMonth() + 1).padStart(2, '0');
|
|
126
|
+
const dd = String(now.getUTCDate()).padStart(2, '0');
|
|
127
|
+
const dir = path.join(home, 'sessions', yyyy, mm, dd);
|
|
128
|
+
await fs.mkdir(dir, { recursive: true });
|
|
129
|
+
const file = path.join(dir, `rollout-${isoForFilename(now)}-${id}.jsonl`);
|
|
130
|
+
const meta = {
|
|
131
|
+
timestamp: now.toISOString(),
|
|
132
|
+
type: 'session_meta',
|
|
133
|
+
payload: {
|
|
134
|
+
id,
|
|
135
|
+
timestamp: now.toISOString(),
|
|
136
|
+
cwd,
|
|
137
|
+
originator: 'ccsm',
|
|
138
|
+
cli_version: '0.0.0',
|
|
139
|
+
source: 'ccsm-seed',
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
await fs.writeFile(file, JSON.stringify(meta) + '\n', 'utf8');
|
|
143
|
+
return file;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
module.exports = { seedCodexSession, probeCodexHome, parseCodexHomeFromDoctor };
|
|
147
|
+
|