@bakapiano/ccsm 0.11.0 → 0.12.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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakapiano/ccsm",
3
- "version": "0.11.0",
3
+ "version": "0.12.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;
@@ -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`
@@ -108,8 +118,22 @@ function SessionRow({ s }) {
108
118
  } catch (e) { setToast(e.message, 'error'); }
109
119
  };
110
120
 
121
+ const onDragStart = (ev) => {
122
+ draggingSessionId.value = s.id;
123
+ ev.dataTransfer.effectAllowed = 'move';
124
+ // Firefox refuses to start a drag without some data set.
125
+ try { ev.dataTransfer.setData('text/plain', s.id); } catch {}
126
+ };
127
+ const onDragEnd = () => {
128
+ draggingSessionId.value = null;
129
+ dragOverFolderKey.value = null;
130
+ };
131
+
111
132
  return html`
112
133
  <div class=${`tree-session${isActive ? ' is-active' : ''}${running ? ' is-running' : ' is-stopped'}`}
134
+ draggable=${true}
135
+ onDragStart=${onDragStart}
136
+ onDragEnd=${onDragEnd}
113
137
  onClick=${onClick}
114
138
  onContextMenu=${onContext}
115
139
  title=${`${title}\n${s.cwd}\n${running ? 'running' : 'stopped'} · ${s.cliId}`}>
@@ -124,7 +148,7 @@ function SessionRow({ s }) {
124
148
  }
125
149
 
126
150
  function FolderGroup({ folder, sessionList, dndHandle, dndRow }) {
127
- const key = folder ? folder.id : 'unsorted';
151
+ const key = folderKey(folder);
128
152
  const collapsed = !!foldersCollapsed.value[key];
129
153
  const name = folder ? folder.name : 'Unsorted';
130
154
  const onToggle = () => toggleFolder(folder ? folder.id : null);
@@ -148,8 +172,55 @@ function FolderGroup({ folder, sessionList, dndHandle, dndRow }) {
148
172
  catch (e) { setToast(e.message, 'error'); }
149
173
  };
150
174
 
175
+ // Session-into-folder drop target. We don't go through useDragSort
176
+ // because that one is wired for folder-reorder. Folder reorder's
177
+ // handlers (in dndRow) short-circuit when no folder is being dragged,
178
+ // and our handlers below short-circuit when no session is being
179
+ // dragged — so composing both is safe.
180
+ const draggedSession = draggingSessionId.value
181
+ ? sessions.value.find((s) => s.id === draggingSessionId.value)
182
+ : null;
183
+ const sameFolder = draggedSession
184
+ && (draggedSession.folderId || null) === (folder ? folder.id : null);
185
+ const isOver = !sameFolder && dragOverFolderKey.value === key;
186
+
187
+ const onSessionDragOver = (ev) => {
188
+ if (!draggingSessionId.value || sameFolder) return;
189
+ ev.preventDefault();
190
+ ev.dataTransfer.dropEffect = 'move';
191
+ if (dragOverFolderKey.value !== key) dragOverFolderKey.value = key;
192
+ };
193
+ const onSessionDragLeave = (ev) => {
194
+ if (!draggingSessionId.value) return;
195
+ const rt = ev.relatedTarget;
196
+ if (rt && ev.currentTarget.contains(rt)) return;
197
+ if (dragOverFolderKey.value === key) dragOverFolderKey.value = null;
198
+ };
199
+ const onSessionDrop = (ev) => {
200
+ const sid = draggingSessionId.value;
201
+ draggingSessionId.value = null;
202
+ dragOverFolderKey.value = null;
203
+ if (!sid || sameFolder) return;
204
+ ev.preventDefault();
205
+ ev.stopPropagation();
206
+ setSessionFolder(sid, folder ? folder.id : null)
207
+ .then(() => setToast(folder ? `moved to ${folder.name}` : 'moved to Unsorted'))
208
+ .catch((e) => setToast(e.message, 'error'));
209
+ };
210
+
211
+ // Spread folder-reorder row handlers first, then compose our
212
+ // session-drop handlers on top so both fire.
213
+ const { onDragOver: rowOver, onDragLeave: rowLeave, onDrop: rowDrop, ...rowAttrs } = dndRow || {};
214
+ const composedOver = (ev) => { onSessionDragOver(ev); rowOver?.(ev); };
215
+ const composedLeave = (ev) => { onSessionDragLeave(ev); rowLeave?.(ev); };
216
+ const composedDrop = (ev) => { onSessionDrop(ev); rowDrop?.(ev); };
217
+
151
218
  return html`
152
- <div class="tree-folder" ...${dndRow || {}}>
219
+ <div class=${`tree-folder${isOver ? ' is-session-drop-target' : ''}`}
220
+ ...${rowAttrs}
221
+ onDragOver=${composedOver}
222
+ onDragLeave=${composedLeave}
223
+ onDrop=${composedDrop}>
153
224
  <button class=${`tree-folder-head${collapsed ? '' : ' is-open'}`} onClick=${onToggle}
154
225
  ...${dndHandle || {}}>
155
226
  <span class="tree-folder-icon">
@@ -194,6 +265,9 @@ function SessionTree() {
194
265
  <div class="tree">
195
266
  <div class="tree-head">
196
267
  <span class="tree-head-label">Sessions</span>
268
+ <button class="tree-head-action" title="New folder" onClick=${onNewFolder}>
269
+ <${IconPlus} />
270
+ </button>
197
271
  </div>
198
272
  ${orderedFolders.map((f) => html`
199
273
  <${FolderGroup} key=${f.id} folder=${f}
@@ -236,9 +310,6 @@ export function Sidebar() {
236
310
  onClick=${() => selectTab('about')}>
237
311
  <span class="brand-mark"><${BrandMark} /></span>
238
312
  <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
313
  </button>
243
314
  </div>
244
315
 
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
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)