@sorenllm/opencode-forge 0.2.2 → 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 -223
  2. package/dist/index.js +1971 -68
  3. package/package.json +61 -58
package/dist/index.js CHANGED
@@ -12334,9 +12334,10 @@ function tool(input) {
12334
12334
  }
12335
12335
  tool.schema = exports_external;
12336
12336
  // plugin.ts
12337
- import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
12338
- import { join as join2, relative } from "node:path";
12339
- import { tmpdir } from "node:os";
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
+ import { tmpdir as tmpdir2 } from "node:os";
12340
12341
 
12341
12342
  // src/plan-file.ts
12342
12343
  class PlanError extends Error {
@@ -13115,14 +13116,98 @@ function completeCheckFailures(doc2, attestations) {
13115
13116
  }
13116
13117
 
13117
13118
  // src/run-check.ts
13118
- import { spawn } from "node:child_process";
13119
+ import { spawn as spawn2 } from "node:child_process";
13119
13120
  import { readFileSync } from "node:fs";
13120
13121
  import { join, resolve, sep } from "node:path";
13122
+
13123
+ // src/proc.ts
13124
+ import { spawn, spawnSync } from "node:child_process";
13125
+ function shellSpawn(spawnFn, cmd, opts = {}) {
13126
+ return spawnFn(cmd, {
13127
+ shell: true,
13128
+ ...opts.cwd !== undefined ? { cwd: opts.cwd } : {},
13129
+ env: opts.env ? { ...process.env, ...opts.env } : process.env,
13130
+ windowsHide: opts.windowsHide ?? true,
13131
+ detached: opts.detached ?? process.platform !== "win32",
13132
+ ...opts.stdio !== undefined ? { stdio: opts.stdio } : {}
13133
+ });
13134
+ }
13135
+ function treeKillPlan(platform, pid, force = true) {
13136
+ if (platform === "win32")
13137
+ return { kind: "taskkill", args: force ? ["/pid", String(pid), "/F", "/T"] : ["/pid", String(pid), "/T"] };
13138
+ return { kind: "group", signal: force ? "SIGKILL" : "SIGTERM" };
13139
+ }
13140
+ function killTree(child, opts = {}) {
13141
+ const platform = opts.platform ?? process.platform;
13142
+ if (!child.pid) {
13143
+ child.kill();
13144
+ return;
13145
+ }
13146
+ const plan = treeKillPlan(platform, child.pid);
13147
+ if (plan.kind === "taskkill") {
13148
+ try {
13149
+ (opts.spawnFn ?? spawn)("taskkill", plan.args, { windowsHide: true, stdio: "ignore" });
13150
+ } catch {
13151
+ child.kill();
13152
+ }
13153
+ } else {
13154
+ try {
13155
+ process.kill(-child.pid, plan.signal);
13156
+ } catch {
13157
+ child.kill(plan.signal);
13158
+ }
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
+ }
13204
+
13205
+ // src/run-check.ts
13121
13206
  var OUTPUT_LIMIT = 2048;
13122
13207
  var defaultShellRunner = (cmd, opts) => new Promise((resolveRun) => {
13123
13208
  let child;
13124
13209
  try {
13125
- child = spawn(cmd, {
13210
+ child = spawn2(cmd, {
13126
13211
  shell: true,
13127
13212
  cwd: opts.cwd,
13128
13213
  windowsHide: true,
@@ -13135,28 +13220,9 @@ var defaultShellRunner = (cmd, opts) => new Promise((resolveRun) => {
13135
13220
  let output = "";
13136
13221
  let timedOut = false;
13137
13222
  let settled = false;
13138
- const killTree = () => {
13139
- if (!child.pid) {
13140
- child.kill();
13141
- return;
13142
- }
13143
- if (process.platform === "win32") {
13144
- try {
13145
- spawn("taskkill", ["/pid", String(child.pid), "/F", "/T"], { windowsHide: true, stdio: "ignore" });
13146
- } catch {
13147
- child.kill();
13148
- }
13149
- } else {
13150
- try {
13151
- process.kill(-child.pid, "SIGKILL");
13152
- } catch {
13153
- child.kill("SIGKILL");
13154
- }
13155
- }
13156
- };
13157
13223
  const timer = setTimeout(() => {
13158
13224
  timedOut = true;
13159
- killTree();
13225
+ killTree(child);
13160
13226
  }, opts.timeoutMs);
13161
13227
  const failsafe = setTimeout(() => settle({ code: null, output, timedOut: true, spawnError: "timeout settle fallback" }), opts.timeoutMs + 30000);
13162
13228
  failsafe.unref?.();
@@ -13249,6 +13315,1414 @@ function formatOutcomes(outcomes) {
13249
13315
  `);
13250
13316
  }
13251
13317
 
13318
+ // src/job-manager.ts
13319
+ var DEFAULT_MAX_FINISHED_JOBS = 50;
13320
+ var DEFAULT_MAX_TAIL_CHARS = 8192;
13321
+ var DEFAULT_MAX_REGISTRY_CHARS = 524288;
13322
+ var DEFAULT_WAKE_WINDOW_MS = 600000;
13323
+ var DEFAULT_MAX_LEDGER_ENTRIES = 200;
13324
+ function iso(now) {
13325
+ return new Date(now()).toISOString();
13326
+ }
13327
+ function createJobManager(opts = {}) {
13328
+ const now = opts.now ?? Date.now;
13329
+ const maxFinishedJobs = opts.maxFinishedJobs ?? DEFAULT_MAX_FINISHED_JOBS;
13330
+ const maxTailChars = opts.maxTailChars ?? DEFAULT_MAX_TAIL_CHARS;
13331
+ const maxRegistryChars = opts.maxRegistryChars ?? DEFAULT_MAX_REGISTRY_CHARS;
13332
+ const wakeWindowMs = opts.wakeWindowMs ?? DEFAULT_WAKE_WINDOW_MS;
13333
+ const maxLedgerEntries = opts.maxLedgerEntries ?? DEFAULT_MAX_LEDGER_ENTRIES;
13334
+ const jobs = new Map;
13335
+ const ledger = [];
13336
+ function emit(kind, job, detail) {
13337
+ const entry = { at: iso(now), kind, jobId: job.id, session: job.ownerSession, detail };
13338
+ ledger.push(entry);
13339
+ if (ledger.length > maxLedgerEntries)
13340
+ ledger.splice(0, ledger.length - maxLedgerEntries);
13341
+ try {
13342
+ opts.sink?.(entry);
13343
+ } catch {}
13344
+ }
13345
+ function totalRegistryChars() {
13346
+ let n = 0;
13347
+ for (const j of jobs.values())
13348
+ n += j.tail.length;
13349
+ return n;
13350
+ }
13351
+ function isTerminal2(job) {
13352
+ return job.state !== "running";
13353
+ }
13354
+ function enforceCaps() {
13355
+ const finished = [...jobs.values()].filter(isTerminal2).sort((a, b) => (a.endedAt ?? a.startedAt) - (b.endedAt ?? b.startedAt));
13356
+ let over = jobs.size - (maxFinishedJobs + countRunning());
13357
+ let chars = totalRegistryChars();
13358
+ for (const j of finished) {
13359
+ if (over <= 0 && chars <= maxRegistryChars)
13360
+ break;
13361
+ emit("evicted", j, `evicted from registry (state ${j.state})`);
13362
+ jobs.delete(j.id);
13363
+ over--;
13364
+ chars -= j.tail.length;
13365
+ }
13366
+ }
13367
+ function countRunning() {
13368
+ let n = 0;
13369
+ for (const j of jobs.values())
13370
+ if (!isTerminal2(j))
13371
+ n++;
13372
+ return n;
13373
+ }
13374
+ function create(input) {
13375
+ const t = now();
13376
+ const job = {
13377
+ ...input,
13378
+ scope: "session",
13379
+ state: "running",
13380
+ exitCode: null,
13381
+ startedAt: t,
13382
+ endedAt: null,
13383
+ lastOutputAt: t,
13384
+ tail: "",
13385
+ outLen: 0,
13386
+ pollCursor: 0,
13387
+ readAfterEnd: false,
13388
+ succeededAt: null,
13389
+ wakeState: "none",
13390
+ wakeQueuedAt: null
13391
+ };
13392
+ jobs.set(job.id, job);
13393
+ enforceCaps();
13394
+ return job;
13395
+ }
13396
+ function setPid(job, pid) {
13397
+ job.pid = pid;
13398
+ }
13399
+ function get(id) {
13400
+ return jobs.get(id);
13401
+ }
13402
+ function list() {
13403
+ return [...jobs.values()].sort((a, b) => b.startedAt - a.startedAt);
13404
+ }
13405
+ function appendOutput(job, chunk) {
13406
+ if (!chunk)
13407
+ return;
13408
+ job.outLen += chunk.length;
13409
+ job.lastOutputAt = now();
13410
+ const next = job.tail + chunk;
13411
+ job.tail = next.length > maxTailChars ? next.slice(next.length - maxTailChars) : next;
13412
+ }
13413
+ function markTerminal(job, state, exitCode) {
13414
+ if (isTerminal2(job))
13415
+ return;
13416
+ job.state = state;
13417
+ job.exitCode = exitCode;
13418
+ job.endedAt = now();
13419
+ if (job.notify && job.wakeState === "none") {
13420
+ job.wakeState = "queued";
13421
+ job.wakeQueuedAt = now();
13422
+ }
13423
+ enforceCaps();
13424
+ }
13425
+ function kill(job) {
13426
+ if (isTerminal2(job))
13427
+ return;
13428
+ try {
13429
+ job.killTree();
13430
+ } catch {}
13431
+ markTerminal(job, "killed", null);
13432
+ }
13433
+ function poll(job) {
13434
+ if (isTerminal2(job))
13435
+ job.readAfterEnd = true;
13436
+ const cursor = job.outLen;
13437
+ const start = job.pollCursor;
13438
+ const newOutput = sliceFromCursor(job, start);
13439
+ job.pollCursor = cursor;
13440
+ return {
13441
+ state: job.state,
13442
+ exitCode: job.exitCode,
13443
+ newOutput,
13444
+ cursor,
13445
+ succeeded: job.succeededAt !== null,
13446
+ logPath: job.logPath
13447
+ };
13448
+ }
13449
+ function sliceFromCursor(job, cursor) {
13450
+ const startOffset = Math.max(0, cursor - (job.outLen - job.tail.length));
13451
+ return job.tail.slice(startOffset);
13452
+ }
13453
+ function clear(id) {
13454
+ const job = jobs.get(id);
13455
+ if (!job || !isTerminal2(job))
13456
+ return false;
13457
+ jobs.delete(id);
13458
+ return true;
13459
+ }
13460
+ function handoff(id, toSession) {
13461
+ const job = jobs.get(id);
13462
+ if (!job)
13463
+ return;
13464
+ job.scope = "global";
13465
+ if (toSession)
13466
+ job.ownerSession = toSession;
13467
+ return job;
13468
+ }
13469
+ function onSessionEnd(sessionID) {
13470
+ for (const job of [...jobs.values()]) {
13471
+ if (job.ownerSession !== sessionID)
13472
+ continue;
13473
+ if (job.survive)
13474
+ continue;
13475
+ if (!isTerminal2(job)) {
13476
+ if (job.scope === "global")
13477
+ continue;
13478
+ kill(job);
13479
+ emit("orphan-job", job, `owner session ended; tree killed (was: ${job.cmd.slice(0, 120)})`);
13480
+ } else if (!job.readAfterEnd) {
13481
+ emit("unread-completion", job, `owner session ended before the completion was read (state ${job.state}, exit ${job.exitCode})`);
13482
+ }
13483
+ jobs.delete(job.id);
13484
+ }
13485
+ }
13486
+ function disposeAll() {
13487
+ for (const job of [...jobs.values()]) {
13488
+ if (job.survive)
13489
+ continue;
13490
+ if (!isTerminal2(job)) {
13491
+ kill(job);
13492
+ emit("orphan-job", job, "plugin dispose; tree killed");
13493
+ }
13494
+ jobs.delete(job.id);
13495
+ }
13496
+ }
13497
+ function deliverWakesFor(sessionID) {
13498
+ abandonStaleWakes();
13499
+ const ready = [];
13500
+ for (const job of jobs.values()) {
13501
+ if (job.wakeState === "queued" && job.ownerSession === sessionID) {
13502
+ job.wakeState = "delivered";
13503
+ ready.push(job);
13504
+ }
13505
+ }
13506
+ return ready;
13507
+ }
13508
+ function abandonStaleWakes() {
13509
+ const t = now();
13510
+ for (const job of jobs.values()) {
13511
+ if (job.wakeState === "queued" && job.wakeQueuedAt !== null && t - job.wakeQueuedAt > wakeWindowMs) {
13512
+ job.wakeState = "abandoned";
13513
+ emit("wake-timeout", job, `session stayed busy past the ${wakeWindowMs}ms delivery window (state ${job.state}, exit ${job.exitCode})`);
13514
+ }
13515
+ }
13516
+ }
13517
+ function ledgerEntries() {
13518
+ return [...ledger];
13519
+ }
13520
+ return {
13521
+ create,
13522
+ setPid,
13523
+ get,
13524
+ list,
13525
+ appendOutput,
13526
+ markTerminal,
13527
+ kill,
13528
+ poll,
13529
+ clear,
13530
+ handoff,
13531
+ onSessionEnd,
13532
+ disposeAll,
13533
+ deliverWakesFor,
13534
+ abandonStaleWakes,
13535
+ ledgerEntries,
13536
+ size: () => jobs.size
13537
+ };
13538
+ }
13539
+ function newJobId(now = Date.now) {
13540
+ const d = new Date(now());
13541
+ const p = (n, w = 2) => String(n).padStart(w, "0");
13542
+ const rand = Math.random().toString(36).slice(2, 8);
13543
+ return `j-${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}-${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}-${rand}`;
13544
+ }
13545
+
13546
+ // src/job-runner.ts
13547
+ import { spawn as spawn3 } from "node:child_process";
13548
+ import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readSync, readFileSync as readFileSync2, statSync, unlinkSync } from "node:fs";
13549
+ import { join as join2 } from "node:path";
13550
+ import { tmpdir } from "node:os";
13551
+ var DEFAULT_IDLE_MS = 60000;
13552
+ var DEFAULT_MAX_WAIT_MS = 120000;
13553
+ var HARD_MAX_WAIT_MS = 600000;
13554
+ var POLL_WAIT_MAX_MS = 30000;
13555
+ var DEFAULT_LOG_KEEP = 50;
13556
+ var JOB_ENV_MARKER = "FORGE_JOB_ID";
13557
+ var TAIL_POLL_MS = 200;
13558
+ var ADOPT_LIVENESS_MS = 1000;
13559
+ function jobsLogDir(base) {
13560
+ return join2(base ?? join2(tmpdir(), "opencode-forge"), "jobs");
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
+ }
13612
+ function startJob(manager, opts) {
13613
+ const idleMs = Math.max(0, opts.idleMs ?? DEFAULT_IDLE_MS);
13614
+ const maxWaitMs = Math.min(Math.max(0, opts.maxWaitMs ?? DEFAULT_MAX_WAIT_MS), HARD_MAX_WAIT_MS);
13615
+ mkdirSync(opts.logDir, { recursive: true });
13616
+ const id = newJobId();
13617
+ const logPath = join2(opts.logDir, `${id}.log`);
13618
+ let child;
13619
+ let settled = false;
13620
+ let exiting = false;
13621
+ let idleTimer = null;
13622
+ const maxWaitTimer = setTimeout(() => {
13623
+ resolveStillRunning();
13624
+ }, maxWaitMs);
13625
+ maxWaitTimer.unref?.();
13626
+ let resolveSettle = () => {};
13627
+ const settle = new Promise((res) => {
13628
+ resolveSettle = res;
13629
+ });
13630
+ const armIdle = () => {
13631
+ if (idleTimer)
13632
+ clearTimeout(idleTimer);
13633
+ idleTimer = setTimeout(() => resolveStillRunning(), idleMs);
13634
+ idleTimer.unref?.();
13635
+ };
13636
+ const stopTimers = () => {
13637
+ if (idleTimer)
13638
+ clearTimeout(idleTimer);
13639
+ idleTimer = null;
13640
+ clearTimeout(maxWaitTimer);
13641
+ };
13642
+ const firstOutputHooks = [];
13643
+ function resolveStillRunning() {
13644
+ if (settled)
13645
+ return;
13646
+ settled = true;
13647
+ stopTimers();
13648
+ resolveSettle({ status: "still-running", idleForMs: Date.now() - job.lastOutputAt, outputTail: job.tail });
13649
+ }
13650
+ const job = manager.create({
13651
+ id,
13652
+ cmd: opts.cmd,
13653
+ worktree: opts.worktree,
13654
+ ownerSession: opts.ownerSession,
13655
+ logPath,
13656
+ notify: opts.notify ?? true,
13657
+ killTree: () => {
13658
+ killTree(child);
13659
+ if (opts.survive)
13660
+ opts.registry?.remove(id);
13661
+ },
13662
+ ...opts.survive ? { survive: true } : {}
13663
+ });
13664
+ let wfd;
13665
+ try {
13666
+ wfd = openSync(logPath, "a");
13667
+ child = shellSpawn(opts.spawnFn ?? spawn3, opts.cmd, {
13668
+ cwd: opts.cwd,
13669
+ env: { ...opts.env ?? {}, [JOB_ENV_MARKER]: id },
13670
+ stdio: ["ignore", wfd, wfd]
13671
+ });
13672
+ } catch (err) {
13673
+ if (wfd !== undefined) {
13674
+ try {
13675
+ closeSync(wfd);
13676
+ } catch {}
13677
+ }
13678
+ manager.markTerminal(job, "killed", null);
13679
+ maxWaitTimer && clearTimeout(maxWaitTimer);
13680
+ resolveSettle({ status: "exited", exitCode: null, outputTail: "", spawnError: String(err) });
13681
+ return { job, settle };
13682
+ }
13683
+ if (wfd !== undefined) {
13684
+ try {
13685
+ closeSync(wfd);
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);
13718
+ if (opts.successPattern && !settled && job.succeededAt === null) {
13719
+ const m = opts.successPattern.exec(text) ?? opts.successPattern.exec(job.tail);
13720
+ if (m) {
13721
+ settled = true;
13722
+ stopTimers();
13723
+ job.succeededAt = Date.now();
13724
+ const keptAlive = opts.keepAlive !== false;
13725
+ if (!keptAlive) {
13726
+ killTree(child);
13727
+ if (opts.survive)
13728
+ opts.registry?.remove(id);
13729
+ manager.markTerminal(job, "succeeded", null);
13730
+ }
13731
+ resolveSettle({ status: "succeeded", matched: m[0], outputTail: job.tail, keptAlive });
13732
+ }
13733
+ }
13734
+ };
13735
+ const tail = createFileTail(logPath, onChunk);
13736
+ liveTails.set(id, tail);
13737
+ child.on("exit", (code) => {
13738
+ if (exiting)
13739
+ return;
13740
+ exiting = true;
13741
+ stopTimers();
13742
+ let finished = false;
13743
+ const finish = () => {
13744
+ if (finished)
13745
+ return;
13746
+ finished = true;
13747
+ tail.flush();
13748
+ tail.stop();
13749
+ liveTails.delete(id);
13750
+ manager.markTerminal(job, "exited", code);
13751
+ if (opts.survive)
13752
+ opts.registry?.remove(id);
13753
+ rotateLogs(opts.logDir);
13754
+ if (!settled) {
13755
+ settled = true;
13756
+ resolveSettle({ status: "exited", exitCode: code, outputTail: job.tail });
13757
+ }
13758
+ };
13759
+ const graceTimer = setTimeout(finish, opts.exitGraceMs ?? 500);
13760
+ graceTimer.unref?.();
13761
+ });
13762
+ child.on("error", (err) => {
13763
+ tail.stop();
13764
+ liveTails.delete(id);
13765
+ if (opts.survive)
13766
+ opts.registry?.remove(id);
13767
+ if (!settled) {
13768
+ settled = true;
13769
+ stopTimers();
13770
+ manager.markTerminal(job, "killed", null);
13771
+ resolveSettle({ status: "exited", exitCode: null, outputTail: job.tail, spawnError: err.message });
13772
+ }
13773
+ });
13774
+ if (opts.runInBackground) {
13775
+ stopTimers();
13776
+ } else {
13777
+ armIdle();
13778
+ }
13779
+ return { job, settle };
13780
+ }
13781
+ async function pollJob(manager, jobId, waitMs = 0) {
13782
+ const job = manager.get(jobId);
13783
+ if (!job)
13784
+ return;
13785
+ const deadline = Date.now() + Math.min(Math.max(0, waitMs), POLL_WAIT_MAX_MS);
13786
+ while (job.outLen === job.pollCursor && job.state === "running" && Date.now() < deadline) {
13787
+ await new Promise((r) => setTimeout(r, 25));
13788
+ flushJobOutput(job);
13789
+ }
13790
+ flushJobOutput(job);
13791
+ return manager.poll(job);
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;
13834
+ function readJobLog(logPath, opts = {}) {
13835
+ const limit = Math.max(1, opts.limit ?? 200);
13836
+ let raw = "";
13837
+ let windowed = false;
13838
+ try {
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
+ }
13863
+ } catch {
13864
+ raw = "";
13865
+ }
13866
+ const lines = raw.length === 0 ? [] : raw.split(/\r?\n/);
13867
+ if (lines.length > 0 && lines[lines.length - 1] === "")
13868
+ lines.pop();
13869
+ const total = lines.length;
13870
+ const offset = opts.offset !== undefined ? Math.max(0, Math.min(opts.offset, total)) : Math.max(0, total - limit);
13871
+ return { lines: lines.slice(offset, offset + limit), total, offset, ...windowed ? { windowed: true } : {} };
13872
+ }
13873
+ function rotateLogs(logDir, keep = DEFAULT_LOG_KEEP) {
13874
+ let entries = [];
13875
+ try {
13876
+ entries = readdirSync(logDir).filter((f) => f.endsWith(".log")).map((name) => {
13877
+ try {
13878
+ return { name, mtime: statSync(join2(logDir, name)).mtimeMs };
13879
+ } catch {
13880
+ return { name, mtime: 0 };
13881
+ }
13882
+ });
13883
+ } catch {
13884
+ return;
13885
+ }
13886
+ const excess = entries.length - keep;
13887
+ if (excess <= 0)
13888
+ return;
13889
+ entries.sort((a, b) => a.mtime - b.mtime);
13890
+ for (const e of entries.slice(0, excess)) {
13891
+ try {
13892
+ unlinkSync(join2(logDir, e.name));
13893
+ } catch {}
13894
+ }
13895
+ }
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
+
14355
+ // src/watchdog.ts
14356
+ import { mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "node:fs";
14357
+ import { dirname as dirname2 } from "node:path";
14358
+ var WATCHDOG_ENV_MARK = "FORGE_WATCHDOG_MARK";
14359
+ var DEFAULT_STALL_MS = 600000;
14360
+ var MIN_STALL_MS = 60000;
14361
+ var DEFAULT_INTERVAL_MS = 30000;
14362
+ var DEFAULT_OBSERVE_MS = 60000;
14363
+ var WARN_RATIO = 0.8;
14364
+ function clampStallMs(raw, min = MIN_STALL_MS) {
14365
+ const n = typeof raw === "number" && Number.isFinite(raw) ? raw : DEFAULT_STALL_MS;
14366
+ return Math.max(min, Math.floor(n));
14367
+ }
14368
+ function parseMode(raw) {
14369
+ return raw === "off" || raw === "dry-run" || raw === "kill" ? raw : "kill";
14370
+ }
14371
+ function createWatchdog(opts = {}) {
14372
+ const mode = parseMode(opts.mode);
14373
+ const stallMs = clampStallMs(opts.stallMs, opts.minStallMs ?? MIN_STALL_MS);
14374
+ const intervalMs = Math.max(1, opts.intervalMs ?? DEFAULT_INTERVAL_MS);
14375
+ const observeMs = opts.observeMs ?? DEFAULT_OBSERVE_MS;
14376
+ const now = opts.now ?? Date.now;
14377
+ const sink = opts.sink ?? (() => {});
14378
+ const locate = opts.locate ?? (async () => []);
14379
+ const killTree2 = opts.killTree ?? (() => {});
14380
+ const isAlive = opts.isAlive ?? ((pid) => {
14381
+ try {
14382
+ process.kill(pid, 0);
14383
+ return true;
14384
+ } catch {
14385
+ return false;
14386
+ }
14387
+ });
14388
+ const setIntervalFn = opts.setIntervalFn ?? setInterval;
14389
+ const clearIntervalFn = opts.clearIntervalFn ?? clearInterval;
14390
+ let currentMode = mode;
14391
+ const table = new Map;
14392
+ const markerSeenIDs = new Set;
14393
+ let timer = null;
14394
+ const write = (event, rec, extra = {}) => {
14395
+ sink({
14396
+ ts: new Date(now()).toISOString(),
14397
+ event,
14398
+ callID: rec.callID,
14399
+ ...rec.sessionID !== undefined ? { sessionID: rec.sessionID } : {},
14400
+ tool: rec.tool,
14401
+ t0: rec.t0,
14402
+ elapsedMs: now() - rec.t0,
14403
+ mode: currentMode,
14404
+ ...extra
14405
+ });
14406
+ };
14407
+ const ensureTimer = () => {
14408
+ if (currentMode === "off" || timer)
14409
+ return;
14410
+ timer = setIntervalFn(() => {
14411
+ scan();
14412
+ }, intervalMs);
14413
+ timer.unref?.();
14414
+ };
14415
+ const maybeStopTimer = () => {
14416
+ if (timer && table.size === 0) {
14417
+ clearIntervalFn(timer);
14418
+ timer = null;
14419
+ }
14420
+ };
14421
+ const act = async (rec) => {
14422
+ rec.acted = true;
14423
+ try {
14424
+ if (!rec.markerSeen) {
14425
+ const pids2 = await locate(rec.callID, rec.t0, rec.cmdNeedle);
14426
+ write("dry-run-candidate", rec, {
14427
+ pids: pids2,
14428
+ reason: "marker-missing (shell.env hook did not fire) — degraded to dry-run"
14429
+ });
14430
+ return;
14431
+ }
14432
+ const pidsA = await locate(rec.callID, rec.t0, rec.cmdNeedle, false);
14433
+ let pids = pidsA;
14434
+ if (pidsA.length === 0 && rec.cmdNeedle !== undefined) {
14435
+ const pidsB = await locate(rec.callID, rec.t0, rec.cmdNeedle, true);
14436
+ if (pidsB.length > 0) {
14437
+ pids = pidsB;
14438
+ rec.wave = 2;
14439
+ }
14440
+ }
14441
+ if (pids.length === 0) {
14442
+ write("unresolved", rec, { reason: "no matching process found" });
14443
+ return;
14444
+ }
14445
+ if (currentMode === "dry-run") {
14446
+ write("dry-run-candidate", rec, { pids, candidates: pidsA });
14447
+ return;
14448
+ }
14449
+ const killed = [];
14450
+ for (const p of pids) {
14451
+ try {
14452
+ killTree2(p.pid);
14453
+ killed.push(p);
14454
+ } catch {}
14455
+ }
14456
+ rec.killedAt = now();
14457
+ if (rec.wave === 0)
14458
+ rec.wave = 1;
14459
+ write("kill", rec, {
14460
+ pids: killed,
14461
+ candidates: pids,
14462
+ reason: pids === pidsA ? "wave 1 — host subtree / command match" : "workspace scope (call's own chain already exited)"
14463
+ });
14464
+ } catch (err) {
14465
+ write("unresolved", rec, { reason: `intervention failed: ${String(err)}` });
14466
+ }
14467
+ };
14468
+ const scan = async () => {
14469
+ if (currentMode === "off")
14470
+ return;
14471
+ const nowMs = now();
14472
+ for (const rec of table.values()) {
14473
+ const elapsed = nowMs - rec.t0;
14474
+ if (rec.acted && rec.killedAt !== undefined && !rec.unresolvedReported) {
14475
+ if (nowMs - rec.killedAt >= observeMs) {
14476
+ if (currentMode === "kill" && rec.wave === 1 && rec.cmdNeedle !== undefined) {
14477
+ const wave2 = await locate(rec.callID, rec.t0, rec.cmdNeedle, true);
14478
+ const stillAlive = wave2.filter((p) => isAlive(p.pid));
14479
+ if (stillAlive.length > 0) {
14480
+ const killed2 = [];
14481
+ for (const p of stillAlive) {
14482
+ try {
14483
+ killTree2(p.pid);
14484
+ killed2.push(p);
14485
+ } catch {}
14486
+ }
14487
+ rec.killedAt = now();
14488
+ rec.wave = 2;
14489
+ write("kill", rec, { pids: killed2, reason: "wave 2 — workspace scope (wave 1 did not unblock the call)" });
14490
+ return;
14491
+ }
14492
+ }
14493
+ rec.unresolvedReported = true;
14494
+ write("unresolved", rec, { reason: "no tool.execute.after within the observation window after kill" });
14495
+ }
14496
+ continue;
14497
+ }
14498
+ if (rec.acted)
14499
+ continue;
14500
+ if (elapsed >= stallMs) {
14501
+ await act(rec);
14502
+ continue;
14503
+ }
14504
+ if (elapsed >= WARN_RATIO * stallMs && !rec.warned) {
14505
+ rec.warned = true;
14506
+ write("warn", rec);
14507
+ }
14508
+ }
14509
+ maybeStopTimer();
14510
+ };
14511
+ return {
14512
+ get mode() {
14513
+ return currentMode;
14514
+ },
14515
+ get stallMs() {
14516
+ return stallMs;
14517
+ },
14518
+ track(callID, sessionID, tool3, t0 = now(), cmdNeedle) {
14519
+ if (currentMode === "off")
14520
+ return;
14521
+ table.set(callID, {
14522
+ callID,
14523
+ sessionID,
14524
+ tool: tool3,
14525
+ t0,
14526
+ markerSeen: markerSeenIDs.has(callID),
14527
+ warned: false,
14528
+ acted: false,
14529
+ wave: 0,
14530
+ ...cmdNeedle !== undefined && cmdNeedle.length > 0 ? { cmdNeedle } : {}
14531
+ });
14532
+ ensureTimer();
14533
+ },
14534
+ markSeen(callID) {
14535
+ markerSeenIDs.add(callID);
14536
+ const rec = table.get(callID);
14537
+ if (rec)
14538
+ rec.markerSeen = true;
14539
+ },
14540
+ untrack(callID) {
14541
+ table.delete(callID);
14542
+ maybeStopTimer();
14543
+ },
14544
+ has: (callID) => table.has(callID),
14545
+ size: () => table.size,
14546
+ scan,
14547
+ setMode(next) {
14548
+ currentMode = next;
14549
+ if (next === "off") {
14550
+ if (timer) {
14551
+ clearIntervalFn(timer);
14552
+ timer = null;
14553
+ }
14554
+ table.clear();
14555
+ } else {
14556
+ ensureTimer();
14557
+ }
14558
+ },
14559
+ dispose() {
14560
+ if (timer) {
14561
+ clearIntervalFn(timer);
14562
+ timer = null;
14563
+ }
14564
+ table.clear();
14565
+ }
14566
+ };
14567
+ }
14568
+ function watchdogLogDir(base) {
14569
+ const tmp = base ?? (process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp");
14570
+ return `${tmp.replace(/[\\/]+$/, "")}/opencode-forge/watchdog`;
14571
+ }
14572
+ function createFileLedger(logPath, maxEntries = 200, maxBytes = 1e6) {
14573
+ const append = (entry) => {
14574
+ try {
14575
+ mkdirSync3(dirname2(logPath), { recursive: true });
14576
+ let lines = [];
14577
+ try {
14578
+ lines = readFileSync4(logPath, "utf8").split(`
14579
+ `).filter((l) => l.trim().length > 0);
14580
+ } catch {}
14581
+ if (lines.length >= maxEntries)
14582
+ lines = lines.slice(lines.length - maxEntries + 1);
14583
+ lines.push(JSON.stringify(entry));
14584
+ let text = lines.join(`
14585
+ `) + `
14586
+ `;
14587
+ if (text.length > maxBytes) {
14588
+ const keep = Math.max(1, Math.floor(maxEntries / 2));
14589
+ text = lines.slice(-keep).join(`
14590
+ `) + `
14591
+ `;
14592
+ }
14593
+ writeFileSync3(logPath, text, "utf8");
14594
+ } catch {}
14595
+ };
14596
+ const entries = () => {
14597
+ try {
14598
+ return readFileSync4(logPath, "utf8").split(`
14599
+ `).filter((l) => l.trim().length > 0).map((l) => JSON.parse(l));
14600
+ } catch {
14601
+ return [];
14602
+ }
14603
+ };
14604
+ return { append, entries, path: logPath };
14605
+ }
14606
+
14607
+ // src/proc-locate.ts
14608
+ import { execFileSync as execFileSync2 } from "node:child_process";
14609
+ import { readdirSync as readdirSync3, readFileSync as readFileSync5 } from "node:fs";
14610
+ function commandNeedle(command) {
14611
+ const trimmed = command.trim().replace(/^["']|["']$/g, "");
14612
+ if (trimmed.length === 0)
14613
+ return "";
14614
+ const tokens = trimmed.split(/\s+/);
14615
+ const longest = tokens.reduce((a, b) => b.length > a.length ? b : a, "");
14616
+ return longest.length >= 6 ? longest : trimmed;
14617
+ }
14618
+ function markerValue(callID) {
14619
+ return `opencode-forge:${callID}`;
14620
+ }
14621
+ function createPosixLocator(deps = {}) {
14622
+ const listPids = deps.listPids ?? (() => readdirSync3("/proc").map(Number).filter((n) => Number.isInteger(n) && n > 0));
14623
+ const readEnv = deps.readEnv ?? ((pid) => {
14624
+ try {
14625
+ return readFileSync5(`/proc/${pid}/environ`);
14626
+ } catch {
14627
+ return null;
14628
+ }
14629
+ });
14630
+ const readCmd = deps.readCmd ?? ((pid) => {
14631
+ try {
14632
+ return readFileSync5(`/proc/${pid}/cmdline`).toString("utf8").split("\x00").filter(Boolean).join(" ");
14633
+ } catch {
14634
+ return String(pid);
14635
+ }
14636
+ });
14637
+ return async (callID, _t0) => {
14638
+ const want = `${WATCHDOG_ENV_MARK}=${markerValue(callID)}`;
14639
+ const hits = [];
14640
+ for (const pid of listPids()) {
14641
+ const env = readEnv(pid);
14642
+ if (!env)
14643
+ continue;
14644
+ if (env.toString("utf8").split("\x00").includes(want))
14645
+ hits.push({ pid, cmd: readCmd(pid) });
14646
+ }
14647
+ return hits;
14648
+ };
14649
+ }
14650
+ var PS_LIST_PROCS = "[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; " + "Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CommandLine,CreationDate " + "| ConvertTo-Json -Compress";
14651
+ function parseWindowsProcs(raw) {
14652
+ let parsed;
14653
+ try {
14654
+ parsed = JSON.parse(raw);
14655
+ } catch {
14656
+ return [];
14657
+ }
14658
+ const arr = Array.isArray(parsed) ? parsed : [parsed];
14659
+ const out = [];
14660
+ for (const p of arr) {
14661
+ const pid = Number(p?.ProcessId ?? p?.pid);
14662
+ const ppid = Number(p?.ParentProcessId ?? p?.ppid);
14663
+ if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(ppid))
14664
+ continue;
14665
+ const cmd = typeof p?.CommandLine === "string" ? p.CommandLine : "";
14666
+ let createdMs = Number.NaN;
14667
+ const cd = p?.CreationDate ?? p?.createdMs;
14668
+ if (typeof cd === "number")
14669
+ createdMs = cd;
14670
+ else if (typeof cd === "string") {
14671
+ const slash = /\/Date\((\d+)\)\//.exec(cd);
14672
+ if (slash)
14673
+ createdMs = Number(slash[1]);
14674
+ else {
14675
+ const t = Date.parse(cd);
14676
+ if (!Number.isNaN(t))
14677
+ createdMs = t;
14678
+ }
14679
+ }
14680
+ if (Number.isNaN(createdMs))
14681
+ continue;
14682
+ out.push({ pid, ppid, cmd, createdMs });
14683
+ }
14684
+ return out;
14685
+ }
14686
+ var WINDOW_SLACK_MS = 2000;
14687
+ function createWindowsLocator(deps = {}) {
14688
+ const hostPid = deps.hostPid ?? process.pid;
14689
+ const execFn = deps.execFn ?? ((cmd) => execFileSync2("powershell", ["-NoProfile", "-Command", cmd], { encoding: "utf8", windowsHide: true, timeout: 15000 }));
14690
+ return async (callID, t0, cmdNeedle, phase2 = false) => {
14691
+ let raw = "[]";
14692
+ try {
14693
+ raw = execFn(PS_LIST_PROCS);
14694
+ } catch {
14695
+ return [];
14696
+ }
14697
+ const procs = parseWindowsProcs(raw);
14698
+ const children = new Map;
14699
+ for (const p of procs) {
14700
+ const list = children.get(p.ppid) ?? [];
14701
+ list.push(p.pid);
14702
+ children.set(p.ppid, list);
14703
+ }
14704
+ const descendants = new Set;
14705
+ const queue = [hostPid];
14706
+ while (queue.length > 0) {
14707
+ const cur = queue.pop();
14708
+ for (const child of children.get(cur) ?? []) {
14709
+ if (!descendants.has(child)) {
14710
+ descendants.add(child);
14711
+ queue.push(child);
14712
+ }
14713
+ }
14714
+ }
14715
+ const windowStart = t0 - WINDOW_SLACK_MS;
14716
+ const norm = (s) => s.replace(/\\/g, "/");
14717
+ const slash = cmdNeedle !== undefined ? cmdNeedle.lastIndexOf("/") : -1;
14718
+ const dirNeedle = phase2 && cmdNeedle !== undefined && slash > 0 ? norm(cmdNeedle.slice(0, slash)) : undefined;
14719
+ return procs.filter((p) => p.createdMs >= windowStart && p.pid !== hostPid && !/Win32_Process/.test(p.cmd) && !/conhost\.exe/i.test(p.cmd) && (descendants.has(p.pid) || cmdNeedle !== undefined && cmdNeedle.length >= 6 && p.cmd.includes(cmdNeedle) || dirNeedle !== undefined && dirNeedle.length >= 6 && norm(p.cmd).includes(dirNeedle))).map((p) => ({ pid: p.pid, cmd: p.cmd, createdMs: p.createdMs }));
14720
+ };
14721
+ }
14722
+ function createLocator(platform = process.platform, posix = {}, win = {}) {
14723
+ return platform === "win32" ? createWindowsLocator(win) : createPosixLocator(posix);
14724
+ }
14725
+
13252
14726
  // plugin.ts
13253
14727
  var FORGE_AGENT = "forge";
13254
14728
  var FORGE_PROMPT = `You are forge — the single general-purpose coding agent. You handle every task directly: exploration, planning, implementation, and verification. There is no agent switching.
@@ -13312,6 +14786,109 @@ function effectiveWorktree(worktree, fallback) {
13312
14786
  }
13313
14787
  var sessions = new Map;
13314
14788
  var forgeDisabled = false;
14789
+ var jobsMode = "auto";
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
+ }
14832
+ var nativeBackgroundSeen = false;
14833
+ function jobStage() {
14834
+ if (jobsMode === "native")
14835
+ return 2;
14836
+ if (jobsMode === "forge")
14837
+ return 0;
14838
+ return nativeBackgroundSeen ? 1 : 0;
14839
+ }
14840
+ var jobLogDir = jobsLogDir();
14841
+ var jobLedgerPath = join4(jobLogDir, "ledger.jsonl");
14842
+ function jobLedgerSink(entry) {
14843
+ try {
14844
+ mkdirSync4(jobLogDir, { recursive: true });
14845
+ if (existsSync3(jobLedgerPath) && statSync3(jobLedgerPath).size > 1e6)
14846
+ writeFileSync4(jobLedgerPath, "");
14847
+ appendFileSync(jobLedgerPath, `${JSON.stringify(entry)}
14848
+ `);
14849
+ } catch {}
14850
+ }
14851
+ var jobManager = createJobManager({ sink: jobLedgerSink });
14852
+ var jobClient = null;
14853
+ function jobWakeText(job) {
14854
+ return [
14855
+ `[forge:job-complete] Background job ${job.id} finished (${job.state}${job.exitCode !== null ? `, exit ${job.exitCode}` : ""}).`,
14856
+ `Command: ${job.cmd}`,
14857
+ `Recent output:
14858
+ ${job.tail.slice(-1500) || "(none)"}`,
14859
+ "Details via forge_jobs (poll/log); the job stays in the registry until cleared."
14860
+ ].join(`
14861
+ `);
14862
+ }
14863
+ async function deliverJobWakes(sessionID) {
14864
+ if (forgeDisabled || jobStage() >= 2)
14865
+ return;
14866
+ if (!jobClient || typeof jobClient.session.promptAsync !== "function")
14867
+ return;
14868
+ for (const job of jobManager.deliverWakesFor(sessionID)) {
14869
+ try {
14870
+ await jobClient.session.promptAsync({ path: { id: sessionID }, body: { parts: [{ type: "text", text: jobWakeText(job) }] } });
14871
+ } catch {
14872
+ job.wakeState = "queued";
14873
+ }
14874
+ }
14875
+ }
14876
+ async function handoffTarget(sessionID) {
14877
+ if (!jobClient || typeof jobClient.session.get !== "function")
14878
+ return;
14879
+ let current = sessionID;
14880
+ for (let depth = 0;depth < 10; depth++) {
14881
+ try {
14882
+ const info = await jobClient.session.get({ path: { id: current } });
14883
+ if (!info?.parentID)
14884
+ return current === sessionID ? undefined : current;
14885
+ current = info.parentID;
14886
+ } catch {
14887
+ return;
14888
+ }
14889
+ }
14890
+ return current === sessionID ? undefined : current;
14891
+ }
13315
14892
  var WRITE_TOOLS = new Set(["write", "edit", "bash", "task", "apply", "applypatch", "patch", "multiedit"]);
13316
14893
  function isWriteTool(name) {
13317
14894
  const n = name.toLowerCase();
@@ -13321,13 +14898,13 @@ function nowIso() {
13321
14898
  return new Date().toISOString();
13322
14899
  }
13323
14900
  function planDirOf(worktree) {
13324
- return join2(worktree, ".opencode", "plan");
14901
+ return join4(worktree, ".opencode", "plan");
13325
14902
  }
13326
14903
  function readPlanDir(worktree) {
13327
14904
  const dir = planDirOf(worktree);
13328
- if (!existsSync(dir))
14905
+ if (!existsSync3(dir))
13329
14906
  return [];
13330
- return readdirSync(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync2(join2(dir, name), "utf8") }));
14907
+ return readdirSync4(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync6(join4(dir, name), "utf8") }));
13331
14908
  }
13332
14909
  function ensureSession(sessionID, worktree) {
13333
14910
  const existing = sessions.get(sessionID);
@@ -13343,8 +14920,8 @@ function worktreeFor(context) {
13343
14920
  return effectiveWorktree(context.worktree, hostWorktree) || context.worktree;
13344
14921
  }
13345
14922
  function resolveActivePlan(state) {
13346
- if (state.planPath && existsSync(state.planPath)) {
13347
- const doc2 = parsePlanLoose(readFileSync2(state.planPath, "utf8"));
14923
+ if (state.planPath && existsSync3(state.planPath)) {
14924
+ const doc2 = parsePlanLoose(readFileSync6(state.planPath, "utf8"));
13348
14925
  if (doc2 && !isTerminal(doc2.status))
13349
14926
  return { path: state.planPath, doc: doc2 };
13350
14927
  state.planPath = undefined;
@@ -13352,7 +14929,7 @@ function resolveActivePlan(state) {
13352
14929
  const ranked = rankActivePlans(readPlanDir(state.worktree));
13353
14930
  if (ranked.length === 0)
13354
14931
  return null;
13355
- const path = join2(planDirOf(state.worktree), ranked[0].name);
14932
+ const path = join4(planDirOf(state.worktree), ranked[0].name);
13356
14933
  state.planPath = path;
13357
14934
  return { path, doc: ranked[0].doc };
13358
14935
  }
@@ -13386,12 +14963,12 @@ var planWriteTool = tool({
13386
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.`);
13387
14964
  } else {
13388
14965
  const dir = planDirOf(state.worktree);
13389
- mkdirSync(dir, { recursive: true });
13390
- path = join2(dir, planFileName(localDate(), slugify(args.goal), readdirSync(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"))));
13391
14968
  mode = "created";
13392
14969
  }
13393
14970
  const text = renderPlan(args, now, created);
13394
- writeFileSync2(path, text);
14971
+ writeFileSync4(path, text);
13395
14972
  state.planPath = path;
13396
14973
  const doc2 = parsePlan(text);
13397
14974
  context.metadata({ title: `${mode === "created" ? "Create" : "Revise"} plan: ${doc2.goal}` });
@@ -13419,8 +14996,8 @@ var planTickTool = tool({
13419
14996
  if (active.doc.status !== "approved") {
13420
14997
  throw new PlanError(`Plan status is ${active.doc.status}; only an approved plan can be ticked. Get user approval via plan_approve first.`);
13421
14998
  }
13422
- const next = tickTask(readFileSync2(active.path, "utf8"), args.n, nowIso());
13423
- writeFileSync2(active.path, next);
14999
+ const next = tickTask(readFileSync6(active.path, "utf8"), args.n, nowIso());
15000
+ writeFileSync4(active.path, next);
13424
15001
  const doc2 = parsePlan(next);
13425
15002
  const p = progressOf(doc2);
13426
15003
  context.metadata({ title: `Tick task ${args.n} (${p.done}/${p.total})` });
@@ -13447,7 +15024,7 @@ var planApproveTool = tool({
13447
15024
  throw new PlanError(`Plan status is ${active.doc.status}; only a draft plan can be approved.`);
13448
15025
  }
13449
15026
  await gate(context.ask, "plan_approve", `Approve plan: ${active.doc.goal}`);
13450
- writeFileSync2(active.path, transitionStatus(readFileSync2(active.path, "utf8"), "approved", nowIso()));
15027
+ writeFileSync4(active.path, transitionStatus(readFileSync6(active.path, "utf8"), "approved", nowIso()));
13451
15028
  context.metadata({ title: `Plan approved: ${active.doc.goal}` });
13452
15029
  return {
13453
15030
  title: "plan approved",
@@ -13480,7 +15057,7 @@ var planCloseTool = tool({
13480
15057
  Fix the implementation and retry, or revise the plan first.`);
13481
15058
  }
13482
15059
  await gate(context.ask, "plan_close", `Close plan: ${active.doc.goal}`);
13483
- writeFileSync2(active.path, transitionStatus(readFileSync2(active.path, "utf8"), "done", nowIso()));
15060
+ writeFileSync4(active.path, transitionStatus(readFileSync6(active.path, "utf8"), "done", nowIso()));
13484
15061
  context.metadata({ title: `Plan done: ${active.doc.goal}` });
13485
15062
  return {
13486
15063
  title: "plan done",
@@ -13498,31 +15075,31 @@ var planDiscardTool = tool({
13498
15075
  const active = resolveActivePlan(state);
13499
15076
  if (!active)
13500
15077
  throw new PlanError("No plan to abandon in this workspace.");
13501
- writeFileSync2(active.path, transitionStatus(readFileSync2(active.path, "utf8"), "abandoned", nowIso()));
15078
+ writeFileSync4(active.path, transitionStatus(readFileSync6(active.path, "utf8"), "abandoned", nowIso()));
13502
15079
  state.planPath = undefined;
13503
15080
  context.metadata({ title: `Plan abandoned: ${active.doc.goal}` });
13504
15081
  return { title: "plan abandoned", output: `Plan abandoned: ${relFrom(state.worktree, active.path)}. Write operations are restored.` };
13505
15082
  }
13506
15083
  });
13507
15084
  function goalDirOf(worktree) {
13508
- return join2(worktree, ".opencode", "goal");
15085
+ return join4(worktree, ".opencode", "goal");
13509
15086
  }
13510
15087
  function readGoalDir(worktree) {
13511
15088
  const dir = goalDirOf(worktree);
13512
- if (!existsSync(dir))
15089
+ if (!existsSync3(dir))
13513
15090
  return [];
13514
- return readdirSync(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync2(join2(dir, name), "utf8") }));
15091
+ return readdirSync4(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync6(join4(dir, name), "utf8") }));
13515
15092
  }
13516
15093
  function resolveSessionGoal(state) {
13517
- if (state.goalPath && existsSync(state.goalPath)) {
13518
- const doc2 = parseGoalLoose(readFileSync2(state.goalPath, "utf8"));
15094
+ if (state.goalPath && existsSync3(state.goalPath)) {
15095
+ const doc2 = parseGoalLoose(readFileSync6(state.goalPath, "utf8"));
13519
15096
  if (doc2 && !isGoalTerminal(doc2.status))
13520
15097
  return { path: state.goalPath, doc: doc2 };
13521
15098
  state.goalPath = undefined;
13522
15099
  }
13523
15100
  const live = rankLiveGoals(readGoalDir(state.worktree))[0];
13524
15101
  if (live) {
13525
- const path = join2(goalDirOf(state.worktree), live.name);
15102
+ const path = join4(goalDirOf(state.worktree), live.name);
13526
15103
  state.goalPath = path;
13527
15104
  return { path, doc: live.doc };
13528
15105
  }
@@ -13619,9 +15196,9 @@ var goalWriteTool = tool({
13619
15196
  await gate(context.ask, "goal_write", `Arm goal: ${args.goal}`);
13620
15197
  }
13621
15198
  const dir = goalDirOf(state.worktree);
13622
- mkdirSync(dir, { recursive: true });
13623
- const name = goalFileName(localDateNow(), slugifyGoal(args.goal), readdirSync(dir).filter((f) => f.endsWith(".md")));
13624
- const path = join2(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);
13625
15202
  const text = renderGoal(input, {
13626
15203
  now,
13627
15204
  status: arm ? "active" : "queued",
@@ -13661,7 +15238,7 @@ var goalCheckTool = tool({
13661
15238
  o.index = selected[i].n;
13662
15239
  });
13663
15240
  const runId = `${nowIso()}-${Math.random().toString(36).slice(2, 8)}`;
13664
- const next = appendCheckLog(readFileSync2(goal.path, "utf8"), runId, outcomes, nowIso());
15241
+ const next = appendCheckLog(readFileSync6(goal.path, "utf8"), runId, outcomes, nowIso());
13665
15242
  atomicWrite(goal.path, next);
13666
15243
  const ok = outcomesAllOk(outcomes);
13667
15244
  context.metadata({ title: `goal_check: ${outcomes.filter((o) => o.ok).length}/${outcomes.length} pass` });
@@ -13695,7 +15272,7 @@ var goalCompleteTool = tool({
13695
15272
  const failures = outcomes.filter((o) => !o.ok);
13696
15273
  if (failures.length > 0) {
13697
15274
  const runId2 = `${nowIso()}-${Math.random().toString(36).slice(2, 8)}`;
13698
- atomicWrite(goal.path, appendCheckLog(readFileSync2(goal.path, "utf8"), runId2, outcomes, nowIso()));
15275
+ atomicWrite(goal.path, appendCheckLog(readFileSync6(goal.path, "utf8"), runId2, outcomes, nowIso()));
13699
15276
  throw new GoalError(`Completion gate: verification re-run failed (fail-closed). The goal stays active.
13700
15277
  ${formatOutcomes(failures)}
13701
15278
  Fix the work and retry; recorded results never substitute for the gate's own re-run.`);
@@ -13708,7 +15285,7 @@ Fix the work and retry; recorded results never substitute for the gate's own re-
13708
15285
  }
13709
15286
  await gate(context.ask, "goal_complete", `Complete goal: ${goal.doc.goal}`);
13710
15287
  const runId = `${nowIso()}-${Math.random().toString(36).slice(2, 8)}`;
13711
- let text = appendCheckLog(readFileSync2(goal.path, "utf8"), runId, outcomes, nowIso());
15288
+ let text = appendCheckLog(readFileSync6(goal.path, "utf8"), runId, outcomes, nowIso());
13712
15289
  text = transitionGoal(text, "completed", nowIso());
13713
15290
  atomicWrite(goal.path, text);
13714
15291
  context.metadata({ title: `Goal completed: ${goal.doc.goal}` });
@@ -13730,7 +15307,7 @@ var goalPauseTool = tool({
13730
15307
  throw new GoalError(`No active goal to pause (status: ${goal?.doc.status ?? "none"}).`);
13731
15308
  }
13732
15309
  const stopReason = args.blocker ? "blocker" : "user";
13733
- atomicWrite(goal.path, transitionGoal(readFileSync2(goal.path, "utf8"), "paused", nowIso(), { stopReason }));
15310
+ atomicWrite(goal.path, transitionGoal(readFileSync6(goal.path, "utf8"), "paused", nowIso(), { stopReason }));
13734
15311
  engineForgetSession(context.sessionID);
13735
15312
  context.metadata({ title: `Goal paused (${stopReason}): ${goal.doc.goal}` });
13736
15313
  return {
@@ -13750,7 +15327,7 @@ var goalResumeTool = tool({
13750
15327
  if (!goal || goal.doc.status === "queued") {
13751
15328
  const oldest = rankQueuedGoals(readGoalDir(state.worktree))[0];
13752
15329
  if (oldest) {
13753
- const path = join2(goalDirOf(state.worktree), oldest.name);
15330
+ const path = join4(goalDirOf(state.worktree), oldest.name);
13754
15331
  state.goalPath = path;
13755
15332
  goal = { path, doc: oldest.doc };
13756
15333
  }
@@ -13760,7 +15337,7 @@ var goalResumeTool = tool({
13760
15337
  }
13761
15338
  const promoting = goal.doc.status === "queued";
13762
15339
  await gate(context.ask, "goal_resume", `${promoting ? "Promote" : "Resume"} goal: ${goal.doc.goal}`);
13763
- let text = readFileSync2(goal.path, "utf8");
15340
+ let text = readFileSync6(goal.path, "utf8");
13764
15341
  if (args.addTurns)
13765
15342
  text = bumpBudget(text, args.addTurns, nowIso());
13766
15343
  text = transitionGoal(text, "active", nowIso(), { session: context.sessionID });
@@ -13786,7 +15363,7 @@ var goalDiscardTool = tool({
13786
15363
  if (!goal)
13787
15364
  throw new GoalError("No goal to discard in this workspace.");
13788
15365
  await gate(context.ask, "goal_discard", `Discard goal: ${goal.doc.goal}`);
13789
- atomicWrite(goal.path, transitionGoal(readFileSync2(goal.path, "utf8"), "abandoned", nowIso()));
15366
+ atomicWrite(goal.path, transitionGoal(readFileSync6(goal.path, "utf8"), "abandoned", nowIso()));
13790
15367
  state.goalPath = undefined;
13791
15368
  engineForgetSession(context.sessionID);
13792
15369
  context.metadata({ title: `Goal abandoned: ${goal.doc.goal}` });
@@ -13796,8 +15373,192 @@ var goalDiscardTool = tool({
13796
15373
  };
13797
15374
  }
13798
15375
  });
15376
+ var FORGE_SHELL_DESCRIPTION = [
15377
+ "Run a shell command without ever blocking the session indefinitely. The call completes on the FIRST of: process exit (bound to the exit event — a detached grandchild holding the stdio pipes cannot suspend the call); success_pattern matching new output (opt-in regex); idle_ms with no new output (default 60000); max_wait_ms hard cap (default 120000, max 600000 — returns still-running, never kills).",
15378
+ "Idle/max-wait return `still-running` with a jobId — the process stays alive; keep watching with forge_jobs poll / log, stop it with forge_jobs kill. run_in_background returns {jobId, logPath} immediately.",
15379
+ "success_pattern semantics: a match completes the call as success; the process is kept alive by default (server semantics — the thing you just verified keeps running); pass keep_alive=false to kill its tree on match. Common patterns: dev servers `listening on|ready in|Local:`, builds `Compiled successfully|Done in`, test suites `passed|all tests`.",
15380
+ "Long-running or possibly non-exiting commands (dev servers, watchers, installers, anything spawning detached children) MUST use this tool instead of the builtin shell."
15381
+ ].join(`
15382
+ `);
15383
+ var STAGE1_NOTE = "Stage note: this host's builtin shell already offers a native run_in_background parameter — prefer that for plain backgrounding; keep using forge_shell when you need idle/success_pattern early return or exit wake messages.";
15384
+ var forgeShellTool = tool({
15385
+ description: FORGE_SHELL_DESCRIPTION,
15386
+ args: {
15387
+ command: tool.schema.string().describe("The shell command to run"),
15388
+ workdir: tool.schema.string().optional().describe("Working directory (workspace-relative, or absolute)"),
15389
+ run_in_background: tool.schema.boolean().optional().describe("Return {jobId, logPath} immediately, without waiting for any output or exit"),
15390
+ idle_ms: tool.schema.number().int().nonnegative().optional().describe("No-new-output early-return threshold in ms (default 60000)"),
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"),
15392
+ success_pattern: tool.schema.string().optional().describe("Regex; a match against new output completes the call as success immediately"),
15393
+ keep_alive: tool.schema.boolean().optional().describe("After a success match: keep the process alive (default) or kill its tree (false)"),
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)")
15396
+ },
15397
+ execute: async (args, context) => {
15398
+ if (jobStage() >= 2) {
15399
+ throw new Error("[forge] The job supervisor is retired on this host (native backgrounding confirmed complete) — use the shell tool's run_in_background parameter.");
15400
+ }
15401
+ const state = ensureSession(context.sessionID, worktreeFor(context));
15402
+ await gate(context.ask, "forge_shell", `forge_shell: ${args.command.slice(0, 100)}`);
15403
+ const survive = effectiveSurvive(jobsSurviveMode, args.survive);
15404
+ let successPattern = null;
15405
+ if (args.success_pattern) {
15406
+ try {
15407
+ successPattern = new RegExp(args.success_pattern);
15408
+ } catch (err) {
15409
+ throw new Error(`Invalid success_pattern: ${err.message}`);
15410
+ }
15411
+ }
15412
+ const { registry: registry2, fence } = ensureJobLifecycle();
15413
+ const cwd = args.workdir ? isAbsolute(args.workdir) ? args.workdir : join4(state.worktree, args.workdir) : state.worktree;
15414
+ const started = startJob(jobManager, {
15415
+ cmd: args.command,
15416
+ cwd,
15417
+ ownerSession: context.sessionID,
15418
+ worktree: state.worktree,
15419
+ logDir: jobLogDir,
15420
+ runInBackground: args.run_in_background === true,
15421
+ ...args.idle_ms !== undefined ? { idleMs: args.idle_ms } : {},
15422
+ ...args.max_wait_ms !== undefined ? { maxWaitMs: args.max_wait_ms } : {},
15423
+ successPattern,
15424
+ ...args.keep_alive !== undefined ? { keepAlive: args.keep_alive } : {},
15425
+ ...args.notify !== undefined ? { notify: args.notify } : {},
15426
+ ...survive ? { survive: true, registry: registry2 ?? undefined } : { fence, relocate: structuralRelocate, relocateAsync: structuralRelocateAsync }
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
+ }
15440
+ context.metadata({ title: `forge_shell: ${args.command.slice(0, 60)}` });
15441
+ if (args.run_in_background === true) {
15442
+ return {
15443
+ title: `job started: ${started.job.id}`,
15444
+ output: [`[forge:job] Started in background.`, `jobId: ${started.job.id}`, `logPath: ${started.job.logPath}`, "Track with forge_jobs poll; a [forge:job-complete] message arrives on exit."].join(`
15445
+ `)
15446
+ };
15447
+ }
15448
+ const r = await started.settle;
15449
+ if (r.status === "exited") {
15450
+ return {
15451
+ title: `exit ${r.exitCode ?? "?"}`,
15452
+ output: [
15453
+ `[forge:job] Command finished (exit=${r.exitCode ?? "none"}).`,
15454
+ ...r.spawnError ? [`spawn error: ${r.spawnError}`] : [],
15455
+ `output:
15456
+ ${r.outputTail || "(none)"}`
15457
+ ].join(`
15458
+ `)
15459
+ };
15460
+ }
15461
+ if (r.status === "succeeded") {
15462
+ return {
15463
+ title: `success: ${r.matched}`,
15464
+ output: [
15465
+ `[forge:job] Success pattern matched: "${r.matched}".`,
15466
+ r.keptAlive ? `The process was kept alive as job ${started.job.id} (stop it with forge_jobs kill when done).` : "The process tree was terminated (keep_alive=false).",
15467
+ `output:
15468
+ ${r.outputTail}`
15469
+ ].join(`
15470
+ `)
15471
+ };
15472
+ }
15473
+ return {
15474
+ title: `still running (${Math.round(r.idleForMs / 1000)}s idle)`,
15475
+ output: [
15476
+ `[forge:job] Still running — ${Math.round(r.idleForMs / 1000)}s without new output (or the wait budget ran out). The process is alive as job ${started.job.id}.`,
15477
+ `logPath: ${started.job.logPath}`,
15478
+ `recent output:
15479
+ ${r.outputTail || "(none yet)"}`,
15480
+ "Next: forge_jobs poll to keep waiting, forge_jobs log for history, forge_jobs kill to stop."
15481
+ ].join(`
15482
+ `)
15483
+ };
15484
+ }
15485
+ });
15486
+ var FORGE_JOBS_ACTIONS = ["list", "poll", "log", "kill", "clear", "handoff"];
15487
+ var forgeJobsTool = tool({
15488
+ description: "Manage forge_shell jobs. Actions: list (all jobs, newest first); poll {jobId, waitMs<=30000} — bounded wait for NEW output or exit, drains it; log {jobId, offset?, limit?} — line paging over the on-disk log (omitted offset = tail window, default 200 lines); kill {jobId} — terminate the job's whole process tree; clear {jobId} — drop a finished job from the registry; handoff {jobId} — rebind ownership to the root session so the job survives this (sub)session's end. A delegated agent MUST poll its jobs before yielding its conclusion.",
15489
+ args: {
15490
+ action: tool.schema.string().describe(`One of: ${FORGE_JOBS_ACTIONS.join(", ")}`),
15491
+ jobId: tool.schema.string().optional().describe("Job id from forge_shell (required for every action except list)"),
15492
+ waitMs: tool.schema.number().int().nonnegative().optional().describe("poll: bounded in-call wait (clamped to 30000)"),
15493
+ offset: tool.schema.number().int().nonnegative().optional().describe("log: first line index; omitted = tail window"),
15494
+ limit: tool.schema.number().int().positive().optional().describe("log: max lines (default 200)")
15495
+ },
15496
+ execute: async (args, context) => {
15497
+ if (jobStage() >= 2)
15498
+ throw new Error("[forge] The job supervisor is retired on this host.");
15499
+ const action = String(args.action ?? "");
15500
+ if (!FORGE_JOBS_ACTIONS.includes(action)) {
15501
+ throw new Error(`Unknown action "${action}" — use one of: ${FORGE_JOBS_ACTIONS.join(", ")}.`);
15502
+ }
15503
+ if (action === "list") {
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)}`);
15506
+ return { title: `jobs (${rows.length})`, output: rows.length > 0 ? rows.join(`
15507
+ `) : "(no jobs)" };
15508
+ }
15509
+ if (!args.jobId)
15510
+ throw new Error(`Action "${action}" requires jobId.`);
15511
+ const job = jobManager.get(args.jobId);
15512
+ if (!job)
15513
+ throw new Error(`No job ${args.jobId} in the registry (finished jobs age out; the on-disk log may still exist).`);
15514
+ if (action === "poll") {
15515
+ const p = await pollJob(jobManager, args.jobId, args.waitMs ?? 0);
15516
+ if (!p)
15517
+ throw new Error(`No job ${args.jobId}.`);
15518
+ return {
15519
+ title: `${job.id}: ${p.state}`,
15520
+ output: [
15521
+ `[forge:job] ${job.id}: ${p.state}${p.exitCode !== null ? ` exit=${p.exitCode}` : ""}${p.succeeded ? " (success pattern matched)" : ""}`,
15522
+ `new output:
15523
+ ${p.newOutput || "(none in this window)"}`,
15524
+ `logPath: ${p.logPath}`
15525
+ ].join(`
15526
+ `)
15527
+ };
15528
+ }
15529
+ if (action === "log") {
15530
+ const page = readJobLog(job.logPath, {
15531
+ ...args.offset !== undefined ? { offset: args.offset } : {},
15532
+ ...args.limit !== undefined ? { limit: args.limit } : {}
15533
+ });
15534
+ return {
15535
+ title: `${job.id} log ${page.offset}-${page.offset + page.lines.length}/${page.total}`,
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(`
15539
+ `) : "(empty)"
15540
+ ].join(`
15541
+ `)
15542
+ };
15543
+ }
15544
+ if (action === "kill") {
15545
+ jobManager.kill(job);
15546
+ return { title: `${job.id} killed`, output: `[forge:job] ${job.id}: tree kill issued (state: killed).` };
15547
+ }
15548
+ if (action === "clear") {
15549
+ const ok = jobManager.clear(args.jobId);
15550
+ return ok ? { title: `${job.id} cleared`, output: `[forge:job] ${job.id} removed from the registry (log file untouched).` } : { title: `${job.id} not cleared`, output: `[forge:job] ${job.id} is still running — kill it first.` };
15551
+ }
15552
+ const target = await handoffTarget(context.sessionID);
15553
+ jobManager.handoff(args.jobId, target);
15554
+ return {
15555
+ title: `${job.id} handed off`,
15556
+ output: `[forge:job] ${job.id} promoted to plugin-global scope${target ? ` and rebound to root session ${target}` : ""}; it now survives this session's end.`
15557
+ };
15558
+ }
15559
+ });
13799
15560
  function forgeTools() {
13800
- return {
15561
+ const tools = {
13801
15562
  plan_write: planWriteTool,
13802
15563
  plan_tick: planTickTool,
13803
15564
  plan_approve: planApproveTool,
@@ -13810,6 +15571,11 @@ function forgeTools() {
13810
15571
  goal_resume: goalResumeTool,
13811
15572
  goal_discard: goalDiscardTool
13812
15573
  };
15574
+ if (jobStage() < 2) {
15575
+ tools.forge_shell = forgeShellTool;
15576
+ tools.forge_jobs = forgeJobsTool;
15577
+ }
15578
+ return tools;
13813
15579
  }
13814
15580
  var hostWorktree = "";
13815
15581
  function stateForBan(sessionID) {
@@ -13832,7 +15598,7 @@ var pendingContinuationTurn = new Set;
13832
15598
  function goalProbe(line) {
13833
15599
  if (process.env.FORGE_GOAL_PROBE) {
13834
15600
  try {
13835
- appendFileSync(join2(tmpdir(), "forge-goal-probe.log"), `${new Date().toISOString()} ${line}
15601
+ appendFileSync(join4(tmpdir2(), "forge-goal-probe.log"), `${new Date().toISOString()} ${line}
13836
15602
  `);
13837
15603
  } catch {}
13838
15604
  }
@@ -13878,7 +15644,7 @@ function wrapupBriefText(goal, reason) {
13878
15644
  }
13879
15645
  async function autoPauseGoal(client, state, goal, reason, wrapup) {
13880
15646
  goalProbe(`auto-pause session=${state.sessionID} reason=${reason} wrapup=${wrapup}`);
13881
- atomicWrite(goal.path, transitionGoal(readFileSync2(goal.path, "utf8"), "paused", nowIso(), { stopReason: reason }));
15647
+ atomicWrite(goal.path, transitionGoal(readFileSync6(goal.path, "utf8"), "paused", nowIso(), { stopReason: reason }));
13882
15648
  engineForgetSession(state.sessionID);
13883
15649
  if (wrapup && goal.doc.session) {
13884
15650
  try {
@@ -13925,11 +15691,11 @@ async function continueIfEligible(client, sessionID) {
13925
15691
  turnHadActivity = !!act && (act.writes > 0 || act.checks > 0);
13926
15692
  turnActivity.set(sessionID, { writes: 0, checks: 0 });
13927
15693
  const ledgerPath = state.goalPath;
13928
- if (ledgerPath && existsSync(ledgerPath)) {
15694
+ if (ledgerPath && existsSync3(ledgerPath)) {
13929
15695
  try {
13930
- const fresh = parseGoalLoose(readFileSync2(ledgerPath, "utf8"));
15696
+ const fresh = parseGoalLoose(readFileSync6(ledgerPath, "utf8"));
13931
15697
  if (fresh && fresh.turnsUsed > 0) {
13932
- atomicWrite(ledgerPath, appendLedger(readFileSync2(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()));
13933
15699
  }
13934
15700
  } catch (err) {
13935
15701
  goalProbe(`ledger append failed session=${sessionID} err=${String(err)}`);
@@ -13990,7 +15756,7 @@ async function continueIfEligible(client, sessionID) {
13990
15756
  pendingContinuationTurn.add(sessionID);
13991
15757
  turnActivity.set(sessionID, { writes: 0, checks: 0 });
13992
15758
  state.goalPath = goal.path;
13993
- atomicWrite(goal.path, incTurns(readFileSync2(goal.path, "utf8"), nowIso()));
15759
+ atomicWrite(goal.path, incTurns(readFileSync6(goal.path, "utf8"), nowIso()));
13994
15760
  goalProbe(`continued session=${sessionID} turn=${goal.doc.turnsUsed + 1}/${goal.doc.maxTurns}`);
13995
15761
  } catch (err) {
13996
15762
  const n = (transportFails.get(sessionID) ?? 0) + 1;
@@ -14017,12 +15783,82 @@ function scheduleIdleContinuation(client, sessionID) {
14017
15783
  continueIfEligible(client, sessionID);
14018
15784
  }, IDLE_DEBOUNCE_MS));
14019
15785
  }
14020
- var server = async (input) => {
15786
+ var server = async (input, options) => {
14021
15787
  hostWorktree = effectiveWorktree(input.worktree, input.directory) || input.directory || "";
14022
15788
  const client = input.client;
15789
+ jobClient = input.client;
15790
+ const jobsOpts = options?.jobs;
15791
+ if (jobsOpts?.mode === "auto" || jobsOpts?.mode === "forge" || jobsOpts?.mode === "native")
15792
+ jobsMode = jobsOpts.mode;
15793
+ jobsKeepBuiltinShell = jobsOpts?.keepBuiltinShell === true;
15794
+ if (jobsOpts?.survive === "never" || jobsOpts?.survive === "always" || jobsOpts?.survive === "deny")
15795
+ jobsSurviveMode = jobsOpts.survive;
15796
+ const wdOpts = options?.watchdog;
15797
+ const wdFallbacks = [];
15798
+ const wdMode = parseMode(wdOpts?.mode);
15799
+ if (wdOpts?.mode !== undefined && wdOpts.mode !== wdMode) {
15800
+ wdFallbacks.push(`invalid watchdog.mode ${JSON.stringify(String(wdOpts.mode))} — fell back to "${wdMode}"`);
15801
+ }
15802
+ const wdStallRaw = wdOpts?.stallMs;
15803
+ const wdStall = clampStallMs(typeof wdStallRaw === "number" ? wdStallRaw : undefined);
15804
+ if (wdStallRaw !== undefined && wdStallRaw !== wdStall) {
15805
+ wdFallbacks.push(`watchdog.stallMs ${JSON.stringify(String(wdStallRaw))} adjusted to ${wdStall} (floor/default applied)`);
15806
+ }
15807
+ const watchdogLedger = createFileLedger(join4(watchdogLogDir(), "log.jsonl"));
15808
+ const rawLocator = createLocator();
15809
+ const probing = () => process.env.FORGE_WATCHDOG_PROBE === "1";
15810
+ const probeLine = (text) => {
15811
+ if (!probing())
15812
+ return;
15813
+ try {
15814
+ appendFileSync(join4(tmpdir2(), "forge-watchdog-probe.log"), `${new Date().toISOString()} ${text}
15815
+ `);
15816
+ } catch {}
15817
+ };
15818
+ const diagnosticLocate = async (callID, t0, cmdNeedle, phase2) => {
15819
+ const started = Date.now();
15820
+ const hits = await rawLocator(callID, t0, cmdNeedle, phase2);
15821
+ probeLine(`locate dur=${Date.now() - started}ms hits=${hits.length} phase2=${phase2 === true} needle=${JSON.stringify(cmdNeedle ?? null)}`);
15822
+ if (hits.length === 0 && cmdNeedle) {
15823
+ try {
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 });
15825
+ const all = parseWindowsProcs(raw);
15826
+ probeLine(`diag rawLen=${raw.length} procs=${all.length} windowStart=${new Date(t0 - 2000).toISOString()}`);
15827
+ for (const p of all) {
15828
+ if (p.cmd.includes(cmdNeedle))
15829
+ probeLine(`diag needle-carrier pid=${p.pid} ppid=${p.ppid} created=${new Date(p.createdMs).toISOString()} cmd=${p.cmd.slice(0, 120)}`);
15830
+ }
15831
+ } catch (err) {
15832
+ probeLine(`diag failed: ${String(err).slice(0, 150)}`);
15833
+ }
15834
+ }
15835
+ return hits;
15836
+ };
15837
+ const watchdog = createWatchdog({
15838
+ mode: wdMode,
15839
+ stallMs: wdStall,
15840
+ sink: (entry) => watchdogLedger.append(entry),
15841
+ locate: diagnosticLocate,
15842
+ killTree: (pid) => killTree({ pid, kill: (sig) => process.kill(pid, sig) })
15843
+ });
15844
+ for (const reason of wdFallbacks) {
15845
+ const entry = {
15846
+ ts: new Date().toISOString(),
15847
+ event: "config-fallback",
15848
+ callID: "-",
15849
+ tool: "config",
15850
+ t0: Date.now(),
15851
+ mode: watchdog.mode,
15852
+ reason
15853
+ };
15854
+ watchdogLedger.append(entry);
15855
+ }
14023
15856
  return {
14024
15857
  dispose: async () => {
14025
15858
  engineForgetAll();
15859
+ exitCleanup?.trigger("dispose");
15860
+ jobManager.disposeAll();
15861
+ watchdog.dispose();
14026
15862
  sessions.clear();
14027
15863
  },
14028
15864
  config: async (cfg) => {
@@ -14035,12 +15871,22 @@ var server = async (input) => {
14035
15871
  agentSection[native] = { ...agentSection[native] ?? {}, disable: true };
14036
15872
  }
14037
15873
  const existing = agentSection[FORGE_AGENT];
15874
+ const userDefinedForge = existing !== undefined;
14038
15875
  agentSection[FORGE_AGENT] = {
14039
15876
  ...existing ?? {},
14040
15877
  description: existing?.description ?? "forge — the single general-purpose coding agent: takes implementation tasks directly; planning goes through /plan into the plan harness (plans land in .opencode/plan/, with approve/close confirmation gates and tick discipline enforced by tools and the permission layer).",
14041
15878
  mode: existing?.mode ?? "primary",
14042
15879
  prompt: existing?.prompt ?? FORGE_PROMPT
14043
15880
  };
15881
+ try {
15882
+ const exp = JSON.stringify(cfg.experimental ?? "");
15883
+ if (/background/i.test(exp))
15884
+ nativeBackgroundSeen = true;
15885
+ } catch {}
15886
+ if (!userDefinedForge && !jobsKeepBuiltinShell && jobStage() < 1) {
15887
+ const entry = agentSection[FORGE_AGENT];
15888
+ entry.tools = { ...entry.tools ?? {}, shell: false, bash: false };
15889
+ }
14044
15890
  cfg.command ??= {};
14045
15891
  cfg.command["plan"] ??= {
14046
15892
  template: PLAN_COMMAND_TEMPLATE,
@@ -14052,7 +15898,7 @@ var server = async (input) => {
14052
15898
  };
14053
15899
  const perm = cfg.permission;
14054
15900
  const permSection = perm ?? (cfg.permission = {});
14055
- for (const gateKey of ["plan_approve", "plan_close", "goal_write", "goal_complete", "goal_resume", "goal_discard"]) {
15901
+ for (const gateKey of ["plan_approve", "plan_close", "goal_write", "goal_complete", "goal_resume", "goal_discard", "forge_shell"]) {
14056
15902
  if (permSection[gateKey] !== "deny")
14057
15903
  permSection[gateKey] = "ask";
14058
15904
  }
@@ -14060,13 +15906,24 @@ var server = async (input) => {
14060
15906
  get tool() {
14061
15907
  return forgeDisabled ? {} : forgeTools();
14062
15908
  },
14063
- "tool.execute.before": async (input2) => {
15909
+ "tool.execute.before": async (input2, output) => {
14064
15910
  if (process.env.FORGE_PERM_PROBE) {
14065
15911
  try {
14066
- appendFileSync(join2(tmpdir(), "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}
14067
15913
  `);
14068
15914
  } catch {}
14069
15915
  }
15916
+ if (watchdog.mode !== "off" && (input2.tool === "shell" || input2.tool === "bash")) {
15917
+ const a = output?.args ?? {};
15918
+ const cmdText = typeof a.command === "string" ? a.command : typeof a.cmd === "string" ? a.cmd : undefined;
15919
+ watchdog.track(input2.callID, input2.sessionID, input2.tool, undefined, cmdText !== undefined ? commandNeedle(cmdText) : undefined);
15920
+ if (process.env.FORGE_WATCHDOG_PROBE) {
15921
+ try {
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)}
15923
+ `);
15924
+ } catch {}
15925
+ }
15926
+ }
14070
15927
  if (typeof input2.tool === "string" && isWriteTool(input2.tool)) {
14071
15928
  const state = stateForBan(input2.sessionID);
14072
15929
  const active = state ? resolveActivePlan(state) : null;
@@ -14081,11 +15938,11 @@ var server = async (input) => {
14081
15938
  const name = (typeof meta.tool === "string" ? meta.tool : undefined) ?? (typeof permissionField === "string" ? permissionField : undefined) ?? input2.id ?? input2.type;
14082
15939
  if (process.env.FORGE_PERM_PROBE) {
14083
15940
  try {
14084
- appendFileSync(join2(tmpdir(), "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)}
14085
15942
  `);
14086
15943
  } catch {}
14087
15944
  }
14088
- if (name === "plan_approve" || name === "plan_close" || name === "goal_write" || name === "goal_complete" || name === "goal_resume" || name === "goal_discard") {
15945
+ if (name === "plan_approve" || name === "plan_close" || name === "goal_write" || name === "goal_complete" || name === "goal_resume" || name === "goal_discard" || name === "forge_shell") {
14089
15946
  if (output.status !== "deny")
14090
15947
  output.status = "ask";
14091
15948
  return;
@@ -14111,10 +15968,40 @@ var server = async (input) => {
14111
15968
  if (typeof sessionID === "string" && sessionID) {
14112
15969
  goalProbe(`idle event session=${sessionID}`);
14113
15970
  scheduleIdleContinuation(client, sessionID);
15971
+ deliverJobWakes(sessionID);
14114
15972
  }
14115
15973
  }
15974
+ if (event.type === "session.deleted") {
15975
+ const info = event.properties.info;
15976
+ if (typeof info?.id === "string" && info.id) {
15977
+ jobManager.onSessionEnd(info.id);
15978
+ }
15979
+ }
15980
+ },
15981
+ "shell.env": async (input2, output) => {
15982
+ if (watchdog.mode === "off")
15983
+ return;
15984
+ if (!input2.callID)
15985
+ return;
15986
+ output.env[WATCHDOG_ENV_MARK] = markerValue(input2.callID);
15987
+ watchdog.markSeen(input2.callID);
15988
+ if (process.env.FORGE_WATCHDOG_PROBE) {
15989
+ try {
15990
+ appendFileSync(join4(tmpdir2(), "forge-watchdog-probe.log"), `${new Date().toISOString()} mark ${input2.callID} hostpid=${process.pid}
15991
+ `);
15992
+ } catch {}
15993
+ }
14116
15994
  },
14117
15995
  "tool.execute.after": async (input2) => {
15996
+ if ((input2.tool === "shell" || input2.tool === "bash") && watchdog.has(input2.callID)) {
15997
+ watchdog.untrack(input2.callID);
15998
+ if (process.env.FORGE_WATCHDOG_PROBE) {
15999
+ try {
16000
+ appendFileSync(join4(tmpdir2(), "forge-watchdog-probe.log"), `${new Date().toISOString()} untrack ${input2.callID}
16001
+ `);
16002
+ } catch {}
16003
+ }
16004
+ }
14118
16005
  if (typeof input2.tool !== "string")
14119
16006
  return;
14120
16007
  const act = turnActivity.get(input2.sessionID);
@@ -14125,9 +16012,24 @@ var server = async (input) => {
14125
16012
  if (input2.tool === "goal_check")
14126
16013
  act.checks++;
14127
16014
  },
16015
+ "tool.definition": async (input2, output) => {
16016
+ if (input2.toolID === "shell" || input2.toolID === "bash") {
16017
+ const props = output.parameters?.properties;
16018
+ if (props && "run_in_background" in props)
16019
+ nativeBackgroundSeen = true;
16020
+ } else if (input2.toolID === "forge_shell" && jobStage() === 1) {
16021
+ if (!output.description.startsWith(STAGE1_NOTE)) {
16022
+ output.description = `${STAGE1_NOTE}
16023
+ ${output.description}`;
16024
+ }
16025
+ }
16026
+ },
14128
16027
  "experimental.chat.system.transform": async (input2, output) => {
14129
16028
  if (!input2.sessionID)
14130
16029
  return;
16030
+ if (!forgeDisabled && jobStage() < 2 && !output.system.some((s) => s.startsWith("[forge:job-guidance]"))) {
16031
+ output.system.push("[forge:job-guidance] Long-running or possibly non-exiting shell commands (dev servers, watchers, installers, anything spawning detached children) go through forge_shell, never the builtin shell: it returns on idle/success/exit with a jobId instead of blocking indefinitely; manage jobs with forge_jobs. Delegated agents: collect your job results with forge_jobs poll before yielding your conclusion.");
16032
+ }
14131
16033
  const state = sessions.get(input2.sessionID);
14132
16034
  if (!state)
14133
16035
  return;
@@ -14200,5 +16102,6 @@ export {
14200
16102
  server,
14201
16103
  isRootish,
14202
16104
  effectiveWorktree,
16105
+ effectiveSurvive,
14203
16106
  plugin_default as default
14204
16107
  };