@ccpocket/bridge 1.6.1 → 1.8.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/dist/websocket.js CHANGED
@@ -1,5 +1,8 @@
1
1
  import { execFile, execFileSync } from "node:child_process";
2
- import { unlink } from "node:fs/promises";
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";
3
6
  import { WebSocketServer, WebSocket } from "ws";
4
7
  import { SessionManager } from "./session.js";
5
8
  import { SdkProcess } from "./sdk-process.js";
@@ -7,7 +10,7 @@ import { parseClientMessage } from "./parser.js";
7
10
  import { getAllRecentSessions, getCodexSessionHistory, getSessionHistory, findSessionsByClaudeIds, extractMessageImages, getClaudeSessionName, loadCodexSessionNames, renameClaudeSession, renameCodexSession } from "./sessions-index.js";
8
11
  import { ArchiveStore } from "./archive-store.js";
9
12
  import { WorktreeStore } from "./worktree-store.js";
10
- import { listWorktrees, removeWorktree, worktreeExists } from "./worktree.js";
13
+ import { listWorktrees, removeWorktree, worktreeExists, getMainBranch } from "./worktree.js";
11
14
  import { listWindows, takeScreenshot } from "./screenshot.js";
12
15
  import { DebugTraceStore } from "./debug-trace-store.js";
13
16
  import { RecordingStore } from "./recording-store.js";
@@ -151,70 +154,76 @@ export class BridgeWebSocketServer {
151
154
  }
152
155
  switch (msg.type) {
153
156
  case "start": {
154
- const provider = msg.provider ?? "claude";
155
- if (provider === "codex") {
156
- console.log(`[ws] start(codex): permissionMode=${msg.permissionMode} → collaboration=${msg.permissionMode === "plan" ? "plan" : "default"}`);
157
- }
158
- const cached = provider === "claude" ? this.sessionManager.getCachedCommands(msg.projectPath) : undefined;
159
- const sessionId = this.sessionManager.create(msg.projectPath, {
160
- sessionId: msg.sessionId,
161
- continueMode: msg.continue,
162
- permissionMode: msg.permissionMode,
163
- model: msg.model,
164
- effort: msg.effort,
165
- maxTurns: msg.maxTurns,
166
- maxBudgetUsd: msg.maxBudgetUsd,
167
- fallbackModel: msg.fallbackModel,
168
- forkSession: msg.forkSession,
169
- persistSession: msg.persistSession,
170
- }, undefined, {
171
- useWorktree: msg.useWorktree,
172
- worktreeBranch: msg.worktreeBranch,
173
- existingWorktreePath: msg.existingWorktreePath,
174
- }, provider, provider === "codex"
175
- ? {
176
- approvalPolicy: permissionModeToApprovalPolicy(msg.permissionMode),
177
- sandboxMode: sandboxModeToInternal(msg.sandboxMode),
178
- model: msg.model,
179
- modelReasoningEffort: msg.modelReasoningEffort ?? undefined,
180
- networkAccessEnabled: msg.networkAccessEnabled,
181
- webSearchMode: msg.webSearchMode ?? undefined,
182
- threadId: msg.sessionId,
183
- collaborationMode: msg.permissionMode === "plan" ? "plan" : "default",
157
+ try {
158
+ const provider = msg.provider ?? "claude";
159
+ if (provider === "codex") {
160
+ console.log(`[ws] start(codex): permissionMode=${msg.permissionMode} → collaboration=${msg.permissionMode === "plan" ? "plan" : "default"}`);
184
161
  }
185
- : undefined);
186
- const createdSession = this.sessionManager.get(sessionId);
187
- // Load saved session name from CLI storage (for resumed sessions)
188
- void this.loadAndSetSessionName(createdSession, provider, msg.projectPath, msg.sessionId).then(() => {
189
- this.send(ws, {
190
- type: "system",
191
- subtype: "session_created",
192
- sessionId,
193
- provider,
162
+ const cached = provider === "claude" ? this.sessionManager.getCachedCommands(msg.projectPath) : undefined;
163
+ const sessionId = this.sessionManager.create(msg.projectPath, {
164
+ sessionId: msg.sessionId,
165
+ continueMode: msg.continue,
166
+ permissionMode: msg.permissionMode,
167
+ model: msg.model,
168
+ effort: msg.effort,
169
+ maxTurns: msg.maxTurns,
170
+ maxBudgetUsd: msg.maxBudgetUsd,
171
+ fallbackModel: msg.fallbackModel,
172
+ forkSession: msg.forkSession,
173
+ persistSession: msg.persistSession,
174
+ }, undefined, {
175
+ useWorktree: msg.useWorktree,
176
+ worktreeBranch: msg.worktreeBranch,
177
+ existingWorktreePath: msg.existingWorktreePath,
178
+ }, provider, provider === "codex"
179
+ ? {
180
+ approvalPolicy: permissionModeToApprovalPolicy(msg.permissionMode),
181
+ sandboxMode: sandboxModeToInternal(msg.sandboxMode),
182
+ model: msg.model,
183
+ modelReasoningEffort: msg.modelReasoningEffort ?? undefined,
184
+ networkAccessEnabled: msg.networkAccessEnabled,
185
+ webSearchMode: msg.webSearchMode ?? undefined,
186
+ threadId: msg.sessionId,
187
+ collaborationMode: msg.permissionMode === "plan" ? "plan" : "default",
188
+ }
189
+ : undefined);
190
+ const createdSession = this.sessionManager.get(sessionId);
191
+ // Load saved session name from CLI storage (for resumed sessions)
192
+ void this.loadAndSetSessionName(createdSession, provider, msg.projectPath, msg.sessionId).then(() => {
193
+ this.send(ws, {
194
+ type: "system",
195
+ subtype: "session_created",
196
+ sessionId,
197
+ provider,
198
+ projectPath: msg.projectPath,
199
+ ...(provider === "claude" && msg.permissionMode ? { permissionMode: msg.permissionMode } : {}),
200
+ ...(provider === "codex" && msg.sandboxMode ? { sandboxMode: msg.sandboxMode } : {}),
201
+ ...(cached ? { slashCommands: cached.slashCommands, skills: cached.skills } : {}),
202
+ ...(createdSession?.worktreePath ? {
203
+ worktreePath: createdSession.worktreePath,
204
+ worktreeBranch: createdSession.worktreeBranch,
205
+ } : {}),
206
+ });
207
+ this.broadcastSessionList();
208
+ });
209
+ this.debugEvents.set(sessionId, []);
210
+ this.recordDebugEvent(sessionId, {
211
+ direction: "internal",
212
+ channel: "bridge",
213
+ type: "session_created",
214
+ detail: `provider=${provider} projectPath=${msg.projectPath}`,
215
+ });
216
+ this.recordingStore.saveMeta(sessionId, {
217
+ bridgeSessionId: sessionId,
194
218
  projectPath: msg.projectPath,
195
- ...(provider === "claude" && msg.permissionMode ? { permissionMode: msg.permissionMode } : {}),
196
- ...(provider === "codex" && msg.sandboxMode ? { sandboxMode: msg.sandboxMode } : {}),
197
- ...(cached ? { slashCommands: cached.slashCommands, skills: cached.skills } : {}),
198
- ...(createdSession?.worktreePath ? {
199
- worktreePath: createdSession.worktreePath,
200
- worktreeBranch: createdSession.worktreeBranch,
201
- } : {}),
219
+ createdAt: new Date().toISOString(),
202
220
  });
203
- this.broadcastSessionList();
204
- });
205
- this.debugEvents.set(sessionId, []);
206
- this.recordDebugEvent(sessionId, {
207
- direction: "internal",
208
- channel: "bridge",
209
- type: "session_created",
210
- detail: `provider=${provider} projectPath=${msg.projectPath}`,
211
- });
212
- this.recordingStore.saveMeta(sessionId, {
213
- bridgeSessionId: sessionId,
214
- projectPath: msg.projectPath,
215
- createdAt: new Date().toISOString(),
216
- });
217
- this.projectHistory?.addProject(msg.projectPath);
221
+ this.projectHistory?.addProject(msg.projectPath);
222
+ }
223
+ catch (err) {
224
+ console.error(`[ws] Failed to start session:`, err);
225
+ this.send(ws, { type: "error", message: `Failed to start session: ${err.message}` });
226
+ }
218
227
  break;
219
228
  }
220
229
  case "input": {
@@ -959,14 +968,60 @@ export class BridgeWebSocketServer {
959
968
  this.send(ws, { type: "diff_result", diff: "", error: `Failed to get diff: ${error}` });
960
969
  return;
961
970
  }
962
- this.send(ws, { type: "diff_result", diff });
971
+ void this.collectImageChanges(msg.projectPath, diff).then((imageChanges) => {
972
+ if (imageChanges.length > 0) {
973
+ this.send(ws, { type: "diff_result", diff, imageChanges });
974
+ }
975
+ else {
976
+ this.send(ws, { type: "diff_result", diff });
977
+ }
978
+ });
963
979
  });
964
980
  break;
965
981
  }
982
+ case "get_diff_image": {
983
+ if (msg.version === "both") {
984
+ void (async () => {
985
+ try {
986
+ const [oldResult, newResult] = await Promise.all([
987
+ this.loadDiffImageAsync(msg.projectPath, msg.filePath, "old"),
988
+ this.loadDiffImageAsync(msg.projectPath, msg.filePath, "new"),
989
+ ]);
990
+ const errors = [oldResult.error, newResult.error].filter(Boolean);
991
+ this.send(ws, {
992
+ type: "diff_image_result",
993
+ filePath: msg.filePath,
994
+ version: "both",
995
+ oldBase64: oldResult.base64,
996
+ newBase64: newResult.base64,
997
+ mimeType: oldResult.mimeType ?? newResult.mimeType,
998
+ ...(errors.length > 0 ? { error: errors.join("; ") } : {}),
999
+ });
1000
+ }
1001
+ catch {
1002
+ // WebSocket may have closed; ignore send errors.
1003
+ }
1004
+ })();
1005
+ }
1006
+ else {
1007
+ const version = msg.version;
1008
+ void (async () => {
1009
+ try {
1010
+ const result = await this.loadDiffImageAsync(msg.projectPath, msg.filePath, version);
1011
+ this.send(ws, { type: "diff_image_result", filePath: msg.filePath, version, ...result });
1012
+ }
1013
+ catch {
1014
+ // WebSocket may have closed; ignore send errors.
1015
+ }
1016
+ })();
1017
+ }
1018
+ break;
1019
+ }
966
1020
  case "list_worktrees": {
967
1021
  try {
968
1022
  const worktrees = listWorktrees(msg.projectPath);
969
- this.send(ws, { type: "worktree_list", worktrees });
1023
+ const mainBranch = getMainBranch(msg.projectPath);
1024
+ this.send(ws, { type: "worktree_list", worktrees, mainBranch });
970
1025
  }
971
1026
  catch (err) {
972
1027
  this.send(ws, { type: "error", message: `Failed to list worktrees: ${err}` });
@@ -1542,6 +1597,183 @@ export class BridgeWebSocketServer {
1542
1597
  callback({ diff: stdout });
1543
1598
  });
1544
1599
  }
1600
+ // ---------------------------------------------------------------------------
1601
+ // Image diff helpers
1602
+ // ---------------------------------------------------------------------------
1603
+ static IMAGE_EXTENSIONS = new Set([
1604
+ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".bmp", ".svg",
1605
+ ]);
1606
+ // Image diff thresholds (configurable via environment variables)
1607
+ // - Auto-display: images ≤ threshold are sent inline as base64
1608
+ // - Max size: images ≤ max are available for on-demand loading
1609
+ // - Images > max size show text info only
1610
+ static AUTO_DISPLAY_THRESHOLD = (() => {
1611
+ const kb = parseInt(process.env.DIFF_IMAGE_AUTO_DISPLAY_KB ?? "", 10);
1612
+ return Number.isFinite(kb) && kb > 0 ? kb * 1024 : 1024 * 1024; // default 1 MB
1613
+ })();
1614
+ static MAX_IMAGE_SIZE = (() => {
1615
+ const mb = parseInt(process.env.DIFF_IMAGE_MAX_SIZE_MB ?? "", 10);
1616
+ return Number.isFinite(mb) && mb > 0 ? mb * 1024 * 1024 : 5 * 1024 * 1024; // default 5 MB
1617
+ })();
1618
+ static mimeTypeForExt(ext) {
1619
+ const map = {
1620
+ ".png": "image/png",
1621
+ ".jpg": "image/jpeg",
1622
+ ".jpeg": "image/jpeg",
1623
+ ".gif": "image/gif",
1624
+ ".webp": "image/webp",
1625
+ ".ico": "image/x-icon",
1626
+ ".bmp": "image/bmp",
1627
+ ".svg": "image/svg+xml",
1628
+ };
1629
+ return map[ext.toLowerCase()] ?? "application/octet-stream";
1630
+ }
1631
+ /**
1632
+ * Scan diff text for image file changes and extract base64 data where appropriate.
1633
+ *
1634
+ * Detection strategy:
1635
+ * 1. Binary markers: "Binary files a/<path> and b/<path> differ"
1636
+ * 2. diff --git headers where the file extension is an image type
1637
+ *
1638
+ * For each detected image file:
1639
+ * - Old version: `git show HEAD:<path>` (committed version)
1640
+ * - New version: read from working tree
1641
+ * - Apply size thresholds for auto-display / on-demand / text-only
1642
+ */
1643
+ async collectImageChanges(cwd, diffText) {
1644
+ const entries = [];
1645
+ const processedPaths = new Set();
1646
+ const lines = diffText.split("\n");
1647
+ for (let i = 0; i < lines.length; i++) {
1648
+ const line = lines[i];
1649
+ const gitMatch = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
1650
+ if (!gitMatch)
1651
+ continue;
1652
+ const filePath = gitMatch[2];
1653
+ const ext = extname(filePath).toLowerCase();
1654
+ if (!BridgeWebSocketServer.IMAGE_EXTENSIONS.has(ext))
1655
+ continue;
1656
+ if (processedPaths.has(filePath))
1657
+ continue;
1658
+ processedPaths.add(filePath);
1659
+ let isNew = false;
1660
+ let isDeleted = false;
1661
+ for (let j = i + 1; j < Math.min(i + 6, lines.length); j++) {
1662
+ if (lines[j].startsWith("diff --git "))
1663
+ break;
1664
+ if (lines[j].startsWith("new file mode"))
1665
+ isNew = true;
1666
+ if (lines[j].startsWith("deleted file mode"))
1667
+ isDeleted = true;
1668
+ }
1669
+ entries.push({
1670
+ filePath,
1671
+ isNew,
1672
+ isDeleted,
1673
+ isSvg: ext === ".svg",
1674
+ mimeType: BridgeWebSocketServer.mimeTypeForExt(ext),
1675
+ ext,
1676
+ });
1677
+ }
1678
+ if (entries.length === 0)
1679
+ return [];
1680
+ // Phase 2: Read image data asynchronously
1681
+ const execFileAsync = promisify(execFile);
1682
+ const changes = [];
1683
+ for (const entry of entries) {
1684
+ let oldBuf;
1685
+ let newBuf;
1686
+ // Read old image (committed version)
1687
+ if (!entry.isNew) {
1688
+ try {
1689
+ const result = await execFileAsync("git", ["show", `HEAD:${entry.filePath}`], {
1690
+ cwd,
1691
+ maxBuffer: BridgeWebSocketServer.MAX_IMAGE_SIZE + 1024,
1692
+ encoding: "buffer",
1693
+ });
1694
+ oldBuf = result.stdout;
1695
+ }
1696
+ catch {
1697
+ // File may not exist in HEAD (e.g. untracked)
1698
+ }
1699
+ }
1700
+ // Read new image (working tree)
1701
+ if (!entry.isDeleted) {
1702
+ try {
1703
+ const absPath = resolve(cwd, entry.filePath);
1704
+ if (existsSync(absPath)) {
1705
+ newBuf = await readFile(absPath);
1706
+ }
1707
+ }
1708
+ catch {
1709
+ // Ignore read errors
1710
+ }
1711
+ }
1712
+ const oldSize = oldBuf?.length;
1713
+ const newSize = newBuf?.length;
1714
+ const maxSize = Math.max(oldSize ?? 0, newSize ?? 0);
1715
+ const autoDisplay = maxSize <= BridgeWebSocketServer.AUTO_DISPLAY_THRESHOLD;
1716
+ const loadable = autoDisplay || maxSize <= BridgeWebSocketServer.MAX_IMAGE_SIZE;
1717
+ const change = {
1718
+ filePath: entry.filePath,
1719
+ isNew: entry.isNew,
1720
+ isDeleted: entry.isDeleted,
1721
+ isSvg: entry.isSvg,
1722
+ mimeType: entry.mimeType,
1723
+ loadable,
1724
+ autoDisplay: autoDisplay || undefined,
1725
+ };
1726
+ if (oldSize !== undefined)
1727
+ change.oldSize = oldSize;
1728
+ if (newSize !== undefined)
1729
+ change.newSize = newSize;
1730
+ // Auto-display images are no longer embedded in the initial response.
1731
+ // They are loaded on-demand when the Flutter widget becomes visible.
1732
+ changes.push(change);
1733
+ }
1734
+ return changes;
1735
+ }
1736
+ /**
1737
+ * Load a single diff image on demand (async I/O for better throughput).
1738
+ */
1739
+ async loadDiffImageAsync(cwd, filePath, version) {
1740
+ // Path traversal guard: reject paths containing '..' or absolute paths
1741
+ if (filePath.includes("..") || filePath.startsWith("/")) {
1742
+ return { error: "Invalid file path" };
1743
+ }
1744
+ const ext = extname(filePath).toLowerCase();
1745
+ if (!BridgeWebSocketServer.IMAGE_EXTENSIONS.has(ext)) {
1746
+ return { error: "Not an image file" };
1747
+ }
1748
+ const mimeType = BridgeWebSocketServer.mimeTypeForExt(ext);
1749
+ try {
1750
+ const execFileAsync = promisify(execFile);
1751
+ let buf;
1752
+ if (version === "old") {
1753
+ const result = await execFileAsync("git", ["show", `HEAD:${filePath}`], {
1754
+ cwd,
1755
+ maxBuffer: BridgeWebSocketServer.MAX_IMAGE_SIZE + 1024,
1756
+ encoding: "buffer",
1757
+ });
1758
+ buf = result.stdout;
1759
+ }
1760
+ else {
1761
+ const absPath = resolve(cwd, filePath);
1762
+ // Verify resolved path stays within cwd
1763
+ if (!absPath.startsWith(resolve(cwd) + "/")) {
1764
+ return { error: "Invalid file path" };
1765
+ }
1766
+ buf = await readFile(absPath);
1767
+ }
1768
+ if (buf.length > BridgeWebSocketServer.MAX_IMAGE_SIZE) {
1769
+ return { error: "Image too large" };
1770
+ }
1771
+ return { base64: buf.toString("base64"), mimeType };
1772
+ }
1773
+ catch (err) {
1774
+ return { error: err instanceof Error ? err.message : String(err) };
1775
+ }
1776
+ }
1545
1777
  extractSessionIdFromClientMessage(msg) {
1546
1778
  return "sessionId" in msg && typeof msg.sessionId === "string" ? msg.sessionId : undefined;
1547
1779
  }