@generativereality/cctabs 0.4.2 → 0.4.3

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.3",
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.3";
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
  /**
@@ -1269,6 +1269,18 @@ function shellQuoteEnv$1(env) {
1269
1269
  if (!entries.length) return "";
1270
1270
  return entries.map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(" ") + " ";
1271
1271
  }
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
+ }
1272
1284
  /** Poll scrollback until a pattern is visible, then return. Rejects on timeout. */
1273
1285
  async function waitForScrollbackMatch(adapter, blockId, pattern, label, timeoutMs, pollInterval = 1e3) {
1274
1286
  const deadline = Date.now() + timeoutMs;
@@ -1282,21 +1294,31 @@ async function waitForScrollbackMatch(adapter, blockId, pattern, label, timeoutM
1282
1294
  }
1283
1295
  throw new Error(`Timed out waiting for ${label}`);
1284
1296
  }
1297
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
1285
1298
  /**
1286
1299
  * Wait for Claude's input prompt, then send the initial task and reliably
1287
1300
  * submit it.
1288
1301
  *
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.
1302
+ * The naive "send text, then send \r" is unreliable for two distinct reasons,
1303
+ * each handled by its own verify-and-retry stage:
1304
+ *
1305
+ * 1. The paste can be *dropped*. The welcome-screen placeholder (`❯ Try "…"`)
1306
+ * renders several seconds before the input handler is fully attached, so
1307
+ * input sent the instant `❯` appears lands in a not-ready terminal and is
1308
+ * silently lostthe box stays empty and a later Enter does nothing.
1309
+ * Fix: after pasting, confirm the text actually landed in the box and
1310
+ * re-paste if not (clearing first so a retry never duplicates).
1311
+ *
1312
+ * 2. The Enter can be *swallowed*. A burst of "text then \r" is seen as one
1313
+ * bracketed paste and the \r is absorbed as a newline rather than a
1314
+ * submit. Fix: wrap the prompt in explicit bracketed-paste markers so the
1315
+ * Enter lands outside the paste, then confirm a turn actually started
1316
+ * (spinner / "esc to interrupt" appear only while Claude is processing)
1317
+ * and re-send Enter a few times if not.
1318
+ *
1319
+ * Landed-detection handles both render shapes observed empirically: a short
1320
+ * prompt is echoed inline (so a distinctive prompt substring appears), while a
1321
+ * long / multi-line prompt collapses to a "[Pasted text #N +M lines]" chip.
1300
1322
  */
1301
1323
  async function sendInitialPrompt(adapter, blockId, initialPromptFile) {
1302
1324
  try {
@@ -1306,16 +1328,37 @@ async function sendInitialPrompt(adapter, blockId, initialPromptFile) {
1306
1328
  throw new Error("Claude prompt (❯) never appeared — not sending initial prompt. Check that claude started successfully.");
1307
1329
  }
1308
1330
  const prompt = readFileSync(initialPromptFile, "utf-8").trimEnd();
1309
- await adapter.sendInput(blockId, `\x1b[200~${prompt}\x1b[201~`);
1331
+ const sentinel = prompt.replace(/\s+/g, "").slice(0, 24);
1332
+ const landed = () => {
1333
+ const c = adapter.scrollback(blockId, 60).replace(/\s+/g, "");
1334
+ return sentinel.length >= 4 && c.includes(sentinel) || c.includes("[Pastedtext");
1335
+ };
1336
+ let inBox = false;
1337
+ for (let attempt = 0; attempt < 3 && !inBox; attempt++) {
1338
+ if (attempt > 0) {
1339
+ await adapter.sendInput(blockId, "");
1340
+ await sleep(200);
1341
+ }
1342
+ await adapter.sendInput(blockId, `\x1b[200~${prompt}\x1b[201~`);
1343
+ for (let i = 0; i < 8; i++) {
1344
+ await sleep(300);
1345
+ if (landed()) {
1346
+ inBox = true;
1347
+ break;
1348
+ }
1349
+ }
1350
+ }
1351
+ if (!inBox) {
1352
+ 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).");
1353
+ return;
1354
+ }
1310
1355
  for (let attempt = 0; attempt < 4; attempt++) {
1311
- await new Promise((r) => setTimeout(r, attempt === 0 ? 300 : 500));
1312
1356
  await adapter.sendInput(blockId, "\r");
1313
- await new Promise((r) => setTimeout(r, 500));
1357
+ await sleep(500);
1314
1358
  const tail = adapter.scrollback(blockId, 40);
1315
- const compact = tail.replace(/\s+/g, "");
1316
- if (/[✻✽✶✳✢]/.test(tail) || /esctointerrupt/i.test(compact)) return;
1359
+ if (/[✻✽✶✳✢]/.test(tail) || /esctointerrupt/i.test(tail.replace(/\s+/g, ""))) return;
1317
1360
  }
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.");
1361
+ consola.warn("Could not confirm the initial prompt was submitted — switch to the tab and press Enter to send it.");
1319
1362
  }
1320
1363
  async function openSession(opts) {
1321
1364
  const { tabName, claudeCmd, workspaceQuery, initialPromptFile, envVars, modelOverride } = opts;
@@ -1333,7 +1376,7 @@ async function openSession(opts) {
1333
1376
  tPhase = now;
1334
1377
  };
1335
1378
  if (adapter.openTabDirect) {
1336
- const extraFlags = config.claude.flags.join(" ");
1379
+ const extraFlags = config.claude.flags.map(shellQuoteArg).join(" ");
1337
1380
  const namePart = claudeCmd.includes("--resume") ? "" : ` --name ${JSON.stringify(tabName)}`;
1338
1381
  const modelPart = modelOverride ? ` --model ${JSON.stringify(modelOverride)}` : "";
1339
1382
  const envPrefix = envVars ? shellQuoteEnv$1(envVars) : "";
@@ -1382,7 +1425,7 @@ async function openSession(opts) {
1382
1425
  throw new Error("Shell prompt never appeared in new tab — aborting. Check your shell profile (e.g. nvm default alias).");
1383
1426
  }
1384
1427
  mark("shellPrompt");
1385
- const extraFlags = config.claude.flags.join(" ");
1428
+ const extraFlags = config.claude.flags.map(shellQuoteArg).join(" ");
1386
1429
  const namePart = claudeCmd.includes("--resume") ? "" : ` --name ${JSON.stringify(tabName)}`;
1387
1430
  const modelPart = modelOverride ? ` --model ${JSON.stringify(modelOverride)}` : "";
1388
1431
  const envPrefix = envVars ? shellQuoteEnv$1(envVars) : "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@generativereality/cctabs",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "Claude Code tab manager. Terminal tabs as the UI, no tmux.",
5
5
  "type": "module",
6
6
  "bin": {