@generativereality/cctabs 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +12 -0
- package/README.md +190 -0
- package/dist/index.js +1341 -0
- package/package.json +59 -0
- package/skills/herd/SKILL.md +202 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1341 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import updateNotifier from "update-notifier";
|
|
3
|
+
import { cli, define } from "gunshi";
|
|
4
|
+
import { createConnection } from "net";
|
|
5
|
+
import { execFileSync, spawnSync } from "child_process";
|
|
6
|
+
import { randomUUID } from "crypto";
|
|
7
|
+
import { homedir, tmpdir } from "os";
|
|
8
|
+
import { basename, dirname, extname, join, resolve } from "path";
|
|
9
|
+
import { consola } from "consola";
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
|
|
11
|
+
//#region package.json
|
|
12
|
+
var name = "@generativereality/cctabs";
|
|
13
|
+
var version = "0.1.0";
|
|
14
|
+
var description = "Claude Code tab manager. Terminal tabs as the UI, no tmux.";
|
|
15
|
+
var package_default = {
|
|
16
|
+
name,
|
|
17
|
+
version,
|
|
18
|
+
description,
|
|
19
|
+
type: "module",
|
|
20
|
+
bin: { "cctabs": "dist/index.js" },
|
|
21
|
+
files: [
|
|
22
|
+
"dist",
|
|
23
|
+
".claude-plugin",
|
|
24
|
+
"skills"
|
|
25
|
+
],
|
|
26
|
+
scripts: {
|
|
27
|
+
"dev": "bun run ./src/index.ts",
|
|
28
|
+
"build": "tsdown",
|
|
29
|
+
"typecheck": "tsc --noEmit",
|
|
30
|
+
"lint": "eslint src/",
|
|
31
|
+
"check": "bun run typecheck && bun run build",
|
|
32
|
+
"release": "bumpp && npm publish",
|
|
33
|
+
"sync-plugin": "bash scripts/sync-plugin.sh",
|
|
34
|
+
"prepack": "bash scripts/sync-plugin.sh --check && bun run build"
|
|
35
|
+
},
|
|
36
|
+
keywords: [
|
|
37
|
+
"claude-code",
|
|
38
|
+
"ai-agents",
|
|
39
|
+
"session-manager",
|
|
40
|
+
"wave-terminal",
|
|
41
|
+
"cctabs",
|
|
42
|
+
"agentherder"
|
|
43
|
+
],
|
|
44
|
+
author: "motin",
|
|
45
|
+
license: "MIT",
|
|
46
|
+
repository: {
|
|
47
|
+
"type": "git",
|
|
48
|
+
"url": "git+https://github.com/generativereality/cctabs.git"
|
|
49
|
+
},
|
|
50
|
+
homepage: "https://cctabs.com",
|
|
51
|
+
engines: { "node": ">=20.19.4" },
|
|
52
|
+
publishConfig: {
|
|
53
|
+
"registry": "https://registry.npmjs.org",
|
|
54
|
+
"access": "public"
|
|
55
|
+
},
|
|
56
|
+
dependencies: {
|
|
57
|
+
"@clack/prompts": "^0.9.1",
|
|
58
|
+
"consola": "^3.4.0",
|
|
59
|
+
"gunshi": "^0.23.0",
|
|
60
|
+
"update-notifier": "^7.3.1"
|
|
61
|
+
},
|
|
62
|
+
devDependencies: {
|
|
63
|
+
"@types/node": "^22.0.0",
|
|
64
|
+
"@types/update-notifier": "^6.0.8",
|
|
65
|
+
"bumpp": "^9.11.1",
|
|
66
|
+
"tsdown": "^0.12.0",
|
|
67
|
+
"typescript": "^5.8.0"
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
//#endregion
|
|
71
|
+
//#region src/core/terminal.ts
|
|
72
|
+
function detectTerminal() {
|
|
73
|
+
if (process.env.WAVETERM_JWT) return "wave";
|
|
74
|
+
const prog = process.env.TERM_PROGRAM ?? "";
|
|
75
|
+
const term = process.env.TERM ?? "";
|
|
76
|
+
if (prog === "iTerm.app") return "iterm2";
|
|
77
|
+
if (prog === "ghostty" || process.env.GHOSTTY_RESOURCES_DIR) return "ghostty";
|
|
78
|
+
if (prog === "WarpTerminal") return "warp";
|
|
79
|
+
if (prog === "vscode") return "vscode";
|
|
80
|
+
if (prog === "Hyper") return "hyper";
|
|
81
|
+
if (prog === "Apple_Terminal") return "apple-terminal";
|
|
82
|
+
if (term === "xterm-kitty" || process.env.KITTY_WINDOW_ID) return "kitty";
|
|
83
|
+
if (term === "alacritty") return "alacritty";
|
|
84
|
+
return "unknown";
|
|
85
|
+
}
|
|
86
|
+
const TERMINAL_NAMES = {
|
|
87
|
+
wave: "Wave Terminal",
|
|
88
|
+
iterm2: "iTerm2",
|
|
89
|
+
ghostty: "Ghostty",
|
|
90
|
+
warp: "Warp",
|
|
91
|
+
kitty: "Kitty",
|
|
92
|
+
vscode: "VS Code terminal",
|
|
93
|
+
hyper: "Hyper",
|
|
94
|
+
alacritty: "Alacritty",
|
|
95
|
+
"apple-terminal": "Terminal.app",
|
|
96
|
+
unknown: "an unrecognised terminal"
|
|
97
|
+
};
|
|
98
|
+
function printUnsupportedTerminalError(terminal) {
|
|
99
|
+
const name = TERMINAL_NAMES[terminal];
|
|
100
|
+
const lines = [
|
|
101
|
+
"",
|
|
102
|
+
` cctabs currently requires Wave Terminal.`,
|
|
103
|
+
` You appear to be running in: ${name}`,
|
|
104
|
+
"",
|
|
105
|
+
` Option 1 — Switch to Wave Terminal (full support today):`,
|
|
106
|
+
` brew install --cask wave`,
|
|
107
|
+
` https://waveterm.dev`,
|
|
108
|
+
"",
|
|
109
|
+
` Option 2 — Add ${name} support (one adapter file, PRs welcome):`,
|
|
110
|
+
` git clone https://github.com/generativereality/cctabs`,
|
|
111
|
+
` cd cctabs`,
|
|
112
|
+
` claude # ask Claude to implement the ${name} adapter`,
|
|
113
|
+
"",
|
|
114
|
+
` Claude will find src/core/wave.ts, use it as the reference`,
|
|
115
|
+
` implementation, create src/core/${adapterFileName(terminal)},`,
|
|
116
|
+
` wire it up, and open a PR — all in one session.`,
|
|
117
|
+
""
|
|
118
|
+
];
|
|
119
|
+
console.error(lines.join("\n"));
|
|
120
|
+
}
|
|
121
|
+
function adapterFileName(terminal) {
|
|
122
|
+
if (terminal === "unknown") return "<terminal>.ts";
|
|
123
|
+
return `${terminal}.ts`;
|
|
124
|
+
}
|
|
125
|
+
//#endregion
|
|
126
|
+
//#region src/core/wave.ts
|
|
127
|
+
const SOCK_PATH = join(homedir(), "Library", "Application Support", "waveterm", "wave.sock");
|
|
128
|
+
var WaveSocket = class {
|
|
129
|
+
socket;
|
|
130
|
+
buffer = "";
|
|
131
|
+
pendingReaders = [];
|
|
132
|
+
routeId = "";
|
|
133
|
+
jwt;
|
|
134
|
+
constructor(jwt) {
|
|
135
|
+
this.jwt = jwt;
|
|
136
|
+
this.socket = createConnection(SOCK_PATH);
|
|
137
|
+
this.socket.on("data", (chunk) => {
|
|
138
|
+
this.buffer += chunk.toString();
|
|
139
|
+
let nl;
|
|
140
|
+
while ((nl = this.buffer.indexOf("\n")) !== -1) {
|
|
141
|
+
const line = this.buffer.slice(0, nl).trim();
|
|
142
|
+
this.buffer = this.buffer.slice(nl + 1);
|
|
143
|
+
if (!line) continue;
|
|
144
|
+
try {
|
|
145
|
+
const msg = JSON.parse(line);
|
|
146
|
+
this.pendingReaders.shift()?.(msg);
|
|
147
|
+
} catch {}
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
waitForMessage(timeoutMs = 8e3) {
|
|
152
|
+
return new Promise((resolve, reject) => {
|
|
153
|
+
const timer = setTimeout(() => {
|
|
154
|
+
const idx = this.pendingReaders.indexOf(resolve);
|
|
155
|
+
if (idx !== -1) this.pendingReaders.splice(idx, 1);
|
|
156
|
+
reject(/* @__PURE__ */ new Error("Wave socket timeout"));
|
|
157
|
+
}, timeoutMs);
|
|
158
|
+
this.pendingReaders.push((msg) => {
|
|
159
|
+
clearTimeout(timer);
|
|
160
|
+
resolve(msg);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
send(msg) {
|
|
165
|
+
this.socket.write(JSON.stringify(msg) + "\n");
|
|
166
|
+
}
|
|
167
|
+
async connect() {
|
|
168
|
+
await new Promise((resolve, reject) => {
|
|
169
|
+
this.socket.once("connect", resolve);
|
|
170
|
+
this.socket.once("error", reject);
|
|
171
|
+
});
|
|
172
|
+
this.send({
|
|
173
|
+
command: "authenticate",
|
|
174
|
+
reqid: randomUUID(),
|
|
175
|
+
route: "$control",
|
|
176
|
+
data: this.jwt
|
|
177
|
+
});
|
|
178
|
+
this.routeId = (await this.waitForMessage()).data.routeid;
|
|
179
|
+
}
|
|
180
|
+
async command(command, data, route = "wavesrv") {
|
|
181
|
+
this.send({
|
|
182
|
+
command,
|
|
183
|
+
reqid: randomUUID(),
|
|
184
|
+
route,
|
|
185
|
+
source: this.routeId,
|
|
186
|
+
data
|
|
187
|
+
});
|
|
188
|
+
try {
|
|
189
|
+
return await this.waitForMessage();
|
|
190
|
+
} catch {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
destroy() {
|
|
195
|
+
this.socket.destroy();
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
var WaveAdapter = class {
|
|
199
|
+
socket = null;
|
|
200
|
+
jwt;
|
|
201
|
+
constructor() {
|
|
202
|
+
this.jwt = process.env.WAVETERM_JWT ?? "";
|
|
203
|
+
}
|
|
204
|
+
blocksList() {
|
|
205
|
+
try {
|
|
206
|
+
const out = execFileSync("wsh", [
|
|
207
|
+
"blocks",
|
|
208
|
+
"list",
|
|
209
|
+
"--json"
|
|
210
|
+
], { encoding: "utf-8" });
|
|
211
|
+
return JSON.parse(out);
|
|
212
|
+
} catch {
|
|
213
|
+
return [];
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
scrollback(blockId, lastN = 50) {
|
|
217
|
+
return spawnSync("wsh", [
|
|
218
|
+
"termscrollback",
|
|
219
|
+
"-b",
|
|
220
|
+
blockId,
|
|
221
|
+
"--start",
|
|
222
|
+
`-${lastN}`
|
|
223
|
+
], { encoding: "utf-8" }).stdout ?? "";
|
|
224
|
+
}
|
|
225
|
+
/** Detect whether a Claude session is running in a terminal block */
|
|
226
|
+
detectSessionStatus(blockId) {
|
|
227
|
+
const tail = this.scrollback(blockId, 10);
|
|
228
|
+
if (!tail.trim()) return "unknown";
|
|
229
|
+
const lastLine = tail.split("\n").map((l) => l.trim()).filter(Boolean).at(-1) ?? "";
|
|
230
|
+
if (/[$%>]\s*$/.test(lastLine) && !lastLine.includes("claude")) return "terminal";
|
|
231
|
+
if ([
|
|
232
|
+
"Claude Code",
|
|
233
|
+
"claude.ai/code",
|
|
234
|
+
"✻ Thinking",
|
|
235
|
+
"✽ Hatching",
|
|
236
|
+
"⏵⏵ bypass"
|
|
237
|
+
].some((s) => tail.includes(s))) return "active";
|
|
238
|
+
if (lastLine.toLowerCase().includes("claude")) return "idle";
|
|
239
|
+
return "terminal";
|
|
240
|
+
}
|
|
241
|
+
deleteBlock(blockId) {
|
|
242
|
+
spawnSync("wsh", [
|
|
243
|
+
"deleteblock",
|
|
244
|
+
"-b",
|
|
245
|
+
blockId
|
|
246
|
+
], { encoding: "utf-8" });
|
|
247
|
+
}
|
|
248
|
+
async newTab(focusWindowId) {
|
|
249
|
+
if (focusWindowId) {
|
|
250
|
+
await this.focusWindow(focusWindowId);
|
|
251
|
+
await sleep(300);
|
|
252
|
+
}
|
|
253
|
+
const r = spawnSync("osascript", ["-e", [
|
|
254
|
+
"tell application \"Wave\" to activate",
|
|
255
|
+
"delay 0.25",
|
|
256
|
+
"tell application \"System Events\" to keystroke \"t\" using command down"
|
|
257
|
+
].join("\n")], { encoding: "utf-8" });
|
|
258
|
+
if (r.status !== 0) {
|
|
259
|
+
const msg = r.stderr?.trim();
|
|
260
|
+
throw new Error(msg ? `osascript failed: ${msg}` : "Failed to open new tab — ensure Wave Terminal has Accessibility permission:\n System Settings → Privacy & Security → Accessibility → Wave ✓");
|
|
261
|
+
}
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
async waitForNewBlock(beforeIds, timeoutMs = 5e3) {
|
|
265
|
+
const deadline = Date.now() + timeoutMs;
|
|
266
|
+
while (Date.now() < deadline) {
|
|
267
|
+
await sleep(250);
|
|
268
|
+
for (const b of this.blocksList()) if (b.view === "term" && !beforeIds.has(b.blockid)) return {
|
|
269
|
+
blockId: b.blockid,
|
|
270
|
+
tabId: b.tabid
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
async sock() {
|
|
276
|
+
if (!this.socket) {
|
|
277
|
+
const s = new WaveSocket(this.jwt);
|
|
278
|
+
await s.connect();
|
|
279
|
+
this.socket = s;
|
|
280
|
+
}
|
|
281
|
+
return this.socket;
|
|
282
|
+
}
|
|
283
|
+
closeSocket() {
|
|
284
|
+
this.socket?.destroy();
|
|
285
|
+
this.socket = null;
|
|
286
|
+
}
|
|
287
|
+
async getTab(tabId) {
|
|
288
|
+
return (await (await this.sock()).command("gettab", tabId))?.data ?? {};
|
|
289
|
+
}
|
|
290
|
+
async workspaceList() {
|
|
291
|
+
return (await (await this.sock()).command("workspacelist", null))?.data ?? [];
|
|
292
|
+
}
|
|
293
|
+
async focusWindow(windowId) {
|
|
294
|
+
await (await this.sock()).command("focuswindow", windowId, "electron");
|
|
295
|
+
}
|
|
296
|
+
async renameTab(tabId, name) {
|
|
297
|
+
await (await this.sock()).command("updatetabname", { args: [tabId, name] });
|
|
298
|
+
}
|
|
299
|
+
async sendInput(blockId, text) {
|
|
300
|
+
const s = await this.sock();
|
|
301
|
+
const inputdata64 = Buffer.from(text).toString("base64");
|
|
302
|
+
return s.command("controllerinput", {
|
|
303
|
+
blockid: blockId,
|
|
304
|
+
inputdata64
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
async getAllData() {
|
|
308
|
+
const blocks = this.blocksList();
|
|
309
|
+
const tabsById = /* @__PURE__ */ new Map();
|
|
310
|
+
for (const b of blocks) {
|
|
311
|
+
const arr = tabsById.get(b.tabid) ?? [];
|
|
312
|
+
arr.push(b);
|
|
313
|
+
tabsById.set(b.tabid, arr);
|
|
314
|
+
}
|
|
315
|
+
const tabNames = /* @__PURE__ */ new Map();
|
|
316
|
+
let workspaces = [];
|
|
317
|
+
try {
|
|
318
|
+
for (const tabId of tabsById.keys()) {
|
|
319
|
+
const td = await this.getTab(tabId);
|
|
320
|
+
tabNames.set(tabId, td.name ?? tabId.slice(0, 8));
|
|
321
|
+
}
|
|
322
|
+
workspaces = await this.workspaceList();
|
|
323
|
+
} catch {} finally {
|
|
324
|
+
this.closeSocket();
|
|
325
|
+
}
|
|
326
|
+
if (!workspaces.length) {
|
|
327
|
+
const wsId = process.env.WAVETERM_WORKSPACEID ?? "";
|
|
328
|
+
workspaces = [{
|
|
329
|
+
workspacedata: {
|
|
330
|
+
oid: wsId,
|
|
331
|
+
name: wsId.slice(0, 8) || "default",
|
|
332
|
+
tabids: [...tabsById.keys()]
|
|
333
|
+
},
|
|
334
|
+
windowid: ""
|
|
335
|
+
}];
|
|
336
|
+
}
|
|
337
|
+
return {
|
|
338
|
+
blocks,
|
|
339
|
+
tabsById,
|
|
340
|
+
workspaces,
|
|
341
|
+
tabNames
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
resolveTab(query, tabsById, tabNames) {
|
|
345
|
+
const q = query.toLowerCase();
|
|
346
|
+
return [...tabsById.keys()].filter((tid) => {
|
|
347
|
+
const name = tabNames.get(tid) ?? "";
|
|
348
|
+
return name.toLowerCase() === q || tid.startsWith(query) || name.toLowerCase().startsWith(q);
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
resolveBlock(query, blocks) {
|
|
352
|
+
return blocks.filter((b) => b.blockid.startsWith(query));
|
|
353
|
+
}
|
|
354
|
+
resolveWorkspace(workspaces, query) {
|
|
355
|
+
const q = query.toLowerCase();
|
|
356
|
+
return workspaces.filter(({ workspacedata: wd }) => {
|
|
357
|
+
const name = wd.name ?? "";
|
|
358
|
+
return name.toLowerCase() === q || wd.oid.startsWith(query) || name.toLowerCase().startsWith(q);
|
|
359
|
+
}).map((w) => ({
|
|
360
|
+
data: w.workspacedata,
|
|
361
|
+
windowId: w.windowid
|
|
362
|
+
}));
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
function requireWaveAdapter() {
|
|
366
|
+
if (!process.env.WAVETERM_JWT) {
|
|
367
|
+
printUnsupportedTerminalError(detectTerminal());
|
|
368
|
+
process.exit(1);
|
|
369
|
+
}
|
|
370
|
+
return new WaveAdapter();
|
|
371
|
+
}
|
|
372
|
+
function sleep(ms) {
|
|
373
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
374
|
+
}
|
|
375
|
+
//#endregion
|
|
376
|
+
//#region src/commands/sessions.ts
|
|
377
|
+
const sessionsCommand = define({
|
|
378
|
+
name: "sessions",
|
|
379
|
+
description: "List tabs with active/idle session status",
|
|
380
|
+
args: {},
|
|
381
|
+
async run() {
|
|
382
|
+
const adapter = requireWaveAdapter();
|
|
383
|
+
const { tabsById, workspaces, tabNames } = await adapter.getAllData();
|
|
384
|
+
const currentTab = process.env.WAVETERM_TABID ?? "";
|
|
385
|
+
const currentWs = process.env.WAVETERM_WORKSPACEID ?? "";
|
|
386
|
+
console.log("Sessions");
|
|
387
|
+
console.log("=".repeat(50));
|
|
388
|
+
for (const wsp of workspaces) {
|
|
389
|
+
const { oid, name, tabids } = wsp.workspacedata;
|
|
390
|
+
const wsMarker = oid === currentWs ? " (current)" : "";
|
|
391
|
+
const tabIds = tabids.filter((t) => tabsById.has(t));
|
|
392
|
+
if (!tabIds.length) continue;
|
|
393
|
+
console.log(`\nWorkspace: ${name}${wsMarker}`);
|
|
394
|
+
for (const tabId of tabIds) {
|
|
395
|
+
const termBlocks = (tabsById.get(tabId) ?? []).filter((b) => b.view === "term");
|
|
396
|
+
if (!termBlocks.length) continue;
|
|
397
|
+
const name = tabNames.get(tabId) ?? tabId.slice(0, 8);
|
|
398
|
+
const cur = tabId === currentTab ? " ◄" : "";
|
|
399
|
+
const b = termBlocks[0];
|
|
400
|
+
const cwd = (b.meta?.["cmd:cwd"] ?? "").replace(process.env.HOME ?? "", "~");
|
|
401
|
+
const status = adapter.detectSessionStatus(b.blockid);
|
|
402
|
+
const statusLabel = status === "active" ? "● active" : status === "idle" ? "○ idle" : status === "unknown" ? "? unknown" : " terminal";
|
|
403
|
+
console.log(` [${tabId.slice(0, 8)}] "${name}"${cur} ${cwd}`);
|
|
404
|
+
console.log(` ${statusLabel}`);
|
|
405
|
+
if (status === "terminal") {
|
|
406
|
+
const lastLine = adapter.scrollback(b.blockid, 5).split("\n").map((l) => l.trim()).filter(Boolean).at(-1) ?? "";
|
|
407
|
+
if (lastLine) console.log(` last: ${lastLine.slice(0, 80)}`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
//#endregion
|
|
414
|
+
//#region src/commands/list.ts
|
|
415
|
+
const listCommand = define({
|
|
416
|
+
name: "list",
|
|
417
|
+
description: "List all workspaces, tabs, and blocks",
|
|
418
|
+
args: {},
|
|
419
|
+
async run() {
|
|
420
|
+
const { tabsById, workspaces, tabNames } = await requireWaveAdapter().getAllData();
|
|
421
|
+
const currentBlock = process.env.WAVETERM_BLOCKID ?? "";
|
|
422
|
+
const currentTab = process.env.WAVETERM_TABID ?? "";
|
|
423
|
+
const currentWs = process.env.WAVETERM_WORKSPACEID ?? "";
|
|
424
|
+
for (const wsp of workspaces) {
|
|
425
|
+
const { oid, name, tabids } = wsp.workspacedata;
|
|
426
|
+
const noWindow = !wsp.windowid ? " (no window)" : "";
|
|
427
|
+
const wsMarker = oid === currentWs ? " ◄ current" : noWindow;
|
|
428
|
+
console.log(`Workspace: ${name} [${oid.slice(0, 8)}]${wsMarker}`);
|
|
429
|
+
console.log();
|
|
430
|
+
const tabIds = tabids.filter((t) => tabsById.has(t));
|
|
431
|
+
if (!tabIds.length) {
|
|
432
|
+
console.log(" (no open tabs)");
|
|
433
|
+
console.log();
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
for (const tabId of tabIds) {
|
|
437
|
+
const tabName = tabNames.get(tabId) ?? tabId.slice(0, 8);
|
|
438
|
+
const cur = tabId === currentTab ? " ◄" : "";
|
|
439
|
+
console.log(` Tab "${tabName}" [${tabId.slice(0, 8)}]${cur}`);
|
|
440
|
+
for (const b of tabsById.get(tabId) ?? []) {
|
|
441
|
+
const here = b.blockid === currentBlock ? " ◄ here" : "";
|
|
442
|
+
const cwd = b.meta?.["cmd:cwd"] ?? "";
|
|
443
|
+
console.log(` ${b.view.padEnd(8)} ${b.blockid.slice(0, 8)}${cwd ? ` ${cwd}` : ""}${here}`);
|
|
444
|
+
}
|
|
445
|
+
console.log();
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
//#endregion
|
|
451
|
+
//#region src/core/config.ts
|
|
452
|
+
const CONFIG_PATH = join(homedir(), ".config", "cctabs", "config.toml");
|
|
453
|
+
const DEFAULT_CONFIG = {
|
|
454
|
+
claude: { flags: ["--allow-dangerously-skip-permissions"] },
|
|
455
|
+
defaults: { workspace: "" }
|
|
456
|
+
};
|
|
457
|
+
const DEFAULT_CONFIG_FILE = `# cctabs configuration
|
|
458
|
+
# https://cctabs.com
|
|
459
|
+
|
|
460
|
+
[claude]
|
|
461
|
+
# Extra flags passed to every \`claude\` invocation.
|
|
462
|
+
flags = ["--allow-dangerously-skip-permissions"]
|
|
463
|
+
|
|
464
|
+
[defaults]
|
|
465
|
+
# Default Wave workspace to open new sessions in.
|
|
466
|
+
# workspace = ""
|
|
467
|
+
`;
|
|
468
|
+
function parseToml(text) {
|
|
469
|
+
const result = {};
|
|
470
|
+
let section = null;
|
|
471
|
+
for (const raw of text.split("\n")) {
|
|
472
|
+
const line = raw.trim();
|
|
473
|
+
if (!line || line.startsWith("#")) continue;
|
|
474
|
+
if (line.startsWith("[") && line.endsWith("]")) {
|
|
475
|
+
section = line.slice(1, -1).trim();
|
|
476
|
+
result[section] ??= {};
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
if (section && line.includes("=")) {
|
|
480
|
+
const [rawKey, ...rest] = line.split("=");
|
|
481
|
+
const key = rawKey.trim();
|
|
482
|
+
const val = rest.join("=").trim();
|
|
483
|
+
if (val.startsWith("[")) {
|
|
484
|
+
const items = [...val.matchAll(/"([^"]*)"/g)].map((m) => m[1]);
|
|
485
|
+
result[section][key] = items;
|
|
486
|
+
} else if (val.startsWith("\"") && val.endsWith("\"")) result[section][key] = val.slice(1, -1);
|
|
487
|
+
else if (val === "true" || val === "false") result[section][key] = val === "true";
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return result;
|
|
491
|
+
}
|
|
492
|
+
function loadConfig() {
|
|
493
|
+
const config = {
|
|
494
|
+
claude: { ...DEFAULT_CONFIG.claude },
|
|
495
|
+
defaults: { ...DEFAULT_CONFIG.defaults }
|
|
496
|
+
};
|
|
497
|
+
if (!existsSync(CONFIG_PATH)) return config;
|
|
498
|
+
try {
|
|
499
|
+
const parsed = parseToml(readFileSync(CONFIG_PATH, "utf-8"));
|
|
500
|
+
if (parsed.claude) Object.assign(config.claude, parsed.claude);
|
|
501
|
+
if (parsed.defaults) Object.assign(config.defaults, parsed.defaults);
|
|
502
|
+
} catch {}
|
|
503
|
+
return config;
|
|
504
|
+
}
|
|
505
|
+
function ensureConfigExists() {
|
|
506
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
507
|
+
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
|
|
508
|
+
writeFileSync(CONFIG_PATH, DEFAULT_CONFIG_FILE);
|
|
509
|
+
}
|
|
510
|
+
return CONFIG_PATH;
|
|
511
|
+
}
|
|
512
|
+
//#endregion
|
|
513
|
+
//#region src/core/open-session.ts
|
|
514
|
+
/** Poll scrollback until a pattern is visible, then return. Rejects on timeout. */
|
|
515
|
+
async function waitForScrollbackMatch(adapter, blockId, pattern, label, timeoutMs, pollInterval = 1e3) {
|
|
516
|
+
const deadline = Date.now() + timeoutMs;
|
|
517
|
+
while (Date.now() < deadline) {
|
|
518
|
+
await new Promise((r) => setTimeout(r, pollInterval));
|
|
519
|
+
try {
|
|
520
|
+
const lines = adapter.scrollback(blockId, 10);
|
|
521
|
+
if (!lines) continue;
|
|
522
|
+
if (typeof pattern === "string" ? lines.includes(pattern) : pattern.test(lines)) return;
|
|
523
|
+
} catch {}
|
|
524
|
+
}
|
|
525
|
+
throw new Error(`Timed out waiting for ${label}`);
|
|
526
|
+
}
|
|
527
|
+
async function openSession(opts) {
|
|
528
|
+
const { tabName, claudeCmd, workspaceQuery, initialPromptFile } = opts;
|
|
529
|
+
const dir = resolve(opts.dir.replace(/^~/, homedir()));
|
|
530
|
+
if (!existsSync(dir)) {
|
|
531
|
+
consola.error(`Directory does not exist: ${dir}`);
|
|
532
|
+
process.exit(1);
|
|
533
|
+
}
|
|
534
|
+
const config = loadConfig();
|
|
535
|
+
const adapter = requireWaveAdapter();
|
|
536
|
+
let focusWindowId;
|
|
537
|
+
if (workspaceQuery) {
|
|
538
|
+
const { workspaces } = await adapter.getAllData();
|
|
539
|
+
const matches = adapter.resolveWorkspace(workspaces, workspaceQuery);
|
|
540
|
+
if (!matches.length) {
|
|
541
|
+
consola.error(`No workspace matching '${workspaceQuery}'`);
|
|
542
|
+
process.exit(1);
|
|
543
|
+
}
|
|
544
|
+
const { data, windowId } = matches[0];
|
|
545
|
+
if (!windowId) {
|
|
546
|
+
consola.error(`Workspace '${data.name}' has no open window`);
|
|
547
|
+
process.exit(1);
|
|
548
|
+
}
|
|
549
|
+
focusWindowId = windowId;
|
|
550
|
+
consola.info(`Workspace: ${data.name}`);
|
|
551
|
+
}
|
|
552
|
+
const beforeIds = new Set(adapter.blocksList().filter((b) => b.view === "term").map((b) => b.blockid));
|
|
553
|
+
await adapter.newTab(focusWindowId);
|
|
554
|
+
const result = await adapter.waitForNewBlock(beforeIds);
|
|
555
|
+
if (!result) {
|
|
556
|
+
consola.error("Timed out waiting for new terminal block");
|
|
557
|
+
process.exit(1);
|
|
558
|
+
}
|
|
559
|
+
const { blockId, tabId } = result;
|
|
560
|
+
await adapter.renameTab(tabId, tabName);
|
|
561
|
+
try {
|
|
562
|
+
await waitForScrollbackMatch(adapter, blockId, /[$%>]\s*$/, "shell prompt", 1e4, 250);
|
|
563
|
+
} catch {
|
|
564
|
+
consola.error("Shell prompt never appeared in new tab — aborting. Check your shell profile (e.g. nvm default alias).");
|
|
565
|
+
process.exit(1);
|
|
566
|
+
}
|
|
567
|
+
const extraFlags = config.claude.flags.join(" ");
|
|
568
|
+
const namePart = claudeCmd.includes("--resume") ? "" : ` --name ${JSON.stringify(tabName)}`;
|
|
569
|
+
const cmd = `cd ${JSON.stringify(dir)} && claude${extraFlags ? " " + extraFlags : ""} ${claudeCmd.replace(/^claude\s*/, "")}${namePart}\r`;
|
|
570
|
+
await adapter.sendInput(blockId, cmd);
|
|
571
|
+
if (initialPromptFile) {
|
|
572
|
+
try {
|
|
573
|
+
await waitForScrollbackMatch(adapter, blockId, "❯", "Claude prompt", 3e4);
|
|
574
|
+
} catch {
|
|
575
|
+
consola.error("Claude prompt (❯) never appeared — not sending initial prompt. Check that claude started successfully.");
|
|
576
|
+
adapter.closeSocket();
|
|
577
|
+
process.exit(1);
|
|
578
|
+
}
|
|
579
|
+
const prompt = readFileSync(initialPromptFile, "utf-8").trimEnd();
|
|
580
|
+
await adapter.sendInput(blockId, prompt);
|
|
581
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
582
|
+
await adapter.sendInput(blockId, "\r");
|
|
583
|
+
}
|
|
584
|
+
await new Promise((r) => setTimeout(r, 2e3));
|
|
585
|
+
adapter.closeSocket();
|
|
586
|
+
return tabId;
|
|
587
|
+
}
|
|
588
|
+
//#endregion
|
|
589
|
+
//#region src/commands/new.ts
|
|
590
|
+
const newCommand = define({
|
|
591
|
+
name: "new",
|
|
592
|
+
description: "Open a new tab and launch claude",
|
|
593
|
+
args: {
|
|
594
|
+
name: {
|
|
595
|
+
type: "positional",
|
|
596
|
+
description: "Tab name"
|
|
597
|
+
},
|
|
598
|
+
dir: {
|
|
599
|
+
type: "positional",
|
|
600
|
+
description: "Working directory / repo root (default: cwd)"
|
|
601
|
+
},
|
|
602
|
+
workspace: {
|
|
603
|
+
type: "string",
|
|
604
|
+
short: "w",
|
|
605
|
+
description: "Target workspace"
|
|
606
|
+
},
|
|
607
|
+
worktree: {
|
|
608
|
+
type: "boolean",
|
|
609
|
+
short: "W",
|
|
610
|
+
description: "Launch claude with --worktree <name> for isolated branch work"
|
|
611
|
+
},
|
|
612
|
+
file: {
|
|
613
|
+
type: "string",
|
|
614
|
+
short: "f",
|
|
615
|
+
description: "Send initial prompt from file once Claude is ready"
|
|
616
|
+
},
|
|
617
|
+
prompt: {
|
|
618
|
+
type: "string",
|
|
619
|
+
short: "p",
|
|
620
|
+
description: "Send initial prompt text once Claude is ready"
|
|
621
|
+
}
|
|
622
|
+
},
|
|
623
|
+
async run(ctx) {
|
|
624
|
+
const name = ctx.positionals[1];
|
|
625
|
+
const dir = ctx.positionals[2] ?? process.cwd();
|
|
626
|
+
const workspace = ctx.values.workspace;
|
|
627
|
+
const useWorktree = ctx.values.worktree ?? false;
|
|
628
|
+
const promptFile = ctx.values.file;
|
|
629
|
+
const promptText = ctx.values.prompt;
|
|
630
|
+
if (!name) {
|
|
631
|
+
consola.error("Tab name is required");
|
|
632
|
+
process.exit(1);
|
|
633
|
+
}
|
|
634
|
+
let initialPromptFile;
|
|
635
|
+
if (promptText) {
|
|
636
|
+
initialPromptFile = join(tmpdir(), `herd-prompt-${Date.now()}.txt`);
|
|
637
|
+
writeFileSync(initialPromptFile, promptText);
|
|
638
|
+
} else if (promptFile) initialPromptFile = promptFile;
|
|
639
|
+
const tabId = await openSession({
|
|
640
|
+
tabName: name,
|
|
641
|
+
dir,
|
|
642
|
+
claudeCmd: useWorktree ? `claude --worktree ${JSON.stringify(name)}` : "claude",
|
|
643
|
+
workspaceQuery: workspace,
|
|
644
|
+
initialPromptFile
|
|
645
|
+
});
|
|
646
|
+
const suffix = useWorktree ? ` (worktree: .claude/worktrees/${name})` : "";
|
|
647
|
+
consola.success(`Tab "${name}" [${tabId.slice(0, 8)}] → claude at ${dir}${suffix}`);
|
|
648
|
+
}
|
|
649
|
+
});
|
|
650
|
+
//#endregion
|
|
651
|
+
//#region src/core/session.ts
|
|
652
|
+
/** Convert an absolute path to Claude's project slug (/ and . → -) */
|
|
653
|
+
function pathToProjectSlug(dir) {
|
|
654
|
+
return resolve(dir).replace(/[/.]/g, "-");
|
|
655
|
+
}
|
|
656
|
+
/** Find the most recent .jsonl session file in a Claude project directory */
|
|
657
|
+
function latestJsonlIn(projectDir) {
|
|
658
|
+
if (!existsSync(projectDir)) return null;
|
|
659
|
+
const files = readdirSync(projectDir).filter((f) => extname(f) === ".jsonl").map((f) => ({
|
|
660
|
+
name: f,
|
|
661
|
+
mtime: statSync(join(projectDir, f)).mtimeMs
|
|
662
|
+
})).sort((a, b) => b.mtime - a.mtime);
|
|
663
|
+
return files.length ? basename(files[0].name, ".jsonl") : null;
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Find the most recent Claude Code session ID for a directory.
|
|
667
|
+
* Also checks worktree subdirectories (.claude/worktrees/*) since tabs
|
|
668
|
+
* opened with --worktree run from a worktree path, not the repo root.
|
|
669
|
+
*/
|
|
670
|
+
function findLatestSessionId(dir) {
|
|
671
|
+
const projectsRoot = join(homedir(), ".claude", "projects");
|
|
672
|
+
const direct = latestJsonlIn(join(projectsRoot, pathToProjectSlug(dir)));
|
|
673
|
+
if (direct) return direct;
|
|
674
|
+
const worktreesDir = join(dir, ".claude", "worktrees");
|
|
675
|
+
if (existsSync(worktreesDir)) {
|
|
676
|
+
const candidates = [];
|
|
677
|
+
for (const entry of readdirSync(worktreesDir)) {
|
|
678
|
+
const projectDir = join(projectsRoot, pathToProjectSlug(join(worktreesDir, entry)));
|
|
679
|
+
const id = latestJsonlIn(projectDir);
|
|
680
|
+
if (id) {
|
|
681
|
+
const mtime = statSync(join(projectDir, id + ".jsonl")).mtimeMs;
|
|
682
|
+
candidates.push({
|
|
683
|
+
id,
|
|
684
|
+
mtime
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
if (candidates.length) {
|
|
689
|
+
candidates.sort((a, b) => b.mtime - a.mtime);
|
|
690
|
+
return candidates[0].id;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
return null;
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Find all sessions with a given custom title (--name).
|
|
697
|
+
* Returns them sorted by most recent first, with the first user prompt for context.
|
|
698
|
+
*/
|
|
699
|
+
function findSessionsByName(dir, name) {
|
|
700
|
+
const projectDir = join(join(homedir(), ".claude", "projects"), pathToProjectSlug(dir));
|
|
701
|
+
if (!existsSync(projectDir)) return [];
|
|
702
|
+
const matches = [];
|
|
703
|
+
const files = readdirSync(projectDir).filter((f) => extname(f) === ".jsonl");
|
|
704
|
+
for (const f of files) {
|
|
705
|
+
const fullPath = join(projectDir, f);
|
|
706
|
+
try {
|
|
707
|
+
const lines = readFileSync(fullPath, "utf-8").split("\n");
|
|
708
|
+
let currentTitle = "";
|
|
709
|
+
let firstPrompt = "";
|
|
710
|
+
let lastActivity = "";
|
|
711
|
+
for (const line of lines) {
|
|
712
|
+
if (!line.trim()) continue;
|
|
713
|
+
try {
|
|
714
|
+
const entry = JSON.parse(line);
|
|
715
|
+
if (entry.customTitle !== void 0) currentTitle = entry.customTitle;
|
|
716
|
+
if (!firstPrompt && entry.type === "user" && entry.message?.content) {
|
|
717
|
+
const text = typeof entry.message.content === "string" ? entry.message.content : entry.message.content.find((c) => c.type === "text")?.text ?? "";
|
|
718
|
+
if (text.startsWith("<")) continue;
|
|
719
|
+
firstPrompt = text.slice(0, 120).replace(/\n/g, " ").trim();
|
|
720
|
+
if (text.length > 120) firstPrompt += "…";
|
|
721
|
+
}
|
|
722
|
+
if (entry.message?.role === "assistant" && entry.message?.content) {
|
|
723
|
+
const parts = Array.isArray(entry.message.content) ? entry.message.content : [{
|
|
724
|
+
type: "text",
|
|
725
|
+
text: entry.message.content
|
|
726
|
+
}];
|
|
727
|
+
for (const p of parts) if (p.type === "text" && p.text?.trim()) {
|
|
728
|
+
lastActivity = p.text.slice(0, 120).replace(/\n/g, " ").trim();
|
|
729
|
+
if (p.text.length > 120) lastActivity += "…";
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
} catch {}
|
|
733
|
+
}
|
|
734
|
+
if (currentTitle !== name) continue;
|
|
735
|
+
const stat = statSync(fullPath);
|
|
736
|
+
matches.push({
|
|
737
|
+
id: basename(f, ".jsonl"),
|
|
738
|
+
mtime: stat.mtimeMs,
|
|
739
|
+
size: stat.size,
|
|
740
|
+
firstPrompt,
|
|
741
|
+
lastActivity
|
|
742
|
+
});
|
|
743
|
+
} catch {}
|
|
744
|
+
}
|
|
745
|
+
return matches.sort((a, b) => b.mtime - a.mtime);
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* List all unique session names (customTitle) in a project directory.
|
|
749
|
+
* Used to show available names when a resume lookup fails.
|
|
750
|
+
*/
|
|
751
|
+
function listSessionNames(dir) {
|
|
752
|
+
const projectDir = join(join(homedir(), ".claude", "projects"), pathToProjectSlug(dir));
|
|
753
|
+
if (!existsSync(projectDir)) return [];
|
|
754
|
+
const results = [];
|
|
755
|
+
const seen = /* @__PURE__ */ new Set();
|
|
756
|
+
const files = readdirSync(projectDir).filter((f) => extname(f) === ".jsonl");
|
|
757
|
+
for (const f of files) {
|
|
758
|
+
const fullPath = join(projectDir, f);
|
|
759
|
+
try {
|
|
760
|
+
const firstLine = readFileSync(fullPath, "utf-8").split("\n")[0];
|
|
761
|
+
if (!firstLine) continue;
|
|
762
|
+
const title = JSON.parse(firstLine).customTitle;
|
|
763
|
+
if (!title || seen.has(title)) continue;
|
|
764
|
+
seen.add(title);
|
|
765
|
+
const stat = statSync(fullPath);
|
|
766
|
+
results.push({
|
|
767
|
+
name: title,
|
|
768
|
+
id: basename(f, ".jsonl"),
|
|
769
|
+
mtime: stat.mtimeMs
|
|
770
|
+
});
|
|
771
|
+
} catch {}
|
|
772
|
+
}
|
|
773
|
+
return results.sort((a, b) => b.mtime - a.mtime);
|
|
774
|
+
}
|
|
775
|
+
//#endregion
|
|
776
|
+
//#region src/commands/resume.ts
|
|
777
|
+
function formatAge(mtimeMs) {
|
|
778
|
+
const mins = Math.round((Date.now() - mtimeMs) / 6e4);
|
|
779
|
+
if (mins < 60) return `${mins}m ago`;
|
|
780
|
+
const hours = Math.round(mins / 60);
|
|
781
|
+
if (hours < 24) return `${hours}h ago`;
|
|
782
|
+
return `${Math.round(hours / 24)}d ago`;
|
|
783
|
+
}
|
|
784
|
+
function formatSize(bytes) {
|
|
785
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
786
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
787
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
788
|
+
}
|
|
789
|
+
const resumeCommand = define({
|
|
790
|
+
name: "resume",
|
|
791
|
+
description: "Resume a claude session by name — reuses existing tab or creates a new one",
|
|
792
|
+
args: {
|
|
793
|
+
name: {
|
|
794
|
+
type: "positional",
|
|
795
|
+
description: "Tab / session name"
|
|
796
|
+
},
|
|
797
|
+
dir: {
|
|
798
|
+
type: "positional",
|
|
799
|
+
description: "Working directory (default: cwd)"
|
|
800
|
+
},
|
|
801
|
+
session: {
|
|
802
|
+
type: "string",
|
|
803
|
+
short: "s",
|
|
804
|
+
description: "Session ID to resume (use when multiple sessions share the same name)"
|
|
805
|
+
}
|
|
806
|
+
},
|
|
807
|
+
async run(ctx) {
|
|
808
|
+
const name = ctx.positionals[1];
|
|
809
|
+
const dir = resolve((ctx.positionals[2] ?? process.cwd()).replace(/^~/, homedir()));
|
|
810
|
+
if (!name) {
|
|
811
|
+
consola.error("Tab name is required");
|
|
812
|
+
process.exit(1);
|
|
813
|
+
}
|
|
814
|
+
const explicitSession = ctx.values.session;
|
|
815
|
+
let sessionId;
|
|
816
|
+
if (explicitSession) sessionId = explicitSession;
|
|
817
|
+
else {
|
|
818
|
+
const sessions = findSessionsByName(dir, name);
|
|
819
|
+
if (sessions.length === 0) {
|
|
820
|
+
consola.error(`No session named "${name}" in ${dir}`);
|
|
821
|
+
const available = listSessionNames(dir);
|
|
822
|
+
if (available.length) {
|
|
823
|
+
consola.info("Available session names:");
|
|
824
|
+
for (const s of available.slice(0, 15)) consola.log(` ${s.name} (${s.id.slice(0, 8)}…)`);
|
|
825
|
+
} else consola.info(`Looked in ~/.claude/projects/${pathToProjectSlug(dir)}/`);
|
|
826
|
+
process.exit(1);
|
|
827
|
+
} else if (sessions.length === 1) sessionId = sessions[0].id;
|
|
828
|
+
else {
|
|
829
|
+
consola.error(`Multiple "${name}" sessions found. Use --session <id> to pick one:\n`);
|
|
830
|
+
for (const s of sessions) {
|
|
831
|
+
consola.log(` ${s.id} ${formatAge(s.mtime)} ${formatSize(s.size)}`);
|
|
832
|
+
if (s.firstPrompt) consola.log(` start: "${s.firstPrompt}"`);
|
|
833
|
+
if (s.lastActivity) consola.log(` last: "${s.lastActivity}"`);
|
|
834
|
+
}
|
|
835
|
+
process.exit(1);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
const adapter = requireWaveAdapter();
|
|
839
|
+
const { tabsById, tabNames } = await adapter.getAllData();
|
|
840
|
+
const matchingTabs = adapter.resolveTab(name, tabsById, tabNames);
|
|
841
|
+
if (matchingTabs.length > 1) {
|
|
842
|
+
consola.error(`Multiple tabs match '${name}':`);
|
|
843
|
+
for (const tid of matchingTabs) consola.error(` "${tabNames.get(tid)}" [${tid.slice(0, 8)}]`);
|
|
844
|
+
process.exit(1);
|
|
845
|
+
}
|
|
846
|
+
if (matchingTabs.length === 1) {
|
|
847
|
+
if (!sessionId) {
|
|
848
|
+
consola.error(`Tab "${name}" exists but no Claude session found to resume in ${dir}`);
|
|
849
|
+
process.exit(1);
|
|
850
|
+
}
|
|
851
|
+
const tabId = matchingTabs[0];
|
|
852
|
+
const termBlock = (tabsById.get(tabId) ?? []).find((b) => b.view === "term");
|
|
853
|
+
if (!termBlock) {
|
|
854
|
+
consola.error(`No terminal block found in tab '${name}'`);
|
|
855
|
+
process.exit(1);
|
|
856
|
+
}
|
|
857
|
+
const status = adapter.detectSessionStatus(termBlock.blockid);
|
|
858
|
+
if (status === "active" || status === "idle") {
|
|
859
|
+
adapter.closeSocket();
|
|
860
|
+
consola.warn(`Claude is already running in tab "${name}" (${status}) — skipping resume`);
|
|
861
|
+
process.exit(0);
|
|
862
|
+
}
|
|
863
|
+
if (status === "unknown") consola.warn(`Scrollback unavailable for tab "${name}" — cannot confirm shell is ready. Proceeding anyway.`);
|
|
864
|
+
const extraFlags = loadConfig().claude.flags.join(" ");
|
|
865
|
+
const cmd = `cd ${JSON.stringify(dir)} && claude${extraFlags ? " " + extraFlags : ""} --resume ${sessionId} --name ${JSON.stringify(name)}\r`;
|
|
866
|
+
await adapter.sendInput(termBlock.blockid, cmd);
|
|
867
|
+
let verified = false;
|
|
868
|
+
const deadline = Date.now() + 15e3;
|
|
869
|
+
while (Date.now() < deadline) {
|
|
870
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
871
|
+
const newStatus = adapter.detectSessionStatus(termBlock.blockid);
|
|
872
|
+
if (newStatus === "active" || newStatus === "idle") {
|
|
873
|
+
verified = true;
|
|
874
|
+
break;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
adapter.closeSocket();
|
|
878
|
+
if (verified) consola.success(`Tab "${name}" [${tabId.slice(0, 8)}] → claude --resume ${sessionId.slice(0, 8)}… at ${dir}`);
|
|
879
|
+
else consola.warn(`Tab "${name}" [${tabId.slice(0, 8)}] — command sent but Claude may not have started (scrollback check inconclusive)`);
|
|
880
|
+
} else if (sessionId) {
|
|
881
|
+
adapter.closeSocket();
|
|
882
|
+
const tabId = await openSession({
|
|
883
|
+
tabName: name,
|
|
884
|
+
dir,
|
|
885
|
+
claudeCmd: `claude --resume ${sessionId} --name ${JSON.stringify(name)}`
|
|
886
|
+
});
|
|
887
|
+
consola.success(`Tab "${name}" [${tabId.slice(0, 8)}] → claude --resume ${sessionId.slice(0, 8)}… at ${dir} (new tab)`);
|
|
888
|
+
} else {
|
|
889
|
+
adapter.closeSocket();
|
|
890
|
+
const tabId = await openSession({
|
|
891
|
+
tabName: name,
|
|
892
|
+
dir,
|
|
893
|
+
claudeCmd: "claude"
|
|
894
|
+
});
|
|
895
|
+
consola.success(`Tab "${name}" [${tabId.slice(0, 8)}] → claude at ${dir} (new tab, no prior session found)`);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
});
|
|
899
|
+
//#endregion
|
|
900
|
+
//#region src/commands/fork.ts
|
|
901
|
+
/** If dir is inside .claude/worktrees/<name>, return the repo root instead */
|
|
902
|
+
function resolveSessionDir(dir) {
|
|
903
|
+
const worktreeMarker = `${join(".claude", "worktrees")}/`;
|
|
904
|
+
const idx = dir.indexOf(worktreeMarker);
|
|
905
|
+
if (idx !== -1) {
|
|
906
|
+
const repoRoot = dir.slice(0, idx - 1);
|
|
907
|
+
return {
|
|
908
|
+
sessionLookupDir: repoRoot,
|
|
909
|
+
openDir: repoRoot
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
return {
|
|
913
|
+
sessionLookupDir: dir,
|
|
914
|
+
openDir: dir
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
const forkCommand = define({
|
|
918
|
+
name: "fork",
|
|
919
|
+
description: "Fork a session into a new tab via --resume <id> --fork-session",
|
|
920
|
+
args: {
|
|
921
|
+
tab: {
|
|
922
|
+
type: "positional",
|
|
923
|
+
description: "Source tab name or ID prefix"
|
|
924
|
+
},
|
|
925
|
+
name: {
|
|
926
|
+
type: "string",
|
|
927
|
+
short: "n",
|
|
928
|
+
description: "Name for the new tab"
|
|
929
|
+
}
|
|
930
|
+
},
|
|
931
|
+
async run(ctx) {
|
|
932
|
+
const sourceQuery = ctx.positionals[1];
|
|
933
|
+
const customName = ctx.values.name;
|
|
934
|
+
if (!sourceQuery) {
|
|
935
|
+
consola.error("Source tab name is required");
|
|
936
|
+
process.exit(1);
|
|
937
|
+
}
|
|
938
|
+
const adapter = requireWaveAdapter();
|
|
939
|
+
const { tabsById, tabNames } = await adapter.getAllData();
|
|
940
|
+
const matches = adapter.resolveTab(sourceQuery, tabsById, tabNames);
|
|
941
|
+
if (!matches.length) {
|
|
942
|
+
consola.error(`No tab matching '${sourceQuery}' (tabs in workspaces with no open window are not visible — open that workspace first)`);
|
|
943
|
+
process.exit(1);
|
|
944
|
+
}
|
|
945
|
+
if (matches.length > 1) {
|
|
946
|
+
consola.error(`Multiple tabs match '${sourceQuery}':`);
|
|
947
|
+
for (const tid of matches) consola.log(` "${tabNames.get(tid)}" [${tid.slice(0, 8)}]`);
|
|
948
|
+
process.exit(1);
|
|
949
|
+
}
|
|
950
|
+
const tabId = matches[0];
|
|
951
|
+
const tabName = tabNames.get(tabId) ?? tabId.slice(0, 8);
|
|
952
|
+
const newName = customName ?? `${tabName}-fork`;
|
|
953
|
+
const termBlocks = (tabsById.get(tabId) ?? []).filter((b) => b.view === "term");
|
|
954
|
+
if (!termBlocks.length) {
|
|
955
|
+
consola.error(`Tab "${tabName}" has no terminal block`);
|
|
956
|
+
process.exit(1);
|
|
957
|
+
}
|
|
958
|
+
const { sessionLookupDir, openDir } = resolveSessionDir(termBlocks[0].meta?.["cmd:cwd"] ?? process.cwd());
|
|
959
|
+
const sessionId = findLatestSessionId(sessionLookupDir);
|
|
960
|
+
if (!sessionId) {
|
|
961
|
+
consola.error(`No Claude session found for ${sessionLookupDir}`);
|
|
962
|
+
consola.info(`Looked in ~/.claude/projects/${pathToProjectSlug(sessionLookupDir)}/`);
|
|
963
|
+
process.exit(1);
|
|
964
|
+
}
|
|
965
|
+
const newTabId = await openSession({
|
|
966
|
+
tabName: newName,
|
|
967
|
+
dir: openDir,
|
|
968
|
+
claudeCmd: `claude --resume ${sessionId} --fork-session`
|
|
969
|
+
});
|
|
970
|
+
consola.success(`Forked "${tabName}" → "${newName}" [${newTabId.slice(0, 8)}]`);
|
|
971
|
+
consola.info(`session: ${sessionId}`);
|
|
972
|
+
}
|
|
973
|
+
});
|
|
974
|
+
//#endregion
|
|
975
|
+
//#region src/commands/close.ts
|
|
976
|
+
const closeCommand = define({
|
|
977
|
+
name: "close",
|
|
978
|
+
description: "Close a tab by name or ID prefix",
|
|
979
|
+
args: { tab: {
|
|
980
|
+
type: "positional",
|
|
981
|
+
description: "Tab name or ID prefix"
|
|
982
|
+
} },
|
|
983
|
+
async run(ctx) {
|
|
984
|
+
const query = ctx.positionals[1];
|
|
985
|
+
if (!query) {
|
|
986
|
+
consola.error("Tab name or ID is required");
|
|
987
|
+
process.exit(1);
|
|
988
|
+
}
|
|
989
|
+
const adapter = requireWaveAdapter();
|
|
990
|
+
const { tabsById, tabNames } = await adapter.getAllData();
|
|
991
|
+
const matches = adapter.resolveTab(query, tabsById, tabNames);
|
|
992
|
+
if (!matches.length) {
|
|
993
|
+
consola.error(`No tab matching '${query}' (tabs in workspaces with no open window are not visible — open that workspace first)`);
|
|
994
|
+
process.exit(1);
|
|
995
|
+
}
|
|
996
|
+
if (matches.length > 1) {
|
|
997
|
+
consola.error(`Multiple tabs match '${query}':`);
|
|
998
|
+
for (const tid of matches) consola.log(` "${tabNames.get(tid)}" [${tid.slice(0, 8)}]`);
|
|
999
|
+
process.exit(1);
|
|
1000
|
+
}
|
|
1001
|
+
const tabId = matches[0];
|
|
1002
|
+
const name = tabNames.get(tabId) ?? tabId.slice(0, 8);
|
|
1003
|
+
for (const b of tabsById.get(tabId) ?? []) adapter.deleteBlock(b.blockid);
|
|
1004
|
+
adapter.closeSocket();
|
|
1005
|
+
consola.success(`Closed "${name}" [${tabId.slice(0, 8)}]`);
|
|
1006
|
+
}
|
|
1007
|
+
});
|
|
1008
|
+
//#endregion
|
|
1009
|
+
//#region src/commands/rename.ts
|
|
1010
|
+
const renameCommand = define({
|
|
1011
|
+
name: "rename",
|
|
1012
|
+
description: "Rename a tab",
|
|
1013
|
+
args: {
|
|
1014
|
+
tab: {
|
|
1015
|
+
type: "positional",
|
|
1016
|
+
description: "Tab name or ID prefix"
|
|
1017
|
+
},
|
|
1018
|
+
newName: {
|
|
1019
|
+
type: "positional",
|
|
1020
|
+
description: "New name"
|
|
1021
|
+
}
|
|
1022
|
+
},
|
|
1023
|
+
async run(ctx) {
|
|
1024
|
+
const query = ctx.positionals[1];
|
|
1025
|
+
const newName = ctx.positionals[2];
|
|
1026
|
+
if (!query || !newName) {
|
|
1027
|
+
consola.error("Usage: herd rename <tab> <new-name>");
|
|
1028
|
+
process.exit(1);
|
|
1029
|
+
}
|
|
1030
|
+
const adapter = requireWaveAdapter();
|
|
1031
|
+
const { tabsById, tabNames } = await adapter.getAllData();
|
|
1032
|
+
const matches = adapter.resolveTab(query, tabsById, tabNames);
|
|
1033
|
+
if (!matches.length) {
|
|
1034
|
+
consola.error(`No tab matching '${query}'`);
|
|
1035
|
+
process.exit(1);
|
|
1036
|
+
}
|
|
1037
|
+
if (matches.length > 1) {
|
|
1038
|
+
consola.error(`Multiple tabs match '${query}':`);
|
|
1039
|
+
for (const tid of matches) consola.log(` "${tabNames.get(tid)}" [${tid.slice(0, 8)}]`);
|
|
1040
|
+
process.exit(1);
|
|
1041
|
+
}
|
|
1042
|
+
const oldName = tabNames.get(matches[0]) ?? matches[0].slice(0, 8);
|
|
1043
|
+
await adapter.renameTab(matches[0], newName);
|
|
1044
|
+
adapter.closeSocket();
|
|
1045
|
+
consola.success(`Renamed "${oldName}" → "${newName}"`);
|
|
1046
|
+
}
|
|
1047
|
+
});
|
|
1048
|
+
//#endregion
|
|
1049
|
+
//#region src/commands/scrollback.ts
|
|
1050
|
+
const scrollbackCommand = define({
|
|
1051
|
+
name: "scrollback",
|
|
1052
|
+
description: "Show terminal output for a tab or block (default: last 50 lines)",
|
|
1053
|
+
args: {
|
|
1054
|
+
target: {
|
|
1055
|
+
type: "positional",
|
|
1056
|
+
description: "Tab name, tab ID prefix, or block ID prefix"
|
|
1057
|
+
},
|
|
1058
|
+
lines: {
|
|
1059
|
+
type: "number",
|
|
1060
|
+
description: "Number of lines to show",
|
|
1061
|
+
default: 50
|
|
1062
|
+
}
|
|
1063
|
+
},
|
|
1064
|
+
async run(ctx) {
|
|
1065
|
+
const query = ctx.positionals[1];
|
|
1066
|
+
const lines = ctx.values.lines ?? 50;
|
|
1067
|
+
if (!query) {
|
|
1068
|
+
consola.error("Tab name or block ID is required");
|
|
1069
|
+
process.exit(1);
|
|
1070
|
+
}
|
|
1071
|
+
const adapter = requireWaveAdapter();
|
|
1072
|
+
const { tabsById, tabNames } = await adapter.getAllData();
|
|
1073
|
+
const tabMatches = adapter.resolveTab(query, tabsById, tabNames);
|
|
1074
|
+
let blockId;
|
|
1075
|
+
if (tabMatches.length === 1) {
|
|
1076
|
+
const blocks = (tabsById.get(tabMatches[0]) ?? []).filter((b) => b.view === "term");
|
|
1077
|
+
if (!blocks.length) {
|
|
1078
|
+
consola.error(`Tab "${tabNames.get(tabMatches[0])}" has no terminal block`);
|
|
1079
|
+
process.exit(1);
|
|
1080
|
+
}
|
|
1081
|
+
blockId = blocks[0].blockid;
|
|
1082
|
+
} else if (tabMatches.length > 1) {
|
|
1083
|
+
consola.error(`Multiple tabs match '${query}':`);
|
|
1084
|
+
for (const tid of tabMatches) consola.log(` "${tabNames.get(tid)}" [${tid.slice(0, 8)}]`);
|
|
1085
|
+
process.exit(1);
|
|
1086
|
+
} else {
|
|
1087
|
+
const allBlocks = adapter.blocksList();
|
|
1088
|
+
const blockMatches = adapter.resolveBlock(query, allBlocks);
|
|
1089
|
+
if (!blockMatches.length) {
|
|
1090
|
+
consola.error(`No tab or block matching '${query}' (tabs in workspaces with no open window are not visible — open that workspace first)`);
|
|
1091
|
+
process.exit(1);
|
|
1092
|
+
}
|
|
1093
|
+
if (blockMatches.length > 1) {
|
|
1094
|
+
consola.error(`Multiple blocks match '${query}':`);
|
|
1095
|
+
for (const b of blockMatches) consola.log(` ${b.blockid}`);
|
|
1096
|
+
process.exit(1);
|
|
1097
|
+
}
|
|
1098
|
+
blockId = blockMatches[0].blockid;
|
|
1099
|
+
}
|
|
1100
|
+
process.stdout.write(adapter.scrollback(blockId, lines));
|
|
1101
|
+
}
|
|
1102
|
+
});
|
|
1103
|
+
//#endregion
|
|
1104
|
+
//#region src/commands/send.ts
|
|
1105
|
+
function readStdin() {
|
|
1106
|
+
return new Promise((resolve) => {
|
|
1107
|
+
const chunks = [];
|
|
1108
|
+
process.stdin.on("data", (c) => chunks.push(c));
|
|
1109
|
+
process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString()));
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
const sendCommand = define({
|
|
1113
|
+
name: "send",
|
|
1114
|
+
description: "Send input to a tab or block (text arg, --file, or stdin pipe)",
|
|
1115
|
+
args: {
|
|
1116
|
+
target: {
|
|
1117
|
+
type: "positional",
|
|
1118
|
+
description: "Tab name, tab ID prefix, or block ID prefix"
|
|
1119
|
+
},
|
|
1120
|
+
file: {
|
|
1121
|
+
type: "string",
|
|
1122
|
+
short: "f",
|
|
1123
|
+
description: "Read text from file"
|
|
1124
|
+
},
|
|
1125
|
+
enter: {
|
|
1126
|
+
type: "boolean",
|
|
1127
|
+
short: "e",
|
|
1128
|
+
description: "Append newline after text (default: true)"
|
|
1129
|
+
}
|
|
1130
|
+
},
|
|
1131
|
+
async run(ctx) {
|
|
1132
|
+
const query = ctx.positionals[1];
|
|
1133
|
+
const inlineText = ctx.positionals[2];
|
|
1134
|
+
const filePath = ctx.values.file;
|
|
1135
|
+
const appendEnter = ctx.values.enter ?? true;
|
|
1136
|
+
if (!query) {
|
|
1137
|
+
consola.error("Usage: herd send <tab-or-block> [text]");
|
|
1138
|
+
process.exit(1);
|
|
1139
|
+
}
|
|
1140
|
+
let rawText;
|
|
1141
|
+
if (inlineText !== void 0) rawText = inlineText.replace(/\\n/g, "\r").replace(/\\r/g, "\r").replace(/\\t/g, " ");
|
|
1142
|
+
else if (filePath) rawText = readFileSync(filePath, "utf-8").replace(/\n/g, "\r");
|
|
1143
|
+
else rawText = (await readStdin()).replace(/\n/g, "\r");
|
|
1144
|
+
if (appendEnter && !rawText.endsWith("\r")) rawText += "\r";
|
|
1145
|
+
const adapter = requireWaveAdapter();
|
|
1146
|
+
const { tabsById, tabNames } = await adapter.getAllData();
|
|
1147
|
+
const tabMatches = adapter.resolveTab(query, tabsById, tabNames);
|
|
1148
|
+
let blockId;
|
|
1149
|
+
if (tabMatches.length === 1) {
|
|
1150
|
+
const blocks = (tabsById.get(tabMatches[0]) ?? []).filter((b) => b.view === "term");
|
|
1151
|
+
if (!blocks.length) {
|
|
1152
|
+
consola.error(`Tab "${tabNames.get(tabMatches[0])}" has no terminal block`);
|
|
1153
|
+
process.exit(1);
|
|
1154
|
+
}
|
|
1155
|
+
blockId = blocks[0].blockid;
|
|
1156
|
+
} else if (tabMatches.length > 1) {
|
|
1157
|
+
consola.error(`Multiple tabs match '${query}':`);
|
|
1158
|
+
for (const tid of tabMatches) consola.log(` "${tabNames.get(tid)}" [${tid.slice(0, 8)}]`);
|
|
1159
|
+
process.exit(1);
|
|
1160
|
+
} else {
|
|
1161
|
+
const allBlocks = adapter.blocksList();
|
|
1162
|
+
const blockMatches = adapter.resolveBlock(query, allBlocks);
|
|
1163
|
+
if (!blockMatches.length) {
|
|
1164
|
+
consola.error(`No tab or block matching '${query}' (tabs in workspaces with no open window are not visible — open that workspace first)`);
|
|
1165
|
+
process.exit(1);
|
|
1166
|
+
}
|
|
1167
|
+
if (blockMatches.length > 1) {
|
|
1168
|
+
consola.error(`Multiple blocks match '${query}':`);
|
|
1169
|
+
for (const b of blockMatches) consola.log(` ${b.blockid}`);
|
|
1170
|
+
process.exit(1);
|
|
1171
|
+
}
|
|
1172
|
+
blockId = blockMatches[0].blockid;
|
|
1173
|
+
}
|
|
1174
|
+
const resp = await adapter.sendInput(blockId, rawText);
|
|
1175
|
+
adapter.closeSocket();
|
|
1176
|
+
if (resp && resp.error) {
|
|
1177
|
+
consola.error(String(resp.error));
|
|
1178
|
+
process.exit(1);
|
|
1179
|
+
}
|
|
1180
|
+
const preview = rawText.slice(0, 80).replace(/\n/g, "↵").replace(/\t/g, "→");
|
|
1181
|
+
consola.success(`Sent to ${blockId.slice(0, 8)}: ${JSON.stringify(preview)}${rawText.length > 80 ? "…" : ""}`);
|
|
1182
|
+
}
|
|
1183
|
+
});
|
|
1184
|
+
//#endregion
|
|
1185
|
+
//#region src/commands/config-cmd.ts
|
|
1186
|
+
const configCommand = define({
|
|
1187
|
+
name: "config",
|
|
1188
|
+
description: "Show config file path and current values",
|
|
1189
|
+
args: {},
|
|
1190
|
+
async run() {
|
|
1191
|
+
ensureConfigExists();
|
|
1192
|
+
const config = loadConfig();
|
|
1193
|
+
consola.info(`Config: ${CONFIG_PATH}`);
|
|
1194
|
+
console.log();
|
|
1195
|
+
console.log(`claude.flags = ${config.claude.flags.length ? JSON.stringify(config.claude.flags) : "(none)"}`);
|
|
1196
|
+
console.log(`defaults.workspace = ${config.defaults.workspace || "(none)"}`);
|
|
1197
|
+
}
|
|
1198
|
+
});
|
|
1199
|
+
//#endregion
|
|
1200
|
+
//#region src/commands/restore.ts
|
|
1201
|
+
const restoreCommand = define({
|
|
1202
|
+
name: "restore",
|
|
1203
|
+
description: "Resume Claude sessions in all terminal-state tabs (e.g. after a reboot)",
|
|
1204
|
+
args: {
|
|
1205
|
+
dir: {
|
|
1206
|
+
type: "positional",
|
|
1207
|
+
description: "Working directory (default: cwd)"
|
|
1208
|
+
},
|
|
1209
|
+
dry: {
|
|
1210
|
+
type: "boolean",
|
|
1211
|
+
short: "n",
|
|
1212
|
+
description: "Show what would be resumed without actually doing it"
|
|
1213
|
+
}
|
|
1214
|
+
},
|
|
1215
|
+
async run(ctx) {
|
|
1216
|
+
const dir = resolve((ctx.positionals[1] ?? process.cwd()).replace(/^~/, homedir()));
|
|
1217
|
+
const dryRun = ctx.values.dry;
|
|
1218
|
+
const adapter = requireWaveAdapter();
|
|
1219
|
+
const { tabsById, workspaces, tabNames } = await adapter.getAllData();
|
|
1220
|
+
const currentTab = process.env.WAVETERM_TABID ?? "";
|
|
1221
|
+
const tabs = [];
|
|
1222
|
+
for (const wsp of workspaces) for (const tabId of wsp.workspacedata.tabids) {
|
|
1223
|
+
if (tabId === currentTab) continue;
|
|
1224
|
+
const blocks = (tabsById.get(tabId) ?? []).filter((b) => b.view === "term");
|
|
1225
|
+
if (!blocks.length) continue;
|
|
1226
|
+
const name = tabNames.get(tabId) ?? tabId.slice(0, 8);
|
|
1227
|
+
const status = adapter.detectSessionStatus(blocks[0].blockid);
|
|
1228
|
+
tabs.push({
|
|
1229
|
+
tabId,
|
|
1230
|
+
name,
|
|
1231
|
+
blockId: blocks[0].blockid,
|
|
1232
|
+
status
|
|
1233
|
+
});
|
|
1234
|
+
}
|
|
1235
|
+
const toResume = tabs.filter((t) => t.status === "terminal" || t.status === "unknown");
|
|
1236
|
+
const alreadyActive = tabs.filter((t) => t.status === "active" || t.status === "idle");
|
|
1237
|
+
if (alreadyActive.length) consola.info(`Already running: ${alreadyActive.map((t) => t.name).join(", ")}`);
|
|
1238
|
+
if (!toResume.length) {
|
|
1239
|
+
consola.info("No terminal-state tabs to restore.");
|
|
1240
|
+
adapter.closeSocket();
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
1243
|
+
consola.info(`Found ${toResume.length} tab(s) to restore:`);
|
|
1244
|
+
const extraFlags = loadConfig().claude.flags.join(" ");
|
|
1245
|
+
const results = [];
|
|
1246
|
+
for (const tab of toResume) {
|
|
1247
|
+
const sessions = findSessionsByName(dir, tab.name);
|
|
1248
|
+
if (sessions.length === 0) {
|
|
1249
|
+
consola.log(` ${tab.name} — no session named "${tab.name}" found, skipping`);
|
|
1250
|
+
results.push({
|
|
1251
|
+
name: tab.name,
|
|
1252
|
+
result: "no matching session"
|
|
1253
|
+
});
|
|
1254
|
+
continue;
|
|
1255
|
+
}
|
|
1256
|
+
if (sessions.length > 1) {
|
|
1257
|
+
consola.log(` ${tab.name} — multiple sessions found, skipping (use herd resume --session to pick one)`);
|
|
1258
|
+
results.push({
|
|
1259
|
+
name: tab.name,
|
|
1260
|
+
result: "ambiguous (multiple sessions)"
|
|
1261
|
+
});
|
|
1262
|
+
continue;
|
|
1263
|
+
}
|
|
1264
|
+
const sessionId = sessions[0].id;
|
|
1265
|
+
if (dryRun) {
|
|
1266
|
+
consola.log(` ${tab.name} → would resume session ${sessionId.slice(0, 8)}…`);
|
|
1267
|
+
results.push({
|
|
1268
|
+
name: tab.name,
|
|
1269
|
+
result: `dry run: ${sessionId.slice(0, 8)}…`
|
|
1270
|
+
});
|
|
1271
|
+
continue;
|
|
1272
|
+
}
|
|
1273
|
+
consola.log(` ${tab.name} → resuming session ${sessionId.slice(0, 8)}…`);
|
|
1274
|
+
const cmd = `cd ${JSON.stringify(dir)} && claude${extraFlags ? " " + extraFlags : ""} --resume ${sessionId} --name ${JSON.stringify(tab.name)}\r`;
|
|
1275
|
+
await adapter.sendInput(tab.blockId, cmd);
|
|
1276
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
1277
|
+
results.push({
|
|
1278
|
+
name: tab.name,
|
|
1279
|
+
result: "sent"
|
|
1280
|
+
});
|
|
1281
|
+
}
|
|
1282
|
+
if (!dryRun) {
|
|
1283
|
+
const sent = results.filter((r) => r.result === "sent");
|
|
1284
|
+
if (sent.length) {
|
|
1285
|
+
consola.info("Waiting for sessions to start…");
|
|
1286
|
+
await new Promise((r) => setTimeout(r, 1e4));
|
|
1287
|
+
for (const r of sent) {
|
|
1288
|
+
const tab = toResume.find((t) => t.name === r.name);
|
|
1289
|
+
const status = adapter.detectSessionStatus(tab.blockId);
|
|
1290
|
+
if (status === "active" || status === "idle") r.result = "✔ running";
|
|
1291
|
+
else if (status === "unknown") r.result = "? scrollback unavailable";
|
|
1292
|
+
else r.result = "✘ may not have started";
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
adapter.closeSocket();
|
|
1297
|
+
console.log("\nRestore summary:");
|
|
1298
|
+
for (const r of results) console.log(` ${r.name}: ${r.result}`);
|
|
1299
|
+
}
|
|
1300
|
+
});
|
|
1301
|
+
//#endregion
|
|
1302
|
+
//#region src/commands/index.ts
|
|
1303
|
+
const defaultCommand = define({
|
|
1304
|
+
name: "cctabs",
|
|
1305
|
+
description,
|
|
1306
|
+
args: {},
|
|
1307
|
+
async run() {
|
|
1308
|
+
await sessionsCommand.run?.call(this, { args: {} });
|
|
1309
|
+
}
|
|
1310
|
+
});
|
|
1311
|
+
const subCommands = new Map([
|
|
1312
|
+
["sessions", sessionsCommand],
|
|
1313
|
+
["list", listCommand],
|
|
1314
|
+
["ls", listCommand],
|
|
1315
|
+
["new", newCommand],
|
|
1316
|
+
["resume", resumeCommand],
|
|
1317
|
+
["fork", forkCommand],
|
|
1318
|
+
["close", closeCommand],
|
|
1319
|
+
["rename", renameCommand],
|
|
1320
|
+
["scrollback", scrollbackCommand],
|
|
1321
|
+
["send", sendCommand],
|
|
1322
|
+
["config", configCommand],
|
|
1323
|
+
["restore", restoreCommand]
|
|
1324
|
+
]);
|
|
1325
|
+
async function run() {
|
|
1326
|
+
await cli(process.argv.slice(2), defaultCommand, {
|
|
1327
|
+
name,
|
|
1328
|
+
version,
|
|
1329
|
+
description,
|
|
1330
|
+
subCommands
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
//#endregion
|
|
1334
|
+
//#region src/index.ts
|
|
1335
|
+
updateNotifier({ pkg: package_default }).notify();
|
|
1336
|
+
run().catch((err) => {
|
|
1337
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
1338
|
+
process.exit(1);
|
|
1339
|
+
});
|
|
1340
|
+
//#endregion
|
|
1341
|
+
export {};
|