@ccpocket/bridge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -0
- package/dist/claude-process.d.ts +108 -0
- package/dist/claude-process.js +471 -0
- package/dist/claude-process.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +42 -0
- package/dist/cli.js.map +1 -0
- package/dist/codex-process.d.ts +46 -0
- package/dist/codex-process.js +420 -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/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 +66 -0
- package/dist/gallery-store.js +310 -0
- package/dist/gallery-store.js.map +1 -0
- package/dist/image-store.d.ts +22 -0
- package/dist/image-store.js +113 -0
- package/dist/image-store.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +153 -0
- package/dist/index.js.map +1 -0
- package/dist/mdns.d.ts +6 -0
- package/dist/mdns.js +42 -0
- package/dist/mdns.js.map +1 -0
- package/dist/parser.d.ts +381 -0
- package/dist/parser.js +218 -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/push-relay.d.ts +27 -0
- package/dist/push-relay.js +69 -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 +151 -0
- package/dist/sdk-process.js +740 -0
- package/dist/sdk-process.js.map +1 -0
- package/dist/session.d.ts +126 -0
- package/dist/session.js +550 -0
- package/dist/session.js.map +1 -0
- package/dist/sessions-index.d.ts +86 -0
- package/dist/sessions-index.js +1027 -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/startup-info.d.ts +8 -0
- package/dist/startup-info.js +78 -0
- package/dist/startup-info.js.map +1 -0
- package/dist/usage.d.ts +17 -0
- package/dist/usage.js +236 -0
- package/dist/usage.js.map +1 -0
- package/dist/version.d.ts +11 -0
- package/dist/version.js +39 -0
- package/dist/version.js.map +1 -0
- package/dist/websocket.d.ts +71 -0
- package/dist/websocket.js +1487 -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 +43 -0
- package/dist/worktree.js +295 -0
- package/dist/worktree.js.map +1 -0
- package/package.json +63 -0
|
@@ -0,0 +1,1487 @@
|
|
|
1
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
4
|
+
import { SessionManager } from "./session.js";
|
|
5
|
+
import { SdkProcess } from "./sdk-process.js";
|
|
6
|
+
import { parseClientMessage } from "./parser.js";
|
|
7
|
+
import { getAllRecentSessions, getCodexSessionHistory, getSessionHistory, findSessionsByClaudeIds, extractMessageImages } from "./sessions-index.js";
|
|
8
|
+
import { WorktreeStore } from "./worktree-store.js";
|
|
9
|
+
import { listWorktrees, removeWorktree, worktreeExists } from "./worktree.js";
|
|
10
|
+
import { listWindows, takeScreenshot } from "./screenshot.js";
|
|
11
|
+
import { DebugTraceStore } from "./debug-trace-store.js";
|
|
12
|
+
import { RecordingStore } from "./recording-store.js";
|
|
13
|
+
import { PushRelayClient } from "./push-relay.js";
|
|
14
|
+
import { fetchAllUsage } from "./usage.js";
|
|
15
|
+
export class BridgeWebSocketServer {
|
|
16
|
+
static MAX_DEBUG_EVENTS = 800;
|
|
17
|
+
static MAX_HISTORY_SUMMARY_ITEMS = 300;
|
|
18
|
+
wss;
|
|
19
|
+
sessionManager;
|
|
20
|
+
apiKey;
|
|
21
|
+
imageStore;
|
|
22
|
+
galleryStore;
|
|
23
|
+
projectHistory;
|
|
24
|
+
debugTraceStore;
|
|
25
|
+
recordingStore;
|
|
26
|
+
worktreeStore;
|
|
27
|
+
pushRelay;
|
|
28
|
+
promptHistoryBackup;
|
|
29
|
+
recentSessionsRequestId = 0;
|
|
30
|
+
debugEvents = new Map();
|
|
31
|
+
notifiedPermissionToolUses = new Map();
|
|
32
|
+
constructor(options) {
|
|
33
|
+
const { server, apiKey, imageStore, galleryStore, projectHistory, debugTraceStore, recordingStore, firebaseAuth, promptHistoryBackup } = options;
|
|
34
|
+
this.apiKey = apiKey ?? null;
|
|
35
|
+
this.imageStore = imageStore ?? null;
|
|
36
|
+
this.galleryStore = galleryStore ?? null;
|
|
37
|
+
this.projectHistory = projectHistory ?? null;
|
|
38
|
+
this.debugTraceStore = debugTraceStore ?? new DebugTraceStore();
|
|
39
|
+
this.recordingStore = recordingStore ?? new RecordingStore();
|
|
40
|
+
this.worktreeStore = new WorktreeStore();
|
|
41
|
+
this.pushRelay = new PushRelayClient({ firebaseAuth });
|
|
42
|
+
this.promptHistoryBackup = promptHistoryBackup ?? null;
|
|
43
|
+
void this.debugTraceStore.init().catch((err) => {
|
|
44
|
+
console.error("[ws] Failed to initialize debug trace store:", err);
|
|
45
|
+
});
|
|
46
|
+
void this.recordingStore.init().catch((err) => {
|
|
47
|
+
console.error("[ws] Failed to initialize recording store:", err);
|
|
48
|
+
});
|
|
49
|
+
if (!this.pushRelay.isConfigured) {
|
|
50
|
+
console.log("[ws] Push relay disabled (Firebase auth not available)");
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
console.log("[ws] Push relay enabled (Firebase Anonymous Auth)");
|
|
54
|
+
}
|
|
55
|
+
this.wss = new WebSocketServer({ server });
|
|
56
|
+
this.sessionManager = new SessionManager((sessionId, msg) => {
|
|
57
|
+
this.broadcastSessionMessage(sessionId, msg);
|
|
58
|
+
}, imageStore, galleryStore,
|
|
59
|
+
// Broadcast gallery_new_image when a new image is added
|
|
60
|
+
(meta) => {
|
|
61
|
+
if (this.galleryStore) {
|
|
62
|
+
const info = this.galleryStore.metaToInfo(meta);
|
|
63
|
+
this.broadcast({ type: "gallery_new_image", image: info });
|
|
64
|
+
}
|
|
65
|
+
}, this.worktreeStore);
|
|
66
|
+
this.wss.on("connection", (ws, req) => {
|
|
67
|
+
// API key authentication
|
|
68
|
+
if (this.apiKey) {
|
|
69
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
|
|
70
|
+
const token = url.searchParams.get("token");
|
|
71
|
+
if (token !== this.apiKey) {
|
|
72
|
+
console.log("[ws] Client rejected: invalid token");
|
|
73
|
+
ws.close(4001, "Unauthorized");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
console.log("[ws] Client connected");
|
|
78
|
+
this.handleConnection(ws);
|
|
79
|
+
});
|
|
80
|
+
this.wss.on("error", (err) => {
|
|
81
|
+
console.error("[ws] Server error:", err.message);
|
|
82
|
+
});
|
|
83
|
+
console.log(`[ws] WebSocket server attached to HTTP server`);
|
|
84
|
+
}
|
|
85
|
+
close() {
|
|
86
|
+
console.log("[ws] Shutting down...");
|
|
87
|
+
this.sessionManager.destroyAll();
|
|
88
|
+
this.debugEvents.clear();
|
|
89
|
+
this.wss.close();
|
|
90
|
+
}
|
|
91
|
+
/** Return session count for /health endpoint. */
|
|
92
|
+
get sessionCount() {
|
|
93
|
+
return this.sessionManager.list().length;
|
|
94
|
+
}
|
|
95
|
+
/** Return connected WebSocket client count. */
|
|
96
|
+
get clientCount() {
|
|
97
|
+
return this.wss.clients.size;
|
|
98
|
+
}
|
|
99
|
+
handleConnection(ws) {
|
|
100
|
+
// Send session list on connect
|
|
101
|
+
this.sendSessionList(ws);
|
|
102
|
+
ws.on("message", (data) => {
|
|
103
|
+
const raw = data.toString();
|
|
104
|
+
const msg = parseClientMessage(raw);
|
|
105
|
+
if (!msg) {
|
|
106
|
+
console.error("[ws] Invalid message:", raw.slice(0, 200));
|
|
107
|
+
this.send(ws, { type: "error", message: "Invalid message format" });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
console.log(`[ws] Received: ${msg.type}`);
|
|
111
|
+
this.handleClientMessage(msg, ws);
|
|
112
|
+
});
|
|
113
|
+
ws.on("close", () => {
|
|
114
|
+
console.log("[ws] Client disconnected");
|
|
115
|
+
});
|
|
116
|
+
ws.on("error", (err) => {
|
|
117
|
+
console.error("[ws] Client error:", err.message);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
handleClientMessage(msg, ws) {
|
|
121
|
+
const incomingSessionId = this.extractSessionIdFromClientMessage(msg);
|
|
122
|
+
const isActiveRuntimeSession = incomingSessionId != null && this.sessionManager.get(incomingSessionId) != null;
|
|
123
|
+
if (incomingSessionId && isActiveRuntimeSession) {
|
|
124
|
+
this.recordDebugEvent(incomingSessionId, {
|
|
125
|
+
direction: "incoming",
|
|
126
|
+
channel: "ws",
|
|
127
|
+
type: msg.type,
|
|
128
|
+
detail: this.summarizeClientMessage(msg),
|
|
129
|
+
});
|
|
130
|
+
this.recordingStore.record(incomingSessionId, "incoming", msg);
|
|
131
|
+
}
|
|
132
|
+
switch (msg.type) {
|
|
133
|
+
case "start": {
|
|
134
|
+
const provider = msg.provider ?? "claude";
|
|
135
|
+
const cached = provider === "claude" ? this.sessionManager.getCachedCommands(msg.projectPath) : undefined;
|
|
136
|
+
const sessionId = this.sessionManager.create(msg.projectPath, {
|
|
137
|
+
sessionId: msg.sessionId,
|
|
138
|
+
continueMode: msg.continue,
|
|
139
|
+
permissionMode: msg.permissionMode,
|
|
140
|
+
model: msg.model,
|
|
141
|
+
effort: msg.effort,
|
|
142
|
+
maxTurns: msg.maxTurns,
|
|
143
|
+
maxBudgetUsd: msg.maxBudgetUsd,
|
|
144
|
+
fallbackModel: msg.fallbackModel,
|
|
145
|
+
forkSession: msg.forkSession,
|
|
146
|
+
persistSession: msg.persistSession,
|
|
147
|
+
}, undefined, {
|
|
148
|
+
useWorktree: msg.useWorktree,
|
|
149
|
+
worktreeBranch: msg.worktreeBranch,
|
|
150
|
+
existingWorktreePath: msg.existingWorktreePath,
|
|
151
|
+
}, provider, provider === "codex"
|
|
152
|
+
? {
|
|
153
|
+
approvalPolicy: msg.approvalPolicy ?? undefined,
|
|
154
|
+
sandboxMode: msg.sandboxMode ?? undefined,
|
|
155
|
+
model: msg.model,
|
|
156
|
+
modelReasoningEffort: msg.modelReasoningEffort ?? undefined,
|
|
157
|
+
networkAccessEnabled: msg.networkAccessEnabled,
|
|
158
|
+
webSearchMode: msg.webSearchMode ?? undefined,
|
|
159
|
+
threadId: msg.sessionId,
|
|
160
|
+
}
|
|
161
|
+
: undefined);
|
|
162
|
+
const createdSession = this.sessionManager.get(sessionId);
|
|
163
|
+
this.send(ws, {
|
|
164
|
+
type: "system",
|
|
165
|
+
subtype: "session_created",
|
|
166
|
+
sessionId,
|
|
167
|
+
provider,
|
|
168
|
+
projectPath: msg.projectPath,
|
|
169
|
+
...(provider === "claude" && msg.permissionMode ? { permissionMode: msg.permissionMode } : {}),
|
|
170
|
+
...(provider === "codex" && msg.sandboxMode ? { sandboxMode: msg.sandboxMode } : {}),
|
|
171
|
+
...(cached ? { slashCommands: cached.slashCommands, skills: cached.skills } : {}),
|
|
172
|
+
...(createdSession?.worktreePath ? {
|
|
173
|
+
worktreePath: createdSession.worktreePath,
|
|
174
|
+
worktreeBranch: createdSession.worktreeBranch,
|
|
175
|
+
} : {}),
|
|
176
|
+
});
|
|
177
|
+
this.debugEvents.set(sessionId, []);
|
|
178
|
+
this.recordDebugEvent(sessionId, {
|
|
179
|
+
direction: "internal",
|
|
180
|
+
channel: "bridge",
|
|
181
|
+
type: "session_created",
|
|
182
|
+
detail: `provider=${provider} projectPath=${msg.projectPath}`,
|
|
183
|
+
});
|
|
184
|
+
this.recordingStore.saveMeta(sessionId, {
|
|
185
|
+
bridgeSessionId: sessionId,
|
|
186
|
+
projectPath: msg.projectPath,
|
|
187
|
+
createdAt: new Date().toISOString(),
|
|
188
|
+
});
|
|
189
|
+
this.projectHistory?.addProject(msg.projectPath);
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
case "input": {
|
|
193
|
+
const session = this.resolveSession(msg.sessionId);
|
|
194
|
+
if (!session) {
|
|
195
|
+
this.send(ws, { type: "error", message: "No active session. Send 'start' first." });
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const text = msg.text;
|
|
199
|
+
// Codex: reject if the process is not waiting for input (turn-based, no internal queue)
|
|
200
|
+
// SDK (Claude Code): always accept — the async generator keeps the resolver set during processing
|
|
201
|
+
if (session.provider === "codex" && !session.process.isWaitingForInput) {
|
|
202
|
+
this.send(ws, { type: "input_rejected", sessionId: session.id, reason: "Process is busy" });
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
// Acknowledge receipt immediately so the client can mark the message as sent
|
|
206
|
+
this.send(ws, { type: "input_ack", sessionId: session.id });
|
|
207
|
+
// Add user_input to in-memory history.
|
|
208
|
+
// The SDK stream does NOT emit user messages, so session.history would
|
|
209
|
+
// otherwise lack them. This ensures get_history responses include user
|
|
210
|
+
// messages and replaceEntries on the client side preserves them.
|
|
211
|
+
// We do NOT broadcast this back — Flutter already shows it via sendMessage().
|
|
212
|
+
const hasImage = !!(msg.imageBase64 || msg.imageId);
|
|
213
|
+
session.history.push({
|
|
214
|
+
type: "user_input",
|
|
215
|
+
text,
|
|
216
|
+
...(hasImage ? { imageCount: 1 } : {}),
|
|
217
|
+
});
|
|
218
|
+
// Codex input path (text + optional image)
|
|
219
|
+
if (session.provider === "codex") {
|
|
220
|
+
const codexProc = session.process;
|
|
221
|
+
if (msg.imageBase64 && msg.mimeType) {
|
|
222
|
+
codexProc.sendInputWithImage(text, {
|
|
223
|
+
base64: msg.imageBase64,
|
|
224
|
+
mimeType: msg.mimeType,
|
|
225
|
+
});
|
|
226
|
+
if (this.galleryStore && session.projectPath) {
|
|
227
|
+
this.galleryStore.addImageFromBase64(msg.imageBase64, msg.mimeType, session.projectPath, msg.sessionId).catch((err) => {
|
|
228
|
+
console.warn(`[ws] Failed to persist image to gallery: ${err}`);
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
else if (msg.imageId && this.galleryStore) {
|
|
233
|
+
this.galleryStore.getImageAsBase64(msg.imageId).then((imageData) => {
|
|
234
|
+
if (imageData) {
|
|
235
|
+
codexProc.sendInputWithImage(text, imageData);
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
console.warn(`[ws] Image not found: ${msg.imageId}`);
|
|
239
|
+
codexProc.sendInput(text);
|
|
240
|
+
}
|
|
241
|
+
}).catch((err) => {
|
|
242
|
+
console.error(`[ws] Failed to load image: ${err}`);
|
|
243
|
+
codexProc.sendInput(text);
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
codexProc.sendInput(text);
|
|
248
|
+
}
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
// Priority 1: Direct Base64 image (simplified flow)
|
|
252
|
+
const claudeProc = session.process;
|
|
253
|
+
if (msg.imageBase64 && msg.mimeType) {
|
|
254
|
+
console.log(`[ws] Sending message with inline Base64 image (${msg.mimeType})`);
|
|
255
|
+
claudeProc.sendInputWithImage(text, {
|
|
256
|
+
base64: msg.imageBase64,
|
|
257
|
+
mimeType: msg.mimeType,
|
|
258
|
+
});
|
|
259
|
+
// Persist to Gallery Store asynchronously (fire-and-forget)
|
|
260
|
+
if (this.galleryStore && session.projectPath) {
|
|
261
|
+
this.galleryStore.addImageFromBase64(msg.imageBase64, msg.mimeType, session.projectPath, msg.sessionId).catch((err) => {
|
|
262
|
+
console.warn(`[ws] Failed to persist image to gallery: ${err}`);
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
// Priority 2: Legacy imageId mode (backward compatibility)
|
|
267
|
+
else if (msg.imageId && this.galleryStore) {
|
|
268
|
+
this.galleryStore.getImageAsBase64(msg.imageId).then((imageData) => {
|
|
269
|
+
if (imageData) {
|
|
270
|
+
claudeProc.sendInputWithImage(text, imageData);
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
console.warn(`[ws] Image not found: ${msg.imageId}`);
|
|
274
|
+
session.process.sendInput(text);
|
|
275
|
+
}
|
|
276
|
+
}).catch((err) => {
|
|
277
|
+
console.error(`[ws] Failed to load image: ${err}`);
|
|
278
|
+
session.process.sendInput(text);
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
// Priority 3: Text-only message
|
|
282
|
+
else {
|
|
283
|
+
session.process.sendInput(text);
|
|
284
|
+
}
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
case "push_register": {
|
|
288
|
+
console.log(`[ws] push_register received (platform: ${msg.platform}, configured: ${this.pushRelay.isConfigured})`);
|
|
289
|
+
if (!this.pushRelay.isConfigured) {
|
|
290
|
+
this.send(ws, { type: "error", message: "Push relay is not configured on bridge" });
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
this.pushRelay.registerToken(msg.token, msg.platform).then(() => {
|
|
294
|
+
console.log("[ws] push_register: token registered successfully");
|
|
295
|
+
}).catch((err) => {
|
|
296
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
297
|
+
console.error(`[ws] push_register failed: ${detail}`);
|
|
298
|
+
this.send(ws, { type: "error", message: `Failed to register push token: ${detail}` });
|
|
299
|
+
});
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
case "push_unregister": {
|
|
303
|
+
console.log("[ws] push_unregister received");
|
|
304
|
+
if (!this.pushRelay.isConfigured) {
|
|
305
|
+
this.send(ws, { type: "error", message: "Push relay is not configured on bridge" });
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
this.pushRelay.unregisterToken(msg.token).then(() => {
|
|
309
|
+
console.log("[ws] push_unregister: token unregistered successfully");
|
|
310
|
+
}).catch((err) => {
|
|
311
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
312
|
+
console.error(`[ws] push_unregister failed: ${detail}`);
|
|
313
|
+
this.send(ws, { type: "error", message: `Failed to unregister push token: ${detail}` });
|
|
314
|
+
});
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
case "set_permission_mode": {
|
|
318
|
+
const session = this.resolveSession(msg.sessionId);
|
|
319
|
+
if (!session) {
|
|
320
|
+
this.send(ws, { type: "error", message: "No active session." });
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (session.provider === "codex") {
|
|
324
|
+
this.send(ws, {
|
|
325
|
+
type: "error",
|
|
326
|
+
message: "Codex sessions do not support runtime permission mode changes",
|
|
327
|
+
});
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
session.process.setPermissionMode(msg.mode).catch((err) => {
|
|
331
|
+
this.send(ws, {
|
|
332
|
+
type: "error",
|
|
333
|
+
message: `Failed to set permission mode: ${err instanceof Error ? err.message : String(err)}`,
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
break;
|
|
337
|
+
}
|
|
338
|
+
case "set_sandbox_mode": {
|
|
339
|
+
const session = this.resolveSession(msg.sessionId);
|
|
340
|
+
if (!session) {
|
|
341
|
+
this.send(ws, { type: "error", message: "No active session." });
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (session.provider !== "codex") {
|
|
345
|
+
this.send(ws, { type: "error", message: "Only Codex sessions support sandbox mode changes" });
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const validModes = ["read-only", "workspace-write", "danger-full-access"];
|
|
349
|
+
if (!validModes.includes(msg.sandboxMode)) {
|
|
350
|
+
this.send(ws, { type: "error", message: `Invalid sandbox mode: ${msg.sandboxMode}` });
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const newSandboxMode = msg.sandboxMode;
|
|
354
|
+
// Update stored settings
|
|
355
|
+
if (!session.codexSettings) {
|
|
356
|
+
session.codexSettings = {};
|
|
357
|
+
}
|
|
358
|
+
session.codexSettings.sandboxMode = newSandboxMode;
|
|
359
|
+
// Restart Codex process with new sandboxMode (required: sandboxMode is set at thread start)
|
|
360
|
+
const codexProc = session.process;
|
|
361
|
+
const threadId = session.claudeSessionId ?? undefined;
|
|
362
|
+
const effectiveCwd = session.worktreePath ?? session.projectPath;
|
|
363
|
+
codexProc.stop();
|
|
364
|
+
codexProc.start(effectiveCwd, {
|
|
365
|
+
approvalPolicy: session.codexSettings.approvalPolicy ?? undefined,
|
|
366
|
+
sandboxMode: newSandboxMode,
|
|
367
|
+
model: session.codexSettings.model,
|
|
368
|
+
modelReasoningEffort: session.codexSettings.modelReasoningEffort ?? undefined,
|
|
369
|
+
networkAccessEnabled: session.codexSettings.networkAccessEnabled,
|
|
370
|
+
webSearchMode: session.codexSettings.webSearchMode ?? undefined,
|
|
371
|
+
threadId,
|
|
372
|
+
});
|
|
373
|
+
console.log(`[ws] Sandbox mode changed to ${newSandboxMode} for session ${session.id} (thread restart)`);
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
376
|
+
case "approve": {
|
|
377
|
+
const session = this.resolveSession(msg.sessionId);
|
|
378
|
+
if (!session) {
|
|
379
|
+
this.send(ws, { type: "error", message: "No active session." });
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (session.provider === "codex") {
|
|
383
|
+
this.send(ws, { type: "error", message: "Codex sessions do not support approval" });
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
const sdkProc = session.process;
|
|
387
|
+
if (msg.clearContext) {
|
|
388
|
+
// Clear & Accept: immediately destroy this runtime session and
|
|
389
|
+
// create a fresh one that continues the same Claude conversation.
|
|
390
|
+
// This guarantees chat history is cleared in the mobile UI without
|
|
391
|
+
// waiting for additional in-turn tool approvals.
|
|
392
|
+
const pending = sdkProc.getPendingPermission(msg.id);
|
|
393
|
+
const mergedInput = {
|
|
394
|
+
...(pending?.input ?? {}),
|
|
395
|
+
...(msg.updatedInput ?? {}),
|
|
396
|
+
};
|
|
397
|
+
const planText = typeof mergedInput.plan === "string" ? mergedInput.plan : "";
|
|
398
|
+
// Use session.id (always present) instead of msg.sessionId.
|
|
399
|
+
const sessionId = session.id;
|
|
400
|
+
// Capture session properties before destroy.
|
|
401
|
+
const claudeSessionId = session.claudeSessionId;
|
|
402
|
+
const projectPath = session.projectPath;
|
|
403
|
+
const permissionMode = sdkProc.permissionMode;
|
|
404
|
+
const worktreePath = session.worktreePath;
|
|
405
|
+
const worktreeBranch = session.worktreeBranch;
|
|
406
|
+
this.sessionManager.destroy(sessionId);
|
|
407
|
+
console.log(`[ws] Clear context: destroyed session ${sessionId}`);
|
|
408
|
+
const newId = this.sessionManager.create(projectPath, {
|
|
409
|
+
...(claudeSessionId
|
|
410
|
+
? {
|
|
411
|
+
sessionId: claudeSessionId,
|
|
412
|
+
continueMode: true,
|
|
413
|
+
}
|
|
414
|
+
: {}),
|
|
415
|
+
permissionMode,
|
|
416
|
+
initialInput: planText || undefined,
|
|
417
|
+
}, undefined, worktreePath ? { existingWorktreePath: worktreePath, worktreeBranch } : undefined);
|
|
418
|
+
console.log(`[ws] Clear context: created new session ${newId} (CLI session: ${claudeSessionId ?? "new"})`);
|
|
419
|
+
// Notify all clients. Broadcast is used so reconnecting clients also receive it.
|
|
420
|
+
const newSession = this.sessionManager.get(newId);
|
|
421
|
+
this.broadcast({
|
|
422
|
+
type: "system",
|
|
423
|
+
subtype: "session_created",
|
|
424
|
+
sessionId: newId,
|
|
425
|
+
provider: newSession?.provider ?? "claude",
|
|
426
|
+
projectPath,
|
|
427
|
+
...(permissionMode ? { permissionMode } : {}),
|
|
428
|
+
clearContext: true,
|
|
429
|
+
});
|
|
430
|
+
this.broadcastSessionList();
|
|
431
|
+
}
|
|
432
|
+
else {
|
|
433
|
+
sdkProc.approve(msg.id, msg.updatedInput);
|
|
434
|
+
}
|
|
435
|
+
break;
|
|
436
|
+
}
|
|
437
|
+
case "approve_always": {
|
|
438
|
+
const session = this.resolveSession(msg.sessionId);
|
|
439
|
+
if (!session) {
|
|
440
|
+
this.send(ws, { type: "error", message: "No active session." });
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (session.provider === "codex") {
|
|
444
|
+
this.send(ws, { type: "error", message: "Codex sessions do not support approval" });
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
session.process.approveAlways(msg.id);
|
|
448
|
+
break;
|
|
449
|
+
}
|
|
450
|
+
case "reject": {
|
|
451
|
+
const session = this.resolveSession(msg.sessionId);
|
|
452
|
+
if (!session) {
|
|
453
|
+
this.send(ws, { type: "error", message: "No active session." });
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
if (session.provider === "codex") {
|
|
457
|
+
this.send(ws, { type: "error", message: "Codex sessions do not support rejection" });
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
session.process.reject(msg.id, msg.message);
|
|
461
|
+
break;
|
|
462
|
+
}
|
|
463
|
+
case "answer": {
|
|
464
|
+
const session = this.resolveSession(msg.sessionId);
|
|
465
|
+
if (!session) {
|
|
466
|
+
this.send(ws, { type: "error", message: "No active session." });
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
if (session.provider === "codex") {
|
|
470
|
+
this.send(ws, { type: "error", message: "Codex sessions do not support answer" });
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
session.process.answer(msg.toolUseId, msg.result);
|
|
474
|
+
break;
|
|
475
|
+
}
|
|
476
|
+
case "list_sessions": {
|
|
477
|
+
this.sendSessionList(ws);
|
|
478
|
+
break;
|
|
479
|
+
}
|
|
480
|
+
case "stop_session": {
|
|
481
|
+
const session = this.sessionManager.get(msg.sessionId);
|
|
482
|
+
if (session) {
|
|
483
|
+
// Notify clients before destroying (destroy removes listeners)
|
|
484
|
+
this.broadcastSessionMessage(msg.sessionId, {
|
|
485
|
+
type: "result",
|
|
486
|
+
subtype: "stopped",
|
|
487
|
+
sessionId: session.claudeSessionId,
|
|
488
|
+
});
|
|
489
|
+
this.sessionManager.destroy(msg.sessionId);
|
|
490
|
+
this.recordDebugEvent(msg.sessionId, {
|
|
491
|
+
direction: "internal",
|
|
492
|
+
channel: "bridge",
|
|
493
|
+
type: "session_stopped",
|
|
494
|
+
});
|
|
495
|
+
this.debugEvents.delete(msg.sessionId);
|
|
496
|
+
this.notifiedPermissionToolUses.delete(msg.sessionId);
|
|
497
|
+
this.sendSessionList(ws);
|
|
498
|
+
}
|
|
499
|
+
else {
|
|
500
|
+
this.send(ws, { type: "error", message: `Session ${msg.sessionId} not found` });
|
|
501
|
+
}
|
|
502
|
+
break;
|
|
503
|
+
}
|
|
504
|
+
case "get_history": {
|
|
505
|
+
const session = this.sessionManager.get(msg.sessionId);
|
|
506
|
+
if (session) {
|
|
507
|
+
// Send past conversation from disk (resume) before in-memory history
|
|
508
|
+
if (session.pastMessages && session.pastMessages.length > 0) {
|
|
509
|
+
this.send(ws, {
|
|
510
|
+
type: "past_history",
|
|
511
|
+
claudeSessionId: session.claudeSessionId ?? msg.sessionId,
|
|
512
|
+
sessionId: msg.sessionId,
|
|
513
|
+
messages: session.pastMessages,
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
this.send(ws, { type: "history", messages: session.history, sessionId: msg.sessionId });
|
|
517
|
+
this.send(ws, { type: "status", status: session.status, sessionId: msg.sessionId });
|
|
518
|
+
}
|
|
519
|
+
else {
|
|
520
|
+
this.send(ws, { type: "error", message: `Session ${msg.sessionId} not found` });
|
|
521
|
+
}
|
|
522
|
+
break;
|
|
523
|
+
}
|
|
524
|
+
case "get_debug_bundle": {
|
|
525
|
+
const session = this.sessionManager.get(msg.sessionId);
|
|
526
|
+
if (!session) {
|
|
527
|
+
this.send(ws, { type: "error", message: `Session ${msg.sessionId} not found` });
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
const emitBundle = (diff, diffError) => {
|
|
531
|
+
const traceLimit = msg.traceLimit ?? BridgeWebSocketServer.MAX_DEBUG_EVENTS;
|
|
532
|
+
const trace = this.getDebugEvents(msg.sessionId, traceLimit);
|
|
533
|
+
const generatedAt = new Date().toISOString();
|
|
534
|
+
const includeDiff = msg.includeDiff !== false;
|
|
535
|
+
const bundlePayload = {
|
|
536
|
+
type: "debug_bundle",
|
|
537
|
+
sessionId: msg.sessionId,
|
|
538
|
+
generatedAt,
|
|
539
|
+
session: {
|
|
540
|
+
id: session.id,
|
|
541
|
+
provider: session.provider,
|
|
542
|
+
status: session.status,
|
|
543
|
+
projectPath: session.projectPath,
|
|
544
|
+
worktreePath: session.worktreePath,
|
|
545
|
+
worktreeBranch: session.worktreeBranch,
|
|
546
|
+
claudeSessionId: session.claudeSessionId,
|
|
547
|
+
createdAt: session.createdAt.toISOString(),
|
|
548
|
+
lastActivityAt: session.lastActivityAt.toISOString(),
|
|
549
|
+
},
|
|
550
|
+
pastMessageCount: session.pastMessages?.length ?? 0,
|
|
551
|
+
historySummary: this.buildHistorySummary(session.history),
|
|
552
|
+
debugTrace: trace,
|
|
553
|
+
traceFilePath: this.debugTraceStore.getTraceFilePath(msg.sessionId),
|
|
554
|
+
reproRecipe: this.buildReproRecipe(session, traceLimit, includeDiff),
|
|
555
|
+
agentPrompt: this.buildAgentPrompt(session),
|
|
556
|
+
diff,
|
|
557
|
+
diffError,
|
|
558
|
+
};
|
|
559
|
+
const savedBundlePath = this.debugTraceStore.getBundleFilePath(msg.sessionId, generatedAt);
|
|
560
|
+
bundlePayload.savedBundlePath = savedBundlePath;
|
|
561
|
+
this.debugTraceStore.saveBundleAtPath(savedBundlePath, bundlePayload);
|
|
562
|
+
this.send(ws, bundlePayload);
|
|
563
|
+
};
|
|
564
|
+
if (msg.includeDiff === false) {
|
|
565
|
+
emitBundle("");
|
|
566
|
+
break;
|
|
567
|
+
}
|
|
568
|
+
const cwd = session.worktreePath ?? session.projectPath;
|
|
569
|
+
this.collectGitDiff(cwd, ({ diff, error }) => {
|
|
570
|
+
emitBundle(diff, error);
|
|
571
|
+
});
|
|
572
|
+
break;
|
|
573
|
+
}
|
|
574
|
+
case "get_usage": {
|
|
575
|
+
fetchAllUsage().then((providers) => {
|
|
576
|
+
this.send(ws, { type: "usage_result", providers });
|
|
577
|
+
}).catch((err) => {
|
|
578
|
+
this.send(ws, { type: "error", message: `Failed to fetch usage: ${err}` });
|
|
579
|
+
});
|
|
580
|
+
break;
|
|
581
|
+
}
|
|
582
|
+
case "list_recent_sessions": {
|
|
583
|
+
const requestId = ++this.recentSessionsRequestId;
|
|
584
|
+
getAllRecentSessions({
|
|
585
|
+
limit: msg.limit,
|
|
586
|
+
offset: msg.offset,
|
|
587
|
+
projectPath: msg.projectPath,
|
|
588
|
+
}).then(({ sessions, hasMore }) => {
|
|
589
|
+
// Drop stale responses when rapid filter switches cause out-of-order completion
|
|
590
|
+
if (requestId !== this.recentSessionsRequestId)
|
|
591
|
+
return;
|
|
592
|
+
this.send(ws, { type: "recent_sessions", sessions, hasMore });
|
|
593
|
+
}).catch((err) => {
|
|
594
|
+
if (requestId !== this.recentSessionsRequestId)
|
|
595
|
+
return;
|
|
596
|
+
this.send(ws, { type: "error", message: `Failed to list recent sessions: ${err}` });
|
|
597
|
+
});
|
|
598
|
+
break;
|
|
599
|
+
}
|
|
600
|
+
case "resume_session": {
|
|
601
|
+
const provider = msg.provider ?? "claude";
|
|
602
|
+
const sessionRefId = msg.sessionId;
|
|
603
|
+
// Resume flow: keep past history in SessionInfo and deliver it only
|
|
604
|
+
// via get_history(sessionId) to avoid duplicate/missed replay races.
|
|
605
|
+
if (provider === "codex") {
|
|
606
|
+
const wtMapping = this.worktreeStore.get(sessionRefId);
|
|
607
|
+
const effectiveProjectPath = wtMapping?.projectPath ?? msg.projectPath;
|
|
608
|
+
let worktreeOpts;
|
|
609
|
+
if (wtMapping) {
|
|
610
|
+
if (worktreeExists(wtMapping.worktreePath)) {
|
|
611
|
+
worktreeOpts = {
|
|
612
|
+
existingWorktreePath: wtMapping.worktreePath,
|
|
613
|
+
worktreeBranch: wtMapping.worktreeBranch,
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
else {
|
|
617
|
+
worktreeOpts = {
|
|
618
|
+
useWorktree: true,
|
|
619
|
+
worktreeBranch: wtMapping.worktreeBranch,
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
getCodexSessionHistory(sessionRefId).then((pastMessages) => {
|
|
624
|
+
const sessionId = this.sessionManager.create(effectiveProjectPath, undefined, pastMessages, worktreeOpts, "codex", {
|
|
625
|
+
threadId: sessionRefId,
|
|
626
|
+
approvalPolicy: msg.approvalPolicy ?? undefined,
|
|
627
|
+
sandboxMode: msg.sandboxMode ?? undefined,
|
|
628
|
+
model: msg.model,
|
|
629
|
+
modelReasoningEffort: msg.modelReasoningEffort ?? undefined,
|
|
630
|
+
networkAccessEnabled: msg.networkAccessEnabled,
|
|
631
|
+
webSearchMode: msg.webSearchMode ?? undefined,
|
|
632
|
+
});
|
|
633
|
+
const createdSession = this.sessionManager.get(sessionId);
|
|
634
|
+
this.send(ws, {
|
|
635
|
+
type: "system",
|
|
636
|
+
subtype: "session_created",
|
|
637
|
+
sessionId,
|
|
638
|
+
provider: "codex",
|
|
639
|
+
projectPath: effectiveProjectPath,
|
|
640
|
+
...(createdSession?.codexSettings?.sandboxMode ? { sandboxMode: createdSession.codexSettings.sandboxMode } : {}),
|
|
641
|
+
...(createdSession?.worktreePath ? {
|
|
642
|
+
worktreePath: createdSession.worktreePath,
|
|
643
|
+
worktreeBranch: createdSession.worktreeBranch,
|
|
644
|
+
} : {}),
|
|
645
|
+
});
|
|
646
|
+
this.debugEvents.set(sessionId, []);
|
|
647
|
+
this.recordDebugEvent(sessionId, {
|
|
648
|
+
direction: "internal",
|
|
649
|
+
channel: "bridge",
|
|
650
|
+
type: "session_resumed",
|
|
651
|
+
detail: `provider=codex thread=${sessionRefId}`,
|
|
652
|
+
});
|
|
653
|
+
this.projectHistory?.addProject(effectiveProjectPath);
|
|
654
|
+
}).catch((err) => {
|
|
655
|
+
this.send(ws, { type: "error", message: `Failed to load Codex session history: ${err}` });
|
|
656
|
+
});
|
|
657
|
+
break;
|
|
658
|
+
}
|
|
659
|
+
const claudeSessionId = sessionRefId;
|
|
660
|
+
const cached = this.sessionManager.getCachedCommands(msg.projectPath);
|
|
661
|
+
// Look up worktree mapping for this Claude session
|
|
662
|
+
const wtMapping = this.worktreeStore.get(claudeSessionId);
|
|
663
|
+
let worktreeOpts;
|
|
664
|
+
if (wtMapping) {
|
|
665
|
+
if (worktreeExists(wtMapping.worktreePath)) {
|
|
666
|
+
// Worktree exists — reuse it directly
|
|
667
|
+
worktreeOpts = {
|
|
668
|
+
existingWorktreePath: wtMapping.worktreePath,
|
|
669
|
+
worktreeBranch: wtMapping.worktreeBranch,
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
else {
|
|
673
|
+
// Worktree was deleted — recreate on the same branch
|
|
674
|
+
worktreeOpts = { useWorktree: true, worktreeBranch: wtMapping.worktreeBranch };
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
getSessionHistory(claudeSessionId).then((pastMessages) => {
|
|
678
|
+
const sessionId = this.sessionManager.create(msg.projectPath, {
|
|
679
|
+
sessionId: claudeSessionId,
|
|
680
|
+
permissionMode: msg.permissionMode,
|
|
681
|
+
model: msg.model,
|
|
682
|
+
effort: msg.effort,
|
|
683
|
+
maxTurns: msg.maxTurns,
|
|
684
|
+
maxBudgetUsd: msg.maxBudgetUsd,
|
|
685
|
+
fallbackModel: msg.fallbackModel,
|
|
686
|
+
forkSession: msg.forkSession,
|
|
687
|
+
persistSession: msg.persistSession,
|
|
688
|
+
}, pastMessages, worktreeOpts);
|
|
689
|
+
const createdSession = this.sessionManager.get(sessionId);
|
|
690
|
+
this.send(ws, {
|
|
691
|
+
type: "system",
|
|
692
|
+
subtype: "session_created",
|
|
693
|
+
sessionId,
|
|
694
|
+
claudeSessionId,
|
|
695
|
+
provider: "claude",
|
|
696
|
+
projectPath: msg.projectPath,
|
|
697
|
+
...(msg.permissionMode ? { permissionMode: msg.permissionMode } : {}),
|
|
698
|
+
...(cached ? { slashCommands: cached.slashCommands, skills: cached.skills } : {}),
|
|
699
|
+
...(createdSession?.worktreePath ? {
|
|
700
|
+
worktreePath: createdSession.worktreePath,
|
|
701
|
+
worktreeBranch: createdSession.worktreeBranch,
|
|
702
|
+
} : {}),
|
|
703
|
+
});
|
|
704
|
+
this.debugEvents.set(sessionId, []);
|
|
705
|
+
this.recordDebugEvent(sessionId, {
|
|
706
|
+
direction: "internal",
|
|
707
|
+
channel: "bridge",
|
|
708
|
+
type: "session_resumed",
|
|
709
|
+
detail: `provider=claude session=${claudeSessionId}`,
|
|
710
|
+
});
|
|
711
|
+
this.projectHistory?.addProject(msg.projectPath);
|
|
712
|
+
}).catch((err) => {
|
|
713
|
+
this.send(ws, { type: "error", message: `Failed to load session history: ${err}` });
|
|
714
|
+
});
|
|
715
|
+
break;
|
|
716
|
+
}
|
|
717
|
+
case "list_gallery": {
|
|
718
|
+
if (this.galleryStore) {
|
|
719
|
+
const images = this.galleryStore.list({
|
|
720
|
+
projectPath: msg.project,
|
|
721
|
+
sessionId: msg.sessionId,
|
|
722
|
+
});
|
|
723
|
+
this.send(ws, { type: "gallery_list", images });
|
|
724
|
+
}
|
|
725
|
+
else {
|
|
726
|
+
this.send(ws, { type: "gallery_list", images: [] });
|
|
727
|
+
}
|
|
728
|
+
break;
|
|
729
|
+
}
|
|
730
|
+
case "get_message_images": {
|
|
731
|
+
void extractMessageImages(msg.claudeSessionId, msg.messageUuid).then((images) => {
|
|
732
|
+
const refs = [];
|
|
733
|
+
if (this.imageStore) {
|
|
734
|
+
for (const img of images) {
|
|
735
|
+
const ref = this.imageStore.registerFromBase64(img.base64, img.mimeType);
|
|
736
|
+
if (ref)
|
|
737
|
+
refs.push(ref);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
this.send(ws, { type: "message_images_result", messageUuid: msg.messageUuid, images: refs });
|
|
741
|
+
}).catch((err) => {
|
|
742
|
+
console.error("[ws] Failed to extract message images:", err);
|
|
743
|
+
this.send(ws, { type: "message_images_result", messageUuid: msg.messageUuid, images: [] });
|
|
744
|
+
});
|
|
745
|
+
break;
|
|
746
|
+
}
|
|
747
|
+
case "interrupt": {
|
|
748
|
+
const session = this.resolveSession(msg.sessionId);
|
|
749
|
+
if (!session) {
|
|
750
|
+
this.send(ws, { type: "error", message: "No active session." });
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
session.process.interrupt();
|
|
754
|
+
break;
|
|
755
|
+
}
|
|
756
|
+
case "list_project_history": {
|
|
757
|
+
const projects = this.projectHistory?.getProjects() ?? [];
|
|
758
|
+
this.send(ws, { type: "project_history", projects });
|
|
759
|
+
break;
|
|
760
|
+
}
|
|
761
|
+
case "remove_project_history": {
|
|
762
|
+
this.projectHistory?.removeProject(msg.projectPath);
|
|
763
|
+
const projects = this.projectHistory?.getProjects() ?? [];
|
|
764
|
+
this.send(ws, { type: "project_history", projects });
|
|
765
|
+
break;
|
|
766
|
+
}
|
|
767
|
+
case "list_files": {
|
|
768
|
+
execFile("git", ["ls-files"], { cwd: msg.projectPath, maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => {
|
|
769
|
+
if (err) {
|
|
770
|
+
this.send(ws, { type: "error", message: `Failed to list files: ${err.message}` });
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
const files = stdout.trim().split("\n").filter(Boolean);
|
|
774
|
+
this.send(ws, { type: "file_list", files });
|
|
775
|
+
});
|
|
776
|
+
break;
|
|
777
|
+
}
|
|
778
|
+
case "list_recordings": {
|
|
779
|
+
void this.recordingStore.listRecordings().then(async (recordings) => {
|
|
780
|
+
// First pass: extract info from JSONL for recordings missing firstPrompt
|
|
781
|
+
// This covers both meta-less legacy recordings and new ones where sessions-index hasn't indexed yet
|
|
782
|
+
await Promise.all(recordings.map(async (rec) => {
|
|
783
|
+
const info = await this.recordingStore.extractInfoFromJsonl(rec.name);
|
|
784
|
+
if (info.firstPrompt && !rec.firstPrompt)
|
|
785
|
+
rec.firstPrompt = info.firstPrompt;
|
|
786
|
+
if (info.lastPrompt && !rec.lastPrompt)
|
|
787
|
+
rec.lastPrompt = info.lastPrompt;
|
|
788
|
+
// Backfill meta for legacy recordings
|
|
789
|
+
if (!rec.meta && (info.claudeSessionId || info.projectPath)) {
|
|
790
|
+
rec.meta = {
|
|
791
|
+
bridgeSessionId: rec.name,
|
|
792
|
+
claudeSessionId: info.claudeSessionId,
|
|
793
|
+
projectPath: info.projectPath ?? "",
|
|
794
|
+
createdAt: rec.modified,
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
}));
|
|
798
|
+
// Second pass: look up sessions-index for summaries (if claudeSessionIds available)
|
|
799
|
+
const claudeIds = new Set();
|
|
800
|
+
const idToIdx = new Map();
|
|
801
|
+
for (let i = 0; i < recordings.length; i++) {
|
|
802
|
+
const cid = recordings[i].meta?.claudeSessionId;
|
|
803
|
+
if (cid) {
|
|
804
|
+
claudeIds.add(cid);
|
|
805
|
+
const arr = idToIdx.get(cid) ?? [];
|
|
806
|
+
arr.push(i);
|
|
807
|
+
idToIdx.set(cid, arr);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
if (claudeIds.size > 0) {
|
|
811
|
+
const sessionInfo = await findSessionsByClaudeIds(claudeIds);
|
|
812
|
+
for (const [cid, info] of sessionInfo) {
|
|
813
|
+
const indices = idToIdx.get(cid) ?? [];
|
|
814
|
+
for (const idx of indices) {
|
|
815
|
+
if (info.summary)
|
|
816
|
+
recordings[idx].summary = info.summary;
|
|
817
|
+
if (info.firstPrompt)
|
|
818
|
+
recordings[idx].firstPrompt = info.firstPrompt;
|
|
819
|
+
if (info.lastPrompt)
|
|
820
|
+
recordings[idx].lastPrompt = info.lastPrompt;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
this.send(ws, { type: "recording_list", recordings });
|
|
825
|
+
});
|
|
826
|
+
break;
|
|
827
|
+
}
|
|
828
|
+
case "get_recording": {
|
|
829
|
+
void this.recordingStore.getRecordingContent(msg.sessionId).then((content) => {
|
|
830
|
+
if (content !== null) {
|
|
831
|
+
this.send(ws, { type: "recording_content", sessionId: msg.sessionId, content });
|
|
832
|
+
}
|
|
833
|
+
else {
|
|
834
|
+
this.send(ws, { type: "error", message: `Recording ${msg.sessionId} not found` });
|
|
835
|
+
}
|
|
836
|
+
});
|
|
837
|
+
break;
|
|
838
|
+
}
|
|
839
|
+
case "get_diff": {
|
|
840
|
+
this.collectGitDiff(msg.projectPath, ({ diff, error }) => {
|
|
841
|
+
if (error) {
|
|
842
|
+
this.send(ws, { type: "diff_result", diff: "", error: `Failed to get diff: ${error}` });
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
this.send(ws, { type: "diff_result", diff });
|
|
846
|
+
});
|
|
847
|
+
break;
|
|
848
|
+
}
|
|
849
|
+
case "list_worktrees": {
|
|
850
|
+
try {
|
|
851
|
+
const worktrees = listWorktrees(msg.projectPath);
|
|
852
|
+
this.send(ws, { type: "worktree_list", worktrees });
|
|
853
|
+
}
|
|
854
|
+
catch (err) {
|
|
855
|
+
this.send(ws, { type: "error", message: `Failed to list worktrees: ${err}` });
|
|
856
|
+
}
|
|
857
|
+
break;
|
|
858
|
+
}
|
|
859
|
+
case "remove_worktree": {
|
|
860
|
+
try {
|
|
861
|
+
removeWorktree(msg.projectPath, msg.worktreePath);
|
|
862
|
+
this.worktreeStore.deleteByWorktreePath(msg.worktreePath);
|
|
863
|
+
this.send(ws, { type: "worktree_removed", worktreePath: msg.worktreePath });
|
|
864
|
+
}
|
|
865
|
+
catch (err) {
|
|
866
|
+
this.send(ws, { type: "error", message: `Failed to remove worktree: ${err}` });
|
|
867
|
+
}
|
|
868
|
+
break;
|
|
869
|
+
}
|
|
870
|
+
case "rewind_dry_run": {
|
|
871
|
+
const session = this.sessionManager.get(msg.sessionId);
|
|
872
|
+
if (!session) {
|
|
873
|
+
this.send(ws, { type: "rewind_preview", canRewind: false, error: `Session ${msg.sessionId} not found` });
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
this.sessionManager.rewindFiles(msg.sessionId, msg.targetUuid, true).then((result) => {
|
|
877
|
+
this.send(ws, {
|
|
878
|
+
type: "rewind_preview",
|
|
879
|
+
canRewind: result.canRewind,
|
|
880
|
+
filesChanged: result.filesChanged,
|
|
881
|
+
insertions: result.insertions,
|
|
882
|
+
deletions: result.deletions,
|
|
883
|
+
error: result.error,
|
|
884
|
+
});
|
|
885
|
+
}).catch((err) => {
|
|
886
|
+
this.send(ws, { type: "rewind_preview", canRewind: false, error: `Dry run failed: ${err}` });
|
|
887
|
+
});
|
|
888
|
+
break;
|
|
889
|
+
}
|
|
890
|
+
case "rewind": {
|
|
891
|
+
const session = this.sessionManager.get(msg.sessionId);
|
|
892
|
+
if (!session) {
|
|
893
|
+
this.send(ws, { type: "rewind_result", success: false, mode: msg.mode, error: `Session ${msg.sessionId} not found` });
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
const handleError = (err) => {
|
|
897
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
898
|
+
this.send(ws, { type: "rewind_result", success: false, mode: msg.mode, error: errMsg });
|
|
899
|
+
};
|
|
900
|
+
if (msg.mode === "code") {
|
|
901
|
+
// Code-only rewind: rewind files without restarting the conversation
|
|
902
|
+
this.sessionManager.rewindFiles(msg.sessionId, msg.targetUuid).then((result) => {
|
|
903
|
+
if (result.canRewind) {
|
|
904
|
+
this.send(ws, { type: "rewind_result", success: true, mode: "code" });
|
|
905
|
+
}
|
|
906
|
+
else {
|
|
907
|
+
this.send(ws, { type: "rewind_result", success: false, mode: "code", error: result.error ?? "Cannot rewind files" });
|
|
908
|
+
}
|
|
909
|
+
}).catch(handleError);
|
|
910
|
+
}
|
|
911
|
+
else if (msg.mode === "conversation") {
|
|
912
|
+
// Conversation-only rewind: restart session at the target UUID
|
|
913
|
+
try {
|
|
914
|
+
this.sessionManager.rewindConversation(msg.sessionId, msg.targetUuid, (newSessionId) => {
|
|
915
|
+
this.send(ws, { type: "rewind_result", success: true, mode: "conversation" });
|
|
916
|
+
// Notify the new session ID
|
|
917
|
+
const newSession = this.sessionManager.get(newSessionId);
|
|
918
|
+
const rewindPermMode = newSession?.process instanceof SdkProcess ? newSession.process.permissionMode : undefined;
|
|
919
|
+
this.send(ws, {
|
|
920
|
+
type: "system",
|
|
921
|
+
subtype: "session_created",
|
|
922
|
+
sessionId: newSessionId,
|
|
923
|
+
provider: newSession?.provider ?? "claude",
|
|
924
|
+
projectPath: newSession?.projectPath ?? "",
|
|
925
|
+
...(rewindPermMode ? { permissionMode: rewindPermMode } : {}),
|
|
926
|
+
});
|
|
927
|
+
this.sendSessionList(ws);
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
catch (err) {
|
|
931
|
+
handleError(err);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
else {
|
|
935
|
+
// Both: rewind files first, then rewind conversation
|
|
936
|
+
this.sessionManager.rewindFiles(msg.sessionId, msg.targetUuid).then((result) => {
|
|
937
|
+
if (!result.canRewind) {
|
|
938
|
+
this.send(ws, { type: "rewind_result", success: false, mode: "both", error: result.error ?? "Cannot rewind files" });
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
try {
|
|
942
|
+
this.sessionManager.rewindConversation(msg.sessionId, msg.targetUuid, (newSessionId) => {
|
|
943
|
+
this.send(ws, { type: "rewind_result", success: true, mode: "both" });
|
|
944
|
+
const newSession = this.sessionManager.get(newSessionId);
|
|
945
|
+
const rewindPermMode2 = newSession?.process instanceof SdkProcess ? newSession.process.permissionMode : undefined;
|
|
946
|
+
this.send(ws, {
|
|
947
|
+
type: "system",
|
|
948
|
+
subtype: "session_created",
|
|
949
|
+
sessionId: newSessionId,
|
|
950
|
+
provider: newSession?.provider ?? "claude",
|
|
951
|
+
projectPath: newSession?.projectPath ?? "",
|
|
952
|
+
...(rewindPermMode2 ? { permissionMode: rewindPermMode2 } : {}),
|
|
953
|
+
});
|
|
954
|
+
this.sendSessionList(ws);
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
catch (err) {
|
|
958
|
+
handleError(err);
|
|
959
|
+
}
|
|
960
|
+
}).catch(handleError);
|
|
961
|
+
}
|
|
962
|
+
break;
|
|
963
|
+
}
|
|
964
|
+
case "list_windows": {
|
|
965
|
+
listWindows()
|
|
966
|
+
.then((windows) => {
|
|
967
|
+
this.send(ws, { type: "window_list", windows });
|
|
968
|
+
})
|
|
969
|
+
.catch((err) => {
|
|
970
|
+
this.send(ws, {
|
|
971
|
+
type: "error",
|
|
972
|
+
message: `Failed to list windows: ${err instanceof Error ? err.message : String(err)}`,
|
|
973
|
+
});
|
|
974
|
+
});
|
|
975
|
+
break;
|
|
976
|
+
}
|
|
977
|
+
case "take_screenshot": {
|
|
978
|
+
// For window mode, verify the window ID is still valid.
|
|
979
|
+
// The user may have fetched the window list minutes ago and the
|
|
980
|
+
// window could have been closed since then.
|
|
981
|
+
const doCapture = async () => {
|
|
982
|
+
if (msg.mode !== "window" || msg.windowId == null) {
|
|
983
|
+
return { mode: msg.mode };
|
|
984
|
+
}
|
|
985
|
+
const current = await listWindows();
|
|
986
|
+
if (current.some((w) => w.windowId === msg.windowId)) {
|
|
987
|
+
return { mode: "window", windowId: msg.windowId };
|
|
988
|
+
}
|
|
989
|
+
// Window ID is stale — fall back to fullscreen and notify
|
|
990
|
+
console.warn(`[screenshot] Window ID ${msg.windowId} no longer exists, falling back to fullscreen`);
|
|
991
|
+
return { mode: "fullscreen" };
|
|
992
|
+
};
|
|
993
|
+
doCapture()
|
|
994
|
+
.then((opts) => takeScreenshot(opts))
|
|
995
|
+
.then(async (result) => {
|
|
996
|
+
try {
|
|
997
|
+
if (this.galleryStore) {
|
|
998
|
+
const meta = await this.galleryStore.addImage(result.filePath, msg.projectPath, msg.sessionId);
|
|
999
|
+
if (meta) {
|
|
1000
|
+
const info = this.galleryStore.metaToInfo(meta);
|
|
1001
|
+
this.send(ws, { type: "screenshot_result", success: true, image: info });
|
|
1002
|
+
this.broadcast({ type: "gallery_new_image", image: info });
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
this.send(ws, {
|
|
1007
|
+
type: "screenshot_result",
|
|
1008
|
+
success: false,
|
|
1009
|
+
error: "Failed to save screenshot to gallery",
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
finally {
|
|
1013
|
+
// Always clean up temp file
|
|
1014
|
+
unlink(result.filePath).catch(() => { });
|
|
1015
|
+
}
|
|
1016
|
+
})
|
|
1017
|
+
.catch((err) => {
|
|
1018
|
+
this.send(ws, {
|
|
1019
|
+
type: "screenshot_result",
|
|
1020
|
+
success: false,
|
|
1021
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1022
|
+
});
|
|
1023
|
+
});
|
|
1024
|
+
break;
|
|
1025
|
+
}
|
|
1026
|
+
case "backup_prompt_history": {
|
|
1027
|
+
if (!this.promptHistoryBackup) {
|
|
1028
|
+
this.send(ws, { type: "prompt_history_backup_result", success: false, error: "Backup store not available" });
|
|
1029
|
+
break;
|
|
1030
|
+
}
|
|
1031
|
+
const buf = Buffer.from(msg.data, "base64");
|
|
1032
|
+
this.promptHistoryBackup.save(buf, msg.appVersion, msg.dbVersion).then((meta) => {
|
|
1033
|
+
this.send(ws, { type: "prompt_history_backup_result", success: true, backedUpAt: meta.backedUpAt });
|
|
1034
|
+
}).catch((err) => {
|
|
1035
|
+
this.send(ws, { type: "prompt_history_backup_result", success: false, error: err instanceof Error ? err.message : String(err) });
|
|
1036
|
+
});
|
|
1037
|
+
break;
|
|
1038
|
+
}
|
|
1039
|
+
case "restore_prompt_history": {
|
|
1040
|
+
if (!this.promptHistoryBackup) {
|
|
1041
|
+
this.send(ws, { type: "prompt_history_restore_result", success: false, error: "Backup store not available" });
|
|
1042
|
+
break;
|
|
1043
|
+
}
|
|
1044
|
+
this.promptHistoryBackup.load().then((result) => {
|
|
1045
|
+
if (result) {
|
|
1046
|
+
this.send(ws, {
|
|
1047
|
+
type: "prompt_history_restore_result",
|
|
1048
|
+
success: true,
|
|
1049
|
+
data: result.data.toString("base64"),
|
|
1050
|
+
appVersion: result.meta.appVersion,
|
|
1051
|
+
dbVersion: result.meta.dbVersion,
|
|
1052
|
+
backedUpAt: result.meta.backedUpAt,
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
else {
|
|
1056
|
+
this.send(ws, { type: "prompt_history_restore_result", success: false, error: "No backup found" });
|
|
1057
|
+
}
|
|
1058
|
+
}).catch((err) => {
|
|
1059
|
+
this.send(ws, { type: "prompt_history_restore_result", success: false, error: err instanceof Error ? err.message : String(err) });
|
|
1060
|
+
});
|
|
1061
|
+
break;
|
|
1062
|
+
}
|
|
1063
|
+
case "get_prompt_history_backup_info": {
|
|
1064
|
+
if (!this.promptHistoryBackup) {
|
|
1065
|
+
this.send(ws, { type: "prompt_history_backup_info", exists: false });
|
|
1066
|
+
break;
|
|
1067
|
+
}
|
|
1068
|
+
this.promptHistoryBackup.getMeta().then((meta) => {
|
|
1069
|
+
if (meta) {
|
|
1070
|
+
this.send(ws, { type: "prompt_history_backup_info", exists: true, ...meta });
|
|
1071
|
+
}
|
|
1072
|
+
else {
|
|
1073
|
+
this.send(ws, { type: "prompt_history_backup_info", exists: false });
|
|
1074
|
+
}
|
|
1075
|
+
}).catch(() => {
|
|
1076
|
+
this.send(ws, { type: "prompt_history_backup_info", exists: false });
|
|
1077
|
+
});
|
|
1078
|
+
break;
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
resolveSession(sessionId) {
|
|
1083
|
+
if (sessionId)
|
|
1084
|
+
return this.sessionManager.get(sessionId);
|
|
1085
|
+
return this.getFirstSession();
|
|
1086
|
+
}
|
|
1087
|
+
getFirstSession() {
|
|
1088
|
+
const sessions = this.sessionManager.list();
|
|
1089
|
+
if (sessions.length === 0)
|
|
1090
|
+
return undefined;
|
|
1091
|
+
return this.sessionManager.get(sessions[sessions.length - 1].id);
|
|
1092
|
+
}
|
|
1093
|
+
sendSessionList(ws) {
|
|
1094
|
+
this.pruneDebugEvents();
|
|
1095
|
+
const sessions = this.sessionManager.list();
|
|
1096
|
+
this.send(ws, { type: "session_list", sessions });
|
|
1097
|
+
}
|
|
1098
|
+
/** Broadcast session list to all connected clients. */
|
|
1099
|
+
broadcastSessionList() {
|
|
1100
|
+
this.pruneDebugEvents();
|
|
1101
|
+
const sessions = this.sessionManager.list();
|
|
1102
|
+
this.broadcast({ type: "session_list", sessions });
|
|
1103
|
+
}
|
|
1104
|
+
broadcastSessionMessage(sessionId, msg) {
|
|
1105
|
+
this.maybeSendPushNotification(sessionId, msg);
|
|
1106
|
+
this.recordDebugEvent(sessionId, {
|
|
1107
|
+
direction: "outgoing",
|
|
1108
|
+
channel: "session",
|
|
1109
|
+
type: msg.type,
|
|
1110
|
+
detail: this.summarizeServerMessage(msg),
|
|
1111
|
+
});
|
|
1112
|
+
this.recordingStore.record(sessionId, "outgoing", msg);
|
|
1113
|
+
// Update recording meta with claudeSessionId when it becomes available
|
|
1114
|
+
if ((msg.type === "system" || msg.type === "result") && "sessionId" in msg && msg.sessionId) {
|
|
1115
|
+
const session = this.sessionManager.get(sessionId);
|
|
1116
|
+
if (session) {
|
|
1117
|
+
this.recordingStore.saveMeta(sessionId, {
|
|
1118
|
+
bridgeSessionId: sessionId,
|
|
1119
|
+
claudeSessionId: msg.sessionId,
|
|
1120
|
+
projectPath: session.projectPath,
|
|
1121
|
+
createdAt: session.createdAt.toISOString(),
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
// Wrap the message with sessionId
|
|
1126
|
+
const data = JSON.stringify({ ...msg, sessionId });
|
|
1127
|
+
for (const client of this.wss.clients) {
|
|
1128
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
1129
|
+
client.send(data);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
/** Extract a short project label from the full projectPath (last directory name). */
|
|
1134
|
+
projectLabel(sessionId) {
|
|
1135
|
+
const session = this.sessionManager.get(sessionId);
|
|
1136
|
+
if (!session?.projectPath)
|
|
1137
|
+
return "";
|
|
1138
|
+
const parts = session.projectPath.replace(/\/+$/, "").split("/");
|
|
1139
|
+
return parts[parts.length - 1] || "";
|
|
1140
|
+
}
|
|
1141
|
+
maybeSendPushNotification(sessionId, msg) {
|
|
1142
|
+
if (!this.pushRelay.isConfigured)
|
|
1143
|
+
return;
|
|
1144
|
+
const project = this.projectLabel(sessionId);
|
|
1145
|
+
if (msg.type === "permission_request") {
|
|
1146
|
+
const seen = this.notifiedPermissionToolUses.get(sessionId) ?? new Set();
|
|
1147
|
+
if (seen.has(msg.toolUseId))
|
|
1148
|
+
return;
|
|
1149
|
+
seen.add(msg.toolUseId);
|
|
1150
|
+
this.notifiedPermissionToolUses.set(sessionId, seen);
|
|
1151
|
+
const isAskUserQuestion = msg.toolName === "AskUserQuestion";
|
|
1152
|
+
const eventType = isAskUserQuestion ? "ask_user_question" : "approval_required";
|
|
1153
|
+
const title = project
|
|
1154
|
+
? (isAskUserQuestion ? `回答待ち - ${project}` : `承認待ち - ${project}`)
|
|
1155
|
+
: (isAskUserQuestion ? "回答待ち" : "承認待ち");
|
|
1156
|
+
let body;
|
|
1157
|
+
if (isAskUserQuestion) {
|
|
1158
|
+
// Extract question text from input.questions[0].question
|
|
1159
|
+
const questions = msg.input?.questions;
|
|
1160
|
+
const firstQuestion = Array.isArray(questions) && questions.length > 0
|
|
1161
|
+
? questions[0]?.question
|
|
1162
|
+
: undefined;
|
|
1163
|
+
body = typeof firstQuestion === "string" && firstQuestion.length > 0
|
|
1164
|
+
? firstQuestion.slice(0, 120)
|
|
1165
|
+
: "Claude が質問しています";
|
|
1166
|
+
}
|
|
1167
|
+
else {
|
|
1168
|
+
body = `${msg.toolName} の実行を承認してください`;
|
|
1169
|
+
}
|
|
1170
|
+
void this.pushRelay.notify({
|
|
1171
|
+
eventType,
|
|
1172
|
+
title,
|
|
1173
|
+
body,
|
|
1174
|
+
data: {
|
|
1175
|
+
sessionId,
|
|
1176
|
+
provider: this.sessionManager.get(sessionId)?.provider ?? "claude",
|
|
1177
|
+
toolUseId: msg.toolUseId,
|
|
1178
|
+
toolName: msg.toolName,
|
|
1179
|
+
},
|
|
1180
|
+
}).catch((err) => {
|
|
1181
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
1182
|
+
console.warn(`[ws] Failed to send push notification (${eventType}): ${detail}`);
|
|
1183
|
+
});
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
if (msg.type !== "result")
|
|
1187
|
+
return;
|
|
1188
|
+
if (msg.subtype === "stopped")
|
|
1189
|
+
return;
|
|
1190
|
+
if (msg.subtype !== "success" && msg.subtype !== "error")
|
|
1191
|
+
return;
|
|
1192
|
+
const isSuccess = msg.subtype === "success";
|
|
1193
|
+
const eventType = isSuccess ? "session_completed" : "session_failed";
|
|
1194
|
+
const title = project
|
|
1195
|
+
? (isSuccess ? `✅ ${project}` : `❌ ${project}`)
|
|
1196
|
+
: (isSuccess ? "タスク完了" : "エラー発生");
|
|
1197
|
+
let body;
|
|
1198
|
+
if (isSuccess) {
|
|
1199
|
+
const pieces = [];
|
|
1200
|
+
if (msg.duration != null)
|
|
1201
|
+
pieces.push(`${msg.duration.toFixed(1)}s`);
|
|
1202
|
+
if (msg.cost != null)
|
|
1203
|
+
pieces.push(`$${msg.cost.toFixed(4)}`);
|
|
1204
|
+
const stats = pieces.length > 0 ? ` (${pieces.join(", ")})` : "";
|
|
1205
|
+
body = msg.result
|
|
1206
|
+
? `${msg.result.slice(0, 120)}${stats}`
|
|
1207
|
+
: `セッション完了${stats}`;
|
|
1208
|
+
}
|
|
1209
|
+
else {
|
|
1210
|
+
body = msg.error ? msg.error.slice(0, 120) : "セッションが失敗しました";
|
|
1211
|
+
}
|
|
1212
|
+
const data = {
|
|
1213
|
+
sessionId,
|
|
1214
|
+
provider: this.sessionManager.get(sessionId)?.provider ?? "claude",
|
|
1215
|
+
subtype: msg.subtype,
|
|
1216
|
+
};
|
|
1217
|
+
if (msg.stopReason)
|
|
1218
|
+
data.stopReason = msg.stopReason;
|
|
1219
|
+
if (msg.sessionId)
|
|
1220
|
+
data.providerSessionId = msg.sessionId;
|
|
1221
|
+
void this.pushRelay.notify({
|
|
1222
|
+
eventType,
|
|
1223
|
+
title,
|
|
1224
|
+
body,
|
|
1225
|
+
data,
|
|
1226
|
+
}).catch((err) => {
|
|
1227
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
1228
|
+
console.warn(`[ws] Failed to send push notification (${eventType}): ${detail}`);
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
broadcast(msg) {
|
|
1232
|
+
const data = JSON.stringify(msg);
|
|
1233
|
+
for (const client of this.wss.clients) {
|
|
1234
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
1235
|
+
client.send(data);
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
send(ws, msg) {
|
|
1240
|
+
const sessionId = this.extractSessionIdFromServerMessage(msg);
|
|
1241
|
+
if (sessionId) {
|
|
1242
|
+
this.recordDebugEvent(sessionId, {
|
|
1243
|
+
direction: "outgoing",
|
|
1244
|
+
channel: "ws",
|
|
1245
|
+
type: String(msg.type ?? "unknown"),
|
|
1246
|
+
detail: this.summarizeOutboundMessage(msg),
|
|
1247
|
+
});
|
|
1248
|
+
}
|
|
1249
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
1250
|
+
ws.send(JSON.stringify(msg));
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
/** Broadcast a gallery_new_image message to all connected clients. */
|
|
1254
|
+
broadcastGalleryNewImage(image) {
|
|
1255
|
+
this.broadcast({ type: "gallery_new_image", image });
|
|
1256
|
+
}
|
|
1257
|
+
collectGitDiff(cwd, callback) {
|
|
1258
|
+
const execOpts = { cwd, maxBuffer: 10 * 1024 * 1024 };
|
|
1259
|
+
// Collect untracked files so they appear in the diff.
|
|
1260
|
+
let untrackedFiles = [];
|
|
1261
|
+
try {
|
|
1262
|
+
const out = execFileSync("git", ["ls-files", "--others", "--exclude-standard"], { cwd }).toString().trim();
|
|
1263
|
+
untrackedFiles = out ? out.split("\n") : [];
|
|
1264
|
+
}
|
|
1265
|
+
catch {
|
|
1266
|
+
// Ignore errors: non-git directories are handled by git diff callback.
|
|
1267
|
+
}
|
|
1268
|
+
// Temporarily stage untracked files with --intent-to-add.
|
|
1269
|
+
if (untrackedFiles.length > 0) {
|
|
1270
|
+
try {
|
|
1271
|
+
execFileSync("git", ["add", "--intent-to-add", ...untrackedFiles], { cwd });
|
|
1272
|
+
}
|
|
1273
|
+
catch {
|
|
1274
|
+
// Ignore staging errors.
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
execFile("git", ["diff", "--no-color"], execOpts, (err, stdout) => {
|
|
1278
|
+
// Revert intent-to-add for untracked files.
|
|
1279
|
+
if (untrackedFiles.length > 0) {
|
|
1280
|
+
try {
|
|
1281
|
+
execFileSync("git", ["reset", "--", ...untrackedFiles], { cwd });
|
|
1282
|
+
}
|
|
1283
|
+
catch {
|
|
1284
|
+
// Ignore reset errors.
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
if (err) {
|
|
1288
|
+
callback({ diff: "", error: err.message });
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
callback({ diff: stdout });
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
extractSessionIdFromClientMessage(msg) {
|
|
1295
|
+
return "sessionId" in msg && typeof msg.sessionId === "string" ? msg.sessionId : undefined;
|
|
1296
|
+
}
|
|
1297
|
+
extractSessionIdFromServerMessage(msg) {
|
|
1298
|
+
if ("sessionId" in msg && typeof msg.sessionId === "string")
|
|
1299
|
+
return msg.sessionId;
|
|
1300
|
+
return undefined;
|
|
1301
|
+
}
|
|
1302
|
+
recordDebugEvent(sessionId, event) {
|
|
1303
|
+
const events = this.debugEvents.get(sessionId) ?? [];
|
|
1304
|
+
const fullEvent = {
|
|
1305
|
+
ts: new Date().toISOString(),
|
|
1306
|
+
sessionId,
|
|
1307
|
+
...event,
|
|
1308
|
+
};
|
|
1309
|
+
events.push(fullEvent);
|
|
1310
|
+
if (events.length > BridgeWebSocketServer.MAX_DEBUG_EVENTS) {
|
|
1311
|
+
events.splice(0, events.length - BridgeWebSocketServer.MAX_DEBUG_EVENTS);
|
|
1312
|
+
}
|
|
1313
|
+
this.debugEvents.set(sessionId, events);
|
|
1314
|
+
this.debugTraceStore.record(fullEvent);
|
|
1315
|
+
}
|
|
1316
|
+
getDebugEvents(sessionId, limit) {
|
|
1317
|
+
const events = this.debugEvents.get(sessionId) ?? [];
|
|
1318
|
+
const capped = Math.max(0, Math.min(limit, BridgeWebSocketServer.MAX_DEBUG_EVENTS));
|
|
1319
|
+
if (capped === 0)
|
|
1320
|
+
return [];
|
|
1321
|
+
return events.slice(-capped);
|
|
1322
|
+
}
|
|
1323
|
+
buildHistorySummary(history) {
|
|
1324
|
+
const lines = history
|
|
1325
|
+
.map((msg, index) => {
|
|
1326
|
+
const num = String(index + 1).padStart(3, "0");
|
|
1327
|
+
return `${num}. ${this.summarizeServerMessage(msg)}`;
|
|
1328
|
+
});
|
|
1329
|
+
if (lines.length <= BridgeWebSocketServer.MAX_HISTORY_SUMMARY_ITEMS) {
|
|
1330
|
+
return lines;
|
|
1331
|
+
}
|
|
1332
|
+
return lines.slice(-BridgeWebSocketServer.MAX_HISTORY_SUMMARY_ITEMS);
|
|
1333
|
+
}
|
|
1334
|
+
summarizeClientMessage(msg) {
|
|
1335
|
+
switch (msg.type) {
|
|
1336
|
+
case "input": {
|
|
1337
|
+
const textPreview = msg.text.replace(/\s+/g, " ").trim().slice(0, 80);
|
|
1338
|
+
const hasImage = msg.imageBase64 != null || msg.imageId != null;
|
|
1339
|
+
return `text=\"${textPreview}\" image=${hasImage}`;
|
|
1340
|
+
}
|
|
1341
|
+
case "push_register":
|
|
1342
|
+
return `platform=${msg.platform} token=${msg.token.slice(0, 8)}...`;
|
|
1343
|
+
case "push_unregister":
|
|
1344
|
+
return `token=${msg.token.slice(0, 8)}...`;
|
|
1345
|
+
case "approve":
|
|
1346
|
+
case "approve_always":
|
|
1347
|
+
case "reject":
|
|
1348
|
+
return `id=${msg.id}`;
|
|
1349
|
+
case "answer":
|
|
1350
|
+
return `toolUseId=${msg.toolUseId}`;
|
|
1351
|
+
case "start":
|
|
1352
|
+
return `projectPath=${msg.projectPath} provider=${msg.provider ?? "claude"}`;
|
|
1353
|
+
case "resume_session":
|
|
1354
|
+
return `sessionId=${msg.sessionId} provider=${msg.provider ?? "claude"}`;
|
|
1355
|
+
case "get_debug_bundle":
|
|
1356
|
+
return `traceLimit=${msg.traceLimit ?? BridgeWebSocketServer.MAX_DEBUG_EVENTS} includeDiff=${msg.includeDiff ?? true}`;
|
|
1357
|
+
case "get_usage":
|
|
1358
|
+
return "get_usage";
|
|
1359
|
+
default:
|
|
1360
|
+
return msg.type;
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
summarizeServerMessage(msg) {
|
|
1364
|
+
switch (msg.type) {
|
|
1365
|
+
case "assistant": {
|
|
1366
|
+
const textChunks = [];
|
|
1367
|
+
for (const content of msg.message.content) {
|
|
1368
|
+
if (content.type === "text") {
|
|
1369
|
+
textChunks.push(content.text);
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
const text = textChunks
|
|
1373
|
+
.join(" ")
|
|
1374
|
+
.replace(/\s+/g, " ")
|
|
1375
|
+
.trim()
|
|
1376
|
+
.slice(0, 100);
|
|
1377
|
+
return text ? `assistant: ${text}` : "assistant";
|
|
1378
|
+
}
|
|
1379
|
+
case "tool_result": {
|
|
1380
|
+
const contentPreview = msg.content.replace(/\s+/g, " ").trim().slice(0, 100);
|
|
1381
|
+
return `${msg.toolName ?? "tool_result"}(${msg.toolUseId}) ${contentPreview}`;
|
|
1382
|
+
}
|
|
1383
|
+
case "permission_request":
|
|
1384
|
+
return `${msg.toolName}(${msg.toolUseId})`;
|
|
1385
|
+
case "result":
|
|
1386
|
+
return `${msg.subtype}${msg.error ? ` error=${msg.error}` : ""}`;
|
|
1387
|
+
case "status":
|
|
1388
|
+
return msg.status;
|
|
1389
|
+
case "error":
|
|
1390
|
+
return msg.message;
|
|
1391
|
+
case "stream_delta":
|
|
1392
|
+
case "thinking_delta":
|
|
1393
|
+
return `${msg.type}(${msg.text.length})`;
|
|
1394
|
+
default:
|
|
1395
|
+
return msg.type;
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
summarizeOutboundMessage(msg) {
|
|
1399
|
+
if ("type" in msg && typeof msg.type === "string") {
|
|
1400
|
+
return msg.type;
|
|
1401
|
+
}
|
|
1402
|
+
return "message";
|
|
1403
|
+
}
|
|
1404
|
+
pruneDebugEvents() {
|
|
1405
|
+
const active = new Set(this.sessionManager.list().map((s) => s.id));
|
|
1406
|
+
for (const sessionId of this.debugEvents.keys()) {
|
|
1407
|
+
if (!active.has(sessionId)) {
|
|
1408
|
+
this.debugEvents.delete(sessionId);
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
for (const sessionId of this.notifiedPermissionToolUses.keys()) {
|
|
1412
|
+
if (!active.has(sessionId)) {
|
|
1413
|
+
this.notifiedPermissionToolUses.delete(sessionId);
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
buildReproRecipe(session, traceLimit, includeDiff) {
|
|
1418
|
+
const bridgePort = process.env.BRIDGE_PORT ?? "8765";
|
|
1419
|
+
const wsUrlHint = `ws://localhost:${bridgePort}`;
|
|
1420
|
+
const notes = [
|
|
1421
|
+
"1) Connect with wsUrlHint and send resumeSessionMessage.",
|
|
1422
|
+
"2) Read session_created.sessionId from server response.",
|
|
1423
|
+
"3) Replace <runtime_session_id> in getHistoryMessage/getDebugBundleMessage and send them.",
|
|
1424
|
+
"4) Compare history/debugTrace/diff with the saved bundle snapshot.",
|
|
1425
|
+
];
|
|
1426
|
+
if (!session.claudeSessionId) {
|
|
1427
|
+
notes.push("claudeSessionId is not available yet. Use list_recent_sessions to pick the right session id.");
|
|
1428
|
+
}
|
|
1429
|
+
return {
|
|
1430
|
+
wsUrlHint,
|
|
1431
|
+
startBridgeCommand: `BRIDGE_PORT=${bridgePort} npm run bridge`,
|
|
1432
|
+
resumeSessionMessage: this.buildResumeSessionMessage(session),
|
|
1433
|
+
getHistoryMessage: {
|
|
1434
|
+
type: "get_history",
|
|
1435
|
+
sessionId: "<runtime_session_id>",
|
|
1436
|
+
},
|
|
1437
|
+
getDebugBundleMessage: {
|
|
1438
|
+
type: "get_debug_bundle",
|
|
1439
|
+
sessionId: "<runtime_session_id>",
|
|
1440
|
+
traceLimit,
|
|
1441
|
+
includeDiff,
|
|
1442
|
+
},
|
|
1443
|
+
notes,
|
|
1444
|
+
};
|
|
1445
|
+
}
|
|
1446
|
+
buildResumeSessionMessage(session) {
|
|
1447
|
+
const msg = {
|
|
1448
|
+
type: "resume_session",
|
|
1449
|
+
sessionId: session.claudeSessionId ?? "<session_id_from_recent_sessions>",
|
|
1450
|
+
projectPath: session.projectPath,
|
|
1451
|
+
provider: session.provider,
|
|
1452
|
+
};
|
|
1453
|
+
if (session.provider === "codex" && session.codexSettings) {
|
|
1454
|
+
if (session.codexSettings.approvalPolicy !== undefined) {
|
|
1455
|
+
msg.approvalPolicy = session.codexSettings.approvalPolicy;
|
|
1456
|
+
}
|
|
1457
|
+
if (session.codexSettings.sandboxMode !== undefined) {
|
|
1458
|
+
msg.sandboxMode = session.codexSettings.sandboxMode;
|
|
1459
|
+
}
|
|
1460
|
+
if (session.codexSettings.model !== undefined) {
|
|
1461
|
+
msg.model = session.codexSettings.model;
|
|
1462
|
+
}
|
|
1463
|
+
if (session.codexSettings.modelReasoningEffort !== undefined) {
|
|
1464
|
+
msg.modelReasoningEffort = session.codexSettings.modelReasoningEffort;
|
|
1465
|
+
}
|
|
1466
|
+
if (session.codexSettings.networkAccessEnabled !== undefined) {
|
|
1467
|
+
msg.networkAccessEnabled = session.codexSettings.networkAccessEnabled;
|
|
1468
|
+
}
|
|
1469
|
+
if (session.codexSettings.webSearchMode !== undefined) {
|
|
1470
|
+
msg.webSearchMode = session.codexSettings.webSearchMode;
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
return msg;
|
|
1474
|
+
}
|
|
1475
|
+
buildAgentPrompt(session) {
|
|
1476
|
+
return [
|
|
1477
|
+
"Use this ccpocket debug bundle to investigate a chat-screen bug.",
|
|
1478
|
+
`Target provider: ${session.provider}`,
|
|
1479
|
+
`Project path: ${session.projectPath}`,
|
|
1480
|
+
"Required output:",
|
|
1481
|
+
"1) Timeline analysis from historySummary + debugTrace.",
|
|
1482
|
+
"2) Top 1-3 root-cause hypotheses with confidence.",
|
|
1483
|
+
"3) Concrete validation steps and the minimum extra logs needed.",
|
|
1484
|
+
].join("\n");
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
//# sourceMappingURL=websocket.js.map
|