@generativereality/cctabs 0.4.0 → 0.4.1

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.0",
4
+ "version": "0.4.1",
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.0";
14
+ var version = "0.4.1";
15
15
  var description = "Claude Code tab manager. Terminal tabs as the UI, no tmux.";
16
16
  var package_default = {
17
17
  name,
@@ -651,6 +651,8 @@ var TabbyAdapter = class {
651
651
  "claude.ai/code",
652
652
  "⏵⏵ bypass",
653
653
  "⏵⏵ auto",
654
+ "new task?",
655
+ "Checking for updates",
654
656
  "Thinking",
655
657
  "Hatching",
656
658
  "Composing",
@@ -694,6 +696,27 @@ var TabbyAdapter = class {
694
696
  async renameTab(tabId, name) {
695
697
  await this.http("PUT", `/api/tabs/${tabId}/title`, { title: name });
696
698
  }
699
+ /**
700
+ * Fast path: the plugin's POST /api/tabs/new accepts {cwd, title, command,
701
+ * args} and returns the new tab's uuid synchronously. This collapses the
702
+ * whole newTab → waitForNewBlock → renameTab → wait-for-shell-prompt →
703
+ * sendInput sequence into a single round-trip, and (because the uuid is
704
+ * returned, not discovered by diffing) lets the caller open many tabs at once.
705
+ */
706
+ async openTabDirect(opts) {
707
+ this.ensureHealthy();
708
+ const uuid = (await this.http("POST", "/api/tabs/new", {
709
+ cwd: opts.cwd,
710
+ title: opts.title,
711
+ command: opts.command,
712
+ args: opts.args
713
+ }))?.uuid;
714
+ if (!uuid) throw new Error("Tabby plugin did not return a tab uuid");
715
+ return {
716
+ blockId: uuid,
717
+ tabId: uuid
718
+ };
719
+ }
697
720
  async sendInput(blockId, text) {
698
721
  return this.http("POST", `/api/tabs/${blockId}/send`, { data: text });
699
722
  }
@@ -1259,66 +1282,114 @@ async function waitForScrollbackMatch(adapter, blockId, pattern, label, timeoutM
1259
1282
  }
1260
1283
  throw new Error(`Timed out waiting for ${label}`);
1261
1284
  }
1285
+ /**
1286
+ * Wait for Claude's input prompt, then send the initial task and reliably
1287
+ * submit it.
1288
+ *
1289
+ * Reliability matters because a naive "send text, then send \r" loses the
1290
+ * Enter for multi-line prompts: the terminal treats the burst as a bracketed
1291
+ * paste and swallows a \r that arrives inside the paste window, leaving the
1292
+ * text sitting unsent in the input box. We fix that two ways:
1293
+ * 1. Wrap the prompt in explicit bracketed-paste markers so the (possibly
1294
+ * multi-line) text is ingested as one paste and the following Enter lands
1295
+ * *outside* it — an unambiguous submit, not a newline.
1296
+ * 2. Verify the turn actually started (a spinner / "esc to interrupt" hint
1297
+ * appears only while Claude is processing, never at the idle prompt), and
1298
+ * re-send Enter a few times if not, since a large paste can still be
1299
+ * mid-ingest when the first Enter arrives.
1300
+ */
1301
+ async function sendInitialPrompt(adapter, blockId, initialPromptFile) {
1302
+ try {
1303
+ await waitForScrollbackMatch(adapter, blockId, "❯", "Claude prompt", 3e4);
1304
+ } catch {
1305
+ adapter.closeSocket();
1306
+ throw new Error("Claude prompt (❯) never appeared — not sending initial prompt. Check that claude started successfully.");
1307
+ }
1308
+ const prompt = readFileSync(initialPromptFile, "utf-8").trimEnd();
1309
+ await adapter.sendInput(blockId, `\x1b[200~${prompt}\x1b[201~`);
1310
+ for (let attempt = 0; attempt < 4; attempt++) {
1311
+ await new Promise((r) => setTimeout(r, attempt === 0 ? 300 : 500));
1312
+ await adapter.sendInput(blockId, "\r");
1313
+ await new Promise((r) => setTimeout(r, 500));
1314
+ const tail = adapter.scrollback(blockId, 40);
1315
+ const compact = tail.replace(/\s+/g, "");
1316
+ if (/[✻✽✶✳✢]/.test(tail) || /esctointerrupt/i.test(compact)) return;
1317
+ }
1318
+ consola.warn("Could not confirm the initial prompt was submitted — it may be sitting in the input box. Press Enter in the tab to send it.");
1319
+ }
1262
1320
  async function openSession(opts) {
1263
1321
  const { tabName, claudeCmd, workspaceQuery, initialPromptFile, envVars, modelOverride } = opts;
1322
+ const tailDelayMs = opts.tailDelayMs ?? 2e3;
1264
1323
  const dir = resolve(opts.dir.replace(/^~/, homedir()));
1265
- if (!existsSync(dir)) {
1266
- consola.error(`Directory does not exist: ${dir}`);
1267
- process.exit(1);
1268
- }
1324
+ if (!existsSync(dir)) throw new Error(`Directory does not exist: ${dir}`);
1269
1325
  const config = loadConfig();
1270
1326
  const adapter = requireAdapter();
1327
+ const timing = !!process.env.CCTABS_TIMING;
1328
+ let tPhase = Date.now();
1329
+ const mark = (label) => {
1330
+ if (!timing) return;
1331
+ const now = Date.now();
1332
+ consola.log(` ⏱ ${tabName} ${label}: ${now - tPhase}ms`);
1333
+ tPhase = now;
1334
+ };
1335
+ if (adapter.openTabDirect) {
1336
+ const extraFlags = config.claude.flags.join(" ");
1337
+ const namePart = claudeCmd.includes("--resume") ? "" : ` --name ${JSON.stringify(tabName)}`;
1338
+ const modelPart = modelOverride ? ` --model ${JSON.stringify(modelOverride)}` : "";
1339
+ const envPrefix = envVars ? shellQuoteEnv$1(envVars) : "";
1340
+ const claudeCore = `claude${extraFlags ? " " + extraFlags : ""} ${claudeCmd.replace(/^claude\s*/, "")}${namePart}${modelPart}`.replace(/\s+/g, " ").trim();
1341
+ const shell = process.env.SHELL ?? "/bin/zsh";
1342
+ const launch = `${envPrefix}exec ${claudeCore}`;
1343
+ const { blockId, tabId } = await adapter.openTabDirect({
1344
+ cwd: dir,
1345
+ title: tabName,
1346
+ command: shell,
1347
+ args: [
1348
+ "-l",
1349
+ "-c",
1350
+ launch
1351
+ ]
1352
+ });
1353
+ mark("openTabDirect");
1354
+ if (initialPromptFile) await sendInitialPrompt(adapter, blockId, initialPromptFile);
1355
+ adapter.closeSocket();
1356
+ return tabId;
1357
+ }
1271
1358
  let focusWindowId;
1272
1359
  if (workspaceQuery) {
1273
1360
  const { workspaces } = await adapter.getAllData();
1274
1361
  const matches = adapter.resolveWorkspace(workspaces, workspaceQuery);
1275
- if (!matches.length) {
1276
- consola.error(`No workspace matching '${workspaceQuery}'`);
1277
- process.exit(1);
1278
- }
1362
+ if (!matches.length) throw new Error(`No workspace matching '${workspaceQuery}'`);
1279
1363
  const { data, windowId } = matches[0];
1280
- if (!windowId) {
1281
- consola.error(`Workspace '${data.name}' has no open window`);
1282
- process.exit(1);
1283
- }
1364
+ if (!windowId) throw new Error(`Workspace '${data.name}' has no open window`);
1284
1365
  focusWindowId = windowId;
1285
1366
  consola.info(`Workspace: ${data.name}`);
1286
1367
  }
1287
1368
  const beforeIds = new Set(adapter.blocksList().filter((b) => b.view === "term").map((b) => b.blockid));
1369
+ mark("beforeIds");
1288
1370
  await adapter.newTab(focusWindowId);
1371
+ mark("newTab");
1289
1372
  const result = await adapter.waitForNewBlock(beforeIds);
1290
- if (!result) {
1291
- consola.error("Timed out waiting for new terminal block");
1292
- process.exit(1);
1293
- }
1373
+ if (!result) throw new Error("Timed out waiting for new terminal block");
1374
+ mark("waitForNewBlock");
1294
1375
  const { blockId, tabId } = result;
1295
1376
  await adapter.renameTab(tabId, tabName);
1377
+ mark("renameTab");
1296
1378
  try {
1297
1379
  await waitForScrollbackMatch(adapter, blockId, /[$%>]\s*$/, "shell prompt", 1e4, 250);
1298
1380
  } catch {
1299
- consola.error("Shell prompt never appeared in new tab — aborting. Check your shell profile (e.g. nvm default alias).");
1300
- process.exit(1);
1381
+ throw new Error("Shell prompt never appeared in new tab — aborting. Check your shell profile (e.g. nvm default alias).");
1301
1382
  }
1383
+ mark("shellPrompt");
1302
1384
  const extraFlags = config.claude.flags.join(" ");
1303
1385
  const namePart = claudeCmd.includes("--resume") ? "" : ` --name ${JSON.stringify(tabName)}`;
1304
1386
  const modelPart = modelOverride ? ` --model ${JSON.stringify(modelOverride)}` : "";
1305
1387
  const envPrefix = envVars ? shellQuoteEnv$1(envVars) : "";
1306
1388
  const cmd = `cd ${JSON.stringify(dir)} && ${envPrefix}claude${extraFlags ? " " + extraFlags : ""} ${claudeCmd.replace(/^claude\s*/, "")}${namePart}${modelPart}\r`;
1307
1389
  await adapter.sendInput(blockId, cmd);
1308
- if (initialPromptFile) {
1309
- try {
1310
- await waitForScrollbackMatch(adapter, blockId, "", "Claude prompt", 3e4);
1311
- } catch {
1312
- consola.error("Claude prompt (❯) never appeared — not sending initial prompt. Check that claude started successfully.");
1313
- adapter.closeSocket();
1314
- process.exit(1);
1315
- }
1316
- const prompt = readFileSync(initialPromptFile, "utf-8").trimEnd();
1317
- await adapter.sendInput(blockId, prompt);
1318
- await new Promise((r) => setTimeout(r, 100));
1319
- await adapter.sendInput(blockId, "\r");
1320
- }
1321
- await new Promise((r) => setTimeout(r, 2e3));
1390
+ if (initialPromptFile) await sendInitialPrompt(adapter, blockId, initialPromptFile);
1391
+ if (tailDelayMs > 0) await new Promise((r) => setTimeout(r, tailDelayMs));
1392
+ mark("tail");
1322
1393
  adapter.closeSocket();
1323
1394
  return tabId;
1324
1395
  }
@@ -2339,24 +2410,29 @@ async function runManifestMode(manifestPath, createMissing, dryRun) {
2339
2410
  }
2340
2411
  }
2341
2412
  adapter.closeSocket();
2342
- for (const entry of toSpawn) try {
2343
- const claudeCmd = entry.session_id ? `claude --resume ${entry.session_id} --name ${JSON.stringify(entry.name)}` : "claude";
2344
- const newTabId = await openSession({
2345
- tabName: entry.name,
2346
- dir: entry.dir,
2347
- claudeCmd
2348
- });
2349
- const sid = entry.session_id ? entry.session_id.slice(0, 8) + "…" : "fresh";
2350
- results.push({
2351
- name: entry.name,
2352
- result: `✔ spawned [${newTabId.slice(0, 8)}] (${sid})`
2353
- });
2354
- } catch (err) {
2355
- results.push({
2356
- name: entry.name,
2357
- result: `✘ spawn failed: ${err.message}`
2358
- });
2359
- }
2413
+ const spawnOne = async (entry) => {
2414
+ try {
2415
+ const claudeCmd = entry.session_id ? `claude --resume ${entry.session_id} --name ${JSON.stringify(entry.name)}` : "claude";
2416
+ const newTabId = await openSession({
2417
+ tabName: entry.name,
2418
+ dir: entry.dir,
2419
+ claudeCmd,
2420
+ tailDelayMs: 500
2421
+ });
2422
+ const sid = entry.session_id ? entry.session_id.slice(0, 8) + "…" : "fresh";
2423
+ results.push({
2424
+ name: entry.name,
2425
+ result: `✔ spawned [${newTabId.slice(0, 8)}] (${sid})`
2426
+ });
2427
+ } catch (err) {
2428
+ results.push({
2429
+ name: entry.name,
2430
+ result: `✘ spawn failed: ${err.message}`
2431
+ });
2432
+ }
2433
+ };
2434
+ if (typeof adapter.openTabDirect === "function") await Promise.all(toSpawn.map(spawnOne));
2435
+ else for (const entry of toSpawn) await spawnOne(entry);
2360
2436
  console.log("\nRestore summary:");
2361
2437
  for (const r of results) console.log(` ${r.name}: ${r.result}`);
2362
2438
  }
@@ -2391,6 +2467,7 @@ async function runLegacyMode(rawDir, dryRun) {
2391
2467
  const extraFlags = loadConfig().claude.flags.join(" ");
2392
2468
  const results = [];
2393
2469
  const toRecreate = [];
2470
+ const resolved = [];
2394
2471
  for (const tab of toResume) {
2395
2472
  let sessionId = null;
2396
2473
  let sessionDir = null;
@@ -2437,8 +2514,21 @@ async function runLegacyMode(rawDir, dryRun) {
2437
2514
  });
2438
2515
  continue;
2439
2516
  }
2440
- if (tab.status === "unknown") {
2441
- if (await adapter.confirmScrollbackEmpty(tab.blockId)) {
2517
+ resolved.push({
2518
+ tab,
2519
+ sessionId,
2520
+ sessionDir
2521
+ });
2522
+ }
2523
+ if (!dryRun) {
2524
+ const unknownTabs = resolved.filter((r) => r.tab.status === "unknown");
2525
+ const emptyById = /* @__PURE__ */ new Map();
2526
+ await Promise.all(unknownTabs.map(async (r) => {
2527
+ emptyById.set(r.tab.tabId, await adapter.confirmScrollbackEmpty(r.tab.blockId));
2528
+ }));
2529
+ for (const r of resolved) {
2530
+ const { tab, sessionId, sessionDir } = r;
2531
+ if (tab.status === "unknown" && emptyById.get(tab.tabId)) {
2442
2532
  const blockIds = (tabsById.get(tab.tabId) ?? []).map((b) => b.blockid);
2443
2533
  toRecreate.push({
2444
2534
  name: tab.name,
@@ -2453,15 +2543,15 @@ async function runLegacyMode(rawDir, dryRun) {
2453
2543
  });
2454
2544
  continue;
2455
2545
  }
2546
+ consola.log(` ${tab.name} → resuming session ${sessionId.slice(0, 8)}… in ${sessionDir} (send)`);
2547
+ const cmd = `cd ${JSON.stringify(sessionDir)} && claude${extraFlags ? " " + extraFlags : ""} --resume ${sessionId} --name ${JSON.stringify(tab.name)}\r`;
2548
+ await adapter.sendInput(tab.blockId, cmd);
2549
+ await new Promise((r) => setTimeout(r, 500));
2550
+ results.push({
2551
+ name: tab.name,
2552
+ result: "sent"
2553
+ });
2456
2554
  }
2457
- consola.log(` ${tab.name} → resuming session ${sessionId.slice(0, 8)}… in ${sessionDir} (send)`);
2458
- const cmd = `cd ${JSON.stringify(sessionDir)} && claude${extraFlags ? " " + extraFlags : ""} --resume ${sessionId} --name ${JSON.stringify(tab.name)}\r`;
2459
- await adapter.sendInput(tab.blockId, cmd);
2460
- await new Promise((r) => setTimeout(r, 500));
2461
- results.push({
2462
- name: tab.name,
2463
- result: "sent"
2464
- });
2465
2555
  }
2466
2556
  if (!dryRun) {
2467
2557
  const sent = results.filter((r) => r.result === "sent");
@@ -2481,18 +2571,23 @@ async function runLegacyMode(rawDir, dryRun) {
2481
2571
  for (const t of toRecreate) for (const bid of t.blockIds) adapter.deleteBlock(bid);
2482
2572
  adapter.closeSocket();
2483
2573
  consola.info(`Recreating ${toRecreate.length} dead tab(s)…`);
2484
- for (const t of toRecreate) try {
2485
- const newTabId = await openSession({
2486
- tabName: t.name,
2487
- dir: t.sessionDir,
2488
- claudeCmd: `claude --resume ${t.sessionId} --name ${JSON.stringify(t.name)}`
2489
- });
2490
- const r = results.find((x) => x.name === t.name);
2491
- r.result = `✔ recreated [${newTabId.slice(0, 8)}]`;
2492
- } catch (err) {
2493
- const r = results.find((x) => x.name === t.name);
2494
- r.result = `✘ recreate failed: ${err.message}`;
2495
- }
2574
+ const recreateOne = async (t) => {
2575
+ try {
2576
+ const newTabId = await openSession({
2577
+ tabName: t.name,
2578
+ dir: t.sessionDir,
2579
+ claudeCmd: `claude --resume ${t.sessionId} --name ${JSON.stringify(t.name)}`,
2580
+ tailDelayMs: 500
2581
+ });
2582
+ const r = results.find((x) => x.name === t.name);
2583
+ r.result = `✔ recreated [${newTabId.slice(0, 8)}]`;
2584
+ } catch (err) {
2585
+ const r = results.find((x) => x.name === t.name);
2586
+ r.result = `✘ recreate failed: ${err.message}`;
2587
+ }
2588
+ };
2589
+ if (typeof adapter.openTabDirect === "function") await Promise.all(toRecreate.map(recreateOne));
2590
+ else for (const t of toRecreate) await recreateOne(t);
2496
2591
  } else adapter.closeSocket();
2497
2592
  console.log("\nRestore summary:");
2498
2593
  for (const r of results) console.log(` ${r.name}: ${r.result}`);
@@ -3301,7 +3396,12 @@ async function run() {
3301
3396
  }
3302
3397
  //#endregion
3303
3398
  //#region src/index.ts
3304
- updateNotifier({ pkg: package_default }).notify();
3399
+ const notifier = updateNotifier({ pkg: package_default });
3400
+ notifier.notify();
3401
+ if (notifier.update && notifier.update.latest !== notifier.update.current) {
3402
+ const { current, latest } = notifier.update;
3403
+ process.stdout.write(`[cctabs] OUTDATED ${current} < ${latest} — run: npm install -g ${name}@latest\n`);
3404
+ }
3305
3405
  run().catch((err) => {
3306
3406
  console.error(err instanceof Error ? err.message : String(err));
3307
3407
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@generativereality/cctabs",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Claude Code tab manager. Terminal tabs as the UI, no tmux.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -44,6 +44,14 @@ npm install -g @generativereality/cctabs
44
44
 
45
45
  Do not modify PATH or npm configuration beyond this.
46
46
 
47
+ ### Check the installed version isn't stale
48
+
49
+ On your first cctabs invocation in a session, look at the version banner cctabs prints (`(@generativereality/cctabs vX.Y.Z)`) and at any `[cctabs] OUTDATED ...` warning line in the output. If you see the warning — or if the banner version is older than the version in this skill's `plugin.json` — tell the user:
50
+
51
+ > *"Your installed cctabs is `vX.Y.Z`; the current release is `vA.B.C`. Want me to upgrade with `npm install -g @generativereality/cctabs@latest` before continuing?"*
52
+
53
+ Don't silently work around an outdated CLI: detection heuristics, command flags, and bug fixes diverge between versions, so misbehavior on the user's machine is often "binary on PATH lags behind the plugin docs you're reading." The Claude Code marketplace plugin update path only refreshes this skill — the npm-installed CLI binary is a separate channel and must be upgraded explicitly.
54
+
47
55
  ### Tabby users: a one-time plugin install is needed
48
56
 
49
57
  Wave Terminal works out of the box. **Tabby additionally needs a small companion plugin** that exposes a localhost HTTP API the cctabs CLI talks to.
@@ -66,7 +74,7 @@ On approval, run `cctabs install-tabby-plugin --yes`. Tabby quits ~2s after the
66
74
 
67
75
  If the user wants to keep their other Tabby tabs intact, run `cctabs install-tabby-plugin --no-restart` instead and tell them to quit + reopen Tabby themselves.
68
76
 
69
- `cctabs doctor` is also available for a deliberate environment check (terminal, Wave Accessibility, plugin reachability, Wave DB) useful if something feels off, but **not required as a preflight** since every command fails loudly on its own.
77
+ `cctabs doctor` is also available for a deliberate environment check. It adapts to whichever terminal you're running in — terminal detection runs either way; on Wave it additionally inspects Accessibility permission and scans the Wave DB for orphan tabids; on Tabby it probes the cctabs plugin's localhost health endpoint. Useful if something feels off, but **not required as a preflight** since every command fails loudly on its own.
70
78
 
71
79
  #### Auto-install + auto-restart (recommended)
72
80
 
@@ -263,7 +271,7 @@ cctabs resume api ~/Dev/myapp
263
271
 
264
272
  ## Workflow: Restoring tabs after a reboot
265
273
 
266
- After a Wave/computer restart, every tab loses its Claude session and shows up with `terminal` or `unknown` status. `cctabs restore` walks every such tab, looks up its session by name across **all** Claude project directories, and re-attaches in place.
274
+ After a terminal restart or computer reboot, every tab loses its Claude session and shows up with `terminal` or `unknown` status (true for both Wave and Tabby). `cctabs restore` walks every such tab, looks up its session by name across **all** Claude project directories, and re-attaches in place.
267
275
 
268
276
  ```bash
269
277
  cctabs restore # search all projects (default)