@sorenllm/opencode-forge 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -29
- package/dist/index.js +146 -31
- package/package.json +64 -64
package/README.md
CHANGED
|
@@ -33,8 +33,10 @@ OpenSpec spec workflows remain a third, separate lane.
|
|
|
33
33
|
- Plan files: `.opencode/plan/<date>-<slug>.md` in your project, frontmatter
|
|
34
34
|
state machine `draft → approved → done` (exit: `abandoned`).
|
|
35
35
|
- While a plan is in draft, `write` / `edit` / `bash` / `task` are **denied
|
|
36
|
-
at the permission layer** — including your own `allow` config
|
|
37
|
-
|
|
36
|
+
at the permission layer** — including your own `allow` config, and for
|
|
37
|
+
every agent in that session (the draft protects shared session state; see
|
|
38
|
+
Agent partition below). The only exits are approval and discard. This is
|
|
39
|
+
deliberate; see Design stance.
|
|
38
40
|
- `plan_approve` / `plan_close` are pinned to a confirmation dialog: the
|
|
39
41
|
model can never flip the state itself.
|
|
40
42
|
- Goal files: `.opencode/goal/<date>-<slug>.md`, state machine
|
|
@@ -42,9 +44,40 @@ OpenSpec spec workflows remain a third, separate lane.
|
|
|
42
44
|
session plus a workspace queue. Completion is **verified by the plugin**,
|
|
43
45
|
not attested by the model: `goal_complete` re-executes every check itself
|
|
44
46
|
and refuses (fail-closed) on any failure.
|
|
45
|
-
- The native `build` / `plan` agents
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
- The native `build` / `plan` agents **stay installed and independent**:
|
|
48
|
+
forge coexists with them in the Tab cycle. The plugin hard-partitions the
|
|
49
|
+
two worlds (see Agent partition below) instead of hiding anything; plan
|
|
50
|
+
and goal files are never deleted on uninstall.
|
|
51
|
+
|
|
52
|
+
## Agent partition (who sees what)
|
|
53
|
+
|
|
54
|
+
The plugin draws a hard line between two agent families, enforced at the
|
|
55
|
+
tool layer (verified against the host's `tools` filter):
|
|
56
|
+
|
|
57
|
+
| | **forge family** — `forge` + your forge.json `forge-*` workers | **everyone else** — native `build` / `plan` / `general` / `explore` and your own agents |
|
|
58
|
+
| --- | --- | --- |
|
|
59
|
+
| exec surface | `forge_shell` / `forge_jobs` only; builtin `shell`/`bash` hidden (unconditional, probe-independent; covers user-defined `agent.forge` entries too) | builtin `shell`/`bash`, fully native |
|
|
60
|
+
| forge tools (`plan_*`, `goal_*`, `crew_*`, `forge_shell`, `forge_jobs`) | `forge` sees all; `forge-*` workers only `forge_shell` + `forge_jobs` | none visible |
|
|
61
|
+
| dispatching `forge-*` via `task` | allowed | refused at the tool layer |
|
|
62
|
+
| writing `.opencode/plan/` / `.opencode/goal/` | allowed (state machine governs) | refused at the tool layer (reads stay open) |
|
|
63
|
+
| forge system prompts (job guidance, plan/goal notices) | injected | never injected — zero forge text |
|
|
64
|
+
| watchdog (stuck builtin shell) | governed | untouched — no markers, no kills |
|
|
65
|
+
|
|
66
|
+
Escape hatches: `jobs.keepBuiltinShell: true` gives the forge family the
|
|
67
|
+
builtin shell back; `jobs.mode: "native"` retires `forge_shell`/`forge_jobs`
|
|
68
|
+
entirely (the hide is withdrawn in the same stroke). Mixed sessions (Tab
|
|
69
|
+
between agents in one session): harness state (plans/goals) stays
|
|
70
|
+
session-bound, tool surfaces follow the current speaker, and the goal loop
|
|
71
|
+
parks instead of driving a non-forge turn. One deliberate exception: a
|
|
72
|
+
draft-phase write ban binds the **session** (protecting shared state), so a
|
|
73
|
+
non-forge agent is write-blocked until the draft is approved or discarded in
|
|
74
|
+
forge. Running `/plan` `/goal` `/crew` under a non-forge agent redirects to
|
|
75
|
+
forge instead of executing.
|
|
76
|
+
|
|
77
|
+
**Default subject.** The host otherwise defaults new sessions to `build`
|
|
78
|
+
once a build agent exists — the plugin pins `default_agent: "forge"`
|
|
79
|
+
automatically (a `default_agent` you set yourself always wins), so bare
|
|
80
|
+
runs and fresh sessions still land on forge under coexistence.
|
|
48
81
|
|
|
49
82
|
## Install
|
|
50
83
|
|
|
@@ -78,18 +111,19 @@ the plugin never writes them):
|
|
|
78
111
|
"forge": {
|
|
79
112
|
"model": "provider/model", // pick any model for forge
|
|
80
113
|
"disable": true // one-knob return to native: no forge,
|
|
81
|
-
//
|
|
114
|
+
// no injections at all, no tools/commands
|
|
82
115
|
}
|
|
83
116
|
},
|
|
84
117
|
// per-plugin options ride the plugin entry as a [spec, options] tuple
|
|
85
118
|
"plugin": [
|
|
86
119
|
["@sorenllm/opencode-forge", {
|
|
87
120
|
"jobs": {
|
|
88
|
-
"mode": "auto", // "auto" (default) | "forge"
|
|
89
|
-
|
|
121
|
+
"mode": "auto", // "auto" (default) | "forge" — equivalent, both keep the partition
|
|
122
|
+
// | "native" — retire the supervisor, builtin shell returns
|
|
123
|
+
"keepBuiltinShell": false // true: give the forge family the builtin shell back (the one exec escape hatch)
|
|
90
124
|
},
|
|
91
125
|
"watchdog": {
|
|
92
|
-
"mode": "kill", // "kill" (default) | "dry-run" | "off"
|
|
126
|
+
"mode": "kill", // "kill" (default) | "dry-run" | "off" — forge-family sessions only
|
|
93
127
|
"stallMs": 600000 // stall threshold, min 60000
|
|
94
128
|
}
|
|
95
129
|
}]
|
|
@@ -110,7 +144,8 @@ and the plugin's command of that name is not registered.
|
|
|
110
144
|
`~/.cache/opencode/packages/github_ChengZiiii/opencode-forge/`).
|
|
111
145
|
3. Delete the `agent["forge"]` block from your config if you added one
|
|
112
146
|
(otherwise the name lingers in the agent list).
|
|
113
|
-
4. Done —
|
|
147
|
+
4. Done — all runtime injections (agents, tool hiding, commands) are gone
|
|
148
|
+
with the plugin; nothing was ever written to your config
|
|
114
149
|
(the hide was runtime-only). Your `.opencode/plan/` and `.opencode/goal/`
|
|
115
150
|
files are yours; delete them yourself if you want.
|
|
116
151
|
5. Optional runtime debris: delete `<tmp>/opencode-forge/` (job logs, the
|
|
@@ -126,7 +161,7 @@ What this plugin touches, exhaustively:
|
|
|
126
161
|
| --- | --- | --- |
|
|
127
162
|
| `<project>/.opencode/plan/*.md` | plan files | user data — kept forever, uninstall never deletes |
|
|
128
163
|
| `<project>/.opencode/goal/*.md` | goal files (contract, Check Log, Turn Ledger) | user data — kept forever, uninstall never deletes |
|
|
129
|
-
| merged config object (RAM only) | forge agent, static `forge-<id>` subagents, native build/plan
|
|
164
|
+
| merged config object (RAM only) | forge agent, static `forge-<id>` subagents, tool-partition injections (forge family shell hide, non-forge forge-tool hide, native build/plan/general/explore minimal entries), `command.plan`, `command.goal`, `command.crew`, goal permission keys, `permission.forge_shell`, `permission.crew_close` | vanishes when the plugin is removed; nothing is written to disk |
|
|
130
165
|
| `<tmp>/opencode-forge/jobs/<jobId>.log` | job output tee (full output; oldest rotated out above 50 files) | runtime debris — delete freely, also after uninstall |
|
|
131
166
|
| `<tmp>/opencode-forge/jobs/ledger.jsonl` | job registry ledger (bounded: 1 MB reset, 200 entries) | runtime debris — delete freely, also after uninstall |
|
|
132
167
|
| `<tmp>/opencode-forge/jobs/registry.json` | persistent survivor registry (bounded: 100 entries) | runtime debris — after uninstall, kill any still-running `survive` jobs yourself first |
|
|
@@ -293,19 +328,20 @@ as usual. Config `jobs.survive: "always"` flips the per-call default; config
|
|
|
293
328
|
whose pid died are detected at next start, ledgered as orphans, and dropped
|
|
294
329
|
from the registry. Stop survivors explicitly — nothing else will.
|
|
295
330
|
|
|
296
|
-
###
|
|
331
|
+
### Mode matrix (supervisor lifecycle)
|
|
297
332
|
|
|
298
|
-
|
|
299
|
-
|
|
333
|
+
The old three-stage capability probe is retired: the forge-side partition
|
|
334
|
+
holds regardless of what the host's builtin shell can do, so `auto` and
|
|
335
|
+
`forge` are equivalent. Only the manual `native` mode retires the
|
|
336
|
+
supervisor:
|
|
300
337
|
|
|
301
|
-
|
|
|
338
|
+
| mode | how you get there | builtin shell (forge family) | forge_shell / forge_jobs |
|
|
302
339
|
| --- | --- | --- | --- |
|
|
303
|
-
|
|
|
304
|
-
|
|
|
305
|
-
| 2 — native confirmed complete | manual only: `jobs.mode: "native"` (never auto-detected — completeness is a semantic judgement) | visible | retired; calls throw with a pointer to the native parameter |
|
|
340
|
+
| auto (default) / forge | nothing to do | hidden — the exec surface is forge_shell, always | registered |
|
|
341
|
+
| native | manual only: `jobs.mode: "native"` | restored | retired; calls throw with a pointer to the native parameter |
|
|
306
342
|
|
|
307
|
-
|
|
308
|
-
|
|
343
|
+
`jobs.keepBuiltinShell: true` gives the forge family the builtin shell back
|
|
344
|
+
in any mode (the watchdog keeps governing those calls).
|
|
309
345
|
|
|
310
346
|
### Artifacts and uninstall additions
|
|
311
347
|
|
|
@@ -453,10 +489,11 @@ delete the directory freely, also after uninstalling.
|
|
|
453
489
|
## Hang watchdog (a stuck builtin shell unblocks itself)
|
|
454
490
|
|
|
455
491
|
`forge_shell` is structurally immune to the stdio-EOF hang, but the builtin
|
|
456
|
-
`shell`/`bash` tool can still hang outside it —
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
492
|
+
`shell`/`bash` tool can still hang outside it — under `jobs.mode: "native"`
|
|
493
|
+
or `jobs.keepBuiltinShell: true`, forge-family sessions carry it again. The
|
|
494
|
+
watchdog is the independent backstop for those paths, in **forge-family
|
|
495
|
+
sessions only** (primary and delegated forge-* workers alike) — non-forge
|
|
496
|
+
agents' builtin shells are entirely native: no markers, no timing, no kills:
|
|
460
497
|
|
|
461
498
|
1. The host's shell environment hook stamps each builtin shell call's
|
|
462
499
|
process with a plugin-namespaced marker (`FORGE_WATCHDOG_MARK`), and the
|
|
@@ -519,11 +556,12 @@ read/grep/glob; if you genuinely need a shell command to decide the plan,
|
|
|
519
556
|
approve the plan first (revising after approval is allowed via a new `/plan`).
|
|
520
557
|
The escape hatches are `plan_approve` and `/plan discard`, by design.
|
|
521
558
|
|
|
522
|
-
Separately,
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
`
|
|
526
|
-
|
|
559
|
+
Separately, the builtin shell is hidden on the forge agent on purpose —
|
|
560
|
+
`forge_shell` is the exec surface there (see Agent partition and the job
|
|
561
|
+
supervisor chapter above). The hide is unconditional: it covers a
|
|
562
|
+
**user-defined** `agent.forge` entry too. The escape hatches are
|
|
563
|
+
`jobs.keepBuiltinShell: true` (builtin shell back on the forge family) and
|
|
564
|
+
`jobs.mode: "native"` (supervisor retired entirely).
|
|
527
565
|
|
|
528
566
|
A process restart forgets the session binding: the write-ban soft-disables
|
|
529
567
|
(safety over strictness) and the next session's system notice + `/plan
|
package/dist/index.js
CHANGED
|
@@ -14867,6 +14867,7 @@ function validateCrewReport(report, declared) {
|
|
|
14867
14867
|
var WORKER_MANDATES = [
|
|
14868
14868
|
"Mandates (non-negotiable):",
|
|
14869
14869
|
"- Use workspace-relative paths ONLY. Never write to or read from absolute paths outside this workspace; the workspace root is where you were started, not the filesystem root.",
|
|
14870
|
+
"- Run every shell command through the forge_shell tool (the builtin shell is not part of your toolset by design). Long-running or non-exiting commands: forge_shell with run_in_background, then collect results via forge_jobs poll before yielding.",
|
|
14870
14871
|
"- If any tool call is refused or denied, report the refusal VERBATIM in your final answer and stop that line of work. Never improvise a workaround around a permission denial.",
|
|
14871
14872
|
"- End with conclusions backed by evidence references (file:line, command output, or the exact file/content you produced)."
|
|
14872
14873
|
].join(`
|
|
@@ -14912,7 +14913,8 @@ function forgeAgentDef(agentId, def) {
|
|
|
14912
14913
|
shape,
|
|
14913
14914
|
role: rolePromptFor(agentId, def)
|
|
14914
14915
|
}),
|
|
14915
|
-
permission
|
|
14916
|
+
permission,
|
|
14917
|
+
tools: { shell: false, bash: false }
|
|
14916
14918
|
};
|
|
14917
14919
|
}
|
|
14918
14920
|
|
|
@@ -15363,6 +15365,25 @@ function createForgeConfigLoader(deps) {
|
|
|
15363
15365
|
|
|
15364
15366
|
// plugin.ts
|
|
15365
15367
|
var FORGE_AGENT = "forge";
|
|
15368
|
+
var FORGE_STATE_TOOLS = [
|
|
15369
|
+
"plan_write",
|
|
15370
|
+
"plan_tick",
|
|
15371
|
+
"plan_approve",
|
|
15372
|
+
"plan_close",
|
|
15373
|
+
"plan_discard",
|
|
15374
|
+
"goal_write",
|
|
15375
|
+
"goal_check",
|
|
15376
|
+
"goal_complete",
|
|
15377
|
+
"goal_pause",
|
|
15378
|
+
"goal_resume",
|
|
15379
|
+
"goal_discard",
|
|
15380
|
+
"crew_begin",
|
|
15381
|
+
"crew_close"
|
|
15382
|
+
];
|
|
15383
|
+
var FORGE_EXEC_TOOLS = ["forge_shell", "forge_jobs"];
|
|
15384
|
+
var FORGE_ALL_TOOLS = [...FORGE_STATE_TOOLS, ...FORGE_EXEC_TOOLS];
|
|
15385
|
+
var FORGE_TOOL_SET = new Set(FORGE_ALL_TOOLS);
|
|
15386
|
+
var NATIVE_PARTITION_FALLBACKS = ["build", "plan", "general", "explore"];
|
|
15366
15387
|
var FORGE_PROMPT = `You are forge — the single general-purpose coding agent. You handle every task directly: exploration, planning, implementation, and verification. There is no agent switching.
|
|
15367
15388
|
|
|
15368
15389
|
Workflow modes are strictly user-initiated. Never enter plan or goal mode — and never call a plan_* or goal_* tool — unless the user ran /plan or /goal, unmistakably asked for that mode (e.g. "plan first", "set a goal"), or the session already carries a [forge:plan-notice] / [forge:goal-notice] for a mode they started. Ordinary task requests are normal work. Mode-specific rules arrive with those commands and notices; when a notice is present, follow it.
|
|
@@ -15371,6 +15392,8 @@ Subagent dispatch (task tool): when the work suits a scoped worker, prefer a con
|
|
|
15371
15392
|
var PLAN_COMMAND_TEMPLATE = [
|
|
15372
15393
|
'(forge plan harness routing. Argument: "$ARGUMENTS")',
|
|
15373
15394
|
"",
|
|
15395
|
+
"FAMILY GUARD (step 0, before anything else): if you do not have the plan_write tool in your available tools, you are not in a forge session — STOP now: execute nothing below (including the directory listing), and tell the user that /plan belongs to the forge agent, who should switch to forge (Tab) and rerun it there.",
|
|
15396
|
+
"",
|
|
15374
15397
|
"Current .opencode/plan/ directory:",
|
|
15375
15398
|
"!`ls -1 .opencode/plan 2>/dev/null || echo '(empty)'`",
|
|
15376
15399
|
"",
|
|
@@ -15397,6 +15420,8 @@ var PLAN_COMMAND_TEMPLATE = [
|
|
|
15397
15420
|
var GOAL_COMMAND_TEMPLATE = [
|
|
15398
15421
|
'(forge goal harness routing. Argument: "$ARGUMENTS")',
|
|
15399
15422
|
"",
|
|
15423
|
+
"FAMILY GUARD (step 0, before anything else): if you do not have the goal_write tool in your available tools, you are not in a forge session — STOP now: execute nothing below (including the directory listing), and tell the user that /goal belongs to the forge agent, who should switch to forge (Tab) and rerun it there.",
|
|
15424
|
+
"",
|
|
15400
15425
|
"Current .opencode/goal/ directory:",
|
|
15401
15426
|
"!`ls -1 .opencode/goal 2>/dev/null || echo '(empty)'`",
|
|
15402
15427
|
"",
|
|
@@ -15425,6 +15450,23 @@ function effectiveWorktree(worktree, fallback) {
|
|
|
15425
15450
|
return (fallback ?? "").trim();
|
|
15426
15451
|
}
|
|
15427
15452
|
var sessions = new Map;
|
|
15453
|
+
var sessionAgents = new Map;
|
|
15454
|
+
function isForgeFamilyAgent(name) {
|
|
15455
|
+
if (typeof name !== "string")
|
|
15456
|
+
return false;
|
|
15457
|
+
const n = name.toLowerCase();
|
|
15458
|
+
return n === FORGE_AGENT || n.startsWith("forge-");
|
|
15459
|
+
}
|
|
15460
|
+
function sessionIsForgeFamily(sessionID) {
|
|
15461
|
+
if (!sessionID)
|
|
15462
|
+
return false;
|
|
15463
|
+
return isForgeFamilyAgent(sessionAgents.get(sessionID));
|
|
15464
|
+
}
|
|
15465
|
+
function noteToolAgent(context) {
|
|
15466
|
+
if (context && typeof context.sessionID === "string" && context.sessionID && typeof context.agent === "string" && context.agent) {
|
|
15467
|
+
sessionAgents.set(context.sessionID, context.agent);
|
|
15468
|
+
}
|
|
15469
|
+
}
|
|
15428
15470
|
var forgeDisabled = false;
|
|
15429
15471
|
var forgeLoader = null;
|
|
15430
15472
|
var forgeLoaderDir = null;
|
|
@@ -15489,13 +15531,10 @@ function ensureJobLifecycle() {
|
|
|
15489
15531
|
});
|
|
15490
15532
|
return { registry: registry2, fence: jobFence };
|
|
15491
15533
|
}
|
|
15492
|
-
var nativeBackgroundSeen = false;
|
|
15493
15534
|
function jobStage() {
|
|
15494
15535
|
if (jobsMode === "native")
|
|
15495
15536
|
return 2;
|
|
15496
|
-
|
|
15497
|
-
return 0;
|
|
15498
|
-
return nativeBackgroundSeen ? 1 : 0;
|
|
15537
|
+
return 0;
|
|
15499
15538
|
}
|
|
15500
15539
|
var jobLogDir = jobsLogDir();
|
|
15501
15540
|
var jobLedgerPath = join5(jobLogDir, "ledger.jsonl");
|
|
@@ -15577,6 +15616,7 @@ function ensureSession(sessionID, worktree) {
|
|
|
15577
15616
|
return state;
|
|
15578
15617
|
}
|
|
15579
15618
|
function worktreeFor(context) {
|
|
15619
|
+
noteToolAgent(context);
|
|
15580
15620
|
return effectiveWorktree(context.worktree, hostWorktree) || context.worktree;
|
|
15581
15621
|
}
|
|
15582
15622
|
function resolveActivePlan(state) {
|
|
@@ -16040,7 +16080,6 @@ var FORGE_SHELL_DESCRIPTION = [
|
|
|
16040
16080
|
"Long-running or possibly non-exiting commands (dev servers, watchers, installers, anything spawning detached children) MUST use this tool instead of the builtin shell."
|
|
16041
16081
|
].join(`
|
|
16042
16082
|
`);
|
|
16043
|
-
var STAGE1_NOTE = "Stage note: this host's builtin shell already offers a native run_in_background parameter — prefer that for plain backgrounding; keep using forge_shell when you need idle/success_pattern early return or exit wake messages.";
|
|
16044
16083
|
var forgeShellTool = tool({
|
|
16045
16084
|
description: FORGE_SHELL_DESCRIPTION,
|
|
16046
16085
|
args: {
|
|
@@ -16159,6 +16198,7 @@ var forgeJobsTool = tool({
|
|
|
16159
16198
|
execute: async (args, context) => {
|
|
16160
16199
|
if (jobStage() >= 2)
|
|
16161
16200
|
throw new Error("[forge] The job supervisor is retired on this host.");
|
|
16201
|
+
noteToolAgent(context);
|
|
16162
16202
|
const action = String(args.action ?? "");
|
|
16163
16203
|
if (!FORGE_JOBS_ACTIONS.includes(action)) {
|
|
16164
16204
|
throw new Error(`Unknown action "${action}" — use one of: ${FORGE_JOBS_ACTIONS.join(", ")}.`);
|
|
@@ -16223,6 +16263,8 @@ ${p.newOutput || "(none in this window)"}`,
|
|
|
16223
16263
|
var CREW_INIT_TEMPLATE = [
|
|
16224
16264
|
'(forge crew orchestration. Argument: "$ARGUMENTS")',
|
|
16225
16265
|
"",
|
|
16266
|
+
"FAMILY GUARD (step 0, before anything else): if you do not have the crew_begin tool in your available tools, you are not in a forge session — STOP now and tell the user that /crew belongs to the forge agent, who should switch to forge (Tab) and rerun it there.",
|
|
16267
|
+
"",
|
|
16226
16268
|
"CREW IS NOT INITIALIZED: no forge subagents are configured (no usable forge.json agents). Refuse to enter orchestration and tell the user this feature needs a one-time setup, offering BOTH options:",
|
|
16227
16269
|
"",
|
|
16228
16270
|
"1. The user configures it themselves: create ONE of these files (project-level wins, never merged):",
|
|
@@ -16240,6 +16282,8 @@ function crewOrchestrationTemplate(agentIds) {
|
|
|
16240
16282
|
return [
|
|
16241
16283
|
'(forge crew orchestration. Argument: "$ARGUMENTS")',
|
|
16242
16284
|
"",
|
|
16285
|
+
"FAMILY GUARD (step 0, before anything else): if you do not have the crew_begin tool in your available tools, you are not in a forge session — STOP now and tell the user that /crew belongs to the forge agent, who should switch to forge (Tab) and rerun it there.",
|
|
16286
|
+
"",
|
|
16243
16287
|
`Configured forge subagents (task-tool vocabulary): ${agentIds.map((id) => `forge-${id}`).join(", ")}`,
|
|
16244
16288
|
"",
|
|
16245
16289
|
"You are entering CREW ORCHESTRATION discipline for this session (single subject: do not switch agents). Follow it exactly:",
|
|
@@ -16263,6 +16307,7 @@ var crewBeginTool = tool({
|
|
|
16263
16307
|
})).describe("The declared subtask plan — decompose the objective into these before any execution; crew_close cross-checks the report against exactly this list")
|
|
16264
16308
|
},
|
|
16265
16309
|
execute: async (args, context) => {
|
|
16310
|
+
noteToolAgent(context);
|
|
16266
16311
|
const state = stateForBan(context.sessionID);
|
|
16267
16312
|
const active = state ? resolveActivePlan(state) : null;
|
|
16268
16313
|
if (active && active.doc.status === "draft") {
|
|
@@ -16325,6 +16370,7 @@ var crewCloseTool = tool({
|
|
|
16325
16370
|
})).describe("The full crew report — one entry per declared subtask")
|
|
16326
16371
|
},
|
|
16327
16372
|
execute: async (args, context) => {
|
|
16373
|
+
noteToolAgent(context);
|
|
16328
16374
|
const crew = crews.get(context.sessionID);
|
|
16329
16375
|
if (!crew)
|
|
16330
16376
|
throw new Error("[forge:crew] No active crew in this session (crew state is in-memory and dies on host restart). Start one with /crew <objective>.");
|
|
@@ -16509,6 +16555,10 @@ async function continueIfEligible(client, sessionID) {
|
|
|
16509
16555
|
goalProbe(`skip: not goal owner session=${sessionID} owner=${goal.doc.session || "(none)"}`);
|
|
16510
16556
|
return;
|
|
16511
16557
|
}
|
|
16558
|
+
if (!sessionIsForgeFamily(sessionID)) {
|
|
16559
|
+
goalProbe(`skip: agent switched to non-forge before send session=${sessionID}`);
|
|
16560
|
+
return;
|
|
16561
|
+
}
|
|
16512
16562
|
if (accountedContinuationTurn) {
|
|
16513
16563
|
if (turnHadActivity) {
|
|
16514
16564
|
noProgressStreak.delete(sessionID);
|
|
@@ -16574,6 +16624,10 @@ async function continueIfEligible(client, sessionID) {
|
|
|
16574
16624
|
function scheduleIdleContinuation(client, sessionID) {
|
|
16575
16625
|
if (forgeDisabled)
|
|
16576
16626
|
return;
|
|
16627
|
+
if (!sessionIsForgeFamily(sessionID)) {
|
|
16628
|
+
goalProbe(`skip: non-forge agent at schedule session=${sessionID}`);
|
|
16629
|
+
return;
|
|
16630
|
+
}
|
|
16577
16631
|
const existing = idleTimers.get(sessionID);
|
|
16578
16632
|
if (existing)
|
|
16579
16633
|
clearTimeout(existing);
|
|
@@ -16666,23 +16720,15 @@ var server = async (input, options) => {
|
|
|
16666
16720
|
forgeDisabled = forgeUserCfg?.disable === true;
|
|
16667
16721
|
if (forgeDisabled)
|
|
16668
16722
|
return;
|
|
16669
|
-
|
|
16670
|
-
agentSection[native] = { ...agentSection[native] ?? {}, disable: true };
|
|
16671
|
-
}
|
|
16723
|
+
cfg.default_agent ??= FORGE_AGENT;
|
|
16672
16724
|
const existing = agentSection[FORGE_AGENT];
|
|
16673
|
-
const userDefinedForge = existing !== undefined;
|
|
16674
16725
|
agentSection[FORGE_AGENT] = {
|
|
16675
16726
|
...existing ?? {},
|
|
16676
16727
|
description: existing?.description ?? "forge — the single general-purpose coding agent: takes implementation tasks directly; planning goes through /plan into the plan harness (plans land in .opencode/plan/, with approve/close confirmation gates and tick discipline enforced by tools and the permission layer).",
|
|
16677
16728
|
mode: existing?.mode ?? "primary",
|
|
16678
16729
|
prompt: existing?.prompt ?? FORGE_PROMPT
|
|
16679
16730
|
};
|
|
16680
|
-
|
|
16681
|
-
const exp = JSON.stringify(cfg.experimental ?? "");
|
|
16682
|
-
if (/background/i.test(exp))
|
|
16683
|
-
nativeBackgroundSeen = true;
|
|
16684
|
-
} catch {}
|
|
16685
|
-
if (!userDefinedForge && !jobsKeepBuiltinShell && jobStage() < 1) {
|
|
16731
|
+
if (!jobsKeepBuiltinShell && jobStage() < 2) {
|
|
16686
16732
|
const entry = agentSection[FORGE_AGENT];
|
|
16687
16733
|
entry.tools = { ...entry.tools ?? {}, shell: false, bash: false };
|
|
16688
16734
|
}
|
|
@@ -16721,6 +16767,34 @@ var server = async (input, options) => {
|
|
|
16721
16767
|
agentSection[id] = entry;
|
|
16722
16768
|
materializedSnapshot[id] = entry;
|
|
16723
16769
|
}
|
|
16770
|
+
for (const id of Object.keys(agentSection)) {
|
|
16771
|
+
if (!id.startsWith("forge-"))
|
|
16772
|
+
continue;
|
|
16773
|
+
const entry = agentSection[id];
|
|
16774
|
+
const tools = { ...entry.tools ?? {} };
|
|
16775
|
+
for (const t of FORGE_STATE_TOOLS)
|
|
16776
|
+
if (tools[t] !== true)
|
|
16777
|
+
tools[t] = false;
|
|
16778
|
+
entry.tools = tools;
|
|
16779
|
+
}
|
|
16780
|
+
for (const [id, entry] of Object.entries(agentSection)) {
|
|
16781
|
+
if (id === FORGE_AGENT || id.startsWith("forge-"))
|
|
16782
|
+
continue;
|
|
16783
|
+
const e = entry;
|
|
16784
|
+
const tools = { ...e.tools ?? {} };
|
|
16785
|
+
for (const t of FORGE_ALL_TOOLS)
|
|
16786
|
+
if (tools[t] !== true)
|
|
16787
|
+
tools[t] = false;
|
|
16788
|
+
e.tools = tools;
|
|
16789
|
+
}
|
|
16790
|
+
for (const native of NATIVE_PARTITION_FALLBACKS) {
|
|
16791
|
+
if (agentSection[native] !== undefined)
|
|
16792
|
+
continue;
|
|
16793
|
+
const tools = {};
|
|
16794
|
+
for (const t of FORGE_ALL_TOOLS)
|
|
16795
|
+
tools[t] = false;
|
|
16796
|
+
agentSection[native] = { tools };
|
|
16797
|
+
}
|
|
16724
16798
|
if (process.env.FORGE_TEST_NO_DISPATCH_FETCH === "1") {
|
|
16725
16799
|
modelsDevCatalog = null;
|
|
16726
16800
|
} else {
|
|
@@ -16754,7 +16828,7 @@ var server = async (input, options) => {
|
|
|
16754
16828
|
`);
|
|
16755
16829
|
} catch {}
|
|
16756
16830
|
}
|
|
16757
|
-
if (watchdog.mode !== "off" && (input2.tool === "shell" || input2.tool === "bash")) {
|
|
16831
|
+
if (watchdog.mode !== "off" && (input2.tool === "shell" || input2.tool === "bash") && sessionIsForgeFamily(input2.sessionID)) {
|
|
16758
16832
|
const a = output?.args ?? {};
|
|
16759
16833
|
const cmdText = typeof a.command === "string" ? a.command : typeof a.cmd === "string" ? a.cmd : undefined;
|
|
16760
16834
|
watchdog.track(input2.callID, input2.sessionID, input2.tool, undefined, cmdText !== undefined ? commandNeedle(cmdText) : undefined);
|
|
@@ -16765,14 +16839,58 @@ var server = async (input, options) => {
|
|
|
16765
16839
|
} catch {}
|
|
16766
16840
|
}
|
|
16767
16841
|
}
|
|
16842
|
+
if (input2.tool === "task") {
|
|
16843
|
+
const speaker = sessionAgents.get(input2.sessionID);
|
|
16844
|
+
if (speaker !== undefined && !isForgeFamilyAgent(speaker)) {
|
|
16845
|
+
const a = output?.args ?? {};
|
|
16846
|
+
const target = typeof a.subagent_type === "string" ? a.subagent_type : typeof a.agent === "string" ? a.agent : undefined;
|
|
16847
|
+
if (target && target.toLowerCase().startsWith("forge-")) {
|
|
16848
|
+
throw new Error(`[forge:partition] Dispatch refused: "${target}" is a forge-family worker and the current agent ("${speaker}") is outside the forge family. Use the native general/explore subagents, or switch to the forge agent to dispatch forge-* workers.`);
|
|
16849
|
+
}
|
|
16850
|
+
}
|
|
16851
|
+
}
|
|
16852
|
+
if ((input2.tool === "write" || input2.tool === "edit") && input2.sessionID) {
|
|
16853
|
+
const speaker = sessionAgents.get(input2.sessionID);
|
|
16854
|
+
if (speaker !== undefined && !isForgeFamilyAgent(speaker)) {
|
|
16855
|
+
const a = output?.args ?? {};
|
|
16856
|
+
const raw = typeof a.filePath === "string" ? a.filePath : typeof a.path === "string" ? a.path : undefined;
|
|
16857
|
+
if (raw) {
|
|
16858
|
+
const state = stateForBan(input2.sessionID);
|
|
16859
|
+
const base = state?.worktree ?? hostWorktree ?? "";
|
|
16860
|
+
const abs = (isAbsolute(raw) ? raw : join5(base, raw)).replaceAll("\\", "/").toLowerCase();
|
|
16861
|
+
if (abs.includes("/.opencode/plan/") || abs.includes("/.opencode/goal/")) {
|
|
16862
|
+
throw new Error(`[forge:partition] Write refused: "${raw}" is inside the forge state directories (.opencode/plan, .opencode/goal), which belong to the forge agent in this workspace.`);
|
|
16863
|
+
}
|
|
16864
|
+
}
|
|
16865
|
+
}
|
|
16866
|
+
}
|
|
16768
16867
|
if (typeof input2.tool === "string" && isWriteTool(input2.tool)) {
|
|
16769
16868
|
const state = stateForBan(input2.sessionID);
|
|
16770
16869
|
const active = state ? resolveActivePlan(state) : null;
|
|
16771
16870
|
if (active && active.doc.status === "draft") {
|
|
16772
|
-
throw new Error(`[forge] A plan is in draft; write operations are denied (${input2.tool}).
|
|
16871
|
+
throw new Error(`[forge] A plan is in draft; write operations are denied (${input2.tool}). Switch back to the forge agent to present it and complete plan approval (plan_approve), or run /plan discard to abandon the plan.`);
|
|
16872
|
+
}
|
|
16873
|
+
}
|
|
16874
|
+
if (input2.sessionID && typeof input2.tool === "string") {
|
|
16875
|
+
const speaker = sessionAgents.get(input2.sessionID);
|
|
16876
|
+
if (speaker !== undefined) {
|
|
16877
|
+
if (!isForgeFamilyAgent(speaker) && FORGE_TOOL_SET.has(input2.tool)) {
|
|
16878
|
+
throw new Error(`[forge:partition] Tool refused: "${input2.tool}" belongs to the forge agent family and the current agent ("${speaker}") is outside it. Switch to forge (Tab) to use forge tooling.`);
|
|
16879
|
+
}
|
|
16880
|
+
if (isForgeFamilyAgent(speaker) && (input2.tool === "shell" || input2.tool === "bash") && !jobsKeepBuiltinShell && jobStage() < 2) {
|
|
16881
|
+
throw new Error(`[forge:partition] Builtin shell refused: the forge family executes through forge_shell (jobs.keepBuiltinShell or jobs.mode: "native" restores the builtin shell). Re-issue the command through forge_shell.`);
|
|
16882
|
+
}
|
|
16773
16883
|
}
|
|
16774
16884
|
}
|
|
16775
16885
|
},
|
|
16886
|
+
"chat.message": async (input2) => {
|
|
16887
|
+
if (forgeDisabled)
|
|
16888
|
+
return;
|
|
16889
|
+
const raw = input2;
|
|
16890
|
+
if (typeof raw.sessionID === "string" && raw.sessionID && typeof raw.agent === "string" && raw.agent) {
|
|
16891
|
+
sessionAgents.set(raw.sessionID, raw.agent);
|
|
16892
|
+
}
|
|
16893
|
+
},
|
|
16776
16894
|
"chat.params": async (input2, output) => {
|
|
16777
16895
|
if (forgeDisabled)
|
|
16778
16896
|
return;
|
|
@@ -16781,6 +16899,8 @@ var server = async (input, options) => {
|
|
|
16781
16899
|
if (!sessionID)
|
|
16782
16900
|
return;
|
|
16783
16901
|
const agentName = typeof raw.agent === "string" ? raw.agent : raw.agent?.name;
|
|
16902
|
+
if (typeof agentName === "string" && agentName)
|
|
16903
|
+
sessionAgents.set(sessionID, agentName);
|
|
16784
16904
|
if (typeof agentName !== "string" || !agentName.startsWith("forge-"))
|
|
16785
16905
|
return;
|
|
16786
16906
|
const frozen = sessionDepths.get(sessionID);
|
|
@@ -16863,6 +16983,7 @@ var server = async (input, options) => {
|
|
|
16863
16983
|
if (typeof info?.id === "string" && info.id) {
|
|
16864
16984
|
jobManager.onSessionEnd(info.id);
|
|
16865
16985
|
sessionDepths.delete(info.id);
|
|
16986
|
+
sessionAgents.delete(info.id);
|
|
16866
16987
|
}
|
|
16867
16988
|
}
|
|
16868
16989
|
},
|
|
@@ -16871,6 +16992,8 @@ var server = async (input, options) => {
|
|
|
16871
16992
|
return;
|
|
16872
16993
|
if (!input2.callID)
|
|
16873
16994
|
return;
|
|
16995
|
+
if (!sessionIsForgeFamily(input2.sessionID))
|
|
16996
|
+
return;
|
|
16874
16997
|
output.env[WATCHDOG_ENV_MARK] = markerValue(input2.callID);
|
|
16875
16998
|
watchdog.markSeen(input2.callID);
|
|
16876
16999
|
if (process.env.FORGE_WATCHDOG_PROBE) {
|
|
@@ -16900,21 +17023,11 @@ var server = async (input, options) => {
|
|
|
16900
17023
|
if (input2.tool === "goal_check")
|
|
16901
17024
|
act.checks++;
|
|
16902
17025
|
},
|
|
16903
|
-
"tool.definition": async (input2, output) => {
|
|
16904
|
-
if (input2.toolID === "shell" || input2.toolID === "bash") {
|
|
16905
|
-
const props = output.parameters?.properties;
|
|
16906
|
-
if (props && "run_in_background" in props)
|
|
16907
|
-
nativeBackgroundSeen = true;
|
|
16908
|
-
} else if (input2.toolID === "forge_shell" && jobStage() === 1) {
|
|
16909
|
-
if (!output.description.startsWith(STAGE1_NOTE)) {
|
|
16910
|
-
output.description = `${STAGE1_NOTE}
|
|
16911
|
-
${output.description}`;
|
|
16912
|
-
}
|
|
16913
|
-
}
|
|
16914
|
-
},
|
|
16915
17026
|
"experimental.chat.system.transform": async (input2, output) => {
|
|
16916
17027
|
if (!input2.sessionID)
|
|
16917
17028
|
return;
|
|
17029
|
+
if (!sessionIsForgeFamily(input2.sessionID))
|
|
17030
|
+
return;
|
|
16918
17031
|
const crewActive = crews.get(input2.sessionID);
|
|
16919
17032
|
if (crewActive && !output.system.some((s) => s.startsWith("[forge:crew-active]"))) {
|
|
16920
17033
|
output.system.push(`[forge:crew-active] A crew is ACTIVE in this session: "${crewActive.objective}" (started ${crewActive.startedAt}). A second /crew must be refused; finish with crew_close when every subtask has a verdict.`);
|
|
@@ -16947,6 +17060,8 @@ ${output.description}`;
|
|
|
16947
17060
|
return;
|
|
16948
17061
|
if (!input2.sessionID)
|
|
16949
17062
|
return;
|
|
17063
|
+
if (!sessionIsForgeFamily(input2.sessionID))
|
|
17064
|
+
return;
|
|
16950
17065
|
const state = sessions.get(input2.sessionID);
|
|
16951
17066
|
if (!state)
|
|
16952
17067
|
return;
|
|
@@ -16968,7 +17083,7 @@ Post-compaction turns must keep following this goal and its constraints.`);
|
|
|
16968
17083
|
if (!state)
|
|
16969
17084
|
return;
|
|
16970
17085
|
const goal = resolveLiveGoal(state);
|
|
16971
|
-
if (goal)
|
|
17086
|
+
if (goal && sessionIsForgeFamily(input2.sessionID))
|
|
16972
17087
|
output.enabled = false;
|
|
16973
17088
|
}
|
|
16974
17089
|
};
|
package/package.json
CHANGED
|
@@ -1,64 +1,64 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@sorenllm/opencode-forge",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Single general-purpose forge agent for opencode: static worker subagents defined in forge.json (pinned model + optional reasoning depth, dispatched through the native task tool — TUI-visible), a plan harness (file-backed plans with approve/close confirmation gates and a hard draft write-ban), a goal harness (autonomous, host-verified objectives), a /crew orchestration command (declared-plan waves with an evidence-checked close gate), a job supervisor (forge_shell with exit-bound completion, idle/success early return, background jobs with poll/log/kill/handoff), and a builtin-shell hang watchdog.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "./dist/index.js",
|
|
7
|
-
"exports": {
|
|
8
|
-
".": {
|
|
9
|
-
"import": "./dist/index.js"
|
|
10
|
-
},
|
|
11
|
-
"./server": {
|
|
12
|
-
"import": "./dist/index.js"
|
|
13
|
-
}
|
|
14
|
-
},
|
|
15
|
-
"files": [
|
|
16
|
-
"dist",
|
|
17
|
-
"README.md"
|
|
18
|
-
],
|
|
19
|
-
"scripts": {
|
|
20
|
-
"bundle": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && bun build ./plugin.ts --outfile ./dist/index.js --target node --format esm",
|
|
21
|
-
"test": "node --test tests/*.test.mjs",
|
|
22
|
-
"typecheck": "tsc --noEmit"
|
|
23
|
-
},
|
|
24
|
-
"keywords": [
|
|
25
|
-
"opencode",
|
|
26
|
-
"opencode-plugin",
|
|
27
|
-
"subagents",
|
|
28
|
-
"plan",
|
|
29
|
-
"planning",
|
|
30
|
-
"workflow",
|
|
31
|
-
"single-agent",
|
|
32
|
-
"goal",
|
|
33
|
-
"crew",
|
|
34
|
-
"orchestration",
|
|
35
|
-
"background-jobs",
|
|
36
|
-
"shell",
|
|
37
|
-
"watchdog"
|
|
38
|
-
],
|
|
39
|
-
"license": "MIT",
|
|
40
|
-
"author": "chengsongren",
|
|
41
|
-
"homepage": "https://github.com/ChengZiiii/opencode-forge#readme",
|
|
42
|
-
"repository": {
|
|
43
|
-
"type": "git",
|
|
44
|
-
"url": "git+https://github.com/ChengZiiii/opencode-forge.git"
|
|
45
|
-
},
|
|
46
|
-
"bugs": {
|
|
47
|
-
"url": "https://github.com/ChengZiiii/opencode-forge/issues"
|
|
48
|
-
},
|
|
49
|
-
"peerDependencies": {
|
|
50
|
-
"@opencode-ai/plugin": "^1.18.0"
|
|
51
|
-
},
|
|
52
|
-
"devDependencies": {
|
|
53
|
-
"@opencode-ai/plugin": "^1.18.0",
|
|
54
|
-
"@types/node": "^22.13.9",
|
|
55
|
-
"typescript": "^5.8.2"
|
|
56
|
-
},
|
|
57
|
-
"engines": {
|
|
58
|
-
"opencode": "^1.18.0"
|
|
59
|
-
},
|
|
60
|
-
"publishConfig": {
|
|
61
|
-
"access": "public",
|
|
62
|
-
"registry": "https://registry.npmjs.org/"
|
|
63
|
-
}
|
|
64
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@sorenllm/opencode-forge",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Single general-purpose forge agent for opencode: static worker subagents defined in forge.json (pinned model + optional reasoning depth, dispatched through the native task tool — TUI-visible), a plan harness (file-backed plans with approve/close confirmation gates and a hard draft write-ban), a goal harness (autonomous, host-verified objectives), a /crew orchestration command (declared-plan waves with an evidence-checked close gate), a job supervisor (forge_shell with exit-bound completion, idle/success early return, background jobs with poll/log/kill/handoff), and a builtin-shell hang watchdog.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"import": "./dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"./server": {
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"bundle": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && bun build ./plugin.ts --outfile ./dist/index.js --target node --format esm",
|
|
21
|
+
"test": "node --test tests/*.test.mjs",
|
|
22
|
+
"typecheck": "tsc --noEmit"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"opencode",
|
|
26
|
+
"opencode-plugin",
|
|
27
|
+
"subagents",
|
|
28
|
+
"plan",
|
|
29
|
+
"planning",
|
|
30
|
+
"workflow",
|
|
31
|
+
"single-agent",
|
|
32
|
+
"goal",
|
|
33
|
+
"crew",
|
|
34
|
+
"orchestration",
|
|
35
|
+
"background-jobs",
|
|
36
|
+
"shell",
|
|
37
|
+
"watchdog"
|
|
38
|
+
],
|
|
39
|
+
"license": "MIT",
|
|
40
|
+
"author": "chengsongren",
|
|
41
|
+
"homepage": "https://github.com/ChengZiiii/opencode-forge#readme",
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "git+https://github.com/ChengZiiii/opencode-forge.git"
|
|
45
|
+
},
|
|
46
|
+
"bugs": {
|
|
47
|
+
"url": "https://github.com/ChengZiiii/opencode-forge/issues"
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"@opencode-ai/plugin": "^1.18.0"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@opencode-ai/plugin": "^1.18.0",
|
|
54
|
+
"@types/node": "^22.13.9",
|
|
55
|
+
"typescript": "^5.8.2"
|
|
56
|
+
},
|
|
57
|
+
"engines": {
|
|
58
|
+
"opencode": "^1.18.0"
|
|
59
|
+
},
|
|
60
|
+
"publishConfig": {
|
|
61
|
+
"access": "public",
|
|
62
|
+
"registry": "https://registry.npmjs.org/"
|
|
63
|
+
}
|
|
64
|
+
}
|