@kmmao/happy-agent 0.5.0 → 0.5.1

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
@@ -17,8 +17,9 @@ var crypto = require('crypto');
17
17
  var path = require('path');
18
18
  var fs = require('fs');
19
19
  var os = require('os');
20
+ var http = require('http');
20
21
 
21
- var version = "0.5.0";
22
+ var version = "0.5.1";
22
23
 
23
24
  function loadConfig() {
24
25
  const serverUrl = (process.env.HAPPY_SERVER_URL ?? "https://happyserve.xycloud.info").replace(/\/+$/, "");
@@ -1838,12 +1839,15 @@ function isNotFound(err) {
1838
1839
  }
1839
1840
 
1840
1841
  const pidToSession = /* @__PURE__ */ new Map();
1842
+ let persistPath = null;
1841
1843
  function trackSession(session) {
1842
1844
  pidToSession.set(session.pid, session);
1845
+ flush();
1843
1846
  }
1844
1847
  function untrackSession(pid) {
1845
1848
  const session = pidToSession.get(pid);
1846
1849
  pidToSession.delete(pid);
1850
+ flush();
1847
1851
  return session;
1848
1852
  }
1849
1853
  function getTrackedSession(pid) {
@@ -1855,9 +1859,59 @@ function getAllTrackedSessions() {
1855
1859
  function getTrackedSessionCount() {
1856
1860
  return pidToSession.size;
1857
1861
  }
1862
+ function enablePersistence(filePath) {
1863
+ persistPath = filePath;
1864
+ load();
1865
+ }
1866
+ function load() {
1867
+ if (!persistPath) return;
1868
+ try {
1869
+ const raw = fs.readFileSync(persistPath, "utf-8");
1870
+ const entries = JSON.parse(raw);
1871
+ let recovered = 0;
1872
+ for (const entry of entries) {
1873
+ try {
1874
+ process.kill(entry.pid, 0);
1875
+ } catch {
1876
+ continue;
1877
+ }
1878
+ pidToSession.set(entry.pid, {
1879
+ pid: entry.pid,
1880
+ directory: entry.directory,
1881
+ startedAt: entry.startedAt,
1882
+ happySessionId: entry.happySessionId,
1883
+ lastActivityAt: entry.lastActivityAt,
1884
+ automationContext: entry.automationContext
1885
+ });
1886
+ recovered++;
1887
+ }
1888
+ if (recovered > 0) {
1889
+ logger.debug(`[TRACKED] Recovered ${recovered} sessions from ${persistPath}`);
1890
+ }
1891
+ } catch {
1892
+ }
1893
+ }
1894
+ function flush() {
1895
+ if (!persistPath) return;
1896
+ try {
1897
+ const entries = [...pidToSession.values()].map((s) => ({
1898
+ pid: s.pid,
1899
+ directory: s.directory,
1900
+ startedAt: s.startedAt,
1901
+ happySessionId: s.happySessionId,
1902
+ lastActivityAt: s.lastActivityAt,
1903
+ automationContext: s.automationContext
1904
+ }));
1905
+ fs.mkdirSync(path.dirname(persistPath), { recursive: true });
1906
+ fs.writeFileSync(persistPath, JSON.stringify(entries, null, 2), "utf-8");
1907
+ } catch (err) {
1908
+ logger.debug(`[TRACKED] Failed to persist: ${err}`);
1909
+ }
1910
+ }
1858
1911
 
1859
1912
  var trackedSessions = /*#__PURE__*/Object.freeze({
1860
1913
  __proto__: null,
1914
+ enablePersistence: enablePersistence,
1861
1915
  getAllTrackedSessions: getAllTrackedSessions,
1862
1916
  getTrackedSession: getTrackedSession,
1863
1917
  getTrackedSessionCount: getTrackedSessionCount,
@@ -3125,6 +3179,101 @@ class AutomationAuditStore {
3125
3179
  }
3126
3180
  }
3127
3181
 
3182
+ class WebhookServer {
3183
+ server = null;
3184
+ port = 0;
3185
+ onSessionStarted = null;
3186
+ /**
3187
+ * Start the HTTP server on a random available port.
3188
+ */
3189
+ async start() {
3190
+ return new Promise((resolve, reject) => {
3191
+ this.server = http.createServer((req, res) => this.handleRequest(req, res));
3192
+ this.server.on("error", (err) => {
3193
+ logger.debug(`[WEBHOOK-SERVER] Error: ${err.message}`);
3194
+ reject(err);
3195
+ });
3196
+ this.server.listen(0, "127.0.0.1", () => {
3197
+ const addr = this.server.address();
3198
+ if (addr && typeof addr === "object") {
3199
+ this.port = addr.port;
3200
+ logger.debug(`[WEBHOOK-SERVER] Listening on 127.0.0.1:${this.port}`);
3201
+ resolve(this.port);
3202
+ } else {
3203
+ reject(new Error("Failed to get server address"));
3204
+ }
3205
+ });
3206
+ });
3207
+ }
3208
+ /**
3209
+ * Set the callback for session-started events.
3210
+ */
3211
+ setSessionStartedHandler(handler) {
3212
+ this.onSessionStarted = handler;
3213
+ }
3214
+ /**
3215
+ * Get the port the server is listening on.
3216
+ */
3217
+ getPort() {
3218
+ return this.port;
3219
+ }
3220
+ /**
3221
+ * Stop the server.
3222
+ */
3223
+ shutdown() {
3224
+ if (this.server) {
3225
+ this.server.close();
3226
+ this.server = null;
3227
+ logger.debug("[WEBHOOK-SERVER] Shutdown");
3228
+ }
3229
+ }
3230
+ // -----------------------------------------------------------------------
3231
+ // Internal
3232
+ // -----------------------------------------------------------------------
3233
+ handleRequest(req, res) {
3234
+ if (req.method === "POST" && req.url === "/session-started") {
3235
+ this.handleSessionStarted(req, res);
3236
+ return;
3237
+ }
3238
+ if (req.method === "GET" && (req.url === "/" || req.url === "/health")) {
3239
+ res.writeHead(200, { "Content-Type": "application/json" });
3240
+ res.end(JSON.stringify({ status: "ok", port: this.port }));
3241
+ return;
3242
+ }
3243
+ res.writeHead(404);
3244
+ res.end("Not Found");
3245
+ }
3246
+ handleSessionStarted(req, res) {
3247
+ let body = "";
3248
+ req.on("data", (chunk) => {
3249
+ body += chunk.toString();
3250
+ });
3251
+ req.on("end", () => {
3252
+ try {
3253
+ const parsed = JSON.parse(body);
3254
+ if (!parsed.sessionId || typeof parsed.sessionId !== "string") {
3255
+ res.writeHead(400, { "Content-Type": "application/json" });
3256
+ res.end(JSON.stringify({ error: "sessionId is required" }));
3257
+ return;
3258
+ }
3259
+ const hostPid = typeof parsed.metadata?.hostPid === "number" ? parsed.metadata.hostPid : void 0;
3260
+ logger.debug(`[WEBHOOK-SERVER] Session started: ${parsed.sessionId} (hostPid=${hostPid})`);
3261
+ this.onSessionStarted?.(
3262
+ parsed.sessionId,
3263
+ parsed.metadata ?? {},
3264
+ hostPid
3265
+ );
3266
+ res.writeHead(200, { "Content-Type": "application/json" });
3267
+ res.end(JSON.stringify({ status: "ok" }));
3268
+ } catch (err) {
3269
+ logger.debug(`[WEBHOOK-SERVER] Parse error: ${err}`);
3270
+ res.writeHead(400, { "Content-Type": "application/json" });
3271
+ res.end(JSON.stringify({ error: "Invalid JSON" }));
3272
+ }
3273
+ });
3274
+ }
3275
+ }
3276
+
3128
3277
  function pidFilePath(homeDir) {
3129
3278
  return node_path.join(homeDir, "agent-daemon.pid");
3130
3279
  }
@@ -3199,6 +3348,26 @@ async function startDaemon(options) {
3199
3348
  });
3200
3349
  const guardian = new GuardianSessionRegistry();
3201
3350
  const loopCoordinator = new AgentLoopCoordinator(scheduler, config.serverUrl, creds.token, guardian);
3351
+ enablePersistence(node_path.join(config.homeDir, "agent-tracked-sessions.json"));
3352
+ const webhookServer = new WebhookServer();
3353
+ const webhookPort = await webhookServer.start();
3354
+ webhookServer.setSessionStartedHandler((sessionId, _metadata, hostPid) => {
3355
+ if (hostPid) {
3356
+ const tracked = getTrackedSession(hostPid);
3357
+ if (tracked) {
3358
+ tracked.happySessionId = sessionId;
3359
+ logger.debug(`[DAEMON] Session ${sessionId} linked to PID ${hostPid}`);
3360
+ if (tracked.automationContext?.kind === "agent_loop") {
3361
+ guardian.remember(sessionId, {
3362
+ loopId: tracked.automationContext.trigger?.split(":")[1],
3363
+ projectId: tracked.automationContext.projectId
3364
+ });
3365
+ }
3366
+ }
3367
+ }
3368
+ });
3369
+ process.env.HAPPY_DAEMON_HTTP_PORT = String(webhookPort);
3370
+ console.log(`Webhook server: 127.0.0.1:${webhookPort}`);
3202
3371
  client.setTailscaleInfo(fullTailscale);
3203
3372
  client.enableAutomation(config.serverUrl, creds.token, scheduler, loopCoordinator, auditStore);
3204
3373
  loopCoordinator.start();
@@ -3212,6 +3381,7 @@ async function startDaemon(options) {
3212
3381
  logger.debug(`[DAEMON] Received ${signal}, shutting down...`);
3213
3382
  console.log(`
3214
3383
  Received ${signal}, shutting down...`);
3384
+ webhookServer.shutdown();
3215
3385
  loopCoordinator.shutdown();
3216
3386
  scheduler.shutdown();
3217
3387
  client.shutdown();
package/dist/index.mjs CHANGED
@@ -12,11 +12,12 @@ import { exec, execFile, spawn } 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, randomUUID } from 'crypto';
15
- import { join as join$1, resolve } from 'path';
16
- import { realpathSync } from 'fs';
15
+ import { join as join$1, resolve, dirname as dirname$1 } from 'path';
16
+ import { realpathSync, readFileSync as readFileSync$1, mkdirSync as mkdirSync$1, writeFileSync as writeFileSync$1 } from 'fs';
17
17
  import { tmpdir } from 'os';
18
+ import { createServer } from 'http';
18
19
 
19
- var version = "0.5.0";
20
+ var version = "0.5.1";
20
21
 
21
22
  function loadConfig() {
22
23
  const serverUrl = (process.env.HAPPY_SERVER_URL ?? "https://happyserve.xycloud.info").replace(/\/+$/, "");
@@ -1836,12 +1837,15 @@ function isNotFound(err) {
1836
1837
  }
1837
1838
 
1838
1839
  const pidToSession = /* @__PURE__ */ new Map();
1840
+ let persistPath = null;
1839
1841
  function trackSession(session) {
1840
1842
  pidToSession.set(session.pid, session);
1843
+ flush();
1841
1844
  }
1842
1845
  function untrackSession(pid) {
1843
1846
  const session = pidToSession.get(pid);
1844
1847
  pidToSession.delete(pid);
1848
+ flush();
1845
1849
  return session;
1846
1850
  }
1847
1851
  function getTrackedSession(pid) {
@@ -1853,9 +1857,59 @@ function getAllTrackedSessions() {
1853
1857
  function getTrackedSessionCount() {
1854
1858
  return pidToSession.size;
1855
1859
  }
1860
+ function enablePersistence(filePath) {
1861
+ persistPath = filePath;
1862
+ load();
1863
+ }
1864
+ function load() {
1865
+ if (!persistPath) return;
1866
+ try {
1867
+ const raw = readFileSync$1(persistPath, "utf-8");
1868
+ const entries = JSON.parse(raw);
1869
+ let recovered = 0;
1870
+ for (const entry of entries) {
1871
+ try {
1872
+ process.kill(entry.pid, 0);
1873
+ } catch {
1874
+ continue;
1875
+ }
1876
+ pidToSession.set(entry.pid, {
1877
+ pid: entry.pid,
1878
+ directory: entry.directory,
1879
+ startedAt: entry.startedAt,
1880
+ happySessionId: entry.happySessionId,
1881
+ lastActivityAt: entry.lastActivityAt,
1882
+ automationContext: entry.automationContext
1883
+ });
1884
+ recovered++;
1885
+ }
1886
+ if (recovered > 0) {
1887
+ logger.debug(`[TRACKED] Recovered ${recovered} sessions from ${persistPath}`);
1888
+ }
1889
+ } catch {
1890
+ }
1891
+ }
1892
+ function flush() {
1893
+ if (!persistPath) return;
1894
+ try {
1895
+ const entries = [...pidToSession.values()].map((s) => ({
1896
+ pid: s.pid,
1897
+ directory: s.directory,
1898
+ startedAt: s.startedAt,
1899
+ happySessionId: s.happySessionId,
1900
+ lastActivityAt: s.lastActivityAt,
1901
+ automationContext: s.automationContext
1902
+ }));
1903
+ mkdirSync$1(dirname$1(persistPath), { recursive: true });
1904
+ writeFileSync$1(persistPath, JSON.stringify(entries, null, 2), "utf-8");
1905
+ } catch (err) {
1906
+ logger.debug(`[TRACKED] Failed to persist: ${err}`);
1907
+ }
1908
+ }
1856
1909
 
1857
1910
  var trackedSessions = /*#__PURE__*/Object.freeze({
1858
1911
  __proto__: null,
1912
+ enablePersistence: enablePersistence,
1859
1913
  getAllTrackedSessions: getAllTrackedSessions,
1860
1914
  getTrackedSession: getTrackedSession,
1861
1915
  getTrackedSessionCount: getTrackedSessionCount,
@@ -3123,6 +3177,101 @@ class AutomationAuditStore {
3123
3177
  }
3124
3178
  }
3125
3179
 
3180
+ class WebhookServer {
3181
+ server = null;
3182
+ port = 0;
3183
+ onSessionStarted = null;
3184
+ /**
3185
+ * Start the HTTP server on a random available port.
3186
+ */
3187
+ async start() {
3188
+ return new Promise((resolve, reject) => {
3189
+ this.server = createServer((req, res) => this.handleRequest(req, res));
3190
+ this.server.on("error", (err) => {
3191
+ logger.debug(`[WEBHOOK-SERVER] Error: ${err.message}`);
3192
+ reject(err);
3193
+ });
3194
+ this.server.listen(0, "127.0.0.1", () => {
3195
+ const addr = this.server.address();
3196
+ if (addr && typeof addr === "object") {
3197
+ this.port = addr.port;
3198
+ logger.debug(`[WEBHOOK-SERVER] Listening on 127.0.0.1:${this.port}`);
3199
+ resolve(this.port);
3200
+ } else {
3201
+ reject(new Error("Failed to get server address"));
3202
+ }
3203
+ });
3204
+ });
3205
+ }
3206
+ /**
3207
+ * Set the callback for session-started events.
3208
+ */
3209
+ setSessionStartedHandler(handler) {
3210
+ this.onSessionStarted = handler;
3211
+ }
3212
+ /**
3213
+ * Get the port the server is listening on.
3214
+ */
3215
+ getPort() {
3216
+ return this.port;
3217
+ }
3218
+ /**
3219
+ * Stop the server.
3220
+ */
3221
+ shutdown() {
3222
+ if (this.server) {
3223
+ this.server.close();
3224
+ this.server = null;
3225
+ logger.debug("[WEBHOOK-SERVER] Shutdown");
3226
+ }
3227
+ }
3228
+ // -----------------------------------------------------------------------
3229
+ // Internal
3230
+ // -----------------------------------------------------------------------
3231
+ handleRequest(req, res) {
3232
+ if (req.method === "POST" && req.url === "/session-started") {
3233
+ this.handleSessionStarted(req, res);
3234
+ return;
3235
+ }
3236
+ if (req.method === "GET" && (req.url === "/" || req.url === "/health")) {
3237
+ res.writeHead(200, { "Content-Type": "application/json" });
3238
+ res.end(JSON.stringify({ status: "ok", port: this.port }));
3239
+ return;
3240
+ }
3241
+ res.writeHead(404);
3242
+ res.end("Not Found");
3243
+ }
3244
+ handleSessionStarted(req, res) {
3245
+ let body = "";
3246
+ req.on("data", (chunk) => {
3247
+ body += chunk.toString();
3248
+ });
3249
+ req.on("end", () => {
3250
+ try {
3251
+ const parsed = JSON.parse(body);
3252
+ if (!parsed.sessionId || typeof parsed.sessionId !== "string") {
3253
+ res.writeHead(400, { "Content-Type": "application/json" });
3254
+ res.end(JSON.stringify({ error: "sessionId is required" }));
3255
+ return;
3256
+ }
3257
+ const hostPid = typeof parsed.metadata?.hostPid === "number" ? parsed.metadata.hostPid : void 0;
3258
+ logger.debug(`[WEBHOOK-SERVER] Session started: ${parsed.sessionId} (hostPid=${hostPid})`);
3259
+ this.onSessionStarted?.(
3260
+ parsed.sessionId,
3261
+ parsed.metadata ?? {},
3262
+ hostPid
3263
+ );
3264
+ res.writeHead(200, { "Content-Type": "application/json" });
3265
+ res.end(JSON.stringify({ status: "ok" }));
3266
+ } catch (err) {
3267
+ logger.debug(`[WEBHOOK-SERVER] Parse error: ${err}`);
3268
+ res.writeHead(400, { "Content-Type": "application/json" });
3269
+ res.end(JSON.stringify({ error: "Invalid JSON" }));
3270
+ }
3271
+ });
3272
+ }
3273
+ }
3274
+
3126
3275
  function pidFilePath(homeDir) {
3127
3276
  return join(homeDir, "agent-daemon.pid");
3128
3277
  }
@@ -3197,6 +3346,26 @@ async function startDaemon(options) {
3197
3346
  });
3198
3347
  const guardian = new GuardianSessionRegistry();
3199
3348
  const loopCoordinator = new AgentLoopCoordinator(scheduler, config.serverUrl, creds.token, guardian);
3349
+ enablePersistence(join(config.homeDir, "agent-tracked-sessions.json"));
3350
+ const webhookServer = new WebhookServer();
3351
+ const webhookPort = await webhookServer.start();
3352
+ webhookServer.setSessionStartedHandler((sessionId, _metadata, hostPid) => {
3353
+ if (hostPid) {
3354
+ const tracked = getTrackedSession(hostPid);
3355
+ if (tracked) {
3356
+ tracked.happySessionId = sessionId;
3357
+ logger.debug(`[DAEMON] Session ${sessionId} linked to PID ${hostPid}`);
3358
+ if (tracked.automationContext?.kind === "agent_loop") {
3359
+ guardian.remember(sessionId, {
3360
+ loopId: tracked.automationContext.trigger?.split(":")[1],
3361
+ projectId: tracked.automationContext.projectId
3362
+ });
3363
+ }
3364
+ }
3365
+ }
3366
+ });
3367
+ process.env.HAPPY_DAEMON_HTTP_PORT = String(webhookPort);
3368
+ console.log(`Webhook server: 127.0.0.1:${webhookPort}`);
3200
3369
  client.setTailscaleInfo(fullTailscale);
3201
3370
  client.enableAutomation(config.serverUrl, creds.token, scheduler, loopCoordinator, auditStore);
3202
3371
  loopCoordinator.start();
@@ -3210,6 +3379,7 @@ async function startDaemon(options) {
3210
3379
  logger.debug(`[DAEMON] Received ${signal}, shutting down...`);
3211
3380
  console.log(`
3212
3381
  Received ${signal}, shutting down...`);
3382
+ webhookServer.shutdown();
3213
3383
  loopCoordinator.shutdown();
3214
3384
  scheduler.shutdown();
3215
3385
  client.shutdown();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kmmao/happy-agent",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "CLI client for controlling Happy Coder agents remotely",
5
5
  "author": "Kirill Dubovitskiy",
6
6
  "license": "MIT",