linkshell-cli 0.2.51 → 0.2.53

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.
@@ -133,6 +133,7 @@ export class BridgeSession {
133
133
  private hookMarker = `lsh-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
134
134
  private screenCapture: ScreenFallback | undefined;
135
135
  private screenShare: ScreenShare | undefined;
136
+ private tunnelSockets = new Map<string, WebSocket>();
136
137
 
137
138
  constructor(options: BridgeSessionOptions) {
138
139
  this.options = options;
@@ -442,11 +443,209 @@ export class BridgeSession {
442
443
  }
443
444
  break;
444
445
  }
446
+ case "tunnel.request": {
447
+ const p = parseTypedPayload("tunnel.request", envelope.payload);
448
+ this.handleTunnelRequest(p);
449
+ break;
450
+ }
451
+ case "tunnel.ws.data": {
452
+ const p = parseTypedPayload("tunnel.ws.data", envelope.payload);
453
+ this.handleTunnelWsData(p);
454
+ break;
455
+ }
456
+ case "tunnel.ws.close": {
457
+ const p = parseTypedPayload("tunnel.ws.close", envelope.payload);
458
+ this.handleTunnelWsClose(p);
459
+ break;
460
+ }
445
461
  default:
446
462
  break;
447
463
  }
448
464
  }
449
465
 
466
+ // ── Tunnel handlers ────────────────────────────────────────────────
467
+
468
+ private handleTunnelRequest(payload: {
469
+ requestId: string;
470
+ method: string;
471
+ url: string;
472
+ headers: Record<string, string>;
473
+ body: string | null;
474
+ port: number;
475
+ }): void {
476
+ const { requestId, method, url: reqUrl, headers, body, port } = payload;
477
+
478
+ // WebSocket upgrade request
479
+ if (headers.upgrade === "websocket") {
480
+ this.handleTunnelWsUpgrade(requestId, port, reqUrl);
481
+ return;
482
+ }
483
+
484
+ const parsedUrl = new URL(reqUrl, `http://127.0.0.1:${port}`);
485
+
486
+ const reqOptions: http.RequestOptions = {
487
+ hostname: "127.0.0.1",
488
+ port,
489
+ path: parsedUrl.pathname + parsedUrl.search,
490
+ method,
491
+ headers: { ...headers, host: `127.0.0.1:${port}` },
492
+ };
493
+
494
+ const proxyReq = http.request(reqOptions, (proxyRes) => {
495
+ // Collect response headers
496
+ const resHeaders: Record<string, string> = {};
497
+ for (const [key, val] of Object.entries(proxyRes.headers)) {
498
+ if (typeof val === "string") resHeaders[key] = val;
499
+ else if (Array.isArray(val)) resHeaders[key] = val.join(", ");
500
+ }
501
+
502
+ let firstChunk = true;
503
+ proxyRes.on("data", (chunk: Buffer) => {
504
+ this.send(
505
+ createEnvelope({
506
+ type: "tunnel.response",
507
+ sessionId: this.sessionId,
508
+ payload: {
509
+ requestId,
510
+ statusCode: proxyRes.statusCode ?? 200,
511
+ headers: firstChunk ? resHeaders : {},
512
+ body: chunk.toString("base64"),
513
+ isFinal: false,
514
+ },
515
+ }),
516
+ );
517
+ firstChunk = false;
518
+ });
519
+
520
+ proxyRes.on("end", () => {
521
+ this.send(
522
+ createEnvelope({
523
+ type: "tunnel.response",
524
+ sessionId: this.sessionId,
525
+ payload: {
526
+ requestId,
527
+ statusCode: proxyRes.statusCode ?? 200,
528
+ headers: firstChunk ? resHeaders : {},
529
+ body: "",
530
+ isFinal: true,
531
+ },
532
+ }),
533
+ );
534
+ });
535
+
536
+ proxyRes.on("error", () => {
537
+ this.sendTunnelError(requestId, 502, "Upstream read error");
538
+ });
539
+ });
540
+
541
+ proxyReq.on("error", () => {
542
+ this.sendTunnelError(requestId, 502, "Connection refused");
543
+ });
544
+
545
+ proxyReq.setTimeout(30_000, () => {
546
+ proxyReq.destroy();
547
+ this.sendTunnelError(requestId, 504, "Upstream timeout");
548
+ });
549
+
550
+ if (body) {
551
+ proxyReq.write(Buffer.from(body, "base64"));
552
+ }
553
+ proxyReq.end();
554
+ }
555
+
556
+ private handleTunnelWsUpgrade(requestId: string, port: number, url: string): void {
557
+ const wsUrl = `ws://127.0.0.1:${port}${url}`;
558
+ const localWs = new WebSocket(wsUrl);
559
+
560
+ localWs.on("open", () => {
561
+ this.tunnelSockets.set(requestId, localWs);
562
+ });
563
+
564
+ localWs.on("message", (data: Buffer | string) => {
565
+ const isBinary = typeof data !== "string";
566
+ const buf = typeof data === "string" ? Buffer.from(data) : data;
567
+ this.send(
568
+ createEnvelope({
569
+ type: "tunnel.ws.data",
570
+ sessionId: this.sessionId,
571
+ payload: {
572
+ requestId,
573
+ data: buf.toString("base64"),
574
+ isBinary,
575
+ },
576
+ }),
577
+ );
578
+ });
579
+
580
+ localWs.on("close", (code, reason) => {
581
+ this.tunnelSockets.delete(requestId);
582
+ this.send(
583
+ createEnvelope({
584
+ type: "tunnel.ws.close",
585
+ sessionId: this.sessionId,
586
+ payload: {
587
+ requestId,
588
+ code,
589
+ reason: reason?.toString() || "",
590
+ },
591
+ }),
592
+ );
593
+ });
594
+
595
+ localWs.on("error", () => {
596
+ this.tunnelSockets.delete(requestId);
597
+ this.send(
598
+ createEnvelope({
599
+ type: "tunnel.ws.close",
600
+ sessionId: this.sessionId,
601
+ payload: {
602
+ requestId,
603
+ code: 1001,
604
+ reason: "Local WebSocket error",
605
+ },
606
+ }),
607
+ );
608
+ });
609
+ }
610
+
611
+ private handleTunnelWsData(payload: {
612
+ requestId: string;
613
+ data: string;
614
+ isBinary: boolean;
615
+ }): void {
616
+ const ws = this.tunnelSockets.get(payload.requestId);
617
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
618
+ const buf = Buffer.from(payload.data, "base64");
619
+ ws.send(payload.isBinary ? buf : buf.toString("utf8"));
620
+ }
621
+
622
+ private handleTunnelWsClose(payload: {
623
+ requestId: string;
624
+ code?: number;
625
+ reason?: string;
626
+ }): void {
627
+ const ws = this.tunnelSockets.get(payload.requestId);
628
+ if (!ws) return;
629
+ ws.close(payload.code ?? 1000, payload.reason ?? "");
630
+ this.tunnelSockets.delete(payload.requestId);
631
+ }
632
+
633
+ private sendTunnelError(requestId: string, statusCode: number, message: string): void {
634
+ this.send(
635
+ createEnvelope({
636
+ type: "tunnel.response",
637
+ sessionId: this.sessionId,
638
+ payload: {
639
+ requestId,
640
+ statusCode,
641
+ headers: { "content-type": "text/plain" },
642
+ body: Buffer.from(message).toString("base64"),
643
+ isFinal: true,
644
+ },
645
+ }),
646
+ );
647
+ }
648
+
450
649
  private sendTerminalList(): void {
451
650
  const terminals = [...this.terminals.values()].map((t) => ({
452
651
  terminalId: t.id,
@@ -843,19 +1042,21 @@ export class BridgeSession {
843
1042
  const rawHookName = (event.hook_event_name ?? event.event_name) as string | undefined;
844
1043
  if (!rawHookName) return;
845
1044
 
846
- // Auto-detect actual provider from hook event fields (only when terminal is "custom")
1045
+ // Auto-detect provider from hook event fields
847
1046
  const hookTerm = this.terminals.get(terminalId);
848
1047
  let detectedProvider = provider;
849
- if (hookTerm?.provider === "custom") {
850
- // Detect provider from transcript_path (most reliable)
851
- const transcriptPath = typeof event.transcript_path === "string" ? event.transcript_path as string : "";
852
- if (transcriptPath.includes(".claude/")) {
853
- detectedProvider = "claude";
854
- } else if (transcriptPath.includes(".gemini/")) {
855
- detectedProvider = "gemini";
856
- } else if (transcriptPath.includes(".codex/")) {
857
- detectedProvider = "codex";
858
- } else if (event.model && typeof event.model === "string" && /^(gpt|o[0-9]|codex)/i.test(event.model as string)) {
1048
+
1049
+ // Always detect from transcript_path (most reliable), regardless of current provider
1050
+ const transcriptPath = typeof event.transcript_path === "string" ? event.transcript_path as string : "";
1051
+ if (transcriptPath.includes(".claude/")) {
1052
+ detectedProvider = "claude";
1053
+ } else if (transcriptPath.includes(".gemini/")) {
1054
+ detectedProvider = "gemini";
1055
+ } else if (transcriptPath.includes(".codex/")) {
1056
+ detectedProvider = "codex";
1057
+ } else if (hookTerm?.provider === "custom") {
1058
+ // Fallback heuristics only when provider is still unknown
1059
+ if (event.model && typeof event.model === "string" && /^(gpt|o[0-9]|codex)/i.test(event.model as string)) {
859
1060
  detectedProvider = "codex";
860
1061
  } else if (event.session_id && !transcriptPath) {
861
1062
  detectedProvider = "codex";
@@ -864,17 +1065,12 @@ export class BridgeSession {
864
1065
  } else if (/^(pre|post)ToolUse$|^session(Start|End)$|^userPromptSubmitted$|^errorOccurred$/.test(rawHookName)) {
865
1066
  detectedProvider = "copilot";
866
1067
  }
1068
+ }
867
1069
 
868
- if (detectedProvider !== "custom") {
869
- hookTerm.provider = detectedProvider;
870
- this.log(`detected provider for ${terminalId}: ${detectedProvider}`);
871
- this.permissionStacks.delete(terminalId);
872
- this.sendTerminalList();
873
- }
874
- } else if (hookTerm && hookTerm.provider !== "custom" && detectedProvider !== hookTerm.provider) {
875
- // Provider switched mid-session (e.g., user exited claude and started codex)
1070
+ if (hookTerm && detectedProvider !== hookTerm.provider) {
1071
+ const wasCustom = hookTerm.provider === "custom";
876
1072
  hookTerm.provider = detectedProvider;
877
- this.log(`provider switched for ${terminalId}: ${detectedProvider}`);
1073
+ this.log(`${wasCustom ? "detected" : "provider switched"} provider for ${terminalId}: ${detectedProvider}`);
878
1074
  this.permissionStacks.delete(terminalId);
879
1075
  this.sendTerminalList();
880
1076
  }
@@ -1277,6 +1473,11 @@ export class BridgeSession {
1277
1473
  }
1278
1474
  this.socket?.close();
1279
1475
  this.socket = undefined;
1476
+ // Clean up tunnel WebSockets
1477
+ for (const ws of this.tunnelSockets.values()) {
1478
+ ws.close(1001, "Session stopped");
1479
+ }
1480
+ this.tunnelSockets.clear();
1280
1481
  for (const term of this.terminals.values()) {
1281
1482
  this.cleanupHookServer(term);
1282
1483
  if (term.status === "running") term.pty.kill();