Skip to content

Commit 60ece46

Browse files
committed
fix: scope worker plugin to Ralph worker sessions (no pollution of normal sessions)
The plugin loads for every session in a project. Its worker system prompt, edit/bash context gate, and compaction context were applied to ALL sessions — breaking normal OpenCode usage (gated edit/bash, injected worker prompt). Now scoped to rlm-worker-attempt-* sessions only; ralph_/rlm_ tools are inert elsewhere. +5 tests (102 total).
1 parent 45ef0b2 commit 60ece46

8 files changed

Lines changed: 147 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ All notable changes to this project are documented here.
44

55
Format based on [Keep a Changelog](https://keepachangelog.com/).
66

7+
## [0.3.6] - 2026-06-24
8+
9+
### Fixed
10+
11+
- **Worker plugin no longer pollutes normal OpenCode sessions.** The plugin loads for every session in a project, but its worker system prompt, the edit/bash **context gate**, and compaction context were being applied to *all* sessions — which injected worker instructions into normal chats and blocked `edit`/`bash` until `ralph_load_context()` (a call a normal session never makes). These now apply **only to Ralph worker sessions** (identified by the `rlm-worker-attempt-*` session title). In normal sessions the `ralph_*` / `rlm_*` tools are inert (they error if invoked) and `edit`/`bash` are never gated.
12+
713
## [0.3.5] - 2026-06-24
814

915
### Changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@doeixd/opencode-ralph-rlm",
3-
"version": "0.3.5",
3+
"version": "0.3.6",
44
"description": "Ralph RLM v0.2: OpenCode supervisor provider + loop engine + worker plugin",
55
"type": "module",
66
"workspaces": [

packages/engine/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@doeixd/opencode-ralph-rlm-engine",
33
"private": true,
4-
"version": "0.3.5",
4+
"version": "0.3.6",
55
"description": "Ralph RLM loop engine — verify, rollover, OpenCode SDK worker lifecycle",
66
"license": "MIT",
77
"repository": {

packages/provider/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@doeixd/opencode-ralph-rlm-provider",
33
"private": true,
4-
"version": "0.3.5",
4+
"version": "0.3.6",
55
"description": "Ralph RLM OpenAI-compatible supervisor provider (Nitro)",
66
"license": "MIT",
77
"repository": {

packages/provider/server/routes/api/health.get.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export default defineHandler(async () => {
1616
return {
1717
healthy: true,
1818
provider: "@doeixd/opencode-ralph-rlm",
19-
version: "0.3.5",
19+
version: "0.3.6",
2020
opencode: {
2121
baseUrl: runtime.baseUrl,
2222
...opencode,

packages/worker-plugin/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@doeixd/opencode-ralph-rlm-worker-plugin",
33
"private": true,
4-
"version": "0.3.5",
4+
"version": "0.3.6",
55
"description": "Thin Ralph RLM worker plugin — file-first tools and context gating",
66
"license": "MIT",
77
"repository": {

packages/worker-plugin/src/ralph-worker.ts

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
type PlanContext,
2626
type ResolvedConfig,
2727
} from "@doeixd/opencode-ralph-rlm/engine";
28-
import { shouldGateDestructiveTool } from "./gate.js";
28+
import { SAFE_TOOLS, shouldGateDestructiveTool } from "./gate.js";
2929
import { freshWorkerSession, type WorkerSessionState } from "./session-state.js";
3030
import { loadWorkerPluginTemplates } from "./templates.js";
3131

@@ -48,6 +48,30 @@ export const RalphWorkerPlugin: Plugin = async ({ client, worktree }) => {
4848
return resolvePlanContext(root, cfg.plans);
4949
}
5050

51+
// This plugin loads for EVERY session in the project. Ralph behavior (worker
52+
// system prompt, the edit/bash context gate, compaction context, and the
53+
// ralph_/rlm_ tools) must only apply to Ralph worker sessions — never to a
54+
// user's normal OpenCode sessions. Worker sessions are titled
55+
// `rlm-worker-attempt-N` by the engine; we scope by that title.
56+
const workerSessionCache = new Map<string, boolean>();
57+
async function isRalphWorkerSession(sessionID: string): Promise<boolean> {
58+
const cached = workerSessionCache.get(sessionID);
59+
if (cached !== undefined) return cached;
60+
try {
61+
const res = await client.session.get({
62+
path: { id: sessionID },
63+
query: { directory: worktree },
64+
});
65+
const title = ((res as { data?: { title?: string } })?.data?.title ?? "") as string;
66+
const isWorker = title.startsWith("rlm-worker-attempt-");
67+
workerSessionCache.set(sessionID, isWorker); // cache only confirmed results
68+
return isWorker;
69+
} catch {
70+
// Transient failure — don't cache; retry on the next call.
71+
return false;
72+
}
73+
}
74+
5175
/** Worktree-relative path to CONTEXT_FOR_RLM.md for the active plan. */
5276
function rlmCtxRel(pctx: PlanContext): string {
5377
return pctx.protocolRel
@@ -573,27 +597,43 @@ export const RalphWorkerPlugin: Plugin = async ({ client, worktree }) => {
573597
},
574598

575599
"experimental.chat.system.transform": async (input: { sessionID?: string }, output: { system?: string[] }) => {
600+
const sessionID = input.sessionID;
601+
// Only inject the worker system prompt into Ralph worker sessions —
602+
// never into the user's normal OpenCode sessions.
603+
if (!sessionID || !(await isRalphWorkerSession(sessionID))) return;
576604
output.system = output.system ?? [];
577605
output.system.push(templates.workerSystemPrompt);
578-
const sessionID = input.sessionID;
579-
if (sessionID) {
580-
await syncSessionAttempt(sessionID, worktree);
581-
}
606+
await syncSessionAttempt(sessionID, worktree);
582607
},
583608

584-
"experimental.session.compacting": async (_input: unknown, output: { context?: string[] }) => {
609+
"experimental.session.compacting": async (
610+
input: { sessionID?: string },
611+
output: { context?: string[] }
612+
) => {
613+
if (!input.sessionID || !(await isRalphWorkerSession(input.sessionID))) return;
585614
output.context = output.context ?? [];
586615
output.context.push(templates.compactionContext);
587616
},
588617

589618
"tool.execute.before": async (input: { sessionID?: string; tool?: string; call?: { name?: string } }) => {
590-
const cfg = await getConfig();
591619
const sessionID = input.sessionID;
592620
if (!sessionID) return;
621+
const toolName = input.tool ?? input.call?.name ?? "";
622+
623+
// In a normal (non-worker) session, the plugin's tools are present but
624+
// must stay inert, and we must NOT gate the user's edit/bash.
625+
if (!(await isRalphWorkerSession(sessionID))) {
626+
if (SAFE_TOOLS.has(toolName)) {
627+
throw new Error(
628+
"This Ralph RLM tool only runs inside a Ralph worker session (created by the loop), not a normal OpenCode session."
629+
);
630+
}
631+
return;
632+
}
593633

634+
const cfg = await getConfig();
594635
await syncSessionAttempt(sessionID, worktree);
595636
const st = getSession(sessionID);
596-
const toolName = input.tool ?? input.call?.name ?? "";
597637
if (
598638
shouldGateDestructiveTool({
599639
gateEnabled: cfg.gateDestructiveToolsUntilContextLoaded,
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import path from "node:path";
2+
import { mkdtemp, rm } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import { describe, expect, test } from "bun:test";
5+
import { RalphWorkerPlugin } from "../ralph-worker.js";
6+
7+
function mockClient(title: string) {
8+
return {
9+
session: { get: async () => ({ data: { title } }) },
10+
tui: { showToast: async () => {} },
11+
};
12+
}
13+
14+
async function loadHooks(title: string) {
15+
const worktree = await mkdtemp(path.join(tmpdir(), "ralph-scope-"));
16+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
17+
const hooks = await RalphWorkerPlugin({ client: mockClient(title), worktree } as any);
18+
return { hooks: hooks as any, worktree };
19+
}
20+
21+
describe("plugin scoping: normal vs worker sessions", () => {
22+
test("worker system prompt is injected only in worker sessions", async () => {
23+
const worker = await loadHooks("rlm-worker-attempt-1");
24+
const normal = await loadHooks("My normal chat");
25+
try {
26+
const wOut: { system?: string[] } = {};
27+
await worker.hooks["experimental.chat.system.transform"]({ sessionID: "w" }, wOut);
28+
expect(wOut.system?.length).toBe(1);
29+
30+
const nOut: { system?: string[] } = {};
31+
await normal.hooks["experimental.chat.system.transform"]({ sessionID: "n" }, nOut);
32+
expect(nOut.system ?? []).toEqual([]);
33+
} finally {
34+
await rm(worker.worktree, { recursive: true, force: true });
35+
await rm(normal.worktree, { recursive: true, force: true });
36+
}
37+
});
38+
39+
test("normal session: edit/bash are NOT gated", async () => {
40+
const { hooks, worktree } = await loadHooks("Just a chat");
41+
try {
42+
// Must not throw — the gate must not apply to non-worker sessions.
43+
await hooks["tool.execute.before"]({ sessionID: "n", tool: "edit" });
44+
await hooks["tool.execute.before"]({ sessionID: "n", tool: "bash" });
45+
} finally {
46+
await rm(worktree, { recursive: true, force: true });
47+
}
48+
});
49+
50+
test("normal session: Ralph tools are blocked (inert)", async () => {
51+
const { hooks, worktree } = await loadHooks("Just a chat");
52+
try {
53+
await expect(
54+
hooks["tool.execute.before"]({ sessionID: "n", tool: "ralph_load_context" })
55+
).rejects.toThrow(/Ralph/);
56+
} finally {
57+
await rm(worktree, { recursive: true, force: true });
58+
}
59+
});
60+
61+
test("worker session: edit is gated until ralph_load_context", async () => {
62+
const { hooks, worktree } = await loadHooks("rlm-worker-attempt-2");
63+
try {
64+
await expect(
65+
hooks["tool.execute.before"]({ sessionID: "w", tool: "edit" })
66+
).rejects.toThrow();
67+
} finally {
68+
await rm(worktree, { recursive: true, force: true });
69+
}
70+
});
71+
72+
test("compaction context only added in worker sessions", async () => {
73+
const worker = await loadHooks("rlm-worker-attempt-1");
74+
const normal = await loadHooks("chat");
75+
try {
76+
const wOut: { context?: string[] } = {};
77+
await worker.hooks["experimental.session.compacting"]({ sessionID: "w" }, wOut);
78+
expect(wOut.context?.length).toBe(1);
79+
80+
const nOut: { context?: string[] } = {};
81+
await normal.hooks["experimental.session.compacting"]({ sessionID: "n" }, nOut);
82+
expect(nOut.context ?? []).toEqual([]);
83+
} finally {
84+
await rm(worker.worktree, { recursive: true, force: true });
85+
await rm(normal.worktree, { recursive: true, force: true });
86+
}
87+
});
88+
});

0 commit comments

Comments
 (0)