@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.
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +788 -33
- package/package.json +3 -1
- package/skills/cctabs/SKILL.md +67 -7
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cctabs",
|
|
3
3
|
"description": "Claude Code tab manager. Terminal tabs as the UI, no tmux. Claude can orchestrate its own sibling sessions.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"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
|
|
11
|
+
import * as p from "@clack/prompts";
|
|
11
12
|
//#region package.json
|
|
12
13
|
var name = "@generativereality/cctabs";
|
|
13
|
-
var version = "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
|
-
|
|
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
|
|
395
|
-
|
|
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 =
|
|
828
|
+
const adapter = requireAdapter();
|
|
412
829
|
const { tabsById, workspaces, tabNames } = await adapter.getAllData();
|
|
413
|
-
const currentTab =
|
|
414
|
-
const currentWs =
|
|
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
|
|
450
|
-
const
|
|
451
|
-
const
|
|
452
|
-
const
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
2025
|
+
const adapter = requireAdapter();
|
|
1608
2026
|
const { tabsById, workspaces, tabNames } = await adapter.getAllData();
|
|
1609
|
-
const currentTab =
|
|
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.
|
|
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": [
|
package/skills/cctabs/SKILL.md
CHANGED
|
@@ -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** (
|
|
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
|
|
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
|
|
323
|
-
-
|
|
324
|
-
- The internal timeout may be slightly too short for the current system load
|
|
325
|
-
- Transient IPC timing issue between cctabs and
|
|
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
|
|
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
|