@hienlh/ppm 0.9.75 → 0.9.77
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/AGENTS.md +80 -0
- package/CHANGELOG.md +10 -0
- package/dist/web/assets/{chat-tab-jKL8IW3Y.js → chat-tab-B3gpx-qv.js} +2 -2
- package/dist/web/assets/{code-editor-CR8ddAzL.js → code-editor-kyaXcsZW.js} +1 -1
- package/dist/web/assets/{database-viewer-B9mqA0gn.js → database-viewer-DmAux3OF.js} +1 -1
- package/dist/web/assets/{diff-viewer-BHJnCxdy.js → diff-viewer-Cikon1YK.js} +1 -1
- package/dist/web/assets/{extension-webview-qOmDNW1k.js → extension-webview-DVvC7SQ-.js} +1 -1
- package/dist/web/assets/{git-graph-C_JG3CzH.js → git-graph-Bon2J1_A.js} +1 -1
- package/dist/web/assets/{index-_YxJ4vi1.js → index-Buc4QA5O.js} +3 -3
- package/dist/web/assets/keybindings-store-CT_EvCrb.js +1 -0
- package/dist/web/assets/{markdown-renderer-_rUM4Qox.js → markdown-renderer-ttL1fRGG.js} +1 -1
- package/dist/web/assets/{port-forwarding-tab-BR3UIrwb.js → port-forwarding-tab-Bljq2XEH.js} +1 -1
- package/dist/web/assets/{postgres-viewer-B9lLK97W.js → postgres-viewer-CqburCkJ.js} +1 -1
- package/dist/web/assets/{settings-tab-BAQdizup.js → settings-tab-CQVn8u_D.js} +1 -1
- package/dist/web/assets/{sql-query-editor-DZK6zX5t.js → sql-query-editor-DP6Kh2R8.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-vLFjca8E.js → sqlite-viewer-CrqzbhyF.js} +1 -1
- package/dist/web/assets/{terminal-tab-B1mmTE_I.js → terminal-tab-BmBB838x.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-B21s-870.js → use-monaco-theme-ZmSrfclJ.js} +1 -1
- package/dist/web/index.html +1 -1
- package/dist/web/sw.js +1 -1
- package/output/pdf/ppm-app-summary.pdf +80 -0
- package/package.json +1 -1
- package/src/cli/commands/restart.ts +5 -3
- package/src/providers/claude-agent-sdk.ts +21 -36
- package/src/server/index.ts +5 -2
- package/src/server/ws/chat.ts +2 -1
- package/src/services/cloud-ws.service.ts +6 -0
- package/src/services/supervisor-state.ts +9 -1
- package/src/services/supervisor.ts +17 -4
- package/src/services/tunnel.service.ts +19 -7
- package/src/types/config.ts +1 -1
- package/src/web/components/chat/message-list.tsx +12 -4
- package/dist/web/assets/keybindings-store-A0Ag0ZYI.js +0 -1
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
%PDF-1.4
|
|
2
|
+
%���� ReportLab Generated PDF document (opensource)
|
|
3
|
+
1 0 obj
|
|
4
|
+
<<
|
|
5
|
+
/F1 2 0 R /F2 3 0 R /F3 4 0 R
|
|
6
|
+
>>
|
|
7
|
+
endobj
|
|
8
|
+
2 0 obj
|
|
9
|
+
<<
|
|
10
|
+
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
|
11
|
+
>>
|
|
12
|
+
endobj
|
|
13
|
+
3 0 obj
|
|
14
|
+
<<
|
|
15
|
+
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
|
16
|
+
>>
|
|
17
|
+
endobj
|
|
18
|
+
4 0 obj
|
|
19
|
+
<<
|
|
20
|
+
/BaseFont /ZapfDingbats /Name /F3 /Subtype /Type1 /Type /Font
|
|
21
|
+
>>
|
|
22
|
+
endobj
|
|
23
|
+
5 0 obj
|
|
24
|
+
<<
|
|
25
|
+
/Contents 9 0 R /MediaBox [ 0 0 612 792 ] /Parent 8 0 R /Resources <<
|
|
26
|
+
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
|
27
|
+
>> /Rotate 0 /Trans <<
|
|
28
|
+
|
|
29
|
+
>>
|
|
30
|
+
/Type /Page
|
|
31
|
+
>>
|
|
32
|
+
endobj
|
|
33
|
+
6 0 obj
|
|
34
|
+
<<
|
|
35
|
+
/PageMode /UseNone /Pages 8 0 R /Type /Catalog
|
|
36
|
+
>>
|
|
37
|
+
endobj
|
|
38
|
+
7 0 obj
|
|
39
|
+
<<
|
|
40
|
+
/Author (Codex) /CreationDate (D:20260410093008+07'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260410093008+07'00') /Producer (ReportLab PDF Library - \(opensource\))
|
|
41
|
+
/Subject (\(unspecified\)) /Title (PPM App Summary) /Trapped /False
|
|
42
|
+
>>
|
|
43
|
+
endobj
|
|
44
|
+
8 0 obj
|
|
45
|
+
<<
|
|
46
|
+
/Count 1 /Kids [ 5 0 R ] /Type /Pages
|
|
47
|
+
>>
|
|
48
|
+
endobj
|
|
49
|
+
9 0 obj
|
|
50
|
+
<<
|
|
51
|
+
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2835
|
|
52
|
+
>>
|
|
53
|
+
stream
|
|
54
|
+
Gau`V>Bedl&q9SYW2EcR$UQe6j?1DdPO\'OiYZ9JAklYNWA1\)[F-Wmr3F(cX'-=T_N5>SEJWY*]";/'=-p*o=CC.3df5%N^r7"Bmjt\ZR*NNg%2YnV_q"DGl^:b6F?RM\K4g7)d28e>'uM$e^?pM\#,W,o_6Cq^B9#:7S`+TiCVbR9MQ>K>"gkjXe(VL)Rh)]1H4SJ3aj)8]F_mJ^g6V.%Y%*%AkW`IbQK=)r*F!\ub5uP'ZF$Tl)'@r+NW[IG:h?(Nq$;1,j,t->K"2RE8=g4Ib;eKaa:E+2*kV'Fg0!:d,<#TIn4b:7l*kRmH=IgfELjAnK09PM)[=h3a^P0!Y"13/Wk)ApFPQ8'VRBacVImp!2eI:/n\IZNr#_]RDQ2;k[eG'`$Fn*jl<,6WE5iCU>$K`ngG(6_eM8X&U.tO)LmBtNgDZ%Rg5Ur2JjF='g6HgdpsQd:igto/quZZ.O=LJV=GG&A0<ZL!^d.F#5$/)E]_*E_ZT-orGq;tCm'O7]1Ut03)Wp]jBsdrbauCh8A8kCX#2NQ5dTN)["nfr["gs5GE"Q`,I2io?i)*bCG6O=IS?W3!8:0[?qm_/CLdYU0=M\TC[Ed6*`dG`O%V&WEDlt6/=Q9hY@OB&dEB0]N&ZgY5:cSooYn-8b=2Fjr@s?G,n?\Vm"Q`tth?g`*)g:-VNTWOLk&,t,E.l4iORH9V@u`O*N1GS6M1CC8Gp;H.=Q-\<"sGR^,0gi3M$R84U0P%&1s.>7'aXJ_.2A&-BPa^LcPIeca<@$!]]d90/pe13%u&(6\>:/VM%J92'\_YO&Ym7BPU-X!cVih%ElFFT(CdY:.[PPY0iu97m1kl,XkH:Bm<]S*:HnJA`Xh4_r;h9ppq@<J7qS#?lJ*$%h"Z#4fuuV(X9"coC`5W`SaAGNMO)OkNVT-b^Yb7%Y4J/2I3@tq1dU#R'OWIFRKuFSma*QHA&%hMTL/:W22Q6hY3U<YmirW8InJ@g.#\%W,#p:M@qGKn.)*TH$N+,"#`0ud:J+<r(mgqp;\c,q+YhTfo/TeGXl"^hPFP(8/CRa]iS2I]#W/H!qnTOi3jlX(I')qf!-l[i1+;.HYDWQr*!Kf.G^ee]M$P2JJJeM^#(31#`dO,`YUm1QhG!sh8gfr[QC5G-H"?ZGODBfI#.[<VmZS879rVqqQUI7;b)ZR]q7*V$/g]17#s,_Zfm<%>F)k\i'J*Kc!j>m,,tonhjC9p$p1*C=(%+LEFapp6m_b:]$)PLaJpINq8!oMQ+fl#OPc>(#e^q%`a?.N7"oNZ%7t&7[E7%"p2"!5aZlW,a+_MZgp-WPR?`q,M`_Q]u"j+dcWjbI[28Tmd6Ghg4mr?1!2^hAg,fb+bBpS9-\$`M0h2p4Y:MWE$oP;QG!V((j/8=lC5QgVSd&\djW)R]VG1';L@MCIUH<q?W2kSu8Q^6f$U/%27U(HQS/N@)foGln\9Cg0:`3@NT&^TL!D0W&$km_4]&p[$&@3S>1oJl]\gm:t,0pRBTF],u2($VsK,Y&?sFM-P1]OOkZp2fjift`f+cF+2NLkb>g'H,&kkT".k-4or2r.Q;g>OXDCKO4,mDAMOe)%Nou)n+L=/lZ`Y:Pie$6Z=PT,.sqH(m-UR-WNXa!D*Hjho_4](@-ut*KEiBpT_4BeDf$(_m(nj9;43_EYQR%O".rC,H]Y7Bh4_dkhu"OIM8-r<+3Yuack-TLG:R[bpZPX1),j;+[*;9PA(E76ljN&Jss,b$i;UlV(UGf(O_rC^6(M/V"CA)YuX<7Rh"afY\tt:W,'s-!q!cdW^JjHVb]Td]7JENUQGK)ne4s_ik.9bd,?Zp($S&@#Lud%XCEj%W'T#Z<?395V7NAs\iU"Jg48PYE"d)qC@;mp&e]^uRpb!Z+;rd6$V`gk<"EU&HqmuC7R&*$+(;1sJ<JNnN4-5?<s$)]M0RB=guB<;dlE9NSarik"ZH(]'^)U0DE;:/\"^\<Y[#$!KU!.)alS#]%Q["_i3JoQFtcZTdU'uB9?Ra*is_\=H?d^G.fNGm8KS^]1nb'0C4+98SE[\k+\m1.(a?H\p^VPW"NBLcGs'$F'c^:o:W@.]J^^iLM:HF@UZWV30F0=[k,i-N8NJrN]GY/Dc5Ld_M0l;$9rcLF)e&R_Xcg7WF2S$47\6MFr%XpKG<0@7LbCKlmkVR:QS3R-J).QOeaXV"&CPE()!_jOVZh^A+$.rUrT<Z[;j/;H%]=Y?='tO#e""jK31L>c>rVc/D.`ELIFOGiI,1pqf:H`,f3,lLP(,&nKoU93ob4>l&jmS\QiWO97hNkLn.Aq9rA[rPr79^%H.NZ.&-[rM;J-C)!se(6?Dt3a14Kkkn&b_Skm9_s=-,1`MLjF\_<bWaObW)<70e=,b7"<nKK?<8c!Ch52"45sg6"%gLd)0ZFkhf].Gp>^D`KoNY[msX8EbXK+>\`bC8Z!qO:29R]Fn'^cjkVLjB:`HYR5O]Oe72>hDU8;@YJ+H%g2KF8#+i4:NL.=&[NV!-.([H.d<[RX*\T]Wg,Z^QRX%HQKNX@[=0I&eMN8hrK:'1U,CZg4Tss#X_t%`A^kmr5?A?fe$lAF:Z*amc/FTp+I4&cm`5i2)iTn@1'>:,cs&_doEaM<!^,%7hn6`#@lc?mkV]eBi<-)Zj'IT/[=/V<4@C7;nNB%T)!$gTZ0NV;)PnKT_:&EJ(.:2M3b$MFmV%j:1Ue=&\uVgmcln55d=rURe+V#!np5/@e5EE-NV^>n]ANDprnO+`?9g[]L@:)*1WA8DAHiBWhk-UOY;X,COFeE2mFN*dR-8<tqmj91RCP2X):;^l8QiIYLnFd<hi490TC`N:rX\QP\5!~>endstream
|
|
55
|
+
endobj
|
|
56
|
+
xref
|
|
57
|
+
0 10
|
|
58
|
+
0000000000 65535 f
|
|
59
|
+
0000000061 00000 n
|
|
60
|
+
0000000112 00000 n
|
|
61
|
+
0000000219 00000 n
|
|
62
|
+
0000000331 00000 n
|
|
63
|
+
0000000414 00000 n
|
|
64
|
+
0000000607 00000 n
|
|
65
|
+
0000000675 00000 n
|
|
66
|
+
0000000949 00000 n
|
|
67
|
+
0000001008 00000 n
|
|
68
|
+
trailer
|
|
69
|
+
<<
|
|
70
|
+
/ID
|
|
71
|
+
[<ed781e36a58178ba295c4b649581811a><ed781e36a58178ba295c4b649581811a>]
|
|
72
|
+
% ReportLab generated PDF document -- digest (opensource)
|
|
73
|
+
|
|
74
|
+
/Info 7 0 R
|
|
75
|
+
/Root 6 0 R
|
|
76
|
+
/Size 10
|
|
77
|
+
>>
|
|
78
|
+
startxref
|
|
79
|
+
3934
|
|
80
|
+
%%EOF
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { readFileSync, writeFileSync, existsSync, openSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { readFileSync, writeFileSync, existsSync, openSync, unlinkSync, renameSync } from "node:fs";
|
|
4
4
|
|
|
5
5
|
const PPM_DIR = resolve(homedir(), ".ppm");
|
|
6
6
|
const STATUS_FILE = resolve(PPM_DIR, "status.json");
|
|
@@ -237,12 +237,14 @@ async function main() {
|
|
|
237
237
|
childPid = child.pid;
|
|
238
238
|
}
|
|
239
239
|
|
|
240
|
-
// Update status.json with new PID, keep tunnel info
|
|
240
|
+
// Update status.json with new PID, keep tunnel info (atomic write to avoid cross-process races)
|
|
241
241
|
try {
|
|
242
242
|
const status = JSON.parse(readFileSync(P.statusFile, "utf-8"));
|
|
243
243
|
status.pid = childPid;
|
|
244
244
|
status.serverScript = P.serverScript;
|
|
245
|
-
|
|
245
|
+
const tmp = P.statusFile + ".tmp." + process.pid;
|
|
246
|
+
writeFileSync(tmp, JSON.stringify(status));
|
|
247
|
+
renameSync(tmp, P.statusFile);
|
|
246
248
|
writeFileSync(P.pidFile, String(childPid));
|
|
247
249
|
} catch {}
|
|
248
250
|
|
|
@@ -688,18 +688,9 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
688
688
|
const expiresIn = account.expiresAt ? account.expiresAt - nowS : null;
|
|
689
689
|
console.log(`[sdk] Using account ${account.id} (${account.email ?? "no-email"}) token_expires_in=${expiresIn}s`);
|
|
690
690
|
|
|
691
|
-
//
|
|
692
|
-
|
|
693
|
-
const
|
|
694
|
-
|
|
695
|
-
if (!needsRefresh) {
|
|
696
|
-
// Token fresh or API key — proceed
|
|
697
|
-
yield { type: "account_info" as const, accountId: account.id, accountLabel };
|
|
698
|
-
break;
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
// Token expiring — attempt refresh
|
|
702
|
-
yield { type: "status_update" as const, phase: "refreshing" as const, message: `Refreshing token for ${accountLabel}...`, accountLabel };
|
|
691
|
+
// ensureFreshToken re-reads DB (picks up concurrent refreshes) and
|
|
692
|
+
// only refreshes if truly needed — safe to call unconditionally.
|
|
693
|
+
yield { type: "status_update" as const, phase: "refreshing" as const, message: `Checking token for ${accountLabel}...`, accountLabel };
|
|
703
694
|
const fresh = await accountService.ensureFreshToken(account.id);
|
|
704
695
|
|
|
705
696
|
if (fresh) {
|
|
@@ -929,10 +920,9 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
929
920
|
const retryEnv = this.buildQueryEnv(meta.projectPath, refreshedAccount);
|
|
930
921
|
closeCurrentStream();
|
|
931
922
|
const { generator: earlyAuthGen, controller: earlyAuthCtrl } = createMessageChannel();
|
|
932
|
-
const
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
const retryOpts = { ...queryOptions, sessionId: undefined, resume: canResume ? currentSdkId : undefined, env: retryEnv };
|
|
923
|
+
const hasHistory = (this.messageCount.get(sessionId) ?? 0) > 0;
|
|
924
|
+
if (!hasHistory) earlyAuthCtrl.push(firstMsg);
|
|
925
|
+
const retryOpts = { ...queryOptions, sessionId: undefined, resume: hasHistory ? getSdkSessionId(sessionId) : undefined, env: retryEnv };
|
|
936
926
|
const rq = query({
|
|
937
927
|
prompt: earlyAuthGen,
|
|
938
928
|
options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
|
|
@@ -956,10 +946,9 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
956
946
|
const switchEnv = this.buildQueryEnv(meta.projectPath, nextAcc);
|
|
957
947
|
closeCurrentStream();
|
|
958
948
|
const { generator: switchGen, controller: switchCtrl } = createMessageChannel();
|
|
959
|
-
const
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
const retryOpts = { ...queryOptions, sessionId: undefined, resume: canResume ? currentSdkId : undefined, env: switchEnv };
|
|
949
|
+
const hasHistory = (this.messageCount.get(sessionId) ?? 0) > 0;
|
|
950
|
+
if (!hasHistory) switchCtrl.push(firstMsg);
|
|
951
|
+
const retryOpts = { ...queryOptions, sessionId: undefined, resume: hasHistory ? getSdkSessionId(sessionId) : undefined, env: switchEnv };
|
|
963
952
|
const rq = query({
|
|
964
953
|
prompt: switchGen,
|
|
965
954
|
options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
|
|
@@ -1108,10 +1097,9 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
1108
1097
|
// resume a non-existent session, causing the SDK to hang forever.
|
|
1109
1098
|
closeCurrentStream();
|
|
1110
1099
|
const { generator: authRetryGen, controller: authRetryCtrl } = createMessageChannel();
|
|
1111
|
-
const
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
const retryOpts = { ...queryOptions, sessionId: undefined, resume: canResume ? currentSdkId : undefined, env: retryEnv };
|
|
1100
|
+
const hasHistory = (this.messageCount.get(sessionId) ?? 0) > 0;
|
|
1101
|
+
if (!hasHistory) authRetryCtrl.push(firstMsg);
|
|
1102
|
+
const retryOpts = { ...queryOptions, sessionId: undefined, resume: hasHistory ? getSdkSessionId(sessionId) : undefined, env: retryEnv };
|
|
1115
1103
|
const rq = query({
|
|
1116
1104
|
prompt: authRetryGen,
|
|
1117
1105
|
options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
|
|
@@ -1162,10 +1150,9 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
1162
1150
|
closeCurrentStream();
|
|
1163
1151
|
const rlRetryEnv = this.buildQueryEnv(meta.projectPath, account);
|
|
1164
1152
|
const { generator: rlRetryGen, controller: rlRetryCtrl } = createMessageChannel();
|
|
1165
|
-
const
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
const retryOpts = { ...queryOptions, sessionId: undefined, resume: rlCanResume ? rlCurrentSdkId : undefined, env: rlRetryEnv };
|
|
1153
|
+
const rlHasHistory = (this.messageCount.get(sessionId) ?? 0) > 0;
|
|
1154
|
+
if (!rlHasHistory) rlRetryCtrl.push(firstMsg);
|
|
1155
|
+
const retryOpts = { ...queryOptions, sessionId: undefined, resume: rlHasHistory ? getSdkSessionId(sessionId) : undefined, env: rlRetryEnv };
|
|
1169
1156
|
const rq = query({
|
|
1170
1157
|
prompt: rlRetryGen,
|
|
1171
1158
|
options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
|
|
@@ -1260,10 +1247,9 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
1260
1247
|
closeCurrentStream();
|
|
1261
1248
|
const rlRetryEnv = this.buildQueryEnv(meta.projectPath, account);
|
|
1262
1249
|
const { generator: rlRetryGen, controller: rlRetryCtrl } = createMessageChannel();
|
|
1263
|
-
const
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
const retryOpts = { ...queryOptions, sessionId: undefined, resume: rlCanResume2 ? rlCurrentSdkId2 : undefined, env: rlRetryEnv };
|
|
1250
|
+
const rlHasHistory2 = (this.messageCount.get(sessionId) ?? 0) > 0;
|
|
1251
|
+
if (!rlHasHistory2) rlRetryCtrl.push(firstMsg);
|
|
1252
|
+
const retryOpts = { ...queryOptions, sessionId: undefined, resume: rlHasHistory2 ? getSdkSessionId(sessionId) : undefined, env: rlRetryEnv };
|
|
1267
1253
|
const rq = query({
|
|
1268
1254
|
prompt: rlRetryGen,
|
|
1269
1255
|
options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
|
|
@@ -1292,10 +1278,9 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
1292
1278
|
closeCurrentStream();
|
|
1293
1279
|
const retryEnv = this.buildQueryEnv(meta.projectPath, refreshedAccount);
|
|
1294
1280
|
const { generator: authRetryGen2, controller: authRetryCtrl2 } = createMessageChannel();
|
|
1295
|
-
const
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
const retryOpts = { ...queryOptions, sessionId: undefined, resume: authCanResume2 ? authCurrentSdkId2 : undefined, env: retryEnv };
|
|
1281
|
+
const authHasHistory2 = (this.messageCount.get(sessionId) ?? 0) > 0;
|
|
1282
|
+
if (!authHasHistory2) authRetryCtrl2.push(firstMsg);
|
|
1283
|
+
const retryOpts = { ...queryOptions, sessionId: undefined, resume: authHasHistory2 ? getSdkSessionId(sessionId) : undefined, env: retryEnv };
|
|
1299
1284
|
const rq = query({
|
|
1300
1285
|
prompt: authRetryGen2,
|
|
1301
1286
|
options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
|
package/src/server/index.ts
CHANGED
|
@@ -491,12 +491,15 @@ if (process.argv.includes("__serve__")) {
|
|
|
491
491
|
try {
|
|
492
492
|
const { resolve: r } = await import("node:path");
|
|
493
493
|
const { homedir: h } = await import("node:os");
|
|
494
|
-
const { readFileSync: rf, writeFileSync: wf } = await import("node:fs");
|
|
494
|
+
const { readFileSync: rf, writeFileSync: wf, renameSync: rn } = await import("node:fs");
|
|
495
495
|
const statusFile = r(h(), ".ppm", "status.json");
|
|
496
496
|
const status = JSON.parse(rf(statusFile, "utf-8"));
|
|
497
497
|
// Write running server version — source of truth for heartbeat
|
|
498
498
|
status.serverVersion = VERSION;
|
|
499
|
-
|
|
499
|
+
// Atomic write: tmp + rename to avoid cross-process partial-read races
|
|
500
|
+
const tmp = statusFile + ".tmp." + process.pid;
|
|
501
|
+
wf(tmp, JSON.stringify(status));
|
|
502
|
+
rn(tmp, statusFile);
|
|
500
503
|
if (status.shareUrl) {
|
|
501
504
|
const { tunnelService } = await import("../services/tunnel.service.ts");
|
|
502
505
|
if (status.tunnelPid) tunnelService.setExternalPid(status.tunnelPid);
|
package/src/server/ws/chat.ts
CHANGED
|
@@ -241,7 +241,8 @@ async function startSessionConsumer(sessionId: string, providerId: string, conte
|
|
|
241
241
|
}
|
|
242
242
|
|
|
243
243
|
// First content event — stop heartbeat, transition phase
|
|
244
|
-
|
|
244
|
+
// status_update is PPM's pre-flight account selection — not actual SDK content
|
|
245
|
+
const isMetadataEvent = evType === "account_info" || evType === "account_retry" || evType === "streaming_status" || evType === "status_update";
|
|
245
246
|
if (!firstEventReceived && !isMetadataEvent) {
|
|
246
247
|
firstEventReceived = true;
|
|
247
248
|
const waitMs = Date.now() - startTime;
|
|
@@ -204,6 +204,12 @@ function doConnect(): void {
|
|
|
204
204
|
reconnecting = false;
|
|
205
205
|
ws = null;
|
|
206
206
|
if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
|
|
207
|
+
// 4010 = server replaced this connection (another is already active) or reconnecting
|
|
208
|
+
// too fast — do NOT reconnect to avoid feedback loop
|
|
209
|
+
if (event.code === 4010) {
|
|
210
|
+
log("INFO", "Cloud WS closed with 4010 (replaced/throttled), not reconnecting");
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
207
213
|
if (shouldConnect) scheduleReconnect("onclose");
|
|
208
214
|
};
|
|
209
215
|
|
|
@@ -37,6 +37,14 @@ export function triggerResume(): void {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
// ─── Status file helpers ───────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/** Atomic write: write to tmp file then rename (prevents partial-read races across processes) */
|
|
42
|
+
function atomicWriteJson(filePath: string, data: unknown) {
|
|
43
|
+
const tmp = filePath + ".tmp." + process.pid;
|
|
44
|
+
writeFileSync(tmp, JSON.stringify(data));
|
|
45
|
+
renameSync(tmp, filePath);
|
|
46
|
+
}
|
|
47
|
+
|
|
40
48
|
export function readStatus(): Record<string, unknown> {
|
|
41
49
|
try {
|
|
42
50
|
if (existsSync(STATUS_FILE)) return JSON.parse(readFileSync(STATUS_FILE, "utf-8"));
|
|
@@ -47,7 +55,7 @@ export function readStatus(): Record<string, unknown> {
|
|
|
47
55
|
export function updateStatus(patch: Record<string, unknown>) {
|
|
48
56
|
try {
|
|
49
57
|
const data = { ...readStatus(), ...patch };
|
|
50
|
-
|
|
58
|
+
atomicWriteJson(STATUS_FILE, data);
|
|
51
59
|
} catch {}
|
|
52
60
|
}
|
|
53
61
|
|
|
@@ -27,8 +27,8 @@ const BACKOFF_MAX_MS = 60_000;
|
|
|
27
27
|
const STABLE_WINDOW_MS = 300_000; // 5min stable → reset restart counter
|
|
28
28
|
const SERVER_HEALTH_INTERVAL_MS = 30_000;
|
|
29
29
|
const SERVER_HEALTH_FAIL_THRESHOLD = 3;
|
|
30
|
-
const TUNNEL_PROBE_INTERVAL_MS =
|
|
31
|
-
const TUNNEL_PROBE_FAIL_THRESHOLD =
|
|
30
|
+
const TUNNEL_PROBE_INTERVAL_MS = 30_000; // 30s — adopted tunnels have no `exited` promise
|
|
31
|
+
const TUNNEL_PROBE_FAIL_THRESHOLD = 3; // 3 HTTP failures before regenerating (PID check is instant)
|
|
32
32
|
const TUNNEL_URL_REGEX = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
|
|
33
33
|
const UPGRADE_CHECK_INTERVAL_MS = 900_000; // 15min
|
|
34
34
|
const UPGRADE_SKIP_INITIAL_MS = 300_000; // 5min delay before first check
|
|
@@ -415,12 +415,25 @@ async function selfReplace(): Promise<{ success: boolean; error?: string }> {
|
|
|
415
415
|
// Set restarting flag so server child's stopTunnel() skips killing the tunnel
|
|
416
416
|
try { writeFileSync(RESTARTING_FLAG, ""); } catch {}
|
|
417
417
|
|
|
418
|
+
// Clear probe timer FIRST to prevent race between flush check and queued callback
|
|
419
|
+
if (tunnelProbeTimer) { clearInterval(tunnelProbeTimer); tunnelProbeTimer = null; }
|
|
420
|
+
|
|
421
|
+
// Final tunnel liveness check before handing off to new supervisor —
|
|
422
|
+
// if the adopted tunnel died since the last probe, clear status so the
|
|
423
|
+
// new supervisor spawns fresh instead of discovering ESRCH.
|
|
424
|
+
if (adoptedTunnelPid && !tunnelChild) {
|
|
425
|
+
try { process.kill(adoptedTunnelPid, 0); } catch {
|
|
426
|
+
log("WARN", "Pre-upgrade: adopted tunnel dead, clearing for new supervisor to spawn fresh");
|
|
427
|
+
adoptedTunnelPid = null;
|
|
428
|
+
tunnelUrl = null;
|
|
429
|
+
updateStatus({ shareUrl: null, tunnelPid: null });
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
418
433
|
// Kill server child to free the port; keep tunnel alive for domain continuity
|
|
419
434
|
log("INFO", "Stopping server before spawning new supervisor (tunnel kept alive)");
|
|
420
435
|
if (serverChild) { try { serverChild.kill(); } catch {} serverChild = null; }
|
|
421
|
-
// Clear health timers so we don't try to respawn killed children
|
|
422
436
|
if (healthTimer) { clearInterval(healthTimer); healthTimer = null; }
|
|
423
|
-
if (tunnelProbeTimer) { clearInterval(tunnelProbeTimer); tunnelProbeTimer = null; }
|
|
424
437
|
// Brief wait for port release
|
|
425
438
|
await Bun.sleep(500);
|
|
426
439
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Subprocess } from "bun";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
-
import { existsSync, unlinkSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { existsSync, unlinkSync, readFileSync, writeFileSync, renameSync } from "node:fs";
|
|
5
5
|
import { ensureCloudflared } from "./cloudflared.service.ts";
|
|
6
6
|
|
|
7
7
|
const TUNNEL_URL_REGEX = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
|
|
@@ -19,6 +19,8 @@ class TunnelService {
|
|
|
19
19
|
private externalPid: number | null = null;
|
|
20
20
|
private url: string | null = null;
|
|
21
21
|
private cleanupHandler: (() => void) | null = null;
|
|
22
|
+
/** True when tunnel is owned by the supervisor (adopted), not by this server process */
|
|
23
|
+
private supervisorManaged = false;
|
|
22
24
|
|
|
23
25
|
/** Spawn cloudflared Quick Tunnel and return public URL */
|
|
24
26
|
async startTunnel(port: number): Promise<string> {
|
|
@@ -81,12 +83,13 @@ class TunnelService {
|
|
|
81
83
|
});
|
|
82
84
|
|
|
83
85
|
this.url = url;
|
|
86
|
+
this.supervisorManaged = false; // this process owns the tunnel now
|
|
84
87
|
this.persistToStatusFile();
|
|
85
88
|
this.syncToCloud();
|
|
86
89
|
return url;
|
|
87
90
|
}
|
|
88
91
|
|
|
89
|
-
/** Kill the cloudflared child process (skipped during restart) */
|
|
92
|
+
/** Kill the cloudflared child process (skipped during restart or when supervisor-managed) */
|
|
90
93
|
stopTunnel(): void {
|
|
91
94
|
if (this.cleanupHandler) {
|
|
92
95
|
process.off("SIGINT", this.cleanupHandler);
|
|
@@ -101,16 +104,22 @@ class TunnelService {
|
|
|
101
104
|
this.url = null;
|
|
102
105
|
return;
|
|
103
106
|
}
|
|
107
|
+
// Only kill tunnels this process spawned; externally-managed tunnels (set via
|
|
108
|
+
// setExternalPid) belong to the supervisor — killing them causes silent URL resets
|
|
104
109
|
if (this.childProcess) {
|
|
105
110
|
try { this.childProcess.kill(); } catch {}
|
|
106
111
|
this.childProcess = null;
|
|
107
112
|
}
|
|
108
113
|
if (this.externalPid) {
|
|
109
|
-
|
|
114
|
+
// Don't kill — supervisor owns this process and monitors it via probe
|
|
110
115
|
this.externalPid = null;
|
|
111
116
|
}
|
|
112
117
|
this.url = null;
|
|
113
|
-
|
|
118
|
+
// Don't persist nulls to status.json when tunnel is supervisor-managed;
|
|
119
|
+
// the supervisor is the source of truth for tunnel state
|
|
120
|
+
if (!this.supervisorManaged) {
|
|
121
|
+
this.persistToStatusFile();
|
|
122
|
+
}
|
|
114
123
|
this.stopCloudSync();
|
|
115
124
|
}
|
|
116
125
|
|
|
@@ -131,12 +140,13 @@ class TunnelService {
|
|
|
131
140
|
this.syncToCloud();
|
|
132
141
|
}
|
|
133
142
|
|
|
134
|
-
/** Adopt an externally-started tunnel by PID (
|
|
143
|
+
/** Adopt an externally-started tunnel by PID (supervisor-managed, don't kill on exit) */
|
|
135
144
|
setExternalPid(pid: number): void {
|
|
136
145
|
this.externalPid = pid;
|
|
146
|
+
this.supervisorManaged = true;
|
|
137
147
|
}
|
|
138
148
|
|
|
139
|
-
/** Persist shareUrl + tunnelPid to status.json (
|
|
149
|
+
/** Persist shareUrl + tunnelPid to status.json (atomic write to avoid cross-process races) */
|
|
140
150
|
private persistToStatusFile(): void {
|
|
141
151
|
const statusFile = resolve(homedir(), ".ppm", "status.json");
|
|
142
152
|
if (!existsSync(statusFile)) return;
|
|
@@ -144,7 +154,9 @@ class TunnelService {
|
|
|
144
154
|
const data = JSON.parse(readFileSync(statusFile, "utf-8"));
|
|
145
155
|
data.shareUrl = this.url;
|
|
146
156
|
data.tunnelPid = this.getTunnelPid() ?? null;
|
|
147
|
-
|
|
157
|
+
const tmp = statusFile + ".tmp." + process.pid;
|
|
158
|
+
writeFileSync(tmp, JSON.stringify(data));
|
|
159
|
+
renameSync(tmp, statusFile);
|
|
148
160
|
} catch {}
|
|
149
161
|
}
|
|
150
162
|
|
package/src/types/config.ts
CHANGED
|
@@ -719,12 +719,20 @@ function InterleavedEvents({ events, isStreaming, projectName }: { events: ChatE
|
|
|
719
719
|
/** Collapsible thinking block — shows Claude's reasoning, collapsed by default when done */
|
|
720
720
|
function ThinkingBlock({ content, isStreaming }: { content: string; isStreaming: boolean }) {
|
|
721
721
|
const [expanded, setExpanded] = useState(isStreaming);
|
|
722
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
722
723
|
|
|
723
724
|
// Auto-collapse when streaming finishes
|
|
724
725
|
useEffect(() => {
|
|
725
726
|
if (!isStreaming && content.length > 0) setExpanded(false);
|
|
726
727
|
}, [isStreaming, content.length]);
|
|
727
728
|
|
|
729
|
+
// Auto-scroll to bottom during streaming
|
|
730
|
+
useEffect(() => {
|
|
731
|
+
if (isStreaming && expanded && scrollRef.current) {
|
|
732
|
+
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
733
|
+
}
|
|
734
|
+
}, [content, isStreaming, expanded]);
|
|
735
|
+
|
|
728
736
|
return (
|
|
729
737
|
<div className="rounded border border-border/50 bg-surface/30 text-xs">
|
|
730
738
|
<button
|
|
@@ -736,11 +744,11 @@ function ThinkingBlock({ content, isStreaming }: { content: string; isStreaming:
|
|
|
736
744
|
{!isStreaming && <span className="text-text-subtle/50 ml-auto">{content.length > 100 ? `${Math.round(content.length / 4)} tokens` : ""}</span>}
|
|
737
745
|
</button>
|
|
738
746
|
{expanded && (
|
|
739
|
-
<
|
|
740
|
-
<
|
|
747
|
+
<div ref={scrollRef} className="max-h-60 overflow-y-auto">
|
|
748
|
+
<div className="px-2 pb-2 text-text-subtle/80 whitespace-pre-wrap text-[11px] leading-relaxed">
|
|
741
749
|
{content}
|
|
742
|
-
</
|
|
743
|
-
</
|
|
750
|
+
</div>
|
|
751
|
+
</div>
|
|
744
752
|
)}
|
|
745
753
|
</div>
|
|
746
754
|
);
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import"./react-nm2Ru1Pt.js";import"./api-client-BfBM3I7n.js";import{G as e}from"./index-_YxJ4vi1.js";export{e as useKeybindingsStore};
|