@generativereality/cctabs 0.2.0 → 0.3.0

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.2.0",
4
+ "version": "0.3.0",
5
5
  "author": {
6
6
  "name": "generativereality",
7
7
  "url": "https://cctabs.com"
package/dist/index.js CHANGED
@@ -2,15 +2,16 @@
2
2
  import updateNotifier from "update-notifier";
3
3
  import { cli, define } from "gunshi";
4
4
  import { createConnection } from "net";
5
- import { execFileSync, spawnSync } from "child_process";
5
+ import { execFileSync, spawn, spawnSync } from "child_process";
6
6
  import { randomUUID } from "crypto";
7
- import { homedir, tmpdir } from "os";
7
+ import { homedir, platform, tmpdir } from "os";
8
8
  import { basename, dirname, extname, join, resolve } from "path";
9
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
9
10
  import { consola } from "consola";
10
- import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
11
+ import * as p from "@clack/prompts";
11
12
  //#region package.json
12
13
  var name = "@generativereality/cctabs";
13
- var version = "0.2.0";
14
+ var version = "0.3.0";
14
15
  var description = "Claude Code tab manager. Terminal tabs as the UI, no tmux.";
15
16
  var package_default = {
16
17
  name,
@@ -31,6 +32,8 @@ var package_default = {
31
32
  "check": "npm run typecheck && npm run build",
32
33
  "release": "bumpp && npm publish",
33
34
  "sync-plugin": "bash scripts/sync-plugin.sh",
35
+ "build:tabby-plugin": "cd tabby-plugin && npm install && npm run build",
36
+ "publish:tabby-plugin": "cd tabby-plugin && npm publish --access public",
34
37
  "prepack": "bash scripts/sync-plugin.sh --check && npm run build"
35
38
  },
36
39
  keywords: [
@@ -70,9 +73,10 @@ var package_default = {
70
73
  //#endregion
71
74
  //#region src/core/terminal.ts
72
75
  function detectTerminal() {
73
- if (process.env.WAVETERM_JWT) return "wave";
74
76
  const prog = process.env.TERM_PROGRAM ?? "";
75
77
  const term = process.env.TERM ?? "";
78
+ if (prog === "Tabby") return "tabby";
79
+ if (process.env.WAVETERM_JWT) return "wave";
76
80
  if (prog === "iTerm.app") return "iterm2";
77
81
  if (prog === "ghostty" || process.env.GHOSTTY_RESOURCES_DIR) return "ghostty";
78
82
  if (prog === "WarpTerminal") return "warp";
@@ -85,6 +89,7 @@ function detectTerminal() {
85
89
  }
86
90
  const TERMINAL_NAMES = {
87
91
  wave: "Wave Terminal",
92
+ tabby: "Tabby",
88
93
  iterm2: "iTerm2",
89
94
  ghostty: "Ghostty",
90
95
  warp: "Warp",
@@ -123,7 +128,89 @@ function adapterFileName(terminal) {
123
128
  return `${terminal}.ts`;
124
129
  }
125
130
  //#endregion
131
+ //#region src/core/wave-db.ts
132
+ const WAVE_DB_PATH = process.env.CCTABS_WAVE_DB_PATH ?? join(homedir(), "Library", "Application Support", "waveterm", "db", "waveterm.db");
133
+ const WSH_ORPHAN_TABID_PATTERN = /couldn't list blocks for workspace [0-9a-f-]+: not found/i;
134
+ function sqliteRun(query, mode = "list") {
135
+ return execFileSync("sqlite3", [
136
+ "-readonly",
137
+ "-bail",
138
+ `-${mode}`,
139
+ WAVE_DB_PATH,
140
+ query
141
+ ], { encoding: "utf-8" }).trim();
142
+ }
143
+ /** Inspect every workspace; report any tabids that point at non-existent rows in db_tab. */
144
+ function findOrphanTabIds() {
145
+ if (!existsSync(WAVE_DB_PATH)) return [];
146
+ const rawWs = sqliteRun("SELECT oid AS oid, json_extract(data, '$.name') AS name, json_extract(data, '$.tabids') AS tabids FROM db_workspace;", "json");
147
+ const tabRows = sqliteRun("SELECT oid FROM db_tab;", "list");
148
+ const liveTabs = new Set(tabRows.split("\n").map((s) => s.trim()).filter(Boolean));
149
+ const wsRows = rawWs ? JSON.parse(rawWs) : [];
150
+ const reports = [];
151
+ for (const row of wsRows) {
152
+ if (!row.tabids) continue;
153
+ let tabids;
154
+ try {
155
+ tabids = JSON.parse(row.tabids);
156
+ } catch {
157
+ continue;
158
+ }
159
+ if (!Array.isArray(tabids)) continue;
160
+ const present = [];
161
+ const orphans = [];
162
+ for (const t of tabids) {
163
+ if (typeof t !== "string") continue;
164
+ if (liveTabs.has(t)) present.push(t);
165
+ else orphans.push(t);
166
+ }
167
+ if (orphans.length) reports.push({
168
+ workspaceId: row.oid,
169
+ workspaceName: row.name || row.oid.slice(0, 8),
170
+ presentTabIds: present,
171
+ orphanTabIds: orphans
172
+ });
173
+ }
174
+ return reports;
175
+ }
176
+ /** Copy the live DB to a timestamped backup next to it. Returns the backup path. */
177
+ function backupWaveDb() {
178
+ const dest = `${WAVE_DB_PATH}.cctabs-backup-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}`;
179
+ copyFileSync(WAVE_DB_PATH, dest);
180
+ return dest;
181
+ }
182
+ /** Surgically strip orphan tabids from a workspace's tabids array.
183
+ *
184
+ * SQLite's json_remove evaluates each path against the *previous* result, so
185
+ * we delete by descending index — that way each removal doesn't shift later
186
+ * indices out from under us.
187
+ */
188
+ function removeOrphanTabIds(report) {
189
+ if (!report.orphanTabIds.length) return;
190
+ const r = spawnSync("sqlite3", [WAVE_DB_PATH, `UPDATE db_workspace SET data = json_set(data, '$.tabids', json('${JSON.stringify(report.presentTabIds).replace(/'/g, "''")}')) WHERE oid = '${report.workspaceId}';`], { encoding: "utf-8" });
191
+ if (r.status !== 0) throw new Error(`sqlite3 update failed (status ${r.status}): ${r.stderr?.trim() || "unknown error"}`);
192
+ }
193
+ //#endregion
126
194
  //#region src/core/wave.ts
195
+ var WaveOrphanTabidError = class extends Error {
196
+ reports;
197
+ wshStderr;
198
+ constructor(reports, wshStderr) {
199
+ const lines = [
200
+ "Wave Terminal's block listing aborted: a workspace's tabids list references a tab that no longer exists in db_tab.",
201
+ "wsh stderr: " + wshStderr.trim(),
202
+ "",
203
+ "Affected workspaces:",
204
+ ...reports.map((r) => ` • "${r.workspaceName}" [${r.workspaceId.slice(0, 8)}] — ${r.orphanTabIds.length} orphan tabid(s): ${r.orphanTabIds.map((t) => t.slice(0, 8)).join(", ")}`),
205
+ "",
206
+ "Run `cctabs doctor` to back up the Wave DB and apply the fix."
207
+ ];
208
+ super(lines.join("\n"));
209
+ this.name = "WaveOrphanTabidError";
210
+ this.reports = reports;
211
+ this.wshStderr = wshStderr;
212
+ }
213
+ };
127
214
  const SOCK_PATH = join(homedir(), "Library", "Application Support", "waveterm", "wave.sock");
128
215
  var WaveSocket = class {
129
216
  socket;
@@ -198,6 +285,7 @@ var WaveSocket = class {
198
285
  var WaveAdapter = class {
199
286
  socket = null;
200
287
  jwt;
288
+ lastBlocksListStderr = "";
201
289
  constructor() {
202
290
  this.jwt = process.env.WAVETERM_JWT ?? "";
203
291
  }
@@ -211,13 +299,27 @@ var WaveAdapter = class {
211
299
  "15000"
212
300
  ];
213
301
  if (wsId) args.push("--workspace", wsId);
302
+ const r = spawnSync("wsh", args, { encoding: "utf-8" });
303
+ this.lastBlocksListStderr = r.stderr ?? "";
304
+ if (r.status !== 0 || !r.stdout?.trim()) return [];
214
305
  try {
215
- const out = execFileSync("wsh", args, { encoding: "utf-8" });
216
- return JSON.parse(out);
306
+ return JSON.parse(r.stdout);
217
307
  } catch {
218
308
  return [];
219
309
  }
220
310
  }
311
+ /** If the most recent blocksList() looked like it hit Wave's orphan-tabid
312
+ * abort, run a sqlite3 self-check to confirm. Returns reports when orphans
313
+ * are found, otherwise null. */
314
+ diagnoseOrphanTabids() {
315
+ const stderr = this.lastBlocksListStderr;
316
+ if (!stderr || !WSH_ORPHAN_TABID_PATTERN.test(stderr)) return null;
317
+ const reports = findOrphanTabIds();
318
+ return reports.length ? reports : null;
319
+ }
320
+ getLastBlocksListStderr() {
321
+ return this.lastBlocksListStderr;
322
+ }
221
323
  scrollback(blockId, lastN = 50) {
222
324
  return spawnSync("wsh", [
223
325
  "termscrollback",
@@ -233,7 +335,7 @@ var WaveAdapter = class {
233
335
  async confirmScrollbackEmpty(blockId, attempts = 3, intervalMs = 500) {
234
336
  for (let i = 0; i < attempts; i++) {
235
337
  if (this.scrollback(blockId, 10).trim()) return false;
236
- if (i < attempts - 1) await sleep(intervalMs);
338
+ if (i < attempts - 1) await sleep$1(intervalMs);
237
339
  }
238
340
  return true;
239
341
  }
@@ -263,7 +365,7 @@ var WaveAdapter = class {
263
365
  async newTab(focusWindowId) {
264
366
  if (focusWindowId) {
265
367
  await this.focusWindow(focusWindowId);
266
- await sleep(300);
368
+ await sleep$1(300);
267
369
  }
268
370
  const r = spawnSync("osascript", ["-e", [
269
371
  "tell application \"Wave\" to activate",
@@ -279,7 +381,7 @@ var WaveAdapter = class {
279
381
  async waitForNewBlock(beforeIds, timeoutMs = 15e3) {
280
382
  const deadline = Date.now() + timeoutMs;
281
383
  while (Date.now() < deadline) {
282
- await sleep(250);
384
+ await sleep$1(250);
283
385
  for (const b of this.blocksList()) if (b.view === "term" && !beforeIds.has(b.blockid)) return {
284
386
  blockId: b.blockid,
285
387
  tabId: b.tabid
@@ -321,6 +423,13 @@ var WaveAdapter = class {
321
423
  }
322
424
  async getAllData() {
323
425
  const blocks = this.blocksList();
426
+ if (!blocks.length) {
427
+ const orphans = this.diagnoseOrphanTabids();
428
+ if (orphans) {
429
+ this.closeSocket();
430
+ throw new WaveOrphanTabidError(orphans, this.lastBlocksListStderr);
431
+ }
432
+ }
324
433
  const tabsById = /* @__PURE__ */ new Map();
325
434
  for (const b of blocks) {
326
435
  const arr = tabsById.get(b.tabid) ?? [];
@@ -379,6 +488,15 @@ var WaveAdapter = class {
379
488
  resolveBlock(query, blocks) {
380
489
  return blocks.filter((b) => b.blockid.startsWith(query));
381
490
  }
491
+ currentTabId() {
492
+ return process.env.WAVETERM_TABID ?? "";
493
+ }
494
+ currentBlockId() {
495
+ return process.env.WAVETERM_BLOCKID ?? "";
496
+ }
497
+ currentWorkspaceId() {
498
+ return process.env.WAVETERM_WORKSPACEID ?? "";
499
+ }
382
500
  resolveWorkspace(workspaces, query) {
383
501
  const q = query.toLowerCase();
384
502
  const exact = workspaces.filter(({ workspacedata: wd }) => (wd.name ?? "").toLowerCase() === q);
@@ -391,16 +509,315 @@ var WaveAdapter = class {
391
509
  }));
392
510
  }
393
511
  };
394
- function requireWaveAdapter() {
395
- if (!process.env.WAVETERM_JWT) {
396
- printUnsupportedTerminalError(detectTerminal());
397
- process.exit(1);
398
- }
399
- return new WaveAdapter();
512
+ function sleep$1(ms) {
513
+ return new Promise((r) => setTimeout(r, ms));
400
514
  }
515
+ //#endregion
516
+ //#region src/core/tabby.ts
517
+ /**
518
+ * Thrown when the cctabs Tabby plugin's HTTP API is unreachable.
519
+ *
520
+ * Failing loud here matters: if we silently returned empty data the
521
+ * caller would conclude "no tabs" and act on it. The CLI's outer error
522
+ * handler renders this with the install hint.
523
+ */
524
+ var TabbyPluginUnreachableError = class extends Error {
525
+ constructor(host, port, underlying) {
526
+ const lines = [
527
+ `cctabs Tabby plugin not reachable at http://${host}:${port}.`,
528
+ underlying ? ` reason: ${underlying}` : "",
529
+ "",
530
+ "Install + restart Tabby in one shot from inside a Tabby tab:",
531
+ " cctabs install-tabby-plugin",
532
+ "",
533
+ "Or do it by hand:",
534
+ ` npm install --legacy-peer-deps --prefix "$HOME/Library/Application Support/tabby/plugins" tabby-cctabs`,
535
+ " # then quit Tabby (Cmd+Q) and reopen it.",
536
+ "",
537
+ "Verify with: cctabs doctor"
538
+ ].filter(Boolean);
539
+ super(lines.join("\n"));
540
+ this.host = host;
541
+ this.port = port;
542
+ this.underlying = underlying;
543
+ this.name = "TabbyPluginUnreachableError";
544
+ }
545
+ };
546
+ /**
547
+ * cctabs adapter for Tabby Terminal. Talks to the tabby-cctabs
548
+ * plugin's HTTP API (default 127.0.0.1:3300).
549
+ *
550
+ * Tab identity: cctabs (running inside a Tabby tab) walks its own
551
+ * process.pid → ppid chain via `ps`, POSTs the PID list to
552
+ * /api/tabs/identify, and caches the resulting plugin UUID.
553
+ *
554
+ * In Tabby's data model there are no Wave-style "workspaces" or "blocks";
555
+ * we project a single synthetic workspace and a 1:1 tab↔block mapping so
556
+ * the rest of cctabs sees the same shape it does on Wave.
557
+ */
558
+ var TabbyAdapter = class {
559
+ host;
560
+ port;
561
+ cachedSelfUuid = null;
562
+ healthChecked = false;
563
+ constructor() {
564
+ this.host = process.env.CCTABS_TABBY_HOST ?? "127.0.0.1";
565
+ this.port = Number(process.env.CCTABS_TABBY_PORT ?? "3300");
566
+ }
567
+ /**
568
+ * One-shot health check. Throws TabbyPluginUnreachableError on the
569
+ * first call if /api/health is unreachable. Subsequent calls are no-ops
570
+ * — once we've confirmed the plugin is up, we trust it for this process.
571
+ */
572
+ ensureHealthy() {
573
+ if (this.healthChecked) return;
574
+ const r = spawnSync("curl", [
575
+ "-fsS",
576
+ "--max-time",
577
+ "3",
578
+ this.url("/api/health")
579
+ ], { encoding: "utf-8" });
580
+ if (r.status !== 0 || !r.stdout) {
581
+ const reason = (r.stderr || "").trim() || `curl exit ${r.status}`;
582
+ throw new TabbyPluginUnreachableError(this.host, this.port, reason);
583
+ }
584
+ this.healthChecked = true;
585
+ }
586
+ url(path) {
587
+ return `http://${this.host}:${this.port}${path}`;
588
+ }
589
+ async http(method, path, body) {
590
+ const res = await fetch(this.url(path), {
591
+ method,
592
+ headers: body ? { "content-type": "application/json" } : void 0,
593
+ body: body ? JSON.stringify(body) : void 0
594
+ });
595
+ if (res.status === 404) return null;
596
+ if (!res.ok) throw new Error(`Tabby plugin ${method} ${path} failed: ${res.status}`);
597
+ return res.json();
598
+ }
599
+ closeSocket() {}
600
+ blocksList() {
601
+ this.ensureHealthy();
602
+ const out = spawnSync("curl", [
603
+ "-fsS",
604
+ "--max-time",
605
+ "5",
606
+ this.url("/api/tabs")
607
+ ], { encoding: "utf-8" });
608
+ if (out.status !== 0 || !out.stdout) return [];
609
+ let parsed;
610
+ try {
611
+ parsed = JSON.parse(out.stdout);
612
+ } catch {
613
+ return [];
614
+ }
615
+ return parsed.tabs.filter((t) => t.type === "terminal").map((t) => ({
616
+ blockid: t.uuid,
617
+ tabid: t.uuid,
618
+ view: "term",
619
+ meta: t.cwd ? { "cmd:cwd": t.cwd } : void 0
620
+ }));
621
+ }
622
+ scrollback(blockId, lastN = 50) {
623
+ const out = spawnSync("curl", [
624
+ "-fsS",
625
+ "--max-time",
626
+ "5",
627
+ this.url(`/api/tabs/${blockId}/buffer?lines=${lastN}`)
628
+ ], { encoding: "utf-8" });
629
+ if (out.status !== 0 || !out.stdout) return "";
630
+ try {
631
+ return JSON.parse(out.stdout).lines.join("\n");
632
+ } catch {
633
+ return "";
634
+ }
635
+ }
636
+ async confirmScrollbackEmpty(blockId, attempts = 3, intervalMs = 500) {
637
+ for (let i = 0; i < attempts; i++) {
638
+ if (this.scrollback(blockId, 10).trim()) return false;
639
+ if (i < attempts - 1) await sleep(intervalMs);
640
+ }
641
+ return true;
642
+ }
643
+ detectSessionStatus(blockId) {
644
+ const tail = this.scrollback(blockId, 10);
645
+ if (!tail.trim()) return "unknown";
646
+ const lastLine = tail.split("\n").map((l) => l.trim()).filter(Boolean).at(-1) ?? "";
647
+ if (/[$%>]\s*$/.test(lastLine) && !lastLine.includes("claude")) return "terminal";
648
+ if ([
649
+ "Claude Code",
650
+ "claude.ai/code",
651
+ "✻ Thinking",
652
+ "✽ Hatching",
653
+ "⏵⏵ bypass"
654
+ ].some((s) => tail.includes(s))) return "active";
655
+ if (lastLine.toLowerCase().includes("claude")) return "idle";
656
+ return "terminal";
657
+ }
658
+ deleteBlock(blockId) {
659
+ spawnSync("curl", [
660
+ "-fsS",
661
+ "-X",
662
+ "POST",
663
+ "--max-time",
664
+ "5",
665
+ this.url(`/api/tabs/${blockId}/close`)
666
+ ], { encoding: "utf-8" });
667
+ }
668
+ async newTab(_focusWindowId) {
669
+ const r = await this.http("POST", "/api/tabs/new", {});
670
+ return Boolean(r);
671
+ }
672
+ async waitForNewBlock(beforeIds, timeoutMs = 15e3) {
673
+ const deadline = Date.now() + timeoutMs;
674
+ while (Date.now() < deadline) {
675
+ await sleep(250);
676
+ for (const b of this.blocksList()) if (!beforeIds.has(b.blockid)) return {
677
+ blockId: b.blockid,
678
+ tabId: b.tabid
679
+ };
680
+ }
681
+ return null;
682
+ }
683
+ async renameTab(tabId, name) {
684
+ await this.http("PUT", `/api/tabs/${tabId}/title`, { title: name });
685
+ }
686
+ async sendInput(blockId, text) {
687
+ return this.http("POST", `/api/tabs/${blockId}/send`, { data: text });
688
+ }
689
+ async getAllData() {
690
+ const blocks = this.blocksList();
691
+ const tabsById = /* @__PURE__ */ new Map();
692
+ for (const b of blocks) {
693
+ const arr = tabsById.get(b.tabid) ?? [];
694
+ arr.push(b);
695
+ tabsById.set(b.tabid, arr);
696
+ }
697
+ const tabNames = /* @__PURE__ */ new Map();
698
+ try {
699
+ const res = await fetch(this.url("/api/tabs"));
700
+ if (res.ok) {
701
+ const data = await res.json();
702
+ for (const t of data.tabs) tabNames.set(t.uuid, t.customTitle || t.title || t.uuid.slice(0, 8));
703
+ }
704
+ } catch {}
705
+ return {
706
+ blocks,
707
+ tabsById,
708
+ workspaces: [{
709
+ workspacedata: {
710
+ oid: "tabby",
711
+ name: "tabby",
712
+ tabids: [...tabsById.keys()]
713
+ },
714
+ windowid: ""
715
+ }],
716
+ tabNames
717
+ };
718
+ }
719
+ resolveTab(query, tabsById, tabNames) {
720
+ const q = query.toLowerCase();
721
+ if (query === "." || query === "self") {
722
+ const self = this.identifySelf();
723
+ return self ? [self] : [];
724
+ }
725
+ const ids = [...tabsById.keys()];
726
+ const exact = ids.filter((tid) => (tabNames.get(tid) ?? "").toLowerCase() === q);
727
+ if (exact.length > 0) return exact;
728
+ return ids.filter((tid) => {
729
+ const name = tabNames.get(tid) ?? "";
730
+ return tid.startsWith(query) || name.toLowerCase().startsWith(q);
731
+ });
732
+ }
733
+ resolveBlock(query, blocks) {
734
+ return blocks.filter((b) => b.blockid.startsWith(query));
735
+ }
736
+ resolveWorkspace(workspaces, _query) {
737
+ return workspaces.map((w) => ({
738
+ data: w.workspacedata,
739
+ windowId: w.windowid
740
+ }));
741
+ }
742
+ currentTabId() {
743
+ return this.identifySelf() ?? "";
744
+ }
745
+ currentBlockId() {
746
+ return this.currentTabId();
747
+ }
748
+ currentWorkspaceId() {
749
+ return "tabby";
750
+ }
751
+ /**
752
+ * Walk the process tree from `process.pid` upwards collecting PIDs, then
753
+ * ask the plugin which tab owns any of them. Result is cached for the
754
+ * lifetime of this adapter.
755
+ */
756
+ identifySelf() {
757
+ if (this.cachedSelfUuid !== null) return this.cachedSelfUuid;
758
+ const pids = walkAncestorPids(process.pid);
759
+ const out = spawnSync("curl", [
760
+ "-fsS",
761
+ "-X",
762
+ "POST",
763
+ "-H",
764
+ "content-type: application/json",
765
+ "--max-time",
766
+ "5",
767
+ "--data",
768
+ JSON.stringify({ pids }),
769
+ this.url("/api/tabs/identify")
770
+ ], { encoding: "utf-8" });
771
+ if (out.status !== 0 || !out.stdout) {
772
+ this.cachedSelfUuid = "";
773
+ return null;
774
+ }
775
+ try {
776
+ const parsed = JSON.parse(out.stdout);
777
+ this.cachedSelfUuid = parsed.uuid ?? "";
778
+ return parsed.uuid ?? null;
779
+ } catch {
780
+ this.cachedSelfUuid = "";
781
+ return null;
782
+ }
783
+ }
784
+ };
401
785
  function sleep(ms) {
402
786
  return new Promise((r) => setTimeout(r, ms));
403
787
  }
788
+ /**
789
+ * Walk `pid → ppid → ...` via `ps`. Caps at 32 levels to avoid pathological
790
+ * loops on misconfigured systems. Returns [pid, ppid, gppid, ...].
791
+ */
792
+ function walkAncestorPids(startPid, cap = 32) {
793
+ const out = [startPid];
794
+ let cur = startPid;
795
+ for (let i = 0; i < cap; i++) {
796
+ const r = spawnSync("ps", [
797
+ "-o",
798
+ "ppid=",
799
+ "-p",
800
+ String(cur)
801
+ ], { encoding: "utf-8" });
802
+ if (r.status !== 0) break;
803
+ const next = parseInt(r.stdout.trim(), 10);
804
+ if (!Number.isFinite(next) || next <= 1 || next === cur) break;
805
+ out.push(next);
806
+ cur = next;
807
+ }
808
+ return out;
809
+ }
810
+ //#endregion
811
+ //#region src/core/adapter.ts
812
+ /** Returns the adapter that matches the running terminal. Exits with a clear
813
+ * error message if none is supported. */
814
+ function requireAdapter() {
815
+ const terminal = detectTerminal();
816
+ if (terminal === "wave") return new WaveAdapter();
817
+ if (terminal === "tabby") return new TabbyAdapter();
818
+ printUnsupportedTerminalError(terminal);
819
+ process.exit(1);
820
+ }
404
821
  //#endregion
405
822
  //#region src/commands/sessions.ts
406
823
  const sessionsCommand = define({
@@ -408,10 +825,10 @@ const sessionsCommand = define({
408
825
  description: "List tabs with active/idle session status",
409
826
  args: {},
410
827
  async run() {
411
- const adapter = requireWaveAdapter();
828
+ const adapter = requireAdapter();
412
829
  const { tabsById, workspaces, tabNames } = await adapter.getAllData();
413
- const currentTab = process.env.WAVETERM_TABID ?? "";
414
- const currentWs = process.env.WAVETERM_WORKSPACEID ?? "";
830
+ const currentTab = adapter.currentTabId();
831
+ const currentWs = adapter.currentWorkspaceId();
415
832
  console.log("Sessions");
416
833
  console.log("=".repeat(50));
417
834
  for (const wsp of workspaces) {
@@ -446,10 +863,11 @@ const listCommand = define({
446
863
  description: "List all workspaces, tabs, and blocks",
447
864
  args: {},
448
865
  async run() {
449
- const { tabsById, workspaces, tabNames } = await requireWaveAdapter().getAllData();
450
- const currentBlock = process.env.WAVETERM_BLOCKID ?? "";
451
- const currentTab = process.env.WAVETERM_TABID ?? "";
452
- const currentWs = process.env.WAVETERM_WORKSPACEID ?? "";
866
+ const adapter = requireAdapter();
867
+ const { tabsById, workspaces, tabNames } = await adapter.getAllData();
868
+ const currentBlock = adapter.currentBlockId();
869
+ const currentTab = adapter.currentTabId();
870
+ const currentWs = adapter.currentWorkspaceId();
453
871
  for (const wsp of workspaces) {
454
872
  const { oid, name, tabids } = wsp.workspacedata;
455
873
  const noWindow = !wsp.windowid ? " (no window)" : "";
@@ -566,7 +984,7 @@ async function openSession(opts) {
566
984
  process.exit(1);
567
985
  }
568
986
  const config = loadConfig();
569
- const adapter = requireWaveAdapter();
987
+ const adapter = requireAdapter();
570
988
  let focusWindowId;
571
989
  if (workspaceQuery) {
572
990
  const { workspaces } = await adapter.getAllData();
@@ -1207,7 +1625,7 @@ const resumeCommand = define({
1207
1625
  process.exit(1);
1208
1626
  }
1209
1627
  }
1210
- const adapter = requireWaveAdapter();
1628
+ const adapter = requireAdapter();
1211
1629
  const { tabsById, tabNames } = await adapter.getAllData();
1212
1630
  const matchingTabs = adapter.resolveTab(name, tabsById, tabNames);
1213
1631
  if (matchingTabs.length > 1) {
@@ -1329,7 +1747,7 @@ const forkCommand = define({
1329
1747
  consola.error("Source tab name is required");
1330
1748
  process.exit(1);
1331
1749
  }
1332
- const adapter = requireWaveAdapter();
1750
+ const adapter = requireAdapter();
1333
1751
  const { tabsById, tabNames } = await adapter.getAllData();
1334
1752
  const matches = adapter.resolveTab(sourceQuery, tabsById, tabNames);
1335
1753
  if (!matches.length) {
@@ -1380,7 +1798,7 @@ const closeCommand = define({
1380
1798
  consola.error("Tab name or ID is required");
1381
1799
  process.exit(1);
1382
1800
  }
1383
- const adapter = requireWaveAdapter();
1801
+ const adapter = requireAdapter();
1384
1802
  const { tabsById, tabNames } = await adapter.getAllData();
1385
1803
  const matches = adapter.resolveTab(query, tabsById, tabNames);
1386
1804
  if (!matches.length) {
@@ -1421,7 +1839,7 @@ const renameCommand = define({
1421
1839
  consola.error("Usage: cctabs rename <tab> <new-name>");
1422
1840
  process.exit(1);
1423
1841
  }
1424
- const adapter = requireWaveAdapter();
1842
+ const adapter = requireAdapter();
1425
1843
  const { tabsById, tabNames } = await adapter.getAllData();
1426
1844
  const matches = adapter.resolveTab(query, tabsById, tabNames);
1427
1845
  if (!matches.length) {
@@ -1462,7 +1880,7 @@ const scrollbackCommand = define({
1462
1880
  consola.error("Tab name or block ID is required");
1463
1881
  process.exit(1);
1464
1882
  }
1465
- const adapter = requireWaveAdapter();
1883
+ const adapter = requireAdapter();
1466
1884
  const { tabsById, tabNames } = await adapter.getAllData();
1467
1885
  const tabMatches = adapter.resolveTab(query, tabsById, tabNames);
1468
1886
  let blockId;
@@ -1536,7 +1954,7 @@ const sendCommand = define({
1536
1954
  else if (filePath) rawText = readFileSync(filePath, "utf-8").replace(/\n/g, "\r");
1537
1955
  else rawText = (await readStdin()).replace(/\n/g, "\r");
1538
1956
  if (appendEnter && !rawText.endsWith("\r")) rawText += "\r";
1539
- const adapter = requireWaveAdapter();
1957
+ const adapter = requireAdapter();
1540
1958
  const { tabsById, tabNames } = await adapter.getAllData();
1541
1959
  const tabMatches = adapter.resolveTab(query, tabsById, tabNames);
1542
1960
  let blockId;
@@ -1604,9 +2022,9 @@ const restoreCommand = define({
1604
2022
  const rawDir = ctx.positionals[1];
1605
2023
  const scopedDir = rawDir ? resolve(rawDir.replace(/^~/, homedir())) : null;
1606
2024
  const dryRun = ctx.values.dry;
1607
- const adapter = requireWaveAdapter();
2025
+ const adapter = requireAdapter();
1608
2026
  const { tabsById, workspaces, tabNames } = await adapter.getAllData();
1609
- const currentTab = process.env.WAVETERM_TABID ?? "";
2027
+ const currentTab = adapter.currentTabId();
1610
2028
  const tabs = [];
1611
2029
  for (const wsp of workspaces) for (const tabId of wsp.workspacedata.tabids) {
1612
2030
  if (tabId === currentTab) continue;
@@ -1754,6 +2172,341 @@ const backendsCommand = define({
1754
2172
  }
1755
2173
  });
1756
2174
  //#endregion
2175
+ //#region src/commands/doctor.ts
2176
+ const STATUS_GLYPH = {
2177
+ ok: "✔",
2178
+ warn: "⚠",
2179
+ fail: "✘",
2180
+ skip: "–"
2181
+ };
2182
+ function printResult(r) {
2183
+ const line = ` ${STATUS_GLYPH[r.status]} ${r.name}${r.detail ? " — " + r.detail : ""}`;
2184
+ console.log(line);
2185
+ if (r.hint) console.log(` ↳ ${r.hint}`);
2186
+ }
2187
+ function checkTerminal(terminal) {
2188
+ if (terminal === "wave" || terminal === "tabby") return {
2189
+ name: "Terminal",
2190
+ status: "ok",
2191
+ detail: terminal
2192
+ };
2193
+ return {
2194
+ name: "Terminal",
2195
+ status: "fail",
2196
+ detail: terminal === "unknown" ? "unrecognised" : terminal,
2197
+ hint: "cctabs supports Wave Terminal and Tabby. Switch to one of those."
2198
+ };
2199
+ }
2200
+ function checkWaveAccessibility() {
2201
+ const r = spawnSync("osascript", ["-e", "tell application \"System Events\" to count processes"], {
2202
+ encoding: "utf-8",
2203
+ timeout: 2e3
2204
+ });
2205
+ const stderr = (r.stderr ?? "").trim();
2206
+ if (r.status === 0) return {
2207
+ name: "Wave Accessibility permission",
2208
+ status: "ok"
2209
+ };
2210
+ if (stderr.includes("not allowed") || stderr.includes("1002") || stderr.includes("-1719")) return {
2211
+ name: "Wave Accessibility permission",
2212
+ status: "fail",
2213
+ detail: "osascript denied",
2214
+ hint: "System Settings → Privacy & Security → Accessibility → enable Wave Terminal"
2215
+ };
2216
+ return {
2217
+ name: "Wave Accessibility permission",
2218
+ status: "warn",
2219
+ detail: stderr || `osascript exit ${r.status}`,
2220
+ hint: "Could not verify automatically. If `cctabs new` errors, check Privacy & Security → Accessibility."
2221
+ };
2222
+ }
2223
+ function probeTabbyPlugin(host, port) {
2224
+ const r = spawnSync("curl", [
2225
+ "-fsS",
2226
+ "--max-time",
2227
+ "3",
2228
+ `http://${host}:${port}/api/health`
2229
+ ], { encoding: "utf-8" });
2230
+ if (r.status !== 0 || !r.stdout) return {
2231
+ ok: false,
2232
+ error: (r.stderr || "").trim() || `exit ${r.status}`
2233
+ };
2234
+ try {
2235
+ const parsed = JSON.parse(r.stdout);
2236
+ return {
2237
+ ok: !!parsed.ok,
2238
+ version: parsed.version,
2239
+ raw: r.stdout
2240
+ };
2241
+ } catch {
2242
+ return {
2243
+ ok: false,
2244
+ error: "non-JSON response",
2245
+ raw: r.stdout
2246
+ };
2247
+ }
2248
+ }
2249
+ function checkTabbyPlugin() {
2250
+ const host = process.env.CCTABS_TABBY_HOST ?? "127.0.0.1";
2251
+ const port = Number(process.env.CCTABS_TABBY_PORT ?? "3300");
2252
+ const health = probeTabbyPlugin(host, port);
2253
+ if (health.ok) return {
2254
+ name: "Tabby cctabs plugin",
2255
+ status: "ok",
2256
+ detail: `${host}:${port}, version ${health.version ?? "unknown"}`
2257
+ };
2258
+ return {
2259
+ name: "Tabby cctabs plugin",
2260
+ status: "fail",
2261
+ detail: `${host}:${port} unreachable (${health.error ?? "unknown"})`,
2262
+ hint: "Run `cctabs install-tabby-plugin` from inside a Tabby tab — it npm-installs the plugin and reopens Tabby. Or do it by hand: `npm install --legacy-peer-deps --prefix \"$HOME/Library/Application Support/tabby/plugins\" tabby-cctabs`, then quit + reopen Tabby."
2263
+ };
2264
+ }
2265
+ function checkWaveDb() {
2266
+ if (!existsSync(WAVE_DB_PATH)) return { result: {
2267
+ name: "Wave DB",
2268
+ status: "skip",
2269
+ detail: "not found — Wave Terminal not installed?"
2270
+ } };
2271
+ let reports;
2272
+ try {
2273
+ reports = findOrphanTabIds();
2274
+ } catch (err) {
2275
+ return { result: {
2276
+ name: "Wave DB orphan-tabid scan",
2277
+ status: "fail",
2278
+ detail: err.message,
2279
+ hint: "sqlite3 CLI must be on PATH (ships with macOS by default)."
2280
+ } };
2281
+ }
2282
+ if (!reports.length) return { result: {
2283
+ name: "Wave DB orphan-tabid scan",
2284
+ status: "ok",
2285
+ detail: "no orphans"
2286
+ } };
2287
+ return {
2288
+ result: {
2289
+ name: "Wave DB orphan-tabid scan",
2290
+ status: "warn",
2291
+ detail: `${reports.length} workspace(s) affected`,
2292
+ hint: "Run `cctabs doctor --fix` (after quitting Wave) to clean up."
2293
+ },
2294
+ reports
2295
+ };
2296
+ }
2297
+ async function fixWaveDb(reports, yes) {
2298
+ console.log("");
2299
+ consola.warn(`${reports.length} workspace(s) with orphan tabids:`);
2300
+ for (const r of reports) {
2301
+ console.log(` • "${r.workspaceName}" [${r.workspaceId.slice(0, 8)}]`);
2302
+ console.log(` present: ${r.presentTabIds.length} tab(s)`);
2303
+ console.log(` orphan : ${r.orphanTabIds.length} tabid(s) → ${r.orphanTabIds.map((t) => t.slice(0, 8)).join(", ")}`);
2304
+ }
2305
+ console.log("");
2306
+ console.log("These tabids point at rows that no longer exist in db_tab. Wave's");
2307
+ console.log("BlocksList RPC aborts on the first missing tab, so `wsh blocks list`");
2308
+ console.log("currently fails with: \"couldn't list blocks for workspace …: not found\".");
2309
+ console.log("");
2310
+ console.log("Fix steps:");
2311
+ console.log(" 1. Copy the Wave DB to a timestamped backup next to it.");
2312
+ console.log(" 2. For each affected workspace, rewrite `data.tabids` to drop the orphan IDs.");
2313
+ console.log(" 3. Leave Wave's db_tab untouched.");
2314
+ console.log("");
2315
+ console.log("IMPORTANT: Quit Wave Terminal first — otherwise Wave may overwrite the");
2316
+ console.log("fix on its next save. (Cmd+Q on the Wave app, then re-run this command.)");
2317
+ console.log("");
2318
+ let proceed = yes;
2319
+ if (!yes) {
2320
+ const ans = await p.confirm({
2321
+ message: "Apply the fix now?",
2322
+ initialValue: false
2323
+ });
2324
+ if (p.isCancel(ans)) {
2325
+ consola.info("Cancelled.");
2326
+ return;
2327
+ }
2328
+ proceed = Boolean(ans);
2329
+ }
2330
+ if (!proceed) {
2331
+ consola.info("Aborted. No changes made.");
2332
+ return;
2333
+ }
2334
+ const backup = backupWaveDb();
2335
+ consola.success(`Backup written: ${backup}`);
2336
+ for (const r of reports) try {
2337
+ removeOrphanTabIds(r);
2338
+ consola.success(`Cleaned "${r.workspaceName}" [${r.workspaceId.slice(0, 8)}] — removed ${r.orphanTabIds.length} orphan tabid(s)`);
2339
+ } catch (err) {
2340
+ consola.error(`Failed to clean ${r.workspaceId}: ${err.message}`);
2341
+ consola.info(`Restore from backup if anything looks wrong: cp ${JSON.stringify(backup)} ${JSON.stringify(WAVE_DB_PATH)}`);
2342
+ process.exit(1);
2343
+ }
2344
+ consola.success("All orphan tabids removed. Start Wave Terminal again and re-run `cctabs sessions`.");
2345
+ }
2346
+ const doctorCommand = define({
2347
+ name: "doctor",
2348
+ description: "Run environment checks (terminal detection, plugin reachability, Wave DB orphan scan, Wave Accessibility) and offer fixes for known problems.",
2349
+ args: {
2350
+ yes: {
2351
+ type: "boolean",
2352
+ short: "y",
2353
+ description: "Apply the fix without an interactive confirmation prompt"
2354
+ },
2355
+ fix: {
2356
+ type: "boolean",
2357
+ description: "Apply available fixes (currently: Wave DB orphan-tabids)"
2358
+ }
2359
+ },
2360
+ async run(ctx) {
2361
+ const yes = Boolean(ctx.values.yes);
2362
+ const fix = Boolean(ctx.values.fix) || yes;
2363
+ const terminal = detectTerminal();
2364
+ console.log("cctabs doctor — environment checks");
2365
+ console.log("─".repeat(40));
2366
+ const results = [];
2367
+ let waveDb = null;
2368
+ results.push(checkTerminal(terminal));
2369
+ if (terminal === "tabby") results.push(checkTabbyPlugin());
2370
+ if (terminal === "wave") {
2371
+ results.push(checkWaveAccessibility());
2372
+ waveDb = checkWaveDb();
2373
+ results.push(waveDb.result);
2374
+ } else if (existsSync(WAVE_DB_PATH)) {
2375
+ waveDb = checkWaveDb();
2376
+ results.push({
2377
+ ...waveDb.result,
2378
+ name: "Wave DB orphan-tabid scan (offline)"
2379
+ });
2380
+ }
2381
+ for (const r of results) printResult(r);
2382
+ const failed = results.some((r) => r.status === "fail");
2383
+ const orphans = waveDb?.reports ?? [];
2384
+ if (orphans.length && fix) await fixWaveDb(orphans, yes);
2385
+ else if (orphans.length) {
2386
+ console.log("");
2387
+ consola.info("Re-run with `cctabs doctor --fix` to clean up orphan tabids (a DB backup is made first).");
2388
+ }
2389
+ if (failed) process.exit(1);
2390
+ }
2391
+ });
2392
+ //#endregion
2393
+ //#region src/commands/install-tabby-plugin.ts
2394
+ function pluginsDir() {
2395
+ if (platform() === "darwin") return join(homedir(), "Library", "Application Support", "tabby", "plugins");
2396
+ if (platform() === "linux") return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "tabby", "plugins");
2397
+ if (platform() === "win32") return join(process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"), "tabby", "plugins");
2398
+ throw new Error(`unsupported platform: ${platform()}`);
2399
+ }
2400
+ function shellQuote(s) {
2401
+ return `'${s.replace(/'/g, `'\\''`)}'`;
2402
+ }
2403
+ const installTabbyPluginCommand = define({
2404
+ name: "install-tabby-plugin",
2405
+ description: "Install the tabby-cctabs plugin via npm, then quit + reopen Tabby in the background and resume the current claude session in a new tab. Must be run from inside Tabby.",
2406
+ args: {
2407
+ yes: {
2408
+ type: "boolean",
2409
+ short: "y",
2410
+ description: "Skip the \"this will restart Tabby\" confirmation"
2411
+ },
2412
+ "no-restart": {
2413
+ type: "boolean",
2414
+ description: "Install the plugin only; do not quit Tabby. You restart it yourself."
2415
+ }
2416
+ },
2417
+ async run(ctx) {
2418
+ const yes = Boolean(ctx.values.yes);
2419
+ const noRestart = Boolean(ctx.values["no-restart"]);
2420
+ if (detectTerminal() !== "tabby") {
2421
+ consola.error("cctabs install-tabby-plugin must be run from inside a Tabby tab.");
2422
+ consola.info("On Wave Terminal you don't need this — Wave works without a plugin.");
2423
+ process.exit(1);
2424
+ }
2425
+ if (platform() !== "darwin" && platform() !== "linux") {
2426
+ consola.error(`Auto-restart isn't implemented for ${platform()} yet. Run the manual install snippet from \`cctabs doctor\` and restart Tabby yourself.`);
2427
+ process.exit(1);
2428
+ }
2429
+ const dir = pluginsDir();
2430
+ consola.info(`Tabby plugins dir: ${dir}`);
2431
+ mkdirSync(dir, { recursive: true });
2432
+ const pkgPath = join(dir, "package.json");
2433
+ if (!existsSync(pkgPath)) writeFileSync(pkgPath, "{\"private\":true}\n");
2434
+ consola.info("Installing tabby-cctabs from npm…");
2435
+ const npm = spawnSync("npm", [
2436
+ "install",
2437
+ "--legacy-peer-deps",
2438
+ "--silent",
2439
+ "--prefix",
2440
+ dir,
2441
+ "tabby-cctabs"
2442
+ ], { stdio: "inherit" });
2443
+ if (npm.status !== 0) {
2444
+ consola.error("npm install failed. Bail out before touching Tabby.");
2445
+ process.exit(npm.status ?? 1);
2446
+ }
2447
+ consola.success("Plugin installed.");
2448
+ if (noRestart) {
2449
+ consola.info("Skipping restart (--no-restart). Quit and reopen Tabby manually, then run `cctabs doctor`.");
2450
+ return;
2451
+ }
2452
+ const cwd = process.cwd();
2453
+ const sessionId = findLatestSessionId(cwd);
2454
+ if (!sessionId) consola.warn(`No prior Claude session found for ${cwd}. The restart will reopen Tabby with a plain shell tab; you can launch claude yourself.`);
2455
+ else consola.info(`Will resume session ${sessionId.slice(0, 8)}… via --fork-session after restart.`);
2456
+ if (!yes) {
2457
+ consola.warn("About to quit Tabby and reopen it. ALL Tabby tabs will close.");
2458
+ consola.warn("Tabby's session recovery may or may not restore other tabs.");
2459
+ consola.info("Re-run with --yes to suppress this prompt.");
2460
+ if (!await consola.prompt("Proceed?", {
2461
+ type: "confirm",
2462
+ initial: false
2463
+ })) {
2464
+ consola.info("Aborted.");
2465
+ return;
2466
+ }
2467
+ }
2468
+ const claudeBin = (spawnSync("which", ["claude"], { encoding: "utf-8" }).stdout || "").trim() || "claude";
2469
+ const stamp = Date.now();
2470
+ const restartScript = join("/tmp", `cctabs-tabby-restart-${stamp}.sh`);
2471
+ const launcherScript = join("/tmp", `cctabs-tabby-launcher-${stamp}.sh`);
2472
+ writeFileSync(launcherScript, sessionId ? `#!/bin/zsh -l
2473
+ cd ${shellQuote(cwd)} || exit 1
2474
+ exec ${claudeBin} --resume ${sessionId} --fork-session
2475
+ ` : `#!/bin/zsh -l
2476
+ cd ${shellQuote(cwd)} || exit 1
2477
+ exec /bin/zsh -l
2478
+ `);
2479
+ chmodSync(launcherScript, 493);
2480
+ const isDarwin = platform() === "darwin";
2481
+ writeFileSync(restartScript, `#!/usr/bin/env bash
2482
+ set -e
2483
+ exec >/tmp/cctabs-tabby-restart-${stamp}.log 2>&1
2484
+ echo "[$(date)] sleeping 2s before quitting Tabby"
2485
+ sleep 2
2486
+ echo "[$(date)] quitting Tabby"
2487
+ ${isDarwin ? `osascript -e 'tell application "Tabby" to quit' >/dev/null 2>&1 || true` : `pkill -TERM -f Tabby || true`}
2488
+ echo "[$(date)] waiting for Tabby to exit"
2489
+ for i in \$(seq 1 30); do pgrep -f "Tabby" >/dev/null || break; sleep 0.5; done
2490
+ echo "[$(date)] reopening Tabby"
2491
+ ${isDarwin ? `open -a Tabby` : `nohup tabby >/dev/null 2>&1 &`}
2492
+ sleep 5
2493
+ echo "[$(date)] activating + launching resume tab"
2494
+ ${isDarwin ? `osascript -e 'tell application "Tabby" to activate' >/dev/null 2>&1 || true` : `:`}
2495
+ sleep 1
2496
+ ${isDarwin ? `/Applications/Tabby.app/Contents/MacOS/Tabby` : `tabby`} run ${shellQuote(launcherScript)}
2497
+ echo "[$(date)] done"
2498
+ `);
2499
+ chmodSync(restartScript, 493);
2500
+ spawn("/bin/bash", [restartScript], {
2501
+ detached: true,
2502
+ stdio: "ignore"
2503
+ }).unref();
2504
+ consola.success("Restart worker dispatched.");
2505
+ consola.info(`Logs: /tmp/cctabs-tabby-restart-${stamp}.log`);
2506
+ consola.info("Tabby will quit in ~2 seconds. After it reopens, your session resumes via --fork-session in a new tab.");
2507
+ }
2508
+ });
2509
+ //#endregion
1757
2510
  //#region src/commands/index.ts
1758
2511
  const defaultCommand = define({
1759
2512
  name: "cctabs",
@@ -1776,7 +2529,9 @@ const subCommands = new Map([
1776
2529
  ["send", sendCommand],
1777
2530
  ["config", configCommand],
1778
2531
  ["restore", restoreCommand],
1779
- ["backends", backendsCommand]
2532
+ ["backends", backendsCommand],
2533
+ ["doctor", doctorCommand],
2534
+ ["install-tabby-plugin", installTabbyPluginCommand]
1780
2535
  ]);
1781
2536
  async function run() {
1782
2537
  await cli(process.argv.slice(2), defaultCommand, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@generativereality/cctabs",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Claude Code tab manager. Terminal tabs as the UI, no tmux.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,6 +19,8 @@
19
19
  "check": "npm run typecheck && npm run build",
20
20
  "release": "bumpp && npm publish",
21
21
  "sync-plugin": "bash scripts/sync-plugin.sh",
22
+ "build:tabby-plugin": "cd tabby-plugin && npm install && npm run build",
23
+ "publish:tabby-plugin": "cd tabby-plugin && npm publish --access public",
22
24
  "prepack": "bash scripts/sync-plugin.sh --check && npm run build"
23
25
  },
24
26
  "keywords": [
@@ -5,7 +5,7 @@ description: Manage Claude Code sessions across terminal tabs (NOT browser tabs)
5
5
 
6
6
  You are managing Claude Code sessions using the `cctabs` CLI.
7
7
 
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.
8
+ **Important:** "tabs" here means **terminal tabs** (Wave Terminal or Tabby), 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
10
  ## Before you spawn anything: is cctabs the right tool?
11
11
 
@@ -37,6 +37,64 @@ npm install -g @generativereality/cctabs
37
37
 
38
38
  Do not modify PATH or npm configuration beyond this.
39
39
 
40
+ ### Tabby users: a one-time plugin install is needed
41
+
42
+ 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.
43
+
44
+ You don't need to detect this proactively — every cctabs command will fail with a self-documenting error if the plugin isn't running:
45
+
46
+ ```
47
+ cctabs Tabby plugin not reachable at http://127.0.0.1:3300.
48
+ reason: …
49
+ Install + restart Tabby in one shot from inside a Tabby tab:
50
+ cctabs install-tabby-plugin
51
+
52
+ ```
53
+
54
+ When you see that error, ask the user once:
55
+
56
+ > *"You're in Tabby and the cctabs plugin isn't installed. I can `cctabs install-tabby-plugin --yes` — that npm-installs the plugin AND restarts Tabby in the background, dropping you back into a forked session. Caveat: any other Tabby tabs you have open will be killed. OK?"*
57
+
58
+ On approval, run `cctabs install-tabby-plugin --yes`. Tabby quits ~2s after the command returns, reopens automatically, and spawns a new tab with your forked claude session. **Your current turn ends when Tabby quits**; the resumed claude in the new tab is where the user will continue.
59
+
60
+ 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.
61
+
62
+ `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.
63
+
64
+ #### Auto-install + auto-restart (recommended)
65
+
66
+ ```bash
67
+ cctabs install-tabby-plugin --yes
68
+ ```
69
+
70
+ What it does, in order:
71
+ 1. `npm install --legacy-peer-deps --prefix <tabby-plugins-dir> tabby-cctabs`
72
+ 2. Captures the current claude session id from `~/.claude/projects/<slug>/`
73
+ 3. Spawns a detached background worker that quits Tabby, waits for it to die, reopens it, then opens a new tab running `claude --resume <id> --fork-session` in your current cwd.
74
+
75
+ **Other Tabby tabs in the same window get killed.** Tabby's session recovery may or may not bring them back. Use `--no-restart` to skip step 3 if the user wants control.
76
+
77
+ #### Manual install (fallback)
78
+
79
+ ```bash
80
+ TABBY_PLUGINS="$HOME/Library/Application Support/tabby/plugins"
81
+ mkdir -p "$TABBY_PLUGINS"
82
+ [ -f "$TABBY_PLUGINS/package.json" ] || echo '{"private":true}' > "$TABBY_PLUGINS/package.json"
83
+ npm install --legacy-peer-deps --prefix "$TABBY_PLUGINS" tabby-cctabs
84
+ # then ask the user to quit + reopen Tabby
85
+ ```
86
+
87
+ `--legacy-peer-deps` is required: the plugin's peer deps (`tabby-core`, `@angular/*`, …) live inside Tabby itself, not on npm. Tabby's GUI plugin manager handles this internally.
88
+
89
+ Linux: replace `~/Library/Application Support/tabby` with `${XDG_CONFIG_HOME:-$HOME/.config}/tabby`.
90
+ Windows: `%APPDATA%\tabby`.
91
+
92
+ #### Alternative: install via Tabby's GUI
93
+
94
+ If the user prefers, point them at Tabby → **Settings → Plugins**, search "cctabs", click install, then quit + reopen Tabby. Same end state.
95
+
96
+ Do not assume "no Wave detected → cctabs unusable" — Tabby is fully supported.
97
+
40
98
  ---
41
99
 
42
100
  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.
@@ -317,17 +375,19 @@ cctabs new feature ~/Dev/myapp --worktree
317
375
 
318
376
  ## Handling `cctabs new` Timeout Errors
319
377
 
320
- `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.
378
+ `cctabs new` may occasionally fail with "Timed out waiting for new terminal block" (or, on Tabby, "Shell prompt never appeared in new tab"). This does **NOT** mean you have too many tabs or that the terminal has hit a limit.
321
379
 
322
- **Possible causes** (root cause not yet confirmed):
323
- - Wave Terminal may need to be in focus / foreground for tab creation to register
324
- - The internal timeout may be slightly too short for the current system load
325
- - Transient IPC timing issue between cctabs and Wave
380
+ **Possible causes:**
381
+ - The terminal app may need to be in focus / foreground for tab creation to register (true for both Wave and Tabby).
382
+ - The internal timeout may be slightly too short for the current system load.
383
+ - Transient IPC timing issue between cctabs and the terminal.
384
+ - **Tabby only:** the cctabs plugin must be installed and running (`curl http://127.0.0.1:3300/api/health` to verify).
326
385
 
327
386
  **What to do:**
328
387
  1. **Retry the same command** — it often works on the second attempt
329
388
  2. If it fails again, wait a few seconds and retry once more
330
- 3. If it keeps failing, ask the user to bring Wave Terminal to the foreground and try again
389
+ 3. If it keeps failing, ask the user to bring the terminal app to the foreground and try again
390
+ 4. On Tabby, also confirm the plugin is reachable (see health check above)
331
391
 
332
392
  **What NOT to do:**
333
393
  - ❌ Do NOT assume there is a "tab limit" — there isn't one