@bakapiano/ccsm 0.1.0
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 +134 -0
- package/README.md +58 -0
- package/lib/config.js +98 -0
- package/lib/focus.js +235 -0
- package/lib/launcher.js +219 -0
- package/lib/sessions.js +119 -0
- package/lib/snapshot.js +141 -0
- package/lib/workspace.js +229 -0
- package/package.json +49 -0
- package/public/app.js +551 -0
- package/public/index.html +155 -0
- package/public/styles.css +136 -0
- package/server.js +339 -0
package/lib/launcher.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawn, exec } = require('node:child_process');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
|
|
7
|
+
// Terminal kinds we know how to open a new window for. Each entry has:
|
|
8
|
+
// processName — what shows up in tasklist, used by focus.js to find the
|
|
9
|
+
// newly-created window via HWND diff.
|
|
10
|
+
// spawn(opts) — returns { spawned: ChildProcess, args: string[] }.
|
|
11
|
+
//
|
|
12
|
+
// All variants take { cwd, command, args, title, commandShell } and open a
|
|
13
|
+
// new on-screen window with `command args...` running in `cwd`. `commandShell`
|
|
14
|
+
// only matters for the wt kind — see comment there.
|
|
15
|
+
const TERMINAL_KINDS = {
|
|
16
|
+
wt: {
|
|
17
|
+
processName: 'WindowsTerminal.exe',
|
|
18
|
+
spawn({ cwd, command, args, title, commandShell }) {
|
|
19
|
+
// `-w new` forces a new wt window. Without it, recent wt versions
|
|
20
|
+
// honor the user's "windowingBehavior" setting and may fold the
|
|
21
|
+
// invocation into an existing window as a tab — which breaks the
|
|
22
|
+
// "one window per session" promise and the auto-focus HWND diff.
|
|
23
|
+
const wtArgs = ['-w', 'new'];
|
|
24
|
+
if (title) wtArgs.push('--title', title);
|
|
25
|
+
wtArgs.push('-d', cwd);
|
|
26
|
+
if (command) {
|
|
27
|
+
// wt by default runs the command via CreateProcess (no shell), so a
|
|
28
|
+
// PowerShell alias / function / profile-defined name like "ccp" can't
|
|
29
|
+
// be found. Wrapping in pwsh/powershell loads $PROFILE and resolves
|
|
30
|
+
// those names. commandShell="none" reverts to direct invocation for
|
|
31
|
+
// anyone who wants raw exe semantics.
|
|
32
|
+
//
|
|
33
|
+
// We use -EncodedCommand (base64 UTF-16LE) instead of -Command because
|
|
34
|
+
// wt's CLI parser treats `;` as a sub-command separator at any nesting
|
|
35
|
+
// depth — a `;` inside our -Command string would make wt try to launch
|
|
36
|
+
// whatever follows as a brand-new wt sub-command. Base64 has no `;`.
|
|
37
|
+
if (commandShell === 'pwsh' || commandShell === 'powershell') {
|
|
38
|
+
const shellExe = commandShell === 'powershell' ? 'powershell.exe' : 'pwsh.exe';
|
|
39
|
+
wtArgs.push(
|
|
40
|
+
shellExe,
|
|
41
|
+
'-NoExit', '-NoLogo',
|
|
42
|
+
'-EncodedCommand', buildPwshEncodedCommand({ cwd, command, args })
|
|
43
|
+
);
|
|
44
|
+
} else {
|
|
45
|
+
wtArgs.push(command, ...args);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const spawned = spawn('wt.exe', wtArgs, {
|
|
49
|
+
detached: true,
|
|
50
|
+
stdio: 'ignore',
|
|
51
|
+
windowsHide: false,
|
|
52
|
+
});
|
|
53
|
+
spawned.unref();
|
|
54
|
+
return { spawned, args: wtArgs };
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
powershell: {
|
|
58
|
+
processName: 'powershell.exe',
|
|
59
|
+
spawn(opts) {
|
|
60
|
+
return spawnViaCmdStart('powershell.exe', opts);
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
pwsh: {
|
|
64
|
+
processName: 'pwsh.exe',
|
|
65
|
+
spawn(opts) {
|
|
66
|
+
return spawnViaCmdStart('pwsh.exe', opts);
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
cmd: {
|
|
70
|
+
processName: 'cmd.exe',
|
|
71
|
+
spawn({ cwd, command, args, title }) {
|
|
72
|
+
// cmd /K runs a command and stays open. We use `start` to create a new
|
|
73
|
+
// window. The empty "" is the new window's title slot.
|
|
74
|
+
const inner = command
|
|
75
|
+
? [command, ...args].map(quoteForCmd).join(' ')
|
|
76
|
+
: '';
|
|
77
|
+
const cmdLine = inner ? `/K ${inner}` : '/K';
|
|
78
|
+
const spawned = spawn(
|
|
79
|
+
'cmd.exe',
|
|
80
|
+
['/c', 'start', title || '', '/D', cwd, 'cmd.exe', cmdLine],
|
|
81
|
+
{ detached: true, stdio: 'ignore', windowsHide: false }
|
|
82
|
+
);
|
|
83
|
+
spawned.unref();
|
|
84
|
+
return { spawned, args: ['/c', 'start', title || '', '/D', cwd, 'cmd.exe', cmdLine] };
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
function quoteForCmd(s) {
|
|
90
|
+
if (s == null) return '';
|
|
91
|
+
const str = String(s);
|
|
92
|
+
if (/[\s"&|<>^]/.test(str)) return '"' + str.replace(/"/g, '\\"') + '"';
|
|
93
|
+
return str;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function quoteForPwsh(s) {
|
|
97
|
+
return "'" + String(s).replace(/'/g, "''") + "'";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Build a PowerShell command-line string that cd's into cwd then invokes
|
|
101
|
+
// command with args via `&` (works for aliases, functions, scripts, exes).
|
|
102
|
+
function buildPwshScript({ cwd, command, args }) {
|
|
103
|
+
const pieces = [`Set-Location -LiteralPath ${quoteForPwsh(cwd)}`];
|
|
104
|
+
if (command) {
|
|
105
|
+
const argTail = (args || []).map(quoteForPwsh).join(' ');
|
|
106
|
+
pieces.push(`& ${quoteForPwsh(command)} ${argTail}`.trim());
|
|
107
|
+
}
|
|
108
|
+
return pieces.join('; ');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// PowerShell -EncodedCommand expects UTF-16LE base64. We pass scripts this
|
|
112
|
+
// way so the wt CLI parser doesn't munge ';' (which wt uses as a sub-command
|
|
113
|
+
// separator at any nesting depth) or other shell metacharacters.
|
|
114
|
+
function buildPwshEncodedCommand(opts) {
|
|
115
|
+
return Buffer.from(buildPwshScript(opts), 'utf16le').toString('base64');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Helper for the powershell/pwsh kinds: open a new window via `cmd /c start`
|
|
119
|
+
// running powershell/pwsh that cd's to cwd and runs the command.
|
|
120
|
+
function spawnViaCmdStart(psExe, { cwd, command, args, title }) {
|
|
121
|
+
const psScript = buildPwshScript({ cwd, command, args });
|
|
122
|
+
const startArgs = [
|
|
123
|
+
'/c', 'start',
|
|
124
|
+
title || '',
|
|
125
|
+
'/D', cwd,
|
|
126
|
+
psExe,
|
|
127
|
+
'-NoExit', '-NoLogo',
|
|
128
|
+
'-Command', psScript,
|
|
129
|
+
];
|
|
130
|
+
const spawned = spawn('cmd.exe', startArgs, {
|
|
131
|
+
detached: true,
|
|
132
|
+
stdio: 'ignore',
|
|
133
|
+
windowsHide: false,
|
|
134
|
+
});
|
|
135
|
+
spawned.unref();
|
|
136
|
+
return { spawned, args: startArgs };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function launchInTerminal({
|
|
140
|
+
cwd,
|
|
141
|
+
command = null,
|
|
142
|
+
args = [],
|
|
143
|
+
title = null,
|
|
144
|
+
terminal = 'wt',
|
|
145
|
+
commandShell = 'pwsh',
|
|
146
|
+
}) {
|
|
147
|
+
if (!cwd) throw new Error('launchInTerminal: cwd required');
|
|
148
|
+
const kind = TERMINAL_KINDS[terminal];
|
|
149
|
+
if (!kind) throw new Error(`launchInTerminal: unknown terminal "${terminal}"`);
|
|
150
|
+
const resolved = path.resolve(cwd);
|
|
151
|
+
if (!fs.existsSync(resolved)) {
|
|
152
|
+
throw new Error(`launchInTerminal: cwd does not exist: ${resolved}`);
|
|
153
|
+
}
|
|
154
|
+
const { spawned, args: launchedArgs } = kind.spawn({
|
|
155
|
+
cwd: resolved,
|
|
156
|
+
command,
|
|
157
|
+
args,
|
|
158
|
+
title,
|
|
159
|
+
commandShell,
|
|
160
|
+
});
|
|
161
|
+
return {
|
|
162
|
+
pid: spawned.pid,
|
|
163
|
+
cwd: resolved,
|
|
164
|
+
terminal,
|
|
165
|
+
commandShell,
|
|
166
|
+
processName: kind.processName,
|
|
167
|
+
args: launchedArgs,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Convenience wrappers — claudeCommand defaults to 'claude' but should be
|
|
172
|
+
// supplied by the caller from config so the user's preference applies.
|
|
173
|
+
function launchResume({ cwd, sessionId, title = null, terminal = 'wt', claudeCommand = 'claude', commandShell = 'pwsh' }) {
|
|
174
|
+
return launchInTerminal({
|
|
175
|
+
cwd,
|
|
176
|
+
command: claudeCommand,
|
|
177
|
+
args: ['--resume', sessionId],
|
|
178
|
+
title: title || `resume ${sessionId.slice(0, 8)}`,
|
|
179
|
+
terminal,
|
|
180
|
+
commandShell,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function launchNewClaude({
|
|
185
|
+
cwd,
|
|
186
|
+
title = null,
|
|
187
|
+
extraArgs = [],
|
|
188
|
+
terminal = 'wt',
|
|
189
|
+
claudeCommand = 'claude',
|
|
190
|
+
commandShell = 'pwsh',
|
|
191
|
+
}) {
|
|
192
|
+
return launchInTerminal({
|
|
193
|
+
cwd,
|
|
194
|
+
command: claudeCommand,
|
|
195
|
+
args: extraArgs,
|
|
196
|
+
title: title || path.basename(cwd),
|
|
197
|
+
terminal,
|
|
198
|
+
commandShell,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function listTerminalKinds() {
|
|
203
|
+
return Object.keys(TERMINAL_KINDS).map((name) => ({
|
|
204
|
+
name,
|
|
205
|
+
processName: TERMINAL_KINDS[name].processName,
|
|
206
|
+
}));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function processNameFor(terminal) {
|
|
210
|
+
return TERMINAL_KINDS[terminal] ? TERMINAL_KINDS[terminal].processName : null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
module.exports = {
|
|
214
|
+
launchInTerminal,
|
|
215
|
+
launchResume,
|
|
216
|
+
launchNewClaude,
|
|
217
|
+
listTerminalKinds,
|
|
218
|
+
processNameFor,
|
|
219
|
+
};
|
package/lib/sessions.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs/promises');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const { exec } = require('node:child_process');
|
|
7
|
+
|
|
8
|
+
const SESSIONS_DIR = path.join(os.homedir(), '.claude', 'sessions');
|
|
9
|
+
const PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects');
|
|
10
|
+
|
|
11
|
+
function projectSlugForCwd(cwd) {
|
|
12
|
+
return String(cwd).replace(/[:\\]/g, '-');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function getLiveClaudePids() {
|
|
16
|
+
return new Promise((resolve) => {
|
|
17
|
+
exec(
|
|
18
|
+
'tasklist /FI "IMAGENAME eq claude.exe" /FO CSV /NH',
|
|
19
|
+
{ windowsHide: true },
|
|
20
|
+
(err, stdout) => {
|
|
21
|
+
if (err) return resolve(new Set());
|
|
22
|
+
const pids = new Set();
|
|
23
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
24
|
+
const m = line.match(/"claude\.exe","(\d+)"/);
|
|
25
|
+
if (m) pids.add(Number(m[1]));
|
|
26
|
+
}
|
|
27
|
+
resolve(pids);
|
|
28
|
+
}
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function getAiTitleFromJsonl(jsonlPath) {
|
|
34
|
+
try {
|
|
35
|
+
const stat = await fs.stat(jsonlPath);
|
|
36
|
+
if (stat.size === 0) return null;
|
|
37
|
+
const TAIL = 1024 * 1024;
|
|
38
|
+
const offset = Math.max(0, stat.size - TAIL);
|
|
39
|
+
const readSize = stat.size - offset;
|
|
40
|
+
const fd = await fs.open(jsonlPath, 'r');
|
|
41
|
+
try {
|
|
42
|
+
const buf = Buffer.alloc(readSize);
|
|
43
|
+
await fd.read(buf, 0, readSize, offset);
|
|
44
|
+
const text = buf.toString('utf8');
|
|
45
|
+
const lines = text.split('\n');
|
|
46
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
47
|
+
const line = lines[i];
|
|
48
|
+
if (!line.includes('"type":"ai-title"')) continue;
|
|
49
|
+
try {
|
|
50
|
+
const obj = JSON.parse(line);
|
|
51
|
+
if (obj.aiTitle) return obj.aiTitle;
|
|
52
|
+
} catch {}
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
} finally {
|
|
56
|
+
await fd.close();
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function listSessions() {
|
|
64
|
+
let files;
|
|
65
|
+
try {
|
|
66
|
+
files = await fs.readdir(SESSIONS_DIR);
|
|
67
|
+
} catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const livePids = await getLiveClaudePids();
|
|
72
|
+
|
|
73
|
+
const entries = await Promise.all(
|
|
74
|
+
files
|
|
75
|
+
.filter((f) => f.endsWith('.json'))
|
|
76
|
+
.map(async (file) => {
|
|
77
|
+
const fullPath = path.join(SESSIONS_DIR, file);
|
|
78
|
+
try {
|
|
79
|
+
const raw = await fs.readFile(fullPath, 'utf8');
|
|
80
|
+
const s = JSON.parse(raw);
|
|
81
|
+
if (!livePids.has(Number(s.pid))) return null;
|
|
82
|
+
|
|
83
|
+
const slug = projectSlugForCwd(s.cwd);
|
|
84
|
+
const jsonl = path.join(PROJECTS_DIR, slug, `${s.sessionId}.jsonl`);
|
|
85
|
+
const aiTitle = await getAiTitleFromJsonl(jsonl);
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
pid: s.pid,
|
|
89
|
+
sessionId: s.sessionId,
|
|
90
|
+
cwd: s.cwd,
|
|
91
|
+
status: s.status || 'unknown',
|
|
92
|
+
startedAt: s.startedAt || null,
|
|
93
|
+
updatedAt: s.updatedAt || null,
|
|
94
|
+
version: s.version || null,
|
|
95
|
+
kind: s.kind || null,
|
|
96
|
+
name: s.name || null,
|
|
97
|
+
aiTitle: aiTitle || null,
|
|
98
|
+
title: aiTitle || s.name || null,
|
|
99
|
+
jsonlPath: jsonl,
|
|
100
|
+
sessionFile: fullPath,
|
|
101
|
+
};
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
return entries
|
|
109
|
+
.filter(Boolean)
|
|
110
|
+
.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = {
|
|
114
|
+
listSessions,
|
|
115
|
+
projectSlugForCwd,
|
|
116
|
+
getLiveClaudePids,
|
|
117
|
+
SESSIONS_DIR,
|
|
118
|
+
PROJECTS_DIR,
|
|
119
|
+
};
|
package/lib/snapshot.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
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 { listSessions } = require('./sessions');
|
|
7
|
+
const { launchResume } = require('./launcher');
|
|
8
|
+
const { DATA_DIR } = require('./config');
|
|
9
|
+
|
|
10
|
+
const SNAPSHOT_PATH = path.join(DATA_DIR, 'snapshot.json');
|
|
11
|
+
const SNAPSHOT_HISTORY_DIR = path.join(DATA_DIR, 'snapshots');
|
|
12
|
+
|
|
13
|
+
function ensureDirs() {
|
|
14
|
+
for (const d of [DATA_DIR, SNAPSHOT_HISTORY_DIR]) {
|
|
15
|
+
if (!fsSync.existsSync(d)) fsSync.mkdirSync(d, { recursive: true });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function snapshotFromSessions(sessions) {
|
|
20
|
+
return {
|
|
21
|
+
takenAt: Date.now(),
|
|
22
|
+
sessions: sessions.map((s) => ({
|
|
23
|
+
pid: s.pid,
|
|
24
|
+
sessionId: s.sessionId,
|
|
25
|
+
cwd: s.cwd,
|
|
26
|
+
title: s.title || null,
|
|
27
|
+
status: s.status,
|
|
28
|
+
updatedAt: s.updatedAt,
|
|
29
|
+
})),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function tsLabel(ms) {
|
|
34
|
+
const d = new Date(ms);
|
|
35
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
36
|
+
return (
|
|
37
|
+
d.getFullYear() +
|
|
38
|
+
p(d.getMonth() + 1) +
|
|
39
|
+
p(d.getDate()) +
|
|
40
|
+
'-' +
|
|
41
|
+
p(d.getHours()) +
|
|
42
|
+
p(d.getMinutes()) +
|
|
43
|
+
p(d.getSeconds())
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function rotateHistory(keep) {
|
|
48
|
+
if (!keep || keep < 1) return;
|
|
49
|
+
try {
|
|
50
|
+
const files = (await fs.readdir(SNAPSHOT_HISTORY_DIR))
|
|
51
|
+
.filter((f) => f.endsWith('.json'))
|
|
52
|
+
.sort();
|
|
53
|
+
const excess = files.length - keep;
|
|
54
|
+
for (let i = 0; i < excess; i++) {
|
|
55
|
+
await fs.unlink(path.join(SNAPSHOT_HISTORY_DIR, files[i])).catch(() => {});
|
|
56
|
+
}
|
|
57
|
+
} catch {}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function saveSnapshot({ keep = 30 } = {}) {
|
|
61
|
+
ensureDirs();
|
|
62
|
+
const sessions = await listSessions();
|
|
63
|
+
const snap = snapshotFromSessions(sessions);
|
|
64
|
+
const payload = JSON.stringify(snap, null, 2);
|
|
65
|
+
await fs.writeFile(SNAPSHOT_PATH, payload);
|
|
66
|
+
const histPath = path.join(SNAPSHOT_HISTORY_DIR, `${tsLabel(snap.takenAt)}.json`);
|
|
67
|
+
await fs.writeFile(histPath, payload);
|
|
68
|
+
await rotateHistory(keep);
|
|
69
|
+
return snap;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function loadLatestSnapshot() {
|
|
73
|
+
try {
|
|
74
|
+
const raw = await fs.readFile(SNAPSHOT_PATH, 'utf8');
|
|
75
|
+
return JSON.parse(raw);
|
|
76
|
+
} catch (e) {
|
|
77
|
+
if (e.code === 'ENOENT') return null;
|
|
78
|
+
throw e;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function listSnapshotHistory() {
|
|
83
|
+
ensureDirs();
|
|
84
|
+
try {
|
|
85
|
+
const files = (await fs.readdir(SNAPSHOT_HISTORY_DIR))
|
|
86
|
+
.filter((f) => f.endsWith('.json'))
|
|
87
|
+
.sort()
|
|
88
|
+
.reverse();
|
|
89
|
+
return files.map((f) => ({
|
|
90
|
+
file: f,
|
|
91
|
+
path: path.join(SNAPSHOT_HISTORY_DIR, f),
|
|
92
|
+
}));
|
|
93
|
+
} catch {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function loadSnapshotByFile(file) {
|
|
99
|
+
const safe = path.basename(file);
|
|
100
|
+
const p = path.join(SNAPSHOT_HISTORY_DIR, safe);
|
|
101
|
+
const raw = await fs.readFile(p, 'utf8');
|
|
102
|
+
return JSON.parse(raw);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function restoreSnapshot(snap, { terminal = 'wt', claudeCommand = 'claude', commandShell = 'pwsh' } = {}) {
|
|
106
|
+
if (!snap || !Array.isArray(snap.sessions)) {
|
|
107
|
+
return { launched: [], skipped: [] };
|
|
108
|
+
}
|
|
109
|
+
const launched = [];
|
|
110
|
+
const skipped = [];
|
|
111
|
+
for (const s of snap.sessions) {
|
|
112
|
+
if (!s.sessionId || !s.cwd) {
|
|
113
|
+
skipped.push({ ...s, reason: 'missing sessionId or cwd' });
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
const { pid, args } = launchResume({
|
|
118
|
+
cwd: s.cwd,
|
|
119
|
+
sessionId: s.sessionId,
|
|
120
|
+
title: (s.title || s.sessionId.slice(0, 8)),
|
|
121
|
+
terminal,
|
|
122
|
+
claudeCommand,
|
|
123
|
+
commandShell,
|
|
124
|
+
});
|
|
125
|
+
launched.push({ sessionId: s.sessionId, cwd: s.cwd, wtPid: pid, args });
|
|
126
|
+
} catch (e) {
|
|
127
|
+
skipped.push({ ...s, reason: String(e && e.message || e) });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return { launched, skipped };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = {
|
|
134
|
+
saveSnapshot,
|
|
135
|
+
loadLatestSnapshot,
|
|
136
|
+
listSnapshotHistory,
|
|
137
|
+
loadSnapshotByFile,
|
|
138
|
+
restoreSnapshot,
|
|
139
|
+
SNAPSHOT_PATH,
|
|
140
|
+
SNAPSHOT_HISTORY_DIR,
|
|
141
|
+
};
|
package/lib/workspace.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
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 { spawn } = require('node:child_process');
|
|
7
|
+
const { listSessions } = require('./sessions');
|
|
8
|
+
|
|
9
|
+
function normWin(p) {
|
|
10
|
+
return path.resolve(String(p)).toLowerCase();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isInside(child, parent) {
|
|
14
|
+
const c = normWin(child);
|
|
15
|
+
const p = normWin(parent);
|
|
16
|
+
if (c === p) return true;
|
|
17
|
+
const pSep = p.endsWith(path.sep) ? p : p + path.sep;
|
|
18
|
+
return c.startsWith(pSep);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function ensureDir(p) {
|
|
22
|
+
await fs.mkdir(p, { recursive: true });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function dirExists(p) {
|
|
26
|
+
try {
|
|
27
|
+
const st = await fs.stat(p);
|
|
28
|
+
return st.isDirectory();
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function isGitClone(p) {
|
|
35
|
+
return dirExists(path.join(p, '.git'));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function listSubdirs(p) {
|
|
39
|
+
try {
|
|
40
|
+
const entries = await fs.readdir(p, { withFileTypes: true });
|
|
41
|
+
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
42
|
+
} catch (e) {
|
|
43
|
+
if (e.code === 'ENOENT') return [];
|
|
44
|
+
throw e;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function describeWorkspace(workspacePath, repos, busyPaths) {
|
|
49
|
+
const repoStatus = await Promise.all(
|
|
50
|
+
repos.map(async (r) => {
|
|
51
|
+
const repoPath = path.join(workspacePath, r.name);
|
|
52
|
+
const exists = await dirExists(repoPath);
|
|
53
|
+
const cloned = exists ? await isGitClone(repoPath) : false;
|
|
54
|
+
return {
|
|
55
|
+
name: r.name,
|
|
56
|
+
url: r.url,
|
|
57
|
+
path: repoPath,
|
|
58
|
+
exists,
|
|
59
|
+
cloned,
|
|
60
|
+
};
|
|
61
|
+
})
|
|
62
|
+
);
|
|
63
|
+
const inUse = busyPaths.some((p) => isInside(p, workspacePath));
|
|
64
|
+
const sessionsHere = busyPaths.filter((p) => isInside(p, workspacePath));
|
|
65
|
+
return {
|
|
66
|
+
name: path.basename(workspacePath),
|
|
67
|
+
path: workspacePath,
|
|
68
|
+
inUse,
|
|
69
|
+
sessionsHere,
|
|
70
|
+
repos: repoStatus,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function listWorkspaces({ workDir, repos }) {
|
|
75
|
+
await ensureDir(workDir);
|
|
76
|
+
const subdirs = await listSubdirs(workDir);
|
|
77
|
+
const sessions = await listSessions();
|
|
78
|
+
const busyPaths = sessions.map((s) => s.cwd);
|
|
79
|
+
|
|
80
|
+
const workspaces = await Promise.all(
|
|
81
|
+
subdirs.map((name) =>
|
|
82
|
+
describeWorkspace(path.join(workDir, name), repos, busyPaths)
|
|
83
|
+
)
|
|
84
|
+
);
|
|
85
|
+
workspaces.sort((a, b) => {
|
|
86
|
+
if (a.inUse !== b.inUse) return a.inUse ? 1 : -1;
|
|
87
|
+
return a.name.localeCompare(b.name, undefined, { numeric: true });
|
|
88
|
+
});
|
|
89
|
+
return workspaces;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function nextWorkspaceName(existing) {
|
|
93
|
+
const used = new Set(existing.map((w) => w.name.toLowerCase()));
|
|
94
|
+
for (let i = 1; i < 10000; i++) {
|
|
95
|
+
const candidate = `ws-${i}`;
|
|
96
|
+
if (!used.has(candidate)) return candidate;
|
|
97
|
+
}
|
|
98
|
+
throw new Error('Could not allocate workspace name');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function findOrCreateWorkspace({ workDir, repos, requireUnused = true }) {
|
|
102
|
+
const all = await listWorkspaces({ workDir, repos });
|
|
103
|
+
if (requireUnused) {
|
|
104
|
+
const free = all.find((w) => !w.inUse);
|
|
105
|
+
if (free) return { workspace: free, created: false };
|
|
106
|
+
}
|
|
107
|
+
const name = nextWorkspaceName(all);
|
|
108
|
+
const wsPath = path.join(workDir, name);
|
|
109
|
+
await ensureDir(wsPath);
|
|
110
|
+
const ws = await describeWorkspace(wsPath, repos, []);
|
|
111
|
+
return { workspace: ws, created: true };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Parse a single git --progress line. Git emits these on stderr, using \r
|
|
115
|
+
// to overwrite the same line in place, with the format:
|
|
116
|
+
// "<phase>: <pct>% (<cur>/<total>), <detail>"
|
|
117
|
+
// Examples:
|
|
118
|
+
// "Receiving objects: 45% (12345/27384), 23.4 MiB | 5.2 MiB/s"
|
|
119
|
+
// "Resolving deltas: 100% (5847/5847), done."
|
|
120
|
+
function parseGitProgress(line) {
|
|
121
|
+
if (!line) return null;
|
|
122
|
+
const clean = line.replace(/^remote:\s*/, '').trim();
|
|
123
|
+
const m = clean.match(/^([^:]+):\s+(\d+)%\s*(?:\((\d+)\/(\d+)\))?(?:,\s+(.+?))?$/);
|
|
124
|
+
if (!m) return null;
|
|
125
|
+
return {
|
|
126
|
+
phase: m[1].trim(),
|
|
127
|
+
percent: Number(m[2]),
|
|
128
|
+
current: m[3] ? Number(m[3]) : null,
|
|
129
|
+
total: m[4] ? Number(m[4]) : null,
|
|
130
|
+
detail: m[5] ? m[5].trim() : null,
|
|
131
|
+
raw: clean,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function runGit(args, cwd, { onProgress, onLine } = {}) {
|
|
136
|
+
return new Promise((resolve, reject) => {
|
|
137
|
+
const child = spawn('git', args, {
|
|
138
|
+
cwd,
|
|
139
|
+
windowsHide: true,
|
|
140
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
141
|
+
});
|
|
142
|
+
let out = '';
|
|
143
|
+
let err = '';
|
|
144
|
+
let stderrBuf = '';
|
|
145
|
+
child.stdout.on('data', (d) => (out += d.toString()));
|
|
146
|
+
child.stderr.on('data', (d) => {
|
|
147
|
+
const text = d.toString();
|
|
148
|
+
err += text;
|
|
149
|
+
if (onProgress || onLine) {
|
|
150
|
+
stderrBuf += text;
|
|
151
|
+
const parts = stderrBuf.split(/[\r\n]/);
|
|
152
|
+
stderrBuf = parts.pop();
|
|
153
|
+
for (const line of parts) {
|
|
154
|
+
if (!line) continue;
|
|
155
|
+
if (onLine) onLine(line);
|
|
156
|
+
if (onProgress) {
|
|
157
|
+
const p = parseGitProgress(line);
|
|
158
|
+
if (p) onProgress(p);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
child.on('error', reject);
|
|
164
|
+
child.on('close', (code) => {
|
|
165
|
+
if (stderrBuf && (onLine || onProgress)) {
|
|
166
|
+
if (onLine) onLine(stderrBuf);
|
|
167
|
+
if (onProgress) {
|
|
168
|
+
const p = parseGitProgress(stderrBuf);
|
|
169
|
+
if (p) onProgress(p);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (code === 0) resolve({ stdout: out, stderr: err });
|
|
173
|
+
else
|
|
174
|
+
reject(
|
|
175
|
+
Object.assign(
|
|
176
|
+
new Error(`git ${args.join(' ')} exited ${code}: ${err.trim()}`),
|
|
177
|
+
{ code, stdout: out, stderr: err }
|
|
178
|
+
)
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function cloneRepoInto({ workspacePath, repo, onProgress, onLine }) {
|
|
185
|
+
const target = path.join(workspacePath, repo.name);
|
|
186
|
+
if (await dirExists(target)) {
|
|
187
|
+
if (await isGitClone(target)) {
|
|
188
|
+
return { repo: repo.name, action: 'already-cloned', path: target };
|
|
189
|
+
}
|
|
190
|
+
throw new Error(
|
|
191
|
+
`Target ${target} exists but is not a git clone — refusing to overwrite`
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
await runGit(['clone', '--progress', repo.url, repo.name], workspacePath, {
|
|
195
|
+
onProgress,
|
|
196
|
+
onLine,
|
|
197
|
+
});
|
|
198
|
+
return { repo: repo.name, action: 'cloned', path: target };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function ensureReposInWorkspace({ workspacePath, repos, onProgress, onLine, onRepoStart, onRepoEnd }) {
|
|
202
|
+
const results = [];
|
|
203
|
+
for (const repo of repos) {
|
|
204
|
+
if (onRepoStart) onRepoStart(repo);
|
|
205
|
+
try {
|
|
206
|
+
const r = await cloneRepoInto({
|
|
207
|
+
workspacePath,
|
|
208
|
+
repo,
|
|
209
|
+
onProgress: onProgress ? (p) => onProgress(repo, p) : null,
|
|
210
|
+
onLine: onLine ? (l) => onLine(repo, l) : null,
|
|
211
|
+
});
|
|
212
|
+
if (onRepoEnd) onRepoEnd(repo, { ok: true, ...r });
|
|
213
|
+
results.push({ ok: true, ...r });
|
|
214
|
+
} catch (e) {
|
|
215
|
+
const err = { ok: false, repo: repo.name, error: String(e && e.message || e) };
|
|
216
|
+
if (onRepoEnd) onRepoEnd(repo, err);
|
|
217
|
+
results.push(err);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return results;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
module.exports = {
|
|
224
|
+
listWorkspaces,
|
|
225
|
+
findOrCreateWorkspace,
|
|
226
|
+
ensureReposInWorkspace,
|
|
227
|
+
isInside,
|
|
228
|
+
nextWorkspaceName,
|
|
229
|
+
};
|