@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.
Files changed (33) hide show
  1. package/AGENTS.md +80 -0
  2. package/CHANGELOG.md +10 -0
  3. package/dist/web/assets/{chat-tab-jKL8IW3Y.js → chat-tab-B3gpx-qv.js} +2 -2
  4. package/dist/web/assets/{code-editor-CR8ddAzL.js → code-editor-kyaXcsZW.js} +1 -1
  5. package/dist/web/assets/{database-viewer-B9mqA0gn.js → database-viewer-DmAux3OF.js} +1 -1
  6. package/dist/web/assets/{diff-viewer-BHJnCxdy.js → diff-viewer-Cikon1YK.js} +1 -1
  7. package/dist/web/assets/{extension-webview-qOmDNW1k.js → extension-webview-DVvC7SQ-.js} +1 -1
  8. package/dist/web/assets/{git-graph-C_JG3CzH.js → git-graph-Bon2J1_A.js} +1 -1
  9. package/dist/web/assets/{index-_YxJ4vi1.js → index-Buc4QA5O.js} +3 -3
  10. package/dist/web/assets/keybindings-store-CT_EvCrb.js +1 -0
  11. package/dist/web/assets/{markdown-renderer-_rUM4Qox.js → markdown-renderer-ttL1fRGG.js} +1 -1
  12. package/dist/web/assets/{port-forwarding-tab-BR3UIrwb.js → port-forwarding-tab-Bljq2XEH.js} +1 -1
  13. package/dist/web/assets/{postgres-viewer-B9lLK97W.js → postgres-viewer-CqburCkJ.js} +1 -1
  14. package/dist/web/assets/{settings-tab-BAQdizup.js → settings-tab-CQVn8u_D.js} +1 -1
  15. package/dist/web/assets/{sql-query-editor-DZK6zX5t.js → sql-query-editor-DP6Kh2R8.js} +1 -1
  16. package/dist/web/assets/{sqlite-viewer-vLFjca8E.js → sqlite-viewer-CrqzbhyF.js} +1 -1
  17. package/dist/web/assets/{terminal-tab-B1mmTE_I.js → terminal-tab-BmBB838x.js} +1 -1
  18. package/dist/web/assets/{use-monaco-theme-B21s-870.js → use-monaco-theme-ZmSrfclJ.js} +1 -1
  19. package/dist/web/index.html +1 -1
  20. package/dist/web/sw.js +1 -1
  21. package/output/pdf/ppm-app-summary.pdf +80 -0
  22. package/package.json +1 -1
  23. package/src/cli/commands/restart.ts +5 -3
  24. package/src/providers/claude-agent-sdk.ts +21 -36
  25. package/src/server/index.ts +5 -2
  26. package/src/server/ws/chat.ts +2 -1
  27. package/src/services/cloud-ws.service.ts +6 -0
  28. package/src/services/supervisor-state.ts +9 -1
  29. package/src/services/supervisor.ts +17 -4
  30. package/src/services/tunnel.service.ts +19 -7
  31. package/src/types/config.ts +1 -1
  32. package/src/web/components/chat/message-list.tsx +12 -4
  33. 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
  {
2
2
  "name": "@hienlh/ppm",
3
- "version": "0.9.75",
3
+ "version": "0.9.77",
4
4
  "description": "Personal Project Manager — mobile-first web IDE with AI assistance",
5
5
  "author": "hienlh",
6
6
  "license": "MIT",
@@ -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
- writeFileSync(P.statusFile, JSON.stringify(status));
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
- // Check if token needs refresh
692
- const isOAuth = account.accessToken.startsWith("sk-ant-oat");
693
- const needsRefresh = isOAuth && account.expiresAt && (account.expiresAt - nowS <= 3600);
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 currentSdkId = getSessionMapping(sessionId);
933
- const canResume = !!currentSdkId;
934
- if (!canResume) earlyAuthCtrl.push(firstMsg);
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 currentSdkId = getSessionMapping(sessionId);
960
- const canResume = !!currentSdkId;
961
- if (!canResume) switchCtrl.push(firstMsg);
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 currentSdkId = getSessionMapping(sessionId);
1112
- const canResume = !!currentSdkId;
1113
- if (!canResume) authRetryCtrl.push(firstMsg);
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 rlCurrentSdkId = getSessionMapping(sessionId);
1166
- const rlCanResume = !!rlCurrentSdkId;
1167
- if (!rlCanResume) rlRetryCtrl.push(firstMsg);
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 rlCurrentSdkId2 = getSessionMapping(sessionId);
1264
- const rlCanResume2 = !!rlCurrentSdkId2;
1265
- if (!rlCanResume2) rlRetryCtrl.push(firstMsg);
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 authCurrentSdkId2 = getSessionMapping(sessionId);
1296
- const authCanResume2 = !!authCurrentSdkId2;
1297
- if (!authCanResume2) authRetryCtrl2.push(firstMsg);
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,
@@ -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
- wf(statusFile, JSON.stringify(status));
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);
@@ -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
- const isMetadataEvent = evType === "account_info" || evType === "account_retry" || evType === "streaming_status";
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
- writeFileSync(STATUS_FILE, JSON.stringify(data));
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 = 120_000;
31
- const TUNNEL_PROBE_FAIL_THRESHOLD = 2;
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
- try { process.kill(this.externalPid); } catch {}
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
- this.persistToStatusFile();
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 (for stop management after restart) */
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 (central write point) */
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
- writeFileSync(statusFile, JSON.stringify(data));
157
+ const tmp = statusFile + ".tmp." + process.pid;
158
+ writeFileSync(tmp, JSON.stringify(data));
159
+ renameSync(tmp, statusFile);
148
160
  } catch {}
149
161
  }
150
162
 
@@ -90,7 +90,7 @@ export const DEFAULT_CONFIG: PpmConfig = {
90
90
  api_key_env: "ANTHROPIC_API_KEY",
91
91
  model: "claude-sonnet-4-6",
92
92
  effort: "high",
93
- max_turns: 100,
93
+ max_turns: 1000,
94
94
  permission_mode: "bypassPermissions",
95
95
  },
96
96
  },
@@ -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
- <StickToBottom className="max-h-60 overflow-y-auto" resize="smooth" initial="instant">
740
- <StickToBottom.Content className="px-2 pb-2 text-text-subtle/80 whitespace-pre-wrap text-[11px] leading-relaxed">
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
- </StickToBottom.Content>
743
- </StickToBottom>
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};