@bakapiano/ccsm 0.14.0 → 0.15.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.
Files changed (52) hide show
  1. package/CLAUDE.md +474 -475
  2. package/README.md +189 -190
  3. package/bin/ccsm.js +194 -194
  4. package/lib/cliActivity.js +118 -0
  5. package/lib/codexSeed.js +147 -0
  6. package/lib/config.js +211 -188
  7. package/lib/folders.js +105 -105
  8. package/lib/localCliSessions.js +489 -489
  9. package/lib/persistedSessions.js +144 -142
  10. package/lib/webTerminal.js +224 -224
  11. package/lib/workspace.js +230 -230
  12. package/package.json +57 -57
  13. package/public/css/base.css +99 -99
  14. package/public/css/cards.css +183 -183
  15. package/public/css/feedback.css +303 -303
  16. package/public/css/forms.css +405 -405
  17. package/public/css/layout.css +160 -160
  18. package/public/css/modal.css +190 -190
  19. package/public/css/responsive.css +10 -10
  20. package/public/css/sidebar.css +613 -608
  21. package/public/css/terminals.css +294 -294
  22. package/public/css/tokens.css +81 -81
  23. package/public/css/wco.css +98 -98
  24. package/public/css/widgets.css +1628 -1628
  25. package/public/index.html +111 -105
  26. package/public/js/api.js +296 -280
  27. package/public/js/components/AdoptModal.js +343 -343
  28. package/public/js/components/App.js +35 -35
  29. package/public/js/components/DirectoryPicker.js +203 -203
  30. package/public/js/components/EntityFormModal.js +141 -141
  31. package/public/js/components/Modal.js +51 -51
  32. package/public/js/components/OfflineBanner.js +93 -93
  33. package/public/js/components/PageTitleBar.js +13 -13
  34. package/public/js/components/Picker.js +179 -179
  35. package/public/js/components/Popover.js +55 -55
  36. package/public/js/components/Sidebar.js +299 -299
  37. package/public/js/components/TerminalView.js +314 -314
  38. package/public/js/components/useDragSort.js +67 -67
  39. package/public/js/dialog.js +67 -67
  40. package/public/js/icons.js +177 -177
  41. package/public/js/main.js +132 -132
  42. package/public/js/pages/AboutPage.js +165 -165
  43. package/public/js/pages/ConfigurePage.js +505 -475
  44. package/public/js/pages/LaunchPage.js +369 -369
  45. package/public/js/pages/SessionsPage.js +101 -97
  46. package/public/js/state.js +231 -231
  47. package/scripts/dev.js +44 -11
  48. package/scripts/install.js +158 -158
  49. package/scripts/restart-helper.js +91 -0
  50. package/server.js +1278 -1254
  51. package/lib/cliSessionWatcher.js +0 -275
  52. package/public/manifest.webmanifest +0 -15
@@ -1,224 +1,224 @@
1
- 'use strict';
2
-
3
- // ccsm in-process PTY pool. Used by the "web terminal" launch path:
4
- // claude (or any cmd) runs as a child of ccsm and its stdio is bridged
5
- // to one or more WebSocket clients via xterm.js in the browser.
6
- //
7
- // Lifecycle: a PTY entry is created by spawn(), broadcasts every output
8
- // chunk to all attached sockets, keeps a rolling history ring so a fresh
9
- // connection can replay recent output. attach() wires a websocket to an
10
- // entry, kill() ends a PTY explicitly, list() returns metadata for UI.
11
- //
12
- // node-pty is optional (Windows native binary). If it failed to load,
13
- // `available` is false and spawn() throws — server.js gates the
14
- // /api/sessions/web route on this flag so install failures degrade
15
- // gracefully to wt-only mode.
16
-
17
- const path = require('node:path');
18
-
19
- let pty = null;
20
- let loadError = null;
21
- try {
22
- pty = require('node-pty');
23
- } catch (e) {
24
- loadError = e;
25
- }
26
-
27
- const HISTORY_BYTES = 256 * 1024;
28
-
29
- // Map<id, { id, pty, history, sockets:Set<ws>, meta }>
30
- const sessions = new Map();
31
-
32
- function genId() {
33
- return 'web-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
34
- }
35
-
36
-
37
-
38
- // Spawn a new PTY. `command` and `args` are passed straight to node-pty.
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.
45
- // Throws if node-pty isn't available.
46
- function spawn({ command, args = [], cwd, env, cols = 120, rows = 30, meta = {}, id, onData, onExit }) {
47
- if (!pty) {
48
- const err = new Error('node-pty is not available · ' + (loadError && loadError.message || 'unknown'));
49
- err.code = 'PTY_UNAVAILABLE';
50
- throw err;
51
- }
52
- const entryId = id || genId();
53
- const ptyOpts = {
54
- name: 'xterm-256color',
55
- cols, rows,
56
- cwd: cwd ? path.resolve(cwd) : process.cwd(),
57
- env: { ...process.env, ...(env || {}) },
58
- };
59
- if (process.platform === 'win32') {
60
- ptyOpts.useConpty = true;
61
- ptyOpts.useConptyDll = true;
62
- }
63
- const proc = pty.spawn(command, args, ptyOpts);
64
- const entry = {
65
- id: entryId,
66
- pty: proc,
67
- history: '',
68
- sockets: new Set(),
69
- meta: { ...meta, startedAt: Date.now(), command, args, cwd: cwd || process.cwd(), pid: proc.pid },
70
- exitCode: null,
71
- exitedAt: null,
72
- onDataExtra: onData,
73
- onExitExtra: onExit,
74
- };
75
- proc.onData((data) => {
76
- entry.history = (entry.history + data);
77
- if (entry.history.length > HISTORY_BYTES) {
78
- entry.history = entry.history.slice(-HISTORY_BYTES);
79
- }
80
- const frame = JSON.stringify({ type: 'output', data });
81
- for (const ws of entry.sockets) {
82
- try { ws.send(frame); } catch {}
83
- }
84
- if (entry.onDataExtra) { try { entry.onDataExtra(data); } catch {} }
85
- });
86
- proc.onExit(({ exitCode, signal }) => {
87
- // If a respawn replaced us in the pool (same entryId, new entry
88
- // object), do not touch persistedSessions or schedule a delete —
89
- // those belong to the new entry now. Without this guard, a slow-
90
- // dying old PTY would fire markExited on the same sessionId and
91
- // clobber the new spawn's markRunning state.
92
- if (sessions.get(entryId) !== entry) return;
93
- entry.exitCode = exitCode;
94
- entry.exitedAt = Date.now();
95
- const frame = JSON.stringify({ type: 'exit', code: exitCode, signal });
96
- for (const ws of entry.sockets) {
97
- try { ws.send(frame); } catch {}
98
- }
99
- if (entry.onExitExtra) { try { entry.onExitExtra({ exitCode, signal }); } catch {} }
100
- setTimeout(() => {
101
- if (sessions.get(entryId) === entry) sessions.delete(entryId);
102
- }, 30_000);
103
- });
104
- // If a previous entry exists under the same id (respawn), kill its
105
- // pty so we don't have zombie claude.exe processes hanging on. The
106
- // onExit guard above ensures its callback no-ops once we've taken
107
- // over the slot.
108
- const prev = sessions.get(entryId);
109
- if (prev && !prev.exitedAt) {
110
- try { prev.pty.kill(); } catch {}
111
- }
112
- sessions.set(entryId, entry);
113
- return entry;
114
- }
115
-
116
- // Strip ANSI sequences from history that would cause spurious
117
- // terminal-to-host responses if a fresh xterm.js re-parses the replay.
118
- // Specifically: device-attribute / device-status queries (CSI c, CSI 0c,
119
- // CSI >0c, CSI 5n, CSI 6n, …) — the original xterm already answered
120
- // them, but on attach we replay everything; without scrubbing, the new
121
- // xterm answers them too, the reply goes through our onData→PTY pipe,
122
- // the CLI sees garbage bytes in its stdin, and echoes them back as
123
- // visible junk like `[?12;2c`.
124
- function scrubReplayResponses(history) {
125
- return history
126
- // CSI [ ? Ps c (primary DA query)
127
- .replace(/\x1b\[[?>0-9]*c/g, '')
128
- // CSI [ Ps n (device status / cursor position queries)
129
- .replace(/\x1b\[[?>0-9;]*n/g, '');
130
- }
131
-
132
- // Wire a websocket to a session. Replays history immediately so the
133
- // client sees recent context; then forwards input/resize messages from
134
- // the client to the PTY and broadcast outputs back via onData above.
135
- function attach(id, ws) {
136
- const entry = sessions.get(id);
137
- if (!entry) {
138
- try { ws.close(4404, 'no such terminal'); } catch {}
139
- return;
140
- }
141
- entry.sockets.add(ws);
142
- if (entry.history) {
143
- try { ws.send(JSON.stringify({ type: 'output', data: scrubReplayResponses(entry.history) })); } catch {}
144
- }
145
- if (entry.exitedAt) {
146
- try { ws.send(JSON.stringify({ type: 'exit', code: entry.exitCode })); } catch {}
147
- } else {
148
- try { ws.send(JSON.stringify({ type: 'attached', meta: entry.meta })); } catch {}
149
- }
150
-
151
- ws.on('message', (msg) => {
152
- let event;
153
- try { event = JSON.parse(msg.toString()); } catch { return; }
154
- if (entry.exitedAt) return; // PTY is dead, ignore further input
155
- switch (event.type) {
156
- case 'input':
157
- if (typeof event.data === 'string') {
158
- if (process.env.CCSM_DEBUG_PASTE === '1') {
159
- const d = event.data;
160
- const hex = Buffer.from(d, 'utf8').toString('hex').match(/.{1,2}/g).join(' ');
161
- console.log(`[pty.write id=${id}] len=${d.length} hex=${hex.slice(0, 400)}${hex.length > 400 ? '...' : ''}`);
162
- }
163
- entry.pty.write(event.data);
164
- }
165
- break;
166
- case 'resize':
167
- if (Number(event.cols) > 0 && Number(event.rows) > 0) {
168
- try { entry.pty.resize(Number(event.cols), Number(event.rows)); } catch {}
169
- }
170
- break;
171
- case 'kill':
172
- kill(id);
173
- break;
174
- }
175
- });
176
-
177
- ws.on('close', () => {
178
- entry.sockets.delete(ws);
179
- });
180
- }
181
-
182
- function kill(id) {
183
- const entry = sessions.get(id);
184
- if (!entry || entry.exitedAt) return false;
185
- try { entry.pty.kill(); } catch {}
186
- return true;
187
- }
188
-
189
- // Public summary for the frontend. Don't leak the pty / sockets objects.
190
- function describe(entry) {
191
- return {
192
- id: entry.id,
193
- meta: entry.meta,
194
- attached: entry.sockets.size,
195
- exitedAt: entry.exitedAt,
196
- exitCode: entry.exitCode,
197
- };
198
- }
199
-
200
- function list() {
201
- return Array.from(sessions.values()).map(describe);
202
- }
203
-
204
- function get(id) {
205
- const e = sessions.get(id);
206
- return e ? describe(e) : null;
207
- }
208
-
209
- function killAll() {
210
- for (const e of sessions.values()) {
211
- try { e.pty.kill(); } catch {}
212
- }
213
- }
214
-
215
- module.exports = {
216
- available: !!pty,
217
- loadError,
218
- spawn,
219
- attach,
220
- kill,
221
- list,
222
- get,
223
- killAll,
224
- };
1
+ 'use strict';
2
+
3
+ // ccsm in-process PTY pool. Used by the "web terminal" launch path:
4
+ // claude (or any cmd) runs as a child of ccsm and its stdio is bridged
5
+ // to one or more WebSocket clients via xterm.js in the browser.
6
+ //
7
+ // Lifecycle: a PTY entry is created by spawn(), broadcasts every output
8
+ // chunk to all attached sockets, keeps a rolling history ring so a fresh
9
+ // connection can replay recent output. attach() wires a websocket to an
10
+ // entry, kill() ends a PTY explicitly, list() returns metadata for UI.
11
+ //
12
+ // node-pty is optional (Windows native binary). If it failed to load,
13
+ // `available` is false and spawn() throws — server.js gates the
14
+ // /api/sessions/web route on this flag so install failures degrade
15
+ // gracefully to wt-only mode.
16
+
17
+ const path = require('node:path');
18
+
19
+ let pty = null;
20
+ let loadError = null;
21
+ try {
22
+ pty = require('node-pty');
23
+ } catch (e) {
24
+ loadError = e;
25
+ }
26
+
27
+ const HISTORY_BYTES = 256 * 1024;
28
+
29
+ // Map<id, { id, pty, history, sockets:Set<ws>, meta }>
30
+ const sessions = new Map();
31
+
32
+ function genId() {
33
+ return 'web-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
34
+ }
35
+
36
+
37
+
38
+ // Spawn a new PTY. `command` and `args` are passed straight to node-pty.
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.
45
+ // Throws if node-pty isn't available.
46
+ function spawn({ command, args = [], cwd, env, cols = 120, rows = 30, meta = {}, id, onData, onExit }) {
47
+ if (!pty) {
48
+ const err = new Error('node-pty is not available · ' + (loadError && loadError.message || 'unknown'));
49
+ err.code = 'PTY_UNAVAILABLE';
50
+ throw err;
51
+ }
52
+ const entryId = id || genId();
53
+ const ptyOpts = {
54
+ name: 'xterm-256color',
55
+ cols, rows,
56
+ cwd: cwd ? path.resolve(cwd) : process.cwd(),
57
+ env: { ...process.env, ...(env || {}) },
58
+ };
59
+ if (process.platform === 'win32') {
60
+ ptyOpts.useConpty = true;
61
+ ptyOpts.useConptyDll = true;
62
+ }
63
+ const proc = pty.spawn(command, args, ptyOpts);
64
+ const entry = {
65
+ id: entryId,
66
+ pty: proc,
67
+ history: '',
68
+ sockets: new Set(),
69
+ meta: { ...meta, startedAt: Date.now(), command, args, cwd: cwd || process.cwd(), pid: proc.pid },
70
+ exitCode: null,
71
+ exitedAt: null,
72
+ onDataExtra: onData,
73
+ onExitExtra: onExit,
74
+ };
75
+ proc.onData((data) => {
76
+ entry.history = (entry.history + data);
77
+ if (entry.history.length > HISTORY_BYTES) {
78
+ entry.history = entry.history.slice(-HISTORY_BYTES);
79
+ }
80
+ const frame = JSON.stringify({ type: 'output', data });
81
+ for (const ws of entry.sockets) {
82
+ try { ws.send(frame); } catch {}
83
+ }
84
+ if (entry.onDataExtra) { try { entry.onDataExtra(data); } catch {} }
85
+ });
86
+ proc.onExit(({ exitCode, signal }) => {
87
+ // If a respawn replaced us in the pool (same entryId, new entry
88
+ // object), do not touch persistedSessions or schedule a delete —
89
+ // those belong to the new entry now. Without this guard, a slow-
90
+ // dying old PTY would fire markExited on the same sessionId and
91
+ // clobber the new spawn's markRunning state.
92
+ if (sessions.get(entryId) !== entry) return;
93
+ entry.exitCode = exitCode;
94
+ entry.exitedAt = Date.now();
95
+ const frame = JSON.stringify({ type: 'exit', code: exitCode, signal });
96
+ for (const ws of entry.sockets) {
97
+ try { ws.send(frame); } catch {}
98
+ }
99
+ if (entry.onExitExtra) { try { entry.onExitExtra({ exitCode, signal }); } catch {} }
100
+ setTimeout(() => {
101
+ if (sessions.get(entryId) === entry) sessions.delete(entryId);
102
+ }, 30_000);
103
+ });
104
+ // If a previous entry exists under the same id (respawn), kill its
105
+ // pty so we don't have zombie claude.exe processes hanging on. The
106
+ // onExit guard above ensures its callback no-ops once we've taken
107
+ // over the slot.
108
+ const prev = sessions.get(entryId);
109
+ if (prev && !prev.exitedAt) {
110
+ try { prev.pty.kill(); } catch {}
111
+ }
112
+ sessions.set(entryId, entry);
113
+ return entry;
114
+ }
115
+
116
+ // Strip ANSI sequences from history that would cause spurious
117
+ // terminal-to-host responses if a fresh xterm.js re-parses the replay.
118
+ // Specifically: device-attribute / device-status queries (CSI c, CSI 0c,
119
+ // CSI >0c, CSI 5n, CSI 6n, …) — the original xterm already answered
120
+ // them, but on attach we replay everything; without scrubbing, the new
121
+ // xterm answers them too, the reply goes through our onData→PTY pipe,
122
+ // the CLI sees garbage bytes in its stdin, and echoes them back as
123
+ // visible junk like `[?12;2c`.
124
+ function scrubReplayResponses(history) {
125
+ return history
126
+ // CSI [ ? Ps c (primary DA query)
127
+ .replace(/\x1b\[[?>0-9]*c/g, '')
128
+ // CSI [ Ps n (device status / cursor position queries)
129
+ .replace(/\x1b\[[?>0-9;]*n/g, '');
130
+ }
131
+
132
+ // Wire a websocket to a session. Replays history immediately so the
133
+ // client sees recent context; then forwards input/resize messages from
134
+ // the client to the PTY and broadcast outputs back via onData above.
135
+ function attach(id, ws) {
136
+ const entry = sessions.get(id);
137
+ if (!entry) {
138
+ try { ws.close(4404, 'no such terminal'); } catch {}
139
+ return;
140
+ }
141
+ entry.sockets.add(ws);
142
+ if (entry.history) {
143
+ try { ws.send(JSON.stringify({ type: 'output', data: scrubReplayResponses(entry.history) })); } catch {}
144
+ }
145
+ if (entry.exitedAt) {
146
+ try { ws.send(JSON.stringify({ type: 'exit', code: entry.exitCode })); } catch {}
147
+ } else {
148
+ try { ws.send(JSON.stringify({ type: 'attached', meta: entry.meta })); } catch {}
149
+ }
150
+
151
+ ws.on('message', (msg) => {
152
+ let event;
153
+ try { event = JSON.parse(msg.toString()); } catch { return; }
154
+ if (entry.exitedAt) return; // PTY is dead, ignore further input
155
+ switch (event.type) {
156
+ case 'input':
157
+ if (typeof event.data === 'string') {
158
+ if (process.env.CCSM_DEBUG_PASTE === '1') {
159
+ const d = event.data;
160
+ const hex = Buffer.from(d, 'utf8').toString('hex').match(/.{1,2}/g).join(' ');
161
+ console.log(`[pty.write id=${id}] len=${d.length} hex=${hex.slice(0, 400)}${hex.length > 400 ? '...' : ''}`);
162
+ }
163
+ entry.pty.write(event.data);
164
+ }
165
+ break;
166
+ case 'resize':
167
+ if (Number(event.cols) > 0 && Number(event.rows) > 0) {
168
+ try { entry.pty.resize(Number(event.cols), Number(event.rows)); } catch {}
169
+ }
170
+ break;
171
+ case 'kill':
172
+ kill(id);
173
+ break;
174
+ }
175
+ });
176
+
177
+ ws.on('close', () => {
178
+ entry.sockets.delete(ws);
179
+ });
180
+ }
181
+
182
+ function kill(id) {
183
+ const entry = sessions.get(id);
184
+ if (!entry || entry.exitedAt) return false;
185
+ try { entry.pty.kill(); } catch {}
186
+ return true;
187
+ }
188
+
189
+ // Public summary for the frontend. Don't leak the pty / sockets objects.
190
+ function describe(entry) {
191
+ return {
192
+ id: entry.id,
193
+ meta: entry.meta,
194
+ attached: entry.sockets.size,
195
+ exitedAt: entry.exitedAt,
196
+ exitCode: entry.exitCode,
197
+ };
198
+ }
199
+
200
+ function list() {
201
+ return Array.from(sessions.values()).map(describe);
202
+ }
203
+
204
+ function get(id) {
205
+ const e = sessions.get(id);
206
+ return e ? describe(e) : null;
207
+ }
208
+
209
+ function killAll() {
210
+ for (const e of sessions.values()) {
211
+ try { e.pty.kill(); } catch {}
212
+ }
213
+ }
214
+
215
+ module.exports = {
216
+ available: !!pty,
217
+ loadError,
218
+ spawn,
219
+ attach,
220
+ kill,
221
+ list,
222
+ get,
223
+ killAll,
224
+ };