@bulletproof-sh/ctrl-daemon 0.0.16 → 0.0.18

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.
@@ -1,26 +1,26 @@
1
1
  #!/usr/bin/env node
2
- import { execFileSync } from "node:child_process";
3
- import { dirname, join } from "node:path";
4
- import { fileURLToPath } from "node:url";
2
+ import { execFileSync } from 'node:child_process';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
5
 
6
6
  const __dirname = dirname(fileURLToPath(import.meta.url));
7
- const entry = join(__dirname, "..", "dist", "index.js");
7
+ const entry = join(__dirname, '..', 'dist', 'index.js');
8
8
 
9
9
  // Check for bun in PATH
10
10
  try {
11
- execFileSync("bun", ["--version"], { stdio: "ignore" });
11
+ execFileSync('bun', ['--version'], { stdio: 'ignore' });
12
12
  } catch {
13
13
  console.error(
14
- "ctrl-daemon requires Bun to run.\n\nInstall Bun: https://bun.sh\n curl -fsSL https://bun.sh/install | bash",
14
+ 'ctrl-daemon requires Bun to run.\n\nInstall Bun: https://bun.sh\n curl -fsSL https://bun.sh/install | bash',
15
15
  );
16
16
  process.exit(1);
17
17
  }
18
18
 
19
19
  // Forward all CLI args to the daemon entry point
20
- const args = ["run", entry, ...process.argv.slice(2)];
20
+ const args = ['run', entry, ...process.argv.slice(2)];
21
21
 
22
22
  try {
23
- execFileSync("bun", args, { stdio: "inherit" });
23
+ execFileSync('bun', args, { stdio: 'inherit' });
24
24
  } catch (err) {
25
25
  // execFileSync throws on non-zero exit — just propagate the code
26
26
  process.exit(err.status ?? 1);
package/dist/index.js CHANGED
@@ -11,8 +11,9 @@ var __export = (target, all) => {
11
11
  };
12
12
 
13
13
  // src/index.ts
14
- import * as fs3 from "fs";
15
- import * as path3 from "path";
14
+ import { spawn } from "child_process";
15
+ import * as fs4 from "fs";
16
+ import * as path4 from "path";
16
17
 
17
18
  // src/analytics.ts
18
19
  import os from "os";
@@ -4231,18 +4232,21 @@ function initAnalytics() {
4231
4232
  return { customKey, customHost };
4232
4233
  }
4233
4234
  function trackEvent(event, properties) {
4234
- if (!client)
4235
+ if (!client) {
4235
4236
  return;
4237
+ }
4236
4238
  client.capture({ distinctId, event, properties });
4237
4239
  }
4238
4240
  function trackException(error, properties) {
4239
- if (!client)
4241
+ if (!client) {
4240
4242
  return;
4243
+ }
4241
4244
  client.captureException(error, distinctId, properties);
4242
4245
  }
4243
4246
  async function shutdownAnalytics() {
4244
- if (!client)
4247
+ if (!client) {
4245
4248
  return;
4249
+ }
4246
4250
  await client.shutdown();
4247
4251
  }
4248
4252
 
@@ -4360,6 +4364,7 @@ var LOGO_LINES = [
4360
4364
  " \u255A\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D"
4361
4365
  ];
4362
4366
  function printBanner() {
4367
+ process.stdout.write("\x1B]0;ctrl-daemon\x07");
4363
4368
  process.stdout.write(`
4364
4369
  `);
4365
4370
  for (const line of LOGO_LINES) {
@@ -4382,8 +4387,9 @@ function startSpinner(text) {
4382
4387
  };
4383
4388
  }
4384
4389
  function buildWebUrl(port, host) {
4385
- if (port === DEFAULT_WS_PORT)
4390
+ if (port === DEFAULT_WS_PORT) {
4386
4391
  return WEB_APP_BASE_URL;
4392
+ }
4387
4393
  const clientHost = host === "0.0.0.0" ? "localhost" : host;
4388
4394
  return `${WEB_APP_BASE_URL}?daemon=ws://${clientHost}:${port}/ws`;
4389
4395
  }
@@ -4406,9 +4412,43 @@ ${YL}${updateMsg}${RS}
4406
4412
  console.log("");
4407
4413
  }
4408
4414
 
4415
+ // src/lockfile.ts
4416
+ import * as fs from "fs";
4417
+ import * as path from "path";
4418
+ var LOCK_DIR = path.join(process.env.HOME || "~", ".ctrl");
4419
+ var LOCK_FILE = path.join(LOCK_DIR, "daemon.pid");
4420
+ function isProcessRunning(pid) {
4421
+ try {
4422
+ process.kill(pid, 0);
4423
+ return true;
4424
+ } catch {
4425
+ return false;
4426
+ }
4427
+ }
4428
+ function acquireLock() {
4429
+ fs.mkdirSync(LOCK_DIR, { recursive: true });
4430
+ try {
4431
+ const content = fs.readFileSync(LOCK_FILE, "utf-8").trim();
4432
+ const pid = Number.parseInt(content, 10);
4433
+ if (!Number.isNaN(pid) && isProcessRunning(pid)) {
4434
+ return { acquired: false, existingPid: pid };
4435
+ }
4436
+ } catch {}
4437
+ fs.writeFileSync(LOCK_FILE, String(process.pid), "utf-8");
4438
+ return { acquired: true };
4439
+ }
4440
+ function releaseLock() {
4441
+ try {
4442
+ const content = fs.readFileSync(LOCK_FILE, "utf-8").trim();
4443
+ if (content === String(process.pid)) {
4444
+ fs.unlinkSync(LOCK_FILE);
4445
+ }
4446
+ } catch {}
4447
+ }
4448
+
4409
4449
  // src/projectScanner.ts
4410
- import * as fs2 from "fs";
4411
- import * as path2 from "path";
4450
+ import * as fs3 from "fs";
4451
+ import * as path3 from "path";
4412
4452
 
4413
4453
  // ../shared/src/transcript/constants.ts
4414
4454
  var FILE_WATCHER_POLL_INTERVAL_MS = 2000;
@@ -4421,7 +4461,7 @@ var BASH_COMMAND_DISPLAY_MAX_LENGTH = 30;
4421
4461
  var TASK_DESCRIPTION_DISPLAY_MAX_LENGTH = 40;
4422
4462
 
4423
4463
  // src/sessionWatcher.ts
4424
- import * as fs from "fs";
4464
+ import * as fs2 from "fs";
4425
4465
  // ../shared/src/transcript/timerManager.ts
4426
4466
  function clearAgentActivity(agent, agentId, permissionTimers, broadcast) {
4427
4467
  if (!agent)
@@ -4511,10 +4551,10 @@ function startPermissionTimer(agentId, agents, permissionTimers, permissionExemp
4511
4551
  permissionTimers.set(agentId, timer);
4512
4552
  }
4513
4553
  // ../shared/src/transcript/transcriptParser.ts
4514
- import * as path from "path";
4554
+ import * as path2 from "path";
4515
4555
  var PERMISSION_EXEMPT_TOOLS = new Set(["Task", "AskUserQuestion"]);
4516
4556
  function formatToolStatus(toolName, input) {
4517
- const base = (p) => typeof p === "string" ? path.basename(p) : "";
4557
+ const base = (p) => typeof p === "string" ? path2.basename(p) : "";
4518
4558
  switch (toolName) {
4519
4559
  case "Read":
4520
4560
  return `Reading ${base(input.file_path)}`;
@@ -4657,7 +4697,7 @@ function processTranscriptLine(agentId, line, agents, waitingTimers, permissionT
4657
4697
  }
4658
4698
  } catch {}
4659
4699
  }
4660
- function processProgressRecord(agentId, record, agents, waitingTimers, permissionTimers, broadcast) {
4700
+ function processProgressRecord(agentId, record, agents, _waitingTimers, permissionTimers, broadcast) {
4661
4701
  const agent = agents.get(agentId);
4662
4702
  if (!agent)
4663
4703
  return;
@@ -4783,10 +4823,12 @@ function daemonLog(message, color) {
4783
4823
  }
4784
4824
  }
4785
4825
  function onBroadcast(msg) {
4786
- if (!tuiActive)
4826
+ if (!tuiActive) {
4787
4827
  return;
4788
- if (typeof msg !== "object" || msg === null)
4828
+ }
4829
+ if (typeof msg !== "object" || msg === null) {
4789
4830
  return;
4831
+ }
4790
4832
  const m = msg;
4791
4833
  const type = m.type;
4792
4834
  const id = m.id;
@@ -4847,14 +4889,14 @@ var SHARED_BUF_SIZE = 64 * 1024;
4847
4889
  var sharedReadBuf = Buffer.allocUnsafe(SHARED_BUF_SIZE);
4848
4890
  function startSessionWatcher(agentId, filePath, agents, openFds, staleAgents, fileWatchers, pollingTimers, waitingTimers, permissionTimers, broadcast) {
4849
4891
  try {
4850
- const fd = fs.openSync(filePath, "r");
4892
+ const fd = fs2.openSync(filePath, "r");
4851
4893
  openFds.set(agentId, fd);
4852
4894
  } catch (e) {
4853
4895
  daemonLog(`Failed to open fd for agent ${agentId}: ${e}`);
4854
4896
  return;
4855
4897
  }
4856
4898
  try {
4857
- const watcher = fs.watch(filePath, () => {
4899
+ const watcher = fs2.watch(filePath, () => {
4858
4900
  readNewLines(agentId, agents, openFds, staleAgents, waitingTimers, permissionTimers, broadcast);
4859
4901
  });
4860
4902
  fileWatchers.set(agentId, watcher);
@@ -4872,18 +4914,21 @@ function startSessionWatcher(agentId, filePath, agents, openFds, staleAgents, fi
4872
4914
  }
4873
4915
  function readNewLines(agentId, agents, openFds, staleAgents, waitingTimers, permissionTimers, broadcast) {
4874
4916
  const agent = agents.get(agentId);
4875
- if (!agent)
4917
+ if (!agent) {
4876
4918
  return;
4919
+ }
4877
4920
  const fd = openFds.get(agentId);
4878
- if (fd === undefined)
4921
+ if (fd === undefined) {
4879
4922
  return;
4923
+ }
4880
4924
  try {
4881
- const stat = fs.fstatSync(fd);
4882
- if (stat.size <= agent.fileOffset)
4925
+ const stat = fs2.fstatSync(fd);
4926
+ if (stat.size <= agent.fileOffset) {
4883
4927
  return;
4928
+ }
4884
4929
  const readSize = stat.size - agent.fileOffset;
4885
4930
  const buf = readSize <= sharedReadBuf.length ? sharedReadBuf : Buffer.allocUnsafe(readSize);
4886
- const bytesRead = fs.readSync(fd, buf, 0, readSize, agent.fileOffset);
4931
+ const bytesRead = fs2.readSync(fd, buf, 0, readSize, agent.fileOffset);
4887
4932
  agent.fileOffset = stat.size;
4888
4933
  const text = agent.lineBuffer + buf.toString("utf-8", 0, bytesRead);
4889
4934
  const lines = text.split(`
@@ -4891,8 +4936,9 @@ function readNewLines(agentId, agents, openFds, staleAgents, waitingTimers, perm
4891
4936
  agent.lineBuffer = lines.pop() || "";
4892
4937
  let activityUpdated = false;
4893
4938
  for (const line of lines) {
4894
- if (!line.trim())
4939
+ if (!line.trim()) {
4895
4940
  continue;
4941
+ }
4896
4942
  if (!activityUpdated) {
4897
4943
  agent.lastActivityAt = Date.now();
4898
4944
  cancelWaitingTimer(agentId, waitingTimers);
@@ -4914,15 +4960,16 @@ function stopSessionWatcher(agentId, openFds, fileWatchers, pollingTimers, waiti
4914
4960
  const fd = openFds.get(agentId);
4915
4961
  if (fd !== undefined) {
4916
4962
  try {
4917
- fs.closeSync(fd);
4963
+ fs2.closeSync(fd);
4918
4964
  } catch {}
4919
4965
  openFds.delete(agentId);
4920
4966
  }
4921
4967
  fileWatchers.get(agentId)?.close();
4922
4968
  fileWatchers.delete(agentId);
4923
4969
  const pt = pollingTimers.get(agentId);
4924
- if (pt)
4970
+ if (pt) {
4925
4971
  clearInterval(pt);
4972
+ }
4926
4973
  pollingTimers.delete(agentId);
4927
4974
  cancelWaitingTimer(agentId, waitingTimers);
4928
4975
  cancelPermissionTimer(agentId, permissionTimers);
@@ -4933,27 +4980,30 @@ function resolveProjectHash(projectDir) {
4933
4980
  return projectDir.replace(/[:\\/.]/g, "-");
4934
4981
  }
4935
4982
  function resolveProjectDir(projectDir, claudeHome) {
4936
- const home = claudeHome || path2.join(process.env.HOME || "~", ".claude");
4937
- const projectsDir = path2.join(home, "projects");
4983
+ const home = claudeHome || path3.join(process.env.HOME || "~", ".claude");
4984
+ const projectsDir = path3.join(home, "projects");
4938
4985
  const exactHash = resolveProjectHash(projectDir);
4939
- const exactPath = path2.join(projectsDir, exactHash);
4940
- if (fs2.existsSync(exactPath))
4986
+ const exactPath = path3.join(projectsDir, exactHash);
4987
+ if (fs3.existsSync(exactPath)) {
4941
4988
  return exactPath;
4989
+ }
4942
4990
  const macHash = projectDir.replace(/[:\\/._ ]/g, "-");
4943
- const macPath = path2.join(projectsDir, macHash);
4944
- if (fs2.existsSync(macPath))
4991
+ const macPath = path3.join(projectsDir, macHash);
4992
+ if (fs3.existsSync(macPath)) {
4945
4993
  return macPath;
4994
+ }
4946
4995
  return exactPath;
4947
4996
  }
4948
4997
  function collectJsonlFiles(dir) {
4949
4998
  try {
4950
4999
  const results = [];
4951
- for (const f of fs2.readdirSync(dir)) {
4952
- if (!f.endsWith(".jsonl"))
5000
+ for (const f of fs3.readdirSync(dir)) {
5001
+ if (!f.endsWith(".jsonl")) {
4953
5002
  continue;
4954
- const filePath = path2.join(dir, f);
5003
+ }
5004
+ const filePath = path3.join(dir, f);
4955
5005
  try {
4956
- const stat = fs2.statSync(filePath);
5006
+ const stat = fs3.statSync(filePath);
4957
5007
  results.push({ filePath, mtimeMs: stat.mtimeMs });
4958
5008
  } catch {}
4959
5009
  }
@@ -4966,14 +5016,14 @@ function collectAllJsonlFiles(projectsRoot) {
4966
5016
  const files = [];
4967
5017
  let subdirs;
4968
5018
  try {
4969
- subdirs = fs2.readdirSync(projectsRoot);
5019
+ subdirs = fs3.readdirSync(projectsRoot);
4970
5020
  } catch {
4971
5021
  return [];
4972
5022
  }
4973
5023
  for (const subdir of subdirs) {
4974
- const fullPath = path2.join(projectsRoot, subdir);
5024
+ const fullPath = path3.join(projectsRoot, subdir);
4975
5025
  try {
4976
- const stat = fs2.statSync(fullPath);
5026
+ const stat = fs3.statSync(fullPath);
4977
5027
  if (stat.isDirectory()) {
4978
5028
  files.push(...collectJsonlFiles(fullPath));
4979
5029
  }
@@ -4988,13 +5038,15 @@ function startProjectScanner(rootDir, scanAll, agents, openFds, staleAgents, fil
4988
5038
  const now = Date.now();
4989
5039
  const fileInfos = scanAll ? collectAllJsonlFiles(rootDir) : collectJsonlFiles(rootDir);
4990
5040
  for (const { filePath, mtimeMs } of fileInfos) {
4991
- if (knownJsonlFiles.has(filePath))
5041
+ if (knownJsonlFiles.has(filePath)) {
4992
5042
  continue;
4993
- if (now - mtimeMs > idleTimeoutMs)
5043
+ }
5044
+ if (now - mtimeMs > idleTimeoutMs) {
4994
5045
  continue;
5046
+ }
4995
5047
  knownJsonlFiles.add(filePath);
4996
5048
  const id = nextAgentId++;
4997
- const agentProjectDir = path2.dirname(filePath);
5049
+ const agentProjectDir = path3.dirname(filePath);
4998
5050
  const agent = {
4999
5051
  id,
5000
5052
  projectDir: agentProjectDir,
@@ -5012,7 +5064,7 @@ function startProjectScanner(rootDir, scanAll, agents, openFds, staleAgents, fil
5012
5064
  lastActivityAt: mtimeMs
5013
5065
  };
5014
5066
  agents.set(id, agent);
5015
- daemonLog(`Agent ${id}: watching ${path2.basename(filePath)}`);
5067
+ daemonLog(`Agent ${id}: watching ${path3.basename(filePath)}`);
5016
5068
  broadcast({ type: "agentCreated", id });
5017
5069
  startSessionWatcher(id, filePath, agents, openFds, staleAgents, fileWatchers, pollingTimers, waitingTimers, permissionTimers, broadcast);
5018
5070
  readNewLines(id, agents, openFds, staleAgents, waitingTimers, permissionTimers, broadcast);
@@ -5058,8 +5110,9 @@ function createServer({ port, host, agents }) {
5058
5110
  fetch(req, server2) {
5059
5111
  const url = new URL(req.url);
5060
5112
  if (url.pathname === "/ws") {
5061
- if (server2.upgrade(req))
5113
+ if (server2.upgrade(req)) {
5062
5114
  return;
5115
+ }
5063
5116
  return new Response("WebSocket upgrade failed", { status: 400 });
5064
5117
  }
5065
5118
  if (url.pathname === "/health") {
@@ -5112,8 +5165,9 @@ function createServer({ port, host, agents }) {
5112
5165
  const errCode = err.code ?? "";
5113
5166
  const errMsg = err instanceof Error ? err.message : "";
5114
5167
  const isAddrInUse = errCode === "EADDRINUSE" || errMsg.includes("EADDRINUSE") || errMsg.includes("address already in use");
5115
- if (!isAddrInUse || attempt === PORT_RETRY_LIMIT - 1)
5168
+ if (!isAddrInUse || attempt === PORT_RETRY_LIMIT - 1) {
5116
5169
  throw err;
5170
+ }
5117
5171
  boundPort++;
5118
5172
  }
5119
5173
  }
@@ -5127,7 +5181,7 @@ function createServer({ port, host, agents }) {
5127
5181
  return {
5128
5182
  broadcast,
5129
5183
  clientCount: () => clients.size,
5130
- port: server.port,
5184
+ port: server.port ?? port,
5131
5185
  stop: () => server.stop()
5132
5186
  };
5133
5187
  }
@@ -5247,8 +5301,9 @@ function flushDiff(current, previous) {
5247
5301
  }
5248
5302
  }
5249
5303
  if (changed) {
5250
- if (runStart === -1)
5304
+ if (runStart === -1) {
5251
5305
  runStart = c;
5306
+ }
5252
5307
  } else if (runStart !== -1) {
5253
5308
  parts.push(flushRun(curRow, r, runStart, c, lastFg, lastBold, lastDim));
5254
5309
  const last = curRow[c - 1];
@@ -5273,12 +5328,15 @@ function flushRun(row, r, start, end, lastFg, lastBold, lastDim) {
5273
5328
  const cell = row[i];
5274
5329
  if (cell.fg !== fg || cell.bold !== bold || cell.dim !== dim) {
5275
5330
  s += RESET;
5276
- if (cell.fg)
5331
+ if (cell.fg) {
5277
5332
  s += cell.fg;
5278
- if (cell.bold)
5333
+ }
5334
+ if (cell.bold) {
5279
5335
  s += BOLD;
5280
- if (cell.dim)
5336
+ }
5337
+ if (cell.dim) {
5281
5338
  s += DIM;
5339
+ }
5282
5340
  fg = cell.fg;
5283
5341
  bold = cell.bold;
5284
5342
  dim = cell.dim;
@@ -5316,10 +5374,12 @@ function computePanelGeometry(termRows, termCols) {
5316
5374
  function borderColor(borderIdx, perimeterLen) {
5317
5375
  const dist = Math.abs(borderIdx - glowPosition);
5318
5376
  const wrappedDist = Math.min(dist, perimeterLen - dist);
5319
- if (wrappedDist < 3)
5377
+ if (wrappedDist < 3) {
5320
5378
  return GLOW_BRIGHT;
5321
- if (wrappedDist < 6)
5379
+ }
5380
+ if (wrappedDist < 6) {
5322
5381
  return GLOW_MEDIUM;
5382
+ }
5323
5383
  return GLOW_DIM;
5324
5384
  }
5325
5385
  function writeString(buf, row, col, str, fg, bold, dim) {
@@ -5328,8 +5388,9 @@ function writeString(buf, row, col, str, fg, bold, dim) {
5328
5388
  }
5329
5389
  }
5330
5390
  function renderPanel(buf, panel, logs, agentCount, clientCount, version2, webUrl) {
5331
- if (!panel.visible)
5391
+ if (!panel.visible) {
5332
5392
  return;
5393
+ }
5333
5394
  const { x, y, width, height } = panel;
5334
5395
  const innerWidth = width - 2;
5335
5396
  const perimeter = 2 * (width + height) - 4;
@@ -5392,8 +5453,9 @@ function renderPanel(buf, panel, logs, agentCount, clientCount, version2, webUrl
5392
5453
  const visibleLogs = logs.slice(-panel.logLines);
5393
5454
  for (let i = 0;i < visibleLogs.length; i++) {
5394
5455
  const row = logStartRow + i;
5395
- if (row >= y + height - 1)
5456
+ if (row >= y + height - 1) {
5396
5457
  break;
5458
+ }
5397
5459
  const entry = visibleLogs[i];
5398
5460
  writeString(buf, row, x + 2, entry.timestamp, CYAN_FG, false, true);
5399
5461
  const msgCol = x + 2 + entry.timestamp.length + 1;
@@ -5477,8 +5539,9 @@ function tickRain(layers, rows) {
5477
5539
  const drop = drops[d];
5478
5540
  drop.y += drop.speed;
5479
5541
  drop.frameCount++;
5480
- if (drop.y - drop.trailLen > rows)
5542
+ if (drop.y - drop.trailLen > rows) {
5481
5543
  continue;
5544
+ }
5482
5545
  const mutInterval = drop.isLightning ? 3 : cfg.mutationInterval;
5483
5546
  if (drop.frameCount % mutInterval === 0 && drop.chars.length > 1) {
5484
5547
  const idx = 1 + (Math.random() * (drop.chars.length - 1) | 0);
@@ -5523,10 +5586,12 @@ function renderRain(layers, buf, panel) {
5523
5586
  const trailLen = drop.trailLen;
5524
5587
  for (let i = 0;i < trailLen; i++) {
5525
5588
  const row = headRow - i;
5526
- if (row < 0 || row >= bufRows)
5589
+ if (row < 0 || row >= bufRows) {
5527
5590
  continue;
5528
- if (checkPanel && row >= panelY && row < panelY2)
5591
+ }
5592
+ if (checkPanel && row >= panelY && row < panelY2) {
5529
5593
  continue;
5594
+ }
5530
5595
  const color = drop.isLightning ? LIGHTNING_COLORS[Math.min(i, 2)] : gradStrs[Math.min(trailLen > 1 ? i * gradLen / (trailLen - 1) | 0 : 0, gradLen)];
5531
5596
  const bold = i === 0 || drop.isLightning;
5532
5597
  const dim = !drop.isLightning && i > trailLen * 0.7;
@@ -5566,13 +5631,15 @@ function handleResize() {
5566
5631
  panel = computePanelGeometry(rows, cols);
5567
5632
  }
5568
5633
  function scheduleFrame() {
5569
- if (!running)
5634
+ if (!running) {
5570
5635
  return;
5636
+ }
5571
5637
  renderTimer = setTimeout(renderFrame, FRAME_INTERVAL_MS);
5572
5638
  }
5573
5639
  function renderFrame() {
5574
- if (!running || !bufA || !bufB || !tuiOptions)
5640
+ if (!running || !bufA || !bufB || !tuiOptions) {
5575
5641
  return;
5642
+ }
5576
5643
  const current = currentIsA ? bufA : bufB;
5577
5644
  const previous = currentIsA ? bufB : bufA;
5578
5645
  tickRain(rainLayers, current.rows);
@@ -5585,8 +5652,9 @@ function renderFrame() {
5585
5652
  scheduleFrame();
5586
5653
  }
5587
5654
  function startTui(options) {
5588
- if (running)
5655
+ if (running) {
5589
5656
  return;
5657
+ }
5590
5658
  running = true;
5591
5659
  tuiOptions = options;
5592
5660
  enterAltScreen();
@@ -5604,8 +5672,9 @@ function startTui(options) {
5604
5672
  daemonLog("TUI started");
5605
5673
  }
5606
5674
  function stopTui() {
5607
- if (!running)
5675
+ if (!running) {
5608
5676
  return;
5677
+ }
5609
5678
  running = false;
5610
5679
  if (renderTimer) {
5611
5680
  clearTimeout(renderTimer);
@@ -5626,20 +5695,24 @@ var NPM_REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
5626
5695
  var UPDATE_CHECK_TIMEOUT_MS = 5000;
5627
5696
  function parseSemver(version2) {
5628
5697
  const match = version2.match(/^(\d+)\.(\d+)\.(\d+)/);
5629
- if (!match)
5698
+ if (!match) {
5630
5699
  return null;
5700
+ }
5631
5701
  return [Number(match[1]), Number(match[2]), Number(match[3])];
5632
5702
  }
5633
5703
  function isNewer(current, next) {
5634
5704
  const a = parseSemver(current);
5635
5705
  const b = parseSemver(next);
5636
- if (!a || !b)
5706
+ if (!a || !b) {
5637
5707
  return false;
5708
+ }
5638
5709
  for (let i = 0;i < 3; i++) {
5639
- if (b[i] > a[i])
5710
+ if (b[i] > a[i]) {
5640
5711
  return true;
5641
- if (b[i] < a[i])
5712
+ }
5713
+ if (b[i] < a[i]) {
5642
5714
  return false;
5715
+ }
5643
5716
  }
5644
5717
  return false;
5645
5718
  }
@@ -5658,8 +5731,9 @@ async function getLatestVersion() {
5658
5731
  const timeout = setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS);
5659
5732
  const res = await fetch(NPM_REGISTRY_URL, { signal: controller.signal });
5660
5733
  clearTimeout(timeout);
5661
- if (!res.ok)
5734
+ if (!res.ok) {
5662
5735
  return null;
5736
+ }
5663
5737
  const data = await res.json();
5664
5738
  return data.version ?? null;
5665
5739
  } catch {
@@ -5667,14 +5741,13 @@ async function getLatestVersion() {
5667
5741
  }
5668
5742
  }
5669
5743
  async function checkForUpdate() {
5670
- const [current, latest] = await Promise.all([
5671
- getCurrentVersion(),
5672
- getLatestVersion()
5673
- ]);
5674
- if (!current || !latest)
5744
+ const [current, latest] = await Promise.all([getCurrentVersion(), getLatestVersion()]);
5745
+ if (!current || !latest) {
5675
5746
  return null;
5676
- if (!isNewer(current, latest))
5747
+ }
5748
+ if (!isNewer(current, latest)) {
5677
5749
  return null;
5750
+ }
5678
5751
  return ` \u2B06 Update available: v${current} \u2192 v${latest}
5679
5752
  ` + ` Run: npx ${PACKAGE_NAME}@latest`;
5680
5753
  }
@@ -5719,18 +5792,28 @@ function parseArgs() {
5719
5792
  }
5720
5793
  return { projectDir, port, host, idleTimeoutMs, noTui };
5721
5794
  }
5795
+ function openBrowser(url) {
5796
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
5797
+ try {
5798
+ const child = spawn(cmd, [url], { detached: true, stdio: "ignore" });
5799
+ child.unref();
5800
+ } catch {}
5801
+ }
5722
5802
  function resolveProjectsRoot(claudeHome) {
5723
- const home = claudeHome || path3.join(process.env.HOME || "~", ".claude");
5724
- return path3.join(home, "projects");
5803
+ const home = claudeHome || path4.join(process.env.HOME || "~", ".claude");
5804
+ return path4.join(home, "projects");
5725
5805
  }
5726
5806
  async function main() {
5727
5807
  printBanner();
5808
+ const lockResult = acquireLock();
5809
+ if (!lockResult.acquired) {
5810
+ console.error(`\x1B[31m[ctrl-daemon] Another instance is already running (pid ${lockResult.existingPid}).\x1B[0m`);
5811
+ console.error("Kill it first or use a different --port.");
5812
+ process.exit(1);
5813
+ }
5728
5814
  const stopSpinner = startSpinner("Starting\u2026");
5729
5815
  const analyticsConfig = initAnalytics();
5730
- const [version2, updateMsg] = await Promise.all([
5731
- getCurrentVersion(),
5732
- checkForUpdate()
5733
- ]);
5816
+ const [version2, updateMsg] = await Promise.all([getCurrentVersion(), checkForUpdate()]);
5734
5817
  stopSpinner();
5735
5818
  process.on("uncaughtException", async (err) => {
5736
5819
  stopTui();
@@ -5767,7 +5850,9 @@ async function main() {
5767
5850
  const permissionTimers = new Map;
5768
5851
  const server = createServer({ port, host, agents });
5769
5852
  const boundPort = server.port;
5853
+ const webUrl = buildWebUrl(boundPort, host);
5770
5854
  printReady(boundPort, host, version2, updateMsg);
5855
+ openBrowser(webUrl);
5771
5856
  const rawBroadcast = server.broadcast;
5772
5857
  const broadcast = (msg) => {
5773
5858
  rawBroadcast(msg);
@@ -5777,7 +5862,7 @@ async function main() {
5777
5862
  startTui({
5778
5863
  version: version2,
5779
5864
  port: boundPort,
5780
- webUrl: buildWebUrl(boundPort, host),
5865
+ webUrl,
5781
5866
  agentCount: () => agents.size,
5782
5867
  clientCount: () => server.clientCount()
5783
5868
  });
@@ -5807,17 +5892,22 @@ async function main() {
5807
5892
  server.stop();
5808
5893
  for (const fd of openFds.values()) {
5809
5894
  try {
5810
- fs3.closeSync(fd);
5895
+ fs4.closeSync(fd);
5811
5896
  } catch {}
5812
5897
  }
5813
- for (const watcher of fileWatchers.values())
5898
+ for (const watcher of fileWatchers.values()) {
5814
5899
  watcher.close();
5815
- for (const timer of pollingTimers.values())
5900
+ }
5901
+ for (const timer of pollingTimers.values()) {
5816
5902
  clearInterval(timer);
5817
- for (const timer of waitingTimers.values())
5903
+ }
5904
+ for (const timer of waitingTimers.values()) {
5818
5905
  clearTimeout(timer);
5819
- for (const timer of permissionTimers.values())
5906
+ }
5907
+ for (const timer of permissionTimers.values()) {
5820
5908
  clearTimeout(timer);
5909
+ }
5910
+ releaseLock();
5821
5911
  await shutdownAnalytics();
5822
5912
  process.exit(0);
5823
5913
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bulletproof-sh/ctrl-daemon",
3
- "version": "0.0.16",
3
+ "version": "0.0.18",
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",
@@ -16,10 +16,11 @@
16
16
  "start": "bun src/index.ts",
17
17
  "build": "bun build src/index.ts --outdir dist --target bun",
18
18
  "check": "biome check .",
19
+ "verify": "bunx tsc --noEmit && biome check .",
19
20
  "prepublishOnly": "bun run build"
20
21
  },
21
22
  "devDependencies": {
22
- "@biomejs/biome": "^1.9.4",
23
+ "@biomejs/biome": "^2.4.4",
23
24
  "@types/bun": "latest",
24
25
  "typescript": "~5.9.3"
25
26
  },