@kmmao/happy-agent 0.4.1 → 0.5.0
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 +418 -7
- package/dist/index.d.cts +187 -1
- package/dist/index.d.mts +187 -1
- package/dist/index.mjs +418 -7
- package/package.json +2 -2
package/dist/index.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import { join as join$1, resolve } from 'path';
|
|
|
16
16
|
import { realpathSync } from 'fs';
|
|
17
17
|
import { tmpdir } from 'os';
|
|
18
18
|
|
|
19
|
-
var version = "0.
|
|
19
|
+
var version = "0.5.0";
|
|
20
20
|
|
|
21
21
|
function loadConfig() {
|
|
22
22
|
const serverUrl = (process.env.HAPPY_SERVER_URL ?? "https://happyserve.xycloud.info").replace(/\/+$/, "");
|
|
@@ -2023,11 +2023,11 @@ function stopSession(pid) {
|
|
|
2023
2023
|
}
|
|
2024
2024
|
}
|
|
2025
2025
|
|
|
2026
|
-
const PROMPT_DIR = join$1(tmpdir(), "happy", "agent-prompts");
|
|
2026
|
+
const PROMPT_DIR$1 = join$1(tmpdir(), "happy", "agent-prompts");
|
|
2027
2027
|
async function writePromptFile(prefix, content) {
|
|
2028
|
-
await mkdir(PROMPT_DIR, { recursive: true });
|
|
2028
|
+
await mkdir(PROMPT_DIR$1, { recursive: true });
|
|
2029
2029
|
const filename = `${prefix}-${Date.now()}.md`;
|
|
2030
|
-
const filepath = join$1(PROMPT_DIR, filename);
|
|
2030
|
+
const filepath = join$1(PROMPT_DIR$1, filename);
|
|
2031
2031
|
await writeFile(filepath, content, "utf-8");
|
|
2032
2032
|
return filepath;
|
|
2033
2033
|
}
|
|
@@ -2211,6 +2211,8 @@ class MachineClient {
|
|
|
2211
2211
|
automationServerUrl = "";
|
|
2212
2212
|
automationAuthToken = "";
|
|
2213
2213
|
scheduler = null;
|
|
2214
|
+
loopCoordinator = null;
|
|
2215
|
+
auditStore = null;
|
|
2214
2216
|
constructor(opts) {
|
|
2215
2217
|
this.token = opts.token;
|
|
2216
2218
|
this.machine = opts.machine;
|
|
@@ -2252,6 +2254,34 @@ class MachineClient {
|
|
|
2252
2254
|
return { sessions };
|
|
2253
2255
|
});
|
|
2254
2256
|
}
|
|
2257
|
+
registerLoopHandlers() {
|
|
2258
|
+
const coord = this.loopCoordinator;
|
|
2259
|
+
this.rpcHandlerManager.registerHandler("create-loop", async (data) => {
|
|
2260
|
+
const loop = coord.createLoop(data);
|
|
2261
|
+
return { loop: { id: loop.id, name: loop.name, state: loop.state } };
|
|
2262
|
+
});
|
|
2263
|
+
this.rpcHandlerManager.registerHandler("list-loops", async () => {
|
|
2264
|
+
return { loops: coord.listLoops() };
|
|
2265
|
+
});
|
|
2266
|
+
this.rpcHandlerManager.registerHandler("pause-loop", async (data) => {
|
|
2267
|
+
return { success: coord.pauseLoop(data.loopId) };
|
|
2268
|
+
});
|
|
2269
|
+
this.rpcHandlerManager.registerHandler("resume-loop", async (data) => {
|
|
2270
|
+
return { success: coord.resumeLoop(data.loopId) };
|
|
2271
|
+
});
|
|
2272
|
+
this.rpcHandlerManager.registerHandler("delete-loop", async (data) => {
|
|
2273
|
+
return { success: coord.deleteLoop(data.loopId) };
|
|
2274
|
+
});
|
|
2275
|
+
}
|
|
2276
|
+
registerAuditHandlers() {
|
|
2277
|
+
const audit = this.auditStore;
|
|
2278
|
+
this.rpcHandlerManager.registerHandler("query-audit-log", async (data) => {
|
|
2279
|
+
return { events: audit.query(data) };
|
|
2280
|
+
});
|
|
2281
|
+
this.rpcHandlerManager.registerHandler("audit-summary", async () => {
|
|
2282
|
+
return { summary: audit.summarize() };
|
|
2283
|
+
});
|
|
2284
|
+
}
|
|
2255
2285
|
// -----------------------------------------------------------------------
|
|
2256
2286
|
// Connection
|
|
2257
2287
|
// -----------------------------------------------------------------------
|
|
@@ -2433,11 +2463,19 @@ class MachineClient {
|
|
|
2433
2463
|
* Enable automation handling — agent will process webhook, supervisor,
|
|
2434
2464
|
* and task triggers from the server by spawning Happy CLI sessions.
|
|
2435
2465
|
*/
|
|
2436
|
-
enableAutomation(serverUrl, authToken, scheduler) {
|
|
2466
|
+
enableAutomation(serverUrl, authToken, scheduler, loopCoordinator, auditStore) {
|
|
2437
2467
|
this.automationEnabled = true;
|
|
2438
2468
|
this.automationServerUrl = serverUrl;
|
|
2439
2469
|
this.automationAuthToken = authToken;
|
|
2440
2470
|
this.scheduler = scheduler;
|
|
2471
|
+
this.loopCoordinator = loopCoordinator ?? null;
|
|
2472
|
+
this.auditStore = auditStore ?? null;
|
|
2473
|
+
if (this.loopCoordinator) {
|
|
2474
|
+
this.registerLoopHandlers();
|
|
2475
|
+
}
|
|
2476
|
+
if (this.auditStore) {
|
|
2477
|
+
this.registerAuditHandlers();
|
|
2478
|
+
}
|
|
2441
2479
|
logger.debug("[MACHINE] Automation enabled");
|
|
2442
2480
|
}
|
|
2443
2481
|
/** Internal dispatch for ephemeral events that need automation handling. */
|
|
@@ -2558,6 +2596,7 @@ class AutomationScheduler {
|
|
|
2558
2596
|
retryDelayMs;
|
|
2559
2597
|
defaultMaxAttempts;
|
|
2560
2598
|
maxRecentCompletions;
|
|
2599
|
+
onAudit;
|
|
2561
2600
|
/** Active jobs indexed by id. */
|
|
2562
2601
|
jobs = /* @__PURE__ */ new Map();
|
|
2563
2602
|
/** dedupeKey → jobId for fast dedup lookups. */
|
|
@@ -2571,6 +2610,7 @@ class AutomationScheduler {
|
|
|
2571
2610
|
this.retryDelayMs = options?.retryDelayMs ?? 5e3;
|
|
2572
2611
|
this.defaultMaxAttempts = options?.maxAttempts ?? 3;
|
|
2573
2612
|
this.maxRecentCompletions = options?.maxRecentCompletions ?? 50;
|
|
2613
|
+
this.onAudit = options?.onAudit ?? null;
|
|
2574
2614
|
this.pumpTimer = setInterval(() => this.pump(), 1e3);
|
|
2575
2615
|
}
|
|
2576
2616
|
// -----------------------------------------------------------------------
|
|
@@ -2601,6 +2641,7 @@ class AutomationScheduler {
|
|
|
2601
2641
|
this.jobs.set(job.id, job);
|
|
2602
2642
|
this.dedupeIndex.set(job.dedupeKey, job.id);
|
|
2603
2643
|
logger.debug(`[SCHEDULER] Enqueued: ${job.kind} ${job.dedupeKey} (${job.priority}) id=${job.id}`);
|
|
2644
|
+
this.onAudit?.({ kind: "job_enqueued", jobId: job.id, dedupeKey: job.dedupeKey, message: `${job.kind}:${job.priority}` });
|
|
2604
2645
|
this.pump();
|
|
2605
2646
|
return { job, deduped: false };
|
|
2606
2647
|
}
|
|
@@ -2610,6 +2651,7 @@ class AutomationScheduler {
|
|
|
2610
2651
|
job.status = "completed";
|
|
2611
2652
|
job.updatedAt = Date.now();
|
|
2612
2653
|
logger.debug(`[SCHEDULER] Completed: ${job.kind} ${job.dedupeKey} id=${jobId}`);
|
|
2654
|
+
this.onAudit?.({ kind: "job_completed", jobId, dedupeKey: job.dedupeKey });
|
|
2613
2655
|
this.finalize(job);
|
|
2614
2656
|
}
|
|
2615
2657
|
markFailed(jobId, error) {
|
|
@@ -2621,11 +2663,13 @@ class AutomationScheduler {
|
|
|
2621
2663
|
job.status = "queued";
|
|
2622
2664
|
job.nextRunAt = Date.now() + job.attempt * this.retryDelayMs;
|
|
2623
2665
|
logger.debug(`[SCHEDULER] Retry queued: ${job.dedupeKey} attempt=${job.attempt}/${job.maxAttempts} nextRunAt=+${job.attempt * this.retryDelayMs}ms`);
|
|
2666
|
+
this.onAudit?.({ kind: "job_retried", jobId, dedupeKey: job.dedupeKey, errorMessage: error, message: `attempt ${job.attempt}/${job.maxAttempts}` });
|
|
2624
2667
|
this.pump();
|
|
2625
2668
|
return;
|
|
2626
2669
|
}
|
|
2627
2670
|
job.status = "failed";
|
|
2628
2671
|
logger.debug(`[SCHEDULER] Failed (exhausted): ${job.kind} ${job.dedupeKey} id=${jobId}: ${error}`);
|
|
2672
|
+
this.onAudit?.({ kind: "job_failed", jobId, dedupeKey: job.dedupeKey, errorMessage: error });
|
|
2629
2673
|
this.finalize(job);
|
|
2630
2674
|
}
|
|
2631
2675
|
getStatus() {
|
|
@@ -2685,6 +2729,7 @@ class AutomationScheduler {
|
|
|
2685
2729
|
job.attempt++;
|
|
2686
2730
|
job.updatedAt = Date.now();
|
|
2687
2731
|
logger.debug(`[SCHEDULER] Dispatching: ${job.kind} ${job.dedupeKey} attempt=${job.attempt}`);
|
|
2732
|
+
this.onAudit?.({ kind: "job_dispatched", jobId: job.id, dedupeKey: job.dedupeKey, message: `attempt ${job.attempt}` });
|
|
2688
2733
|
job.run(job.id).then(({ pid }) => {
|
|
2689
2734
|
if (job.status === "dispatching") {
|
|
2690
2735
|
job.status = "running";
|
|
@@ -2720,6 +2765,364 @@ class AutomationScheduler {
|
|
|
2720
2765
|
}
|
|
2721
2766
|
}
|
|
2722
2767
|
|
|
2768
|
+
const PROMPT_DIR = join$1(tmpdir(), "happy", "agent-loop-prompts");
|
|
2769
|
+
const DEFAULT_MAX_CONSECUTIVE_FAILURES = 5;
|
|
2770
|
+
const DEFAULT_MAX_ITERATIONS = 0;
|
|
2771
|
+
class AgentLoopCoordinator {
|
|
2772
|
+
loops = /* @__PURE__ */ new Map();
|
|
2773
|
+
scheduler;
|
|
2774
|
+
serverUrl;
|
|
2775
|
+
authToken;
|
|
2776
|
+
guardian;
|
|
2777
|
+
tickTimer = null;
|
|
2778
|
+
constructor(scheduler, serverUrl, authToken, guardian) {
|
|
2779
|
+
this.scheduler = scheduler;
|
|
2780
|
+
this.serverUrl = serverUrl;
|
|
2781
|
+
this.authToken = authToken;
|
|
2782
|
+
this.guardian = guardian ?? null;
|
|
2783
|
+
}
|
|
2784
|
+
// -----------------------------------------------------------------------
|
|
2785
|
+
// Lifecycle
|
|
2786
|
+
// -----------------------------------------------------------------------
|
|
2787
|
+
start() {
|
|
2788
|
+
if (this.tickTimer) return;
|
|
2789
|
+
this.tickTimer = setInterval(() => this.tick(), 1e3);
|
|
2790
|
+
logger.debug("[LOOP] Coordinator started");
|
|
2791
|
+
}
|
|
2792
|
+
shutdown() {
|
|
2793
|
+
if (this.tickTimer) {
|
|
2794
|
+
clearInterval(this.tickTimer);
|
|
2795
|
+
this.tickTimer = null;
|
|
2796
|
+
}
|
|
2797
|
+
logger.debug("[LOOP] Coordinator shutdown");
|
|
2798
|
+
}
|
|
2799
|
+
// -----------------------------------------------------------------------
|
|
2800
|
+
// CRUD
|
|
2801
|
+
// -----------------------------------------------------------------------
|
|
2802
|
+
createLoop(input) {
|
|
2803
|
+
const loop = {
|
|
2804
|
+
id: randomUUID(),
|
|
2805
|
+
name: input.name,
|
|
2806
|
+
prompt: input.prompt,
|
|
2807
|
+
directory: input.directory,
|
|
2808
|
+
intervalMs: Math.max(input.intervalMs, 1e4),
|
|
2809
|
+
// min 10s
|
|
2810
|
+
createdAt: Date.now(),
|
|
2811
|
+
state: "idle",
|
|
2812
|
+
iteration: 0,
|
|
2813
|
+
nextRunAt: Date.now() + Math.max(input.intervalMs, 1e4),
|
|
2814
|
+
consecutiveFailures: 0,
|
|
2815
|
+
maxConsecutiveFailures: input.maxConsecutiveFailures ?? DEFAULT_MAX_CONSECUTIVE_FAILURES,
|
|
2816
|
+
maxIterations: input.maxIterations ?? DEFAULT_MAX_ITERATIONS
|
|
2817
|
+
};
|
|
2818
|
+
this.loops.set(loop.id, loop);
|
|
2819
|
+
logger.debug(`[LOOP] Created: ${loop.name} (${loop.id}) interval=${loop.intervalMs}ms`);
|
|
2820
|
+
return loop;
|
|
2821
|
+
}
|
|
2822
|
+
getLoop(id) {
|
|
2823
|
+
return this.loops.get(id);
|
|
2824
|
+
}
|
|
2825
|
+
listLoops() {
|
|
2826
|
+
return [...this.loops.values()].map((l) => ({
|
|
2827
|
+
id: l.id,
|
|
2828
|
+
name: l.name,
|
|
2829
|
+
state: l.state,
|
|
2830
|
+
iteration: l.iteration,
|
|
2831
|
+
intervalMs: l.intervalMs,
|
|
2832
|
+
nextRunAt: l.nextRunAt,
|
|
2833
|
+
lastCompletedAt: l.lastCompletedAt
|
|
2834
|
+
}));
|
|
2835
|
+
}
|
|
2836
|
+
pauseLoop(id) {
|
|
2837
|
+
const loop = this.loops.get(id);
|
|
2838
|
+
if (!loop || loop.state === "paused") return false;
|
|
2839
|
+
loop.state = "paused";
|
|
2840
|
+
logger.debug(`[LOOP] Paused: ${loop.name} (${id})`);
|
|
2841
|
+
return true;
|
|
2842
|
+
}
|
|
2843
|
+
resumeLoop(id) {
|
|
2844
|
+
const loop = this.loops.get(id);
|
|
2845
|
+
if (!loop || loop.state !== "paused") return false;
|
|
2846
|
+
loop.state = "idle";
|
|
2847
|
+
loop.nextRunAt = Date.now() + loop.intervalMs;
|
|
2848
|
+
loop.consecutiveFailures = 0;
|
|
2849
|
+
loop.errorMessage = void 0;
|
|
2850
|
+
logger.debug(`[LOOP] Resumed: ${loop.name} (${id})`);
|
|
2851
|
+
return true;
|
|
2852
|
+
}
|
|
2853
|
+
deleteLoop(id) {
|
|
2854
|
+
const deleted = this.loops.delete(id);
|
|
2855
|
+
if (deleted) logger.debug(`[LOOP] Deleted: ${id}`);
|
|
2856
|
+
return deleted;
|
|
2857
|
+
}
|
|
2858
|
+
// -----------------------------------------------------------------------
|
|
2859
|
+
// Scheduler callback
|
|
2860
|
+
// -----------------------------------------------------------------------
|
|
2861
|
+
onJobTerminal(loopId, status, errorMessage) {
|
|
2862
|
+
const loop = this.loops.get(loopId);
|
|
2863
|
+
if (!loop) return;
|
|
2864
|
+
loop.activeJobId = void 0;
|
|
2865
|
+
loop.lastCompletedAt = Date.now();
|
|
2866
|
+
if (status === "completed") {
|
|
2867
|
+
loop.consecutiveFailures = 0;
|
|
2868
|
+
loop.state = "idle";
|
|
2869
|
+
loop.nextRunAt = Date.now() + loop.intervalMs;
|
|
2870
|
+
logger.debug(`[LOOP] Iteration ${loop.iteration} completed: ${loop.name}`);
|
|
2871
|
+
} else {
|
|
2872
|
+
loop.consecutiveFailures++;
|
|
2873
|
+
loop.errorMessage = errorMessage;
|
|
2874
|
+
if (loop.consecutiveFailures >= loop.maxConsecutiveFailures) {
|
|
2875
|
+
loop.state = "blocked";
|
|
2876
|
+
logger.debug(`[LOOP] Blocked after ${loop.consecutiveFailures} failures: ${loop.name}`);
|
|
2877
|
+
} else {
|
|
2878
|
+
loop.state = "idle";
|
|
2879
|
+
loop.nextRunAt = Date.now() + loop.intervalMs;
|
|
2880
|
+
logger.debug(`[LOOP] Iteration ${loop.iteration} failed (${loop.consecutiveFailures}/${loop.maxConsecutiveFailures}): ${loop.name}`);
|
|
2881
|
+
}
|
|
2882
|
+
}
|
|
2883
|
+
}
|
|
2884
|
+
// -----------------------------------------------------------------------
|
|
2885
|
+
// Tick
|
|
2886
|
+
// -----------------------------------------------------------------------
|
|
2887
|
+
tick() {
|
|
2888
|
+
const now = Date.now();
|
|
2889
|
+
for (const loop of this.loops.values()) {
|
|
2890
|
+
if (loop.state !== "idle") continue;
|
|
2891
|
+
if (loop.nextRunAt > now) continue;
|
|
2892
|
+
if (loop.maxIterations > 0 && loop.iteration >= loop.maxIterations) {
|
|
2893
|
+
loop.state = "paused";
|
|
2894
|
+
logger.debug(`[LOOP] Max iterations reached (${loop.maxIterations}): ${loop.name}`);
|
|
2895
|
+
continue;
|
|
2896
|
+
}
|
|
2897
|
+
this.enqueueLoop(loop);
|
|
2898
|
+
}
|
|
2899
|
+
}
|
|
2900
|
+
enqueueLoop(loop) {
|
|
2901
|
+
loop.iteration++;
|
|
2902
|
+
loop.state = "active";
|
|
2903
|
+
loop.lastStartedAt = Date.now();
|
|
2904
|
+
const iterationNum = loop.iteration;
|
|
2905
|
+
const loopId = loop.id;
|
|
2906
|
+
const coordinator = this;
|
|
2907
|
+
const guardianSessionId = this.guardian?.resolve({ loopId: loop.id }) ?? void 0;
|
|
2908
|
+
const { job, deduped } = this.scheduler.enqueue({
|
|
2909
|
+
kind: "task",
|
|
2910
|
+
dedupeKey: `agent-loop:${loop.id}:${loop.iteration}`,
|
|
2911
|
+
priority: "background",
|
|
2912
|
+
run: async (jobId) => {
|
|
2913
|
+
const promptFile = await writeLoopPromptFile(loop.name, loop.prompt, iterationNum);
|
|
2914
|
+
const result = await spawnSession({
|
|
2915
|
+
directory: loop.directory,
|
|
2916
|
+
approvedNewDirectoryCreation: false,
|
|
2917
|
+
happySessionId: guardianSessionId,
|
|
2918
|
+
automationContext: {
|
|
2919
|
+
kind: "agent_loop",
|
|
2920
|
+
trigger: `loop:${loop.name}:iteration-${iterationNum}`
|
|
2921
|
+
},
|
|
2922
|
+
environmentVariables: {
|
|
2923
|
+
HAPPY_INITIAL_PROMPT_FILE: promptFile,
|
|
2924
|
+
HAPPY_LOOP_ID: loopId,
|
|
2925
|
+
HAPPY_LOOP_NAME: loop.name,
|
|
2926
|
+
HAPPY_LOOP_ITERATION: String(iterationNum),
|
|
2927
|
+
HAPPY_SERVER_URL: coordinator.serverUrl,
|
|
2928
|
+
HAPPY_AUTH_TOKEN: coordinator.authToken
|
|
2929
|
+
}
|
|
2930
|
+
});
|
|
2931
|
+
if (result.type !== "success") {
|
|
2932
|
+
throw new Error(result.type === "error" ? result.errorMessage : "Directory not approved");
|
|
2933
|
+
}
|
|
2934
|
+
const tracked = (await Promise.resolve().then(function () { return trackedSessions; })).getTrackedSession(result.pid);
|
|
2935
|
+
if (tracked?.childProcess) {
|
|
2936
|
+
tracked.childProcess.on("exit", (code) => {
|
|
2937
|
+
const status = code === 0 ? "completed" : "failed";
|
|
2938
|
+
coordinator.onJobTerminal(loopId, status, code !== 0 ? `exit code ${code}` : void 0);
|
|
2939
|
+
if (code === 0) coordinator.scheduler.markCompleted(jobId);
|
|
2940
|
+
else coordinator.scheduler.markFailed(jobId, `exit code ${code}`);
|
|
2941
|
+
});
|
|
2942
|
+
}
|
|
2943
|
+
return { pid: result.pid };
|
|
2944
|
+
}
|
|
2945
|
+
});
|
|
2946
|
+
if (deduped) {
|
|
2947
|
+
loop.iteration--;
|
|
2948
|
+
loop.state = "idle";
|
|
2949
|
+
logger.debug(`[LOOP] Enqueue deduped: ${loop.name} iteration ${iterationNum}`);
|
|
2950
|
+
} else {
|
|
2951
|
+
loop.activeJobId = job.id;
|
|
2952
|
+
logger.debug(`[LOOP] Enqueued: ${loop.name} iteration ${iterationNum} job=${job.id}`);
|
|
2953
|
+
}
|
|
2954
|
+
}
|
|
2955
|
+
}
|
|
2956
|
+
async function writeLoopPromptFile(name, prompt, iteration) {
|
|
2957
|
+
await mkdir(PROMPT_DIR, { recursive: true });
|
|
2958
|
+
const filename = `loop-${name.replace(/[^a-zA-Z0-9-]/g, "_")}-${iteration}-${Date.now()}.md`;
|
|
2959
|
+
const filepath = join$1(PROMPT_DIR, filename);
|
|
2960
|
+
const content = [
|
|
2961
|
+
`# Agent Loop: ${name}`,
|
|
2962
|
+
`Iteration: ${iteration}`,
|
|
2963
|
+
"",
|
|
2964
|
+
prompt
|
|
2965
|
+
].join("\n");
|
|
2966
|
+
await writeFile(filepath, content, "utf-8");
|
|
2967
|
+
return filepath;
|
|
2968
|
+
}
|
|
2969
|
+
|
|
2970
|
+
class GuardianSessionRegistry {
|
|
2971
|
+
entries = /* @__PURE__ */ new Map();
|
|
2972
|
+
/**
|
|
2973
|
+
* Find an existing session to reuse.
|
|
2974
|
+
* Tries loop key first, then project key.
|
|
2975
|
+
*/
|
|
2976
|
+
resolve(input) {
|
|
2977
|
+
if (input.loopId) {
|
|
2978
|
+
const entry = this.entries.get(`loop:${input.loopId}`);
|
|
2979
|
+
if (entry) {
|
|
2980
|
+
logger.debug(`[GUARDIAN] Resolved session ${entry.sessionId} for loop:${input.loopId}`);
|
|
2981
|
+
return entry.sessionId;
|
|
2982
|
+
}
|
|
2983
|
+
}
|
|
2984
|
+
if (input.projectId) {
|
|
2985
|
+
const entry = this.entries.get(`project:${input.projectId}`);
|
|
2986
|
+
if (entry) {
|
|
2987
|
+
logger.debug(`[GUARDIAN] Resolved session ${entry.sessionId} for project:${input.projectId}`);
|
|
2988
|
+
return entry.sessionId;
|
|
2989
|
+
}
|
|
2990
|
+
}
|
|
2991
|
+
return null;
|
|
2992
|
+
}
|
|
2993
|
+
/**
|
|
2994
|
+
* Remember a session for future reuse.
|
|
2995
|
+
* Stores under both loop and project keys if available.
|
|
2996
|
+
*/
|
|
2997
|
+
remember(sessionId, input) {
|
|
2998
|
+
const now = Date.now();
|
|
2999
|
+
if (input.loopId) {
|
|
3000
|
+
const key = `loop:${input.loopId}`;
|
|
3001
|
+
this.entries.set(key, {
|
|
3002
|
+
key,
|
|
3003
|
+
sessionId,
|
|
3004
|
+
loopId: input.loopId,
|
|
3005
|
+
projectId: input.projectId,
|
|
3006
|
+
updatedAt: now
|
|
3007
|
+
});
|
|
3008
|
+
logger.debug(`[GUARDIAN] Remembered ${sessionId} for ${key}`);
|
|
3009
|
+
}
|
|
3010
|
+
if (input.projectId) {
|
|
3011
|
+
const key = `project:${input.projectId}`;
|
|
3012
|
+
if (!this.entries.has(key)) {
|
|
3013
|
+
this.entries.set(key, {
|
|
3014
|
+
key,
|
|
3015
|
+
sessionId,
|
|
3016
|
+
projectId: input.projectId,
|
|
3017
|
+
updatedAt: now
|
|
3018
|
+
});
|
|
3019
|
+
logger.debug(`[GUARDIAN] Remembered ${sessionId} for ${key}`);
|
|
3020
|
+
}
|
|
3021
|
+
}
|
|
3022
|
+
}
|
|
3023
|
+
/**
|
|
3024
|
+
* Forget a specific session (e.g., after it exits).
|
|
3025
|
+
*/
|
|
3026
|
+
forgetSession(sessionId) {
|
|
3027
|
+
let removed = 0;
|
|
3028
|
+
for (const [key, entry] of this.entries) {
|
|
3029
|
+
if (entry.sessionId === sessionId) {
|
|
3030
|
+
this.entries.delete(key);
|
|
3031
|
+
removed++;
|
|
3032
|
+
}
|
|
3033
|
+
}
|
|
3034
|
+
if (removed > 0) {
|
|
3035
|
+
logger.debug(`[GUARDIAN] Forgot session ${sessionId} (${removed} entries)`);
|
|
3036
|
+
}
|
|
3037
|
+
return removed;
|
|
3038
|
+
}
|
|
3039
|
+
/**
|
|
3040
|
+
* Forget all entries for a loop.
|
|
3041
|
+
*/
|
|
3042
|
+
forgetLoop(loopId) {
|
|
3043
|
+
return this.entries.delete(`loop:${loopId}`);
|
|
3044
|
+
}
|
|
3045
|
+
/**
|
|
3046
|
+
* Get all entries (for observability).
|
|
3047
|
+
*/
|
|
3048
|
+
getSnapshot() {
|
|
3049
|
+
return [...this.entries.values()].sort((a, b) => b.updatedAt - a.updatedAt);
|
|
3050
|
+
}
|
|
3051
|
+
/**
|
|
3052
|
+
* Number of tracked guardian entries.
|
|
3053
|
+
*/
|
|
3054
|
+
get size() {
|
|
3055
|
+
return this.entries.size;
|
|
3056
|
+
}
|
|
3057
|
+
}
|
|
3058
|
+
|
|
3059
|
+
class AutomationAuditStore {
|
|
3060
|
+
maxEntries;
|
|
3061
|
+
events = [];
|
|
3062
|
+
nextId = 1;
|
|
3063
|
+
constructor(options) {
|
|
3064
|
+
this.maxEntries = options?.maxEntries ?? 500;
|
|
3065
|
+
}
|
|
3066
|
+
/**
|
|
3067
|
+
* Record an audit event.
|
|
3068
|
+
*/
|
|
3069
|
+
record(event) {
|
|
3070
|
+
const entry = {
|
|
3071
|
+
...event,
|
|
3072
|
+
id: this.nextId++,
|
|
3073
|
+
timestamp: Date.now()
|
|
3074
|
+
};
|
|
3075
|
+
this.events.push(entry);
|
|
3076
|
+
while (this.events.length > this.maxEntries) {
|
|
3077
|
+
this.events.shift();
|
|
3078
|
+
}
|
|
3079
|
+
logger.debug(`[AUDIT] ${entry.kind}: ${entry.message ?? entry.dedupeKey ?? entry.jobId ?? ""}`);
|
|
3080
|
+
return entry;
|
|
3081
|
+
}
|
|
3082
|
+
/**
|
|
3083
|
+
* Query events with optional filters.
|
|
3084
|
+
*/
|
|
3085
|
+
query(filter) {
|
|
3086
|
+
const limit = filter?.limit ?? 50;
|
|
3087
|
+
let results = this.events;
|
|
3088
|
+
if (filter?.kind) {
|
|
3089
|
+
results = results.filter((e) => e.kind === filter.kind);
|
|
3090
|
+
}
|
|
3091
|
+
if (filter?.loopId) {
|
|
3092
|
+
results = results.filter((e) => e.loopId === filter.loopId);
|
|
3093
|
+
}
|
|
3094
|
+
if (filter?.since) {
|
|
3095
|
+
results = results.filter((e) => e.timestamp >= filter.since);
|
|
3096
|
+
}
|
|
3097
|
+
return results.slice(-limit).reverse();
|
|
3098
|
+
}
|
|
3099
|
+
/**
|
|
3100
|
+
* Summary counts by kind.
|
|
3101
|
+
*/
|
|
3102
|
+
summarize() {
|
|
3103
|
+
const counts = {
|
|
3104
|
+
job_enqueued: 0,
|
|
3105
|
+
job_dispatched: 0,
|
|
3106
|
+
job_completed: 0,
|
|
3107
|
+
job_failed: 0,
|
|
3108
|
+
job_retried: 0,
|
|
3109
|
+
loop_started: 0,
|
|
3110
|
+
loop_blocked: 0,
|
|
3111
|
+
loop_paused: 0
|
|
3112
|
+
};
|
|
3113
|
+
for (const event of this.events) {
|
|
3114
|
+
counts[event.kind]++;
|
|
3115
|
+
}
|
|
3116
|
+
return counts;
|
|
3117
|
+
}
|
|
3118
|
+
/**
|
|
3119
|
+
* Total number of stored events.
|
|
3120
|
+
*/
|
|
3121
|
+
get size() {
|
|
3122
|
+
return this.events.length;
|
|
3123
|
+
}
|
|
3124
|
+
}
|
|
3125
|
+
|
|
2723
3126
|
function pidFilePath(homeDir) {
|
|
2724
3127
|
return join(homeDir, "agent-daemon.pid");
|
|
2725
3128
|
}
|
|
@@ -2787,9 +3190,16 @@ async function startDaemon(options) {
|
|
|
2787
3190
|
agentVersion: version,
|
|
2788
3191
|
workingDirectory: workDir
|
|
2789
3192
|
});
|
|
2790
|
-
const
|
|
3193
|
+
const auditStore = new AutomationAuditStore();
|
|
3194
|
+
const scheduler = new AutomationScheduler({
|
|
3195
|
+
maxConcurrentJobs: 2,
|
|
3196
|
+
onAudit: (event) => auditStore.record(event)
|
|
3197
|
+
});
|
|
3198
|
+
const guardian = new GuardianSessionRegistry();
|
|
3199
|
+
const loopCoordinator = new AgentLoopCoordinator(scheduler, config.serverUrl, creds.token, guardian);
|
|
2791
3200
|
client.setTailscaleInfo(fullTailscale);
|
|
2792
|
-
client.enableAutomation(config.serverUrl, creds.token, scheduler);
|
|
3201
|
+
client.enableAutomation(config.serverUrl, creds.token, scheduler, loopCoordinator, auditStore);
|
|
3202
|
+
loopCoordinator.start();
|
|
2793
3203
|
client.connect();
|
|
2794
3204
|
writePidFile(config.homeDir, process.pid);
|
|
2795
3205
|
console.log(`Daemon started (PID ${process.pid})`);
|
|
@@ -2800,6 +3210,7 @@ async function startDaemon(options) {
|
|
|
2800
3210
|
logger.debug(`[DAEMON] Received ${signal}, shutting down...`);
|
|
2801
3211
|
console.log(`
|
|
2802
3212
|
Received ${signal}, shutting down...`);
|
|
3213
|
+
loopCoordinator.shutdown();
|
|
2803
3214
|
scheduler.shutdown();
|
|
2804
3215
|
client.shutdown();
|
|
2805
3216
|
removePidFile(config.homeDir);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kmmao/happy-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "CLI client for controlling Happy Coder agents remotely",
|
|
5
5
|
"author": "Kirill Dubovitskiy",
|
|
6
6
|
"license": "MIT",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"release": "npx --no-install release-it"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@kmmao/happy-wire": "^0.11.
|
|
46
|
+
"@kmmao/happy-wire": "^0.11.1",
|
|
47
47
|
"axios": "^1.13.5",
|
|
48
48
|
"chalk": "^5.6.2",
|
|
49
49
|
"commander": "^13.1.0",
|