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,99 @@
1
+ import type { ServerWebSocket } from "bun";
2
+ import type { SocketData } from "./ws-bridge-types.js";
3
+ import { containerManager } from "./container-manager.js";
4
+
5
+ const NOVNC_CONTAINER_PORT = 6080;
6
+
7
+ interface ProxyPair {
8
+ browserWs: ServerWebSocket<SocketData>;
9
+ upstreamWs: WebSocket;
10
+ }
11
+
12
+ /**
13
+ * Proxies noVNC WebSocket traffic between the user's browser and the
14
+ * container's websockify server. This allows the noVNC client to connect
15
+ * through HeyHank's single port instead of requiring direct access
16
+ * to the container's mapped port.
17
+ */
18
+ export class NoVncProxy {
19
+ private pairs = new Map<ServerWebSocket<SocketData>, ProxyPair>();
20
+
21
+ handleOpen(ws: ServerWebSocket<SocketData>, sessionId: string): void {
22
+ const container = containerManager.getContainer(sessionId);
23
+ if (!container) {
24
+ console.warn(`[novnc-proxy] No container found for session ${sessionId}`);
25
+ ws.close(1011, "Container not found");
26
+ return;
27
+ }
28
+
29
+ const portMapping = container.portMappings.find(
30
+ (p) => p.containerPort === NOVNC_CONTAINER_PORT,
31
+ );
32
+ if (!portMapping) {
33
+ console.warn(`[novnc-proxy] No noVNC port mapping for session ${sessionId}`);
34
+ ws.close(1011, "noVNC port not mapped");
35
+ return;
36
+ }
37
+
38
+ // Connect to the container's websockify server
39
+ const upstreamUrl = `ws://127.0.0.1:${portMapping.hostPort}`;
40
+ const upstream = new WebSocket(upstreamUrl, ["binary"]);
41
+ upstream.binaryType = "arraybuffer";
42
+
43
+ const pair: ProxyPair = { browserWs: ws, upstreamWs: upstream };
44
+ this.pairs.set(ws, pair);
45
+
46
+ upstream.addEventListener("open", () => {
47
+ console.log(`[novnc-proxy] Upstream connected for session ${sessionId}`);
48
+ });
49
+
50
+ upstream.addEventListener("message", (event) => {
51
+ try {
52
+ if (event.data instanceof ArrayBuffer) {
53
+ ws.send(new Uint8Array(event.data));
54
+ } else {
55
+ ws.send(event.data);
56
+ }
57
+ } catch {
58
+ // Browser socket may have closed
59
+ }
60
+ });
61
+
62
+ upstream.addEventListener("close", () => {
63
+ this.pairs.delete(ws);
64
+ try { ws.close(); } catch { /* already closed */ }
65
+ });
66
+
67
+ upstream.addEventListener("error", (err) => {
68
+ console.error(`[novnc-proxy] Upstream error for session ${sessionId}:`, err);
69
+ this.pairs.delete(ws);
70
+ try { ws.close(1011, "Upstream connection failed"); } catch { /* already closed */ }
71
+ });
72
+ }
73
+
74
+ handleMessage(ws: ServerWebSocket<SocketData>, msg: string | Buffer): void {
75
+ const pair = this.pairs.get(ws);
76
+ if (!pair) return;
77
+
78
+ const { upstreamWs } = pair;
79
+ if (upstreamWs.readyState !== WebSocket.OPEN) return;
80
+
81
+ try {
82
+ upstreamWs.send(msg instanceof Buffer ? new Uint8Array(msg) : msg);
83
+ } catch {
84
+ // Upstream may have closed
85
+ }
86
+ }
87
+
88
+ handleClose(ws: ServerWebSocket<SocketData>): void {
89
+ const pair = this.pairs.get(ws);
90
+ if (!pair) return;
91
+
92
+ this.pairs.delete(ws);
93
+ try {
94
+ pair.upstreamWs.close();
95
+ } catch {
96
+ // Already closed
97
+ }
98
+ }
99
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * PATH discovery and binary resolution for service environments.
3
+ *
4
+ * When HeyHank runs as a macOS launchd or Linux systemd service, it inherits
5
+ * a restricted PATH that omits directories from version managers (nvm, fnm, volta,
6
+ * mise, etc.) and user-local installs (~/.local/bin, ~/.cargo/bin). This module
7
+ * captures the user's real shell PATH at runtime and provides binary resolution
8
+ * that works regardless of how the server was started.
9
+ */
10
+
11
+ import { execSync } from "node:child_process";
12
+ import { existsSync, readdirSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { join } from "node:path";
15
+
16
+ /**
17
+ * Capture the user's full interactive shell PATH by spawning a login shell.
18
+ * This picks up all version manager initializations (nvm, fnm, volta, mise, etc.).
19
+ * Falls back to probing common directories if shell sourcing fails.
20
+ */
21
+ export function captureUserShellPath(): string {
22
+ try {
23
+ const shell = process.env.SHELL || "/bin/bash";
24
+ const captured = execSync(
25
+ `${shell} -lic 'echo "___PATH_START___$PATH___PATH_END___"'`,
26
+ {
27
+ encoding: "utf-8",
28
+ timeout: 10_000,
29
+ env: { HOME: homedir(), USER: process.env.USER, SHELL: shell },
30
+ },
31
+ );
32
+ const match = captured.match(/___PATH_START___(.+)___PATH_END___/);
33
+ if (match?.[1]) {
34
+ return match[1];
35
+ }
36
+ } catch {
37
+ // Shell sourcing failed (timeout, compinit prompt, etc.)
38
+ }
39
+
40
+ return buildFallbackPath();
41
+ }
42
+
43
+ /**
44
+ * Build a PATH by probing common binary installation directories.
45
+ * Used as fallback when shell-sourcing fails.
46
+ */
47
+ export function buildFallbackPath(): string {
48
+ const home = homedir();
49
+ const candidates = [
50
+ // Standard system paths
51
+ "/opt/homebrew/bin",
52
+ "/opt/homebrew/sbin",
53
+ "/usr/local/bin",
54
+ "/usr/bin",
55
+ "/bin",
56
+ "/usr/sbin",
57
+ "/sbin",
58
+ // Bun
59
+ join(home, ".bun", "bin"),
60
+ // Claude CLI / user-local installs
61
+ join(home, ".local", "bin"),
62
+ // Cargo / Rust
63
+ join(home, ".cargo", "bin"),
64
+ // Volta (Node version manager)
65
+ join(home, ".volta", "bin"),
66
+ // mise (formerly rtx)
67
+ join(home, ".local", "share", "mise", "shims"),
68
+ // pyenv
69
+ join(home, ".pyenv", "bin"),
70
+ join(home, ".pyenv", "shims"),
71
+ // Go
72
+ join(home, "go", "bin"),
73
+ "/usr/local/go/bin",
74
+ // Deno
75
+ join(home, ".deno", "bin"),
76
+ ];
77
+
78
+ // Probe nvm-managed node versions
79
+ const nvmDir = process.env.NVM_DIR || join(home, ".nvm");
80
+ const nvmVersionsDir = join(nvmDir, "versions", "node");
81
+ if (existsSync(nvmVersionsDir)) {
82
+ try {
83
+ for (const v of readdirSync(nvmVersionsDir)) {
84
+ candidates.push(join(nvmVersionsDir, v, "bin"));
85
+ }
86
+ } catch { /* ignore */ }
87
+ }
88
+
89
+ // fnm (Fast Node Manager) — versions stored in fnm multishell or XDG data
90
+ const fnmDir = join(home, "Library", "Application Support", "fnm", "node-versions");
91
+ if (existsSync(fnmDir)) {
92
+ try {
93
+ for (const v of readdirSync(fnmDir)) {
94
+ candidates.push(join(fnmDir, v, "installation", "bin"));
95
+ }
96
+ } catch { /* ignore */ }
97
+ }
98
+
99
+ const pathSep = process.platform === "win32" ? ";" : ":";
100
+ return [...new Set(candidates.filter((dir) => existsSync(dir)))].join(pathSep);
101
+ }
102
+
103
+ // ─── Enriched PATH (cached) ───────────────────────────────────────────────────
104
+
105
+ let _cachedPath: string | null = null;
106
+
107
+ /**
108
+ * Returns an enriched PATH that merges the user's shell PATH (or probed common
109
+ * directories) with the current process PATH. Deduplicates entries.
110
+ * Result is cached after the first call.
111
+ */
112
+ export function getEnrichedPath(): string {
113
+ if (_cachedPath) return _cachedPath;
114
+
115
+ const currentPath = process.env.PATH || "";
116
+ const userPath = captureUserShellPath();
117
+ const pathSep = process.platform === "win32" ? ";" : ":";
118
+
119
+ // Merge: user shell PATH first (takes precedence), then current process PATH
120
+ const allDirs = [...userPath.split(pathSep), ...currentPath.split(pathSep)];
121
+ const seen = new Set<string>();
122
+ const deduped: string[] = [];
123
+ for (const dir of allDirs) {
124
+ if (dir && !seen.has(dir)) {
125
+ seen.add(dir);
126
+ deduped.push(dir);
127
+ }
128
+ }
129
+
130
+ _cachedPath = deduped.join(pathSep);
131
+ return _cachedPath;
132
+ }
133
+
134
+ /** Reset the cached PATH (for testing). */
135
+ export function _resetPathCache(): void {
136
+ _cachedPath = null;
137
+ }
138
+
139
+ // ─── Binary resolution ────────────────────────────────────────────────────────
140
+
141
+ /**
142
+ * Resolve a binary name to an absolute path using the enriched PATH.
143
+ * Returns null if the binary is not found anywhere.
144
+ */
145
+ export function resolveBinary(name: string): string | null {
146
+ if (name.startsWith("/")) {
147
+ return existsSync(name) ? name : null;
148
+ }
149
+ // On Windows, also accept absolute paths like C:\... or D:\...
150
+ if (process.platform === "win32" && /^[a-zA-Z]:[/\\]/.test(name)) {
151
+ return existsSync(name) ? name : null;
152
+ }
153
+
154
+ const sanitized = name.replace(/[^a-zA-Z0-9._@/-]/g, "");
155
+ const enrichedPath = getEnrichedPath();
156
+
157
+ // Try `where` first on Windows (returns native Win32 paths), then `which` as fallback
158
+ const commands = process.platform === "win32" ? ["where", "which"] : ["which"];
159
+ for (const cmd of commands) {
160
+ try {
161
+ const result = execSync(`${cmd} ${sanitized}`, {
162
+ encoding: "utf-8",
163
+ timeout: 5_000,
164
+ env: { ...process.env, PATH: enrichedPath },
165
+ }).trim();
166
+ if (!result) continue;
167
+ // `where` on Windows may return multiple lines; prefer .cmd for Bun.spawn compatibility
168
+ if (cmd === "where") {
169
+ const lines = result.split(/\r?\n/).filter(Boolean);
170
+ return lines.find(l => l.endsWith(".cmd")) || lines[0];
171
+ }
172
+ return result;
173
+ } catch {
174
+ continue;
175
+ }
176
+ }
177
+ return null;
178
+ }
179
+
180
+ /**
181
+ * Returns a PATH string suitable for embedding in service definitions
182
+ * (plist/systemd unit). Captures the user's shell PATH at install time.
183
+ */
184
+ export function getServicePath(): string {
185
+ return getEnrichedPath();
186
+ }
@@ -0,0 +1,13 @@
1
+ import { join } from "node:path";
2
+ import { homedir } from "node:os";
3
+
4
+ /**
5
+ * Base directory for all HeyHank configuration and state.
6
+ * Defaults to ~/.heyhank/ for self-hosted installs.
7
+ * Override with HEYHANK_HOME env var for managed deployments
8
+ * (e.g. HEYHANK_HOME=/data/heyhank on Fly.io volumes).
9
+ * Falls back to COMPANION_HOME for backward compatibility.
10
+ */
11
+ export const HEYHANK_HOME =
12
+ process.env.HEYHANK_HOME || process.env.COMPANION_HOME || join(homedir(), ".heyhank");
13
+
@@ -0,0 +1,162 @@
1
+ import { fetchPRInfoAsync, computeAdaptiveTTL, type GitHubPRInfo } from "./github-pr.js";
2
+ import type { WsBridge } from "./ws-bridge.js";
3
+
4
+ // ─── Types ───────────────────────────────────────────────────────────────────
5
+
6
+ interface WatchedPR {
7
+ cwd: string;
8
+ branch: string;
9
+ /** Sessions interested in this PR (same cwd:branch may be shared) */
10
+ sessionIds: Set<string>;
11
+ lastData: GitHubPRInfo | null;
12
+ timer: ReturnType<typeof setTimeout> | null;
13
+ lastFetchTime: number;
14
+ currentInterval: number;
15
+ fetching: boolean;
16
+ }
17
+
18
+ // ─── PR Poller ───────────────────────────────────────────────────────────────
19
+
20
+ /**
21
+ * Server-side poller that fetches GitHub PR status at adaptive intervals
22
+ * and pushes updates to browsers via WebSocket.
23
+ *
24
+ * One timer per unique cwd:branch — shared across sessions on the same branch.
25
+ */
26
+ export class PRPoller {
27
+ private watched = new Map<string, WatchedPR>();
28
+ private wsBridge: WsBridge;
29
+ /** Reverse index: sessionId → cwd:branch key (a session can only watch one PR at a time) */
30
+ private sessionToKey = new Map<string, string>();
31
+
32
+ constructor(wsBridge: WsBridge) {
33
+ this.wsBridge = wsBridge;
34
+ }
35
+
36
+ /**
37
+ * Start watching a PR for a session.
38
+ * Returns cached data immediately if available.
39
+ * Triggers an async fetch if cache is stale or missing.
40
+ */
41
+ watch(sessionId: string, cwd: string, branch: string): GitHubPRInfo | null {
42
+ const key = `${cwd}:${branch}`;
43
+
44
+ // If this session was watching a different PR, unregister from the old one
45
+ const prevKey = this.sessionToKey.get(sessionId);
46
+ if (prevKey && prevKey !== key) {
47
+ this.unwatchKey(sessionId, prevKey);
48
+ }
49
+ this.sessionToKey.set(sessionId, key);
50
+
51
+ const existing = this.watched.get(key);
52
+ if (existing) {
53
+ existing.sessionIds.add(sessionId);
54
+ // If cache is stale, trigger a refresh
55
+ if (Date.now() - existing.lastFetchTime > existing.currentInterval) {
56
+ this.fetchAndBroadcast(key);
57
+ }
58
+ return existing.lastData;
59
+ }
60
+
61
+ // New watch — create entry and fetch immediately
62
+ const entry: WatchedPR = {
63
+ cwd,
64
+ branch,
65
+ sessionIds: new Set([sessionId]),
66
+ lastData: null,
67
+ timer: null,
68
+ lastFetchTime: 0,
69
+ currentInterval: 10_000, // start aggressive for fast initial load
70
+ fetching: false,
71
+ };
72
+ this.watched.set(key, entry);
73
+ this.fetchAndBroadcast(key);
74
+
75
+ return null;
76
+ }
77
+
78
+ /** Stop watching for a specific session. */
79
+ unwatch(sessionId: string): void {
80
+ const key = this.sessionToKey.get(sessionId);
81
+ if (!key) return;
82
+ this.sessionToKey.delete(sessionId);
83
+ this.unwatchKey(sessionId, key);
84
+ }
85
+
86
+ /** Get current cached data for a cwd:branch pair (for REST fallback). */
87
+ getCached(cwd: string, branch: string): { available: boolean; pr: GitHubPRInfo | null } | null {
88
+ const key = `${cwd}:${branch}`;
89
+ const entry = this.watched.get(key);
90
+ if (!entry) return null;
91
+ return { available: true, pr: entry.lastData };
92
+ }
93
+
94
+ /** Stop all timers (for testing / cleanup). */
95
+ destroy(): void {
96
+ for (const entry of this.watched.values()) {
97
+ if (entry.timer) clearTimeout(entry.timer);
98
+ }
99
+ this.watched.clear();
100
+ this.sessionToKey.clear();
101
+ }
102
+
103
+ // ─── Internal ──────────────────────────────────────────────────────────
104
+
105
+ private unwatchKey(sessionId: string, key: string): void {
106
+ const entry = this.watched.get(key);
107
+ if (!entry) return;
108
+ entry.sessionIds.delete(sessionId);
109
+ if (entry.sessionIds.size === 0) {
110
+ if (entry.timer) clearTimeout(entry.timer);
111
+ this.watched.delete(key);
112
+ }
113
+ }
114
+
115
+ private async fetchAndBroadcast(key: string): Promise<void> {
116
+ const entry = this.watched.get(key);
117
+ if (!entry) return;
118
+
119
+ // Prevent concurrent fetches for the same key
120
+ if (entry.fetching) return;
121
+ entry.fetching = true;
122
+
123
+ // Clear existing timer (will be rescheduled after fetch)
124
+ if (entry.timer) {
125
+ clearTimeout(entry.timer);
126
+ entry.timer = null;
127
+ }
128
+
129
+ try {
130
+ const prInfo = await fetchPRInfoAsync(entry.cwd, entry.branch);
131
+ // Re-check entry still exists (may have been unwatched during async fetch)
132
+ const current = this.watched.get(key);
133
+ if (!current) return;
134
+
135
+ current.lastData = prInfo;
136
+ current.lastFetchTime = Date.now();
137
+ current.currentInterval = computeAdaptiveTTL(prInfo);
138
+
139
+ // Push to all sessions watching this PR
140
+ for (const sessionId of current.sessionIds) {
141
+ this.wsBridge.broadcastToSession(sessionId, {
142
+ type: "pr_status_update",
143
+ pr: prInfo,
144
+ available: true,
145
+ });
146
+ }
147
+ } catch {
148
+ // On error, use a moderate interval
149
+ const current = this.watched.get(key);
150
+ if (current) {
151
+ current.currentInterval = 30_000;
152
+ }
153
+ } finally {
154
+ const current = this.watched.get(key);
155
+ if (current) {
156
+ current.fetching = false;
157
+ // Schedule next fetch
158
+ current.timer = setTimeout(() => this.fetchAndBroadcast(key), current.currentInterval);
159
+ }
160
+ }
161
+ }
162
+ }
@@ -0,0 +1,211 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { HEYHANK_HOME } from "./paths.js";
4
+
5
+ export type PromptScope = "global" | "project";
6
+
7
+ export interface SavedPrompt {
8
+ id: string;
9
+ name: string;
10
+ content: string;
11
+ scope: PromptScope;
12
+ projectPath?: string;
13
+ projectPaths?: string[];
14
+ createdAt: number;
15
+ updatedAt: number;
16
+ }
17
+
18
+ export interface PromptUpdateFields {
19
+ name?: string;
20
+ content?: string;
21
+ scope?: PromptScope;
22
+ projectPaths?: string[];
23
+ }
24
+
25
+ const PROMPTS_FILE = join(HEYHANK_HOME, "prompts.json");
26
+
27
+ function ensureDir(): void {
28
+ mkdirSync(HEYHANK_HOME, { recursive: true });
29
+ }
30
+
31
+ function normalizePath(path: string): string {
32
+ return resolve(path).replace(/[\\/]+$/, "");
33
+ }
34
+
35
+ function loadPrompts(): SavedPrompt[] {
36
+ ensureDir();
37
+ if (!existsSync(PROMPTS_FILE)) return [];
38
+ try {
39
+ const raw = readFileSync(PROMPTS_FILE, "utf-8");
40
+ const parsed = JSON.parse(raw) as unknown;
41
+ if (!Array.isArray(parsed)) return [];
42
+ return parsed.filter((p): p is SavedPrompt => {
43
+ if (!p || typeof p !== "object") return false;
44
+ const candidate = p as Partial<SavedPrompt>;
45
+ return (
46
+ typeof candidate.id === "string"
47
+ && typeof candidate.name === "string"
48
+ && typeof candidate.content === "string"
49
+ && (candidate.scope === "global" || candidate.scope === "project")
50
+ );
51
+ });
52
+ } catch {
53
+ return [];
54
+ }
55
+ }
56
+
57
+ function savePrompts(prompts: SavedPrompt[]): void {
58
+ ensureDir();
59
+ writeFileSync(PROMPTS_FILE, JSON.stringify(prompts, null, 2), "utf-8");
60
+ }
61
+
62
+ function sortPrompts(prompts: SavedPrompt[]): SavedPrompt[] {
63
+ return [...prompts].sort((a, b) => b.updatedAt - a.updatedAt || a.name.localeCompare(b.name));
64
+ }
65
+
66
+ function visibleForCwd(prompt: SavedPrompt, cwd: string): boolean {
67
+ if (prompt.scope === "global") return true;
68
+ const paths = resolveProjectPaths(prompt);
69
+ if (paths.length === 0) return false;
70
+ const normalizedCwd = normalizePath(cwd);
71
+ return paths.some((p) => {
72
+ const normalizedProject = normalizePath(p);
73
+ return normalizedCwd === normalizedProject || normalizedCwd.startsWith(`${normalizedProject}/`);
74
+ });
75
+ }
76
+
77
+ /** Merges legacy projectPath and projectPaths into a single deduplicated list. */
78
+ function resolveProjectPaths(prompt: SavedPrompt): string[] {
79
+ const paths: string[] = [];
80
+ if (prompt.projectPaths && prompt.projectPaths.length > 0) {
81
+ paths.push(...prompt.projectPaths);
82
+ }
83
+ if (prompt.projectPath && !paths.some((p) => normalizePath(p) === normalizePath(prompt.projectPath!))) {
84
+ paths.push(prompt.projectPath);
85
+ }
86
+ return paths;
87
+ }
88
+
89
+ export function listPrompts(opts?: { cwd?: string; scope?: "global" | "project" | "all" }): SavedPrompt[] {
90
+ const prompts = loadPrompts();
91
+ const scope = opts?.scope ?? "all";
92
+
93
+ const filteredByScope = prompts.filter((p) => {
94
+ if (scope === "all") return true;
95
+ return p.scope === scope;
96
+ });
97
+
98
+ if (!opts?.cwd) return sortPrompts(filteredByScope);
99
+
100
+ return sortPrompts(filteredByScope.filter((p) => visibleForCwd(p, opts.cwd!)));
101
+ }
102
+
103
+ export function getPrompt(id: string): SavedPrompt | null {
104
+ return loadPrompts().find((p) => p.id === id) ?? null;
105
+ }
106
+
107
+ export function createPrompt(
108
+ name: string,
109
+ content: string,
110
+ scope: PromptScope,
111
+ projectPath?: string,
112
+ projectPaths?: string[],
113
+ ): SavedPrompt {
114
+ const cleanName = name?.trim();
115
+ const cleanContent = content?.trim();
116
+ if (!cleanName) throw new Error("Prompt name is required");
117
+ if (!cleanContent) throw new Error("Prompt content is required");
118
+ if (scope !== "global" && scope !== "project") throw new Error("Invalid prompt scope");
119
+
120
+ // Merge projectPaths and legacy projectPath into a single deduplicated list
121
+ const mergedPaths = dedupeAndNormalizePaths(projectPaths, projectPath);
122
+ if (scope === "project" && mergedPaths.length === 0) {
123
+ throw new Error("Project path is required for project prompts");
124
+ }
125
+
126
+ const prompts = loadPrompts();
127
+ const now = Date.now();
128
+ const prompt: SavedPrompt = {
129
+ id: crypto.randomUUID(),
130
+ name: cleanName,
131
+ content: cleanContent,
132
+ scope,
133
+ projectPath: scope === "project" ? mergedPaths[0] : undefined,
134
+ projectPaths: scope === "project" ? mergedPaths : undefined,
135
+ createdAt: now,
136
+ updatedAt: now,
137
+ };
138
+ prompts.push(prompt);
139
+ savePrompts(prompts);
140
+ return prompt;
141
+ }
142
+
143
+ function dedupeAndNormalizePaths(paths?: string[], legacyPath?: string): string[] {
144
+ const seen = new Set<string>();
145
+ const result: string[] = [];
146
+ const all = [...(paths ?? []), ...(legacyPath?.trim() ? [legacyPath] : [])];
147
+ for (const p of all) {
148
+ const trimmed = p.trim();
149
+ if (!trimmed) continue;
150
+ const normalized = normalizePath(trimmed);
151
+ if (!seen.has(normalized)) {
152
+ seen.add(normalized);
153
+ result.push(normalized);
154
+ }
155
+ }
156
+ return result;
157
+ }
158
+
159
+ export function updatePrompt(id: string, updates: PromptUpdateFields): SavedPrompt | null {
160
+ const prompts = loadPrompts();
161
+ const index = prompts.findIndex((p) => p.id === id);
162
+ if (index < 0) return null;
163
+
164
+ if (updates.name !== undefined && !updates.name.trim()) {
165
+ throw new Error("Prompt name cannot be empty");
166
+ }
167
+ if (updates.content !== undefined && !updates.content.trim()) {
168
+ throw new Error("Prompt content cannot be empty");
169
+ }
170
+
171
+ const newScope = updates.scope ?? prompts[index].scope;
172
+ if (updates.scope !== undefined && updates.scope !== "global" && updates.scope !== "project") {
173
+ throw new Error("Invalid prompt scope");
174
+ }
175
+
176
+ let newProjectPaths = prompts[index].projectPaths;
177
+ let newProjectPath = prompts[index].projectPath;
178
+ if (updates.projectPaths !== undefined) {
179
+ const normalized = dedupeAndNormalizePaths(updates.projectPaths);
180
+ newProjectPaths = normalized.length > 0 ? normalized : undefined;
181
+ newProjectPath = normalized.length > 0 ? normalized[0] : undefined;
182
+ }
183
+ if (newScope === "project" && (!newProjectPaths || newProjectPaths.length === 0)) {
184
+ throw new Error("Project path is required for project prompts");
185
+ }
186
+ if (newScope === "global") {
187
+ newProjectPaths = undefined;
188
+ newProjectPath = undefined;
189
+ }
190
+
191
+ const updated: SavedPrompt = {
192
+ ...prompts[index],
193
+ name: updates.name !== undefined ? updates.name.trim() : prompts[index].name,
194
+ content: updates.content !== undefined ? updates.content.trim() : prompts[index].content,
195
+ scope: newScope,
196
+ projectPath: newProjectPath,
197
+ projectPaths: newProjectPaths,
198
+ updatedAt: Date.now(),
199
+ };
200
+ prompts[index] = updated;
201
+ savePrompts(prompts);
202
+ return updated;
203
+ }
204
+
205
+ export function deletePrompt(id: string): boolean {
206
+ const prompts = loadPrompts();
207
+ const next = prompts.filter((p) => p.id !== id);
208
+ if (next.length === prompts.length) return false;
209
+ savePrompts(next);
210
+ return true;
211
+ }
@@ -0,0 +1,19 @@
1
+ # Claude Upstream Snapshot
2
+
3
+ This folder contains an offline snapshot of the official Claude Agent SDK TypeScript
4
+ surface used by the bridge compatibility tests.
5
+
6
+ Source package:
7
+ - `@anthropic-ai/claude-agent-sdk@0.2.41`
8
+ - tarball: `https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.41.tgz`
9
+
10
+ Files:
11
+ - `sdk.d.ts.txt` — copied from `package/sdk.d.ts` in the npm tarball
12
+
13
+ Refresh command (example):
14
+ ```bash
15
+ TARBALL=$(npm view @anthropic-ai/claude-agent-sdk dist.tarball)
16
+ curl -fsSL "$TARBALL" -o /tmp/claude-agent-sdk.tgz
17
+ tar -xzf /tmp/claude-agent-sdk.tgz -C /tmp package/sdk.d.ts
18
+ cp /tmp/package/sdk.d.ts web/server/protocol/claude-upstream/sdk.d.ts.txt
19
+ ```