@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.
- package/CHANGELOG.md +3668 -3657
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +1 -1
- package/dist/web/assets/{ai-resource-editor-DxM8JFQ0.js → ai-resource-editor-x8J0omsj.js} +1 -1
- package/dist/web/assets/{audio-preview-DE1sEUmx.js → audio-preview-Bdf_SZHY.js} +1 -1
- package/dist/web/assets/chat-tab-CT6SP5D3.js +13 -0
- package/dist/web/assets/{code-editor-DjconF_F.js → code-editor-BgsJkCtK.js} +2 -2
- package/dist/web/assets/{conflict-editor-CLfc44RC.js → conflict-editor-DdSe9RCG.js} +1 -1
- package/dist/web/assets/{csv-preview-DxD081uz.js → csv-preview-DrUfLPRE.js} +1 -1
- package/dist/web/assets/{database-viewer-Du1HOmex.js → database-viewer-1NLuRknj.js} +1 -1
- package/dist/web/assets/{diff-viewer-CeznVBNn.js → diff-viewer-DPn8QJXG.js} +1 -1
- package/dist/web/assets/{docx-preview-DYbOm4bo.js → docx-preview-BUVtMDQF.js} +1 -1
- package/dist/web/assets/{extension-webview-BYBwAaZv.js → extension-webview-CEdJP2XP.js} +1 -1
- package/dist/web/assets/{git-log-panel-CgUkbt6i.js → git-log-panel-ClLf6-as.js} +1 -1
- package/dist/web/assets/{glide-data-grid-CBLhaW5g.js → glide-data-grid-CBhv99Ii.js} +1 -1
- package/dist/web/assets/{group-chat-tab-5EKPaL_r.js → group-chat-tab-KTKtMy6L.js} +1 -1
- package/dist/web/assets/{image-preview-DjqmBn2G.js → image-preview-DYyo-Twv.js} +1 -1
- package/dist/web/assets/{index-nsuPdCaW.js → index-BiBAhdYZ.js} +3 -3
- package/dist/web/assets/{keybindings-store-DTJlq-2C.js → keybindings-store-C1yAVIlX.js} +1 -1
- package/dist/web/assets/{markdown-renderer-BqthPP7U.js → markdown-renderer-CCBPLJC7.js} +1 -1
- package/dist/web/assets/{markdown-renderer-CrZWFsD-.js → markdown-renderer-JNQd7xG0.js} +1 -1
- package/dist/web/assets/{notification-store-ClZEc-lT.js → notification-store-lQg17iIv.js} +1 -1
- package/dist/web/assets/{pdf-preview-BZY4PbMF.js → pdf-preview-CWnXgjqc.js} +1 -1
- package/dist/web/assets/{postgres-viewer-DzpIkZtk.js → postgres-viewer-BIXfNiGy.js} +1 -1
- package/dist/web/assets/{settings-tab-o3KSdtxR.js → settings-tab-CpltHPv5.js} +1 -1
- package/dist/web/assets/{sql-query-editor-COXQHj5D.js → sql-query-editor-BDTzbvEM.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-Dew5JH18.js → sqlite-viewer-BkOrBWZP.js} +1 -1
- package/dist/web/assets/{system-monitor-tab-CwGbHdi_.js → system-monitor-tab-s0Jm9zah.js} +1 -1
- package/dist/web/assets/{terminal-tab-DqdmayEL.js → terminal-tab-D3S_kz4Y.js} +1 -1
- package/dist/web/assets/{tool-cards-CNuiAf2X.js → tool-cards-DbhWrJ_o.js} +2 -2
- package/dist/web/assets/{use-monaco-theme-uvD7nRfD.js → use-monaco-theme-DKGvvHk1.js} +1 -1
- package/dist/web/assets/{video-preview-BDpCb2Pd.js → video-preview-BCU0PfT1.js} +1 -1
- package/dist/web/index.html +1 -1
- package/dist/web/sw.js +1 -1
- package/docs/lessons-learned.md +69 -0
- package/docs/system-architecture.md +14 -3
- package/package.json +106 -106
- package/src/cli/commands/restart.ts +39 -23
- package/src/cli/commands/stop.ts +5 -0
- package/src/index.ts +7 -1
- package/src/providers/claude-agent-sdk.ts +5 -6
- package/src/server/index.ts +14 -1
- package/src/services/edge-forwarder.ts +191 -0
- package/src/services/edge-target-resolver.ts +63 -0
- package/src/services/jsonl-transcript-parser.ts +45 -3
- package/src/services/supervisor-stopped-page.ts +20 -3
- package/src/services/supervisor.ts +248 -61
- package/src/web/hooks/use-chat.ts +45 -1
- 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
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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__",
|
|
251
|
-
: ["run", P.serverScript, "__serve__",
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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;
|
package/src/cli/commands/stop.ts
CHANGED
|
@@ -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(
|
|
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,
|
|
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
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
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),
|
package/src/server/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
237
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
}
|