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.
- package/dist/cli/src/runtime/bridge-session.d.ts +6 -0
- package/dist/cli/src/runtime/bridge-session.js +179 -23
- package/dist/cli/src/runtime/bridge-session.js.map +1 -1
- package/dist/cli/tsconfig.tsbuildinfo +1 -1
- package/dist/shared-protocol/src/index.d.ts +164 -30
- package/dist/shared-protocol/src/index.js +30 -0
- package/dist/shared-protocol/src/index.js.map +1 -1
- package/package.json +3 -3
- package/src/runtime/bridge-session.ts +221 -20
|
@@ -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
|
|
1045
|
+
// Auto-detect provider from hook event fields
|
|
847
1046
|
const hookTerm = this.terminals.get(terminalId);
|
|
848
1047
|
let detectedProvider = provider;
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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
|
-
|
|
869
|
-
|
|
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(
|
|
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();
|