@hienlh/ppm 0.20.3 → 0.20.4
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/.release-node-shim.sh +10 -0
- package/CHANGELOG.md +8 -0
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/cli-reference.md +4 -4
- package/assets/skills/ppm/references/http-api.md +1 -1
- package/dist/web/assets/{accounts-settings-section-DzDmIsPe.js → accounts-settings-section-BcI3ithh.js} +1 -1
- package/dist/web/assets/{ai-resource-editor-D7C9wmwA.js → ai-resource-editor-C3i7VkUS.js} +1 -1
- package/dist/web/assets/{ai-settings-section-DZuwllLy.js → ai-settings-section-COdsN_5h.js} +1 -1
- package/dist/web/assets/{ai-settings-section-BW5a07-Z.js → ai-settings-section-DIqSLISN.js} +1 -1
- package/dist/web/assets/{appearance-settings-section-RfH2N1rj.js → appearance-settings-section-B9n42QPH.js} +1 -1
- package/dist/web/assets/{chat-tab-Bz0pk0_E.js → chat-tab-D9sk38e8.js} +3 -3
- package/dist/web/assets/{code-editor-C4o1SyuO.js → code-editor-iTD62P14.js} +2 -2
- package/dist/web/assets/{conflict-editor-Dk65mBIe.js → conflict-editor-idh_kBSa.js} +1 -1
- package/dist/web/assets/{csv-preview-DENi3CFz.js → csv-preview-B2PR7z0o.js} +1 -1
- package/dist/web/assets/{database-viewer-BiBC7ulB.js → database-viewer-DSv_pvEq.js} +1 -1
- package/dist/web/assets/{diff-viewer-CSvZJHak.js → diff-viewer-6X5S1lw3.js} +1 -1
- package/dist/web/assets/{explorer-body-QXS0h4AF.js → explorer-body-CrtXpmZM.js} +1 -1
- package/dist/web/assets/{explorer-window-content-CQgqoRsj.js → explorer-window-content-D11FZUgq.js} +1 -1
- package/dist/web/assets/{extension-manager-section-CyC7wX35.js → extension-manager-section-Cz8fvK1u.js} +1 -1
- package/dist/web/assets/{extension-webview-Da8SIlRa.js → extension-webview-DzZeXQsO.js} +1 -1
- package/dist/web/assets/{files-settings-section-D8cebM51.js → files-settings-section-pc4jOun8.js} +1 -1
- package/dist/web/assets/{general-settings-section-1HVTvlIw.js → general-settings-section-DIZIA54q.js} +1 -1
- package/dist/web/assets/{git-log-panel-C7a4rp8f.js → git-log-panel-BkcIfZIE.js} +1 -1
- package/dist/web/assets/{glide-data-grid-BT9p_4AN.js → glide-data-grid-nA5LsoJK.js} +1 -1
- package/dist/web/assets/{group-chat-tab-BUHGnUyW.js → group-chat-tab-DyzfaPol.js} +1 -1
- package/dist/web/assets/{index-kGmcC9cb.js → index-DJM59ogE.js} +4 -4
- package/dist/web/assets/{jira-watcher-section-BxzwW4uQ.js → jira-watcher-section-UEtpcxM9.js} +1 -1
- package/dist/web/assets/{keybindings-store-DjJum4OS.js → keybindings-store-BV0MMt1c.js} +1 -1
- package/dist/web/assets/{keyboard-shortcuts-section-DDKG3BU1.js → keyboard-shortcuts-section-sY56ThaI.js} +1 -1
- package/dist/web/assets/{markdown-renderer-dHeak2mE.js → markdown-renderer-Bkrk39pO.js} +1 -1
- package/dist/web/assets/{markdown-renderer-iBw1fCI_.js → markdown-renderer-Bthd7hxl.js} +1 -1
- package/dist/web/assets/{mobile-explorer-sheet-CzL8SLtz.js → mobile-explorer-sheet--NKsnNJn.js} +1 -1
- package/dist/web/assets/{notification-store-DVi6Ds71.js → notification-store-Bv4fnyvR.js} +1 -1
- package/dist/web/assets/{notifications-settings-section-shQ-ugKh.js → notifications-settings-section-BGSLkMuE.js} +1 -1
- package/dist/web/assets/{pdf-preview-DB26KSzm.js → pdf-preview-DE0wVfyM.js} +1 -1
- package/dist/web/assets/{postgres-viewer-CJe2meyF.js → postgres-viewer-C0S8dCbA.js} +1 -1
- package/dist/web/assets/{ppmbot-settings-section-Ba4c9AI9.js → ppmbot-settings-section-B2AZ8whe.js} +1 -1
- package/dist/web/assets/{process-table-PXwLsMuG.js → process-table-BmT34-_s.js} +1 -1
- package/dist/web/assets/{proxy-settings-section-BHBhK6HC.js → proxy-settings-section-BYwWFWvn.js} +1 -1
- package/dist/web/assets/{query-audit-section-BD5ZVqDz.js → query-audit-section-Dj1-npew.js} +1 -1
- package/dist/web/assets/{remote-desktop-mobile-sheet-Di4QG8VX.js → remote-desktop-mobile-sheet-bFIYIQIj.js} +2 -2
- package/dist/web/assets/{remote-desktop-mobile-view-DzoZvAqc.js → remote-desktop-mobile-view-BbrHPmPU.js} +1 -1
- package/dist/web/assets/{remote-desktop-readiness-gate-CHO-KJ9Z.js → remote-desktop-readiness-gate-Dme8r7Jp.js} +1 -1
- package/dist/web/assets/{remote-desktop-window-content-X324gYbW.js → remote-desktop-window-content-BdbszoSx.js} +1 -1
- package/dist/web/assets/{schedules-settings-section-a4-h6THc.js → schedules-settings-section-rczhjkeM.js} +1 -1
- package/dist/web/assets/{settings-body-Fq7BVDw2.js → settings-body-CwaQg_uc.js} +2 -2
- package/dist/web/assets/{settings-tab-CVLQy-ie.js → settings-tab-C8l9-_9E.js} +1 -1
- package/dist/web/assets/{settings-window-content-Bi7M0iaZ.js → settings-window-content-EA1489gi.js} +1 -1
- package/dist/web/assets/{sql-query-editor-D1r265fl.js → sql-query-editor-BQsKZVtz.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-DHkIotRl.js → sqlite-viewer-Di8s3Z1m.js} +1 -1
- package/dist/web/assets/{system-monitor-body-y6VUkYTL.js → system-monitor-body-Dlknm4Uo.js} +2 -2
- package/dist/web/assets/{system-monitor-tab-DRAOejhk.js → system-monitor-tab-LZRO6_DP.js} +1 -1
- package/dist/web/assets/{system-monitor-window-content-B5i57xHD.js → system-monitor-window-content-BFDfhqDd.js} +1 -1
- package/dist/web/assets/{tab-host-window-content-SkyPFFlc.js → tab-host-window-content-Bh7064Yw.js} +1 -1
- package/dist/web/assets/{team-member-sheet-BB63u-Lf.js → team-member-sheet-CU68IqU0.js} +2 -2
- package/dist/web/assets/{team-member-window-content-D6tVTXuV.js → team-member-window-content-BYtro89j.js} +1 -1
- package/dist/web/assets/{terminal-tab-BYaHAkI5.js → terminal-tab-qfSPxRtZ.js} +1 -1
- package/dist/web/assets/{tool-cards-DRJu20sL.js → tool-cards-CY9J4ux4.js} +2 -2
- package/dist/web/assets/{use-accounts-data-Ba1_uvEE.js → use-accounts-data-pAQw6nCa.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-Uakgj3hJ.js → use-monaco-theme-DT5Q8kuf.js} +1 -1
- package/dist/web/assets/{use-remote-desktop-display-choice-Cd3Xalca.js → use-remote-desktop-display-choice-CYQsrOMC.js} +1 -1
- package/dist/web/assets/{use-websocket-BC_EGf5q.js → use-websocket-mkNA1M8U.js} +1 -1
- package/dist/web/assets/{video-preview-BUb43zWb.js → video-preview-B2dk48oQ.js} +1 -1
- package/dist/web/index.html +1 -1
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/providers/codex-app-server/codex-event-mapper.ts +4 -0
- package/src/providers/codex-app-server/codex-history.ts +4 -2
- package/src/providers/codex-app-server/codex-protocol.ts +4 -0
- package/src/providers/codex-app-server/codex-provider.ts +69 -5
- package/src/server/routes/chat.ts +22 -3
- package/src/server/ws/chat.ts +5 -0
- package/src/services/edge-forwarder.ts +191 -191
- package/src/services/windows-process-tree.ts +351 -351
- package/src/web/hooks/use-chat.ts +22 -11
- package/packages/vscode-compat/node_modules/.bin/tsc.bunx +0 -0
- package/packages/vscode-compat/node_modules/.bin/tsc.exe +0 -0
- package/packages/vscode-compat/node_modules/.bin/tsserver.bunx +0 -0
- package/packages/vscode-compat/node_modules/.bin/tsserver.exe +0 -0
package/package.json
CHANGED
|
@@ -168,6 +168,10 @@ export function mapCodexEvent(notif: Notif, sessionId: string): ChatEvent[] {
|
|
|
168
168
|
return typeof p.delta === "string" ? [{ type: "text", content: p.delta }] : [];
|
|
169
169
|
|
|
170
170
|
case "item/reasoning/textDelta":
|
|
171
|
+
// `summary` is what turn/start requests for PPM's Thinking switch. Recent
|
|
172
|
+
// app-server builds stream it under this method, while older builds used
|
|
173
|
+
// textDelta directly. Both are safe, user-visible reasoning summaries.
|
|
174
|
+
case "item/reasoning/summaryTextDelta":
|
|
171
175
|
return typeof p.delta === "string" ? [{ type: "thinking", content: p.delta }] : [];
|
|
172
176
|
|
|
173
177
|
case "item/started": {
|
|
@@ -411,10 +411,12 @@ export function getRolloutMessages(sessionsDir: string, threadId: string, reques
|
|
|
411
411
|
} catch { return []; }
|
|
412
412
|
}
|
|
413
413
|
|
|
414
|
-
/** True when a path points at a codex rollout
|
|
414
|
+
/** True when a path points at a codex rollout in the ambient or a PPM account home. */
|
|
415
415
|
export function isCodexRolloutPath(p: string): boolean {
|
|
416
416
|
const n = normPath(p);
|
|
417
|
-
|
|
417
|
+
const ambient = normPath(join(homedir(), ".codex", "sessions"));
|
|
418
|
+
const managed = normPath(join(homedir(), ".ppm", "codex-accounts"));
|
|
419
|
+
return n.endsWith(".jsonl") && (n.includes(ambient) || (n.includes(managed) && n.includes("/sessions/")));
|
|
418
420
|
}
|
|
419
421
|
|
|
420
422
|
/**
|
|
@@ -56,6 +56,10 @@ export interface TurnStartParams {
|
|
|
56
56
|
approvalPolicy?: AskForApproval;
|
|
57
57
|
sandboxPolicy?: unknown;
|
|
58
58
|
model?: string;
|
|
59
|
+
/** Codex's per-turn reasoning depth (low through ultra, model dependent). */
|
|
60
|
+
effort?: string;
|
|
61
|
+
/** Controls whether Codex streams a safe reasoning summary to the client. */
|
|
62
|
+
summary?: "none" | "auto" | "concise" | "detailed";
|
|
59
63
|
}
|
|
60
64
|
|
|
61
65
|
// ── Decisions (approval responses) ──
|
|
@@ -10,7 +10,7 @@ import type {
|
|
|
10
10
|
UsageInfo,
|
|
11
11
|
} from "../provider.interface.ts";
|
|
12
12
|
import { configService } from "../../services/config.service.ts";
|
|
13
|
-
import { setSessionMetadata, getSessionProjectPath, setSessionProvider, setSessionCodexAccount, getSessionCodexAccount, getSessionTitles } from "../../services/db.service.ts";
|
|
13
|
+
import { setSessionMetadata, getSessionProjectPath, setSessionProvider, setSessionCodexAccount, getSessionCodexAccount, getSessionTitles, insertTurnUsage } from "../../services/db.service.ts";
|
|
14
14
|
import {
|
|
15
15
|
resolveCodexAccountForSession,
|
|
16
16
|
getCodexAccount,
|
|
@@ -29,6 +29,7 @@ import { join } from "node:path";
|
|
|
29
29
|
import { CodexJsonRpcClient, CONTROL_REQUEST_TIMEOUT_MS } from "./codex-jsonrpc-client.ts";
|
|
30
30
|
import { permissionModeToCodex, type CodexPermission } from "./codex-permission-map.ts";
|
|
31
31
|
import { mapCodexEvent, parseTokenUsage } from "./codex-event-mapper.ts";
|
|
32
|
+
import { subagentCardId } from "./codex-subagent-thread.ts";
|
|
32
33
|
import { decisionFor, isApprovalMethod, type ApprovalMethod } from "./codex-approval-decision.ts";
|
|
33
34
|
import { parseModelList } from "./codex-model-parser.ts";
|
|
34
35
|
import { getOrFetchUsage, registerUsageSource } from "../../services/provider-usage/usage-registry.ts";
|
|
@@ -147,6 +148,8 @@ interface LiveSession {
|
|
|
147
148
|
compactRequested?: boolean;
|
|
148
149
|
/** Token counts from the most recent usage notification, attached to `done`. */
|
|
149
150
|
lastUsage?: import("../../shared/turn-usage.ts").TurnUsage;
|
|
151
|
+
/** Spawned thread ids. Notifications from these threads belong inside their Agent card. */
|
|
152
|
+
subagentThreadIds: Set<string>;
|
|
150
153
|
/** A turn is running on the thread. Codex accepts exactly one at a time: a
|
|
151
154
|
* second `turn/start` resolves with the ALREADY-RUNNING turn (same id,
|
|
152
155
|
* status `inProgress`) and silently discards the new input — no error, so
|
|
@@ -219,6 +222,21 @@ function createEventChannel(): EventChannel {
|
|
|
219
222
|
};
|
|
220
223
|
}
|
|
221
224
|
|
|
225
|
+
/** The app-server puts the owner thread on every item notification. An
|
|
226
|
+
* `agentThreadId` inside a SubAgentActivity is the newly spawned child, so it
|
|
227
|
+
* must never be used here: that would nest the launch card inside itself. */
|
|
228
|
+
export function threadIdFromNotification(params: unknown): string | null {
|
|
229
|
+
if (!params || typeof params !== "object") return null;
|
|
230
|
+
const p = params as Record<string, unknown>;
|
|
231
|
+
const direct = p.threadId ?? p.thread_id;
|
|
232
|
+
if (typeof direct === "string" && direct) return direct;
|
|
233
|
+
const thread = p.thread;
|
|
234
|
+
if (thread && typeof thread === "object" && typeof (thread as Record<string, unknown>).id === "string") {
|
|
235
|
+
return (thread as Record<string, unknown>).id as string;
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
222
240
|
/**
|
|
223
241
|
* Drop a model id that isn't a codex model. PPM's per-session model can fall back
|
|
224
242
|
* to the global Claude default (e.g. `claude-opus-4-8`), which codex rejects
|
|
@@ -425,6 +443,7 @@ export class CodexAppServerProvider implements AIProvider {
|
|
|
425
443
|
live.lastTurnInput = { message, opts };
|
|
426
444
|
live.currentAssistant = "";
|
|
427
445
|
live.currentEvents = [];
|
|
446
|
+
live.lastUsage = undefined;
|
|
428
447
|
// Codex takes an image as a path, never as a payload, so the uploaded copy is what it
|
|
429
448
|
// gets. Images lead: the text usually refers to them ("what is this?").
|
|
430
449
|
const input: UserInput[] = [
|
|
@@ -432,10 +451,17 @@ export class CodexAppServerProvider implements AIProvider {
|
|
|
432
451
|
{ type: "text" as const, text: message, text_elements: [] },
|
|
433
452
|
];
|
|
434
453
|
const turnModel = codexModel(opts?.model);
|
|
454
|
+
const turnEffort = opts?.effort ?? this.config?.effort;
|
|
455
|
+
const configuredThinking = this.config?.thinking_budget_tokens;
|
|
456
|
+
// Codex does not accept a token budget. Its equivalent is an effort level,
|
|
457
|
+
// while `summary` controls the safe thinking text the UI can show.
|
|
458
|
+
const thinkingEnabled = (opts?.thinkingBudget ?? configuredThinking ?? -1) !== 0;
|
|
435
459
|
live.client.request("turn/start", {
|
|
436
460
|
threadId: live.threadId,
|
|
437
461
|
input,
|
|
438
462
|
...(turnModel ? { model: turnModel } : {}),
|
|
463
|
+
...(turnEffort ? { effort: turnEffort } : {}),
|
|
464
|
+
summary: thinkingEnabled ? "detailed" : "none",
|
|
439
465
|
}).then((res) => {
|
|
440
466
|
// Backup for the `turn/started` notification, which is what normally names
|
|
441
467
|
// the turn — an interrupt arriving in between has nothing to address.
|
|
@@ -678,7 +704,7 @@ export class CodexAppServerProvider implements AIProvider {
|
|
|
678
704
|
client, threadId: null, cwd, channel, permission, model,
|
|
679
705
|
pendingApprovals: new Map(), answeredCodexIds: new Set(),
|
|
680
706
|
history: [], transcript: [], currentAssistant: "", currentEvents: [],
|
|
681
|
-
pendingTurns: [],
|
|
707
|
+
pendingTurns: [], subagentThreadIds: new Set(),
|
|
682
708
|
};
|
|
683
709
|
this.live.set(sessionId, live);
|
|
684
710
|
|
|
@@ -750,14 +776,26 @@ export class CodexAppServerProvider implements AIProvider {
|
|
|
750
776
|
const usage = parseTokenUsage(notif.params, live.model);
|
|
751
777
|
if (usage) live.lastUsage = usage;
|
|
752
778
|
}
|
|
779
|
+
const notificationThreadId = threadIdFromNotification(notif.params);
|
|
780
|
+
const parentToolUseId = notificationThreadId && live.subagentThreadIds.has(notificationThreadId)
|
|
781
|
+
? subagentCardId(notificationThreadId)
|
|
782
|
+
: undefined;
|
|
753
783
|
const events = mapCodexEvent(notif, live.threadId ?? "");
|
|
754
784
|
for (const ev of events) {
|
|
755
785
|
// The counts arrive on their own notification just before the turn ends,
|
|
756
786
|
// so `done` is where they become visible to a consumer.
|
|
757
|
-
if (ev.type === "done" && live.lastUsage)
|
|
758
|
-
|
|
787
|
+
if (ev.type === "done" && live.lastUsage) {
|
|
788
|
+
ev.usage = live.lastUsage;
|
|
789
|
+
this.recordTurnUsage(live, live.lastUsage);
|
|
790
|
+
}
|
|
791
|
+
const nested = parentToolUseId ? { ...ev, parentToolUseId } as ChatEvent : ev;
|
|
792
|
+
live.channel.push(nested);
|
|
759
793
|
// Accumulate tool calls into the turn so getMessages (live) keeps them.
|
|
760
|
-
if (
|
|
794
|
+
if (nested.type === "tool_use" || nested.type === "tool_result") live.currentEvents.push(nested);
|
|
795
|
+
if (nested.type === "tool_use" && nested.tool === "Agent" && typeof nested.toolUseId === "string"
|
|
796
|
+
&& nested.toolUseId.startsWith("subagent-")) {
|
|
797
|
+
live.subagentThreadIds.add(nested.toolUseId.slice("subagent-".length));
|
|
798
|
+
}
|
|
761
799
|
}
|
|
762
800
|
if (notif.method === "turn/completed") {
|
|
763
801
|
if (live.currentAssistant || live.currentEvents.length) {
|
|
@@ -798,6 +836,32 @@ export class CodexAppServerProvider implements AIProvider {
|
|
|
798
836
|
}
|
|
799
837
|
}
|
|
800
838
|
|
|
839
|
+
/** Persist Codex's per-turn token and prompt-cache split for the debug panel. */
|
|
840
|
+
private recordTurnUsage(live: LiveSession, usage: import("../../shared/turn-usage.ts").TurnUsage): void {
|
|
841
|
+
const accountId = live.threadId ? getSessionCodexAccount(live.threadId) ?? undefined : undefined;
|
|
842
|
+
const account = accountId ? getCodexAccount(accountId) : null;
|
|
843
|
+
try {
|
|
844
|
+
insertTurnUsage({
|
|
845
|
+
sessionId: live.threadId ?? "",
|
|
846
|
+
model: usage.model,
|
|
847
|
+
inputTokens: usage.inputTokens,
|
|
848
|
+
outputTokens: usage.outputTokens,
|
|
849
|
+
cacheReadTokens: usage.cacheReadTokens,
|
|
850
|
+
cacheWriteTokens: usage.cacheWriteTokens,
|
|
851
|
+
contextWindow: usage.contextWindow,
|
|
852
|
+
costUsd: usage.costUsd,
|
|
853
|
+
// A cache miss alone does not prove a process restart: a first turn and
|
|
854
|
+
// an evicted prompt cache look the same on the wire. Keep this false
|
|
855
|
+
// until the provider has an explicit lifecycle reason to record.
|
|
856
|
+
coldStart: false,
|
|
857
|
+
...(accountId ? { accountId } : {}),
|
|
858
|
+
...(account?.label ? { accountLabel: account.label } : {}),
|
|
859
|
+
});
|
|
860
|
+
} catch (err) {
|
|
861
|
+
console.warn(`[usage] failed to persist codex usage: ${(err as Error).message}`);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
801
865
|
private handleServerRequest(live: LiveSession, req: ServerRequest): void {
|
|
802
866
|
const method = req.method;
|
|
803
867
|
if (isApprovalMethod(method)) {
|
|
@@ -19,6 +19,8 @@ import { parseJsonlTranscript, validateJsonlPath } from "../../services/jsonl-tr
|
|
|
19
19
|
import { aggregateTasks } from "../../services/task-status-aggregator.ts";
|
|
20
20
|
import { MANY_IMAGE_DIMENSION_LIMIT, type StripMode } from "../../services/transcript-images.ts";
|
|
21
21
|
import { auditTranscriptImagesFile, stripTranscriptImagesFile } from "../../services/transcript-images-file.ts";
|
|
22
|
+
import { listCodexAccounts } from "../../services/codex-account.service.ts";
|
|
23
|
+
import { findRolloutByThreadId } from "../../providers/codex-app-server/codex-history.ts";
|
|
22
24
|
import { getSessionProjectPath, setSessionMetadata, setSessionTitle, getSessionTitle, getPinnedSessionIds, pinSession, unpinSession, deleteSessionMapping, deleteSessionMetadata, deleteSessionTitle, getAllUnread, clearSessionUnread, setSessionUnread } from "../../services/db.service.ts";
|
|
23
25
|
import { setSessionTag, bulkSetSessionTag, getTagById, getSessionTags, getProjectDefaultTagId } from "../../services/tag.service.ts";
|
|
24
26
|
import { recordBranch, resolveVersionGroup, resolveVersionMap, collapseTreesToHeads, hasChildren, deleteBranchesFor, getRootId } from "../../services/session-branch.service.ts";
|
|
@@ -469,8 +471,11 @@ chatRoutes.patch("/sessions/:id", async (c) => {
|
|
|
469
471
|
const projectPath = c.get("projectPath");
|
|
470
472
|
// Persist to PPM DB (authoritative source for user-set titles)
|
|
471
473
|
setSessionTitle(id, title);
|
|
472
|
-
//
|
|
473
|
-
|
|
474
|
+
// Codex owns its rollout title and has no Claude SDK session to rename.
|
|
475
|
+
// PPM's title table is the authoritative title for both providers.
|
|
476
|
+
if (getSessionProvider(id) !== "codex") {
|
|
477
|
+
await sdkRenameSession(id, title, { dir: projectPath });
|
|
478
|
+
}
|
|
474
479
|
// Also update in-memory session
|
|
475
480
|
const session = chatService.getSession(id);
|
|
476
481
|
if (session) session.title = title;
|
|
@@ -763,9 +768,22 @@ chatRoutes.get("/sessions/:id/logs", (c) => {
|
|
|
763
768
|
*/
|
|
764
769
|
function resolveSessionJsonlPath(sessionId: string): { jsonlPath: string; jsonlDir: string; projectPath: string; exists: boolean } {
|
|
765
770
|
const homedir = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
|
771
|
+
const providerId = getSessionProvider(sessionId);
|
|
772
|
+
const storedProjectPath = getSessionProjectPath(sessionId) ?? "";
|
|
773
|
+
if (providerId === "codex") {
|
|
774
|
+
const dirs = [
|
|
775
|
+
...listCodexAccounts().map((account) => join(account.home, "sessions")),
|
|
776
|
+
...(homedir ? [resolve(homedir, ".codex", "sessions")] : []),
|
|
777
|
+
];
|
|
778
|
+
for (const dir of dirs) {
|
|
779
|
+
const found = findRolloutByThreadId(dir, sessionId, storedProjectPath || undefined);
|
|
780
|
+
if (found) return { jsonlPath: found, jsonlDir: dir, projectPath: storedProjectPath, exists: true };
|
|
781
|
+
}
|
|
782
|
+
return { jsonlPath: "", jsonlDir: "", projectPath: storedProjectPath, exists: false };
|
|
783
|
+
}
|
|
766
784
|
const provider = providerRegistry.get("claude") as any;
|
|
767
785
|
const projectPath = provider?.activeSessions?.get(sessionId)?.projectPath
|
|
768
|
-
??
|
|
786
|
+
?? storedProjectPath
|
|
769
787
|
?? "";
|
|
770
788
|
const projectsRoot = homedir ? resolve(homedir, ".claude", "projects") : "";
|
|
771
789
|
// SDK encodes cwd by replacing path separators + drive colon with "-".
|
|
@@ -815,6 +833,7 @@ chatRoutes.get("/sessions/:id/debug", (c) => {
|
|
|
815
833
|
return c.json(ok({
|
|
816
834
|
ppmSessionId: sessionId,
|
|
817
835
|
sdkSessionId: sessionId,
|
|
836
|
+
providerId: getSessionProvider(sessionId) ?? "claude",
|
|
818
837
|
sessionId,
|
|
819
838
|
jsonlPath: exists ? jsonlPath : null,
|
|
820
839
|
jsonlDir,
|
package/src/server/ws/chat.ts
CHANGED
|
@@ -1286,10 +1286,15 @@ export const chatWebSocket = {
|
|
|
1286
1286
|
} else {
|
|
1287
1287
|
// Follow-up: push into existing generator via provider
|
|
1288
1288
|
if (provider && "pushMessage" in provider && parsed.type === "message") {
|
|
1289
|
+
const effort = getSessionEffort(sessionId) ?? undefined;
|
|
1290
|
+
const thinkingBudget = getSessionThinking(sessionId);
|
|
1289
1291
|
(provider as any).pushMessage(sessionId, parsed.content, {
|
|
1290
1292
|
priority: parsed.priority ?? 'next',
|
|
1291
1293
|
images: parsed.images,
|
|
1292
1294
|
imagePaths: parsed.imagePaths,
|
|
1295
|
+
...(entry.model ? { model: entry.model } : {}),
|
|
1296
|
+
...(effort ? { effort } : {}),
|
|
1297
|
+
...(thinkingBudget != null ? { thinkingBudget } : {}),
|
|
1293
1298
|
});
|
|
1294
1299
|
}
|
|
1295
1300
|
// Clear turn events for new turn display + transition phase
|
|
@@ -1,191 +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
|
-
}
|
|
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
|
+
}
|