@generativereality/cctabs 0.1.4 → 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 +1055 -42
- package/package.json +3 -1
- package/skills/cctabs/SKILL.md +133 -7
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)" : "";
|
|
@@ -540,6 +958,11 @@ function ensureConfigExists() {
|
|
|
540
958
|
}
|
|
541
959
|
//#endregion
|
|
542
960
|
//#region src/core/open-session.ts
|
|
961
|
+
function shellQuoteEnv$1(env) {
|
|
962
|
+
const entries = Object.entries(env);
|
|
963
|
+
if (!entries.length) return "";
|
|
964
|
+
return entries.map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(" ") + " ";
|
|
965
|
+
}
|
|
543
966
|
/** Poll scrollback until a pattern is visible, then return. Rejects on timeout. */
|
|
544
967
|
async function waitForScrollbackMatch(adapter, blockId, pattern, label, timeoutMs, pollInterval = 1e3) {
|
|
545
968
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -554,14 +977,14 @@ async function waitForScrollbackMatch(adapter, blockId, pattern, label, timeoutM
|
|
|
554
977
|
throw new Error(`Timed out waiting for ${label}`);
|
|
555
978
|
}
|
|
556
979
|
async function openSession(opts) {
|
|
557
|
-
const { tabName, claudeCmd, workspaceQuery, initialPromptFile } = opts;
|
|
980
|
+
const { tabName, claudeCmd, workspaceQuery, initialPromptFile, envVars, modelOverride } = opts;
|
|
558
981
|
const dir = resolve(opts.dir.replace(/^~/, homedir()));
|
|
559
982
|
if (!existsSync(dir)) {
|
|
560
983
|
consola.error(`Directory does not exist: ${dir}`);
|
|
561
984
|
process.exit(1);
|
|
562
985
|
}
|
|
563
986
|
const config = loadConfig();
|
|
564
|
-
const adapter =
|
|
987
|
+
const adapter = requireAdapter();
|
|
565
988
|
let focusWindowId;
|
|
566
989
|
if (workspaceQuery) {
|
|
567
990
|
const { workspaces } = await adapter.getAllData();
|
|
@@ -595,7 +1018,9 @@ async function openSession(opts) {
|
|
|
595
1018
|
}
|
|
596
1019
|
const extraFlags = config.claude.flags.join(" ");
|
|
597
1020
|
const namePart = claudeCmd.includes("--resume") ? "" : ` --name ${JSON.stringify(tabName)}`;
|
|
598
|
-
const
|
|
1021
|
+
const modelPart = modelOverride ? ` --model ${JSON.stringify(modelOverride)}` : "";
|
|
1022
|
+
const envPrefix = envVars ? shellQuoteEnv$1(envVars) : "";
|
|
1023
|
+
const cmd = `cd ${JSON.stringify(dir)} && ${envPrefix}claude${extraFlags ? " " + extraFlags : ""} ${claudeCmd.replace(/^claude\s*/, "")}${namePart}${modelPart}\r`;
|
|
599
1024
|
await adapter.sendInput(blockId, cmd);
|
|
600
1025
|
if (initialPromptFile) {
|
|
601
1026
|
try {
|
|
@@ -615,6 +1040,179 @@ async function openSession(opts) {
|
|
|
615
1040
|
return tabId;
|
|
616
1041
|
}
|
|
617
1042
|
//#endregion
|
|
1043
|
+
//#region src/core/backends.ts
|
|
1044
|
+
/**
|
|
1045
|
+
* Backend presets. Each preset resolves to a set of env vars (prepended to the
|
|
1046
|
+
* shell command in the new tab) plus a Claude --model name.
|
|
1047
|
+
*
|
|
1048
|
+
* The default `anthropic` preset is a no-op: no env vars, no --model override —
|
|
1049
|
+
* Claude Code uses its built-in API connection.
|
|
1050
|
+
*
|
|
1051
|
+
* Ollama-backed presets point ANTHROPIC_BASE_URL at Ollama's
|
|
1052
|
+
* Anthropic-compatible /v1/messages endpoint (Ollama ≥ 0.14):
|
|
1053
|
+
* https://docs.ollama.com/openai
|
|
1054
|
+
*
|
|
1055
|
+
* The `*-tee` variants route through the local logging proxy on :11500
|
|
1056
|
+
* (`npm run ollama-tee` in the motin-scripts repo) for wire-level inspection.
|
|
1057
|
+
*/
|
|
1058
|
+
const OLLAMA_LOCAL = "http://localhost:11434";
|
|
1059
|
+
const OLLAMA_TEE = "http://localhost:11500";
|
|
1060
|
+
/**
|
|
1061
|
+
* For Ollama-backed Claude Code sessions we pin the small/fast/haiku model to
|
|
1062
|
+
* the same model. Otherwise Claude Code's background "haiku" calls 404 against
|
|
1063
|
+
* Ollama because the haiku tag doesn't exist there.
|
|
1064
|
+
*/
|
|
1065
|
+
function ollamaEnv(baseUrl, model) {
|
|
1066
|
+
return {
|
|
1067
|
+
ANTHROPIC_BASE_URL: baseUrl,
|
|
1068
|
+
ANTHROPIC_AUTH_TOKEN: "ollama",
|
|
1069
|
+
ANTHROPIC_API_KEY: "",
|
|
1070
|
+
ANTHROPIC_SMALL_FAST_MODEL: model,
|
|
1071
|
+
ANTHROPIC_DEFAULT_HAIKU_MODEL: model
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
const BUILTIN_BACKENDS = {
|
|
1075
|
+
anthropic: {
|
|
1076
|
+
env: {},
|
|
1077
|
+
model: "",
|
|
1078
|
+
description: "Default Anthropic API (no override)"
|
|
1079
|
+
},
|
|
1080
|
+
kimi: {
|
|
1081
|
+
env: ollamaEnv(OLLAMA_LOCAL, "kimi-k2.6:cloud"),
|
|
1082
|
+
model: "kimi-k2.6:cloud",
|
|
1083
|
+
description: "Kimi K2.6 via Ollama Cloud (Pro)"
|
|
1084
|
+
},
|
|
1085
|
+
"qwen-cloud": {
|
|
1086
|
+
env: ollamaEnv(OLLAMA_LOCAL, "qwen3-coder-next:cloud"),
|
|
1087
|
+
model: "qwen3-coder-next:cloud",
|
|
1088
|
+
description: "Qwen3 Coder Next via Ollama Cloud"
|
|
1089
|
+
},
|
|
1090
|
+
"gemma-cloud": {
|
|
1091
|
+
env: ollamaEnv(OLLAMA_LOCAL, "gemma4:31b-cloud"),
|
|
1092
|
+
model: "gemma4:31b-cloud",
|
|
1093
|
+
description: "Gemma4 31B via Ollama Cloud"
|
|
1094
|
+
},
|
|
1095
|
+
"qwen-local": {
|
|
1096
|
+
env: ollamaEnv(OLLAMA_LOCAL, "qwen3-coder:30b"),
|
|
1097
|
+
model: "qwen3-coder:30b",
|
|
1098
|
+
description: "Qwen3 Coder 30B local (18GB)"
|
|
1099
|
+
},
|
|
1100
|
+
"qwen-next-local": {
|
|
1101
|
+
env: ollamaEnv(OLLAMA_LOCAL, "qwen3-coder-next:q3_K_M"),
|
|
1102
|
+
model: "qwen3-coder-next:q3_K_M",
|
|
1103
|
+
description: "Qwen3 Coder Next Q3_K_M local (38GB) — needs `ollama create` import"
|
|
1104
|
+
},
|
|
1105
|
+
"gpt-oss": {
|
|
1106
|
+
env: ollamaEnv(OLLAMA_LOCAL, "gpt-oss:20b"),
|
|
1107
|
+
model: "gpt-oss:20b",
|
|
1108
|
+
description: "gpt-oss 20B local (13GB)"
|
|
1109
|
+
},
|
|
1110
|
+
llama: {
|
|
1111
|
+
env: ollamaEnv(OLLAMA_LOCAL, "llama3.1:8b"),
|
|
1112
|
+
model: "llama3.1:8b",
|
|
1113
|
+
description: "Llama 3.1 8B local (5GB) — note: garbles on Claude Code's 50k system prompt"
|
|
1114
|
+
},
|
|
1115
|
+
"gemma-local": {
|
|
1116
|
+
env: ollamaEnv(OLLAMA_LOCAL, "gemma4:26b"),
|
|
1117
|
+
model: "gemma4:26b",
|
|
1118
|
+
description: "Gemma4 26B local (17GB)"
|
|
1119
|
+
},
|
|
1120
|
+
"kimi-tee": {
|
|
1121
|
+
env: ollamaEnv(OLLAMA_TEE, "kimi-k2.6:cloud"),
|
|
1122
|
+
model: "kimi-k2.6:cloud",
|
|
1123
|
+
description: "Kimi via tee proxy (logs to /tmp/ollama-tee.log)"
|
|
1124
|
+
},
|
|
1125
|
+
"qwen-cloud-tee": {
|
|
1126
|
+
env: ollamaEnv(OLLAMA_TEE, "qwen3-coder-next:cloud"),
|
|
1127
|
+
model: "qwen3-coder-next:cloud",
|
|
1128
|
+
description: "Qwen Cloud via tee proxy"
|
|
1129
|
+
},
|
|
1130
|
+
"qwen-next-local-tee": {
|
|
1131
|
+
env: ollamaEnv(OLLAMA_TEE, "qwen3-coder-next:q3_K_M"),
|
|
1132
|
+
model: "qwen3-coder-next:q3_K_M",
|
|
1133
|
+
description: "Qwen Next local Q3 via tee proxy"
|
|
1134
|
+
}
|
|
1135
|
+
};
|
|
1136
|
+
/**
|
|
1137
|
+
* Parse a `[backends.<name>]` section from the config TOML. Each section can
|
|
1138
|
+
* override env vars and/or model. Format:
|
|
1139
|
+
*
|
|
1140
|
+
* [backends.my-preset]
|
|
1141
|
+
* model = "qwen3-coder-next:cloud"
|
|
1142
|
+
* base_url = "http://localhost:11434"
|
|
1143
|
+
* auth_token = "ollama" # optional, defaults to "ollama" if base_url is set
|
|
1144
|
+
*
|
|
1145
|
+
* Or for full control:
|
|
1146
|
+
*
|
|
1147
|
+
* [backends.my-preset]
|
|
1148
|
+
* model = "..."
|
|
1149
|
+
* env_ANTHROPIC_BASE_URL = "..."
|
|
1150
|
+
* env_ANTHROPIC_AUTH_TOKEN = "..."
|
|
1151
|
+
*/
|
|
1152
|
+
function loadCustomBackends() {
|
|
1153
|
+
if (!existsSync(CONFIG_PATH)) return {};
|
|
1154
|
+
const text = readFileSync(CONFIG_PATH, "utf-8");
|
|
1155
|
+
const sections = {};
|
|
1156
|
+
let section = null;
|
|
1157
|
+
for (const raw of text.split("\n")) {
|
|
1158
|
+
const line = raw.trim();
|
|
1159
|
+
if (!line || line.startsWith("#")) continue;
|
|
1160
|
+
if (line.startsWith("[") && line.endsWith("]")) {
|
|
1161
|
+
section = line.slice(1, -1).trim();
|
|
1162
|
+
sections[section] ??= {};
|
|
1163
|
+
continue;
|
|
1164
|
+
}
|
|
1165
|
+
if (section?.startsWith("backends.") && line.includes("=")) {
|
|
1166
|
+
const [rawKey, ...rest] = line.split("=");
|
|
1167
|
+
const key = rawKey.trim();
|
|
1168
|
+
const val = rest.join("=").trim();
|
|
1169
|
+
if (val.startsWith("\"") && val.endsWith("\"")) sections[section][key] = val.slice(1, -1);
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
const result = {};
|
|
1173
|
+
for (const [section, kv] of Object.entries(sections)) {
|
|
1174
|
+
if (!section.startsWith("backends.")) continue;
|
|
1175
|
+
const name = section.slice(9);
|
|
1176
|
+
const model = kv.model ?? "";
|
|
1177
|
+
const env = {};
|
|
1178
|
+
if (kv.base_url) {
|
|
1179
|
+
const baseUrl = kv.base_url;
|
|
1180
|
+
const token = kv.auth_token ?? "ollama";
|
|
1181
|
+
Object.assign(env, {
|
|
1182
|
+
ANTHROPIC_BASE_URL: baseUrl,
|
|
1183
|
+
ANTHROPIC_AUTH_TOKEN: token,
|
|
1184
|
+
ANTHROPIC_API_KEY: ""
|
|
1185
|
+
});
|
|
1186
|
+
if (model) {
|
|
1187
|
+
env.ANTHROPIC_SMALL_FAST_MODEL = model;
|
|
1188
|
+
env.ANTHROPIC_DEFAULT_HAIKU_MODEL = model;
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
for (const [k, v] of Object.entries(kv)) if (k.startsWith("env_")) env[k.slice(4)] = v;
|
|
1192
|
+
result[name] = {
|
|
1193
|
+
env,
|
|
1194
|
+
model,
|
|
1195
|
+
description: kv.description ?? `User-defined preset (${CONFIG_PATH})`
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
return result;
|
|
1199
|
+
}
|
|
1200
|
+
function resolveBackend(name) {
|
|
1201
|
+
if (!name) return null;
|
|
1202
|
+
return loadCustomBackends()[name] ?? BUILTIN_BACKENDS[name] ?? null;
|
|
1203
|
+
}
|
|
1204
|
+
function listBackends() {
|
|
1205
|
+
const custom = loadCustomBackends();
|
|
1206
|
+
const merged = {
|
|
1207
|
+
...BUILTIN_BACKENDS,
|
|
1208
|
+
...custom
|
|
1209
|
+
};
|
|
1210
|
+
return Object.entries(merged).map(([name, spec]) => ({
|
|
1211
|
+
name,
|
|
1212
|
+
description: spec.description ?? ""
|
|
1213
|
+
}));
|
|
1214
|
+
}
|
|
1215
|
+
//#endregion
|
|
618
1216
|
//#region src/commands/new.ts
|
|
619
1217
|
const newCommand = define({
|
|
620
1218
|
name: "new",
|
|
@@ -647,6 +1245,16 @@ const newCommand = define({
|
|
|
647
1245
|
type: "string",
|
|
648
1246
|
short: "p",
|
|
649
1247
|
description: "Send initial prompt text once Claude is ready"
|
|
1248
|
+
},
|
|
1249
|
+
backend: {
|
|
1250
|
+
type: "string",
|
|
1251
|
+
short: "b",
|
|
1252
|
+
description: "Backend preset (e.g. kimi, qwen-cloud, qwen-next-local, gpt-oss). Run `cctabs backends` to list."
|
|
1253
|
+
},
|
|
1254
|
+
model: {
|
|
1255
|
+
type: "string",
|
|
1256
|
+
short: "m",
|
|
1257
|
+
description: "Override the model name (passed as --model to claude). Useful with --backend ollama-local."
|
|
650
1258
|
}
|
|
651
1259
|
},
|
|
652
1260
|
async run(ctx) {
|
|
@@ -656,10 +1264,24 @@ const newCommand = define({
|
|
|
656
1264
|
const useWorktree = ctx.values.worktree ?? false;
|
|
657
1265
|
const promptFile = ctx.values.file;
|
|
658
1266
|
const promptText = ctx.values.prompt;
|
|
1267
|
+
const backendName = ctx.values.backend;
|
|
1268
|
+
const modelOverride = ctx.values.model;
|
|
659
1269
|
if (!name) {
|
|
660
1270
|
consola.error("Tab name is required");
|
|
661
1271
|
process.exit(1);
|
|
662
1272
|
}
|
|
1273
|
+
let envVars;
|
|
1274
|
+
let resolvedModel = modelOverride;
|
|
1275
|
+
if (backendName) {
|
|
1276
|
+
const backend = resolveBackend(backendName);
|
|
1277
|
+
if (!backend) {
|
|
1278
|
+
consola.error(`Unknown backend "${backendName}". Available:`);
|
|
1279
|
+
for (const b of listBackends()) consola.log(` ${b.name.padEnd(22)} ${b.description}`);
|
|
1280
|
+
process.exit(1);
|
|
1281
|
+
}
|
|
1282
|
+
envVars = backend.env;
|
|
1283
|
+
resolvedModel ??= backend.model || void 0;
|
|
1284
|
+
}
|
|
663
1285
|
let initialPromptFile;
|
|
664
1286
|
if (promptText) {
|
|
665
1287
|
initialPromptFile = join(tmpdir(), `cctabs-prompt-${Date.now()}.txt`);
|
|
@@ -670,10 +1292,13 @@ const newCommand = define({
|
|
|
670
1292
|
dir,
|
|
671
1293
|
claudeCmd: useWorktree ? `claude --worktree ${JSON.stringify(name)}` : "claude",
|
|
672
1294
|
workspaceQuery: workspace,
|
|
673
|
-
initialPromptFile
|
|
1295
|
+
initialPromptFile,
|
|
1296
|
+
envVars,
|
|
1297
|
+
modelOverride: resolvedModel
|
|
674
1298
|
});
|
|
675
|
-
const
|
|
676
|
-
|
|
1299
|
+
const wt = useWorktree ? ` (worktree: .claude/worktrees/${name})` : "";
|
|
1300
|
+
const be = backendName ? ` [backend: ${backendName}${resolvedModel ? ` → ${resolvedModel}` : ""}]` : "";
|
|
1301
|
+
consola.success(`Tab "${name}" [${tabId.slice(0, 8)}] → claude at ${dir}${wt}${be}`);
|
|
677
1302
|
}
|
|
678
1303
|
});
|
|
679
1304
|
//#endregion
|
|
@@ -904,6 +1529,11 @@ function expandSessionId(input, dir) {
|
|
|
904
1529
|
}
|
|
905
1530
|
//#endregion
|
|
906
1531
|
//#region src/commands/resume.ts
|
|
1532
|
+
function shellQuoteEnv(env) {
|
|
1533
|
+
const entries = Object.entries(env);
|
|
1534
|
+
if (!entries.length) return "";
|
|
1535
|
+
return entries.map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(" ") + " ";
|
|
1536
|
+
}
|
|
907
1537
|
function formatAge(mtimeMs) {
|
|
908
1538
|
const mins = Math.round((Date.now() - mtimeMs) / 6e4);
|
|
909
1539
|
if (mins < 60) return `${mins}m ago`;
|
|
@@ -932,6 +1562,16 @@ const resumeCommand = define({
|
|
|
932
1562
|
type: "string",
|
|
933
1563
|
short: "s",
|
|
934
1564
|
description: "Session ID to resume (use when multiple sessions share the same name)"
|
|
1565
|
+
},
|
|
1566
|
+
backend: {
|
|
1567
|
+
type: "string",
|
|
1568
|
+
short: "b",
|
|
1569
|
+
description: "Backend preset (e.g. kimi, qwen-cloud, qwen-next-local). Run `cctabs backends` to list."
|
|
1570
|
+
},
|
|
1571
|
+
model: {
|
|
1572
|
+
type: "string",
|
|
1573
|
+
short: "m",
|
|
1574
|
+
description: "Override the model name (passed as --model to claude)."
|
|
935
1575
|
}
|
|
936
1576
|
},
|
|
937
1577
|
async run(ctx) {
|
|
@@ -942,6 +1582,20 @@ const resumeCommand = define({
|
|
|
942
1582
|
process.exit(1);
|
|
943
1583
|
}
|
|
944
1584
|
const explicitSession = ctx.values.session;
|
|
1585
|
+
const backendName = ctx.values.backend;
|
|
1586
|
+
const modelOverride = ctx.values.model;
|
|
1587
|
+
let envVars;
|
|
1588
|
+
let resolvedModel = modelOverride;
|
|
1589
|
+
if (backendName) {
|
|
1590
|
+
const backend = resolveBackend(backendName);
|
|
1591
|
+
if (!backend) {
|
|
1592
|
+
consola.error(`Unknown backend "${backendName}". Available:`);
|
|
1593
|
+
for (const b of listBackends()) consola.log(` ${b.name.padEnd(22)} ${b.description}`);
|
|
1594
|
+
process.exit(1);
|
|
1595
|
+
}
|
|
1596
|
+
envVars = backend.env;
|
|
1597
|
+
resolvedModel ??= backend.model || void 0;
|
|
1598
|
+
}
|
|
945
1599
|
let sessionId;
|
|
946
1600
|
if (explicitSession) {
|
|
947
1601
|
const expanded = expandSessionId(explicitSession, dir) ?? expandSessionId(explicitSession);
|
|
@@ -971,7 +1625,7 @@ const resumeCommand = define({
|
|
|
971
1625
|
process.exit(1);
|
|
972
1626
|
}
|
|
973
1627
|
}
|
|
974
|
-
const adapter =
|
|
1628
|
+
const adapter = requireAdapter();
|
|
975
1629
|
const { tabsById, tabNames } = await adapter.getAllData();
|
|
976
1630
|
const matchingTabs = adapter.resolveTab(name, tabsById, tabNames);
|
|
977
1631
|
if (matchingTabs.length > 1) {
|
|
@@ -1005,14 +1659,18 @@ const resumeCommand = define({
|
|
|
1005
1659
|
const newTabId = await openSession({
|
|
1006
1660
|
tabName: name,
|
|
1007
1661
|
dir,
|
|
1008
|
-
claudeCmd: `claude --resume ${sessionId} --name ${JSON.stringify(name)}
|
|
1662
|
+
claudeCmd: `claude --resume ${sessionId} --name ${JSON.stringify(name)}`,
|
|
1663
|
+
envVars,
|
|
1664
|
+
modelOverride: resolvedModel
|
|
1009
1665
|
});
|
|
1010
1666
|
consola.success(`Tab "${name}" [${newTabId.slice(0, 8)}] → claude --resume ${sessionId.slice(0, 8)}… at ${dir} (recreated)`);
|
|
1011
1667
|
return;
|
|
1012
1668
|
}
|
|
1013
1669
|
}
|
|
1014
1670
|
const extraFlags = loadConfig().claude.flags.join(" ");
|
|
1015
|
-
const
|
|
1671
|
+
const envPrefix = envVars ? shellQuoteEnv(envVars) : "";
|
|
1672
|
+
const modelPart = resolvedModel ? ` --model ${JSON.stringify(resolvedModel)}` : "";
|
|
1673
|
+
const cmd = `cd ${JSON.stringify(dir)} && ${envPrefix}claude${extraFlags ? " " + extraFlags : ""} --resume ${sessionId} --name ${JSON.stringify(name)}${modelPart}\r`;
|
|
1016
1674
|
await adapter.sendInput(termBlock.blockid, cmd);
|
|
1017
1675
|
let verified = false;
|
|
1018
1676
|
const deadline = Date.now() + 15e3;
|
|
@@ -1032,7 +1690,9 @@ const resumeCommand = define({
|
|
|
1032
1690
|
const tabId = await openSession({
|
|
1033
1691
|
tabName: name,
|
|
1034
1692
|
dir,
|
|
1035
|
-
claudeCmd: `claude --resume ${sessionId} --name ${JSON.stringify(name)}
|
|
1693
|
+
claudeCmd: `claude --resume ${sessionId} --name ${JSON.stringify(name)}`,
|
|
1694
|
+
envVars,
|
|
1695
|
+
modelOverride: resolvedModel
|
|
1036
1696
|
});
|
|
1037
1697
|
consola.success(`Tab "${name}" [${tabId.slice(0, 8)}] → claude --resume ${sessionId.slice(0, 8)}… at ${dir} (new tab)`);
|
|
1038
1698
|
} else {
|
|
@@ -1040,7 +1700,9 @@ const resumeCommand = define({
|
|
|
1040
1700
|
const tabId = await openSession({
|
|
1041
1701
|
tabName: name,
|
|
1042
1702
|
dir,
|
|
1043
|
-
claudeCmd: "claude"
|
|
1703
|
+
claudeCmd: "claude",
|
|
1704
|
+
envVars,
|
|
1705
|
+
modelOverride: resolvedModel
|
|
1044
1706
|
});
|
|
1045
1707
|
consola.success(`Tab "${name}" [${tabId.slice(0, 8)}] → claude at ${dir} (new tab, no prior session found)`);
|
|
1046
1708
|
}
|
|
@@ -1085,7 +1747,7 @@ const forkCommand = define({
|
|
|
1085
1747
|
consola.error("Source tab name is required");
|
|
1086
1748
|
process.exit(1);
|
|
1087
1749
|
}
|
|
1088
|
-
const adapter =
|
|
1750
|
+
const adapter = requireAdapter();
|
|
1089
1751
|
const { tabsById, tabNames } = await adapter.getAllData();
|
|
1090
1752
|
const matches = adapter.resolveTab(sourceQuery, tabsById, tabNames);
|
|
1091
1753
|
if (!matches.length) {
|
|
@@ -1136,7 +1798,7 @@ const closeCommand = define({
|
|
|
1136
1798
|
consola.error("Tab name or ID is required");
|
|
1137
1799
|
process.exit(1);
|
|
1138
1800
|
}
|
|
1139
|
-
const adapter =
|
|
1801
|
+
const adapter = requireAdapter();
|
|
1140
1802
|
const { tabsById, tabNames } = await adapter.getAllData();
|
|
1141
1803
|
const matches = adapter.resolveTab(query, tabsById, tabNames);
|
|
1142
1804
|
if (!matches.length) {
|
|
@@ -1177,7 +1839,7 @@ const renameCommand = define({
|
|
|
1177
1839
|
consola.error("Usage: cctabs rename <tab> <new-name>");
|
|
1178
1840
|
process.exit(1);
|
|
1179
1841
|
}
|
|
1180
|
-
const adapter =
|
|
1842
|
+
const adapter = requireAdapter();
|
|
1181
1843
|
const { tabsById, tabNames } = await adapter.getAllData();
|
|
1182
1844
|
const matches = adapter.resolveTab(query, tabsById, tabNames);
|
|
1183
1845
|
if (!matches.length) {
|
|
@@ -1218,7 +1880,7 @@ const scrollbackCommand = define({
|
|
|
1218
1880
|
consola.error("Tab name or block ID is required");
|
|
1219
1881
|
process.exit(1);
|
|
1220
1882
|
}
|
|
1221
|
-
const adapter =
|
|
1883
|
+
const adapter = requireAdapter();
|
|
1222
1884
|
const { tabsById, tabNames } = await adapter.getAllData();
|
|
1223
1885
|
const tabMatches = adapter.resolveTab(query, tabsById, tabNames);
|
|
1224
1886
|
let blockId;
|
|
@@ -1292,7 +1954,7 @@ const sendCommand = define({
|
|
|
1292
1954
|
else if (filePath) rawText = readFileSync(filePath, "utf-8").replace(/\n/g, "\r");
|
|
1293
1955
|
else rawText = (await readStdin()).replace(/\n/g, "\r");
|
|
1294
1956
|
if (appendEnter && !rawText.endsWith("\r")) rawText += "\r";
|
|
1295
|
-
const adapter =
|
|
1957
|
+
const adapter = requireAdapter();
|
|
1296
1958
|
const { tabsById, tabNames } = await adapter.getAllData();
|
|
1297
1959
|
const tabMatches = adapter.resolveTab(query, tabsById, tabNames);
|
|
1298
1960
|
let blockId;
|
|
@@ -1360,9 +2022,9 @@ const restoreCommand = define({
|
|
|
1360
2022
|
const rawDir = ctx.positionals[1];
|
|
1361
2023
|
const scopedDir = rawDir ? resolve(rawDir.replace(/^~/, homedir())) : null;
|
|
1362
2024
|
const dryRun = ctx.values.dry;
|
|
1363
|
-
const adapter =
|
|
2025
|
+
const adapter = requireAdapter();
|
|
1364
2026
|
const { tabsById, workspaces, tabNames } = await adapter.getAllData();
|
|
1365
|
-
const currentTab =
|
|
2027
|
+
const currentTab = adapter.currentTabId();
|
|
1366
2028
|
const tabs = [];
|
|
1367
2029
|
for (const wsp of workspaces) for (const tabId of wsp.workspacedata.tabids) {
|
|
1368
2030
|
if (tabId === currentTab) continue;
|
|
@@ -1497,6 +2159,354 @@ const restoreCommand = define({
|
|
|
1497
2159
|
}
|
|
1498
2160
|
});
|
|
1499
2161
|
//#endregion
|
|
2162
|
+
//#region src/commands/backends.ts
|
|
2163
|
+
const backendsCommand = define({
|
|
2164
|
+
name: "backends",
|
|
2165
|
+
description: "List available Claude Code backend presets (Anthropic, Ollama Cloud, local Ollama)",
|
|
2166
|
+
args: {},
|
|
2167
|
+
async run() {
|
|
2168
|
+
consola.log("Available backends:\n");
|
|
2169
|
+
for (const b of listBackends()) consola.log(` ${b.name.padEnd(22)} ${b.description}`);
|
|
2170
|
+
consola.log("\nUsage: cctabs new <tab> <dir> --backend <name>");
|
|
2171
|
+
consola.log("Add custom presets in ~/.config/cctabs/config.toml under [backends.<name>].");
|
|
2172
|
+
}
|
|
2173
|
+
});
|
|
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
|
|
1500
2510
|
//#region src/commands/index.ts
|
|
1501
2511
|
const defaultCommand = define({
|
|
1502
2512
|
name: "cctabs",
|
|
@@ -1518,7 +2528,10 @@ const subCommands = new Map([
|
|
|
1518
2528
|
["scrollback", scrollbackCommand],
|
|
1519
2529
|
["send", sendCommand],
|
|
1520
2530
|
["config", configCommand],
|
|
1521
|
-
["restore", restoreCommand]
|
|
2531
|
+
["restore", restoreCommand],
|
|
2532
|
+
["backends", backendsCommand],
|
|
2533
|
+
["doctor", doctorCommand],
|
|
2534
|
+
["install-tabby-plugin", installTabbyPluginCommand]
|
|
1522
2535
|
]);
|
|
1523
2536
|
async function run() {
|
|
1524
2537
|
await cli(process.argv.slice(2), defaultCommand, {
|