heyhank 0.1.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.
Files changed (199) hide show
  1. package/README.md +40 -0
  2. package/bin/cli.ts +168 -0
  3. package/bin/ctl.ts +528 -0
  4. package/bin/generate-token.ts +28 -0
  5. package/dist/apple-touch-icon.png +0 -0
  6. package/dist/assets/AgentsPage-BPhirnCe.js +7 -0
  7. package/dist/assets/AssistantPage-DJ-cMQfb.js +1 -0
  8. package/dist/assets/CronManager-DDbz-yiT.js +1 -0
  9. package/dist/assets/HelpPage-DMfkzERp.js +1 -0
  10. package/dist/assets/IntegrationsPage-CrOitCmJ.js +1 -0
  11. package/dist/assets/MediaPage-CE5rdvkC.js +1 -0
  12. package/dist/assets/PlatformDashboard-Do6F0O2p.js +1 -0
  13. package/dist/assets/Playground-Fc5cdc5p.js +109 -0
  14. package/dist/assets/ProcessPanel-CslEiZkI.js +2 -0
  15. package/dist/assets/PromptsPage-D2EhsdNO.js +4 -0
  16. package/dist/assets/RunsPage-C5BZF5Rx.js +1 -0
  17. package/dist/assets/SandboxManager-a1AVI5q2.js +8 -0
  18. package/dist/assets/SettingsPage-DirhjQrJ.js +51 -0
  19. package/dist/assets/SocialMediaPage-DBuM28vD.js +1 -0
  20. package/dist/assets/TailscalePage-CHiFhZXF.js +1 -0
  21. package/dist/assets/TelephonyPage-x0VV0fOo.js +1 -0
  22. package/dist/assets/TerminalPage-Drwyrnfd.js +1 -0
  23. package/dist/assets/gemini-audio-t-TSU-To.js +17 -0
  24. package/dist/assets/gemini-live-client-C7rqAW7G.js +166 -0
  25. package/dist/assets/index-C8M_PUmX.css +32 -0
  26. package/dist/assets/index-CEqZnThB.js +204 -0
  27. package/dist/assets/sw-register-LSSpj6RU.js +1 -0
  28. package/dist/assets/time-ago-B6r_l9u1.js +1 -0
  29. package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
  30. package/dist/favicon-32-original.png +0 -0
  31. package/dist/favicon-32.png +0 -0
  32. package/dist/favicon.ico +0 -0
  33. package/dist/favicon.svg +8 -0
  34. package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
  35. package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
  36. package/dist/heyhank-mascot-poster.png +0 -0
  37. package/dist/heyhank-mascot.mp4 +0 -0
  38. package/dist/heyhank-mascot.webm +0 -0
  39. package/dist/icon-192-original.png +0 -0
  40. package/dist/icon-192.png +0 -0
  41. package/dist/icon-512-original.png +0 -0
  42. package/dist/icon-512.png +0 -0
  43. package/dist/index.html +21 -0
  44. package/dist/logo-192.png +0 -0
  45. package/dist/logo-512.png +0 -0
  46. package/dist/logo-codex.svg +14 -0
  47. package/dist/logo-docker.svg +4 -0
  48. package/dist/logo-original.png +0 -0
  49. package/dist/logo.png +0 -0
  50. package/dist/logo.svg +14 -0
  51. package/dist/manifest.json +24 -0
  52. package/dist/push-sw.js +34 -0
  53. package/dist/sw.js +1 -0
  54. package/dist/workbox-d2a0910a.js +1 -0
  55. package/package.json +109 -0
  56. package/server/agent-cron-migrator.ts +85 -0
  57. package/server/agent-executor.ts +357 -0
  58. package/server/agent-store.ts +185 -0
  59. package/server/agent-timeout.ts +107 -0
  60. package/server/agent-types.ts +122 -0
  61. package/server/ai-validation-settings.ts +37 -0
  62. package/server/ai-validator.ts +181 -0
  63. package/server/anthropic-provider-migration.ts +48 -0
  64. package/server/assistant-store.ts +272 -0
  65. package/server/auth-manager.ts +150 -0
  66. package/server/auto-approve.ts +153 -0
  67. package/server/auto-namer.ts +36 -0
  68. package/server/backend-adapter.ts +54 -0
  69. package/server/cache-headers.ts +61 -0
  70. package/server/calendar-service.ts +434 -0
  71. package/server/claude-adapter.ts +889 -0
  72. package/server/claude-container-auth.ts +30 -0
  73. package/server/claude-session-discovery.ts +157 -0
  74. package/server/claude-session-history.ts +410 -0
  75. package/server/cli-launcher.ts +1303 -0
  76. package/server/codex-adapter.ts +3027 -0
  77. package/server/codex-container-auth.ts +24 -0
  78. package/server/codex-home.ts +27 -0
  79. package/server/codex-ws-proxy.cjs +226 -0
  80. package/server/commands-discovery.ts +81 -0
  81. package/server/constants.ts +7 -0
  82. package/server/container-manager.ts +1053 -0
  83. package/server/cost-tracker.ts +222 -0
  84. package/server/cron-scheduler.ts +243 -0
  85. package/server/cron-store.ts +148 -0
  86. package/server/cron-types.ts +63 -0
  87. package/server/email-service.ts +354 -0
  88. package/server/env-manager.ts +161 -0
  89. package/server/event-bus-types.ts +75 -0
  90. package/server/event-bus.ts +124 -0
  91. package/server/execution-store.ts +170 -0
  92. package/server/federation/node-connection.ts +190 -0
  93. package/server/federation/node-manager.ts +366 -0
  94. package/server/federation/node-store.ts +86 -0
  95. package/server/federation/node-types.ts +121 -0
  96. package/server/fs-utils.ts +15 -0
  97. package/server/git-utils.ts +421 -0
  98. package/server/github-pr.ts +379 -0
  99. package/server/google-media.ts +342 -0
  100. package/server/image-pull-manager.ts +279 -0
  101. package/server/index.ts +491 -0
  102. package/server/internal-ai.ts +237 -0
  103. package/server/kill-switch.ts +99 -0
  104. package/server/llm-providers.ts +342 -0
  105. package/server/logger.ts +259 -0
  106. package/server/mcp-registry.ts +401 -0
  107. package/server/message-bus.ts +271 -0
  108. package/server/message-delivery.ts +128 -0
  109. package/server/metrics-collector.ts +350 -0
  110. package/server/metrics-types.ts +108 -0
  111. package/server/middleware/managed-auth.ts +195 -0
  112. package/server/novnc-proxy.ts +99 -0
  113. package/server/path-resolver.ts +186 -0
  114. package/server/paths.ts +13 -0
  115. package/server/pr-poller.ts +162 -0
  116. package/server/prompt-manager.ts +211 -0
  117. package/server/protocol/claude-upstream/README.md +19 -0
  118. package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
  119. package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
  120. package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
  121. package/server/protocol/codex-upstream/README.md +18 -0
  122. package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
  123. package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
  124. package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
  125. package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
  126. package/server/protocol-monitor.ts +50 -0
  127. package/server/provider-manager.ts +111 -0
  128. package/server/provider-registry.ts +393 -0
  129. package/server/push-notifications.ts +221 -0
  130. package/server/recorder.ts +374 -0
  131. package/server/recording-hub/compat-validator.ts +284 -0
  132. package/server/recording-hub/diagnostics.ts +299 -0
  133. package/server/recording-hub/hub-config.ts +19 -0
  134. package/server/recording-hub/hub-routes.ts +236 -0
  135. package/server/recording-hub/hub-store.ts +265 -0
  136. package/server/recording-hub/replay-adapter.ts +207 -0
  137. package/server/relay-client.ts +320 -0
  138. package/server/reminder-scheduler.ts +38 -0
  139. package/server/replay.ts +78 -0
  140. package/server/routes/agent-routes.ts +264 -0
  141. package/server/routes/assistant-routes.ts +90 -0
  142. package/server/routes/cron-routes.ts +103 -0
  143. package/server/routes/env-routes.ts +95 -0
  144. package/server/routes/federation-routes.ts +76 -0
  145. package/server/routes/fs-routes.ts +622 -0
  146. package/server/routes/git-routes.ts +97 -0
  147. package/server/routes/llm-routes.ts +166 -0
  148. package/server/routes/media-routes.ts +135 -0
  149. package/server/routes/metrics-routes.ts +13 -0
  150. package/server/routes/platform-routes.ts +1379 -0
  151. package/server/routes/prompt-routes.ts +67 -0
  152. package/server/routes/provider-routes.ts +109 -0
  153. package/server/routes/sandbox-routes.ts +127 -0
  154. package/server/routes/settings-routes.ts +285 -0
  155. package/server/routes/skills-routes.ts +100 -0
  156. package/server/routes/socialmedia-routes.ts +208 -0
  157. package/server/routes/system-routes.ts +228 -0
  158. package/server/routes/tailscale-routes.ts +22 -0
  159. package/server/routes/telephony-routes.ts +259 -0
  160. package/server/routes.ts +1379 -0
  161. package/server/sandbox-manager.ts +168 -0
  162. package/server/service.ts +718 -0
  163. package/server/session-creation-service.ts +457 -0
  164. package/server/session-git-info.ts +104 -0
  165. package/server/session-names.ts +67 -0
  166. package/server/session-orchestrator.ts +824 -0
  167. package/server/session-state-machine.ts +207 -0
  168. package/server/session-store.ts +146 -0
  169. package/server/session-types.ts +511 -0
  170. package/server/settings-manager.ts +149 -0
  171. package/server/shared-context.ts +157 -0
  172. package/server/socialmedia/adapter.ts +15 -0
  173. package/server/socialmedia/adapters/ayrshare-adapter.ts +169 -0
  174. package/server/socialmedia/adapters/buffer-adapter.ts +299 -0
  175. package/server/socialmedia/adapters/postiz-adapter.ts +298 -0
  176. package/server/socialmedia/manager.ts +227 -0
  177. package/server/socialmedia/store.ts +98 -0
  178. package/server/socialmedia/types.ts +89 -0
  179. package/server/tailscale-manager.ts +451 -0
  180. package/server/telephony/audio-bridge.ts +331 -0
  181. package/server/telephony/call-manager.ts +457 -0
  182. package/server/telephony/call-types.ts +108 -0
  183. package/server/telephony/telephony-store.ts +119 -0
  184. package/server/terminal-manager.ts +240 -0
  185. package/server/update-checker.ts +192 -0
  186. package/server/usage-limits.ts +225 -0
  187. package/server/web-push.d.ts +51 -0
  188. package/server/worktree-tracker.ts +84 -0
  189. package/server/ws-auth.ts +41 -0
  190. package/server/ws-bridge-browser-ingest.ts +72 -0
  191. package/server/ws-bridge-browser.ts +112 -0
  192. package/server/ws-bridge-cli-ingest.ts +81 -0
  193. package/server/ws-bridge-codex.ts +266 -0
  194. package/server/ws-bridge-controls.ts +20 -0
  195. package/server/ws-bridge-persist.ts +66 -0
  196. package/server/ws-bridge-publish.ts +79 -0
  197. package/server/ws-bridge-replay.ts +61 -0
  198. package/server/ws-bridge-types.ts +121 -0
  199. package/server/ws-bridge.ts +1240 -0
@@ -0,0 +1,357 @@
1
+ import { Cron } from "croner";
2
+ import { mkdtempSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import type { AgentConfig, AgentExecution } from "./agent-types.js";
6
+ import type { CliLauncher, SdkSessionInfo } from "./cli-launcher.js";
7
+ import type { WsBridge } from "./ws-bridge.js";
8
+ import * as agentStore from "./agent-store.js";
9
+ import * as envManager from "./env-manager.js";
10
+ import * as sessionNames from "./session-names.js";
11
+ import { ExecutionStore } from "./execution-store.js";
12
+ import { notifyAgentAlert } from "./push-notifications.js";
13
+
14
+ /** Max consecutive failures before auto-disabling an agent */
15
+ const MAX_CONSECUTIVE_FAILURES = 5;
16
+ /** Max time to wait for CLI to connect (ms) */
17
+ const CLI_CONNECT_TIMEOUT_MS = 30_000;
18
+ /** Poll interval when waiting for CLI connection */
19
+ const CLI_CONNECT_POLL_MS = 500;
20
+
21
+ export interface ExecuteAgentOptions {
22
+ force?: boolean;
23
+ triggerType?: "manual" | "webhook" | "schedule";
24
+ additionalEnv?: Record<string, string>;
25
+ systemPrompt?: string;
26
+ }
27
+
28
+ export class AgentExecutor {
29
+ private timers = new Map<string, Cron>();
30
+ private launcher: CliLauncher;
31
+ private wsBridge: WsBridge;
32
+ /** In-memory execution history (last N per agent) */
33
+ private executions = new Map<string, AgentExecution[]>();
34
+ private static readonly MAX_EXECUTIONS_PER_AGENT = 50;
35
+ /** Persistent execution store (JSONL on disk) */
36
+ private executionStore = new ExecutionStore();
37
+
38
+ constructor(launcher: CliLauncher, wsBridge: WsBridge) {
39
+ this.launcher = launcher;
40
+ this.wsBridge = wsBridge;
41
+ }
42
+
43
+ /** Start all enabled agents with schedule triggers from disk. Called once at server startup. */
44
+ startAll(): void {
45
+ const agents = agentStore.listAgents();
46
+ let started = 0;
47
+ for (const agent of agents) {
48
+ if (agent.enabled && agent.triggers?.schedule?.enabled) {
49
+ this.scheduleAgent(agent);
50
+ started++;
51
+ }
52
+ }
53
+ if (started > 0) {
54
+ console.log(`[agent-executor] Started ${started} scheduled agent(s)`);
55
+ }
56
+ }
57
+
58
+ /** Schedule (or reschedule) an agent's cron trigger. */
59
+ scheduleAgent(agent: AgentConfig): void {
60
+ this.stopAgent(agent.id);
61
+
62
+ const schedule = agent.triggers?.schedule;
63
+ if (!agent.enabled || !schedule?.enabled || !schedule.expression) return;
64
+
65
+ try {
66
+ if (schedule.recurring) {
67
+ const cronTask = new Cron(schedule.expression, {}, () => {
68
+ this.executeAgent(agent.id, undefined, { triggerType: "schedule" }).catch((err) => {
69
+ console.error(`[agent-executor] Unhandled error in agent "${agent.name}":`, err);
70
+ });
71
+ });
72
+ this.timers.set(agent.id, cronTask);
73
+ console.log(`[agent-executor] Scheduled "${agent.name}" with cron "${schedule.expression}"`);
74
+ } else {
75
+ // One-shot: schedule for the specified datetime
76
+ const targetTime = new Date(schedule.expression);
77
+ if (targetTime.getTime() > Date.now()) {
78
+ const cronTask = new Cron(targetTime, () => {
79
+ this.executeAgent(agent.id, undefined, { triggerType: "schedule" })
80
+ .then(() => {
81
+ // Auto-disable schedule after one-shot execution
82
+ const current = agentStore.getAgent(agent.id);
83
+ if (current?.triggers?.schedule) {
84
+ agentStore.updateAgent(agent.id, {
85
+ triggers: {
86
+ ...current.triggers,
87
+ schedule: { ...current.triggers.schedule, enabled: false },
88
+ },
89
+ });
90
+ }
91
+ this.timers.delete(agent.id);
92
+ })
93
+ .catch((err) => {
94
+ console.error(`[agent-executor] Unhandled error in one-shot agent "${agent.name}":`, err);
95
+ });
96
+ });
97
+ this.timers.set(agent.id, cronTask);
98
+ console.log(`[agent-executor] Scheduled one-shot "${agent.name}" at ${targetTime.toISOString()}`);
99
+ } else {
100
+ console.log(`[agent-executor] Skipping one-shot "${agent.name}" — target time is in the past`);
101
+ }
102
+ }
103
+ } catch (err) {
104
+ console.error(`[agent-executor] Failed to schedule "${agent.name}":`, err);
105
+ }
106
+ }
107
+
108
+ /** Stop an agent's cron timer. */
109
+ stopAgent(agentId: string): void {
110
+ const timer = this.timers.get(agentId);
111
+ if (timer) {
112
+ timer.stop();
113
+ this.timers.delete(agentId);
114
+ }
115
+ }
116
+
117
+ /** Execute an agent: create a session, configure MCP, send the prompt, track the result. */
118
+ async executeAgent(
119
+ agentId: string,
120
+ input?: string,
121
+ opts?: ExecuteAgentOptions,
122
+ ): Promise<SdkSessionInfo | undefined> {
123
+ const agent = agentStore.getAgent(agentId);
124
+ if (!agent) return;
125
+ if (!agent.enabled && !opts?.force) return;
126
+
127
+ // Overlap prevention: skip if previous execution is still running (unless forced)
128
+ if (!opts?.force && agent.lastSessionId && this.launcher.isAlive(agent.lastSessionId)) {
129
+ console.log(`[agent-executor] Skipping "${agent.name}" — previous execution still running (${agent.lastSessionId})`);
130
+ return;
131
+ }
132
+
133
+ const triggerType = opts?.triggerType || "manual";
134
+ console.log(`[agent-executor] Executing agent "${agent.name}" (${agentId}) via ${triggerType}`);
135
+
136
+ const execution: AgentExecution = {
137
+ sessionId: "",
138
+ agentId,
139
+ triggerType,
140
+ startedAt: Date.now(),
141
+ };
142
+
143
+ try {
144
+ // Resolve environment variables
145
+ let envVars: Record<string, string> | undefined;
146
+ if (agent.envSlug) {
147
+ const env = envManager.getEnv(agent.envSlug);
148
+ if (env) envVars = { ...env.variables };
149
+ }
150
+ if (agent.env) {
151
+ envVars = { ...envVars, ...agent.env };
152
+ }
153
+ if (opts?.additionalEnv) {
154
+ envVars = { ...envVars, ...opts.additionalEnv };
155
+ }
156
+
157
+ // Resolve working directory
158
+ let cwd = agent.cwd;
159
+ if (cwd === "temp" || !cwd) {
160
+ cwd = mkdtempSync(join(tmpdir(), `heyhank-agent-${agent.id}-`));
161
+ }
162
+
163
+ // Launch the session via CliLauncher.
164
+ // Agents always run with full permissions — no interactive prompts.
165
+ // For Claude Code this sets --permission-mode bypassPermissions;
166
+ // for Codex, approvalPolicy is already hardcoded to "never".
167
+ if (agent.permissionMode && agent.permissionMode !== "bypassPermissions") {
168
+ console.warn(
169
+ `[agent-executor] Agent "${agent.name}" has permissionMode="${agent.permissionMode}" ` +
170
+ `but agent sessions always run with bypassPermissions`,
171
+ );
172
+ }
173
+ const sessionInfo = this.launcher.launch({
174
+ model: agent.model,
175
+ permissionMode: "bypassPermissions",
176
+ cwd,
177
+ env: envVars,
178
+ allowedTools: agent.allowedTools,
179
+ backendType: agent.backendType,
180
+ codexInternetAccess: agent.backendType === "codex" ? (agent.codexInternetAccess ?? true) : undefined,
181
+ codexSandbox: agent.backendType === "codex"
182
+ ? (agent.permissionMode === "bypassPermissions" ? "danger-full-access" : "workspace-write")
183
+ : undefined,
184
+ systemPrompt: agent.backendType === "codex" ? opts?.systemPrompt : undefined,
185
+ });
186
+
187
+ execution.sessionId = sessionInfo.sessionId;
188
+
189
+ // Tag the session as agent-originated
190
+ sessionInfo.agentId = agentId;
191
+ sessionInfo.agentName = agent.name;
192
+
193
+ // Set the session name
194
+ const runLabel = `🤖 ${agent.name}`;
195
+ sessionNames.setName(sessionInfo.sessionId, runLabel);
196
+
197
+ // Wait for CLI to connect
198
+ await this.waitForCLIConnection(sessionInfo.sessionId);
199
+
200
+ // Configure MCP servers if specified
201
+ if (agent.mcpServers && Object.keys(agent.mcpServers).length > 0) {
202
+ this.wsBridge.injectMcpSetServers(sessionInfo.sessionId, agent.mcpServers);
203
+ // MCP servers need time to initialize before the CLI processes the prompt.
204
+ // The CLI handles MCP setup asynchronously; this delay ensures servers are
205
+ // ready. A proper health-check mechanism would be better long-term, but the
206
+ // CLI doesn't expose an MCP-ready signal yet.
207
+ const MCP_INIT_DELAY_MS = 2000;
208
+ await new Promise((r) => setTimeout(r, MCP_INIT_DELAY_MS));
209
+ }
210
+
211
+ if (opts?.systemPrompt && agent.backendType === "claude") {
212
+ this.wsBridge.injectSystemPrompt(sessionInfo.sessionId, opts.systemPrompt);
213
+ }
214
+
215
+ // Resolve prompt: replace {{input}} placeholder with trigger input
216
+ let resolvedPrompt = agent.prompt;
217
+ if (input !== undefined) {
218
+ resolvedPrompt = resolvedPrompt.replace(/\{\{input\}\}/g, input);
219
+ } else {
220
+ resolvedPrompt = resolvedPrompt.replace(/\{\{input\}\}/g, "");
221
+ }
222
+
223
+ // Send the prompt with agent prefix for traceability
224
+ const fullPrompt = `[agent:${agent.id} ${agent.name}]\n\n${resolvedPrompt}`;
225
+ this.wsBridge.injectUserMessage(sessionInfo.sessionId, fullPrompt);
226
+
227
+ // Update agent tracking
228
+ agentStore.updateAgent(agentId, {
229
+ lastRunAt: Date.now(),
230
+ lastSessionId: sessionInfo.sessionId,
231
+ totalRuns: agent.totalRuns + 1,
232
+ consecutiveFailures: 0,
233
+ });
234
+
235
+ // Execution is now "running" — completedAt/success will be set
236
+ // when the CLI process exits via handleSessionExited().
237
+ this.addExecution(agentId, execution);
238
+
239
+ return sessionInfo;
240
+ } catch (err) {
241
+ console.error(`[agent-executor] Agent "${agent.name}" failed:`, err);
242
+ execution.error = err instanceof Error ? err.message : String(err);
243
+ execution.completedAt = Date.now();
244
+ this.addExecution(agentId, execution);
245
+
246
+ const failures = agent.consecutiveFailures + 1;
247
+ const updates: Partial<AgentConfig> = {
248
+ consecutiveFailures: failures,
249
+ lastRunAt: Date.now(),
250
+ };
251
+
252
+ // Auto-disable after too many failures
253
+ if (failures >= MAX_CONSECUTIVE_FAILURES) {
254
+ updates.enabled = false;
255
+ this.stopAgent(agentId);
256
+ console.warn(`[agent-executor] Agent "${agent.name}" disabled after ${failures} consecutive failures`);
257
+ }
258
+
259
+ agentStore.updateAgent(agentId, updates);
260
+ return undefined;
261
+ }
262
+ }
263
+
264
+ /** Manual trigger (run now regardless of schedule, bypasses enabled check). */
265
+ executeAgentManually(agentId: string, input?: string): void {
266
+ this.executeAgent(agentId, input, { force: true, triggerType: "manual" }).catch((err) => {
267
+ console.error(`[agent-executor] Manual execution of agent "${agentId}" failed:`, err);
268
+ });
269
+ }
270
+
271
+ /** Wait for CLI to be connected (poll up to timeout). */
272
+ private async waitForCLIConnection(sessionId: string): Promise<void> {
273
+ const start = Date.now();
274
+
275
+ while (Date.now() - start < CLI_CONNECT_TIMEOUT_MS) {
276
+ const info = this.launcher.getSession(sessionId);
277
+ if (info && (info.state === "connected" || info.state === "running")) {
278
+ return;
279
+ }
280
+ if (info?.state === "exited") {
281
+ throw new Error(`CLI process exited before connecting (exit code: ${info.exitCode})`);
282
+ }
283
+ await new Promise((r) => setTimeout(r, CLI_CONNECT_POLL_MS));
284
+ }
285
+
286
+ throw new Error(`CLI process did not connect within ${CLI_CONNECT_TIMEOUT_MS / 1000}s`);
287
+ }
288
+
289
+ /** Get next run time for an agent. */
290
+ getNextRunTime(agentId: string): Date | null {
291
+ const timer = this.timers.get(agentId);
292
+ if (!timer) return null;
293
+ return timer.nextRun() || null;
294
+ }
295
+
296
+ /** Get recent executions for an agent. */
297
+ getExecutions(agentId: string): AgentExecution[] {
298
+ return this.executions.get(agentId) || [];
299
+ }
300
+
301
+ private addExecution(agentId: string, execution: AgentExecution): void {
302
+ if (!this.executions.has(agentId)) {
303
+ this.executions.set(agentId, []);
304
+ }
305
+ const list = this.executions.get(agentId)!;
306
+ list.push(execution);
307
+ if (list.length > AgentExecutor.MAX_EXECUTIONS_PER_AGENT) {
308
+ list.splice(0, list.length - AgentExecutor.MAX_EXECUTIONS_PER_AGENT);
309
+ }
310
+ // Persist to disk
311
+ this.executionStore.append(execution);
312
+ }
313
+
314
+ /** Query executions across all agents (for Runs view). */
315
+ listAllExecutions(opts?: { agentId?: string; triggerType?: string; status?: "running" | "success" | "error"; limit?: number; offset?: number }) {
316
+ return this.executionStore.list(opts);
317
+ }
318
+
319
+ /** Handle session exit: mark the corresponding execution as completed. */
320
+ handleSessionExited(sessionId: string, exitCode: number | null): void {
321
+ for (const [, execs] of this.executions) {
322
+ const exec = execs.find((e) => e.sessionId === sessionId && !e.completedAt);
323
+ if (exec) {
324
+ exec.completedAt = Date.now();
325
+ exec.success = exitCode === 0 || exitCode === null;
326
+ if (exitCode && exitCode !== 0) {
327
+ exec.error = exec.error || `Process exited with code ${exitCode}`;
328
+ }
329
+ this.executionStore.update(sessionId, {
330
+ completedAt: exec.completedAt,
331
+ success: exec.success,
332
+ error: exec.error,
333
+ });
334
+
335
+ // Send push notification for agent completion
336
+ const agent = agentStore.getAgent(exec.agentId);
337
+ const agentName = agent?.name || exec.agentId;
338
+ if (exec.success) {
339
+ notifyAgentAlert(agentName, `Aufgabe erfolgreich abgeschlossen.`, "info").catch(() => {});
340
+ } else {
341
+ notifyAgentAlert(agentName, `Aufgabe fehlgeschlagen: ${exec.error || "Unbekannter Fehler"}`, "error").catch(() => {});
342
+ }
343
+
344
+ break;
345
+ }
346
+ }
347
+ }
348
+
349
+ /** Stop all timers (for graceful shutdown). */
350
+ destroy(): void {
351
+ for (const timer of this.timers.values()) {
352
+ timer.stop();
353
+ }
354
+ this.timers.clear();
355
+ this.executions.clear();
356
+ }
357
+ }
@@ -0,0 +1,185 @@
1
+ import {
2
+ mkdirSync,
3
+ readdirSync,
4
+ readFileSync,
5
+ writeFileSync,
6
+ unlinkSync,
7
+ existsSync,
8
+ } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { HEYHANK_HOME } from "./paths.js";
11
+ import { randomBytes } from "node:crypto";
12
+ import type { AgentConfig, AgentConfigCreateInput } from "./agent-types.js";
13
+
14
+ // ─── Paths ──────────────────────────────────────────────────────────────────
15
+
16
+ const AGENTS_DIR = join(HEYHANK_HOME, "agents");
17
+
18
+ function ensureDir(): void {
19
+ mkdirSync(AGENTS_DIR, { recursive: true });
20
+ }
21
+
22
+ function filePath(id: string): string {
23
+ return join(AGENTS_DIR, `${id}.json`);
24
+ }
25
+
26
+ // ─── Helpers ────────────────────────────────────────────────────────────────
27
+
28
+ function slugify(name: string): string {
29
+ return name
30
+ .toLowerCase()
31
+ .replace(/\s+/g, "-")
32
+ .replace(/[^a-z0-9-]/g, "")
33
+ .replace(/-+/g, "-")
34
+ .replace(/^-|-$/g, "");
35
+ }
36
+
37
+ function generateWebhookSecret(): string {
38
+ return randomBytes(24).toString("hex");
39
+ }
40
+
41
+ /**
42
+ * Strip the legacy `triggers.chat` block from agents loaded from disk.
43
+ * The Chat SDK was removed but agents saved with the old schema may still
44
+ * have chat platform credentials on disk. Stripping on load prevents
45
+ * leaking those secrets via the API.
46
+ */
47
+ function stripLegacyChatTrigger(agent: AgentConfig): AgentConfig {
48
+ if (!agent.triggers || !("chat" in agent.triggers)) return agent;
49
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
50
+ const { chat: _chat, ...rest } = agent.triggers as Record<string, unknown>;
51
+ return { ...agent, triggers: rest as AgentConfig["triggers"] };
52
+ }
53
+
54
+ // ─── CRUD ───────────────────────────────────────────────────────────────────
55
+
56
+ export function listAgents(): AgentConfig[] {
57
+ ensureDir();
58
+ try {
59
+ const files = readdirSync(AGENTS_DIR).filter((f) => f.endsWith(".json"));
60
+ const agents: AgentConfig[] = [];
61
+ for (const file of files) {
62
+ try {
63
+ const raw = readFileSync(join(AGENTS_DIR, file), "utf-8");
64
+ agents.push(stripLegacyChatTrigger(JSON.parse(raw)));
65
+ } catch {
66
+ // Skip corrupt files
67
+ }
68
+ }
69
+ agents.sort((a, b) => a.name.localeCompare(b.name));
70
+ return agents;
71
+ } catch {
72
+ return [];
73
+ }
74
+ }
75
+
76
+ export function getAgent(id: string): AgentConfig | null {
77
+ ensureDir();
78
+ try {
79
+ const raw = readFileSync(filePath(id), "utf-8");
80
+ return stripLegacyChatTrigger(JSON.parse(raw) as AgentConfig);
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+
86
+ export function createAgent(data: AgentConfigCreateInput): AgentConfig {
87
+ if (!data.name || !data.name.trim()) throw new Error("Agent name is required");
88
+ if (!data.prompt || !data.prompt.trim()) throw new Error("Agent prompt is required");
89
+
90
+ const id = slugify(data.name.trim());
91
+ if (!id) throw new Error("Agent name must contain alphanumeric characters");
92
+
93
+ ensureDir();
94
+ if (existsSync(filePath(id))) {
95
+ throw new Error(`An agent with a similar name already exists ("${id}")`);
96
+ }
97
+
98
+ // Auto-generate webhook secret if webhook trigger is enabled but has no secret
99
+ const triggers = data.triggers ? { ...data.triggers } : undefined;
100
+ if (triggers?.webhook && !triggers.webhook.secret) {
101
+ triggers.webhook = { ...triggers.webhook, secret: generateWebhookSecret() };
102
+ }
103
+
104
+ const now = Date.now();
105
+ const agent: AgentConfig = {
106
+ ...data,
107
+ triggers,
108
+ id,
109
+ name: data.name.trim(),
110
+ prompt: data.prompt.trim(),
111
+ description: data.description?.trim() || "",
112
+ cwd: data.cwd?.trim() || "",
113
+ createdAt: now,
114
+ updatedAt: now,
115
+ totalRuns: 0,
116
+ consecutiveFailures: 0,
117
+ };
118
+ writeFileSync(filePath(id), JSON.stringify(agent, null, 2), "utf-8");
119
+ return agent;
120
+ }
121
+
122
+ export function updateAgent(
123
+ id: string,
124
+ updates: Partial<AgentConfig>,
125
+ ): AgentConfig | null {
126
+ ensureDir();
127
+ const existing = getAgent(id);
128
+ if (!existing) return null;
129
+
130
+ const newName = updates.name?.trim() || existing.name;
131
+ const newId = slugify(newName);
132
+ if (!newId) throw new Error("Agent name must contain alphanumeric characters");
133
+
134
+ // If name changed, check for slug collision with a different agent
135
+ if (newId !== id && existsSync(filePath(newId))) {
136
+ throw new Error(`An agent with a similar name already exists ("${newId}")`);
137
+ }
138
+
139
+ const agent: AgentConfig = {
140
+ ...existing,
141
+ ...updates,
142
+ id: newId,
143
+ name: newName,
144
+ updatedAt: Date.now(),
145
+ // Preserve immutable fields
146
+ createdAt: existing.createdAt,
147
+ };
148
+
149
+ // If id changed, delete old file
150
+ if (newId !== id) {
151
+ try {
152
+ unlinkSync(filePath(id));
153
+ } catch {
154
+ /* ok */
155
+ }
156
+ }
157
+
158
+ writeFileSync(filePath(newId), JSON.stringify(agent, null, 2), "utf-8");
159
+ return agent;
160
+ }
161
+
162
+ export function deleteAgent(id: string): boolean {
163
+ ensureDir();
164
+ if (!existsSync(filePath(id))) return false;
165
+ try {
166
+ unlinkSync(filePath(id));
167
+ return true;
168
+ } catch {
169
+ return false;
170
+ }
171
+ }
172
+
173
+ /** Generate a new webhook secret for an agent */
174
+ export function regenerateWebhookSecret(id: string): AgentConfig | null {
175
+ const agent = getAgent(id);
176
+ if (!agent) return null;
177
+
178
+ const triggers = agent.triggers || {};
179
+ triggers.webhook = {
180
+ enabled: triggers.webhook?.enabled ?? false,
181
+ secret: generateWebhookSecret(),
182
+ };
183
+
184
+ return updateAgent(id, { triggers });
185
+ }
@@ -0,0 +1,107 @@
1
+ // ─── Agent Timeout Management ────────────────────────────────────────────────
2
+ // Monitors agent sessions and kills those that exceed time limits
3
+
4
+ import type { CliLauncher, SdkSessionInfo } from "./cli-launcher.js";
5
+ import type { WsBridge } from "./ws-bridge.js";
6
+ import { isKilled } from "./kill-switch.js";
7
+ import { notifyAgentAlert } from "./push-notifications.js";
8
+
9
+ // ─── Constants ───────────────────────────────────────────────────────────────
10
+
11
+ /** Default timeout per agent session (30 minutes) */
12
+ const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
13
+
14
+ /** Check interval (every 60 seconds) */
15
+ const CHECK_INTERVAL_MS = 60 * 1000;
16
+
17
+ /** Agent-specific timeouts (in ms) */
18
+ const AGENT_TIMEOUTS: Record<string, number> = {
19
+ "monitoring-agent": 5 * 60 * 1000, // 5 min (should be quick)
20
+ "personal-agent": 10 * 60 * 1000, // 10 min
21
+ "coding-agent": 60 * 60 * 1000, // 60 min (complex tasks)
22
+ "marketing-agent": 30 * 60 * 1000, // 30 min
23
+ "content-agent": 30 * 60 * 1000, // 30 min
24
+ "agent-max": 60 * 60 * 1000, // 60 min (meta-agent)
25
+ };
26
+
27
+ // ─── Timeout Manager ─────────────────────────────────────────────────────────
28
+
29
+ let intervalId: ReturnType<typeof setInterval> | null = null;
30
+
31
+ export function startTimeoutMonitor(
32
+ launcher: CliLauncher,
33
+ wsBridge: WsBridge,
34
+ ): void {
35
+ if (intervalId) return; // Already running
36
+
37
+ intervalId = setInterval(() => {
38
+ checkTimeouts(launcher, wsBridge);
39
+ }, CHECK_INTERVAL_MS);
40
+
41
+ // Don't keep process alive just for this timer
42
+ if (intervalId && typeof intervalId === "object" && "unref" in intervalId) {
43
+ intervalId.unref();
44
+ }
45
+
46
+ console.log("[agent-timeout] Timeout monitor started");
47
+ }
48
+
49
+ export function stopTimeoutMonitor(): void {
50
+ if (intervalId) {
51
+ clearInterval(intervalId);
52
+ intervalId = null;
53
+ }
54
+ }
55
+
56
+ function checkTimeouts(launcher: CliLauncher, wsBridge: WsBridge): void {
57
+ // If kill switch is active, kill everything
58
+ if (isKilled()) {
59
+ const sessions = launcher.listSessions();
60
+ for (const session of sessions) {
61
+ if (session.state === "running" || session.state === "connected") {
62
+ try {
63
+ launcher.kill(session.sessionId);
64
+ console.log(`[agent-timeout] Kill switch: killed session ${session.sessionId.slice(0, 8)}`);
65
+ } catch {
66
+ // ignore
67
+ }
68
+ }
69
+ }
70
+ return;
71
+ }
72
+
73
+ const now = Date.now();
74
+ const sessions = launcher.listSessions();
75
+
76
+ for (const session of sessions) {
77
+ if (session.state !== "running" && session.state !== "connected") continue;
78
+
79
+ const agentId = (session as SdkSessionInfo & { agentId?: string }).agentId;
80
+ if (!agentId) continue; // Only timeout agent sessions
81
+
82
+ const timeout = AGENT_TIMEOUTS[agentId] ?? DEFAULT_TIMEOUT_MS;
83
+ const elapsed = now - session.createdAt;
84
+
85
+ if (elapsed > timeout) {
86
+ console.warn(
87
+ `[agent-timeout] Session ${session.sessionId.slice(0, 8)} for agent "${agentId}" ` +
88
+ `exceeded timeout (${Math.round(elapsed / 60000)}m > ${Math.round(timeout / 60000)}m). Killing.`,
89
+ );
90
+ try {
91
+ launcher.kill(session.sessionId);
92
+ notifyAgentAlert(
93
+ agentId,
94
+ `Session timed out after ${Math.round(elapsed / 60000)} minutes`,
95
+ "warning",
96
+ ).catch(() => {});
97
+ } catch {
98
+ // ignore
99
+ }
100
+ }
101
+ }
102
+ }
103
+
104
+ /** Get timeout config for an agent (for API). */
105
+ export function getTimeoutConfig(): Record<string, number> {
106
+ return { ...AGENT_TIMEOUTS, _default: DEFAULT_TIMEOUT_MS };
107
+ }