Skip to content

feat(plan-mode): minimal plan mode with mutation gate and resume#61845

Closed
nick0809cn-ux wants to merge 4 commits into
openclaw:mainfrom
nick0809cn-ux:feat/plan-mode-upstream
Closed

feat(plan-mode): minimal plan mode with mutation gate and resume#61845
nick0809cn-ux wants to merge 4 commits into
openclaw:mainfrom
nick0809cn-ux:feat/plan-mode-upstream

Conversation

@nick0809cn-ux

Copy link
Copy Markdown

Summary

Adds a minimal Plan Mode runtime mode that lets agents plan before executing complex changes. When active, mutation tools are gated behind an explicit confirmation step.

Changes

Core: Runtime Mode Enum & Session Persistence

  • Introduces SessionRuntimeMode = "plan" | "normal" | "auto" on SessionEntry and AcpSessionRuntimeOptions
  • New helpers: getSessionRuntimeMode, setSessionRuntimeMode, getSessionPlanState, updateSessionPlanState in src/config/sessions/runtime-mode.ts
  • readSessionRuntimeStateFromStorePath allows reading mode directly from a known store path (no config dependency)
  • ACP runtime option validation now uses the typed SessionRuntimeMode enum

Tools: Plan Mode Tool Set (5 tools)

  • enter_plan_mode — sets runtime to "plan", initializes plan state
  • exit_plan_mode — sets runtime to "normal", marks confirmedAt
  • todo_write — writes/updates plan content and todo list in session metadata
  • task_create — creates a session-scoped task from a plan todo (reuses Task Registry)
  • task_update — updates task status, progress, and terminal summaries

Safety: Mutation Gate Hook

  • New before_tool_call hook (plan-mode-hook.ts) that blocks mutation tools when runtimeMode === "plan"
  • Default blocklist: apply_patch, edit, exec, gateway, message, nodes, process, sessions_send, sessions_spawn, subagents, write
  • Pattern matching: any tool ending in .write, .edit, or .delete is also blocked
  • Structured block reason includes: error code, tool name, current mode, required confirmation action
  • Registered as a core plugin hook in initializeGlobalHookRunner

Resume: Persistence & System Prompt Integration

  • Plan state (content, todos, enteredAt, confirmedAt) persists in session store
  • On resume, buildEmbeddedSystemPrompt injects a ## Plan Mode section with current plan and todos
  • Mutation gate remains active across hook runner reinitialization
  • session_status tool surfaces plan mode, plan content, and todo list

Test Coverage

20 tests across 7 test files:

  • Runtime mode enum, validation, and session store read/write
  • Plan mode enter/exit lifecycle with todo persistence
  • Mutation gate: blocks in plan mode, allows after exit, configurable tool list
  • Integration: plan state survives hook runner reinitialization, mutation tools blocked until confirmed
  • Resume: plan state recovered from session store, system prompt includes plan guidance, session_status shows plan details

Files Changed

New files:

  • src/config/sessions/runtime-mode.ts + .test.ts
  • src/acp/control-plane/runtime-options.test.ts
  • src/agents/tools/plan-mode-tools.ts + .test.ts
  • src/agents/plan-mode-hook.ts + .test.ts
  • src/agents/plan-mode.before-tool-call.integration.test.ts
  • src/agents/plan-mode-resume.test.ts

Modified files:

  • src/config/sessions/types.ts — SessionRuntimeMode, SessionPlanState types
  • src/config/sessions.ts — re-export runtime-mode module
  • src/acp/control-plane/runtime-options.ts — typed runtimeMode validation
  • src/agents/openclaw-tools.ts — register 5 plan mode tools
  • src/agents/tool-description-presets.ts — tool descriptions
  • src/agents/tool-catalog.ts + tool-display-config.ts — display config
  • src/agents/pi-embedded-runner/system-prompt.ts — plan mode prompt section
  • src/agents/pi-embedded-runner/run/attempt.ts — plan state injection
  • src/agents/tools/session-status-tool.ts — plan mode status display
  • src/plugins/hook-runner-global.ts — register mutation gate hook
  • src/tasks/task-registry.ts — export helpers for plan mode tools

Design Document

See docs/design/plan-mode-slice.md for the original design rationale and implementation slices.

@openclaw-barnacle openclaw-barnacle Bot added docs Improvements or additions to documentation scripts Repository scripts agents Agent runtime and tooling size: XL labels Apr 6, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 70141855f8

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +716 to +718
? readSessionRuntimeStateFromStorePath({
sessionKey: params.sessionKey,
storePath: path.join(path.dirname(path.resolve(params.sessionFile)), "sessions.json"),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Read plan mode state from configured session store path

This lookup hardcodes <sessionFileDir>/sessions.json, so resumed runs will miss persisted plan mode state whenever session.store uses a custom filename/path (for example /var/openclaw/custom-store.json or a non-default per-agent template). In that configuration, plan mode is still active in the real store but buildEmbeddedSystemPrompt won't get planModeActive, so the ## Plan Mode guardrails silently disappear on resume. Resolve the store path from config/session key here instead of assuming sessions.json.

Useful? React with 👍 / 👎.

Comment on lines +75 to +76
if (!SESSION_RUNTIME_MODES.includes(mode as (typeof SESSION_RUNTIME_MODES)[number])) {
failInvalidOption(`Runtime mode must be one of ${SESSION_RUNTIME_MODES.join(", ")}.`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve backend-defined ACP runtime modes

This new allowlist rejects every runtime mode except plan, normal, and auto, but ACP mode values are backend-defined (setMode accepts arbitrary strings) and were previously passed through. As a result, /acp set-mode <custom-mode> now fails before reaching compatible backends, which is a compatibility regression for adapters that expose non-core mode tokens.

Useful? React with 👍 / 👎.

Comment on lines +136 to +140
nextEntry.acp = {
...params.entry.acp,
runtimeOptions: {
...params.entry.acp.runtimeOptions,
runtimeMode: params.mode,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid forcing ACP mode overrides during plan toggles

Mirroring every plan-mode change into acp.runtimeOptions.runtimeMode makes enter_plan_mode/exit_plan_mode implicitly set ACP runtime controls for any ACP-bound session. For runtimes without session/set_mode, later control application throws unsupported-control errors, so simply toggling plan mode can break subsequent ACP turns. This write needs capability-aware gating (or should stay session-local) instead of unconditional ACP option mutation.

Useful? React with 👍 / 👎.

@greptile-apps

greptile-apps Bot commented Apr 6, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds a minimal Plan Mode runtime with a mutation gate, session-persistent plan state, and resume support. The implementation is well-structured and backed by solid test coverage across 7 test files. All findings are P2.

Confidence Score: 5/5

Safe to merge; all findings are P2 style/cleanup items with no blocking correctness issues.

No P0 or P1 issues found. The mutation gate, session persistence, and system-prompt injection are all correct. The three findings are a misleading debug-log count, a potential store-path mismatch under a non-default session.store config, and a silent plan-state reset when re-entering plan mode — none blocking.

src/agents/pi-embedded-runner/run/attempt.ts (store path derivation at line 718) and src/plugins/hook-runner-global.ts (hook count log at line 50)

Comments Outside Diff (1)

  1. src/plugins/hook-runner-global.ts, line 50-53 (link)

    P2 registry.hooks does not count typedHooks

    The plan-mode hook is registered via registry.typedHooks.push(...), but the debug-log counter reads registry.hooks.length, which is a separate array the HookRunner does not use (it reads typedHooks exclusively). After this PR, initializeGlobalHookRunner will always log "0 hooks" even when the mutation gate is active.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/plugins/hook-runner-global.ts
    Line: 50-53
    
    Comment:
    **`registry.hooks` does not count `typedHooks`**
    
    The plan-mode hook is registered via `registry.typedHooks.push(...)`, but the debug-log counter reads `registry.hooks.length`, which is a separate array the `HookRunner` does not use (it reads `typedHooks` exclusively). After this PR, `initializeGlobalHookRunner` will always log "0 hooks" even when the mutation gate is active.
    
    
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/plugins/hook-runner-global.ts
Line: 50-53

Comment:
**`registry.hooks` does not count `typedHooks`**

The plan-mode hook is registered via `registry.typedHooks.push(...)`, but the debug-log counter reads `registry.hooks.length`, which is a separate array the `HookRunner` does not use (it reads `typedHooks` exclusively). After this PR, `initializeGlobalHookRunner` will always log "0 hooks" even when the mutation gate is active.

```suggestion
  const hookCount = registry.typedHooks.length + registry.hooks.length;
  if (hookCount > 0) {
    log.debug(`hook runner initialized with ${hookCount} registered hooks`);
  }
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/agents/pi-embedded-runner/run/attempt.ts
Line: 715-720

Comment:
**Hardcoded store path may miss custom `session.store` config**

The store path is derived from the transcript file's parent directory rather than from `resolveStorePath`. For users with a custom `session.store` config the two paths can diverge, so `planModeActive` will always be `false` on resume and the `## Plan Mode` system-prompt section will never be injected. `params.config` and `sessionAgentId` are both in scope here:

```
// add import: import { resolveStorePath } from "../../../config/sessions/paths.js";

const planModeState = params.sessionKey
  ? readSessionRuntimeStateFromStorePath({
      sessionKey: params.sessionKey,
      storePath: resolveStorePath(params.config?.session?.store, {
        agentId: sessionAgentId,
      }),
    })
  : null;
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/agents/tools/plan-mode-tools.ts
Line: 194-214

Comment:
**Re-entering plan mode silently clears existing plan state**

`enter_plan_mode` is not in the mutation blocklist, so it can be called while plan mode is already active. The `mutate` callback returns only `{ enteredAt, updatedAt }``updateSessionPlanState` replaces the entire `planState` field with this object, discarding any previously written `content` and `todos`. If the reset is intentional a short comment or an early-return guard (when `runtimeMode === "plan"` already) would make the behavior explicit.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "feat(plan-mode): surface persisted plan ..." | Re-trigger Greptile

Comment on lines +715 to +720
const planModeState = params.sessionKey
? readSessionRuntimeStateFromStorePath({
sessionKey: params.sessionKey,
storePath: path.join(path.dirname(path.resolve(params.sessionFile)), "sessions.json"),
})
: null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Hardcoded store path may miss custom session.store config

The store path is derived from the transcript file's parent directory rather than from resolveStorePath. For users with a custom session.store config the two paths can diverge, so planModeActive will always be false on resume and the ## Plan Mode system-prompt section will never be injected. params.config and sessionAgentId are both in scope here:

// add import: import { resolveStorePath } from "../../../config/sessions/paths.js";

const planModeState = params.sessionKey
  ? readSessionRuntimeStateFromStorePath({
      sessionKey: params.sessionKey,
      storePath: resolveStorePath(params.config?.session?.store, {
        agentId: sessionAgentId,
      }),
    })
  : null;
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/pi-embedded-runner/run/attempt.ts
Line: 715-720

Comment:
**Hardcoded store path may miss custom `session.store` config**

The store path is derived from the transcript file's parent directory rather than from `resolveStorePath`. For users with a custom `session.store` config the two paths can diverge, so `planModeActive` will always be `false` on resume and the `## Plan Mode` system-prompt section will never be injected. `params.config` and `sessionAgentId` are both in scope here:

```
// add import: import { resolveStorePath } from "../../../config/sessions/paths.js";

const planModeState = params.sessionKey
  ? readSessionRuntimeStateFromStorePath({
      sessionKey: params.sessionKey,
      storePath: resolveStorePath(params.config?.session?.store, {
        agentId: sessionAgentId,
      }),
    })
  : null;
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +194 to +214
execute: async () => {
const cfg = opts?.config ?? loadConfig();
const sessionKey = requireSessionKey(opts?.agentSessionKey);
const now = Date.now();
await setSessionRuntimeMode(sessionKey, "plan", cfg);
const state = await updateSessionPlanState({
sessionKey,
cfg,
mutate: () => ({
enteredAt: now,
updatedAt: now,
}),
});
return textResult("Plan mode enabled.", {
status: "enabled" as const,
sessionKey,
runtimeMode: "plan" as const,
planState: state?.planState ?? { enteredAt: now, updatedAt: now },
});
},
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Re-entering plan mode silently clears existing plan state

enter_plan_mode is not in the mutation blocklist, so it can be called while plan mode is already active. The mutate callback returns only { enteredAt, updatedAt }updateSessionPlanState replaces the entire planState field with this object, discarding any previously written content and todos. If the reset is intentional a short comment or an early-return guard (when runtimeMode === "plan" already) would make the behavior explicit.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/tools/plan-mode-tools.ts
Line: 194-214

Comment:
**Re-entering plan mode silently clears existing plan state**

`enter_plan_mode` is not in the mutation blocklist, so it can be called while plan mode is already active. The `mutate` callback returns only `{ enteredAt, updatedAt }``updateSessionPlanState` replaces the entire `planState` field with this object, discarding any previously written `content` and `todos`. If the reset is intentional a short comment or an early-return guard (when `runtimeMode === "plan"` already) would make the behavior explicit.

How can I resolve this? If you propose a fix, please make it concise.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f784b1182d

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +10 to +14
export const DEFAULT_PLAN_MODE_MUTATION_TOOL_NAMES = [
"apply_patch",
"edit",
"exec",
"gateway",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Block cron mutations while plan mode is active

The plan-mode mutation gate never treats cron as a mutation tool, so while runtimeMode === "plan" the agent can still call cron with mutating actions (for example add/update/remove/run) and cause side effects before user confirmation. That bypasses the core safety guarantee of plan mode and can modify scheduled jobs even though mutation tools are supposed to be blocked until exit_plan_mode.

Useful? React with 👍 / 👎.

Comment on lines +198 to +202
const runtimeMode =
(typeof params.cfg.session?.store === "string"
? getSessionRuntimeMode(params.sessionKey, params.cfg)
: undefined) ??
fallback?.runtimeMode ??

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Let store-path fallback run when runtime mode lookup misses

resolvePlanModeSnapshot intends to fall back to readSessionRuntimeStateFromStorePath, but getSessionRuntimeMode(...) already defaults to "auto" for any non-empty session key, so this branch never becomes undefined and the fallback runtime mode is effectively dead. In cases where the helper resolves the wrong store (for example legacy/non-agent keys in status lookups), session_status can report auto even when the actual resolved store entry is in plan mode.

Useful? React with 👍 / 👎.

@clawsweeper

clawsweeper Bot commented Apr 26, 2026

Copy link
Copy Markdown
Contributor

Closing this as duplicate or superseded after Codex automated review.

Close #61845 as superseded. Current main does not ship this PR's minimal plan-mode runtime; the broader Plan Mode direction moved through later plan-mode rollout attempts and is now represented by the active #71731 plugin-host RFC path plus #71732-#71737 decision threads. #61845 is no longer the right merge vehicle because it is stale, dirty, and has unresolved review concerns.

Best possible solution:

Close #61845 and keep remaining Plan Mode design on #71731 and #71732-#71737. If the feature moves forward, it should land as a fresh clean implementation after the plugin-host seams are settled, or through an explicit maintainer-approved core PR.

What I checked:

So I’m closing this here and keeping the remaining discussion on the canonical linked item.

Codex Review notes: model gpt-5.5, reasoning high; reviewed against 06d409dc2738.

@clawsweeper clawsweeper Bot closed this Apr 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling docs Improvements or additions to documentation scripts Repository scripts size: XL triage: dirty-candidate Candidate: broad unrelated surfaces; may need splitting or cleanup.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants