@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.
- package/CLAUDE.md +222 -195
- package/README.md +77 -79
- package/lib/cliSessionWatcher.js +249 -0
- package/lib/config.js +101 -24
- package/lib/folders.js +96 -0
- package/lib/localCliSessions.js +177 -0
- package/lib/persistedSessions.js +134 -0
- package/lib/webTerminal.js +31 -18
- package/lib/workspace.js +26 -4
- package/package.json +1 -1
- package/public/assets/claude-color.svg +1 -0
- package/public/assets/codex-color.svg +1 -0
- package/public/assets/copilot-color.svg +1 -0
- package/public/css/base.css +22 -5
- package/public/css/cards.css +37 -3
- package/public/css/feedback.css +127 -43
- package/public/css/forms.css +97 -25
- package/public/css/layout.css +74 -26
- package/public/css/modal.css +40 -26
- package/public/css/responsive.css +2 -2
- package/public/css/sidebar.css +424 -25
- package/public/css/terminals.css +138 -0
- package/public/css/tokens.css +28 -12
- package/public/css/wco.css +38 -39
- package/public/css/widgets.css +1177 -6
- package/public/index.html +35 -2
- package/public/js/api.js +194 -37
- package/public/js/components/AdoptModal.js +171 -0
- package/public/js/components/App.js +1 -11
- package/public/js/components/DirectoryPicker.js +203 -0
- package/public/js/components/EntityFormModal.js +105 -0
- package/public/js/components/Modal.js +51 -0
- package/public/js/components/OfflineBanner.js +29 -23
- package/public/js/components/PageTitleBar.js +13 -0
- package/public/js/components/Picker.js +179 -0
- package/public/js/components/Popover.js +55 -0
- package/public/js/components/Sidebar.js +219 -32
- package/public/js/components/TerminalView.js +27 -3
- package/public/js/components/useDragSort.js +67 -0
- package/public/js/dialog.js +10 -2
- package/public/js/icons.js +66 -3
- package/public/js/main.js +54 -3
- package/public/js/pages/AboutPage.js +80 -0
- package/public/js/pages/ConfigurePage.js +429 -207
- package/public/js/pages/LaunchPage.js +326 -86
- package/public/js/pages/SessionsPage.js +91 -41
- package/public/js/state.js +102 -73
- package/public/manifest.webmanifest +2 -2
- package/scripts/install.js +7 -2
- package/server.js +755 -441
- package/lib/favorites.js +0 -51
- package/lib/focus.js +0 -369
- package/lib/labels.js +0 -29
- package/lib/launcher.js +0 -219
- package/lib/sessions.js +0 -272
- package/lib/snapshot.js +0 -141
- package/public/js/actions.js +0 -107
- package/public/js/components/Fab.js +0 -11
- package/public/js/components/FavoritesTable.js +0 -81
- package/public/js/components/Footer.js +0 -12
- package/public/js/components/NewSessionModal.js +0 -153
- package/public/js/components/PageHead.js +0 -33
- package/public/js/components/Pagination.js +0 -27
- package/public/js/components/RecentTable.js +0 -68
- package/public/js/components/SessionsTable.js +0 -71
- package/public/js/components/SnapshotPanel.js +0 -77
- package/public/js/components/TitleCell.js +0 -40
- package/public/js/components/WorkspacesGrid.js +0 -41
- 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
|
+
};
|
package/lib/webTerminal.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
95
|
-
|
|
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(
|
|
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.
|
|
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>
|
package/public/css/base.css
CHANGED
|
@@ -4,10 +4,9 @@
|
|
|
4
4
|
[hidden] { display: none !important; }
|
|
5
5
|
|
|
6
6
|
html {
|
|
7
|
-
/*
|
|
8
|
-
|
|
9
|
-
|
|
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:
|
|
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. */
|