@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.
- package/README.md +155 -4
- package/dist/index.js +1240 -72
- 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 {
|
|
12338
|
-
import {
|
|
12339
|
-
import {
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
14191
|
+
return join3(worktree, ".opencode", "plan");
|
|
13325
14192
|
}
|
|
13326
14193
|
function readPlanDir(worktree) {
|
|
13327
14194
|
const dir = planDirOf(worktree);
|
|
13328
|
-
if (!
|
|
14195
|
+
if (!existsSync2(dir))
|
|
13329
14196
|
return [];
|
|
13330
|
-
return
|
|
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 &&
|
|
13347
|
-
const doc2 = parsePlanLoose(
|
|
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 =
|
|
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
|
-
|
|
13390
|
-
path =
|
|
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
|
-
|
|
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(
|
|
13423
|
-
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
14375
|
+
return join3(worktree, ".opencode", "goal");
|
|
13509
14376
|
}
|
|
13510
14377
|
function readGoalDir(worktree) {
|
|
13511
14378
|
const dir = goalDirOf(worktree);
|
|
13512
|
-
if (!
|
|
14379
|
+
if (!existsSync2(dir))
|
|
13513
14380
|
return [];
|
|
13514
|
-
return
|
|
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 &&
|
|
13518
|
-
const doc2 = parseGoalLoose(
|
|
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 =
|
|
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
|
-
|
|
13623
|
-
const name = goalFileName(localDateNow(), slugifyGoal(args.goal),
|
|
13624
|
-
const path =
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 &&
|
|
14963
|
+
if (ledgerPath && existsSync2(ledgerPath)) {
|
|
13929
14964
|
try {
|
|
13930
|
-
const fresh = parseGoalLoose(
|
|
14965
|
+
const fresh = parseGoalLoose(readFileSync5(ledgerPath, "utf8"));
|
|
13931
14966
|
if (fresh && fresh.turnsUsed > 0) {
|
|
13932
|
-
atomicWrite(ledgerPath, appendLedger(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)
|