@hienlh/ppm 0.17.48 → 0.17.50

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 (49) hide show
  1. package/CHANGELOG.md +3668 -3657
  2. package/assets/skills/ppm/SKILL.md +1 -1
  3. package/assets/skills/ppm/references/http-api.md +1 -1
  4. package/dist/web/assets/{ai-resource-editor-DxM8JFQ0.js → ai-resource-editor-x8J0omsj.js} +1 -1
  5. package/dist/web/assets/{audio-preview-DE1sEUmx.js → audio-preview-Bdf_SZHY.js} +1 -1
  6. package/dist/web/assets/chat-tab-CT6SP5D3.js +13 -0
  7. package/dist/web/assets/{code-editor-DjconF_F.js → code-editor-BgsJkCtK.js} +2 -2
  8. package/dist/web/assets/{conflict-editor-CLfc44RC.js → conflict-editor-DdSe9RCG.js} +1 -1
  9. package/dist/web/assets/{csv-preview-DxD081uz.js → csv-preview-DrUfLPRE.js} +1 -1
  10. package/dist/web/assets/{database-viewer-Du1HOmex.js → database-viewer-1NLuRknj.js} +1 -1
  11. package/dist/web/assets/{diff-viewer-CeznVBNn.js → diff-viewer-DPn8QJXG.js} +1 -1
  12. package/dist/web/assets/{docx-preview-DYbOm4bo.js → docx-preview-BUVtMDQF.js} +1 -1
  13. package/dist/web/assets/{extension-webview-BYBwAaZv.js → extension-webview-CEdJP2XP.js} +1 -1
  14. package/dist/web/assets/{git-log-panel-CgUkbt6i.js → git-log-panel-ClLf6-as.js} +1 -1
  15. package/dist/web/assets/{glide-data-grid-CBLhaW5g.js → glide-data-grid-CBhv99Ii.js} +1 -1
  16. package/dist/web/assets/{group-chat-tab-5EKPaL_r.js → group-chat-tab-KTKtMy6L.js} +1 -1
  17. package/dist/web/assets/{image-preview-DjqmBn2G.js → image-preview-DYyo-Twv.js} +1 -1
  18. package/dist/web/assets/{index-nsuPdCaW.js → index-BiBAhdYZ.js} +3 -3
  19. package/dist/web/assets/{keybindings-store-DTJlq-2C.js → keybindings-store-C1yAVIlX.js} +1 -1
  20. package/dist/web/assets/{markdown-renderer-BqthPP7U.js → markdown-renderer-CCBPLJC7.js} +1 -1
  21. package/dist/web/assets/{markdown-renderer-CrZWFsD-.js → markdown-renderer-JNQd7xG0.js} +1 -1
  22. package/dist/web/assets/{notification-store-ClZEc-lT.js → notification-store-lQg17iIv.js} +1 -1
  23. package/dist/web/assets/{pdf-preview-BZY4PbMF.js → pdf-preview-CWnXgjqc.js} +1 -1
  24. package/dist/web/assets/{postgres-viewer-DzpIkZtk.js → postgres-viewer-BIXfNiGy.js} +1 -1
  25. package/dist/web/assets/{settings-tab-o3KSdtxR.js → settings-tab-CpltHPv5.js} +1 -1
  26. package/dist/web/assets/{sql-query-editor-COXQHj5D.js → sql-query-editor-BDTzbvEM.js} +1 -1
  27. package/dist/web/assets/{sqlite-viewer-Dew5JH18.js → sqlite-viewer-BkOrBWZP.js} +1 -1
  28. package/dist/web/assets/{system-monitor-tab-CwGbHdi_.js → system-monitor-tab-s0Jm9zah.js} +1 -1
  29. package/dist/web/assets/{terminal-tab-DqdmayEL.js → terminal-tab-D3S_kz4Y.js} +1 -1
  30. package/dist/web/assets/{tool-cards-CNuiAf2X.js → tool-cards-DbhWrJ_o.js} +2 -2
  31. package/dist/web/assets/{use-monaco-theme-uvD7nRfD.js → use-monaco-theme-DKGvvHk1.js} +1 -1
  32. package/dist/web/assets/{video-preview-BDpCb2Pd.js → video-preview-BCU0PfT1.js} +1 -1
  33. package/dist/web/index.html +1 -1
  34. package/dist/web/sw.js +1 -1
  35. package/docs/lessons-learned.md +69 -0
  36. package/docs/system-architecture.md +14 -3
  37. package/package.json +106 -106
  38. package/src/cli/commands/restart.ts +39 -23
  39. package/src/cli/commands/stop.ts +5 -0
  40. package/src/index.ts +7 -1
  41. package/src/providers/claude-agent-sdk.ts +5 -6
  42. package/src/server/index.ts +14 -1
  43. package/src/services/edge-forwarder.ts +191 -0
  44. package/src/services/edge-target-resolver.ts +63 -0
  45. package/src/services/jsonl-transcript-parser.ts +45 -3
  46. package/src/services/supervisor-stopped-page.ts +20 -3
  47. package/src/services/supervisor.ts +248 -61
  48. package/src/web/hooks/use-chat.ts +45 -1
  49. package/dist/web/assets/chat-tab-BKZAcGU7.js +0 -13
@@ -177,6 +177,12 @@ export async function restartServer(options: { force?: boolean }) {
177
177
  // terminal (and its process group) to receive SIGHUP.
178
178
  const params = JSON.stringify({
179
179
  serverPid, port, host, serverScript,
180
+ // The server's own loopback port, distinct from `port` (the PUBLIC port,
181
+ // owned by the edge forwarder). The worker must only ever reclaim this one:
182
+ // force-killing whatever listens on the public port would kill the edge,
183
+ // which is detached and legitimately still running even with no supervisor.
184
+ serverPort: (status.serverPort as number | undefined) ?? null,
185
+ serverPortFile: resolve(getPpmDir(), ".server-port"),
180
186
  statusFile: statusFile(),
181
187
  pidFile: pidFile(),
182
188
  restartingFlag: restartingFlag(),
@@ -210,16 +216,20 @@ async function main() {
210
216
  try { process.kill(P.serverPid); log("INFO", "Restart: killed old server PID " + P.serverPid); } catch {}
211
217
  await Bun.sleep(500);
212
218
 
213
- // Force-kill any process still holding the port (handles orphan/zombie processes)
219
+ // Force-kill anything still holding the OLD SERVER's loopback port (orphaned
220
+ // grandchildren keep its inherited socket open). Never P.port: that is the
221
+ // public port and the edge forwarder is listening there — it is detached, so
222
+ // it is alive and correct even when no supervisor is.
214
223
  const killByPort = () => {
224
+ if (!P.serverPort) return;
215
225
  try {
216
226
  if (process.platform === "win32") {
217
- const r = Bun.spawnSync(["cmd", "/c", "netstat -ano | findstr :" + P.port + " | findstr LISTENING"]);
227
+ const r = Bun.spawnSync(["cmd", "/c", "netstat -ano | findstr :" + P.serverPort + " | findstr LISTENING"]);
218
228
  const lines = r.stdout.toString().trim().split("\\n");
219
229
  const pids = new Set(lines.map((l: string) => l.trim().split(/\\s+/).pop()).filter(Boolean));
220
230
  for (const pid of pids) { try { process.kill(Number(pid)); } catch {} }
221
231
  } else {
222
- const r = Bun.spawnSync(["lsof", "-t", "-i", ":" + P.port]);
232
+ const r = Bun.spawnSync(["lsof", "-t", "-i", ":" + P.serverPort]);
223
233
  const pids = r.stdout.toString().trim().split("\\n").filter(Boolean);
224
234
  for (const pid of pids) { try { process.kill(Number(pid)); } catch {} }
225
235
  }
@@ -227,28 +237,21 @@ async function main() {
227
237
  };
228
238
  killByPort();
229
239
 
230
- // Wait for port to be free (up to 5s)
231
- const start = Date.now();
232
- while (Date.now() - start < 5000) {
233
- const inUse: boolean = await new Promise((res) => {
234
- const t = createServer()
235
- .once("error", () => res(true))
236
- .once("listening", () => { t.close(() => res(false)); })
237
- .listen(P.port, P.host);
238
- });
239
- if (!inUse) break;
240
- killByPort();
241
- await Bun.sleep(200);
242
- }
240
+ // No wait-for-free loop: the replacement server asks for port 0, so a wedged
241
+ // old port cannot block it. That loop existed only because the server used to
242
+ // need one specific port back.
243
+ try { unlinkSync(P.serverPortFile); } catch {}
243
244
 
244
245
  // Spawn new server — on Windows use PowerShell Start-Process for true detach
245
246
  // (Bun.spawn + unref on Windows keeps child in same job object → dies when worker exits)
246
247
  let childPid: number;
247
248
  // Compiled binary: execPath IS the server, no "run script" needed
248
249
  const isCompiled = !process.execPath.includes("bun");
250
+ // Port 0 / loopback, matching how the supervisor spawns it: the edge owns the
251
+ // public port and forwards to whatever the server ends up binding.
249
252
  const serverArgs = isCompiled
250
- ? ["__serve__", String(P.port), P.host]
251
- : ["run", P.serverScript, "__serve__", String(P.port), P.host];
253
+ ? ["__serve__", "0", "127.0.0.1"]
254
+ : ["run", P.serverScript, "__serve__", "0", "127.0.0.1"];
252
255
 
253
256
  if (process.platform === "win32") {
254
257
  const bunExe = process.execPath.replace(/\\\\/g, "\\\\\\\\");
@@ -293,16 +296,29 @@ async function main() {
293
296
  // Remove restarting flag
294
297
  try { unlinkSync(P.restartingFlag); } catch {}
295
298
 
296
- // Health check (up to 10s)
299
+ // Health check (up to 10s). Probe the PUBLIC port first — that is the whole
300
+ // chain (edge → server) and what a user actually hits. Fall back to the
301
+ // server's own loopback port so a dead edge is reported as "server up, public
302
+ // access down" instead of a blanket restart failure.
297
303
  let ready = false;
304
+ let edgeUp = false;
305
+ const probe = async (p: number) => {
306
+ try {
307
+ const res = await fetch("http://127.0.0.1:" + p + "/api/health", { signal: AbortSignal.timeout(1000) });
308
+ return res.ok;
309
+ } catch { return false; }
310
+ };
298
311
  const hStart = Date.now();
299
312
  while (Date.now() - hStart < 10000) {
300
- try {
301
- const res = await fetch("http://127.0.0.1:" + P.port + "/api/health", { signal: AbortSignal.timeout(1000) });
302
- if (res.ok) { ready = true; break; }
303
- } catch {}
313
+ if (await probe(P.port)) { ready = true; edgeUp = true; break; }
314
+ let direct = 0;
315
+ try { direct = parseInt(readFileSync(P.serverPortFile, "utf-8").trim(), 10); } catch {}
316
+ if (direct > 0 && await probe(direct)) { ready = true; break; }
304
317
  await Bun.sleep(300);
305
318
  }
319
+ if (ready && !edgeUp) {
320
+ log("WARN", "Server is up but the public port is not being served — edge forwarder is down. Run 'ppm stop --kill' then 'ppm start'.");
321
+ }
306
322
 
307
323
  // Check tunnel
308
324
  let tunnelAlive = false;
@@ -81,6 +81,11 @@ export async function stopServer(options?: { all?: boolean; kill?: boolean }) {
81
81
  if (data.supervisorPid) { killPid(data.supervisorPid, "supervisor"); killed++; }
82
82
  if (data.pid) { killPid(data.pid, "server"); killed++; }
83
83
  if (data.tunnelPid) { killPid(data.tunnelPid, "tunnel"); killed++; }
84
+ // The edge forwarder is spawned detached so it survives an upgrade's
85
+ // self-replace — which means killing the supervisor does NOT take it
86
+ // down. Left running it keeps holding the public port and the next
87
+ // `ppm start` collides with it.
88
+ if (data.edgePid) { killPid(data.edgePid, "edge"); killed++; }
84
89
  } catch {}
85
90
  }
86
91
  if (existsSync(pidFile())) {
package/src/index.ts CHANGED
@@ -179,9 +179,12 @@ export async function buildProgram(): Promise<Command> {
179
179
  * which would reject them as unknown commands. Source installs spawn the
180
180
  * `.ts` files directly and never reach this entry with a sentinel.
181
181
  */
182
- export function resolveEntryMode(argv: string[]): "supervise" | "serve" | "cli" {
182
+ export function resolveEntryMode(
183
+ argv: string[],
184
+ ): "supervise" | "serve" | "edge" | "cli" {
183
185
  if (argv.includes("__supervise__")) return "supervise";
184
186
  if (argv.includes("__serve__")) return "serve";
187
+ if (argv.includes("__edge__")) return "edge";
185
188
  return "cli";
186
189
  }
187
190
 
@@ -193,6 +196,9 @@ if (import.meta.main) {
193
196
  case "serve":
194
197
  await import("./server/index.ts");
195
198
  break;
199
+ case "edge":
200
+ await import("./services/edge-forwarder.ts");
201
+ break;
196
202
  default: {
197
203
  const program = await buildProgram();
198
204
  program.parse();
@@ -23,7 +23,7 @@ import { getSessionProjectPath, setSessionMetadata, getSessionTitles, insertTurn
23
23
  import { buildTurnUsage, formatTurnUsageLog } from "../shared/turn-usage.ts";
24
24
  import { accountSelector } from "../services/account-selector.service.ts";
25
25
  import { accountService, type AccountWithTokens } from "../services/account.service.ts";
26
- import { parseSessionMessage, nestChildEvents } from "../services/jsonl-transcript-parser.ts";
26
+ import { parseSessionMessage, nestChildEventsAcrossMessages } from "../services/jsonl-transcript-parser.ts";
27
27
  import { stringifyToolResultContent } from "../shared/tool-result-content.ts";
28
28
  import { isCompiledBinary } from "../services/autostart-generator.ts";
29
29
  import { resolveClaudeCliPath } from "../services/claude-cli-resolver.ts";
@@ -2080,11 +2080,10 @@ export class ClaudeAgentSdkProvider implements AIProvider {
2080
2080
  merged.push(msg);
2081
2081
  }
2082
2082
 
2083
- // Nest child events under their parent Agent/Task tool_use's children array
2084
- for (const msg of merged) {
2085
- if (!msg.events) continue;
2086
- nestChildEvents(msg.events);
2087
- }
2083
+ // Nest child events under their parent Agent/Task tool_use's children array.
2084
+ // Cross-message: a backgrounded subagent's events land in later messages
2085
+ // than the Agent tool_use that spawned it.
2086
+ nestChildEventsAcrossMessages(merged);
2088
2087
 
2089
2088
  return merged.filter(
2090
2089
  (msg) => msg.content.trim().length > 0 || (msg.events && msg.events.length > 0),
@@ -1,5 +1,7 @@
1
1
  import { Hono } from "hono";
2
2
  import { cors } from "hono/cors";
3
+ import { writeFileSync } from "node:fs";
4
+ import { SERVER_PORT_FILE } from "../services/edge-target-resolver.ts";
3
5
  import { configService } from "../services/config.service.ts";
4
6
  import { VERSION } from "../version.ts";
5
7
  import { authMiddleware } from "./middleware/auth.ts";
@@ -940,5 +942,16 @@ if (process.argv.includes("__serve__")) {
940
942
  }, 200);
941
943
  }
942
944
 
943
- console.log(`Server child ready on port ${port}`);
945
+ // Publish the port we actually bound. With `port: 0` the OS picks it, so this
946
+ // file is the only way anything else can find the server — the edge forwarder
947
+ // reads it to know where to send traffic, and the supervisor mirrors it into
948
+ // status.json. The server is the single writer; see edge-target-resolver.ts
949
+ // for why this is not status.json.
950
+ try {
951
+ writeFileSync(SERVER_PORT_FILE(), String(server.port));
952
+ } catch (e) {
953
+ console.error(`[serve] Failed to publish server port: ${e}`);
954
+ }
955
+
956
+ console.log(`Server child ready on port ${server.port}`);
944
957
  }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Edge forwarder — owns the public port and pipes raw TCP to the server's
3
+ * current loopback port.
4
+ *
5
+ * ─── CRITICAL INVARIANT: THIS PROCESS MUST NEVER SPAWN A CHILD ───
6
+ *
7
+ * On Windows a child spawned with fd stdio inherits every inheritable handle,
8
+ * including a listening socket. When such a descendant is orphaned it keeps the
9
+ * socket open, so the port stays in LISTEN under a dead PID and can never be
10
+ * rebound — a "zombie port". The server hits this constantly because it spawns
11
+ * chat/tool/MCP subprocesses; those inherit its listener and wedge its port.
12
+ *
13
+ * The edge exists precisely because it spawns nothing, so its socket cannot be
14
+ * inherited and its port cannot zombie. cloudflared can therefore stay pinned
15
+ * to it forever and the public URL stops rotating. Adding any child-process
16
+ * call to this file reintroduces the exact bug the edge was built to remove.
17
+ *
18
+ * Raw TCP, never HTTP: WebSocket upgrades and SSE streams pass through
19
+ * untouched, and forwarded requests keep the headers cloudflared set.
20
+ */
21
+ import net from "node:net";
22
+ import { resolveTargetPort, _resetTargetCache } from "./edge-target-resolver.ts";
23
+
24
+ /** Delay between attempts while waiting for the server to come back. */
25
+ const CONNECT_RETRY_MS = 250;
26
+ /**
27
+ * How long a client connection waits for a reachable server before giving up.
28
+ * During an upgrade the server is briefly absent; refusing instantly would
29
+ * surface as a broken page rather than a slow one.
30
+ */
31
+ const CONNECT_WINDOW_MS = 5000;
32
+
33
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
34
+
35
+ /** Resolve once the socket is connected; reject on the first connect error. */
36
+ function connectUpstream(port: number): Promise<net.Socket> {
37
+ return new Promise((resolvePromise, reject) => {
38
+ const upstream = net.connect({
39
+ port,
40
+ host: "127.0.0.1", // never taken from status.json — loopback only
41
+ allowHalfOpen: true,
42
+ });
43
+ const onError = (err: Error) => {
44
+ upstream.destroy();
45
+ reject(err);
46
+ };
47
+ upstream.once("error", onError);
48
+ upstream.once("connect", () => {
49
+ upstream.removeListener("error", onError);
50
+ resolvePromise(upstream);
51
+ });
52
+ });
53
+ }
54
+
55
+ /**
56
+ * Largest amount of client data held while waiting for the server. Bounds the
57
+ * memory a slow upload can pin during an upgrade window.
58
+ */
59
+ const MAX_PENDING_BYTES = 8 * 1024 * 1024;
60
+
61
+ /**
62
+ * Pipe one client connection to the server.
63
+ *
64
+ * Resolving the upstream is async, so the first bytes usually arrive before
65
+ * there is anywhere to send them. They must be captured by a `data` listener
66
+ * attached synchronously on this very tick and replayed once the upstream
67
+ * exists.
68
+ *
69
+ * `socket.pause()` is NOT a substitute — verified against Bun 1.3.13: bytes
70
+ * that arrive while paused are lost, and a later `pipe()`/`resume()` does not
71
+ * bring them back. A socket with no `data` listener drops them outright too.
72
+ * Both variants silently swallowed the first request in testing.
73
+ */
74
+ async function handleConnection(
75
+ client: net.Socket,
76
+ connectWindowMs: number,
77
+ ): Promise<void> {
78
+ const pending: Buffer[] = [];
79
+ let pendingBytes = 0;
80
+ let clientEnded = false;
81
+ const onEarlyData = (chunk: Buffer) => {
82
+ pendingBytes += chunk.length;
83
+ if (pendingBytes > MAX_PENDING_BYTES) {
84
+ client.destroy();
85
+ return;
86
+ }
87
+ pending.push(chunk);
88
+ };
89
+ client.on("data", onEarlyData);
90
+ client.once("end", () => { clientEnded = true; });
91
+
92
+ let upstream: net.Socket | null = null;
93
+ let clientGone = false;
94
+
95
+ // A socket error with no listener is an unhandled 'error' event, which would
96
+ // take the whole edge process — and the public port — down with it.
97
+ const teardown = () => {
98
+ upstream?.destroy();
99
+ client.destroy();
100
+ };
101
+ client.on("error", teardown);
102
+ client.once("close", () => { clientGone = true; });
103
+
104
+ const deadline = Date.now() + connectWindowMs;
105
+ while (!clientGone && Date.now() < deadline) {
106
+ const port = resolveTargetPort();
107
+ if (port !== null) {
108
+ try {
109
+ upstream = await connectUpstream(port);
110
+ break;
111
+ } catch {
112
+ // Server not up yet (or moved mid-restart) — re-resolve and retry.
113
+ _resetTargetCache();
114
+ }
115
+ }
116
+ await sleep(CONNECT_RETRY_MS);
117
+ }
118
+
119
+ if (clientGone) return;
120
+ if (!upstream) {
121
+ client.destroy();
122
+ return;
123
+ }
124
+
125
+ upstream.on("error", teardown);
126
+
127
+ // Replay what arrived during the connect, then hand both directions to the
128
+ // stream machinery. Removing the listener and piping happen in the same tick,
129
+ // so no chunk can slip through the gap.
130
+ client.off("data", onEarlyData);
131
+ for (const chunk of pending) upstream.write(chunk);
132
+ pending.length = 0;
133
+
134
+ client.pipe(upstream);
135
+ upstream.pipe(client);
136
+
137
+ // The client may have half-closed while we were still connecting.
138
+ if (clientEnded) upstream.end();
139
+ }
140
+
141
+ export interface EdgeForwarderOptions {
142
+ publicPort: number;
143
+ host?: string;
144
+ /** How long a client waits for a reachable server. Defaults to 5s. */
145
+ connectWindowMs?: number;
146
+ }
147
+
148
+ /**
149
+ * Bind the public port and start forwarding. Resolves once listening so the
150
+ * caller can fail fast when the port is unavailable.
151
+ */
152
+ export function startEdgeForwarder(
153
+ opts: EdgeForwarderOptions,
154
+ ): Promise<net.Server> {
155
+ const host = opts.host ?? "0.0.0.0";
156
+ const connectWindowMs = opts.connectWindowMs ?? CONNECT_WINDOW_MS;
157
+ return new Promise((resolvePromise, reject) => {
158
+ // allowHalfOpen keeps the other direction alive when one side sends FIN —
159
+ // long-lived chat WS and SSE streams depend on it.
160
+ const server = net.createServer({ allowHalfOpen: true }, (client) => {
161
+ void handleConnection(client, connectWindowMs);
162
+ });
163
+ server.once("error", reject);
164
+ server.listen(opts.publicPort, host, () => {
165
+ server.removeListener("error", reject);
166
+ resolvePromise(server);
167
+ });
168
+ });
169
+ }
170
+
171
+ // ─── Process entry ─────────────────────────────────────────────────────
172
+ // Mirrors the `__serve__` guard in src/server/index.ts: the supervisor
173
+ // re-invokes the binary as `<bin> __edge__ <publicPort> <host>`.
174
+ if (process.argv.includes("__edge__")) {
175
+ const idx = process.argv.indexOf("__edge__");
176
+ const publicPort = parseInt(process.argv[idx + 1] ?? "", 10);
177
+ const host = process.argv[idx + 2] ?? "0.0.0.0";
178
+
179
+ if (!Number.isInteger(publicPort) || publicPort <= 0 || publicPort > 65535) {
180
+ process.stderr.write(`[edge] Invalid public port: ${process.argv[idx + 1]}\n`);
181
+ process.exit(2);
182
+ }
183
+
184
+ try {
185
+ await startEdgeForwarder({ publicPort, host });
186
+ process.stderr.write(`[edge] Listening on ${host}:${publicPort}\n`);
187
+ } catch (err) {
188
+ process.stderr.write(`[edge] Failed to bind ${host}:${publicPort}: ${err}\n`);
189
+ process.exit(1);
190
+ }
191
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Resolves which loopback port the edge forwarder should send traffic to.
3
+ *
4
+ * Split out of `edge-forwarder.ts` so the forwarder file stays about piping
5
+ * bytes and this one stays about trusting (or not trusting) the port it is told.
6
+ *
7
+ * The source of truth is `~/.ppm/.server-port`, written by the server itself
8
+ * the moment it binds — NOT `status.json`. Two reasons:
9
+ * 1. `writeStatus` replaces status.json wholesale at supervisor startup, so a
10
+ * supervisor restart would blank the target out from under a live edge.
11
+ * 2. The edge is deliberately detached and outlives the supervisor; it must
12
+ * not depend on the supervisor being alive to know where the server is.
13
+ * The supervisor mirrors the value into `status.json.serverPort` for `ppm
14
+ * status`, but that copy is observability only.
15
+ */
16
+ import { readFileSync, existsSync } from "node:fs";
17
+ import { resolve } from "node:path";
18
+ import { getPpmDir } from "./ppm-dir.ts";
19
+
20
+ /** File the server writes its bound port to. Single writer: the server. */
21
+ export const SERVER_PORT_FILE = () => resolve(getPpmDir(), ".server-port");
22
+
23
+ /** How long a resolved target port is trusted before re-reading from disk. */
24
+ const TARGET_CACHE_MS = 1000;
25
+
26
+ let cachedPort: number | null = null;
27
+ let cachedAt = 0;
28
+
29
+ /**
30
+ * Current server port, or null when it is absent/invalid.
31
+ *
32
+ * The value is validated rather than trusted: anything able to write the file
33
+ * would otherwise be able to redirect all tunnel traffic. Only a plausible port
34
+ * number is accepted, and callers must always dial loopback.
35
+ *
36
+ * Only successful reads are cached — caching a miss would stretch the
37
+ * forwarder's retry loop to one attempt per second during startup, which is
38
+ * exactly when the server is still coming up.
39
+ */
40
+ export function resolveTargetPort(now: number = Date.now()): number | null {
41
+ if (cachedPort !== null && now - cachedAt < TARGET_CACHE_MS) return cachedPort;
42
+
43
+ let raw: number;
44
+ try {
45
+ const file = SERVER_PORT_FILE();
46
+ if (!existsSync(file)) return null;
47
+ raw = parseInt(readFileSync(file, "utf-8").trim(), 10);
48
+ } catch {
49
+ return null;
50
+ }
51
+
52
+ if (!Number.isInteger(raw) || raw <= 0 || raw > 65535) return null;
53
+
54
+ cachedPort = raw;
55
+ cachedAt = now;
56
+ return cachedPort;
57
+ }
58
+
59
+ /** Drop the memoized target port (tests, and after a known server restart). */
60
+ export function _resetTargetCache(): void {
61
+ cachedPort = null;
62
+ cachedAt = 0;
63
+ }
@@ -154,6 +154,48 @@ export function nestChildEvents(events: ChatEvent[]): void {
154
154
  }
155
155
  }
156
156
 
157
+ /**
158
+ * Nest child events across message boundaries. A backgrounded subagent keeps
159
+ * running after its turn ends, so its events land in later messages than the
160
+ * Agent/Task tool_use that spawned it. Collects parents globally, moves each
161
+ * child event into its parent's children array, and blanks out messages left
162
+ * with nothing but moved child content (callers filter empty messages).
163
+ * Mutates messages in-place.
164
+ */
165
+ export function nestChildEventsAcrossMessages(messages: { content: string; events?: ChatEvent[] }[]): void {
166
+ const parentMap = new Map<string, ChatEvent & { type: "tool_use" }>();
167
+ for (const msg of messages) {
168
+ for (const ev of msg.events ?? []) {
169
+ if (ev.type === "tool_use" && (ev.tool === "Agent" || ev.tool === "Task") && ev.toolUseId) {
170
+ parentMap.set(ev.toolUseId, ev);
171
+ }
172
+ }
173
+ }
174
+ if (parentMap.size === 0) return;
175
+
176
+ for (const msg of messages) {
177
+ if (!msg.events?.length) continue;
178
+ const kept: ChatEvent[] = [];
179
+ let moved = 0;
180
+ for (const ev of msg.events) {
181
+ const pid = (ev as any).parentToolUseId as string | undefined;
182
+ const parent = pid ? parentMap.get(pid) : undefined;
183
+ if (parent && parent !== ev) {
184
+ if (!parent.children) parent.children = [];
185
+ parent.children.push(ev);
186
+ moved++;
187
+ } else {
188
+ kept.push(ev);
189
+ }
190
+ }
191
+ if (moved === 0) continue;
192
+ msg.events = kept.length > 0 ? kept : undefined;
193
+ // A message that was purely subagent output duplicates its (now nested)
194
+ // text via `content` — blank it so the empty-message filter drops it.
195
+ if (kept.length === 0) msg.content = "";
196
+ }
197
+ }
198
+
157
199
  /**
158
200
  * Validate JSONL path — must be under ~/.claude/ (prevents arbitrary file reads).
159
201
  * Throws Error with descriptive message. Returns resolved realpath on success.
@@ -233,9 +275,9 @@ export async function parseJsonlTranscript(
233
275
  merged.push(msg);
234
276
  }
235
277
 
236
- for (const msg of merged) {
237
- if (msg.events) nestChildEvents(msg.events);
238
- }
278
+ // Nest across messages: a backgrounded subagent's events land in later
279
+ // messages than the Agent tool_use that spawned it.
280
+ nestChildEventsAcrossMessages(merged);
239
281
 
240
282
  return merged.filter(
241
283
  (msg) => msg.content.trim().length > 0 || (msg.events && msg.events.length > 0),
@@ -1,10 +1,16 @@
1
1
  /**
2
2
  * Minimal HTTP server that serves a "stopped" page when the PPM server child is down.
3
- * Binds to the same port so the tunnel URL still works.
3
+ *
4
+ * It stands in for the server, so it binds an OS-assigned loopback port and
5
+ * publishes it to `.server-port` exactly as the real server does. The edge
6
+ * forwarder then routes the public port here and the tunnel URL keeps working —
7
+ * which is the whole point of the page. Binding the public port directly would
8
+ * collide with the edge, which owns it.
4
9
  */
5
- import { appendFileSync } from "node:fs";
10
+ import { appendFileSync, writeFileSync } from "node:fs";
6
11
  import { resolve } from "node:path";
7
12
  import { getPpmDir } from "./ppm-dir.ts";
13
+ import { SERVER_PORT_FILE } from "./edge-target-resolver.ts";
8
14
 
9
15
  function log(level: string, msg: string) {
10
16
  const ts = new Date().toISOString();
@@ -36,6 +42,11 @@ const STOPPED_HTML = `<!DOCTYPE html>
36
42
 
37
43
  let stoppedServer: ReturnType<typeof Bun.serve> | null = null;
38
44
 
45
+ /**
46
+ * @param port 0 in normal operation — the edge finds this page via
47
+ * `.server-port`, so it needs no fixed port of its own.
48
+ * @param host loopback; the edge is the only public listener.
49
+ */
39
50
  export function startStoppedPage(port: number, host: string) {
40
51
  if (stoppedServer) return;
41
52
 
@@ -56,7 +67,13 @@ export function startStoppedPage(port: number, host: string) {
56
67
  });
57
68
  },
58
69
  });
59
- log("INFO", `Stopped page serving on port ${port}`);
70
+ // Take over as the edge's target so the public URL shows this page.
71
+ try {
72
+ writeFileSync(SERVER_PORT_FILE(), String(stoppedServer.port));
73
+ } catch (e) {
74
+ log("WARN", `Failed to publish stopped-page port: ${e}`);
75
+ }
76
+ log("INFO", `Stopped page serving on ${host}:${stoppedServer.port}`);
60
77
  } catch (e) {
61
78
  log("WARN", `Failed to start stopped page: ${e}`);
62
79
  }