@sorenllm/opencode-forge 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +119 -124
  2. package/dist/index.js +482 -1570
  3. package/package.json +64 -61
package/dist/index.js CHANGED
@@ -12335,7 +12335,7 @@ 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 mkdirSync6, readdirSync as readdirSync4, readFileSync as readFileSync9, statSync as statSync4, writeFileSync as writeFileSync6 } from "node:fs";
12338
+ import { appendFileSync, existsSync as existsSync3, mkdirSync as mkdirSync5, readdirSync as readdirSync4, readFileSync as readFileSync8, statSync as statSync4, writeFileSync as writeFileSync5 } from "node:fs";
12339
12339
  import { isAbsolute, join as join5, relative } from "node:path";
12340
12340
  import { tmpdir as tmpdir3, homedir } from "node:os";
12341
12341
 
@@ -14723,15 +14723,20 @@ function createLocator(platform = process.platform, posix = {}, win = {}) {
14723
14723
  return platform === "win32" ? createWindowsLocator(win) : createPosixLocator(posix);
14724
14724
  }
14725
14725
 
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-]+$/;
14726
+ // src/models-dev.ts
14727
+ import { mkdirSync as mkdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "node:fs";
14728
+ import { join as join4 } from "node:path";
14729
+ import { tmpdir as tmpdir2 } from "node:os";
14730
+ var DEFAULT_MODELS_DEV_URL = "https://models.dev/api.json";
14731
+ function parseIdentity(identity) {
14732
+ const slash = identity.indexOf("/");
14733
+ if (slash <= 0 || slash === identity.length - 1)
14734
+ return null;
14735
+ return { provider: identity.slice(0, slash), model: identity.slice(slash + 1) };
14736
+ }
14734
14737
  function nativeLadder(catalog, identity) {
14738
+ if (!catalog)
14739
+ return null;
14735
14740
  const parsed = parseIdentity(identity);
14736
14741
  if (!parsed)
14737
14742
  return null;
@@ -14741,352 +14746,174 @@ function nativeLadder(catalog, identity) {
14741
14746
  const options = entry.reasoningOptions;
14742
14747
  return Array.isArray(options) ? [...options] : null;
14743
14748
  }
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;
14749
- }
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)
14759
- return false;
14760
- return Boolean(catalog.providers?.[parsed.provider]?.models?.[parsed.model]);
14749
+ function modelsDevCacheDir(base) {
14750
+ return join4(base ?? join4(tmpdir2(), "opencode-forge"), "models-dev");
14761
14751
  }
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
- });
14752
+ function namedLevels(rows) {
14753
+ const out = [];
14754
+ for (const r of rows) {
14755
+ if (r && typeof r === "object" && r.type === "effort") {
14756
+ const values = r.values;
14757
+ if (Array.isArray(values)) {
14758
+ for (const v of values)
14759
+ if (typeof v === "string")
14760
+ out.push(v);
14816
14761
  }
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
- }
14825
- }
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
14762
  }
14843
14763
  }
14844
- for (const [id, tier] of Object.entries(tiers)) {
14845
- if (tier.defaultDepth === undefined)
14764
+ return out;
14765
+ }
14766
+ function reduceModelsDev(raw) {
14767
+ const catalog = { providers: {} };
14768
+ const providers = raw?.constructor === Object ? raw : null;
14769
+ if (!providers)
14770
+ return catalog;
14771
+ for (const [providerID, provider] of Object.entries(providers)) {
14772
+ const models = provider.models;
14773
+ if (!models || typeof models !== "object")
14846
14774
  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 };
14775
+ const out = { models: {} };
14776
+ for (const [modelID, model] of Object.entries(models)) {
14777
+ const m = model;
14778
+ const options = Array.isArray(m.reasoning_options) ? namedLevels(m.reasoning_options) : undefined;
14779
+ const cost = m.cost && typeof m.cost === "object" ? m.cost : undefined;
14780
+ out.models[modelID] = {
14781
+ ...options ? { reasoningOptions: options } : {},
14782
+ ...cost ? { cost } : {}
14783
+ };
14855
14784
  }
14785
+ catalog.providers[providerID] = out;
14856
14786
  }
14857
- return { roster, tiers, findings };
14787
+ return catalog;
14858
14788
  }
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 } };
14865
- }
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 } };
14789
+ async function loadModelsDevSnapshot(opts = {}) {
14790
+ const url2 = opts.url ?? process.env.OPENCODE_MODELS_URL ?? DEFAULT_MODELS_DEV_URL;
14791
+ const cacheDir = modelsDevCacheDir(opts.cacheDir);
14792
+ const cacheFile = join4(cacheDir, "snapshot.json");
14793
+ const fetcher = opts.fetcher;
14794
+ if (fetcher) {
14795
+ try {
14796
+ const res = await fetcher(url2, { signal: AbortSignal.timeout(opts.timeoutMs ?? 8000) });
14797
+ if (res.ok)
14798
+ return { catalog: reduceModelsDev(JSON.parse(await res.text())), degraded: false, source: url2 };
14799
+ } catch {}
14800
+ return { catalog: { providers: {} }, degraded: true, source: "fetch-failed" };
14870
14801
  }
14871
- return { stablePolls, verdict: "waiting", last: { ...cur } };
14802
+ try {
14803
+ const res = await fetch(url2, { signal: AbortSignal.timeout(opts.timeoutMs ?? 8000) });
14804
+ if (res.ok) {
14805
+ const catalog = reduceModelsDev(JSON.parse(await res.text()));
14806
+ try {
14807
+ mkdirSync4(cacheDir, { recursive: true });
14808
+ writeFileSync4(cacheFile, JSON.stringify(catalog));
14809
+ } catch {}
14810
+ return { catalog, degraded: false, source: url2 };
14811
+ }
14812
+ } catch {}
14813
+ try {
14814
+ const cached2 = JSON.parse(readFileSync6(cacheFile, "utf8"));
14815
+ if (cached2 && typeof cached2 === "object" && cached2.providers)
14816
+ return { catalog: cached2, degraded: true, source: "cache" };
14817
+ } catch {}
14818
+ return { catalog: { providers: {} }, degraded: true, source: "unavailable" };
14872
14819
  }
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;
14820
+
14821
+ // src/crew-gate.ts
14822
+ function normTitle(t) {
14823
+ return String(t ?? "").trim().toLowerCase();
14883
14824
  }
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)" };
14825
+ function validateCrewReport(report, declared) {
14826
+ const gaps = [];
14827
+ if (!Array.isArray(report) || report.length === 0) {
14828
+ return { ok: false, gaps: ["the report lists no subtasks — a crew completion report must cover every declared subtask"] };
14887
14829
  }
14888
- if (tokens.cache.read > 0 && typeof price.cacheRead !== "number") {
14889
- return { costUsd: null, note: "price unavailable: cache-read price missing from snapshot" };
14830
+ const declaredTitles = new Set;
14831
+ for (const s of Array.isArray(declared) ? declared : []) {
14832
+ const t = normTitle(s?.title);
14833
+ if (t)
14834
+ declaredTitles.add(t);
14890
14835
  }
14891
- if (tokens.cache.write > 0 && typeof price.cacheWrite !== "number") {
14892
- return { costUsd: null, note: "price unavailable: cache-write price missing from snapshot" };
14836
+ const covered = new Set;
14837
+ report.forEach((sub, i) => {
14838
+ const label = sub?.title?.trim() || `subtask #${i + 1}`;
14839
+ const key = normTitle(sub?.title);
14840
+ if (!sub?.title?.trim())
14841
+ gaps.push(`${label}: missing title`);
14842
+ else if (covered.has(key))
14843
+ gaps.push(`${label}: duplicate report entry for the same title`);
14844
+ covered.add(key);
14845
+ const verdict = String(sub?.verdict ?? "").toUpperCase();
14846
+ if (verdict !== "PASS" && verdict !== "FAIL")
14847
+ gaps.push(`${label}: verdict must be PASS or FAIL (got "${sub?.verdict ?? ""}")`);
14848
+ if (!String(sub?.evidence ?? "").trim())
14849
+ gaps.push(`${label}: missing evidence statement`);
14850
+ if (verdict === "FAIL" && (!Array.isArray(sub?.attempts) || sub.attempts.length < 2)) {
14851
+ gaps.push(`${label}: FAIL requires both failure reports (the attempt and the one retry) in attempts[]`);
14852
+ }
14853
+ if (key && declaredTitles.size > 0 && !declaredTitles.has(key)) {
14854
+ gaps.push(`${label}: not part of the plan registered at crew_begin (renegade work) — fold discoveries into an existing subtask's verdict notes, or discard and re-crew with the full plan`);
14855
+ }
14856
+ });
14857
+ for (const t of declaredTitles) {
14858
+ if (!covered.has(t)) {
14859
+ const original = (Array.isArray(declared) ? declared : []).find((s) => normTitle(s?.title) === t)?.title ?? t;
14860
+ gaps.push(`"${original}": declared at crew_begin but has no verdict in the report — a declared subtask was silently dropped`);
14861
+ }
14893
14862
  }
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 };
14863
+ return { ok: gaps.length === 0, gaps };
14896
14864
  }
14897
14865
 
14898
14866
  // src/dispatch-prompt.ts
14899
- var COMMON_MANDATES = [
14867
+ var WORKER_MANDATES = [
14900
14868
  "Mandates (non-negotiable):",
14901
14869
  "- 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
14870
  "- 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
14871
  "- End with conclusions backed by evidence references (file:line, command output, or the exact file/content you produced)."
14904
14872
  ].join(`
14905
14873
  `);
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);
14910
- return [
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."
14921
- ].join(`
14874
+ var READONLY_DISCIPLINE = (agent) => `You are forge-${agent}, a readonly 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.`;
14875
+ var WRITE_DISCIPLINE = (agent) => `You are forge-${agent}, a worker executing scoped tasks handed to you through the task tool. Do exactly the task, nothing else: no refactoring beyond scope, no unrelated files, no side quests.`;
14876
+ function materializeAgentPrompt(input) {
14877
+ const frame = input.shape === "readonly" ? READONLY_DISCIPLINE(input.agent) : WRITE_DISCIPLINE(input.agent);
14878
+ return [frame, "", "Your role:", input.role.trim(), "", WORKER_MANDATES].join(`
14922
14879
  `);
14923
14880
  }
14924
14881
 
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
- };
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
- };
15037
- }
15038
- return { ok: true, identity: candidates[0].entry.model, depth, pinned: false };
14882
+ // src/dispatch-tiers.ts
14883
+ var MUTATING_TOOLS = ["write", "edit", "bash"];
14884
+ var ROLE_PROMPTS = {
14885
+ research: "You are a research worker: search, read, and run read-only checks to answer the assigned question. Report findings as a compact brief with sources and file:line evidence; state plainly what could not be verified.",
14886
+ 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."
14887
+ };
14888
+ function rolePromptFor(agentId, def) {
14889
+ if (def?.prompt && def.prompt.trim().length > 0)
14890
+ return def.prompt;
14891
+ return ROLE_PROMPTS[agentId] ?? `You are a ${agentId} worker. Execute the assigned task exactly as scoped.`;
15039
14892
  }
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
- };
15050
- }
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
- };
14893
+ function agentDescriptionFor(agentId, def) {
14894
+ const shape = def.shape === "write" ? "write" : "readonly";
14895
+ return `forge subagent "${agentId}" (${shape}, pinned ${def.model}) — dispatch via the task tool with subagent_type "forge-${agentId}".`;
14896
+ }
14897
+ function forgeAgentDef(agentId, def) {
14898
+ const permission = { task: "deny" };
14899
+ const shape = def.shape ?? "readonly";
14900
+ if (shape === "readonly") {
14901
+ for (const t of MUTATING_TOOLS)
14902
+ permission[t] = "deny";
15088
14903
  }
15089
- return { ok: true, model: def.model, depth, def };
14904
+ Object.assign(permission, def.permission ?? {});
14905
+ permission.task = "deny";
14906
+ return {
14907
+ description: agentDescriptionFor(agentId, def),
14908
+ mode: "subagent",
14909
+ model: def.model,
14910
+ prompt: materializeAgentPrompt({
14911
+ agent: agentId,
14912
+ shape,
14913
+ role: rolePromptFor(agentId, def)
14914
+ }),
14915
+ permission
14916
+ };
15090
14917
  }
15091
14918
 
15092
14919
  // src/dispatch-depth.ts
@@ -15147,633 +14974,13 @@ function applyDepthTranslation(options, t) {
15147
14974
  options.thinking = { type: "enabled" };
15148
14975
  }
15149
14976
  }
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
- await Promise.race([turnPost, deadlinePromise]);
15335
- if (deadlineHit && deps.now() - t0 >= timeoutMs) {
15336
- const elapsed = deps.now() - t0;
15337
- deps.sink({
15338
- ts: new Date().toISOString(),
15339
- event: "timeout",
15340
- tier: req.agent,
15341
- agent: req.agent,
15342
- identity: r.model,
15343
- depth: r.depth,
15344
- sessionID: childID,
15345
- durationMs: elapsed,
15346
- ...dispatchId !== undefined ? { dispatchId, parentSessionID: parentSessionID ?? "" } : {}
15347
- });
15348
- 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");
15349
- }
15350
- await turnPost;
15351
- let poll = null;
15352
- let messages = [];
15353
- for (;; ) {
15354
- if (ctl?.fired)
15355
- throw new KillRequested;
15356
- const elapsed = deps.now() - t0;
15357
- if (elapsed >= timeoutMs) {
15358
- deps.sink({
15359
- ts: new Date().toISOString(),
15360
- event: "timeout",
15361
- tier: req.agent,
15362
- agent: req.agent,
15363
- identity: r.model,
15364
- depth: r.depth,
15365
- sessionID: childID,
15366
- durationMs: elapsed,
15367
- ...dispatchId !== undefined ? { dispatchId, parentSessionID: parentSessionID ?? "" } : {}
15368
- });
15369
- 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");
15370
- }
15371
- await deps.sleep(pollIntervalMs);
15372
- messages = await (ctl ? Promise.race([fetchMessages(childID), ctl.promise.then(() => [])]) : fetchMessages(childID));
15373
- if (ctl?.fired)
15374
- throw new KillRequested;
15375
- poll = completionVerdict(poll, { count: messages.length, text: textOf(messages) });
15376
- if (poll.verdict === "complete" || poll.verdict === "empty")
15377
- break;
15378
- }
15379
- const durationMs = deps.now() - t0;
15380
- if (poll.verdict === "empty") {
15381
- deps.sink({
15382
- ts: new Date().toISOString(),
15383
- event: "empty-response",
15384
- tier: req.agent,
15385
- agent: req.agent,
15386
- identity: r.model,
15387
- depth: r.depth,
15388
- sessionID: childID,
15389
- durationMs,
15390
- ...dispatchId !== undefined ? { dispatchId, parentSessionID: parentSessionID ?? "" } : {}
15391
- });
15392
- 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");
15393
- }
15394
- const tokens = sumTokens(tokensOf(messages));
15395
- const price = priceFor(deps.catalog, r.model);
15396
- const cost = computeCost(tokens, price?.spec ?? null);
15397
- const result = {
15398
- agent: req.agent,
15399
- requested: { agent: req.agent, depth: req.depth },
15400
- actual: { model: r.model, depth: r.depth },
15401
- sessionID: childID,
15402
- durationMs,
15403
- tokens,
15404
- costUsd: cost.costUsd,
15405
- ...cost.note ? { costNote: cost.note } : {},
15406
- ...price?.label ? { priceSnapshot: price.label } : {},
15407
- text: textOf(messages),
15408
- depthTranslation: depthInjected
15409
- };
15410
- deps.sink({
15411
- ts: new Date().toISOString(),
15412
- event: "completed",
15413
- tier: req.agent,
15414
- agent: req.agent,
15415
- identity: r.model,
15416
- depth: r.depth,
15417
- sessionID: childID,
15418
- outcome: "completed",
15419
- tokens,
15420
- costUsd: cost.costUsd,
15421
- durationMs,
15422
- ...dispatchId !== undefined ? { dispatchId, parentSessionID: parentSessionID ?? "" } : {}
15423
- });
15424
- return result;
15425
- }
15426
- function bgTerminal(reg, dispatchId, identity, req, t0, e) {
15427
- const report = {
15428
- agent: req.agent,
15429
- requested: { agent: req.agent, depth: req.depth },
15430
- actual: { model: identity, depth: null },
15431
- sessionID: "",
15432
- outcome: "error",
15433
- durationMs: deps.now() - t0,
15434
- error: e instanceof Error ? e.message : String(e)
15435
- };
15436
- if (e instanceof DispatchError && e.code === "timeout") {
15437
- report.outcome = "timeout";
15438
- reg.markTimeout(dispatchId, report);
15439
- deps.onTerminal?.(reg.get(dispatchId).parentSessionID);
15440
- return;
15441
- }
15442
- deps.sink({ ts: new Date().toISOString(), event: "error", tier: req.agent, identity, outcome: "error", note: report.error });
15443
- reg.markError(dispatchId, report);
15444
- deps.onTerminal?.(reg.get(dispatchId).parentSessionID);
15445
- }
15446
- async function dispatch(req, parentSessionID, opts) {
15447
- const t0 = deps.now();
15448
- if (inFlight.size >= maxConcurrent) {
15449
- deps.sink({ ts: new Date().toISOString(), event: "refused-cap", tier: req.agent, agent: req.agent, note: `${inFlight.size} in flight` });
15450
- throw new DispatchError(`forge_dispatch refused: ${inFlight.size} dispatches already in flight (cap ${maxConcurrent}). Wait for a completion and retry.`, "cap-refused");
15451
- }
15452
- const bg = opts?.background === true;
15453
- if (bg && !deps.registry)
15454
- throw new DispatchError("background dispatch requires the dispatch registry (internal error)", "host-error");
15455
- const first = resolveForThrow(req, undefined);
15456
- translationOrThrow(first.model, first.depth);
15457
- if (bg) {
15458
- const reg = deps.registry;
15459
- const { dispatchId } = reg.submit({ sessionID: "", identity: first.model, depth: first.depth, tier: req.agent, parentSessionID: parentSessionID ?? "" });
15460
- let fire = () => {};
15461
- const promise2 = new Promise((res) => {
15462
- fire = res;
15463
- });
15464
- const ctl = { fired: false, promise: promise2, fire: () => {
15465
- ctl.fired = true;
15466
- fire();
15467
- } };
15468
- killControls.set(dispatchId, ctl);
15469
- const slot = `d${++seq}`;
15470
- inFlight.add(slot);
15471
- (async () => {
15472
- try {
15473
- if (reg.get(dispatchId).state === "killed")
15474
- return;
15475
- reg.markRunning(dispatchId);
15476
- let current = first;
15477
- let exclude2 = undefined;
15478
- for (let attemptNo = 0;; attemptNo++) {
15479
- try {
15480
- const result = await runAttempt(current, req, parentSessionID, t0, ctl, (sid) => {
15481
- try {
15482
- reg.get(dispatchId).sessionID = sid;
15483
- } catch {}
15484
- }, dispatchId);
15485
- if (reg.get(dispatchId).state === "killed") {
15486
- reg.noteLateCompletion(dispatchId, result);
15487
- 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" });
15488
- } else {
15489
- reg.markCompleted(dispatchId, result);
15490
- deps.onTerminal?.(reg.get(dispatchId).parentSessionID);
15491
- }
15492
- return;
15493
- } catch (e) {
15494
- if (e instanceof KillRequested)
15495
- return;
15496
- if (reg.get(dispatchId).state === "killed")
15497
- return;
15498
- const retryable = !current.agentPath && e instanceof DispatchError && (e.code === "host-error" || e.code === "empty-response");
15499
- const alternative = retryable ? resolveDispatch(deps.cfg, { profile: req.agent, depth: req.depth, exclude: [current.model] }, deps.available, deps.catalog) : { ok: false };
15500
- if (!retryable || attemptNo > 0 || !alternative.ok) {
15501
- bgTerminal(reg, dispatchId, current.model, req, t0, e);
15502
- return;
15503
- }
15504
- deps.sink({ ts: new Date().toISOString(), event: "retry-excluded", tier: req.agent, agent: req.agent, identity: current.model, note: `${current.model}: ${e.code}` });
15505
- exclude2 = [current.model];
15506
- current = resolveForThrow(req, exclude2);
15507
- reg.get(dispatchId).identity = current.model;
15508
- }
15509
- }
15510
- } finally {
15511
- inFlight.delete(slot);
15512
- killControls.delete(dispatchId);
15513
- }
15514
- })();
15515
- return {
15516
- dispatchId,
15517
- agent: req.agent,
15518
- requested: { agent: req.agent, depth: req.depth },
15519
- resolved: first.model,
15520
- depth: first.depth,
15521
- queuedAt: reg.get(dispatchId).queuedAt
15522
- };
15523
- }
15524
- let exclude = undefined;
15525
- for (let attemptNo = 0;; attemptNo++) {
15526
- const r = attemptNo === 0 ? first : resolveForThrow(req, exclude);
15527
- const slot = `d${++seq}`;
15528
- inFlight.add(slot);
15529
- try {
15530
- return await runAttempt(r, req, parentSessionID, t0, undefined, undefined);
15531
- } catch (e) {
15532
- const identity = r.model;
15533
- const retryable = !r.agentPath && e instanceof DispatchError && (e.code === "host-error" || e.code === "empty-response");
15534
- if (!retryable || attemptNo > 0)
15535
- throw e;
15536
- const alternative = resolveDispatch(deps.cfg, { profile: req.agent, depth: req.depth, exclude: [identity] }, deps.available, deps.catalog);
15537
- if (!alternative.ok)
15538
- throw e;
15539
- deps.sink({ ts: new Date().toISOString(), event: "retry-excluded", tier: req.agent, agent: req.agent, identity, note: `${identity}: ${e.code}` });
15540
- exclude = [identity];
15541
- } finally {
15542
- inFlight.delete(slot);
15543
- }
15544
- }
15545
- }
15546
- return {
15547
- dispatch,
15548
- kill: (dispatchId) => {
15549
- const reg = deps.registry;
15550
- if (!reg)
15551
- throw new DispatchError("forge_dispatch_kill unavailable: no dispatch registry (internal error)", "kill-failed");
15552
- const e = reg.get(dispatchId);
15553
- if (e.state === "completed" || e.state === "timeout" || e.state === "error") {
15554
- throw new DispatchError(`forge_dispatch_kill refused: dispatch ${dispatchId} already ${e.state}; use forge_dispatch_list for its result.`, "kill-failed");
15555
- }
15556
- if (e.state === "killed")
15557
- throw new DispatchError(`dispatch ${dispatchId} is already killed.`, "kill-failed");
15558
- reg.markKilled(dispatchId);
15559
- deps.sink({ ts: new Date().toISOString(), event: "killed", tier: e.tier, identity: e.identity, depth: e.depth ?? undefined, sessionID: e.sessionID, outcome: "killed" });
15560
- killControls.get(dispatchId)?.fire();
15561
- },
15562
- inFlightCount: () => inFlight.size,
15563
- inFlightIDs: () => [...inFlight],
15564
- dispose: () => {
15565
- for (const slot of inFlight) {
15566
- deps.sink({ ts: new Date().toISOString(), event: "lost-on-exit", note: `dispatch ${slot} in flight at host exit` });
15567
- }
15568
- inFlight.clear();
15569
- }
15570
- };
15571
- }
15572
-
15573
- class KillRequested extends Error {
15574
- constructor() {
15575
- super("kill requested");
15576
- }
15577
- }
15578
- function priceFor(catalog, identity) {
15579
- const slash = identity.indexOf("/");
15580
- if (slash <= 0)
15581
- return null;
15582
- const model = catalog.providers?.[identity.slice(0, slash)]?.models?.[identity.slice(slash + 1)];
15583
- const cost = model?.cost;
15584
- if (!cost)
15585
- return null;
15586
- return {
15587
- spec: {
15588
- input: cost.input,
15589
- output: cost.output,
15590
- cacheRead: cost.cache_read,
15591
- cacheWrite: cost.cache_write
15592
- },
15593
- label: "models.dev snapshot"
15594
- };
15595
- }
15596
-
15597
- // src/dispatch-ledger.ts
15598
- import { mkdirSync as mkdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "node:fs";
15599
- import { dirname as dirname3 } from "node:path";
15600
- var DISPATCH_LEDGER_MAX_ENTRIES = 200;
15601
- var DISPATCH_LEDGER_MAX_BYTES = 1e6;
15602
- function createDispatchLedger(logPath, maxEntries = DISPATCH_LEDGER_MAX_ENTRIES, maxBytes = DISPATCH_LEDGER_MAX_BYTES) {
15603
- const append = (entry) => {
15604
- try {
15605
- mkdirSync4(dirname3(logPath), { recursive: true });
15606
- let lines = [];
15607
- try {
15608
- lines = readFileSync6(logPath, "utf8").split(`
15609
- `).filter((l) => l.trim().length > 0);
15610
- } catch {}
15611
- if (lines.length >= maxEntries)
15612
- lines = lines.slice(lines.length - maxEntries + 1);
15613
- lines.push(JSON.stringify(entry));
15614
- let text = lines.join(`
15615
- `) + `
15616
- `;
15617
- if (text.length > maxBytes) {
15618
- const keep = Math.max(1, Math.floor(maxEntries / 2));
15619
- text = lines.slice(-keep).join(`
15620
- `) + `
15621
- `;
15622
- }
15623
- writeFileSync4(logPath, text, "utf8");
15624
- } catch {}
15625
- };
15626
- const entries = () => {
15627
- try {
15628
- return readFileSync6(logPath, "utf8").split(`
15629
- `).filter((l) => l.trim().length > 0).map((l) => JSON.parse(l));
15630
- } catch {
15631
- return [];
15632
- }
15633
- };
15634
- return { append, entries, path: logPath };
15635
- }
15636
-
15637
- // src/dispatch-registry.ts
15638
- var TERMINAL_CAP = 50;
15639
- function createDispatchRegistry() {
15640
- const entries = new Map;
15641
- let seq = 0;
15642
- const fail = (msg) => {
15643
- throw new Error(`[forge:dispatch-registry] ${msg}`);
15644
- };
15645
- const isTerminal2 = (st) => st === "completed" || st === "timeout" || st === "killed" || st === "error";
15646
- return {
15647
- submit(entry) {
15648
- const dispatchId = `bg-${++seq}`;
15649
- entries.set(dispatchId, {
15650
- ...entry,
15651
- dispatchId,
15652
- background: true,
15653
- queuedAt: entry.queuedAt ?? new Date().toISOString(),
15654
- state: "queued",
15655
- delivered: false
15656
- });
15657
- return { dispatchId };
15658
- },
15659
- get(dispatchId) {
15660
- const e = entries.get(dispatchId);
15661
- if (!e)
15662
- throw new Error(`[forge:dispatch-registry] unknown dispatch id "${dispatchId}"`);
15663
- return e;
15664
- },
15665
- markRunning(dispatchId) {
15666
- const e = this.get(dispatchId);
15667
- if (e.state !== "queued")
15668
- fail(`illegal transition ${e.state} → running for ${dispatchId}`);
15669
- e.state = "running";
15670
- return e;
15671
- },
15672
- markCompleted(dispatchId, result) {
15673
- return this.terminate(dispatchId, "completed", result);
15674
- },
15675
- markTimeout(dispatchId, report) {
15676
- return this.terminate(dispatchId, "timeout", report);
15677
- },
15678
- markError(dispatchId, report) {
15679
- return this.terminate(dispatchId, "error", report);
15680
- },
15681
- markKilled(dispatchId) {
15682
- return this.terminate(dispatchId, "killed");
15683
- },
15684
- noteLateCompletion(dispatchId, result) {
15685
- const e = this.get(dispatchId);
15686
- if (e.state !== "killed")
15687
- fail(`late completion only makes sense on a killed dispatch (${dispatchId} is ${e.state})`);
15688
- e.lateCompletion = result;
15689
- },
15690
- terminate(dispatchId, state, result) {
15691
- const e = this.get(dispatchId);
15692
- if (isTerminal2(e.state))
15693
- fail(`illegal transition ${e.state} → ${state} for ${dispatchId}`);
15694
- e.state = state;
15695
- if (result !== undefined)
15696
- e.result = result;
15697
- return e;
15698
- },
15699
- takeUndelivered(parentSessionID) {
15700
- const out = [];
15701
- for (const e of entries.values()) {
15702
- if (parentSessionID !== undefined && e.parentSessionID !== parentSessionID)
15703
- continue;
15704
- if (isTerminal2(e.state) && !e.delivered && e.state !== "killed") {
15705
- e.delivered = true;
15706
- out.push(e);
15707
- }
15708
- }
15709
- return out;
15710
- },
15711
- list() {
15712
- const inFlight = [];
15713
- const terminal = [];
15714
- for (const e of entries.values()) {
15715
- if (isTerminal2(e.state))
15716
- terminal.push(e);
15717
- else
15718
- inFlight.push(e);
15719
- }
15720
- return { inFlight, terminal: terminal.slice(-TERMINAL_CAP) };
15721
- }
15722
- };
15723
- }
15724
-
15725
- // src/crew-gate.ts
15726
- var CREW_DISPATCH_EVENTS = new Set(["completed", "timeout", "error", "killed", "empty-response"]);
15727
- var DISPATCH_ID = /bg-\d+/g;
15728
- function validateCrewReport(report, ledgerRows, crew) {
15729
- const gaps = [];
15730
- if (!Array.isArray(report) || report.length === 0) {
15731
- return { ok: false, gaps: ["the report lists no subtasks — a crew completion report must cover every subtask"], dispatchIds: [] };
15732
- }
15733
- const rowById = new Map;
15734
- for (const row of ledgerRows) {
15735
- if (row.parentSessionID !== crew.sessionID)
15736
- continue;
15737
- if (row.ts < crew.startedAt)
15738
- continue;
15739
- 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")
15740
- continue;
15741
- if (CREW_DISPATCH_EVENTS.has(row.event) && row.dispatchId)
15742
- rowById.set(row.dispatchId, row);
15743
- }
15744
- const referenced = new Set;
15745
- report.forEach((sub, i) => {
15746
- const label = sub?.title?.trim() || `subtask #${i + 1}`;
15747
- const verdict = String(sub?.verdict ?? "").toUpperCase();
15748
- if (!sub?.title?.trim())
15749
- gaps.push(`${label}: missing title`);
15750
- if (verdict !== "PASS" && verdict !== "FAIL")
15751
- gaps.push(`${label}: verdict must be PASS or FAIL (got "${sub?.verdict ?? ""}")`);
15752
- const ids = [...String(sub?.evidence ?? "").matchAll(DISPATCH_ID)].map((m) => m[0]);
15753
- if (!String(sub?.evidence ?? "").trim())
15754
- gaps.push(`${label}: missing evidence reference`);
15755
- else if (ids.length === 0)
15756
- gaps.push(`${label}: evidence must reference a dispatch id (bg-N) that exists in the dispatch ledger`);
15757
- for (const id of ids) {
15758
- if (referenced.has(id))
15759
- gaps.push(`${label}: dispatch ${id} is already cited by another subtask — each dispatch belongs to exactly one verdict`);
15760
- referenced.add(id);
15761
- const row = rowById.get(id);
15762
- if (!row)
15763
- gaps.push(`${label}: dispatch ${id} has no ledger row for this crew (fabricated, stale, or pre-crew)`);
15764
- else if (verdict === "PASS" && row.event !== "completed") {
15765
- 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`);
15766
- }
15767
- }
15768
- if (verdict === "FAIL" && (!Array.isArray(sub?.attempts) || sub.attempts.length < 2)) {
15769
- gaps.push(`${label}: FAIL requires both failure reports (the attempt and the one retry) in attempts[]`);
15770
- }
15771
- });
15772
- for (const [id, row] of rowById) {
15773
- if (!referenced.has(id))
15774
- gaps.push(`dispatch ${id} (${row.event}, ${row.ts}) has no verdict in the report — a dispatched subtask was silently dropped`);
15775
- }
15776
- return { ok: gaps.length === 0, gaps, dispatchIds: [...rowById.keys()] };
14977
+ function resolvePinnedDepth(thoughtLevel, family, ladder) {
14978
+ const t = translateDepth(thoughtLevel, family, ladder);
14979
+ if (t.kind === "no-mapping")
14980
+ return { translation: null, findingMessage: t.message };
14981
+ if (t.kind === "not-injected")
14982
+ return { translation: null };
14983
+ return { translation: t };
15777
14984
  }
15778
14985
 
15779
14986
  // src/forge-config.ts
@@ -15804,7 +15011,7 @@ function sanitizeJsonc(text) {
15804
15011
  if (c === "/" && d === "*") {
15805
15012
  out.push(" ", " ");
15806
15013
  i += 2;
15807
- while (i < n && !(text[i] === "*" && i + 1 < n && text[i + 1] === "/")) {
15014
+ while (i < n && !(text[i] === "*" && i + 1 < n)) {
15808
15015
  out.push(text[i] === `
15809
15016
  ` ? `
15810
15017
  ` : " ");
@@ -15995,77 +15202,92 @@ function locateJsonError(text) {
15995
15202
  ws();
15996
15203
  return i >= n ? null : fail(`unexpected token '${text[i]}' after top-level value`);
15997
15204
  }
15998
- i++;
15999
- continue;
15205
+ i++;
15206
+ continue;
15207
+ }
15208
+ return fail(`unexpected token '${c}'`);
15209
+ }
15210
+ }
15211
+ function parseForgeJsonc(text) {
15212
+ const sanitized = sanitizeJsonc(text);
15213
+ let parsed;
15214
+ try {
15215
+ parsed = JSON.parse(sanitized);
15216
+ } catch {
15217
+ const located = locateJsonError(sanitized);
15218
+ return {
15219
+ ok: false,
15220
+ error: located ?? { message: "invalid JSON", line: 1, column: 1 }
15221
+ };
15222
+ }
15223
+ return { ok: true, config: parsed };
15224
+ }
15225
+ var AGENT_ID_RE = /^[a-z0-9-]+$/;
15226
+ function isRecord(v) {
15227
+ return typeof v === "object" && v !== null && !Array.isArray(v);
15228
+ }
15229
+ function nonEmptyString(v) {
15230
+ return typeof v === "string" && v.trim().length > 0;
15231
+ }
15232
+ function validateAgentSet(raw) {
15233
+ const findings = [];
15234
+ const agents = {};
15235
+ if (raw === undefined)
15236
+ return { agents, findings };
15237
+ if (!isRecord(raw)) {
15238
+ findings.push({ level: "error", code: "agents-not-object", message: 'forge.json "agents" must be an object mapping agent ids to definitions — the agent set is empty' });
15239
+ return { agents, findings };
15240
+ }
15241
+ for (const [id, entry] of Object.entries(raw)) {
15242
+ const label = `agents.${id}`;
15243
+ if (!AGENT_ID_RE.test(id)) {
15244
+ findings.push({ level: "error", code: "agent-id-invalid", message: `${label}: agent id must match [a-z0-9-] — entry skipped` });
15245
+ continue;
15246
+ }
15247
+ if (!isRecord(entry)) {
15248
+ findings.push({ level: "error", code: "agent-not-object", message: `${label}: definition must be an object — entry skipped` });
15249
+ continue;
15250
+ }
15251
+ if (!nonEmptyString(entry.model)) {
15252
+ findings.push({ level: "error", code: "agent-model-missing", message: `${label}.model: a non-empty "provider/model" string is required — entry skipped, siblings still apply` });
15253
+ continue;
15254
+ }
15255
+ const def = { model: entry.model.trim() };
15256
+ if (entry.depths !== undefined) {
15257
+ findings.push({ level: "warn", code: "depths-deprecated", message: `${label}.depths is no longer used (static agents pin one thoughtLevel) — ignored; set "thoughtLevel" instead` });
15258
+ }
15259
+ if (entry.thoughtLevel !== undefined) {
15260
+ if (nonEmptyString(entry.thoughtLevel)) {
15261
+ def.thoughtLevel = entry.thoughtLevel.trim();
15262
+ } else {
15263
+ findings.push({ level: "warn", code: "thoughtlevel-invalid", message: `${label}.thoughtLevel must be a non-empty string — field ignored, the agent still materializes` });
15264
+ }
15265
+ }
15266
+ if (entry.prompt !== undefined) {
15267
+ if (nonEmptyString(entry.prompt)) {
15268
+ def.prompt = entry.prompt;
15269
+ } else {
15270
+ findings.push({ level: "warn", code: "prompt-invalid", message: `${label}.prompt must be a non-empty string — field ignored` });
15271
+ }
15272
+ }
15273
+ if (entry.shape !== undefined) {
15274
+ if (entry.shape === "readonly" || entry.shape === "write") {
15275
+ def.shape = entry.shape;
15276
+ } else {
15277
+ findings.push({ level: "warn", code: "shape-invalid", message: `${label}.shape must be "readonly" or "write" — defaulting to readonly` });
15278
+ }
15279
+ }
15280
+ if (entry.permission !== undefined) {
15281
+ if (isRecord(entry.permission) && Object.values(entry.permission).every((v) => typeof v === "string")) {
15282
+ def.permission = entry.permission;
15283
+ } else {
15284
+ findings.push({ level: "warn", code: "permission-invalid", message: `${label}.permission must map tool names to string values — field ignored` });
15285
+ }
16000
15286
  }
16001
- return fail(`unexpected token '${c}'`);
16002
- }
16003
- }
16004
- function parseForgeJsonc(text) {
16005
- const sanitized = sanitizeJsonc(text);
16006
- let parsed;
16007
- try {
16008
- parsed = JSON.parse(sanitized);
16009
- } catch {
16010
- const located = locateJsonError(sanitized);
16011
- return {
16012
- ok: false,
16013
- error: located ?? { message: "invalid JSON", line: 1, column: 1 }
16014
- };
15287
+ agents[id] = def;
16015
15288
  }
16016
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
16017
- return { ok: false, error: { message: "forge.json top level must be an object", line: 1, column: 1 } };
16018
- }
16019
- const agents = parsed.agents;
16020
- if (agents === undefined)
16021
- return { ok: true, config: { agents: {} } };
16022
- if (typeof agents !== "object" || agents === null || Array.isArray(agents)) {
16023
- return { ok: false, error: { message: 'forge.json "agents" must be an object mapping agent ids to definitions', line: 1, column: 1 } };
16024
- }
16025
- return { ok: true, config: { agents } };
16026
- }
16027
- function forgeRecipe(input) {
16028
- const suggested = input.detectedIdentities[0] ?? "provider/model";
16029
- const identityLines = input.detectedIdentities.length > 0 ? input.detectedIdentities.map((id) => ` - ${id}`).join(`
16030
- `) : " (none detected — check the provider section of the host's opencode config)";
16031
- const template = `{
16032
- // forge dispatch agents — save the file; changes apply on the next dispatch, no restart
16033
- "agents": {
16034
- "research": {
16035
- "model": "${suggested}", // exact "provider/model" string, pick from the detected list above
16036
- "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
16037
- // "prompt": "optional role prompt riding inside the discipline wrapper",
16038
- // "shape": "write", // default readonly denies mutating tools; write agents need this
16039
- // "permission": { "bash": "deny" } // optional override of the shape-derived permission
16040
- }
16041
- }
16042
- }`;
16043
- return [
16044
- "[forge dispatch onboarding recipe]",
16045
- 'The dispatch agent you called is pinned to the placeholder model "Local/GPT Luna" — forge.json is not configured. Steps:',
16046
- "",
16047
- "1. Detected configured identities (use one as the exact model string):",
16048
- identityLines,
16049
- "",
16050
- "2. Create ONE of these files (project-level wins; a single file fully applies — never merged):",
16051
- ` - ${input.projectPath} (project-level, versionable, team-shared)`,
16052
- ` - ${input.globalPath} (global)`,
16053
- "",
16054
- "3. Copy-paste template (JSONC — comments allowed) and edit model/depths:",
16055
- "",
16056
- template,
16057
- "",
16058
- '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.',
16059
- "",
16060
- '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.'
16061
- ].join(`
16062
- `);
15289
+ return { agents, findings };
16063
15290
  }
16064
- var PLACEHOLDER_IDENTITY = "Local/GPT Luna";
16065
- var SEED_AGENTS = {
16066
- research: { model: PLACEHOLDER_IDENTITY, depths: ["low", "medium"], shape: "readonly" },
16067
- review: { model: PLACEHOLDER_IDENTITY, depths: ["medium", "high", "max"], shape: "readonly" }
16068
- };
16069
15291
  function forgeConfigPaths(opts) {
16070
15292
  const sep2 = opts.projectDir?.includes("\\") && !opts.projectDir?.includes("/") ? "\\" : "/";
16071
15293
  const project = opts.projectDir ? `${opts.projectDir}${sep2}.opencode${sep2}forge.json` : "";
@@ -16098,19 +15320,30 @@ function createForgeConfigLoader(deps) {
16098
15320
  let result;
16099
15321
  if (!parsed.ok) {
16100
15322
  result = {
16101
- agents: { ...SEED_AGENTS },
16102
- source: "seed",
16103
- path: null,
15323
+ agents: {},
15324
+ source,
15325
+ path,
16104
15326
  findings: [
16105
15327
  {
16106
15328
  level: "error",
16107
15329
  code: "config-parse-error",
16108
- message: `${path}: ${parsed.error.message}${parsed.error.line !== undefined ? ` (line ${parsed.error.line}, column ${parsed.error.column})` : ""} — fell back to the built-in seed`
15330
+ message: `${path}: ${parsed.error.message}${parsed.error.line !== undefined ? ` (line ${parsed.error.line}, column ${parsed.error.column})` : ""} — the agent set is empty until the file parses`
16109
15331
  }
16110
15332
  ]
16111
15333
  };
16112
15334
  } else {
16113
- result = { agents: parsed.config.agents, source, path, findings: [] };
15335
+ const top = parsed.config;
15336
+ if (top !== undefined && !isRecord(top)) {
15337
+ result = {
15338
+ agents: {},
15339
+ source,
15340
+ path,
15341
+ findings: [{ level: "error", code: "config-not-object", message: `${path}: top level must be an object — the agent set is empty` }]
15342
+ };
15343
+ } else {
15344
+ const validated = validateAgentSet(top === undefined ? undefined : top.agents);
15345
+ result = { agents: validated.agents, source, path, findings: validated.findings };
15346
+ }
16114
15347
  }
16115
15348
  cache.set(path, { mtimeMs: st.mtimeMs, size: st.size, result });
16116
15349
  return result;
@@ -16123,104 +15356,18 @@ function createForgeConfigLoader(deps) {
16123
15356
  const global = paths.global ? readCandidate(paths.global, "global") : null;
16124
15357
  if (global)
16125
15358
  return global;
16126
- return {
16127
- agents: { ...SEED_AGENTS },
16128
- source: "seed",
16129
- path: null,
16130
- findings: [
16131
- {
16132
- level: "notice",
16133
- code: "dispatch-unconfigured",
16134
- 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."
16135
- }
16136
- ]
16137
- };
15359
+ return { agents: {}, source: "none", path: null, findings: [] };
16138
15360
  }
16139
15361
  };
16140
15362
  }
16141
15363
 
16142
- // src/models-dev.ts
16143
- import { mkdirSync as mkdirSync5, readFileSync as readFileSync8, writeFileSync as writeFileSync5 } from "node:fs";
16144
- import { join as join4 } from "node:path";
16145
- import { tmpdir as tmpdir2 } from "node:os";
16146
- var DEFAULT_MODELS_DEV_URL = "https://models.dev/api.json";
16147
- function modelsDevCacheDir(base) {
16148
- return join4(base ?? join4(tmpdir2(), "opencode-forge"), "models-dev");
16149
- }
16150
- function namedLevels(rows) {
16151
- const out = [];
16152
- for (const r of rows) {
16153
- if (r && typeof r === "object" && r.type === "effort") {
16154
- const values = r.values;
16155
- if (Array.isArray(values)) {
16156
- for (const v of values)
16157
- if (typeof v === "string")
16158
- out.push(v);
16159
- }
16160
- }
16161
- }
16162
- return out;
16163
- }
16164
- function reduceModelsDev(raw) {
16165
- const catalog = { providers: {} };
16166
- const providers = raw?.constructor === Object ? raw : null;
16167
- if (!providers)
16168
- return catalog;
16169
- for (const [providerID, provider] of Object.entries(providers)) {
16170
- const models = provider.models;
16171
- if (!models || typeof models !== "object")
16172
- continue;
16173
- const out = { models: {} };
16174
- for (const [modelID, model] of Object.entries(models)) {
16175
- const m = model;
16176
- const options = Array.isArray(m.reasoning_options) ? namedLevels(m.reasoning_options) : undefined;
16177
- const cost = m.cost && typeof m.cost === "object" ? m.cost : undefined;
16178
- out.models[modelID] = {
16179
- ...options ? { reasoningOptions: options } : {},
16180
- ...cost ? { cost } : {}
16181
- };
16182
- }
16183
- catalog.providers[providerID] = out;
16184
- }
16185
- return catalog;
16186
- }
16187
- async function loadModelsDevSnapshot(opts = {}) {
16188
- const url2 = opts.url ?? process.env.OPENCODE_MODELS_URL ?? DEFAULT_MODELS_DEV_URL;
16189
- const cacheDir = modelsDevCacheDir(opts.cacheDir);
16190
- const cacheFile = join4(cacheDir, "snapshot.json");
16191
- const fetcher = opts.fetcher;
16192
- if (fetcher) {
16193
- try {
16194
- const res = await fetcher(url2, { signal: AbortSignal.timeout(opts.timeoutMs ?? 8000) });
16195
- if (res.ok)
16196
- return { catalog: reduceModelsDev(JSON.parse(await res.text())), degraded: false, source: url2 };
16197
- } catch {}
16198
- return { catalog: { providers: {} }, degraded: true, source: "fetch-failed" };
16199
- }
16200
- try {
16201
- const res = await fetch(url2, { signal: AbortSignal.timeout(opts.timeoutMs ?? 8000) });
16202
- if (res.ok) {
16203
- const catalog = reduceModelsDev(JSON.parse(await res.text()));
16204
- try {
16205
- mkdirSync5(cacheDir, { recursive: true });
16206
- writeFileSync5(cacheFile, JSON.stringify(catalog));
16207
- } catch {}
16208
- return { catalog, degraded: false, source: url2 };
16209
- }
16210
- } catch {}
16211
- try {
16212
- const cached2 = JSON.parse(readFileSync8(cacheFile, "utf8"));
16213
- if (cached2 && typeof cached2 === "object" && cached2.providers)
16214
- return { catalog: cached2, degraded: true, source: "cache" };
16215
- } catch {}
16216
- return { catalog: { providers: {} }, degraded: true, source: "unavailable" };
16217
- }
16218
-
16219
15364
  // plugin.ts
16220
15365
  var FORGE_AGENT = "forge";
16221
15366
  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.
16222
15367
 
16223
- 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.`;
15368
+ 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.
15369
+
15370
+ Subagent dispatch (task tool): when the work suits a scoped worker, prefer a configured \`forge-*\` subagent whose description matches the job. If none matches, say so plainly to the user and dispatch through the native task channel instead.`;
16224
15371
  var PLAN_COMMAND_TEMPLATE = [
16225
15372
  '(forge plan harness routing. Argument: "$ARGUMENTS")',
16226
15373
  "",
@@ -16279,22 +15426,14 @@ function effectiveWorktree(worktree, fallback) {
16279
15426
  }
16280
15427
  var sessions = new Map;
16281
15428
  var forgeDisabled = false;
16282
- var dispatchDisabled = false;
16283
- var dispatchMaxConcurrent = 4;
16284
- var dispatchTimeoutMs = 600000;
16285
- var dispatchBase = null;
16286
15429
  var forgeLoader = null;
16287
15430
  var forgeLoaderDir = null;
16288
- var forgeConfigState = null;
16289
- var lastDetectedIdentities = [];
16290
- var activeDispatchEngine = null;
16291
- var dispatchLogDir = join5(tmpdir3(), "opencode-forge", "dispatch");
16292
- var dispatchLedger = createDispatchLedger(join5(dispatchLogDir, "ledger.jsonl"));
16293
- var activeDispatchRegistry = null;
16294
- var dispatchClient = null;
16295
- var dispatchWakeTimers = new Map;
16296
15431
  var crews = new Map;
16297
- var dispatchDepths = new Map;
15432
+ var modelsDevCatalog = null;
15433
+ var depthFindingNotes = new Map;
15434
+ var crewCommandSnapshot = null;
15435
+ var materializedSnapshot = {};
15436
+ var sessionDepths = new Map;
16298
15437
  var OPENAI_FAMILY = new Set(["openai", "opencode", "openrouter", "groq", "xai", "azure", "github-copilot", "deepseek", "together", "vercel", "opus"]);
16299
15438
  var ANTHROPIC_FAMILY = new Set(["anthropic"]);
16300
15439
  var ZAI_FAMILY = new Set(["zai", "zai-coding-plan"]);
@@ -16307,32 +15446,6 @@ function dispatchProviderFamily(providerID) {
16307
15446
  return "zai";
16308
15447
  return null;
16309
15448
  }
16310
- function queueDispatchDepth(sessionID, translation) {
16311
- const key = (t) => t.kind === "verbatim" ? `verbatim:${t.word}` : t.kind === "tiered" ? `tiered:${t.budgetTokens}` : t.kind;
16312
- const existing = dispatchDepths.get(sessionID);
16313
- if (existing)
16314
- return key(existing) === key(translation);
16315
- dispatchDepths.set(sessionID, translation);
16316
- return true;
16317
- }
16318
- function configuredIdentitiesOf(cfg, catalog) {
16319
- const providers = cfg.provider ?? {};
16320
- const identities = [];
16321
- for (const [providerID, provider] of Object.entries(providers)) {
16322
- const modelKeys = provider?.models ? Object.keys(provider.models) : null;
16323
- if (modelKeys && modelKeys.length > 0) {
16324
- for (const m of modelKeys)
16325
- identities.push(`${providerID}/${m}`);
16326
- } else {
16327
- const catModels = catalog.providers?.[providerID]?.models ?? {};
16328
- const ids = Object.keys(catModels);
16329
- if (ids.length > 0)
16330
- for (const m of ids)
16331
- identities.push(`${providerID}/${m}`);
16332
- }
16333
- }
16334
- return identities;
16335
- }
16336
15449
  var jobsMode = "auto";
16337
15450
  var jobsKeepBuiltinShell = false;
16338
15451
  var jobsSurviveMode = "never";
@@ -16388,9 +15501,9 @@ var jobLogDir = jobsLogDir();
16388
15501
  var jobLedgerPath = join5(jobLogDir, "ledger.jsonl");
16389
15502
  function jobLedgerSink(entry) {
16390
15503
  try {
16391
- mkdirSync6(jobLogDir, { recursive: true });
15504
+ mkdirSync5(jobLogDir, { recursive: true });
16392
15505
  if (existsSync3(jobLedgerPath) && statSync4(jobLedgerPath).size > 1e6)
16393
- writeFileSync6(jobLedgerPath, "");
15506
+ writeFileSync5(jobLedgerPath, "");
16394
15507
  appendFileSync(jobLedgerPath, `${JSON.stringify(entry)}
16395
15508
  `);
16396
15509
  } catch {}
@@ -16451,7 +15564,7 @@ function readPlanDir(worktree) {
16451
15564
  const dir = planDirOf(worktree);
16452
15565
  if (!existsSync3(dir))
16453
15566
  return [];
16454
- return readdirSync4(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync9(join5(dir, name), "utf8") }));
15567
+ return readdirSync4(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync8(join5(dir, name), "utf8") }));
16455
15568
  }
16456
15569
  function ensureSession(sessionID, worktree) {
16457
15570
  const existing = sessions.get(sessionID);
@@ -16468,7 +15581,7 @@ function worktreeFor(context) {
16468
15581
  }
16469
15582
  function resolveActivePlan(state) {
16470
15583
  if (state.planPath && existsSync3(state.planPath)) {
16471
- const doc2 = parsePlanLoose(readFileSync9(state.planPath, "utf8"));
15584
+ const doc2 = parsePlanLoose(readFileSync8(state.planPath, "utf8"));
16472
15585
  if (doc2 && !isTerminal(doc2.status))
16473
15586
  return { path: state.planPath, doc: doc2 };
16474
15587
  state.planPath = undefined;
@@ -16510,12 +15623,12 @@ var planWriteTool = tool({
16510
15623
  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.`);
16511
15624
  } else {
16512
15625
  const dir = planDirOf(state.worktree);
16513
- mkdirSync6(dir, { recursive: true });
15626
+ mkdirSync5(dir, { recursive: true });
16514
15627
  path = join5(dir, planFileName(localDate(), slugify(args.goal), readdirSync4(dir).filter((f) => f.endsWith(".md"))));
16515
15628
  mode = "created";
16516
15629
  }
16517
15630
  const text = renderPlan(args, now, created);
16518
- writeFileSync6(path, text);
15631
+ writeFileSync5(path, text);
16519
15632
  state.planPath = path;
16520
15633
  const doc2 = parsePlan(text);
16521
15634
  context.metadata({ title: `${mode === "created" ? "Create" : "Revise"} plan: ${doc2.goal}` });
@@ -16543,8 +15656,8 @@ var planTickTool = tool({
16543
15656
  if (active.doc.status !== "approved") {
16544
15657
  throw new PlanError(`Plan status is ${active.doc.status}; only an approved plan can be ticked. Get user approval via plan_approve first.`);
16545
15658
  }
16546
- const next = tickTask(readFileSync9(active.path, "utf8"), args.n, nowIso());
16547
- writeFileSync6(active.path, next);
15659
+ const next = tickTask(readFileSync8(active.path, "utf8"), args.n, nowIso());
15660
+ writeFileSync5(active.path, next);
16548
15661
  const doc2 = parsePlan(next);
16549
15662
  const p = progressOf(doc2);
16550
15663
  context.metadata({ title: `Tick task ${args.n} (${p.done}/${p.total})` });
@@ -16571,7 +15684,7 @@ var planApproveTool = tool({
16571
15684
  throw new PlanError(`Plan status is ${active.doc.status}; only a draft plan can be approved.`);
16572
15685
  }
16573
15686
  await gate(context.ask, "plan_approve", `Approve plan: ${active.doc.goal}`);
16574
- writeFileSync6(active.path, transitionStatus(readFileSync9(active.path, "utf8"), "approved", nowIso()));
15687
+ writeFileSync5(active.path, transitionStatus(readFileSync8(active.path, "utf8"), "approved", nowIso()));
16575
15688
  context.metadata({ title: `Plan approved: ${active.doc.goal}` });
16576
15689
  return {
16577
15690
  title: "plan approved",
@@ -16604,7 +15717,7 @@ var planCloseTool = tool({
16604
15717
  Fix the implementation and retry, or revise the plan first.`);
16605
15718
  }
16606
15719
  await gate(context.ask, "plan_close", `Close plan: ${active.doc.goal}`);
16607
- writeFileSync6(active.path, transitionStatus(readFileSync9(active.path, "utf8"), "done", nowIso()));
15720
+ writeFileSync5(active.path, transitionStatus(readFileSync8(active.path, "utf8"), "done", nowIso()));
16608
15721
  context.metadata({ title: `Plan done: ${active.doc.goal}` });
16609
15722
  return {
16610
15723
  title: "plan done",
@@ -16622,7 +15735,7 @@ var planDiscardTool = tool({
16622
15735
  const active = resolveActivePlan(state);
16623
15736
  if (!active)
16624
15737
  throw new PlanError("No plan to abandon in this workspace.");
16625
- writeFileSync6(active.path, transitionStatus(readFileSync9(active.path, "utf8"), "abandoned", nowIso()));
15738
+ writeFileSync5(active.path, transitionStatus(readFileSync8(active.path, "utf8"), "abandoned", nowIso()));
16626
15739
  state.planPath = undefined;
16627
15740
  context.metadata({ title: `Plan abandoned: ${active.doc.goal}` });
16628
15741
  return { title: "plan abandoned", output: `Plan abandoned: ${relFrom(state.worktree, active.path)}. Write operations are restored.` };
@@ -16635,11 +15748,11 @@ function readGoalDir(worktree) {
16635
15748
  const dir = goalDirOf(worktree);
16636
15749
  if (!existsSync3(dir))
16637
15750
  return [];
16638
- return readdirSync4(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync9(join5(dir, name), "utf8") }));
15751
+ return readdirSync4(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync8(join5(dir, name), "utf8") }));
16639
15752
  }
16640
15753
  function resolveSessionGoal(state) {
16641
15754
  if (state.goalPath && existsSync3(state.goalPath)) {
16642
- const doc2 = parseGoalLoose(readFileSync9(state.goalPath, "utf8"));
15755
+ const doc2 = parseGoalLoose(readFileSync8(state.goalPath, "utf8"));
16643
15756
  if (doc2 && !isGoalTerminal(doc2.status))
16644
15757
  return { path: state.goalPath, doc: doc2 };
16645
15758
  state.goalPath = undefined;
@@ -16743,7 +15856,7 @@ var goalWriteTool = tool({
16743
15856
  await gate(context.ask, "goal_write", `Arm goal: ${args.goal}`);
16744
15857
  }
16745
15858
  const dir = goalDirOf(state.worktree);
16746
- mkdirSync6(dir, { recursive: true });
15859
+ mkdirSync5(dir, { recursive: true });
16747
15860
  const name = goalFileName(localDateNow(), slugifyGoal(args.goal), readdirSync4(dir).filter((f) => f.endsWith(".md")));
16748
15861
  const path = join5(dir, name);
16749
15862
  const text = renderGoal(input, {
@@ -16785,7 +15898,7 @@ var goalCheckTool = tool({
16785
15898
  o.index = selected[i].n;
16786
15899
  });
16787
15900
  const runId = `${nowIso()}-${Math.random().toString(36).slice(2, 8)}`;
16788
- const next = appendCheckLog(readFileSync9(goal.path, "utf8"), runId, outcomes, nowIso());
15901
+ const next = appendCheckLog(readFileSync8(goal.path, "utf8"), runId, outcomes, nowIso());
16789
15902
  atomicWrite(goal.path, next);
16790
15903
  const ok = outcomesAllOk(outcomes);
16791
15904
  context.metadata({ title: `goal_check: ${outcomes.filter((o) => o.ok).length}/${outcomes.length} pass` });
@@ -16819,7 +15932,7 @@ var goalCompleteTool = tool({
16819
15932
  const failures = outcomes.filter((o) => !o.ok);
16820
15933
  if (failures.length > 0) {
16821
15934
  const runId2 = `${nowIso()}-${Math.random().toString(36).slice(2, 8)}`;
16822
- atomicWrite(goal.path, appendCheckLog(readFileSync9(goal.path, "utf8"), runId2, outcomes, nowIso()));
15935
+ atomicWrite(goal.path, appendCheckLog(readFileSync8(goal.path, "utf8"), runId2, outcomes, nowIso()));
16823
15936
  throw new GoalError(`Completion gate: verification re-run failed (fail-closed). The goal stays active.
16824
15937
  ${formatOutcomes(failures)}
16825
15938
  Fix the work and retry; recorded results never substitute for the gate's own re-run.`);
@@ -16832,7 +15945,7 @@ Fix the work and retry; recorded results never substitute for the gate's own re-
16832
15945
  }
16833
15946
  await gate(context.ask, "goal_complete", `Complete goal: ${goal.doc.goal}`);
16834
15947
  const runId = `${nowIso()}-${Math.random().toString(36).slice(2, 8)}`;
16835
- let text = appendCheckLog(readFileSync9(goal.path, "utf8"), runId, outcomes, nowIso());
15948
+ let text = appendCheckLog(readFileSync8(goal.path, "utf8"), runId, outcomes, nowIso());
16836
15949
  text = transitionGoal(text, "completed", nowIso());
16837
15950
  atomicWrite(goal.path, text);
16838
15951
  context.metadata({ title: `Goal completed: ${goal.doc.goal}` });
@@ -16854,7 +15967,7 @@ var goalPauseTool = tool({
16854
15967
  throw new GoalError(`No active goal to pause (status: ${goal?.doc.status ?? "none"}).`);
16855
15968
  }
16856
15969
  const stopReason = args.blocker ? "blocker" : "user";
16857
- atomicWrite(goal.path, transitionGoal(readFileSync9(goal.path, "utf8"), "paused", nowIso(), { stopReason }));
15970
+ atomicWrite(goal.path, transitionGoal(readFileSync8(goal.path, "utf8"), "paused", nowIso(), { stopReason }));
16858
15971
  engineForgetSession(context.sessionID);
16859
15972
  context.metadata({ title: `Goal paused (${stopReason}): ${goal.doc.goal}` });
16860
15973
  return {
@@ -16884,7 +15997,7 @@ var goalResumeTool = tool({
16884
15997
  }
16885
15998
  const promoting = goal.doc.status === "queued";
16886
15999
  await gate(context.ask, "goal_resume", `${promoting ? "Promote" : "Resume"} goal: ${goal.doc.goal}`);
16887
- let text = readFileSync9(goal.path, "utf8");
16000
+ let text = readFileSync8(goal.path, "utf8");
16888
16001
  if (args.addTurns)
16889
16002
  text = bumpBudget(text, args.addTurns, nowIso());
16890
16003
  text = transitionGoal(text, "active", nowIso(), { session: context.sessionID });
@@ -16910,7 +16023,7 @@ var goalDiscardTool = tool({
16910
16023
  if (!goal)
16911
16024
  throw new GoalError("No goal to discard in this workspace.");
16912
16025
  await gate(context.ask, "goal_discard", `Discard goal: ${goal.doc.goal}`);
16913
- atomicWrite(goal.path, transitionGoal(readFileSync9(goal.path, "utf8"), "abandoned", nowIso()));
16026
+ atomicWrite(goal.path, transitionGoal(readFileSync8(goal.path, "utf8"), "abandoned", nowIso()));
16914
16027
  state.goalPath = undefined;
16915
16028
  engineForgetSession(context.sessionID);
16916
16029
  context.metadata({ title: `Goal abandoned: ${goal.doc.goal}` });
@@ -17107,226 +16220,47 @@ ${p.newOutput || "(none in this window)"}`,
17107
16220
  };
17108
16221
  }
17109
16222
  });
17110
- function dispatchWakeDelay(idleDriven) {
17111
- const env = Number(process.env.FORGE_DISPATCH_WAKE_DEBOUNCE_MS);
17112
- if (Number.isFinite(env) && env >= 0)
17113
- return env;
17114
- return idleDriven ? IDLE_DEBOUNCE_MS + 50 : 1500;
17115
- }
17116
- function scheduleDispatchWake(sessionID, idleDriven) {
17117
- if (dispatchDisabled || forgeDisabled)
17118
- return;
17119
- if (dispatchWakeTimers.has(sessionID))
17120
- return;
17121
- const t = setTimeout(() => {
17122
- dispatchWakeTimers.delete(sessionID);
17123
- injectDispatchBrief(sessionID);
17124
- }, dispatchWakeDelay(idleDriven));
17125
- dispatchWakeTimers.set(sessionID, t);
17126
- }
17127
- function dispatchBriefText(entries) {
17128
- const lines = [`[forge:dispatch-complete] ${entries.length} background dispatch(es) reached terminal state:`];
17129
- for (const e of entries) {
17130
- lines.push("", `- ${e.dispatchId} · agent ${e.tier} · ${e.identity}${e.depth ? ` @${e.depth}` : ""} · ${e.state.toUpperCase()} · session ${e.sessionID || "(not yet started)"}`);
17131
- const r = e.result;
17132
- if (r !== undefined) {
17133
- lines.push(` result: ${JSON.stringify(r, null, 1).replace(/\n/g, `
17134
- `).slice(0, 2000)}`);
17135
- }
17136
- }
17137
- return lines.join(`
17138
- `);
17139
- }
17140
- async function injectDispatchBrief(sessionID) {
17141
- const reg = activeDispatchRegistry;
17142
- if (!reg)
17143
- return;
17144
- const c0 = dispatchClient;
17145
- if (typeof c0?.session.status === "function") {
17146
- try {
17147
- const st = await c0.session.status({ path: { id: sessionID } });
17148
- const t = st?.[sessionID]?.type;
17149
- if (t && t !== "idle")
17150
- return;
17151
- } catch {
17152
- return;
17153
- }
17154
- }
17155
- const entries = reg.takeUndelivered(sessionID);
17156
- if (entries.length === 0)
17157
- return;
17158
- const c = dispatchClient;
17159
- const send = c?.session.promptAsync ?? c?.session.prompt;
17160
- if (typeof send !== "function")
17161
- return;
17162
- try {
17163
- await send.call(c.session, { path: { id: sessionID }, body: { parts: [{ type: "text", text: dispatchBriefText(entries) }] } });
17164
- } catch {
17165
- for (const e of entries)
17166
- e.delivered = false;
17167
- }
17168
- }
17169
- var forgeDispatchTool = tool({
17170
- 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.",
17171
- args: {
17172
- prompt: tool.schema.string().describe("Complete, self-contained task for the worker (it cannot see this conversation)"),
17173
- 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)"),
17174
- 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"),
17175
- 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.")
17176
- },
17177
- execute: async (args, context) => {
17178
- const state = stateForBan(context.sessionID);
17179
- const active = state ? resolveActivePlan(state) : null;
17180
- if (active && active.doc.status === "draft") {
17181
- 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.");
17182
- }
17183
- if (!args.prompt?.trim())
17184
- throw new Error("forge_dispatch requires a non-empty prompt.");
17185
- const engine = activeDispatchEngine;
17186
- if (!engine)
17187
- throw new Error("[forge] dispatch engine unavailable (disabled by dispatch.disable, or config still loading).");
17188
- try {
17189
- 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);
17190
- if ("dispatchId" in r) {
17191
- context.metadata({ title: `dispatch ${r.agent} queued (${r.dispatchId})` });
17192
- return {
17193
- title: `dispatch ${r.agent} queued → ${r.resolved}${r.depth ? ` @${r.depth}` : ""}`,
17194
- output: [
17195
- `[forge:dispatch] accepted in the background: ${r.dispatchId}`,
17196
- `agent: ${r.agent} resolved: ${r.resolved}${r.depth ? ` depth: ${r.depth}` : ""}`,
17197
- `queuedAt: ${r.queuedAt}`,
17198
- "",
17199
- "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."
17200
- ].join(`
17201
- `)
17202
- };
17203
- }
17204
- const tok = r.tokens;
17205
- const costLine = r.costUsd !== null && r.costUsd !== undefined ? `$${r.costUsd.toFixed(6)}${r.priceSnapshot ? ` (${r.priceSnapshot})` : ""}` : `unpriced${r.costNote ? ` — ${r.costNote}` : ""}`;
17206
- context.metadata({ title: `dispatch ${r.agent}: ${r.actual.model} @ ${r.actual.depth}` });
17207
- return {
17208
- title: `dispatch ${r.agent} → ${r.actual.model} @${r.actual.depth}`,
17209
- output: [
17210
- `[forge:dispatch] completed in ${r.durationMs}ms`,
17211
- `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})` : ""}`,
17212
- `session: ${r.sessionID}`,
17213
- `tokens: input ${tok.input ?? 0}, output ${tok.output ?? 0}, reasoning ${tok.reasoning ?? 0}, cache r/w ${tok.cache?.read ?? 0}/${tok.cache?.write ?? 0}`,
17214
- `cost: ${costLine}`,
17215
- `depthTranslation: ${r.depthTranslation}`,
17216
- "",
17217
- "Worker report:",
17218
- r.text || "(empty worker report)"
17219
- ].join(`
17220
- `)
17221
- };
17222
- } catch (err) {
17223
- if (err instanceof DispatchError && err.code === "pin-unavailable") {
17224
- throw new Error(`[forge:dispatch:pin-unavailable] ${err.message}
17225
-
17226
- ${dispatchRecipeText()}`);
17227
- }
17228
- if (err instanceof DispatchError)
17229
- throw new Error(`[forge:dispatch:${err.code}] ${err.message}`);
17230
- throw err;
17231
- }
17232
- }
17233
- });
17234
- function dispatchRecipeText() {
17235
- const paths = forgeConfigPaths({ projectDir: forgeLoaderDir ?? undefined, homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() });
17236
- return forgeRecipe({
17237
- detectedIdentities: lastDetectedIdentities,
17238
- projectPath: paths.project || "(project .opencode/forge.json)",
17239
- globalPath: paths.global
17240
- });
17241
- }
17242
- var forgeDispatchConfigTool = tool({
17243
- 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.",
17244
- args: {},
17245
- execute: async (_args, context) => {
17246
- const loaded = forgeLoader?.load() ?? { agents: {}, source: "seed", path: null, findings: [] };
17247
- context.metadata({ title: `dispatch config (${loaded.source})` });
17248
- return {
17249
- title: `dispatch config (${loaded.source}${loaded.path ? `: ${loaded.path}` : ""})`,
17250
- output: JSON.stringify({
17251
- agents: loaded.agents,
17252
- knobs: { timeoutMs: dispatchTimeoutMs, maxConcurrent: dispatchMaxConcurrent },
17253
- findings: loaded.findings
17254
- }, null, 2)
17255
- };
17256
- }
17257
- });
17258
- var forgeDispatchListTool = tool({
17259
- 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.",
17260
- args: {},
17261
- execute: async () => {
17262
- const reg = activeDispatchRegistry;
17263
- if (!reg)
17264
- throw new Error("[forge] no background dispatches have been submitted.");
17265
- const l = reg.list();
17266
- if (l.inFlight.length === 0 && l.terminal.length === 0) {
17267
- return { title: "no background dispatches", output: "[forge:dispatch-list] no background dispatches recorded in this session." };
17268
- }
17269
- const lines = [];
17270
- if (l.inFlight.length > 0) {
17271
- lines.push("In flight:");
17272
- for (const e of l.inFlight) {
17273
- lines.push(`- ${e.dispatchId} · agent ${e.tier} · ${e.identity}${e.depth ? ` @${e.depth}` : ""} · ${e.state} · queued ${e.queuedAt} · session ${e.sessionID || "(starting)"}`);
17274
- }
17275
- }
17276
- if (l.terminal.length > 0) {
17277
- lines.push("", "Recent terminal results:");
17278
- for (const e of l.terminal.slice().reverse()) {
17279
- lines.push(`- ${e.dispatchId} · agent ${e.tier} · ${e.identity}${e.depth ? ` @${e.depth}` : ""} · ${e.state.toUpperCase()} · session ${e.sessionID || "(not started)"}`);
17280
- const r = e.result;
17281
- if (r !== undefined)
17282
- lines.push(` result: ${JSON.stringify(r, null, 1).slice(0, 2000)}`);
17283
- if (e.lateCompletion !== undefined)
17284
- lines.push(` (killed; a late result arrived after the kill and was discarded: ${JSON.stringify(e.lateCompletion).slice(0, 300)})`);
17285
- }
17286
- }
17287
- return { title: `dispatches: ${l.inFlight.length} in flight, ${l.terminal.length} terminal`, output: lines.join(`
17288
- `) };
17289
- }
17290
- });
17291
- var forgeDispatchKillTool = tool({
17292
- 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.",
17293
- args: {
17294
- dispatchId: tool.schema.string().describe("The dispatchId returned by forge_dispatch (background) or shown by forge_dispatch_list")
17295
- },
17296
- execute: async (args) => {
17297
- const engine = activeDispatchEngine;
17298
- if (!engine)
17299
- throw new Error("[forge] dispatch engine unavailable.");
17300
- try {
17301
- engine.kill(args.dispatchId);
17302
- } catch (err) {
17303
- if (err instanceof DispatchError)
17304
- throw new Error(`[forge:dispatch:${err.code}] ${err.message}`);
17305
- throw err;
17306
- }
17307
- return {
17308
- title: `${args.dispatchId} killed`,
17309
- 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.`
17310
- };
17311
- }
17312
- });
17313
- var CREW_COMMAND_TEMPLATE = [
17314
- "Objective: {args}",
16223
+ var CREW_INIT_TEMPLATE = [
16224
+ '(forge crew orchestration. Argument: "$ARGUMENTS")',
17315
16225
  "",
17316
- "You are entering CREW ORCHESTRATION discipline for this session (single subject: do not switch agents). Follow it exactly:",
16226
+ "CREW IS NOT INITIALIZED: no forge subagents are configured (no usable forge.json agents). Refuse to enter orchestration and tell the user this feature needs a one-time setup, offering BOTH options:",
17317
16227
  "",
17318
- "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.",
17319
- "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.",
17320
- "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.",
17321
- "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.",
17322
- "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.",
17323
- "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."
16228
+ "1. The user configures it themselves: create ONE of these files (project-level wins, never merged):",
16229
+ "{paths}",
16230
+ "",
16231
+ "2. Or, ONLY if the user explicitly asks you to configure it in this conversation, you may write the file yourself through the normal write path. NEVER write forge.json without that explicit go-ahead.",
16232
+ "",
16233
+ "Template (JSONC — comments allowed; `model` is the exact provider/model identity, `thoughtLevel` is optional: none/low/medium/high/max or a native level name):",
16234
+ "{template}",
16235
+ "",
16236
+ "After the file is saved: NEW agents require a host restart to appear in the task tool; thoughtLevel edits on existing agents apply without restart. Until then, crew stays unavailable."
17324
16237
  ].join(`
17325
16238
  `);
16239
+ function crewOrchestrationTemplate(agentIds) {
16240
+ return [
16241
+ '(forge crew orchestration. Argument: "$ARGUMENTS")',
16242
+ "",
16243
+ `Configured forge subagents (task-tool vocabulary): ${agentIds.map((id) => `forge-${id}`).join(", ")}`,
16244
+ "",
16245
+ "You are entering CREW ORCHESTRATION discipline for this session (single subject: do not switch agents). Follow it exactly:",
16246
+ "",
16247
+ "1. REGISTER: call `crew_begin` with the objective AND the declared subtask plan (subtasks[]: one {title} per subtask, optionally naming the intended forge-* agent). If crew_begin refuses (a crew is already active here, or a plan draft is active), STOP and tell the user why — never start a second crew. Reconnaissance first if you need it to decompose well; a wrong plan is fixed by discarding and re-crewing, never by improvising outside the declared plan.",
16248
+ "2. WAVES, NEVER FLOODS: execute subtasks in waves of parallel native `task` calls (subagent_type = the matching forge-* agent; a wave's results return within the calling turn). Launch the next wave ONLY after the previous wave's results are in.",
16249
+ "3. MISSING ROLE: when a subtask needs a role no configured forge-* agent matches, run that subtask through a native task call anyway AND tell the user about the gap — suggest configuring the missing role in forge.json.",
16250
+ "4. VERIFY: judge each subtask strictly against its acceptance-evidence statement from the task result text. A result that does not meet the statement is a failure even if the worker sounded confident.",
16251
+ "5. RETRY AT MOST ONCE: a failed subtask may be retried once with an adjusted prompt or agent. 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.",
16252
+ "6. CLOSE: when every declared 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 declared plan — a declared subtask missing from the report refuses the close, and so does an undeclared one. The user confirms the closing dialog."
16253
+ ].join(`
16254
+ `);
16255
+ }
17326
16256
  var crewBeginTool = tool({
17327
- 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).",
16257
+ description: "Register this session's crew (from /crew) with its DECLARED subtask plan. Refuses when a crew is already active in the session, while a plan draft is active, or when subtasks are missing/duplicated. Crew state is in-memory: a host restart ends it honestly.",
17328
16258
  args: {
17329
- objective: tool.schema.string().describe("One-line crew objective (from /crew)")
16259
+ objective: tool.schema.string().describe("One-line crew objective (from /crew)"),
16260
+ subtasks: tool.schema.array(tool.schema.object({
16261
+ title: tool.schema.string().describe("Short unique subtask title — the crew_close report must use these exact titles"),
16262
+ agent: tool.schema.string().optional().describe("Intended forge-* agent id for this subtask, when one matches")
16263
+ })).describe("The declared subtask plan — decompose the objective into these before any execution; crew_close cross-checks the report against exactly this list")
17330
16264
  },
17331
16265
  execute: async (args, context) => {
17332
16266
  const state = stateForBan(context.sessionID);
@@ -17334,90 +16268,84 @@ var crewBeginTool = tool({
17334
16268
  if (active && active.doc.status === "draft") {
17335
16269
  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.");
17336
16270
  }
16271
+ const loaded = forgeLoader?.load();
16272
+ if (!loaded || Object.keys(loaded.agents).length === 0) {
16273
+ const paths = forgeConfigPaths({ projectDir: forgeLoaderDir ?? undefined, homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() });
16274
+ throw new Error([
16275
+ "[forge:crew] CREW IS NOT INITIALIZED: no forge subagents are configured. Crew orchestration is unavailable until the user sets it up.",
16276
+ `Create ONE of these files (project-level wins): ${paths.project} OR ${paths.global}`,
16277
+ 'Template (JSONC): {"agents": {"research": {"model": "provider/model", "thoughtLevel": "low"}}}',
16278
+ "The user may configure it themselves, or ask you to write the file — only on their explicit go-ahead. Newly added agents need a host restart; thoughtLevel edits apply without one."
16279
+ ].join(`
16280
+ `));
16281
+ }
17337
16282
  if (!args.objective?.trim())
17338
16283
  throw new Error("crew_begin requires a non-empty objective (from /crew <objective>).");
16284
+ const raw = Array.isArray(args.subtasks) ? args.subtasks : [];
16285
+ if (raw.length === 0)
16286
+ throw new Error("[forge:crew] crew_begin requires subtasks[] — declare the plan before executing (decompose the objective into titled subtasks).");
16287
+ const seen = new Set;
16288
+ const subtasks = [];
16289
+ for (const s of raw) {
16290
+ const title = String(s?.title ?? "").trim();
16291
+ if (!title)
16292
+ throw new Error("[forge:crew] every subtask needs a non-empty title.");
16293
+ const key = title.toLowerCase();
16294
+ if (seen.has(key))
16295
+ throw new Error(`[forge:crew] duplicate subtask title "${title}" — titles must be unique (they are the crew_close matching key).`);
16296
+ seen.add(key);
16297
+ const agent = String(s?.agent ?? "").trim();
16298
+ subtasks.push(agent ? { title, agent } : { title });
16299
+ }
17339
16300
  const existing = crews.get(context.sessionID);
17340
16301
  if (existing) {
17341
16302
  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.`);
17342
16303
  }
17343
- crews.set(context.sessionID, { objective: args.objective.trim(), startedAt: nowIso() });
16304
+ crews.set(context.sessionID, { objective: args.objective.trim(), startedAt: nowIso(), subtasks });
17344
16305
  return {
17345
16306
  title: `crew registered: ${args.objective.trim().slice(0, 60)}`,
17346
- 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.`
16307
+ output: [
16308
+ `[forge:crew] Crew registered for this session. Objective: ${args.objective.trim()}`,
16309
+ `Declared plan (${subtasks.length}):`,
16310
+ ...subtasks.map((s, i) => ` ${i + 1}. ${s.title}${s.agent ? ` → ${s.agent}` : ""}`),
16311
+ "Proceed with the discipline: waves of parallel native task calls (next wave after the previous results) → per-subtask evidence verdicts → at most one retry → crew_close with the full report covering every declared subtask."
16312
+ ].join(`
16313
+ `)
17347
16314
  };
17348
16315
  }
17349
16316
  });
17350
16317
  var crewCloseTool = tool({
17351
- 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.",
16318
+ description: "Close this session's crew with the final report (ask-level user confirmation). The gate cross-checks the report against the declared subtask plan: every declared subtask needs a verdict (PASS/FAIL) and an evidence statement; an undeclared subtask in the report refuses the close; FAIL requires both failure reports (attempt + the one retry). On success the crew ends and the summary is the tool output (there is no persistent crew record).",
17352
16319
  args: {
17353
16320
  report: tool.schema.array(tool.schema.object({
17354
- title: tool.schema.string().describe("Subtask title"),
16321
+ title: tool.schema.string().describe("Subtask title — must match a title declared at crew_begin"),
17355
16322
  verdict: tool.schema.string().describe("PASS or FAIL"),
17356
- evidence: tool.schema.string().describe("Acceptance evidence with the bg-N dispatch id reference"),
16323
+ evidence: tool.schema.string().describe("Acceptance evidence: what the result contained that satisfies the acceptance statement"),
17357
16324
  attempts: tool.schema.array(tool.schema.string()).optional().describe("For FAIL: both failure reports (attempt and retry)")
17358
- })).describe("The full crew report — one entry per subtask")
16325
+ })).describe("The full crew report — one entry per declared subtask")
17359
16326
  },
17360
16327
  execute: async (args, context) => {
17361
16328
  const crew = crews.get(context.sessionID);
17362
16329
  if (!crew)
17363
16330
  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>.");
17364
- const rows = [];
17365
- try {
17366
- const file2 = readFileSync9(join5(dispatchLogDir, "ledger.jsonl"), "utf8");
17367
- for (const line of file2.split(`
17368
- `)) {
17369
- const t = line.trim();
17370
- if (!t)
17371
- continue;
17372
- try {
17373
- rows.push(JSON.parse(t));
17374
- } catch {}
17375
- }
17376
- } catch {}
17377
- const check2 = validateCrewReport(args.report ?? [], rows, { objective: crew.objective, startedAt: crew.startedAt, sessionID: context.sessionID });
16331
+ const report = args.report ?? [];
16332
+ const check2 = validateCrewReport(report, crew.subtasks);
17378
16333
  if (!check2.ok) {
17379
- throw new Error(`[forge:crew] crew_close refused — the report does not match the dispatch ledger:
16334
+ throw new Error(`[forge:crew] crew_close refused — the report does not match the declared plan:
17380
16335
  ${check2.gaps.map((g) => `- ${g}`).join(`
17381
16336
  `)}
17382
16337
  The crew stays active; complete or correct the report and call crew_close again.`);
17383
16338
  }
17384
- let tokensIn = 0;
17385
- let tokensOut = 0;
17386
- let costSum = 0;
17387
- let costKnown = 0;
17388
- for (const row of rows) {
17389
- if (typeof row.dispatchId !== "string" || !check2.dispatchIds.includes(row.dispatchId))
17390
- continue;
17391
- const tok = row.tokens;
17392
- tokensIn += tok?.input ?? 0;
17393
- tokensOut += (tok?.output ?? 0) + (tok?.reasoning ?? 0);
17394
- if (typeof row.costUsd === "number") {
17395
- costSum += row.costUsd;
17396
- costKnown++;
17397
- }
17398
- }
17399
16339
  await gate(context.ask, "crew_close", `Close crew: ${crew.objective}`);
17400
- dispatchLedger.append({
17401
- ts: nowIso(),
17402
- event: "crew-summary",
17403
- tier: "crew",
17404
- outcome: "closed",
17405
- sessionID: context.sessionID,
17406
- note: crew.objective,
17407
- tokens: { input: tokensIn, output: tokensOut, reasoning: 0, cache: { read: 0, write: 0 } },
17408
- costUsd: costKnown > 0 ? costSum : null,
17409
- dispatchIds: check2.dispatchIds,
17410
- subtasks: (args.report ?? []).map((r) => ({ title: r.title, verdict: String(r.verdict).toUpperCase(), evidence: r.evidence }))
17411
- });
17412
16340
  crews.delete(context.sessionID);
17413
- const fails = (args.report ?? []).filter((r) => String(r.verdict).toUpperCase() === "FAIL").length;
16341
+ const fails = report.filter((r) => String(r.verdict).toUpperCase() === "FAIL").length;
17414
16342
  return {
17415
16343
  title: `crew closed: ${crew.objective.slice(0, 60)}`,
17416
16344
  output: [
17417
16345
  `[forge:crew] Crew closed: ${crew.objective}`,
17418
- `subtasks: ${(args.report ?? []).length} (PASS ${(args.report ?? []).length - fails}, FAIL ${fails})`,
17419
- `dispatches: ${check2.dispatchIds.length} tokens: in ${tokensIn}, out ${tokensOut} cost: ${costKnown > 0 ? `$${costSum.toFixed(6)}` : "unpriced"}`,
17420
- "The summary is appended to the dispatch ledger."
16346
+ `subtasks: ${report.length} (PASS ${report.length - fails}, FAIL ${fails})`,
16347
+ ...report.map((r) => `- ${r.title}: ${String(r.verdict).toUpperCase()} — ${String(r.evidence).slice(0, 200)}`),
16348
+ "This output is the crew's record (crew state is in-memory; nothing persists)."
17421
16349
  ].join(`
17422
16350
  `)
17423
16351
  };
@@ -17441,11 +16369,7 @@ function forgeTools() {
17441
16369
  tools.forge_shell = forgeShellTool;
17442
16370
  tools.forge_jobs = forgeJobsTool;
17443
16371
  }
17444
- if (!dispatchDisabled && !forgeDisabled) {
17445
- tools.forge_dispatch = forgeDispatchTool;
17446
- tools.forge_dispatch_config = forgeDispatchConfigTool;
17447
- tools.forge_dispatch_list = forgeDispatchListTool;
17448
- tools.forge_dispatch_kill = forgeDispatchKillTool;
16372
+ if (!forgeDisabled) {
17449
16373
  tools.crew_begin = crewBeginTool;
17450
16374
  tools.crew_close = crewCloseTool;
17451
16375
  }
@@ -17518,7 +16442,7 @@ function wrapupBriefText(goal, reason) {
17518
16442
  }
17519
16443
  async function autoPauseGoal(client, state, goal, reason, wrapup) {
17520
16444
  goalProbe(`auto-pause session=${state.sessionID} reason=${reason} wrapup=${wrapup}`);
17521
- atomicWrite(goal.path, transitionGoal(readFileSync9(goal.path, "utf8"), "paused", nowIso(), { stopReason: reason }));
16445
+ atomicWrite(goal.path, transitionGoal(readFileSync8(goal.path, "utf8"), "paused", nowIso(), { stopReason: reason }));
17522
16446
  engineForgetSession(state.sessionID);
17523
16447
  if (wrapup && goal.doc.session) {
17524
16448
  try {
@@ -17567,9 +16491,9 @@ async function continueIfEligible(client, sessionID) {
17567
16491
  const ledgerPath = state.goalPath;
17568
16492
  if (ledgerPath && existsSync3(ledgerPath)) {
17569
16493
  try {
17570
- const fresh = parseGoalLoose(readFileSync9(ledgerPath, "utf8"));
16494
+ const fresh = parseGoalLoose(readFileSync8(ledgerPath, "utf8"));
17571
16495
  if (fresh && fresh.turnsUsed > 0) {
17572
- 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()));
16496
+ atomicWrite(ledgerPath, appendLedger(readFileSync8(ledgerPath, "utf8"), { turn: fresh.turnsUsed, revision: fresh.revision, at: nowIso(), activity: turnHadActivity, writes: act?.writes ?? 0, checks: act?.checks ?? 0 }, nowIso()));
17573
16497
  }
17574
16498
  } catch (err) {
17575
16499
  goalProbe(`ledger append failed session=${sessionID} err=${String(err)}`);
@@ -17622,10 +16546,7 @@ async function continueIfEligible(client, sessionID) {
17622
16546
  }
17623
16547
  }
17624
16548
  try {
17625
- const wakeEntries = activeDispatchRegistry?.takeUndelivered(sessionID) ?? [];
17626
- const briefText = wakeEntries.length > 0 ? `${dispatchBriefText(wakeEntries)}
17627
-
17628
- ${goalBriefText(state, goal)}` : goalBriefText(state, goal);
16549
+ const briefText = goalBriefText(state, goal);
17629
16550
  await client.session.prompt({
17630
16551
  path: { id: sessionID },
17631
16552
  body: { parts: [{ type: "text", text: briefText }] }
@@ -17634,7 +16555,7 @@ ${goalBriefText(state, goal)}` : goalBriefText(state, goal);
17634
16555
  pendingContinuationTurn.add(sessionID);
17635
16556
  turnActivity.set(sessionID, { writes: 0, checks: 0 });
17636
16557
  state.goalPath = goal.path;
17637
- atomicWrite(goal.path, incTurns(readFileSync9(goal.path, "utf8"), nowIso()));
16558
+ atomicWrite(goal.path, incTurns(readFileSync8(goal.path, "utf8"), nowIso()));
17638
16559
  goalProbe(`continued session=${sessionID} turn=${goal.doc.turnsUsed + 1}/${goal.doc.maxTurns}`);
17639
16560
  } catch (err) {
17640
16561
  const n = (transportFails.get(sessionID) ?? 0) + 1;
@@ -17665,7 +16586,6 @@ var server = async (input, options) => {
17665
16586
  hostWorktree = effectiveWorktree(input.worktree, input.directory) || input.directory || "";
17666
16587
  const client = input.client;
17667
16588
  jobClient = input.client;
17668
- dispatchClient = input.client;
17669
16589
  const jobsOpts = options?.jobs;
17670
16590
  if (jobsOpts?.mode === "auto" || jobsOpts?.mode === "forge" || jobsOpts?.mode === "native")
17671
16591
  jobsMode = jobsOpts.mode;
@@ -17683,18 +16603,6 @@ var server = async (input, options) => {
17683
16603
  if (wdStallRaw !== undefined && wdStallRaw !== wdStall) {
17684
16604
  wdFallbacks.push(`watchdog.stallMs ${JSON.stringify(String(wdStallRaw))} adjusted to ${wdStall} (floor/default applied)`);
17685
16605
  }
17686
- const dpOpts = options?.dispatch;
17687
- dispatchDisabled = dpOpts?.disable === true;
17688
- if (Number.isInteger(dpOpts?.maxConcurrent) && dpOpts.maxConcurrent >= 1 && dpOpts.maxConcurrent <= 16) {
17689
- dispatchMaxConcurrent = dpOpts.maxConcurrent;
17690
- } else if (dpOpts?.maxConcurrent !== undefined) {
17691
- dispatchMaxConcurrent = 4;
17692
- }
17693
- if (Number.isInteger(dpOpts?.timeoutMs) && dpOpts.timeoutMs >= 1000 && dpOpts.timeoutMs <= 600000) {
17694
- dispatchTimeoutMs = dpOpts.timeoutMs;
17695
- } else if (dpOpts?.timeoutMs !== undefined) {
17696
- dispatchTimeoutMs = 600000;
17697
- }
17698
16606
  const watchdogLedger = createFileLedger(join5(watchdogLogDir(), "log.jsonl"));
17699
16607
  const rawLocator = createLocator();
17700
16608
  const probing = () => process.env.FORGE_WATCHDOG_PROBE === "1";
@@ -17750,7 +16658,6 @@ var server = async (input, options) => {
17750
16658
  exitCleanup?.trigger("dispose");
17751
16659
  jobManager.disposeAll();
17752
16660
  watchdog.dispose();
17753
- activeDispatchEngine?.dispose();
17754
16661
  sessions.clear();
17755
16662
  },
17756
16663
  config: async (cfg) => {
@@ -17784,10 +16691,6 @@ var server = async (input, options) => {
17784
16691
  template: PLAN_COMMAND_TEMPLATE,
17785
16692
  description: "forge plan harness: no argument lists in-progress plans; resume continues the latest; discard abandons it; a goal enters planning discipline"
17786
16693
  };
17787
- cfg.command["crew"] ??= {
17788
- template: CREW_COMMAND_TEMPLATE,
17789
- 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"
17790
- };
17791
16694
  cfg.command["goal"] ??= {
17792
16695
  template: GOAL_COMMAND_TEMPLATE,
17793
16696
  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)"
@@ -17798,75 +16701,47 @@ var server = async (input, options) => {
17798
16701
  if (permSection[gateKey] !== "deny")
17799
16702
  permSection[gateKey] = "ask";
17800
16703
  }
17801
- if (!dispatchDisabled) {
17802
- const snapshot = process.env.FORGE_TEST_NO_DISPATCH_FETCH === "1" ? { catalog: { providers: {} }, degraded: true, source: "test-off" } : await loadModelsDevSnapshot();
17803
- const dispatchOpts = options?.dispatch ?? {};
17804
- const legacyInline = (dispatchOpts.roster?.length ?? 0) > 0 || dispatchOpts.tiers !== undefined;
17805
- if (!forgeLoader || forgeLoaderDir !== hostWorktree) {
17806
- forgeLoader = createForgeConfigLoader({
17807
- projectDir: hostWorktree,
17808
- homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir()
17809
- });
17810
- forgeLoaderDir = hostWorktree;
17811
- }
17812
- const loaded = forgeLoader.load();
17813
- forgeConfigState = loaded;
17814
- for (const finding of loaded.findings) {
17815
- dispatchLedger.append({ ts: new Date().toISOString(), event: "validation", level: finding.level, code: finding.code, note: finding.message });
17816
- }
17817
- for (const [agentId, def] of Object.entries(loaded.agents)) {
17818
- const id = `forge-${agentId}`;
17819
- if (agentSection[id] !== undefined)
17820
- continue;
17821
- agentSection[id] = forgeAgentDef(agentId, def);
17822
- }
17823
- const detected = configuredIdentitiesOf(cfg, snapshot.catalog);
17824
- lastDetectedIdentities = detected;
17825
- const built = buildDispatchConfig({
17826
- configuredIdentities: detected,
17827
- catalog: snapshot.catalog,
17828
- userRoster: legacyInline ? dispatchOpts.roster ?? [] : [],
17829
- userTiers: legacyInline ? dispatchOpts.tiers : undefined
16704
+ if (!forgeLoader || forgeLoaderDir !== hostWorktree) {
16705
+ forgeLoader = createForgeConfigLoader({
16706
+ projectDir: hostWorktree,
16707
+ homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir()
17830
16708
  });
17831
- dispatchBase = { cfg: built, catalog: snapshot.catalog, findings: built.findings, degraded: snapshot.degraded };
17832
- for (const finding of built.findings) {
17833
- dispatchLedger.append({ ts: new Date().toISOString(), event: "validation", level: finding.level, code: finding.code, note: finding.message });
17834
- }
17835
- if (snapshot.degraded) {
17836
- dispatchLedger.append({ ts: new Date().toISOString(), event: "models-dev-degraded", note: "models.dev snapshot unavailable — cost reporting degrades to null" });
17837
- }
17838
- if (legacyInline) {
17839
- for (const [tierId, tier] of Object.entries(built.tiers)) {
17840
- const id = `forge-${tierId}`;
17841
- if (agentSection[id] !== undefined)
17842
- continue;
17843
- agentSection[id] = tierAgentDef(tierId, tier);
17844
- }
16709
+ forgeLoaderDir = hostWorktree;
16710
+ }
16711
+ const loaded = forgeLoader.load();
16712
+ for (const finding of loaded.findings) {
16713
+ console.warn(`[forge:config] ${finding.level}: ${finding.message}`);
16714
+ }
16715
+ materializedSnapshot = {};
16716
+ for (const [agentId, def] of Object.entries(loaded.agents)) {
16717
+ const id = `forge-${agentId}`;
16718
+ if (agentSection[id] !== undefined)
16719
+ continue;
16720
+ const entry = forgeAgentDef(agentId, def);
16721
+ agentSection[id] = entry;
16722
+ materializedSnapshot[id] = entry;
16723
+ }
16724
+ if (process.env.FORGE_TEST_NO_DISPATCH_FETCH === "1") {
16725
+ modelsDevCatalog = null;
16726
+ } else {
16727
+ try {
16728
+ const snapshot = await loadModelsDevSnapshot();
16729
+ modelsDevCatalog = snapshot.catalog;
16730
+ } catch {
16731
+ modelsDevCatalog = null;
17845
16732
  }
17846
- const roster = built;
17847
- activeDispatchRegistry = activeDispatchRegistry ?? createDispatchRegistry();
17848
- activeDispatchEngine?.dispose();
17849
- activeDispatchEngine = createDispatchEngine({
17850
- serverUrl: String(input.serverUrl ?? "").replace(/\/$/, ""),
17851
- workspace: hostWorktree,
17852
- fetcher: globalThis.fetch,
17853
- now: Date.now,
17854
- sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
17855
- cfg: roster,
17856
- catalog: snapshot.catalog,
17857
- available: (identity) => {
17858
- return roster.roster.some((e) => e.model === identity) || lastDetectedIdentities.includes(identity);
17859
- },
17860
- agents: () => forgeLoader?.load().agents ?? {},
17861
- legacy: legacyInline,
17862
- sink: (entry) => dispatchLedger.append(entry),
17863
- registry: activeDispatchRegistry,
17864
- onTerminal: (parentSessionID) => scheduleDispatchWake(parentSessionID, false),
17865
- onDepth: queueDispatchDepth,
17866
- providerFamily: dispatchProviderFamily,
17867
- maxConcurrent: dispatchMaxConcurrent,
17868
- timeoutMs: dispatchTimeoutMs
17869
- });
16733
+ }
16734
+ cfg.command ??= {};
16735
+ const existingCrew = cfg.command["crew"];
16736
+ if (!existingCrew || String(existingCrew.template ?? "").startsWith("(forge crew")) {
16737
+ const agentIds = Object.keys(loaded.agents);
16738
+ const template = agentIds.length > 0 ? crewOrchestrationTemplate(agentIds) : CREW_INIT_TEMPLATE.replaceAll("{paths}", ` - ${forgeConfigPaths({ projectDir: forgeLoaderDir ?? undefined, homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() }).project}
16739
+ - ${forgeConfigPaths({ projectDir: forgeLoaderDir ?? undefined, homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() }).global}`).replaceAll("{template}", JSON.stringify({ agents: { research: { model: "provider/model", thoughtLevel: "low" } } }, null, 2).split(`
16740
+ `).map((l) => " " + l).join(`
16741
+ `));
16742
+ const description = agentIds.length > 0 ? "forge crew orchestration: register a declared subtask plan, execute it in waves of parallel native task calls, close with an evidence-checked report" : "forge crew orchestration — NOT INITIALIZED: no forge subagents configured; invoking it shows the one-time setup guidance";
16743
+ cfg.command["crew"] = { template, description };
16744
+ crewCommandSnapshot = { template, description };
17870
16745
  }
17871
16746
  },
17872
16747
  get tool() {
@@ -17897,32 +16772,52 @@ var server = async (input, options) => {
17897
16772
  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.`);
17898
16773
  }
17899
16774
  }
17900
- if (input2.tool === "forge_dispatch") {
17901
- const state = stateForBan(input2.sessionID);
17902
- const active = state ? resolveActivePlan(state) : null;
17903
- if (active && active.doc.status === "draft") {
17904
- 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.");
17905
- }
17906
- }
17907
16775
  },
17908
16776
  "chat.params": async (input2, output) => {
17909
- const entry = dispatchDepths.get(input2.sessionID);
17910
- if (!entry || entry.kind === "not-injected")
16777
+ if (forgeDisabled)
16778
+ return;
16779
+ const raw = input2;
16780
+ const sessionID = raw.sessionID;
16781
+ if (!sessionID)
16782
+ return;
16783
+ const agentName = typeof raw.agent === "string" ? raw.agent : raw.agent?.name;
16784
+ if (typeof agentName !== "string" || !agentName.startsWith("forge-"))
16785
+ return;
16786
+ const frozen = sessionDepths.get(sessionID);
16787
+ if (frozen) {
16788
+ applyDepthTranslation(output.options ?? (output.options = {}), frozen.translation);
16789
+ return;
16790
+ }
16791
+ const agentId = agentName.slice("forge-".length);
16792
+ const def = forgeLoader?.load().agents[agentId];
16793
+ if (!def?.thoughtLevel)
16794
+ return;
16795
+ const providerID = typeof raw.model?.providerID === "string" ? raw.model.providerID : "";
16796
+ const modelID = typeof raw.model?.modelID === "string" ? raw.model.modelID : "";
16797
+ const family = dispatchProviderFamily(providerID);
16798
+ if (!family)
17911
16799
  return;
17912
- applyDepthTranslation(output.options ?? (output.options = {}), entry);
16800
+ const { translation, findingMessage } = resolvePinnedDepth(def.thoughtLevel, family, nativeLadder(modelsDevCatalog, `${providerID}/${modelID}`));
16801
+ if (!translation) {
16802
+ if (findingMessage && !depthFindingNotes.has(agentId)) {
16803
+ depthFindingNotes.set(agentId, findingMessage);
16804
+ console.warn(`[forge:depth] agent forge-${agentId}: ${findingMessage}`);
16805
+ }
16806
+ return;
16807
+ }
16808
+ sessionDepths.set(sessionID, { translation });
16809
+ applyDepthTranslation(output.options ?? (output.options = {}), translation);
17913
16810
  },
17914
- __forgeDispatchTest: {
17915
- queueDepth: queueDispatchDepth,
17916
- depthState: (sessionID) => dispatchDepths.get(sessionID) ?? null,
17917
- composePrompt: composeWorkerPrompt,
17918
- registry: () => activeDispatchRegistry,
16811
+ __forgeSubagentsTest: {
17919
16812
  crews: () => crews,
17920
- dispatchLogDir: () => dispatchLogDir,
17921
- dispatchBriefText,
17922
- setEngine: (engine) => {
17923
- activeDispatchEngine?.dispose();
17924
- activeDispatchEngine = engine;
17925
- }
16813
+ depthState: (sessionID) => sessionDepths.get(sessionID)?.translation ?? null,
16814
+ depthFinding: (agentId) => depthFindingNotes.get(agentId) ?? null,
16815
+ catalog: () => modelsDevCatalog,
16816
+ setCatalog: (c) => {
16817
+ modelsDevCatalog = c;
16818
+ },
16819
+ crewCommand: () => crewCommandSnapshot,
16820
+ materialized: () => materializedSnapshot
17926
16821
  },
17927
16822
  "permission.ask": async (input2, output) => {
17928
16823
  const meta = input2.metadata ?? {};
@@ -17961,13 +16856,13 @@ var server = async (input, options) => {
17961
16856
  goalProbe(`idle event session=${sessionID}`);
17962
16857
  scheduleIdleContinuation(client, sessionID);
17963
16858
  deliverJobWakes(sessionID);
17964
- scheduleDispatchWake(sessionID, true);
17965
16859
  }
17966
16860
  }
17967
16861
  if (event.type === "session.deleted") {
17968
16862
  const info = event.properties.info;
17969
16863
  if (typeof info?.id === "string" && info.id) {
17970
16864
  jobManager.onSessionEnd(info.id);
16865
+ sessionDepths.delete(info.id);
17971
16866
  }
17972
16867
  }
17973
16868
  },
@@ -18083,13 +16978,30 @@ async function v2Setup(ctx) {
18083
16978
  await ctx.agent.transform(async (draft) => {
18084
16979
  if (typeof draft.get !== "function" || typeof draft.update !== "function")
18085
16980
  return;
18086
- if (draft.get(FORGE_AGENT) !== undefined)
18087
- return;
18088
- draft.update(FORGE_AGENT, (agent) => {
18089
- agent.description = "forge — the single general-purpose coding agent: takes implementation tasks directly; planning goes through /plan into the plan harness (plans land in .opencode/plan/, with approve/close confirmation gates and tick discipline enforced by tools and the permission layer).";
18090
- agent.system = FORGE_PROMPT;
18091
- agent.mode = "primary";
18092
- });
16981
+ if (draft.get(FORGE_AGENT) === undefined) {
16982
+ draft.update(FORGE_AGENT, (agent) => {
16983
+ agent.description = "forge — the single general-purpose coding agent: takes implementation tasks directly; planning goes through /plan into the plan harness (plans land in .opencode/plan/, with approve/close confirmation gates and tick discipline enforced by tools and the permission layer).";
16984
+ agent.system = FORGE_PROMPT;
16985
+ agent.mode = "primary";
16986
+ });
16987
+ }
16988
+ try {
16989
+ const loader = createForgeConfigLoader({ projectDir: process.cwd(), homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() });
16990
+ const loaded = loader.load();
16991
+ for (const [agentId, def] of Object.entries(loaded.agents)) {
16992
+ const id = `forge-${agentId}`;
16993
+ if (draft.get(id) !== undefined)
16994
+ continue;
16995
+ const v1def = forgeAgentDef(agentId, def);
16996
+ draft.update(id, (agent) => {
16997
+ agent.description = v1def.description;
16998
+ agent.system = v1def.prompt;
16999
+ agent.mode = v1def.mode;
17000
+ agent.model = v1def.model;
17001
+ agent.permission = v1def.permission;
17002
+ });
17003
+ }
17004
+ } catch {}
18093
17005
  });
18094
17006
  }
18095
17007
  }