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,222 @@
|
|
|
1
|
+
// ─── Cost Tracker ────────────────────────────────────────────────────────────
|
|
2
|
+
// Ported from AgentManager/src/cost-tracker.ts — uses Bun's native SQLite
|
|
3
|
+
|
|
4
|
+
import { Database } from "bun:sqlite";
|
|
5
|
+
import { mkdirSync } from "node:fs";
|
|
6
|
+
import { join, dirname } from "node:path";
|
|
7
|
+
import { HEYHANK_HOME } from "./paths.js";
|
|
8
|
+
|
|
9
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
export interface CostRecord {
|
|
12
|
+
agentId: string;
|
|
13
|
+
agentName: string;
|
|
14
|
+
model: string;
|
|
15
|
+
tokensIn: number;
|
|
16
|
+
tokensOut: number;
|
|
17
|
+
estimatedCost: number;
|
|
18
|
+
createdAt: string;
|
|
19
|
+
closedAt: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface CostHistorySummary {
|
|
23
|
+
allTimeCost: number;
|
|
24
|
+
allTimeTokensIn: number;
|
|
25
|
+
allTimeTokensOut: number;
|
|
26
|
+
totalRecords: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
const DB_PATH = join(HEYHANK_HOME, "costs.db");
|
|
32
|
+
|
|
33
|
+
// ─── CostTracker ─────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
export class CostTracker {
|
|
36
|
+
private db: Database;
|
|
37
|
+
|
|
38
|
+
constructor() {
|
|
39
|
+
mkdirSync(dirname(DB_PATH), { recursive: true });
|
|
40
|
+
this.db = new Database(DB_PATH);
|
|
41
|
+
this.db.exec("PRAGMA journal_mode=WAL");
|
|
42
|
+
this.db.exec("PRAGMA synchronous=NORMAL");
|
|
43
|
+
this.initSchema();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private initSchema(): void {
|
|
47
|
+
this.db.exec(`
|
|
48
|
+
CREATE TABLE IF NOT EXISTS cost_records (
|
|
49
|
+
agent_id TEXT PRIMARY KEY,
|
|
50
|
+
agent_name TEXT NOT NULL,
|
|
51
|
+
model TEXT NOT NULL,
|
|
52
|
+
tokens_in INTEGER DEFAULT 0,
|
|
53
|
+
tokens_out INTEGER DEFAULT 0,
|
|
54
|
+
estimated_cost REAL DEFAULT 0,
|
|
55
|
+
created_at TEXT NOT NULL,
|
|
56
|
+
closed_at TEXT
|
|
57
|
+
);
|
|
58
|
+
CREATE TABLE IF NOT EXISTS settings (
|
|
59
|
+
key TEXT PRIMARY KEY,
|
|
60
|
+
value TEXT NOT NULL
|
|
61
|
+
);
|
|
62
|
+
`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Insert or update a cost record. */
|
|
66
|
+
upsert(record: CostRecord): void {
|
|
67
|
+
try {
|
|
68
|
+
const stmt = this.db.prepare(`
|
|
69
|
+
INSERT INTO cost_records (agent_id, agent_name, model, tokens_in, tokens_out, estimated_cost, created_at, closed_at)
|
|
70
|
+
VALUES ($agentId, $agentName, $model, $tokensIn, $tokensOut, $estimatedCost, $createdAt, $closedAt)
|
|
71
|
+
ON CONFLICT(agent_id) DO UPDATE SET
|
|
72
|
+
agent_name = $agentName,
|
|
73
|
+
model = $model,
|
|
74
|
+
tokens_in = $tokensIn,
|
|
75
|
+
tokens_out = $tokensOut,
|
|
76
|
+
estimated_cost = $estimatedCost,
|
|
77
|
+
closed_at = $closedAt
|
|
78
|
+
`);
|
|
79
|
+
stmt.run({
|
|
80
|
+
$agentId: record.agentId,
|
|
81
|
+
$agentName: record.agentName,
|
|
82
|
+
$model: record.model,
|
|
83
|
+
$tokensIn: record.tokensIn,
|
|
84
|
+
$tokensOut: record.tokensOut,
|
|
85
|
+
$estimatedCost: record.estimatedCost,
|
|
86
|
+
$createdAt: record.createdAt,
|
|
87
|
+
$closedAt: record.closedAt,
|
|
88
|
+
});
|
|
89
|
+
} catch (err) {
|
|
90
|
+
console.warn("[cost-tracker] upsert failed:", err);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Mark a record as closed (agent destroyed). */
|
|
95
|
+
finalize(agentId: string): void {
|
|
96
|
+
try {
|
|
97
|
+
this.db
|
|
98
|
+
.prepare("UPDATE cost_records SET closed_at = $closedAt WHERE agent_id = $agentId")
|
|
99
|
+
.run({
|
|
100
|
+
$closedAt: new Date().toISOString(),
|
|
101
|
+
$agentId: agentId,
|
|
102
|
+
});
|
|
103
|
+
} catch (err) {
|
|
104
|
+
console.warn("[cost-tracker] finalize failed:", err);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Get all records, newest first. */
|
|
109
|
+
getAll(limit = 500): CostRecord[] {
|
|
110
|
+
try {
|
|
111
|
+
const rows = this.db
|
|
112
|
+
.prepare(
|
|
113
|
+
"SELECT agent_id, agent_name, model, tokens_in, tokens_out, estimated_cost, created_at, closed_at FROM cost_records ORDER BY created_at DESC LIMIT ?",
|
|
114
|
+
)
|
|
115
|
+
.all(limit) as Array<{
|
|
116
|
+
agent_id: string;
|
|
117
|
+
agent_name: string;
|
|
118
|
+
model: string;
|
|
119
|
+
tokens_in: number;
|
|
120
|
+
tokens_out: number;
|
|
121
|
+
estimated_cost: number;
|
|
122
|
+
created_at: string;
|
|
123
|
+
closed_at: string | null;
|
|
124
|
+
}>;
|
|
125
|
+
return rows.map((r) => ({
|
|
126
|
+
agentId: r.agent_id,
|
|
127
|
+
agentName: r.agent_name,
|
|
128
|
+
model: r.model,
|
|
129
|
+
tokensIn: r.tokens_in,
|
|
130
|
+
tokensOut: r.tokens_out,
|
|
131
|
+
estimatedCost: r.estimated_cost,
|
|
132
|
+
createdAt: r.created_at,
|
|
133
|
+
closedAt: r.closed_at,
|
|
134
|
+
}));
|
|
135
|
+
} catch (err) {
|
|
136
|
+
console.warn("[cost-tracker] getAll failed:", err);
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Get aggregate summary. */
|
|
142
|
+
getSummary(): CostHistorySummary {
|
|
143
|
+
try {
|
|
144
|
+
const row = this.db
|
|
145
|
+
.prepare(
|
|
146
|
+
"SELECT COALESCE(SUM(estimated_cost),0) as total_cost, COALESCE(SUM(tokens_in),0) as total_in, COALESCE(SUM(tokens_out),0) as total_out, COUNT(*) as cnt FROM cost_records",
|
|
147
|
+
)
|
|
148
|
+
.get() as {
|
|
149
|
+
total_cost: number;
|
|
150
|
+
total_in: number;
|
|
151
|
+
total_out: number;
|
|
152
|
+
cnt: number;
|
|
153
|
+
};
|
|
154
|
+
return {
|
|
155
|
+
allTimeCost: row.total_cost,
|
|
156
|
+
allTimeTokensIn: row.total_in,
|
|
157
|
+
allTimeTokensOut: row.total_out,
|
|
158
|
+
totalRecords: row.cnt,
|
|
159
|
+
};
|
|
160
|
+
} catch (err) {
|
|
161
|
+
console.warn("[cost-tracker] getSummary failed:", err);
|
|
162
|
+
return {
|
|
163
|
+
allTimeCost: 0,
|
|
164
|
+
allTimeTokensIn: 0,
|
|
165
|
+
allTimeTokensOut: 0,
|
|
166
|
+
totalRecords: 0,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Delete all cost records. Returns number deleted. */
|
|
172
|
+
reset(): number {
|
|
173
|
+
try {
|
|
174
|
+
const result = this.db.prepare("DELETE FROM cost_records").run();
|
|
175
|
+
return result.changes;
|
|
176
|
+
} catch (err) {
|
|
177
|
+
console.warn("[cost-tracker] reset failed:", err);
|
|
178
|
+
return 0;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Get spend limit. */
|
|
183
|
+
getSpendLimit(): number | null {
|
|
184
|
+
try {
|
|
185
|
+
const row = this.db
|
|
186
|
+
.prepare("SELECT value FROM settings WHERE key = 'spend_limit'")
|
|
187
|
+
.get() as { value: string } | undefined;
|
|
188
|
+
return row ? parseFloat(row.value) : null;
|
|
189
|
+
} catch {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Set or clear spend limit. */
|
|
195
|
+
setSpendLimit(limit: number | null): void {
|
|
196
|
+
try {
|
|
197
|
+
if (limit === null) {
|
|
198
|
+
this.db.prepare("DELETE FROM settings WHERE key = 'spend_limit'").run();
|
|
199
|
+
} else {
|
|
200
|
+
this.db
|
|
201
|
+
.prepare(
|
|
202
|
+
"INSERT INTO settings (key, value) VALUES ('spend_limit', $val) ON CONFLICT(key) DO UPDATE SET value = $val",
|
|
203
|
+
)
|
|
204
|
+
.run({ $val: String(limit) });
|
|
205
|
+
}
|
|
206
|
+
} catch (err) {
|
|
207
|
+
console.warn("[cost-tracker] setSpendLimit failed:", err);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Close the database connection. */
|
|
212
|
+
close(): void {
|
|
213
|
+
try {
|
|
214
|
+
this.db.close();
|
|
215
|
+
} catch {
|
|
216
|
+
// Ignore
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Singleton instance */
|
|
222
|
+
export const costTracker = new CostTracker();
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { Cron } from "croner";
|
|
2
|
+
import type { CronJob, CronJobExecution } from "./cron-types.js";
|
|
3
|
+
import type { CliLauncher } from "./cli-launcher.js";
|
|
4
|
+
import type { WsBridge } from "./ws-bridge.js";
|
|
5
|
+
import * as cronStore from "./cron-store.js";
|
|
6
|
+
import * as envManager from "./env-manager.js";
|
|
7
|
+
import * as sessionNames from "./session-names.js";
|
|
8
|
+
|
|
9
|
+
/** Max consecutive failures before auto-disabling a job */
|
|
10
|
+
const MAX_CONSECUTIVE_FAILURES = 5;
|
|
11
|
+
/** Max time to wait for CLI to connect (ms) */
|
|
12
|
+
const CLI_CONNECT_TIMEOUT_MS = 30_000;
|
|
13
|
+
/** Poll interval when waiting for CLI connection */
|
|
14
|
+
const CLI_CONNECT_POLL_MS = 500;
|
|
15
|
+
|
|
16
|
+
export class CronScheduler {
|
|
17
|
+
private timers = new Map<string, Cron>();
|
|
18
|
+
private launcher: CliLauncher;
|
|
19
|
+
private wsBridge: WsBridge;
|
|
20
|
+
/** In-memory execution history (last N per job) */
|
|
21
|
+
private executions = new Map<string, CronJobExecution[]>();
|
|
22
|
+
private static readonly MAX_EXECUTIONS_PER_JOB = 50;
|
|
23
|
+
|
|
24
|
+
constructor(launcher: CliLauncher, wsBridge: WsBridge) {
|
|
25
|
+
this.launcher = launcher;
|
|
26
|
+
this.wsBridge = wsBridge;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Start all enabled jobs from disk. Called once at server startup. */
|
|
30
|
+
startAll(): void {
|
|
31
|
+
const jobs = cronStore.listJobs();
|
|
32
|
+
let started = 0;
|
|
33
|
+
for (const job of jobs) {
|
|
34
|
+
if (job.enabled) {
|
|
35
|
+
this.scheduleJob(job);
|
|
36
|
+
started++;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (started > 0) {
|
|
40
|
+
console.log(`[cron-scheduler] Started ${started} cron job(s)`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Schedule (or reschedule) a single job. */
|
|
45
|
+
scheduleJob(job: CronJob): void {
|
|
46
|
+
this.stopJob(job.id);
|
|
47
|
+
|
|
48
|
+
if (!job.enabled) return;
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
if (job.recurring) {
|
|
52
|
+
const cronTask = new Cron(job.schedule, {}, () => {
|
|
53
|
+
this.executeJob(job.id).catch((err) => {
|
|
54
|
+
console.error(`[cron-scheduler] Unhandled error in job "${job.name}":`, err);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
this.timers.set(job.id, cronTask);
|
|
58
|
+
console.log(`[cron-scheduler] Scheduled "${job.name}" with cron "${job.schedule}"`);
|
|
59
|
+
} else {
|
|
60
|
+
// One-shot: schedule for the specified datetime
|
|
61
|
+
const targetTime = new Date(job.schedule);
|
|
62
|
+
if (targetTime.getTime() > Date.now()) {
|
|
63
|
+
const cronTask = new Cron(targetTime, () => {
|
|
64
|
+
this.executeJob(job.id)
|
|
65
|
+
.then(() => {
|
|
66
|
+
// Auto-disable after one-shot execution
|
|
67
|
+
cronStore.updateJob(job.id, { enabled: false });
|
|
68
|
+
this.timers.delete(job.id);
|
|
69
|
+
})
|
|
70
|
+
.catch((err) => {
|
|
71
|
+
console.error(`[cron-scheduler] Unhandled error in one-shot job "${job.name}":`, err);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
this.timers.set(job.id, cronTask);
|
|
75
|
+
console.log(`[cron-scheduler] Scheduled one-shot "${job.name}" at ${targetTime.toISOString()}`);
|
|
76
|
+
} else {
|
|
77
|
+
console.log(`[cron-scheduler] Skipping one-shot "${job.name}" — target time is in the past`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
} catch (err) {
|
|
81
|
+
console.error(`[cron-scheduler] Failed to schedule "${job.name}":`, err);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Stop a job's timer. */
|
|
86
|
+
stopJob(jobId: string): void {
|
|
87
|
+
const timer = this.timers.get(jobId);
|
|
88
|
+
if (timer) {
|
|
89
|
+
timer.stop();
|
|
90
|
+
this.timers.delete(jobId);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Execute a job: create a session, send the prompt, track the result. */
|
|
95
|
+
async executeJob(jobId: string, opts?: { force?: boolean }): Promise<void> {
|
|
96
|
+
const job = cronStore.getJob(jobId);
|
|
97
|
+
if (!job) return;
|
|
98
|
+
if (!job.enabled && !opts?.force) return;
|
|
99
|
+
|
|
100
|
+
// Overlap prevention: skip if previous execution is still running
|
|
101
|
+
if (job.lastSessionId && this.launcher.isAlive(job.lastSessionId)) {
|
|
102
|
+
console.log(`[cron-scheduler] Skipping "${job.name}" — previous execution still running (${job.lastSessionId})`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
console.log(`[cron-scheduler] Executing job "${job.name}" (${jobId})`);
|
|
107
|
+
|
|
108
|
+
const execution: CronJobExecution = {
|
|
109
|
+
sessionId: "",
|
|
110
|
+
jobId,
|
|
111
|
+
startedAt: Date.now(),
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
// Resolve environment variables
|
|
116
|
+
let envVars: Record<string, string> | undefined;
|
|
117
|
+
if (job.envSlug) {
|
|
118
|
+
const env = envManager.getEnv(job.envSlug);
|
|
119
|
+
if (env) envVars = env.variables;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Launch the session via CliLauncher
|
|
123
|
+
// For Codex, explicitly set sandbox and internet access for full autonomy
|
|
124
|
+
const sessionInfo = this.launcher.launch({
|
|
125
|
+
model: job.model,
|
|
126
|
+
permissionMode: job.permissionMode,
|
|
127
|
+
cwd: job.cwd,
|
|
128
|
+
env: envVars,
|
|
129
|
+
backendType: job.backendType,
|
|
130
|
+
codexInternetAccess: job.backendType === "codex" ? (job.codexInternetAccess ?? true) : undefined,
|
|
131
|
+
codexSandbox: job.backendType === "codex"
|
|
132
|
+
? (job.permissionMode === "bypassPermissions" ? "danger-full-access" : "workspace-write")
|
|
133
|
+
: undefined,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
execution.sessionId = sessionInfo.sessionId;
|
|
137
|
+
|
|
138
|
+
// Tag the session as cron-originated
|
|
139
|
+
sessionInfo.cronJobId = jobId;
|
|
140
|
+
sessionInfo.cronJobName = job.name;
|
|
141
|
+
|
|
142
|
+
// Set the session name
|
|
143
|
+
const runLabel = `⏰ ${job.name}`;
|
|
144
|
+
sessionNames.setName(sessionInfo.sessionId, runLabel);
|
|
145
|
+
|
|
146
|
+
// Wait for CLI to connect, then send the prompt
|
|
147
|
+
await this.waitForCLIConnection(sessionInfo.sessionId);
|
|
148
|
+
|
|
149
|
+
// Send the prompt with cron prefix for traceability
|
|
150
|
+
const fullPrompt = `[cron:${job.id} ${job.name}]\n\n${job.prompt}`;
|
|
151
|
+
this.wsBridge.injectUserMessage(sessionInfo.sessionId, fullPrompt);
|
|
152
|
+
|
|
153
|
+
// Update job tracking
|
|
154
|
+
cronStore.updateJob(jobId, {
|
|
155
|
+
lastRunAt: Date.now(),
|
|
156
|
+
lastSessionId: sessionInfo.sessionId,
|
|
157
|
+
totalRuns: job.totalRuns + 1,
|
|
158
|
+
consecutiveFailures: 0,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
execution.success = true;
|
|
162
|
+
this.addExecution(jobId, execution);
|
|
163
|
+
|
|
164
|
+
} catch (err) {
|
|
165
|
+
console.error(`[cron-scheduler] Job "${job.name}" failed:`, err);
|
|
166
|
+
execution.error = err instanceof Error ? err.message : String(err);
|
|
167
|
+
execution.completedAt = Date.now();
|
|
168
|
+
this.addExecution(jobId, execution);
|
|
169
|
+
|
|
170
|
+
const failures = job.consecutiveFailures + 1;
|
|
171
|
+
const updates: Partial<CronJob> = {
|
|
172
|
+
consecutiveFailures: failures,
|
|
173
|
+
lastRunAt: Date.now(),
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// Auto-disable after too many failures
|
|
177
|
+
if (failures >= MAX_CONSECUTIVE_FAILURES) {
|
|
178
|
+
updates.enabled = false;
|
|
179
|
+
this.stopJob(jobId);
|
|
180
|
+
console.warn(`[cron-scheduler] Job "${job.name}" disabled after ${failures} consecutive failures`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
cronStore.updateJob(jobId, updates);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Manual trigger (run now regardless of schedule, bypasses enabled check). */
|
|
188
|
+
executeJobManually(jobId: string): void {
|
|
189
|
+
this.executeJob(jobId, { force: true }).catch((err) => {
|
|
190
|
+
console.error(`[cron-scheduler] Manual execution of job "${jobId}" failed:`, err);
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Wait for CLI to be connected (poll up to timeout). */
|
|
195
|
+
private async waitForCLIConnection(sessionId: string): Promise<void> {
|
|
196
|
+
const start = Date.now();
|
|
197
|
+
|
|
198
|
+
while (Date.now() - start < CLI_CONNECT_TIMEOUT_MS) {
|
|
199
|
+
const info = this.launcher.getSession(sessionId);
|
|
200
|
+
if (info && (info.state === "connected" || info.state === "running")) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (info?.state === "exited") {
|
|
204
|
+
throw new Error(`CLI process exited before connecting (exit code: ${info.exitCode})`);
|
|
205
|
+
}
|
|
206
|
+
await new Promise((r) => setTimeout(r, CLI_CONNECT_POLL_MS));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
throw new Error(`CLI process did not connect within ${CLI_CONNECT_TIMEOUT_MS / 1000}s`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Get next run time for a job. */
|
|
213
|
+
getNextRunTime(jobId: string): Date | null {
|
|
214
|
+
const timer = this.timers.get(jobId);
|
|
215
|
+
if (!timer) return null;
|
|
216
|
+
return timer.nextRun() || null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Get recent executions for a job. */
|
|
220
|
+
getExecutions(jobId: string): CronJobExecution[] {
|
|
221
|
+
return this.executions.get(jobId) || [];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private addExecution(jobId: string, execution: CronJobExecution): void {
|
|
225
|
+
if (!this.executions.has(jobId)) {
|
|
226
|
+
this.executions.set(jobId, []);
|
|
227
|
+
}
|
|
228
|
+
const list = this.executions.get(jobId)!;
|
|
229
|
+
list.push(execution);
|
|
230
|
+
if (list.length > CronScheduler.MAX_EXECUTIONS_PER_JOB) {
|
|
231
|
+
list.splice(0, list.length - CronScheduler.MAX_EXECUTIONS_PER_JOB);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Stop all timers (for graceful shutdown). */
|
|
236
|
+
destroy(): void {
|
|
237
|
+
for (const timer of this.timers.values()) {
|
|
238
|
+
timer.stop();
|
|
239
|
+
}
|
|
240
|
+
this.timers.clear();
|
|
241
|
+
this.executions.clear();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
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 type { CronJob, CronJobCreateInput } from "./cron-types.js";
|
|
11
|
+
import { HEYHANK_HOME } from "./paths.js";
|
|
12
|
+
|
|
13
|
+
// ─── Paths ──────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
const CRON_DIR = join(HEYHANK_HOME, "cron");
|
|
16
|
+
|
|
17
|
+
function ensureDir(): void {
|
|
18
|
+
mkdirSync(CRON_DIR, { recursive: true });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function filePath(id: string): string {
|
|
22
|
+
return join(CRON_DIR, `${id}.json`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
function slugify(name: string): string {
|
|
28
|
+
return name
|
|
29
|
+
.toLowerCase()
|
|
30
|
+
.replace(/\s+/g, "-")
|
|
31
|
+
.replace(/[^a-z0-9-]/g, "")
|
|
32
|
+
.replace(/-+/g, "-")
|
|
33
|
+
.replace(/^-|-$/g, "");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ─── CRUD ───────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
export function listJobs(): CronJob[] {
|
|
39
|
+
ensureDir();
|
|
40
|
+
try {
|
|
41
|
+
const files = readdirSync(CRON_DIR).filter((f) => f.endsWith(".json"));
|
|
42
|
+
const jobs: CronJob[] = [];
|
|
43
|
+
for (const file of files) {
|
|
44
|
+
try {
|
|
45
|
+
const raw = readFileSync(join(CRON_DIR, file), "utf-8");
|
|
46
|
+
jobs.push(JSON.parse(raw));
|
|
47
|
+
} catch {
|
|
48
|
+
// Skip corrupt files
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
jobs.sort((a, b) => a.name.localeCompare(b.name));
|
|
52
|
+
return jobs;
|
|
53
|
+
} catch {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function getJob(id: string): CronJob | null {
|
|
59
|
+
ensureDir();
|
|
60
|
+
try {
|
|
61
|
+
const raw = readFileSync(filePath(id), "utf-8");
|
|
62
|
+
return JSON.parse(raw) as CronJob;
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createJob(data: CronJobCreateInput): CronJob {
|
|
69
|
+
if (!data.name || !data.name.trim()) throw new Error("Job name is required");
|
|
70
|
+
if (!data.prompt || !data.prompt.trim()) throw new Error("Job prompt is required");
|
|
71
|
+
if (!data.schedule || !data.schedule.trim()) throw new Error("Job schedule is required");
|
|
72
|
+
if (!data.cwd || !data.cwd.trim()) throw new Error("Job working directory is required");
|
|
73
|
+
|
|
74
|
+
const id = slugify(data.name.trim());
|
|
75
|
+
if (!id) throw new Error("Job name must contain alphanumeric characters");
|
|
76
|
+
|
|
77
|
+
ensureDir();
|
|
78
|
+
if (existsSync(filePath(id))) {
|
|
79
|
+
throw new Error(`A job with a similar name already exists ("${id}")`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
const job: CronJob = {
|
|
84
|
+
...data,
|
|
85
|
+
id,
|
|
86
|
+
name: data.name.trim(),
|
|
87
|
+
prompt: data.prompt.trim(),
|
|
88
|
+
schedule: data.schedule.trim(),
|
|
89
|
+
cwd: data.cwd.trim(),
|
|
90
|
+
createdAt: now,
|
|
91
|
+
updatedAt: now,
|
|
92
|
+
consecutiveFailures: 0,
|
|
93
|
+
totalRuns: 0,
|
|
94
|
+
};
|
|
95
|
+
writeFileSync(filePath(id), JSON.stringify(job, null, 2), "utf-8");
|
|
96
|
+
return job;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function updateJob(
|
|
100
|
+
id: string,
|
|
101
|
+
updates: Partial<CronJob>,
|
|
102
|
+
): CronJob | null {
|
|
103
|
+
ensureDir();
|
|
104
|
+
const existing = getJob(id);
|
|
105
|
+
if (!existing) return null;
|
|
106
|
+
|
|
107
|
+
const newName = updates.name?.trim() || existing.name;
|
|
108
|
+
const newId = slugify(newName);
|
|
109
|
+
if (!newId) throw new Error("Job name must contain alphanumeric characters");
|
|
110
|
+
|
|
111
|
+
// If name changed, check for slug collision with a different job
|
|
112
|
+
if (newId !== id && existsSync(filePath(newId))) {
|
|
113
|
+
throw new Error(`A job with a similar name already exists ("${newId}")`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const job: CronJob = {
|
|
117
|
+
...existing,
|
|
118
|
+
...updates,
|
|
119
|
+
id: newId,
|
|
120
|
+
name: newName,
|
|
121
|
+
updatedAt: Date.now(),
|
|
122
|
+
// Preserve immutable fields
|
|
123
|
+
createdAt: existing.createdAt,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// If id changed, delete old file
|
|
127
|
+
if (newId !== id) {
|
|
128
|
+
try {
|
|
129
|
+
unlinkSync(filePath(id));
|
|
130
|
+
} catch {
|
|
131
|
+
/* ok */
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
writeFileSync(filePath(newId), JSON.stringify(job, null, 2), "utf-8");
|
|
136
|
+
return job;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function deleteJob(id: string): boolean {
|
|
140
|
+
ensureDir();
|
|
141
|
+
if (!existsSync(filePath(id))) return false;
|
|
142
|
+
try {
|
|
143
|
+
unlinkSync(filePath(id));
|
|
144
|
+
return true;
|
|
145
|
+
} catch {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// ─── Cron Job Types ────────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
export interface CronJob {
|
|
4
|
+
/** Unique slug-based ID (derived from name) */
|
|
5
|
+
id: string;
|
|
6
|
+
/** Human-readable job name */
|
|
7
|
+
name: string;
|
|
8
|
+
/** The prompt to send when the job fires */
|
|
9
|
+
prompt: string;
|
|
10
|
+
/** Cron expression (e.g. "0 8 * * *") or ISO datetime string for one-shot */
|
|
11
|
+
schedule: string;
|
|
12
|
+
/** true = recurring cron, false = one-shot at a specific time */
|
|
13
|
+
recurring: boolean;
|
|
14
|
+
/** Backend to use */
|
|
15
|
+
backendType: "claude" | "codex";
|
|
16
|
+
/** Model to use (e.g. "claude-sonnet-4-6") */
|
|
17
|
+
model: string;
|
|
18
|
+
/** Working directory for the session */
|
|
19
|
+
cwd: string;
|
|
20
|
+
/** Optional environment slug (references ~/.heyhank/envs/) */
|
|
21
|
+
envSlug?: string;
|
|
22
|
+
/** Whether the job is currently enabled */
|
|
23
|
+
enabled: boolean;
|
|
24
|
+
/** Permission mode — defaults to "bypassPermissions" for autonomy */
|
|
25
|
+
permissionMode: string;
|
|
26
|
+
/** Codex-only: enable internet access */
|
|
27
|
+
codexInternetAccess?: boolean;
|
|
28
|
+
|
|
29
|
+
// ── Tracking ──
|
|
30
|
+
createdAt: number;
|
|
31
|
+
updatedAt: number;
|
|
32
|
+
/** Last time this job was triggered */
|
|
33
|
+
lastRunAt?: number;
|
|
34
|
+
/** Session ID of the last execution */
|
|
35
|
+
lastSessionId?: string;
|
|
36
|
+
/** Number of consecutive failures */
|
|
37
|
+
consecutiveFailures: number;
|
|
38
|
+
/** Total number of runs */
|
|
39
|
+
totalRuns: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CronJobExecution {
|
|
43
|
+
/** The session ID created for this execution */
|
|
44
|
+
sessionId: string;
|
|
45
|
+
/** The job ID that triggered this */
|
|
46
|
+
jobId: string;
|
|
47
|
+
/** When the execution started */
|
|
48
|
+
startedAt: number;
|
|
49
|
+
/** When the execution completed (result received) */
|
|
50
|
+
completedAt?: number;
|
|
51
|
+
/** Whether the execution succeeded */
|
|
52
|
+
success?: boolean;
|
|
53
|
+
/** Error message if it failed */
|
|
54
|
+
error?: string;
|
|
55
|
+
/** Cost in USD */
|
|
56
|
+
costUsd?: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Input for creating a cron job (without auto-generated fields) */
|
|
60
|
+
export type CronJobCreateInput = Omit<
|
|
61
|
+
CronJob,
|
|
62
|
+
"id" | "createdAt" | "updatedAt" | "consecutiveFailures" | "totalRuns" | "lastRunAt" | "lastSessionId"
|
|
63
|
+
>;
|