@sorenllm/opencode-forge 0.3.2 → 0.3.4
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 +154 -2
- package/dist/index.js +2234 -202
- package/package.json +61 -61
package/dist/index.js
CHANGED
|
@@ -12335,9 +12335,9 @@ function tool(input) {
|
|
|
12335
12335
|
tool.schema = exports_external;
|
|
12336
12336
|
// plugin.ts
|
|
12337
12337
|
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
12338
|
-
import { appendFileSync, existsSync as existsSync3, mkdirSync as
|
|
12339
|
-
import { isAbsolute, join as
|
|
12340
|
-
import { tmpdir as
|
|
12338
|
+
import { appendFileSync, existsSync as existsSync3, mkdirSync as mkdirSync6, readdirSync as readdirSync4, readFileSync as readFileSync9, statSync as statSync4, writeFileSync as writeFileSync6 } from "node:fs";
|
|
12339
|
+
import { isAbsolute, join as join5, relative } from "node:path";
|
|
12340
|
+
import { tmpdir as tmpdir3, homedir } from "node:os";
|
|
12341
12341
|
|
|
12342
12342
|
// src/plan-file.ts
|
|
12343
12343
|
class PlanError extends Error {
|
|
@@ -14723,188 +14723,1773 @@ function createLocator(platform = process.platform, posix = {}, win = {}) {
|
|
|
14723
14723
|
return platform === "win32" ? createWindowsLocator(win) : createPosixLocator(posix);
|
|
14724
14724
|
}
|
|
14725
14725
|
|
|
14726
|
-
//
|
|
14727
|
-
var
|
|
14728
|
-
|
|
14729
|
-
|
|
14730
|
-
|
|
14731
|
-
|
|
14732
|
-
|
|
14733
|
-
|
|
14734
|
-
|
|
14735
|
-
|
|
14736
|
-
|
|
14737
|
-
|
|
14738
|
-
|
|
14739
|
-
|
|
14740
|
-
|
|
14741
|
-
|
|
14742
|
-
|
|
14743
|
-
"## Plan discipline",
|
|
14744
|
-
"",
|
|
14745
|
-
"Plans are short-horizon, single-task-goal documents (.opencode/plan/<date>-<slug>.md). The harness enforces the hard parts (draft write-ban, approval/close dialogs); you supply the engineering judgment. Never edit or create plan files by hand — every change goes through the plan_* tools.",
|
|
14746
|
-
"",
|
|
14747
|
-
"1. Reconnaissance and alignment (read-only): explore with read/grep/glob only — all write tools, bash, and subagents are DENIED while a draft exists; do not attempt them, do not ask the user to bypass. Gather concrete evidence with file:line references (verified findings, not guesses). If the goal is ambiguous or leaves meaningful choices open (scope, approach, acceptance), settle them with the user FIRST — 1-3 focused questions — and only write the draft once the shape is agreed; never plan against assumptions the user could settle in one line.",
|
|
14748
|
-
"2. Draft (plan_write): call plan_write with structured fields; the tool renders and validates the fixed sections, so a malformed plan cannot exist. Quality bar: goal = one line, the outcome not the activity; context = verified findings with file:line evidence, including what you ruled out and why; approach = the chosen approach AND at least one rejected alternative with the reason (a plan with no considered alternative is a guess); tasks = 3-8 concrete, independently verifiable steps, each doable in one sitting ('improve the code' is invalid; 'extract the timeout constant into config.ts and default it to 3000' is valid); risks = what could break, blast radius, rollback path; acceptance = criteria verifiable by a command, a file, or an observable behavior (vague criteria will fail the close); nonGoals = explicit out-of-scope items. To revise after feedback, call plan_write again — while in draft it overwrites the same file.",
|
|
14749
|
-
`3. User review, then approval (plan_approve): after plan_write, present the plan in chat — goal, chosen approach with one line why, the numbered task list, the acceptance criteria — then STOP: end your turn and wait. The user owns the review, at their own pace: feedback → revise with plan_write, re-present, and wait again; rejection → /plan discard (or re-plan together); explicit go-ahead (e.g. "execute", "批准", "looks good") → call plan_approve — its confirmation dialog is the final hard gate, and the user's Allow starts execution. Never call plan_approve in the same turn that presents a draft, and never implement before approval succeeds.`,
|
|
14750
|
-
"4. Execution (tick as you go): work tasks in order; call plan_tick with the task number immediately after EACH task's work is actually done — never batch ticks, never tick ahead of reality (tick timestamps are an audit trail). If the plan turns out wrong mid-execution, do not silently improvise: tell the user what changed and either finish the affected task or ask about revising (/plan with the same goal re-enters planning).",
|
|
14751
|
-
"5. Completion (plan_close): when all tasks are ticked, self-check EVERY acceptance criterion with concrete evidence (file:line, command output, test result), then call plan_close with one check per criterion, pass/fail honest — a failing check refuses the close, and that is the design working, not an inconvenience. The user confirms closure in a dialog.",
|
|
14752
|
-
"",
|
|
14753
|
-
"Boundaries: /plan discard abandons the plan (terminal, file kept as history, writes restored); /plan resume continues an unfinished plan from its remaining tasks. Work expected to span multiple sessions or days of multi-file change is spec work (e.g. OpenSpec), not plan work — say so once and let the user choose.",
|
|
14754
|
-
""
|
|
14755
|
-
].join(`
|
|
14756
|
-
`);
|
|
14757
|
-
var GOAL_COMMAND_TEMPLATE = [
|
|
14758
|
-
'(forge goal harness routing. Argument: "$ARGUMENTS")',
|
|
14759
|
-
"",
|
|
14760
|
-
"Current .opencode/goal/ directory:",
|
|
14761
|
-
"!`ls -1 .opencode/goal 2>/dev/null || echo '(empty)'`",
|
|
14762
|
-
"",
|
|
14763
|
-
"Route on the argument above (decide silently; do not recite this routing text to the user):",
|
|
14764
|
-
"- Argument empty: for every non-terminal goal in the directory above, read its frontmatter and report to the user: path, status (queued/active/paused + stop_reason), revision, turns_used/max_turns budget. Mention queued goals and the /goal next promotion option. Ask whether to resume/promote one or arm something new.",
|
|
14765
|
-
'- Argument "pause": call the goal_pause tool (with the blocker text as `blocker` when there is one), then tell the user the loop is stopped and how to resume.',
|
|
14766
|
-
`- Argument "resume": call the goal_resume tool for the session's paused goal (optionally with addTurns if the user asked for more budget). If no live goal exists but queued goals do, promote the oldest via goal_resume instead. The user confirms in a dialog.`,
|
|
14767
|
-
'- Argument "next": promote the oldest queued goal via the goal_resume tool (no live goal must remain). The user confirms in a dialog.',
|
|
14768
|
-
'- Argument "discard" (also "stop"/"cancel"/"off"): call the goal_discard tool. The user confirms in a dialog.',
|
|
14769
|
-
'- Argument starting with "add " (or the user clearly wants to queue without arming): create a NEW contract from the rest of the argument and call goal_write with arm=false — never revise an existing goal for an add, never omit arm. It becomes a queued, inert goal (no dialog).',
|
|
14770
|
-
'- Any other argument: treat it as a goal statement. Extract contract markers into the structured goal_write fields: --check "cmd" becomes a shell verification item, --contains "file::text" a file-contract item, --success "..." an extra success criterion, --constraint "..." goes into constraints, --non-goal "..." into nonGoals, --max-turns N and --max-minutes N set budgets. Draft a complete contract (goal, criteria, checks, constraints, non-goals), SHOW the verification items verbatim to the user, then call goal_write with arm=true — a confirmation dialog arms the autonomous loop. If this session already has a live goal, say so and offer revise/queue/discard instead.',
|
|
14771
|
-
"",
|
|
14772
|
-
"Goal files are host-managed: never edit or create anything under .opencode/goal/ by hand — contract changes go through goal_write (each revision bump invalidates evidence collected under earlier revisions), state changes through the goal_* tools. Likewise never enter the goal loop or call any goal_* tool unless the user ran /goal or explicitly asked for a goal loop.",
|
|
14773
|
-
""
|
|
14774
|
-
].join(`
|
|
14775
|
-
`);
|
|
14776
|
-
function isRootish(p) {
|
|
14777
|
-
if (!p)
|
|
14778
|
-
return true;
|
|
14779
|
-
return p === "/" || p === "\\" || /^[A-Za-z]:[\\/]?$/.test(p);
|
|
14726
|
+
// src/dispatch-roster.ts
|
|
14727
|
+
var DEFAULT_TIERS = {
|
|
14728
|
+
scout: { shape: "readonly", defaultDepth: "low" },
|
|
14729
|
+
build: { shape: "write", defaultDepth: "high" },
|
|
14730
|
+
review: { shape: "readonly", defaultDepth: "high" },
|
|
14731
|
+
quick: { shape: "write", defaultDepth: "off" }
|
|
14732
|
+
};
|
|
14733
|
+
var TIER_ID_RE = /^[a-z0-9-]+$/;
|
|
14734
|
+
function nativeLadder(catalog, identity) {
|
|
14735
|
+
const parsed = parseIdentity(identity);
|
|
14736
|
+
if (!parsed)
|
|
14737
|
+
return null;
|
|
14738
|
+
const entry = catalog.providers?.[parsed.provider]?.models?.[parsed.model];
|
|
14739
|
+
if (!entry)
|
|
14740
|
+
return null;
|
|
14741
|
+
const options = entry.reasoningOptions;
|
|
14742
|
+
return Array.isArray(options) ? [...options] : null;
|
|
14780
14743
|
}
|
|
14781
|
-
function
|
|
14782
|
-
const
|
|
14783
|
-
if (!
|
|
14784
|
-
return
|
|
14785
|
-
return
|
|
14744
|
+
function identityCost(catalog, identity) {
|
|
14745
|
+
const parsed = parseIdentity(identity);
|
|
14746
|
+
if (!parsed)
|
|
14747
|
+
return;
|
|
14748
|
+
return catalog.providers?.[parsed.provider]?.models?.[parsed.model]?.cost;
|
|
14786
14749
|
}
|
|
14787
|
-
|
|
14788
|
-
|
|
14789
|
-
|
|
14790
|
-
|
|
14791
|
-
|
|
14792
|
-
|
|
14793
|
-
|
|
14794
|
-
|
|
14795
|
-
|
|
14750
|
+
function parseIdentity(identity) {
|
|
14751
|
+
const slash = identity.indexOf("/");
|
|
14752
|
+
if (slash <= 0 || slash === identity.length - 1)
|
|
14753
|
+
return null;
|
|
14754
|
+
return { provider: identity.slice(0, slash), model: identity.slice(slash + 1) };
|
|
14755
|
+
}
|
|
14756
|
+
function catalogKnowsIdentity(catalog, identity) {
|
|
14757
|
+
const parsed = parseIdentity(identity);
|
|
14758
|
+
if (!parsed)
|
|
14796
14759
|
return false;
|
|
14760
|
+
return Boolean(catalog.providers?.[parsed.provider]?.models?.[parsed.model]);
|
|
14761
|
+
}
|
|
14762
|
+
function buildDispatchConfig(opts) {
|
|
14763
|
+
const findings = [];
|
|
14764
|
+
const catalog = opts.catalog ?? { providers: {} };
|
|
14765
|
+
const tiers = {};
|
|
14766
|
+
for (const [id, def] of Object.entries(DEFAULT_TIERS)) {
|
|
14767
|
+
tiers[id] = { ...def, shape: def.shape ?? "readonly" };
|
|
14768
|
+
}
|
|
14769
|
+
for (const [id, def] of Object.entries(opts.userTiers ?? {})) {
|
|
14770
|
+
if (!TIER_ID_RE.test(id)) {
|
|
14771
|
+
findings.push({
|
|
14772
|
+
level: "warn",
|
|
14773
|
+
code: "tier-id-charset",
|
|
14774
|
+
message: `tier id "${id}" violates [a-z0-9-] (it materializes as forge-<tier> agent ids)`
|
|
14775
|
+
});
|
|
14776
|
+
}
|
|
14777
|
+
const shape = def.shape ?? "readonly";
|
|
14778
|
+
if (def.shape === undefined) {
|
|
14779
|
+
findings.push({
|
|
14780
|
+
level: "warn",
|
|
14781
|
+
code: "shape-defaulted",
|
|
14782
|
+
message: `custom tier "${id}" has no shape; defaulting to readonly (write requires explicit "shape": "write")`
|
|
14783
|
+
});
|
|
14784
|
+
}
|
|
14785
|
+
tiers[id] = { ...def, shape };
|
|
14786
|
+
}
|
|
14787
|
+
const roster = [];
|
|
14788
|
+
for (const entry of opts.userRoster ?? []) {
|
|
14789
|
+
const ladder = nativeLadder(catalog, entry.model);
|
|
14790
|
+
const verified = ladder !== null;
|
|
14791
|
+
if (verified && catalogKnowsIdentity(catalog, entry.model)) {
|
|
14792
|
+
const legal = ladder;
|
|
14793
|
+
if (legal.length === 0) {
|
|
14794
|
+
findings.push({
|
|
14795
|
+
level: "notice",
|
|
14796
|
+
code: "unnamed-levels-accepted",
|
|
14797
|
+
message: `roster entry ${entry.model} has no cataloged named reasoning levels (toggle/budget model); expose accepted verbatim without validation`
|
|
14798
|
+
});
|
|
14799
|
+
roster.push({ model: entry.model, expose: [...entry.expose ?? []], profiles: [...entry.profiles], verified: true });
|
|
14800
|
+
} else {
|
|
14801
|
+
const expose = entry.expose ?? legal;
|
|
14802
|
+
const illegal = expose.filter((d) => !legal.includes(d));
|
|
14803
|
+
if (illegal.length > 0) {
|
|
14804
|
+
findings.push({
|
|
14805
|
+
level: "error",
|
|
14806
|
+
code: "expose-unknown-level",
|
|
14807
|
+
message: `roster entry ${entry.model} exposes unknown level(s) ${illegal.map((d) => `"${d}"`).join(", ")}; legal ladder: ${legal.join(", ")}`
|
|
14808
|
+
});
|
|
14809
|
+
}
|
|
14810
|
+
roster.push({
|
|
14811
|
+
model: entry.model,
|
|
14812
|
+
expose: expose.filter((d) => legal.includes(d)),
|
|
14813
|
+
profiles: [...entry.profiles],
|
|
14814
|
+
verified: true
|
|
14815
|
+
});
|
|
14816
|
+
}
|
|
14817
|
+
} else {
|
|
14818
|
+
findings.push({
|
|
14819
|
+
level: "notice",
|
|
14820
|
+
code: "unverified-identity",
|
|
14821
|
+
message: `roster entry ${entry.model} is not in the models.dev snapshot; expose accepted verbatim without validation`
|
|
14822
|
+
});
|
|
14823
|
+
roster.push({ model: entry.model, expose: [...entry.expose ?? []], profiles: [...entry.profiles], verified: false });
|
|
14824
|
+
}
|
|
14797
14825
|
}
|
|
14798
|
-
|
|
14799
|
-
|
|
14800
|
-
|
|
14826
|
+
const tierIds = new Set(Object.keys(tiers));
|
|
14827
|
+
for (const entry of roster) {
|
|
14828
|
+
if (!opts.configuredIdentities.includes(entry.model)) {
|
|
14829
|
+
findings.push({
|
|
14830
|
+
level: "warn",
|
|
14831
|
+
code: "dead-key",
|
|
14832
|
+
message: `roster entry ${entry.model} is not a configured provider/model identity (dead key)`
|
|
14833
|
+
});
|
|
14834
|
+
}
|
|
14835
|
+
const unknownTiers = entry.profiles.filter((p) => !tierIds.has(p));
|
|
14836
|
+
if (unknownTiers.length > 0) {
|
|
14837
|
+
findings.push({
|
|
14838
|
+
level: "warn",
|
|
14839
|
+
code: "unknown-tier-ref",
|
|
14840
|
+
message: `roster entry ${entry.model} references nonexistent tier(s): ${unknownTiers.join(", ")}`
|
|
14841
|
+
});
|
|
14842
|
+
}
|
|
14843
|
+
}
|
|
14844
|
+
for (const [id, tier] of Object.entries(tiers)) {
|
|
14845
|
+
if (tier.defaultDepth === undefined)
|
|
14846
|
+
continue;
|
|
14847
|
+
const served = roster.some((e) => e.profiles.includes(id) && e.expose.includes(tier.defaultDepth));
|
|
14848
|
+
if (!served) {
|
|
14849
|
+
findings.push({
|
|
14850
|
+
level: "warn",
|
|
14851
|
+
code: "dead-default-depth",
|
|
14852
|
+
message: `tier "${id}" defaultDepth "${tier.defaultDepth}" is exposed by no candidate; tier degrades to depth-required (dispatches must pass depth explicitly)`
|
|
14853
|
+
});
|
|
14854
|
+
tiers[id] = { ...tier, defaultDepth: undefined, depthRequired: true };
|
|
14855
|
+
}
|
|
14856
|
+
}
|
|
14857
|
+
return { roster, tiers, findings };
|
|
14801
14858
|
}
|
|
14802
|
-
|
|
14803
|
-
|
|
14804
|
-
var
|
|
14805
|
-
|
|
14806
|
-
|
|
14807
|
-
|
|
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 });
|
|
14859
|
+
|
|
14860
|
+
// src/dispatch-client.ts
|
|
14861
|
+
var CONSECUTIVE_STABLE_POLLS = 2;
|
|
14862
|
+
function completionVerdict(prev, cur) {
|
|
14863
|
+
if (!prev) {
|
|
14864
|
+
return { stablePolls: 0, verdict: "waiting", last: { ...cur } };
|
|
14815
14865
|
}
|
|
14816
|
-
|
|
14817
|
-
|
|
14866
|
+
const unchanged = cur.count === prev.last.count && cur.text === prev.last.text;
|
|
14867
|
+
const stablePolls = unchanged ? prev.stablePolls + 1 : 0;
|
|
14868
|
+
if (stablePolls >= CONSECUTIVE_STABLE_POLLS && cur.count > 0) {
|
|
14869
|
+
return { stablePolls, verdict: cur.text.trim() === "" ? "empty" : "complete", last: { ...cur } };
|
|
14818
14870
|
}
|
|
14819
|
-
|
|
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 };
|
|
14871
|
+
return { stablePolls, verdict: "waiting", last: { ...cur } };
|
|
14831
14872
|
}
|
|
14832
|
-
|
|
14833
|
-
|
|
14834
|
-
|
|
14835
|
-
|
|
14836
|
-
|
|
14837
|
-
|
|
14838
|
-
|
|
14873
|
+
function sumTokens(list) {
|
|
14874
|
+
const total = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } };
|
|
14875
|
+
for (const t of list) {
|
|
14876
|
+
total.input += t.input ?? 0;
|
|
14877
|
+
total.output += t.output ?? 0;
|
|
14878
|
+
total.reasoning += t.reasoning ?? 0;
|
|
14879
|
+
total.cache.read += t.cache?.read ?? 0;
|
|
14880
|
+
total.cache.write += t.cache?.write ?? 0;
|
|
14881
|
+
}
|
|
14882
|
+
return total;
|
|
14839
14883
|
}
|
|
14840
|
-
|
|
14841
|
-
|
|
14842
|
-
|
|
14843
|
-
|
|
14844
|
-
|
|
14845
|
-
|
|
14846
|
-
|
|
14847
|
-
|
|
14848
|
-
|
|
14849
|
-
}
|
|
14884
|
+
function computeCost(tokens, price) {
|
|
14885
|
+
if (!price || typeof price.input !== "number" || typeof price.output !== "number") {
|
|
14886
|
+
return { costUsd: null, note: "price unavailable for this identity (no snapshot price)" };
|
|
14887
|
+
}
|
|
14888
|
+
if (tokens.cache.read > 0 && typeof price.cacheRead !== "number") {
|
|
14889
|
+
return { costUsd: null, note: "price unavailable: cache-read price missing from snapshot" };
|
|
14890
|
+
}
|
|
14891
|
+
if (tokens.cache.write > 0 && typeof price.cacheWrite !== "number") {
|
|
14892
|
+
return { costUsd: null, note: "price unavailable: cache-write price missing from snapshot" };
|
|
14893
|
+
}
|
|
14894
|
+
const usd = (tokens.input * price.input + (tokens.output + tokens.reasoning) * price.output + tokens.cache.read * (price.cacheRead ?? 0) + tokens.cache.write * (price.cacheWrite ?? 0)) / 1e6;
|
|
14895
|
+
return { costUsd: usd };
|
|
14850
14896
|
}
|
|
14851
|
-
|
|
14852
|
-
|
|
14853
|
-
|
|
14897
|
+
|
|
14898
|
+
// src/dispatch-prompt.ts
|
|
14899
|
+
var COMMON_MANDATES = [
|
|
14900
|
+
"Mandates (non-negotiable):",
|
|
14901
|
+
"- Use workspace-relative paths ONLY. Never write to or read from absolute paths outside this workspace; the workspace root is where you were started, not the filesystem root.",
|
|
14902
|
+
"- If any tool call is refused or denied, report the refusal VERBATIM in your final answer and stop that line of work. Never improvise a workaround around a permission denial.",
|
|
14903
|
+
"- End with conclusions backed by evidence references (file:line, command output, or the exact file/content you produced)."
|
|
14904
|
+
].join(`
|
|
14905
|
+
`);
|
|
14906
|
+
var READONLY_DISCIPLINE = `You are a readonly {agent} worker: you gather, read, run read-only checks, and REPORT. Your restrictions are the design, not obstacles — do not attempt to work around them; report what you found instead.`;
|
|
14907
|
+
var WRITE_DISCIPLINE = `You are an {agent} worker executing one scoped task. Do exactly the task, nothing else: no refactoring beyond scope, no unrelated files, no starting side quests.`;
|
|
14908
|
+
function composeWorkerPrompt(input) {
|
|
14909
|
+
const discipline = input.shape === "readonly" ? READONLY_DISCIPLINE.replaceAll("{agent}", input.agent) : WRITE_DISCIPLINE.replaceAll("{agent}", input.agent);
|
|
14854
14910
|
return [
|
|
14855
|
-
|
|
14856
|
-
|
|
14857
|
-
|
|
14858
|
-
|
|
14859
|
-
"
|
|
14911
|
+
"[forge:dispatch] You were dispatched by the forge main agent as a scoped worker.",
|
|
14912
|
+
discipline,
|
|
14913
|
+
...input.role ? ["", "Your role:", input.role.trim()] : [],
|
|
14914
|
+
"",
|
|
14915
|
+
"Task:",
|
|
14916
|
+
input.prompt.trim(),
|
|
14917
|
+
"",
|
|
14918
|
+
COMMON_MANDATES,
|
|
14919
|
+
"",
|
|
14920
|
+
"Finish with a single concluding report (the main agent will read only this): what you did, what you found/produced, and the evidence."
|
|
14860
14921
|
].join(`
|
|
14861
14922
|
`);
|
|
14862
14923
|
}
|
|
14863
|
-
|
|
14864
|
-
|
|
14865
|
-
|
|
14866
|
-
|
|
14867
|
-
|
|
14868
|
-
|
|
14869
|
-
|
|
14870
|
-
|
|
14871
|
-
|
|
14872
|
-
|
|
14924
|
+
|
|
14925
|
+
// src/dispatch-resolver.ts
|
|
14926
|
+
function exposedVocabulary(cfg) {
|
|
14927
|
+
const set2 = new Set;
|
|
14928
|
+
for (const entry of cfg.roster)
|
|
14929
|
+
for (const d of entry.expose)
|
|
14930
|
+
set2.add(d);
|
|
14931
|
+
return [...set2];
|
|
14932
|
+
}
|
|
14933
|
+
function menuFor(cfg, tierId, depth, exclude, available) {
|
|
14934
|
+
const lines = [];
|
|
14935
|
+
for (const entry of cfg.roster) {
|
|
14936
|
+
if (!entry.profiles.includes(tierId))
|
|
14937
|
+
continue;
|
|
14938
|
+
let reason;
|
|
14939
|
+
if (exclude.includes(entry.model))
|
|
14940
|
+
reason = "excluded after a failed attempt";
|
|
14941
|
+
else if (!available(entry.model))
|
|
14942
|
+
reason = "identity currently unavailable";
|
|
14943
|
+
else if (depth !== undefined && !entry.expose.includes(depth))
|
|
14944
|
+
reason = `depth "${depth}" not exposed`;
|
|
14945
|
+
else
|
|
14946
|
+
reason = "eligible";
|
|
14947
|
+
lines.push({ identity: entry.model, expose: [...entry.expose], verified: entry.verified, reason });
|
|
14948
|
+
}
|
|
14949
|
+
return lines;
|
|
14950
|
+
}
|
|
14951
|
+
function costRank(catalog, identity) {
|
|
14952
|
+
const cost = identityCost(catalog, identity);
|
|
14953
|
+
if (!cost || typeof cost.input !== "number" || typeof cost.output !== "number")
|
|
14954
|
+
return Number.POSITIVE_INFINITY;
|
|
14955
|
+
return cost.input + cost.output;
|
|
14956
|
+
}
|
|
14957
|
+
function resolveDispatch(cfg, req, available, catalog = { providers: {} }) {
|
|
14958
|
+
const tier = cfg.tiers[req.profile];
|
|
14959
|
+
if (!tier) {
|
|
14960
|
+
return {
|
|
14961
|
+
ok: false,
|
|
14962
|
+
error: {
|
|
14963
|
+
code: "unknown-tier",
|
|
14964
|
+
message: `unknown tier "${req.profile}"; known tiers: ${Object.keys(cfg.tiers).join(", ")}`,
|
|
14965
|
+
menu: [],
|
|
14966
|
+
vocabulary: exposedVocabulary(cfg)
|
|
14967
|
+
}
|
|
14968
|
+
};
|
|
14969
|
+
}
|
|
14970
|
+
const exclude = req.exclude ?? [];
|
|
14971
|
+
if (tier.model) {
|
|
14972
|
+
const pinnedIdentity = tier.model;
|
|
14973
|
+
const entry = cfg.roster.find((e) => e.model === pinnedIdentity);
|
|
14974
|
+
const expose = entry?.expose ?? [];
|
|
14975
|
+
const menu = [
|
|
14976
|
+
{ identity: pinnedIdentity, expose, verified: entry?.verified ?? false, reason: "pinned tier target" }
|
|
14977
|
+
];
|
|
14978
|
+
if (!available(pinnedIdentity)) {
|
|
14979
|
+
return {
|
|
14980
|
+
ok: false,
|
|
14981
|
+
error: {
|
|
14982
|
+
code: "pin-unavailable",
|
|
14983
|
+
message: `tier "${req.profile}" is pinned to ${pinnedIdentity}, which is currently unavailable; fix or unpin the tier — no fallback model will be used`,
|
|
14984
|
+
menu,
|
|
14985
|
+
vocabulary: exposedVocabulary(cfg)
|
|
14986
|
+
}
|
|
14987
|
+
};
|
|
14988
|
+
}
|
|
14989
|
+
const depth2 = req.depth ?? tier.depth;
|
|
14990
|
+
if (depth2 === undefined) {
|
|
14991
|
+
return {
|
|
14992
|
+
ok: false,
|
|
14993
|
+
error: {
|
|
14994
|
+
code: "depth-required",
|
|
14995
|
+
message: `tier "${req.profile}" is pinned without a depth and none was requested; pass depth explicitly`,
|
|
14996
|
+
menu,
|
|
14997
|
+
vocabulary: exposedVocabulary(cfg)
|
|
14998
|
+
}
|
|
14999
|
+
};
|
|
15000
|
+
}
|
|
15001
|
+
if (!expose.includes(depth2)) {
|
|
15002
|
+
return {
|
|
15003
|
+
ok: false,
|
|
15004
|
+
error: {
|
|
15005
|
+
code: "pin-depth-mismatch",
|
|
15006
|
+
message: `pinned model ${pinnedIdentity} does not expose depth "${depth2}" (expose: ${expose.join(", ") || "none"}); exact match only — no clamping`,
|
|
15007
|
+
menu,
|
|
15008
|
+
vocabulary: exposedVocabulary(cfg)
|
|
15009
|
+
}
|
|
15010
|
+
};
|
|
14873
15011
|
}
|
|
15012
|
+
return { ok: true, identity: pinnedIdentity, depth: depth2, pinned: true };
|
|
15013
|
+
}
|
|
15014
|
+
const depth = req.depth ?? tier.defaultDepth;
|
|
15015
|
+
if (depth === undefined) {
|
|
15016
|
+
return {
|
|
15017
|
+
ok: false,
|
|
15018
|
+
error: {
|
|
15019
|
+
code: "depth-required",
|
|
15020
|
+
message: `tier "${req.profile}" has no usable default depth (its default was removed at validation); pass depth explicitly`,
|
|
15021
|
+
menu: menuFor(cfg, req.profile, undefined, exclude, available),
|
|
15022
|
+
vocabulary: exposedVocabulary(cfg)
|
|
15023
|
+
}
|
|
15024
|
+
};
|
|
15025
|
+
}
|
|
15026
|
+
const candidates = cfg.roster.map((entry, index) => ({ entry, index })).filter(({ entry }) => entry.profiles.includes(req.profile)).filter(({ entry }) => !exclude.includes(entry.model)).filter(({ entry }) => available(entry.model)).filter(({ entry }) => entry.expose.includes(depth)).sort((a, b) => a.index - b.index || costRank(catalog, a.entry.model) - costRank(catalog, b.entry.model));
|
|
15027
|
+
if (candidates.length === 0) {
|
|
15028
|
+
return {
|
|
15029
|
+
ok: false,
|
|
15030
|
+
error: {
|
|
15031
|
+
code: "no-candidate",
|
|
15032
|
+
message: `no candidate serves tier "${req.profile}" at depth "${depth}"; pick a depth from the menu's exposures (exact match — the plugin never clamps)`,
|
|
15033
|
+
menu: menuFor(cfg, req.profile, depth, exclude, available),
|
|
15034
|
+
vocabulary: exposedVocabulary(cfg)
|
|
15035
|
+
}
|
|
15036
|
+
};
|
|
14874
15037
|
}
|
|
15038
|
+
return { ok: true, identity: candidates[0].entry.model, depth, pinned: false };
|
|
14875
15039
|
}
|
|
14876
|
-
|
|
14877
|
-
|
|
14878
|
-
|
|
14879
|
-
|
|
14880
|
-
|
|
14881
|
-
|
|
14882
|
-
|
|
14883
|
-
|
|
14884
|
-
|
|
14885
|
-
|
|
14886
|
-
} catch {
|
|
14887
|
-
return;
|
|
14888
|
-
}
|
|
15040
|
+
function resolveAgent(agents, req, available) {
|
|
15041
|
+
const def = agents[req.agent];
|
|
15042
|
+
if (!def || typeof def !== "object") {
|
|
15043
|
+
return {
|
|
15044
|
+
ok: false,
|
|
15045
|
+
error: {
|
|
15046
|
+
code: "unknown-agent",
|
|
15047
|
+
message: `unknown dispatch agent "${req.agent}"; defined agents: ${Object.keys(agents).join(", ") || "(none)"}`
|
|
15048
|
+
}
|
|
15049
|
+
};
|
|
14889
15050
|
}
|
|
14890
|
-
|
|
15051
|
+
if (!def.model || typeof def.model !== "string") {
|
|
15052
|
+
return {
|
|
15053
|
+
ok: false,
|
|
15054
|
+
error: {
|
|
15055
|
+
code: "pin-unavailable",
|
|
15056
|
+
message: `agent "${req.agent}" has no usable model (model: ${JSON.stringify(def.model)}); fix its "model" in forge.json — no fallback model will be used`
|
|
15057
|
+
}
|
|
15058
|
+
};
|
|
15059
|
+
}
|
|
15060
|
+
if (!available(def.model)) {
|
|
15061
|
+
return {
|
|
15062
|
+
ok: false,
|
|
15063
|
+
error: {
|
|
15064
|
+
code: "pin-unavailable",
|
|
15065
|
+
message: `agent "${req.agent}" is pinned to ${def.model}, which is not configured on this host; fix the agent's "model" in forge.json — no fallback model will be used`
|
|
15066
|
+
}
|
|
15067
|
+
};
|
|
15068
|
+
}
|
|
15069
|
+
const depths = Array.isArray(def.depths) ? def.depths : [];
|
|
15070
|
+
if (depths.length === 0) {
|
|
15071
|
+
return {
|
|
15072
|
+
ok: false,
|
|
15073
|
+
error: {
|
|
15074
|
+
code: "depth-not-in-set",
|
|
15075
|
+
message: `agent "${req.agent}" defines no depths; give it at least one depth in forge.json (canonical words: none, low, medium, high, max — or the model's native level names)`
|
|
15076
|
+
}
|
|
15077
|
+
};
|
|
15078
|
+
}
|
|
15079
|
+
const depth = req.depth ?? depths[0];
|
|
15080
|
+
if (!depths.includes(depth)) {
|
|
15081
|
+
return {
|
|
15082
|
+
ok: false,
|
|
15083
|
+
error: {
|
|
15084
|
+
code: "depth-not-in-set",
|
|
15085
|
+
message: `agent "${req.agent}" does not allow depth "${depth}" (allowed: ${depths.join(", ")}) — exact match only, the plugin never clamps`
|
|
15086
|
+
}
|
|
15087
|
+
};
|
|
15088
|
+
}
|
|
15089
|
+
return { ok: true, model: def.model, depth, def };
|
|
14891
15090
|
}
|
|
14892
|
-
|
|
14893
|
-
|
|
14894
|
-
|
|
14895
|
-
|
|
15091
|
+
|
|
15092
|
+
// src/dispatch-depth.ts
|
|
15093
|
+
var META_DEPTHS = ["none", "low", "medium", "high", "max"];
|
|
15094
|
+
var BUDGET_TIERS = {
|
|
15095
|
+
low: 8192,
|
|
15096
|
+
medium: 16384,
|
|
15097
|
+
high: 24576,
|
|
15098
|
+
max: 32768
|
|
15099
|
+
};
|
|
15100
|
+
function noMapping(depth, family, nativeVocabulary, metaAvailable) {
|
|
15101
|
+
return {
|
|
15102
|
+
kind: "no-mapping",
|
|
15103
|
+
family,
|
|
15104
|
+
message: `depth "${depth}" has no native counterpart on this model — the plugin never interpolates. Native levels: ${nativeVocabulary.join(", ") || "(none named)"}. Meta words this model accepts: ${metaAvailable.join(", ") || "(none)"}.`,
|
|
15105
|
+
nativeVocabulary,
|
|
15106
|
+
metaAvailable
|
|
15107
|
+
};
|
|
14896
15108
|
}
|
|
14897
|
-
function
|
|
14898
|
-
|
|
15109
|
+
function translateDepth(depth, family, ladder) {
|
|
15110
|
+
if (family === "unknown") {
|
|
15111
|
+
return { kind: "not-injected", family, disclose: "not injected (unknown provider shape)" };
|
|
15112
|
+
}
|
|
15113
|
+
if (family === "anthropic") {
|
|
15114
|
+
if (depth === "none")
|
|
15115
|
+
return { kind: "off", family, disclose: "canonical none → native thinking:off" };
|
|
15116
|
+
const budget = BUDGET_TIERS[depth];
|
|
15117
|
+
if (budget !== undefined) {
|
|
15118
|
+
return { kind: "tiered", family, budgetTokens: budget, disclose: `canonical ${depth} → native thinking budget:${budget}` };
|
|
15119
|
+
}
|
|
15120
|
+
return noMapping(depth, family, [], [...META_DEPTHS]);
|
|
15121
|
+
}
|
|
15122
|
+
if (family === "zai") {
|
|
15123
|
+
if (depth === "none")
|
|
15124
|
+
return { kind: "off", family, disclose: "canonical none → native thinking:off" };
|
|
15125
|
+
if (META_DEPTHS.includes(depth)) {
|
|
15126
|
+
return { kind: "toggle-on", family, disclose: `canonical ${depth} → native thinking:on (this provider shape takes a toggle, not a level)` };
|
|
15127
|
+
}
|
|
15128
|
+
return noMapping(depth, family, ["thinking on/off"], [...META_DEPTHS]);
|
|
15129
|
+
}
|
|
15130
|
+
const native = ladder ?? null;
|
|
15131
|
+
if (native !== null && native.includes(depth)) {
|
|
15132
|
+
return { kind: "verbatim", family: "openai", word: depth, disclose: META_DEPTHS.includes(depth) ? "verbatim" : `verbatim (native word "${depth}")` };
|
|
15133
|
+
}
|
|
15134
|
+
if (native !== null && native.length > 0 && META_DEPTHS.includes(depth)) {
|
|
15135
|
+
return noMapping(depth, family, native, META_DEPTHS.filter((w) => native.includes(w)));
|
|
15136
|
+
}
|
|
15137
|
+
return { kind: "verbatim", family: "openai", word: depth, disclose: `verbatim (unverified — ${native === null ? "catalog does not know this model" : `catalog ladder does not list "${depth}"`}; the provider judges)` };
|
|
14899
15138
|
}
|
|
14900
|
-
function
|
|
14901
|
-
|
|
15139
|
+
function applyDepthTranslation(options, t) {
|
|
15140
|
+
if (t.kind === "verbatim") {
|
|
15141
|
+
options.reasoningEffort = t.word;
|
|
15142
|
+
} else if (t.kind === "tiered") {
|
|
15143
|
+
options.thinking = { type: "enabled", budget_tokens: t.budgetTokens };
|
|
15144
|
+
} else if (t.kind === "off") {
|
|
15145
|
+
options.thinking = { type: "disabled" };
|
|
15146
|
+
} else if (t.kind === "toggle-on") {
|
|
15147
|
+
options.thinking = { type: "enabled" };
|
|
15148
|
+
}
|
|
14902
15149
|
}
|
|
14903
|
-
|
|
14904
|
-
|
|
14905
|
-
|
|
14906
|
-
|
|
14907
|
-
|
|
15150
|
+
|
|
15151
|
+
// src/dispatch-tiers.ts
|
|
15152
|
+
var MUTATING_TOOLS = ["write", "edit", "bash"];
|
|
15153
|
+
var TIER_PROMPTS = {
|
|
15154
|
+
scout: "You are forge-scout, a readonly reconnaissance worker. Search, read, and run read-only commands; report findings with file:line evidence. You never modify anything — write tools are denied by design.",
|
|
15155
|
+
build: "You are forge-build, an implementation worker. Execute the dispatched task exactly: minimal, focused changes with tests where the task implies them. Do not expand scope.",
|
|
15156
|
+
review: "You are forge-review, a readonly review worker. Read the diff or files under review, run read-only checks/tests, and report findings as a prioritized list with file:line evidence and a single-line verdict. Write tools are denied by design.",
|
|
15157
|
+
quick: "You are forge-quick, a mechanical small-change worker. Make the smallest correct edit the task names (rename, constant, one-liner fix). No refactoring, no drive-by improvements."
|
|
15158
|
+
};
|
|
15159
|
+
function tierAgentPrompt(tierId) {
|
|
15160
|
+
return TIER_PROMPTS[tierId] ?? `You are forge-${tierId}, a dispatched worker. Execute the dispatched task exactly as scoped.`;
|
|
15161
|
+
}
|
|
15162
|
+
var ROLE_PROMPTS = {
|
|
15163
|
+
research: "You are a research worker: search, read, and run read-only checks to answer the dispatched question. Report findings as a compact brief with sources and file:line evidence; state plainly what could not be verified.",
|
|
15164
|
+
review: "You are a review worker: read the diff or files under review, run read-only checks/tests, and report findings as a prioritized list, each with file:line evidence and a concrete fix suggestion. End with a single-line verdict."
|
|
15165
|
+
};
|
|
15166
|
+
function rolePromptFor(agentId, def) {
|
|
15167
|
+
if (def?.prompt && def.prompt.trim().length > 0)
|
|
15168
|
+
return def.prompt;
|
|
15169
|
+
return ROLE_PROMPTS[agentId] ?? `You are forge-${agentId}, a dispatched worker. Execute the dispatched task exactly as scoped.`;
|
|
15170
|
+
}
|
|
15171
|
+
function forgeAgentDef(agentId, def) {
|
|
15172
|
+
const permission = { task: "deny" };
|
|
15173
|
+
const shape = def.shape ?? "readonly";
|
|
15174
|
+
if (shape === "readonly") {
|
|
15175
|
+
for (const t of MUTATING_TOOLS)
|
|
15176
|
+
permission[t] = "deny";
|
|
15177
|
+
}
|
|
15178
|
+
Object.assign(permission, def.permission ?? {});
|
|
15179
|
+
permission.task = "deny";
|
|
15180
|
+
const shapeLine = shape === "readonly" ? "Readonly agent: mutating tools are denied by design — report findings, never work around restrictions." : "Write agent: execute the scoped task with minimal, focused changes.";
|
|
15181
|
+
return {
|
|
15182
|
+
description: `forge dispatch agent "${agentId}" (${shape}) — spawned by forge_dispatch, not user-facing.`,
|
|
15183
|
+
mode: "subagent",
|
|
15184
|
+
hidden: true,
|
|
15185
|
+
prompt: `${rolePromptFor(agentId, def)}
|
|
15186
|
+
|
|
15187
|
+
${shapeLine}`,
|
|
15188
|
+
permission
|
|
15189
|
+
};
|
|
15190
|
+
}
|
|
15191
|
+
function tierAgentDef(tierId, tier) {
|
|
15192
|
+
const permission = { task: "deny" };
|
|
15193
|
+
if (tier.shape === "readonly") {
|
|
15194
|
+
for (const t of MUTATING_TOOLS)
|
|
15195
|
+
permission[t] = "deny";
|
|
15196
|
+
}
|
|
15197
|
+
const shapeLine = tier.shape === "readonly" ? "Readonly tier: mutating tools are denied by design — report findings, never work around restrictions." : "Write tier: execute the scoped task with minimal, focused changes.";
|
|
15198
|
+
const defaultLine = tier.defaultDepth ? ` Default reasoning depth: ${tier.defaultDepth}.` : "";
|
|
15199
|
+
return {
|
|
15200
|
+
description: `forge dispatch tier "${tierId}" (${tier.shape}) — spawned by forge_dispatch, not user-facing.${defaultLine}`,
|
|
15201
|
+
mode: "subagent",
|
|
15202
|
+
hidden: true,
|
|
15203
|
+
prompt: `${tierAgentPrompt(tierId)}
|
|
15204
|
+
|
|
15205
|
+
${shapeLine}`,
|
|
15206
|
+
permission
|
|
15207
|
+
};
|
|
15208
|
+
}
|
|
15209
|
+
|
|
15210
|
+
// src/dispatch-engine.ts
|
|
15211
|
+
var DEFAULT_DISPATCH_TIMEOUT_MS = 600000;
|
|
15212
|
+
var MAX_DISPATCH_TIMEOUT_MS = 600000;
|
|
15213
|
+
var DEFAULT_MAX_CONCURRENT = 4;
|
|
15214
|
+
var DEFAULT_POLL_INTERVAL_MS = 2000;
|
|
15215
|
+
|
|
15216
|
+
class DispatchError extends Error {
|
|
15217
|
+
code;
|
|
15218
|
+
constructor(message, code) {
|
|
15219
|
+
super(message);
|
|
15220
|
+
this.name = "DispatchError";
|
|
15221
|
+
this.code = code;
|
|
15222
|
+
}
|
|
15223
|
+
}
|
|
15224
|
+
function textOf(messages) {
|
|
15225
|
+
const assistant = [...messages].reverse().find((m) => (m.info?.role ?? m.role) === "assistant");
|
|
15226
|
+
if (!assistant)
|
|
15227
|
+
return "";
|
|
15228
|
+
return (assistant.parts ?? []).filter((p) => p.type === "text" || typeof p.text === "string").map((p) => p.text ?? "").join(`
|
|
15229
|
+
`).trim();
|
|
15230
|
+
}
|
|
15231
|
+
function tokensOf(messages) {
|
|
15232
|
+
return messages.filter((m) => (m.info?.role ?? m.role) === "assistant" && m.info?.tokens).map((m) => m.info.tokens);
|
|
15233
|
+
}
|
|
15234
|
+
function createDispatchEngine(deps) {
|
|
15235
|
+
const maxConcurrent = deps.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
|
|
15236
|
+
const timeoutMs = Math.min(deps.timeoutMs ?? DEFAULT_DISPATCH_TIMEOUT_MS, MAX_DISPATCH_TIMEOUT_MS);
|
|
15237
|
+
const pollIntervalMs = deps.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
15238
|
+
const inFlight = new Set;
|
|
15239
|
+
let seq = 0;
|
|
15240
|
+
const api2 = async (path, init) => {
|
|
15241
|
+
const res = await deps.fetcher(`${deps.serverUrl}${path}`, {
|
|
15242
|
+
...init,
|
|
15243
|
+
headers: { "content-type": "application/json", ...init?.headers ?? {} }
|
|
15244
|
+
});
|
|
15245
|
+
const text = await res.text();
|
|
15246
|
+
if (!res.ok)
|
|
15247
|
+
throw new DispatchError(`${init?.method ?? "GET"} ${path} -> ${res.status}: ${text.slice(0, 300)}`, "host-error");
|
|
15248
|
+
return text ? JSON.parse(text) : undefined;
|
|
15249
|
+
};
|
|
15250
|
+
const fetchMessages = async (sessionID) => {
|
|
15251
|
+
const list = await api2(`/session/${sessionID}/message`);
|
|
15252
|
+
return Array.isArray(list) ? list : [];
|
|
15253
|
+
};
|
|
15254
|
+
const killControls = new Map;
|
|
15255
|
+
const resolveOrThrow = (profile, depth, exclude) => {
|
|
15256
|
+
const r = resolveDispatch(deps.cfg, { profile, depth, exclude }, deps.available, deps.catalog);
|
|
15257
|
+
if (!r.ok) {
|
|
15258
|
+
deps.sink({ ts: new Date().toISOString(), event: "resolve-error", tier: profile, outcome: r.error.code, note: r.error.message });
|
|
15259
|
+
throw new DispatchError(`${r.error.message}
|
|
15260
|
+
Menu:
|
|
15261
|
+
${r.error.menu.map((m) => `- ${m.identity} expose=[${m.expose.join(", ") || "none"}] ${m.reason}`).join(`
|
|
15262
|
+
`)}
|
|
15263
|
+
Vocabulary: ${r.error.vocabulary.join(", ") || "(none)"}`, r.error.code);
|
|
15264
|
+
}
|
|
15265
|
+
return r;
|
|
15266
|
+
};
|
|
15267
|
+
const resolveForThrow = (req, exclude) => {
|
|
15268
|
+
if (deps.legacy && req.agent in deps.cfg.tiers) {
|
|
15269
|
+
const r = resolveOrThrow(req.agent, req.depth, exclude);
|
|
15270
|
+
return {
|
|
15271
|
+
model: r.identity,
|
|
15272
|
+
depth: r.depth,
|
|
15273
|
+
agentPath: false,
|
|
15274
|
+
shape: deps.cfg.tiers[req.agent]?.shape ?? "readonly",
|
|
15275
|
+
role: tierAgentPrompt(req.agent)
|
|
15276
|
+
};
|
|
15277
|
+
}
|
|
15278
|
+
const a = resolveAgent(deps.agents(), { agent: req.agent, depth: req.depth }, deps.available);
|
|
15279
|
+
if (!a.ok) {
|
|
15280
|
+
deps.sink({ ts: new Date().toISOString(), event: "resolve-error", tier: req.agent, agent: req.agent, outcome: a.error.code, note: a.error.message });
|
|
15281
|
+
throw new DispatchError(a.error.message, a.error.code);
|
|
15282
|
+
}
|
|
15283
|
+
return {
|
|
15284
|
+
model: a.model,
|
|
15285
|
+
depth: a.depth,
|
|
15286
|
+
agentPath: true,
|
|
15287
|
+
shape: a.def?.shape === "write" ? "write" : "readonly",
|
|
15288
|
+
role: rolePromptFor(req.agent, a.def)
|
|
15289
|
+
};
|
|
15290
|
+
};
|
|
15291
|
+
const translationFor = (identity, depth) => {
|
|
15292
|
+
const providerID = identity.slice(0, identity.indexOf("/"));
|
|
15293
|
+
const family = deps.providerFamily(providerID) ?? "unknown";
|
|
15294
|
+
const ladder = nativeLadder(deps.catalog, identity);
|
|
15295
|
+
return translateDepth(depth, family, ladder);
|
|
15296
|
+
};
|
|
15297
|
+
const translationOrThrow = (identity, depth) => {
|
|
15298
|
+
const translation = translationFor(identity, depth);
|
|
15299
|
+
if (translation.kind === "no-mapping") {
|
|
15300
|
+
deps.sink({ ts: new Date().toISOString(), event: "resolve-error", agent: undefined, identity, depth, outcome: "no-native-mapping", note: translation.message });
|
|
15301
|
+
throw new DispatchError(translation.message, "no-native-mapping");
|
|
15302
|
+
}
|
|
15303
|
+
return translation;
|
|
15304
|
+
};
|
|
15305
|
+
async function runAttempt(r, req, parentSessionID, t0, ctl, onChild, dispatchId) {
|
|
15306
|
+
const providerID = r.model.slice(0, r.model.indexOf("/"));
|
|
15307
|
+
const translation = translationOrThrow(r.model, r.depth);
|
|
15308
|
+
let childID = "";
|
|
15309
|
+
const dir = encodeURIComponent(deps.workspace);
|
|
15310
|
+
let created;
|
|
15311
|
+
try {
|
|
15312
|
+
created = await api2(`/session?directory=${dir}`, { method: "POST", body: JSON.stringify(parentSessionID ? { parentID: parentSessionID } : {}) });
|
|
15313
|
+
} catch {
|
|
15314
|
+
created = await api2(`/session?directory=${dir}`, { method: "POST", body: JSON.stringify({}) });
|
|
15315
|
+
}
|
|
15316
|
+
childID = created?.id ?? "";
|
|
15317
|
+
if (!childID)
|
|
15318
|
+
throw new DispatchError("host returned no session id for the child session", "host-error");
|
|
15319
|
+
onChild?.(childID);
|
|
15320
|
+
deps.onDepth(childID, translation);
|
|
15321
|
+
const depthInjected = translation.disclose;
|
|
15322
|
+
let deadlineHit = false;
|
|
15323
|
+
const deadlinePromise = deps.sleep(Math.max(timeoutMs - (deps.now() - t0), 0)).then(() => {
|
|
15324
|
+
deadlineHit = true;
|
|
15325
|
+
});
|
|
15326
|
+
const turnPost = api2(`/session/${childID}/message`, {
|
|
15327
|
+
method: "POST",
|
|
15328
|
+
body: JSON.stringify({
|
|
15329
|
+
model: { providerID, modelID: r.model.slice(r.model.indexOf("/") + 1) },
|
|
15330
|
+
agent: `forge-${req.agent}`,
|
|
15331
|
+
parts: [{ type: "text", text: composeWorkerPrompt({ prompt: req.prompt, agent: req.agent, shape: r.shape, ...r.role ? { role: r.role } : {} }) }]
|
|
15332
|
+
})
|
|
15333
|
+
});
|
|
15334
|
+
turnPost.catch(() => {});
|
|
15335
|
+
try {
|
|
15336
|
+
await Promise.race([turnPost, deadlinePromise]);
|
|
15337
|
+
if (deadlineHit && deps.now() - t0 >= timeoutMs) {
|
|
15338
|
+
const elapsed = deps.now() - t0;
|
|
15339
|
+
deps.sink({
|
|
15340
|
+
ts: new Date().toISOString(),
|
|
15341
|
+
event: "timeout",
|
|
15342
|
+
tier: req.agent,
|
|
15343
|
+
agent: req.agent,
|
|
15344
|
+
identity: r.model,
|
|
15345
|
+
depth: r.depth,
|
|
15346
|
+
sessionID: childID,
|
|
15347
|
+
durationMs: elapsed,
|
|
15348
|
+
...dispatchId !== undefined ? { dispatchId, parentSessionID: parentSessionID ?? "" } : {}
|
|
15349
|
+
});
|
|
15350
|
+
throw new DispatchError(`forge_dispatch timed out after ${elapsed}ms (deadline ${timeoutMs}ms). Partial state: child session ${childID} is left for the host to reclaim; its transcript remains queryable via the host API.`, "timeout");
|
|
15351
|
+
}
|
|
15352
|
+
await turnPost;
|
|
15353
|
+
} catch (e) {
|
|
15354
|
+
if (e instanceof DispatchError)
|
|
15355
|
+
throw e;
|
|
15356
|
+
deps.sink({
|
|
15357
|
+
ts: new Date().toISOString(),
|
|
15358
|
+
event: "transport-interrupted",
|
|
15359
|
+
tier: req.agent,
|
|
15360
|
+
agent: req.agent,
|
|
15361
|
+
identity: r.model,
|
|
15362
|
+
depth: r.depth,
|
|
15363
|
+
sessionID: childID,
|
|
15364
|
+
durationMs: deps.now() - t0,
|
|
15365
|
+
note: e instanceof Error ? e.message : String(e),
|
|
15366
|
+
...dispatchId !== undefined ? { dispatchId, parentSessionID: parentSessionID ?? "" } : {}
|
|
15367
|
+
});
|
|
15368
|
+
}
|
|
15369
|
+
let poll = null;
|
|
15370
|
+
let messages = [];
|
|
15371
|
+
for (;; ) {
|
|
15372
|
+
if (ctl?.fired)
|
|
15373
|
+
throw new KillRequested;
|
|
15374
|
+
const elapsed = deps.now() - t0;
|
|
15375
|
+
if (elapsed >= timeoutMs) {
|
|
15376
|
+
deps.sink({
|
|
15377
|
+
ts: new Date().toISOString(),
|
|
15378
|
+
event: "timeout",
|
|
15379
|
+
tier: req.agent,
|
|
15380
|
+
agent: req.agent,
|
|
15381
|
+
identity: r.model,
|
|
15382
|
+
depth: r.depth,
|
|
15383
|
+
sessionID: childID,
|
|
15384
|
+
durationMs: elapsed,
|
|
15385
|
+
...dispatchId !== undefined ? { dispatchId, parentSessionID: parentSessionID ?? "" } : {}
|
|
15386
|
+
});
|
|
15387
|
+
throw new DispatchError(`forge_dispatch timed out after ${elapsed}ms (deadline ${timeoutMs}ms). Partial state: child session ${childID} is left for the host to reclaim; its transcript remains queryable via the host API.`, "timeout");
|
|
15388
|
+
}
|
|
15389
|
+
await deps.sleep(pollIntervalMs);
|
|
15390
|
+
messages = await (ctl ? Promise.race([fetchMessages(childID), ctl.promise.then(() => [])]) : fetchMessages(childID));
|
|
15391
|
+
if (ctl?.fired)
|
|
15392
|
+
throw new KillRequested;
|
|
15393
|
+
poll = completionVerdict(poll, { count: messages.length, text: textOf(messages) });
|
|
15394
|
+
if (poll.verdict === "complete" || poll.verdict === "empty")
|
|
15395
|
+
break;
|
|
15396
|
+
}
|
|
15397
|
+
const durationMs = deps.now() - t0;
|
|
15398
|
+
if (poll.verdict === "empty") {
|
|
15399
|
+
deps.sink({
|
|
15400
|
+
ts: new Date().toISOString(),
|
|
15401
|
+
event: "empty-response",
|
|
15402
|
+
tier: req.agent,
|
|
15403
|
+
agent: req.agent,
|
|
15404
|
+
identity: r.model,
|
|
15405
|
+
depth: r.depth,
|
|
15406
|
+
sessionID: childID,
|
|
15407
|
+
durationMs,
|
|
15408
|
+
...dispatchId !== undefined ? { dispatchId, parentSessionID: parentSessionID ?? "" } : {}
|
|
15409
|
+
});
|
|
15410
|
+
throw new DispatchError(`child session ${childID} returned an empty response (0-token assistant message). This is a known quirk of keyless endpoints combined with restricted tiers — see the README dispatch notes. The dispatch is NOT counted as success.`, "empty-response");
|
|
15411
|
+
}
|
|
15412
|
+
const tokens = sumTokens(tokensOf(messages));
|
|
15413
|
+
const price = priceFor(deps.catalog, r.model);
|
|
15414
|
+
const cost = computeCost(tokens, price?.spec ?? null);
|
|
15415
|
+
const result = {
|
|
15416
|
+
agent: req.agent,
|
|
15417
|
+
requested: { agent: req.agent, depth: req.depth },
|
|
15418
|
+
actual: { model: r.model, depth: r.depth },
|
|
15419
|
+
sessionID: childID,
|
|
15420
|
+
durationMs,
|
|
15421
|
+
tokens,
|
|
15422
|
+
costUsd: cost.costUsd,
|
|
15423
|
+
...cost.note ? { costNote: cost.note } : {},
|
|
15424
|
+
...price?.label ? { priceSnapshot: price.label } : {},
|
|
15425
|
+
text: textOf(messages),
|
|
15426
|
+
depthTranslation: depthInjected
|
|
15427
|
+
};
|
|
15428
|
+
deps.sink({
|
|
15429
|
+
ts: new Date().toISOString(),
|
|
15430
|
+
event: "completed",
|
|
15431
|
+
tier: req.agent,
|
|
15432
|
+
agent: req.agent,
|
|
15433
|
+
identity: r.model,
|
|
15434
|
+
depth: r.depth,
|
|
15435
|
+
sessionID: childID,
|
|
15436
|
+
outcome: "completed",
|
|
15437
|
+
tokens,
|
|
15438
|
+
costUsd: cost.costUsd,
|
|
15439
|
+
durationMs,
|
|
15440
|
+
...dispatchId !== undefined ? { dispatchId, parentSessionID: parentSessionID ?? "" } : {}
|
|
15441
|
+
});
|
|
15442
|
+
return result;
|
|
15443
|
+
}
|
|
15444
|
+
function bgTerminal(reg, dispatchId, identity, req, t0, e) {
|
|
15445
|
+
let entrySessionID = "";
|
|
15446
|
+
let parentSessionID = "";
|
|
15447
|
+
try {
|
|
15448
|
+
const entry = reg.get(dispatchId);
|
|
15449
|
+
entrySessionID = entry.sessionID;
|
|
15450
|
+
parentSessionID = entry.parentSessionID;
|
|
15451
|
+
} catch {}
|
|
15452
|
+
const durationMs = deps.now() - t0;
|
|
15453
|
+
const report = {
|
|
15454
|
+
agent: req.agent,
|
|
15455
|
+
requested: { agent: req.agent, depth: req.depth },
|
|
15456
|
+
actual: { model: identity, depth: null },
|
|
15457
|
+
sessionID: entrySessionID,
|
|
15458
|
+
outcome: "error",
|
|
15459
|
+
durationMs,
|
|
15460
|
+
error: e instanceof Error ? e.message : String(e)
|
|
15461
|
+
};
|
|
15462
|
+
if (e instanceof DispatchError && e.code === "timeout") {
|
|
15463
|
+
report.outcome = "timeout";
|
|
15464
|
+
reg.markTimeout(dispatchId, report);
|
|
15465
|
+
deps.onTerminal?.(parentSessionID);
|
|
15466
|
+
return;
|
|
15467
|
+
}
|
|
15468
|
+
deps.sink({
|
|
15469
|
+
ts: new Date().toISOString(),
|
|
15470
|
+
event: "error",
|
|
15471
|
+
tier: req.agent,
|
|
15472
|
+
agent: req.agent,
|
|
15473
|
+
identity,
|
|
15474
|
+
outcome: "error",
|
|
15475
|
+
sessionID: entrySessionID,
|
|
15476
|
+
durationMs,
|
|
15477
|
+
note: report.error,
|
|
15478
|
+
dispatchId,
|
|
15479
|
+
parentSessionID
|
|
15480
|
+
});
|
|
15481
|
+
reg.markError(dispatchId, report);
|
|
15482
|
+
deps.onTerminal?.(parentSessionID);
|
|
15483
|
+
}
|
|
15484
|
+
async function dispatch(req, parentSessionID, opts) {
|
|
15485
|
+
const t0 = deps.now();
|
|
15486
|
+
if (inFlight.size >= maxConcurrent) {
|
|
15487
|
+
deps.sink({ ts: new Date().toISOString(), event: "refused-cap", tier: req.agent, agent: req.agent, note: `${inFlight.size} in flight` });
|
|
15488
|
+
throw new DispatchError(`forge_dispatch refused: ${inFlight.size} dispatches already in flight (cap ${maxConcurrent}). Wait for a completion and retry.`, "cap-refused");
|
|
15489
|
+
}
|
|
15490
|
+
const bg = opts?.background === true;
|
|
15491
|
+
if (bg && !deps.registry)
|
|
15492
|
+
throw new DispatchError("background dispatch requires the dispatch registry (internal error)", "host-error");
|
|
15493
|
+
const first = resolveForThrow(req, undefined);
|
|
15494
|
+
translationOrThrow(first.model, first.depth);
|
|
15495
|
+
if (bg) {
|
|
15496
|
+
const reg = deps.registry;
|
|
15497
|
+
const { dispatchId } = reg.submit({ sessionID: "", identity: first.model, depth: first.depth, tier: req.agent, parentSessionID: parentSessionID ?? "" });
|
|
15498
|
+
let fire = () => {};
|
|
15499
|
+
const promise2 = new Promise((res) => {
|
|
15500
|
+
fire = res;
|
|
15501
|
+
});
|
|
15502
|
+
const ctl = { fired: false, promise: promise2, fire: () => {
|
|
15503
|
+
ctl.fired = true;
|
|
15504
|
+
fire();
|
|
15505
|
+
} };
|
|
15506
|
+
killControls.set(dispatchId, ctl);
|
|
15507
|
+
const slot = `d${++seq}`;
|
|
15508
|
+
inFlight.add(slot);
|
|
15509
|
+
(async () => {
|
|
15510
|
+
try {
|
|
15511
|
+
if (reg.get(dispatchId).state === "killed")
|
|
15512
|
+
return;
|
|
15513
|
+
reg.markRunning(dispatchId);
|
|
15514
|
+
let current = first;
|
|
15515
|
+
let exclude2 = undefined;
|
|
15516
|
+
for (let attemptNo = 0;; attemptNo++) {
|
|
15517
|
+
try {
|
|
15518
|
+
const result = await runAttempt(current, req, parentSessionID, t0, ctl, (sid) => {
|
|
15519
|
+
try {
|
|
15520
|
+
reg.get(dispatchId).sessionID = sid;
|
|
15521
|
+
} catch {}
|
|
15522
|
+
}, dispatchId);
|
|
15523
|
+
if (reg.get(dispatchId).state === "killed") {
|
|
15524
|
+
reg.noteLateCompletion(dispatchId, result);
|
|
15525
|
+
deps.sink({ ts: new Date().toISOString(), event: "kill-late-completion", tier: req.agent, agent: req.agent, identity: current.model, sessionID: result.sessionID, outcome: "kill-late-completion" });
|
|
15526
|
+
} else {
|
|
15527
|
+
reg.markCompleted(dispatchId, result);
|
|
15528
|
+
deps.onTerminal?.(reg.get(dispatchId).parentSessionID);
|
|
15529
|
+
}
|
|
15530
|
+
return;
|
|
15531
|
+
} catch (e) {
|
|
15532
|
+
if (e instanceof KillRequested)
|
|
15533
|
+
return;
|
|
15534
|
+
if (reg.get(dispatchId).state === "killed")
|
|
15535
|
+
return;
|
|
15536
|
+
const retryable = !current.agentPath && e instanceof DispatchError && (e.code === "host-error" || e.code === "empty-response");
|
|
15537
|
+
const alternative = retryable ? resolveDispatch(deps.cfg, { profile: req.agent, depth: req.depth, exclude: [current.model] }, deps.available, deps.catalog) : { ok: false };
|
|
15538
|
+
if (!retryable || attemptNo > 0 || !alternative.ok) {
|
|
15539
|
+
bgTerminal(reg, dispatchId, current.model, req, t0, e);
|
|
15540
|
+
return;
|
|
15541
|
+
}
|
|
15542
|
+
deps.sink({ ts: new Date().toISOString(), event: "retry-excluded", tier: req.agent, agent: req.agent, identity: current.model, note: `${current.model}: ${e.code}` });
|
|
15543
|
+
exclude2 = [current.model];
|
|
15544
|
+
current = resolveForThrow(req, exclude2);
|
|
15545
|
+
reg.get(dispatchId).identity = current.model;
|
|
15546
|
+
}
|
|
15547
|
+
}
|
|
15548
|
+
} finally {
|
|
15549
|
+
inFlight.delete(slot);
|
|
15550
|
+
killControls.delete(dispatchId);
|
|
15551
|
+
}
|
|
15552
|
+
})();
|
|
15553
|
+
return {
|
|
15554
|
+
dispatchId,
|
|
15555
|
+
agent: req.agent,
|
|
15556
|
+
requested: { agent: req.agent, depth: req.depth },
|
|
15557
|
+
resolved: first.model,
|
|
15558
|
+
depth: first.depth,
|
|
15559
|
+
queuedAt: reg.get(dispatchId).queuedAt
|
|
15560
|
+
};
|
|
15561
|
+
}
|
|
15562
|
+
let exclude = undefined;
|
|
15563
|
+
for (let attemptNo = 0;; attemptNo++) {
|
|
15564
|
+
const r = attemptNo === 0 ? first : resolveForThrow(req, exclude);
|
|
15565
|
+
const slot = `d${++seq}`;
|
|
15566
|
+
inFlight.add(slot);
|
|
15567
|
+
try {
|
|
15568
|
+
return await runAttempt(r, req, parentSessionID, t0, undefined, undefined);
|
|
15569
|
+
} catch (e) {
|
|
15570
|
+
const identity = r.model;
|
|
15571
|
+
const retryable = !r.agentPath && e instanceof DispatchError && (e.code === "host-error" || e.code === "empty-response");
|
|
15572
|
+
if (!retryable || attemptNo > 0)
|
|
15573
|
+
throw e;
|
|
15574
|
+
const alternative = resolveDispatch(deps.cfg, { profile: req.agent, depth: req.depth, exclude: [identity] }, deps.available, deps.catalog);
|
|
15575
|
+
if (!alternative.ok)
|
|
15576
|
+
throw e;
|
|
15577
|
+
deps.sink({ ts: new Date().toISOString(), event: "retry-excluded", tier: req.agent, agent: req.agent, identity, note: `${identity}: ${e.code}` });
|
|
15578
|
+
exclude = [identity];
|
|
15579
|
+
} finally {
|
|
15580
|
+
inFlight.delete(slot);
|
|
15581
|
+
}
|
|
15582
|
+
}
|
|
15583
|
+
}
|
|
15584
|
+
return {
|
|
15585
|
+
dispatch,
|
|
15586
|
+
kill: (dispatchId) => {
|
|
15587
|
+
const reg = deps.registry;
|
|
15588
|
+
if (!reg)
|
|
15589
|
+
throw new DispatchError("forge_dispatch_kill unavailable: no dispatch registry (internal error)", "kill-failed");
|
|
15590
|
+
const e = reg.get(dispatchId);
|
|
15591
|
+
if (e.state === "completed" || e.state === "timeout" || e.state === "error") {
|
|
15592
|
+
throw new DispatchError(`forge_dispatch_kill refused: dispatch ${dispatchId} already ${e.state}; use forge_dispatch_list for its result.`, "kill-failed");
|
|
15593
|
+
}
|
|
15594
|
+
if (e.state === "killed")
|
|
15595
|
+
throw new DispatchError(`dispatch ${dispatchId} is already killed.`, "kill-failed");
|
|
15596
|
+
reg.markKilled(dispatchId);
|
|
15597
|
+
deps.sink({ ts: new Date().toISOString(), event: "killed", tier: e.tier, identity: e.identity, depth: e.depth ?? undefined, sessionID: e.sessionID, outcome: "killed" });
|
|
15598
|
+
killControls.get(dispatchId)?.fire();
|
|
15599
|
+
},
|
|
15600
|
+
inFlightCount: () => inFlight.size,
|
|
15601
|
+
inFlightIDs: () => [...inFlight],
|
|
15602
|
+
dispose: () => {
|
|
15603
|
+
for (const slot of inFlight) {
|
|
15604
|
+
deps.sink({ ts: new Date().toISOString(), event: "lost-on-exit", note: `dispatch ${slot} in flight at host exit` });
|
|
15605
|
+
}
|
|
15606
|
+
inFlight.clear();
|
|
15607
|
+
}
|
|
15608
|
+
};
|
|
15609
|
+
}
|
|
15610
|
+
|
|
15611
|
+
class KillRequested extends Error {
|
|
15612
|
+
constructor() {
|
|
15613
|
+
super("kill requested");
|
|
15614
|
+
}
|
|
15615
|
+
}
|
|
15616
|
+
function priceFor(catalog, identity) {
|
|
15617
|
+
const slash = identity.indexOf("/");
|
|
15618
|
+
if (slash <= 0)
|
|
15619
|
+
return null;
|
|
15620
|
+
const model = catalog.providers?.[identity.slice(0, slash)]?.models?.[identity.slice(slash + 1)];
|
|
15621
|
+
const cost = model?.cost;
|
|
15622
|
+
if (!cost)
|
|
15623
|
+
return null;
|
|
15624
|
+
return {
|
|
15625
|
+
spec: {
|
|
15626
|
+
input: cost.input,
|
|
15627
|
+
output: cost.output,
|
|
15628
|
+
cacheRead: cost.cache_read,
|
|
15629
|
+
cacheWrite: cost.cache_write
|
|
15630
|
+
},
|
|
15631
|
+
label: "models.dev snapshot"
|
|
15632
|
+
};
|
|
15633
|
+
}
|
|
15634
|
+
|
|
15635
|
+
// src/dispatch-ledger.ts
|
|
15636
|
+
import { mkdirSync as mkdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "node:fs";
|
|
15637
|
+
import { dirname as dirname3 } from "node:path";
|
|
15638
|
+
var DISPATCH_LEDGER_MAX_ENTRIES = 200;
|
|
15639
|
+
var DISPATCH_LEDGER_MAX_BYTES = 1e6;
|
|
15640
|
+
function createDispatchLedger(logPath, maxEntries = DISPATCH_LEDGER_MAX_ENTRIES, maxBytes = DISPATCH_LEDGER_MAX_BYTES) {
|
|
15641
|
+
const append = (entry) => {
|
|
15642
|
+
try {
|
|
15643
|
+
mkdirSync4(dirname3(logPath), { recursive: true });
|
|
15644
|
+
let lines = [];
|
|
15645
|
+
try {
|
|
15646
|
+
lines = readFileSync6(logPath, "utf8").split(`
|
|
15647
|
+
`).filter((l) => l.trim().length > 0);
|
|
15648
|
+
} catch {}
|
|
15649
|
+
if (lines.length >= maxEntries)
|
|
15650
|
+
lines = lines.slice(lines.length - maxEntries + 1);
|
|
15651
|
+
lines.push(JSON.stringify(entry));
|
|
15652
|
+
let text = lines.join(`
|
|
15653
|
+
`) + `
|
|
15654
|
+
`;
|
|
15655
|
+
if (text.length > maxBytes) {
|
|
15656
|
+
const keep = Math.max(1, Math.floor(maxEntries / 2));
|
|
15657
|
+
text = lines.slice(-keep).join(`
|
|
15658
|
+
`) + `
|
|
15659
|
+
`;
|
|
15660
|
+
}
|
|
15661
|
+
writeFileSync4(logPath, text, "utf8");
|
|
15662
|
+
} catch {}
|
|
15663
|
+
};
|
|
15664
|
+
const entries = () => {
|
|
15665
|
+
try {
|
|
15666
|
+
return readFileSync6(logPath, "utf8").split(`
|
|
15667
|
+
`).filter((l) => l.trim().length > 0).map((l) => JSON.parse(l));
|
|
15668
|
+
} catch {
|
|
15669
|
+
return [];
|
|
15670
|
+
}
|
|
15671
|
+
};
|
|
15672
|
+
return { append, entries, path: logPath };
|
|
15673
|
+
}
|
|
15674
|
+
|
|
15675
|
+
// src/dispatch-registry.ts
|
|
15676
|
+
var TERMINAL_CAP = 50;
|
|
15677
|
+
function createDispatchRegistry() {
|
|
15678
|
+
const entries = new Map;
|
|
15679
|
+
let seq = 0;
|
|
15680
|
+
const fail = (msg) => {
|
|
15681
|
+
throw new Error(`[forge:dispatch-registry] ${msg}`);
|
|
15682
|
+
};
|
|
15683
|
+
const isTerminal2 = (st) => st === "completed" || st === "timeout" || st === "killed" || st === "error";
|
|
15684
|
+
return {
|
|
15685
|
+
submit(entry) {
|
|
15686
|
+
const dispatchId = `bg-${++seq}`;
|
|
15687
|
+
entries.set(dispatchId, {
|
|
15688
|
+
...entry,
|
|
15689
|
+
dispatchId,
|
|
15690
|
+
background: true,
|
|
15691
|
+
queuedAt: entry.queuedAt ?? new Date().toISOString(),
|
|
15692
|
+
state: "queued",
|
|
15693
|
+
delivered: false
|
|
15694
|
+
});
|
|
15695
|
+
return { dispatchId };
|
|
15696
|
+
},
|
|
15697
|
+
get(dispatchId) {
|
|
15698
|
+
const e = entries.get(dispatchId);
|
|
15699
|
+
if (!e)
|
|
15700
|
+
throw new Error(`[forge:dispatch-registry] unknown dispatch id "${dispatchId}"`);
|
|
15701
|
+
return e;
|
|
15702
|
+
},
|
|
15703
|
+
markRunning(dispatchId) {
|
|
15704
|
+
const e = this.get(dispatchId);
|
|
15705
|
+
if (e.state !== "queued")
|
|
15706
|
+
fail(`illegal transition ${e.state} → running for ${dispatchId}`);
|
|
15707
|
+
e.state = "running";
|
|
15708
|
+
return e;
|
|
15709
|
+
},
|
|
15710
|
+
markCompleted(dispatchId, result) {
|
|
15711
|
+
return this.terminate(dispatchId, "completed", result);
|
|
15712
|
+
},
|
|
15713
|
+
markTimeout(dispatchId, report) {
|
|
15714
|
+
return this.terminate(dispatchId, "timeout", report);
|
|
15715
|
+
},
|
|
15716
|
+
markError(dispatchId, report) {
|
|
15717
|
+
return this.terminate(dispatchId, "error", report);
|
|
15718
|
+
},
|
|
15719
|
+
markKilled(dispatchId) {
|
|
15720
|
+
return this.terminate(dispatchId, "killed");
|
|
15721
|
+
},
|
|
15722
|
+
noteLateCompletion(dispatchId, result) {
|
|
15723
|
+
const e = this.get(dispatchId);
|
|
15724
|
+
if (e.state !== "killed")
|
|
15725
|
+
fail(`late completion only makes sense on a killed dispatch (${dispatchId} is ${e.state})`);
|
|
15726
|
+
e.lateCompletion = result;
|
|
15727
|
+
},
|
|
15728
|
+
terminate(dispatchId, state, result) {
|
|
15729
|
+
const e = this.get(dispatchId);
|
|
15730
|
+
if (isTerminal2(e.state))
|
|
15731
|
+
fail(`illegal transition ${e.state} → ${state} for ${dispatchId}`);
|
|
15732
|
+
e.state = state;
|
|
15733
|
+
if (result !== undefined)
|
|
15734
|
+
e.result = result;
|
|
15735
|
+
return e;
|
|
15736
|
+
},
|
|
15737
|
+
takeUndelivered(parentSessionID) {
|
|
15738
|
+
const out = [];
|
|
15739
|
+
for (const e of entries.values()) {
|
|
15740
|
+
if (parentSessionID !== undefined && e.parentSessionID !== parentSessionID)
|
|
15741
|
+
continue;
|
|
15742
|
+
if (isTerminal2(e.state) && !e.delivered && e.state !== "killed") {
|
|
15743
|
+
e.delivered = true;
|
|
15744
|
+
out.push(e);
|
|
15745
|
+
}
|
|
15746
|
+
}
|
|
15747
|
+
return out;
|
|
15748
|
+
},
|
|
15749
|
+
list() {
|
|
15750
|
+
const inFlight = [];
|
|
15751
|
+
const terminal = [];
|
|
15752
|
+
for (const e of entries.values()) {
|
|
15753
|
+
if (isTerminal2(e.state))
|
|
15754
|
+
terminal.push(e);
|
|
15755
|
+
else
|
|
15756
|
+
inFlight.push(e);
|
|
15757
|
+
}
|
|
15758
|
+
return { inFlight, terminal: terminal.slice(-TERMINAL_CAP) };
|
|
15759
|
+
}
|
|
15760
|
+
};
|
|
15761
|
+
}
|
|
15762
|
+
|
|
15763
|
+
// src/crew-gate.ts
|
|
15764
|
+
var CREW_DISPATCH_EVENTS = new Set(["completed", "timeout", "error", "killed", "empty-response"]);
|
|
15765
|
+
var DISPATCH_ID = /bg-\d+/g;
|
|
15766
|
+
function validateCrewReport(report, ledgerRows, crew) {
|
|
15767
|
+
const gaps = [];
|
|
15768
|
+
if (!Array.isArray(report) || report.length === 0) {
|
|
15769
|
+
return { ok: false, gaps: ["the report lists no subtasks — a crew completion report must cover every subtask"], dispatchIds: [] };
|
|
15770
|
+
}
|
|
15771
|
+
const rowById = new Map;
|
|
15772
|
+
for (const row of ledgerRows) {
|
|
15773
|
+
if (row.parentSessionID !== crew.sessionID)
|
|
15774
|
+
continue;
|
|
15775
|
+
if (row.ts < crew.startedAt)
|
|
15776
|
+
continue;
|
|
15777
|
+
if (row.event === "validation" || row.event === "resolve-error" || row.event === "refused-cap" || row.event === "models-dev-degraded" || row.event === "retry-excluded" || row.event === "lost-on-exit" || row.event === "kill-late-completion" || row.event === "transport-interrupted")
|
|
15778
|
+
continue;
|
|
15779
|
+
if (CREW_DISPATCH_EVENTS.has(row.event) && row.dispatchId)
|
|
15780
|
+
rowById.set(row.dispatchId, row);
|
|
15781
|
+
}
|
|
15782
|
+
const referenced = new Set;
|
|
15783
|
+
report.forEach((sub, i) => {
|
|
15784
|
+
const label = sub?.title?.trim() || `subtask #${i + 1}`;
|
|
15785
|
+
const verdict = String(sub?.verdict ?? "").toUpperCase();
|
|
15786
|
+
if (!sub?.title?.trim())
|
|
15787
|
+
gaps.push(`${label}: missing title`);
|
|
15788
|
+
if (verdict !== "PASS" && verdict !== "FAIL")
|
|
15789
|
+
gaps.push(`${label}: verdict must be PASS or FAIL (got "${sub?.verdict ?? ""}")`);
|
|
15790
|
+
const ids = [...String(sub?.evidence ?? "").matchAll(DISPATCH_ID)].map((m) => m[0]);
|
|
15791
|
+
if (!String(sub?.evidence ?? "").trim())
|
|
15792
|
+
gaps.push(`${label}: missing evidence reference`);
|
|
15793
|
+
else if (ids.length === 0)
|
|
15794
|
+
gaps.push(`${label}: evidence must reference a dispatch id (bg-N) that exists in the dispatch ledger`);
|
|
15795
|
+
for (const id of ids) {
|
|
15796
|
+
if (referenced.has(id))
|
|
15797
|
+
gaps.push(`${label}: dispatch ${id} is already cited by another subtask — each dispatch belongs to exactly one verdict`);
|
|
15798
|
+
referenced.add(id);
|
|
15799
|
+
const row = rowById.get(id);
|
|
15800
|
+
if (!row)
|
|
15801
|
+
gaps.push(`${label}: dispatch ${id} has no ledger row for this crew (fabricated, stale, or pre-crew)`);
|
|
15802
|
+
else if (verdict === "PASS" && row.event !== "completed") {
|
|
15803
|
+
gaps.push(`${label}: marked PASS but dispatch ${id} ended ${row.event} — a subtask whose attempt and retry both failed must be marked FAIL with both reports visible`);
|
|
15804
|
+
}
|
|
15805
|
+
}
|
|
15806
|
+
if (verdict === "FAIL" && (!Array.isArray(sub?.attempts) || sub.attempts.length < 2)) {
|
|
15807
|
+
gaps.push(`${label}: FAIL requires both failure reports (the attempt and the one retry) in attempts[]`);
|
|
15808
|
+
}
|
|
15809
|
+
});
|
|
15810
|
+
for (const [id, row] of rowById) {
|
|
15811
|
+
if (!referenced.has(id))
|
|
15812
|
+
gaps.push(`dispatch ${id} (${row.event}, ${row.ts}) has no verdict in the report — a dispatched subtask was silently dropped`);
|
|
15813
|
+
}
|
|
15814
|
+
return { ok: gaps.length === 0, gaps, dispatchIds: [...rowById.keys()] };
|
|
15815
|
+
}
|
|
15816
|
+
|
|
15817
|
+
// src/forge-config.ts
|
|
15818
|
+
import { readFileSync as readFileSync7, statSync as statSync3 } from "node:fs";
|
|
15819
|
+
function sanitizeJsonc(text) {
|
|
15820
|
+
const out = [];
|
|
15821
|
+
let i = 0;
|
|
15822
|
+
const n = text.length;
|
|
15823
|
+
let pendingComma = false;
|
|
15824
|
+
const flushComma = (nextChar) => {
|
|
15825
|
+
if (pendingComma) {
|
|
15826
|
+
if (nextChar !== "}" && nextChar !== "]")
|
|
15827
|
+
out.push(",");
|
|
15828
|
+
pendingComma = false;
|
|
15829
|
+
}
|
|
15830
|
+
};
|
|
15831
|
+
while (i < n) {
|
|
15832
|
+
const c = text[i];
|
|
15833
|
+
const d = i + 1 < n ? text[i + 1] : "";
|
|
15834
|
+
if (c === "/" && d === "/") {
|
|
15835
|
+
while (i < n && text[i] !== `
|
|
15836
|
+
`) {
|
|
15837
|
+
out.push(" ");
|
|
15838
|
+
i++;
|
|
15839
|
+
}
|
|
15840
|
+
continue;
|
|
15841
|
+
}
|
|
15842
|
+
if (c === "/" && d === "*") {
|
|
15843
|
+
out.push(" ", " ");
|
|
15844
|
+
i += 2;
|
|
15845
|
+
while (i < n && !(text[i] === "*" && i + 1 < n && text[i + 1] === "/")) {
|
|
15846
|
+
out.push(text[i] === `
|
|
15847
|
+
` ? `
|
|
15848
|
+
` : " ");
|
|
15849
|
+
i++;
|
|
15850
|
+
}
|
|
15851
|
+
if (i < n) {
|
|
15852
|
+
out.push(" ", " ");
|
|
15853
|
+
i += 2;
|
|
15854
|
+
}
|
|
15855
|
+
continue;
|
|
15856
|
+
}
|
|
15857
|
+
if (c === '"') {
|
|
15858
|
+
flushComma(c);
|
|
15859
|
+
out.push(c);
|
|
15860
|
+
i++;
|
|
15861
|
+
while (i < n && text[i] !== '"') {
|
|
15862
|
+
out.push(text[i]);
|
|
15863
|
+
if (text[i] === "\\") {
|
|
15864
|
+
i++;
|
|
15865
|
+
if (i < n)
|
|
15866
|
+
out.push(text[i]);
|
|
15867
|
+
}
|
|
15868
|
+
i++;
|
|
15869
|
+
}
|
|
15870
|
+
if (i < n) {
|
|
15871
|
+
out.push('"');
|
|
15872
|
+
i++;
|
|
15873
|
+
}
|
|
15874
|
+
continue;
|
|
15875
|
+
}
|
|
15876
|
+
if (c === ",") {
|
|
15877
|
+
pendingComma = true;
|
|
15878
|
+
out.push(" ");
|
|
15879
|
+
i++;
|
|
15880
|
+
continue;
|
|
15881
|
+
}
|
|
15882
|
+
if (c === " " || c === "\t" || c === `
|
|
15883
|
+
` || c === "\r") {
|
|
15884
|
+
out.push(c);
|
|
15885
|
+
i++;
|
|
15886
|
+
continue;
|
|
15887
|
+
}
|
|
15888
|
+
flushComma(c);
|
|
15889
|
+
out.push(c);
|
|
15890
|
+
i++;
|
|
15891
|
+
}
|
|
15892
|
+
return out.join("");
|
|
15893
|
+
}
|
|
15894
|
+
function lineColumnAt(text, offset) {
|
|
15895
|
+
let line = 1;
|
|
15896
|
+
let lineStart = 0;
|
|
15897
|
+
for (let i = 0;i < offset && i < text.length; i++) {
|
|
15898
|
+
if (text[i] === `
|
|
15899
|
+
`) {
|
|
15900
|
+
line++;
|
|
15901
|
+
lineStart = i + 1;
|
|
15902
|
+
}
|
|
15903
|
+
}
|
|
15904
|
+
return { line, column: offset - lineStart + 1 };
|
|
15905
|
+
}
|
|
15906
|
+
function locateJsonError(text) {
|
|
15907
|
+
const n = text.length;
|
|
15908
|
+
let i = 0;
|
|
15909
|
+
const stack = [];
|
|
15910
|
+
const ws = () => {
|
|
15911
|
+
while (i < n && (text[i] === " " || text[i] === "\t" || text[i] === `
|
|
15912
|
+
` || text[i] === "\r"))
|
|
15913
|
+
i++;
|
|
15914
|
+
};
|
|
15915
|
+
const fail = (why) => {
|
|
15916
|
+
const { line, column } = lineColumnAt(text, Math.min(i, n));
|
|
15917
|
+
return { message: `${why} (line ${line}, column ${column})`, line, column };
|
|
15918
|
+
};
|
|
15919
|
+
const skipString = () => {
|
|
15920
|
+
if (text[i] !== '"')
|
|
15921
|
+
return null;
|
|
15922
|
+
i++;
|
|
15923
|
+
while (i < n && text[i] !== '"') {
|
|
15924
|
+
if (text[i] === "\\")
|
|
15925
|
+
i++;
|
|
15926
|
+
i++;
|
|
15927
|
+
}
|
|
15928
|
+
if (i >= n)
|
|
15929
|
+
return "unterminated string";
|
|
15930
|
+
i++;
|
|
15931
|
+
return null;
|
|
15932
|
+
};
|
|
15933
|
+
let expectValue = true;
|
|
15934
|
+
ws();
|
|
15935
|
+
if (i >= n)
|
|
15936
|
+
return { message: "empty document", line: 1, column: 1 };
|
|
15937
|
+
for (;; ) {
|
|
15938
|
+
ws();
|
|
15939
|
+
if (i >= n)
|
|
15940
|
+
return stack.length === 0 ? null : fail("unexpected end of document");
|
|
15941
|
+
const top = stack[stack.length - 1];
|
|
15942
|
+
const c = text[i];
|
|
15943
|
+
if (expectValue) {
|
|
15944
|
+
if (c === "{") {
|
|
15945
|
+
stack.push("o");
|
|
15946
|
+
i++;
|
|
15947
|
+
expectValue = false;
|
|
15948
|
+
continue;
|
|
15949
|
+
}
|
|
15950
|
+
if (c === "[") {
|
|
15951
|
+
stack.push("a");
|
|
15952
|
+
i++;
|
|
15953
|
+
continue;
|
|
15954
|
+
}
|
|
15955
|
+
if (c === "}") {
|
|
15956
|
+
return fail(`expected a value before '}'`);
|
|
15957
|
+
}
|
|
15958
|
+
if (c === "]") {
|
|
15959
|
+
if (top === "a" || top === "av") {
|
|
15960
|
+
stack.pop();
|
|
15961
|
+
expectValue = false;
|
|
15962
|
+
if (stack.length === 0) {
|
|
15963
|
+
ws();
|
|
15964
|
+
return i >= n ? null : fail(`unexpected token '${text[i]}' after top-level value`);
|
|
15965
|
+
}
|
|
15966
|
+
i++;
|
|
15967
|
+
continue;
|
|
15968
|
+
}
|
|
15969
|
+
return fail(`unexpected ']'`);
|
|
15970
|
+
}
|
|
15971
|
+
if (c === '"') {
|
|
15972
|
+
const err = skipString();
|
|
15973
|
+
if (err)
|
|
15974
|
+
return fail(err);
|
|
15975
|
+
expectValue = false;
|
|
15976
|
+
if (top === "o")
|
|
15977
|
+
stack[stack.length - 1] = "ov";
|
|
15978
|
+
if (top === "a")
|
|
15979
|
+
stack[stack.length - 1] = "av";
|
|
15980
|
+
continue;
|
|
15981
|
+
}
|
|
15982
|
+
if (c === "-" || c >= "0" && c <= "9" || c === "t" || c === "f" || c === "n") {
|
|
15983
|
+
while (i < n && !`{}[],:"
|
|
15984
|
+
\r`.includes(text[i]))
|
|
15985
|
+
i++;
|
|
15986
|
+
expectValue = false;
|
|
15987
|
+
if (top === "a")
|
|
15988
|
+
stack[stack.length - 1] = "av";
|
|
15989
|
+
continue;
|
|
15990
|
+
}
|
|
15991
|
+
return fail(`unexpected token '${c}'`);
|
|
15992
|
+
}
|
|
15993
|
+
if (top === "o") {
|
|
15994
|
+
if (c === '"') {
|
|
15995
|
+
const err = skipString();
|
|
15996
|
+
if (err)
|
|
15997
|
+
return fail(err);
|
|
15998
|
+
ws();
|
|
15999
|
+
if (text[i] !== ":")
|
|
16000
|
+
return fail("expected ':' after object key");
|
|
16001
|
+
i++;
|
|
16002
|
+
expectValue = true;
|
|
16003
|
+
continue;
|
|
16004
|
+
}
|
|
16005
|
+
return fail(`expected '"' or '}' in object, got '${c}'`);
|
|
16006
|
+
}
|
|
16007
|
+
if (top === "ov" || top === "av") {
|
|
16008
|
+
if (c === ",") {
|
|
16009
|
+
i++;
|
|
16010
|
+
expectValue = true;
|
|
16011
|
+
if (top === "ov")
|
|
16012
|
+
stack[stack.length - 1] = "o";
|
|
16013
|
+
else
|
|
16014
|
+
stack[stack.length - 1] = "a";
|
|
16015
|
+
continue;
|
|
16016
|
+
}
|
|
16017
|
+
if (top === "ov" && c === "}" || top === "av" && c === "]") {
|
|
16018
|
+
stack.pop();
|
|
16019
|
+
if (stack.length === 0) {
|
|
16020
|
+
i++;
|
|
16021
|
+
ws();
|
|
16022
|
+
return i >= n ? null : fail(`unexpected token '${text[i]}' after top-level value`);
|
|
16023
|
+
}
|
|
16024
|
+
i++;
|
|
16025
|
+
continue;
|
|
16026
|
+
}
|
|
16027
|
+
return fail(`expected ',' or closing bracket, got '${c}'`);
|
|
16028
|
+
}
|
|
16029
|
+
if (c === "]") {
|
|
16030
|
+
stack.pop();
|
|
16031
|
+
if (stack.length === 0) {
|
|
16032
|
+
i++;
|
|
16033
|
+
ws();
|
|
16034
|
+
return i >= n ? null : fail(`unexpected token '${text[i]}' after top-level value`);
|
|
16035
|
+
}
|
|
16036
|
+
i++;
|
|
16037
|
+
continue;
|
|
16038
|
+
}
|
|
16039
|
+
return fail(`unexpected token '${c}'`);
|
|
16040
|
+
}
|
|
16041
|
+
}
|
|
16042
|
+
function parseForgeJsonc(text) {
|
|
16043
|
+
const sanitized = sanitizeJsonc(text);
|
|
16044
|
+
let parsed;
|
|
16045
|
+
try {
|
|
16046
|
+
parsed = JSON.parse(sanitized);
|
|
16047
|
+
} catch {
|
|
16048
|
+
const located = locateJsonError(sanitized);
|
|
16049
|
+
return {
|
|
16050
|
+
ok: false,
|
|
16051
|
+
error: located ?? { message: "invalid JSON", line: 1, column: 1 }
|
|
16052
|
+
};
|
|
16053
|
+
}
|
|
16054
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
16055
|
+
return { ok: false, error: { message: "forge.json top level must be an object", line: 1, column: 1 } };
|
|
16056
|
+
}
|
|
16057
|
+
const agents = parsed.agents;
|
|
16058
|
+
if (agents === undefined)
|
|
16059
|
+
return { ok: true, config: { agents: {} } };
|
|
16060
|
+
if (typeof agents !== "object" || agents === null || Array.isArray(agents)) {
|
|
16061
|
+
return { ok: false, error: { message: 'forge.json "agents" must be an object mapping agent ids to definitions', line: 1, column: 1 } };
|
|
16062
|
+
}
|
|
16063
|
+
return { ok: true, config: { agents } };
|
|
16064
|
+
}
|
|
16065
|
+
function forgeRecipe(input) {
|
|
16066
|
+
const suggested = input.detectedIdentities[0] ?? "provider/model";
|
|
16067
|
+
const identityLines = input.detectedIdentities.length > 0 ? input.detectedIdentities.map((id) => ` - ${id}`).join(`
|
|
16068
|
+
`) : " (none detected — check the provider section of the host's opencode config)";
|
|
16069
|
+
const template = `{
|
|
16070
|
+
// forge dispatch agents — save the file; changes apply on the next dispatch, no restart
|
|
16071
|
+
"agents": {
|
|
16072
|
+
"research": {
|
|
16073
|
+
"model": "${suggested}", // exact "provider/model" string, pick from the detected list above
|
|
16074
|
+
"depths": ["low", "medium"], // first entry is the default; canonical words: none, low, medium, high, max — or the model's native level names, passed through verbatim
|
|
16075
|
+
// "prompt": "optional role prompt riding inside the discipline wrapper",
|
|
16076
|
+
// "shape": "write", // default readonly denies mutating tools; write agents need this
|
|
16077
|
+
// "permission": { "bash": "deny" } // optional override of the shape-derived permission
|
|
16078
|
+
}
|
|
16079
|
+
}
|
|
16080
|
+
}`;
|
|
16081
|
+
return [
|
|
16082
|
+
"[forge dispatch onboarding recipe]",
|
|
16083
|
+
'The dispatch agent you called is pinned to the placeholder model "Local/GPT Luna" — forge.json is not configured. Steps:',
|
|
16084
|
+
"",
|
|
16085
|
+
"1. Detected configured identities (use one as the exact model string):",
|
|
16086
|
+
identityLines,
|
|
16087
|
+
"",
|
|
16088
|
+
"2. Create ONE of these files (project-level wins; a single file fully applies — never merged):",
|
|
16089
|
+
` - ${input.projectPath} (project-level, versionable, team-shared)`,
|
|
16090
|
+
` - ${input.globalPath} (global)`,
|
|
16091
|
+
"",
|
|
16092
|
+
"3. Copy-paste template (JSONC — comments allowed) and edit model/depths:",
|
|
16093
|
+
"",
|
|
16094
|
+
template,
|
|
16095
|
+
"",
|
|
16096
|
+
'4. Verify: call the forge_dispatch tool again with agent "research" and a depth from its depths — the report shows the actual model and the depth translation.',
|
|
16097
|
+
"",
|
|
16098
|
+
'5. Extending: more agents are more entries under "agents"; agents that edit files need "shape": "write"; an explicit "permission" map overrides the shape-derived defaults — "task" is always denied, recursive dispatch stays impossible.'
|
|
16099
|
+
].join(`
|
|
16100
|
+
`);
|
|
16101
|
+
}
|
|
16102
|
+
var PLACEHOLDER_IDENTITY = "Local/GPT Luna";
|
|
16103
|
+
var SEED_AGENTS = {
|
|
16104
|
+
research: { model: PLACEHOLDER_IDENTITY, depths: ["low", "medium"], shape: "readonly" },
|
|
16105
|
+
review: { model: PLACEHOLDER_IDENTITY, depths: ["medium", "high", "max"], shape: "readonly" }
|
|
16106
|
+
};
|
|
16107
|
+
function forgeConfigPaths(opts) {
|
|
16108
|
+
const sep2 = opts.projectDir?.includes("\\") && !opts.projectDir?.includes("/") ? "\\" : "/";
|
|
16109
|
+
const project = opts.projectDir ? `${opts.projectDir}${sep2}.opencode${sep2}forge.json` : "";
|
|
16110
|
+
const global = opts.homeDir ? `${opts.homeDir}${sep2}.config${sep2}opencode${sep2}forge.json` : "";
|
|
16111
|
+
return { project, global };
|
|
16112
|
+
}
|
|
16113
|
+
function createForgeConfigLoader(deps) {
|
|
16114
|
+
const stat = deps.stat ?? ((path) => {
|
|
16115
|
+
try {
|
|
16116
|
+
const s = statSync3(path);
|
|
16117
|
+
return { mtimeMs: s.mtimeMs, size: s.size };
|
|
16118
|
+
} catch {
|
|
16119
|
+
return null;
|
|
16120
|
+
}
|
|
16121
|
+
});
|
|
16122
|
+
const readFile = deps.readFile ?? ((path) => readFileSync7(path, "utf8"));
|
|
16123
|
+
const paths = forgeConfigPaths({ projectDir: deps.projectDir, homeDir: deps.homeDir });
|
|
16124
|
+
const cache = new Map;
|
|
16125
|
+
const readCandidate = (path, source) => {
|
|
16126
|
+
const st = stat(path);
|
|
16127
|
+
if (st === null) {
|
|
16128
|
+
cache.delete(path);
|
|
16129
|
+
return null;
|
|
16130
|
+
}
|
|
16131
|
+
const hit = cache.get(path);
|
|
16132
|
+
if (hit && hit.mtimeMs === st.mtimeMs && hit.size === st.size)
|
|
16133
|
+
return hit.result;
|
|
16134
|
+
const text = readFile(path);
|
|
16135
|
+
const parsed = parseForgeJsonc(text);
|
|
16136
|
+
let result;
|
|
16137
|
+
if (!parsed.ok) {
|
|
16138
|
+
result = {
|
|
16139
|
+
agents: { ...SEED_AGENTS },
|
|
16140
|
+
source: "seed",
|
|
16141
|
+
path: null,
|
|
16142
|
+
findings: [
|
|
16143
|
+
{
|
|
16144
|
+
level: "error",
|
|
16145
|
+
code: "config-parse-error",
|
|
16146
|
+
message: `${path}: ${parsed.error.message}${parsed.error.line !== undefined ? ` (line ${parsed.error.line}, column ${parsed.error.column})` : ""} — fell back to the built-in seed`
|
|
16147
|
+
}
|
|
16148
|
+
]
|
|
16149
|
+
};
|
|
16150
|
+
} else {
|
|
16151
|
+
result = { agents: parsed.config.agents, source, path, findings: [] };
|
|
16152
|
+
}
|
|
16153
|
+
cache.set(path, { mtimeMs: st.mtimeMs, size: st.size, result });
|
|
16154
|
+
return result;
|
|
16155
|
+
};
|
|
16156
|
+
return {
|
|
16157
|
+
load() {
|
|
16158
|
+
const project = paths.project ? readCandidate(paths.project, "project") : null;
|
|
16159
|
+
if (project)
|
|
16160
|
+
return project;
|
|
16161
|
+
const global = paths.global ? readCandidate(paths.global, "global") : null;
|
|
16162
|
+
if (global)
|
|
16163
|
+
return global;
|
|
16164
|
+
return {
|
|
16165
|
+
agents: { ...SEED_AGENTS },
|
|
16166
|
+
source: "seed",
|
|
16167
|
+
path: null,
|
|
16168
|
+
findings: [
|
|
16169
|
+
{
|
|
16170
|
+
level: "notice",
|
|
16171
|
+
code: "dispatch-unconfigured",
|
|
16172
|
+
message: "forge dispatch is unconfigured (no forge.json found): every forge_dispatch call will fail with the configuration recipe. Create .opencode/forge.json (project) or ~/.config/opencode/forge.json (global), or ask your session AI to configure dispatch — the recipe in the dispatch error tells it exactly what to write."
|
|
16173
|
+
}
|
|
16174
|
+
]
|
|
16175
|
+
};
|
|
16176
|
+
}
|
|
16177
|
+
};
|
|
16178
|
+
}
|
|
16179
|
+
|
|
16180
|
+
// src/models-dev.ts
|
|
16181
|
+
import { mkdirSync as mkdirSync5, readFileSync as readFileSync8, writeFileSync as writeFileSync5 } from "node:fs";
|
|
16182
|
+
import { join as join4 } from "node:path";
|
|
16183
|
+
import { tmpdir as tmpdir2 } from "node:os";
|
|
16184
|
+
var DEFAULT_MODELS_DEV_URL = "https://models.dev/api.json";
|
|
16185
|
+
function modelsDevCacheDir(base) {
|
|
16186
|
+
return join4(base ?? join4(tmpdir2(), "opencode-forge"), "models-dev");
|
|
16187
|
+
}
|
|
16188
|
+
function namedLevels(rows) {
|
|
16189
|
+
const out = [];
|
|
16190
|
+
for (const r of rows) {
|
|
16191
|
+
if (r && typeof r === "object" && r.type === "effort") {
|
|
16192
|
+
const values = r.values;
|
|
16193
|
+
if (Array.isArray(values)) {
|
|
16194
|
+
for (const v of values)
|
|
16195
|
+
if (typeof v === "string")
|
|
16196
|
+
out.push(v);
|
|
16197
|
+
}
|
|
16198
|
+
}
|
|
16199
|
+
}
|
|
16200
|
+
return out;
|
|
16201
|
+
}
|
|
16202
|
+
function reduceModelsDev(raw) {
|
|
16203
|
+
const catalog = { providers: {} };
|
|
16204
|
+
const providers = raw?.constructor === Object ? raw : null;
|
|
16205
|
+
if (!providers)
|
|
16206
|
+
return catalog;
|
|
16207
|
+
for (const [providerID, provider] of Object.entries(providers)) {
|
|
16208
|
+
const models = provider.models;
|
|
16209
|
+
if (!models || typeof models !== "object")
|
|
16210
|
+
continue;
|
|
16211
|
+
const out = { models: {} };
|
|
16212
|
+
for (const [modelID, model] of Object.entries(models)) {
|
|
16213
|
+
const m = model;
|
|
16214
|
+
const options = Array.isArray(m.reasoning_options) ? namedLevels(m.reasoning_options) : undefined;
|
|
16215
|
+
const cost = m.cost && typeof m.cost === "object" ? m.cost : undefined;
|
|
16216
|
+
out.models[modelID] = {
|
|
16217
|
+
...options ? { reasoningOptions: options } : {},
|
|
16218
|
+
...cost ? { cost } : {}
|
|
16219
|
+
};
|
|
16220
|
+
}
|
|
16221
|
+
catalog.providers[providerID] = out;
|
|
16222
|
+
}
|
|
16223
|
+
return catalog;
|
|
16224
|
+
}
|
|
16225
|
+
async function loadModelsDevSnapshot(opts = {}) {
|
|
16226
|
+
const url2 = opts.url ?? process.env.OPENCODE_MODELS_URL ?? DEFAULT_MODELS_DEV_URL;
|
|
16227
|
+
const cacheDir = modelsDevCacheDir(opts.cacheDir);
|
|
16228
|
+
const cacheFile = join4(cacheDir, "snapshot.json");
|
|
16229
|
+
const fetcher = opts.fetcher;
|
|
16230
|
+
if (fetcher) {
|
|
16231
|
+
try {
|
|
16232
|
+
const res = await fetcher(url2, { signal: AbortSignal.timeout(opts.timeoutMs ?? 8000) });
|
|
16233
|
+
if (res.ok)
|
|
16234
|
+
return { catalog: reduceModelsDev(JSON.parse(await res.text())), degraded: false, source: url2 };
|
|
16235
|
+
} catch {}
|
|
16236
|
+
return { catalog: { providers: {} }, degraded: true, source: "fetch-failed" };
|
|
16237
|
+
}
|
|
16238
|
+
try {
|
|
16239
|
+
const res = await fetch(url2, { signal: AbortSignal.timeout(opts.timeoutMs ?? 8000) });
|
|
16240
|
+
if (res.ok) {
|
|
16241
|
+
const catalog = reduceModelsDev(JSON.parse(await res.text()));
|
|
16242
|
+
try {
|
|
16243
|
+
mkdirSync5(cacheDir, { recursive: true });
|
|
16244
|
+
writeFileSync5(cacheFile, JSON.stringify(catalog));
|
|
16245
|
+
} catch {}
|
|
16246
|
+
return { catalog, degraded: false, source: url2 };
|
|
16247
|
+
}
|
|
16248
|
+
} catch {}
|
|
16249
|
+
try {
|
|
16250
|
+
const cached2 = JSON.parse(readFileSync8(cacheFile, "utf8"));
|
|
16251
|
+
if (cached2 && typeof cached2 === "object" && cached2.providers)
|
|
16252
|
+
return { catalog: cached2, degraded: true, source: "cache" };
|
|
16253
|
+
} catch {}
|
|
16254
|
+
return { catalog: { providers: {} }, degraded: true, source: "unavailable" };
|
|
16255
|
+
}
|
|
16256
|
+
|
|
16257
|
+
// plugin.ts
|
|
16258
|
+
var FORGE_AGENT = "forge";
|
|
16259
|
+
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.
|
|
16260
|
+
|
|
16261
|
+
Workflow modes are strictly user-initiated. Never enter plan or goal mode — and never call a plan_* or goal_* tool — unless the user ran /plan or /goal, unmistakably asked for that mode (e.g. "plan first", "set a goal"), or the session already carries a [forge:plan-notice] / [forge:goal-notice] for a mode they started. Ordinary task requests are normal work. Mode-specific rules arrive with those commands and notices; when a notice is present, follow it.`;
|
|
16262
|
+
var PLAN_COMMAND_TEMPLATE = [
|
|
16263
|
+
'(forge plan harness routing. Argument: "$ARGUMENTS")',
|
|
16264
|
+
"",
|
|
16265
|
+
"Current .opencode/plan/ directory:",
|
|
16266
|
+
"!`ls -1 .opencode/plan 2>/dev/null || echo '(empty)'`",
|
|
16267
|
+
"",
|
|
16268
|
+
"Route on the argument above (decide silently; do not recite this routing text to the user):",
|
|
16269
|
+
"- Argument empty: for every non-terminal plan (status draft or approved) in the directory above, read its frontmatter and task checkboxes, then report to the user: path, status, progress (x/y ticked). Ask whether to resume one or start something new.",
|
|
16270
|
+
'- Argument "resume": pick the most recently updated non-terminal plan, summarize its remaining unticked tasks to the user in one short list, then continue executing it — tick each task the moment it is done (plan_tick). If none exists, say so.',
|
|
16271
|
+
'- Argument "discard": call the plan_discard tool, then tell the user the plan was abandoned and writes are restored.',
|
|
16272
|
+
"- Any other argument: treat it as the task goal — you are now in planning mode for this goal. Follow the plan discipline below end to end.",
|
|
16273
|
+
"",
|
|
16274
|
+
"## Plan discipline",
|
|
16275
|
+
"",
|
|
16276
|
+
"Plans are short-horizon, single-task-goal documents (.opencode/plan/<date>-<slug>.md). The harness enforces the hard parts (draft write-ban, approval/close dialogs); you supply the engineering judgment. Never edit or create plan files by hand — every change goes through the plan_* tools.",
|
|
16277
|
+
"",
|
|
16278
|
+
"1. Reconnaissance and alignment (read-only): explore with read/grep/glob only — all write tools, bash, and subagents are DENIED while a draft exists; do not attempt them, do not ask the user to bypass. Gather concrete evidence with file:line references (verified findings, not guesses). If the goal is ambiguous or leaves meaningful choices open (scope, approach, acceptance), settle them with the user FIRST — 1-3 focused questions — and only write the draft once the shape is agreed; never plan against assumptions the user could settle in one line.",
|
|
16279
|
+
"2. Draft (plan_write): call plan_write with structured fields; the tool renders and validates the fixed sections, so a malformed plan cannot exist. Quality bar: goal = one line, the outcome not the activity; context = verified findings with file:line evidence, including what you ruled out and why; approach = the chosen approach AND at least one rejected alternative with the reason (a plan with no considered alternative is a guess); tasks = 3-8 concrete, independently verifiable steps, each doable in one sitting ('improve the code' is invalid; 'extract the timeout constant into config.ts and default it to 3000' is valid); risks = what could break, blast radius, rollback path; acceptance = criteria verifiable by a command, a file, or an observable behavior (vague criteria will fail the close); nonGoals = explicit out-of-scope items. To revise after feedback, call plan_write again — while in draft it overwrites the same file.",
|
|
16280
|
+
`3. User review, then approval (plan_approve): after plan_write, present the plan in chat — goal, chosen approach with one line why, the numbered task list, the acceptance criteria — then STOP: end your turn and wait. The user owns the review, at their own pace: feedback → revise with plan_write, re-present, and wait again; rejection → /plan discard (or re-plan together); explicit go-ahead (e.g. "execute", "批准", "looks good") → call plan_approve — its confirmation dialog is the final hard gate, and the user's Allow starts execution. Never call plan_approve in the same turn that presents a draft, and never implement before approval succeeds.`,
|
|
16281
|
+
"4. Execution (tick as you go): work tasks in order; call plan_tick with the task number immediately after EACH task's work is actually done — never batch ticks, never tick ahead of reality (tick timestamps are an audit trail). If the plan turns out wrong mid-execution, do not silently improvise: tell the user what changed and either finish the affected task or ask about revising (/plan with the same goal re-enters planning).",
|
|
16282
|
+
"5. Completion (plan_close): when all tasks are ticked, self-check EVERY acceptance criterion with concrete evidence (file:line, command output, test result), then call plan_close with one check per criterion, pass/fail honest — a failing check refuses the close, and that is the design working, not an inconvenience. The user confirms closure in a dialog.",
|
|
16283
|
+
"",
|
|
16284
|
+
"Boundaries: /plan discard abandons the plan (terminal, file kept as history, writes restored); /plan resume continues an unfinished plan from its remaining tasks. Work expected to span multiple sessions or days of multi-file change is spec work (e.g. OpenSpec), not plan work — say so once and let the user choose.",
|
|
16285
|
+
""
|
|
16286
|
+
].join(`
|
|
16287
|
+
`);
|
|
16288
|
+
var GOAL_COMMAND_TEMPLATE = [
|
|
16289
|
+
'(forge goal harness routing. Argument: "$ARGUMENTS")',
|
|
16290
|
+
"",
|
|
16291
|
+
"Current .opencode/goal/ directory:",
|
|
16292
|
+
"!`ls -1 .opencode/goal 2>/dev/null || echo '(empty)'`",
|
|
16293
|
+
"",
|
|
16294
|
+
"Route on the argument above (decide silently; do not recite this routing text to the user):",
|
|
16295
|
+
"- Argument empty: for every non-terminal goal in the directory above, read its frontmatter and report to the user: path, status (queued/active/paused + stop_reason), revision, turns_used/max_turns budget. Mention queued goals and the /goal next promotion option. Ask whether to resume/promote one or arm something new.",
|
|
16296
|
+
'- Argument "pause": call the goal_pause tool (with the blocker text as `blocker` when there is one), then tell the user the loop is stopped and how to resume.',
|
|
16297
|
+
`- Argument "resume": call the goal_resume tool for the session's paused goal (optionally with addTurns if the user asked for more budget). If no live goal exists but queued goals do, promote the oldest via goal_resume instead. The user confirms in a dialog.`,
|
|
16298
|
+
'- Argument "next": promote the oldest queued goal via the goal_resume tool (no live goal must remain). The user confirms in a dialog.',
|
|
16299
|
+
'- Argument "discard" (also "stop"/"cancel"/"off"): call the goal_discard tool. The user confirms in a dialog.',
|
|
16300
|
+
'- Argument starting with "add " (or the user clearly wants to queue without arming): create a NEW contract from the rest of the argument and call goal_write with arm=false — never revise an existing goal for an add, never omit arm. It becomes a queued, inert goal (no dialog).',
|
|
16301
|
+
'- Any other argument: treat it as a goal statement. Extract contract markers into the structured goal_write fields: --check "cmd" becomes a shell verification item, --contains "file::text" a file-contract item, --success "..." an extra success criterion, --constraint "..." goes into constraints, --non-goal "..." into nonGoals, --max-turns N and --max-minutes N set budgets. Draft a complete contract (goal, criteria, checks, constraints, non-goals), SHOW the verification items verbatim to the user, then call goal_write with arm=true — a confirmation dialog arms the autonomous loop. If this session already has a live goal, say so and offer revise/queue/discard instead.',
|
|
16302
|
+
"",
|
|
16303
|
+
"Goal files are host-managed: never edit or create anything under .opencode/goal/ by hand — contract changes go through goal_write (each revision bump invalidates evidence collected under earlier revisions), state changes through the goal_* tools. Likewise never enter the goal loop or call any goal_* tool unless the user ran /goal or explicitly asked for a goal loop.",
|
|
16304
|
+
""
|
|
16305
|
+
].join(`
|
|
16306
|
+
`);
|
|
16307
|
+
function isRootish(p) {
|
|
16308
|
+
if (!p)
|
|
16309
|
+
return true;
|
|
16310
|
+
return p === "/" || p === "\\" || /^[A-Za-z]:[\\/]?$/.test(p);
|
|
16311
|
+
}
|
|
16312
|
+
function effectiveWorktree(worktree, fallback) {
|
|
16313
|
+
const wt = (worktree ?? "").trim();
|
|
16314
|
+
if (!isRootish(wt))
|
|
16315
|
+
return wt;
|
|
16316
|
+
return (fallback ?? "").trim();
|
|
16317
|
+
}
|
|
16318
|
+
var sessions = new Map;
|
|
16319
|
+
var forgeDisabled = false;
|
|
16320
|
+
var dispatchDisabled = false;
|
|
16321
|
+
var dispatchMaxConcurrent = 4;
|
|
16322
|
+
var dispatchTimeoutMs = 600000;
|
|
16323
|
+
var dispatchBase = null;
|
|
16324
|
+
var forgeLoader = null;
|
|
16325
|
+
var forgeLoaderDir = null;
|
|
16326
|
+
var forgeConfigState = null;
|
|
16327
|
+
var lastDetectedIdentities = [];
|
|
16328
|
+
var activeDispatchEngine = null;
|
|
16329
|
+
var dispatchLogDir = join5(tmpdir3(), "opencode-forge", "dispatch");
|
|
16330
|
+
var dispatchLedger = createDispatchLedger(join5(dispatchLogDir, "ledger.jsonl"));
|
|
16331
|
+
var activeDispatchRegistry = null;
|
|
16332
|
+
var dispatchClient = null;
|
|
16333
|
+
var dispatchWakeTimers = new Map;
|
|
16334
|
+
var crews = new Map;
|
|
16335
|
+
var dispatchDepths = new Map;
|
|
16336
|
+
var OPENAI_FAMILY = new Set(["openai", "opencode", "openrouter", "groq", "xai", "azure", "github-copilot", "deepseek", "together", "vercel", "opus"]);
|
|
16337
|
+
var ANTHROPIC_FAMILY = new Set(["anthropic"]);
|
|
16338
|
+
var ZAI_FAMILY = new Set(["zai", "zai-coding-plan"]);
|
|
16339
|
+
function dispatchProviderFamily(providerID) {
|
|
16340
|
+
if (OPENAI_FAMILY.has(providerID))
|
|
16341
|
+
return "openai";
|
|
16342
|
+
if (ANTHROPIC_FAMILY.has(providerID))
|
|
16343
|
+
return "anthropic";
|
|
16344
|
+
if (ZAI_FAMILY.has(providerID))
|
|
16345
|
+
return "zai";
|
|
16346
|
+
return null;
|
|
16347
|
+
}
|
|
16348
|
+
function queueDispatchDepth(sessionID, translation) {
|
|
16349
|
+
const key = (t) => t.kind === "verbatim" ? `verbatim:${t.word}` : t.kind === "tiered" ? `tiered:${t.budgetTokens}` : t.kind;
|
|
16350
|
+
const existing = dispatchDepths.get(sessionID);
|
|
16351
|
+
if (existing)
|
|
16352
|
+
return key(existing) === key(translation);
|
|
16353
|
+
dispatchDepths.set(sessionID, translation);
|
|
16354
|
+
return true;
|
|
16355
|
+
}
|
|
16356
|
+
function configuredIdentitiesOf(cfg, catalog) {
|
|
16357
|
+
const providers = cfg.provider ?? {};
|
|
16358
|
+
const identities = [];
|
|
16359
|
+
for (const [providerID, provider] of Object.entries(providers)) {
|
|
16360
|
+
const modelKeys = provider?.models ? Object.keys(provider.models) : null;
|
|
16361
|
+
if (modelKeys && modelKeys.length > 0) {
|
|
16362
|
+
for (const m of modelKeys)
|
|
16363
|
+
identities.push(`${providerID}/${m}`);
|
|
16364
|
+
} else {
|
|
16365
|
+
const catModels = catalog.providers?.[providerID]?.models ?? {};
|
|
16366
|
+
const ids = Object.keys(catModels);
|
|
16367
|
+
if (ids.length > 0)
|
|
16368
|
+
for (const m of ids)
|
|
16369
|
+
identities.push(`${providerID}/${m}`);
|
|
16370
|
+
}
|
|
16371
|
+
}
|
|
16372
|
+
return identities;
|
|
16373
|
+
}
|
|
16374
|
+
var jobsMode = "auto";
|
|
16375
|
+
var jobsKeepBuiltinShell = false;
|
|
16376
|
+
var jobsSurviveMode = "never";
|
|
16377
|
+
function effectiveSurvive(config2, param) {
|
|
16378
|
+
if (config2 === "deny") {
|
|
16379
|
+
if (param === true)
|
|
16380
|
+
throw new Error("[forge] jobs.survive is explicitly denied in config; a per-call survive=true cannot override it.");
|
|
16381
|
+
return false;
|
|
16382
|
+
}
|
|
16383
|
+
if (param !== undefined)
|
|
16384
|
+
return param;
|
|
16385
|
+
return config2 === "always";
|
|
16386
|
+
}
|
|
16387
|
+
var jobFence = null;
|
|
16388
|
+
var jobRegistry = null;
|
|
16389
|
+
var exitCleanup = null;
|
|
16390
|
+
var jobsLifecycleStarted = false;
|
|
16391
|
+
function ensureJobLifecycle() {
|
|
16392
|
+
if (jobsLifecycleStarted)
|
|
16393
|
+
return { registry: jobRegistry, fence: jobFence };
|
|
16394
|
+
jobsLifecycleStarted = true;
|
|
16395
|
+
const registry2 = createJobRegistry(registryPathFor(jobLogDir));
|
|
16396
|
+
jobRegistry = registry2;
|
|
16397
|
+
const { adopted, dead } = registry2.rescan(pidAlive, structuralRelocate);
|
|
16398
|
+
for (const entry of adopted) {
|
|
16399
|
+
adoptSurvivor(jobManager, entry, { registry: registry2, logDir: jobLogDir, relocate: structuralRelocate });
|
|
16400
|
+
}
|
|
16401
|
+
for (const entry of dead) {
|
|
16402
|
+
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)})` });
|
|
16403
|
+
}
|
|
16404
|
+
jobFence = process.env.FORGE_TEST_NO_FENCE === "1" ? null : createJobFence({
|
|
16405
|
+
onDegrade: (reason) => {
|
|
16406
|
+
jobLedgerSink({ at: new Date().toISOString(), kind: "fence-degraded", jobId: "-", session: "-", detail: reason });
|
|
16407
|
+
}
|
|
16408
|
+
});
|
|
16409
|
+
exitCleanup = createExitCleanup(jobManager, {
|
|
16410
|
+
graceMs: 3000,
|
|
16411
|
+
after: () => {
|
|
16412
|
+
jobFence?.dispose();
|
|
16413
|
+
}
|
|
16414
|
+
});
|
|
16415
|
+
return { registry: registry2, fence: jobFence };
|
|
16416
|
+
}
|
|
16417
|
+
var nativeBackgroundSeen = false;
|
|
16418
|
+
function jobStage() {
|
|
16419
|
+
if (jobsMode === "native")
|
|
16420
|
+
return 2;
|
|
16421
|
+
if (jobsMode === "forge")
|
|
16422
|
+
return 0;
|
|
16423
|
+
return nativeBackgroundSeen ? 1 : 0;
|
|
16424
|
+
}
|
|
16425
|
+
var jobLogDir = jobsLogDir();
|
|
16426
|
+
var jobLedgerPath = join5(jobLogDir, "ledger.jsonl");
|
|
16427
|
+
function jobLedgerSink(entry) {
|
|
16428
|
+
try {
|
|
16429
|
+
mkdirSync6(jobLogDir, { recursive: true });
|
|
16430
|
+
if (existsSync3(jobLedgerPath) && statSync4(jobLedgerPath).size > 1e6)
|
|
16431
|
+
writeFileSync6(jobLedgerPath, "");
|
|
16432
|
+
appendFileSync(jobLedgerPath, `${JSON.stringify(entry)}
|
|
16433
|
+
`);
|
|
16434
|
+
} catch {}
|
|
16435
|
+
}
|
|
16436
|
+
var jobManager = createJobManager({ sink: jobLedgerSink });
|
|
16437
|
+
var jobClient = null;
|
|
16438
|
+
function jobWakeText(job) {
|
|
16439
|
+
return [
|
|
16440
|
+
`[forge:job-complete] Background job ${job.id} finished (${job.state}${job.exitCode !== null ? `, exit ${job.exitCode}` : ""}).`,
|
|
16441
|
+
`Command: ${job.cmd}`,
|
|
16442
|
+
`Recent output:
|
|
16443
|
+
${job.tail.slice(-1500) || "(none)"}`,
|
|
16444
|
+
"Details via forge_jobs (poll/log); the job stays in the registry until cleared."
|
|
16445
|
+
].join(`
|
|
16446
|
+
`);
|
|
16447
|
+
}
|
|
16448
|
+
async function deliverJobWakes(sessionID) {
|
|
16449
|
+
if (forgeDisabled || jobStage() >= 2)
|
|
16450
|
+
return;
|
|
16451
|
+
if (!jobClient || typeof jobClient.session.promptAsync !== "function")
|
|
16452
|
+
return;
|
|
16453
|
+
for (const job of jobManager.deliverWakesFor(sessionID)) {
|
|
16454
|
+
try {
|
|
16455
|
+
await jobClient.session.promptAsync({ path: { id: sessionID }, body: { parts: [{ type: "text", text: jobWakeText(job) }] } });
|
|
16456
|
+
} catch {
|
|
16457
|
+
job.wakeState = "queued";
|
|
16458
|
+
}
|
|
16459
|
+
}
|
|
16460
|
+
}
|
|
16461
|
+
async function handoffTarget(sessionID) {
|
|
16462
|
+
if (!jobClient || typeof jobClient.session.get !== "function")
|
|
16463
|
+
return;
|
|
16464
|
+
let current = sessionID;
|
|
16465
|
+
for (let depth = 0;depth < 10; depth++) {
|
|
16466
|
+
try {
|
|
16467
|
+
const info = await jobClient.session.get({ path: { id: current } });
|
|
16468
|
+
if (!info?.parentID)
|
|
16469
|
+
return current === sessionID ? undefined : current;
|
|
16470
|
+
current = info.parentID;
|
|
16471
|
+
} catch {
|
|
16472
|
+
return;
|
|
16473
|
+
}
|
|
16474
|
+
}
|
|
16475
|
+
return current === sessionID ? undefined : current;
|
|
16476
|
+
}
|
|
16477
|
+
var WRITE_TOOLS = new Set(["write", "edit", "bash", "task", "apply", "applypatch", "patch", "multiedit"]);
|
|
16478
|
+
function isWriteTool(name) {
|
|
16479
|
+
const n = name.toLowerCase();
|
|
16480
|
+
return WRITE_TOOLS.has(n) || n.includes("patch");
|
|
16481
|
+
}
|
|
16482
|
+
function nowIso() {
|
|
16483
|
+
return new Date().toISOString();
|
|
16484
|
+
}
|
|
16485
|
+
function planDirOf(worktree) {
|
|
16486
|
+
return join5(worktree, ".opencode", "plan");
|
|
16487
|
+
}
|
|
16488
|
+
function readPlanDir(worktree) {
|
|
16489
|
+
const dir = planDirOf(worktree);
|
|
16490
|
+
if (!existsSync3(dir))
|
|
16491
|
+
return [];
|
|
16492
|
+
return readdirSync4(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync9(join5(dir, name), "utf8") }));
|
|
14908
16493
|
}
|
|
14909
16494
|
function ensureSession(sessionID, worktree) {
|
|
14910
16495
|
const existing = sessions.get(sessionID);
|
|
@@ -14921,7 +16506,7 @@ function worktreeFor(context) {
|
|
|
14921
16506
|
}
|
|
14922
16507
|
function resolveActivePlan(state) {
|
|
14923
16508
|
if (state.planPath && existsSync3(state.planPath)) {
|
|
14924
|
-
const doc2 = parsePlanLoose(
|
|
16509
|
+
const doc2 = parsePlanLoose(readFileSync9(state.planPath, "utf8"));
|
|
14925
16510
|
if (doc2 && !isTerminal(doc2.status))
|
|
14926
16511
|
return { path: state.planPath, doc: doc2 };
|
|
14927
16512
|
state.planPath = undefined;
|
|
@@ -14929,7 +16514,7 @@ function resolveActivePlan(state) {
|
|
|
14929
16514
|
const ranked = rankActivePlans(readPlanDir(state.worktree));
|
|
14930
16515
|
if (ranked.length === 0)
|
|
14931
16516
|
return null;
|
|
14932
|
-
const path =
|
|
16517
|
+
const path = join5(planDirOf(state.worktree), ranked[0].name);
|
|
14933
16518
|
state.planPath = path;
|
|
14934
16519
|
return { path, doc: ranked[0].doc };
|
|
14935
16520
|
}
|
|
@@ -14963,12 +16548,12 @@ var planWriteTool = tool({
|
|
|
14963
16548
|
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.`);
|
|
14964
16549
|
} else {
|
|
14965
16550
|
const dir = planDirOf(state.worktree);
|
|
14966
|
-
|
|
14967
|
-
path =
|
|
16551
|
+
mkdirSync6(dir, { recursive: true });
|
|
16552
|
+
path = join5(dir, planFileName(localDate(), slugify(args.goal), readdirSync4(dir).filter((f) => f.endsWith(".md"))));
|
|
14968
16553
|
mode = "created";
|
|
14969
16554
|
}
|
|
14970
16555
|
const text = renderPlan(args, now, created);
|
|
14971
|
-
|
|
16556
|
+
writeFileSync6(path, text);
|
|
14972
16557
|
state.planPath = path;
|
|
14973
16558
|
const doc2 = parsePlan(text);
|
|
14974
16559
|
context.metadata({ title: `${mode === "created" ? "Create" : "Revise"} plan: ${doc2.goal}` });
|
|
@@ -14996,8 +16581,8 @@ var planTickTool = tool({
|
|
|
14996
16581
|
if (active.doc.status !== "approved") {
|
|
14997
16582
|
throw new PlanError(`Plan status is ${active.doc.status}; only an approved plan can be ticked. Get user approval via plan_approve first.`);
|
|
14998
16583
|
}
|
|
14999
|
-
const next = tickTask(
|
|
15000
|
-
|
|
16584
|
+
const next = tickTask(readFileSync9(active.path, "utf8"), args.n, nowIso());
|
|
16585
|
+
writeFileSync6(active.path, next);
|
|
15001
16586
|
const doc2 = parsePlan(next);
|
|
15002
16587
|
const p = progressOf(doc2);
|
|
15003
16588
|
context.metadata({ title: `Tick task ${args.n} (${p.done}/${p.total})` });
|
|
@@ -15024,7 +16609,7 @@ var planApproveTool = tool({
|
|
|
15024
16609
|
throw new PlanError(`Plan status is ${active.doc.status}; only a draft plan can be approved.`);
|
|
15025
16610
|
}
|
|
15026
16611
|
await gate(context.ask, "plan_approve", `Approve plan: ${active.doc.goal}`);
|
|
15027
|
-
|
|
16612
|
+
writeFileSync6(active.path, transitionStatus(readFileSync9(active.path, "utf8"), "approved", nowIso()));
|
|
15028
16613
|
context.metadata({ title: `Plan approved: ${active.doc.goal}` });
|
|
15029
16614
|
return {
|
|
15030
16615
|
title: "plan approved",
|
|
@@ -15057,7 +16642,7 @@ var planCloseTool = tool({
|
|
|
15057
16642
|
Fix the implementation and retry, or revise the plan first.`);
|
|
15058
16643
|
}
|
|
15059
16644
|
await gate(context.ask, "plan_close", `Close plan: ${active.doc.goal}`);
|
|
15060
|
-
|
|
16645
|
+
writeFileSync6(active.path, transitionStatus(readFileSync9(active.path, "utf8"), "done", nowIso()));
|
|
15061
16646
|
context.metadata({ title: `Plan done: ${active.doc.goal}` });
|
|
15062
16647
|
return {
|
|
15063
16648
|
title: "plan done",
|
|
@@ -15075,31 +16660,31 @@ var planDiscardTool = tool({
|
|
|
15075
16660
|
const active = resolveActivePlan(state);
|
|
15076
16661
|
if (!active)
|
|
15077
16662
|
throw new PlanError("No plan to abandon in this workspace.");
|
|
15078
|
-
|
|
16663
|
+
writeFileSync6(active.path, transitionStatus(readFileSync9(active.path, "utf8"), "abandoned", nowIso()));
|
|
15079
16664
|
state.planPath = undefined;
|
|
15080
16665
|
context.metadata({ title: `Plan abandoned: ${active.doc.goal}` });
|
|
15081
16666
|
return { title: "plan abandoned", output: `Plan abandoned: ${relFrom(state.worktree, active.path)}. Write operations are restored.` };
|
|
15082
16667
|
}
|
|
15083
16668
|
});
|
|
15084
16669
|
function goalDirOf(worktree) {
|
|
15085
|
-
return
|
|
16670
|
+
return join5(worktree, ".opencode", "goal");
|
|
15086
16671
|
}
|
|
15087
16672
|
function readGoalDir(worktree) {
|
|
15088
16673
|
const dir = goalDirOf(worktree);
|
|
15089
16674
|
if (!existsSync3(dir))
|
|
15090
16675
|
return [];
|
|
15091
|
-
return readdirSync4(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text:
|
|
16676
|
+
return readdirSync4(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync9(join5(dir, name), "utf8") }));
|
|
15092
16677
|
}
|
|
15093
16678
|
function resolveSessionGoal(state) {
|
|
15094
16679
|
if (state.goalPath && existsSync3(state.goalPath)) {
|
|
15095
|
-
const doc2 = parseGoalLoose(
|
|
16680
|
+
const doc2 = parseGoalLoose(readFileSync9(state.goalPath, "utf8"));
|
|
15096
16681
|
if (doc2 && !isGoalTerminal(doc2.status))
|
|
15097
16682
|
return { path: state.goalPath, doc: doc2 };
|
|
15098
16683
|
state.goalPath = undefined;
|
|
15099
16684
|
}
|
|
15100
16685
|
const live = rankLiveGoals(readGoalDir(state.worktree))[0];
|
|
15101
16686
|
if (live) {
|
|
15102
|
-
const path =
|
|
16687
|
+
const path = join5(goalDirOf(state.worktree), live.name);
|
|
15103
16688
|
state.goalPath = path;
|
|
15104
16689
|
return { path, doc: live.doc };
|
|
15105
16690
|
}
|
|
@@ -15196,9 +16781,9 @@ var goalWriteTool = tool({
|
|
|
15196
16781
|
await gate(context.ask, "goal_write", `Arm goal: ${args.goal}`);
|
|
15197
16782
|
}
|
|
15198
16783
|
const dir = goalDirOf(state.worktree);
|
|
15199
|
-
|
|
16784
|
+
mkdirSync6(dir, { recursive: true });
|
|
15200
16785
|
const name = goalFileName(localDateNow(), slugifyGoal(args.goal), readdirSync4(dir).filter((f) => f.endsWith(".md")));
|
|
15201
|
-
const path =
|
|
16786
|
+
const path = join5(dir, name);
|
|
15202
16787
|
const text = renderGoal(input, {
|
|
15203
16788
|
now,
|
|
15204
16789
|
status: arm ? "active" : "queued",
|
|
@@ -15238,7 +16823,7 @@ var goalCheckTool = tool({
|
|
|
15238
16823
|
o.index = selected[i].n;
|
|
15239
16824
|
});
|
|
15240
16825
|
const runId = `${nowIso()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
15241
|
-
const next = appendCheckLog(
|
|
16826
|
+
const next = appendCheckLog(readFileSync9(goal.path, "utf8"), runId, outcomes, nowIso());
|
|
15242
16827
|
atomicWrite(goal.path, next);
|
|
15243
16828
|
const ok = outcomesAllOk(outcomes);
|
|
15244
16829
|
context.metadata({ title: `goal_check: ${outcomes.filter((o) => o.ok).length}/${outcomes.length} pass` });
|
|
@@ -15272,7 +16857,7 @@ var goalCompleteTool = tool({
|
|
|
15272
16857
|
const failures = outcomes.filter((o) => !o.ok);
|
|
15273
16858
|
if (failures.length > 0) {
|
|
15274
16859
|
const runId2 = `${nowIso()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
15275
|
-
atomicWrite(goal.path, appendCheckLog(
|
|
16860
|
+
atomicWrite(goal.path, appendCheckLog(readFileSync9(goal.path, "utf8"), runId2, outcomes, nowIso()));
|
|
15276
16861
|
throw new GoalError(`Completion gate: verification re-run failed (fail-closed). The goal stays active.
|
|
15277
16862
|
${formatOutcomes(failures)}
|
|
15278
16863
|
Fix the work and retry; recorded results never substitute for the gate's own re-run.`);
|
|
@@ -15285,7 +16870,7 @@ Fix the work and retry; recorded results never substitute for the gate's own re-
|
|
|
15285
16870
|
}
|
|
15286
16871
|
await gate(context.ask, "goal_complete", `Complete goal: ${goal.doc.goal}`);
|
|
15287
16872
|
const runId = `${nowIso()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
15288
|
-
let text = appendCheckLog(
|
|
16873
|
+
let text = appendCheckLog(readFileSync9(goal.path, "utf8"), runId, outcomes, nowIso());
|
|
15289
16874
|
text = transitionGoal(text, "completed", nowIso());
|
|
15290
16875
|
atomicWrite(goal.path, text);
|
|
15291
16876
|
context.metadata({ title: `Goal completed: ${goal.doc.goal}` });
|
|
@@ -15307,7 +16892,7 @@ var goalPauseTool = tool({
|
|
|
15307
16892
|
throw new GoalError(`No active goal to pause (status: ${goal?.doc.status ?? "none"}).`);
|
|
15308
16893
|
}
|
|
15309
16894
|
const stopReason = args.blocker ? "blocker" : "user";
|
|
15310
|
-
atomicWrite(goal.path, transitionGoal(
|
|
16895
|
+
atomicWrite(goal.path, transitionGoal(readFileSync9(goal.path, "utf8"), "paused", nowIso(), { stopReason }));
|
|
15311
16896
|
engineForgetSession(context.sessionID);
|
|
15312
16897
|
context.metadata({ title: `Goal paused (${stopReason}): ${goal.doc.goal}` });
|
|
15313
16898
|
return {
|
|
@@ -15327,7 +16912,7 @@ var goalResumeTool = tool({
|
|
|
15327
16912
|
if (!goal || goal.doc.status === "queued") {
|
|
15328
16913
|
const oldest = rankQueuedGoals(readGoalDir(state.worktree))[0];
|
|
15329
16914
|
if (oldest) {
|
|
15330
|
-
const path =
|
|
16915
|
+
const path = join5(goalDirOf(state.worktree), oldest.name);
|
|
15331
16916
|
state.goalPath = path;
|
|
15332
16917
|
goal = { path, doc: oldest.doc };
|
|
15333
16918
|
}
|
|
@@ -15337,7 +16922,7 @@ var goalResumeTool = tool({
|
|
|
15337
16922
|
}
|
|
15338
16923
|
const promoting = goal.doc.status === "queued";
|
|
15339
16924
|
await gate(context.ask, "goal_resume", `${promoting ? "Promote" : "Resume"} goal: ${goal.doc.goal}`);
|
|
15340
|
-
let text =
|
|
16925
|
+
let text = readFileSync9(goal.path, "utf8");
|
|
15341
16926
|
if (args.addTurns)
|
|
15342
16927
|
text = bumpBudget(text, args.addTurns, nowIso());
|
|
15343
16928
|
text = transitionGoal(text, "active", nowIso(), { session: context.sessionID });
|
|
@@ -15363,7 +16948,7 @@ var goalDiscardTool = tool({
|
|
|
15363
16948
|
if (!goal)
|
|
15364
16949
|
throw new GoalError("No goal to discard in this workspace.");
|
|
15365
16950
|
await gate(context.ask, "goal_discard", `Discard goal: ${goal.doc.goal}`);
|
|
15366
|
-
atomicWrite(goal.path, transitionGoal(
|
|
16951
|
+
atomicWrite(goal.path, transitionGoal(readFileSync9(goal.path, "utf8"), "abandoned", nowIso()));
|
|
15367
16952
|
state.goalPath = undefined;
|
|
15368
16953
|
engineForgetSession(context.sessionID);
|
|
15369
16954
|
context.metadata({ title: `Goal abandoned: ${goal.doc.goal}` });
|
|
@@ -15410,7 +16995,7 @@ var forgeShellTool = tool({
|
|
|
15410
16995
|
}
|
|
15411
16996
|
}
|
|
15412
16997
|
const { registry: registry2, fence } = ensureJobLifecycle();
|
|
15413
|
-
const cwd = args.workdir ? isAbsolute(args.workdir) ? args.workdir :
|
|
16998
|
+
const cwd = args.workdir ? isAbsolute(args.workdir) ? args.workdir : join5(state.worktree, args.workdir) : state.worktree;
|
|
15414
16999
|
const started = startJob(jobManager, {
|
|
15415
17000
|
cmd: args.command,
|
|
15416
17001
|
cwd,
|
|
@@ -15556,7 +17141,323 @@ ${p.newOutput || "(none in this window)"}`,
|
|
|
15556
17141
|
jobManager.handoff(args.jobId, target);
|
|
15557
17142
|
return {
|
|
15558
17143
|
title: `${job.id} handed off`,
|
|
15559
|
-
output: `[forge:job] ${job.id} promoted to plugin-global scope${target ? ` and rebound to root session ${target}` : ""}; it now survives this session's end.`
|
|
17144
|
+
output: `[forge:job] ${job.id}: promoted to plugin-global scope${target ? ` and rebound to root session ${target}` : ""}; it now survives this session's end.`
|
|
17145
|
+
};
|
|
17146
|
+
}
|
|
17147
|
+
});
|
|
17148
|
+
function dispatchWakeDelay(idleDriven) {
|
|
17149
|
+
const env = Number(process.env.FORGE_DISPATCH_WAKE_DEBOUNCE_MS);
|
|
17150
|
+
if (Number.isFinite(env) && env >= 0)
|
|
17151
|
+
return env;
|
|
17152
|
+
return idleDriven ? IDLE_DEBOUNCE_MS + 50 : 1500;
|
|
17153
|
+
}
|
|
17154
|
+
function scheduleDispatchWake(sessionID, idleDriven) {
|
|
17155
|
+
if (dispatchDisabled || forgeDisabled)
|
|
17156
|
+
return;
|
|
17157
|
+
if (dispatchWakeTimers.has(sessionID))
|
|
17158
|
+
return;
|
|
17159
|
+
const t = setTimeout(() => {
|
|
17160
|
+
dispatchWakeTimers.delete(sessionID);
|
|
17161
|
+
injectDispatchBrief(sessionID);
|
|
17162
|
+
}, dispatchWakeDelay(idleDriven));
|
|
17163
|
+
dispatchWakeTimers.set(sessionID, t);
|
|
17164
|
+
}
|
|
17165
|
+
function dispatchBriefText(entries) {
|
|
17166
|
+
const lines = [`[forge:dispatch-complete] ${entries.length} background dispatch(es) reached terminal state:`];
|
|
17167
|
+
for (const e of entries) {
|
|
17168
|
+
lines.push("", `- ${e.dispatchId} · agent ${e.tier} · ${e.identity}${e.depth ? ` @${e.depth}` : ""} · ${e.state.toUpperCase()} · session ${e.sessionID || "(not yet started)"}`);
|
|
17169
|
+
const r = e.result;
|
|
17170
|
+
if (r !== undefined) {
|
|
17171
|
+
lines.push(` result: ${JSON.stringify(r, null, 1).replace(/\n/g, `
|
|
17172
|
+
`).slice(0, 2000)}`);
|
|
17173
|
+
}
|
|
17174
|
+
}
|
|
17175
|
+
return lines.join(`
|
|
17176
|
+
`);
|
|
17177
|
+
}
|
|
17178
|
+
async function injectDispatchBrief(sessionID) {
|
|
17179
|
+
const reg = activeDispatchRegistry;
|
|
17180
|
+
if (!reg)
|
|
17181
|
+
return;
|
|
17182
|
+
const c0 = dispatchClient;
|
|
17183
|
+
if (typeof c0?.session.status === "function") {
|
|
17184
|
+
try {
|
|
17185
|
+
const st = await c0.session.status({ path: { id: sessionID } });
|
|
17186
|
+
const t = st?.[sessionID]?.type;
|
|
17187
|
+
if (t && t !== "idle")
|
|
17188
|
+
return;
|
|
17189
|
+
} catch {
|
|
17190
|
+
return;
|
|
17191
|
+
}
|
|
17192
|
+
}
|
|
17193
|
+
const entries = reg.takeUndelivered(sessionID);
|
|
17194
|
+
if (entries.length === 0)
|
|
17195
|
+
return;
|
|
17196
|
+
const c = dispatchClient;
|
|
17197
|
+
const send = c?.session.promptAsync ?? c?.session.prompt;
|
|
17198
|
+
if (typeof send !== "function")
|
|
17199
|
+
return;
|
|
17200
|
+
try {
|
|
17201
|
+
await send.call(c.session, { path: { id: sessionID }, body: { parts: [{ type: "text", text: dispatchBriefText(entries) }] } });
|
|
17202
|
+
} catch {
|
|
17203
|
+
for (const e of entries)
|
|
17204
|
+
e.delivered = false;
|
|
17205
|
+
}
|
|
17206
|
+
}
|
|
17207
|
+
var forgeDispatchTool = tool({
|
|
17208
|
+
description: "Dispatch a scoped task to a worker subagent defined in forge.json. Takes {prompt, agent, depth}: agent names a dispatch agent (see forge_dispatch_config for the effective set); the agent's model is pinned by its definition — no fallback ever. depth comes from the canonical metalanguage (none, low, medium, high, max; default = the agent's first entry) or the model's native level names verbatim (escape hatch); the ONLY validation is membership in the agent's depths — the provider is the final judge and its raw errors flow back. The report discloses the depth translation (canonical -> native). Background mode targets live TUI sessions; under `opencode run` prefer sync. The result reports the actual model/depth, real token counts, a self-computed cost (null when unpriced), and the worker's concluding report. Refuses while a plan draft is active.",
|
|
17209
|
+
args: {
|
|
17210
|
+
prompt: tool.schema.string().describe("Complete, self-contained task for the worker (it cannot see this conversation)"),
|
|
17211
|
+
agent: tool.schema.string().describe("Dispatch agent id from forge.json (e.g. research, review, or a custom id; legacy inline tier ids still work when configured)"),
|
|
17212
|
+
depth: tool.schema.string().optional().describe("Reasoning depth: none | low | medium | high | max (canonical) or the model's native level name verbatim; omit to use the agent's first depths entry"),
|
|
17213
|
+
background: tool.schema.boolean().optional().describe("Run in the background: returns {dispatchId, resolved, queuedAt} immediately; the full result arrives later as a coalesced [forge:dispatch-complete] brief when the session is idle. Background mode targets live TUI sessions — under `opencode run` the session ends before any brief, so completions are ledger-only (read them via forge_dispatch_list) and sync mode is preferred.")
|
|
17214
|
+
},
|
|
17215
|
+
execute: async (args, context) => {
|
|
17216
|
+
const state = stateForBan(context.sessionID);
|
|
17217
|
+
const active = state ? resolveActivePlan(state) : null;
|
|
17218
|
+
if (active && active.doc.status === "draft") {
|
|
17219
|
+
throw new Error("[forge] A plan is in draft; dispatching workers is denied during planning. Call plan_approve for user approval, or /plan discard to abandon the plan.");
|
|
17220
|
+
}
|
|
17221
|
+
if (!args.prompt?.trim())
|
|
17222
|
+
throw new Error("forge_dispatch requires a non-empty prompt.");
|
|
17223
|
+
const engine = activeDispatchEngine;
|
|
17224
|
+
if (!engine)
|
|
17225
|
+
throw new Error("[forge] dispatch engine unavailable (disabled by dispatch.disable, or config still loading).");
|
|
17226
|
+
try {
|
|
17227
|
+
const r = await engine.dispatch({ prompt: args.prompt, agent: args.agent, ...args.depth !== undefined ? { depth: args.depth } : {} }, context.sessionID, args.background === true ? { background: true } : undefined);
|
|
17228
|
+
if ("dispatchId" in r) {
|
|
17229
|
+
context.metadata({ title: `dispatch ${r.agent} queued (${r.dispatchId})` });
|
|
17230
|
+
return {
|
|
17231
|
+
title: `dispatch ${r.agent} queued → ${r.resolved}${r.depth ? ` @${r.depth}` : ""}`,
|
|
17232
|
+
output: [
|
|
17233
|
+
`[forge:dispatch] accepted in the background: ${r.dispatchId}`,
|
|
17234
|
+
`agent: ${r.agent} resolved: ${r.resolved}${r.depth ? ` depth: ${r.depth}` : ""}`,
|
|
17235
|
+
`queuedAt: ${r.queuedAt}`,
|
|
17236
|
+
"",
|
|
17237
|
+
"The full result (tokens/cost/report — or the timeout/error report) arrives as a [forge:dispatch-complete] brief when this session is next idle. Track progress any time with forge_dispatch_list; cancel with forge_dispatch_kill."
|
|
17238
|
+
].join(`
|
|
17239
|
+
`)
|
|
17240
|
+
};
|
|
17241
|
+
}
|
|
17242
|
+
const tok = r.tokens;
|
|
17243
|
+
const costLine = r.costUsd !== null && r.costUsd !== undefined ? `$${r.costUsd.toFixed(6)}${r.priceSnapshot ? ` (${r.priceSnapshot})` : ""}` : `unpriced${r.costNote ? ` — ${r.costNote}` : ""}`;
|
|
17244
|
+
context.metadata({ title: `dispatch ${r.agent}: ${r.actual.model} @ ${r.actual.depth}` });
|
|
17245
|
+
return {
|
|
17246
|
+
title: `dispatch ${r.agent} → ${r.actual.model} @${r.actual.depth}`,
|
|
17247
|
+
output: [
|
|
17248
|
+
`[forge:dispatch] completed in ${r.durationMs}ms`,
|
|
17249
|
+
`agent: ${r.agent} model: ${r.actual.model} depth: ${r.actual.depth}${r.requested.depth !== undefined && r.requested.depth !== r.actual.depth ? ` (requested ${r.requested.depth})` : ""}`,
|
|
17250
|
+
`session: ${r.sessionID}`,
|
|
17251
|
+
`tokens: input ${tok.input ?? 0}, output ${tok.output ?? 0}, reasoning ${tok.reasoning ?? 0}, cache r/w ${tok.cache?.read ?? 0}/${tok.cache?.write ?? 0}`,
|
|
17252
|
+
`cost: ${costLine}`,
|
|
17253
|
+
`depthTranslation: ${r.depthTranslation}`,
|
|
17254
|
+
"",
|
|
17255
|
+
"Worker report:",
|
|
17256
|
+
r.text || "(empty worker report)"
|
|
17257
|
+
].join(`
|
|
17258
|
+
`)
|
|
17259
|
+
};
|
|
17260
|
+
} catch (err) {
|
|
17261
|
+
if (err instanceof DispatchError && err.code === "pin-unavailable") {
|
|
17262
|
+
throw new Error(`[forge:dispatch:pin-unavailable] ${err.message}
|
|
17263
|
+
|
|
17264
|
+
${dispatchRecipeText()}`);
|
|
17265
|
+
}
|
|
17266
|
+
if (err instanceof DispatchError)
|
|
17267
|
+
throw new Error(`[forge:dispatch:${err.code}] ${err.message}`);
|
|
17268
|
+
throw err;
|
|
17269
|
+
}
|
|
17270
|
+
}
|
|
17271
|
+
});
|
|
17272
|
+
function dispatchRecipeText() {
|
|
17273
|
+
const paths = forgeConfigPaths({ projectDir: forgeLoaderDir ?? undefined, homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() });
|
|
17274
|
+
return forgeRecipe({
|
|
17275
|
+
detectedIdentities: lastDetectedIdentities,
|
|
17276
|
+
projectPath: paths.project || "(project .opencode/forge.json)",
|
|
17277
|
+
globalPath: paths.global
|
|
17278
|
+
});
|
|
17279
|
+
}
|
|
17280
|
+
var forgeDispatchConfigTool = tool({
|
|
17281
|
+
description: "Read the effective forge dispatch configuration: {agents, knobs, findings}. agents mirrors the winning forge.json (project .opencode/forge.json > global ~/.config/opencode/forge.json > built-in seed) exactly — same keys, same shapes, writable back as valid forge.json agents content. knobs are the inline dispatch options {timeoutMs, maxConcurrent}. findings carry config state (config-parse-error, dispatch-unconfigured notice). Re-reads the file (mtime-cached) on every call, so edits are reflected immediately.",
|
|
17282
|
+
args: {},
|
|
17283
|
+
execute: async (_args, context) => {
|
|
17284
|
+
const loaded = forgeLoader?.load() ?? { agents: {}, source: "seed", path: null, findings: [] };
|
|
17285
|
+
context.metadata({ title: `dispatch config (${loaded.source})` });
|
|
17286
|
+
return {
|
|
17287
|
+
title: `dispatch config (${loaded.source}${loaded.path ? `: ${loaded.path}` : ""})`,
|
|
17288
|
+
output: JSON.stringify({
|
|
17289
|
+
agents: loaded.agents,
|
|
17290
|
+
knobs: { timeoutMs: dispatchTimeoutMs, maxConcurrent: dispatchMaxConcurrent },
|
|
17291
|
+
findings: loaded.findings
|
|
17292
|
+
}, null, 2)
|
|
17293
|
+
};
|
|
17294
|
+
}
|
|
17295
|
+
});
|
|
17296
|
+
var forgeDispatchListTool = tool({
|
|
17297
|
+
description: "List background forge_dispatch activity: in-flight dispatches (with dispatchIds) and recent terminal results (completed/timeout/error, full report objects). Use it to recover dispatch state after context compaction or to check on a background dispatch instead of waiting.",
|
|
17298
|
+
args: {},
|
|
17299
|
+
execute: async () => {
|
|
17300
|
+
const reg = activeDispatchRegistry;
|
|
17301
|
+
if (!reg)
|
|
17302
|
+
throw new Error("[forge] no background dispatches have been submitted.");
|
|
17303
|
+
const l = reg.list();
|
|
17304
|
+
if (l.inFlight.length === 0 && l.terminal.length === 0) {
|
|
17305
|
+
return { title: "no background dispatches", output: "[forge:dispatch-list] no background dispatches recorded in this session." };
|
|
17306
|
+
}
|
|
17307
|
+
const lines = [];
|
|
17308
|
+
if (l.inFlight.length > 0) {
|
|
17309
|
+
lines.push("In flight:");
|
|
17310
|
+
for (const e of l.inFlight) {
|
|
17311
|
+
lines.push(`- ${e.dispatchId} · agent ${e.tier} · ${e.identity}${e.depth ? ` @${e.depth}` : ""} · ${e.state} · queued ${e.queuedAt} · session ${e.sessionID || "(starting)"}`);
|
|
17312
|
+
}
|
|
17313
|
+
}
|
|
17314
|
+
if (l.terminal.length > 0) {
|
|
17315
|
+
lines.push("", "Recent terminal results:");
|
|
17316
|
+
for (const e of l.terminal.slice().reverse()) {
|
|
17317
|
+
lines.push(`- ${e.dispatchId} · agent ${e.tier} · ${e.identity}${e.depth ? ` @${e.depth}` : ""} · ${e.state.toUpperCase()} · session ${e.sessionID || "(not started)"}`);
|
|
17318
|
+
const r = e.result;
|
|
17319
|
+
if (r !== undefined)
|
|
17320
|
+
lines.push(` result: ${JSON.stringify(r, null, 1).slice(0, 2000)}`);
|
|
17321
|
+
if (e.lateCompletion !== undefined)
|
|
17322
|
+
lines.push(` (killed; a late result arrived after the kill and was discarded: ${JSON.stringify(e.lateCompletion).slice(0, 300)})`);
|
|
17323
|
+
}
|
|
17324
|
+
}
|
|
17325
|
+
return { title: `dispatches: ${l.inFlight.length} in flight, ${l.terminal.length} terminal`, output: lines.join(`
|
|
17326
|
+
`) };
|
|
17327
|
+
}
|
|
17328
|
+
});
|
|
17329
|
+
var forgeDispatchKillTool = tool({
|
|
17330
|
+
description: "Cancel an in-flight background forge_dispatch by dispatchId: polling stops, the completion brief is suppressed, and the outcome is ledgered as killed. Refuses honestly if the dispatch already finished (use forge_dispatch_list). The child session itself cannot be aborted on this host version and is left for the host to reclaim.",
|
|
17331
|
+
args: {
|
|
17332
|
+
dispatchId: tool.schema.string().describe("The dispatchId returned by forge_dispatch (background) or shown by forge_dispatch_list")
|
|
17333
|
+
},
|
|
17334
|
+
execute: async (args) => {
|
|
17335
|
+
const engine = activeDispatchEngine;
|
|
17336
|
+
if (!engine)
|
|
17337
|
+
throw new Error("[forge] dispatch engine unavailable.");
|
|
17338
|
+
try {
|
|
17339
|
+
engine.kill(args.dispatchId);
|
|
17340
|
+
} catch (err) {
|
|
17341
|
+
if (err instanceof DispatchError)
|
|
17342
|
+
throw new Error(`[forge:dispatch:${err.code}] ${err.message}`);
|
|
17343
|
+
throw err;
|
|
17344
|
+
}
|
|
17345
|
+
return {
|
|
17346
|
+
title: `${args.dispatchId} killed`,
|
|
17347
|
+
output: `[forge:dispatch] ${args.dispatchId}: killed — polling stopped, the completion brief is suppressed, and the outcome is ledgered. The child session cannot be aborted on this host and is left for the host to reclaim.`
|
|
17348
|
+
};
|
|
17349
|
+
}
|
|
17350
|
+
});
|
|
17351
|
+
var CREW_COMMAND_TEMPLATE = [
|
|
17352
|
+
"Objective: {args}",
|
|
17353
|
+
"",
|
|
17354
|
+
"You are entering CREW ORCHESTRATION discipline for this session (single subject: do not switch agents). Follow it exactly:",
|
|
17355
|
+
"",
|
|
17356
|
+
"1. REGISTER: call `crew_begin` with the objective. If it refuses (a crew is already active here, or a plan draft is active), STOP and tell the user why — never start a second crew.",
|
|
17357
|
+
"2. DECOMPOSE: break the objective into subtasks. Every subtask carries FOUR fields before dispatch: (a) a self-contained prompt, (b) a tier/profile (scout = readonly recon, build = implementation, review = readonly review, quick = mechanical change), (c) an exact reasoning depth from the exposed vocabulary, (d) an acceptance-evidence statement — what the result text must contain for you to call it PASS.",
|
|
17358
|
+
"3. WAVES, NEVER FLOODS: dispatch subtasks with background forge_dispatch in batches no larger than the concurrency cap (default 4). Launch the next batch ONLY as [forge:dispatch-complete] briefs free capacity. Never fire more dispatches while the cap is full.",
|
|
17359
|
+
"4. VERIFY: judge each subtask strictly against its acceptance-evidence statement from the dispatch result text — cite the dispatchId (bg-N) as the evidence reference. A result that does not meet the statement is a failure even if the worker sounded confident.",
|
|
17360
|
+
"5. RETRY AT MOST ONCE: a failed subtask may be retried once with an adjusted prompt or depth. If the retry also fails, record the subtask as FAIL with BOTH failure reports visible (attempts[]). Do not retry twice, do not paper over a failure.",
|
|
17361
|
+
"6. CLOSE: when every subtask has a verdict, call `crew_close` with the full report (title/verdict/evidence per subtask; attempts[] for FAILs). The gate cross-checks your report against the dispatch ledger — a dispatched subtask missing from the report refuses the close. The user confirms the closing dialog."
|
|
17362
|
+
].join(`
|
|
17363
|
+
`);
|
|
17364
|
+
var crewBeginTool = tool({
|
|
17365
|
+
description: "Register this session's crew (from /crew). Refuses when a crew is already active in the session or while a plan draft is active. Crew state is in-memory: a host restart ends it honestly (the dispatch ledger keeps the history).",
|
|
17366
|
+
args: {
|
|
17367
|
+
objective: tool.schema.string().describe("One-line crew objective (from /crew)")
|
|
17368
|
+
},
|
|
17369
|
+
execute: async (args, context) => {
|
|
17370
|
+
const state = stateForBan(context.sessionID);
|
|
17371
|
+
const active = state ? resolveActivePlan(state) : null;
|
|
17372
|
+
if (active && active.doc.status === "draft") {
|
|
17373
|
+
throw new Error("[forge:crew] A plan is in draft; crew orchestration is denied during planning. Call plan_approve for user approval, or /plan discard to abandon the plan.");
|
|
17374
|
+
}
|
|
17375
|
+
if (!args.objective?.trim())
|
|
17376
|
+
throw new Error("crew_begin requires a non-empty objective (from /crew <objective>).");
|
|
17377
|
+
const existing = crews.get(context.sessionID);
|
|
17378
|
+
if (existing) {
|
|
17379
|
+
throw new Error(`[forge:crew] This session already has an active crew: "${existing.objective}" (started ${existing.startedAt}). Finish and close it with crew_close before starting another.`);
|
|
17380
|
+
}
|
|
17381
|
+
crews.set(context.sessionID, { objective: args.objective.trim(), startedAt: nowIso() });
|
|
17382
|
+
return {
|
|
17383
|
+
title: `crew registered: ${args.objective.trim().slice(0, 60)}`,
|
|
17384
|
+
output: `[forge:crew] Crew registered for this session. Objective: ${args.objective.trim()}. Proceed with the discipline: decompose → waves paced on completion briefs (never past the cap) → per-subtask evidence verdicts → at most one retry → crew_close with the full report.`
|
|
17385
|
+
};
|
|
17386
|
+
}
|
|
17387
|
+
});
|
|
17388
|
+
var crewCloseTool = tool({
|
|
17389
|
+
description: "Close this session's crew with the final report (ask-level user confirmation). The gate cross-checks the report against the dispatch ledger: every subtask needs a verdict (PASS/FAIL) and a bg-N evidence reference; every dispatch this crew submitted must appear in the report; FAIL requires both failure reports (attempt + the one retry). On success the summary (verdicts, ledger references, aggregate tokens/cost) is appended to the dispatch ledger and the crew ends.",
|
|
17390
|
+
args: {
|
|
17391
|
+
report: tool.schema.array(tool.schema.object({
|
|
17392
|
+
title: tool.schema.string().describe("Subtask title"),
|
|
17393
|
+
verdict: tool.schema.string().describe("PASS or FAIL"),
|
|
17394
|
+
evidence: tool.schema.string().describe("Acceptance evidence with the bg-N dispatch id reference"),
|
|
17395
|
+
attempts: tool.schema.array(tool.schema.string()).optional().describe("For FAIL: both failure reports (attempt and retry)")
|
|
17396
|
+
})).describe("The full crew report — one entry per subtask")
|
|
17397
|
+
},
|
|
17398
|
+
execute: async (args, context) => {
|
|
17399
|
+
const crew = crews.get(context.sessionID);
|
|
17400
|
+
if (!crew)
|
|
17401
|
+
throw new Error("[forge:crew] No active crew in this session (crew state is in-memory and dies on host restart). Start one with /crew <objective>.");
|
|
17402
|
+
const rows = [];
|
|
17403
|
+
try {
|
|
17404
|
+
const file2 = readFileSync9(join5(dispatchLogDir, "ledger.jsonl"), "utf8");
|
|
17405
|
+
for (const line of file2.split(`
|
|
17406
|
+
`)) {
|
|
17407
|
+
const t = line.trim();
|
|
17408
|
+
if (!t)
|
|
17409
|
+
continue;
|
|
17410
|
+
try {
|
|
17411
|
+
rows.push(JSON.parse(t));
|
|
17412
|
+
} catch {}
|
|
17413
|
+
}
|
|
17414
|
+
} catch {}
|
|
17415
|
+
const check2 = validateCrewReport(args.report ?? [], rows, { objective: crew.objective, startedAt: crew.startedAt, sessionID: context.sessionID });
|
|
17416
|
+
if (!check2.ok) {
|
|
17417
|
+
throw new Error(`[forge:crew] crew_close refused — the report does not match the dispatch ledger:
|
|
17418
|
+
${check2.gaps.map((g) => `- ${g}`).join(`
|
|
17419
|
+
`)}
|
|
17420
|
+
The crew stays active; complete or correct the report and call crew_close again.`);
|
|
17421
|
+
}
|
|
17422
|
+
let tokensIn = 0;
|
|
17423
|
+
let tokensOut = 0;
|
|
17424
|
+
let costSum = 0;
|
|
17425
|
+
let costKnown = 0;
|
|
17426
|
+
for (const row of rows) {
|
|
17427
|
+
if (typeof row.dispatchId !== "string" || !check2.dispatchIds.includes(row.dispatchId))
|
|
17428
|
+
continue;
|
|
17429
|
+
const tok = row.tokens;
|
|
17430
|
+
tokensIn += tok?.input ?? 0;
|
|
17431
|
+
tokensOut += (tok?.output ?? 0) + (tok?.reasoning ?? 0);
|
|
17432
|
+
if (typeof row.costUsd === "number") {
|
|
17433
|
+
costSum += row.costUsd;
|
|
17434
|
+
costKnown++;
|
|
17435
|
+
}
|
|
17436
|
+
}
|
|
17437
|
+
await gate(context.ask, "crew_close", `Close crew: ${crew.objective}`);
|
|
17438
|
+
dispatchLedger.append({
|
|
17439
|
+
ts: nowIso(),
|
|
17440
|
+
event: "crew-summary",
|
|
17441
|
+
tier: "crew",
|
|
17442
|
+
outcome: "closed",
|
|
17443
|
+
sessionID: context.sessionID,
|
|
17444
|
+
note: crew.objective,
|
|
17445
|
+
tokens: { input: tokensIn, output: tokensOut, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
17446
|
+
costUsd: costKnown > 0 ? costSum : null,
|
|
17447
|
+
dispatchIds: check2.dispatchIds,
|
|
17448
|
+
subtasks: (args.report ?? []).map((r) => ({ title: r.title, verdict: String(r.verdict).toUpperCase(), evidence: r.evidence }))
|
|
17449
|
+
});
|
|
17450
|
+
crews.delete(context.sessionID);
|
|
17451
|
+
const fails = (args.report ?? []).filter((r) => String(r.verdict).toUpperCase() === "FAIL").length;
|
|
17452
|
+
return {
|
|
17453
|
+
title: `crew closed: ${crew.objective.slice(0, 60)}`,
|
|
17454
|
+
output: [
|
|
17455
|
+
`[forge:crew] Crew closed: ${crew.objective}`,
|
|
17456
|
+
`subtasks: ${(args.report ?? []).length} (PASS ${(args.report ?? []).length - fails}, FAIL ${fails})`,
|
|
17457
|
+
`dispatches: ${check2.dispatchIds.length} tokens: in ${tokensIn}, out ${tokensOut} cost: ${costKnown > 0 ? `$${costSum.toFixed(6)}` : "unpriced"}`,
|
|
17458
|
+
"The summary is appended to the dispatch ledger."
|
|
17459
|
+
].join(`
|
|
17460
|
+
`)
|
|
15560
17461
|
};
|
|
15561
17462
|
}
|
|
15562
17463
|
});
|
|
@@ -15578,6 +17479,14 @@ function forgeTools() {
|
|
|
15578
17479
|
tools.forge_shell = forgeShellTool;
|
|
15579
17480
|
tools.forge_jobs = forgeJobsTool;
|
|
15580
17481
|
}
|
|
17482
|
+
if (!dispatchDisabled && !forgeDisabled) {
|
|
17483
|
+
tools.forge_dispatch = forgeDispatchTool;
|
|
17484
|
+
tools.forge_dispatch_config = forgeDispatchConfigTool;
|
|
17485
|
+
tools.forge_dispatch_list = forgeDispatchListTool;
|
|
17486
|
+
tools.forge_dispatch_kill = forgeDispatchKillTool;
|
|
17487
|
+
tools.crew_begin = crewBeginTool;
|
|
17488
|
+
tools.crew_close = crewCloseTool;
|
|
17489
|
+
}
|
|
15581
17490
|
return tools;
|
|
15582
17491
|
}
|
|
15583
17492
|
var hostWorktree = "";
|
|
@@ -15601,7 +17510,7 @@ var pendingContinuationTurn = new Set;
|
|
|
15601
17510
|
function goalProbe(line) {
|
|
15602
17511
|
if (process.env.FORGE_GOAL_PROBE) {
|
|
15603
17512
|
try {
|
|
15604
|
-
appendFileSync(
|
|
17513
|
+
appendFileSync(join5(tmpdir3(), "forge-goal-probe.log"), `${new Date().toISOString()} ${line}
|
|
15605
17514
|
`);
|
|
15606
17515
|
} catch {}
|
|
15607
17516
|
}
|
|
@@ -15647,7 +17556,7 @@ function wrapupBriefText(goal, reason) {
|
|
|
15647
17556
|
}
|
|
15648
17557
|
async function autoPauseGoal(client, state, goal, reason, wrapup) {
|
|
15649
17558
|
goalProbe(`auto-pause session=${state.sessionID} reason=${reason} wrapup=${wrapup}`);
|
|
15650
|
-
atomicWrite(goal.path, transitionGoal(
|
|
17559
|
+
atomicWrite(goal.path, transitionGoal(readFileSync9(goal.path, "utf8"), "paused", nowIso(), { stopReason: reason }));
|
|
15651
17560
|
engineForgetSession(state.sessionID);
|
|
15652
17561
|
if (wrapup && goal.doc.session) {
|
|
15653
17562
|
try {
|
|
@@ -15696,9 +17605,9 @@ async function continueIfEligible(client, sessionID) {
|
|
|
15696
17605
|
const ledgerPath = state.goalPath;
|
|
15697
17606
|
if (ledgerPath && existsSync3(ledgerPath)) {
|
|
15698
17607
|
try {
|
|
15699
|
-
const fresh = parseGoalLoose(
|
|
17608
|
+
const fresh = parseGoalLoose(readFileSync9(ledgerPath, "utf8"));
|
|
15700
17609
|
if (fresh && fresh.turnsUsed > 0) {
|
|
15701
|
-
atomicWrite(ledgerPath, appendLedger(
|
|
17610
|
+
atomicWrite(ledgerPath, appendLedger(readFileSync9(ledgerPath, "utf8"), { turn: fresh.turnsUsed, revision: fresh.revision, at: nowIso(), activity: turnHadActivity, writes: act?.writes ?? 0, checks: act?.checks ?? 0 }, nowIso()));
|
|
15702
17611
|
}
|
|
15703
17612
|
} catch (err) {
|
|
15704
17613
|
goalProbe(`ledger append failed session=${sessionID} err=${String(err)}`);
|
|
@@ -15751,15 +17660,19 @@ async function continueIfEligible(client, sessionID) {
|
|
|
15751
17660
|
}
|
|
15752
17661
|
}
|
|
15753
17662
|
try {
|
|
17663
|
+
const wakeEntries = activeDispatchRegistry?.takeUndelivered(sessionID) ?? [];
|
|
17664
|
+
const briefText = wakeEntries.length > 0 ? `${dispatchBriefText(wakeEntries)}
|
|
17665
|
+
|
|
17666
|
+
${goalBriefText(state, goal)}` : goalBriefText(state, goal);
|
|
15754
17667
|
await client.session.prompt({
|
|
15755
17668
|
path: { id: sessionID },
|
|
15756
|
-
body: { parts: [{ type: "text", text:
|
|
17669
|
+
body: { parts: [{ type: "text", text: briefText }] }
|
|
15757
17670
|
});
|
|
15758
17671
|
transportFails.delete(sessionID);
|
|
15759
17672
|
pendingContinuationTurn.add(sessionID);
|
|
15760
17673
|
turnActivity.set(sessionID, { writes: 0, checks: 0 });
|
|
15761
17674
|
state.goalPath = goal.path;
|
|
15762
|
-
atomicWrite(goal.path, incTurns(
|
|
17675
|
+
atomicWrite(goal.path, incTurns(readFileSync9(goal.path, "utf8"), nowIso()));
|
|
15763
17676
|
goalProbe(`continued session=${sessionID} turn=${goal.doc.turnsUsed + 1}/${goal.doc.maxTurns}`);
|
|
15764
17677
|
} catch (err) {
|
|
15765
17678
|
const n = (transportFails.get(sessionID) ?? 0) + 1;
|
|
@@ -15790,6 +17703,7 @@ var server = async (input, options) => {
|
|
|
15790
17703
|
hostWorktree = effectiveWorktree(input.worktree, input.directory) || input.directory || "";
|
|
15791
17704
|
const client = input.client;
|
|
15792
17705
|
jobClient = input.client;
|
|
17706
|
+
dispatchClient = input.client;
|
|
15793
17707
|
const jobsOpts = options?.jobs;
|
|
15794
17708
|
if (jobsOpts?.mode === "auto" || jobsOpts?.mode === "forge" || jobsOpts?.mode === "native")
|
|
15795
17709
|
jobsMode = jobsOpts.mode;
|
|
@@ -15807,14 +17721,26 @@ var server = async (input, options) => {
|
|
|
15807
17721
|
if (wdStallRaw !== undefined && wdStallRaw !== wdStall) {
|
|
15808
17722
|
wdFallbacks.push(`watchdog.stallMs ${JSON.stringify(String(wdStallRaw))} adjusted to ${wdStall} (floor/default applied)`);
|
|
15809
17723
|
}
|
|
15810
|
-
const
|
|
17724
|
+
const dpOpts = options?.dispatch;
|
|
17725
|
+
dispatchDisabled = dpOpts?.disable === true;
|
|
17726
|
+
if (Number.isInteger(dpOpts?.maxConcurrent) && dpOpts.maxConcurrent >= 1 && dpOpts.maxConcurrent <= 16) {
|
|
17727
|
+
dispatchMaxConcurrent = dpOpts.maxConcurrent;
|
|
17728
|
+
} else if (dpOpts?.maxConcurrent !== undefined) {
|
|
17729
|
+
dispatchMaxConcurrent = 4;
|
|
17730
|
+
}
|
|
17731
|
+
if (Number.isInteger(dpOpts?.timeoutMs) && dpOpts.timeoutMs >= 1000 && dpOpts.timeoutMs <= 600000) {
|
|
17732
|
+
dispatchTimeoutMs = dpOpts.timeoutMs;
|
|
17733
|
+
} else if (dpOpts?.timeoutMs !== undefined) {
|
|
17734
|
+
dispatchTimeoutMs = 600000;
|
|
17735
|
+
}
|
|
17736
|
+
const watchdogLedger = createFileLedger(join5(watchdogLogDir(), "log.jsonl"));
|
|
15811
17737
|
const rawLocator = createLocator();
|
|
15812
17738
|
const probing = () => process.env.FORGE_WATCHDOG_PROBE === "1";
|
|
15813
17739
|
const probeLine = (text) => {
|
|
15814
17740
|
if (!probing())
|
|
15815
17741
|
return;
|
|
15816
17742
|
try {
|
|
15817
|
-
appendFileSync(
|
|
17743
|
+
appendFileSync(join5(tmpdir3(), "forge-watchdog-probe.log"), `${new Date().toISOString()} ${text}
|
|
15818
17744
|
`);
|
|
15819
17745
|
} catch {}
|
|
15820
17746
|
};
|
|
@@ -15862,6 +17788,7 @@ var server = async (input, options) => {
|
|
|
15862
17788
|
exitCleanup?.trigger("dispose");
|
|
15863
17789
|
jobManager.disposeAll();
|
|
15864
17790
|
watchdog.dispose();
|
|
17791
|
+
activeDispatchEngine?.dispose();
|
|
15865
17792
|
sessions.clear();
|
|
15866
17793
|
},
|
|
15867
17794
|
config: async (cfg) => {
|
|
@@ -15895,16 +17822,90 @@ var server = async (input, options) => {
|
|
|
15895
17822
|
template: PLAN_COMMAND_TEMPLATE,
|
|
15896
17823
|
description: "forge plan harness: no argument lists in-progress plans; resume continues the latest; discard abandons it; a goal enters planning discipline"
|
|
15897
17824
|
};
|
|
17825
|
+
cfg.command["crew"] ??= {
|
|
17826
|
+
template: CREW_COMMAND_TEMPLATE,
|
|
17827
|
+
description: "forge crew orchestration: decompose an objective into evidence-checked subtasks dispatched as paced background waves; at most one retry per subtask; crew_close cross-checks the report against the dispatch ledger"
|
|
17828
|
+
};
|
|
15898
17829
|
cfg.command["goal"] ??= {
|
|
15899
17830
|
template: GOAL_COMMAND_TEMPLATE,
|
|
15900
17831
|
description: "forge goal harness (autonomous, host-verified): no argument lists goals; pause/resume/discard drive the loop; add queues; next promotes; an objective drafts a contract and arms it (--check/--contains markers become verification items)"
|
|
15901
17832
|
};
|
|
15902
17833
|
const perm = cfg.permission;
|
|
15903
17834
|
const permSection = perm ?? (cfg.permission = {});
|
|
15904
|
-
for (const gateKey of ["plan_approve", "plan_close", "goal_write", "goal_complete", "goal_resume", "goal_discard", "forge_shell"]) {
|
|
17835
|
+
for (const gateKey of ["plan_approve", "plan_close", "goal_write", "goal_complete", "goal_resume", "goal_discard", "forge_shell", "crew_close"]) {
|
|
15905
17836
|
if (permSection[gateKey] !== "deny")
|
|
15906
17837
|
permSection[gateKey] = "ask";
|
|
15907
17838
|
}
|
|
17839
|
+
if (!dispatchDisabled) {
|
|
17840
|
+
const snapshot = process.env.FORGE_TEST_NO_DISPATCH_FETCH === "1" ? { catalog: { providers: {} }, degraded: true, source: "test-off" } : await loadModelsDevSnapshot();
|
|
17841
|
+
const dispatchOpts = options?.dispatch ?? {};
|
|
17842
|
+
const legacyInline = (dispatchOpts.roster?.length ?? 0) > 0 || dispatchOpts.tiers !== undefined;
|
|
17843
|
+
if (!forgeLoader || forgeLoaderDir !== hostWorktree) {
|
|
17844
|
+
forgeLoader = createForgeConfigLoader({
|
|
17845
|
+
projectDir: hostWorktree,
|
|
17846
|
+
homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir()
|
|
17847
|
+
});
|
|
17848
|
+
forgeLoaderDir = hostWorktree;
|
|
17849
|
+
}
|
|
17850
|
+
const loaded = forgeLoader.load();
|
|
17851
|
+
forgeConfigState = loaded;
|
|
17852
|
+
for (const finding of loaded.findings) {
|
|
17853
|
+
dispatchLedger.append({ ts: new Date().toISOString(), event: "validation", level: finding.level, code: finding.code, note: finding.message });
|
|
17854
|
+
}
|
|
17855
|
+
for (const [agentId, def] of Object.entries(loaded.agents)) {
|
|
17856
|
+
const id = `forge-${agentId}`;
|
|
17857
|
+
if (agentSection[id] !== undefined)
|
|
17858
|
+
continue;
|
|
17859
|
+
agentSection[id] = forgeAgentDef(agentId, def);
|
|
17860
|
+
}
|
|
17861
|
+
const detected = configuredIdentitiesOf(cfg, snapshot.catalog);
|
|
17862
|
+
lastDetectedIdentities = detected;
|
|
17863
|
+
const built = buildDispatchConfig({
|
|
17864
|
+
configuredIdentities: detected,
|
|
17865
|
+
catalog: snapshot.catalog,
|
|
17866
|
+
userRoster: legacyInline ? dispatchOpts.roster ?? [] : [],
|
|
17867
|
+
userTiers: legacyInline ? dispatchOpts.tiers : undefined
|
|
17868
|
+
});
|
|
17869
|
+
dispatchBase = { cfg: built, catalog: snapshot.catalog, findings: built.findings, degraded: snapshot.degraded };
|
|
17870
|
+
for (const finding of built.findings) {
|
|
17871
|
+
dispatchLedger.append({ ts: new Date().toISOString(), event: "validation", level: finding.level, code: finding.code, note: finding.message });
|
|
17872
|
+
}
|
|
17873
|
+
if (snapshot.degraded) {
|
|
17874
|
+
dispatchLedger.append({ ts: new Date().toISOString(), event: "models-dev-degraded", note: "models.dev snapshot unavailable — cost reporting degrades to null" });
|
|
17875
|
+
}
|
|
17876
|
+
if (legacyInline) {
|
|
17877
|
+
for (const [tierId, tier] of Object.entries(built.tiers)) {
|
|
17878
|
+
const id = `forge-${tierId}`;
|
|
17879
|
+
if (agentSection[id] !== undefined)
|
|
17880
|
+
continue;
|
|
17881
|
+
agentSection[id] = tierAgentDef(tierId, tier);
|
|
17882
|
+
}
|
|
17883
|
+
}
|
|
17884
|
+
const roster = built;
|
|
17885
|
+
activeDispatchRegistry = activeDispatchRegistry ?? createDispatchRegistry();
|
|
17886
|
+
activeDispatchEngine?.dispose();
|
|
17887
|
+
activeDispatchEngine = createDispatchEngine({
|
|
17888
|
+
serverUrl: String(input.serverUrl ?? "").replace(/\/$/, ""),
|
|
17889
|
+
workspace: hostWorktree,
|
|
17890
|
+
fetcher: globalThis.fetch,
|
|
17891
|
+
now: Date.now,
|
|
17892
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
17893
|
+
cfg: roster,
|
|
17894
|
+
catalog: snapshot.catalog,
|
|
17895
|
+
available: (identity) => {
|
|
17896
|
+
return roster.roster.some((e) => e.model === identity) || lastDetectedIdentities.includes(identity);
|
|
17897
|
+
},
|
|
17898
|
+
agents: () => forgeLoader?.load().agents ?? {},
|
|
17899
|
+
legacy: legacyInline,
|
|
17900
|
+
sink: (entry) => dispatchLedger.append(entry),
|
|
17901
|
+
registry: activeDispatchRegistry,
|
|
17902
|
+
onTerminal: (parentSessionID) => scheduleDispatchWake(parentSessionID, false),
|
|
17903
|
+
onDepth: queueDispatchDepth,
|
|
17904
|
+
providerFamily: dispatchProviderFamily,
|
|
17905
|
+
maxConcurrent: dispatchMaxConcurrent,
|
|
17906
|
+
timeoutMs: dispatchTimeoutMs
|
|
17907
|
+
});
|
|
17908
|
+
}
|
|
15908
17909
|
},
|
|
15909
17910
|
get tool() {
|
|
15910
17911
|
return forgeDisabled ? {} : forgeTools();
|
|
@@ -15912,7 +17913,7 @@ var server = async (input, options) => {
|
|
|
15912
17913
|
"tool.execute.before": async (input2, output) => {
|
|
15913
17914
|
if (process.env.FORGE_PERM_PROBE) {
|
|
15914
17915
|
try {
|
|
15915
|
-
appendFileSync(
|
|
17916
|
+
appendFileSync(join5(tmpdir3(), "forge-perm-probe.log"), `${new Date().toISOString()} before tool=${JSON.stringify(input2.tool)} session=${input2.sessionID}
|
|
15916
17917
|
`);
|
|
15917
17918
|
} catch {}
|
|
15918
17919
|
}
|
|
@@ -15922,7 +17923,7 @@ var server = async (input, options) => {
|
|
|
15922
17923
|
watchdog.track(input2.callID, input2.sessionID, input2.tool, undefined, cmdText !== undefined ? commandNeedle(cmdText) : undefined);
|
|
15923
17924
|
if (process.env.FORGE_WATCHDOG_PROBE) {
|
|
15924
17925
|
try {
|
|
15925
|
-
appendFileSync(
|
|
17926
|
+
appendFileSync(join5(tmpdir3(), "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)}
|
|
15926
17927
|
`);
|
|
15927
17928
|
} catch {}
|
|
15928
17929
|
}
|
|
@@ -15934,6 +17935,32 @@ var server = async (input, options) => {
|
|
|
15934
17935
|
throw new Error(`[forge] A plan is in draft; write operations are denied (${input2.tool}). Present the plan summary and call plan_approve for user approval, or /plan discard to abandon it.`);
|
|
15935
17936
|
}
|
|
15936
17937
|
}
|
|
17938
|
+
if (input2.tool === "forge_dispatch") {
|
|
17939
|
+
const state = stateForBan(input2.sessionID);
|
|
17940
|
+
const active = state ? resolveActivePlan(state) : null;
|
|
17941
|
+
if (active && active.doc.status === "draft") {
|
|
17942
|
+
throw new Error("[forge] A plan is in draft; dispatching workers is denied during planning. Call plan_approve for user approval, or /plan discard to abandon the plan.");
|
|
17943
|
+
}
|
|
17944
|
+
}
|
|
17945
|
+
},
|
|
17946
|
+
"chat.params": async (input2, output) => {
|
|
17947
|
+
const entry = dispatchDepths.get(input2.sessionID);
|
|
17948
|
+
if (!entry || entry.kind === "not-injected")
|
|
17949
|
+
return;
|
|
17950
|
+
applyDepthTranslation(output.options ?? (output.options = {}), entry);
|
|
17951
|
+
},
|
|
17952
|
+
__forgeDispatchTest: {
|
|
17953
|
+
queueDepth: queueDispatchDepth,
|
|
17954
|
+
depthState: (sessionID) => dispatchDepths.get(sessionID) ?? null,
|
|
17955
|
+
composePrompt: composeWorkerPrompt,
|
|
17956
|
+
registry: () => activeDispatchRegistry,
|
|
17957
|
+
crews: () => crews,
|
|
17958
|
+
dispatchLogDir: () => dispatchLogDir,
|
|
17959
|
+
dispatchBriefText,
|
|
17960
|
+
setEngine: (engine) => {
|
|
17961
|
+
activeDispatchEngine?.dispose();
|
|
17962
|
+
activeDispatchEngine = engine;
|
|
17963
|
+
}
|
|
15937
17964
|
},
|
|
15938
17965
|
"permission.ask": async (input2, output) => {
|
|
15939
17966
|
const meta = input2.metadata ?? {};
|
|
@@ -15941,7 +17968,7 @@ var server = async (input, options) => {
|
|
|
15941
17968
|
const name = (typeof meta.tool === "string" ? meta.tool : undefined) ?? (typeof permissionField === "string" ? permissionField : undefined) ?? input2.id ?? input2.type;
|
|
15942
17969
|
if (process.env.FORGE_PERM_PROBE) {
|
|
15943
17970
|
try {
|
|
15944
|
-
appendFileSync(
|
|
17971
|
+
appendFileSync(join5(tmpdir3(), "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)}
|
|
15945
17972
|
`);
|
|
15946
17973
|
} catch {}
|
|
15947
17974
|
}
|
|
@@ -15972,6 +17999,7 @@ var server = async (input, options) => {
|
|
|
15972
17999
|
goalProbe(`idle event session=${sessionID}`);
|
|
15973
18000
|
scheduleIdleContinuation(client, sessionID);
|
|
15974
18001
|
deliverJobWakes(sessionID);
|
|
18002
|
+
scheduleDispatchWake(sessionID, true);
|
|
15975
18003
|
}
|
|
15976
18004
|
}
|
|
15977
18005
|
if (event.type === "session.deleted") {
|
|
@@ -15990,7 +18018,7 @@ var server = async (input, options) => {
|
|
|
15990
18018
|
watchdog.markSeen(input2.callID);
|
|
15991
18019
|
if (process.env.FORGE_WATCHDOG_PROBE) {
|
|
15992
18020
|
try {
|
|
15993
|
-
appendFileSync(
|
|
18021
|
+
appendFileSync(join5(tmpdir3(), "forge-watchdog-probe.log"), `${new Date().toISOString()} mark ${input2.callID} hostpid=${process.pid}
|
|
15994
18022
|
`);
|
|
15995
18023
|
} catch {}
|
|
15996
18024
|
}
|
|
@@ -16000,7 +18028,7 @@ var server = async (input, options) => {
|
|
|
16000
18028
|
watchdog.untrack(input2.callID);
|
|
16001
18029
|
if (process.env.FORGE_WATCHDOG_PROBE) {
|
|
16002
18030
|
try {
|
|
16003
|
-
appendFileSync(
|
|
18031
|
+
appendFileSync(join5(tmpdir3(), "forge-watchdog-probe.log"), `${new Date().toISOString()} untrack ${input2.callID}
|
|
16004
18032
|
`);
|
|
16005
18033
|
} catch {}
|
|
16006
18034
|
}
|
|
@@ -16030,6 +18058,10 @@ ${output.description}`;
|
|
|
16030
18058
|
"experimental.chat.system.transform": async (input2, output) => {
|
|
16031
18059
|
if (!input2.sessionID)
|
|
16032
18060
|
return;
|
|
18061
|
+
const crewActive = crews.get(input2.sessionID);
|
|
18062
|
+
if (crewActive && !output.system.some((s) => s.startsWith("[forge:crew-active]"))) {
|
|
18063
|
+
output.system.push(`[forge:crew-active] A crew is ACTIVE in this session: "${crewActive.objective}" (started ${crewActive.startedAt}). A second /crew must be refused; finish with crew_close when every subtask has a verdict.`);
|
|
18064
|
+
}
|
|
16033
18065
|
if (!forgeDisabled && jobStage() < 2 && !output.system.some((s) => s.startsWith("[forge:job-guidance]"))) {
|
|
16034
18066
|
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.");
|
|
16035
18067
|
}
|