@sorenllm/opencode-forge 0.3.1 → 0.3.3

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