@bakapiano/ccsm 0.9.0 → 0.10.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.
Files changed (69) hide show
  1. package/CLAUDE.md +222 -195
  2. package/README.md +77 -79
  3. package/lib/cliSessionWatcher.js +249 -0
  4. package/lib/config.js +101 -24
  5. package/lib/folders.js +96 -0
  6. package/lib/localCliSessions.js +177 -0
  7. package/lib/persistedSessions.js +134 -0
  8. package/lib/webTerminal.js +31 -18
  9. package/lib/workspace.js +26 -4
  10. package/package.json +1 -1
  11. package/public/assets/claude-color.svg +1 -0
  12. package/public/assets/codex-color.svg +1 -0
  13. package/public/assets/copilot-color.svg +1 -0
  14. package/public/css/base.css +22 -5
  15. package/public/css/cards.css +37 -3
  16. package/public/css/feedback.css +127 -43
  17. package/public/css/forms.css +97 -25
  18. package/public/css/layout.css +74 -26
  19. package/public/css/modal.css +40 -26
  20. package/public/css/responsive.css +2 -2
  21. package/public/css/sidebar.css +424 -25
  22. package/public/css/terminals.css +138 -0
  23. package/public/css/tokens.css +28 -12
  24. package/public/css/wco.css +38 -39
  25. package/public/css/widgets.css +1177 -6
  26. package/public/index.html +35 -2
  27. package/public/js/api.js +194 -37
  28. package/public/js/components/AdoptModal.js +171 -0
  29. package/public/js/components/App.js +1 -11
  30. package/public/js/components/DirectoryPicker.js +203 -0
  31. package/public/js/components/EntityFormModal.js +105 -0
  32. package/public/js/components/Modal.js +51 -0
  33. package/public/js/components/OfflineBanner.js +29 -23
  34. package/public/js/components/PageTitleBar.js +13 -0
  35. package/public/js/components/Picker.js +179 -0
  36. package/public/js/components/Popover.js +55 -0
  37. package/public/js/components/Sidebar.js +219 -32
  38. package/public/js/components/TerminalView.js +27 -3
  39. package/public/js/components/useDragSort.js +67 -0
  40. package/public/js/dialog.js +10 -2
  41. package/public/js/icons.js +66 -3
  42. package/public/js/main.js +54 -3
  43. package/public/js/pages/AboutPage.js +80 -0
  44. package/public/js/pages/ConfigurePage.js +429 -207
  45. package/public/js/pages/LaunchPage.js +326 -86
  46. package/public/js/pages/SessionsPage.js +91 -41
  47. package/public/js/state.js +102 -73
  48. package/public/manifest.webmanifest +2 -2
  49. package/scripts/install.js +7 -2
  50. package/server.js +755 -441
  51. package/lib/favorites.js +0 -51
  52. package/lib/focus.js +0 -369
  53. package/lib/labels.js +0 -29
  54. package/lib/launcher.js +0 -219
  55. package/lib/sessions.js +0 -272
  56. package/lib/snapshot.js +0 -141
  57. package/public/js/actions.js +0 -107
  58. package/public/js/components/Fab.js +0 -11
  59. package/public/js/components/FavoritesTable.js +0 -81
  60. package/public/js/components/Footer.js +0 -12
  61. package/public/js/components/NewSessionModal.js +0 -153
  62. package/public/js/components/PageHead.js +0 -33
  63. package/public/js/components/Pagination.js +0 -27
  64. package/public/js/components/RecentTable.js +0 -68
  65. package/public/js/components/SessionsTable.js +0 -71
  66. package/public/js/components/SnapshotPanel.js +0 -77
  67. package/public/js/components/TitleCell.js +0 -40
  68. package/public/js/components/WorkspacesGrid.js +0 -41
  69. package/public/js/pages/TerminalsPage.js +0 -74
package/lib/folders.js ADDED
@@ -0,0 +1,96 @@
1
+ 'use strict';
2
+
3
+ // User-curated folders. Sessions reference these by id. Order is
4
+ // user-controlled (drag-reorder in sidebar). The store is a flat list
5
+ // in $DATA_DIR/folders.json:
6
+ // [{ id, name, order, createdAt }]
7
+ //
8
+ // Top-level "Unsorted" is implicit — sessions with folderId === null
9
+ // render under it. The user can't delete or rename it; we just synthesise
10
+ // the bucket in the frontend.
11
+
12
+ const path = require('node:path');
13
+ const fs = require('node:fs/promises');
14
+ const { DATA_DIR } = require('./config');
15
+
16
+ const FILE = path.join(DATA_DIR, 'folders.json');
17
+
18
+ async function loadAll() {
19
+ try {
20
+ const raw = await fs.readFile(FILE, 'utf8');
21
+ const j = JSON.parse(raw);
22
+ return Array.isArray(j) ? j : [];
23
+ } catch (e) {
24
+ if (e.code === 'ENOENT') return [];
25
+ throw e;
26
+ }
27
+ }
28
+
29
+ async function saveAll(list) {
30
+ await fs.writeFile(FILE, JSON.stringify(list, null, 2));
31
+ }
32
+
33
+ function genId() {
34
+ return 'folder-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
35
+ }
36
+
37
+ async function create({ name }) {
38
+ 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;
49
+ }
50
+
51
+ 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];
62
+ }
63
+
64
+ 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;
71
+ }
72
+
73
+ async function reorder(idsInOrder) {
74
+ 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
+ next.push(f);
83
+ byId.delete(id);
84
+ }
85
+ });
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
+ }
95
+
96
+ module.exports = { loadAll, create, update, remove, reorder, FILE };
@@ -0,0 +1,177 @@
1
+ 'use strict';
2
+
3
+ // Discover existing CLI sessions on this machine and surface them so
4
+ // ccsm can "adopt" them — i.e. create a persistedSessions record that
5
+ // resumes the same upstream conversation later.
6
+ //
7
+ // Per CLI:
8
+ // claude · ~/.claude/projects/<slug>/<uuid>.jsonl (uuid = id)
9
+ // codex · ~/.codex/sessions/**/<uuid>.jsonl (uuid = id)
10
+ // copilot · ~/.copilot/session-state/<uuid>/ (uuid = dir name;
11
+ // cwd + summary in workspace.yaml)
12
+ //
13
+ // Each session is reported as:
14
+ // { cliType, cliSessionId, cwd, mtime, summary }
15
+ //
16
+ // Heuristic for `summary`: the first user message text (claude/codex)
17
+ // or the YAML `summary:` line (copilot). Truncated to 120 chars.
18
+
19
+ const fs = require('node:fs');
20
+ const fsp = require('node:fs/promises');
21
+ const path = require('node:path');
22
+ const os = require('node:os');
23
+ const readline = require('node:readline');
24
+
25
+ const SUMMARY_MAX = 120;
26
+
27
+ async function listClaude() {
28
+ const root = path.join(os.homedir(), '.claude', 'projects');
29
+ let slugs;
30
+ try { slugs = await fsp.readdir(root, { withFileTypes: true }); }
31
+ catch { return []; }
32
+ const out = [];
33
+ for (const slug of slugs) {
34
+ if (!slug.isDirectory()) continue;
35
+ const slugDir = path.join(root, slug.name);
36
+ let files;
37
+ try { files = await fsp.readdir(slugDir, { withFileTypes: true }); }
38
+ catch { continue; }
39
+ for (const f of files) {
40
+ if (!f.isFile() || !f.name.endsWith('.jsonl')) continue;
41
+ const filepath = path.join(slugDir, f.name);
42
+ const id = f.name.replace(/\.jsonl$/, '');
43
+ let st; try { st = await fsp.stat(filepath); } catch { continue; }
44
+ const { cwd, summary } = await parseClaudeJsonl(filepath);
45
+ if (!cwd) continue;
46
+ out.push({
47
+ cliType: 'claude',
48
+ cliSessionId: id,
49
+ cwd,
50
+ mtime: st.mtimeMs,
51
+ summary,
52
+ });
53
+ }
54
+ }
55
+ return out;
56
+ }
57
+
58
+ async function listCodex() {
59
+ const root = path.join(os.homedir(), '.codex', 'sessions');
60
+ const out = [];
61
+ await walkFiles(root, async (filepath) => {
62
+ if (!filepath.endsWith('.jsonl')) return;
63
+ const base = path.basename(filepath);
64
+ 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);
65
+ if (!m) return;
66
+ let st; try { st = await fsp.stat(filepath); } catch { return; }
67
+ const { cwd, summary } = await parseClaudeJsonl(filepath); // same shape
68
+ if (!cwd) return;
69
+ out.push({
70
+ cliType: 'codex',
71
+ cliSessionId: m[1],
72
+ cwd,
73
+ mtime: st.mtimeMs,
74
+ summary,
75
+ });
76
+ });
77
+ return out;
78
+ }
79
+
80
+ async function listCopilot() {
81
+ const root = path.join(os.homedir(), '.copilot', 'session-state');
82
+ let dirs;
83
+ try { dirs = await fsp.readdir(root, { withFileTypes: true }); }
84
+ catch { return []; }
85
+ const out = [];
86
+ for (const d of dirs) {
87
+ if (!d.isDirectory()) continue;
88
+ const id = d.name;
89
+ if (!/^[0-9a-f-]+$/i.test(id)) continue;
90
+ const dirpath = path.join(root, id);
91
+ let st; try { st = await fsp.stat(dirpath); } catch { continue; }
92
+ const yaml = path.join(dirpath, 'workspace.yaml');
93
+ let txt;
94
+ try { txt = await fsp.readFile(yaml, 'utf8'); }
95
+ catch { continue; }
96
+ const cwd = (txt.match(/^\s*cwd\s*:\s*(.+?)\s*$/m) || [])[1] || null;
97
+ const summary = (txt.match(/^\s*summary\s*:\s*(.+?)\s*$/m) || [])[1] || '';
98
+ const updated = (txt.match(/^\s*updated_at\s*:\s*(.+?)\s*$/m) || [])[1];
99
+ if (!cwd) continue;
100
+ out.push({
101
+ cliType: 'copilot',
102
+ cliSessionId: id,
103
+ cwd: cwd.trim(),
104
+ mtime: updated ? Date.parse(updated) || st.mtimeMs : st.mtimeMs,
105
+ summary: truncate(summary, SUMMARY_MAX),
106
+ });
107
+ }
108
+ return out;
109
+ }
110
+
111
+ async function listForType(cliType) {
112
+ if (cliType === 'claude') return listClaude();
113
+ if (cliType === 'codex') return listCodex();
114
+ if (cliType === 'copilot') return listCopilot();
115
+ return [];
116
+ }
117
+
118
+ module.exports = { listForType, listClaude, listCodex, listCopilot };
119
+
120
+ // ── helpers ─────────────────────────────────────────────────────────
121
+
122
+ async function walkFiles(root, visit) {
123
+ let entries;
124
+ try { entries = await fsp.readdir(root, { withFileTypes: true }); }
125
+ catch { return; }
126
+ for (const e of entries) {
127
+ const p = path.join(root, e.name);
128
+ if (e.isDirectory()) await walkFiles(p, visit);
129
+ else await visit(p);
130
+ }
131
+ }
132
+
133
+ function truncate(s, n) {
134
+ if (!s) return '';
135
+ const t = String(s).replace(/\s+/g, ' ').trim();
136
+ return t.length > n ? t.slice(0, n - 1) + '…' : t;
137
+ }
138
+
139
+ // Returns { cwd, summary } from the first ~30 lines of a claude/codex
140
+ // jsonl. Looks for the first object with a `cwd` field, plus the first
141
+ // user message text content for a 1-line preview.
142
+ async function parseClaudeJsonl(filepath) {
143
+ return new Promise((resolve) => {
144
+ let stream;
145
+ try { stream = fs.createReadStream(filepath, { encoding: 'utf8' }); }
146
+ catch { resolve({ cwd: null, summary: '' }); return; }
147
+ const rl = readline.createInterface({ input: stream });
148
+ let count = 0;
149
+ let cwd = null;
150
+ let summary = '';
151
+ let settled = false;
152
+ const done = () => {
153
+ if (settled) return;
154
+ settled = true;
155
+ try { rl.close(); } catch {}
156
+ try { stream.destroy(); } catch {}
157
+ resolve({ cwd, summary });
158
+ };
159
+ rl.on('line', (line) => {
160
+ count++;
161
+ try {
162
+ const obj = JSON.parse(line);
163
+ if (!cwd && obj && obj.cwd) cwd = obj.cwd;
164
+ if (!summary && obj) {
165
+ // First user text wins.
166
+ if (obj.type === 'user' && obj.message?.content) {
167
+ const c = obj.message.content;
168
+ if (typeof c === 'string') summary = truncate(c, SUMMARY_MAX);
169
+ }
170
+ }
171
+ } catch {}
172
+ if (count >= 30 || (cwd && summary)) done();
173
+ });
174
+ rl.on('close', done);
175
+ rl.on('error', done);
176
+ });
177
+ }
@@ -0,0 +1,134 @@
1
+ 'use strict';
2
+
3
+ // ccsm-owned session records. Replaces the old "scan ~/.claude/sessions/
4
+ // + tasklist" path entirely: we no longer try to enumerate every claude
5
+ // process on the machine. Instead, every session ccsm starts (via the
6
+ // web terminal) gets recorded here, and the user organises them in
7
+ // folders.
8
+ //
9
+ // Each entry:
10
+ // {
11
+ // id: 'sess-...', // ccsm's session id (matches webTerminal id)
12
+ // cliId: 'claude', // which CLI from config.clis
13
+ // cwd: '...', // absolute workspace path
14
+ // workspace: 'ws-3', // basename of cwd (display)
15
+ // title: '', // user-edited label (Configure / sidebar tree)
16
+ // folderId: null, // nullable; null = "Unsorted" top-level
17
+ // repos: ['foo','bar'], // names of repos cloned into cwd at launch
18
+ // createdAt: 1234,
19
+ // lastActiveAt: 1234, // updated on attach/input; drives sort
20
+ // status: 'running'|'exited',
21
+ // exitedAt: null,
22
+ // exitCode: null,
23
+ // pid: null, // current pid if running
24
+ // cliSessionId: null, // upstream CLI's session UUID (captured
25
+ // // by lib/cliSessionWatcher after spawn);
26
+ // // used for precise --resume <id>.
27
+ // }
28
+
29
+ const path = require('node:path');
30
+ const fs = require('node:fs/promises');
31
+ const { DATA_DIR } = require('./config');
32
+
33
+ const FILE = path.join(DATA_DIR, 'sessions.json');
34
+
35
+ async function loadAll() {
36
+ try {
37
+ const raw = await fs.readFile(FILE, 'utf8');
38
+ const j = JSON.parse(raw);
39
+ return Array.isArray(j) ? j : [];
40
+ } catch (e) {
41
+ if (e.code === 'ENOENT') return [];
42
+ throw e;
43
+ }
44
+ }
45
+
46
+ async function saveAll(list) {
47
+ await fs.writeFile(FILE, JSON.stringify(list, null, 2));
48
+ }
49
+
50
+ function genId() {
51
+ return 'sess-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
52
+ }
53
+
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;
75
+ }
76
+
77
+ async function get(id) {
78
+ const list = await loadAll();
79
+ return list.find((s) => s.id === id) || null;
80
+ }
81
+
82
+ 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];
89
+ }
90
+
91
+ 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
+ }
99
+
100
+ // Convenience helpers used at runtime so callers don't have to do
101
+ // load/find/update/save themselves.
102
+ async function markRunning(id, pid) {
103
+ return update(id, { status: 'running', pid, exitedAt: null, exitCode: null, lastActiveAt: Date.now() });
104
+ }
105
+
106
+ async function markExited(id, exitCode) {
107
+ return update(id, { status: 'exited', exitCode: exitCode ?? null, exitedAt: Date.now(), pid: null });
108
+ }
109
+
110
+ async function touch(id) {
111
+ return update(id, { lastActiveAt: Date.now() });
112
+ }
113
+
114
+ async function setFolder(id, folderId) {
115
+ return update(id, { folderId: folderId || null });
116
+ }
117
+
118
+ async function setTitle(id, title) {
119
+ return update(id, { title: title || '' });
120
+ }
121
+
122
+ module.exports = {
123
+ loadAll,
124
+ create,
125
+ get,
126
+ update,
127
+ remove,
128
+ markRunning,
129
+ markExited,
130
+ touch,
131
+ setFolder,
132
+ setTitle,
133
+ FILE,
134
+ };
@@ -37,22 +37,19 @@ function genId() {
37
37
 
38
38
  // Spawn a new PTY. `command` and `args` are passed straight to node-pty.
39
39
  // `meta` is whatever the caller wants surfaced to the UI (title, cwd, etc).
40
+ // `id` lets the caller dictate the session id (so persistedSessions can
41
+ // keep PTY id == record id); defaults to an auto-generated one.
42
+ // `onData` / `onExit` are optional callbacks fired alongside the built-in
43
+ // history-recording + socket-broadcast, so persistedSessions can mark
44
+ // status/lastActiveAt without us having to drill the dependency in here.
40
45
  // Throws if node-pty isn't available.
41
- function spawn({ command, args = [], cwd, env, cols = 120, rows = 30, meta = {} }) {
46
+ function spawn({ command, args = [], cwd, env, cols = 120, rows = 30, meta = {}, id, onData, onExit }) {
42
47
  if (!pty) {
43
48
  const err = new Error('node-pty is not available · ' + (loadError && loadError.message || 'unknown'));
44
49
  err.code = 'PTY_UNAVAILABLE';
45
50
  throw err;
46
51
  }
47
- const id = genId();
48
- // useConpty: new ConPTY API (Win10 1809+). node-pty defaults this true on
49
- // Windows, but spell it out so we know we're on the modern path.
50
- // useConptyDll: opt-in to the newest, separately-versioned conpty.dll
51
- // (node-pty 1.0+, Windows 10 1809+ if the dll is present). This is the
52
- // path VSCode uses (see vscode/src/vs/platform/terminal/node/terminalProcess.ts)
53
- // — it has a larger stdin buffer and doesn't split bracketed-paste
54
- // payloads across multiple child-process reads, so claude code's
55
- // [Pasted text] chip detection actually fires.
52
+ const entryId = id || genId();
56
53
  const ptyOpts = {
57
54
  name: 'xterm-256color',
58
55
  cols, rows,
@@ -65,16 +62,17 @@ function spawn({ command, args = [], cwd, env, cols = 120, rows = 30, meta = {}
65
62
  }
66
63
  const proc = pty.spawn(command, args, ptyOpts);
67
64
  const entry = {
68
- id,
65
+ id: entryId,
69
66
  pty: proc,
70
67
  history: '',
71
68
  sockets: new Set(),
72
69
  meta: { ...meta, startedAt: Date.now(), command, args, cwd: cwd || process.cwd(), pid: proc.pid },
73
70
  exitCode: null,
74
71
  exitedAt: null,
72
+ onDataExtra: onData,
73
+ onExitExtra: onExit,
75
74
  };
76
75
  proc.onData((data) => {
77
- // Append to ring; truncate to last HISTORY_BYTES so memory stays bounded.
78
76
  entry.history = (entry.history + data);
79
77
  if (entry.history.length > HISTORY_BYTES) {
80
78
  entry.history = entry.history.slice(-HISTORY_BYTES);
@@ -83,6 +81,7 @@ function spawn({ command, args = [], cwd, env, cols = 120, rows = 30, meta = {}
83
81
  for (const ws of entry.sockets) {
84
82
  try { ws.send(frame); } catch {}
85
83
  }
84
+ if (entry.onDataExtra) { try { entry.onDataExtra(data); } catch {} }
86
85
  });
87
86
  proc.onExit(({ exitCode, signal }) => {
88
87
  entry.exitCode = exitCode;
@@ -91,15 +90,29 @@ function spawn({ command, args = [], cwd, env, cols = 120, rows = 30, meta = {}
91
90
  for (const ws of entry.sockets) {
92
91
  try { ws.send(frame); } catch {}
93
92
  }
94
- // Keep the entry around briefly so a reconnecting client can see the
95
- // exit code + final transcript, then drop it. 30s is enough for a UI
96
- // re-render but won't hoard memory forever.
97
- setTimeout(() => sessions.delete(id), 30_000);
93
+ if (entry.onExitExtra) { try { entry.onExitExtra({ exitCode, signal }); } catch {} }
94
+ setTimeout(() => sessions.delete(entryId), 30_000);
98
95
  });
99
- sessions.set(id, entry);
96
+ sessions.set(entryId, entry);
100
97
  return entry;
101
98
  }
102
99
 
100
+ // Strip ANSI sequences from history that would cause spurious
101
+ // terminal-to-host responses if a fresh xterm.js re-parses the replay.
102
+ // Specifically: device-attribute / device-status queries (CSI c, CSI 0c,
103
+ // CSI >0c, CSI 5n, CSI 6n, …) — the original xterm already answered
104
+ // them, but on attach we replay everything; without scrubbing, the new
105
+ // xterm answers them too, the reply goes through our onData→PTY pipe,
106
+ // the CLI sees garbage bytes in its stdin, and echoes them back as
107
+ // visible junk like `[?12;2c`.
108
+ function scrubReplayResponses(history) {
109
+ return history
110
+ // CSI [ ? Ps c (primary DA query)
111
+ .replace(/\x1b\[[?>0-9]*c/g, '')
112
+ // CSI [ Ps n (device status / cursor position queries)
113
+ .replace(/\x1b\[[?>0-9;]*n/g, '');
114
+ }
115
+
103
116
  // Wire a websocket to a session. Replays history immediately so the
104
117
  // client sees recent context; then forwards input/resize messages from
105
118
  // the client to the PTY and broadcast outputs back via onData above.
@@ -111,7 +124,7 @@ function attach(id, ws) {
111
124
  }
112
125
  entry.sockets.add(ws);
113
126
  if (entry.history) {
114
- try { ws.send(JSON.stringify({ type: 'output', data: entry.history })); } catch {}
127
+ try { ws.send(JSON.stringify({ type: 'output', data: scrubReplayResponses(entry.history) })); } catch {}
115
128
  }
116
129
  if (entry.exitedAt) {
117
130
  try { ws.send(JSON.stringify({ type: 'exit', code: entry.exitCode })); } catch {}
package/lib/workspace.js CHANGED
@@ -4,7 +4,6 @@ const fs = require('node:fs/promises');
4
4
  const fsSync = require('node:fs');
5
5
  const path = require('node:path');
6
6
  const { spawn } = require('node:child_process');
7
- const { listSessions } = require('./sessions');
8
7
 
9
8
  function normWin(p) {
10
9
  return path.resolve(String(p)).toLowerCase();
@@ -71,11 +70,9 @@ async function describeWorkspace(workspacePath, repos, busyPaths) {
71
70
  };
72
71
  }
73
72
 
74
- async function listWorkspaces({ workDir, repos }) {
73
+ async function listWorkspaces({ workDir, repos, busyPaths = [] }) {
75
74
  await ensureDir(workDir);
76
75
  const subdirs = await listSubdirs(workDir);
77
- const sessions = await listSessions();
78
- const busyPaths = sessions.map((s) => s.cwd);
79
76
 
80
77
  const workspaces = await Promise.all(
81
78
  subdirs.map((name) =>
@@ -224,10 +221,35 @@ async function ensureReposInWorkspace({ workspacePath, repos, onProgress, onLine
224
221
  return results;
225
222
  }
226
223
 
224
+ async function dirSize(p) {
225
+ let total = 0;
226
+ async function walk(dir) {
227
+ let entries;
228
+ try {
229
+ entries = await fs.readdir(dir, { withFileTypes: true });
230
+ } catch { return; }
231
+ await Promise.all(entries.map(async (e) => {
232
+ const full = path.join(dir, e.name);
233
+ if (e.isSymbolicLink()) return; // don't follow symlinks
234
+ if (e.isDirectory()) {
235
+ await walk(full);
236
+ } else if (e.isFile()) {
237
+ try {
238
+ const st = await fs.stat(full);
239
+ total += st.size;
240
+ } catch { /* skip */ }
241
+ }
242
+ }));
243
+ }
244
+ await walk(p);
245
+ return total;
246
+ }
247
+
227
248
  module.exports = {
228
249
  listWorkspaces,
229
250
  findOrCreateWorkspace,
230
251
  ensureReposInWorkspace,
231
252
  isInside,
232
253
  nextWorkspaceName,
254
+ dirSize,
233
255
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakapiano/ccsm",
3
- "version": "0.9.0",
3
+ "version": "0.10.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",
@@ -0,0 +1 @@
1
+ <svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"></path></svg>
@@ -0,0 +1 @@
1
+ <svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Codex</title><path d="M19.503 0H4.496A4.496 4.496 0 000 4.496v15.007A4.496 4.496 0 004.496 24h15.007A4.496 4.496 0 0024 19.503V4.496A4.496 4.496 0 0019.503 0z" fill="#fff"></path><path d="M9.064 3.344a4.578 4.578 0 012.285-.312c1 .115 1.891.54 2.673 1.275.01.01.024.017.037.021a.09.09 0 00.043 0 4.55 4.55 0 013.046.275l.047.022.116.057a4.581 4.581 0 012.188 2.399c.209.51.313 1.041.315 1.595a4.24 4.24 0 01-.134 1.223.123.123 0 00.03.115c.594.607.988 1.33 1.183 2.17.289 1.425-.007 2.71-.887 3.854l-.136.166a4.548 4.548 0 01-2.201 1.388.123.123 0 00-.081.076c-.191.551-.383 1.023-.74 1.494-.9 1.187-2.222 1.846-3.711 1.838-1.187-.006-2.239-.44-3.157-1.302a.107.107 0 00-.105-.024c-.388.125-.78.143-1.204.138a4.441 4.441 0 01-1.945-.466 4.544 4.544 0 01-1.61-1.335c-.152-.202-.303-.392-.414-.617a5.81 5.81 0 01-.37-.961 4.582 4.582 0 01-.014-2.298.124.124 0 00.006-.056.085.085 0 00-.027-.048 4.467 4.467 0 01-1.034-1.651 3.896 3.896 0 01-.251-1.192 5.189 5.189 0 01.141-1.6c.337-1.112.982-1.985 1.933-2.618.212-.141.413-.251.601-.33.215-.089.43-.164.646-.227a.098.098 0 00.065-.066 4.51 4.51 0 01.829-1.615 4.535 4.535 0 011.837-1.388zm3.482 10.565a.637.637 0 000 1.272h3.636a.637.637 0 100-1.272h-3.636zM8.462 9.23a.637.637 0 00-1.106.631l1.272 2.224-1.266 2.136a.636.636 0 101.095.649l1.454-2.455a.636.636 0 00.005-.64L8.462 9.23z" fill="url(#lobe-icons-codex-_R_0_)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-codex-_R_0_" x1="12" x2="12" y1="3" y2="21"><stop stop-color="#B1A7FF"></stop><stop offset=".5" stop-color="#7A9DFF"></stop><stop offset="1" stop-color="#3941FF"></stop></linearGradient></defs></svg>
@@ -0,0 +1 @@
1
+ <svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>GithubCopilot</title><path d="M19.245 5.364c1.322 1.36 1.877 3.216 2.11 5.817.622 0 1.2.135 1.592.654l.73.964c.21.278.323.61.323.955v2.62c0 .339-.173.669-.453.868C20.239 19.602 16.157 21.5 12 21.5c-4.6 0-9.205-2.583-11.547-4.258-.28-.2-.452-.53-.453-.868v-2.62c0-.345.113-.679.321-.956l.73-.963c.392-.517.974-.654 1.593-.654l.029-.297c.25-2.446.81-4.213 2.082-5.52 2.461-2.54 5.71-2.851 7.146-2.864h.198c1.436.013 4.685.323 7.146 2.864zm-7.244 4.328c-.284 0-.613.016-.962.05-.123.447-.305.85-.57 1.108-1.05 1.023-2.316 1.18-2.994 1.18-.638 0-1.306-.13-1.851-.464-.516.165-1.012.403-1.044.996a65.882 65.882 0 00-.063 2.884l-.002.48c-.002.563-.005 1.126-.013 1.69.002.326.204.63.51.765 2.482 1.102 4.83 1.657 6.99 1.657 2.156 0 4.504-.555 6.985-1.657a.854.854 0 00.51-.766c.03-1.682.006-3.372-.076-5.053-.031-.596-.528-.83-1.046-.996-.546.333-1.212.464-1.85.464-.677 0-1.942-.157-2.993-1.18-.266-.258-.447-.661-.57-1.108-.32-.032-.64-.049-.96-.05zm-2.525 4.013c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zm5 0c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zM7.635 5.087c-1.05.102-1.935.438-2.385.906-.975 1.037-.765 3.668-.21 4.224.405.394 1.17.657 1.995.657h.09c.649-.013 1.785-.176 2.73-1.11.435-.41.705-1.433.675-2.47-.03-.834-.27-1.52-.63-1.813-.39-.336-1.275-.482-2.265-.394zm6.465.394c-.36.292-.6.98-.63 1.813-.03 1.037.24 2.06.675 2.47.968.957 2.136 1.104 2.776 1.11h.044c.825 0 1.59-.263 1.995-.657.555-.556.765-3.187-.21-4.224-.45-.468-1.335-.804-2.385-.906-.99-.088-1.875.058-2.265.394zM12 7.615c-.24 0-.525.015-.84.044.03.16.045.336.06.526l-.001.159a2.94 2.94 0 01-.014.25c.225-.022.425-.027.612-.028h.366c.187 0 .387.006.612.028-.015-.146-.015-.277-.015-.409.015-.19.03-.365.06-.526a9.29 9.29 0 00-.84-.044z"></path></svg>
@@ -4,10 +4,9 @@
4
4
  [hidden] { display: none !important; }
5
5
 
6
6
  html {
7
- /* Reserve the scrollbar lane so the layout never shifts horizontally
8
- when content grows past one viewport. Both columns of scroll-gutter
9
- stay symmetric (Firefox/Chromium both honor this). */
10
- scrollbar-gutter: stable;
7
+ /* No reserved scrollbar gutter the sessions page is full-bleed and a
8
+ reserved right-side lane shows as visible empty space against the
9
+ terminal. Pages that overflow get the standard overlay scrollbar. */
11
10
  }
12
11
  html, body {
13
12
  background: var(--bg);
@@ -16,12 +15,30 @@ html, body {
16
15
  font-size: 14px;
17
16
  line-height: 1.5;
18
17
  font-variant-numeric: tabular-nums;
18
+ font-feature-settings: "ss01", "ss02", "cv11";
19
19
  min-height: 100vh;
20
20
  -webkit-font-smoothing: antialiased;
21
21
  -moz-osx-font-smoothing: grayscale;
22
+ text-rendering: optimizeLegibility;
22
23
  }
23
24
 
25
+ /* Layer the paper grain over the entire app surface — fixed so panning
26
+ doesn't shift the noise pattern under the cursor. Subtle enough that
27
+ you only notice it on cream regions, never on terminals or images. */
28
+ body::before {
29
+ content: "";
30
+ position: fixed;
31
+ inset: 0;
32
+ pointer-events: none;
33
+ z-index: 0;
34
+ background-image: var(--paper-noise);
35
+ opacity: 0.55;
36
+ mix-blend-mode: multiply;
37
+ }
38
+ #app { position: relative; z-index: 1; }
39
+
24
40
  ::selection { background: var(--ink); color: var(--bg-elev); }
41
+ ::-moz-selection { background: var(--ink); color: var(--bg-elev); }
25
42
 
26
43
  code, .kbd {
27
44
  font-family: var(--mono);
@@ -43,7 +60,7 @@ code, .kbd {
43
60
  ::-webkit-scrollbar-track { background: transparent; }
44
61
  ::-webkit-scrollbar-thumb {
45
62
  background: var(--border-strong);
46
- border-radius: 8px;
63
+ border-radius: 4px;
47
64
  border: 2px solid var(--bg);
48
65
  /* Forced minimum height so dragging the thumb is always practical even on
49
66
  very long pages. */