opencode-ralph-rlm 0.1.13 → 0.1.14

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 +36 -42
  2. package/dist/ralph-rlm.js +88 -114
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -6,8 +6,8 @@ New here? Start with [`GETTINGSTARTEDGUIDE.md`](GETTINGSTARTEDGUIDE.md).
6
6
 
7
7
  Two techniques combine to make this work:
8
8
 
9
- - **Ralph** a strategist session spawned fresh per attempt. It reviews what failed, adjusts the plan and instructions, then delegates coding to a worker. It never writes code itself.
10
- - **RLM** (Recursive Language Model worker) a file-first coding session based on [arXiv:2512.24601](https://arxiv.org/abs/2512.24601). Each attempt gets a clean context window and loads all state from files rather than inheriting noise from prior turns.
9
+ - **Ralph** - the main session acting as strategist+supervisor. It reviews what failed, adjusts the plan and instructions, then delegates coding to a worker. It never writes code itself.
10
+ - **RLM** (Recursive Language Model worker) - a file-first coding session based on [arXiv:2512.24601](https://arxiv.org/abs/2512.24601). Each attempt gets a clean context window and loads all state from files rather than inheriting noise from prior turns.
11
11
 
12
12
 
13
13
  ## The problem this solves
@@ -43,9 +43,9 @@ Context windows are session-local and finite. Files are persistent, inspectable,
43
43
 
44
44
  ### Separation of strategy and execution
45
45
 
46
- The Ralph strategist session exists because mixing strategy and execution in the same context is how reasoning degrades. When a session that just wrote failing code is also responsible for diagnosing *why* it failed and planning the next approach, it pattern-matches against its own failed reasoning. It proposes variations on what didn't work rather than stepping back.
46
+ Ralph keeps strategy and execution separate: **you (main session)** handle strategy and delegation, while **workers** implement. When a session that just wrote failing code is also responsible for diagnosing *why* it failed and planning the next approach, it pattern-matches against its own failed reasoning. It proposes variations on what didn't work rather than stepping back.
47
47
 
48
- Ralph's session gets a fresh window. It reads the failure record cold, without the accumulated baggage of having written the code. This mirrors how experienced engineering teams work: the reviewer of a failing PR is often not the one who writes the fix.
48
+ In this mode, the *worker* always gets a fresh window. It reads the failure record cold, without the accumulated baggage of having written the code. This mirrors how experienced engineering teams work: the reviewer of a failing PR is often not the one who writes the fix.
49
49
 
50
50
  ### The verify contract
51
51
 
@@ -63,52 +63,48 @@ The RLM paper demonstrates that full-file reads are expensive and often counterp
63
63
 
64
64
  `NOTES_AND_LEARNINGS.md` and `RLM_INSTRUCTIONS.md` are the loop's long-term memory. They survive context resets and accumulate across attempts. The loop doesn't just retry — it gets smarter with each failure.
65
65
 
66
- `RLM_INSTRUCTIONS.md` is the inner loop's operating manual. The Ralph strategist updates it between attempts when a pattern of failures reveals a gap in guidance. By attempt 10, the instructions encode everything learned from attempts 1-9.
66
+ `RLM_INSTRUCTIONS.md` is the inner loop's operating manual. The main strategist (you) updates it between attempts when a pattern of failures reveals a gap in guidance. By attempt 10, the instructions encode everything learned from attempts 1-9.
67
67
 
68
68
  This is why the approach scales to overnight runs. A fresh worker in attempt 10 starts with the accumulated knowledge of 9 prior attempts, encoded in protocol files, without the accumulated noise.
69
69
 
70
70
 
71
71
  ## How it works
72
72
 
73
- ### Three-level architecture
73
+ ### Two-level architecture (main session = strategist + supervisor)
74
74
 
75
75
  ```
76
- You → main session (thin meta-supervisor your conversation)
76
+ You → main session (supervisor + strategist in one)
77
77
 
78
78
  ├─ attempt 1:
79
- │ ├─ spawns Ralph strategist session R1 ← fresh context
80
- │ R1: ralph_load_context() → review failures update PLAN.md
81
- │ │ → ralph_spawn_worker() → STOP
79
+ │ ├─ strategist (you): ralph_load_context() review failures → update PLAN.md
80
+ ├─ ralph_spawn_worker() → spawns RLM worker session W1
82
81
  │ │
83
- │ └─ spawns RLM worker session W1 ← fresh context
82
+ │ └─ RLM worker session W1 ← fresh context
84
83
  │ W1: ralph_load_context() → code → ralph_verify() → STOP
85
84
 
86
85
  ├─ plugin verifies on W1 idle
87
86
  │ fail → roll state files → spawn attempt 2
88
87
 
89
88
  ├─ attempt 2:
90
- │ ├─ spawns Ralph strategist session R2 ← fresh context again
91
- │ R2: reads AGENT_CONTEXT_FOR_NEXT_RALPH.md adjusts strategy
92
- │ │ → ralph_spawn_worker() → STOP
89
+ │ ├─ strategist (you): reads AGENT_CONTEXT_FOR_NEXT_RALPH.md adjusts strategy
90
+ ├─ ralph_spawn_worker()spawns RLM worker session W2
93
91
  │ │
94
- │ └─ spawns RLM worker session W2 ← fresh context
92
+ │ └─ RLM worker session W2 ← fresh context
95
93
  │ W2: loads compact state from files → code → STOP
96
94
 
97
95
  └─ pass → done toast
98
96
  ```
99
97
 
100
- Each session role has a distinct purpose and **fresh context window**:
98
+ Each role has a distinct purpose and **fresh context window** where applicable:
101
99
 
102
100
  | Role | Session | Context | Responsibility |
103
101
  |---|---|---|---|
104
- | **main** | Your conversation | Persistent | Goal stop. Plugin handles the rest. |
105
- | **ralph** | Per-attempt strategist | Fresh | Review failure, update PLAN.md / RLM_INSTRUCTIONS.md, call `ralph_spawn_worker()`. |
102
+ | **main** | Your conversation | Persistent | Supervisor + strategist. Review failures, update PLAN.md / RLM_INSTRUCTIONS.md, call `ralph_spawn_worker()`. |
106
103
  | **worker** | Per-attempt coder | Fresh | `ralph_load_context()` → code → `ralph_verify()` → stop. |
107
104
 
108
105
  ### Roles and responsibilities (quick map)
109
106
 
110
- - **Supervisor (main session):** orchestration and decisions only. Never edits files or runs code; uses `ralph_*` tools to control lifecycle.
111
- - **Ralph strategist:** updates plan/instructions and delegates to a worker. No direct implementation.
107
+ - **Supervisor+strategist (main session):** orchestration, planning, and delegation. Never edits files or runs code directly.
112
108
  - **RLM worker:** does the actual coding and verification for this attempt. One pass per session.
113
109
  - **Sub-agent:** narrow task helper; updates its own state files under `.opencode/agents/<name>/`.
114
110
 
@@ -116,15 +112,13 @@ Each session role has a distinct purpose and **fresh context window**:
116
112
 
117
113
  ```
118
114
  main idle
119
- └─ spawn Ralph(1)
120
- └─ Ralph(1) calls ralph_spawn_worker()
121
- └─ spawn Worker(1)
122
- └─ Worker(1) calls ralph_verify() and goes idle
123
- └─ plugin runs verify
124
- ├─ passdone
125
- └─ fail roll state files
126
- └─ spawn Ralph(2)
127
- └─ (repeat)
115
+ └─ strategist (you) calls ralph_spawn_worker()
116
+ └─ spawn Worker(1)
117
+ └─ Worker(1) calls ralph_verify() and goes idle
118
+ └─ plugin runs verify
119
+ ├─ pass done
120
+ └─ failroll state files
121
+ └─ strategist (you) handles next attempt
128
122
  ```
129
123
 
130
124
  The plugin drives the loop from `session.idle` events. Neither Ralph nor the worker need to know about the outer loop — they just load context, do their job, and stop.
@@ -295,7 +289,7 @@ This repo now includes project-local agent files under `.opencode/agents/`:
295
289
  - `.opencode/agents/security-auditor.md`
296
290
 
297
291
  These profiles intentionally keep loop ownership in `ralph-rlm`.
298
- Do not model Ralph strategist/worker as OpenCode primary/subagent replacements.
292
+ Do not model the strategist/worker roles as OpenCode primary/subagent replacements.
299
293
 
300
294
 
301
295
  ## Protocol files
@@ -524,9 +518,9 @@ Run the configured verify command. Returns `{ verdict: "pass"|"fail"|"unknown",
524
518
 
525
519
  #### `ralph_spawn_worker()`
526
520
 
527
- **Ralph strategist sessions only.** Spawn a fresh RLM worker session for this attempt. Call this after reviewing protocol files and optionally updating `PLAN.md` / `RLM_INSTRUCTIONS.md`. Then stop — the plugin handles verification and spawns the next Ralph session if needed.
521
+ **Main strategist only.** Spawn a fresh RLM worker session for this attempt. Call this after reviewing protocol files and optionally updating `PLAN.md` / `RLM_INSTRUCTIONS.md`. Then stop — the plugin handles verification and prompts you for the next attempt if needed.
528
522
 
529
- If you call this from the main conversation you will get: `ralph_spawn_worker() can only be called from a Ralph strategist session.` In normal operation the plugin creates strategist sessions automatically on `session.idle`.
523
+ If you call this from an unbound session you will get: `ralph_spawn_worker() must be called from the bound supervisor session.` Bind first with `ralph_create_supervisor_session()`.
530
524
 
531
525
  ### Sub-agents
532
526
 
@@ -548,7 +542,7 @@ List all sub-agents registered in the current session with their name, goal, sta
548
542
 
549
543
  ### Supervisor communication
550
544
 
551
- These tools let spawned sessions (Ralph strategist, RLM worker) communicate back to the main conversation at runtime. State is carried in `.opencode/pending_input.json` for question/response pairs, `SUPERVISOR_LOG.md` for structured status entries, and `CONVERSATION.md` for the readable event timeline.
545
+ These tools let spawned sessions (RLM worker + sub-agents) communicate back to the main conversation at runtime. State is carried in `.opencode/pending_input.json` for question/response pairs, `SUPERVISOR_LOG.md` for structured status entries, and `CONVERSATION.md` for the readable event timeline.
552
546
 
553
547
  User answers to `ralph_ask()` are persisted too: when you reply via `ralph_respond()`, the response is appended to `CONVERSATION.md`.
554
548
 
@@ -655,8 +649,8 @@ RALPH_BOOTSTRAP_RLM_INSTRUCTIONS="@/home/user/prompts/rlm-instructions.md"
655
649
  | `RALPH_CONTEXT_GATE_ERROR` | — | Error message thrown when the agent tries a destructive tool before loading context. |
656
650
  | `RALPH_WORKER_SYSTEM_PROMPT` | — | System prompt injected into every RLM worker session. Describes the one-pass contract. |
657
651
  | `RALPH_WORKER_PROMPT` | `{{attempt}}` | Initial prompt sent to each spawned RLM worker session. |
658
- | `RALPH_SESSION_SYSTEM_PROMPT` | — | System prompt injected into Ralph strategist sessions. |
659
- | `RALPH_SESSION_PROMPT` | `{{attempt}}` | Initial prompt sent to each spawned Ralph strategist session. |
652
+ | `RALPH_SESSION_SYSTEM_PROMPT` | — | Legacy: system prompt for separate strategist sessions (unused in main-as-strategist mode). |
653
+ | `RALPH_SESSION_PROMPT` | `{{attempt}}` | Prompt sent to the main strategist session when an attempt starts. |
660
654
 
661
655
  ### Example: custom continue prompt from a file
662
656
 
@@ -699,7 +693,7 @@ Set `maxAttempts` high (25–50), write a detailed `PLAN.md` with a precise defi
699
693
 
700
694
  1. Make an attempt.
701
695
  2. Run verify.
702
- 3. On failure: roll state, spawn Ralph to diagnose and adjust, spawn the next worker.
696
+ 3. On failure: roll state, prompt the strategist (you) to diagnose and adjust, spawn the next worker.
703
697
  4. Repeat until it passes or hits `maxAttempts`.
704
698
 
705
699
  In the morning, check `SUPERVISOR_LOG.md` and `CONVERSATION.md` for the progress feed, `NOTES_AND_LEARNINGS.md` for what the loop learned, and `AGENT_CONTEXT_FOR_NEXT_RALPH.md` for where it stopped.
@@ -746,19 +740,19 @@ Parent agent:
746
740
 
747
741
  Edit `RLM_INSTRUCTIONS.md` to add project-specific playbooks, register MCP tools, or adjust the debug workflow. Changes persist across attempts. Use `ralph_update_rlm_instructions()` from within a session, or edit the file directly.
748
742
 
749
- The instructions file is the primary lever for improving loop performance. If the loop keeps making the same mistake, add a rule. If it keeps following an inefficient path, add a playbook. The Ralph strategist is responsible for updating these instructions between attempts based on what it observes in the failure record.
743
+ The instructions file is the primary lever for improving loop performance. If the loop keeps making the same mistake, add a rule. If it keeps following an inefficient path, add a playbook. The main strategist (you) updates these instructions between attempts based on what it observes in the failure record.
750
744
 
751
745
 
752
746
  ## Hooks installed
753
747
 
754
748
  | Hook | What it does |
755
749
  |---|---|
756
- | `event: session.idle` | Routes idle events: **worker** → `handleWorkerIdle` (verify + continue loop); **ralph** → `handleRalphSessionIdle` (warn if no worker spawned); **main/other** → `handleMainIdle` (kick off attempt 1). Also emits heartbeat/staleness warnings and supervisor status updates to `SUPERVISOR_LOG.md` and `CONVERSATION.md`. |
757
- | `event: session.created` | Pre-allocates session state for known worker/ralph sessions. |
750
+ | `event: session.idle` | Routes idle events: **worker** → `handleWorkerIdle` (verify + continue loop); **main/other** → `handleMainIdle` (kick off attempt 1). Also emits heartbeat/staleness warnings and supervisor status updates to `SUPERVISOR_LOG.md` and `CONVERSATION.md`. |
751
+ | `event: session.created` | Pre-allocates session state for known worker sessions. |
758
752
  | `event: session.status` | Refreshes heartbeat/progress timestamps for active sessions and surfaces explicit session error statuses to the supervisor feed. |
759
- | `experimental.chat.system.transform` | Three-way routing: **worker** → RLM file-first prompt; **ralph** → Ralph strategist prompt; **main/other** → supervisor prompt. |
753
+ | `experimental.chat.system.transform` | Two-way routing: **worker** → RLM file-first prompt; **main/other** → supervisor+strategist prompt. |
760
754
  | `experimental.session.compacting` | Injects protocol file pointers into compaction context so state survives context compression. |
761
- | `tool.execute.before` | Blocks destructive tools (`write`, `edit`, `bash`, `delete`, `move`, `rename`) in **worker and sub-agent sessions** until `ralph_load_context()` has been called. Ralph strategist sessions are not gated. |
755
+ | `tool.execute.before` | Blocks destructive tools (`write`, `edit`, `bash`, `delete`, `move`, `rename`) in **worker and sub-agent sessions** until `ralph_load_context()` has been called. |
762
756
 
763
757
 
764
758
  ## Background
@@ -767,7 +761,7 @@ The instructions file is the primary lever for improving loop performance. If th
767
761
 
768
762
  The outer loop is named after the [Ralph Wiggum technique](https://www.geoffreyhuntley.com/ralph) — a `while` loop that feeds a prompt to an AI agent until it succeeds. The name reflects the philosophy: persistent, not clever. The loop doesn't try to be smart about when to give up. It tries, records what happened, and tries again with better instructions.
769
763
 
770
- The key addition in this plugin over a naive Ralph implementation is the **separation of the strategist from the worker**. A naive loop re-prompts the same session. This plugin spawns a fresh Ralph strategist to review the failure before spawning the next worker. The strategist's fresh context means it analyses the failure without being anchored to the reasoning that produced it.
764
+ The key addition in this plugin over a naive Ralph implementation is the **separation of the strategist from the worker**. The main session handles strategy and delegation, while each worker gets a fresh context to implement. This keeps planning clean while still benefiting from fresh worker windows.
771
765
 
772
766
  ### The RLM inner loop
773
767
 
package/dist/ralph-rlm.js CHANGED
@@ -23510,9 +23510,9 @@ var DEFAULT_TEMPLATES = {
23510
23510
  systemPrompt: [
23511
23511
  "RALPH SUPERVISOR:",
23512
23512
  "- You are the Ralph supervisor. You orchestrate RLM worker sessions; you do NOT write code yourself.",
23513
- "- When the user gives you a goal, describe the task briefly and stop \u2014 the plugin will spawn an RLM worker automatically.",
23514
- "- You are NOT the Ralph strategist and NOT the RLM worker. Those are separate sessions.",
23515
- " Supervisor = orchestration + decisions; Ralph strategist = planning + delegation; RLM worker = implementation.",
23513
+ "- When the user gives you a goal, describe the task briefly and then act as the strategist: call ralph_spawn_worker() to hand off.",
23514
+ "- You ARE the strategist in the main session, and you are NOT the RLM worker.",
23515
+ " Supervisor+strategist = orchestration + planning + delegation; RLM worker = implementation.",
23516
23516
  "- Workers are spawned per-attempt with a fresh context window. They load state from protocol files.",
23517
23517
  "- Protocol files (PLAN.md, RLM_INSTRUCTIONS.md, etc.) persist across all attempts \u2014 edit them to guide workers.",
23518
23518
  "- After each worker attempt the plugin runs verify and either finishes or spawns the next worker.",
@@ -24290,6 +24290,49 @@ var RalphRLM = async ({ client, $, worktree }) => {
24290
24290
  return false;
24291
24291
  }
24292
24292
  };
24293
+ const promptStrategistInMain = async (attempt) => {
24294
+ if (!supervisor.sessionId) {
24295
+ await notifySupervisor("supervisor", "Cannot prompt strategist: supervisor session not bound. Run ralph_create_supervisor_session().", "warning", true);
24296
+ return;
24297
+ }
24298
+ supervisor.awaitingStrategist = true;
24299
+ if (supervisor.ralphHandoffAttempt !== attempt) {
24300
+ supervisor.ralphHandoffAttempt = attempt;
24301
+ supervisor.ralphHandoffRetries = 0;
24302
+ }
24303
+ const promptText = interpolate(templates.ralphSessionPrompt, {
24304
+ attempt: String(attempt),
24305
+ nextAttempt: String(attempt + 1)
24306
+ });
24307
+ const ok = await sendPromptWithFallback(supervisor.sessionId, promptText, `Strategist prompt (attempt ${attempt})`, supervisor.sessionId);
24308
+ if (!ok) {
24309
+ supervisor.paused = true;
24310
+ await notifySupervisor(`supervisor/attempt-${attempt}`, "Strategist prompt failed; supervision paused. Retry with ralph_create_supervisor_session(restart_if_done=true).", "error", true, supervisor.sessionId);
24311
+ return;
24312
+ }
24313
+ if (supervisor.ralphHandoffTimer) {
24314
+ clearTimeout(supervisor.ralphHandoffTimer);
24315
+ }
24316
+ const cfg = await run(getConfig());
24317
+ const timeoutMs = Math.max(cfg.strategistHandoffMinutes, 1) * 60000;
24318
+ supervisor.ralphHandoffTimer = setTimeout(async () => {
24319
+ if (supervisor.done || supervisor.paused)
24320
+ return;
24321
+ if (!supervisor.awaitingStrategist)
24322
+ return;
24323
+ if (supervisor.ralphHandoffAttempt !== attempt)
24324
+ return;
24325
+ const retries = supervisor.ralphHandoffRetries ?? 0;
24326
+ if (retries < cfg.strategistHandoffMaxRetries) {
24327
+ supervisor.ralphHandoffRetries = retries + 1;
24328
+ await notifySupervisor(`supervisor/attempt-${attempt}`, `Strategist did not hand off; re-prompting main session (${retries + 1}/${cfg.strategistHandoffMaxRetries}).`, "warning", true, supervisor.sessionId);
24329
+ await promptStrategistInMain(attempt);
24330
+ return;
24331
+ }
24332
+ await notifySupervisor(`supervisor/attempt-${attempt}`, "Strategist did not hand off after retries; supervision paused. Use ralph_create_supervisor_session(restart_if_done=true) to retry.", "error", true, supervisor.sessionId);
24333
+ supervisor.paused = true;
24334
+ }, timeoutMs);
24335
+ };
24293
24336
  const detectProjectDefaults = (root) => exports_Effect.gen(function* () {
24294
24337
  const j = (f) => NodePath.join(root, f);
24295
24338
  const hasBunLock = (yield* fileExists(j("bun.lockb"))) || (yield* fileExists(j("bun.lock")));
@@ -25133,7 +25176,7 @@ No pending questions found.`));
25133
25176
  if (supervisor.done && args2.restart_if_done === true) {
25134
25177
  supervisor.done = false;
25135
25178
  supervisor.paused = false;
25136
- supervisor.currentRalphSessionId = undefined;
25179
+ supervisor.awaitingStrategist = false;
25137
25180
  supervisor.currentWorkerSessionId = undefined;
25138
25181
  supervisor.activeReviewerName = undefined;
25139
25182
  supervisor.activeReviewerAttempt = undefined;
@@ -25142,19 +25185,19 @@ No pending questions found.`));
25142
25185
  await persistReviewerState();
25143
25186
  await notifySupervisor("supervisor", "Supervisor done-state reset for a new run.", "info", true, sessionID);
25144
25187
  }
25145
- if (supervisor.currentRalphSessionId || supervisor.currentWorkerSessionId || supervisor.done) {
25188
+ if (supervisor.currentWorkerSessionId || supervisor.done) {
25146
25189
  return JSON.stringify({
25147
25190
  ok: true,
25148
25191
  started: false,
25149
25192
  message: "Loop is already running or completed for this process.",
25150
- currentRalphSessionId: supervisor.currentRalphSessionId,
25151
25193
  currentWorkerSessionId: supervisor.currentWorkerSessionId,
25152
25194
  done: supervisor.done
25153
25195
  }, null, 2);
25154
25196
  }
25155
25197
  supervisor.attempt = 1;
25198
+ supervisor.awaitingStrategist = false;
25156
25199
  await notifySupervisor("supervisor", "Starting Ralph loop at attempt 1 (manual start).", "info", true, sessionID);
25157
- await spawnRalphSession(1);
25200
+ await promptStrategistInMain(1);
25158
25201
  return JSON.stringify({ ok: true, started: true, attempt: 1 }, null, 2);
25159
25202
  }
25160
25203
  });
@@ -25170,6 +25213,7 @@ No pending questions found.`));
25170
25213
  const reason = args2.reason?.trim();
25171
25214
  supervisor.done = true;
25172
25215
  supervisor.paused = true;
25216
+ supervisor.awaitingStrategist = false;
25173
25217
  const sessionsToAbort = Array.from(sessionMap.keys());
25174
25218
  for (const id of sessionsToAbort) {
25175
25219
  await client.session.abort({ path: { id } }).catch(() => {});
@@ -25179,7 +25223,6 @@ No pending questions found.`));
25179
25223
  }
25180
25224
  stopAllCommands("supervision-ended");
25181
25225
  sessionMap.clear();
25182
- supervisor.currentRalphSessionId = undefined;
25183
25226
  supervisor.currentWorkerSessionId = undefined;
25184
25227
  supervisor.activeReviewerName = undefined;
25185
25228
  supervisor.activeReviewerAttempt = undefined;
@@ -25203,7 +25246,7 @@ No pending questions found.`));
25203
25246
  }
25204
25247
  });
25205
25248
  const tool_ralph_supervision_status = tool({
25206
- description: "Get current supervision state (binding, attempt, active strategist/worker, done flag).",
25249
+ description: "Get current supervision state (binding, attempt, awaiting strategist/worker, done flag).",
25207
25250
  args: {},
25208
25251
  async execute(_args, _ctx) {
25209
25252
  return JSON.stringify({
@@ -25212,7 +25255,7 @@ No pending questions found.`));
25212
25255
  attempt: supervisor.attempt,
25213
25256
  done: supervisor.done,
25214
25257
  paused: supervisor.paused ?? false,
25215
- currentRalphSessionId: supervisor.currentRalphSessionId ?? null,
25258
+ awaitingStrategist: supervisor.awaitingStrategist ?? false,
25216
25259
  currentWorkerSessionId: supervisor.currentWorkerSessionId ?? null,
25217
25260
  activeReviewerName: supervisor.activeReviewerName ?? null,
25218
25261
  activeReviewerAttempt: supervisor.activeReviewerAttempt ?? null,
@@ -25258,11 +25301,11 @@ No pending questions found.`));
25258
25301
  message: "Loop is marked done. Use ralph_create_supervisor_session(restart_if_done=true)."
25259
25302
  }, null, 2);
25260
25303
  }
25261
- if (supervisor.currentRalphSessionId || supervisor.currentWorkerSessionId) {
25304
+ if (supervisor.currentWorkerSessionId) {
25262
25305
  return JSON.stringify({ ok: true, resumed: true, started: false, message: "Loop already running." }, null, 2);
25263
25306
  }
25264
25307
  supervisor.attempt = Math.max(1, supervisor.attempt || 1);
25265
- await spawnRalphSession(supervisor.attempt);
25308
+ await promptStrategistInMain(supervisor.attempt);
25266
25309
  return JSON.stringify({ ok: true, resumed: true, started: true, attempt: supervisor.attempt }, null, 2);
25267
25310
  }
25268
25311
  });
@@ -25348,8 +25391,14 @@ Set a new goal and run again.
25348
25391
  supervisor.done = false;
25349
25392
  supervisor.paused = false;
25350
25393
  supervisor.attempt = 0;
25351
- supervisor.currentRalphSessionId = undefined;
25352
25394
  supervisor.currentWorkerSessionId = undefined;
25395
+ supervisor.awaitingStrategist = false;
25396
+ supervisor.ralphHandoffRetries = 0;
25397
+ supervisor.ralphHandoffAttempt = undefined;
25398
+ if (supervisor.ralphHandoffTimer) {
25399
+ clearTimeout(supervisor.ralphHandoffTimer);
25400
+ supervisor.ralphHandoffTimer = undefined;
25401
+ }
25353
25402
  supervisor.activeReviewerName = undefined;
25354
25403
  supervisor.activeReviewerAttempt = undefined;
25355
25404
  supervisor.activeReviewerSessionId = undefined;
@@ -25439,7 +25488,7 @@ Set a new goal and run again.
25439
25488
  supervisor.done = false;
25440
25489
  supervisor.paused = false;
25441
25490
  supervisor.attempt = 1;
25442
- await spawnRalphSession(1);
25491
+ await promptStrategistInMain(1);
25443
25492
  actions.push("Started loop at attempt 1");
25444
25493
  }
25445
25494
  await appendConversationEntry("supervisor", `Quickstart completed for goal: ${args2.goal}`);
@@ -25624,18 +25673,12 @@ Set a new goal and run again.
25624
25673
  s.lastProgressAt = now2;
25625
25674
  });
25626
25675
  };
25627
- await maybeWarn(supervisor.currentRalphSessionId, "Strategist");
25628
25676
  await maybeWarn(supervisor.currentWorkerSessionId, "Worker");
25629
25677
  };
25630
25678
  const clearSessionTracking = async (sessionId, reason) => {
25631
25679
  const st = sessionMap.get(sessionId);
25632
25680
  sessionMap.delete(sessionId);
25633
25681
  let didUpdate = false;
25634
- const clearedRalph = supervisor.currentRalphSessionId === sessionId;
25635
- if (clearedRalph) {
25636
- supervisor.currentRalphSessionId = undefined;
25637
- didUpdate = true;
25638
- }
25639
25682
  if (supervisor.currentWorkerSessionId === sessionId) {
25640
25683
  supervisor.currentWorkerSessionId = undefined;
25641
25684
  didUpdate = true;
@@ -25648,7 +25691,7 @@ Set a new goal and run again.
25648
25691
  didUpdate = true;
25649
25692
  await persistReviewerState();
25650
25693
  }
25651
- if (supervisor.ralphHandoffTimer && clearedRalph) {
25694
+ if (supervisor.ralphHandoffTimer && supervisor.sessionId === sessionId) {
25652
25695
  clearTimeout(supervisor.ralphHandoffTimer);
25653
25696
  supervisor.ralphHandoffTimer = undefined;
25654
25697
  }
@@ -25730,77 +25773,30 @@ ${interpolate(templates.continuePrompt, { attempt: String(attemptN), verdict })}
25730
25773
  tool_ralph_spawn_worker_impl = async (_args, ctx) => {
25731
25774
  const sessionID = ctx.sessionID ?? "";
25732
25775
  const st = sessionMap.get(sessionID);
25733
- if (st?.role !== "ralph") {
25734
- throw new Error("ralph_spawn_worker() can only be called from a Ralph strategist session.");
25776
+ const isMainStrategist = st?.role === "main";
25777
+ if (!isMainStrategist && st?.role !== "ralph") {
25778
+ throw new Error("ralph_spawn_worker() must be called from the main strategist session.");
25735
25779
  }
25736
- if (st.workerSpawned) {
25737
- throw new Error("ralph_spawn_worker() has already been called for this attempt.");
25780
+ if (isMainStrategist && supervisor.sessionId && supervisor.sessionId !== sessionID) {
25781
+ throw new Error("ralph_spawn_worker() must be called from the bound supervisor session.");
25782
+ }
25783
+ if (supervisor.attempt < 1) {
25784
+ throw new Error("No active attempt. Start with ralph_create_supervisor_session(start_loop=true).");
25738
25785
  }
25739
25786
  if (supervisor.ralphHandoffTimer) {
25740
25787
  clearTimeout(supervisor.ralphHandoffTimer);
25741
25788
  supervisor.ralphHandoffTimer = undefined;
25742
25789
  }
25743
- const workerId = await spawnRlmWorker(st.attempt);
25790
+ supervisor.awaitingStrategist = false;
25791
+ const attempt = isMainStrategist ? supervisor.attempt : st.attempt;
25792
+ const workerId = await spawnRlmWorker(attempt);
25744
25793
  mutateSession(sessionID, (s) => {
25745
25794
  s.workerSpawned = true;
25746
25795
  });
25747
25796
  supervisor.ralphHandoffRetries = 0;
25748
- supervisor.ralphHandoffAttempt = st.attempt;
25749
- await notifySupervisor(`ralph/attempt-${st.attempt}`, `Delegated coding to worker session ${workerId}.`, "info", true, sessionID);
25750
- return JSON.stringify({ ok: true, workerSessionId: workerId, attempt: st.attempt }, null, 2);
25751
- };
25752
- const spawnRalphSession = async (attempt) => {
25753
- const result = await client.session.create({
25754
- body: { title: `ralph-strategist-attempt-${attempt}` }
25755
- });
25756
- const ralphId = result.data?.id ?? `ralph-${Date.now()}`;
25757
- if (supervisor.ralphHandoffAttempt !== attempt) {
25758
- supervisor.ralphHandoffAttempt = attempt;
25759
- supervisor.ralphHandoffRetries = 0;
25760
- }
25761
- supervisor.currentRalphSessionId = ralphId;
25762
- sessionMap.set(ralphId, freshSession("ralph", attempt));
25763
- mutateSession(ralphId, (s) => {
25764
- s.lastProgressAt = Date.now();
25765
- });
25766
- const promptText = interpolate(templates.ralphSessionPrompt, {
25767
- attempt: String(attempt),
25768
- nextAttempt: String(attempt + 1)
25769
- });
25770
- const promptOk = await sendPromptWithFallback(ralphId, promptText, `Strategist prompt (attempt ${attempt})`, ralphId);
25771
- if (!promptOk) {
25772
- supervisor.currentRalphSessionId = undefined;
25773
- await client.session.abort({ path: { id: ralphId } }).catch(() => {});
25774
- await notifySupervisor(`supervisor/attempt-${attempt}`, "Strategist prompt failed; supervision paused. Retry with ralph_create_supervisor_session(restart_if_done=true).", "error", true);
25775
- supervisor.paused = true;
25776
- return;
25777
- }
25778
- if (supervisor.ralphHandoffTimer) {
25779
- clearTimeout(supervisor.ralphHandoffTimer);
25780
- }
25781
- const cfg = await run(getConfig());
25782
- const timeoutMs = Math.max(cfg.strategistHandoffMinutes, 1) * 60000;
25783
- supervisor.ralphHandoffTimer = setTimeout(async () => {
25784
- if (supervisor.done || supervisor.paused)
25785
- return;
25786
- if (supervisor.currentRalphSessionId !== ralphId)
25787
- return;
25788
- const st = sessionMap.get(ralphId);
25789
- if (st?.workerSpawned)
25790
- return;
25791
- const retries = supervisor.ralphHandoffRetries ?? 0;
25792
- if (retries < cfg.strategistHandoffMaxRetries) {
25793
- supervisor.ralphHandoffRetries = retries + 1;
25794
- await notifySupervisor(`ralph/attempt-${attempt}`, `Strategist did not hand off; retrying strategist (${retries + 1}/${cfg.strategistHandoffMaxRetries}).`, "warning", true, ralphId);
25795
- supervisor.currentRalphSessionId = undefined;
25796
- await client.session.abort({ path: { id: ralphId } }).catch(() => {});
25797
- await spawnRalphSession(attempt);
25798
- return;
25799
- }
25800
- await notifySupervisor(`ralph/attempt-${attempt}`, "Strategist did not hand off after retries; supervision paused. Use ralph_create_supervisor_session(restart_if_done=true) to retry.", "error", true, ralphId);
25801
- supervisor.paused = true;
25802
- }, timeoutMs);
25803
- await notifySupervisor(`supervisor/attempt-${attempt}`, `Spawned Ralph strategist session ${ralphId}.`, "info", true);
25797
+ supervisor.ralphHandoffAttempt = attempt;
25798
+ await notifySupervisor(`supervisor/attempt-${attempt}`, `Delegated coding to worker session ${workerId}.`, "info", true, sessionID);
25799
+ return JSON.stringify({ ok: true, workerSessionId: workerId, attempt }, null, 2);
25804
25800
  };
25805
25801
  const handleWorkerIdle = async (workerSessionId) => {
25806
25802
  if (supervisor.currentWorkerSessionId !== workerSessionId)
@@ -25819,6 +25815,11 @@ ${interpolate(templates.continuePrompt, { attempt: String(attemptN), verdict })}
25819
25815
  const { verdict, details } = await runAndParseVerify();
25820
25816
  if (verdict === "pass") {
25821
25817
  supervisor.done = true;
25818
+ supervisor.awaitingStrategist = false;
25819
+ if (supervisor.ralphHandoffTimer) {
25820
+ clearTimeout(supervisor.ralphHandoffTimer);
25821
+ supervisor.ralphHandoffTimer = undefined;
25822
+ }
25822
25823
  await run(writeFile(NodePath.join(worktree, FILES.NEXT_RALPH), interpolate(templates.doneFileContent, { timestamp: nowISO() })));
25823
25824
  await client.tui.showToast({
25824
25825
  body: { title: "Ralph: Done", message: "Verification passed. Loop complete.", variant: "success" }
@@ -25840,38 +25841,17 @@ ${interpolate(templates.continuePrompt, { attempt: String(attemptN), verdict })}
25840
25841
  await notifySupervisor(`worker/attempt-${supervisor.attempt}`, `Verification ${verdict}. Preparing next attempt.`, verdict === "fail" ? "warning" : "info", true, workerSessionId);
25841
25842
  supervisor.attempt += 1;
25842
25843
  await rolloverState(supervisor.attempt - 1, verdict, details);
25843
- await spawnRalphSession(supervisor.attempt);
25844
- };
25845
- const handleRalphSessionIdle = async (ralphSessionId) => {
25846
- if (supervisor.currentRalphSessionId !== ralphSessionId)
25847
- return;
25848
- if (supervisor.done)
25849
- return;
25850
- supervisor.currentRalphSessionId = undefined;
25851
- const st = sessionMap.get(ralphSessionId);
25852
- if (st && !st.reportedStatus) {
25853
- await notifySupervisor(`ralph/attempt-${st.attempt}`, "No explicit strategist status reported before idle.", "info", true, ralphSessionId);
25854
- }
25855
- if (!st?.workerSpawned) {
25856
- await client.tui.showToast({
25857
- body: {
25858
- title: "Ralph: no worker spawned",
25859
- message: `Ralph session for attempt ${st?.attempt ?? supervisor.attempt} ended without calling ralph_spawn_worker().`,
25860
- variant: "warning"
25861
- }
25862
- }).catch(() => {});
25863
- await notifySupervisor(`ralph/attempt-${st?.attempt ?? supervisor.attempt}`, "Strategist went idle without spawning a worker.", "warning", true, ralphSessionId);
25864
- }
25844
+ await promptStrategistInMain(supervisor.attempt);
25865
25845
  };
25866
25846
  const handleMainIdle = async (sessionID) => {
25867
25847
  if (supervisor.done)
25868
25848
  return;
25869
25849
  if (supervisor.paused)
25870
25850
  return;
25871
- if (supervisor.currentRalphSessionId)
25872
- return;
25873
25851
  if (supervisor.currentWorkerSessionId)
25874
25852
  return;
25853
+ if (supervisor.awaitingStrategist)
25854
+ return;
25875
25855
  const cfg = await run(getConfig());
25876
25856
  if (!cfg.enabled)
25877
25857
  return;
@@ -25892,7 +25872,7 @@ ${interpolate(templates.continuePrompt, { attempt: String(attemptN), verdict })}
25892
25872
  }
25893
25873
  supervisor.attempt = 1;
25894
25874
  await notifySupervisor("supervisor", "Starting Ralph loop at attempt 1.", "info", true, sessionID);
25895
- await spawnRalphSession(1);
25875
+ await promptStrategistInMain(1);
25896
25876
  };
25897
25877
  return {
25898
25878
  tool: {
@@ -25931,7 +25911,7 @@ ${interpolate(templates.continuePrompt, { attempt: String(attemptN), verdict })}
25931
25911
  output.system = output.system ?? [];
25932
25912
  const sessionID = input.sessionID ?? input.session_id ?? input.session?.id;
25933
25913
  const role = sessionMap.get(sessionID ?? "")?.role;
25934
- const base = role === "worker" || role === "subagent" ? templates.workerSystemPrompt : role === "ralph" ? templates.ralphSessionSystemPrompt : templates.systemPrompt;
25914
+ const base = role === "worker" || role === "subagent" ? templates.workerSystemPrompt : templates.systemPrompt;
25935
25915
  const full = templates.systemPromptAppend ? `${base}
25936
25916
  ${templates.systemPromptAppend}` : base;
25937
25917
  output.system.push(full);
@@ -25973,8 +25953,6 @@ ${templates.systemPromptAppend}` : base;
25973
25953
  const state = sessionMap.get(sessionID);
25974
25954
  if (state?.role === "worker" && supervisor.currentWorkerSessionId !== sessionID)
25975
25955
  return;
25976
- if (state?.role === "ralph" && supervisor.currentRalphSessionId !== sessionID)
25977
- return;
25978
25956
  if (state) {
25979
25957
  mutateSession(sessionID, (s) => {
25980
25958
  s.lastProgressAt = Date.now();
@@ -25995,10 +25973,6 @@ ${templates.systemPromptAppend}` : base;
25995
25973
  await handleWorkerIdle(sessionID).catch((err) => {
25996
25974
  appLog("error", "handleWorkerIdle error", { error: String(err), sessionID });
25997
25975
  });
25998
- } else if (supervisor.currentRalphSessionId === sessionID) {
25999
- await handleRalphSessionIdle(sessionID).catch((err) => {
26000
- appLog("error", "handleRalphSessionIdle error", { error: String(err), sessionID });
26001
- });
26002
25976
  } else {
26003
25977
  await handleMainIdle(sessionID).catch((err) => {
26004
25978
  appLog("error", "handleMainIdle error", { error: String(err), sessionID });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-ralph-rlm",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "description": "OpenCode plugin: Ralph outer loop + RLM inner loop. Iterative AI development with file-first discipline and sub-agent support.",
5
5
  "type": "module",
6
6
  "main": "./dist/ralph-rlm.js",