@bakapiano/ccsm 0.22.2 → 0.22.4

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 (60) hide show
  1. package/CLAUDE.md +538 -538
  2. package/README.md +189 -189
  3. package/bin/ccsm.js +235 -235
  4. package/lib/cliActivity.js +139 -139
  5. package/lib/codexSeed.js +183 -183
  6. package/lib/config.js +274 -274
  7. package/lib/devices.js +229 -229
  8. package/lib/folders.js +124 -124
  9. package/lib/localCliSessions.js +519 -519
  10. package/lib/persistedSessions.js +129 -129
  11. package/lib/tunnel.js +621 -621
  12. package/lib/webTerminal.js +233 -231
  13. package/lib/workspace.js +233 -233
  14. package/package.json +57 -57
  15. package/public/css/base.css +99 -99
  16. package/public/css/cards.css +183 -183
  17. package/public/css/feedback.css +504 -504
  18. package/public/css/forms.css +453 -453
  19. package/public/css/layout.css +176 -176
  20. package/public/css/modal.css +190 -190
  21. package/public/css/responsive.css +176 -176
  22. package/public/css/sidebar.css +707 -707
  23. package/public/css/terminals.css +592 -592
  24. package/public/css/tokens.css +81 -81
  25. package/public/css/wco.css +196 -196
  26. package/public/css/widgets.css +2725 -2725
  27. package/public/index.html +152 -152
  28. package/public/js/api.js +371 -371
  29. package/public/js/backend.js +149 -149
  30. package/public/js/components/App.js +73 -73
  31. package/public/js/components/DirectoryPicker.js +203 -203
  32. package/public/js/components/EntityFormModal.js +153 -153
  33. package/public/js/components/Modal.js +57 -57
  34. package/public/js/components/OfflineBanner.js +67 -67
  35. package/public/js/components/PageTitleBar.js +13 -13
  36. package/public/js/components/PendingApprovalOverlay.js +128 -128
  37. package/public/js/components/Picker.js +179 -179
  38. package/public/js/components/Popover.js +55 -55
  39. package/public/js/components/RestartOverlay.js +36 -36
  40. package/public/js/components/Sidebar.js +380 -380
  41. package/public/js/components/TerminalInstance.js +187 -15
  42. package/public/js/components/TerminalResizeDebouncer.js +126 -0
  43. package/public/js/components/XtermTerminal.js +148 -14
  44. package/public/js/components/useDragSort.js +67 -67
  45. package/public/js/dialog.js +67 -67
  46. package/public/js/icons.js +212 -212
  47. package/public/js/main.js +296 -296
  48. package/public/js/pages/AboutPage.js +90 -90
  49. package/public/js/pages/ConfigurePage.js +713 -713
  50. package/public/js/pages/LaunchPage.js +421 -421
  51. package/public/js/pages/RemotePage.js +743 -743
  52. package/public/js/pages/SessionsPage.js +100 -100
  53. package/public/js/state.js +335 -335
  54. package/public/manifest.webmanifest +25 -0
  55. package/public/setup/index.html +567 -0
  56. package/scripts/dev.js +149 -149
  57. package/scripts/install.js +153 -153
  58. package/scripts/restart-helper.js +96 -96
  59. package/scripts/upgrade-helper.js +687 -687
  60. package/server.js +1807 -1807
@@ -1,149 +1,149 @@
1
- // One source of truth for "where is the ccsm backend reachable"
2
- // and "what auth token (if any) do we attach to every request".
3
- //
4
- // localhost / 127.0.0.1 same-origin (page IS the backend)
5
- // bakapiano.github.io http://localhost:7777 (the hosted
6
- // frontend talks to the user's local
7
- // backend via CORS)
8
- // anything else (tunnel domain) same-origin (the local backend is
9
- // serving this frontend over the
10
- // tunnel; API calls go to the same
11
- // tunnel URL automatically)
12
- //
13
- // httpBase is used by fetch(); wsBase is used by WebSocket constructions.
14
- // Keep both as functions rather than constants so the values reflect
15
- // `location.*` at call time (matters for tests / route changes).
16
-
17
- const HOSTED_HOST = 'bakapiano.github.io';
18
-
19
- function isLocal() {
20
- return location.hostname === 'localhost' || location.hostname === '127.0.0.1';
21
- }
22
- function isHosted() {
23
- return location.hostname === HOSTED_HOST;
24
- }
25
-
26
- export function httpBase() {
27
- if (isHosted()) return 'http://localhost:7777';
28
- // Local OR tunnel-served — both same-origin.
29
- return '';
30
- }
31
-
32
- export function wsBase() {
33
- if (isHosted()) return 'ws://localhost:7777';
34
- return `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}`;
35
- }
36
-
37
- export function isHostedFrontend() {
38
- return isHosted();
39
- }
40
- // True when the page is being served via a remote tunnel — neither the
41
- // host machine itself (localhost) nor the GH-Pages router. Used to gate
42
- // off "wake backend" affordances that only work locally.
43
- export function isRemoteAccess() {
44
- return !isLocal() && !isHosted();
45
- }
46
-
47
- // ── Remote-access bearer token ────────────────────────────────────
48
- // Persisted in localStorage so it survives reloads on whatever device
49
- // loaded the share URL. main.js captures a fresh token from `?token=`
50
- // on first arrival and stashes it via setToken(), then strips the
51
- // query string from the URL so the secret doesn't sit in the address
52
- // bar / browser history.
53
- const LS_KEY = 'ccsm.token';
54
-
55
- export function getToken() {
56
- try { return localStorage.getItem(LS_KEY) || null; } catch { return null; }
57
- }
58
- export function setToken(t) {
59
- try {
60
- if (t) localStorage.setItem(LS_KEY, t);
61
- else localStorage.removeItem(LS_KEY);
62
- } catch {}
63
- }
64
-
65
- // ── Device id ─────────────────────────────────────────────────────
66
- // Per-browser-profile UUID that identifies this device to the host
67
- // machine for the approval flow. Generated once, persisted in
68
- // localStorage, sent on every API call as X-Device-Id. The host pairs
69
- // the id with the User-Agent the server records on first sight, so
70
- // the approval UI can show "iPhone · Safari" instead of a raw uuid.
71
- const LS_DEVICE = 'ccsm.deviceId';
72
-
73
- export function getDeviceId() {
74
- try {
75
- let id = localStorage.getItem(LS_DEVICE);
76
- if (!id) {
77
- id = (crypto.randomUUID && crypto.randomUUID()) || (Math.random().toString(36).slice(2) + Date.now().toString(36));
78
- localStorage.setItem(LS_DEVICE, id);
79
- }
80
- return id;
81
- } catch {
82
- return null;
83
- }
84
- }
85
-
86
- // ── Initial terminal geometry ─────────────────────────────────────
87
- // Estimate how many cols/rows the live session pane can hold, so a
88
- // resumed / newly-launched PTY can spawn at roughly the right size
89
- // instead of node-pty's 120×30 default. Why it matters: an alt-screen
90
- // TUI like claude lays its entire UI out the instant it starts, using
91
- // whatever size the PTY had then. xterm only sends the real size once
92
- // its WebSocket opens — a beat later — and claude, having already
93
- // painted at 30 rows, doesn't re-expand to fill a tall window until
94
- // something forces a redraw (e.g. the user resizing it). On a big
95
- // display that strands the terminal at ~1/4 height. Seeding the spawn
96
- // with the pane's real dimensions sidesteps the race; xterm's own fit
97
- // still corrects any few-row estimate error when it attaches.
98
- // Returns null when nothing measurable is mounted, so the caller omits
99
- // the hint and the backend keeps its default.
100
- export function estimateTermSize() {
101
- let w, h;
102
- const pane = document.querySelector('.terminal-host')
103
- || document.querySelector('.session-pane-body');
104
- if (pane) {
105
- const r = pane.getBoundingClientRect();
106
- w = r.width; h = r.height;
107
- } else {
108
- // Launching from the Launch page — no pane yet. Approximate from the
109
- // window minus the sidebar column and the ~70px of top chrome.
110
- const sb = document.querySelector('.sidebar');
111
- w = window.innerWidth - (sb ? sb.getBoundingClientRect().width : 232) - 32;
112
- h = window.innerHeight - 70;
113
- }
114
- if (!(w > 40) || !(h > 40)) return null;
115
- // Mirror TerminalView's font sizing (13px desktop / 11px mobile,
116
- // lineHeight 1.2); cell advance ≈ 0.6em for the mono stack.
117
- const isMobile = window.matchMedia('(max-width: 640px)').matches;
118
- const fontSize = isMobile ? 11 : 13;
119
- return {
120
- cols: Math.max(20, Math.min(400, Math.floor(w / (fontSize * 0.6)))),
121
- rows: Math.max(8, Math.min(200, Math.floor(h / (fontSize * 1.2)))),
122
- };
123
- }
124
-
125
- // Per-device 4-digit human-verification code. Sent alongside the
126
- // device id so the operator approving on the host can match what
127
- // they see in the Remote page against what the requesting user
128
- // reads off their own screen — guards against approving the wrong
129
- // pending request when two devices arrive in quick succession.
130
- // Purely identification, NOT a credential — no secrecy assumed.
131
- const LS_DEVICE_CODE = 'ccsm.deviceCode';
132
-
133
- export function getDeviceCode() {
134
- try {
135
- let c = localStorage.getItem(LS_DEVICE_CODE);
136
- if (!c || !/^\d{4}$/.test(c)) {
137
- // 1000..9999 inclusive so the leading digit is never 0 — keeps
138
- // the code visually consistent at 4 characters wherever it
139
- // shows up. Random.value covers 9000 possibilities, plenty for
140
- // a "which of these is yours" disambiguator.
141
- const n = 1000 + Math.floor(Math.random() * 9000);
142
- c = String(n);
143
- localStorage.setItem(LS_DEVICE_CODE, c);
144
- }
145
- return c;
146
- } catch {
147
- return null;
148
- }
149
- }
1
+ // One source of truth for "where is the ccsm backend reachable"
2
+ // and "what auth token (if any) do we attach to every request".
3
+ //
4
+ // localhost / 127.0.0.1 same-origin (page IS the backend)
5
+ // bakapiano.github.io http://localhost:7777 (the hosted
6
+ // frontend talks to the user's local
7
+ // backend via CORS)
8
+ // anything else (tunnel domain) same-origin (the local backend is
9
+ // serving this frontend over the
10
+ // tunnel; API calls go to the same
11
+ // tunnel URL automatically)
12
+ //
13
+ // httpBase is used by fetch(); wsBase is used by WebSocket constructions.
14
+ // Keep both as functions rather than constants so the values reflect
15
+ // `location.*` at call time (matters for tests / route changes).
16
+
17
+ const HOSTED_HOST = 'bakapiano.github.io';
18
+
19
+ function isLocal() {
20
+ return location.hostname === 'localhost' || location.hostname === '127.0.0.1';
21
+ }
22
+ function isHosted() {
23
+ return location.hostname === HOSTED_HOST;
24
+ }
25
+
26
+ export function httpBase() {
27
+ if (isHosted()) return 'http://localhost:7777';
28
+ // Local OR tunnel-served — both same-origin.
29
+ return '';
30
+ }
31
+
32
+ export function wsBase() {
33
+ if (isHosted()) return 'ws://localhost:7777';
34
+ return `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}`;
35
+ }
36
+
37
+ export function isHostedFrontend() {
38
+ return isHosted();
39
+ }
40
+ // True when the page is being served via a remote tunnel — neither the
41
+ // host machine itself (localhost) nor the GH-Pages router. Used to gate
42
+ // off "wake backend" affordances that only work locally.
43
+ export function isRemoteAccess() {
44
+ return !isLocal() && !isHosted();
45
+ }
46
+
47
+ // ── Remote-access bearer token ────────────────────────────────────
48
+ // Persisted in localStorage so it survives reloads on whatever device
49
+ // loaded the share URL. main.js captures a fresh token from `?token=`
50
+ // on first arrival and stashes it via setToken(), then strips the
51
+ // query string from the URL so the secret doesn't sit in the address
52
+ // bar / browser history.
53
+ const LS_KEY = 'ccsm.token';
54
+
55
+ export function getToken() {
56
+ try { return localStorage.getItem(LS_KEY) || null; } catch { return null; }
57
+ }
58
+ export function setToken(t) {
59
+ try {
60
+ if (t) localStorage.setItem(LS_KEY, t);
61
+ else localStorage.removeItem(LS_KEY);
62
+ } catch {}
63
+ }
64
+
65
+ // ── Device id ─────────────────────────────────────────────────────
66
+ // Per-browser-profile UUID that identifies this device to the host
67
+ // machine for the approval flow. Generated once, persisted in
68
+ // localStorage, sent on every API call as X-Device-Id. The host pairs
69
+ // the id with the User-Agent the server records on first sight, so
70
+ // the approval UI can show "iPhone · Safari" instead of a raw uuid.
71
+ const LS_DEVICE = 'ccsm.deviceId';
72
+
73
+ export function getDeviceId() {
74
+ try {
75
+ let id = localStorage.getItem(LS_DEVICE);
76
+ if (!id) {
77
+ id = (crypto.randomUUID && crypto.randomUUID()) || (Math.random().toString(36).slice(2) + Date.now().toString(36));
78
+ localStorage.setItem(LS_DEVICE, id);
79
+ }
80
+ return id;
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+
86
+ // ── Initial terminal geometry ─────────────────────────────────────
87
+ // Estimate how many cols/rows the live session pane can hold, so a
88
+ // resumed / newly-launched PTY can spawn at roughly the right size
89
+ // instead of node-pty's 120×30 default. Why it matters: an alt-screen
90
+ // TUI like claude lays its entire UI out the instant it starts, using
91
+ // whatever size the PTY had then. xterm only sends the real size once
92
+ // its WebSocket opens — a beat later — and claude, having already
93
+ // painted at 30 rows, doesn't re-expand to fill a tall window until
94
+ // something forces a redraw (e.g. the user resizing it). On a big
95
+ // display that strands the terminal at ~1/4 height. Seeding the spawn
96
+ // with the pane's real dimensions sidesteps the race; xterm's own fit
97
+ // still corrects any few-row estimate error when it attaches.
98
+ // Returns null when nothing measurable is mounted, so the caller omits
99
+ // the hint and the backend keeps its default.
100
+ export function estimateTermSize() {
101
+ let w, h;
102
+ const pane = document.querySelector('.terminal-host')
103
+ || document.querySelector('.session-pane-body');
104
+ if (pane) {
105
+ const r = pane.getBoundingClientRect();
106
+ w = r.width; h = r.height;
107
+ } else {
108
+ // Launching from the Launch page — no pane yet. Approximate from the
109
+ // window minus the sidebar column and the ~70px of top chrome.
110
+ const sb = document.querySelector('.sidebar');
111
+ w = window.innerWidth - (sb ? sb.getBoundingClientRect().width : 232) - 32;
112
+ h = window.innerHeight - 70;
113
+ }
114
+ if (!(w > 40) || !(h > 40)) return null;
115
+ // Mirror TerminalView's font sizing (13px desktop / 11px mobile,
116
+ // lineHeight 1.2); cell advance ≈ 0.6em for the mono stack.
117
+ const isMobile = window.matchMedia('(max-width: 640px)').matches;
118
+ const fontSize = isMobile ? 11 : 13;
119
+ return {
120
+ cols: Math.max(20, Math.min(400, Math.floor(w / (fontSize * 0.6)))),
121
+ rows: Math.max(8, Math.min(200, Math.floor(h / (fontSize * 1.2)))),
122
+ };
123
+ }
124
+
125
+ // Per-device 4-digit human-verification code. Sent alongside the
126
+ // device id so the operator approving on the host can match what
127
+ // they see in the Remote page against what the requesting user
128
+ // reads off their own screen — guards against approving the wrong
129
+ // pending request when two devices arrive in quick succession.
130
+ // Purely identification, NOT a credential — no secrecy assumed.
131
+ const LS_DEVICE_CODE = 'ccsm.deviceCode';
132
+
133
+ export function getDeviceCode() {
134
+ try {
135
+ let c = localStorage.getItem(LS_DEVICE_CODE);
136
+ if (!c || !/^\d{4}$/.test(c)) {
137
+ // 1000..9999 inclusive so the leading digit is never 0 — keeps
138
+ // the code visually consistent at 4 characters wherever it
139
+ // shows up. Random.value covers 9000 possibilities, plenty for
140
+ // a "which of these is yours" disambiguator.
141
+ const n = 1000 + Math.floor(Math.random() * 9000);
142
+ c = String(n);
143
+ localStorage.setItem(LS_DEVICE_CODE, c);
144
+ }
145
+ return c;
146
+ } catch {
147
+ return null;
148
+ }
149
+ }
@@ -1,73 +1,73 @@
1
- import { html } from '../html.js';
2
- import { activeTab, selectTab } from '../state.js';
3
- import { useEffect } from 'preact/hooks';
4
- import { isRemoteAccess } from '../backend.js';
5
- import { PageTitleBar } from './PageTitleBar.js';
6
- import { Sidebar } from './Sidebar.js';
7
- import { Toast } from './Toast.js';
8
- import { DialogHost } from './DialogHost.js';
9
- import { HealthOverlay } from './HealthOverlay.js';
10
- import { PendingApprovalOverlay } from './PendingApprovalOverlay.js';
11
- import { RestartOverlay } from './RestartOverlay.js';
12
- import { MobileNavFab } from './MobileNavFab.js';
13
- import { isMobile, mobileDrawerOpen } from '../state.js';
14
- import { SessionsPage } from '../pages/SessionsPage.js';
15
- import { LaunchPage } from '../pages/LaunchPage.js';
16
- import { ConfigurePage } from '../pages/ConfigurePage.js';
17
- import { RemotePage } from '../pages/RemotePage.js';
18
- import { AboutPage } from '../pages/AboutPage.js';
19
-
20
- function Panel({ name, children }) {
21
- const active = activeTab.value === name;
22
- return html`<section class="tab-panel" data-panel=${name} data-active=${active || null}>${children}</section>`;
23
- }
24
-
25
- // Static placeholder for #remote on tunnel-served pages. Remote / device
26
- // / tunnel management is loopback-only — the server returns 403 on
27
- // every relevant endpoint — so even if a user navigates here via URL
28
- // hash we render a clear "host machine only" message instead of a
29
- // broken RemotePage spamming the console.
30
- function RemoteHostOnlyPanel() {
31
- useEffect(() => {
32
- // Bounce back to whatever tab they were on before, after a brief
33
- // moment so the message is readable.
34
- const t = setTimeout(() => selectTab('sessions'), 2500);
35
- return () => clearTimeout(t);
36
- }, []);
37
- return html`
38
- <${PageTitleBar} title="Remote" />
39
- <div class="settings-scroll">
40
- <p class="remote-empty" style="margin-top:var(--s-6)">
41
- Remote management is only available on the host machine.
42
- Bouncing back to Sessions…
43
- </p>
44
- </div>`;
45
- }
46
-
47
- export function App() {
48
- const tab = activeTab.value;
49
- const remoteLocked = tab === 'remote' && isRemoteAccess();
50
- const mobile = isMobile.value;
51
- const drawer = mobileDrawerOpen.value;
52
-
53
- return html`
54
- <div class=${`app${mobile ? ' is-mobile' : ''}${mobile && drawer ? ' drawer-open' : ''}`}>
55
- <${Sidebar} />
56
- <main class="main">
57
- <div class="content">
58
- ${tab === 'sessions' ? html`<${Panel} name="sessions"><${SessionsPage} /></${Panel}>` : null}
59
- ${tab === 'launch' ? html`<${Panel} name="launch"><${LaunchPage} /></${Panel}>` : null}
60
- ${tab === 'configure' ? html`<${Panel} name="configure"><${ConfigurePage} /></${Panel}>` : null}
61
- ${tab === 'remote' && !remoteLocked ? html`<${Panel} name="remote"><${RemotePage} /></${Panel}>` : null}
62
- ${remoteLocked ? html`<${Panel} name="remote"><${RemoteHostOnlyPanel} /></${Panel}>` : null}
63
- ${tab === 'about' ? html`<${Panel} name="about"><${AboutPage} /></${Panel}>` : null}
64
- </div>
65
- </main>
66
- <${Toast} />
67
- <${DialogHost} />
68
- <${HealthOverlay} />
69
- <${RestartOverlay} />
70
- <${PendingApprovalOverlay} />
71
- <${MobileNavFab} />
72
- </div>`;
73
- }
1
+ import { html } from '../html.js';
2
+ import { activeTab, selectTab } from '../state.js';
3
+ import { useEffect } from 'preact/hooks';
4
+ import { isRemoteAccess } from '../backend.js';
5
+ import { PageTitleBar } from './PageTitleBar.js';
6
+ import { Sidebar } from './Sidebar.js';
7
+ import { Toast } from './Toast.js';
8
+ import { DialogHost } from './DialogHost.js';
9
+ import { HealthOverlay } from './HealthOverlay.js';
10
+ import { PendingApprovalOverlay } from './PendingApprovalOverlay.js';
11
+ import { RestartOverlay } from './RestartOverlay.js';
12
+ import { MobileNavFab } from './MobileNavFab.js';
13
+ import { isMobile, mobileDrawerOpen } from '../state.js';
14
+ import { SessionsPage } from '../pages/SessionsPage.js';
15
+ import { LaunchPage } from '../pages/LaunchPage.js';
16
+ import { ConfigurePage } from '../pages/ConfigurePage.js';
17
+ import { RemotePage } from '../pages/RemotePage.js';
18
+ import { AboutPage } from '../pages/AboutPage.js';
19
+
20
+ function Panel({ name, children }) {
21
+ const active = activeTab.value === name;
22
+ return html`<section class="tab-panel" data-panel=${name} data-active=${active || null}>${children}</section>`;
23
+ }
24
+
25
+ // Static placeholder for #remote on tunnel-served pages. Remote / device
26
+ // / tunnel management is loopback-only — the server returns 403 on
27
+ // every relevant endpoint — so even if a user navigates here via URL
28
+ // hash we render a clear "host machine only" message instead of a
29
+ // broken RemotePage spamming the console.
30
+ function RemoteHostOnlyPanel() {
31
+ useEffect(() => {
32
+ // Bounce back to whatever tab they were on before, after a brief
33
+ // moment so the message is readable.
34
+ const t = setTimeout(() => selectTab('sessions'), 2500);
35
+ return () => clearTimeout(t);
36
+ }, []);
37
+ return html`
38
+ <${PageTitleBar} title="Remote" />
39
+ <div class="settings-scroll">
40
+ <p class="remote-empty" style="margin-top:var(--s-6)">
41
+ Remote management is only available on the host machine.
42
+ Bouncing back to Sessions…
43
+ </p>
44
+ </div>`;
45
+ }
46
+
47
+ export function App() {
48
+ const tab = activeTab.value;
49
+ const remoteLocked = tab === 'remote' && isRemoteAccess();
50
+ const mobile = isMobile.value;
51
+ const drawer = mobileDrawerOpen.value;
52
+
53
+ return html`
54
+ <div class=${`app${mobile ? ' is-mobile' : ''}${mobile && drawer ? ' drawer-open' : ''}`}>
55
+ <${Sidebar} />
56
+ <main class="main">
57
+ <div class="content">
58
+ ${tab === 'sessions' ? html`<${Panel} name="sessions"><${SessionsPage} /></${Panel}>` : null}
59
+ ${tab === 'launch' ? html`<${Panel} name="launch"><${LaunchPage} /></${Panel}>` : null}
60
+ ${tab === 'configure' ? html`<${Panel} name="configure"><${ConfigurePage} /></${Panel}>` : null}
61
+ ${tab === 'remote' && !remoteLocked ? html`<${Panel} name="remote"><${RemotePage} /></${Panel}>` : null}
62
+ ${remoteLocked ? html`<${Panel} name="remote"><${RemoteHostOnlyPanel} /></${Panel}>` : null}
63
+ ${tab === 'about' ? html`<${Panel} name="about"><${AboutPage} /></${Panel}>` : null}
64
+ </div>
65
+ </main>
66
+ <${Toast} />
67
+ <${DialogHost} />
68
+ <${HealthOverlay} />
69
+ <${RestartOverlay} />
70
+ <${PendingApprovalOverlay} />
71
+ <${MobileNavFab} />
72
+ </div>`;
73
+ }