@sorenllm/opencode-forge 0.3.0 → 0.3.1

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 (3) hide show
  1. package/README.md +419 -373
  2. package/dist/index.js +834 -99
  3. package/package.json +61 -61
package/dist/index.js CHANGED
@@ -12334,9 +12334,9 @@ function tool(input) {
12334
12334
  }
12335
12335
  tool.schema = exports_external;
12336
12336
  // plugin.ts
12337
- import { execFileSync as execFileSync2 } from "node:child_process";
12338
- import { appendFileSync as appendFileSync2, existsSync as existsSync2, mkdirSync as mkdirSync3, readdirSync as readdirSync3, readFileSync as readFileSync5, statSync as statSync2, writeFileSync as writeFileSync3 } from "node:fs";
12339
- import { isAbsolute, join as join3, relative } from "node:path";
12337
+ import { execFileSync as execFileSync3 } from "node:child_process";
12338
+ import { appendFileSync, existsSync as existsSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync4, readFileSync as readFileSync6, statSync as statSync3, writeFileSync as writeFileSync4 } from "node:fs";
12339
+ import { isAbsolute, join as join4, relative } from "node:path";
12340
12340
  import { tmpdir as tmpdir2 } from "node:os";
12341
12341
 
12342
12342
  // src/plan-file.ts
@@ -13121,20 +13121,21 @@ import { readFileSync } from "node:fs";
13121
13121
  import { join, resolve, sep } from "node:path";
13122
13122
 
13123
13123
  // src/proc.ts
13124
- import { spawn } from "node:child_process";
13124
+ import { spawn, spawnSync } from "node:child_process";
13125
13125
  function shellSpawn(spawnFn, cmd, opts = {}) {
13126
13126
  return spawnFn(cmd, {
13127
13127
  shell: true,
13128
13128
  ...opts.cwd !== undefined ? { cwd: opts.cwd } : {},
13129
13129
  env: opts.env ? { ...process.env, ...opts.env } : process.env,
13130
13130
  windowsHide: opts.windowsHide ?? true,
13131
- detached: opts.detached ?? process.platform !== "win32"
13131
+ detached: opts.detached ?? process.platform !== "win32",
13132
+ ...opts.stdio !== undefined ? { stdio: opts.stdio } : {}
13132
13133
  });
13133
13134
  }
13134
- function treeKillPlan(platform, pid) {
13135
+ function treeKillPlan(platform, pid, force = true) {
13135
13136
  if (platform === "win32")
13136
- return { kind: "taskkill", args: ["/pid", String(pid), "/F", "/T"] };
13137
- return { kind: "group", signal: "SIGKILL" };
13137
+ return { kind: "taskkill", args: force ? ["/pid", String(pid), "/F", "/T"] : ["/pid", String(pid), "/T"] };
13138
+ return { kind: "group", signal: force ? "SIGKILL" : "SIGTERM" };
13138
13139
  }
13139
13140
  function killTree(child, opts = {}) {
13140
13141
  const platform = opts.platform ?? process.platform;
@@ -13157,6 +13158,49 @@ function killTree(child, opts = {}) {
13157
13158
  }
13158
13159
  }
13159
13160
  }
13161
+ function pidAlive(pid) {
13162
+ if (!pid || pid <= 0)
13163
+ return false;
13164
+ try {
13165
+ process.kill(pid, 0);
13166
+ return true;
13167
+ } catch (err) {
13168
+ return err.code === "EPERM";
13169
+ }
13170
+ }
13171
+ function terminateTreeSync(pid, opts = {}) {
13172
+ const graceMs = Math.max(0, opts.graceMs ?? 0);
13173
+ const platform = opts.platform ?? process.platform;
13174
+ const sync = opts.spawnSyncFn ?? spawnSync;
13175
+ const wait = opts.wait ?? true;
13176
+ const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
13177
+ const run = (force) => {
13178
+ const plan = treeKillPlan(platform, pid, force);
13179
+ if (plan.kind === "taskkill") {
13180
+ try {
13181
+ sync("taskkill", plan.args, { windowsHide: true, stdio: "ignore", timeout: 1e4 });
13182
+ } catch {}
13183
+ } else {
13184
+ try {
13185
+ process.kill(-pid, plan.signal);
13186
+ } catch {
13187
+ try {
13188
+ process.kill(pid, plan.signal);
13189
+ } catch {}
13190
+ }
13191
+ }
13192
+ };
13193
+ if (graceMs > 0) {
13194
+ run(false);
13195
+ if (wait) {
13196
+ const deadline = Date.now() + graceMs;
13197
+ while (Date.now() < deadline && pidAlive(pid))
13198
+ sleep(Math.min(100, deadline - Date.now()));
13199
+ }
13200
+ }
13201
+ run(true);
13202
+ return { graceful: graceMs > 0, forced: true };
13203
+ }
13160
13204
 
13161
13205
  // src/run-check.ts
13162
13206
  var OUTPUT_LIMIT = 2048;
@@ -13349,6 +13393,9 @@ function createJobManager(opts = {}) {
13349
13393
  enforceCaps();
13350
13394
  return job;
13351
13395
  }
13396
+ function setPid(job, pid) {
13397
+ job.pid = pid;
13398
+ }
13352
13399
  function get(id) {
13353
13400
  return jobs.get(id);
13354
13401
  }
@@ -13423,6 +13470,8 @@ function createJobManager(opts = {}) {
13423
13470
  for (const job of [...jobs.values()]) {
13424
13471
  if (job.ownerSession !== sessionID)
13425
13472
  continue;
13473
+ if (job.survive)
13474
+ continue;
13426
13475
  if (!isTerminal2(job)) {
13427
13476
  if (job.scope === "global")
13428
13477
  continue;
@@ -13436,6 +13485,8 @@ function createJobManager(opts = {}) {
13436
13485
  }
13437
13486
  function disposeAll() {
13438
13487
  for (const job of [...jobs.values()]) {
13488
+ if (job.survive)
13489
+ continue;
13439
13490
  if (!isTerminal2(job)) {
13440
13491
  kill(job);
13441
13492
  emit("orphan-job", job, "plugin dispose; tree killed");
@@ -13468,6 +13519,7 @@ function createJobManager(opts = {}) {
13468
13519
  }
13469
13520
  return {
13470
13521
  create,
13522
+ setPid,
13471
13523
  get,
13472
13524
  list,
13473
13525
  appendOutput,
@@ -13493,7 +13545,7 @@ function newJobId(now = Date.now) {
13493
13545
 
13494
13546
  // src/job-runner.ts
13495
13547
  import { spawn as spawn3 } from "node:child_process";
13496
- import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync as readFileSync2, statSync, unlinkSync } from "node:fs";
13548
+ import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readSync, readFileSync as readFileSync2, statSync, unlinkSync } from "node:fs";
13497
13549
  import { join as join2 } from "node:path";
13498
13550
  import { tmpdir } from "node:os";
13499
13551
  var DEFAULT_IDLE_MS = 60000;
@@ -13502,9 +13554,61 @@ var HARD_MAX_WAIT_MS = 600000;
13502
13554
  var POLL_WAIT_MAX_MS = 30000;
13503
13555
  var DEFAULT_LOG_KEEP = 50;
13504
13556
  var JOB_ENV_MARKER = "FORGE_JOB_ID";
13557
+ var TAIL_POLL_MS = 200;
13558
+ var ADOPT_LIVENESS_MS = 1000;
13505
13559
  function jobsLogDir(base) {
13506
13560
  return join2(base ?? join2(tmpdir(), "opencode-forge"), "jobs");
13507
13561
  }
13562
+ var TAIL_CHUNK_BYTES = 4 * 1024 * 1024;
13563
+ function createFileTail(logPath, onText, pollMs = TAIL_POLL_MS) {
13564
+ let pos = existsSync(logPath) ? statSync(logPath).size : 0;
13565
+ let stopped = false;
13566
+ const flush = () => {
13567
+ if (stopped)
13568
+ return;
13569
+ try {
13570
+ let size = existsSync(logPath) ? statSync(logPath).size : 0;
13571
+ if (size < pos)
13572
+ pos = size;
13573
+ const fd = openSync(logPath, "r");
13574
+ try {
13575
+ const buf = Buffer.allocUnsafe(TAIL_CHUNK_BYTES);
13576
+ while (pos < size) {
13577
+ const want = Math.min(TAIL_CHUNK_BYTES, size - pos);
13578
+ let read = 0;
13579
+ while (read < want) {
13580
+ const n = readSync(fd, buf, read, want - read, pos + read);
13581
+ if (n <= 0)
13582
+ break;
13583
+ read += n;
13584
+ }
13585
+ if (read <= 0)
13586
+ break;
13587
+ pos += read;
13588
+ onText(buf.toString("utf8", 0, read));
13589
+ size = existsSync(logPath) ? statSync(logPath).size : size;
13590
+ if (stopped)
13591
+ break;
13592
+ }
13593
+ } finally {
13594
+ closeSync(fd);
13595
+ }
13596
+ } catch {}
13597
+ };
13598
+ const timer = setInterval(flush, pollMs);
13599
+ timer.unref?.();
13600
+ return {
13601
+ flush,
13602
+ stop() {
13603
+ stopped = true;
13604
+ clearInterval(timer);
13605
+ }
13606
+ };
13607
+ }
13608
+ var liveTails = new Map;
13609
+ function flushJobOutput(job) {
13610
+ liveTails.get(job.id)?.flush();
13611
+ }
13508
13612
  function startJob(manager, opts) {
13509
13613
  const idleMs = Math.max(0, opts.idleMs ?? DEFAULT_IDLE_MS);
13510
13614
  const maxWaitMs = Math.min(Math.max(0, opts.maxWaitMs ?? DEFAULT_MAX_WAIT_MS), HARD_MAX_WAIT_MS);
@@ -13535,6 +13639,7 @@ function startJob(manager, opts) {
13535
13639
  idleTimer = null;
13536
13640
  clearTimeout(maxWaitTimer);
13537
13641
  };
13642
+ const firstOutputHooks = [];
13538
13643
  function resolveStillRunning() {
13539
13644
  if (settled)
13540
13645
  return;
@@ -13549,25 +13654,67 @@ function startJob(manager, opts) {
13549
13654
  ownerSession: opts.ownerSession,
13550
13655
  logPath,
13551
13656
  notify: opts.notify ?? true,
13552
- killTree: () => killTree(child)
13657
+ killTree: () => {
13658
+ killTree(child);
13659
+ if (opts.survive)
13660
+ opts.registry?.remove(id);
13661
+ },
13662
+ ...opts.survive ? { survive: true } : {}
13553
13663
  });
13664
+ let wfd;
13554
13665
  try {
13666
+ wfd = openSync(logPath, "a");
13555
13667
  child = shellSpawn(opts.spawnFn ?? spawn3, opts.cmd, {
13556
13668
  cwd: opts.cwd,
13557
- env: { ...opts.env ?? {}, [JOB_ENV_MARKER]: id }
13669
+ env: { ...opts.env ?? {}, [JOB_ENV_MARKER]: id },
13670
+ stdio: ["ignore", wfd, wfd]
13558
13671
  });
13559
13672
  } catch (err) {
13673
+ if (wfd !== undefined) {
13674
+ try {
13675
+ closeSync(wfd);
13676
+ } catch {}
13677
+ }
13560
13678
  manager.markTerminal(job, "killed", null);
13561
13679
  maxWaitTimer && clearTimeout(maxWaitTimer);
13562
13680
  resolveSettle({ status: "exited", exitCode: null, outputTail: "", spawnError: String(err) });
13563
13681
  return { job, settle };
13564
13682
  }
13565
- const onChunk = (d) => {
13566
- const text = String(d);
13567
- manager.appendOutput(job, text);
13683
+ if (wfd !== undefined) {
13568
13684
  try {
13569
- appendFileSync(logPath, text);
13685
+ closeSync(wfd);
13570
13686
  } catch {}
13687
+ }
13688
+ manager.setPid(job, child.pid ?? 0);
13689
+ if (opts.survive) {
13690
+ opts.registry?.add({ id, pid: child.pid ?? 0, cmd: opts.cmd, logPath, startedAt: job.startedAt, ownerSession: opts.ownerSession, hostPid: process.pid });
13691
+ } else if (opts.fence) {
13692
+ opts.fence.assign(child.pid ?? 0);
13693
+ let reinforced = false;
13694
+ const reinforce = () => {
13695
+ if (reinforced)
13696
+ return;
13697
+ (async () => {
13698
+ const kid = await opts.relocateAsync?.(child.pid ?? 0) ?? opts.relocate?.(child.pid ?? 0) ?? null;
13699
+ if (kid !== null && kid > 0) {
13700
+ reinforced = true;
13701
+ opts.fence?.assign(kid);
13702
+ manager.setPid(job, kid);
13703
+ }
13704
+ })();
13705
+ };
13706
+ for (const delay of [50, 400, 1500]) {
13707
+ const t = setTimeout(reinforce, delay);
13708
+ t.unref?.();
13709
+ }
13710
+ firstOutputHooks.push(reinforce);
13711
+ }
13712
+ const onChunk = (text) => {
13713
+ if (firstOutputHooks.length > 0) {
13714
+ for (const hook of firstOutputHooks.splice(0))
13715
+ hook();
13716
+ }
13717
+ manager.appendOutput(job, text);
13571
13718
  if (opts.successPattern && !settled && job.succeededAt === null) {
13572
13719
  const m = opts.successPattern.exec(text) ?? opts.successPattern.exec(job.tail);
13573
13720
  if (m) {
@@ -13577,14 +13724,16 @@ function startJob(manager, opts) {
13577
13724
  const keptAlive = opts.keepAlive !== false;
13578
13725
  if (!keptAlive) {
13579
13726
  killTree(child);
13727
+ if (opts.survive)
13728
+ opts.registry?.remove(id);
13580
13729
  manager.markTerminal(job, "succeeded", null);
13581
13730
  }
13582
13731
  resolveSettle({ status: "succeeded", matched: m[0], outputTail: job.tail, keptAlive });
13583
13732
  }
13584
13733
  }
13585
13734
  };
13586
- child.stdout?.on("data", onChunk);
13587
- child.stderr?.on("data", onChunk);
13735
+ const tail = createFileTail(logPath, onChunk);
13736
+ liveTails.set(id, tail);
13588
13737
  child.on("exit", (code) => {
13589
13738
  if (exiting)
13590
13739
  return;
@@ -13595,7 +13744,12 @@ function startJob(manager, opts) {
13595
13744
  if (finished)
13596
13745
  return;
13597
13746
  finished = true;
13747
+ tail.flush();
13748
+ tail.stop();
13749
+ liveTails.delete(id);
13598
13750
  manager.markTerminal(job, "exited", code);
13751
+ if (opts.survive)
13752
+ opts.registry?.remove(id);
13599
13753
  rotateLogs(opts.logDir);
13600
13754
  if (!settled) {
13601
13755
  settled = true;
@@ -13604,22 +13758,12 @@ function startJob(manager, opts) {
13604
13758
  };
13605
13759
  const graceTimer = setTimeout(finish, opts.exitGraceMs ?? 500);
13606
13760
  graceTimer.unref?.();
13607
- const streams = [];
13608
- if (child.stdout)
13609
- streams.push(child.stdout);
13610
- if (child.stderr)
13611
- streams.push(child.stderr);
13612
- let left = streams.length;
13613
- if (left === 0)
13614
- finish();
13615
- else
13616
- for (const s of streams)
13617
- s.once("close", () => {
13618
- if (--left === 0)
13619
- finish();
13620
- });
13621
13761
  });
13622
13762
  child.on("error", (err) => {
13763
+ tail.stop();
13764
+ liveTails.delete(id);
13765
+ if (opts.survive)
13766
+ opts.registry?.remove(id);
13623
13767
  if (!settled) {
13624
13768
  settled = true;
13625
13769
  stopTimers();
@@ -13641,14 +13785,81 @@ async function pollJob(manager, jobId, waitMs = 0) {
13641
13785
  const deadline = Date.now() + Math.min(Math.max(0, waitMs), POLL_WAIT_MAX_MS);
13642
13786
  while (job.outLen === job.pollCursor && job.state === "running" && Date.now() < deadline) {
13643
13787
  await new Promise((r) => setTimeout(r, 25));
13788
+ flushJobOutput(job);
13644
13789
  }
13790
+ flushJobOutput(job);
13645
13791
  return manager.poll(job);
13646
13792
  }
13793
+ function adoptSurvivor(manager, entry, opts) {
13794
+ const kill = () => {
13795
+ let pid = entry.pid;
13796
+ for (let hop = 0;hop < 3 && !pidAlive(pid); hop++) {
13797
+ const kid = opts.relocate?.(pid) ?? null;
13798
+ if (kid === null || kid <= 0)
13799
+ break;
13800
+ pid = kid;
13801
+ }
13802
+ killTree({ pid, kill: (sig) => process.kill(pid, sig) });
13803
+ opts.registry.remove(entry.id);
13804
+ };
13805
+ const job = manager.create({
13806
+ id: entry.id,
13807
+ cmd: entry.cmd,
13808
+ worktree: opts.logDir,
13809
+ ownerSession: entry.ownerSession,
13810
+ logPath: entry.logPath,
13811
+ notify: false,
13812
+ killTree: kill,
13813
+ survive: true,
13814
+ previousRun: true
13815
+ });
13816
+ manager.setPid(job, entry.pid);
13817
+ const tail = createFileTail(entry.logPath, (t) => manager.appendOutput(job, t));
13818
+ liveTails.set(job.id, tail);
13819
+ const watcher = setInterval(() => {
13820
+ if (!pidAlive(entry.pid)) {
13821
+ clearInterval(watcher);
13822
+ tail.flush();
13823
+ tail.stop();
13824
+ liveTails.delete(job.id);
13825
+ opts.registry.remove(entry.id);
13826
+ manager.markTerminal(job, "exited", null);
13827
+ rotateLogs(opts.logDir);
13828
+ }
13829
+ }, ADOPT_LIVENESS_MS);
13830
+ watcher.unref?.();
13831
+ return job;
13832
+ }
13833
+ var LOG_READ_MAX_BYTES = 8 * 1024 * 1024;
13647
13834
  function readJobLog(logPath, opts = {}) {
13648
13835
  const limit = Math.max(1, opts.limit ?? 200);
13649
13836
  let raw = "";
13837
+ let windowed = false;
13650
13838
  try {
13651
- raw = existsSync(logPath) ? readFileSync2(logPath, "utf8") : "";
13839
+ if (existsSync(logPath)) {
13840
+ const size = statSync(logPath).size;
13841
+ if (size > LOG_READ_MAX_BYTES) {
13842
+ const fd = openSync(logPath, "r");
13843
+ try {
13844
+ const buf = Buffer.allocUnsafe(LOG_READ_MAX_BYTES);
13845
+ let read = 0;
13846
+ while (read < LOG_READ_MAX_BYTES) {
13847
+ const n = readSync(fd, buf, read, LOG_READ_MAX_BYTES - read, size - LOG_READ_MAX_BYTES + read);
13848
+ if (n <= 0)
13849
+ break;
13850
+ read += n;
13851
+ }
13852
+ const text = buf.toString("utf8", 0, read);
13853
+ raw = text.slice(text.indexOf(`
13854
+ `) + 1);
13855
+ windowed = true;
13856
+ } finally {
13857
+ closeSync(fd);
13858
+ }
13859
+ } else {
13860
+ raw = readFileSync2(logPath, "utf8");
13861
+ }
13862
+ }
13652
13863
  } catch {
13653
13864
  raw = "";
13654
13865
  }
@@ -13657,7 +13868,7 @@ function readJobLog(logPath, opts = {}) {
13657
13868
  lines.pop();
13658
13869
  const total = lines.length;
13659
13870
  const offset = opts.offset !== undefined ? Math.max(0, Math.min(opts.offset, total)) : Math.max(0, total - limit);
13660
- return { lines: lines.slice(offset, offset + limit), total, offset };
13871
+ return { lines: lines.slice(offset, offset + limit), total, offset, ...windowed ? { windowed: true } : {} };
13661
13872
  }
13662
13873
  function rotateLogs(logDir, keep = DEFAULT_LOG_KEEP) {
13663
13874
  let entries = [];
@@ -13683,9 +13894,467 @@ function rotateLogs(logDir, keep = DEFAULT_LOG_KEEP) {
13683
13894
  }
13684
13895
  }
13685
13896
 
13897
+ // src/job-fence.ts
13898
+ import { spawn as spawn4 } from "node:child_process";
13899
+ var FENCE_PS_SCRIPT = String.raw`
13900
+ Add-Type -TypeDefinition @"
13901
+ using System;
13902
+ using System.Collections.Generic;
13903
+ using System.IO;
13904
+ using System.Runtime.InteropServices;
13905
+ using System.Threading;
13906
+ public static class ForgeJobFence {
13907
+ [StructLayout(LayoutKind.Sequential)]
13908
+ public struct BASIC_LIMITS {
13909
+ public long PerProcessUserTimeLimit;
13910
+ public long PerJobUserTimeLimit;
13911
+ public uint LimitFlags;
13912
+ public UIntPtr MinimumWorkingSetSize;
13913
+ public UIntPtr MaximumWorkingSetSize;
13914
+ public uint ActiveProcessLimit;
13915
+ public UIntPtr Affinity;
13916
+ public uint PriorityClass;
13917
+ public uint SchedulingClass;
13918
+ }
13919
+ [StructLayout(LayoutKind.Sequential)]
13920
+ public struct IO_COUNTERS {
13921
+ public ulong ReadOperationCount;
13922
+ public ulong WriteOperationCount;
13923
+ public ulong OtherOperationCount;
13924
+ public ulong ReadTransferCount;
13925
+ public ulong WriteTransferCount;
13926
+ public ulong OtherTransferCount;
13927
+ }
13928
+ [StructLayout(LayoutKind.Sequential)]
13929
+ public struct EXTENDED_LIMITS {
13930
+ public BASIC_LIMITS Basic;
13931
+ public IO_COUNTERS IoInfo;
13932
+ public UIntPtr ProcessMemoryLimit;
13933
+ public UIntPtr JobMemoryLimit;
13934
+ public UIntPtr PeakProcessMemoryUsed;
13935
+ public UIntPtr PeakJobMemoryUsed;
13936
+ }
13937
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
13938
+ public struct PE32 {
13939
+ public uint dwSize;
13940
+ public uint cntUsage;
13941
+ public uint th32ProcessID;
13942
+ public IntPtr th32DefaultHeapID;
13943
+ public uint th32ModuleID;
13944
+ public uint cntThreads;
13945
+ public uint th32ParentProcessID;
13946
+ public int pcPriClassBase;
13947
+ public uint dwFlags;
13948
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)]
13949
+ public string szExeFile;
13950
+ }
13951
+ [DllImport("kernel32.dll", SetLastError = true)]
13952
+ static extern IntPtr CreateJobObject(IntPtr a, string n);
13953
+ [DllImport("kernel32.dll", SetLastError = true)]
13954
+ static extern bool SetInformationJobObject(IntPtr hJob, int infoClass, IntPtr lpInfo, int cbInfo);
13955
+ [DllImport("kernel32.dll", SetLastError = true)]
13956
+ static extern bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess);
13957
+ [DllImport("kernel32.dll")]
13958
+ static extern IntPtr OpenProcess(uint access, bool inherit, int pid);
13959
+ [DllImport("kernel32.dll")]
13960
+ static extern bool CloseHandle(IntPtr h);
13961
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
13962
+ static extern IntPtr CreateToolhelp32Snapshot(uint flags, uint pid);
13963
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
13964
+ static extern bool Process32FirstW(IntPtr h, ref PE32 e);
13965
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
13966
+ static extern bool Process32NextW(IntPtr h, ref PE32 e);
13967
+
13968
+ const int EXTENDED_LIMITS_CLASS = 9;
13969
+ const uint KILL_ON_JOB_CLOSE = 0x2000;
13970
+ const uint PROCESS_ALL_ACCESS = 0x1FFFFF;
13971
+ const uint SNAP_PROCESS = 0x2;
13972
+ // Descendant walk bound: deep enough for wrapper -> command -> children,
13973
+ // shallow enough that a recycled ancestor pid cannot pull unrelated
13974
+ // processes into the kill-on-close job.
13975
+ const int MAX_DEPTH = 4;
13976
+
13977
+ static IntPtr _job = IntPtr.Zero;
13978
+ static readonly HashSet<uint> _roots = new HashSet<uint>();
13979
+ static readonly HashSet<uint> _fenced = new HashSet<uint>();
13980
+ static readonly object _gate = new object();
13981
+ static Timer _sweep;
13982
+
13983
+ static void Sweep(object state) {
13984
+ try {
13985
+ var parents = new Dictionary<uint, uint>();
13986
+ var h = CreateToolhelp32Snapshot(SNAP_PROCESS, 0);
13987
+ if (h != IntPtr.Zero && h != new IntPtr(-1)) {
13988
+ var e = new PE32();
13989
+ e.dwSize = (uint)Marshal.SizeOf(typeof(PE32));
13990
+ if (Process32FirstW(h, ref e)) {
13991
+ do { parents[e.th32ProcessID] = e.th32ParentProcessID; } while (Process32NextW(h, ref e));
13992
+ }
13993
+ CloseHandle(h);
13994
+ }
13995
+ lock (_gate) {
13996
+ if (_job == IntPtr.Zero) return;
13997
+ foreach (var kv in parents) {
13998
+ if (_fenced.Contains(kv.Key)) continue;
13999
+ uint p = kv.Key;
14000
+ int depth = 0;
14001
+ bool tracked = false;
14002
+ while (p != 0 && depth <= MAX_DEPTH) {
14003
+ if (_roots.Contains(p)) { tracked = true; break; }
14004
+ uint up;
14005
+ if (parents.TryGetValue(p, out up)) { p = up; } else { p = 0; }
14006
+ depth++;
14007
+ }
14008
+ if (!tracked) continue;
14009
+ var ph = OpenProcess(PROCESS_ALL_ACCESS, false, (int)kv.Key);
14010
+ if (ph != IntPtr.Zero) {
14011
+ if (AssignProcessToJobObject(_job, ph)) _fenced.Add(kv.Key);
14012
+ CloseHandle(ph);
14013
+ }
14014
+ }
14015
+ }
14016
+ } catch { }
14017
+ }
14018
+
14019
+ public static int Run() {
14020
+ _job = CreateJobObject(IntPtr.Zero, null);
14021
+ if (_job == IntPtr.Zero) return 2;
14022
+ var info = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(EXTENDED_LIMITS)));
14023
+ try {
14024
+ Marshal.WriteInt32(info, 16, (int)KILL_ON_JOB_CLOSE); // Basic.LimitFlags offset
14025
+ if (!SetInformationJobObject(_job, EXTENDED_LIMITS_CLASS, info, Marshal.SizeOf(typeof(EXTENDED_LIMITS)))) return 3;
14026
+ } finally {
14027
+ Marshal.FreeHGlobal(info);
14028
+ }
14029
+ _sweep = new Timer(Sweep, null, 0, 400);
14030
+ string line;
14031
+ while ((line = Console.In.ReadLine()) != null) {
14032
+ uint pid;
14033
+ var t = line.Trim();
14034
+ if (t.Length > 0 && uint.TryParse(t, out pid) && pid > 0) {
14035
+ lock (_gate) { _roots.Add(pid); }
14036
+ var ph = OpenProcess(PROCESS_ALL_ACCESS, false, (int)pid);
14037
+ if (ph != IntPtr.Zero) {
14038
+ if (AssignProcessToJobObject(_job, ph)) {
14039
+ lock (_gate) { _fenced.Add(pid); }
14040
+ }
14041
+ CloseHandle(ph);
14042
+ }
14043
+ }
14044
+ }
14045
+ return 0; // stdin EOF: the host is gone -> exit -> handle closes -> kernel kills
14046
+ }
14047
+ }
14048
+ "@
14049
+ [ForgeJobFence]::Run()
14050
+ exit $LASTEXITCODE
14051
+ `;
14052
+ function createJobFence(opts = {}) {
14053
+ const platform = opts.platform ?? process.platform;
14054
+ if (platform !== "win32")
14055
+ return null;
14056
+ const spawnFn = opts.spawnFn ?? spawn4;
14057
+ let child;
14058
+ try {
14059
+ child = spawnFn("powershell", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", FENCE_PS_SCRIPT], {
14060
+ windowsHide: true,
14061
+ stdio: ["pipe", "ignore", "ignore"]
14062
+ });
14063
+ } catch (err) {
14064
+ opts.onDegrade?.(`job fence not started: spawn failed (${String(err).slice(0, 120)})`);
14065
+ return null;
14066
+ }
14067
+ const stdin = child.stdin;
14068
+ if (!stdin) {
14069
+ opts.onDegrade?.("job fence not started: watcher has no stdin");
14070
+ return null;
14071
+ }
14072
+ let healthy = true;
14073
+ child.on("exit", () => {
14074
+ healthy = false;
14075
+ });
14076
+ child.on("error", () => {
14077
+ healthy = false;
14078
+ });
14079
+ let reported = false;
14080
+ const fail = (why) => {
14081
+ healthy = false;
14082
+ if (reported)
14083
+ return;
14084
+ reported = true;
14085
+ opts.onDegrade?.(why);
14086
+ };
14087
+ return {
14088
+ assign(pid) {
14089
+ if (!healthy || !stdin.writable) {
14090
+ fail("job fence watcher gone; job relies on the JS exit matrix only");
14091
+ return;
14092
+ }
14093
+ try {
14094
+ stdin.write(`${pid}
14095
+ `);
14096
+ } catch (err) {
14097
+ fail(`job fence write failed (${String(err).slice(0, 120)})`);
14098
+ }
14099
+ },
14100
+ dispose() {
14101
+ try {
14102
+ stdin.end();
14103
+ } catch {}
14104
+ },
14105
+ get healthy() {
14106
+ return healthy;
14107
+ }
14108
+ };
14109
+ }
14110
+
14111
+ // src/job-registry.ts
14112
+ import { closeSync as closeSync2, existsSync as existsSync2, mkdirSync as mkdirSync2, openSync as openSync2, readFileSync as readFileSync3, readdirSync as readdirSync2, statSync as statSync2, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "node:fs";
14113
+ import { execFileSync, spawn as spawn5 } from "node:child_process";
14114
+ import { dirname, join as join3 } from "node:path";
14115
+ var REGISTRY_KEEP = 100;
14116
+ function structuralRelocate(deadPid, platform = process.platform) {
14117
+ if (deadPid <= 0)
14118
+ return null;
14119
+ if (platform === "win32") {
14120
+ for (let attempt = 0;attempt < 2; attempt++) {
14121
+ if (attempt > 0)
14122
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 300);
14123
+ try {
14124
+ const raw = execFileSync("powershell", ["-NoProfile", "-Command", `Get-CimInstance Win32_Process | Where-Object { $_.ParentProcessId -eq ${deadPid} -and $_.Name -ne 'conhost.exe' } | Select-Object -First 1 -ExpandProperty ProcessId`], { encoding: "utf8", windowsHide: true, timeout: 1e4 });
14125
+ const pid = Number(String(raw).trim());
14126
+ if (Number.isInteger(pid) && pid > 0)
14127
+ return pid;
14128
+ } catch {}
14129
+ }
14130
+ return null;
14131
+ }
14132
+ try {
14133
+ for (const ent of readdirSync2("/proc")) {
14134
+ if (!/^\d+$/.test(ent))
14135
+ continue;
14136
+ try {
14137
+ const m = /^(\d+) \((.*)\) (\w) (\d+)/.exec(readFileSync3(`/proc/${ent}/stat`, "utf8"));
14138
+ if (m && Number(m[4]) === deadPid)
14139
+ return Number(m[1]);
14140
+ } catch {}
14141
+ }
14142
+ } catch {
14143
+ return null;
14144
+ }
14145
+ return null;
14146
+ }
14147
+ function structuralRelocateAsync(deadPid, platform = process.platform) {
14148
+ if (deadPid <= 0)
14149
+ return Promise.resolve(null);
14150
+ if (platform !== "win32")
14151
+ return Promise.resolve(structuralRelocate(deadPid, platform));
14152
+ return new Promise((resolve2) => {
14153
+ let out = "";
14154
+ let settled = false;
14155
+ const done = (pid) => {
14156
+ if (settled)
14157
+ return;
14158
+ settled = true;
14159
+ clearTimeout(timer);
14160
+ const n = Number(String(out).trim());
14161
+ resolve2(Number.isInteger(n) && n > 0 ? n : pid);
14162
+ };
14163
+ const child = spawn5("powershell", ["-NoProfile", "-Command", `Get-CimInstance Win32_Process | Where-Object { $_.ParentProcessId -eq ${deadPid} -and $_.Name -ne 'conhost.exe' } | Select-Object -First 1 -ExpandProperty ProcessId`], { windowsHide: true, stdio: ["ignore", "pipe", "ignore"] });
14164
+ const timer = setTimeout(() => {
14165
+ try {
14166
+ child.kill();
14167
+ } catch {}
14168
+ done(null);
14169
+ }, 1e4);
14170
+ child.stdout?.on("data", (d) => {
14171
+ out += d;
14172
+ });
14173
+ child.on("error", () => done(null));
14174
+ child.on("exit", () => done(null));
14175
+ });
14176
+ }
14177
+ function acquireLock(lockPath, timeoutMs = 2000) {
14178
+ const deadline = Date.now() + timeoutMs;
14179
+ for (;; ) {
14180
+ try {
14181
+ const fd = openSync2(lockPath, "wx");
14182
+ return {
14183
+ release: () => {
14184
+ try {
14185
+ closeSync2(fd);
14186
+ unlinkSync2(lockPath);
14187
+ } catch {}
14188
+ }
14189
+ };
14190
+ } catch (err) {
14191
+ if (err.code !== "EEXIST")
14192
+ throw err;
14193
+ try {
14194
+ if (Date.now() - statSync2(lockPath).mtimeMs > 5000) {
14195
+ unlinkSync2(lockPath);
14196
+ continue;
14197
+ }
14198
+ } catch {}
14199
+ if (Date.now() >= deadline)
14200
+ return null;
14201
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
14202
+ }
14203
+ }
14204
+ }
14205
+ function readRaw(path) {
14206
+ try {
14207
+ const raw = existsSync2(path) ? readFileSync3(path, "utf8") : "";
14208
+ if (!raw.trim())
14209
+ return [];
14210
+ const parsed = JSON.parse(raw);
14211
+ if (!Array.isArray(parsed.entries))
14212
+ return [];
14213
+ return parsed.entries.filter((e) => typeof e?.id === "string" && Number.isFinite(e?.pid) && e.pid > 0);
14214
+ } catch {
14215
+ return [];
14216
+ }
14217
+ }
14218
+ function writeRaw(path, entries) {
14219
+ mkdirSync2(dirname(path), { recursive: true });
14220
+ writeFileSync2(path, `${JSON.stringify({ version: 1, entries: entries.slice(0, REGISTRY_KEEP) }, null, 2)}
14221
+ `);
14222
+ }
14223
+ function createJobRegistry(registryPath) {
14224
+ const lockPath = `${registryPath}.lock`;
14225
+ const mutate = (fn) => {
14226
+ const lock = acquireLock(lockPath);
14227
+ if (!lock)
14228
+ return false;
14229
+ try {
14230
+ writeRaw(registryPath, fn(readRaw(registryPath)));
14231
+ return true;
14232
+ } finally {
14233
+ lock.release();
14234
+ }
14235
+ };
14236
+ return {
14237
+ add(entry) {
14238
+ mutate((entries) => [entry, ...entries.filter((e) => e.id !== entry.id)].slice(0, REGISTRY_KEEP));
14239
+ },
14240
+ remove(id) {
14241
+ mutate((entries) => entries.filter((e) => e.id !== id));
14242
+ },
14243
+ list() {
14244
+ return readRaw(registryPath);
14245
+ },
14246
+ rescan(isAlive, relocate) {
14247
+ const entries = readRaw(registryPath);
14248
+ const adopted = [];
14249
+ const dead = [];
14250
+ for (const e of entries) {
14251
+ if (isAlive(e.pid)) {
14252
+ adopted.push(e);
14253
+ continue;
14254
+ }
14255
+ const relocated = relocate?.(e.pid) ?? null;
14256
+ if (relocated !== null && relocated !== e.pid && isAlive(relocated)) {
14257
+ adopted.push({ ...e, pid: relocated });
14258
+ } else {
14259
+ dead.push(e);
14260
+ }
14261
+ }
14262
+ const lock = acquireLock(lockPath);
14263
+ if (lock) {
14264
+ try {
14265
+ writeRaw(registryPath, adopted);
14266
+ } finally {
14267
+ lock.release();
14268
+ }
14269
+ }
14270
+ return { adopted, dead };
14271
+ }
14272
+ };
14273
+ }
14274
+ function registryPathFor(jobsDir) {
14275
+ return join3(jobsDir, "registry.json");
14276
+ }
14277
+
14278
+ // src/host-exit.ts
14279
+ function createExitCleanup(manager, opts = {}) {
14280
+ const graceMs = opts.graceMs ?? 3000;
14281
+ let sequenced = false;
14282
+ let forceIssued = false;
14283
+ let afterRan = false;
14284
+ const installed = [];
14285
+ const liveTargets = () => {
14286
+ const targets = [];
14287
+ for (const job of manager.list()) {
14288
+ if (job.state === "running" && !job.survive)
14289
+ targets.push(job);
14290
+ }
14291
+ return targets;
14292
+ };
14293
+ const killOne = (job, graceful) => {
14294
+ const pid = job.pid ?? 0;
14295
+ if (pid > 0) {
14296
+ terminateTreeSync(pid, {
14297
+ graceMs: graceful ? graceMs : 0,
14298
+ spawnSyncFn: opts.spawnSyncFn,
14299
+ platform: opts.platform,
14300
+ ...opts.noWait ? { wait: false } : {}
14301
+ });
14302
+ }
14303
+ try {
14304
+ job.killTree();
14305
+ } catch {}
14306
+ manager.markTerminal(job, "killed", null);
14307
+ };
14308
+ const forcePass = () => {
14309
+ if (forceIssued)
14310
+ return;
14311
+ forceIssued = true;
14312
+ for (const job of liveTargets())
14313
+ killOne(job, false);
14314
+ };
14315
+ const trigger = (kind) => {
14316
+ if (kind === "dispose") {
14317
+ if (sequenced)
14318
+ return;
14319
+ sequenced = true;
14320
+ for (const job of liveTargets())
14321
+ killOne(job, true);
14322
+ forcePass();
14323
+ if (!afterRan) {
14324
+ afterRan = true;
14325
+ opts.after?.();
14326
+ }
14327
+ return;
14328
+ }
14329
+ forcePass();
14330
+ };
14331
+ const onSignal = (kind) => () => trigger(kind);
14332
+ const wired = [
14333
+ ["SIGINT", onSignal("SIGINT")],
14334
+ ["SIGTERM", onSignal("SIGTERM")],
14335
+ ["exit", onSignal("exit")],
14336
+ ["uncaughtException", onSignal("uncaughtException")],
14337
+ ["unhandledRejection", onSignal("unhandledRejection")]
14338
+ ];
14339
+ for (const [ev, fn] of wired) {
14340
+ process.on(ev, fn);
14341
+ installed.push([ev, fn]);
14342
+ }
14343
+ return {
14344
+ trigger,
14345
+ uninstall() {
14346
+ for (const [ev, fn] of installed) {
14347
+ try {
14348
+ process.removeListener(ev, fn);
14349
+ } catch {}
14350
+ }
14351
+ }
14352
+ };
14353
+ }
14354
+
13686
14355
  // src/watchdog.ts
13687
- import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
13688
- import { dirname } from "node:path";
14356
+ import { mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "node:fs";
14357
+ import { dirname as dirname2 } from "node:path";
13689
14358
  var WATCHDOG_ENV_MARK = "FORGE_WATCHDOG_MARK";
13690
14359
  var DEFAULT_STALL_MS = 600000;
13691
14360
  var MIN_STALL_MS = 60000;
@@ -13903,10 +14572,10 @@ function watchdogLogDir(base) {
13903
14572
  function createFileLedger(logPath, maxEntries = 200, maxBytes = 1e6) {
13904
14573
  const append = (entry) => {
13905
14574
  try {
13906
- mkdirSync2(dirname(logPath), { recursive: true });
14575
+ mkdirSync3(dirname2(logPath), { recursive: true });
13907
14576
  let lines = [];
13908
14577
  try {
13909
- lines = readFileSync3(logPath, "utf8").split(`
14578
+ lines = readFileSync4(logPath, "utf8").split(`
13910
14579
  `).filter((l) => l.trim().length > 0);
13911
14580
  } catch {}
13912
14581
  if (lines.length >= maxEntries)
@@ -13921,12 +14590,12 @@ function createFileLedger(logPath, maxEntries = 200, maxBytes = 1e6) {
13921
14590
  `) + `
13922
14591
  `;
13923
14592
  }
13924
- writeFileSync2(logPath, text, "utf8");
14593
+ writeFileSync3(logPath, text, "utf8");
13925
14594
  } catch {}
13926
14595
  };
13927
14596
  const entries = () => {
13928
14597
  try {
13929
- return readFileSync3(logPath, "utf8").split(`
14598
+ return readFileSync4(logPath, "utf8").split(`
13930
14599
  `).filter((l) => l.trim().length > 0).map((l) => JSON.parse(l));
13931
14600
  } catch {
13932
14601
  return [];
@@ -13936,8 +14605,8 @@ function createFileLedger(logPath, maxEntries = 200, maxBytes = 1e6) {
13936
14605
  }
13937
14606
 
13938
14607
  // src/proc-locate.ts
13939
- import { execFileSync } from "node:child_process";
13940
- import { readdirSync as readdirSync2, readFileSync as readFileSync4 } from "node:fs";
14608
+ import { execFileSync as execFileSync2 } from "node:child_process";
14609
+ import { readdirSync as readdirSync3, readFileSync as readFileSync5 } from "node:fs";
13941
14610
  function commandNeedle(command) {
13942
14611
  const trimmed = command.trim().replace(/^["']|["']$/g, "");
13943
14612
  if (trimmed.length === 0)
@@ -13950,17 +14619,17 @@ function markerValue(callID) {
13950
14619
  return `opencode-forge:${callID}`;
13951
14620
  }
13952
14621
  function createPosixLocator(deps = {}) {
13953
- const listPids = deps.listPids ?? (() => readdirSync2("/proc").map(Number).filter((n) => Number.isInteger(n) && n > 0));
14622
+ const listPids = deps.listPids ?? (() => readdirSync3("/proc").map(Number).filter((n) => Number.isInteger(n) && n > 0));
13954
14623
  const readEnv = deps.readEnv ?? ((pid) => {
13955
14624
  try {
13956
- return readFileSync4(`/proc/${pid}/environ`);
14625
+ return readFileSync5(`/proc/${pid}/environ`);
13957
14626
  } catch {
13958
14627
  return null;
13959
14628
  }
13960
14629
  });
13961
14630
  const readCmd = deps.readCmd ?? ((pid) => {
13962
14631
  try {
13963
- return readFileSync4(`/proc/${pid}/cmdline`).toString("utf8").split("\x00").filter(Boolean).join(" ");
14632
+ return readFileSync5(`/proc/${pid}/cmdline`).toString("utf8").split("\x00").filter(Boolean).join(" ");
13964
14633
  } catch {
13965
14634
  return String(pid);
13966
14635
  }
@@ -14017,7 +14686,7 @@ function parseWindowsProcs(raw) {
14017
14686
  var WINDOW_SLACK_MS = 2000;
14018
14687
  function createWindowsLocator(deps = {}) {
14019
14688
  const hostPid = deps.hostPid ?? process.pid;
14020
- const execFn = deps.execFn ?? ((cmd) => execFileSync("powershell", ["-NoProfile", "-Command", cmd], { encoding: "utf8", windowsHide: true, timeout: 15000 }));
14689
+ const execFn = deps.execFn ?? ((cmd) => execFileSync2("powershell", ["-NoProfile", "-Command", cmd], { encoding: "utf8", windowsHide: true, timeout: 15000 }));
14021
14690
  return async (callID, t0, cmdNeedle, phase2 = false) => {
14022
14691
  let raw = "[]";
14023
14692
  try {
@@ -14119,6 +14788,47 @@ var sessions = new Map;
14119
14788
  var forgeDisabled = false;
14120
14789
  var jobsMode = "auto";
14121
14790
  var jobsKeepBuiltinShell = false;
14791
+ var jobsSurviveMode = "never";
14792
+ function effectiveSurvive(config2, param) {
14793
+ if (config2 === "deny") {
14794
+ if (param === true)
14795
+ throw new Error("[forge] jobs.survive is explicitly denied in config; a per-call survive=true cannot override it.");
14796
+ return false;
14797
+ }
14798
+ if (param !== undefined)
14799
+ return param;
14800
+ return config2 === "always";
14801
+ }
14802
+ var jobFence = null;
14803
+ var jobRegistry = null;
14804
+ var exitCleanup = null;
14805
+ var jobsLifecycleStarted = false;
14806
+ function ensureJobLifecycle() {
14807
+ if (jobsLifecycleStarted)
14808
+ return { registry: jobRegistry, fence: jobFence };
14809
+ jobsLifecycleStarted = true;
14810
+ const registry2 = createJobRegistry(registryPathFor(jobLogDir));
14811
+ jobRegistry = registry2;
14812
+ const { adopted, dead } = registry2.rescan(pidAlive, structuralRelocate);
14813
+ for (const entry of adopted) {
14814
+ adoptSurvivor(jobManager, entry, { registry: registry2, logDir: jobLogDir, relocate: structuralRelocate });
14815
+ }
14816
+ for (const entry of dead) {
14817
+ jobLedgerSink({ at: new Date().toISOString(), kind: "orphan-job", jobId: entry.id, session: entry.ownerSession, detail: `previous-run survivor pid ${entry.pid} is dead (cmd: ${entry.cmd.slice(0, 120)})` });
14818
+ }
14819
+ jobFence = process.env.FORGE_TEST_NO_FENCE === "1" ? null : createJobFence({
14820
+ onDegrade: (reason) => {
14821
+ jobLedgerSink({ at: new Date().toISOString(), kind: "fence-degraded", jobId: "-", session: "-", detail: reason });
14822
+ }
14823
+ });
14824
+ exitCleanup = createExitCleanup(jobManager, {
14825
+ graceMs: 3000,
14826
+ after: () => {
14827
+ jobFence?.dispose();
14828
+ }
14829
+ });
14830
+ return { registry: registry2, fence: jobFence };
14831
+ }
14122
14832
  var nativeBackgroundSeen = false;
14123
14833
  function jobStage() {
14124
14834
  if (jobsMode === "native")
@@ -14128,13 +14838,13 @@ function jobStage() {
14128
14838
  return nativeBackgroundSeen ? 1 : 0;
14129
14839
  }
14130
14840
  var jobLogDir = jobsLogDir();
14131
- var jobLedgerPath = join3(jobLogDir, "ledger.jsonl");
14841
+ var jobLedgerPath = join4(jobLogDir, "ledger.jsonl");
14132
14842
  function jobLedgerSink(entry) {
14133
14843
  try {
14134
- mkdirSync3(jobLogDir, { recursive: true });
14135
- if (existsSync2(jobLedgerPath) && statSync2(jobLedgerPath).size > 1e6)
14136
- writeFileSync3(jobLedgerPath, "");
14137
- appendFileSync2(jobLedgerPath, `${JSON.stringify(entry)}
14844
+ mkdirSync4(jobLogDir, { recursive: true });
14845
+ if (existsSync3(jobLedgerPath) && statSync3(jobLedgerPath).size > 1e6)
14846
+ writeFileSync4(jobLedgerPath, "");
14847
+ appendFileSync(jobLedgerPath, `${JSON.stringify(entry)}
14138
14848
  `);
14139
14849
  } catch {}
14140
14850
  }
@@ -14188,13 +14898,13 @@ function nowIso() {
14188
14898
  return new Date().toISOString();
14189
14899
  }
14190
14900
  function planDirOf(worktree) {
14191
- return join3(worktree, ".opencode", "plan");
14901
+ return join4(worktree, ".opencode", "plan");
14192
14902
  }
14193
14903
  function readPlanDir(worktree) {
14194
14904
  const dir = planDirOf(worktree);
14195
- if (!existsSync2(dir))
14905
+ if (!existsSync3(dir))
14196
14906
  return [];
14197
- return readdirSync3(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync5(join3(dir, name), "utf8") }));
14907
+ return readdirSync4(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync6(join4(dir, name), "utf8") }));
14198
14908
  }
14199
14909
  function ensureSession(sessionID, worktree) {
14200
14910
  const existing = sessions.get(sessionID);
@@ -14210,8 +14920,8 @@ function worktreeFor(context) {
14210
14920
  return effectiveWorktree(context.worktree, hostWorktree) || context.worktree;
14211
14921
  }
14212
14922
  function resolveActivePlan(state) {
14213
- if (state.planPath && existsSync2(state.planPath)) {
14214
- const doc2 = parsePlanLoose(readFileSync5(state.planPath, "utf8"));
14923
+ if (state.planPath && existsSync3(state.planPath)) {
14924
+ const doc2 = parsePlanLoose(readFileSync6(state.planPath, "utf8"));
14215
14925
  if (doc2 && !isTerminal(doc2.status))
14216
14926
  return { path: state.planPath, doc: doc2 };
14217
14927
  state.planPath = undefined;
@@ -14219,7 +14929,7 @@ function resolveActivePlan(state) {
14219
14929
  const ranked = rankActivePlans(readPlanDir(state.worktree));
14220
14930
  if (ranked.length === 0)
14221
14931
  return null;
14222
- const path = join3(planDirOf(state.worktree), ranked[0].name);
14932
+ const path = join4(planDirOf(state.worktree), ranked[0].name);
14223
14933
  state.planPath = path;
14224
14934
  return { path, doc: ranked[0].doc };
14225
14935
  }
@@ -14253,12 +14963,12 @@ var planWriteTool = tool({
14253
14963
  throw new PlanError(`A plan in ${active.doc.status} state is already active (${relFrom(state.worktree, active.path)}). Finish it with plan_close, or /plan discard it before planning something new.`);
14254
14964
  } else {
14255
14965
  const dir = planDirOf(state.worktree);
14256
- mkdirSync3(dir, { recursive: true });
14257
- path = join3(dir, planFileName(localDate(), slugify(args.goal), readdirSync3(dir).filter((f) => f.endsWith(".md"))));
14966
+ mkdirSync4(dir, { recursive: true });
14967
+ path = join4(dir, planFileName(localDate(), slugify(args.goal), readdirSync4(dir).filter((f) => f.endsWith(".md"))));
14258
14968
  mode = "created";
14259
14969
  }
14260
14970
  const text = renderPlan(args, now, created);
14261
- writeFileSync3(path, text);
14971
+ writeFileSync4(path, text);
14262
14972
  state.planPath = path;
14263
14973
  const doc2 = parsePlan(text);
14264
14974
  context.metadata({ title: `${mode === "created" ? "Create" : "Revise"} plan: ${doc2.goal}` });
@@ -14286,8 +14996,8 @@ var planTickTool = tool({
14286
14996
  if (active.doc.status !== "approved") {
14287
14997
  throw new PlanError(`Plan status is ${active.doc.status}; only an approved plan can be ticked. Get user approval via plan_approve first.`);
14288
14998
  }
14289
- const next = tickTask(readFileSync5(active.path, "utf8"), args.n, nowIso());
14290
- writeFileSync3(active.path, next);
14999
+ const next = tickTask(readFileSync6(active.path, "utf8"), args.n, nowIso());
15000
+ writeFileSync4(active.path, next);
14291
15001
  const doc2 = parsePlan(next);
14292
15002
  const p = progressOf(doc2);
14293
15003
  context.metadata({ title: `Tick task ${args.n} (${p.done}/${p.total})` });
@@ -14314,7 +15024,7 @@ var planApproveTool = tool({
14314
15024
  throw new PlanError(`Plan status is ${active.doc.status}; only a draft plan can be approved.`);
14315
15025
  }
14316
15026
  await gate(context.ask, "plan_approve", `Approve plan: ${active.doc.goal}`);
14317
- writeFileSync3(active.path, transitionStatus(readFileSync5(active.path, "utf8"), "approved", nowIso()));
15027
+ writeFileSync4(active.path, transitionStatus(readFileSync6(active.path, "utf8"), "approved", nowIso()));
14318
15028
  context.metadata({ title: `Plan approved: ${active.doc.goal}` });
14319
15029
  return {
14320
15030
  title: "plan approved",
@@ -14347,7 +15057,7 @@ var planCloseTool = tool({
14347
15057
  Fix the implementation and retry, or revise the plan first.`);
14348
15058
  }
14349
15059
  await gate(context.ask, "plan_close", `Close plan: ${active.doc.goal}`);
14350
- writeFileSync3(active.path, transitionStatus(readFileSync5(active.path, "utf8"), "done", nowIso()));
15060
+ writeFileSync4(active.path, transitionStatus(readFileSync6(active.path, "utf8"), "done", nowIso()));
14351
15061
  context.metadata({ title: `Plan done: ${active.doc.goal}` });
14352
15062
  return {
14353
15063
  title: "plan done",
@@ -14365,31 +15075,31 @@ var planDiscardTool = tool({
14365
15075
  const active = resolveActivePlan(state);
14366
15076
  if (!active)
14367
15077
  throw new PlanError("No plan to abandon in this workspace.");
14368
- writeFileSync3(active.path, transitionStatus(readFileSync5(active.path, "utf8"), "abandoned", nowIso()));
15078
+ writeFileSync4(active.path, transitionStatus(readFileSync6(active.path, "utf8"), "abandoned", nowIso()));
14369
15079
  state.planPath = undefined;
14370
15080
  context.metadata({ title: `Plan abandoned: ${active.doc.goal}` });
14371
15081
  return { title: "plan abandoned", output: `Plan abandoned: ${relFrom(state.worktree, active.path)}. Write operations are restored.` };
14372
15082
  }
14373
15083
  });
14374
15084
  function goalDirOf(worktree) {
14375
- return join3(worktree, ".opencode", "goal");
15085
+ return join4(worktree, ".opencode", "goal");
14376
15086
  }
14377
15087
  function readGoalDir(worktree) {
14378
15088
  const dir = goalDirOf(worktree);
14379
- if (!existsSync2(dir))
15089
+ if (!existsSync3(dir))
14380
15090
  return [];
14381
- return readdirSync3(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync5(join3(dir, name), "utf8") }));
15091
+ return readdirSync4(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync6(join4(dir, name), "utf8") }));
14382
15092
  }
14383
15093
  function resolveSessionGoal(state) {
14384
- if (state.goalPath && existsSync2(state.goalPath)) {
14385
- const doc2 = parseGoalLoose(readFileSync5(state.goalPath, "utf8"));
15094
+ if (state.goalPath && existsSync3(state.goalPath)) {
15095
+ const doc2 = parseGoalLoose(readFileSync6(state.goalPath, "utf8"));
14386
15096
  if (doc2 && !isGoalTerminal(doc2.status))
14387
15097
  return { path: state.goalPath, doc: doc2 };
14388
15098
  state.goalPath = undefined;
14389
15099
  }
14390
15100
  const live = rankLiveGoals(readGoalDir(state.worktree))[0];
14391
15101
  if (live) {
14392
- const path = join3(goalDirOf(state.worktree), live.name);
15102
+ const path = join4(goalDirOf(state.worktree), live.name);
14393
15103
  state.goalPath = path;
14394
15104
  return { path, doc: live.doc };
14395
15105
  }
@@ -14486,9 +15196,9 @@ var goalWriteTool = tool({
14486
15196
  await gate(context.ask, "goal_write", `Arm goal: ${args.goal}`);
14487
15197
  }
14488
15198
  const dir = goalDirOf(state.worktree);
14489
- mkdirSync3(dir, { recursive: true });
14490
- const name = goalFileName(localDateNow(), slugifyGoal(args.goal), readdirSync3(dir).filter((f) => f.endsWith(".md")));
14491
- const path = join3(dir, name);
15199
+ mkdirSync4(dir, { recursive: true });
15200
+ const name = goalFileName(localDateNow(), slugifyGoal(args.goal), readdirSync4(dir).filter((f) => f.endsWith(".md")));
15201
+ const path = join4(dir, name);
14492
15202
  const text = renderGoal(input, {
14493
15203
  now,
14494
15204
  status: arm ? "active" : "queued",
@@ -14528,7 +15238,7 @@ var goalCheckTool = tool({
14528
15238
  o.index = selected[i].n;
14529
15239
  });
14530
15240
  const runId = `${nowIso()}-${Math.random().toString(36).slice(2, 8)}`;
14531
- const next = appendCheckLog(readFileSync5(goal.path, "utf8"), runId, outcomes, nowIso());
15241
+ const next = appendCheckLog(readFileSync6(goal.path, "utf8"), runId, outcomes, nowIso());
14532
15242
  atomicWrite(goal.path, next);
14533
15243
  const ok = outcomesAllOk(outcomes);
14534
15244
  context.metadata({ title: `goal_check: ${outcomes.filter((o) => o.ok).length}/${outcomes.length} pass` });
@@ -14562,7 +15272,7 @@ var goalCompleteTool = tool({
14562
15272
  const failures = outcomes.filter((o) => !o.ok);
14563
15273
  if (failures.length > 0) {
14564
15274
  const runId2 = `${nowIso()}-${Math.random().toString(36).slice(2, 8)}`;
14565
- atomicWrite(goal.path, appendCheckLog(readFileSync5(goal.path, "utf8"), runId2, outcomes, nowIso()));
15275
+ atomicWrite(goal.path, appendCheckLog(readFileSync6(goal.path, "utf8"), runId2, outcomes, nowIso()));
14566
15276
  throw new GoalError(`Completion gate: verification re-run failed (fail-closed). The goal stays active.
14567
15277
  ${formatOutcomes(failures)}
14568
15278
  Fix the work and retry; recorded results never substitute for the gate's own re-run.`);
@@ -14575,7 +15285,7 @@ Fix the work and retry; recorded results never substitute for the gate's own re-
14575
15285
  }
14576
15286
  await gate(context.ask, "goal_complete", `Complete goal: ${goal.doc.goal}`);
14577
15287
  const runId = `${nowIso()}-${Math.random().toString(36).slice(2, 8)}`;
14578
- let text = appendCheckLog(readFileSync5(goal.path, "utf8"), runId, outcomes, nowIso());
15288
+ let text = appendCheckLog(readFileSync6(goal.path, "utf8"), runId, outcomes, nowIso());
14579
15289
  text = transitionGoal(text, "completed", nowIso());
14580
15290
  atomicWrite(goal.path, text);
14581
15291
  context.metadata({ title: `Goal completed: ${goal.doc.goal}` });
@@ -14597,7 +15307,7 @@ var goalPauseTool = tool({
14597
15307
  throw new GoalError(`No active goal to pause (status: ${goal?.doc.status ?? "none"}).`);
14598
15308
  }
14599
15309
  const stopReason = args.blocker ? "blocker" : "user";
14600
- atomicWrite(goal.path, transitionGoal(readFileSync5(goal.path, "utf8"), "paused", nowIso(), { stopReason }));
15310
+ atomicWrite(goal.path, transitionGoal(readFileSync6(goal.path, "utf8"), "paused", nowIso(), { stopReason }));
14601
15311
  engineForgetSession(context.sessionID);
14602
15312
  context.metadata({ title: `Goal paused (${stopReason}): ${goal.doc.goal}` });
14603
15313
  return {
@@ -14617,7 +15327,7 @@ var goalResumeTool = tool({
14617
15327
  if (!goal || goal.doc.status === "queued") {
14618
15328
  const oldest = rankQueuedGoals(readGoalDir(state.worktree))[0];
14619
15329
  if (oldest) {
14620
- const path = join3(goalDirOf(state.worktree), oldest.name);
15330
+ const path = join4(goalDirOf(state.worktree), oldest.name);
14621
15331
  state.goalPath = path;
14622
15332
  goal = { path, doc: oldest.doc };
14623
15333
  }
@@ -14627,7 +15337,7 @@ var goalResumeTool = tool({
14627
15337
  }
14628
15338
  const promoting = goal.doc.status === "queued";
14629
15339
  await gate(context.ask, "goal_resume", `${promoting ? "Promote" : "Resume"} goal: ${goal.doc.goal}`);
14630
- let text = readFileSync5(goal.path, "utf8");
15340
+ let text = readFileSync6(goal.path, "utf8");
14631
15341
  if (args.addTurns)
14632
15342
  text = bumpBudget(text, args.addTurns, nowIso());
14633
15343
  text = transitionGoal(text, "active", nowIso(), { session: context.sessionID });
@@ -14653,7 +15363,7 @@ var goalDiscardTool = tool({
14653
15363
  if (!goal)
14654
15364
  throw new GoalError("No goal to discard in this workspace.");
14655
15365
  await gate(context.ask, "goal_discard", `Discard goal: ${goal.doc.goal}`);
14656
- atomicWrite(goal.path, transitionGoal(readFileSync5(goal.path, "utf8"), "abandoned", nowIso()));
15366
+ atomicWrite(goal.path, transitionGoal(readFileSync6(goal.path, "utf8"), "abandoned", nowIso()));
14657
15367
  state.goalPath = undefined;
14658
15368
  engineForgetSession(context.sessionID);
14659
15369
  context.metadata({ title: `Goal abandoned: ${goal.doc.goal}` });
@@ -14681,7 +15391,8 @@ var forgeShellTool = tool({
14681
15391
  max_wait_ms: tool.schema.number().int().nonnegative().optional().describe("Hard cap on this call's wait in ms (default 120000, clamped to 600000); returns still-running, never kills"),
14682
15392
  success_pattern: tool.schema.string().optional().describe("Regex; a match against new output completes the call as success immediately"),
14683
15393
  keep_alive: tool.schema.boolean().optional().describe("After a success match: keep the process alive (default) or kill its tree (false)"),
14684
- notify: tool.schema.boolean().optional().describe("Send a [forge:job-complete] message into this session when the job exits (default true)")
15394
+ notify: tool.schema.boolean().optional().describe("Send a [forge:job-complete] message into this session when the job exits (default true)"),
15395
+ survive: tool.schema.boolean().optional().describe("Opt this job OUT of dying with the host: it keeps running after opencode exits, recorded in the persistent registry so the next run can poll/kill it (config jobs.survive sets the default; an explicit config deny cannot be overridden)")
14685
15396
  },
14686
15397
  execute: async (args, context) => {
14687
15398
  if (jobStage() >= 2) {
@@ -14689,6 +15400,7 @@ var forgeShellTool = tool({
14689
15400
  }
14690
15401
  const state = ensureSession(context.sessionID, worktreeFor(context));
14691
15402
  await gate(context.ask, "forge_shell", `forge_shell: ${args.command.slice(0, 100)}`);
15403
+ const survive = effectiveSurvive(jobsSurviveMode, args.survive);
14692
15404
  let successPattern = null;
14693
15405
  if (args.success_pattern) {
14694
15406
  try {
@@ -14697,7 +15409,8 @@ var forgeShellTool = tool({
14697
15409
  throw new Error(`Invalid success_pattern: ${err.message}`);
14698
15410
  }
14699
15411
  }
14700
- const cwd = args.workdir ? isAbsolute(args.workdir) ? args.workdir : join3(state.worktree, args.workdir) : state.worktree;
15412
+ const { registry: registry2, fence } = ensureJobLifecycle();
15413
+ const cwd = args.workdir ? isAbsolute(args.workdir) ? args.workdir : join4(state.worktree, args.workdir) : state.worktree;
14701
15414
  const started = startJob(jobManager, {
14702
15415
  cmd: args.command,
14703
15416
  cwd,
@@ -14709,8 +15422,21 @@ var forgeShellTool = tool({
14709
15422
  ...args.max_wait_ms !== undefined ? { maxWaitMs: args.max_wait_ms } : {},
14710
15423
  successPattern,
14711
15424
  ...args.keep_alive !== undefined ? { keepAlive: args.keep_alive } : {},
14712
- ...args.notify !== undefined ? { notify: args.notify } : {}
15425
+ ...args.notify !== undefined ? { notify: args.notify } : {},
15426
+ ...survive ? { survive: true, registry: registry2 ?? undefined } : { fence, relocate: structuralRelocate, relocateAsync: structuralRelocateAsync }
14713
15427
  });
15428
+ if (survive) {
15429
+ return {
15430
+ title: `job started (survives host exit): ${started.job.id}`,
15431
+ output: [
15432
+ `[forge:job] Started in background — SURVIVES host exit (no fence, no exit kill).`,
15433
+ `jobId: ${started.job.id}`,
15434
+ `logPath: ${started.job.logPath}`,
15435
+ "Recorded in the persistent registry: the next opencode run can poll/log/kill it via forge_jobs. Stop it explicitly when done."
15436
+ ].join(`
15437
+ `)
15438
+ };
15439
+ }
14714
15440
  context.metadata({ title: `forge_shell: ${args.command.slice(0, 60)}` });
14715
15441
  if (args.run_in_background === true) {
14716
15442
  return {
@@ -14775,7 +15501,8 @@ var forgeJobsTool = tool({
14775
15501
  throw new Error(`Unknown action "${action}" — use one of: ${FORGE_JOBS_ACTIONS.join(", ")}.`);
14776
15502
  }
14777
15503
  if (action === "list") {
14778
- const rows = jobManager.list().map((j) => `${j.id} ${j.state}${j.exitCode !== null ? `(${j.exitCode})` : ""}${j.succeededAt ? "*" : ""} ${j.scope} ${j.cmd.slice(0, 60)}`);
15504
+ ensureJobLifecycle();
15505
+ const rows = jobManager.list().map((j) => `${j.id} ${j.state}${j.exitCode !== null ? `(${j.exitCode})` : ""}${j.succeededAt ? "*" : ""} ${j.previousRun ? "previous-run" : j.survive ? "survive" : j.scope} ${j.cmd.slice(0, 60)}`);
14779
15506
  return { title: `jobs (${rows.length})`, output: rows.length > 0 ? rows.join(`
14780
15507
  `) : "(no jobs)" };
14781
15508
  }
@@ -14806,8 +15533,12 @@ ${p.newOutput || "(none in this window)"}`,
14806
15533
  });
14807
15534
  return {
14808
15535
  title: `${job.id} log ${page.offset}-${page.offset + page.lines.length}/${page.total}`,
14809
- output: page.lines.length > 0 ? page.lines.join(`
15536
+ output: [
15537
+ ...page.windowed ? [`(log exceeds ${8}MB — showing the most recent window; read the file directly for full history: ${job.logPath})`] : [],
15538
+ page.lines.length > 0 ? page.lines.join(`
14810
15539
  `) : "(empty)"
15540
+ ].join(`
15541
+ `)
14811
15542
  };
14812
15543
  }
14813
15544
  if (action === "kill") {
@@ -14867,7 +15598,7 @@ var pendingContinuationTurn = new Set;
14867
15598
  function goalProbe(line) {
14868
15599
  if (process.env.FORGE_GOAL_PROBE) {
14869
15600
  try {
14870
- appendFileSync2(join3(tmpdir2(), "forge-goal-probe.log"), `${new Date().toISOString()} ${line}
15601
+ appendFileSync(join4(tmpdir2(), "forge-goal-probe.log"), `${new Date().toISOString()} ${line}
14871
15602
  `);
14872
15603
  } catch {}
14873
15604
  }
@@ -14913,7 +15644,7 @@ function wrapupBriefText(goal, reason) {
14913
15644
  }
14914
15645
  async function autoPauseGoal(client, state, goal, reason, wrapup) {
14915
15646
  goalProbe(`auto-pause session=${state.sessionID} reason=${reason} wrapup=${wrapup}`);
14916
- atomicWrite(goal.path, transitionGoal(readFileSync5(goal.path, "utf8"), "paused", nowIso(), { stopReason: reason }));
15647
+ atomicWrite(goal.path, transitionGoal(readFileSync6(goal.path, "utf8"), "paused", nowIso(), { stopReason: reason }));
14917
15648
  engineForgetSession(state.sessionID);
14918
15649
  if (wrapup && goal.doc.session) {
14919
15650
  try {
@@ -14960,11 +15691,11 @@ async function continueIfEligible(client, sessionID) {
14960
15691
  turnHadActivity = !!act && (act.writes > 0 || act.checks > 0);
14961
15692
  turnActivity.set(sessionID, { writes: 0, checks: 0 });
14962
15693
  const ledgerPath = state.goalPath;
14963
- if (ledgerPath && existsSync2(ledgerPath)) {
15694
+ if (ledgerPath && existsSync3(ledgerPath)) {
14964
15695
  try {
14965
- const fresh = parseGoalLoose(readFileSync5(ledgerPath, "utf8"));
15696
+ const fresh = parseGoalLoose(readFileSync6(ledgerPath, "utf8"));
14966
15697
  if (fresh && fresh.turnsUsed > 0) {
14967
- atomicWrite(ledgerPath, appendLedger(readFileSync5(ledgerPath, "utf8"), { turn: fresh.turnsUsed, revision: fresh.revision, at: nowIso(), activity: turnHadActivity, writes: act?.writes ?? 0, checks: act?.checks ?? 0 }, nowIso()));
15698
+ atomicWrite(ledgerPath, appendLedger(readFileSync6(ledgerPath, "utf8"), { turn: fresh.turnsUsed, revision: fresh.revision, at: nowIso(), activity: turnHadActivity, writes: act?.writes ?? 0, checks: act?.checks ?? 0 }, nowIso()));
14968
15699
  }
14969
15700
  } catch (err) {
14970
15701
  goalProbe(`ledger append failed session=${sessionID} err=${String(err)}`);
@@ -15025,7 +15756,7 @@ async function continueIfEligible(client, sessionID) {
15025
15756
  pendingContinuationTurn.add(sessionID);
15026
15757
  turnActivity.set(sessionID, { writes: 0, checks: 0 });
15027
15758
  state.goalPath = goal.path;
15028
- atomicWrite(goal.path, incTurns(readFileSync5(goal.path, "utf8"), nowIso()));
15759
+ atomicWrite(goal.path, incTurns(readFileSync6(goal.path, "utf8"), nowIso()));
15029
15760
  goalProbe(`continued session=${sessionID} turn=${goal.doc.turnsUsed + 1}/${goal.doc.maxTurns}`);
15030
15761
  } catch (err) {
15031
15762
  const n = (transportFails.get(sessionID) ?? 0) + 1;
@@ -15060,6 +15791,8 @@ var server = async (input, options) => {
15060
15791
  if (jobsOpts?.mode === "auto" || jobsOpts?.mode === "forge" || jobsOpts?.mode === "native")
15061
15792
  jobsMode = jobsOpts.mode;
15062
15793
  jobsKeepBuiltinShell = jobsOpts?.keepBuiltinShell === true;
15794
+ if (jobsOpts?.survive === "never" || jobsOpts?.survive === "always" || jobsOpts?.survive === "deny")
15795
+ jobsSurviveMode = jobsOpts.survive;
15063
15796
  const wdOpts = options?.watchdog;
15064
15797
  const wdFallbacks = [];
15065
15798
  const wdMode = parseMode(wdOpts?.mode);
@@ -15071,14 +15804,14 @@ var server = async (input, options) => {
15071
15804
  if (wdStallRaw !== undefined && wdStallRaw !== wdStall) {
15072
15805
  wdFallbacks.push(`watchdog.stallMs ${JSON.stringify(String(wdStallRaw))} adjusted to ${wdStall} (floor/default applied)`);
15073
15806
  }
15074
- const watchdogLedger = createFileLedger(join3(watchdogLogDir(), "log.jsonl"));
15807
+ const watchdogLedger = createFileLedger(join4(watchdogLogDir(), "log.jsonl"));
15075
15808
  const rawLocator = createLocator();
15076
15809
  const probing = () => process.env.FORGE_WATCHDOG_PROBE === "1";
15077
15810
  const probeLine = (text) => {
15078
15811
  if (!probing())
15079
15812
  return;
15080
15813
  try {
15081
- appendFileSync2(join3(tmpdir2(), "forge-watchdog-probe.log"), `${new Date().toISOString()} ${text}
15814
+ appendFileSync(join4(tmpdir2(), "forge-watchdog-probe.log"), `${new Date().toISOString()} ${text}
15082
15815
  `);
15083
15816
  } catch {}
15084
15817
  };
@@ -15088,7 +15821,7 @@ var server = async (input, options) => {
15088
15821
  probeLine(`locate dur=${Date.now() - started}ms hits=${hits.length} phase2=${phase2 === true} needle=${JSON.stringify(cmdNeedle ?? null)}`);
15089
15822
  if (hits.length === 0 && cmdNeedle) {
15090
15823
  try {
15091
- const raw = execFileSync2("powershell", ["-NoProfile", "-Command", "[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CommandLine,CreationDate | ConvertTo-Json -Compress"], { encoding: "utf8", windowsHide: true, timeout: 15000 });
15824
+ const raw = execFileSync3("powershell", ["-NoProfile", "-Command", "[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CommandLine,CreationDate | ConvertTo-Json -Compress"], { encoding: "utf8", windowsHide: true, timeout: 15000 });
15092
15825
  const all = parseWindowsProcs(raw);
15093
15826
  probeLine(`diag rawLen=${raw.length} procs=${all.length} windowStart=${new Date(t0 - 2000).toISOString()}`);
15094
15827
  for (const p of all) {
@@ -15123,6 +15856,7 @@ var server = async (input, options) => {
15123
15856
  return {
15124
15857
  dispose: async () => {
15125
15858
  engineForgetAll();
15859
+ exitCleanup?.trigger("dispose");
15126
15860
  jobManager.disposeAll();
15127
15861
  watchdog.dispose();
15128
15862
  sessions.clear();
@@ -15175,7 +15909,7 @@ var server = async (input, options) => {
15175
15909
  "tool.execute.before": async (input2, output) => {
15176
15910
  if (process.env.FORGE_PERM_PROBE) {
15177
15911
  try {
15178
- appendFileSync2(join3(tmpdir2(), "forge-perm-probe.log"), `${new Date().toISOString()} before tool=${JSON.stringify(input2.tool)} session=${input2.sessionID}
15912
+ appendFileSync(join4(tmpdir2(), "forge-perm-probe.log"), `${new Date().toISOString()} before tool=${JSON.stringify(input2.tool)} session=${input2.sessionID}
15179
15913
  `);
15180
15914
  } catch {}
15181
15915
  }
@@ -15185,7 +15919,7 @@ var server = async (input, options) => {
15185
15919
  watchdog.track(input2.callID, input2.sessionID, input2.tool, undefined, cmdText !== undefined ? commandNeedle(cmdText) : undefined);
15186
15920
  if (process.env.FORGE_WATCHDOG_PROBE) {
15187
15921
  try {
15188
- appendFileSync2(join3(tmpdir2(), "forge-watchdog-probe.log"), `${new Date().toISOString()} track ${input2.callID} needle=${JSON.stringify(cmdText !== undefined ? commandNeedle(cmdText) : undefined)} rawArgs=${JSON.stringify(output?.args ?? null).slice(0, 200)}
15922
+ appendFileSync(join4(tmpdir2(), "forge-watchdog-probe.log"), `${new Date().toISOString()} track ${input2.callID} needle=${JSON.stringify(cmdText !== undefined ? commandNeedle(cmdText) : undefined)} rawArgs=${JSON.stringify(output?.args ?? null).slice(0, 200)}
15189
15923
  `);
15190
15924
  } catch {}
15191
15925
  }
@@ -15204,7 +15938,7 @@ var server = async (input, options) => {
15204
15938
  const name = (typeof meta.tool === "string" ? meta.tool : undefined) ?? (typeof permissionField === "string" ? permissionField : undefined) ?? input2.id ?? input2.type;
15205
15939
  if (process.env.FORGE_PERM_PROBE) {
15206
15940
  try {
15207
- appendFileSync2(join3(tmpdir2(), "forge-perm-probe.log"), `${new Date().toISOString()} ask name=${JSON.stringify(name)} in_status=${output.status} id=${JSON.stringify(input2.id)} type=${JSON.stringify(input2.type)} meta=${JSON.stringify(input2.metadata)}
15941
+ appendFileSync(join4(tmpdir2(), "forge-perm-probe.log"), `${new Date().toISOString()} ask name=${JSON.stringify(name)} in_status=${output.status} id=${JSON.stringify(input2.id)} type=${JSON.stringify(input2.type)} meta=${JSON.stringify(input2.metadata)}
15208
15942
  `);
15209
15943
  } catch {}
15210
15944
  }
@@ -15253,7 +15987,7 @@ var server = async (input, options) => {
15253
15987
  watchdog.markSeen(input2.callID);
15254
15988
  if (process.env.FORGE_WATCHDOG_PROBE) {
15255
15989
  try {
15256
- appendFileSync2(join3(tmpdir2(), "forge-watchdog-probe.log"), `${new Date().toISOString()} mark ${input2.callID} hostpid=${process.pid}
15990
+ appendFileSync(join4(tmpdir2(), "forge-watchdog-probe.log"), `${new Date().toISOString()} mark ${input2.callID} hostpid=${process.pid}
15257
15991
  `);
15258
15992
  } catch {}
15259
15993
  }
@@ -15263,7 +15997,7 @@ var server = async (input, options) => {
15263
15997
  watchdog.untrack(input2.callID);
15264
15998
  if (process.env.FORGE_WATCHDOG_PROBE) {
15265
15999
  try {
15266
- appendFileSync2(join3(tmpdir2(), "forge-watchdog-probe.log"), `${new Date().toISOString()} untrack ${input2.callID}
16000
+ appendFileSync(join4(tmpdir2(), "forge-watchdog-probe.log"), `${new Date().toISOString()} untrack ${input2.callID}
15267
16001
  `);
15268
16002
  } catch {}
15269
16003
  }
@@ -15368,5 +16102,6 @@ export {
15368
16102
  server,
15369
16103
  isRootish,
15370
16104
  effectiveWorktree,
16105
+ effectiveSurvive,
15371
16106
  plugin_default as default
15372
16107
  };