heyhank 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -0
- package/bin/cli.ts +168 -0
- package/bin/ctl.ts +528 -0
- package/bin/generate-token.ts +28 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/AgentsPage-BPhirnCe.js +7 -0
- package/dist/assets/AssistantPage-DJ-cMQfb.js +1 -0
- package/dist/assets/CronManager-DDbz-yiT.js +1 -0
- package/dist/assets/HelpPage-DMfkzERp.js +1 -0
- package/dist/assets/IntegrationsPage-CrOitCmJ.js +1 -0
- package/dist/assets/MediaPage-CE5rdvkC.js +1 -0
- package/dist/assets/PlatformDashboard-Do6F0O2p.js +1 -0
- package/dist/assets/Playground-Fc5cdc5p.js +109 -0
- package/dist/assets/ProcessPanel-CslEiZkI.js +2 -0
- package/dist/assets/PromptsPage-D2EhsdNO.js +4 -0
- package/dist/assets/RunsPage-C5BZF5Rx.js +1 -0
- package/dist/assets/SandboxManager-a1AVI5q2.js +8 -0
- package/dist/assets/SettingsPage-DirhjQrJ.js +51 -0
- package/dist/assets/SocialMediaPage-DBuM28vD.js +1 -0
- package/dist/assets/TailscalePage-CHiFhZXF.js +1 -0
- package/dist/assets/TelephonyPage-x0VV0fOo.js +1 -0
- package/dist/assets/TerminalPage-Drwyrnfd.js +1 -0
- package/dist/assets/gemini-audio-t-TSU-To.js +17 -0
- package/dist/assets/gemini-live-client-C7rqAW7G.js +166 -0
- package/dist/assets/index-C8M_PUmX.css +32 -0
- package/dist/assets/index-CEqZnThB.js +204 -0
- package/dist/assets/sw-register-LSSpj6RU.js +1 -0
- package/dist/assets/time-ago-B6r_l9u1.js +1 -0
- package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
- package/dist/favicon-32-original.png +0 -0
- package/dist/favicon-32.png +0 -0
- package/dist/favicon.ico +0 -0
- package/dist/favicon.svg +8 -0
- package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
- package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
- package/dist/heyhank-mascot-poster.png +0 -0
- package/dist/heyhank-mascot.mp4 +0 -0
- package/dist/heyhank-mascot.webm +0 -0
- package/dist/icon-192-original.png +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512-original.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +21 -0
- package/dist/logo-192.png +0 -0
- package/dist/logo-512.png +0 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo-docker.svg +4 -0
- package/dist/logo-original.png +0 -0
- package/dist/logo.png +0 -0
- package/dist/logo.svg +14 -0
- package/dist/manifest.json +24 -0
- package/dist/push-sw.js +34 -0
- package/dist/sw.js +1 -0
- package/dist/workbox-d2a0910a.js +1 -0
- package/package.json +109 -0
- package/server/agent-cron-migrator.ts +85 -0
- package/server/agent-executor.ts +357 -0
- package/server/agent-store.ts +185 -0
- package/server/agent-timeout.ts +107 -0
- package/server/agent-types.ts +122 -0
- package/server/ai-validation-settings.ts +37 -0
- package/server/ai-validator.ts +181 -0
- package/server/anthropic-provider-migration.ts +48 -0
- package/server/assistant-store.ts +272 -0
- package/server/auth-manager.ts +150 -0
- package/server/auto-approve.ts +153 -0
- package/server/auto-namer.ts +36 -0
- package/server/backend-adapter.ts +54 -0
- package/server/cache-headers.ts +61 -0
- package/server/calendar-service.ts +434 -0
- package/server/claude-adapter.ts +889 -0
- package/server/claude-container-auth.ts +30 -0
- package/server/claude-session-discovery.ts +157 -0
- package/server/claude-session-history.ts +410 -0
- package/server/cli-launcher.ts +1303 -0
- package/server/codex-adapter.ts +3027 -0
- package/server/codex-container-auth.ts +24 -0
- package/server/codex-home.ts +27 -0
- package/server/codex-ws-proxy.cjs +226 -0
- package/server/commands-discovery.ts +81 -0
- package/server/constants.ts +7 -0
- package/server/container-manager.ts +1053 -0
- package/server/cost-tracker.ts +222 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.ts +148 -0
- package/server/cron-types.ts +63 -0
- package/server/email-service.ts +354 -0
- package/server/env-manager.ts +161 -0
- package/server/event-bus-types.ts +75 -0
- package/server/event-bus.ts +124 -0
- package/server/execution-store.ts +170 -0
- package/server/federation/node-connection.ts +190 -0
- package/server/federation/node-manager.ts +366 -0
- package/server/federation/node-store.ts +86 -0
- package/server/federation/node-types.ts +121 -0
- package/server/fs-utils.ts +15 -0
- package/server/git-utils.ts +421 -0
- package/server/github-pr.ts +379 -0
- package/server/google-media.ts +342 -0
- package/server/image-pull-manager.ts +279 -0
- package/server/index.ts +491 -0
- package/server/internal-ai.ts +237 -0
- package/server/kill-switch.ts +99 -0
- package/server/llm-providers.ts +342 -0
- package/server/logger.ts +259 -0
- package/server/mcp-registry.ts +401 -0
- package/server/message-bus.ts +271 -0
- package/server/message-delivery.ts +128 -0
- package/server/metrics-collector.ts +350 -0
- package/server/metrics-types.ts +108 -0
- package/server/middleware/managed-auth.ts +195 -0
- package/server/novnc-proxy.ts +99 -0
- package/server/path-resolver.ts +186 -0
- package/server/paths.ts +13 -0
- package/server/pr-poller.ts +162 -0
- package/server/prompt-manager.ts +211 -0
- package/server/protocol/claude-upstream/README.md +19 -0
- package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
- package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
- package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
- package/server/protocol/codex-upstream/README.md +18 -0
- package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
- package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
- package/server/protocol-monitor.ts +50 -0
- package/server/provider-manager.ts +111 -0
- package/server/provider-registry.ts +393 -0
- package/server/push-notifications.ts +221 -0
- package/server/recorder.ts +374 -0
- package/server/recording-hub/compat-validator.ts +284 -0
- package/server/recording-hub/diagnostics.ts +299 -0
- package/server/recording-hub/hub-config.ts +19 -0
- package/server/recording-hub/hub-routes.ts +236 -0
- package/server/recording-hub/hub-store.ts +265 -0
- package/server/recording-hub/replay-adapter.ts +207 -0
- package/server/relay-client.ts +320 -0
- package/server/reminder-scheduler.ts +38 -0
- package/server/replay.ts +78 -0
- package/server/routes/agent-routes.ts +264 -0
- package/server/routes/assistant-routes.ts +90 -0
- package/server/routes/cron-routes.ts +103 -0
- package/server/routes/env-routes.ts +95 -0
- package/server/routes/federation-routes.ts +76 -0
- package/server/routes/fs-routes.ts +622 -0
- package/server/routes/git-routes.ts +97 -0
- package/server/routes/llm-routes.ts +166 -0
- package/server/routes/media-routes.ts +135 -0
- package/server/routes/metrics-routes.ts +13 -0
- package/server/routes/platform-routes.ts +1379 -0
- package/server/routes/prompt-routes.ts +67 -0
- package/server/routes/provider-routes.ts +109 -0
- package/server/routes/sandbox-routes.ts +127 -0
- package/server/routes/settings-routes.ts +285 -0
- package/server/routes/skills-routes.ts +100 -0
- package/server/routes/socialmedia-routes.ts +208 -0
- package/server/routes/system-routes.ts +228 -0
- package/server/routes/tailscale-routes.ts +22 -0
- package/server/routes/telephony-routes.ts +259 -0
- package/server/routes.ts +1379 -0
- package/server/sandbox-manager.ts +168 -0
- package/server/service.ts +718 -0
- package/server/session-creation-service.ts +457 -0
- package/server/session-git-info.ts +104 -0
- package/server/session-names.ts +67 -0
- package/server/session-orchestrator.ts +824 -0
- package/server/session-state-machine.ts +207 -0
- package/server/session-store.ts +146 -0
- package/server/session-types.ts +511 -0
- package/server/settings-manager.ts +149 -0
- package/server/shared-context.ts +157 -0
- package/server/socialmedia/adapter.ts +15 -0
- package/server/socialmedia/adapters/ayrshare-adapter.ts +169 -0
- package/server/socialmedia/adapters/buffer-adapter.ts +299 -0
- package/server/socialmedia/adapters/postiz-adapter.ts +298 -0
- package/server/socialmedia/manager.ts +227 -0
- package/server/socialmedia/store.ts +98 -0
- package/server/socialmedia/types.ts +89 -0
- package/server/tailscale-manager.ts +451 -0
- package/server/telephony/audio-bridge.ts +331 -0
- package/server/telephony/call-manager.ts +457 -0
- package/server/telephony/call-types.ts +108 -0
- package/server/telephony/telephony-store.ts +119 -0
- package/server/terminal-manager.ts +240 -0
- package/server/update-checker.ts +192 -0
- package/server/usage-limits.ts +225 -0
- package/server/web-push.d.ts +51 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-auth.ts +41 -0
- package/server/ws-bridge-browser-ingest.ts +72 -0
- package/server/ws-bridge-browser.ts +112 -0
- package/server/ws-bridge-cli-ingest.ts +81 -0
- package/server/ws-bridge-codex.ts +266 -0
- package/server/ws-bridge-controls.ts +20 -0
- package/server/ws-bridge-persist.ts +66 -0
- package/server/ws-bridge-publish.ts +79 -0
- package/server/ws-bridge-replay.ts +61 -0
- package/server/ws-bridge-types.ts +121 -0
- package/server/ws-bridge.ts +1240 -0
|
@@ -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
|
+
}
|