@bakapiano/ccsm 0.22.0 → 0.22.2

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 CHANGED
@@ -311,6 +311,8 @@ allows `https://bakapiano.github.io` only — never `*`.
311
311
  | GET | `/api/sessions` | list persisted sessions |
312
312
  | PUT | `/api/sessions/:id` | rename / move to folder |
313
313
  | DELETE | `/api/sessions/:id` | kill PTY + drop record |
314
+ | POST | `/api/sessions/:id/switch-cli` | change the persisted `cliId` for future resumes; current and target CLI must share `type` |
315
+ | POST | `/api/sessions/:id/stop` | kill the live PTY but keep the record; sets `manualStopped:true` so UI won't auto-resume |
314
316
  | POST | `/api/sessions/new` | body `{cliId, cwd?, repos?, folderId?, title?}` — NDJSON stream (workspace · clone-progress · launched) |
315
317
  | POST | `/api/sessions/:id/resume` | re-spawn at `cwd` with `cli.resumeIdArgs <id>` (fallback `resumeArgs`) |
316
318
  | GET | `/api/cli-sessions/:type` | scan disk for unimported `claude`/`codex`/`copilot` sessions |
@@ -26,6 +26,9 @@
26
26
  // // newSessionIdArgs (claude, copilot); set
27
27
  // // from disk for adopted sessions. Used
28
28
  // // for precise --resume <id>.
29
+ // manualStopped: false, // true only when the user explicitly stopped
30
+ // // it from ccsm; prevents auto-resume until
31
+ // // they press Resume.
29
32
  // }
30
33
 
31
34
  const path = require('node:path');
@@ -73,6 +76,7 @@ async function create(opts) {
73
76
  exitCode: null,
74
77
  pid: null,
75
78
  cliSessionId,
79
+ manualStopped: false,
76
80
  };
77
81
  list.push(entry);
78
82
  await saveAll(list);
@@ -110,7 +114,7 @@ async function remove(id) {
110
114
  // Convenience helpers used at runtime so callers don't have to do
111
115
  // load/find/update/save themselves.
112
116
  async function markRunning(id, pid) {
113
- return update(id, { status: 'running', pid, exitedAt: null, exitCode: null, lastActiveAt: Date.now() });
117
+ return update(id, { status: 'running', pid, exitedAt: null, exitCode: null, manualStopped: false, lastActiveAt: Date.now() });
114
118
  }
115
119
 
116
120
  async function markExited(id, exitCode) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakapiano/ccsm",
3
- "version": "0.22.0",
3
+ "version": "0.22.2",
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",
@@ -282,6 +282,7 @@
282
282
  display: flex;
283
283
  align-items: center;
284
284
  flex-shrink: 0;
285
+ gap: 4px;
285
286
  padding-right: 2px;
286
287
  }
287
288
  /* Close the gap to the page-title-bar above — only when there IS one.
@@ -335,6 +336,27 @@
335
336
  .session-tab-add:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
336
337
  .session-tab-add svg { width: 14px; height: 14px; }
337
338
 
339
+ .session-controls {
340
+ display: inline-flex;
341
+ align-items: center;
342
+ flex-shrink: 0;
343
+ }
344
+ .session-control-btn.danger {
345
+ color: #f4b8b8;
346
+ }
347
+ .session-control-btn.danger:hover:not(:disabled) {
348
+ color: #ffd1d1;
349
+ background: rgba(183, 63, 63, 0.22);
350
+ }
351
+ .session-control-btn:disabled {
352
+ opacity: .55;
353
+ cursor: wait;
354
+ }
355
+ .session-control-btn svg {
356
+ width: 13px;
357
+ height: 13px;
358
+ }
359
+
338
360
  /* Kebab in the page-title-bar (top-right). Compact 24px square so it
339
361
  doesn't dominate the masthead. In WCO mode the title-bar already
340
362
  reserves padding-right for OS controls, so this slides cleanly to
@@ -359,7 +381,8 @@
359
381
  }
360
382
  /* Neutral-grey hover tint works on either strip colour (darkens the light
361
383
  one, lightens the dark one) without needing a per-theme override. */
362
- .session-menu-btn:hover { background: rgba(128, 128, 128, 0.2); color: var(--term-on); }
384
+ .session-menu-btn:hover:not(:disabled) { background: rgba(128, 128, 128, 0.2); color: var(--term-on); }
385
+ .session-menu-btn:disabled { opacity: .55; cursor: wait; }
363
386
  .session-menu-btn svg { width: 16px; height: 16px; }
364
387
 
365
388
  .session-menu {
@@ -391,6 +414,20 @@
391
414
  .session-menu-item.danger { color: var(--danger, #b73f3f); }
392
415
  .session-menu-item.danger:hover { background: rgba(183, 63, 63, 0.08); }
393
416
  .session-menu-item svg { width: 14px; height: 14px; }
417
+ .session-menu-item img { width: 14px; height: 14px; }
418
+ .session-menu-separator {
419
+ height: 1px;
420
+ background: var(--border);
421
+ margin: 3px 2px;
422
+ }
423
+ .session-menu-label {
424
+ padding: 4px 10px 2px;
425
+ font-size: 11px;
426
+ line-height: 1.2;
427
+ color: var(--ink-muted);
428
+ text-transform: uppercase;
429
+ letter-spacing: 0;
430
+ }
394
431
 
395
432
  .session-pane-head {
396
433
  display: flex;
package/public/js/api.js CHANGED
@@ -254,6 +254,20 @@ export async function setSessionTitle(sessionId, title) {
254
254
  await loadSessions();
255
255
  }
256
256
 
257
+ export async function switchSessionCli(sessionId, cliId) {
258
+ const r = await api('POST', `/api/sessions/${sessionId}/switch-cli`, { cliId });
259
+ resumeFailed.delete(sessionId);
260
+ await loadSessions();
261
+ return r;
262
+ }
263
+
264
+ export async function stopSession(sessionId) {
265
+ const r = await api('POST', `/api/sessions/${sessionId}/stop`);
266
+ resumeFailed.delete(sessionId);
267
+ await loadSessions();
268
+ return r.session;
269
+ }
270
+
257
271
  export async function deleteSession(sessionId) {
258
272
  await api('DELETE', `/api/sessions/${sessionId}`);
259
273
  await loadSessions();
@@ -54,10 +54,9 @@ function SessionRow({ s, folderId, siblingIds }) {
54
54
  const onClick = async (ev) => {
55
55
  ev.preventDefault();
56
56
  selectSession(s.id);
57
- // Auto-resume on click if the session is stopped saves the user
58
- // from a second click on the "Resume" button in the right pane.
59
- // No-op if already running.
60
- if (s.status !== 'running') {
57
+ // Auto-resume on click if the session stopped on its own. Explicitly
58
+ // stopped sessions stay stopped until the user presses Resume.
59
+ if (s.status !== 'running' && !s.manualStopped) {
61
60
  try { await resumeSession(s.id); }
62
61
  catch (e) { setToast(e.message, 'error'); }
63
62
  }
@@ -0,0 +1,266 @@
1
+ // VS Code-style terminal instance lifecycle for a single ccsm session.
2
+ // Owns attach/detach, WebSocket transport, xterm input/output forwarding,
3
+ // resize propagation, paste handling, and browser/mobile lifecycle hooks.
4
+
5
+ import { wsBase, getToken, getDeviceId } from '../backend.js';
6
+ import { XtermTerminal } from './XtermTerminal.js';
7
+
8
+ export class TerminalInstance {
9
+ constructor({ terminalId, cliType, onDisplaced }) {
10
+ this.terminalId = terminalId;
11
+ this.cliType = cliType;
12
+ this.onDisplaced = onDisplaced;
13
+ this.xterm = new XtermTerminal();
14
+ this.ws = null;
15
+ this.host = null;
16
+ this.closedByUs = false;
17
+ this.reconnectTimer = null;
18
+ this.attempts = 0;
19
+ this.everOpened = false;
20
+ this.disposables = [];
21
+ this.helperTextarea = null;
22
+ }
23
+
24
+ attachToElement(host) {
25
+ this.host = host;
26
+ this.xterm.attachToElement(host);
27
+ this._registerColorOscHandlers();
28
+ this._connect();
29
+ this._wireXtermEvents();
30
+ this._wireDomLifecycle();
31
+ this.xterm.focus();
32
+ }
33
+
34
+ sendInput(data) {
35
+ this._sendFrame({ type: 'input', data });
36
+ }
37
+
38
+ setCliType(cliType) {
39
+ this.cliType = cliType;
40
+ }
41
+
42
+ applyTheme() {
43
+ this.xterm.applyResolvedTheme();
44
+ }
45
+
46
+ dispose() {
47
+ this.closedByUs = true;
48
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
49
+ for (const dispose of this.disposables.splice(0)) {
50
+ try { dispose(); } catch {}
51
+ }
52
+ try { this.ws?.close(); } catch {}
53
+ this.ws = null;
54
+ this.helperTextarea = null;
55
+ this.xterm.dispose();
56
+ }
57
+
58
+ _connect() {
59
+ const ws = new WebSocket(this._wsUrl());
60
+ ws.binaryType = 'arraybuffer';
61
+ this.ws = ws;
62
+
63
+ ws.onopen = () => {
64
+ if (this.everOpened) {
65
+ this.xterm.reset();
66
+ }
67
+ this.everOpened = true;
68
+ this.attempts = 0;
69
+ this.xterm.scheduleFit();
70
+ this._sendFrame({ type: 'resize', cols: this.xterm.cols, rows: this.xterm.rows });
71
+ };
72
+ ws.onmessage = (ev) => {
73
+ let frame;
74
+ try { frame = JSON.parse(ev.data); } catch { return; }
75
+ if (frame.type === 'output') {
76
+ this.xterm.write(frame.data);
77
+ } else if (frame.type === 'exit') {
78
+ this.xterm.write(`\r\n\x1b[2m[process exited · code ${frame.code}]\x1b[0m\r\n`);
79
+ }
80
+ };
81
+ ws.onclose = (ev) => this._handleClose(ev);
82
+ }
83
+
84
+ _handleClose(ev) {
85
+ if (this.closedByUs) return;
86
+ if (ev && ev.code === 4001) {
87
+ this.onDisplaced?.();
88
+ return;
89
+ }
90
+ if (ev && ev.code === 4404) {
91
+ this.xterm.write('\r\n\x1b[2m[session ended]\x1b[0m\r\n');
92
+ return;
93
+ }
94
+ this.attempts++;
95
+ const delay = Math.min(8000, 500 * 2 ** Math.min(this.attempts - 1, 4));
96
+ this.xterm.write('\r\n\x1b[2m[disconnected · reconnecting…]\x1b[0m\r\n');
97
+ this.reconnectTimer = setTimeout(() => {
98
+ if (!this.closedByUs) this._connect();
99
+ }, delay);
100
+ }
101
+
102
+ _wireXtermEvents() {
103
+ const dataDisposable = this.xterm.onData((data) => {
104
+ this._sendFrame({ type: 'input', data });
105
+ });
106
+ const resizeDisposable = this.xterm.onResize(({ cols, rows }) => {
107
+ this._sendFrame({ type: 'resize', cols, rows });
108
+ });
109
+ this.disposables.push(
110
+ () => dataDisposable.dispose(),
111
+ () => resizeDisposable.dispose(),
112
+ );
113
+ }
114
+
115
+ _wireDomLifecycle() {
116
+ const host = this.host;
117
+ const ro = new ResizeObserver(() => this.xterm.fit());
118
+ ro.observe(host);
119
+ this.disposables.push(() => ro.disconnect());
120
+
121
+ const vv = window.visualViewport;
122
+ const onVisualResize = () => this.xterm.scheduleFit();
123
+ vv?.addEventListener?.('resize', onVisualResize);
124
+ vv?.addEventListener?.('scroll', onVisualResize);
125
+ this.disposables.push(() => {
126
+ vv?.removeEventListener?.('resize', onVisualResize);
127
+ vv?.removeEventListener?.('scroll', onVisualResize);
128
+ });
129
+
130
+ const onHostClick = () => this.xterm.focus();
131
+ if (this.xterm.isMobile) {
132
+ host.addEventListener('click', onHostClick);
133
+ this.disposables.push(() => host.removeEventListener('click', onHostClick));
134
+ }
135
+
136
+ this._wireTabVisibilityRefresh(host);
137
+ this._wirePasteHandlers(host);
138
+ this._wireModifiedEnterHandler(host);
139
+ this._wireCompositionHandlers();
140
+ }
141
+
142
+ _wireTabVisibilityRefresh(host) {
143
+ const panel = host.closest('.tab-panel');
144
+ if (!panel) return;
145
+ const panelMo = new MutationObserver(() => {
146
+ if (panel.hasAttribute('data-active')) {
147
+ requestAnimationFrame(() => {
148
+ this.xterm.clearTextureAtlas();
149
+ this.xterm.scheduleFit();
150
+ this.xterm.refresh();
151
+ });
152
+ }
153
+ });
154
+ panelMo.observe(panel, { attributes: true, attributeFilter: ['data-active'] });
155
+ this.disposables.push(() => panelMo.disconnect());
156
+ }
157
+
158
+ _wirePasteHandlers(host) {
159
+ const isOurs = () => {
160
+ const ae = document.activeElement;
161
+ return ae && host.contains(ae);
162
+ };
163
+ const doPaste = (text) => {
164
+ if (!text) return;
165
+ const normalized = text.replace(/\r?\n/g, '\r');
166
+ this.sendInput(`\x1b[200~${normalized}\x1b[201~`);
167
+ };
168
+ const onPaste = async (ev) => {
169
+ if (!isOurs()) return;
170
+ let text = '';
171
+ if (ev.clipboardData) text = ev.clipboardData.getData('text');
172
+ if (!text && navigator.clipboard) {
173
+ try { text = await navigator.clipboard.readText(); } catch {}
174
+ }
175
+ if (!text) return;
176
+ ev.preventDefault();
177
+ ev.stopPropagation();
178
+ doPaste(text);
179
+ };
180
+ const onKey = (ev) => {
181
+ const meta = ev.ctrlKey || ev.metaKey;
182
+ if (!meta || ev.key.toLowerCase() !== 'v') return;
183
+ if (ev.shiftKey || ev.altKey) return;
184
+ if (!isOurs()) return;
185
+ if (!navigator.clipboard?.readText) return;
186
+ ev.preventDefault();
187
+ ev.stopPropagation();
188
+ ev.stopImmediatePropagation();
189
+ navigator.clipboard.readText().then((text) => {
190
+ if (text) doPaste(text);
191
+ }).catch(() => {});
192
+ };
193
+ document.addEventListener('paste', onPaste, true);
194
+ document.addEventListener('keydown', onKey, true);
195
+ this.disposables.push(
196
+ () => document.removeEventListener('paste', onPaste, true),
197
+ () => document.removeEventListener('keydown', onKey, true),
198
+ );
199
+ }
200
+
201
+ _wireModifiedEnterHandler(host) {
202
+ const isOurs = () => {
203
+ const ae = document.activeElement;
204
+ return ae && host.contains(ae);
205
+ };
206
+ const onShiftEnter = (ev) => {
207
+ if (ev.key !== 'Enter') return;
208
+ if (!(ev.shiftKey || ev.ctrlKey)) return;
209
+ if (ev.metaKey || ev.altKey) return;
210
+ if (!isOurs()) return;
211
+ const data = this.cliType === 'claude' ? '\n' : '\x1b\r';
212
+ ev.preventDefault();
213
+ ev.stopPropagation();
214
+ ev.stopImmediatePropagation();
215
+ this.sendInput(data);
216
+ };
217
+ document.addEventListener('keydown', onShiftEnter, true);
218
+ this.disposables.push(() => document.removeEventListener('keydown', onShiftEnter, true));
219
+ }
220
+
221
+ _wireCompositionHandlers() {
222
+ const helper = this.xterm.helperTextarea;
223
+ this.helperTextarea = helper;
224
+ if (!helper) return;
225
+ const onCompStart = () => this.xterm.setCursorVisible(false);
226
+ const onCompEnd = () => this.xterm.setCursorVisible(true);
227
+ helper.addEventListener('compositionstart', onCompStart);
228
+ helper.addEventListener('compositionend', onCompEnd);
229
+ this.disposables.push(() => {
230
+ helper.removeEventListener('compositionstart', onCompStart);
231
+ helper.removeEventListener('compositionend', onCompEnd);
232
+ });
233
+ }
234
+
235
+ _registerColorOscHandlers() {
236
+ const answerColorOsc = (code, getHex) => (data) => {
237
+ if (data !== '?') return false;
238
+ const hex = getHex();
239
+ const ch = (i) => parseInt(hex.slice(i, i + 2), 16);
240
+ const w = (v) => (v * 257).toString(16).padStart(4, '0');
241
+ const reply = `\x1b]${code};rgb:${w(ch(1))}/${w(ch(3))}/${w(ch(5))}\x07`;
242
+ this.sendInput(reply);
243
+ return true;
244
+ };
245
+ try {
246
+ this.xterm.parser.registerOscHandler(11, answerColorOsc(11, () => this.xterm.theme.background));
247
+ this.xterm.parser.registerOscHandler(10, answerColorOsc(10, () => this.xterm.theme.foreground));
248
+ } catch {}
249
+ }
250
+
251
+ _sendFrame(frame) {
252
+ if (this.ws && this.ws.readyState === 1) {
253
+ this.ws.send(JSON.stringify(frame));
254
+ }
255
+ }
256
+
257
+ _wsUrl() {
258
+ const tok = getToken();
259
+ const dev = getDeviceId();
260
+ const params = new URLSearchParams();
261
+ if (tok) params.set('token', tok);
262
+ if (dev) params.set('device', dev);
263
+ const qs = params.toString();
264
+ return `${wsBase()}/ws/terminal/${encodeURIComponent(this.terminalId)}${qs ? `?${qs}` : ''}`;
265
+ }
266
+ }