@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.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.
|
|
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
|
|
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);
|