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
package/server/index.ts
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = "1";
|
|
2
|
+
|
|
3
|
+
// Enrich process PATH at startup so binary resolution and `which` calls can find
|
|
4
|
+
// binaries installed via version managers (nvm, volta, fnm, etc.).
|
|
5
|
+
// Critical when running as a launchd/systemd service with a restricted PATH.
|
|
6
|
+
import { getEnrichedPath } from "./path-resolver.js";
|
|
7
|
+
process.env.PATH = getEnrichedPath();
|
|
8
|
+
|
|
9
|
+
import { dirname, resolve } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { Hono } from "hono";
|
|
12
|
+
import { cors } from "hono/cors";
|
|
13
|
+
import { serveStatic } from "hono/bun";
|
|
14
|
+
import { cacheControlMiddleware } from "./cache-headers.js";
|
|
15
|
+
import { createRoutes } from "./routes.js";
|
|
16
|
+
import { CliLauncher } from "./cli-launcher.js";
|
|
17
|
+
import { WsBridge } from "./ws-bridge.js";
|
|
18
|
+
import { SessionStore } from "./session-store.js";
|
|
19
|
+
import { WorktreeTracker } from "./worktree-tracker.js";
|
|
20
|
+
import { containerManager } from "./container-manager.js";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { HEYHANK_HOME } from "./paths.js";
|
|
23
|
+
import { TerminalManager } from "./terminal-manager.js";
|
|
24
|
+
import { PRPoller } from "./pr-poller.js";
|
|
25
|
+
import { RecorderManager } from "./recorder.js";
|
|
26
|
+
import { initLogFile, closeLogFile } from "./logger.js";
|
|
27
|
+
import { CronScheduler } from "./cron-scheduler.js";
|
|
28
|
+
import { AgentExecutor } from "./agent-executor.js";
|
|
29
|
+
import { SessionOrchestrator } from "./session-orchestrator.js";
|
|
30
|
+
import { migrateCronJobsToAgents } from "./agent-cron-migrator.js";
|
|
31
|
+
import { migrateAnthropicApiKeyToProvider } from "./anthropic-provider-migration.js";
|
|
32
|
+
import { authenticateManagedWebSocket } from "./ws-auth.js";
|
|
33
|
+
import { NoVncProxy } from "./novnc-proxy.js";
|
|
34
|
+
import { nodeManager } from "./federation/node-manager.js";
|
|
35
|
+
import { callManager } from "./telephony/call-manager.js";
|
|
36
|
+
|
|
37
|
+
import { startPeriodicCheck, setServiceMode } from "./update-checker.js";
|
|
38
|
+
import { startReminderScheduler } from "./reminder-scheduler.js";
|
|
39
|
+
import { imagePullManager } from "./image-pull-manager.js";
|
|
40
|
+
import { restoreIfNeeded as restoreTailscaleFunnel, cleanup as cleanupTailscaleFunnel } from "./tailscale-manager.js";
|
|
41
|
+
import { isRunningAsService } from "./service.js";
|
|
42
|
+
import { getToken, verifyToken } from "./auth-manager.js";
|
|
43
|
+
import { getCookie } from "hono/cookie";
|
|
44
|
+
import type { SocketData } from "./ws-bridge.js";
|
|
45
|
+
import type { ServerWebSocket } from "bun";
|
|
46
|
+
|
|
47
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
48
|
+
const packageRoot = process.env.__HEYHANK_PACKAGE_ROOT || process.env.__COMPANION_PACKAGE_ROOT || resolve(__dirname, "..");
|
|
49
|
+
|
|
50
|
+
import { DEFAULT_PORT_DEV, DEFAULT_PORT_PROD } from "./constants.js";
|
|
51
|
+
|
|
52
|
+
const defaultPort = process.env.NODE_ENV === "production" ? DEFAULT_PORT_PROD : DEFAULT_PORT_DEV;
|
|
53
|
+
const port = Number(process.env.PORT) || defaultPort;
|
|
54
|
+
const host = process.env.HOST || "127.0.0.1";
|
|
55
|
+
const sessionStore = new SessionStore(process.env.HEYHANK_SESSION_DIR || process.env.COMPANION_SESSION_DIR);
|
|
56
|
+
const wsBridge = new WsBridge();
|
|
57
|
+
const launcher = new CliLauncher(port);
|
|
58
|
+
const worktreeTracker = new WorktreeTracker();
|
|
59
|
+
const CONTAINER_STATE_PATH = join(HEYHANK_HOME, "containers.json");
|
|
60
|
+
const terminalManager = new TerminalManager();
|
|
61
|
+
const noVncProxy = new NoVncProxy();
|
|
62
|
+
const prPoller = new PRPoller(wsBridge);
|
|
63
|
+
const recorder = new RecorderManager();
|
|
64
|
+
const cronScheduler = new CronScheduler(launcher, wsBridge);
|
|
65
|
+
const agentExecutor = new AgentExecutor(launcher, wsBridge);
|
|
66
|
+
const orchestrator = new SessionOrchestrator({
|
|
67
|
+
launcher, wsBridge, sessionStore, worktreeTracker,
|
|
68
|
+
prPoller, agentExecutor,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// ── Cloud relay connection (for receiving webhooks behind a firewall) ────────
|
|
72
|
+
// The relay forwards platform webhooks (e.g. GitHub, Slack) to the HeyHank
|
|
73
|
+
// instance via an outbound WebSocket. Currently no webhook handlers are
|
|
74
|
+
// registered (Chat SDK was removed). The relay is left disabled until handlers
|
|
75
|
+
// are wired up (e.g. future platform integrations).
|
|
76
|
+
const relayUrl = process.env.HEYHANK_RELAY_URL || process.env.COMPANION_RELAY_URL;
|
|
77
|
+
const relaySecret = process.env.HEYHANK_RELAY_SECRET || process.env.COMPANION_RELAY_SECRET;
|
|
78
|
+
if (relayUrl && relaySecret) {
|
|
79
|
+
console.warn(
|
|
80
|
+
"[server] HEYHANK_RELAY_URL is set but no relay webhook handlers are registered. " +
|
|
81
|
+
"The relay client will not be started. Remove HEYHANK_RELAY_URL/HEYHANK_RELAY_SECRET " +
|
|
82
|
+
"or wire up webhook handlers to use relay mode.",
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Restore persisted sessions from disk ────────────────────────────────────
|
|
87
|
+
wsBridge.setStore(sessionStore);
|
|
88
|
+
wsBridge.setRecorder(recorder);
|
|
89
|
+
launcher.setStore(sessionStore);
|
|
90
|
+
launcher.setRecorder(recorder);
|
|
91
|
+
launcher.restoreFromDisk();
|
|
92
|
+
wsBridge.restoreFromDisk();
|
|
93
|
+
containerManager.restoreState(CONTAINER_STATE_PATH);
|
|
94
|
+
|
|
95
|
+
// ── Session orchestrator — centralizes lifecycle event wiring ────────────────
|
|
96
|
+
orchestrator.initialize();
|
|
97
|
+
|
|
98
|
+
console.log(`[server] Session persistence: ${sessionStore.directory}`);
|
|
99
|
+
if (recorder.isGloballyEnabled()) {
|
|
100
|
+
console.log(`[server] Recording enabled (dir: ${recorder.getRecordingsDir()}, max: ${recorder.getMaxLines()} lines)`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Log file persistence — writes all log output to ~/.heyhank/logs/ ───────
|
|
104
|
+
const logFileWriter = initLogFile();
|
|
105
|
+
if (logFileWriter) {
|
|
106
|
+
console.log(`[server] Log file enabled (dir: ${logFileWriter.getLogsDir()}, max: ${logFileWriter.getMaxLines()} lines, file: ${logFileWriter.filePath})`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const app = new Hono();
|
|
110
|
+
|
|
111
|
+
// ── Health endpoint — always unauthenticated (used by Fly.io + control plane) ─
|
|
112
|
+
const startTime = Date.now();
|
|
113
|
+
app.get("/health", (c) => {
|
|
114
|
+
return c.json({
|
|
115
|
+
ok: true,
|
|
116
|
+
uptime: Math.floor((Date.now() - startTime) / 1000),
|
|
117
|
+
sessions: launcher.listSessions().length,
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// ── Managed auth middleware — only active when HEYHANK_AUTH_ENABLED=1 ────
|
|
122
|
+
const hasManagedAuthSecret = Boolean((process.env.HEYHANK_AUTH_SECRET || process.env.COMPANION_AUTH_SECRET)?.trim());
|
|
123
|
+
const managedAuthEnabled =
|
|
124
|
+
(process.env.HEYHANK_AUTH_ENABLED || process.env.COMPANION_AUTH_ENABLED) === "1" ||
|
|
125
|
+
(hasManagedAuthSecret && (process.env.HEYHANK_AUTH_ENABLED || process.env.COMPANION_AUTH_ENABLED) !== "0");
|
|
126
|
+
|
|
127
|
+
if (managedAuthEnabled) {
|
|
128
|
+
const { managedAuth } = await import("./middleware/managed-auth.js");
|
|
129
|
+
app.use("/*", managedAuth);
|
|
130
|
+
console.log("[server] Managed auth enabled");
|
|
131
|
+
} else {
|
|
132
|
+
console.log("[server] Managed auth disabled");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
app.use("/api/*", cors());
|
|
136
|
+
app.route("/api", createRoutes(orchestrator, launcher, wsBridge, terminalManager, prPoller, recorder, cronScheduler, agentExecutor, port));
|
|
137
|
+
|
|
138
|
+
// Dynamic manifest — embeds auth token in start_url so PWA auto-authenticates
|
|
139
|
+
// on first launch. iOS gives standalone PWAs isolated storage from Safari,
|
|
140
|
+
// so this is the only way to bridge auth across the install boundary.
|
|
141
|
+
app.get("/manifest.json", (c) => {
|
|
142
|
+
const manifest = {
|
|
143
|
+
name: "HeyHank",
|
|
144
|
+
short_name: "HeyHank",
|
|
145
|
+
description: "Multi-Agent Platform with AI-powered coding, monitoring, and personal assistant",
|
|
146
|
+
start_url: "/",
|
|
147
|
+
scope: "/",
|
|
148
|
+
display: "standalone" as const,
|
|
149
|
+
background_color: "#262624",
|
|
150
|
+
theme_color: "#d97757",
|
|
151
|
+
icons: [
|
|
152
|
+
{ src: "/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },
|
|
153
|
+
{ src: "/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" },
|
|
154
|
+
],
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// If the user has an auth cookie (set during login), embed token in start_url.
|
|
158
|
+
// Safari sends this cookie when fetching the manifest at "Add to Home Screen" time.
|
|
159
|
+
const authCookie = getCookie(c, "heyhank_auth") || getCookie(c, "companion_auth");
|
|
160
|
+
if (authCookie && verifyToken(authCookie)) {
|
|
161
|
+
manifest.start_url = `/?token=${authCookie}`;
|
|
162
|
+
} else {
|
|
163
|
+
// Localhost bypass — only for direct connections (not behind reverse proxy)
|
|
164
|
+
const realIp = c.req.header("x-real-ip");
|
|
165
|
+
if (!realIp) {
|
|
166
|
+
const bunServer = c.env as { requestIP?: (req: Request) => { address: string } | null };
|
|
167
|
+
const ip = bunServer?.requestIP?.(c.req.raw);
|
|
168
|
+
const addr = ip?.address ?? "";
|
|
169
|
+
if (addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1") {
|
|
170
|
+
manifest.start_url = `/?token=${getToken()}`;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
c.header("Content-Type", "application/manifest+json");
|
|
176
|
+
return c.json(manifest);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// In production, serve built frontend using absolute path (works when installed as npm package)
|
|
180
|
+
if (process.env.NODE_ENV === "production") {
|
|
181
|
+
const distDir = resolve(packageRoot, "dist");
|
|
182
|
+
app.use("/*", cacheControlMiddleware());
|
|
183
|
+
app.use("/*", serveStatic({ root: distDir }));
|
|
184
|
+
app.get("/*", serveStatic({ path: resolve(distDir, "index.html") }));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const server = Bun.serve<SocketData>({
|
|
188
|
+
hostname: host,
|
|
189
|
+
port,
|
|
190
|
+
idleTimeout: 0, // Disable top-level idle timeout — it kills idle browser WebSockets (code 1006)
|
|
191
|
+
async fetch(req, server) {
|
|
192
|
+
const url = new URL(req.url);
|
|
193
|
+
|
|
194
|
+
// ── CLI WebSocket — Claude Code CLI connects here via --sdk-url ────
|
|
195
|
+
const cliMatch = url.pathname.match(/^\/ws\/cli\/([a-f0-9-]+)$/);
|
|
196
|
+
if (cliMatch) {
|
|
197
|
+
const sessionId = cliMatch[1];
|
|
198
|
+
const upgraded = server.upgrade(req, {
|
|
199
|
+
data: { kind: "cli" as const, sessionId },
|
|
200
|
+
});
|
|
201
|
+
if (upgraded) return undefined;
|
|
202
|
+
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Helper: check if request is from localhost (same machine, not behind proxy)
|
|
206
|
+
const realIpHeader = req.headers.get("x-real-ip");
|
|
207
|
+
const isLocalhost = (() => {
|
|
208
|
+
if (realIpHeader) {
|
|
209
|
+
const trimmed = realIpHeader.trim();
|
|
210
|
+
return trimmed === "127.0.0.1" || trimmed === "::1" || trimmed === "::ffff:127.0.0.1";
|
|
211
|
+
}
|
|
212
|
+
const reqIp = server.requestIP(req);
|
|
213
|
+
const reqAddr = reqIp?.address ?? "";
|
|
214
|
+
return reqAddr === "127.0.0.1" || reqAddr === "::1" || reqAddr === "::ffff:127.0.0.1";
|
|
215
|
+
})();
|
|
216
|
+
|
|
217
|
+
// ── Browser WebSocket — connects to a specific session ─────────────
|
|
218
|
+
const browserMatch = url.pathname.match(/^\/ws\/browser\/([a-f0-9-]+)$/);
|
|
219
|
+
if (browserMatch) {
|
|
220
|
+
if (managedAuthEnabled) {
|
|
221
|
+
const auth = await authenticateManagedWebSocket(req);
|
|
222
|
+
if (!auth.ok) {
|
|
223
|
+
return new Response(auth.body || "Unauthorized", { status: auth.status });
|
|
224
|
+
}
|
|
225
|
+
} else {
|
|
226
|
+
const wsToken = url.searchParams.get("token");
|
|
227
|
+
if (!isLocalhost && !verifyToken(wsToken)) {
|
|
228
|
+
return new Response("Unauthorized", { status: 401 });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const sessionId = browserMatch[1];
|
|
232
|
+
const upgraded = server.upgrade(req, {
|
|
233
|
+
data: { kind: "browser" as const, sessionId },
|
|
234
|
+
});
|
|
235
|
+
if (upgraded) return undefined;
|
|
236
|
+
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── Terminal WebSocket — embedded terminal PTY connection ─────────
|
|
240
|
+
const termMatch = url.pathname.match(/^\/ws\/terminal\/([a-f0-9-]+)$/);
|
|
241
|
+
if (termMatch) {
|
|
242
|
+
if (managedAuthEnabled) {
|
|
243
|
+
const auth = await authenticateManagedWebSocket(req);
|
|
244
|
+
if (!auth.ok) {
|
|
245
|
+
return new Response(auth.body || "Unauthorized", { status: auth.status });
|
|
246
|
+
}
|
|
247
|
+
} else {
|
|
248
|
+
const wsToken = url.searchParams.get("token");
|
|
249
|
+
if (!isLocalhost && !verifyToken(wsToken)) {
|
|
250
|
+
return new Response("Unauthorized", { status: 401 });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const terminalId = termMatch[1];
|
|
254
|
+
const upgraded = server.upgrade(req, {
|
|
255
|
+
data: { kind: "terminal" as const, terminalId },
|
|
256
|
+
});
|
|
257
|
+
if (upgraded) return undefined;
|
|
258
|
+
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ── noVNC WebSocket — proxies VNC data to container's websockify ────
|
|
262
|
+
const novncMatch = url.pathname.match(/^\/ws\/novnc\/([a-f0-9-]+)$/);
|
|
263
|
+
if (novncMatch) {
|
|
264
|
+
if (managedAuthEnabled) {
|
|
265
|
+
const auth = await authenticateManagedWebSocket(req);
|
|
266
|
+
if (!auth.ok) {
|
|
267
|
+
return new Response(auth.body || "Unauthorized", { status: auth.status });
|
|
268
|
+
}
|
|
269
|
+
} else {
|
|
270
|
+
const wsToken = url.searchParams.get("token");
|
|
271
|
+
if (!isLocalhost && !verifyToken(wsToken)) {
|
|
272
|
+
return new Response("Unauthorized", { status: 401 });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const sessionId = novncMatch[1];
|
|
276
|
+
const upgraded = server.upgrade(req, {
|
|
277
|
+
data: { kind: "novnc" as const, sessionId },
|
|
278
|
+
});
|
|
279
|
+
if (upgraded) return undefined;
|
|
280
|
+
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ── Telephony Audio WebSocket — FreeSWITCH mod_audio_fork ───────
|
|
284
|
+
const telAudioMatch = url.pathname.match(/^\/ws\/telephony\/audio\/([a-f0-9-]+)$/);
|
|
285
|
+
if (telAudioMatch) {
|
|
286
|
+
const upgraded = server.upgrade(req, {
|
|
287
|
+
data: { kind: "telephony-audio" as const, callId: telAudioMatch[1] },
|
|
288
|
+
});
|
|
289
|
+
if (upgraded) return undefined;
|
|
290
|
+
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ── Telephony Transcript WebSocket — browser live transcript ─────
|
|
294
|
+
const telTranscriptMatch = url.pathname.match(/^\/ws\/telephony\/transcript\/([a-f0-9-]+)$/);
|
|
295
|
+
if (telTranscriptMatch) {
|
|
296
|
+
const upgraded = server.upgrade(req, {
|
|
297
|
+
data: { kind: "telephony-transcript" as const, callId: telTranscriptMatch[1] },
|
|
298
|
+
});
|
|
299
|
+
if (upgraded) return undefined;
|
|
300
|
+
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ── Federation WebSocket — peer node connections ─────────────────
|
|
304
|
+
if (url.pathname === "/ws/node") {
|
|
305
|
+
// Auth is handled inside the federation protocol (first frame)
|
|
306
|
+
const upgraded = server.upgrade(req, {
|
|
307
|
+
data: { kind: "node" as const, nodeId: "" },
|
|
308
|
+
});
|
|
309
|
+
if (upgraded) return undefined;
|
|
310
|
+
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Hono handles the rest
|
|
314
|
+
return app.fetch(req, server);
|
|
315
|
+
},
|
|
316
|
+
websocket: {
|
|
317
|
+
idleTimeout: 0,
|
|
318
|
+
sendPings: false, // Disable Bun ping timeout that kills CLI connections (code 1006)
|
|
319
|
+
open(ws: ServerWebSocket<SocketData>) {
|
|
320
|
+
const data = ws.data;
|
|
321
|
+
if (data.kind === "cli") {
|
|
322
|
+
wsBridge.handleCLIOpen(ws, data.sessionId);
|
|
323
|
+
launcher.markConnected(data.sessionId);
|
|
324
|
+
} else if (data.kind === "browser") {
|
|
325
|
+
wsBridge.handleBrowserOpen(ws, data.sessionId);
|
|
326
|
+
} else if (data.kind === "terminal") {
|
|
327
|
+
terminalManager.addBrowserSocket(ws);
|
|
328
|
+
} else if (data.kind === "novnc") {
|
|
329
|
+
noVncProxy.handleOpen(ws, data.sessionId);
|
|
330
|
+
} else if (data.kind === "node") {
|
|
331
|
+
nodeManager.handleInboundConnection(ws);
|
|
332
|
+
} else if (data.kind === "telephony-audio") {
|
|
333
|
+
callManager.addFreeSwitchSocket(data.callId, ws);
|
|
334
|
+
} else if (data.kind === "telephony-transcript") {
|
|
335
|
+
callManager.addTranscriptSocket(data.callId, ws);
|
|
336
|
+
}
|
|
337
|
+
},
|
|
338
|
+
message(ws: ServerWebSocket<SocketData>, msg: string | Buffer) {
|
|
339
|
+
const data = ws.data;
|
|
340
|
+
if (data.kind === "cli") {
|
|
341
|
+
wsBridge.handleCLIMessage(ws, msg);
|
|
342
|
+
} else if (data.kind === "browser") {
|
|
343
|
+
wsBridge.handleBrowserMessage(ws, msg);
|
|
344
|
+
} else if (data.kind === "terminal") {
|
|
345
|
+
terminalManager.handleBrowserMessage(ws, msg);
|
|
346
|
+
} else if (data.kind === "novnc") {
|
|
347
|
+
noVncProxy.handleMessage(ws, msg);
|
|
348
|
+
} else if (data.kind === "node") {
|
|
349
|
+
const handler = (ws as unknown as Record<string, unknown>).__federationOnMessage as ((data: string | Buffer) => void) | undefined;
|
|
350
|
+
handler?.(typeof msg === "string" ? msg : msg.toString());
|
|
351
|
+
} else if (data.kind === "telephony-audio") {
|
|
352
|
+
// Binary audio from FreeSWITCH mod_audio_fork
|
|
353
|
+
if (msg instanceof Buffer || msg instanceof Uint8Array) {
|
|
354
|
+
callManager.handleFreeSwitchAudio(data.callId, msg as Buffer);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
// telephony-transcript: browser only sends keep-alive, no handling needed
|
|
358
|
+
},
|
|
359
|
+
close(ws: ServerWebSocket<SocketData>, code?: number, _reason?: string) {
|
|
360
|
+
console.log("[ws-close]", ws.data.kind, "code=" + code);
|
|
361
|
+
const data = ws.data;
|
|
362
|
+
if (data.kind === "cli") {
|
|
363
|
+
wsBridge.handleCLIClose(ws);
|
|
364
|
+
} else if (data.kind === "browser") {
|
|
365
|
+
wsBridge.handleBrowserClose(ws);
|
|
366
|
+
} else if (data.kind === "terminal") {
|
|
367
|
+
terminalManager.removeBrowserSocket(ws);
|
|
368
|
+
} else if (data.kind === "novnc") {
|
|
369
|
+
noVncProxy.handleClose(ws);
|
|
370
|
+
} else if (data.kind === "node") {
|
|
371
|
+
const handler = (ws as unknown as Record<string, unknown>).__federationOnClose as (() => void) | undefined;
|
|
372
|
+
handler?.();
|
|
373
|
+
} else if (data.kind === "telephony-audio") {
|
|
374
|
+
callManager.removeFreeSwitchSocket(data.callId, ws);
|
|
375
|
+
} else if (data.kind === "telephony-transcript") {
|
|
376
|
+
callManager.removeTranscriptSocket(data.callId, ws);
|
|
377
|
+
}
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
const authToken = getToken();
|
|
383
|
+
console.log(`Server running on http://${host}:${server.port}`);
|
|
384
|
+
console.log();
|
|
385
|
+
console.log(` Auth token: ${authToken}`);
|
|
386
|
+
if (process.env.HEYHANK_AUTH_TOKEN || process.env.COMPANION_AUTH_TOKEN) {
|
|
387
|
+
console.log(" (using HEYHANK_AUTH_TOKEN env var)");
|
|
388
|
+
}
|
|
389
|
+
console.log();
|
|
390
|
+
console.log(` CLI WebSocket: ws://localhost:${server.port}/ws/cli/:sessionId`);
|
|
391
|
+
console.log(` Browser WebSocket: ws://localhost:${server.port}/ws/browser/:sessionId`);
|
|
392
|
+
|
|
393
|
+
if (process.env.NODE_ENV !== "production") {
|
|
394
|
+
console.log("Dev mode: frontend at http://localhost:5174");
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ── Federation — multi-node mesh ──────────────────────────────────────────
|
|
398
|
+
nodeManager.getLocalSessions = () => {
|
|
399
|
+
return launcher.listSessions().map((s) => {
|
|
400
|
+
const session = wsBridge.getSession(s.sessionId);
|
|
401
|
+
const state = session?.state;
|
|
402
|
+
return {
|
|
403
|
+
sessionId: s.sessionId,
|
|
404
|
+
name: s.name ?? s.sessionId,
|
|
405
|
+
model: state?.model ?? s.model ?? "",
|
|
406
|
+
cwd: state?.cwd ?? s.cwd ?? "",
|
|
407
|
+
status: s.state ?? "unknown",
|
|
408
|
+
backendType: state?.backend_type ?? s.backendType ?? "claude",
|
|
409
|
+
isConnected: s.state === "connected" || s.state === "running",
|
|
410
|
+
};
|
|
411
|
+
});
|
|
412
|
+
};
|
|
413
|
+
nodeManager.initialize();
|
|
414
|
+
|
|
415
|
+
// ── Cron scheduler ──────────────────────────────────────────────────────────
|
|
416
|
+
cronScheduler.startAll();
|
|
417
|
+
|
|
418
|
+
// ── Agent system ────────────────────────────────────────────────────────────
|
|
419
|
+
migrateCronJobsToAgents();
|
|
420
|
+
migrateAnthropicApiKeyToProvider();
|
|
421
|
+
agentExecutor.startAll();
|
|
422
|
+
|
|
423
|
+
// ── Agent Platform extensions ──────────────────────────────────────────────
|
|
424
|
+
import { attachMessageDelivery } from "./message-delivery.js";
|
|
425
|
+
import { startTimeoutMonitor } from "./agent-timeout.js";
|
|
426
|
+
|
|
427
|
+
attachMessageDelivery(agentExecutor, wsBridge);
|
|
428
|
+
startTimeoutMonitor(launcher, wsBridge);
|
|
429
|
+
|
|
430
|
+
// ── Image pull manager — pre-pull missing Docker images for environments ────
|
|
431
|
+
imagePullManager.initFromEnvironments();
|
|
432
|
+
|
|
433
|
+
// ── Tailscale Funnel restoration ────────────────────────────────────────────
|
|
434
|
+
restoreTailscaleFunnel(port).catch((err) => {
|
|
435
|
+
console.warn("[server] Tailscale Funnel restoration failed:", err);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
// ── Update checker ──────────────────────────────────────────────────────────
|
|
439
|
+
startPeriodicCheck();
|
|
440
|
+
|
|
441
|
+
// ── Reminder scheduler ──────────────────────────────────────────────────────
|
|
442
|
+
startReminderScheduler();
|
|
443
|
+
if (isRunningAsService()) {
|
|
444
|
+
setServiceMode(true);
|
|
445
|
+
console.log("[server] Running as background service (auto-update available)");
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ── Runtime diagnostics ──────────────────────────────────────────────────────
|
|
449
|
+
import { log } from "./logger.js";
|
|
450
|
+
import { metricsCollector } from "./metrics-collector.js";
|
|
451
|
+
|
|
452
|
+
const DIAGNOSTICS_INTERVAL_MS = 5 * 60_000; // every 5 minutes
|
|
453
|
+
setInterval(() => {
|
|
454
|
+
const snap = metricsCollector.getSnapshot(wsBridge);
|
|
455
|
+
const mem = snap.gauges.memory;
|
|
456
|
+
const mb = (bytes: number) => (bytes / 1024 / 1024).toFixed(1);
|
|
457
|
+
const sessionStats = wsBridge.getSessionMemoryStats();
|
|
458
|
+
const topSessions = sessionStats
|
|
459
|
+
.sort((a, b) => b.historyLen - a.historyLen)
|
|
460
|
+
.slice(0, 3)
|
|
461
|
+
.map((s) => `${s.id.slice(0, 8)}(h=${s.historyLen},b=${s.browsers})`)
|
|
462
|
+
.join(", ");
|
|
463
|
+
|
|
464
|
+
log.info("diagnostics", "Runtime snapshot", {
|
|
465
|
+
rss: `${mb(mem.rss)}MB`,
|
|
466
|
+
heap: `${mb(mem.heapUsed)}/${mb(mem.heapTotal)}MB`,
|
|
467
|
+
external: `${mb(mem.external)}MB`,
|
|
468
|
+
sessions: snap.gauges.totalActiveSessions,
|
|
469
|
+
browsers: snap.gauges.connectedBrowsers,
|
|
470
|
+
historyMsgs: snap.gauges.totalHistoryMessages,
|
|
471
|
+
pendingMsgs: snap.gauges.totalPendingMessages,
|
|
472
|
+
eventBuffer: snap.gauges.totalEventBufferSize,
|
|
473
|
+
errors: Object.values(snap.counters.errors).reduce((a, b) => a + b, 0),
|
|
474
|
+
topSessions: topSessions || "none",
|
|
475
|
+
});
|
|
476
|
+
}, DIAGNOSTICS_INTERVAL_MS);
|
|
477
|
+
|
|
478
|
+
// ── Graceful shutdown — persist container state ──────────────────────────────
|
|
479
|
+
function gracefulShutdown() {
|
|
480
|
+
console.log("[server] Persisting container state before shutdown...");
|
|
481
|
+
nodeManager.shutdown();
|
|
482
|
+
containerManager.persistState(CONTAINER_STATE_PATH);
|
|
483
|
+
cleanupTailscaleFunnel(port);
|
|
484
|
+
import("./agent-timeout.js").then(({ stopTimeoutMonitor }) => stopTimeoutMonitor()).catch(() => {});
|
|
485
|
+
import("./cost-tracker.js").then(({ costTracker }) => costTracker.close()).catch(() => {});
|
|
486
|
+
closeLogFile();
|
|
487
|
+
process.exit(0);
|
|
488
|
+
}
|
|
489
|
+
process.on("SIGTERM", gracefulShutdown);
|
|
490
|
+
process.on("SIGINT", gracefulShutdown);
|
|
491
|
+
|