@bakapiano/ccsm 0.22.7 → 0.22.8

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/lib/config.js CHANGED
@@ -26,11 +26,8 @@ const DEFAULT_CLIS = [
26
26
  name: 'Claude Code',
27
27
  command: 'claude',
28
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>'],
29
+ resumeLatestArgs: ['--continue'],
30
+ resumePickerArgs: ['--resume'],
34
31
  shell: 'direct',
35
32
  type: 'claude',
36
33
  builtin: true,
@@ -40,13 +37,8 @@ const DEFAULT_CLIS = [
40
37
  name: 'OpenAI Codex',
41
38
  command: 'codex',
42
39
  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>'],
40
+ resumeLatestArgs: ['resume', '--last'],
41
+ resumePickerArgs: ['resume'],
50
42
  shell: 'direct',
51
43
  type: 'codex',
52
44
  builtin: true,
@@ -56,13 +48,8 @@ const DEFAULT_CLIS = [
56
48
  name: 'GitHub Copilot',
57
49
  command: 'copilot',
58
50
  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>'],
51
+ resumeLatestArgs: ['--continue'],
52
+ resumePickerArgs: ['--resume'],
66
53
  shell: 'direct',
67
54
  type: 'copilot',
68
55
  builtin: true,
@@ -72,10 +59,10 @@ const DEFAULT_CLIS = [
72
59
  const DEFAULTS = {
73
60
  port: 7777,
74
61
  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,
62
+ // How a stopped session is reattached for its cwd: latest asks the CLI
63
+ // for the most recent session in that folder; picker opens the CLI's
64
+ // interactive resume picker for that folder.
65
+ resumeMode: 'latest',
79
66
  // Repos available for cloning into a fresh workspace at launch time.
80
67
  // { name: 'foo', url: 'https://github.com/me/foo.git', defaultSelected: true }
81
68
  repos: [],
@@ -110,6 +97,27 @@ function ensureDataDir() {
110
97
  }
111
98
  }
112
99
 
100
+ function isArgArray(v) {
101
+ return Array.isArray(v);
102
+ }
103
+
104
+ function cloneArgs(v) {
105
+ return Array.isArray(v) ? [...v] : [];
106
+ }
107
+
108
+ function needsBackfill(v) {
109
+ return !Array.isArray(v) || v.length === 0;
110
+ }
111
+
112
+ function migratePickerArgsFromLegacyResumeIdArgs(args) {
113
+ if (!Array.isArray(args)) return [];
114
+ const clean = args.map((a) => String(a));
115
+ if (clean.length === 2 && clean[1].includes('<id>')) {
116
+ if (clean[0] === '--resume' || clean[0] === 'resume') return [clean[0]];
117
+ }
118
+ return [];
119
+ }
120
+
113
121
  // If we find a legacy <repo>/data dir from before the home-dir move AND
114
122
  // no ~/.ccsm yet, copy across. Idempotent — only fires when DATA_DIR is
115
123
  // empty so existing users with both dirs aren't clobbered.
@@ -149,12 +157,13 @@ function mergeWithDefaults(partial) {
149
157
  delete out.autoOpenBrowser;
150
158
  delete out.browserMode;
151
159
  delete out.finderPrompt;
160
+ delete out.reserveWorkspacesForStoppedSessions;
152
161
 
153
162
  if (!Array.isArray(out.repos)) out.repos = DEFAULTS.repos;
154
163
  if (!Array.isArray(out.clis)) out.clis = [];
155
164
  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
165
+ out.resumeMode = out.resumeMode === 'picker' ? 'picker' : 'latest';
166
+ // Always inject builtin CLIs (claude, codex, copilot) if they're missing or were
158
167
  // deleted from a saved config — they're managed by ccsm, the user can
159
168
  // tweak args/shell but can't remove them. Preserves any user
160
169
  // customisation on existing builtin entries.
@@ -162,30 +171,12 @@ function mergeWithDefaults(partial) {
162
171
  const existing = out.clis.find((c) => c.id === def.id);
163
172
  if (existing) {
164
173
  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.
174
+ // Backfill the canonical folder-level resume templates for built-ins.
175
+ // These are integration args, not regular user runtime args.
176
+ if (needsBackfill(existing.resumeLatestArgs)) existing.resumeLatestArgs = cloneArgs(def.resumeLatestArgs);
177
+ if (needsBackfill(existing.resumePickerArgs)) existing.resumePickerArgs = cloneArgs(def.resumePickerArgs);
178
+ delete existing.newSessionIdArgs;
179
+ delete existing.resumeIdArgs;
189
180
  delete existing.resumeArgs;
190
181
  if (!existing.type) existing.type = def.type;
191
182
  } else {
@@ -194,31 +185,38 @@ function mergeWithDefaults(partial) {
194
185
  }
195
186
  // Normalize per-CLI fields.
196
187
  out.clis = out.clis.map((c) => {
197
- const { installed, installPath, resumeArgs, ...rest } = c; // strip computed probe fields + v0.x resumeArgs
188
+ const {
189
+ installed,
190
+ installPath,
191
+ resumeArgs,
192
+ resumeIdArgs,
193
+ newSessionIdArgs,
194
+ ...rest
195
+ } = c; // strip computed probe fields + legacy session-id resume fields
198
196
  const normalized = {
199
197
  ...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 : [],
198
+ args: isArgArray(rest.args) ? rest.args : [],
199
+ resumeLatestArgs: isArgArray(rest.resumeLatestArgs)
200
+ ? rest.resumeLatestArgs
201
+ : (isArgArray(resumeArgs) ? resumeArgs : []),
202
+ resumePickerArgs: isArgArray(rest.resumePickerArgs)
203
+ ? rest.resumePickerArgs
204
+ : migratePickerArgsFromLegacyResumeIdArgs(resumeIdArgs),
203
205
  shell: ['direct', 'pwsh', 'cmd'].includes(rest.shell) ? rest.shell : 'direct',
204
206
  type: ['claude', 'codex', 'copilot', 'other'].includes(rest.type) ? rest.type : 'other',
205
207
  builtin: !!rest.builtin,
206
208
  };
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`.
209
+ // Type-based fallback for non-builtin CLIs (wrappers that just call
210
+ // a known CLI under the hood). If the resume templates are blank, use
211
+ // the same folder-level resume commands as the matching built-in.
214
212
  if (!normalized.builtin && normalized.type !== 'other') {
215
213
  const template = DEFAULT_CLIS.find((d) => d.type === normalized.type);
216
214
  if (template) {
217
- if (normalized.newSessionIdArgs.length === 0) {
218
- normalized.newSessionIdArgs = [...template.newSessionIdArgs];
215
+ if (normalized.resumeLatestArgs.length === 0) {
216
+ normalized.resumeLatestArgs = cloneArgs(template.resumeLatestArgs);
219
217
  }
220
- if (normalized.resumeIdArgs.length === 0) {
221
- normalized.resumeIdArgs = [...template.resumeIdArgs];
218
+ if (normalized.resumePickerArgs.length === 0) {
219
+ normalized.resumePickerArgs = cloneArgs(template.resumePickerArgs);
222
220
  }
223
221
  }
224
222
  }
@@ -10,22 +10,17 @@
10
10
  // {
11
11
  // id: 'sess-...', // ccsm's session id (matches webTerminal id)
12
12
  // cliId: 'claude', // which CLI from config.clis
13
- // cwd: '...', // absolute workspace path
14
- // workspace: 'ws-3', // basename of cwd (display)
13
+ // cwd: '...', // absolute launch path (workspace or repo)
14
+ // workspace: 'ws-3', // workspace display name
15
15
  // title: '', // user-edited label (Configure / sidebar tree)
16
16
  // folderId: null, // nullable; null = "Unsorted" top-level
17
- // repos: ['foo','bar'], // names of repos cloned into cwd at launch
17
+ // repos: ['foo','bar'], // selected repo names cloned at launch
18
18
  // createdAt: 1234,
19
19
  // lastActiveAt: 1234, // updated on attach/input; drives sort
20
20
  // status: 'running'|'exited',
21
21
  // exitedAt: null,
22
22
  // exitCode: null,
23
23
  // pid: null, // current pid if running
24
- // cliSessionId: null, // upstream CLI's session UUID. Pre-assigned
25
- // // at spawn time for CLIs with
26
- // // newSessionIdArgs (claude, copilot); set
27
- // // from disk for adopted sessions. Used
28
- // // for precise --resume <id>.
29
24
  // manualStopped: false, // true only when the user explicitly stopped
30
25
  // // it from ccsm; prevents auto-resume until
31
26
  // // they press Resume.
@@ -38,11 +33,17 @@ const { atomicWriteJson, withFileLock } = require('./atomicJson');
38
33
 
39
34
  const FILE = path.join(DATA_DIR, 'sessions.json');
40
35
 
36
+ function normalizeEntry(entry) {
37
+ if (!entry || typeof entry !== 'object') return entry;
38
+ const { cliSessionId, ...rest } = entry;
39
+ return rest;
40
+ }
41
+
41
42
  async function loadAll() {
42
43
  try {
43
44
  const raw = await fs.readFile(FILE, 'utf8');
44
45
  const j = JSON.parse(raw);
45
- return Array.isArray(j) ? j : [];
46
+ return Array.isArray(j) ? j.map(normalizeEntry) : [];
46
47
  } catch (e) {
47
48
  if (e.code === 'ENOENT') return [];
48
49
  throw e;
@@ -50,40 +51,68 @@ async function loadAll() {
50
51
  }
51
52
 
52
53
  async function saveAll(list) {
53
- await atomicWriteJson(FILE, list);
54
+ await atomicWriteJson(FILE, (list || []).map(normalizeEntry));
54
55
  }
55
56
 
56
57
  function genId() {
57
58
  return 'sess-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
58
59
  }
59
60
 
61
+ function cwdKey(cwd) {
62
+ return path.resolve(String(cwd || '')).toLowerCase();
63
+ }
64
+
65
+ function sameCliAndCwd(entry, cliId, cwd) {
66
+ return entry && entry.cliId === cliId && entry.cwd && cwdKey(entry.cwd) === cwdKey(cwd);
67
+ }
68
+
69
+ function buildEntry(opts) {
70
+ const { cliId, cwd, workspace, repos = [], folderId = null, title = '', status = 'running' } = opts;
71
+ return {
72
+ id: genId(),
73
+ cliId,
74
+ cwd,
75
+ workspace,
76
+ title,
77
+ folderId,
78
+ repos,
79
+ createdAt: Date.now(),
80
+ lastActiveAt: Date.now(),
81
+ status,
82
+ exitedAt: status === 'exited' ? Date.now() : null,
83
+ exitCode: null,
84
+ pid: null,
85
+ manualStopped: false,
86
+ };
87
+ }
88
+
60
89
  async function create(opts) {
61
90
  return withFileLock(FILE, async () => {
62
- const { cliId, cwd, workspace, repos = [], folderId = null, title = '', status = 'running', cliSessionId = null } = opts;
63
91
  const list = await loadAll();
64
- const entry = {
65
- id: genId(),
66
- cliId,
67
- cwd,
68
- workspace,
69
- title,
70
- folderId,
71
- repos,
72
- createdAt: Date.now(),
73
- lastActiveAt: Date.now(),
74
- status,
75
- exitedAt: status === 'exited' ? Date.now() : null,
76
- exitCode: null,
77
- pid: null,
78
- cliSessionId,
79
- manualStopped: false,
80
- };
92
+ const entry = buildEntry(opts);
81
93
  list.push(entry);
82
94
  await saveAll(list);
83
95
  return entry;
84
96
  });
85
97
  }
86
98
 
99
+ async function findByCliAndCwd(cliId, cwd) {
100
+ const list = await loadAll();
101
+ return list.find((s) => sameCliAndCwd(s, cliId, cwd)) || null;
102
+ }
103
+
104
+ async function createOrGetByCliAndCwd(opts) {
105
+ return withFileLock(FILE, async () => {
106
+ const list = await loadAll();
107
+ const existing = list.find((s) => sameCliAndCwd(s, opts.cliId, opts.cwd));
108
+ if (existing) return { entry: existing, created: false };
109
+ const entry = buildEntry(opts);
110
+ list.push(entry);
111
+ await saveAll(list);
112
+ return { entry, created: true };
113
+ });
114
+ }
115
+
87
116
  async function get(id) {
88
117
  const list = await loadAll();
89
118
  return list.find((s) => s.id === id) || null;
@@ -117,6 +146,14 @@ async function markRunning(id, pid) {
117
146
  return update(id, { status: 'running', pid, exitedAt: null, exitCode: null, manualStopped: false, lastActiveAt: Date.now() });
118
147
  }
119
148
 
149
+ async function normalizeStore() {
150
+ return withFileLock(FILE, async () => {
151
+ const list = await loadAll();
152
+ await saveAll(list);
153
+ return list;
154
+ });
155
+ }
156
+
120
157
  async function markExited(id, exitCode) {
121
158
  return update(id, { status: 'exited', exitCode: exitCode ?? null, exitedAt: Date.now(), pid: null });
122
159
  }
@@ -136,6 +173,8 @@ async function setTitle(id, title) {
136
173
  module.exports = {
137
174
  loadAll,
138
175
  create,
176
+ findByCliAndCwd,
177
+ createOrGetByCliAndCwd,
139
178
  get,
140
179
  update,
141
180
  remove,
@@ -144,5 +183,6 @@ module.exports = {
144
183
  touch,
145
184
  setFolder,
146
185
  setTitle,
186
+ normalizeStore,
147
187
  FILE,
148
188
  };
package/lib/winPath.js CHANGED
@@ -5,7 +5,7 @@
5
5
  // installed or put on PATH AFTER the server started. We read the
6
6
  // persisted user PATH from the registry and merge it in, then hand the
7
7
  // deduped env to every child we spawn. server.js uses this for session
8
- // launches; codexSeed uses it for the `<cli> doctor` home probe so the wrapper
8
+ // launches; the Codex theme helper uses it for the `<cli> doctor` home probe so the wrapper
9
9
  // resolves regardless of the parent process's PATH.
10
10
  //
11
11
  // (server.js keeps its own equivalent inline for the hot spawn path; this
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakapiano/ccsm",
3
- "version": "0.22.7",
3
+ "version": "0.22.8",
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",