@hienlh/ppm 0.9.0-beta.4 → 0.9.0-beta.6
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/CHANGELOG.md +27 -8
- package/dist/web/assets/{_basePickBy-COwDPZl_.js → _basePickBy-CZovQgWd.js} +1 -1
- package/dist/web/assets/{_baseUniq-DCb0mkTp.js → _baseUniq-ClnvscgW.js} +1 -1
- package/dist/web/assets/{api-settings-CuUkz5gb.js → api-settings--eVrUeZM.js} +1 -1
- package/dist/web/assets/{arc-D0bJaFyD.js → arc-C2Qaz-ch.js} +1 -1
- package/dist/web/assets/architecture-PBZL5I3N-ChOahOB7.js +1 -0
- package/dist/web/assets/{architectureDiagram-2XIMDMQ5-BVEUkQYB.js → architectureDiagram-2XIMDMQ5-Jq91S_rs.js} +1 -1
- package/dist/web/assets/{blockDiagram-WCTKOSBZ-CU2t4NHJ.js → blockDiagram-WCTKOSBZ-CKGufRTy.js} +1 -1
- package/dist/web/assets/browser-tab-DAvH4mv0.js +1 -0
- package/dist/web/assets/{c4Diagram-IC4MRINW-DzjR91sM.js → c4Diagram-IC4MRINW-BNP2L9r_.js} +1 -1
- package/dist/web/assets/channel-w7yboq56.js +1 -0
- package/dist/web/assets/chat-tab-WEBXxGgN.js +7 -0
- package/dist/web/assets/{chunk-4BX2VUAB-0YMkpW2S.js → chunk-4BX2VUAB-BptTlTyl.js} +1 -1
- package/dist/web/assets/{chunk-55IACEB6-Dp0pTM5r.js → chunk-55IACEB6-C4mUdyio.js} +1 -1
- package/dist/web/assets/{chunk-7E7YKBS2-CuYKSUgJ.js → chunk-7E7YKBS2-6xAQfBwa.js} +1 -1
- package/dist/web/assets/{chunk-7R4GIKGN-DvbvLUIN.js → chunk-7R4GIKGN-DXaGAn_K.js} +2 -2
- package/dist/web/assets/{chunk-C72U2L5F-CcEW1AMZ.js → chunk-C72U2L5F-DOtEiN5f.js} +1 -1
- package/dist/web/assets/{chunk-EGIJ26TM-Cgt-qg75.js → chunk-EGIJ26TM-D0KJTa_T.js} +1 -1
- package/dist/web/assets/{chunk-FMBD7UC4-JCLgVcaC.js → chunk-FMBD7UC4-C_1aG0eb.js} +1 -1
- package/dist/web/assets/{chunk-GEFDOKGD-B82RP9ow.js → chunk-GEFDOKGD-DwVPiYfW.js} +1 -1
- package/dist/web/assets/chunk-GLR3WWYH-D9pZakqr.js +2 -0
- package/dist/web/assets/chunk-HHEYEP7N-Dld5BpGB.js +1 -0
- package/dist/web/assets/{chunk-JSJVCQXG-Pb-JMOgO.js → chunk-JSJVCQXG-BSrqCL_3.js} +1 -1
- package/dist/web/assets/{chunk-KX2RTZJC-BRj-ZEvL.js → chunk-KX2RTZJC-BCxGmbzy.js} +1 -1
- package/dist/web/assets/{chunk-KYZI473N-CBRPKraG.js → chunk-KYZI473N-BKO5gMeU.js} +1 -1
- package/dist/web/assets/{chunk-L3YUKLVL-DNFj84V6.js → chunk-L3YUKLVL-3wBgkSvL.js} +1 -1
- package/dist/web/assets/{chunk-MX3YWQON-BnPzQK-O.js → chunk-MX3YWQON-BgjSEzus.js} +1 -1
- package/dist/web/assets/{chunk-NQ4KR5QH-BRj25yO7.js → chunk-NQ4KR5QH-DLrZwBEm.js} +1 -1
- package/dist/web/assets/{chunk-O4XLMI2P-BdXwVXjJ.js → chunk-O4XLMI2P-BurQy8tt.js} +1 -1
- package/dist/web/assets/{chunk-OZEHJAEY-LfXT4p8B.js → chunk-OZEHJAEY-YTn24bGg.js} +1 -1
- package/dist/web/assets/{chunk-PQ6SQG4A-EdgQyTqa.js → chunk-PQ6SQG4A-BxtUGYhW.js} +1 -1
- package/dist/web/assets/{chunk-PU5JKC2W-D3thuSok.js → chunk-PU5JKC2W-B66ELkQm.js} +1 -1
- package/dist/web/assets/chunk-QZHKN3VN-DwSXwtjH.js +1 -0
- package/dist/web/assets/{chunk-R5LLSJPH-LdG7RqsM.js → chunk-R5LLSJPH-euR2RxLN.js} +1 -1
- package/dist/web/assets/{chunk-WL4C6EOR-BHFnnXOt.js → chunk-WL4C6EOR-_2CBOJdI.js} +1 -1
- package/dist/web/assets/{chunk-XIRO2GV7-DUmQrLsF.js → chunk-XIRO2GV7-kqQ0g6wW.js} +1 -1
- package/dist/web/assets/{chunk-XPW4576I-CsGTseUr.js → chunk-XPW4576I-CtcaMb09.js} +1 -1
- package/dist/web/assets/{chunk-XZSTWKYB-5W2emiq4.js → chunk-XZSTWKYB-BYxFzZwS.js} +1 -1
- package/dist/web/assets/{chunk-YBOYWFTD-COdZIaX4.js → chunk-YBOYWFTD-Dx_fX35n.js} +1 -1
- package/dist/web/assets/classDiagram-VBA2DB6C-BpJ6Oog2.js +1 -0
- package/dist/web/assets/classDiagram-v2-RAHNMMFH-Bj8gIhkP.js +1 -0
- package/dist/web/assets/clone-BSi6cgDh.js +1 -0
- package/dist/web/assets/{code-editor-BRMOypkX.js → code-editor-B5sg_uJQ.js} +1 -1
- package/dist/web/assets/{cose-bilkent-S5V4N54A-C1QJ6GPW.js → cose-bilkent-S5V4N54A-CHHjH2dV.js} +1 -1
- package/dist/web/assets/{dagre-CWo8w9wK.js → dagre-CNtSxiE_.js} +1 -1
- package/dist/web/assets/{dagre-KLK3FWXG-Br4t5TRV.js → dagre-KLK3FWXG-ChenfPp1.js} +1 -1
- package/dist/web/assets/database-viewer-CwtyWCkE.js +1 -0
- package/dist/web/assets/{diagram-E7M64L7V-CkDC2uAj.js → diagram-E7M64L7V-CzKYZM0Y.js} +1 -1
- package/dist/web/assets/{diagram-IFDJBPK2-NvhckwcA.js → diagram-IFDJBPK2-ChB_paPo.js} +1 -1
- package/dist/web/assets/{diagram-P4PSJMXO--nUaNiyB.js → diagram-P4PSJMXO-D1eW1dkL.js} +1 -1
- package/dist/web/assets/{diff-viewer-jDU2bcGj.js → diff-viewer-CzE5M-Wd.js} +1 -1
- package/dist/web/assets/{erDiagram-INFDFZHY-DK4QEZYh.js → erDiagram-INFDFZHY-mCvUFSn6.js} +1 -1
- package/dist/web/assets/{flowDiagram-PKNHOUZH-B9h_Ba-v.js → flowDiagram-PKNHOUZH-14ohZ1M1.js} +1 -1
- package/dist/web/assets/{ganttDiagram-A5KZAMGK-BVlftqyZ.js → ganttDiagram-A5KZAMGK-DIX0pLbk.js} +1 -1
- package/dist/web/assets/git-graph-6yxCeeN9.js +1 -0
- package/dist/web/assets/gitGraph-HDMCJU4V-CEee2FCA.js +1 -0
- package/dist/web/assets/{gitGraphDiagram-K3NZZRJ6-L7sj3Bs-.js → gitGraphDiagram-K3NZZRJ6-yEWZbdf_.js} +1 -1
- package/dist/web/assets/{graphlib-BbbiUImY.js → graphlib-DhOZxqsh.js} +1 -1
- package/dist/web/assets/index-DE8b9u8F.css +2 -0
- package/dist/web/assets/index-wuWZBO9y.js +37 -0
- package/dist/web/assets/info-3K5VOQVL-ce_pi3En.js +1 -0
- package/dist/web/assets/infoDiagram-LFFYTUFH-BzqyoqXw.js +2 -0
- package/dist/web/assets/input-Brjz2Vv-.js +41 -0
- package/dist/web/assets/{isEmpty-DXomfd7J.js → isEmpty-C0YYdhYj.js} +1 -1
- package/dist/web/assets/{ishikawaDiagram-PHBUUO56-cW7SMLa_.js → ishikawaDiagram-PHBUUO56-olazD6dZ.js} +1 -1
- package/dist/web/assets/{journeyDiagram-4ABVD52K-DFQXUZsc.js → journeyDiagram-4ABVD52K-CttDH9bb.js} +1 -1
- package/dist/web/assets/{kanban-definition-K7BYSVSG-BMUhjxqj.js → kanban-definition-K7BYSVSG-BBXbI37U.js} +1 -1
- package/dist/web/assets/keybindings-store-mkBHnWN1.js +1 -0
- package/dist/web/assets/{line--xyfYP3x.js → line-DBLLF7lH.js} +1 -1
- package/dist/web/assets/{linear-BdqW7iQu.js → linear-BLFWatDe.js} +1 -1
- package/dist/web/assets/{markdown-renderer-BCjJbGP8.js → markdown-renderer-CxWxvrzT.js} +5 -5
- package/dist/web/assets/{mermaid-parser.core-BY8JfkE_.js → mermaid-parser.core-BKiGOTjR.js} +2 -2
- package/dist/web/assets/{mindmap-definition-YRQLILUH-DIv-LMXG.js → mindmap-definition-YRQLILUH-DoT7m4Sz.js} +1 -1
- package/dist/web/assets/{ordinal-CIoJK3nc.js → ordinal-CCj7PWgZ.js} +1 -1
- package/dist/web/assets/packet-RMMSAZCW-CdYSLjRL.js +1 -0
- package/dist/web/assets/pie-UPGHQEXC-Bm5LiD-6.js +1 -0
- package/dist/web/assets/{pieDiagram-SKSYHLDU-seSK40d1.js → pieDiagram-SKSYHLDU-Bkh2E4zE.js} +1 -1
- package/dist/web/assets/postgres-viewer-UP3yv9Yh.js +1 -0
- package/dist/web/assets/{quadrantDiagram-337W2JSQ-BaRFqlsA.js → quadrantDiagram-337W2JSQ-B7zgALOL.js} +1 -1
- package/dist/web/assets/radar-KQ55EAFF-C4PnyG7_.js +1 -0
- package/dist/web/assets/{requirementDiagram-Z7DCOOCP-1WWjMQB_.js → requirementDiagram-Z7DCOOCP-D_5GXNRo.js} +1 -1
- package/dist/web/assets/{sankeyDiagram-WA2Y5GQK-DEGGYsk7.js → sankeyDiagram-WA2Y5GQK-BA9EFAAe.js} +1 -1
- package/dist/web/assets/{sequenceDiagram-2WXFIKYE-BtRvoUTC.js → sequenceDiagram-2WXFIKYE-fyWIrHiG.js} +1 -1
- package/dist/web/assets/{settings-store-D3dJqGhB.js → settings-store-Bbhg_ptG.js} +2 -2
- package/dist/web/assets/settings-tab-BoBXlVHe.js +1 -0
- package/dist/web/assets/sqlite-viewer-lzRVvM5j.js +1 -0
- package/dist/web/assets/{stateDiagram-RAJIS63D-C16aO8tn.js → stateDiagram-RAJIS63D-DfRBcaBu.js} +1 -1
- package/dist/web/assets/stateDiagram-v2-FVOUBMTO-CMN4M2Em.js +1 -0
- package/dist/web/assets/{tab-store-DSz5PQI0.js → tab-store-DcIBZTD4.js} +1 -1
- package/dist/web/assets/{terminal-tab-MRg8y1xF.js → terminal-tab-CAZtLK6i.js} +2 -2
- package/dist/web/assets/{timeline-definition-YZTLITO2-DrjxCpEM.js → timeline-definition-YZTLITO2-DYfwJ1jM.js} +1 -1
- package/dist/web/assets/treemap-KZPCXAKY-2_y-mhkz.js +1 -0
- package/dist/web/assets/{use-monaco-theme-BQzvItNE.js → use-monaco-theme-vwto-Vlf.js} +1 -1
- package/dist/web/assets/{vennDiagram-LZ73GAT5-DfYFnniI.js → vennDiagram-LZ73GAT5-DqbKNRD9.js} +1 -1
- package/dist/web/assets/{xychartDiagram-JWTSCODW-BRvXOVlG.js → xychartDiagram-JWTSCODW-DhUL86qT.js} +1 -1
- package/dist/web/index.html +10 -12
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/providers/claude-agent-sdk.ts +212 -76
- package/src/server/index.ts +4 -1
- package/src/server/routes/browser-preview.ts +135 -65
- package/src/server/ws/chat.ts +103 -73
- package/src/types/api.ts +1 -1
- package/src/types/chat.ts +2 -0
- package/src/web/components/browser/browser-tab.tsx +105 -224
- package/src/web/components/chat/chat-tab.tsx +3 -3
- package/src/web/components/chat/message-input.tsx +42 -4
- package/src/web/hooks/use-chat.ts +21 -9
- package/dist/web/assets/architecture-PBZL5I3N-281eTKQ3.js +0 -1
- package/dist/web/assets/arrow-left-C_j9Ki73.js +0 -1
- package/dist/web/assets/browser-tab-BhTdeeZd.js +0 -1
- package/dist/web/assets/channel-CKNZAqoN.js +0 -1
- package/dist/web/assets/chat-tab-ZiiUVOxM.js +0 -7
- package/dist/web/assets/chunk-GLR3WWYH-Bx2UL5jF.js +0 -2
- package/dist/web/assets/chunk-HHEYEP7N-BnRVfNc5.js +0 -1
- package/dist/web/assets/chunk-QZHKN3VN-gaBt0Rbd.js +0 -1
- package/dist/web/assets/classDiagram-VBA2DB6C-CqaIqYPn.js +0 -1
- package/dist/web/assets/classDiagram-v2-RAHNMMFH-Bo5WN2ok.js +0 -1
- package/dist/web/assets/clone-DNDy9Sms.js +0 -1
- package/dist/web/assets/database-viewer-CEoDpzPz.js +0 -1
- package/dist/web/assets/git-graph-DMQzw4Sp.js +0 -1
- package/dist/web/assets/gitGraph-HDMCJU4V-D5qEPjgs.js +0 -1
- package/dist/web/assets/index-B4Iz1Wbi.css +0 -2
- package/dist/web/assets/index-QiSWS6f-.js +0 -37
- package/dist/web/assets/info-3K5VOQVL-CbpovIYU.js +0 -1
- package/dist/web/assets/infoDiagram-LFFYTUFH-DFh9c-S2.js +0 -2
- package/dist/web/assets/input-DGlv6gt_.js +0 -41
- package/dist/web/assets/keybindings-store-BplH-yiN.js +0 -1
- package/dist/web/assets/packet-RMMSAZCW-BbzPU9BK.js +0 -1
- package/dist/web/assets/pie-UPGHQEXC-B0h6hM1j.js +0 -1
- package/dist/web/assets/postgres-viewer-s0snZ9CL.js +0 -1
- package/dist/web/assets/radar-KQ55EAFF-CHptMqVT.js +0 -1
- package/dist/web/assets/settings-tab-2YkgmrY0.js +0 -1
- package/dist/web/assets/sqlite-viewer-B5GNwXaG.js +0 -1
- package/dist/web/assets/stateDiagram-v2-FVOUBMTO-D7qSAjnK.js +0 -1
- package/dist/web/assets/switch-mjGtIVDJ.js +0 -1
- package/dist/web/assets/treemap-KZPCXAKY-BL9OJq3X.js +0 -1
- /package/dist/web/assets/{api-client-icCZ-07C.js → api-client-DpGMOZNf.js} +0 -0
- /package/dist/web/assets/{array-CLwNaqU1.js → array-BGFCBI0e.js} +0 -0
- /package/dist/web/assets/{columns-2-Bcg3QJBg.js → columns-2-ChOTgl3e.js} +0 -0
- /package/dist/web/assets/{cytoscape.esm-B-QQuWwK.js → cytoscape.esm-Ccan6xou.js} +0 -0
- /package/dist/web/assets/{defaultLocale-D_VMtRaY.js → defaultLocale-CRZydyG6.js} +0 -0
- /package/dist/web/assets/{dist-Ckxnw5rl.js → dist-Cce3efmT.js} +0 -0
- /package/dist/web/assets/{dist-CMmNEgEP.js → dist-T0Vhi0Mh.js} +0 -0
- /package/dist/web/assets/{init-vVpfz1D6.js → init-B8gtcn7T.js} +0 -0
- /package/dist/web/assets/{isArrayLikeObject-DvHDmeBe.js → isArrayLikeObject-B4pdpV8V.js} +0 -0
- /package/dist/web/assets/{katex-C3cZrCvP.js → katex-Bbu770d9.js} +0 -0
- /package/dist/web/assets/{math-a44lmFDa.js → math-DwgHI-Cu.js} +0 -0
- /package/dist/web/assets/{path-CuyvWNAH.js → path-DZF-JdEe.js} +0 -0
- /package/dist/web/assets/{preload-helper-CsoeaaUJ.js → preload-helper-qlgyTAkD.js} +0 -0
- /package/dist/web/assets/{react-BPIfZRKM.js → react-BGf7KNLk.js} +0 -0
- /package/dist/web/assets/{rough.esm-c4PR5shF.js → rough.esm-VLpapkIG.js} +0 -0
- /package/dist/web/assets/{src-CLWraeNW.js → src-BoSBNdA_.js} +0 -0
- /package/dist/web/assets/{table-C9jDaRl2.js → table-Yo02WRH-.js} +0 -0
- /package/dist/web/assets/{tag-CENGyt_L.js → tag-CaC1ng2E.js} +0 -0
- /package/dist/web/assets/{utils-Bslrbb-G.js → utils-btZ8C8-R.js} +0 -0
|
@@ -25,6 +25,84 @@ function getSdkSessionId(ppmId: string): string {
|
|
|
25
25
|
return getSessionMapping(ppmId) ?? ppmId;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// ── Streaming Input: message channel for persistent query ──
|
|
29
|
+
|
|
30
|
+
interface MessageController {
|
|
31
|
+
push(msg: any): void;
|
|
32
|
+
done(): void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function createMessageChannel(): {
|
|
36
|
+
generator: AsyncGenerator<any, void, undefined>;
|
|
37
|
+
controller: MessageController;
|
|
38
|
+
} {
|
|
39
|
+
const queue: any[] = [];
|
|
40
|
+
let resolve: ((msg: any) => void) | null = null;
|
|
41
|
+
let isDone = false;
|
|
42
|
+
|
|
43
|
+
async function* gen(): AsyncGenerator<any, void, undefined> {
|
|
44
|
+
while (!isDone) {
|
|
45
|
+
if (queue.length > 0) {
|
|
46
|
+
yield queue.shift()!;
|
|
47
|
+
} else {
|
|
48
|
+
const msg = await new Promise<any>((r) => { resolve = r; });
|
|
49
|
+
if (!isDone) yield msg;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
generator: gen(),
|
|
56
|
+
controller: {
|
|
57
|
+
push(msg: any) {
|
|
58
|
+
if (isDone) return;
|
|
59
|
+
if (resolve) {
|
|
60
|
+
const r = resolve;
|
|
61
|
+
resolve = null;
|
|
62
|
+
r(msg);
|
|
63
|
+
} else {
|
|
64
|
+
queue.push(msg);
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
done() {
|
|
68
|
+
isDone = true;
|
|
69
|
+
if (resolve) {
|
|
70
|
+
const r = resolve;
|
|
71
|
+
resolve = null;
|
|
72
|
+
r(null); // Unblock pending promise; isDone prevents yield
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Build a MessageParam with optional image content blocks */
|
|
80
|
+
function buildMessageParam(
|
|
81
|
+
text: string,
|
|
82
|
+
images?: Array<{ data: string; mediaType: string }>,
|
|
83
|
+
): { role: 'user'; content: string | any[] } {
|
|
84
|
+
if (!images || images.length === 0) {
|
|
85
|
+
return { role: 'user' as const, content: text };
|
|
86
|
+
}
|
|
87
|
+
const blocks: any[] = [];
|
|
88
|
+
for (const img of images) {
|
|
89
|
+
blocks.push({
|
|
90
|
+
type: 'image',
|
|
91
|
+
source: { type: 'base64', media_type: img.mediaType, data: img.data },
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
if (text.trim()) {
|
|
95
|
+
blocks.push({ type: 'text', text });
|
|
96
|
+
}
|
|
97
|
+
return { role: 'user' as const, content: blocks };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface StreamingSession {
|
|
101
|
+
meta: Session;
|
|
102
|
+
query: any;
|
|
103
|
+
controller: MessageController;
|
|
104
|
+
}
|
|
105
|
+
|
|
28
106
|
/**
|
|
29
107
|
* Pending approval: canUseTool callback creates a promise,
|
|
30
108
|
* yields an approval_request event, then awaits resolution from FE.
|
|
@@ -50,6 +128,8 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
50
128
|
private activeQueries = new Map<string, { close: () => void }>();
|
|
51
129
|
/** Fork source: ppmSessionId → sourceSessionId (used on first message to fork) */
|
|
52
130
|
private forkSources = new Map<string, string>();
|
|
131
|
+
/** Streaming sessions: persistent query + message channel per session */
|
|
132
|
+
private streamingSessions = new Map<string, StreamingSession>();
|
|
53
133
|
|
|
54
134
|
/** Auth-related env keys for diagnostic logging */
|
|
55
135
|
private readonly AUTH_ENV_KEYS = ["ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN"];
|
|
@@ -220,6 +300,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
220
300
|
}
|
|
221
301
|
|
|
222
302
|
async deleteSession(sessionId: string): Promise<void> {
|
|
303
|
+
this.closeStreamingSession(sessionId);
|
|
223
304
|
this.activeSessions.delete(sessionId);
|
|
224
305
|
this.messageCount.delete(sessionId);
|
|
225
306
|
}
|
|
@@ -260,11 +341,63 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
260
341
|
}
|
|
261
342
|
}
|
|
262
343
|
|
|
344
|
+
/**
|
|
345
|
+
* Push a follow-up message into an existing streaming session's generator.
|
|
346
|
+
* Called by WS handler for follow-up messages (Phase 2).
|
|
347
|
+
*/
|
|
348
|
+
pushMessage(sessionId: string, content: string, opts?: { priority?: 'now' | 'next' | 'later'; images?: Array<{ data: string; mediaType: string }> }): void {
|
|
349
|
+
const ss = this.streamingSessions.get(sessionId);
|
|
350
|
+
if (!ss) {
|
|
351
|
+
console.warn(`[sdk] pushMessage: no streaming session for ${sessionId}`);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const msgContent = buildMessageParam(content, opts?.images);
|
|
355
|
+
ss.controller.push({
|
|
356
|
+
type: 'user',
|
|
357
|
+
message: msgContent,
|
|
358
|
+
parent_tool_use_id: null,
|
|
359
|
+
session_id: sessionId,
|
|
360
|
+
priority: opts?.priority ?? 'next',
|
|
361
|
+
});
|
|
362
|
+
console.log(`[sdk] pushMessage: session=${sessionId} priority=${opts?.priority ?? 'next'}`);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Close a streaming session — generator + query cleanup */
|
|
366
|
+
closeStreamingSession(sessionId: string): void {
|
|
367
|
+
const ss = this.streamingSessions.get(sessionId);
|
|
368
|
+
if (ss) {
|
|
369
|
+
ss.controller.done();
|
|
370
|
+
ss.query.close();
|
|
371
|
+
this.streamingSessions.delete(sessionId);
|
|
372
|
+
console.log(`[sdk] closeStreamingSession: session=${sessionId}`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Check if a streaming session is active for a given session ID */
|
|
377
|
+
hasStreamingSession(sessionId: string): boolean {
|
|
378
|
+
return this.streamingSessions.has(sessionId);
|
|
379
|
+
}
|
|
380
|
+
|
|
263
381
|
async *sendMessage(
|
|
264
382
|
sessionId: string,
|
|
265
383
|
message: string,
|
|
266
|
-
opts?: import("./provider.interface.ts").SendMessageOpts & { forkSession?: boolean },
|
|
384
|
+
opts?: import("./provider.interface.ts").SendMessageOpts & { forkSession?: boolean; priority?: 'now' | 'next' | 'later'; images?: Array<{ data: string; mediaType: string }> },
|
|
267
385
|
): AsyncIterable<ChatEvent> {
|
|
386
|
+
// Follow-up: push into existing streaming session, yield nothing
|
|
387
|
+
const existingStream = this.streamingSessions.get(sessionId);
|
|
388
|
+
if (existingStream) {
|
|
389
|
+
const msgContent = buildMessageParam(message, opts?.images);
|
|
390
|
+
existingStream.controller.push({
|
|
391
|
+
type: 'user',
|
|
392
|
+
message: msgContent,
|
|
393
|
+
parent_tool_use_id: null,
|
|
394
|
+
session_id: sessionId,
|
|
395
|
+
priority: opts?.priority ?? 'next',
|
|
396
|
+
});
|
|
397
|
+
console.log(`[sdk] sendMessage follow-up: session=${sessionId} pushed to generator`);
|
|
398
|
+
return; // Events flow through first-message's consumer loop
|
|
399
|
+
}
|
|
400
|
+
|
|
268
401
|
if (!this.activeSessions.has(sessionId)) {
|
|
269
402
|
await this.resumeSession(sessionId);
|
|
270
403
|
}
|
|
@@ -387,6 +520,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
387
520
|
let resultSubtype: string | undefined;
|
|
388
521
|
let resultNumTurns: number | undefined;
|
|
389
522
|
let resultContextWindowPct: number | undefined;
|
|
523
|
+
let yieldedDone = false;
|
|
390
524
|
try {
|
|
391
525
|
// Resolve SDK's actual session ID for resume (may differ from PPM's UUID)
|
|
392
526
|
// For fork: use the source session's SDK id
|
|
@@ -458,14 +592,25 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
458
592
|
includePartialMessages: true,
|
|
459
593
|
};
|
|
460
594
|
|
|
595
|
+
// Streaming input: create message channel and persistent query
|
|
596
|
+
const { generator: streamGen, controller: streamCtrl } = createMessageChannel();
|
|
597
|
+
const firstMsg = {
|
|
598
|
+
type: 'user' as const,
|
|
599
|
+
message: buildMessageParam(message),
|
|
600
|
+
parent_tool_use_id: null,
|
|
601
|
+
session_id: sessionId,
|
|
602
|
+
};
|
|
603
|
+
streamCtrl.push(firstMsg);
|
|
604
|
+
|
|
461
605
|
const q = query({
|
|
462
|
-
prompt:
|
|
606
|
+
prompt: streamGen,
|
|
463
607
|
options: {
|
|
464
608
|
...queryOptions,
|
|
465
609
|
...(permissionHooks && { hooks: permissionHooks }),
|
|
466
610
|
canUseTool,
|
|
467
611
|
} as any,
|
|
468
612
|
});
|
|
613
|
+
this.streamingSessions.set(sessionId, { meta, query: q, controller: streamCtrl });
|
|
469
614
|
this.activeQueries.set(sessionId, q);
|
|
470
615
|
let eventSource: AsyncIterable<any> = q;
|
|
471
616
|
|
|
@@ -480,22 +625,29 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
480
625
|
let retryCount = 0;
|
|
481
626
|
let authRetried = false;
|
|
482
627
|
|
|
628
|
+
let hadAnyEvents = false;
|
|
483
629
|
retryLoop: while (true) {
|
|
484
630
|
let sdkEventCount = 0;
|
|
485
631
|
for await (const msg of eventSource) {
|
|
486
632
|
sdkEventCount++;
|
|
633
|
+
hadAnyEvents = true;
|
|
487
634
|
if (sdkEventCount === 1) {
|
|
488
635
|
console.log(`[sdk] first event received: type=${(msg as any).type} subtype=${(msg as any).subtype ?? "none"}`);
|
|
489
636
|
// Detect immediate failure: first event is a result with error + 0 turns
|
|
490
637
|
if ((msg as any).type === "result" && (msg as any).subtype === "error_during_execution" && ((msg as any).num_turns ?? 0) === 0 && retryCount < MAX_RETRIES) {
|
|
491
638
|
retryCount++;
|
|
492
639
|
console.warn(`[sdk] transient error on first event — retrying (attempt ${retryCount}/${MAX_RETRIES})`);
|
|
493
|
-
//
|
|
640
|
+
// Close failed query and old channel, create new channel + query for retry
|
|
641
|
+
streamCtrl.done();
|
|
642
|
+
q.close();
|
|
643
|
+
const { generator: retryGen, controller: retryCtrl } = createMessageChannel();
|
|
644
|
+
retryCtrl.push(firstMsg);
|
|
494
645
|
const retryOpts = { ...queryOptions, sessionId: undefined, resume: undefined };
|
|
495
646
|
const rq = query({
|
|
496
|
-
prompt:
|
|
647
|
+
prompt: retryGen,
|
|
497
648
|
options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
|
|
498
649
|
});
|
|
650
|
+
this.streamingSessions.set(sessionId, { meta, query: rq, controller: retryCtrl });
|
|
499
651
|
this.activeQueries.set(sessionId, rq);
|
|
500
652
|
eventSource = rq;
|
|
501
653
|
continue retryLoop;
|
|
@@ -641,11 +793,17 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
641
793
|
const refreshedAccount = accountService.getWithTokens(account.id);
|
|
642
794
|
if (refreshedAccount) {
|
|
643
795
|
const retryEnv = this.buildQueryEnv(meta.projectPath, refreshedAccount);
|
|
796
|
+
// Close failed query and old channel, create new channel + query with refreshed token
|
|
797
|
+
streamCtrl.done();
|
|
798
|
+
q.close();
|
|
799
|
+
const { generator: authRetryGen, controller: authRetryCtrl } = createMessageChannel();
|
|
800
|
+
authRetryCtrl.push(firstMsg);
|
|
644
801
|
const retryOpts = { ...queryOptions, sessionId: undefined, resume: undefined, env: retryEnv };
|
|
645
802
|
const rq = query({
|
|
646
|
-
prompt:
|
|
803
|
+
prompt: authRetryGen,
|
|
647
804
|
options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
|
|
648
805
|
});
|
|
806
|
+
this.streamingSessions.set(sessionId, { meta, query: rq, controller: authRetryCtrl });
|
|
649
807
|
this.activeQueries.set(sessionId, rq);
|
|
650
808
|
eventSource = rq;
|
|
651
809
|
continue retryLoop;
|
|
@@ -717,9 +875,9 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
717
875
|
const errCode = this.detectResultErrorCode(msg);
|
|
718
876
|
if (errCode === 429) {
|
|
719
877
|
accountSelector.onRateLimit(account.id);
|
|
720
|
-
// Post-stream 429
|
|
878
|
+
// Post-stream 429 — surface error, continue waiting for next turn
|
|
721
879
|
yield { type: "error", message: "Rate limited. This account is now on cooldown. Please retry." };
|
|
722
|
-
|
|
880
|
+
continue;
|
|
723
881
|
} else if (errCode === 401) {
|
|
724
882
|
// Try refresh once
|
|
725
883
|
try {
|
|
@@ -827,7 +985,26 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
827
985
|
}
|
|
828
986
|
}
|
|
829
987
|
}
|
|
830
|
-
|
|
988
|
+
|
|
989
|
+
// Streaming input: yield done for this turn, then continue for next turn
|
|
990
|
+
yieldedDone = true;
|
|
991
|
+
yield {
|
|
992
|
+
type: "done",
|
|
993
|
+
sessionId,
|
|
994
|
+
resultSubtype: resultSubtype as any,
|
|
995
|
+
numTurns: resultNumTurns,
|
|
996
|
+
contextWindowPct: resultContextWindowPct,
|
|
997
|
+
};
|
|
998
|
+
|
|
999
|
+
// Reset per-turn state for next turn
|
|
1000
|
+
lastPartialText = "";
|
|
1001
|
+
pendingToolCount = 0;
|
|
1002
|
+
assistantContent = "";
|
|
1003
|
+
resultSubtype = undefined;
|
|
1004
|
+
resultNumTurns = undefined;
|
|
1005
|
+
resultContextWindowPct = undefined;
|
|
1006
|
+
sdkEventCount = 0;
|
|
1007
|
+
continue; // Wait for next turn from generator
|
|
831
1008
|
}
|
|
832
1009
|
}
|
|
833
1010
|
|
|
@@ -836,7 +1013,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
836
1013
|
yield approvalEvents.shift()!;
|
|
837
1014
|
}
|
|
838
1015
|
|
|
839
|
-
if (
|
|
1016
|
+
if (!hadAnyEvents) {
|
|
840
1017
|
yield { type: "error", message: "Claude did not respond. Check that 'claude' CLI works in your terminal." };
|
|
841
1018
|
}
|
|
842
1019
|
break; // Exit retryLoop — normal completion
|
|
@@ -846,88 +1023,47 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
846
1023
|
console.error(`[sdk] session=${sessionId} cwd=${meta.projectPath} error: ${msg}`);
|
|
847
1024
|
if (msg.includes("abort") || msg.includes("closed")) {
|
|
848
1025
|
// User-initiated abort or WS closed — nothing to report
|
|
849
|
-
} else if (
|
|
850
|
-
//
|
|
851
|
-
console.warn(`[sdk] session
|
|
852
|
-
|
|
853
|
-
const providerConfig = this.getProviderConfig();
|
|
854
|
-
const effectiveCwd = meta.projectPath || homedir();
|
|
855
|
-
const retryAccount = accountSelector.isEnabled() ? accountSelector.next() : null;
|
|
856
|
-
const queryEnv = this.buildQueryEnv(meta.projectPath, retryAccount);
|
|
857
|
-
const retryOptions = {
|
|
858
|
-
...(process.platform === "win32" && { executable: "node" }),
|
|
859
|
-
cwd: effectiveCwd,
|
|
860
|
-
systemPrompt: systemPromptOpt,
|
|
861
|
-
settingSources: ["user", "project"],
|
|
862
|
-
env: queryEnv,
|
|
863
|
-
settings: { permissions: { allow: [], deny: [] } },
|
|
864
|
-
allowedTools,
|
|
865
|
-
permissionMode,
|
|
866
|
-
allowDangerouslySkipPermissions: isBypass,
|
|
867
|
-
...(providerConfig.model && { model: providerConfig.model }),
|
|
868
|
-
maxTurns: providerConfig.max_turns ?? 100,
|
|
869
|
-
includePartialMessages: true,
|
|
870
|
-
};
|
|
871
|
-
const retryQuery = query({
|
|
872
|
-
prompt: message,
|
|
873
|
-
options: {
|
|
874
|
-
...retryOptions,
|
|
875
|
-
...(permissionHooks && { hooks: permissionHooks }),
|
|
876
|
-
canUseTool,
|
|
877
|
-
} as any,
|
|
878
|
-
});
|
|
879
|
-
this.activeQueries.set(sessionId, retryQuery);
|
|
880
|
-
for await (const retryMsg of retryQuery) {
|
|
881
|
-
if (retryMsg.type === "system") continue;
|
|
882
|
-
if (retryMsg.type === "result") {
|
|
883
|
-
const r = retryMsg as any;
|
|
884
|
-
if (r.subtype && r.subtype !== "success") {
|
|
885
|
-
const retryErrors = Array.isArray(r.errors) ? r.errors.join("\n") : "";
|
|
886
|
-
yield { type: "error", message: retryErrors || `Agent stopped: ${r.subtype}` };
|
|
887
|
-
}
|
|
888
|
-
resultSubtype = r.subtype;
|
|
889
|
-
resultNumTurns = r.num_turns;
|
|
890
|
-
break;
|
|
891
|
-
}
|
|
892
|
-
if ((retryMsg as any).type === "assistant") {
|
|
893
|
-
const content = (retryMsg as any).message?.content;
|
|
894
|
-
if (Array.isArray(content)) {
|
|
895
|
-
for (const block of content) {
|
|
896
|
-
if (block.type === "text" && typeof block.text === "string") {
|
|
897
|
-
yield { type: "text", content: block.text };
|
|
898
|
-
}
|
|
899
|
-
}
|
|
900
|
-
}
|
|
901
|
-
}
|
|
902
|
-
}
|
|
903
|
-
} catch (retryErr) {
|
|
904
|
-
const retryMsg = (retryErr as Error).message ?? String(retryErr);
|
|
905
|
-
console.error(`[sdk] retry also failed: ${retryMsg}`);
|
|
906
|
-
yield { type: "error", message: `SDK error: ${msg}` };
|
|
907
|
-
}
|
|
1026
|
+
} else if (msg.includes("exited with code")) {
|
|
1027
|
+
// Subprocess crashed — session will auto-recover on next message
|
|
1028
|
+
console.warn(`[sdk] session=${sessionId} subprocess crashed: ${msg}`);
|
|
1029
|
+
yield { type: "error", message: `SDK subprocess crashed. Send another message to auto-recover.` };
|
|
908
1030
|
} else {
|
|
909
1031
|
yield { type: "error", message: `SDK error: ${msg}` };
|
|
910
1032
|
}
|
|
911
1033
|
} finally {
|
|
912
1034
|
this.activeQueries.delete(sessionId);
|
|
1035
|
+
this.streamingSessions.delete(sessionId);
|
|
1036
|
+
console.log(`[sdk] session=${sessionId} streaming session ended`);
|
|
913
1037
|
}
|
|
914
1038
|
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
1039
|
+
// Final done event when query ends (crash, close, generator done)
|
|
1040
|
+
// Skip if we already yielded done from the result handler (avoid duplicate)
|
|
1041
|
+
if (!yieldedDone) {
|
|
1042
|
+
yield {
|
|
1043
|
+
type: "done",
|
|
1044
|
+
sessionId,
|
|
1045
|
+
resultSubtype: resultSubtype as any,
|
|
1046
|
+
numTurns: resultNumTurns,
|
|
1047
|
+
contextWindowPct: resultContextWindowPct,
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
922
1050
|
}
|
|
923
1051
|
|
|
924
1052
|
|
|
925
|
-
/**
|
|
1053
|
+
/** Interrupt the current turn — session stays alive for the next message */
|
|
926
1054
|
abortQuery(sessionId: string): void {
|
|
1055
|
+
const ss = this.streamingSessions.get(sessionId);
|
|
1056
|
+
if (ss && typeof ss.query.interrupt === "function") {
|
|
1057
|
+
ss.query.interrupt().catch(() => {});
|
|
1058
|
+
console.log(`[sdk] abortQuery: interrupted session=${sessionId}`);
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
// Fallback: close query entirely and clean up streaming session
|
|
927
1062
|
const q = this.activeQueries.get(sessionId);
|
|
928
1063
|
if (q) {
|
|
929
1064
|
q.close();
|
|
930
1065
|
this.activeQueries.delete(sessionId);
|
|
1066
|
+
this.streamingSessions.delete(sessionId);
|
|
931
1067
|
}
|
|
932
1068
|
}
|
|
933
1069
|
|
package/src/server/index.ts
CHANGED
|
@@ -455,12 +455,15 @@ export async function startServer(options: {
|
|
|
455
455
|
}
|
|
456
456
|
console.log();
|
|
457
457
|
|
|
458
|
-
// Graceful shutdown — stop server + tunnel + DB on exit
|
|
458
|
+
// Graceful shutdown — stop server + tunnel + preview tunnels + DB on exit
|
|
459
459
|
const shutdown = () => {
|
|
460
460
|
try { server.stop(true); } catch {}
|
|
461
461
|
try {
|
|
462
462
|
import("../services/tunnel.service.ts").then(({ tunnelService }) => tunnelService.stopTunnel()).catch(() => {});
|
|
463
463
|
} catch {}
|
|
464
|
+
try {
|
|
465
|
+
import("./routes/browser-preview.ts").then(({ stopAllPreviewTunnels }) => stopAllPreviewTunnels()).catch(() => {});
|
|
466
|
+
} catch {}
|
|
464
467
|
try {
|
|
465
468
|
import("../services/db.service.ts").then(({ closeDb }) => closeDb()).catch(() => {});
|
|
466
469
|
} catch {}
|
|
@@ -1,89 +1,159 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
|
+
import { ok, err } from "../../types/api.ts";
|
|
3
|
+
import { ensureCloudflared } from "../../services/cloudflared.service.ts";
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
|
-
* Browser preview
|
|
5
|
-
*
|
|
6
|
-
*
|
|
6
|
+
* Browser preview API — starts per-port Cloudflare Quick Tunnels so the
|
|
7
|
+
* frontend can iframe any localhost dev server without CORS/path issues.
|
|
8
|
+
*
|
|
9
|
+
* POST /api/preview/tunnel { port: 3000 } → { url: "https://xxx.trycloudflare.com" }
|
|
10
|
+
* DELETE /api/preview/tunnel/:port → stops tunnel for that port
|
|
11
|
+
* GET /api/preview/tunnels → list active tunnels
|
|
7
12
|
*/
|
|
8
13
|
export const browserPreviewRoutes = new Hono();
|
|
9
14
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
15
|
+
const TUNNEL_URL_REGEX = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
|
|
16
|
+
|
|
17
|
+
interface ActiveTunnel {
|
|
18
|
+
port: number;
|
|
19
|
+
url: string;
|
|
20
|
+
process: import("bun").Subprocess;
|
|
21
|
+
startedAt: number;
|
|
14
22
|
}
|
|
15
23
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
24
|
+
/** Active tunnels keyed by port */
|
|
25
|
+
const activeTunnels = new Map<number, ActiveTunnel>();
|
|
26
|
+
|
|
27
|
+
/** Start a tunnel for a localhost port */
|
|
28
|
+
browserPreviewRoutes.post("/tunnel", async (c) => {
|
|
29
|
+
const body = await c.req.json<{ port: number }>().catch(() => null);
|
|
30
|
+
const port = body?.port;
|
|
31
|
+
if (!port || port < 1 || port > 65535) {
|
|
32
|
+
return c.json(err("Invalid port (1-65535)"), 400);
|
|
20
33
|
}
|
|
21
34
|
|
|
22
|
-
//
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
35
|
+
// Return existing tunnel if already running
|
|
36
|
+
const existing = activeTunnels.get(port);
|
|
37
|
+
if (existing) {
|
|
38
|
+
return c.json(ok({ port, url: existing.url }));
|
|
39
|
+
}
|
|
27
40
|
|
|
28
41
|
try {
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const resp = await fetch(targetUrl, {
|
|
35
|
-
method: c.req.method,
|
|
36
|
-
headers,
|
|
37
|
-
body: ["GET", "HEAD"].includes(c.req.method) ? undefined : c.req.raw.body,
|
|
38
|
-
redirect: "manual",
|
|
39
|
-
});
|
|
42
|
+
const bin = await ensureCloudflared();
|
|
43
|
+
const proc = Bun.spawn(
|
|
44
|
+
[bin, "tunnel", "--url", `http://127.0.0.1:${port}`],
|
|
45
|
+
{ stderr: "pipe", stdout: "ignore", stdin: "ignore" },
|
|
46
|
+
);
|
|
40
47
|
|
|
41
|
-
//
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
48
|
+
// Read stderr to find tunnel URL
|
|
49
|
+
const reader = proc.stderr.getReader();
|
|
50
|
+
const decoder = new TextDecoder();
|
|
51
|
+
const url = await new Promise<string>((resolve, reject) => {
|
|
52
|
+
const timeout = setTimeout(() => {
|
|
53
|
+
try { proc.kill(); } catch {}
|
|
54
|
+
reject(new Error("Tunnel timed out after 30s"));
|
|
55
|
+
}, 30_000);
|
|
45
56
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
57
|
+
let buffer = "";
|
|
58
|
+
let found = false;
|
|
59
|
+
const read = async () => {
|
|
60
|
+
try {
|
|
61
|
+
while (true) {
|
|
62
|
+
const { done, value } = await reader.read();
|
|
63
|
+
if (done) break;
|
|
64
|
+
if (found) continue;
|
|
65
|
+
buffer += decoder.decode(value, { stream: true });
|
|
66
|
+
const match = buffer.match(TUNNEL_URL_REGEX);
|
|
67
|
+
if (match) {
|
|
68
|
+
found = true;
|
|
69
|
+
buffer = "";
|
|
70
|
+
clearTimeout(timeout);
|
|
71
|
+
resolve(match[0]);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (!found) {
|
|
75
|
+
clearTimeout(timeout);
|
|
76
|
+
reject(new Error("cloudflared exited without tunnel URL"));
|
|
77
|
+
}
|
|
78
|
+
} catch (e) {
|
|
79
|
+
if (!found) { clearTimeout(timeout); reject(e); }
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
read();
|
|
50
83
|
});
|
|
51
|
-
|
|
52
|
-
|
|
84
|
+
|
|
85
|
+
activeTunnels.set(port, { port, url, process: proc, startedAt: Date.now() });
|
|
86
|
+
|
|
87
|
+
// Auto-cleanup when process exits
|
|
88
|
+
proc.exited.then(() => activeTunnels.delete(port)).catch(() => activeTunnels.delete(port));
|
|
89
|
+
|
|
90
|
+
console.log(`[preview] tunnel started for port ${port} → ${url}`);
|
|
91
|
+
return c.json(ok({ port, url }));
|
|
92
|
+
} catch (e: any) {
|
|
93
|
+
return c.json(err(e.message || "Failed to start tunnel"), 500);
|
|
53
94
|
}
|
|
54
95
|
});
|
|
55
96
|
|
|
56
|
-
|
|
57
|
-
browserPreviewRoutes.
|
|
58
|
-
const port = c.req.param("port");
|
|
59
|
-
|
|
60
|
-
|
|
97
|
+
/** Stop a tunnel */
|
|
98
|
+
browserPreviewRoutes.delete("/tunnel/:port{[0-9]+}", (c) => {
|
|
99
|
+
const port = parseInt(c.req.param("port"), 10);
|
|
100
|
+
const tunnel = activeTunnels.get(port);
|
|
101
|
+
if (!tunnel) {
|
|
102
|
+
return c.json(err("No tunnel running for this port"), 404);
|
|
61
103
|
}
|
|
62
104
|
|
|
63
|
-
|
|
64
|
-
|
|
105
|
+
try { tunnel.process.kill(); } catch {}
|
|
106
|
+
activeTunnels.delete(port);
|
|
107
|
+
console.log(`[preview] tunnel stopped for port ${port}`);
|
|
108
|
+
return c.json(ok({ port }));
|
|
109
|
+
});
|
|
65
110
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
});
|
|
111
|
+
/** List active tunnels */
|
|
112
|
+
browserPreviewRoutes.get("/tunnels", (c) => {
|
|
113
|
+
const list = Array.from(activeTunnels.values()).map((t) => ({
|
|
114
|
+
port: t.port,
|
|
115
|
+
url: t.url,
|
|
116
|
+
startedAt: t.startedAt,
|
|
117
|
+
}));
|
|
118
|
+
return c.json(ok(list));
|
|
119
|
+
});
|
|
76
120
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
121
|
+
/** Check if a cloudflared process is still alive */
|
|
122
|
+
function isProcessAlive(proc: import("bun").Subprocess): boolean {
|
|
123
|
+
try { process.kill(proc.pid, 0); return true; } catch { return false; }
|
|
124
|
+
}
|
|
80
125
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
126
|
+
/** Remove ghost tunnels (process died or target port no longer listening) */
|
|
127
|
+
async function cleanupGhostTunnels() {
|
|
128
|
+
for (const [port, tunnel] of activeTunnels) {
|
|
129
|
+
// Check if cloudflared process is still running
|
|
130
|
+
if (!isProcessAlive(tunnel.process)) {
|
|
131
|
+
console.log(`[preview] ghost cleanup: tunnel for port ${port} — process dead`);
|
|
132
|
+
activeTunnels.delete(port);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
// Check if target port is still listening
|
|
136
|
+
try {
|
|
137
|
+
const conn = await Bun.connect({ hostname: "127.0.0.1", port, socket: {
|
|
138
|
+
data() {}, open(s) { s.end(); }, error() {}, close() {},
|
|
139
|
+
}});
|
|
140
|
+
conn.end();
|
|
141
|
+
} catch {
|
|
142
|
+
// Port not listening — kill tunnel
|
|
143
|
+
console.log(`[preview] ghost cleanup: tunnel for port ${port} — port not listening`);
|
|
144
|
+
try { tunnel.process.kill(); } catch {}
|
|
145
|
+
activeTunnels.delete(port);
|
|
146
|
+
}
|
|
88
147
|
}
|
|
89
|
-
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Run ghost cleanup every 30s
|
|
151
|
+
setInterval(cleanupGhostTunnels, 30_000);
|
|
152
|
+
|
|
153
|
+
/** Cleanup all tunnels on server shutdown */
|
|
154
|
+
export function stopAllPreviewTunnels() {
|
|
155
|
+
for (const [port, tunnel] of activeTunnels) {
|
|
156
|
+
try { tunnel.process.kill(); } catch {}
|
|
157
|
+
activeTunnels.delete(port);
|
|
158
|
+
}
|
|
159
|
+
}
|