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.
- package/README.md +40 -0
- package/bin/cli.ts +168 -0
- package/bin/ctl.ts +528 -0
- package/bin/generate-token.ts +28 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/AgentsPage-BPhirnCe.js +7 -0
- package/dist/assets/AssistantPage-DJ-cMQfb.js +1 -0
- package/dist/assets/CronManager-DDbz-yiT.js +1 -0
- package/dist/assets/HelpPage-DMfkzERp.js +1 -0
- package/dist/assets/IntegrationsPage-CrOitCmJ.js +1 -0
- package/dist/assets/MediaPage-CE5rdvkC.js +1 -0
- package/dist/assets/PlatformDashboard-Do6F0O2p.js +1 -0
- package/dist/assets/Playground-Fc5cdc5p.js +109 -0
- package/dist/assets/ProcessPanel-CslEiZkI.js +2 -0
- package/dist/assets/PromptsPage-D2EhsdNO.js +4 -0
- package/dist/assets/RunsPage-C5BZF5Rx.js +1 -0
- package/dist/assets/SandboxManager-a1AVI5q2.js +8 -0
- package/dist/assets/SettingsPage-DirhjQrJ.js +51 -0
- package/dist/assets/SocialMediaPage-DBuM28vD.js +1 -0
- package/dist/assets/TailscalePage-CHiFhZXF.js +1 -0
- package/dist/assets/TelephonyPage-x0VV0fOo.js +1 -0
- package/dist/assets/TerminalPage-Drwyrnfd.js +1 -0
- package/dist/assets/gemini-audio-t-TSU-To.js +17 -0
- package/dist/assets/gemini-live-client-C7rqAW7G.js +166 -0
- package/dist/assets/index-C8M_PUmX.css +32 -0
- package/dist/assets/index-CEqZnThB.js +204 -0
- package/dist/assets/sw-register-LSSpj6RU.js +1 -0
- package/dist/assets/time-ago-B6r_l9u1.js +1 -0
- package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
- package/dist/favicon-32-original.png +0 -0
- package/dist/favicon-32.png +0 -0
- package/dist/favicon.ico +0 -0
- package/dist/favicon.svg +8 -0
- package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
- package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
- package/dist/heyhank-mascot-poster.png +0 -0
- package/dist/heyhank-mascot.mp4 +0 -0
- package/dist/heyhank-mascot.webm +0 -0
- package/dist/icon-192-original.png +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512-original.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +21 -0
- package/dist/logo-192.png +0 -0
- package/dist/logo-512.png +0 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo-docker.svg +4 -0
- package/dist/logo-original.png +0 -0
- package/dist/logo.png +0 -0
- package/dist/logo.svg +14 -0
- package/dist/manifest.json +24 -0
- package/dist/push-sw.js +34 -0
- package/dist/sw.js +1 -0
- package/dist/workbox-d2a0910a.js +1 -0
- package/package.json +109 -0
- package/server/agent-cron-migrator.ts +85 -0
- package/server/agent-executor.ts +357 -0
- package/server/agent-store.ts +185 -0
- package/server/agent-timeout.ts +107 -0
- package/server/agent-types.ts +122 -0
- package/server/ai-validation-settings.ts +37 -0
- package/server/ai-validator.ts +181 -0
- package/server/anthropic-provider-migration.ts +48 -0
- package/server/assistant-store.ts +272 -0
- package/server/auth-manager.ts +150 -0
- package/server/auto-approve.ts +153 -0
- package/server/auto-namer.ts +36 -0
- package/server/backend-adapter.ts +54 -0
- package/server/cache-headers.ts +61 -0
- package/server/calendar-service.ts +434 -0
- package/server/claude-adapter.ts +889 -0
- package/server/claude-container-auth.ts +30 -0
- package/server/claude-session-discovery.ts +157 -0
- package/server/claude-session-history.ts +410 -0
- package/server/cli-launcher.ts +1303 -0
- package/server/codex-adapter.ts +3027 -0
- package/server/codex-container-auth.ts +24 -0
- package/server/codex-home.ts +27 -0
- package/server/codex-ws-proxy.cjs +226 -0
- package/server/commands-discovery.ts +81 -0
- package/server/constants.ts +7 -0
- package/server/container-manager.ts +1053 -0
- package/server/cost-tracker.ts +222 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.ts +148 -0
- package/server/cron-types.ts +63 -0
- package/server/email-service.ts +354 -0
- package/server/env-manager.ts +161 -0
- package/server/event-bus-types.ts +75 -0
- package/server/event-bus.ts +124 -0
- package/server/execution-store.ts +170 -0
- package/server/federation/node-connection.ts +190 -0
- package/server/federation/node-manager.ts +366 -0
- package/server/federation/node-store.ts +86 -0
- package/server/federation/node-types.ts +121 -0
- package/server/fs-utils.ts +15 -0
- package/server/git-utils.ts +421 -0
- package/server/github-pr.ts +379 -0
- package/server/google-media.ts +342 -0
- package/server/image-pull-manager.ts +279 -0
- package/server/index.ts +491 -0
- package/server/internal-ai.ts +237 -0
- package/server/kill-switch.ts +99 -0
- package/server/llm-providers.ts +342 -0
- package/server/logger.ts +259 -0
- package/server/mcp-registry.ts +401 -0
- package/server/message-bus.ts +271 -0
- package/server/message-delivery.ts +128 -0
- package/server/metrics-collector.ts +350 -0
- package/server/metrics-types.ts +108 -0
- package/server/middleware/managed-auth.ts +195 -0
- package/server/novnc-proxy.ts +99 -0
- package/server/path-resolver.ts +186 -0
- package/server/paths.ts +13 -0
- package/server/pr-poller.ts +162 -0
- package/server/prompt-manager.ts +211 -0
- package/server/protocol/claude-upstream/README.md +19 -0
- package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
- package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
- package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
- package/server/protocol/codex-upstream/README.md +18 -0
- package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
- package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
- package/server/protocol-monitor.ts +50 -0
- package/server/provider-manager.ts +111 -0
- package/server/provider-registry.ts +393 -0
- package/server/push-notifications.ts +221 -0
- package/server/recorder.ts +374 -0
- package/server/recording-hub/compat-validator.ts +284 -0
- package/server/recording-hub/diagnostics.ts +299 -0
- package/server/recording-hub/hub-config.ts +19 -0
- package/server/recording-hub/hub-routes.ts +236 -0
- package/server/recording-hub/hub-store.ts +265 -0
- package/server/recording-hub/replay-adapter.ts +207 -0
- package/server/relay-client.ts +320 -0
- package/server/reminder-scheduler.ts +38 -0
- package/server/replay.ts +78 -0
- package/server/routes/agent-routes.ts +264 -0
- package/server/routes/assistant-routes.ts +90 -0
- package/server/routes/cron-routes.ts +103 -0
- package/server/routes/env-routes.ts +95 -0
- package/server/routes/federation-routes.ts +76 -0
- package/server/routes/fs-routes.ts +622 -0
- package/server/routes/git-routes.ts +97 -0
- package/server/routes/llm-routes.ts +166 -0
- package/server/routes/media-routes.ts +135 -0
- package/server/routes/metrics-routes.ts +13 -0
- package/server/routes/platform-routes.ts +1379 -0
- package/server/routes/prompt-routes.ts +67 -0
- package/server/routes/provider-routes.ts +109 -0
- package/server/routes/sandbox-routes.ts +127 -0
- package/server/routes/settings-routes.ts +285 -0
- package/server/routes/skills-routes.ts +100 -0
- package/server/routes/socialmedia-routes.ts +208 -0
- package/server/routes/system-routes.ts +228 -0
- package/server/routes/tailscale-routes.ts +22 -0
- package/server/routes/telephony-routes.ts +259 -0
- package/server/routes.ts +1379 -0
- package/server/sandbox-manager.ts +168 -0
- package/server/service.ts +718 -0
- package/server/session-creation-service.ts +457 -0
- package/server/session-git-info.ts +104 -0
- package/server/session-names.ts +67 -0
- package/server/session-orchestrator.ts +824 -0
- package/server/session-state-machine.ts +207 -0
- package/server/session-store.ts +146 -0
- package/server/session-types.ts +511 -0
- package/server/settings-manager.ts +149 -0
- package/server/shared-context.ts +157 -0
- package/server/socialmedia/adapter.ts +15 -0
- package/server/socialmedia/adapters/ayrshare-adapter.ts +169 -0
- package/server/socialmedia/adapters/buffer-adapter.ts +299 -0
- package/server/socialmedia/adapters/postiz-adapter.ts +298 -0
- package/server/socialmedia/manager.ts +227 -0
- package/server/socialmedia/store.ts +98 -0
- package/server/socialmedia/types.ts +89 -0
- package/server/tailscale-manager.ts +451 -0
- package/server/telephony/audio-bridge.ts +331 -0
- package/server/telephony/call-manager.ts +457 -0
- package/server/telephony/call-types.ts +108 -0
- package/server/telephony/telephony-store.ts +119 -0
- package/server/terminal-manager.ts +240 -0
- package/server/update-checker.ts +192 -0
- package/server/usage-limits.ts +225 -0
- package/server/web-push.d.ts +51 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-auth.ts +41 -0
- package/server/ws-bridge-browser-ingest.ts +72 -0
- package/server/ws-bridge-browser.ts +112 -0
- package/server/ws-bridge-cli-ingest.ts +81 -0
- package/server/ws-bridge-codex.ts +266 -0
- package/server/ws-bridge-controls.ts +20 -0
- package/server/ws-bridge-persist.ts +66 -0
- package/server/ws-bridge-publish.ts +79 -0
- package/server/ws-bridge-replay.ts +61 -0
- package/server/ws-bridge-types.ts +121 -0
- 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
|
+
}
|