@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
|
@@ -14,8 +14,7 @@ import { ProgressList } from '../components/ProgressList.js';
|
|
|
14
14
|
import { Modal } from '../components/Modal.js';
|
|
15
15
|
import { PickerPanel } from '../components/Picker.js';
|
|
16
16
|
import { DirectoryPicker } from '../components/DirectoryPicker.js';
|
|
17
|
-
import {
|
|
18
|
-
import { BrandMark, IconTerminal, IconFolder, IconFolderOpen, IconBranch, IconChevronDown, IconForCliType, IconClaudeColor, IconCodexColor, IconCopilotColor, IconSparkle, IconWorkspace, IconArrowRight } from '../icons.js';
|
|
17
|
+
import { BrandMark, IconTerminal, IconFolder, IconFolderOpen, IconBranch, IconChevronDown, IconForCliType, IconClaudeColor, IconCodexColor, IconCopilotColor, IconSparkle, IconWorkspace } from '../icons.js';
|
|
19
18
|
|
|
20
19
|
const ROOT_ID = 'newSessionProgress';
|
|
21
20
|
const selectedRepos = signal(new Set());
|
|
@@ -80,7 +79,6 @@ function LaunchHero() {
|
|
|
80
79
|
const [busy, setBusy] = useState(false);
|
|
81
80
|
const [result, setResult] = useState('');
|
|
82
81
|
const [openPicker, setOpenPicker] = useState(null); // 'cli' | 'folder' | 'workdir' | null
|
|
83
|
-
const [adoptOpen, setAdoptOpen] = useState(false);
|
|
84
82
|
|
|
85
83
|
// If config arrives after first render (cliId === '') OR the saved
|
|
86
84
|
// cli was removed, snap to the current default.
|
|
@@ -186,13 +184,13 @@ function LaunchHero() {
|
|
|
186
184
|
{ value: 'other', label: 'Other', icon: html`<${IconTerminal} />` },
|
|
187
185
|
],
|
|
188
186
|
onChange: (v, next) => {
|
|
189
|
-
const presets = { claude: { command: 'claude',
|
|
190
|
-
codex: { command: 'codex',
|
|
191
|
-
copilot: { command: 'copilot',
|
|
187
|
+
const presets = { claude: { command: 'claude', resumeLatestArgs: '--continue', resumePickerArgs: '--resume', name: 'Claude Code' },
|
|
188
|
+
codex: { command: 'codex', resumeLatestArgs: 'resume --last', resumePickerArgs: 'resume', name: 'OpenAI Codex' },
|
|
189
|
+
copilot: { command: 'copilot', resumeLatestArgs: '--continue', resumePickerArgs: '--resume', name: 'GitHub Copilot' },
|
|
192
190
|
other: {} }[v] || {};
|
|
193
191
|
const patch = {};
|
|
194
|
-
if (presets.
|
|
195
|
-
if (presets.
|
|
192
|
+
if (presets.resumeLatestArgs != null) patch.resumeLatestArgs = presets.resumeLatestArgs;
|
|
193
|
+
if (presets.resumePickerArgs != null) patch.resumePickerArgs = presets.resumePickerArgs;
|
|
196
194
|
if (!next.command || !next.command.trim()) patch.command = presets.command || '';
|
|
197
195
|
if (!next.name || !next.name.trim()) patch.name = presets.name || '';
|
|
198
196
|
return patch;
|
|
@@ -201,10 +199,8 @@ function LaunchHero() {
|
|
|
201
199
|
{ key: 'name', label: 'Name', placeholder: 'My CLI', required: true },
|
|
202
200
|
{ key: 'command', label: 'Command', mono: true, placeholder: 'claude / codex / ...', required: true },
|
|
203
201
|
{ key: 'args', label: 'Args (space-separated)', mono: true, placeholder: '' },
|
|
204
|
-
{ key: '
|
|
205
|
-
|
|
206
|
-
{ key: 'resumeIdArgs', label: 'Resume by id args', mono: true, placeholder: '--resume <id>',
|
|
207
|
-
hint: 'Use <id> as the placeholder for the captured upstream session UUID.' },
|
|
202
|
+
{ key: 'resumeLatestArgs', label: 'Resume latest args', mono: true, placeholder: '--continue' },
|
|
203
|
+
{ key: 'resumePickerArgs', label: 'Resume picker args', mono: true, placeholder: '--resume' },
|
|
208
204
|
{ key: 'shell', label: 'Shell', type: 'select', default: 'direct', options: [
|
|
209
205
|
{ value: 'direct', label: 'direct (real .exe / .cmd)' },
|
|
210
206
|
{ value: 'pwsh', label: 'pwsh (PowerShell aliases & functions)' },
|
|
@@ -376,20 +372,6 @@ function LaunchHero() {
|
|
|
376
372
|
</span>`}
|
|
377
373
|
</button>
|
|
378
374
|
|
|
379
|
-
<button type="button" class="launch-import-link"
|
|
380
|
-
onClick=${() => setAdoptOpen(true)}>
|
|
381
|
-
or import existing<span class="launch-import-arrow" aria-hidden="true"><${IconArrowRight} /></span>
|
|
382
|
-
</button>
|
|
383
|
-
|
|
384
|
-
${adoptOpen ? html`
|
|
385
|
-
<${AdoptModal} onClose=${() => setAdoptOpen(false)}
|
|
386
|
-
onAdopted=${async (id) => {
|
|
387
|
-
setAdoptOpen(false);
|
|
388
|
-
await refreshAll();
|
|
389
|
-
if (id) selectSession(id);
|
|
390
|
-
selectTab('sessions');
|
|
391
|
-
}} />` : null}
|
|
392
|
-
|
|
393
375
|
<${ProgressList} rootId=${ROOT_ID} />
|
|
394
376
|
${result ? html`<div class="launch-status mono">${result}</div>` : null}
|
|
395
377
|
</div>`;
|
|
@@ -198,7 +198,7 @@ export function SessionsPage() {
|
|
|
198
198
|
const cli = (config.value?.clis || []).find((c) => c.id === session.cliId);
|
|
199
199
|
const cliForSession = (s) => (config.value?.clis || []).find((c) => c.id === s.cliId);
|
|
200
200
|
const switchableClis = cli
|
|
201
|
-
? (config.value?.clis || []).filter((c) => c.id !== cli.id
|
|
201
|
+
? (config.value?.clis || []).filter((c) => c.id !== cli.id)
|
|
202
202
|
: [];
|
|
203
203
|
const running = session.status === 'running';
|
|
204
204
|
const openSessions = Array.from(openTerminalIds)
|
package/public/js/util.js
CHANGED
|
@@ -24,7 +24,7 @@ export function nowClock() {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
// Shell-style argv tokenizer / formatter used by the CLI editor's
|
|
27
|
-
// args /
|
|
27
|
+
// args / resumeLatestArgs / resumePickerArgs fields. Modeled on POSIX sh
|
|
28
28
|
// word splitting + bash quoting (the rules every dev already has in
|
|
29
29
|
// muscle memory) — not a full shell parser. Handles:
|
|
30
30
|
// bare token -Model → "-Model"
|
package/server.js
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
|
|
4
4
|
const path = require('node:path');
|
|
5
5
|
const os = require('node:os');
|
|
6
|
-
const crypto = require('node:crypto');
|
|
7
6
|
const express = require('express');
|
|
8
7
|
|
|
9
8
|
const { loadConfig, saveConfig, DATA_DIR } = require('./lib/config');
|
|
@@ -18,14 +17,6 @@ const persistedSessions = require('./lib/persistedSessions');
|
|
|
18
17
|
const folders = require('./lib/folders');
|
|
19
18
|
const tunnel = require('./lib/tunnel');
|
|
20
19
|
const devices = require('./lib/devices');
|
|
21
|
-
// Upstream CLI session-id capture used to live in lib/cliSessionWatcher
|
|
22
|
-
// (poll the CLI's transcript dir, match by cwd). It's gone now — for
|
|
23
|
-
// CLIs that expose a "set the UUID for a new session" flag (claude +
|
|
24
|
-
// copilot both have --session-id <uuid>) we pre-generate the id in
|
|
25
|
-
// /api/sessions/new and pass it via cli.newSessionIdArgs. For CLIs
|
|
26
|
-
// without that flag (codex) we just don't capture an id; the user
|
|
27
|
-
// gets cli.resumeArgs (--continue / resume --last) on relaunch.
|
|
28
|
-
const localCliSessions = require('./lib/localCliSessions');
|
|
29
20
|
|
|
30
21
|
// One unified exit path: kill PTY children, then exit. v1.0 dropped the
|
|
31
22
|
// snapshot-on-exit behaviour because the new persistedSessions store is
|
|
@@ -302,8 +293,8 @@ function spawnCliSession({ cli, cwd, sessionId, meta, extraArgs = [], theme, col
|
|
|
302
293
|
// terminal — the "字体颜色和背景重复" bug. --settings is session-scoped, so
|
|
303
294
|
// the user's global ~/.claude/settings.json is left untouched, and ccsm
|
|
304
295
|
// sessions Just Work on a fresh machine without anyone running /theme auto.
|
|
305
|
-
// (Injected here as an integration arg,
|
|
306
|
-
//
|
|
296
|
+
// (Injected here as an integration arg, not via the user-editable
|
|
297
|
+
// cli.args, so it reaches existing configs too.)
|
|
307
298
|
// Skip the injection entirely if the user already put their own --settings
|
|
308
299
|
// in cli.args — claude deep-merges multiple --settings (verified: later ones
|
|
309
300
|
// win per-key), so ours would silently override a theme they set on purpose.
|
|
@@ -474,14 +465,55 @@ function stripTunnelKeys(cfg) {
|
|
|
474
465
|
}
|
|
475
466
|
|
|
476
467
|
function workspaceOccupancySessions(sessions, cfg) {
|
|
477
|
-
|
|
478
|
-
return (sessions || []).filter((s) =>
|
|
479
|
-
s && s.cwd && (includeStopped || s.status === 'running')
|
|
480
|
-
);
|
|
468
|
+
return (sessions || []).filter((s) => s && s.cwd);
|
|
481
469
|
}
|
|
482
470
|
|
|
483
471
|
function workspaceOccupancyLabel(cfg) {
|
|
484
|
-
return
|
|
472
|
+
return 'session';
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function launchCwdFor(workspace, wantedRepos, explicitCwd) {
|
|
476
|
+
return explicitCwd
|
|
477
|
+
? workspace.path
|
|
478
|
+
: (wantedRepos.length === 1 ? path.join(workspace.path, wantedRepos[0].name) : workspace.path);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function resumeMode(cfg) {
|
|
482
|
+
return cfg?.resumeMode === 'picker' ? 'picker' : 'latest';
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function buildFolderResumeArgs(cli, cfg) {
|
|
486
|
+
const mode = resumeMode(cfg);
|
|
487
|
+
const field = mode === 'picker' ? 'resumePickerArgs' : 'resumeLatestArgs';
|
|
488
|
+
const args = Array.isArray(cli?.[field]) ? cli[field] : [];
|
|
489
|
+
if (args.length === 0) {
|
|
490
|
+
throw new Error(`CLI ${cli?.id || '(unknown)'} has no ${field} configured`);
|
|
491
|
+
}
|
|
492
|
+
return args;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async function spawnSessionRecord({ record, cli, cfg, body, resume = false }) {
|
|
496
|
+
const live = webTerminal.get(record.id);
|
|
497
|
+
if (live && !live.exitedAt) {
|
|
498
|
+
if (record.status !== 'running' || record.pid !== live.meta.pid) {
|
|
499
|
+
try { await persistedSessions.markRunning(record.id, live.meta.pid); } catch {}
|
|
500
|
+
}
|
|
501
|
+
return { id: record.id, pid: live.meta.pid, cliId: record.cliId };
|
|
502
|
+
}
|
|
503
|
+
const themeArgs = await codexThemeArgs(cli, body && body.theme);
|
|
504
|
+
const folderResumeArgs = resume ? buildFolderResumeArgs(cli, cfg) : [];
|
|
505
|
+
const entry = spawnCliSession({
|
|
506
|
+
cli,
|
|
507
|
+
cwd: record.cwd,
|
|
508
|
+
sessionId: record.id,
|
|
509
|
+
meta: { title: record.title || record.workspace, workspace: record.workspace, cwd: record.cwd },
|
|
510
|
+
extraArgs: [...themeArgs, ...folderResumeArgs],
|
|
511
|
+
theme: body && body.theme,
|
|
512
|
+
cols: body && body.cols,
|
|
513
|
+
rows: body && body.rows,
|
|
514
|
+
});
|
|
515
|
+
await persistedSessions.markRunning(record.id, entry.meta.pid);
|
|
516
|
+
return { id: record.id, pid: entry.meta.pid, cliId: cli.id };
|
|
485
517
|
}
|
|
486
518
|
|
|
487
519
|
app.get('/api/config', asyncH(async (_req, res) => {
|
|
@@ -655,10 +687,9 @@ app.put('/api/sessions/:id', asyncH(async (req, res) => {
|
|
|
655
687
|
res.json({ session: updated });
|
|
656
688
|
}));
|
|
657
689
|
|
|
658
|
-
// Switch the CLI config used to resume an existing session.
|
|
659
|
-
//
|
|
660
|
-
//
|
|
661
|
-
// to another) so its captured upstream cliSessionId stays meaningful.
|
|
690
|
+
// Switch the CLI config used to resume an existing session. Folder-level
|
|
691
|
+
// resume only depends on the record cwd, so this is just a persisted
|
|
692
|
+
// preference for the next launch.
|
|
662
693
|
app.post('/api/sessions/:id/switch-cli', asyncH(async (req, res) => {
|
|
663
694
|
const targetCliId = typeof req.body?.cliId === 'string' ? req.body.cliId.trim() : '';
|
|
664
695
|
if (!targetCliId) return res.status(400).json({ error: 'cliId required' });
|
|
@@ -671,11 +702,6 @@ app.post('/api/sessions/:id/switch-cli', asyncH(async (req, res) => {
|
|
|
671
702
|
const targetCli = findCliById(cfg, targetCliId);
|
|
672
703
|
if (!currentCli) return res.status(400).json({ error: `current CLI ${record.cliId} no longer configured` });
|
|
673
704
|
if (!targetCli) return res.status(400).json({ error: `target CLI ${targetCliId} not configured` });
|
|
674
|
-
if (currentCli.type !== targetCli.type) {
|
|
675
|
-
return res.status(400).json({
|
|
676
|
-
error: `cannot switch ${currentCli.type} session to ${targetCli.type} CLI`,
|
|
677
|
-
});
|
|
678
|
-
}
|
|
679
705
|
|
|
680
706
|
if (record.cliId === targetCli.id) {
|
|
681
707
|
const live = webTerminal.get(record.id);
|
|
@@ -690,7 +716,6 @@ app.post('/api/sessions/:id/switch-cli', asyncH(async (req, res) => {
|
|
|
690
716
|
running: !!(live && !live.exitedAt),
|
|
691
717
|
fromCliId: currentCli.id,
|
|
692
718
|
toCliId: targetCli.id,
|
|
693
|
-
cliType: targetCli.type,
|
|
694
719
|
});
|
|
695
720
|
}));
|
|
696
721
|
|
|
@@ -912,9 +937,6 @@ app.post('/api/sessions/new', async (req, res) => {
|
|
|
912
937
|
const all = await listWorkspaces({ workDir: cfg.workDir, repos: cfg.repos, busyPaths });
|
|
913
938
|
workspace = all.find((w) => w.name === req.body.workspace);
|
|
914
939
|
if (!workspace) return fail(`workspace ${req.body.workspace} not found`);
|
|
915
|
-
if (workspace.inUse) {
|
|
916
|
-
return fail(`workspace ${req.body.workspace} is already used by a ${workspaceOccupancyLabel(cfg)}`);
|
|
917
|
-
}
|
|
918
940
|
} else {
|
|
919
941
|
// Collect cwds of sessions that currently reserve workspaces so
|
|
920
942
|
// findOrCreateWorkspace can flag them as in-use and skip past them.
|
|
@@ -931,6 +953,43 @@ app.post('/api/sessions/new', async (req, res) => {
|
|
|
931
953
|
}
|
|
932
954
|
emit({ type: 'workspace', workspace, created });
|
|
933
955
|
|
|
956
|
+
const launchCwd = launchCwdFor(workspace, wantedRepos, req.body && req.body.cwd);
|
|
957
|
+
const existing = await persistedSessions.findByCliAndCwd(cli.id, launchCwd);
|
|
958
|
+
if (workspace.inUse && !existing) {
|
|
959
|
+
return fail(`workspace ${workspace.name} is already used by a ${workspaceOccupancyLabel(cfg)}`);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
const shouldLaunch = req.body && req.body.launch !== false;
|
|
963
|
+
if (existing) {
|
|
964
|
+
let launched = null;
|
|
965
|
+
if (shouldLaunch) {
|
|
966
|
+
try {
|
|
967
|
+
launched = await spawnSessionRecord({
|
|
968
|
+
record: existing,
|
|
969
|
+
cli,
|
|
970
|
+
cfg,
|
|
971
|
+
body: req.body,
|
|
972
|
+
resume: true,
|
|
973
|
+
});
|
|
974
|
+
emit({ type: 'launched', launched });
|
|
975
|
+
} catch (e) {
|
|
976
|
+
return fail(`spawn failed: ${e.message}`);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
emit({
|
|
980
|
+
type: 'done',
|
|
981
|
+
success: true,
|
|
982
|
+
workspace,
|
|
983
|
+
created: false,
|
|
984
|
+
reused: true,
|
|
985
|
+
session: existing,
|
|
986
|
+
cloneResults: [],
|
|
987
|
+
launched,
|
|
988
|
+
});
|
|
989
|
+
res.end();
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
|
|
934
993
|
// Skip clone entirely when user picked an existing directory — we
|
|
935
994
|
// don't want to dump random repos into someone's project.
|
|
936
995
|
const cloneResults = (req.body && req.body.cwd) ? [] : await ensureReposInWorkspace({
|
|
@@ -948,56 +1007,28 @@ app.post('/api/sessions/new', async (req, res) => {
|
|
|
948
1007
|
const failed = cloneResults.filter((r) => !r.ok);
|
|
949
1008
|
if (failed.length > 0) return fail('Some repos failed to clone', { cloneResults });
|
|
950
1009
|
|
|
951
|
-
const shouldLaunch = req.body && req.body.launch !== false;
|
|
952
1010
|
let launched = null;
|
|
1011
|
+
let record = null;
|
|
953
1012
|
if (shouldLaunch) {
|
|
954
|
-
// Pre-assign the upstream CLI session UUID so we never have to
|
|
955
|
-
// poll/scan the transcript dir to find out what id the CLI picked.
|
|
956
|
-
// - claude / copilot expose `--session-id <uuid>` natively.
|
|
957
|
-
// - codex has no flag, but accepts `resume <uuid>` against a
|
|
958
|
-
// pre-existing rollout file. We seed a fake file (see
|
|
959
|
-
// lib/codexSeed.js) so the first launch is a resume against
|
|
960
|
-
// our seed; codex then appends to the same file.
|
|
961
|
-
const newIdTpl = Array.isArray(cli.newSessionIdArgs) ? cli.newSessionIdArgs : [];
|
|
962
|
-
const preAssignedId = newIdTpl.length > 0 ? crypto.randomUUID() : null;
|
|
963
|
-
const newSessionArgs = preAssignedId
|
|
964
|
-
? newIdTpl.map((a) => (typeof a === 'string' ? a.replace(/<id>/g, preAssignedId) : a))
|
|
965
|
-
: [];
|
|
966
|
-
|
|
967
|
-
if (preAssignedId && cli.type === 'codex') {
|
|
968
|
-
try {
|
|
969
|
-
const { seedCodexSession } = require('./lib/codexSeed');
|
|
970
|
-
await seedCodexSession({ id: preAssignedId, cwd: workspace.path, cli });
|
|
971
|
-
} catch (e) {
|
|
972
|
-
return fail(`codex seed failed: ${e.message}`);
|
|
973
|
-
}
|
|
974
|
-
}
|
|
975
|
-
|
|
976
1013
|
// Create the persistedSessions record FIRST so spawnCliSession can
|
|
977
1014
|
// use its id as the PTY id (matching ids simplify resume/attach).
|
|
978
|
-
const
|
|
1015
|
+
const createdRecord = await persistedSessions.createOrGetByCliAndCwd({
|
|
979
1016
|
cliId: cli.id,
|
|
980
|
-
cwd:
|
|
1017
|
+
cwd: launchCwd,
|
|
981
1018
|
workspace: workspace.name,
|
|
982
1019
|
repos: wantedRepos.map((r) => r.name),
|
|
983
1020
|
folderId: (req.body && req.body.folderId) || null,
|
|
984
1021
|
title: '',
|
|
985
|
-
cliSessionId: preAssignedId || undefined,
|
|
986
1022
|
});
|
|
1023
|
+
record = createdRecord.entry;
|
|
987
1024
|
try {
|
|
988
|
-
|
|
989
|
-
|
|
1025
|
+
launched = await spawnSessionRecord({
|
|
1026
|
+
record,
|
|
990
1027
|
cli,
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
extraArgs: [...themeArgs, ...newSessionArgs],
|
|
995
|
-
theme: req.body && req.body.theme,
|
|
996
|
-
cols: req.body && req.body.cols,
|
|
997
|
-
rows: req.body && req.body.rows,
|
|
1028
|
+
cfg,
|
|
1029
|
+
body: req.body,
|
|
1030
|
+
resume: !createdRecord.created,
|
|
998
1031
|
});
|
|
999
|
-
await persistedSessions.markRunning(record.id, entry.meta.pid);
|
|
1000
|
-
launched = { id: record.id, pid: entry.meta.pid, cliId: cli.id };
|
|
1001
1032
|
emit({ type: 'launched', launched });
|
|
1002
1033
|
} catch (e) {
|
|
1003
1034
|
await persistedSessions.markExited(record.id, null);
|
|
@@ -1005,7 +1036,7 @@ app.post('/api/sessions/new', async (req, res) => {
|
|
|
1005
1036
|
}
|
|
1006
1037
|
}
|
|
1007
1038
|
|
|
1008
|
-
emit({ type: 'done', success: true, workspace, created, cloneResults, launched });
|
|
1039
|
+
emit({ type: 'done', success: true, workspace, created, cloneResults, launched, session: record });
|
|
1009
1040
|
res.end();
|
|
1010
1041
|
} catch (e) {
|
|
1011
1042
|
console.error('[/api/sessions/new]', e);
|
|
@@ -1013,91 +1044,6 @@ app.post('/api/sessions/new', async (req, res) => {
|
|
|
1013
1044
|
}
|
|
1014
1045
|
});
|
|
1015
1046
|
|
|
1016
|
-
// ---- list local CLI sessions discovered on disk (for "adopt") ----
|
|
1017
|
-
// Returns sessions found in ~/.claude / ~/.codex / ~/.copilot that
|
|
1018
|
-
// aren't yet adopted by ccsm. Frontend uses this in the Import modal.
|
|
1019
|
-
app.get('/api/cli-sessions/:cliType', asyncH(async (req, res) => {
|
|
1020
|
-
const type = String(req.params.cliType || '').toLowerCase();
|
|
1021
|
-
if (!['claude', 'codex', 'copilot'].includes(type)) {
|
|
1022
|
-
return res.status(400).json({ error: `unsupported cli type: ${type}` });
|
|
1023
|
-
}
|
|
1024
|
-
const offset = Math.max(0, Number(req.query.offset) || 0);
|
|
1025
|
-
const limit = Math.min(200, Math.max(1, Number(req.query.limit) || 30));
|
|
1026
|
-
|
|
1027
|
-
const [page, adopted] = await Promise.all([
|
|
1028
|
-
localCliSessions.listPaginated(type, { offset, limit }),
|
|
1029
|
-
persistedSessions.loadAll(),
|
|
1030
|
-
]);
|
|
1031
|
-
|
|
1032
|
-
const adoptedIds = new Set(adopted.map((s) => s.cliSessionId).filter(Boolean));
|
|
1033
|
-
const sessions = page.sessions.map((s) => ({
|
|
1034
|
-
...s,
|
|
1035
|
-
adopted: adoptedIds.has(s.cliSessionId),
|
|
1036
|
-
}));
|
|
1037
|
-
res.json({
|
|
1038
|
-
sessions,
|
|
1039
|
-
totalActive: page.totalActive,
|
|
1040
|
-
totalNonActive: page.totalNonActive,
|
|
1041
|
-
total: page.totalActive + page.totalNonActive,
|
|
1042
|
-
offset: page.offset,
|
|
1043
|
-
limit: page.limit,
|
|
1044
|
-
hasMore: page.hasMore,
|
|
1045
|
-
});
|
|
1046
|
-
}));
|
|
1047
|
-
|
|
1048
|
-
// ---- adopt: create a ccsm record pointing at an existing CLI session ----
|
|
1049
|
-
// Body: { cliId, cliSessionId, cwd, title?, folderId? }
|
|
1050
|
-
// Doesn't spawn — the new entry shows up as "exited" in the sidebar;
|
|
1051
|
-
// clicking it kicks off the regular resume flow which uses
|
|
1052
|
-
// `cli.resumeIdArgs` ('--resume <id>') so the upstream session reattaches.
|
|
1053
|
-
app.post('/api/sessions/adopt', asyncH(async (req, res) => {
|
|
1054
|
-
const { cliId, cliSessionId, cwd, title, folderId } = req.body || {};
|
|
1055
|
-
if (!cliId || !cliSessionId || !cwd) {
|
|
1056
|
-
return res.status(400).json({ error: 'cliId, cliSessionId and cwd required' });
|
|
1057
|
-
}
|
|
1058
|
-
const cfg = await loadConfig();
|
|
1059
|
-
const cli = pickCli(cfg, cliId);
|
|
1060
|
-
if (!cli) return res.status(400).json({ error: `CLI ${cliId} not configured` });
|
|
1061
|
-
|
|
1062
|
-
// Normalize the cwd up front. /api/sessions/new also resolves cwd, and
|
|
1063
|
-
// the workspaces "in use" check (GET /api/workspaces) does
|
|
1064
|
-
// path.resolve(s.cwd).toLowerCase() — adopted records must match the
|
|
1065
|
-
// same shape, otherwise an adopted+running session leaves its
|
|
1066
|
-
// workspace falsely marked as free and a fresh launch could collide.
|
|
1067
|
-
const resolvedCwd = path.resolve(cwd);
|
|
1068
|
-
try {
|
|
1069
|
-
const fsmod = require('node:fs/promises');
|
|
1070
|
-
const st = await fsmod.stat(resolvedCwd);
|
|
1071
|
-
if (!st.isDirectory()) {
|
|
1072
|
-
return res.status(400).json({ error: `cwd is not a directory: ${resolvedCwd}` });
|
|
1073
|
-
}
|
|
1074
|
-
} catch (e) {
|
|
1075
|
-
return res.status(400).json({ error: `cwd not found: ${resolvedCwd}` });
|
|
1076
|
-
}
|
|
1077
|
-
|
|
1078
|
-
// Refuse duplicates: if any ccsm record already owns this upstream
|
|
1079
|
-
// session id, return it so the caller can jump to it.
|
|
1080
|
-
const all = await persistedSessions.loadAll();
|
|
1081
|
-
const dup = all.find((s) => s.cliSessionId === cliSessionId);
|
|
1082
|
-
if (dup) return res.json({ session: dup, alreadyAdopted: true });
|
|
1083
|
-
|
|
1084
|
-
const workspace = path.basename(resolvedCwd) || resolvedCwd;
|
|
1085
|
-
// Create directly with status='exited' + cliSessionId set, so a
|
|
1086
|
-
// concurrent GET /api/sessions can never observe a "running but no
|
|
1087
|
-
// PTY" intermediate state.
|
|
1088
|
-
const record = await persistedSessions.create({
|
|
1089
|
-
cliId,
|
|
1090
|
-
cwd: resolvedCwd,
|
|
1091
|
-
workspace,
|
|
1092
|
-
folderId: folderId || null,
|
|
1093
|
-
title: title || '',
|
|
1094
|
-
repos: [],
|
|
1095
|
-
status: 'exited',
|
|
1096
|
-
cliSessionId,
|
|
1097
|
-
});
|
|
1098
|
-
res.json({ session: record, alreadyAdopted: false });
|
|
1099
|
-
}));
|
|
1100
|
-
|
|
1101
1047
|
// ---- resume a previous session in the same cwd / cli ----
|
|
1102
1048
|
app.post('/api/sessions/:id/resume', asyncH(async (req, res) => {
|
|
1103
1049
|
const record = await persistedSessions.get(req.params.id);
|
|
@@ -1116,27 +1062,17 @@ app.post('/api/sessions/:id/resume', asyncH(async (req, res) => {
|
|
|
1116
1062
|
return res.json({ launched: { id: record.id, pid: live.meta.pid, cliId: record.cliId } });
|
|
1117
1063
|
}
|
|
1118
1064
|
const cfg = await loadConfig();
|
|
1119
|
-
const cli =
|
|
1065
|
+
const cli = findCliById(cfg, record.cliId);
|
|
1120
1066
|
if (!cli) return res.status(400).json({ error: `CLI ${record.cliId} no longer configured` });
|
|
1121
1067
|
try {
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
// newSessionIdArgs flag or the codex seed trick), and adopted
|
|
1125
|
-
// sessions inherit theirs from the disk scan.
|
|
1126
|
-
const themeArgs = await codexThemeArgs(cli, req.body && req.body.theme);
|
|
1127
|
-
const extraArgs = buildResumeArgs(cli, record);
|
|
1128
|
-
const entry = spawnCliSession({
|
|
1068
|
+
const launched = await spawnSessionRecord({
|
|
1069
|
+
record,
|
|
1129
1070
|
cli,
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
extraArgs: [...themeArgs, ...extraArgs],
|
|
1134
|
-
theme: req.body && req.body.theme,
|
|
1135
|
-
cols: req.body && req.body.cols,
|
|
1136
|
-
rows: req.body && req.body.rows,
|
|
1071
|
+
cfg,
|
|
1072
|
+
body: req.body,
|
|
1073
|
+
resume: true,
|
|
1137
1074
|
});
|
|
1138
|
-
|
|
1139
|
-
res.json({ launched: { id: record.id, pid: entry.meta.pid, cliId: cli.id } });
|
|
1075
|
+
res.json({ launched });
|
|
1140
1076
|
} catch (e) {
|
|
1141
1077
|
res.status(500).json({ error: e.message });
|
|
1142
1078
|
}
|
|
@@ -1149,7 +1085,7 @@ app.post('/api/sessions/:id/resume', asyncH(async (req, res) => {
|
|
|
1149
1085
|
// lever is a syntax theme whose markup.inserted/deleted scopes carry light
|
|
1150
1086
|
// backgrounds (they override the diff palette at true-color level). We ship
|
|
1151
1087
|
// that theme (ccsm-light.tmTheme), copy it into the codex home, and point
|
|
1152
|
-
// tui.theme at it. Returns the args to prepend (before
|
|
1088
|
+
// tui.theme at it. Returns the args to prepend (before any subcommand so the
|
|
1153
1089
|
// global -c lands before the subcommand), or [] when not applicable. Skipped
|
|
1154
1090
|
// in dark mode (codex's dark default is already correct on a dark terminal)
|
|
1155
1091
|
// and when the user configured their own tui.theme in cli.args.
|
|
@@ -1169,21 +1105,6 @@ async function codexThemeArgs(cli, theme) {
|
|
|
1169
1105
|
} catch { return []; }
|
|
1170
1106
|
}
|
|
1171
1107
|
|
|
1172
|
-
// Build the args appended on resume: substitute the captured upstream
|
|
1173
|
-
// session UUID into cli.resumeIdArgs (e.g. ['--resume', '<id>'] →
|
|
1174
|
-
// ['--resume', '7c28...']). Throws if either piece is missing — by
|
|
1175
|
-
// design every ccsm session has a pre-assigned id, so missing one means
|
|
1176
|
-
// something upstream is misconfigured (adopt without id, user-added CLI
|
|
1177
|
-
// without resumeIdArgs, etc.) and we surface that instead of silently
|
|
1178
|
-
// re-launching without the id.
|
|
1179
|
-
function buildResumeArgs(cli, record) {
|
|
1180
|
-
const id = record.cliSessionId;
|
|
1181
|
-
const tpl = Array.isArray(cli.resumeIdArgs) ? cli.resumeIdArgs : [];
|
|
1182
|
-
if (!id) throw new Error(`session ${record.id} has no cliSessionId — cannot resume`);
|
|
1183
|
-
if (tpl.length === 0) throw new Error(`CLI ${cli.id} has no resumeIdArgs configured`);
|
|
1184
|
-
return tpl.map((a) => (typeof a === 'string' ? a.replace(/<id>/g, id) : a));
|
|
1185
|
-
}
|
|
1186
|
-
|
|
1187
1108
|
// ---- capabilities probe ----
|
|
1188
1109
|
app.get('/api/capabilities', (_req, res) => res.json({
|
|
1189
1110
|
webTerminal: webTerminal.available,
|
|
@@ -1761,9 +1682,11 @@ function openInBrowser(url) {
|
|
|
1761
1682
|
const { server, port } = await listenWithFallback(preferredPort);
|
|
1762
1683
|
currentPort = port;
|
|
1763
1684
|
|
|
1764
|
-
// On boot, mark any persisted "running"
|
|
1765
|
-
// belong to a previous server process whose
|
|
1685
|
+
// On boot, normalize legacy records and mark any persisted "running"
|
|
1686
|
+
// sessions as exited — they belong to a previous server process whose
|
|
1687
|
+
// PTYs are gone.
|
|
1766
1688
|
try {
|
|
1689
|
+
await persistedSessions.normalizeStore();
|
|
1767
1690
|
const all = await persistedSessions.loadAll();
|
|
1768
1691
|
for (const s of all) {
|
|
1769
1692
|
if (s.status === 'running') {
|
|
@@ -1774,11 +1697,6 @@ function openInBrowser(url) {
|
|
|
1774
1697
|
console.error('[ccsm] could not reconcile persisted sessions:', e.message);
|
|
1775
1698
|
}
|
|
1776
1699
|
|
|
1777
|
-
// Prewarm `tasklist` cache used by the import modal's "live" markers —
|
|
1778
|
-
// it takes ~500ms on Windows and is the single biggest contributor to
|
|
1779
|
-
// a slow Import dialog cold-open. Fire in the background; the lib also
|
|
1780
|
-
// starts its own 15s refresh loop.
|
|
1781
|
-
try { localCliSessions.prewarmLivePids(['claude.exe']); } catch {}
|
|
1782
1700
|
// Prewarm tunnel provider probe. First /api/tunnel/status round-trip
|
|
1783
1701
|
// shells out to where.exe / --version / devtunnel user show — ~700ms
|
|
1784
1702
|
// of synchronous work that the user otherwise waits on the moment
|