@sorenllm/opencode-forge 0.2.1 → 0.3.0

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 +155 -4
  2. package/dist/index.js +1240 -72
  3. package/package.json +6 -3
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 execFileSync2 } from "node:child_process";
12338
+ import { appendFileSync as appendFileSync2, existsSync as existsSync2, mkdirSync as mkdirSync3, readdirSync as readdirSync3, readFileSync as readFileSync5, statSync as statSync2, writeFileSync as writeFileSync3 } from "node:fs";
12339
+ import { isAbsolute, join as join3, relative } from "node:path";
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,54 @@ 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 } 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
+ });
13133
+ }
13134
+ function treeKillPlan(platform, pid) {
13135
+ if (platform === "win32")
13136
+ return { kind: "taskkill", args: ["/pid", String(pid), "/F", "/T"] };
13137
+ return { kind: "group", signal: "SIGKILL" };
13138
+ }
13139
+ function killTree(child, opts = {}) {
13140
+ const platform = opts.platform ?? process.platform;
13141
+ if (!child.pid) {
13142
+ child.kill();
13143
+ return;
13144
+ }
13145
+ const plan = treeKillPlan(platform, child.pid);
13146
+ if (plan.kind === "taskkill") {
13147
+ try {
13148
+ (opts.spawnFn ?? spawn)("taskkill", plan.args, { windowsHide: true, stdio: "ignore" });
13149
+ } catch {
13150
+ child.kill();
13151
+ }
13152
+ } else {
13153
+ try {
13154
+ process.kill(-child.pid, plan.signal);
13155
+ } catch {
13156
+ child.kill(plan.signal);
13157
+ }
13158
+ }
13159
+ }
13160
+
13161
+ // src/run-check.ts
13121
13162
  var OUTPUT_LIMIT = 2048;
13122
13163
  var defaultShellRunner = (cmd, opts) => new Promise((resolveRun) => {
13123
13164
  let child;
13124
13165
  try {
13125
- child = spawn(cmd, {
13166
+ child = spawn2(cmd, {
13126
13167
  shell: true,
13127
13168
  cwd: opts.cwd,
13128
13169
  windowsHide: true,
@@ -13135,28 +13176,9 @@ var defaultShellRunner = (cmd, opts) => new Promise((resolveRun) => {
13135
13176
  let output = "";
13136
13177
  let timedOut = false;
13137
13178
  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
13179
  const timer = setTimeout(() => {
13158
13180
  timedOut = true;
13159
- killTree();
13181
+ killTree(child);
13160
13182
  }, opts.timeoutMs);
13161
13183
  const failsafe = setTimeout(() => settle({ code: null, output, timedOut: true, spawnError: "timeout settle fallback" }), opts.timeoutMs + 30000);
13162
13184
  failsafe.unref?.();
@@ -13249,6 +13271,789 @@ function formatOutcomes(outcomes) {
13249
13271
  `);
13250
13272
  }
13251
13273
 
13274
+ // src/job-manager.ts
13275
+ var DEFAULT_MAX_FINISHED_JOBS = 50;
13276
+ var DEFAULT_MAX_TAIL_CHARS = 8192;
13277
+ var DEFAULT_MAX_REGISTRY_CHARS = 524288;
13278
+ var DEFAULT_WAKE_WINDOW_MS = 600000;
13279
+ var DEFAULT_MAX_LEDGER_ENTRIES = 200;
13280
+ function iso(now) {
13281
+ return new Date(now()).toISOString();
13282
+ }
13283
+ function createJobManager(opts = {}) {
13284
+ const now = opts.now ?? Date.now;
13285
+ const maxFinishedJobs = opts.maxFinishedJobs ?? DEFAULT_MAX_FINISHED_JOBS;
13286
+ const maxTailChars = opts.maxTailChars ?? DEFAULT_MAX_TAIL_CHARS;
13287
+ const maxRegistryChars = opts.maxRegistryChars ?? DEFAULT_MAX_REGISTRY_CHARS;
13288
+ const wakeWindowMs = opts.wakeWindowMs ?? DEFAULT_WAKE_WINDOW_MS;
13289
+ const maxLedgerEntries = opts.maxLedgerEntries ?? DEFAULT_MAX_LEDGER_ENTRIES;
13290
+ const jobs = new Map;
13291
+ const ledger = [];
13292
+ function emit(kind, job, detail) {
13293
+ const entry = { at: iso(now), kind, jobId: job.id, session: job.ownerSession, detail };
13294
+ ledger.push(entry);
13295
+ if (ledger.length > maxLedgerEntries)
13296
+ ledger.splice(0, ledger.length - maxLedgerEntries);
13297
+ try {
13298
+ opts.sink?.(entry);
13299
+ } catch {}
13300
+ }
13301
+ function totalRegistryChars() {
13302
+ let n = 0;
13303
+ for (const j of jobs.values())
13304
+ n += j.tail.length;
13305
+ return n;
13306
+ }
13307
+ function isTerminal2(job) {
13308
+ return job.state !== "running";
13309
+ }
13310
+ function enforceCaps() {
13311
+ const finished = [...jobs.values()].filter(isTerminal2).sort((a, b) => (a.endedAt ?? a.startedAt) - (b.endedAt ?? b.startedAt));
13312
+ let over = jobs.size - (maxFinishedJobs + countRunning());
13313
+ let chars = totalRegistryChars();
13314
+ for (const j of finished) {
13315
+ if (over <= 0 && chars <= maxRegistryChars)
13316
+ break;
13317
+ emit("evicted", j, `evicted from registry (state ${j.state})`);
13318
+ jobs.delete(j.id);
13319
+ over--;
13320
+ chars -= j.tail.length;
13321
+ }
13322
+ }
13323
+ function countRunning() {
13324
+ let n = 0;
13325
+ for (const j of jobs.values())
13326
+ if (!isTerminal2(j))
13327
+ n++;
13328
+ return n;
13329
+ }
13330
+ function create(input) {
13331
+ const t = now();
13332
+ const job = {
13333
+ ...input,
13334
+ scope: "session",
13335
+ state: "running",
13336
+ exitCode: null,
13337
+ startedAt: t,
13338
+ endedAt: null,
13339
+ lastOutputAt: t,
13340
+ tail: "",
13341
+ outLen: 0,
13342
+ pollCursor: 0,
13343
+ readAfterEnd: false,
13344
+ succeededAt: null,
13345
+ wakeState: "none",
13346
+ wakeQueuedAt: null
13347
+ };
13348
+ jobs.set(job.id, job);
13349
+ enforceCaps();
13350
+ return job;
13351
+ }
13352
+ function get(id) {
13353
+ return jobs.get(id);
13354
+ }
13355
+ function list() {
13356
+ return [...jobs.values()].sort((a, b) => b.startedAt - a.startedAt);
13357
+ }
13358
+ function appendOutput(job, chunk) {
13359
+ if (!chunk)
13360
+ return;
13361
+ job.outLen += chunk.length;
13362
+ job.lastOutputAt = now();
13363
+ const next = job.tail + chunk;
13364
+ job.tail = next.length > maxTailChars ? next.slice(next.length - maxTailChars) : next;
13365
+ }
13366
+ function markTerminal(job, state, exitCode) {
13367
+ if (isTerminal2(job))
13368
+ return;
13369
+ job.state = state;
13370
+ job.exitCode = exitCode;
13371
+ job.endedAt = now();
13372
+ if (job.notify && job.wakeState === "none") {
13373
+ job.wakeState = "queued";
13374
+ job.wakeQueuedAt = now();
13375
+ }
13376
+ enforceCaps();
13377
+ }
13378
+ function kill(job) {
13379
+ if (isTerminal2(job))
13380
+ return;
13381
+ try {
13382
+ job.killTree();
13383
+ } catch {}
13384
+ markTerminal(job, "killed", null);
13385
+ }
13386
+ function poll(job) {
13387
+ if (isTerminal2(job))
13388
+ job.readAfterEnd = true;
13389
+ const cursor = job.outLen;
13390
+ const start = job.pollCursor;
13391
+ const newOutput = sliceFromCursor(job, start);
13392
+ job.pollCursor = cursor;
13393
+ return {
13394
+ state: job.state,
13395
+ exitCode: job.exitCode,
13396
+ newOutput,
13397
+ cursor,
13398
+ succeeded: job.succeededAt !== null,
13399
+ logPath: job.logPath
13400
+ };
13401
+ }
13402
+ function sliceFromCursor(job, cursor) {
13403
+ const startOffset = Math.max(0, cursor - (job.outLen - job.tail.length));
13404
+ return job.tail.slice(startOffset);
13405
+ }
13406
+ function clear(id) {
13407
+ const job = jobs.get(id);
13408
+ if (!job || !isTerminal2(job))
13409
+ return false;
13410
+ jobs.delete(id);
13411
+ return true;
13412
+ }
13413
+ function handoff(id, toSession) {
13414
+ const job = jobs.get(id);
13415
+ if (!job)
13416
+ return;
13417
+ job.scope = "global";
13418
+ if (toSession)
13419
+ job.ownerSession = toSession;
13420
+ return job;
13421
+ }
13422
+ function onSessionEnd(sessionID) {
13423
+ for (const job of [...jobs.values()]) {
13424
+ if (job.ownerSession !== sessionID)
13425
+ continue;
13426
+ if (!isTerminal2(job)) {
13427
+ if (job.scope === "global")
13428
+ continue;
13429
+ kill(job);
13430
+ emit("orphan-job", job, `owner session ended; tree killed (was: ${job.cmd.slice(0, 120)})`);
13431
+ } else if (!job.readAfterEnd) {
13432
+ emit("unread-completion", job, `owner session ended before the completion was read (state ${job.state}, exit ${job.exitCode})`);
13433
+ }
13434
+ jobs.delete(job.id);
13435
+ }
13436
+ }
13437
+ function disposeAll() {
13438
+ for (const job of [...jobs.values()]) {
13439
+ if (!isTerminal2(job)) {
13440
+ kill(job);
13441
+ emit("orphan-job", job, "plugin dispose; tree killed");
13442
+ }
13443
+ jobs.delete(job.id);
13444
+ }
13445
+ }
13446
+ function deliverWakesFor(sessionID) {
13447
+ abandonStaleWakes();
13448
+ const ready = [];
13449
+ for (const job of jobs.values()) {
13450
+ if (job.wakeState === "queued" && job.ownerSession === sessionID) {
13451
+ job.wakeState = "delivered";
13452
+ ready.push(job);
13453
+ }
13454
+ }
13455
+ return ready;
13456
+ }
13457
+ function abandonStaleWakes() {
13458
+ const t = now();
13459
+ for (const job of jobs.values()) {
13460
+ if (job.wakeState === "queued" && job.wakeQueuedAt !== null && t - job.wakeQueuedAt > wakeWindowMs) {
13461
+ job.wakeState = "abandoned";
13462
+ emit("wake-timeout", job, `session stayed busy past the ${wakeWindowMs}ms delivery window (state ${job.state}, exit ${job.exitCode})`);
13463
+ }
13464
+ }
13465
+ }
13466
+ function ledgerEntries() {
13467
+ return [...ledger];
13468
+ }
13469
+ return {
13470
+ create,
13471
+ get,
13472
+ list,
13473
+ appendOutput,
13474
+ markTerminal,
13475
+ kill,
13476
+ poll,
13477
+ clear,
13478
+ handoff,
13479
+ onSessionEnd,
13480
+ disposeAll,
13481
+ deliverWakesFor,
13482
+ abandonStaleWakes,
13483
+ ledgerEntries,
13484
+ size: () => jobs.size
13485
+ };
13486
+ }
13487
+ function newJobId(now = Date.now) {
13488
+ const d = new Date(now());
13489
+ const p = (n, w = 2) => String(n).padStart(w, "0");
13490
+ const rand = Math.random().toString(36).slice(2, 8);
13491
+ return `j-${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}-${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}-${rand}`;
13492
+ }
13493
+
13494
+ // src/job-runner.ts
13495
+ import { spawn as spawn3 } from "node:child_process";
13496
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync as readFileSync2, statSync, unlinkSync } from "node:fs";
13497
+ import { join as join2 } from "node:path";
13498
+ import { tmpdir } from "node:os";
13499
+ var DEFAULT_IDLE_MS = 60000;
13500
+ var DEFAULT_MAX_WAIT_MS = 120000;
13501
+ var HARD_MAX_WAIT_MS = 600000;
13502
+ var POLL_WAIT_MAX_MS = 30000;
13503
+ var DEFAULT_LOG_KEEP = 50;
13504
+ var JOB_ENV_MARKER = "FORGE_JOB_ID";
13505
+ function jobsLogDir(base) {
13506
+ return join2(base ?? join2(tmpdir(), "opencode-forge"), "jobs");
13507
+ }
13508
+ function startJob(manager, opts) {
13509
+ const idleMs = Math.max(0, opts.idleMs ?? DEFAULT_IDLE_MS);
13510
+ const maxWaitMs = Math.min(Math.max(0, opts.maxWaitMs ?? DEFAULT_MAX_WAIT_MS), HARD_MAX_WAIT_MS);
13511
+ mkdirSync(opts.logDir, { recursive: true });
13512
+ const id = newJobId();
13513
+ const logPath = join2(opts.logDir, `${id}.log`);
13514
+ let child;
13515
+ let settled = false;
13516
+ let exiting = false;
13517
+ let idleTimer = null;
13518
+ const maxWaitTimer = setTimeout(() => {
13519
+ resolveStillRunning();
13520
+ }, maxWaitMs);
13521
+ maxWaitTimer.unref?.();
13522
+ let resolveSettle = () => {};
13523
+ const settle = new Promise((res) => {
13524
+ resolveSettle = res;
13525
+ });
13526
+ const armIdle = () => {
13527
+ if (idleTimer)
13528
+ clearTimeout(idleTimer);
13529
+ idleTimer = setTimeout(() => resolveStillRunning(), idleMs);
13530
+ idleTimer.unref?.();
13531
+ };
13532
+ const stopTimers = () => {
13533
+ if (idleTimer)
13534
+ clearTimeout(idleTimer);
13535
+ idleTimer = null;
13536
+ clearTimeout(maxWaitTimer);
13537
+ };
13538
+ function resolveStillRunning() {
13539
+ if (settled)
13540
+ return;
13541
+ settled = true;
13542
+ stopTimers();
13543
+ resolveSettle({ status: "still-running", idleForMs: Date.now() - job.lastOutputAt, outputTail: job.tail });
13544
+ }
13545
+ const job = manager.create({
13546
+ id,
13547
+ cmd: opts.cmd,
13548
+ worktree: opts.worktree,
13549
+ ownerSession: opts.ownerSession,
13550
+ logPath,
13551
+ notify: opts.notify ?? true,
13552
+ killTree: () => killTree(child)
13553
+ });
13554
+ try {
13555
+ child = shellSpawn(opts.spawnFn ?? spawn3, opts.cmd, {
13556
+ cwd: opts.cwd,
13557
+ env: { ...opts.env ?? {}, [JOB_ENV_MARKER]: id }
13558
+ });
13559
+ } catch (err) {
13560
+ manager.markTerminal(job, "killed", null);
13561
+ maxWaitTimer && clearTimeout(maxWaitTimer);
13562
+ resolveSettle({ status: "exited", exitCode: null, outputTail: "", spawnError: String(err) });
13563
+ return { job, settle };
13564
+ }
13565
+ const onChunk = (d) => {
13566
+ const text = String(d);
13567
+ manager.appendOutput(job, text);
13568
+ try {
13569
+ appendFileSync(logPath, text);
13570
+ } catch {}
13571
+ if (opts.successPattern && !settled && job.succeededAt === null) {
13572
+ const m = opts.successPattern.exec(text) ?? opts.successPattern.exec(job.tail);
13573
+ if (m) {
13574
+ settled = true;
13575
+ stopTimers();
13576
+ job.succeededAt = Date.now();
13577
+ const keptAlive = opts.keepAlive !== false;
13578
+ if (!keptAlive) {
13579
+ killTree(child);
13580
+ manager.markTerminal(job, "succeeded", null);
13581
+ }
13582
+ resolveSettle({ status: "succeeded", matched: m[0], outputTail: job.tail, keptAlive });
13583
+ }
13584
+ }
13585
+ };
13586
+ child.stdout?.on("data", onChunk);
13587
+ child.stderr?.on("data", onChunk);
13588
+ child.on("exit", (code) => {
13589
+ if (exiting)
13590
+ return;
13591
+ exiting = true;
13592
+ stopTimers();
13593
+ let finished = false;
13594
+ const finish = () => {
13595
+ if (finished)
13596
+ return;
13597
+ finished = true;
13598
+ manager.markTerminal(job, "exited", code);
13599
+ rotateLogs(opts.logDir);
13600
+ if (!settled) {
13601
+ settled = true;
13602
+ resolveSettle({ status: "exited", exitCode: code, outputTail: job.tail });
13603
+ }
13604
+ };
13605
+ const graceTimer = setTimeout(finish, opts.exitGraceMs ?? 500);
13606
+ graceTimer.unref?.();
13607
+ const streams = [];
13608
+ if (child.stdout)
13609
+ streams.push(child.stdout);
13610
+ if (child.stderr)
13611
+ streams.push(child.stderr);
13612
+ let left = streams.length;
13613
+ if (left === 0)
13614
+ finish();
13615
+ else
13616
+ for (const s of streams)
13617
+ s.once("close", () => {
13618
+ if (--left === 0)
13619
+ finish();
13620
+ });
13621
+ });
13622
+ child.on("error", (err) => {
13623
+ if (!settled) {
13624
+ settled = true;
13625
+ stopTimers();
13626
+ manager.markTerminal(job, "killed", null);
13627
+ resolveSettle({ status: "exited", exitCode: null, outputTail: job.tail, spawnError: err.message });
13628
+ }
13629
+ });
13630
+ if (opts.runInBackground) {
13631
+ stopTimers();
13632
+ } else {
13633
+ armIdle();
13634
+ }
13635
+ return { job, settle };
13636
+ }
13637
+ async function pollJob(manager, jobId, waitMs = 0) {
13638
+ const job = manager.get(jobId);
13639
+ if (!job)
13640
+ return;
13641
+ const deadline = Date.now() + Math.min(Math.max(0, waitMs), POLL_WAIT_MAX_MS);
13642
+ while (job.outLen === job.pollCursor && job.state === "running" && Date.now() < deadline) {
13643
+ await new Promise((r) => setTimeout(r, 25));
13644
+ }
13645
+ return manager.poll(job);
13646
+ }
13647
+ function readJobLog(logPath, opts = {}) {
13648
+ const limit = Math.max(1, opts.limit ?? 200);
13649
+ let raw = "";
13650
+ try {
13651
+ raw = existsSync(logPath) ? readFileSync2(logPath, "utf8") : "";
13652
+ } catch {
13653
+ raw = "";
13654
+ }
13655
+ const lines = raw.length === 0 ? [] : raw.split(/\r?\n/);
13656
+ if (lines.length > 0 && lines[lines.length - 1] === "")
13657
+ lines.pop();
13658
+ const total = lines.length;
13659
+ const offset = opts.offset !== undefined ? Math.max(0, Math.min(opts.offset, total)) : Math.max(0, total - limit);
13660
+ return { lines: lines.slice(offset, offset + limit), total, offset };
13661
+ }
13662
+ function rotateLogs(logDir, keep = DEFAULT_LOG_KEEP) {
13663
+ let entries = [];
13664
+ try {
13665
+ entries = readdirSync(logDir).filter((f) => f.endsWith(".log")).map((name) => {
13666
+ try {
13667
+ return { name, mtime: statSync(join2(logDir, name)).mtimeMs };
13668
+ } catch {
13669
+ return { name, mtime: 0 };
13670
+ }
13671
+ });
13672
+ } catch {
13673
+ return;
13674
+ }
13675
+ const excess = entries.length - keep;
13676
+ if (excess <= 0)
13677
+ return;
13678
+ entries.sort((a, b) => a.mtime - b.mtime);
13679
+ for (const e of entries.slice(0, excess)) {
13680
+ try {
13681
+ unlinkSync(join2(logDir, e.name));
13682
+ } catch {}
13683
+ }
13684
+ }
13685
+
13686
+ // src/watchdog.ts
13687
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
13688
+ import { dirname } from "node:path";
13689
+ var WATCHDOG_ENV_MARK = "FORGE_WATCHDOG_MARK";
13690
+ var DEFAULT_STALL_MS = 600000;
13691
+ var MIN_STALL_MS = 60000;
13692
+ var DEFAULT_INTERVAL_MS = 30000;
13693
+ var DEFAULT_OBSERVE_MS = 60000;
13694
+ var WARN_RATIO = 0.8;
13695
+ function clampStallMs(raw, min = MIN_STALL_MS) {
13696
+ const n = typeof raw === "number" && Number.isFinite(raw) ? raw : DEFAULT_STALL_MS;
13697
+ return Math.max(min, Math.floor(n));
13698
+ }
13699
+ function parseMode(raw) {
13700
+ return raw === "off" || raw === "dry-run" || raw === "kill" ? raw : "kill";
13701
+ }
13702
+ function createWatchdog(opts = {}) {
13703
+ const mode = parseMode(opts.mode);
13704
+ const stallMs = clampStallMs(opts.stallMs, opts.minStallMs ?? MIN_STALL_MS);
13705
+ const intervalMs = Math.max(1, opts.intervalMs ?? DEFAULT_INTERVAL_MS);
13706
+ const observeMs = opts.observeMs ?? DEFAULT_OBSERVE_MS;
13707
+ const now = opts.now ?? Date.now;
13708
+ const sink = opts.sink ?? (() => {});
13709
+ const locate = opts.locate ?? (async () => []);
13710
+ const killTree2 = opts.killTree ?? (() => {});
13711
+ const isAlive = opts.isAlive ?? ((pid) => {
13712
+ try {
13713
+ process.kill(pid, 0);
13714
+ return true;
13715
+ } catch {
13716
+ return false;
13717
+ }
13718
+ });
13719
+ const setIntervalFn = opts.setIntervalFn ?? setInterval;
13720
+ const clearIntervalFn = opts.clearIntervalFn ?? clearInterval;
13721
+ let currentMode = mode;
13722
+ const table = new Map;
13723
+ const markerSeenIDs = new Set;
13724
+ let timer = null;
13725
+ const write = (event, rec, extra = {}) => {
13726
+ sink({
13727
+ ts: new Date(now()).toISOString(),
13728
+ event,
13729
+ callID: rec.callID,
13730
+ ...rec.sessionID !== undefined ? { sessionID: rec.sessionID } : {},
13731
+ tool: rec.tool,
13732
+ t0: rec.t0,
13733
+ elapsedMs: now() - rec.t0,
13734
+ mode: currentMode,
13735
+ ...extra
13736
+ });
13737
+ };
13738
+ const ensureTimer = () => {
13739
+ if (currentMode === "off" || timer)
13740
+ return;
13741
+ timer = setIntervalFn(() => {
13742
+ scan();
13743
+ }, intervalMs);
13744
+ timer.unref?.();
13745
+ };
13746
+ const maybeStopTimer = () => {
13747
+ if (timer && table.size === 0) {
13748
+ clearIntervalFn(timer);
13749
+ timer = null;
13750
+ }
13751
+ };
13752
+ const act = async (rec) => {
13753
+ rec.acted = true;
13754
+ try {
13755
+ if (!rec.markerSeen) {
13756
+ const pids2 = await locate(rec.callID, rec.t0, rec.cmdNeedle);
13757
+ write("dry-run-candidate", rec, {
13758
+ pids: pids2,
13759
+ reason: "marker-missing (shell.env hook did not fire) — degraded to dry-run"
13760
+ });
13761
+ return;
13762
+ }
13763
+ const pidsA = await locate(rec.callID, rec.t0, rec.cmdNeedle, false);
13764
+ let pids = pidsA;
13765
+ if (pidsA.length === 0 && rec.cmdNeedle !== undefined) {
13766
+ const pidsB = await locate(rec.callID, rec.t0, rec.cmdNeedle, true);
13767
+ if (pidsB.length > 0) {
13768
+ pids = pidsB;
13769
+ rec.wave = 2;
13770
+ }
13771
+ }
13772
+ if (pids.length === 0) {
13773
+ write("unresolved", rec, { reason: "no matching process found" });
13774
+ return;
13775
+ }
13776
+ if (currentMode === "dry-run") {
13777
+ write("dry-run-candidate", rec, { pids, candidates: pidsA });
13778
+ return;
13779
+ }
13780
+ const killed = [];
13781
+ for (const p of pids) {
13782
+ try {
13783
+ killTree2(p.pid);
13784
+ killed.push(p);
13785
+ } catch {}
13786
+ }
13787
+ rec.killedAt = now();
13788
+ if (rec.wave === 0)
13789
+ rec.wave = 1;
13790
+ write("kill", rec, {
13791
+ pids: killed,
13792
+ candidates: pids,
13793
+ reason: pids === pidsA ? "wave 1 — host subtree / command match" : "workspace scope (call's own chain already exited)"
13794
+ });
13795
+ } catch (err) {
13796
+ write("unresolved", rec, { reason: `intervention failed: ${String(err)}` });
13797
+ }
13798
+ };
13799
+ const scan = async () => {
13800
+ if (currentMode === "off")
13801
+ return;
13802
+ const nowMs = now();
13803
+ for (const rec of table.values()) {
13804
+ const elapsed = nowMs - rec.t0;
13805
+ if (rec.acted && rec.killedAt !== undefined && !rec.unresolvedReported) {
13806
+ if (nowMs - rec.killedAt >= observeMs) {
13807
+ if (currentMode === "kill" && rec.wave === 1 && rec.cmdNeedle !== undefined) {
13808
+ const wave2 = await locate(rec.callID, rec.t0, rec.cmdNeedle, true);
13809
+ const stillAlive = wave2.filter((p) => isAlive(p.pid));
13810
+ if (stillAlive.length > 0) {
13811
+ const killed2 = [];
13812
+ for (const p of stillAlive) {
13813
+ try {
13814
+ killTree2(p.pid);
13815
+ killed2.push(p);
13816
+ } catch {}
13817
+ }
13818
+ rec.killedAt = now();
13819
+ rec.wave = 2;
13820
+ write("kill", rec, { pids: killed2, reason: "wave 2 — workspace scope (wave 1 did not unblock the call)" });
13821
+ return;
13822
+ }
13823
+ }
13824
+ rec.unresolvedReported = true;
13825
+ write("unresolved", rec, { reason: "no tool.execute.after within the observation window after kill" });
13826
+ }
13827
+ continue;
13828
+ }
13829
+ if (rec.acted)
13830
+ continue;
13831
+ if (elapsed >= stallMs) {
13832
+ await act(rec);
13833
+ continue;
13834
+ }
13835
+ if (elapsed >= WARN_RATIO * stallMs && !rec.warned) {
13836
+ rec.warned = true;
13837
+ write("warn", rec);
13838
+ }
13839
+ }
13840
+ maybeStopTimer();
13841
+ };
13842
+ return {
13843
+ get mode() {
13844
+ return currentMode;
13845
+ },
13846
+ get stallMs() {
13847
+ return stallMs;
13848
+ },
13849
+ track(callID, sessionID, tool3, t0 = now(), cmdNeedle) {
13850
+ if (currentMode === "off")
13851
+ return;
13852
+ table.set(callID, {
13853
+ callID,
13854
+ sessionID,
13855
+ tool: tool3,
13856
+ t0,
13857
+ markerSeen: markerSeenIDs.has(callID),
13858
+ warned: false,
13859
+ acted: false,
13860
+ wave: 0,
13861
+ ...cmdNeedle !== undefined && cmdNeedle.length > 0 ? { cmdNeedle } : {}
13862
+ });
13863
+ ensureTimer();
13864
+ },
13865
+ markSeen(callID) {
13866
+ markerSeenIDs.add(callID);
13867
+ const rec = table.get(callID);
13868
+ if (rec)
13869
+ rec.markerSeen = true;
13870
+ },
13871
+ untrack(callID) {
13872
+ table.delete(callID);
13873
+ maybeStopTimer();
13874
+ },
13875
+ has: (callID) => table.has(callID),
13876
+ size: () => table.size,
13877
+ scan,
13878
+ setMode(next) {
13879
+ currentMode = next;
13880
+ if (next === "off") {
13881
+ if (timer) {
13882
+ clearIntervalFn(timer);
13883
+ timer = null;
13884
+ }
13885
+ table.clear();
13886
+ } else {
13887
+ ensureTimer();
13888
+ }
13889
+ },
13890
+ dispose() {
13891
+ if (timer) {
13892
+ clearIntervalFn(timer);
13893
+ timer = null;
13894
+ }
13895
+ table.clear();
13896
+ }
13897
+ };
13898
+ }
13899
+ function watchdogLogDir(base) {
13900
+ const tmp = base ?? (process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp");
13901
+ return `${tmp.replace(/[\\/]+$/, "")}/opencode-forge/watchdog`;
13902
+ }
13903
+ function createFileLedger(logPath, maxEntries = 200, maxBytes = 1e6) {
13904
+ const append = (entry) => {
13905
+ try {
13906
+ mkdirSync2(dirname(logPath), { recursive: true });
13907
+ let lines = [];
13908
+ try {
13909
+ lines = readFileSync3(logPath, "utf8").split(`
13910
+ `).filter((l) => l.trim().length > 0);
13911
+ } catch {}
13912
+ if (lines.length >= maxEntries)
13913
+ lines = lines.slice(lines.length - maxEntries + 1);
13914
+ lines.push(JSON.stringify(entry));
13915
+ let text = lines.join(`
13916
+ `) + `
13917
+ `;
13918
+ if (text.length > maxBytes) {
13919
+ const keep = Math.max(1, Math.floor(maxEntries / 2));
13920
+ text = lines.slice(-keep).join(`
13921
+ `) + `
13922
+ `;
13923
+ }
13924
+ writeFileSync2(logPath, text, "utf8");
13925
+ } catch {}
13926
+ };
13927
+ const entries = () => {
13928
+ try {
13929
+ return readFileSync3(logPath, "utf8").split(`
13930
+ `).filter((l) => l.trim().length > 0).map((l) => JSON.parse(l));
13931
+ } catch {
13932
+ return [];
13933
+ }
13934
+ };
13935
+ return { append, entries, path: logPath };
13936
+ }
13937
+
13938
+ // src/proc-locate.ts
13939
+ import { execFileSync } from "node:child_process";
13940
+ import { readdirSync as readdirSync2, readFileSync as readFileSync4 } from "node:fs";
13941
+ function commandNeedle(command) {
13942
+ const trimmed = command.trim().replace(/^["']|["']$/g, "");
13943
+ if (trimmed.length === 0)
13944
+ return "";
13945
+ const tokens = trimmed.split(/\s+/);
13946
+ const longest = tokens.reduce((a, b) => b.length > a.length ? b : a, "");
13947
+ return longest.length >= 6 ? longest : trimmed;
13948
+ }
13949
+ function markerValue(callID) {
13950
+ return `opencode-forge:${callID}`;
13951
+ }
13952
+ function createPosixLocator(deps = {}) {
13953
+ const listPids = deps.listPids ?? (() => readdirSync2("/proc").map(Number).filter((n) => Number.isInteger(n) && n > 0));
13954
+ const readEnv = deps.readEnv ?? ((pid) => {
13955
+ try {
13956
+ return readFileSync4(`/proc/${pid}/environ`);
13957
+ } catch {
13958
+ return null;
13959
+ }
13960
+ });
13961
+ const readCmd = deps.readCmd ?? ((pid) => {
13962
+ try {
13963
+ return readFileSync4(`/proc/${pid}/cmdline`).toString("utf8").split("\x00").filter(Boolean).join(" ");
13964
+ } catch {
13965
+ return String(pid);
13966
+ }
13967
+ });
13968
+ return async (callID, _t0) => {
13969
+ const want = `${WATCHDOG_ENV_MARK}=${markerValue(callID)}`;
13970
+ const hits = [];
13971
+ for (const pid of listPids()) {
13972
+ const env = readEnv(pid);
13973
+ if (!env)
13974
+ continue;
13975
+ if (env.toString("utf8").split("\x00").includes(want))
13976
+ hits.push({ pid, cmd: readCmd(pid) });
13977
+ }
13978
+ return hits;
13979
+ };
13980
+ }
13981
+ var PS_LIST_PROCS = "[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; " + "Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CommandLine,CreationDate " + "| ConvertTo-Json -Compress";
13982
+ function parseWindowsProcs(raw) {
13983
+ let parsed;
13984
+ try {
13985
+ parsed = JSON.parse(raw);
13986
+ } catch {
13987
+ return [];
13988
+ }
13989
+ const arr = Array.isArray(parsed) ? parsed : [parsed];
13990
+ const out = [];
13991
+ for (const p of arr) {
13992
+ const pid = Number(p?.ProcessId ?? p?.pid);
13993
+ const ppid = Number(p?.ParentProcessId ?? p?.ppid);
13994
+ if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(ppid))
13995
+ continue;
13996
+ const cmd = typeof p?.CommandLine === "string" ? p.CommandLine : "";
13997
+ let createdMs = Number.NaN;
13998
+ const cd = p?.CreationDate ?? p?.createdMs;
13999
+ if (typeof cd === "number")
14000
+ createdMs = cd;
14001
+ else if (typeof cd === "string") {
14002
+ const slash = /\/Date\((\d+)\)\//.exec(cd);
14003
+ if (slash)
14004
+ createdMs = Number(slash[1]);
14005
+ else {
14006
+ const t = Date.parse(cd);
14007
+ if (!Number.isNaN(t))
14008
+ createdMs = t;
14009
+ }
14010
+ }
14011
+ if (Number.isNaN(createdMs))
14012
+ continue;
14013
+ out.push({ pid, ppid, cmd, createdMs });
14014
+ }
14015
+ return out;
14016
+ }
14017
+ var WINDOW_SLACK_MS = 2000;
14018
+ function createWindowsLocator(deps = {}) {
14019
+ const hostPid = deps.hostPid ?? process.pid;
14020
+ const execFn = deps.execFn ?? ((cmd) => execFileSync("powershell", ["-NoProfile", "-Command", cmd], { encoding: "utf8", windowsHide: true, timeout: 15000 }));
14021
+ return async (callID, t0, cmdNeedle, phase2 = false) => {
14022
+ let raw = "[]";
14023
+ try {
14024
+ raw = execFn(PS_LIST_PROCS);
14025
+ } catch {
14026
+ return [];
14027
+ }
14028
+ const procs = parseWindowsProcs(raw);
14029
+ const children = new Map;
14030
+ for (const p of procs) {
14031
+ const list = children.get(p.ppid) ?? [];
14032
+ list.push(p.pid);
14033
+ children.set(p.ppid, list);
14034
+ }
14035
+ const descendants = new Set;
14036
+ const queue = [hostPid];
14037
+ while (queue.length > 0) {
14038
+ const cur = queue.pop();
14039
+ for (const child of children.get(cur) ?? []) {
14040
+ if (!descendants.has(child)) {
14041
+ descendants.add(child);
14042
+ queue.push(child);
14043
+ }
14044
+ }
14045
+ }
14046
+ const windowStart = t0 - WINDOW_SLACK_MS;
14047
+ const norm = (s) => s.replace(/\\/g, "/");
14048
+ const slash = cmdNeedle !== undefined ? cmdNeedle.lastIndexOf("/") : -1;
14049
+ const dirNeedle = phase2 && cmdNeedle !== undefined && slash > 0 ? norm(cmdNeedle.slice(0, slash)) : undefined;
14050
+ 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 }));
14051
+ };
14052
+ }
14053
+ function createLocator(platform = process.platform, posix = {}, win = {}) {
14054
+ return platform === "win32" ? createWindowsLocator(win) : createPosixLocator(posix);
14055
+ }
14056
+
13252
14057
  // plugin.ts
13253
14058
  var FORGE_AGENT = "forge";
13254
14059
  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.
@@ -13270,9 +14075,9 @@ var PLAN_COMMAND_TEMPLATE = [
13270
14075
  "",
13271
14076
  "Plans are short-horizon, single-task-goal documents (.opencode/plan/<date>-<slug>.md). The harness enforces the hard parts (draft write-ban, approval/close dialogs); you supply the engineering judgment. Never edit or create plan files by hand — every change goes through the plan_* tools.",
13272
14077
  "",
13273
- "1. Reconnaissance (read-only): explore with read/grep/glob only — all write tools, bash, and subagents are DENIED while a draft exists; do not attempt them, do not ask the user to bypass. Gather concrete evidence with file:line references (verified findings, not guesses). If the goal is ambiguous on scope, behavior, or acceptance, ask the user 1-3 focused questions FIRST — do not plan against assumptions the user could settle in one line.",
14078
+ "1. Reconnaissance and alignment (read-only): explore with read/grep/glob only — all write tools, bash, and subagents are DENIED while a draft exists; do not attempt them, do not ask the user to bypass. Gather concrete evidence with file:line references (verified findings, not guesses). If the goal is ambiguous or leaves meaningful choices open (scope, approach, acceptance), settle them with the user FIRST — 1-3 focused questions — and only write the draft once the shape is agreed; never plan against assumptions the user could settle in one line.",
13274
14079
  "2. Draft (plan_write): call plan_write with structured fields; the tool renders and validates the fixed sections, so a malformed plan cannot exist. Quality bar: goal = one line, the outcome not the activity; context = verified findings with file:line evidence, including what you ruled out and why; approach = the chosen approach AND at least one rejected alternative with the reason (a plan with no considered alternative is a guess); tasks = 3-8 concrete, independently verifiable steps, each doable in one sitting ('improve the code' is invalid; 'extract the timeout constant into config.ts and default it to 3000' is valid); risks = what could break, blast radius, rollback path; acceptance = criteria verifiable by a command, a file, or an observable behavior (vague criteria will fail the close); nonGoals = explicit out-of-scope items. To revise after feedback, call plan_write again — while in draft it overwrites the same file.",
13275
- "3. Approval (plan_approve): present briefly — goal, chosen approach with one line why, the numbered task list, the acceptance criteria — then call plan_approve. The user's confirmation in the dialog IS the approval; if they object, revise with plan_write and present again. Never implement before approval succeeds.",
14080
+ `3. User review, then approval (plan_approve): after plan_write, present the plan in chat — goal, chosen approach with one line why, the numbered task list, the acceptance criteria — then STOP: end your turn and wait. The user owns the review, at their own pace: feedback → revise with plan_write, re-present, and wait again; rejection → /plan discard (or re-plan together); explicit go-ahead (e.g. "execute", "批准", "looks good") → call plan_approve — its confirmation dialog is the final hard gate, and the user's Allow starts execution. Never call plan_approve in the same turn that presents a draft, and never implement before approval succeeds.`,
13276
14081
  "4. Execution (tick as you go): work tasks in order; call plan_tick with the task number immediately after EACH task's work is actually done — never batch ticks, never tick ahead of reality (tick timestamps are an audit trail). If the plan turns out wrong mid-execution, do not silently improvise: tell the user what changed and either finish the affected task or ask about revising (/plan with the same goal re-enters planning).",
13277
14082
  "5. Completion (plan_close): when all tasks are ticked, self-check EVERY acceptance criterion with concrete evidence (file:line, command output, test result), then call plan_close with one check per criterion, pass/fail honest — a failing check refuses the close, and that is the design working, not an inconvenience. The user confirms closure in a dialog.",
13278
14083
  "",
@@ -13312,6 +14117,68 @@ function effectiveWorktree(worktree, fallback) {
13312
14117
  }
13313
14118
  var sessions = new Map;
13314
14119
  var forgeDisabled = false;
14120
+ var jobsMode = "auto";
14121
+ var jobsKeepBuiltinShell = false;
14122
+ var nativeBackgroundSeen = false;
14123
+ function jobStage() {
14124
+ if (jobsMode === "native")
14125
+ return 2;
14126
+ if (jobsMode === "forge")
14127
+ return 0;
14128
+ return nativeBackgroundSeen ? 1 : 0;
14129
+ }
14130
+ var jobLogDir = jobsLogDir();
14131
+ var jobLedgerPath = join3(jobLogDir, "ledger.jsonl");
14132
+ function jobLedgerSink(entry) {
14133
+ try {
14134
+ mkdirSync3(jobLogDir, { recursive: true });
14135
+ if (existsSync2(jobLedgerPath) && statSync2(jobLedgerPath).size > 1e6)
14136
+ writeFileSync3(jobLedgerPath, "");
14137
+ appendFileSync2(jobLedgerPath, `${JSON.stringify(entry)}
14138
+ `);
14139
+ } catch {}
14140
+ }
14141
+ var jobManager = createJobManager({ sink: jobLedgerSink });
14142
+ var jobClient = null;
14143
+ function jobWakeText(job) {
14144
+ return [
14145
+ `[forge:job-complete] Background job ${job.id} finished (${job.state}${job.exitCode !== null ? `, exit ${job.exitCode}` : ""}).`,
14146
+ `Command: ${job.cmd}`,
14147
+ `Recent output:
14148
+ ${job.tail.slice(-1500) || "(none)"}`,
14149
+ "Details via forge_jobs (poll/log); the job stays in the registry until cleared."
14150
+ ].join(`
14151
+ `);
14152
+ }
14153
+ async function deliverJobWakes(sessionID) {
14154
+ if (forgeDisabled || jobStage() >= 2)
14155
+ return;
14156
+ if (!jobClient || typeof jobClient.session.promptAsync !== "function")
14157
+ return;
14158
+ for (const job of jobManager.deliverWakesFor(sessionID)) {
14159
+ try {
14160
+ await jobClient.session.promptAsync({ path: { id: sessionID }, body: { parts: [{ type: "text", text: jobWakeText(job) }] } });
14161
+ } catch {
14162
+ job.wakeState = "queued";
14163
+ }
14164
+ }
14165
+ }
14166
+ async function handoffTarget(sessionID) {
14167
+ if (!jobClient || typeof jobClient.session.get !== "function")
14168
+ return;
14169
+ let current = sessionID;
14170
+ for (let depth = 0;depth < 10; depth++) {
14171
+ try {
14172
+ const info = await jobClient.session.get({ path: { id: current } });
14173
+ if (!info?.parentID)
14174
+ return current === sessionID ? undefined : current;
14175
+ current = info.parentID;
14176
+ } catch {
14177
+ return;
14178
+ }
14179
+ }
14180
+ return current === sessionID ? undefined : current;
14181
+ }
13315
14182
  var WRITE_TOOLS = new Set(["write", "edit", "bash", "task", "apply", "applypatch", "patch", "multiedit"]);
13316
14183
  function isWriteTool(name) {
13317
14184
  const n = name.toLowerCase();
@@ -13321,13 +14188,13 @@ function nowIso() {
13321
14188
  return new Date().toISOString();
13322
14189
  }
13323
14190
  function planDirOf(worktree) {
13324
- return join2(worktree, ".opencode", "plan");
14191
+ return join3(worktree, ".opencode", "plan");
13325
14192
  }
13326
14193
  function readPlanDir(worktree) {
13327
14194
  const dir = planDirOf(worktree);
13328
- if (!existsSync(dir))
14195
+ if (!existsSync2(dir))
13329
14196
  return [];
13330
- return readdirSync(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync2(join2(dir, name), "utf8") }));
14197
+ return readdirSync3(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync5(join3(dir, name), "utf8") }));
13331
14198
  }
13332
14199
  function ensureSession(sessionID, worktree) {
13333
14200
  const existing = sessions.get(sessionID);
@@ -13343,8 +14210,8 @@ function worktreeFor(context) {
13343
14210
  return effectiveWorktree(context.worktree, hostWorktree) || context.worktree;
13344
14211
  }
13345
14212
  function resolveActivePlan(state) {
13346
- if (state.planPath && existsSync(state.planPath)) {
13347
- const doc2 = parsePlanLoose(readFileSync2(state.planPath, "utf8"));
14213
+ if (state.planPath && existsSync2(state.planPath)) {
14214
+ const doc2 = parsePlanLoose(readFileSync5(state.planPath, "utf8"));
13348
14215
  if (doc2 && !isTerminal(doc2.status))
13349
14216
  return { path: state.planPath, doc: doc2 };
13350
14217
  state.planPath = undefined;
@@ -13352,7 +14219,7 @@ function resolveActivePlan(state) {
13352
14219
  const ranked = rankActivePlans(readPlanDir(state.worktree));
13353
14220
  if (ranked.length === 0)
13354
14221
  return null;
13355
- const path = join2(planDirOf(state.worktree), ranked[0].name);
14222
+ const path = join3(planDirOf(state.worktree), ranked[0].name);
13356
14223
  state.planPath = path;
13357
14224
  return { path, doc: ranked[0].doc };
13358
14225
  }
@@ -13386,12 +14253,12 @@ var planWriteTool = tool({
13386
14253
  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
14254
  } else {
13388
14255
  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"))));
14256
+ mkdirSync3(dir, { recursive: true });
14257
+ path = join3(dir, planFileName(localDate(), slugify(args.goal), readdirSync3(dir).filter((f) => f.endsWith(".md"))));
13391
14258
  mode = "created";
13392
14259
  }
13393
14260
  const text = renderPlan(args, now, created);
13394
- writeFileSync2(path, text);
14261
+ writeFileSync3(path, text);
13395
14262
  state.planPath = path;
13396
14263
  const doc2 = parsePlan(text);
13397
14264
  context.metadata({ title: `${mode === "created" ? "Create" : "Revise"} plan: ${doc2.goal}` });
@@ -13419,8 +14286,8 @@ var planTickTool = tool({
13419
14286
  if (active.doc.status !== "approved") {
13420
14287
  throw new PlanError(`Plan status is ${active.doc.status}; only an approved plan can be ticked. Get user approval via plan_approve first.`);
13421
14288
  }
13422
- const next = tickTask(readFileSync2(active.path, "utf8"), args.n, nowIso());
13423
- writeFileSync2(active.path, next);
14289
+ const next = tickTask(readFileSync5(active.path, "utf8"), args.n, nowIso());
14290
+ writeFileSync3(active.path, next);
13424
14291
  const doc2 = parsePlan(next);
13425
14292
  const p = progressOf(doc2);
13426
14293
  context.metadata({ title: `Tick task ${args.n} (${p.done}/${p.total})` });
@@ -13434,7 +14301,7 @@ async function gate(ask, permission, title) {
13434
14301
  await ask({ permission, patterns: ["*"], always: [], metadata: { title } });
13435
14302
  }
13436
14303
  var planApproveTool = tool({
13437
- description: "Ask the user to approve the session's draft plan (draft -> approved). The permission layer pins this call to a confirmation dialog — the user's Allow IS the approval. After approval the draft-phase write ban is lifted. Optionally pass a one-line summary of what changed since the last revision.",
14304
+ description: "Approve the session's draft plan (draft -> approved). Call it ONLY after presenting the plan and receiving the user's explicit go-ahead in chat — never in the same turn that presents the draft. The permission layer pins this call to a confirmation dialog — the user's Allow is the final hard gate and lifts the draft-phase write ban. Optionally pass a one-line summary of what changed since the last revision.",
13438
14305
  args: {
13439
14306
  summary: tool.schema.string().optional().describe("One-line summary presented alongside the approval request")
13440
14307
  },
@@ -13447,7 +14314,7 @@ var planApproveTool = tool({
13447
14314
  throw new PlanError(`Plan status is ${active.doc.status}; only a draft plan can be approved.`);
13448
14315
  }
13449
14316
  await gate(context.ask, "plan_approve", `Approve plan: ${active.doc.goal}`);
13450
- writeFileSync2(active.path, transitionStatus(readFileSync2(active.path, "utf8"), "approved", nowIso()));
14317
+ writeFileSync3(active.path, transitionStatus(readFileSync5(active.path, "utf8"), "approved", nowIso()));
13451
14318
  context.metadata({ title: `Plan approved: ${active.doc.goal}` });
13452
14319
  return {
13453
14320
  title: "plan approved",
@@ -13480,7 +14347,7 @@ var planCloseTool = tool({
13480
14347
  Fix the implementation and retry, or revise the plan first.`);
13481
14348
  }
13482
14349
  await gate(context.ask, "plan_close", `Close plan: ${active.doc.goal}`);
13483
- writeFileSync2(active.path, transitionStatus(readFileSync2(active.path, "utf8"), "done", nowIso()));
14350
+ writeFileSync3(active.path, transitionStatus(readFileSync5(active.path, "utf8"), "done", nowIso()));
13484
14351
  context.metadata({ title: `Plan done: ${active.doc.goal}` });
13485
14352
  return {
13486
14353
  title: "plan done",
@@ -13498,31 +14365,31 @@ var planDiscardTool = tool({
13498
14365
  const active = resolveActivePlan(state);
13499
14366
  if (!active)
13500
14367
  throw new PlanError("No plan to abandon in this workspace.");
13501
- writeFileSync2(active.path, transitionStatus(readFileSync2(active.path, "utf8"), "abandoned", nowIso()));
14368
+ writeFileSync3(active.path, transitionStatus(readFileSync5(active.path, "utf8"), "abandoned", nowIso()));
13502
14369
  state.planPath = undefined;
13503
14370
  context.metadata({ title: `Plan abandoned: ${active.doc.goal}` });
13504
14371
  return { title: "plan abandoned", output: `Plan abandoned: ${relFrom(state.worktree, active.path)}. Write operations are restored.` };
13505
14372
  }
13506
14373
  });
13507
14374
  function goalDirOf(worktree) {
13508
- return join2(worktree, ".opencode", "goal");
14375
+ return join3(worktree, ".opencode", "goal");
13509
14376
  }
13510
14377
  function readGoalDir(worktree) {
13511
14378
  const dir = goalDirOf(worktree);
13512
- if (!existsSync(dir))
14379
+ if (!existsSync2(dir))
13513
14380
  return [];
13514
- return readdirSync(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync2(join2(dir, name), "utf8") }));
14381
+ return readdirSync3(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync5(join3(dir, name), "utf8") }));
13515
14382
  }
13516
14383
  function resolveSessionGoal(state) {
13517
- if (state.goalPath && existsSync(state.goalPath)) {
13518
- const doc2 = parseGoalLoose(readFileSync2(state.goalPath, "utf8"));
14384
+ if (state.goalPath && existsSync2(state.goalPath)) {
14385
+ const doc2 = parseGoalLoose(readFileSync5(state.goalPath, "utf8"));
13519
14386
  if (doc2 && !isGoalTerminal(doc2.status))
13520
14387
  return { path: state.goalPath, doc: doc2 };
13521
14388
  state.goalPath = undefined;
13522
14389
  }
13523
14390
  const live = rankLiveGoals(readGoalDir(state.worktree))[0];
13524
14391
  if (live) {
13525
- const path = join2(goalDirOf(state.worktree), live.name);
14392
+ const path = join3(goalDirOf(state.worktree), live.name);
13526
14393
  state.goalPath = path;
13527
14394
  return { path, doc: live.doc };
13528
14395
  }
@@ -13619,9 +14486,9 @@ var goalWriteTool = tool({
13619
14486
  await gate(context.ask, "goal_write", `Arm goal: ${args.goal}`);
13620
14487
  }
13621
14488
  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);
14489
+ mkdirSync3(dir, { recursive: true });
14490
+ const name = goalFileName(localDateNow(), slugifyGoal(args.goal), readdirSync3(dir).filter((f) => f.endsWith(".md")));
14491
+ const path = join3(dir, name);
13625
14492
  const text = renderGoal(input, {
13626
14493
  now,
13627
14494
  status: arm ? "active" : "queued",
@@ -13661,7 +14528,7 @@ var goalCheckTool = tool({
13661
14528
  o.index = selected[i].n;
13662
14529
  });
13663
14530
  const runId = `${nowIso()}-${Math.random().toString(36).slice(2, 8)}`;
13664
- const next = appendCheckLog(readFileSync2(goal.path, "utf8"), runId, outcomes, nowIso());
14531
+ const next = appendCheckLog(readFileSync5(goal.path, "utf8"), runId, outcomes, nowIso());
13665
14532
  atomicWrite(goal.path, next);
13666
14533
  const ok = outcomesAllOk(outcomes);
13667
14534
  context.metadata({ title: `goal_check: ${outcomes.filter((o) => o.ok).length}/${outcomes.length} pass` });
@@ -13695,7 +14562,7 @@ var goalCompleteTool = tool({
13695
14562
  const failures = outcomes.filter((o) => !o.ok);
13696
14563
  if (failures.length > 0) {
13697
14564
  const runId2 = `${nowIso()}-${Math.random().toString(36).slice(2, 8)}`;
13698
- atomicWrite(goal.path, appendCheckLog(readFileSync2(goal.path, "utf8"), runId2, outcomes, nowIso()));
14565
+ atomicWrite(goal.path, appendCheckLog(readFileSync5(goal.path, "utf8"), runId2, outcomes, nowIso()));
13699
14566
  throw new GoalError(`Completion gate: verification re-run failed (fail-closed). The goal stays active.
13700
14567
  ${formatOutcomes(failures)}
13701
14568
  Fix the work and retry; recorded results never substitute for the gate's own re-run.`);
@@ -13708,7 +14575,7 @@ Fix the work and retry; recorded results never substitute for the gate's own re-
13708
14575
  }
13709
14576
  await gate(context.ask, "goal_complete", `Complete goal: ${goal.doc.goal}`);
13710
14577
  const runId = `${nowIso()}-${Math.random().toString(36).slice(2, 8)}`;
13711
- let text = appendCheckLog(readFileSync2(goal.path, "utf8"), runId, outcomes, nowIso());
14578
+ let text = appendCheckLog(readFileSync5(goal.path, "utf8"), runId, outcomes, nowIso());
13712
14579
  text = transitionGoal(text, "completed", nowIso());
13713
14580
  atomicWrite(goal.path, text);
13714
14581
  context.metadata({ title: `Goal completed: ${goal.doc.goal}` });
@@ -13730,7 +14597,7 @@ var goalPauseTool = tool({
13730
14597
  throw new GoalError(`No active goal to pause (status: ${goal?.doc.status ?? "none"}).`);
13731
14598
  }
13732
14599
  const stopReason = args.blocker ? "blocker" : "user";
13733
- atomicWrite(goal.path, transitionGoal(readFileSync2(goal.path, "utf8"), "paused", nowIso(), { stopReason }));
14600
+ atomicWrite(goal.path, transitionGoal(readFileSync5(goal.path, "utf8"), "paused", nowIso(), { stopReason }));
13734
14601
  engineForgetSession(context.sessionID);
13735
14602
  context.metadata({ title: `Goal paused (${stopReason}): ${goal.doc.goal}` });
13736
14603
  return {
@@ -13750,7 +14617,7 @@ var goalResumeTool = tool({
13750
14617
  if (!goal || goal.doc.status === "queued") {
13751
14618
  const oldest = rankQueuedGoals(readGoalDir(state.worktree))[0];
13752
14619
  if (oldest) {
13753
- const path = join2(goalDirOf(state.worktree), oldest.name);
14620
+ const path = join3(goalDirOf(state.worktree), oldest.name);
13754
14621
  state.goalPath = path;
13755
14622
  goal = { path, doc: oldest.doc };
13756
14623
  }
@@ -13760,7 +14627,7 @@ var goalResumeTool = tool({
13760
14627
  }
13761
14628
  const promoting = goal.doc.status === "queued";
13762
14629
  await gate(context.ask, "goal_resume", `${promoting ? "Promote" : "Resume"} goal: ${goal.doc.goal}`);
13763
- let text = readFileSync2(goal.path, "utf8");
14630
+ let text = readFileSync5(goal.path, "utf8");
13764
14631
  if (args.addTurns)
13765
14632
  text = bumpBudget(text, args.addTurns, nowIso());
13766
14633
  text = transitionGoal(text, "active", nowIso(), { session: context.sessionID });
@@ -13786,7 +14653,7 @@ var goalDiscardTool = tool({
13786
14653
  if (!goal)
13787
14654
  throw new GoalError("No goal to discard in this workspace.");
13788
14655
  await gate(context.ask, "goal_discard", `Discard goal: ${goal.doc.goal}`);
13789
- atomicWrite(goal.path, transitionGoal(readFileSync2(goal.path, "utf8"), "abandoned", nowIso()));
14656
+ atomicWrite(goal.path, transitionGoal(readFileSync5(goal.path, "utf8"), "abandoned", nowIso()));
13790
14657
  state.goalPath = undefined;
13791
14658
  engineForgetSession(context.sessionID);
13792
14659
  context.metadata({ title: `Goal abandoned: ${goal.doc.goal}` });
@@ -13796,8 +14663,171 @@ var goalDiscardTool = tool({
13796
14663
  };
13797
14664
  }
13798
14665
  });
14666
+ var FORGE_SHELL_DESCRIPTION = [
14667
+ "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).",
14668
+ "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.",
14669
+ "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`.",
14670
+ "Long-running or possibly non-exiting commands (dev servers, watchers, installers, anything spawning detached children) MUST use this tool instead of the builtin shell."
14671
+ ].join(`
14672
+ `);
14673
+ 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.";
14674
+ var forgeShellTool = tool({
14675
+ description: FORGE_SHELL_DESCRIPTION,
14676
+ args: {
14677
+ command: tool.schema.string().describe("The shell command to run"),
14678
+ workdir: tool.schema.string().optional().describe("Working directory (workspace-relative, or absolute)"),
14679
+ run_in_background: tool.schema.boolean().optional().describe("Return {jobId, logPath} immediately, without waiting for any output or exit"),
14680
+ idle_ms: tool.schema.number().int().nonnegative().optional().describe("No-new-output early-return threshold in ms (default 60000)"),
14681
+ max_wait_ms: tool.schema.number().int().nonnegative().optional().describe("Hard cap on this call's wait in ms (default 120000, clamped to 600000); returns still-running, never kills"),
14682
+ success_pattern: tool.schema.string().optional().describe("Regex; a match against new output completes the call as success immediately"),
14683
+ keep_alive: tool.schema.boolean().optional().describe("After a success match: keep the process alive (default) or kill its tree (false)"),
14684
+ notify: tool.schema.boolean().optional().describe("Send a [forge:job-complete] message into this session when the job exits (default true)")
14685
+ },
14686
+ execute: async (args, context) => {
14687
+ if (jobStage() >= 2) {
14688
+ 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.");
14689
+ }
14690
+ const state = ensureSession(context.sessionID, worktreeFor(context));
14691
+ await gate(context.ask, "forge_shell", `forge_shell: ${args.command.slice(0, 100)}`);
14692
+ let successPattern = null;
14693
+ if (args.success_pattern) {
14694
+ try {
14695
+ successPattern = new RegExp(args.success_pattern);
14696
+ } catch (err) {
14697
+ throw new Error(`Invalid success_pattern: ${err.message}`);
14698
+ }
14699
+ }
14700
+ const cwd = args.workdir ? isAbsolute(args.workdir) ? args.workdir : join3(state.worktree, args.workdir) : state.worktree;
14701
+ const started = startJob(jobManager, {
14702
+ cmd: args.command,
14703
+ cwd,
14704
+ ownerSession: context.sessionID,
14705
+ worktree: state.worktree,
14706
+ logDir: jobLogDir,
14707
+ runInBackground: args.run_in_background === true,
14708
+ ...args.idle_ms !== undefined ? { idleMs: args.idle_ms } : {},
14709
+ ...args.max_wait_ms !== undefined ? { maxWaitMs: args.max_wait_ms } : {},
14710
+ successPattern,
14711
+ ...args.keep_alive !== undefined ? { keepAlive: args.keep_alive } : {},
14712
+ ...args.notify !== undefined ? { notify: args.notify } : {}
14713
+ });
14714
+ context.metadata({ title: `forge_shell: ${args.command.slice(0, 60)}` });
14715
+ if (args.run_in_background === true) {
14716
+ return {
14717
+ title: `job started: ${started.job.id}`,
14718
+ 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(`
14719
+ `)
14720
+ };
14721
+ }
14722
+ const r = await started.settle;
14723
+ if (r.status === "exited") {
14724
+ return {
14725
+ title: `exit ${r.exitCode ?? "?"}`,
14726
+ output: [
14727
+ `[forge:job] Command finished (exit=${r.exitCode ?? "none"}).`,
14728
+ ...r.spawnError ? [`spawn error: ${r.spawnError}`] : [],
14729
+ `output:
14730
+ ${r.outputTail || "(none)"}`
14731
+ ].join(`
14732
+ `)
14733
+ };
14734
+ }
14735
+ if (r.status === "succeeded") {
14736
+ return {
14737
+ title: `success: ${r.matched}`,
14738
+ output: [
14739
+ `[forge:job] Success pattern matched: "${r.matched}".`,
14740
+ 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).",
14741
+ `output:
14742
+ ${r.outputTail}`
14743
+ ].join(`
14744
+ `)
14745
+ };
14746
+ }
14747
+ return {
14748
+ title: `still running (${Math.round(r.idleForMs / 1000)}s idle)`,
14749
+ output: [
14750
+ `[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}.`,
14751
+ `logPath: ${started.job.logPath}`,
14752
+ `recent output:
14753
+ ${r.outputTail || "(none yet)"}`,
14754
+ "Next: forge_jobs poll to keep waiting, forge_jobs log for history, forge_jobs kill to stop."
14755
+ ].join(`
14756
+ `)
14757
+ };
14758
+ }
14759
+ });
14760
+ var FORGE_JOBS_ACTIONS = ["list", "poll", "log", "kill", "clear", "handoff"];
14761
+ var forgeJobsTool = tool({
14762
+ 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.",
14763
+ args: {
14764
+ action: tool.schema.string().describe(`One of: ${FORGE_JOBS_ACTIONS.join(", ")}`),
14765
+ jobId: tool.schema.string().optional().describe("Job id from forge_shell (required for every action except list)"),
14766
+ waitMs: tool.schema.number().int().nonnegative().optional().describe("poll: bounded in-call wait (clamped to 30000)"),
14767
+ offset: tool.schema.number().int().nonnegative().optional().describe("log: first line index; omitted = tail window"),
14768
+ limit: tool.schema.number().int().positive().optional().describe("log: max lines (default 200)")
14769
+ },
14770
+ execute: async (args, context) => {
14771
+ if (jobStage() >= 2)
14772
+ throw new Error("[forge] The job supervisor is retired on this host.");
14773
+ const action = String(args.action ?? "");
14774
+ if (!FORGE_JOBS_ACTIONS.includes(action)) {
14775
+ throw new Error(`Unknown action "${action}" — use one of: ${FORGE_JOBS_ACTIONS.join(", ")}.`);
14776
+ }
14777
+ if (action === "list") {
14778
+ const rows = jobManager.list().map((j) => `${j.id} ${j.state}${j.exitCode !== null ? `(${j.exitCode})` : ""}${j.succeededAt ? "*" : ""} ${j.scope} ${j.cmd.slice(0, 60)}`);
14779
+ return { title: `jobs (${rows.length})`, output: rows.length > 0 ? rows.join(`
14780
+ `) : "(no jobs)" };
14781
+ }
14782
+ if (!args.jobId)
14783
+ throw new Error(`Action "${action}" requires jobId.`);
14784
+ const job = jobManager.get(args.jobId);
14785
+ if (!job)
14786
+ throw new Error(`No job ${args.jobId} in the registry (finished jobs age out; the on-disk log may still exist).`);
14787
+ if (action === "poll") {
14788
+ const p = await pollJob(jobManager, args.jobId, args.waitMs ?? 0);
14789
+ if (!p)
14790
+ throw new Error(`No job ${args.jobId}.`);
14791
+ return {
14792
+ title: `${job.id}: ${p.state}`,
14793
+ output: [
14794
+ `[forge:job] ${job.id}: ${p.state}${p.exitCode !== null ? ` exit=${p.exitCode}` : ""}${p.succeeded ? " (success pattern matched)" : ""}`,
14795
+ `new output:
14796
+ ${p.newOutput || "(none in this window)"}`,
14797
+ `logPath: ${p.logPath}`
14798
+ ].join(`
14799
+ `)
14800
+ };
14801
+ }
14802
+ if (action === "log") {
14803
+ const page = readJobLog(job.logPath, {
14804
+ ...args.offset !== undefined ? { offset: args.offset } : {},
14805
+ ...args.limit !== undefined ? { limit: args.limit } : {}
14806
+ });
14807
+ return {
14808
+ title: `${job.id} log ${page.offset}-${page.offset + page.lines.length}/${page.total}`,
14809
+ output: page.lines.length > 0 ? page.lines.join(`
14810
+ `) : "(empty)"
14811
+ };
14812
+ }
14813
+ if (action === "kill") {
14814
+ jobManager.kill(job);
14815
+ return { title: `${job.id} killed`, output: `[forge:job] ${job.id}: tree kill issued (state: killed).` };
14816
+ }
14817
+ if (action === "clear") {
14818
+ const ok = jobManager.clear(args.jobId);
14819
+ 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.` };
14820
+ }
14821
+ const target = await handoffTarget(context.sessionID);
14822
+ jobManager.handoff(args.jobId, target);
14823
+ return {
14824
+ title: `${job.id} handed off`,
14825
+ output: `[forge:job] ${job.id} promoted to plugin-global scope${target ? ` and rebound to root session ${target}` : ""}; it now survives this session's end.`
14826
+ };
14827
+ }
14828
+ });
13799
14829
  function forgeTools() {
13800
- return {
14830
+ const tools = {
13801
14831
  plan_write: planWriteTool,
13802
14832
  plan_tick: planTickTool,
13803
14833
  plan_approve: planApproveTool,
@@ -13810,6 +14840,11 @@ function forgeTools() {
13810
14840
  goal_resume: goalResumeTool,
13811
14841
  goal_discard: goalDiscardTool
13812
14842
  };
14843
+ if (jobStage() < 2) {
14844
+ tools.forge_shell = forgeShellTool;
14845
+ tools.forge_jobs = forgeJobsTool;
14846
+ }
14847
+ return tools;
13813
14848
  }
13814
14849
  var hostWorktree = "";
13815
14850
  function stateForBan(sessionID) {
@@ -13832,7 +14867,7 @@ var pendingContinuationTurn = new Set;
13832
14867
  function goalProbe(line) {
13833
14868
  if (process.env.FORGE_GOAL_PROBE) {
13834
14869
  try {
13835
- appendFileSync(join2(tmpdir(), "forge-goal-probe.log"), `${new Date().toISOString()} ${line}
14870
+ appendFileSync2(join3(tmpdir2(), "forge-goal-probe.log"), `${new Date().toISOString()} ${line}
13836
14871
  `);
13837
14872
  } catch {}
13838
14873
  }
@@ -13878,7 +14913,7 @@ function wrapupBriefText(goal, reason) {
13878
14913
  }
13879
14914
  async function autoPauseGoal(client, state, goal, reason, wrapup) {
13880
14915
  goalProbe(`auto-pause session=${state.sessionID} reason=${reason} wrapup=${wrapup}`);
13881
- atomicWrite(goal.path, transitionGoal(readFileSync2(goal.path, "utf8"), "paused", nowIso(), { stopReason: reason }));
14916
+ atomicWrite(goal.path, transitionGoal(readFileSync5(goal.path, "utf8"), "paused", nowIso(), { stopReason: reason }));
13882
14917
  engineForgetSession(state.sessionID);
13883
14918
  if (wrapup && goal.doc.session) {
13884
14919
  try {
@@ -13925,11 +14960,11 @@ async function continueIfEligible(client, sessionID) {
13925
14960
  turnHadActivity = !!act && (act.writes > 0 || act.checks > 0);
13926
14961
  turnActivity.set(sessionID, { writes: 0, checks: 0 });
13927
14962
  const ledgerPath = state.goalPath;
13928
- if (ledgerPath && existsSync(ledgerPath)) {
14963
+ if (ledgerPath && existsSync2(ledgerPath)) {
13929
14964
  try {
13930
- const fresh = parseGoalLoose(readFileSync2(ledgerPath, "utf8"));
14965
+ const fresh = parseGoalLoose(readFileSync5(ledgerPath, "utf8"));
13931
14966
  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()));
14967
+ atomicWrite(ledgerPath, appendLedger(readFileSync5(ledgerPath, "utf8"), { turn: fresh.turnsUsed, revision: fresh.revision, at: nowIso(), activity: turnHadActivity, writes: act?.writes ?? 0, checks: act?.checks ?? 0 }, nowIso()));
13933
14968
  }
13934
14969
  } catch (err) {
13935
14970
  goalProbe(`ledger append failed session=${sessionID} err=${String(err)}`);
@@ -13990,7 +15025,7 @@ async function continueIfEligible(client, sessionID) {
13990
15025
  pendingContinuationTurn.add(sessionID);
13991
15026
  turnActivity.set(sessionID, { writes: 0, checks: 0 });
13992
15027
  state.goalPath = goal.path;
13993
- atomicWrite(goal.path, incTurns(readFileSync2(goal.path, "utf8"), nowIso()));
15028
+ atomicWrite(goal.path, incTurns(readFileSync5(goal.path, "utf8"), nowIso()));
13994
15029
  goalProbe(`continued session=${sessionID} turn=${goal.doc.turnsUsed + 1}/${goal.doc.maxTurns}`);
13995
15030
  } catch (err) {
13996
15031
  const n = (transportFails.get(sessionID) ?? 0) + 1;
@@ -14017,12 +15052,79 @@ function scheduleIdleContinuation(client, sessionID) {
14017
15052
  continueIfEligible(client, sessionID);
14018
15053
  }, IDLE_DEBOUNCE_MS));
14019
15054
  }
14020
- var server = async (input) => {
15055
+ var server = async (input, options) => {
14021
15056
  hostWorktree = effectiveWorktree(input.worktree, input.directory) || input.directory || "";
14022
15057
  const client = input.client;
15058
+ jobClient = input.client;
15059
+ const jobsOpts = options?.jobs;
15060
+ if (jobsOpts?.mode === "auto" || jobsOpts?.mode === "forge" || jobsOpts?.mode === "native")
15061
+ jobsMode = jobsOpts.mode;
15062
+ jobsKeepBuiltinShell = jobsOpts?.keepBuiltinShell === true;
15063
+ const wdOpts = options?.watchdog;
15064
+ const wdFallbacks = [];
15065
+ const wdMode = parseMode(wdOpts?.mode);
15066
+ if (wdOpts?.mode !== undefined && wdOpts.mode !== wdMode) {
15067
+ wdFallbacks.push(`invalid watchdog.mode ${JSON.stringify(String(wdOpts.mode))} — fell back to "${wdMode}"`);
15068
+ }
15069
+ const wdStallRaw = wdOpts?.stallMs;
15070
+ const wdStall = clampStallMs(typeof wdStallRaw === "number" ? wdStallRaw : undefined);
15071
+ if (wdStallRaw !== undefined && wdStallRaw !== wdStall) {
15072
+ wdFallbacks.push(`watchdog.stallMs ${JSON.stringify(String(wdStallRaw))} adjusted to ${wdStall} (floor/default applied)`);
15073
+ }
15074
+ const watchdogLedger = createFileLedger(join3(watchdogLogDir(), "log.jsonl"));
15075
+ const rawLocator = createLocator();
15076
+ const probing = () => process.env.FORGE_WATCHDOG_PROBE === "1";
15077
+ const probeLine = (text) => {
15078
+ if (!probing())
15079
+ return;
15080
+ try {
15081
+ appendFileSync2(join3(tmpdir2(), "forge-watchdog-probe.log"), `${new Date().toISOString()} ${text}
15082
+ `);
15083
+ } catch {}
15084
+ };
15085
+ const diagnosticLocate = async (callID, t0, cmdNeedle, phase2) => {
15086
+ const started = Date.now();
15087
+ const hits = await rawLocator(callID, t0, cmdNeedle, phase2);
15088
+ probeLine(`locate dur=${Date.now() - started}ms hits=${hits.length} phase2=${phase2 === true} needle=${JSON.stringify(cmdNeedle ?? null)}`);
15089
+ if (hits.length === 0 && cmdNeedle) {
15090
+ try {
15091
+ const raw = execFileSync2("powershell", ["-NoProfile", "-Command", "[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CommandLine,CreationDate | ConvertTo-Json -Compress"], { encoding: "utf8", windowsHide: true, timeout: 15000 });
15092
+ const all = parseWindowsProcs(raw);
15093
+ probeLine(`diag rawLen=${raw.length} procs=${all.length} windowStart=${new Date(t0 - 2000).toISOString()}`);
15094
+ for (const p of all) {
15095
+ if (p.cmd.includes(cmdNeedle))
15096
+ probeLine(`diag needle-carrier pid=${p.pid} ppid=${p.ppid} created=${new Date(p.createdMs).toISOString()} cmd=${p.cmd.slice(0, 120)}`);
15097
+ }
15098
+ } catch (err) {
15099
+ probeLine(`diag failed: ${String(err).slice(0, 150)}`);
15100
+ }
15101
+ }
15102
+ return hits;
15103
+ };
15104
+ const watchdog = createWatchdog({
15105
+ mode: wdMode,
15106
+ stallMs: wdStall,
15107
+ sink: (entry) => watchdogLedger.append(entry),
15108
+ locate: diagnosticLocate,
15109
+ killTree: (pid) => killTree({ pid, kill: (sig) => process.kill(pid, sig) })
15110
+ });
15111
+ for (const reason of wdFallbacks) {
15112
+ const entry = {
15113
+ ts: new Date().toISOString(),
15114
+ event: "config-fallback",
15115
+ callID: "-",
15116
+ tool: "config",
15117
+ t0: Date.now(),
15118
+ mode: watchdog.mode,
15119
+ reason
15120
+ };
15121
+ watchdogLedger.append(entry);
15122
+ }
14023
15123
  return {
14024
15124
  dispose: async () => {
14025
15125
  engineForgetAll();
15126
+ jobManager.disposeAll();
15127
+ watchdog.dispose();
14026
15128
  sessions.clear();
14027
15129
  },
14028
15130
  config: async (cfg) => {
@@ -14035,12 +15137,22 @@ var server = async (input) => {
14035
15137
  agentSection[native] = { ...agentSection[native] ?? {}, disable: true };
14036
15138
  }
14037
15139
  const existing = agentSection[FORGE_AGENT];
15140
+ const userDefinedForge = existing !== undefined;
14038
15141
  agentSection[FORGE_AGENT] = {
14039
15142
  ...existing ?? {},
14040
15143
  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
15144
  mode: existing?.mode ?? "primary",
14042
15145
  prompt: existing?.prompt ?? FORGE_PROMPT
14043
15146
  };
15147
+ try {
15148
+ const exp = JSON.stringify(cfg.experimental ?? "");
15149
+ if (/background/i.test(exp))
15150
+ nativeBackgroundSeen = true;
15151
+ } catch {}
15152
+ if (!userDefinedForge && !jobsKeepBuiltinShell && jobStage() < 1) {
15153
+ const entry = agentSection[FORGE_AGENT];
15154
+ entry.tools = { ...entry.tools ?? {}, shell: false, bash: false };
15155
+ }
14044
15156
  cfg.command ??= {};
14045
15157
  cfg.command["plan"] ??= {
14046
15158
  template: PLAN_COMMAND_TEMPLATE,
@@ -14052,7 +15164,7 @@ var server = async (input) => {
14052
15164
  };
14053
15165
  const perm = cfg.permission;
14054
15166
  const permSection = perm ?? (cfg.permission = {});
14055
- for (const gateKey of ["plan_approve", "plan_close", "goal_write", "goal_complete", "goal_resume", "goal_discard"]) {
15167
+ for (const gateKey of ["plan_approve", "plan_close", "goal_write", "goal_complete", "goal_resume", "goal_discard", "forge_shell"]) {
14056
15168
  if (permSection[gateKey] !== "deny")
14057
15169
  permSection[gateKey] = "ask";
14058
15170
  }
@@ -14060,13 +15172,24 @@ var server = async (input) => {
14060
15172
  get tool() {
14061
15173
  return forgeDisabled ? {} : forgeTools();
14062
15174
  },
14063
- "tool.execute.before": async (input2) => {
15175
+ "tool.execute.before": async (input2, output) => {
14064
15176
  if (process.env.FORGE_PERM_PROBE) {
14065
15177
  try {
14066
- appendFileSync(join2(tmpdir(), "forge-perm-probe.log"), `${new Date().toISOString()} before tool=${JSON.stringify(input2.tool)} session=${input2.sessionID}
15178
+ appendFileSync2(join3(tmpdir2(), "forge-perm-probe.log"), `${new Date().toISOString()} before tool=${JSON.stringify(input2.tool)} session=${input2.sessionID}
14067
15179
  `);
14068
15180
  } catch {}
14069
15181
  }
15182
+ if (watchdog.mode !== "off" && (input2.tool === "shell" || input2.tool === "bash")) {
15183
+ const a = output?.args ?? {};
15184
+ const cmdText = typeof a.command === "string" ? a.command : typeof a.cmd === "string" ? a.cmd : undefined;
15185
+ watchdog.track(input2.callID, input2.sessionID, input2.tool, undefined, cmdText !== undefined ? commandNeedle(cmdText) : undefined);
15186
+ if (process.env.FORGE_WATCHDOG_PROBE) {
15187
+ try {
15188
+ appendFileSync2(join3(tmpdir2(), "forge-watchdog-probe.log"), `${new Date().toISOString()} track ${input2.callID} needle=${JSON.stringify(cmdText !== undefined ? commandNeedle(cmdText) : undefined)} rawArgs=${JSON.stringify(output?.args ?? null).slice(0, 200)}
15189
+ `);
15190
+ } catch {}
15191
+ }
15192
+ }
14070
15193
  if (typeof input2.tool === "string" && isWriteTool(input2.tool)) {
14071
15194
  const state = stateForBan(input2.sessionID);
14072
15195
  const active = state ? resolveActivePlan(state) : null;
@@ -14081,11 +15204,11 @@ var server = async (input) => {
14081
15204
  const name = (typeof meta.tool === "string" ? meta.tool : undefined) ?? (typeof permissionField === "string" ? permissionField : undefined) ?? input2.id ?? input2.type;
14082
15205
  if (process.env.FORGE_PERM_PROBE) {
14083
15206
  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)}
15207
+ appendFileSync2(join3(tmpdir2(), "forge-perm-probe.log"), `${new Date().toISOString()} ask name=${JSON.stringify(name)} in_status=${output.status} id=${JSON.stringify(input2.id)} type=${JSON.stringify(input2.type)} meta=${JSON.stringify(input2.metadata)}
14085
15208
  `);
14086
15209
  } catch {}
14087
15210
  }
14088
- if (name === "plan_approve" || name === "plan_close" || name === "goal_write" || name === "goal_complete" || name === "goal_resume" || name === "goal_discard") {
15211
+ if (name === "plan_approve" || name === "plan_close" || name === "goal_write" || name === "goal_complete" || name === "goal_resume" || name === "goal_discard" || name === "forge_shell") {
14089
15212
  if (output.status !== "deny")
14090
15213
  output.status = "ask";
14091
15214
  return;
@@ -14111,10 +15234,40 @@ var server = async (input) => {
14111
15234
  if (typeof sessionID === "string" && sessionID) {
14112
15235
  goalProbe(`idle event session=${sessionID}`);
14113
15236
  scheduleIdleContinuation(client, sessionID);
15237
+ deliverJobWakes(sessionID);
15238
+ }
15239
+ }
15240
+ if (event.type === "session.deleted") {
15241
+ const info = event.properties.info;
15242
+ if (typeof info?.id === "string" && info.id) {
15243
+ jobManager.onSessionEnd(info.id);
14114
15244
  }
14115
15245
  }
14116
15246
  },
15247
+ "shell.env": async (input2, output) => {
15248
+ if (watchdog.mode === "off")
15249
+ return;
15250
+ if (!input2.callID)
15251
+ return;
15252
+ output.env[WATCHDOG_ENV_MARK] = markerValue(input2.callID);
15253
+ watchdog.markSeen(input2.callID);
15254
+ if (process.env.FORGE_WATCHDOG_PROBE) {
15255
+ try {
15256
+ appendFileSync2(join3(tmpdir2(), "forge-watchdog-probe.log"), `${new Date().toISOString()} mark ${input2.callID} hostpid=${process.pid}
15257
+ `);
15258
+ } catch {}
15259
+ }
15260
+ },
14117
15261
  "tool.execute.after": async (input2) => {
15262
+ if ((input2.tool === "shell" || input2.tool === "bash") && watchdog.has(input2.callID)) {
15263
+ watchdog.untrack(input2.callID);
15264
+ if (process.env.FORGE_WATCHDOG_PROBE) {
15265
+ try {
15266
+ appendFileSync2(join3(tmpdir2(), "forge-watchdog-probe.log"), `${new Date().toISOString()} untrack ${input2.callID}
15267
+ `);
15268
+ } catch {}
15269
+ }
15270
+ }
14118
15271
  if (typeof input2.tool !== "string")
14119
15272
  return;
14120
15273
  const act = turnActivity.get(input2.sessionID);
@@ -14125,9 +15278,24 @@ var server = async (input) => {
14125
15278
  if (input2.tool === "goal_check")
14126
15279
  act.checks++;
14127
15280
  },
15281
+ "tool.definition": async (input2, output) => {
15282
+ if (input2.toolID === "shell" || input2.toolID === "bash") {
15283
+ const props = output.parameters?.properties;
15284
+ if (props && "run_in_background" in props)
15285
+ nativeBackgroundSeen = true;
15286
+ } else if (input2.toolID === "forge_shell" && jobStage() === 1) {
15287
+ if (!output.description.startsWith(STAGE1_NOTE)) {
15288
+ output.description = `${STAGE1_NOTE}
15289
+ ${output.description}`;
15290
+ }
15291
+ }
15292
+ },
14128
15293
  "experimental.chat.system.transform": async (input2, output) => {
14129
15294
  if (!input2.sessionID)
14130
15295
  return;
15296
+ if (!forgeDisabled && jobStage() < 2 && !output.system.some((s) => s.startsWith("[forge:job-guidance]"))) {
15297
+ 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.");
15298
+ }
14131
15299
  const state = sessions.get(input2.sessionID);
14132
15300
  if (!state)
14133
15301
  return;
@@ -14135,7 +15303,7 @@ var server = async (input) => {
14135
15303
  if (active) {
14136
15304
  const p = progressOf(active.doc);
14137
15305
  const rel = relFrom(state.worktree, active.path);
14138
- const rule = active.doc.status === "draft" ? "while in draft, write operations are denied at the tool layer; present the summary then call plan_approve for approval, or /plan discard to abandon" : "call plan_tick immediately after each completed task; when all are done, self-check every acceptance criterion and call plan_close";
15306
+ const rule = active.doc.status === "draft" ? "while in draft, write operations are denied at the tool layer; present the plan then end your turn to await the user's review — on feedback revise via plan_write and re-present; call plan_approve only after the user explicitly approves in chat (their confirmation dialog is the final gate); /plan discard to abandon" : "call plan_tick immediately after each completed task; when all are done, self-check every acceptance criterion and call plan_close";
14139
15307
  output.system.push(`[forge:plan-notice] This session is bound to a plan: ${rel} (status: ${active.doc.status}, ${p.done}/${p.total} tasks done). Rule: ${rule}. If the user has not mentioned this plan yet, relay its path and progress to them in one short line at the start of your reply.`);
14140
15308
  }
14141
15309
  if (forgeDisabled)