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,84 @@
1
+ import {
2
+ mkdirSync,
3
+ readFileSync,
4
+ writeFileSync,
5
+ existsSync,
6
+ } from "node:fs";
7
+ import { join, dirname } from "node:path";
8
+ import { HEYHANK_HOME } from "./paths.js";
9
+
10
+ // ─── Types ──────────────────────────────────────────────────────────────────
11
+
12
+ export interface WorktreeMapping {
13
+ sessionId: string;
14
+ repoRoot: string;
15
+ branch: string;
16
+ /** Actual git branch in the worktree (may differ from `branch` for -wt-N branches) */
17
+ actualBranch?: string;
18
+ worktreePath: string;
19
+ createdAt: number;
20
+ }
21
+
22
+ // ─── Paths ──────────────────────────────────────────────────────────────────
23
+
24
+ const TRACKER_PATH = join(HEYHANK_HOME, "worktrees.json");
25
+
26
+ // ─── Tracker ────────────────────────────────────────────────────────────────
27
+
28
+ export class WorktreeTracker {
29
+ private mappings: WorktreeMapping[] = [];
30
+
31
+ constructor() {
32
+ this.load();
33
+ }
34
+
35
+ load(): WorktreeMapping[] {
36
+ try {
37
+ if (existsSync(TRACKER_PATH)) {
38
+ const raw = readFileSync(TRACKER_PATH, "utf-8");
39
+ this.mappings = JSON.parse(raw) as WorktreeMapping[];
40
+ }
41
+ } catch {
42
+ this.mappings = [];
43
+ }
44
+ return this.mappings;
45
+ }
46
+
47
+ private save(): void {
48
+ mkdirSync(dirname(TRACKER_PATH), { recursive: true });
49
+ writeFileSync(TRACKER_PATH, JSON.stringify(this.mappings, null, 2), "utf-8");
50
+ }
51
+
52
+ addMapping(mapping: WorktreeMapping): void {
53
+ // Remove any existing mapping for this session
54
+ this.mappings = this.mappings.filter((m) => m.sessionId !== mapping.sessionId);
55
+ this.mappings.push(mapping);
56
+ this.save();
57
+ }
58
+
59
+ removeBySession(sessionId: string): WorktreeMapping | null {
60
+ const idx = this.mappings.findIndex((m) => m.sessionId === sessionId);
61
+ if (idx === -1) return null;
62
+ const [removed] = this.mappings.splice(idx, 1);
63
+ this.save();
64
+ return removed;
65
+ }
66
+
67
+ getBySession(sessionId: string): WorktreeMapping | null {
68
+ return this.mappings.find((m) => m.sessionId === sessionId) || null;
69
+ }
70
+
71
+ getSessionsForWorktree(worktreePath: string): WorktreeMapping[] {
72
+ return this.mappings.filter((m) => m.worktreePath === worktreePath);
73
+ }
74
+
75
+ getSessionsForRepo(repoRoot: string): WorktreeMapping[] {
76
+ return this.mappings.filter((m) => m.repoRoot === repoRoot);
77
+ }
78
+
79
+ isWorktreeInUse(worktreePath: string, excludeSessionId?: string): boolean {
80
+ return this.mappings.some(
81
+ (m) => m.worktreePath === worktreePath && m.sessionId !== excludeSessionId,
82
+ );
83
+ }
84
+ }
@@ -0,0 +1,41 @@
1
+ import { verifyToken } from "./middleware/managed-auth.js";
2
+
3
+ export interface WsAuthResult {
4
+ ok: boolean;
5
+ status: number;
6
+ body?: string;
7
+ }
8
+
9
+ function getCookie(header: string | null, name: string): string | undefined {
10
+ if (!header) return undefined;
11
+ const match = header.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`));
12
+ return match?.[1];
13
+ }
14
+
15
+ /**
16
+ * Authenticate browser/terminal WebSocket upgrade requests in managed mode.
17
+ * Accepts token in query param or heyhank_token cookie (query takes precedence).
18
+ */
19
+ export async function authenticateManagedWebSocket(req: Request): Promise<WsAuthResult> {
20
+ const secret = (process.env.HEYHANK_AUTH_SECRET || process.env.COMPANION_AUTH_SECRET)?.trim();
21
+ if (!secret) {
22
+ return { ok: false, status: 500, body: "Server misconfigured" };
23
+ }
24
+
25
+ const url = new URL(req.url);
26
+ const queryToken = url.searchParams.get("token");
27
+ const cookieToken = getCookie(req.headers.get("cookie"), "heyhank_token") || getCookie(req.headers.get("cookie"), "companion_token");
28
+ const token = queryToken || cookieToken;
29
+
30
+ if (!token) {
31
+ return { ok: false, status: 401, body: "Unauthorized" };
32
+ }
33
+
34
+ const valid = await verifyToken(token, secret);
35
+ if (!valid) {
36
+ return { ok: false, status: 401, body: "Unauthorized" };
37
+ }
38
+
39
+ return { ok: true, status: 200 };
40
+ }
41
+
@@ -0,0 +1,72 @@
1
+ import type { BrowserOutgoingMessage } from "./session-types.js";
2
+ import type { Session } from "./ws-bridge-types.js";
3
+ import {
4
+ isDuplicateClientMessage,
5
+ rememberClientMessage,
6
+ } from "./ws-bridge-replay.js";
7
+
8
+ // ─── Browser Ingest Pipeline ────────────────────────────────────────────────
9
+ // Pure functions for parsing and deduplicating browser WebSocket messages.
10
+ // Extracted from WsBridge.handleBrowserMessage and routeBrowserMessage
11
+ // to enable isolated testing of idempotent message scenarios.
12
+
13
+ /** Message types that support client_msg_id-based deduplication. */
14
+ export const IDEMPOTENT_BROWSER_MESSAGE_TYPES: ReadonlySet<string> = new Set([
15
+ "user_message",
16
+ "permission_response",
17
+ "interrupt",
18
+ "set_model",
19
+ "set_permission_mode",
20
+ "mcp_get_status",
21
+ "mcp_toggle",
22
+ "mcp_reconnect",
23
+ "mcp_set_servers",
24
+ "set_ai_validation",
25
+ ]);
26
+
27
+ /**
28
+ * Parse a raw browser WebSocket message into a typed BrowserOutgoingMessage.
29
+ * Returns null if parsing fails (malformed JSON).
30
+ */
31
+ export function parseBrowserMessage(raw: string | Buffer): BrowserOutgoingMessage | null {
32
+ const data = typeof raw === "string" ? raw : raw.toString("utf-8");
33
+ try {
34
+ return JSON.parse(data) as BrowserOutgoingMessage;
35
+ } catch {
36
+ console.warn(`[ws-bridge] Failed to parse browser message: ${data.substring(0, 200)}`);
37
+ return null;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Check if a browser message is a duplicate based on client_msg_id.
43
+ * Returns true if the message should be skipped.
44
+ *
45
+ * Only checks messages whose type is in `idempotentTypes` and that have
46
+ * a non-empty `client_msg_id` field. For non-idempotent types or messages
47
+ * without client_msg_id, always returns false.
48
+ *
49
+ * If not a duplicate, remembers the client_msg_id for future dedup checks.
50
+ */
51
+ export function deduplicateBrowserMessage(
52
+ msg: BrowserOutgoingMessage,
53
+ idempotentTypes: ReadonlySet<string>,
54
+ session: Session,
55
+ processedIdLimit: number,
56
+ persistFn: (session: Session) => void,
57
+ ): boolean {
58
+ if (
59
+ !idempotentTypes.has(msg.type)
60
+ || !("client_msg_id" in msg)
61
+ || !msg.client_msg_id
62
+ ) {
63
+ return false;
64
+ }
65
+
66
+ if (isDuplicateClientMessage(session, msg.client_msg_id)) {
67
+ return true;
68
+ }
69
+
70
+ rememberClientMessage(session, msg.client_msg_id, processedIdLimit, persistFn);
71
+ return false;
72
+ }
@@ -0,0 +1,112 @@
1
+ import type { ServerWebSocket } from "bun";
2
+ import type { BrowserSocketData, Session, SocketData } from "./ws-bridge-types.js";
3
+ import type {
4
+ BrowserIncomingMessage,
5
+ ReplayableBrowserIncomingMessage,
6
+ } from "./session-types.js";
7
+
8
+ /**
9
+ * Infer the CLI's current status from server-side session state.
10
+ * Used as a ground-truth correction after event replay to prevent
11
+ * stale "running"/"generating" state when `result` was pruned from
12
+ * the event buffer.
13
+ */
14
+ function inferCliStatus(session: Session): "idle" | "running" | "compacting" | null {
15
+ if (session.state.is_compacting) return "compacting";
16
+ const last = session.messageHistory[session.messageHistory.length - 1];
17
+ if (!last) return "idle";
18
+ // `result` means the last turn completed → idle
19
+ if (last.type === "result") return "idle";
20
+ // `assistant` means CLI sent a response and is executing tools or streaming → running
21
+ if (last.type === "assistant") return "running";
22
+ // For other types (user_message, system_event), default to idle
23
+ return "idle";
24
+ }
25
+
26
+ export function handleSessionSubscribe(
27
+ session: Session,
28
+ ws: ServerWebSocket<SocketData> | undefined,
29
+ lastSeq: number,
30
+ sendToBrowser: (ws: ServerWebSocket<SocketData>, msg: BrowserIncomingMessage) => void,
31
+ isHistoryBackedEvent: (msg: ReplayableBrowserIncomingMessage) => boolean,
32
+ ): void {
33
+ if (!ws) return;
34
+ const data = ws.data as BrowserSocketData;
35
+ data.subscribed = true;
36
+ const lastAckSeq = Number.isFinite(lastSeq) ? Math.max(0, Math.floor(lastSeq)) : 0;
37
+ data.lastAckSeq = lastAckSeq;
38
+
39
+ if (lastAckSeq === 0 && session.messageHistory.length > 0) {
40
+ sendToBrowser(ws, {
41
+ type: "message_history",
42
+ messages: session.messageHistory,
43
+ });
44
+ }
45
+
46
+ if (session.eventBuffer.length === 0) return;
47
+ if (lastAckSeq >= session.nextEventSeq - 1) return;
48
+
49
+ const earliest = session.eventBuffer[0]?.seq ?? session.nextEventSeq;
50
+ const hasGap = lastAckSeq > 0 && lastAckSeq < earliest - 1;
51
+ if (hasGap) {
52
+ if (session.messageHistory.length > 0) {
53
+ sendToBrowser(ws, {
54
+ type: "message_history",
55
+ messages: session.messageHistory,
56
+ });
57
+ }
58
+ const transientMissed = session.eventBuffer
59
+ .filter((evt) => evt.seq > lastAckSeq && !isHistoryBackedEvent(evt.message));
60
+ if (transientMissed.length > 0) {
61
+ sendToBrowser(ws, {
62
+ type: "event_replay",
63
+ events: transientMissed,
64
+ });
65
+ }
66
+ // Send ground-truth status after replay to correct stale streaming state
67
+ sendToBrowser(ws, { type: "status_change", status: inferCliStatus(session) });
68
+ // Send authoritative session_phase so replayed transient phases don't leave stale cliConnected
69
+ sendToBrowser(ws, {
70
+ type: "session_phase",
71
+ phase: session.stateMachine.phase,
72
+ previousPhase: session.stateMachine.phase,
73
+ });
74
+ return;
75
+ }
76
+
77
+ const sentFullHistory = lastAckSeq === 0 && session.messageHistory.length > 0;
78
+ const missed = session.eventBuffer.filter(
79
+ (evt) => evt.seq > lastAckSeq && (!sentFullHistory || !isHistoryBackedEvent(evt.message)),
80
+ );
81
+ if (missed.length === 0) return;
82
+ sendToBrowser(ws, {
83
+ type: "event_replay",
84
+ events: missed,
85
+ });
86
+ // Send ground-truth status after replay to correct stale streaming state
87
+ sendToBrowser(ws, { type: "status_change", status: inferCliStatus(session) });
88
+ // Send authoritative session_phase so replayed transient phases don't leave stale cliConnected
89
+ sendToBrowser(ws, {
90
+ type: "session_phase",
91
+ phase: session.stateMachine.phase,
92
+ previousPhase: session.stateMachine.phase,
93
+ });
94
+ }
95
+
96
+ export function handleSessionAck(
97
+ session: Session,
98
+ ws: ServerWebSocket<SocketData> | undefined,
99
+ lastSeq: number,
100
+ persistSession: (session: Session) => void,
101
+ ): void {
102
+ const normalized = Number.isFinite(lastSeq) ? Math.max(0, Math.floor(lastSeq)) : 0;
103
+ if (ws) {
104
+ const data = ws.data as BrowserSocketData;
105
+ const prior = typeof data.lastAckSeq === "number" ? data.lastAckSeq : 0;
106
+ data.lastAckSeq = Math.max(prior, normalized);
107
+ }
108
+ if (normalized > session.lastAckSeq) {
109
+ session.lastAckSeq = normalized;
110
+ persistSession(session);
111
+ }
112
+ }
@@ -0,0 +1,81 @@
1
+ import type { CLIMessage } from "./session-types.js";
2
+
3
+ // ─── CLI Ingest Pipeline ────────────────────────────────────────────────────
4
+ // Pure functions for parsing and deduplicating CLI (NDJSON) messages.
5
+ // Extracted from WsBridge.handleCLIMessage to enable isolated testing
6
+ // of reconnect/replay deduplication scenarios.
7
+
8
+ /** State needed for CLI message deduplication. Matches a subset of Session. */
9
+ export interface CLIDedupState {
10
+ recentCLIMessageHashes: string[];
11
+ recentCLIMessageHashSet: Set<string>;
12
+ }
13
+
14
+ /**
15
+ * Parse raw NDJSON data into individual line strings.
16
+ * Splits on newlines and filters blank lines.
17
+ */
18
+ export function parseNDJSON(raw: string | Buffer): string[] {
19
+ const data = typeof raw === "string" ? raw : raw.toString("utf-8");
20
+ return data.split("\n").filter((l) => l.trim());
21
+ }
22
+
23
+ /**
24
+ * Check if a CLI message is a duplicate based on a rolling hash window.
25
+ * On WS reconnect, the CLI replays in-flight messages; this dedup prevents
26
+ * duplicates from reaching downstream handlers.
27
+ *
28
+ * - `assistant`, `result`, `system` messages: deduped by content hash (Bun.hash)
29
+ * - `stream_event` messages: deduped by their stable `uuid` field
30
+ * - All other types (keep_alive, control_request, tool_progress, etc.): never deduped
31
+ *
32
+ * Returns true if the message is a duplicate and should be skipped.
33
+ * Mutates the dedupState window as a side effect.
34
+ */
35
+ export function isDuplicateCLIMessage(
36
+ msg: CLIMessage,
37
+ rawLine: string,
38
+ state: CLIDedupState,
39
+ windowSize: number,
40
+ ): boolean {
41
+ if (msg.type === "assistant" || msg.type === "result" || msg.type === "system") {
42
+ // Namespace with "h:" prefix to prevent collisions with uuid-based keys
43
+ const key = `h:${Bun.hash(rawLine).toString(36)}`;
44
+ if (state.recentCLIMessageHashSet.has(key)) {
45
+ return true;
46
+ }
47
+ state.recentCLIMessageHashes.push(key);
48
+ state.recentCLIMessageHashSet.add(key);
49
+ while (state.recentCLIMessageHashes.length > windowSize) {
50
+ const old = state.recentCLIMessageHashes.shift()!;
51
+ state.recentCLIMessageHashSet.delete(old);
52
+ }
53
+ return false;
54
+ }
55
+
56
+ if (msg.type === "stream_event" && (msg as { uuid?: string }).uuid) {
57
+ // Namespace with "u:" prefix to prevent collisions with hash-based keys.
58
+ // Current CLI versions (1.0+) always provide UUIDs on stream_event messages.
59
+ // UUID-less stream_events from older protocol versions fall through to no-dedup below.
60
+ const key = `u:${(msg as { uuid: string }).uuid}`;
61
+ if (state.recentCLIMessageHashSet.has(key)) {
62
+ return true;
63
+ }
64
+ state.recentCLIMessageHashes.push(key);
65
+ state.recentCLIMessageHashSet.add(key);
66
+ while (state.recentCLIMessageHashes.length > windowSize) {
67
+ const old = state.recentCLIMessageHashes.shift()!;
68
+ state.recentCLIMessageHashSet.delete(old);
69
+ }
70
+ return false;
71
+ }
72
+
73
+ // All other message types (keep_alive, control_request, tool_progress, etc.)
74
+ // are never considered duplicates — they're either stateless or handled by
75
+ // separate mechanisms. stream_event without uuid also falls through here;
76
+ // current CLI versions (1.0+) always provide UUIDs, but older protocol
77
+ // versions may not. In that case, reconnect replay could produce duplicate
78
+ // stream content in the UI — acceptable since stream_events are transient
79
+ // and the final assistant message is always deduplicated.
80
+ return false;
81
+ }
@@ -0,0 +1,266 @@
1
+ import type {
2
+ BrowserIncomingMessage,
3
+ BrowserOutgoingMessage,
4
+ PermissionRequest,
5
+ } from "./session-types.js";
6
+ import type { CodexAdapter } from "./codex-adapter.js";
7
+ import type { Session } from "./ws-bridge-types.js";
8
+ import { appendHistory } from "./ws-bridge-persist.js";
9
+ import { validatePermission } from "./ai-validator.js";
10
+ import { getEffectiveAiValidation } from "./ai-validation-settings.js";
11
+ import { heyHankBus } from "./event-bus.js";
12
+
13
+ /**
14
+ * @deprecated This file is no longer used in production. Codex adapters are now
15
+ * wired through the unified `attachBackendAdapter()` pipeline in `ws-bridge.ts`.
16
+ * This file is kept only for its test coverage which validates Codex-specific
17
+ * adapter handler logic patterns. It will be removed in a future cleanup pass.
18
+ */
19
+
20
+ export interface CodexAttachDeps {
21
+ persistSession: (session: Session) => void;
22
+ refreshGitInfo: (
23
+ session: Session,
24
+ options?: { broadcastUpdate?: boolean; notifyPoller?: boolean },
25
+ ) => void;
26
+ broadcastToBrowsers: (session: Session, msg: BrowserIncomingMessage) => void;
27
+ autoNamingAttempted: Set<string>;
28
+ }
29
+
30
+ export function attachCodexAdapterHandlers(
31
+ sessionId: string,
32
+ session: Session,
33
+ adapter: CodexAdapter,
34
+ deps: CodexAttachDeps,
35
+ ): void {
36
+ adapter.onBrowserMessage((msg) => {
37
+ // Track activity for idle detection — mirrors routeCLIMessage logic for
38
+ // Claude Code NDJSON. Without this, Codex sessions get incorrectly
39
+ // idle-killed because lastCliActivityTs is never updated.
40
+ session.lastCliActivityTs = Date.now();
41
+
42
+ if (msg.type === "session_init") {
43
+ // Preserve pre-populated commands/skills when adapter sends empty arrays
44
+ // (Codex does not provide its own commands/skills)
45
+ // Exclude session_id: the adapter may report its own internal session ID
46
+ // which differs from the HeyHank's session ID. Allowing it to overwrite
47
+ // session.state.session_id causes duplicate sidebar entries.
48
+ const { slash_commands, skills, session_id: _cliSessionId, ...rest } = msg.session;
49
+ session.state = {
50
+ ...session.state,
51
+ ...rest,
52
+ ...(slash_commands?.length ? { slash_commands } : {}),
53
+ ...(skills?.length ? { skills } : {}),
54
+ backend_type: "codex",
55
+ };
56
+ deps.refreshGitInfo(session, { notifyPoller: true });
57
+ deps.persistSession(session);
58
+ session.stateMachine.transition("ready", "codex_session_init");
59
+ } else if (msg.type === "session_update") {
60
+ // Exclude session_id — same rationale as session_init above.
61
+ const { slash_commands, skills, session_id: _cliSessionId, ...rest } = msg.session;
62
+ session.state = {
63
+ ...session.state,
64
+ ...rest,
65
+ ...(slash_commands?.length ? { slash_commands } : {}),
66
+ ...(skills?.length ? { skills } : {}),
67
+ backend_type: "codex",
68
+ };
69
+ deps.refreshGitInfo(session, { notifyPoller: true });
70
+ deps.persistSession(session);
71
+ } else if (msg.type === "status_change") {
72
+ session.state.is_compacting = msg.status === "compacting";
73
+ if (msg.status === "compacting") {
74
+ session.stateMachine.transition("compacting", "codex_compacting_started");
75
+ } else {
76
+ session.stateMachine.transition("ready", "codex_compacting_ended");
77
+ }
78
+ deps.persistSession(session);
79
+ }
80
+
81
+ if (msg.type === "assistant") {
82
+ const assistantMsg = { ...msg, timestamp: msg.timestamp || Date.now() };
83
+ appendHistory(session, assistantMsg);
84
+ deps.persistSession(session);
85
+ heyHankBus.emit("message:assistant", { sessionId, message: assistantMsg });
86
+ } else if (msg.type === "result") {
87
+ appendHistory(session, msg);
88
+ deps.persistSession(session);
89
+ heyHankBus.emit("message:result", { sessionId, message: msg });
90
+ session.stateMachine.transition("ready", "codex_turn_completed");
91
+ }
92
+
93
+ if (msg.type === "assistant") {
94
+ const content = (msg as { message?: { content?: Array<{ type: string }> } }).message?.content;
95
+ const hasToolUse = content?.some((b) => b.type === "tool_use");
96
+ if (hasToolUse) {
97
+ console.log(`[ws-bridge] Broadcasting tool_use assistant to ${session.browserSockets.size} browser(s) for session ${session.id}`);
98
+ }
99
+ }
100
+
101
+ if (msg.type === "permission_cancelled") {
102
+ const reqId = (msg as { request_id: string }).request_id;
103
+ session.pendingPermissions.delete(reqId);
104
+ // If no more pending permissions, transition back to streaming
105
+ if (session.pendingPermissions.size === 0 && session.stateMachine.phase === "awaiting_permission") {
106
+ session.stateMachine.transition("streaming", "permission_cancelled");
107
+ }
108
+ deps.persistSession(session);
109
+ }
110
+
111
+ if (msg.type === "permission_request") {
112
+ const perm = msg.request;
113
+
114
+ // AI Validation Mode for Codex sessions
115
+ const aiSettings = getEffectiveAiValidation(session.state);
116
+ if (
117
+ aiSettings.enabled
118
+ && aiSettings.anthropicApiKey
119
+ && perm.tool_name !== "AskUserQuestion"
120
+ && perm.tool_name !== "ExitPlanMode"
121
+ ) {
122
+ // Run AI validation async — don't broadcast yet
123
+ handleCodexAiValidation(session, adapter, perm, deps).catch((err) => {
124
+ console.warn(`[ws-bridge-codex] AI validation error for tool=${perm.tool_name} request_id=${perm.request_id} session=${session.id}, falling through to manual:`, err);
125
+ // On error, fall through to normal permission flow
126
+ session.pendingPermissions.set(perm.request_id, perm);
127
+ session.stateMachine.transition("awaiting_permission", "ai_validation_error_fallback");
128
+ deps.persistSession(session);
129
+ deps.broadcastToBrowsers(session, msg);
130
+ });
131
+ return;
132
+ }
133
+
134
+ session.pendingPermissions.set(perm.request_id, perm);
135
+ deps.persistSession(session);
136
+ session.stateMachine.transition("awaiting_permission", "codex_permission_requested");
137
+ }
138
+
139
+ deps.broadcastToBrowsers(session, msg);
140
+
141
+ if (
142
+ msg.type === "result" &&
143
+ !(msg.data as { is_error?: boolean }).is_error &&
144
+ !deps.autoNamingAttempted.has(session.id)
145
+ ) {
146
+ deps.autoNamingAttempted.add(session.id);
147
+ const firstUserMsg = session.messageHistory.find((m) => m.type === "user_message");
148
+ if (firstUserMsg && firstUserMsg.type === "user_message") {
149
+ heyHankBus.emit("session:first-turn-completed", { sessionId: session.id, firstUserMessage: firstUserMsg.content });
150
+ }
151
+ }
152
+ });
153
+
154
+ adapter.onSessionMeta((meta) => {
155
+ if (meta.cliSessionId) {
156
+ heyHankBus.emit("session:cli-id-received", { sessionId: session.id, cliSessionId: meta.cliSessionId });
157
+ }
158
+ if (meta.model) session.state.model = meta.model;
159
+ if (meta.cwd) session.state.cwd = meta.cwd;
160
+ session.state.backend_type = "codex";
161
+ deps.refreshGitInfo(session, { broadcastUpdate: true, notifyPoller: true });
162
+ deps.persistSession(session);
163
+ });
164
+
165
+ adapter.onDisconnect(() => {
166
+ // Guard: only clear the adapter reference if THIS adapter is still the active
167
+ // one. During relaunch, a NEW adapter is attached before the OLD one fires
168
+ // its disconnect callback — without this check the new adapter gets nulled out.
169
+ if (session.backendAdapter !== adapter) {
170
+ console.log(`[ws-bridge] Ignoring stale disconnect for session ${sessionId} (adapter replaced)`);
171
+ return;
172
+ }
173
+ for (const [reqId] of session.pendingPermissions) {
174
+ deps.broadcastToBrowsers(session, { type: "permission_cancelled", request_id: reqId });
175
+ }
176
+ session.pendingPermissions.clear();
177
+ session.backendAdapter = null;
178
+ deps.persistSession(session);
179
+ console.log(`[ws-bridge] Codex adapter disconnected for session ${sessionId}`);
180
+ deps.broadcastToBrowsers(session, { type: "cli_disconnected" });
181
+
182
+ // Auto-relaunch if browsers are still connected (don't leave users staring
183
+ // at a dead session when the transport drops mid-conversation).
184
+ if (session.browserSockets.size > 0) {
185
+ console.log(`[ws-bridge] Auto-relaunching Codex for session ${sessionId} (${session.browserSockets.size} browser(s) connected)`);
186
+ heyHankBus.emit("session:relaunch-needed", { sessionId });
187
+ }
188
+ });
189
+
190
+ if (session.pendingMessages.length > 0) {
191
+ console.log(`[ws-bridge] Flushing ${session.pendingMessages.length} queued message(s) to Codex adapter for session ${sessionId}`);
192
+ const queued = session.pendingMessages.splice(0);
193
+ for (const raw of queued) {
194
+ try {
195
+ const msg = JSON.parse(raw) as BrowserOutgoingMessage;
196
+ adapter.sendBrowserMessage(msg);
197
+ } catch {
198
+ console.warn(`[ws-bridge] Failed to parse queued message for Codex: ${raw.substring(0, 100)}`);
199
+ }
200
+ }
201
+ }
202
+
203
+ deps.broadcastToBrowsers(session, { type: "cli_connected" });
204
+ console.log(`[ws-bridge] Codex adapter attached for session ${sessionId}`);
205
+ }
206
+
207
+ async function handleCodexAiValidation(
208
+ session: Session,
209
+ adapter: CodexAdapter,
210
+ perm: PermissionRequest,
211
+ deps: CodexAttachDeps,
212
+ ): Promise<void> {
213
+ const aiSettings = getEffectiveAiValidation(session.state);
214
+ const result = await validatePermission(
215
+ perm.tool_name,
216
+ perm.input,
217
+ perm.description,
218
+ );
219
+
220
+ perm.ai_validation = {
221
+ verdict: result.verdict,
222
+ reason: result.reason,
223
+ ruleBasedOnly: result.ruleBasedOnly,
224
+ };
225
+
226
+ // Auto-approve safe tools
227
+ if (result.verdict === "safe" && aiSettings.autoApprove) {
228
+ deps.broadcastToBrowsers(session, {
229
+ type: "permission_auto_resolved",
230
+ request: perm,
231
+ behavior: "allow",
232
+ reason: result.reason,
233
+ });
234
+ adapter.sendBrowserMessage({
235
+ type: "permission_response",
236
+ request_id: perm.request_id,
237
+ behavior: "allow",
238
+ });
239
+ return;
240
+ }
241
+
242
+ // Auto-deny dangerous tools
243
+ if (result.verdict === "dangerous" && aiSettings.autoDeny) {
244
+ deps.broadcastToBrowsers(session, {
245
+ type: "permission_auto_resolved",
246
+ request: perm,
247
+ behavior: "deny",
248
+ reason: result.reason,
249
+ });
250
+ adapter.sendBrowserMessage({
251
+ type: "permission_response",
252
+ request_id: perm.request_id,
253
+ behavior: "deny",
254
+ });
255
+ return;
256
+ }
257
+
258
+ // Uncertain or auto-action disabled: fall through to manual
259
+ session.pendingPermissions.set(perm.request_id, perm);
260
+ session.stateMachine.transition("awaiting_permission", "ai_validation_manual_fallback");
261
+ deps.persistSession(session);
262
+ deps.broadcastToBrowsers(session, {
263
+ type: "permission_request",
264
+ request: perm,
265
+ });
266
+ }
@@ -0,0 +1,20 @@
1
+ import type { Session } from "./ws-bridge-types.js";
2
+
3
+ export function handleSetAiValidation(
4
+ session: Session,
5
+ msg: {
6
+ aiValidationEnabled?: boolean | null;
7
+ aiValidationAutoApprove?: boolean | null;
8
+ aiValidationAutoDeny?: boolean | null;
9
+ },
10
+ ): void {
11
+ if (msg.aiValidationEnabled !== undefined) {
12
+ session.state.aiValidationEnabled = msg.aiValidationEnabled;
13
+ }
14
+ if (msg.aiValidationAutoApprove !== undefined) {
15
+ session.state.aiValidationAutoApprove = msg.aiValidationAutoApprove;
16
+ }
17
+ if (msg.aiValidationAutoDeny !== undefined) {
18
+ session.state.aiValidationAutoDeny = msg.aiValidationAutoDeny;
19
+ }
20
+ }