@kmmao/happy-agent 0.3.1 → 0.3.2

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.cjs CHANGED
@@ -18,7 +18,7 @@ var path = require('path');
18
18
  var fs = require('fs');
19
19
  var os = require('os');
20
20
 
21
- var version = "0.3.1";
21
+ var version = "0.3.2";
22
22
 
23
23
  function loadConfig() {
24
24
  const serverUrl = (process.env.HAPPY_SERVER_URL ?? "https://happyserve.xycloud.info").replace(/\/+$/, "");
@@ -1351,11 +1351,114 @@ class SessionClient extends node_events.EventEmitter {
1351
1351
  }
1352
1352
  }
1353
1353
 
1354
+ const NOT_INSTALLED = Object.freeze({ status: "not-installed" });
1355
+ const DISCONNECTED = Object.freeze({ status: "disconnected" });
1356
+ const DETECT_TIMEOUT_MS = 3e3;
1357
+ async function detectTailscale() {
1358
+ try {
1359
+ const raw = await execTailscale(["status", "--json"]);
1360
+ return parseTailscaleStatus(raw);
1361
+ } catch (err) {
1362
+ if (isNotFound(err)) {
1363
+ logger.debug("[TAILSCALE] tailscale binary not found");
1364
+ return NOT_INSTALLED;
1365
+ }
1366
+ logger.debug(`[TAILSCALE] detection failed: ${String(err)}`);
1367
+ return DISCONNECTED;
1368
+ }
1369
+ }
1370
+ async function detectTailscaleServe() {
1371
+ try {
1372
+ const raw = await execTailscale(["serve", "status", "--json"]);
1373
+ return parseTailscaleServeStatus(raw);
1374
+ } catch (err) {
1375
+ logger.debug(`[TAILSCALE] serve detection failed: ${String(err)}`);
1376
+ return [];
1377
+ }
1378
+ }
1379
+ function execTailscale(args) {
1380
+ return new Promise((resolve, reject) => {
1381
+ child_process.execFile("tailscale", args, { timeout: DETECT_TIMEOUT_MS }, (err, stdout) => {
1382
+ if (err) return reject(err);
1383
+ resolve(stdout);
1384
+ });
1385
+ });
1386
+ }
1387
+ function parseTailscaleStatus(raw) {
1388
+ let json;
1389
+ try {
1390
+ json = JSON.parse(raw);
1391
+ } catch {
1392
+ logger.debug("[TAILSCALE] failed to parse status JSON");
1393
+ return DISCONNECTED;
1394
+ }
1395
+ const backendState = json.BackendState;
1396
+ if (backendState && backendState !== "Running") {
1397
+ logger.debug(`[TAILSCALE] backend state: ${backendState}`);
1398
+ return DISCONNECTED;
1399
+ }
1400
+ const self = json.Self;
1401
+ if (!self) {
1402
+ logger.debug("[TAILSCALE] no Self node in status output");
1403
+ return DISCONNECTED;
1404
+ }
1405
+ const ips = self.TailscaleIPs ?? [];
1406
+ const ipv4 = ips.find((ip) => ip.includes("."));
1407
+ const ipv6 = ips.find((ip) => ip.includes(":"));
1408
+ const dnsName = self.DNSName;
1409
+ const hostname = dnsName?.replace(/\.$/, "").split(".")[0];
1410
+ const tailnetName = dnsName ? dnsName.replace(/\.$/, "").split(".").slice(1).join(".") : void 0;
1411
+ const version = json.Version;
1412
+ logger.debug(
1413
+ `[TAILSCALE] detected: ipv4=${ipv4 ?? "none"}, hostname=${hostname ?? "none"}`
1414
+ );
1415
+ return {
1416
+ status: "connected",
1417
+ ipv4,
1418
+ ipv6,
1419
+ hostname,
1420
+ tailnetName,
1421
+ version
1422
+ };
1423
+ }
1424
+ function parseTailscaleServeStatus(raw) {
1425
+ let json;
1426
+ try {
1427
+ json = JSON.parse(raw);
1428
+ } catch {
1429
+ logger.debug("[TAILSCALE] failed to parse serve status JSON");
1430
+ return [];
1431
+ }
1432
+ const web = json.Web ?? {};
1433
+ const allowFunnel = json.AllowFunnel ?? {};
1434
+ const entries = [];
1435
+ for (const [hostPort, config] of Object.entries(web)) {
1436
+ const colonIdx = hostPort.lastIndexOf(":");
1437
+ if (colonIdx === -1) continue;
1438
+ const hostname = hostPort.slice(0, colonIdx);
1439
+ const port = parseInt(hostPort.slice(colonIdx + 1), 10);
1440
+ if (!Number.isFinite(port)) continue;
1441
+ const handlers = config.Handlers ?? {};
1442
+ const rootHandler = handlers["/"];
1443
+ const target = rootHandler?.Proxy ?? "unknown";
1444
+ const funnel = allowFunnel[hostPort] === true;
1445
+ entries.push({ port, protocol: "HTTPS", target, funnel, hostname });
1446
+ }
1447
+ logger.debug(`[TAILSCALE] detected ${entries.length} serve entries`);
1448
+ return entries;
1449
+ }
1450
+ function isNotFound(err) {
1451
+ return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
1452
+ }
1453
+
1454
+ const TAILSCALE_REFRESH_MS = 5 * 60 * 1e3;
1354
1455
  class MachineClient {
1355
1456
  machine;
1356
1457
  rpcHandlerManager;
1357
1458
  socket;
1358
1459
  keepAliveInterval = null;
1460
+ tailscaleRefreshInterval = null;
1461
+ lastTailscaleInfo = null;
1359
1462
  token;
1360
1463
  serverUrl;
1361
1464
  onEphemeral;
@@ -1393,12 +1496,21 @@ class MachineClient {
1393
1496
  this.socket.on("connect", () => {
1394
1497
  logger.debug("[MACHINE] Connected to server");
1395
1498
  this.rpcHandlerManager.onSocketConnect(this.socket);
1499
+ this.updateDaemonState((state) => ({
1500
+ ...state,
1501
+ status: "running",
1502
+ pid: process.pid,
1503
+ startedAt: Date.now(),
1504
+ tailscale: this.lastTailscaleInfo ?? state?.tailscale
1505
+ }));
1396
1506
  this.startKeepAlive();
1507
+ this.startTailscaleRefresh();
1397
1508
  });
1398
1509
  this.socket.on("disconnect", () => {
1399
1510
  logger.debug("[MACHINE] Disconnected from server");
1400
1511
  this.rpcHandlerManager.onSocketDisconnect();
1401
1512
  this.stopKeepAlive();
1513
+ this.stopTailscaleRefresh();
1402
1514
  });
1403
1515
  this.socket.on(
1404
1516
  "rpc-request",
@@ -1512,9 +1624,14 @@ class MachineClient {
1512
1624
  // -----------------------------------------------------------------------
1513
1625
  // Lifecycle
1514
1626
  // -----------------------------------------------------------------------
1627
+ /** Seed initial Tailscale info detected before connect. */
1628
+ setTailscaleInfo(info) {
1629
+ this.lastTailscaleInfo = info;
1630
+ }
1515
1631
  shutdown() {
1516
1632
  logger.debug("[MACHINE] Shutting down");
1517
1633
  this.stopKeepAlive();
1634
+ this.stopTailscaleRefresh();
1518
1635
  this.rpcHandlerManager.onSocketDisconnect();
1519
1636
  this.socket?.close();
1520
1637
  }
@@ -1536,6 +1653,35 @@ class MachineClient {
1536
1653
  this.keepAliveInterval = null;
1537
1654
  }
1538
1655
  }
1656
+ startTailscaleRefresh() {
1657
+ this.stopTailscaleRefresh();
1658
+ this.tailscaleRefreshInterval = setInterval(async () => {
1659
+ const info = await detectTailscale();
1660
+ const serves = info.status === "connected" ? await detectTailscaleServe() : [];
1661
+ const fullInfo = { ...info, serves };
1662
+ if (tailscaleChanged(this.lastTailscaleInfo, fullInfo)) {
1663
+ logger.debug(
1664
+ `[MACHINE] Tailscale changed: ${this.lastTailscaleInfo?.status} \u2192 ${fullInfo.status}, serves: ${serves.length}`
1665
+ );
1666
+ this.lastTailscaleInfo = fullInfo;
1667
+ this.updateDaemonState((state) => {
1668
+ if (!state) return { status: "running", tailscale: fullInfo };
1669
+ return { ...state, tailscale: fullInfo };
1670
+ });
1671
+ }
1672
+ }, TAILSCALE_REFRESH_MS);
1673
+ logger.debug("[MACHINE] Tailscale refresh started (5m interval)");
1674
+ }
1675
+ stopTailscaleRefresh() {
1676
+ if (this.tailscaleRefreshInterval) {
1677
+ clearInterval(this.tailscaleRefreshInterval);
1678
+ this.tailscaleRefreshInterval = null;
1679
+ }
1680
+ }
1681
+ }
1682
+ function tailscaleChanged(prev, next) {
1683
+ if (!prev) return true;
1684
+ return prev.status !== next.status || prev.ipv4 !== next.ipv4 || prev.ipv6 !== next.ipv6 || prev.hostname !== next.hostname || JSON.stringify(prev.serves) !== JSON.stringify(next.serves);
1539
1685
  }
1540
1686
 
1541
1687
  function formatTime(ts) {
package/dist/index.d.cts CHANGED
@@ -298,6 +298,29 @@ declare class SessionClient extends EventEmitter {
298
298
  private setupSocketListeners;
299
299
  }
300
300
 
301
+ /**
302
+ * Tailscale detection utility for happy-agent.
303
+ *
304
+ * Adapted from happy-cli/src/utils/tailscale.ts — keep in sync.
305
+ */
306
+ type TailscaleStatus = "connected" | "disconnected" | "not-installed";
307
+ type TailscaleServeEntry = {
308
+ port: number;
309
+ protocol: string;
310
+ target: string;
311
+ funnel: boolean;
312
+ hostname: string;
313
+ };
314
+ type TailscaleInfo = {
315
+ status: TailscaleStatus;
316
+ ipv4?: string;
317
+ ipv6?: string;
318
+ hostname?: string;
319
+ tailnetName?: string;
320
+ version?: string;
321
+ serves?: TailscaleServeEntry[];
322
+ };
323
+
301
324
  /**
302
325
  * Machine WebSocket client — trimmed from CLI's ApiMachineClient.
303
326
  *
@@ -331,6 +354,8 @@ declare class MachineClient {
331
354
  readonly rpcHandlerManager: RpcHandlerManager;
332
355
  private socket;
333
356
  private keepAliveInterval;
357
+ private tailscaleRefreshInterval;
358
+ private lastTailscaleInfo;
334
359
  private readonly token;
335
360
  private readonly serverUrl;
336
361
  private readonly onEphemeral?;
@@ -338,9 +363,13 @@ declare class MachineClient {
338
363
  connect(): void;
339
364
  updateMachineMetadata(handler: (metadata: MachineMetadata | null) => MachineMetadata): Promise<void>;
340
365
  updateDaemonState(handler: (state: DaemonState | null) => DaemonState): Promise<void>;
366
+ /** Seed initial Tailscale info detected before connect. */
367
+ setTailscaleInfo(info: TailscaleInfo): void;
341
368
  shutdown(): void;
342
369
  private startKeepAlive;
343
370
  private stopKeepAlive;
371
+ private startTailscaleRefresh;
372
+ private stopTailscaleRefresh;
344
373
  }
345
374
 
346
375
  export { MachineClient, RpcHandlerManager, SessionClient, authLogin, authLogout, authStatus, createRpcHandlerManager, createSession, deleteSession, fetchMessagesAfterSeq, getOrCreateMachine, getSessionMessages, listActiveSessions, listMachines, listSessions, loadConfig, readCredentials, requireCredentials, resolveSessionEncryption, sendMessagesBatch };
package/dist/index.d.mts CHANGED
@@ -298,6 +298,29 @@ declare class SessionClient extends EventEmitter {
298
298
  private setupSocketListeners;
299
299
  }
300
300
 
301
+ /**
302
+ * Tailscale detection utility for happy-agent.
303
+ *
304
+ * Adapted from happy-cli/src/utils/tailscale.ts — keep in sync.
305
+ */
306
+ type TailscaleStatus = "connected" | "disconnected" | "not-installed";
307
+ type TailscaleServeEntry = {
308
+ port: number;
309
+ protocol: string;
310
+ target: string;
311
+ funnel: boolean;
312
+ hostname: string;
313
+ };
314
+ type TailscaleInfo = {
315
+ status: TailscaleStatus;
316
+ ipv4?: string;
317
+ ipv6?: string;
318
+ hostname?: string;
319
+ tailnetName?: string;
320
+ version?: string;
321
+ serves?: TailscaleServeEntry[];
322
+ };
323
+
301
324
  /**
302
325
  * Machine WebSocket client — trimmed from CLI's ApiMachineClient.
303
326
  *
@@ -331,6 +354,8 @@ declare class MachineClient {
331
354
  readonly rpcHandlerManager: RpcHandlerManager;
332
355
  private socket;
333
356
  private keepAliveInterval;
357
+ private tailscaleRefreshInterval;
358
+ private lastTailscaleInfo;
334
359
  private readonly token;
335
360
  private readonly serverUrl;
336
361
  private readonly onEphemeral?;
@@ -338,9 +363,13 @@ declare class MachineClient {
338
363
  connect(): void;
339
364
  updateMachineMetadata(handler: (metadata: MachineMetadata | null) => MachineMetadata): Promise<void>;
340
365
  updateDaemonState(handler: (state: DaemonState | null) => DaemonState): Promise<void>;
366
+ /** Seed initial Tailscale info detected before connect. */
367
+ setTailscaleInfo(info: TailscaleInfo): void;
341
368
  shutdown(): void;
342
369
  private startKeepAlive;
343
370
  private stopKeepAlive;
371
+ private startTailscaleRefresh;
372
+ private stopTailscaleRefresh;
344
373
  }
345
374
 
346
375
  export { MachineClient, RpcHandlerManager, SessionClient, authLogin, authLogout, authStatus, createRpcHandlerManager, createSession, deleteSession, fetchMessagesAfterSeq, getOrCreateMachine, getSessionMessages, listActiveSessions, listMachines, listSessions, loadConfig, readCredentials, requireCredentials, resolveSessionEncryption, sendMessagesBatch };
package/dist/index.mjs CHANGED
@@ -8,7 +8,7 @@ import axios, { AxiosError } from 'axios';
8
8
  import qrcode from 'qrcode-terminal';
9
9
  import { EventEmitter } from 'node:events';
10
10
  import { io } from 'socket.io-client';
11
- import { exec } from 'child_process';
11
+ import { exec, execFile } from 'child_process';
12
12
  import { promisify } from 'util';
13
13
  import { readFile, mkdir, writeFile, readdir, stat } from 'fs/promises';
14
14
  import { createHash as createHash$1 } from 'crypto';
@@ -16,7 +16,7 @@ import { join as join$1, resolve } from 'path';
16
16
  import { realpathSync } from 'fs';
17
17
  import { tmpdir } from 'os';
18
18
 
19
- var version = "0.3.1";
19
+ var version = "0.3.2";
20
20
 
21
21
  function loadConfig() {
22
22
  const serverUrl = (process.env.HAPPY_SERVER_URL ?? "https://happyserve.xycloud.info").replace(/\/+$/, "");
@@ -1349,11 +1349,114 @@ class SessionClient extends EventEmitter {
1349
1349
  }
1350
1350
  }
1351
1351
 
1352
+ const NOT_INSTALLED = Object.freeze({ status: "not-installed" });
1353
+ const DISCONNECTED = Object.freeze({ status: "disconnected" });
1354
+ const DETECT_TIMEOUT_MS = 3e3;
1355
+ async function detectTailscale() {
1356
+ try {
1357
+ const raw = await execTailscale(["status", "--json"]);
1358
+ return parseTailscaleStatus(raw);
1359
+ } catch (err) {
1360
+ if (isNotFound(err)) {
1361
+ logger.debug("[TAILSCALE] tailscale binary not found");
1362
+ return NOT_INSTALLED;
1363
+ }
1364
+ logger.debug(`[TAILSCALE] detection failed: ${String(err)}`);
1365
+ return DISCONNECTED;
1366
+ }
1367
+ }
1368
+ async function detectTailscaleServe() {
1369
+ try {
1370
+ const raw = await execTailscale(["serve", "status", "--json"]);
1371
+ return parseTailscaleServeStatus(raw);
1372
+ } catch (err) {
1373
+ logger.debug(`[TAILSCALE] serve detection failed: ${String(err)}`);
1374
+ return [];
1375
+ }
1376
+ }
1377
+ function execTailscale(args) {
1378
+ return new Promise((resolve, reject) => {
1379
+ execFile("tailscale", args, { timeout: DETECT_TIMEOUT_MS }, (err, stdout) => {
1380
+ if (err) return reject(err);
1381
+ resolve(stdout);
1382
+ });
1383
+ });
1384
+ }
1385
+ function parseTailscaleStatus(raw) {
1386
+ let json;
1387
+ try {
1388
+ json = JSON.parse(raw);
1389
+ } catch {
1390
+ logger.debug("[TAILSCALE] failed to parse status JSON");
1391
+ return DISCONNECTED;
1392
+ }
1393
+ const backendState = json.BackendState;
1394
+ if (backendState && backendState !== "Running") {
1395
+ logger.debug(`[TAILSCALE] backend state: ${backendState}`);
1396
+ return DISCONNECTED;
1397
+ }
1398
+ const self = json.Self;
1399
+ if (!self) {
1400
+ logger.debug("[TAILSCALE] no Self node in status output");
1401
+ return DISCONNECTED;
1402
+ }
1403
+ const ips = self.TailscaleIPs ?? [];
1404
+ const ipv4 = ips.find((ip) => ip.includes("."));
1405
+ const ipv6 = ips.find((ip) => ip.includes(":"));
1406
+ const dnsName = self.DNSName;
1407
+ const hostname = dnsName?.replace(/\.$/, "").split(".")[0];
1408
+ const tailnetName = dnsName ? dnsName.replace(/\.$/, "").split(".").slice(1).join(".") : void 0;
1409
+ const version = json.Version;
1410
+ logger.debug(
1411
+ `[TAILSCALE] detected: ipv4=${ipv4 ?? "none"}, hostname=${hostname ?? "none"}`
1412
+ );
1413
+ return {
1414
+ status: "connected",
1415
+ ipv4,
1416
+ ipv6,
1417
+ hostname,
1418
+ tailnetName,
1419
+ version
1420
+ };
1421
+ }
1422
+ function parseTailscaleServeStatus(raw) {
1423
+ let json;
1424
+ try {
1425
+ json = JSON.parse(raw);
1426
+ } catch {
1427
+ logger.debug("[TAILSCALE] failed to parse serve status JSON");
1428
+ return [];
1429
+ }
1430
+ const web = json.Web ?? {};
1431
+ const allowFunnel = json.AllowFunnel ?? {};
1432
+ const entries = [];
1433
+ for (const [hostPort, config] of Object.entries(web)) {
1434
+ const colonIdx = hostPort.lastIndexOf(":");
1435
+ if (colonIdx === -1) continue;
1436
+ const hostname = hostPort.slice(0, colonIdx);
1437
+ const port = parseInt(hostPort.slice(colonIdx + 1), 10);
1438
+ if (!Number.isFinite(port)) continue;
1439
+ const handlers = config.Handlers ?? {};
1440
+ const rootHandler = handlers["/"];
1441
+ const target = rootHandler?.Proxy ?? "unknown";
1442
+ const funnel = allowFunnel[hostPort] === true;
1443
+ entries.push({ port, protocol: "HTTPS", target, funnel, hostname });
1444
+ }
1445
+ logger.debug(`[TAILSCALE] detected ${entries.length} serve entries`);
1446
+ return entries;
1447
+ }
1448
+ function isNotFound(err) {
1449
+ return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
1450
+ }
1451
+
1452
+ const TAILSCALE_REFRESH_MS = 5 * 60 * 1e3;
1352
1453
  class MachineClient {
1353
1454
  machine;
1354
1455
  rpcHandlerManager;
1355
1456
  socket;
1356
1457
  keepAliveInterval = null;
1458
+ tailscaleRefreshInterval = null;
1459
+ lastTailscaleInfo = null;
1357
1460
  token;
1358
1461
  serverUrl;
1359
1462
  onEphemeral;
@@ -1391,12 +1494,21 @@ class MachineClient {
1391
1494
  this.socket.on("connect", () => {
1392
1495
  logger.debug("[MACHINE] Connected to server");
1393
1496
  this.rpcHandlerManager.onSocketConnect(this.socket);
1497
+ this.updateDaemonState((state) => ({
1498
+ ...state,
1499
+ status: "running",
1500
+ pid: process.pid,
1501
+ startedAt: Date.now(),
1502
+ tailscale: this.lastTailscaleInfo ?? state?.tailscale
1503
+ }));
1394
1504
  this.startKeepAlive();
1505
+ this.startTailscaleRefresh();
1395
1506
  });
1396
1507
  this.socket.on("disconnect", () => {
1397
1508
  logger.debug("[MACHINE] Disconnected from server");
1398
1509
  this.rpcHandlerManager.onSocketDisconnect();
1399
1510
  this.stopKeepAlive();
1511
+ this.stopTailscaleRefresh();
1400
1512
  });
1401
1513
  this.socket.on(
1402
1514
  "rpc-request",
@@ -1510,9 +1622,14 @@ class MachineClient {
1510
1622
  // -----------------------------------------------------------------------
1511
1623
  // Lifecycle
1512
1624
  // -----------------------------------------------------------------------
1625
+ /** Seed initial Tailscale info detected before connect. */
1626
+ setTailscaleInfo(info) {
1627
+ this.lastTailscaleInfo = info;
1628
+ }
1513
1629
  shutdown() {
1514
1630
  logger.debug("[MACHINE] Shutting down");
1515
1631
  this.stopKeepAlive();
1632
+ this.stopTailscaleRefresh();
1516
1633
  this.rpcHandlerManager.onSocketDisconnect();
1517
1634
  this.socket?.close();
1518
1635
  }
@@ -1534,6 +1651,35 @@ class MachineClient {
1534
1651
  this.keepAliveInterval = null;
1535
1652
  }
1536
1653
  }
1654
+ startTailscaleRefresh() {
1655
+ this.stopTailscaleRefresh();
1656
+ this.tailscaleRefreshInterval = setInterval(async () => {
1657
+ const info = await detectTailscale();
1658
+ const serves = info.status === "connected" ? await detectTailscaleServe() : [];
1659
+ const fullInfo = { ...info, serves };
1660
+ if (tailscaleChanged(this.lastTailscaleInfo, fullInfo)) {
1661
+ logger.debug(
1662
+ `[MACHINE] Tailscale changed: ${this.lastTailscaleInfo?.status} \u2192 ${fullInfo.status}, serves: ${serves.length}`
1663
+ );
1664
+ this.lastTailscaleInfo = fullInfo;
1665
+ this.updateDaemonState((state) => {
1666
+ if (!state) return { status: "running", tailscale: fullInfo };
1667
+ return { ...state, tailscale: fullInfo };
1668
+ });
1669
+ }
1670
+ }, TAILSCALE_REFRESH_MS);
1671
+ logger.debug("[MACHINE] Tailscale refresh started (5m interval)");
1672
+ }
1673
+ stopTailscaleRefresh() {
1674
+ if (this.tailscaleRefreshInterval) {
1675
+ clearInterval(this.tailscaleRefreshInterval);
1676
+ this.tailscaleRefreshInterval = null;
1677
+ }
1678
+ }
1679
+ }
1680
+ function tailscaleChanged(prev, next) {
1681
+ if (!prev) return true;
1682
+ return prev.status !== next.status || prev.ipv4 !== next.ipv4 || prev.ipv6 !== next.ipv6 || prev.hostname !== next.hostname || JSON.stringify(prev.serves) !== JSON.stringify(next.serves);
1537
1683
  }
1538
1684
 
1539
1685
  function formatTime(ts) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kmmao/happy-agent",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "CLI client for controlling Happy Coder agents remotely",
5
5
  "author": "Kirill Dubovitskiy",
6
6
  "license": "MIT",