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,299 @@
1
+ /**
2
+ * Disconnection diagnostics for recorded sessions.
3
+ *
4
+ * Analyzes recording entries for connection lifecycle events and data gaps
5
+ * to identify disconnection patterns and potential causes.
6
+ */
7
+
8
+ import type { Recording } from "../replay.js";
9
+ import type { RecordingEntry } from "../recorder.js";
10
+
11
+ // ─── Types ───────────────────────────────────────────────────────────────────
12
+
13
+ export interface TimelineEntry {
14
+ ts: number;
15
+ event: string;
16
+ channel: "cli" | "browser";
17
+ detail?: string;
18
+ }
19
+
20
+ export interface DisconnectionEvent {
21
+ ts: number;
22
+ channel: "cli" | "browser";
23
+ closeCode?: number;
24
+ closeReason?: string;
25
+ reconnectedAt?: number;
26
+ gapMs: number;
27
+ messagesLostEstimate: number;
28
+ }
29
+
30
+ export interface DisconnectionReport {
31
+ sessionId: string;
32
+ backendType: string;
33
+ totalDuration: number;
34
+ totalDisconnections: number;
35
+ disconnections: DisconnectionEvent[];
36
+ patterns: string[];
37
+ dataGaps: DataGap[];
38
+ }
39
+
40
+ export interface DataGap {
41
+ startTs: number;
42
+ endTs: number;
43
+ gapMs: number;
44
+ channel: "cli" | "browser";
45
+ messagesBefore: number;
46
+ messagesAfter: number;
47
+ }
48
+
49
+ // ─── Constants ───────────────────────────────────────────────────────────────
50
+
51
+ /** Gaps longer than this in CLI messages suggest a disconnection. */
52
+ const CLI_GAP_THRESHOLD_MS = 30_000;
53
+ /** Minimum number of disconnections to detect a pattern. */
54
+ const PATTERN_MIN_COUNT = 3;
55
+ /** Tolerance for regular interval detection (±20%). */
56
+ const INTERVAL_TOLERANCE = 0.2;
57
+
58
+ // ─── Analysis ────────────────────────────────────────────────────────────────
59
+
60
+ /**
61
+ * Analyze a recording for disconnection patterns.
62
+ *
63
+ * Works with both legacy recordings (data messages only) and enhanced
64
+ * recordings that include connection lifecycle events.
65
+ */
66
+ export function analyzeDisconnections(recording: Recording): DisconnectionReport {
67
+ const entries = recording.entries;
68
+ const header = recording.header;
69
+
70
+ // Build timeline from both lifecycle events and data gap analysis
71
+ const timeline = buildTimeline(recording);
72
+ const disconnections = detectDisconnections(entries, timeline);
73
+ const dataGaps = detectDataGaps(entries);
74
+ const patterns = detectPatterns(disconnections, dataGaps);
75
+
76
+ const firstTs = entries[0]?.ts ?? header.started_at;
77
+ const lastTs = entries[entries.length - 1]?.ts ?? firstTs;
78
+
79
+ return {
80
+ sessionId: header.session_id,
81
+ backendType: header.backend_type,
82
+ totalDuration: lastTs - firstTs,
83
+ totalDisconnections: disconnections.length,
84
+ disconnections,
85
+ patterns,
86
+ dataGaps,
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Build a timeline of connection events from a recording.
92
+ *
93
+ * Extracts both explicit lifecycle events (ws_open, ws_close, etc.) from
94
+ * enhanced recordings and infers events from data gaps in legacy recordings.
95
+ */
96
+ export function buildTimeline(recording: Recording): TimelineEntry[] {
97
+ const timeline: TimelineEntry[] = [];
98
+
99
+ for (const entry of recording.entries) {
100
+ // Enhanced recordings have explicit lifecycle events
101
+ const enhanced = entry as RecordingEntry & { event?: string; meta?: Record<string, unknown> };
102
+ if (enhanced.event) {
103
+ timeline.push({
104
+ ts: entry.ts,
105
+ event: enhanced.event,
106
+ channel: entry.ch,
107
+ detail: enhanced.meta ? JSON.stringify(enhanced.meta) : undefined,
108
+ });
109
+ continue;
110
+ }
111
+
112
+ // For data messages, track key protocol events
113
+ if (entry.dir === "out" && entry.ch === "browser") {
114
+ try {
115
+ const msg = JSON.parse(entry.raw);
116
+ if (msg.type === "cli_connected") {
117
+ timeline.push({ ts: entry.ts, event: "cli_connected", channel: "cli" });
118
+ } else if (msg.type === "cli_disconnected") {
119
+ timeline.push({ ts: entry.ts, event: "cli_disconnected", channel: "cli" });
120
+ } else if (msg.type === "session_init") {
121
+ timeline.push({ ts: entry.ts, event: "session_init", channel: "cli" });
122
+ }
123
+ } catch {
124
+ // Skip unparseable
125
+ }
126
+ }
127
+ }
128
+
129
+ return timeline.sort((a, b) => a.ts - b.ts);
130
+ }
131
+
132
+ // ─── Internal helpers ────────────────────────────────────────────────────────
133
+
134
+ function detectDisconnections(
135
+ entries: RecordingEntry[],
136
+ timeline: TimelineEntry[],
137
+ ): DisconnectionEvent[] {
138
+ const disconnections: DisconnectionEvent[] = [];
139
+
140
+ // From explicit timeline events
141
+ for (let i = 0; i < timeline.length; i++) {
142
+ const event = timeline[i];
143
+ if (event.event === "ws_close" || event.event === "cli_disconnected") {
144
+ // Find next reconnect/connect event on same channel
145
+ let reconnectedAt: number | undefined;
146
+ for (let j = i + 1; j < timeline.length; j++) {
147
+ if (
148
+ timeline[j].channel === event.channel &&
149
+ (timeline[j].event === "ws_open" || timeline[j].event === "cli_connected" || timeline[j].event === "reconnect_success")
150
+ ) {
151
+ reconnectedAt = timeline[j].ts;
152
+ break;
153
+ }
154
+ }
155
+
156
+ // Estimate messages lost during the gap
157
+ let messagesLost = 0;
158
+ if (reconnectedAt) {
159
+ // Count messages that arrived on the other channel during the gap
160
+ messagesLost = entries.filter(
161
+ (e) =>
162
+ e.ts > event.ts &&
163
+ e.ts < reconnectedAt! &&
164
+ e.ch !== event.channel,
165
+ ).length;
166
+ }
167
+
168
+ let meta: Record<string, unknown> | undefined;
169
+ try {
170
+ meta = event.detail ? JSON.parse(event.detail) : undefined;
171
+ } catch {
172
+ // ignore
173
+ }
174
+
175
+ disconnections.push({
176
+ ts: event.ts,
177
+ channel: event.channel,
178
+ closeCode: meta?.code as number | undefined,
179
+ closeReason: meta?.reason as string | undefined,
180
+ reconnectedAt,
181
+ gapMs: reconnectedAt ? reconnectedAt - event.ts : 0,
182
+ messagesLostEstimate: messagesLost,
183
+ });
184
+ }
185
+ }
186
+
187
+ // Deduplicate: ws_close and cli_disconnected for the same outage.
188
+ // Only dedup if the second disconnect happens before the first one reconnected.
189
+ const deduped: DisconnectionEvent[] = [];
190
+ for (const d of disconnections) {
191
+ const isDuplicate = deduped.some(
192
+ (existing) =>
193
+ existing.channel === d.channel &&
194
+ // Only dedup if this disconnect happened before the previous one reconnected
195
+ // (i.e. same outage, not a new one after recovery)
196
+ (!existing.reconnectedAt || d.ts < existing.reconnectedAt),
197
+ );
198
+ if (!isDuplicate) deduped.push(d);
199
+ }
200
+ return deduped;
201
+ }
202
+
203
+ function detectDataGaps(entries: RecordingEntry[]): DataGap[] {
204
+ const gaps: DataGap[] = [];
205
+
206
+ // Group entries by channel
207
+ const cliEntries = entries.filter((e) => e.ch === "cli" && e.dir === "in" && !e.event);
208
+ const browserEntries = entries.filter((e) => e.ch === "browser" && e.dir === "in" && !e.event);
209
+
210
+ for (const [channel, channelEntries] of [
211
+ ["cli", cliEntries],
212
+ ["browser", browserEntries],
213
+ ] as const) {
214
+ for (let i = 1; i < channelEntries.length; i++) {
215
+ const gapMs = channelEntries[i].ts - channelEntries[i - 1].ts;
216
+ if (gapMs > CLI_GAP_THRESHOLD_MS) {
217
+ gaps.push({
218
+ startTs: channelEntries[i - 1].ts,
219
+ endTs: channelEntries[i].ts,
220
+ gapMs,
221
+ channel,
222
+ messagesBefore: i,
223
+ messagesAfter: channelEntries.length - i,
224
+ });
225
+ }
226
+ }
227
+ }
228
+
229
+ return gaps.sort((a, b) => a.startTs - b.startTs);
230
+ }
231
+
232
+ function detectPatterns(
233
+ disconnections: DisconnectionEvent[],
234
+ dataGaps: DataGap[],
235
+ ): string[] {
236
+ const patterns: string[] = [];
237
+
238
+ // Pattern: Keep-alive failure (regular interval disconnections)
239
+ if (disconnections.length >= PATTERN_MIN_COUNT) {
240
+ const cliDisconnections = disconnections.filter((d) => d.channel === "cli");
241
+ if (cliDisconnections.length >= PATTERN_MIN_COUNT) {
242
+ const intervals = [];
243
+ for (let i = 1; i < cliDisconnections.length; i++) {
244
+ intervals.push(cliDisconnections[i].ts - cliDisconnections[i - 1].ts);
245
+ }
246
+ const avgInterval = intervals.reduce((a, b) => a + b, 0) / intervals.length;
247
+ const allClose = intervals.every(
248
+ (iv) => Math.abs(iv - avgInterval) / avgInterval < INTERVAL_TOLERANCE,
249
+ );
250
+ if (allClose) {
251
+ patterns.push(
252
+ `Regular CLI disconnections every ~${Math.round(avgInterval / 1000)}s — possible keep-alive or timeout issue`,
253
+ );
254
+ }
255
+ }
256
+ }
257
+
258
+ // Pattern: Rapid reconnect cycling
259
+ const rapidReconnects = disconnections.filter(
260
+ (d) => d.reconnectedAt && d.gapMs < 5000,
261
+ );
262
+ if (rapidReconnects.length >= PATTERN_MIN_COUNT) {
263
+ patterns.push(
264
+ `${rapidReconnects.length} rapid reconnections (< 5s gap) — possible flapping connection`,
265
+ );
266
+ }
267
+
268
+ // Pattern: Large data gaps without explicit disconnect events
269
+ const unexplainedGaps = dataGaps.filter((g) => {
270
+ // Check if any disconnection event falls within this gap
271
+ return !disconnections.some(
272
+ (d) => d.ts >= g.startTs && d.ts <= g.endTs,
273
+ );
274
+ });
275
+ if (unexplainedGaps.length > 0) {
276
+ patterns.push(
277
+ `${unexplainedGaps.length} data gap(s) without recorded disconnect events — possible silent connection drops`,
278
+ );
279
+ }
280
+
281
+ // Pattern: Asymmetric disconnection (CLI drops but browser stays)
282
+ const cliOnly = disconnections.filter((d) => d.channel === "cli");
283
+ const browserOnly = disconnections.filter((d) => d.channel === "browser");
284
+ if (cliOnly.length > 0 && browserOnly.length === 0) {
285
+ patterns.push(
286
+ `All ${cliOnly.length} disconnection(s) are CLI-side — browser connections are stable. Check CLI process health.`,
287
+ );
288
+ } else if (browserOnly.length > 0 && cliOnly.length === 0) {
289
+ patterns.push(
290
+ `All ${browserOnly.length} disconnection(s) are browser-side — CLI connection is stable. Check network/proxy.`,
291
+ );
292
+ }
293
+
294
+ if (patterns.length === 0 && disconnections.length === 0 && dataGaps.length === 0) {
295
+ patterns.push("No disconnection issues detected in this recording.");
296
+ }
297
+
298
+ return patterns;
299
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Feature gate for the Recording Hub.
3
+ *
4
+ * The hub is disabled by default. Enable with HEYHANK_RECORDING_HUB=1 (or COMPANION_RECORDING_HUB=1).
5
+ * When disabled, hub routes are not registered and hub storage is not initialized.
6
+ */
7
+
8
+ const DEFAULT_MAX_UPLOAD_MB = 50;
9
+
10
+ export function isRecordingHubEnabled(): boolean {
11
+ const env = process.env.HEYHANK_RECORDING_HUB || process.env.COMPANION_RECORDING_HUB;
12
+ return env === "1" || env === "true";
13
+ }
14
+
15
+ export function getMaxUploadBytes(): number {
16
+ const parsed = Number(process.env.HEYHANK_HUB_MAX_UPLOAD_MB || process.env.COMPANION_HUB_MAX_UPLOAD_MB);
17
+ const mb = Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_UPLOAD_MB;
18
+ return mb * 1024 * 1024;
19
+ }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * REST API routes for the Recording Hub.
3
+ *
4
+ * All routes are under /api/hub/ and only registered when HEYHANK_RECORDING_HUB=1.
5
+ */
6
+
7
+ import type { Hono } from "hono";
8
+ import { join, resolve, sep } from "node:path";
9
+ import { existsSync } from "node:fs";
10
+ import { HubStore } from "./hub-store.js";
11
+ import type { ReplayAdapter } from "./replay-adapter.js";
12
+ import type { WsBridge } from "../ws-bridge.js";
13
+
14
+ // ─── Types ───────────────────────────────────────────────────────────────────
15
+
16
+ interface HubRoutesOptions {
17
+ wsBridge: WsBridge;
18
+ recordingsDir: string; // Auto-recording directory for import-local
19
+ }
20
+
21
+ // ─── Route Registration ─────────────────────────────────────────────────────
22
+
23
+ export function registerHubRoutes(api: Hono, options: HubRoutesOptions): void {
24
+ const store = new HubStore();
25
+ const replayAdapters = new Map<string, ReplayAdapter>();
26
+
27
+ // ── Recording CRUD ────────────────────────────────────────────────────
28
+
29
+ api.get("/hub/recordings", (c) => {
30
+ return c.json(store.list());
31
+ });
32
+
33
+ api.get("/hub/recordings/:id", (c) => {
34
+ const meta = store.get(c.req.param("id"));
35
+ if (!meta) return c.json({ error: "Recording not found" }, 404);
36
+ return c.json(meta);
37
+ });
38
+
39
+ api.get("/hub/recordings/:id/summary", (c) => {
40
+ const summary = store.getSummary(c.req.param("id"));
41
+ if (!summary) return c.json({ error: "Recording not found" }, 404);
42
+ return c.json(summary);
43
+ });
44
+
45
+ api.delete("/hub/recordings/:id", (c) => {
46
+ const deleted = store.delete(c.req.param("id"));
47
+ if (!deleted) return c.json({ error: "Recording not found" }, 404);
48
+ return c.json({ ok: true });
49
+ });
50
+
51
+ // ── Upload (raw JSONL content in body) ────────────────────────────────
52
+
53
+ api.post("/hub/recordings/upload", async (c) => {
54
+ try {
55
+ const contentType = c.req.header("content-type") || "";
56
+
57
+ let content: string;
58
+ let originalFilename: string | undefined;
59
+
60
+ if (contentType.includes("multipart/form-data")) {
61
+ const formData = await c.req.formData();
62
+ const file = formData.get("file");
63
+ if (!file || !(file instanceof File)) {
64
+ return c.json({ error: "Missing 'file' field in multipart form" }, 400);
65
+ }
66
+ content = await file.text();
67
+ originalFilename = file.name;
68
+ } else {
69
+ // Plain text body
70
+ content = await c.req.text();
71
+ }
72
+
73
+ const meta = store.importContent(content, originalFilename);
74
+ return c.json(meta, 201);
75
+ } catch (e: unknown) {
76
+ return c.json({ error: e instanceof Error ? e.message : String(e) }, 400);
77
+ }
78
+ });
79
+
80
+ // ── Import from local auto-recordings ─────────────────────────────────
81
+
82
+ api.post("/hub/recordings/import-local", async (c) => {
83
+ try {
84
+ const body = await c.req.json().catch(() => ({} as { filename?: string }));
85
+ if (!body.filename) {
86
+ return c.json({ error: "Missing 'filename' field" }, 400);
87
+ }
88
+ const sourcePath = join(options.recordingsDir, body.filename);
89
+ const resolvedSource = resolve(sourcePath);
90
+ const resolvedBase = resolve(options.recordingsDir);
91
+ if (!resolvedSource.startsWith(resolvedBase + sep) && resolvedSource !== resolvedBase) {
92
+ return c.json({ error: "Invalid filename" }, 400);
93
+ }
94
+ if (!existsSync(sourcePath)) {
95
+ return c.json({ error: "Recording file not found in auto-recordings directory" }, 404);
96
+ }
97
+ const meta = store.importLocal(sourcePath);
98
+ return c.json(meta, 201);
99
+ } catch (e: unknown) {
100
+ return c.json({ error: e instanceof Error ? e.message : String(e) }, 400);
101
+ }
102
+ });
103
+
104
+ // ── Tags ──────────────────────────────────────────────────────────────
105
+
106
+ api.put("/hub/recordings/:id/tags", async (c) => {
107
+ const body = await c.req.json().catch(() => ({} as { tags?: string[] }));
108
+ if (!Array.isArray(body.tags)) {
109
+ return c.json({ error: "Missing 'tags' array" }, 400);
110
+ }
111
+ const meta = store.updateTags(c.req.param("id"), body.tags);
112
+ if (!meta) return c.json({ error: "Recording not found" }, 404);
113
+ return c.json(meta);
114
+ });
115
+
116
+ // ── Replay Sessions ───────────────────────────────────────────────────
117
+
118
+ api.post("/hub/replay", async (c) => {
119
+ try {
120
+ const body = await c.req.json().catch(() => ({} as { recordingId?: string; speed?: number }));
121
+ if (!body.recordingId) {
122
+ return c.json({ error: "Missing 'recordingId'" }, 400);
123
+ }
124
+
125
+ if (body.speed !== undefined && (typeof body.speed !== "number" || body.speed <= 0)) {
126
+ return c.json({ error: "Invalid 'speed' value" }, 400);
127
+ }
128
+
129
+ const recording = store.loadRecording(body.recordingId);
130
+ if (!recording) {
131
+ return c.json({ error: "Recording not found" }, 404);
132
+ }
133
+
134
+ // Lazy import to avoid circular dependency at module load time
135
+ const { ReplayAdapter } = await import("./replay-adapter.js");
136
+
137
+ const replaySessionId = `replay-${Date.now().toString(36)}`;
138
+ const speed = body.speed ?? 1;
139
+ const adapter = new ReplayAdapter(recording, speed);
140
+
141
+ options.wsBridge.attachBackendAdapter(
142
+ replaySessionId,
143
+ adapter,
144
+ recording.header.backend_type,
145
+ );
146
+
147
+ replayAdapters.set(replaySessionId, adapter);
148
+
149
+ // Clean up when replay finishes
150
+ adapter.onDisconnect(() => {
151
+ replayAdapters.delete(replaySessionId);
152
+ });
153
+
154
+ // Start playback
155
+ adapter.play();
156
+
157
+ return c.json({
158
+ sessionId: replaySessionId,
159
+ backendType: recording.header.backend_type,
160
+ speed,
161
+ entryCount: recording.entries.length,
162
+ }, 201);
163
+ } catch (e: unknown) {
164
+ return c.json({ error: e instanceof Error ? e.message : String(e) }, 400);
165
+ }
166
+ });
167
+
168
+ api.post("/hub/replay/:sessionId/pause", (c) => {
169
+ const adapter = replayAdapters.get(c.req.param("sessionId"));
170
+ if (!adapter) return c.json({ error: "Replay session not found" }, 404);
171
+ adapter.pause();
172
+ return c.json({ ok: true, paused: true });
173
+ });
174
+
175
+ api.post("/hub/replay/:sessionId/resume", (c) => {
176
+ const adapter = replayAdapters.get(c.req.param("sessionId"));
177
+ if (!adapter) return c.json({ error: "Replay session not found" }, 404);
178
+ adapter.play();
179
+ return c.json({ ok: true, paused: false });
180
+ });
181
+
182
+ api.post("/hub/replay/:sessionId/speed", async (c) => {
183
+ const adapter = replayAdapters.get(c.req.param("sessionId"));
184
+ if (!adapter) return c.json({ error: "Replay session not found" }, 404);
185
+ const body = await c.req.json().catch(() => ({} as { speed?: number }));
186
+ if (typeof body.speed !== "number" || body.speed <= 0) {
187
+ return c.json({ error: "Invalid 'speed' value" }, 400);
188
+ }
189
+ adapter.setSpeed(body.speed);
190
+ return c.json({ ok: true, speed: body.speed });
191
+ });
192
+
193
+ api.get("/hub/replay/:sessionId/progress", (c) => {
194
+ const adapter = replayAdapters.get(c.req.param("sessionId"));
195
+ if (!adapter) return c.json({ error: "Replay session not found" }, 404);
196
+ return c.json(adapter.getProgress());
197
+ });
198
+
199
+ // ── Compatibility Validation ──────────────────────────────────────────
200
+
201
+ api.post("/hub/recordings/:id/validate", async (c) => {
202
+ try {
203
+ const recording = store.loadRecording(c.req.param("id"));
204
+ if (!recording) return c.json({ error: "Recording not found" }, 404);
205
+ const { validateRecording } = await import("./compat-validator.js");
206
+ const result = validateRecording(recording);
207
+ return c.json(result);
208
+ } catch (e: unknown) {
209
+ return c.json({ error: e instanceof Error ? e.message : String(e) }, 500);
210
+ }
211
+ });
212
+
213
+ // ── Disconnection Diagnostics ─────────────────────────────────────────
214
+
215
+ api.get("/hub/recordings/:id/diagnostics", async (c) => {
216
+ try {
217
+ const recording = store.loadRecording(c.req.param("id"));
218
+ if (!recording) return c.json({ error: "Recording not found" }, 404);
219
+ const { analyzeDisconnections } = await import("./diagnostics.js");
220
+ return c.json(analyzeDisconnections(recording));
221
+ } catch (e: unknown) {
222
+ return c.json({ error: e instanceof Error ? e.message : String(e) }, 500);
223
+ }
224
+ });
225
+
226
+ api.get("/hub/recordings/:id/timeline", async (c) => {
227
+ try {
228
+ const recording = store.loadRecording(c.req.param("id"));
229
+ if (!recording) return c.json({ error: "Recording not found" }, 404);
230
+ const { buildTimeline } = await import("./diagnostics.js");
231
+ return c.json(buildTimeline(recording));
232
+ } catch (e: unknown) {
233
+ return c.json({ error: e instanceof Error ? e.message : String(e) }, 500);
234
+ }
235
+ });
236
+ }