@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 +588 -7
- package/dist/index.d.cts +187 -1
- package/dist/index.d.mts +187 -1
- package/dist/index.mjs +590 -9
- package/package.json +2 -2
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.
|
|
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
|
|
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);
|