@kmmao/happy-agent 0.4.0 → 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.mjs CHANGED
@@ -11,12 +11,12 @@ import { io } from 'socket.io-client';
11
11
  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
- import { createHash as createHash$1 } from 'crypto';
14
+ import { createHash as createHash$1, randomUUID } from 'crypto';
15
15
  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.4.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(/\/+$/, "");
@@ -1844,9 +1844,24 @@ function untrackSession(pid) {
1844
1844
  pidToSession.delete(pid);
1845
1845
  return session;
1846
1846
  }
1847
+ function getTrackedSession(pid) {
1848
+ return pidToSession.get(pid);
1849
+ }
1847
1850
  function getAllTrackedSessions() {
1848
1851
  return [...pidToSession.values()];
1849
1852
  }
1853
+ function getTrackedSessionCount() {
1854
+ return pidToSession.size;
1855
+ }
1856
+
1857
+ var trackedSessions = /*#__PURE__*/Object.freeze({
1858
+ __proto__: null,
1859
+ getAllTrackedSessions: getAllTrackedSessions,
1860
+ getTrackedSession: getTrackedSession,
1861
+ getTrackedSessionCount: getTrackedSessionCount,
1862
+ trackSession: trackSession,
1863
+ untrackSession: untrackSession
1864
+ });
1850
1865
 
1851
1866
  const execFileAsync = promisify(execFile);
1852
1867
  const SERVER_INTERNAL_SECRETS = /* @__PURE__ */ new Set([
@@ -2008,11 +2023,11 @@ function stopSession(pid) {
2008
2023
  }
2009
2024
  }
2010
2025
 
2011
- const PROMPT_DIR = join$1(tmpdir(), "happy", "agent-prompts");
2026
+ const PROMPT_DIR$1 = join$1(tmpdir(), "happy", "agent-prompts");
2012
2027
  async function writePromptFile(prefix, content) {
2013
- await mkdir(PROMPT_DIR, { recursive: true });
2028
+ await mkdir(PROMPT_DIR$1, { recursive: true });
2014
2029
  const filename = `${prefix}-${Date.now()}.md`;
2015
- const filepath = join$1(PROMPT_DIR, filename);
2030
+ const filepath = join$1(PROMPT_DIR$1, filename);
2016
2031
  await writeFile(filepath, content, "utf-8");
2017
2032
  return filepath;
2018
2033
  }
@@ -2042,130 +2057,139 @@ function buildSupervisorPrompt(data) {
2042
2057
  if (data.customRules) parts.push("", "## Custom Rules", data.customRules);
2043
2058
  return parts.join("\n");
2044
2059
  }
2045
- async function handleWebhookTrigger(data, client, serverUrl, authToken) {
2060
+ function mapTaskPriority(priority) {
2061
+ if (priority === "urgent" || priority === "high") return "urgent";
2062
+ if (priority === "background" || priority === "low") return "background";
2063
+ return "user";
2064
+ }
2065
+ function handleWebhookTrigger(data, client, serverUrl, authToken, scheduler) {
2046
2066
  logger.debug(`[TRIGGER] Webhook: issue #${data.issueNumber} in ${data.repoPath}`);
2047
- client.emitWebhookStatus({
2048
- webhookEventId: data.webhookEventId,
2049
- status: "dispatched"
2050
- });
2051
- try {
2052
- const promptFile = await writePromptFile("webhook", buildWebhookPrompt(data));
2053
- const result = await spawnSession({
2054
- directory: data.repoPath,
2055
- approvedNewDirectoryCreation: false,
2056
- automationContext: {
2057
- kind: "webhook",
2058
- trigger: `issue#${data.issueNumber}`
2059
- },
2060
- environmentVariables: {
2061
- HAPPY_INITIAL_PROMPT_FILE: promptFile,
2062
- HAPPY_WEBHOOK_EVENT_ID: data.webhookEventId,
2063
- HAPPY_WEBHOOK_ISSUE_URL: data.issueUrl,
2064
- HAPPY_SERVER_URL: serverUrl,
2065
- HAPPY_AUTH_TOKEN: authToken
2066
- }
2067
- });
2068
- if (result.type === "success") {
2069
- logger.debug(`[TRIGGER] Webhook session spawned: PID ${result.pid}`);
2070
- } else {
2071
- logger.debug(`[TRIGGER] Webhook spawn failed: ${result.type === "error" ? result.errorMessage : "needs approval"}`);
2072
- client.emitWebhookStatus({
2073
- webhookEventId: data.webhookEventId,
2074
- status: "failed",
2075
- errorMessage: result.type === "error" ? result.errorMessage : "Directory creation not approved"
2067
+ const { deduped } = scheduler.enqueue({
2068
+ kind: "webhook",
2069
+ dedupeKey: `webhook:${data.webhookEventId}`,
2070
+ priority: "background",
2071
+ run: async (jobId) => {
2072
+ client.emitWebhookStatus({ webhookEventId: data.webhookEventId, status: "dispatched" });
2073
+ const promptFile = await writePromptFile("webhook", buildWebhookPrompt(data));
2074
+ const result = await spawnSession({
2075
+ directory: data.repoPath,
2076
+ approvedNewDirectoryCreation: false,
2077
+ automationContext: { kind: "webhook", trigger: `issue#${data.issueNumber}` },
2078
+ environmentVariables: {
2079
+ HAPPY_INITIAL_PROMPT_FILE: promptFile,
2080
+ HAPPY_WEBHOOK_EVENT_ID: data.webhookEventId,
2081
+ HAPPY_WEBHOOK_ISSUE_URL: data.issueUrl,
2082
+ HAPPY_SERVER_URL: serverUrl,
2083
+ HAPPY_AUTH_TOKEN: authToken
2084
+ }
2076
2085
  });
2086
+ if (result.type !== "success") {
2087
+ const msg = result.type === "error" ? result.errorMessage : "Directory creation not approved";
2088
+ client.emitWebhookStatus({ webhookEventId: data.webhookEventId, status: "failed", errorMessage: msg });
2089
+ throw new Error(msg);
2090
+ }
2091
+ const tracked = (await Promise.resolve().then(function () { return trackedSessions; })).getTrackedSession(result.pid);
2092
+ if (tracked?.childProcess) {
2093
+ tracked.childProcess.on("exit", (code) => {
2094
+ if (code === 0) scheduler.markCompleted(jobId);
2095
+ else scheduler.markFailed(jobId, `exit code ${code}`);
2096
+ client.emitWebhookStatus({ webhookEventId: data.webhookEventId, status: code === 0 ? "completed" : "failed" });
2097
+ });
2098
+ }
2099
+ return { pid: result.pid };
2077
2100
  }
2078
- } catch (error) {
2079
- const msg = error instanceof Error ? error.message : String(error);
2080
- logger.debug(`[TRIGGER] Webhook error: ${msg}`);
2081
- client.emitWebhookStatus({
2082
- webhookEventId: data.webhookEventId,
2083
- status: "failed",
2084
- errorMessage: msg
2085
- });
2101
+ });
2102
+ if (deduped) {
2103
+ logger.debug(`[TRIGGER] Webhook deduped: ${data.webhookEventId}`);
2086
2104
  }
2087
2105
  }
2088
- async function handleSupervisorTrigger(data, client, serverUrl, authToken) {
2106
+ function handleSupervisorTrigger(data, client, serverUrl, authToken, scheduler) {
2089
2107
  logger.debug(`[TRIGGER] Supervisor: run ${data.runId} in ${data.repoPath}`);
2090
- client.emitSupervisorRunStatus({
2091
- runId: data.runId,
2092
- projectId: data.projectId,
2093
- status: "running"
2094
- });
2095
- try {
2096
- const promptFile = await writePromptFile("supervisor", buildSupervisorPrompt(data));
2097
- const result = await spawnSession({
2098
- directory: data.repoPath,
2099
- approvedNewDirectoryCreation: false,
2100
- automationContext: {
2101
- kind: "supervisor",
2102
- trigger: data.trigger,
2103
- projectId: data.projectId,
2104
- runId: data.runId
2105
- },
2106
- environmentVariables: {
2107
- HAPPY_INITIAL_PROMPT_FILE: promptFile,
2108
- HAPPY_SUPERVISOR_RUN_ID: data.runId,
2109
- HAPPY_SUPERVISOR_PROJECT_ID: data.projectId,
2110
- HAPPY_SUPERVISOR_SERVER_URL: serverUrl,
2111
- HAPPY_SUPERVISOR_AUTH_TOKEN: authToken
2112
- }
2113
- });
2114
- if (result.type !== "success") {
2115
- client.emitSupervisorRunStatus({
2116
- runId: data.runId,
2117
- projectId: data.projectId,
2118
- status: "failed",
2119
- errorMessage: result.type === "error" ? result.errorMessage : "Directory creation not approved"
2108
+ const { deduped } = scheduler.enqueue({
2109
+ kind: "supervisor",
2110
+ dedupeKey: `supervisor:${data.runId}`,
2111
+ priority: "background",
2112
+ run: async (jobId) => {
2113
+ client.emitSupervisorRunStatus({ runId: data.runId, projectId: data.projectId, status: "running" });
2114
+ const promptFile = await writePromptFile("supervisor", buildSupervisorPrompt(data));
2115
+ const result = await spawnSession({
2116
+ directory: data.repoPath,
2117
+ approvedNewDirectoryCreation: false,
2118
+ automationContext: { kind: "supervisor", trigger: data.trigger, projectId: data.projectId, runId: data.runId },
2119
+ environmentVariables: {
2120
+ HAPPY_INITIAL_PROMPT_FILE: promptFile,
2121
+ HAPPY_SUPERVISOR_RUN_ID: data.runId,
2122
+ HAPPY_SUPERVISOR_PROJECT_ID: data.projectId,
2123
+ HAPPY_SUPERVISOR_SERVER_URL: serverUrl,
2124
+ HAPPY_SUPERVISOR_AUTH_TOKEN: authToken
2125
+ }
2120
2126
  });
2127
+ if (result.type !== "success") {
2128
+ const msg = result.type === "error" ? result.errorMessage : "Directory creation not approved";
2129
+ client.emitSupervisorRunStatus({ runId: data.runId, projectId: data.projectId, status: "failed", errorMessage: msg });
2130
+ throw new Error(msg);
2131
+ }
2132
+ const tracked = (await Promise.resolve().then(function () { return trackedSessions; })).getTrackedSession(result.pid);
2133
+ if (tracked?.childProcess) {
2134
+ tracked.childProcess.on("exit", (code) => {
2135
+ const status = code === 0 ? "completed" : "failed";
2136
+ if (code === 0) scheduler.markCompleted(jobId);
2137
+ else scheduler.markFailed(jobId, `exit code ${code}`);
2138
+ client.emitSupervisorRunStatus({ runId: data.runId, projectId: data.projectId, status });
2139
+ });
2140
+ }
2141
+ return { pid: result.pid };
2121
2142
  }
2122
- } catch (error) {
2123
- const msg = error instanceof Error ? error.message : String(error);
2124
- logger.debug(`[TRIGGER] Supervisor error: ${msg}`);
2125
- client.emitSupervisorRunStatus({
2126
- runId: data.runId,
2127
- projectId: data.projectId,
2128
- status: "failed",
2129
- errorMessage: msg
2130
- });
2143
+ });
2144
+ if (deduped) {
2145
+ logger.debug(`[TRIGGER] Supervisor deduped: ${data.runId}`);
2131
2146
  }
2132
2147
  }
2133
- async function handleTaskTrigger(data, serverUrl, _authToken) {
2148
+ function handleTaskTrigger(data, serverUrl, _authToken, scheduler) {
2134
2149
  logger.debug(`[TRIGGER] Task: ${data.taskId} in ${data.directory}`);
2135
- try {
2136
- const promptFile = await writePromptFile("task", data.prompt);
2137
- const skillEnv = {};
2138
- if (data.skillContents?.length) {
2139
- skillEnv.HAPPY_TASK_SKILL_COUNT = String(data.skillContents.length);
2140
- for (let i = 0; i < data.skillContents.length; i++) {
2141
- skillEnv[`HAPPY_TASK_SKILL_${i}_NAME`] = data.skillContents[i].name;
2142
- skillEnv[`HAPPY_TASK_SKILL_${i}_CONTENT`] = data.skillContents[i].content;
2150
+ const { deduped } = scheduler.enqueue({
2151
+ kind: "task",
2152
+ dedupeKey: `task:${data.taskId}`,
2153
+ priority: mapTaskPriority(data.priority),
2154
+ run: async (jobId) => {
2155
+ const promptFile = await writePromptFile("task", data.prompt);
2156
+ const skillEnv = {};
2157
+ if (data.skillContents?.length) {
2158
+ skillEnv.HAPPY_TASK_SKILL_COUNT = String(data.skillContents.length);
2159
+ for (let i = 0; i < data.skillContents.length; i++) {
2160
+ skillEnv[`HAPPY_TASK_SKILL_${i}_NAME`] = data.skillContents[i].name;
2161
+ skillEnv[`HAPPY_TASK_SKILL_${i}_CONTENT`] = data.skillContents[i].content;
2162
+ }
2143
2163
  }
2144
- }
2145
- const result = await spawnSession({
2146
- directory: data.directory,
2147
- approvedNewDirectoryCreation: true,
2148
- // Tasks always auto-approve directory
2149
- automationContext: {
2150
- kind: "task",
2151
- trigger: "task-dispatch",
2152
- projectId: data.projectId
2153
- },
2154
- environmentVariables: {
2155
- HAPPY_INITIAL_PROMPT_FILE: promptFile,
2156
- HAPPY_TASK_ID: data.taskId,
2157
- HAPPY_TASK_PRIORITY: data.priority,
2158
- HAPPY_TASK_SERVER_URL: serverUrl,
2159
- HAPPY_TASK_RESULT_TOKEN: data.resultToken ?? "",
2160
- HAPPY_TASK_REPORT_URL: `${serverUrl}/v1/tasks/${data.taskId}/result`,
2161
- ...skillEnv
2164
+ const result = await spawnSession({
2165
+ directory: data.directory,
2166
+ approvedNewDirectoryCreation: true,
2167
+ automationContext: { kind: "task", trigger: "task-dispatch", projectId: data.projectId },
2168
+ environmentVariables: {
2169
+ HAPPY_INITIAL_PROMPT_FILE: promptFile,
2170
+ HAPPY_TASK_ID: data.taskId,
2171
+ HAPPY_TASK_PRIORITY: data.priority,
2172
+ HAPPY_TASK_SERVER_URL: serverUrl,
2173
+ HAPPY_TASK_RESULT_TOKEN: data.resultToken ?? "",
2174
+ HAPPY_TASK_REPORT_URL: `${serverUrl}/v1/tasks/${data.taskId}/result`,
2175
+ ...skillEnv
2176
+ }
2177
+ });
2178
+ if (result.type !== "success") {
2179
+ throw new Error(result.type === "error" ? result.errorMessage : "Directory creation not approved");
2162
2180
  }
2163
- });
2164
- if (result.type !== "success") {
2165
- logger.debug(`[TRIGGER] Task spawn failed: ${result.type === "error" ? result.errorMessage : "needs approval"}`);
2181
+ const tracked = (await Promise.resolve().then(function () { return trackedSessions; })).getTrackedSession(result.pid);
2182
+ if (tracked?.childProcess) {
2183
+ tracked.childProcess.on("exit", (code) => {
2184
+ if (code === 0) scheduler.markCompleted(jobId);
2185
+ else scheduler.markFailed(jobId, `exit code ${code}`);
2186
+ });
2187
+ }
2188
+ return { pid: result.pid };
2166
2189
  }
2167
- } catch (error) {
2168
- logger.debug(`[TRIGGER] Task error: ${error instanceof Error ? error.message : String(error)}`);
2190
+ });
2191
+ if (deduped) {
2192
+ logger.debug(`[TRIGGER] Task deduped: ${data.taskId}`);
2169
2193
  }
2170
2194
  }
2171
2195
 
@@ -2186,6 +2210,9 @@ class MachineClient {
2186
2210
  automationEnabled = false;
2187
2211
  automationServerUrl = "";
2188
2212
  automationAuthToken = "";
2213
+ scheduler = null;
2214
+ loopCoordinator = null;
2215
+ auditStore = null;
2189
2216
  constructor(opts) {
2190
2217
  this.token = opts.token;
2191
2218
  this.machine = opts.machine;
@@ -2227,6 +2254,34 @@ class MachineClient {
2227
2254
  return { sessions };
2228
2255
  });
2229
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
+ }
2230
2285
  // -----------------------------------------------------------------------
2231
2286
  // Connection
2232
2287
  // -----------------------------------------------------------------------
@@ -2408,37 +2463,49 @@ class MachineClient {
2408
2463
  * Enable automation handling — agent will process webhook, supervisor,
2409
2464
  * and task triggers from the server by spawning Happy CLI sessions.
2410
2465
  */
2411
- enableAutomation(serverUrl, authToken) {
2466
+ enableAutomation(serverUrl, authToken, scheduler, loopCoordinator, auditStore) {
2412
2467
  this.automationEnabled = true;
2413
2468
  this.automationServerUrl = serverUrl;
2414
2469
  this.automationAuthToken = authToken;
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
+ }
2415
2479
  logger.debug("[MACHINE] Automation enabled");
2416
2480
  }
2417
2481
  /** Internal dispatch for ephemeral events that need automation handling. */
2418
2482
  handleAutomationEvent(event) {
2419
- if (!this.automationEnabled) return;
2483
+ if (!this.automationEnabled || !this.scheduler) return;
2420
2484
  switch (event.type) {
2421
2485
  case "webhook-trigger":
2422
- void handleWebhookTrigger(
2486
+ handleWebhookTrigger(
2423
2487
  event,
2424
2488
  this,
2425
2489
  this.automationServerUrl,
2426
- this.automationAuthToken
2490
+ this.automationAuthToken,
2491
+ this.scheduler
2427
2492
  );
2428
2493
  break;
2429
2494
  case "supervisor-trigger":
2430
- void handleSupervisorTrigger(
2495
+ handleSupervisorTrigger(
2431
2496
  event,
2432
2497
  this,
2433
2498
  this.automationServerUrl,
2434
- this.automationAuthToken
2499
+ this.automationAuthToken,
2500
+ this.scheduler
2435
2501
  );
2436
2502
  break;
2437
2503
  case "task-trigger":
2438
- void handleTaskTrigger(
2504
+ handleTaskTrigger(
2439
2505
  event,
2440
2506
  this.automationServerUrl,
2441
- this.automationAuthToken
2507
+ this.automationAuthToken,
2508
+ this.scheduler
2442
2509
  );
2443
2510
  break;
2444
2511
  }
@@ -2518,6 +2585,544 @@ function tailscaleChanged(prev, next) {
2518
2585
  return prev.status !== next.status || prev.ipv4 !== next.ipv4 || prev.ipv6 !== next.ipv6 || prev.hostname !== next.hostname || JSON.stringify(prev.serves) !== JSON.stringify(next.serves);
2519
2586
  }
2520
2587
 
2588
+ const PRIORITY_ORDER = {
2589
+ urgent: 0,
2590
+ user: 1,
2591
+ background: 2
2592
+ };
2593
+ const ACTIVE_STATUSES = /* @__PURE__ */ new Set(["queued", "dispatching", "running"]);
2594
+ class AutomationScheduler {
2595
+ maxConcurrentJobs;
2596
+ retryDelayMs;
2597
+ defaultMaxAttempts;
2598
+ maxRecentCompletions;
2599
+ onAudit;
2600
+ /** Active jobs indexed by id. */
2601
+ jobs = /* @__PURE__ */ new Map();
2602
+ /** dedupeKey → jobId for fast dedup lookups. */
2603
+ dedupeIndex = /* @__PURE__ */ new Map();
2604
+ /** Ring buffer for completed/failed jobs. */
2605
+ recentCompletions = [];
2606
+ pumpTimer = null;
2607
+ pumping = false;
2608
+ constructor(options) {
2609
+ this.maxConcurrentJobs = options?.maxConcurrentJobs ?? 2;
2610
+ this.retryDelayMs = options?.retryDelayMs ?? 5e3;
2611
+ this.defaultMaxAttempts = options?.maxAttempts ?? 3;
2612
+ this.maxRecentCompletions = options?.maxRecentCompletions ?? 50;
2613
+ this.onAudit = options?.onAudit ?? null;
2614
+ this.pumpTimer = setInterval(() => this.pump(), 1e3);
2615
+ }
2616
+ // -----------------------------------------------------------------------
2617
+ // Public API
2618
+ // -----------------------------------------------------------------------
2619
+ enqueue(opts) {
2620
+ const existingId = this.dedupeIndex.get(opts.dedupeKey);
2621
+ if (existingId) {
2622
+ const existing = this.jobs.get(existingId);
2623
+ if (existing && ACTIVE_STATUSES.has(existing.status)) {
2624
+ logger.debug(`[SCHEDULER] Deduped: ${opts.dedupeKey} (job ${existingId} is ${existing.status})`);
2625
+ return { job: existing, deduped: true };
2626
+ }
2627
+ }
2628
+ const job = {
2629
+ id: randomUUID(),
2630
+ kind: opts.kind,
2631
+ dedupeKey: opts.dedupeKey,
2632
+ priority: opts.priority,
2633
+ status: "queued",
2634
+ attempt: 0,
2635
+ maxAttempts: this.defaultMaxAttempts,
2636
+ createdAt: Date.now(),
2637
+ updatedAt: Date.now(),
2638
+ nextRunAt: Date.now(),
2639
+ run: opts.run
2640
+ };
2641
+ this.jobs.set(job.id, job);
2642
+ this.dedupeIndex.set(job.dedupeKey, job.id);
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}` });
2645
+ this.pump();
2646
+ return { job, deduped: false };
2647
+ }
2648
+ markCompleted(jobId) {
2649
+ const job = this.jobs.get(jobId);
2650
+ if (!job || job.status === "completed" || job.status === "failed") return;
2651
+ job.status = "completed";
2652
+ job.updatedAt = Date.now();
2653
+ logger.debug(`[SCHEDULER] Completed: ${job.kind} ${job.dedupeKey} id=${jobId}`);
2654
+ this.onAudit?.({ kind: "job_completed", jobId, dedupeKey: job.dedupeKey });
2655
+ this.finalize(job);
2656
+ }
2657
+ markFailed(jobId, error) {
2658
+ const job = this.jobs.get(jobId);
2659
+ if (!job || job.status === "completed" || job.status === "failed") return;
2660
+ job.errorMessage = error;
2661
+ job.updatedAt = Date.now();
2662
+ if (job.attempt < job.maxAttempts) {
2663
+ job.status = "queued";
2664
+ job.nextRunAt = Date.now() + job.attempt * this.retryDelayMs;
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}` });
2667
+ this.pump();
2668
+ return;
2669
+ }
2670
+ job.status = "failed";
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 });
2673
+ this.finalize(job);
2674
+ }
2675
+ getStatus() {
2676
+ let queueLength = 0;
2677
+ let runningCount = 0;
2678
+ for (const job of this.jobs.values()) {
2679
+ if (job.status === "queued") queueLength++;
2680
+ else if (job.status === "dispatching" || job.status === "running") runningCount++;
2681
+ }
2682
+ return {
2683
+ queueLength,
2684
+ runningCount,
2685
+ recentCompletions: [...this.recentCompletions]
2686
+ };
2687
+ }
2688
+ shutdown() {
2689
+ if (this.pumpTimer) {
2690
+ clearInterval(this.pumpTimer);
2691
+ this.pumpTimer = null;
2692
+ }
2693
+ }
2694
+ // -----------------------------------------------------------------------
2695
+ // Internal
2696
+ // -----------------------------------------------------------------------
2697
+ pump() {
2698
+ if (this.pumping) return;
2699
+ this.pumping = true;
2700
+ try {
2701
+ const now = Date.now();
2702
+ let runningCount = 0;
2703
+ for (const job of this.jobs.values()) {
2704
+ if (job.status === "dispatching" || job.status === "running") runningCount++;
2705
+ }
2706
+ if (runningCount >= this.maxConcurrentJobs) return;
2707
+ const ready = [];
2708
+ for (const job of this.jobs.values()) {
2709
+ if (job.status === "queued" && job.nextRunAt <= now) {
2710
+ ready.push(job);
2711
+ }
2712
+ }
2713
+ ready.sort((a, b) => {
2714
+ const pDiff = PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority];
2715
+ if (pDiff !== 0) return pDiff;
2716
+ return a.createdAt - b.createdAt;
2717
+ });
2718
+ const slotsAvailable = this.maxConcurrentJobs - runningCount;
2719
+ const toDispatch = ready.slice(0, slotsAvailable);
2720
+ for (const job of toDispatch) {
2721
+ this.dispatch(job);
2722
+ }
2723
+ } finally {
2724
+ this.pumping = false;
2725
+ }
2726
+ }
2727
+ dispatch(job) {
2728
+ job.status = "dispatching";
2729
+ job.attempt++;
2730
+ job.updatedAt = Date.now();
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}` });
2733
+ job.run(job.id).then(({ pid }) => {
2734
+ if (job.status === "dispatching") {
2735
+ job.status = "running";
2736
+ job.pid = pid;
2737
+ job.updatedAt = Date.now();
2738
+ logger.debug(`[SCHEDULER] Running: ${job.dedupeKey} pid=${pid}`);
2739
+ }
2740
+ }).catch((error) => {
2741
+ const msg = error instanceof Error ? error.message : String(error);
2742
+ logger.debug(`[SCHEDULER] Dispatch failed: ${job.dedupeKey}: ${msg}`);
2743
+ if (job.status === "dispatching") {
2744
+ this.markFailed(job.id, msg);
2745
+ }
2746
+ });
2747
+ }
2748
+ finalize(job) {
2749
+ this.jobs.delete(job.id);
2750
+ if (this.dedupeIndex.get(job.dedupeKey) === job.id) {
2751
+ this.dedupeIndex.delete(job.dedupeKey);
2752
+ }
2753
+ this.recentCompletions.push({
2754
+ id: job.id,
2755
+ kind: job.kind,
2756
+ dedupeKey: job.dedupeKey,
2757
+ status: job.status,
2758
+ completedAt: job.updatedAt,
2759
+ errorMessage: job.errorMessage
2760
+ });
2761
+ while (this.recentCompletions.length > this.maxRecentCompletions) {
2762
+ this.recentCompletions.shift();
2763
+ }
2764
+ this.pump();
2765
+ }
2766
+ }
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
+
2521
3126
  function pidFilePath(homeDir) {
2522
3127
  return join(homeDir, "agent-daemon.pid");
2523
3128
  }
@@ -2585,8 +3190,16 @@ async function startDaemon(options) {
2585
3190
  agentVersion: version,
2586
3191
  workingDirectory: workDir
2587
3192
  });
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);
2588
3200
  client.setTailscaleInfo(fullTailscale);
2589
- client.enableAutomation(config.serverUrl, creds.token);
3201
+ client.enableAutomation(config.serverUrl, creds.token, scheduler, loopCoordinator, auditStore);
3202
+ loopCoordinator.start();
2590
3203
  client.connect();
2591
3204
  writePidFile(config.homeDir, process.pid);
2592
3205
  console.log(`Daemon started (PID ${process.pid})`);
@@ -2597,6 +3210,8 @@ async function startDaemon(options) {
2597
3210
  logger.debug(`[DAEMON] Received ${signal}, shutting down...`);
2598
3211
  console.log(`
2599
3212
  Received ${signal}, shutting down...`);
3213
+ loopCoordinator.shutdown();
3214
+ scheduler.shutdown();
2600
3215
  client.shutdown();
2601
3216
  removePidFile(config.homeDir);
2602
3217
  process.exit(0);