@bakapiano/ccsm 0.22.3 → 0.22.5

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 (61) 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 +225 -225
  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 +645 -543
  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 +159 -22
  42. package/public/js/components/TerminalResizeDebouncer.js +126 -0
  43. package/public/js/components/TerminalView.js +15 -2
  44. package/public/js/components/XtermTerminal.js +74 -15
  45. package/public/js/components/useDragSort.js +67 -67
  46. package/public/js/dialog.js +67 -67
  47. package/public/js/icons.js +212 -212
  48. package/public/js/main.js +296 -296
  49. package/public/js/pages/AboutPage.js +90 -90
  50. package/public/js/pages/ConfigurePage.js +713 -713
  51. package/public/js/pages/LaunchPage.js +421 -421
  52. package/public/js/pages/RemotePage.js +743 -743
  53. package/public/js/pages/SessionsPage.js +199 -80
  54. package/public/js/state.js +335 -335
  55. package/public/manifest.webmanifest +25 -0
  56. package/public/setup/index.html +567 -0
  57. package/scripts/dev.js +149 -149
  58. package/scripts/install.js +153 -153
  59. package/scripts/restart-helper.js +96 -96
  60. package/scripts/upgrade-helper.js +687 -687
  61. package/server.js +1807 -1807
package/public/js/api.js CHANGED
@@ -1,254 +1,254 @@
1
- // Fetch wrapper + every loader. Loaders push into signals from ./state.js.
2
- // Cross-origin (hosted frontend → local backend) flows through httpBase().
3
-
4
- import { signal } from '@preact/signals';
5
- import * as S from './state.js';
6
- import { httpBase, getToken, getDeviceId, getDeviceCode, isRemoteAccess, estimateTermSize } from './backend.js';
7
-
8
- // Global pending-approval signal. Flipped to true whenever any /api
9
- // call returns 403 {pending:true}; PendingApprovalOverlay watches this
10
- // and shows the blocking screen. We also stash the server's record so
11
- // the overlay can display "we recorded you at HH:MM" detail.
12
- export const pendingDevice = signal(null);
13
-
14
- export async function api(method, url, body) {
15
- const opts = { method, headers: { 'Content-Type': 'application/json' } };
16
- // When a remote token is configured (Remote page set it OR the page
17
- // was loaded with ?token= and we stashed it in localStorage), attach
18
- // it to every API call. The server middleware lets loopback Hosts
19
- // through without the token; for tunnel-served pages this is the
20
- // only way past the 401.
21
- const tok = getToken();
22
- if (tok) opts.headers['Authorization'] = `Bearer ${tok}`;
23
- // Always send our device id when one exists in localStorage. The host
24
- // browser at localhost doesn't strictly need it (loopback bypass),
25
- // but harmless — the server simply records lastSeen for it. Required
26
- // for any tunnel-served page to clear the device-approval gate.
27
- const dev = getDeviceId();
28
- if (dev) opts.headers['X-Device-Id'] = dev;
29
- // 4-digit identification code (see getDeviceCode in backend.js).
30
- // Server stores it on first sight; the Remote page renders it
31
- // alongside each pending device so the operator can confirm "yes,
32
- // this is the request I just made on my phone" before approving.
33
- const code = getDeviceCode();
34
- if (code) opts.headers['X-Device-Code'] = code;
35
- if (body !== undefined) opts.body = JSON.stringify(body);
36
- const r = await fetch(httpBase() + url, opts);
37
- const text = await r.text();
38
- let json;
39
- try { json = text ? JSON.parse(text) : {}; } catch { json = { raw: text }; }
40
- if (!r.ok) {
41
- // Surface device-approval pending state. Only matters on remote
42
- // tabs — host's loopback browser never gets a 401/403 from these
43
- // checks.
44
- if (isRemoteAccess()) {
45
- if (r.status === 403 && json && (json.pending || json.rejected)) {
46
- // Merge into the existing pendingDevice rather than overwriting
47
- // so the "we recorded you at HH:MM" detail (only present on the
48
- // initial /me hit, not subsequent gate 403s) survives. Without
49
- // this merge, the first failing /api/sessions tick after the
50
- // overlay mounts wipes the firstSeen timestamp and the copy
51
- // reverts to a generic "The host machine got your request".
52
- const prev = pendingDevice.value || {};
53
- pendingDevice.value = { ...prev, ...json, at: Date.now() };
54
- } else if (r.status === 401) {
55
- // Server doesn't recognise our device — either fresh page load
56
- // (no /api/devices/me hit yet) or our record got pruned (24h
57
- // pending TTL) AND our token no longer matches the host's
58
- // current one. PendingApprovalOverlay's /me poll will try to
59
- // re-register; on token mismatch /me itself 401s and the
60
- // overlay flips into "token expired" state. We just nudge the
61
- // overlay alive here.
62
- const prev = pendingDevice.value || {};
63
- pendingDevice.value = { ...prev, pending: true, at: Date.now() };
64
- }
65
- }
66
- throw new Error(json.error || `HTTP ${r.status}`);
67
- }
68
- // PendingApprovalOverlay clears pendingDevice itself based on the
69
- // /api/devices/me body (which can return 200 with status:'pending'
70
- // since that endpoint is gate-exempt). Doing an auto-clear here on
71
- // any 2xx would race the overlay's poll and dismiss it prematurely.
72
- return json;
73
- }
74
-
75
- export async function loadConfig() {
76
- const [cfg, caps] = await Promise.all([
77
- api('GET', '/api/config'),
78
- api('GET', '/api/capabilities').catch(() => ({ webTerminal: false })),
79
- ]);
80
- S.config.value = cfg;
81
- S.capabilities.value = caps;
82
- }
83
-
84
- // Update an existing CLI by id. patch is shallow-merged into the record.
85
- export async function updateCli(id, patch) {
86
- const cfg = S.config.value || (await api('GET', '/api/config'));
87
- const target = (cfg.clis || []).find((c) => c.id === id);
88
- // Built-in CLIs lock down structural fields (id + builtin flag) but
89
- // allow command edits — users routinely need to point at an absolute
90
- // path (e.g. C:\Users\you\.local\bin\claude.exe) or a wrapper script
91
- // when the bare name isn't on the spawn-time PATH.
92
- if (target?.builtin) {
93
- delete patch.id;
94
- delete patch.builtin;
95
- }
96
- const toArr = (v, fallback) => Array.isArray(v) ? v :
97
- typeof v === 'string' ? v.split(/\s+/).filter(Boolean) : fallback;
98
- const next = {
99
- ...cfg,
100
- clis: (cfg.clis || []).map((c) => c.id === id ? {
101
- ...c, ...patch,
102
- args: toArr(patch.args, c.args),
103
- shell: ['direct', 'pwsh', 'cmd'].includes(patch.shell ?? c.shell) ? (patch.shell ?? c.shell) : 'direct',
104
- } : c),
105
- };
106
- const saved = await api('PUT', '/api/config', next);
107
- S.config.value = saved;
108
- return id;
109
- }
110
-
111
- // Probe a (possibly-unsaved) CLI config: spawn its command with
112
- // `--version`, capture output, see if it looks like the claimed type.
113
- // `args` is intentionally ignored server-side — runtime flags can
114
- // disturb a quick probe.
115
- export async function testCli({ command, shell, type }) {
116
- return api('POST', '/api/clis/test', { command, shell, type });
117
- }
118
-
119
- export async function deleteCli(id) {
120
- const cfg = S.config.value || (await api('GET', '/api/config'));
121
- const target = (cfg.clis || []).find((c) => c.id === id);
122
- if (target?.builtin) throw new Error(`"${target.name}" is built-in and can't be deleted`);
123
- const clis = (cfg.clis || []).filter((c) => c.id !== id);
124
- if (clis.length === 0) throw new Error('cannot delete the last CLI');
125
- const next = { ...cfg, clis };
126
- if (next.defaultCliId === id) next.defaultCliId = clis[0].id;
127
- const saved = await api('PUT', '/api/config', next);
128
- S.config.value = saved;
129
- }
130
-
131
- export async function updateRepo(name, patch) {
132
- const cfg = S.config.value || (await api('GET', '/api/config'));
133
- const next = {
134
- ...cfg,
135
- repos: (cfg.repos || []).map((r) => r.name === name ? {
136
- ...r,
137
- name: (patch.name ?? r.name).trim(),
138
- url: (patch.url ?? r.url).trim(),
139
- defaultSelected: patch.defaultSelected ?? r.defaultSelected,
140
- } : r),
141
- };
142
- const saved = await api('PUT', '/api/config', next);
143
- S.config.value = saved;
144
- }
145
-
146
- export async function deleteRepo(name) {
147
- const cfg = S.config.value || (await api('GET', '/api/config'));
148
- const next = { ...cfg, repos: (cfg.repos || []).filter((r) => r.name !== name) };
149
- const saved = await api('PUT', '/api/config', next);
150
- S.config.value = saved;
151
- }
152
-
153
- export async function setDefaultCli(id) {
154
- const cfg = S.config.value || (await api('GET', '/api/config'));
155
- const saved = await api('PUT', '/api/config', { ...cfg, defaultCliId: id });
156
- S.config.value = saved;
157
- }
158
-
159
- // Add a new CLI to config.clis and return its id. Generates a fresh id
160
- // from the command name + an integer suffix when collisions exist.
161
- export async function createCli({ name, command, args, shell, type }) {
162
- const cfg = S.config.value || (await api('GET', '/api/config'));
163
- const base = (name || command || 'cli').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'cli';
164
- let id = base, n = 1;
165
- while ((cfg.clis || []).some((c) => c.id === id)) { id = `${base}-${++n}`; }
166
- const toArr = (v) => Array.isArray(v) ? v : (typeof v === 'string' ? v.split(/\s+/).filter(Boolean) : []);
167
- const next = {
168
- ...cfg,
169
- clis: [...(cfg.clis || []), {
170
- id,
171
- name: (name || command || id).trim(),
172
- command: (command || '').trim(),
173
- args: toArr(args),
174
- shell: ['direct', 'pwsh', 'cmd'].includes(shell) ? shell : 'direct',
175
- type: ['claude', 'codex', 'copilot', 'other'].includes(type) ? type : 'other',
176
- }],
177
- };
178
- const saved = await api('PUT', '/api/config', next);
179
- S.config.value = saved;
180
- return id;
181
- }
182
-
183
- // Add a new repo to config.repos. Repos are addressed by name (which must
184
- // be unique). Returns the name on success, throws on duplicate.
185
- export async function createRepo({ name, url, defaultSelected }) {
186
- const cfg = S.config.value || (await api('GET', '/api/config'));
187
- const cleanName = (name || '').trim();
188
- const cleanUrl = (url || '').trim();
189
- if (!cleanName) throw new Error('repo name required');
190
- if (!cleanUrl) throw new Error('repo url required');
191
- if ((cfg.repos || []).some((r) => r.name === cleanName)) {
192
- throw new Error(`repo "${cleanName}" already exists`);
193
- }
194
- const next = {
195
- ...cfg,
196
- repos: [...(cfg.repos || []), {
197
- name: cleanName,
198
- url: cleanUrl,
199
- defaultSelected: !!defaultSelected,
200
- }],
201
- };
202
- const saved = await api('PUT', '/api/config', next);
203
- S.config.value = saved;
204
- return cleanName;
205
- }
206
-
207
- export async function loadSessions() {
208
- const r = await api('GET', '/api/sessions');
209
- S.sessions.value = r.sessions || [];
210
- try { localStorage.setItem('ccsm.sessions-cache', JSON.stringify(S.sessions.value)); } catch {}
211
- }
212
-
213
- export async function loadFolders() {
214
- const r = await api('GET', '/api/folders');
215
- S.folders.value = (r.folders || []).sort((a, b) => (a.order || 0) - (b.order || 0));
216
- try { localStorage.setItem('ccsm.folders-cache', JSON.stringify(S.folders.value)); } catch {}
217
- }
218
-
219
- export async function createFolder(name) {
220
- const r = await api('POST', '/api/folders', { name });
221
- await loadFolders();
222
- return r.folder;
223
- }
224
-
225
- export async function renameFolder(id, name) {
226
- const r = await api('PUT', `/api/folders/${id}`, { name });
227
- await loadFolders();
228
- return r.folder;
229
- }
230
-
231
- export async function deleteFolder(id) {
232
- await api('DELETE', `/api/folders/${id}`);
233
- await Promise.all([loadFolders(), loadSessions()]);
234
- }
235
-
236
- export async function reorderFolders(ids) {
237
- const r = await api('POST', '/api/folders/reorder', { ids });
238
- await loadFolders();
239
- return r.folders;
240
- }
241
-
242
- export async function setSessionFolder(sessionId, folderId) {
243
- await api('PUT', `/api/sessions/${sessionId}`, { folderId: folderId || null });
244
- await loadSessions();
245
- }
246
-
247
- export async function reorderSessions(folderId, ids) {
248
- await api('POST', '/api/sessions/reorder', { folderId: folderId || null, ids });
249
- await loadSessions();
250
- }
251
-
1
+ // Fetch wrapper + every loader. Loaders push into signals from ./state.js.
2
+ // Cross-origin (hosted frontend → local backend) flows through httpBase().
3
+
4
+ import { signal } from '@preact/signals';
5
+ import * as S from './state.js';
6
+ import { httpBase, getToken, getDeviceId, getDeviceCode, isRemoteAccess, estimateTermSize } from './backend.js';
7
+
8
+ // Global pending-approval signal. Flipped to true whenever any /api
9
+ // call returns 403 {pending:true}; PendingApprovalOverlay watches this
10
+ // and shows the blocking screen. We also stash the server's record so
11
+ // the overlay can display "we recorded you at HH:MM" detail.
12
+ export const pendingDevice = signal(null);
13
+
14
+ export async function api(method, url, body) {
15
+ const opts = { method, headers: { 'Content-Type': 'application/json' } };
16
+ // When a remote token is configured (Remote page set it OR the page
17
+ // was loaded with ?token= and we stashed it in localStorage), attach
18
+ // it to every API call. The server middleware lets loopback Hosts
19
+ // through without the token; for tunnel-served pages this is the
20
+ // only way past the 401.
21
+ const tok = getToken();
22
+ if (tok) opts.headers['Authorization'] = `Bearer ${tok}`;
23
+ // Always send our device id when one exists in localStorage. The host
24
+ // browser at localhost doesn't strictly need it (loopback bypass),
25
+ // but harmless — the server simply records lastSeen for it. Required
26
+ // for any tunnel-served page to clear the device-approval gate.
27
+ const dev = getDeviceId();
28
+ if (dev) opts.headers['X-Device-Id'] = dev;
29
+ // 4-digit identification code (see getDeviceCode in backend.js).
30
+ // Server stores it on first sight; the Remote page renders it
31
+ // alongside each pending device so the operator can confirm "yes,
32
+ // this is the request I just made on my phone" before approving.
33
+ const code = getDeviceCode();
34
+ if (code) opts.headers['X-Device-Code'] = code;
35
+ if (body !== undefined) opts.body = JSON.stringify(body);
36
+ const r = await fetch(httpBase() + url, opts);
37
+ const text = await r.text();
38
+ let json;
39
+ try { json = text ? JSON.parse(text) : {}; } catch { json = { raw: text }; }
40
+ if (!r.ok) {
41
+ // Surface device-approval pending state. Only matters on remote
42
+ // tabs — host's loopback browser never gets a 401/403 from these
43
+ // checks.
44
+ if (isRemoteAccess()) {
45
+ if (r.status === 403 && json && (json.pending || json.rejected)) {
46
+ // Merge into the existing pendingDevice rather than overwriting
47
+ // so the "we recorded you at HH:MM" detail (only present on the
48
+ // initial /me hit, not subsequent gate 403s) survives. Without
49
+ // this merge, the first failing /api/sessions tick after the
50
+ // overlay mounts wipes the firstSeen timestamp and the copy
51
+ // reverts to a generic "The host machine got your request".
52
+ const prev = pendingDevice.value || {};
53
+ pendingDevice.value = { ...prev, ...json, at: Date.now() };
54
+ } else if (r.status === 401) {
55
+ // Server doesn't recognise our device — either fresh page load
56
+ // (no /api/devices/me hit yet) or our record got pruned (24h
57
+ // pending TTL) AND our token no longer matches the host's
58
+ // current one. PendingApprovalOverlay's /me poll will try to
59
+ // re-register; on token mismatch /me itself 401s and the
60
+ // overlay flips into "token expired" state. We just nudge the
61
+ // overlay alive here.
62
+ const prev = pendingDevice.value || {};
63
+ pendingDevice.value = { ...prev, pending: true, at: Date.now() };
64
+ }
65
+ }
66
+ throw new Error(json.error || `HTTP ${r.status}`);
67
+ }
68
+ // PendingApprovalOverlay clears pendingDevice itself based on the
69
+ // /api/devices/me body (which can return 200 with status:'pending'
70
+ // since that endpoint is gate-exempt). Doing an auto-clear here on
71
+ // any 2xx would race the overlay's poll and dismiss it prematurely.
72
+ return json;
73
+ }
74
+
75
+ export async function loadConfig() {
76
+ const [cfg, caps] = await Promise.all([
77
+ api('GET', '/api/config'),
78
+ api('GET', '/api/capabilities').catch(() => ({ webTerminal: false })),
79
+ ]);
80
+ S.config.value = cfg;
81
+ S.capabilities.value = caps;
82
+ }
83
+
84
+ // Update an existing CLI by id. patch is shallow-merged into the record.
85
+ export async function updateCli(id, patch) {
86
+ const cfg = S.config.value || (await api('GET', '/api/config'));
87
+ const target = (cfg.clis || []).find((c) => c.id === id);
88
+ // Built-in CLIs lock down structural fields (id + builtin flag) but
89
+ // allow command edits — users routinely need to point at an absolute
90
+ // path (e.g. C:\Users\you\.local\bin\claude.exe) or a wrapper script
91
+ // when the bare name isn't on the spawn-time PATH.
92
+ if (target?.builtin) {
93
+ delete patch.id;
94
+ delete patch.builtin;
95
+ }
96
+ const toArr = (v, fallback) => Array.isArray(v) ? v :
97
+ typeof v === 'string' ? v.split(/\s+/).filter(Boolean) : fallback;
98
+ const next = {
99
+ ...cfg,
100
+ clis: (cfg.clis || []).map((c) => c.id === id ? {
101
+ ...c, ...patch,
102
+ args: toArr(patch.args, c.args),
103
+ shell: ['direct', 'pwsh', 'cmd'].includes(patch.shell ?? c.shell) ? (patch.shell ?? c.shell) : 'direct',
104
+ } : c),
105
+ };
106
+ const saved = await api('PUT', '/api/config', next);
107
+ S.config.value = saved;
108
+ return id;
109
+ }
110
+
111
+ // Probe a (possibly-unsaved) CLI config: spawn its command with
112
+ // `--version`, capture output, see if it looks like the claimed type.
113
+ // `args` is intentionally ignored server-side — runtime flags can
114
+ // disturb a quick probe.
115
+ export async function testCli({ command, shell, type }) {
116
+ return api('POST', '/api/clis/test', { command, shell, type });
117
+ }
118
+
119
+ export async function deleteCli(id) {
120
+ const cfg = S.config.value || (await api('GET', '/api/config'));
121
+ const target = (cfg.clis || []).find((c) => c.id === id);
122
+ if (target?.builtin) throw new Error(`"${target.name}" is built-in and can't be deleted`);
123
+ const clis = (cfg.clis || []).filter((c) => c.id !== id);
124
+ if (clis.length === 0) throw new Error('cannot delete the last CLI');
125
+ const next = { ...cfg, clis };
126
+ if (next.defaultCliId === id) next.defaultCliId = clis[0].id;
127
+ const saved = await api('PUT', '/api/config', next);
128
+ S.config.value = saved;
129
+ }
130
+
131
+ export async function updateRepo(name, patch) {
132
+ const cfg = S.config.value || (await api('GET', '/api/config'));
133
+ const next = {
134
+ ...cfg,
135
+ repos: (cfg.repos || []).map((r) => r.name === name ? {
136
+ ...r,
137
+ name: (patch.name ?? r.name).trim(),
138
+ url: (patch.url ?? r.url).trim(),
139
+ defaultSelected: patch.defaultSelected ?? r.defaultSelected,
140
+ } : r),
141
+ };
142
+ const saved = await api('PUT', '/api/config', next);
143
+ S.config.value = saved;
144
+ }
145
+
146
+ export async function deleteRepo(name) {
147
+ const cfg = S.config.value || (await api('GET', '/api/config'));
148
+ const next = { ...cfg, repos: (cfg.repos || []).filter((r) => r.name !== name) };
149
+ const saved = await api('PUT', '/api/config', next);
150
+ S.config.value = saved;
151
+ }
152
+
153
+ export async function setDefaultCli(id) {
154
+ const cfg = S.config.value || (await api('GET', '/api/config'));
155
+ const saved = await api('PUT', '/api/config', { ...cfg, defaultCliId: id });
156
+ S.config.value = saved;
157
+ }
158
+
159
+ // Add a new CLI to config.clis and return its id. Generates a fresh id
160
+ // from the command name + an integer suffix when collisions exist.
161
+ export async function createCli({ name, command, args, shell, type }) {
162
+ const cfg = S.config.value || (await api('GET', '/api/config'));
163
+ const base = (name || command || 'cli').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'cli';
164
+ let id = base, n = 1;
165
+ while ((cfg.clis || []).some((c) => c.id === id)) { id = `${base}-${++n}`; }
166
+ const toArr = (v) => Array.isArray(v) ? v : (typeof v === 'string' ? v.split(/\s+/).filter(Boolean) : []);
167
+ const next = {
168
+ ...cfg,
169
+ clis: [...(cfg.clis || []), {
170
+ id,
171
+ name: (name || command || id).trim(),
172
+ command: (command || '').trim(),
173
+ args: toArr(args),
174
+ shell: ['direct', 'pwsh', 'cmd'].includes(shell) ? shell : 'direct',
175
+ type: ['claude', 'codex', 'copilot', 'other'].includes(type) ? type : 'other',
176
+ }],
177
+ };
178
+ const saved = await api('PUT', '/api/config', next);
179
+ S.config.value = saved;
180
+ return id;
181
+ }
182
+
183
+ // Add a new repo to config.repos. Repos are addressed by name (which must
184
+ // be unique). Returns the name on success, throws on duplicate.
185
+ export async function createRepo({ name, url, defaultSelected }) {
186
+ const cfg = S.config.value || (await api('GET', '/api/config'));
187
+ const cleanName = (name || '').trim();
188
+ const cleanUrl = (url || '').trim();
189
+ if (!cleanName) throw new Error('repo name required');
190
+ if (!cleanUrl) throw new Error('repo url required');
191
+ if ((cfg.repos || []).some((r) => r.name === cleanName)) {
192
+ throw new Error(`repo "${cleanName}" already exists`);
193
+ }
194
+ const next = {
195
+ ...cfg,
196
+ repos: [...(cfg.repos || []), {
197
+ name: cleanName,
198
+ url: cleanUrl,
199
+ defaultSelected: !!defaultSelected,
200
+ }],
201
+ };
202
+ const saved = await api('PUT', '/api/config', next);
203
+ S.config.value = saved;
204
+ return cleanName;
205
+ }
206
+
207
+ export async function loadSessions() {
208
+ const r = await api('GET', '/api/sessions');
209
+ S.sessions.value = r.sessions || [];
210
+ try { localStorage.setItem('ccsm.sessions-cache', JSON.stringify(S.sessions.value)); } catch {}
211
+ }
212
+
213
+ export async function loadFolders() {
214
+ const r = await api('GET', '/api/folders');
215
+ S.folders.value = (r.folders || []).sort((a, b) => (a.order || 0) - (b.order || 0));
216
+ try { localStorage.setItem('ccsm.folders-cache', JSON.stringify(S.folders.value)); } catch {}
217
+ }
218
+
219
+ export async function createFolder(name) {
220
+ const r = await api('POST', '/api/folders', { name });
221
+ await loadFolders();
222
+ return r.folder;
223
+ }
224
+
225
+ export async function renameFolder(id, name) {
226
+ const r = await api('PUT', `/api/folders/${id}`, { name });
227
+ await loadFolders();
228
+ return r.folder;
229
+ }
230
+
231
+ export async function deleteFolder(id) {
232
+ await api('DELETE', `/api/folders/${id}`);
233
+ await Promise.all([loadFolders(), loadSessions()]);
234
+ }
235
+
236
+ export async function reorderFolders(ids) {
237
+ const r = await api('POST', '/api/folders/reorder', { ids });
238
+ await loadFolders();
239
+ return r.folders;
240
+ }
241
+
242
+ export async function setSessionFolder(sessionId, folderId) {
243
+ await api('PUT', `/api/sessions/${sessionId}`, { folderId: folderId || null });
244
+ await loadSessions();
245
+ }
246
+
247
+ export async function reorderSessions(folderId, ids) {
248
+ await api('POST', '/api/sessions/reorder', { folderId: folderId || null, ids });
249
+ await loadSessions();
250
+ }
251
+
252
252
  export async function setSessionTitle(sessionId, title) {
253
253
  await api('PUT', `/api/sessions/${sessionId}`, { title });
254
254
  await loadSessions();
@@ -272,123 +272,123 @@ export async function deleteSession(sessionId) {
272
272
  await api('DELETE', `/api/sessions/${sessionId}`);
273
273
  await loadSessions();
274
274
  }
275
-
276
- // Open the session's working directory in the user's configured editor
277
- // (Settings → Editor, default `code`). Returns { editor, cwd } so the
278
- // caller can surface which editor it launched.
279
- export function openSessionInEditor(sessionId) {
280
- return api('POST', `/api/sessions/${sessionId}/open-editor`);
281
- }
282
-
283
- // Per-session in-flight resume promise. Sidebar.onClick and the
284
- // SessionsPage auto-resume effect can both fire for the same exited
285
- // session in the same tick (clicking an exited row mounts SessionsPage
286
- // which runs its effect AND awaits Sidebar's own POST). Without this
287
- // dedup the backend gets two concurrent /resume requests and may spawn
288
- // two PTYs against the same record. Cleared on resolve/reject.
289
- const resumeInFlight = new Map(); // sessionId → Promise
290
- // Sticky failure cache: once a resume fails, subsequent calls reject
291
- // immediately with the cached error until clearResumeFailure(id) is
292
- // called. Stops the SessionsPage auto-resume effect from looping on a
293
- // session whose CLI keeps exiting (bad command, missing flag, etc.).
294
- const resumeFailed = new Map(); // sessionId → Error
295
-
296
- export function clearResumeFailure(sessionId) {
297
- resumeFailed.delete(sessionId);
298
- }
299
-
300
- export function resumeSession(sessionId) {
301
- const failed = resumeFailed.get(sessionId);
302
- if (failed) return Promise.reject(failed);
303
- const cached = resumeInFlight.get(sessionId);
304
- if (cached) return cached;
305
- const p = (async () => {
306
- const r = await api('POST', `/api/sessions/${sessionId}/resume`, {
307
- // Resolved terminal theme → backend sets a matching COLORFGBG so the
308
- // CLI's light/dark auto-detection follows the ccsm terminal.
309
- theme: document.documentElement.dataset.theme,
310
- // Seed the PTY at the pane's real size so alt-screen CLIs (claude)
311
- // don't lay out at node-pty's 30-row default and get stranded short.
312
- ...(estimateTermSize() || {}),
313
- });
314
- await loadSessions();
315
- return r.launched;
316
- })();
317
- resumeInFlight.set(sessionId, p);
318
- p.then(
319
- () => { resumeInFlight.delete(sessionId); },
320
- (e) => { resumeInFlight.delete(sessionId); resumeFailed.set(sessionId, e); },
321
- );
322
- return p;
323
- }
324
-
325
- export async function loadWorkspaces() {
326
- const r = await api('GET', '/api/workspaces');
327
- S.workspaces.value = r.workspaces;
328
- }
329
-
330
- export async function deleteWorkspace(name) {
331
- await api('DELETE', `/api/workspaces/${encodeURIComponent(name)}`);
332
- }
333
-
334
- export async function refreshAll() {
335
- await Promise.all([
336
- loadSessions(),
337
- loadFolders(),
338
- loadWorkspaces(),
339
- ]);
340
- S.lastRefreshAt.value = Date.now();
341
- }
342
-
343
- // List existing CLI sessions discovered on disk for a given cli type.
344
- // Paginated: page 0 returns all currently-active sessions + the first
345
- // `limit` non-active (sorted mtime desc). Subsequent pages return the
346
- // next slice of non-active sessions.
347
- // Returns { sessions, totalActive, totalNonActive, total, offset, limit, hasMore }.
348
- export async function listLocalCliSessions(cliType, { offset = 0, limit = 30 } = {}) {
349
- const qs = `offset=${offset}&limit=${limit}`;
350
- const r = await api('GET', `/api/cli-sessions/${cliType}?${qs}`);
351
- return {
352
- sessions: r.sessions || [],
353
- totalActive: r.totalActive ?? 0,
354
- totalNonActive: r.totalNonActive ?? 0,
355
- total: r.total ?? (r.sessions?.length || 0),
356
- offset: r.offset ?? offset,
357
- limit: r.limit ?? limit,
358
- hasMore: !!r.hasMore,
359
- };
360
- }
361
-
362
- // Adopt an existing upstream CLI session into ccsm. Returns the created
363
- // (or existing) persistedSessions record.
364
- export async function adoptSession({ cliId, cliSessionId, cwd, title, folderId }) {
365
- const r = await api('POST', '/api/sessions/adopt', { cliId, cliSessionId, cwd, title, folderId });
366
- return r;
367
- }
368
-
369
- export async function restartBackend() {
370
- return api('POST', '/api/restart');
371
- }
372
-
373
- let consecutiveOffline = 0;
374
- export async function pollHealth() {
375
- const ctrl = new AbortController();
376
- const t = setTimeout(() => ctrl.abort(), 3000);
377
- try {
378
- const r = await fetch(httpBase() + '/api/health', { signal: ctrl.signal });
379
- if (!r.ok) throw new Error(`HTTP ${r.status}`);
380
- const j = await r.json();
381
- consecutiveOffline = 0;
382
- S.serverHealth.value = { state: 'online', version: j.version, pid: j.pid, failureCount: 0 };
383
- if (!S.hasBootedOnline.value) S.hasBootedOnline.value = true;
384
- } catch (e) {
385
- consecutiveOffline++;
386
- S.serverHealth.value = {
387
- state: 'offline',
388
- error: String(e.message || e),
389
- failureCount: consecutiveOffline,
390
- };
391
- } finally {
392
- clearTimeout(t);
393
- }
394
- }
275
+
276
+ // Open the session's working directory in the user's configured editor
277
+ // (Settings → Editor, default `code`). Returns { editor, cwd } so the
278
+ // caller can surface which editor it launched.
279
+ export function openSessionInEditor(sessionId) {
280
+ return api('POST', `/api/sessions/${sessionId}/open-editor`);
281
+ }
282
+
283
+ // Per-session in-flight resume promise. Sidebar.onClick and the
284
+ // SessionsPage auto-resume effect can both fire for the same exited
285
+ // session in the same tick (clicking an exited row mounts SessionsPage
286
+ // which runs its effect AND awaits Sidebar's own POST). Without this
287
+ // dedup the backend gets two concurrent /resume requests and may spawn
288
+ // two PTYs against the same record. Cleared on resolve/reject.
289
+ const resumeInFlight = new Map(); // sessionId → Promise
290
+ // Sticky failure cache: once a resume fails, subsequent calls reject
291
+ // immediately with the cached error until clearResumeFailure(id) is
292
+ // called. Stops the SessionsPage auto-resume effect from looping on a
293
+ // session whose CLI keeps exiting (bad command, missing flag, etc.).
294
+ const resumeFailed = new Map(); // sessionId → Error
295
+
296
+ export function clearResumeFailure(sessionId) {
297
+ resumeFailed.delete(sessionId);
298
+ }
299
+
300
+ export function resumeSession(sessionId) {
301
+ const failed = resumeFailed.get(sessionId);
302
+ if (failed) return Promise.reject(failed);
303
+ const cached = resumeInFlight.get(sessionId);
304
+ if (cached) return cached;
305
+ const p = (async () => {
306
+ const r = await api('POST', `/api/sessions/${sessionId}/resume`, {
307
+ // Resolved terminal theme → backend sets a matching COLORFGBG so the
308
+ // CLI's light/dark auto-detection follows the ccsm terminal.
309
+ theme: document.documentElement.dataset.theme,
310
+ // Seed the PTY at the pane's real size so alt-screen CLIs (claude)
311
+ // don't lay out at node-pty's 30-row default and get stranded short.
312
+ ...(estimateTermSize() || {}),
313
+ });
314
+ await loadSessions();
315
+ return r.launched;
316
+ })();
317
+ resumeInFlight.set(sessionId, p);
318
+ p.then(
319
+ () => { resumeInFlight.delete(sessionId); },
320
+ (e) => { resumeInFlight.delete(sessionId); resumeFailed.set(sessionId, e); },
321
+ );
322
+ return p;
323
+ }
324
+
325
+ export async function loadWorkspaces() {
326
+ const r = await api('GET', '/api/workspaces');
327
+ S.workspaces.value = r.workspaces;
328
+ }
329
+
330
+ export async function deleteWorkspace(name) {
331
+ await api('DELETE', `/api/workspaces/${encodeURIComponent(name)}`);
332
+ }
333
+
334
+ export async function refreshAll() {
335
+ await Promise.all([
336
+ loadSessions(),
337
+ loadFolders(),
338
+ loadWorkspaces(),
339
+ ]);
340
+ S.lastRefreshAt.value = Date.now();
341
+ }
342
+
343
+ // List existing CLI sessions discovered on disk for a given cli type.
344
+ // Paginated: page 0 returns all currently-active sessions + the first
345
+ // `limit` non-active (sorted mtime desc). Subsequent pages return the
346
+ // next slice of non-active sessions.
347
+ // Returns { sessions, totalActive, totalNonActive, total, offset, limit, hasMore }.
348
+ export async function listLocalCliSessions(cliType, { offset = 0, limit = 30 } = {}) {
349
+ const qs = `offset=${offset}&limit=${limit}`;
350
+ const r = await api('GET', `/api/cli-sessions/${cliType}?${qs}`);
351
+ return {
352
+ sessions: r.sessions || [],
353
+ totalActive: r.totalActive ?? 0,
354
+ totalNonActive: r.totalNonActive ?? 0,
355
+ total: r.total ?? (r.sessions?.length || 0),
356
+ offset: r.offset ?? offset,
357
+ limit: r.limit ?? limit,
358
+ hasMore: !!r.hasMore,
359
+ };
360
+ }
361
+
362
+ // Adopt an existing upstream CLI session into ccsm. Returns the created
363
+ // (or existing) persistedSessions record.
364
+ export async function adoptSession({ cliId, cliSessionId, cwd, title, folderId }) {
365
+ const r = await api('POST', '/api/sessions/adopt', { cliId, cliSessionId, cwd, title, folderId });
366
+ return r;
367
+ }
368
+
369
+ export async function restartBackend() {
370
+ return api('POST', '/api/restart');
371
+ }
372
+
373
+ let consecutiveOffline = 0;
374
+ export async function pollHealth() {
375
+ const ctrl = new AbortController();
376
+ const t = setTimeout(() => ctrl.abort(), 3000);
377
+ try {
378
+ const r = await fetch(httpBase() + '/api/health', { signal: ctrl.signal });
379
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
380
+ const j = await r.json();
381
+ consecutiveOffline = 0;
382
+ S.serverHealth.value = { state: 'online', version: j.version, pid: j.pid, failureCount: 0 };
383
+ if (!S.hasBootedOnline.value) S.hasBootedOnline.value = true;
384
+ } catch (e) {
385
+ consecutiveOffline++;
386
+ S.serverHealth.value = {
387
+ state: 'offline',
388
+ error: String(e.message || e),
389
+ failureCount: consecutiveOffline,
390
+ };
391
+ } finally {
392
+ clearTimeout(t);
393
+ }
394
+ }