@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/CLAUDE.md +44 -63
- package/README.md +11 -14
- package/lib/cliActivity.js +11 -114
- package/lib/codexSeed.js +4 -61
- package/lib/config.js +62 -64
- package/lib/persistedSessions.js +68 -28
- package/lib/winPath.js +1 -1
- package/package.json +1 -1
- package/public/css/widgets.css +1 -379
- package/public/js/api.js +5 -27
- package/public/js/components/EntityFormModal.js +2 -2
- package/public/js/pages/ConfigurePage.js +37 -35
- package/public/js/pages/LaunchPage.js +8 -26
- package/public/js/pages/SessionsPage.js +1 -1
- package/public/js/util.js +1 -1
- package/server.js +110 -192
- package/lib/localCliSessions.js +0 -519
- package/public/js/components/AdoptModal.js +0 -261
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
|
|
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.
|
|
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
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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 {
|
|
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:
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
//
|
|
209
|
-
//
|
|
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.
|
|
218
|
-
normalized.
|
|
215
|
+
if (normalized.resumeLatestArgs.length === 0) {
|
|
216
|
+
normalized.resumeLatestArgs = cloneArgs(template.resumeLatestArgs);
|
|
219
217
|
}
|
|
220
|
-
if (normalized.
|
|
221
|
-
normalized.
|
|
218
|
+
if (normalized.resumePickerArgs.length === 0) {
|
|
219
|
+
normalized.resumePickerArgs = cloneArgs(template.resumePickerArgs);
|
|
222
220
|
}
|
|
223
221
|
}
|
|
224
222
|
}
|
package/lib/persistedSessions.js
CHANGED
|
@@ -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
|
|
14
|
-
// workspace: 'ws-3', //
|
|
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'], //
|
|
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;
|
|
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.
|
|
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",
|