@ccpocket-base-auth/bridge 1.26.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 +67 -0
- package/dist/archive-store.d.ts +28 -0
- package/dist/archive-store.js +68 -0
- package/dist/archive-store.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +82 -0
- package/dist/cli.js.map +1 -0
- package/dist/codex-process.d.ts +171 -0
- package/dist/codex-process.js +1928 -0
- package/dist/codex-process.js.map +1 -0
- package/dist/debug-trace-store.d.ts +15 -0
- package/dist/debug-trace-store.js +78 -0
- package/dist/debug-trace-store.js.map +1 -0
- package/dist/doctor.d.ts +58 -0
- package/dist/doctor.js +663 -0
- package/dist/doctor.js.map +1 -0
- package/dist/firebase-auth.d.ts +35 -0
- package/dist/firebase-auth.js +132 -0
- package/dist/firebase-auth.js.map +1 -0
- package/dist/gallery-store.d.ts +67 -0
- package/dist/gallery-store.js +333 -0
- package/dist/gallery-store.js.map +1 -0
- package/dist/image-store.d.ts +23 -0
- package/dist/image-store.js +142 -0
- package/dist/image-store.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +191 -0
- package/dist/index.js.map +1 -0
- package/dist/mdns.d.ts +7 -0
- package/dist/mdns.js +49 -0
- package/dist/mdns.js.map +1 -0
- package/dist/parser.d.ts +465 -0
- package/dist/parser.js +251 -0
- package/dist/parser.js.map +1 -0
- package/dist/project-history.d.ts +10 -0
- package/dist/project-history.js +73 -0
- package/dist/project-history.js.map +1 -0
- package/dist/prompt-history-backup.d.ts +15 -0
- package/dist/prompt-history-backup.js +46 -0
- package/dist/prompt-history-backup.js.map +1 -0
- package/dist/proxy.d.ts +15 -0
- package/dist/proxy.js +95 -0
- package/dist/proxy.js.map +1 -0
- package/dist/push-i18n.d.ts +7 -0
- package/dist/push-i18n.js +75 -0
- package/dist/push-i18n.js.map +1 -0
- package/dist/push-relay.d.ts +29 -0
- package/dist/push-relay.js +70 -0
- package/dist/push-relay.js.map +1 -0
- package/dist/recording-store.d.ts +51 -0
- package/dist/recording-store.js +158 -0
- package/dist/recording-store.js.map +1 -0
- package/dist/screenshot.d.ts +28 -0
- package/dist/screenshot.js +98 -0
- package/dist/screenshot.js.map +1 -0
- package/dist/sdk-process.d.ts +180 -0
- package/dist/sdk-process.js +937 -0
- package/dist/sdk-process.js.map +1 -0
- package/dist/session.d.ts +142 -0
- package/dist/session.js +615 -0
- package/dist/session.js.map +1 -0
- package/dist/sessions-index.d.ts +128 -0
- package/dist/sessions-index.js +1767 -0
- package/dist/sessions-index.js.map +1 -0
- package/dist/setup-launchd.d.ts +8 -0
- package/dist/setup-launchd.js +109 -0
- package/dist/setup-launchd.js.map +1 -0
- package/dist/setup-systemd.d.ts +8 -0
- package/dist/setup-systemd.js +118 -0
- package/dist/setup-systemd.js.map +1 -0
- package/dist/startup-info.d.ts +8 -0
- package/dist/startup-info.js +92 -0
- package/dist/startup-info.js.map +1 -0
- package/dist/usage.d.ts +69 -0
- package/dist/usage.js +545 -0
- package/dist/usage.js.map +1 -0
- package/dist/version.d.ts +13 -0
- package/dist/version.js +43 -0
- package/dist/version.js.map +1 -0
- package/dist/websocket.d.ts +127 -0
- package/dist/websocket.js +2482 -0
- package/dist/websocket.js.map +1 -0
- package/dist/worktree-store.d.ts +25 -0
- package/dist/worktree-store.js +59 -0
- package/dist/worktree-store.js.map +1 -0
- package/dist/worktree.d.ts +47 -0
- package/dist/worktree.js +313 -0
- package/dist/worktree.js.map +1 -0
- package/package.json +68 -0
|
@@ -0,0 +1,2482 @@
|
|
|
1
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { readFile, unlink } from "node:fs/promises";
|
|
4
|
+
import { resolve, extname } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
7
|
+
import { SessionManager } from "./session.js";
|
|
8
|
+
import { SdkProcess } from "./sdk-process.js";
|
|
9
|
+
import { CodexProcess } from "./codex-process.js";
|
|
10
|
+
import { parseClientMessage } from "./parser.js";
|
|
11
|
+
import { getAllRecentSessions, getCodexSessionHistory, getSessionHistory, findSessionsByClaudeIds, extractMessageImages, getClaudeSessionName, loadCodexSessionNames, renameClaudeSession, renameCodexSession } from "./sessions-index.js";
|
|
12
|
+
import { ArchiveStore } from "./archive-store.js";
|
|
13
|
+
import { WorktreeStore } from "./worktree-store.js";
|
|
14
|
+
import { listWorktrees, removeWorktree, worktreeExists, getMainBranch } from "./worktree.js";
|
|
15
|
+
import { listWindows, takeScreenshot } from "./screenshot.js";
|
|
16
|
+
import { DebugTraceStore } from "./debug-trace-store.js";
|
|
17
|
+
import { PushRelayClient } from "./push-relay.js";
|
|
18
|
+
import { normalizePushLocale, t } from "./push-i18n.js";
|
|
19
|
+
import { fetchAllUsage } from "./usage.js";
|
|
20
|
+
import { getPackageVersion } from "./version.js";
|
|
21
|
+
// ---- Available model lists (delivered to clients via session_list) ----
|
|
22
|
+
const CLAUDE_MODELS = [
|
|
23
|
+
"claude-opus-4-6[1m]",
|
|
24
|
+
"claude-opus-4-6",
|
|
25
|
+
"claude-sonnet-4-6",
|
|
26
|
+
"claude-haiku-4-6",
|
|
27
|
+
];
|
|
28
|
+
const CODEX_MODELS = [
|
|
29
|
+
"gpt-5.4",
|
|
30
|
+
"gpt-5.4-mini",
|
|
31
|
+
"gpt-5.3-codex",
|
|
32
|
+
"gpt-5.3-codex-spark",
|
|
33
|
+
"gpt-5.2-codex",
|
|
34
|
+
];
|
|
35
|
+
// ---- Codex mode mapping helpers ----
|
|
36
|
+
/** Map unified PermissionMode to Codex approval_policy.
|
|
37
|
+
* Only "bypassPermissions" maps to "never"; all others use "on-request". */
|
|
38
|
+
function permissionModeToApprovalPolicy(mode) {
|
|
39
|
+
return mode === "bypassPermissions" ? "never" : "on-request";
|
|
40
|
+
}
|
|
41
|
+
/** Map simplified SandboxMode (on/off) to Codex internal sandbox mode. */
|
|
42
|
+
function sandboxModeToInternal(mode) {
|
|
43
|
+
switch (mode) {
|
|
44
|
+
case "danger-full-access":
|
|
45
|
+
case "workspace-write":
|
|
46
|
+
case "read-only":
|
|
47
|
+
return mode;
|
|
48
|
+
case "off":
|
|
49
|
+
return "danger-full-access";
|
|
50
|
+
default:
|
|
51
|
+
return "workspace-write";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Map Codex internal sandbox mode back to simplified on/off for clients. */
|
|
55
|
+
function sandboxModeToExternal(mode) {
|
|
56
|
+
return mode === "danger-full-access" ? "off" : "on";
|
|
57
|
+
}
|
|
58
|
+
function threadTimestampToIso(value) {
|
|
59
|
+
return value > 0 ? new Date(value * 1000).toISOString() : "";
|
|
60
|
+
}
|
|
61
|
+
function codexThreadToRecentSession(thread, indexed) {
|
|
62
|
+
return {
|
|
63
|
+
sessionId: thread.id,
|
|
64
|
+
provider: "codex",
|
|
65
|
+
...(thread.name ? { name: thread.name } : {}),
|
|
66
|
+
...(thread.agentNickname ? { agentNickname: thread.agentNickname } : {}),
|
|
67
|
+
...(thread.agentRole ? { agentRole: thread.agentRole } : {}),
|
|
68
|
+
summary: thread.preview || undefined,
|
|
69
|
+
firstPrompt: thread.preview || "",
|
|
70
|
+
created: threadTimestampToIso(thread.createdAt),
|
|
71
|
+
modified: threadTimestampToIso(thread.updatedAt),
|
|
72
|
+
gitBranch: thread.gitBranch ?? "",
|
|
73
|
+
projectPath: thread.cwd,
|
|
74
|
+
...(indexed?.resumeCwd ? { resumeCwd: indexed.resumeCwd } : {}),
|
|
75
|
+
isSidechain: false,
|
|
76
|
+
...(indexed?.codexSettings ? { codexSettings: indexed.codexSettings } : {}),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
export class BridgeWebSocketServer {
|
|
80
|
+
static MAX_DEBUG_EVENTS = 800;
|
|
81
|
+
static MAX_HISTORY_SUMMARY_ITEMS = 300;
|
|
82
|
+
wss;
|
|
83
|
+
sessionManager;
|
|
84
|
+
apiKey;
|
|
85
|
+
allowedDirs;
|
|
86
|
+
imageStore;
|
|
87
|
+
galleryStore;
|
|
88
|
+
projectHistory;
|
|
89
|
+
debugTraceStore;
|
|
90
|
+
recordingStore;
|
|
91
|
+
worktreeStore;
|
|
92
|
+
pushRelay;
|
|
93
|
+
promptHistoryBackup;
|
|
94
|
+
recentSessionsRequestId = 0;
|
|
95
|
+
debugEvents = new Map();
|
|
96
|
+
notifiedPermissionToolUses = new Map();
|
|
97
|
+
archiveStore;
|
|
98
|
+
/** FCM token → push notification locale */
|
|
99
|
+
tokenLocales = new Map();
|
|
100
|
+
tokenPrivacyMode = new Map();
|
|
101
|
+
constructor(options) {
|
|
102
|
+
const { server, apiKey, allowedDirs, imageStore, galleryStore, projectHistory, debugTraceStore, recordingStore, firebaseAuth, promptHistoryBackup } = options;
|
|
103
|
+
this.apiKey = apiKey ?? null;
|
|
104
|
+
this.allowedDirs = allowedDirs ?? [];
|
|
105
|
+
this.imageStore = imageStore ?? null;
|
|
106
|
+
this.galleryStore = galleryStore ?? null;
|
|
107
|
+
this.projectHistory = projectHistory ?? null;
|
|
108
|
+
this.debugTraceStore = debugTraceStore ?? new DebugTraceStore();
|
|
109
|
+
this.recordingStore = recordingStore ?? null;
|
|
110
|
+
this.worktreeStore = new WorktreeStore();
|
|
111
|
+
this.pushRelay = new PushRelayClient({ firebaseAuth });
|
|
112
|
+
this.promptHistoryBackup = promptHistoryBackup ?? null;
|
|
113
|
+
this.archiveStore = new ArchiveStore();
|
|
114
|
+
void this.debugTraceStore.init().catch((err) => {
|
|
115
|
+
console.error("[ws] Failed to initialize debug trace store:", err);
|
|
116
|
+
});
|
|
117
|
+
if (this.recordingStore) {
|
|
118
|
+
void this.recordingStore.init().catch((err) => {
|
|
119
|
+
console.error("[ws] Failed to initialize recording store:", err);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
void this.archiveStore.init().catch((err) => {
|
|
123
|
+
console.error("[ws] Failed to initialize archive store:", err);
|
|
124
|
+
});
|
|
125
|
+
if (!this.pushRelay.isConfigured) {
|
|
126
|
+
console.log("[ws] Push relay disabled (Firebase auth not available)");
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
console.log("[ws] Push relay enabled (Firebase Anonymous Auth)");
|
|
130
|
+
}
|
|
131
|
+
this.wss = new WebSocketServer({ server });
|
|
132
|
+
this.sessionManager = new SessionManager((sessionId, msg) => {
|
|
133
|
+
this.broadcastSessionMessage(sessionId, msg);
|
|
134
|
+
}, imageStore, galleryStore,
|
|
135
|
+
// Broadcast gallery_new_image when a new image is added
|
|
136
|
+
(meta) => {
|
|
137
|
+
if (this.galleryStore) {
|
|
138
|
+
const info = this.galleryStore.metaToInfo(meta);
|
|
139
|
+
this.broadcast({ type: "gallery_new_image", image: info });
|
|
140
|
+
}
|
|
141
|
+
}, this.worktreeStore);
|
|
142
|
+
this.wss.on("connection", (ws, req) => {
|
|
143
|
+
// API key authentication
|
|
144
|
+
if (this.apiKey) {
|
|
145
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
|
|
146
|
+
const token = url.searchParams.get("token");
|
|
147
|
+
if (token !== this.apiKey) {
|
|
148
|
+
console.log("[ws] Client rejected: invalid token");
|
|
149
|
+
ws.close(4001, "Unauthorized");
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
console.log("[ws] Client connected");
|
|
154
|
+
this.handleConnection(ws);
|
|
155
|
+
});
|
|
156
|
+
this.wss.on("error", (err) => {
|
|
157
|
+
console.error("[ws] Server error:", err.message);
|
|
158
|
+
});
|
|
159
|
+
console.log(`[ws] WebSocket server attached to HTTP server`);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Validate that a project path is within the allowed directories.
|
|
163
|
+
* Returns true if the path is allowed, false otherwise.
|
|
164
|
+
*/
|
|
165
|
+
isPathAllowed(path) {
|
|
166
|
+
if (this.allowedDirs.length === 0)
|
|
167
|
+
return true;
|
|
168
|
+
const resolved = resolve(path);
|
|
169
|
+
return this.allowedDirs.some((dir) => resolved === dir || resolved.startsWith(dir + "/"));
|
|
170
|
+
}
|
|
171
|
+
/** Build a user-friendly error for disallowed project paths. */
|
|
172
|
+
buildPathNotAllowedError(projectPath) {
|
|
173
|
+
return {
|
|
174
|
+
type: "error",
|
|
175
|
+
message: `⚠ Project path not allowed\n\n"${projectPath}" is not in the allowed directories.\n\nFix: Update BRIDGE_ALLOWED_DIRS on the Bridge server to include this path.`,
|
|
176
|
+
errorCode: "path_not_allowed",
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
close() {
|
|
180
|
+
console.log("[ws] Shutting down...");
|
|
181
|
+
this.sessionManager.destroyAll();
|
|
182
|
+
this.debugEvents.clear();
|
|
183
|
+
this.wss.close();
|
|
184
|
+
}
|
|
185
|
+
/** Return session count for /health endpoint. */
|
|
186
|
+
get sessionCount() {
|
|
187
|
+
return this.sessionManager.list().length;
|
|
188
|
+
}
|
|
189
|
+
/** Return connected WebSocket client count. */
|
|
190
|
+
get clientCount() {
|
|
191
|
+
return this.wss.clients.size;
|
|
192
|
+
}
|
|
193
|
+
handleConnection(ws) {
|
|
194
|
+
// Send session list and project history on connect
|
|
195
|
+
this.sendSessionList(ws);
|
|
196
|
+
const projects = this.projectHistory?.getProjects() ?? [];
|
|
197
|
+
this.send(ws, { type: "project_history", projects });
|
|
198
|
+
ws.on("message", (data) => {
|
|
199
|
+
const raw = data.toString();
|
|
200
|
+
const msg = parseClientMessage(raw);
|
|
201
|
+
if (!msg) {
|
|
202
|
+
// Try to extract the message type so the client can decide how to
|
|
203
|
+
// handle the unsupported message (suppress vs show update hint).
|
|
204
|
+
let rawType;
|
|
205
|
+
try {
|
|
206
|
+
rawType = JSON.parse(raw)?.type;
|
|
207
|
+
}
|
|
208
|
+
catch { /* ignore */ }
|
|
209
|
+
console.error("[ws] Unsupported message:", rawType ?? raw.slice(0, 200));
|
|
210
|
+
this.send(ws, {
|
|
211
|
+
type: "error",
|
|
212
|
+
errorCode: "unsupported_message",
|
|
213
|
+
message: rawType ?? "unknown",
|
|
214
|
+
});
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
console.log(`[ws] Received: ${msg.type}`);
|
|
218
|
+
this.handleClientMessage(msg, ws);
|
|
219
|
+
});
|
|
220
|
+
ws.on("close", () => {
|
|
221
|
+
console.log("[ws] Client disconnected");
|
|
222
|
+
});
|
|
223
|
+
ws.on("error", (err) => {
|
|
224
|
+
console.error("[ws] Client error:", err.message);
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
async handleClientMessage(msg, ws) {
|
|
228
|
+
const incomingSessionId = this.extractSessionIdFromClientMessage(msg);
|
|
229
|
+
const isActiveRuntimeSession = incomingSessionId != null && this.sessionManager.get(incomingSessionId) != null;
|
|
230
|
+
if (incomingSessionId && isActiveRuntimeSession) {
|
|
231
|
+
this.recordDebugEvent(incomingSessionId, {
|
|
232
|
+
direction: "incoming",
|
|
233
|
+
channel: "ws",
|
|
234
|
+
type: msg.type,
|
|
235
|
+
detail: this.summarizeClientMessage(msg),
|
|
236
|
+
});
|
|
237
|
+
this.recordingStore?.record(incomingSessionId, "incoming", msg);
|
|
238
|
+
}
|
|
239
|
+
switch (msg.type) {
|
|
240
|
+
case "start": {
|
|
241
|
+
if (!this.isPathAllowed(msg.projectPath)) {
|
|
242
|
+
this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
const provider = msg.provider ?? "claude";
|
|
247
|
+
if (provider === "codex") {
|
|
248
|
+
console.log(`[ws] start(codex): permissionMode=${msg.permissionMode} → collaboration=${msg.permissionMode === "plan" ? "plan" : "default"}`);
|
|
249
|
+
}
|
|
250
|
+
const cached = provider === "claude" ? this.sessionManager.getCachedCommands(msg.projectPath) : undefined;
|
|
251
|
+
const sessionId = this.sessionManager.create(msg.projectPath, {
|
|
252
|
+
sessionId: msg.sessionId,
|
|
253
|
+
continueMode: msg.continue,
|
|
254
|
+
permissionMode: msg.permissionMode,
|
|
255
|
+
model: msg.model,
|
|
256
|
+
effort: msg.effort,
|
|
257
|
+
maxTurns: msg.maxTurns,
|
|
258
|
+
maxBudgetUsd: msg.maxBudgetUsd,
|
|
259
|
+
fallbackModel: msg.fallbackModel,
|
|
260
|
+
forkSession: msg.forkSession,
|
|
261
|
+
persistSession: msg.persistSession,
|
|
262
|
+
// Claude sandbox: map "on"/"off" to boolean
|
|
263
|
+
...(provider === "claude" && msg.sandboxMode
|
|
264
|
+
? { sandboxEnabled: msg.sandboxMode === "on" }
|
|
265
|
+
: {}),
|
|
266
|
+
}, undefined, {
|
|
267
|
+
useWorktree: msg.useWorktree,
|
|
268
|
+
worktreeBranch: msg.worktreeBranch,
|
|
269
|
+
existingWorktreePath: msg.existingWorktreePath,
|
|
270
|
+
}, provider, provider === "codex"
|
|
271
|
+
? {
|
|
272
|
+
approvalPolicy: permissionModeToApprovalPolicy(msg.permissionMode),
|
|
273
|
+
sandboxMode: sandboxModeToInternal(msg.sandboxMode),
|
|
274
|
+
model: msg.model,
|
|
275
|
+
modelReasoningEffort: msg.modelReasoningEffort ?? undefined,
|
|
276
|
+
networkAccessEnabled: msg.networkAccessEnabled,
|
|
277
|
+
webSearchMode: msg.webSearchMode ?? undefined,
|
|
278
|
+
threadId: msg.sessionId,
|
|
279
|
+
collaborationMode: msg.permissionMode === "plan" ? "plan" : "default",
|
|
280
|
+
}
|
|
281
|
+
: undefined);
|
|
282
|
+
const createdSession = this.sessionManager.get(sessionId);
|
|
283
|
+
// Load saved session name from CLI storage (for resumed sessions)
|
|
284
|
+
void this.loadAndSetSessionName(createdSession, provider, msg.projectPath, msg.sessionId).then(() => {
|
|
285
|
+
this.send(ws, {
|
|
286
|
+
type: "system",
|
|
287
|
+
subtype: "session_created",
|
|
288
|
+
sessionId,
|
|
289
|
+
provider,
|
|
290
|
+
projectPath: msg.projectPath,
|
|
291
|
+
...(msg.permissionMode ? { permissionMode: msg.permissionMode } : {}),
|
|
292
|
+
...(msg.sandboxMode ? { sandboxMode: msg.sandboxMode } : {}),
|
|
293
|
+
...(cached ? { slashCommands: cached.slashCommands, skills: cached.skills, ...(cached.skillMetadata ? { skillMetadata: cached.skillMetadata } : {}) } : {}),
|
|
294
|
+
...(createdSession?.worktreePath ? {
|
|
295
|
+
worktreePath: createdSession.worktreePath,
|
|
296
|
+
worktreeBranch: createdSession.worktreeBranch,
|
|
297
|
+
} : {}),
|
|
298
|
+
});
|
|
299
|
+
this.broadcastSessionList();
|
|
300
|
+
// Send a gentle tip when the project is not a git repository
|
|
301
|
+
if (createdSession && !createdSession.gitBranch) {
|
|
302
|
+
const tipMsg = {
|
|
303
|
+
type: "system",
|
|
304
|
+
subtype: "tip",
|
|
305
|
+
tipCode: "git_not_available",
|
|
306
|
+
sessionId,
|
|
307
|
+
};
|
|
308
|
+
createdSession.history.push(tipMsg);
|
|
309
|
+
this.send(ws, tipMsg);
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
this.debugEvents.set(sessionId, []);
|
|
313
|
+
this.recordDebugEvent(sessionId, {
|
|
314
|
+
direction: "internal",
|
|
315
|
+
channel: "bridge",
|
|
316
|
+
type: "session_created",
|
|
317
|
+
detail: `provider=${provider} projectPath=${msg.projectPath}`,
|
|
318
|
+
});
|
|
319
|
+
this.recordingStore?.saveMeta(sessionId, {
|
|
320
|
+
bridgeSessionId: sessionId,
|
|
321
|
+
projectPath: msg.projectPath,
|
|
322
|
+
createdAt: new Date().toISOString(),
|
|
323
|
+
});
|
|
324
|
+
this.projectHistory?.addProject(msg.projectPath);
|
|
325
|
+
}
|
|
326
|
+
catch (err) {
|
|
327
|
+
console.error(`[ws] Failed to start session:`, err);
|
|
328
|
+
this.send(ws, { type: "error", message: `Failed to start session: ${err.message}` });
|
|
329
|
+
}
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
case "input": {
|
|
333
|
+
const session = this.resolveSession(msg.sessionId);
|
|
334
|
+
if (!session) {
|
|
335
|
+
this.send(ws, { type: "error", message: "No active session. Send 'start' first." });
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const text = msg.text;
|
|
339
|
+
// Codex: reject if the process is not waiting for input (turn-based, no internal queue)
|
|
340
|
+
if (session.provider === "codex" && !session.process.isWaitingForInput) {
|
|
341
|
+
this.send(ws, { type: "input_rejected", sessionId: session.id, reason: "Process is busy" });
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
// Snapshot busy state before dispatch. We prefer the actual enqueue
|
|
345
|
+
// result returned by SdkProcess sendInput* below, but keep this as a
|
|
346
|
+
// fallback for test doubles and async paths.
|
|
347
|
+
const isAgentBusySnapshot = session.provider === "claude" && !session.process.isWaitingForInput;
|
|
348
|
+
// Normalize images: support new `images` array and legacy single-image fields
|
|
349
|
+
let images = [];
|
|
350
|
+
if (msg.images && msg.images.length > 0) {
|
|
351
|
+
images = msg.images;
|
|
352
|
+
}
|
|
353
|
+
else if (msg.imageBase64 && msg.mimeType) {
|
|
354
|
+
// Legacy single-image fallback
|
|
355
|
+
images = [{ base64: msg.imageBase64, mimeType: msg.mimeType }];
|
|
356
|
+
}
|
|
357
|
+
// Add user_input to in-memory history.
|
|
358
|
+
// The SDK stream does NOT emit user messages, so session.history would
|
|
359
|
+
// otherwise lack them. This ensures get_history responses include user
|
|
360
|
+
// messages and replaceEntries on the client side preserves them.
|
|
361
|
+
// We do NOT broadcast this back — Flutter already shows it via sendMessage().
|
|
362
|
+
//
|
|
363
|
+
// Register images in the image store so they can be served via HTTP
|
|
364
|
+
// when the client re-enters the session and loads history.
|
|
365
|
+
let imageRefs;
|
|
366
|
+
if (images.length > 0 && this.imageStore) {
|
|
367
|
+
imageRefs = [];
|
|
368
|
+
for (const img of images) {
|
|
369
|
+
const ref = this.imageStore.registerFromBase64(img.base64, img.mimeType);
|
|
370
|
+
if (ref)
|
|
371
|
+
imageRefs.push(ref);
|
|
372
|
+
}
|
|
373
|
+
if (imageRefs.length === 0)
|
|
374
|
+
imageRefs = undefined;
|
|
375
|
+
}
|
|
376
|
+
session.history.push({
|
|
377
|
+
type: "user_input",
|
|
378
|
+
text,
|
|
379
|
+
timestamp: new Date().toISOString(),
|
|
380
|
+
...(images.length > 0 ? { imageCount: images.length } : {}),
|
|
381
|
+
...(imageRefs ? { images: imageRefs } : {}),
|
|
382
|
+
});
|
|
383
|
+
// Persist images to Gallery Store asynchronously (fire-and-forget)
|
|
384
|
+
if (images.length > 0 && this.galleryStore && session.projectPath) {
|
|
385
|
+
for (const img of images) {
|
|
386
|
+
this.galleryStore.addImageFromBase64(img.base64, img.mimeType, session.projectPath, msg.sessionId).catch((err) => {
|
|
387
|
+
console.warn(`[ws] Failed to persist image to gallery: ${err}`);
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
// Codex input path
|
|
392
|
+
if (session.provider === "codex") {
|
|
393
|
+
this.send(ws, { type: "input_ack", sessionId: session.id, queued: false });
|
|
394
|
+
const codexProc = session.process;
|
|
395
|
+
if (images.length > 0) {
|
|
396
|
+
codexProc.sendInputWithImages(text, images);
|
|
397
|
+
}
|
|
398
|
+
else if (msg.imageId && this.galleryStore) {
|
|
399
|
+
this.galleryStore.getImageAsBase64(msg.imageId).then((imageData) => {
|
|
400
|
+
if (imageData) {
|
|
401
|
+
codexProc.sendInputWithImages(text, [imageData]);
|
|
402
|
+
}
|
|
403
|
+
else {
|
|
404
|
+
console.warn(`[ws] Image not found: ${msg.imageId}`);
|
|
405
|
+
codexProc.sendInput(text);
|
|
406
|
+
}
|
|
407
|
+
}).catch((err) => {
|
|
408
|
+
console.error(`[ws] Failed to load image: ${err}`);
|
|
409
|
+
codexProc.sendInput(text);
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
else if (msg.skill) {
|
|
413
|
+
codexProc.sendInputWithSkill(text, msg.skill);
|
|
414
|
+
}
|
|
415
|
+
else {
|
|
416
|
+
codexProc.sendInput(text);
|
|
417
|
+
}
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
// Claude Code input path — enqueue first, then interrupt if busy
|
|
421
|
+
const claudeProc = session.process;
|
|
422
|
+
let wasQueued = false;
|
|
423
|
+
if (images.length > 0) {
|
|
424
|
+
console.log(`[ws] Sending message with ${images.length} inline Base64 image(s)`);
|
|
425
|
+
const result = claudeProc.sendInputWithImages(text, images);
|
|
426
|
+
wasQueued = typeof result === "boolean" ? result : isAgentBusySnapshot;
|
|
427
|
+
}
|
|
428
|
+
// Legacy imageId mode (backward compatibility)
|
|
429
|
+
else if (msg.imageId && this.galleryStore) {
|
|
430
|
+
this.send(ws, {
|
|
431
|
+
type: "input_ack",
|
|
432
|
+
sessionId: session.id,
|
|
433
|
+
queued: isAgentBusySnapshot,
|
|
434
|
+
});
|
|
435
|
+
this.galleryStore.getImageAsBase64(msg.imageId).then((imageData) => {
|
|
436
|
+
let queuedAfterResolve = false;
|
|
437
|
+
if (imageData) {
|
|
438
|
+
const result = claudeProc.sendInputWithImages(text, [imageData]);
|
|
439
|
+
queuedAfterResolve = typeof result === "boolean" ? result : isAgentBusySnapshot;
|
|
440
|
+
}
|
|
441
|
+
else {
|
|
442
|
+
console.warn(`[ws] Image not found: ${msg.imageId}`);
|
|
443
|
+
const result = session.process.sendInput(text);
|
|
444
|
+
queuedAfterResolve = typeof result === "boolean" ? result : isAgentBusySnapshot;
|
|
445
|
+
}
|
|
446
|
+
if (queuedAfterResolve) {
|
|
447
|
+
console.log(`[ws] Agent is busy — will queue input and interrupt current turn`);
|
|
448
|
+
claudeProc.interrupt();
|
|
449
|
+
}
|
|
450
|
+
}).catch((err) => {
|
|
451
|
+
console.error(`[ws] Failed to load image: ${err}`);
|
|
452
|
+
const result = session.process.sendInput(text);
|
|
453
|
+
const queuedAfterResolve = typeof result === "boolean" ? result : isAgentBusySnapshot;
|
|
454
|
+
if (queuedAfterResolve) {
|
|
455
|
+
console.log(`[ws] Agent is busy — will queue input and interrupt current turn`);
|
|
456
|
+
claudeProc.interrupt();
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
break;
|
|
460
|
+
}
|
|
461
|
+
// Text-only message
|
|
462
|
+
else {
|
|
463
|
+
const result = session.process.sendInput(text);
|
|
464
|
+
wasQueued = typeof result === "boolean" ? result : isAgentBusySnapshot;
|
|
465
|
+
}
|
|
466
|
+
// Acknowledge receipt so the client can mark the message state.
|
|
467
|
+
// queued=true means the input was enqueued instead of being consumed
|
|
468
|
+
// immediately by the SDK stream.
|
|
469
|
+
this.send(ws, { type: "input_ack", sessionId: session.id, queued: wasQueued });
|
|
470
|
+
if (wasQueued) {
|
|
471
|
+
console.log(`[ws] Agent is busy — will queue input and interrupt current turn`);
|
|
472
|
+
claudeProc.interrupt();
|
|
473
|
+
}
|
|
474
|
+
break;
|
|
475
|
+
}
|
|
476
|
+
case "push_register": {
|
|
477
|
+
const locale = normalizePushLocale(msg.locale);
|
|
478
|
+
const privacyMode = msg.privacyMode === true;
|
|
479
|
+
console.log(`[ws] push_register received (platform: ${msg.platform}, locale: ${locale}, privacy: ${privacyMode}, configured: ${this.pushRelay.isConfigured})`);
|
|
480
|
+
if (!this.pushRelay.isConfigured) {
|
|
481
|
+
this.send(ws, { type: "error", message: "Push relay is not configured on bridge" });
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
this.tokenLocales.set(msg.token, locale);
|
|
485
|
+
this.tokenPrivacyMode.set(msg.token, privacyMode);
|
|
486
|
+
this.pushRelay.registerToken(msg.token, msg.platform, locale).then(() => {
|
|
487
|
+
console.log("[ws] push_register: token registered successfully");
|
|
488
|
+
}).catch((err) => {
|
|
489
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
490
|
+
console.error(`[ws] push_register failed: ${detail}`);
|
|
491
|
+
this.send(ws, { type: "error", message: `Failed to register push token: ${detail}` });
|
|
492
|
+
});
|
|
493
|
+
break;
|
|
494
|
+
}
|
|
495
|
+
case "push_unregister": {
|
|
496
|
+
console.log("[ws] push_unregister received");
|
|
497
|
+
if (!this.pushRelay.isConfigured) {
|
|
498
|
+
this.send(ws, { type: "error", message: "Push relay is not configured on bridge" });
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
this.tokenLocales.delete(msg.token);
|
|
502
|
+
this.tokenPrivacyMode.delete(msg.token);
|
|
503
|
+
this.pushRelay.unregisterToken(msg.token).then(() => {
|
|
504
|
+
console.log("[ws] push_unregister: token unregistered successfully");
|
|
505
|
+
}).catch((err) => {
|
|
506
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
507
|
+
console.error(`[ws] push_unregister failed: ${detail}`);
|
|
508
|
+
this.send(ws, { type: "error", message: `Failed to unregister push token: ${detail}` });
|
|
509
|
+
});
|
|
510
|
+
break;
|
|
511
|
+
}
|
|
512
|
+
case "set_permission_mode": {
|
|
513
|
+
const session = this.resolveSession(msg.sessionId);
|
|
514
|
+
if (!session) {
|
|
515
|
+
this.send(ws, { type: "error", message: "No active session." });
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
if (session.provider === "codex") {
|
|
519
|
+
// Permission mode for Codex requires a session restart (like sandbox mode).
|
|
520
|
+
// approvalPolicy and collaborationMode are thread-level settings that
|
|
521
|
+
// only take effect reliably at thread/start or thread/resume time.
|
|
522
|
+
const newApproval = permissionModeToApprovalPolicy(msg.mode);
|
|
523
|
+
const newCollaboration = msg.mode === "plan" ? "plan" : "default";
|
|
524
|
+
const currentApproval = session.process.approvalPolicy;
|
|
525
|
+
const currentCollaboration = session.process.collaborationMode;
|
|
526
|
+
if (newApproval === currentApproval && newCollaboration === currentCollaboration) {
|
|
527
|
+
break; // No change needed
|
|
528
|
+
}
|
|
529
|
+
console.log(`[ws] set_permission_mode(codex): mode=${msg.mode} → approval=${newApproval}, collaboration=${newCollaboration} (restart)`);
|
|
530
|
+
const oldSessionId = session.id;
|
|
531
|
+
const threadId = session.claudeSessionId;
|
|
532
|
+
const projectPath = session.projectPath;
|
|
533
|
+
const oldSettings = session.codexSettings ?? {};
|
|
534
|
+
const worktreePath = session.worktreePath;
|
|
535
|
+
const worktreeBranch = session.worktreeBranch;
|
|
536
|
+
const sessionName = session.name;
|
|
537
|
+
this.sessionManager.destroy(oldSessionId);
|
|
538
|
+
console.log(`[ws] Permission mode change: destroyed session ${oldSessionId}`);
|
|
539
|
+
const hasUserMessages = session.history?.some((m) => m.type === "user_input" || m.type === "assistant") || (session.pastMessages && session.pastMessages.length > 0);
|
|
540
|
+
if (!threadId || !hasUserMessages) {
|
|
541
|
+
const newId = this.sessionManager.create(projectPath, undefined, undefined, worktreePath ? { existingWorktreePath: worktreePath, worktreeBranch } : undefined, "codex", {
|
|
542
|
+
approvalPolicy: newApproval,
|
|
543
|
+
sandboxMode: oldSettings.sandboxMode,
|
|
544
|
+
model: oldSettings.model,
|
|
545
|
+
modelReasoningEffort: oldSettings.modelReasoningEffort,
|
|
546
|
+
networkAccessEnabled: oldSettings.networkAccessEnabled,
|
|
547
|
+
webSearchMode: oldSettings.webSearchMode,
|
|
548
|
+
collaborationMode: newCollaboration,
|
|
549
|
+
});
|
|
550
|
+
const newSession = this.sessionManager.get(newId);
|
|
551
|
+
if (newSession && sessionName)
|
|
552
|
+
newSession.name = sessionName;
|
|
553
|
+
this.broadcast({
|
|
554
|
+
type: "system",
|
|
555
|
+
subtype: "session_created",
|
|
556
|
+
sessionId: newId,
|
|
557
|
+
provider: "codex",
|
|
558
|
+
projectPath,
|
|
559
|
+
permissionMode: msg.mode,
|
|
560
|
+
...(oldSettings.sandboxMode ? { sandboxMode: sandboxModeToExternal(oldSettings.sandboxMode) } : {}),
|
|
561
|
+
sourceSessionId: oldSessionId,
|
|
562
|
+
...(newSession?.worktreePath ? { worktreePath: newSession.worktreePath, worktreeBranch: newSession.worktreeBranch } : {}),
|
|
563
|
+
});
|
|
564
|
+
this.broadcastSessionList();
|
|
565
|
+
console.log(`[ws] Permission mode change (no thread): created new session ${newId} (mode=${msg.mode})`);
|
|
566
|
+
break;
|
|
567
|
+
}
|
|
568
|
+
// Worktree resolution
|
|
569
|
+
const wtMapping = this.worktreeStore.get(threadId);
|
|
570
|
+
const effectiveProjectPath = wtMapping?.projectPath ?? projectPath;
|
|
571
|
+
let worktreeOpts;
|
|
572
|
+
if (wtMapping) {
|
|
573
|
+
if (worktreeExists(wtMapping.worktreePath)) {
|
|
574
|
+
worktreeOpts = { existingWorktreePath: wtMapping.worktreePath, worktreeBranch: wtMapping.worktreeBranch };
|
|
575
|
+
}
|
|
576
|
+
else {
|
|
577
|
+
worktreeOpts = { useWorktree: true, worktreeBranch: wtMapping.worktreeBranch };
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
else if (worktreePath) {
|
|
581
|
+
worktreeOpts = { existingWorktreePath: worktreePath, worktreeBranch };
|
|
582
|
+
}
|
|
583
|
+
getCodexSessionHistory(threadId).then((pastMessages) => {
|
|
584
|
+
const newId = this.sessionManager.create(effectiveProjectPath, undefined, pastMessages, worktreeOpts, "codex", {
|
|
585
|
+
threadId,
|
|
586
|
+
approvalPolicy: newApproval,
|
|
587
|
+
sandboxMode: oldSettings.sandboxMode,
|
|
588
|
+
model: oldSettings.model,
|
|
589
|
+
modelReasoningEffort: oldSettings.modelReasoningEffort,
|
|
590
|
+
networkAccessEnabled: oldSettings.networkAccessEnabled,
|
|
591
|
+
webSearchMode: oldSettings.webSearchMode,
|
|
592
|
+
collaborationMode: newCollaboration,
|
|
593
|
+
});
|
|
594
|
+
const newSession = this.sessionManager.get(newId);
|
|
595
|
+
if (newSession && sessionName) {
|
|
596
|
+
newSession.name = sessionName;
|
|
597
|
+
}
|
|
598
|
+
void this.loadAndSetSessionName(newSession, "codex", effectiveProjectPath, threadId).then(() => {
|
|
599
|
+
this.broadcast({
|
|
600
|
+
type: "system",
|
|
601
|
+
subtype: "session_created",
|
|
602
|
+
sessionId: newId,
|
|
603
|
+
provider: "codex",
|
|
604
|
+
projectPath: effectiveProjectPath,
|
|
605
|
+
permissionMode: msg.mode,
|
|
606
|
+
...(oldSettings.sandboxMode ? { sandboxMode: sandboxModeToExternal(oldSettings.sandboxMode) } : {}),
|
|
607
|
+
sourceSessionId: oldSessionId,
|
|
608
|
+
...(newSession?.worktreePath ? {
|
|
609
|
+
worktreePath: newSession.worktreePath,
|
|
610
|
+
worktreeBranch: newSession.worktreeBranch,
|
|
611
|
+
} : {}),
|
|
612
|
+
});
|
|
613
|
+
this.broadcastSessionList();
|
|
614
|
+
});
|
|
615
|
+
this.debugEvents.set(newId, []);
|
|
616
|
+
this.recordDebugEvent(newId, {
|
|
617
|
+
direction: "internal",
|
|
618
|
+
channel: "bridge",
|
|
619
|
+
type: "permission_mode_changed",
|
|
620
|
+
detail: `mode=${msg.mode} approval=${newApproval} collaboration=${newCollaboration} thread=${threadId} oldSession=${oldSessionId}`,
|
|
621
|
+
});
|
|
622
|
+
console.log(`[ws] Permission mode change: created new session ${newId} (thread=${threadId}, mode=${msg.mode})`);
|
|
623
|
+
}).catch((err) => {
|
|
624
|
+
this.send(ws, { type: "error", message: `Failed to restart session for permission mode change: ${err}` });
|
|
625
|
+
});
|
|
626
|
+
break;
|
|
627
|
+
}
|
|
628
|
+
session.process.setPermissionMode(msg.mode).catch((err) => {
|
|
629
|
+
this.send(ws, {
|
|
630
|
+
type: "error",
|
|
631
|
+
message: `Failed to set permission mode: ${err instanceof Error ? err.message : String(err)}`,
|
|
632
|
+
});
|
|
633
|
+
});
|
|
634
|
+
break;
|
|
635
|
+
}
|
|
636
|
+
case "set_sandbox_mode": {
|
|
637
|
+
const session = this.resolveSession(msg.sessionId);
|
|
638
|
+
if (!session) {
|
|
639
|
+
this.send(ws, { type: "error", message: "No active session." });
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
if (msg.sandboxMode !== "on" && msg.sandboxMode !== "off") {
|
|
643
|
+
this.send(ws, { type: "error", message: `Invalid sandbox mode: ${msg.sandboxMode}` });
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
// ---- Claude sandbox toggle ----
|
|
647
|
+
if (session.provider === "claude") {
|
|
648
|
+
const newEnabled = msg.sandboxMode === "on";
|
|
649
|
+
if (session.sandboxEnabled === newEnabled) {
|
|
650
|
+
break; // No change needed
|
|
651
|
+
}
|
|
652
|
+
// Sandbox is a query-level setting — requires session restart.
|
|
653
|
+
const oldSessionId = session.id;
|
|
654
|
+
const claudeSessionId = session.claudeSessionId;
|
|
655
|
+
const projectPath = session.projectPath;
|
|
656
|
+
const worktreePath = session.worktreePath;
|
|
657
|
+
const worktreeBranch = session.worktreeBranch;
|
|
658
|
+
const sessionName = session.name;
|
|
659
|
+
const permissionMode = session.process.permissionMode;
|
|
660
|
+
const model = session.process.model;
|
|
661
|
+
this.sessionManager.destroy(oldSessionId);
|
|
662
|
+
console.log(`[ws] Claude sandbox change: destroyed session ${oldSessionId}`);
|
|
663
|
+
const newId = this.sessionManager.create(projectPath, {
|
|
664
|
+
sessionId: claudeSessionId,
|
|
665
|
+
permissionMode,
|
|
666
|
+
model,
|
|
667
|
+
sandboxEnabled: newEnabled,
|
|
668
|
+
}, undefined, worktreePath ? { existingWorktreePath: worktreePath, worktreeBranch } : undefined, "claude");
|
|
669
|
+
const newSession = this.sessionManager.get(newId);
|
|
670
|
+
if (newSession && sessionName)
|
|
671
|
+
newSession.name = sessionName;
|
|
672
|
+
void this.loadAndSetSessionName(newSession, "claude", projectPath, claudeSessionId).then(() => {
|
|
673
|
+
this.broadcast({
|
|
674
|
+
type: "system",
|
|
675
|
+
subtype: "session_created",
|
|
676
|
+
sessionId: newId,
|
|
677
|
+
provider: "claude",
|
|
678
|
+
projectPath,
|
|
679
|
+
sandboxMode: msg.sandboxMode,
|
|
680
|
+
sourceSessionId: oldSessionId,
|
|
681
|
+
...(newSession?.worktreePath ? {
|
|
682
|
+
worktreePath: newSession.worktreePath,
|
|
683
|
+
worktreeBranch: newSession.worktreeBranch,
|
|
684
|
+
} : {}),
|
|
685
|
+
});
|
|
686
|
+
this.broadcastSessionList();
|
|
687
|
+
});
|
|
688
|
+
this.debugEvents.set(newId, []);
|
|
689
|
+
this.recordDebugEvent(newId, {
|
|
690
|
+
direction: "internal",
|
|
691
|
+
channel: "bridge",
|
|
692
|
+
type: "sandbox_mode_changed",
|
|
693
|
+
detail: `sandbox=${newEnabled} claude=${claudeSessionId} oldSession=${oldSessionId}`,
|
|
694
|
+
});
|
|
695
|
+
console.log(`[ws] Claude sandbox change: created new session ${newId} (sandbox=${newEnabled})`);
|
|
696
|
+
break;
|
|
697
|
+
}
|
|
698
|
+
// ---- Codex sandbox toggle ----
|
|
699
|
+
const newSandboxMode = sandboxModeToInternal(msg.sandboxMode);
|
|
700
|
+
const currentSandboxMode = session.codexSettings?.sandboxMode ?? "workspace-write";
|
|
701
|
+
if (newSandboxMode === currentSandboxMode) {
|
|
702
|
+
break; // No change needed
|
|
703
|
+
}
|
|
704
|
+
// Sandbox mode is a thread-level setting — it can only be applied at
|
|
705
|
+
// thread/start or thread/resume time, not per-turn. To apply the new
|
|
706
|
+
// mode we destroy the current session and resume the same Codex thread
|
|
707
|
+
// with the updated sandbox parameter (same pattern as clearContext).
|
|
708
|
+
const oldSessionId = session.id;
|
|
709
|
+
const threadId = session.claudeSessionId;
|
|
710
|
+
const projectPath = session.projectPath;
|
|
711
|
+
const oldSettings = session.codexSettings ?? {};
|
|
712
|
+
const worktreePath = session.worktreePath;
|
|
713
|
+
const worktreeBranch = session.worktreeBranch;
|
|
714
|
+
const sessionName = session.name;
|
|
715
|
+
const collaborationMode = session.process.collaborationMode;
|
|
716
|
+
this.sessionManager.destroy(oldSessionId);
|
|
717
|
+
console.log(`[ws] Sandbox mode change: destroyed session ${oldSessionId}`);
|
|
718
|
+
// Check if the user actually exchanged messages in this session.
|
|
719
|
+
// session.history always contains system events (init, status, etc.)
|
|
720
|
+
// even before the first user turn, so we check for user_input/assistant
|
|
721
|
+
// messages specifically.
|
|
722
|
+
const hasUserMessages = session.history?.some((m) => m.type === "user_input" || m.type === "assistant") || (session.pastMessages && session.pastMessages.length > 0);
|
|
723
|
+
if (!threadId || !hasUserMessages) {
|
|
724
|
+
// Session has no thread yet, or has a thread but no messages exchanged.
|
|
725
|
+
// Create a fresh session with the new sandbox — no resume needed.
|
|
726
|
+
// (A thread with no messages cannot be resumed — Codex returns
|
|
727
|
+
// "no rollout found for thread id".)
|
|
728
|
+
const newId = this.sessionManager.create(projectPath, undefined, undefined, worktreePath ? { existingWorktreePath: worktreePath, worktreeBranch } : undefined, "codex", {
|
|
729
|
+
approvalPolicy: oldSettings.approvalPolicy,
|
|
730
|
+
sandboxMode: newSandboxMode,
|
|
731
|
+
model: oldSettings.model,
|
|
732
|
+
modelReasoningEffort: oldSettings.modelReasoningEffort,
|
|
733
|
+
networkAccessEnabled: oldSettings.networkAccessEnabled,
|
|
734
|
+
webSearchMode: oldSettings.webSearchMode,
|
|
735
|
+
collaborationMode,
|
|
736
|
+
});
|
|
737
|
+
const newSession = this.sessionManager.get(newId);
|
|
738
|
+
if (newSession && sessionName)
|
|
739
|
+
newSession.name = sessionName;
|
|
740
|
+
this.broadcast({
|
|
741
|
+
type: "system",
|
|
742
|
+
subtype: "session_created",
|
|
743
|
+
sessionId: newId,
|
|
744
|
+
provider: "codex",
|
|
745
|
+
projectPath,
|
|
746
|
+
sandboxMode: sandboxModeToExternal(newSandboxMode),
|
|
747
|
+
sourceSessionId: oldSessionId,
|
|
748
|
+
...(newSession?.worktreePath ? { worktreePath: newSession.worktreePath, worktreeBranch: newSession.worktreeBranch } : {}),
|
|
749
|
+
});
|
|
750
|
+
this.broadcastSessionList();
|
|
751
|
+
console.log(`[ws] Sandbox mode change (no thread): created new session ${newId} (sandbox=${newSandboxMode})`);
|
|
752
|
+
break;
|
|
753
|
+
}
|
|
754
|
+
// Worktree resolution (same as resume_session)
|
|
755
|
+
const wtMapping = this.worktreeStore.get(threadId);
|
|
756
|
+
const effectiveProjectPath = wtMapping?.projectPath ?? projectPath;
|
|
757
|
+
let worktreeOpts;
|
|
758
|
+
if (wtMapping) {
|
|
759
|
+
if (worktreeExists(wtMapping.worktreePath)) {
|
|
760
|
+
worktreeOpts = { existingWorktreePath: wtMapping.worktreePath, worktreeBranch: wtMapping.worktreeBranch };
|
|
761
|
+
}
|
|
762
|
+
else {
|
|
763
|
+
worktreeOpts = { useWorktree: true, worktreeBranch: wtMapping.worktreeBranch };
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
else if (worktreePath) {
|
|
767
|
+
worktreeOpts = { existingWorktreePath: worktreePath, worktreeBranch };
|
|
768
|
+
}
|
|
769
|
+
getCodexSessionHistory(threadId).then((pastMessages) => {
|
|
770
|
+
const newId = this.sessionManager.create(effectiveProjectPath, undefined, pastMessages, worktreeOpts, "codex", {
|
|
771
|
+
threadId,
|
|
772
|
+
approvalPolicy: oldSettings.approvalPolicy,
|
|
773
|
+
sandboxMode: newSandboxMode,
|
|
774
|
+
model: oldSettings.model,
|
|
775
|
+
modelReasoningEffort: oldSettings.modelReasoningEffort,
|
|
776
|
+
networkAccessEnabled: oldSettings.networkAccessEnabled,
|
|
777
|
+
webSearchMode: oldSettings.webSearchMode,
|
|
778
|
+
collaborationMode,
|
|
779
|
+
});
|
|
780
|
+
// Restore session name
|
|
781
|
+
const newSession = this.sessionManager.get(newId);
|
|
782
|
+
if (newSession && sessionName) {
|
|
783
|
+
newSession.name = sessionName;
|
|
784
|
+
}
|
|
785
|
+
void this.loadAndSetSessionName(newSession, "codex", effectiveProjectPath, threadId).then(() => {
|
|
786
|
+
this.broadcast({
|
|
787
|
+
type: "system",
|
|
788
|
+
subtype: "session_created",
|
|
789
|
+
sessionId: newId,
|
|
790
|
+
provider: "codex",
|
|
791
|
+
projectPath: effectiveProjectPath,
|
|
792
|
+
sandboxMode: sandboxModeToExternal(newSandboxMode),
|
|
793
|
+
sourceSessionId: oldSessionId,
|
|
794
|
+
...(newSession?.worktreePath ? {
|
|
795
|
+
worktreePath: newSession.worktreePath,
|
|
796
|
+
worktreeBranch: newSession.worktreeBranch,
|
|
797
|
+
} : {}),
|
|
798
|
+
});
|
|
799
|
+
this.broadcastSessionList();
|
|
800
|
+
});
|
|
801
|
+
this.debugEvents.set(newId, []);
|
|
802
|
+
this.recordDebugEvent(newId, {
|
|
803
|
+
direction: "internal",
|
|
804
|
+
channel: "bridge",
|
|
805
|
+
type: "sandbox_mode_changed",
|
|
806
|
+
detail: `sandbox=${newSandboxMode} thread=${threadId} oldSession=${oldSessionId}`,
|
|
807
|
+
});
|
|
808
|
+
console.log(`[ws] Sandbox mode change: created new session ${newId} (thread=${threadId}, sandbox=${newSandboxMode})`);
|
|
809
|
+
}).catch((err) => {
|
|
810
|
+
this.send(ws, { type: "error", message: `Failed to restart session for sandbox mode change: ${err}` });
|
|
811
|
+
});
|
|
812
|
+
break;
|
|
813
|
+
}
|
|
814
|
+
case "approve": {
|
|
815
|
+
const session = this.resolveSession(msg.sessionId);
|
|
816
|
+
if (!session) {
|
|
817
|
+
this.send(ws, { type: "error", message: "No active session." });
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
if (session.provider === "codex") {
|
|
821
|
+
session.process.approve(msg.id, msg.updatedInput);
|
|
822
|
+
break;
|
|
823
|
+
}
|
|
824
|
+
const sdkProc = session.process;
|
|
825
|
+
if (msg.clearContext) {
|
|
826
|
+
// Clear & Accept: immediately destroy this runtime session and
|
|
827
|
+
// create a fresh one that continues the same Claude conversation.
|
|
828
|
+
// This guarantees chat history is cleared in the mobile UI without
|
|
829
|
+
// waiting for additional in-turn tool approvals.
|
|
830
|
+
const pending = sdkProc.getPendingPermission(msg.id);
|
|
831
|
+
const mergedInput = {
|
|
832
|
+
...(pending?.input ?? {}),
|
|
833
|
+
...(msg.updatedInput ?? {}),
|
|
834
|
+
};
|
|
835
|
+
const planText = typeof mergedInput.plan === "string" ? mergedInput.plan : "";
|
|
836
|
+
// Use session.id (always present) instead of msg.sessionId.
|
|
837
|
+
const sessionId = session.id;
|
|
838
|
+
// Capture session properties before destroy.
|
|
839
|
+
const claudeSessionId = session.claudeSessionId;
|
|
840
|
+
const projectPath = session.projectPath;
|
|
841
|
+
const permissionMode = sdkProc.permissionMode;
|
|
842
|
+
const worktreePath = session.worktreePath;
|
|
843
|
+
const worktreeBranch = session.worktreeBranch;
|
|
844
|
+
this.sessionManager.destroy(sessionId);
|
|
845
|
+
console.log(`[ws] Clear context: destroyed session ${sessionId}`);
|
|
846
|
+
const newId = this.sessionManager.create(projectPath, {
|
|
847
|
+
...(claudeSessionId
|
|
848
|
+
? {
|
|
849
|
+
sessionId: claudeSessionId,
|
|
850
|
+
continueMode: true,
|
|
851
|
+
}
|
|
852
|
+
: {}),
|
|
853
|
+
permissionMode,
|
|
854
|
+
initialInput: planText || undefined,
|
|
855
|
+
}, undefined, worktreePath ? { existingWorktreePath: worktreePath, worktreeBranch } : undefined);
|
|
856
|
+
console.log(`[ws] Clear context: created new session ${newId} (CLI session: ${claudeSessionId ?? "new"})`);
|
|
857
|
+
// Notify all clients. Broadcast is used so reconnecting clients also receive it.
|
|
858
|
+
const newSession = this.sessionManager.get(newId);
|
|
859
|
+
this.broadcast({
|
|
860
|
+
type: "system",
|
|
861
|
+
subtype: "session_created",
|
|
862
|
+
sessionId: newId,
|
|
863
|
+
provider: newSession?.provider ?? "claude",
|
|
864
|
+
projectPath,
|
|
865
|
+
...(permissionMode ? { permissionMode } : {}),
|
|
866
|
+
clearContext: true,
|
|
867
|
+
sourceSessionId: sessionId,
|
|
868
|
+
});
|
|
869
|
+
this.broadcastSessionList();
|
|
870
|
+
}
|
|
871
|
+
else {
|
|
872
|
+
sdkProc.approve(msg.id, msg.updatedInput);
|
|
873
|
+
}
|
|
874
|
+
break;
|
|
875
|
+
}
|
|
876
|
+
case "approve_always": {
|
|
877
|
+
const session = this.resolveSession(msg.sessionId);
|
|
878
|
+
if (!session) {
|
|
879
|
+
this.send(ws, { type: "error", message: "No active session." });
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
if (session.provider === "codex") {
|
|
883
|
+
session.process.approveAlways(msg.id);
|
|
884
|
+
break;
|
|
885
|
+
}
|
|
886
|
+
session.process.approveAlways(msg.id);
|
|
887
|
+
break;
|
|
888
|
+
}
|
|
889
|
+
case "reject": {
|
|
890
|
+
const session = this.resolveSession(msg.sessionId);
|
|
891
|
+
if (!session) {
|
|
892
|
+
this.send(ws, { type: "error", message: "No active session." });
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
if (session.provider === "codex") {
|
|
896
|
+
session.process.reject(msg.id, msg.message);
|
|
897
|
+
break;
|
|
898
|
+
}
|
|
899
|
+
session.process.reject(msg.id, msg.message);
|
|
900
|
+
break;
|
|
901
|
+
}
|
|
902
|
+
case "answer": {
|
|
903
|
+
const session = this.resolveSession(msg.sessionId);
|
|
904
|
+
if (!session) {
|
|
905
|
+
this.send(ws, { type: "error", message: "No active session." });
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
if (session.provider === "codex") {
|
|
909
|
+
session.process.answer(msg.toolUseId, msg.result);
|
|
910
|
+
break;
|
|
911
|
+
}
|
|
912
|
+
session.process.answer(msg.toolUseId, msg.result);
|
|
913
|
+
break;
|
|
914
|
+
}
|
|
915
|
+
case "list_sessions": {
|
|
916
|
+
this.sendSessionList(ws);
|
|
917
|
+
break;
|
|
918
|
+
}
|
|
919
|
+
case "stop_session": {
|
|
920
|
+
const session = this.sessionManager.get(msg.sessionId);
|
|
921
|
+
if (session) {
|
|
922
|
+
// Notify clients before destroying (destroy removes listeners)
|
|
923
|
+
this.broadcastSessionMessage(msg.sessionId, {
|
|
924
|
+
type: "result",
|
|
925
|
+
subtype: "stopped",
|
|
926
|
+
sessionId: session.claudeSessionId,
|
|
927
|
+
});
|
|
928
|
+
this.sessionManager.destroy(msg.sessionId);
|
|
929
|
+
this.recordDebugEvent(msg.sessionId, {
|
|
930
|
+
direction: "internal",
|
|
931
|
+
channel: "bridge",
|
|
932
|
+
type: "session_stopped",
|
|
933
|
+
});
|
|
934
|
+
this.debugEvents.delete(msg.sessionId);
|
|
935
|
+
this.notifiedPermissionToolUses.delete(msg.sessionId);
|
|
936
|
+
this.sendSessionList(ws);
|
|
937
|
+
}
|
|
938
|
+
else {
|
|
939
|
+
this.send(ws, { type: "error", message: `Session ${msg.sessionId} not found` });
|
|
940
|
+
}
|
|
941
|
+
break;
|
|
942
|
+
}
|
|
943
|
+
case "get_history": {
|
|
944
|
+
const session = this.sessionManager.get(msg.sessionId);
|
|
945
|
+
if (session) {
|
|
946
|
+
// Send past conversation from disk (resume) before in-memory history
|
|
947
|
+
if (session.pastMessages && session.pastMessages.length > 0) {
|
|
948
|
+
this.send(ws, {
|
|
949
|
+
type: "past_history",
|
|
950
|
+
claudeSessionId: session.claudeSessionId ?? msg.sessionId,
|
|
951
|
+
sessionId: msg.sessionId,
|
|
952
|
+
messages: session.pastMessages,
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
this.send(ws, { type: "history", messages: session.history, sessionId: msg.sessionId });
|
|
956
|
+
this.send(ws, { type: "status", status: session.status, sessionId: msg.sessionId });
|
|
957
|
+
// Send cached slash commands so the client can restore them even when
|
|
958
|
+
// the original init/supported_commands message was evicted from the
|
|
959
|
+
// in-memory history (MAX_HISTORY_PER_SESSION overflow).
|
|
960
|
+
const cached = this.sessionManager.getCachedCommands(session.projectPath);
|
|
961
|
+
if (cached && cached.slashCommands.length > 0) {
|
|
962
|
+
this.send(ws, {
|
|
963
|
+
type: "system",
|
|
964
|
+
subtype: "supported_commands",
|
|
965
|
+
sessionId: msg.sessionId,
|
|
966
|
+
slashCommands: cached.slashCommands,
|
|
967
|
+
skills: cached.skills,
|
|
968
|
+
...(cached.skillMetadata ? { skillMetadata: cached.skillMetadata } : {}),
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
else {
|
|
973
|
+
this.send(ws, { type: "error", message: `Session ${msg.sessionId} not found` });
|
|
974
|
+
}
|
|
975
|
+
break;
|
|
976
|
+
}
|
|
977
|
+
case "refresh_branch": {
|
|
978
|
+
const session = this.sessionManager.get(msg.sessionId);
|
|
979
|
+
if (session) {
|
|
980
|
+
const cwd = session.worktreePath ?? session.projectPath;
|
|
981
|
+
let branch = "";
|
|
982
|
+
try {
|
|
983
|
+
branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
984
|
+
cwd, encoding: "utf-8",
|
|
985
|
+
}).trim();
|
|
986
|
+
}
|
|
987
|
+
catch { /* not a git repo */ }
|
|
988
|
+
// Update stored branch so future session_list responses are also current
|
|
989
|
+
session.gitBranch = branch;
|
|
990
|
+
this.send(ws, {
|
|
991
|
+
type: "branch_update",
|
|
992
|
+
sessionId: msg.sessionId,
|
|
993
|
+
branch,
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
else {
|
|
997
|
+
this.send(ws, { type: "error", message: `Session ${msg.sessionId} not found` });
|
|
998
|
+
}
|
|
999
|
+
break;
|
|
1000
|
+
}
|
|
1001
|
+
case "get_debug_bundle": {
|
|
1002
|
+
const session = this.sessionManager.get(msg.sessionId);
|
|
1003
|
+
if (!session) {
|
|
1004
|
+
this.send(ws, { type: "error", message: `Session ${msg.sessionId} not found` });
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
const emitBundle = (diff, diffError) => {
|
|
1008
|
+
const traceLimit = msg.traceLimit ?? BridgeWebSocketServer.MAX_DEBUG_EVENTS;
|
|
1009
|
+
const trace = this.getDebugEvents(msg.sessionId, traceLimit);
|
|
1010
|
+
const generatedAt = new Date().toISOString();
|
|
1011
|
+
const includeDiff = msg.includeDiff !== false;
|
|
1012
|
+
const bundlePayload = {
|
|
1013
|
+
type: "debug_bundle",
|
|
1014
|
+
sessionId: msg.sessionId,
|
|
1015
|
+
generatedAt,
|
|
1016
|
+
session: {
|
|
1017
|
+
id: session.id,
|
|
1018
|
+
provider: session.provider,
|
|
1019
|
+
status: session.status,
|
|
1020
|
+
projectPath: session.projectPath,
|
|
1021
|
+
worktreePath: session.worktreePath,
|
|
1022
|
+
worktreeBranch: session.worktreeBranch,
|
|
1023
|
+
claudeSessionId: session.claudeSessionId,
|
|
1024
|
+
createdAt: session.createdAt.toISOString(),
|
|
1025
|
+
lastActivityAt: session.lastActivityAt.toISOString(),
|
|
1026
|
+
},
|
|
1027
|
+
pastMessageCount: session.pastMessages?.length ?? 0,
|
|
1028
|
+
historySummary: this.buildHistorySummary(session.history),
|
|
1029
|
+
debugTrace: trace,
|
|
1030
|
+
traceFilePath: this.debugTraceStore.getTraceFilePath(msg.sessionId),
|
|
1031
|
+
reproRecipe: this.buildReproRecipe(session, traceLimit, includeDiff),
|
|
1032
|
+
agentPrompt: this.buildAgentPrompt(session),
|
|
1033
|
+
diff,
|
|
1034
|
+
diffError,
|
|
1035
|
+
};
|
|
1036
|
+
const savedBundlePath = this.debugTraceStore.getBundleFilePath(msg.sessionId, generatedAt);
|
|
1037
|
+
bundlePayload.savedBundlePath = savedBundlePath;
|
|
1038
|
+
this.debugTraceStore.saveBundleAtPath(savedBundlePath, bundlePayload);
|
|
1039
|
+
this.send(ws, bundlePayload);
|
|
1040
|
+
};
|
|
1041
|
+
if (msg.includeDiff === false) {
|
|
1042
|
+
emitBundle("");
|
|
1043
|
+
break;
|
|
1044
|
+
}
|
|
1045
|
+
const cwd = session.worktreePath ?? session.projectPath;
|
|
1046
|
+
this.collectGitDiff(cwd, ({ diff, error }) => {
|
|
1047
|
+
emitBundle(diff, error);
|
|
1048
|
+
});
|
|
1049
|
+
break;
|
|
1050
|
+
}
|
|
1051
|
+
case "get_usage": {
|
|
1052
|
+
fetchAllUsage().then((providers) => {
|
|
1053
|
+
this.send(ws, { type: "usage_result", providers });
|
|
1054
|
+
}).catch((err) => {
|
|
1055
|
+
this.send(ws, { type: "error", message: `Failed to fetch usage: ${err}` });
|
|
1056
|
+
});
|
|
1057
|
+
break;
|
|
1058
|
+
}
|
|
1059
|
+
case "list_recent_sessions": {
|
|
1060
|
+
const requestId = ++this.recentSessionsRequestId;
|
|
1061
|
+
this.listRecentSessions(msg).then(({ sessions, hasMore }) => {
|
|
1062
|
+
// Drop stale responses when rapid filter switches cause out-of-order completion
|
|
1063
|
+
if (requestId !== this.recentSessionsRequestId)
|
|
1064
|
+
return;
|
|
1065
|
+
this.send(ws, { type: "recent_sessions", sessions, hasMore });
|
|
1066
|
+
}).catch((err) => {
|
|
1067
|
+
if (requestId !== this.recentSessionsRequestId)
|
|
1068
|
+
return;
|
|
1069
|
+
this.send(ws, { type: "error", message: `Failed to list recent sessions: ${err}` });
|
|
1070
|
+
});
|
|
1071
|
+
break;
|
|
1072
|
+
}
|
|
1073
|
+
case "archive_session": {
|
|
1074
|
+
const { sessionId, provider, projectPath } = msg;
|
|
1075
|
+
this.archiveStore.archive(sessionId, provider, projectPath).then(() => {
|
|
1076
|
+
// For Codex sessions, also call thread/archive RPC (best-effort).
|
|
1077
|
+
// Requires a running Codex app-server process; skip if none active.
|
|
1078
|
+
if (provider === "codex") {
|
|
1079
|
+
const activeSessions = this.sessionManager.list();
|
|
1080
|
+
const codexSession = activeSessions.find((s) => s.provider === "codex");
|
|
1081
|
+
if (codexSession) {
|
|
1082
|
+
const session = this.sessionManager.get(codexSession.id);
|
|
1083
|
+
if (session) {
|
|
1084
|
+
session.process.archiveThread(sessionId).catch((err) => {
|
|
1085
|
+
console.warn(`[ws] Codex thread/archive failed (non-fatal): ${err}`);
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
this.send(ws, {
|
|
1091
|
+
type: "archive_result",
|
|
1092
|
+
sessionId,
|
|
1093
|
+
success: true,
|
|
1094
|
+
});
|
|
1095
|
+
}).catch((err) => {
|
|
1096
|
+
this.send(ws, {
|
|
1097
|
+
type: "archive_result",
|
|
1098
|
+
sessionId,
|
|
1099
|
+
success: false,
|
|
1100
|
+
error: String(err),
|
|
1101
|
+
});
|
|
1102
|
+
});
|
|
1103
|
+
break;
|
|
1104
|
+
}
|
|
1105
|
+
case "resume_session": {
|
|
1106
|
+
console.log(`[ws] resume_session: sessionId=${msg.sessionId} projectPath=${msg.projectPath} provider=${msg.provider ?? "claude"}`);
|
|
1107
|
+
if (!this.isPathAllowed(msg.projectPath)) {
|
|
1108
|
+
this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
|
|
1109
|
+
break;
|
|
1110
|
+
}
|
|
1111
|
+
const provider = msg.provider ?? "claude";
|
|
1112
|
+
const sessionRefId = msg.sessionId;
|
|
1113
|
+
// Resume flow: keep past history in SessionInfo and deliver it only
|
|
1114
|
+
// via get_history(sessionId) to avoid duplicate/missed replay races.
|
|
1115
|
+
if (provider === "codex") {
|
|
1116
|
+
const wtMapping = this.worktreeStore.get(sessionRefId);
|
|
1117
|
+
const effectiveProjectPath = wtMapping?.projectPath ?? msg.projectPath;
|
|
1118
|
+
let worktreeOpts;
|
|
1119
|
+
if (wtMapping) {
|
|
1120
|
+
if (worktreeExists(wtMapping.worktreePath)) {
|
|
1121
|
+
worktreeOpts = {
|
|
1122
|
+
existingWorktreePath: wtMapping.worktreePath,
|
|
1123
|
+
worktreeBranch: wtMapping.worktreeBranch,
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
else {
|
|
1127
|
+
worktreeOpts = {
|
|
1128
|
+
useWorktree: true,
|
|
1129
|
+
worktreeBranch: wtMapping.worktreeBranch,
|
|
1130
|
+
};
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
getCodexSessionHistory(sessionRefId).then((pastMessages) => {
|
|
1134
|
+
const sessionId = this.sessionManager.create(effectiveProjectPath, undefined, pastMessages, worktreeOpts, "codex", {
|
|
1135
|
+
threadId: sessionRefId,
|
|
1136
|
+
approvalPolicy: permissionModeToApprovalPolicy(msg.permissionMode),
|
|
1137
|
+
sandboxMode: sandboxModeToInternal(msg.sandboxMode),
|
|
1138
|
+
model: msg.model,
|
|
1139
|
+
modelReasoningEffort: msg.modelReasoningEffort ?? undefined,
|
|
1140
|
+
networkAccessEnabled: msg.networkAccessEnabled,
|
|
1141
|
+
webSearchMode: msg.webSearchMode ?? undefined,
|
|
1142
|
+
collaborationMode: msg.permissionMode === "plan" ? "plan" : "default",
|
|
1143
|
+
});
|
|
1144
|
+
const createdSession = this.sessionManager.get(sessionId);
|
|
1145
|
+
void this.loadAndSetSessionName(createdSession, "codex", effectiveProjectPath, sessionRefId).then(() => {
|
|
1146
|
+
this.send(ws, {
|
|
1147
|
+
type: "system",
|
|
1148
|
+
subtype: "session_created",
|
|
1149
|
+
sessionId,
|
|
1150
|
+
provider: "codex",
|
|
1151
|
+
projectPath: effectiveProjectPath,
|
|
1152
|
+
...(createdSession?.codexSettings?.sandboxMode ? { sandboxMode: sandboxModeToExternal(createdSession.codexSettings.sandboxMode) } : {}),
|
|
1153
|
+
...(msg.permissionMode ? { permissionMode: msg.permissionMode } : {}),
|
|
1154
|
+
...(createdSession?.worktreePath ? {
|
|
1155
|
+
worktreePath: createdSession.worktreePath,
|
|
1156
|
+
worktreeBranch: createdSession.worktreeBranch,
|
|
1157
|
+
} : {}),
|
|
1158
|
+
});
|
|
1159
|
+
this.broadcastSessionList();
|
|
1160
|
+
});
|
|
1161
|
+
this.debugEvents.set(sessionId, []);
|
|
1162
|
+
this.recordDebugEvent(sessionId, {
|
|
1163
|
+
direction: "internal",
|
|
1164
|
+
channel: "bridge",
|
|
1165
|
+
type: "session_resumed",
|
|
1166
|
+
detail: `provider=codex thread=${sessionRefId}`,
|
|
1167
|
+
});
|
|
1168
|
+
this.projectHistory?.addProject(effectiveProjectPath);
|
|
1169
|
+
}).catch((err) => {
|
|
1170
|
+
this.send(ws, { type: "error", message: `Failed to load Codex session history: ${err}` });
|
|
1171
|
+
});
|
|
1172
|
+
break;
|
|
1173
|
+
}
|
|
1174
|
+
const claudeSessionId = sessionRefId;
|
|
1175
|
+
const cached = this.sessionManager.getCachedCommands(msg.projectPath);
|
|
1176
|
+
// Look up worktree mapping for this Claude session
|
|
1177
|
+
const wtMapping = this.worktreeStore.get(claudeSessionId);
|
|
1178
|
+
let worktreeOpts;
|
|
1179
|
+
if (wtMapping) {
|
|
1180
|
+
if (worktreeExists(wtMapping.worktreePath)) {
|
|
1181
|
+
// Worktree exists — reuse it directly
|
|
1182
|
+
worktreeOpts = {
|
|
1183
|
+
existingWorktreePath: wtMapping.worktreePath,
|
|
1184
|
+
worktreeBranch: wtMapping.worktreeBranch,
|
|
1185
|
+
};
|
|
1186
|
+
}
|
|
1187
|
+
else {
|
|
1188
|
+
// Worktree was deleted — recreate on the same branch
|
|
1189
|
+
worktreeOpts = { useWorktree: true, worktreeBranch: wtMapping.worktreeBranch };
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
getSessionHistory(claudeSessionId).then((pastMessages) => {
|
|
1193
|
+
const sessionId = this.sessionManager.create(msg.projectPath, {
|
|
1194
|
+
sessionId: claudeSessionId,
|
|
1195
|
+
permissionMode: msg.permissionMode,
|
|
1196
|
+
model: msg.model,
|
|
1197
|
+
effort: msg.effort,
|
|
1198
|
+
maxTurns: msg.maxTurns,
|
|
1199
|
+
maxBudgetUsd: msg.maxBudgetUsd,
|
|
1200
|
+
fallbackModel: msg.fallbackModel,
|
|
1201
|
+
forkSession: msg.forkSession,
|
|
1202
|
+
persistSession: msg.persistSession,
|
|
1203
|
+
...(msg.sandboxMode ? { sandboxEnabled: msg.sandboxMode === "on" } : {}),
|
|
1204
|
+
}, pastMessages, worktreeOpts);
|
|
1205
|
+
const createdSession = this.sessionManager.get(sessionId);
|
|
1206
|
+
void this.loadAndSetSessionName(createdSession, "claude", msg.projectPath, claudeSessionId).then(() => {
|
|
1207
|
+
this.send(ws, {
|
|
1208
|
+
type: "system",
|
|
1209
|
+
subtype: "session_created",
|
|
1210
|
+
sessionId,
|
|
1211
|
+
claudeSessionId,
|
|
1212
|
+
provider: "claude",
|
|
1213
|
+
projectPath: msg.projectPath,
|
|
1214
|
+
...(msg.permissionMode ? { permissionMode: msg.permissionMode } : {}),
|
|
1215
|
+
...(msg.sandboxMode ? { sandboxMode: msg.sandboxMode } : {}),
|
|
1216
|
+
...(cached ? { slashCommands: cached.slashCommands, skills: cached.skills, ...(cached.skillMetadata ? { skillMetadata: cached.skillMetadata } : {}) } : {}),
|
|
1217
|
+
...(createdSession?.worktreePath ? {
|
|
1218
|
+
worktreePath: createdSession.worktreePath,
|
|
1219
|
+
worktreeBranch: createdSession.worktreeBranch,
|
|
1220
|
+
} : {}),
|
|
1221
|
+
});
|
|
1222
|
+
this.broadcastSessionList();
|
|
1223
|
+
});
|
|
1224
|
+
this.debugEvents.set(sessionId, []);
|
|
1225
|
+
this.recordDebugEvent(sessionId, {
|
|
1226
|
+
direction: "internal",
|
|
1227
|
+
channel: "bridge",
|
|
1228
|
+
type: "session_resumed",
|
|
1229
|
+
detail: `provider=claude session=${claudeSessionId}`,
|
|
1230
|
+
});
|
|
1231
|
+
this.projectHistory?.addProject(msg.projectPath);
|
|
1232
|
+
}).catch((err) => {
|
|
1233
|
+
this.send(ws, { type: "error", message: `Failed to load session history: ${err}` });
|
|
1234
|
+
});
|
|
1235
|
+
break;
|
|
1236
|
+
}
|
|
1237
|
+
case "list_gallery": {
|
|
1238
|
+
if (this.galleryStore) {
|
|
1239
|
+
const images = this.galleryStore.list({
|
|
1240
|
+
projectPath: msg.project,
|
|
1241
|
+
sessionId: msg.sessionId,
|
|
1242
|
+
});
|
|
1243
|
+
this.send(ws, { type: "gallery_list", images });
|
|
1244
|
+
}
|
|
1245
|
+
else {
|
|
1246
|
+
this.send(ws, { type: "gallery_list", images: [] });
|
|
1247
|
+
}
|
|
1248
|
+
break;
|
|
1249
|
+
}
|
|
1250
|
+
case "get_message_images": {
|
|
1251
|
+
void extractMessageImages(msg.claudeSessionId, msg.messageUuid).then((images) => {
|
|
1252
|
+
const refs = [];
|
|
1253
|
+
if (this.imageStore) {
|
|
1254
|
+
for (const img of images) {
|
|
1255
|
+
const ref = this.imageStore.registerFromBase64(img.base64, img.mimeType);
|
|
1256
|
+
if (ref)
|
|
1257
|
+
refs.push(ref);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
this.send(ws, { type: "message_images_result", messageUuid: msg.messageUuid, images: refs });
|
|
1261
|
+
}).catch((err) => {
|
|
1262
|
+
console.error("[ws] Failed to extract message images:", err);
|
|
1263
|
+
this.send(ws, { type: "message_images_result", messageUuid: msg.messageUuid, images: [] });
|
|
1264
|
+
});
|
|
1265
|
+
break;
|
|
1266
|
+
}
|
|
1267
|
+
case "interrupt": {
|
|
1268
|
+
const session = this.resolveSession(msg.sessionId);
|
|
1269
|
+
if (!session) {
|
|
1270
|
+
this.send(ws, { type: "error", message: "No active session." });
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
session.process.interrupt();
|
|
1274
|
+
break;
|
|
1275
|
+
}
|
|
1276
|
+
case "list_project_history": {
|
|
1277
|
+
const projects = this.projectHistory?.getProjects() ?? [];
|
|
1278
|
+
this.send(ws, { type: "project_history", projects });
|
|
1279
|
+
break;
|
|
1280
|
+
}
|
|
1281
|
+
case "remove_project_history": {
|
|
1282
|
+
this.projectHistory?.removeProject(msg.projectPath);
|
|
1283
|
+
const projects = this.projectHistory?.getProjects() ?? [];
|
|
1284
|
+
this.send(ws, { type: "project_history", projects });
|
|
1285
|
+
break;
|
|
1286
|
+
}
|
|
1287
|
+
case "list_files": {
|
|
1288
|
+
if (!this.isPathAllowed(msg.projectPath)) {
|
|
1289
|
+
this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
|
|
1290
|
+
break;
|
|
1291
|
+
}
|
|
1292
|
+
execFile("git", ["ls-files"], { cwd: msg.projectPath, maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => {
|
|
1293
|
+
if (err) {
|
|
1294
|
+
if (/not a git repository/i.test(err.message)) {
|
|
1295
|
+
// Non-git project: silently return empty list (file listing is auxiliary)
|
|
1296
|
+
this.send(ws, { type: "file_list", files: [] });
|
|
1297
|
+
}
|
|
1298
|
+
else {
|
|
1299
|
+
this.send(ws, { type: "error", message: `Failed to list files: ${err.message}` });
|
|
1300
|
+
}
|
|
1301
|
+
return;
|
|
1302
|
+
}
|
|
1303
|
+
const files = stdout.trim().split("\n").filter(Boolean);
|
|
1304
|
+
this.send(ws, { type: "file_list", files });
|
|
1305
|
+
});
|
|
1306
|
+
break;
|
|
1307
|
+
}
|
|
1308
|
+
case "list_recordings": {
|
|
1309
|
+
if (!this.recordingStore) {
|
|
1310
|
+
this.send(ws, { type: "recording_list", recordings: [] });
|
|
1311
|
+
break;
|
|
1312
|
+
}
|
|
1313
|
+
const store = this.recordingStore;
|
|
1314
|
+
void store.listRecordings().then(async (recordings) => {
|
|
1315
|
+
// First pass: extract info from JSONL for recordings missing firstPrompt
|
|
1316
|
+
// This covers both meta-less legacy recordings and new ones where sessions-index hasn't indexed yet
|
|
1317
|
+
await Promise.all(recordings.map(async (rec) => {
|
|
1318
|
+
const info = await store.extractInfoFromJsonl(rec.name);
|
|
1319
|
+
if (info.firstPrompt && !rec.firstPrompt)
|
|
1320
|
+
rec.firstPrompt = info.firstPrompt;
|
|
1321
|
+
if (info.lastPrompt && !rec.lastPrompt)
|
|
1322
|
+
rec.lastPrompt = info.lastPrompt;
|
|
1323
|
+
// Backfill meta for legacy recordings
|
|
1324
|
+
if (!rec.meta && (info.claudeSessionId || info.projectPath)) {
|
|
1325
|
+
rec.meta = {
|
|
1326
|
+
bridgeSessionId: rec.name,
|
|
1327
|
+
claudeSessionId: info.claudeSessionId,
|
|
1328
|
+
projectPath: info.projectPath ?? "",
|
|
1329
|
+
createdAt: rec.modified,
|
|
1330
|
+
};
|
|
1331
|
+
}
|
|
1332
|
+
}));
|
|
1333
|
+
// Second pass: look up sessions-index for summaries (if claudeSessionIds available)
|
|
1334
|
+
const claudeIds = new Set();
|
|
1335
|
+
const idToIdx = new Map();
|
|
1336
|
+
for (let i = 0; i < recordings.length; i++) {
|
|
1337
|
+
const cid = recordings[i].meta?.claudeSessionId;
|
|
1338
|
+
if (cid) {
|
|
1339
|
+
claudeIds.add(cid);
|
|
1340
|
+
const arr = idToIdx.get(cid) ?? [];
|
|
1341
|
+
arr.push(i);
|
|
1342
|
+
idToIdx.set(cid, arr);
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
if (claudeIds.size > 0) {
|
|
1346
|
+
const sessionInfo = await findSessionsByClaudeIds(claudeIds);
|
|
1347
|
+
for (const [cid, info] of sessionInfo) {
|
|
1348
|
+
const indices = idToIdx.get(cid) ?? [];
|
|
1349
|
+
for (const idx of indices) {
|
|
1350
|
+
if (info.summary)
|
|
1351
|
+
recordings[idx].summary = info.summary;
|
|
1352
|
+
if (info.firstPrompt)
|
|
1353
|
+
recordings[idx].firstPrompt = info.firstPrompt;
|
|
1354
|
+
if (info.lastPrompt)
|
|
1355
|
+
recordings[idx].lastPrompt = info.lastPrompt;
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
this.send(ws, { type: "recording_list", recordings });
|
|
1360
|
+
});
|
|
1361
|
+
break;
|
|
1362
|
+
}
|
|
1363
|
+
case "get_recording": {
|
|
1364
|
+
if (!this.recordingStore) {
|
|
1365
|
+
this.send(ws, { type: "error", message: "Recording is not enabled on this server" });
|
|
1366
|
+
break;
|
|
1367
|
+
}
|
|
1368
|
+
void this.recordingStore.getRecordingContent(msg.sessionId).then((content) => {
|
|
1369
|
+
if (content !== null) {
|
|
1370
|
+
this.send(ws, { type: "recording_content", sessionId: msg.sessionId, content });
|
|
1371
|
+
}
|
|
1372
|
+
else {
|
|
1373
|
+
this.send(ws, { type: "error", message: `Recording ${msg.sessionId} not found` });
|
|
1374
|
+
}
|
|
1375
|
+
});
|
|
1376
|
+
break;
|
|
1377
|
+
}
|
|
1378
|
+
case "get_diff": {
|
|
1379
|
+
if (!this.isPathAllowed(msg.projectPath)) {
|
|
1380
|
+
this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
|
|
1381
|
+
break;
|
|
1382
|
+
}
|
|
1383
|
+
this.collectGitDiff(msg.projectPath, ({ diff, error }) => {
|
|
1384
|
+
if (error) {
|
|
1385
|
+
if (/not a git repository/i.test(error)) {
|
|
1386
|
+
this.send(ws, {
|
|
1387
|
+
type: "diff_result",
|
|
1388
|
+
diff: "",
|
|
1389
|
+
error: "This project is not a git repository",
|
|
1390
|
+
errorCode: "git_not_available",
|
|
1391
|
+
});
|
|
1392
|
+
}
|
|
1393
|
+
else {
|
|
1394
|
+
this.send(ws, { type: "diff_result", diff: "", error: `Failed to get diff: ${error}` });
|
|
1395
|
+
}
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
void this.collectImageChanges(msg.projectPath, diff).then((imageChanges) => {
|
|
1399
|
+
if (imageChanges.length > 0) {
|
|
1400
|
+
this.send(ws, { type: "diff_result", diff, imageChanges });
|
|
1401
|
+
}
|
|
1402
|
+
else {
|
|
1403
|
+
this.send(ws, { type: "diff_result", diff });
|
|
1404
|
+
}
|
|
1405
|
+
});
|
|
1406
|
+
});
|
|
1407
|
+
break;
|
|
1408
|
+
}
|
|
1409
|
+
case "get_diff_image": {
|
|
1410
|
+
if (!this.isPathAllowed(msg.projectPath) || !this.isPathAllowed(resolve(msg.projectPath, msg.filePath))) {
|
|
1411
|
+
this.send(ws, { type: "error", message: `Path not allowed` });
|
|
1412
|
+
break;
|
|
1413
|
+
}
|
|
1414
|
+
if (msg.version === "both") {
|
|
1415
|
+
void (async () => {
|
|
1416
|
+
try {
|
|
1417
|
+
const [oldResult, newResult] = await Promise.all([
|
|
1418
|
+
this.loadDiffImageAsync(msg.projectPath, msg.filePath, "old"),
|
|
1419
|
+
this.loadDiffImageAsync(msg.projectPath, msg.filePath, "new"),
|
|
1420
|
+
]);
|
|
1421
|
+
const errors = [oldResult.error, newResult.error].filter(Boolean);
|
|
1422
|
+
this.send(ws, {
|
|
1423
|
+
type: "diff_image_result",
|
|
1424
|
+
filePath: msg.filePath,
|
|
1425
|
+
version: "both",
|
|
1426
|
+
oldBase64: oldResult.base64,
|
|
1427
|
+
newBase64: newResult.base64,
|
|
1428
|
+
mimeType: oldResult.mimeType ?? newResult.mimeType,
|
|
1429
|
+
...(errors.length > 0 ? { error: errors.join("; ") } : {}),
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
1432
|
+
catch {
|
|
1433
|
+
// WebSocket may have closed; ignore send errors.
|
|
1434
|
+
}
|
|
1435
|
+
})();
|
|
1436
|
+
}
|
|
1437
|
+
else {
|
|
1438
|
+
const version = msg.version;
|
|
1439
|
+
void (async () => {
|
|
1440
|
+
try {
|
|
1441
|
+
const result = await this.loadDiffImageAsync(msg.projectPath, msg.filePath, version);
|
|
1442
|
+
this.send(ws, { type: "diff_image_result", filePath: msg.filePath, version, ...result });
|
|
1443
|
+
}
|
|
1444
|
+
catch {
|
|
1445
|
+
// WebSocket may have closed; ignore send errors.
|
|
1446
|
+
}
|
|
1447
|
+
})();
|
|
1448
|
+
}
|
|
1449
|
+
break;
|
|
1450
|
+
}
|
|
1451
|
+
case "list_worktrees": {
|
|
1452
|
+
if (!this.isPathAllowed(msg.projectPath)) {
|
|
1453
|
+
this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
|
|
1454
|
+
break;
|
|
1455
|
+
}
|
|
1456
|
+
try {
|
|
1457
|
+
const worktrees = listWorktrees(msg.projectPath);
|
|
1458
|
+
const mainBranch = getMainBranch(msg.projectPath);
|
|
1459
|
+
this.send(ws, { type: "worktree_list", worktrees, mainBranch });
|
|
1460
|
+
}
|
|
1461
|
+
catch (err) {
|
|
1462
|
+
this.send(ws, { type: "error", message: `Failed to list worktrees: ${err}` });
|
|
1463
|
+
}
|
|
1464
|
+
break;
|
|
1465
|
+
}
|
|
1466
|
+
case "remove_worktree": {
|
|
1467
|
+
if (!this.isPathAllowed(msg.projectPath)) {
|
|
1468
|
+
this.send(ws, this.buildPathNotAllowedError(msg.projectPath));
|
|
1469
|
+
break;
|
|
1470
|
+
}
|
|
1471
|
+
try {
|
|
1472
|
+
removeWorktree(msg.projectPath, msg.worktreePath);
|
|
1473
|
+
this.worktreeStore.deleteByWorktreePath(msg.worktreePath);
|
|
1474
|
+
this.send(ws, { type: "worktree_removed", worktreePath: msg.worktreePath });
|
|
1475
|
+
}
|
|
1476
|
+
catch (err) {
|
|
1477
|
+
this.send(ws, { type: "error", message: `Failed to remove worktree: ${err}` });
|
|
1478
|
+
}
|
|
1479
|
+
break;
|
|
1480
|
+
}
|
|
1481
|
+
case "rewind_dry_run": {
|
|
1482
|
+
const session = this.sessionManager.get(msg.sessionId);
|
|
1483
|
+
if (!session) {
|
|
1484
|
+
this.send(ws, { type: "rewind_preview", canRewind: false, error: `Session ${msg.sessionId} not found` });
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
this.sessionManager.rewindFiles(msg.sessionId, msg.targetUuid, true).then((result) => {
|
|
1488
|
+
this.send(ws, {
|
|
1489
|
+
type: "rewind_preview",
|
|
1490
|
+
canRewind: result.canRewind,
|
|
1491
|
+
filesChanged: result.filesChanged,
|
|
1492
|
+
insertions: result.insertions,
|
|
1493
|
+
deletions: result.deletions,
|
|
1494
|
+
error: result.error,
|
|
1495
|
+
});
|
|
1496
|
+
}).catch((err) => {
|
|
1497
|
+
this.send(ws, { type: "rewind_preview", canRewind: false, error: `Dry run failed: ${err}` });
|
|
1498
|
+
});
|
|
1499
|
+
break;
|
|
1500
|
+
}
|
|
1501
|
+
case "rewind": {
|
|
1502
|
+
const session = this.sessionManager.get(msg.sessionId);
|
|
1503
|
+
if (!session) {
|
|
1504
|
+
this.send(ws, { type: "rewind_result", success: false, mode: msg.mode, error: `Session ${msg.sessionId} not found` });
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
const handleError = (err) => {
|
|
1508
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1509
|
+
this.send(ws, { type: "rewind_result", success: false, mode: msg.mode, error: errMsg });
|
|
1510
|
+
};
|
|
1511
|
+
if (msg.mode === "code") {
|
|
1512
|
+
// Code-only rewind: rewind files without restarting the conversation
|
|
1513
|
+
this.sessionManager.rewindFiles(msg.sessionId, msg.targetUuid).then((result) => {
|
|
1514
|
+
if (result.canRewind) {
|
|
1515
|
+
this.send(ws, { type: "rewind_result", success: true, mode: "code" });
|
|
1516
|
+
}
|
|
1517
|
+
else {
|
|
1518
|
+
this.send(ws, { type: "rewind_result", success: false, mode: "code", error: result.error ?? "Cannot rewind files" });
|
|
1519
|
+
}
|
|
1520
|
+
}).catch(handleError);
|
|
1521
|
+
}
|
|
1522
|
+
else if (msg.mode === "conversation") {
|
|
1523
|
+
// Conversation-only rewind: restart session at the target UUID
|
|
1524
|
+
try {
|
|
1525
|
+
this.sessionManager.rewindConversation(msg.sessionId, msg.targetUuid, (newSessionId) => {
|
|
1526
|
+
this.send(ws, { type: "rewind_result", success: true, mode: "conversation" });
|
|
1527
|
+
// Notify the new session ID
|
|
1528
|
+
const newSession = this.sessionManager.get(newSessionId);
|
|
1529
|
+
const rewindPermMode = newSession?.process instanceof SdkProcess ? newSession.process.permissionMode : undefined;
|
|
1530
|
+
this.send(ws, {
|
|
1531
|
+
type: "system",
|
|
1532
|
+
subtype: "session_created",
|
|
1533
|
+
sessionId: newSessionId,
|
|
1534
|
+
provider: newSession?.provider ?? "claude",
|
|
1535
|
+
projectPath: newSession?.projectPath ?? "",
|
|
1536
|
+
...(rewindPermMode ? { permissionMode: rewindPermMode } : {}),
|
|
1537
|
+
sourceSessionId: msg.sessionId,
|
|
1538
|
+
});
|
|
1539
|
+
this.sendSessionList(ws);
|
|
1540
|
+
});
|
|
1541
|
+
}
|
|
1542
|
+
catch (err) {
|
|
1543
|
+
handleError(err);
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
else {
|
|
1547
|
+
// Both: rewind files first, then rewind conversation
|
|
1548
|
+
this.sessionManager.rewindFiles(msg.sessionId, msg.targetUuid).then((result) => {
|
|
1549
|
+
if (!result.canRewind) {
|
|
1550
|
+
this.send(ws, { type: "rewind_result", success: false, mode: "both", error: result.error ?? "Cannot rewind files" });
|
|
1551
|
+
return;
|
|
1552
|
+
}
|
|
1553
|
+
try {
|
|
1554
|
+
this.sessionManager.rewindConversation(msg.sessionId, msg.targetUuid, (newSessionId) => {
|
|
1555
|
+
this.send(ws, { type: "rewind_result", success: true, mode: "both" });
|
|
1556
|
+
const newSession = this.sessionManager.get(newSessionId);
|
|
1557
|
+
const rewindPermMode2 = newSession?.process instanceof SdkProcess ? newSession.process.permissionMode : undefined;
|
|
1558
|
+
this.send(ws, {
|
|
1559
|
+
type: "system",
|
|
1560
|
+
subtype: "session_created",
|
|
1561
|
+
sessionId: newSessionId,
|
|
1562
|
+
provider: newSession?.provider ?? "claude",
|
|
1563
|
+
projectPath: newSession?.projectPath ?? "",
|
|
1564
|
+
...(rewindPermMode2 ? { permissionMode: rewindPermMode2 } : {}),
|
|
1565
|
+
sourceSessionId: msg.sessionId,
|
|
1566
|
+
});
|
|
1567
|
+
this.sendSessionList(ws);
|
|
1568
|
+
});
|
|
1569
|
+
}
|
|
1570
|
+
catch (err) {
|
|
1571
|
+
handleError(err);
|
|
1572
|
+
}
|
|
1573
|
+
}).catch(handleError);
|
|
1574
|
+
}
|
|
1575
|
+
break;
|
|
1576
|
+
}
|
|
1577
|
+
case "list_windows": {
|
|
1578
|
+
listWindows()
|
|
1579
|
+
.then((windows) => {
|
|
1580
|
+
this.send(ws, { type: "window_list", windows });
|
|
1581
|
+
})
|
|
1582
|
+
.catch((err) => {
|
|
1583
|
+
this.send(ws, {
|
|
1584
|
+
type: "error",
|
|
1585
|
+
message: `Failed to list windows: ${err instanceof Error ? err.message : String(err)}`,
|
|
1586
|
+
});
|
|
1587
|
+
});
|
|
1588
|
+
break;
|
|
1589
|
+
}
|
|
1590
|
+
case "take_screenshot": {
|
|
1591
|
+
// For window mode, verify the window ID is still valid.
|
|
1592
|
+
// The user may have fetched the window list minutes ago and the
|
|
1593
|
+
// window could have been closed since then.
|
|
1594
|
+
const doCapture = async () => {
|
|
1595
|
+
if (msg.mode !== "window" || msg.windowId == null) {
|
|
1596
|
+
return { mode: msg.mode };
|
|
1597
|
+
}
|
|
1598
|
+
const current = await listWindows();
|
|
1599
|
+
if (current.some((w) => w.windowId === msg.windowId)) {
|
|
1600
|
+
return { mode: "window", windowId: msg.windowId };
|
|
1601
|
+
}
|
|
1602
|
+
// Window ID is stale — fall back to fullscreen and notify
|
|
1603
|
+
console.warn(`[screenshot] Window ID ${msg.windowId} no longer exists, falling back to fullscreen`);
|
|
1604
|
+
return { mode: "fullscreen" };
|
|
1605
|
+
};
|
|
1606
|
+
doCapture()
|
|
1607
|
+
.then((opts) => takeScreenshot(opts))
|
|
1608
|
+
.then(async (result) => {
|
|
1609
|
+
try {
|
|
1610
|
+
if (this.galleryStore) {
|
|
1611
|
+
const meta = await this.galleryStore.addImage(result.filePath, msg.projectPath, msg.sessionId);
|
|
1612
|
+
if (meta) {
|
|
1613
|
+
const info = this.galleryStore.metaToInfo(meta);
|
|
1614
|
+
this.send(ws, { type: "screenshot_result", success: true, image: info });
|
|
1615
|
+
this.broadcast({ type: "gallery_new_image", image: info });
|
|
1616
|
+
return;
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
this.send(ws, {
|
|
1620
|
+
type: "screenshot_result",
|
|
1621
|
+
success: false,
|
|
1622
|
+
error: "Failed to save screenshot to gallery",
|
|
1623
|
+
});
|
|
1624
|
+
}
|
|
1625
|
+
finally {
|
|
1626
|
+
// Always clean up temp file
|
|
1627
|
+
unlink(result.filePath).catch(() => { });
|
|
1628
|
+
}
|
|
1629
|
+
})
|
|
1630
|
+
.catch((err) => {
|
|
1631
|
+
this.send(ws, {
|
|
1632
|
+
type: "screenshot_result",
|
|
1633
|
+
success: false,
|
|
1634
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1635
|
+
});
|
|
1636
|
+
});
|
|
1637
|
+
break;
|
|
1638
|
+
}
|
|
1639
|
+
case "backup_prompt_history": {
|
|
1640
|
+
if (!this.promptHistoryBackup) {
|
|
1641
|
+
this.send(ws, { type: "prompt_history_backup_result", success: false, error: "Backup store not available" });
|
|
1642
|
+
break;
|
|
1643
|
+
}
|
|
1644
|
+
const buf = Buffer.from(msg.data, "base64");
|
|
1645
|
+
this.promptHistoryBackup.save(buf, msg.appVersion, msg.dbVersion).then((meta) => {
|
|
1646
|
+
this.send(ws, { type: "prompt_history_backup_result", success: true, backedUpAt: meta.backedUpAt });
|
|
1647
|
+
}).catch((err) => {
|
|
1648
|
+
this.send(ws, { type: "prompt_history_backup_result", success: false, error: err instanceof Error ? err.message : String(err) });
|
|
1649
|
+
});
|
|
1650
|
+
break;
|
|
1651
|
+
}
|
|
1652
|
+
case "restore_prompt_history": {
|
|
1653
|
+
if (!this.promptHistoryBackup) {
|
|
1654
|
+
this.send(ws, { type: "prompt_history_restore_result", success: false, error: "Backup store not available" });
|
|
1655
|
+
break;
|
|
1656
|
+
}
|
|
1657
|
+
this.promptHistoryBackup.load().then((result) => {
|
|
1658
|
+
if (result) {
|
|
1659
|
+
this.send(ws, {
|
|
1660
|
+
type: "prompt_history_restore_result",
|
|
1661
|
+
success: true,
|
|
1662
|
+
data: result.data.toString("base64"),
|
|
1663
|
+
appVersion: result.meta.appVersion,
|
|
1664
|
+
dbVersion: result.meta.dbVersion,
|
|
1665
|
+
backedUpAt: result.meta.backedUpAt,
|
|
1666
|
+
});
|
|
1667
|
+
}
|
|
1668
|
+
else {
|
|
1669
|
+
this.send(ws, { type: "prompt_history_restore_result", success: false, error: "No backup found" });
|
|
1670
|
+
}
|
|
1671
|
+
}).catch((err) => {
|
|
1672
|
+
this.send(ws, { type: "prompt_history_restore_result", success: false, error: err instanceof Error ? err.message : String(err) });
|
|
1673
|
+
});
|
|
1674
|
+
break;
|
|
1675
|
+
}
|
|
1676
|
+
case "get_prompt_history_backup_info": {
|
|
1677
|
+
if (!this.promptHistoryBackup) {
|
|
1678
|
+
this.send(ws, { type: "prompt_history_backup_info", exists: false });
|
|
1679
|
+
break;
|
|
1680
|
+
}
|
|
1681
|
+
this.promptHistoryBackup.getMeta().then((meta) => {
|
|
1682
|
+
if (meta) {
|
|
1683
|
+
this.send(ws, { type: "prompt_history_backup_info", exists: true, ...meta });
|
|
1684
|
+
}
|
|
1685
|
+
else {
|
|
1686
|
+
this.send(ws, { type: "prompt_history_backup_info", exists: false });
|
|
1687
|
+
}
|
|
1688
|
+
}).catch(() => {
|
|
1689
|
+
this.send(ws, { type: "prompt_history_backup_info", exists: false });
|
|
1690
|
+
});
|
|
1691
|
+
break;
|
|
1692
|
+
}
|
|
1693
|
+
case "rename_session": {
|
|
1694
|
+
const name = msg.name || null;
|
|
1695
|
+
await this.handleRenameSession(ws, msg.sessionId, name, msg);
|
|
1696
|
+
break;
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
/**
|
|
1701
|
+
* Load the saved session name from CLI storage and set it on the SessionInfo.
|
|
1702
|
+
* Called after SessionManager.create() so that session_created carries the name.
|
|
1703
|
+
*/
|
|
1704
|
+
async loadAndSetSessionName(session, provider, projectPath, cliSessionId) {
|
|
1705
|
+
if (!session || !cliSessionId)
|
|
1706
|
+
return;
|
|
1707
|
+
try {
|
|
1708
|
+
if (provider === "claude") {
|
|
1709
|
+
const name = await getClaudeSessionName(projectPath, cliSessionId);
|
|
1710
|
+
if (name)
|
|
1711
|
+
session.name = name;
|
|
1712
|
+
}
|
|
1713
|
+
else if (provider === "codex") {
|
|
1714
|
+
const names = await loadCodexSessionNames();
|
|
1715
|
+
const name = names.get(cliSessionId);
|
|
1716
|
+
if (name)
|
|
1717
|
+
session.name = name;
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
catch {
|
|
1721
|
+
// Non-critical: session works without name
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
/**
|
|
1725
|
+
* Handle rename_session: update in-memory name and persist to CLI storage.
|
|
1726
|
+
*
|
|
1727
|
+
* Supports both running sessions (by bridge session id) and recent sessions
|
|
1728
|
+
* (by provider session id, i.e. claudeSessionId or codex threadId).
|
|
1729
|
+
*/
|
|
1730
|
+
async handleRenameSession(ws, sessionId, name, msg) {
|
|
1731
|
+
// 1. Try running session first
|
|
1732
|
+
const runningSession = this.sessionManager.get(sessionId);
|
|
1733
|
+
if (runningSession) {
|
|
1734
|
+
this.sessionManager.renameSession(sessionId, name);
|
|
1735
|
+
// Persist to provider storage
|
|
1736
|
+
if (runningSession.provider === "claude" && runningSession.claudeSessionId) {
|
|
1737
|
+
await renameClaudeSession(runningSession.worktreePath ?? runningSession.projectPath, runningSession.claudeSessionId, name);
|
|
1738
|
+
}
|
|
1739
|
+
else if (runningSession.provider === "codex" && runningSession.process) {
|
|
1740
|
+
try {
|
|
1741
|
+
await runningSession.process.renameThread(name ?? "");
|
|
1742
|
+
}
|
|
1743
|
+
catch (err) {
|
|
1744
|
+
console.warn(`[websocket] Failed to rename Codex thread:`, err);
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
this.broadcastSessionList();
|
|
1748
|
+
this.send(ws, { type: "rename_result", sessionId, name, success: true });
|
|
1749
|
+
return;
|
|
1750
|
+
}
|
|
1751
|
+
// 2. Recent session (not running) — use provider + providerSessionId + projectPath from message
|
|
1752
|
+
const renameMsg = msg;
|
|
1753
|
+
const provider = renameMsg.provider;
|
|
1754
|
+
const providerSessionId = renameMsg.providerSessionId;
|
|
1755
|
+
const projectPath = renameMsg.projectPath;
|
|
1756
|
+
if (provider === "claude" && providerSessionId && projectPath) {
|
|
1757
|
+
const success = await renameClaudeSession(projectPath, providerSessionId, name);
|
|
1758
|
+
this.send(ws, { type: "rename_result", sessionId, name, success });
|
|
1759
|
+
return;
|
|
1760
|
+
}
|
|
1761
|
+
// For Codex recent sessions, write directly to session_index.jsonl.
|
|
1762
|
+
if (provider === "codex" && providerSessionId) {
|
|
1763
|
+
const success = await renameCodexSession(providerSessionId, name);
|
|
1764
|
+
this.send(ws, { type: "rename_result", sessionId, name, success });
|
|
1765
|
+
return;
|
|
1766
|
+
}
|
|
1767
|
+
this.send(ws, { type: "rename_result", sessionId, name, success: false });
|
|
1768
|
+
}
|
|
1769
|
+
resolveSession(sessionId) {
|
|
1770
|
+
if (sessionId)
|
|
1771
|
+
return this.sessionManager.get(sessionId);
|
|
1772
|
+
return this.getFirstSession();
|
|
1773
|
+
}
|
|
1774
|
+
getFirstSession() {
|
|
1775
|
+
const sessions = this.sessionManager.list();
|
|
1776
|
+
if (sessions.length === 0)
|
|
1777
|
+
return undefined;
|
|
1778
|
+
return this.sessionManager.get(sessions[sessions.length - 1].id);
|
|
1779
|
+
}
|
|
1780
|
+
sendSessionList(ws) {
|
|
1781
|
+
this.pruneDebugEvents();
|
|
1782
|
+
const sessions = this.sessionManager.list();
|
|
1783
|
+
this.send(ws, { type: "session_list", sessions, allowedDirs: this.allowedDirs, claudeModels: CLAUDE_MODELS, codexModels: CODEX_MODELS, bridgeVersion: getPackageVersion() });
|
|
1784
|
+
}
|
|
1785
|
+
/** Broadcast session list to all connected clients. */
|
|
1786
|
+
broadcastSessionList() {
|
|
1787
|
+
this.pruneDebugEvents();
|
|
1788
|
+
const sessions = this.sessionManager.list();
|
|
1789
|
+
this.broadcast({ type: "session_list", sessions, allowedDirs: this.allowedDirs, claudeModels: CLAUDE_MODELS, codexModels: CODEX_MODELS, bridgeVersion: getPackageVersion() });
|
|
1790
|
+
}
|
|
1791
|
+
broadcastSessionMessage(sessionId, msg) {
|
|
1792
|
+
this.maybeSendPushNotification(sessionId, msg);
|
|
1793
|
+
this.recordDebugEvent(sessionId, {
|
|
1794
|
+
direction: "outgoing",
|
|
1795
|
+
channel: "session",
|
|
1796
|
+
type: msg.type,
|
|
1797
|
+
detail: this.summarizeServerMessage(msg),
|
|
1798
|
+
});
|
|
1799
|
+
this.recordingStore?.record(sessionId, "outgoing", msg);
|
|
1800
|
+
// Update recording meta with claudeSessionId when it becomes available
|
|
1801
|
+
if ((msg.type === "system" || msg.type === "result") && "sessionId" in msg && msg.sessionId) {
|
|
1802
|
+
const session = this.sessionManager.get(sessionId);
|
|
1803
|
+
if (session) {
|
|
1804
|
+
this.recordingStore?.saveMeta(sessionId, {
|
|
1805
|
+
bridgeSessionId: sessionId,
|
|
1806
|
+
claudeSessionId: msg.sessionId,
|
|
1807
|
+
projectPath: session.projectPath,
|
|
1808
|
+
createdAt: session.createdAt.toISOString(),
|
|
1809
|
+
});
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
// Wrap the message with sessionId
|
|
1813
|
+
const data = JSON.stringify({ ...msg, sessionId });
|
|
1814
|
+
for (const client of this.wss.clients) {
|
|
1815
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
1816
|
+
client.send(data);
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
async listRecentSessions(msg) {
|
|
1821
|
+
if (msg.provider === "codex") {
|
|
1822
|
+
try {
|
|
1823
|
+
return await this.listRecentCodexThreads(msg);
|
|
1824
|
+
}
|
|
1825
|
+
catch (err) {
|
|
1826
|
+
console.warn(`[ws] Codex thread/list failed, falling back to rollout scan: ${err}`);
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
return getAllRecentSessions({
|
|
1830
|
+
limit: msg.limit,
|
|
1831
|
+
offset: msg.offset,
|
|
1832
|
+
projectPath: msg.projectPath,
|
|
1833
|
+
provider: msg.provider,
|
|
1834
|
+
namedOnly: msg.namedOnly,
|
|
1835
|
+
searchQuery: msg.searchQuery,
|
|
1836
|
+
archivedSessionIds: this.archiveStore.archivedIds(),
|
|
1837
|
+
});
|
|
1838
|
+
}
|
|
1839
|
+
getActiveCodexProcess() {
|
|
1840
|
+
const summary = this.sessionManager.list().find((session) => session.provider === "codex");
|
|
1841
|
+
if (!summary)
|
|
1842
|
+
return null;
|
|
1843
|
+
const session = this.sessionManager.get(summary.id);
|
|
1844
|
+
return session?.provider === "codex" ? session.process : null;
|
|
1845
|
+
}
|
|
1846
|
+
async listRecentCodexThreads(msg) {
|
|
1847
|
+
const limit = msg.limit ?? 20;
|
|
1848
|
+
const offset = msg.offset ?? 0;
|
|
1849
|
+
const process = this.getActiveCodexProcess() ?? await this.createStandaloneCodexProcess(msg.projectPath);
|
|
1850
|
+
const isStandalone = process !== this.getActiveCodexProcess();
|
|
1851
|
+
try {
|
|
1852
|
+
const result = await process.listThreads({
|
|
1853
|
+
limit: limit + offset,
|
|
1854
|
+
cwd: msg.projectPath,
|
|
1855
|
+
searchTerm: msg.searchQuery,
|
|
1856
|
+
});
|
|
1857
|
+
const archivedIds = this.archiveStore.archivedIds();
|
|
1858
|
+
const indexedSessions = await getAllRecentSessions({
|
|
1859
|
+
provider: "codex",
|
|
1860
|
+
projectPath: msg.projectPath,
|
|
1861
|
+
archivedSessionIds: archivedIds,
|
|
1862
|
+
});
|
|
1863
|
+
const indexedById = new Map(indexedSessions.sessions.map((session) => [
|
|
1864
|
+
session.sessionId,
|
|
1865
|
+
{
|
|
1866
|
+
codexSettings: session.codexSettings,
|
|
1867
|
+
resumeCwd: session.resumeCwd,
|
|
1868
|
+
},
|
|
1869
|
+
]));
|
|
1870
|
+
const sessions = result.data
|
|
1871
|
+
.filter((thread) => !archivedIds.has(thread.id))
|
|
1872
|
+
.filter((thread) => !msg.namedOnly || !!thread.name)
|
|
1873
|
+
.slice(offset, offset + limit)
|
|
1874
|
+
.map((thread) => codexThreadToRecentSession(thread, indexedById.get(thread.id)));
|
|
1875
|
+
return {
|
|
1876
|
+
sessions,
|
|
1877
|
+
hasMore: result.nextCursor != null,
|
|
1878
|
+
};
|
|
1879
|
+
}
|
|
1880
|
+
finally {
|
|
1881
|
+
if (isStandalone) {
|
|
1882
|
+
process.stop();
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
async createStandaloneCodexProcess(projectPath) {
|
|
1887
|
+
const proc = new CodexProcess();
|
|
1888
|
+
await proc.initializeOnly(projectPath ?? process.cwd());
|
|
1889
|
+
return proc;
|
|
1890
|
+
}
|
|
1891
|
+
/** Extract a short project label from the full projectPath (last directory name). */
|
|
1892
|
+
projectLabel(sessionId) {
|
|
1893
|
+
const session = this.sessionManager.get(sessionId);
|
|
1894
|
+
if (!session?.projectPath)
|
|
1895
|
+
return "";
|
|
1896
|
+
const parts = session.projectPath.replace(/\/+$/, "").split("/");
|
|
1897
|
+
return parts[parts.length - 1] || "";
|
|
1898
|
+
}
|
|
1899
|
+
/** Get unique locales from registered tokens. Falls back to ["en"] if none registered. */
|
|
1900
|
+
getRegisteredLocales() {
|
|
1901
|
+
const locales = new Set(this.tokenLocales.values());
|
|
1902
|
+
return locales.size > 0 ? [...locales] : ["en"];
|
|
1903
|
+
}
|
|
1904
|
+
/** Whether any registered token has privacy mode enabled (conservative: privacy wins). */
|
|
1905
|
+
isPrivacyMode() {
|
|
1906
|
+
for (const privacy of this.tokenPrivacyMode.values()) {
|
|
1907
|
+
if (privacy)
|
|
1908
|
+
return true;
|
|
1909
|
+
}
|
|
1910
|
+
return false;
|
|
1911
|
+
}
|
|
1912
|
+
/** Get a display label for push notification title: "name (project)" or just project. */
|
|
1913
|
+
sessionLabel(sessionId) {
|
|
1914
|
+
const session = this.sessionManager.get(sessionId);
|
|
1915
|
+
const project = this.projectLabel(sessionId);
|
|
1916
|
+
if (session?.name) {
|
|
1917
|
+
return project ? `${session.name} (${project})` : session.name;
|
|
1918
|
+
}
|
|
1919
|
+
return project;
|
|
1920
|
+
}
|
|
1921
|
+
maybeSendPushNotification(sessionId, msg) {
|
|
1922
|
+
if (!this.pushRelay.isConfigured)
|
|
1923
|
+
return;
|
|
1924
|
+
const privacy = this.isPrivacyMode();
|
|
1925
|
+
const label = privacy ? "" : this.sessionLabel(sessionId);
|
|
1926
|
+
if (msg.type === "permission_request") {
|
|
1927
|
+
const seen = this.notifiedPermissionToolUses.get(sessionId) ?? new Set();
|
|
1928
|
+
if (seen.has(msg.toolUseId))
|
|
1929
|
+
return;
|
|
1930
|
+
seen.add(msg.toolUseId);
|
|
1931
|
+
this.notifiedPermissionToolUses.set(sessionId, seen);
|
|
1932
|
+
const isAskUserQuestion = msg.toolName === "AskUserQuestion";
|
|
1933
|
+
const isExitPlanMode = msg.toolName === "ExitPlanMode";
|
|
1934
|
+
const eventType = isAskUserQuestion ? "ask_user_question" : "approval_required";
|
|
1935
|
+
// Extract question text for AskUserQuestion (standard mode only)
|
|
1936
|
+
let questionText;
|
|
1937
|
+
if (!privacy && isAskUserQuestion) {
|
|
1938
|
+
const questions = msg.input?.questions;
|
|
1939
|
+
const firstQuestion = Array.isArray(questions) && questions.length > 0
|
|
1940
|
+
? questions[0]?.question
|
|
1941
|
+
: undefined;
|
|
1942
|
+
if (typeof firstQuestion === "string" && firstQuestion.length > 0) {
|
|
1943
|
+
questionText = firstQuestion.slice(0, 120);
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
const data = {
|
|
1947
|
+
sessionId,
|
|
1948
|
+
provider: this.sessionManager.get(sessionId)?.provider ?? "claude",
|
|
1949
|
+
toolUseId: msg.toolUseId,
|
|
1950
|
+
toolName: msg.toolName,
|
|
1951
|
+
};
|
|
1952
|
+
for (const locale of this.getRegisteredLocales()) {
|
|
1953
|
+
let title;
|
|
1954
|
+
let body;
|
|
1955
|
+
if (isExitPlanMode) {
|
|
1956
|
+
const titleKey = "plan_ready_title";
|
|
1957
|
+
title = label ? `${t(locale, titleKey)} - ${label}` : t(locale, titleKey);
|
|
1958
|
+
body = t(locale, "plan_ready_body");
|
|
1959
|
+
}
|
|
1960
|
+
else if (isAskUserQuestion) {
|
|
1961
|
+
const titleKey = "ask_title";
|
|
1962
|
+
title = label ? `${t(locale, titleKey)} - ${label}` : t(locale, titleKey);
|
|
1963
|
+
body = privacy
|
|
1964
|
+
? t(locale, "ask_body_private")
|
|
1965
|
+
: (questionText ?? t(locale, "ask_default_body"));
|
|
1966
|
+
}
|
|
1967
|
+
else {
|
|
1968
|
+
const titleKey = "approval_title";
|
|
1969
|
+
title = label ? `${t(locale, titleKey)} - ${label}` : t(locale, titleKey);
|
|
1970
|
+
body = privacy
|
|
1971
|
+
? t(locale, "approval_body_private")
|
|
1972
|
+
: t(locale, "approval_body", { toolName: msg.toolName });
|
|
1973
|
+
}
|
|
1974
|
+
void this.pushRelay.notify({
|
|
1975
|
+
eventType,
|
|
1976
|
+
title,
|
|
1977
|
+
body,
|
|
1978
|
+
locale,
|
|
1979
|
+
data,
|
|
1980
|
+
}).catch((err) => {
|
|
1981
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
1982
|
+
console.warn(`[ws] Failed to send push notification (${eventType}, ${locale}): ${detail}`);
|
|
1983
|
+
});
|
|
1984
|
+
}
|
|
1985
|
+
return;
|
|
1986
|
+
}
|
|
1987
|
+
if (msg.type !== "result")
|
|
1988
|
+
return;
|
|
1989
|
+
if (msg.subtype === "stopped")
|
|
1990
|
+
return;
|
|
1991
|
+
if (msg.subtype !== "success" && msg.subtype !== "error")
|
|
1992
|
+
return;
|
|
1993
|
+
const isSuccess = msg.subtype === "success";
|
|
1994
|
+
const eventType = isSuccess ? "session_completed" : "session_failed";
|
|
1995
|
+
const pieces = [];
|
|
1996
|
+
if (isSuccess) {
|
|
1997
|
+
if (msg.duration != null)
|
|
1998
|
+
pieces.push(`${msg.duration.toFixed(1)}s`);
|
|
1999
|
+
if (msg.cost != null)
|
|
2000
|
+
pieces.push(`$${msg.cost.toFixed(4)}`);
|
|
2001
|
+
}
|
|
2002
|
+
const stats = pieces.length > 0 ? ` (${pieces.join(", ")})` : "";
|
|
2003
|
+
const data = {
|
|
2004
|
+
sessionId,
|
|
2005
|
+
provider: this.sessionManager.get(sessionId)?.provider ?? "claude",
|
|
2006
|
+
subtype: msg.subtype,
|
|
2007
|
+
};
|
|
2008
|
+
if (msg.stopReason)
|
|
2009
|
+
data.stopReason = msg.stopReason;
|
|
2010
|
+
if (msg.sessionId)
|
|
2011
|
+
data.providerSessionId = msg.sessionId;
|
|
2012
|
+
for (const locale of this.getRegisteredLocales()) {
|
|
2013
|
+
let title;
|
|
2014
|
+
if (privacy) {
|
|
2015
|
+
title = isSuccess ? t(locale, "task_completed") : t(locale, "error_occurred");
|
|
2016
|
+
}
|
|
2017
|
+
else {
|
|
2018
|
+
title = label
|
|
2019
|
+
? (isSuccess ? `✅ ${label}` : `❌ ${label}`)
|
|
2020
|
+
: (isSuccess ? t(locale, "task_completed") : t(locale, "error_occurred"));
|
|
2021
|
+
}
|
|
2022
|
+
let body;
|
|
2023
|
+
if (privacy) {
|
|
2024
|
+
const privateBody = isSuccess
|
|
2025
|
+
? t(locale, "result_success_body_private")
|
|
2026
|
+
: t(locale, "result_error_body_private");
|
|
2027
|
+
body = isSuccess ? `${privateBody}${stats}` : privateBody;
|
|
2028
|
+
}
|
|
2029
|
+
else if (isSuccess) {
|
|
2030
|
+
body = msg.result
|
|
2031
|
+
? `${msg.result.slice(0, 120)}${stats}`
|
|
2032
|
+
: `${t(locale, "session_completed")}${stats}`;
|
|
2033
|
+
}
|
|
2034
|
+
else {
|
|
2035
|
+
body = msg.error ? msg.error.slice(0, 120) : t(locale, "session_failed");
|
|
2036
|
+
}
|
|
2037
|
+
void this.pushRelay.notify({
|
|
2038
|
+
eventType,
|
|
2039
|
+
title,
|
|
2040
|
+
body,
|
|
2041
|
+
locale,
|
|
2042
|
+
data,
|
|
2043
|
+
}).catch((err) => {
|
|
2044
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
2045
|
+
console.warn(`[ws] Failed to send push notification (${eventType}, ${locale}): ${detail}`);
|
|
2046
|
+
});
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
broadcast(msg) {
|
|
2050
|
+
const data = JSON.stringify(msg);
|
|
2051
|
+
for (const client of this.wss.clients) {
|
|
2052
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
2053
|
+
client.send(data);
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
send(ws, msg) {
|
|
2058
|
+
const sessionId = this.extractSessionIdFromServerMessage(msg);
|
|
2059
|
+
if (sessionId) {
|
|
2060
|
+
this.recordDebugEvent(sessionId, {
|
|
2061
|
+
direction: "outgoing",
|
|
2062
|
+
channel: "ws",
|
|
2063
|
+
type: String(msg.type ?? "unknown"),
|
|
2064
|
+
detail: this.summarizeOutboundMessage(msg),
|
|
2065
|
+
});
|
|
2066
|
+
}
|
|
2067
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
2068
|
+
ws.send(JSON.stringify(msg));
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
/** Broadcast a gallery_new_image message to all connected clients. */
|
|
2072
|
+
broadcastGalleryNewImage(image) {
|
|
2073
|
+
this.broadcast({ type: "gallery_new_image", image });
|
|
2074
|
+
}
|
|
2075
|
+
collectGitDiff(cwd, callback) {
|
|
2076
|
+
const execOpts = { cwd, maxBuffer: 10 * 1024 * 1024 };
|
|
2077
|
+
// Collect untracked files so they appear in the diff.
|
|
2078
|
+
let untrackedFiles = [];
|
|
2079
|
+
try {
|
|
2080
|
+
const out = execFileSync("git", ["ls-files", "--others", "--exclude-standard"], { cwd }).toString().trim();
|
|
2081
|
+
untrackedFiles = out ? out.split("\n") : [];
|
|
2082
|
+
}
|
|
2083
|
+
catch {
|
|
2084
|
+
// Ignore errors: non-git directories are handled by git diff callback.
|
|
2085
|
+
}
|
|
2086
|
+
// Temporarily stage untracked files with --intent-to-add.
|
|
2087
|
+
if (untrackedFiles.length > 0) {
|
|
2088
|
+
try {
|
|
2089
|
+
execFileSync("git", ["add", "--intent-to-add", ...untrackedFiles], { cwd });
|
|
2090
|
+
}
|
|
2091
|
+
catch {
|
|
2092
|
+
// Ignore staging errors.
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
execFile("git", ["diff", "--no-color"], execOpts, (err, stdout) => {
|
|
2096
|
+
// Revert intent-to-add for untracked files.
|
|
2097
|
+
if (untrackedFiles.length > 0) {
|
|
2098
|
+
try {
|
|
2099
|
+
execFileSync("git", ["reset", "--", ...untrackedFiles], { cwd });
|
|
2100
|
+
}
|
|
2101
|
+
catch {
|
|
2102
|
+
// Ignore reset errors.
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
if (err) {
|
|
2106
|
+
callback({ diff: "", error: err.message });
|
|
2107
|
+
return;
|
|
2108
|
+
}
|
|
2109
|
+
callback({ diff: stdout });
|
|
2110
|
+
});
|
|
2111
|
+
}
|
|
2112
|
+
// ---------------------------------------------------------------------------
|
|
2113
|
+
// Image diff helpers
|
|
2114
|
+
// ---------------------------------------------------------------------------
|
|
2115
|
+
static IMAGE_EXTENSIONS = new Set([
|
|
2116
|
+
".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".bmp", ".svg",
|
|
2117
|
+
]);
|
|
2118
|
+
// Image diff thresholds (configurable via environment variables)
|
|
2119
|
+
// - Auto-display: images ≤ threshold are sent inline as base64
|
|
2120
|
+
// - Max size: images ≤ max are available for on-demand loading
|
|
2121
|
+
// - Images > max size show text info only
|
|
2122
|
+
static AUTO_DISPLAY_THRESHOLD = (() => {
|
|
2123
|
+
const kb = parseInt(process.env.DIFF_IMAGE_AUTO_DISPLAY_KB ?? "", 10);
|
|
2124
|
+
return Number.isFinite(kb) && kb > 0 ? kb * 1024 : 1024 * 1024; // default 1 MB
|
|
2125
|
+
})();
|
|
2126
|
+
static MAX_IMAGE_SIZE = (() => {
|
|
2127
|
+
const mb = parseInt(process.env.DIFF_IMAGE_MAX_SIZE_MB ?? "", 10);
|
|
2128
|
+
return Number.isFinite(mb) && mb > 0 ? mb * 1024 * 1024 : 5 * 1024 * 1024; // default 5 MB
|
|
2129
|
+
})();
|
|
2130
|
+
static mimeTypeForExt(ext) {
|
|
2131
|
+
const map = {
|
|
2132
|
+
".png": "image/png",
|
|
2133
|
+
".jpg": "image/jpeg",
|
|
2134
|
+
".jpeg": "image/jpeg",
|
|
2135
|
+
".gif": "image/gif",
|
|
2136
|
+
".webp": "image/webp",
|
|
2137
|
+
".ico": "image/x-icon",
|
|
2138
|
+
".bmp": "image/bmp",
|
|
2139
|
+
".svg": "image/svg+xml",
|
|
2140
|
+
};
|
|
2141
|
+
return map[ext.toLowerCase()] ?? "application/octet-stream";
|
|
2142
|
+
}
|
|
2143
|
+
/**
|
|
2144
|
+
* Scan diff text for image file changes and extract base64 data where appropriate.
|
|
2145
|
+
*
|
|
2146
|
+
* Detection strategy:
|
|
2147
|
+
* 1. Binary markers: "Binary files a/<path> and b/<path> differ"
|
|
2148
|
+
* 2. diff --git headers where the file extension is an image type
|
|
2149
|
+
*
|
|
2150
|
+
* For each detected image file:
|
|
2151
|
+
* - Old version: `git show HEAD:<path>` (committed version)
|
|
2152
|
+
* - New version: read from working tree
|
|
2153
|
+
* - Apply size thresholds for auto-display / on-demand / text-only
|
|
2154
|
+
*/
|
|
2155
|
+
async collectImageChanges(cwd, diffText) {
|
|
2156
|
+
const entries = [];
|
|
2157
|
+
const processedPaths = new Set();
|
|
2158
|
+
const lines = diffText.split("\n");
|
|
2159
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2160
|
+
const line = lines[i];
|
|
2161
|
+
const gitMatch = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
|
|
2162
|
+
if (!gitMatch)
|
|
2163
|
+
continue;
|
|
2164
|
+
const filePath = gitMatch[2];
|
|
2165
|
+
const ext = extname(filePath).toLowerCase();
|
|
2166
|
+
if (!BridgeWebSocketServer.IMAGE_EXTENSIONS.has(ext))
|
|
2167
|
+
continue;
|
|
2168
|
+
if (processedPaths.has(filePath))
|
|
2169
|
+
continue;
|
|
2170
|
+
processedPaths.add(filePath);
|
|
2171
|
+
let isNew = false;
|
|
2172
|
+
let isDeleted = false;
|
|
2173
|
+
for (let j = i + 1; j < Math.min(i + 6, lines.length); j++) {
|
|
2174
|
+
if (lines[j].startsWith("diff --git "))
|
|
2175
|
+
break;
|
|
2176
|
+
if (lines[j].startsWith("new file mode"))
|
|
2177
|
+
isNew = true;
|
|
2178
|
+
if (lines[j].startsWith("deleted file mode"))
|
|
2179
|
+
isDeleted = true;
|
|
2180
|
+
}
|
|
2181
|
+
entries.push({
|
|
2182
|
+
filePath,
|
|
2183
|
+
isNew,
|
|
2184
|
+
isDeleted,
|
|
2185
|
+
isSvg: ext === ".svg",
|
|
2186
|
+
mimeType: BridgeWebSocketServer.mimeTypeForExt(ext),
|
|
2187
|
+
ext,
|
|
2188
|
+
});
|
|
2189
|
+
}
|
|
2190
|
+
if (entries.length === 0)
|
|
2191
|
+
return [];
|
|
2192
|
+
// Phase 2: Read image data asynchronously
|
|
2193
|
+
const execFileAsync = promisify(execFile);
|
|
2194
|
+
const changes = [];
|
|
2195
|
+
for (const entry of entries) {
|
|
2196
|
+
let oldBuf;
|
|
2197
|
+
let newBuf;
|
|
2198
|
+
// Read old image (committed version)
|
|
2199
|
+
if (!entry.isNew) {
|
|
2200
|
+
try {
|
|
2201
|
+
const result = await execFileAsync("git", ["show", `HEAD:${entry.filePath}`], {
|
|
2202
|
+
cwd,
|
|
2203
|
+
maxBuffer: BridgeWebSocketServer.MAX_IMAGE_SIZE + 1024,
|
|
2204
|
+
encoding: "buffer",
|
|
2205
|
+
});
|
|
2206
|
+
oldBuf = result.stdout;
|
|
2207
|
+
}
|
|
2208
|
+
catch {
|
|
2209
|
+
// File may not exist in HEAD (e.g. untracked)
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
// Read new image (working tree)
|
|
2213
|
+
if (!entry.isDeleted) {
|
|
2214
|
+
try {
|
|
2215
|
+
const absPath = resolve(cwd, entry.filePath);
|
|
2216
|
+
if (existsSync(absPath)) {
|
|
2217
|
+
newBuf = await readFile(absPath);
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
catch {
|
|
2221
|
+
// Ignore read errors
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
const oldSize = oldBuf?.length;
|
|
2225
|
+
const newSize = newBuf?.length;
|
|
2226
|
+
const maxSize = Math.max(oldSize ?? 0, newSize ?? 0);
|
|
2227
|
+
const autoDisplay = maxSize <= BridgeWebSocketServer.AUTO_DISPLAY_THRESHOLD;
|
|
2228
|
+
const loadable = autoDisplay || maxSize <= BridgeWebSocketServer.MAX_IMAGE_SIZE;
|
|
2229
|
+
const change = {
|
|
2230
|
+
filePath: entry.filePath,
|
|
2231
|
+
isNew: entry.isNew,
|
|
2232
|
+
isDeleted: entry.isDeleted,
|
|
2233
|
+
isSvg: entry.isSvg,
|
|
2234
|
+
mimeType: entry.mimeType,
|
|
2235
|
+
loadable,
|
|
2236
|
+
autoDisplay: autoDisplay || undefined,
|
|
2237
|
+
};
|
|
2238
|
+
if (oldSize !== undefined)
|
|
2239
|
+
change.oldSize = oldSize;
|
|
2240
|
+
if (newSize !== undefined)
|
|
2241
|
+
change.newSize = newSize;
|
|
2242
|
+
// Auto-display images are no longer embedded in the initial response.
|
|
2243
|
+
// They are loaded on-demand when the Flutter widget becomes visible.
|
|
2244
|
+
changes.push(change);
|
|
2245
|
+
}
|
|
2246
|
+
return changes;
|
|
2247
|
+
}
|
|
2248
|
+
/**
|
|
2249
|
+
* Load a single diff image on demand (async I/O for better throughput).
|
|
2250
|
+
*/
|
|
2251
|
+
async loadDiffImageAsync(cwd, filePath, version) {
|
|
2252
|
+
// Path traversal guard: reject paths containing '..' or absolute paths
|
|
2253
|
+
if (filePath.includes("..") || filePath.startsWith("/")) {
|
|
2254
|
+
return { error: "Invalid file path" };
|
|
2255
|
+
}
|
|
2256
|
+
const ext = extname(filePath).toLowerCase();
|
|
2257
|
+
if (!BridgeWebSocketServer.IMAGE_EXTENSIONS.has(ext)) {
|
|
2258
|
+
return { error: "Not an image file" };
|
|
2259
|
+
}
|
|
2260
|
+
const mimeType = BridgeWebSocketServer.mimeTypeForExt(ext);
|
|
2261
|
+
try {
|
|
2262
|
+
const execFileAsync = promisify(execFile);
|
|
2263
|
+
let buf;
|
|
2264
|
+
if (version === "old") {
|
|
2265
|
+
const result = await execFileAsync("git", ["show", `HEAD:${filePath}`], {
|
|
2266
|
+
cwd,
|
|
2267
|
+
maxBuffer: BridgeWebSocketServer.MAX_IMAGE_SIZE + 1024,
|
|
2268
|
+
encoding: "buffer",
|
|
2269
|
+
});
|
|
2270
|
+
buf = result.stdout;
|
|
2271
|
+
}
|
|
2272
|
+
else {
|
|
2273
|
+
const absPath = resolve(cwd, filePath);
|
|
2274
|
+
// Verify resolved path stays within cwd
|
|
2275
|
+
if (!absPath.startsWith(resolve(cwd) + "/")) {
|
|
2276
|
+
return { error: "Invalid file path" };
|
|
2277
|
+
}
|
|
2278
|
+
buf = await readFile(absPath);
|
|
2279
|
+
}
|
|
2280
|
+
if (buf.length > BridgeWebSocketServer.MAX_IMAGE_SIZE) {
|
|
2281
|
+
return { error: "Image too large" };
|
|
2282
|
+
}
|
|
2283
|
+
return { base64: buf.toString("base64"), mimeType };
|
|
2284
|
+
}
|
|
2285
|
+
catch (err) {
|
|
2286
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
extractSessionIdFromClientMessage(msg) {
|
|
2290
|
+
return "sessionId" in msg && typeof msg.sessionId === "string" ? msg.sessionId : undefined;
|
|
2291
|
+
}
|
|
2292
|
+
extractSessionIdFromServerMessage(msg) {
|
|
2293
|
+
if ("sessionId" in msg && typeof msg.sessionId === "string")
|
|
2294
|
+
return msg.sessionId;
|
|
2295
|
+
return undefined;
|
|
2296
|
+
}
|
|
2297
|
+
recordDebugEvent(sessionId, event) {
|
|
2298
|
+
const events = this.debugEvents.get(sessionId) ?? [];
|
|
2299
|
+
const fullEvent = {
|
|
2300
|
+
ts: new Date().toISOString(),
|
|
2301
|
+
sessionId,
|
|
2302
|
+
...event,
|
|
2303
|
+
};
|
|
2304
|
+
events.push(fullEvent);
|
|
2305
|
+
if (events.length > BridgeWebSocketServer.MAX_DEBUG_EVENTS) {
|
|
2306
|
+
events.splice(0, events.length - BridgeWebSocketServer.MAX_DEBUG_EVENTS);
|
|
2307
|
+
}
|
|
2308
|
+
this.debugEvents.set(sessionId, events);
|
|
2309
|
+
this.debugTraceStore.record(fullEvent);
|
|
2310
|
+
}
|
|
2311
|
+
getDebugEvents(sessionId, limit) {
|
|
2312
|
+
const events = this.debugEvents.get(sessionId) ?? [];
|
|
2313
|
+
const capped = Math.max(0, Math.min(limit, BridgeWebSocketServer.MAX_DEBUG_EVENTS));
|
|
2314
|
+
if (capped === 0)
|
|
2315
|
+
return [];
|
|
2316
|
+
return events.slice(-capped);
|
|
2317
|
+
}
|
|
2318
|
+
buildHistorySummary(history) {
|
|
2319
|
+
const lines = history
|
|
2320
|
+
.map((msg, index) => {
|
|
2321
|
+
const num = String(index + 1).padStart(3, "0");
|
|
2322
|
+
return `${num}. ${this.summarizeServerMessage(msg)}`;
|
|
2323
|
+
});
|
|
2324
|
+
if (lines.length <= BridgeWebSocketServer.MAX_HISTORY_SUMMARY_ITEMS) {
|
|
2325
|
+
return lines;
|
|
2326
|
+
}
|
|
2327
|
+
return lines.slice(-BridgeWebSocketServer.MAX_HISTORY_SUMMARY_ITEMS);
|
|
2328
|
+
}
|
|
2329
|
+
summarizeClientMessage(msg) {
|
|
2330
|
+
switch (msg.type) {
|
|
2331
|
+
case "input": {
|
|
2332
|
+
const textPreview = msg.text.replace(/\s+/g, " ").trim().slice(0, 80);
|
|
2333
|
+
const hasImage = msg.imageBase64 != null || msg.imageId != null;
|
|
2334
|
+
return `text=\"${textPreview}\" image=${hasImage}`;
|
|
2335
|
+
}
|
|
2336
|
+
case "push_register":
|
|
2337
|
+
return `platform=${msg.platform} token=${msg.token.slice(0, 8)}...`;
|
|
2338
|
+
case "push_unregister":
|
|
2339
|
+
return `token=${msg.token.slice(0, 8)}...`;
|
|
2340
|
+
case "approve":
|
|
2341
|
+
case "approve_always":
|
|
2342
|
+
case "reject":
|
|
2343
|
+
return `id=${msg.id}`;
|
|
2344
|
+
case "answer":
|
|
2345
|
+
return `toolUseId=${msg.toolUseId}`;
|
|
2346
|
+
case "start":
|
|
2347
|
+
return `projectPath=${msg.projectPath} provider=${msg.provider ?? "claude"}`;
|
|
2348
|
+
case "resume_session":
|
|
2349
|
+
return `sessionId=${msg.sessionId} provider=${msg.provider ?? "claude"}`;
|
|
2350
|
+
case "get_debug_bundle":
|
|
2351
|
+
return `traceLimit=${msg.traceLimit ?? BridgeWebSocketServer.MAX_DEBUG_EVENTS} includeDiff=${msg.includeDiff ?? true}`;
|
|
2352
|
+
case "get_usage":
|
|
2353
|
+
return "get_usage";
|
|
2354
|
+
default:
|
|
2355
|
+
return msg.type;
|
|
2356
|
+
}
|
|
2357
|
+
}
|
|
2358
|
+
summarizeServerMessage(msg) {
|
|
2359
|
+
switch (msg.type) {
|
|
2360
|
+
case "assistant": {
|
|
2361
|
+
const textChunks = [];
|
|
2362
|
+
for (const content of msg.message.content) {
|
|
2363
|
+
if (content.type === "text") {
|
|
2364
|
+
textChunks.push(content.text);
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
const text = textChunks
|
|
2368
|
+
.join(" ")
|
|
2369
|
+
.replace(/\s+/g, " ")
|
|
2370
|
+
.trim()
|
|
2371
|
+
.slice(0, 100);
|
|
2372
|
+
return text ? `assistant: ${text}` : "assistant";
|
|
2373
|
+
}
|
|
2374
|
+
case "tool_result": {
|
|
2375
|
+
const contentPreview = msg.content.replace(/\s+/g, " ").trim().slice(0, 100);
|
|
2376
|
+
return `${msg.toolName ?? "tool_result"}(${msg.toolUseId}) ${contentPreview}`;
|
|
2377
|
+
}
|
|
2378
|
+
case "permission_request":
|
|
2379
|
+
return `${msg.toolName}(${msg.toolUseId})`;
|
|
2380
|
+
case "result":
|
|
2381
|
+
return `${msg.subtype}${msg.error ? ` error=${msg.error}` : ""}`;
|
|
2382
|
+
case "status":
|
|
2383
|
+
return msg.status;
|
|
2384
|
+
case "error":
|
|
2385
|
+
return msg.message;
|
|
2386
|
+
case "stream_delta":
|
|
2387
|
+
case "thinking_delta":
|
|
2388
|
+
return `${msg.type}(${msg.text.length})`;
|
|
2389
|
+
default:
|
|
2390
|
+
return msg.type;
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
summarizeOutboundMessage(msg) {
|
|
2394
|
+
if ("type" in msg && typeof msg.type === "string") {
|
|
2395
|
+
return msg.type;
|
|
2396
|
+
}
|
|
2397
|
+
return "message";
|
|
2398
|
+
}
|
|
2399
|
+
pruneDebugEvents() {
|
|
2400
|
+
const active = new Set(this.sessionManager.list().map((s) => s.id));
|
|
2401
|
+
for (const sessionId of this.debugEvents.keys()) {
|
|
2402
|
+
if (!active.has(sessionId)) {
|
|
2403
|
+
this.debugEvents.delete(sessionId);
|
|
2404
|
+
}
|
|
2405
|
+
}
|
|
2406
|
+
for (const sessionId of this.notifiedPermissionToolUses.keys()) {
|
|
2407
|
+
if (!active.has(sessionId)) {
|
|
2408
|
+
this.notifiedPermissionToolUses.delete(sessionId);
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
2411
|
+
}
|
|
2412
|
+
buildReproRecipe(session, traceLimit, includeDiff) {
|
|
2413
|
+
const bridgePort = process.env.BRIDGE_PORT ?? "8765";
|
|
2414
|
+
const wsUrlHint = `ws://localhost:${bridgePort}`;
|
|
2415
|
+
const notes = [
|
|
2416
|
+
"1) Connect with wsUrlHint and send resumeSessionMessage.",
|
|
2417
|
+
"2) Read session_created.sessionId from server response.",
|
|
2418
|
+
"3) Replace <runtime_session_id> in getHistoryMessage/getDebugBundleMessage and send them.",
|
|
2419
|
+
"4) Compare history/debugTrace/diff with the saved bundle snapshot.",
|
|
2420
|
+
];
|
|
2421
|
+
if (!session.claudeSessionId) {
|
|
2422
|
+
notes.push("claudeSessionId is not available yet. Use list_recent_sessions to pick the right session id.");
|
|
2423
|
+
}
|
|
2424
|
+
return {
|
|
2425
|
+
wsUrlHint,
|
|
2426
|
+
startBridgeCommand: `BRIDGE_PORT=${bridgePort} npm run bridge`,
|
|
2427
|
+
resumeSessionMessage: this.buildResumeSessionMessage(session),
|
|
2428
|
+
getHistoryMessage: {
|
|
2429
|
+
type: "get_history",
|
|
2430
|
+
sessionId: "<runtime_session_id>",
|
|
2431
|
+
},
|
|
2432
|
+
getDebugBundleMessage: {
|
|
2433
|
+
type: "get_debug_bundle",
|
|
2434
|
+
sessionId: "<runtime_session_id>",
|
|
2435
|
+
traceLimit,
|
|
2436
|
+
includeDiff,
|
|
2437
|
+
},
|
|
2438
|
+
notes,
|
|
2439
|
+
};
|
|
2440
|
+
}
|
|
2441
|
+
buildResumeSessionMessage(session) {
|
|
2442
|
+
const msg = {
|
|
2443
|
+
type: "resume_session",
|
|
2444
|
+
sessionId: session.claudeSessionId ?? "<session_id_from_recent_sessions>",
|
|
2445
|
+
projectPath: session.projectPath,
|
|
2446
|
+
provider: session.provider,
|
|
2447
|
+
};
|
|
2448
|
+
if (session.provider === "codex" && session.codexSettings) {
|
|
2449
|
+
if (session.codexSettings.approvalPolicy !== undefined) {
|
|
2450
|
+
msg.approvalPolicy = session.codexSettings.approvalPolicy;
|
|
2451
|
+
}
|
|
2452
|
+
if (session.codexSettings.sandboxMode !== undefined) {
|
|
2453
|
+
msg.sandboxMode = session.codexSettings.sandboxMode;
|
|
2454
|
+
}
|
|
2455
|
+
if (session.codexSettings.model !== undefined) {
|
|
2456
|
+
msg.model = session.codexSettings.model;
|
|
2457
|
+
}
|
|
2458
|
+
if (session.codexSettings.modelReasoningEffort !== undefined) {
|
|
2459
|
+
msg.modelReasoningEffort = session.codexSettings.modelReasoningEffort;
|
|
2460
|
+
}
|
|
2461
|
+
if (session.codexSettings.networkAccessEnabled !== undefined) {
|
|
2462
|
+
msg.networkAccessEnabled = session.codexSettings.networkAccessEnabled;
|
|
2463
|
+
}
|
|
2464
|
+
if (session.codexSettings.webSearchMode !== undefined) {
|
|
2465
|
+
msg.webSearchMode = session.codexSettings.webSearchMode;
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
return msg;
|
|
2469
|
+
}
|
|
2470
|
+
buildAgentPrompt(session) {
|
|
2471
|
+
return [
|
|
2472
|
+
"Use this ccpocket debug bundle to investigate a chat-screen bug.",
|
|
2473
|
+
`Target provider: ${session.provider}`,
|
|
2474
|
+
`Project path: ${session.projectPath}`,
|
|
2475
|
+
"Required output:",
|
|
2476
|
+
"1) Timeline analysis from historySummary + debugTrace.",
|
|
2477
|
+
"2) Top 1-3 root-cause hypotheses with confidence.",
|
|
2478
|
+
"3) Concrete validation steps and the minimum extra logs needed.",
|
|
2479
|
+
].join("\n");
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
2482
|
+
//# sourceMappingURL=websocket.js.map
|