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/routes.ts
ADDED
|
@@ -0,0 +1,1379 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { getCookie, setCookie } from "hono/cookie";
|
|
3
|
+
import { streamSSE } from "hono/streaming";
|
|
4
|
+
import { execSync } from "node:child_process";
|
|
5
|
+
import { resolveBinary } from "./path-resolver.js";
|
|
6
|
+
import { join, dirname } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { HEYHANK_HOME } from "./paths.js";
|
|
10
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
11
|
+
import type { SessionOrchestrator } from "./session-orchestrator.js";
|
|
12
|
+
import type { CliLauncher } from "./cli-launcher.js";
|
|
13
|
+
import type { WsBridge } from "./ws-bridge.js";
|
|
14
|
+
import type { TerminalManager } from "./terminal-manager.js";
|
|
15
|
+
import * as sessionNames from "./session-names.js";
|
|
16
|
+
import { containerManager } from "./container-manager.js";
|
|
17
|
+
import { registerFsRoutes } from "./routes/fs-routes.js";
|
|
18
|
+
import { registerSkillRoutes } from "./routes/skills-routes.js";
|
|
19
|
+
import { registerEnvRoutes } from "./routes/env-routes.js";
|
|
20
|
+
import { registerSandboxRoutes } from "./routes/sandbox-routes.js";
|
|
21
|
+
import { registerCronRoutes } from "./routes/cron-routes.js";
|
|
22
|
+
import { registerAgentRoutes } from "./routes/agent-routes.js";
|
|
23
|
+
import { registerMetricsRoutes } from "./routes/metrics-routes.js";
|
|
24
|
+
import { registerPromptRoutes } from "./routes/prompt-routes.js";
|
|
25
|
+
import { registerSettingsRoutes } from "./routes/settings-routes.js";
|
|
26
|
+
import { registerTailscaleRoutes } from "./routes/tailscale-routes.js";
|
|
27
|
+
import { registerGitRoutes } from "./routes/git-routes.js";
|
|
28
|
+
import { registerSystemRoutes } from "./routes/system-routes.js";
|
|
29
|
+
import { registerPlatformRoutes } from "./routes/platform-routes.js";
|
|
30
|
+
import { registerLLMRoutes } from "./routes/llm-routes.js";
|
|
31
|
+
import { registerMediaRoutes } from "./routes/media-routes.js";
|
|
32
|
+
import { isRecordingHubEnabled } from "./recording-hub/hub-config.js";
|
|
33
|
+
import { registerHubRoutes } from "./recording-hub/hub-routes.js";
|
|
34
|
+
import { registerFederationRoutes } from "./routes/federation-routes.js";
|
|
35
|
+
import { registerTelephonyRoutes } from "./routes/telephony-routes.js";
|
|
36
|
+
import { registerSocialMediaRoutes } from "./routes/socialmedia-routes.js";
|
|
37
|
+
import { registerAssistantRoutes } from "./routes/assistant-routes.js";
|
|
38
|
+
import { registerProviderRoutes } from "./routes/provider-routes.js";
|
|
39
|
+
import { nodeManager } from "./federation/node-manager.js";
|
|
40
|
+
import { discoverClaudeSessions } from "./claude-session-discovery.js";
|
|
41
|
+
import { getClaudeSessionHistoryPage } from "./claude-session-history.js";
|
|
42
|
+
import { verifyToken, getToken, regenerateToken, getAllAddresses } from "./auth-manager.js";
|
|
43
|
+
import QRCode from "qrcode";
|
|
44
|
+
import { VSCODE_EDITOR_CONTAINER_PORT, NOVNC_CONTAINER_PORT } from "./constants.js";
|
|
45
|
+
|
|
46
|
+
const UPDATE_CHECK_STALE_MS = 5 * 60 * 1000;
|
|
47
|
+
const ROUTES_DIR = dirname(fileURLToPath(import.meta.url));
|
|
48
|
+
const WEB_DIR = dirname(ROUTES_DIR);
|
|
49
|
+
const VSCODE_EDITOR_HOST_PORT = Number(process.env.HEYHANK_EDITOR_PORT || process.env.COMPANION_EDITOR_PORT || "13338");
|
|
50
|
+
|
|
51
|
+
function shellEscapeArg(value: string): string {
|
|
52
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createRoutes(
|
|
56
|
+
orchestrator: SessionOrchestrator,
|
|
57
|
+
launcher: CliLauncher,
|
|
58
|
+
wsBridge: WsBridge,
|
|
59
|
+
terminalManager: TerminalManager,
|
|
60
|
+
prPoller?: import("./pr-poller.js").PRPoller,
|
|
61
|
+
recorder?: import("./recorder.js").RecorderManager,
|
|
62
|
+
cronScheduler?: import("./cron-scheduler.js").CronScheduler,
|
|
63
|
+
agentExecutor?: import("./agent-executor.js").AgentExecutor,
|
|
64
|
+
port?: number,
|
|
65
|
+
) {
|
|
66
|
+
const api = new Hono();
|
|
67
|
+
|
|
68
|
+
// ─── Auth endpoints (exempt from auth middleware) ──────────────────
|
|
69
|
+
|
|
70
|
+
api.post("/auth/verify", async (c) => {
|
|
71
|
+
const body = await c.req.json().catch(() => ({} as { token?: string }));
|
|
72
|
+
if (verifyToken(body.token)) {
|
|
73
|
+
// Set cookie so the dynamic manifest can embed the token in start_url.
|
|
74
|
+
// This bridges auth from Safari to standalone PWA on iOS (isolated storage).
|
|
75
|
+
setCookie(c, "heyhank_auth", body.token!, {
|
|
76
|
+
path: "/",
|
|
77
|
+
httpOnly: true,
|
|
78
|
+
sameSite: "Strict",
|
|
79
|
+
maxAge: 365 * 24 * 60 * 60,
|
|
80
|
+
});
|
|
81
|
+
return c.json({ ok: true });
|
|
82
|
+
}
|
|
83
|
+
return c.json({ error: "Invalid token" }, 401);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
api.get("/auth/qr", async (c) => {
|
|
87
|
+
// QR endpoint requires auth — only authenticated users can generate QR for mobile
|
|
88
|
+
const authHeader = c.req.header("Authorization");
|
|
89
|
+
const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
|
|
90
|
+
if (!isLocalhostRequest(c) && !verifyToken(token)) {
|
|
91
|
+
return c.json({ error: "unauthorized" }, 401);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const port = Number(process.env.PORT) || (process.env.NODE_ENV === "production" ? 3456 : 3457);
|
|
95
|
+
const authToken = getToken();
|
|
96
|
+
|
|
97
|
+
// Build QR codes for each remote address (skip localhost — it auto-auths).
|
|
98
|
+
// Each QR encodes the full login URL so the native iPhone Camera app can
|
|
99
|
+
// open it directly: scan → tap popup → Safari opens → auto-authenticated.
|
|
100
|
+
//
|
|
101
|
+
// If the request arrives via a public domain (reverse proxy), prefer that
|
|
102
|
+
// domain with HTTPS so the QR code works from any network.
|
|
103
|
+
const reqHost = c.req.header("Host") || "";
|
|
104
|
+
const isPublicDomain = reqHost && !reqHost.match(/^(localhost|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/);
|
|
105
|
+
|
|
106
|
+
const addresses = getAllAddresses().filter((a) => a.ip !== "localhost");
|
|
107
|
+
const qrCodes: { label: string; url: string; qrDataUrl: string }[] = [];
|
|
108
|
+
|
|
109
|
+
// If accessed via public domain, add it as the first (preferred) QR code
|
|
110
|
+
if (isPublicDomain) {
|
|
111
|
+
const domain = reqHost.replace(/:\d+$/, ""); // strip port if present
|
|
112
|
+
const loginUrl = `https://${domain}/?token=${authToken}`;
|
|
113
|
+
const qrDataUrl = await QRCode.toDataURL(loginUrl, { width: 256, margin: 2 });
|
|
114
|
+
qrCodes.push({ label: domain, url: `https://${domain}`, qrDataUrl });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Also include LAN/Tailscale addresses as fallback options
|
|
118
|
+
const lanQrCodes = await Promise.all(
|
|
119
|
+
addresses.map(async (a) => {
|
|
120
|
+
const loginUrl = `http://${a.ip}:${port}/?token=${authToken}`;
|
|
121
|
+
const qrDataUrl = await QRCode.toDataURL(loginUrl, { width: 256, margin: 2 });
|
|
122
|
+
return { label: a.label, url: `http://${a.ip}:${port}`, qrDataUrl };
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
qrCodes.push(...lanQrCodes);
|
|
126
|
+
|
|
127
|
+
return c.json({ qrCodes });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// ─── Localhost auto-auth (exempt from auth middleware) ────────────
|
|
131
|
+
// Localhost users are on the same machine as the server, so they can
|
|
132
|
+
// auto-authenticate without a token. This makes first-launch seamless.
|
|
133
|
+
|
|
134
|
+
// Check if the request comes from localhost (same machine as the server).
|
|
135
|
+
// When behind a reverse proxy (Nginx), check X-Real-IP / X-Forwarded-For
|
|
136
|
+
// headers first, since the TCP source will always be 127.0.0.1 from the proxy.
|
|
137
|
+
function isLocalhostRequest(c: { env: unknown; req: { raw: Request; header: (name: string) => string | undefined } }): boolean {
|
|
138
|
+
// If a reverse proxy set X-Real-IP, use that (this is the real client IP)
|
|
139
|
+
const realIp = c.req.header("x-real-ip");
|
|
140
|
+
if (realIp) {
|
|
141
|
+
const trimmed = realIp.trim();
|
|
142
|
+
return trimmed === "127.0.0.1" || trimmed === "::1" || trimmed === "::ffff:127.0.0.1";
|
|
143
|
+
}
|
|
144
|
+
// Fallback to TCP socket address (direct connections without proxy)
|
|
145
|
+
const bunServer = c.env as { requestIP?: (req: Request) => { address: string } | null };
|
|
146
|
+
const ip = bunServer?.requestIP?.(c.req.raw);
|
|
147
|
+
const addr = ip?.address ?? "";
|
|
148
|
+
return addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
api.get("/auth/auto", (c) => {
|
|
152
|
+
if (isLocalhostRequest(c)) {
|
|
153
|
+
const token = getToken();
|
|
154
|
+
setCookie(c, "heyhank_auth", token, {
|
|
155
|
+
path: "/",
|
|
156
|
+
httpOnly: true,
|
|
157
|
+
sameSite: "Strict",
|
|
158
|
+
maxAge: 365 * 24 * 60 * 60,
|
|
159
|
+
});
|
|
160
|
+
return c.json({ ok: true, token });
|
|
161
|
+
}
|
|
162
|
+
return c.json({ ok: false });
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// ─── Auth middleware (protects all routes below) ───────────────────
|
|
166
|
+
|
|
167
|
+
api.use("/*", async (c, next) => {
|
|
168
|
+
// Skip auth for the verify endpoint (handled above)
|
|
169
|
+
if (c.req.path === "/auth/verify") {
|
|
170
|
+
return next();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Localhost bypass — same machine as the server, always trusted
|
|
174
|
+
if (isLocalhostRequest(c)) {
|
|
175
|
+
return next();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const authHeader = c.req.header("Authorization");
|
|
179
|
+
const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
|
|
180
|
+
// Also check the heyhank_auth cookie — iframes (browser preview) can't
|
|
181
|
+
// send Authorization headers, but browsers do forward cookies automatically.
|
|
182
|
+
const cookieToken = getCookie(c, "heyhank_auth") ?? null;
|
|
183
|
+
if (!verifyToken(token) && !verifyToken(cookieToken)) {
|
|
184
|
+
return c.json({ error: "unauthorized" }, 401);
|
|
185
|
+
}
|
|
186
|
+
return next();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// ─── Auth management (protected) ──────────────────────────────────
|
|
190
|
+
|
|
191
|
+
api.get("/auth/token", (c) => {
|
|
192
|
+
return c.json({ token: getToken() });
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
api.post("/auth/regenerate", (c) => {
|
|
196
|
+
const token = regenerateToken();
|
|
197
|
+
return c.json({ token });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// ─── SDK Sessions (--sdk-url) ─────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
api.post("/sessions/create", async (c) => {
|
|
203
|
+
const body = await c.req.json().catch(() => ({}));
|
|
204
|
+
const result = await orchestrator.createSession(body);
|
|
205
|
+
if (!result.ok) {
|
|
206
|
+
return c.json({ error: result.error }, result.status as any);
|
|
207
|
+
}
|
|
208
|
+
return c.json(result.session);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// ─── SSE Session Creation (with progress streaming) ─────────────────────
|
|
212
|
+
|
|
213
|
+
api.post("/sessions/create-stream", async (c) => {
|
|
214
|
+
const body = await c.req.json().catch(() => ({}));
|
|
215
|
+
|
|
216
|
+
return streamSSE(c, async (stream) => {
|
|
217
|
+
const result = await orchestrator.createSessionStreaming(
|
|
218
|
+
body,
|
|
219
|
+
async (step, label, status, detail) => {
|
|
220
|
+
await stream.writeSSE({
|
|
221
|
+
event: "progress",
|
|
222
|
+
data: JSON.stringify({ step, label, status, detail }),
|
|
223
|
+
});
|
|
224
|
+
},
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
if (!result.ok) {
|
|
228
|
+
await stream.writeSSE({
|
|
229
|
+
event: "error",
|
|
230
|
+
data: JSON.stringify({ error: result.error }),
|
|
231
|
+
});
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
await stream.writeSSE({
|
|
236
|
+
event: "done",
|
|
237
|
+
data: JSON.stringify({
|
|
238
|
+
sessionId: result.session.sessionId,
|
|
239
|
+
state: result.session.state,
|
|
240
|
+
cwd: result.session.cwd,
|
|
241
|
+
backendType: result.session.backendType,
|
|
242
|
+
resumeSessionAt: result.session.resumeSessionAt,
|
|
243
|
+
forkSession: result.session.forkSession,
|
|
244
|
+
}),
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
api.get("/sessions", (c) => {
|
|
250
|
+
const sessions = launcher.listSessions();
|
|
251
|
+
const names = sessionNames.getAllNames();
|
|
252
|
+
const bridgeStates = wsBridge.getAllSessions();
|
|
253
|
+
const bridgeMap = new Map(bridgeStates.map((s) => [s.session_id, s]));
|
|
254
|
+
const enriched = sessions.map((s) => {
|
|
255
|
+
const bridge = bridgeMap.get(s.sessionId);
|
|
256
|
+
return {
|
|
257
|
+
...s,
|
|
258
|
+
// Bridge state is the source of truth for runtime cwd updates
|
|
259
|
+
// (notably containerized sessions mapped back to host paths).
|
|
260
|
+
cwd: bridge?.cwd || s.cwd,
|
|
261
|
+
name: names[s.sessionId] ?? s.name,
|
|
262
|
+
gitBranch: bridge?.git_branch || "",
|
|
263
|
+
gitAhead: bridge?.git_ahead || 0,
|
|
264
|
+
gitBehind: bridge?.git_behind || 0,
|
|
265
|
+
totalLinesAdded: bridge?.total_lines_added || 0,
|
|
266
|
+
totalLinesRemoved: bridge?.total_lines_removed || 0,
|
|
267
|
+
};
|
|
268
|
+
});
|
|
269
|
+
// Merge remote federation sessions
|
|
270
|
+
const remoteSessions = nodeManager.getRemoteSessions().map((rs) => ({
|
|
271
|
+
sessionId: rs.sessionId,
|
|
272
|
+
state: rs.status === "running" ? "running" : "connected",
|
|
273
|
+
model: rs.model || "",
|
|
274
|
+
cwd: rs.cwd || "",
|
|
275
|
+
name: rs.name || rs.sessionId.slice(0, 8),
|
|
276
|
+
createdAt: 0,
|
|
277
|
+
backendType: rs.backendType || "claude",
|
|
278
|
+
nodeId: rs.nodeId,
|
|
279
|
+
nodeName: rs.nodeName,
|
|
280
|
+
gitBranch: "",
|
|
281
|
+
gitAhead: 0,
|
|
282
|
+
gitBehind: 0,
|
|
283
|
+
totalLinesAdded: 0,
|
|
284
|
+
totalLinesRemoved: 0,
|
|
285
|
+
}));
|
|
286
|
+
|
|
287
|
+
return c.json([...enriched, ...remoteSessions]);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
/** Search across all sessions' message histories */
|
|
291
|
+
api.get("/sessions/search", (c) => {
|
|
292
|
+
const query = (c.req.query("q") || "").toLowerCase().trim();
|
|
293
|
+
if (!query || query.length < 2) return c.json({ results: [], query });
|
|
294
|
+
|
|
295
|
+
const bridgeStates = wsBridge.getAllSessions();
|
|
296
|
+
const names = sessionNames.getAllNames();
|
|
297
|
+
const results: Array<{
|
|
298
|
+
sessionId: string;
|
|
299
|
+
sessionName: string;
|
|
300
|
+
matches: Array<{ role: string; text: string; timestamp?: number }>;
|
|
301
|
+
}> = [];
|
|
302
|
+
|
|
303
|
+
for (const s of bridgeStates) {
|
|
304
|
+
const sessionMatches: Array<{ role: string; text: string; timestamp?: number }> = [];
|
|
305
|
+
for (const msg of s.messageHistory || []) {
|
|
306
|
+
// Search in user messages and assistant text
|
|
307
|
+
let text = "";
|
|
308
|
+
if (msg.type === "user_message") {
|
|
309
|
+
const content = (msg as Record<string, unknown>).content;
|
|
310
|
+
text = typeof content === "string" ? content : JSON.stringify(content || "");
|
|
311
|
+
} else if (msg.type === "assistant") {
|
|
312
|
+
const content = (msg as Record<string, unknown>).content;
|
|
313
|
+
if (Array.isArray(content)) {
|
|
314
|
+
text = content
|
|
315
|
+
.filter((b: Record<string, unknown>) => b.type === "text")
|
|
316
|
+
.map((b: Record<string, unknown>) => b.text || "")
|
|
317
|
+
.join(" ");
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (text.toLowerCase().includes(query)) {
|
|
321
|
+
sessionMatches.push({
|
|
322
|
+
role: msg.type === "user_message" ? "user" : "assistant",
|
|
323
|
+
text: text.slice(0, 300),
|
|
324
|
+
timestamp: (msg as Record<string, unknown>).timestamp as number | undefined,
|
|
325
|
+
});
|
|
326
|
+
if (sessionMatches.length >= 5) break; // limit per session
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (sessionMatches.length > 0) {
|
|
330
|
+
results.push({
|
|
331
|
+
sessionId: s.session_id,
|
|
332
|
+
sessionName: names[s.session_id] || s.session_id.slice(0, 8),
|
|
333
|
+
matches: sessionMatches,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
if (results.length >= 20) break; // limit total
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return c.json({ results, query });
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
api.get("/sessions/:id", (c) => {
|
|
343
|
+
const id = c.req.param("id");
|
|
344
|
+
const session = launcher.getSession(id);
|
|
345
|
+
if (!session) return c.json({ error: "Session not found" }, 404);
|
|
346
|
+
return c.json(session);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
/** Rich session status for Gemini agent monitoring — includes phase, pending permissions, recent activity */
|
|
350
|
+
api.get("/sessions/:id/agent-status", (c) => {
|
|
351
|
+
const id = c.req.param("id");
|
|
352
|
+
const session = launcher.getSession(id);
|
|
353
|
+
if (!session) return c.json({ error: "Session not found" }, 404);
|
|
354
|
+
|
|
355
|
+
const bridgeSession = wsBridge.getSession(id);
|
|
356
|
+
const phase = bridgeSession?.stateMachine?.phase || "unknown";
|
|
357
|
+
const pendingPerms = bridgeSession?.pendingPermissions
|
|
358
|
+
? Array.from(bridgeSession.pendingPermissions.values()).map((p) => ({
|
|
359
|
+
requestId: p.request_id,
|
|
360
|
+
toolName: p.tool_name,
|
|
361
|
+
description: p.description || "",
|
|
362
|
+
}))
|
|
363
|
+
: [];
|
|
364
|
+
|
|
365
|
+
// Get last few messages for context
|
|
366
|
+
const history = bridgeSession?.messageHistory || [];
|
|
367
|
+
const recentMessages = history.slice(-5).map((m) => {
|
|
368
|
+
const msg = m as Record<string, unknown>;
|
|
369
|
+
return {
|
|
370
|
+
type: msg.type || msg.subtype || "unknown",
|
|
371
|
+
text: typeof msg.markdown === "string" ? msg.markdown.slice(0, 200) : undefined,
|
|
372
|
+
tool: msg.tool_name || undefined,
|
|
373
|
+
};
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
return c.json({
|
|
377
|
+
sessionId: id,
|
|
378
|
+
state: session.state,
|
|
379
|
+
phase,
|
|
380
|
+
agentId: session.agentId || null,
|
|
381
|
+
agentName: session.agentName || null,
|
|
382
|
+
model: session.model || "unknown",
|
|
383
|
+
cwd: session.cwd,
|
|
384
|
+
needsInput: pendingPerms.length > 0,
|
|
385
|
+
pendingPermissions: pendingPerms,
|
|
386
|
+
isCompleted: phase === "terminated",
|
|
387
|
+
isWorking: phase === "streaming" || phase === "initializing" || phase === "compacting",
|
|
388
|
+
recentActivity: recentMessages,
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
api.get("/claude/sessions/discover", (c) => {
|
|
393
|
+
const limitRaw = c.req.query("limit");
|
|
394
|
+
const limit = limitRaw ? Number(limitRaw) : undefined;
|
|
395
|
+
const sessions = discoverClaudeSessions({ limit });
|
|
396
|
+
return c.json({ sessions });
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
api.get("/claude/sessions/:id/history", (c) => {
|
|
400
|
+
const sessionId = c.req.param("id");
|
|
401
|
+
const limitRaw = c.req.query("limit");
|
|
402
|
+
const cursorRaw = c.req.query("cursor");
|
|
403
|
+
const limit = limitRaw !== undefined ? Number(limitRaw) : undefined;
|
|
404
|
+
const cursor = cursorRaw !== undefined ? Number(cursorRaw) : undefined;
|
|
405
|
+
|
|
406
|
+
const page = getClaudeSessionHistoryPage({
|
|
407
|
+
sessionId,
|
|
408
|
+
limit,
|
|
409
|
+
cursor,
|
|
410
|
+
});
|
|
411
|
+
if (!page) {
|
|
412
|
+
return c.json({ error: "Claude session history not found" }, 404);
|
|
413
|
+
}
|
|
414
|
+
return c.json(page);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
api.post("/sessions/:id/editor/start", async (c) => {
|
|
418
|
+
const id = c.req.param("id");
|
|
419
|
+
const session = launcher.getSession(id);
|
|
420
|
+
if (!session) return c.json({ error: "Session not found" }, 404);
|
|
421
|
+
|
|
422
|
+
// For container sessions, try code-server inside the container first.
|
|
423
|
+
// If unavailable, fall through to host code-server with the host-mapped cwd.
|
|
424
|
+
let hostFallbackCwd = session.cwd;
|
|
425
|
+
|
|
426
|
+
if (session.containerId) {
|
|
427
|
+
const container = containerManager.getContainer(id);
|
|
428
|
+
const hasContainerCodeServer = container
|
|
429
|
+
&& containerManager.hasBinaryInContainer(container.containerId, "code-server");
|
|
430
|
+
|
|
431
|
+
if (container && hasContainerCodeServer) {
|
|
432
|
+
const editorPathSuffix = `?folder=${encodeURIComponent("/workspace")}`;
|
|
433
|
+
const portMapping = container.portMappings.find(
|
|
434
|
+
(p) => p.containerPort === VSCODE_EDITOR_CONTAINER_PORT,
|
|
435
|
+
);
|
|
436
|
+
if (!portMapping) {
|
|
437
|
+
return c.json({
|
|
438
|
+
available: false,
|
|
439
|
+
installed: true,
|
|
440
|
+
mode: "container",
|
|
441
|
+
message: "Container editor port is missing. Start a new session to enable the VS Code editor.",
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
try {
|
|
446
|
+
const alive = containerManager.isContainerAlive(container.containerId);
|
|
447
|
+
if (alive === "stopped") {
|
|
448
|
+
containerManager.startContainer(container.containerId);
|
|
449
|
+
} else if (alive === "missing") {
|
|
450
|
+
return c.json({
|
|
451
|
+
available: false,
|
|
452
|
+
installed: true,
|
|
453
|
+
mode: "container",
|
|
454
|
+
message: "Session container no longer exists. Start a new session to use the editor.",
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const startCmd = [
|
|
459
|
+
`if ! pgrep -f ${shellEscapeArg(`code-server.*--bind-addr 0.0.0.0:${VSCODE_EDITOR_CONTAINER_PORT}`)} >/dev/null 2>&1; then`,
|
|
460
|
+
`nohup code-server --auth none --disable-telemetry --bind-addr 0.0.0.0:${VSCODE_EDITOR_CONTAINER_PORT} /workspace >/tmp/heyhank-code-server.log 2>&1 &`,
|
|
461
|
+
"fi",
|
|
462
|
+
].join(" ");
|
|
463
|
+
containerManager.execInContainer(container.containerId, ["sh", "-lc", startCmd], 10_000);
|
|
464
|
+
|
|
465
|
+
// Wait for code-server to be ready (up to 5s)
|
|
466
|
+
const containerEditorUrl = `http://localhost:${portMapping.hostPort}${editorPathSuffix}`;
|
|
467
|
+
for (let i = 0; i < 25; i++) {
|
|
468
|
+
try {
|
|
469
|
+
const res = await fetch(`http://127.0.0.1:${portMapping.hostPort}/healthz`);
|
|
470
|
+
if (res.ok || res.status === 302 || res.status === 200) break;
|
|
471
|
+
} catch {
|
|
472
|
+
// not ready yet
|
|
473
|
+
}
|
|
474
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return c.json({
|
|
478
|
+
available: true,
|
|
479
|
+
installed: true,
|
|
480
|
+
mode: "container",
|
|
481
|
+
url: containerEditorUrl,
|
|
482
|
+
});
|
|
483
|
+
} catch (e) {
|
|
484
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
485
|
+
return c.json({
|
|
486
|
+
available: false,
|
|
487
|
+
installed: true,
|
|
488
|
+
mode: "container",
|
|
489
|
+
message: `Failed to start VS Code editor in container: ${message}`,
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Container doesn't have code-server — fall through to host code-server
|
|
495
|
+
// using the host-mapped workspace path
|
|
496
|
+
if (container) {
|
|
497
|
+
hostFallbackCwd = container.hostCwd;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const hostCodeServer = resolveBinary("code-server");
|
|
502
|
+
if (!hostCodeServer) {
|
|
503
|
+
return c.json({
|
|
504
|
+
available: false,
|
|
505
|
+
installed: false,
|
|
506
|
+
mode: "host",
|
|
507
|
+
message: "VS Code editor is not installed. Install it with: brew install code-server",
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const editorPathSuffix = `?folder=${encodeURIComponent(hostFallbackCwd)}`;
|
|
512
|
+
|
|
513
|
+
try {
|
|
514
|
+
const logFile = join(HEYHANK_HOME, "code-server-host.log");
|
|
515
|
+
const startCmd = [
|
|
516
|
+
`if ! pgrep -f ${shellEscapeArg(`code-server.*--bind-addr 127.0.0.1:${VSCODE_EDITOR_HOST_PORT}`)} >/dev/null 2>&1; then`,
|
|
517
|
+
`nohup ${shellEscapeArg(hostCodeServer)} --auth none --disable-telemetry --bind-addr 127.0.0.1:${VSCODE_EDITOR_HOST_PORT} ${shellEscapeArg(hostFallbackCwd)} >> ${shellEscapeArg(logFile)} 2>&1 &`,
|
|
518
|
+
"fi",
|
|
519
|
+
].join(" ");
|
|
520
|
+
const startHostCmd = `mkdir -p ${shellEscapeArg(HEYHANK_HOME)} && ${startCmd}`;
|
|
521
|
+
execSync(startHostCmd, { encoding: "utf-8", timeout: 10_000 });
|
|
522
|
+
|
|
523
|
+
// Wait for code-server to be ready (up to 5s)
|
|
524
|
+
const editorUrl = `http://localhost:${VSCODE_EDITOR_HOST_PORT}${editorPathSuffix}`;
|
|
525
|
+
for (let i = 0; i < 25; i++) {
|
|
526
|
+
try {
|
|
527
|
+
const res = await fetch(`http://127.0.0.1:${VSCODE_EDITOR_HOST_PORT}/healthz`);
|
|
528
|
+
if (res.ok || res.status === 302 || res.status === 200) break;
|
|
529
|
+
} catch {
|
|
530
|
+
// not ready yet
|
|
531
|
+
}
|
|
532
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return c.json({
|
|
536
|
+
available: true,
|
|
537
|
+
installed: true,
|
|
538
|
+
mode: "host",
|
|
539
|
+
url: editorUrl,
|
|
540
|
+
});
|
|
541
|
+
} catch (e) {
|
|
542
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
543
|
+
return c.json({
|
|
544
|
+
available: false,
|
|
545
|
+
installed: true,
|
|
546
|
+
mode: "host",
|
|
547
|
+
message: `Failed to start VS Code editor: ${message}`,
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
// ── Browser preview ──────────────────────────────────────────────────────
|
|
553
|
+
|
|
554
|
+
api.post("/sessions/:id/browser/start", async (c) => {
|
|
555
|
+
const id = c.req.param("id");
|
|
556
|
+
const body = await c.req.json().catch(() => ({} as { url?: string }));
|
|
557
|
+
const session = launcher.getSession(id);
|
|
558
|
+
if (!session) return c.json({ error: "Session not found" }, 404);
|
|
559
|
+
|
|
560
|
+
if (!session.containerId) {
|
|
561
|
+
return c.json({
|
|
562
|
+
available: true,
|
|
563
|
+
mode: "host" as const,
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const container = containerManager.getContainer(id);
|
|
568
|
+
if (!container) {
|
|
569
|
+
return c.json({
|
|
570
|
+
available: false,
|
|
571
|
+
mode: "container" as const,
|
|
572
|
+
message: "Container not found for this session.",
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const alive = containerManager.isContainerAlive(container.containerId);
|
|
577
|
+
if (alive === "stopped") {
|
|
578
|
+
containerManager.startContainer(container.containerId);
|
|
579
|
+
} else if (alive === "missing") {
|
|
580
|
+
return c.json({
|
|
581
|
+
available: false,
|
|
582
|
+
mode: "container" as const,
|
|
583
|
+
message: "Session container no longer exists.",
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const portMapping = container.portMappings.find(
|
|
588
|
+
(p) => p.containerPort === NOVNC_CONTAINER_PORT,
|
|
589
|
+
);
|
|
590
|
+
if (!portMapping) {
|
|
591
|
+
return c.json({
|
|
592
|
+
available: false,
|
|
593
|
+
mode: "container" as const,
|
|
594
|
+
message: "Browser preview port not mapped. Start a new session to enable browser preview.",
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const hasXvfb = containerManager.hasBinaryInContainer(container.containerId, "Xvfb");
|
|
599
|
+
const hasWebsockify = containerManager.hasBinaryInContainer(container.containerId, "websockify");
|
|
600
|
+
if (!hasXvfb || !hasWebsockify) {
|
|
601
|
+
return c.json({
|
|
602
|
+
available: false,
|
|
603
|
+
mode: "container" as const,
|
|
604
|
+
message: "Browser preview requires Xvfb and noVNC in the container image. Rebuild with the latest the-companion image.",
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
try {
|
|
609
|
+
// Start display stack (idempotent — guarded by pgrep)
|
|
610
|
+
const startScript = [
|
|
611
|
+
"export DISPLAY=:99",
|
|
612
|
+
'if ! pgrep -f "Xvfb :99" >/dev/null 2>&1; then',
|
|
613
|
+
" Xvfb :99 -screen 0 1280x720x24 -ac -nolisten tcp &",
|
|
614
|
+
" sleep 0.5",
|
|
615
|
+
" fluxbox -display :99 &>/dev/null &",
|
|
616
|
+
" sleep 0.3",
|
|
617
|
+
" x11vnc -display :99 -forever -shared -nopw -rfbport 5900 -noxdamage -wait 20 &>/dev/null &",
|
|
618
|
+
" sleep 0.3",
|
|
619
|
+
" websockify --web /usr/share/novnc/ 6080 localhost:5900 &>/dev/null &",
|
|
620
|
+
" sleep 1.0",
|
|
621
|
+
"fi",
|
|
622
|
+
].join("\n");
|
|
623
|
+
|
|
624
|
+
await containerManager.execInContainerAsync(
|
|
625
|
+
container.containerId,
|
|
626
|
+
["sh", "-c", startScript],
|
|
627
|
+
{ timeout: 15_000 },
|
|
628
|
+
);
|
|
629
|
+
|
|
630
|
+
// Optionally launch Chromium to a URL (validate scheme if provided)
|
|
631
|
+
let targetUrl = "about:blank";
|
|
632
|
+
if (body.url && typeof body.url === "string") {
|
|
633
|
+
try {
|
|
634
|
+
const parsed = new URL(body.url);
|
|
635
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
636
|
+
return c.json({
|
|
637
|
+
available: false,
|
|
638
|
+
mode: "container" as const,
|
|
639
|
+
message: "Only http:// and https:// URLs are allowed.",
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
targetUrl = body.url;
|
|
643
|
+
} catch {
|
|
644
|
+
return c.json({
|
|
645
|
+
available: false,
|
|
646
|
+
mode: "container" as const,
|
|
647
|
+
message: "Invalid URL provided.",
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
const launchChrome = [
|
|
652
|
+
"export DISPLAY=:99",
|
|
653
|
+
'if ! pgrep -f "chromium.*--user-data-dir=/tmp/heyhank-chrome" >/dev/null 2>&1; then',
|
|
654
|
+
` nohup chromium --no-sandbox --disable-gpu --disable-dev-shm-usage --user-data-dir=/tmp/heyhank-chrome --window-size=1280,720 --window-position=0,0 ${shellEscapeArg(targetUrl)} &>/dev/null &`,
|
|
655
|
+
"fi",
|
|
656
|
+
].join("\n");
|
|
657
|
+
|
|
658
|
+
await containerManager.execInContainerAsync(
|
|
659
|
+
container.containerId,
|
|
660
|
+
["sh", "-c", launchChrome],
|
|
661
|
+
{ timeout: 10_000 },
|
|
662
|
+
);
|
|
663
|
+
|
|
664
|
+
// Wait for noVNC to be ready (up to 10s)
|
|
665
|
+
let noVncReady = false;
|
|
666
|
+
for (let i = 0; i < 50; i++) {
|
|
667
|
+
try {
|
|
668
|
+
const res = await fetch(`http://127.0.0.1:${portMapping.hostPort}/`);
|
|
669
|
+
if (res.ok || res.status === 200) {
|
|
670
|
+
noVncReady = true;
|
|
671
|
+
break;
|
|
672
|
+
}
|
|
673
|
+
} catch {
|
|
674
|
+
// not ready yet
|
|
675
|
+
}
|
|
676
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (!noVncReady) {
|
|
680
|
+
return c.json({
|
|
681
|
+
available: false,
|
|
682
|
+
mode: "container" as const,
|
|
683
|
+
message: "Browser preview timed out waiting for noVNC to start.",
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const proxyBase = `/api/sessions/${encodeURIComponent(id)}/browser/proxy`;
|
|
688
|
+
const noVncUrl = `${proxyBase}/vnc.html?autoconnect=true&resize=scale&path=ws/novnc/${encodeURIComponent(id)}`;
|
|
689
|
+
|
|
690
|
+
return c.json({
|
|
691
|
+
available: true,
|
|
692
|
+
mode: "container" as const,
|
|
693
|
+
url: noVncUrl,
|
|
694
|
+
});
|
|
695
|
+
} catch (e) {
|
|
696
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
697
|
+
return c.json({
|
|
698
|
+
available: false,
|
|
699
|
+
mode: "container" as const,
|
|
700
|
+
message: `Failed to start browser preview: ${message}`,
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
api.post("/sessions/:id/browser/navigate", async (c) => {
|
|
706
|
+
const id = c.req.param("id");
|
|
707
|
+
const body = await c.req.json().catch(() => ({} as { url?: string }));
|
|
708
|
+
const session = launcher.getSession(id);
|
|
709
|
+
if (!session) return c.json({ error: "Session not found" }, 404);
|
|
710
|
+
if (!session.containerId) return c.json({ error: "Not a container session" }, 400);
|
|
711
|
+
|
|
712
|
+
const url = body.url;
|
|
713
|
+
if (!url || typeof url !== "string") return c.json({ error: "url is required" }, 400);
|
|
714
|
+
|
|
715
|
+
// Validate URL scheme — only allow http/https to prevent file:// access
|
|
716
|
+
try {
|
|
717
|
+
const parsed = new URL(url);
|
|
718
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
719
|
+
return c.json({ error: "Only http:// and https:// URLs are allowed" }, 400);
|
|
720
|
+
}
|
|
721
|
+
} catch {
|
|
722
|
+
return c.json({ error: "Invalid URL" }, 400);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const container = containerManager.getContainer(id);
|
|
726
|
+
if (!container) return c.json({ error: "Container not found" }, 404);
|
|
727
|
+
|
|
728
|
+
try {
|
|
729
|
+
// Use xdotool to send the URL to the existing Chromium window's address bar
|
|
730
|
+
// instead of spawning a new Chromium process each time
|
|
731
|
+
const navScript = [
|
|
732
|
+
"export DISPLAY=:99",
|
|
733
|
+
// Focus the Chromium window and navigate via keyboard shortcut
|
|
734
|
+
'xdotool search --onlyvisible --name "Chromium" windowactivate --sync key --clearmodifiers ctrl+l',
|
|
735
|
+
"sleep 0.1",
|
|
736
|
+
`xdotool type --clearmodifiers ${shellEscapeArg(url)}`,
|
|
737
|
+
"xdotool key --clearmodifiers Return",
|
|
738
|
+
].join(" && ");
|
|
739
|
+
await containerManager.execInContainerAsync(
|
|
740
|
+
container.containerId,
|
|
741
|
+
["sh", "-c", navScript],
|
|
742
|
+
{ timeout: 10_000 },
|
|
743
|
+
);
|
|
744
|
+
return c.json({ ok: true, url });
|
|
745
|
+
} catch {
|
|
746
|
+
return c.json({ error: "Navigation failed" }, 500);
|
|
747
|
+
}
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
// HTTP proxy for noVNC static files — serves through HeyHank's port
|
|
751
|
+
api.get("/sessions/:id/browser/proxy/*", async (c) => {
|
|
752
|
+
const id = c.req.param("id");
|
|
753
|
+
const session = launcher.getSession(id);
|
|
754
|
+
if (!session) return c.json({ error: "Session not found" }, 404);
|
|
755
|
+
if (!session.containerId) return c.json({ error: "Not a container session" }, 400);
|
|
756
|
+
|
|
757
|
+
const container = containerManager.getContainer(id);
|
|
758
|
+
if (!container) return c.json({ error: "Container not found" }, 404);
|
|
759
|
+
|
|
760
|
+
const portMapping = container.portMappings.find(
|
|
761
|
+
(p) => p.containerPort === NOVNC_CONTAINER_PORT,
|
|
762
|
+
);
|
|
763
|
+
if (!portMapping) return c.json({ error: "Browser preview port not mapped" }, 400);
|
|
764
|
+
|
|
765
|
+
// Extract the wildcard path after /browser/proxy/
|
|
766
|
+
const fullPath = c.req.path;
|
|
767
|
+
const proxyPrefix = `/api/sessions/${id}/browser/proxy/`;
|
|
768
|
+
const subPath = fullPath.startsWith(proxyPrefix) ? fullPath.slice(proxyPrefix.length) : "";
|
|
769
|
+
|
|
770
|
+
// Block path traversal (defense-in-depth)
|
|
771
|
+
if (subPath.includes("..")) {
|
|
772
|
+
return c.json({ error: "Invalid path" }, 400);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
const queryString = new URL(c.req.url).search;
|
|
776
|
+
|
|
777
|
+
try {
|
|
778
|
+
const targetUrl = `http://127.0.0.1:${portMapping.hostPort}/${subPath}${queryString}`;
|
|
779
|
+
const upstream = await fetch(targetUrl);
|
|
780
|
+
const headers = new Headers();
|
|
781
|
+
const ct = upstream.headers.get("content-type");
|
|
782
|
+
if (ct) headers.set("Content-Type", ct);
|
|
783
|
+
const cl = upstream.headers.get("content-length");
|
|
784
|
+
if (cl) headers.set("Content-Length", cl);
|
|
785
|
+
return new Response(upstream.body, {
|
|
786
|
+
status: upstream.status,
|
|
787
|
+
headers,
|
|
788
|
+
});
|
|
789
|
+
} catch {
|
|
790
|
+
return c.json({ error: "Proxy failed: upstream unreachable" }, 502);
|
|
791
|
+
}
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
// HTTP proxy for host browser preview — proxies localhost requests through HeyHank’s port
|
|
796
|
+
const HOP_BY_HOP = new Set(["connection", "keep-alive", "transfer-encoding", "upgrade", "proxy-connection", "te", "trailer"]);
|
|
797
|
+
api.all("/sessions/:id/browser/host-proxy/:port/*", async (c) => {
|
|
798
|
+
const id = c.req.param("id");
|
|
799
|
+
const session = launcher.getSession(id);
|
|
800
|
+
if (!session) return c.json({ error: "Session not found" }, 404);
|
|
801
|
+
|
|
802
|
+
const portStr = c.req.param("port");
|
|
803
|
+
const portNum = parseInt(portStr, 10);
|
|
804
|
+
if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
|
|
805
|
+
return c.json({ error: "Invalid port" }, 400);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// Block well-known sensitive service ports to limit SSRF surface area
|
|
809
|
+
const BLOCKED_PORTS = new Set([22, 23, 25, 110, 143, 3306, 5432, 6379, 27017, 11211]);
|
|
810
|
+
const serverPort = port || (process.env.NODE_ENV === "production" ? 3456 : 3457);
|
|
811
|
+
if (portNum === serverPort || BLOCKED_PORTS.has(portNum)) {
|
|
812
|
+
return c.json({ error: "Port not allowed" }, 400);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// Reconstruct path from wildcard — only take path, query comes separately
|
|
816
|
+
const fullPath = c.req.path;
|
|
817
|
+
const proxyPrefix = `/api/sessions/${id}/browser/host-proxy/${portNum}/`;
|
|
818
|
+
const subPath = fullPath.startsWith(proxyPrefix) ? fullPath.slice(proxyPrefix.length) : "";
|
|
819
|
+
|
|
820
|
+
// Block path traversal (Hono decodes %2e%2e before c.req.path)
|
|
821
|
+
if (subPath.includes("..")) {
|
|
822
|
+
return c.json({ error: "Invalid path" }, 400);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const queryString = new URL(c.req.url).search;
|
|
826
|
+
|
|
827
|
+
const controller = new AbortController();
|
|
828
|
+
const timeout = setTimeout(() => controller.abort(), 15_000);
|
|
829
|
+
try {
|
|
830
|
+
const targetUrl = `http://127.0.0.1:${portNum}/${subPath}${queryString}`;
|
|
831
|
+
const upstream = await fetch(targetUrl, {
|
|
832
|
+
method: c.req.method,
|
|
833
|
+
headers: { "accept": c.req.header("accept") || "*/*" },
|
|
834
|
+
body: ["GET", "HEAD"].includes(c.req.method) ? undefined : c.req.raw.body,
|
|
835
|
+
redirect: "follow",
|
|
836
|
+
signal: controller.signal,
|
|
837
|
+
});
|
|
838
|
+
clearTimeout(timeout);
|
|
839
|
+
// Forward response headers, stripping hop-by-hop headers
|
|
840
|
+
const headers = new Headers();
|
|
841
|
+
upstream.headers.forEach((value, key) => {
|
|
842
|
+
if (!HOP_BY_HOP.has(key.toLowerCase())) {
|
|
843
|
+
headers.set(key, value);
|
|
844
|
+
}
|
|
845
|
+
});
|
|
846
|
+
return new Response(upstream.body, {
|
|
847
|
+
status: upstream.status,
|
|
848
|
+
headers,
|
|
849
|
+
});
|
|
850
|
+
} catch {
|
|
851
|
+
clearTimeout(timeout);
|
|
852
|
+
return c.json({ error: "Proxy failed: upstream unreachable" }, 502);
|
|
853
|
+
}
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
api.patch("/sessions/:id/name", async (c) => {
|
|
857
|
+
const id = c.req.param("id");
|
|
858
|
+
const body = await c.req.json().catch(() => ({}));
|
|
859
|
+
if (typeof body.name !== "string" || !body.name.trim()) {
|
|
860
|
+
return c.json({ error: "name is required" }, 400);
|
|
861
|
+
}
|
|
862
|
+
const session = launcher.getSession(id);
|
|
863
|
+
if (!session) return c.json({ error: "Session not found" }, 404);
|
|
864
|
+
sessionNames.setName(id, body.name.trim());
|
|
865
|
+
wsBridge.broadcastNameUpdate(id, body.name.trim());
|
|
866
|
+
return c.json({ ok: true, name: body.name.trim() });
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
api.post("/sessions/:id/kill", async (c) => {
|
|
870
|
+
const id = c.req.param("id");
|
|
871
|
+
const result = await orchestrator.killSession(id);
|
|
872
|
+
if (!result.ok) return c.json({ error: "Session not found or already exited" }, 404);
|
|
873
|
+
return c.json({ ok: true });
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
api.post("/sessions/:id/relaunch", async (c) => {
|
|
877
|
+
const id = c.req.param("id");
|
|
878
|
+
const result = await orchestrator.relaunchSession(id);
|
|
879
|
+
if (!result.ok) {
|
|
880
|
+
const status = result.error?.includes("not found") || result.error?.includes("Session not found") ? 404 : 503;
|
|
881
|
+
return c.json({ error: result.error || "Relaunch failed" }, status);
|
|
882
|
+
}
|
|
883
|
+
return c.json({ ok: true });
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
// Kill a background process spawned by a session
|
|
887
|
+
api.post("/sessions/:id/processes/:taskId/kill", async (c) => {
|
|
888
|
+
const sessionId = c.req.param("id");
|
|
889
|
+
const taskId = c.req.param("taskId");
|
|
890
|
+
|
|
891
|
+
// Validate taskId to prevent command injection (hex string from Claude Code)
|
|
892
|
+
if (!/^[a-f0-9]+$/i.test(taskId)) {
|
|
893
|
+
return c.json({ error: "Invalid task ID format" }, 400);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
const session = launcher.getSession(sessionId);
|
|
897
|
+
if (!session) return c.json({ error: "Session not found" }, 404);
|
|
898
|
+
if (!session.pid) return c.json({ error: "Session PID unknown" }, 503);
|
|
899
|
+
|
|
900
|
+
try {
|
|
901
|
+
const { execFileSync } = await import("node:child_process");
|
|
902
|
+
// The taskId appears in the output file path of the background process,
|
|
903
|
+
// so pkill -f matches it reliably.
|
|
904
|
+
// Use execFileSync (array form) to avoid shell injection — taskId is passed
|
|
905
|
+
// as an argument, never interpolated into a shell string.
|
|
906
|
+
if (session.containerId) {
|
|
907
|
+
containerManager.execInContainer(
|
|
908
|
+
session.containerId,
|
|
909
|
+
["pkill", "-f", taskId],
|
|
910
|
+
5_000,
|
|
911
|
+
);
|
|
912
|
+
} else {
|
|
913
|
+
try {
|
|
914
|
+
execFileSync("pkill", ["-f", taskId], {
|
|
915
|
+
timeout: 5_000,
|
|
916
|
+
encoding: "utf-8",
|
|
917
|
+
});
|
|
918
|
+
} catch {
|
|
919
|
+
// pkill returns non-zero when no processes matched — that's fine
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
return c.json({ ok: true, taskId });
|
|
923
|
+
} catch (e) {
|
|
924
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
925
|
+
return c.json({ error: `Kill failed: ${msg}` }, 500);
|
|
926
|
+
}
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
// Kill all background processes for a session
|
|
930
|
+
api.post("/sessions/:id/processes/kill-all", async (c) => {
|
|
931
|
+
const sessionId = c.req.param("id");
|
|
932
|
+
const body = await c.req.json().catch(() => ({} as { taskIds?: string[] }));
|
|
933
|
+
const taskIds = Array.isArray(body.taskIds) ? body.taskIds : [];
|
|
934
|
+
|
|
935
|
+
const session = launcher.getSession(sessionId);
|
|
936
|
+
if (!session) return c.json({ error: "Session not found" }, 404);
|
|
937
|
+
if (!session.pid) return c.json({ error: "Session PID unknown" }, 503);
|
|
938
|
+
|
|
939
|
+
const results: { taskId: string; ok: boolean; error?: string }[] = [];
|
|
940
|
+
const { execSync } = await import("node:child_process");
|
|
941
|
+
|
|
942
|
+
for (const taskId of taskIds) {
|
|
943
|
+
if (!/^[a-f0-9]+$/i.test(taskId)) {
|
|
944
|
+
results.push({ taskId, ok: false, error: "Invalid task ID" });
|
|
945
|
+
continue;
|
|
946
|
+
}
|
|
947
|
+
try {
|
|
948
|
+
if (session.containerId) {
|
|
949
|
+
containerManager.execInContainer(
|
|
950
|
+
session.containerId,
|
|
951
|
+
["sh", "-c", `pkill -f ${shellEscapeArg(taskId)} 2>/dev/null; true`],
|
|
952
|
+
5_000,
|
|
953
|
+
);
|
|
954
|
+
} else {
|
|
955
|
+
execSync(`pkill -f ${shellEscapeArg(taskId)} 2>/dev/null; true`, {
|
|
956
|
+
timeout: 5_000,
|
|
957
|
+
encoding: "utf-8",
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
results.push({ taskId, ok: true });
|
|
961
|
+
} catch (e) {
|
|
962
|
+
results.push({ taskId, ok: false, error: e instanceof Error ? e.message : String(e) });
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
return c.json({ ok: true, results });
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
// Scan for dev-related processes listening on TCP ports
|
|
970
|
+
const DEV_COMMANDS = new Set([
|
|
971
|
+
"node", "bun", "deno", "ts-node", "tsx",
|
|
972
|
+
"python", "python3", "uvicorn", "gunicorn", "flask",
|
|
973
|
+
"ruby", "rails", "puma",
|
|
974
|
+
"go", "air",
|
|
975
|
+
"java", "gradle", "mvn",
|
|
976
|
+
"cargo",
|
|
977
|
+
"php", "php-fpm",
|
|
978
|
+
"dotnet",
|
|
979
|
+
"vite", "next", "nuxt", "remix", "astro",
|
|
980
|
+
"webpack", "esbuild", "rollup", "parcel",
|
|
981
|
+
"tsc",
|
|
982
|
+
]);
|
|
983
|
+
// System/IDE processes to exclude even if they listen on a port
|
|
984
|
+
const EXCLUDE_COMMANDS = new Set([
|
|
985
|
+
"launchd", "mDNSResponder", "rapportd", "systemd",
|
|
986
|
+
"sshd", "cupsd", "httpd", "nginx", "postgres", "mysqld",
|
|
987
|
+
"Cursor", "Code", "Electron", "WindowServer", "BetterDisplay",
|
|
988
|
+
"com.docker", "Docker", "docker-proxy", "vpnkit",
|
|
989
|
+
"Dropbox", "Creative Cloud", "zoom.us",
|
|
990
|
+
"ControlCenter", "Finder", "loginwindow", "SystemUIServer",
|
|
991
|
+
]);
|
|
992
|
+
|
|
993
|
+
function parseLsofCwd(raw: string): string | undefined {
|
|
994
|
+
// `lsof -Fn` emits records like:
|
|
995
|
+
// p1234\nfcwd\nn/Users/me/project\n
|
|
996
|
+
const match = raw.match(/^n(.+)$/m);
|
|
997
|
+
const cwd = match?.[1]?.trim();
|
|
998
|
+
return cwd || undefined;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function parsePsStartTime(raw: string): number | undefined {
|
|
1002
|
+
const text = raw.trim();
|
|
1003
|
+
if (!text) return undefined;
|
|
1004
|
+
const ts = Date.parse(text);
|
|
1005
|
+
if (!Number.isFinite(ts)) return undefined;
|
|
1006
|
+
return ts;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
api.get("/sessions/:id/processes/system", async (c) => {
|
|
1010
|
+
const sessionId = c.req.param("id");
|
|
1011
|
+
const session = launcher.getSession(sessionId);
|
|
1012
|
+
if (!session) return c.json({ error: "Session not found" }, 404);
|
|
1013
|
+
|
|
1014
|
+
try {
|
|
1015
|
+
let raw: string;
|
|
1016
|
+
if (session.containerId) {
|
|
1017
|
+
raw = containerManager.execInContainer(
|
|
1018
|
+
session.containerId,
|
|
1019
|
+
["sh", "-c", "lsof -iTCP -sTCP:LISTEN -P -n 2>/dev/null || ss -tlnp 2>/dev/null || true"],
|
|
1020
|
+
5_000,
|
|
1021
|
+
);
|
|
1022
|
+
} else {
|
|
1023
|
+
raw = execSync("lsof -iTCP -sTCP:LISTEN -P -n 2>/dev/null || true", {
|
|
1024
|
+
timeout: 5_000,
|
|
1025
|
+
encoding: "utf-8",
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// Parse lsof output: COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
|
|
1030
|
+
const lines = raw.trim().split("\n").slice(1); // skip header
|
|
1031
|
+
const pidMap = new Map<number, { command: string; ports: Set<number> }>();
|
|
1032
|
+
|
|
1033
|
+
for (const line of lines) {
|
|
1034
|
+
const parts = line.trim().split(/\s+/);
|
|
1035
|
+
if (parts.length < 9) continue;
|
|
1036
|
+
const command = parts[0];
|
|
1037
|
+
const pid = parseInt(parts[1], 10);
|
|
1038
|
+
if (isNaN(pid)) continue;
|
|
1039
|
+
if (EXCLUDE_COMMANDS.has(command)) continue;
|
|
1040
|
+
|
|
1041
|
+
// macOS lsof NAME ends like `TCP *:3000 (LISTEN)`, so the final token is
|
|
1042
|
+
// often `(LISTEN)` rather than the address. Parse from the full line.
|
|
1043
|
+
const portMatch = line.match(/:(\d+)\s+\(LISTEN\)\s*$/) ?? line.match(/:(\d+)\s*$/);
|
|
1044
|
+
if (!portMatch) continue;
|
|
1045
|
+
const port = parseInt(portMatch[1], 10);
|
|
1046
|
+
|
|
1047
|
+
const existing = pidMap.get(pid);
|
|
1048
|
+
if (existing) {
|
|
1049
|
+
existing.ports.add(port);
|
|
1050
|
+
} else {
|
|
1051
|
+
pidMap.set(pid, { command, ports: new Set([port]) });
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// Get full command line for each PID
|
|
1056
|
+
const processes: {
|
|
1057
|
+
pid: number;
|
|
1058
|
+
command: string;
|
|
1059
|
+
fullCommand: string;
|
|
1060
|
+
ports: number[];
|
|
1061
|
+
cwd?: string;
|
|
1062
|
+
startedAt?: number;
|
|
1063
|
+
}[] = [];
|
|
1064
|
+
|
|
1065
|
+
for (const [pid, info] of pidMap) {
|
|
1066
|
+
// Skip if command isn't dev-related (check both exact name and prefix)
|
|
1067
|
+
const lowerCmd = info.command.toLowerCase();
|
|
1068
|
+
const isDev = DEV_COMMANDS.has(lowerCmd)
|
|
1069
|
+
|| DEV_COMMANDS.has(info.command)
|
|
1070
|
+
|| [...DEV_COMMANDS].some((d) => lowerCmd.startsWith(d));
|
|
1071
|
+
|
|
1072
|
+
if (!isDev) continue;
|
|
1073
|
+
|
|
1074
|
+
let fullCommand = info.command;
|
|
1075
|
+
let cwd: string | undefined;
|
|
1076
|
+
let startedAt: number | undefined;
|
|
1077
|
+
try {
|
|
1078
|
+
if (session.containerId) {
|
|
1079
|
+
fullCommand = containerManager.execInContainer(
|
|
1080
|
+
session.containerId,
|
|
1081
|
+
["ps", "-p", String(pid), "-o", "args="],
|
|
1082
|
+
2_000,
|
|
1083
|
+
).trim();
|
|
1084
|
+
} else {
|
|
1085
|
+
fullCommand = execSync(`ps -p ${pid} -o args= 2>/dev/null || true`, {
|
|
1086
|
+
timeout: 2_000,
|
|
1087
|
+
encoding: "utf-8",
|
|
1088
|
+
}).trim();
|
|
1089
|
+
}
|
|
1090
|
+
} catch {
|
|
1091
|
+
// Fall back to short command name
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
try {
|
|
1095
|
+
if (session.containerId) {
|
|
1096
|
+
const cwdRaw = containerManager.execInContainer(
|
|
1097
|
+
session.containerId,
|
|
1098
|
+
["sh", "-c", `readlink /proc/${pid}/cwd 2>/dev/null || true`],
|
|
1099
|
+
2_000,
|
|
1100
|
+
).trim();
|
|
1101
|
+
cwd = cwdRaw || undefined;
|
|
1102
|
+
} else {
|
|
1103
|
+
const cwdRaw = execSync(`lsof -a -p ${pid} -d cwd -Fn 2>/dev/null || true`, {
|
|
1104
|
+
timeout: 2_000,
|
|
1105
|
+
encoding: "utf-8",
|
|
1106
|
+
});
|
|
1107
|
+
cwd = parseLsofCwd(cwdRaw);
|
|
1108
|
+
}
|
|
1109
|
+
} catch {
|
|
1110
|
+
// Best-effort only
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
try {
|
|
1114
|
+
if (session.containerId) {
|
|
1115
|
+
const startRaw = containerManager.execInContainer(
|
|
1116
|
+
session.containerId,
|
|
1117
|
+
["sh", "-c", `ps -p ${pid} -o lstart= 2>/dev/null || true`],
|
|
1118
|
+
2_000,
|
|
1119
|
+
);
|
|
1120
|
+
startedAt = parsePsStartTime(startRaw);
|
|
1121
|
+
} else {
|
|
1122
|
+
const startRaw = execSync(`ps -p ${pid} -o lstart= 2>/dev/null || true`, {
|
|
1123
|
+
timeout: 2_000,
|
|
1124
|
+
encoding: "utf-8",
|
|
1125
|
+
});
|
|
1126
|
+
startedAt = parsePsStartTime(startRaw);
|
|
1127
|
+
}
|
|
1128
|
+
} catch {
|
|
1129
|
+
// Best-effort only
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
processes.push({
|
|
1133
|
+
pid,
|
|
1134
|
+
command: info.command,
|
|
1135
|
+
fullCommand: fullCommand || info.command,
|
|
1136
|
+
ports: [...info.ports].sort((a, b) => a - b),
|
|
1137
|
+
cwd,
|
|
1138
|
+
startedAt,
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// Sort by port (lowest first)
|
|
1143
|
+
processes.sort((a, b) => (a.ports[0] || 0) - (b.ports[0] || 0));
|
|
1144
|
+
|
|
1145
|
+
return c.json({ ok: true, processes });
|
|
1146
|
+
} catch (e) {
|
|
1147
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1148
|
+
return c.json({ error: `Scan failed: ${msg}` }, 500);
|
|
1149
|
+
}
|
|
1150
|
+
});
|
|
1151
|
+
|
|
1152
|
+
// Kill a system process by PID
|
|
1153
|
+
api.post("/sessions/:id/processes/system/:pid/kill", async (c) => {
|
|
1154
|
+
const sessionId = c.req.param("id");
|
|
1155
|
+
const pidStr = c.req.param("pid");
|
|
1156
|
+
const pid = parseInt(pidStr, 10);
|
|
1157
|
+
|
|
1158
|
+
if (isNaN(pid) || pid <= 0) {
|
|
1159
|
+
return c.json({ error: "Invalid PID" }, 400);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
const session = launcher.getSession(sessionId);
|
|
1163
|
+
if (!session) return c.json({ error: "Session not found" }, 404);
|
|
1164
|
+
|
|
1165
|
+
// Safety: don't allow killing the HeyHank server or Claude CLI process itself
|
|
1166
|
+
if (pid === process.pid) {
|
|
1167
|
+
return c.json({ error: "Cannot kill the HeyHank server" }, 403);
|
|
1168
|
+
}
|
|
1169
|
+
if (session.pid === pid) {
|
|
1170
|
+
return c.json({ error: "Use the session kill endpoint to terminate Claude" }, 403);
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
try {
|
|
1174
|
+
if (session.containerId) {
|
|
1175
|
+
containerManager.execInContainer(
|
|
1176
|
+
session.containerId,
|
|
1177
|
+
["kill", "-TERM", String(pid)],
|
|
1178
|
+
5_000,
|
|
1179
|
+
);
|
|
1180
|
+
} else {
|
|
1181
|
+
process.kill(pid, "SIGTERM");
|
|
1182
|
+
}
|
|
1183
|
+
return c.json({ ok: true, pid });
|
|
1184
|
+
} catch (e) {
|
|
1185
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1186
|
+
return c.json({ error: `Kill failed: ${msg}` }, 500);
|
|
1187
|
+
}
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
api.delete("/sessions/:id", async (c) => {
|
|
1191
|
+
const id = c.req.param("id");
|
|
1192
|
+
const result = await orchestrator.deleteSession(id);
|
|
1193
|
+
return c.json({ ok: true, worktree: result.worktree });
|
|
1194
|
+
});
|
|
1195
|
+
|
|
1196
|
+
api.get("/sessions/:id/archive-info", (c) => {
|
|
1197
|
+
return c.json({ hasLinkedIssue: false, issueNotDone: false });
|
|
1198
|
+
});
|
|
1199
|
+
|
|
1200
|
+
api.post("/sessions/:id/archive", async (c) => {
|
|
1201
|
+
const id = c.req.param("id");
|
|
1202
|
+
const body = await c.req.json().catch(() => ({}));
|
|
1203
|
+
const result = await orchestrator.archiveSession(id, { force: body.force });
|
|
1204
|
+
return c.json({ ok: true, worktree: result.worktree });
|
|
1205
|
+
});
|
|
1206
|
+
|
|
1207
|
+
api.post("/sessions/:id/unarchive", (c) => {
|
|
1208
|
+
const id = c.req.param("id");
|
|
1209
|
+
orchestrator.unarchiveSession(id);
|
|
1210
|
+
return c.json({ ok: true });
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
// ─── Recording Management ──────────────────────────────────
|
|
1214
|
+
|
|
1215
|
+
api.post("/sessions/:id/recording/start", (c) => {
|
|
1216
|
+
const id = c.req.param("id");
|
|
1217
|
+
if (!recorder) return c.json({ error: "Recording not available" }, 501);
|
|
1218
|
+
recorder.enableForSession(id);
|
|
1219
|
+
return c.json({ ok: true, recording: true });
|
|
1220
|
+
});
|
|
1221
|
+
|
|
1222
|
+
api.post("/sessions/:id/recording/stop", (c) => {
|
|
1223
|
+
const id = c.req.param("id");
|
|
1224
|
+
if (!recorder) return c.json({ error: "Recording not available" }, 501);
|
|
1225
|
+
recorder.disableForSession(id);
|
|
1226
|
+
return c.json({ ok: true, recording: false });
|
|
1227
|
+
});
|
|
1228
|
+
|
|
1229
|
+
api.get("/sessions/:id/recording/status", (c) => {
|
|
1230
|
+
const id = c.req.param("id");
|
|
1231
|
+
if (!recorder) return c.json({ recording: false, available: false });
|
|
1232
|
+
return c.json({
|
|
1233
|
+
recording: recorder.isRecording(id),
|
|
1234
|
+
available: true,
|
|
1235
|
+
...recorder.getRecordingStatus(id),
|
|
1236
|
+
});
|
|
1237
|
+
});
|
|
1238
|
+
|
|
1239
|
+
api.get("/recordings", (c) => {
|
|
1240
|
+
if (!recorder) return c.json({ recordings: [] });
|
|
1241
|
+
return c.json({ recordings: recorder.listRecordings() });
|
|
1242
|
+
});
|
|
1243
|
+
|
|
1244
|
+
// ─── Available backends ─────────────────────────────────────
|
|
1245
|
+
|
|
1246
|
+
api.get("/backends", (c) => {
|
|
1247
|
+
const backends: Array<{ id: string; name: string; available: boolean }> = [];
|
|
1248
|
+
|
|
1249
|
+
backends.push({ id: "claude", name: "Claude Code", available: resolveBinary("claude") !== null });
|
|
1250
|
+
backends.push({ id: "codex", name: "Codex", available: resolveBinary("codex") !== null });
|
|
1251
|
+
|
|
1252
|
+
return c.json(backends);
|
|
1253
|
+
});
|
|
1254
|
+
|
|
1255
|
+
api.get("/backends/:id/models", (c) => {
|
|
1256
|
+
const backendId = c.req.param("id");
|
|
1257
|
+
|
|
1258
|
+
if (backendId === "codex") {
|
|
1259
|
+
// Read Codex model list from its local cache file
|
|
1260
|
+
const cachePath = join(homedir(), ".codex", "models_cache.json");
|
|
1261
|
+
if (!existsSync(cachePath)) {
|
|
1262
|
+
return c.json({ error: "Codex models cache not found. Run codex once to populate it." }, 404);
|
|
1263
|
+
}
|
|
1264
|
+
try {
|
|
1265
|
+
const raw = readFileSync(cachePath, "utf-8");
|
|
1266
|
+
const cache = JSON.parse(raw) as {
|
|
1267
|
+
models: Array<{
|
|
1268
|
+
slug: string;
|
|
1269
|
+
display_name?: string;
|
|
1270
|
+
description?: string;
|
|
1271
|
+
visibility?: string;
|
|
1272
|
+
priority?: number;
|
|
1273
|
+
}>;
|
|
1274
|
+
};
|
|
1275
|
+
// Only return visible models, sorted by priority
|
|
1276
|
+
const models = cache.models
|
|
1277
|
+
.filter((m) => m.visibility === "list")
|
|
1278
|
+
.sort((a, b) => (a.priority ?? 99) - (b.priority ?? 99))
|
|
1279
|
+
.map((m) => ({
|
|
1280
|
+
value: m.slug,
|
|
1281
|
+
label: m.display_name || m.slug,
|
|
1282
|
+
description: m.description || "",
|
|
1283
|
+
}));
|
|
1284
|
+
return c.json(models);
|
|
1285
|
+
} catch (e) {
|
|
1286
|
+
return c.json({ error: "Failed to parse Codex models cache" }, 500);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
// Claude models are hardcoded on the frontend
|
|
1291
|
+
return c.json({ error: "Use frontend defaults for this backend" }, 404);
|
|
1292
|
+
});
|
|
1293
|
+
|
|
1294
|
+
// ─── Containers ─────────────────────────────────────────────────
|
|
1295
|
+
|
|
1296
|
+
api.get("/containers/status", (c) => {
|
|
1297
|
+
const available = containerManager.checkDocker();
|
|
1298
|
+
const version = available ? containerManager.getDockerVersion() : null;
|
|
1299
|
+
return c.json({ available, version });
|
|
1300
|
+
});
|
|
1301
|
+
|
|
1302
|
+
api.get("/containers/images", (c) => {
|
|
1303
|
+
const images = containerManager.listImages();
|
|
1304
|
+
return c.json(images);
|
|
1305
|
+
});
|
|
1306
|
+
|
|
1307
|
+
registerFsRoutes(api);
|
|
1308
|
+
registerEnvRoutes(api, { webDir: WEB_DIR });
|
|
1309
|
+
registerSandboxRoutes(api);
|
|
1310
|
+
|
|
1311
|
+
registerPromptRoutes(api);
|
|
1312
|
+
registerSettingsRoutes(api);
|
|
1313
|
+
|
|
1314
|
+
// ─── Tailscale ──────────────────────────────────────────────────────
|
|
1315
|
+
|
|
1316
|
+
if (port !== undefined) registerTailscaleRoutes(api, port);
|
|
1317
|
+
|
|
1318
|
+
registerGitRoutes(api, prPoller);
|
|
1319
|
+
registerSystemRoutes(api, {
|
|
1320
|
+
launcher,
|
|
1321
|
+
wsBridge,
|
|
1322
|
+
terminalManager,
|
|
1323
|
+
updateCheckStaleMs: UPDATE_CHECK_STALE_MS,
|
|
1324
|
+
});
|
|
1325
|
+
|
|
1326
|
+
registerSkillRoutes(api);
|
|
1327
|
+
registerCronRoutes(api, cronScheduler);
|
|
1328
|
+
registerAgentRoutes(api, agentExecutor);
|
|
1329
|
+
registerMetricsRoutes(api, { gaugeProvider: wsBridge });
|
|
1330
|
+
registerPlatformRoutes(api);
|
|
1331
|
+
registerFederationRoutes(api);
|
|
1332
|
+
registerTelephonyRoutes(api);
|
|
1333
|
+
registerSocialMediaRoutes(api);
|
|
1334
|
+
registerAssistantRoutes(api);
|
|
1335
|
+
registerProviderRoutes(api);
|
|
1336
|
+
|
|
1337
|
+
// ─── Gemini → Session bridge ───────────────────────────────────────
|
|
1338
|
+
// Allows Gemini voice chat tool calls to send messages to active sessions
|
|
1339
|
+
api.post("/gemini/send-to-session", async (c) => {
|
|
1340
|
+
const body = await c.req.json().catch(() => ({} as { sessionId?: string; message?: string }));
|
|
1341
|
+
const { sessionId, message } = body;
|
|
1342
|
+
if (!sessionId || !message) {
|
|
1343
|
+
return c.json({ error: "sessionId and message are required" }, 400);
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// Wait for session to be connected (up to 10s for newly created sessions)
|
|
1347
|
+
let attempts = 0;
|
|
1348
|
+
const maxAttempts = 20;
|
|
1349
|
+
while (attempts < maxAttempts) {
|
|
1350
|
+
if (wsBridge.hasConnectedCli(sessionId)) break;
|
|
1351
|
+
attempts++;
|
|
1352
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
if (!wsBridge.hasConnectedCli(sessionId)) {
|
|
1356
|
+
return c.json({ error: "Session not connected or not found" }, 404);
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
try {
|
|
1360
|
+
wsBridge.injectUserMessage(sessionId, message);
|
|
1361
|
+
return c.json({ success: true });
|
|
1362
|
+
} catch {
|
|
1363
|
+
return c.json({ error: "Failed to send message to session" }, 500);
|
|
1364
|
+
}
|
|
1365
|
+
});
|
|
1366
|
+
|
|
1367
|
+
registerLLMRoutes(api);
|
|
1368
|
+
registerMediaRoutes(api);
|
|
1369
|
+
|
|
1370
|
+
// ─── Recording Hub (hidden feature: HEYHANK_RECORDING_HUB=1) ──────
|
|
1371
|
+
if (isRecordingHubEnabled()) {
|
|
1372
|
+
registerHubRoutes(api, {
|
|
1373
|
+
wsBridge,
|
|
1374
|
+
recordingsDir: recorder?.getRecordingsDir() ?? "",
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
return api;
|
|
1379
|
+
}
|