@sorenllm/opencode-forge 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +419 -223
- package/dist/index.js +1971 -68
- package/package.json +61 -58
package/dist/index.js
CHANGED
|
@@ -12334,9 +12334,10 @@ function tool(input) {
|
|
|
12334
12334
|
}
|
|
12335
12335
|
tool.schema = exports_external;
|
|
12336
12336
|
// plugin.ts
|
|
12337
|
-
import {
|
|
12338
|
-
import {
|
|
12339
|
-
import {
|
|
12337
|
+
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
12338
|
+
import { appendFileSync, existsSync as existsSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync4, readFileSync as readFileSync6, statSync as statSync3, writeFileSync as writeFileSync4 } from "node:fs";
|
|
12339
|
+
import { isAbsolute, join as join4, relative } from "node:path";
|
|
12340
|
+
import { tmpdir as tmpdir2 } from "node:os";
|
|
12340
12341
|
|
|
12341
12342
|
// src/plan-file.ts
|
|
12342
12343
|
class PlanError extends Error {
|
|
@@ -13115,14 +13116,98 @@ function completeCheckFailures(doc2, attestations) {
|
|
|
13115
13116
|
}
|
|
13116
13117
|
|
|
13117
13118
|
// src/run-check.ts
|
|
13118
|
-
import { spawn } from "node:child_process";
|
|
13119
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
13119
13120
|
import { readFileSync } from "node:fs";
|
|
13120
13121
|
import { join, resolve, sep } from "node:path";
|
|
13122
|
+
|
|
13123
|
+
// src/proc.ts
|
|
13124
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
13125
|
+
function shellSpawn(spawnFn, cmd, opts = {}) {
|
|
13126
|
+
return spawnFn(cmd, {
|
|
13127
|
+
shell: true,
|
|
13128
|
+
...opts.cwd !== undefined ? { cwd: opts.cwd } : {},
|
|
13129
|
+
env: opts.env ? { ...process.env, ...opts.env } : process.env,
|
|
13130
|
+
windowsHide: opts.windowsHide ?? true,
|
|
13131
|
+
detached: opts.detached ?? process.platform !== "win32",
|
|
13132
|
+
...opts.stdio !== undefined ? { stdio: opts.stdio } : {}
|
|
13133
|
+
});
|
|
13134
|
+
}
|
|
13135
|
+
function treeKillPlan(platform, pid, force = true) {
|
|
13136
|
+
if (platform === "win32")
|
|
13137
|
+
return { kind: "taskkill", args: force ? ["/pid", String(pid), "/F", "/T"] : ["/pid", String(pid), "/T"] };
|
|
13138
|
+
return { kind: "group", signal: force ? "SIGKILL" : "SIGTERM" };
|
|
13139
|
+
}
|
|
13140
|
+
function killTree(child, opts = {}) {
|
|
13141
|
+
const platform = opts.platform ?? process.platform;
|
|
13142
|
+
if (!child.pid) {
|
|
13143
|
+
child.kill();
|
|
13144
|
+
return;
|
|
13145
|
+
}
|
|
13146
|
+
const plan = treeKillPlan(platform, child.pid);
|
|
13147
|
+
if (plan.kind === "taskkill") {
|
|
13148
|
+
try {
|
|
13149
|
+
(opts.spawnFn ?? spawn)("taskkill", plan.args, { windowsHide: true, stdio: "ignore" });
|
|
13150
|
+
} catch {
|
|
13151
|
+
child.kill();
|
|
13152
|
+
}
|
|
13153
|
+
} else {
|
|
13154
|
+
try {
|
|
13155
|
+
process.kill(-child.pid, plan.signal);
|
|
13156
|
+
} catch {
|
|
13157
|
+
child.kill(plan.signal);
|
|
13158
|
+
}
|
|
13159
|
+
}
|
|
13160
|
+
}
|
|
13161
|
+
function pidAlive(pid) {
|
|
13162
|
+
if (!pid || pid <= 0)
|
|
13163
|
+
return false;
|
|
13164
|
+
try {
|
|
13165
|
+
process.kill(pid, 0);
|
|
13166
|
+
return true;
|
|
13167
|
+
} catch (err) {
|
|
13168
|
+
return err.code === "EPERM";
|
|
13169
|
+
}
|
|
13170
|
+
}
|
|
13171
|
+
function terminateTreeSync(pid, opts = {}) {
|
|
13172
|
+
const graceMs = Math.max(0, opts.graceMs ?? 0);
|
|
13173
|
+
const platform = opts.platform ?? process.platform;
|
|
13174
|
+
const sync = opts.spawnSyncFn ?? spawnSync;
|
|
13175
|
+
const wait = opts.wait ?? true;
|
|
13176
|
+
const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
13177
|
+
const run = (force) => {
|
|
13178
|
+
const plan = treeKillPlan(platform, pid, force);
|
|
13179
|
+
if (plan.kind === "taskkill") {
|
|
13180
|
+
try {
|
|
13181
|
+
sync("taskkill", plan.args, { windowsHide: true, stdio: "ignore", timeout: 1e4 });
|
|
13182
|
+
} catch {}
|
|
13183
|
+
} else {
|
|
13184
|
+
try {
|
|
13185
|
+
process.kill(-pid, plan.signal);
|
|
13186
|
+
} catch {
|
|
13187
|
+
try {
|
|
13188
|
+
process.kill(pid, plan.signal);
|
|
13189
|
+
} catch {}
|
|
13190
|
+
}
|
|
13191
|
+
}
|
|
13192
|
+
};
|
|
13193
|
+
if (graceMs > 0) {
|
|
13194
|
+
run(false);
|
|
13195
|
+
if (wait) {
|
|
13196
|
+
const deadline = Date.now() + graceMs;
|
|
13197
|
+
while (Date.now() < deadline && pidAlive(pid))
|
|
13198
|
+
sleep(Math.min(100, deadline - Date.now()));
|
|
13199
|
+
}
|
|
13200
|
+
}
|
|
13201
|
+
run(true);
|
|
13202
|
+
return { graceful: graceMs > 0, forced: true };
|
|
13203
|
+
}
|
|
13204
|
+
|
|
13205
|
+
// src/run-check.ts
|
|
13121
13206
|
var OUTPUT_LIMIT = 2048;
|
|
13122
13207
|
var defaultShellRunner = (cmd, opts) => new Promise((resolveRun) => {
|
|
13123
13208
|
let child;
|
|
13124
13209
|
try {
|
|
13125
|
-
child =
|
|
13210
|
+
child = spawn2(cmd, {
|
|
13126
13211
|
shell: true,
|
|
13127
13212
|
cwd: opts.cwd,
|
|
13128
13213
|
windowsHide: true,
|
|
@@ -13135,28 +13220,9 @@ var defaultShellRunner = (cmd, opts) => new Promise((resolveRun) => {
|
|
|
13135
13220
|
let output = "";
|
|
13136
13221
|
let timedOut = false;
|
|
13137
13222
|
let settled = false;
|
|
13138
|
-
const killTree = () => {
|
|
13139
|
-
if (!child.pid) {
|
|
13140
|
-
child.kill();
|
|
13141
|
-
return;
|
|
13142
|
-
}
|
|
13143
|
-
if (process.platform === "win32") {
|
|
13144
|
-
try {
|
|
13145
|
-
spawn("taskkill", ["/pid", String(child.pid), "/F", "/T"], { windowsHide: true, stdio: "ignore" });
|
|
13146
|
-
} catch {
|
|
13147
|
-
child.kill();
|
|
13148
|
-
}
|
|
13149
|
-
} else {
|
|
13150
|
-
try {
|
|
13151
|
-
process.kill(-child.pid, "SIGKILL");
|
|
13152
|
-
} catch {
|
|
13153
|
-
child.kill("SIGKILL");
|
|
13154
|
-
}
|
|
13155
|
-
}
|
|
13156
|
-
};
|
|
13157
13223
|
const timer = setTimeout(() => {
|
|
13158
13224
|
timedOut = true;
|
|
13159
|
-
killTree();
|
|
13225
|
+
killTree(child);
|
|
13160
13226
|
}, opts.timeoutMs);
|
|
13161
13227
|
const failsafe = setTimeout(() => settle({ code: null, output, timedOut: true, spawnError: "timeout settle fallback" }), opts.timeoutMs + 30000);
|
|
13162
13228
|
failsafe.unref?.();
|
|
@@ -13249,6 +13315,1414 @@ function formatOutcomes(outcomes) {
|
|
|
13249
13315
|
`);
|
|
13250
13316
|
}
|
|
13251
13317
|
|
|
13318
|
+
// src/job-manager.ts
|
|
13319
|
+
var DEFAULT_MAX_FINISHED_JOBS = 50;
|
|
13320
|
+
var DEFAULT_MAX_TAIL_CHARS = 8192;
|
|
13321
|
+
var DEFAULT_MAX_REGISTRY_CHARS = 524288;
|
|
13322
|
+
var DEFAULT_WAKE_WINDOW_MS = 600000;
|
|
13323
|
+
var DEFAULT_MAX_LEDGER_ENTRIES = 200;
|
|
13324
|
+
function iso(now) {
|
|
13325
|
+
return new Date(now()).toISOString();
|
|
13326
|
+
}
|
|
13327
|
+
function createJobManager(opts = {}) {
|
|
13328
|
+
const now = opts.now ?? Date.now;
|
|
13329
|
+
const maxFinishedJobs = opts.maxFinishedJobs ?? DEFAULT_MAX_FINISHED_JOBS;
|
|
13330
|
+
const maxTailChars = opts.maxTailChars ?? DEFAULT_MAX_TAIL_CHARS;
|
|
13331
|
+
const maxRegistryChars = opts.maxRegistryChars ?? DEFAULT_MAX_REGISTRY_CHARS;
|
|
13332
|
+
const wakeWindowMs = opts.wakeWindowMs ?? DEFAULT_WAKE_WINDOW_MS;
|
|
13333
|
+
const maxLedgerEntries = opts.maxLedgerEntries ?? DEFAULT_MAX_LEDGER_ENTRIES;
|
|
13334
|
+
const jobs = new Map;
|
|
13335
|
+
const ledger = [];
|
|
13336
|
+
function emit(kind, job, detail) {
|
|
13337
|
+
const entry = { at: iso(now), kind, jobId: job.id, session: job.ownerSession, detail };
|
|
13338
|
+
ledger.push(entry);
|
|
13339
|
+
if (ledger.length > maxLedgerEntries)
|
|
13340
|
+
ledger.splice(0, ledger.length - maxLedgerEntries);
|
|
13341
|
+
try {
|
|
13342
|
+
opts.sink?.(entry);
|
|
13343
|
+
} catch {}
|
|
13344
|
+
}
|
|
13345
|
+
function totalRegistryChars() {
|
|
13346
|
+
let n = 0;
|
|
13347
|
+
for (const j of jobs.values())
|
|
13348
|
+
n += j.tail.length;
|
|
13349
|
+
return n;
|
|
13350
|
+
}
|
|
13351
|
+
function isTerminal2(job) {
|
|
13352
|
+
return job.state !== "running";
|
|
13353
|
+
}
|
|
13354
|
+
function enforceCaps() {
|
|
13355
|
+
const finished = [...jobs.values()].filter(isTerminal2).sort((a, b) => (a.endedAt ?? a.startedAt) - (b.endedAt ?? b.startedAt));
|
|
13356
|
+
let over = jobs.size - (maxFinishedJobs + countRunning());
|
|
13357
|
+
let chars = totalRegistryChars();
|
|
13358
|
+
for (const j of finished) {
|
|
13359
|
+
if (over <= 0 && chars <= maxRegistryChars)
|
|
13360
|
+
break;
|
|
13361
|
+
emit("evicted", j, `evicted from registry (state ${j.state})`);
|
|
13362
|
+
jobs.delete(j.id);
|
|
13363
|
+
over--;
|
|
13364
|
+
chars -= j.tail.length;
|
|
13365
|
+
}
|
|
13366
|
+
}
|
|
13367
|
+
function countRunning() {
|
|
13368
|
+
let n = 0;
|
|
13369
|
+
for (const j of jobs.values())
|
|
13370
|
+
if (!isTerminal2(j))
|
|
13371
|
+
n++;
|
|
13372
|
+
return n;
|
|
13373
|
+
}
|
|
13374
|
+
function create(input) {
|
|
13375
|
+
const t = now();
|
|
13376
|
+
const job = {
|
|
13377
|
+
...input,
|
|
13378
|
+
scope: "session",
|
|
13379
|
+
state: "running",
|
|
13380
|
+
exitCode: null,
|
|
13381
|
+
startedAt: t,
|
|
13382
|
+
endedAt: null,
|
|
13383
|
+
lastOutputAt: t,
|
|
13384
|
+
tail: "",
|
|
13385
|
+
outLen: 0,
|
|
13386
|
+
pollCursor: 0,
|
|
13387
|
+
readAfterEnd: false,
|
|
13388
|
+
succeededAt: null,
|
|
13389
|
+
wakeState: "none",
|
|
13390
|
+
wakeQueuedAt: null
|
|
13391
|
+
};
|
|
13392
|
+
jobs.set(job.id, job);
|
|
13393
|
+
enforceCaps();
|
|
13394
|
+
return job;
|
|
13395
|
+
}
|
|
13396
|
+
function setPid(job, pid) {
|
|
13397
|
+
job.pid = pid;
|
|
13398
|
+
}
|
|
13399
|
+
function get(id) {
|
|
13400
|
+
return jobs.get(id);
|
|
13401
|
+
}
|
|
13402
|
+
function list() {
|
|
13403
|
+
return [...jobs.values()].sort((a, b) => b.startedAt - a.startedAt);
|
|
13404
|
+
}
|
|
13405
|
+
function appendOutput(job, chunk) {
|
|
13406
|
+
if (!chunk)
|
|
13407
|
+
return;
|
|
13408
|
+
job.outLen += chunk.length;
|
|
13409
|
+
job.lastOutputAt = now();
|
|
13410
|
+
const next = job.tail + chunk;
|
|
13411
|
+
job.tail = next.length > maxTailChars ? next.slice(next.length - maxTailChars) : next;
|
|
13412
|
+
}
|
|
13413
|
+
function markTerminal(job, state, exitCode) {
|
|
13414
|
+
if (isTerminal2(job))
|
|
13415
|
+
return;
|
|
13416
|
+
job.state = state;
|
|
13417
|
+
job.exitCode = exitCode;
|
|
13418
|
+
job.endedAt = now();
|
|
13419
|
+
if (job.notify && job.wakeState === "none") {
|
|
13420
|
+
job.wakeState = "queued";
|
|
13421
|
+
job.wakeQueuedAt = now();
|
|
13422
|
+
}
|
|
13423
|
+
enforceCaps();
|
|
13424
|
+
}
|
|
13425
|
+
function kill(job) {
|
|
13426
|
+
if (isTerminal2(job))
|
|
13427
|
+
return;
|
|
13428
|
+
try {
|
|
13429
|
+
job.killTree();
|
|
13430
|
+
} catch {}
|
|
13431
|
+
markTerminal(job, "killed", null);
|
|
13432
|
+
}
|
|
13433
|
+
function poll(job) {
|
|
13434
|
+
if (isTerminal2(job))
|
|
13435
|
+
job.readAfterEnd = true;
|
|
13436
|
+
const cursor = job.outLen;
|
|
13437
|
+
const start = job.pollCursor;
|
|
13438
|
+
const newOutput = sliceFromCursor(job, start);
|
|
13439
|
+
job.pollCursor = cursor;
|
|
13440
|
+
return {
|
|
13441
|
+
state: job.state,
|
|
13442
|
+
exitCode: job.exitCode,
|
|
13443
|
+
newOutput,
|
|
13444
|
+
cursor,
|
|
13445
|
+
succeeded: job.succeededAt !== null,
|
|
13446
|
+
logPath: job.logPath
|
|
13447
|
+
};
|
|
13448
|
+
}
|
|
13449
|
+
function sliceFromCursor(job, cursor) {
|
|
13450
|
+
const startOffset = Math.max(0, cursor - (job.outLen - job.tail.length));
|
|
13451
|
+
return job.tail.slice(startOffset);
|
|
13452
|
+
}
|
|
13453
|
+
function clear(id) {
|
|
13454
|
+
const job = jobs.get(id);
|
|
13455
|
+
if (!job || !isTerminal2(job))
|
|
13456
|
+
return false;
|
|
13457
|
+
jobs.delete(id);
|
|
13458
|
+
return true;
|
|
13459
|
+
}
|
|
13460
|
+
function handoff(id, toSession) {
|
|
13461
|
+
const job = jobs.get(id);
|
|
13462
|
+
if (!job)
|
|
13463
|
+
return;
|
|
13464
|
+
job.scope = "global";
|
|
13465
|
+
if (toSession)
|
|
13466
|
+
job.ownerSession = toSession;
|
|
13467
|
+
return job;
|
|
13468
|
+
}
|
|
13469
|
+
function onSessionEnd(sessionID) {
|
|
13470
|
+
for (const job of [...jobs.values()]) {
|
|
13471
|
+
if (job.ownerSession !== sessionID)
|
|
13472
|
+
continue;
|
|
13473
|
+
if (job.survive)
|
|
13474
|
+
continue;
|
|
13475
|
+
if (!isTerminal2(job)) {
|
|
13476
|
+
if (job.scope === "global")
|
|
13477
|
+
continue;
|
|
13478
|
+
kill(job);
|
|
13479
|
+
emit("orphan-job", job, `owner session ended; tree killed (was: ${job.cmd.slice(0, 120)})`);
|
|
13480
|
+
} else if (!job.readAfterEnd) {
|
|
13481
|
+
emit("unread-completion", job, `owner session ended before the completion was read (state ${job.state}, exit ${job.exitCode})`);
|
|
13482
|
+
}
|
|
13483
|
+
jobs.delete(job.id);
|
|
13484
|
+
}
|
|
13485
|
+
}
|
|
13486
|
+
function disposeAll() {
|
|
13487
|
+
for (const job of [...jobs.values()]) {
|
|
13488
|
+
if (job.survive)
|
|
13489
|
+
continue;
|
|
13490
|
+
if (!isTerminal2(job)) {
|
|
13491
|
+
kill(job);
|
|
13492
|
+
emit("orphan-job", job, "plugin dispose; tree killed");
|
|
13493
|
+
}
|
|
13494
|
+
jobs.delete(job.id);
|
|
13495
|
+
}
|
|
13496
|
+
}
|
|
13497
|
+
function deliverWakesFor(sessionID) {
|
|
13498
|
+
abandonStaleWakes();
|
|
13499
|
+
const ready = [];
|
|
13500
|
+
for (const job of jobs.values()) {
|
|
13501
|
+
if (job.wakeState === "queued" && job.ownerSession === sessionID) {
|
|
13502
|
+
job.wakeState = "delivered";
|
|
13503
|
+
ready.push(job);
|
|
13504
|
+
}
|
|
13505
|
+
}
|
|
13506
|
+
return ready;
|
|
13507
|
+
}
|
|
13508
|
+
function abandonStaleWakes() {
|
|
13509
|
+
const t = now();
|
|
13510
|
+
for (const job of jobs.values()) {
|
|
13511
|
+
if (job.wakeState === "queued" && job.wakeQueuedAt !== null && t - job.wakeQueuedAt > wakeWindowMs) {
|
|
13512
|
+
job.wakeState = "abandoned";
|
|
13513
|
+
emit("wake-timeout", job, `session stayed busy past the ${wakeWindowMs}ms delivery window (state ${job.state}, exit ${job.exitCode})`);
|
|
13514
|
+
}
|
|
13515
|
+
}
|
|
13516
|
+
}
|
|
13517
|
+
function ledgerEntries() {
|
|
13518
|
+
return [...ledger];
|
|
13519
|
+
}
|
|
13520
|
+
return {
|
|
13521
|
+
create,
|
|
13522
|
+
setPid,
|
|
13523
|
+
get,
|
|
13524
|
+
list,
|
|
13525
|
+
appendOutput,
|
|
13526
|
+
markTerminal,
|
|
13527
|
+
kill,
|
|
13528
|
+
poll,
|
|
13529
|
+
clear,
|
|
13530
|
+
handoff,
|
|
13531
|
+
onSessionEnd,
|
|
13532
|
+
disposeAll,
|
|
13533
|
+
deliverWakesFor,
|
|
13534
|
+
abandonStaleWakes,
|
|
13535
|
+
ledgerEntries,
|
|
13536
|
+
size: () => jobs.size
|
|
13537
|
+
};
|
|
13538
|
+
}
|
|
13539
|
+
function newJobId(now = Date.now) {
|
|
13540
|
+
const d = new Date(now());
|
|
13541
|
+
const p = (n, w = 2) => String(n).padStart(w, "0");
|
|
13542
|
+
const rand = Math.random().toString(36).slice(2, 8);
|
|
13543
|
+
return `j-${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}-${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}-${rand}`;
|
|
13544
|
+
}
|
|
13545
|
+
|
|
13546
|
+
// src/job-runner.ts
|
|
13547
|
+
import { spawn as spawn3 } from "node:child_process";
|
|
13548
|
+
import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readSync, readFileSync as readFileSync2, statSync, unlinkSync } from "node:fs";
|
|
13549
|
+
import { join as join2 } from "node:path";
|
|
13550
|
+
import { tmpdir } from "node:os";
|
|
13551
|
+
var DEFAULT_IDLE_MS = 60000;
|
|
13552
|
+
var DEFAULT_MAX_WAIT_MS = 120000;
|
|
13553
|
+
var HARD_MAX_WAIT_MS = 600000;
|
|
13554
|
+
var POLL_WAIT_MAX_MS = 30000;
|
|
13555
|
+
var DEFAULT_LOG_KEEP = 50;
|
|
13556
|
+
var JOB_ENV_MARKER = "FORGE_JOB_ID";
|
|
13557
|
+
var TAIL_POLL_MS = 200;
|
|
13558
|
+
var ADOPT_LIVENESS_MS = 1000;
|
|
13559
|
+
function jobsLogDir(base) {
|
|
13560
|
+
return join2(base ?? join2(tmpdir(), "opencode-forge"), "jobs");
|
|
13561
|
+
}
|
|
13562
|
+
var TAIL_CHUNK_BYTES = 4 * 1024 * 1024;
|
|
13563
|
+
function createFileTail(logPath, onText, pollMs = TAIL_POLL_MS) {
|
|
13564
|
+
let pos = existsSync(logPath) ? statSync(logPath).size : 0;
|
|
13565
|
+
let stopped = false;
|
|
13566
|
+
const flush = () => {
|
|
13567
|
+
if (stopped)
|
|
13568
|
+
return;
|
|
13569
|
+
try {
|
|
13570
|
+
let size = existsSync(logPath) ? statSync(logPath).size : 0;
|
|
13571
|
+
if (size < pos)
|
|
13572
|
+
pos = size;
|
|
13573
|
+
const fd = openSync(logPath, "r");
|
|
13574
|
+
try {
|
|
13575
|
+
const buf = Buffer.allocUnsafe(TAIL_CHUNK_BYTES);
|
|
13576
|
+
while (pos < size) {
|
|
13577
|
+
const want = Math.min(TAIL_CHUNK_BYTES, size - pos);
|
|
13578
|
+
let read = 0;
|
|
13579
|
+
while (read < want) {
|
|
13580
|
+
const n = readSync(fd, buf, read, want - read, pos + read);
|
|
13581
|
+
if (n <= 0)
|
|
13582
|
+
break;
|
|
13583
|
+
read += n;
|
|
13584
|
+
}
|
|
13585
|
+
if (read <= 0)
|
|
13586
|
+
break;
|
|
13587
|
+
pos += read;
|
|
13588
|
+
onText(buf.toString("utf8", 0, read));
|
|
13589
|
+
size = existsSync(logPath) ? statSync(logPath).size : size;
|
|
13590
|
+
if (stopped)
|
|
13591
|
+
break;
|
|
13592
|
+
}
|
|
13593
|
+
} finally {
|
|
13594
|
+
closeSync(fd);
|
|
13595
|
+
}
|
|
13596
|
+
} catch {}
|
|
13597
|
+
};
|
|
13598
|
+
const timer = setInterval(flush, pollMs);
|
|
13599
|
+
timer.unref?.();
|
|
13600
|
+
return {
|
|
13601
|
+
flush,
|
|
13602
|
+
stop() {
|
|
13603
|
+
stopped = true;
|
|
13604
|
+
clearInterval(timer);
|
|
13605
|
+
}
|
|
13606
|
+
};
|
|
13607
|
+
}
|
|
13608
|
+
var liveTails = new Map;
|
|
13609
|
+
function flushJobOutput(job) {
|
|
13610
|
+
liveTails.get(job.id)?.flush();
|
|
13611
|
+
}
|
|
13612
|
+
function startJob(manager, opts) {
|
|
13613
|
+
const idleMs = Math.max(0, opts.idleMs ?? DEFAULT_IDLE_MS);
|
|
13614
|
+
const maxWaitMs = Math.min(Math.max(0, opts.maxWaitMs ?? DEFAULT_MAX_WAIT_MS), HARD_MAX_WAIT_MS);
|
|
13615
|
+
mkdirSync(opts.logDir, { recursive: true });
|
|
13616
|
+
const id = newJobId();
|
|
13617
|
+
const logPath = join2(opts.logDir, `${id}.log`);
|
|
13618
|
+
let child;
|
|
13619
|
+
let settled = false;
|
|
13620
|
+
let exiting = false;
|
|
13621
|
+
let idleTimer = null;
|
|
13622
|
+
const maxWaitTimer = setTimeout(() => {
|
|
13623
|
+
resolveStillRunning();
|
|
13624
|
+
}, maxWaitMs);
|
|
13625
|
+
maxWaitTimer.unref?.();
|
|
13626
|
+
let resolveSettle = () => {};
|
|
13627
|
+
const settle = new Promise((res) => {
|
|
13628
|
+
resolveSettle = res;
|
|
13629
|
+
});
|
|
13630
|
+
const armIdle = () => {
|
|
13631
|
+
if (idleTimer)
|
|
13632
|
+
clearTimeout(idleTimer);
|
|
13633
|
+
idleTimer = setTimeout(() => resolveStillRunning(), idleMs);
|
|
13634
|
+
idleTimer.unref?.();
|
|
13635
|
+
};
|
|
13636
|
+
const stopTimers = () => {
|
|
13637
|
+
if (idleTimer)
|
|
13638
|
+
clearTimeout(idleTimer);
|
|
13639
|
+
idleTimer = null;
|
|
13640
|
+
clearTimeout(maxWaitTimer);
|
|
13641
|
+
};
|
|
13642
|
+
const firstOutputHooks = [];
|
|
13643
|
+
function resolveStillRunning() {
|
|
13644
|
+
if (settled)
|
|
13645
|
+
return;
|
|
13646
|
+
settled = true;
|
|
13647
|
+
stopTimers();
|
|
13648
|
+
resolveSettle({ status: "still-running", idleForMs: Date.now() - job.lastOutputAt, outputTail: job.tail });
|
|
13649
|
+
}
|
|
13650
|
+
const job = manager.create({
|
|
13651
|
+
id,
|
|
13652
|
+
cmd: opts.cmd,
|
|
13653
|
+
worktree: opts.worktree,
|
|
13654
|
+
ownerSession: opts.ownerSession,
|
|
13655
|
+
logPath,
|
|
13656
|
+
notify: opts.notify ?? true,
|
|
13657
|
+
killTree: () => {
|
|
13658
|
+
killTree(child);
|
|
13659
|
+
if (opts.survive)
|
|
13660
|
+
opts.registry?.remove(id);
|
|
13661
|
+
},
|
|
13662
|
+
...opts.survive ? { survive: true } : {}
|
|
13663
|
+
});
|
|
13664
|
+
let wfd;
|
|
13665
|
+
try {
|
|
13666
|
+
wfd = openSync(logPath, "a");
|
|
13667
|
+
child = shellSpawn(opts.spawnFn ?? spawn3, opts.cmd, {
|
|
13668
|
+
cwd: opts.cwd,
|
|
13669
|
+
env: { ...opts.env ?? {}, [JOB_ENV_MARKER]: id },
|
|
13670
|
+
stdio: ["ignore", wfd, wfd]
|
|
13671
|
+
});
|
|
13672
|
+
} catch (err) {
|
|
13673
|
+
if (wfd !== undefined) {
|
|
13674
|
+
try {
|
|
13675
|
+
closeSync(wfd);
|
|
13676
|
+
} catch {}
|
|
13677
|
+
}
|
|
13678
|
+
manager.markTerminal(job, "killed", null);
|
|
13679
|
+
maxWaitTimer && clearTimeout(maxWaitTimer);
|
|
13680
|
+
resolveSettle({ status: "exited", exitCode: null, outputTail: "", spawnError: String(err) });
|
|
13681
|
+
return { job, settle };
|
|
13682
|
+
}
|
|
13683
|
+
if (wfd !== undefined) {
|
|
13684
|
+
try {
|
|
13685
|
+
closeSync(wfd);
|
|
13686
|
+
} catch {}
|
|
13687
|
+
}
|
|
13688
|
+
manager.setPid(job, child.pid ?? 0);
|
|
13689
|
+
if (opts.survive) {
|
|
13690
|
+
opts.registry?.add({ id, pid: child.pid ?? 0, cmd: opts.cmd, logPath, startedAt: job.startedAt, ownerSession: opts.ownerSession, hostPid: process.pid });
|
|
13691
|
+
} else if (opts.fence) {
|
|
13692
|
+
opts.fence.assign(child.pid ?? 0);
|
|
13693
|
+
let reinforced = false;
|
|
13694
|
+
const reinforce = () => {
|
|
13695
|
+
if (reinforced)
|
|
13696
|
+
return;
|
|
13697
|
+
(async () => {
|
|
13698
|
+
const kid = await opts.relocateAsync?.(child.pid ?? 0) ?? opts.relocate?.(child.pid ?? 0) ?? null;
|
|
13699
|
+
if (kid !== null && kid > 0) {
|
|
13700
|
+
reinforced = true;
|
|
13701
|
+
opts.fence?.assign(kid);
|
|
13702
|
+
manager.setPid(job, kid);
|
|
13703
|
+
}
|
|
13704
|
+
})();
|
|
13705
|
+
};
|
|
13706
|
+
for (const delay of [50, 400, 1500]) {
|
|
13707
|
+
const t = setTimeout(reinforce, delay);
|
|
13708
|
+
t.unref?.();
|
|
13709
|
+
}
|
|
13710
|
+
firstOutputHooks.push(reinforce);
|
|
13711
|
+
}
|
|
13712
|
+
const onChunk = (text) => {
|
|
13713
|
+
if (firstOutputHooks.length > 0) {
|
|
13714
|
+
for (const hook of firstOutputHooks.splice(0))
|
|
13715
|
+
hook();
|
|
13716
|
+
}
|
|
13717
|
+
manager.appendOutput(job, text);
|
|
13718
|
+
if (opts.successPattern && !settled && job.succeededAt === null) {
|
|
13719
|
+
const m = opts.successPattern.exec(text) ?? opts.successPattern.exec(job.tail);
|
|
13720
|
+
if (m) {
|
|
13721
|
+
settled = true;
|
|
13722
|
+
stopTimers();
|
|
13723
|
+
job.succeededAt = Date.now();
|
|
13724
|
+
const keptAlive = opts.keepAlive !== false;
|
|
13725
|
+
if (!keptAlive) {
|
|
13726
|
+
killTree(child);
|
|
13727
|
+
if (opts.survive)
|
|
13728
|
+
opts.registry?.remove(id);
|
|
13729
|
+
manager.markTerminal(job, "succeeded", null);
|
|
13730
|
+
}
|
|
13731
|
+
resolveSettle({ status: "succeeded", matched: m[0], outputTail: job.tail, keptAlive });
|
|
13732
|
+
}
|
|
13733
|
+
}
|
|
13734
|
+
};
|
|
13735
|
+
const tail = createFileTail(logPath, onChunk);
|
|
13736
|
+
liveTails.set(id, tail);
|
|
13737
|
+
child.on("exit", (code) => {
|
|
13738
|
+
if (exiting)
|
|
13739
|
+
return;
|
|
13740
|
+
exiting = true;
|
|
13741
|
+
stopTimers();
|
|
13742
|
+
let finished = false;
|
|
13743
|
+
const finish = () => {
|
|
13744
|
+
if (finished)
|
|
13745
|
+
return;
|
|
13746
|
+
finished = true;
|
|
13747
|
+
tail.flush();
|
|
13748
|
+
tail.stop();
|
|
13749
|
+
liveTails.delete(id);
|
|
13750
|
+
manager.markTerminal(job, "exited", code);
|
|
13751
|
+
if (opts.survive)
|
|
13752
|
+
opts.registry?.remove(id);
|
|
13753
|
+
rotateLogs(opts.logDir);
|
|
13754
|
+
if (!settled) {
|
|
13755
|
+
settled = true;
|
|
13756
|
+
resolveSettle({ status: "exited", exitCode: code, outputTail: job.tail });
|
|
13757
|
+
}
|
|
13758
|
+
};
|
|
13759
|
+
const graceTimer = setTimeout(finish, opts.exitGraceMs ?? 500);
|
|
13760
|
+
graceTimer.unref?.();
|
|
13761
|
+
});
|
|
13762
|
+
child.on("error", (err) => {
|
|
13763
|
+
tail.stop();
|
|
13764
|
+
liveTails.delete(id);
|
|
13765
|
+
if (opts.survive)
|
|
13766
|
+
opts.registry?.remove(id);
|
|
13767
|
+
if (!settled) {
|
|
13768
|
+
settled = true;
|
|
13769
|
+
stopTimers();
|
|
13770
|
+
manager.markTerminal(job, "killed", null);
|
|
13771
|
+
resolveSettle({ status: "exited", exitCode: null, outputTail: job.tail, spawnError: err.message });
|
|
13772
|
+
}
|
|
13773
|
+
});
|
|
13774
|
+
if (opts.runInBackground) {
|
|
13775
|
+
stopTimers();
|
|
13776
|
+
} else {
|
|
13777
|
+
armIdle();
|
|
13778
|
+
}
|
|
13779
|
+
return { job, settle };
|
|
13780
|
+
}
|
|
13781
|
+
async function pollJob(manager, jobId, waitMs = 0) {
|
|
13782
|
+
const job = manager.get(jobId);
|
|
13783
|
+
if (!job)
|
|
13784
|
+
return;
|
|
13785
|
+
const deadline = Date.now() + Math.min(Math.max(0, waitMs), POLL_WAIT_MAX_MS);
|
|
13786
|
+
while (job.outLen === job.pollCursor && job.state === "running" && Date.now() < deadline) {
|
|
13787
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
13788
|
+
flushJobOutput(job);
|
|
13789
|
+
}
|
|
13790
|
+
flushJobOutput(job);
|
|
13791
|
+
return manager.poll(job);
|
|
13792
|
+
}
|
|
13793
|
+
function adoptSurvivor(manager, entry, opts) {
|
|
13794
|
+
const kill = () => {
|
|
13795
|
+
let pid = entry.pid;
|
|
13796
|
+
for (let hop = 0;hop < 3 && !pidAlive(pid); hop++) {
|
|
13797
|
+
const kid = opts.relocate?.(pid) ?? null;
|
|
13798
|
+
if (kid === null || kid <= 0)
|
|
13799
|
+
break;
|
|
13800
|
+
pid = kid;
|
|
13801
|
+
}
|
|
13802
|
+
killTree({ pid, kill: (sig) => process.kill(pid, sig) });
|
|
13803
|
+
opts.registry.remove(entry.id);
|
|
13804
|
+
};
|
|
13805
|
+
const job = manager.create({
|
|
13806
|
+
id: entry.id,
|
|
13807
|
+
cmd: entry.cmd,
|
|
13808
|
+
worktree: opts.logDir,
|
|
13809
|
+
ownerSession: entry.ownerSession,
|
|
13810
|
+
logPath: entry.logPath,
|
|
13811
|
+
notify: false,
|
|
13812
|
+
killTree: kill,
|
|
13813
|
+
survive: true,
|
|
13814
|
+
previousRun: true
|
|
13815
|
+
});
|
|
13816
|
+
manager.setPid(job, entry.pid);
|
|
13817
|
+
const tail = createFileTail(entry.logPath, (t) => manager.appendOutput(job, t));
|
|
13818
|
+
liveTails.set(job.id, tail);
|
|
13819
|
+
const watcher = setInterval(() => {
|
|
13820
|
+
if (!pidAlive(entry.pid)) {
|
|
13821
|
+
clearInterval(watcher);
|
|
13822
|
+
tail.flush();
|
|
13823
|
+
tail.stop();
|
|
13824
|
+
liveTails.delete(job.id);
|
|
13825
|
+
opts.registry.remove(entry.id);
|
|
13826
|
+
manager.markTerminal(job, "exited", null);
|
|
13827
|
+
rotateLogs(opts.logDir);
|
|
13828
|
+
}
|
|
13829
|
+
}, ADOPT_LIVENESS_MS);
|
|
13830
|
+
watcher.unref?.();
|
|
13831
|
+
return job;
|
|
13832
|
+
}
|
|
13833
|
+
var LOG_READ_MAX_BYTES = 8 * 1024 * 1024;
|
|
13834
|
+
function readJobLog(logPath, opts = {}) {
|
|
13835
|
+
const limit = Math.max(1, opts.limit ?? 200);
|
|
13836
|
+
let raw = "";
|
|
13837
|
+
let windowed = false;
|
|
13838
|
+
try {
|
|
13839
|
+
if (existsSync(logPath)) {
|
|
13840
|
+
const size = statSync(logPath).size;
|
|
13841
|
+
if (size > LOG_READ_MAX_BYTES) {
|
|
13842
|
+
const fd = openSync(logPath, "r");
|
|
13843
|
+
try {
|
|
13844
|
+
const buf = Buffer.allocUnsafe(LOG_READ_MAX_BYTES);
|
|
13845
|
+
let read = 0;
|
|
13846
|
+
while (read < LOG_READ_MAX_BYTES) {
|
|
13847
|
+
const n = readSync(fd, buf, read, LOG_READ_MAX_BYTES - read, size - LOG_READ_MAX_BYTES + read);
|
|
13848
|
+
if (n <= 0)
|
|
13849
|
+
break;
|
|
13850
|
+
read += n;
|
|
13851
|
+
}
|
|
13852
|
+
const text = buf.toString("utf8", 0, read);
|
|
13853
|
+
raw = text.slice(text.indexOf(`
|
|
13854
|
+
`) + 1);
|
|
13855
|
+
windowed = true;
|
|
13856
|
+
} finally {
|
|
13857
|
+
closeSync(fd);
|
|
13858
|
+
}
|
|
13859
|
+
} else {
|
|
13860
|
+
raw = readFileSync2(logPath, "utf8");
|
|
13861
|
+
}
|
|
13862
|
+
}
|
|
13863
|
+
} catch {
|
|
13864
|
+
raw = "";
|
|
13865
|
+
}
|
|
13866
|
+
const lines = raw.length === 0 ? [] : raw.split(/\r?\n/);
|
|
13867
|
+
if (lines.length > 0 && lines[lines.length - 1] === "")
|
|
13868
|
+
lines.pop();
|
|
13869
|
+
const total = lines.length;
|
|
13870
|
+
const offset = opts.offset !== undefined ? Math.max(0, Math.min(opts.offset, total)) : Math.max(0, total - limit);
|
|
13871
|
+
return { lines: lines.slice(offset, offset + limit), total, offset, ...windowed ? { windowed: true } : {} };
|
|
13872
|
+
}
|
|
13873
|
+
function rotateLogs(logDir, keep = DEFAULT_LOG_KEEP) {
|
|
13874
|
+
let entries = [];
|
|
13875
|
+
try {
|
|
13876
|
+
entries = readdirSync(logDir).filter((f) => f.endsWith(".log")).map((name) => {
|
|
13877
|
+
try {
|
|
13878
|
+
return { name, mtime: statSync(join2(logDir, name)).mtimeMs };
|
|
13879
|
+
} catch {
|
|
13880
|
+
return { name, mtime: 0 };
|
|
13881
|
+
}
|
|
13882
|
+
});
|
|
13883
|
+
} catch {
|
|
13884
|
+
return;
|
|
13885
|
+
}
|
|
13886
|
+
const excess = entries.length - keep;
|
|
13887
|
+
if (excess <= 0)
|
|
13888
|
+
return;
|
|
13889
|
+
entries.sort((a, b) => a.mtime - b.mtime);
|
|
13890
|
+
for (const e of entries.slice(0, excess)) {
|
|
13891
|
+
try {
|
|
13892
|
+
unlinkSync(join2(logDir, e.name));
|
|
13893
|
+
} catch {}
|
|
13894
|
+
}
|
|
13895
|
+
}
|
|
13896
|
+
|
|
13897
|
+
// src/job-fence.ts
|
|
13898
|
+
import { spawn as spawn4 } from "node:child_process";
|
|
13899
|
+
var FENCE_PS_SCRIPT = String.raw`
|
|
13900
|
+
Add-Type -TypeDefinition @"
|
|
13901
|
+
using System;
|
|
13902
|
+
using System.Collections.Generic;
|
|
13903
|
+
using System.IO;
|
|
13904
|
+
using System.Runtime.InteropServices;
|
|
13905
|
+
using System.Threading;
|
|
13906
|
+
public static class ForgeJobFence {
|
|
13907
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
13908
|
+
public struct BASIC_LIMITS {
|
|
13909
|
+
public long PerProcessUserTimeLimit;
|
|
13910
|
+
public long PerJobUserTimeLimit;
|
|
13911
|
+
public uint LimitFlags;
|
|
13912
|
+
public UIntPtr MinimumWorkingSetSize;
|
|
13913
|
+
public UIntPtr MaximumWorkingSetSize;
|
|
13914
|
+
public uint ActiveProcessLimit;
|
|
13915
|
+
public UIntPtr Affinity;
|
|
13916
|
+
public uint PriorityClass;
|
|
13917
|
+
public uint SchedulingClass;
|
|
13918
|
+
}
|
|
13919
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
13920
|
+
public struct IO_COUNTERS {
|
|
13921
|
+
public ulong ReadOperationCount;
|
|
13922
|
+
public ulong WriteOperationCount;
|
|
13923
|
+
public ulong OtherOperationCount;
|
|
13924
|
+
public ulong ReadTransferCount;
|
|
13925
|
+
public ulong WriteTransferCount;
|
|
13926
|
+
public ulong OtherTransferCount;
|
|
13927
|
+
}
|
|
13928
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
13929
|
+
public struct EXTENDED_LIMITS {
|
|
13930
|
+
public BASIC_LIMITS Basic;
|
|
13931
|
+
public IO_COUNTERS IoInfo;
|
|
13932
|
+
public UIntPtr ProcessMemoryLimit;
|
|
13933
|
+
public UIntPtr JobMemoryLimit;
|
|
13934
|
+
public UIntPtr PeakProcessMemoryUsed;
|
|
13935
|
+
public UIntPtr PeakJobMemoryUsed;
|
|
13936
|
+
}
|
|
13937
|
+
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
|
13938
|
+
public struct PE32 {
|
|
13939
|
+
public uint dwSize;
|
|
13940
|
+
public uint cntUsage;
|
|
13941
|
+
public uint th32ProcessID;
|
|
13942
|
+
public IntPtr th32DefaultHeapID;
|
|
13943
|
+
public uint th32ModuleID;
|
|
13944
|
+
public uint cntThreads;
|
|
13945
|
+
public uint th32ParentProcessID;
|
|
13946
|
+
public int pcPriClassBase;
|
|
13947
|
+
public uint dwFlags;
|
|
13948
|
+
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)]
|
|
13949
|
+
public string szExeFile;
|
|
13950
|
+
}
|
|
13951
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
13952
|
+
static extern IntPtr CreateJobObject(IntPtr a, string n);
|
|
13953
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
13954
|
+
static extern bool SetInformationJobObject(IntPtr hJob, int infoClass, IntPtr lpInfo, int cbInfo);
|
|
13955
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
13956
|
+
static extern bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess);
|
|
13957
|
+
[DllImport("kernel32.dll")]
|
|
13958
|
+
static extern IntPtr OpenProcess(uint access, bool inherit, int pid);
|
|
13959
|
+
[DllImport("kernel32.dll")]
|
|
13960
|
+
static extern bool CloseHandle(IntPtr h);
|
|
13961
|
+
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
|
|
13962
|
+
static extern IntPtr CreateToolhelp32Snapshot(uint flags, uint pid);
|
|
13963
|
+
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
|
|
13964
|
+
static extern bool Process32FirstW(IntPtr h, ref PE32 e);
|
|
13965
|
+
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
|
|
13966
|
+
static extern bool Process32NextW(IntPtr h, ref PE32 e);
|
|
13967
|
+
|
|
13968
|
+
const int EXTENDED_LIMITS_CLASS = 9;
|
|
13969
|
+
const uint KILL_ON_JOB_CLOSE = 0x2000;
|
|
13970
|
+
const uint PROCESS_ALL_ACCESS = 0x1FFFFF;
|
|
13971
|
+
const uint SNAP_PROCESS = 0x2;
|
|
13972
|
+
// Descendant walk bound: deep enough for wrapper -> command -> children,
|
|
13973
|
+
// shallow enough that a recycled ancestor pid cannot pull unrelated
|
|
13974
|
+
// processes into the kill-on-close job.
|
|
13975
|
+
const int MAX_DEPTH = 4;
|
|
13976
|
+
|
|
13977
|
+
static IntPtr _job = IntPtr.Zero;
|
|
13978
|
+
static readonly HashSet<uint> _roots = new HashSet<uint>();
|
|
13979
|
+
static readonly HashSet<uint> _fenced = new HashSet<uint>();
|
|
13980
|
+
static readonly object _gate = new object();
|
|
13981
|
+
static Timer _sweep;
|
|
13982
|
+
|
|
13983
|
+
static void Sweep(object state) {
|
|
13984
|
+
try {
|
|
13985
|
+
var parents = new Dictionary<uint, uint>();
|
|
13986
|
+
var h = CreateToolhelp32Snapshot(SNAP_PROCESS, 0);
|
|
13987
|
+
if (h != IntPtr.Zero && h != new IntPtr(-1)) {
|
|
13988
|
+
var e = new PE32();
|
|
13989
|
+
e.dwSize = (uint)Marshal.SizeOf(typeof(PE32));
|
|
13990
|
+
if (Process32FirstW(h, ref e)) {
|
|
13991
|
+
do { parents[e.th32ProcessID] = e.th32ParentProcessID; } while (Process32NextW(h, ref e));
|
|
13992
|
+
}
|
|
13993
|
+
CloseHandle(h);
|
|
13994
|
+
}
|
|
13995
|
+
lock (_gate) {
|
|
13996
|
+
if (_job == IntPtr.Zero) return;
|
|
13997
|
+
foreach (var kv in parents) {
|
|
13998
|
+
if (_fenced.Contains(kv.Key)) continue;
|
|
13999
|
+
uint p = kv.Key;
|
|
14000
|
+
int depth = 0;
|
|
14001
|
+
bool tracked = false;
|
|
14002
|
+
while (p != 0 && depth <= MAX_DEPTH) {
|
|
14003
|
+
if (_roots.Contains(p)) { tracked = true; break; }
|
|
14004
|
+
uint up;
|
|
14005
|
+
if (parents.TryGetValue(p, out up)) { p = up; } else { p = 0; }
|
|
14006
|
+
depth++;
|
|
14007
|
+
}
|
|
14008
|
+
if (!tracked) continue;
|
|
14009
|
+
var ph = OpenProcess(PROCESS_ALL_ACCESS, false, (int)kv.Key);
|
|
14010
|
+
if (ph != IntPtr.Zero) {
|
|
14011
|
+
if (AssignProcessToJobObject(_job, ph)) _fenced.Add(kv.Key);
|
|
14012
|
+
CloseHandle(ph);
|
|
14013
|
+
}
|
|
14014
|
+
}
|
|
14015
|
+
}
|
|
14016
|
+
} catch { }
|
|
14017
|
+
}
|
|
14018
|
+
|
|
14019
|
+
public static int Run() {
|
|
14020
|
+
_job = CreateJobObject(IntPtr.Zero, null);
|
|
14021
|
+
if (_job == IntPtr.Zero) return 2;
|
|
14022
|
+
var info = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(EXTENDED_LIMITS)));
|
|
14023
|
+
try {
|
|
14024
|
+
Marshal.WriteInt32(info, 16, (int)KILL_ON_JOB_CLOSE); // Basic.LimitFlags offset
|
|
14025
|
+
if (!SetInformationJobObject(_job, EXTENDED_LIMITS_CLASS, info, Marshal.SizeOf(typeof(EXTENDED_LIMITS)))) return 3;
|
|
14026
|
+
} finally {
|
|
14027
|
+
Marshal.FreeHGlobal(info);
|
|
14028
|
+
}
|
|
14029
|
+
_sweep = new Timer(Sweep, null, 0, 400);
|
|
14030
|
+
string line;
|
|
14031
|
+
while ((line = Console.In.ReadLine()) != null) {
|
|
14032
|
+
uint pid;
|
|
14033
|
+
var t = line.Trim();
|
|
14034
|
+
if (t.Length > 0 && uint.TryParse(t, out pid) && pid > 0) {
|
|
14035
|
+
lock (_gate) { _roots.Add(pid); }
|
|
14036
|
+
var ph = OpenProcess(PROCESS_ALL_ACCESS, false, (int)pid);
|
|
14037
|
+
if (ph != IntPtr.Zero) {
|
|
14038
|
+
if (AssignProcessToJobObject(_job, ph)) {
|
|
14039
|
+
lock (_gate) { _fenced.Add(pid); }
|
|
14040
|
+
}
|
|
14041
|
+
CloseHandle(ph);
|
|
14042
|
+
}
|
|
14043
|
+
}
|
|
14044
|
+
}
|
|
14045
|
+
return 0; // stdin EOF: the host is gone -> exit -> handle closes -> kernel kills
|
|
14046
|
+
}
|
|
14047
|
+
}
|
|
14048
|
+
"@
|
|
14049
|
+
[ForgeJobFence]::Run()
|
|
14050
|
+
exit $LASTEXITCODE
|
|
14051
|
+
`;
|
|
14052
|
+
function createJobFence(opts = {}) {
|
|
14053
|
+
const platform = opts.platform ?? process.platform;
|
|
14054
|
+
if (platform !== "win32")
|
|
14055
|
+
return null;
|
|
14056
|
+
const spawnFn = opts.spawnFn ?? spawn4;
|
|
14057
|
+
let child;
|
|
14058
|
+
try {
|
|
14059
|
+
child = spawnFn("powershell", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", FENCE_PS_SCRIPT], {
|
|
14060
|
+
windowsHide: true,
|
|
14061
|
+
stdio: ["pipe", "ignore", "ignore"]
|
|
14062
|
+
});
|
|
14063
|
+
} catch (err) {
|
|
14064
|
+
opts.onDegrade?.(`job fence not started: spawn failed (${String(err).slice(0, 120)})`);
|
|
14065
|
+
return null;
|
|
14066
|
+
}
|
|
14067
|
+
const stdin = child.stdin;
|
|
14068
|
+
if (!stdin) {
|
|
14069
|
+
opts.onDegrade?.("job fence not started: watcher has no stdin");
|
|
14070
|
+
return null;
|
|
14071
|
+
}
|
|
14072
|
+
let healthy = true;
|
|
14073
|
+
child.on("exit", () => {
|
|
14074
|
+
healthy = false;
|
|
14075
|
+
});
|
|
14076
|
+
child.on("error", () => {
|
|
14077
|
+
healthy = false;
|
|
14078
|
+
});
|
|
14079
|
+
let reported = false;
|
|
14080
|
+
const fail = (why) => {
|
|
14081
|
+
healthy = false;
|
|
14082
|
+
if (reported)
|
|
14083
|
+
return;
|
|
14084
|
+
reported = true;
|
|
14085
|
+
opts.onDegrade?.(why);
|
|
14086
|
+
};
|
|
14087
|
+
return {
|
|
14088
|
+
assign(pid) {
|
|
14089
|
+
if (!healthy || !stdin.writable) {
|
|
14090
|
+
fail("job fence watcher gone; job relies on the JS exit matrix only");
|
|
14091
|
+
return;
|
|
14092
|
+
}
|
|
14093
|
+
try {
|
|
14094
|
+
stdin.write(`${pid}
|
|
14095
|
+
`);
|
|
14096
|
+
} catch (err) {
|
|
14097
|
+
fail(`job fence write failed (${String(err).slice(0, 120)})`);
|
|
14098
|
+
}
|
|
14099
|
+
},
|
|
14100
|
+
dispose() {
|
|
14101
|
+
try {
|
|
14102
|
+
stdin.end();
|
|
14103
|
+
} catch {}
|
|
14104
|
+
},
|
|
14105
|
+
get healthy() {
|
|
14106
|
+
return healthy;
|
|
14107
|
+
}
|
|
14108
|
+
};
|
|
14109
|
+
}
|
|
14110
|
+
|
|
14111
|
+
// src/job-registry.ts
|
|
14112
|
+
import { closeSync as closeSync2, existsSync as existsSync2, mkdirSync as mkdirSync2, openSync as openSync2, readFileSync as readFileSync3, readdirSync as readdirSync2, statSync as statSync2, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
14113
|
+
import { execFileSync, spawn as spawn5 } from "node:child_process";
|
|
14114
|
+
import { dirname, join as join3 } from "node:path";
|
|
14115
|
+
var REGISTRY_KEEP = 100;
|
|
14116
|
+
function structuralRelocate(deadPid, platform = process.platform) {
|
|
14117
|
+
if (deadPid <= 0)
|
|
14118
|
+
return null;
|
|
14119
|
+
if (platform === "win32") {
|
|
14120
|
+
for (let attempt = 0;attempt < 2; attempt++) {
|
|
14121
|
+
if (attempt > 0)
|
|
14122
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 300);
|
|
14123
|
+
try {
|
|
14124
|
+
const raw = execFileSync("powershell", ["-NoProfile", "-Command", `Get-CimInstance Win32_Process | Where-Object { $_.ParentProcessId -eq ${deadPid} -and $_.Name -ne 'conhost.exe' } | Select-Object -First 1 -ExpandProperty ProcessId`], { encoding: "utf8", windowsHide: true, timeout: 1e4 });
|
|
14125
|
+
const pid = Number(String(raw).trim());
|
|
14126
|
+
if (Number.isInteger(pid) && pid > 0)
|
|
14127
|
+
return pid;
|
|
14128
|
+
} catch {}
|
|
14129
|
+
}
|
|
14130
|
+
return null;
|
|
14131
|
+
}
|
|
14132
|
+
try {
|
|
14133
|
+
for (const ent of readdirSync2("/proc")) {
|
|
14134
|
+
if (!/^\d+$/.test(ent))
|
|
14135
|
+
continue;
|
|
14136
|
+
try {
|
|
14137
|
+
const m = /^(\d+) \((.*)\) (\w) (\d+)/.exec(readFileSync3(`/proc/${ent}/stat`, "utf8"));
|
|
14138
|
+
if (m && Number(m[4]) === deadPid)
|
|
14139
|
+
return Number(m[1]);
|
|
14140
|
+
} catch {}
|
|
14141
|
+
}
|
|
14142
|
+
} catch {
|
|
14143
|
+
return null;
|
|
14144
|
+
}
|
|
14145
|
+
return null;
|
|
14146
|
+
}
|
|
14147
|
+
function structuralRelocateAsync(deadPid, platform = process.platform) {
|
|
14148
|
+
if (deadPid <= 0)
|
|
14149
|
+
return Promise.resolve(null);
|
|
14150
|
+
if (platform !== "win32")
|
|
14151
|
+
return Promise.resolve(structuralRelocate(deadPid, platform));
|
|
14152
|
+
return new Promise((resolve2) => {
|
|
14153
|
+
let out = "";
|
|
14154
|
+
let settled = false;
|
|
14155
|
+
const done = (pid) => {
|
|
14156
|
+
if (settled)
|
|
14157
|
+
return;
|
|
14158
|
+
settled = true;
|
|
14159
|
+
clearTimeout(timer);
|
|
14160
|
+
const n = Number(String(out).trim());
|
|
14161
|
+
resolve2(Number.isInteger(n) && n > 0 ? n : pid);
|
|
14162
|
+
};
|
|
14163
|
+
const child = spawn5("powershell", ["-NoProfile", "-Command", `Get-CimInstance Win32_Process | Where-Object { $_.ParentProcessId -eq ${deadPid} -and $_.Name -ne 'conhost.exe' } | Select-Object -First 1 -ExpandProperty ProcessId`], { windowsHide: true, stdio: ["ignore", "pipe", "ignore"] });
|
|
14164
|
+
const timer = setTimeout(() => {
|
|
14165
|
+
try {
|
|
14166
|
+
child.kill();
|
|
14167
|
+
} catch {}
|
|
14168
|
+
done(null);
|
|
14169
|
+
}, 1e4);
|
|
14170
|
+
child.stdout?.on("data", (d) => {
|
|
14171
|
+
out += d;
|
|
14172
|
+
});
|
|
14173
|
+
child.on("error", () => done(null));
|
|
14174
|
+
child.on("exit", () => done(null));
|
|
14175
|
+
});
|
|
14176
|
+
}
|
|
14177
|
+
function acquireLock(lockPath, timeoutMs = 2000) {
|
|
14178
|
+
const deadline = Date.now() + timeoutMs;
|
|
14179
|
+
for (;; ) {
|
|
14180
|
+
try {
|
|
14181
|
+
const fd = openSync2(lockPath, "wx");
|
|
14182
|
+
return {
|
|
14183
|
+
release: () => {
|
|
14184
|
+
try {
|
|
14185
|
+
closeSync2(fd);
|
|
14186
|
+
unlinkSync2(lockPath);
|
|
14187
|
+
} catch {}
|
|
14188
|
+
}
|
|
14189
|
+
};
|
|
14190
|
+
} catch (err) {
|
|
14191
|
+
if (err.code !== "EEXIST")
|
|
14192
|
+
throw err;
|
|
14193
|
+
try {
|
|
14194
|
+
if (Date.now() - statSync2(lockPath).mtimeMs > 5000) {
|
|
14195
|
+
unlinkSync2(lockPath);
|
|
14196
|
+
continue;
|
|
14197
|
+
}
|
|
14198
|
+
} catch {}
|
|
14199
|
+
if (Date.now() >= deadline)
|
|
14200
|
+
return null;
|
|
14201
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
|
|
14202
|
+
}
|
|
14203
|
+
}
|
|
14204
|
+
}
|
|
14205
|
+
function readRaw(path) {
|
|
14206
|
+
try {
|
|
14207
|
+
const raw = existsSync2(path) ? readFileSync3(path, "utf8") : "";
|
|
14208
|
+
if (!raw.trim())
|
|
14209
|
+
return [];
|
|
14210
|
+
const parsed = JSON.parse(raw);
|
|
14211
|
+
if (!Array.isArray(parsed.entries))
|
|
14212
|
+
return [];
|
|
14213
|
+
return parsed.entries.filter((e) => typeof e?.id === "string" && Number.isFinite(e?.pid) && e.pid > 0);
|
|
14214
|
+
} catch {
|
|
14215
|
+
return [];
|
|
14216
|
+
}
|
|
14217
|
+
}
|
|
14218
|
+
function writeRaw(path, entries) {
|
|
14219
|
+
mkdirSync2(dirname(path), { recursive: true });
|
|
14220
|
+
writeFileSync2(path, `${JSON.stringify({ version: 1, entries: entries.slice(0, REGISTRY_KEEP) }, null, 2)}
|
|
14221
|
+
`);
|
|
14222
|
+
}
|
|
14223
|
+
function createJobRegistry(registryPath) {
|
|
14224
|
+
const lockPath = `${registryPath}.lock`;
|
|
14225
|
+
const mutate = (fn) => {
|
|
14226
|
+
const lock = acquireLock(lockPath);
|
|
14227
|
+
if (!lock)
|
|
14228
|
+
return false;
|
|
14229
|
+
try {
|
|
14230
|
+
writeRaw(registryPath, fn(readRaw(registryPath)));
|
|
14231
|
+
return true;
|
|
14232
|
+
} finally {
|
|
14233
|
+
lock.release();
|
|
14234
|
+
}
|
|
14235
|
+
};
|
|
14236
|
+
return {
|
|
14237
|
+
add(entry) {
|
|
14238
|
+
mutate((entries) => [entry, ...entries.filter((e) => e.id !== entry.id)].slice(0, REGISTRY_KEEP));
|
|
14239
|
+
},
|
|
14240
|
+
remove(id) {
|
|
14241
|
+
mutate((entries) => entries.filter((e) => e.id !== id));
|
|
14242
|
+
},
|
|
14243
|
+
list() {
|
|
14244
|
+
return readRaw(registryPath);
|
|
14245
|
+
},
|
|
14246
|
+
rescan(isAlive, relocate) {
|
|
14247
|
+
const entries = readRaw(registryPath);
|
|
14248
|
+
const adopted = [];
|
|
14249
|
+
const dead = [];
|
|
14250
|
+
for (const e of entries) {
|
|
14251
|
+
if (isAlive(e.pid)) {
|
|
14252
|
+
adopted.push(e);
|
|
14253
|
+
continue;
|
|
14254
|
+
}
|
|
14255
|
+
const relocated = relocate?.(e.pid) ?? null;
|
|
14256
|
+
if (relocated !== null && relocated !== e.pid && isAlive(relocated)) {
|
|
14257
|
+
adopted.push({ ...e, pid: relocated });
|
|
14258
|
+
} else {
|
|
14259
|
+
dead.push(e);
|
|
14260
|
+
}
|
|
14261
|
+
}
|
|
14262
|
+
const lock = acquireLock(lockPath);
|
|
14263
|
+
if (lock) {
|
|
14264
|
+
try {
|
|
14265
|
+
writeRaw(registryPath, adopted);
|
|
14266
|
+
} finally {
|
|
14267
|
+
lock.release();
|
|
14268
|
+
}
|
|
14269
|
+
}
|
|
14270
|
+
return { adopted, dead };
|
|
14271
|
+
}
|
|
14272
|
+
};
|
|
14273
|
+
}
|
|
14274
|
+
function registryPathFor(jobsDir) {
|
|
14275
|
+
return join3(jobsDir, "registry.json");
|
|
14276
|
+
}
|
|
14277
|
+
|
|
14278
|
+
// src/host-exit.ts
|
|
14279
|
+
function createExitCleanup(manager, opts = {}) {
|
|
14280
|
+
const graceMs = opts.graceMs ?? 3000;
|
|
14281
|
+
let sequenced = false;
|
|
14282
|
+
let forceIssued = false;
|
|
14283
|
+
let afterRan = false;
|
|
14284
|
+
const installed = [];
|
|
14285
|
+
const liveTargets = () => {
|
|
14286
|
+
const targets = [];
|
|
14287
|
+
for (const job of manager.list()) {
|
|
14288
|
+
if (job.state === "running" && !job.survive)
|
|
14289
|
+
targets.push(job);
|
|
14290
|
+
}
|
|
14291
|
+
return targets;
|
|
14292
|
+
};
|
|
14293
|
+
const killOne = (job, graceful) => {
|
|
14294
|
+
const pid = job.pid ?? 0;
|
|
14295
|
+
if (pid > 0) {
|
|
14296
|
+
terminateTreeSync(pid, {
|
|
14297
|
+
graceMs: graceful ? graceMs : 0,
|
|
14298
|
+
spawnSyncFn: opts.spawnSyncFn,
|
|
14299
|
+
platform: opts.platform,
|
|
14300
|
+
...opts.noWait ? { wait: false } : {}
|
|
14301
|
+
});
|
|
14302
|
+
}
|
|
14303
|
+
try {
|
|
14304
|
+
job.killTree();
|
|
14305
|
+
} catch {}
|
|
14306
|
+
manager.markTerminal(job, "killed", null);
|
|
14307
|
+
};
|
|
14308
|
+
const forcePass = () => {
|
|
14309
|
+
if (forceIssued)
|
|
14310
|
+
return;
|
|
14311
|
+
forceIssued = true;
|
|
14312
|
+
for (const job of liveTargets())
|
|
14313
|
+
killOne(job, false);
|
|
14314
|
+
};
|
|
14315
|
+
const trigger = (kind) => {
|
|
14316
|
+
if (kind === "dispose") {
|
|
14317
|
+
if (sequenced)
|
|
14318
|
+
return;
|
|
14319
|
+
sequenced = true;
|
|
14320
|
+
for (const job of liveTargets())
|
|
14321
|
+
killOne(job, true);
|
|
14322
|
+
forcePass();
|
|
14323
|
+
if (!afterRan) {
|
|
14324
|
+
afterRan = true;
|
|
14325
|
+
opts.after?.();
|
|
14326
|
+
}
|
|
14327
|
+
return;
|
|
14328
|
+
}
|
|
14329
|
+
forcePass();
|
|
14330
|
+
};
|
|
14331
|
+
const onSignal = (kind) => () => trigger(kind);
|
|
14332
|
+
const wired = [
|
|
14333
|
+
["SIGINT", onSignal("SIGINT")],
|
|
14334
|
+
["SIGTERM", onSignal("SIGTERM")],
|
|
14335
|
+
["exit", onSignal("exit")],
|
|
14336
|
+
["uncaughtException", onSignal("uncaughtException")],
|
|
14337
|
+
["unhandledRejection", onSignal("unhandledRejection")]
|
|
14338
|
+
];
|
|
14339
|
+
for (const [ev, fn] of wired) {
|
|
14340
|
+
process.on(ev, fn);
|
|
14341
|
+
installed.push([ev, fn]);
|
|
14342
|
+
}
|
|
14343
|
+
return {
|
|
14344
|
+
trigger,
|
|
14345
|
+
uninstall() {
|
|
14346
|
+
for (const [ev, fn] of installed) {
|
|
14347
|
+
try {
|
|
14348
|
+
process.removeListener(ev, fn);
|
|
14349
|
+
} catch {}
|
|
14350
|
+
}
|
|
14351
|
+
}
|
|
14352
|
+
};
|
|
14353
|
+
}
|
|
14354
|
+
|
|
14355
|
+
// src/watchdog.ts
|
|
14356
|
+
import { mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "node:fs";
|
|
14357
|
+
import { dirname as dirname2 } from "node:path";
|
|
14358
|
+
var WATCHDOG_ENV_MARK = "FORGE_WATCHDOG_MARK";
|
|
14359
|
+
var DEFAULT_STALL_MS = 600000;
|
|
14360
|
+
var MIN_STALL_MS = 60000;
|
|
14361
|
+
var DEFAULT_INTERVAL_MS = 30000;
|
|
14362
|
+
var DEFAULT_OBSERVE_MS = 60000;
|
|
14363
|
+
var WARN_RATIO = 0.8;
|
|
14364
|
+
function clampStallMs(raw, min = MIN_STALL_MS) {
|
|
14365
|
+
const n = typeof raw === "number" && Number.isFinite(raw) ? raw : DEFAULT_STALL_MS;
|
|
14366
|
+
return Math.max(min, Math.floor(n));
|
|
14367
|
+
}
|
|
14368
|
+
function parseMode(raw) {
|
|
14369
|
+
return raw === "off" || raw === "dry-run" || raw === "kill" ? raw : "kill";
|
|
14370
|
+
}
|
|
14371
|
+
function createWatchdog(opts = {}) {
|
|
14372
|
+
const mode = parseMode(opts.mode);
|
|
14373
|
+
const stallMs = clampStallMs(opts.stallMs, opts.minStallMs ?? MIN_STALL_MS);
|
|
14374
|
+
const intervalMs = Math.max(1, opts.intervalMs ?? DEFAULT_INTERVAL_MS);
|
|
14375
|
+
const observeMs = opts.observeMs ?? DEFAULT_OBSERVE_MS;
|
|
14376
|
+
const now = opts.now ?? Date.now;
|
|
14377
|
+
const sink = opts.sink ?? (() => {});
|
|
14378
|
+
const locate = opts.locate ?? (async () => []);
|
|
14379
|
+
const killTree2 = opts.killTree ?? (() => {});
|
|
14380
|
+
const isAlive = opts.isAlive ?? ((pid) => {
|
|
14381
|
+
try {
|
|
14382
|
+
process.kill(pid, 0);
|
|
14383
|
+
return true;
|
|
14384
|
+
} catch {
|
|
14385
|
+
return false;
|
|
14386
|
+
}
|
|
14387
|
+
});
|
|
14388
|
+
const setIntervalFn = opts.setIntervalFn ?? setInterval;
|
|
14389
|
+
const clearIntervalFn = opts.clearIntervalFn ?? clearInterval;
|
|
14390
|
+
let currentMode = mode;
|
|
14391
|
+
const table = new Map;
|
|
14392
|
+
const markerSeenIDs = new Set;
|
|
14393
|
+
let timer = null;
|
|
14394
|
+
const write = (event, rec, extra = {}) => {
|
|
14395
|
+
sink({
|
|
14396
|
+
ts: new Date(now()).toISOString(),
|
|
14397
|
+
event,
|
|
14398
|
+
callID: rec.callID,
|
|
14399
|
+
...rec.sessionID !== undefined ? { sessionID: rec.sessionID } : {},
|
|
14400
|
+
tool: rec.tool,
|
|
14401
|
+
t0: rec.t0,
|
|
14402
|
+
elapsedMs: now() - rec.t0,
|
|
14403
|
+
mode: currentMode,
|
|
14404
|
+
...extra
|
|
14405
|
+
});
|
|
14406
|
+
};
|
|
14407
|
+
const ensureTimer = () => {
|
|
14408
|
+
if (currentMode === "off" || timer)
|
|
14409
|
+
return;
|
|
14410
|
+
timer = setIntervalFn(() => {
|
|
14411
|
+
scan();
|
|
14412
|
+
}, intervalMs);
|
|
14413
|
+
timer.unref?.();
|
|
14414
|
+
};
|
|
14415
|
+
const maybeStopTimer = () => {
|
|
14416
|
+
if (timer && table.size === 0) {
|
|
14417
|
+
clearIntervalFn(timer);
|
|
14418
|
+
timer = null;
|
|
14419
|
+
}
|
|
14420
|
+
};
|
|
14421
|
+
const act = async (rec) => {
|
|
14422
|
+
rec.acted = true;
|
|
14423
|
+
try {
|
|
14424
|
+
if (!rec.markerSeen) {
|
|
14425
|
+
const pids2 = await locate(rec.callID, rec.t0, rec.cmdNeedle);
|
|
14426
|
+
write("dry-run-candidate", rec, {
|
|
14427
|
+
pids: pids2,
|
|
14428
|
+
reason: "marker-missing (shell.env hook did not fire) — degraded to dry-run"
|
|
14429
|
+
});
|
|
14430
|
+
return;
|
|
14431
|
+
}
|
|
14432
|
+
const pidsA = await locate(rec.callID, rec.t0, rec.cmdNeedle, false);
|
|
14433
|
+
let pids = pidsA;
|
|
14434
|
+
if (pidsA.length === 0 && rec.cmdNeedle !== undefined) {
|
|
14435
|
+
const pidsB = await locate(rec.callID, rec.t0, rec.cmdNeedle, true);
|
|
14436
|
+
if (pidsB.length > 0) {
|
|
14437
|
+
pids = pidsB;
|
|
14438
|
+
rec.wave = 2;
|
|
14439
|
+
}
|
|
14440
|
+
}
|
|
14441
|
+
if (pids.length === 0) {
|
|
14442
|
+
write("unresolved", rec, { reason: "no matching process found" });
|
|
14443
|
+
return;
|
|
14444
|
+
}
|
|
14445
|
+
if (currentMode === "dry-run") {
|
|
14446
|
+
write("dry-run-candidate", rec, { pids, candidates: pidsA });
|
|
14447
|
+
return;
|
|
14448
|
+
}
|
|
14449
|
+
const killed = [];
|
|
14450
|
+
for (const p of pids) {
|
|
14451
|
+
try {
|
|
14452
|
+
killTree2(p.pid);
|
|
14453
|
+
killed.push(p);
|
|
14454
|
+
} catch {}
|
|
14455
|
+
}
|
|
14456
|
+
rec.killedAt = now();
|
|
14457
|
+
if (rec.wave === 0)
|
|
14458
|
+
rec.wave = 1;
|
|
14459
|
+
write("kill", rec, {
|
|
14460
|
+
pids: killed,
|
|
14461
|
+
candidates: pids,
|
|
14462
|
+
reason: pids === pidsA ? "wave 1 — host subtree / command match" : "workspace scope (call's own chain already exited)"
|
|
14463
|
+
});
|
|
14464
|
+
} catch (err) {
|
|
14465
|
+
write("unresolved", rec, { reason: `intervention failed: ${String(err)}` });
|
|
14466
|
+
}
|
|
14467
|
+
};
|
|
14468
|
+
const scan = async () => {
|
|
14469
|
+
if (currentMode === "off")
|
|
14470
|
+
return;
|
|
14471
|
+
const nowMs = now();
|
|
14472
|
+
for (const rec of table.values()) {
|
|
14473
|
+
const elapsed = nowMs - rec.t0;
|
|
14474
|
+
if (rec.acted && rec.killedAt !== undefined && !rec.unresolvedReported) {
|
|
14475
|
+
if (nowMs - rec.killedAt >= observeMs) {
|
|
14476
|
+
if (currentMode === "kill" && rec.wave === 1 && rec.cmdNeedle !== undefined) {
|
|
14477
|
+
const wave2 = await locate(rec.callID, rec.t0, rec.cmdNeedle, true);
|
|
14478
|
+
const stillAlive = wave2.filter((p) => isAlive(p.pid));
|
|
14479
|
+
if (stillAlive.length > 0) {
|
|
14480
|
+
const killed2 = [];
|
|
14481
|
+
for (const p of stillAlive) {
|
|
14482
|
+
try {
|
|
14483
|
+
killTree2(p.pid);
|
|
14484
|
+
killed2.push(p);
|
|
14485
|
+
} catch {}
|
|
14486
|
+
}
|
|
14487
|
+
rec.killedAt = now();
|
|
14488
|
+
rec.wave = 2;
|
|
14489
|
+
write("kill", rec, { pids: killed2, reason: "wave 2 — workspace scope (wave 1 did not unblock the call)" });
|
|
14490
|
+
return;
|
|
14491
|
+
}
|
|
14492
|
+
}
|
|
14493
|
+
rec.unresolvedReported = true;
|
|
14494
|
+
write("unresolved", rec, { reason: "no tool.execute.after within the observation window after kill" });
|
|
14495
|
+
}
|
|
14496
|
+
continue;
|
|
14497
|
+
}
|
|
14498
|
+
if (rec.acted)
|
|
14499
|
+
continue;
|
|
14500
|
+
if (elapsed >= stallMs) {
|
|
14501
|
+
await act(rec);
|
|
14502
|
+
continue;
|
|
14503
|
+
}
|
|
14504
|
+
if (elapsed >= WARN_RATIO * stallMs && !rec.warned) {
|
|
14505
|
+
rec.warned = true;
|
|
14506
|
+
write("warn", rec);
|
|
14507
|
+
}
|
|
14508
|
+
}
|
|
14509
|
+
maybeStopTimer();
|
|
14510
|
+
};
|
|
14511
|
+
return {
|
|
14512
|
+
get mode() {
|
|
14513
|
+
return currentMode;
|
|
14514
|
+
},
|
|
14515
|
+
get stallMs() {
|
|
14516
|
+
return stallMs;
|
|
14517
|
+
},
|
|
14518
|
+
track(callID, sessionID, tool3, t0 = now(), cmdNeedle) {
|
|
14519
|
+
if (currentMode === "off")
|
|
14520
|
+
return;
|
|
14521
|
+
table.set(callID, {
|
|
14522
|
+
callID,
|
|
14523
|
+
sessionID,
|
|
14524
|
+
tool: tool3,
|
|
14525
|
+
t0,
|
|
14526
|
+
markerSeen: markerSeenIDs.has(callID),
|
|
14527
|
+
warned: false,
|
|
14528
|
+
acted: false,
|
|
14529
|
+
wave: 0,
|
|
14530
|
+
...cmdNeedle !== undefined && cmdNeedle.length > 0 ? { cmdNeedle } : {}
|
|
14531
|
+
});
|
|
14532
|
+
ensureTimer();
|
|
14533
|
+
},
|
|
14534
|
+
markSeen(callID) {
|
|
14535
|
+
markerSeenIDs.add(callID);
|
|
14536
|
+
const rec = table.get(callID);
|
|
14537
|
+
if (rec)
|
|
14538
|
+
rec.markerSeen = true;
|
|
14539
|
+
},
|
|
14540
|
+
untrack(callID) {
|
|
14541
|
+
table.delete(callID);
|
|
14542
|
+
maybeStopTimer();
|
|
14543
|
+
},
|
|
14544
|
+
has: (callID) => table.has(callID),
|
|
14545
|
+
size: () => table.size,
|
|
14546
|
+
scan,
|
|
14547
|
+
setMode(next) {
|
|
14548
|
+
currentMode = next;
|
|
14549
|
+
if (next === "off") {
|
|
14550
|
+
if (timer) {
|
|
14551
|
+
clearIntervalFn(timer);
|
|
14552
|
+
timer = null;
|
|
14553
|
+
}
|
|
14554
|
+
table.clear();
|
|
14555
|
+
} else {
|
|
14556
|
+
ensureTimer();
|
|
14557
|
+
}
|
|
14558
|
+
},
|
|
14559
|
+
dispose() {
|
|
14560
|
+
if (timer) {
|
|
14561
|
+
clearIntervalFn(timer);
|
|
14562
|
+
timer = null;
|
|
14563
|
+
}
|
|
14564
|
+
table.clear();
|
|
14565
|
+
}
|
|
14566
|
+
};
|
|
14567
|
+
}
|
|
14568
|
+
function watchdogLogDir(base) {
|
|
14569
|
+
const tmp = base ?? (process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp");
|
|
14570
|
+
return `${tmp.replace(/[\\/]+$/, "")}/opencode-forge/watchdog`;
|
|
14571
|
+
}
|
|
14572
|
+
function createFileLedger(logPath, maxEntries = 200, maxBytes = 1e6) {
|
|
14573
|
+
const append = (entry) => {
|
|
14574
|
+
try {
|
|
14575
|
+
mkdirSync3(dirname2(logPath), { recursive: true });
|
|
14576
|
+
let lines = [];
|
|
14577
|
+
try {
|
|
14578
|
+
lines = readFileSync4(logPath, "utf8").split(`
|
|
14579
|
+
`).filter((l) => l.trim().length > 0);
|
|
14580
|
+
} catch {}
|
|
14581
|
+
if (lines.length >= maxEntries)
|
|
14582
|
+
lines = lines.slice(lines.length - maxEntries + 1);
|
|
14583
|
+
lines.push(JSON.stringify(entry));
|
|
14584
|
+
let text = lines.join(`
|
|
14585
|
+
`) + `
|
|
14586
|
+
`;
|
|
14587
|
+
if (text.length > maxBytes) {
|
|
14588
|
+
const keep = Math.max(1, Math.floor(maxEntries / 2));
|
|
14589
|
+
text = lines.slice(-keep).join(`
|
|
14590
|
+
`) + `
|
|
14591
|
+
`;
|
|
14592
|
+
}
|
|
14593
|
+
writeFileSync3(logPath, text, "utf8");
|
|
14594
|
+
} catch {}
|
|
14595
|
+
};
|
|
14596
|
+
const entries = () => {
|
|
14597
|
+
try {
|
|
14598
|
+
return readFileSync4(logPath, "utf8").split(`
|
|
14599
|
+
`).filter((l) => l.trim().length > 0).map((l) => JSON.parse(l));
|
|
14600
|
+
} catch {
|
|
14601
|
+
return [];
|
|
14602
|
+
}
|
|
14603
|
+
};
|
|
14604
|
+
return { append, entries, path: logPath };
|
|
14605
|
+
}
|
|
14606
|
+
|
|
14607
|
+
// src/proc-locate.ts
|
|
14608
|
+
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
14609
|
+
import { readdirSync as readdirSync3, readFileSync as readFileSync5 } from "node:fs";
|
|
14610
|
+
function commandNeedle(command) {
|
|
14611
|
+
const trimmed = command.trim().replace(/^["']|["']$/g, "");
|
|
14612
|
+
if (trimmed.length === 0)
|
|
14613
|
+
return "";
|
|
14614
|
+
const tokens = trimmed.split(/\s+/);
|
|
14615
|
+
const longest = tokens.reduce((a, b) => b.length > a.length ? b : a, "");
|
|
14616
|
+
return longest.length >= 6 ? longest : trimmed;
|
|
14617
|
+
}
|
|
14618
|
+
function markerValue(callID) {
|
|
14619
|
+
return `opencode-forge:${callID}`;
|
|
14620
|
+
}
|
|
14621
|
+
function createPosixLocator(deps = {}) {
|
|
14622
|
+
const listPids = deps.listPids ?? (() => readdirSync3("/proc").map(Number).filter((n) => Number.isInteger(n) && n > 0));
|
|
14623
|
+
const readEnv = deps.readEnv ?? ((pid) => {
|
|
14624
|
+
try {
|
|
14625
|
+
return readFileSync5(`/proc/${pid}/environ`);
|
|
14626
|
+
} catch {
|
|
14627
|
+
return null;
|
|
14628
|
+
}
|
|
14629
|
+
});
|
|
14630
|
+
const readCmd = deps.readCmd ?? ((pid) => {
|
|
14631
|
+
try {
|
|
14632
|
+
return readFileSync5(`/proc/${pid}/cmdline`).toString("utf8").split("\x00").filter(Boolean).join(" ");
|
|
14633
|
+
} catch {
|
|
14634
|
+
return String(pid);
|
|
14635
|
+
}
|
|
14636
|
+
});
|
|
14637
|
+
return async (callID, _t0) => {
|
|
14638
|
+
const want = `${WATCHDOG_ENV_MARK}=${markerValue(callID)}`;
|
|
14639
|
+
const hits = [];
|
|
14640
|
+
for (const pid of listPids()) {
|
|
14641
|
+
const env = readEnv(pid);
|
|
14642
|
+
if (!env)
|
|
14643
|
+
continue;
|
|
14644
|
+
if (env.toString("utf8").split("\x00").includes(want))
|
|
14645
|
+
hits.push({ pid, cmd: readCmd(pid) });
|
|
14646
|
+
}
|
|
14647
|
+
return hits;
|
|
14648
|
+
};
|
|
14649
|
+
}
|
|
14650
|
+
var PS_LIST_PROCS = "[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; " + "Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CommandLine,CreationDate " + "| ConvertTo-Json -Compress";
|
|
14651
|
+
function parseWindowsProcs(raw) {
|
|
14652
|
+
let parsed;
|
|
14653
|
+
try {
|
|
14654
|
+
parsed = JSON.parse(raw);
|
|
14655
|
+
} catch {
|
|
14656
|
+
return [];
|
|
14657
|
+
}
|
|
14658
|
+
const arr = Array.isArray(parsed) ? parsed : [parsed];
|
|
14659
|
+
const out = [];
|
|
14660
|
+
for (const p of arr) {
|
|
14661
|
+
const pid = Number(p?.ProcessId ?? p?.pid);
|
|
14662
|
+
const ppid = Number(p?.ParentProcessId ?? p?.ppid);
|
|
14663
|
+
if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(ppid))
|
|
14664
|
+
continue;
|
|
14665
|
+
const cmd = typeof p?.CommandLine === "string" ? p.CommandLine : "";
|
|
14666
|
+
let createdMs = Number.NaN;
|
|
14667
|
+
const cd = p?.CreationDate ?? p?.createdMs;
|
|
14668
|
+
if (typeof cd === "number")
|
|
14669
|
+
createdMs = cd;
|
|
14670
|
+
else if (typeof cd === "string") {
|
|
14671
|
+
const slash = /\/Date\((\d+)\)\//.exec(cd);
|
|
14672
|
+
if (slash)
|
|
14673
|
+
createdMs = Number(slash[1]);
|
|
14674
|
+
else {
|
|
14675
|
+
const t = Date.parse(cd);
|
|
14676
|
+
if (!Number.isNaN(t))
|
|
14677
|
+
createdMs = t;
|
|
14678
|
+
}
|
|
14679
|
+
}
|
|
14680
|
+
if (Number.isNaN(createdMs))
|
|
14681
|
+
continue;
|
|
14682
|
+
out.push({ pid, ppid, cmd, createdMs });
|
|
14683
|
+
}
|
|
14684
|
+
return out;
|
|
14685
|
+
}
|
|
14686
|
+
var WINDOW_SLACK_MS = 2000;
|
|
14687
|
+
function createWindowsLocator(deps = {}) {
|
|
14688
|
+
const hostPid = deps.hostPid ?? process.pid;
|
|
14689
|
+
const execFn = deps.execFn ?? ((cmd) => execFileSync2("powershell", ["-NoProfile", "-Command", cmd], { encoding: "utf8", windowsHide: true, timeout: 15000 }));
|
|
14690
|
+
return async (callID, t0, cmdNeedle, phase2 = false) => {
|
|
14691
|
+
let raw = "[]";
|
|
14692
|
+
try {
|
|
14693
|
+
raw = execFn(PS_LIST_PROCS);
|
|
14694
|
+
} catch {
|
|
14695
|
+
return [];
|
|
14696
|
+
}
|
|
14697
|
+
const procs = parseWindowsProcs(raw);
|
|
14698
|
+
const children = new Map;
|
|
14699
|
+
for (const p of procs) {
|
|
14700
|
+
const list = children.get(p.ppid) ?? [];
|
|
14701
|
+
list.push(p.pid);
|
|
14702
|
+
children.set(p.ppid, list);
|
|
14703
|
+
}
|
|
14704
|
+
const descendants = new Set;
|
|
14705
|
+
const queue = [hostPid];
|
|
14706
|
+
while (queue.length > 0) {
|
|
14707
|
+
const cur = queue.pop();
|
|
14708
|
+
for (const child of children.get(cur) ?? []) {
|
|
14709
|
+
if (!descendants.has(child)) {
|
|
14710
|
+
descendants.add(child);
|
|
14711
|
+
queue.push(child);
|
|
14712
|
+
}
|
|
14713
|
+
}
|
|
14714
|
+
}
|
|
14715
|
+
const windowStart = t0 - WINDOW_SLACK_MS;
|
|
14716
|
+
const norm = (s) => s.replace(/\\/g, "/");
|
|
14717
|
+
const slash = cmdNeedle !== undefined ? cmdNeedle.lastIndexOf("/") : -1;
|
|
14718
|
+
const dirNeedle = phase2 && cmdNeedle !== undefined && slash > 0 ? norm(cmdNeedle.slice(0, slash)) : undefined;
|
|
14719
|
+
return procs.filter((p) => p.createdMs >= windowStart && p.pid !== hostPid && !/Win32_Process/.test(p.cmd) && !/conhost\.exe/i.test(p.cmd) && (descendants.has(p.pid) || cmdNeedle !== undefined && cmdNeedle.length >= 6 && p.cmd.includes(cmdNeedle) || dirNeedle !== undefined && dirNeedle.length >= 6 && norm(p.cmd).includes(dirNeedle))).map((p) => ({ pid: p.pid, cmd: p.cmd, createdMs: p.createdMs }));
|
|
14720
|
+
};
|
|
14721
|
+
}
|
|
14722
|
+
function createLocator(platform = process.platform, posix = {}, win = {}) {
|
|
14723
|
+
return platform === "win32" ? createWindowsLocator(win) : createPosixLocator(posix);
|
|
14724
|
+
}
|
|
14725
|
+
|
|
13252
14726
|
// plugin.ts
|
|
13253
14727
|
var FORGE_AGENT = "forge";
|
|
13254
14728
|
var FORGE_PROMPT = `You are forge — the single general-purpose coding agent. You handle every task directly: exploration, planning, implementation, and verification. There is no agent switching.
|
|
@@ -13312,6 +14786,109 @@ function effectiveWorktree(worktree, fallback) {
|
|
|
13312
14786
|
}
|
|
13313
14787
|
var sessions = new Map;
|
|
13314
14788
|
var forgeDisabled = false;
|
|
14789
|
+
var jobsMode = "auto";
|
|
14790
|
+
var jobsKeepBuiltinShell = false;
|
|
14791
|
+
var jobsSurviveMode = "never";
|
|
14792
|
+
function effectiveSurvive(config2, param) {
|
|
14793
|
+
if (config2 === "deny") {
|
|
14794
|
+
if (param === true)
|
|
14795
|
+
throw new Error("[forge] jobs.survive is explicitly denied in config; a per-call survive=true cannot override it.");
|
|
14796
|
+
return false;
|
|
14797
|
+
}
|
|
14798
|
+
if (param !== undefined)
|
|
14799
|
+
return param;
|
|
14800
|
+
return config2 === "always";
|
|
14801
|
+
}
|
|
14802
|
+
var jobFence = null;
|
|
14803
|
+
var jobRegistry = null;
|
|
14804
|
+
var exitCleanup = null;
|
|
14805
|
+
var jobsLifecycleStarted = false;
|
|
14806
|
+
function ensureJobLifecycle() {
|
|
14807
|
+
if (jobsLifecycleStarted)
|
|
14808
|
+
return { registry: jobRegistry, fence: jobFence };
|
|
14809
|
+
jobsLifecycleStarted = true;
|
|
14810
|
+
const registry2 = createJobRegistry(registryPathFor(jobLogDir));
|
|
14811
|
+
jobRegistry = registry2;
|
|
14812
|
+
const { adopted, dead } = registry2.rescan(pidAlive, structuralRelocate);
|
|
14813
|
+
for (const entry of adopted) {
|
|
14814
|
+
adoptSurvivor(jobManager, entry, { registry: registry2, logDir: jobLogDir, relocate: structuralRelocate });
|
|
14815
|
+
}
|
|
14816
|
+
for (const entry of dead) {
|
|
14817
|
+
jobLedgerSink({ at: new Date().toISOString(), kind: "orphan-job", jobId: entry.id, session: entry.ownerSession, detail: `previous-run survivor pid ${entry.pid} is dead (cmd: ${entry.cmd.slice(0, 120)})` });
|
|
14818
|
+
}
|
|
14819
|
+
jobFence = process.env.FORGE_TEST_NO_FENCE === "1" ? null : createJobFence({
|
|
14820
|
+
onDegrade: (reason) => {
|
|
14821
|
+
jobLedgerSink({ at: new Date().toISOString(), kind: "fence-degraded", jobId: "-", session: "-", detail: reason });
|
|
14822
|
+
}
|
|
14823
|
+
});
|
|
14824
|
+
exitCleanup = createExitCleanup(jobManager, {
|
|
14825
|
+
graceMs: 3000,
|
|
14826
|
+
after: () => {
|
|
14827
|
+
jobFence?.dispose();
|
|
14828
|
+
}
|
|
14829
|
+
});
|
|
14830
|
+
return { registry: registry2, fence: jobFence };
|
|
14831
|
+
}
|
|
14832
|
+
var nativeBackgroundSeen = false;
|
|
14833
|
+
function jobStage() {
|
|
14834
|
+
if (jobsMode === "native")
|
|
14835
|
+
return 2;
|
|
14836
|
+
if (jobsMode === "forge")
|
|
14837
|
+
return 0;
|
|
14838
|
+
return nativeBackgroundSeen ? 1 : 0;
|
|
14839
|
+
}
|
|
14840
|
+
var jobLogDir = jobsLogDir();
|
|
14841
|
+
var jobLedgerPath = join4(jobLogDir, "ledger.jsonl");
|
|
14842
|
+
function jobLedgerSink(entry) {
|
|
14843
|
+
try {
|
|
14844
|
+
mkdirSync4(jobLogDir, { recursive: true });
|
|
14845
|
+
if (existsSync3(jobLedgerPath) && statSync3(jobLedgerPath).size > 1e6)
|
|
14846
|
+
writeFileSync4(jobLedgerPath, "");
|
|
14847
|
+
appendFileSync(jobLedgerPath, `${JSON.stringify(entry)}
|
|
14848
|
+
`);
|
|
14849
|
+
} catch {}
|
|
14850
|
+
}
|
|
14851
|
+
var jobManager = createJobManager({ sink: jobLedgerSink });
|
|
14852
|
+
var jobClient = null;
|
|
14853
|
+
function jobWakeText(job) {
|
|
14854
|
+
return [
|
|
14855
|
+
`[forge:job-complete] Background job ${job.id} finished (${job.state}${job.exitCode !== null ? `, exit ${job.exitCode}` : ""}).`,
|
|
14856
|
+
`Command: ${job.cmd}`,
|
|
14857
|
+
`Recent output:
|
|
14858
|
+
${job.tail.slice(-1500) || "(none)"}`,
|
|
14859
|
+
"Details via forge_jobs (poll/log); the job stays in the registry until cleared."
|
|
14860
|
+
].join(`
|
|
14861
|
+
`);
|
|
14862
|
+
}
|
|
14863
|
+
async function deliverJobWakes(sessionID) {
|
|
14864
|
+
if (forgeDisabled || jobStage() >= 2)
|
|
14865
|
+
return;
|
|
14866
|
+
if (!jobClient || typeof jobClient.session.promptAsync !== "function")
|
|
14867
|
+
return;
|
|
14868
|
+
for (const job of jobManager.deliverWakesFor(sessionID)) {
|
|
14869
|
+
try {
|
|
14870
|
+
await jobClient.session.promptAsync({ path: { id: sessionID }, body: { parts: [{ type: "text", text: jobWakeText(job) }] } });
|
|
14871
|
+
} catch {
|
|
14872
|
+
job.wakeState = "queued";
|
|
14873
|
+
}
|
|
14874
|
+
}
|
|
14875
|
+
}
|
|
14876
|
+
async function handoffTarget(sessionID) {
|
|
14877
|
+
if (!jobClient || typeof jobClient.session.get !== "function")
|
|
14878
|
+
return;
|
|
14879
|
+
let current = sessionID;
|
|
14880
|
+
for (let depth = 0;depth < 10; depth++) {
|
|
14881
|
+
try {
|
|
14882
|
+
const info = await jobClient.session.get({ path: { id: current } });
|
|
14883
|
+
if (!info?.parentID)
|
|
14884
|
+
return current === sessionID ? undefined : current;
|
|
14885
|
+
current = info.parentID;
|
|
14886
|
+
} catch {
|
|
14887
|
+
return;
|
|
14888
|
+
}
|
|
14889
|
+
}
|
|
14890
|
+
return current === sessionID ? undefined : current;
|
|
14891
|
+
}
|
|
13315
14892
|
var WRITE_TOOLS = new Set(["write", "edit", "bash", "task", "apply", "applypatch", "patch", "multiedit"]);
|
|
13316
14893
|
function isWriteTool(name) {
|
|
13317
14894
|
const n = name.toLowerCase();
|
|
@@ -13321,13 +14898,13 @@ function nowIso() {
|
|
|
13321
14898
|
return new Date().toISOString();
|
|
13322
14899
|
}
|
|
13323
14900
|
function planDirOf(worktree) {
|
|
13324
|
-
return
|
|
14901
|
+
return join4(worktree, ".opencode", "plan");
|
|
13325
14902
|
}
|
|
13326
14903
|
function readPlanDir(worktree) {
|
|
13327
14904
|
const dir = planDirOf(worktree);
|
|
13328
|
-
if (!
|
|
14905
|
+
if (!existsSync3(dir))
|
|
13329
14906
|
return [];
|
|
13330
|
-
return
|
|
14907
|
+
return readdirSync4(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync6(join4(dir, name), "utf8") }));
|
|
13331
14908
|
}
|
|
13332
14909
|
function ensureSession(sessionID, worktree) {
|
|
13333
14910
|
const existing = sessions.get(sessionID);
|
|
@@ -13343,8 +14920,8 @@ function worktreeFor(context) {
|
|
|
13343
14920
|
return effectiveWorktree(context.worktree, hostWorktree) || context.worktree;
|
|
13344
14921
|
}
|
|
13345
14922
|
function resolveActivePlan(state) {
|
|
13346
|
-
if (state.planPath &&
|
|
13347
|
-
const doc2 = parsePlanLoose(
|
|
14923
|
+
if (state.planPath && existsSync3(state.planPath)) {
|
|
14924
|
+
const doc2 = parsePlanLoose(readFileSync6(state.planPath, "utf8"));
|
|
13348
14925
|
if (doc2 && !isTerminal(doc2.status))
|
|
13349
14926
|
return { path: state.planPath, doc: doc2 };
|
|
13350
14927
|
state.planPath = undefined;
|
|
@@ -13352,7 +14929,7 @@ function resolveActivePlan(state) {
|
|
|
13352
14929
|
const ranked = rankActivePlans(readPlanDir(state.worktree));
|
|
13353
14930
|
if (ranked.length === 0)
|
|
13354
14931
|
return null;
|
|
13355
|
-
const path =
|
|
14932
|
+
const path = join4(planDirOf(state.worktree), ranked[0].name);
|
|
13356
14933
|
state.planPath = path;
|
|
13357
14934
|
return { path, doc: ranked[0].doc };
|
|
13358
14935
|
}
|
|
@@ -13386,12 +14963,12 @@ var planWriteTool = tool({
|
|
|
13386
14963
|
throw new PlanError(`A plan in ${active.doc.status} state is already active (${relFrom(state.worktree, active.path)}). Finish it with plan_close, or /plan discard it before planning something new.`);
|
|
13387
14964
|
} else {
|
|
13388
14965
|
const dir = planDirOf(state.worktree);
|
|
13389
|
-
|
|
13390
|
-
path =
|
|
14966
|
+
mkdirSync4(dir, { recursive: true });
|
|
14967
|
+
path = join4(dir, planFileName(localDate(), slugify(args.goal), readdirSync4(dir).filter((f) => f.endsWith(".md"))));
|
|
13391
14968
|
mode = "created";
|
|
13392
14969
|
}
|
|
13393
14970
|
const text = renderPlan(args, now, created);
|
|
13394
|
-
|
|
14971
|
+
writeFileSync4(path, text);
|
|
13395
14972
|
state.planPath = path;
|
|
13396
14973
|
const doc2 = parsePlan(text);
|
|
13397
14974
|
context.metadata({ title: `${mode === "created" ? "Create" : "Revise"} plan: ${doc2.goal}` });
|
|
@@ -13419,8 +14996,8 @@ var planTickTool = tool({
|
|
|
13419
14996
|
if (active.doc.status !== "approved") {
|
|
13420
14997
|
throw new PlanError(`Plan status is ${active.doc.status}; only an approved plan can be ticked. Get user approval via plan_approve first.`);
|
|
13421
14998
|
}
|
|
13422
|
-
const next = tickTask(
|
|
13423
|
-
|
|
14999
|
+
const next = tickTask(readFileSync6(active.path, "utf8"), args.n, nowIso());
|
|
15000
|
+
writeFileSync4(active.path, next);
|
|
13424
15001
|
const doc2 = parsePlan(next);
|
|
13425
15002
|
const p = progressOf(doc2);
|
|
13426
15003
|
context.metadata({ title: `Tick task ${args.n} (${p.done}/${p.total})` });
|
|
@@ -13447,7 +15024,7 @@ var planApproveTool = tool({
|
|
|
13447
15024
|
throw new PlanError(`Plan status is ${active.doc.status}; only a draft plan can be approved.`);
|
|
13448
15025
|
}
|
|
13449
15026
|
await gate(context.ask, "plan_approve", `Approve plan: ${active.doc.goal}`);
|
|
13450
|
-
|
|
15027
|
+
writeFileSync4(active.path, transitionStatus(readFileSync6(active.path, "utf8"), "approved", nowIso()));
|
|
13451
15028
|
context.metadata({ title: `Plan approved: ${active.doc.goal}` });
|
|
13452
15029
|
return {
|
|
13453
15030
|
title: "plan approved",
|
|
@@ -13480,7 +15057,7 @@ var planCloseTool = tool({
|
|
|
13480
15057
|
Fix the implementation and retry, or revise the plan first.`);
|
|
13481
15058
|
}
|
|
13482
15059
|
await gate(context.ask, "plan_close", `Close plan: ${active.doc.goal}`);
|
|
13483
|
-
|
|
15060
|
+
writeFileSync4(active.path, transitionStatus(readFileSync6(active.path, "utf8"), "done", nowIso()));
|
|
13484
15061
|
context.metadata({ title: `Plan done: ${active.doc.goal}` });
|
|
13485
15062
|
return {
|
|
13486
15063
|
title: "plan done",
|
|
@@ -13498,31 +15075,31 @@ var planDiscardTool = tool({
|
|
|
13498
15075
|
const active = resolveActivePlan(state);
|
|
13499
15076
|
if (!active)
|
|
13500
15077
|
throw new PlanError("No plan to abandon in this workspace.");
|
|
13501
|
-
|
|
15078
|
+
writeFileSync4(active.path, transitionStatus(readFileSync6(active.path, "utf8"), "abandoned", nowIso()));
|
|
13502
15079
|
state.planPath = undefined;
|
|
13503
15080
|
context.metadata({ title: `Plan abandoned: ${active.doc.goal}` });
|
|
13504
15081
|
return { title: "plan abandoned", output: `Plan abandoned: ${relFrom(state.worktree, active.path)}. Write operations are restored.` };
|
|
13505
15082
|
}
|
|
13506
15083
|
});
|
|
13507
15084
|
function goalDirOf(worktree) {
|
|
13508
|
-
return
|
|
15085
|
+
return join4(worktree, ".opencode", "goal");
|
|
13509
15086
|
}
|
|
13510
15087
|
function readGoalDir(worktree) {
|
|
13511
15088
|
const dir = goalDirOf(worktree);
|
|
13512
|
-
if (!
|
|
15089
|
+
if (!existsSync3(dir))
|
|
13513
15090
|
return [];
|
|
13514
|
-
return
|
|
15091
|
+
return readdirSync4(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync6(join4(dir, name), "utf8") }));
|
|
13515
15092
|
}
|
|
13516
15093
|
function resolveSessionGoal(state) {
|
|
13517
|
-
if (state.goalPath &&
|
|
13518
|
-
const doc2 = parseGoalLoose(
|
|
15094
|
+
if (state.goalPath && existsSync3(state.goalPath)) {
|
|
15095
|
+
const doc2 = parseGoalLoose(readFileSync6(state.goalPath, "utf8"));
|
|
13519
15096
|
if (doc2 && !isGoalTerminal(doc2.status))
|
|
13520
15097
|
return { path: state.goalPath, doc: doc2 };
|
|
13521
15098
|
state.goalPath = undefined;
|
|
13522
15099
|
}
|
|
13523
15100
|
const live = rankLiveGoals(readGoalDir(state.worktree))[0];
|
|
13524
15101
|
if (live) {
|
|
13525
|
-
const path =
|
|
15102
|
+
const path = join4(goalDirOf(state.worktree), live.name);
|
|
13526
15103
|
state.goalPath = path;
|
|
13527
15104
|
return { path, doc: live.doc };
|
|
13528
15105
|
}
|
|
@@ -13619,9 +15196,9 @@ var goalWriteTool = tool({
|
|
|
13619
15196
|
await gate(context.ask, "goal_write", `Arm goal: ${args.goal}`);
|
|
13620
15197
|
}
|
|
13621
15198
|
const dir = goalDirOf(state.worktree);
|
|
13622
|
-
|
|
13623
|
-
const name = goalFileName(localDateNow(), slugifyGoal(args.goal),
|
|
13624
|
-
const path =
|
|
15199
|
+
mkdirSync4(dir, { recursive: true });
|
|
15200
|
+
const name = goalFileName(localDateNow(), slugifyGoal(args.goal), readdirSync4(dir).filter((f) => f.endsWith(".md")));
|
|
15201
|
+
const path = join4(dir, name);
|
|
13625
15202
|
const text = renderGoal(input, {
|
|
13626
15203
|
now,
|
|
13627
15204
|
status: arm ? "active" : "queued",
|
|
@@ -13661,7 +15238,7 @@ var goalCheckTool = tool({
|
|
|
13661
15238
|
o.index = selected[i].n;
|
|
13662
15239
|
});
|
|
13663
15240
|
const runId = `${nowIso()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
13664
|
-
const next = appendCheckLog(
|
|
15241
|
+
const next = appendCheckLog(readFileSync6(goal.path, "utf8"), runId, outcomes, nowIso());
|
|
13665
15242
|
atomicWrite(goal.path, next);
|
|
13666
15243
|
const ok = outcomesAllOk(outcomes);
|
|
13667
15244
|
context.metadata({ title: `goal_check: ${outcomes.filter((o) => o.ok).length}/${outcomes.length} pass` });
|
|
@@ -13695,7 +15272,7 @@ var goalCompleteTool = tool({
|
|
|
13695
15272
|
const failures = outcomes.filter((o) => !o.ok);
|
|
13696
15273
|
if (failures.length > 0) {
|
|
13697
15274
|
const runId2 = `${nowIso()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
13698
|
-
atomicWrite(goal.path, appendCheckLog(
|
|
15275
|
+
atomicWrite(goal.path, appendCheckLog(readFileSync6(goal.path, "utf8"), runId2, outcomes, nowIso()));
|
|
13699
15276
|
throw new GoalError(`Completion gate: verification re-run failed (fail-closed). The goal stays active.
|
|
13700
15277
|
${formatOutcomes(failures)}
|
|
13701
15278
|
Fix the work and retry; recorded results never substitute for the gate's own re-run.`);
|
|
@@ -13708,7 +15285,7 @@ Fix the work and retry; recorded results never substitute for the gate's own re-
|
|
|
13708
15285
|
}
|
|
13709
15286
|
await gate(context.ask, "goal_complete", `Complete goal: ${goal.doc.goal}`);
|
|
13710
15287
|
const runId = `${nowIso()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
13711
|
-
let text = appendCheckLog(
|
|
15288
|
+
let text = appendCheckLog(readFileSync6(goal.path, "utf8"), runId, outcomes, nowIso());
|
|
13712
15289
|
text = transitionGoal(text, "completed", nowIso());
|
|
13713
15290
|
atomicWrite(goal.path, text);
|
|
13714
15291
|
context.metadata({ title: `Goal completed: ${goal.doc.goal}` });
|
|
@@ -13730,7 +15307,7 @@ var goalPauseTool = tool({
|
|
|
13730
15307
|
throw new GoalError(`No active goal to pause (status: ${goal?.doc.status ?? "none"}).`);
|
|
13731
15308
|
}
|
|
13732
15309
|
const stopReason = args.blocker ? "blocker" : "user";
|
|
13733
|
-
atomicWrite(goal.path, transitionGoal(
|
|
15310
|
+
atomicWrite(goal.path, transitionGoal(readFileSync6(goal.path, "utf8"), "paused", nowIso(), { stopReason }));
|
|
13734
15311
|
engineForgetSession(context.sessionID);
|
|
13735
15312
|
context.metadata({ title: `Goal paused (${stopReason}): ${goal.doc.goal}` });
|
|
13736
15313
|
return {
|
|
@@ -13750,7 +15327,7 @@ var goalResumeTool = tool({
|
|
|
13750
15327
|
if (!goal || goal.doc.status === "queued") {
|
|
13751
15328
|
const oldest = rankQueuedGoals(readGoalDir(state.worktree))[0];
|
|
13752
15329
|
if (oldest) {
|
|
13753
|
-
const path =
|
|
15330
|
+
const path = join4(goalDirOf(state.worktree), oldest.name);
|
|
13754
15331
|
state.goalPath = path;
|
|
13755
15332
|
goal = { path, doc: oldest.doc };
|
|
13756
15333
|
}
|
|
@@ -13760,7 +15337,7 @@ var goalResumeTool = tool({
|
|
|
13760
15337
|
}
|
|
13761
15338
|
const promoting = goal.doc.status === "queued";
|
|
13762
15339
|
await gate(context.ask, "goal_resume", `${promoting ? "Promote" : "Resume"} goal: ${goal.doc.goal}`);
|
|
13763
|
-
let text =
|
|
15340
|
+
let text = readFileSync6(goal.path, "utf8");
|
|
13764
15341
|
if (args.addTurns)
|
|
13765
15342
|
text = bumpBudget(text, args.addTurns, nowIso());
|
|
13766
15343
|
text = transitionGoal(text, "active", nowIso(), { session: context.sessionID });
|
|
@@ -13786,7 +15363,7 @@ var goalDiscardTool = tool({
|
|
|
13786
15363
|
if (!goal)
|
|
13787
15364
|
throw new GoalError("No goal to discard in this workspace.");
|
|
13788
15365
|
await gate(context.ask, "goal_discard", `Discard goal: ${goal.doc.goal}`);
|
|
13789
|
-
atomicWrite(goal.path, transitionGoal(
|
|
15366
|
+
atomicWrite(goal.path, transitionGoal(readFileSync6(goal.path, "utf8"), "abandoned", nowIso()));
|
|
13790
15367
|
state.goalPath = undefined;
|
|
13791
15368
|
engineForgetSession(context.sessionID);
|
|
13792
15369
|
context.metadata({ title: `Goal abandoned: ${goal.doc.goal}` });
|
|
@@ -13796,8 +15373,192 @@ var goalDiscardTool = tool({
|
|
|
13796
15373
|
};
|
|
13797
15374
|
}
|
|
13798
15375
|
});
|
|
15376
|
+
var FORGE_SHELL_DESCRIPTION = [
|
|
15377
|
+
"Run a shell command without ever blocking the session indefinitely. The call completes on the FIRST of: process exit (bound to the exit event — a detached grandchild holding the stdio pipes cannot suspend the call); success_pattern matching new output (opt-in regex); idle_ms with no new output (default 60000); max_wait_ms hard cap (default 120000, max 600000 — returns still-running, never kills).",
|
|
15378
|
+
"Idle/max-wait return `still-running` with a jobId — the process stays alive; keep watching with forge_jobs poll / log, stop it with forge_jobs kill. run_in_background returns {jobId, logPath} immediately.",
|
|
15379
|
+
"success_pattern semantics: a match completes the call as success; the process is kept alive by default (server semantics — the thing you just verified keeps running); pass keep_alive=false to kill its tree on match. Common patterns: dev servers `listening on|ready in|Local:`, builds `Compiled successfully|Done in`, test suites `passed|all tests`.",
|
|
15380
|
+
"Long-running or possibly non-exiting commands (dev servers, watchers, installers, anything spawning detached children) MUST use this tool instead of the builtin shell."
|
|
15381
|
+
].join(`
|
|
15382
|
+
`);
|
|
15383
|
+
var STAGE1_NOTE = "Stage note: this host's builtin shell already offers a native run_in_background parameter — prefer that for plain backgrounding; keep using forge_shell when you need idle/success_pattern early return or exit wake messages.";
|
|
15384
|
+
var forgeShellTool = tool({
|
|
15385
|
+
description: FORGE_SHELL_DESCRIPTION,
|
|
15386
|
+
args: {
|
|
15387
|
+
command: tool.schema.string().describe("The shell command to run"),
|
|
15388
|
+
workdir: tool.schema.string().optional().describe("Working directory (workspace-relative, or absolute)"),
|
|
15389
|
+
run_in_background: tool.schema.boolean().optional().describe("Return {jobId, logPath} immediately, without waiting for any output or exit"),
|
|
15390
|
+
idle_ms: tool.schema.number().int().nonnegative().optional().describe("No-new-output early-return threshold in ms (default 60000)"),
|
|
15391
|
+
max_wait_ms: tool.schema.number().int().nonnegative().optional().describe("Hard cap on this call's wait in ms (default 120000, clamped to 600000); returns still-running, never kills"),
|
|
15392
|
+
success_pattern: tool.schema.string().optional().describe("Regex; a match against new output completes the call as success immediately"),
|
|
15393
|
+
keep_alive: tool.schema.boolean().optional().describe("After a success match: keep the process alive (default) or kill its tree (false)"),
|
|
15394
|
+
notify: tool.schema.boolean().optional().describe("Send a [forge:job-complete] message into this session when the job exits (default true)"),
|
|
15395
|
+
survive: tool.schema.boolean().optional().describe("Opt this job OUT of dying with the host: it keeps running after opencode exits, recorded in the persistent registry so the next run can poll/kill it (config jobs.survive sets the default; an explicit config deny cannot be overridden)")
|
|
15396
|
+
},
|
|
15397
|
+
execute: async (args, context) => {
|
|
15398
|
+
if (jobStage() >= 2) {
|
|
15399
|
+
throw new Error("[forge] The job supervisor is retired on this host (native backgrounding confirmed complete) — use the shell tool's run_in_background parameter.");
|
|
15400
|
+
}
|
|
15401
|
+
const state = ensureSession(context.sessionID, worktreeFor(context));
|
|
15402
|
+
await gate(context.ask, "forge_shell", `forge_shell: ${args.command.slice(0, 100)}`);
|
|
15403
|
+
const survive = effectiveSurvive(jobsSurviveMode, args.survive);
|
|
15404
|
+
let successPattern = null;
|
|
15405
|
+
if (args.success_pattern) {
|
|
15406
|
+
try {
|
|
15407
|
+
successPattern = new RegExp(args.success_pattern);
|
|
15408
|
+
} catch (err) {
|
|
15409
|
+
throw new Error(`Invalid success_pattern: ${err.message}`);
|
|
15410
|
+
}
|
|
15411
|
+
}
|
|
15412
|
+
const { registry: registry2, fence } = ensureJobLifecycle();
|
|
15413
|
+
const cwd = args.workdir ? isAbsolute(args.workdir) ? args.workdir : join4(state.worktree, args.workdir) : state.worktree;
|
|
15414
|
+
const started = startJob(jobManager, {
|
|
15415
|
+
cmd: args.command,
|
|
15416
|
+
cwd,
|
|
15417
|
+
ownerSession: context.sessionID,
|
|
15418
|
+
worktree: state.worktree,
|
|
15419
|
+
logDir: jobLogDir,
|
|
15420
|
+
runInBackground: args.run_in_background === true,
|
|
15421
|
+
...args.idle_ms !== undefined ? { idleMs: args.idle_ms } : {},
|
|
15422
|
+
...args.max_wait_ms !== undefined ? { maxWaitMs: args.max_wait_ms } : {},
|
|
15423
|
+
successPattern,
|
|
15424
|
+
...args.keep_alive !== undefined ? { keepAlive: args.keep_alive } : {},
|
|
15425
|
+
...args.notify !== undefined ? { notify: args.notify } : {},
|
|
15426
|
+
...survive ? { survive: true, registry: registry2 ?? undefined } : { fence, relocate: structuralRelocate, relocateAsync: structuralRelocateAsync }
|
|
15427
|
+
});
|
|
15428
|
+
if (survive) {
|
|
15429
|
+
return {
|
|
15430
|
+
title: `job started (survives host exit): ${started.job.id}`,
|
|
15431
|
+
output: [
|
|
15432
|
+
`[forge:job] Started in background — SURVIVES host exit (no fence, no exit kill).`,
|
|
15433
|
+
`jobId: ${started.job.id}`,
|
|
15434
|
+
`logPath: ${started.job.logPath}`,
|
|
15435
|
+
"Recorded in the persistent registry: the next opencode run can poll/log/kill it via forge_jobs. Stop it explicitly when done."
|
|
15436
|
+
].join(`
|
|
15437
|
+
`)
|
|
15438
|
+
};
|
|
15439
|
+
}
|
|
15440
|
+
context.metadata({ title: `forge_shell: ${args.command.slice(0, 60)}` });
|
|
15441
|
+
if (args.run_in_background === true) {
|
|
15442
|
+
return {
|
|
15443
|
+
title: `job started: ${started.job.id}`,
|
|
15444
|
+
output: [`[forge:job] Started in background.`, `jobId: ${started.job.id}`, `logPath: ${started.job.logPath}`, "Track with forge_jobs poll; a [forge:job-complete] message arrives on exit."].join(`
|
|
15445
|
+
`)
|
|
15446
|
+
};
|
|
15447
|
+
}
|
|
15448
|
+
const r = await started.settle;
|
|
15449
|
+
if (r.status === "exited") {
|
|
15450
|
+
return {
|
|
15451
|
+
title: `exit ${r.exitCode ?? "?"}`,
|
|
15452
|
+
output: [
|
|
15453
|
+
`[forge:job] Command finished (exit=${r.exitCode ?? "none"}).`,
|
|
15454
|
+
...r.spawnError ? [`spawn error: ${r.spawnError}`] : [],
|
|
15455
|
+
`output:
|
|
15456
|
+
${r.outputTail || "(none)"}`
|
|
15457
|
+
].join(`
|
|
15458
|
+
`)
|
|
15459
|
+
};
|
|
15460
|
+
}
|
|
15461
|
+
if (r.status === "succeeded") {
|
|
15462
|
+
return {
|
|
15463
|
+
title: `success: ${r.matched}`,
|
|
15464
|
+
output: [
|
|
15465
|
+
`[forge:job] Success pattern matched: "${r.matched}".`,
|
|
15466
|
+
r.keptAlive ? `The process was kept alive as job ${started.job.id} (stop it with forge_jobs kill when done).` : "The process tree was terminated (keep_alive=false).",
|
|
15467
|
+
`output:
|
|
15468
|
+
${r.outputTail}`
|
|
15469
|
+
].join(`
|
|
15470
|
+
`)
|
|
15471
|
+
};
|
|
15472
|
+
}
|
|
15473
|
+
return {
|
|
15474
|
+
title: `still running (${Math.round(r.idleForMs / 1000)}s idle)`,
|
|
15475
|
+
output: [
|
|
15476
|
+
`[forge:job] Still running — ${Math.round(r.idleForMs / 1000)}s without new output (or the wait budget ran out). The process is alive as job ${started.job.id}.`,
|
|
15477
|
+
`logPath: ${started.job.logPath}`,
|
|
15478
|
+
`recent output:
|
|
15479
|
+
${r.outputTail || "(none yet)"}`,
|
|
15480
|
+
"Next: forge_jobs poll to keep waiting, forge_jobs log for history, forge_jobs kill to stop."
|
|
15481
|
+
].join(`
|
|
15482
|
+
`)
|
|
15483
|
+
};
|
|
15484
|
+
}
|
|
15485
|
+
});
|
|
15486
|
+
var FORGE_JOBS_ACTIONS = ["list", "poll", "log", "kill", "clear", "handoff"];
|
|
15487
|
+
var forgeJobsTool = tool({
|
|
15488
|
+
description: "Manage forge_shell jobs. Actions: list (all jobs, newest first); poll {jobId, waitMs<=30000} — bounded wait for NEW output or exit, drains it; log {jobId, offset?, limit?} — line paging over the on-disk log (omitted offset = tail window, default 200 lines); kill {jobId} — terminate the job's whole process tree; clear {jobId} — drop a finished job from the registry; handoff {jobId} — rebind ownership to the root session so the job survives this (sub)session's end. A delegated agent MUST poll its jobs before yielding its conclusion.",
|
|
15489
|
+
args: {
|
|
15490
|
+
action: tool.schema.string().describe(`One of: ${FORGE_JOBS_ACTIONS.join(", ")}`),
|
|
15491
|
+
jobId: tool.schema.string().optional().describe("Job id from forge_shell (required for every action except list)"),
|
|
15492
|
+
waitMs: tool.schema.number().int().nonnegative().optional().describe("poll: bounded in-call wait (clamped to 30000)"),
|
|
15493
|
+
offset: tool.schema.number().int().nonnegative().optional().describe("log: first line index; omitted = tail window"),
|
|
15494
|
+
limit: tool.schema.number().int().positive().optional().describe("log: max lines (default 200)")
|
|
15495
|
+
},
|
|
15496
|
+
execute: async (args, context) => {
|
|
15497
|
+
if (jobStage() >= 2)
|
|
15498
|
+
throw new Error("[forge] The job supervisor is retired on this host.");
|
|
15499
|
+
const action = String(args.action ?? "");
|
|
15500
|
+
if (!FORGE_JOBS_ACTIONS.includes(action)) {
|
|
15501
|
+
throw new Error(`Unknown action "${action}" — use one of: ${FORGE_JOBS_ACTIONS.join(", ")}.`);
|
|
15502
|
+
}
|
|
15503
|
+
if (action === "list") {
|
|
15504
|
+
ensureJobLifecycle();
|
|
15505
|
+
const rows = jobManager.list().map((j) => `${j.id} ${j.state}${j.exitCode !== null ? `(${j.exitCode})` : ""}${j.succeededAt ? "*" : ""} ${j.previousRun ? "previous-run" : j.survive ? "survive" : j.scope} ${j.cmd.slice(0, 60)}`);
|
|
15506
|
+
return { title: `jobs (${rows.length})`, output: rows.length > 0 ? rows.join(`
|
|
15507
|
+
`) : "(no jobs)" };
|
|
15508
|
+
}
|
|
15509
|
+
if (!args.jobId)
|
|
15510
|
+
throw new Error(`Action "${action}" requires jobId.`);
|
|
15511
|
+
const job = jobManager.get(args.jobId);
|
|
15512
|
+
if (!job)
|
|
15513
|
+
throw new Error(`No job ${args.jobId} in the registry (finished jobs age out; the on-disk log may still exist).`);
|
|
15514
|
+
if (action === "poll") {
|
|
15515
|
+
const p = await pollJob(jobManager, args.jobId, args.waitMs ?? 0);
|
|
15516
|
+
if (!p)
|
|
15517
|
+
throw new Error(`No job ${args.jobId}.`);
|
|
15518
|
+
return {
|
|
15519
|
+
title: `${job.id}: ${p.state}`,
|
|
15520
|
+
output: [
|
|
15521
|
+
`[forge:job] ${job.id}: ${p.state}${p.exitCode !== null ? ` exit=${p.exitCode}` : ""}${p.succeeded ? " (success pattern matched)" : ""}`,
|
|
15522
|
+
`new output:
|
|
15523
|
+
${p.newOutput || "(none in this window)"}`,
|
|
15524
|
+
`logPath: ${p.logPath}`
|
|
15525
|
+
].join(`
|
|
15526
|
+
`)
|
|
15527
|
+
};
|
|
15528
|
+
}
|
|
15529
|
+
if (action === "log") {
|
|
15530
|
+
const page = readJobLog(job.logPath, {
|
|
15531
|
+
...args.offset !== undefined ? { offset: args.offset } : {},
|
|
15532
|
+
...args.limit !== undefined ? { limit: args.limit } : {}
|
|
15533
|
+
});
|
|
15534
|
+
return {
|
|
15535
|
+
title: `${job.id} log ${page.offset}-${page.offset + page.lines.length}/${page.total}`,
|
|
15536
|
+
output: [
|
|
15537
|
+
...page.windowed ? [`(log exceeds ${8}MB — showing the most recent window; read the file directly for full history: ${job.logPath})`] : [],
|
|
15538
|
+
page.lines.length > 0 ? page.lines.join(`
|
|
15539
|
+
`) : "(empty)"
|
|
15540
|
+
].join(`
|
|
15541
|
+
`)
|
|
15542
|
+
};
|
|
15543
|
+
}
|
|
15544
|
+
if (action === "kill") {
|
|
15545
|
+
jobManager.kill(job);
|
|
15546
|
+
return { title: `${job.id} killed`, output: `[forge:job] ${job.id}: tree kill issued (state: killed).` };
|
|
15547
|
+
}
|
|
15548
|
+
if (action === "clear") {
|
|
15549
|
+
const ok = jobManager.clear(args.jobId);
|
|
15550
|
+
return ok ? { title: `${job.id} cleared`, output: `[forge:job] ${job.id} removed from the registry (log file untouched).` } : { title: `${job.id} not cleared`, output: `[forge:job] ${job.id} is still running — kill it first.` };
|
|
15551
|
+
}
|
|
15552
|
+
const target = await handoffTarget(context.sessionID);
|
|
15553
|
+
jobManager.handoff(args.jobId, target);
|
|
15554
|
+
return {
|
|
15555
|
+
title: `${job.id} handed off`,
|
|
15556
|
+
output: `[forge:job] ${job.id} promoted to plugin-global scope${target ? ` and rebound to root session ${target}` : ""}; it now survives this session's end.`
|
|
15557
|
+
};
|
|
15558
|
+
}
|
|
15559
|
+
});
|
|
13799
15560
|
function forgeTools() {
|
|
13800
|
-
|
|
15561
|
+
const tools = {
|
|
13801
15562
|
plan_write: planWriteTool,
|
|
13802
15563
|
plan_tick: planTickTool,
|
|
13803
15564
|
plan_approve: planApproveTool,
|
|
@@ -13810,6 +15571,11 @@ function forgeTools() {
|
|
|
13810
15571
|
goal_resume: goalResumeTool,
|
|
13811
15572
|
goal_discard: goalDiscardTool
|
|
13812
15573
|
};
|
|
15574
|
+
if (jobStage() < 2) {
|
|
15575
|
+
tools.forge_shell = forgeShellTool;
|
|
15576
|
+
tools.forge_jobs = forgeJobsTool;
|
|
15577
|
+
}
|
|
15578
|
+
return tools;
|
|
13813
15579
|
}
|
|
13814
15580
|
var hostWorktree = "";
|
|
13815
15581
|
function stateForBan(sessionID) {
|
|
@@ -13832,7 +15598,7 @@ var pendingContinuationTurn = new Set;
|
|
|
13832
15598
|
function goalProbe(line) {
|
|
13833
15599
|
if (process.env.FORGE_GOAL_PROBE) {
|
|
13834
15600
|
try {
|
|
13835
|
-
appendFileSync(
|
|
15601
|
+
appendFileSync(join4(tmpdir2(), "forge-goal-probe.log"), `${new Date().toISOString()} ${line}
|
|
13836
15602
|
`);
|
|
13837
15603
|
} catch {}
|
|
13838
15604
|
}
|
|
@@ -13878,7 +15644,7 @@ function wrapupBriefText(goal, reason) {
|
|
|
13878
15644
|
}
|
|
13879
15645
|
async function autoPauseGoal(client, state, goal, reason, wrapup) {
|
|
13880
15646
|
goalProbe(`auto-pause session=${state.sessionID} reason=${reason} wrapup=${wrapup}`);
|
|
13881
|
-
atomicWrite(goal.path, transitionGoal(
|
|
15647
|
+
atomicWrite(goal.path, transitionGoal(readFileSync6(goal.path, "utf8"), "paused", nowIso(), { stopReason: reason }));
|
|
13882
15648
|
engineForgetSession(state.sessionID);
|
|
13883
15649
|
if (wrapup && goal.doc.session) {
|
|
13884
15650
|
try {
|
|
@@ -13925,11 +15691,11 @@ async function continueIfEligible(client, sessionID) {
|
|
|
13925
15691
|
turnHadActivity = !!act && (act.writes > 0 || act.checks > 0);
|
|
13926
15692
|
turnActivity.set(sessionID, { writes: 0, checks: 0 });
|
|
13927
15693
|
const ledgerPath = state.goalPath;
|
|
13928
|
-
if (ledgerPath &&
|
|
15694
|
+
if (ledgerPath && existsSync3(ledgerPath)) {
|
|
13929
15695
|
try {
|
|
13930
|
-
const fresh = parseGoalLoose(
|
|
15696
|
+
const fresh = parseGoalLoose(readFileSync6(ledgerPath, "utf8"));
|
|
13931
15697
|
if (fresh && fresh.turnsUsed > 0) {
|
|
13932
|
-
atomicWrite(ledgerPath, appendLedger(
|
|
15698
|
+
atomicWrite(ledgerPath, appendLedger(readFileSync6(ledgerPath, "utf8"), { turn: fresh.turnsUsed, revision: fresh.revision, at: nowIso(), activity: turnHadActivity, writes: act?.writes ?? 0, checks: act?.checks ?? 0 }, nowIso()));
|
|
13933
15699
|
}
|
|
13934
15700
|
} catch (err) {
|
|
13935
15701
|
goalProbe(`ledger append failed session=${sessionID} err=${String(err)}`);
|
|
@@ -13990,7 +15756,7 @@ async function continueIfEligible(client, sessionID) {
|
|
|
13990
15756
|
pendingContinuationTurn.add(sessionID);
|
|
13991
15757
|
turnActivity.set(sessionID, { writes: 0, checks: 0 });
|
|
13992
15758
|
state.goalPath = goal.path;
|
|
13993
|
-
atomicWrite(goal.path, incTurns(
|
|
15759
|
+
atomicWrite(goal.path, incTurns(readFileSync6(goal.path, "utf8"), nowIso()));
|
|
13994
15760
|
goalProbe(`continued session=${sessionID} turn=${goal.doc.turnsUsed + 1}/${goal.doc.maxTurns}`);
|
|
13995
15761
|
} catch (err) {
|
|
13996
15762
|
const n = (transportFails.get(sessionID) ?? 0) + 1;
|
|
@@ -14017,12 +15783,82 @@ function scheduleIdleContinuation(client, sessionID) {
|
|
|
14017
15783
|
continueIfEligible(client, sessionID);
|
|
14018
15784
|
}, IDLE_DEBOUNCE_MS));
|
|
14019
15785
|
}
|
|
14020
|
-
var server = async (input) => {
|
|
15786
|
+
var server = async (input, options) => {
|
|
14021
15787
|
hostWorktree = effectiveWorktree(input.worktree, input.directory) || input.directory || "";
|
|
14022
15788
|
const client = input.client;
|
|
15789
|
+
jobClient = input.client;
|
|
15790
|
+
const jobsOpts = options?.jobs;
|
|
15791
|
+
if (jobsOpts?.mode === "auto" || jobsOpts?.mode === "forge" || jobsOpts?.mode === "native")
|
|
15792
|
+
jobsMode = jobsOpts.mode;
|
|
15793
|
+
jobsKeepBuiltinShell = jobsOpts?.keepBuiltinShell === true;
|
|
15794
|
+
if (jobsOpts?.survive === "never" || jobsOpts?.survive === "always" || jobsOpts?.survive === "deny")
|
|
15795
|
+
jobsSurviveMode = jobsOpts.survive;
|
|
15796
|
+
const wdOpts = options?.watchdog;
|
|
15797
|
+
const wdFallbacks = [];
|
|
15798
|
+
const wdMode = parseMode(wdOpts?.mode);
|
|
15799
|
+
if (wdOpts?.mode !== undefined && wdOpts.mode !== wdMode) {
|
|
15800
|
+
wdFallbacks.push(`invalid watchdog.mode ${JSON.stringify(String(wdOpts.mode))} — fell back to "${wdMode}"`);
|
|
15801
|
+
}
|
|
15802
|
+
const wdStallRaw = wdOpts?.stallMs;
|
|
15803
|
+
const wdStall = clampStallMs(typeof wdStallRaw === "number" ? wdStallRaw : undefined);
|
|
15804
|
+
if (wdStallRaw !== undefined && wdStallRaw !== wdStall) {
|
|
15805
|
+
wdFallbacks.push(`watchdog.stallMs ${JSON.stringify(String(wdStallRaw))} adjusted to ${wdStall} (floor/default applied)`);
|
|
15806
|
+
}
|
|
15807
|
+
const watchdogLedger = createFileLedger(join4(watchdogLogDir(), "log.jsonl"));
|
|
15808
|
+
const rawLocator = createLocator();
|
|
15809
|
+
const probing = () => process.env.FORGE_WATCHDOG_PROBE === "1";
|
|
15810
|
+
const probeLine = (text) => {
|
|
15811
|
+
if (!probing())
|
|
15812
|
+
return;
|
|
15813
|
+
try {
|
|
15814
|
+
appendFileSync(join4(tmpdir2(), "forge-watchdog-probe.log"), `${new Date().toISOString()} ${text}
|
|
15815
|
+
`);
|
|
15816
|
+
} catch {}
|
|
15817
|
+
};
|
|
15818
|
+
const diagnosticLocate = async (callID, t0, cmdNeedle, phase2) => {
|
|
15819
|
+
const started = Date.now();
|
|
15820
|
+
const hits = await rawLocator(callID, t0, cmdNeedle, phase2);
|
|
15821
|
+
probeLine(`locate dur=${Date.now() - started}ms hits=${hits.length} phase2=${phase2 === true} needle=${JSON.stringify(cmdNeedle ?? null)}`);
|
|
15822
|
+
if (hits.length === 0 && cmdNeedle) {
|
|
15823
|
+
try {
|
|
15824
|
+
const raw = execFileSync3("powershell", ["-NoProfile", "-Command", "[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CommandLine,CreationDate | ConvertTo-Json -Compress"], { encoding: "utf8", windowsHide: true, timeout: 15000 });
|
|
15825
|
+
const all = parseWindowsProcs(raw);
|
|
15826
|
+
probeLine(`diag rawLen=${raw.length} procs=${all.length} windowStart=${new Date(t0 - 2000).toISOString()}`);
|
|
15827
|
+
for (const p of all) {
|
|
15828
|
+
if (p.cmd.includes(cmdNeedle))
|
|
15829
|
+
probeLine(`diag needle-carrier pid=${p.pid} ppid=${p.ppid} created=${new Date(p.createdMs).toISOString()} cmd=${p.cmd.slice(0, 120)}`);
|
|
15830
|
+
}
|
|
15831
|
+
} catch (err) {
|
|
15832
|
+
probeLine(`diag failed: ${String(err).slice(0, 150)}`);
|
|
15833
|
+
}
|
|
15834
|
+
}
|
|
15835
|
+
return hits;
|
|
15836
|
+
};
|
|
15837
|
+
const watchdog = createWatchdog({
|
|
15838
|
+
mode: wdMode,
|
|
15839
|
+
stallMs: wdStall,
|
|
15840
|
+
sink: (entry) => watchdogLedger.append(entry),
|
|
15841
|
+
locate: diagnosticLocate,
|
|
15842
|
+
killTree: (pid) => killTree({ pid, kill: (sig) => process.kill(pid, sig) })
|
|
15843
|
+
});
|
|
15844
|
+
for (const reason of wdFallbacks) {
|
|
15845
|
+
const entry = {
|
|
15846
|
+
ts: new Date().toISOString(),
|
|
15847
|
+
event: "config-fallback",
|
|
15848
|
+
callID: "-",
|
|
15849
|
+
tool: "config",
|
|
15850
|
+
t0: Date.now(),
|
|
15851
|
+
mode: watchdog.mode,
|
|
15852
|
+
reason
|
|
15853
|
+
};
|
|
15854
|
+
watchdogLedger.append(entry);
|
|
15855
|
+
}
|
|
14023
15856
|
return {
|
|
14024
15857
|
dispose: async () => {
|
|
14025
15858
|
engineForgetAll();
|
|
15859
|
+
exitCleanup?.trigger("dispose");
|
|
15860
|
+
jobManager.disposeAll();
|
|
15861
|
+
watchdog.dispose();
|
|
14026
15862
|
sessions.clear();
|
|
14027
15863
|
},
|
|
14028
15864
|
config: async (cfg) => {
|
|
@@ -14035,12 +15871,22 @@ var server = async (input) => {
|
|
|
14035
15871
|
agentSection[native] = { ...agentSection[native] ?? {}, disable: true };
|
|
14036
15872
|
}
|
|
14037
15873
|
const existing = agentSection[FORGE_AGENT];
|
|
15874
|
+
const userDefinedForge = existing !== undefined;
|
|
14038
15875
|
agentSection[FORGE_AGENT] = {
|
|
14039
15876
|
...existing ?? {},
|
|
14040
15877
|
description: existing?.description ?? "forge — the single general-purpose coding agent: takes implementation tasks directly; planning goes through /plan into the plan harness (plans land in .opencode/plan/, with approve/close confirmation gates and tick discipline enforced by tools and the permission layer).",
|
|
14041
15878
|
mode: existing?.mode ?? "primary",
|
|
14042
15879
|
prompt: existing?.prompt ?? FORGE_PROMPT
|
|
14043
15880
|
};
|
|
15881
|
+
try {
|
|
15882
|
+
const exp = JSON.stringify(cfg.experimental ?? "");
|
|
15883
|
+
if (/background/i.test(exp))
|
|
15884
|
+
nativeBackgroundSeen = true;
|
|
15885
|
+
} catch {}
|
|
15886
|
+
if (!userDefinedForge && !jobsKeepBuiltinShell && jobStage() < 1) {
|
|
15887
|
+
const entry = agentSection[FORGE_AGENT];
|
|
15888
|
+
entry.tools = { ...entry.tools ?? {}, shell: false, bash: false };
|
|
15889
|
+
}
|
|
14044
15890
|
cfg.command ??= {};
|
|
14045
15891
|
cfg.command["plan"] ??= {
|
|
14046
15892
|
template: PLAN_COMMAND_TEMPLATE,
|
|
@@ -14052,7 +15898,7 @@ var server = async (input) => {
|
|
|
14052
15898
|
};
|
|
14053
15899
|
const perm = cfg.permission;
|
|
14054
15900
|
const permSection = perm ?? (cfg.permission = {});
|
|
14055
|
-
for (const gateKey of ["plan_approve", "plan_close", "goal_write", "goal_complete", "goal_resume", "goal_discard"]) {
|
|
15901
|
+
for (const gateKey of ["plan_approve", "plan_close", "goal_write", "goal_complete", "goal_resume", "goal_discard", "forge_shell"]) {
|
|
14056
15902
|
if (permSection[gateKey] !== "deny")
|
|
14057
15903
|
permSection[gateKey] = "ask";
|
|
14058
15904
|
}
|
|
@@ -14060,13 +15906,24 @@ var server = async (input) => {
|
|
|
14060
15906
|
get tool() {
|
|
14061
15907
|
return forgeDisabled ? {} : forgeTools();
|
|
14062
15908
|
},
|
|
14063
|
-
"tool.execute.before": async (input2) => {
|
|
15909
|
+
"tool.execute.before": async (input2, output) => {
|
|
14064
15910
|
if (process.env.FORGE_PERM_PROBE) {
|
|
14065
15911
|
try {
|
|
14066
|
-
appendFileSync(
|
|
15912
|
+
appendFileSync(join4(tmpdir2(), "forge-perm-probe.log"), `${new Date().toISOString()} before tool=${JSON.stringify(input2.tool)} session=${input2.sessionID}
|
|
14067
15913
|
`);
|
|
14068
15914
|
} catch {}
|
|
14069
15915
|
}
|
|
15916
|
+
if (watchdog.mode !== "off" && (input2.tool === "shell" || input2.tool === "bash")) {
|
|
15917
|
+
const a = output?.args ?? {};
|
|
15918
|
+
const cmdText = typeof a.command === "string" ? a.command : typeof a.cmd === "string" ? a.cmd : undefined;
|
|
15919
|
+
watchdog.track(input2.callID, input2.sessionID, input2.tool, undefined, cmdText !== undefined ? commandNeedle(cmdText) : undefined);
|
|
15920
|
+
if (process.env.FORGE_WATCHDOG_PROBE) {
|
|
15921
|
+
try {
|
|
15922
|
+
appendFileSync(join4(tmpdir2(), "forge-watchdog-probe.log"), `${new Date().toISOString()} track ${input2.callID} needle=${JSON.stringify(cmdText !== undefined ? commandNeedle(cmdText) : undefined)} rawArgs=${JSON.stringify(output?.args ?? null).slice(0, 200)}
|
|
15923
|
+
`);
|
|
15924
|
+
} catch {}
|
|
15925
|
+
}
|
|
15926
|
+
}
|
|
14070
15927
|
if (typeof input2.tool === "string" && isWriteTool(input2.tool)) {
|
|
14071
15928
|
const state = stateForBan(input2.sessionID);
|
|
14072
15929
|
const active = state ? resolveActivePlan(state) : null;
|
|
@@ -14081,11 +15938,11 @@ var server = async (input) => {
|
|
|
14081
15938
|
const name = (typeof meta.tool === "string" ? meta.tool : undefined) ?? (typeof permissionField === "string" ? permissionField : undefined) ?? input2.id ?? input2.type;
|
|
14082
15939
|
if (process.env.FORGE_PERM_PROBE) {
|
|
14083
15940
|
try {
|
|
14084
|
-
appendFileSync(
|
|
15941
|
+
appendFileSync(join4(tmpdir2(), "forge-perm-probe.log"), `${new Date().toISOString()} ask name=${JSON.stringify(name)} in_status=${output.status} id=${JSON.stringify(input2.id)} type=${JSON.stringify(input2.type)} meta=${JSON.stringify(input2.metadata)}
|
|
14085
15942
|
`);
|
|
14086
15943
|
} catch {}
|
|
14087
15944
|
}
|
|
14088
|
-
if (name === "plan_approve" || name === "plan_close" || name === "goal_write" || name === "goal_complete" || name === "goal_resume" || name === "goal_discard") {
|
|
15945
|
+
if (name === "plan_approve" || name === "plan_close" || name === "goal_write" || name === "goal_complete" || name === "goal_resume" || name === "goal_discard" || name === "forge_shell") {
|
|
14089
15946
|
if (output.status !== "deny")
|
|
14090
15947
|
output.status = "ask";
|
|
14091
15948
|
return;
|
|
@@ -14111,10 +15968,40 @@ var server = async (input) => {
|
|
|
14111
15968
|
if (typeof sessionID === "string" && sessionID) {
|
|
14112
15969
|
goalProbe(`idle event session=${sessionID}`);
|
|
14113
15970
|
scheduleIdleContinuation(client, sessionID);
|
|
15971
|
+
deliverJobWakes(sessionID);
|
|
14114
15972
|
}
|
|
14115
15973
|
}
|
|
15974
|
+
if (event.type === "session.deleted") {
|
|
15975
|
+
const info = event.properties.info;
|
|
15976
|
+
if (typeof info?.id === "string" && info.id) {
|
|
15977
|
+
jobManager.onSessionEnd(info.id);
|
|
15978
|
+
}
|
|
15979
|
+
}
|
|
15980
|
+
},
|
|
15981
|
+
"shell.env": async (input2, output) => {
|
|
15982
|
+
if (watchdog.mode === "off")
|
|
15983
|
+
return;
|
|
15984
|
+
if (!input2.callID)
|
|
15985
|
+
return;
|
|
15986
|
+
output.env[WATCHDOG_ENV_MARK] = markerValue(input2.callID);
|
|
15987
|
+
watchdog.markSeen(input2.callID);
|
|
15988
|
+
if (process.env.FORGE_WATCHDOG_PROBE) {
|
|
15989
|
+
try {
|
|
15990
|
+
appendFileSync(join4(tmpdir2(), "forge-watchdog-probe.log"), `${new Date().toISOString()} mark ${input2.callID} hostpid=${process.pid}
|
|
15991
|
+
`);
|
|
15992
|
+
} catch {}
|
|
15993
|
+
}
|
|
14116
15994
|
},
|
|
14117
15995
|
"tool.execute.after": async (input2) => {
|
|
15996
|
+
if ((input2.tool === "shell" || input2.tool === "bash") && watchdog.has(input2.callID)) {
|
|
15997
|
+
watchdog.untrack(input2.callID);
|
|
15998
|
+
if (process.env.FORGE_WATCHDOG_PROBE) {
|
|
15999
|
+
try {
|
|
16000
|
+
appendFileSync(join4(tmpdir2(), "forge-watchdog-probe.log"), `${new Date().toISOString()} untrack ${input2.callID}
|
|
16001
|
+
`);
|
|
16002
|
+
} catch {}
|
|
16003
|
+
}
|
|
16004
|
+
}
|
|
14118
16005
|
if (typeof input2.tool !== "string")
|
|
14119
16006
|
return;
|
|
14120
16007
|
const act = turnActivity.get(input2.sessionID);
|
|
@@ -14125,9 +16012,24 @@ var server = async (input) => {
|
|
|
14125
16012
|
if (input2.tool === "goal_check")
|
|
14126
16013
|
act.checks++;
|
|
14127
16014
|
},
|
|
16015
|
+
"tool.definition": async (input2, output) => {
|
|
16016
|
+
if (input2.toolID === "shell" || input2.toolID === "bash") {
|
|
16017
|
+
const props = output.parameters?.properties;
|
|
16018
|
+
if (props && "run_in_background" in props)
|
|
16019
|
+
nativeBackgroundSeen = true;
|
|
16020
|
+
} else if (input2.toolID === "forge_shell" && jobStage() === 1) {
|
|
16021
|
+
if (!output.description.startsWith(STAGE1_NOTE)) {
|
|
16022
|
+
output.description = `${STAGE1_NOTE}
|
|
16023
|
+
${output.description}`;
|
|
16024
|
+
}
|
|
16025
|
+
}
|
|
16026
|
+
},
|
|
14128
16027
|
"experimental.chat.system.transform": async (input2, output) => {
|
|
14129
16028
|
if (!input2.sessionID)
|
|
14130
16029
|
return;
|
|
16030
|
+
if (!forgeDisabled && jobStage() < 2 && !output.system.some((s) => s.startsWith("[forge:job-guidance]"))) {
|
|
16031
|
+
output.system.push("[forge:job-guidance] Long-running or possibly non-exiting shell commands (dev servers, watchers, installers, anything spawning detached children) go through forge_shell, never the builtin shell: it returns on idle/success/exit with a jobId instead of blocking indefinitely; manage jobs with forge_jobs. Delegated agents: collect your job results with forge_jobs poll before yielding your conclusion.");
|
|
16032
|
+
}
|
|
14131
16033
|
const state = sessions.get(input2.sessionID);
|
|
14132
16034
|
if (!state)
|
|
14133
16035
|
return;
|
|
@@ -14200,5 +16102,6 @@ export {
|
|
|
14200
16102
|
server,
|
|
14201
16103
|
isRootish,
|
|
14202
16104
|
effectiveWorktree,
|
|
16105
|
+
effectiveSurvive,
|
|
14203
16106
|
plugin_default as default
|
|
14204
16107
|
};
|