@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/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 {};