@bakapiano/ccsm 0.20.2 → 0.21.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/lib/codexSeed.js +23 -8
- package/lib/config.js +3 -3
- package/lib/localCliSessions.js +102 -72
- package/lib/webTerminal.js +7 -1
- package/lib/winPath.js +67 -0
- package/package.json +1 -1
- package/public/css/responsive.css +16 -0
- package/public/css/terminals.css +26 -38
- package/public/css/widgets.css +114 -86
- package/public/js/components/AdoptModal.js +168 -250
- package/public/js/components/TerminalView.js +42 -16
- package/public/js/pages/ConfigurePage.js +1 -1
- package/public/js/pages/LaunchPage.js +1 -1
- package/server.js +20 -3
package/lib/codexSeed.js
CHANGED
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
// Filename timestamp uses dashes-only (codex's convention), but it's
|
|
15
15
|
// purely cosmetic — codex looks up sessions by UUID, not filename.
|
|
16
16
|
//
|
|
17
|
-
// CODEX_HOME resolution.
|
|
18
|
-
// non-default dir (e.g. %LOCALAPPDATA
|
|
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
19
|
// to land there or `resume <id>` won't find it. We probe by running
|
|
20
20
|
// `<cli.command> doctor` once per (command, shell) pair and parsing the
|
|
21
21
|
// "CODEX_HOME ... (dir)" line out of its output. Cached for the life of
|
|
@@ -25,6 +25,7 @@ const fs = require('node:fs/promises');
|
|
|
25
25
|
const path = require('node:path');
|
|
26
26
|
const os = require('node:os');
|
|
27
27
|
const { execFile } = require('node:child_process');
|
|
28
|
+
const { spawnEnv } = require('./winPath');
|
|
28
29
|
|
|
29
30
|
function isoForFilename(d = new Date()) {
|
|
30
31
|
// 2026-05-25T15:39:11 → 2026-05-25T15-39-11 (codex strips ms + colons)
|
|
@@ -42,18 +43,32 @@ function execWithTimeout(exe, args, { timeoutMs = 8000 } = {}) {
|
|
|
42
43
|
windowsHide: true,
|
|
43
44
|
timeout: timeoutMs,
|
|
44
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(),
|
|
45
49
|
}, (err, stdout, stderr) => {
|
|
46
50
|
resolve({ err, stdout: String(stdout || ''), stderr: String(stderr || '') });
|
|
47
51
|
});
|
|
48
52
|
});
|
|
49
53
|
}
|
|
50
54
|
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
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.
|
|
54
67
|
function parseCodexHomeFromDoctor(text) {
|
|
55
68
|
if (!text) return null;
|
|
56
|
-
const
|
|
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
|
|
57
72
|
if (!m) return null;
|
|
58
73
|
const p = m[1].trim();
|
|
59
74
|
return p || null;
|
|
@@ -99,7 +114,7 @@ async function probeCodexHome({ command, shell }) {
|
|
|
99
114
|
const inv = buildDoctorInvocation(command, shell);
|
|
100
115
|
if (!inv) { codexHomeCache.set(key, null); return null; }
|
|
101
116
|
const { stdout, stderr } = await execWithTimeout(inv.exe, inv.args);
|
|
102
|
-
//
|
|
117
|
+
// Some wrappers print their banner to stderr; doctor itself prints
|
|
103
118
|
// the CODEX_HOME line to stdout. Search both to be safe.
|
|
104
119
|
const home = parseCodexHomeFromDoctor(stdout) || parseCodexHomeFromDoctor(stderr);
|
|
105
120
|
codexHomeCache.set(key, home);
|
|
@@ -109,7 +124,7 @@ async function probeCodexHome({ command, shell }) {
|
|
|
109
124
|
async function seedCodexSession({ id, cwd, cli }) {
|
|
110
125
|
if (!id || !cwd) throw new Error('seedCodexSession: id and cwd required');
|
|
111
126
|
// Resolution order:
|
|
112
|
-
// 1. `<cli.command> doctor` probe (handles wrappers
|
|
127
|
+
// 1. `<cli.command> doctor` probe (handles wrappers that
|
|
113
128
|
// relocate CODEX_HOME)
|
|
114
129
|
// 2. process.env.CODEX_HOME (global override)
|
|
115
130
|
// 3. ~/.codex (codex's own default)
|
package/lib/config.js
CHANGED
|
@@ -70,7 +70,7 @@ const DEFAULTS = {
|
|
|
70
70
|
// Repos available for cloning into a fresh workspace at launch time.
|
|
71
71
|
// { name: 'foo', url: 'https://github.com/me/foo.git', defaultSelected: true }
|
|
72
72
|
repos: [],
|
|
73
|
-
// Pluggable CLIs. Add wrappers
|
|
73
|
+
// Pluggable CLIs. Add custom wrappers or self-hosted
|
|
74
74
|
// proxies by appending an entry. defaultCliId picks one for the
|
|
75
75
|
// Launch button when the user doesn't override.
|
|
76
76
|
clis: DEFAULT_CLIS,
|
|
@@ -170,8 +170,8 @@ function mergeWithDefaults(partial) {
|
|
|
170
170
|
type: ['claude', 'codex', 'copilot', 'other'].includes(rest.type) ? rest.type : 'other',
|
|
171
171
|
builtin: !!rest.builtin,
|
|
172
172
|
};
|
|
173
|
-
// Type-based fallback for non-builtin CLIs (wrappers
|
|
174
|
-
//
|
|
173
|
+
// Type-based fallback for non-builtin CLIs (wrappers that
|
|
174
|
+
// just call claude under the hood). If user picked
|
|
175
175
|
// type='claude' but left newSessionIdArgs / resumeIdArgs blank,
|
|
176
176
|
// assume they want the same args claude / copilot / codex use
|
|
177
177
|
// canonically — without this the wrapped CLI gets spawned with
|
package/lib/localCliSessions.js
CHANGED
|
@@ -103,16 +103,47 @@ async function discoverClaude() {
|
|
|
103
103
|
return all;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
// Codex sessions can live under a RELOCATED CODEX_HOME. Some wrappers
|
|
107
|
+
// point it at e.g. %LOCALAPPDATA%\<wrapper>\codex-home, so `~/.codex` is empty
|
|
108
|
+
// of the sessions the user actually created. Seeding already honours this
|
|
109
|
+
// (codexSeed.probeCodexHome runs `<cli> doctor`); the import scan must too,
|
|
110
|
+
// or those relocated sessions are invisible in the adopt modal. Gather every
|
|
111
|
+
// candidate `<home>/sessions` dir: each configured codex CLI's detected
|
|
112
|
+
// home, the CODEX_HOME env override, and codex's own ~/.codex default.
|
|
113
|
+
async function codexSessionRoots() {
|
|
114
|
+
const roots = new Set();
|
|
115
|
+
try {
|
|
116
|
+
const { loadConfig } = require('./config');
|
|
117
|
+
const { probeCodexHome } = require('./codexSeed');
|
|
118
|
+
const cfg = await loadConfig();
|
|
119
|
+
const codexClis = (cfg?.clis || []).filter((c) => c.type === 'codex' || c.id === 'codex');
|
|
120
|
+
for (const c of codexClis) {
|
|
121
|
+
if (!c.command) continue;
|
|
122
|
+
let home = null;
|
|
123
|
+
try { home = await probeCodexHome({ command: c.command, shell: c.shell }); }
|
|
124
|
+
catch { /* probe is best-effort */ }
|
|
125
|
+
if (home) roots.add(path.join(home, 'sessions'));
|
|
126
|
+
}
|
|
127
|
+
} catch { /* config/probe unavailable — fall through to defaults */ }
|
|
128
|
+
if (process.env.CODEX_HOME) roots.add(path.join(process.env.CODEX_HOME, 'sessions'));
|
|
129
|
+
roots.add(path.join(os.homedir(), '.codex', 'sessions'));
|
|
130
|
+
return [...roots];
|
|
131
|
+
}
|
|
132
|
+
|
|
106
133
|
async function discoverCodex() {
|
|
107
|
-
const
|
|
134
|
+
const roots = await codexSessionRoots();
|
|
108
135
|
const candidates = [];
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
136
|
+
const seen = new Set(); // dedup by session id across homes
|
|
137
|
+
for (const root of roots) {
|
|
138
|
+
await walkFiles(root, async (filepath, st) => {
|
|
139
|
+
if (!filepath.endsWith('.jsonl')) return;
|
|
140
|
+
const base = path.basename(filepath);
|
|
141
|
+
const m = base.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i);
|
|
142
|
+
if (!m || seen.has(m[1])) return;
|
|
143
|
+
seen.add(m[1]);
|
|
144
|
+
candidates.push({ id: m[1], filepath, mtimeMs: st.mtimeMs });
|
|
145
|
+
});
|
|
146
|
+
}
|
|
116
147
|
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
117
148
|
return candidates;
|
|
118
149
|
}
|
|
@@ -121,8 +152,14 @@ async function discoverCodex() {
|
|
|
121
152
|
// records by parsing each jsonl head. cliType is the discriminator in the
|
|
122
153
|
// returned record.
|
|
123
154
|
async function hydrateJsonl(candidates, cliType) {
|
|
155
|
+
// codex prepends a big system preamble (<permissions instructions> +
|
|
156
|
+
// <environment_context>, ~32KB) before the first user message, so 16KB
|
|
157
|
+
// catches the cwd (line 1) but not the summary. Read a larger head for
|
|
158
|
+
// codex; its dataset is tiny so the extra bytes are free. claude/copilot
|
|
159
|
+
// keep the cheap 16KB.
|
|
160
|
+
const headBytes = cliType === 'codex' ? 48 * 1024 : HEAD_BYTES;
|
|
124
161
|
const parseTasks = candidates.map((c) => async () => {
|
|
125
|
-
const { cwd, summary } = await parseJsonlHead(c.filepath, c.mtimeMs);
|
|
162
|
+
const { cwd, summary } = await parseJsonlHead(c.filepath, c.mtimeMs, headBytes);
|
|
126
163
|
if (!cwd) return null;
|
|
127
164
|
return {
|
|
128
165
|
cliType,
|
|
@@ -328,74 +365,53 @@ async function listForTypeWithActive(cliType) {
|
|
|
328
365
|
}));
|
|
329
366
|
}
|
|
330
367
|
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
// 1. Discover phase = stat all candidates. Cheap, even at 1000+ files.
|
|
335
|
-
// 2. Compute active set in parallel.
|
|
336
|
-
// 3. ALWAYS hydrate every active candidate (they go first, never paged
|
|
337
|
-
// out — user explicitly wants live sessions visible up top).
|
|
338
|
-
// 4. For non-active, hydrate only `[offset, offset+limit)` sorted mtime
|
|
339
|
-
// desc. "Load more" = call again with the next offset.
|
|
368
|
+
// Page-based list for the import modal. offset/limit is a window into the
|
|
369
|
+
// full candidate set, ordered active-first then newest-first. Each call
|
|
370
|
+
// returns exactly that page — the modal renders ‹ Prev · X–Y of Z · Next ›.
|
|
340
371
|
//
|
|
341
|
-
// Returns: { sessions, totalActive, totalNonActive, offset, limit, hasMore }
|
|
342
|
-
async function listPaginated(cliType, { offset = 0, limit =
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
//
|
|
372
|
+
// Returns: { sessions, total, totalActive, totalNonActive, offset, limit, hasMore }
|
|
373
|
+
async function listPaginated(cliType, { offset = 0, limit = 20 } = {}) {
|
|
374
|
+
const now = Date.now();
|
|
375
|
+
|
|
376
|
+
// copilot's "discover" is also the parse (no cheap jsonl head), so we
|
|
377
|
+
// already have the full hydrated list — just slice the requested page.
|
|
346
378
|
if (cliType === 'copilot') {
|
|
347
379
|
const all = await listForTypeWithActive('copilot');
|
|
348
|
-
all.sort((a, b) =>
|
|
349
|
-
|
|
350
|
-
return b.mtime - a.mtime;
|
|
351
|
-
});
|
|
380
|
+
all.sort((a, b) => (a.active !== b.active ? (a.active ? -1 : 1) : b.mtime - a.mtime));
|
|
381
|
+
const totalActive = all.filter((x) => x.active).length;
|
|
352
382
|
return {
|
|
353
|
-
sessions: all,
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
limit
|
|
358
|
-
hasMore:
|
|
383
|
+
sessions: all.slice(offset, offset + limit),
|
|
384
|
+
total: all.length,
|
|
385
|
+
totalActive,
|
|
386
|
+
totalNonActive: all.length - totalActive,
|
|
387
|
+
offset, limit,
|
|
388
|
+
hasMore: offset + limit < all.length,
|
|
359
389
|
};
|
|
360
390
|
}
|
|
361
391
|
|
|
392
|
+
// claude / codex: two-phase — cheap discover (stat only), hydrate just the
|
|
393
|
+
// page's slice.
|
|
362
394
|
const discover = cliType === 'codex' ? discoverCodex : discoverClaude;
|
|
363
|
-
const [candidates, activeIds] = await Promise.all([
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
const
|
|
372
|
-
const rest = candidates.filter((c) => !isActiveCand(c));
|
|
373
|
-
|
|
374
|
-
// Slice non-active to the requested page.
|
|
375
|
-
const slice = rest.slice(offset, offset + limit);
|
|
376
|
-
|
|
377
|
-
// Hydrate active (always all) + slice of non-active.
|
|
378
|
-
// First page: hydrate both. Later pages: only the slice — frontend
|
|
379
|
-
// already has the active set from page 0.
|
|
380
|
-
const toHydrate = offset === 0 ? [...active, ...slice] : slice;
|
|
381
|
-
const hydrated = await hydrateJsonl(toHydrate, cliType);
|
|
382
|
-
|
|
383
|
-
// Stamp active flag back on. Doing it post-hydrate so we don't have
|
|
384
|
-
// to thread it through hydrateJsonl.
|
|
385
|
-
const activeIdSet = new Set(active.map((c) => c.id));
|
|
395
|
+
const [candidates, activeIds] = await Promise.all([discover(), getActiveIds(cliType)]);
|
|
396
|
+
const isActive = (c) => activeIds.has(c.id) || (now - c.mtimeMs) < RECENT_MS;
|
|
397
|
+
// Active-first; discover already sorted mtime-desc so a stable partition
|
|
398
|
+
// keeps newest-first within each group.
|
|
399
|
+
const ordered = [...candidates.filter(isActive), ...candidates.filter((c) => !isActive(c))];
|
|
400
|
+
const totalActive = ordered.length - candidates.filter((c) => !isActive(c)).length;
|
|
401
|
+
|
|
402
|
+
const slice = ordered.slice(offset, offset + limit);
|
|
403
|
+
const hydrated = await hydrateJsonl(slice, cliType);
|
|
386
404
|
for (const s of hydrated) {
|
|
387
|
-
s.active =
|
|
388
|
-
|| activeIds.has(s.cliSessionId)
|
|
389
|
-
|| (now - s.mtime) < RECENT_MS;
|
|
405
|
+
s.active = activeIds.has(s.cliSessionId) || (now - s.mtime) < RECENT_MS;
|
|
390
406
|
}
|
|
391
407
|
|
|
392
408
|
return {
|
|
393
409
|
sessions: hydrated,
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
limit,
|
|
398
|
-
hasMore: offset + limit <
|
|
410
|
+
total: candidates.length,
|
|
411
|
+
totalActive,
|
|
412
|
+
totalNonActive: candidates.length - totalActive,
|
|
413
|
+
offset, limit,
|
|
414
|
+
hasMore: offset + limit < candidates.length,
|
|
399
415
|
};
|
|
400
416
|
}
|
|
401
417
|
|
|
@@ -442,17 +458,17 @@ function truncate(s, n) {
|
|
|
442
458
|
// cwd lives in the head of every jsonl (it's part of the per-message
|
|
443
459
|
// envelope), so 16KB is more than enough. First user text usually too;
|
|
444
460
|
// if it's beyond the head we just don't preview, that's fine.
|
|
445
|
-
async function parseJsonlHead(filepath, mtimeMs) {
|
|
461
|
+
async function parseJsonlHead(filepath, mtimeMs, headBytes = HEAD_BYTES) {
|
|
446
462
|
const cached = cacheGet(filepath, mtimeMs);
|
|
447
463
|
if (cached) return cached;
|
|
448
464
|
|
|
449
465
|
let fh;
|
|
450
466
|
try { fh = await fsp.open(filepath, 'r'); }
|
|
451
467
|
catch { return { cwd: null, summary: '' }; }
|
|
452
|
-
const buf = Buffer.allocUnsafe(
|
|
468
|
+
const buf = Buffer.allocUnsafe(headBytes);
|
|
453
469
|
let bytesRead = 0;
|
|
454
470
|
try {
|
|
455
|
-
const r = await fh.read(buf, 0,
|
|
471
|
+
const r = await fh.read(buf, 0, headBytes, 0);
|
|
456
472
|
bytesRead = r.bytesRead || 0;
|
|
457
473
|
} catch {
|
|
458
474
|
/* leave bytesRead = 0 */
|
|
@@ -468,7 +484,7 @@ async function parseJsonlHead(filepath, mtimeMs) {
|
|
|
468
484
|
const text = buf.slice(0, bytesRead).toString('utf8');
|
|
469
485
|
// Drop the trailing partial line — JSON.parse on it will fail anyway.
|
|
470
486
|
const lines = text.split('\n');
|
|
471
|
-
if (bytesRead ===
|
|
487
|
+
if (bytesRead === headBytes) lines.pop();
|
|
472
488
|
|
|
473
489
|
let cwd = null;
|
|
474
490
|
let summary = '';
|
|
@@ -477,10 +493,24 @@ async function parseJsonlHead(filepath, mtimeMs) {
|
|
|
477
493
|
if (!line) continue;
|
|
478
494
|
let obj;
|
|
479
495
|
try { obj = JSON.parse(line); } catch { continue; }
|
|
480
|
-
if (!
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
496
|
+
if (!obj) continue;
|
|
497
|
+
// cwd —
|
|
498
|
+
// claude · top-level { cwd: "..." } on every message envelope
|
|
499
|
+
// codex · session_meta line { payload: { cwd: "..." } }
|
|
500
|
+
if (!cwd) {
|
|
501
|
+
if (typeof obj.cwd === 'string') cwd = obj.cwd;
|
|
502
|
+
else if (obj.payload && typeof obj.payload.cwd === 'string') cwd = obj.payload.cwd;
|
|
503
|
+
}
|
|
504
|
+
// first user message (preview) —
|
|
505
|
+
// claude · { type:'user', message:{ content:'...' } }
|
|
506
|
+
// codex · { type:'event_msg', payload:{ type:'user_message', message:'...' } }
|
|
507
|
+
if (!summary) {
|
|
508
|
+
if (obj.type === 'user' && typeof obj.message?.content === 'string') {
|
|
509
|
+
summary = truncate(obj.message.content, SUMMARY_MAX);
|
|
510
|
+
} else if (obj.type === 'event_msg' && obj.payload?.type === 'user_message'
|
|
511
|
+
&& typeof obj.payload.message === 'string') {
|
|
512
|
+
summary = truncate(obj.payload.message, SUMMARY_MAX);
|
|
513
|
+
}
|
|
484
514
|
}
|
|
485
515
|
}
|
|
486
516
|
const v = { cwd, summary };
|
package/lib/webTerminal.js
CHANGED
|
@@ -54,7 +54,13 @@ function spawn({ command, args = [], cwd, env, cols = 120, rows = 30, meta = {},
|
|
|
54
54
|
name: 'xterm-256color',
|
|
55
55
|
cols, rows,
|
|
56
56
|
cwd: cwd ? path.resolve(cwd) : process.cwd(),
|
|
57
|
-
|
|
57
|
+
// Advertise 24-bit colour. xterm.js renders truecolor, and CLIs like
|
|
58
|
+
// claude paint their diffs with 24-bit DARK red/green backgrounds +
|
|
59
|
+
// white text when COLORTERM says so. Without it they downgrade to the
|
|
60
|
+
// bright ANSI-16 red/green backgrounds, which wash the white foreground
|
|
61
|
+
// out (the "white text gets buried" report). VSCode sets the same on its
|
|
62
|
+
// terminal PTYs; our ANSI-16 palette already matches VSCode's verbatim.
|
|
63
|
+
env: { ...process.env, ...(env || {}), COLORTERM: 'truecolor' },
|
|
58
64
|
};
|
|
59
65
|
if (process.platform === 'win32') {
|
|
60
66
|
ptyOpts.useConpty = true;
|
package/lib/winPath.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Windows user-PATH merge. A long-running ccsm server inherits whatever PATH
|
|
4
|
+
// it was launched with, which can be stale — e.g. a CLI (or a wrapper for one)
|
|
5
|
+
// installed or put on PATH AFTER the server started. We read the
|
|
6
|
+
// persisted user PATH from the registry and merge it in, then hand the
|
|
7
|
+
// deduped env to every child we spawn. server.js uses this for session
|
|
8
|
+
// launches; codexSeed uses it for the `<cli> doctor` home probe so the wrapper
|
|
9
|
+
// resolves regardless of the parent process's PATH.
|
|
10
|
+
//
|
|
11
|
+
// (server.js keeps its own equivalent inline for the hot spawn path; this
|
|
12
|
+
// module is the shared copy used by lib/ code that can't depend on server.js.)
|
|
13
|
+
|
|
14
|
+
const { spawnSync } = require('node:child_process');
|
|
15
|
+
|
|
16
|
+
let _merged = null; // cached for the process lifetime
|
|
17
|
+
|
|
18
|
+
function buildMergedUserPath() {
|
|
19
|
+
if (process.platform !== 'win32') return process.env.PATH;
|
|
20
|
+
try {
|
|
21
|
+
const r = spawnSync('reg.exe', ['query', 'HKCU\\Environment', '/v', 'PATH'],
|
|
22
|
+
{ encoding: 'utf8', windowsHide: true });
|
|
23
|
+
if (r.status !== 0 || !r.stdout) return process.env.PATH;
|
|
24
|
+
const line = r.stdout.split(/\r?\n/).find((l) => /\bPATH\b/i.test(l) && /REG_(EXPAND_)?SZ/i.test(l));
|
|
25
|
+
if (!line) return process.env.PATH;
|
|
26
|
+
const m = line.match(/REG_(?:EXPAND_)?SZ\s+(.+)$/);
|
|
27
|
+
if (!m) return process.env.PATH;
|
|
28
|
+
// REG_EXPAND_SZ keeps %VAR% literal — expand against the current env.
|
|
29
|
+
const userPath = m[1].replace(/%([^%]+)%/g, (_, name) => process.env[name] || '');
|
|
30
|
+
const existing = (process.env.PATH || '').split(';').map((s) => s.trim()).filter(Boolean);
|
|
31
|
+
const adds = userPath.split(';').map((s) => s.trim()).filter(Boolean);
|
|
32
|
+
const merged = [];
|
|
33
|
+
const seen = new Set();
|
|
34
|
+
for (const p of [...adds, ...existing]) {
|
|
35
|
+
const k = p.toLowerCase();
|
|
36
|
+
if (seen.has(k)) continue;
|
|
37
|
+
seen.add(k);
|
|
38
|
+
merged.push(p);
|
|
39
|
+
}
|
|
40
|
+
return merged.join(';');
|
|
41
|
+
} catch {
|
|
42
|
+
return process.env.PATH;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function mergedUserPath() {
|
|
47
|
+
if (_merged === null) _merged = buildMergedUserPath();
|
|
48
|
+
return _merged;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Fresh env for a child spawn: process.env + extras, every case variant of
|
|
52
|
+
// PATH stripped and replaced by the merged user PATH. Windows resolves the
|
|
53
|
+
// FIRST path-case key in the env block, so a stale inherited `Path` would
|
|
54
|
+
// otherwise shadow our `PATH`.
|
|
55
|
+
function spawnEnv(extraEnv = {}) {
|
|
56
|
+
const env = { ...process.env, ...extraEnv };
|
|
57
|
+
if (process.platform === 'win32') {
|
|
58
|
+
for (const k of Object.keys(env)) {
|
|
59
|
+
if (k.toLowerCase() === 'path') delete env[k];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const mp = mergedUserPath();
|
|
63
|
+
if (mp) env.PATH = mp;
|
|
64
|
+
return env;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { mergedUserPath, spawnEnv, buildMergedUserPath };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakapiano/ccsm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.1",
|
|
4
4
|
"description": "Claude Code Session Manager — Windows web UI to manage many concurrent claude sessions: live list, snapshot/restore, focus existing window, new session in an isolated workspace with repo clones",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "server.js",
|
|
@@ -66,6 +66,22 @@
|
|
|
66
66
|
the FAB sits over the terminal corner — leave it; the user can
|
|
67
67
|
scroll the terminal independently. */
|
|
68
68
|
|
|
69
|
+
/* Touch scrolling: drive xterm's OWN scrollable .xterm-viewport natively
|
|
70
|
+
so finger drags get real momentum and never "drop" mid-flick. A
|
|
71
|
+
JS-intercepted scroll can't: the moment Chrome's compositor decides the
|
|
72
|
+
drag is a vertical pan it cancels the touch sequence, so the handler
|
|
73
|
+
only ever sees the opening frames — the "滑一点就断触" symptom. The
|
|
74
|
+
catch is that the .xterm-screen layer (DOM rows on mobile) sits ON TOP
|
|
75
|
+
of the viewport and swallows every touch; only the thin scrollbar strip
|
|
76
|
+
at the right edge reached the viewport, which is exactly the
|
|
77
|
+
"中间滑不动、侧边能滑" report. Make the screen transparent to pointers so
|
|
78
|
+
the drag lands on the viewport underneath; pan-y marks it a vertical
|
|
79
|
+
scroller so the compositor scrolls it directly. (Tap-to-focus is then
|
|
80
|
+
re-added in JS — see TerminalView — since taps no longer reach xterm's
|
|
81
|
+
own focus path.) */
|
|
82
|
+
.app.is-mobile .terminal-host .xterm-screen { pointer-events: none; }
|
|
83
|
+
.app.is-mobile .terminal-host .xterm-viewport { touch-action: pan-y; }
|
|
84
|
+
|
|
69
85
|
/* Long inline code (URLs, paths) in the About / Remote bodies break
|
|
70
86
|
out of card edges on a narrow viewport. Let them wrap on any
|
|
71
87
|
character instead of stretching the line. */
|
package/public/css/terminals.css
CHANGED
|
@@ -125,49 +125,37 @@
|
|
|
125
125
|
min-height: 0;
|
|
126
126
|
width: 100%;
|
|
127
127
|
/* IME composition (Chinese/Japanese pinyin) lives in absolutely-positioned
|
|
128
|
-
.xterm-helper-textarea + .composition-view
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
the
|
|
132
|
-
Do NOT touch the textarea/composition-view's own
|
|
133
|
-
xterm relies on their single-line behaviour to keep IME
|
|
134
|
-
|
|
128
|
+
.xterm-helper-textarea + .composition-view at the cursor. xterm 5.5 caps
|
|
129
|
+
the view's width to the remaining columns, but as a belt-and-braces guard
|
|
130
|
+
we still clip here so any residual right-edge overflow is absorbed rather
|
|
131
|
+
than expanding the page (it used to trigger a horizontal scrollbar that
|
|
132
|
+
"pushed" the layout). Do NOT touch the textarea/composition-view's own
|
|
133
|
+
text properties — xterm relies on their single-line behaviour to keep IME
|
|
134
|
+
events firing (forcing pre-wrap / break-all eats compositionupdate events
|
|
135
135
|
in Chromium and Chinese input stops working entirely). */
|
|
136
136
|
overflow: hidden;
|
|
137
137
|
contain: layout;
|
|
138
138
|
}
|
|
139
|
-
/*
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
composed string inline. We've moved it to the right edge to stop it
|
|
150
|
-
pushing layout — but that means the composed pinyin would now visibly
|
|
151
|
-
appear on the right. Hide its glyphs (caret + text) so the user only
|
|
152
|
-
sees the OS-native IME candidate popup, which floats independently
|
|
153
|
-
and is unaffected. */
|
|
154
|
-
color: transparent !important;
|
|
155
|
-
caret-color: transparent !important;
|
|
156
|
-
background: transparent !important;
|
|
157
|
-
text-shadow: none !important;
|
|
158
|
-
}
|
|
159
|
-
/* xterm also overlays a .composition-view (a small box at the cursor with
|
|
160
|
-
the in-progress string + a gold caret using THEME.cursor). We can't
|
|
161
|
-
display:none it — Chromium needs it in the layout tree to keep the IME
|
|
162
|
-
compositionupdate events flowing — but we can make it visually invisible
|
|
163
|
-
while leaving it laid out. */
|
|
139
|
+
/* IME composition box. While you're typing pinyin — before committing the
|
|
140
|
+
Chinese characters — xterm writes the in-progress string into
|
|
141
|
+
.composition-view at the cursor. VSCode's terminal shows this box in the
|
|
142
|
+
terminal's own colours; we do the same. An earlier version hid it outright
|
|
143
|
+
(opacity:0) to dodge a layout push, which is why the typed letters were
|
|
144
|
+
invisible — but the host's overflow:hidden + contain:layout above already
|
|
145
|
+
absorb any overflow, and xterm caps the box width, so hiding it was both
|
|
146
|
+
unnecessary and the bug. The helper textarea is left exactly where xterm
|
|
147
|
+
puts it (at the cursor, opacity:0) so the OS IME candidate popup anchors
|
|
148
|
+
correctly — we no longer move or restyle it. */
|
|
164
149
|
.terminal-host .composition-view {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
150
|
+
/* !important to beat xterm.css's own `.xterm .composition-view`
|
|
151
|
+
(#000/#FFF, same specificity, loaded after us). var() still re-resolves
|
|
152
|
+
per theme, so this tracks light/dark automatically. */
|
|
153
|
+
background: var(--term-surface) !important;
|
|
154
|
+
color: var(--term-on) !important;
|
|
155
|
+
border: 1px solid var(--accent);
|
|
156
|
+
border-radius: 3px;
|
|
157
|
+
padding: 0 3px;
|
|
158
|
+
z-index: 5;
|
|
171
159
|
}
|
|
172
160
|
/* Don't override xterm's background — its renderer (canvas/WebGL) assumes
|
|
173
161
|
an opaque surface and ghosts on scroll if we force transparent. The
|