@bakapiano/ccsm 0.10.3 → 0.11.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 (48) hide show
  1. package/CLAUDE.md +475 -475
  2. package/README.md +190 -190
  3. package/bin/ccsm.js +194 -194
  4. package/lib/cliSessionWatcher.js +249 -249
  5. package/lib/config.js +185 -185
  6. package/lib/folders.js +96 -96
  7. package/lib/localCliSessions.js +489 -177
  8. package/lib/persistedSessions.js +134 -134
  9. package/lib/webTerminal.js +208 -208
  10. package/lib/workspace.js +230 -255
  11. package/package.json +57 -57
  12. package/public/css/base.css +99 -99
  13. package/public/css/cards.css +183 -183
  14. package/public/css/feedback.css +303 -303
  15. package/public/css/forms.css +405 -405
  16. package/public/css/layout.css +160 -160
  17. package/public/css/modal.css +190 -183
  18. package/public/css/responsive.css +10 -10
  19. package/public/css/sidebar.css +616 -601
  20. package/public/css/terminals.css +294 -294
  21. package/public/css/tokens.css +81 -79
  22. package/public/css/wco.css +98 -98
  23. package/public/css/widgets.css +1596 -1375
  24. package/public/index.html +105 -103
  25. package/public/js/api.js +272 -260
  26. package/public/js/components/AdoptModal.js +343 -171
  27. package/public/js/components/App.js +35 -35
  28. package/public/js/components/DirectoryPicker.js +203 -203
  29. package/public/js/components/EntityFormModal.js +105 -105
  30. package/public/js/components/Modal.js +51 -51
  31. package/public/js/components/OfflineBanner.js +93 -93
  32. package/public/js/components/PageTitleBar.js +13 -13
  33. package/public/js/components/Picker.js +179 -179
  34. package/public/js/components/Popover.js +55 -55
  35. package/public/js/components/Sidebar.js +270 -270
  36. package/public/js/components/TerminalView.js +298 -298
  37. package/public/js/components/useDragSort.js +67 -67
  38. package/public/js/dialog.js +67 -67
  39. package/public/js/icons.js +177 -177
  40. package/public/js/main.js +140 -140
  41. package/public/js/pages/AboutPage.js +165 -165
  42. package/public/js/pages/ConfigurePage.js +475 -487
  43. package/public/js/pages/LaunchPage.js +369 -369
  44. package/public/js/pages/SessionsPage.js +97 -97
  45. package/public/js/state.js +231 -231
  46. package/public/manifest.webmanifest +15 -15
  47. package/scripts/install.js +137 -137
  48. package/server.js +1126 -1117
@@ -1,208 +1,208 @@
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
- entry.exitCode = exitCode;
88
- entry.exitedAt = Date.now();
89
- const frame = JSON.stringify({ type: 'exit', code: exitCode, signal });
90
- for (const ws of entry.sockets) {
91
- try { ws.send(frame); } catch {}
92
- }
93
- if (entry.onExitExtra) { try { entry.onExitExtra({ exitCode, signal }); } catch {} }
94
- setTimeout(() => sessions.delete(entryId), 30_000);
95
- });
96
- sessions.set(entryId, entry);
97
- return entry;
98
- }
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
-
116
- // Wire a websocket to a session. Replays history immediately so the
117
- // client sees recent context; then forwards input/resize messages from
118
- // the client to the PTY and broadcast outputs back via onData above.
119
- function attach(id, ws) {
120
- const entry = sessions.get(id);
121
- if (!entry) {
122
- try { ws.close(4404, 'no such terminal'); } catch {}
123
- return;
124
- }
125
- entry.sockets.add(ws);
126
- if (entry.history) {
127
- try { ws.send(JSON.stringify({ type: 'output', data: scrubReplayResponses(entry.history) })); } catch {}
128
- }
129
- if (entry.exitedAt) {
130
- try { ws.send(JSON.stringify({ type: 'exit', code: entry.exitCode })); } catch {}
131
- } else {
132
- try { ws.send(JSON.stringify({ type: 'attached', meta: entry.meta })); } catch {}
133
- }
134
-
135
- ws.on('message', (msg) => {
136
- let event;
137
- try { event = JSON.parse(msg.toString()); } catch { return; }
138
- if (entry.exitedAt) return; // PTY is dead, ignore further input
139
- switch (event.type) {
140
- case 'input':
141
- if (typeof event.data === 'string') {
142
- if (process.env.CCSM_DEBUG_PASTE === '1') {
143
- const d = event.data;
144
- const hex = Buffer.from(d, 'utf8').toString('hex').match(/.{1,2}/g).join(' ');
145
- console.log(`[pty.write id=${id}] len=${d.length} hex=${hex.slice(0, 400)}${hex.length > 400 ? '...' : ''}`);
146
- }
147
- entry.pty.write(event.data);
148
- }
149
- break;
150
- case 'resize':
151
- if (Number(event.cols) > 0 && Number(event.rows) > 0) {
152
- try { entry.pty.resize(Number(event.cols), Number(event.rows)); } catch {}
153
- }
154
- break;
155
- case 'kill':
156
- kill(id);
157
- break;
158
- }
159
- });
160
-
161
- ws.on('close', () => {
162
- entry.sockets.delete(ws);
163
- });
164
- }
165
-
166
- function kill(id) {
167
- const entry = sessions.get(id);
168
- if (!entry || entry.exitedAt) return false;
169
- try { entry.pty.kill(); } catch {}
170
- return true;
171
- }
172
-
173
- // Public summary for the frontend. Don't leak the pty / sockets objects.
174
- function describe(entry) {
175
- return {
176
- id: entry.id,
177
- meta: entry.meta,
178
- attached: entry.sockets.size,
179
- exitedAt: entry.exitedAt,
180
- exitCode: entry.exitCode,
181
- };
182
- }
183
-
184
- function list() {
185
- return Array.from(sessions.values()).map(describe);
186
- }
187
-
188
- function get(id) {
189
- const e = sessions.get(id);
190
- return e ? describe(e) : null;
191
- }
192
-
193
- function killAll() {
194
- for (const e of sessions.values()) {
195
- try { e.pty.kill(); } catch {}
196
- }
197
- }
198
-
199
- module.exports = {
200
- available: !!pty,
201
- loadError,
202
- spawn,
203
- attach,
204
- kill,
205
- list,
206
- get,
207
- killAll,
208
- };
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
+ entry.exitCode = exitCode;
88
+ entry.exitedAt = Date.now();
89
+ const frame = JSON.stringify({ type: 'exit', code: exitCode, signal });
90
+ for (const ws of entry.sockets) {
91
+ try { ws.send(frame); } catch {}
92
+ }
93
+ if (entry.onExitExtra) { try { entry.onExitExtra({ exitCode, signal }); } catch {} }
94
+ setTimeout(() => sessions.delete(entryId), 30_000);
95
+ });
96
+ sessions.set(entryId, entry);
97
+ return entry;
98
+ }
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
+
116
+ // Wire a websocket to a session. Replays history immediately so the
117
+ // client sees recent context; then forwards input/resize messages from
118
+ // the client to the PTY and broadcast outputs back via onData above.
119
+ function attach(id, ws) {
120
+ const entry = sessions.get(id);
121
+ if (!entry) {
122
+ try { ws.close(4404, 'no such terminal'); } catch {}
123
+ return;
124
+ }
125
+ entry.sockets.add(ws);
126
+ if (entry.history) {
127
+ try { ws.send(JSON.stringify({ type: 'output', data: scrubReplayResponses(entry.history) })); } catch {}
128
+ }
129
+ if (entry.exitedAt) {
130
+ try { ws.send(JSON.stringify({ type: 'exit', code: entry.exitCode })); } catch {}
131
+ } else {
132
+ try { ws.send(JSON.stringify({ type: 'attached', meta: entry.meta })); } catch {}
133
+ }
134
+
135
+ ws.on('message', (msg) => {
136
+ let event;
137
+ try { event = JSON.parse(msg.toString()); } catch { return; }
138
+ if (entry.exitedAt) return; // PTY is dead, ignore further input
139
+ switch (event.type) {
140
+ case 'input':
141
+ if (typeof event.data === 'string') {
142
+ if (process.env.CCSM_DEBUG_PASTE === '1') {
143
+ const d = event.data;
144
+ const hex = Buffer.from(d, 'utf8').toString('hex').match(/.{1,2}/g).join(' ');
145
+ console.log(`[pty.write id=${id}] len=${d.length} hex=${hex.slice(0, 400)}${hex.length > 400 ? '...' : ''}`);
146
+ }
147
+ entry.pty.write(event.data);
148
+ }
149
+ break;
150
+ case 'resize':
151
+ if (Number(event.cols) > 0 && Number(event.rows) > 0) {
152
+ try { entry.pty.resize(Number(event.cols), Number(event.rows)); } catch {}
153
+ }
154
+ break;
155
+ case 'kill':
156
+ kill(id);
157
+ break;
158
+ }
159
+ });
160
+
161
+ ws.on('close', () => {
162
+ entry.sockets.delete(ws);
163
+ });
164
+ }
165
+
166
+ function kill(id) {
167
+ const entry = sessions.get(id);
168
+ if (!entry || entry.exitedAt) return false;
169
+ try { entry.pty.kill(); } catch {}
170
+ return true;
171
+ }
172
+
173
+ // Public summary for the frontend. Don't leak the pty / sockets objects.
174
+ function describe(entry) {
175
+ return {
176
+ id: entry.id,
177
+ meta: entry.meta,
178
+ attached: entry.sockets.size,
179
+ exitedAt: entry.exitedAt,
180
+ exitCode: entry.exitCode,
181
+ };
182
+ }
183
+
184
+ function list() {
185
+ return Array.from(sessions.values()).map(describe);
186
+ }
187
+
188
+ function get(id) {
189
+ const e = sessions.get(id);
190
+ return e ? describe(e) : null;
191
+ }
192
+
193
+ function killAll() {
194
+ for (const e of sessions.values()) {
195
+ try { e.pty.kill(); } catch {}
196
+ }
197
+ }
198
+
199
+ module.exports = {
200
+ available: !!pty,
201
+ loadError,
202
+ spawn,
203
+ attach,
204
+ kill,
205
+ list,
206
+ get,
207
+ killAll,
208
+ };