@bakapiano/ccsm 0.22.5 → 0.22.7

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 (59) 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 +279 -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 +177 -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 +547 -553
  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 +28 -9
  42. package/public/js/components/XtermTerminal.js +62 -2
  43. package/public/js/components/useDragSort.js +67 -67
  44. package/public/js/dialog.js +67 -67
  45. package/public/js/icons.js +212 -212
  46. package/public/js/main.js +296 -296
  47. package/public/js/pages/AboutPage.js +90 -90
  48. package/public/js/pages/ConfigurePage.js +728 -713
  49. package/public/js/pages/LaunchPage.js +421 -421
  50. package/public/js/pages/RemotePage.js +743 -743
  51. package/public/js/pages/SessionsPage.js +73 -80
  52. package/public/js/state.js +335 -335
  53. package/scripts/dev.js +149 -149
  54. package/scripts/install.js +153 -153
  55. package/scripts/restart-helper.js +96 -96
  56. package/scripts/upgrade-helper.js +687 -687
  57. package/server.js +1820 -1807
  58. package/public/manifest.webmanifest +0 -25
  59. package/public/setup/index.html +0 -567
package/lib/config.js CHANGED
@@ -1,274 +1,279 @@
1
- 'use strict';
2
-
3
- const fs = require('node:fs/promises');
4
- const fsSync = require('node:fs');
5
- const path = require('node:path');
6
- const os = require('node:os');
7
- const { atomicWriteJson, withFileLock } = require('./atomicJson');
8
-
9
- // Data dir lives under ~/.ccsm by default so config survives across upgrades
10
- // (incl. running from a new npx checkout). Override with CCSM_HOME if you
11
- // want a different location.
12
- const DATA_DIR = process.env.CCSM_HOME || path.join(os.homedir(), '.ccsm');
13
- const CONFIG_PATH = path.join(DATA_DIR, 'config.json');
14
-
15
- const LEGACY_DATA_DIR = path.join(__dirname, '..', 'data');
16
-
17
- // v1.0 — wt / system-terminal launch path removed. Sessions are always
18
- // in-page web terminals managed by ccsm. CLI is pluggable: configure one
19
- // or more entries under `clis` (claude, codex, custom wrappers), pick a
20
- // default. Old config keys (`terminal`, `commandShell`, `claudeCommand`,
21
- // `defaultTerminalMode`, `autoFocusOnLaunch`, `focusMovesToCenter`,
22
- // `snapshot*`) are silently dropped on load.
23
- const DEFAULT_CLIS = [
24
- {
25
- id: 'claude',
26
- name: 'Claude Code',
27
- command: 'claude',
28
- args: [],
29
- // Pre-assign the upstream session UUID when starting a NEW session.
30
- // ccsm picks the UUID, passes it via these args, and stores it on
31
- // the record immediately — no need to poll the transcript dir later.
32
- newSessionIdArgs: ['--session-id', '<id>'],
33
- resumeIdArgs: ['--resume', '<id>'],
34
- shell: 'direct',
35
- type: 'claude',
36
- builtin: true,
37
- },
38
- {
39
- id: 'codex',
40
- name: 'OpenAI Codex',
41
- command: 'codex',
42
- args: [],
43
- // codex has no "use this UUID for a new session" flag, but we work
44
- // around it: ccsm seeds a fake rollout file at the right path with
45
- // our chosen UUID (lib/codexSeed.js), then spawns `codex resume <id>`
46
- // so the first launch *is* a resume against our seed. From then on
47
- // codex appends to the same file and resume-by-id keeps working.
48
- newSessionIdArgs: ['resume', '<id>'],
49
- resumeIdArgs: ['resume', '<id>'],
50
- shell: 'direct',
51
- type: 'codex',
52
- builtin: true,
53
- },
54
- {
55
- id: 'copilot',
56
- name: 'GitHub Copilot',
57
- command: 'copilot',
58
- args: [],
59
- // copilot has no `--resume <id>` (space) flag — that parses as `--resume`
60
- // (valueless → session picker) plus a stray positional arg, which hangs on
61
- // a blank screen. `--session-id <id>` both creates AND resumes a session by
62
- // id (`copilot --help`: "Resume an existing session or task by ID"), so we
63
- // use it for both new and resume.
64
- newSessionIdArgs: ['--session-id', '<id>'],
65
- resumeIdArgs: ['--session-id', '<id>'],
66
- shell: 'direct',
67
- type: 'copilot',
68
- builtin: true,
69
- },
70
- ];
71
-
72
- const DEFAULTS = {
73
- port: 7777,
74
- workDir: path.join(os.homedir(), 'ccsm-workspaces'),
75
- // Repos available for cloning into a fresh workspace at launch time.
76
- // { name: 'foo', url: 'https://github.com/me/foo.git', defaultSelected: true }
77
- repos: [],
78
- // Pluggable CLIs. Add custom wrappers or self-hosted
79
- // proxies by appending an entry. defaultCliId picks one for the
80
- // Launch button when the user doesn't override.
81
- clis: DEFAULT_CLIS,
82
- defaultCliId: 'claude',
83
- // External editor command for the "Open in editor" session action.
84
- // Spawned as `<editor> "<cwd>"`; default `code` = VS Code (whose Source
85
- // Control panel doubles as the review-changes view once the folder's
86
- // open). Point it at `cursor`, `code-insiders`, `subl`, … as desired.
87
- editor: 'code',
88
- // Devtunnel state. tunnelId holds the persistent (named) tunnel
89
- // ccsm minted via `devtunnel create` on first Start. Reusing it
90
- // across host restarts keeps the public URL and therefore the
91
- // remote browsers' approval records — stable. `devtunnel delete <id>`
92
- // is invoked when the user explicitly rotates via the Reset button.
93
- devtunnel: { tunnelId: null },
94
- // Provider-agnostic tunnel prefs. When autoStart is on, the backend
95
- // brings the tunnel up during its own startup (server.js boot hook)
96
- // NOT an OS-level autostart. token is persisted so share URLs survive
97
- // a backend restart; it's written ONLY while autoStart is on and is
98
- // stripped from /api/config so remote devices can't read it. provider
99
- // is 'devtunnel' | 'cloudflared'.
100
- tunnel: { autoStart: false, provider: null, token: null },
101
- };
102
-
103
- function ensureDataDir() {
104
- if (!fsSync.existsSync(DATA_DIR)) {
105
- fsSync.mkdirSync(DATA_DIR, { recursive: true });
106
- }
107
- }
108
-
109
- // If we find a legacy <repo>/data dir from before the home-dir move AND
110
- // no ~/.ccsm yet, copy across. Idempotent — only fires when DATA_DIR is
111
- // empty so existing users with both dirs aren't clobbered.
112
- function migrateLegacyDataIfNeeded() {
113
- if (!fsSync.existsSync(LEGACY_DATA_DIR)) return;
114
- if (LEGACY_DATA_DIR === DATA_DIR) return;
115
- ensureDataDir();
116
- const dataEmpty = fsSync.readdirSync(DATA_DIR).length === 0;
117
- if (!dataEmpty) return;
118
- try {
119
- fsSync.cpSync(LEGACY_DATA_DIR, DATA_DIR, { recursive: true });
120
- console.log(`[ccsm] migrated legacy data: ${LEGACY_DATA_DIR} → ${DATA_DIR}`);
121
- } catch (e) {
122
- console.error('[ccsm] legacy migration failed:', e.message);
123
- }
124
- }
125
-
126
- migrateLegacyDataIfNeeded();
127
-
128
- // Strip dropped v0.x keys + clamp shape of survivors. Returns a fresh
129
- // object so callers don't mutate DEFAULTS.
130
- function mergeWithDefaults(partial) {
131
- const out = { ...DEFAULTS, ...partial };
132
- // Deep-merge devtunnel + tunnel so a partial save (just .tunnelId, or
133
- // just .autoStart) doesn't wipe sibling keys.
134
- out.devtunnel = { ...DEFAULTS.devtunnel, ...(partial?.devtunnel || {}) };
135
- out.tunnel = { ...DEFAULTS.tunnel, ...(partial?.tunnel || {}) };
136
- // Drop v0.x keys that the new architecture doesn't use.
137
- delete out.terminal;
138
- delete out.commandShell;
139
- delete out.claudeCommand;
140
- delete out.defaultTerminalMode;
141
- delete out.autoFocusOnLaunch;
142
- delete out.focusMovesToCenter;
143
- delete out.snapshotIntervalMs;
144
- delete out.snapshotHistoryKeep;
145
- delete out.autoOpenBrowser;
146
- delete out.browserMode;
147
- delete out.finderPrompt;
148
-
149
- if (!Array.isArray(out.repos)) out.repos = DEFAULTS.repos;
150
- if (!Array.isArray(out.clis)) out.clis = [];
151
- if (typeof out.editor !== 'string') out.editor = DEFAULTS.editor;
152
- // Always inject builtin CLIs (claude, codex) if they're missing or were
153
- // deleted from a saved config — they're managed by ccsm, the user can
154
- // tweak args/shell but can't remove them. Preserves any user
155
- // customisation on existing builtin entries.
156
- for (const def of DEFAULT_CLIS) {
157
- const existing = out.clis.find((c) => c.id === def.id);
158
- if (existing) {
159
- existing.builtin = true;
160
- // Backfill defaults from the built-in template for any field the
161
- // user's saved copy is missing OR has as an empty array. Empty
162
- // arrays matter because users upgrading from a pre-0.15 config
163
- // never wrote `newSessionIdArgs` (didn't exist), AND a partial
164
- // 0.14→0.15 dev iteration shipped codex with `[]`. Treat both
165
- // the same: a builtin with no template means "use the canonical
166
- // one ccsm now knows about", since these fields are the
167
- // integration contract with the upstream CLI not user knobs.
168
- const needsBackfill = (v) => v == null || (Array.isArray(v) && v.length === 0);
169
- if (needsBackfill(existing.resumeIdArgs)) existing.resumeIdArgs = def.resumeIdArgs;
170
- if (needsBackfill(existing.newSessionIdArgs)) existing.newSessionIdArgs = def.newSessionIdArgs;
171
- // Heal the old broken copilot resume flag: `--resume <id>` (space form)
172
- // isn't valid copilot syntax it parses as a valueless `--resume`
173
- // (session picker) plus a stray positional, hanging on a blank screen.
174
- // The builtin now uses `--session-id <id>`. Migrate configs still
175
- // carrying the old default; genuine user customisations are left alone.
176
- if (existing.id === 'copilot'
177
- && Array.isArray(existing.resumeIdArgs)
178
- && existing.resumeIdArgs.join(' ') === '--resume <id>') {
179
- existing.resumeIdArgs = def.resumeIdArgs;
180
- }
181
- // Drop the v0.x `resumeArgs` fallback — every builtin now has a
182
- // pre-assigned UUID (claude/copilot via flag, codex via seed) so
183
- // resumeIdArgs always applies on resume; the field is dead weight.
184
- delete existing.resumeArgs;
185
- if (!existing.type) existing.type = def.type;
186
- } else {
187
- out.clis.unshift({ ...def });
188
- }
189
- }
190
- // Normalize per-CLI fields.
191
- out.clis = out.clis.map((c) => {
192
- const { installed, installPath, resumeArgs, ...rest } = c; // strip computed probe fields + v0.x resumeArgs
193
- const normalized = {
194
- ...rest,
195
- args: Array.isArray(rest.args) ? rest.args : [],
196
- resumeIdArgs: Array.isArray(rest.resumeIdArgs) ? rest.resumeIdArgs : [],
197
- newSessionIdArgs: Array.isArray(rest.newSessionIdArgs) ? rest.newSessionIdArgs : [],
198
- shell: ['direct', 'pwsh', 'cmd'].includes(rest.shell) ? rest.shell : 'direct',
199
- type: ['claude', 'codex', 'copilot', 'other'].includes(rest.type) ? rest.type : 'other',
200
- builtin: !!rest.builtin,
201
- };
202
- // Type-based fallback for non-builtin CLIs (wrappers that
203
- // just call claude under the hood). If user picked
204
- // type='claude' but left newSessionIdArgs / resumeIdArgs blank,
205
- // assume they want the same args claude / copilot / codex use
206
- // canonically — without this the wrapped CLI gets spawned with
207
- // no UUID and ccsm can never recapture the upstream session.
208
- // Builtins are already handled by the loop above with `def`.
209
- if (!normalized.builtin && normalized.type !== 'other') {
210
- const template = DEFAULT_CLIS.find((d) => d.type === normalized.type);
211
- if (template) {
212
- if (normalized.newSessionIdArgs.length === 0) {
213
- normalized.newSessionIdArgs = [...template.newSessionIdArgs];
214
- }
215
- if (normalized.resumeIdArgs.length === 0) {
216
- normalized.resumeIdArgs = [...template.resumeIdArgs];
217
- }
218
- }
219
- }
220
- return normalized;
221
- });
222
- // Make sure defaultCliId points at an actual CLI; fall back to first.
223
- if (!out.clis.find((c) => c.id === out.defaultCliId)) {
224
- out.defaultCliId = out.clis[0].id;
225
- }
226
- return out;
227
- }
228
-
229
- async function loadConfig() {
230
- ensureDataDir();
231
- try {
232
- const raw = await fs.readFile(CONFIG_PATH, 'utf8');
233
- return mergeWithDefaults(JSON.parse(raw));
234
- } catch (e) {
235
- if (e.code === 'ENOENT') {
236
- const cfg = { ...DEFAULTS };
237
- await atomicWriteJson(CONFIG_PATH, cfg);
238
- return cfg;
239
- }
240
- throw e;
241
- }
242
- }
243
-
244
- async function saveConfig(partial) {
245
- ensureDataDir();
246
- return withFileLock(CONFIG_PATH, async () => {
247
- const current = await loadConfig();
248
- // mergeWithDefaults re-merges nested objects (devtunnel, tunnel)
249
- // against DEFAULTS only, so a partial save like
250
- // saveConfig({ tunnel: { autoStart: true } }) would reset the
251
- // sibling token/provider back to defaults. Pre-merge the nested
252
- // blocks against `current` so a partial update preserves siblings.
253
- const merged = { ...current, ...partial };
254
- if (partial && partial.devtunnel) {
255
- merged.devtunnel = { ...current.devtunnel, ...partial.devtunnel };
256
- }
257
- if (partial && partial.tunnel) {
258
- merged.tunnel = { ...current.tunnel, ...partial.tunnel };
259
- }
260
- const next = mergeWithDefaults(merged);
261
- await atomicWriteJson(CONFIG_PATH, next);
262
- return next;
263
- });
264
- }
265
-
266
- module.exports = {
267
- loadConfig,
268
- saveConfig,
269
- DATA_DIR,
270
- CONFIG_PATH,
271
- LEGACY_DATA_DIR,
272
- DEFAULTS,
273
- DEFAULT_CLIS,
274
- };
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs/promises');
4
+ const fsSync = require('node:fs');
5
+ const path = require('node:path');
6
+ const os = require('node:os');
7
+ const { atomicWriteJson, withFileLock } = require('./atomicJson');
8
+
9
+ // Data dir lives under ~/.ccsm by default so config survives across upgrades
10
+ // (incl. running from a new npx checkout). Override with CCSM_HOME if you
11
+ // want a different location.
12
+ const DATA_DIR = process.env.CCSM_HOME || path.join(os.homedir(), '.ccsm');
13
+ const CONFIG_PATH = path.join(DATA_DIR, 'config.json');
14
+
15
+ const LEGACY_DATA_DIR = path.join(__dirname, '..', 'data');
16
+
17
+ // v1.0 — wt / system-terminal launch path removed. Sessions are always
18
+ // in-page web terminals managed by ccsm. CLI is pluggable: configure one
19
+ // or more entries under `clis` (claude, codex, custom wrappers), pick a
20
+ // default. Old config keys (`terminal`, `commandShell`, `claudeCommand`,
21
+ // `defaultTerminalMode`, `autoFocusOnLaunch`, `focusMovesToCenter`,
22
+ // `snapshot*`) are silently dropped on load.
23
+ const DEFAULT_CLIS = [
24
+ {
25
+ id: 'claude',
26
+ name: 'Claude Code',
27
+ command: 'claude',
28
+ args: [],
29
+ // Pre-assign the upstream session UUID when starting a NEW session.
30
+ // ccsm picks the UUID, passes it via these args, and stores it on
31
+ // the record immediately — no need to poll the transcript dir later.
32
+ newSessionIdArgs: ['--session-id', '<id>'],
33
+ resumeIdArgs: ['--resume', '<id>'],
34
+ shell: 'direct',
35
+ type: 'claude',
36
+ builtin: true,
37
+ },
38
+ {
39
+ id: 'codex',
40
+ name: 'OpenAI Codex',
41
+ command: 'codex',
42
+ args: [],
43
+ // codex has no "use this UUID for a new session" flag, but we work
44
+ // around it: ccsm seeds a fake rollout file at the right path with
45
+ // our chosen UUID (lib/codexSeed.js), then spawns `codex resume <id>`
46
+ // so the first launch *is* a resume against our seed. From then on
47
+ // codex appends to the same file and resume-by-id keeps working.
48
+ newSessionIdArgs: ['resume', '<id>'],
49
+ resumeIdArgs: ['resume', '<id>'],
50
+ shell: 'direct',
51
+ type: 'codex',
52
+ builtin: true,
53
+ },
54
+ {
55
+ id: 'copilot',
56
+ name: 'GitHub Copilot',
57
+ command: 'copilot',
58
+ args: [],
59
+ // copilot has no `--resume <id>` (space) flag — that parses as `--resume`
60
+ // (valueless → session picker) plus a stray positional arg, which hangs on
61
+ // a blank screen. `--session-id <id>` both creates AND resumes a session by
62
+ // id (`copilot --help`: "Resume an existing session or task by ID"), so we
63
+ // use it for both new and resume.
64
+ newSessionIdArgs: ['--session-id', '<id>'],
65
+ resumeIdArgs: ['--session-id', '<id>'],
66
+ shell: 'direct',
67
+ type: 'copilot',
68
+ builtin: true,
69
+ },
70
+ ];
71
+
72
+ const DEFAULTS = {
73
+ port: 7777,
74
+ workDir: path.join(os.homedir(), 'ccsm-workspaces'),
75
+ // When false, only running sessions reserve a workspace. When true,
76
+ // any persisted session with a cwd keeps that workspace in-use until
77
+ // the session record is deleted.
78
+ reserveWorkspacesForStoppedSessions: false,
79
+ // Repos available for cloning into a fresh workspace at launch time.
80
+ // { name: 'foo', url: 'https://github.com/me/foo.git', defaultSelected: true }
81
+ repos: [],
82
+ // Pluggable CLIs. Add custom wrappers or self-hosted
83
+ // proxies by appending an entry. defaultCliId picks one for the
84
+ // Launch button when the user doesn't override.
85
+ clis: DEFAULT_CLIS,
86
+ defaultCliId: 'claude',
87
+ // External editor command for the "Open in editor" session action.
88
+ // Spawned as `<editor> "<cwd>"`; default `code` = VS Code (whose Source
89
+ // Control panel doubles as the review-changes view once the folder's
90
+ // open). Point it at `cursor`, `code-insiders`, `subl`, as desired.
91
+ editor: 'code',
92
+ // Devtunnel state. tunnelId holds the persistent (named) tunnel
93
+ // ccsm minted via `devtunnel create` on first Start. Reusing it
94
+ // across host restarts keeps the public URL — and therefore the
95
+ // remote browsers' approval records stable. `devtunnel delete <id>`
96
+ // is invoked when the user explicitly rotates via the Reset button.
97
+ devtunnel: { tunnelId: null },
98
+ // Provider-agnostic tunnel prefs. When autoStart is on, the backend
99
+ // brings the tunnel up during its own startup (server.js boot hook) —
100
+ // NOT an OS-level autostart. token is persisted so share URLs survive
101
+ // a backend restart; it's written ONLY while autoStart is on and is
102
+ // stripped from /api/config so remote devices can't read it. provider
103
+ // is 'devtunnel' | 'cloudflared'.
104
+ tunnel: { autoStart: false, provider: null, token: null },
105
+ };
106
+
107
+ function ensureDataDir() {
108
+ if (!fsSync.existsSync(DATA_DIR)) {
109
+ fsSync.mkdirSync(DATA_DIR, { recursive: true });
110
+ }
111
+ }
112
+
113
+ // If we find a legacy <repo>/data dir from before the home-dir move AND
114
+ // no ~/.ccsm yet, copy across. Idempotent — only fires when DATA_DIR is
115
+ // empty so existing users with both dirs aren't clobbered.
116
+ function migrateLegacyDataIfNeeded() {
117
+ if (!fsSync.existsSync(LEGACY_DATA_DIR)) return;
118
+ if (LEGACY_DATA_DIR === DATA_DIR) return;
119
+ ensureDataDir();
120
+ const dataEmpty = fsSync.readdirSync(DATA_DIR).length === 0;
121
+ if (!dataEmpty) return;
122
+ try {
123
+ fsSync.cpSync(LEGACY_DATA_DIR, DATA_DIR, { recursive: true });
124
+ console.log(`[ccsm] migrated legacy data: ${LEGACY_DATA_DIR} → ${DATA_DIR}`);
125
+ } catch (e) {
126
+ console.error('[ccsm] legacy migration failed:', e.message);
127
+ }
128
+ }
129
+
130
+ migrateLegacyDataIfNeeded();
131
+
132
+ // Strip dropped v0.x keys + clamp shape of survivors. Returns a fresh
133
+ // object so callers don't mutate DEFAULTS.
134
+ function mergeWithDefaults(partial) {
135
+ const out = { ...DEFAULTS, ...partial };
136
+ // Deep-merge devtunnel + tunnel so a partial save (just .tunnelId, or
137
+ // just .autoStart) doesn't wipe sibling keys.
138
+ out.devtunnel = { ...DEFAULTS.devtunnel, ...(partial?.devtunnel || {}) };
139
+ out.tunnel = { ...DEFAULTS.tunnel, ...(partial?.tunnel || {}) };
140
+ // Drop v0.x keys that the new architecture doesn't use.
141
+ delete out.terminal;
142
+ delete out.commandShell;
143
+ delete out.claudeCommand;
144
+ delete out.defaultTerminalMode;
145
+ delete out.autoFocusOnLaunch;
146
+ delete out.focusMovesToCenter;
147
+ delete out.snapshotIntervalMs;
148
+ delete out.snapshotHistoryKeep;
149
+ delete out.autoOpenBrowser;
150
+ delete out.browserMode;
151
+ delete out.finderPrompt;
152
+
153
+ if (!Array.isArray(out.repos)) out.repos = DEFAULTS.repos;
154
+ if (!Array.isArray(out.clis)) out.clis = [];
155
+ if (typeof out.editor !== 'string') out.editor = DEFAULTS.editor;
156
+ out.reserveWorkspacesForStoppedSessions = out.reserveWorkspacesForStoppedSessions === true;
157
+ // Always inject builtin CLIs (claude, codex) if they're missing or were
158
+ // deleted from a saved config — they're managed by ccsm, the user can
159
+ // tweak args/shell but can't remove them. Preserves any user
160
+ // customisation on existing builtin entries.
161
+ for (const def of DEFAULT_CLIS) {
162
+ const existing = out.clis.find((c) => c.id === def.id);
163
+ if (existing) {
164
+ existing.builtin = true;
165
+ // Backfill defaults from the built-in template for any field the
166
+ // user's saved copy is missing OR has as an empty array. Empty
167
+ // arrays matter because users upgrading from a pre-0.15 config
168
+ // never wrote `newSessionIdArgs` (didn't exist), AND a partial
169
+ // 0.14→0.15 dev iteration shipped codex with `[]`. Treat both
170
+ // the same: a builtin with no template means "use the canonical
171
+ // one ccsm now knows about", since these fields are the
172
+ // integration contract with the upstream CLI not user knobs.
173
+ const needsBackfill = (v) => v == null || (Array.isArray(v) && v.length === 0);
174
+ if (needsBackfill(existing.resumeIdArgs)) existing.resumeIdArgs = def.resumeIdArgs;
175
+ if (needsBackfill(existing.newSessionIdArgs)) existing.newSessionIdArgs = def.newSessionIdArgs;
176
+ // Heal the old broken copilot resume flag: `--resume <id>` (space form)
177
+ // isn't valid copilot syntax — it parses as a valueless `--resume`
178
+ // (session picker) plus a stray positional, hanging on a blank screen.
179
+ // The builtin now uses `--session-id <id>`. Migrate configs still
180
+ // carrying the old default; genuine user customisations are left alone.
181
+ if (existing.id === 'copilot'
182
+ && Array.isArray(existing.resumeIdArgs)
183
+ && existing.resumeIdArgs.join(' ') === '--resume <id>') {
184
+ existing.resumeIdArgs = def.resumeIdArgs;
185
+ }
186
+ // Drop the v0.x `resumeArgs` fallback — every builtin now has a
187
+ // pre-assigned UUID (claude/copilot via flag, codex via seed) so
188
+ // resumeIdArgs always applies on resume; the field is dead weight.
189
+ delete existing.resumeArgs;
190
+ if (!existing.type) existing.type = def.type;
191
+ } else {
192
+ out.clis.unshift({ ...def });
193
+ }
194
+ }
195
+ // Normalize per-CLI fields.
196
+ out.clis = out.clis.map((c) => {
197
+ const { installed, installPath, resumeArgs, ...rest } = c; // strip computed probe fields + v0.x resumeArgs
198
+ const normalized = {
199
+ ...rest,
200
+ args: Array.isArray(rest.args) ? rest.args : [],
201
+ resumeIdArgs: Array.isArray(rest.resumeIdArgs) ? rest.resumeIdArgs : [],
202
+ newSessionIdArgs: Array.isArray(rest.newSessionIdArgs) ? rest.newSessionIdArgs : [],
203
+ shell: ['direct', 'pwsh', 'cmd'].includes(rest.shell) ? rest.shell : 'direct',
204
+ type: ['claude', 'codex', 'copilot', 'other'].includes(rest.type) ? rest.type : 'other',
205
+ builtin: !!rest.builtin,
206
+ };
207
+ // Type-based fallback for non-builtin CLIs (wrappers that
208
+ // just call claude under the hood). If user picked
209
+ // type='claude' but left newSessionIdArgs / resumeIdArgs blank,
210
+ // assume they want the same args claude / copilot / codex use
211
+ // canonically — without this the wrapped CLI gets spawned with
212
+ // no UUID and ccsm can never recapture the upstream session.
213
+ // Builtins are already handled by the loop above with `def`.
214
+ if (!normalized.builtin && normalized.type !== 'other') {
215
+ const template = DEFAULT_CLIS.find((d) => d.type === normalized.type);
216
+ if (template) {
217
+ if (normalized.newSessionIdArgs.length === 0) {
218
+ normalized.newSessionIdArgs = [...template.newSessionIdArgs];
219
+ }
220
+ if (normalized.resumeIdArgs.length === 0) {
221
+ normalized.resumeIdArgs = [...template.resumeIdArgs];
222
+ }
223
+ }
224
+ }
225
+ return normalized;
226
+ });
227
+ // Make sure defaultCliId points at an actual CLI; fall back to first.
228
+ if (!out.clis.find((c) => c.id === out.defaultCliId)) {
229
+ out.defaultCliId = out.clis[0].id;
230
+ }
231
+ return out;
232
+ }
233
+
234
+ async function loadConfig() {
235
+ ensureDataDir();
236
+ try {
237
+ const raw = await fs.readFile(CONFIG_PATH, 'utf8');
238
+ return mergeWithDefaults(JSON.parse(raw));
239
+ } catch (e) {
240
+ if (e.code === 'ENOENT') {
241
+ const cfg = { ...DEFAULTS };
242
+ await atomicWriteJson(CONFIG_PATH, cfg);
243
+ return cfg;
244
+ }
245
+ throw e;
246
+ }
247
+ }
248
+
249
+ async function saveConfig(partial) {
250
+ ensureDataDir();
251
+ return withFileLock(CONFIG_PATH, async () => {
252
+ const current = await loadConfig();
253
+ // mergeWithDefaults re-merges nested objects (devtunnel, tunnel)
254
+ // against DEFAULTS only, so a partial save like
255
+ // saveConfig({ tunnel: { autoStart: true } }) would reset the
256
+ // sibling token/provider back to defaults. Pre-merge the nested
257
+ // blocks against `current` so a partial update preserves siblings.
258
+ const merged = { ...current, ...partial };
259
+ if (partial && partial.devtunnel) {
260
+ merged.devtunnel = { ...current.devtunnel, ...partial.devtunnel };
261
+ }
262
+ if (partial && partial.tunnel) {
263
+ merged.tunnel = { ...current.tunnel, ...partial.tunnel };
264
+ }
265
+ const next = mergeWithDefaults(merged);
266
+ await atomicWriteJson(CONFIG_PATH, next);
267
+ return next;
268
+ });
269
+ }
270
+
271
+ module.exports = {
272
+ loadConfig,
273
+ saveConfig,
274
+ DATA_DIR,
275
+ CONFIG_PATH,
276
+ LEGACY_DATA_DIR,
277
+ DEFAULTS,
278
+ DEFAULT_CLIS,
279
+ };