codex-webstrapper 0.1.5 → 0.2.5

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.
@@ -1,9 +1,10 @@
1
- import { spawn } from "node:child_process";
1
+ import { spawn as childSpawn, spawnSync as childSpawnSync } from "node:child_process";
2
2
  import { Worker } from "node:worker_threads";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
- import { readFileSync } from "node:fs";
5
+ import { readFileSync, statSync } from "node:fs";
6
6
  import fs from "node:fs/promises";
7
+ import { fileURLToPath } from "node:url";
7
8
 
8
9
  import { createLogger, randomId, safeJsonParse, toErrorMessage } from "./util.mjs";
9
10
 
@@ -76,6 +77,11 @@ class TerminalRegistry {
76
77
  this.sendToWs = sendToWs;
77
78
  this.logger = logger;
78
79
  this.sessions = new Map();
80
+ this.bunPtyAvailable = this._detectBunPtyAvailability();
81
+ this.loggedBunPtyFailure = false;
82
+ this.pythonPtyAvailable = this._detectPythonPtyAvailability();
83
+ this.loggedPythonPtyFailure = false;
84
+ this.hasPtyLikeRuntime = this.bunPtyAvailable || this.pythonPtyAvailable;
79
85
  }
80
86
 
81
87
  createOrAttach(ws, message) {
@@ -83,41 +89,80 @@ class TerminalRegistry {
83
89
  const existing = this.sessions.get(sessionId);
84
90
  if (existing) {
85
91
  existing.listeners.add(ws);
86
- this.sendToWs(ws, { type: "terminal-attached", sessionId });
92
+ this.sendToWs(ws, {
93
+ type: "terminal-attached",
94
+ sessionId,
95
+ cwd: existing.cwd,
96
+ shell: existing.shell
97
+ });
98
+ this.sendToWs(ws, {
99
+ type: "terminal-init-log",
100
+ sessionId,
101
+ log: existing.attachLog
102
+ });
103
+ if (this._hasExplicitDimensions(message)) {
104
+ this.resize(sessionId, message.cols, message.rows);
105
+ }
87
106
  return;
88
107
  }
89
108
 
90
- const shell = process.env.SHELL || "/bin/zsh";
91
- const command = Array.isArray(message.command) && message.command.length > 0
92
- ? message.command
93
- : [shell];
109
+ const launchConfig = this._resolveLaunchConfig(message);
110
+ let runtime;
111
+ try {
112
+ runtime = this._spawnRuntime(launchConfig, message);
113
+ } catch (error) {
114
+ this.sendToWs(ws, {
115
+ type: "terminal-error",
116
+ sessionId,
117
+ message: toErrorMessage(error)
118
+ });
119
+ this.logger.warn("Terminal spawn failed", {
120
+ sessionId,
121
+ error: toErrorMessage(error)
122
+ });
123
+ return;
124
+ }
94
125
 
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
- });
126
+ const attachLogLines = [
127
+ `Terminal attached via codex-webstrapper (${runtime.mode})`
128
+ ];
129
+ if (launchConfig.cwdWasFallback && launchConfig.requestedCwd) {
130
+ attachLogLines.push(`[webstrap] Requested cwd unavailable: ${launchConfig.requestedCwd}`);
131
+ attachLogLines.push(`[webstrap] Using cwd: ${launchConfig.cwd}`);
132
+ }
133
+ const attachLog = `${attachLogLines.join("\r\n")}\r\n`;
104
134
 
105
135
  const session = {
106
136
  sessionId,
107
- proc,
108
- listeners: new Set([ws])
137
+ listeners: new Set([ws]),
138
+ cwd: launchConfig.cwd,
139
+ shell: launchConfig.shell,
140
+ attachLog,
141
+ cols: runtime.cols ?? this._coerceDimension(message?.cols, 120),
142
+ rows: runtime.rows ?? this._coerceDimension(message?.rows, 30),
143
+ ...runtime
109
144
  };
110
145
 
111
146
  this.sessions.set(sessionId, session);
112
147
 
113
- this.sendToWs(ws, { type: "terminal-attached", sessionId });
148
+ this.sendToWs(ws, {
149
+ type: "terminal-attached",
150
+ sessionId,
151
+ cwd: session.cwd,
152
+ shell: session.shell
153
+ });
114
154
  this.sendToWs(ws, {
115
155
  type: "terminal-init-log",
116
156
  sessionId,
117
- log: "Terminal attached via codex-webstrapper\r\n"
157
+ log: session.attachLog
118
158
  });
119
159
 
120
- proc.stdout?.on("data", (chunk) => {
160
+ if (session.mode === "bun-pty") {
161
+ this._attachBunPtyProcess(sessionId, session);
162
+ return;
163
+ }
164
+
165
+ session.proc.stdout?.on("data", (chunk) => {
121
166
  this._broadcast(sessionId, {
122
167
  type: "terminal-data",
123
168
  sessionId,
@@ -125,7 +170,7 @@ class TerminalRegistry {
125
170
  });
126
171
  });
127
172
 
128
- proc.stderr?.on("data", (chunk) => {
173
+ session.proc.stderr?.on("data", (chunk) => {
129
174
  this._broadcast(sessionId, {
130
175
  type: "terminal-data",
131
176
  sessionId,
@@ -133,7 +178,7 @@ class TerminalRegistry {
133
178
  });
134
179
  });
135
180
 
136
- proc.on("error", (error) => {
181
+ session.proc.on("error", (error) => {
137
182
  this._broadcast(sessionId, {
138
183
  type: "terminal-error",
139
184
  sessionId,
@@ -141,7 +186,7 @@ class TerminalRegistry {
141
186
  });
142
187
  });
143
188
 
144
- proc.on("exit", (code, signal) => {
189
+ session.proc.on("exit", (code, signal) => {
145
190
  this._broadcast(sessionId, {
146
191
  type: "terminal-exit",
147
192
  sessionId,
@@ -154,17 +199,72 @@ class TerminalRegistry {
154
199
 
155
200
  write(sessionId, data) {
156
201
  const session = this.sessions.get(sessionId);
157
- if (!session || !session.proc.stdin || session.proc.stdin.destroyed) {
202
+ if (!session) {
203
+ return;
204
+ }
205
+
206
+ if (session.mode === "bun-pty") {
207
+ try {
208
+ this._writeToBunPty(session, {
209
+ type: "write",
210
+ data
211
+ });
212
+ } catch (error) {
213
+ this._broadcast(sessionId, {
214
+ type: "terminal-error",
215
+ sessionId,
216
+ message: toErrorMessage(error)
217
+ });
218
+ }
158
219
  return;
159
220
  }
160
- session.proc.stdin.write(data);
221
+
222
+ if (!session.proc.stdin || session.proc.stdin.destroyed) {
223
+ return;
224
+ }
225
+
226
+ try {
227
+ session.proc.stdin.write(data);
228
+ } catch (error) {
229
+ this._broadcast(sessionId, {
230
+ type: "terminal-error",
231
+ sessionId,
232
+ message: toErrorMessage(error)
233
+ });
234
+ }
161
235
  }
162
236
 
163
- resize(sessionId) {
164
- if (!this.sessions.has(sessionId)) {
237
+ resize(sessionId, cols, rows) {
238
+ const session = this.sessions.get(sessionId);
239
+ if (!session) {
240
+ return;
241
+ }
242
+
243
+ if (session.mode === "bun-pty") {
244
+ try {
245
+ const nextCols = this._coerceDimension(cols, session.cols || 120);
246
+ const nextRows = this._coerceDimension(rows, session.rows || 30);
247
+ this._writeToBunPty(session, {
248
+ type: "resize",
249
+ cols: nextCols,
250
+ rows: nextRows
251
+ });
252
+ session.cols = nextCols;
253
+ session.rows = nextRows;
254
+ } catch (error) {
255
+ this._broadcast(sessionId, {
256
+ type: "terminal-error",
257
+ sessionId,
258
+ message: toErrorMessage(error)
259
+ });
260
+ }
261
+ return;
262
+ }
263
+
264
+ if (session.mode !== "python-pty") {
265
+ this.logger.debug("Terminal resize ignored (non-PTY mode)", { sessionId });
165
266
  return;
166
267
  }
167
- this.logger.debug("Terminal resize ignored (non-PTY mode)", { sessionId });
168
268
  }
169
269
 
170
270
  close(sessionId) {
@@ -173,6 +273,24 @@ class TerminalRegistry {
173
273
  return;
174
274
  }
175
275
 
276
+ if (session.mode === "bun-pty") {
277
+ try {
278
+ this._writeToBunPty(session, {
279
+ type: "close"
280
+ });
281
+ } catch (error) {
282
+ this.logger.debug("Bun PTY close command failed", {
283
+ sessionId,
284
+ error: toErrorMessage(error)
285
+ });
286
+ }
287
+ if (!session.proc.killed) {
288
+ session.proc.kill();
289
+ }
290
+ this.sessions.delete(sessionId);
291
+ return;
292
+ }
293
+
176
294
  if (!session.proc.killed) {
177
295
  session.proc.kill();
178
296
  }
@@ -204,6 +322,353 @@ class TerminalRegistry {
204
322
  this.sendToWs(listener, message);
205
323
  }
206
324
  }
325
+
326
+ _resolveLaunchConfig(message) {
327
+ const requestedCwd = typeof message?.cwd === "string" ? message.cwd.trim() : "";
328
+ const cwdResult = this._resolveCwd(requestedCwd);
329
+
330
+ const commandFromMessage = Array.isArray(message?.command)
331
+ ? message.command.filter((entry) => typeof entry === "string" && entry.length > 0)
332
+ : [];
333
+
334
+ let command = commandFromMessage;
335
+ let shell = null;
336
+
337
+ if (command.length === 0) {
338
+ shell = this._resolveShellPath(message?.shell);
339
+ command = this._defaultShellCommand(shell);
340
+ } else {
341
+ shell = command[0];
342
+ }
343
+
344
+ return {
345
+ command,
346
+ shell,
347
+ cwd: cwdResult.cwd,
348
+ requestedCwd: cwdResult.requestedCwd,
349
+ cwdWasFallback: cwdResult.cwdWasFallback,
350
+ env: this._buildEnv(message?.env)
351
+ };
352
+ }
353
+
354
+ _spawnRuntime(launchConfig, message) {
355
+ const bunPtyRuntime = this._spawnBunPtyRuntime(launchConfig, message);
356
+ if (bunPtyRuntime) {
357
+ return bunPtyRuntime;
358
+ }
359
+
360
+ const pythonPtyRuntime = this._spawnPythonPtyRuntime(launchConfig);
361
+ if (pythonPtyRuntime) {
362
+ return pythonPtyRuntime;
363
+ }
364
+
365
+ const [bin, ...args] = launchConfig.command;
366
+ const proc = childSpawn(bin, args, {
367
+ cwd: launchConfig.cwd,
368
+ env: launchConfig.env,
369
+ stdio: ["pipe", "pipe", "pipe"]
370
+ });
371
+
372
+ return {
373
+ mode: "pipe",
374
+ proc,
375
+ cols: this._coerceDimension(message?.cols, 120),
376
+ rows: this._coerceDimension(message?.rows, 30)
377
+ };
378
+ }
379
+
380
+ _spawnBunPtyRuntime(launchConfig, message) {
381
+ if (!this.bunPtyAvailable) {
382
+ return null;
383
+ }
384
+
385
+ const [bin, ...args] = launchConfig.command;
386
+ try {
387
+ const bridgePath = fileURLToPath(new URL("./bun-pty-bridge.mjs", import.meta.url));
388
+ const initialCols = this._coerceDimension(message?.cols, 120);
389
+ const initialRows = this._coerceDimension(message?.rows, 30);
390
+ const config = JSON.stringify({
391
+ file: bin,
392
+ args,
393
+ cwd: launchConfig.cwd,
394
+ env: launchConfig.env,
395
+ cols: initialCols,
396
+ rows: initialRows,
397
+ term: launchConfig.env.TERM || "xterm-256color"
398
+ });
399
+
400
+ const proc = childSpawn("bun", [bridgePath], {
401
+ cwd: launchConfig.cwd,
402
+ env: {
403
+ ...launchConfig.env,
404
+ CODEX_WEBSTRAP_BUN_PTY_CONFIG: config
405
+ },
406
+ stdio: ["pipe", "pipe", "pipe"]
407
+ });
408
+
409
+ return {
410
+ mode: "bun-pty",
411
+ proc,
412
+ bunStdoutBuffer: "",
413
+ cols: initialCols,
414
+ rows: initialRows
415
+ };
416
+ } catch (error) {
417
+ this.bunPtyAvailable = false;
418
+ if (!this.loggedBunPtyFailure) {
419
+ this.loggedBunPtyFailure = true;
420
+ this.logger.warn("Bun PTY unavailable; falling back", {
421
+ error: toErrorMessage(error)
422
+ });
423
+ }
424
+ return null;
425
+ }
426
+ }
427
+
428
+ _spawnPythonPtyRuntime(launchConfig) {
429
+ if (!this.pythonPtyAvailable) {
430
+ return null;
431
+ }
432
+
433
+ const [bin, ...args] = launchConfig.command;
434
+ try {
435
+ const proc = childSpawn(
436
+ "python3",
437
+ [
438
+ "-c",
439
+ "import pty, sys; pty.spawn(sys.argv[1:])",
440
+ bin,
441
+ ...args
442
+ ],
443
+ {
444
+ cwd: launchConfig.cwd,
445
+ env: launchConfig.env,
446
+ stdio: ["pipe", "pipe", "pipe"]
447
+ }
448
+ );
449
+ return {
450
+ mode: "python-pty",
451
+ proc,
452
+ cols: 120,
453
+ rows: 30
454
+ };
455
+ } catch (error) {
456
+ this.pythonPtyAvailable = false;
457
+ if (!this.loggedPythonPtyFailure) {
458
+ this.loggedPythonPtyFailure = true;
459
+ this.logger.warn("python3 PTY fallback unavailable; using pipe terminal", {
460
+ error: toErrorMessage(error)
461
+ });
462
+ }
463
+ return null;
464
+ }
465
+ }
466
+
467
+ _attachBunPtyProcess(sessionId, session) {
468
+ const handleBridgeLine = (line) => {
469
+ const parsed = safeJsonParse(line);
470
+ if (!parsed || typeof parsed !== "object") {
471
+ return;
472
+ }
473
+
474
+ if (parsed.type === "data") {
475
+ this._broadcast(sessionId, {
476
+ type: "terminal-data",
477
+ sessionId,
478
+ data: typeof parsed.data === "string" ? parsed.data : ""
479
+ });
480
+ return;
481
+ }
482
+
483
+ if (parsed.type === "error") {
484
+ this._broadcast(sessionId, {
485
+ type: "terminal-error",
486
+ sessionId,
487
+ message: typeof parsed.message === "string" ? parsed.message : "bun-pty bridge error"
488
+ });
489
+ return;
490
+ }
491
+
492
+ if (parsed.type === "exit") {
493
+ this._broadcast(sessionId, {
494
+ type: "terminal-exit",
495
+ sessionId,
496
+ code: typeof parsed.exitCode === "number" ? parsed.exitCode : null,
497
+ signal: parsed.signal ?? null
498
+ });
499
+ this.sessions.delete(sessionId);
500
+ }
501
+ };
502
+
503
+ session.proc.stdout?.on("data", (chunk) => {
504
+ session.bunStdoutBuffer += chunk.toString("utf8");
505
+ for (;;) {
506
+ const newlineAt = session.bunStdoutBuffer.indexOf("\n");
507
+ if (newlineAt < 0) {
508
+ break;
509
+ }
510
+ const line = session.bunStdoutBuffer.slice(0, newlineAt);
511
+ session.bunStdoutBuffer = session.bunStdoutBuffer.slice(newlineAt + 1);
512
+ if (line.trim().length === 0) {
513
+ continue;
514
+ }
515
+ handleBridgeLine(line);
516
+ }
517
+ });
518
+
519
+ session.proc.stderr?.on("data", (chunk) => {
520
+ this._broadcast(sessionId, {
521
+ type: "terminal-error",
522
+ sessionId,
523
+ message: chunk.toString("utf8")
524
+ });
525
+ });
526
+
527
+ session.proc.on("error", (error) => {
528
+ this._broadcast(sessionId, {
529
+ type: "terminal-error",
530
+ sessionId,
531
+ message: toErrorMessage(error)
532
+ });
533
+ });
534
+
535
+ session.proc.on("exit", (code, signal) => {
536
+ if (!this.sessions.has(sessionId)) {
537
+ return;
538
+ }
539
+ this._broadcast(sessionId, {
540
+ type: "terminal-exit",
541
+ sessionId,
542
+ code,
543
+ signal
544
+ });
545
+ this.sessions.delete(sessionId);
546
+ });
547
+ }
548
+
549
+ _writeToBunPty(session, payload) {
550
+ if (!session?.proc?.stdin || session.proc.stdin.destroyed) {
551
+ return;
552
+ }
553
+ session.proc.stdin.write(`${JSON.stringify(payload)}\n`);
554
+ }
555
+
556
+ _detectBunPtyAvailability() {
557
+ const hasBun = childSpawnSync("bun", ["--version"], { stdio: "ignore" });
558
+ if (hasBun.status !== 0 || hasBun.error) {
559
+ return false;
560
+ }
561
+
562
+ const probe = childSpawnSync(
563
+ "bun",
564
+ ["-e", "import 'bun-pty';"],
565
+ { stdio: "ignore" }
566
+ );
567
+ return probe.status === 0 && !probe.error;
568
+ }
569
+
570
+ _detectPythonPtyAvailability() {
571
+ const probe = childSpawnSync(
572
+ "python3",
573
+ ["-c", "import pty"],
574
+ { stdio: "ignore" }
575
+ );
576
+ return probe.status === 0 && !probe.error;
577
+ }
578
+
579
+ _buildEnv(messageEnv) {
580
+ const env = {
581
+ ...process.env,
582
+ ...(messageEnv && typeof messageEnv === "object" ? messageEnv : {})
583
+ };
584
+
585
+ if (!env.TERM || env.TERM === "dumb") {
586
+ env.TERM = "xterm-256color";
587
+ }
588
+ if (!env.COLORTERM) {
589
+ env.COLORTERM = "truecolor";
590
+ }
591
+ if (!env.TERM_PROGRAM) {
592
+ env.TERM_PROGRAM = "codex-webstrapper";
593
+ }
594
+ return env;
595
+ }
596
+
597
+ _resolveShellPath(messageShell) {
598
+ if (typeof messageShell === "string" && messageShell.trim().length > 0) {
599
+ return messageShell.trim();
600
+ }
601
+ return process.env.SHELL || "/bin/zsh";
602
+ }
603
+
604
+ _defaultShellCommand(shellPath) {
605
+ const shellName = path.basename(shellPath).toLowerCase();
606
+ const disableProfileLoad = process.env.CODEX_WEBSTRAP_TERMINAL_NO_PROFILE === "1";
607
+ const preferLoginProfile = this.hasPtyLikeRuntime && !disableProfileLoad;
608
+
609
+ if (shellName === "zsh") {
610
+ return preferLoginProfile
611
+ ? [shellPath, "-il"]
612
+ : [shellPath, "-fi"];
613
+ }
614
+ if (shellName === "bash") {
615
+ return preferLoginProfile
616
+ ? [shellPath, "-il"]
617
+ : [shellPath, "--noprofile", "--norc", "-i"];
618
+ }
619
+ if (shellName === "fish") {
620
+ return preferLoginProfile
621
+ ? [shellPath, "-il"]
622
+ : [shellPath, "-i"];
623
+ }
624
+ return [shellPath, "-i"];
625
+ }
626
+
627
+ _resolveCwd(requestedCwd) {
628
+ const requested = requestedCwd && requestedCwd.length > 0
629
+ ? path.resolve(requestedCwd)
630
+ : null;
631
+ if (requested && this._isDirectory(requested)) {
632
+ return {
633
+ cwd: requested,
634
+ requestedCwd: requested,
635
+ cwdWasFallback: false
636
+ };
637
+ }
638
+
639
+ const fallbackCandidates = [process.cwd(), os.homedir(), "/"];
640
+ const fallback = fallbackCandidates.find((candidate) => this._isDirectory(candidate)) || process.cwd();
641
+ return {
642
+ cwd: fallback,
643
+ requestedCwd: requested,
644
+ cwdWasFallback: Boolean(requested)
645
+ };
646
+ }
647
+
648
+ _isDirectory(candidatePath) {
649
+ if (!candidatePath || typeof candidatePath !== "string") {
650
+ return false;
651
+ }
652
+ try {
653
+ return statSync(candidatePath).isDirectory();
654
+ } catch {
655
+ return false;
656
+ }
657
+ }
658
+
659
+ _coerceDimension(value, fallback) {
660
+ const parsed = Number(value);
661
+ if (!Number.isFinite(parsed) || parsed <= 0) {
662
+ return fallback;
663
+ }
664
+ return Math.max(1, Math.floor(parsed));
665
+ }
666
+
667
+ _hasExplicitDimensions(message) {
668
+ const cols = Number(message?.cols);
669
+ const rows = Number(message?.rows);
670
+ return Number.isFinite(cols) && cols > 0 && Number.isFinite(rows) && rows > 0;
671
+ }
207
672
  }
208
673
 
209
674
  class GitWorkerBridge {
@@ -543,7 +1008,7 @@ export class MessageRouter {
543
1008
  this.terminals.write(message.sessionId, message.data || "");
544
1009
  return;
545
1010
  case "terminal-resize":
546
- this.terminals.resize(message.sessionId);
1011
+ this.terminals.resize(message.sessionId, message.cols, message.rows);
547
1012
  return;
548
1013
  case "terminal-close":
549
1014
  this.terminals.close(message.sessionId);
@@ -753,14 +1218,25 @@ export class MessageRouter {
753
1218
  this.logger.debug("renderer-fetch", {
754
1219
  requestId,
755
1220
  method: message.method || "GET",
756
- url: message.url || null,
757
- body: typeof message.body === "string" ? message.body.slice(0, 400) : null
1221
+ url: this._sanitizeUrlForLogs(message.url),
1222
+ hasBody: message.body != null
758
1223
  });
759
1224
 
760
1225
  if (await this._handleVirtualFetch(ws, requestId, message)) {
761
1226
  return;
762
1227
  }
763
1228
 
1229
+ if (typeof message.url === "string" && message.url === "/transcribe") {
1230
+ const controller = new AbortController();
1231
+ this.fetchControllers.set(requestId, controller);
1232
+ try {
1233
+ await this._handleTranscribeFetch(ws, requestId, message, controller.signal);
1234
+ } finally {
1235
+ this.fetchControllers.delete(requestId);
1236
+ }
1237
+ return;
1238
+ }
1239
+
764
1240
  const resolvedUrl = this._resolveFetchUrl(message.url);
765
1241
  if (!resolvedUrl) {
766
1242
  this.sendMainMessage(ws, {
@@ -772,7 +1248,7 @@ export class MessageRouter {
772
1248
  });
773
1249
  this.logger.warn("renderer-fetch-failed", {
774
1250
  requestId,
775
- url: message.url || null,
1251
+ url: this._sanitizeUrlForLogs(message.url),
776
1252
  error: "unsupported_fetch_url"
777
1253
  });
778
1254
  return;
@@ -782,10 +1258,11 @@ export class MessageRouter {
782
1258
  this.fetchControllers.set(requestId, controller);
783
1259
 
784
1260
  try {
1261
+ const outbound = this._prepareOutgoingFetchRequest(message);
785
1262
  const response = await fetch(resolvedUrl, {
786
- method: message.method || "GET",
787
- headers: message.headers || {},
788
- body: message.body,
1263
+ method: outbound.method,
1264
+ headers: outbound.headers,
1265
+ body: outbound.body,
789
1266
  signal: controller.signal
790
1267
  });
791
1268
 
@@ -814,7 +1291,7 @@ export class MessageRouter {
814
1291
  requestId,
815
1292
  status: response.status,
816
1293
  ok: response.ok,
817
- url: response.url || resolvedUrl
1294
+ url: this._sanitizeUrlForLogs(response.url || resolvedUrl)
818
1295
  });
819
1296
  } catch (error) {
820
1297
  this.sendMainMessage(ws, {
@@ -826,7 +1303,7 @@ export class MessageRouter {
826
1303
  });
827
1304
  this.logger.warn("renderer-fetch-failed", {
828
1305
  requestId,
829
- url: resolvedUrl,
1306
+ url: this._sanitizeUrlForLogs(resolvedUrl),
830
1307
  error: toErrorMessage(error)
831
1308
  });
832
1309
  } finally {
@@ -834,31 +1311,296 @@ export class MessageRouter {
834
1311
  }
835
1312
  }
836
1313
 
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
- }
1314
+ async _handleTranscribeFetch(ws, requestId, message, signal) {
1315
+ try {
1316
+ const outbound = this._prepareOutgoingFetchRequest(message);
1317
+ const bodyBuffer = this._asBuffer(outbound.body);
1318
+ const contentType = this._readHeader(outbound.headers, "content-type");
1319
+ const boundary = this._extractMultipartBoundary(contentType);
1320
+ if (!boundary) {
1321
+ throw new Error("Missing multipart boundary for /transcribe request.");
1322
+ }
849
1323
 
850
- async _handleVirtualFetch(ws, requestId, message) {
851
- if (typeof message.url !== "string") {
852
- return false;
853
- }
1324
+ const { fields, files } = this._parseMultipartBody(bodyBuffer, boundary);
1325
+ const file = files.find((part) => part.name === "file") || files[0];
1326
+ if (!file || !file.data || file.data.length === 0) {
1327
+ throw new Error("Missing audio file in /transcribe request.");
1328
+ }
854
1329
 
855
- if (message.url.startsWith("sentry-ipc://")) {
856
- this._sendFetchJson(ws, {
857
- requestId,
858
- url: message.url,
859
- status: 204,
860
- payload: ""
861
- });
1330
+ const authToken = await this._resolveTranscriptionAuthToken();
1331
+ if (!authToken) {
1332
+ throw new Error("Dictation requires ChatGPT authentication in Codex.");
1333
+ }
1334
+
1335
+ const model = process.env.CODEX_WEBSTRAP_TRANSCRIBE_MODEL || "gpt-4o-mini-transcribe";
1336
+ const form = new FormData();
1337
+ form.append("model", model);
1338
+ if (typeof fields.language === "string" && fields.language.trim().length > 0) {
1339
+ form.append("language", fields.language.trim());
1340
+ }
1341
+ form.append(
1342
+ "file",
1343
+ new Blob([file.data], { type: file.contentType || "audio/webm" }),
1344
+ file.filename || "codex.webm"
1345
+ );
1346
+
1347
+ const response = await fetch("https://api.openai.com/v1/audio/transcriptions", {
1348
+ method: "POST",
1349
+ headers: {
1350
+ Authorization: `Bearer ${authToken}`
1351
+ },
1352
+ body: form,
1353
+ signal
1354
+ });
1355
+ const responseText = await response.text();
1356
+
1357
+ let bodyJsonString = responseText;
1358
+ if (response.ok) {
1359
+ const parsed = safeJsonParse(responseText);
1360
+ if (parsed && typeof parsed === "object" && typeof parsed.text === "string") {
1361
+ bodyJsonString = JSON.stringify({ text: parsed.text });
1362
+ } else {
1363
+ bodyJsonString = JSON.stringify({ text: "" });
1364
+ }
1365
+ }
1366
+
1367
+ this.sendMainMessage(ws, {
1368
+ type: "fetch-response",
1369
+ requestId,
1370
+ responseType: "success",
1371
+ status: response.status,
1372
+ headers: {
1373
+ "content-type": response.headers.get("content-type") || "application/json"
1374
+ },
1375
+ bodyJsonString
1376
+ });
1377
+ } catch (error) {
1378
+ this.sendMainMessage(ws, {
1379
+ type: "fetch-response",
1380
+ requestId,
1381
+ responseType: "error",
1382
+ status: 0,
1383
+ error: toErrorMessage(error)
1384
+ });
1385
+ this.logger.warn("transcribe-fetch-failed", {
1386
+ requestId,
1387
+ error: toErrorMessage(error)
1388
+ });
1389
+ }
1390
+ }
1391
+
1392
+ _prepareOutgoingFetchRequest(message) {
1393
+ const method = message?.method || "GET";
1394
+ const headers = message?.headers && typeof message.headers === "object"
1395
+ ? { ...message.headers }
1396
+ : {};
1397
+ let body = message?.body;
1398
+
1399
+ const base64Marker = this._readHeader(headers, "x-codex-base64");
1400
+ if (base64Marker === "1") {
1401
+ this._deleteHeader(headers, "x-codex-base64");
1402
+ if (typeof body !== "string") {
1403
+ throw new Error("X-Codex-Base64 fetch body must be a base64 string.");
1404
+ }
1405
+ body = Buffer.from(body, "base64");
1406
+ }
1407
+
1408
+ return {
1409
+ method,
1410
+ headers,
1411
+ body
1412
+ };
1413
+ }
1414
+
1415
+ _asBuffer(body) {
1416
+ if (Buffer.isBuffer(body)) {
1417
+ return body;
1418
+ }
1419
+ if (body instanceof Uint8Array) {
1420
+ return Buffer.from(body);
1421
+ }
1422
+ if (typeof body === "string") {
1423
+ return Buffer.from(body, "utf8");
1424
+ }
1425
+ if (body == null) {
1426
+ return Buffer.alloc(0);
1427
+ }
1428
+ return Buffer.from(String(body), "utf8");
1429
+ }
1430
+
1431
+ _extractMultipartBoundary(contentType) {
1432
+ if (typeof contentType !== "string") {
1433
+ return null;
1434
+ }
1435
+ const match = contentType.match(/boundary=([^;]+)/i);
1436
+ if (!match || !match[1]) {
1437
+ return null;
1438
+ }
1439
+ return match[1].trim().replace(/^"|"$/g, "");
1440
+ }
1441
+
1442
+ _parseMultipartBody(body, boundary) {
1443
+ const files = [];
1444
+ const fields = {};
1445
+ const delimiter = Buffer.from(`--${boundary}`);
1446
+ const partSeparator = Buffer.from("\r\n\r\n");
1447
+
1448
+ let cursor = 0;
1449
+ for (;;) {
1450
+ const start = body.indexOf(delimiter, cursor);
1451
+ if (start < 0) {
1452
+ break;
1453
+ }
1454
+
1455
+ cursor = start + delimiter.length;
1456
+ if (body[cursor] === 45 && body[cursor + 1] === 45) {
1457
+ break;
1458
+ }
1459
+ if (body[cursor] === 13 && body[cursor + 1] === 10) {
1460
+ cursor += 2;
1461
+ }
1462
+
1463
+ const headerEnd = body.indexOf(partSeparator, cursor);
1464
+ if (headerEnd < 0) {
1465
+ break;
1466
+ }
1467
+
1468
+ const headersText = body.slice(cursor, headerEnd).toString("utf8");
1469
+ const contentStart = headerEnd + partSeparator.length;
1470
+ const nextBoundary = body.indexOf(Buffer.from(`\r\n--${boundary}`), contentStart);
1471
+ const contentEnd = nextBoundary >= 0 ? nextBoundary : body.length;
1472
+ const content = body.slice(contentStart, contentEnd);
1473
+
1474
+ const headers = {};
1475
+ for (const line of headersText.split("\r\n")) {
1476
+ const splitAt = line.indexOf(":");
1477
+ if (splitAt <= 0) {
1478
+ continue;
1479
+ }
1480
+ const key = line.slice(0, splitAt).trim().toLowerCase();
1481
+ const value = line.slice(splitAt + 1).trim();
1482
+ headers[key] = value;
1483
+ }
1484
+
1485
+ const disposition = headers["content-disposition"] || "";
1486
+ const nameMatch = disposition.match(/\bname="([^"]+)"/i);
1487
+ const filenameMatch = disposition.match(/\bfilename="([^"]+)"/i);
1488
+ const name = nameMatch ? nameMatch[1] : null;
1489
+ const filename = filenameMatch ? filenameMatch[1] : null;
1490
+ const part = {
1491
+ name,
1492
+ filename,
1493
+ contentType: headers["content-type"] || null,
1494
+ data: content
1495
+ };
1496
+
1497
+ if (filename) {
1498
+ files.push(part);
1499
+ } else if (name) {
1500
+ fields[name] = content.toString("utf8");
1501
+ }
1502
+
1503
+ cursor = contentEnd + 2;
1504
+ }
1505
+
1506
+ return { fields, files };
1507
+ }
1508
+
1509
+ async _resolveTranscriptionAuthToken() {
1510
+ if (!this.appServer) {
1511
+ return null;
1512
+ }
1513
+ try {
1514
+ const response = await this.appServer.sendRequest("getAuthStatus", {
1515
+ includeToken: true,
1516
+ refreshToken: true
1517
+ }, {
1518
+ timeoutMs: 10_000
1519
+ });
1520
+ const token = response?.result?.authToken;
1521
+ return typeof token === "string" && token.trim().length > 0 ? token.trim() : null;
1522
+ } catch (error) {
1523
+ this.logger.warn("Failed to resolve transcription auth token", {
1524
+ error: toErrorMessage(error)
1525
+ });
1526
+ return null;
1527
+ }
1528
+ }
1529
+
1530
+ _readHeader(headers, name) {
1531
+ if (!headers || typeof headers !== "object") {
1532
+ return null;
1533
+ }
1534
+
1535
+ const target = String(name).toLowerCase();
1536
+ for (const [key, value] of Object.entries(headers)) {
1537
+ if (String(key).toLowerCase() !== target) {
1538
+ continue;
1539
+ }
1540
+ if (Array.isArray(value)) {
1541
+ return value.length > 0 ? String(value[0]) : "";
1542
+ }
1543
+ return value == null ? null : String(value);
1544
+ }
1545
+ return null;
1546
+ }
1547
+
1548
+ _deleteHeader(headers, name) {
1549
+ if (!headers || typeof headers !== "object") {
1550
+ return;
1551
+ }
1552
+
1553
+ const target = String(name).toLowerCase();
1554
+ for (const key of Object.keys(headers)) {
1555
+ if (String(key).toLowerCase() === target) {
1556
+ delete headers[key];
1557
+ }
1558
+ }
1559
+ }
1560
+
1561
+ _sanitizeUrlForLogs(url) {
1562
+ if (typeof url !== "string" || url.length === 0) {
1563
+ return null;
1564
+ }
1565
+
1566
+ // Drop query params and fragments from logs to avoid leaking tokens/user data.
1567
+ const withoutQuery = url.split("?")[0]?.split("#")[0] ?? "";
1568
+ if (withoutQuery.startsWith("http://") || withoutQuery.startsWith("https://")) {
1569
+ try {
1570
+ const parsed = new URL(withoutQuery);
1571
+ return `${parsed.origin}${parsed.pathname}`;
1572
+ } catch {
1573
+ return withoutQuery;
1574
+ }
1575
+ }
1576
+ return withoutQuery;
1577
+ }
1578
+
1579
+ _resolveFetchUrl(url) {
1580
+ if (typeof url !== "string" || url.length === 0) {
1581
+ return null;
1582
+ }
1583
+ if (url.startsWith("http://") || url.startsWith("https://")) {
1584
+ return url;
1585
+ }
1586
+ if (url.startsWith("/")) {
1587
+ return `https://chatgpt.com${url}`;
1588
+ }
1589
+ return null;
1590
+ }
1591
+
1592
+ async _handleVirtualFetch(ws, requestId, message) {
1593
+ if (typeof message.url !== "string") {
1594
+ return false;
1595
+ }
1596
+
1597
+ if (message.url.startsWith("sentry-ipc://")) {
1598
+ this._sendFetchJson(ws, {
1599
+ requestId,
1600
+ url: message.url,
1601
+ status: 204,
1602
+ payload: ""
1603
+ });
862
1604
  this.logger.debug("renderer-fetch-response", {
863
1605
  requestId,
864
1606
  status: 204,
@@ -1051,6 +1793,16 @@ export class MessageRouter {
1051
1793
  };
1052
1794
  break;
1053
1795
  }
1796
+ case "git-create-branch": {
1797
+ payload = await this._handleGitCreateBranch(params);
1798
+ status = payload.ok ? 200 : 500;
1799
+ break;
1800
+ }
1801
+ case "git-checkout-branch": {
1802
+ payload = await this._handleGitCheckoutBranch(params);
1803
+ status = payload.ok ? 200 : 500;
1804
+ break;
1805
+ }
1054
1806
  case "git-push": {
1055
1807
  payload = await this._handleGitPush(params);
1056
1808
  status = payload.ok ? 200 : 500;
@@ -1108,14 +1860,29 @@ export class MessageRouter {
1108
1860
  payload = await this._resolveGhPrStatus({ cwd, headBranch });
1109
1861
  break;
1110
1862
  }
1863
+ case "generate-pull-request-message":
1864
+ payload = await this._handleGeneratePullRequestMessage(params);
1865
+ break;
1866
+ case "gh-pr-create":
1867
+ payload = await this._handleGhPrCreate(params);
1868
+ break;
1111
1869
  case "paths-exist": {
1112
1870
  const paths = Array.isArray(params?.paths) ? params.paths.filter((p) => typeof p === "string") : [];
1113
1871
  payload = { existingPaths: paths };
1114
1872
  break;
1115
1873
  }
1116
1874
  default:
1117
- this.logger.warn("Unhandled vscode fetch endpoint", { endpoint });
1118
- payload = {};
1875
+ if (endpoint.startsWith("git-")) {
1876
+ this.logger.warn("Unhandled git vscode fetch endpoint", { endpoint });
1877
+ payload = {
1878
+ ok: false,
1879
+ error: `unhandled git endpoint: ${endpoint}`
1880
+ };
1881
+ status = 500;
1882
+ } else {
1883
+ this.logger.warn("Unhandled vscode fetch endpoint", { endpoint });
1884
+ payload = {};
1885
+ }
1119
1886
  }
1120
1887
 
1121
1888
  this._sendFetchJson(ws, {
@@ -1318,6 +2085,350 @@ export class MessageRouter {
1318
2085
  };
1319
2086
  }
1320
2087
 
2088
+ async _handleGeneratePullRequestMessage(params) {
2089
+ const cwd = typeof params?.cwd === "string" && params.cwd.length > 0
2090
+ ? params.cwd
2091
+ : process.cwd();
2092
+ const prompt = typeof params?.prompt === "string" ? params.prompt : "";
2093
+ const generated = await this._generatePullRequestMessageWithCodex({ cwd, prompt });
2094
+ const fallback = generated || await this._generateFallbackPullRequestMessage({ cwd, prompt });
2095
+ const title = this._normalizePullRequestTitle(fallback.title);
2096
+ const body = this._normalizePullRequestBody(fallback.body);
2097
+
2098
+ return {
2099
+ status: "success",
2100
+ title,
2101
+ body,
2102
+ // Older clients read `bodyInstructions`; keep it in sync with the generated body.
2103
+ bodyInstructions: body
2104
+ };
2105
+ }
2106
+
2107
+ _normalizePullRequestTitle(title) {
2108
+ if (typeof title !== "string") {
2109
+ return "Update project files";
2110
+ }
2111
+
2112
+ const normalized = title.replace(/\s+/g, " ").trim();
2113
+ if (normalized.length === 0) {
2114
+ return "Update project files";
2115
+ }
2116
+ if (normalized.length <= 120) {
2117
+ return normalized;
2118
+ }
2119
+ return `${normalized.slice(0, 117).trimEnd()}...`;
2120
+ }
2121
+
2122
+ _normalizePullRequestBody(body) {
2123
+ if (typeof body !== "string") {
2124
+ return "## Summary\n- Update project files.\n\n## Testing\n- Not run (context not provided).";
2125
+ }
2126
+
2127
+ const normalized = body.trim();
2128
+ if (normalized.length > 0) {
2129
+ return normalized;
2130
+ }
2131
+ return "## Summary\n- Update project files.\n\n## Testing\n- Not run (context not provided).";
2132
+ }
2133
+
2134
+ _buildPullRequestCodexPrompt(prompt) {
2135
+ const context = typeof prompt === "string" && prompt.trim().length > 0
2136
+ ? prompt.trim().slice(0, 20_000)
2137
+ : "No additional context was provided.";
2138
+
2139
+ return [
2140
+ "Generate a GitHub pull request title and body.",
2141
+ "Return JSON that matches the provided schema.",
2142
+ "Requirements:",
2143
+ "- title: concise imperative sentence, maximum 72 characters.",
2144
+ "- body: markdown with sections exactly '## Summary' and '## Testing'.",
2145
+ "- Summary should include 2 to 6 concrete bullet points.",
2146
+ "- Testing should include bullet points. If unknown, say '- Not run (context not provided).'.",
2147
+ "- Do not wrap output in code fences.",
2148
+ "- Use only the provided context.",
2149
+ "",
2150
+ "Context:",
2151
+ context
2152
+ ].join("\n");
2153
+ }
2154
+
2155
+ async _generatePullRequestMessageWithCodex({ cwd, prompt }) {
2156
+ let tempDir = null;
2157
+ try {
2158
+ tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "codex-webstrap-prmsg-"));
2159
+ const schemaPath = path.join(tempDir, "schema.json");
2160
+ const outputPath = path.join(tempDir, "output.json");
2161
+ const schema = {
2162
+ $schema: "http://json-schema.org/draft-07/schema#",
2163
+ type: "object",
2164
+ required: ["title", "body"],
2165
+ additionalProperties: false,
2166
+ properties: {
2167
+ title: { type: "string" },
2168
+ body: { type: "string" }
2169
+ }
2170
+ };
2171
+ await fs.writeFile(schemaPath, JSON.stringify(schema), "utf8");
2172
+
2173
+ const result = await this._runCommand(
2174
+ "codex",
2175
+ [
2176
+ "exec",
2177
+ "--ephemeral",
2178
+ "--sandbox",
2179
+ "read-only",
2180
+ "--output-schema",
2181
+ schemaPath,
2182
+ "--output-last-message",
2183
+ outputPath,
2184
+ this._buildPullRequestCodexPrompt(prompt)
2185
+ ],
2186
+ {
2187
+ timeoutMs: 120_000,
2188
+ allowNonZero: true,
2189
+ cwd
2190
+ }
2191
+ );
2192
+
2193
+ if (!result.ok) {
2194
+ this.logger.warn("PR message generation via codex failed", {
2195
+ cwd,
2196
+ error: result.error || result.stderr || "unknown error"
2197
+ });
2198
+ return null;
2199
+ }
2200
+
2201
+ const rawOutput = await fs.readFile(outputPath, "utf8");
2202
+ const parsed = safeJsonParse(rawOutput);
2203
+ if (!parsed || typeof parsed !== "object") {
2204
+ return null;
2205
+ }
2206
+
2207
+ const title = this._normalizePullRequestTitle(parsed.title);
2208
+ const body = this._normalizePullRequestBody(parsed.body);
2209
+ return { title, body };
2210
+ } catch (error) {
2211
+ this.logger.warn("PR message generation via codex errored", {
2212
+ cwd,
2213
+ error: toErrorMessage(error)
2214
+ });
2215
+ return null;
2216
+ } finally {
2217
+ if (tempDir) {
2218
+ await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
2219
+ }
2220
+ }
2221
+ }
2222
+
2223
+ async _resolvePullRequestBaseRef(cwd) {
2224
+ const originHead = await this._runCommand(
2225
+ "git",
2226
+ ["-C", cwd, "symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
2227
+ { timeoutMs: 5_000, allowNonZero: true, cwd }
2228
+ );
2229
+ if (originHead.ok && originHead.stdout) {
2230
+ return originHead.stdout;
2231
+ }
2232
+
2233
+ const candidates = ["origin/main", "origin/master", "main", "master"];
2234
+ for (const candidate of candidates) {
2235
+ const exists = await this._runCommand(
2236
+ "git",
2237
+ ["-C", cwd, "rev-parse", "--verify", "--quiet", candidate],
2238
+ { timeoutMs: 5_000, allowNonZero: true, cwd }
2239
+ );
2240
+ if (exists.code === 0) {
2241
+ return candidate;
2242
+ }
2243
+ }
2244
+
2245
+ return null;
2246
+ }
2247
+
2248
+ async _generateFallbackPullRequestMessage({ cwd, prompt }) {
2249
+ const baseRef = await this._resolvePullRequestBaseRef(cwd);
2250
+ const logArgs = baseRef
2251
+ ? ["-C", cwd, "log", "--no-merges", "--pretty=format:%s", `${baseRef}..HEAD`, "-n", "6"]
2252
+ : ["-C", cwd, "log", "--no-merges", "--pretty=format:%s", "-n", "6"];
2253
+ const logResult = await this._runCommand("git", logArgs, {
2254
+ timeoutMs: 8_000,
2255
+ allowNonZero: true,
2256
+ cwd
2257
+ });
2258
+ const commitSubjects = (logResult.stdout || "")
2259
+ .split("\n")
2260
+ .map((line) => line.trim())
2261
+ .filter(Boolean);
2262
+
2263
+ const range = baseRef ? `${baseRef}...HEAD` : "HEAD~1..HEAD";
2264
+ const filesResult = await this._runCommand(
2265
+ "git",
2266
+ ["-C", cwd, "diff", "--name-only", "--diff-filter=ACMR", range],
2267
+ { timeoutMs: 8_000, allowNonZero: true, cwd }
2268
+ );
2269
+ const changedFiles = (filesResult.stdout || "")
2270
+ .split("\n")
2271
+ .map((line) => line.trim())
2272
+ .filter(Boolean);
2273
+
2274
+ const statsResult = await this._runCommand(
2275
+ "git",
2276
+ ["-C", cwd, "diff", "--numstat", range],
2277
+ { timeoutMs: 8_000, allowNonZero: true, cwd }
2278
+ );
2279
+ let additions = 0;
2280
+ let deletions = 0;
2281
+ for (const line of (statsResult.stdout || "").split("\n")) {
2282
+ const [addedRaw, deletedRaw] = line.split("\t");
2283
+ const added = Number.parseInt(addedRaw, 10);
2284
+ const deleted = Number.parseInt(deletedRaw, 10);
2285
+ additions += Number.isFinite(added) ? added : 0;
2286
+ deletions += Number.isFinite(deleted) ? deleted : 0;
2287
+ }
2288
+
2289
+ const branch = await this._resolveGitCurrentBranch(cwd);
2290
+ const title = this._normalizePullRequestTitle(
2291
+ commitSubjects[0] || (branch ? `Update ${branch}` : "Update project files")
2292
+ );
2293
+
2294
+ const summaryBullets = [];
2295
+ for (const subject of commitSubjects.slice(0, 3)) {
2296
+ summaryBullets.push(subject);
2297
+ }
2298
+ if (summaryBullets.length === 0) {
2299
+ summaryBullets.push("Update project files.");
2300
+ }
2301
+ if (changedFiles.length > 0) {
2302
+ summaryBullets.push(`Modify ${changedFiles.length} file${changedFiles.length === 1 ? "" : "s"}.`);
2303
+ }
2304
+ if (additions > 0 || deletions > 0) {
2305
+ summaryBullets.push(`Diff summary: +${additions} / -${deletions} lines.`);
2306
+ }
2307
+ if (baseRef) {
2308
+ summaryBullets.push(`Base branch: ${baseRef}.`);
2309
+ }
2310
+
2311
+ const bodyLines = ["## Summary"];
2312
+ for (const bullet of summaryBullets.slice(0, 6)) {
2313
+ bodyLines.push(`- ${bullet}`);
2314
+ }
2315
+
2316
+ bodyLines.push("", "## Testing", "- Not run (context not provided).");
2317
+
2318
+ if (changedFiles.length > 0) {
2319
+ bodyLines.push("", "## Files Changed");
2320
+ for (const file of changedFiles.slice(0, 12)) {
2321
+ bodyLines.push(`- \`${file}\``);
2322
+ }
2323
+ if (changedFiles.length > 12) {
2324
+ bodyLines.push(`- \`...and ${changedFiles.length - 12} more\``);
2325
+ }
2326
+ } else if (typeof prompt === "string" && prompt.trim().length > 0) {
2327
+ bodyLines.push("", "## Notes", "- Generated from available thread context.");
2328
+ }
2329
+
2330
+ return {
2331
+ title,
2332
+ body: this._normalizePullRequestBody(bodyLines.join("\n"))
2333
+ };
2334
+ }
2335
+
2336
+ _extractGithubPrUrl(text) {
2337
+ if (typeof text !== "string" || text.length === 0) {
2338
+ return null;
2339
+ }
2340
+ const match = text.match(/https:\/\/github\.com\/[^\s/]+\/[^\s/]+\/pull\/\d+/);
2341
+ return match ? match[0] : null;
2342
+ }
2343
+
2344
+ async _handleGhPrCreate(params) {
2345
+ const cwd = typeof params?.cwd === "string" && params.cwd.length > 0
2346
+ ? params.cwd
2347
+ : process.cwd();
2348
+ const headBranch = typeof params?.headBranch === "string" ? params.headBranch.trim() : "";
2349
+ const baseBranch = typeof params?.baseBranch === "string" ? params.baseBranch.trim() : "";
2350
+ const bodyInstructions = typeof params?.bodyInstructions === "string" ? params.bodyInstructions : "";
2351
+ const titleOverride = typeof params?.titleOverride === "string" ? params.titleOverride.trim() : "";
2352
+ const bodyOverride = typeof params?.bodyOverride === "string" ? params.bodyOverride.trim() : "";
2353
+
2354
+ if (!headBranch || !baseBranch) {
2355
+ return {
2356
+ status: "error",
2357
+ error: "headBranch and baseBranch are required",
2358
+ url: null,
2359
+ number: null
2360
+ };
2361
+ }
2362
+
2363
+ const ghStatus = await this._resolveGhCliStatus();
2364
+ if (!ghStatus.isInstalled || !ghStatus.isAuthenticated) {
2365
+ return {
2366
+ status: "error",
2367
+ error: "gh cli unavailable or unauthenticated",
2368
+ url: null,
2369
+ number: null
2370
+ };
2371
+ }
2372
+
2373
+ const args = [
2374
+ "pr",
2375
+ "create",
2376
+ "--head",
2377
+ headBranch,
2378
+ "--base",
2379
+ baseBranch
2380
+ ];
2381
+ const shouldFill = titleOverride.length === 0 || bodyOverride.length === 0;
2382
+ if (shouldFill) {
2383
+ args.push("--fill");
2384
+ }
2385
+ if (titleOverride.length > 0) {
2386
+ args.push("--title", titleOverride);
2387
+ }
2388
+ if (bodyOverride.length > 0) {
2389
+ args.push("--body", bodyOverride);
2390
+ } else if (bodyInstructions.trim().length > 0) {
2391
+ args.push("--body", bodyInstructions);
2392
+ }
2393
+
2394
+ const result = await this._runCommand("gh", args, {
2395
+ timeoutMs: 30_000,
2396
+ allowNonZero: true,
2397
+ cwd
2398
+ });
2399
+
2400
+ if (result.ok) {
2401
+ const url = this._extractGithubPrUrl(result.stdout) || this._extractGithubPrUrl(result.stderr);
2402
+ const numberMatch = url ? url.match(/\/pull\/(\d+)/) : null;
2403
+ const number = numberMatch ? Number.parseInt(numberMatch[1], 10) : null;
2404
+ return {
2405
+ status: "success",
2406
+ url: url || null,
2407
+ number: Number.isFinite(number) ? number : null
2408
+ };
2409
+ }
2410
+
2411
+ const combinedOutput = `${result.stdout || ""}\n${result.stderr || ""}`;
2412
+ const existingUrl = this._extractGithubPrUrl(combinedOutput);
2413
+ const alreadyExists = /already exists|a pull request for branch/i.test(combinedOutput);
2414
+ if (alreadyExists && existingUrl) {
2415
+ const numberMatch = existingUrl.match(/\/pull\/(\d+)/);
2416
+ const number = numberMatch ? Number.parseInt(numberMatch[1], 10) : null;
2417
+ return {
2418
+ status: "success",
2419
+ url: existingUrl,
2420
+ number: Number.isFinite(number) ? number : null
2421
+ };
2422
+ }
2423
+
2424
+ return {
2425
+ status: "error",
2426
+ error: result.error || result.stderr || "failed to create pull request",
2427
+ url: null,
2428
+ number: null
2429
+ };
2430
+ }
2431
+
1321
2432
  async _resolveGitMergeBase({ gitRoot, baseBranch }) {
1322
2433
  if (!baseBranch) {
1323
2434
  return {
@@ -1339,16 +2450,138 @@ export class MessageRouter {
1339
2450
  };
1340
2451
  }
1341
2452
 
2453
+ async _resolveGitCurrentBranch(cwd) {
2454
+ const result = await this._runCommand("git", ["-C", cwd, "rev-parse", "--abbrev-ref", "HEAD"], {
2455
+ timeoutMs: 5_000,
2456
+ allowNonZero: true,
2457
+ cwd
2458
+ });
2459
+ if (!result.ok || !result.stdout || result.stdout === "HEAD") {
2460
+ return null;
2461
+ }
2462
+ return result.stdout;
2463
+ }
2464
+
2465
+ async _handleGitCreateBranch(params) {
2466
+ const cwd = typeof params?.cwd === "string" && params.cwd.length > 0
2467
+ ? params.cwd
2468
+ : process.cwd();
2469
+ const branch = typeof params?.branch === "string" && params.branch.trim().length > 0
2470
+ ? params.branch.trim()
2471
+ : null;
2472
+
2473
+ if (!branch) {
2474
+ return {
2475
+ ok: false,
2476
+ code: null,
2477
+ error: "branch is required",
2478
+ stdout: "",
2479
+ stderr: ""
2480
+ };
2481
+ }
2482
+
2483
+ const existingResult = await this._runCommand("git", ["-C", cwd, "show-ref", "--verify", "--quiet", `refs/heads/${branch}`], {
2484
+ cwd,
2485
+ timeoutMs: 10_000,
2486
+ allowNonZero: true
2487
+ });
2488
+ if (existingResult.code === 0) {
2489
+ return {
2490
+ ok: true,
2491
+ code: 0,
2492
+ branch,
2493
+ created: false,
2494
+ alreadyExists: true,
2495
+ stdout: existingResult.stdout || "",
2496
+ stderr: existingResult.stderr || ""
2497
+ };
2498
+ }
2499
+
2500
+ const createResult = await this._runCommand("git", ["-C", cwd, "branch", branch], {
2501
+ cwd,
2502
+ timeoutMs: 10_000,
2503
+ allowNonZero: true
2504
+ });
2505
+ if (createResult.ok) {
2506
+ return {
2507
+ ok: true,
2508
+ code: createResult.code,
2509
+ branch,
2510
+ created: true,
2511
+ alreadyExists: false,
2512
+ stdout: createResult.stdout || "",
2513
+ stderr: createResult.stderr || ""
2514
+ };
2515
+ }
2516
+
2517
+ return {
2518
+ ok: false,
2519
+ code: createResult.code,
2520
+ error: createResult.error || createResult.stderr || "git branch failed",
2521
+ stdout: createResult.stdout || "",
2522
+ stderr: createResult.stderr || ""
2523
+ };
2524
+ }
2525
+
2526
+ async _handleGitCheckoutBranch(params) {
2527
+ const cwd = typeof params?.cwd === "string" && params.cwd.length > 0
2528
+ ? params.cwd
2529
+ : process.cwd();
2530
+ const branch = typeof params?.branch === "string" && params.branch.trim().length > 0
2531
+ ? params.branch.trim()
2532
+ : null;
2533
+
2534
+ if (!branch) {
2535
+ return {
2536
+ ok: false,
2537
+ code: null,
2538
+ error: "branch is required",
2539
+ stdout: "",
2540
+ stderr: ""
2541
+ };
2542
+ }
2543
+
2544
+ const checkoutResult = await this._runCommand("git", ["-C", cwd, "checkout", branch], {
2545
+ cwd,
2546
+ timeoutMs: 20_000,
2547
+ allowNonZero: true
2548
+ });
2549
+ if (!checkoutResult.ok) {
2550
+ return {
2551
+ ok: false,
2552
+ code: checkoutResult.code,
2553
+ error: checkoutResult.error || checkoutResult.stderr || "git checkout failed",
2554
+ stdout: checkoutResult.stdout || "",
2555
+ stderr: checkoutResult.stderr || ""
2556
+ };
2557
+ }
2558
+
2559
+ const currentBranch = await this._resolveGitCurrentBranch(cwd);
2560
+ return {
2561
+ ok: true,
2562
+ code: checkoutResult.code,
2563
+ branch: currentBranch || branch,
2564
+ stdout: checkoutResult.stdout || "",
2565
+ stderr: checkoutResult.stderr || ""
2566
+ };
2567
+ }
2568
+
1342
2569
  async _handleGitPush(params) {
1343
2570
  const cwd = typeof params?.cwd === "string" && params.cwd.length > 0
1344
2571
  ? params.cwd
1345
2572
  : process.cwd();
1346
- const remote = typeof params?.remote === "string" && params.remote.trim().length > 0
2573
+ const explicitRemote = typeof params?.remote === "string" && params.remote.trim().length > 0
1347
2574
  ? params.remote.trim()
1348
2575
  : null;
1349
2576
  const branch = typeof params?.branch === "string" && params.branch.trim().length > 0
1350
2577
  ? params.branch.trim()
1351
2578
  : null;
2579
+ const refspec = typeof params?.refspec === "string" && params.refspec.trim().length > 0
2580
+ ? params.refspec.trim()
2581
+ : null;
2582
+ const remote = explicitRemote || (
2583
+ params?.setUpstream === true && (branch || refspec) ? "origin" : null
2584
+ );
1352
2585
 
1353
2586
  const args = ["-C", cwd, "push"];
1354
2587
  if (params?.force === true || params?.forceWithLease === true) {
@@ -1360,7 +2593,9 @@ export class MessageRouter {
1360
2593
  if (remote) {
1361
2594
  args.push(remote);
1362
2595
  }
1363
- if (branch) {
2596
+ if (refspec) {
2597
+ args.push(refspec);
2598
+ } else if (branch) {
1364
2599
  args.push(branch);
1365
2600
  }
1366
2601
 
@@ -1390,7 +2625,7 @@ export class MessageRouter {
1390
2625
 
1391
2626
  async _runCommand(command, args, { timeoutMs = 5_000, allowNonZero = false, cwd = process.cwd() } = {}) {
1392
2627
  return new Promise((resolve) => {
1393
- const child = spawn(command, args, {
2628
+ const child = childSpawn(command, args, {
1394
2629
  cwd,
1395
2630
  env: process.env,
1396
2631
  stdio: ["ignore", "pipe", "pipe"]
@@ -1465,7 +2700,7 @@ export class MessageRouter {
1465
2700
  requestId,
1466
2701
  status,
1467
2702
  ok: status >= 200 && status < 300,
1468
- url
2703
+ url: this._sanitizeUrlForLogs(url)
1469
2704
  });
1470
2705
  }
1471
2706
 
@@ -1978,7 +3213,7 @@ export class MessageRouter {
1978
3213
  return;
1979
3214
  }
1980
3215
 
1981
- const child = spawn("open", [url], {
3216
+ const child = childSpawn("open", [url], {
1982
3217
  stdio: ["ignore", "ignore", "ignore"],
1983
3218
  detached: true
1984
3219
  });