feat(plan-mode): full rollout + iter-1/2/3 hardening (consolidates PRs A/B/C/D/E/F/7/8/10/11)#68939
feat(plan-mode): full rollout + iter-1/2/3 hardening (consolidates PRs A/B/C/D/E/F/7/8/10/11)#68939100yenadmin wants to merge 171 commits into
Conversation
…injection scanning Consolidation of v3 PRs #66371, #66372, #66373, #66374, #66375 into a single review unit. Ports the Hermes Agent GPT-5 prompt stack to OpenClaw with Hermes-verbatim semantics (only mechanical tool-ID substitutions for OpenClaw's catalog: terminal→exec, execute_code→ code_execution, read_file→read, search_files→exec). ## Prompt additions (extensions/openai/prompt-overlay.ts) OPENAI_GPT5_TOOL_ENFORCEMENT — Hermes mandatory_tool_use block: "NEVER answer these from memory — ALWAYS use a tool" + 7 categories. OPENAI_GPT5_EXECUTION_BIAS enhanced with 3 Hermes subsections: - Act, Don't Ask (prompt_builder.py:221-229) - Tool Persistence (prompt_builder.py:198-204) - Verification (prompt_builder.py:238-245) ## Architecture (src/agents/system-prompt-contribution.ts) Add "tool_enforcement" to ProviderSystemPromptSectionId union so providers can inject mandatory tool-use guidance independently of execution_bias. GPT-5 overlay uses sectionOverrides.tool_enforcement. ## Security (src/agents/context-file-injection-scan.ts) Port Hermes's _CONTEXT_THREAT_PATTERNS (prompt_builder.py:36-52): 10 threat patterns + 10 invisible chars. Flagged files are BLOCKED (content replaced with placeholder), matching Hermes behavior. ## Tests - index.test.ts: EXPECTED_GPT5_STABLE_PREFIX helper + 7 assertions updated for tool_enforcement sectionOverride - context-file-injection-scan.test.ts: 17 tests covering all patterns, false-positive avoidance, and BLOCK behavior Part of GPT 5.4 Enhancement v3 sprint. Tracking: #66345. Supersedes: #66371, #66372, #66373, #66374, #66375.
Filename is interpolated into the BLOCKED string. A crafted file path containing brackets or newlines could confuse the model about what content is blocked. Strip ], [, and newlines from the filename.
…bosity Additions to the GPT-5.4 prompt overlay (interaction_style, output_contract, execution_bias sections): - Identity Enforcement: prime GPT-5.4 to adopt SOUL.md as primary identity, ban corporate default patterns - Voice Calibration: counter flat/analytical drift, anti-sycophancy reinforcement - Response Length Discipline: 200-word target with 95% confidence self-evaluation gate leveraging thinking mode, file-offload for genuine long-form - Investigation Discipline: prevent partial-findings-then-ask pattern, force parallel tool calls for independent lookups - Plan Confidence Gate: 95%+ → execute without approval, 80-94% → state uncertainty and begin, <80% → iterate privately through research
…tterns The prompt_injection pattern only matched single-qualifier forms like 'ignore all instructions' but missed 'ignore all previous instructions' (two qualifiers). Changed to allow zero or more qualifier words between 'ignore' and 'instructions'.
Copilot review: safeFilename only stripped brackets/newlines, leaving bidi overrides, zero-width chars, and other Cc/Cf/Zl/Zp chars that could manipulate the BLOCKED placeholder text. Now uses sanitizeForPromptLiteral() for full Unicode category coverage, then strips brackets separately.
1. Apply sanitizeForPromptLiteral() to file.path in the ## heading of buildProjectContextSection(). This closes the 3x-raised Copilot concern about crafted filenames with control/bidi chars breaking prompt structure. 2. Add || 'unknown' fallback to safeFilename in injection scan placeholder, preventing empty string if filename is entirely control/format chars.
…, allowlist, filename strip Prompt overlay (extensions/openai/prompt-overlay.ts): - Plan Confidence Gate carve-out: explicit exception that plan mode goes through approval flow regardless of confidence level Injection scanner (src/agents/context-file-injection-scan.ts): - Multi-line regex flag (im) on translate_execute, exfil_curl, read_secrets patterns. Attacks split across newlines no longer bypass detection - Default allowlist for security docs: SECURITY.md, CONTRIBUTING.md, docs/security/*, qa/scenarios/* — content with injection patterns passes through with optional onAllowlistBypass callback for telemetry - Filename defense-in-depth: strip <, >, & in addition to brackets so a filename like '<!--ignore-->.md' can't embed instruction-like text in the BLOCKED placeholder - New SanitizeContextFileOptions interface with allowlist + bypass callback Tests: 13 new tests (multi-line attack patterns, allowlist for SECURITY/ CONTRIBUTING/docs/security/qa/scenarios, custom allowlist override, regular files still blocked, filename angle/bracket/ampersand stripping, callback fires)
…ion + custom-list bug
Two security/correctness fixes from adversarial review of the
context-file injection scanner:
1. **Allowlist Windows-path bypass + traversal bypass (HIGH):**
The default allowlist patterns
/(?:^|\\/)docs\\/security\\//i
/(?:^|\\/)qa\\/scenarios\\//i
only matched forward-slash paths and accepted any path containing
the segment. This let two bypasses slip through:
- Windows: 'docs\\\\security\\\\foo.md' wouldn't match the regex
because backslashes aren't forward-slashes.
- Traversal: 'qa/scenarios/../../etc/passwd' DID match because the
'qa/scenarios/' segment appeared anywhere in the path.
New helper normalizePathForAllowlist():
- Replaces '\\\\' with '/' before matching (cross-platform).
- Rejects any path with a literal '..' SEGMENT (returns null →
never allowlisted, fail-closed). Filenames containing '..' as a
substring (e.g. 'foo..bar.md') are still allowed — only '..'
segments are hostile.
2. **Custom allowlist parameter silently ignored (MEDIUM):**
sanitizeContextFileForInjection accepted options.allowlist but
then called isAllowlistedPath(filename) WITHOUT forwarding the
custom list — so callers' custom allowlists were no-ops. Fixed to
pass the resolved allowlist through.
Tests added (4 new + 1 strengthened, 37 total pass):
- custom allowlist override actually applies (was a void-result no-op
test before)
- empty custom allowlist forces SECURITY.md to be scanned
- Windows backslash path matches default allowlist (regression
guard for the bypass)
- 'qa/scenarios/../../etc/passwd' fails closed
- 'foo..bar.md' (substring, not segment) is still allowed
…ist matching Codex P2 (PR #67512 r3096412188): now that custom `allowlist` regexes are honored (per the iter-1 `2b610f6f` fix), callers passing a regex with `g` or `y` flags would see nondeterministic results: `re.test(normalized)` mutates `lastIndex` on stateful flags, so back-to-back scans against the same regex object alternate between allowlisted and blocked. Fix: reset `re.lastIndex = 0` before each `.test()` call. Defensive on non-stateful regexes (no-op there) but eliminates the alternating- outcome failure mode. Test added: 5-iteration loop against `/docs\/security\//g` confirms the same path consistently resolves as allowlisted. 38 tests pass.
…e, activeForm, hydration
Ports three Hermes todo-tool features that OpenClaw's update_plan
was missing, plus adds plan hydration for post-compaction recovery.
## cancelled status (Hermes parity)
Add "cancelled" as a fourth plan step status. Hermes uses this to
mark steps that were started but abandoned due to failure, keeping
them visible in history instead of silently dropping them.
Ref: Hermes tools/todo_tool.py Status enum.
## merge mode (Hermes parity)
Add optional merge: boolean parameter (default false). When true,
incoming steps update existing ones by matching step text and new
steps are appended. When false, the entire plan is replaced.
Ref: Hermes tools/todo_tool.py write(todos, merge=False).
## activeForm field (Claude Code TodoWrite parity)
Add optional activeForm: string field on plan steps. Shows the
present-continuous form while in_progress (e.g. "Running tests"
instead of "Run tests"). Small schema addition with disproportionate
UX impact for plan rendering.
## Post-compaction plan hydration (Hermes parity)
New src/agents/plan-hydration.ts helper that formats active
(pending/in_progress) plan items for injection after context
compression. The injected text uses factual phrasing ("Your active
plan was preserved...") to avoid triggering the planning-only retry
guard's promise-language detection.
Ref: Hermes run_agent.py:6754-6756 + tools/todo_tool.py:format_for_injection().
Note: the compaction hook point in compact.ts is not wired in this
PR — that requires a follow-up to integrate with the compaction
checkpoint system (#62146). This PR provides the formatter; the
hook wiring is the next step.
Part of GPT 5.4 Enhancement v3 sprint. Tracking: #66345.
…rge mode The execute() third param is an AbortSignal/context, not a plan context, so context?.previousPlan was always undefined and merge mode was a no-op. Fall back to replace until PlanStore (#67542) provides previous-plan lookup.
- Add plan-hydration.test.ts: tests formatPlanForHydration returns null for empty/all-completed/all-cancelled steps, filters out completed and cancelled steps, includes pending and in_progress with correct markers, and validates preserved plan header format - Add update-plan-tool.parity.test.ts: tests cancelled status is accepted in schema, activeForm field is preserved in output, and merge=true with no previousPlan falls back to replace
Now that activeForm is explicitly declared, disallow arbitrary extra keys on plan step objects to catch malformed input early.
…escription - Rename _context → _signal in execute() to match actual parameter meaning - Fix activeForm schema description: accepted on any status, rendered for in_progress - Fix plan-hydration JSDoc: 'task list' → 'plan' to match OpenClaw terminology
…text.lastPlanSteps
Replaces the no-op if(merge){replace}else{replace} placeholder in
update-plan-tool.ts with a real merge implementation. Storage is keyed
on runId via AgentRunContext.lastPlanSteps so each run sees only its
own plan history.
- src/infra/agent-events.ts: add lastPlanSteps?: PlanStepSnapshot[]
to AgentRunContext (in-memory; PlanStore disk persistence remains
separate scope of #67542).
- src/agents/tools/update-plan-tool.ts:
- export PLAN_STEP_STATUSES + PlanStepStatus union
- accept { runId } factory option
- port mergeSteps from phase4/cross-session-plans:plan-store.ts:204
(in-memory variant — no updatedBy/updatedAt attribution)
- emit agent_plan_event after every update so channel renderers see
the merged result
- src/agents/openclaw-tools.ts: thread runId through to
createUpdatePlanTool factory
- src/agents/pi-tools.ts: pass options.runId to createOpenClawTools
- src/agents/plan-hydration.ts: use the exported PlanStepStatus union
via 'satisfies' for compile-time coupling
- src/agents/test-helpers/fast-openclaw-tools-sessions.ts: update mock
signature + re-export PLAN_STEP_STATUSES
Tests: 9 new merge-mode tests in update-plan-tool.parity.test.ts
covering overlap, append, completed-preservation, cancelled rollback,
runId isolation, persistence, and emission. All 23 update-plan-related
tests pass.
…erge + reject duplicate step text Two adversarial fixes from Codex review: P1 r3096162551: Revalidate active-step invariant after merge. readPlanSteps enforces 'at most one in_progress' only on the incoming patch, but merge mode can still create an invalid final plan: if the previous plan has step A as in_progress and the patch marks step B as in_progress (without changing A), the merged result has two active steps — violates the tool's own contract and breaks downstream plan renderers that assume a single active step. Fix: after merge, count in_progress in the merged plan; throw if >1 with a clear remediation hint (mark the prior in_progress step completed/cancelled in the same patch). P2 r3096162555: Reject duplicate step text within a single patch. Merge keys steps by 'step' text, so two patch entries with the same step text would silently rewrite history (second entry clobbers the first, then both collide on the same merge key against the previous plan, rewriting unrelated entries). Better to fail at input time with a message explaining the join-key constraint. Tests: 2 new regression tests in update-plan-tool.parity.test.ts: - merge that would yield two in_progress throws - patch with duplicate step text throws Total: 15 parity tests pass.
Phase 4.2 of the GPT 5.4 parity sprint. Adds a PlanStore class for cross-session task coordination, modeled after Claude Code's Tasks API concept. ## PlanStore (plan-store.ts) - read(namespace): loads plan from ~/.openclaw/plans/<ns>/plan.json - write(namespace, plan): persists plan to disk with auto-mkdir - lock(namespace): file-level exclusive lock with O_EXCL + 10s stale-lock auto-cleanup to prevent deadlocks from crashes - mergeSteps(): merge incoming steps into existing plan by matching step text, appending new steps, preserving existing order. Tracks updatedBy (session key) and updatedAt per step. ## Schema (StoredPlan, StoredPlanStep) - StoredPlanStep: step, status (4 values), activeForm?, updatedBy?, updatedAt? - StoredPlan: namespace, steps[], createdAt, updatedAt ## Tests (8 tests) - Read/write round-trip + nested directory creation - Lock acquisition, release, and concurrent contention - Merge: update by text match, append new, preserve order Default behavior (no namespace configured): plan is session-scoped, PlanStore is not used. Cross-session coordination only activates when planStore.namespace is set in config. Tracking: #66345, #67523.
… errors, lock ownership - Validate namespace to prevent path traversal (reject "..", absolute paths) - Atomic write: write to temp file then rename to prevent mid-write corruption - Fix file handle leak: ensure handle.close() in finally on open errors - Distinguish ENOENT from parse/permission errors in read() instead of swallowing all errors - Verify lock ownership token before unlinking on release to prevent one process from deleting another's lock
Replace O(n) linear scan with a Set when checking whether incoming steps already exist, improving merge performance for large plans.
Three fixes from Copilot review: 1. mergeSteps: only set updatedBy when sessionKey is truthy, avoiding undefined overwriting existing attribution 2. write(): validate plan.namespace matches the namespace argument to prevent accidental mismatches 3. Added tests: path traversal rejection + namespace mismatch in write
…g in mergeSteps 1. read(): verify plan.namespace matches requested namespace to catch file corruption or misrouted reads 2. mergeSteps(): deduplicate incoming steps by tracking appended step texts in a Set, preventing duplicate step entries when incoming contains the same step text multiple times
Change from truthy check to explicit undefined check so that plans with no namespace field (backward-compat) pass through, while non-undefined mismatches still throw a corruption error.
Path traversal namespace like '../escape' was caught by the mismatch check (comparing against plan.namespace) before reaching validateNamespace(), producing a confusing error message instead of the security-relevant one. Swap the order so validateNamespace runs first.
Review finding: temp files created without explicit mode inherit umask defaults, which may be more permissive than intended for plan data. Added mode: 0o600 to writeFile call to match repo convention for sensitive data files.
- Add runtime shape validation in read(): verify plan has string namespace and steps array before returning, instead of trusting JSON.parse cast - Normalize dot segments in validateNamespace(): path.normalize() prevents cross-namespace collisions from ./foo vs foo equivalence
- Replace handle.write(lockToken) with handle.writeFile(lockToken) to handle partial writes correctly (write() can return short) - Fix docstring: 'auto-expires after 10s' → 'stale locks cleaned up opportunistically by next lock() caller' (no timer-based expiry)
|
@codex please re-review the latest push at
13 new regression tests across |
|
Codex Review: Didn't find any major issues. 🎉 ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
|
@codex do one final pass for any major issues and trace all the plan mode paths |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5774aa039d
ℹ️ 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".
| planModeCfg?.enabled === true && | ||
| cronSession.sessionEntry.planMode === undefined && | ||
| planModeCfg.autoEnableFor && |
There was a problem hiding this comment.
Prevent auto-enable from rearming after plan approval
This condition re-enables plan mode whenever sessionEntry.planMode is missing, but the approval path clears planMode on successful approve/edit when autoApprove is unset (src/gateway/sessions-patch.ts, delete next.planMode). In auto-enable deployments, that means a just-approved session can be put back into plan mode on the next cron turn, re-locking mutation tools and forcing repeated propose/approve loops instead of execution. The auto-enable gate should distinguish “never initialized” from “already completed/disabled this cycle” (for example by honoring an explicit persisted normal/settled marker).
Useful? React with 👍 / 👎.
| "memory_get", | ||
| "update_plan", | ||
| "exit_plan_mode", | ||
| "session_status", |
There was a problem hiding this comment.
Block mutating session_status calls during plan mode
Allowlisting session_status as unconditionally safe creates a mutation bypass: the tool accepts a model argument and persists model overrides to the session store, so an agent can change runtime session state while plan mode is active and before approval. If plan mode is meant to block mutating operations, session_status should only be allowed when no model override is requested (or the mutating branch should be split into a separate gated tool).
Useful? React with 👍 / 👎.
|
Stacked for testing purposes given size. Closing to re-divide now that it's achieved 1.0 state. |
|
Migration map (added 2026-04-22) — for anyone landing here from search: This PR's content has been decomposed into the 8-part stack:
Maintainer note: review/merge in numerical order (2 → 3 → 4 → ...). Each PR's diff against |
Plan mode — full rollout + iter-1/2/3 hardening (consolidated stack of 10 PR's & 300+ Code Reviews)
Executive summary
This PR ships plan mode across OpenClaw: an opt-in, per-session workflow where agents must propose a structured, approvable plan (title + steps + assumptions + risks + verification criteria) before executing any mutating tool (
bash,edit,write,apply_patch, process management, messaging, etc.). The user reviews, edits, approves, or rejects with feedback; only on approve/edit do the mutation tools unlock for that session. Plan mode is off by default and activated per-session via/plan onor the chip in webchat. (Auto-activation viaagents.defaults.planMode.autoEnableFormodel-pattern matching is schema-reserved but runtime wiring is deferred — see "Deferred features" below.)The feature spans the stack: 6 new agent tools, 2 runtime gates (mutation gate + subagent gate), a 4-state approval state machine, disk-persisted markdown plan files (audit trail), live sidebar rendering in webchat, and universal
/planslash commands on Telegram, Slack, Discord, iMessage, Signal, Matrix, CLI, and WhatsApp. Backed by 200+ new tests across unit / integration / e2e layers.This is one umbrella PR replacing 10 dependent sub-PRs (A/B/C/D/E/F/7/8/10/11) that had drifted 734 commits behind
main. Every prior-PR review thread (240+) is resolved. Deferred features (schema-reserved but runtime wiring not in this PR): Telegram document-attachment delivery, non-web-channel inline-button cards (Telegram/Slack/Discord all text-only via/plancommands today),agents.defaults.planMode.autoEnableFormodel-pattern auto-enable,agents.defaults.planMode.approvalTimeoutSecondscron-time watchdog,/plan self-testslash-command harness, and the Bug B stale-card auto-dismiss. All tracked in §19 "Known follow-ups" + the in-code SCHEMA-RESERVED comments atsrc/config/types.agent-defaults.ts:316-355(which are the authoritative source of truth on deferral status).Stats: +31,494 / −230 across 145 files. Rebased onto
main(v2026.4.19-beta.2).Risk profile: Additive + flag-gated. Default-off means zero behavioral change for existing users until they opt in.
TL;DR
approvalId(cryptographic random, regenerated per exit). Subagent gate blocks approve/edit while research children are in flight.pnpm testgreen. No new flakes.agents.defaults.planMode.enabled: false) disables the feature instantly. Full branch rollback tobee5e8c364preserved onfeat/plan-channel-parity-backup./plan self-testharness (iter-3 R1–R5);autoEnableFormodel-pattern auto-enable (schema-reserved, no cron-time scanner yet);approvalTimeoutSecondscron watchdog (schema-reserved, no timeout firing yet); non-web-channel inline-button cards (Telegram/Slack/Discord text-only today via/plancommands). All tracked in §19; the in-code SCHEMA-RESERVED comments atsrc/config/types.agent-defaults.ts:316-355are the authoritative source of truth.Table of contents
1. Feature overview
Plan mode borrows the "propose, approve, execute" pattern from Claude Code. An agent enters plan mode either because the user asked (
/plan on, webchat chip) or because the session model is inagents.defaults.planMode.autoEnableFor(e.g. GPT-5.4 for structured task runs). While in plan mode the agent can:read,web_search,web_fetch,memory_search,sessions_list,sessions_history)ls,cat,pwd,git status/log/diff/show,which,find,grep,rg,head,tail,wc,file,stat,du,df,echo,printenv,whoami,hostname,uname— with dangerous flags blocked)update_plan(closure-gated:status:"completed"rejected untilverifiedCriteria ⊇ acceptanceCriteria)sessions_spawn) — the parent tracks theirrunIds inopenSubagentRunIdsask_user_question)plan_mode_status)…and cannot:
When the agent is ready, it calls
exit_plan_modewith the plan archetype (title,plan[], optionalsummary/analysis/assumptions/risks/verification/references). This:~/.openclaw/agents/<agentId>/plans/plan-YYYY-MM-DD-<slug>.md.pendingwith a freshly minted cryptographicapprovalId.The user approves / edits / rejects via webchat buttons, Telegram inline keyboard (pending PR-14), or the universal
/plan accept|revise|answer|autoslash commands on any other channel. The approval-side subagent gate rejects approve/edit while children are in flight (reject is intentionally always allowed). On approve/edit, mode flips tonormal, a synthetic[PLAN_DECISION]: approvedmessage is queued aspendingAgentInjection, and mutations unlock on the next agent turn.2. High-level architecture
flowchart LR subgraph UI[Channels] Web[Webchat<br/>inline card + sidebar] TG[Telegram<br/>/plan commands] SL[Slack / Discord /<br/>Matrix / iMessage /<br/>Signal / CLI] end subgraph GW[Gateway] Patch[sessions.patch<br/>handler] Persister[plan-snapshot<br/>persister] Sub[sessions.changed<br/>broadcaster] end subgraph RT[Agent runtime] Runner[pi-embedded-runner] Gate[pi-tools/<br/>before-tool-call<br/>mutation gate] Hyd[plan-hydration] Inj[pending-injection<br/>consumer] end subgraph PM[Plan-mode core src/agents/plan-mode/] Types[types.ts<br/>approval.ts] Ref[reference-card.ts] Nudge[plan-nudge-crons.ts] Persist[plan-archetype-persist.ts] Debug[plan-mode-debug-log.ts] MGate[mutation-gate.ts] end subgraph Tools[Agent tools src/agents/tools/] Enter[enter_plan_mode] Exit[exit_plan_mode] Update[update_plan] Ask[ask_user_question] Status[plan_mode_status] Spawn[sessions_spawn] end subgraph Cfg[Config src/config/] ZAD[zod-schema.agent-defaults] ZAR[zod-schema.agent-runtime] Sess[sessions/types] end subgraph Store[Persistence ~/.openclaw/] SE[SessionEntry<br/>.planMode] MD[agents/<id>/plans/<br/>plan-*.md] Log[logs/gateway.err.log<br/>plan-mode/* events] end Web -- "sessions.patch<br/>{planApproval}" --> Patch TG -- "/plan accept" --> Patch SL -- "/plan accept" --> Patch Runner -- "update_plan event" --> Persister Persister --> Sub Sub --> Web Sub --> TG Sub --> SL Runner -- uses --> Gate Runner -- uses --> Hyd Runner -- uses --> Inj Gate -- calls --> MGate Tools -- registered via --> Runner Exit -- persists --> Persist Enter -- schedules --> Nudge PM -- types/state --> SE Persist -- writes --> MD Debug -- appends --> Log Cfg -- validates --> Patch Cfg -- validates --> Runner3. State machine
Session state is the Cartesian product of
PlanMode ∈ {normal, plan}andPlanApprovalState ∈ {none, pending, approved, edited, rejected, timed_out}. Valid transitions:stateDiagram-v2 [*] --> Normal Normal --> PlanInvestigation : enter_plan_mode<br/>OR /plan on<br/>OR autoEnableFor match PlanInvestigation --> PlanInvestigation : update_plan<br/>(tracks progress) PlanInvestigation --> PlanPendingApproval : exit_plan_mode<br/>(new approvalId) PlanPendingApproval --> Normal : approve / edit<br/>(mutations unlock) PlanPendingApproval --> PlanInvestigation : reject<br/>(+feedback, rejectionCount++) PlanPendingApproval --> PlanInvestigation : timed_out<br/>(approvalTimeoutSeconds) PlanInvestigation --> Normal : /plan off<br/>(user escape hatch) Normal --> Normal : auto-close-on-complete<br/>(all steps terminal) note right of PlanPendingApproval approve / edit is gated by<br/>openSubagentRunIds.size > 0<br/>→ PLAN_APPROVAL_BLOCKED_BY_SUBAGENTS<br/>Reject is never gated. end noteKey invariants:
approvalIdis a cryptographic random token (newPlanApprovalIdinsrc/agents/plan-mode/types.ts) regenerated on everyexit_plan_mode. Stale UI clicks (oldapprovalId) are silently no-op'd.normal. The agent stays in plan mode and revises.[PLAN_DECISION]injection suggests the agent ask a clarifying question instead of looping.normalvia/plan offis always allowed (user escape hatch). The agent cannot force-exit; it must propose and be approved.4. Critical flows (sequence diagrams)
4.1 Enter plan mode
sequenceDiagram actor User participant UI as Webchat / channel participant GW as Gateway<br/>sessions.patch participant Store as SessionEntry participant Run as pi-embedded-runner participant Cron as plan-nudge-crons User->>UI: /plan on (or click chip) UI->>GW: sessions.patch { planMode: "plan" } GW->>Store: SessionEntry.planMode.mode = "plan"<br/>approval = "none"<br/>nudgeJobIds = [] GW->>Cron: schedulePlanNudges([10,30,60] min) Cron-->>Store: nudgeJobIds = [cron/plan-nudge:...] GW-->>UI: ack + broadcast sessions.changed Note over Run: next agent turn Run->>Store: load SessionEntry Run->>Run: inject PLAN_MODE_REFERENCE_CARD<br/>+ PLAN_ARCHETYPE_PROMPT<br/>+ (first-turn) [PLAN_MODE_INTRO] Run->>Run: arm mutation gate via<br/>getLatestPlanMode accessor Run-->>User: agent now operates under plan-mode contract4.2 Update plan (progress tracking)
sequenceDiagram participant Agent participant Tool as update_plan tool participant Ctx as AgentRunContext<br/>(in-memory) participant Evt as agent_plan_event bus participant Persister as plan-snapshot-persister participant Store as SessionEntry participant UI as Subscribers Agent->>Tool: update_plan({ plan, merge?, explanation? }) Tool->>Tool: validate steps<br/>(≤1 in_progress, closure gate) alt merge=true Tool->>Ctx: read lastPlanSteps Tool->>Tool: merge by step-text join<br/>re-validate closure gate on result end Tool->>Ctx: lastPlanSteps = merged Tool->>Evt: emit { phase: "update", steps } alt all steps terminal (completed/cancelled) Tool->>Evt: emit { phase: "completed", steps } end Persister->>Evt: subscribe Persister->>Store: planMode.lastPlanSteps = steps alt phase=completed Persister->>Store: planMode.mode = "normal"<br/>cleanupPlanNudges() end Persister->>UI: broadcast sessions.changed UI-->>UI: sidebar checklist refresh4.3 Exit + approval (happy path)
sequenceDiagram participant Agent participant Exit as exit_plan_mode tool participant Ctx as AgentRunContext participant Persist as plan-archetype-persist participant GW as gateway/sessions-patch participant Store as SessionEntry actor User participant UI as Webchat<br/>approval card participant Run as pi-embedded-runner participant Inj as pending-injection Agent->>Exit: { title, plan, assumptions?, risks?, verification? } Exit->>Ctx: read openSubagentRunIds alt size > 0 Exit-->>Agent: ToolInputError<br/>(child ids listed) Note over Exit: always-on [exit-plan-gate] log else size == 0 Exit->>Persist: write markdown to ~/.openclaw/agents/<id>/plans/ Persist-->>Exit: { absPath, filename } Exit-->>Run: tool result<br/>(title + plan + path) Run->>GW: sessions.patch<br/>{ planApproval: "pending",<br/> approvalId: new,<br/> title, approvalRunId } GW->>Store: persist pending state GW->>UI: broadcast approval request UI-->>User: render approval card<br/>(Accept / Accept edits / Revise) User->>UI: click Accept UI->>GW: sessions.patch<br/>{ planApproval: { action: "approve", approvalId }} GW->>Ctx: read openSubagentRunIds(approvalRunId) alt approval-side gate: size > 0 GW-->>UI: error PLAN_APPROVAL_BLOCKED_BY_SUBAGENTS<br/>details.openSubagentRunIds UI-->>User: toast with child ids else GW->>Store: planMode.mode = "normal"<br/>approval = "approved"<br/>pendingAgentInjection = buildApprovedPlanInjection(plan) GW-->>UI: broadcast sessions.changed Note over Run: next agent turn Run->>Inj: consumePendingAgentInjection() Inj->>Store: atomically read + clear Run->>Run: prepend [PLAN_DECISION]: approved<br/>to user prompt Run-->>Agent: mutations now unlocked end end4.4 Rejection loop
sequenceDiagram actor User participant UI participant GW as sessions-patch participant Store as SessionEntry participant Run as runner participant Agent User->>UI: click Revise + type feedback UI->>GW: sessions.patch { planApproval: { action: "reject", feedback }} GW->>Store: approval = "rejected"<br/>rejectionCount += 1<br/>feedback stored<br/>pendingAgentInjection = buildPlanDecisionInjection("rejected", feedback, count) GW->>Store: mode stays "plan" Note over Run: next turn Run->>Run: consume injection<br/>prepend to prompt Run-->>Agent: "[PLAN_DECISION]: rejected<br/>feedback: ..." Agent->>Agent: revise plan, call update_plan Agent->>Agent: exit_plan_mode again<br/>(NEW approvalId) alt rejectionCount >= 3 Note over Agent: injection suggests<br/>ask_user_question instead of loop end4.5 Compaction + plan hydration
5. Channel delivery matrix
Webchat has the full mode. All other channels get a text-reduced mode driven by universal
/planslash commands. This is intentional — inline cards, sidebars, and modal textareas are web-UI affordances; async text channels have button/markdown constraints that can't replicate them faithfully. Any request from a non web channel the agent will send a markdown file of the plan to approve via text commands.NOTE: Telegram can be wired up via native mode but that is a different PR capability wise.
/planslash commands/plan restate)/plan auto)ask_user_question/plan answer …Universal slash commands (all channels):
All paths route through
src/gateway/sessions-patch.ts— there is one approval state machine, not one per channel.6. File-level architecture
For each module: role, public surface, invariants. File paths are relative to repo root.
6.1 Plan-mode core —
src/agents/plan-mode/types.tsPlanMode,PlanApprovalState,PlanModeSessionState,newPlanApprovalId(),buildPlanDecisionInjection()approvalIdcryptographic (notMath.random), regenerated per exit. Feedback sanitized against envelope-closing. After 3 rejections, injection hints atask_user_question.approval.tsresolvePlanApproval(),buildApprovedPlanInjection(),DEFAULT_APPROVAL_CONFIG = { approvalTimeoutSeconds: 600 }approvalIdguard: mismatch → no-op. Terminal states (approved/edited/timed_out) require fresh exit. Reject never gated by subagent state.reference-card.tsPLAN_MODE_REFERENCE_CARDplan-mode-101skill — must stay in sync.plan-nudge-crons.tsschedulePlanNudges(),cleanupPlanNudges()[10, 30, 60]min. Job names prefixedplan-nudge:. Scheduling failures returned as partial-success list, never thrown. Heartbeat degrades nudge to no-op if plan resolved.plan-mode-debug-log.tsPlanModeDebugEvent,logPlanModeDebug()OPENCLAW_DEBUG_PLAN_MODE=1or config flagagents.defaults.planMode.debug: true.plan-archetype-persist.tspersistPlanArchetypeMarkdown()agentIdrejects/\, control chars,.,... Resolved path must stay insidebaseDir. Collision suffix retries up to 99.mutation-gate.tscheckMutationGate(toolName, mode, execCommand?),PLAN_STEP_STATUSES-delete,-exec,--output,-rf) blocked. Shell compound operators (;,|,&,$(),>,<) rejected.index.ts6.2 Agent tools —
src/agents/tools/enter-plan-mode-tool.ts{ reason?: string }exit-plan-mode-tool.ts{ title, plan[], summary?, analysis?, assumptions?, risks?, verification?, references? }openSubagentRunIds.size > 0→ToolInputError. Title required (no fallback). ≤1in_progress.update-plan-tool.ts{ plan[{ step, status, activeForm?, acceptanceCriteria?, verifiedCriteria? }], merge?, explanation? }status:"completed"rejected untilverifiedCriteria ⊇ acceptanceCriteria(whitespace-trimmed). Merge validates closure on final result (catches inherited unverified criteria). Auto-close-on-complete → emitsphase: "completed"event.ask-user-question-tool.ts{ question, options: [string,...], allowFreetext?: boolean }questionIddeterministic from toolCallId (prompt-cache stable).plan-mode-status-tool.ts{}skipCache: true. Safe to call anytime including during pending approval.sessions-spawn-tool.tscleanup: "keep", registers childrunIdin parent'sopenSubagentRunIds. Cleaned on child completion bysubagent-registry-run-manager.ts.tool-catalog.tsagents.defaults.planMode.enabled.tool-description-presets.tstool-display-config.tsapps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json.6.3 Runtime —
src/agents/pi-embedded-runner/+src/agents/pi-embedded-runner/run/params.tsplanMode?: "plan" | "normal"(snapshot) +getLatestPlanMode?: () => …(live accessor — fixes iter-2 Bug 4 closure-stale-ref).pi-embedded-runner/run/helpers.tspi-embedded-runner/pending-injection.tspendingAgentInjectionpi-embedded-runner/pending-injection.test.tspi-tools.ts+pi-tools.before-tool-call.tscheckMutationGate. UsesgetLatestPlanMode()for freshness across mid-turn approval.plan-hydration.tsformatPlanForHydration(steps)→ factual phrasing (not imperative) to avoid planning-only retry guard.subagent-announce.tssubagent-registry-run-manager.tsopenSubagentRunIdstransport-message-transform.tstool_resultplaceholdersystem-prompt.tssystem-prompt-contribution.tstool_enforcement.openclaw-tools.ts+openclaw-tools.registration.tsagent-scope.tsautoContinue+maxIterationsoverride resolution6.4 Auto-reply / commands —
src/auto-reply/reply/commands-handlers.runtime.ts/planhandlercommands-registry.shared.ts/plancommand definitionreply/commands-system-prompt.tsreply/fresh-session-entry.tsgetLatestPlanModereturns"normal"on planMode deletion)6.5 Gateway —
src/gateway/server-runtime-subscriptions.tsplan-snapshot-persisteron startup; emitsessions.changedon persister mutationsserver-runtime-handles.tsplanSnapshotUnsubto gateway handlesserver-close.tsserver.impl.tsplanSnapshotUnsubinto close depsserver-methods/sessions.tsexec+planModeinsessions.changedpayloadsessions-patch.tssession-utils.ts+session-utils.types.tsexec+planModeon session rowsprotocol/schema/sessions.tsplanMode,planApproval,lastPlanStepsfieldsprotocol/schema/error-codes.tsPLAN_APPROVAL_BLOCKED_BY_SUBAGENTSerror codeprotocol/index.tsErrorCodeunion6.6 Config —
src/config/zod-schema.agent-defaults.tsembeddedPi.autoContinue,embeddedPi.maxIterations,planMode.{enabled, autoEnableFor, approvalTimeoutSeconds, debug}zod-schema.agent-runtime.tsembeddedPioverrideszod-schema.tsmaxPlanTemplateStepsskill limitsessions/types.tsapprovalId,lastPlanSteps,approvalRunId,nudgeJobIds,feedback,rejectionCount,pendingAgentInjectiontypes.agents.ts,types.agent-defaults.ts,types.skills.ts6.7 Skills —
src/agents/skills/skill-planner.tsfrontmatter.tsplanTemplatefrom skill frontmatter (alias + precedence rules)types.tsSkillPlanTemplateStep,resolvedPlanTemplatessnapshot fieldworkspace.ts6.8 Infra —
src/infra/heartbeat-runner.tsheartbeat-runner.plan-nudge.test.ts6.9 UI —
ui/src/ui/app-chat.tsui/views/plan-approval-inline.tsui/chat/plan-cards.tsstyles/chat/plan-cards.cssstyles/chat.cssi18n/locales/en.ts6.10 Channels —
extensions/extensions/openai/prompt-overlay.tsextensions/telegram/src/send.tssendDocumentTelegramhelper (delivery surface — currently unused; see §14 post-rebase)extensions/telegram/runtime-api.ts6.11 Apps / docs / QA
apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.jsontool-display-config.tsfor native appsdocs/concepts/plan-mode.mddocs/plans/PLAN-MODE-ARCHITECTURE.mdqa/scenarios/gpt54-plan-mode-default-off.mdqa/scenarios/gpt54-mandatory-tool-use.mdqa/scenarios/gpt54-injection-scan.mdqa/scenarios/gpt54-cancelled-status.mdcancelledstatus inupdate_planqa/scenarios/gpt54-act-dont-ask.md7. Dependencies & integration points
Upstream deps (what we require from
main):plugin-sdkrestructure (merged upstream) — our Telegram delivery surfaceextensions/telegram/src/send.tswas removed; we keep the helper exported but cannot call it until follow-up PR re-wires it.server-runtime-subscriptions.ts→params.minimalTestGateway— renamed/dropped upstream; removed our conditional inserver-runtime-subscriptions.ts:82. Persister always starts now.plan-hydration.tshooks into existing snapshot machinery; no new plumbing.Downstream integration:
apps/shared/OpenClawKit) — consumesessions.changedwith the newplanModefield. Apps built against older gateway still ignore the field (additive wire)./plancommands through the universal handler. No per-channel wiring beyond the existingmarkdownCapableflag.sendDocumentTelegramto the new SDK location. Markdown file is already written to disk; only the document-upload step is skipped today.Hard blockers: none. Every upstream change is merged.
8. Configuration reference
All config is additive; no existing key changes meaning.
8.1 Agent defaults (
src/config/zod-schema.agent-defaults.ts)8.2 Per-agent overrides (
src/config/zod-schema.agent-runtime.ts)8.3 Skills (
src/config/zod-schema.ts)8.4 Env vars
OPENCLAW_DEBUG_PLAN_MODE=18.5 Runtime config commands (any channel)
9. Runtime behavior & performance
Disk I/O:
exit_plan_mode: one markdown write to~/.openclaw/agents/<id>/plans/plan-YYYY-MM-DD-<slug>.md(avg ~2–6 KB).update_plan:SessionEntry.planMode.lastPlanStepspersisted byplan-snapshot-persister. Debounced: one write per event, not per step.SessionEntry.planMode+SessionEntry.pendingAgentInjectionupdated in a single patch.Memory:
AgentRunContextadds:inPlanMode: boolean,openSubagentRunIds: Set<string>,lastPlanSteps: PlanStepSnapshot[]. Bounded by plan size (typical 5–20 steps, hard cap viamaxPlanTemplateSteps).CPU:
Cron load:
[10, 30, 60]min by default). Cleaned on exit / close / session leave. Orphan cleanup at gateway start.Network:
sessions.changedbroadcast on every persister mutation (existing channel, additional fields only).Latency impact on non-plan-mode sessions: zero. Mutation gate runs
checkMutationGate(tool, "normal")which short-circuits at line 1 when mode is normal.10. Backward compatibility
planMode.enabled: false. Existing sessions, extensions, and channel clients operate identically tomain.sessions.changedpayload gains optionalplanMode/lastPlanStepsfields. Older clients ignore unknown keys.enter_plan_mode,exit_plan_mode,update_plan,ask_user_question,plan_mode_statusonly registered when flag on. Agents without the flag see no new tools.sessions.patchnew actions (planApproval: { action: ... }) reject whenplanMode.enabled: falseat the gateway. UI chip hidden when disabled.PLAN_APPROVAL_BLOCKED_BY_SUBAGENTSis newly reserved inprotocol/schema/error-codes.ts. No existing error code repurposed.11. Upgrade / migration / rollback
Upgrade (enable plan mode for a given install):
Migration:
SessionEntry.planModeis populated lazily on firstenter_plan_modeor/plan on.planModeare treated as{ mode: "normal", approval: "none" }byfresh-session-entry.ts.Rollback:
openclaw config set agents.defaults.planMode.enabled false→ gateway restart. All new sessions normal-mode, tools hidden, UI chip hidden.feat/plan-channel-parity-backup(fork100yenadmin/openclaw-1) preserved atbee5e8c364. Revert to main, then cherry-pickmain-only hot-fixes.What won't roll back cleanly:
~/.openclaw/agents/<id>/plans/remain on disk. Harmless (markdown files, no process holds them).find ~/.openclaw/agents -name 'plan-*.md' -deleteto clean.SessionEntry.planModefields persisted in existing session files are ignored by the pre-feature gateway (unknown keys).12. 10-PR consolidation map
final-sprint/gpt5-openai-prompt-stackfinal-sprint/gpt5-task-system-parityphase3/plan-renderingphase3/plan-modephase4/skill-plan-templatesphase4/cross-session-plansfeat/ui-mode-switcher-plan-cardsfeat/plan-mode-integrationask_user_question+ auto modefeat/plan-archetype-and-questions/planslash commands across all channelsfeat/plan-channel-parity13. Iter-1/2/3 hardening detail
Three live-testing iteration cycles against webchat surfaced and fixed 13 real bugs:
iter-1 (4 bugs from first live run):
[PLAN_*]:tag missing on synthetic messagespending-injection.ts,approval.tstitlenot inSessionEntry.planModesessions/types.ts,sessions-patch.tssessions-patch.ts+ new error codePLAN_APPROVAL_BLOCKED_BY_SUBAGENTSplan-mode-debug-log.tsiter-2 (6 bugs from deeper live run):
planModesnapshotpi-embedded-runner/run/params.ts— addedgetLatestPlanMode: () => …live accessor;fresh-session-entry.ts—getLatestPlanModereturns"normal"on planMode deletionplan-snapshot-persister.tsplan-snapshot-persister.tsplan-mode-debug-log.ts— config-flag path added[PLAN_DECISION]format inconsistent across approve/reject/editapproval.ts— unified one-line formattool-description-presets.tsiter-3 (self-discovery + R6 subagent hardening + critical persister typo):
PLAN_MODE_REFERENCE_CARDinjected on every plan-mode turn; first-time[PLAN_MODE_INTRO]:injection;plan-mode-101skill; user-facingdocs/concepts/plan-mode.md.[exit-plan-gate]and[plan-approval-gate]diagnostics; plan-mode-aware subagent announce-turn instruction;plan_mode_statusintrospection tool.plan-snapshot-persister.tsfilteredphase === "request"but the emitter writesphase === "requested"(past tense). This had silently broken iter-1 R3 and iter-1 Bug 2 for the entire iter-1 + iter-2 lifetime in production. Unit tests passed because they injected the event payload manually, sidestepping the filter. Addedfresh-session-entry.test.tsandplan-snapshot-persisterintegration test that go through the real event pipeline.14. Post-rebase residual fixes
Two type errors surfaced after rebasing onto
upstream/main:src/plugin-sdk/telegram.tsdeleted by upstream. OursendDocumentTelegramcaller inextensions/telegram/src/send.tswas orphaned. Deferred: Telegram document attachment re-wire is a small follow-up PR. The markdown plan file is still persisted to disk; only the Telegram document upload step is skipped (with awarn-level log line so the gap is visible in gateway logs).params.minimalTestGatewayrenamed/dropped upstream. Removed our conditional inserver-runtime-subscriptions.ts:82. Persister always starts now. No-op for production behavior.15. Test coverage matrix
200+ tests across 30+ test files. Coverage by layer:
plan-mode/approval.test.ts,plan-mode/types.test.tsplan-mode/mutation-gate.test.tstools/exit-plan-mode-tool.test.ts,tools/update-plan-tool.test.ts,tools/update-plan-tool.parity.test.ts,tools/ask-user-question-tool.test.tsplan-mode/plan-archetype-persist.test.ts,plan-mode/plan-archetype-prompt.test.ts,plan-mode/plan-archetype-bridge.test.tsplan-mode/plan-nudge-crons.test.ts,heartbeat-runner.plan-nudge.test.tsplan-hydration.test.tsplan-mode/plan-mode-debug-log.test.tsauto-reply/reply/fresh-session-entry.test.tssystem-prompt-gpt5-boot-reorder.test.tsskills/frontmatter.test.ts,skills/skill-planner.test.ts,skills.buildworkspaceskillsnapshot.test.tsgateway/sessions-patch.subagent-gate.test.ts,gateway/server-close.test.tspi-embedded-runner/run.incomplete-turn.test.ts,pi-embedded-runner/pending-injection.test.tsauto-reply/reply/commands-plan.test.ts/planrouting across channel formatsqa/scenarios/gpt54-*.mdui/src/ui/chat/mode-switcher.test.ts,ui/src/ui/chat/plan-cards.test.tsEvery iter-1/2/3 bug has a dedicated regression test. The critical persister-typo bug gained both a unit test (
plan-snapshot-persister.ts) and a real-pipeline integration test (fresh-session-entry.test.ts) so a future typo would fail CI, not silently break prod.Run locally:
16. Security considerations
The mutation gate is a security boundary. A bug that lets an agent execute a mutation tool while in plan mode before approval defeats the whole feature.
Threat model:
mutation-gate.tsdefault-deny: any tool not on the explicit allowlist is blocked in plan mode. New plugins cannot bypass the gate silently.find . -delete)-delete,-exec,-execdir,-rf,--delete,-fprint*,-fprintf,-fls,--outputblocked. Shell compound operators (;,|,&,$(),>,<, newline, carriage return) rejected.approvalIdis cryptographic random (notMath.random), regenerated perexit_plan_mode. Stale-id guard silently no-ops mismatches.[PLAN_DECISION]: approvedin the injectionbuildPlanDecisionInjectionsanitizes feedback/step text against envelope-closing sequences. Injection text is built server-side, not from agent output.persistPlanArchetypeMarkdownrejectsagentIdcontaining/\, control chars,.,... Resolved absolute path must stay insidebaseDir(belt-and-suspenders).exit_plan_mode) readsopenSubagentRunIdsat submission; approval-side (sessions.patch) re-reads at approve/edit. Reject is intentionally never gated.completedwithout verifying criteriaupdate_planrejectsstatus:"completed"unlessverifiedCriteria ⊇ acceptanceCriteria(whitespace-trimmed). Merge mode re-validates on the merged result — catches inherited unverified criteria.system-prompt.tssanitizes context files; PR-A injection-scan regex narrowed (context-file-injection-scan.test.ts).Not in scope for this PR:
operator.approvals).planApproval.quorumfield).What should get extra review eyes:
src/agents/plan-mode/mutation-gate.ts— the canonical allow/denylist. Any future tool plugin adding mutating behavior must add itself here.src/gateway/sessions-patch.ts— approval dispatcher. The two gate call sites (planApprovaland theexit_plan_modetool handler) must stay in sync.src/agents/plan-mode/plan-archetype-persist.ts— path traversal defense. Reviewers: try to craft anagentIdthat escapes.17. Worked examples
17.1 Minimal: webchat user asks for a refactor
UserStoretoAccountStoreacross the codebase."planMode.autoEnableFormatching the model) enters plan mode. Chip flips to "Plan mode".grep,readto map usage.update_planwith 6 steps (1 in_progress, 5 pending). Sidebar checklist updates live.exit_plan_modewithtitle: "Rename UserStore → AccountStore",plan,risks: ["public API; will break downstream consumers"],verification: ["grep confirms 0 remaining UserStore; tests pass"].editon each file.update_plancalled after each step (statuscompleted,verifiedCriteria= what was checked).normal, nudge crons cleaned up.17.2 Intermediate: Telegram operator approves from phone
/plan accept. Gateway dispatches the samesessions.patch { planApproval: { action: "approve" }}as webchat.sessions_spawnchildren are in flight, operator seesPLAN_APPROVAL_BLOCKED_BY_SUBAGENTSwith child session IDs and retries when they complete.[PLAN_DECISION]: approvedprepended to its prompt; mutations unlock.17.3 Advanced: subagent-orchestrated research
sessions_spawnthree times (Stripe, Adyen, PayPal integrations — each a research subagent).update_planwith "await subagent reports" as anin_progressstep.exit_plan_modeearly — tool-side subagent gate fires:ToolInputError: 3 open subagents: <runIds>.subagent-registry-run-manager.tsdrainsopenSubagentRunIds.exit_plan_modeagain with the synthesis. Plan persisted as markdown. Approval fires.apply_patchon vulnerable code paths.18. Troubleshooting
agents.defaults.planMode.enabled: falsesessions listfor stuck children.exit_plan_modein same turnopenclaw config touch agents.defaults.systemPrompt. Verify by tailing[plan-mode/tool_call]debug events.~/.openclaw/agents/<id>/plans/plan-*.md. Use/plan statusor/plan restateto text-render in channel.cleanupPlanNudgesruns on mode-transition. ChecknudgeJobIdsinSessionEntry. If stuck, delete by ID via cron management.sessions.changedfloods UI on long plansupdate_plancalllastPlanStepsnot written becauseupdate_planfailed silently before compaction[plan-mode/tool_call]debug log for last successful update.plan-hydration.tscannot restore what was never persisted.[plan-mode/*]events missing fromgateway.err.logOPENCLAW_DEBUG_PLAN_MODE=1(env) oropenclaw config set agents.defaults.planMode.debug true(persistent).19. Known follow-ups
sendDocumentTelegramlost its home in the upstream plugin-SDK restructure; needs to be re-wired to the new SDK location. Small (~100 LoC). Blocks nothing; plan markdown is already on disk.timed_outstate. New error codePLAN_APPROVAL_EXPIRED+ UI auto-dismiss. Deferred from iter-2./plan self-testslash command. Deferred from iter-3 plan. Non-blocking hardening + debuggability.20. Mergeability scorecard
Self-assessment. Reviewers should disagree loudly where warranted.
v2026.4.19-beta.2. Two residual-fix call-outs in §14./plan. Telegram attachment deferred.Checklist to reach 10/10 (reviewer-actionable):
qa/scenarios/gpt54-plan-mode-default-off.mdfirst (ensures feature stays off), then enable and run remaining four.PLAN_MODE_REFERENCE_CARDinflation is ~80 lines and must not break cache hits across turns within a session.update_planvstask_createtool-name aliases and confirm tool-name reconciliation is complete.agents.defaults.planMode.debugdoes nothing whenplanMode.enabled: false(debug should never enable the feature itself).openclaw config setround-tripsautoEnableForas a regex array cleanly.21. Maintenance guidance
Suggested code ownership:
src/agents/plan-mode/**— Plan-mode core. One owner.src/agents/tools/*plan*.ts,*ask-user-question*.ts,*sessions-spawn*.ts— Tools. Same owner as core; tight coupling.src/gateway/sessions-patch.ts,src/gateway/protocol/schema/sessions.ts— Gateway. Gateway codeowner.ui/src/ui/views/plan-approval-inline.ts,ui/src/ui/chat/plan-cards.ts— UI. UI codeowner.extensions/telegram/**,extensions/openai/prompt-overlay.ts, other channels — per-extension owners.qa/scenarios/gpt54-*.md,docs/concepts/plan-mode.md,docs/plans/PLAN-MODE-ARCHITECTURE.md— Docs/QA. Core owner + tech-writer review for doc changes.Review checklist (per PR in this surface area going forward):
mutation-gate.tsallow or deny list explicitly (default-deny guarantees absence = blocked).types.tsunion, state-machine diagram,resolvePlanApproval, wire schema inprotocol/schema/sessions.ts, tests inapproval.test.ts.isMarkdownCapableMessageChannel, decide which mode (web-full / text-reduced), add a row to §5 matrix.zod-schema.agent-defaults.tsand TS type intypes.agent-defaults.ts, plus README indocs/concepts/plan-mode.md§config.22. Split-PR assessment
Stays consolidated:
Could split (candidates):
PLAN_APPROVAL_EXPIRED). Small UI + error-code addition. Could ship independently; not on a critical path.qa/scenarios/gpt54-*.mdfiles are independent of runtime logic; a QA-only PR would be trivial to re-submit if this PR is held.Recommendation: Merge as-is. The three split candidates are < 10% of the LoC and the consolidation's review-bot-noise argument (§why-consolidate) still holds even at reduced size.
23. Reviewer checklist
High-level gates (tick each):
mutation-gate.tsand accept the allowlist / denylist / default-deny posture (§16).approval.tsstate machine and accept the stale-id and terminal-state guards (§3).plan-archetype-persist.ts(§16).pnpm test plan-modelocally and got green.Appendix A — full file inventory
New files (selection):
Plan-mode core (8)
src/agents/plan-mode/types.tssrc/agents/plan-mode/approval.tssrc/agents/plan-mode/reference-card.tssrc/agents/plan-mode/plan-nudge-crons.tssrc/agents/plan-mode/plan-mode-debug-log.tssrc/agents/plan-mode/plan-archetype-persist.tssrc/agents/plan-mode/mutation-gate.tssrc/agents/plan-mode/index.tsAgent tools (6)
src/agents/tools/enter-plan-mode-tool.tssrc/agents/tools/exit-plan-mode-tool.tssrc/agents/tools/update-plan-tool.ts(expanded)src/agents/tools/ask-user-question-tool.tssrc/agents/tools/plan-mode-status-tool.tssrc/agents/tools/sessions-spawn-tool.ts(expanded for subagent gating)Runtime + auto-reply (11)
src/agents/pi-embedded-runner/run/params.tssrc/agents/pi-embedded-runner/run/helpers.tssrc/agents/pi-embedded-runner/pending-injection.tssrc/agents/pi-tools.ts,src/agents/pi-tools.before-tool-call.tssrc/agents/plan-hydration.tssrc/agents/subagent-announce.ts,src/agents/subagent-registry-run-manager.tssrc/auto-reply/commands-registry.shared.ts,src/auto-reply/reply/commands-handlers.runtime.tssrc/auto-reply/reply/fresh-session-entry.tssrc/infra/heartbeat-runner.tsGateway + config + schema (14)
src/gateway/server-runtime-subscriptions.ts,server-runtime-handles.ts,server-close.ts,server.impl.tssrc/gateway/server-methods/sessions.tssrc/gateway/sessions-patch.ts(expanded)src/gateway/session-utils.ts,session-utils.types.tssrc/gateway/protocol/schema/sessions.ts,protocol/schema/error-codes.ts,protocol/index.tssrc/config/zod-schema.agent-defaults.ts,zod-schema.agent-runtime.ts,zod-schema.tssrc/config/sessions/types.ts,types.agents.ts,types.agent-defaults.ts,types.skills.tsSkills + UI + channels + apps (16)
src/agents/skills/skill-planner.ts,skills/frontmatter.ts,skills/types.ts,skills/workspace.tsui/src/ui/app-chat.ts,ui/views/plan-approval-inline.ts,ui/chat/plan-cards.tsui/src/styles/chat/plan-cards.css,styles/chat.cssui/src/i18n/locales/en.tsextensions/openai/prompt-overlay.tsextensions/telegram/src/send.ts,extensions/telegram/runtime-api.tsapps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.jsondocs/concepts/plan-mode.md,docs/plans/PLAN-MODE-ARCHITECTURE.mdTests (30+) and QA (5)
src/agents/plan-mode/*.test.ts(approval, mutation-gate, plan-archetype-persist, plan-archetype-prompt, plan-archetype-bridge, plan-mode-debug-log, plan-nudge-crons)src/agents/tools/*.test.ts(exit-plan-mode-tool, update-plan-tool, update-plan-tool.parity, ask-user-question-tool)src/agents/*.test.ts(plan-hydration, plan-render, plan-store, system-prompt-gpt5-boot-reorder, skills/frontmatter, skills/skill-planner, skills.buildworkspaceskillsnapshot, context-file-injection-scan)src/agents/pi-embedded-runner/*.test.ts(run.incomplete-turn, pending-injection, skills-runtime)src/auto-reply/reply/*.test.ts(commands-plan, fresh-session-entry)src/gateway/*.test.ts(sessions-patch.subagent-gate, server-close)src/infra/*.test.ts(heartbeat-runner.plan-nudge)ui/src/ui/chat/*.test.ts(mode-switcher, plan-cards)qa/scenarios/gpt54-*.md(5 scenarios)Appendix B — change provenance
Commit ranges per iteration cycle (branch
feat/plan-channel-parity):…640ac2ed97— consolidates 10 PRs, marks them closed.[PLAN_DECISION]format + tool STOP-AFTER-EXIT rule + always-on[plan-approval-gate]diagnostic.…47db745da6— bootstrap reference card +[PLAN_MODE_INTRO]+plan-mode-101skill + user doc.…a33bee453a— always-on[exit-plan-gate]+ plan-mode-aware subagent announce +plan_mode_statustool.phase === "request"→phase === "requested"inplan-snapshot-persister.ts.…b3081a6858— Telegram-send orphan deferral +minimalTestGatewayremoval.…b3081a6858— 8 real + 5 lint baseline issues resolved.Full commit list available via
git log --oneline upstream/main..HEADonfeat/plan-channel-parity.Backup
A backup branch
feat/plan-channel-parity-backupexists on the fork (100yenadmin/openclaw-1) at the pre-rebase HEADbee5e8c364for rollback safety. Will be deleted after merge with maintainer approval.Verification (live retest after merge)
Then test the canonical flow:
/plan onor agent callsenter_plan_mode)sessions_spawnfor a long-running task)exit_plan_modewith title + plan + archetype fields~/.openclaw/agents/<id>/plans/plan-YYYY-MM-DD-<slug>.md