@sorenllm/opencode-forge 0.3.4 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +185 -166
- package/dist/index.js +706 -1717
- 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
|
|
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/
|
|
14727
|
-
|
|
14728
|
-
|
|
14729
|
-
|
|
14730
|
-
|
|
14731
|
-
|
|
14732
|
-
|
|
14733
|
-
|
|
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,1077 +14746,243 @@ function nativeLadder(catalog, identity) {
|
|
|
14741
14746
|
const options = entry.reasoningOptions;
|
|
14742
14747
|
return Array.isArray(options) ? [...options] : null;
|
|
14743
14748
|
}
|
|
14744
|
-
function
|
|
14745
|
-
|
|
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
|
|
14763
|
-
const
|
|
14764
|
-
const
|
|
14765
|
-
|
|
14766
|
-
|
|
14767
|
-
|
|
14768
|
-
|
|
14769
|
-
|
|
14770
|
-
|
|
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
|
-
|
|
14845
|
-
if (tier.defaultDepth === undefined)
|
|
14846
|
-
continue;
|
|
14847
|
-
const served = roster.some((e) => e.profiles.includes(id) && e.expose.includes(tier.defaultDepth));
|
|
14848
|
-
if (!served) {
|
|
14849
|
-
findings.push({
|
|
14850
|
-
level: "warn",
|
|
14851
|
-
code: "dead-default-depth",
|
|
14852
|
-
message: `tier "${id}" defaultDepth "${tier.defaultDepth}" is exposed by no candidate; tier degrades to depth-required (dispatches must pass depth explicitly)`
|
|
14853
|
-
});
|
|
14854
|
-
tiers[id] = { ...tier, defaultDepth: undefined, depthRequired: true };
|
|
14855
|
-
}
|
|
14856
|
-
}
|
|
14857
|
-
return { roster, tiers, findings };
|
|
14858
|
-
}
|
|
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 } };
|
|
14870
|
-
}
|
|
14871
|
-
return { stablePolls, verdict: "waiting", last: { ...cur } };
|
|
14872
|
-
}
|
|
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;
|
|
14883
|
-
}
|
|
14884
|
-
function computeCost(tokens, price) {
|
|
14885
|
-
if (!price || typeof price.input !== "number" || typeof price.output !== "number") {
|
|
14886
|
-
return { costUsd: null, note: "price unavailable for this identity (no snapshot price)" };
|
|
14887
|
-
}
|
|
14888
|
-
if (tokens.cache.read > 0 && typeof price.cacheRead !== "number") {
|
|
14889
|
-
return { costUsd: null, note: "price unavailable: cache-read price missing from snapshot" };
|
|
14890
|
-
}
|
|
14891
|
-
if (tokens.cache.write > 0 && typeof price.cacheWrite !== "number") {
|
|
14892
|
-
return { costUsd: null, note: "price unavailable: cache-write price missing from snapshot" };
|
|
14893
|
-
}
|
|
14894
|
-
const usd = (tokens.input * price.input + (tokens.output + tokens.reasoning) * price.output + tokens.cache.read * (price.cacheRead ?? 0) + tokens.cache.write * (price.cacheWrite ?? 0)) / 1e6;
|
|
14895
|
-
return { costUsd: usd };
|
|
14896
|
-
}
|
|
14897
|
-
|
|
14898
|
-
// src/dispatch-prompt.ts
|
|
14899
|
-
var COMMON_MANDATES = [
|
|
14900
|
-
"Mandates (non-negotiable):",
|
|
14901
|
-
"- Use workspace-relative paths ONLY. Never write to or read from absolute paths outside this workspace; the workspace root is where you were started, not the filesystem root.",
|
|
14902
|
-
"- If any tool call is refused or denied, report the refusal VERBATIM in your final answer and stop that line of work. Never improvise a workaround around a permission denial.",
|
|
14903
|
-
"- End with conclusions backed by evidence references (file:line, command output, or the exact file/content you produced)."
|
|
14904
|
-
].join(`
|
|
14905
|
-
`);
|
|
14906
|
-
var READONLY_DISCIPLINE = `You are a readonly {agent} worker: you gather, read, run read-only checks, and REPORT. Your restrictions are the design, not obstacles — do not attempt to work around them; report what you found instead.`;
|
|
14907
|
-
var WRITE_DISCIPLINE = `You are an {agent} worker executing one scoped task. Do exactly the task, nothing else: no refactoring beyond scope, no unrelated files, no starting side quests.`;
|
|
14908
|
-
function composeWorkerPrompt(input) {
|
|
14909
|
-
const discipline = input.shape === "readonly" ? READONLY_DISCIPLINE.replaceAll("{agent}", input.agent) : WRITE_DISCIPLINE.replaceAll("{agent}", input.agent);
|
|
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(`
|
|
14922
|
-
`);
|
|
14764
|
+
return out;
|
|
14923
14765
|
}
|
|
14924
|
-
|
|
14925
|
-
|
|
14926
|
-
|
|
14927
|
-
|
|
14928
|
-
|
|
14929
|
-
|
|
14930
|
-
|
|
14931
|
-
|
|
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))
|
|
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")
|
|
14937
14774
|
continue;
|
|
14938
|
-
|
|
14939
|
-
|
|
14940
|
-
|
|
14941
|
-
|
|
14942
|
-
|
|
14943
|
-
|
|
14944
|
-
|
|
14945
|
-
|
|
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 };
|
|
15039
|
-
}
|
|
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
|
-
};
|
|
15088
|
-
}
|
|
15089
|
-
return { ok: true, model: def.model, depth, def };
|
|
15090
|
-
}
|
|
15091
|
-
|
|
15092
|
-
// src/dispatch-depth.ts
|
|
15093
|
-
var META_DEPTHS = ["none", "low", "medium", "high", "max"];
|
|
15094
|
-
var BUDGET_TIERS = {
|
|
15095
|
-
low: 8192,
|
|
15096
|
-
medium: 16384,
|
|
15097
|
-
high: 24576,
|
|
15098
|
-
max: 32768
|
|
15099
|
-
};
|
|
15100
|
-
function noMapping(depth, family, nativeVocabulary, metaAvailable) {
|
|
15101
|
-
return {
|
|
15102
|
-
kind: "no-mapping",
|
|
15103
|
-
family,
|
|
15104
|
-
message: `depth "${depth}" has no native counterpart on this model — the plugin never interpolates. Native levels: ${nativeVocabulary.join(", ") || "(none named)"}. Meta words this model accepts: ${metaAvailable.join(", ") || "(none)"}.`,
|
|
15105
|
-
nativeVocabulary,
|
|
15106
|
-
metaAvailable
|
|
15107
|
-
};
|
|
15108
|
-
}
|
|
15109
|
-
function translateDepth(depth, family, ladder) {
|
|
15110
|
-
if (family === "unknown") {
|
|
15111
|
-
return { kind: "not-injected", family, disclose: "not injected (unknown provider shape)" };
|
|
15112
|
-
}
|
|
15113
|
-
if (family === "anthropic") {
|
|
15114
|
-
if (depth === "none")
|
|
15115
|
-
return { kind: "off", family, disclose: "canonical none → native thinking:off" };
|
|
15116
|
-
const budget = BUDGET_TIERS[depth];
|
|
15117
|
-
if (budget !== undefined) {
|
|
15118
|
-
return { kind: "tiered", family, budgetTokens: budget, disclose: `canonical ${depth} → native thinking budget:${budget}` };
|
|
15119
|
-
}
|
|
15120
|
-
return noMapping(depth, family, [], [...META_DEPTHS]);
|
|
15121
|
-
}
|
|
15122
|
-
if (family === "zai") {
|
|
15123
|
-
if (depth === "none")
|
|
15124
|
-
return { kind: "off", family, disclose: "canonical none → native thinking:off" };
|
|
15125
|
-
if (META_DEPTHS.includes(depth)) {
|
|
15126
|
-
return { kind: "toggle-on", family, disclose: `canonical ${depth} → native thinking:on (this provider shape takes a toggle, not a level)` };
|
|
15127
|
-
}
|
|
15128
|
-
return noMapping(depth, family, ["thinking on/off"], [...META_DEPTHS]);
|
|
15129
|
-
}
|
|
15130
|
-
const native = ladder ?? null;
|
|
15131
|
-
if (native !== null && native.includes(depth)) {
|
|
15132
|
-
return { kind: "verbatim", family: "openai", word: depth, disclose: META_DEPTHS.includes(depth) ? "verbatim" : `verbatim (native word "${depth}")` };
|
|
15133
|
-
}
|
|
15134
|
-
if (native !== null && native.length > 0 && META_DEPTHS.includes(depth)) {
|
|
15135
|
-
return noMapping(depth, family, native, META_DEPTHS.filter((w) => native.includes(w)));
|
|
15136
|
-
}
|
|
15137
|
-
return { kind: "verbatim", family: "openai", word: depth, disclose: `verbatim (unverified — ${native === null ? "catalog does not know this model" : `catalog ladder does not list "${depth}"`}; the provider judges)` };
|
|
15138
|
-
}
|
|
15139
|
-
function applyDepthTranslation(options, t) {
|
|
15140
|
-
if (t.kind === "verbatim") {
|
|
15141
|
-
options.reasoningEffort = t.word;
|
|
15142
|
-
} else if (t.kind === "tiered") {
|
|
15143
|
-
options.thinking = { type: "enabled", budget_tokens: t.budgetTokens };
|
|
15144
|
-
} else if (t.kind === "off") {
|
|
15145
|
-
options.thinking = { type: "disabled" };
|
|
15146
|
-
} else if (t.kind === "toggle-on") {
|
|
15147
|
-
options.thinking = { type: "enabled" };
|
|
15148
|
-
}
|
|
15149
|
-
}
|
|
15150
|
-
|
|
15151
|
-
// src/dispatch-tiers.ts
|
|
15152
|
-
var MUTATING_TOOLS = ["write", "edit", "bash"];
|
|
15153
|
-
var TIER_PROMPTS = {
|
|
15154
|
-
scout: "You are forge-scout, a readonly reconnaissance worker. Search, read, and run read-only commands; report findings with file:line evidence. You never modify anything — write tools are denied by design.",
|
|
15155
|
-
build: "You are forge-build, an implementation worker. Execute the dispatched task exactly: minimal, focused changes with tests where the task implies them. Do not expand scope.",
|
|
15156
|
-
review: "You are forge-review, a readonly review worker. Read the diff or files under review, run read-only checks/tests, and report findings as a prioritized list with file:line evidence and a single-line verdict. Write tools are denied by design.",
|
|
15157
|
-
quick: "You are forge-quick, a mechanical small-change worker. Make the smallest correct edit the task names (rename, constant, one-liner fix). No refactoring, no drive-by improvements."
|
|
15158
|
-
};
|
|
15159
|
-
function tierAgentPrompt(tierId) {
|
|
15160
|
-
return TIER_PROMPTS[tierId] ?? `You are forge-${tierId}, a dispatched worker. Execute the dispatched task exactly as scoped.`;
|
|
15161
|
-
}
|
|
15162
|
-
var ROLE_PROMPTS = {
|
|
15163
|
-
research: "You are a research worker: search, read, and run read-only checks to answer the dispatched question. Report findings as a compact brief with sources and file:line evidence; state plainly what could not be verified.",
|
|
15164
|
-
review: "You are a review worker: read the diff or files under review, run read-only checks/tests, and report findings as a prioritized list, each with file:line evidence and a concrete fix suggestion. End with a single-line verdict."
|
|
15165
|
-
};
|
|
15166
|
-
function rolePromptFor(agentId, def) {
|
|
15167
|
-
if (def?.prompt && def.prompt.trim().length > 0)
|
|
15168
|
-
return def.prompt;
|
|
15169
|
-
return ROLE_PROMPTS[agentId] ?? `You are forge-${agentId}, a dispatched worker. Execute the dispatched task exactly as scoped.`;
|
|
15170
|
-
}
|
|
15171
|
-
function forgeAgentDef(agentId, def) {
|
|
15172
|
-
const permission = { task: "deny" };
|
|
15173
|
-
const shape = def.shape ?? "readonly";
|
|
15174
|
-
if (shape === "readonly") {
|
|
15175
|
-
for (const t of MUTATING_TOOLS)
|
|
15176
|
-
permission[t] = "deny";
|
|
15177
|
-
}
|
|
15178
|
-
Object.assign(permission, def.permission ?? {});
|
|
15179
|
-
permission.task = "deny";
|
|
15180
|
-
const shapeLine = shape === "readonly" ? "Readonly agent: mutating tools are denied by design — report findings, never work around restrictions." : "Write agent: execute the scoped task with minimal, focused changes.";
|
|
15181
|
-
return {
|
|
15182
|
-
description: `forge dispatch agent "${agentId}" (${shape}) — spawned by forge_dispatch, not user-facing.`,
|
|
15183
|
-
mode: "subagent",
|
|
15184
|
-
hidden: true,
|
|
15185
|
-
prompt: `${rolePromptFor(agentId, def)}
|
|
15186
|
-
|
|
15187
|
-
${shapeLine}`,
|
|
15188
|
-
permission
|
|
15189
|
-
};
|
|
15190
|
-
}
|
|
15191
|
-
function tierAgentDef(tierId, tier) {
|
|
15192
|
-
const permission = { task: "deny" };
|
|
15193
|
-
if (tier.shape === "readonly") {
|
|
15194
|
-
for (const t of MUTATING_TOOLS)
|
|
15195
|
-
permission[t] = "deny";
|
|
15196
|
-
}
|
|
15197
|
-
const shapeLine = tier.shape === "readonly" ? "Readonly tier: mutating tools are denied by design — report findings, never work around restrictions." : "Write tier: execute the scoped task with minimal, focused changes.";
|
|
15198
|
-
const defaultLine = tier.defaultDepth ? ` Default reasoning depth: ${tier.defaultDepth}.` : "";
|
|
15199
|
-
return {
|
|
15200
|
-
description: `forge dispatch tier "${tierId}" (${tier.shape}) — spawned by forge_dispatch, not user-facing.${defaultLine}`,
|
|
15201
|
-
mode: "subagent",
|
|
15202
|
-
hidden: true,
|
|
15203
|
-
prompt: `${tierAgentPrompt(tierId)}
|
|
15204
|
-
|
|
15205
|
-
${shapeLine}`,
|
|
15206
|
-
permission
|
|
15207
|
-
};
|
|
15208
|
-
}
|
|
15209
|
-
|
|
15210
|
-
// src/dispatch-engine.ts
|
|
15211
|
-
var DEFAULT_DISPATCH_TIMEOUT_MS = 600000;
|
|
15212
|
-
var MAX_DISPATCH_TIMEOUT_MS = 600000;
|
|
15213
|
-
var DEFAULT_MAX_CONCURRENT = 4;
|
|
15214
|
-
var DEFAULT_POLL_INTERVAL_MS = 2000;
|
|
15215
|
-
|
|
15216
|
-
class DispatchError extends Error {
|
|
15217
|
-
code;
|
|
15218
|
-
constructor(message, code) {
|
|
15219
|
-
super(message);
|
|
15220
|
-
this.name = "DispatchError";
|
|
15221
|
-
this.code = code;
|
|
15222
|
-
}
|
|
15223
|
-
}
|
|
15224
|
-
function textOf(messages) {
|
|
15225
|
-
const assistant = [...messages].reverse().find((m) => (m.info?.role ?? m.role) === "assistant");
|
|
15226
|
-
if (!assistant)
|
|
15227
|
-
return "";
|
|
15228
|
-
return (assistant.parts ?? []).filter((p) => p.type === "text" || typeof p.text === "string").map((p) => p.text ?? "").join(`
|
|
15229
|
-
`).trim();
|
|
15230
|
-
}
|
|
15231
|
-
function tokensOf(messages) {
|
|
15232
|
-
return messages.filter((m) => (m.info?.role ?? m.role) === "assistant" && m.info?.tokens).map((m) => m.info.tokens);
|
|
15233
|
-
}
|
|
15234
|
-
function createDispatchEngine(deps) {
|
|
15235
|
-
const maxConcurrent = deps.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
|
|
15236
|
-
const timeoutMs = Math.min(deps.timeoutMs ?? DEFAULT_DISPATCH_TIMEOUT_MS, MAX_DISPATCH_TIMEOUT_MS);
|
|
15237
|
-
const pollIntervalMs = deps.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
15238
|
-
const inFlight = new Set;
|
|
15239
|
-
let seq = 0;
|
|
15240
|
-
const api2 = async (path, init) => {
|
|
15241
|
-
const res = await deps.fetcher(`${deps.serverUrl}${path}`, {
|
|
15242
|
-
...init,
|
|
15243
|
-
headers: { "content-type": "application/json", ...init?.headers ?? {} }
|
|
15244
|
-
});
|
|
15245
|
-
const text = await res.text();
|
|
15246
|
-
if (!res.ok)
|
|
15247
|
-
throw new DispatchError(`${init?.method ?? "GET"} ${path} -> ${res.status}: ${text.slice(0, 300)}`, "host-error");
|
|
15248
|
-
return text ? JSON.parse(text) : undefined;
|
|
15249
|
-
};
|
|
15250
|
-
const fetchMessages = async (sessionID) => {
|
|
15251
|
-
const list = await api2(`/session/${sessionID}/message`);
|
|
15252
|
-
return Array.isArray(list) ? list : [];
|
|
15253
|
-
};
|
|
15254
|
-
const killControls = new Map;
|
|
15255
|
-
const resolveOrThrow = (profile, depth, exclude) => {
|
|
15256
|
-
const r = resolveDispatch(deps.cfg, { profile, depth, exclude }, deps.available, deps.catalog);
|
|
15257
|
-
if (!r.ok) {
|
|
15258
|
-
deps.sink({ ts: new Date().toISOString(), event: "resolve-error", tier: profile, outcome: r.error.code, note: r.error.message });
|
|
15259
|
-
throw new DispatchError(`${r.error.message}
|
|
15260
|
-
Menu:
|
|
15261
|
-
${r.error.menu.map((m) => `- ${m.identity} expose=[${m.expose.join(", ") || "none"}] ${m.reason}`).join(`
|
|
15262
|
-
`)}
|
|
15263
|
-
Vocabulary: ${r.error.vocabulary.join(", ") || "(none)"}`, r.error.code);
|
|
15264
|
-
}
|
|
15265
|
-
return r;
|
|
15266
|
-
};
|
|
15267
|
-
const resolveForThrow = (req, exclude) => {
|
|
15268
|
-
if (deps.legacy && req.agent in deps.cfg.tiers) {
|
|
15269
|
-
const r = resolveOrThrow(req.agent, req.depth, exclude);
|
|
15270
|
-
return {
|
|
15271
|
-
model: r.identity,
|
|
15272
|
-
depth: r.depth,
|
|
15273
|
-
agentPath: false,
|
|
15274
|
-
shape: deps.cfg.tiers[req.agent]?.shape ?? "readonly",
|
|
15275
|
-
role: tierAgentPrompt(req.agent)
|
|
15276
|
-
};
|
|
15277
|
-
}
|
|
15278
|
-
const a = resolveAgent(deps.agents(), { agent: req.agent, depth: req.depth }, deps.available);
|
|
15279
|
-
if (!a.ok) {
|
|
15280
|
-
deps.sink({ ts: new Date().toISOString(), event: "resolve-error", tier: req.agent, agent: req.agent, outcome: a.error.code, note: a.error.message });
|
|
15281
|
-
throw new DispatchError(a.error.message, a.error.code);
|
|
15282
|
-
}
|
|
15283
|
-
return {
|
|
15284
|
-
model: a.model,
|
|
15285
|
-
depth: a.depth,
|
|
15286
|
-
agentPath: true,
|
|
15287
|
-
shape: a.def?.shape === "write" ? "write" : "readonly",
|
|
15288
|
-
role: rolePromptFor(req.agent, a.def)
|
|
15289
|
-
};
|
|
15290
|
-
};
|
|
15291
|
-
const translationFor = (identity, depth) => {
|
|
15292
|
-
const providerID = identity.slice(0, identity.indexOf("/"));
|
|
15293
|
-
const family = deps.providerFamily(providerID) ?? "unknown";
|
|
15294
|
-
const ladder = nativeLadder(deps.catalog, identity);
|
|
15295
|
-
return translateDepth(depth, family, ladder);
|
|
15296
|
-
};
|
|
15297
|
-
const translationOrThrow = (identity, depth) => {
|
|
15298
|
-
const translation = translationFor(identity, depth);
|
|
15299
|
-
if (translation.kind === "no-mapping") {
|
|
15300
|
-
deps.sink({ ts: new Date().toISOString(), event: "resolve-error", agent: undefined, identity, depth, outcome: "no-native-mapping", note: translation.message });
|
|
15301
|
-
throw new DispatchError(translation.message, "no-native-mapping");
|
|
15302
|
-
}
|
|
15303
|
-
return translation;
|
|
15304
|
-
};
|
|
15305
|
-
async function runAttempt(r, req, parentSessionID, t0, ctl, onChild, dispatchId) {
|
|
15306
|
-
const providerID = r.model.slice(0, r.model.indexOf("/"));
|
|
15307
|
-
const translation = translationOrThrow(r.model, r.depth);
|
|
15308
|
-
let childID = "";
|
|
15309
|
-
const dir = encodeURIComponent(deps.workspace);
|
|
15310
|
-
let created;
|
|
15311
|
-
try {
|
|
15312
|
-
created = await api2(`/session?directory=${dir}`, { method: "POST", body: JSON.stringify(parentSessionID ? { parentID: parentSessionID } : {}) });
|
|
15313
|
-
} catch {
|
|
15314
|
-
created = await api2(`/session?directory=${dir}`, { method: "POST", body: JSON.stringify({}) });
|
|
15315
|
-
}
|
|
15316
|
-
childID = created?.id ?? "";
|
|
15317
|
-
if (!childID)
|
|
15318
|
-
throw new DispatchError("host returned no session id for the child session", "host-error");
|
|
15319
|
-
onChild?.(childID);
|
|
15320
|
-
deps.onDepth(childID, translation);
|
|
15321
|
-
const depthInjected = translation.disclose;
|
|
15322
|
-
let deadlineHit = false;
|
|
15323
|
-
const deadlinePromise = deps.sleep(Math.max(timeoutMs - (deps.now() - t0), 0)).then(() => {
|
|
15324
|
-
deadlineHit = true;
|
|
15325
|
-
});
|
|
15326
|
-
const turnPost = api2(`/session/${childID}/message`, {
|
|
15327
|
-
method: "POST",
|
|
15328
|
-
body: JSON.stringify({
|
|
15329
|
-
model: { providerID, modelID: r.model.slice(r.model.indexOf("/") + 1) },
|
|
15330
|
-
agent: `forge-${req.agent}`,
|
|
15331
|
-
parts: [{ type: "text", text: composeWorkerPrompt({ prompt: req.prompt, agent: req.agent, shape: r.shape, ...r.role ? { role: r.role } : {} }) }]
|
|
15332
|
-
})
|
|
15333
|
-
});
|
|
15334
|
-
turnPost.catch(() => {});
|
|
15335
|
-
try {
|
|
15336
|
-
await Promise.race([turnPost, deadlinePromise]);
|
|
15337
|
-
if (deadlineHit && deps.now() - t0 >= timeoutMs) {
|
|
15338
|
-
const elapsed = deps.now() - t0;
|
|
15339
|
-
deps.sink({
|
|
15340
|
-
ts: new Date().toISOString(),
|
|
15341
|
-
event: "timeout",
|
|
15342
|
-
tier: req.agent,
|
|
15343
|
-
agent: req.agent,
|
|
15344
|
-
identity: r.model,
|
|
15345
|
-
depth: r.depth,
|
|
15346
|
-
sessionID: childID,
|
|
15347
|
-
durationMs: elapsed,
|
|
15348
|
-
...dispatchId !== undefined ? { dispatchId, parentSessionID: parentSessionID ?? "" } : {}
|
|
15349
|
-
});
|
|
15350
|
-
throw new DispatchError(`forge_dispatch timed out after ${elapsed}ms (deadline ${timeoutMs}ms). Partial state: child session ${childID} is left for the host to reclaim; its transcript remains queryable via the host API.`, "timeout");
|
|
15351
|
-
}
|
|
15352
|
-
await turnPost;
|
|
15353
|
-
} catch (e) {
|
|
15354
|
-
if (e instanceof DispatchError)
|
|
15355
|
-
throw e;
|
|
15356
|
-
deps.sink({
|
|
15357
|
-
ts: new Date().toISOString(),
|
|
15358
|
-
event: "transport-interrupted",
|
|
15359
|
-
tier: req.agent,
|
|
15360
|
-
agent: req.agent,
|
|
15361
|
-
identity: r.model,
|
|
15362
|
-
depth: r.depth,
|
|
15363
|
-
sessionID: childID,
|
|
15364
|
-
durationMs: deps.now() - t0,
|
|
15365
|
-
note: e instanceof Error ? e.message : String(e),
|
|
15366
|
-
...dispatchId !== undefined ? { dispatchId, parentSessionID: parentSessionID ?? "" } : {}
|
|
15367
|
-
});
|
|
15368
|
-
}
|
|
15369
|
-
let poll = null;
|
|
15370
|
-
let messages = [];
|
|
15371
|
-
for (;; ) {
|
|
15372
|
-
if (ctl?.fired)
|
|
15373
|
-
throw new KillRequested;
|
|
15374
|
-
const elapsed = deps.now() - t0;
|
|
15375
|
-
if (elapsed >= timeoutMs) {
|
|
15376
|
-
deps.sink({
|
|
15377
|
-
ts: new Date().toISOString(),
|
|
15378
|
-
event: "timeout",
|
|
15379
|
-
tier: req.agent,
|
|
15380
|
-
agent: req.agent,
|
|
15381
|
-
identity: r.model,
|
|
15382
|
-
depth: r.depth,
|
|
15383
|
-
sessionID: childID,
|
|
15384
|
-
durationMs: elapsed,
|
|
15385
|
-
...dispatchId !== undefined ? { dispatchId, parentSessionID: parentSessionID ?? "" } : {}
|
|
15386
|
-
});
|
|
15387
|
-
throw new DispatchError(`forge_dispatch timed out after ${elapsed}ms (deadline ${timeoutMs}ms). Partial state: child session ${childID} is left for the host to reclaim; its transcript remains queryable via the host API.`, "timeout");
|
|
15388
|
-
}
|
|
15389
|
-
await deps.sleep(pollIntervalMs);
|
|
15390
|
-
messages = await (ctl ? Promise.race([fetchMessages(childID), ctl.promise.then(() => [])]) : fetchMessages(childID));
|
|
15391
|
-
if (ctl?.fired)
|
|
15392
|
-
throw new KillRequested;
|
|
15393
|
-
poll = completionVerdict(poll, { count: messages.length, text: textOf(messages) });
|
|
15394
|
-
if (poll.verdict === "complete" || poll.verdict === "empty")
|
|
15395
|
-
break;
|
|
15396
|
-
}
|
|
15397
|
-
const durationMs = deps.now() - t0;
|
|
15398
|
-
if (poll.verdict === "empty") {
|
|
15399
|
-
deps.sink({
|
|
15400
|
-
ts: new Date().toISOString(),
|
|
15401
|
-
event: "empty-response",
|
|
15402
|
-
tier: req.agent,
|
|
15403
|
-
agent: req.agent,
|
|
15404
|
-
identity: r.model,
|
|
15405
|
-
depth: r.depth,
|
|
15406
|
-
sessionID: childID,
|
|
15407
|
-
durationMs,
|
|
15408
|
-
...dispatchId !== undefined ? { dispatchId, parentSessionID: parentSessionID ?? "" } : {}
|
|
15409
|
-
});
|
|
15410
|
-
throw new DispatchError(`child session ${childID} returned an empty response (0-token assistant message). This is a known quirk of keyless endpoints combined with restricted tiers — see the README dispatch notes. The dispatch is NOT counted as success.`, "empty-response");
|
|
15411
|
-
}
|
|
15412
|
-
const tokens = sumTokens(tokensOf(messages));
|
|
15413
|
-
const price = priceFor(deps.catalog, r.model);
|
|
15414
|
-
const cost = computeCost(tokens, price?.spec ?? null);
|
|
15415
|
-
const result = {
|
|
15416
|
-
agent: req.agent,
|
|
15417
|
-
requested: { agent: req.agent, depth: req.depth },
|
|
15418
|
-
actual: { model: r.model, depth: r.depth },
|
|
15419
|
-
sessionID: childID,
|
|
15420
|
-
durationMs,
|
|
15421
|
-
tokens,
|
|
15422
|
-
costUsd: cost.costUsd,
|
|
15423
|
-
...cost.note ? { costNote: cost.note } : {},
|
|
15424
|
-
...price?.label ? { priceSnapshot: price.label } : {},
|
|
15425
|
-
text: textOf(messages),
|
|
15426
|
-
depthTranslation: depthInjected
|
|
15427
|
-
};
|
|
15428
|
-
deps.sink({
|
|
15429
|
-
ts: new Date().toISOString(),
|
|
15430
|
-
event: "completed",
|
|
15431
|
-
tier: req.agent,
|
|
15432
|
-
agent: req.agent,
|
|
15433
|
-
identity: r.model,
|
|
15434
|
-
depth: r.depth,
|
|
15435
|
-
sessionID: childID,
|
|
15436
|
-
outcome: "completed",
|
|
15437
|
-
tokens,
|
|
15438
|
-
costUsd: cost.costUsd,
|
|
15439
|
-
durationMs,
|
|
15440
|
-
...dispatchId !== undefined ? { dispatchId, parentSessionID: parentSessionID ?? "" } : {}
|
|
15441
|
-
});
|
|
15442
|
-
return result;
|
|
15443
|
-
}
|
|
15444
|
-
function bgTerminal(reg, dispatchId, identity, req, t0, e) {
|
|
15445
|
-
let entrySessionID = "";
|
|
15446
|
-
let parentSessionID = "";
|
|
15447
|
-
try {
|
|
15448
|
-
const entry = reg.get(dispatchId);
|
|
15449
|
-
entrySessionID = entry.sessionID;
|
|
15450
|
-
parentSessionID = entry.parentSessionID;
|
|
15451
|
-
} catch {}
|
|
15452
|
-
const durationMs = deps.now() - t0;
|
|
15453
|
-
const report = {
|
|
15454
|
-
agent: req.agent,
|
|
15455
|
-
requested: { agent: req.agent, depth: req.depth },
|
|
15456
|
-
actual: { model: identity, depth: null },
|
|
15457
|
-
sessionID: entrySessionID,
|
|
15458
|
-
outcome: "error",
|
|
15459
|
-
durationMs,
|
|
15460
|
-
error: e instanceof Error ? e.message : String(e)
|
|
15461
|
-
};
|
|
15462
|
-
if (e instanceof DispatchError && e.code === "timeout") {
|
|
15463
|
-
report.outcome = "timeout";
|
|
15464
|
-
reg.markTimeout(dispatchId, report);
|
|
15465
|
-
deps.onTerminal?.(parentSessionID);
|
|
15466
|
-
return;
|
|
15467
|
-
}
|
|
15468
|
-
deps.sink({
|
|
15469
|
-
ts: new Date().toISOString(),
|
|
15470
|
-
event: "error",
|
|
15471
|
-
tier: req.agent,
|
|
15472
|
-
agent: req.agent,
|
|
15473
|
-
identity,
|
|
15474
|
-
outcome: "error",
|
|
15475
|
-
sessionID: entrySessionID,
|
|
15476
|
-
durationMs,
|
|
15477
|
-
note: report.error,
|
|
15478
|
-
dispatchId,
|
|
15479
|
-
parentSessionID
|
|
15480
|
-
});
|
|
15481
|
-
reg.markError(dispatchId, report);
|
|
15482
|
-
deps.onTerminal?.(parentSessionID);
|
|
15483
|
-
}
|
|
15484
|
-
async function dispatch(req, parentSessionID, opts) {
|
|
15485
|
-
const t0 = deps.now();
|
|
15486
|
-
if (inFlight.size >= maxConcurrent) {
|
|
15487
|
-
deps.sink({ ts: new Date().toISOString(), event: "refused-cap", tier: req.agent, agent: req.agent, note: `${inFlight.size} in flight` });
|
|
15488
|
-
throw new DispatchError(`forge_dispatch refused: ${inFlight.size} dispatches already in flight (cap ${maxConcurrent}). Wait for a completion and retry.`, "cap-refused");
|
|
15489
|
-
}
|
|
15490
|
-
const bg = opts?.background === true;
|
|
15491
|
-
if (bg && !deps.registry)
|
|
15492
|
-
throw new DispatchError("background dispatch requires the dispatch registry (internal error)", "host-error");
|
|
15493
|
-
const first = resolveForThrow(req, undefined);
|
|
15494
|
-
translationOrThrow(first.model, first.depth);
|
|
15495
|
-
if (bg) {
|
|
15496
|
-
const reg = deps.registry;
|
|
15497
|
-
const { dispatchId } = reg.submit({ sessionID: "", identity: first.model, depth: first.depth, tier: req.agent, parentSessionID: parentSessionID ?? "" });
|
|
15498
|
-
let fire = () => {};
|
|
15499
|
-
const promise2 = new Promise((res) => {
|
|
15500
|
-
fire = res;
|
|
15501
|
-
});
|
|
15502
|
-
const ctl = { fired: false, promise: promise2, fire: () => {
|
|
15503
|
-
ctl.fired = true;
|
|
15504
|
-
fire();
|
|
15505
|
-
} };
|
|
15506
|
-
killControls.set(dispatchId, ctl);
|
|
15507
|
-
const slot = `d${++seq}`;
|
|
15508
|
-
inFlight.add(slot);
|
|
15509
|
-
(async () => {
|
|
15510
|
-
try {
|
|
15511
|
-
if (reg.get(dispatchId).state === "killed")
|
|
15512
|
-
return;
|
|
15513
|
-
reg.markRunning(dispatchId);
|
|
15514
|
-
let current = first;
|
|
15515
|
-
let exclude2 = undefined;
|
|
15516
|
-
for (let attemptNo = 0;; attemptNo++) {
|
|
15517
|
-
try {
|
|
15518
|
-
const result = await runAttempt(current, req, parentSessionID, t0, ctl, (sid) => {
|
|
15519
|
-
try {
|
|
15520
|
-
reg.get(dispatchId).sessionID = sid;
|
|
15521
|
-
} catch {}
|
|
15522
|
-
}, dispatchId);
|
|
15523
|
-
if (reg.get(dispatchId).state === "killed") {
|
|
15524
|
-
reg.noteLateCompletion(dispatchId, result);
|
|
15525
|
-
deps.sink({ ts: new Date().toISOString(), event: "kill-late-completion", tier: req.agent, agent: req.agent, identity: current.model, sessionID: result.sessionID, outcome: "kill-late-completion" });
|
|
15526
|
-
} else {
|
|
15527
|
-
reg.markCompleted(dispatchId, result);
|
|
15528
|
-
deps.onTerminal?.(reg.get(dispatchId).parentSessionID);
|
|
15529
|
-
}
|
|
15530
|
-
return;
|
|
15531
|
-
} catch (e) {
|
|
15532
|
-
if (e instanceof KillRequested)
|
|
15533
|
-
return;
|
|
15534
|
-
if (reg.get(dispatchId).state === "killed")
|
|
15535
|
-
return;
|
|
15536
|
-
const retryable = !current.agentPath && e instanceof DispatchError && (e.code === "host-error" || e.code === "empty-response");
|
|
15537
|
-
const alternative = retryable ? resolveDispatch(deps.cfg, { profile: req.agent, depth: req.depth, exclude: [current.model] }, deps.available, deps.catalog) : { ok: false };
|
|
15538
|
-
if (!retryable || attemptNo > 0 || !alternative.ok) {
|
|
15539
|
-
bgTerminal(reg, dispatchId, current.model, req, t0, e);
|
|
15540
|
-
return;
|
|
15541
|
-
}
|
|
15542
|
-
deps.sink({ ts: new Date().toISOString(), event: "retry-excluded", tier: req.agent, agent: req.agent, identity: current.model, note: `${current.model}: ${e.code}` });
|
|
15543
|
-
exclude2 = [current.model];
|
|
15544
|
-
current = resolveForThrow(req, exclude2);
|
|
15545
|
-
reg.get(dispatchId).identity = current.model;
|
|
15546
|
-
}
|
|
15547
|
-
}
|
|
15548
|
-
} finally {
|
|
15549
|
-
inFlight.delete(slot);
|
|
15550
|
-
killControls.delete(dispatchId);
|
|
15551
|
-
}
|
|
15552
|
-
})();
|
|
15553
|
-
return {
|
|
15554
|
-
dispatchId,
|
|
15555
|
-
agent: req.agent,
|
|
15556
|
-
requested: { agent: req.agent, depth: req.depth },
|
|
15557
|
-
resolved: first.model,
|
|
15558
|
-
depth: first.depth,
|
|
15559
|
-
queuedAt: reg.get(dispatchId).queuedAt
|
|
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 } : {}
|
|
15560
14783
|
};
|
|
15561
14784
|
}
|
|
15562
|
-
|
|
15563
|
-
|
|
15564
|
-
|
|
15565
|
-
|
|
15566
|
-
|
|
15567
|
-
|
|
15568
|
-
|
|
15569
|
-
|
|
15570
|
-
|
|
15571
|
-
|
|
15572
|
-
|
|
15573
|
-
|
|
15574
|
-
|
|
15575
|
-
|
|
15576
|
-
|
|
15577
|
-
|
|
15578
|
-
|
|
15579
|
-
|
|
15580
|
-
|
|
15581
|
-
|
|
15582
|
-
|
|
15583
|
-
|
|
15584
|
-
|
|
15585
|
-
|
|
15586
|
-
|
|
15587
|
-
|
|
15588
|
-
|
|
15589
|
-
|
|
15590
|
-
|
|
15591
|
-
|
|
15592
|
-
|
|
15593
|
-
}
|
|
15594
|
-
|
|
15595
|
-
|
|
15596
|
-
reg.markKilled(dispatchId);
|
|
15597
|
-
deps.sink({ ts: new Date().toISOString(), event: "killed", tier: e.tier, identity: e.identity, depth: e.depth ?? undefined, sessionID: e.sessionID, outcome: "killed" });
|
|
15598
|
-
killControls.get(dispatchId)?.fire();
|
|
15599
|
-
},
|
|
15600
|
-
inFlightCount: () => inFlight.size,
|
|
15601
|
-
inFlightIDs: () => [...inFlight],
|
|
15602
|
-
dispose: () => {
|
|
15603
|
-
for (const slot of inFlight) {
|
|
15604
|
-
deps.sink({ ts: new Date().toISOString(), event: "lost-on-exit", note: `dispatch ${slot} in flight at host exit` });
|
|
15605
|
-
}
|
|
15606
|
-
inFlight.clear();
|
|
15607
|
-
}
|
|
15608
|
-
};
|
|
15609
|
-
}
|
|
15610
|
-
|
|
15611
|
-
class KillRequested extends Error {
|
|
15612
|
-
constructor() {
|
|
15613
|
-
super("kill requested");
|
|
15614
|
-
}
|
|
15615
|
-
}
|
|
15616
|
-
function priceFor(catalog, identity) {
|
|
15617
|
-
const slash = identity.indexOf("/");
|
|
15618
|
-
if (slash <= 0)
|
|
15619
|
-
return null;
|
|
15620
|
-
const model = catalog.providers?.[identity.slice(0, slash)]?.models?.[identity.slice(slash + 1)];
|
|
15621
|
-
const cost = model?.cost;
|
|
15622
|
-
if (!cost)
|
|
15623
|
-
return null;
|
|
15624
|
-
return {
|
|
15625
|
-
spec: {
|
|
15626
|
-
input: cost.input,
|
|
15627
|
-
output: cost.output,
|
|
15628
|
-
cacheRead: cost.cache_read,
|
|
15629
|
-
cacheWrite: cost.cache_write
|
|
15630
|
-
},
|
|
15631
|
-
label: "models.dev snapshot"
|
|
15632
|
-
};
|
|
15633
|
-
}
|
|
15634
|
-
|
|
15635
|
-
// src/dispatch-ledger.ts
|
|
15636
|
-
import { mkdirSync as mkdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "node:fs";
|
|
15637
|
-
import { dirname as dirname3 } from "node:path";
|
|
15638
|
-
var DISPATCH_LEDGER_MAX_ENTRIES = 200;
|
|
15639
|
-
var DISPATCH_LEDGER_MAX_BYTES = 1e6;
|
|
15640
|
-
function createDispatchLedger(logPath, maxEntries = DISPATCH_LEDGER_MAX_ENTRIES, maxBytes = DISPATCH_LEDGER_MAX_BYTES) {
|
|
15641
|
-
const append = (entry) => {
|
|
15642
|
-
try {
|
|
15643
|
-
mkdirSync4(dirname3(logPath), { recursive: true });
|
|
15644
|
-
let lines = [];
|
|
15645
|
-
try {
|
|
15646
|
-
lines = readFileSync6(logPath, "utf8").split(`
|
|
15647
|
-
`).filter((l) => l.trim().length > 0);
|
|
15648
|
-
} catch {}
|
|
15649
|
-
if (lines.length >= maxEntries)
|
|
15650
|
-
lines = lines.slice(lines.length - maxEntries + 1);
|
|
15651
|
-
lines.push(JSON.stringify(entry));
|
|
15652
|
-
let text = lines.join(`
|
|
15653
|
-
`) + `
|
|
15654
|
-
`;
|
|
15655
|
-
if (text.length > maxBytes) {
|
|
15656
|
-
const keep = Math.max(1, Math.floor(maxEntries / 2));
|
|
15657
|
-
text = lines.slice(-keep).join(`
|
|
15658
|
-
`) + `
|
|
15659
|
-
`;
|
|
15660
|
-
}
|
|
15661
|
-
writeFileSync4(logPath, text, "utf8");
|
|
15662
|
-
} catch {}
|
|
15663
|
-
};
|
|
15664
|
-
const entries = () => {
|
|
15665
|
-
try {
|
|
15666
|
-
return readFileSync6(logPath, "utf8").split(`
|
|
15667
|
-
`).filter((l) => l.trim().length > 0).map((l) => JSON.parse(l));
|
|
15668
|
-
} catch {
|
|
15669
|
-
return [];
|
|
15670
|
-
}
|
|
15671
|
-
};
|
|
15672
|
-
return { append, entries, path: logPath };
|
|
15673
|
-
}
|
|
15674
|
-
|
|
15675
|
-
// src/dispatch-registry.ts
|
|
15676
|
-
var TERMINAL_CAP = 50;
|
|
15677
|
-
function createDispatchRegistry() {
|
|
15678
|
-
const entries = new Map;
|
|
15679
|
-
let seq = 0;
|
|
15680
|
-
const fail = (msg) => {
|
|
15681
|
-
throw new Error(`[forge:dispatch-registry] ${msg}`);
|
|
15682
|
-
};
|
|
15683
|
-
const isTerminal2 = (st) => st === "completed" || st === "timeout" || st === "killed" || st === "error";
|
|
15684
|
-
return {
|
|
15685
|
-
submit(entry) {
|
|
15686
|
-
const dispatchId = `bg-${++seq}`;
|
|
15687
|
-
entries.set(dispatchId, {
|
|
15688
|
-
...entry,
|
|
15689
|
-
dispatchId,
|
|
15690
|
-
background: true,
|
|
15691
|
-
queuedAt: entry.queuedAt ?? new Date().toISOString(),
|
|
15692
|
-
state: "queued",
|
|
15693
|
-
delivered: false
|
|
15694
|
-
});
|
|
15695
|
-
return { dispatchId };
|
|
15696
|
-
},
|
|
15697
|
-
get(dispatchId) {
|
|
15698
|
-
const e = entries.get(dispatchId);
|
|
15699
|
-
if (!e)
|
|
15700
|
-
throw new Error(`[forge:dispatch-registry] unknown dispatch id "${dispatchId}"`);
|
|
15701
|
-
return e;
|
|
15702
|
-
},
|
|
15703
|
-
markRunning(dispatchId) {
|
|
15704
|
-
const e = this.get(dispatchId);
|
|
15705
|
-
if (e.state !== "queued")
|
|
15706
|
-
fail(`illegal transition ${e.state} → running for ${dispatchId}`);
|
|
15707
|
-
e.state = "running";
|
|
15708
|
-
return e;
|
|
15709
|
-
},
|
|
15710
|
-
markCompleted(dispatchId, result) {
|
|
15711
|
-
return this.terminate(dispatchId, "completed", result);
|
|
15712
|
-
},
|
|
15713
|
-
markTimeout(dispatchId, report) {
|
|
15714
|
-
return this.terminate(dispatchId, "timeout", report);
|
|
15715
|
-
},
|
|
15716
|
-
markError(dispatchId, report) {
|
|
15717
|
-
return this.terminate(dispatchId, "error", report);
|
|
15718
|
-
},
|
|
15719
|
-
markKilled(dispatchId) {
|
|
15720
|
-
return this.terminate(dispatchId, "killed");
|
|
15721
|
-
},
|
|
15722
|
-
noteLateCompletion(dispatchId, result) {
|
|
15723
|
-
const e = this.get(dispatchId);
|
|
15724
|
-
if (e.state !== "killed")
|
|
15725
|
-
fail(`late completion only makes sense on a killed dispatch (${dispatchId} is ${e.state})`);
|
|
15726
|
-
e.lateCompletion = result;
|
|
15727
|
-
},
|
|
15728
|
-
terminate(dispatchId, state, result) {
|
|
15729
|
-
const e = this.get(dispatchId);
|
|
15730
|
-
if (isTerminal2(e.state))
|
|
15731
|
-
fail(`illegal transition ${e.state} → ${state} for ${dispatchId}`);
|
|
15732
|
-
e.state = state;
|
|
15733
|
-
if (result !== undefined)
|
|
15734
|
-
e.result = result;
|
|
15735
|
-
return e;
|
|
15736
|
-
},
|
|
15737
|
-
takeUndelivered(parentSessionID) {
|
|
15738
|
-
const out = [];
|
|
15739
|
-
for (const e of entries.values()) {
|
|
15740
|
-
if (parentSessionID !== undefined && e.parentSessionID !== parentSessionID)
|
|
15741
|
-
continue;
|
|
15742
|
-
if (isTerminal2(e.state) && !e.delivered && e.state !== "killed") {
|
|
15743
|
-
e.delivered = true;
|
|
15744
|
-
out.push(e);
|
|
15745
|
-
}
|
|
15746
|
-
}
|
|
15747
|
-
return out;
|
|
15748
|
-
},
|
|
15749
|
-
list() {
|
|
15750
|
-
const inFlight = [];
|
|
15751
|
-
const terminal = [];
|
|
15752
|
-
for (const e of entries.values()) {
|
|
15753
|
-
if (isTerminal2(e.state))
|
|
15754
|
-
terminal.push(e);
|
|
15755
|
-
else
|
|
15756
|
-
inFlight.push(e);
|
|
15757
|
-
}
|
|
15758
|
-
return { inFlight, terminal: terminal.slice(-TERMINAL_CAP) };
|
|
15759
|
-
}
|
|
15760
|
-
};
|
|
14785
|
+
catalog.providers[providerID] = out;
|
|
14786
|
+
}
|
|
14787
|
+
return catalog;
|
|
14788
|
+
}
|
|
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" };
|
|
14801
|
+
}
|
|
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" };
|
|
15761
14819
|
}
|
|
15762
14820
|
|
|
15763
14821
|
// src/crew-gate.ts
|
|
15764
|
-
|
|
15765
|
-
|
|
15766
|
-
|
|
14822
|
+
function normTitle(t) {
|
|
14823
|
+
return String(t ?? "").trim().toLowerCase();
|
|
14824
|
+
}
|
|
14825
|
+
function validateCrewReport(report, declared) {
|
|
15767
14826
|
const gaps = [];
|
|
15768
14827
|
if (!Array.isArray(report) || report.length === 0) {
|
|
15769
|
-
return { ok: false, gaps: ["the report lists no subtasks — a crew completion report must cover every subtask"]
|
|
14828
|
+
return { ok: false, gaps: ["the report lists no subtasks — a crew completion report must cover every declared subtask"] };
|
|
15770
14829
|
}
|
|
15771
|
-
const
|
|
15772
|
-
for (const
|
|
15773
|
-
|
|
15774
|
-
|
|
15775
|
-
|
|
15776
|
-
continue;
|
|
15777
|
-
if (row.event === "validation" || row.event === "resolve-error" || row.event === "refused-cap" || row.event === "models-dev-degraded" || row.event === "retry-excluded" || row.event === "lost-on-exit" || row.event === "kill-late-completion" || row.event === "transport-interrupted")
|
|
15778
|
-
continue;
|
|
15779
|
-
if (CREW_DISPATCH_EVENTS.has(row.event) && row.dispatchId)
|
|
15780
|
-
rowById.set(row.dispatchId, row);
|
|
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);
|
|
15781
14835
|
}
|
|
15782
|
-
const
|
|
14836
|
+
const covered = new Set;
|
|
15783
14837
|
report.forEach((sub, i) => {
|
|
15784
14838
|
const label = sub?.title?.trim() || `subtask #${i + 1}`;
|
|
15785
|
-
const
|
|
14839
|
+
const key = normTitle(sub?.title);
|
|
15786
14840
|
if (!sub?.title?.trim())
|
|
15787
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();
|
|
15788
14846
|
if (verdict !== "PASS" && verdict !== "FAIL")
|
|
15789
14847
|
gaps.push(`${label}: verdict must be PASS or FAIL (got "${sub?.verdict ?? ""}")`);
|
|
15790
|
-
const ids = [...String(sub?.evidence ?? "").matchAll(DISPATCH_ID)].map((m) => m[0]);
|
|
15791
14848
|
if (!String(sub?.evidence ?? "").trim())
|
|
15792
|
-
gaps.push(`${label}: missing evidence
|
|
15793
|
-
else if (ids.length === 0)
|
|
15794
|
-
gaps.push(`${label}: evidence must reference a dispatch id (bg-N) that exists in the dispatch ledger`);
|
|
15795
|
-
for (const id of ids) {
|
|
15796
|
-
if (referenced.has(id))
|
|
15797
|
-
gaps.push(`${label}: dispatch ${id} is already cited by another subtask — each dispatch belongs to exactly one verdict`);
|
|
15798
|
-
referenced.add(id);
|
|
15799
|
-
const row = rowById.get(id);
|
|
15800
|
-
if (!row)
|
|
15801
|
-
gaps.push(`${label}: dispatch ${id} has no ledger row for this crew (fabricated, stale, or pre-crew)`);
|
|
15802
|
-
else if (verdict === "PASS" && row.event !== "completed") {
|
|
15803
|
-
gaps.push(`${label}: marked PASS but dispatch ${id} ended ${row.event} — a subtask whose attempt and retry both failed must be marked FAIL with both reports visible`);
|
|
15804
|
-
}
|
|
15805
|
-
}
|
|
14849
|
+
gaps.push(`${label}: missing evidence statement`);
|
|
15806
14850
|
if (verdict === "FAIL" && (!Array.isArray(sub?.attempts) || sub.attempts.length < 2)) {
|
|
15807
14851
|
gaps.push(`${label}: FAIL requires both failure reports (the attempt and the one retry) in attempts[]`);
|
|
15808
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
|
+
}
|
|
15809
14856
|
});
|
|
15810
|
-
for (const
|
|
15811
|
-
if (!
|
|
15812
|
-
|
|
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
|
+
}
|
|
14862
|
+
}
|
|
14863
|
+
return { ok: gaps.length === 0, gaps };
|
|
14864
|
+
}
|
|
14865
|
+
|
|
14866
|
+
// src/dispatch-prompt.ts
|
|
14867
|
+
var WORKER_MANDATES = [
|
|
14868
|
+
"Mandates (non-negotiable):",
|
|
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.",
|
|
14870
|
+
"- Run every shell command through the forge_shell tool (the builtin shell is not part of your toolset by design). Long-running or non-exiting commands: forge_shell with run_in_background, then collect results via forge_jobs poll before yielding.",
|
|
14871
|
+
"- 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.",
|
|
14872
|
+
"- End with conclusions backed by evidence references (file:line, command output, or the exact file/content you produced)."
|
|
14873
|
+
].join(`
|
|
14874
|
+
`);
|
|
14875
|
+
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.`;
|
|
14876
|
+
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.`;
|
|
14877
|
+
function materializeAgentPrompt(input) {
|
|
14878
|
+
const frame = input.shape === "readonly" ? READONLY_DISCIPLINE(input.agent) : WRITE_DISCIPLINE(input.agent);
|
|
14879
|
+
return [frame, "", "Your role:", input.role.trim(), "", WORKER_MANDATES].join(`
|
|
14880
|
+
`);
|
|
14881
|
+
}
|
|
14882
|
+
|
|
14883
|
+
// src/dispatch-tiers.ts
|
|
14884
|
+
var MUTATING_TOOLS = ["write", "edit", "bash"];
|
|
14885
|
+
var ROLE_PROMPTS = {
|
|
14886
|
+
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.",
|
|
14887
|
+
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."
|
|
14888
|
+
};
|
|
14889
|
+
function rolePromptFor(agentId, def) {
|
|
14890
|
+
if (def?.prompt && def.prompt.trim().length > 0)
|
|
14891
|
+
return def.prompt;
|
|
14892
|
+
return ROLE_PROMPTS[agentId] ?? `You are a ${agentId} worker. Execute the assigned task exactly as scoped.`;
|
|
14893
|
+
}
|
|
14894
|
+
function agentDescriptionFor(agentId, def) {
|
|
14895
|
+
const shape = def.shape === "write" ? "write" : "readonly";
|
|
14896
|
+
return `forge subagent "${agentId}" (${shape}, pinned ${def.model}) — dispatch via the task tool with subagent_type "forge-${agentId}".`;
|
|
14897
|
+
}
|
|
14898
|
+
function forgeAgentDef(agentId, def) {
|
|
14899
|
+
const permission = { task: "deny" };
|
|
14900
|
+
const shape = def.shape ?? "readonly";
|
|
14901
|
+
if (shape === "readonly") {
|
|
14902
|
+
for (const t of MUTATING_TOOLS)
|
|
14903
|
+
permission[t] = "deny";
|
|
14904
|
+
}
|
|
14905
|
+
Object.assign(permission, def.permission ?? {});
|
|
14906
|
+
permission.task = "deny";
|
|
14907
|
+
return {
|
|
14908
|
+
description: agentDescriptionFor(agentId, def),
|
|
14909
|
+
mode: "subagent",
|
|
14910
|
+
model: def.model,
|
|
14911
|
+
prompt: materializeAgentPrompt({
|
|
14912
|
+
agent: agentId,
|
|
14913
|
+
shape,
|
|
14914
|
+
role: rolePromptFor(agentId, def)
|
|
14915
|
+
}),
|
|
14916
|
+
permission,
|
|
14917
|
+
tools: { shell: false, bash: false }
|
|
14918
|
+
};
|
|
14919
|
+
}
|
|
14920
|
+
|
|
14921
|
+
// src/dispatch-depth.ts
|
|
14922
|
+
var META_DEPTHS = ["none", "low", "medium", "high", "max"];
|
|
14923
|
+
var BUDGET_TIERS = {
|
|
14924
|
+
low: 8192,
|
|
14925
|
+
medium: 16384,
|
|
14926
|
+
high: 24576,
|
|
14927
|
+
max: 32768
|
|
14928
|
+
};
|
|
14929
|
+
function noMapping(depth, family, nativeVocabulary, metaAvailable) {
|
|
14930
|
+
return {
|
|
14931
|
+
kind: "no-mapping",
|
|
14932
|
+
family,
|
|
14933
|
+
message: `depth "${depth}" has no native counterpart on this model — the plugin never interpolates. Native levels: ${nativeVocabulary.join(", ") || "(none named)"}. Meta words this model accepts: ${metaAvailable.join(", ") || "(none)"}.`,
|
|
14934
|
+
nativeVocabulary,
|
|
14935
|
+
metaAvailable
|
|
14936
|
+
};
|
|
14937
|
+
}
|
|
14938
|
+
function translateDepth(depth, family, ladder) {
|
|
14939
|
+
if (family === "unknown") {
|
|
14940
|
+
return { kind: "not-injected", family, disclose: "not injected (unknown provider shape)" };
|
|
14941
|
+
}
|
|
14942
|
+
if (family === "anthropic") {
|
|
14943
|
+
if (depth === "none")
|
|
14944
|
+
return { kind: "off", family, disclose: "canonical none → native thinking:off" };
|
|
14945
|
+
const budget = BUDGET_TIERS[depth];
|
|
14946
|
+
if (budget !== undefined) {
|
|
14947
|
+
return { kind: "tiered", family, budgetTokens: budget, disclose: `canonical ${depth} → native thinking budget:${budget}` };
|
|
14948
|
+
}
|
|
14949
|
+
return noMapping(depth, family, [], [...META_DEPTHS]);
|
|
14950
|
+
}
|
|
14951
|
+
if (family === "zai") {
|
|
14952
|
+
if (depth === "none")
|
|
14953
|
+
return { kind: "off", family, disclose: "canonical none → native thinking:off" };
|
|
14954
|
+
if (META_DEPTHS.includes(depth)) {
|
|
14955
|
+
return { kind: "toggle-on", family, disclose: `canonical ${depth} → native thinking:on (this provider shape takes a toggle, not a level)` };
|
|
14956
|
+
}
|
|
14957
|
+
return noMapping(depth, family, ["thinking on/off"], [...META_DEPTHS]);
|
|
14958
|
+
}
|
|
14959
|
+
const native = ladder ?? null;
|
|
14960
|
+
if (native !== null && native.includes(depth)) {
|
|
14961
|
+
return { kind: "verbatim", family: "openai", word: depth, disclose: META_DEPTHS.includes(depth) ? "verbatim" : `verbatim (native word "${depth}")` };
|
|
14962
|
+
}
|
|
14963
|
+
if (native !== null && native.length > 0 && META_DEPTHS.includes(depth)) {
|
|
14964
|
+
return noMapping(depth, family, native, META_DEPTHS.filter((w) => native.includes(w)));
|
|
14965
|
+
}
|
|
14966
|
+
return { kind: "verbatim", family: "openai", word: depth, disclose: `verbatim (unverified — ${native === null ? "catalog does not know this model" : `catalog ladder does not list "${depth}"`}; the provider judges)` };
|
|
14967
|
+
}
|
|
14968
|
+
function applyDepthTranslation(options, t) {
|
|
14969
|
+
if (t.kind === "verbatim") {
|
|
14970
|
+
options.reasoningEffort = t.word;
|
|
14971
|
+
} else if (t.kind === "tiered") {
|
|
14972
|
+
options.thinking = { type: "enabled", budget_tokens: t.budgetTokens };
|
|
14973
|
+
} else if (t.kind === "off") {
|
|
14974
|
+
options.thinking = { type: "disabled" };
|
|
14975
|
+
} else if (t.kind === "toggle-on") {
|
|
14976
|
+
options.thinking = { type: "enabled" };
|
|
15813
14977
|
}
|
|
15814
|
-
|
|
14978
|
+
}
|
|
14979
|
+
function resolvePinnedDepth(thoughtLevel, family, ladder) {
|
|
14980
|
+
const t = translateDepth(thoughtLevel, family, ladder);
|
|
14981
|
+
if (t.kind === "no-mapping")
|
|
14982
|
+
return { translation: null, findingMessage: t.message };
|
|
14983
|
+
if (t.kind === "not-injected")
|
|
14984
|
+
return { translation: null };
|
|
14985
|
+
return { translation: t };
|
|
15815
14986
|
}
|
|
15816
14987
|
|
|
15817
14988
|
// src/forge-config.ts
|
|
@@ -15842,7 +15013,7 @@ function sanitizeJsonc(text) {
|
|
|
15842
15013
|
if (c === "/" && d === "*") {
|
|
15843
15014
|
out.push(" ", " ");
|
|
15844
15015
|
i += 2;
|
|
15845
|
-
while (i < n && !(text[i] === "*" && i + 1 < n
|
|
15016
|
+
while (i < n && !(text[i] === "*" && i + 1 < n)) {
|
|
15846
15017
|
out.push(text[i] === `
|
|
15847
15018
|
` ? `
|
|
15848
15019
|
` : " ");
|
|
@@ -16051,59 +15222,74 @@ function parseForgeJsonc(text) {
|
|
|
16051
15222
|
error: located ?? { message: "invalid JSON", line: 1, column: 1 }
|
|
16052
15223
|
};
|
|
16053
15224
|
}
|
|
16054
|
-
|
|
16055
|
-
|
|
16056
|
-
|
|
16057
|
-
|
|
16058
|
-
|
|
16059
|
-
|
|
16060
|
-
|
|
16061
|
-
|
|
16062
|
-
|
|
16063
|
-
|
|
16064
|
-
|
|
16065
|
-
|
|
16066
|
-
|
|
16067
|
-
|
|
16068
|
-
|
|
16069
|
-
|
|
16070
|
-
|
|
16071
|
-
|
|
16072
|
-
|
|
16073
|
-
|
|
16074
|
-
|
|
16075
|
-
|
|
16076
|
-
|
|
16077
|
-
|
|
16078
|
-
|
|
16079
|
-
|
|
16080
|
-
|
|
16081
|
-
|
|
16082
|
-
|
|
16083
|
-
|
|
16084
|
-
|
|
16085
|
-
|
|
16086
|
-
|
|
16087
|
-
|
|
16088
|
-
|
|
16089
|
-
|
|
16090
|
-
|
|
16091
|
-
|
|
16092
|
-
|
|
16093
|
-
|
|
16094
|
-
|
|
16095
|
-
|
|
16096
|
-
|
|
16097
|
-
|
|
16098
|
-
|
|
16099
|
-
|
|
16100
|
-
|
|
15225
|
+
return { ok: true, config: parsed };
|
|
15226
|
+
}
|
|
15227
|
+
var AGENT_ID_RE = /^[a-z0-9-]+$/;
|
|
15228
|
+
function isRecord(v) {
|
|
15229
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
15230
|
+
}
|
|
15231
|
+
function nonEmptyString(v) {
|
|
15232
|
+
return typeof v === "string" && v.trim().length > 0;
|
|
15233
|
+
}
|
|
15234
|
+
function validateAgentSet(raw) {
|
|
15235
|
+
const findings = [];
|
|
15236
|
+
const agents = {};
|
|
15237
|
+
if (raw === undefined)
|
|
15238
|
+
return { agents, findings };
|
|
15239
|
+
if (!isRecord(raw)) {
|
|
15240
|
+
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' });
|
|
15241
|
+
return { agents, findings };
|
|
15242
|
+
}
|
|
15243
|
+
for (const [id, entry] of Object.entries(raw)) {
|
|
15244
|
+
const label = `agents.${id}`;
|
|
15245
|
+
if (!AGENT_ID_RE.test(id)) {
|
|
15246
|
+
findings.push({ level: "error", code: "agent-id-invalid", message: `${label}: agent id must match [a-z0-9-] — entry skipped` });
|
|
15247
|
+
continue;
|
|
15248
|
+
}
|
|
15249
|
+
if (!isRecord(entry)) {
|
|
15250
|
+
findings.push({ level: "error", code: "agent-not-object", message: `${label}: definition must be an object — entry skipped` });
|
|
15251
|
+
continue;
|
|
15252
|
+
}
|
|
15253
|
+
if (!nonEmptyString(entry.model)) {
|
|
15254
|
+
findings.push({ level: "error", code: "agent-model-missing", message: `${label}.model: a non-empty "provider/model" string is required — entry skipped, siblings still apply` });
|
|
15255
|
+
continue;
|
|
15256
|
+
}
|
|
15257
|
+
const def = { model: entry.model.trim() };
|
|
15258
|
+
if (entry.depths !== undefined) {
|
|
15259
|
+
findings.push({ level: "warn", code: "depths-deprecated", message: `${label}.depths is no longer used (static agents pin one thoughtLevel) — ignored; set "thoughtLevel" instead` });
|
|
15260
|
+
}
|
|
15261
|
+
if (entry.thoughtLevel !== undefined) {
|
|
15262
|
+
if (nonEmptyString(entry.thoughtLevel)) {
|
|
15263
|
+
def.thoughtLevel = entry.thoughtLevel.trim();
|
|
15264
|
+
} else {
|
|
15265
|
+
findings.push({ level: "warn", code: "thoughtlevel-invalid", message: `${label}.thoughtLevel must be a non-empty string — field ignored, the agent still materializes` });
|
|
15266
|
+
}
|
|
15267
|
+
}
|
|
15268
|
+
if (entry.prompt !== undefined) {
|
|
15269
|
+
if (nonEmptyString(entry.prompt)) {
|
|
15270
|
+
def.prompt = entry.prompt;
|
|
15271
|
+
} else {
|
|
15272
|
+
findings.push({ level: "warn", code: "prompt-invalid", message: `${label}.prompt must be a non-empty string — field ignored` });
|
|
15273
|
+
}
|
|
15274
|
+
}
|
|
15275
|
+
if (entry.shape !== undefined) {
|
|
15276
|
+
if (entry.shape === "readonly" || entry.shape === "write") {
|
|
15277
|
+
def.shape = entry.shape;
|
|
15278
|
+
} else {
|
|
15279
|
+
findings.push({ level: "warn", code: "shape-invalid", message: `${label}.shape must be "readonly" or "write" — defaulting to readonly` });
|
|
15280
|
+
}
|
|
15281
|
+
}
|
|
15282
|
+
if (entry.permission !== undefined) {
|
|
15283
|
+
if (isRecord(entry.permission) && Object.values(entry.permission).every((v) => typeof v === "string")) {
|
|
15284
|
+
def.permission = entry.permission;
|
|
15285
|
+
} else {
|
|
15286
|
+
findings.push({ level: "warn", code: "permission-invalid", message: `${label}.permission must map tool names to string values — field ignored` });
|
|
15287
|
+
}
|
|
15288
|
+
}
|
|
15289
|
+
agents[id] = def;
|
|
15290
|
+
}
|
|
15291
|
+
return { agents, findings };
|
|
16101
15292
|
}
|
|
16102
|
-
var PLACEHOLDER_IDENTITY = "Local/GPT Luna";
|
|
16103
|
-
var SEED_AGENTS = {
|
|
16104
|
-
research: { model: PLACEHOLDER_IDENTITY, depths: ["low", "medium"], shape: "readonly" },
|
|
16105
|
-
review: { model: PLACEHOLDER_IDENTITY, depths: ["medium", "high", "max"], shape: "readonly" }
|
|
16106
|
-
};
|
|
16107
15293
|
function forgeConfigPaths(opts) {
|
|
16108
15294
|
const sep2 = opts.projectDir?.includes("\\") && !opts.projectDir?.includes("/") ? "\\" : "/";
|
|
16109
15295
|
const project = opts.projectDir ? `${opts.projectDir}${sep2}.opencode${sep2}forge.json` : "";
|
|
@@ -16119,149 +15305,95 @@ function createForgeConfigLoader(deps) {
|
|
|
16119
15305
|
return null;
|
|
16120
15306
|
}
|
|
16121
15307
|
});
|
|
16122
|
-
const readFile = deps.readFile ?? ((path) => readFileSync7(path, "utf8"));
|
|
16123
|
-
const paths = forgeConfigPaths({ projectDir: deps.projectDir, homeDir: deps.homeDir });
|
|
16124
|
-
const cache = new Map;
|
|
16125
|
-
const readCandidate = (path, source) => {
|
|
16126
|
-
const st = stat(path);
|
|
16127
|
-
if (st === null) {
|
|
16128
|
-
cache.delete(path);
|
|
16129
|
-
return null;
|
|
16130
|
-
}
|
|
16131
|
-
const hit = cache.get(path);
|
|
16132
|
-
if (hit && hit.mtimeMs === st.mtimeMs && hit.size === st.size)
|
|
16133
|
-
return hit.result;
|
|
16134
|
-
const text = readFile(path);
|
|
16135
|
-
const parsed = parseForgeJsonc(text);
|
|
16136
|
-
let result;
|
|
16137
|
-
if (!parsed.ok) {
|
|
16138
|
-
result = {
|
|
16139
|
-
agents: {
|
|
16140
|
-
source
|
|
16141
|
-
path
|
|
16142
|
-
findings: [
|
|
16143
|
-
{
|
|
16144
|
-
level: "error",
|
|
16145
|
-
code: "config-parse-error",
|
|
16146
|
-
message: `${path}: ${parsed.error.message}${parsed.error.line !== undefined ? ` (line ${parsed.error.line}, column ${parsed.error.column})` : ""} —
|
|
16147
|
-
}
|
|
16148
|
-
]
|
|
16149
|
-
};
|
|
16150
|
-
} else {
|
|
16151
|
-
result = { agents: parsed.config.agents, source, path, findings: [] };
|
|
16152
|
-
}
|
|
16153
|
-
cache.set(path, { mtimeMs: st.mtimeMs, size: st.size, result });
|
|
16154
|
-
return result;
|
|
16155
|
-
};
|
|
16156
|
-
return {
|
|
16157
|
-
load() {
|
|
16158
|
-
const project = paths.project ? readCandidate(paths.project, "project") : null;
|
|
16159
|
-
if (project)
|
|
16160
|
-
return project;
|
|
16161
|
-
const global = paths.global ? readCandidate(paths.global, "global") : null;
|
|
16162
|
-
if (global)
|
|
16163
|
-
return global;
|
|
16164
|
-
return {
|
|
16165
|
-
agents: { ...SEED_AGENTS },
|
|
16166
|
-
source: "seed",
|
|
16167
|
-
path: null,
|
|
16168
|
-
findings: [
|
|
16169
|
-
{
|
|
16170
|
-
level: "notice",
|
|
16171
|
-
code: "dispatch-unconfigured",
|
|
16172
|
-
message: "forge dispatch is unconfigured (no forge.json found): every forge_dispatch call will fail with the configuration recipe. Create .opencode/forge.json (project) or ~/.config/opencode/forge.json (global), or ask your session AI to configure dispatch — the recipe in the dispatch error tells it exactly what to write."
|
|
16173
|
-
}
|
|
16174
|
-
]
|
|
16175
|
-
};
|
|
16176
|
-
}
|
|
16177
|
-
};
|
|
16178
|
-
}
|
|
16179
|
-
|
|
16180
|
-
// src/models-dev.ts
|
|
16181
|
-
import { mkdirSync as mkdirSync5, readFileSync as readFileSync8, writeFileSync as writeFileSync5 } from "node:fs";
|
|
16182
|
-
import { join as join4 } from "node:path";
|
|
16183
|
-
import { tmpdir as tmpdir2 } from "node:os";
|
|
16184
|
-
var DEFAULT_MODELS_DEV_URL = "https://models.dev/api.json";
|
|
16185
|
-
function modelsDevCacheDir(base) {
|
|
16186
|
-
return join4(base ?? join4(tmpdir2(), "opencode-forge"), "models-dev");
|
|
16187
|
-
}
|
|
16188
|
-
function namedLevels(rows) {
|
|
16189
|
-
const out = [];
|
|
16190
|
-
for (const r of rows) {
|
|
16191
|
-
if (r && typeof r === "object" && r.type === "effort") {
|
|
16192
|
-
const values = r.values;
|
|
16193
|
-
if (Array.isArray(values)) {
|
|
16194
|
-
for (const v of values)
|
|
16195
|
-
if (typeof v === "string")
|
|
16196
|
-
out.push(v);
|
|
16197
|
-
}
|
|
16198
|
-
}
|
|
16199
|
-
}
|
|
16200
|
-
return out;
|
|
16201
|
-
}
|
|
16202
|
-
function reduceModelsDev(raw) {
|
|
16203
|
-
const catalog = { providers: {} };
|
|
16204
|
-
const providers = raw?.constructor === Object ? raw : null;
|
|
16205
|
-
if (!providers)
|
|
16206
|
-
return catalog;
|
|
16207
|
-
for (const [providerID, provider] of Object.entries(providers)) {
|
|
16208
|
-
const models = provider.models;
|
|
16209
|
-
if (!models || typeof models !== "object")
|
|
16210
|
-
continue;
|
|
16211
|
-
const out = { models: {} };
|
|
16212
|
-
for (const [modelID, model] of Object.entries(models)) {
|
|
16213
|
-
const m = model;
|
|
16214
|
-
const options = Array.isArray(m.reasoning_options) ? namedLevels(m.reasoning_options) : undefined;
|
|
16215
|
-
const cost = m.cost && typeof m.cost === "object" ? m.cost : undefined;
|
|
16216
|
-
out.models[modelID] = {
|
|
16217
|
-
...options ? { reasoningOptions: options } : {},
|
|
16218
|
-
...cost ? { cost } : {}
|
|
15308
|
+
const readFile = deps.readFile ?? ((path) => readFileSync7(path, "utf8"));
|
|
15309
|
+
const paths = forgeConfigPaths({ projectDir: deps.projectDir, homeDir: deps.homeDir });
|
|
15310
|
+
const cache = new Map;
|
|
15311
|
+
const readCandidate = (path, source) => {
|
|
15312
|
+
const st = stat(path);
|
|
15313
|
+
if (st === null) {
|
|
15314
|
+
cache.delete(path);
|
|
15315
|
+
return null;
|
|
15316
|
+
}
|
|
15317
|
+
const hit = cache.get(path);
|
|
15318
|
+
if (hit && hit.mtimeMs === st.mtimeMs && hit.size === st.size)
|
|
15319
|
+
return hit.result;
|
|
15320
|
+
const text = readFile(path);
|
|
15321
|
+
const parsed = parseForgeJsonc(text);
|
|
15322
|
+
let result;
|
|
15323
|
+
if (!parsed.ok) {
|
|
15324
|
+
result = {
|
|
15325
|
+
agents: {},
|
|
15326
|
+
source,
|
|
15327
|
+
path,
|
|
15328
|
+
findings: [
|
|
15329
|
+
{
|
|
15330
|
+
level: "error",
|
|
15331
|
+
code: "config-parse-error",
|
|
15332
|
+
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`
|
|
15333
|
+
}
|
|
15334
|
+
]
|
|
16219
15335
|
};
|
|
15336
|
+
} else {
|
|
15337
|
+
const top = parsed.config;
|
|
15338
|
+
if (top !== undefined && !isRecord(top)) {
|
|
15339
|
+
result = {
|
|
15340
|
+
agents: {},
|
|
15341
|
+
source,
|
|
15342
|
+
path,
|
|
15343
|
+
findings: [{ level: "error", code: "config-not-object", message: `${path}: top level must be an object — the agent set is empty` }]
|
|
15344
|
+
};
|
|
15345
|
+
} else {
|
|
15346
|
+
const validated = validateAgentSet(top === undefined ? undefined : top.agents);
|
|
15347
|
+
result = { agents: validated.agents, source, path, findings: validated.findings };
|
|
15348
|
+
}
|
|
16220
15349
|
}
|
|
16221
|
-
|
|
16222
|
-
|
|
16223
|
-
|
|
16224
|
-
|
|
16225
|
-
|
|
16226
|
-
|
|
16227
|
-
|
|
16228
|
-
|
|
16229
|
-
|
|
16230
|
-
|
|
16231
|
-
|
|
16232
|
-
|
|
16233
|
-
if (res.ok)
|
|
16234
|
-
return { catalog: reduceModelsDev(JSON.parse(await res.text())), degraded: false, source: url2 };
|
|
16235
|
-
} catch {}
|
|
16236
|
-
return { catalog: { providers: {} }, degraded: true, source: "fetch-failed" };
|
|
16237
|
-
}
|
|
16238
|
-
try {
|
|
16239
|
-
const res = await fetch(url2, { signal: AbortSignal.timeout(opts.timeoutMs ?? 8000) });
|
|
16240
|
-
if (res.ok) {
|
|
16241
|
-
const catalog = reduceModelsDev(JSON.parse(await res.text()));
|
|
16242
|
-
try {
|
|
16243
|
-
mkdirSync5(cacheDir, { recursive: true });
|
|
16244
|
-
writeFileSync5(cacheFile, JSON.stringify(catalog));
|
|
16245
|
-
} catch {}
|
|
16246
|
-
return { catalog, degraded: false, source: url2 };
|
|
15350
|
+
cache.set(path, { mtimeMs: st.mtimeMs, size: st.size, result });
|
|
15351
|
+
return result;
|
|
15352
|
+
};
|
|
15353
|
+
return {
|
|
15354
|
+
load() {
|
|
15355
|
+
const project = paths.project ? readCandidate(paths.project, "project") : null;
|
|
15356
|
+
if (project)
|
|
15357
|
+
return project;
|
|
15358
|
+
const global = paths.global ? readCandidate(paths.global, "global") : null;
|
|
15359
|
+
if (global)
|
|
15360
|
+
return global;
|
|
15361
|
+
return { agents: {}, source: "none", path: null, findings: [] };
|
|
16247
15362
|
}
|
|
16248
|
-
}
|
|
16249
|
-
try {
|
|
16250
|
-
const cached2 = JSON.parse(readFileSync8(cacheFile, "utf8"));
|
|
16251
|
-
if (cached2 && typeof cached2 === "object" && cached2.providers)
|
|
16252
|
-
return { catalog: cached2, degraded: true, source: "cache" };
|
|
16253
|
-
} catch {}
|
|
16254
|
-
return { catalog: { providers: {} }, degraded: true, source: "unavailable" };
|
|
15363
|
+
};
|
|
16255
15364
|
}
|
|
16256
15365
|
|
|
16257
15366
|
// plugin.ts
|
|
16258
15367
|
var FORGE_AGENT = "forge";
|
|
15368
|
+
var FORGE_STATE_TOOLS = [
|
|
15369
|
+
"plan_write",
|
|
15370
|
+
"plan_tick",
|
|
15371
|
+
"plan_approve",
|
|
15372
|
+
"plan_close",
|
|
15373
|
+
"plan_discard",
|
|
15374
|
+
"goal_write",
|
|
15375
|
+
"goal_check",
|
|
15376
|
+
"goal_complete",
|
|
15377
|
+
"goal_pause",
|
|
15378
|
+
"goal_resume",
|
|
15379
|
+
"goal_discard",
|
|
15380
|
+
"crew_begin",
|
|
15381
|
+
"crew_close"
|
|
15382
|
+
];
|
|
15383
|
+
var FORGE_EXEC_TOOLS = ["forge_shell", "forge_jobs"];
|
|
15384
|
+
var FORGE_ALL_TOOLS = [...FORGE_STATE_TOOLS, ...FORGE_EXEC_TOOLS];
|
|
15385
|
+
var FORGE_TOOL_SET = new Set(FORGE_ALL_TOOLS);
|
|
15386
|
+
var NATIVE_PARTITION_FALLBACKS = ["build", "plan", "general", "explore"];
|
|
16259
15387
|
var FORGE_PROMPT = `You are forge — the single general-purpose coding agent. You handle every task directly: exploration, planning, implementation, and verification. There is no agent switching.
|
|
16260
15388
|
|
|
16261
|
-
Workflow modes are strictly user-initiated. Never enter plan or goal mode — and never call a plan_* or goal_* tool — unless the user ran /plan or /goal, unmistakably asked for that mode (e.g. "plan first", "set a goal"), or the session already carries a [forge:plan-notice] / [forge:goal-notice] for a mode they started. Ordinary task requests are normal work. Mode-specific rules arrive with those commands and notices; when a notice is present, follow it
|
|
15389
|
+
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.
|
|
15390
|
+
|
|
15391
|
+
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.`;
|
|
16262
15392
|
var PLAN_COMMAND_TEMPLATE = [
|
|
16263
15393
|
'(forge plan harness routing. Argument: "$ARGUMENTS")',
|
|
16264
15394
|
"",
|
|
15395
|
+
"FAMILY GUARD (step 0, before anything else): if you do not have the plan_write tool in your available tools, you are not in a forge session — STOP now: execute nothing below (including the directory listing), and tell the user that /plan belongs to the forge agent, who should switch to forge (Tab) and rerun it there.",
|
|
15396
|
+
"",
|
|
16265
15397
|
"Current .opencode/plan/ directory:",
|
|
16266
15398
|
"!`ls -1 .opencode/plan 2>/dev/null || echo '(empty)'`",
|
|
16267
15399
|
"",
|
|
@@ -16288,6 +15420,8 @@ var PLAN_COMMAND_TEMPLATE = [
|
|
|
16288
15420
|
var GOAL_COMMAND_TEMPLATE = [
|
|
16289
15421
|
'(forge goal harness routing. Argument: "$ARGUMENTS")',
|
|
16290
15422
|
"",
|
|
15423
|
+
"FAMILY GUARD (step 0, before anything else): if you do not have the goal_write tool in your available tools, you are not in a forge session — STOP now: execute nothing below (including the directory listing), and tell the user that /goal belongs to the forge agent, who should switch to forge (Tab) and rerun it there.",
|
|
15424
|
+
"",
|
|
16291
15425
|
"Current .opencode/goal/ directory:",
|
|
16292
15426
|
"!`ls -1 .opencode/goal 2>/dev/null || echo '(empty)'`",
|
|
16293
15427
|
"",
|
|
@@ -16316,23 +15450,32 @@ function effectiveWorktree(worktree, fallback) {
|
|
|
16316
15450
|
return (fallback ?? "").trim();
|
|
16317
15451
|
}
|
|
16318
15452
|
var sessions = new Map;
|
|
15453
|
+
var sessionAgents = new Map;
|
|
15454
|
+
function isForgeFamilyAgent(name) {
|
|
15455
|
+
if (typeof name !== "string")
|
|
15456
|
+
return false;
|
|
15457
|
+
const n = name.toLowerCase();
|
|
15458
|
+
return n === FORGE_AGENT || n.startsWith("forge-");
|
|
15459
|
+
}
|
|
15460
|
+
function sessionIsForgeFamily(sessionID) {
|
|
15461
|
+
if (!sessionID)
|
|
15462
|
+
return false;
|
|
15463
|
+
return isForgeFamilyAgent(sessionAgents.get(sessionID));
|
|
15464
|
+
}
|
|
15465
|
+
function noteToolAgent(context) {
|
|
15466
|
+
if (context && typeof context.sessionID === "string" && context.sessionID && typeof context.agent === "string" && context.agent) {
|
|
15467
|
+
sessionAgents.set(context.sessionID, context.agent);
|
|
15468
|
+
}
|
|
15469
|
+
}
|
|
16319
15470
|
var forgeDisabled = false;
|
|
16320
|
-
var dispatchDisabled = false;
|
|
16321
|
-
var dispatchMaxConcurrent = 4;
|
|
16322
|
-
var dispatchTimeoutMs = 600000;
|
|
16323
|
-
var dispatchBase = null;
|
|
16324
15471
|
var forgeLoader = null;
|
|
16325
15472
|
var forgeLoaderDir = null;
|
|
16326
|
-
var forgeConfigState = null;
|
|
16327
|
-
var lastDetectedIdentities = [];
|
|
16328
|
-
var activeDispatchEngine = null;
|
|
16329
|
-
var dispatchLogDir = join5(tmpdir3(), "opencode-forge", "dispatch");
|
|
16330
|
-
var dispatchLedger = createDispatchLedger(join5(dispatchLogDir, "ledger.jsonl"));
|
|
16331
|
-
var activeDispatchRegistry = null;
|
|
16332
|
-
var dispatchClient = null;
|
|
16333
|
-
var dispatchWakeTimers = new Map;
|
|
16334
15473
|
var crews = new Map;
|
|
16335
|
-
var
|
|
15474
|
+
var modelsDevCatalog = null;
|
|
15475
|
+
var depthFindingNotes = new Map;
|
|
15476
|
+
var crewCommandSnapshot = null;
|
|
15477
|
+
var materializedSnapshot = {};
|
|
15478
|
+
var sessionDepths = new Map;
|
|
16336
15479
|
var OPENAI_FAMILY = new Set(["openai", "opencode", "openrouter", "groq", "xai", "azure", "github-copilot", "deepseek", "together", "vercel", "opus"]);
|
|
16337
15480
|
var ANTHROPIC_FAMILY = new Set(["anthropic"]);
|
|
16338
15481
|
var ZAI_FAMILY = new Set(["zai", "zai-coding-plan"]);
|
|
@@ -16345,32 +15488,6 @@ function dispatchProviderFamily(providerID) {
|
|
|
16345
15488
|
return "zai";
|
|
16346
15489
|
return null;
|
|
16347
15490
|
}
|
|
16348
|
-
function queueDispatchDepth(sessionID, translation) {
|
|
16349
|
-
const key = (t) => t.kind === "verbatim" ? `verbatim:${t.word}` : t.kind === "tiered" ? `tiered:${t.budgetTokens}` : t.kind;
|
|
16350
|
-
const existing = dispatchDepths.get(sessionID);
|
|
16351
|
-
if (existing)
|
|
16352
|
-
return key(existing) === key(translation);
|
|
16353
|
-
dispatchDepths.set(sessionID, translation);
|
|
16354
|
-
return true;
|
|
16355
|
-
}
|
|
16356
|
-
function configuredIdentitiesOf(cfg, catalog) {
|
|
16357
|
-
const providers = cfg.provider ?? {};
|
|
16358
|
-
const identities = [];
|
|
16359
|
-
for (const [providerID, provider] of Object.entries(providers)) {
|
|
16360
|
-
const modelKeys = provider?.models ? Object.keys(provider.models) : null;
|
|
16361
|
-
if (modelKeys && modelKeys.length > 0) {
|
|
16362
|
-
for (const m of modelKeys)
|
|
16363
|
-
identities.push(`${providerID}/${m}`);
|
|
16364
|
-
} else {
|
|
16365
|
-
const catModels = catalog.providers?.[providerID]?.models ?? {};
|
|
16366
|
-
const ids = Object.keys(catModels);
|
|
16367
|
-
if (ids.length > 0)
|
|
16368
|
-
for (const m of ids)
|
|
16369
|
-
identities.push(`${providerID}/${m}`);
|
|
16370
|
-
}
|
|
16371
|
-
}
|
|
16372
|
-
return identities;
|
|
16373
|
-
}
|
|
16374
15491
|
var jobsMode = "auto";
|
|
16375
15492
|
var jobsKeepBuiltinShell = false;
|
|
16376
15493
|
var jobsSurviveMode = "never";
|
|
@@ -16414,21 +15531,18 @@ function ensureJobLifecycle() {
|
|
|
16414
15531
|
});
|
|
16415
15532
|
return { registry: registry2, fence: jobFence };
|
|
16416
15533
|
}
|
|
16417
|
-
var nativeBackgroundSeen = false;
|
|
16418
15534
|
function jobStage() {
|
|
16419
15535
|
if (jobsMode === "native")
|
|
16420
15536
|
return 2;
|
|
16421
|
-
|
|
16422
|
-
return 0;
|
|
16423
|
-
return nativeBackgroundSeen ? 1 : 0;
|
|
15537
|
+
return 0;
|
|
16424
15538
|
}
|
|
16425
15539
|
var jobLogDir = jobsLogDir();
|
|
16426
15540
|
var jobLedgerPath = join5(jobLogDir, "ledger.jsonl");
|
|
16427
15541
|
function jobLedgerSink(entry) {
|
|
16428
15542
|
try {
|
|
16429
|
-
|
|
15543
|
+
mkdirSync5(jobLogDir, { recursive: true });
|
|
16430
15544
|
if (existsSync3(jobLedgerPath) && statSync4(jobLedgerPath).size > 1e6)
|
|
16431
|
-
|
|
15545
|
+
writeFileSync5(jobLedgerPath, "");
|
|
16432
15546
|
appendFileSync(jobLedgerPath, `${JSON.stringify(entry)}
|
|
16433
15547
|
`);
|
|
16434
15548
|
} catch {}
|
|
@@ -16489,7 +15603,7 @@ function readPlanDir(worktree) {
|
|
|
16489
15603
|
const dir = planDirOf(worktree);
|
|
16490
15604
|
if (!existsSync3(dir))
|
|
16491
15605
|
return [];
|
|
16492
|
-
return readdirSync4(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text:
|
|
15606
|
+
return readdirSync4(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync8(join5(dir, name), "utf8") }));
|
|
16493
15607
|
}
|
|
16494
15608
|
function ensureSession(sessionID, worktree) {
|
|
16495
15609
|
const existing = sessions.get(sessionID);
|
|
@@ -16502,11 +15616,12 @@ function ensureSession(sessionID, worktree) {
|
|
|
16502
15616
|
return state;
|
|
16503
15617
|
}
|
|
16504
15618
|
function worktreeFor(context) {
|
|
15619
|
+
noteToolAgent(context);
|
|
16505
15620
|
return effectiveWorktree(context.worktree, hostWorktree) || context.worktree;
|
|
16506
15621
|
}
|
|
16507
15622
|
function resolveActivePlan(state) {
|
|
16508
15623
|
if (state.planPath && existsSync3(state.planPath)) {
|
|
16509
|
-
const doc2 = parsePlanLoose(
|
|
15624
|
+
const doc2 = parsePlanLoose(readFileSync8(state.planPath, "utf8"));
|
|
16510
15625
|
if (doc2 && !isTerminal(doc2.status))
|
|
16511
15626
|
return { path: state.planPath, doc: doc2 };
|
|
16512
15627
|
state.planPath = undefined;
|
|
@@ -16548,12 +15663,12 @@ var planWriteTool = tool({
|
|
|
16548
15663
|
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.`);
|
|
16549
15664
|
} else {
|
|
16550
15665
|
const dir = planDirOf(state.worktree);
|
|
16551
|
-
|
|
15666
|
+
mkdirSync5(dir, { recursive: true });
|
|
16552
15667
|
path = join5(dir, planFileName(localDate(), slugify(args.goal), readdirSync4(dir).filter((f) => f.endsWith(".md"))));
|
|
16553
15668
|
mode = "created";
|
|
16554
15669
|
}
|
|
16555
15670
|
const text = renderPlan(args, now, created);
|
|
16556
|
-
|
|
15671
|
+
writeFileSync5(path, text);
|
|
16557
15672
|
state.planPath = path;
|
|
16558
15673
|
const doc2 = parsePlan(text);
|
|
16559
15674
|
context.metadata({ title: `${mode === "created" ? "Create" : "Revise"} plan: ${doc2.goal}` });
|
|
@@ -16581,8 +15696,8 @@ var planTickTool = tool({
|
|
|
16581
15696
|
if (active.doc.status !== "approved") {
|
|
16582
15697
|
throw new PlanError(`Plan status is ${active.doc.status}; only an approved plan can be ticked. Get user approval via plan_approve first.`);
|
|
16583
15698
|
}
|
|
16584
|
-
const next = tickTask(
|
|
16585
|
-
|
|
15699
|
+
const next = tickTask(readFileSync8(active.path, "utf8"), args.n, nowIso());
|
|
15700
|
+
writeFileSync5(active.path, next);
|
|
16586
15701
|
const doc2 = parsePlan(next);
|
|
16587
15702
|
const p = progressOf(doc2);
|
|
16588
15703
|
context.metadata({ title: `Tick task ${args.n} (${p.done}/${p.total})` });
|
|
@@ -16609,7 +15724,7 @@ var planApproveTool = tool({
|
|
|
16609
15724
|
throw new PlanError(`Plan status is ${active.doc.status}; only a draft plan can be approved.`);
|
|
16610
15725
|
}
|
|
16611
15726
|
await gate(context.ask, "plan_approve", `Approve plan: ${active.doc.goal}`);
|
|
16612
|
-
|
|
15727
|
+
writeFileSync5(active.path, transitionStatus(readFileSync8(active.path, "utf8"), "approved", nowIso()));
|
|
16613
15728
|
context.metadata({ title: `Plan approved: ${active.doc.goal}` });
|
|
16614
15729
|
return {
|
|
16615
15730
|
title: "plan approved",
|
|
@@ -16642,7 +15757,7 @@ var planCloseTool = tool({
|
|
|
16642
15757
|
Fix the implementation and retry, or revise the plan first.`);
|
|
16643
15758
|
}
|
|
16644
15759
|
await gate(context.ask, "plan_close", `Close plan: ${active.doc.goal}`);
|
|
16645
|
-
|
|
15760
|
+
writeFileSync5(active.path, transitionStatus(readFileSync8(active.path, "utf8"), "done", nowIso()));
|
|
16646
15761
|
context.metadata({ title: `Plan done: ${active.doc.goal}` });
|
|
16647
15762
|
return {
|
|
16648
15763
|
title: "plan done",
|
|
@@ -16660,7 +15775,7 @@ var planDiscardTool = tool({
|
|
|
16660
15775
|
const active = resolveActivePlan(state);
|
|
16661
15776
|
if (!active)
|
|
16662
15777
|
throw new PlanError("No plan to abandon in this workspace.");
|
|
16663
|
-
|
|
15778
|
+
writeFileSync5(active.path, transitionStatus(readFileSync8(active.path, "utf8"), "abandoned", nowIso()));
|
|
16664
15779
|
state.planPath = undefined;
|
|
16665
15780
|
context.metadata({ title: `Plan abandoned: ${active.doc.goal}` });
|
|
16666
15781
|
return { title: "plan abandoned", output: `Plan abandoned: ${relFrom(state.worktree, active.path)}. Write operations are restored.` };
|
|
@@ -16673,11 +15788,11 @@ function readGoalDir(worktree) {
|
|
|
16673
15788
|
const dir = goalDirOf(worktree);
|
|
16674
15789
|
if (!existsSync3(dir))
|
|
16675
15790
|
return [];
|
|
16676
|
-
return readdirSync4(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text:
|
|
15791
|
+
return readdirSync4(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync8(join5(dir, name), "utf8") }));
|
|
16677
15792
|
}
|
|
16678
15793
|
function resolveSessionGoal(state) {
|
|
16679
15794
|
if (state.goalPath && existsSync3(state.goalPath)) {
|
|
16680
|
-
const doc2 = parseGoalLoose(
|
|
15795
|
+
const doc2 = parseGoalLoose(readFileSync8(state.goalPath, "utf8"));
|
|
16681
15796
|
if (doc2 && !isGoalTerminal(doc2.status))
|
|
16682
15797
|
return { path: state.goalPath, doc: doc2 };
|
|
16683
15798
|
state.goalPath = undefined;
|
|
@@ -16781,7 +15896,7 @@ var goalWriteTool = tool({
|
|
|
16781
15896
|
await gate(context.ask, "goal_write", `Arm goal: ${args.goal}`);
|
|
16782
15897
|
}
|
|
16783
15898
|
const dir = goalDirOf(state.worktree);
|
|
16784
|
-
|
|
15899
|
+
mkdirSync5(dir, { recursive: true });
|
|
16785
15900
|
const name = goalFileName(localDateNow(), slugifyGoal(args.goal), readdirSync4(dir).filter((f) => f.endsWith(".md")));
|
|
16786
15901
|
const path = join5(dir, name);
|
|
16787
15902
|
const text = renderGoal(input, {
|
|
@@ -16823,7 +15938,7 @@ var goalCheckTool = tool({
|
|
|
16823
15938
|
o.index = selected[i].n;
|
|
16824
15939
|
});
|
|
16825
15940
|
const runId = `${nowIso()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
16826
|
-
const next = appendCheckLog(
|
|
15941
|
+
const next = appendCheckLog(readFileSync8(goal.path, "utf8"), runId, outcomes, nowIso());
|
|
16827
15942
|
atomicWrite(goal.path, next);
|
|
16828
15943
|
const ok = outcomesAllOk(outcomes);
|
|
16829
15944
|
context.metadata({ title: `goal_check: ${outcomes.filter((o) => o.ok).length}/${outcomes.length} pass` });
|
|
@@ -16857,7 +15972,7 @@ var goalCompleteTool = tool({
|
|
|
16857
15972
|
const failures = outcomes.filter((o) => !o.ok);
|
|
16858
15973
|
if (failures.length > 0) {
|
|
16859
15974
|
const runId2 = `${nowIso()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
16860
|
-
atomicWrite(goal.path, appendCheckLog(
|
|
15975
|
+
atomicWrite(goal.path, appendCheckLog(readFileSync8(goal.path, "utf8"), runId2, outcomes, nowIso()));
|
|
16861
15976
|
throw new GoalError(`Completion gate: verification re-run failed (fail-closed). The goal stays active.
|
|
16862
15977
|
${formatOutcomes(failures)}
|
|
16863
15978
|
Fix the work and retry; recorded results never substitute for the gate's own re-run.`);
|
|
@@ -16870,7 +15985,7 @@ Fix the work and retry; recorded results never substitute for the gate's own re-
|
|
|
16870
15985
|
}
|
|
16871
15986
|
await gate(context.ask, "goal_complete", `Complete goal: ${goal.doc.goal}`);
|
|
16872
15987
|
const runId = `${nowIso()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
16873
|
-
let text = appendCheckLog(
|
|
15988
|
+
let text = appendCheckLog(readFileSync8(goal.path, "utf8"), runId, outcomes, nowIso());
|
|
16874
15989
|
text = transitionGoal(text, "completed", nowIso());
|
|
16875
15990
|
atomicWrite(goal.path, text);
|
|
16876
15991
|
context.metadata({ title: `Goal completed: ${goal.doc.goal}` });
|
|
@@ -16892,7 +16007,7 @@ var goalPauseTool = tool({
|
|
|
16892
16007
|
throw new GoalError(`No active goal to pause (status: ${goal?.doc.status ?? "none"}).`);
|
|
16893
16008
|
}
|
|
16894
16009
|
const stopReason = args.blocker ? "blocker" : "user";
|
|
16895
|
-
atomicWrite(goal.path, transitionGoal(
|
|
16010
|
+
atomicWrite(goal.path, transitionGoal(readFileSync8(goal.path, "utf8"), "paused", nowIso(), { stopReason }));
|
|
16896
16011
|
engineForgetSession(context.sessionID);
|
|
16897
16012
|
context.metadata({ title: `Goal paused (${stopReason}): ${goal.doc.goal}` });
|
|
16898
16013
|
return {
|
|
@@ -16922,7 +16037,7 @@ var goalResumeTool = tool({
|
|
|
16922
16037
|
}
|
|
16923
16038
|
const promoting = goal.doc.status === "queued";
|
|
16924
16039
|
await gate(context.ask, "goal_resume", `${promoting ? "Promote" : "Resume"} goal: ${goal.doc.goal}`);
|
|
16925
|
-
let text =
|
|
16040
|
+
let text = readFileSync8(goal.path, "utf8");
|
|
16926
16041
|
if (args.addTurns)
|
|
16927
16042
|
text = bumpBudget(text, args.addTurns, nowIso());
|
|
16928
16043
|
text = transitionGoal(text, "active", nowIso(), { session: context.sessionID });
|
|
@@ -16948,7 +16063,7 @@ var goalDiscardTool = tool({
|
|
|
16948
16063
|
if (!goal)
|
|
16949
16064
|
throw new GoalError("No goal to discard in this workspace.");
|
|
16950
16065
|
await gate(context.ask, "goal_discard", `Discard goal: ${goal.doc.goal}`);
|
|
16951
|
-
atomicWrite(goal.path, transitionGoal(
|
|
16066
|
+
atomicWrite(goal.path, transitionGoal(readFileSync8(goal.path, "utf8"), "abandoned", nowIso()));
|
|
16952
16067
|
state.goalPath = undefined;
|
|
16953
16068
|
engineForgetSession(context.sessionID);
|
|
16954
16069
|
context.metadata({ title: `Goal abandoned: ${goal.doc.goal}` });
|
|
@@ -16965,7 +16080,6 @@ var FORGE_SHELL_DESCRIPTION = [
|
|
|
16965
16080
|
"Long-running or possibly non-exiting commands (dev servers, watchers, installers, anything spawning detached children) MUST use this tool instead of the builtin shell."
|
|
16966
16081
|
].join(`
|
|
16967
16082
|
`);
|
|
16968
|
-
var STAGE1_NOTE = "Stage note: this host's builtin shell already offers a native run_in_background parameter — prefer that for plain backgrounding; keep using forge_shell when you need idle/success_pattern early return or exit wake messages.";
|
|
16969
16083
|
var forgeShellTool = tool({
|
|
16970
16084
|
description: FORGE_SHELL_DESCRIPTION,
|
|
16971
16085
|
args: {
|
|
@@ -17084,6 +16198,7 @@ var forgeJobsTool = tool({
|
|
|
17084
16198
|
execute: async (args, context) => {
|
|
17085
16199
|
if (jobStage() >= 2)
|
|
17086
16200
|
throw new Error("[forge] The job supervisor is retired on this host.");
|
|
16201
|
+
noteToolAgent(context);
|
|
17087
16202
|
const action = String(args.action ?? "");
|
|
17088
16203
|
if (!FORGE_JOBS_ACTIONS.includes(action)) {
|
|
17089
16204
|
throw new Error(`Unknown action "${action}" — use one of: ${FORGE_JOBS_ACTIONS.join(", ")}.`);
|
|
@@ -17145,317 +16260,138 @@ ${p.newOutput || "(none in this window)"}`,
|
|
|
17145
16260
|
};
|
|
17146
16261
|
}
|
|
17147
16262
|
});
|
|
17148
|
-
|
|
17149
|
-
|
|
17150
|
-
|
|
17151
|
-
|
|
17152
|
-
|
|
17153
|
-
|
|
17154
|
-
function scheduleDispatchWake(sessionID, idleDriven) {
|
|
17155
|
-
if (dispatchDisabled || forgeDisabled)
|
|
17156
|
-
return;
|
|
17157
|
-
if (dispatchWakeTimers.has(sessionID))
|
|
17158
|
-
return;
|
|
17159
|
-
const t = setTimeout(() => {
|
|
17160
|
-
dispatchWakeTimers.delete(sessionID);
|
|
17161
|
-
injectDispatchBrief(sessionID);
|
|
17162
|
-
}, dispatchWakeDelay(idleDriven));
|
|
17163
|
-
dispatchWakeTimers.set(sessionID, t);
|
|
17164
|
-
}
|
|
17165
|
-
function dispatchBriefText(entries) {
|
|
17166
|
-
const lines = [`[forge:dispatch-complete] ${entries.length} background dispatch(es) reached terminal state:`];
|
|
17167
|
-
for (const e of entries) {
|
|
17168
|
-
lines.push("", `- ${e.dispatchId} · agent ${e.tier} · ${e.identity}${e.depth ? ` @${e.depth}` : ""} · ${e.state.toUpperCase()} · session ${e.sessionID || "(not yet started)"}`);
|
|
17169
|
-
const r = e.result;
|
|
17170
|
-
if (r !== undefined) {
|
|
17171
|
-
lines.push(` result: ${JSON.stringify(r, null, 1).replace(/\n/g, `
|
|
17172
|
-
`).slice(0, 2000)}`);
|
|
17173
|
-
}
|
|
17174
|
-
}
|
|
17175
|
-
return lines.join(`
|
|
17176
|
-
`);
|
|
17177
|
-
}
|
|
17178
|
-
async function injectDispatchBrief(sessionID) {
|
|
17179
|
-
const reg = activeDispatchRegistry;
|
|
17180
|
-
if (!reg)
|
|
17181
|
-
return;
|
|
17182
|
-
const c0 = dispatchClient;
|
|
17183
|
-
if (typeof c0?.session.status === "function") {
|
|
17184
|
-
try {
|
|
17185
|
-
const st = await c0.session.status({ path: { id: sessionID } });
|
|
17186
|
-
const t = st?.[sessionID]?.type;
|
|
17187
|
-
if (t && t !== "idle")
|
|
17188
|
-
return;
|
|
17189
|
-
} catch {
|
|
17190
|
-
return;
|
|
17191
|
-
}
|
|
17192
|
-
}
|
|
17193
|
-
const entries = reg.takeUndelivered(sessionID);
|
|
17194
|
-
if (entries.length === 0)
|
|
17195
|
-
return;
|
|
17196
|
-
const c = dispatchClient;
|
|
17197
|
-
const send = c?.session.promptAsync ?? c?.session.prompt;
|
|
17198
|
-
if (typeof send !== "function")
|
|
17199
|
-
return;
|
|
17200
|
-
try {
|
|
17201
|
-
await send.call(c.session, { path: { id: sessionID }, body: { parts: [{ type: "text", text: dispatchBriefText(entries) }] } });
|
|
17202
|
-
} catch {
|
|
17203
|
-
for (const e of entries)
|
|
17204
|
-
e.delivered = false;
|
|
17205
|
-
}
|
|
17206
|
-
}
|
|
17207
|
-
var forgeDispatchTool = tool({
|
|
17208
|
-
description: "Dispatch a scoped task to a worker subagent defined in forge.json. Takes {prompt, agent, depth}: agent names a dispatch agent (see forge_dispatch_config for the effective set); the agent's model is pinned by its definition — no fallback ever. depth comes from the canonical metalanguage (none, low, medium, high, max; default = the agent's first entry) or the model's native level names verbatim (escape hatch); the ONLY validation is membership in the agent's depths — the provider is the final judge and its raw errors flow back. The report discloses the depth translation (canonical -> native). Background mode targets live TUI sessions; under `opencode run` prefer sync. The result reports the actual model/depth, real token counts, a self-computed cost (null when unpriced), and the worker's concluding report. Refuses while a plan draft is active.",
|
|
17209
|
-
args: {
|
|
17210
|
-
prompt: tool.schema.string().describe("Complete, self-contained task for the worker (it cannot see this conversation)"),
|
|
17211
|
-
agent: tool.schema.string().describe("Dispatch agent id from forge.json (e.g. research, review, or a custom id; legacy inline tier ids still work when configured)"),
|
|
17212
|
-
depth: tool.schema.string().optional().describe("Reasoning depth: none | low | medium | high | max (canonical) or the model's native level name verbatim; omit to use the agent's first depths entry"),
|
|
17213
|
-
background: tool.schema.boolean().optional().describe("Run in the background: returns {dispatchId, resolved, queuedAt} immediately; the full result arrives later as a coalesced [forge:dispatch-complete] brief when the session is idle. Background mode targets live TUI sessions — under `opencode run` the session ends before any brief, so completions are ledger-only (read them via forge_dispatch_list) and sync mode is preferred.")
|
|
17214
|
-
},
|
|
17215
|
-
execute: async (args, context) => {
|
|
17216
|
-
const state = stateForBan(context.sessionID);
|
|
17217
|
-
const active = state ? resolveActivePlan(state) : null;
|
|
17218
|
-
if (active && active.doc.status === "draft") {
|
|
17219
|
-
throw new Error("[forge] A plan is in draft; dispatching workers is denied during planning. Call plan_approve for user approval, or /plan discard to abandon the plan.");
|
|
17220
|
-
}
|
|
17221
|
-
if (!args.prompt?.trim())
|
|
17222
|
-
throw new Error("forge_dispatch requires a non-empty prompt.");
|
|
17223
|
-
const engine = activeDispatchEngine;
|
|
17224
|
-
if (!engine)
|
|
17225
|
-
throw new Error("[forge] dispatch engine unavailable (disabled by dispatch.disable, or config still loading).");
|
|
17226
|
-
try {
|
|
17227
|
-
const r = await engine.dispatch({ prompt: args.prompt, agent: args.agent, ...args.depth !== undefined ? { depth: args.depth } : {} }, context.sessionID, args.background === true ? { background: true } : undefined);
|
|
17228
|
-
if ("dispatchId" in r) {
|
|
17229
|
-
context.metadata({ title: `dispatch ${r.agent} queued (${r.dispatchId})` });
|
|
17230
|
-
return {
|
|
17231
|
-
title: `dispatch ${r.agent} queued → ${r.resolved}${r.depth ? ` @${r.depth}` : ""}`,
|
|
17232
|
-
output: [
|
|
17233
|
-
`[forge:dispatch] accepted in the background: ${r.dispatchId}`,
|
|
17234
|
-
`agent: ${r.agent} resolved: ${r.resolved}${r.depth ? ` depth: ${r.depth}` : ""}`,
|
|
17235
|
-
`queuedAt: ${r.queuedAt}`,
|
|
17236
|
-
"",
|
|
17237
|
-
"The full result (tokens/cost/report — or the timeout/error report) arrives as a [forge:dispatch-complete] brief when this session is next idle. Track progress any time with forge_dispatch_list; cancel with forge_dispatch_kill."
|
|
17238
|
-
].join(`
|
|
17239
|
-
`)
|
|
17240
|
-
};
|
|
17241
|
-
}
|
|
17242
|
-
const tok = r.tokens;
|
|
17243
|
-
const costLine = r.costUsd !== null && r.costUsd !== undefined ? `$${r.costUsd.toFixed(6)}${r.priceSnapshot ? ` (${r.priceSnapshot})` : ""}` : `unpriced${r.costNote ? ` — ${r.costNote}` : ""}`;
|
|
17244
|
-
context.metadata({ title: `dispatch ${r.agent}: ${r.actual.model} @ ${r.actual.depth}` });
|
|
17245
|
-
return {
|
|
17246
|
-
title: `dispatch ${r.agent} → ${r.actual.model} @${r.actual.depth}`,
|
|
17247
|
-
output: [
|
|
17248
|
-
`[forge:dispatch] completed in ${r.durationMs}ms`,
|
|
17249
|
-
`agent: ${r.agent} model: ${r.actual.model} depth: ${r.actual.depth}${r.requested.depth !== undefined && r.requested.depth !== r.actual.depth ? ` (requested ${r.requested.depth})` : ""}`,
|
|
17250
|
-
`session: ${r.sessionID}`,
|
|
17251
|
-
`tokens: input ${tok.input ?? 0}, output ${tok.output ?? 0}, reasoning ${tok.reasoning ?? 0}, cache r/w ${tok.cache?.read ?? 0}/${tok.cache?.write ?? 0}`,
|
|
17252
|
-
`cost: ${costLine}`,
|
|
17253
|
-
`depthTranslation: ${r.depthTranslation}`,
|
|
17254
|
-
"",
|
|
17255
|
-
"Worker report:",
|
|
17256
|
-
r.text || "(empty worker report)"
|
|
17257
|
-
].join(`
|
|
17258
|
-
`)
|
|
17259
|
-
};
|
|
17260
|
-
} catch (err) {
|
|
17261
|
-
if (err instanceof DispatchError && err.code === "pin-unavailable") {
|
|
17262
|
-
throw new Error(`[forge:dispatch:pin-unavailable] ${err.message}
|
|
17263
|
-
|
|
17264
|
-
${dispatchRecipeText()}`);
|
|
17265
|
-
}
|
|
17266
|
-
if (err instanceof DispatchError)
|
|
17267
|
-
throw new Error(`[forge:dispatch:${err.code}] ${err.message}`);
|
|
17268
|
-
throw err;
|
|
17269
|
-
}
|
|
17270
|
-
}
|
|
17271
|
-
});
|
|
17272
|
-
function dispatchRecipeText() {
|
|
17273
|
-
const paths = forgeConfigPaths({ projectDir: forgeLoaderDir ?? undefined, homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() });
|
|
17274
|
-
return forgeRecipe({
|
|
17275
|
-
detectedIdentities: lastDetectedIdentities,
|
|
17276
|
-
projectPath: paths.project || "(project .opencode/forge.json)",
|
|
17277
|
-
globalPath: paths.global
|
|
17278
|
-
});
|
|
17279
|
-
}
|
|
17280
|
-
var forgeDispatchConfigTool = tool({
|
|
17281
|
-
description: "Read the effective forge dispatch configuration: {agents, knobs, findings}. agents mirrors the winning forge.json (project .opencode/forge.json > global ~/.config/opencode/forge.json > built-in seed) exactly — same keys, same shapes, writable back as valid forge.json agents content. knobs are the inline dispatch options {timeoutMs, maxConcurrent}. findings carry config state (config-parse-error, dispatch-unconfigured notice). Re-reads the file (mtime-cached) on every call, so edits are reflected immediately.",
|
|
17282
|
-
args: {},
|
|
17283
|
-
execute: async (_args, context) => {
|
|
17284
|
-
const loaded = forgeLoader?.load() ?? { agents: {}, source: "seed", path: null, findings: [] };
|
|
17285
|
-
context.metadata({ title: `dispatch config (${loaded.source})` });
|
|
17286
|
-
return {
|
|
17287
|
-
title: `dispatch config (${loaded.source}${loaded.path ? `: ${loaded.path}` : ""})`,
|
|
17288
|
-
output: JSON.stringify({
|
|
17289
|
-
agents: loaded.agents,
|
|
17290
|
-
knobs: { timeoutMs: dispatchTimeoutMs, maxConcurrent: dispatchMaxConcurrent },
|
|
17291
|
-
findings: loaded.findings
|
|
17292
|
-
}, null, 2)
|
|
17293
|
-
};
|
|
17294
|
-
}
|
|
17295
|
-
});
|
|
17296
|
-
var forgeDispatchListTool = tool({
|
|
17297
|
-
description: "List background forge_dispatch activity: in-flight dispatches (with dispatchIds) and recent terminal results (completed/timeout/error, full report objects). Use it to recover dispatch state after context compaction or to check on a background dispatch instead of waiting.",
|
|
17298
|
-
args: {},
|
|
17299
|
-
execute: async () => {
|
|
17300
|
-
const reg = activeDispatchRegistry;
|
|
17301
|
-
if (!reg)
|
|
17302
|
-
throw new Error("[forge] no background dispatches have been submitted.");
|
|
17303
|
-
const l = reg.list();
|
|
17304
|
-
if (l.inFlight.length === 0 && l.terminal.length === 0) {
|
|
17305
|
-
return { title: "no background dispatches", output: "[forge:dispatch-list] no background dispatches recorded in this session." };
|
|
17306
|
-
}
|
|
17307
|
-
const lines = [];
|
|
17308
|
-
if (l.inFlight.length > 0) {
|
|
17309
|
-
lines.push("In flight:");
|
|
17310
|
-
for (const e of l.inFlight) {
|
|
17311
|
-
lines.push(`- ${e.dispatchId} · agent ${e.tier} · ${e.identity}${e.depth ? ` @${e.depth}` : ""} · ${e.state} · queued ${e.queuedAt} · session ${e.sessionID || "(starting)"}`);
|
|
17312
|
-
}
|
|
17313
|
-
}
|
|
17314
|
-
if (l.terminal.length > 0) {
|
|
17315
|
-
lines.push("", "Recent terminal results:");
|
|
17316
|
-
for (const e of l.terminal.slice().reverse()) {
|
|
17317
|
-
lines.push(`- ${e.dispatchId} · agent ${e.tier} · ${e.identity}${e.depth ? ` @${e.depth}` : ""} · ${e.state.toUpperCase()} · session ${e.sessionID || "(not started)"}`);
|
|
17318
|
-
const r = e.result;
|
|
17319
|
-
if (r !== undefined)
|
|
17320
|
-
lines.push(` result: ${JSON.stringify(r, null, 1).slice(0, 2000)}`);
|
|
17321
|
-
if (e.lateCompletion !== undefined)
|
|
17322
|
-
lines.push(` (killed; a late result arrived after the kill and was discarded: ${JSON.stringify(e.lateCompletion).slice(0, 300)})`);
|
|
17323
|
-
}
|
|
17324
|
-
}
|
|
17325
|
-
return { title: `dispatches: ${l.inFlight.length} in flight, ${l.terminal.length} terminal`, output: lines.join(`
|
|
17326
|
-
`) };
|
|
17327
|
-
}
|
|
17328
|
-
});
|
|
17329
|
-
var forgeDispatchKillTool = tool({
|
|
17330
|
-
description: "Cancel an in-flight background forge_dispatch by dispatchId: polling stops, the completion brief is suppressed, and the outcome is ledgered as killed. Refuses honestly if the dispatch already finished (use forge_dispatch_list). The child session itself cannot be aborted on this host version and is left for the host to reclaim.",
|
|
17331
|
-
args: {
|
|
17332
|
-
dispatchId: tool.schema.string().describe("The dispatchId returned by forge_dispatch (background) or shown by forge_dispatch_list")
|
|
17333
|
-
},
|
|
17334
|
-
execute: async (args) => {
|
|
17335
|
-
const engine = activeDispatchEngine;
|
|
17336
|
-
if (!engine)
|
|
17337
|
-
throw new Error("[forge] dispatch engine unavailable.");
|
|
17338
|
-
try {
|
|
17339
|
-
engine.kill(args.dispatchId);
|
|
17340
|
-
} catch (err) {
|
|
17341
|
-
if (err instanceof DispatchError)
|
|
17342
|
-
throw new Error(`[forge:dispatch:${err.code}] ${err.message}`);
|
|
17343
|
-
throw err;
|
|
17344
|
-
}
|
|
17345
|
-
return {
|
|
17346
|
-
title: `${args.dispatchId} killed`,
|
|
17347
|
-
output: `[forge:dispatch] ${args.dispatchId}: killed — polling stopped, the completion brief is suppressed, and the outcome is ledgered. The child session cannot be aborted on this host and is left for the host to reclaim.`
|
|
17348
|
-
};
|
|
17349
|
-
}
|
|
17350
|
-
});
|
|
17351
|
-
var CREW_COMMAND_TEMPLATE = [
|
|
17352
|
-
"Objective: {args}",
|
|
16263
|
+
var CREW_INIT_TEMPLATE = [
|
|
16264
|
+
'(forge crew orchestration. Argument: "$ARGUMENTS")',
|
|
16265
|
+
"",
|
|
16266
|
+
"FAMILY GUARD (step 0, before anything else): if you do not have the crew_begin tool in your available tools, you are not in a forge session — STOP now and tell the user that /crew belongs to the forge agent, who should switch to forge (Tab) and rerun it there.",
|
|
16267
|
+
"",
|
|
16268
|
+
"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:",
|
|
17353
16269
|
"",
|
|
17354
|
-
"
|
|
16270
|
+
"1. The user configures it themselves: create ONE of these files (project-level wins, never merged):",
|
|
16271
|
+
"{paths}",
|
|
17355
16272
|
"",
|
|
17356
|
-
"
|
|
17357
|
-
"
|
|
17358
|
-
"
|
|
17359
|
-
"
|
|
17360
|
-
"
|
|
17361
|
-
"
|
|
16273
|
+
"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.",
|
|
16274
|
+
"",
|
|
16275
|
+
"Template (JSONC — comments allowed; `model` is the exact provider/model identity, `thoughtLevel` is optional: none/low/medium/high/max or a native level name):",
|
|
16276
|
+
"{template}",
|
|
16277
|
+
"",
|
|
16278
|
+
"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."
|
|
17362
16279
|
].join(`
|
|
17363
16280
|
`);
|
|
16281
|
+
function crewOrchestrationTemplate(agentIds) {
|
|
16282
|
+
return [
|
|
16283
|
+
'(forge crew orchestration. Argument: "$ARGUMENTS")',
|
|
16284
|
+
"",
|
|
16285
|
+
"FAMILY GUARD (step 0, before anything else): if you do not have the crew_begin tool in your available tools, you are not in a forge session — STOP now and tell the user that /crew belongs to the forge agent, who should switch to forge (Tab) and rerun it there.",
|
|
16286
|
+
"",
|
|
16287
|
+
`Configured forge subagents (task-tool vocabulary): ${agentIds.map((id) => `forge-${id}`).join(", ")}`,
|
|
16288
|
+
"",
|
|
16289
|
+
"You are entering CREW ORCHESTRATION discipline for this session (single subject: do not switch agents). Follow it exactly:",
|
|
16290
|
+
"",
|
|
16291
|
+
"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.",
|
|
16292
|
+
"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.",
|
|
16293
|
+
"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.",
|
|
16294
|
+
"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.",
|
|
16295
|
+
"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.",
|
|
16296
|
+
"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."
|
|
16297
|
+
].join(`
|
|
16298
|
+
`);
|
|
16299
|
+
}
|
|
17364
16300
|
var crewBeginTool = tool({
|
|
17365
|
-
description: "Register this session's crew (from /crew). Refuses when a crew is already active in the session
|
|
16301
|
+
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.",
|
|
17366
16302
|
args: {
|
|
17367
|
-
objective: tool.schema.string().describe("One-line crew objective (from /crew)")
|
|
16303
|
+
objective: tool.schema.string().describe("One-line crew objective (from /crew)"),
|
|
16304
|
+
subtasks: tool.schema.array(tool.schema.object({
|
|
16305
|
+
title: tool.schema.string().describe("Short unique subtask title — the crew_close report must use these exact titles"),
|
|
16306
|
+
agent: tool.schema.string().optional().describe("Intended forge-* agent id for this subtask, when one matches")
|
|
16307
|
+
})).describe("The declared subtask plan — decompose the objective into these before any execution; crew_close cross-checks the report against exactly this list")
|
|
17368
16308
|
},
|
|
17369
16309
|
execute: async (args, context) => {
|
|
16310
|
+
noteToolAgent(context);
|
|
17370
16311
|
const state = stateForBan(context.sessionID);
|
|
17371
16312
|
const active = state ? resolveActivePlan(state) : null;
|
|
17372
16313
|
if (active && active.doc.status === "draft") {
|
|
17373
16314
|
throw new Error("[forge:crew] A plan is in draft; crew orchestration is denied during planning. Call plan_approve for user approval, or /plan discard to abandon the plan.");
|
|
17374
16315
|
}
|
|
16316
|
+
const loaded = forgeLoader?.load();
|
|
16317
|
+
if (!loaded || Object.keys(loaded.agents).length === 0) {
|
|
16318
|
+
const paths = forgeConfigPaths({ projectDir: forgeLoaderDir ?? undefined, homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() });
|
|
16319
|
+
throw new Error([
|
|
16320
|
+
"[forge:crew] CREW IS NOT INITIALIZED: no forge subagents are configured. Crew orchestration is unavailable until the user sets it up.",
|
|
16321
|
+
`Create ONE of these files (project-level wins): ${paths.project} OR ${paths.global}`,
|
|
16322
|
+
'Template (JSONC): {"agents": {"research": {"model": "provider/model", "thoughtLevel": "low"}}}',
|
|
16323
|
+
"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."
|
|
16324
|
+
].join(`
|
|
16325
|
+
`));
|
|
16326
|
+
}
|
|
17375
16327
|
if (!args.objective?.trim())
|
|
17376
16328
|
throw new Error("crew_begin requires a non-empty objective (from /crew <objective>).");
|
|
16329
|
+
const raw = Array.isArray(args.subtasks) ? args.subtasks : [];
|
|
16330
|
+
if (raw.length === 0)
|
|
16331
|
+
throw new Error("[forge:crew] crew_begin requires subtasks[] — declare the plan before executing (decompose the objective into titled subtasks).");
|
|
16332
|
+
const seen = new Set;
|
|
16333
|
+
const subtasks = [];
|
|
16334
|
+
for (const s of raw) {
|
|
16335
|
+
const title = String(s?.title ?? "").trim();
|
|
16336
|
+
if (!title)
|
|
16337
|
+
throw new Error("[forge:crew] every subtask needs a non-empty title.");
|
|
16338
|
+
const key = title.toLowerCase();
|
|
16339
|
+
if (seen.has(key))
|
|
16340
|
+
throw new Error(`[forge:crew] duplicate subtask title "${title}" — titles must be unique (they are the crew_close matching key).`);
|
|
16341
|
+
seen.add(key);
|
|
16342
|
+
const agent = String(s?.agent ?? "").trim();
|
|
16343
|
+
subtasks.push(agent ? { title, agent } : { title });
|
|
16344
|
+
}
|
|
17377
16345
|
const existing = crews.get(context.sessionID);
|
|
17378
16346
|
if (existing) {
|
|
17379
16347
|
throw new Error(`[forge:crew] This session already has an active crew: "${existing.objective}" (started ${existing.startedAt}). Finish and close it with crew_close before starting another.`);
|
|
17380
16348
|
}
|
|
17381
|
-
crews.set(context.sessionID, { objective: args.objective.trim(), startedAt: nowIso() });
|
|
16349
|
+
crews.set(context.sessionID, { objective: args.objective.trim(), startedAt: nowIso(), subtasks });
|
|
17382
16350
|
return {
|
|
17383
16351
|
title: `crew registered: ${args.objective.trim().slice(0, 60)}`,
|
|
17384
|
-
output:
|
|
16352
|
+
output: [
|
|
16353
|
+
`[forge:crew] Crew registered for this session. Objective: ${args.objective.trim()}`,
|
|
16354
|
+
`Declared plan (${subtasks.length}):`,
|
|
16355
|
+
...subtasks.map((s, i) => ` ${i + 1}. ${s.title}${s.agent ? ` → ${s.agent}` : ""}`),
|
|
16356
|
+
"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."
|
|
16357
|
+
].join(`
|
|
16358
|
+
`)
|
|
17385
16359
|
};
|
|
17386
16360
|
}
|
|
17387
16361
|
});
|
|
17388
16362
|
var crewCloseTool = tool({
|
|
17389
|
-
description: "Close this session's crew with the final report (ask-level user confirmation). The gate cross-checks the report against the
|
|
16363
|
+
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).",
|
|
17390
16364
|
args: {
|
|
17391
16365
|
report: tool.schema.array(tool.schema.object({
|
|
17392
|
-
title: tool.schema.string().describe("Subtask title"),
|
|
16366
|
+
title: tool.schema.string().describe("Subtask title — must match a title declared at crew_begin"),
|
|
17393
16367
|
verdict: tool.schema.string().describe("PASS or FAIL"),
|
|
17394
|
-
evidence: tool.schema.string().describe("Acceptance evidence
|
|
16368
|
+
evidence: tool.schema.string().describe("Acceptance evidence: what the result contained that satisfies the acceptance statement"),
|
|
17395
16369
|
attempts: tool.schema.array(tool.schema.string()).optional().describe("For FAIL: both failure reports (attempt and retry)")
|
|
17396
|
-
})).describe("The full crew report — one entry per subtask")
|
|
16370
|
+
})).describe("The full crew report — one entry per declared subtask")
|
|
17397
16371
|
},
|
|
17398
16372
|
execute: async (args, context) => {
|
|
16373
|
+
noteToolAgent(context);
|
|
17399
16374
|
const crew = crews.get(context.sessionID);
|
|
17400
16375
|
if (!crew)
|
|
17401
16376
|
throw new Error("[forge:crew] No active crew in this session (crew state is in-memory and dies on host restart). Start one with /crew <objective>.");
|
|
17402
|
-
const
|
|
17403
|
-
|
|
17404
|
-
const file2 = readFileSync9(join5(dispatchLogDir, "ledger.jsonl"), "utf8");
|
|
17405
|
-
for (const line of file2.split(`
|
|
17406
|
-
`)) {
|
|
17407
|
-
const t = line.trim();
|
|
17408
|
-
if (!t)
|
|
17409
|
-
continue;
|
|
17410
|
-
try {
|
|
17411
|
-
rows.push(JSON.parse(t));
|
|
17412
|
-
} catch {}
|
|
17413
|
-
}
|
|
17414
|
-
} catch {}
|
|
17415
|
-
const check2 = validateCrewReport(args.report ?? [], rows, { objective: crew.objective, startedAt: crew.startedAt, sessionID: context.sessionID });
|
|
16377
|
+
const report = args.report ?? [];
|
|
16378
|
+
const check2 = validateCrewReport(report, crew.subtasks);
|
|
17416
16379
|
if (!check2.ok) {
|
|
17417
|
-
throw new Error(`[forge:crew] crew_close refused — the report does not match the
|
|
16380
|
+
throw new Error(`[forge:crew] crew_close refused — the report does not match the declared plan:
|
|
17418
16381
|
${check2.gaps.map((g) => `- ${g}`).join(`
|
|
17419
16382
|
`)}
|
|
17420
16383
|
The crew stays active; complete or correct the report and call crew_close again.`);
|
|
17421
16384
|
}
|
|
17422
|
-
let tokensIn = 0;
|
|
17423
|
-
let tokensOut = 0;
|
|
17424
|
-
let costSum = 0;
|
|
17425
|
-
let costKnown = 0;
|
|
17426
|
-
for (const row of rows) {
|
|
17427
|
-
if (typeof row.dispatchId !== "string" || !check2.dispatchIds.includes(row.dispatchId))
|
|
17428
|
-
continue;
|
|
17429
|
-
const tok = row.tokens;
|
|
17430
|
-
tokensIn += tok?.input ?? 0;
|
|
17431
|
-
tokensOut += (tok?.output ?? 0) + (tok?.reasoning ?? 0);
|
|
17432
|
-
if (typeof row.costUsd === "number") {
|
|
17433
|
-
costSum += row.costUsd;
|
|
17434
|
-
costKnown++;
|
|
17435
|
-
}
|
|
17436
|
-
}
|
|
17437
16385
|
await gate(context.ask, "crew_close", `Close crew: ${crew.objective}`);
|
|
17438
|
-
dispatchLedger.append({
|
|
17439
|
-
ts: nowIso(),
|
|
17440
|
-
event: "crew-summary",
|
|
17441
|
-
tier: "crew",
|
|
17442
|
-
outcome: "closed",
|
|
17443
|
-
sessionID: context.sessionID,
|
|
17444
|
-
note: crew.objective,
|
|
17445
|
-
tokens: { input: tokensIn, output: tokensOut, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
17446
|
-
costUsd: costKnown > 0 ? costSum : null,
|
|
17447
|
-
dispatchIds: check2.dispatchIds,
|
|
17448
|
-
subtasks: (args.report ?? []).map((r) => ({ title: r.title, verdict: String(r.verdict).toUpperCase(), evidence: r.evidence }))
|
|
17449
|
-
});
|
|
17450
16386
|
crews.delete(context.sessionID);
|
|
17451
|
-
const fails =
|
|
16387
|
+
const fails = report.filter((r) => String(r.verdict).toUpperCase() === "FAIL").length;
|
|
17452
16388
|
return {
|
|
17453
16389
|
title: `crew closed: ${crew.objective.slice(0, 60)}`,
|
|
17454
16390
|
output: [
|
|
17455
16391
|
`[forge:crew] Crew closed: ${crew.objective}`,
|
|
17456
|
-
`subtasks: ${
|
|
17457
|
-
|
|
17458
|
-
"
|
|
16392
|
+
`subtasks: ${report.length} (PASS ${report.length - fails}, FAIL ${fails})`,
|
|
16393
|
+
...report.map((r) => `- ${r.title}: ${String(r.verdict).toUpperCase()} — ${String(r.evidence).slice(0, 200)}`),
|
|
16394
|
+
"This output is the crew's record (crew state is in-memory; nothing persists)."
|
|
17459
16395
|
].join(`
|
|
17460
16396
|
`)
|
|
17461
16397
|
};
|
|
@@ -17479,11 +16415,7 @@ function forgeTools() {
|
|
|
17479
16415
|
tools.forge_shell = forgeShellTool;
|
|
17480
16416
|
tools.forge_jobs = forgeJobsTool;
|
|
17481
16417
|
}
|
|
17482
|
-
if (!
|
|
17483
|
-
tools.forge_dispatch = forgeDispatchTool;
|
|
17484
|
-
tools.forge_dispatch_config = forgeDispatchConfigTool;
|
|
17485
|
-
tools.forge_dispatch_list = forgeDispatchListTool;
|
|
17486
|
-
tools.forge_dispatch_kill = forgeDispatchKillTool;
|
|
16418
|
+
if (!forgeDisabled) {
|
|
17487
16419
|
tools.crew_begin = crewBeginTool;
|
|
17488
16420
|
tools.crew_close = crewCloseTool;
|
|
17489
16421
|
}
|
|
@@ -17556,7 +16488,7 @@ function wrapupBriefText(goal, reason) {
|
|
|
17556
16488
|
}
|
|
17557
16489
|
async function autoPauseGoal(client, state, goal, reason, wrapup) {
|
|
17558
16490
|
goalProbe(`auto-pause session=${state.sessionID} reason=${reason} wrapup=${wrapup}`);
|
|
17559
|
-
atomicWrite(goal.path, transitionGoal(
|
|
16491
|
+
atomicWrite(goal.path, transitionGoal(readFileSync8(goal.path, "utf8"), "paused", nowIso(), { stopReason: reason }));
|
|
17560
16492
|
engineForgetSession(state.sessionID);
|
|
17561
16493
|
if (wrapup && goal.doc.session) {
|
|
17562
16494
|
try {
|
|
@@ -17605,9 +16537,9 @@ async function continueIfEligible(client, sessionID) {
|
|
|
17605
16537
|
const ledgerPath = state.goalPath;
|
|
17606
16538
|
if (ledgerPath && existsSync3(ledgerPath)) {
|
|
17607
16539
|
try {
|
|
17608
|
-
const fresh = parseGoalLoose(
|
|
16540
|
+
const fresh = parseGoalLoose(readFileSync8(ledgerPath, "utf8"));
|
|
17609
16541
|
if (fresh && fresh.turnsUsed > 0) {
|
|
17610
|
-
atomicWrite(ledgerPath, appendLedger(
|
|
16542
|
+
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()));
|
|
17611
16543
|
}
|
|
17612
16544
|
} catch (err) {
|
|
17613
16545
|
goalProbe(`ledger append failed session=${sessionID} err=${String(err)}`);
|
|
@@ -17623,6 +16555,10 @@ async function continueIfEligible(client, sessionID) {
|
|
|
17623
16555
|
goalProbe(`skip: not goal owner session=${sessionID} owner=${goal.doc.session || "(none)"}`);
|
|
17624
16556
|
return;
|
|
17625
16557
|
}
|
|
16558
|
+
if (!sessionIsForgeFamily(sessionID)) {
|
|
16559
|
+
goalProbe(`skip: agent switched to non-forge before send session=${sessionID}`);
|
|
16560
|
+
return;
|
|
16561
|
+
}
|
|
17626
16562
|
if (accountedContinuationTurn) {
|
|
17627
16563
|
if (turnHadActivity) {
|
|
17628
16564
|
noProgressStreak.delete(sessionID);
|
|
@@ -17660,10 +16596,7 @@ async function continueIfEligible(client, sessionID) {
|
|
|
17660
16596
|
}
|
|
17661
16597
|
}
|
|
17662
16598
|
try {
|
|
17663
|
-
const
|
|
17664
|
-
const briefText = wakeEntries.length > 0 ? `${dispatchBriefText(wakeEntries)}
|
|
17665
|
-
|
|
17666
|
-
${goalBriefText(state, goal)}` : goalBriefText(state, goal);
|
|
16599
|
+
const briefText = goalBriefText(state, goal);
|
|
17667
16600
|
await client.session.prompt({
|
|
17668
16601
|
path: { id: sessionID },
|
|
17669
16602
|
body: { parts: [{ type: "text", text: briefText }] }
|
|
@@ -17672,7 +16605,7 @@ ${goalBriefText(state, goal)}` : goalBriefText(state, goal);
|
|
|
17672
16605
|
pendingContinuationTurn.add(sessionID);
|
|
17673
16606
|
turnActivity.set(sessionID, { writes: 0, checks: 0 });
|
|
17674
16607
|
state.goalPath = goal.path;
|
|
17675
|
-
atomicWrite(goal.path, incTurns(
|
|
16608
|
+
atomicWrite(goal.path, incTurns(readFileSync8(goal.path, "utf8"), nowIso()));
|
|
17676
16609
|
goalProbe(`continued session=${sessionID} turn=${goal.doc.turnsUsed + 1}/${goal.doc.maxTurns}`);
|
|
17677
16610
|
} catch (err) {
|
|
17678
16611
|
const n = (transportFails.get(sessionID) ?? 0) + 1;
|
|
@@ -17691,6 +16624,10 @@ ${goalBriefText(state, goal)}` : goalBriefText(state, goal);
|
|
|
17691
16624
|
function scheduleIdleContinuation(client, sessionID) {
|
|
17692
16625
|
if (forgeDisabled)
|
|
17693
16626
|
return;
|
|
16627
|
+
if (!sessionIsForgeFamily(sessionID)) {
|
|
16628
|
+
goalProbe(`skip: non-forge agent at schedule session=${sessionID}`);
|
|
16629
|
+
return;
|
|
16630
|
+
}
|
|
17694
16631
|
const existing = idleTimers.get(sessionID);
|
|
17695
16632
|
if (existing)
|
|
17696
16633
|
clearTimeout(existing);
|
|
@@ -17703,7 +16640,6 @@ var server = async (input, options) => {
|
|
|
17703
16640
|
hostWorktree = effectiveWorktree(input.worktree, input.directory) || input.directory || "";
|
|
17704
16641
|
const client = input.client;
|
|
17705
16642
|
jobClient = input.client;
|
|
17706
|
-
dispatchClient = input.client;
|
|
17707
16643
|
const jobsOpts = options?.jobs;
|
|
17708
16644
|
if (jobsOpts?.mode === "auto" || jobsOpts?.mode === "forge" || jobsOpts?.mode === "native")
|
|
17709
16645
|
jobsMode = jobsOpts.mode;
|
|
@@ -17721,18 +16657,6 @@ var server = async (input, options) => {
|
|
|
17721
16657
|
if (wdStallRaw !== undefined && wdStallRaw !== wdStall) {
|
|
17722
16658
|
wdFallbacks.push(`watchdog.stallMs ${JSON.stringify(String(wdStallRaw))} adjusted to ${wdStall} (floor/default applied)`);
|
|
17723
16659
|
}
|
|
17724
|
-
const dpOpts = options?.dispatch;
|
|
17725
|
-
dispatchDisabled = dpOpts?.disable === true;
|
|
17726
|
-
if (Number.isInteger(dpOpts?.maxConcurrent) && dpOpts.maxConcurrent >= 1 && dpOpts.maxConcurrent <= 16) {
|
|
17727
|
-
dispatchMaxConcurrent = dpOpts.maxConcurrent;
|
|
17728
|
-
} else if (dpOpts?.maxConcurrent !== undefined) {
|
|
17729
|
-
dispatchMaxConcurrent = 4;
|
|
17730
|
-
}
|
|
17731
|
-
if (Number.isInteger(dpOpts?.timeoutMs) && dpOpts.timeoutMs >= 1000 && dpOpts.timeoutMs <= 600000) {
|
|
17732
|
-
dispatchTimeoutMs = dpOpts.timeoutMs;
|
|
17733
|
-
} else if (dpOpts?.timeoutMs !== undefined) {
|
|
17734
|
-
dispatchTimeoutMs = 600000;
|
|
17735
|
-
}
|
|
17736
16660
|
const watchdogLedger = createFileLedger(join5(watchdogLogDir(), "log.jsonl"));
|
|
17737
16661
|
const rawLocator = createLocator();
|
|
17738
16662
|
const probing = () => process.env.FORGE_WATCHDOG_PROBE === "1";
|
|
@@ -17788,7 +16712,6 @@ var server = async (input, options) => {
|
|
|
17788
16712
|
exitCleanup?.trigger("dispose");
|
|
17789
16713
|
jobManager.disposeAll();
|
|
17790
16714
|
watchdog.dispose();
|
|
17791
|
-
activeDispatchEngine?.dispose();
|
|
17792
16715
|
sessions.clear();
|
|
17793
16716
|
},
|
|
17794
16717
|
config: async (cfg) => {
|
|
@@ -17797,23 +16720,15 @@ var server = async (input, options) => {
|
|
|
17797
16720
|
forgeDisabled = forgeUserCfg?.disable === true;
|
|
17798
16721
|
if (forgeDisabled)
|
|
17799
16722
|
return;
|
|
17800
|
-
|
|
17801
|
-
agentSection[native] = { ...agentSection[native] ?? {}, disable: true };
|
|
17802
|
-
}
|
|
16723
|
+
cfg.default_agent ??= FORGE_AGENT;
|
|
17803
16724
|
const existing = agentSection[FORGE_AGENT];
|
|
17804
|
-
const userDefinedForge = existing !== undefined;
|
|
17805
16725
|
agentSection[FORGE_AGENT] = {
|
|
17806
16726
|
...existing ?? {},
|
|
17807
16727
|
description: existing?.description ?? "forge — the single general-purpose coding agent: takes implementation tasks directly; planning goes through /plan into the plan harness (plans land in .opencode/plan/, with approve/close confirmation gates and tick discipline enforced by tools and the permission layer).",
|
|
17808
16728
|
mode: existing?.mode ?? "primary",
|
|
17809
16729
|
prompt: existing?.prompt ?? FORGE_PROMPT
|
|
17810
16730
|
};
|
|
17811
|
-
|
|
17812
|
-
const exp = JSON.stringify(cfg.experimental ?? "");
|
|
17813
|
-
if (/background/i.test(exp))
|
|
17814
|
-
nativeBackgroundSeen = true;
|
|
17815
|
-
} catch {}
|
|
17816
|
-
if (!userDefinedForge && !jobsKeepBuiltinShell && jobStage() < 1) {
|
|
16731
|
+
if (!jobsKeepBuiltinShell && jobStage() < 2) {
|
|
17817
16732
|
const entry = agentSection[FORGE_AGENT];
|
|
17818
16733
|
entry.tools = { ...entry.tools ?? {}, shell: false, bash: false };
|
|
17819
16734
|
}
|
|
@@ -17822,10 +16737,6 @@ var server = async (input, options) => {
|
|
|
17822
16737
|
template: PLAN_COMMAND_TEMPLATE,
|
|
17823
16738
|
description: "forge plan harness: no argument lists in-progress plans; resume continues the latest; discard abandons it; a goal enters planning discipline"
|
|
17824
16739
|
};
|
|
17825
|
-
cfg.command["crew"] ??= {
|
|
17826
|
-
template: CREW_COMMAND_TEMPLATE,
|
|
17827
|
-
description: "forge crew orchestration: decompose an objective into evidence-checked subtasks dispatched as paced background waves; at most one retry per subtask; crew_close cross-checks the report against the dispatch ledger"
|
|
17828
|
-
};
|
|
17829
16740
|
cfg.command["goal"] ??= {
|
|
17830
16741
|
template: GOAL_COMMAND_TEMPLATE,
|
|
17831
16742
|
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)"
|
|
@@ -17836,75 +16747,75 @@ var server = async (input, options) => {
|
|
|
17836
16747
|
if (permSection[gateKey] !== "deny")
|
|
17837
16748
|
permSection[gateKey] = "ask";
|
|
17838
16749
|
}
|
|
17839
|
-
if (!
|
|
17840
|
-
|
|
17841
|
-
|
|
17842
|
-
|
|
17843
|
-
if (!forgeLoader || forgeLoaderDir !== hostWorktree) {
|
|
17844
|
-
forgeLoader = createForgeConfigLoader({
|
|
17845
|
-
projectDir: hostWorktree,
|
|
17846
|
-
homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir()
|
|
17847
|
-
});
|
|
17848
|
-
forgeLoaderDir = hostWorktree;
|
|
17849
|
-
}
|
|
17850
|
-
const loaded = forgeLoader.load();
|
|
17851
|
-
forgeConfigState = loaded;
|
|
17852
|
-
for (const finding of loaded.findings) {
|
|
17853
|
-
dispatchLedger.append({ ts: new Date().toISOString(), event: "validation", level: finding.level, code: finding.code, note: finding.message });
|
|
17854
|
-
}
|
|
17855
|
-
for (const [agentId, def] of Object.entries(loaded.agents)) {
|
|
17856
|
-
const id = `forge-${agentId}`;
|
|
17857
|
-
if (agentSection[id] !== undefined)
|
|
17858
|
-
continue;
|
|
17859
|
-
agentSection[id] = forgeAgentDef(agentId, def);
|
|
17860
|
-
}
|
|
17861
|
-
const detected = configuredIdentitiesOf(cfg, snapshot.catalog);
|
|
17862
|
-
lastDetectedIdentities = detected;
|
|
17863
|
-
const built = buildDispatchConfig({
|
|
17864
|
-
configuredIdentities: detected,
|
|
17865
|
-
catalog: snapshot.catalog,
|
|
17866
|
-
userRoster: legacyInline ? dispatchOpts.roster ?? [] : [],
|
|
17867
|
-
userTiers: legacyInline ? dispatchOpts.tiers : undefined
|
|
16750
|
+
if (!forgeLoader || forgeLoaderDir !== hostWorktree) {
|
|
16751
|
+
forgeLoader = createForgeConfigLoader({
|
|
16752
|
+
projectDir: hostWorktree,
|
|
16753
|
+
homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir()
|
|
17868
16754
|
});
|
|
17869
|
-
|
|
17870
|
-
|
|
17871
|
-
|
|
17872
|
-
|
|
17873
|
-
|
|
17874
|
-
|
|
17875
|
-
|
|
17876
|
-
|
|
17877
|
-
|
|
17878
|
-
|
|
17879
|
-
|
|
17880
|
-
|
|
17881
|
-
|
|
17882
|
-
|
|
16755
|
+
forgeLoaderDir = hostWorktree;
|
|
16756
|
+
}
|
|
16757
|
+
const loaded = forgeLoader.load();
|
|
16758
|
+
for (const finding of loaded.findings) {
|
|
16759
|
+
console.warn(`[forge:config] ${finding.level}: ${finding.message}`);
|
|
16760
|
+
}
|
|
16761
|
+
materializedSnapshot = {};
|
|
16762
|
+
for (const [agentId, def] of Object.entries(loaded.agents)) {
|
|
16763
|
+
const id = `forge-${agentId}`;
|
|
16764
|
+
if (agentSection[id] !== undefined)
|
|
16765
|
+
continue;
|
|
16766
|
+
const entry = forgeAgentDef(agentId, def);
|
|
16767
|
+
agentSection[id] = entry;
|
|
16768
|
+
materializedSnapshot[id] = entry;
|
|
16769
|
+
}
|
|
16770
|
+
for (const id of Object.keys(agentSection)) {
|
|
16771
|
+
if (!id.startsWith("forge-"))
|
|
16772
|
+
continue;
|
|
16773
|
+
const entry = agentSection[id];
|
|
16774
|
+
const tools = { ...entry.tools ?? {} };
|
|
16775
|
+
for (const t of FORGE_STATE_TOOLS)
|
|
16776
|
+
if (tools[t] !== true)
|
|
16777
|
+
tools[t] = false;
|
|
16778
|
+
entry.tools = tools;
|
|
16779
|
+
}
|
|
16780
|
+
for (const [id, entry] of Object.entries(agentSection)) {
|
|
16781
|
+
if (id === FORGE_AGENT || id.startsWith("forge-"))
|
|
16782
|
+
continue;
|
|
16783
|
+
const e = entry;
|
|
16784
|
+
const tools = { ...e.tools ?? {} };
|
|
16785
|
+
for (const t of FORGE_ALL_TOOLS)
|
|
16786
|
+
if (tools[t] !== true)
|
|
16787
|
+
tools[t] = false;
|
|
16788
|
+
e.tools = tools;
|
|
16789
|
+
}
|
|
16790
|
+
for (const native of NATIVE_PARTITION_FALLBACKS) {
|
|
16791
|
+
if (agentSection[native] !== undefined)
|
|
16792
|
+
continue;
|
|
16793
|
+
const tools = {};
|
|
16794
|
+
for (const t of FORGE_ALL_TOOLS)
|
|
16795
|
+
tools[t] = false;
|
|
16796
|
+
agentSection[native] = { tools };
|
|
16797
|
+
}
|
|
16798
|
+
if (process.env.FORGE_TEST_NO_DISPATCH_FETCH === "1") {
|
|
16799
|
+
modelsDevCatalog = null;
|
|
16800
|
+
} else {
|
|
16801
|
+
try {
|
|
16802
|
+
const snapshot = await loadModelsDevSnapshot();
|
|
16803
|
+
modelsDevCatalog = snapshot.catalog;
|
|
16804
|
+
} catch {
|
|
16805
|
+
modelsDevCatalog = null;
|
|
17883
16806
|
}
|
|
17884
|
-
|
|
17885
|
-
|
|
17886
|
-
|
|
17887
|
-
|
|
17888
|
-
|
|
17889
|
-
|
|
17890
|
-
|
|
17891
|
-
|
|
17892
|
-
|
|
17893
|
-
|
|
17894
|
-
|
|
17895
|
-
|
|
17896
|
-
return roster.roster.some((e) => e.model === identity) || lastDetectedIdentities.includes(identity);
|
|
17897
|
-
},
|
|
17898
|
-
agents: () => forgeLoader?.load().agents ?? {},
|
|
17899
|
-
legacy: legacyInline,
|
|
17900
|
-
sink: (entry) => dispatchLedger.append(entry),
|
|
17901
|
-
registry: activeDispatchRegistry,
|
|
17902
|
-
onTerminal: (parentSessionID) => scheduleDispatchWake(parentSessionID, false),
|
|
17903
|
-
onDepth: queueDispatchDepth,
|
|
17904
|
-
providerFamily: dispatchProviderFamily,
|
|
17905
|
-
maxConcurrent: dispatchMaxConcurrent,
|
|
17906
|
-
timeoutMs: dispatchTimeoutMs
|
|
17907
|
-
});
|
|
16807
|
+
}
|
|
16808
|
+
cfg.command ??= {};
|
|
16809
|
+
const existingCrew = cfg.command["crew"];
|
|
16810
|
+
if (!existingCrew || String(existingCrew.template ?? "").startsWith("(forge crew")) {
|
|
16811
|
+
const agentIds = Object.keys(loaded.agents);
|
|
16812
|
+
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}
|
|
16813
|
+
- ${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(`
|
|
16814
|
+
`).map((l) => " " + l).join(`
|
|
16815
|
+
`));
|
|
16816
|
+
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";
|
|
16817
|
+
cfg.command["crew"] = { template, description };
|
|
16818
|
+
crewCommandSnapshot = { template, description };
|
|
17908
16819
|
}
|
|
17909
16820
|
},
|
|
17910
16821
|
get tool() {
|
|
@@ -17917,7 +16828,7 @@ var server = async (input, options) => {
|
|
|
17917
16828
|
`);
|
|
17918
16829
|
} catch {}
|
|
17919
16830
|
}
|
|
17920
|
-
if (watchdog.mode !== "off" && (input2.tool === "shell" || input2.tool === "bash")) {
|
|
16831
|
+
if (watchdog.mode !== "off" && (input2.tool === "shell" || input2.tool === "bash") && sessionIsForgeFamily(input2.sessionID)) {
|
|
17921
16832
|
const a = output?.args ?? {};
|
|
17922
16833
|
const cmdText = typeof a.command === "string" ? a.command : typeof a.cmd === "string" ? a.cmd : undefined;
|
|
17923
16834
|
watchdog.track(input2.callID, input2.sessionID, input2.tool, undefined, cmdText !== undefined ? commandNeedle(cmdText) : undefined);
|
|
@@ -17928,39 +16839,105 @@ var server = async (input, options) => {
|
|
|
17928
16839
|
} catch {}
|
|
17929
16840
|
}
|
|
17930
16841
|
}
|
|
16842
|
+
if (input2.tool === "task") {
|
|
16843
|
+
const speaker = sessionAgents.get(input2.sessionID);
|
|
16844
|
+
if (speaker !== undefined && !isForgeFamilyAgent(speaker)) {
|
|
16845
|
+
const a = output?.args ?? {};
|
|
16846
|
+
const target = typeof a.subagent_type === "string" ? a.subagent_type : typeof a.agent === "string" ? a.agent : undefined;
|
|
16847
|
+
if (target && target.toLowerCase().startsWith("forge-")) {
|
|
16848
|
+
throw new Error(`[forge:partition] Dispatch refused: "${target}" is a forge-family worker and the current agent ("${speaker}") is outside the forge family. Use the native general/explore subagents, or switch to the forge agent to dispatch forge-* workers.`);
|
|
16849
|
+
}
|
|
16850
|
+
}
|
|
16851
|
+
}
|
|
16852
|
+
if ((input2.tool === "write" || input2.tool === "edit") && input2.sessionID) {
|
|
16853
|
+
const speaker = sessionAgents.get(input2.sessionID);
|
|
16854
|
+
if (speaker !== undefined && !isForgeFamilyAgent(speaker)) {
|
|
16855
|
+
const a = output?.args ?? {};
|
|
16856
|
+
const raw = typeof a.filePath === "string" ? a.filePath : typeof a.path === "string" ? a.path : undefined;
|
|
16857
|
+
if (raw) {
|
|
16858
|
+
const state = stateForBan(input2.sessionID);
|
|
16859
|
+
const base = state?.worktree ?? hostWorktree ?? "";
|
|
16860
|
+
const abs = (isAbsolute(raw) ? raw : join5(base, raw)).replaceAll("\\", "/").toLowerCase();
|
|
16861
|
+
if (abs.includes("/.opencode/plan/") || abs.includes("/.opencode/goal/")) {
|
|
16862
|
+
throw new Error(`[forge:partition] Write refused: "${raw}" is inside the forge state directories (.opencode/plan, .opencode/goal), which belong to the forge agent in this workspace.`);
|
|
16863
|
+
}
|
|
16864
|
+
}
|
|
16865
|
+
}
|
|
16866
|
+
}
|
|
17931
16867
|
if (typeof input2.tool === "string" && isWriteTool(input2.tool)) {
|
|
17932
16868
|
const state = stateForBan(input2.sessionID);
|
|
17933
16869
|
const active = state ? resolveActivePlan(state) : null;
|
|
17934
16870
|
if (active && active.doc.status === "draft") {
|
|
17935
|
-
throw new Error(`[forge] A plan is in draft; write operations are denied (${input2.tool}).
|
|
16871
|
+
throw new Error(`[forge] A plan is in draft; write operations are denied (${input2.tool}). Switch back to the forge agent to present it and complete plan approval (plan_approve), or run /plan discard to abandon the plan.`);
|
|
17936
16872
|
}
|
|
17937
16873
|
}
|
|
17938
|
-
if (input2.tool === "
|
|
17939
|
-
const
|
|
17940
|
-
|
|
17941
|
-
|
|
17942
|
-
|
|
16874
|
+
if (input2.sessionID && typeof input2.tool === "string") {
|
|
16875
|
+
const speaker = sessionAgents.get(input2.sessionID);
|
|
16876
|
+
if (speaker !== undefined) {
|
|
16877
|
+
if (!isForgeFamilyAgent(speaker) && FORGE_TOOL_SET.has(input2.tool)) {
|
|
16878
|
+
throw new Error(`[forge:partition] Tool refused: "${input2.tool}" belongs to the forge agent family and the current agent ("${speaker}") is outside it. Switch to forge (Tab) to use forge tooling.`);
|
|
16879
|
+
}
|
|
16880
|
+
if (isForgeFamilyAgent(speaker) && (input2.tool === "shell" || input2.tool === "bash") && !jobsKeepBuiltinShell && jobStage() < 2) {
|
|
16881
|
+
throw new Error(`[forge:partition] Builtin shell refused: the forge family executes through forge_shell (jobs.keepBuiltinShell or jobs.mode: "native" restores the builtin shell). Re-issue the command through forge_shell.`);
|
|
16882
|
+
}
|
|
17943
16883
|
}
|
|
17944
16884
|
}
|
|
17945
16885
|
},
|
|
16886
|
+
"chat.message": async (input2) => {
|
|
16887
|
+
if (forgeDisabled)
|
|
16888
|
+
return;
|
|
16889
|
+
const raw = input2;
|
|
16890
|
+
if (typeof raw.sessionID === "string" && raw.sessionID && typeof raw.agent === "string" && raw.agent) {
|
|
16891
|
+
sessionAgents.set(raw.sessionID, raw.agent);
|
|
16892
|
+
}
|
|
16893
|
+
},
|
|
17946
16894
|
"chat.params": async (input2, output) => {
|
|
17947
|
-
|
|
17948
|
-
|
|
16895
|
+
if (forgeDisabled)
|
|
16896
|
+
return;
|
|
16897
|
+
const raw = input2;
|
|
16898
|
+
const sessionID = raw.sessionID;
|
|
16899
|
+
if (!sessionID)
|
|
16900
|
+
return;
|
|
16901
|
+
const agentName = typeof raw.agent === "string" ? raw.agent : raw.agent?.name;
|
|
16902
|
+
if (typeof agentName === "string" && agentName)
|
|
16903
|
+
sessionAgents.set(sessionID, agentName);
|
|
16904
|
+
if (typeof agentName !== "string" || !agentName.startsWith("forge-"))
|
|
16905
|
+
return;
|
|
16906
|
+
const frozen = sessionDepths.get(sessionID);
|
|
16907
|
+
if (frozen) {
|
|
16908
|
+
applyDepthTranslation(output.options ?? (output.options = {}), frozen.translation);
|
|
16909
|
+
return;
|
|
16910
|
+
}
|
|
16911
|
+
const agentId = agentName.slice("forge-".length);
|
|
16912
|
+
const def = forgeLoader?.load().agents[agentId];
|
|
16913
|
+
if (!def?.thoughtLevel)
|
|
16914
|
+
return;
|
|
16915
|
+
const providerID = typeof raw.model?.providerID === "string" ? raw.model.providerID : "";
|
|
16916
|
+
const modelID = typeof raw.model?.modelID === "string" ? raw.model.modelID : "";
|
|
16917
|
+
const family = dispatchProviderFamily(providerID);
|
|
16918
|
+
if (!family)
|
|
16919
|
+
return;
|
|
16920
|
+
const { translation, findingMessage } = resolvePinnedDepth(def.thoughtLevel, family, nativeLadder(modelsDevCatalog, `${providerID}/${modelID}`));
|
|
16921
|
+
if (!translation) {
|
|
16922
|
+
if (findingMessage && !depthFindingNotes.has(agentId)) {
|
|
16923
|
+
depthFindingNotes.set(agentId, findingMessage);
|
|
16924
|
+
console.warn(`[forge:depth] agent forge-${agentId}: ${findingMessage}`);
|
|
16925
|
+
}
|
|
17949
16926
|
return;
|
|
17950
|
-
|
|
16927
|
+
}
|
|
16928
|
+
sessionDepths.set(sessionID, { translation });
|
|
16929
|
+
applyDepthTranslation(output.options ?? (output.options = {}), translation);
|
|
17951
16930
|
},
|
|
17952
|
-
|
|
17953
|
-
queueDepth: queueDispatchDepth,
|
|
17954
|
-
depthState: (sessionID) => dispatchDepths.get(sessionID) ?? null,
|
|
17955
|
-
composePrompt: composeWorkerPrompt,
|
|
17956
|
-
registry: () => activeDispatchRegistry,
|
|
16931
|
+
__forgeSubagentsTest: {
|
|
17957
16932
|
crews: () => crews,
|
|
17958
|
-
|
|
17959
|
-
|
|
17960
|
-
|
|
17961
|
-
|
|
17962
|
-
|
|
17963
|
-
}
|
|
16933
|
+
depthState: (sessionID) => sessionDepths.get(sessionID)?.translation ?? null,
|
|
16934
|
+
depthFinding: (agentId) => depthFindingNotes.get(agentId) ?? null,
|
|
16935
|
+
catalog: () => modelsDevCatalog,
|
|
16936
|
+
setCatalog: (c) => {
|
|
16937
|
+
modelsDevCatalog = c;
|
|
16938
|
+
},
|
|
16939
|
+
crewCommand: () => crewCommandSnapshot,
|
|
16940
|
+
materialized: () => materializedSnapshot
|
|
17964
16941
|
},
|
|
17965
16942
|
"permission.ask": async (input2, output) => {
|
|
17966
16943
|
const meta = input2.metadata ?? {};
|
|
@@ -17999,13 +16976,14 @@ var server = async (input, options) => {
|
|
|
17999
16976
|
goalProbe(`idle event session=${sessionID}`);
|
|
18000
16977
|
scheduleIdleContinuation(client, sessionID);
|
|
18001
16978
|
deliverJobWakes(sessionID);
|
|
18002
|
-
scheduleDispatchWake(sessionID, true);
|
|
18003
16979
|
}
|
|
18004
16980
|
}
|
|
18005
16981
|
if (event.type === "session.deleted") {
|
|
18006
16982
|
const info = event.properties.info;
|
|
18007
16983
|
if (typeof info?.id === "string" && info.id) {
|
|
18008
16984
|
jobManager.onSessionEnd(info.id);
|
|
16985
|
+
sessionDepths.delete(info.id);
|
|
16986
|
+
sessionAgents.delete(info.id);
|
|
18009
16987
|
}
|
|
18010
16988
|
}
|
|
18011
16989
|
},
|
|
@@ -18014,6 +16992,8 @@ var server = async (input, options) => {
|
|
|
18014
16992
|
return;
|
|
18015
16993
|
if (!input2.callID)
|
|
18016
16994
|
return;
|
|
16995
|
+
if (!sessionIsForgeFamily(input2.sessionID))
|
|
16996
|
+
return;
|
|
18017
16997
|
output.env[WATCHDOG_ENV_MARK] = markerValue(input2.callID);
|
|
18018
16998
|
watchdog.markSeen(input2.callID);
|
|
18019
16999
|
if (process.env.FORGE_WATCHDOG_PROBE) {
|
|
@@ -18043,21 +17023,11 @@ var server = async (input, options) => {
|
|
|
18043
17023
|
if (input2.tool === "goal_check")
|
|
18044
17024
|
act.checks++;
|
|
18045
17025
|
},
|
|
18046
|
-
"tool.definition": async (input2, output) => {
|
|
18047
|
-
if (input2.toolID === "shell" || input2.toolID === "bash") {
|
|
18048
|
-
const props = output.parameters?.properties;
|
|
18049
|
-
if (props && "run_in_background" in props)
|
|
18050
|
-
nativeBackgroundSeen = true;
|
|
18051
|
-
} else if (input2.toolID === "forge_shell" && jobStage() === 1) {
|
|
18052
|
-
if (!output.description.startsWith(STAGE1_NOTE)) {
|
|
18053
|
-
output.description = `${STAGE1_NOTE}
|
|
18054
|
-
${output.description}`;
|
|
18055
|
-
}
|
|
18056
|
-
}
|
|
18057
|
-
},
|
|
18058
17026
|
"experimental.chat.system.transform": async (input2, output) => {
|
|
18059
17027
|
if (!input2.sessionID)
|
|
18060
17028
|
return;
|
|
17029
|
+
if (!sessionIsForgeFamily(input2.sessionID))
|
|
17030
|
+
return;
|
|
18061
17031
|
const crewActive = crews.get(input2.sessionID);
|
|
18062
17032
|
if (crewActive && !output.system.some((s) => s.startsWith("[forge:crew-active]"))) {
|
|
18063
17033
|
output.system.push(`[forge:crew-active] A crew is ACTIVE in this session: "${crewActive.objective}" (started ${crewActive.startedAt}). A second /crew must be refused; finish with crew_close when every subtask has a verdict.`);
|
|
@@ -18090,6 +17060,8 @@ ${output.description}`;
|
|
|
18090
17060
|
return;
|
|
18091
17061
|
if (!input2.sessionID)
|
|
18092
17062
|
return;
|
|
17063
|
+
if (!sessionIsForgeFamily(input2.sessionID))
|
|
17064
|
+
return;
|
|
18093
17065
|
const state = sessions.get(input2.sessionID);
|
|
18094
17066
|
if (!state)
|
|
18095
17067
|
return;
|
|
@@ -18111,7 +17083,7 @@ Post-compaction turns must keep following this goal and its constraints.`);
|
|
|
18111
17083
|
if (!state)
|
|
18112
17084
|
return;
|
|
18113
17085
|
const goal = resolveLiveGoal(state);
|
|
18114
|
-
if (goal)
|
|
17086
|
+
if (goal && sessionIsForgeFamily(input2.sessionID))
|
|
18115
17087
|
output.enabled = false;
|
|
18116
17088
|
}
|
|
18117
17089
|
};
|
|
@@ -18121,13 +17093,30 @@ async function v2Setup(ctx) {
|
|
|
18121
17093
|
await ctx.agent.transform(async (draft) => {
|
|
18122
17094
|
if (typeof draft.get !== "function" || typeof draft.update !== "function")
|
|
18123
17095
|
return;
|
|
18124
|
-
if (draft.get(FORGE_AGENT)
|
|
18125
|
-
|
|
18126
|
-
|
|
18127
|
-
|
|
18128
|
-
|
|
18129
|
-
|
|
18130
|
-
}
|
|
17096
|
+
if (draft.get(FORGE_AGENT) === undefined) {
|
|
17097
|
+
draft.update(FORGE_AGENT, (agent) => {
|
|
17098
|
+
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).";
|
|
17099
|
+
agent.system = FORGE_PROMPT;
|
|
17100
|
+
agent.mode = "primary";
|
|
17101
|
+
});
|
|
17102
|
+
}
|
|
17103
|
+
try {
|
|
17104
|
+
const loader = createForgeConfigLoader({ projectDir: process.cwd(), homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() });
|
|
17105
|
+
const loaded = loader.load();
|
|
17106
|
+
for (const [agentId, def] of Object.entries(loaded.agents)) {
|
|
17107
|
+
const id = `forge-${agentId}`;
|
|
17108
|
+
if (draft.get(id) !== undefined)
|
|
17109
|
+
continue;
|
|
17110
|
+
const v1def = forgeAgentDef(agentId, def);
|
|
17111
|
+
draft.update(id, (agent) => {
|
|
17112
|
+
agent.description = v1def.description;
|
|
17113
|
+
agent.system = v1def.prompt;
|
|
17114
|
+
agent.mode = v1def.mode;
|
|
17115
|
+
agent.model = v1def.model;
|
|
17116
|
+
agent.permission = v1def.permission;
|
|
17117
|
+
});
|
|
17118
|
+
}
|
|
17119
|
+
} catch {}
|
|
18131
17120
|
});
|
|
18132
17121
|
}
|
|
18133
17122
|
}
|