claude-b 0.4.3 → 0.5.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.
Files changed (2) hide show
  1. package/dist/cli/index.js +303 -8
  2. package/package.json +1 -1
package/dist/cli/index.js CHANGED
@@ -160,7 +160,7 @@ var DaemonClient = class extends EventEmitter {
160
160
  // package.json
161
161
  var package_default = {
162
162
  name: "claude-b",
163
- version: "0.4.3",
163
+ version: "0.5.0",
164
164
  description: "Background-capable Claude Code with async workflows and REST API",
165
165
  type: "module",
166
166
  main: "dist/cli/index.js",
@@ -467,6 +467,184 @@ async function runInit() {
467
467
  }
468
468
  }
469
469
 
470
+ // src/tunnel/manager.ts
471
+ import { execFile } from "child_process";
472
+ import { promisify } from "util";
473
+ import { writeFile as writeFile2, mkdir as mkdir2, unlink, readdir } from "fs/promises";
474
+ import { existsSync as existsSync2 } from "fs";
475
+ import { homedir as homedir2 } from "os";
476
+ import { join as join2 } from "path";
477
+ import { createServer } from "net";
478
+ var exec = promisify(execFile);
479
+ var UNIT_PREFIX = "cb-tunnel-";
480
+ var NAME_RE = /^[a-zA-Z][a-zA-Z0-9._-]{0,31}$/;
481
+ function renderUnit(spec) {
482
+ return `[Unit]
483
+ Description=Persistent SSH tunnel for Claude-B (local:${spec.localPort} -> ${spec.sshHost}:${spec.remotePort})
484
+ After=network-online.target
485
+ Wants=network-online.target
486
+
487
+ [Service]
488
+ Type=simple
489
+ Environment=AUTOSSH_GATETIME=0
490
+ ExecStart=/usr/bin/autossh -M 0 -N -T \\
491
+ -o ServerAliveInterval=30 \\
492
+ -o ServerAliveCountMax=3 \\
493
+ -o ExitOnForwardFailure=yes \\
494
+ -o StrictHostKeyChecking=accept-new \\
495
+ -L ${spec.localPort}:127.0.0.1:${spec.remotePort} \\
496
+ ${spec.sshHost}
497
+ Restart=always
498
+ RestartSec=10
499
+
500
+ [Install]
501
+ WantedBy=${spec.system ? "multi-user.target" : "default.target"}
502
+ `;
503
+ }
504
+ function unitName(name) {
505
+ return `${UNIT_PREFIX}${name}.service`;
506
+ }
507
+ function unitPath(name, system) {
508
+ if (system) return `/etc/systemd/system/${unitName(name)}`;
509
+ return join2(homedir2(), ".config", "systemd", "user", unitName(name));
510
+ }
511
+ async function pickLocalPort() {
512
+ for (let p = 13847; p < 13947; p++) {
513
+ const free = await new Promise((resolve) => {
514
+ const srv = createServer();
515
+ srv.once("error", () => resolve(false));
516
+ srv.once("listening", () => srv.close(() => resolve(true)));
517
+ srv.listen(p, "127.0.0.1");
518
+ });
519
+ if (free) return p;
520
+ }
521
+ throw new Error("no free port in 13847-13946 \u2014 pass --tunnel-local-port");
522
+ }
523
+ async function which(bin) {
524
+ try {
525
+ const { stdout } = await exec("which", [bin]);
526
+ return stdout.trim() || null;
527
+ } catch {
528
+ return null;
529
+ }
530
+ }
531
+ async function sshReachable(host) {
532
+ try {
533
+ await exec("ssh", [
534
+ "-o",
535
+ "BatchMode=yes",
536
+ "-o",
537
+ "ConnectTimeout=5",
538
+ "-o",
539
+ "StrictHostKeyChecking=accept-new",
540
+ host,
541
+ "true"
542
+ ]);
543
+ return { ok: true };
544
+ } catch (err) {
545
+ const msg = err instanceof Error ? err.message : String(err);
546
+ return { ok: false, reason: msg.split("\n")[0] };
547
+ }
548
+ }
549
+ async function systemctl(args, system) {
550
+ const cmd = system ? ["sudo", "systemctl", ...args] : ["systemctl", "--user", ...args];
551
+ const { stdout } = await exec(cmd[0], cmd.slice(1));
552
+ return stdout.trim();
553
+ }
554
+ async function resolveSpec(spec) {
555
+ if (!NAME_RE.test(spec.name)) {
556
+ throw new Error(
557
+ `invalid tunnel name "${spec.name}" \u2014 must match ${NAME_RE} (letters/digits/._- ; starts with letter; max 32 chars)`
558
+ );
559
+ }
560
+ if (!spec.sshHost) throw new Error("--tunnel-ssh-host is required with --install-tunnel");
561
+ const remotePort = spec.remotePort ?? 3847;
562
+ const localPort = spec.localPort ?? await pickLocalPort();
563
+ return {
564
+ name: spec.name,
565
+ sshHost: spec.sshHost,
566
+ localPort,
567
+ remotePort,
568
+ system: spec.system ?? false
569
+ };
570
+ }
571
+ async function install(spec) {
572
+ const resolved = await resolveSpec(spec);
573
+ const autossh = await which("autossh");
574
+ if (!autossh) {
575
+ throw new Error("autossh is not installed \u2014 install with `dnf install autossh` (RHEL) or `apt install autossh` (Debian)");
576
+ }
577
+ const reach = await sshReachable(resolved.sshHost);
578
+ if (!reach.ok) {
579
+ throw new Error(
580
+ `ssh to "${resolved.sshHost}" failed in batch mode (${reach.reason}). autossh needs unattended key auth \u2014 check your ssh config / agent before installing.`
581
+ );
582
+ }
583
+ const path = unitPath(resolved.name, resolved.system);
584
+ if (existsSync2(path)) {
585
+ throw new Error(`unit already exists at ${path} \u2014 run \`cb --remove-tunnel ${resolved.name}\` first`);
586
+ }
587
+ await mkdir2(join2(path, ".."), { recursive: true });
588
+ await writeFile2(path, renderUnit(resolved), { mode: 420 });
589
+ await systemctl(["daemon-reload"], resolved.system);
590
+ await systemctl(["enable", "--now", unitName(resolved.name)], resolved.system);
591
+ await new Promise((r) => setTimeout(r, 3e3));
592
+ let active = false;
593
+ try {
594
+ const state = await systemctl(["is-active", unitName(resolved.name)], resolved.system);
595
+ active = state === "active";
596
+ } catch {
597
+ active = false;
598
+ }
599
+ return { unitPath: path, spec: resolved, active };
600
+ }
601
+ async function remove(name, system) {
602
+ if (!NAME_RE.test(name)) throw new Error(`invalid tunnel name "${name}"`);
603
+ const path = unitPath(name, system);
604
+ if (!existsSync2(path)) {
605
+ const otherPath = unitPath(name, !system);
606
+ if (existsSync2(otherPath)) {
607
+ throw new Error(`tunnel "${name}" is installed in the ${system ? "user" : "system"} scope, not ${system ? "system" : "user"}. Re-run with${system ? "out" : ""} --tunnel-system.`);
608
+ }
609
+ return { removed: false, unitPath: path };
610
+ }
611
+ try {
612
+ await systemctl(["disable", "--now", unitName(name)], system);
613
+ } catch {
614
+ }
615
+ await unlink(path);
616
+ await systemctl(["daemon-reload"], system).catch(() => void 0);
617
+ return { removed: true, unitPath: path };
618
+ }
619
+ async function list() {
620
+ const out = [];
621
+ const dirs = [
622
+ { dir: join2(homedir2(), ".config", "systemd", "user"), scope: "user" },
623
+ { dir: "/etc/systemd/system", scope: "system" }
624
+ ];
625
+ for (const { dir, scope } of dirs) {
626
+ let names = [];
627
+ try {
628
+ names = await readdir(dir);
629
+ } catch {
630
+ continue;
631
+ }
632
+ for (const file of names) {
633
+ if (!file.startsWith(UNIT_PREFIX) || !file.endsWith(".service")) continue;
634
+ const name = file.slice(UNIT_PREFIX.length, -".service".length);
635
+ let active = false;
636
+ try {
637
+ const state = await systemctl(["is-active", file], scope === "system");
638
+ active = state === "active";
639
+ } catch {
640
+ active = false;
641
+ }
642
+ out.push({ name, unitPath: join2(dir, file), scope, active });
643
+ }
644
+ }
645
+ return out;
646
+ }
647
+
470
648
  // src/cli/index.ts
471
649
  loadEnv();
472
650
  var program = new Command();
@@ -480,7 +658,48 @@ program.command("init").description("Interactive setup: writes ~/.claude-b/.env,
480
658
  process.exit(1);
481
659
  }
482
660
  });
483
- program.argument("[prompt...]", "Prompt to send to Claude").option("-l, --last", "Show status and output of last prompt").option("-s, --sess", "List all sessions").option("-a, --attach <id>", "Attach to session (foreground mode)").option("-d, --detach", "Detach from current session").option("-n, --new [name]", "Create new session").option("-m, --model <model>", "Claude model to use").option("-k, --kill <id>", "Kill/terminate session").option("-w, --watch", "Watch live output (tail -f style)").option("-x, --select <id>", "Select session for subsequent commands").option("-c, --current", "Show current selected session").option("-r, --rest [port]", "Start REST API server").option("--rest-stop", "Stop REST API server").option("--status", "Daemon status and health").option("--logs", "View daemon logs").option("--hook <event> <cmd>", "Register shell hook for event").option("--hook-session <id>", "Only trigger hook for this session (use with --hook)").option("--unhook <id>", "Remove a shell hook").option("--hooks", "List all shell hooks").option("--webhook <url>", "Register webhook for notifications").option("--webhook-event <event>", "Event type for webhook (default: *)").option("--webhook-session <id>", "Only trigger webhook for this session (use with --webhook)").option("--unwebhook <id>", "Remove a webhook").option("--webhooks", "List all webhooks").option("--hook-stats", "Show hook statistics").option("--api-key", "Generate/show API key for REST access").option("--config", "Edit configuration").option("--export <id>", "Export session transcript").option("--import <file>", "Import session from file").option("--remote-add <url>", "Add a remote Claude-B host").option("--remote-key <apiKey>", "API key for remote host (use with --remote-add)").option("--remote-name <name>", "Name for remote host (use with --remote-add)").option("--remote-priority <n>", "Priority for remote host (use with --remote-add)").option("--remote-remove <id>", "Remove a remote host").option("--remote-toggle <id>", "Toggle remote host enabled/disabled").option("--remote-hosts", "List all remote hosts").option("--remote-health", "Show health status of remote hosts").option("--remote-stats", "Show orchestration statistics").option("--remote <hostId>", "Send prompt to specific remote host").option("-f, --fire", "Fire and forget (launch task in background, no watching)").option("-g, --goal <description>", "Goal/objective for fire-and-forget task (use with -f)").option("-i, --inbox", "Show notification inbox (completed tasks)").option("--inbox-clear", "Mark all notifications as read").option("--inbox-count", "Show unread notification count").option("--remote-fire <hostId>", "Fire and forget to remote host").option("--telegram <token>", "Set up Telegram bot with token").option("--telegram-stop", "Disable Telegram notifications").option("--telegram-status", "Show Telegram bot status").option("--telegram-forward <on|off>", "Toggle forwarding all session completions to Telegram").option("--voice-setup <provider>", "Configure STT provider: speechmatics, deepgram, or openai").option("--ai-provider <config>", 'Set AI provider: "anthropic <key>" or "openrouter <key>"').option("--voice-status", "Show voice pipeline status").option("--shell-init", "Output shell hook for inbox notifications (add to .bashrc/.zshrc)").action(async (promptParts, options) => {
661
+ program.argument("[prompt...]", "Prompt to send to Claude").option("-l, --last", "Show status and output of last prompt").option("-s, --sess", "List all sessions").option("-a, --attach <id>", "Attach to session (foreground mode)").option("-d, --detach", "Detach from current session").option("-n, --new [name]", "Create new session").option("-m, --model <model>", "Claude model to use").option("-k, --kill <id>", "Kill/terminate session").option("-w, --watch", "Watch live output (tail -f style)").option("-x, --select <id>", "Select session for subsequent commands").option("-c, --current", "Show current selected session").option("-r, --rest [port]", "Start REST API server").option("--rest-stop", "Stop REST API server").option("--status", "Daemon status and health").option("--logs", "View daemon logs").option("--hook <event> <cmd>", "Register shell hook for event").option("--hook-session <id>", "Only trigger hook for this session (use with --hook)").option("--unhook <id>", "Remove a shell hook").option("--hooks", "List all shell hooks").option("--webhook <url>", "Register webhook for notifications").option("--webhook-event <event>", "Event type for webhook (default: *)").option("--webhook-session <id>", "Only trigger webhook for this session (use with --webhook)").option("--unwebhook <id>", "Remove a webhook").option("--webhooks", "List all webhooks").option("--hook-stats", "Show hook statistics").option("--api-key", "Generate/show API key for REST access").option("--config", "Edit configuration").option("--export <id>", "Export session transcript").option("--import <file>", "Import session from file").option("--remote-add <url>", "Add a remote Claude-B host").option("--remote-key <apiKey>", "API key for remote host (use with --remote-add)").option("--remote-name <name>", "Name for remote host (use with --remote-add)").option("--remote-priority <n>", "Priority for remote host (use with --remote-add)").option("--remote-remove <id>", "Remove a remote host").option("--remote-toggle <id>", "Toggle remote host enabled/disabled").option("--remote-hosts", "List all remote hosts").option("--remote-health", "Show health status of remote hosts").option("--remote-stats", "Show orchestration statistics").option("--remote <hostId>", "Send prompt to specific remote host").option("-f, --fire", "Fire and forget (launch task in background, no watching)").option("-g, --goal <description>", "Goal/objective for fire-and-forget task (use with -f)").option("-i, --inbox", "Show notification inbox (completed tasks)").option("--inbox-clear", "Mark all notifications as read").option("--inbox-count", "Show unread notification count").option("--remote-fire <hostId>", "Fire and forget to remote host").option("--install-tunnel <name>", "Install a persistent SSH tunnel (autossh + systemd) so --remote-fire can reach a remote daemon").option("--tunnel-ssh-host <host>", "SSH host alias to tunnel to (required with --install-tunnel)").option("--tunnel-local-port <port>", "Local TCP port to bind (default: auto-pick 13847+)").option("--tunnel-remote-port <port>", "Remote port on the target host (default: 3847)").option("--tunnel-system", "Install at /etc/systemd/system instead of user scope (requires sudo)").option("--remove-tunnel <name>", "Remove an installed tunnel and its systemd unit").option("--list-tunnels", "List installed tunnels (user and system scope)").option("--telegram <token>", "Set up Telegram bot with token").option("--telegram-stop", "Disable Telegram notifications").option("--telegram-status", "Show Telegram bot status").option("--telegram-forward <on|off>", "Toggle forwarding all session completions to Telegram").option("--voice-setup <provider>", "Configure STT provider: speechmatics, deepgram, or openai").option("--ai-provider <config>", 'Set AI provider: "anthropic <key>" or "openrouter <key>"').option("--voice-status", "Show voice pipeline status").option("--shell-init", "Output shell hook for inbox notifications (add to .bashrc/.zshrc)").addHelpText("after", `
662
+ USE CASES \u2014 when to reach for which group of commands:
663
+
664
+ Local sessions (default)
665
+ cb "prompt" send a prompt; auto-creates a session
666
+ cb -s / -l / -w / -a <id> list / last output / live tail / attach
667
+ cb -f -g "<goal>" "<prompt>" fire-and-forget on this host
668
+
669
+ Cross-host orchestration (talk to a Claude-B daemon on another machine)
670
+ cb --remote-add <url> --remote-key <k> --remote-name <name>
671
+ register a remote daemon
672
+ cb --remote-fire <name> "..." fire-and-forget on the named host
673
+ cb --remote <name> "..." synchronous send to that host
674
+ cb --remote-hosts / --remote-health / --remote-stats
675
+ inspect orchestration state
676
+ cb -i read replies (notification inbox)
677
+
678
+ Cross-host plumbing (only needed if --remote-fire can't reach the target)
679
+ cb --install-tunnel <name> --tunnel-ssh-host <ssh-alias>
680
+ installs an autossh systemd unit so
681
+ a local port forwards to the remote
682
+ daemon's 127.0.0.1:3847. Survives
683
+ reboots and network blips.
684
+ cb --remove-tunnel <name> undoes it
685
+ cb --list-tunnels shows installed tunnels
686
+
687
+ The daemon binds 127.0.0.1:3847 only, so cross-host orchestration always
688
+ needs a TCP path. If both hosts share a private network and you can
689
+ reconfigure the daemon, bind it to that interface directly. If not (the
690
+ usual case), install a tunnel and point --remote-add at localhost:<port>.
691
+
692
+ Typical first-time setup, host A \u2192 host B:
693
+ 1. cb --install-tunnel b --tunnel-ssh-host hostB
694
+ 2. cb --remote-add http://localhost:13847 \\
695
+ --remote-key "$(ssh hostB cat ~/.claude-b/api.key)" \\
696
+ --remote-name b
697
+ 3. cb --remote-fire b "hello from A"
698
+
699
+ Read in --help-as-documentation order: local \u2192 remote \u2192 tunnels.
700
+ --remote-fire is the verb you'll type day to day; --install-tunnel is a
701
+ one-time install on each pair of hosts.
702
+ `).action(async (promptParts, options) => {
484
703
  if (options.shellInit) {
485
704
  printShellInit();
486
705
  return;
@@ -676,6 +895,27 @@ program.argument("[prompt...]", "Prompt to send to Claude").option("-l, --last",
676
895
  await showVoiceStatus(client);
677
896
  return;
678
897
  }
898
+ if (options.installTunnel) {
899
+ client.close();
900
+ await installTunnelCmd({
901
+ name: options.installTunnel,
902
+ sshHost: options.tunnelSshHost,
903
+ localPort: options.tunnelLocalPort ? Number(options.tunnelLocalPort) : void 0,
904
+ remotePort: options.tunnelRemotePort ? Number(options.tunnelRemotePort) : void 0,
905
+ system: Boolean(options.tunnelSystem)
906
+ });
907
+ return;
908
+ }
909
+ if (options.removeTunnel) {
910
+ client.close();
911
+ await removeTunnelCmd(options.removeTunnel, Boolean(options.tunnelSystem));
912
+ return;
913
+ }
914
+ if (options.listTunnels) {
915
+ client.close();
916
+ await listTunnelsCmd();
917
+ return;
918
+ }
679
919
  if (options.remoteFire) {
680
920
  if (promptParts.length === 0) {
681
921
  console.error(chalk2.red("Prompt required for remote fire-and-forget"));
@@ -909,8 +1149,8 @@ async function showStatus(client) {
909
1149
  process.exit(0);
910
1150
  }
911
1151
  async function showLogs() {
912
- const { homedir: homedir2 } = await import("os");
913
- const logFile = `${homedir2()}/.claude-b/daemon.log`;
1152
+ const { homedir: homedir3 } = await import("os");
1153
+ const logFile = `${homedir3()}/.claude-b/daemon.log`;
914
1154
  console.log(chalk2.gray(`Log file: ${logFile}`));
915
1155
  console.log(chalk2.gray("(Log viewing implementation pending)"));
916
1156
  process.exit(0);
@@ -964,10 +1204,10 @@ async function sendPrompt(client, prompt, model) {
964
1204
  }
965
1205
  async function startDaemon() {
966
1206
  const { spawn } = await import("child_process");
967
- const { homedir: homedir2 } = await import("os");
968
- const { mkdir: mkdir2 } = await import("fs/promises");
969
- const configDir = `${homedir2()}/.claude-b`;
970
- await mkdir2(configDir, { recursive: true });
1207
+ const { homedir: homedir3 } = await import("os");
1208
+ const { mkdir: mkdir3 } = await import("fs/promises");
1209
+ const configDir = `${homedir3()}/.claude-b`;
1210
+ await mkdir3(configDir, { recursive: true });
971
1211
  const daemon = spawn("node", ["dist/daemon/index.js"], {
972
1212
  cwd: process.cwd(),
973
1213
  detached: true,
@@ -1330,6 +1570,61 @@ async function fireRemotePrompt(client, hostId, prompt, goal) {
1330
1570
  console.log(` ${chalk2.yellow("cb -i")} ${chalk2.gray("# check when done")}`);
1331
1571
  process.exit(0);
1332
1572
  }
1573
+ async function installTunnelCmd(spec) {
1574
+ try {
1575
+ const result = await install(spec);
1576
+ console.log(chalk2.green("Tunnel installed"));
1577
+ console.log(` Name: ${chalk2.cyan(result.spec.name)}`);
1578
+ console.log(` Unit: ${chalk2.gray(result.unitPath)}`);
1579
+ console.log(` Forward: ${chalk2.cyan(`127.0.0.1:${result.spec.localPort}`)} \u2192 ${chalk2.cyan(`${result.spec.sshHost}:${result.spec.remotePort}`)}`);
1580
+ console.log(` Scope: ${result.spec.system ? "system (sudo systemctl)" : "user (systemctl --user)"}`);
1581
+ console.log(` Active: ${result.active ? chalk2.green("yes") : chalk2.yellow("not yet \u2014 check `systemctl status`")}`);
1582
+ console.log("");
1583
+ console.log(chalk2.gray("Next:"));
1584
+ console.log(` cb --remote-add http://localhost:${result.spec.localPort} \\`);
1585
+ console.log(` --remote-key "$(ssh ${result.spec.sshHost} cat ~/.claude-b/api.key)" \\`);
1586
+ console.log(` --remote-name ${result.spec.name}`);
1587
+ console.log(` cb --remote-fire ${result.spec.name} "your prompt"`);
1588
+ process.exit(0);
1589
+ } catch (err) {
1590
+ console.error(chalk2.red(err instanceof Error ? err.message : String(err)));
1591
+ process.exit(1);
1592
+ }
1593
+ }
1594
+ async function removeTunnelCmd(name, system) {
1595
+ try {
1596
+ const result = await remove(name, system);
1597
+ if (result.removed) {
1598
+ console.log(chalk2.green(`Tunnel removed: ${name}`));
1599
+ console.log(chalk2.gray(` ${result.unitPath}`));
1600
+ } else {
1601
+ console.log(chalk2.yellow(`No tunnel named "${name}" in ${system ? "system" : "user"} scope`));
1602
+ }
1603
+ process.exit(0);
1604
+ } catch (err) {
1605
+ console.error(chalk2.red(err instanceof Error ? err.message : String(err)));
1606
+ process.exit(1);
1607
+ }
1608
+ }
1609
+ async function listTunnelsCmd() {
1610
+ try {
1611
+ const tunnels = await list();
1612
+ if (tunnels.length === 0) {
1613
+ console.log(chalk2.gray("No tunnels installed."));
1614
+ console.log(chalk2.gray("Install one with: cb --install-tunnel <name> --tunnel-ssh-host <host>"));
1615
+ process.exit(0);
1616
+ }
1617
+ console.log(chalk2.bold("Installed tunnels:"));
1618
+ for (const t of tunnels) {
1619
+ const status = t.active ? chalk2.green("active") : chalk2.red("inactive");
1620
+ console.log(` ${chalk2.cyan(t.name.padEnd(20))} ${status.padEnd(20)} ${chalk2.gray(`${t.scope}:`)} ${chalk2.gray(t.unitPath)}`);
1621
+ }
1622
+ process.exit(0);
1623
+ } catch (err) {
1624
+ console.error(chalk2.red(err instanceof Error ? err.message : String(err)));
1625
+ process.exit(1);
1626
+ }
1627
+ }
1333
1628
  function renderMarkdown(text) {
1334
1629
  const lines = text.split("\n");
1335
1630
  const result = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-b",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "description": "Background-capable Claude Code with async workflows and REST API",
5
5
  "type": "module",
6
6
  "main": "dist/cli/index.js",