@bakapiano/ccsm 0.22.0 → 0.22.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 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.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",
@@ -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
  }
@@ -141,6 +141,14 @@ export const IconMoreVert = ic('0 0 24 24', html`
141
141
  <circle cx="12" cy="19" r="1.6" fill="currentColor" stroke="none"/>
142
142
  `, 16);
143
143
 
144
+ export const IconPlay = ic('0 0 24 24', html`
145
+ <polygon points="8 5 19 12 8 19 8 5"/>
146
+ `, 14);
147
+
148
+ export const IconStop = ic('0 0 24 24', html`
149
+ <rect x="7" y="7" width="10" height="10" rx="1.5"/>
150
+ `, 14);
151
+
144
152
  // Broadcast / remote — radiating arcs over a centre dot. Used on the
145
153
  // Remote nav tab; reads as "this machine is broadcasting" / "remote
146
154
  // access available".
@@ -7,13 +7,13 @@
7
7
  import { html } from '../html.js';
8
8
  import { useEffect, useRef, useState } from 'preact/hooks';
9
9
  import { activeSessionId, sessions, config, selectTab, selectSession, clockTick } from '../state.js';
10
- import { resumeSession, clearResumeFailure, deleteSession, setSessionTitle, openSessionInEditor } from '../api.js';
10
+ import { resumeSession, clearResumeFailure, deleteSession, setSessionTitle, switchSessionCli, stopSession, openSessionInEditor } from '../api.js';
11
11
  import { setToast } from '../toast.js';
12
12
  import { ccsmConfirm, ccsmPrompt } from '../dialog.js';
13
13
  import { TerminalView } from '../components/TerminalView.js';
14
14
  import { PageTitleBar } from '../components/PageTitleBar.js';
15
15
  import { Popover } from '../components/Popover.js';
16
- import { IconMoreVert, IconPencil, IconClose, IconPlus, IconForCliType, IconTerminal, IconExternal } from '../icons.js';
16
+ import { IconMoreVert, IconPencil, IconClose, IconPlus, IconForCliType, IconTerminal, IconExternal, IconPlay, IconStop } from '../icons.js';
17
17
  import { fmtAgo } from '../util.js';
18
18
 
19
19
  function SessionTabs({ activeId, onActivate, onNew, kebab }) {
@@ -51,7 +51,7 @@ function SessionTabs({ activeId, onActivate, onNew, kebab }) {
51
51
  </div>`;
52
52
  }
53
53
 
54
- function SessionMenu({ session, onRename, onDelete, onOpenEditor }) {
54
+ function SessionMenu({ session, switchableClis, onRename, onDelete, onOpenEditor, onSwitchCli }) {
55
55
  const [open, setOpen] = useState(false);
56
56
  const anchor = useRef(null);
57
57
  return html`
@@ -67,6 +67,18 @@ function SessionMenu({ session, onRename, onDelete, onOpenEditor }) {
67
67
  <button class="session-menu-item" onClick=${() => { setOpen(false); onOpenEditor(); }}>
68
68
  <${IconExternal} /> Open in editor
69
69
  </button>
70
+ ${switchableClis.length ? html`
71
+ <div class="session-menu-separator"></div>
72
+ <div class="session-menu-label">Switch CLI</div>
73
+ ${switchableClis.map((target) => {
74
+ const TargetIcon = IconForCliType(target.type) || IconTerminal;
75
+ return html`
76
+ <button class="session-menu-item" key=${target.id}
77
+ onClick=${() => { setOpen(false); onSwitchCli(target); }}>
78
+ <${TargetIcon} /> Switch to ${target.name}
79
+ </button>`;
80
+ })}
81
+ ` : null}
70
82
  <button class="session-menu-item" onClick=${() => { setOpen(false); onRename(); }}>
71
83
  <${IconPencil} /> Rename
72
84
  </button>
@@ -77,12 +89,35 @@ function SessionMenu({ session, onRename, onDelete, onOpenEditor }) {
77
89
  </${Popover}>` : null}`;
78
90
  }
79
91
 
92
+ function SessionControls({ running, busy, onStop, onResume }) {
93
+ return html`
94
+ <div class="session-controls">
95
+ ${running ? html`
96
+ <button class="session-menu-btn session-control-btn danger" type="button"
97
+ title="Stop session" aria-label="Stop session"
98
+ disabled=${busy}
99
+ onClick=${onStop}>
100
+ <${IconStop} />
101
+ </button>
102
+ ` : html`
103
+ <button class="session-menu-btn session-control-btn" type="button"
104
+ title=${busy ? 'Resuming session' : 'Resume session'}
105
+ aria-label=${busy ? 'Resuming session' : 'Resume session'}
106
+ disabled=${busy}
107
+ onClick=${onResume}>
108
+ <${IconPlay} />
109
+ </button>
110
+ `}
111
+ </div>`;
112
+ }
113
+
80
114
  export function SessionsPage() {
81
115
  clockTick.value; // resubscribe fmtAgo
82
116
  const id = activeSessionId.value;
83
117
  const list = sessions.value;
84
118
  const session = id ? list.find((s) => s.id === id) : null;
85
119
  const [resumeError, setResumeError] = useState(null);
120
+ const [actionBusy, setActionBusy] = useState(false);
86
121
  // Bumps to force the auto-resume effect to re-run on Retry without
87
122
  // mutating any signal. Primitive in the dep array → identity changes.
88
123
  const [retryNonce, setRetryNonce] = useState(0);
@@ -100,22 +135,50 @@ export function SessionsPage() {
100
135
  useEffect(() => {
101
136
  if (!session) return;
102
137
  if (session.status === 'running') { setResumeError(null); return; }
138
+ if (session.manualStopped) { setResumeError(null); return; }
103
139
  setResumeError(null);
104
140
  resumeSession(session.id)
105
141
  .then((launched) => { if (launched?.id) selectSession(launched.id); })
106
142
  .catch((e) => { setResumeError(e.message); setToast(e.message, 'error'); });
107
- }, [session?.id, session?.status, retryNonce]);
143
+ }, [session?.id, session?.status, session?.cliId, session?.manualStopped, retryNonce]);
108
144
 
109
145
  if (!session) return null;
110
146
 
111
147
  const cli = (config.value?.clis || []).find((c) => c.id === session.cliId);
148
+ const switchableClis = cli
149
+ ? (config.value?.clis || []).filter((c) => c.id !== cli.id && c.type === cli.type)
150
+ : [];
112
151
  const running = session.status === 'running';
113
152
  const title = session.title || session.workspace || session.id.slice(0, 12);
114
153
 
115
- const onRetry = () => {
154
+ const onResume = async () => {
116
155
  clearResumeFailure(session.id);
117
156
  setResumeError(null);
118
- setRetryNonce((n) => n + 1);
157
+ setActionBusy(true);
158
+ try {
159
+ const launched = await resumeSession(session.id);
160
+ if (launched?.id) selectSession(launched.id);
161
+ } catch (e) {
162
+ setResumeError(e.message);
163
+ setToast(e.message, 'error');
164
+ } finally {
165
+ setActionBusy(false);
166
+ }
167
+ };
168
+ const onRetry = () => {
169
+ onResume();
170
+ };
171
+ const onStop = async () => {
172
+ setActionBusy(true);
173
+ try {
174
+ await stopSession(session.id);
175
+ setResumeError(null);
176
+ setToast('Session stopped');
177
+ } catch (e) {
178
+ setToast(e.message, 'error');
179
+ } finally {
180
+ setActionBusy(false);
181
+ }
119
182
  };
120
183
  const onRename = async () => {
121
184
  const next = await ccsmPrompt('Rename session', title, { okLabel: 'Save' });
@@ -138,6 +201,26 @@ export function SessionsPage() {
138
201
  setToast(`Opening in ${r?.editor || 'editor'}…`);
139
202
  } catch (e) { setToast(e.message, 'error'); }
140
203
  };
204
+ const onSwitchCli = async (target) => {
205
+ const fromName = cli?.name || session.cliId;
206
+ if (running) {
207
+ const ok = await ccsmConfirm(
208
+ `Switch ${title} from ${fromName} to ${target.name}? The running terminal keeps its current process; ${target.name} is used next time this session resumes.`,
209
+ { title: 'Switch CLI', okLabel: 'Switch' },
210
+ );
211
+ if (!ok) return;
212
+ }
213
+ try {
214
+ const r = await switchSessionCli(session.id, target.id);
215
+ setToast(r.running
216
+ ? `CLI switched to ${target.name} for next resume`
217
+ : `CLI switched to ${target.name}`);
218
+ if (!running && !session.manualStopped) {
219
+ clearResumeFailure(session.id);
220
+ setRetryNonce((n) => n + 1);
221
+ }
222
+ } catch (e) { setToast(e.message, 'error'); }
223
+ };
141
224
 
142
225
  return html`
143
226
  <${PageTitleBar} title=${html`
@@ -148,14 +231,24 @@ export function SessionsPage() {
148
231
  <span>${cli ? cli.name : session.cliId}</span>
149
232
  ${session.repos.length ? html`<span>·</span><span>${session.repos.join(', ')}</span>` : null}
150
233
  <span>·</span>
151
- <span>${running ? 'running' : (resumeError ? 'resume failed' : 'resuming…')}</span>
234
+ <span>${running ? 'running' : (resumeError ? 'resume failed' : (session.manualStopped ? 'stopped' : 'resuming…'))}</span>
152
235
  </span>
153
236
  `} />
154
237
  <${SessionTabs}
155
238
  activeId=${session.id}
156
239
  onActivate=${(sid) => selectSession(sid)}
157
240
  onNew=${() => selectTab('launch')}
158
- kebab=${html`<${SessionMenu} session=${session} onRename=${onRename} onDelete=${onDelete} onOpenEditor=${onOpenEditor} />`} />
241
+ kebab=${html`
242
+ <${SessionControls} running=${running}
243
+ busy=${actionBusy}
244
+ onStop=${onStop}
245
+ onResume=${onResume} />
246
+ <${SessionMenu} session=${session}
247
+ switchableClis=${switchableClis}
248
+ onRename=${onRename}
249
+ onDelete=${onDelete}
250
+ onOpenEditor=${onOpenEditor}
251
+ onSwitchCli=${onSwitchCli} />`} />
159
252
  <div class="session-pane">
160
253
  <div class="session-pane-body">
161
254
  ${running
@@ -165,6 +258,11 @@ export function SessionsPage() {
165
258
  ${resumeError ? html`
166
259
  <div>Failed to resume: <span class="mono">${resumeError}</span></div>
167
260
  <button class="action primary" onClick=${onRetry}>Retry</button>
261
+ ` : session.manualStopped ? html`
262
+ <div>Session stopped</div>
263
+ <button class="action primary" onClick=${onResume} disabled=${actionBusy}>
264
+ ${actionBusy ? 'Resuming…' : 'Resume'}
265
+ </button>
168
266
  ` : html`
169
267
  <div>Resuming session…</div>
170
268
  `}
package/server.js CHANGED
@@ -217,6 +217,10 @@ function pickCli(cfg, requestedId) {
217
217
  return cfg.clis.find((c) => c.id === wanted) || cfg.clis[0];
218
218
  }
219
219
 
220
+ function findCliById(cfg, id) {
221
+ return (cfg.clis || []).find((c) => c.id === id) || null;
222
+ }
223
+
220
224
  // Resolve how to spawn a CLI command. Windows quirks:
221
225
  // v1.1 — spawn strategy is now caller-controlled via cli.shell:
222
226
  // 'direct' — pty.spawn(command, args). Real .exe / absolute paths only.
@@ -639,6 +643,64 @@ app.put('/api/sessions/:id', asyncH(async (req, res) => {
639
643
  res.json({ session: updated });
640
644
  }));
641
645
 
646
+ // Switch the CLI config used to resume an existing session. This is
647
+ // intentionally narrower than the generic PUT route: a session can only
648
+ // move between configured CLIs of the same type (e.g. one claude wrapper
649
+ // to another) so its captured upstream cliSessionId stays meaningful.
650
+ app.post('/api/sessions/:id/switch-cli', asyncH(async (req, res) => {
651
+ const targetCliId = typeof req.body?.cliId === 'string' ? req.body.cliId.trim() : '';
652
+ if (!targetCliId) return res.status(400).json({ error: 'cliId required' });
653
+
654
+ const record = await persistedSessions.get(req.params.id);
655
+ if (!record) return res.status(404).json({ error: 'session not found' });
656
+
657
+ const cfg = await loadConfig();
658
+ const currentCli = findCliById(cfg, record.cliId);
659
+ const targetCli = findCliById(cfg, targetCliId);
660
+ if (!currentCli) return res.status(400).json({ error: `current CLI ${record.cliId} no longer configured` });
661
+ if (!targetCli) return res.status(400).json({ error: `target CLI ${targetCliId} not configured` });
662
+ if (currentCli.type !== targetCli.type) {
663
+ return res.status(400).json({
664
+ error: `cannot switch ${currentCli.type} session to ${targetCli.type} CLI`,
665
+ });
666
+ }
667
+
668
+ if (record.cliId === targetCli.id) {
669
+ const live = webTerminal.get(record.id);
670
+ return res.json({ session: record, changed: false, running: !!(live && !live.exitedAt) });
671
+ }
672
+
673
+ const updated = await persistedSessions.update(record.id, { cliId: targetCli.id });
674
+ const live = webTerminal.get(record.id);
675
+ res.json({
676
+ session: updated,
677
+ changed: true,
678
+ running: !!(live && !live.exitedAt),
679
+ fromCliId: currentCli.id,
680
+ toCliId: targetCli.id,
681
+ cliType: targetCli.type,
682
+ });
683
+ }));
684
+
685
+ // Stop the live PTY for a session without deleting its persisted record.
686
+ // Unlike a natural CLI exit, this is a user intent signal: the frontend
687
+ // should not auto-resume it again until the user explicitly presses Resume.
688
+ app.post('/api/sessions/:id/stop', asyncH(async (req, res) => {
689
+ const record = await persistedSessions.get(req.params.id);
690
+ if (!record) return res.status(404).json({ error: 'session not found' });
691
+ const stopped = webTerminal.kill(record.id);
692
+ const updated = await persistedSessions.update(record.id, {
693
+ status: 'exited',
694
+ pid: null,
695
+ exitCode: null,
696
+ exitedAt: Date.now(),
697
+ manualStopped: true,
698
+ lastActiveAt: Date.now(),
699
+ });
700
+ try { require('./lib/cliActivity').releaseSession(record.id); } catch {}
701
+ res.json({ stopped, session: updated });
702
+ }));
703
+
642
704
  app.delete('/api/sessions/:id', asyncH(async (req, res) => {
643
705
  // Kill PTY first if it's still alive, then drop the record.
644
706
  try { webTerminal.kill(req.params.id); } catch {}