@bakapiano/ccsm 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,48 @@
1
+ 'use strict';
2
+
3
+ // Atomic JSON-file writes + per-file serialization.
4
+ //
5
+ // The naive pattern (`fs.writeFile(path, JSON.stringify(...))`) has two
6
+ // bugs under concurrent callers:
7
+ //
8
+ // 1. fs.writeFile overwrites byte-by-byte but does NOT pre-truncate.
9
+ // If writer A's serialization is longer than writer B's, and B
10
+ // finishes second, B writes only its own bytes — A's trailing
11
+ // bytes stay on disk. Result: `] }\n]` style JSON corruption.
12
+ //
13
+ // 2. Even with atomic writes, concurrent `load → mutate → save`
14
+ // sequences lose updates: A and B both read state v0, both write
15
+ // their own v1 — the later writer wins, the earlier one's edits
16
+ // vanish.
17
+ //
18
+ // Fixes:
19
+ //
20
+ // - atomicWriteJson: write to a sibling tmp file, then rename onto
21
+ // the target. rename is atomic on the same volume (NTFS / POSIX),
22
+ // so readers see either the old complete file or the new complete
23
+ // file. No truncation problem.
24
+ //
25
+ // - withFileLock: serialize all mutators of a given file through a
26
+ // per-path promise chain. Callers wrap their whole load/mutate/save
27
+ // in withFileLock(path, fn) and are guaranteed exclusivity.
28
+
29
+ const fs = require('node:fs/promises');
30
+
31
+ async function atomicWriteJson(filePath, data) {
32
+ const tmp = `${filePath}.tmp.${process.pid}.${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
33
+ await fs.writeFile(tmp, JSON.stringify(data, null, 2));
34
+ await fs.rename(tmp, filePath);
35
+ }
36
+
37
+ const locks = new Map();
38
+ function withFileLock(filePath, fn) {
39
+ const prev = locks.get(filePath) || Promise.resolve();
40
+ const next = prev.then(fn, fn);
41
+ // Swallow rejections in the chain holder so a single failed mutator
42
+ // doesn't poison every subsequent caller. The returned `next` still
43
+ // rejects for THIS caller — only the stored chain is sanitized.
44
+ locks.set(filePath, next.catch(() => {}));
45
+ return next;
46
+ }
47
+
48
+ module.exports = { atomicWriteJson, withFileLock };
package/lib/config.js CHANGED
@@ -4,6 +4,7 @@ const fs = require('node:fs/promises');
4
4
  const fsSync = require('node:fs');
5
5
  const path = require('node:path');
6
6
  const os = require('node:os');
7
+ const { atomicWriteJson, withFileLock } = require('./atomicJson');
7
8
 
8
9
  // Data dir lives under ~/.ccsm by default so config survives across upgrades
9
10
  // (incl. running from a new npx checkout). Override with CCSM_HOME if you
@@ -159,7 +160,7 @@ async function loadConfig() {
159
160
  } catch (e) {
160
161
  if (e.code === 'ENOENT') {
161
162
  const cfg = { ...DEFAULTS };
162
- await fs.writeFile(CONFIG_PATH, JSON.stringify(cfg, null, 2));
163
+ await atomicWriteJson(CONFIG_PATH, cfg);
163
164
  return cfg;
164
165
  }
165
166
  throw e;
@@ -168,10 +169,12 @@ async function loadConfig() {
168
169
 
169
170
  async function saveConfig(partial) {
170
171
  ensureDataDir();
171
- const current = await loadConfig();
172
- const next = mergeWithDefaults({ ...current, ...partial });
173
- await fs.writeFile(CONFIG_PATH, JSON.stringify(next, null, 2));
174
- return next;
172
+ return withFileLock(CONFIG_PATH, async () => {
173
+ const current = await loadConfig();
174
+ const next = mergeWithDefaults({ ...current, ...partial });
175
+ await atomicWriteJson(CONFIG_PATH, next);
176
+ return next;
177
+ });
175
178
  }
176
179
 
177
180
  module.exports = {
package/lib/folders.js CHANGED
@@ -12,6 +12,7 @@
12
12
  const path = require('node:path');
13
13
  const fs = require('node:fs/promises');
14
14
  const { DATA_DIR } = require('./config');
15
+ const { atomicWriteJson, withFileLock } = require('./atomicJson');
15
16
 
16
17
  const FILE = path.join(DATA_DIR, 'folders.json');
17
18
 
@@ -27,7 +28,7 @@ async function loadAll() {
27
28
  }
28
29
 
29
30
  async function saveAll(list) {
30
- await fs.writeFile(FILE, JSON.stringify(list, null, 2));
31
+ await atomicWriteJson(FILE, list);
31
32
  }
32
33
 
33
34
  function genId() {
@@ -36,61 +37,69 @@ function genId() {
36
37
 
37
38
  async function create({ name }) {
38
39
  if (!name || typeof name !== 'string') throw new Error('name required');
39
- const list = await loadAll();
40
- const entry = {
41
- id: genId(),
42
- name: name.trim(),
43
- order: list.length,
44
- createdAt: Date.now(),
45
- };
46
- list.push(entry);
47
- await saveAll(list);
48
- return entry;
40
+ return withFileLock(FILE, async () => {
41
+ const list = await loadAll();
42
+ const entry = {
43
+ id: genId(),
44
+ name: name.trim(),
45
+ order: list.length,
46
+ createdAt: Date.now(),
47
+ };
48
+ list.push(entry);
49
+ await saveAll(list);
50
+ return entry;
51
+ });
49
52
  }
50
53
 
51
54
  async function update(id, patch) {
52
- const list = await loadAll();
53
- const idx = list.findIndex((f) => f.id === id);
54
- if (idx < 0) return null;
55
- // Allow rename + reorder, ignore other keys.
56
- const allowed = {};
57
- if (typeof patch.name === 'string') allowed.name = patch.name.trim();
58
- if (typeof patch.order === 'number') allowed.order = patch.order;
59
- list[idx] = { ...list[idx], ...allowed };
60
- await saveAll(list);
61
- return list[idx];
55
+ return withFileLock(FILE, async () => {
56
+ const list = await loadAll();
57
+ const idx = list.findIndex((f) => f.id === id);
58
+ if (idx < 0) return null;
59
+ // Allow rename + reorder, ignore other keys.
60
+ const allowed = {};
61
+ if (typeof patch.name === 'string') allowed.name = patch.name.trim();
62
+ if (typeof patch.order === 'number') allowed.order = patch.order;
63
+ list[idx] = { ...list[idx], ...allowed };
64
+ await saveAll(list);
65
+ return list[idx];
66
+ });
62
67
  }
63
68
 
64
69
  async function remove(id) {
65
- const list = await loadAll();
66
- const idx = list.findIndex((f) => f.id === id);
67
- if (idx < 0) return false;
68
- list.splice(idx, 1);
69
- await saveAll(list);
70
- return true;
70
+ return withFileLock(FILE, async () => {
71
+ const list = await loadAll();
72
+ const idx = list.findIndex((f) => f.id === id);
73
+ if (idx < 0) return false;
74
+ list.splice(idx, 1);
75
+ await saveAll(list);
76
+ return true;
77
+ });
71
78
  }
72
79
 
73
80
  async function reorder(idsInOrder) {
74
81
  if (!Array.isArray(idsInOrder)) throw new Error('idsInOrder must be array');
75
- const list = await loadAll();
76
- const byId = new Map(list.map((f) => [f.id, f]));
77
- const next = [];
78
- idsInOrder.forEach((id, i) => {
79
- const f = byId.get(id);
80
- if (f) {
81
- f.order = i;
82
+ return withFileLock(FILE, async () => {
83
+ const list = await loadAll();
84
+ const byId = new Map(list.map((f) => [f.id, f]));
85
+ const next = [];
86
+ idsInOrder.forEach((id, i) => {
87
+ const f = byId.get(id);
88
+ if (f) {
89
+ f.order = i;
90
+ next.push(f);
91
+ byId.delete(id);
92
+ }
93
+ });
94
+ // Append any folders not mentioned in the new order, preserving original
95
+ // relative order. Prevents accidentally dropping folders.
96
+ for (const f of byId.values()) {
97
+ f.order = next.length;
82
98
  next.push(f);
83
- byId.delete(id);
84
99
  }
100
+ await saveAll(next);
101
+ return next;
85
102
  });
86
- // Append any folders not mentioned in the new order, preserving original
87
- // relative order. Prevents accidentally dropping folders.
88
- for (const f of byId.values()) {
89
- f.order = next.length;
90
- next.push(f);
91
- }
92
- await saveAll(next);
93
- return next;
94
103
  }
95
104
 
96
105
  module.exports = { loadAll, create, update, remove, reorder, FILE };
package/lib/jsonStore.js CHANGED
@@ -12,6 +12,7 @@
12
12
 
13
13
  const fs = require('node:fs/promises');
14
14
  const path = require('node:path');
15
+ const { atomicWriteJson, withFileLock } = require('./atomicJson');
15
16
 
16
17
  function createKeyedJsonStore({ dataDir, filename, transformValue = (v) => v }) {
17
18
  const filePath = path.join(dataDir, filename);
@@ -28,25 +29,29 @@ function createKeyedJsonStore({ dataDir, filename, transformValue = (v) => v })
28
29
  }
29
30
 
30
31
  async function save(map) {
31
- await fs.writeFile(filePath, JSON.stringify(map, null, 2));
32
+ await atomicWriteJson(filePath, map);
32
33
  }
33
34
 
34
35
  async function set(key, value) {
35
36
  if (!key) throw new Error('set: key required');
36
37
  const next = transformValue(value, key);
37
38
  if (next == null) return remove(key);
38
- const map = await load();
39
- map[key] = next;
40
- await save(map);
41
- return next;
39
+ return withFileLock(filePath, async () => {
40
+ const map = await load();
41
+ map[key] = next;
42
+ await save(map);
43
+ return next;
44
+ });
42
45
  }
43
46
 
44
47
  async function remove(key) {
45
- const map = await load();
46
- if (!(key in map)) return false;
47
- delete map[key];
48
- await save(map);
49
- return true;
48
+ return withFileLock(filePath, async () => {
49
+ const map = await load();
50
+ if (!(key in map)) return false;
51
+ delete map[key];
52
+ await save(map);
53
+ return true;
54
+ });
50
55
  }
51
56
 
52
57
  async function list() {
@@ -29,6 +29,7 @@
29
29
  const path = require('node:path');
30
30
  const fs = require('node:fs/promises');
31
31
  const { DATA_DIR } = require('./config');
32
+ const { atomicWriteJson, withFileLock } = require('./atomicJson');
32
33
 
33
34
  const FILE = path.join(DATA_DIR, 'sessions.json');
34
35
 
@@ -44,34 +45,37 @@ async function loadAll() {
44
45
  }
45
46
 
46
47
  async function saveAll(list) {
47
- await fs.writeFile(FILE, JSON.stringify(list, null, 2));
48
+ await atomicWriteJson(FILE, list);
48
49
  }
49
50
 
50
51
  function genId() {
51
52
  return 'sess-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
52
53
  }
53
54
 
54
- async function create({ cliId, cwd, workspace, repos = [], folderId = null, title = '', status = 'running', cliSessionId = null }) {
55
- const list = await loadAll();
56
- const entry = {
57
- id: genId(),
58
- cliId,
59
- cwd,
60
- workspace,
61
- title,
62
- folderId,
63
- repos,
64
- createdAt: Date.now(),
65
- lastActiveAt: Date.now(),
66
- status,
67
- exitedAt: status === 'exited' ? Date.now() : null,
68
- exitCode: null,
69
- pid: null,
70
- cliSessionId,
71
- };
72
- list.push(entry);
73
- await saveAll(list);
74
- return entry;
55
+ async function create(opts) {
56
+ return withFileLock(FILE, async () => {
57
+ const { cliId, cwd, workspace, repos = [], folderId = null, title = '', status = 'running', cliSessionId = null } = opts;
58
+ const list = await loadAll();
59
+ const entry = {
60
+ id: genId(),
61
+ cliId,
62
+ cwd,
63
+ workspace,
64
+ title,
65
+ folderId,
66
+ repos,
67
+ createdAt: Date.now(),
68
+ lastActiveAt: Date.now(),
69
+ status,
70
+ exitedAt: status === 'exited' ? Date.now() : null,
71
+ exitCode: null,
72
+ pid: null,
73
+ cliSessionId,
74
+ };
75
+ list.push(entry);
76
+ await saveAll(list);
77
+ return entry;
78
+ });
75
79
  }
76
80
 
77
81
  async function get(id) {
@@ -80,21 +84,25 @@ async function get(id) {
80
84
  }
81
85
 
82
86
  async function update(id, patch) {
83
- const list = await loadAll();
84
- const idx = list.findIndex((s) => s.id === id);
85
- if (idx < 0) return null;
86
- list[idx] = { ...list[idx], ...patch };
87
- await saveAll(list);
88
- return list[idx];
87
+ return withFileLock(FILE, async () => {
88
+ const list = await loadAll();
89
+ const idx = list.findIndex((s) => s.id === id);
90
+ if (idx < 0) return null;
91
+ list[idx] = { ...list[idx], ...patch };
92
+ await saveAll(list);
93
+ return list[idx];
94
+ });
89
95
  }
90
96
 
91
97
  async function remove(id) {
92
- const list = await loadAll();
93
- const idx = list.findIndex((s) => s.id === id);
94
- if (idx < 0) return false;
95
- list.splice(idx, 1);
96
- await saveAll(list);
97
- return true;
98
+ return withFileLock(FILE, async () => {
99
+ const list = await loadAll();
100
+ const idx = list.findIndex((s) => s.id === id);
101
+ if (idx < 0) return false;
102
+ list.splice(idx, 1);
103
+ await saveAll(list);
104
+ return true;
105
+ });
98
106
  }
99
107
 
100
108
  // Convenience helpers used at runtime so callers don't have to do
@@ -84,6 +84,12 @@ function spawn({ command, args = [], cwd, env, cols = 120, rows = 30, meta = {},
84
84
  if (entry.onDataExtra) { try { entry.onDataExtra(data); } catch {} }
85
85
  });
86
86
  proc.onExit(({ exitCode, signal }) => {
87
+ // If a respawn replaced us in the pool (same entryId, new entry
88
+ // object), do not touch persistedSessions or schedule a delete —
89
+ // those belong to the new entry now. Without this guard, a slow-
90
+ // dying old PTY would fire markExited on the same sessionId and
91
+ // clobber the new spawn's markRunning state.
92
+ if (sessions.get(entryId) !== entry) return;
87
93
  entry.exitCode = exitCode;
88
94
  entry.exitedAt = Date.now();
89
95
  const frame = JSON.stringify({ type: 'exit', code: exitCode, signal });
@@ -91,8 +97,18 @@ function spawn({ command, args = [], cwd, env, cols = 120, rows = 30, meta = {},
91
97
  try { ws.send(frame); } catch {}
92
98
  }
93
99
  if (entry.onExitExtra) { try { entry.onExitExtra({ exitCode, signal }); } catch {} }
94
- setTimeout(() => sessions.delete(entryId), 30_000);
100
+ setTimeout(() => {
101
+ if (sessions.get(entryId) === entry) sessions.delete(entryId);
102
+ }, 30_000);
95
103
  });
104
+ // If a previous entry exists under the same id (respawn), kill its
105
+ // pty so we don't have zombie claude.exe processes hanging on. The
106
+ // onExit guard above ensures its callback no-ops once we've taken
107
+ // over the slot.
108
+ const prev = sessions.get(entryId);
109
+ if (prev && !prev.exitedAt) {
110
+ try { prev.pty.kill(); } catch {}
111
+ }
96
112
  sessions.set(entryId, entry);
97
113
  return entry;
98
114
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakapiano/ccsm",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
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",
@@ -18,7 +18,7 @@
18
18
  ],
19
19
  "scripts": {
20
20
  "start": "node server.js",
21
- "dev": "node --watch server.js",
21
+ "dev": "node scripts/dev.js",
22
22
  "postinstall": "node scripts/install.js",
23
23
  "preuninstall": "node scripts/uninstall.js"
24
24
  },
@@ -405,10 +405,12 @@ body.is-resizing-sidebar * {
405
405
  color: var(--ink);
406
406
  display: inline-flex;
407
407
  align-items: center;
408
- opacity: 0.6;
408
+ opacity: 0;
409
409
  transition: opacity .12s, background .12s;
410
410
  }
411
- .tree-head-action:hover { opacity: 1; background: var(--sidebar-hover); }
411
+ .tree-head:hover .tree-head-action,
412
+ .tree-head-action:focus-visible { opacity: 0.7; }
413
+ .tree-head:hover .tree-head-action:hover { opacity: 1; background: var(--sidebar-hover); }
412
414
  .tree-head-action svg { width: 14px; height: 14px; }
413
415
 
414
416
  /* Folder grouping. Chevron rotates on expand. */
@@ -419,6 +421,17 @@ body.is-resizing-sidebar * {
419
421
  .tree-folder[data-dnd-over="true"] > .tree-folder-head {
420
422
  box-shadow: 0 -2px 0 var(--accent) inset;
421
423
  }
424
+ /* Session being dragged → folder is a drop target. Tint the folder
425
+ head + outline the whole folder so the user knows where it'll land,
426
+ independent of whether the folder is expanded or collapsed. */
427
+ .tree-folder.is-session-drop-target {
428
+ border-radius: 6px;
429
+ outline: 1px dashed var(--ink-mid);
430
+ outline-offset: -2px;
431
+ background: var(--sidebar-hover);
432
+ }
433
+ .tree-session[draggable="true"] { cursor: pointer; }
434
+ .tree-session[draggable="true"]:active { cursor: grabbing; }
422
435
  .tree-folder-head[draggable="true"] {
423
436
  cursor: grab;
424
437
  }
@@ -532,6 +545,11 @@ body.is-resizing-sidebar * {
532
545
  background: var(--sidebar-active);
533
546
  font-weight: 500;
534
547
  }
548
+ /* Status dot · deliberately understated. The earlier version had a
549
+ green dot + soft glow + expanding halo pulse; in a sidebar with
550
+ eight running sessions it read as a row of strobing alerts. Now:
551
+ one 5px dot, no halo, no shadow, no animation. Color alone carries
552
+ running vs stopped. */
535
553
  .tree-dot {
536
554
  width: 14px;
537
555
  height: 14px;
@@ -539,7 +557,6 @@ body.is-resizing-sidebar * {
539
557
  display: inline-flex;
540
558
  align-items: center;
541
559
  justify-content: center;
542
- position: relative;
543
560
  }
544
561
  .tree-dot::after {
545
562
  content: "";
@@ -551,31 +568,6 @@ body.is-resizing-sidebar * {
551
568
  }
552
569
  .tree-session.is-running .tree-dot::after {
553
570
  background: var(--green);
554
- box-shadow: 0 0 0 3px rgba(74, 138, 74, 0.18);
555
- }
556
- /* Pulse halo for running sessions — implemented on .tree-dot (the
557
- wrapper) via a second pseudo-element animating opacity + transform,
558
- not box-shadow. opacity is composited; box-shadow forces paint every
559
- frame, and with N running sessions in the sidebar that adds up. */
560
- .tree-session.is-running .tree-dot::before {
561
- content: "";
562
- position: absolute;
563
- left: 50%; top: 50%;
564
- width: 7px; height: 7px;
565
- margin: -3.5px 0 0 -3.5px;
566
- border-radius: 50%;
567
- background: var(--green);
568
- opacity: 0.45;
569
- animation: tree-dot-pulse 1.8s ease-in-out infinite;
570
- pointer-events: none;
571
- }
572
- .tree-session.is-stopped .tree-dot::after {
573
- background: var(--ink-faint);
574
- box-shadow: none;
575
- }
576
- @keyframes tree-dot-pulse {
577
- 0%, 100% { opacity: 0.45; transform: scale(1); }
578
- 50% { opacity: 0; transform: scale(2.4); }
579
571
  }
580
572
  .tree-label {
581
573
  flex: 1;
@@ -980,6 +980,38 @@
980
980
  gap: 6px;
981
981
  margin-top: 4px;
982
982
  }
983
+ .entity-test-button { margin-right: auto; }
984
+
985
+ .entity-test-result {
986
+ margin-top: 4px;
987
+ padding: 8px 10px;
988
+ border-radius: 6px;
989
+ border: 1px solid var(--border);
990
+ background: var(--bg);
991
+ display: flex;
992
+ flex-direction: column;
993
+ gap: 6px;
994
+ font-size: 12.5px;
995
+ }
996
+ .entity-test-result.is-ok { border-color: rgba(74, 138, 74, 0.4); background: rgba(74, 138, 74, 0.06); }
997
+ .entity-test-result.is-fail { border-color: rgba(183, 63, 63, 0.4); background: rgba(183, 63, 63, 0.06); }
998
+ .entity-test-summary {
999
+ font-family: var(--body);
1000
+ font-weight: 500;
1001
+ color: var(--ink);
1002
+ }
1003
+ .entity-test-result.is-fail .entity-test-summary { color: var(--red); }
1004
+ .entity-test-out {
1005
+ margin: 0;
1006
+ font-family: var(--mono);
1007
+ font-size: 11.5px;
1008
+ color: var(--ink-mid);
1009
+ white-space: pre-wrap;
1010
+ word-break: break-word;
1011
+ max-height: 120px;
1012
+ overflow-y: auto;
1013
+ }
1014
+ .entity-test-out.is-stderr { color: var(--ink-muted); border-top: 1px dashed var(--border); padding-top: 4px; }
983
1015
 
984
1016
  .icon-radio {
985
1017
  display: grid;
package/public/js/api.js CHANGED
@@ -28,11 +28,11 @@ export async function loadConfig() {
28
28
  export async function updateCli(id, patch) {
29
29
  const cfg = S.config.value || (await api('GET', '/api/config'));
30
30
  const target = (cfg.clis || []).find((c) => c.id === id);
31
- // Built-in CLIs lock down identity-defining fields. UI already greys these
32
- // out; we belt-and-braces here so a tampered request from elsewhere
33
- // can't change them either.
31
+ // Built-in CLIs lock down structural fields (id + builtin flag) but
32
+ // allow command edits users routinely need to point at an absolute
33
+ // path (e.g. C:\Users\you\.local\bin\claude.exe) or a wrapper script
34
+ // when the bare name isn't on the spawn-time PATH.
34
35
  if (target?.builtin) {
35
- delete patch.command;
36
36
  delete patch.id;
37
37
  delete patch.builtin;
38
38
  }
@@ -52,6 +52,14 @@ export async function updateCli(id, patch) {
52
52
  return id;
53
53
  }
54
54
 
55
+ // Probe a (possibly-unsaved) CLI config: spawn its command with
56
+ // `--version`, capture output, see if it looks like the claimed type.
57
+ // `args` is intentionally ignored server-side — runtime flags can
58
+ // disturb a quick probe.
59
+ export async function testCli({ command, shell, type }) {
60
+ return api('POST', '/api/clis/test', { command, shell, type });
61
+ }
62
+
55
63
  export async function deleteCli(id) {
56
64
  const cfg = S.config.value || (await api('GET', '/api/config'));
57
65
  const target = (cfg.clis || []).find((c) => c.id === id);
@@ -18,10 +18,13 @@ import { Modal } from './Modal.js';
18
18
  export function EntityFormModal({
19
19
  title, fields, initial = {}, submitLabel = 'Save',
20
20
  readOnlyKeys = [],
21
- onSubmit, onClose, danger,
21
+ onSubmit, onClose, onTest, testLabel = 'Test',
22
+ danger,
22
23
  }) {
23
24
  const [draft, setDraft] = useState(() => ({ ...initialFrom(fields), ...initial }));
24
25
  const [saving, setSaving] = useState(false);
26
+ const [testing, setTesting] = useState(false);
27
+ const [testResult, setTestResult] = useState(null);
25
28
 
26
29
  const isReadOnly = (key) => readOnlyKeys.includes(key);
27
30
 
@@ -36,6 +39,20 @@ export function EntityFormModal({
36
39
  finally { setSaving(false); }
37
40
  };
38
41
 
42
+ const runTest = async () => {
43
+ if (!onTest) return;
44
+ setTesting(true);
45
+ setTestResult(null);
46
+ try {
47
+ const r = await onTest(draft);
48
+ setTestResult(r);
49
+ } catch (e) {
50
+ setTestResult({ ok: false, spawnError: String(e?.message || e) });
51
+ } finally {
52
+ setTesting(false);
53
+ }
54
+ };
55
+
39
56
  return html`
40
57
  <${Modal} title=${title} onClose=${onClose} width=${440}>
41
58
  <form class="entity-form" onSubmit=${submit}>
@@ -87,7 +104,26 @@ export function EntityFormModal({
87
104
  ${f.hint && f.type !== 'checkbox' ? html`
88
105
  <span class="entity-field-hint">${f.hint}</span>` : null}
89
106
  </label>`)}
107
+ ${testResult ? html`
108
+ <div class=${`entity-test-result ${testResult.ok ? 'is-ok' : 'is-fail'}`}>
109
+ <div class="entity-test-summary">
110
+ ${testResult.ok ? '✓' : '✗'} ${testResult.ok ? 'works' : 'failed'}
111
+ ${typeof testResult.exitCode === 'number' ? html` · exit ${testResult.exitCode}` : null}
112
+ ${typeof testResult.durationMs === 'number' ? html` · ${testResult.durationMs}ms` : null}
113
+ ${testResult.timedOut ? html` · timed out` : null}
114
+ ${testResult.matchedType === true ? html` · type matches ${testResult.expectedType}` : null}
115
+ ${testResult.matchedType === false ? html` · type mismatch (expected ${testResult.expectedType})` : null}
116
+ </div>
117
+ ${testResult.spawnError ? html`<pre class="entity-test-out">${testResult.spawnError}</pre>` : null}
118
+ ${testResult.stdout ? html`<pre class="entity-test-out">${testResult.stdout}</pre>` : null}
119
+ ${testResult.stderr ? html`<pre class="entity-test-out is-stderr">${testResult.stderr}</pre>` : null}
120
+ </div>` : null}
90
121
  <div class="entity-form-actions">
122
+ ${onTest ? html`
123
+ <button type="button" class="action small subtle entity-test-button"
124
+ disabled=${testing} onClick=${runTest}>
125
+ ${testing ? 'Testing…' : testLabel}
126
+ </button>` : null}
91
127
  <button type="button" class="action small subtle" onClick=${onClose}>Cancel</button>
92
128
  <button type="submit" class=${`action small ${danger ? 'danger' : 'primary'}`}
93
129
  disabled=${saving}>
@@ -1,6 +1,7 @@
1
1
  import { html } from '../html.js';
2
+ import { signal } from '@preact/signals';
2
3
  import {
3
- activeTab, sidebarCollapsed, sidebarForcedCollapsed, configDirty, capabilities, serverHealth,
4
+ activeTab, sidebarCollapsed, sidebarForcedCollapsed, configDirty, capabilities,
4
5
  sessions, folders, sessionsByFolder, foldersCollapsed, activeSessionId,
5
6
  selectTab, selectSession, toggleSidebar, toggleFolder, setSidebarWidth,
6
7
  } from '../state.js';
@@ -12,9 +13,18 @@ import { clockTick } from '../state.js';
12
13
  import { useDragSort } from './useDragSort.js';
13
14
  import {
14
15
  IconLaunch, IconConfigure,
15
- IconSidebarToggle, IconPencil, IconClose, IconFolder, IconFolderOpen, BrandMark,
16
+ IconSidebarToggle, IconPencil, IconClose, IconFolder, IconFolderOpen, IconPlus, BrandMark,
16
17
  } from '../icons.js';
17
18
 
19
+ // Module-level drag state for session → folder moves. Lives outside the
20
+ // useDragSort hook (which handles same-list folder reorder) so the two
21
+ // don't interfere — session drags and folder drags use disjoint state.
22
+ // Folder key: folder.id for real folders, the literal string 'unsorted'
23
+ // for the implicit top-level Unsorted bucket.
24
+ const draggingSessionId = signal(null);
25
+ const dragOverFolderKey = signal(null);
26
+ const folderKey = (folder) => folder ? folder.id : 'unsorted';
27
+
18
28
  function NavItem({ tab, icon, label, dirty }) {
19
29
  const selected = activeTab.value === tab;
20
30
  return html`
@@ -46,47 +56,6 @@ function SessionRow({ s }) {
46
56
  }
47
57
  };
48
58
 
49
- const onContext = async (ev) => {
50
- ev.preventDefault();
51
- ev.stopPropagation();
52
- // Quick menu: Rename / Move / Delete. We use sequential prompts
53
- // to avoid building a real context-menu component for now.
54
- const action = await ccsmPrompt(
55
- `${title} · ${running ? 'running' : 'stopped'}\nType: rename / move / delete / resume / cancel`,
56
- 'cancel', { title: s.id, okLabel: 'OK' });
57
- if (!action) return;
58
- const verb = action.trim().toLowerCase();
59
- if (verb === 'rename') {
60
- const next = await ccsmPrompt('New title', title, { title: 'Rename session', okLabel: 'Save' });
61
- if (next === null) return;
62
- try { await setSessionTitle(s.id, next.trim()); setToast('renamed'); }
63
- catch (e) { setToast(e.message, 'error'); }
64
- } else if (verb === 'move') {
65
- // Move to a folder by name
66
- const folderNames = folders.value.map((f) => f.name).join(', ');
67
- const target = await ccsmPrompt(
68
- `Move to which folder? (empty = Unsorted)\nExisting: ${folderNames || '(none)'}`,
69
- '', { title: 'Move', okLabel: 'Move' });
70
- if (target === null) return;
71
- const t = target.trim();
72
- const folder = t ? folders.value.find((f) => f.name.toLowerCase() === t.toLowerCase()) : null;
73
- if (t && !folder) { setToast(`no folder named "${t}"`, 'error'); return; }
74
- try { await setSessionFolder(s.id, folder ? folder.id : null); setToast('moved'); }
75
- catch (e) { setToast(e.message, 'error'); }
76
- } else if (verb === 'delete') {
77
- const ok = await ccsmConfirm(`Delete session ${title}? PTY will be killed if alive.`, {
78
- title: 'Delete session', okLabel: 'Delete', danger: true });
79
- if (!ok) return;
80
- try {
81
- await deleteSession(s.id);
82
- if (activeSessionId.value === s.id) activeSessionId.value = null;
83
- } catch (e) { setToast(e.message, 'error'); }
84
- } else if (verb === 'resume' && !running) {
85
- try { await resumeSession(s.id); selectSession(s.id); }
86
- catch (e) { setToast(e.message, 'error'); }
87
- }
88
- };
89
-
90
59
  const onRenameClick = async (ev) => {
91
60
  ev.preventDefault();
92
61
  ev.stopPropagation();
@@ -108,10 +77,23 @@ function SessionRow({ s }) {
108
77
  } catch (e) { setToast(e.message, 'error'); }
109
78
  };
110
79
 
80
+ const onDragStart = (ev) => {
81
+ draggingSessionId.value = s.id;
82
+ ev.dataTransfer.effectAllowed = 'move';
83
+ // Firefox refuses to start a drag without some data set.
84
+ try { ev.dataTransfer.setData('text/plain', s.id); } catch {}
85
+ };
86
+ const onDragEnd = () => {
87
+ draggingSessionId.value = null;
88
+ dragOverFolderKey.value = null;
89
+ };
90
+
111
91
  return html`
112
92
  <div class=${`tree-session${isActive ? ' is-active' : ''}${running ? ' is-running' : ' is-stopped'}`}
93
+ draggable=${true}
94
+ onDragStart=${onDragStart}
95
+ onDragEnd=${onDragEnd}
113
96
  onClick=${onClick}
114
- onContextMenu=${onContext}
115
97
  title=${`${title}\n${s.cwd}\n${running ? 'running' : 'stopped'} · ${s.cliId}`}>
116
98
  <span class=${`tree-dot ${running ? 'is-running' : 'is-stopped'}`}></span>
117
99
  <span class="tree-label">${title}</span>
@@ -124,7 +106,7 @@ function SessionRow({ s }) {
124
106
  }
125
107
 
126
108
  function FolderGroup({ folder, sessionList, dndHandle, dndRow }) {
127
- const key = folder ? folder.id : 'unsorted';
109
+ const key = folderKey(folder);
128
110
  const collapsed = !!foldersCollapsed.value[key];
129
111
  const name = folder ? folder.name : 'Unsorted';
130
112
  const onToggle = () => toggleFolder(folder ? folder.id : null);
@@ -148,8 +130,55 @@ function FolderGroup({ folder, sessionList, dndHandle, dndRow }) {
148
130
  catch (e) { setToast(e.message, 'error'); }
149
131
  };
150
132
 
133
+ // Session-into-folder drop target. We don't go through useDragSort
134
+ // because that one is wired for folder-reorder. Folder reorder's
135
+ // handlers (in dndRow) short-circuit when no folder is being dragged,
136
+ // and our handlers below short-circuit when no session is being
137
+ // dragged — so composing both is safe.
138
+ const draggedSession = draggingSessionId.value
139
+ ? sessions.value.find((s) => s.id === draggingSessionId.value)
140
+ : null;
141
+ const sameFolder = draggedSession
142
+ && (draggedSession.folderId || null) === (folder ? folder.id : null);
143
+ const isOver = !sameFolder && dragOverFolderKey.value === key;
144
+
145
+ const onSessionDragOver = (ev) => {
146
+ if (!draggingSessionId.value || sameFolder) return;
147
+ ev.preventDefault();
148
+ ev.dataTransfer.dropEffect = 'move';
149
+ if (dragOverFolderKey.value !== key) dragOverFolderKey.value = key;
150
+ };
151
+ const onSessionDragLeave = (ev) => {
152
+ if (!draggingSessionId.value) return;
153
+ const rt = ev.relatedTarget;
154
+ if (rt && ev.currentTarget.contains(rt)) return;
155
+ if (dragOverFolderKey.value === key) dragOverFolderKey.value = null;
156
+ };
157
+ const onSessionDrop = (ev) => {
158
+ const sid = draggingSessionId.value;
159
+ draggingSessionId.value = null;
160
+ dragOverFolderKey.value = null;
161
+ if (!sid || sameFolder) return;
162
+ ev.preventDefault();
163
+ ev.stopPropagation();
164
+ setSessionFolder(sid, folder ? folder.id : null)
165
+ .then(() => setToast(folder ? `moved to ${folder.name}` : 'moved to Unsorted'))
166
+ .catch((e) => setToast(e.message, 'error'));
167
+ };
168
+
169
+ // Spread folder-reorder row handlers first, then compose our
170
+ // session-drop handlers on top so both fire.
171
+ const { onDragOver: rowOver, onDragLeave: rowLeave, onDrop: rowDrop, ...rowAttrs } = dndRow || {};
172
+ const composedOver = (ev) => { onSessionDragOver(ev); rowOver?.(ev); };
173
+ const composedLeave = (ev) => { onSessionDragLeave(ev); rowLeave?.(ev); };
174
+ const composedDrop = (ev) => { onSessionDrop(ev); rowDrop?.(ev); };
175
+
151
176
  return html`
152
- <div class="tree-folder" ...${dndRow || {}}>
177
+ <div class=${`tree-folder${isOver ? ' is-session-drop-target' : ''}`}
178
+ ...${rowAttrs}
179
+ onDragOver=${composedOver}
180
+ onDragLeave=${composedLeave}
181
+ onDrop=${composedDrop}>
153
182
  <button class=${`tree-folder-head${collapsed ? '' : ' is-open'}`} onClick=${onToggle}
154
183
  ...${dndHandle || {}}>
155
184
  <span class="tree-folder-icon">
@@ -194,6 +223,9 @@ function SessionTree() {
194
223
  <div class="tree">
195
224
  <div class="tree-head">
196
225
  <span class="tree-head-label">Sessions</span>
226
+ <button class="tree-head-action" title="New folder" onClick=${onNewFolder}>
227
+ <${IconPlus} />
228
+ </button>
197
229
  </div>
198
230
  ${orderedFolders.map((f) => html`
199
231
  <${FolderGroup} key=${f.id} folder=${f}
@@ -236,9 +268,6 @@ export function Sidebar() {
236
268
  onClick=${() => selectTab('about')}>
237
269
  <span class="brand-mark"><${BrandMark} /></span>
238
270
  <span class="brand-name">CCSM<span class="brand-dot">.</span></span>
239
- ${serverHealth.value.version ? html`
240
- <span class="brand-version">v${serverHealth.value.version}</span>
241
- ` : null}
242
271
  </button>
243
272
  </div>
244
273
 
@@ -86,6 +86,22 @@ export function TerminalView({ terminalId }) {
86
86
  } catch (e) {
87
87
  console.warn('[ccsm] WebGL addon failed, using DOM renderer:', e);
88
88
  }
89
+ // Ctrl+C with a selection: by default xterm.js sends \x03 AND the
90
+ // browser's own copy event fires — so the user gets "selection
91
+ // copied to clipboard" AND the running CLI gets SIGINT. Mirror
92
+ // VSCode/Windows Terminal behaviour: when there's a selection,
93
+ // suppress \x03 and let the copy event do its thing. With no
94
+ // selection, Ctrl+C still sends \x03 normally.
95
+ term.attachCustomKeyEventHandler((ev) => {
96
+ if (ev.type === 'keydown'
97
+ && ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey
98
+ && ev.key.toLowerCase() === 'c'
99
+ && term.hasSelection()) {
100
+ return false;
101
+ }
102
+ return true;
103
+ });
104
+
89
105
  const host = hostRef.current;
90
106
  term.open(host);
91
107
  // Defer fit one tick so the container has measured layout
package/public/js/main.js CHANGED
@@ -3,32 +3,24 @@
3
3
  // the mount root.
4
4
 
5
5
  import { render } from 'preact';
6
- import { effect } from '@preact/signals';
7
6
  import { html } from './html.js';
8
- import { loadPersisted, clockTick, lastRefreshAt, installPrompt, isInstalledPwa, sidebarForcedCollapsed, serverHealth } from './state.js';
7
+ import { loadPersisted, clockTick, lastRefreshAt, installPrompt, isInstalledPwa, sidebarForcedCollapsed } from './state.js';
9
8
  import { httpBase } from './backend.js';
10
9
  import { loadConfig, refreshAll, loadSessions, loadFolders, loadWorkspaces, pollHealth } from './api.js';
11
10
  import { setToast } from './toast.js';
12
11
  import { App } from './components/App.js';
13
12
 
14
13
  loadPersisted();
15
- // Window/tab title tracks the live backend version "CCSM v0.10.1" once
16
- // /api/health responds, "CCSM" before then. A MutationObserver guards
17
- // against Chromium standalone builds that occasionally try to inject the
18
- // URL into the title bar; it accepts any "CCSM..." string we set and
19
- // resets anything else.
20
- function desiredTitle() {
21
- const v = serverHealth.value.version;
22
- return v ? `CCSM v${v}` : 'CCSM';
23
- }
24
- let expected = desiredTitle();
14
+ // Window/tab title pinned to "CCSM". A MutationObserver guards against
15
+ // Chromium standalone builds that occasionally try to inject the URL
16
+ // into the title bar.
17
+ const expected = 'CCSM';
25
18
  function lockTitle() { if (document.title !== expected) document.title = expected; }
26
19
  lockTitle();
27
20
  new MutationObserver(lockTitle).observe(
28
21
  document.querySelector('title') || document.head,
29
22
  { childList: true, subtree: true, characterData: true }
30
23
  );
31
- effect(() => { expected = desiredTitle(); lockTitle(); });
32
24
  render(html`<${App} />`, document.getElementById('app'));
33
25
 
34
26
  // PWA install affordance — Chromium fires `beforeinstallprompt` when the
@@ -10,7 +10,7 @@ import {
10
10
  } from '../state.js';
11
11
  import {
12
12
  api, loadConfig, loadWorkspaces, loadFolders,
13
- createCli, updateCli, deleteCli, setDefaultCli,
13
+ createCli, updateCli, deleteCli, setDefaultCli, testCli,
14
14
  createRepo, updateRepo, deleteRepo,
15
15
  createFolder, renameFolder, deleteFolder, reorderFolders,
16
16
  deleteWorkspace,
@@ -252,6 +252,7 @@ export function ConfigurePage() {
252
252
  ${edit?.kind === 'cli-new' ? html`
253
253
  <${EntityFormModal} title="New CLI" fields=${cliFieldsFor({ creating: true })}
254
254
  onClose=${close} submitLabel="Create"
255
+ onTest=${(v) => testCli({ command: v.command, shell: v.shell, type: v.type })}
255
256
  onSubmit=${async (v) => {
256
257
  try { await createCli(v); setToast(`created CLI · ${v.name}`); }
257
258
  catch (e) { setToast(e.message, 'error'); throw e; }
@@ -259,7 +260,7 @@ export function ConfigurePage() {
259
260
 
260
261
  ${edit?.kind === 'cli-edit' ? html`
261
262
  <${EntityFormModal} title=${`Edit ${edit.payload.name}`} fields=${cliFieldsFor()}
262
- readOnlyKeys=${edit.payload.builtin ? ['type', 'command'] : []}
263
+ readOnlyKeys=${edit.payload.builtin ? ['type'] : []}
263
264
  initial=${{
264
265
  ...edit.payload,
265
266
  args: (edit.payload.args || []).join(' '),
@@ -267,6 +268,7 @@ export function ConfigurePage() {
267
268
  resumeIdArgs: (edit.payload.resumeIdArgs || []).join(' '),
268
269
  }}
269
270
  onClose=${close}
271
+ onTest=${(v) => testCli({ command: v.command, shell: v.shell, type: v.type })}
270
272
  onSubmit=${async (v) => {
271
273
  try {
272
274
  const patch = {
@@ -275,8 +277,6 @@ export function ConfigurePage() {
275
277
  resumeArgs: typeof v.resumeArgs === 'string' ? v.resumeArgs.split(/\s+/).filter(Boolean) : v.resumeArgs,
276
278
  resumeIdArgs: typeof v.resumeIdArgs === 'string' ? v.resumeIdArgs.split(/\s+/).filter(Boolean) : v.resumeIdArgs,
277
279
  };
278
- // command is locked on builtins — drop any tampered value.
279
- if (edit.payload.builtin) delete patch.command;
280
280
  await updateCli(edit.payload.id, patch);
281
281
  setToast('saved');
282
282
  } catch (e) { setToast(e.message, 'error'); throw e; }
package/scripts/dev.js ADDED
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // Dev launcher · fully isolates from the user's prod ccsm install.
5
+ //
6
+ // Why: many contributors run the published `@bakapiano/ccsm` package
7
+ // for their day-to-day work (port 7777, ~/.ccsm). If `npm run dev`
8
+ // reused the same data dir + port, every hot-reload would clobber the
9
+ // live sessions.json. So dev gets its own:
10
+ //
11
+ // - CCSM_HOME → ~/.ccsm-dev/ (separate config.json, sessions.json, folders.json)
12
+ // - port → 7788 (no contention with prod 7777)
13
+ // - workDir → ~/ccsm-workspaces-dev (separate workspace tree)
14
+ // - no browser auto-open (we're iterating in an already-open tab)
15
+ //
16
+ // Run via `npm run dev`. The first launch seeds a starter config; later
17
+ // launches leave it alone so dev's own customisations stick.
18
+
19
+ const path = require('node:path');
20
+ const os = require('node:os');
21
+ const fs = require('node:fs');
22
+ const { spawn } = require('node:child_process');
23
+
24
+ const DEV_HOME = path.join(os.homedir(), '.ccsm-dev');
25
+ const DEV_PORT = '7788';
26
+ const DEV_WORKDIR = path.join(os.homedir(), 'ccsm-workspaces-dev');
27
+
28
+ fs.mkdirSync(DEV_HOME, { recursive: true });
29
+
30
+ // Seed a fresh dev config the first time. Subsequent runs leave the
31
+ // existing file alone — the dev's own UI edits persist across restarts.
32
+ const configPath = path.join(DEV_HOME, 'config.json');
33
+ if (!fs.existsSync(configPath)) {
34
+ fs.writeFileSync(configPath, JSON.stringify({
35
+ port: Number(DEV_PORT),
36
+ workDir: DEV_WORKDIR,
37
+ repos: [],
38
+ }, null, 2));
39
+ }
40
+
41
+ const env = {
42
+ ...process.env,
43
+ CCSM_HOME: DEV_HOME,
44
+ CCSM_PORT: DEV_PORT,
45
+ CCSM_NO_BROWSER: '1',
46
+ };
47
+
48
+ const serverPath = path.join(__dirname, '..', 'server.js');
49
+ const child = spawn(process.execPath, ['--watch', serverPath], {
50
+ env,
51
+ stdio: 'inherit',
52
+ });
53
+
54
+ const forward = (sig) => () => child.kill(sig);
55
+ process.on('SIGINT', forward('SIGINT'));
56
+ process.on('SIGTERM', forward('SIGTERM'));
57
+ child.on('exit', (code, signal) => {
58
+ process.exit(signal ? 1 : (code ?? 0));
59
+ });
package/server.js CHANGED
@@ -203,8 +203,9 @@ function spawnCliSession({ cli, cwd, sessionId, meta, extraArgs = [] }) {
203
203
  ? prefixArgs
204
204
  : [...prefixArgs, ...(cli.args || []), ...extraArgs];
205
205
  // Merge user-scope PATH from registry into the env we hand the PTY.
206
- const env = { ...process.env, ...(cli.env || {}) };
207
- if (mergedUserPath) env.PATH = mergedUserPath;
206
+ // spawnEnv() also strips duplicate path-case keys so our override
207
+ // doesn't get shadowed by the inherited `Path` from process.env.
208
+ const env = spawnEnv(cli.env);
208
209
  const trySpawn = (executable) => webTerminal.spawn({
209
210
  id: sessionId,
210
211
  command: executable,
@@ -328,6 +329,26 @@ function buildMergedUserPath() {
328
329
  }
329
330
  mergedUserPath = buildMergedUserPath();
330
331
 
332
+ // Hand back a fresh env for spawning a child, with PATH overridden by
333
+ // our merged user PATH and any duplicate case variants of "path"
334
+ // stripped first. Windows env lookup is case-insensitive but the env
335
+ // block we hand CreateProcess is an ordered byte buffer — if both
336
+ // `Path` (inherited from process.env, OS canonical case) and `PATH`
337
+ // (our override) are present, Windows resolves to whichever comes
338
+ // first in the block. Node's Object.keys preserves insertion order,
339
+ // so the inherited `Path` would win and our merged override silently
340
+ // disappear. Strip all path-shaped keys first, then add the merge.
341
+ function spawnEnv(extraEnv = {}) {
342
+ const env = { ...process.env, ...extraEnv };
343
+ if (process.platform === 'win32') {
344
+ for (const k of Object.keys(env)) {
345
+ if (k.toLowerCase() === 'path') delete env[k];
346
+ }
347
+ }
348
+ if (mergedUserPath) env.PATH = mergedUserPath;
349
+ return env;
350
+ }
351
+
331
352
  // ---- config ----
332
353
 
333
354
  // Per-CLI install probe. Looks up the command on PATH using `where` (win)
@@ -372,7 +393,92 @@ app.put('/api/config', asyncH(async (req, res) => {
372
393
  res.json(decorateConfigWithProbes(cfg));
373
394
  }));
374
395
 
375
- // ---- CLIs ----
396
+ // ---- CLI probe / test ----
397
+ //
398
+ // Run the user's configured command with `--version` and report back
399
+ // stdout/stderr + whether the output looks like the claimed CLI type.
400
+ // Used by the Configure page "Test" button so the user can verify the
401
+ // command resolves + actually launches the right tool BEFORE saving.
402
+ // Body: { command, args?, shell?, type? }. args is ignored for the
403
+ // version probe — we always append `--version` directly so the user's
404
+ // runtime args (e.g. --dangerously-skip-permissions) don't perturb the
405
+ // quick probe.
406
+ app.post('/api/clis/test', asyncH(async (req, res) => {
407
+ const { spawn } = require('node:child_process');
408
+ const body = req.body || {};
409
+ const command = String(body.command || '').trim();
410
+ const shell = ['direct', 'pwsh', 'cmd'].includes(body.shell) ? body.shell : 'direct';
411
+ const type = ['claude', 'codex', 'copilot', 'other'].includes(body.type) ? body.type : 'other';
412
+ if (!command) return res.status(400).json({ error: 'command required' });
413
+
414
+ // Build the test exec. Same shell-wrapping rules as resolveCommand,
415
+ // but we force `--version` as the only arg and we DROP `-NoExit`
416
+ // from the pwsh wrapper so pwsh terminates after printing.
417
+ let exe, args;
418
+ const cmd = command.replace(/^\.[\\\/]/, '');
419
+ const versionArg = '--version';
420
+ if (shell === 'pwsh') {
421
+ const joined = `& ${/[\s'"\`$]/.test(cmd) ? `'${cmd.replace(/'/g, "''")}'` : cmd} ${versionArg}`;
422
+ exe = 'pwsh.exe';
423
+ args = ['-NoLogo', '-Command', joined];
424
+ } else if (shell === 'cmd') {
425
+ exe = process.env.ComSpec || 'cmd.exe';
426
+ args = ['/d', '/s', '/c', `${cmd} ${versionArg}`];
427
+ } else if (path.isAbsolute(cmd)) {
428
+ const ext = path.extname(cmd).toLowerCase();
429
+ if (ext === '.cmd' || ext === '.bat') {
430
+ exe = process.env.ComSpec || 'cmd.exe';
431
+ args = ['/d', '/s', '/c', cmd, versionArg];
432
+ } else if (ext === '.ps1') {
433
+ exe = 'powershell.exe';
434
+ args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', cmd, versionArg];
435
+ } else {
436
+ exe = cmd;
437
+ args = [versionArg];
438
+ }
439
+ } else {
440
+ exe = process.env.ComSpec || 'cmd.exe';
441
+ args = ['/d', '/s', '/c', cmd, versionArg];
442
+ }
443
+
444
+ const t0 = Date.now();
445
+ let stdout = '';
446
+ let stderr = '';
447
+ let exitCode = null;
448
+ let timedOut = false;
449
+ let spawnError = null;
450
+ try {
451
+ const child = spawn(exe, args, { env: spawnEnv(), windowsHide: true });
452
+ const killer = setTimeout(() => { timedOut = true; try { child.kill(); } catch {} }, 5000);
453
+ child.stdout.on('data', (d) => { stdout += d.toString(); if (stdout.length > 8192) stdout = stdout.slice(0, 8192); });
454
+ child.stderr.on('data', (d) => { stderr += d.toString(); if (stderr.length > 8192) stderr = stderr.slice(0, 8192); });
455
+ exitCode = await new Promise((resolve, reject) => {
456
+ child.on('exit', (code) => { clearTimeout(killer); resolve(code); });
457
+ child.on('error', (err) => { clearTimeout(killer); reject(err); });
458
+ });
459
+ } catch (e) {
460
+ spawnError = String(e && e.message || e);
461
+ }
462
+ const durationMs = Date.now() - t0;
463
+
464
+ const out = (stdout + '\n' + stderr).toLowerCase();
465
+ const PATTERNS = {
466
+ claude: /claude/,
467
+ codex: /codex|openai/,
468
+ copilot: /copilot/,
469
+ };
470
+ const matchedType = type === 'other' ? null : (PATTERNS[type] ? PATTERNS[type].test(out) : null);
471
+ const ok = !spawnError && !timedOut && exitCode === 0;
472
+ res.json({
473
+ ok, exitCode, durationMs, timedOut, spawnError,
474
+ stdout: stdout.trim(),
475
+ stderr: stderr.trim(),
476
+ matchedType,
477
+ expectedType: type,
478
+ spawned: { exe, args },
479
+ });
480
+ }));
481
+
376
482
  // ---- folders ----
377
483
 
378
484
  app.get('/api/folders', asyncH(async (_req, res) => {