heyhank 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. package/README.md +40 -0
  2. package/bin/cli.ts +168 -0
  3. package/bin/ctl.ts +528 -0
  4. package/bin/generate-token.ts +28 -0
  5. package/dist/apple-touch-icon.png +0 -0
  6. package/dist/assets/AgentsPage-BPhirnCe.js +7 -0
  7. package/dist/assets/AssistantPage-DJ-cMQfb.js +1 -0
  8. package/dist/assets/CronManager-DDbz-yiT.js +1 -0
  9. package/dist/assets/HelpPage-DMfkzERp.js +1 -0
  10. package/dist/assets/IntegrationsPage-CrOitCmJ.js +1 -0
  11. package/dist/assets/MediaPage-CE5rdvkC.js +1 -0
  12. package/dist/assets/PlatformDashboard-Do6F0O2p.js +1 -0
  13. package/dist/assets/Playground-Fc5cdc5p.js +109 -0
  14. package/dist/assets/ProcessPanel-CslEiZkI.js +2 -0
  15. package/dist/assets/PromptsPage-D2EhsdNO.js +4 -0
  16. package/dist/assets/RunsPage-C5BZF5Rx.js +1 -0
  17. package/dist/assets/SandboxManager-a1AVI5q2.js +8 -0
  18. package/dist/assets/SettingsPage-DirhjQrJ.js +51 -0
  19. package/dist/assets/SocialMediaPage-DBuM28vD.js +1 -0
  20. package/dist/assets/TailscalePage-CHiFhZXF.js +1 -0
  21. package/dist/assets/TelephonyPage-x0VV0fOo.js +1 -0
  22. package/dist/assets/TerminalPage-Drwyrnfd.js +1 -0
  23. package/dist/assets/gemini-audio-t-TSU-To.js +17 -0
  24. package/dist/assets/gemini-live-client-C7rqAW7G.js +166 -0
  25. package/dist/assets/index-C8M_PUmX.css +32 -0
  26. package/dist/assets/index-CEqZnThB.js +204 -0
  27. package/dist/assets/sw-register-LSSpj6RU.js +1 -0
  28. package/dist/assets/time-ago-B6r_l9u1.js +1 -0
  29. package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
  30. package/dist/favicon-32-original.png +0 -0
  31. package/dist/favicon-32.png +0 -0
  32. package/dist/favicon.ico +0 -0
  33. package/dist/favicon.svg +8 -0
  34. package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
  35. package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
  36. package/dist/heyhank-mascot-poster.png +0 -0
  37. package/dist/heyhank-mascot.mp4 +0 -0
  38. package/dist/heyhank-mascot.webm +0 -0
  39. package/dist/icon-192-original.png +0 -0
  40. package/dist/icon-192.png +0 -0
  41. package/dist/icon-512-original.png +0 -0
  42. package/dist/icon-512.png +0 -0
  43. package/dist/index.html +21 -0
  44. package/dist/logo-192.png +0 -0
  45. package/dist/logo-512.png +0 -0
  46. package/dist/logo-codex.svg +14 -0
  47. package/dist/logo-docker.svg +4 -0
  48. package/dist/logo-original.png +0 -0
  49. package/dist/logo.png +0 -0
  50. package/dist/logo.svg +14 -0
  51. package/dist/manifest.json +24 -0
  52. package/dist/push-sw.js +34 -0
  53. package/dist/sw.js +1 -0
  54. package/dist/workbox-d2a0910a.js +1 -0
  55. package/package.json +109 -0
  56. package/server/agent-cron-migrator.ts +85 -0
  57. package/server/agent-executor.ts +357 -0
  58. package/server/agent-store.ts +185 -0
  59. package/server/agent-timeout.ts +107 -0
  60. package/server/agent-types.ts +122 -0
  61. package/server/ai-validation-settings.ts +37 -0
  62. package/server/ai-validator.ts +181 -0
  63. package/server/anthropic-provider-migration.ts +48 -0
  64. package/server/assistant-store.ts +272 -0
  65. package/server/auth-manager.ts +150 -0
  66. package/server/auto-approve.ts +153 -0
  67. package/server/auto-namer.ts +36 -0
  68. package/server/backend-adapter.ts +54 -0
  69. package/server/cache-headers.ts +61 -0
  70. package/server/calendar-service.ts +434 -0
  71. package/server/claude-adapter.ts +889 -0
  72. package/server/claude-container-auth.ts +30 -0
  73. package/server/claude-session-discovery.ts +157 -0
  74. package/server/claude-session-history.ts +410 -0
  75. package/server/cli-launcher.ts +1303 -0
  76. package/server/codex-adapter.ts +3027 -0
  77. package/server/codex-container-auth.ts +24 -0
  78. package/server/codex-home.ts +27 -0
  79. package/server/codex-ws-proxy.cjs +226 -0
  80. package/server/commands-discovery.ts +81 -0
  81. package/server/constants.ts +7 -0
  82. package/server/container-manager.ts +1053 -0
  83. package/server/cost-tracker.ts +222 -0
  84. package/server/cron-scheduler.ts +243 -0
  85. package/server/cron-store.ts +148 -0
  86. package/server/cron-types.ts +63 -0
  87. package/server/email-service.ts +354 -0
  88. package/server/env-manager.ts +161 -0
  89. package/server/event-bus-types.ts +75 -0
  90. package/server/event-bus.ts +124 -0
  91. package/server/execution-store.ts +170 -0
  92. package/server/federation/node-connection.ts +190 -0
  93. package/server/federation/node-manager.ts +366 -0
  94. package/server/federation/node-store.ts +86 -0
  95. package/server/federation/node-types.ts +121 -0
  96. package/server/fs-utils.ts +15 -0
  97. package/server/git-utils.ts +421 -0
  98. package/server/github-pr.ts +379 -0
  99. package/server/google-media.ts +342 -0
  100. package/server/image-pull-manager.ts +279 -0
  101. package/server/index.ts +491 -0
  102. package/server/internal-ai.ts +237 -0
  103. package/server/kill-switch.ts +99 -0
  104. package/server/llm-providers.ts +342 -0
  105. package/server/logger.ts +259 -0
  106. package/server/mcp-registry.ts +401 -0
  107. package/server/message-bus.ts +271 -0
  108. package/server/message-delivery.ts +128 -0
  109. package/server/metrics-collector.ts +350 -0
  110. package/server/metrics-types.ts +108 -0
  111. package/server/middleware/managed-auth.ts +195 -0
  112. package/server/novnc-proxy.ts +99 -0
  113. package/server/path-resolver.ts +186 -0
  114. package/server/paths.ts +13 -0
  115. package/server/pr-poller.ts +162 -0
  116. package/server/prompt-manager.ts +211 -0
  117. package/server/protocol/claude-upstream/README.md +19 -0
  118. package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
  119. package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
  120. package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
  121. package/server/protocol/codex-upstream/README.md +18 -0
  122. package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
  123. package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
  124. package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
  125. package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
  126. package/server/protocol-monitor.ts +50 -0
  127. package/server/provider-manager.ts +111 -0
  128. package/server/provider-registry.ts +393 -0
  129. package/server/push-notifications.ts +221 -0
  130. package/server/recorder.ts +374 -0
  131. package/server/recording-hub/compat-validator.ts +284 -0
  132. package/server/recording-hub/diagnostics.ts +299 -0
  133. package/server/recording-hub/hub-config.ts +19 -0
  134. package/server/recording-hub/hub-routes.ts +236 -0
  135. package/server/recording-hub/hub-store.ts +265 -0
  136. package/server/recording-hub/replay-adapter.ts +207 -0
  137. package/server/relay-client.ts +320 -0
  138. package/server/reminder-scheduler.ts +38 -0
  139. package/server/replay.ts +78 -0
  140. package/server/routes/agent-routes.ts +264 -0
  141. package/server/routes/assistant-routes.ts +90 -0
  142. package/server/routes/cron-routes.ts +103 -0
  143. package/server/routes/env-routes.ts +95 -0
  144. package/server/routes/federation-routes.ts +76 -0
  145. package/server/routes/fs-routes.ts +622 -0
  146. package/server/routes/git-routes.ts +97 -0
  147. package/server/routes/llm-routes.ts +166 -0
  148. package/server/routes/media-routes.ts +135 -0
  149. package/server/routes/metrics-routes.ts +13 -0
  150. package/server/routes/platform-routes.ts +1379 -0
  151. package/server/routes/prompt-routes.ts +67 -0
  152. package/server/routes/provider-routes.ts +109 -0
  153. package/server/routes/sandbox-routes.ts +127 -0
  154. package/server/routes/settings-routes.ts +285 -0
  155. package/server/routes/skills-routes.ts +100 -0
  156. package/server/routes/socialmedia-routes.ts +208 -0
  157. package/server/routes/system-routes.ts +228 -0
  158. package/server/routes/tailscale-routes.ts +22 -0
  159. package/server/routes/telephony-routes.ts +259 -0
  160. package/server/routes.ts +1379 -0
  161. package/server/sandbox-manager.ts +168 -0
  162. package/server/service.ts +718 -0
  163. package/server/session-creation-service.ts +457 -0
  164. package/server/session-git-info.ts +104 -0
  165. package/server/session-names.ts +67 -0
  166. package/server/session-orchestrator.ts +824 -0
  167. package/server/session-state-machine.ts +207 -0
  168. package/server/session-store.ts +146 -0
  169. package/server/session-types.ts +511 -0
  170. package/server/settings-manager.ts +149 -0
  171. package/server/shared-context.ts +157 -0
  172. package/server/socialmedia/adapter.ts +15 -0
  173. package/server/socialmedia/adapters/ayrshare-adapter.ts +169 -0
  174. package/server/socialmedia/adapters/buffer-adapter.ts +299 -0
  175. package/server/socialmedia/adapters/postiz-adapter.ts +298 -0
  176. package/server/socialmedia/manager.ts +227 -0
  177. package/server/socialmedia/store.ts +98 -0
  178. package/server/socialmedia/types.ts +89 -0
  179. package/server/tailscale-manager.ts +451 -0
  180. package/server/telephony/audio-bridge.ts +331 -0
  181. package/server/telephony/call-manager.ts +457 -0
  182. package/server/telephony/call-types.ts +108 -0
  183. package/server/telephony/telephony-store.ts +119 -0
  184. package/server/terminal-manager.ts +240 -0
  185. package/server/update-checker.ts +192 -0
  186. package/server/usage-limits.ts +225 -0
  187. package/server/web-push.d.ts +51 -0
  188. package/server/worktree-tracker.ts +84 -0
  189. package/server/ws-auth.ts +41 -0
  190. package/server/ws-bridge-browser-ingest.ts +72 -0
  191. package/server/ws-bridge-browser.ts +112 -0
  192. package/server/ws-bridge-cli-ingest.ts +81 -0
  193. package/server/ws-bridge-codex.ts +266 -0
  194. package/server/ws-bridge-controls.ts +20 -0
  195. package/server/ws-bridge-persist.ts +66 -0
  196. package/server/ws-bridge-publish.ts +79 -0
  197. package/server/ws-bridge-replay.ts +61 -0
  198. package/server/ws-bridge-types.ts +121 -0
  199. package/server/ws-bridge.ts +1240 -0
@@ -0,0 +1,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
+ }