@generativereality/cctabs 0.4.3 → 0.4.5

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cctabs",
3
3
  "description": "Claude Code tab manager. Terminal tabs as the UI, no tmux. Claude can orchestrate its own sibling sessions.",
4
- "version": "0.4.3",
4
+ "version": "0.4.5",
5
5
  "author": {
6
6
  "name": "generativereality",
7
7
  "url": "https://cctabs.com"
package/dist/index.js CHANGED
@@ -11,7 +11,7 @@ import { consola } from "consola";
11
11
  import * as p from "@clack/prompts";
12
12
  //#region package.json
13
13
  var name = "@generativereality/cctabs";
14
- var version = "0.4.3";
14
+ var version = "0.4.5";
15
15
  var description = "Claude Code tab manager. Terminal tabs as the UI, no tmux.";
16
16
  var package_default = {
17
17
  name,
@@ -709,7 +709,8 @@ var TabbyAdapter = class {
709
709
  cwd: opts.cwd,
710
710
  title: opts.title,
711
711
  command: opts.command,
712
- args: opts.args
712
+ args: opts.args,
713
+ afterActive: opts.afterActive ?? false
713
714
  }))?.uuid;
714
715
  if (!uuid) throw new Error("Tabby plugin did not return a tab uuid");
715
716
  return {
@@ -717,6 +718,10 @@ var TabbyAdapter = class {
717
718
  tabId: uuid
718
719
  };
719
720
  }
721
+ async reorderTabs(order) {
722
+ this.ensureHealthy();
723
+ await this.http("POST", "/api/tabs/reorder", { order });
724
+ }
720
725
  async sendInput(blockId, text) {
721
726
  return this.http("POST", `/api/tabs/${blockId}/send`, { data: text });
722
727
  }
@@ -1263,24 +1268,26 @@ function ensureConfigExists() {
1263
1268
  return CONFIG_PATH;
1264
1269
  }
1265
1270
  //#endregion
1271
+ //#region src/core/shell.ts
1272
+ /**
1273
+ * POSIX single-quote escape one argv token. Several commands join the
1274
+ * configured `claude.flags` into a raw shell string and send it as terminal
1275
+ * input, so any value with shell metacharacters must be quoted or the shell
1276
+ * mangles it before `claude` sees it — e.g. a `--model opus[1m]` flag
1277
+ * glob-expands under zsh ("no matches found: opus[1m]") and the launch
1278
+ * silently falls back to the default model. Single quotes are inert in every
1279
+ * POSIX shell; embedded single quotes are closed, escaped, and reopened ('\'').
1280
+ */
1281
+ function shellQuoteArg(arg) {
1282
+ return `'${arg.replace(/'/g, "'\\''")}'`;
1283
+ }
1284
+ //#endregion
1266
1285
  //#region src/core/open-session.ts
1267
1286
  function shellQuoteEnv$1(env) {
1268
1287
  const entries = Object.entries(env);
1269
1288
  if (!entries.length) return "";
1270
1289
  return entries.map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(" ") + " ";
1271
1290
  }
1272
- /**
1273
- * POSIX single-quote escape one argv token. The configured `claude.flags` are
1274
- * joined into a raw shell string and sent as terminal input, so any value with
1275
- * shell metacharacters must be quoted or the shell mangles it before `claude`
1276
- * sees it — e.g. a `--model opus[1m]` flag glob-expands under zsh ("no matches
1277
- * found: opus[1m]") and the launch silently falls back to the default model.
1278
- * Single quotes are inert in every POSIX shell; embedded single quotes are
1279
- * closed, escaped, and reopened ('\'').
1280
- */
1281
- function shellQuoteArg(arg) {
1282
- return `'${arg.replace(/'/g, "'\\''")}'`;
1283
- }
1284
1291
  /** Poll scrollback until a pattern is visible, then return. Rejects on timeout. */
1285
1292
  async function waitForScrollbackMatch(adapter, blockId, pattern, label, timeoutMs, pollInterval = 1e3) {
1286
1293
  const deadline = Date.now() + timeoutMs;
@@ -1327,6 +1334,12 @@ async function sendInitialPrompt(adapter, blockId, initialPromptFile) {
1327
1334
  adapter.closeSocket();
1328
1335
  throw new Error("Claude prompt (❯) never appeared — not sending initial prompt. Check that claude started successfully.");
1329
1336
  }
1337
+ if (/trustthisfolder|Yes,?Itrustthis|Isthisaproject/i.test(adapter.scrollback(blockId, 40).replace(/\s+/g, ""))) for (let attempt = 0; attempt < 18; attempt++) {
1338
+ const screen = adapter.scrollback(blockId, 14).replace(/\s+/g, "");
1339
+ if (/automode|foragents|Try["'“]/i.test(screen)) break;
1340
+ await adapter.sendInput(blockId, "\r");
1341
+ await sleep(800);
1342
+ }
1330
1343
  const prompt = readFileSync(initialPromptFile, "utf-8").trimEnd();
1331
1344
  const sentinel = prompt.replace(/\s+/g, "").slice(0, 24);
1332
1345
  const landed = () => {
@@ -1360,9 +1373,57 @@ async function sendInitialPrompt(adapter, blockId, initialPromptFile) {
1360
1373
  }
1361
1374
  consola.warn("Could not confirm the initial prompt was submitted — switch to the tab and press Enter to send it.");
1362
1375
  }
1376
+ /**
1377
+ * Auto-advance Claude's "resume" picker that appears when `claude --resume <id>`
1378
+ * reattaches a large/old session:
1379
+ *
1380
+ * ❯ 1. Resume from summary (recommended)
1381
+ * 2. Resume full session as-is
1382
+ * 3. Don't ask me again
1383
+ *
1384
+ * It blocks the tab until you choose, which is why a plain `restore` leaves
1385
+ * such tabs stuck. cctabs always wants the FULL session — the whole point of
1386
+ * restore is to bring the conversation back intact, not a lossy summary — so we
1387
+ * select option 2.
1388
+ *
1389
+ * Like the trust dialog, the picker has a brief not-ready window as it paints,
1390
+ * so we poll for it to appear, settle, then navigate. The default highlight is
1391
+ * option 1, so we move the cursor DOWN exactly once to reach option 2. We send
1392
+ * ↓ only once on purpose: spamming it across retries could land on option 3
1393
+ * ("Don't ask me again"), which permanently changes the user's config. The
1394
+ * confirm is the part we retry — re-pressing Enter on the same row is safe, and
1395
+ * if the single ↓ was ever dropped the worst case is a (still-usable) summary
1396
+ * resume, never option 3.
1397
+ */
1398
+ async function confirmResumePicker(adapter, blockId) {
1399
+ const stripped = (n) => adapter.scrollback(blockId, n).replace(/\s+/g, "");
1400
+ const pickerVisible = (n) => {
1401
+ const c = stripped(n);
1402
+ return /Resumefromsummary/i.test(c) && /Resumefullsession/i.test(c);
1403
+ };
1404
+ let appeared = false;
1405
+ for (let i = 0; i < 25; i++) {
1406
+ if (pickerVisible(30)) {
1407
+ appeared = true;
1408
+ break;
1409
+ }
1410
+ await sleep(1e3);
1411
+ }
1412
+ if (!appeared) return;
1413
+ await sleep(1200);
1414
+ await adapter.sendInput(blockId, "\x1B[B");
1415
+ await sleep(250);
1416
+ for (let attempt = 0; attempt < 8; attempt++) {
1417
+ await adapter.sendInput(blockId, "\r");
1418
+ await sleep(900);
1419
+ if (!pickerVisible(8)) return;
1420
+ }
1421
+ consola.warn("Could not confirm the resume picker was dismissed — switch to the tab and pick \"Resume full session as-is\".");
1422
+ }
1363
1423
  async function openSession(opts) {
1364
- const { tabName, claudeCmd, workspaceQuery, initialPromptFile, envVars, modelOverride } = opts;
1424
+ const { tabName, claudeCmd, workspaceQuery, initialPromptFile, envVars, modelOverride, afterActive } = opts;
1365
1425
  const tailDelayMs = opts.tailDelayMs ?? 2e3;
1426
+ const isResume = /--resume\b/.test(claudeCmd);
1366
1427
  const dir = resolve(opts.dir.replace(/^~/, homedir()));
1367
1428
  if (!existsSync(dir)) throw new Error(`Directory does not exist: ${dir}`);
1368
1429
  const config = loadConfig();
@@ -1382,7 +1443,7 @@ async function openSession(opts) {
1382
1443
  const envPrefix = envVars ? shellQuoteEnv$1(envVars) : "";
1383
1444
  const claudeCore = `claude${extraFlags ? " " + extraFlags : ""} ${claudeCmd.replace(/^claude\s*/, "")}${namePart}${modelPart}`.replace(/\s+/g, " ").trim();
1384
1445
  const shell = process.env.SHELL ?? "/bin/zsh";
1385
- const launch = `${envPrefix}exec ${claudeCore}`;
1446
+ const launch = `${envPrefix}${claudeCore}; exec ${shell} -l -i`;
1386
1447
  const { blockId, tabId } = await adapter.openTabDirect({
1387
1448
  cwd: dir,
1388
1449
  title: tabName,
@@ -1392,9 +1453,14 @@ async function openSession(opts) {
1392
1453
  "-i",
1393
1454
  "-c",
1394
1455
  launch
1395
- ]
1456
+ ],
1457
+ afterActive
1396
1458
  });
1397
1459
  mark("openTabDirect");
1460
+ if (isResume) {
1461
+ await confirmResumePicker(adapter, blockId);
1462
+ mark("resumePicker");
1463
+ }
1398
1464
  if (initialPromptFile) await sendInitialPrompt(adapter, blockId, initialPromptFile);
1399
1465
  adapter.closeSocket();
1400
1466
  return tabId;
@@ -1431,6 +1497,10 @@ async function openSession(opts) {
1431
1497
  const envPrefix = envVars ? shellQuoteEnv$1(envVars) : "";
1432
1498
  const cmd = `cd ${JSON.stringify(dir)} && ${envPrefix}claude${extraFlags ? " " + extraFlags : ""} ${claudeCmd.replace(/^claude\s*/, "")}${namePart}${modelPart}\r`;
1433
1499
  await adapter.sendInput(blockId, cmd);
1500
+ if (isResume) {
1501
+ await confirmResumePicker(adapter, blockId);
1502
+ mark("resumePicker");
1503
+ }
1434
1504
  if (initialPromptFile) await sendInitialPrompt(adapter, blockId, initialPromptFile);
1435
1505
  if (tailDelayMs > 0) await new Promise((r) => setTimeout(r, tailDelayMs));
1436
1506
  mark("tail");
@@ -1721,7 +1791,8 @@ const newCommand = define({
1721
1791
  workspaceQuery: workspace,
1722
1792
  initialPromptFile,
1723
1793
  envVars,
1724
- modelOverride: resolvedModel
1794
+ modelOverride: resolvedModel,
1795
+ afterActive: true
1725
1796
  });
1726
1797
  const wt = useWorktree ? ` (worktree: .claude/worktrees/${name})` : "";
1727
1798
  const be = backendName ? ` [backend: ${backendName}${resolvedModel ? ` → ${resolvedModel}` : ""}]` : "";
@@ -1863,13 +1934,14 @@ const resumeCommand = define({
1863
1934
  dir,
1864
1935
  claudeCmd: `claude --resume ${sessionId} --name ${JSON.stringify(name)}`,
1865
1936
  envVars,
1866
- modelOverride: resolvedModel
1937
+ modelOverride: resolvedModel,
1938
+ afterActive: true
1867
1939
  });
1868
1940
  consola.success(`Tab "${name}" [${newTabId.slice(0, 8)}] → claude --resume ${sessionId.slice(0, 8)}… at ${dir} (recreated)`);
1869
1941
  return;
1870
1942
  }
1871
1943
  }
1872
- const extraFlags = loadConfig().claude.flags.join(" ");
1944
+ const extraFlags = loadConfig().claude.flags.map(shellQuoteArg).join(" ");
1873
1945
  const envPrefix = envVars ? shellQuoteEnv(envVars) : "";
1874
1946
  const modelPart = resolvedModel ? ` --model ${JSON.stringify(resolvedModel)}` : "";
1875
1947
  const cmd = `cd ${JSON.stringify(dir)} && ${envPrefix}claude${extraFlags ? " " + extraFlags : ""} --resume ${sessionId} --name ${JSON.stringify(name)}${modelPart}\r`;
@@ -1894,7 +1966,8 @@ const resumeCommand = define({
1894
1966
  dir,
1895
1967
  claudeCmd: `claude --resume ${sessionId} --name ${JSON.stringify(name)}`,
1896
1968
  envVars,
1897
- modelOverride: resolvedModel
1969
+ modelOverride: resolvedModel,
1970
+ afterActive: true
1898
1971
  });
1899
1972
  consola.success(`Tab "${name}" [${tabId.slice(0, 8)}] → claude --resume ${sessionId.slice(0, 8)}… at ${dir} (new tab)`);
1900
1973
  } else {
@@ -1979,7 +2052,8 @@ const forkCommand = define({
1979
2052
  const newTabId = await openSession({
1980
2053
  tabName: newName,
1981
2054
  dir: openDir,
1982
- claudeCmd: `claude --resume ${sessionId} --fork-session`
2055
+ claudeCmd: `claude --resume ${sessionId} --fork-session`,
2056
+ afterActive: true
1983
2057
  });
1984
2058
  consola.success(`Forked "${tabName}" → "${newName}" [${newTabId.slice(0, 8)}]`);
1985
2059
  consola.info(`session: ${sessionId}`);
@@ -2240,6 +2314,15 @@ const configCommand = define({
2240
2314
  });
2241
2315
  //#endregion
2242
2316
  //#region src/commands/restore.ts
2317
+ /**
2318
+ * Settle after each direct-spawn (Tabby) recreate, before creating the next
2319
+ * tab. A freshly created tab spawns its PTY only once it becomes the active
2320
+ * tab, and each new tab steals activation from the previous one — so without
2321
+ * this gap only the last-created tab actually launches Claude. One second is
2322
+ * comfortably longer than a PTY fork + shell exec, while keeping a full restore
2323
+ * snappy.
2324
+ */
2325
+ const SPAWN_SETTLE_MS = 1e3;
2243
2326
  function readStdinSync() {
2244
2327
  if (process.stdin.isTTY) return "";
2245
2328
  try {
@@ -2347,7 +2430,7 @@ async function runManifestMode(manifestPath, createMissing, dryRun) {
2347
2430
  const wsTabIds = currentWsData ? new Set(currentWsData.workspacedata.tabids) : new Set(tabsById.keys());
2348
2431
  const results = [];
2349
2432
  const toSpawn = [];
2350
- const extraFlags = loadConfig().claude.flags.join(" ");
2433
+ const extraFlags = loadConfig().claude.flags.map(shellQuoteArg).join(" ");
2351
2434
  for (const entry of entries) {
2352
2435
  let resolvedSessionId = entry.session_id;
2353
2436
  if (entry.session_id) {
@@ -2486,7 +2569,9 @@ async function runLegacyMode(rawDir, dryRun) {
2486
2569
  const { tabsById, workspaces, tabNames } = await adapter.getAllData();
2487
2570
  const currentTab = adapter.currentTabId();
2488
2571
  const tabs = [];
2572
+ const originalOrder = [];
2489
2573
  for (const wsp of workspaces) for (const tabId of wsp.workspacedata.tabids) {
2574
+ originalOrder.push(tabId);
2490
2575
  if (tabId === currentTab) continue;
2491
2576
  const blocks = (tabsById.get(tabId) ?? []).filter((b) => b.view === "term");
2492
2577
  if (!blocks.length) continue;
@@ -2508,7 +2593,7 @@ async function runLegacyMode(rawDir, dryRun) {
2508
2593
  return;
2509
2594
  }
2510
2595
  consola.info(`Found ${toResume.length} tab(s) to restore:`);
2511
- const extraFlags = loadConfig().claude.flags.join(" ");
2596
+ const extraFlags = loadConfig().claude.flags.map(shellQuoteArg).join(" ");
2512
2597
  const results = [];
2513
2598
  const toRecreate = [];
2514
2599
  const resolved = [];
@@ -2570,8 +2655,20 @@ async function runLegacyMode(rawDir, dryRun) {
2570
2655
  await Promise.all(unknownTabs.map(async (r) => {
2571
2656
  emptyById.set(r.tab.tabId, await adapter.confirmScrollbackEmpty(r.tab.blockId));
2572
2657
  }));
2658
+ const claimedNames = /* @__PURE__ */ new Set();
2573
2659
  for (const r of resolved) {
2574
2660
  const { tab, sessionId, sessionDir } = r;
2661
+ if (claimedNames.has(tab.name)) {
2662
+ const blockIds = (tabsById.get(tab.tabId) ?? []).map((b) => b.blockid);
2663
+ for (const bid of blockIds) adapter.deleteBlock(bid);
2664
+ consola.log(` ${tab.name} — duplicate dead tab, closing (already restoring one)`);
2665
+ results.push({
2666
+ name: tab.name,
2667
+ result: "duplicate dead tab — closed"
2668
+ });
2669
+ continue;
2670
+ }
2671
+ claimedNames.add(tab.name);
2575
2672
  if (tab.status === "unknown" && emptyById.get(tab.tabId)) {
2576
2673
  const blockIds = (tabsById.get(tab.tabId) ?? []).map((b) => b.blockid);
2577
2674
  toRecreate.push({
@@ -2615,6 +2712,7 @@ async function runLegacyMode(rawDir, dryRun) {
2615
2712
  for (const t of toRecreate) for (const bid of t.blockIds) adapter.deleteBlock(bid);
2616
2713
  adapter.closeSocket();
2617
2714
  consola.info(`Recreating ${toRecreate.length} dead tab(s)…`);
2715
+ const recreatedIds = /* @__PURE__ */ new Map();
2618
2716
  const recreateOne = async (t) => {
2619
2717
  try {
2620
2718
  const newTabId = await openSession({
@@ -2623,6 +2721,7 @@ async function runLegacyMode(rawDir, dryRun) {
2623
2721
  claudeCmd: `claude --resume ${t.sessionId} --name ${JSON.stringify(t.name)}`,
2624
2722
  tailDelayMs: 500
2625
2723
  });
2724
+ recreatedIds.set(t.tabId, newTabId);
2626
2725
  const r = results.find((x) => x.name === t.name);
2627
2726
  r.result = `✔ recreated [${newTabId.slice(0, 8)}]`;
2628
2727
  } catch (err) {
@@ -2630,8 +2729,19 @@ async function runLegacyMode(rawDir, dryRun) {
2630
2729
  r.result = `✘ recreate failed: ${err.message}`;
2631
2730
  }
2632
2731
  };
2633
- if (typeof adapter.openTabDirect === "function") await Promise.all(toRecreate.map(recreateOne));
2634
- else for (const t of toRecreate) await recreateOne(t);
2732
+ const usesDirectSpawn = typeof adapter.openTabDirect === "function";
2733
+ for (const t of toRecreate) {
2734
+ await recreateOne(t);
2735
+ if (usesDirectSpawn) await new Promise((r) => setTimeout(r, SPAWN_SETTLE_MS));
2736
+ }
2737
+ if (recreatedIds.size && typeof adapter.reorderTabs === "function") {
2738
+ const desiredOrder = originalOrder.map((id) => recreatedIds.get(id) ?? id);
2739
+ try {
2740
+ await adapter.reorderTabs(desiredOrder);
2741
+ } catch (err) {
2742
+ consola.warn(`Could not restore tab order: ${err.message}`);
2743
+ }
2744
+ }
2635
2745
  } else adapter.closeSocket();
2636
2746
  console.log("\nRestore summary:");
2637
2747
  for (const r of results) console.log(` ${r.name}: ${r.result}`);
@@ -3372,7 +3482,7 @@ const importCommand = define({
3372
3482
  mkdirSync(targetProjectDir, { recursive: true });
3373
3483
  copyFileSync(srcJsonl, targetJsonl);
3374
3484
  const config = loadConfig();
3375
- const claudeCmd = `claude${config.claude.flags.length ? " " + config.claude.flags.join(" ") : ""} --resume ${entry.sessionId} --name ${JSON.stringify(entry.name)}`;
3485
+ const claudeCmd = `claude${config.claude.flags.length ? " " + config.claude.flags.map(shellQuoteArg).join(" ") : ""} --resume ${entry.sessionId} --name ${JSON.stringify(entry.name)}`;
3376
3486
  try {
3377
3487
  await openSession({
3378
3488
  tabName: entry.name,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@generativereality/cctabs",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "Claude Code tab manager. Terminal tabs as the UI, no tmux.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -281,6 +281,18 @@ cctabs restore ~/Dev/myapp # restrict the search to one project dir
281
281
 
282
282
  If a session was started in a different `cwd` than the tab's current directory (common after `cd`-ing inside the tab), the global search still finds it via the recorded session metadata — no need to guess the right dir.
283
283
 
284
+ ### The "Resume from summary / full session" picker
285
+
286
+ When `claude --resume` reattaches a large or old session, Claude first shows a blocking picker:
287
+
288
+ ```
289
+ ❯ 1. Resume from summary (recommended)
290
+ 2. Resume full session as-is
291
+ 3. Don't ask me again
292
+ ```
293
+
294
+ **Always pick option 2, "Resume full session as-is."** The point of `restore` is to bring the conversation back intact — resuming from a summary discards the live context you're restoring for. `restore` auto-advances this picker for you (it moves down once to option 2 and confirms), so you normally never see it. If you ever do drive it manually (e.g. sending keys to a tab), send **↓ then Enter** — never the bare Enter that would accept the summary, and never option 3, which permanently silences the prompt in that session's config.
295
+
284
296
  ## Workflow: Moving sessions across machines
285
297
 
286
298
  Use `export` + `import` to migrate a tab (or a whole workspace) — and its underlying Claude conversation — from one machine to another, e.g. when switching laptops or sharing a debug session with a teammate.