@bulletproof-sh/ctrl-daemon 0.0.6 → 0.0.8

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 (3) hide show
  1. package/README.md +17 -0
  2. package/dist/index.js +92 -23
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -26,10 +26,27 @@ npx @bulletproof-sh/ctrl-daemon --port 3001 --host 127.0.0.1
26
26
  | `--port <number>` | `3001` | Port to listen on |
27
27
  | `--host <address>` | `0.0.0.0` | Host/address to bind to |
28
28
  | `--project-dir <path>` | — | Watch a single project; omit to watch all projects |
29
+ | `--idle-timeout <minutes>` | `15` | Agent idle timeout in minutes |
29
30
  | `--help`, `-h` | — | Print usage |
30
31
 
31
32
  Without `--project-dir`, the daemon scans `~/.claude/projects/` and watches every session it finds there.
32
33
 
34
+ ## Sharing your office
35
+
36
+ Run the daemon and an [ngrok](https://ngrok.com) tunnel in parallel to share a live view with anyone:
37
+
38
+ ```sh
39
+ # Terminal 1 — start the daemon
40
+ npx @bulletproof-sh/ctrl-daemon
41
+
42
+ # Terminal 2 — expose it publicly
43
+ ngrok http 3001
44
+ ```
45
+
46
+ Then open [bulletproof.sh](https://bulletproof.sh), go to **Settings → Share Office** and copy the generated link. Anyone who opens it will see your agents in real time — no auth or setup required on their end.
47
+
48
+ > **Note:** Free ngrok URLs rotate each time you restart the tunnel. A paid ngrok plan gives you a stable domain.
49
+
33
50
  ## WebSocket API
34
51
 
35
52
  Connect to `ws://localhost:3001/ws`. The daemon broadcasts JSON messages whenever agent state changes — the same message format used by the VS Code extension's internal webview protocol.
package/dist/index.js CHANGED
@@ -4245,6 +4245,70 @@ async function shutdownAnalytics() {
4245
4245
  await client.shutdown();
4246
4246
  }
4247
4247
 
4248
+ // src/banner.ts
4249
+ var BG = "\x1B[92m";
4250
+ var CY = "\x1B[36m";
4251
+ var YL = "\x1B[33m";
4252
+ var DM = "\x1B[2m";
4253
+ var BD = "\x1B[1m";
4254
+ var RS = "\x1B[0m";
4255
+ var LOGO_LINES = [
4256
+ " \u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2557 ",
4257
+ " \u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u255A\u2550\u2550\u2588\u2588\u2554\u2550\u2550\u255D\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2551 ",
4258
+ " \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u2588\u2588\u2551 ",
4259
+ " \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2551 ",
4260
+ " \u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557",
4261
+ " \u255A\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D"
4262
+ ];
4263
+ function printBanner() {
4264
+ process.stdout.write(`
4265
+ `);
4266
+ for (const line of LOGO_LINES) {
4267
+ process.stdout.write(`${BD}${BG}${line}${RS}
4268
+ `);
4269
+ }
4270
+ process.stdout.write(`
4271
+ `);
4272
+ }
4273
+ var SPINNER_FRAMES = [
4274
+ "\u280B",
4275
+ "\u2819",
4276
+ "\u2839",
4277
+ "\u2838",
4278
+ "\u283C",
4279
+ "\u2834",
4280
+ "\u2826",
4281
+ "\u2827",
4282
+ "\u2807",
4283
+ "\u280F"
4284
+ ];
4285
+ function startSpinner(text) {
4286
+ let i = 0;
4287
+ const clearWidth = text.length + 4;
4288
+ const timer = setInterval(() => {
4289
+ process.stdout.write(`\r${CY}${SPINNER_FRAMES[i++ % SPINNER_FRAMES.length]}${RS} ${text}`);
4290
+ }, 80);
4291
+ return () => {
4292
+ clearInterval(timer);
4293
+ process.stdout.write(`\r${" ".repeat(clearWidth)}\r`);
4294
+ };
4295
+ }
4296
+ function printReady(port, version2, updateMsg) {
4297
+ const versionStr = version2 ? ` ${DM}v${version2}${RS}` : "";
4298
+ if (updateMsg) {
4299
+ process.stdout.write(`
4300
+ ${YL}${updateMsg}${RS}
4301
+
4302
+ `);
4303
+ }
4304
+ console.log(` ${BG}\u2713${RS} WebSocket server on :${port}${versionStr}`);
4305
+ console.log(` ${BG}\u2713${RS} Watching Claude sessions`);
4306
+ console.log("");
4307
+ console.log(` ${DM}Share: run \`ngrok http ${port}\`, then open Settings \u2192 Share${RS}`);
4308
+ console.log(` ${DM}Issues: https://github.com/bulletproof-sh/ctrl${RS}`);
4309
+ console.log("");
4310
+ }
4311
+
4248
4312
  // src/projectScanner.ts
4249
4313
  import * as fs2 from "fs";
4250
4314
  import * as path2 from "path";
@@ -4255,7 +4319,7 @@ var PROJECT_SCAN_INTERVAL_MS = 1000;
4255
4319
  var TOOL_DONE_DELAY_MS = 300;
4256
4320
  var PERMISSION_TIMER_DELAY_MS = 7000;
4257
4321
  var TEXT_IDLE_DELAY_MS = 5000;
4258
- var AGENT_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
4322
+ var AGENT_IDLE_TIMEOUT_MS = 15 * 60 * 1000;
4259
4323
  var BASH_COMMAND_DISPLAY_MAX_LENGTH = 30;
4260
4324
  var TASK_DESCRIPTION_DISPLAY_MAX_LENGTH = 40;
4261
4325
 
@@ -4715,7 +4779,7 @@ function collectAllJsonlFiles(projectsRoot) {
4715
4779
  }
4716
4780
  return files;
4717
4781
  }
4718
- function startProjectScanner(rootDir, scanAll, agents, fileWatchers, pollingTimers, waitingTimers, permissionTimers, broadcast) {
4782
+ function startProjectScanner(rootDir, scanAll, agents, fileWatchers, pollingTimers, waitingTimers, permissionTimers, broadcast, idleTimeoutMs) {
4719
4783
  const knownJsonlFiles = new Set;
4720
4784
  let nextAgentId = 1;
4721
4785
  function scan() {
@@ -4724,9 +4788,9 @@ function startProjectScanner(rootDir, scanAll, agents, fileWatchers, pollingTime
4724
4788
  for (const { filePath, mtimeMs } of fileInfos) {
4725
4789
  if (knownJsonlFiles.has(filePath))
4726
4790
  continue;
4727
- knownJsonlFiles.add(filePath);
4728
- if (now - mtimeMs > AGENT_IDLE_TIMEOUT_MS)
4791
+ if (now - mtimeMs > idleTimeoutMs)
4729
4792
  continue;
4793
+ knownJsonlFiles.add(filePath);
4730
4794
  const id = nextAgentId++;
4731
4795
  const agentProjectDir = path2.dirname(filePath);
4732
4796
  const agent = {
@@ -4753,7 +4817,7 @@ function startProjectScanner(rootDir, scanAll, agents, fileWatchers, pollingTime
4753
4817
  }
4754
4818
  for (const [agentId, agent] of agents) {
4755
4819
  const lastActivity = agent.lastActivityAt || 0;
4756
- const idle = now - lastActivity > AGENT_IDLE_TIMEOUT_MS;
4820
+ const idle = now - lastActivity > idleTimeoutMs;
4757
4821
  const removed = !fs2.existsSync(agent.jsonlFile);
4758
4822
  if (removed || idle) {
4759
4823
  const reason = removed ? "JSONL removed" : "idle timeout";
@@ -4889,13 +4953,11 @@ async function checkForUpdate() {
4889
4953
  getLatestVersion()
4890
4954
  ]);
4891
4955
  if (!current || !latest)
4892
- return;
4956
+ return null;
4893
4957
  if (!isNewer(current, latest))
4894
- return;
4895
- console.log(`
4896
- [ctrl-daemon] Update available: v${current} \u2192 v${latest}
4897
- ` + ` Run: npx ${PACKAGE_NAME}@latest
4898
- `);
4958
+ return null;
4959
+ return ` \u2B06 Update available: v${current} \u2192 v${latest}
4960
+ ` + ` Run: npx ${PACKAGE_NAME}@latest`;
4899
4961
  }
4900
4962
 
4901
4963
  // src/index.ts
@@ -4903,10 +4965,11 @@ function printUsage() {
4903
4965
  console.log(`Usage: ctrl-daemon [options]
4904
4966
 
4905
4967
  Options:
4906
- --port <number> Port to listen on (default: 3001)
4907
- --host <address> Host/address to bind to (default: 0.0.0.0)
4908
- --project-dir <path> Watch a single project directory
4909
- --help, -h Show this help message
4968
+ --port <number> Port to listen on (default: 3001)
4969
+ --host <address> Host/address to bind to (default: 0.0.0.0)
4970
+ --project-dir <path> Watch a single project directory
4971
+ --idle-timeout <minutes> Agent idle timeout in minutes (default: 15)
4972
+ --help, -h Show this help message
4910
4973
 
4911
4974
  Without --project-dir, watches ALL projects in ~/.claude/projects/.
4912
4975
  With --project-dir, watches only that specific project.`);
@@ -4916,6 +4979,7 @@ function parseArgs() {
4916
4979
  let projectDir;
4917
4980
  let port = 3001;
4918
4981
  let host = "0.0.0.0";
4982
+ let idleTimeoutMs = 15 * 60 * 1000;
4919
4983
  for (let i = 0;i < args.length; i++) {
4920
4984
  if (args[i] === "--help" || args[i] === "-h") {
4921
4985
  printUsage();
@@ -4926,17 +4990,25 @@ function parseArgs() {
4926
4990
  port = Number.parseInt(args[++i], 10);
4927
4991
  } else if (args[i] === "--host" && args[i + 1]) {
4928
4992
  host = args[++i];
4993
+ } else if (args[i] === "--idle-timeout" && args[i + 1]) {
4994
+ idleTimeoutMs = Number.parseInt(args[++i], 10) * 60 * 1000;
4929
4995
  }
4930
4996
  }
4931
- return { projectDir, port, host };
4997
+ return { projectDir, port, host, idleTimeoutMs };
4932
4998
  }
4933
4999
  function resolveProjectsRoot(claudeHome) {
4934
5000
  const home = claudeHome || path3.join(process.env.HOME || "~", ".claude");
4935
5001
  return path3.join(home, "projects");
4936
5002
  }
4937
5003
  async function main() {
5004
+ printBanner();
5005
+ const stopSpinner = startSpinner("Starting\u2026");
4938
5006
  const analyticsConfig = initAnalytics();
4939
- const [version2] = await Promise.all([getCurrentVersion(), checkForUpdate()]);
5007
+ const [version2, updateMsg] = await Promise.all([
5008
+ getCurrentVersion(),
5009
+ checkForUpdate()
5010
+ ]);
5011
+ stopSpinner();
4940
5012
  process.on("uncaughtException", async (err) => {
4941
5013
  console.error("[ctrl-daemon] Uncaught exception:", err);
4942
5014
  trackException(err, { ...systemInfo, crash_type: "uncaughtException" });
@@ -4950,18 +5022,14 @@ async function main() {
4950
5022
  await shutdownAnalytics();
4951
5023
  process.exit(1);
4952
5024
  });
4953
- const { projectDir, port, host } = parseArgs();
5025
+ const { projectDir, port, host, idleTimeoutMs } = parseArgs();
4954
5026
  const claudeHome = process.env.CLAUDE_HOME;
4955
- console.log("[ctrl-daemon] Contribute or report issues: https://github.com/bulletproof-sh/ctrl");
4956
5027
  let scanDirs;
4957
5028
  if (projectDir) {
4958
5029
  const dir = resolveProjectDir(projectDir, claudeHome);
4959
5030
  scanDirs = [dir];
4960
- console.log(`[ctrl-daemon] Project dir: ${projectDir}`);
4961
- console.log(`[ctrl-daemon] Session dir: ${dir}`);
4962
5031
  } else {
4963
5032
  const projectsRoot = resolveProjectsRoot(claudeHome);
4964
- console.log(`[ctrl-daemon] Watching all projects in: ${projectsRoot}`);
4965
5033
  scanDirs = [projectsRoot];
4966
5034
  }
4967
5035
  const agents = new Map;
@@ -4970,6 +5038,7 @@ async function main() {
4970
5038
  const waitingTimers = new Map;
4971
5039
  const permissionTimers = new Map;
4972
5040
  const server = createServer({ port, host, agents });
5041
+ printReady(port, version2, updateMsg);
4973
5042
  trackEvent("daemon_started", {
4974
5043
  version: version2 ?? "unknown",
4975
5044
  port,
@@ -4979,7 +5048,7 @@ async function main() {
4979
5048
  ...analyticsConfig
4980
5049
  });
4981
5050
  const scanAll = !projectDir;
4982
- const scanner = startProjectScanner(scanDirs[0], scanAll, agents, fileWatchers, pollingTimers, waitingTimers, permissionTimers, server.broadcast);
5051
+ const scanner = startProjectScanner(scanDirs[0], scanAll, agents, fileWatchers, pollingTimers, waitingTimers, permissionTimers, server.broadcast, idleTimeoutMs);
4983
5052
  async function shutdown() {
4984
5053
  console.log(`
4985
5054
  [ctrl-daemon] Shutting down...`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bulletproof-sh/ctrl-daemon",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
4
4
  "description": "WebSocket daemon for ctrl — watches Claude Code sessions and broadcasts agent state",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",