@generativereality/cctabs 0.1.1 → 0.1.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.1.1",
4
+ "version": "0.1.3",
5
5
  "author": {
6
6
  "name": "generativereality",
7
7
  "url": "https://cctabs.com"
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@ import { consola } from "consola";
10
10
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
11
11
  //#region package.json
12
12
  var name = "@generativereality/cctabs";
13
- var version = "0.1.1";
13
+ var version = "0.1.3";
14
14
  var description = "Claude Code tab manager. Terminal tabs as the UI, no tmux.";
15
15
  var package_default = {
16
16
  name,
@@ -28,10 +28,10 @@ var package_default = {
28
28
  "build": "tsdown",
29
29
  "typecheck": "tsc --noEmit",
30
30
  "lint": "eslint src/",
31
- "check": "bun run typecheck && bun run build",
31
+ "check": "npm run typecheck && npm run build",
32
32
  "release": "bumpp && npm publish",
33
33
  "sync-plugin": "bash scripts/sync-plugin.sh",
34
- "prepack": "bash scripts/sync-plugin.sh --check && bun run build"
34
+ "prepack": "bash scripts/sync-plugin.sh --check && npm run build"
35
35
  },
36
36
  keywords: [
37
37
  "claude-code",
@@ -206,7 +206,9 @@ var WaveAdapter = class {
206
206
  const out = execFileSync("wsh", [
207
207
  "blocks",
208
208
  "list",
209
- "--json"
209
+ "--json",
210
+ "--timeout",
211
+ "15000"
210
212
  ], { encoding: "utf-8" });
211
213
  return JSON.parse(out);
212
214
  } catch {
@@ -222,6 +224,16 @@ var WaveAdapter = class {
222
224
  `-${lastN}`
223
225
  ], { encoding: "utf-8" }).stdout ?? "";
224
226
  }
227
+ /** Poll scrollback to confirm it really is empty (block has no live shell).
228
+ * A freshly-opened tab may briefly have empty scrollback before the prompt
229
+ * renders, so poll a few times before declaring the block dead. */
230
+ async confirmScrollbackEmpty(blockId, attempts = 3, intervalMs = 500) {
231
+ for (let i = 0; i < attempts; i++) {
232
+ if (this.scrollback(blockId, 10).trim()) return false;
233
+ if (i < attempts - 1) await sleep(intervalMs);
234
+ }
235
+ return true;
236
+ }
225
237
  /** Detect whether a Claude session is running in a terminal block */
226
238
  detectSessionStatus(blockId) {
227
239
  const tail = this.scrollback(blockId, 10);
@@ -261,7 +273,7 @@ var WaveAdapter = class {
261
273
  }
262
274
  return true;
263
275
  }
264
- async waitForNewBlock(beforeIds, timeoutMs = 5e3) {
276
+ async waitForNewBlock(beforeIds, timeoutMs = 15e3) {
265
277
  const deadline = Date.now() + timeoutMs;
266
278
  while (Date.now() < deadline) {
267
279
  await sleep(250);
@@ -343,9 +355,12 @@ var WaveAdapter = class {
343
355
  }
344
356
  resolveTab(query, tabsById, tabNames) {
345
357
  const q = query.toLowerCase();
346
- return [...tabsById.keys()].filter((tid) => {
358
+ const ids = [...tabsById.keys()];
359
+ const exact = ids.filter((tid) => (tabNames.get(tid) ?? "").toLowerCase() === q);
360
+ if (exact.length > 0) return exact;
361
+ return ids.filter((tid) => {
347
362
  const name = tabNames.get(tid) ?? "";
348
- return name.toLowerCase() === q || tid.startsWith(query) || name.toLowerCase().startsWith(q);
363
+ return tid.startsWith(query) || name.toLowerCase().startsWith(q);
349
364
  });
350
365
  }
351
366
  resolveBlock(query, blocks) {
@@ -353,10 +368,11 @@ var WaveAdapter = class {
353
368
  }
354
369
  resolveWorkspace(workspaces, query) {
355
370
  const q = query.toLowerCase();
356
- return workspaces.filter(({ workspacedata: wd }) => {
371
+ const exact = workspaces.filter(({ workspacedata: wd }) => (wd.name ?? "").toLowerCase() === q);
372
+ return (exact.length > 0 ? exact : workspaces.filter(({ workspacedata: wd }) => {
357
373
  const name = wd.name ?? "";
358
- return name.toLowerCase() === q || wd.oid.startsWith(query) || name.toLowerCase().startsWith(q);
359
- }).map((w) => ({
374
+ return wd.oid.startsWith(query) || name.toLowerCase().startsWith(q);
375
+ })).map((w) => ({
360
376
  data: w.workspacedata,
361
377
  windowId: w.windowid
362
378
  }));
@@ -649,9 +665,11 @@ const newCommand = define({
649
665
  });
650
666
  //#endregion
651
667
  //#region src/core/session.ts
652
- /** Convert an absolute path to Claude's project slug (/ and . → -) */
668
+ /** Convert an absolute path to Claude Code's project slug.
669
+ * Claude Code replaces any non-alphanumeric character (spaces, /, ., etc.) with '-'.
670
+ * Hyphens are preserved. Example: "/Users/me/Remember This" → "-Users-me-Remember-This". */
653
671
  function pathToProjectSlug(dir) {
654
- return resolve(dir).replace(/[/.]/g, "-");
672
+ return resolve(dir).replace(/[^A-Za-z0-9-]/g, "-");
655
673
  }
656
674
  /** Find the most recent .jsonl session file in a Claude project directory */
657
675
  function latestJsonlIn(projectDir) {
@@ -745,6 +763,72 @@ function findSessionsByName(dir, name) {
745
763
  return matches.sort((a, b) => b.mtime - a.mtime);
746
764
  }
747
765
  /**
766
+ * Like findSessionsByName, but searches every project directory under
767
+ * ~/.claude/projects. Each match carries the cwd recorded in the session.
768
+ * Used by `cctabs restore` so callers don't have to guess the right dir.
769
+ */
770
+ function findSessionsByNameGlobally(name) {
771
+ const projectsRoot = join(homedir(), ".claude", "projects");
772
+ if (!existsSync(projectsRoot)) return [];
773
+ const matches = [];
774
+ for (const slug of readdirSync(projectsRoot)) {
775
+ const projectDir = join(projectsRoot, slug);
776
+ let isDir = false;
777
+ try {
778
+ isDir = statSync(projectDir).isDirectory();
779
+ } catch {
780
+ continue;
781
+ }
782
+ if (!isDir) continue;
783
+ const files = readdirSync(projectDir).filter((f) => extname(f) === ".jsonl");
784
+ for (const f of files) {
785
+ const fullPath = join(projectDir, f);
786
+ try {
787
+ const lines = readFileSync(fullPath, "utf-8").split("\n");
788
+ let currentTitle = "";
789
+ let cwd = "";
790
+ let firstPrompt = "";
791
+ let lastActivity = "";
792
+ for (const line of lines) {
793
+ if (!line.trim()) continue;
794
+ try {
795
+ const entry = JSON.parse(line);
796
+ if (entry.customTitle !== void 0) currentTitle = entry.customTitle;
797
+ if (!cwd && typeof entry.cwd === "string") cwd = entry.cwd;
798
+ if (!firstPrompt && entry.type === "user" && entry.message?.content) {
799
+ const text = typeof entry.message.content === "string" ? entry.message.content : entry.message.content.find((c) => c.type === "text")?.text ?? "";
800
+ if (text.startsWith("<")) continue;
801
+ firstPrompt = text.slice(0, 120).replace(/\n/g, " ").trim();
802
+ if (text.length > 120) firstPrompt += "…";
803
+ }
804
+ if (entry.message?.role === "assistant" && entry.message?.content) {
805
+ const parts = Array.isArray(entry.message.content) ? entry.message.content : [{
806
+ type: "text",
807
+ text: entry.message.content
808
+ }];
809
+ for (const p of parts) if (p.type === "text" && p.text?.trim()) {
810
+ lastActivity = p.text.slice(0, 120).replace(/\n/g, " ").trim();
811
+ if (p.text.length > 120) lastActivity += "…";
812
+ }
813
+ }
814
+ } catch {}
815
+ }
816
+ if (currentTitle !== name || !cwd) continue;
817
+ const stat = statSync(fullPath);
818
+ matches.push({
819
+ id: basename(f, ".jsonl"),
820
+ mtime: stat.mtimeMs,
821
+ size: stat.size,
822
+ firstPrompt,
823
+ lastActivity,
824
+ dir: cwd
825
+ });
826
+ } catch {}
827
+ }
828
+ }
829
+ return matches.sort((a, b) => b.mtime - a.mtime);
830
+ }
831
+ /**
748
832
  * List all unique session names (customTitle) in a project directory.
749
833
  * Used to show available names when a resume lookup fails.
750
834
  */
@@ -849,7 +933,8 @@ const resumeCommand = define({
849
933
  process.exit(1);
850
934
  }
851
935
  const tabId = matchingTabs[0];
852
- const termBlock = (tabsById.get(tabId) ?? []).find((b) => b.view === "term");
936
+ const blocks = tabsById.get(tabId) ?? [];
937
+ const termBlock = blocks.find((b) => b.view === "term");
853
938
  if (!termBlock) {
854
939
  consola.error(`No terminal block found in tab '${name}'`);
855
940
  process.exit(1);
@@ -860,7 +945,20 @@ const resumeCommand = define({
860
945
  consola.warn(`Claude is already running in tab "${name}" (${status}) — skipping resume`);
861
946
  process.exit(0);
862
947
  }
863
- if (status === "unknown") consola.warn(`Scrollback unavailable for tab "${name}" — cannot confirm shell is ready. Proceeding anyway.`);
948
+ if (status === "unknown") {
949
+ if (await adapter.confirmScrollbackEmpty(termBlock.blockid)) {
950
+ consola.info(`Tab "${name}" has no live shell (empty scrollback) — recreating`);
951
+ for (const b of blocks) adapter.deleteBlock(b.blockid);
952
+ adapter.closeSocket();
953
+ const newTabId = await openSession({
954
+ tabName: name,
955
+ dir,
956
+ claudeCmd: `claude --resume ${sessionId} --name ${JSON.stringify(name)}`
957
+ });
958
+ consola.success(`Tab "${name}" [${newTabId.slice(0, 8)}] → claude --resume ${sessionId.slice(0, 8)}… at ${dir} (recreated)`);
959
+ return;
960
+ }
961
+ }
864
962
  const extraFlags = loadConfig().claude.flags.join(" ");
865
963
  const cmd = `cd ${JSON.stringify(dir)} && claude${extraFlags ? " " + extraFlags : ""} --resume ${sessionId} --name ${JSON.stringify(name)}\r`;
866
964
  await adapter.sendInput(termBlock.blockid, cmd);
@@ -1200,20 +1298,15 @@ const configCommand = define({
1200
1298
  //#region src/commands/restore.ts
1201
1299
  const restoreCommand = define({
1202
1300
  name: "restore",
1203
- description: "Resume Claude sessions in all terminal-state tabs (e.g. after a reboot)",
1204
- args: {
1205
- dir: {
1206
- type: "positional",
1207
- description: "Working directory (default: cwd)"
1208
- },
1209
- dry: {
1210
- type: "boolean",
1211
- short: "n",
1212
- description: "Show what would be resumed without actually doing it"
1213
- }
1214
- },
1301
+ description: "Resume Claude sessions in all terminal-state tabs (e.g. after a reboot). Searches every Claude project dir by default; pass an explicit dir to scope the search.",
1302
+ args: { dry: {
1303
+ type: "boolean",
1304
+ short: "n",
1305
+ description: "Show what would be resumed without actually doing it"
1306
+ } },
1215
1307
  async run(ctx) {
1216
- const dir = resolve((ctx.positionals[1] ?? process.cwd()).replace(/^~/, homedir()));
1308
+ const rawDir = ctx.positionals[1];
1309
+ const scopedDir = rawDir ? resolve(rawDir.replace(/^~/, homedir())) : null;
1217
1310
  const dryRun = ctx.values.dry;
1218
1311
  const adapter = requireWaveAdapter();
1219
1312
  const { tabsById, workspaces, tabNames } = await adapter.getAllData();
@@ -1243,35 +1336,72 @@ const restoreCommand = define({
1243
1336
  consola.info(`Found ${toResume.length} tab(s) to restore:`);
1244
1337
  const extraFlags = loadConfig().claude.flags.join(" ");
1245
1338
  const results = [];
1339
+ const toRecreate = [];
1246
1340
  for (const tab of toResume) {
1247
- const sessions = findSessionsByName(dir, tab.name);
1248
- if (sessions.length === 0) {
1249
- consola.log(` ${tab.name} — no session named "${tab.name}" found, skipping`);
1250
- results.push({
1251
- name: tab.name,
1252
- result: "no matching session"
1253
- });
1254
- continue;
1255
- }
1256
- if (sessions.length > 1) {
1257
- consola.log(` ${tab.name} — multiple sessions found, skipping (use cctabs resume --session to pick one)`);
1258
- results.push({
1259
- name: tab.name,
1260
- result: "ambiguous (multiple sessions)"
1261
- });
1262
- continue;
1341
+ let sessionId = null;
1342
+ let sessionDir = null;
1343
+ if (scopedDir) {
1344
+ const sessions = findSessionsByName(scopedDir, tab.name);
1345
+ if (sessions.length === 0) {
1346
+ consola.log(` ${tab.name} no session named "${tab.name}" found in ${scopedDir}, skipping`);
1347
+ results.push({
1348
+ name: tab.name,
1349
+ result: "no matching session"
1350
+ });
1351
+ continue;
1352
+ }
1353
+ if (sessions.length > 1) {
1354
+ consola.log(` ${tab.name} multiple sessions found, skipping (use cctabs resume --session to pick one)`);
1355
+ results.push({
1356
+ name: tab.name,
1357
+ result: "ambiguous (multiple sessions)"
1358
+ });
1359
+ continue;
1360
+ }
1361
+ sessionId = sessions[0].id;
1362
+ sessionDir = scopedDir;
1363
+ } else {
1364
+ const sessions = findSessionsByNameGlobally(tab.name);
1365
+ if (sessions.length === 0) {
1366
+ consola.log(` ${tab.name} — no session named "${tab.name}" found in any project, skipping`);
1367
+ results.push({
1368
+ name: tab.name,
1369
+ result: "no matching session"
1370
+ });
1371
+ continue;
1372
+ }
1373
+ if (sessions.length > 1) consola.log(` ${tab.name} — multiple sessions found across projects, picking newest (${sessions[0].dir})`);
1374
+ sessionId = sessions[0].id;
1375
+ sessionDir = sessions[0].dir;
1263
1376
  }
1264
- const sessionId = sessions[0].id;
1265
1377
  if (dryRun) {
1266
- consola.log(` ${tab.name} would resume session ${sessionId.slice(0, 8)}…`);
1378
+ const mode = tab.status === "unknown" ? "recreate" : "send";
1379
+ consola.log(` ${tab.name} → would ${mode} session ${sessionId.slice(0, 8)}… in ${sessionDir}`);
1267
1380
  results.push({
1268
1381
  name: tab.name,
1269
1382
  result: `dry run: ${sessionId.slice(0, 8)}…`
1270
1383
  });
1271
1384
  continue;
1272
1385
  }
1273
- consola.log(` ${tab.name} resuming session ${sessionId.slice(0, 8)}…`);
1274
- const cmd = `cd ${JSON.stringify(dir)} && claude${extraFlags ? " " + extraFlags : ""} --resume ${sessionId} --name ${JSON.stringify(tab.name)}\r`;
1386
+ if (tab.status === "unknown") {
1387
+ if (await adapter.confirmScrollbackEmpty(tab.blockId)) {
1388
+ const blockIds = (tabsById.get(tab.tabId) ?? []).map((b) => b.blockid);
1389
+ toRecreate.push({
1390
+ name: tab.name,
1391
+ sessionId,
1392
+ sessionDir,
1393
+ blockIds,
1394
+ tabId: tab.tabId
1395
+ });
1396
+ results.push({
1397
+ name: tab.name,
1398
+ result: "queued for recreate"
1399
+ });
1400
+ continue;
1401
+ }
1402
+ }
1403
+ consola.log(` ${tab.name} → resuming session ${sessionId.slice(0, 8)}… in ${sessionDir} (send)`);
1404
+ const cmd = `cd ${JSON.stringify(sessionDir)} && claude${extraFlags ? " " + extraFlags : ""} --resume ${sessionId} --name ${JSON.stringify(tab.name)}\r`;
1275
1405
  await adapter.sendInput(tab.blockId, cmd);
1276
1406
  await new Promise((r) => setTimeout(r, 500));
1277
1407
  results.push({
@@ -1293,7 +1423,23 @@ const restoreCommand = define({
1293
1423
  }
1294
1424
  }
1295
1425
  }
1296
- adapter.closeSocket();
1426
+ if (!dryRun && toRecreate.length) {
1427
+ for (const t of toRecreate) for (const bid of t.blockIds) adapter.deleteBlock(bid);
1428
+ adapter.closeSocket();
1429
+ consola.info(`Recreating ${toRecreate.length} dead tab(s)…`);
1430
+ for (const t of toRecreate) try {
1431
+ const newTabId = await openSession({
1432
+ tabName: t.name,
1433
+ dir: t.sessionDir,
1434
+ claudeCmd: `claude --resume ${t.sessionId} --name ${JSON.stringify(t.name)}`
1435
+ });
1436
+ const r = results.find((x) => x.name === t.name);
1437
+ r.result = `✔ recreated [${newTabId.slice(0, 8)}]`;
1438
+ } catch (err) {
1439
+ const r = results.find((x) => x.name === t.name);
1440
+ r.result = `✘ recreate failed: ${err.message}`;
1441
+ }
1442
+ } else adapter.closeSocket();
1297
1443
  console.log("\nRestore summary:");
1298
1444
  for (const r of results) console.log(` ${r.name}: ${r.result}`);
1299
1445
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@generativereality/cctabs",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Claude Code tab manager. Terminal tabs as the UI, no tmux.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,10 +16,10 @@
16
16
  "build": "tsdown",
17
17
  "typecheck": "tsc --noEmit",
18
18
  "lint": "eslint src/",
19
- "check": "bun run typecheck && bun run build",
19
+ "check": "npm run typecheck && npm run build",
20
20
  "release": "bumpp && npm publish",
21
21
  "sync-plugin": "bash scripts/sync-plugin.sh",
22
- "prepack": "bash scripts/sync-plugin.sh --check && bun run build"
22
+ "prepack": "bash scripts/sync-plugin.sh --check && npm run build"
23
23
  },
24
24
  "keywords": [
25
25
  "claude-code",
@@ -7,6 +7,20 @@ You are managing Claude Code sessions using the `cctabs` CLI.
7
7
 
8
8
  **Important:** "tabs" here means **terminal tabs** (e.g. Wave Terminal tabs), NOT browser tabs. Each terminal tab runs its own Claude Code session. This skill is for managing those terminal-based Claude Code sessions — not for browser automation.
9
9
 
10
+ ## Before you spawn anything: is cctabs the right tool?
11
+
12
+ cctabs is excellent for:
13
+ - **Multiple human-driven sessions** on unrelated projects (check on a deploy here, draft a blog post there, monitor a long-running task somewhere else).
14
+ - **Genuinely orthogonal parallel work** where each tab touches a disjoint file set (e.g. each tab writes to its own new directory, or each tab works on a different repo).
15
+ - **Long-running background sessions** that the user wants to check on later (builds, scrapes, benchmarks).
16
+
17
+ cctabs is the WRONG tool for:
18
+ - **Interconnected parallel work within one session.** If you're orchestrating and farming out subtasks that all modify the same evolving codebase, tabs hide each other's commits from each other. By the time they're done, you have three diverged branches that need manual merge, and any intervening change on `main`/`next` can make the merge structurally painful. **Use the Agent tool instead** — subagents share your filesystem and git state, commit in place, and surface their result back to you.
19
+ - **Sequential dependencies.** If B depends on A's commits landing, don't parallelize — run A to completion first, then B.
20
+ - **Work that touches the same files as the current orchestrator session.** Commits race, branches diverge, conflicts multiply.
21
+
22
+ A good test: *"If both tabs finish successfully, will merging their output be trivial?"* If yes, cctabs is fine. If no (or you can't tell), do it sequentially or use subagents.
23
+
10
24
  ## First: Ensure cctabs is available
11
25
 
12
26
  ```bash
@@ -27,6 +41,29 @@ Do not modify PATH or npm configuration beyond this.
27
41
 
28
42
  Each Claude Code session runs in its own **terminal tab**. `cctabs` lets you — and other Claude Code sessions — introspect and orchestrate the full session fleet.
29
43
 
44
+ ## When to Use Worktrees
45
+
46
+ **Use `--worktree` whenever a tab will edit code on a branch that differs from the main working tree.** This includes:
47
+ - Fixing CI on a PR (`cctabs new fix-1789 ~/Dev/myapp --worktree`)
48
+ - Working on a feature branch while the main checkout runs a dev server
49
+ - Any task where multiple tabs might checkout different branches
50
+
51
+ Without `--worktree`, all tabs share the same working directory. If two tabs checkout different branches, they stomp on each other's files — causing silent conflicts, lost changes, and broken dev servers.
52
+
53
+ **Rule of thumb:**
54
+ - **Read-only / docs / coordination** → no worktree needed (stays on current branch)
55
+ - **Editing code on a different branch** → always `--worktree`
56
+
57
+ ```bash
58
+ # ❌ WRONG — two tabs checking out different branches in the same directory
59
+ cctabs new fix-auth ~/Dev/myapp --prompt "checkout PR #101 and fix lint"
60
+ cctabs new fix-api ~/Dev/myapp --prompt "checkout PR #102 and fix tests"
61
+
62
+ # ✅ RIGHT — each gets its own isolated copy
63
+ cctabs new fix-auth ~/Dev/myapp --worktree --prompt "checkout PR #101 and fix lint"
64
+ cctabs new fix-api ~/Dev/myapp --worktree --prompt "checkout PR #102 and fix tests"
65
+ ```
66
+
30
67
  ## Quick Reference
31
68
 
32
69
  ```bash
@@ -34,6 +71,7 @@ cctabs sessions # list all tabs with session status
34
71
  cctabs list # list all workspaces, tabs, and blocks
35
72
  cctabs new <name> [dir] [-w workspace] [-p "prompt"] [-f file] # new tab + claude
36
73
  cctabs resume <name> [dir] # resume last session (reuses tab or creates one)
74
+ cctabs restore [dir] [--dry] # resume every dead tab (e.g. after a reboot)
37
75
  cctabs fork <tab-name> [-n new-name] # fork session into new tab (--resume <id> --fork-session)
38
76
  cctabs close <name-or-id> # close a tab
39
77
  cctabs rename <name-or-id> <new-name> # rename a tab
@@ -89,6 +127,18 @@ cctabs resume api ~/Dev/myapp
89
127
  **Use `cctabs resume` instead of `cctabs new` when you want to continue a previous conversation.**
90
128
  `cctabs new` always starts a fresh Claude session. `cctabs resume` picks up where the last session left off.
91
129
 
130
+ ## Workflow: Restoring tabs after a reboot
131
+
132
+ 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.
133
+
134
+ ```bash
135
+ cctabs restore # search all projects (default)
136
+ cctabs restore --dry # preview what would be resumed without doing it
137
+ cctabs restore ~/Dev/myapp # restrict the search to one project dir
138
+ ```
139
+
140
+ 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.
141
+
92
142
  ## Workflow: Forking a Session
93
143
 
94
144
  Use `fork` when you want to explore an alternative approach without disrupting the original.
@@ -104,7 +154,9 @@ The forked session shares full conversation history up to the fork point, then d
104
154
 
105
155
  ## Workflow: Spawning a Parallel Agent
106
156
 
107
- As a Claude Code session, you can spawn a sibling session to work on a parallel task:
157
+ **Before spawning, re-read "is cctabs the right tool?" above.** If the task is interconnected with your current work, use the Agent tool (subagents) instead — they share your filesystem and commits.
158
+
159
+ As a Claude Code session, you can spawn a sibling session for a **genuinely independent** parallel task:
108
160
 
109
161
  **Preferred: pass the initial task directly to `cctabs new`** using `--prompt` or `--file`. This polls internally until Claude's `❯` prompt appears before sending — no race condition:
110
162
 
@@ -125,6 +177,25 @@ cctabs send payments "yes\n" # quick replies
125
177
 
126
178
  **Do NOT call `cctabs send` immediately after `cctabs new`** — Claude is still starting up and the text will land as raw shell commands.
127
179
 
180
+ ### Spawning gotchas (hard-won)
181
+
182
+ 1. **Verify the worktree base immediately after spawn.** `--worktree` does not always branch from your current HEAD — if you have local un-pushed commits, the child session may branch from an older commit (whatever the remote tracking branch points at). Always check:
183
+ ```bash
184
+ cctabs new kid ~/Dev/myapp --worktree -p "..."
185
+ # Then in the ORCHESTRATOR tab:
186
+ git -C ~/Dev/myapp/.claude/worktrees/kid log --oneline -1
187
+ ```
188
+ If the base is not what you expected, abort and fix: either push your commits to the tracking branch first, or spawn without `--worktree` and let the subagent work on your branch directly.
189
+
190
+ 2. **Never instruct a subagent to "rebase your branch on main/next."** Subagents interpret this liberally. A common failure mode: the subagent does `git reset --hard <remote>` and throws away its own completed commits, trying to redo the work from scratch. Instead:
191
+ - Have the orchestrator handle rebases after the subagent is done.
192
+ - Or send a precise patch/diff rather than a verbal rebase instruction.
193
+ - Or tell the subagent explicitly: *"do not rebase, do not reset; make fixup commits on top of your existing branch."*
194
+
195
+ 3. **Subagents won't see each other's commits.** Each tab has its own working tree. If ws-A commits a schema, ws-B cannot consume it until you merge A → main → rebase B. This is a fundamental property, not a bug. Only parallelize when this limitation doesn't matter.
196
+
197
+ 4. **Don't delegate rebases or merges to subagents.** Those are orchestrator work. Subagents produce content; orchestrator integrates.
198
+
128
199
  ## Workflow: Monitoring Another Session
129
200
 
130
201
  ```bash
@@ -176,10 +247,36 @@ cctabs new feature ~/Dev/myapp --worktree
176
247
 
177
248
  **Why:** Manually created worktree dirs placed outside the repo confuse Claude Code's session tracking, project memory lookup (`.claude/` is in the main repo), and CLAUDE.md resolution. Claude Code's built-in worktree support keeps everything co-located under `.claude/worktrees/` and handles cleanup on session exit.
178
249
 
250
+ **Worktree base-commit caveat:** after spawning with `--worktree`, verify the branch base matches your expectation (see "Spawning gotchas" above). If your orchestrator has local commits that haven't been pushed, the worktree may branch from the stale remote tip instead of HEAD. This bites hardest when parallel tabs need to share schema/types your orchestrator has been working on — they won't see those changes if they branched before the commits landed upstream.
251
+
252
+ ## Handling `cctabs new` Timeout Errors
253
+
254
+ `cctabs new` may occasionally fail with "Timed out waiting for new terminal block". This does **NOT** mean you have too many tabs or that Wave Terminal has hit a limit.
255
+
256
+ **Possible causes** (root cause not yet confirmed):
257
+ - Wave Terminal may need to be in focus / foreground for tab creation to register
258
+ - The internal timeout may be slightly too short for the current system load
259
+ - Transient IPC timing issue between cctabs and Wave
260
+
261
+ **What to do:**
262
+ 1. **Retry the same command** — it often works on the second attempt
263
+ 2. If it fails again, wait a few seconds and retry once more
264
+ 3. If it keeps failing, ask the user to bring Wave Terminal to the foreground and try again
265
+
266
+ **What NOT to do:**
267
+ - ❌ Do NOT assume there is a "tab limit" — there isn't one
268
+ - ❌ Do NOT close other tabs to "make room" — this destroys the user's sessions
269
+ - ❌ Do NOT suggest the user has too many tabs open
270
+
179
271
  ## Workflow: Cleanup
180
272
 
273
+ **⚠️ NEVER close tabs without explicit user approval.** Each tab may contain an active session with important context, uncommitted work, or in-progress tasks. Closing a tab is destructive and irreversible.
274
+
275
+ **Always ask first:**
276
+ > "These tabs look idle: `old-feature`, `fix-1234`. Want me to close any of them?"
277
+
278
+ Only after the user confirms:
181
279
  ```bash
182
- cctabs sessions # find idle/terminal tabs
183
280
  cctabs close old-feature # close by name (prefix match)
184
281
  cctabs close e5f6a7b8 # close by block ID prefix
185
282
  ```
@@ -200,3 +297,14 @@ Name tabs after the **project or task**:
200
297
  - `cctabs new` and `cctabs resume` automatically pass `--name <tab-name>` to claude, syncing the session display name with the tab title
201
298
  - Configured `claude.flags` in `~/.config/cctabs/config.toml` are applied to every session
202
299
  - `cctabs send` resolves tab names to their terminal block automatically
300
+
301
+ ## Lesson: the common failure mode
302
+
303
+ A pattern that wastes the most tokens: an orchestrator spawns three tabs for "parallel workstreams" on the same feature, each tab diverges from the base and from each other, the orchestrator loses visibility into what each is doing, one tab misinterprets a course-correct and resets its own work, and finally the orchestrator spends hours hand-merging commits that don't apply cleanly against an intervening refactor.
304
+
305
+ The fix is upstream: before spawning, ask *"are these workstreams actually independent?"* If the answer is "mostly, but they share a common data model / schema / utility module" — they are **not** independent for cctabs purposes. Either:
306
+ - Do them sequentially in one tab (cheapest).
307
+ - Use the Agent tool for subtasks that share orchestrator state.
308
+ - Land the shared pieces first on `main`/`next`, push, then spawn tabs (each branches cleanly off the new tip and work is truly orthogonal from there).
309
+
310
+ Parallel tabs earn their keep when the work is genuinely orthogonal (separate repos, separate brand-new directories, independent features) and when you'd otherwise be idle waiting for one long-running task to finish.