@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.
Files changed (79) hide show
  1. package/.release-node-shim.sh +10 -0
  2. package/CHANGELOG.md +8 -0
  3. package/assets/skills/ppm/SKILL.md +1 -1
  4. package/assets/skills/ppm/references/cli-reference.md +4 -4
  5. package/assets/skills/ppm/references/http-api.md +1 -1
  6. package/dist/web/assets/{accounts-settings-section-DzDmIsPe.js → accounts-settings-section-BcI3ithh.js} +1 -1
  7. package/dist/web/assets/{ai-resource-editor-D7C9wmwA.js → ai-resource-editor-C3i7VkUS.js} +1 -1
  8. package/dist/web/assets/{ai-settings-section-DZuwllLy.js → ai-settings-section-COdsN_5h.js} +1 -1
  9. package/dist/web/assets/{ai-settings-section-BW5a07-Z.js → ai-settings-section-DIqSLISN.js} +1 -1
  10. package/dist/web/assets/{appearance-settings-section-RfH2N1rj.js → appearance-settings-section-B9n42QPH.js} +1 -1
  11. package/dist/web/assets/{chat-tab-Bz0pk0_E.js → chat-tab-D9sk38e8.js} +3 -3
  12. package/dist/web/assets/{code-editor-C4o1SyuO.js → code-editor-iTD62P14.js} +2 -2
  13. package/dist/web/assets/{conflict-editor-Dk65mBIe.js → conflict-editor-idh_kBSa.js} +1 -1
  14. package/dist/web/assets/{csv-preview-DENi3CFz.js → csv-preview-B2PR7z0o.js} +1 -1
  15. package/dist/web/assets/{database-viewer-BiBC7ulB.js → database-viewer-DSv_pvEq.js} +1 -1
  16. package/dist/web/assets/{diff-viewer-CSvZJHak.js → diff-viewer-6X5S1lw3.js} +1 -1
  17. package/dist/web/assets/{explorer-body-QXS0h4AF.js → explorer-body-CrtXpmZM.js} +1 -1
  18. package/dist/web/assets/{explorer-window-content-CQgqoRsj.js → explorer-window-content-D11FZUgq.js} +1 -1
  19. package/dist/web/assets/{extension-manager-section-CyC7wX35.js → extension-manager-section-Cz8fvK1u.js} +1 -1
  20. package/dist/web/assets/{extension-webview-Da8SIlRa.js → extension-webview-DzZeXQsO.js} +1 -1
  21. package/dist/web/assets/{files-settings-section-D8cebM51.js → files-settings-section-pc4jOun8.js} +1 -1
  22. package/dist/web/assets/{general-settings-section-1HVTvlIw.js → general-settings-section-DIZIA54q.js} +1 -1
  23. package/dist/web/assets/{git-log-panel-C7a4rp8f.js → git-log-panel-BkcIfZIE.js} +1 -1
  24. package/dist/web/assets/{glide-data-grid-BT9p_4AN.js → glide-data-grid-nA5LsoJK.js} +1 -1
  25. package/dist/web/assets/{group-chat-tab-BUHGnUyW.js → group-chat-tab-DyzfaPol.js} +1 -1
  26. package/dist/web/assets/{index-kGmcC9cb.js → index-DJM59ogE.js} +4 -4
  27. package/dist/web/assets/{jira-watcher-section-BxzwW4uQ.js → jira-watcher-section-UEtpcxM9.js} +1 -1
  28. package/dist/web/assets/{keybindings-store-DjJum4OS.js → keybindings-store-BV0MMt1c.js} +1 -1
  29. package/dist/web/assets/{keyboard-shortcuts-section-DDKG3BU1.js → keyboard-shortcuts-section-sY56ThaI.js} +1 -1
  30. package/dist/web/assets/{markdown-renderer-dHeak2mE.js → markdown-renderer-Bkrk39pO.js} +1 -1
  31. package/dist/web/assets/{markdown-renderer-iBw1fCI_.js → markdown-renderer-Bthd7hxl.js} +1 -1
  32. package/dist/web/assets/{mobile-explorer-sheet-CzL8SLtz.js → mobile-explorer-sheet--NKsnNJn.js} +1 -1
  33. package/dist/web/assets/{notification-store-DVi6Ds71.js → notification-store-Bv4fnyvR.js} +1 -1
  34. package/dist/web/assets/{notifications-settings-section-shQ-ugKh.js → notifications-settings-section-BGSLkMuE.js} +1 -1
  35. package/dist/web/assets/{pdf-preview-DB26KSzm.js → pdf-preview-DE0wVfyM.js} +1 -1
  36. package/dist/web/assets/{postgres-viewer-CJe2meyF.js → postgres-viewer-C0S8dCbA.js} +1 -1
  37. package/dist/web/assets/{ppmbot-settings-section-Ba4c9AI9.js → ppmbot-settings-section-B2AZ8whe.js} +1 -1
  38. package/dist/web/assets/{process-table-PXwLsMuG.js → process-table-BmT34-_s.js} +1 -1
  39. package/dist/web/assets/{proxy-settings-section-BHBhK6HC.js → proxy-settings-section-BYwWFWvn.js} +1 -1
  40. package/dist/web/assets/{query-audit-section-BD5ZVqDz.js → query-audit-section-Dj1-npew.js} +1 -1
  41. package/dist/web/assets/{remote-desktop-mobile-sheet-Di4QG8VX.js → remote-desktop-mobile-sheet-bFIYIQIj.js} +2 -2
  42. package/dist/web/assets/{remote-desktop-mobile-view-DzoZvAqc.js → remote-desktop-mobile-view-BbrHPmPU.js} +1 -1
  43. package/dist/web/assets/{remote-desktop-readiness-gate-CHO-KJ9Z.js → remote-desktop-readiness-gate-Dme8r7Jp.js} +1 -1
  44. package/dist/web/assets/{remote-desktop-window-content-X324gYbW.js → remote-desktop-window-content-BdbszoSx.js} +1 -1
  45. package/dist/web/assets/{schedules-settings-section-a4-h6THc.js → schedules-settings-section-rczhjkeM.js} +1 -1
  46. package/dist/web/assets/{settings-body-Fq7BVDw2.js → settings-body-CwaQg_uc.js} +2 -2
  47. package/dist/web/assets/{settings-tab-CVLQy-ie.js → settings-tab-C8l9-_9E.js} +1 -1
  48. package/dist/web/assets/{settings-window-content-Bi7M0iaZ.js → settings-window-content-EA1489gi.js} +1 -1
  49. package/dist/web/assets/{sql-query-editor-D1r265fl.js → sql-query-editor-BQsKZVtz.js} +1 -1
  50. package/dist/web/assets/{sqlite-viewer-DHkIotRl.js → sqlite-viewer-Di8s3Z1m.js} +1 -1
  51. package/dist/web/assets/{system-monitor-body-y6VUkYTL.js → system-monitor-body-Dlknm4Uo.js} +2 -2
  52. package/dist/web/assets/{system-monitor-tab-DRAOejhk.js → system-monitor-tab-LZRO6_DP.js} +1 -1
  53. package/dist/web/assets/{system-monitor-window-content-B5i57xHD.js → system-monitor-window-content-BFDfhqDd.js} +1 -1
  54. package/dist/web/assets/{tab-host-window-content-SkyPFFlc.js → tab-host-window-content-Bh7064Yw.js} +1 -1
  55. package/dist/web/assets/{team-member-sheet-BB63u-Lf.js → team-member-sheet-CU68IqU0.js} +2 -2
  56. package/dist/web/assets/{team-member-window-content-D6tVTXuV.js → team-member-window-content-BYtro89j.js} +1 -1
  57. package/dist/web/assets/{terminal-tab-BYaHAkI5.js → terminal-tab-qfSPxRtZ.js} +1 -1
  58. package/dist/web/assets/{tool-cards-DRJu20sL.js → tool-cards-CY9J4ux4.js} +2 -2
  59. package/dist/web/assets/{use-accounts-data-Ba1_uvEE.js → use-accounts-data-pAQw6nCa.js} +1 -1
  60. package/dist/web/assets/{use-monaco-theme-Uakgj3hJ.js → use-monaco-theme-DT5Q8kuf.js} +1 -1
  61. package/dist/web/assets/{use-remote-desktop-display-choice-Cd3Xalca.js → use-remote-desktop-display-choice-CYQsrOMC.js} +1 -1
  62. package/dist/web/assets/{use-websocket-BC_EGf5q.js → use-websocket-mkNA1M8U.js} +1 -1
  63. package/dist/web/assets/{video-preview-BUb43zWb.js → video-preview-B2dk48oQ.js} +1 -1
  64. package/dist/web/index.html +1 -1
  65. package/dist/web/sw.js +1 -1
  66. package/package.json +1 -1
  67. package/src/providers/codex-app-server/codex-event-mapper.ts +4 -0
  68. package/src/providers/codex-app-server/codex-history.ts +4 -2
  69. package/src/providers/codex-app-server/codex-protocol.ts +4 -0
  70. package/src/providers/codex-app-server/codex-provider.ts +69 -5
  71. package/src/server/routes/chat.ts +22 -3
  72. package/src/server/ws/chat.ts +5 -0
  73. package/src/services/edge-forwarder.ts +191 -191
  74. package/src/services/windows-process-tree.ts +351 -351
  75. package/src/web/hooks/use-chat.ts +22 -11
  76. package/packages/vscode-compat/node_modules/.bin/tsc.bunx +0 -0
  77. package/packages/vscode-compat/node_modules/.bin/tsc.exe +0 -0
  78. package/packages/vscode-compat/node_modules/.bin/tsserver.bunx +0 -0
  79. package/packages/vscode-compat/node_modules/.bin/tsserver.exe +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hienlh/ppm",
3
- "version": "0.20.3",
3
+ "version": "0.20.4",
4
4
  "description": "Personal Project Manager — mobile-first web IDE with AI assistance",
5
5
  "author": "hienlh",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -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 under ~/.codex/sessions. */
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
- return n.endsWith(".jsonl") && n.includes(normPath(join(homedir(), ".codex", "sessions")));
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) ev.usage = live.lastUsage;
758
- live.channel.push(ev);
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 (ev.type === "tool_use" || ev.type === "tool_result") live.currentEvents.push(ev);
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
- // Also persist to SDK so Claude Code CLI sees the custom title
473
- await sdkRenameSession(id, title, { dir: projectPath });
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
- ?? getSessionProjectPath(sessionId)
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,
@@ -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
+ }