@generativereality/cctabs 0.4.2 → 0.4.4

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.2",
4
+ "version": "0.4.4",
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.2";
14
+ var version = "0.4.4";
15
15
  var description = "Claude Code tab manager. Terminal tabs as the UI, no tmux.";
16
16
  var package_default = {
17
17
  name,
@@ -335,7 +335,7 @@ var WaveAdapter = class {
335
335
  async confirmScrollbackEmpty(blockId, attempts = 3, intervalMs = 500) {
336
336
  for (let i = 0; i < attempts; i++) {
337
337
  if (this.scrollback(blockId, 10).trim()) return false;
338
- if (i < attempts - 1) await sleep$1(intervalMs);
338
+ if (i < attempts - 1) await sleep$2(intervalMs);
339
339
  }
340
340
  return true;
341
341
  }
@@ -365,7 +365,7 @@ var WaveAdapter = class {
365
365
  async newTab(focusWindowId) {
366
366
  if (focusWindowId) {
367
367
  await this.focusWindow(focusWindowId);
368
- await sleep$1(300);
368
+ await sleep$2(300);
369
369
  }
370
370
  const r = spawnSync("osascript", ["-e", [
371
371
  "tell application \"Wave\" to activate",
@@ -381,7 +381,7 @@ var WaveAdapter = class {
381
381
  async waitForNewBlock(beforeIds, timeoutMs = 15e3) {
382
382
  const deadline = Date.now() + timeoutMs;
383
383
  while (Date.now() < deadline) {
384
- await sleep$1(250);
384
+ await sleep$2(250);
385
385
  for (const b of this.blocksList()) if (b.view === "term" && !beforeIds.has(b.blockid)) return {
386
386
  blockId: b.blockid,
387
387
  tabId: b.tabid
@@ -509,7 +509,7 @@ var WaveAdapter = class {
509
509
  }));
510
510
  }
511
511
  };
512
- function sleep$1(ms) {
512
+ function sleep$2(ms) {
513
513
  return new Promise((r) => setTimeout(r, ms));
514
514
  }
515
515
  //#endregion
@@ -636,7 +636,7 @@ var TabbyAdapter = class {
636
636
  async confirmScrollbackEmpty(blockId, attempts = 3, intervalMs = 500) {
637
637
  for (let i = 0; i < attempts; i++) {
638
638
  if (this.scrollback(blockId, 10).trim()) return false;
639
- if (i < attempts - 1) await sleep(intervalMs);
639
+ if (i < attempts - 1) await sleep$1(intervalMs);
640
640
  }
641
641
  return true;
642
642
  }
@@ -685,7 +685,7 @@ var TabbyAdapter = class {
685
685
  async waitForNewBlock(beforeIds, timeoutMs = 15e3) {
686
686
  const deadline = Date.now() + timeoutMs;
687
687
  while (Date.now() < deadline) {
688
- await sleep(250);
688
+ await sleep$1(250);
689
689
  for (const b of this.blocksList()) if (!beforeIds.has(b.blockid)) return {
690
690
  blockId: b.blockid,
691
691
  tabId: b.tabid
@@ -816,7 +816,7 @@ var TabbyAdapter = class {
816
816
  }
817
817
  }
818
818
  };
819
- function sleep(ms) {
819
+ function sleep$1(ms) {
820
820
  return new Promise((r) => setTimeout(r, ms));
821
821
  }
822
822
  /**
@@ -1263,6 +1263,20 @@ function ensureConfigExists() {
1263
1263
  return CONFIG_PATH;
1264
1264
  }
1265
1265
  //#endregion
1266
+ //#region src/core/shell.ts
1267
+ /**
1268
+ * POSIX single-quote escape one argv token. Several commands join the
1269
+ * configured `claude.flags` into a raw shell string and send it as terminal
1270
+ * input, so any value with shell metacharacters must be quoted or the shell
1271
+ * mangles it before `claude` sees it — e.g. a `--model opus[1m]` flag
1272
+ * glob-expands under zsh ("no matches found: opus[1m]") and the launch
1273
+ * silently falls back to the default model. Single quotes are inert in every
1274
+ * POSIX shell; embedded single quotes are closed, escaped, and reopened ('\'').
1275
+ */
1276
+ function shellQuoteArg(arg) {
1277
+ return `'${arg.replace(/'/g, "'\\''")}'`;
1278
+ }
1279
+ //#endregion
1266
1280
  //#region src/core/open-session.ts
1267
1281
  function shellQuoteEnv$1(env) {
1268
1282
  const entries = Object.entries(env);
@@ -1282,21 +1296,31 @@ async function waitForScrollbackMatch(adapter, blockId, pattern, label, timeoutM
1282
1296
  }
1283
1297
  throw new Error(`Timed out waiting for ${label}`);
1284
1298
  }
1299
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
1285
1300
  /**
1286
1301
  * Wait for Claude's input prompt, then send the initial task and reliably
1287
1302
  * submit it.
1288
1303
  *
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* itan 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.
1304
+ * The naive "send text, then send \r" is unreliable for two distinct reasons,
1305
+ * each handled by its own verify-and-retry stage:
1306
+ *
1307
+ * 1. The paste can be *dropped*. The welcome-screen placeholder (`❯ Try "…"`)
1308
+ * renders several seconds before the input handler is fully attached, so
1309
+ * input sent the instant `❯` appears lands in a not-ready terminal and is
1310
+ * silently lostthe box stays empty and a later Enter does nothing.
1311
+ * Fix: after pasting, confirm the text actually landed in the box and
1312
+ * re-paste if not (clearing first so a retry never duplicates).
1313
+ *
1314
+ * 2. The Enter can be *swallowed*. A burst of "text then \r" is seen as one
1315
+ * bracketed paste and the \r is absorbed as a newline rather than a
1316
+ * submit. Fix: wrap the prompt in explicit bracketed-paste markers so the
1317
+ * Enter lands outside the paste, then confirm a turn actually started
1318
+ * (spinner / "esc to interrupt" appear only while Claude is processing)
1319
+ * and re-send Enter a few times if not.
1320
+ *
1321
+ * Landed-detection handles both render shapes observed empirically: a short
1322
+ * prompt is echoed inline (so a distinctive prompt substring appears), while a
1323
+ * long / multi-line prompt collapses to a "[Pasted text #N +M lines]" chip.
1300
1324
  */
1301
1325
  async function sendInitialPrompt(adapter, blockId, initialPromptFile) {
1302
1326
  try {
@@ -1305,17 +1329,44 @@ async function sendInitialPrompt(adapter, blockId, initialPromptFile) {
1305
1329
  adapter.closeSocket();
1306
1330
  throw new Error("Claude prompt (❯) never appeared — not sending initial prompt. Check that claude started successfully.");
1307
1331
  }
1332
+ if (/trustthisfolder|Yes,?Itrustthis|Isthisaproject/i.test(adapter.scrollback(blockId, 40).replace(/\s+/g, ""))) for (let attempt = 0; attempt < 18; attempt++) {
1333
+ const screen = adapter.scrollback(blockId, 14).replace(/\s+/g, "");
1334
+ if (/automode|foragents|Try["'“]/i.test(screen)) break;
1335
+ await adapter.sendInput(blockId, "\r");
1336
+ await sleep(800);
1337
+ }
1308
1338
  const prompt = readFileSync(initialPromptFile, "utf-8").trimEnd();
1309
- await adapter.sendInput(blockId, `\x1b[200~${prompt}\x1b[201~`);
1339
+ const sentinel = prompt.replace(/\s+/g, "").slice(0, 24);
1340
+ const landed = () => {
1341
+ const c = adapter.scrollback(blockId, 60).replace(/\s+/g, "");
1342
+ return sentinel.length >= 4 && c.includes(sentinel) || c.includes("[Pastedtext");
1343
+ };
1344
+ let inBox = false;
1345
+ for (let attempt = 0; attempt < 3 && !inBox; attempt++) {
1346
+ if (attempt > 0) {
1347
+ await adapter.sendInput(blockId, "");
1348
+ await sleep(200);
1349
+ }
1350
+ await adapter.sendInput(blockId, `\x1b[200~${prompt}\x1b[201~`);
1351
+ for (let i = 0; i < 8; i++) {
1352
+ await sleep(300);
1353
+ if (landed()) {
1354
+ inBox = true;
1355
+ break;
1356
+ }
1357
+ }
1358
+ }
1359
+ if (!inBox) {
1360
+ consola.warn("Initial prompt may not have landed in the input box — switch to the tab and press Enter (re-type if the box is empty).");
1361
+ return;
1362
+ }
1310
1363
  for (let attempt = 0; attempt < 4; attempt++) {
1311
- await new Promise((r) => setTimeout(r, attempt === 0 ? 300 : 500));
1312
1364
  await adapter.sendInput(blockId, "\r");
1313
- await new Promise((r) => setTimeout(r, 500));
1365
+ await sleep(500);
1314
1366
  const tail = adapter.scrollback(blockId, 40);
1315
- const compact = tail.replace(/\s+/g, "");
1316
- if (/[✻✽✶✳✢]/.test(tail) || /esctointerrupt/i.test(compact)) return;
1367
+ if (/[✻✽✶✳✢]/.test(tail) || /esctointerrupt/i.test(tail.replace(/\s+/g, ""))) return;
1317
1368
  }
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.");
1369
+ consola.warn("Could not confirm the initial prompt was submitted — switch to the tab and press Enter to send it.");
1319
1370
  }
1320
1371
  async function openSession(opts) {
1321
1372
  const { tabName, claudeCmd, workspaceQuery, initialPromptFile, envVars, modelOverride } = opts;
@@ -1333,13 +1384,13 @@ async function openSession(opts) {
1333
1384
  tPhase = now;
1334
1385
  };
1335
1386
  if (adapter.openTabDirect) {
1336
- const extraFlags = config.claude.flags.join(" ");
1387
+ const extraFlags = config.claude.flags.map(shellQuoteArg).join(" ");
1337
1388
  const namePart = claudeCmd.includes("--resume") ? "" : ` --name ${JSON.stringify(tabName)}`;
1338
1389
  const modelPart = modelOverride ? ` --model ${JSON.stringify(modelOverride)}` : "";
1339
1390
  const envPrefix = envVars ? shellQuoteEnv$1(envVars) : "";
1340
1391
  const claudeCore = `claude${extraFlags ? " " + extraFlags : ""} ${claudeCmd.replace(/^claude\s*/, "")}${namePart}${modelPart}`.replace(/\s+/g, " ").trim();
1341
1392
  const shell = process.env.SHELL ?? "/bin/zsh";
1342
- const launch = `${envPrefix}exec ${claudeCore}`;
1393
+ const launch = `${envPrefix}${claudeCore}; exec ${shell} -l -i`;
1343
1394
  const { blockId, tabId } = await adapter.openTabDirect({
1344
1395
  cwd: dir,
1345
1396
  title: tabName,
@@ -1382,7 +1433,7 @@ async function openSession(opts) {
1382
1433
  throw new Error("Shell prompt never appeared in new tab — aborting. Check your shell profile (e.g. nvm default alias).");
1383
1434
  }
1384
1435
  mark("shellPrompt");
1385
- const extraFlags = config.claude.flags.join(" ");
1436
+ const extraFlags = config.claude.flags.map(shellQuoteArg).join(" ");
1386
1437
  const namePart = claudeCmd.includes("--resume") ? "" : ` --name ${JSON.stringify(tabName)}`;
1387
1438
  const modelPart = modelOverride ? ` --model ${JSON.stringify(modelOverride)}` : "";
1388
1439
  const envPrefix = envVars ? shellQuoteEnv$1(envVars) : "";
@@ -1826,7 +1877,7 @@ const resumeCommand = define({
1826
1877
  return;
1827
1878
  }
1828
1879
  }
1829
- const extraFlags = loadConfig().claude.flags.join(" ");
1880
+ const extraFlags = loadConfig().claude.flags.map(shellQuoteArg).join(" ");
1830
1881
  const envPrefix = envVars ? shellQuoteEnv(envVars) : "";
1831
1882
  const modelPart = resolvedModel ? ` --model ${JSON.stringify(resolvedModel)}` : "";
1832
1883
  const cmd = `cd ${JSON.stringify(dir)} && ${envPrefix}claude${extraFlags ? " " + extraFlags : ""} --resume ${sessionId} --name ${JSON.stringify(name)}${modelPart}\r`;
@@ -2197,6 +2248,15 @@ const configCommand = define({
2197
2248
  });
2198
2249
  //#endregion
2199
2250
  //#region src/commands/restore.ts
2251
+ /**
2252
+ * Settle after each direct-spawn (Tabby) recreate, before creating the next
2253
+ * tab. A freshly created tab spawns its PTY only once it becomes the active
2254
+ * tab, and each new tab steals activation from the previous one — so without
2255
+ * this gap only the last-created tab actually launches Claude. One second is
2256
+ * comfortably longer than a PTY fork + shell exec, while keeping a full restore
2257
+ * snappy.
2258
+ */
2259
+ const SPAWN_SETTLE_MS = 1e3;
2200
2260
  function readStdinSync() {
2201
2261
  if (process.stdin.isTTY) return "";
2202
2262
  try {
@@ -2304,7 +2364,7 @@ async function runManifestMode(manifestPath, createMissing, dryRun) {
2304
2364
  const wsTabIds = currentWsData ? new Set(currentWsData.workspacedata.tabids) : new Set(tabsById.keys());
2305
2365
  const results = [];
2306
2366
  const toSpawn = [];
2307
- const extraFlags = loadConfig().claude.flags.join(" ");
2367
+ const extraFlags = loadConfig().claude.flags.map(shellQuoteArg).join(" ");
2308
2368
  for (const entry of entries) {
2309
2369
  let resolvedSessionId = entry.session_id;
2310
2370
  if (entry.session_id) {
@@ -2465,7 +2525,7 @@ async function runLegacyMode(rawDir, dryRun) {
2465
2525
  return;
2466
2526
  }
2467
2527
  consola.info(`Found ${toResume.length} tab(s) to restore:`);
2468
- const extraFlags = loadConfig().claude.flags.join(" ");
2528
+ const extraFlags = loadConfig().claude.flags.map(shellQuoteArg).join(" ");
2469
2529
  const results = [];
2470
2530
  const toRecreate = [];
2471
2531
  const resolved = [];
@@ -2587,8 +2647,11 @@ async function runLegacyMode(rawDir, dryRun) {
2587
2647
  r.result = `✘ recreate failed: ${err.message}`;
2588
2648
  }
2589
2649
  };
2590
- if (typeof adapter.openTabDirect === "function") await Promise.all(toRecreate.map(recreateOne));
2591
- else for (const t of toRecreate) await recreateOne(t);
2650
+ const usesDirectSpawn = typeof adapter.openTabDirect === "function";
2651
+ for (const t of toRecreate) {
2652
+ await recreateOne(t);
2653
+ if (usesDirectSpawn) await new Promise((r) => setTimeout(r, SPAWN_SETTLE_MS));
2654
+ }
2592
2655
  } else adapter.closeSocket();
2593
2656
  console.log("\nRestore summary:");
2594
2657
  for (const r of results) console.log(` ${r.name}: ${r.result}`);
@@ -3329,7 +3392,7 @@ const importCommand = define({
3329
3392
  mkdirSync(targetProjectDir, { recursive: true });
3330
3393
  copyFileSync(srcJsonl, targetJsonl);
3331
3394
  const config = loadConfig();
3332
- const claudeCmd = `claude${config.claude.flags.length ? " " + config.claude.flags.join(" ") : ""} --resume ${entry.sessionId} --name ${JSON.stringify(entry.name)}`;
3395
+ const claudeCmd = `claude${config.claude.flags.length ? " " + config.claude.flags.map(shellQuoteArg).join(" ") : ""} --resume ${entry.sessionId} --name ${JSON.stringify(entry.name)}`;
3333
3396
  try {
3334
3397
  await openSession({
3335
3398
  tabName: entry.name,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@generativereality/cctabs",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "Claude Code tab manager. Terminal tabs as the UI, no tmux.",
5
5
  "type": "module",
6
6
  "bin": {