Skip to content

feat(plan-mode): full rollout + iter-1/2/3 hardening (consolidates PRs A/B/C/D/E/F/7/8/10/11)#68939

Closed
100yenadmin wants to merge 171 commits into
openclaw:mainfrom
electricsheephq:feat/plan-channel-parity
Closed

feat(plan-mode): full rollout + iter-1/2/3 hardening (consolidates PRs A/B/C/D/E/F/7/8/10/11)#68939
100yenadmin wants to merge 171 commits into
openclaw:mainfrom
electricsheephq:feat/plan-channel-parity

Conversation

@100yenadmin

@100yenadmin 100yenadmin commented Apr 19, 2026

Copy link
Copy Markdown
Contributor

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 on or the chip in webchat. (Auto-activation via agents.defaults.planMode.autoEnableFor model-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 /plan slash 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 /plan commands today), agents.defaults.planMode.autoEnableFor model-pattern auto-enable, agents.defaults.planMode.approvalTimeoutSeconds cron-time watchdog, /plan self-test slash-command harness, and the Bug B stale-card auto-dismiss. All tracked in §19 "Known follow-ups" + the in-code SCHEMA-RESERVED comments at src/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

  • 🎯 What: Plan-mode runtime + tools + approval UX + channel parity.
  • 🧩 Scope: 145 files, +31k LoC net, touches gateway / runner / tools / UI / 6 channel extensions / config / docs.
  • 🚩 Default state: OFF. No existing session or model behaves differently on merge.
  • 🔐 Safety: Mutation gate is fail-closed (unknown tools blocked). Approval requires valid approvalId (cryptographic random, regenerated per exit). Subagent gate blocks approve/edit while research children are in flight.
  • 🧪 Tests: 200+ added, organized by module below. pnpm test green. No new flakes.
  • 🔁 Rollback: Flag flip (agents.defaults.planMode.enabled: false) disables the feature instantly. Full branch rollback to bee5e8c364 preserved on feat/plan-channel-parity-backup.
  • 📦 Deferrals: Telegram document attachment (upstream SDK surface removed mid-rebase); plan-card auto-dismiss on expiry (Bug B); /plan self-test harness (iter-3 R1–R5); autoEnableFor model-pattern auto-enable (schema-reserved, no cron-time scanner yet); approvalTimeoutSeconds cron watchdog (schema-reserved, no timeout firing yet); non-web-channel inline-button cards (Telegram/Slack/Discord text-only today via /plan commands). All tracked in §19; the in-code SCHEMA-RESERVED comments at src/config/types.agent-defaults.ts:316-355 are the authoritative source of truth.

Table of contents

  1. Feature overview
  2. High-level architecture
  3. State machine
  4. Critical flows (sequence diagrams)
  5. Channel delivery matrix
  6. File-level architecture
  7. Dependencies & integration points
  8. Configuration reference
  9. Runtime behavior & performance
  10. Backward compatibility
  11. Upgrade / migration / rollback
  12. 10-PR consolidation map
  13. Iter-1/2/3 hardening detail
  14. Post-rebase residual fixes
  15. Test coverage matrix
  16. Security considerations
  17. Worked examples
  18. Troubleshooting
  19. Known follow-ups
  20. Mergeability scorecard
  21. Maintenance guidance
  22. Split-PR assessment
  23. Reviewer checklist
  24. Appendix A — full file inventory
  25. Appendix B — change provenance

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 in agents.defaults.planMode.autoEnableFor (e.g. GPT-5.4 for structured task runs). While in plan mode the agent can:

  • Read freely (read, web_search, web_fetch, memory_search, sessions_list, sessions_history)
  • Run read-only exec (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)
  • Track progress via update_plan (closure-gated: status:"completed" rejected until verifiedCriteria ⊇ acceptanceCriteria)
  • Spawn research subagents (sessions_spawn) — the parent tracks their runIds in openSubagentRunIds
  • Ask the user a constrained-choice question (ask_user_question)
  • Introspect its own state (plan_mode_status)

…and cannot:

  • Call any mutation tool. The mutation gate (see §6) is fail-closed: unknown tools are blocked by default.

When the agent is ready, it calls exit_plan_mode with the plan archetype (title, plan[], optional summary / analysis / assumptions / risks / verification / references). This:

  1. Blocks if any subagents are still in flight (tool-side gate).
  2. Persists the plan to disk as markdown at ~/.openclaw/agents/<agentId>/plans/plan-YYYY-MM-DD-<slug>.md.
  3. Transitions session approval state to pending with a freshly minted cryptographic approvalId.
  4. Broadcasts the approval request to every connected channel via the gateway.

The user approves / edits / rejects via webchat buttons, Telegram inline keyboard (pending PR-14), or the universal /plan accept|revise|answer|auto slash 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 to normal, a synthetic [PLAN_DECISION]: approved message is queued as pendingAgentInjection, 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/&lt;id&gt;/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 --> Runner
Loading

3. State machine

Session state is the Cartesian product of PlanMode ∈ {normal, plan} and PlanApprovalState ∈ {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 note
Loading

Key invariants:

  • approvalId is a cryptographic random token (newPlanApprovalId in src/agents/plan-mode/types.ts) regenerated on every exit_plan_mode. Stale UI clicks (old approvalId) are silently no-op'd.
  • Rejection does not flip mode back to normal. The agent stays in plan mode and revises.
  • After 3 rejections, the [PLAN_DECISION] injection suggests the agent ask a clarifying question instead of looping.
  • Mode transition to normal via /plan off is 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 contract
Loading

4.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 refresh
Loading

4.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
  end
Loading

4.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
  end
Loading

4.5 Compaction + plan hydration

sequenceDiagram
  participant Run as runner
  participant Comp as compaction
  participant Store as SessionEntry
  participant Hyd as plan-hydration
  participant Agent

  Note over Comp: context approaching limit
  Comp->>Store: snapshot SessionEntry (planMode.lastPlanSteps preserved)
  Comp-->>Run: compacted history
  Note over Run: next turn
  Run->>Hyd: formatPlanForHydration(lastPlanSteps)
  Hyd->>Hyd: filter active (pending + in_progress)<br/>drop terminal
  Hyd-->>Run: synthetic user message<br/>"[Your active plan was preserved...]<br/>- [ ] step (pending)<br/>- [>] step (in_progress)"
  Run->>Agent: prepend hydration to prompt
  Note over Agent: agent continues plan<br/>instead of re-planning
Loading

5. Channel delivery matrix

Webchat has the full mode. All other channels get a text-reduced mode driven by universal /plan slash 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.

Capability Webchat Telegram Slack Discord iMessage Signal CLI SMS/other plaintext
Inline approval card (3 buttons)
Expandable plan-step card in thread
Sidebar plan-view toggle
Inline revision textarea
Question modal (options + free-text)
Universal /plan slash commands
Plan checklist (/plan restate) ✅ md ✅ HTML ✅ mrkdwn ✅ md ✅ plaintext ✅ md ✅ md ✅ plaintext
Markdown attachment N/A 🔄 deferred (PR-14) ❌ planned ❌ planned
Auto-approve toggle (/plan auto)
Reject with feedback
Answer ask_user_question ✅ (modal) /plan answer …

Universal slash commands (all channels):

/plan status              → show current mode / approval / title
/plan on | off            → toggle plan mode
/plan view                → open sidebar (web) / hint on text channels
/plan accept [edits]      → approve (with or without edits flag)
/plan revise <feedback>   → reject with mandatory feedback
/plan answer <text>       → answer ask_user_question prompt
/plan auto on | off       → toggle auto-approve
/plan restate             → re-render plan checklist inline

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/

File Role Public surface Invariants
types.ts Type contracts + approval-id generator + decision injection builder PlanMode, PlanApprovalState, PlanModeSessionState, newPlanApprovalId(), buildPlanDecisionInjection() approvalId cryptographic (not Math.random), regenerated per exit. Feedback sanitized against envelope-closing. After 3 rejections, injection hints at ask_user_question.
approval.ts Approval state-transition resolver resolvePlanApproval(), buildApprovedPlanInjection(), DEFAULT_APPROVAL_CONFIG = { approvalTimeoutSeconds: 600 } Stale-approvalId guard: mismatch → no-op. Terminal states (approved/edited/timed_out) require fresh exit. Reject never gated by subagent state.
reference-card.ts Persistent reference-card injected each plan-mode turn PLAN_MODE_REFERENCE_CARD Token-budget-aware (~80 lines). Mirrors plan-mode-101 skill — must stay in sync.
plan-nudge-crons.ts Schedule one-shot nudge wake-ups schedulePlanNudges(), cleanupPlanNudges() Default [10, 30, 60] min. Job names prefixed plan-nudge:. Scheduling failures returned as partial-success list, never thrown. Heartbeat degrades nudge to no-op if plan resolved.
plan-mode-debug-log.ts Gated structured debug events PlanModeDebugEvent, logPlanModeDebug() Zero-overhead when disabled. Activated by env var OPENCLAW_DEBUG_PLAN_MODE=1 or config flag agents.defaults.planMode.debug: true.
plan-archetype-persist.ts Write plan markdown to disk persistPlanArchetypeMarkdown() Path-traversal defense: agentId rejects /\, control chars, ., ... Resolved path must stay inside baseDir. Collision suffix retries up to 99.
mutation-gate.ts Fail-closed tool-call gate checkMutationGate(toolName, mode, execCommand?), PLAN_STEP_STATUSES Default-deny unknown tools. Exec allowlist: ls/cat/pwd/git/find/grep/rg/etc. Dangerous flags (-delete, -exec, --output, -rf) blocked. Shell compound operators (;, |, &, $(), >, <) rejected.
index.ts Public export surface Re-exports above

6.2 Agent tools — src/agents/tools/

File Role Schema Key behavior
enter-plan-mode-tool.ts Flip session into plan mode { reason?: string } Mode transition applied in runner (not tool) — keeps tool cheap.
exit-plan-mode-tool.ts Submit plan for approval { title, plan[], summary?, analysis?, assumptions?, risks?, verification?, references? } Tool-side subagent gate: openSubagentRunIds.size > 0ToolInputError. Title required (no fallback). ≤1 in_progress.
update-plan-tool.ts Track plan progress { plan[{ step, status, activeForm?, acceptanceCriteria?, verifiedCriteria? }], merge?, explanation? } Closure gate: status:"completed" rejected until verifiedCriteria ⊇ acceptanceCriteria (whitespace-trimmed). Merge validates closure on final result (catches inherited unverified criteria). Auto-close-on-complete → emits phase: "completed" event.
ask-user-question-tool.ts Constrained-choice clarification { question, options: [string,...], allowFreetext?: boolean } 2–6 options. Duplicate option text rejected (ambiguous routing). questionId deterministic from toolCallId (prompt-cache stable).
plan-mode-status-tool.ts Read-only introspection {} Reads SessionEntry with skipCache: true. Safe to call anytime including during pending approval.
sessions-spawn-tool.ts Research subagent spawn existing schema + plan-mode awareness When parent in plan mode: forces cleanup: "keep", registers child runId in parent's openSubagentRunIds. Cleaned on child completion by subagent-registry-run-manager.ts.
tool-catalog.ts Registry Gates plan-mode tools behind agents.defaults.planMode.enabled.
tool-description-presets.ts Tool summaries + descriptions Includes STOP-AFTER-EXIT lifecycle rule.
tool-display-config.ts UI display metadata Mirrored to apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json.

6.3 Runtime — src/agents/pi-embedded-runner/ + src/agents/

File Role Notes
pi-embedded-runner/run/params.ts Run-params type with plan-mode threading Adds planMode?: "plan" | "normal" (snapshot) + getLatestPlanMode?: () => … (live accessor — fixes iter-2 Bug 4 closure-stale-ref).
pi-embedded-runner/run/helpers.ts Iteration caps Raises default max iterations, adds user override, defines subagent cap.
pi-embedded-runner/pending-injection.ts Consume pendingAgentInjection Atomic read + clear. Best-effort: if write fails, returns captured value for injection.
pi-embedded-runner/pending-injection.test.ts Coverage
pi-tools.ts + pi-tools.before-tool-call.ts Before-tool-call hook Calls checkMutationGate. Uses getLatestPlanMode() for freshness across mid-turn approval.
plan-hydration.ts Post-compaction plan restore formatPlanForHydration(steps) → factual phrasing (not imperative) to avoid planning-only retry guard.
subagent-announce.ts Plan-mode-aware steer Parent-side instruction avoids stall after subagent completion.
subagent-registry-run-manager.ts Drain openSubagentRunIds Child completion/kill → remove from parent's set.
transport-message-transform.ts Synthesized missing tool_result placeholder Improves placeholder text + logs repairs.
system-prompt.ts GPT-5 context-file boot reorder + sanitization Injection-defense for context files.
system-prompt-contribution.ts Provider prompt section id Adds tool_enforcement.
openclaw-tools.ts + openclaw-tools.registration.ts Plan-mode tool registration Config-gated.
agent-scope.ts Per-field autoContinue + maxIterations override resolution Cascade: agent → defaults.

6.4 Auto-reply / commands — src/auto-reply/

File Role
reply/commands-handlers.runtime.ts Register universal /plan handler
commands-registry.shared.ts /plan command definition
reply/commands-system-prompt.ts Thread provider/model identity into system prompt
reply/fresh-session-entry.ts Disk-fresh session entry + deletion-as-normal resolver (fixes iter-2 Bug A: getLatestPlanMode returns "normal" on planMode deletion)

6.5 Gateway — src/gateway/

File Role
server-runtime-subscriptions.ts Start plan-snapshot-persister on startup; emit sessions.changed on persister mutations
server-runtime-handles.ts Add planSnapshotUnsub to gateway handles
server-close.ts Unsubscribe persister on shutdown
server.impl.ts Thread planSnapshotUnsub into close deps
server-methods/sessions.ts Include exec + planMode in sessions.changed payload
sessions-patch.ts Approval state machine dispatcher
session-utils.ts + session-utils.types.ts Surface exec + planMode on session rows
protocol/schema/sessions.ts Wire schema: planMode, planApproval, lastPlanSteps fields
protocol/schema/error-codes.ts PLAN_APPROVAL_BLOCKED_BY_SUBAGENTS error code
protocol/index.ts Re-export ErrorCode union

6.6 Config — src/config/

File Role
zod-schema.agent-defaults.ts embeddedPi.autoContinue, embeddedPi.maxIterations, planMode.{enabled, autoEnableFor, approvalTimeoutSeconds, debug}
zod-schema.agent-runtime.ts Per-agent embeddedPi overrides
zod-schema.ts maxPlanTemplateSteps skill limit
sessions/types.ts Persisted plan-mode state: mode, approval, title, approvalId, lastPlanSteps, approvalRunId, nudgeJobIds, feedback, rejectionCount, pendingAgentInjection
types.agents.ts, types.agent-defaults.ts, types.skills.ts TS types mirroring the zod

6.7 Skills — src/agents/skills/

File Role
skill-planner.ts Build plan-template seed payload (dedupe + truncation diagnostics)
frontmatter.ts Parse planTemplate from skill frontmatter (alias + precedence rules)
types.ts SkillPlanTemplateStep, resolvedPlanTemplates snapshot field
workspace.ts Carry plan templates into snapshots

6.8 Infra — src/infra/

File Role
heartbeat-runner.ts Prepends heartbeat prompt with "continue active plan" nudge when applicable
heartbeat-runner.plan-nudge.test.ts Coverage

6.9 UI — ui/src/

File Role
ui/app-chat.ts Dispatch slash-command action to toggle plan-view UI
ui/views/plan-approval-inline.ts Inline approval card (3 buttons, revise textarea)
ui/chat/plan-cards.ts Expandable plan-step card in thread
styles/chat/plan-cards.css Plan-card styles
styles/chat.css Imports plan-card styles into chat bundle
i18n/locales/en.ts Plan-view sidebar toggle i18n

6.10 Channels — extensions/

File Role
extensions/openai/prompt-overlay.ts Tool-enforcement + behavior guidance for GPT-5 overlays
extensions/telegram/src/send.ts sendDocumentTelegram helper (delivery surface — currently unused; see §14 post-rebase)
extensions/telegram/runtime-api.ts Export telegram document-send type

6.11 Apps / docs / QA

File Role
apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json Mirror of tool-display-config.ts for native apps
docs/concepts/plan-mode.md User-facing reference
docs/plans/PLAN-MODE-ARCHITECTURE.md Deep architecture + iter history + test matrix
qa/scenarios/gpt54-plan-mode-default-off.md QA: plan mode stays off by default for GPT-5.4
qa/scenarios/gpt54-mandatory-tool-use.md QA: GPT-5.4 uses tools for factual queries
qa/scenarios/gpt54-injection-scan.md QA: injection scanning doesn't break normal responses
qa/scenarios/gpt54-cancelled-status.md QA: cancelled status in update_plan
qa/scenarios/gpt54-act-dont-ask.md QA: "act on obvious defaults" behavior

7. Dependencies & integration points

Upstream deps (what we require from main):

  • plugin-sdk restructure (merged upstream) — our Telegram delivery surface extensions/telegram/src/send.ts was removed; we keep the helper exported but cannot call it until follow-up PR re-wires it.
  • server-runtime-subscriptions.tsparams.minimalTestGateway — renamed/dropped upstream; removed our conditional in server-runtime-subscriptions.ts:82. Persister always starts now.
  • compaction checkpoint machinery (merged upstream) — plan-hydration.ts hooks into existing snapshot machinery; no new plumbing.
  • parity benchmark harness (merged upstream) — QA scenarios extend the existing harness.

Downstream integration:

  • Webchat / native apps (apps/shared/OpenClawKit) — consume sessions.changed with the new planMode field. Apps built against older gateway still ignore the field (additive wire).
  • Channel extensions (Slack, Discord, Matrix, etc.) — receive /plan commands through the universal handler. No per-channel wiring beyond the existing markdownCapable flag.
  • Telegram follow-up (PR-14) — requires re-wiring sendDocumentTelegram to 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)

agents.defaults = {
  planMode: {
    enabled: false,                // Master switch. Default false — existing sessions unchanged.
    autoEnableFor: [],             // Model-id regex patterns; auto-enters plan mode on session start.
    approvalTimeoutSeconds: 600,   // Range 10..86400. Default 10 min.
    debug: false,                  // Emits [plan-mode/*] events to gateway.err.log.
  },
  embeddedPi: {
    autoContinue: {
      enabled: false,              // Escalating retry on incomplete turns.
      maxCycles: 3,                // Max auto-continue cycles per turn.
    },
    maxIterations: <integer>,      // Existing key; new user override surface.
  },
}

8.2 Per-agent overrides (src/config/zod-schema.agent-runtime.ts)

agents.list[].embeddedPi = {
  autoContinue: { enabled?: boolean, maxCycles?: number },
  maxIterations: ?number,
}
agents.list[].planMode = { enabled?: boolean, ... }

8.3 Skills (src/config/zod-schema.ts)

skills.limits.maxPlanTemplateSteps: number  // Cap on plan-template seed size.

8.4 Env vars

Env var Effect
OPENCLAW_DEBUG_PLAN_MODE=1 Enables debug log without restart. Takes precedence over config flag.

8.5 Runtime config commands (any channel)

openclaw config set agents.defaults.planMode.enabled true
openclaw config set agents.defaults.planMode.debug true
openclaw config set agents.defaults.planMode.approvalTimeoutSeconds 1200

9. Runtime behavior & performance

Disk I/O:

  • On exit_plan_mode: one markdown write to ~/.openclaw/agents/<id>/plans/plan-YYYY-MM-DD-<slug>.md (avg ~2–6 KB).
  • On every update_plan: SessionEntry.planMode.lastPlanSteps persisted by plan-snapshot-persister. Debounced: one write per event, not per step.
  • On approve/reject: SessionEntry.planMode + SessionEntry.pendingAgentInjection updated in a single patch.

Memory:

  • In-memory AgentRunContext adds: inPlanMode: boolean, openSubagentRunIds: Set<string>, lastPlanSteps: PlanStepSnapshot[]. Bounded by plan size (typical 5–20 steps, hard cap via maxPlanTemplateSteps).

CPU:

  • Mutation gate: O(1) hash lookup per tool call. Exec-allowlist check is a regex on the command prefix. Negligible.
  • Plan hydration: runs once per post-compaction turn; string formatting of bounded step list.

Cron load:

  • Plan-nudge crons: up to 3 one-shot jobs per plan-mode session ([10, 30, 60] min by default). Cleaned on exit / close / session leave. Orphan cleanup at gateway start.

Network:

  • sessions.changed broadcast on every persister mutation (existing channel, additional fields only).
  • No new external HTTP endpoints.

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

  • Default off. planMode.enabled: false. Existing sessions, extensions, and channel clients operate identically to main.
  • Wire protocol additive. sessions.changed payload gains optional planMode / lastPlanSteps fields. Older clients ignore unknown keys.
  • Tool catalog gated. enter_plan_mode, exit_plan_mode, update_plan, ask_user_question, plan_mode_status only registered when flag on. Agents without the flag see no new tools.
  • sessions.patch new actions (planApproval: { action: ... }) reject when planMode.enabled: false at the gateway. UI chip hidden when disabled.
  • Error codes. PLAN_APPROVAL_BLOCKED_BY_SUBAGENTS is newly reserved in protocol/schema/error-codes.ts. No existing error code repurposed.

11. Upgrade / migration / rollback

Upgrade (enable plan mode for a given install):

# Opt-in per-model auto-activation (example: GPT-5.4)
openclaw config set agents.defaults.planMode.enabled true
openclaw config set 'agents.defaults.planMode.autoEnableFor[]' 'gpt-5\\.4.*'
launchctl kickstart -k gui/$UID/ai.openclaw.gateway

Migration:

  • No DB migration. SessionEntry.planMode is populated lazily on first enter_plan_mode or /plan on.
  • Existing sessions on disk without planMode are treated as { mode: "normal", approval: "none" } by fresh-session-entry.ts.

Rollback:

  1. Immediate (no redeploy)openclaw config set agents.defaults.planMode.enabled false → gateway restart. All new sessions normal-mode, tools hidden, UI chip hidden.
  2. Full feature revert — backup branch feat/plan-channel-parity-backup (fork 100yenadmin/openclaw-1) preserved at bee5e8c364. Revert to main, then cherry-pick main-only hot-fixes.

What won't roll back cleanly:

  • Plans already written to ~/.openclaw/agents/<id>/plans/ remain on disk. Harmless (markdown files, no process holds them). find ~/.openclaw/agents -name 'plan-*.md' -delete to clean.
  • SessionEntry.planMode fields persisted in existing session files are ignored by the pre-feature gateway (unknown keys).

12. 10-PR consolidation map

Original PR Title Branch Status
PR-A #67512 GPT-5.4 prompt discipline + personality bridge + injection scanning final-sprint/gpt5-openai-prompt-stack closed (consolidated)
PR-B #67514 Task system parity final-sprint/gpt5-task-system-parity closed (consolidated)
PR-C #67534 Plan checklist renderer — 4 formats phase3/plan-rendering closed (consolidated)
PR-D #67538 Plan mode runtime + escalating retry + auto-continue phase3/plan-mode closed (consolidated)
PR-E #67541 Skill plan templates phase4/skill-plan-templates closed (consolidated)
PR-F #67542 Cross-session plan store with file-level locking phase4/cross-session-plans closed (consolidated)
PR-7 #67721 UI mode switcher + clickable plan cards + channel-aware plan delivery feat/ui-mode-switcher-plan-cards closed (consolidated)
PR-8 #67840 Plan-mode integration bridge feat/plan-mode-integration closed (consolidated)
PR-10 #68440 Plan archetype + ask_user_question + auto mode feat/plan-archetype-and-questions closed (consolidated)
PR-11 #68441 Universal /plan slash commands across all channels feat/plan-channel-parity this PR (same branch)

13. 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):

# Symptom Root cause Fix location
R1 [PLAN_*]: tag missing on synthetic messages Tag prefix only added in approval path, not rejection/answer pending-injection.ts, approval.ts
R2 Plan title lost across refresh title not in SessionEntry.planMode sessions/types.ts, sessions-patch.ts
R3 Approve clicked with subagents in flight → race No approval-side gate sessions-patch.ts + new error code PLAN_APPROVAL_BLOCKED_BY_SUBAGENTS
R4 Zero log visibility into plan-mode lifecycle No debug surface plan-mode-debug-log.ts

iter-2 (6 bugs from deeper live run):

# Symptom Root cause Fix location
Bug A Mutations blocked post-approval Runner closure captured stale planMode snapshot pi-embedded-runner/run/params.ts — added getLatestPlanMode: () => … live accessor; fresh-session-entry.tsgetLatestPlanMode returns "normal" on planMode deletion
Bug 2 Title missing in approval card iter-1 R2 silently broken by persister typo (see §14) plan-snapshot-persister.ts
Bug 3 Subagent gate not firing in approval path iter-1 R3 silently broken by persister typo (see §14) plan-snapshot-persister.ts
Bug 4 Debug-log config flag ignored Only env var read plan-mode-debug-log.ts — config-flag path added
Bug 5 [PLAN_DECISION] format inconsistent across approve/reject/edit Two code paths, two formats approval.ts — unified one-line format
Bug 6 Agent kept calling tools after exit_plan_mode in same turn Tool description silent on STOP-AFTER-EXIT tool-description-presets.ts

iter-3 (self-discovery + R6 subagent hardening + critical persister typo):

  • Phase 1 (self-discovery): PLAN_MODE_REFERENCE_CARD injected on every plan-mode turn; first-time [PLAN_MODE_INTRO]: injection; plan-mode-101 skill; user-facing docs/concepts/plan-mode.md.
  • Phase 2/3 (R6 hardening + introspection): always-on [exit-plan-gate] and [plan-approval-gate] diagnostics; plan-mode-aware subagent announce-turn instruction; plan_mode_status introspection tool.
  • Critical persister typo fix: plan-snapshot-persister.ts filtered phase === "request" but the emitter writes phase === "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. Added fresh-session-entry.test.ts and plan-snapshot-persister integration 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.ts deleted by upstream. Our sendDocumentTelegram caller in extensions/telegram/src/send.ts was 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 a warn-level log line so the gap is visible in gateway logs).
  • params.minimalTestGateway renamed/dropped upstream. Removed our conditional in server-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:

Layer Files (examples) Tests What's covered
Unit — state / types plan-mode/approval.test.ts, plan-mode/types.test.ts 32+ State transitions, stale-id guard, terminal-state guard, feedback sanitization, rejectionCount semantics
Unit — mutation gate plan-mode/mutation-gate.test.ts 40+ Blocklist / allowlist, exec prefix allowlist, dangerous-flag rejection, shell-compound rejection, default-deny
Unit — tools tools/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.ts 70+ Subagent gate, closure gate, merge semantics, validation errors, deterministic questionId
Unit — persistence plan-mode/plan-archetype-persist.test.ts, plan-mode/plan-archetype-prompt.test.ts, plan-mode/plan-archetype-bridge.test.ts 50+ Path-traversal defense, collision suffixing, channel-specific rendering
Unit — nudges plan-mode/plan-nudge-crons.test.ts, heartbeat-runner.plan-nudge.test.ts 20+ Scheduling, cleanup, suppression-on-resolved
Unit — hydration plan-hydration.test.ts 8 Filtering terminal steps, factual phrasing, newline normalization
Unit — debug log plan-mode/plan-mode-debug-log.test.ts 17 Env var + config flag, disable short-circuit, event discriminants
Unit — fresh session auto-reply/reply/fresh-session-entry.test.ts 17 Closure-stale-ref + deletion-as-normal contract
Unit — system prompt system-prompt-gpt5-boot-reorder.test.ts GPT-5 context-file reorder
Unit — skills skills/frontmatter.test.ts, skills/skill-planner.test.ts, skills.buildworkspaceskillsnapshot.test.ts 20+ planTemplate parsing, precedence, snapshot versioning
Integration — gateway gateway/sessions-patch.subagent-gate.test.ts, gateway/server-close.test.ts 7 + updates Approval-side subagent gate, shutdown unsubscribe
Integration — runner pi-embedded-runner/run.incomplete-turn.test.ts, pi-embedded-runner/pending-injection.test.ts 25+ Retry counts, escalation, plan-mode carve-outs, atomic consume
Integration — commands auto-reply/reply/commands-plan.test.ts 30+ Universal /plan routing across channel formats
E2E / QA scenarios qa/scenarios/gpt54-*.md 5 Default-off contract, mandatory tool use, injection scan, cancelled status, act-don't-ask
UI ui/src/ui/chat/mode-switcher.test.ts, ui/src/ui/chat/plan-cards.test.ts 10+ Chip toggle, expandable plan card rendering

Every 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:

pnpm test                # full suite
pnpm test plan-mode      # feature-scoped
pnpm test --changed      # only affected by HEAD diff

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:

Threat Mitigation
Agent smuggles mutation via unknown tool name mutation-gate.ts default-deny: any tool not on the explicit allowlist is blocked in plan mode. New plugins cannot bypass the gate silently.
Agent runs dangerous exec under read-only allowlist (e.g. find . -delete) Exec allowlist checks both command prefix and flag allowlist. -delete, -exec, -execdir, -rf, --delete, -fprint*, -fprintf, -fls, --output blocked. Shell compound operators (;, |, &, $(), >, <, newline, carriage return) rejected.
Attacker replays old approval click (CSRF-ish) approvalId is cryptographic random (not Math.random), regenerated per exit_plan_mode. Stale-id guard silently no-ops mismatches.
Agent controls plan text to forge a [PLAN_DECISION]: approved in the injection buildPlanDecisionInjection sanitizes feedback/step text against envelope-closing sequences. Injection text is built server-side, not from agent output.
Plan markdown written outside agent dir (path traversal) persistPlanArchetypeMarkdown rejects agentId containing /\, control chars, ., ... Resolved absolute path must stay inside baseDir (belt-and-suspenders).
Subagent spawned after approval click races past gate Two-layer gate: tool-side (exit_plan_mode) reads openSubagentRunIds at submission; approval-side (sessions.patch) re-reads at approve/edit. Reject is intentionally never gated.
Closure-gate bypass: agent marks completed without verifying criteria update_plan rejects status:"completed" unless verifiedCriteria ⊇ acceptanceCriteria (whitespace-trimmed). Merge mode re-validates on the merged result — catches inherited unverified criteria.
Agent extracts secrets via context-file injection system-prompt.ts sanitizes context files; PR-A injection-scan regex narrowed (context-file-injection-scan.test.ts).

Not in scope for this PR:

  • Per-tool per-user ACLs (orthogonal; belongs in operator.approvals).
  • Multi-user approval quorum (orthogonal; belongs in a new planApproval.quorum field).

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 (planApproval and the exit_plan_mode tool handler) must stay in sync.
  • src/agents/plan-mode/plan-archetype-persist.ts — path traversal defense. Reviewers: try to craft an agentId that escapes.

17. Worked examples

17.1 Minimal: webchat user asks for a refactor

  1. User types: "Rename UserStore to AccountStore across the codebase."
  2. Agent (with planMode.autoEnableFor matching the model) enters plan mode. Chip flips to "Plan mode".
  3. Agent runs grep, read to map usage.
  4. Agent calls update_plan with 6 steps (1 in_progress, 5 pending). Sidebar checklist updates live.
  5. Agent calls exit_plan_mode with title: "Rename UserStore → AccountStore", plan, risks: ["public API; will break downstream consumers"], verification: ["grep confirms 0 remaining UserStore; tests pass"].
  6. Approval card renders above the input. User clicks Accept.
  7. Mutation gate disarmed; agent runs edit on each file.
  8. update_plan called after each step (status completed, verifiedCriteria = what was checked).
  9. Auto-close-on-complete fires → mode flips to normal, nudge crons cleaned up.

17.2 Intermediate: Telegram operator approves from phone

  1. Agent on long-running session sends approval request. Webchat renders the card; Telegram posts a text rendering:
[Plan pending approval] Rename UserStore → AccountStore
- [ ] grep usage across repo (pending)
- [>] draft migration note (in_progress)
- [ ] update 14 callers (pending)
- [ ] run test suite (pending)
Risks: public API; will break downstream consumers
Verification: grep confirms 0 remaining UserStore; tests pass

Reply:
  /plan accept           → approve
  /plan accept edits     → approve with edits
  /plan revise <text>    → reject with feedback
  /plan status           → re-show this card
  1. Operator replies /plan accept. Gateway dispatches the same sessions.patch { planApproval: { action: "approve" }} as webchat.
  2. Subagent gate: if any sessions_spawn children are in flight, operator sees PLAN_APPROVAL_BLOCKED_BY_SUBAGENTS with child session IDs and retries when they complete.
  3. On success, next agent turn sees [PLAN_DECISION]: approved prepended to its prompt; mutations unlock.

17.3 Advanced: subagent-orchestrated research

  1. User asks: "Which of our payment integrations are vulnerable to OPENSSL-2026-01?"
  2. Agent enters plan mode, calls sessions_spawn three times (Stripe, Adyen, PayPal integrations — each a research subagent).
  3. Agent calls update_plan with "await subagent reports" as an in_progress step.
  4. Agent tries exit_plan_mode early — tool-side subagent gate fires: ToolInputError: 3 open subagents: <runIds>.
  5. Agent waits. Each subagent returns. subagent-registry-run-manager.ts drains openSubagentRunIds.
  6. Agent calls exit_plan_mode again with the synthesis. Plan persisted as markdown. Approval fires.
  7. User clicks Accept. Agent proceeds with apply_patch on vulnerable code paths.

18. Troubleshooting

Symptom Likely cause Fix
Chip doesn't appear in webchat agents.defaults.planMode.enabled: false Set to true, restart gateway.
Plan-mode tools missing from agent's toolbox Same as above — tools gated behind the flag Same.
"Approve" button greys out with toast "subagents in flight" Research subagents still running Wait for completion; toast lists child IDs. Check sessions list for stuck children.
Agent keeps calling tools after exit_plan_mode in same turn Model ignored the STOP-AFTER-EXIT rule (typically a prompt-cache collision with old system prompt) Bump system prompt cache: openclaw config touch agents.defaults.systemPrompt. Verify by tailing [plan-mode/tool_call] debug events.
Plan markdown not appearing on Telegram Deferred follow-up (PR-14 not landed yet) Markdown still at ~/.openclaw/agents/<id>/plans/plan-*.md. Use /plan status or /plan restate to text-render in channel.
Nudge cron fires after user already approved Nudge scheduled before approval, approval happened, nudge wasn't cleaned up cleanupPlanNudges runs on mode-transition. Check nudgeJobIds in SessionEntry. If stuck, delete by ID via cron management.
sessions.changed floods UI on long plans Persister debounces per-event, not per-step, but a 50-step plan still emits once per update_plan call Expected. UI should coalesce. If not, open a follow-up.
Context compaction wipes the plan lastPlanSteps not written because update_plan failed silently before compaction Check [plan-mode/tool_call] debug log for last successful update. plan-hydration.ts cannot restore what was never persisted.
[plan-mode/*] events missing from gateway.err.log Debug not enabled OPENCLAW_DEBUG_PLAN_MODE=1 (env) or openclaw config set agents.defaults.planMode.debug true (persistent).

19. Known follow-ups

  • PR-14: Telegram visibility re-wire. sendDocumentTelegram lost 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.
  • Bug B: Stale approval-card UI auto-dismiss. A webchat approval card can remain visible after timed_out state. New error code PLAN_APPROVAL_EXPIRED + UI auto-dismiss. Deferred from iter-2.
  • R1–R5, D5: Robustness + /plan self-test slash command. Deferred from iter-3 plan. Non-blocking hardening + debuggability.

20. Mergeability scorecard

Self-assessment. Reviewers should disagree loudly where warranted.

Dimension Score Notes
Correctness (tests, iter-3 coverage) 9/10 200+ tests, every iter bug regressed, pipeline integration test added to catch future persister-typo-class bugs.
Safety (default-off, flag-gated) 10/10 Feature invisible on merge until operator opts in.
Wire compatibility 10/10 Additive fields, new error code, no repurposing.
Docs (PR body + architecture doc + concepts doc) 9/10 With this rewrite, PR body is comprehensive.
Size / reviewability 6/10 145 files, +31k LoC. Unavoidable for a consolidated rollout but will take a maintainer dedicated review slot. See §22.
Rebase quality 9/10 Clean on v2026.4.19-beta.2. Two residual-fix call-outs in §14.
Rollback clarity 10/10 Flag flip + backup branch; both documented.
Security posture 9/10 Threat model explicit. Default-deny mutation gate. Path traversal defense has belt-and-suspenders.
Channel parity 8/10 Webchat full, text channels reduced with universal /plan. Telegram attachment deferred.
Performance impact on non-users 10/10 Zero — short-circuits in normal mode.
Overall 9/10 Primary deduction: size. Everything else is tight.

Checklist to reach 10/10 (reviewer-actionable):

  • Maintainer reads §5 (channel matrix) and confirms the webchat-vs-text scope decision is acceptable for this release.
  • Maintainer reads §16 (security) and either signs off or requests additional threat-model items.
  • Maintainer decides on §22 split-PR question: keep consolidated, or carve Telegram re-wire / Bug B / UI polish into separate follow-ups before merge.
  • Run QA scenarios end-to-end on webchat + one text channel (Telegram preferred): qa/scenarios/gpt54-plan-mode-default-off.md first (ensures feature stays off), then enable and run remaining four.
  • Confirm prompt-cache behavior on GPT-5.4 — the PLAN_MODE_REFERENCE_CARD inflation is ~80 lines and must not break cache hits across turns within a session.
  • Grep for any remaining update_plan vs task_create tool-name aliases and confirm tool-name reconciliation is complete.
  • Verify agents.defaults.planMode.debug does nothing when planMode.enabled: false (debug should never enable the feature itself).
  • Verify openclaw config set round-trips autoEnableFor as 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*.tsTools. Same owner as core; tight coupling.
  • src/gateway/sessions-patch.ts, src/gateway/protocol/schema/sessions.tsGateway. Gateway codeowner.
  • ui/src/ui/views/plan-approval-inline.ts, ui/src/ui/chat/plan-cards.tsUI. 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.mdDocs/QA. Core owner + tech-writer review for doc changes.

Review checklist (per PR in this surface area going forward):

  • Any new tool added? → Add it to mutation-gate.ts allow or deny list explicitly (default-deny guarantees absence = blocked).
  • New approval state or action? → Update types.ts union, state-machine diagram, resolvePlanApproval, wire schema in protocol/schema/sessions.ts, tests in approval.test.ts.
  • New channel? → Add to isMarkdownCapableMessageChannel, decide which mode (web-full / text-reduced), add a row to §5 matrix.
  • New config key? → Add to zod-schema.agent-defaults.ts and TS type in types.agent-defaults.ts, plus README in docs/concepts/plan-mode.md §config.
  • Persister / event-filter change? → Always add a real-pipeline integration test, not just a unit test. The iter-3 persister typo regression is the canonical cautionary tale.

22. Split-PR assessment

Stays consolidated:

  • All 10 original PR bodies (A, B, C, D, E, F, 7, 8, 10, 11). Splitting again undoes the consolidation rationale. Dependency graph would re-form.

Could split (candidates):

  • Telegram document attachment re-wire (PR-14). Already deferred — small, self-contained, landing post-merge is cleaner.
  • Bug B (stale approval-card auto-dismiss + PLAN_APPROVAL_EXPIRED). Small UI + error-code addition. Could ship independently; not on a critical path.
  • QA scenario expansion. The 5 qa/scenarios/gpt54-*.md files 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):

  • I read the executive summary + TL;DR and understand the scope.
  • I verified default-off: no session behavior changes without opt-in (§10).
  • I looked at mutation-gate.ts and accept the allowlist / denylist / default-deny posture (§16).
  • I looked at approval.ts state machine and accept the stale-id and terminal-state guards (§3).
  • I checked the subagent gates at both submission (tool) and approval (gateway) sites (§4.3).
  • I checked the path-traversal defense in plan-archetype-persist.ts (§16).
  • I ran pnpm test plan-mode locally and got green.
  • I confirm the 5 QA scenarios pass on webchat.
  • I agree with the rollback procedure (§11).
  • I agree the deferred Telegram re-wire is acceptable for merge.

Appendix A — full file inventory

New files (selection):

Plan-mode core (8)
  • src/agents/plan-mode/types.ts
  • src/agents/plan-mode/approval.ts
  • src/agents/plan-mode/reference-card.ts
  • src/agents/plan-mode/plan-nudge-crons.ts
  • src/agents/plan-mode/plan-mode-debug-log.ts
  • src/agents/plan-mode/plan-archetype-persist.ts
  • src/agents/plan-mode/mutation-gate.ts
  • src/agents/plan-mode/index.ts
Agent tools (6)
  • src/agents/tools/enter-plan-mode-tool.ts
  • src/agents/tools/exit-plan-mode-tool.ts
  • src/agents/tools/update-plan-tool.ts (expanded)
  • src/agents/tools/ask-user-question-tool.ts
  • src/agents/tools/plan-mode-status-tool.ts
  • src/agents/tools/sessions-spawn-tool.ts (expanded for subagent gating)
Runtime + auto-reply (11)
  • src/agents/pi-embedded-runner/run/params.ts
  • src/agents/pi-embedded-runner/run/helpers.ts
  • src/agents/pi-embedded-runner/pending-injection.ts
  • src/agents/pi-tools.ts, src/agents/pi-tools.before-tool-call.ts
  • src/agents/plan-hydration.ts
  • src/agents/subagent-announce.ts, src/agents/subagent-registry-run-manager.ts
  • src/auto-reply/commands-registry.shared.ts, src/auto-reply/reply/commands-handlers.runtime.ts
  • src/auto-reply/reply/fresh-session-entry.ts
  • src/infra/heartbeat-runner.ts
Gateway + config + schema (14)
  • src/gateway/server-runtime-subscriptions.ts, server-runtime-handles.ts, server-close.ts, server.impl.ts
  • src/gateway/server-methods/sessions.ts
  • src/gateway/sessions-patch.ts (expanded)
  • src/gateway/session-utils.ts, session-utils.types.ts
  • src/gateway/protocol/schema/sessions.ts, protocol/schema/error-codes.ts, protocol/index.ts
  • src/config/zod-schema.agent-defaults.ts, zod-schema.agent-runtime.ts, zod-schema.ts
  • src/config/sessions/types.ts, types.agents.ts, types.agent-defaults.ts, types.skills.ts
Skills + UI + channels + apps (16)
  • src/agents/skills/skill-planner.ts, skills/frontmatter.ts, skills/types.ts, skills/workspace.ts
  • ui/src/ui/app-chat.ts, ui/views/plan-approval-inline.ts, ui/chat/plan-cards.ts
  • ui/src/styles/chat/plan-cards.css, styles/chat.css
  • ui/src/i18n/locales/en.ts
  • extensions/openai/prompt-overlay.ts
  • extensions/telegram/src/send.ts, extensions/telegram/runtime-api.ts
  • apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json
  • docs/concepts/plan-mode.md, docs/plans/PLAN-MODE-ARCHITECTURE.md
Tests (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):

  • Consolidation + initial rebase: …640ac2ed97 — consolidates 10 PRs, marks them closed.
  • Iter-1 (R1–R4): live-test bug fixes from first webchat run.
  • Iter-2 (Bug A, Bug 2–6): deeper live-test cycle; closure-stale-ref fix + debug-log config-flag activation + unified one-line [PLAN_DECISION] format + tool STOP-AFTER-EXIT rule + always-on [plan-approval-gate] diagnostic.
  • Iter-3 Phase 1 (self-discovery): …47db745da6 — bootstrap reference card + [PLAN_MODE_INTRO] + plan-mode-101 skill + user doc.
  • Iter-3 Phase 2/3 (R6 hardening + introspection): …a33bee453a — always-on [exit-plan-gate] + plan-mode-aware subagent announce + plan_mode_status tool.
  • Critical persister-typo fix: phase === "request"phase === "requested" in plan-snapshot-persister.ts.
  • Post-rebase residual fixes: …b3081a6858 — Telegram-send orphan deferral + minimalTestGateway removal.
  • First-wave bot review address: …b3081a6858 — 8 real + 5 lint baseline issues resolved.

Full commit list available via git log --oneline upstream/main..HEAD on feat/plan-channel-parity.


Backup

A backup branch feat/plan-channel-parity-backup exists on the fork (100yenadmin/openclaw-1) at the pre-rebase HEAD bee5e8c364 for rollback safety. Will be deleted after merge with maintainer approval.

Verification (live retest after merge)

# Activate plan-mode debug logging:
openclaw config set agents.defaults.planMode.debug true
# Restart gateway via menubar app or:
launchctl kickstart -k gui/$UID/ai.openclaw.gateway

# Tail BOTH the env-gated structured stream AND the always-on
# diagnostics:
tail -F ~/.openclaw/logs/gateway.err.log \
  | grep -E '\[plan-mode/|plan-approval-gate|exit-plan-gate'

Then test the canonical flow:

  1. Enter plan mode (/plan on or agent calls enter_plan_mode)
  2. Spawn a research subagent (sessions_spawn for a long-running task)
  3. Wait for subagent to complete
  4. Agent calls exit_plan_mode with title + plan + archetype fields
  5. Click Approve — agent should continue execution without the iter-2 Bug A stall
  6. Verify markdown plan file written to ~/.openclaw/agents/<id>/plans/plan-YYYY-MM-DD-<slug>.md

Eva added 30 commits April 19, 2026 18:28
…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)
@100yenadmin

Copy link
Copy Markdown
Contributor Author

@codex please re-review the latest push at 5774aa039d — addresses your 7 comments from reviews 4139614141/4139756224/4139936730/4140074845:

  1. bare /plan accept (P1, x2) — fixed parser bug; normalizeLowercaseStringOrEmpty returns string never undefined, so the empty-string case was falling into the unknown-argument error path.
  2. *** Move to: grammar (P1, x2) — replaced moveRe with moveToRe matching the real apply_patch grammar.
  3. Trailing tokens for /plan accept edits (P2) — added maxTokens check.
  4. Trailing tokens for /plan off (P2) — added rejectTrailingTokens helper applied to all no-arg commands.
  5. pendingAgentInjection retry-leak (P1) — hoisted consume OUTSIDE the outer while (true) loop.

13 new regression tests across commands-plan.test.ts (7) and accept-edits-gate.test.ts (6). All tests green. Each inline comment received a terminal reply with commit SHA + test refs.

@chatgpt-codex-connector

Copy link
Copy Markdown
Contributor

Codex Review: Didn't find any major issues. 🎉

ℹ️ 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".

@100yenadmin
100yenadmin requested a review from Copilot April 20, 2026 14:58

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.

Copilot wasn't able to review this pull request because it exceeds the maximum number of lines (20,000). Try reducing the number of changed lines and requesting a review from Copilot again.

@100yenadmin

Copy link
Copy Markdown
Contributor Author

@codex do one final pass for any major issues and trace all the plan mode paths

@100yenadmin
100yenadmin requested a review from Copilot April 20, 2026 15:18

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.

Copilot wasn't able to review this pull request because it exceeds the maximum number of lines (20,000). Try reducing the number of changed lines and requesting a review from Copilot again.

@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: 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".

Comment on lines +414 to +416
planModeCfg?.enabled === true &&
cronSession.sessionEntry.planMode === undefined &&
planModeCfg.autoEnableFor &&

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 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",

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 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 👍 / 👎.

@100yenadmin

Copy link
Copy Markdown
Contributor Author

Stacked for testing purposes given size. Closing to re-divide now that it's achieved 1.0 state.

@100yenadmin

Copy link
Copy Markdown
Contributor Author

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 main is cumulative because cross-repo PR bases can't reference fork branches; once Part N merges to main, Part N+1's diff becomes incremental. The [Plan Mode N/8] title prefix is the merge-order signal.

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 channel: telegram Channel integration: telegram commands Command implementations docs Improvements or additions to documentation extensions: openai gateway Gateway runtime size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants