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