@kmmao/happy-agent 0.4.1 → 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.4.1";
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,
@@ -2025,11 +2079,11 @@ function stopSession(pid) {
2025
2079
  }
2026
2080
  }
2027
2081
 
2028
- const PROMPT_DIR = path.join(os.tmpdir(), "happy", "agent-prompts");
2082
+ const PROMPT_DIR$1 = path.join(os.tmpdir(), "happy", "agent-prompts");
2029
2083
  async function writePromptFile(prefix, content) {
2030
- await promises.mkdir(PROMPT_DIR, { recursive: true });
2084
+ await promises.mkdir(PROMPT_DIR$1, { recursive: true });
2031
2085
  const filename = `${prefix}-${Date.now()}.md`;
2032
- const filepath = path.join(PROMPT_DIR, filename);
2086
+ const filepath = path.join(PROMPT_DIR$1, filename);
2033
2087
  await promises.writeFile(filepath, content, "utf-8");
2034
2088
  return filepath;
2035
2089
  }
@@ -2213,6 +2267,8 @@ class MachineClient {
2213
2267
  automationServerUrl = "";
2214
2268
  automationAuthToken = "";
2215
2269
  scheduler = null;
2270
+ loopCoordinator = null;
2271
+ auditStore = null;
2216
2272
  constructor(opts) {
2217
2273
  this.token = opts.token;
2218
2274
  this.machine = opts.machine;
@@ -2254,6 +2310,34 @@ class MachineClient {
2254
2310
  return { sessions };
2255
2311
  });
2256
2312
  }
2313
+ registerLoopHandlers() {
2314
+ const coord = this.loopCoordinator;
2315
+ this.rpcHandlerManager.registerHandler("create-loop", async (data) => {
2316
+ const loop = coord.createLoop(data);
2317
+ return { loop: { id: loop.id, name: loop.name, state: loop.state } };
2318
+ });
2319
+ this.rpcHandlerManager.registerHandler("list-loops", async () => {
2320
+ return { loops: coord.listLoops() };
2321
+ });
2322
+ this.rpcHandlerManager.registerHandler("pause-loop", async (data) => {
2323
+ return { success: coord.pauseLoop(data.loopId) };
2324
+ });
2325
+ this.rpcHandlerManager.registerHandler("resume-loop", async (data) => {
2326
+ return { success: coord.resumeLoop(data.loopId) };
2327
+ });
2328
+ this.rpcHandlerManager.registerHandler("delete-loop", async (data) => {
2329
+ return { success: coord.deleteLoop(data.loopId) };
2330
+ });
2331
+ }
2332
+ registerAuditHandlers() {
2333
+ const audit = this.auditStore;
2334
+ this.rpcHandlerManager.registerHandler("query-audit-log", async (data) => {
2335
+ return { events: audit.query(data) };
2336
+ });
2337
+ this.rpcHandlerManager.registerHandler("audit-summary", async () => {
2338
+ return { summary: audit.summarize() };
2339
+ });
2340
+ }
2257
2341
  // -----------------------------------------------------------------------
2258
2342
  // Connection
2259
2343
  // -----------------------------------------------------------------------
@@ -2435,11 +2519,19 @@ class MachineClient {
2435
2519
  * Enable automation handling — agent will process webhook, supervisor,
2436
2520
  * and task triggers from the server by spawning Happy CLI sessions.
2437
2521
  */
2438
- enableAutomation(serverUrl, authToken, scheduler) {
2522
+ enableAutomation(serverUrl, authToken, scheduler, loopCoordinator, auditStore) {
2439
2523
  this.automationEnabled = true;
2440
2524
  this.automationServerUrl = serverUrl;
2441
2525
  this.automationAuthToken = authToken;
2442
2526
  this.scheduler = scheduler;
2527
+ this.loopCoordinator = loopCoordinator ?? null;
2528
+ this.auditStore = auditStore ?? null;
2529
+ if (this.loopCoordinator) {
2530
+ this.registerLoopHandlers();
2531
+ }
2532
+ if (this.auditStore) {
2533
+ this.registerAuditHandlers();
2534
+ }
2443
2535
  logger.debug("[MACHINE] Automation enabled");
2444
2536
  }
2445
2537
  /** Internal dispatch for ephemeral events that need automation handling. */
@@ -2560,6 +2652,7 @@ class AutomationScheduler {
2560
2652
  retryDelayMs;
2561
2653
  defaultMaxAttempts;
2562
2654
  maxRecentCompletions;
2655
+ onAudit;
2563
2656
  /** Active jobs indexed by id. */
2564
2657
  jobs = /* @__PURE__ */ new Map();
2565
2658
  /** dedupeKey → jobId for fast dedup lookups. */
@@ -2573,6 +2666,7 @@ class AutomationScheduler {
2573
2666
  this.retryDelayMs = options?.retryDelayMs ?? 5e3;
2574
2667
  this.defaultMaxAttempts = options?.maxAttempts ?? 3;
2575
2668
  this.maxRecentCompletions = options?.maxRecentCompletions ?? 50;
2669
+ this.onAudit = options?.onAudit ?? null;
2576
2670
  this.pumpTimer = setInterval(() => this.pump(), 1e3);
2577
2671
  }
2578
2672
  // -----------------------------------------------------------------------
@@ -2603,6 +2697,7 @@ class AutomationScheduler {
2603
2697
  this.jobs.set(job.id, job);
2604
2698
  this.dedupeIndex.set(job.dedupeKey, job.id);
2605
2699
  logger.debug(`[SCHEDULER] Enqueued: ${job.kind} ${job.dedupeKey} (${job.priority}) id=${job.id}`);
2700
+ this.onAudit?.({ kind: "job_enqueued", jobId: job.id, dedupeKey: job.dedupeKey, message: `${job.kind}:${job.priority}` });
2606
2701
  this.pump();
2607
2702
  return { job, deduped: false };
2608
2703
  }
@@ -2612,6 +2707,7 @@ class AutomationScheduler {
2612
2707
  job.status = "completed";
2613
2708
  job.updatedAt = Date.now();
2614
2709
  logger.debug(`[SCHEDULER] Completed: ${job.kind} ${job.dedupeKey} id=${jobId}`);
2710
+ this.onAudit?.({ kind: "job_completed", jobId, dedupeKey: job.dedupeKey });
2615
2711
  this.finalize(job);
2616
2712
  }
2617
2713
  markFailed(jobId, error) {
@@ -2623,11 +2719,13 @@ class AutomationScheduler {
2623
2719
  job.status = "queued";
2624
2720
  job.nextRunAt = Date.now() + job.attempt * this.retryDelayMs;
2625
2721
  logger.debug(`[SCHEDULER] Retry queued: ${job.dedupeKey} attempt=${job.attempt}/${job.maxAttempts} nextRunAt=+${job.attempt * this.retryDelayMs}ms`);
2722
+ this.onAudit?.({ kind: "job_retried", jobId, dedupeKey: job.dedupeKey, errorMessage: error, message: `attempt ${job.attempt}/${job.maxAttempts}` });
2626
2723
  this.pump();
2627
2724
  return;
2628
2725
  }
2629
2726
  job.status = "failed";
2630
2727
  logger.debug(`[SCHEDULER] Failed (exhausted): ${job.kind} ${job.dedupeKey} id=${jobId}: ${error}`);
2728
+ this.onAudit?.({ kind: "job_failed", jobId, dedupeKey: job.dedupeKey, errorMessage: error });
2631
2729
  this.finalize(job);
2632
2730
  }
2633
2731
  getStatus() {
@@ -2687,6 +2785,7 @@ class AutomationScheduler {
2687
2785
  job.attempt++;
2688
2786
  job.updatedAt = Date.now();
2689
2787
  logger.debug(`[SCHEDULER] Dispatching: ${job.kind} ${job.dedupeKey} attempt=${job.attempt}`);
2788
+ this.onAudit?.({ kind: "job_dispatched", jobId: job.id, dedupeKey: job.dedupeKey, message: `attempt ${job.attempt}` });
2690
2789
  job.run(job.id).then(({ pid }) => {
2691
2790
  if (job.status === "dispatching") {
2692
2791
  job.status = "running";
@@ -2722,6 +2821,459 @@ class AutomationScheduler {
2722
2821
  }
2723
2822
  }
2724
2823
 
2824
+ const PROMPT_DIR = path.join(os.tmpdir(), "happy", "agent-loop-prompts");
2825
+ const DEFAULT_MAX_CONSECUTIVE_FAILURES = 5;
2826
+ const DEFAULT_MAX_ITERATIONS = 0;
2827
+ class AgentLoopCoordinator {
2828
+ loops = /* @__PURE__ */ new Map();
2829
+ scheduler;
2830
+ serverUrl;
2831
+ authToken;
2832
+ guardian;
2833
+ tickTimer = null;
2834
+ constructor(scheduler, serverUrl, authToken, guardian) {
2835
+ this.scheduler = scheduler;
2836
+ this.serverUrl = serverUrl;
2837
+ this.authToken = authToken;
2838
+ this.guardian = guardian ?? null;
2839
+ }
2840
+ // -----------------------------------------------------------------------
2841
+ // Lifecycle
2842
+ // -----------------------------------------------------------------------
2843
+ start() {
2844
+ if (this.tickTimer) return;
2845
+ this.tickTimer = setInterval(() => this.tick(), 1e3);
2846
+ logger.debug("[LOOP] Coordinator started");
2847
+ }
2848
+ shutdown() {
2849
+ if (this.tickTimer) {
2850
+ clearInterval(this.tickTimer);
2851
+ this.tickTimer = null;
2852
+ }
2853
+ logger.debug("[LOOP] Coordinator shutdown");
2854
+ }
2855
+ // -----------------------------------------------------------------------
2856
+ // CRUD
2857
+ // -----------------------------------------------------------------------
2858
+ createLoop(input) {
2859
+ const loop = {
2860
+ id: crypto.randomUUID(),
2861
+ name: input.name,
2862
+ prompt: input.prompt,
2863
+ directory: input.directory,
2864
+ intervalMs: Math.max(input.intervalMs, 1e4),
2865
+ // min 10s
2866
+ createdAt: Date.now(),
2867
+ state: "idle",
2868
+ iteration: 0,
2869
+ nextRunAt: Date.now() + Math.max(input.intervalMs, 1e4),
2870
+ consecutiveFailures: 0,
2871
+ maxConsecutiveFailures: input.maxConsecutiveFailures ?? DEFAULT_MAX_CONSECUTIVE_FAILURES,
2872
+ maxIterations: input.maxIterations ?? DEFAULT_MAX_ITERATIONS
2873
+ };
2874
+ this.loops.set(loop.id, loop);
2875
+ logger.debug(`[LOOP] Created: ${loop.name} (${loop.id}) interval=${loop.intervalMs}ms`);
2876
+ return loop;
2877
+ }
2878
+ getLoop(id) {
2879
+ return this.loops.get(id);
2880
+ }
2881
+ listLoops() {
2882
+ return [...this.loops.values()].map((l) => ({
2883
+ id: l.id,
2884
+ name: l.name,
2885
+ state: l.state,
2886
+ iteration: l.iteration,
2887
+ intervalMs: l.intervalMs,
2888
+ nextRunAt: l.nextRunAt,
2889
+ lastCompletedAt: l.lastCompletedAt
2890
+ }));
2891
+ }
2892
+ pauseLoop(id) {
2893
+ const loop = this.loops.get(id);
2894
+ if (!loop || loop.state === "paused") return false;
2895
+ loop.state = "paused";
2896
+ logger.debug(`[LOOP] Paused: ${loop.name} (${id})`);
2897
+ return true;
2898
+ }
2899
+ resumeLoop(id) {
2900
+ const loop = this.loops.get(id);
2901
+ if (!loop || loop.state !== "paused") return false;
2902
+ loop.state = "idle";
2903
+ loop.nextRunAt = Date.now() + loop.intervalMs;
2904
+ loop.consecutiveFailures = 0;
2905
+ loop.errorMessage = void 0;
2906
+ logger.debug(`[LOOP] Resumed: ${loop.name} (${id})`);
2907
+ return true;
2908
+ }
2909
+ deleteLoop(id) {
2910
+ const deleted = this.loops.delete(id);
2911
+ if (deleted) logger.debug(`[LOOP] Deleted: ${id}`);
2912
+ return deleted;
2913
+ }
2914
+ // -----------------------------------------------------------------------
2915
+ // Scheduler callback
2916
+ // -----------------------------------------------------------------------
2917
+ onJobTerminal(loopId, status, errorMessage) {
2918
+ const loop = this.loops.get(loopId);
2919
+ if (!loop) return;
2920
+ loop.activeJobId = void 0;
2921
+ loop.lastCompletedAt = Date.now();
2922
+ if (status === "completed") {
2923
+ loop.consecutiveFailures = 0;
2924
+ loop.state = "idle";
2925
+ loop.nextRunAt = Date.now() + loop.intervalMs;
2926
+ logger.debug(`[LOOP] Iteration ${loop.iteration} completed: ${loop.name}`);
2927
+ } else {
2928
+ loop.consecutiveFailures++;
2929
+ loop.errorMessage = errorMessage;
2930
+ if (loop.consecutiveFailures >= loop.maxConsecutiveFailures) {
2931
+ loop.state = "blocked";
2932
+ logger.debug(`[LOOP] Blocked after ${loop.consecutiveFailures} failures: ${loop.name}`);
2933
+ } else {
2934
+ loop.state = "idle";
2935
+ loop.nextRunAt = Date.now() + loop.intervalMs;
2936
+ logger.debug(`[LOOP] Iteration ${loop.iteration} failed (${loop.consecutiveFailures}/${loop.maxConsecutiveFailures}): ${loop.name}`);
2937
+ }
2938
+ }
2939
+ }
2940
+ // -----------------------------------------------------------------------
2941
+ // Tick
2942
+ // -----------------------------------------------------------------------
2943
+ tick() {
2944
+ const now = Date.now();
2945
+ for (const loop of this.loops.values()) {
2946
+ if (loop.state !== "idle") continue;
2947
+ if (loop.nextRunAt > now) continue;
2948
+ if (loop.maxIterations > 0 && loop.iteration >= loop.maxIterations) {
2949
+ loop.state = "paused";
2950
+ logger.debug(`[LOOP] Max iterations reached (${loop.maxIterations}): ${loop.name}`);
2951
+ continue;
2952
+ }
2953
+ this.enqueueLoop(loop);
2954
+ }
2955
+ }
2956
+ enqueueLoop(loop) {
2957
+ loop.iteration++;
2958
+ loop.state = "active";
2959
+ loop.lastStartedAt = Date.now();
2960
+ const iterationNum = loop.iteration;
2961
+ const loopId = loop.id;
2962
+ const coordinator = this;
2963
+ const guardianSessionId = this.guardian?.resolve({ loopId: loop.id }) ?? void 0;
2964
+ const { job, deduped } = this.scheduler.enqueue({
2965
+ kind: "task",
2966
+ dedupeKey: `agent-loop:${loop.id}:${loop.iteration}`,
2967
+ priority: "background",
2968
+ run: async (jobId) => {
2969
+ const promptFile = await writeLoopPromptFile(loop.name, loop.prompt, iterationNum);
2970
+ const result = await spawnSession({
2971
+ directory: loop.directory,
2972
+ approvedNewDirectoryCreation: false,
2973
+ happySessionId: guardianSessionId,
2974
+ automationContext: {
2975
+ kind: "agent_loop",
2976
+ trigger: `loop:${loop.name}:iteration-${iterationNum}`
2977
+ },
2978
+ environmentVariables: {
2979
+ HAPPY_INITIAL_PROMPT_FILE: promptFile,
2980
+ HAPPY_LOOP_ID: loopId,
2981
+ HAPPY_LOOP_NAME: loop.name,
2982
+ HAPPY_LOOP_ITERATION: String(iterationNum),
2983
+ HAPPY_SERVER_URL: coordinator.serverUrl,
2984
+ HAPPY_AUTH_TOKEN: coordinator.authToken
2985
+ }
2986
+ });
2987
+ if (result.type !== "success") {
2988
+ throw new Error(result.type === "error" ? result.errorMessage : "Directory not approved");
2989
+ }
2990
+ const tracked = (await Promise.resolve().then(function () { return trackedSessions; })).getTrackedSession(result.pid);
2991
+ if (tracked?.childProcess) {
2992
+ tracked.childProcess.on("exit", (code) => {
2993
+ const status = code === 0 ? "completed" : "failed";
2994
+ coordinator.onJobTerminal(loopId, status, code !== 0 ? `exit code ${code}` : void 0);
2995
+ if (code === 0) coordinator.scheduler.markCompleted(jobId);
2996
+ else coordinator.scheduler.markFailed(jobId, `exit code ${code}`);
2997
+ });
2998
+ }
2999
+ return { pid: result.pid };
3000
+ }
3001
+ });
3002
+ if (deduped) {
3003
+ loop.iteration--;
3004
+ loop.state = "idle";
3005
+ logger.debug(`[LOOP] Enqueue deduped: ${loop.name} iteration ${iterationNum}`);
3006
+ } else {
3007
+ loop.activeJobId = job.id;
3008
+ logger.debug(`[LOOP] Enqueued: ${loop.name} iteration ${iterationNum} job=${job.id}`);
3009
+ }
3010
+ }
3011
+ }
3012
+ async function writeLoopPromptFile(name, prompt, iteration) {
3013
+ await promises.mkdir(PROMPT_DIR, { recursive: true });
3014
+ const filename = `loop-${name.replace(/[^a-zA-Z0-9-]/g, "_")}-${iteration}-${Date.now()}.md`;
3015
+ const filepath = path.join(PROMPT_DIR, filename);
3016
+ const content = [
3017
+ `# Agent Loop: ${name}`,
3018
+ `Iteration: ${iteration}`,
3019
+ "",
3020
+ prompt
3021
+ ].join("\n");
3022
+ await promises.writeFile(filepath, content, "utf-8");
3023
+ return filepath;
3024
+ }
3025
+
3026
+ class GuardianSessionRegistry {
3027
+ entries = /* @__PURE__ */ new Map();
3028
+ /**
3029
+ * Find an existing session to reuse.
3030
+ * Tries loop key first, then project key.
3031
+ */
3032
+ resolve(input) {
3033
+ if (input.loopId) {
3034
+ const entry = this.entries.get(`loop:${input.loopId}`);
3035
+ if (entry) {
3036
+ logger.debug(`[GUARDIAN] Resolved session ${entry.sessionId} for loop:${input.loopId}`);
3037
+ return entry.sessionId;
3038
+ }
3039
+ }
3040
+ if (input.projectId) {
3041
+ const entry = this.entries.get(`project:${input.projectId}`);
3042
+ if (entry) {
3043
+ logger.debug(`[GUARDIAN] Resolved session ${entry.sessionId} for project:${input.projectId}`);
3044
+ return entry.sessionId;
3045
+ }
3046
+ }
3047
+ return null;
3048
+ }
3049
+ /**
3050
+ * Remember a session for future reuse.
3051
+ * Stores under both loop and project keys if available.
3052
+ */
3053
+ remember(sessionId, input) {
3054
+ const now = Date.now();
3055
+ if (input.loopId) {
3056
+ const key = `loop:${input.loopId}`;
3057
+ this.entries.set(key, {
3058
+ key,
3059
+ sessionId,
3060
+ loopId: input.loopId,
3061
+ projectId: input.projectId,
3062
+ updatedAt: now
3063
+ });
3064
+ logger.debug(`[GUARDIAN] Remembered ${sessionId} for ${key}`);
3065
+ }
3066
+ if (input.projectId) {
3067
+ const key = `project:${input.projectId}`;
3068
+ if (!this.entries.has(key)) {
3069
+ this.entries.set(key, {
3070
+ key,
3071
+ sessionId,
3072
+ projectId: input.projectId,
3073
+ updatedAt: now
3074
+ });
3075
+ logger.debug(`[GUARDIAN] Remembered ${sessionId} for ${key}`);
3076
+ }
3077
+ }
3078
+ }
3079
+ /**
3080
+ * Forget a specific session (e.g., after it exits).
3081
+ */
3082
+ forgetSession(sessionId) {
3083
+ let removed = 0;
3084
+ for (const [key, entry] of this.entries) {
3085
+ if (entry.sessionId === sessionId) {
3086
+ this.entries.delete(key);
3087
+ removed++;
3088
+ }
3089
+ }
3090
+ if (removed > 0) {
3091
+ logger.debug(`[GUARDIAN] Forgot session ${sessionId} (${removed} entries)`);
3092
+ }
3093
+ return removed;
3094
+ }
3095
+ /**
3096
+ * Forget all entries for a loop.
3097
+ */
3098
+ forgetLoop(loopId) {
3099
+ return this.entries.delete(`loop:${loopId}`);
3100
+ }
3101
+ /**
3102
+ * Get all entries (for observability).
3103
+ */
3104
+ getSnapshot() {
3105
+ return [...this.entries.values()].sort((a, b) => b.updatedAt - a.updatedAt);
3106
+ }
3107
+ /**
3108
+ * Number of tracked guardian entries.
3109
+ */
3110
+ get size() {
3111
+ return this.entries.size;
3112
+ }
3113
+ }
3114
+
3115
+ class AutomationAuditStore {
3116
+ maxEntries;
3117
+ events = [];
3118
+ nextId = 1;
3119
+ constructor(options) {
3120
+ this.maxEntries = options?.maxEntries ?? 500;
3121
+ }
3122
+ /**
3123
+ * Record an audit event.
3124
+ */
3125
+ record(event) {
3126
+ const entry = {
3127
+ ...event,
3128
+ id: this.nextId++,
3129
+ timestamp: Date.now()
3130
+ };
3131
+ this.events.push(entry);
3132
+ while (this.events.length > this.maxEntries) {
3133
+ this.events.shift();
3134
+ }
3135
+ logger.debug(`[AUDIT] ${entry.kind}: ${entry.message ?? entry.dedupeKey ?? entry.jobId ?? ""}`);
3136
+ return entry;
3137
+ }
3138
+ /**
3139
+ * Query events with optional filters.
3140
+ */
3141
+ query(filter) {
3142
+ const limit = filter?.limit ?? 50;
3143
+ let results = this.events;
3144
+ if (filter?.kind) {
3145
+ results = results.filter((e) => e.kind === filter.kind);
3146
+ }
3147
+ if (filter?.loopId) {
3148
+ results = results.filter((e) => e.loopId === filter.loopId);
3149
+ }
3150
+ if (filter?.since) {
3151
+ results = results.filter((e) => e.timestamp >= filter.since);
3152
+ }
3153
+ return results.slice(-limit).reverse();
3154
+ }
3155
+ /**
3156
+ * Summary counts by kind.
3157
+ */
3158
+ summarize() {
3159
+ const counts = {
3160
+ job_enqueued: 0,
3161
+ job_dispatched: 0,
3162
+ job_completed: 0,
3163
+ job_failed: 0,
3164
+ job_retried: 0,
3165
+ loop_started: 0,
3166
+ loop_blocked: 0,
3167
+ loop_paused: 0
3168
+ };
3169
+ for (const event of this.events) {
3170
+ counts[event.kind]++;
3171
+ }
3172
+ return counts;
3173
+ }
3174
+ /**
3175
+ * Total number of stored events.
3176
+ */
3177
+ get size() {
3178
+ return this.events.length;
3179
+ }
3180
+ }
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
+
2725
3277
  function pidFilePath(homeDir) {
2726
3278
  return node_path.join(homeDir, "agent-daemon.pid");
2727
3279
  }
@@ -2789,9 +3341,36 @@ async function startDaemon(options) {
2789
3341
  agentVersion: version,
2790
3342
  workingDirectory: workDir
2791
3343
  });
2792
- const scheduler = new AutomationScheduler({ maxConcurrentJobs: 2 });
3344
+ const auditStore = new AutomationAuditStore();
3345
+ const scheduler = new AutomationScheduler({
3346
+ maxConcurrentJobs: 2,
3347
+ onAudit: (event) => auditStore.record(event)
3348
+ });
3349
+ const guardian = new GuardianSessionRegistry();
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}`);
2793
3371
  client.setTailscaleInfo(fullTailscale);
2794
- client.enableAutomation(config.serverUrl, creds.token, scheduler);
3372
+ client.enableAutomation(config.serverUrl, creds.token, scheduler, loopCoordinator, auditStore);
3373
+ loopCoordinator.start();
2795
3374
  client.connect();
2796
3375
  writePidFile(config.homeDir, process.pid);
2797
3376
  console.log(`Daemon started (PID ${process.pid})`);
@@ -2802,6 +3381,8 @@ async function startDaemon(options) {
2802
3381
  logger.debug(`[DAEMON] Received ${signal}, shutting down...`);
2803
3382
  console.log(`
2804
3383
  Received ${signal}, shutting down...`);
3384
+ webhookServer.shutdown();
3385
+ loopCoordinator.shutdown();
2805
3386
  scheduler.shutdown();
2806
3387
  client.shutdown();
2807
3388
  removePidFile(config.homeDir);