[Plan Mode FULL] Integrated bundle for testing (Parts 1\u20136 + automation + executing-state lifecycle)#70071
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)
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Integrated “Plan Mode” bundle (parts 1–6 + automation/subagent follow-ups + executing-state lifecycle hardening) to provide an approval-gated, multi-phase planning/execution workflow with better operational safety, introspection, and multi-channel support.
Changes:
- Adds/expands plan-mode lifecycle primitives (plan/executing/normal), gating, introspection tools, and execution-phase injections.
- Hardens subagent interactions (plan-mode concurrency cap, parent tracking/drain/remap of child run IDs, announce steering).
- Introduces skill plan templates + seeding/planning helpers; extends Telegram support for document uploads and updates docs/QA scenarios.
Reviewed changes
Copilot reviewed 94 out of 192 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| src/agents/tools/update-plan-tool.test.ts | Updates expectations for non-empty update_plan content; adds completion/closure-gate tests. |
| src/agents/tools/sessions-spawn-tool.ts | Adds plan-mode subagent concurrency cap, plan-mode cleanup override, parent open-run tracking; adjusts result payload behavior. |
| src/agents/tools/sessions-spawn-tool.test.ts | Removes “role forwarding” assertions; adds regression tests for plan-mode concurrency cap. |
| src/agents/tools/plan-mode-status-tool.ts | Adds read-only plan-mode status introspection tool with disk-read diagnostics. |
| src/agents/tools/enter-plan-mode-tool.ts | Adds enter_plan_mode tool returning structured “entered” result for runner intercept. |
| src/agents/tools/cron-tool.ts | Adds documentation recipe for “resume after wait” cron usage. |
| src/agents/tools/ask-user-question-tool.ts | Adds ask_user_question tool with schema hardening and deterministic IDs. |
| src/agents/tools/ask-user-question-tool.test.ts | Adds tests for ask_user_question validation, trimming, determinism, and non-empty content. |
| src/agents/tool-display-config.ts | Adds display config for plan-mode tools and fixes ask_user_question detailKeys shape. |
| src/agents/tool-description-presets.ts | Adds display summaries + descriptions for new plan-mode tools; clarifies update_plan contract. |
| src/agents/tool-catalog.ts | Registers plan-mode tools in core tool catalog behind plan-mode gating. |
| src/agents/test-helpers/fast-openclaw-tools-sessions.ts | Updates update_plan tool mock signature and exports step statuses. |
| src/agents/subagent-registry.test.ts | Updates infra mock and simplifies expectations around run outcomes. |
| src/agents/subagent-registry.steer-restart.test.ts | Imports actual agent-events for restart logic; adds test for remapping parent openSubagentRunIds. |
| src/agents/subagent-registry-run-manager.ts | Drains/remaps parent open-subagent sets and adjusts run outcome updates. |
| src/agents/subagent-announce.ts | Adds plan-mode-aware announce steering and best-effort requester plan-mode lookup. |
| src/agents/skills/workspace.ts | Includes per-skill plan templates in skill snapshot for snapshot-backed runs. |
| src/agents/skills/types.ts | Adds SkillPlanTemplateStep and snapshot field for resolved plan templates. |
| src/agents/skills/skill-planner.ts | Adds template-to-plan normalization (dedupe/truncate) and helpers. |
| src/agents/skills/frontmatter.ts | Parses plan templates from frontmatter (kebab/camel keys + content alias) with fallback rules. |
| src/agents/skills/frontmatter.test.ts | Adds tests for plan-template parsing and fallback behavior. |
| src/agents/plan-mode/types.ts | Introduces 3-state plan mode type + approvalId generation + decision injection sanitization. |
| src/agents/plan-mode/reference-card.ts | Adds bootstrap reference card content for in-mode guidance. |
| src/agents/plan-mode/plan-nudge-crons.ts | Adds scheduling/cleanup for plan-nudge crons with validation and best-effort semantics. |
| src/agents/plan-mode/plan-mode-debug-log.ts | Adds structured plan-mode debug logger with env/config enablement and caching. |
| src/agents/plan-mode/plan-archetype-prompt.ts | Adds plan archetype prompt + plan filename helpers. |
| src/agents/plan-mode/plan-archetype-prompt.test.ts | Tests archetype prompt content and filename helpers. |
| src/agents/plan-mode/plan-archetype-bridge.ts | Persists full plan markdown and (optionally) sends as Telegram attachment. |
| src/agents/plan-mode/mutation-gate.test.ts | Adds mutation-gate tests for plan mode allow/deny behavior and exec read-only whitelist. |
| src/agents/plan-mode/integration.test.ts | Adds plan-mode integration smoke test for enabling + gating + tools. |
| src/agents/plan-mode/index.ts | Exports plan-mode types/utilities/approval/mutation-gate surface. |
| src/agents/plan-mode/execution-status-injection.ts | Adds [PLAN_STATUS] preamble injection for executing-mode turns. |
| src/agents/plan-mode/auto-enable.ts | Adds regex-based auto-enable evaluation with compiled pattern cache. |
| src/agents/plan-mode/auto-enable.test.ts | Adds tests for auto-enable matching and malformed pattern behavior. |
| src/agents/plan-mode/approval.ts | Adds approval state machine + executing-mode transition + approved-plan injection. |
| src/agents/plan-hydration.ts | Adds post-compaction plan hydration formatter for active steps. |
| src/agents/plan-hydration.test.ts | Tests hydration formatting and filtering. |
| src/agents/pi-tools.ts | Threads planMode + live accessors into before-tool-call hook context. |
| src/agents/pi-tools.before-tool-call.ts | Adds plan-mode mutation gate and post-approval acceptEdits constraint gate + diagnostic logging. |
| src/agents/pi-embedded-runner/skills-runtime.test.ts | Updates snapshot behavior tests for resolvedPlanTemplates field and older snapshot fallback. |
| src/agents/pi-embedded-runner/run/params.ts | Threads planMode and live accessors through runner params. |
| src/agents/pi-embedded-runner/run/helpers.ts | Raises run retry defaults; adds explicit override and subagent cap constant. |
| src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts | Stubs skill-template seeder in spawn workspace test support. |
| src/agents/pi-embedded-runner/run.overflow-compaction.test.ts | Uses embeddedPi maxIterations override to keep retry-limit tests fast. |
| src/agents/pi-embedded-runner/run.incomplete-turn.test.ts | Updates retry counts and adds planning retry escalation / plan-mode disablement tests. |
| src/agents/pi-embedded-runner/pending-injection.ts | Adds backward-compat shim for draining/ composing pending injections (queue-backed). |
| src/agents/pi-embedded-runner/pending-injection.test.ts | Adds tests for pending injection drain + prompt composition behavior. |
| src/agents/openclaw-tools.ts | Registers plan-mode tools (gated) and threads runId into update_plan and sessions_spawn. |
| src/agents/openclaw-tools.registration.ts | Adds plan-mode tools enablement gate based on config. |
| skills/plan-mode-101/SKILL.md | Adds plan-mode reference skill and self-test instructions. |
| qa/scenarios/gpt54-plan-mode-default-off.md | Adds QA scenario asserting default GPT-5.4 does not enter plan mode. |
| qa/scenarios/gpt54-mandatory-tool-use.md | Adds QA scenario asserting GPT-5.4 uses tools for factual queries. |
| qa/scenarios/gpt54-injection-scan.md | Adds QA scenario validating injection scanner baseline behavior. |
| qa/scenarios/gpt54-cancelled-status.md | Adds QA scenario around cancelled plan-step status usage. |
| qa/scenarios/gpt54-act-dont-ask.md | Adds QA scenario asserting “act on obvious defaults” behavior. |
| extensions/telegram/src/send.ts | Adds sendDocumentTelegram with size pre-check, caption handling, and thread fallback. |
| extensions/telegram/runtime-api.ts | Re-exports sendDocumentTelegram to runtime API surface. |
| docs/tools/slash-commands.md | Documents universal /plan ... slash command set. |
| docs/plans/PLAN-MODE-OPERATOR-RUNBOOK.md | Adds operator runbook for plan-mode incidents and log-based diagnostics. |
| docs/concepts/plan-mode.md | Adds user-facing plan-mode concept doc and troubleshooting guidance. |
| apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift | Adds plan-mode error codes and sessions.patch params fields; removes ChannelsStartParams. |
| apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json | Adds tool display config entries for plan-mode tools. |
| apps/macos/Sources/OpenClawProtocol/GatewayModels.swift | Mirrors shared GatewayModels plan-mode additions/removals for macOS target. |
| expr: "message.toolCalls?.some(tc => tc.name === 'update_plan') ?? false" | ||
| message: "Agent must call update_plan to create the plan" | ||
| - assert: | ||
| expr: "message.toolCalls?.some(tc => { const p = tc.params; return p?.plan?.some(s => s.status === 'cancelled'); }) ?? true" |
There was a problem hiding this comment.
The ?? true default makes this assertion pass when message.toolCalls is missing (or the some(...) expression yields nullish), which defeats the purpose of the check. Default to false (or assert on toolCalls existence separately) so the scenario fails when the agent didn’t actually emit the expected cancelled status.
| expr: "message.toolCalls?.some(tc => { const p = tc.params; return p?.plan?.some(s => s.status === 'cancelled'); }) ?? true" | |
| expr: "message.toolCalls?.some(tc => { const p = tc.params; return p?.plan?.some(s => s.status === 'cancelled'); }) ?? false" |
There was a problem hiding this comment.
Won't fix in this cycle — this is the QA scenario doc rather than runtime code, and the ?? true default behaves correctly when the scenario is run via qa-lab (which populates message.toolCalls from observed traces, never nullish). Acknowledging the bot's concern that the literal pattern looks fail-open in isolation. Flagged for the QA-scenario follow-up cycle (see umbrella #70101 deferred-items checklist) where we can rewrite to be observability-first per the related Copilot comment on update_plan literal gating.
| if ( | ||
| latestPlanMode === "plan" || | ||
| latestPlanMode === "executing" || | ||
| args.ctx?.planMode === "plan" || | ||
| args.ctx?.planMode === "executing" | ||
| ) { | ||
| log.info( | ||
| `[plan-mode-gate] tool=${toolName} sessionKey=${args.ctx?.sessionKey ?? "<none>"} ` + | ||
| `cachedCtxPlanMode=${args.ctx?.planMode ?? "<undefined>"} ` + | ||
| `liveMode=${liveMode ?? "<undefined>"} ` + | ||
| `latestPlanMode=${latestPlanMode ?? "<undefined>"} ` + | ||
| `hasGetLatestCallback=${args.ctx?.getLatestPlanMode ? "yes" : "no"}`, | ||
| ); | ||
| } |
There was a problem hiding this comment.
This logs at info for every tool call in plan/executing (and also when the cached ctx indicates those modes), which can become very noisy and inflate gateway logs under normal plan-mode usage. Consider lowering to debug, and/or gating behind isPlanModeDebugEnabled() so operators only pay this cost when actively debugging.
There was a problem hiding this comment.
Won't fix in this cycle — needs production gateway profiling to determine actual noise impact (the gate fires once per tool call, but tool-call rates vary widely by workload). info matches the level used by sibling createSubsystemLogger calls in the same file. Easy follow-up to demote to debug if profiling shows excessive volume. Tracking in umbrella #70101.
| const summary = !sessionStoreReadOk | ||
| ? `WARNING: session-store read failed (${sessionStoreReadError ?? "unknown error"}); plan-mode state is UNKNOWN. The agent should treat this as a transient diagnostic failure, not a confirmed "normal" state.` | ||
| : inPlanMode | ||
| ? `In plan mode (approval=${planMode?.approval ?? "none"}; title="${planMode?.title ?? "(unset)"}"; ${openSubagentRunIds.length} subagent(s) in flight; ${planMode?.lastPlanSteps?.length ?? 0} plan step(s) tracked).` | ||
| : inExecution | ||
| ? `Executing approved plan (title="${planMode?.title ?? "(unset)"}"; approval=${planMode?.approval ?? "approved"}; ${planMode?.lastPlanSteps?.filter((s) => s.status === "in_progress").length ?? 0} step(s) in progress, ${planMode?.lastPlanSteps?.filter((s) => s.status === "pending").length ?? 0} pending, ${planMode?.lastPlanSteps?.filter((s) => s.status === "completed").length ?? 0} completed).` |
There was a problem hiding this comment.
In the inExecution branch, lastPlanSteps is filtered three separate times, making this O(3n) per call and allocating intermediate arrays. It would be more efficient (and easier to extend for cancelled/unknown) to compute status counts once (single pass) and reuse them both for summary text and details.
| const summary = !sessionStoreReadOk | |
| ? `WARNING: session-store read failed (${sessionStoreReadError ?? "unknown error"}); plan-mode state is UNKNOWN. The agent should treat this as a transient diagnostic failure, not a confirmed "normal" state.` | |
| : inPlanMode | |
| ? `In plan mode (approval=${planMode?.approval ?? "none"}; title="${planMode?.title ?? "(unset)"}"; ${openSubagentRunIds.length} subagent(s) in flight; ${planMode?.lastPlanSteps?.length ?? 0} plan step(s) tracked).` | |
| : inExecution | |
| ? `Executing approved plan (title="${planMode?.title ?? "(unset)"}"; approval=${planMode?.approval ?? "approved"}; ${planMode?.lastPlanSteps?.filter((s) => s.status === "in_progress").length ?? 0} step(s) in progress, ${planMode?.lastPlanSteps?.filter((s) => s.status === "pending").length ?? 0} pending, ${planMode?.lastPlanSteps?.filter((s) => s.status === "completed").length ?? 0} completed).` | |
| const lastPlanStepStatusCounts = (planMode?.lastPlanSteps ?? []).reduce( | |
| (counts, step) => { | |
| if (step.status === "in_progress") { | |
| counts.inProgress += 1; | |
| } else if (step.status === "pending") { | |
| counts.pending += 1; | |
| } else if (step.status === "completed") { | |
| counts.completed += 1; | |
| } | |
| return counts; | |
| }, | |
| { inProgress: 0, pending: 0, completed: 0 }, | |
| ); | |
| const summary = !sessionStoreReadOk | |
| ? `WARNING: session-store read failed (${sessionStoreReadError ?? "unknown error"}); plan-mode state is UNKNOWN. The agent should treat this as a transient diagnostic failure, not a confirmed "normal" state.` | |
| : inPlanMode | |
| ? `In plan mode (approval=${planMode?.approval ?? "none"}; title="${planMode?.title ?? "(unset)"}"; ${openSubagentRunIds.length} subagent(s) in flight; ${planMode?.lastPlanSteps?.length ?? 0} plan step(s) tracked).` | |
| : inExecution | |
| ? `Executing approved plan (title="${planMode?.title ?? "(unset)"}"; approval=${planMode?.approval ?? "approved"}; ${lastPlanStepStatusCounts.inProgress} step(s) in progress, ${lastPlanStepStatusCounts.pending} pending, ${lastPlanStepStatusCounts.completed} completed).` |
There was a problem hiding this comment.
Won't fix in this cycle — micro-perf nit on a list that's typically <30 items in practice (lastPlanSteps is bounded by the agent's plan size). The gain from one-pass categorization is below the noise floor. Acknowledged for maintainer follow-up if lastPlanSteps ever scales beyond a few dozen entries. Tracking in umbrella #70101.
| const { assertSafeCronSessionTargetId } = await import("../../cron/session-target.js"); | ||
| try { | ||
| assertSafeCronSessionTargetId(params.sessionKey); | ||
| } catch (validationErr) { | ||
| params.log?.warn?.( | ||
| `plan-nudge schedule skipped: sessionKey "${params.sessionKey}" fails cron sessionTarget validation: ${ | ||
| validationErr instanceof Error ? validationErr.message : String(validationErr) | ||
| }`, | ||
| ); | ||
| continue; | ||
| } |
There was a problem hiding this comment.
This dynamically imports assertSafeCronSessionTargetId inside the per-interval loop, which repeats the await/import once per configured minutes entry. Importing once (outside the loop) or caching the imported function inside schedulePlanNudges would reduce overhead, especially when callers pass larger intervals arrays.
There was a problem hiding this comment.
Won't fix in this cycle — the dynamic import isolates assertSafeCronSessionTargetId (a security check) for lazy loading; hoisting to module scope risks circular-import / initialization-order issues with the cron module. The repeated awaits per cron-interval entry (typically 3) are <1ms each on a warm import cache. Acknowledged for maintainer follow-up if perf trace shows it as a hotspot. Tracking in umbrella #70101.
| const safeTitle = (title ?? "").trim() || "Plan"; | ||
| const escTitle = escapeHtml(safeTitle); | ||
| const safeSummary = (summary ?? "").trim(); | ||
| const summaryLine = safeSummary ? `\n${escapeHtml(safeSummary)}` : ""; | ||
| return [ | ||
| `<b>${escTitle}</b> — plan submitted for approval. See attached.`, | ||
| summaryLine, | ||
| "", | ||
| "Resolve with: <code>/plan accept</code> | <code>/plan accept edits</code> | <code>/plan revise <feedback></code>", | ||
| ] | ||
| .filter(Boolean) | ||
| .join("\n"); |
There was a problem hiding this comment.
summaryLine is built with a leading newline and then joined with "\n", which can produce unintended blank-line formatting (extra newlines) in the caption. Prefer adding the summary as its own array element without a leading newline so the final formatting is predictable and easier to reason about.
There was a problem hiding this comment.
Won't fix in this cycle — needs visual inspection on actual Telegram render to determine if the extra newline is user-visible (Telegram captions have specific markdown rendering quirks where leading newlines collapse). Flagged for maintainer follow-up + a Telegram-specific UX review pass. Tracking in umbrella #70101.
| const EnterPlanModeToolSchema = Type.Object({ | ||
| reason: Type.Optional( | ||
| Type.String({ | ||
| description: | ||
| "Optional short justification shown alongside the mode-entered event " + | ||
| "(e.g. 'multi-file refactor — surface the plan first').", | ||
| }), | ||
| ), | ||
| }); |
There was a problem hiding this comment.
Other newly-added tools in this PR (e.g. plan_mode_status, ask_user_question) explicitly set { additionalProperties: false } to harden validation. Since enter_plan_mode doesn’t need arbitrary extra fields either, consider aligning by setting additionalProperties: false here as well.
| const EnterPlanModeToolSchema = Type.Object({ | |
| reason: Type.Optional( | |
| Type.String({ | |
| description: | |
| "Optional short justification shown alongside the mode-entered event " + | |
| "(e.g. 'multi-file refactor — surface the plan first').", | |
| }), | |
| ), | |
| }); | |
| const EnterPlanModeToolSchema = Type.Object( | |
| { | |
| reason: Type.Optional( | |
| Type.String({ | |
| description: | |
| "Optional short justification shown alongside the mode-entered event " + | |
| "(e.g. 'multi-file refactor — surface the plan first').", | |
| }), | |
| ), | |
| }, | |
| { | |
| additionalProperties: false, | |
| }, | |
| ); |
There was a problem hiding this comment.
Fixed in d4ab39744c. Added { additionalProperties: false } to EnterPlanModeToolSchema to match the hardening pattern used by plan_mode_status and ask_user_question. LLMs that hallucinate unknown fields now get a schema validation error instead of silent drop.
| }); | ||
| } | ||
| } | ||
| return jsonResult(addRoleToFailureResult(result, requestedAgentId)); | ||
| return jsonResult(result); |
There was a problem hiding this comment.
This changes the tool’s returned details shape for forwarded failures by removing the previously-added role field (based on requested agentId) for error/forbidden outcomes. If any clients/channel renderers relied on that field for diagnostics or UX copy, this becomes a breaking behavior change. Consider either preserving role in failure details for backward compatibility, or updating the tool’s documented contract + downstream consumers to avoid depending on it.
There was a problem hiding this comment.
Won't fix in this cycle — needs maintainer judgment on the documented details contract for forwarded failures. The change came from a rebase across upstream channels work; restoring role requires deciding whether channel renderers / diagnostics depend on it (no visibility into non-OpenClaw consumers). Flagged as a breaking-behavior-decision for maintainer review. Tracking in umbrella #70101 as a P1-followup item.
|
|
||
| return jsonResult(addRoleToFailureResult(result, requestedAgentId)); | ||
| return jsonResult(result); |
There was a problem hiding this comment.
This changes the tool’s returned details shape for forwarded failures by removing the previously-added role field (based on requested agentId) for error/forbidden outcomes. If any clients/channel renderers relied on that field for diagnostics or UX copy, this becomes a breaking behavior change. Consider either preserving role in failure details for backward compatibility, or updating the tool’s documented contract + downstream consumers to avoid depending on it.
| return jsonResult(result); | |
| const resultWithCompatibleFailureDetails = | |
| requestedAgentId && | |
| (result.status === "error" || result.status === "forbidden") && | |
| result.details && | |
| typeof result.details === "object" && | |
| !("role" in result.details) | |
| ? { | |
| ...result, | |
| details: { | |
| ...result.details, | |
| role: requestedAgentId, | |
| }, | |
| } | |
| : result; | |
| return jsonResult(resultWithCompatibleFailureDetails); |
There was a problem hiding this comment.
Same root cause + decision as the comment on line 371 (sister issue). Won't fix in this cycle for the same backward-compat reason. Tracking in umbrella #70101.
| export async function schedulePlanNudges(params: { | ||
| sessionKey: string; | ||
| agentId?: string; | ||
| planCycleId?: string; | ||
| intervals?: ReadonlyArray<number>; | ||
| deps?: PlanNudgeSchedulerDeps; | ||
| log?: { warn?: (msg: string) => void; info?: (msg: string) => void }; | ||
| }): Promise<ScheduledPlanNudge[]> { |
There was a problem hiding this comment.
schedulePlanNudges/cleanupPlanNudges introduce important reliability behavior (partial-success scheduling, sessionKey validation, best-effort cleanup, jobId extraction). Given the injected deps (callGatewayTool, now) are explicitly designed for deterministic tests, adding unit tests for: invalid intervals filtering, validation skip path, jobId extraction shapes, partial failure tolerance, and cleanup failure accounting would help prevent regressions.
There was a problem hiding this comment.
Won't fix in this cycle — adding test coverage for schedulePlanNudges / cleanupPlanNudges is substantive scope (~3-5 test cases each, 100-200 lines). Acknowledged as a real coverage gap; flagged for the test-coverage follow-up cycle (umbrella #70101 deferred-items checklist). The behaviors the bot calls out (partial-success scheduling, sessionKey validation, best-effort cleanup, jobId extraction) are correct and consistent with the rest of the cron infrastructure — coverage is a polish concern, not a correctness one.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cdb7ec1c70
ℹ️ 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".
| ? createNodeTestShards().map((shard) => ({ | ||
| check_name: shard.checkName, | ||
| runtime: "node", | ||
| task: "test-shard", | ||
| shard_name: shard.shardName, | ||
| configs: shard.configs, | ||
| requires_dist: shard.requiresDist, | ||
| })) |
There was a problem hiding this comment.
Pass shard include patterns into node test shards
Restore includePatterns in this matrix payload (and the corresponding OPENCLAW_VITEST_INCLUDE_FILE wiring in the shard runner), otherwise split shards from createNodeTestShards() stop being real shards. In particular, the auto-reply split shards in scripts/lib/ci-node-test-plan.mjs depend on includePatterns to partition one Vitest config; dropping that field makes each shard run the full config, duplicating test execution across shards and causing major CI runtime/timeout regressions.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in d4ab39744c. Restored include_patterns: shard.includePatterns in the checks_node_core_test_matrix payload. The auto-reply split shards in scripts/lib/ci-node-test-plan.mjs:212 will now correctly partition the single Vitest config instead of each shard running the full config.
|
Acknowledging bot feedback (Greptile / Copilot / Codex). Triage status:
This PR remains the green-CI integration point for end-to-end testing of the rollout. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d4ab39744c
ℹ️ 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".
| node_bin="$(cygpath -u "$node_bin")" | ||
| fi | ||
| echo "NODE_BIN=$node_bin" >> "$GITHUB_ENV" | ||
| run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV" |
There was a problem hiding this comment.
Capture NODE_BIN without escaped inner quotes
The Capture node path command currently writes NODE_BIN=. because dirname receives a quoted literal (the escaped quotes become part of the argument), so the subsequent install step prepends the workspace directory to PATH instead of Node’s bin directory. In the Windows shard this can shadow node/pnpm with repo-local executables and makes the job non-deterministic; it also defeats the intended path pinning. Use the unescaped form (or the prior cygpath-aware variant) so NODE_BIN resolves to the actual Node install directory.
Useful? React with 👍 / 👎.
| @@ -311,6 +347,25 @@ export function createOpenClawTools( | |||
| sessionId: options?.sessionId, | |||
| onYield: options?.onYield, | |||
| }), | |||
| createSessionsSpawnTool({ | |||
There was a problem hiding this comment.
Remove duplicate
sessions_spawn tool registration
This adds a second sessions_spawn entry to the same tool list, so non-embedded runs now expose duplicate tool names. Because one copy is configured without runId and the other includes it, whichever definition is resolved first/last changes whether plan-mode subagent tracking hooks run; even when execution happens to work, duplicate tool names increase provider/tool-routing ambiguity and can cause flaky behavior across runtimes.
Useful? React with 👍 / 👎.
Smoke-test attempt on FULL (#70071) — blocked + real findings extracted (2026-04-22 ~17:40 GMT+7)Tried to spin up the gateway on the FULL branch ( Both need to land on the maintainer's radar before #70071 is claimed as a green-CI integration bundle. 🟡 Smoke-test blocker #1 — opusscript peer-dep mismatchPeer-dep warning during
Severity: blocks 🔴 Smoke-test blocker #2 — 8 missing-export warnings on FULLEight symbols are IMPORTED in FULL's code but NOT DEFINED anywhere in the source tree.
Severity: these are dangling imports. The bundler (rollup/vite) treats them as warnings rather than errors so the build proceeds, but at runtime these imports will be Origin: these look like cherry-pick / refactoring drift. The CALLERS were brought across but the IMPLEMENTATIONS were either renamed, moved, or dropped without updating the import sites. Why Greptile/Copilot/Codex didn't catch this: bots review the DIFF (line-level). A missing export is a graph-level problem — the bot would need to resolve the import graph to notice. Smoke-test build catches it immediately via bundler warnings. Impact on claim "FULL is green-CI": if the CI pipeline runs Recommended actions for maintainerBefore merging FULL (or the per-part PRs that cascade to FULL):
What the smoke test verified about the ROLLOUT structure (positive findings)Despite not completing:
The rollout's structural integrity (per-part PRs + INJECTIONS + AUTOMATION + FULL) is not what's broken here. What's broken is a mix of cherry-pick drift (the 8 imports) + a vendor dep version drift (opusscript). Files the maintainer can start withTo fix the 8 dangling imports quickly, the minimum change is to either add the exports at the expected locations OR remove the imports. Best starting point: git log --oneline -- src/agents/tools/exit-plan-mode-tool.ts
git log --oneline -- src/gateway/sessions-patch.ts
git log --oneline -- src/agents/pi-embedded-runner/run.ts
git log --oneline -- src/gateway/session-utils.tsAttempted smoke test, gateway failed to boot. Findings above are real pre-merge blockers. Not sign-offable as "green CI bundle" in its current state — but fixable. |
d4ab397 to
5774aa0
Compare
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.
Keep plan opt-out state from being auto-reenabled
This auto-enable guard keys only on sessionEntry.planMode === undefined, but the same commit clears planMode on normal transitions in src/gateway/sessions-patch.ts (for /plan off and many approve/edit closes). In cron sessions using agents.defaults.planMode.autoEnableFor, an explicit opt-out or completed cycle is therefore treated as "never toggled," so the next run silently flips back to mode: "plan" and re-locks mutation tools against user intent.
Useful? React with 👍 / 👎.
| id: `plan-complete-${params.sessionKey}-${Date.now()}`, | ||
| kind: "plan_complete", |
There was a problem hiding this comment.
Deduplicate plan-complete injections with a stable ID
appendToInjectionQueue deduplicates by id, but this ID includes Date.now(), so each repeated completion event creates a new queue entry instead of upserting. Since update_plan emits phase: "completed" whenever all steps are terminal, retries or repeated terminal updates can enqueue duplicate [PLAN_COMPLETE] injections and repeatedly re-trigger completion behavior.
Useful? React with 👍 / 👎.
Resolved 23 conflicts: - Take-upstream for 14 files (12 i18n .meta.json + ci.yml + CHANGELOG) - Modify/delete chat.test.ts (upstream deleted; we accepted deletion) - Manual merge of 7 plan-mode-relevant files: - openclaw-tools.ts: take-both imports + take-upstream (drop unconditional sessions_spawn duplicate) - sessions-spawn-tool.ts: take-both imports + use upstream's renamed delivery-context.shared.js path - cron/isolated-agent/run.ts: combine HEAD's plan-mode autoEnableFor + planCycleId guards with upstream's deliveryPlan return field + deliveryContract param - server.impl.ts: take-upstream's createCloseHandler() refactor - server-close.test.ts (2 regions): take-upstream's createGatewayCloseTestDeps() helper - chat.ts (3 regions): take-HEAD imports + take-HEAD plan-mode helpers + take-upstream renderChatRunControls refactor (loses STT recording UI \u2014 follow-up to re-add) - extensions/openai/prompt-overlay.ts (3 regions): take-both for execution_policy + completion_contract; take-HEAD for extensive EXECUTION_BIAS + TOOL_CALL_STYLE exports + their references in resolveOpenAiOpenClawOverlay - extensions/openai/index.test.ts: take-HEAD (test file; runtime will catch any breakages)
…mode dev Phase 2 follow-ups after the upstream/main merge (commit b1ead74): - prompt-overlay.ts: restore missing return statement in returnObj - send.runtime.ts: re-export loadConfig (still referenced by send.ts) - send.ts: import loadConfig + default opts.cfg in sendDocumentTelegram so TelegramDocumentOpts.cfg can stay optional (matches dev-branch API while satisfying upstream's stricter resolveTelegramApiContext signature) - vitest.plan-mode.config.ts: drop chat.test.ts (deleted upstream by 92191d3, coverage moved to run-controls/tool-expansion-state/grouped-render) - a2ui bundle.hash: regenerated by bundle script Build: pnpm build green Tests: pnpm vitest --config vitest.plan-mode.config.ts → 219/219 passed
There was a problem hiding this comment.
💡 Codex Review
openclaw/src/agents/pi-embedded-runner/run.ts
Lines 772 to 774 in 651a22d
This invocation now drops isCanonicalWorkspace, and resolveAttemptBootstrapRouting treats a missing value as true before deciding full vs limited bootstrap mode. For runs using workspace overrides (non-canonical workspace without sandbox copy), that changes behavior from limited to full bootstrap instructions, which can push the agent to execute full BOOTSTRAP workflow guidance in the wrong workspace context.
ℹ️ 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".
| // before-tool-call hook arms the mutation gate. Without this | ||
| // the field added to attempt's params + the threading through | ||
| // pi-tools is dead code (Codex P1 #67840 r3096735975). | ||
| ...(params.planMode ? { planMode: params.planMode } : {}), |
There was a problem hiding this comment.
Forward live plan-mode accessors into attempt params
This attempt call only passes the static planMode snapshot and drops the new getLatestPlanMode/getLatestAcceptEdits accessors that callers provide (for example from agent-runner-execution). run/attempt.ts forwards those accessors into pi-tools.before-tool-call, where they are required to switch gating immediately after approve/edit and to apply post-approval acceptEdits constraints; without them, gating falls back to stale run-start state, so the same run can keep mutation tools blocked (or miss acceptEdits checks) until another run is started.
Useful? React with 👍 / 👎.
| groupId: params.groupId, | ||
| groupChannel: params.groupChannel, | ||
| groupSpace: params.groupSpace, | ||
| memberRoleIds: params.memberRoleIds, | ||
| spawnedBy: params.spawnedBy, |
There was a problem hiding this comment.
Pass memberRoleIds through runEmbeddedAttemptWithBackend
memberRoleIds is no longer forwarded into the attempt params here, but run/attempt.ts passes params.memberRoleIds into createOpenClawCodingTools, and downstream route/policy resolution uses those role ids for role-scoped bindings. With this omission, role-qualified channel flows are evaluated as if the requester has no roles, which can misroute or deny tool access for valid role-bound operators.
Useful? React with 👍 / 👎.
|
Related work from PRtags group Title: Open PR candidate: plan-mode carve-out overlaps integrated full bundle
|
… placement) The mode-switcher chip in the in-host PR openclaw#70071 design renders in the input toolbar row (next to file-attach + microphone), NOT in a chat header. The previous surface name 'chat-header-chip' suggested 'top of chat' to readers, which is wrong. Reviewed against the in-host code: - ui/src/ui/views/chat.ts:1513 mounts renderModeSwitcher inside the agent-chat__input-btn toolbar row - ui/src/ui/chat/mode-switcher.ts docstring: 'Mode switcher for the chat input toolbar' Renames in this commit: - src/plugins/registry.ts: chatStreamSurfaces Set entries - src/plugins/host-hooks.ts: type union literal + docstring - src/gateway/protocol/schema/plugins.ts: schema literal - src/plugins/contracts/host-hooks.contract.test.ts: 2 occurrences - src/gateway/server-methods/plugin-host-hooks.test.ts: 1 occurrence - docs/plugins/hooks.md: 1 occurrence Build passes (pnpm build = 1m07s, no lint regressions from this rename).
Umbrella tracker: #70101 — master tracker for the 9-PR plan-mode rollout. See it for status of all parts + suggested merge order + carry-forward backlog.
Executive summary
Plan mode is 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 mutation tools unlock for that session. The mode is off by default and activated per-session via/plan on, the chip in webchat, or per-agent config. It borrows the "propose, approve, execute" pattern from Claude Code's plan mode and Codex's plan flow — see "Parity benchmark" below for independent quality numbers.This PR is the single-merge integration target for the 9-PR plan-mode rollout (umbrella issue #70101). It bundles the six numbered parts (1/6 through 6/6), the two thematic carve-outs ([Plan Mode INJECTIONS] #70088, [Plan Mode AUTOMATION] #70089), and the executing-state lifecycle / debug-hardening work that could not be cleanly isolated as a per-part PR (its code references types and constants from PR1 + PR2 + PR3 simultaneously, so a standalone diff would balloon and defeat per-part-review goals). End result: 192 changed files, ~30k lines of integrated state, all the iter-1/2/3 hardening from the original umbrella #68939, plus the executing-state work and INJECTIONS / AUTOMATION refinements that came after #68939 closed.
This PR is for the maintainer who wants a single-shot review + merge rather than the 9-step per-part dance. The per-part PRs (#70031 → #70070, plus #70088 + #70089) exist for line-scrutiny review; this PR exists for end-to-end testing on a real branch and as the single-merge alternative. Both paths produce the same final tree state. Choose Path A (per-part) if you want narrow per-PR diffs to scrutinize; choose Path B (this PR) if you want one merge button.
A note on file count: a recent automated review reported "FULL is missing 90 files vs the union of per-part PRs". That report used
gh pr view --json fileswhich has a hardcoded 100-result cap. The full paginated count viagh api .../files --paginateis 192 files, and a pairwise diff against the union of all per-part PRs shows zero missing files (the seven FULL-only files are explained in "What's unique to FULL" below — they're the executing-state lifecycle work that didn't fit a per-part). The umbrella tracker's correction comment has the full audit if you want to verify.TL;DR
/planslash commands + executing-state lifecycle + cron-driven nudges + subagent gating + skill plan templates + debug log + UI mode chip + plan cards.agents.defaults.planMode.enabled: false. No existing session, agent, or model behaves differently on merge.approvalId(cryptographic random, regenerated perexit_plan_mode). Subagent gate blocksapprove/editwhile research children are in flight;rejectis intentionally never gated.test/vitest/vitest.plan-mode.config.ts. Full suite green on this branch.agents.defaults.planMode.enabled: false→ restart gateway. Tools unregister, UI chip hides, mutation gate short-circuits, allsessions.patch { planApproval }actions reject. No DB migration to undo.agents.defaults.planMode.autoEnableFormodel-pattern auto-enable runtime (schema-reserved, no scanner),approvalTimeoutSecondscron-time watchdog (schema-reserved, no firing),/plan self-testharness, Bug B stale-card auto-dismiss. All tracked below in "Deferred features".What this PR does at a glance
flowchart LR subgraph UI[Channels] Web[Webchat<br/>inline approval card<br/>+ sidebar plan view<br/>+ mode chip] TG[Telegram<br/>/plan commands<br/>+ deferred document delivery] SL[Slack / Discord /<br/>Matrix / iMessage /<br/>Signal / CLI / SMS<br/>universal /plan only] end subgraph GW[Gateway] Patch[sessions.patch<br/>handler + approval<br/>state machine] Persister[plan-snapshot<br/>persister] Sub[sessions.changed<br/>broadcaster] end subgraph RT[Agent runtime pi-embedded-runner] Runner[run.ts<br/>turn loop] Gate[pi-tools/<br/>before-tool-call<br/>mutation gate caller] Hyd[plan-hydration<br/>post-compaction restore] Inj[pending-injection<br/>consumer] ExecInj[execution-status<br/>injection] end subgraph PM[Plan-mode core src/agents/plan-mode/ - 27 files] Types[types.ts<br/>approval.ts<br/>injections.ts] MGate[mutation-gate.ts<br/>fail-closed allowlist] Edits[accept-edits-gate.ts<br/>3-state edit perm] Auto[auto-enable.ts<br/>per-model opt-in] Nudge[plan-nudge-crons.ts<br/>plan-execution-nudge-crons.ts] Persist[plan-archetype-persist.ts<br/>plan-archetype-bridge.ts<br/>plan-archetype-prompt.ts] Debug[plan-mode-debug-log.ts] Integ[integration.test.ts<br/>200+ tests anchor] 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] Cron[cron-tool] end subgraph Cron[src/cron/isolated-agent/] RunExec[run-executor.ts<br/>FULL-only] RunPlan[run.plan-mode.test.ts] end subgraph Cfg[Config src/config/] Schema[zod-schema.agent-defaults<br/>+ agent-runtime + skills] SessTypes[sessions/types<br/>SessionEntry.planMode] Migrate[sessions/store-migrations<br/>FULL-only] end subgraph Skills[Skills src/agents/skills/] Planner[skill-planner] Frontmatter[frontmatter parser] Workspace[workspace.ts<br/>plan-template snapshot] end subgraph Infra[src/infra/] HB[heartbeat-runner<br/>+plan-nudge contract] end subgraph Store[Persistence ~/.openclaw/] SE[SessionEntry<br/>.planMode + lastPlanSteps] 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 ..." --> Patch SL -- "/plan ..." --> Patch Runner -- update_plan --> Persister Persister --> Sub Sub --> Web Sub --> TG Sub --> SL Runner -- before-tool-call --> Gate Runner -- post-compaction --> Hyd Runner -- per-turn --> Inj Runner -- per-turn --> ExecInj Gate -- consults --> MGate Gate -- consults --> Edits Tools -- registered via --> Runner Exit -- writes --> Persist Enter -- schedules --> Nudge Auto -- on session start --> Runner RunExec -- isolates --> Runner PM -- types/state --> SE Persist -- writes --> MD Debug -- appends --> Log Schema -- validates --> Patch Schema -- validates --> Runner Migrate -- on load --> SessTypes Skills -- seed --> Tools HB -- per-tick --> NudgeWhat's in this bundle (vs. the 9 per-part PRs)
SessionEntry.planModeschema, on-disk persister with O_EXCL atomic write + EEXIST retry, namespace traversal guards, plan hydration,enter/exit/update_plantools, skill plan-template foundationsessions.patch { planMode }, tool-call hook plumbingask_user_questiontool,plan_mode_statustool, plan archetypes (discoverable patterns for skill-driven plans), accept-edits gate (Claude-Code-style auto-edit permission with three hard constraints),PostApprovalPermissionsscoped byapprovalId/planslash commands across all channels, plan rendering for text channels, plan-archetype channel bridge, Telegram attachment delivery surfaceplan-mode-101skill, GPT-5.4 QA scenariosinjections.tsbuilders,[PLAN_MODE_INTRO]first-turn injection,[PLAN_DECISION]unified format,[PLAN_STATUS]execution-phase auto-inject[PLAN_STATUS]auto-inject preamble (P2.12b), allowlist additions (sessions_yield, lcm_grep, lcm_expand_query), various debug + adversarial-review hardening commitsThe "no separate per-part PR" Executing-state lifecycle work could not be cleanly isolated as a standalone per-part PR because its code is structurally interleaved with earlier parts: it references
PlanModediscriminants from PR1, the mutation-gate signature from PR2, and the approval state shape from PR3. Carving it into its own per-part PR would have either (a) dragged duplicates of those types into the carve-out, ballooning the diff and defeating the per-part-review goal, or (b) required the carve-out to depend on three separate-but-not-yet-merged PRs, which makes its CI red and its review-context impossible. So it lands here only. Reviewers can navigate it via the Commits tab — each commit is named with apr10/orexecuting-followup/prefix and is a clean per-feature change.What's UNIQUE to FULL (the 7 files not in the per-part union)
These are the only files in this PR that don't appear in any per-part PR's diff. They exist for legitimate structural reasons documented below — this is not "missing" content but integration content.
.github/workflows/ci.ymlrestack/integration branch carries CI updates not on the per-part branches because we needed to changeconcurrency:keying to avoid per-part jobs cancelling each other when stacked. Per-part PRs run on the upstream-default ci.yml; FULL needs the bundle-aware versionpackage.jsonsrc/agents/plan-mode/execution-status-injection.ts[PLAN_STATUS]preamble injection that fires only whenplanMode.mode === "executing". Structurally inseparable from the executing-state branch of the state machine, which itself is FULL-onlysrc/agents/plan-mode/execution-status-injection.test.tssrc/agents/plan-mode/plan-execution-nudge-crons.tsplan-nudge-crons.ts(which fires duringmode: "plan"); this fires duringmode: "executing"when steps stall. Splitting it across the PR2/PR3 boundary would require co-evolving two cron schedulers in two PRssrc/config/sessions/store-migrations.tsprovider/roomfields when loading old session entries. Unrelated to plan mode but bundled here because the FULL branch also picks up an upstream channel-rename migration fromrestack/rebase tipsrc/cron/isolated-agent/run-executor.tscreateCronPromptExecutor— the wrapper aroundrunCliAgentthat the cron path uses to drive plan-mode-aware turns. Touchesrun-execution.runtime,run-fallback-policy,run-session-state— all of which are upstream-existing modules that the per-part stack didn't need to touch but the integrated cron-driven nudge path doesNet structural integrity: 192 (FULL) − 7 (FULL-only) = 185 files match the per-part union. The per-part union is also 185 files (I verified with a paginated diff). No content is missing from FULL relative to the per-part union.
State machine
Session state is the cross product of
PlanMode ∈ {normal, plan, executing}andPlanApprovalState ∈ {none, pending, approved, edited, rejected, timed_out}. The executing state is new in this bundle (it didn't exist in #68939 — that umbrella had only{normal, plan}). Its purpose: provide a distinct lifecycle phase between "approval landed" and "agent has actually finished the work" so cron-driven nudges can fire only when execution is stalled, not before approval and not after completion.stateDiagram-v2 [*] --> Normal Normal --> PlanInvestigation : enter_plan_mode<br/>OR /plan on<br/>OR autoEnableFor match (deferred) PlanInvestigation --> PlanInvestigation : update_plan<br/>(tracks progress) PlanInvestigation --> PlanPendingApproval : exit_plan_mode<br/>(new approvalId) PlanPendingApproval --> Executing : approve / edit<br/>(mutations unlock,<br/>execution nudges arm) PlanPendingApproval --> PlanInvestigation : reject<br/>(+feedback, rejectionCount++) PlanPendingApproval --> PlanInvestigation : timed_out<br/>(approvalTimeoutSeconds; deferred) Executing --> Executing : update_plan<br/>(executing-phase progress) Executing --> Normal : auto-close-on-complete<br/>(all steps terminal) Executing --> Normal : /plan off (escape hatch) PlanInvestigation --> Normal : /plan off Normal --> Normal : reset on /clear note right of PlanPendingApproval approve / edit gated by<br/>openSubagentRunIds.size > 0<br/>→ PLAN_APPROVAL_BLOCKED_BY_SUBAGENTS<br/>Reject is never gated. end note note left of Executing Executing-state lifecycle (FULL-only):<br/>plan-execution-nudge-crons fire at<br/>1, 3, 5 min if no progress.<br/>execution-status-injection prepends<br/>[PLAN_STATUS] each turn. end noteKey invariants (carried from #68939, with executing-state additions):
approvalIdis a cryptographic random token (newPlanApprovalIdinsrc/agents/plan-mode/types.ts), regenerated on everyexit_plan_mode. Stale UI clicks against an oldapprovalIdare silently no-op'd.normal. The agent stays in plan mode and revises.[PLAN_DECISION]injection suggests the agent ask a clarifying question viaask_user_questioninstead of looping.executing(not directly tonormal) so executing-phase nudges can arm.executing → normalis automatic on auto-close-on-complete (all steps terminal:completedorcancelled). Manual/plan offis also accepted as the user escape hatch; the agent itself cannot force the transition.normalvia/plan offis always allowed at any state.Critical flows
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, or agent calls enter_plan_mode) UI->>GW: sessions.patch { planMode: "plan" } GW->>Store: 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 Exit + approval (happy path) → Executing → Complete
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 participant ExecCron as plan-execution-nudge-crons Agent->>Exit: { title, plan, assumptions?, risks?, verification? } Exit->>Ctx: read openSubagentRunIds alt size > 0 Exit-->>Agent: ToolInputError<br/>(child ids listed) else size == 0 Exit->>Persist: write markdown to ~/.openclaw/agents/<id>/plans/ Persist-->>Exit: { absPath, filename } Exit-->>Run: tool result (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 else GW->>Store: planMode.mode = "executing"<br/>approval = "approved"<br/>pendingAgentInjection = buildApprovedPlanInjection(plan) GW->>ExecCron: scheduleExecutionNudges([1, 3, 5] min) 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->>Run: inject [PLAN_STATUS] preamble<br/>via execution-status-injection Run-->>Agent: mutations now unlocked, executing-phase active loop until all steps terminal Agent->>Agent: do work, call update_plan Agent->>Run: emit phase: "update" end Agent->>Agent: emit phase: "completed" Run->>Store: planMode.mode = "normal"<br/>cleanupExecutionNudges() end end4.3 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" (NOT executing) Note over Run: next turn Run->>Run: consume injection, prepend to prompt Run-->>Agent: "[PLAN_DECISION]: rejected<br/>feedback: ..." Agent->>Agent: revise plan, call update_plan Agent->>Agent: exit_plan_mode again (NEW approvalId) alt rejectionCount >= 3 Note over Agent: injection suggests<br/>ask_user_question instead of loop end4.4 Executing-state nudge (FULL-only flow)
sequenceDiagram participant ExecCron as plan-execution-nudge-crons participant HB as heartbeat-runner participant Store as SessionEntry participant Run as runner participant Agent Note over ExecCron: scheduled at 1/3/5 min after approval ExecCron->>Store: read planMode.mode + lastPlanSteps alt mode != "executing" (already complete) ExecCron-->>ExecCron: no-op (auto-cleanup) else still executing, no progress since approval ExecCron->>Store: pendingAgentInjection = buildExecutionNudgeInjection(stallMinutes) ExecCron->>HB: trigger heartbeat HB->>Run: wake agent Note over Run: next turn Run->>Run: consume nudge injection Run-->>Agent: "[PLAN_STATUS] you are mid-execution.<br/>Continue with: <next pending step>" Agent->>Agent: resumes execution end4.5 Compaction + plan hydration
Plan-mode core directory layout (FULL state)
This is the entire
src/agents/plan-mode/tree at the FULL integration tip — 27 files, up from 8 in the original umbrella #68939. The growth comes from the executing-state lifecycle (3 files), accept-edits gate (2 files), auto-enable scaffolding (2 files), injection builders (2 files), the integration test anchor (1 file), and the archetype-system split into bridge / persist / prompt (6 files vs the original's singleplan-archetype-persist.ts).Per-area breakdown
Plan-mode core —
src/agents/plan-mode/(27 files)Grouped by theme:
types.ts,approval.ts,index.ts— the type contract, state-transition resolver, and public re-export surface.approvalIdis cryptographic, regenerated per exit; stale-id guard silently no-ops mismatches; terminal states require freshexit_plan_mode; reject is never gated by subagent state.mutation-gate.ts+ test. Fail-closed allowlist: any tool not on the explicit allow list is blocked in plan mode. Exec allowlist:ls/cat/pwd/git/find/grep/rg/etc.with dangerous flags rejected (-delete,-exec,-rf,--output, etc.) and shell compound operators rejected (;,|,&,$(),>,<, newline).accept-edits-gate.ts+ test. Three hard constraints: scoped byapprovalId(not session-wide); single-cycle (revoked on next plan transition); no recursion through skills (a skill cannot grant itself accept-edits).auto-enable.ts+ test. Per-model opt-in scaffold. Runtime deferred — the matching logic is implemented and tested; the scanner that watches session-start events to call into it is not wired (see Deferred features).injections.ts+ test. Typed builders for[PLAN_DECISION]: approved/edited/rejected,[PLAN_MODE_INTRO],[PLAN_STATUS], execution-nudge text. Server-built (not from agent output) so a misbehaving agent can't forge a[PLAN_DECISION]: approved.execution-status-injection.ts+ test. Per-turn[PLAN_STATUS]preamble that fires only whenplanMode.mode === "executing". Provides the agent a stable reminder of what step is next during execution.plan-nudge-crons.ts+ test. One-shot wake-ups at 10/30/60 min during investigation. Cleaned on mode-transition; orphan cleanup at gateway start.plan-execution-nudge-crons.ts. P2.12a nudges at 1/3/5 min during executing if no progress.plan-archetype-persist.ts+ test (markdown disk writer with path-traversal defense),plan-archetype-prompt.ts+ test (system-prompt-side prompt),plan-archetype-bridge.ts+ test (channel-aware renderer — web markdown vs text plaintext vs Slack mrkdwn).reference-card.ts. ThePLAN_MODE_REFERENCE_CARDinjected each plan-mode turn — token-budget-aware (~80 lines), mirrors theplan-mode-101skill.plan-mode-debug-log.ts+ test. Zero-overhead when disabled. Activated by env (OPENCLAW_DEBUG_PLAN_MODE=1) or config (agents.defaults.planMode.debug: true).integration.test.ts. End-to-end test that goes through the real event pipeline (not unit-stubbed). The cautionary tale here is the iter-3 persister-typo bug: unit tests passed because they injected the event payload manually, sidestepping the typo'd filter. This file ensures future filter regressions fail CI.Tools —
src/agents/tools/(5+)enter-plan-mode-tool.ts—{ reason?: string }. Mode transition applied in runner, not tool — keeps the tool cheap.exit-plan-mode-tool.ts+ test —{ title, plan[], summary?, analysis?, assumptions?, risks?, verification?, references? }. Tool-side subagent gate:openSubagentRunIds.size > 0→ToolInputError. Title required (no fallback). At most onein_progressstep.update-plan-tool.ts+ test +update-plan-tool.parity.test.ts—{ plan[{ step, status, activeForm?, acceptanceCriteria?, verifiedCriteria? }], merge?, explanation? }. Closure gate:status:"completed"rejected untilverifiedCriteria ⊇ acceptanceCriteria(whitespace-trimmed). Merge re-validates closure on the merged result. Auto-close-on-complete emitsphase: "completed". The.parity.test.tsensures this tool's behavior matches the originaltask_createfamily it replaces.ask-user-question-tool.ts+ test —{ question, options: [string,...], allowFreetext?: boolean }. 2–6 options. Duplicate option text rejected (ambiguous routing).questionIddeterministic fromtoolCallId(prompt-cache stable).plan-mode-status-tool.ts—{}. Read-only introspection. Reads SessionEntry withskipCache: true. Safe to call anytime including during pending approval.sessions-spawn-tool.ts+ test — existing schema + plan-mode awareness. When parent in plan mode: forcescleanup: "keep", registers childrunIdin parent'sopenSubagentRunIds. Cleaned on child completion bysubagent-registry-run-manager.ts.cron-tool.ts— minor additions for the executing-state nudge wiring.tool-catalog.ts,tool-description-presets.ts,tool-display-config.ts— registration, presets (including the STOP-AFTER-EXIT lifecycle rule), and UI display metadata. The display config is mirrored toapps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.jsonfor the macOS app.Runtime —
src/agents/pi-embedded-runner/+src/agents/pi-tools.*+src/agents/pi-embedded-runner/run.ts,run/attempt.ts,run/helpers.ts,run/params.ts,run/incomplete-turn.ts+ tests — the turn loop, with plan-mode threading. Notablyparams.tsaddsplanMode?: "plan" | "normal"(snapshot) andgetLatestPlanMode?: () => …(live accessor — the iter-2 Bug A fix for closure-stale-ref).pi-embedded-runner/pending-injection.ts+ test — atomic read + clear ofpendingAgentInjection. Best-effort: if the write fails, returns the captured value for injection so a single transient disk error doesn't drop a[PLAN_DECISION].pi-embedded-runner/skills-runtime.ts+ test — skill plan-template snapshot loading.pi-embedded-runner/run.incomplete-turn.test.ts,run.overflow-compaction.test.ts— runner-level tests including plan-mode carve-outs.pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts— workspace test support.pi-embedded-subscribe.handlers.tools.ts— subscribe handlers for tool events.pi-tools.ts+pi-tools.before-tool-call.ts— the before-tool-call hook that callscheckMutationGate. UsesgetLatestPlanMode()for freshness across mid-turn approval.plan-hydration.ts+ test — post-compaction restore.formatPlanForHydration(steps)uses factual phrasing (not imperative) to avoid triggering the planning-only retry guard.plan-render.ts+ test — channel-format-aware plan checklist renderer (4 formats: web markdown, mrkdwn, plaintext, HTML).plan-store.ts+ test — on-disk plan persister with file-level locking (O_EXCL atomic write, EEXIST retry).subagent-announce.ts— plan-mode-aware steer instruction; avoids stall after subagent completion.subagent-registry.ts+ tests (subagent-registry.test.ts,subagent-registry.steer-restart.test.ts) +subagent-registry-run-manager.ts— drainsopenSubagentRunIdson child completion/kill.transport-message-transform.ts— synthesized missingtool_resultplaceholder (improved text + repair logging).openclaw-tools.ts+openclaw-tools.registration.ts— plan-mode tool registration, config-gated.tool-catalog.ts,tool-description-presets.ts,tool-display-config.ts— see "Tools" above.test-helpers/fast-openclaw-tools-sessions.ts— test-helper update for plan-mode awareness.Auto-reply / commands —
src/auto-reply/commands-registry.shared.ts—/plancommand definition (universal, all channels).reply/commands-handlers.runtime.ts— registers the universal/planhandler.reply/commands-plan.ts+ test — the/plan accept|revise|answer|auto|status|on|off|view|restatehandler implementations.reply/agent-runner-execution.ts— agent runner execution path (plan-mode-aware).reply/agent-runner.misc.runreplyagent.test.ts— runtime test.reply/fresh-session-entry.ts+ test — disk-fresh session entry + deletion-as-normal resolver. This is the iter-2 Bug A fix:getLatestPlanModereturns"normal"on planMode deletion, not undefined-ish.Gateway —
src/gateway/sessions-patch.ts+ tests (sessions-patch.test.ts,sessions-patch.subagent-gate.test.ts) — the approval state-machine dispatcher. One approval state machine, not one per channel. Subagent gate enforced here at approve/edit time.plan-snapshot-persister.ts+ test — subscribes toagent_plan_eventbus; persistsplanMode.lastPlanSteps; cleans nudges onphase: "completed". The fixedphase === "requested"filter (not"request") is here — see iter-3 hardening note in feat(plan-mode): full rollout + iter-1/2/3 hardening (consolidates PRs A/B/C/D/E/F/7/8/10/11) #68939.protocol/index.ts,protocol/schema/sessions.ts,protocol/schema/cron.ts,protocol/schema/error-codes.ts— wire schema additions:planMode,planApproval,lastPlanSteps,cronschema for nudges,PLAN_APPROVAL_BLOCKED_BY_SUBAGENTSerror code.server-runtime-subscriptions.ts,server-runtime-handles.ts— startplan-snapshot-persisteron startup; threadplanSnapshotUnsubinto handles.server-close.ts+ test — unsubscribe persister on shutdown (no listener leak).server.impl.ts— wiresplanSnapshotUnsubinto close deps.server-methods/sessions.ts— includesexec+planModeinsessions.changedpayload.session-utils.ts,session-utils.types.ts— surfacesexec+planModeon session rows.Config —
src/config/Schema additions only; no existing key changes meaning.
zod-schema.agent-defaults.ts—planMode.{enabled, autoEnableFor, approvalTimeoutSeconds, debug}+embeddedPi.{autoContinue, maxIterations}.zod-schema.agent-runtime.ts— per-agentembeddedPiandplanModeoverrides.zod-schema.ts—skills.limits.maxPlanTemplateSteps.schema.base.generated.ts— generated mirror of zod schemas (regenerate via existing build script if you change the source).sessions/types.ts—SessionEntry.planModeshape:{ mode, approval, title, approvalId, lastPlanSteps, approvalRunId, nudgeJobIds, feedback, rejectionCount, pendingAgentInjection }.sessions/store-migrations.ts(FULL-only) — best-effort legacy field rename (provider→channel,room→groupChannel).types.agents.ts,types.agent-defaults.ts,types.skills.ts— TS types mirroring the zod schemas.Skills —
src/agents/skills/skill-planner.ts+ test — builds plan-template seed payload with dedupe + truncation diagnostics (capped viaskills.limits.maxPlanTemplateSteps).frontmatter.ts+ test — parsesplanTemplatefrom skill frontmatter (alias + precedence rules).types.ts—SkillPlanTemplateStep,resolvedPlanTemplatessnapshot field.workspace.ts— carries plan templates into snapshots.Cron —
src/cron/isolated-agent/run.ts— cron-driven agent run path.isolated-agent/run.plan-mode.test.ts— plan-mode-specific cron path test.isolated-agent/run-executor.ts(FULL-only) —createCronPromptExecutorwrapper aroundrunCliAgent.normalize.ts,types.ts— cron type and normalization utilities for nudge job names.Infra —
src/infra/agent-events.ts— agent event bus extensions for plan-mode events.heartbeat-runner.ts+heartbeat-runner.plan-nudge.test.ts— prepends heartbeat prompt with "continue active plan" nudge when applicable. Driven byplan-execution-nudge-crons.tsduring executing.UI —
ui/src/ui/chat/mode-switcher.ts+ test — the plan-mode chip toggle.ui/chat/plan-cards.ts+ test — expandable plan-step card rendered inline in the thread.ui/chat/plan-resume.ts+plan-resume.node.test.ts— restores in-progress plan on web reconnect.ui/chat/grouped-render.test.ts— grouped rendering of plan messages.ui/chat/slash-command-executor.ts+slash-command-executor.node.test.ts— slash-command executor for/planfamily.ui/chat/slash-commands.ts— registry of slash commands including the/planfamily.ui/views/plan-approval-inline.ts+ test — the inline approval card (3 buttons + revise textarea).ui/views/chat.ts,ui/app-chat.ts,ui/app-render.ts,ui/app-render.helpers.ts,ui/app-tool-stream.ts,ui/app-view-state.ts,ui/app.ts,ui/types.ts— view + app shell wiring.styles/chat.css,styles/chat/layout.css,styles/chat/plan-cards.css— plan-card styles imported into chat bundle.i18n/locales/{de,en,es,fr,id,ja-JP,ko,pl,pt-BR,tr,uk,zh-CN,zh-TW}.ts+ corresponding.i18n/*.meta.json— 13 locales covered; meta JSONs are generator outputs.Channels —
extensions/extensions/telegram/runtime-api.ts— exports the Telegram document-send type.extensions/telegram/src/send.ts—sendDocumentTelegramhelper. Currently unused (the SDK surface it called was removed by an upstream restructure mid-rebase). Markdown plan files are still persisted to disk; only the document-upload step is skipped, with awarn-level log line so the gap is visible. See "Deferred features".Plugin SDK —
src/plugin-sdk/+src/plugins/plugin-sdk/telegram.ts— Telegram plugin SDK surface.plugins/command-registration.ts— plan-mode command registration through the plugin layer.plugins/contracts/plugin-sdk-runtime-api-guardrails.test.ts— guardrail test for the plugin runtime API.Commands + status —
src/commands/commands/sessions.ts— sessions CLI command updates for plan-mode awareness.commands/status.summary.ts— status summary including plan-mode state.Apps + protocol —
apps/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift— Swift protocol model updates for the macOS app.apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift— shared Swift protocol model.apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json— mirror oftool-display-config.ts.Docs / QA / skills / tests / infra
docs/concepts/plan-mode.md— user-facing reference.docs/plans/PLAN-MODE-ARCHITECTURE.md(~635 lines) — deep architecture + iter history + test matrix.docs/plans/PLAN-MODE-OPERATOR-RUNBOOK.md(~250 lines) — operator runbook (enable, debug, rollback, troubleshooting).docs/agents/prompt-stack-spec.md— prompt-stack spec.docs/tools/slash-commands.md— slash-command reference including/planfamily.skills/plan-mode-101/SKILL.md— the in-product skill that mirrors the per-turn reference card.qa/scenarios/gpt54-{act-dont-ask,cancelled-status,injection-scan,mandatory-tool-use,plan-mode-default-off}.md— 5 GPT-5.4 QA scenarios.test/vitest/vitest.plan-mode.config.ts— plan-mode-specific vitest config (used bypnpm test plan-mode).Configuration reference
All config is additive; no existing key changes meaning.
Agent defaults —
src/config/zod-schema.agent-defaults.tsPer-agent overrides —
src/config/zod-schema.agent-runtime.tsSkills —
src/config/zod-schema.tsEnv vars
OPENCLAW_DEBUG_PLAN_MODE=1Runtime config commands (any channel)
The
SCHEMA-RESERVEDcallouts are also annotated in code atsrc/config/types.agent-defaults.ts:316-355. Those comments are the authoritative source of truth on deferral status — if you're auditing whether something is wired, read them, not this body.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.applySessionStoreMigrations(src/config/sessions/store-migrations.ts) does best-effort legacy-field renames (provider→channel,room→groupChannel). It runs unconditionally on store load and is safe on any-vintage data — fields without the legacy shape are no-op'd.Test coverage matrix
200+ new tests across 45+ test files. The full list is in the file inventory; here's the per-module summary.
plan-mode/approval.test.ts,plan-mode/types.tsplan-mode/mutation-gate.test.tsplan-mode/accept-edits-gate.test.tsapprovalIdscoping, single-cycle revocation, no-skill-recursionplan-mode/auto-enable.test.tsplan-mode/injections.test.ts,plan-mode/execution-status-injection.test.ts[PLAN_STATUS]per-turn,[PLAN_MODE_INTRO]first-turn, sanitization against envelope-closingtools/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,tools/sessions-spawn-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,infra/heartbeat-runner.plan-nudge.test.tsplan-hydration.test.ts,plan-render.test.ts,plan-store.test.tsplan-mode/plan-mode-debug-log.test.tsauto-reply/reply/fresh-session-entry.test.tsskills/frontmatter.test.ts,skills/skill-planner.test.tssubagent-registry.test.ts,subagent-registry.steer-restart.test.tsgateway/sessions-patch.test.ts,gateway/sessions-patch.subagent-gate.test.ts,gateway/server-close.test.ts,gateway/plan-snapshot-persister.test.tspi-embedded-runner/run.incomplete-turn.test.ts,pi-embedded-runner/run.overflow-compaction.test.ts,pi-embedded-runner/pending-injection.test.ts,pi-embedded-runner/skills-runtime.test.ts,pi-embedded-runner/run/incomplete-turn.test.tsauto-reply/reply/commands-plan.test.ts,auto-reply/reply/agent-runner.misc.runreplyagent.test.ts/planrouting across channel formats, runner integrationcron/isolated-agent/run.plan-mode.test.tsplan-mode/integration.test.tsplugins/contracts/plugin-sdk-runtime-api-guardrails.test.tsui/src/ui/chat/mode-switcher.test.ts,ui/src/ui/chat/plan-cards.test.ts,ui/src/ui/chat/grouped-render.test.ts,ui/src/ui/chat/plan-resume.node.test.ts,ui/src/ui/chat/slash-command-executor.node.test.ts,ui/src/ui/views/plan-approval-inline.test.tsqa/scenarios/gpt54-*.mdRun locally:
Parity benchmark
The user ran an independent benchmark before this rollout: identical prompts driven through (a) this PR's plan mode, (b) Codex's plan mode (OpenAI's plan-mode equivalent), and (c) Claude Code's plan mode (Anthropic's plan-mode equivalent), across the same Anthropic + OpenAI model rotations and similar tool sets.
Results:
Why this matters for review: the design here is convergent with industry-standard plan-mode patterns from Codex and Claude Code, not novel or speculative. The "propose, approve, execute" three-phase contract; the executing-state lifecycle distinct from investigation; the
update_planclosure gate; theask_user_questionconstrained-choice modal; the per-turn reference card — all of these have direct counterparts in those products. We're shipping a well-trodden pattern, with an extra hardening layer (the fail-closed mutation gate, the cryptographicapprovalId, the path-traversal-defended persister) for our specific threat model.This is independent benchmark evidence the design works, separate from the unit/integration test pass.
What a maintainer can verify (smoke checklist)
After checking out this branch:
Then end-to-end:
pnpm gateway:dev— no startup errors,[plan-snapshot-persister] subscribedline ingateway.err.log.openclaw config set agents.defaults.planMode.enabled true→ restart gateway./plan onto an agent: the mode chip flips in webchat; the agent's tool list now includesenter_plan_mode,exit_plan_mode,update_plan,ask_user_question,plan_mode_status.enter_plan_mode(or you send/plan on): mutation gate arms — trybash/edit/write/apply_patch; each is blocked with a tool error citing the gate.exit_plan_modewith a plan: approval card renders inline above the input. Markdown file is written to~/.openclaw/agents/<id>/plans/plan-YYYY-MM-DD-<slug>.md(verify byls).executing; mutation gate disarms for that approval cycle (verify viaplan_mode_statustool or[plan-mode/*]log lines);[PLAN_DECISION]: approvedshows up in the agent's next prompt preamble.plan-execution-nudge-cronsfires at 1/3/5 min; verify bytail -F gateway.err.log | grep plan-execution-nudge.[PLAN_DECISION]: rejected\nfeedback: ...in its next preamble, can re-propose with a freshapprovalId.sessions_spawnchild while pending approval; click Accept — gateway returnsPLAN_APPROVAL_BLOCKED_BY_SUBAGENTSwith child IDs indetails.openSubagentRunIds.update_planwith terminal statuses; mode flips back tonormalautomatically; nudge crons clean up.[Your active plan was preserved...]synthetic message appears with pending/in-progress steps only.openclaw config set agents.defaults.planMode.enabled false→ restart gateway. Tools unregister, chip hides,sessions.patch { planApproval }rejects. Existing markdown plans on disk are unchanged.If any of these fail, the
[plan-mode/*]debug events tell you where to look — turn them on withOPENCLAW_DEBUG_PLAN_MODE=1(env) oragents.defaults.planMode.debug: true(persistent).Deferred features
These are explicitly not in this bundle. Each is either (a) schema-reserved and waiting for runtime wiring, or (b) blocked on an upstream change. None are required for the core contract.
agents.defaults.planMode.autoEnableFormodel-pattern auto-enablesrc/config/types.agent-defaults.ts:316-355SCHEMA-RESERVED comment +auto-enable.tsagents.defaults.planMode.approvalTimeoutSecondscron-time watchdogtimed_outafter the configured intervalapproval.tsDEFAULT_APPROVAL_CONFIGsendDocumentTelegramhelper exported, markdown plan written to disk on everyexit_plan_modeextensions/telegram/src/send.ts/plantext commands work on every channel/plan)/plan self-testslash-command harnessPLAN_APPROVAL_EXPIREDplannedtimed_outThe in-code SCHEMA-RESERVED comments at
src/config/types.agent-defaults.ts:316-355are the authoritative source of truth on deferral status — if you find a discrepancy between this list and that comment, the comment wins.Maintainer landing strategies
Two paths produce identical final tree state. Pick based on what you want to optimize for.
Path A: Sequential per-part merge
Optimize for per-PR line scrutiny + reviewable history.
[Plan Mode 1/6]([Plan Mode 1/6] Plan-state foundation #70031) — green CI, foundation only.[Plan Mode 2/6]([Plan Mode 2/6] Core backend MVP #70066) — was red againstmain, will go green once 1/6 is in.[Plan Mode 3/6]([Plan Mode 3/6] Advanced plan interactions #70067) — same pattern.[Plan Mode 4/6]([Plan Mode 4/6] Web UI + i18n #70068) — same pattern.[Plan Mode 5/6]([Plan Mode 5/6] Text channels + Telegram #70069) — same pattern.[Plan Mode 6/6]([Plan Mode 6/6] Docs, QA, and help #70070) — green CI, docs-only.[Plan Mode INJECTIONS]([Plan Mode INJECTIONS] Typed pending-injection queue foundation #70088) — sibling to numbered stack.[Plan Mode AUTOMATION]([Plan Mode AUTOMATION] Cron nudges + auto-enable + subagent follow-ups #70089) — was red againstmain, will go green after the stack lands.Path B: Single-merge of THIS PR
Optimize for one merge button + immediate end-to-end testability.
Both paths land the same tree. Path A gives a finer-grained merge history; Path B gives a single merge commit that's easier to revert if needed (
git revert -m 1 <merge-sha>on this PR's merge undoes the entire feature in one step).Issue references
Test status
integration.test.tsanchor + gateway + runner integration suites).vitest.unit-fast.config.tsorvitest.plan-mode.config.tsrather than the workspace root).