@generativereality/cctabs 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.1";
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,27 +509,609 @@ 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, 200);
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
+ const compact = tail.replace(/\s+/g, "");
649
+ if ([
650
+ "Claude Code",
651
+ "claude.ai/code",
652
+ "⏵⏵ bypass",
653
+ "⏵⏵ auto",
654
+ "Thinking",
655
+ "Hatching",
656
+ "Composing",
657
+ "Cogitating",
658
+ "Befuddling",
659
+ "Worked for",
660
+ "Baked for",
661
+ "Churned for",
662
+ "Cooked for",
663
+ "high effort"
664
+ ].some((m) => compact.includes(m.replace(/\s+/g, "")))) return "active";
665
+ if (/[✻✽✶✳✢]/.test(tail)) return "active";
666
+ if (lastLine.toLowerCase().includes("claude")) return "idle";
667
+ return "terminal";
668
+ }
669
+ deleteBlock(blockId) {
670
+ spawnSync("curl", [
671
+ "-fsS",
672
+ "-X",
673
+ "POST",
674
+ "--max-time",
675
+ "5",
676
+ this.url(`/api/tabs/${blockId}/close`)
677
+ ], { encoding: "utf-8" });
678
+ }
679
+ async newTab(_focusWindowId) {
680
+ const r = await this.http("POST", "/api/tabs/new", {});
681
+ return Boolean(r);
682
+ }
683
+ async waitForNewBlock(beforeIds, timeoutMs = 15e3) {
684
+ const deadline = Date.now() + timeoutMs;
685
+ while (Date.now() < deadline) {
686
+ await sleep(250);
687
+ for (const b of this.blocksList()) if (!beforeIds.has(b.blockid)) return {
688
+ blockId: b.blockid,
689
+ tabId: b.tabid
690
+ };
691
+ }
692
+ return null;
693
+ }
694
+ async renameTab(tabId, name) {
695
+ await this.http("PUT", `/api/tabs/${tabId}/title`, { title: name });
696
+ }
697
+ async sendInput(blockId, text) {
698
+ return this.http("POST", `/api/tabs/${blockId}/send`, { data: text });
699
+ }
700
+ async getAllData() {
701
+ const blocks = this.blocksList();
702
+ const tabsById = /* @__PURE__ */ new Map();
703
+ for (const b of blocks) {
704
+ const arr = tabsById.get(b.tabid) ?? [];
705
+ arr.push(b);
706
+ tabsById.set(b.tabid, arr);
707
+ }
708
+ const tabNames = /* @__PURE__ */ new Map();
709
+ try {
710
+ const res = await fetch(this.url("/api/tabs"));
711
+ if (res.ok) {
712
+ const data = await res.json();
713
+ for (const t of data.tabs) tabNames.set(t.uuid, t.customTitle || t.title || t.uuid.slice(0, 8));
714
+ }
715
+ } catch {}
716
+ return {
717
+ blocks,
718
+ tabsById,
719
+ workspaces: [{
720
+ workspacedata: {
721
+ oid: "tabby",
722
+ name: "tabby",
723
+ tabids: [...tabsById.keys()]
724
+ },
725
+ windowid: ""
726
+ }],
727
+ tabNames
728
+ };
729
+ }
730
+ resolveTab(query, tabsById, tabNames) {
731
+ const q = query.toLowerCase();
732
+ if (query === "." || query === "self") {
733
+ const self = this.identifySelf();
734
+ return self ? [self] : [];
735
+ }
736
+ const ids = [...tabsById.keys()];
737
+ const exact = ids.filter((tid) => (tabNames.get(tid) ?? "").toLowerCase() === q);
738
+ if (exact.length > 0) return exact;
739
+ return ids.filter((tid) => {
740
+ const name = tabNames.get(tid) ?? "";
741
+ return tid.startsWith(query) || name.toLowerCase().startsWith(q);
742
+ });
743
+ }
744
+ resolveBlock(query, blocks) {
745
+ return blocks.filter((b) => b.blockid.startsWith(query));
746
+ }
747
+ resolveWorkspace(workspaces, _query) {
748
+ return workspaces.map((w) => ({
749
+ data: w.workspacedata,
750
+ windowId: w.windowid
751
+ }));
752
+ }
753
+ currentTabId() {
754
+ return this.identifySelf() ?? "";
755
+ }
756
+ currentBlockId() {
757
+ return this.currentTabId();
758
+ }
759
+ currentWorkspaceId() {
760
+ return "tabby";
761
+ }
762
+ /**
763
+ * Walk the process tree from `process.pid` upwards collecting PIDs, then
764
+ * ask the plugin which tab owns any of them. Result is cached for the
765
+ * lifetime of this adapter.
766
+ */
767
+ identifySelf() {
768
+ if (this.cachedSelfUuid !== null) return this.cachedSelfUuid;
769
+ const pids = walkAncestorPids(process.pid);
770
+ const out = spawnSync("curl", [
771
+ "-fsS",
772
+ "-X",
773
+ "POST",
774
+ "-H",
775
+ "content-type: application/json",
776
+ "--max-time",
777
+ "5",
778
+ "--data",
779
+ JSON.stringify({ pids }),
780
+ this.url("/api/tabs/identify")
781
+ ], { encoding: "utf-8" });
782
+ if (out.status !== 0 || !out.stdout) {
783
+ this.cachedSelfUuid = "";
784
+ return null;
785
+ }
786
+ try {
787
+ const parsed = JSON.parse(out.stdout);
788
+ this.cachedSelfUuid = parsed.uuid ?? "";
789
+ return parsed.uuid ?? null;
790
+ } catch {
791
+ this.cachedSelfUuid = "";
792
+ return null;
793
+ }
794
+ }
795
+ };
401
796
  function sleep(ms) {
402
797
  return new Promise((r) => setTimeout(r, ms));
403
798
  }
799
+ /**
800
+ * Walk `pid → ppid → ...` via `ps`. Caps at 32 levels to avoid pathological
801
+ * loops on misconfigured systems. Returns [pid, ppid, gppid, ...].
802
+ */
803
+ function walkAncestorPids(startPid, cap = 32) {
804
+ const out = [startPid];
805
+ let cur = startPid;
806
+ for (let i = 0; i < cap; i++) {
807
+ const r = spawnSync("ps", [
808
+ "-o",
809
+ "ppid=",
810
+ "-p",
811
+ String(cur)
812
+ ], { encoding: "utf-8" });
813
+ if (r.status !== 0) break;
814
+ const next = parseInt(r.stdout.trim(), 10);
815
+ if (!Number.isFinite(next) || next <= 1 || next === cur) break;
816
+ out.push(next);
817
+ cur = next;
818
+ }
819
+ return out;
820
+ }
821
+ //#endregion
822
+ //#region src/core/adapter.ts
823
+ /** Returns the adapter that matches the running terminal. Exits with a clear
824
+ * error message if none is supported. */
825
+ function requireAdapter() {
826
+ const terminal = detectTerminal();
827
+ if (terminal === "wave") return new WaveAdapter();
828
+ if (terminal === "tabby") return new TabbyAdapter();
829
+ printUnsupportedTerminalError(terminal);
830
+ process.exit(1);
831
+ }
832
+ //#endregion
833
+ //#region src/core/session.ts
834
+ /** Convert an absolute path to Claude Code's project slug.
835
+ * Claude Code replaces any non-alphanumeric character (spaces, /, ., etc.) with '-'.
836
+ * Hyphens are preserved. Example: "/Users/me/Remember This" → "-Users-me-Remember-This". */
837
+ function pathToProjectSlug(dir) {
838
+ return resolve(dir).replace(/[^A-Za-z0-9-]/g, "-");
839
+ }
840
+ /** Find the most recent .jsonl session file in a Claude project directory */
841
+ function latestJsonlIn(projectDir) {
842
+ if (!existsSync(projectDir)) return null;
843
+ const files = readdirSync(projectDir).filter((f) => extname(f) === ".jsonl").map((f) => ({
844
+ name: f,
845
+ mtime: statSync(join(projectDir, f)).mtimeMs
846
+ })).sort((a, b) => b.mtime - a.mtime);
847
+ return files.length ? basename(files[0].name, ".jsonl") : null;
848
+ }
849
+ /**
850
+ * Find the most recent Claude Code session ID for a directory.
851
+ * Also checks worktree subdirectories (.claude/worktrees/*) since tabs
852
+ * opened with --worktree run from a worktree path, not the repo root.
853
+ */
854
+ function findLatestSessionId(dir) {
855
+ const projectsRoot = join(homedir(), ".claude", "projects");
856
+ const direct = latestJsonlIn(join(projectsRoot, pathToProjectSlug(dir)));
857
+ if (direct) return direct;
858
+ const worktreesDir = join(dir, ".claude", "worktrees");
859
+ if (existsSync(worktreesDir)) {
860
+ const candidates = [];
861
+ for (const entry of readdirSync(worktreesDir)) {
862
+ const projectDir = join(projectsRoot, pathToProjectSlug(join(worktreesDir, entry)));
863
+ const id = latestJsonlIn(projectDir);
864
+ if (id) {
865
+ const mtime = statSync(join(projectDir, id + ".jsonl")).mtimeMs;
866
+ candidates.push({
867
+ id,
868
+ mtime
869
+ });
870
+ }
871
+ }
872
+ if (candidates.length) {
873
+ candidates.sort((a, b) => b.mtime - a.mtime);
874
+ return candidates[0].id;
875
+ }
876
+ }
877
+ return null;
878
+ }
879
+ /**
880
+ * Find all sessions with a given custom title (--name).
881
+ * Returns them sorted by most recent first, with the first user prompt for context.
882
+ */
883
+ function findSessionsByName(dir, name) {
884
+ const projectDir = join(join(homedir(), ".claude", "projects"), pathToProjectSlug(dir));
885
+ if (!existsSync(projectDir)) return [];
886
+ const matches = [];
887
+ const files = readdirSync(projectDir).filter((f) => extname(f) === ".jsonl");
888
+ for (const f of files) {
889
+ const fullPath = join(projectDir, f);
890
+ try {
891
+ const lines = readFileSync(fullPath, "utf-8").split("\n");
892
+ let currentTitle = "";
893
+ let firstPrompt = "";
894
+ let lastActivity = "";
895
+ for (const line of lines) {
896
+ if (!line.trim()) continue;
897
+ try {
898
+ const entry = JSON.parse(line);
899
+ if (entry.customTitle !== void 0) currentTitle = entry.customTitle;
900
+ if (!firstPrompt && entry.type === "user" && entry.message?.content) {
901
+ const text = typeof entry.message.content === "string" ? entry.message.content : entry.message.content.find((c) => c.type === "text")?.text ?? "";
902
+ if (text.startsWith("<")) continue;
903
+ firstPrompt = text.slice(0, 120).replace(/\n/g, " ").trim();
904
+ if (text.length > 120) firstPrompt += "…";
905
+ }
906
+ if (entry.message?.role === "assistant" && entry.message?.content) {
907
+ const parts = Array.isArray(entry.message.content) ? entry.message.content : [{
908
+ type: "text",
909
+ text: entry.message.content
910
+ }];
911
+ for (const p of parts) if (p.type === "text" && p.text?.trim()) {
912
+ lastActivity = p.text.slice(0, 120).replace(/\n/g, " ").trim();
913
+ if (p.text.length > 120) lastActivity += "…";
914
+ }
915
+ }
916
+ } catch {}
917
+ }
918
+ if (currentTitle !== name) continue;
919
+ const stat = statSync(fullPath);
920
+ matches.push({
921
+ id: basename(f, ".jsonl"),
922
+ mtime: stat.mtimeMs,
923
+ size: stat.size,
924
+ firstPrompt,
925
+ lastActivity
926
+ });
927
+ } catch {}
928
+ }
929
+ return matches.sort((a, b) => b.mtime - a.mtime);
930
+ }
931
+ /**
932
+ * Like findSessionsByName, but searches every project directory under
933
+ * ~/.claude/projects. Each match carries the cwd recorded in the session.
934
+ * Used by `cctabs restore` so callers don't have to guess the right dir.
935
+ */
936
+ function findSessionsByNameGlobally(name) {
937
+ const projectsRoot = join(homedir(), ".claude", "projects");
938
+ if (!existsSync(projectsRoot)) return [];
939
+ const matches = [];
940
+ for (const slug of readdirSync(projectsRoot)) {
941
+ const projectDir = join(projectsRoot, slug);
942
+ let isDir = false;
943
+ try {
944
+ isDir = statSync(projectDir).isDirectory();
945
+ } catch {
946
+ continue;
947
+ }
948
+ if (!isDir) continue;
949
+ const files = readdirSync(projectDir).filter((f) => extname(f) === ".jsonl");
950
+ for (const f of files) {
951
+ const fullPath = join(projectDir, f);
952
+ try {
953
+ const lines = readFileSync(fullPath, "utf-8").split("\n");
954
+ let currentTitle = "";
955
+ let cwd = "";
956
+ let firstPrompt = "";
957
+ let lastActivity = "";
958
+ for (const line of lines) {
959
+ if (!line.trim()) continue;
960
+ try {
961
+ const entry = JSON.parse(line);
962
+ if (entry.customTitle !== void 0) currentTitle = entry.customTitle;
963
+ if (!cwd && typeof entry.cwd === "string") cwd = entry.cwd;
964
+ if (!firstPrompt && entry.type === "user" && entry.message?.content) {
965
+ const text = typeof entry.message.content === "string" ? entry.message.content : entry.message.content.find((c) => c.type === "text")?.text ?? "";
966
+ if (text.startsWith("<")) continue;
967
+ firstPrompt = text.slice(0, 120).replace(/\n/g, " ").trim();
968
+ if (text.length > 120) firstPrompt += "…";
969
+ }
970
+ if (entry.message?.role === "assistant" && entry.message?.content) {
971
+ const parts = Array.isArray(entry.message.content) ? entry.message.content : [{
972
+ type: "text",
973
+ text: entry.message.content
974
+ }];
975
+ for (const p of parts) if (p.type === "text" && p.text?.trim()) {
976
+ lastActivity = p.text.slice(0, 120).replace(/\n/g, " ").trim();
977
+ if (p.text.length > 120) lastActivity += "…";
978
+ }
979
+ }
980
+ } catch {}
981
+ }
982
+ if (currentTitle !== name || !cwd) continue;
983
+ const stat = statSync(fullPath);
984
+ matches.push({
985
+ id: basename(f, ".jsonl"),
986
+ mtime: stat.mtimeMs,
987
+ size: stat.size,
988
+ firstPrompt,
989
+ lastActivity,
990
+ dir: cwd
991
+ });
992
+ } catch {}
993
+ }
994
+ }
995
+ return matches.sort((a, b) => b.mtime - a.mtime);
996
+ }
997
+ /**
998
+ * List all unique session names (customTitle) in a project directory.
999
+ * Used to show available names when a resume lookup fails.
1000
+ */
1001
+ function listSessionNames(dir) {
1002
+ const projectDir = join(join(homedir(), ".claude", "projects"), pathToProjectSlug(dir));
1003
+ if (!existsSync(projectDir)) return [];
1004
+ const results = [];
1005
+ const seen = /* @__PURE__ */ new Set();
1006
+ const files = readdirSync(projectDir).filter((f) => extname(f) === ".jsonl");
1007
+ for (const f of files) {
1008
+ const fullPath = join(projectDir, f);
1009
+ try {
1010
+ const firstLine = readFileSync(fullPath, "utf-8").split("\n")[0];
1011
+ if (!firstLine) continue;
1012
+ const title = JSON.parse(firstLine).customTitle;
1013
+ if (!title || seen.has(title)) continue;
1014
+ seen.add(title);
1015
+ const stat = statSync(fullPath);
1016
+ results.push({
1017
+ name: title,
1018
+ id: basename(f, ".jsonl"),
1019
+ mtime: stat.mtimeMs
1020
+ });
1021
+ } catch {}
1022
+ }
1023
+ return results.sort((a, b) => b.mtime - a.mtime);
1024
+ }
1025
+ /**
1026
+ * Resolve a session ID prefix (e.g. "19aae7b4") to the full UUID by scanning
1027
+ * `~/.claude/projects/`. Returns the input unchanged if it already looks like
1028
+ * a full UUID, or null if no unique match exists. Pass `dir` to scope the
1029
+ * search to one project; otherwise every project is checked.
1030
+ *
1031
+ * `claude --resume <prefix>` does NOT accept truncated IDs — it treats them
1032
+ * as a search query and shows the picker. So callers must expand prefixes
1033
+ * before forwarding to claude.
1034
+ */
1035
+ function expandSessionId(input, dir) {
1036
+ if (!input) return null;
1037
+ if (/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(input)) return input;
1038
+ const projectsRoot = join(homedir(), ".claude", "projects");
1039
+ if (!existsSync(projectsRoot)) return null;
1040
+ const projectDirs = dir ? [join(projectsRoot, pathToProjectSlug(dir))] : readdirSync(projectsRoot).map((d) => join(projectsRoot, d)).filter((p) => {
1041
+ try {
1042
+ return statSync(p).isDirectory();
1043
+ } catch {
1044
+ return false;
1045
+ }
1046
+ });
1047
+ const matches = [];
1048
+ for (const pd of projectDirs) {
1049
+ if (!existsSync(pd)) continue;
1050
+ for (const f of readdirSync(pd)) {
1051
+ if (extname(f) !== ".jsonl") continue;
1052
+ const id = basename(f, ".jsonl");
1053
+ if (id.startsWith(input) && !matches.includes(id)) matches.push(id);
1054
+ }
1055
+ }
1056
+ return matches.length === 1 ? matches[0] : null;
1057
+ }
404
1058
  //#endregion
405
1059
  //#region src/commands/sessions.ts
406
1060
  const sessionsCommand = define({
407
1061
  name: "sessions",
408
1062
  description: "List tabs with active/idle session status",
409
- args: {},
410
- async run() {
411
- const adapter = requireWaveAdapter();
1063
+ args: { json: {
1064
+ type: "boolean",
1065
+ short: "j",
1066
+ description: "Emit machine-readable JSON. Output can be piped to `cctabs restore --manifest -` on another machine."
1067
+ } },
1068
+ async run(ctx) {
1069
+ const adapter = requireAdapter();
412
1070
  const { tabsById, workspaces, tabNames } = await adapter.getAllData();
413
- const currentTab = process.env.WAVETERM_TABID ?? "";
414
- const currentWs = process.env.WAVETERM_WORKSPACEID ?? "";
1071
+ const currentTab = adapter.currentTabId();
1072
+ const currentWs = adapter.currentWorkspaceId();
1073
+ if (ctx.values.json ?? false) {
1074
+ const out = { workspaces: [] };
1075
+ for (const wsp of workspaces) {
1076
+ const { oid, name: wsName, tabids } = wsp.workspacedata;
1077
+ const tabIds = tabids.filter((t) => tabsById.has(t));
1078
+ if (!tabIds.length) continue;
1079
+ const wsRow = {
1080
+ id: oid,
1081
+ name: wsName,
1082
+ current: oid === currentWs,
1083
+ sessions: []
1084
+ };
1085
+ for (const tabId of tabIds) {
1086
+ const termBlocks = (tabsById.get(tabId) ?? []).filter((b) => b.view === "term");
1087
+ if (!termBlocks.length) continue;
1088
+ const tabName = tabNames.get(tabId) ?? tabId.slice(0, 8);
1089
+ const b = termBlocks[0];
1090
+ const cwd = b.meta?.["cmd:cwd"] ?? "";
1091
+ const status = adapter.detectSessionStatus(b.blockid);
1092
+ const lastLine = adapter.scrollback(b.blockid, 5).split("\n").map((l) => l.trim()).filter(Boolean).at(-1) ?? "";
1093
+ let sessionId = null;
1094
+ if (cwd) try {
1095
+ const matches = findSessionsByName(cwd, tabName);
1096
+ if (matches.length) sessionId = matches[0].id;
1097
+ } catch {}
1098
+ wsRow.sessions.push({
1099
+ block_id: b.blockid,
1100
+ tab_id: tabId,
1101
+ name: tabName,
1102
+ cwd,
1103
+ current: tabId === currentTab,
1104
+ status,
1105
+ last_line: lastLine.slice(0, 200),
1106
+ session_id: sessionId
1107
+ });
1108
+ }
1109
+ out.workspaces.push(wsRow);
1110
+ }
1111
+ adapter.closeSocket?.();
1112
+ console.log(JSON.stringify(out, null, 2));
1113
+ return;
1114
+ }
415
1115
  console.log("Sessions");
416
1116
  console.log("=".repeat(50));
417
1117
  for (const wsp of workspaces) {
@@ -446,10 +1146,11 @@ const listCommand = define({
446
1146
  description: "List all workspaces, tabs, and blocks",
447
1147
  args: {},
448
1148
  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 ?? "";
1149
+ const adapter = requireAdapter();
1150
+ const { tabsById, workspaces, tabNames } = await adapter.getAllData();
1151
+ const currentBlock = adapter.currentBlockId();
1152
+ const currentTab = adapter.currentTabId();
1153
+ const currentWs = adapter.currentWorkspaceId();
453
1154
  for (const wsp of workspaces) {
454
1155
  const { oid, name, tabids } = wsp.workspacedata;
455
1156
  const noWindow = !wsp.windowid ? " (no window)" : "";
@@ -566,7 +1267,7 @@ async function openSession(opts) {
566
1267
  process.exit(1);
567
1268
  }
568
1269
  const config = loadConfig();
569
- const adapter = requireWaveAdapter();
1270
+ const adapter = requireAdapter();
570
1271
  let focusWindowId;
571
1272
  if (workspaceQuery) {
572
1273
  const { workspaces } = await adapter.getAllData();
@@ -828,6 +1529,11 @@ const newCommand = define({
828
1529
  short: "p",
829
1530
  description: "Send initial prompt text once Claude is ready"
830
1531
  },
1532
+ resume: {
1533
+ type: "string",
1534
+ short: "r",
1535
+ description: "Resume an existing Claude session ID (passes --resume <id> to claude). Mutually exclusive with --prompt/--file."
1536
+ },
831
1537
  backend: {
832
1538
  type: "string",
833
1539
  short: "b",
@@ -846,12 +1552,31 @@ const newCommand = define({
846
1552
  const useWorktree = ctx.values.worktree ?? false;
847
1553
  const promptFile = ctx.values.file;
848
1554
  const promptText = ctx.values.prompt;
1555
+ const resumeId = ctx.values.resume;
849
1556
  const backendName = ctx.values.backend;
850
1557
  const modelOverride = ctx.values.model;
851
1558
  if (!name) {
852
1559
  consola.error("Tab name is required");
853
1560
  process.exit(1);
854
1561
  }
1562
+ if (resumeId && (promptText || promptFile)) {
1563
+ consola.error("--resume cannot be combined with --prompt or --file (you cannot send an initial prompt to a resumed session via this path).");
1564
+ process.exit(1);
1565
+ }
1566
+ let resolvedSessionId;
1567
+ if (resumeId) {
1568
+ const absDir = resolve(dir.replace(/^~/, homedir()));
1569
+ const expanded = expandSessionId(resumeId, absDir) ?? expandSessionId(resumeId);
1570
+ if (expanded) resolvedSessionId = expanded;
1571
+ else {
1572
+ const slug = pathToProjectSlug(absDir);
1573
+ if (existsSync(join(homedir(), ".claude", "projects", slug, `${resumeId}.jsonl`))) resolvedSessionId = resumeId;
1574
+ else {
1575
+ consola.warn(`Session ID "${resumeId}" not found in ~/.claude/projects/ — proceeding anyway (claude will error if invalid).`);
1576
+ resolvedSessionId = resumeId;
1577
+ }
1578
+ }
1579
+ }
855
1580
  let envVars;
856
1581
  let resolvedModel = modelOverride;
857
1582
  if (backendName) {
@@ -860,255 +1585,35 @@ const newCommand = define({
860
1585
  consola.error(`Unknown backend "${backendName}". Available:`);
861
1586
  for (const b of listBackends()) consola.log(` ${b.name.padEnd(22)} ${b.description}`);
862
1587
  process.exit(1);
863
- }
864
- envVars = backend.env;
865
- resolvedModel ??= backend.model || void 0;
866
- }
867
- let initialPromptFile;
868
- if (promptText) {
869
- initialPromptFile = join(tmpdir(), `cctabs-prompt-${Date.now()}.txt`);
870
- writeFileSync(initialPromptFile, promptText);
871
- } else if (promptFile) initialPromptFile = promptFile;
872
- const tabId = await openSession({
873
- tabName: name,
874
- dir,
875
- claudeCmd: useWorktree ? `claude --worktree ${JSON.stringify(name)}` : "claude",
876
- workspaceQuery: workspace,
877
- initialPromptFile,
878
- envVars,
879
- modelOverride: resolvedModel
880
- });
881
- const wt = useWorktree ? ` (worktree: .claude/worktrees/${name})` : "";
882
- const be = backendName ? ` [backend: ${backendName}${resolvedModel ? ` → ${resolvedModel}` : ""}]` : "";
883
- consola.success(`Tab "${name}" [${tabId.slice(0, 8)}] → claude at ${dir}${wt}${be}`);
884
- }
885
- });
886
- //#endregion
887
- //#region src/core/session.ts
888
- /** Convert an absolute path to Claude Code's project slug.
889
- * Claude Code replaces any non-alphanumeric character (spaces, /, ., etc.) with '-'.
890
- * Hyphens are preserved. Example: "/Users/me/Remember This" → "-Users-me-Remember-This". */
891
- function pathToProjectSlug(dir) {
892
- return resolve(dir).replace(/[^A-Za-z0-9-]/g, "-");
893
- }
894
- /** Find the most recent .jsonl session file in a Claude project directory */
895
- function latestJsonlIn(projectDir) {
896
- if (!existsSync(projectDir)) return null;
897
- const files = readdirSync(projectDir).filter((f) => extname(f) === ".jsonl").map((f) => ({
898
- name: f,
899
- mtime: statSync(join(projectDir, f)).mtimeMs
900
- })).sort((a, b) => b.mtime - a.mtime);
901
- return files.length ? basename(files[0].name, ".jsonl") : null;
902
- }
903
- /**
904
- * Find the most recent Claude Code session ID for a directory.
905
- * Also checks worktree subdirectories (.claude/worktrees/*) since tabs
906
- * opened with --worktree run from a worktree path, not the repo root.
907
- */
908
- function findLatestSessionId(dir) {
909
- const projectsRoot = join(homedir(), ".claude", "projects");
910
- const direct = latestJsonlIn(join(projectsRoot, pathToProjectSlug(dir)));
911
- if (direct) return direct;
912
- const worktreesDir = join(dir, ".claude", "worktrees");
913
- if (existsSync(worktreesDir)) {
914
- const candidates = [];
915
- for (const entry of readdirSync(worktreesDir)) {
916
- const projectDir = join(projectsRoot, pathToProjectSlug(join(worktreesDir, entry)));
917
- const id = latestJsonlIn(projectDir);
918
- if (id) {
919
- const mtime = statSync(join(projectDir, id + ".jsonl")).mtimeMs;
920
- candidates.push({
921
- id,
922
- mtime
923
- });
924
- }
925
- }
926
- if (candidates.length) {
927
- candidates.sort((a, b) => b.mtime - a.mtime);
928
- return candidates[0].id;
929
- }
930
- }
931
- return null;
932
- }
933
- /**
934
- * Find all sessions with a given custom title (--name).
935
- * Returns them sorted by most recent first, with the first user prompt for context.
936
- */
937
- function findSessionsByName(dir, name) {
938
- const projectDir = join(join(homedir(), ".claude", "projects"), pathToProjectSlug(dir));
939
- if (!existsSync(projectDir)) return [];
940
- const matches = [];
941
- const files = readdirSync(projectDir).filter((f) => extname(f) === ".jsonl");
942
- for (const f of files) {
943
- const fullPath = join(projectDir, f);
944
- try {
945
- const lines = readFileSync(fullPath, "utf-8").split("\n");
946
- let currentTitle = "";
947
- let firstPrompt = "";
948
- let lastActivity = "";
949
- for (const line of lines) {
950
- if (!line.trim()) continue;
951
- try {
952
- const entry = JSON.parse(line);
953
- if (entry.customTitle !== void 0) currentTitle = entry.customTitle;
954
- if (!firstPrompt && entry.type === "user" && entry.message?.content) {
955
- const text = typeof entry.message.content === "string" ? entry.message.content : entry.message.content.find((c) => c.type === "text")?.text ?? "";
956
- if (text.startsWith("<")) continue;
957
- firstPrompt = text.slice(0, 120).replace(/\n/g, " ").trim();
958
- if (text.length > 120) firstPrompt += "…";
959
- }
960
- if (entry.message?.role === "assistant" && entry.message?.content) {
961
- const parts = Array.isArray(entry.message.content) ? entry.message.content : [{
962
- type: "text",
963
- text: entry.message.content
964
- }];
965
- for (const p of parts) if (p.type === "text" && p.text?.trim()) {
966
- lastActivity = p.text.slice(0, 120).replace(/\n/g, " ").trim();
967
- if (p.text.length > 120) lastActivity += "…";
968
- }
969
- }
970
- } catch {}
971
- }
972
- if (currentTitle !== name) continue;
973
- const stat = statSync(fullPath);
974
- matches.push({
975
- id: basename(f, ".jsonl"),
976
- mtime: stat.mtimeMs,
977
- size: stat.size,
978
- firstPrompt,
979
- lastActivity
980
- });
981
- } catch {}
982
- }
983
- return matches.sort((a, b) => b.mtime - a.mtime);
984
- }
985
- /**
986
- * Like findSessionsByName, but searches every project directory under
987
- * ~/.claude/projects. Each match carries the cwd recorded in the session.
988
- * Used by `cctabs restore` so callers don't have to guess the right dir.
989
- */
990
- function findSessionsByNameGlobally(name) {
991
- const projectsRoot = join(homedir(), ".claude", "projects");
992
- if (!existsSync(projectsRoot)) return [];
993
- const matches = [];
994
- for (const slug of readdirSync(projectsRoot)) {
995
- const projectDir = join(projectsRoot, slug);
996
- let isDir = false;
997
- try {
998
- isDir = statSync(projectDir).isDirectory();
999
- } catch {
1000
- continue;
1001
- }
1002
- if (!isDir) continue;
1003
- const files = readdirSync(projectDir).filter((f) => extname(f) === ".jsonl");
1004
- for (const f of files) {
1005
- const fullPath = join(projectDir, f);
1006
- try {
1007
- const lines = readFileSync(fullPath, "utf-8").split("\n");
1008
- let currentTitle = "";
1009
- let cwd = "";
1010
- let firstPrompt = "";
1011
- let lastActivity = "";
1012
- for (const line of lines) {
1013
- if (!line.trim()) continue;
1014
- try {
1015
- const entry = JSON.parse(line);
1016
- if (entry.customTitle !== void 0) currentTitle = entry.customTitle;
1017
- if (!cwd && typeof entry.cwd === "string") cwd = entry.cwd;
1018
- if (!firstPrompt && entry.type === "user" && entry.message?.content) {
1019
- const text = typeof entry.message.content === "string" ? entry.message.content : entry.message.content.find((c) => c.type === "text")?.text ?? "";
1020
- if (text.startsWith("<")) continue;
1021
- firstPrompt = text.slice(0, 120).replace(/\n/g, " ").trim();
1022
- if (text.length > 120) firstPrompt += "…";
1023
- }
1024
- if (entry.message?.role === "assistant" && entry.message?.content) {
1025
- const parts = Array.isArray(entry.message.content) ? entry.message.content : [{
1026
- type: "text",
1027
- text: entry.message.content
1028
- }];
1029
- for (const p of parts) if (p.type === "text" && p.text?.trim()) {
1030
- lastActivity = p.text.slice(0, 120).replace(/\n/g, " ").trim();
1031
- if (p.text.length > 120) lastActivity += "…";
1032
- }
1033
- }
1034
- } catch {}
1035
- }
1036
- if (currentTitle !== name || !cwd) continue;
1037
- const stat = statSync(fullPath);
1038
- matches.push({
1039
- id: basename(f, ".jsonl"),
1040
- mtime: stat.mtimeMs,
1041
- size: stat.size,
1042
- firstPrompt,
1043
- lastActivity,
1044
- dir: cwd
1045
- });
1046
- } catch {}
1047
- }
1048
- }
1049
- return matches.sort((a, b) => b.mtime - a.mtime);
1050
- }
1051
- /**
1052
- * List all unique session names (customTitle) in a project directory.
1053
- * Used to show available names when a resume lookup fails.
1054
- */
1055
- function listSessionNames(dir) {
1056
- const projectDir = join(join(homedir(), ".claude", "projects"), pathToProjectSlug(dir));
1057
- if (!existsSync(projectDir)) return [];
1058
- const results = [];
1059
- const seen = /* @__PURE__ */ new Set();
1060
- const files = readdirSync(projectDir).filter((f) => extname(f) === ".jsonl");
1061
- for (const f of files) {
1062
- const fullPath = join(projectDir, f);
1063
- try {
1064
- const firstLine = readFileSync(fullPath, "utf-8").split("\n")[0];
1065
- if (!firstLine) continue;
1066
- const title = JSON.parse(firstLine).customTitle;
1067
- if (!title || seen.has(title)) continue;
1068
- seen.add(title);
1069
- const stat = statSync(fullPath);
1070
- results.push({
1071
- name: title,
1072
- id: basename(f, ".jsonl"),
1073
- mtime: stat.mtimeMs
1074
- });
1075
- } catch {}
1076
- }
1077
- return results.sort((a, b) => b.mtime - a.mtime);
1078
- }
1079
- /**
1080
- * Resolve a session ID prefix (e.g. "19aae7b4") to the full UUID by scanning
1081
- * `~/.claude/projects/`. Returns the input unchanged if it already looks like
1082
- * a full UUID, or null if no unique match exists. Pass `dir` to scope the
1083
- * search to one project; otherwise every project is checked.
1084
- *
1085
- * `claude --resume <prefix>` does NOT accept truncated IDs — it treats them
1086
- * as a search query and shows the picker. So callers must expand prefixes
1087
- * before forwarding to claude.
1088
- */
1089
- function expandSessionId(input, dir) {
1090
- if (!input) return null;
1091
- if (/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(input)) return input;
1092
- const projectsRoot = join(homedir(), ".claude", "projects");
1093
- if (!existsSync(projectsRoot)) return null;
1094
- const projectDirs = dir ? [join(projectsRoot, pathToProjectSlug(dir))] : readdirSync(projectsRoot).map((d) => join(projectsRoot, d)).filter((p) => {
1095
- try {
1096
- return statSync(p).isDirectory();
1097
- } catch {
1098
- return false;
1099
- }
1100
- });
1101
- const matches = [];
1102
- for (const pd of projectDirs) {
1103
- if (!existsSync(pd)) continue;
1104
- for (const f of readdirSync(pd)) {
1105
- if (extname(f) !== ".jsonl") continue;
1106
- const id = basename(f, ".jsonl");
1107
- if (id.startsWith(input) && !matches.includes(id)) matches.push(id);
1588
+ }
1589
+ envVars = backend.env;
1590
+ resolvedModel ??= backend.model || void 0;
1108
1591
  }
1592
+ let initialPromptFile;
1593
+ if (promptText) {
1594
+ initialPromptFile = join(tmpdir(), `cctabs-prompt-${Date.now()}.txt`);
1595
+ writeFileSync(initialPromptFile, promptText);
1596
+ } else if (promptFile) initialPromptFile = promptFile;
1597
+ let claudeCmd;
1598
+ if (resolvedSessionId) {
1599
+ const worktreePart = useWorktree ? ` --worktree ${JSON.stringify(name)}` : "";
1600
+ claudeCmd = `claude --resume ${resolvedSessionId}${worktreePart} --name ${JSON.stringify(name)}`;
1601
+ } else claudeCmd = useWorktree ? `claude --worktree ${JSON.stringify(name)}` : "claude";
1602
+ const tabId = await openSession({
1603
+ tabName: name,
1604
+ dir,
1605
+ claudeCmd,
1606
+ workspaceQuery: workspace,
1607
+ initialPromptFile,
1608
+ envVars,
1609
+ modelOverride: resolvedModel
1610
+ });
1611
+ const wt = useWorktree ? ` (worktree: .claude/worktrees/${name})` : "";
1612
+ const be = backendName ? ` [backend: ${backendName}${resolvedModel ? ` → ${resolvedModel}` : ""}]` : "";
1613
+ const rs = resolvedSessionId ? ` --resume ${resolvedSessionId.slice(0, 8)}…` : "";
1614
+ consola.success(`Tab "${name}" [${tabId.slice(0, 8)}] → claude${rs} at ${dir}${wt}${be}`);
1109
1615
  }
1110
- return matches.length === 1 ? matches[0] : null;
1111
- }
1616
+ });
1112
1617
  //#endregion
1113
1618
  //#region src/commands/resume.ts
1114
1619
  function shellQuoteEnv(env) {
@@ -1207,7 +1712,7 @@ const resumeCommand = define({
1207
1712
  process.exit(1);
1208
1713
  }
1209
1714
  }
1210
- const adapter = requireWaveAdapter();
1715
+ const adapter = requireAdapter();
1211
1716
  const { tabsById, tabNames } = await adapter.getAllData();
1212
1717
  const matchingTabs = adapter.resolveTab(name, tabsById, tabNames);
1213
1718
  if (matchingTabs.length > 1) {
@@ -1329,7 +1834,7 @@ const forkCommand = define({
1329
1834
  consola.error("Source tab name is required");
1330
1835
  process.exit(1);
1331
1836
  }
1332
- const adapter = requireWaveAdapter();
1837
+ const adapter = requireAdapter();
1333
1838
  const { tabsById, tabNames } = await adapter.getAllData();
1334
1839
  const matches = adapter.resolveTab(sourceQuery, tabsById, tabNames);
1335
1840
  if (!matches.length) {
@@ -1380,7 +1885,7 @@ const closeCommand = define({
1380
1885
  consola.error("Tab name or ID is required");
1381
1886
  process.exit(1);
1382
1887
  }
1383
- const adapter = requireWaveAdapter();
1888
+ const adapter = requireAdapter();
1384
1889
  const { tabsById, tabNames } = await adapter.getAllData();
1385
1890
  const matches = adapter.resolveTab(query, tabsById, tabNames);
1386
1891
  if (!matches.length) {
@@ -1421,7 +1926,7 @@ const renameCommand = define({
1421
1926
  consola.error("Usage: cctabs rename <tab> <new-name>");
1422
1927
  process.exit(1);
1423
1928
  }
1424
- const adapter = requireWaveAdapter();
1929
+ const adapter = requireAdapter();
1425
1930
  const { tabsById, tabNames } = await adapter.getAllData();
1426
1931
  const matches = adapter.resolveTab(query, tabsById, tabNames);
1427
1932
  if (!matches.length) {
@@ -1462,7 +1967,7 @@ const scrollbackCommand = define({
1462
1967
  consola.error("Tab name or block ID is required");
1463
1968
  process.exit(1);
1464
1969
  }
1465
- const adapter = requireWaveAdapter();
1970
+ const adapter = requireAdapter();
1466
1971
  const { tabsById, tabNames } = await adapter.getAllData();
1467
1972
  const tabMatches = adapter.resolveTab(query, tabsById, tabNames);
1468
1973
  let blockId;
@@ -1520,6 +2025,15 @@ const sendCommand = define({
1520
2025
  type: "boolean",
1521
2026
  short: "e",
1522
2027
  description: "Append newline after text (default: true)"
2028
+ },
2029
+ "wait-for-prompt": {
2030
+ type: "boolean",
2031
+ short: "w",
2032
+ description: "Poll the buffer until a shell prompt ($, %, >, ❯) is visible before sending. Useful for freshly-spawned tabs."
2033
+ },
2034
+ "wait-timeout": {
2035
+ type: "number",
2036
+ description: "Timeout in seconds for --wait-for-prompt (default: 10)"
1523
2037
  }
1524
2038
  },
1525
2039
  async run(ctx) {
@@ -1527,6 +2041,8 @@ const sendCommand = define({
1527
2041
  const inlineText = ctx.positionals[2];
1528
2042
  const filePath = ctx.values.file;
1529
2043
  const appendEnter = ctx.values.enter ?? true;
2044
+ const waitForPrompt = ctx.values["wait-for-prompt"] ?? false;
2045
+ const waitTimeoutSec = ctx.values["wait-timeout"] ?? 10;
1530
2046
  if (!query) {
1531
2047
  consola.error("Usage: cctabs send <tab-or-block> [text]");
1532
2048
  process.exit(1);
@@ -1536,7 +2052,7 @@ const sendCommand = define({
1536
2052
  else if (filePath) rawText = readFileSync(filePath, "utf-8").replace(/\n/g, "\r");
1537
2053
  else rawText = (await readStdin()).replace(/\n/g, "\r");
1538
2054
  if (appendEnter && !rawText.endsWith("\r")) rawText += "\r";
1539
- const adapter = requireWaveAdapter();
2055
+ const adapter = requireAdapter();
1540
2056
  const { tabsById, tabNames } = await adapter.getAllData();
1541
2057
  const tabMatches = adapter.resolveTab(query, tabsById, tabNames);
1542
2058
  let blockId;
@@ -1565,6 +2081,23 @@ const sendCommand = define({
1565
2081
  }
1566
2082
  blockId = blockMatches[0].blockid;
1567
2083
  }
2084
+ if (waitForPrompt) {
2085
+ const deadline = Date.now() + waitTimeoutSec * 1e3;
2086
+ let ready = false;
2087
+ while (Date.now() < deadline) {
2088
+ const lastLine = adapter.scrollback(blockId, 5).split("\n").map((l) => l.trim()).filter(Boolean).at(-1) ?? "";
2089
+ if (/[$%>❯]\s*$/.test(lastLine)) {
2090
+ ready = true;
2091
+ break;
2092
+ }
2093
+ await new Promise((r) => setTimeout(r, 250));
2094
+ }
2095
+ if (!ready) {
2096
+ adapter.closeSocket();
2097
+ consola.error(`Timed out after ${waitTimeoutSec}s waiting for shell prompt in ${blockId.slice(0, 8)}`);
2098
+ process.exit(1);
2099
+ }
2100
+ }
1568
2101
  const resp = await adapter.sendInput(blockId, rawText);
1569
2102
  adapter.closeSocket();
1570
2103
  if (resp && resp.error) {
@@ -1592,154 +2125,378 @@ const configCommand = define({
1592
2125
  });
1593
2126
  //#endregion
1594
2127
  //#region src/commands/restore.ts
2128
+ function readStdinSync() {
2129
+ if (process.stdin.isTTY) return "";
2130
+ try {
2131
+ return readFileSync(0, "utf-8");
2132
+ } catch {
2133
+ return "";
2134
+ }
2135
+ }
2136
+ function parseManifest(raw) {
2137
+ let parsed;
2138
+ try {
2139
+ parsed = JSON.parse(raw);
2140
+ } catch (err) {
2141
+ throw new Error(`Manifest is not valid JSON: ${err.message}`);
2142
+ }
2143
+ const collected = [];
2144
+ if (Array.isArray(parsed)) collected.push(...parsed);
2145
+ else if (parsed && typeof parsed === "object") {
2146
+ const p = parsed;
2147
+ if (Array.isArray(p.sessions)) collected.push(...p.sessions);
2148
+ if (Array.isArray(p.workspaces)) {
2149
+ for (const ws of p.workspaces) if (ws && typeof ws === "object" && Array.isArray(ws.sessions)) collected.push(...ws.sessions);
2150
+ }
2151
+ }
2152
+ const entries = [];
2153
+ for (const item of collected) {
2154
+ if (!item || typeof item !== "object") continue;
2155
+ const it = item;
2156
+ const name = typeof it.name === "string" ? it.name : null;
2157
+ const dir = typeof it.dir === "string" ? it.dir : typeof it.cwd === "string" ? it.cwd : null;
2158
+ if (!name || !dir) continue;
2159
+ const sid = typeof it.session_id === "string" ? it.session_id : void 0;
2160
+ entries.push({
2161
+ name,
2162
+ dir: resolve(dir.replace(/^~/, homedir())),
2163
+ session_id: sid
2164
+ });
2165
+ }
2166
+ return entries;
2167
+ }
1595
2168
  const restoreCommand = define({
1596
2169
  name: "restore",
1597
- 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.",
1598
- args: { dry: {
1599
- type: "boolean",
1600
- short: "n",
1601
- description: "Show what would be resumed without actually doing it"
1602
- } },
2170
+ description: "Resume Claude sessions in terminal-state tabs (e.g. after a reboot). With --manifest, drive from an explicit list and optionally spawn missing tabs.",
2171
+ args: {
2172
+ dry: {
2173
+ type: "boolean",
2174
+ short: "n",
2175
+ description: "Show what would be resumed without actually doing it"
2176
+ },
2177
+ manifest: {
2178
+ type: "string",
2179
+ short: "m",
2180
+ description: "Path to a JSON manifest of {name, dir, session_id?} entries (use \"-\" for stdin). Accepts cctabs sessions --json output directly."
2181
+ },
2182
+ "create-missing": {
2183
+ type: "boolean",
2184
+ short: "c",
2185
+ description: "When using --manifest, spawn new tabs for entries that have no existing tab"
2186
+ }
2187
+ },
1603
2188
  async run(ctx) {
1604
- const rawDir = ctx.positionals[1];
1605
- const scopedDir = rawDir ? resolve(rawDir.replace(/^~/, homedir())) : null;
1606
2189
  const dryRun = ctx.values.dry;
1607
- const adapter = requireWaveAdapter();
1608
- const { tabsById, workspaces, tabNames } = await adapter.getAllData();
1609
- const currentTab = process.env.WAVETERM_TABID ?? "";
1610
- const tabs = [];
1611
- for (const wsp of workspaces) for (const tabId of wsp.workspacedata.tabids) {
1612
- if (tabId === currentTab) continue;
1613
- const blocks = (tabsById.get(tabId) ?? []).filter((b) => b.view === "term");
1614
- if (!blocks.length) continue;
1615
- const name = tabNames.get(tabId) ?? tabId.slice(0, 8);
1616
- const status = adapter.detectSessionStatus(blocks[0].blockid);
1617
- tabs.push({
1618
- tabId,
1619
- name,
1620
- blockId: blocks[0].blockid,
1621
- status
2190
+ const manifestPath = ctx.values.manifest;
2191
+ const createMissing = ctx.values["create-missing"] ?? false;
2192
+ if (manifestPath) {
2193
+ await runManifestMode(manifestPath, createMissing, !!dryRun);
2194
+ return;
2195
+ }
2196
+ if (createMissing) consola.warn("--create-missing has no effect without --manifest; ignoring.");
2197
+ await runLegacyMode(ctx.positionals[1], !!dryRun);
2198
+ }
2199
+ });
2200
+ async function runManifestMode(manifestPath, createMissing, dryRun) {
2201
+ let raw;
2202
+ if (manifestPath === "-") {
2203
+ raw = readStdinSync();
2204
+ if (!raw.trim()) {
2205
+ consola.error("--manifest - was given but stdin is empty");
2206
+ process.exit(1);
2207
+ }
2208
+ } else {
2209
+ if (!existsSync(manifestPath)) {
2210
+ consola.error(`Manifest file not found: ${manifestPath}`);
2211
+ process.exit(1);
2212
+ }
2213
+ raw = readFileSync(manifestPath, "utf-8");
2214
+ }
2215
+ let entries;
2216
+ try {
2217
+ entries = parseManifest(raw);
2218
+ } catch (err) {
2219
+ consola.error(err.message);
2220
+ process.exit(1);
2221
+ }
2222
+ if (!entries.length) {
2223
+ consola.error("Manifest contained no usable entries (need at minimum {name, dir} per entry).");
2224
+ process.exit(1);
2225
+ }
2226
+ consola.info(`Manifest: ${entries.length} entry/entries`);
2227
+ const adapter = requireAdapter();
2228
+ const { tabsById, tabNames, workspaces } = await adapter.getAllData();
2229
+ const currentWs = adapter.currentWorkspaceId();
2230
+ const currentTab = adapter.currentTabId();
2231
+ const currentWsData = workspaces.find((w) => w.workspacedata.oid === currentWs);
2232
+ const wsTabIds = currentWsData ? new Set(currentWsData.workspacedata.tabids) : new Set(tabsById.keys());
2233
+ const results = [];
2234
+ const toSpawn = [];
2235
+ const extraFlags = loadConfig().claude.flags.join(" ");
2236
+ for (const entry of entries) {
2237
+ let resolvedSessionId = entry.session_id;
2238
+ if (entry.session_id) {
2239
+ const expanded = expandSessionId(entry.session_id, entry.dir) ?? expandSessionId(entry.session_id);
2240
+ if (expanded) resolvedSessionId = expanded;
2241
+ } else {
2242
+ const sessions = findSessionsByName(entry.dir, entry.name);
2243
+ if (sessions.length === 1) resolvedSessionId = sessions[0].id;
2244
+ else if (sessions.length > 1) resolvedSessionId = sessions[0].id;
2245
+ }
2246
+ const matchingTabs = adapter.resolveTab(entry.name, tabsById, tabNames).filter((tid) => wsTabIds.has(tid) && tid !== currentTab);
2247
+ if (matchingTabs.length > 1) {
2248
+ consola.log(` ${entry.name} — multiple matching tabs, skipping`);
2249
+ results.push({
2250
+ name: entry.name,
2251
+ result: "ambiguous (multiple tabs)"
1622
2252
  });
2253
+ continue;
1623
2254
  }
1624
- const toResume = tabs.filter((t) => t.status === "terminal" || t.status === "unknown");
1625
- const alreadyActive = tabs.filter((t) => t.status === "active" || t.status === "idle");
1626
- if (alreadyActive.length) consola.info(`Already running: ${alreadyActive.map((t) => t.name).join(", ")}`);
1627
- if (!toResume.length) {
1628
- consola.info("No terminal-state tabs to restore.");
1629
- adapter.closeSocket();
1630
- return;
2255
+ if (matchingTabs.length === 1) {
2256
+ const tabId = matchingTabs[0];
2257
+ const termBlock = (tabsById.get(tabId) ?? []).find((b) => b.view === "term");
2258
+ if (!termBlock) {
2259
+ results.push({
2260
+ name: entry.name,
2261
+ result: "no terminal block in tab"
2262
+ });
2263
+ continue;
2264
+ }
2265
+ const status = adapter.detectSessionStatus(termBlock.blockid);
2266
+ if (status === "active" || status === "idle") {
2267
+ consola.log(` ${entry.name} — already running, skipping`);
2268
+ results.push({
2269
+ name: entry.name,
2270
+ result: "already running"
2271
+ });
2272
+ continue;
2273
+ }
2274
+ if (!resolvedSessionId) {
2275
+ consola.log(` ${entry.name} — no session ID and none found in ${entry.dir}, skipping`);
2276
+ results.push({
2277
+ name: entry.name,
2278
+ result: "no matching session"
2279
+ });
2280
+ continue;
2281
+ }
2282
+ if (dryRun) {
2283
+ consola.log(` ${entry.name} → would resume ${resolvedSessionId.slice(0, 8)}… in existing tab`);
2284
+ results.push({
2285
+ name: entry.name,
2286
+ result: `dry run: attach ${resolvedSessionId.slice(0, 8)}…`
2287
+ });
2288
+ continue;
2289
+ }
2290
+ consola.log(` ${entry.name} → resuming ${resolvedSessionId.slice(0, 8)}… in existing tab`);
2291
+ const cmd = `cd ${JSON.stringify(entry.dir)} && claude${extraFlags ? " " + extraFlags : ""} --resume ${resolvedSessionId} --name ${JSON.stringify(entry.name)}\r`;
2292
+ await adapter.sendInput(termBlock.blockid, cmd);
2293
+ await new Promise((r) => setTimeout(r, 500));
2294
+ results.push({
2295
+ name: entry.name,
2296
+ result: "sent"
2297
+ });
2298
+ continue;
1631
2299
  }
1632
- consola.info(`Found ${toResume.length} tab(s) to restore:`);
1633
- const extraFlags = loadConfig().claude.flags.join(" ");
1634
- const results = [];
1635
- const toRecreate = [];
1636
- for (const tab of toResume) {
1637
- let sessionId = null;
1638
- let sessionDir = null;
1639
- if (scopedDir) {
1640
- const sessions = findSessionsByName(scopedDir, tab.name);
1641
- if (sessions.length === 0) {
1642
- consola.log(` ${tab.name} no session named "${tab.name}" found in ${scopedDir}, skipping`);
1643
- results.push({
1644
- name: tab.name,
1645
- result: "no matching session"
1646
- });
1647
- continue;
1648
- }
1649
- if (sessions.length > 1) {
1650
- consola.log(` ${tab.name} — multiple sessions found, skipping (use cctabs resume --session to pick one)`);
1651
- results.push({
1652
- name: tab.name,
1653
- result: "ambiguous (multiple sessions)"
1654
- });
1655
- continue;
1656
- }
1657
- sessionId = sessions[0].id;
1658
- sessionDir = scopedDir;
1659
- } else {
1660
- const sessions = findSessionsByNameGlobally(tab.name);
1661
- if (sessions.length === 0) {
1662
- consola.log(` ${tab.name} — no session named "${tab.name}" found in any project, skipping`);
1663
- results.push({
1664
- name: tab.name,
1665
- result: "no matching session"
1666
- });
2300
+ if (!createMissing) {
2301
+ consola.log(` ${entry.name} no existing tab; pass --create-missing to spawn one`);
2302
+ results.push({
2303
+ name: entry.name,
2304
+ result: "missing (skipped, no --create-missing)"
2305
+ });
2306
+ continue;
2307
+ }
2308
+ if (dryRun) {
2309
+ const sid = resolvedSessionId ? `${resolvedSessionId.slice(0, 8)}…` : "fresh";
2310
+ consola.log(` ${entry.name} would spawn new tab in ${entry.dir} (${sid})`);
2311
+ results.push({
2312
+ name: entry.name,
2313
+ result: `dry run: spawn (${sid})`
2314
+ });
2315
+ continue;
2316
+ }
2317
+ toSpawn.push({
2318
+ ...entry,
2319
+ session_id: resolvedSessionId
2320
+ });
2321
+ }
2322
+ if (!dryRun) {
2323
+ const sent = results.filter((r) => r.result === "sent");
2324
+ if (sent.length) {
2325
+ consola.info("Waiting for sessions to start…");
2326
+ await new Promise((r) => setTimeout(r, 1e4));
2327
+ for (const r of sent) {
2328
+ const tabId = adapter.resolveTab(r.name, tabsById, tabNames).filter((tid) => wsTabIds.has(tid) && tid !== currentTab)[0];
2329
+ const termBlock = tabId ? (tabsById.get(tabId) ?? []).find((b) => b.view === "term") : void 0;
2330
+ if (!termBlock) {
2331
+ r.result = "? tab disappeared";
1667
2332
  continue;
1668
2333
  }
1669
- if (sessions.length > 1) consola.log(` ${tab.name} — multiple sessions found across projects, picking newest (${sessions[0].dir})`);
1670
- sessionId = sessions[0].id;
1671
- sessionDir = sessions[0].dir;
2334
+ const status = adapter.detectSessionStatus(termBlock.blockid);
2335
+ if (status === "active" || status === "idle") r.result = "✔ running";
2336
+ else if (status === "unknown") r.result = "? scrollback unavailable";
2337
+ else r.result = "✘ may not have started";
1672
2338
  }
1673
- if (dryRun) {
1674
- const mode = tab.status === "unknown" ? "recreate" : "send";
1675
- consola.log(` ${tab.name} → would ${mode} session ${sessionId.slice(0, 8)}… in ${sessionDir}`);
2339
+ }
2340
+ }
2341
+ adapter.closeSocket();
2342
+ for (const entry of toSpawn) try {
2343
+ const claudeCmd = entry.session_id ? `claude --resume ${entry.session_id} --name ${JSON.stringify(entry.name)}` : "claude";
2344
+ const newTabId = await openSession({
2345
+ tabName: entry.name,
2346
+ dir: entry.dir,
2347
+ claudeCmd
2348
+ });
2349
+ const sid = entry.session_id ? entry.session_id.slice(0, 8) + "…" : "fresh";
2350
+ results.push({
2351
+ name: entry.name,
2352
+ result: `✔ spawned [${newTabId.slice(0, 8)}] (${sid})`
2353
+ });
2354
+ } catch (err) {
2355
+ results.push({
2356
+ name: entry.name,
2357
+ result: `✘ spawn failed: ${err.message}`
2358
+ });
2359
+ }
2360
+ console.log("\nRestore summary:");
2361
+ for (const r of results) console.log(` ${r.name}: ${r.result}`);
2362
+ }
2363
+ async function runLegacyMode(rawDir, dryRun) {
2364
+ const scopedDir = rawDir ? resolve(rawDir.replace(/^~/, homedir())) : null;
2365
+ const adapter = requireAdapter();
2366
+ const { tabsById, workspaces, tabNames } = await adapter.getAllData();
2367
+ const currentTab = adapter.currentTabId();
2368
+ const tabs = [];
2369
+ for (const wsp of workspaces) for (const tabId of wsp.workspacedata.tabids) {
2370
+ if (tabId === currentTab) continue;
2371
+ const blocks = (tabsById.get(tabId) ?? []).filter((b) => b.view === "term");
2372
+ if (!blocks.length) continue;
2373
+ const name = tabNames.get(tabId) ?? tabId.slice(0, 8);
2374
+ const status = adapter.detectSessionStatus(blocks[0].blockid);
2375
+ tabs.push({
2376
+ tabId,
2377
+ name,
2378
+ blockId: blocks[0].blockid,
2379
+ status
2380
+ });
2381
+ }
2382
+ const toResume = tabs.filter((t) => t.status === "terminal" || t.status === "unknown");
2383
+ const alreadyActive = tabs.filter((t) => t.status === "active" || t.status === "idle");
2384
+ if (alreadyActive.length) consola.info(`Already running: ${alreadyActive.map((t) => t.name).join(", ")}`);
2385
+ if (!toResume.length) {
2386
+ consola.info("No terminal-state tabs to restore.");
2387
+ adapter.closeSocket();
2388
+ return;
2389
+ }
2390
+ consola.info(`Found ${toResume.length} tab(s) to restore:`);
2391
+ const extraFlags = loadConfig().claude.flags.join(" ");
2392
+ const results = [];
2393
+ const toRecreate = [];
2394
+ for (const tab of toResume) {
2395
+ let sessionId = null;
2396
+ let sessionDir = null;
2397
+ if (scopedDir) {
2398
+ const sessions = findSessionsByName(scopedDir, tab.name);
2399
+ if (sessions.length === 0) {
2400
+ consola.log(` ${tab.name} — no session named "${tab.name}" found in ${scopedDir}, skipping`);
1676
2401
  results.push({
1677
2402
  name: tab.name,
1678
- result: `dry run: ${sessionId.slice(0, 8)}…`
2403
+ result: "no matching session"
1679
2404
  });
1680
2405
  continue;
1681
2406
  }
1682
- if (tab.status === "unknown") {
1683
- if (await adapter.confirmScrollbackEmpty(tab.blockId)) {
1684
- const blockIds = (tabsById.get(tab.tabId) ?? []).map((b) => b.blockid);
1685
- toRecreate.push({
1686
- name: tab.name,
1687
- sessionId,
1688
- sessionDir,
1689
- blockIds,
1690
- tabId: tab.tabId
1691
- });
1692
- results.push({
1693
- name: tab.name,
1694
- result: "queued for recreate"
1695
- });
1696
- continue;
1697
- }
2407
+ if (sessions.length > 1) {
2408
+ consola.log(` ${tab.name} — multiple sessions found, skipping (use cctabs resume --session to pick one)`);
2409
+ results.push({
2410
+ name: tab.name,
2411
+ result: "ambiguous (multiple sessions)"
2412
+ });
2413
+ continue;
1698
2414
  }
1699
- consola.log(` ${tab.name} resuming session ${sessionId.slice(0, 8)}… in ${sessionDir} (send)`);
1700
- const cmd = `cd ${JSON.stringify(sessionDir)} && claude${extraFlags ? " " + extraFlags : ""} --resume ${sessionId} --name ${JSON.stringify(tab.name)}\r`;
1701
- await adapter.sendInput(tab.blockId, cmd);
1702
- await new Promise((r) => setTimeout(r, 500));
2415
+ sessionId = sessions[0].id;
2416
+ sessionDir = scopedDir;
2417
+ } else {
2418
+ const sessions = findSessionsByNameGlobally(tab.name);
2419
+ if (sessions.length === 0) {
2420
+ consola.log(` ${tab.name} — no session named "${tab.name}" found in any project, skipping`);
2421
+ results.push({
2422
+ name: tab.name,
2423
+ result: "no matching session"
2424
+ });
2425
+ continue;
2426
+ }
2427
+ if (sessions.length > 1) consola.log(` ${tab.name} — multiple sessions found across projects, picking newest (${sessions[0].dir})`);
2428
+ sessionId = sessions[0].id;
2429
+ sessionDir = sessions[0].dir;
2430
+ }
2431
+ if (dryRun) {
2432
+ const mode = tab.status === "unknown" ? "recreate" : "send";
2433
+ consola.log(` ${tab.name} → would ${mode} session ${sessionId.slice(0, 8)}… in ${sessionDir}`);
1703
2434
  results.push({
1704
2435
  name: tab.name,
1705
- result: "sent"
2436
+ result: `dry run: ${sessionId.slice(0, 8)}…`
1706
2437
  });
2438
+ continue;
1707
2439
  }
1708
- if (!dryRun) {
1709
- const sent = results.filter((r) => r.result === "sent");
1710
- if (sent.length) {
1711
- consola.info("Waiting for sessions to start…");
1712
- await new Promise((r) => setTimeout(r, 1e4));
1713
- for (const r of sent) {
1714
- const tab = toResume.find((t) => t.name === r.name);
1715
- const status = adapter.detectSessionStatus(tab.blockId);
1716
- if (status === "active" || status === "idle") r.result = "✔ running";
1717
- else if (status === "unknown") r.result = "? scrollback unavailable";
1718
- else r.result = "✘ may not have started";
1719
- }
2440
+ if (tab.status === "unknown") {
2441
+ if (await adapter.confirmScrollbackEmpty(tab.blockId)) {
2442
+ const blockIds = (tabsById.get(tab.tabId) ?? []).map((b) => b.blockid);
2443
+ toRecreate.push({
2444
+ name: tab.name,
2445
+ sessionId,
2446
+ sessionDir,
2447
+ blockIds,
2448
+ tabId: tab.tabId
2449
+ });
2450
+ results.push({
2451
+ name: tab.name,
2452
+ result: "queued for recreate"
2453
+ });
2454
+ continue;
1720
2455
  }
1721
2456
  }
1722
- if (!dryRun && toRecreate.length) {
1723
- for (const t of toRecreate) for (const bid of t.blockIds) adapter.deleteBlock(bid);
1724
- adapter.closeSocket();
1725
- consola.info(`Recreating ${toRecreate.length} dead tab(s)…`);
1726
- for (const t of toRecreate) try {
1727
- const newTabId = await openSession({
1728
- tabName: t.name,
1729
- dir: t.sessionDir,
1730
- claudeCmd: `claude --resume ${t.sessionId} --name ${JSON.stringify(t.name)}`
1731
- });
1732
- const r = results.find((x) => x.name === t.name);
1733
- r.result = `✔ recreated [${newTabId.slice(0, 8)}]`;
1734
- } catch (err) {
1735
- const r = results.find((x) => x.name === t.name);
1736
- r.result = `✘ recreate failed: ${err.message}`;
2457
+ consola.log(` ${tab.name} resuming session ${sessionId.slice(0, 8)}… in ${sessionDir} (send)`);
2458
+ const cmd = `cd ${JSON.stringify(sessionDir)} && claude${extraFlags ? " " + extraFlags : ""} --resume ${sessionId} --name ${JSON.stringify(tab.name)}\r`;
2459
+ await adapter.sendInput(tab.blockId, cmd);
2460
+ await new Promise((r) => setTimeout(r, 500));
2461
+ results.push({
2462
+ name: tab.name,
2463
+ result: "sent"
2464
+ });
2465
+ }
2466
+ if (!dryRun) {
2467
+ const sent = results.filter((r) => r.result === "sent");
2468
+ if (sent.length) {
2469
+ consola.info("Waiting for sessions to start…");
2470
+ await new Promise((r) => setTimeout(r, 1e4));
2471
+ for (const r of sent) {
2472
+ const tab = toResume.find((t) => t.name === r.name);
2473
+ const status = adapter.detectSessionStatus(tab.blockId);
2474
+ if (status === "active" || status === "idle") r.result = "✔ running";
2475
+ else if (status === "unknown") r.result = "? scrollback unavailable";
2476
+ else r.result = "✘ may not have started";
1737
2477
  }
1738
- } else adapter.closeSocket();
1739
- console.log("\nRestore summary:");
1740
- for (const r of results) console.log(` ${r.name}: ${r.result}`);
2478
+ }
1741
2479
  }
1742
- });
2480
+ if (!dryRun && toRecreate.length) {
2481
+ for (const t of toRecreate) for (const bid of t.blockIds) adapter.deleteBlock(bid);
2482
+ adapter.closeSocket();
2483
+ consola.info(`Recreating ${toRecreate.length} dead tab(s)…`);
2484
+ for (const t of toRecreate) try {
2485
+ const newTabId = await openSession({
2486
+ tabName: t.name,
2487
+ dir: t.sessionDir,
2488
+ claudeCmd: `claude --resume ${t.sessionId} --name ${JSON.stringify(t.name)}`
2489
+ });
2490
+ const r = results.find((x) => x.name === t.name);
2491
+ r.result = `✔ recreated [${newTabId.slice(0, 8)}]`;
2492
+ } catch (err) {
2493
+ const r = results.find((x) => x.name === t.name);
2494
+ r.result = `✘ recreate failed: ${err.message}`;
2495
+ }
2496
+ } else adapter.closeSocket();
2497
+ console.log("\nRestore summary:");
2498
+ for (const r of results) console.log(` ${r.name}: ${r.result}`);
2499
+ }
1743
2500
  //#endregion
1744
2501
  //#region src/commands/backends.ts
1745
2502
  const backendsCommand = define({
@@ -1754,6 +2511,341 @@ const backendsCommand = define({
1754
2511
  }
1755
2512
  });
1756
2513
  //#endregion
2514
+ //#region src/commands/doctor.ts
2515
+ const STATUS_GLYPH = {
2516
+ ok: "✔",
2517
+ warn: "⚠",
2518
+ fail: "✘",
2519
+ skip: "–"
2520
+ };
2521
+ function printResult(r) {
2522
+ const line = ` ${STATUS_GLYPH[r.status]} ${r.name}${r.detail ? " — " + r.detail : ""}`;
2523
+ console.log(line);
2524
+ if (r.hint) console.log(` ↳ ${r.hint}`);
2525
+ }
2526
+ function checkTerminal(terminal) {
2527
+ if (terminal === "wave" || terminal === "tabby") return {
2528
+ name: "Terminal",
2529
+ status: "ok",
2530
+ detail: terminal
2531
+ };
2532
+ return {
2533
+ name: "Terminal",
2534
+ status: "fail",
2535
+ detail: terminal === "unknown" ? "unrecognised" : terminal,
2536
+ hint: "cctabs supports Wave Terminal and Tabby. Switch to one of those."
2537
+ };
2538
+ }
2539
+ function checkWaveAccessibility() {
2540
+ const r = spawnSync("osascript", ["-e", "tell application \"System Events\" to count processes"], {
2541
+ encoding: "utf-8",
2542
+ timeout: 2e3
2543
+ });
2544
+ const stderr = (r.stderr ?? "").trim();
2545
+ if (r.status === 0) return {
2546
+ name: "Wave Accessibility permission",
2547
+ status: "ok"
2548
+ };
2549
+ if (stderr.includes("not allowed") || stderr.includes("1002") || stderr.includes("-1719")) return {
2550
+ name: "Wave Accessibility permission",
2551
+ status: "fail",
2552
+ detail: "osascript denied",
2553
+ hint: "System Settings → Privacy & Security → Accessibility → enable Wave Terminal"
2554
+ };
2555
+ return {
2556
+ name: "Wave Accessibility permission",
2557
+ status: "warn",
2558
+ detail: stderr || `osascript exit ${r.status}`,
2559
+ hint: "Could not verify automatically. If `cctabs new` errors, check Privacy & Security → Accessibility."
2560
+ };
2561
+ }
2562
+ function probeTabbyPlugin(host, port) {
2563
+ const r = spawnSync("curl", [
2564
+ "-fsS",
2565
+ "--max-time",
2566
+ "3",
2567
+ `http://${host}:${port}/api/health`
2568
+ ], { encoding: "utf-8" });
2569
+ if (r.status !== 0 || !r.stdout) return {
2570
+ ok: false,
2571
+ error: (r.stderr || "").trim() || `exit ${r.status}`
2572
+ };
2573
+ try {
2574
+ const parsed = JSON.parse(r.stdout);
2575
+ return {
2576
+ ok: !!parsed.ok,
2577
+ version: parsed.version,
2578
+ raw: r.stdout
2579
+ };
2580
+ } catch {
2581
+ return {
2582
+ ok: false,
2583
+ error: "non-JSON response",
2584
+ raw: r.stdout
2585
+ };
2586
+ }
2587
+ }
2588
+ function checkTabbyPlugin() {
2589
+ const host = process.env.CCTABS_TABBY_HOST ?? "127.0.0.1";
2590
+ const port = Number(process.env.CCTABS_TABBY_PORT ?? "3300");
2591
+ const health = probeTabbyPlugin(host, port);
2592
+ if (health.ok) return {
2593
+ name: "Tabby cctabs plugin",
2594
+ status: "ok",
2595
+ detail: `${host}:${port}, version ${health.version ?? "unknown"}`
2596
+ };
2597
+ return {
2598
+ name: "Tabby cctabs plugin",
2599
+ status: "fail",
2600
+ detail: `${host}:${port} unreachable (${health.error ?? "unknown"})`,
2601
+ 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."
2602
+ };
2603
+ }
2604
+ function checkWaveDb() {
2605
+ if (!existsSync(WAVE_DB_PATH)) return { result: {
2606
+ name: "Wave DB",
2607
+ status: "skip",
2608
+ detail: "not found — Wave Terminal not installed?"
2609
+ } };
2610
+ let reports;
2611
+ try {
2612
+ reports = findOrphanTabIds();
2613
+ } catch (err) {
2614
+ return { result: {
2615
+ name: "Wave DB orphan-tabid scan",
2616
+ status: "fail",
2617
+ detail: err.message,
2618
+ hint: "sqlite3 CLI must be on PATH (ships with macOS by default)."
2619
+ } };
2620
+ }
2621
+ if (!reports.length) return { result: {
2622
+ name: "Wave DB orphan-tabid scan",
2623
+ status: "ok",
2624
+ detail: "no orphans"
2625
+ } };
2626
+ return {
2627
+ result: {
2628
+ name: "Wave DB orphan-tabid scan",
2629
+ status: "warn",
2630
+ detail: `${reports.length} workspace(s) affected`,
2631
+ hint: "Run `cctabs doctor --fix` (after quitting Wave) to clean up."
2632
+ },
2633
+ reports
2634
+ };
2635
+ }
2636
+ async function fixWaveDb(reports, yes) {
2637
+ console.log("");
2638
+ consola.warn(`${reports.length} workspace(s) with orphan tabids:`);
2639
+ for (const r of reports) {
2640
+ console.log(` • "${r.workspaceName}" [${r.workspaceId.slice(0, 8)}]`);
2641
+ console.log(` present: ${r.presentTabIds.length} tab(s)`);
2642
+ console.log(` orphan : ${r.orphanTabIds.length} tabid(s) → ${r.orphanTabIds.map((t) => t.slice(0, 8)).join(", ")}`);
2643
+ }
2644
+ console.log("");
2645
+ console.log("These tabids point at rows that no longer exist in db_tab. Wave's");
2646
+ console.log("BlocksList RPC aborts on the first missing tab, so `wsh blocks list`");
2647
+ console.log("currently fails with: \"couldn't list blocks for workspace …: not found\".");
2648
+ console.log("");
2649
+ console.log("Fix steps:");
2650
+ console.log(" 1. Copy the Wave DB to a timestamped backup next to it.");
2651
+ console.log(" 2. For each affected workspace, rewrite `data.tabids` to drop the orphan IDs.");
2652
+ console.log(" 3. Leave Wave's db_tab untouched.");
2653
+ console.log("");
2654
+ console.log("IMPORTANT: Quit Wave Terminal first — otherwise Wave may overwrite the");
2655
+ console.log("fix on its next save. (Cmd+Q on the Wave app, then re-run this command.)");
2656
+ console.log("");
2657
+ let proceed = yes;
2658
+ if (!yes) {
2659
+ const ans = await p.confirm({
2660
+ message: "Apply the fix now?",
2661
+ initialValue: false
2662
+ });
2663
+ if (p.isCancel(ans)) {
2664
+ consola.info("Cancelled.");
2665
+ return;
2666
+ }
2667
+ proceed = Boolean(ans);
2668
+ }
2669
+ if (!proceed) {
2670
+ consola.info("Aborted. No changes made.");
2671
+ return;
2672
+ }
2673
+ const backup = backupWaveDb();
2674
+ consola.success(`Backup written: ${backup}`);
2675
+ for (const r of reports) try {
2676
+ removeOrphanTabIds(r);
2677
+ consola.success(`Cleaned "${r.workspaceName}" [${r.workspaceId.slice(0, 8)}] — removed ${r.orphanTabIds.length} orphan tabid(s)`);
2678
+ } catch (err) {
2679
+ consola.error(`Failed to clean ${r.workspaceId}: ${err.message}`);
2680
+ consola.info(`Restore from backup if anything looks wrong: cp ${JSON.stringify(backup)} ${JSON.stringify(WAVE_DB_PATH)}`);
2681
+ process.exit(1);
2682
+ }
2683
+ consola.success("All orphan tabids removed. Start Wave Terminal again and re-run `cctabs sessions`.");
2684
+ }
2685
+ const doctorCommand = define({
2686
+ name: "doctor",
2687
+ description: "Run environment checks (terminal detection, plugin reachability, Wave DB orphan scan, Wave Accessibility) and offer fixes for known problems.",
2688
+ args: {
2689
+ yes: {
2690
+ type: "boolean",
2691
+ short: "y",
2692
+ description: "Apply the fix without an interactive confirmation prompt"
2693
+ },
2694
+ fix: {
2695
+ type: "boolean",
2696
+ description: "Apply available fixes (currently: Wave DB orphan-tabids)"
2697
+ }
2698
+ },
2699
+ async run(ctx) {
2700
+ const yes = Boolean(ctx.values.yes);
2701
+ const fix = Boolean(ctx.values.fix) || yes;
2702
+ const terminal = detectTerminal();
2703
+ console.log("cctabs doctor — environment checks");
2704
+ console.log("─".repeat(40));
2705
+ const results = [];
2706
+ let waveDb = null;
2707
+ results.push(checkTerminal(terminal));
2708
+ if (terminal === "tabby") results.push(checkTabbyPlugin());
2709
+ if (terminal === "wave") {
2710
+ results.push(checkWaveAccessibility());
2711
+ waveDb = checkWaveDb();
2712
+ results.push(waveDb.result);
2713
+ } else if (existsSync(WAVE_DB_PATH)) {
2714
+ waveDb = checkWaveDb();
2715
+ results.push({
2716
+ ...waveDb.result,
2717
+ name: "Wave DB orphan-tabid scan (offline)"
2718
+ });
2719
+ }
2720
+ for (const r of results) printResult(r);
2721
+ const failed = results.some((r) => r.status === "fail");
2722
+ const orphans = waveDb?.reports ?? [];
2723
+ if (orphans.length && fix) await fixWaveDb(orphans, yes);
2724
+ else if (orphans.length) {
2725
+ console.log("");
2726
+ consola.info("Re-run with `cctabs doctor --fix` to clean up orphan tabids (a DB backup is made first).");
2727
+ }
2728
+ if (failed) process.exit(1);
2729
+ }
2730
+ });
2731
+ //#endregion
2732
+ //#region src/commands/install-tabby-plugin.ts
2733
+ function pluginsDir() {
2734
+ if (platform() === "darwin") return join(homedir(), "Library", "Application Support", "tabby", "plugins");
2735
+ if (platform() === "linux") return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "tabby", "plugins");
2736
+ if (platform() === "win32") return join(process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"), "tabby", "plugins");
2737
+ throw new Error(`unsupported platform: ${platform()}`);
2738
+ }
2739
+ function shellQuote(s) {
2740
+ return `'${s.replace(/'/g, `'\\''`)}'`;
2741
+ }
2742
+ const installTabbyPluginCommand = define({
2743
+ name: "install-tabby-plugin",
2744
+ 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.",
2745
+ args: {
2746
+ yes: {
2747
+ type: "boolean",
2748
+ short: "y",
2749
+ description: "Skip the \"this will restart Tabby\" confirmation"
2750
+ },
2751
+ "no-restart": {
2752
+ type: "boolean",
2753
+ description: "Install the plugin only; do not quit Tabby. You restart it yourself."
2754
+ }
2755
+ },
2756
+ async run(ctx) {
2757
+ const yes = Boolean(ctx.values.yes);
2758
+ const noRestart = Boolean(ctx.values["no-restart"]);
2759
+ if (detectTerminal() !== "tabby") {
2760
+ consola.error("cctabs install-tabby-plugin must be run from inside a Tabby tab.");
2761
+ consola.info("On Wave Terminal you don't need this — Wave works without a plugin.");
2762
+ process.exit(1);
2763
+ }
2764
+ if (platform() !== "darwin" && platform() !== "linux") {
2765
+ consola.error(`Auto-restart isn't implemented for ${platform()} yet. Run the manual install snippet from \`cctabs doctor\` and restart Tabby yourself.`);
2766
+ process.exit(1);
2767
+ }
2768
+ const dir = pluginsDir();
2769
+ consola.info(`Tabby plugins dir: ${dir}`);
2770
+ mkdirSync(dir, { recursive: true });
2771
+ const pkgPath = join(dir, "package.json");
2772
+ if (!existsSync(pkgPath)) writeFileSync(pkgPath, "{\"private\":true}\n");
2773
+ consola.info("Installing tabby-cctabs from npm…");
2774
+ const npm = spawnSync("npm", [
2775
+ "install",
2776
+ "--legacy-peer-deps",
2777
+ "--silent",
2778
+ "--prefix",
2779
+ dir,
2780
+ "tabby-cctabs"
2781
+ ], { stdio: "inherit" });
2782
+ if (npm.status !== 0) {
2783
+ consola.error("npm install failed. Bail out before touching Tabby.");
2784
+ process.exit(npm.status ?? 1);
2785
+ }
2786
+ consola.success("Plugin installed.");
2787
+ if (noRestart) {
2788
+ consola.info("Skipping restart (--no-restart). Quit and reopen Tabby manually, then run `cctabs doctor`.");
2789
+ return;
2790
+ }
2791
+ const cwd = process.cwd();
2792
+ const sessionId = findLatestSessionId(cwd);
2793
+ 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.`);
2794
+ else consola.info(`Will resume session ${sessionId.slice(0, 8)}… via --fork-session after restart.`);
2795
+ if (!yes) {
2796
+ consola.warn("About to quit Tabby and reopen it. ALL Tabby tabs will close.");
2797
+ consola.warn("Tabby's session recovery may or may not restore other tabs.");
2798
+ consola.info("Re-run with --yes to suppress this prompt.");
2799
+ if (!await consola.prompt("Proceed?", {
2800
+ type: "confirm",
2801
+ initial: false
2802
+ })) {
2803
+ consola.info("Aborted.");
2804
+ return;
2805
+ }
2806
+ }
2807
+ const claudeBin = (spawnSync("which", ["claude"], { encoding: "utf-8" }).stdout || "").trim() || "claude";
2808
+ const stamp = Date.now();
2809
+ const restartScript = join("/tmp", `cctabs-tabby-restart-${stamp}.sh`);
2810
+ const launcherScript = join("/tmp", `cctabs-tabby-launcher-${stamp}.sh`);
2811
+ writeFileSync(launcherScript, sessionId ? `#!/bin/zsh -l
2812
+ cd ${shellQuote(cwd)} || exit 1
2813
+ exec ${claudeBin} --resume ${sessionId} --fork-session
2814
+ ` : `#!/bin/zsh -l
2815
+ cd ${shellQuote(cwd)} || exit 1
2816
+ exec /bin/zsh -l
2817
+ `);
2818
+ chmodSync(launcherScript, 493);
2819
+ const isDarwin = platform() === "darwin";
2820
+ writeFileSync(restartScript, `#!/usr/bin/env bash
2821
+ set -e
2822
+ exec >/tmp/cctabs-tabby-restart-${stamp}.log 2>&1
2823
+ echo "[$(date)] sleeping 2s before quitting Tabby"
2824
+ sleep 2
2825
+ echo "[$(date)] quitting Tabby"
2826
+ ${isDarwin ? `osascript -e 'tell application "Tabby" to quit' >/dev/null 2>&1 || true` : `pkill -TERM -f Tabby || true`}
2827
+ echo "[$(date)] waiting for Tabby to exit"
2828
+ for i in \$(seq 1 30); do pgrep -f "Tabby" >/dev/null || break; sleep 0.5; done
2829
+ echo "[$(date)] reopening Tabby"
2830
+ ${isDarwin ? `open -a Tabby` : `nohup tabby >/dev/null 2>&1 &`}
2831
+ sleep 5
2832
+ echo "[$(date)] activating + launching resume tab"
2833
+ ${isDarwin ? `osascript -e 'tell application "Tabby" to activate' >/dev/null 2>&1 || true` : `:`}
2834
+ sleep 1
2835
+ ${isDarwin ? `/Applications/Tabby.app/Contents/MacOS/Tabby` : `tabby`} run ${shellQuote(launcherScript)}
2836
+ echo "[$(date)] done"
2837
+ `);
2838
+ chmodSync(restartScript, 493);
2839
+ spawn("/bin/bash", [restartScript], {
2840
+ detached: true,
2841
+ stdio: "ignore"
2842
+ }).unref();
2843
+ consola.success("Restart worker dispatched.");
2844
+ consola.info(`Logs: /tmp/cctabs-tabby-restart-${stamp}.log`);
2845
+ consola.info("Tabby will quit in ~2 seconds. After it reopens, your session resumes via --fork-session in a new tab.");
2846
+ }
2847
+ });
2848
+ //#endregion
1757
2849
  //#region src/commands/index.ts
1758
2850
  const defaultCommand = define({
1759
2851
  name: "cctabs",
@@ -1776,14 +2868,17 @@ const subCommands = new Map([
1776
2868
  ["send", sendCommand],
1777
2869
  ["config", configCommand],
1778
2870
  ["restore", restoreCommand],
1779
- ["backends", backendsCommand]
2871
+ ["backends", backendsCommand],
2872
+ ["doctor", doctorCommand],
2873
+ ["install-tabby-plugin", installTabbyPluginCommand]
1780
2874
  ]);
1781
2875
  async function run() {
1782
2876
  await cli(process.argv.slice(2), defaultCommand, {
1783
2877
  name,
1784
2878
  version,
1785
2879
  description,
1786
- subCommands
2880
+ subCommands,
2881
+ renderHeader: null
1787
2882
  });
1788
2883
  }
1789
2884
  //#endregion