codex-webstrapper 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.
@@ -0,0 +1,1857 @@
1
+ import { spawn } from "node:child_process";
2
+ import { Worker } from "node:worker_threads";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { readFileSync } from "node:fs";
6
+ import fs from "node:fs/promises";
7
+
8
+ import { createLogger, randomId, safeJsonParse, toErrorMessage } from "./util.mjs";
9
+
10
+ export const FULL_HANDLING_BUCKET = [
11
+ "ready",
12
+ "fetch",
13
+ "cancel-fetch",
14
+ "fetch-stream",
15
+ "cancel-fetch-stream",
16
+ "mcp-request",
17
+ "mcp-response",
18
+ "mcp-notification",
19
+ "terminal-create",
20
+ "terminal-attach",
21
+ "terminal-write",
22
+ "terminal-resize",
23
+ "terminal-close",
24
+ "persisted-atom-sync-request",
25
+ "persisted-atom-update",
26
+ "persisted-atom-reset",
27
+ "shared-object-subscribe",
28
+ "shared-object-set",
29
+ "shared-object-unsubscribe",
30
+ "thread-archived",
31
+ "thread-unarchived",
32
+ "archive-thread",
33
+ "unarchive-thread",
34
+ "thread-stream-state-changed",
35
+ "thread-overlay-proxy-start-turn-request",
36
+ "thread-overlay-proxy-start-turn-response",
37
+ "thread-overlay-proxy-interrupt-request",
38
+ "thread-overlay-proxy-interrupt-response",
39
+ "worker-request",
40
+ "worker-request-cancel",
41
+ "set-telemetry-user",
42
+ "view-focused"
43
+ ];
44
+
45
+ export const BROWSER_EQUIVALENT_BUCKET = [
46
+ "open-in-browser",
47
+ "show-diff",
48
+ "show-plan-summary",
49
+ "navigate-in-new-editor-tab"
50
+ ];
51
+
52
+ export const GRACEFUL_UNSUPPORTED_BUCKET = [
53
+ "install-wsl",
54
+ "install-app-update",
55
+ "open-extension-settings",
56
+ "open-vscode-command",
57
+ "open-keyboard-shortcuts",
58
+ "open-debug-window",
59
+ "electron-request-microphone-permission"
60
+ ];
61
+
62
+ const NATIVE_UNSUPPORTED = new Set(GRACEFUL_UNSUPPORTED_BUCKET);
63
+ const IPC_BROADCAST_FORWARD_METHODS = new Set([
64
+ "thread-archived",
65
+ "thread-unarchived",
66
+ "thread-title-updated",
67
+ "pinned-threads-updated",
68
+ "automation-runs-updated",
69
+ "custom-prompts-updated",
70
+ "active-workspace-roots-updated",
71
+ "workspace-root-options-updated"
72
+ ]);
73
+
74
+ class TerminalRegistry {
75
+ constructor(sendToWs, logger) {
76
+ this.sendToWs = sendToWs;
77
+ this.logger = logger;
78
+ this.sessions = new Map();
79
+ }
80
+
81
+ createOrAttach(ws, message) {
82
+ const sessionId = message.sessionId || randomId(8);
83
+ const existing = this.sessions.get(sessionId);
84
+ if (existing) {
85
+ existing.listeners.add(ws);
86
+ this.sendToWs(ws, { type: "terminal-attached", sessionId });
87
+ return;
88
+ }
89
+
90
+ const shell = process.env.SHELL || "/bin/zsh";
91
+ const command = Array.isArray(message.command) && message.command.length > 0
92
+ ? message.command
93
+ : [shell];
94
+
95
+ const [bin, ...args] = command;
96
+ const proc = spawn(bin, args, {
97
+ cwd: message.cwd || process.cwd(),
98
+ env: {
99
+ ...process.env,
100
+ ...(message.env || {})
101
+ },
102
+ stdio: ["pipe", "pipe", "pipe"]
103
+ });
104
+
105
+ const session = {
106
+ sessionId,
107
+ proc,
108
+ listeners: new Set([ws])
109
+ };
110
+
111
+ this.sessions.set(sessionId, session);
112
+
113
+ this.sendToWs(ws, { type: "terminal-attached", sessionId });
114
+ this.sendToWs(ws, {
115
+ type: "terminal-init-log",
116
+ sessionId,
117
+ log: "Terminal attached via codex-webstrapper\r\n"
118
+ });
119
+
120
+ proc.stdout?.on("data", (chunk) => {
121
+ this._broadcast(sessionId, {
122
+ type: "terminal-data",
123
+ sessionId,
124
+ data: chunk.toString("utf8")
125
+ });
126
+ });
127
+
128
+ proc.stderr?.on("data", (chunk) => {
129
+ this._broadcast(sessionId, {
130
+ type: "terminal-data",
131
+ sessionId,
132
+ data: chunk.toString("utf8")
133
+ });
134
+ });
135
+
136
+ proc.on("error", (error) => {
137
+ this._broadcast(sessionId, {
138
+ type: "terminal-error",
139
+ sessionId,
140
+ message: toErrorMessage(error)
141
+ });
142
+ });
143
+
144
+ proc.on("exit", (code, signal) => {
145
+ this._broadcast(sessionId, {
146
+ type: "terminal-exit",
147
+ sessionId,
148
+ code,
149
+ signal
150
+ });
151
+ this.sessions.delete(sessionId);
152
+ });
153
+ }
154
+
155
+ write(sessionId, data) {
156
+ const session = this.sessions.get(sessionId);
157
+ if (!session || !session.proc.stdin || session.proc.stdin.destroyed) {
158
+ return;
159
+ }
160
+ session.proc.stdin.write(data);
161
+ }
162
+
163
+ resize(sessionId) {
164
+ if (!this.sessions.has(sessionId)) {
165
+ return;
166
+ }
167
+ this.logger.debug("Terminal resize ignored (non-PTY mode)", { sessionId });
168
+ }
169
+
170
+ close(sessionId) {
171
+ const session = this.sessions.get(sessionId);
172
+ if (!session) {
173
+ return;
174
+ }
175
+
176
+ if (!session.proc.killed) {
177
+ session.proc.kill();
178
+ }
179
+ this.sessions.delete(sessionId);
180
+ }
181
+
182
+ removeListener(ws) {
183
+ for (const [sessionId, session] of this.sessions.entries()) {
184
+ session.listeners.delete(ws);
185
+ if (session.listeners.size === 0) {
186
+ this.close(sessionId);
187
+ }
188
+ }
189
+ }
190
+
191
+ dispose() {
192
+ for (const sessionId of this.sessions.keys()) {
193
+ this.close(sessionId);
194
+ }
195
+ }
196
+
197
+ _broadcast(sessionId, message) {
198
+ const session = this.sessions.get(sessionId);
199
+ if (!session) {
200
+ return;
201
+ }
202
+
203
+ for (const listener of session.listeners) {
204
+ this.sendToWs(listener, message);
205
+ }
206
+ }
207
+ }
208
+
209
+ class GitWorkerBridge {
210
+ constructor({ workerPath, sendWorkerEvent, logger }) {
211
+ this.workerPath = workerPath;
212
+ this.sendWorkerEvent = sendWorkerEvent;
213
+ this.logger = logger;
214
+ this.worker = null;
215
+ this.pendingByRequestId = new Map();
216
+ }
217
+
218
+ async isAvailable() {
219
+ if (!this.workerPath) {
220
+ return false;
221
+ }
222
+
223
+ try {
224
+ await fs.access(this.workerPath);
225
+ return true;
226
+ } catch {
227
+ return false;
228
+ }
229
+ }
230
+
231
+ async postMessage(ws, payload) {
232
+ if (!(await this.isAvailable())) {
233
+ this.sendWorkerEvent(ws, "git", {
234
+ type: "worker-response",
235
+ workerId: "git",
236
+ response: {
237
+ id: payload?.request?.id || payload?.id,
238
+ ok: false,
239
+ error: "git worker unavailable"
240
+ }
241
+ });
242
+ return;
243
+ }
244
+
245
+ this._ensureWorker();
246
+
247
+ if (payload.type === "worker-request" && payload.request?.id) {
248
+ this.pendingByRequestId.set(payload.request.id, ws);
249
+ }
250
+
251
+ if (payload.type === "worker-request-cancel" && payload.id) {
252
+ this.pendingByRequestId.delete(payload.id);
253
+ }
254
+
255
+ this.worker.postMessage(payload);
256
+ }
257
+
258
+ removeClient(ws) {
259
+ for (const [requestId, owner] of this.pendingByRequestId.entries()) {
260
+ if (owner === ws) {
261
+ this.pendingByRequestId.delete(requestId);
262
+ }
263
+ }
264
+ }
265
+
266
+ dispose() {
267
+ if (this.worker) {
268
+ this.worker.terminate();
269
+ this.worker = null;
270
+ }
271
+ this.pendingByRequestId.clear();
272
+ }
273
+
274
+ _ensureWorker() {
275
+ if (this.worker) {
276
+ return;
277
+ }
278
+
279
+ this.worker = new Worker(this.workerPath, {
280
+ workerData: {
281
+ workerId: "git",
282
+ sentryInitOptions: {},
283
+ maxLogLevel: "info",
284
+ sentryRewriteFramesRoot: process.cwd()
285
+ }
286
+ });
287
+
288
+ this.worker.on("message", (message) => {
289
+ if (message?.type === "worker-response" && message?.response?.id) {
290
+ const owner = this.pendingByRequestId.get(message.response.id);
291
+ if (owner) {
292
+ this.pendingByRequestId.delete(message.response.id);
293
+ this.sendWorkerEvent(owner, "git", message);
294
+ return;
295
+ }
296
+ }
297
+
298
+ // Broadcast unknown worker events to all connected clients.
299
+ this.sendWorkerEvent(null, "git", message);
300
+ });
301
+
302
+ this.worker.on("error", (error) => {
303
+ this.logger.warn("Git worker error", { error: toErrorMessage(error) });
304
+ });
305
+
306
+ this.worker.on("exit", (code) => {
307
+ this.logger.warn("Git worker exited", { code });
308
+ this.worker = null;
309
+ });
310
+ }
311
+ }
312
+
313
+ export class MessageRouter {
314
+ constructor({ appServer, udsClient, workerPath, hostConfig, logger, globalStatePath }) {
315
+ this.logger = logger || createLogger("router");
316
+ this.appServer = appServer;
317
+ this.udsClient = udsClient;
318
+ this.hostConfig = hostConfig || {
319
+ id: "local",
320
+ display_name: "Codex",
321
+ kind: "local"
322
+ };
323
+
324
+ this.clients = new Set();
325
+ this.fetchControllers = new Map();
326
+ this.persistedAtomState = new Map();
327
+ this.sharedObjects = new Map();
328
+ this.sharedObjectSubscribers = new Map();
329
+ this.lastAccountRead = null;
330
+ this.defaultWorkspaceRoot = process.cwd();
331
+ this.workspaceRootOptions = {
332
+ roots: [this.defaultWorkspaceRoot],
333
+ labels: {}
334
+ };
335
+ this.activeWorkspaceRoots = [this.defaultWorkspaceRoot];
336
+ this.userSelectedActiveWorkspaceRoots = false;
337
+ this.globalStatePath = globalStatePath || path.join(os.homedir(), ".codex", ".codex-global-state.json");
338
+ this.globalState = {};
339
+ this.globalStateWriteTimer = null;
340
+ this._loadPersistedGlobalState();
341
+ this._persistWorkspaceState({ writeToDisk: false });
342
+ this.sharedObjects.set("host_config", this.hostConfig);
343
+
344
+ this.terminals = new TerminalRegistry((ws, payload) => {
345
+ this.sendMainMessage(ws, payload);
346
+ }, this.logger);
347
+
348
+ this.gitWorker = new GitWorkerBridge({
349
+ workerPath,
350
+ sendWorkerEvent: (ws, workerId, payload) => {
351
+ if (ws) {
352
+ this.sendWorkerEvent(ws, workerId, payload);
353
+ return;
354
+ }
355
+ this.broadcastWorkerEvent(workerId, payload);
356
+ },
357
+ logger: this.logger
358
+ });
359
+
360
+ this._wireBackends();
361
+ }
362
+
363
+ _wireBackends() {
364
+ if (this.appServer) {
365
+ this.appServer.on("initialized", () => {
366
+ this.broadcastMainMessage({
367
+ type: "codex-app-server-initialized"
368
+ });
369
+ });
370
+
371
+ this.appServer.on("notification", (notification) => {
372
+ this.broadcastMainMessage({
373
+ type: "mcp-notification",
374
+ method: notification?.method,
375
+ params: notification?.params ?? {}
376
+ });
377
+ });
378
+
379
+ this.appServer.on("request", (request) => {
380
+ this.broadcastMainMessage({ type: "mcp-request", request });
381
+ });
382
+
383
+ this.appServer.on("connection-changed", (state) => {
384
+ this.broadcastMainMessage({
385
+ type: "codex-app-server-connection-changed",
386
+ state: state.connected ? "connected" : "disconnected",
387
+ transport: state.transportKind
388
+ });
389
+ });
390
+ }
391
+
392
+ if (this.udsClient) {
393
+ this.udsClient.on("broadcast", (message) => {
394
+ if (!IPC_BROADCAST_FORWARD_METHODS.has(message.method)) {
395
+ return;
396
+ }
397
+ this.broadcastMainMessage({
398
+ type: "ipc-broadcast",
399
+ method: message.method,
400
+ sourceClientId: message.sourceClientId,
401
+ version: message.version,
402
+ params: message.params
403
+ });
404
+ });
405
+ }
406
+ }
407
+
408
+ registerClient(ws) {
409
+ this.clients.add(ws);
410
+
411
+ this.sendBridgeEnvelope(ws, {
412
+ type: "bridge-ready",
413
+ payload: {
414
+ ts: Date.now()
415
+ }
416
+ });
417
+
418
+ if (this.appServer) {
419
+ const state = this.appServer.getState();
420
+ this.sendMainMessage(ws, {
421
+ type: "codex-app-server-connection-changed",
422
+ state: state.connected ? "connected" : "disconnected",
423
+ transport: state.transportKind
424
+ });
425
+ if (state.initialized) {
426
+ this.sendMainMessage(ws, {
427
+ type: "codex-app-server-initialized"
428
+ });
429
+ }
430
+ }
431
+ }
432
+
433
+ unregisterClient(ws) {
434
+ this.clients.delete(ws);
435
+ this.terminals.removeListener(ws);
436
+ this.gitWorker.removeClient(ws);
437
+
438
+ for (const subscribers of this.sharedObjectSubscribers.values()) {
439
+ subscribers.delete(ws);
440
+ }
441
+ }
442
+
443
+ dispose() {
444
+ if (this.globalStateWriteTimer) {
445
+ clearTimeout(this.globalStateWriteTimer);
446
+ this.globalStateWriteTimer = null;
447
+ void this._writeGlobalStateToDisk();
448
+ }
449
+
450
+ this.terminals.dispose();
451
+ this.gitWorker.dispose();
452
+
453
+ for (const controller of this.fetchControllers.values()) {
454
+ controller.abort();
455
+ }
456
+ this.fetchControllers.clear();
457
+ }
458
+
459
+ async handleEnvelope(ws, envelope) {
460
+ if (!envelope || typeof envelope !== "object") {
461
+ this.sendBridgeError(ws, "invalid_envelope", "Envelope must be a JSON object.");
462
+ return;
463
+ }
464
+
465
+ switch (envelope.type) {
466
+ case "view-message": {
467
+ await this._handleViewMessage(ws, envelope.payload);
468
+ return;
469
+ }
470
+ case "worker-message": {
471
+ const workerId = envelope.workerId || envelope.payload?.workerId || "git";
472
+ await this._handleWorkerMessage(ws, workerId, envelope.payload);
473
+ return;
474
+ }
475
+ default: {
476
+ this.sendBridgeError(ws, "unsupported_envelope_type", `Unsupported envelope type: ${envelope.type}`);
477
+ }
478
+ }
479
+ }
480
+
481
+ async _handleViewMessage(ws, message) {
482
+ if (!message || typeof message !== "object") {
483
+ this.sendBridgeError(ws, "invalid_view_message", "View payload must be an object.");
484
+ return;
485
+ }
486
+
487
+ const type = message.type;
488
+
489
+ if (!type) {
490
+ this.sendBridgeError(ws, "missing_message_type", "View payload is missing `type`.");
491
+ return;
492
+ }
493
+
494
+ try {
495
+ this.logger.debug("renderer-message", {
496
+ type
497
+ });
498
+ switch (type) {
499
+ case "ready":
500
+ this._handleReady(ws);
501
+ return;
502
+ case "electron-window-focus-request":
503
+ this.sendMainMessage(ws, {
504
+ type: "electron-window-focus-changed",
505
+ isFocused: true
506
+ });
507
+ return;
508
+ case "log-message":
509
+ this.logger.debug("renderer-log-message", {
510
+ level: message.level || "info",
511
+ message: typeof message.message === "string" ? message.message.slice(0, 500) : null
512
+ });
513
+ return;
514
+ case "fetch":
515
+ await this._handleFetch(ws, message);
516
+ return;
517
+ case "cancel-fetch":
518
+ this._handleCancelFetch(message);
519
+ return;
520
+ case "fetch-stream":
521
+ this.sendMainMessage(ws, {
522
+ type: "fetch-stream-error",
523
+ requestId: message.requestId,
524
+ error: "Streaming fetch is not implemented in webstrapper."
525
+ });
526
+ return;
527
+ case "cancel-fetch-stream":
528
+ return;
529
+ case "mcp-request":
530
+ await this._forwardToAppServer(ws, message.request || message.payload || message);
531
+ return;
532
+ case "mcp-response":
533
+ await this._forwardToAppServer(ws, message.response || message.payload || message);
534
+ return;
535
+ case "mcp-notification":
536
+ await this._forwardToAppServer(ws, message.notification || message.payload || message);
537
+ return;
538
+ case "terminal-create":
539
+ case "terminal-attach":
540
+ this.terminals.createOrAttach(ws, message);
541
+ return;
542
+ case "terminal-write":
543
+ this.terminals.write(message.sessionId, message.data || "");
544
+ return;
545
+ case "terminal-resize":
546
+ this.terminals.resize(message.sessionId);
547
+ return;
548
+ case "terminal-close":
549
+ this.terminals.close(message.sessionId);
550
+ return;
551
+ case "persisted-atom-sync-request":
552
+ this.sendMainMessage(ws, {
553
+ type: "persisted-atom-sync",
554
+ state: Object.fromEntries(this.persistedAtomState.entries())
555
+ });
556
+ return;
557
+ case "persisted-atom-update":
558
+ if (message.key) {
559
+ this.persistedAtomState.set(message.key, message.value);
560
+ this.broadcastMainMessage({
561
+ type: "persisted-atom-updated",
562
+ key: message.key,
563
+ value: message.value
564
+ });
565
+ this._scheduleGlobalStateWrite();
566
+ }
567
+ return;
568
+ case "persisted-atom-reset":
569
+ if (message.key) {
570
+ this.persistedAtomState.delete(message.key);
571
+ this.broadcastMainMessage({
572
+ type: "persisted-atom-updated",
573
+ key: message.key,
574
+ value: null
575
+ });
576
+ this._scheduleGlobalStateWrite();
577
+ }
578
+ return;
579
+ case "shared-object-subscribe":
580
+ this._subscribeSharedObject(ws, message.key);
581
+ return;
582
+ case "shared-object-set":
583
+ this._setSharedObject(message.key, message.value);
584
+ return;
585
+ case "shared-object-unsubscribe":
586
+ this._unsubscribeSharedObject(ws, message.key);
587
+ return;
588
+ case "archive-thread":
589
+ await this._archiveThread(ws, message);
590
+ return;
591
+ case "unarchive-thread":
592
+ await this._unarchiveThread(ws, message);
593
+ return;
594
+ case "thread-archived":
595
+ case "thread-unarchived":
596
+ case "thread-stream-state-changed":
597
+ case "thread-overlay-proxy-start-turn-response":
598
+ case "thread-overlay-proxy-interrupt-response":
599
+ case "set-telemetry-user":
600
+ case "view-focused":
601
+ return;
602
+ case "thread-overlay-proxy-start-turn-request":
603
+ await this._handleThreadOverlayStartTurn(ws, message);
604
+ return;
605
+ case "thread-overlay-proxy-interrupt-request":
606
+ await this._handleThreadOverlayInterrupt(ws, message);
607
+ return;
608
+ case "electron-onboarding-skip-workspace":
609
+ this.workspaceRootOptions = {
610
+ ...this.workspaceRootOptions,
611
+ roots: [this.defaultWorkspaceRoot]
612
+ };
613
+ this.activeWorkspaceRoots = [this.defaultWorkspaceRoot];
614
+ this.userSelectedActiveWorkspaceRoots = false;
615
+ this._persistWorkspaceState();
616
+ this.broadcastMainMessage({
617
+ type: "workspace-root-options-updated",
618
+ options: this.workspaceRootOptions.roots
619
+ });
620
+ this.broadcastMainMessage({
621
+ type: "active-workspace-roots-updated",
622
+ roots: this.activeWorkspaceRoots
623
+ });
624
+ this.sendMainMessage(ws, {
625
+ type: "electron-onboarding-skip-workspace-result",
626
+ success: true,
627
+ error: null
628
+ });
629
+ return;
630
+ case "electron-update-workspace-root-options":
631
+ if (Array.isArray(message.roots)) {
632
+ const normalizedRoots = [...new Set(
633
+ message.roots
634
+ .map((root) => this._normalizeWorkspaceRoot(root))
635
+ .filter(Boolean)
636
+ )];
637
+ this.workspaceRootOptions = {
638
+ ...this.workspaceRootOptions,
639
+ roots: normalizedRoots
640
+ };
641
+ this._persistWorkspaceState();
642
+ this.broadcastMainMessage({
643
+ type: "workspace-root-options-updated",
644
+ options: this.workspaceRootOptions.roots
645
+ });
646
+ }
647
+ return;
648
+ case "electron-set-active-workspace-root":
649
+ {
650
+ const normalizedRoot = this._normalizeWorkspaceRoot(message.root);
651
+ if (!normalizedRoot) {
652
+ return;
653
+ }
654
+ this.activeWorkspaceRoots = [normalizedRoot];
655
+ this.userSelectedActiveWorkspaceRoots = true;
656
+ this._persistWorkspaceState();
657
+ this.broadcastMainMessage({
658
+ type: "active-workspace-roots-updated",
659
+ roots: this.activeWorkspaceRoots
660
+ });
661
+ }
662
+ return;
663
+ case "worker-request":
664
+ case "worker-request-cancel":
665
+ await this._handleWorkerMessage(ws, message.workerId || "git", message);
666
+ return;
667
+ case "open-in-browser":
668
+ this._openInBrowser(ws, message);
669
+ return;
670
+ case "show-diff":
671
+ this.sendMainMessage(ws, {
672
+ type: "toggle-diff-panel",
673
+ open: true
674
+ });
675
+ return;
676
+ case "show-plan-summary":
677
+ case "navigate-in-new-editor-tab":
678
+ // Matches desktop host behavior: these are no-ops.
679
+ return;
680
+ case "electron-set-badge-count":
681
+ case "power-save-blocker-set":
682
+ case "desktop-notification-show":
683
+ case "desktop-notification-hide":
684
+ case "show-context-menu":
685
+ case "inbox-item-set-read-state":
686
+ case "codex-app-server-restart":
687
+ case "open-thread-overlay":
688
+ case "electron-set-window-mode":
689
+ case "electron-pick-workspace-root-option":
690
+ case "electron-app-state-snapshot-trigger":
691
+ case "update-diff-if-open":
692
+ // Electron-only side effects that are safe to ignore in browser mode.
693
+ return;
694
+ default:
695
+ if (NATIVE_UNSUPPORTED.has(type)) {
696
+ this.logger.warn("Unsupported native action in browser mode", {
697
+ type
698
+ });
699
+ this.sendBridgeError(ws, "unsupported_native_action", `${type} is not available in browser mode.`);
700
+ return;
701
+ }
702
+
703
+ this.logger.warn("Unsupported renderer message type", {
704
+ type,
705
+ keys: Object.keys(message)
706
+ });
707
+ this.sendBridgeError(ws, "unsupported_message_type", `Unsupported renderer message type: ${type}`);
708
+ }
709
+ } catch (error) {
710
+ this.logger.warn("Message handling error", {
711
+ type,
712
+ error: toErrorMessage(error)
713
+ });
714
+ this.sendBridgeError(ws, "message_handler_error", toErrorMessage(error));
715
+ }
716
+ }
717
+
718
+ _handleReady(ws) {
719
+ this.sendMainMessage(ws, {
720
+ type: "shared-object-updated",
721
+ key: "host_config",
722
+ value: this.sharedObjects.get("host_config")
723
+ });
724
+
725
+ this.sendMainMessage(ws, {
726
+ type: "active-workspace-roots-updated",
727
+ roots: this.activeWorkspaceRoots
728
+ });
729
+
730
+ this.sendMainMessage(ws, {
731
+ type: "workspace-root-options-updated",
732
+ options: this.workspaceRootOptions.roots
733
+ });
734
+
735
+ this.sendMainMessage(ws, {
736
+ type: "persisted-atom-sync",
737
+ state: Object.fromEntries(this.persistedAtomState.entries())
738
+ });
739
+
740
+ this.sendMainMessage(ws, {
741
+ type: "custom-prompts-updated",
742
+ prompts: []
743
+ });
744
+
745
+ this.sendMainMessage(ws, {
746
+ type: "app-update-ready-changed",
747
+ isUpdateReady: false
748
+ });
749
+ }
750
+
751
+ async _handleFetch(ws, message) {
752
+ const requestId = message.requestId || randomId(8);
753
+ this.logger.debug("renderer-fetch", {
754
+ requestId,
755
+ method: message.method || "GET",
756
+ url: message.url || null,
757
+ body: typeof message.body === "string" ? message.body.slice(0, 400) : null
758
+ });
759
+
760
+ if (await this._handleVirtualFetch(ws, requestId, message)) {
761
+ return;
762
+ }
763
+
764
+ const resolvedUrl = this._resolveFetchUrl(message.url);
765
+ if (!resolvedUrl) {
766
+ this.sendMainMessage(ws, {
767
+ type: "fetch-response",
768
+ requestId,
769
+ responseType: "error",
770
+ status: 0,
771
+ error: `Unsupported fetch URL: ${String(message.url)}`
772
+ });
773
+ this.logger.warn("renderer-fetch-failed", {
774
+ requestId,
775
+ url: message.url || null,
776
+ error: "unsupported_fetch_url"
777
+ });
778
+ return;
779
+ }
780
+
781
+ const controller = new AbortController();
782
+ this.fetchControllers.set(requestId, controller);
783
+
784
+ try {
785
+ const response = await fetch(resolvedUrl, {
786
+ method: message.method || "GET",
787
+ headers: message.headers || {},
788
+ body: message.body,
789
+ signal: controller.signal
790
+ });
791
+
792
+ const body = await response.text();
793
+ const headers = {};
794
+ for (const [key, value] of response.headers.entries()) {
795
+ headers[key] = value;
796
+ }
797
+
798
+ let bodyJsonString = body;
799
+ try {
800
+ JSON.parse(bodyJsonString);
801
+ } catch {
802
+ bodyJsonString = JSON.stringify(body);
803
+ }
804
+
805
+ this.sendMainMessage(ws, {
806
+ type: "fetch-response",
807
+ requestId,
808
+ responseType: "success",
809
+ status: response.status,
810
+ headers,
811
+ bodyJsonString
812
+ });
813
+ this.logger.debug("renderer-fetch-response", {
814
+ requestId,
815
+ status: response.status,
816
+ ok: response.ok,
817
+ url: response.url || resolvedUrl
818
+ });
819
+ } catch (error) {
820
+ this.sendMainMessage(ws, {
821
+ type: "fetch-response",
822
+ requestId,
823
+ responseType: "error",
824
+ status: 0,
825
+ error: toErrorMessage(error)
826
+ });
827
+ this.logger.warn("renderer-fetch-failed", {
828
+ requestId,
829
+ url: resolvedUrl,
830
+ error: toErrorMessage(error)
831
+ });
832
+ } finally {
833
+ this.fetchControllers.delete(requestId);
834
+ }
835
+ }
836
+
837
+ _resolveFetchUrl(url) {
838
+ if (typeof url !== "string" || url.length === 0) {
839
+ return null;
840
+ }
841
+ if (url.startsWith("http://") || url.startsWith("https://")) {
842
+ return url;
843
+ }
844
+ if (url.startsWith("/")) {
845
+ return `https://chatgpt.com${url}`;
846
+ }
847
+ return null;
848
+ }
849
+
850
+ async _handleVirtualFetch(ws, requestId, message) {
851
+ if (typeof message.url !== "string") {
852
+ return false;
853
+ }
854
+
855
+ if (message.url.startsWith("sentry-ipc://")) {
856
+ this._sendFetchJson(ws, {
857
+ requestId,
858
+ url: message.url,
859
+ status: 204,
860
+ payload: ""
861
+ });
862
+ this.logger.debug("renderer-fetch-response", {
863
+ requestId,
864
+ status: 204,
865
+ ok: true,
866
+ sentryIpc: true
867
+ });
868
+ return true;
869
+ }
870
+
871
+ if (message.url.startsWith("vscode://codex/")) {
872
+ const body = safeJsonParse(typeof message.body === "string" ? message.body : "{}") || {};
873
+ const params = body?.params ?? body ?? {};
874
+
875
+ let endpoint = "";
876
+ try {
877
+ endpoint = new URL(message.url).pathname.replace(/^\/+/, "");
878
+ } catch {
879
+ endpoint = "";
880
+ }
881
+
882
+ let payload = {};
883
+ switch (endpoint) {
884
+ case "get-global-state": {
885
+ const key = params?.key;
886
+ if (key === "active-workspace-roots") {
887
+ payload = {
888
+ value: this.activeWorkspaceRoots
889
+ };
890
+ break;
891
+ }
892
+ if (key === "electron-saved-workspace-roots") {
893
+ payload = {
894
+ value: this.workspaceRootOptions
895
+ };
896
+ break;
897
+ }
898
+ if (key === "electron-workspace-root-labels") {
899
+ payload = {
900
+ value: this.workspaceRootOptions.labels
901
+ };
902
+ break;
903
+ }
904
+ const hasGlobalStateValue = typeof key === "string"
905
+ && Object.prototype.hasOwnProperty.call(this.globalState, key);
906
+ payload = {
907
+ value: key
908
+ ? hasGlobalStateValue
909
+ ? this.globalState[key]
910
+ : this.persistedAtomState.get(key) ?? null
911
+ : null
912
+ };
913
+ break;
914
+ }
915
+ case "set-global-state": {
916
+ const key = params?.key;
917
+ const value = params?.value;
918
+ if (typeof key === "string" && key.length > 0) {
919
+ const isActiveWorkspaceRoots = key === "active-workspace-roots";
920
+ const isSavedWorkspaceRoots = key === "electron-saved-workspace-roots";
921
+ const isWorkspaceLabels = key === "electron-workspace-root-labels";
922
+
923
+ if (value == null) {
924
+ this.persistedAtomState.delete(key);
925
+ delete this.globalState[key];
926
+ } else {
927
+ this.persistedAtomState.set(key, value);
928
+ this.globalState[key] = value;
929
+ }
930
+
931
+ if (isActiveWorkspaceRoots && Array.isArray(value)) {
932
+ this.activeWorkspaceRoots = [...new Set(
933
+ value
934
+ .map((root) => this._normalizeWorkspaceRoot(root))
935
+ .filter(Boolean)
936
+ )];
937
+ this.userSelectedActiveWorkspaceRoots = true;
938
+ } else if (isSavedWorkspaceRoots && value && typeof value === "object") {
939
+ const roots = Array.isArray(value.roots)
940
+ ? [...new Set(
941
+ value.roots
942
+ .map((root) => this._normalizeWorkspaceRoot(root))
943
+ .filter(Boolean)
944
+ )]
945
+ : [];
946
+ const labels = value.labels && typeof value.labels === "object" ? value.labels : {};
947
+ if (roots.length > 0) {
948
+ this.workspaceRootOptions = { roots, labels };
949
+ }
950
+ } else if (isWorkspaceLabels && value && typeof value === "object") {
951
+ this.workspaceRootOptions = {
952
+ ...this.workspaceRootOptions,
953
+ labels: value
954
+ };
955
+ }
956
+
957
+ if (isActiveWorkspaceRoots || isSavedWorkspaceRoots || isWorkspaceLabels) {
958
+ this._persistWorkspaceState();
959
+ } else {
960
+ this._scheduleGlobalStateWrite();
961
+ }
962
+ }
963
+ payload = { ok: true };
964
+ break;
965
+ }
966
+ case "list-pinned-threads":
967
+ payload = { threadIds: [] };
968
+ break;
969
+ case "set-thread-pinned":
970
+ payload = { ok: true };
971
+ break;
972
+ case "extension-info":
973
+ payload = {
974
+ name: "codex-webstrapper",
975
+ version: "0.1.0",
976
+ platform: process.platform,
977
+ uiKind: "desktop"
978
+ };
979
+ break;
980
+ case "is-copilot-api-available":
981
+ payload = { isAvailable: false };
982
+ break;
983
+ case "account-info":
984
+ payload = {
985
+ userId: this.lastAccountRead?.userId ?? null,
986
+ accountId: this.lastAccountRead?.accountId ?? null,
987
+ email: this.lastAccountRead?.account?.email ?? null,
988
+ plan: this.lastAccountRead?.account?.planType ?? null,
989
+ account: this.lastAccountRead?.account ?? null,
990
+ requiresOpenaiAuth: this.lastAccountRead?.requiresOpenaiAuth ?? true
991
+ };
992
+ break;
993
+ case "os-info":
994
+ payload = { platform: process.platform };
995
+ break;
996
+ case "ide-context":
997
+ payload = {
998
+ ideContext: {
999
+ workspaceRoot: params?.workspaceRoot ?? null,
1000
+ roots: typeof params?.workspaceRoot === "string" && params.workspaceRoot.length > 0
1001
+ ? [params.workspaceRoot]
1002
+ : [],
1003
+ openFiles: [],
1004
+ activeEditor: null
1005
+ },
1006
+ roots: []
1007
+ };
1008
+ break;
1009
+ case "get-copilot-api-proxy-info":
1010
+ payload = null;
1011
+ break;
1012
+ case "mcp-codex-config":
1013
+ payload = { config: {} };
1014
+ break;
1015
+ case "developer-instructions":
1016
+ payload = {
1017
+ instructions: typeof params?.baseInstructions === "string" ? params.baseInstructions : null
1018
+ };
1019
+ break;
1020
+ case "local-environments":
1021
+ payload = [];
1022
+ break;
1023
+ case "has-custom-cli-executable":
1024
+ payload = { hasCustomCliExecutable: false };
1025
+ break;
1026
+ case "generate-thread-title": {
1027
+ const prompt = typeof params?.prompt === "string" ? params.prompt.trim() : "";
1028
+ payload = {
1029
+ title: prompt.length > 0
1030
+ ? prompt
1031
+ .replace(/\s+/g, " ")
1032
+ .split(" ")
1033
+ .slice(0, 8)
1034
+ .join(" ")
1035
+ .slice(0, 80)
1036
+ : "Update thread"
1037
+ };
1038
+ break;
1039
+ }
1040
+ case "active-workspace-roots":
1041
+ payload = { roots: this.activeWorkspaceRoots };
1042
+ break;
1043
+ case "workspace-root-options":
1044
+ payload = this.workspaceRootOptions;
1045
+ break;
1046
+ case "git-origins": {
1047
+ const dirs = Array.isArray(params?.dirs) ? params.dirs.filter((dir) => typeof dir === "string" && dir.length > 0) : [];
1048
+ payload = {
1049
+ origins: await Promise.all(dirs.map((dir) => this._resolveGitOrigin(dir)))
1050
+ };
1051
+ break;
1052
+ }
1053
+ case "git-merge-base": {
1054
+ const gitRoot = typeof params?.gitRoot === "string" && params.gitRoot.length > 0
1055
+ ? params.gitRoot
1056
+ : process.cwd();
1057
+ const baseBranch = typeof params?.baseBranch === "string" ? params.baseBranch.trim() : "";
1058
+ payload = await this._resolveGitMergeBase({ gitRoot, baseBranch });
1059
+ break;
1060
+ }
1061
+ case "list-pending-automation-run-threads":
1062
+ payload = { threadIds: [] };
1063
+ break;
1064
+ case "inbox-items":
1065
+ payload = { items: [] };
1066
+ break;
1067
+ case "pending-automation-runs":
1068
+ payload = { runs: [] };
1069
+ break;
1070
+ case "list-automations":
1071
+ payload = { items: [] };
1072
+ break;
1073
+ case "open-in-targets":
1074
+ payload = { preferredTarget: null, targets: [], availableTargets: [] };
1075
+ break;
1076
+ case "codex-home":
1077
+ payload = { codexHome: null };
1078
+ break;
1079
+ case "locale-info":
1080
+ payload = { ideLocale: null, systemLocale: null };
1081
+ break;
1082
+ case "get-configuration":
1083
+ payload = { value: null };
1084
+ break;
1085
+ case "set-configuration":
1086
+ payload = { ok: true };
1087
+ break;
1088
+ case "recommended-skills":
1089
+ payload = { skills: [] };
1090
+ break;
1091
+ case "third-party-notices":
1092
+ payload = { notices: [] };
1093
+ break;
1094
+ case "gh-cli-status":
1095
+ payload = await this._resolveGhCliStatus();
1096
+ break;
1097
+ case "gh-pr-status": {
1098
+ const cwd = typeof params?.cwd === "string" && params.cwd.length > 0
1099
+ ? params.cwd
1100
+ : process.cwd();
1101
+ const headBranch = typeof params?.headBranch === "string" ? params.headBranch.trim() : "";
1102
+ payload = await this._resolveGhPrStatus({ cwd, headBranch });
1103
+ break;
1104
+ }
1105
+ case "paths-exist": {
1106
+ const paths = Array.isArray(params?.paths) ? params.paths.filter((p) => typeof p === "string") : [];
1107
+ payload = { existingPaths: paths };
1108
+ break;
1109
+ }
1110
+ default:
1111
+ this.logger.warn("Unhandled vscode fetch endpoint", { endpoint });
1112
+ payload = {};
1113
+ }
1114
+
1115
+ this._sendFetchJson(ws, {
1116
+ requestId,
1117
+ url: message.url,
1118
+ status: 200,
1119
+ payload
1120
+ });
1121
+ return true;
1122
+ }
1123
+
1124
+ if (message.url === "/wham/accounts/check") {
1125
+ this._sendFetchJson(ws, {
1126
+ requestId,
1127
+ url: message.url,
1128
+ status: 200,
1129
+ payload: {
1130
+ account_ordering: [],
1131
+ accounts: []
1132
+ }
1133
+ });
1134
+ return true;
1135
+ }
1136
+
1137
+ if (message.url === "/wham/usage") {
1138
+ this._sendFetchJson(ws, {
1139
+ requestId,
1140
+ url: message.url,
1141
+ status: 200,
1142
+ payload: {}
1143
+ });
1144
+ return true;
1145
+ }
1146
+
1147
+ if (message.url.startsWith("/wham/tasks/list")) {
1148
+ this._sendFetchJson(ws, {
1149
+ requestId,
1150
+ url: message.url,
1151
+ status: 200,
1152
+ payload: { items: [] }
1153
+ });
1154
+ return true;
1155
+ }
1156
+
1157
+ if (message.url === "/wham/environments") {
1158
+ this._sendFetchJson(ws, {
1159
+ requestId,
1160
+ url: message.url,
1161
+ status: 200,
1162
+ payload: []
1163
+ });
1164
+ return true;
1165
+ }
1166
+
1167
+ if (message.url.startsWith("/wham/tasks/")) {
1168
+ this._sendFetchJson(ws, {
1169
+ requestId,
1170
+ url: message.url,
1171
+ status: 200,
1172
+ payload: {}
1173
+ });
1174
+ return true;
1175
+ }
1176
+
1177
+ if (message.url.includes("/accounts/") && message.url.endsWith("/settings")) {
1178
+ this._sendFetchJson(ws, {
1179
+ requestId,
1180
+ url: message.url,
1181
+ status: 200,
1182
+ payload: {}
1183
+ });
1184
+ return true;
1185
+ }
1186
+
1187
+ return false;
1188
+ }
1189
+
1190
+ async _resolveGitOrigin(dir) {
1191
+ const normalizedDir = this._normalizeWorkspaceRoot(dir) || dir;
1192
+ const fallback = {
1193
+ dir: normalizedDir,
1194
+ root: normalizedDir,
1195
+ commonDir: normalizedDir,
1196
+ originUrl: null
1197
+ };
1198
+
1199
+ const rootResult = await this._runCommand("git", ["-C", normalizedDir, "rev-parse", "--show-toplevel"], {
1200
+ timeoutMs: 5_000
1201
+ });
1202
+ if (!rootResult.ok || !rootResult.stdout) {
1203
+ return fallback;
1204
+ }
1205
+
1206
+ const root = this._normalizeWorkspaceRoot(rootResult.stdout) || normalizedDir;
1207
+
1208
+ const commonDirResult = await this._runCommand("git", ["-C", normalizedDir, "rev-parse", "--git-common-dir"], {
1209
+ timeoutMs: 5_000
1210
+ });
1211
+ const commonDir = commonDirResult.ok && commonDirResult.stdout
1212
+ ? path.resolve(normalizedDir, commonDirResult.stdout)
1213
+ : root;
1214
+
1215
+ const originResult = await this._runCommand("git", ["-C", normalizedDir, "remote", "get-url", "origin"], {
1216
+ timeoutMs: 5_000,
1217
+ allowNonZero: true
1218
+ });
1219
+
1220
+ return {
1221
+ dir: normalizedDir,
1222
+ root,
1223
+ commonDir,
1224
+ originUrl: originResult.ok && originResult.stdout ? originResult.stdout : null
1225
+ };
1226
+ }
1227
+
1228
+ async _resolveGhCliStatus() {
1229
+ const ghVersion = await this._runCommand("gh", ["--version"], {
1230
+ timeoutMs: 3_000,
1231
+ allowNonZero: true
1232
+ });
1233
+
1234
+ if (!ghVersion.ok) {
1235
+ return {
1236
+ isInstalled: false,
1237
+ isAuthenticated: false
1238
+ };
1239
+ }
1240
+
1241
+ const auth = await this._runCommand("gh", ["auth", "status", "--hostname", "github.com"], {
1242
+ timeoutMs: 4_000,
1243
+ allowNonZero: true
1244
+ });
1245
+
1246
+ return {
1247
+ isInstalled: true,
1248
+ isAuthenticated: auth.ok
1249
+ };
1250
+ }
1251
+
1252
+ async _resolveGhPrStatus({ cwd, headBranch }) {
1253
+ if (!headBranch) {
1254
+ return {
1255
+ status: "success",
1256
+ hasOpenPr: false,
1257
+ url: null,
1258
+ number: null
1259
+ };
1260
+ }
1261
+
1262
+ const ghStatus = await this._resolveGhCliStatus();
1263
+ if (!ghStatus.isInstalled || !ghStatus.isAuthenticated) {
1264
+ return {
1265
+ status: "error",
1266
+ hasOpenPr: false,
1267
+ url: null,
1268
+ number: null,
1269
+ error: "gh cli unavailable or unauthenticated"
1270
+ };
1271
+ }
1272
+
1273
+ const listResult = await this._runCommand(
1274
+ "gh",
1275
+ ["pr", "list", "--state", "open", "--head", headBranch, "--json", "number,url", "--limit", "1"],
1276
+ {
1277
+ timeoutMs: 8_000,
1278
+ allowNonZero: true,
1279
+ cwd
1280
+ }
1281
+ );
1282
+
1283
+ if (!listResult.ok) {
1284
+ return {
1285
+ status: "error",
1286
+ hasOpenPr: false,
1287
+ url: null,
1288
+ number: null,
1289
+ error: listResult.error || "failed to query open pull requests"
1290
+ };
1291
+ }
1292
+
1293
+ const parsed = safeJsonParse(listResult.stdout);
1294
+ if (!Array.isArray(parsed) || parsed.length === 0) {
1295
+ return {
1296
+ status: "success",
1297
+ hasOpenPr: false,
1298
+ url: null,
1299
+ number: null
1300
+ };
1301
+ }
1302
+
1303
+ const first = parsed[0] && typeof parsed[0] === "object" ? parsed[0] : {};
1304
+ const number = Number.isInteger(first.number) ? first.number : null;
1305
+ const url = typeof first.url === "string" && first.url.length > 0 ? first.url : null;
1306
+
1307
+ return {
1308
+ status: "success",
1309
+ hasOpenPr: true,
1310
+ url,
1311
+ number
1312
+ };
1313
+ }
1314
+
1315
+ async _resolveGitMergeBase({ gitRoot, baseBranch }) {
1316
+ if (!baseBranch) {
1317
+ return {
1318
+ mergeBaseSha: null
1319
+ };
1320
+ }
1321
+
1322
+ const result = await this._runCommand(
1323
+ "git",
1324
+ ["-C", gitRoot, "merge-base", "HEAD", baseBranch],
1325
+ {
1326
+ timeoutMs: 5_000,
1327
+ allowNonZero: true
1328
+ }
1329
+ );
1330
+
1331
+ return {
1332
+ mergeBaseSha: result.ok && result.stdout ? result.stdout : null
1333
+ };
1334
+ }
1335
+
1336
+ async _runCommand(command, args, { timeoutMs = 5_000, allowNonZero = false, cwd = process.cwd() } = {}) {
1337
+ return new Promise((resolve) => {
1338
+ const child = spawn(command, args, {
1339
+ cwd,
1340
+ env: process.env,
1341
+ stdio: ["ignore", "pipe", "pipe"]
1342
+ });
1343
+
1344
+ let stdout = "";
1345
+ let stderr = "";
1346
+ let settled = false;
1347
+
1348
+ const finish = (result) => {
1349
+ if (settled) {
1350
+ return;
1351
+ }
1352
+ settled = true;
1353
+ clearTimeout(timeout);
1354
+ resolve(result);
1355
+ };
1356
+
1357
+ child.stdout?.on("data", (chunk) => {
1358
+ stdout += chunk.toString("utf8");
1359
+ });
1360
+
1361
+ child.stderr?.on("data", (chunk) => {
1362
+ stderr += chunk.toString("utf8");
1363
+ });
1364
+
1365
+ child.on("error", (error) => {
1366
+ finish({
1367
+ ok: false,
1368
+ code: null,
1369
+ stdout: stdout.trim(),
1370
+ stderr: stderr.trim(),
1371
+ error: toErrorMessage(error)
1372
+ });
1373
+ });
1374
+
1375
+ child.on("exit", (code) => {
1376
+ const success = code === 0;
1377
+ finish({
1378
+ ok: success,
1379
+ code,
1380
+ stdout: stdout.trim(),
1381
+ stderr: stderr.trim(),
1382
+ error: success || allowNonZero ? null : stderr.trim() || `exit code ${String(code)}`
1383
+ });
1384
+ });
1385
+
1386
+ const timeout = setTimeout(() => {
1387
+ child.kill("SIGTERM");
1388
+ finish({
1389
+ ok: false,
1390
+ code: null,
1391
+ stdout: stdout.trim(),
1392
+ stderr: stderr.trim(),
1393
+ error: `command timed out after ${timeoutMs}ms`
1394
+ });
1395
+ }, timeoutMs);
1396
+ });
1397
+ }
1398
+
1399
+ _sendFetchJson(ws, { requestId, url, status = 200, payload = {} }) {
1400
+ const bodyJsonString = JSON.stringify(payload);
1401
+ this.sendMainMessage(ws, {
1402
+ type: "fetch-response",
1403
+ requestId,
1404
+ responseType: "success",
1405
+ status,
1406
+ headers: { "content-type": "application/json" },
1407
+ bodyJsonString
1408
+ });
1409
+ this.logger.debug("renderer-fetch-response", {
1410
+ requestId,
1411
+ status,
1412
+ ok: status >= 200 && status < 300,
1413
+ url
1414
+ });
1415
+ }
1416
+
1417
+ _handleCancelFetch(message) {
1418
+ const controller = this.fetchControllers.get(message.requestId);
1419
+ if (!controller) {
1420
+ return;
1421
+ }
1422
+ controller.abort();
1423
+ this.fetchControllers.delete(message.requestId);
1424
+ }
1425
+
1426
+ async _forwardToAppServer(ws, payload) {
1427
+ if (!this.appServer) {
1428
+ this.sendBridgeError(ws, "app_server_unavailable", "App-server backend is unavailable.");
1429
+ return;
1430
+ }
1431
+
1432
+ this.logger.debug("mcp-forward-request", {
1433
+ id: payload?.id ?? null,
1434
+ method: payload?.method ?? null
1435
+ });
1436
+ const response = await this.appServer.sendRaw(payload);
1437
+
1438
+ if (payload?.method === "account/read" && response?.result) {
1439
+ this.lastAccountRead = response.result;
1440
+ }
1441
+
1442
+ if (payload?.method === "thread/list" && response?.result) {
1443
+ response.result = this._filterThreadListResult(response.result);
1444
+ }
1445
+
1446
+ if (response && payload && payload.id != null) {
1447
+ this.logger.debug("mcp-forward-response", {
1448
+ id: response.id ?? payload.id,
1449
+ hasResult: response.result != null,
1450
+ hasError: response.error != null
1451
+ });
1452
+ this.sendMainMessage(ws, {
1453
+ type: "mcp-response",
1454
+ message: {
1455
+ id: response.id ?? payload.id,
1456
+ result: response.result,
1457
+ error: response.error
1458
+ }
1459
+ });
1460
+ }
1461
+ }
1462
+
1463
+ _normalizeWorkspaceRoot(root) {
1464
+ if (typeof root !== "string") {
1465
+ return null;
1466
+ }
1467
+ const trimmed = root.trim();
1468
+ if (!trimmed) {
1469
+ return null;
1470
+ }
1471
+ return trimmed.replace(/\/+$/, "");
1472
+ }
1473
+
1474
+ _loadPersistedGlobalState() {
1475
+ if (!this.globalStatePath) {
1476
+ return;
1477
+ }
1478
+
1479
+ let parsed;
1480
+ try {
1481
+ parsed = JSON.parse(readFileSync(this.globalStatePath, "utf8"));
1482
+ } catch {
1483
+ return;
1484
+ }
1485
+
1486
+ if (!parsed || typeof parsed !== "object") {
1487
+ return;
1488
+ }
1489
+
1490
+ this.globalState = parsed;
1491
+
1492
+ const persistedAtoms = parsed["electron-persisted-atom-state"];
1493
+ if (persistedAtoms && typeof persistedAtoms === "object" && !Array.isArray(persistedAtoms)) {
1494
+ for (const [key, value] of Object.entries(persistedAtoms)) {
1495
+ this.persistedAtomState.set(key, value);
1496
+ }
1497
+ }
1498
+
1499
+ const rawSavedRoots = parsed["electron-saved-workspace-roots"];
1500
+ const rawActiveRoots = parsed["active-workspace-roots"];
1501
+ const rawLabels = parsed["electron-workspace-root-labels"];
1502
+
1503
+ let savedRoots = [];
1504
+ if (Array.isArray(rawSavedRoots)) {
1505
+ savedRoots = rawSavedRoots;
1506
+ } else if (rawSavedRoots && typeof rawSavedRoots === "object" && Array.isArray(rawSavedRoots.roots)) {
1507
+ savedRoots = rawSavedRoots.roots;
1508
+ }
1509
+
1510
+ const normalizedSavedRoots = [...new Set(
1511
+ savedRoots
1512
+ .map((root) => this._normalizeWorkspaceRoot(root))
1513
+ .filter(Boolean)
1514
+ )];
1515
+
1516
+ if (normalizedSavedRoots.length > 0) {
1517
+ const labels = rawLabels && typeof rawLabels === "object"
1518
+ ? rawLabels
1519
+ : rawSavedRoots && typeof rawSavedRoots === "object" && rawSavedRoots.labels && typeof rawSavedRoots.labels === "object"
1520
+ ? rawSavedRoots.labels
1521
+ : {};
1522
+ this.workspaceRootOptions = {
1523
+ roots: normalizedSavedRoots,
1524
+ labels
1525
+ };
1526
+ }
1527
+
1528
+ if (Array.isArray(rawActiveRoots)) {
1529
+ const normalizedActiveRoots = [...new Set(
1530
+ rawActiveRoots
1531
+ .map((root) => this._normalizeWorkspaceRoot(root))
1532
+ .filter(Boolean)
1533
+ )];
1534
+ if (normalizedActiveRoots.length > 0) {
1535
+ this.activeWorkspaceRoots = normalizedActiveRoots;
1536
+ this.userSelectedActiveWorkspaceRoots = true;
1537
+ }
1538
+ }
1539
+ }
1540
+
1541
+ _isCwdInRoot(cwd, root) {
1542
+ if (cwd === root) {
1543
+ return true;
1544
+ }
1545
+ return cwd.startsWith(`${root}/`);
1546
+ }
1547
+
1548
+ _filterThreadListResult(result) {
1549
+ if (!result || !Array.isArray(result.data)) {
1550
+ return result;
1551
+ }
1552
+
1553
+ const normalizedWorkspaceRoots = Array.isArray(this.workspaceRootOptions?.roots)
1554
+ ? this.workspaceRootOptions.roots
1555
+ .map((root) => this._normalizeWorkspaceRoot(root))
1556
+ .filter(Boolean)
1557
+ : [];
1558
+
1559
+ // Desktop effectively scopes sidebar data to known/saved roots, not only the
1560
+ // currently active root. Using saved roots prevents global clutter while still
1561
+ // allowing threads to appear under every configured project folder.
1562
+ if (normalizedWorkspaceRoots.length === 0) {
1563
+ return result;
1564
+ }
1565
+
1566
+ const filteredData = result.data.filter((item) => {
1567
+ const cwd = this._normalizeWorkspaceRoot(item?.cwd);
1568
+ if (!cwd) {
1569
+ return false;
1570
+ }
1571
+
1572
+ return normalizedWorkspaceRoots.some((root) => this._isCwdInRoot(cwd, root));
1573
+ });
1574
+
1575
+ if (filteredData.length === result.data.length) {
1576
+ return result;
1577
+ }
1578
+
1579
+ return {
1580
+ ...result,
1581
+ data: filteredData
1582
+ };
1583
+ }
1584
+
1585
+ _persistWorkspaceState({ writeToDisk = true } = {}) {
1586
+ const labels = this.workspaceRootOptions.labels || {};
1587
+ const roots = [...this.workspaceRootOptions.roots];
1588
+ const activeRoots = [...this.activeWorkspaceRoots];
1589
+
1590
+ this.globalState["active-workspace-roots"] = activeRoots;
1591
+ this.globalState["electron-saved-workspace-roots"] = roots;
1592
+ this.globalState["electron-workspace-root-labels"] = labels;
1593
+
1594
+ this.persistedAtomState.set("active-workspace-roots", this.activeWorkspaceRoots);
1595
+ this.persistedAtomState.set("electron-saved-workspace-roots", this.workspaceRootOptions);
1596
+ this.persistedAtomState.set("electron-workspace-root-labels", labels);
1597
+
1598
+ if (writeToDisk) {
1599
+ this._scheduleGlobalStateWrite();
1600
+ }
1601
+ }
1602
+
1603
+ _scheduleGlobalStateWrite() {
1604
+ if (!this.globalStatePath) {
1605
+ return;
1606
+ }
1607
+
1608
+ if (this.globalStateWriteTimer) {
1609
+ clearTimeout(this.globalStateWriteTimer);
1610
+ }
1611
+
1612
+ this.globalStateWriteTimer = setTimeout(() => {
1613
+ this.globalStateWriteTimer = null;
1614
+ void this._writeGlobalStateToDisk();
1615
+ }, 50);
1616
+
1617
+ if (typeof this.globalStateWriteTimer?.unref === "function") {
1618
+ this.globalStateWriteTimer.unref();
1619
+ }
1620
+ }
1621
+
1622
+ _buildGlobalStatePayload() {
1623
+ const persistedAtomState = {};
1624
+ for (const [key, value] of this.persistedAtomState.entries()) {
1625
+ if (
1626
+ key === "active-workspace-roots"
1627
+ || key === "electron-saved-workspace-roots"
1628
+ || key === "electron-workspace-root-labels"
1629
+ ) {
1630
+ continue;
1631
+ }
1632
+ persistedAtomState[key] = value;
1633
+ }
1634
+
1635
+ return {
1636
+ ...this.globalState,
1637
+ "active-workspace-roots": this.activeWorkspaceRoots,
1638
+ "electron-saved-workspace-roots": this.workspaceRootOptions.roots,
1639
+ "electron-workspace-root-labels": this.workspaceRootOptions.labels || {},
1640
+ "electron-persisted-atom-state": persistedAtomState
1641
+ };
1642
+ }
1643
+
1644
+ async _writeGlobalStateToDisk() {
1645
+ if (!this.globalStatePath) {
1646
+ return;
1647
+ }
1648
+
1649
+ try {
1650
+ const payload = this._buildGlobalStatePayload();
1651
+ await fs.mkdir(path.dirname(this.globalStatePath), { recursive: true });
1652
+ await fs.writeFile(this.globalStatePath, JSON.stringify(payload));
1653
+ } catch (error) {
1654
+ this.logger.warn("Failed to persist global state", {
1655
+ path: this.globalStatePath,
1656
+ error: toErrorMessage(error)
1657
+ });
1658
+ }
1659
+ }
1660
+
1661
+ _subscribeSharedObject(ws, key) {
1662
+ if (!key) {
1663
+ return;
1664
+ }
1665
+
1666
+ let subscribers = this.sharedObjectSubscribers.get(key);
1667
+ if (!subscribers) {
1668
+ subscribers = new Set();
1669
+ this.sharedObjectSubscribers.set(key, subscribers);
1670
+ }
1671
+ subscribers.add(ws);
1672
+
1673
+ this.sendMainMessage(ws, {
1674
+ type: "shared-object-updated",
1675
+ key,
1676
+ value: this.sharedObjects.get(key)
1677
+ });
1678
+ }
1679
+
1680
+ _unsubscribeSharedObject(ws, key) {
1681
+ if (!key) {
1682
+ return;
1683
+ }
1684
+
1685
+ const subscribers = this.sharedObjectSubscribers.get(key);
1686
+ if (!subscribers) {
1687
+ return;
1688
+ }
1689
+
1690
+ subscribers.delete(ws);
1691
+ if (subscribers.size === 0) {
1692
+ this.sharedObjectSubscribers.delete(key);
1693
+ }
1694
+ }
1695
+
1696
+ _setSharedObject(key, value) {
1697
+ if (!key) {
1698
+ return;
1699
+ }
1700
+
1701
+ this.sharedObjects.set(key, value);
1702
+ const subscribers = this.sharedObjectSubscribers.get(key);
1703
+ if (!subscribers) {
1704
+ return;
1705
+ }
1706
+
1707
+ for (const ws of subscribers) {
1708
+ this.sendMainMessage(ws, {
1709
+ type: "shared-object-updated",
1710
+ key,
1711
+ value
1712
+ });
1713
+ }
1714
+ }
1715
+
1716
+ async _archiveThread(ws, message) {
1717
+ // Renderer handles the real archive operation via `thread/archive`.
1718
+ // This event is a pre-archive signal and must not invoke archive again.
1719
+ void ws;
1720
+ void message;
1721
+ }
1722
+
1723
+ async _unarchiveThread(ws, message) {
1724
+ // Renderer handles the real unarchive operation via `thread/unarchive`.
1725
+ // This event is a pre-unarchive signal and must not invoke unarchive again.
1726
+ void ws;
1727
+ void message;
1728
+ }
1729
+
1730
+ async _handleThreadOverlayStartTurn(ws, message) {
1731
+ const requestId = message.requestId;
1732
+ if (!this.appServer) {
1733
+ this.sendMainMessage(ws, {
1734
+ type: "thread-overlay-proxy-start-turn-response",
1735
+ requestId,
1736
+ error: "app-server unavailable"
1737
+ });
1738
+ return;
1739
+ }
1740
+
1741
+ try {
1742
+ const params = message.params || message.turnStartParams || {};
1743
+ const response = await this.appServer.sendRequest("turn/start", params);
1744
+ this.sendMainMessage(ws, {
1745
+ type: "thread-overlay-proxy-start-turn-response",
1746
+ requestId,
1747
+ result: response?.result ?? null,
1748
+ error: null
1749
+ });
1750
+ } catch (error) {
1751
+ this.sendMainMessage(ws, {
1752
+ type: "thread-overlay-proxy-start-turn-response",
1753
+ requestId,
1754
+ result: null,
1755
+ error: toErrorMessage(error)
1756
+ });
1757
+ }
1758
+ }
1759
+
1760
+ async _handleThreadOverlayInterrupt(ws, message) {
1761
+ const requestId = message.requestId;
1762
+ if (!this.appServer) {
1763
+ this.sendMainMessage(ws, {
1764
+ type: "thread-overlay-proxy-interrupt-response",
1765
+ requestId,
1766
+ error: "app-server unavailable"
1767
+ });
1768
+ return;
1769
+ }
1770
+
1771
+ try {
1772
+ const params = message.params || message.interruptParams || {};
1773
+ await this.appServer.sendRequest("turn/interrupt", params);
1774
+ this.sendMainMessage(ws, {
1775
+ type: "thread-overlay-proxy-interrupt-response",
1776
+ requestId,
1777
+ error: null
1778
+ });
1779
+ } catch (error) {
1780
+ this.sendMainMessage(ws, {
1781
+ type: "thread-overlay-proxy-interrupt-response",
1782
+ requestId,
1783
+ error: toErrorMessage(error)
1784
+ });
1785
+ }
1786
+ }
1787
+
1788
+ async _handleWorkerMessage(ws, workerId, payload) {
1789
+ if (workerId !== "git") {
1790
+ this.sendBridgeError(ws, "unsupported_worker", `Unsupported worker id: ${workerId}`);
1791
+ return;
1792
+ }
1793
+
1794
+ await this.gitWorker.postMessage(ws, payload);
1795
+ }
1796
+
1797
+ _openInBrowser(ws, message) {
1798
+ const url = message.url || message.href;
1799
+ if (!url) {
1800
+ this.sendBridgeError(ws, "missing_url", "open-in-browser requires `url`.");
1801
+ return;
1802
+ }
1803
+
1804
+ const child = spawn("open", [url], {
1805
+ stdio: ["ignore", "ignore", "ignore"],
1806
+ detached: true
1807
+ });
1808
+ child.unref();
1809
+ }
1810
+
1811
+ sendBridgeEnvelope(ws, envelope) {
1812
+ if (!ws || ws.readyState !== 1) {
1813
+ return;
1814
+ }
1815
+ ws.send(JSON.stringify(envelope));
1816
+ }
1817
+
1818
+ sendMainMessage(ws, payload) {
1819
+ this.sendBridgeEnvelope(ws, {
1820
+ type: "main-message",
1821
+ payload
1822
+ });
1823
+ }
1824
+
1825
+ broadcastMainMessage(payload) {
1826
+ for (const ws of this.clients) {
1827
+ this.sendMainMessage(ws, payload);
1828
+ }
1829
+ }
1830
+
1831
+ sendWorkerEvent(ws, workerId, payload) {
1832
+ this.sendBridgeEnvelope(ws, {
1833
+ type: "worker-event",
1834
+ workerId,
1835
+ payload
1836
+ });
1837
+ }
1838
+
1839
+ broadcastWorkerEvent(workerId, payload) {
1840
+ for (const ws of this.clients) {
1841
+ this.sendWorkerEvent(ws, workerId, payload);
1842
+ }
1843
+ }
1844
+
1845
+ sendBridgeError(ws, code, message, details) {
1846
+ this.logger.warn("bridge-error", {
1847
+ code,
1848
+ message
1849
+ });
1850
+ this.sendBridgeEnvelope(ws, {
1851
+ type: "bridge-error",
1852
+ code,
1853
+ message,
1854
+ details
1855
+ });
1856
+ }
1857
+ }