Skip to content

[Plan Mode 2/8] Plan-state foundation + core backend MVP#70011

Closed
100yenadmin wants to merge 2 commits into
openclaw:mainfrom
electricsheephq:restack/68939-pr2-3-foundation-backend
Closed

[Plan Mode 2/8] Plan-state foundation + core backend MVP#70011
100yenadmin wants to merge 2 commits into
openclaw:mainfrom
electricsheephq:restack/68939-pr2-3-foundation-backend

Conversation

@100yenadmin

Copy link
Copy Markdown
Contributor

Summary

[Plan Mode 2/8] — part of the 8-part decomposition of #68939, stacking on #69449 (Part 1).

Stacks on: previous part (see [Plan Mode 1/8] / [Plan Mode 1/9 #69449]).

Stacks logically on Part 1 (#69449); GitHub diff is shown vs main. Combines Parts 2 (plan-state foundation: storage, types) + Part 3 (core backend MVP: gates, nudges).

Lines: ~5615

Diff note

This PR is opened cross-repo (head: 100yenadmin:restack/68939-pr2-3-foundation-backend) against openclaw:main. Because GitHub doesn't support stacked-PR bases that point at fork refs, the displayed diff cumulatively includes all previous parts in the stack. Reviewers should focus on the changes specific to this part — see commit list for the new content beyond what previous parts established.

Test status

After post-v2026.4.21 rebase: all targeted plan-mode tests pass (53/53 in unit-fast for approval + integration suites). The full suite has pre-existing vitest workspace project-name conflicts unrelated to this work.

Related

Copilot AI 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.

Pull request overview

Lays down the Plan Mode foundation (session state, storage/types) and a core backend MVP (mutation gating + planning nudges), plus skill plan-template seeding and protocol wiring to support plan-mode-related session patches.

Changes:

  • Add plan-mode runtime types, approval state machine, and a mutation gate enforced in the before-tool-call hook.
  • Extend update_plan with merge-mode, richer step fields, completion detection/events, and add plan persistence primitives (PlanStore) + plan hydration utilities.
  • Add skill plan-template parsing/seeding and thread plan-mode state through the embedded runner/tool assembly.

Reviewed changes

Copilot reviewed 45 out of 45 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/infra/agent-events.ts Expands agent event shapes and run context to carry plan/plan-mode state and subagent gating metadata.
src/gateway/sessions-patch.ts Adds sessions.patch handling for planMode toggling with config gating.
src/gateway/protocol/schema/sessions.ts Extends sessions.patch schema with planMode wire field.
src/config/zod-schema.ts Adds skills.limits.maxPlanTemplateSteps config validation.
src/config/zod-schema.agent-defaults.ts Adds agent defaults for auto-continue and plan-mode enablement.
src/config/types.skills.ts Documents/defines maxPlanTemplateSteps skill limit.
src/config/types.agent-defaults.ts Adds config types for auto-continue and plan mode toggle.
src/config/sessions/types.ts Adds SessionEntry.planMode structural state and refactors plugin status/trace line extraction.
src/agents/tools/update-plan-tool.ts Adds merge mode, richer step schema, completion emission, and event payload improvements.
src/agents/tools/update-plan-tool.parity.test.ts Adds parity + merge-mode behavior tests for update_plan.
src/agents/tools/enter-plan-mode-tool.ts Introduces enter_plan_mode tool.
src/agents/tools/exit-plan-mode-tool.ts Introduces exit_plan_mode tool (approval request trigger).
src/agents/tool-description-presets.ts Adds descriptions/summaries for new plan-mode tools.
src/agents/tool-catalog.ts Registers plan-mode tools in catalog for policy/profile filtering.
src/agents/test-helpers/fast-openclaw-tools-sessions.ts Updates tool mocks for new update_plan exports/options.
src/agents/skills/workspace.ts Carries plan templates into skill snapshots for snapshot-backed runs.
src/agents/skills/types.ts Adds plan template types and snapshot resolvedPlanTemplates.
src/agents/skills/skill-planner.ts Builds normalized plan seed payloads from skill templates.
src/agents/skills/skill-planner.test.ts Adds unit tests for plan-template payload building and seeding integration.
src/agents/skills/frontmatter.ts Parses plan-template / planTemplate from SKILL.md frontmatter.
src/agents/skills/frontmatter.test.ts Adds tests for plan-template frontmatter parsing behavior.
src/agents/plan-store.ts Introduces disk-persisted plan store with confinement + locking hardening.
src/agents/plan-store.test.ts Adds tests for PlanStore read/write/lock/confinement.
src/agents/plan-mode/* Adds plan-mode types, approval resolution, mutation gate, and tests.
src/agents/plan-hydration.ts Adds post-compaction plan hydration formatting.
src/agents/plan-hydration.test.ts Adds tests for hydration formatting/filtering.
src/agents/pi-tools.ts Threads planMode into hook context via tool assembly.
src/agents/pi-tools.before-tool-call.ts Enforces mutation gate during plan mode before plugin hookRunner.
src/agents/pi-embedded-runner/skills-runtime.ts Wires skill plan-template seeding + snapshot fallback.
src/agents/pi-embedded-runner/run/params.ts Threads planMode through embedded runner params.
src/agents/pi-embedded-runner/run/incomplete-turn.ts Adjusts retry limits and adds plan-mode-aware retry/ACK behavior.
src/agents/pi-embedded-runner/run.ts Adds auto-continue logic, prompt-addition de-dupe, and threads planMode into attempt setup.
src/agents/pi-embedded-runner/run.incomplete-turn.test.ts Updates tests for retry-limit + auto-continue and plan-mode-aware retry behavior.
src/agents/openclaw-tools.ts Threads runId into update_plan and registers plan-mode tools.
src/agents/openclaw-tools.registration.ts Adds agents.defaults.planMode.enabled gating for plan-mode tools.
apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift Updates SessionsPatchParams fields for protocol changes.
apps/macos/Sources/OpenClawProtocol/GatewayModels.swift Same as shared Swift protocol models.
Comments suppressed due to low confidence (1)

src/agents/pi-embedded-runner/run.ts:312

  • resolveHookModelSelection supports attachments (and buildBeforeModelResolveAttachments() still exists), but this call no longer passes image attachments. That breaks before_model_resolve hooks that select provider/model based on whether images are present. Pass attachments: buildBeforeModelResolveAttachments(params.images) to preserve the hook contract.
      const hookSelection = await resolveHookModelSelection({
        prompt: params.prompt,
        provider,
        modelId,
        hookRunner,
        hookContext: hookCtx,
      });

Comment on lines 1781 to +1784
planningOnlyRetryAttempts += 1;
planningOnlyRetryInstruction = nextPlanningOnlyRetryInstruction;
planningOnlyRetryInstruction = resolveEscalatingPlanningRetryInstruction(
planningOnlyRetryAttempts - 1,
);
public let planmode: AnyCodable?
public let planapproval: AnyCodable?
public let lastplansteps: [[String: AnyCodable]]?

Comment on lines 1761 to +1764
public let groupactivation: AnyCodable?
public let planmode: AnyCodable?
public let planapproval: AnyCodable?
public let lastplansteps: [[String: AnyCodable]]?
Comment on lines +330 to +343
createSessionsSpawnTool({
agentSessionKey: options?.agentSessionKey,
agentChannel: options?.agentChannel,
agentAccountId: options?.agentAccountId,
agentTo: options?.agentTo,
agentThreadId: options?.agentThreadId,
agentGroupId: options?.agentGroupId,
agentGroupChannel: options?.agentGroupChannel,
agentGroupSpace: options?.agentGroupSpace,
agentMemberRoleIds: options?.agentMemberRoleIds,
sandboxed: options?.sandboxed,
requesterAgentIdOverride: options?.requesterAgentIdOverride,
workspaceDir: spawnWorkspaceDir,
}),
Comment on lines 421 to 433
export function resolveAckExecutionFastPathInstruction(params: {
provider?: string;
modelId?: string;
prompt: string;
/** Plan mode disables ack fast-path: a "do it" reply is the approval signal, not a planning skip. */
planModeActive?: boolean;
}): string | null {
if (
!shouldApplyPlanningOnlyRetryGuard({
provider: params.provider,
modelId: params.modelId,
planModeActive: params.planModeActive,
}) ||
@greptile-apps

greptile-apps Bot commented Apr 22, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces the plan-mode foundation and core backend MVP: a persistent PlanStore with hardened file I/O (atomic writes, file locking with PID-liveness and hard-cap eviction, symlink confinement via O_NOFOLLOW + ancestor realpath), a plan approval state machine (resolvePlanApproval), a mutation gate that blocks write/exec/etc. tools while plan mode is active, and new enter_plan_mode / exit_plan_mode / update_plan tools with closure-gate fields (acceptanceCriteria/verifiedCriteria). The code carries extensive inline adversarial-review traces and already incorporates a large number of review-fix patches. The one P1 concern is that resolvePlanApproval allows \"approve\" and \"edit\" actions against a session whose approval state is \"none\" (no plan proposed yet), which would silently flip mode to \"normal\" for any caller that omits expectedApprovalId.

Confidence Score: 4/5

Safe to merge after addressing the resolvePlanApproval none-state bypass; remaining comments are P2 quality improvements.

One P1 finding: resolvePlanApproval allows approve/edit on the none state (no pending proposal) when expectedApprovalId is omitted, which could silently unlock mutations. The fix is small and targeted. All other findings are P2 with no current observable misbehavior.

src/agents/plan-mode/approval.ts — the terminal-state guard and approve/edit action precondition logic.

Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/agents/plan-mode/approval.ts
Line: 72-81

Comment:
**`"none"` state allows approve/edit without a pending proposal**

The terminal-state guard explicitly passes `"none"` through alongside `"pending"` and `"rejected"`, and the `"approve"`/`"edit"` actions have no additional check for the current approval state. Unlike `"timeout"` (which is already gated on `current.approval !== "pending"`), an `"approve"` or `"edit"` action against a session in the `"none"` state will succeed and flip `mode` to `"normal"` — unlocking all mutation tools — without any plan ever being proposed.

The `expectedApprovalId` guard provides protection only when the caller provides the field. Since the parameter is optional and `current.approvalId` is `undefined` when approval is `"none"`, a caller that omits `expectedApprovalId` (or a future code path that calls `resolvePlanApproval` directly without it) would bypass the gate entirely.

Adding `"none"` to the terminal-state set, or gating `"approve"` and `"edit"` on `current.approval === "pending"`, closes the gap without changing behavior for the normal flow.

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/plan-mode/mutation-gate.ts
Line: 57-81

Comment:
**`echo` and `printenv` in read-only allowlist can surface env secrets in plan mode**

The prefix check itself is correct (word-boundary via trailing space). However, `"echo"` and `"printenv"` both emit environment variables directly — e.g., `echo $ANTHROPIC_API_KEY` or `printenv MY_SECRET` would be allowed. While the agent already has env access, minimising surface area in plan mode (where the agent is supposed to only read/plan, not act) may be preferable. Consider removing or documenting the explicit decision.

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/plan-store.ts
Line: 261

Comment:
**String prefix vs. path prefix in `confine()` while condition**

`probe.startsWith(this.baseDir)` is a raw string prefix check. If `baseDir` is `/home/user/plans`, a probe of `/home/user/plans2/...` satisfies the check as a string but is not lexically under `baseDir`. Security is preserved in practice because the initial `path.relative` guard (path-segment–aware) already throws for any escaping target before this loop is entered. Using `probe.startsWith(this.baseDir + path.sep) || probe === this.baseDir` makes the invariant explicit and removes the edge-case ambiguity.

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

Reviews (1): Last reviewed commit: "feat(plan-mode): split PR3 core backend ..." | Re-trigger Greptile

Comment on lines +72 to +81
if (
current.approval !== "pending" &&
current.approval !== "rejected" &&
current.approval !== "none"
) {
return current;
}
if (action === "timeout" && current.approval !== "pending") {
return current;
}

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 "none" state allows approve/edit without a pending proposal

The terminal-state guard explicitly passes "none" through alongside "pending" and "rejected", and the "approve"/"edit" actions have no additional check for the current approval state. Unlike "timeout" (which is already gated on current.approval !== "pending"), an "approve" or "edit" action against a session in the "none" state will succeed and flip mode to "normal" — unlocking all mutation tools — without any plan ever being proposed.

The expectedApprovalId guard provides protection only when the caller provides the field. Since the parameter is optional and current.approvalId is undefined when approval is "none", a caller that omits expectedApprovalId (or a future code path that calls resolvePlanApproval directly without it) would bypass the gate entirely.

Adding "none" to the terminal-state set, or gating "approve" and "edit" on current.approval === "pending", closes the gap without changing behavior for the normal flow.

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

Comment:
**`"none"` state allows approve/edit without a pending proposal**

The terminal-state guard explicitly passes `"none"` through alongside `"pending"` and `"rejected"`, and the `"approve"`/`"edit"` actions have no additional check for the current approval state. Unlike `"timeout"` (which is already gated on `current.approval !== "pending"`), an `"approve"` or `"edit"` action against a session in the `"none"` state will succeed and flip `mode` to `"normal"` — unlocking all mutation tools — without any plan ever being proposed.

The `expectedApprovalId` guard provides protection only when the caller provides the field. Since the parameter is optional and `current.approvalId` is `undefined` when approval is `"none"`, a caller that omits `expectedApprovalId` (or a future code path that calls `resolvePlanApproval` directly without it) would bypass the gate entirely.

Adding `"none"` to the terminal-state set, or gating `"approve"` and `"edit"` on `current.approval === "pending"`, closes the gap without changing behavior for the normal flow.

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

Comment on lines +57 to +81
const READ_ONLY_EXEC_PREFIXES = [
"ls",
"cat",
"pwd",
"git status",
"git log",
"git diff",
"git show",
"which",
"find",
"grep",
"rg",
"head",
"tail",
"wc",
"file",
"stat",
"du",
"df",
"echo",
"printenv",
"whoami",
"hostname",
"uname",
];

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 echo and printenv in read-only allowlist can surface env secrets in plan mode

The prefix check itself is correct (word-boundary via trailing space). However, "echo" and "printenv" both emit environment variables directly — e.g., echo $ANTHROPIC_API_KEY or printenv MY_SECRET would be allowed. While the agent already has env access, minimising surface area in plan mode (where the agent is supposed to only read/plan, not act) may be preferable. Consider removing or documenting the explicit decision.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/plan-mode/mutation-gate.ts
Line: 57-81

Comment:
**`echo` and `printenv` in read-only allowlist can surface env secrets in plan mode**

The prefix check itself is correct (word-boundary via trailing space). However, `"echo"` and `"printenv"` both emit environment variables directly — e.g., `echo $ANTHROPIC_API_KEY` or `printenv MY_SECRET` would be allowed. While the agent already has env access, minimising surface area in plan mode (where the agent is supposed to only read/plan, not act) may be preferable. Consider removing or documenting the explicit decision.

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

Comment thread src/agents/plan-store.ts
// walk up). If it resolves outside baseDir, reject — a parent
// symlink would redirect us elsewhere.
let probe = path.dirname(target);
while (probe.startsWith(this.baseDir)) {

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 String prefix vs. path prefix in confine() while condition

probe.startsWith(this.baseDir) is a raw string prefix check. If baseDir is /home/user/plans, a probe of /home/user/plans2/... satisfies the check as a string but is not lexically under baseDir. Security is preserved in practice because the initial path.relative guard (path-segment–aware) already throws for any escaping target before this loop is entered. Using probe.startsWith(this.baseDir + path.sep) || probe === this.baseDir makes the invariant explicit and removes the edge-case ambiguity.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/plan-store.ts
Line: 261

Comment:
**String prefix vs. path prefix in `confine()` while condition**

`probe.startsWith(this.baseDir)` is a raw string prefix check. If `baseDir` is `/home/user/plans`, a probe of `/home/user/plans2/...` satisfies the check as a string but is not lexically under `baseDir`. Security is preserved in practice because the initial `path.relative` guard (path-segment–aware) already throws for any escaping target before this loop is entered. Using `probe.startsWith(this.baseDir + path.sep) || probe === this.baseDir` makes the invariant explicit and removes the edge-case ambiguity.

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: 76d9b14550

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines 306 to 308
const hookSelection = await resolveHookModelSelection({
prompt: params.prompt,
attachments: buildBeforeModelResolveAttachments(params.images),
provider,

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 Restore attachments in before_model_resolve hook input

The resolveHookModelSelection call now omits attachments, so before_model_resolve hooks lose image metadata even though the hook event type still supports it. In runs with image inputs, hooks that select a vision-capable provider/model based on attachments will now receive only prompt text and can choose the wrong model, causing avoidable multimodal failures; this regression is introduced by dropping the buildBeforeModelResolveAttachments(params.images) argument at this callsite.

Useful? React with 👍 / 👎.

Comment on lines +330 to +333
createSessionsSpawnTool({
agentSessionKey: options?.agentSessionKey,
agentChannel: options?.agentChannel,
agentAccountId: options?.agentAccountId,

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 Remove unconditional second sessions_spawn registration

This adds a second createSessionsSpawnTool(...) outside the existing embedded ? [] : [...] guard, so non-embedded runs now expose duplicate sessions_spawn tools and embedded runs unexpectedly expose sessions_spawn at all. Because core tools are forwarded without deduplication, this changes runtime tool surface and can enable spawn behavior in contexts that were previously intentionally blocked.

Useful? React with 👍 / 👎.

Comment on lines +50 to +53
content: [],
details: {
status: "entered" as const,
mode: "plan" as const,

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 Persist plan mode when enter_plan_mode reports success

The tool returns status: "entered"/mode: "plan" but performs no state transition, and in this commit there is no runtime handler that applies a sessions.patch for enter_plan_mode. As a result, the agent is told plan mode was entered while SessionEntry.planMode remains unchanged, so the mutation gate never arms and plan-mode safety can be bypassed in normal execution.

Useful? React with 👍 / 👎.

@100yenadmin

Copy link
Copy Markdown
Contributor Author

Closed for sequential rollout — please disregard cumulative diff.

This PR was opened with --base main against the upstream repo. Cross-repo PRs can't reference fork branches as bases, so opening all 8 stack parts simultaneously produced cumulative diffs (each PR included all prior parts, 10k → 30k lines). That's review-hostile and reproduces exactly the problem the original umbrella #68939 had.

The fix: sequential rollout. Each [Plan Mode N/9] PR opens only AFTER the previous one merges to main, so its diff is incremental.

Currently open:

Next: when 1/9 merges, [Plan Mode 2/9] Core backend MVP opens with a clean ~2,000-line diff. Etc.

This PR's branch is preserved on the fork and will be reopened with a clean diff in its turn.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling app: macos App: macos app: web-ui App: web-ui gateway Gateway runtime size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants