@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.
@@ -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 { AdoptModal } from '../components/AdoptModal.js';
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', resumeArgs: '--continue', resumeIdArgs: '--resume <id>', name: 'Claude Code' },
190
- codex: { command: 'codex', resumeArgs: 'resume --last', resumeIdArgs: 'resume <id>', name: 'OpenAI Codex' },
191
- copilot: { command: 'copilot', resumeArgs: '--continue', resumeIdArgs: '--session-id <id>', name: 'GitHub 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.resumeArgs != null) patch.resumeArgs = presets.resumeArgs;
195
- if (presets.resumeIdArgs != null) patch.resumeIdArgs = presets.resumeIdArgs;
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: 'resumeArgs', label: 'Resume args (fallback)', mono: true, placeholder: '--continue',
205
- hint: 'Used when ccsm has no captured upstream session id.' },
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 && c.type === cli.type)
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 / resumeIdArgs / newSessionIdArgs fields. Modeled on POSIX sh
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, like --session-id — not via the
306
- // user-editable cli.args, so it reaches existing configs too.)
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
- const includeStopped = cfg?.reserveWorkspacesForStoppedSessions === true;
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 cfg?.reserveWorkspacesForStoppedSessions === true ? 'session' : 'running session';
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. This is
659
- // intentionally narrower than the generic PUT route: a session can only
660
- // move between configured CLIs of the same type (e.g. one claude wrapper
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 record = await persistedSessions.create({
1015
+ const createdRecord = await persistedSessions.createOrGetByCliAndCwd({
979
1016
  cliId: cli.id,
980
- cwd: workspace.path,
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
- const themeArgs = await codexThemeArgs(cli, req.body && req.body.theme);
989
- const entry = spawnCliSession({
1025
+ launched = await spawnSessionRecord({
1026
+ record,
990
1027
  cli,
991
- cwd: workspace.path,
992
- sessionId: record.id,
993
- meta: { title: workspace.name, workspace: workspace.name, cwd: workspace.path },
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 = pickCli(cfg, record.cliId);
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
- // Resume always uses the captured upstream session UUID. With the
1123
- // pre-assignment refactor every ccsm-launched session has one (via
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
- cwd: record.cwd,
1131
- sessionId: record.id,
1132
- meta: { title: record.title || record.workspace, workspace: record.workspace, cwd: record.cwd },
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
- await persistedSessions.markRunning(record.id, entry.meta.pid);
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 `resume <id>` so the
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" sessions as exited — they
1765
- // belong to a previous server process whose PTYs are gone.
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