codehost 0.23.1 → 0.23.3

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 CHANGED
@@ -1,3 +1,18 @@
1
+ ## [0.23.3](https://github.com/snomiao/codehost/compare/v0.23.2...v0.23.3) (2026-06-20)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **cli:** expand an unexpanded %USERPROFILE% in TEMP/TMP on Windows ([d258ee8](https://github.com/snomiao/codehost/commit/d258ee80131880aa5bc0b2bd283474e65c289184))
7
+ * **cli:** manage the Windows daemon with pm2 instead of oxmgr ([ebade2e](https://github.com/snomiao/codehost/commit/ebade2eb67868a6ace899bba52c47d9c64934dbb))
8
+
9
+ ## [0.23.2](https://github.com/snomiao/codehost/compare/v0.23.1...v0.23.2) (2026-06-16)
10
+
11
+
12
+ ### Performance Improvements
13
+
14
+ * **cli:** throttle meta pushes to the signaling room ([654ee10](https://github.com/snomiao/codehost/commit/654ee1088bc88f95ca6b57545fa477ccb77b994f))
15
+
1
16
  ## [0.23.1](https://github.com/snomiao/codehost/compare/v0.23.0...v0.23.1) (2026-06-16)
2
17
 
3
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codehost",
3
- "version": "0.23.1",
3
+ "version": "0.23.3",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -9,15 +9,17 @@ export const listCommand: CommandModule = {
9
9
  handler: async () => {
10
10
  const detached = listFallbackDaemons();
11
11
  if (detached.length) {
12
- console.log("Detached daemons (no oxmgr):");
12
+ console.log("Managed daemons (no oxmgr):");
13
13
  for (const d of detached) {
14
- console.log(` ${d.name} pid ${d.pid} ${d.cwd} (log: ${d.log})`);
14
+ const backend = d.pm2 ? "pm2" : d.task ? "task" : `pid ${d.pid}`;
15
+ console.log(` ${d.name} [${backend}] ${d.cwd} (log: ${d.log})`);
15
16
  }
16
17
  console.log("");
17
18
  }
18
- // Only hit oxmgr if it's actually runnable `hasOxmgr` doesn't self-heal,
19
- // so a broken install won't re-download its binary on every `list`.
20
- if (await hasOxmgr()) {
19
+ // Windows never uses oxmgr (it manages via pm2/schtasks), so don't poke it
20
+ // there. Elsewhere, only hit oxmgr if it's actually runnable `hasOxmgr`
21
+ // doesn't self-heal, so a broken install won't re-download on every `list`.
22
+ if (process.platform !== "win32" && (await hasOxmgr())) {
21
23
  const shown = await listDaemons();
22
24
  // listDaemons returns the count of codehost daemons (>=0) or -1 if oxmgr
23
25
  // is unusable; only the latter is an error exit. It prints its own
@@ -23,6 +23,11 @@ export const stopCommand: CommandModule<{}, StopArgs> = {
23
23
  console.log(`[codehost] stopped ${full}`);
24
24
  process.exit(0);
25
25
  }
26
+ // Windows manages via pm2/schtasks only — don't fall through to oxmgr there.
27
+ if (process.platform === "win32") {
28
+ console.error(`[codehost] no daemon named ${full}`);
29
+ process.exit(1);
30
+ }
26
31
  process.exit(await stopDaemon(argv.name));
27
32
  },
28
33
  };
@@ -35,9 +35,11 @@ export interface ServeDaemonResult {
35
35
  /**
36
36
  * Launch a foreground `codehost serve` (without -d) so it survives the shell and
37
37
  * restarts on failure. Prefers oxmgr (which also adds login auto-start); when
38
- * oxmgr can't run here (e.g. broken native binary on Windows) it falls back to a
39
- * detached, self-restarting child instead of failing. `CODEHOST_NO_OXMGR=1`
40
- * forces the fallback. Shared by `serve -d` and `setup`.
38
+ * oxmgr can't run here it falls back to a managed daemon instead of failing
39
+ * pm2 on Windows (hidden, restart + logon resurrect), a detached supervisor on
40
+ * POSIX. Windows skips oxmgr entirely (its native binary tends to hang/fail
41
+ * there) and goes straight to pm2. `CODEHOST_NO_OXMGR=1` forces the fallback on
42
+ * any platform. Shared by `serve -d` and `setup`.
41
43
  */
42
44
  export async function launchServeDaemon(opts: ServeDaemonOptions): Promise<ServeDaemonResult> {
43
45
  // Upgrade the global install (if that's how we're running) before spawning, so
@@ -49,7 +51,9 @@ export async function launchServeDaemon(opts: ServeDaemonOptions): Promise<Serve
49
51
  const name = daemonName(label);
50
52
  const argv = buildForegroundArgv(opts);
51
53
 
52
- if (process.env.CODEHOST_NO_OXMGR !== "1") {
54
+ // Windows skips oxmgr (its native binary hangs/fails there) and uses pm2.
55
+ const useOxmgr = process.env.CODEHOST_NO_OXMGR !== "1" && process.platform !== "win32";
56
+ if (useOxmgr) {
53
57
  console.log(`[codehost] starting daemon "${name}" via oxmgr`);
54
58
  // startDaemon attempts to self-heal oxmgr once; false means it's unusable here.
55
59
  const ok = await startDaemon({ name, command: argv.map(quote).join(" "), cwd: opts.dir });
@@ -57,12 +61,13 @@ export async function launchServeDaemon(opts: ServeDaemonOptions): Promise<Serve
57
61
  console.log(`[codehost] daemon started. View: codehost list · Stop: codehost stop ${name}`);
58
62
  return { ok: true, name };
59
63
  }
60
- console.warn("[codehost] oxmgr unavailable — falling back to a detached daemon (no login auto-start).");
64
+ console.warn("[codehost] oxmgr unavailable — falling back to a managed daemon.");
61
65
  }
62
66
 
67
+ console.log(`[codehost] starting daemon "${name}"…`);
63
68
  const ok = startFallbackDaemon({ name, argv, cwd: opts.dir });
64
69
  if (ok) {
65
- console.log(`[codehost] detached daemon "${name}" started. View: codehost list · Stop: codehost stop ${name}`);
70
+ console.log(`[codehost] daemon "${name}" started. View: codehost list · Stop: codehost stop ${name}`);
66
71
  } else {
67
72
  console.error("[codehost] failed to start a detached daemon.");
68
73
  }
@@ -4,15 +4,17 @@ import { mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
6
  import { killProcessTree } from "./proc";
7
+ import { hasPm2, pm2Delete, pm2Online, pm2Start } from "./pm2";
7
8
 
8
9
  // A non-oxmgr daemon manager: keeps a server running across the shell without
9
- // depending on oxmgr's flaky native binary. Two backends:
10
- // - Windows: a Scheduled Task (`schtasks`) — built in, always runnable, and it
11
- // also auto-starts the server at logon. Unref'd child processes don't
12
- // survive their launcher exiting on Windows, so a task is required.
13
- // - POSIX: a detached, unref'd supervisor child (reparents to init).
14
- // Both run the same `__supervise` loop (restart-on-failure) and read their serve
15
- // argv from this registry by name, so the task/launch command stays short.
10
+ // depending on oxmgr's flaky native binary. Backends, by platform:
11
+ // - Windows: pm2 (preferred) — restart-on-failure + logon resurrect, launched
12
+ // hidden so no console window appears (see pm2.ts). If pm2 isn't installed we
13
+ // fall back to a Scheduled Task (`schtasks`), which is always available and
14
+ // also auto-starts at logon. Unref'd children don't survive their launcher on
15
+ // Windows, so one of these managers is required.
16
+ // - POSIX: a detached, unref'd supervisor child (reparents to init), running the
17
+ // `__supervise` restart loop and reading its serve argv from this registry.
16
18
 
17
19
  const ROOT = join(homedir(), ".codehost");
18
20
  const REGISTRY = join(ROOT, "daemons.json");
@@ -29,7 +31,9 @@ export interface FallbackDaemon {
29
31
  startedAt: number;
30
32
  /** POSIX: supervisor process pid. */
31
33
  pid?: number;
32
- /** Windows: scheduled-task name (equals `name`). */
34
+ /** Windows (pm2 backend): pm2 process name (equals `name`). */
35
+ pm2?: string;
36
+ /** Windows (schtasks backend): scheduled-task name (equals `name`). */
33
37
  task?: string;
34
38
  /** Pid of the serve child the supervisor last spawned (for tree-kill on stop). */
35
39
  servePid?: number;
@@ -91,7 +95,18 @@ export function startFallbackDaemon(opts: { name: string; argv: string[]; cwd: s
91
95
  const log = join(LOG_DIR, `${opts.name}.log`);
92
96
  upsert({ name: opts.name, cwd: opts.cwd, argv: opts.argv, log, startedAt: Date.now() });
93
97
 
94
- return isWindows ? startWindowsTask(opts.name, log) : startUnixSupervisor(opts.name, log);
98
+ if (!isWindows) return startUnixSupervisor(opts.name, log);
99
+ // Windows: pm2 if available (hidden, restart + logon resurrect), else schtasks.
100
+ if (hasPm2() && startWindowsPm2(opts.name, opts.cwd, opts.argv, log)) return true;
101
+ return startWindowsTask(opts.name, log);
102
+ }
103
+
104
+ /** Windows pm2 backend: pm2 runs the serve argv directly (no supervisor loop —
105
+ * pm2 owns restart-on-failure), launched hidden. argv[0] is the bun runtime. */
106
+ function startWindowsPm2(name: string, cwd: string, argv: string[], log: string): boolean {
107
+ const ok = pm2Start({ name, cwd, script: argv[0], args: argv.slice(1), log });
108
+ if (ok) patch(name, { pm2: name });
109
+ return ok;
95
110
  }
96
111
 
97
112
  /** POSIX: detached, unref'd supervisor child that survives as an orphan. */
@@ -130,7 +145,9 @@ function startWindowsTask(name: string, log: string): boolean {
130
145
  * long as their task is still registered (a Ready task is a valid auto-start). */
131
146
  export function listFallbackDaemons(): FallbackDaemon[] {
132
147
  const list = readRegistry();
133
- const alive = list.filter((d) => (d.task ? taskExists(d.task) : d.pid != null && isAlive(d.pid)));
148
+ const alive = list.filter((d) =>
149
+ d.pm2 ? pm2Online(d.pm2) : d.task ? taskExists(d.task) : d.pid != null && isAlive(d.pid),
150
+ );
134
151
  if (alive.length !== list.length) writeRegistry(alive);
135
152
  return alive;
136
153
  }
@@ -140,7 +157,9 @@ export function stopFallbackDaemon(name: string): boolean {
140
157
  const list = readRegistry();
141
158
  const hit = list.find((d) => d.name === name);
142
159
  if (!hit) return false;
143
- if (hit.task) {
160
+ if (hit.pm2) {
161
+ pm2Delete(hit.pm2); // pm2 stops + removes the managed process (incl. its child tree)
162
+ } else if (hit.task) {
144
163
  schtasks(["/end", "/tn", hit.task]);
145
164
  schtasks(["/delete", "/tn", hit.task, "/f"]);
146
165
  // /end hard-terminates the task's top process; kill the serve subtree (VS
package/src/cli/index.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  #!/usr/bin/env bun
2
+ import "./tempenv"; // must be first: repair an unexpanded TEMP/TMP before any
3
+ // native dep (node-datachannel/bun-pty) or child process reads it.
2
4
  import yargs from "yargs";
3
5
  import { hideBin } from "yargs/helpers";
4
6
  import { setupCommand } from "./commands/setup";
package/src/cli/pm2.ts ADDED
@@ -0,0 +1,134 @@
1
+ import { type SpawnSyncOptions, spawnSync } from "node:child_process";
2
+ import { mkdirSync, writeFileSync } from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ // Windows daemon backend built on pm2 (https://pm2.keymetrics.io/). oxmgr's
8
+ // native binary tends to hang/fail on Windows, so `serve -d` / `setup` manage the
9
+ // foreground server with pm2 instead — it's a battle-tested, restart-on-failure
10
+ // process manager the user likely already runs.
11
+ //
12
+ // Two Windows-specific wrinkles this module handles:
13
+ // 1. pm2 is invoked via its resolved JS entry under the current runtime (bun) —
14
+ // never the bare `pm2` command, whose PATH shim has no .exe/.cmd extension
15
+ // so spawnSync can't resolve it (same trap as oxmgr).
16
+ // 2. Starting pm2 (which forks its God daemon + the managed child) is routed
17
+ // through a hidden VBScript launcher so no console window ever flashes. A
18
+ // plain spawn with windowsHide isn't enough — the detached daemon and its
19
+ // fork would still pop their own windows.
20
+
21
+ const require = createRequire(import.meta.url);
22
+ const ROOT = join(homedir(), ".codehost");
23
+ const TASK_DIR = join(ROOT, "tasks");
24
+
25
+ /** Resolve pm2's CLI entry from a local dep or the bun/npm global install. */
26
+ export function pm2Entry(): string | null {
27
+ const attempts: Array<() => string> = [
28
+ () => require.resolve("pm2/bin/pm2"),
29
+ () => createRequire(join(bunGlobalRoot(), "_")).resolve("pm2/bin/pm2"),
30
+ ];
31
+ for (const attempt of attempts) {
32
+ try {
33
+ return attempt();
34
+ } catch {
35
+ // try next
36
+ }
37
+ }
38
+ return null;
39
+ }
40
+
41
+ /** Where `bun add -g` drops global packages. */
42
+ function bunGlobalRoot(): string {
43
+ const base = process.env.BUN_INSTALL ?? join(homedir(), ".bun");
44
+ return join(base, "install", "global", "node_modules");
45
+ }
46
+
47
+ /** True if a runnable pm2 is resolvable (so the Windows fallback can use it). */
48
+ export function hasPm2(): boolean {
49
+ return pm2Entry() != null;
50
+ }
51
+
52
+ /** Run the pm2 CLI under the current runtime (bun). Window stays hidden. */
53
+ function pm2(args: string[], opts: SpawnSyncOptions = {}) {
54
+ const entry = pm2Entry();
55
+ if (!entry) return { status: 1, stdout: "", stderr: "" } as const;
56
+ return spawnSync(process.execPath, [entry, ...args], { encoding: "utf8", windowsHide: true, ...opts });
57
+ }
58
+
59
+ /**
60
+ * Start (replacing any same-named instance) `script args` under pm2 with logs
61
+ * redirected to `log`, launched hidden so no window appears, and persist the
62
+ * process list (`pm2 save`). Returns true once pm2 reports the process online.
63
+ *
64
+ * Reboot auto-start is intentionally left to pm2's own startup integration
65
+ * (`pm2 save` records the list for `pm2 resurrect`): the Startup folder is often
66
+ * blocked by Controlled Folder Access and `schtasks /create` needs elevation, so
67
+ * auto-installing a logon hook here can't be done reliably.
68
+ */
69
+ export function pm2Start(opts: { name: string; cwd: string; script: string; args: string[]; log: string }): boolean {
70
+ const entry = pm2Entry();
71
+ if (!entry) return false;
72
+
73
+ // `--interpreter none` execs the script (bun.exe) directly with the args after
74
+ // `--`, instead of trying to run it through node.
75
+ const start = [
76
+ process.execPath, entry, "start", opts.script,
77
+ "--name", opts.name,
78
+ "--cwd", opts.cwd,
79
+ "--interpreter", "none",
80
+ "--output", opts.log,
81
+ "--error", opts.log,
82
+ "--restart-delay", "2000",
83
+ "--", ...opts.args,
84
+ ];
85
+ const save = [process.execPath, entry, "save", "--force"];
86
+ runHidden([start, save], opts.name);
87
+
88
+ // pm2 start is synchronous (the hidden launcher waits), so liveness is the
89
+ // source of truth — more reliable than the launcher's exit code, which is the
90
+ // .cmd's last line.
91
+ return pm2Online(opts.name);
92
+ }
93
+
94
+ /** True if pm2 currently has `name` online. */
95
+ export function pm2Online(name: string): boolean {
96
+ const r = pm2(["jlist"]);
97
+ if (r.status !== 0 || !r.stdout) return false;
98
+ try {
99
+ const list = JSON.parse(String(r.stdout)) as Array<{ name: string; pm2_env?: { status?: string } }>;
100
+ return list.some((p) => p.name === name && p.pm2_env?.status === "online");
101
+ } catch {
102
+ return false;
103
+ }
104
+ }
105
+
106
+ /** Stop + deregister a pm2-managed daemon and re-save the list. */
107
+ export function pm2Delete(name: string): void {
108
+ pm2(["delete", name], { stdio: "ignore" });
109
+ pm2(["save", "--force"], { stdio: "ignore" });
110
+ }
111
+
112
+ /**
113
+ * Run one or more command argv's hidden, in order, waiting for completion. Writes
114
+ * a launcher .cmd (so we get normal Windows quoting, not VBS string escaping) and
115
+ * a .vbs that runs it with window style 0 via wscript (no console host at all).
116
+ */
117
+ function runHidden(commands: string[][], name: string): void {
118
+ mkdirSync(TASK_DIR, { recursive: true });
119
+ const cmdPath = join(TASK_DIR, `${name}.pm2.cmd`);
120
+ const vbsPath = join(TASK_DIR, `${name}.pm2.vbs`);
121
+ const body = ["@echo off", ...commands.map(quoteCmd)].join("\r\n") + "\r\n";
122
+ writeFileSync(cmdPath, body);
123
+ // Hidden launcher: wscript (no console host) runs the .cmd with window style 0
124
+ // and waits. The path is wrapped in literal quotes — in VBS source a `"` inside
125
+ // a string is written `""`, so a quoted path becomes `"""<path>"""`.
126
+ writeFileSync(vbsPath, `Set sh = CreateObject("WScript.Shell")\r\nsh.Run """${cmdPath}""", 0, True\r\n`);
127
+ spawnSync("wscript", ["//B", "//Nologo", vbsPath], { windowsHide: true });
128
+ }
129
+
130
+ /** Quote an argv into a single cmd.exe command line. Paths/tokens here never
131
+ * contain double quotes, so simple space-aware quoting is sufficient. */
132
+ function quoteCmd(argv: string[]): string {
133
+ return argv.map((a) => (/[\s&|<>^()]/.test(a) ? `"${a}"` : a)).join(" ");
134
+ }
@@ -56,6 +56,13 @@ export interface RunServerOptions {
56
56
  /** How often a daemon re-enumerates its workspaces (manual clones show up). */
57
57
  const META_REFRESH_MS = 60_000;
58
58
 
59
+ /** Floor between meta pushes to the room. `serve` re-evaluates meta every ~3s so
60
+ * live agent titles stay fresh, but each push is a billable DO request that
61
+ * fans out to every peer — so coalesce bursts (a churning agent title) into at
62
+ * most one push per this interval. The first change in a quiet period goes out
63
+ * immediately; rapid follow-ups ride a single trailing push. */
64
+ const MIN_META_PUSH_MS = 15_000;
65
+
59
66
  /**
60
67
  * Foreground server loop shared by `serve`, `dev`, and `expose`: register in the
61
68
  * signaling room with the given meta and bridge each client's data channel to a
@@ -113,15 +120,36 @@ export async function runServer(opts: RunServerOptions): Promise<never> {
113
120
  onSignal: (from, data) => rtc.handleSignal(from, data),
114
121
  });
115
122
 
116
- // Re-advertise when the workspace set changes (provision, manual clone).
117
- let lastMeta = JSON.stringify(opts.meta);
123
+ // Re-advertise when the workspace set changes (provision, manual clone),
124
+ // throttled so a burst of changes is at most one room push per MIN_META_PUSH_MS.
125
+ let sentMeta = JSON.stringify(opts.meta); // last meta the room actually has
126
+ let lastPushAt = 0; // ms of the last updateMeta send (0 = none since connect)
127
+ let pushTimer: ReturnType<typeof setTimeout> | null = null;
128
+ const sendMeta = (meta: PeerMeta) => {
129
+ const s = JSON.stringify(meta);
130
+ if (s === sentMeta) return; // nothing new since the last push
131
+ sentMeta = s;
132
+ lastPushAt = Date.now();
133
+ client.updateMeta(meta);
134
+ };
118
135
  const refreshMeta = () => {
119
136
  if (!opts.refreshMeta) return;
120
137
  const meta = opts.refreshMeta();
121
- const s = JSON.stringify(meta);
122
- if (s === lastMeta) return;
123
- lastMeta = s;
124
- client.updateMeta(meta);
138
+ if (JSON.stringify(meta) === sentMeta) return; // unchanged — nothing to do
139
+ const wait = MIN_META_PUSH_MS - (Date.now() - lastPushAt);
140
+ if (wait <= 0) {
141
+ // Cooldown elapsed: push the leading change now, dropping any pending one.
142
+ if (pushTimer) clearTimeout(pushTimer);
143
+ pushTimer = null;
144
+ sendMeta(meta);
145
+ } else if (!pushTimer) {
146
+ // Within the cooldown: schedule one trailing push that re-reads the
147
+ // freshest meta when it fires, so coalesced changes all ship at once.
148
+ pushTimer = setTimeout(() => {
149
+ pushTimer = null;
150
+ sendMeta(opts.refreshMeta!());
151
+ }, wait);
152
+ }
125
153
  };
126
154
  const provision: ProvisionDeps | undefined = opts.provision
127
155
  ? { ...opts.provision, onProvisioned: refreshMeta }
@@ -0,0 +1,32 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { expandWinVars, normalizeTempEnv } from "./tempenv";
3
+
4
+ describe("expandWinVars", () => {
5
+ test("expands a known %VAR% from the given env", () => {
6
+ expect(expandWinVars("%USERPROFILE%\\AppData\\Local\\Temp", { USERPROFILE: "C:\\Users\\x" })).toBe(
7
+ "C:\\Users\\x\\AppData\\Local\\Temp",
8
+ );
9
+ });
10
+
11
+ test("leaves unknown vars untouched", () => {
12
+ expect(expandWinVars("%NOPE%\\a", {})).toBe("%NOPE%\\a");
13
+ });
14
+
15
+ test("passes through a path with no vars", () => {
16
+ expect(expandWinVars("C:\\Users\\x\\AppData\\Local\\Temp", {})).toBe("C:\\Users\\x\\AppData\\Local\\Temp");
17
+ });
18
+ });
19
+
20
+ describe("normalizeTempEnv", () => {
21
+ test("rewrites a literal %USERPROFILE% TEMP to a real path (win32)", () => {
22
+ if (process.platform !== "win32") return; // no-op off Windows
23
+ const saved = process.env.TEMP;
24
+ try {
25
+ process.env.TEMP = "%USERPROFILE%\\AppData\\Local\\Temp";
26
+ normalizeTempEnv();
27
+ expect(process.env.TEMP).not.toContain("%");
28
+ } finally {
29
+ process.env.TEMP = saved;
30
+ }
31
+ });
32
+ });
@@ -0,0 +1,53 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ // Importing this module repairs a broken TEMP/TMP as a side effect, as early as
6
+ // possible — before any native dep (node-datachannel/bun-pty) or child process
7
+ // reads it. See normalizeTempEnv for the why.
8
+
9
+ /** Expand `%VAR%` references in `value` against `env`; unknown vars are left
10
+ * as-is. (`%VAR%` is cmd.exe syntax — bun/node/bash never expand it.) */
11
+ export function expandWinVars(value: string, env: NodeJS.ProcessEnv = process.env): string {
12
+ return value.replace(/%([^%]+)%/g, (whole, name) => env[name] ?? whole);
13
+ }
14
+
15
+ /**
16
+ * Repair an unexpanded TEMP/TMP on Windows. Windows stores them in the registry
17
+ * as REG_EXPAND_SZ — literally `%USERPROFILE%\AppData\Local\Temp` — and some
18
+ * launch contexts (services, scheduled tasks, a parent started by one) pass that
19
+ * string through without expanding it. bun/node/bash don't expand `%VAR%`, so
20
+ * every temp write then resolves *relative to cwd*, littering the working dir
21
+ * with a `%USERPROFILE%/AppData/Local/Temp` tree (we hit ~1GB of this inside
22
+ * provisioned worktrees). Expand it in-process so the server, its native deps,
23
+ * and every spawned child get a real temp dir. Idempotent; no-op off Windows.
24
+ */
25
+ export function normalizeTempEnv(): void {
26
+ if (process.platform !== "win32") return;
27
+ for (const key of ["TEMP", "TMP"]) {
28
+ const raw = process.env[key];
29
+ if (!raw || !raw.includes("%")) continue; // already a real path
30
+ const expanded = expandWinVars(raw);
31
+ // If any var was still unresolved, or the dir can't be created, fall back to
32
+ // the canonical per-user temp under the (always-expanded) home dir.
33
+ const fixed = expanded.includes("%") || !ensureDir(expanded) ? fallbackTemp() : expanded;
34
+ process.env[key] = fixed;
35
+ }
36
+ }
37
+
38
+ function fallbackTemp(): string {
39
+ const dir = join(homedir(), "AppData", "Local", "Temp");
40
+ ensureDir(dir);
41
+ return dir;
42
+ }
43
+
44
+ function ensureDir(path: string): boolean {
45
+ try {
46
+ mkdirSync(path, { recursive: true });
47
+ return true;
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ normalizeTempEnv();