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