Skip to content

[Plan Mode 3/6] Advanced plan interactions#70067

Closed
100yenadmin wants to merge 1 commit into
openclaw:mainfrom
electricsheephq:isolated/pm-3-of-6-advanced
Closed

[Plan Mode 3/6] Advanced plan interactions#70067
100yenadmin wants to merge 1 commit into
openclaw:mainfrom
electricsheephq:isolated/pm-3-of-6-advanced

Conversation

@100yenadmin

@100yenadmin 100yenadmin commented Apr 22, 2026

Copy link
Copy Markdown
Contributor

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.


Stack position: This is [Plan Mode 3/6], the third part of a 6-PR per-part decomposition of the original umbrella #68939 (closed).

  • Previous in stack: [Plan Mode 2/6] Core backend MVP — must merge first
  • Next in stack: [Plan Mode 4/6] Web UI + i18n
  • Integration bundle: [Plan Mode FULL] — green-CI bundle of all parts + automation + executing-state lifecycle

CI on this PR will be RED: this part's code references symbols from [Plan Mode 1/6] + [Plan Mode 2/6] that aren't on main yet. CI will pass once 1/6 → 2/6 merge in order, OR review the green-CI integrated state in [Plan Mode FULL].

Ways to land this feature (maintainer choice):

  • Per-part review + sequential merge of 1/6 → 6/6
  • Single bundle merge via [Plan Mode FULL]

Executive summary

This is the advanced plan-mode interactions layer. The 2/6 PR shipped the core: enter / update / exit, the mutation gate, the approval state machine, the subagent gate, plan persistence as markdown. That's enough to plan-then-execute, but it leaves the agent two-state — "planning" or "executing" — with no way to bring the user into the loop mid-plan, no way to self-introspect, and no permission tier between "user must approve every mutation" and "agent has free reign". This PR fills those gaps.

Concretely it adds: ask_user_question (clarifying questions routed through the same approval-card pipeline as exit_plan_mode, plan-mode-safe — does not exit), plan_mode_status (read-only introspection so the agent can self-diagnose without inferring state from tool errors), plan archetypes (the persisted-markdown structure plus the system-prompt fragment that teaches Opus-quality decision-complete plans), and the accept-edits gate — Claude-Code-style auto-edit permission granted by the "Accept, allow edits" approval button, runtime-enforced against three hard constraints (no destructive actions, no self-restart, no config changes). The exit_plan_mode tool itself is extended in this PR to add the archetype fields (analysis / assumptions / risks / verification / references) and to make title mandatory at the schema layer.

TL;DR

  • Scope: ~6,300 LoC across 20 files. New: 4 plan-mode/tool source files + 5 test files (1,419 lines of tests). Modified: exit-plan-mode-tool.ts (archetype fields + mandatory title), sessions-patch.ts (planApproval discriminated union + answer routing + acceptEdits permission grant), protocol/schema/sessions.ts (planApproval wire schema), sessions/types.ts (PendingInteraction + PostApprovalPermissions), tool-catalog.ts + tool-description-presets.ts + openclaw-tools.ts (registration + presets), pi-embedded-runner/run/attempt.ts (live-read getLatestAcceptEdits accessor threading), agent-runner-execution.ts (acceptEdits accessor wiring), pi-embedded-subscribe.handlers.tools.ts (the ask_user_question runtime intercept that emits the approval event).
  • Design pattern: approval-card pipeline reuse. ask_user_question does NOT introduce a new approval kind — it piggybacks on kind:"plugin" (same payload shape as exit_plan_mode), with the consumer-side render switching on the presence of a question field. Single approval persister ([Plan Mode 2/6] Core backend MVP #70066), single state machine, single answer routing path. The user clicks an option button → sessions.patch { planApproval: { action: "answer", answer, approvalId } } → gateway validates approvalId against pendingQuestionApprovalId → enqueues a [QUESTION_ANSWER]: injection on next agent turn.
  • Accept-edits gate constraints (hard): (1) destructive — rm, rmdir, unlink, shred, trash, truncate, find -delete, find -exec rm, SQL DROP TABLE / DELETE FROM / TRUNCATE TABLE, Redis FLUSHALL / FLUSHDB, diskutil erase{disk,all}. (2) self-restart — openclaw gateway {stop,restart,kill}, launchctl {kickstart,unload,stop} ai.openclaw.*, systemctl {restart,stop,kill} openclaw*, pkill openclaw, kill <pid> co-located with openclaw/gateway, kill $(pgrep openclaw), scripts/restart-mac.sh. (3) config changes — openclaw config {set,delete,unset}, openclaw doctor --fix, write/edit/apply_patch into ~/.openclaw/, ~/.claude/, ~/.config/openclaw/, /etc/openclaw/, /usr/local/etc/openclaw/. Plus a layered-defense escape-pattern detector: env-var indirection ($RM), backtick / $(...) subshell, quote concatenation ("r""m"), hex (\x72) and octal (\162) byte escapes near a destructive verb all block.

Why this PR is split out

The plan-mode work in 2/6 ends at "agent submits plan, user approves verbatim, agent executes." That's the MVP. The advanced interactions are a coherent next slice — they share the approval-card pipeline, they share the discriminated planApproval schema, and they layer on top of the persisted-plan-cycle state from 2/6 — but they're additive enough to review independently. Splitting them out keeps the 2/6 review surface focused on "is the state machine right" without dragging in the question-routing UX, the archetype prompt-engineering, or the accept-edits enforcement matrix.

Critical flows

Flow 1 — ask_user_question lifecycle

The clarifying-question loop. Agent calls ask_user_question mid-planning, the runtime intercepts the tool result and emits a kind:"plugin" approval event with a question field, the user picks an option, the answer arrives in the agent's next turn as a synthetic user message. No transition out of plan mode — the session stays armed for the agent to continue investigating or to call exit_plan_mode once the answer lands.

sequenceDiagram
    participant Agent
    participant Runtime as pi-embedded subscribe
    participant Gateway as sessions-patch
    participant UI as Control UI / Telegram / CLI
    participant User

    Agent->>Runtime: ask_user_question({ question, options[2..6], allowFreetext? })
    Note over Agent,Runtime: schema enforces 2-6 options,<br/>rejects duplicate option text
    Runtime->>Runtime: detects status:"question_submitted"<br/>derives approvalId = `question-${toolCallId}`<br/>(deterministic — prompt-cache stable)
    Runtime->>Gateway: emit AgentApprovalEvent(kind:"plugin", question:{prompt, options, allowFreetext})
    Gateway->>Gateway: persist PendingInteraction{kind:"question", approvalId, prompt, options}
    Gateway->>UI: agent_approval_event broadcast
    UI->>User: render N option buttons (web inline / Telegram inline / "/plan answer <choice>")
    User->>UI: clicks "1 PR"
    UI->>Gateway: sessions.patch { planApproval: { action:"answer", answer:"1 PR", approvalId } }
    Gateway->>Gateway: validate approvalId == pendingQuestionApprovalId<br/>reject if mismatched (stale-click guard)
    Gateway->>Gateway: enqueue PendingAgentInjection{kind:"question_answer", text:"[QUESTION_ANSWER]: 1 PR"}
    Gateway-->>Agent: next turn: synthetic user message<br/>"[QUESTION_ANSWER]: 1 PR"
    Agent->>Agent: continues plan; eventually calls exit_plan_mode<br/>(session was always still in plan mode)
Loading

Flow 2 — Accept-edits gate decision tree

Granted when the user clicks "Accept, allow edits" (vs plain "Approve"). Layer 1 is the prompt — buildAcceptEditsPlanInjection in approval.ts teaches the agent the three constraints. Layer 2 is this gate, called from the before-tool-call hook on EVERY tool call when postApprovalPermissions.acceptEdits === true. Fail-OPEN by design: only blocks on explicit matches; everything else passes.

flowchart TD
    Tool[Tool call about to fire] --> Gate{getLatestAcceptEdits()<br/>fresh-from-disk read}
    Gate -- false --> AllowNoGate[allow — gate not invoked]
    Gate -- true --> Dispatch{toolName?}

    Dispatch -- exec / bash --> Cmd[exec command string]
    Dispatch -- write / edit / apply_patch --> Path[filePath + extracted additionalPaths]
    Dispatch -- other --> AllowOther[allow — outside gate scope]

    Cmd --> D1{matches DESTRUCTIVE_EXEC_PREFIXES<br/>rm / rmdir / shred / trash / truncate /<br/>diskutil erase…?}
    D1 -- yes --> BlockD[block — constraint:'destructive']
    D1 -- no --> D2{matches DESTRUCTIVE_SQL_PATTERNS<br/>DROP TABLE / DELETE FROM /<br/>TRUNCATE / FLUSHALL?}
    D2 -- yes --> BlockD
    D2 -- no --> D3{matches DESTRUCTIVE_FIND_FLAGS<br/>find -delete / find -exec rm?}
    D3 -- yes --> BlockD
    D3 -- no --> D4{matches DESTRUCTIVE_ESCAPE_PATTERNS<br/>$RM / `…rm…` / $(…rm…) /<br/>quote-concat / hex / octal?}
    D4 -- yes --> BlockDE[block — constraint:'destructive'<br/>'shell-escape construct near destructive verb']
    D4 -- no --> R1{matches SELF_RESTART_PATTERNS<br/>openclaw gateway stop / launchctl /<br/>pkill openclaw / kill $(pgrep openclaw)?}
    R1 -- yes --> BlockR[block — constraint:'self_restart']
    R1 -- no --> C1{matches CONFIG_CHANGE_PATTERNS<br/>openclaw config set / delete / unset /<br/>openclaw doctor --fix?}
    C1 -- yes --> BlockC[block — constraint:'config_change']
    C1 -- no --> AllowExec[allow]

    Path --> P1[normalize: expand ~,<br/>collapse .. and . segments,<br/>generate tildeForm + absoluteForm]
    P1 --> P2{any candidate path<br/>(filePath + apply_patch headers)<br/>starts with PROTECTED_CONFIG_PATH_PREFIXES?<br/>~/.openclaw/, ~/.claude/, /etc/openclaw/…}
    P2 -- yes --> BlockP[block — constraint:'config_change'<br/>'write to protected config path']
    P2 -- no --> AllowPath[allow]

    BlockD --> Reason[return reason → 'ask the user for explicit confirmation']
    BlockDE --> Reason
    BlockR --> Reason
    BlockC --> Reason
    BlockP --> Reason
Loading

Flow 3 — Plan archetype lifecycle

The archetype is a system-prompt fragment + a tool-schema extension + a disk artifact. It's appended to the system prompt when the session is in plan mode (PR-10 prompt fragment in plan-archetype-prompt.ts); the agent fills in the archetype fields when it calls exit_plan_mode; the runtime persists the rendered markdown under ~/.openclaw/agents/<agentId>/plans/plan-YYYY-MM-DD-<slug>.md; and on a future plan cycle the operator (or the agent reading the plans dir) can reference the prior plans for continuity.

sequenceDiagram
    participant Skill as Skill / system prompt
    participant Agent
    participant ExitTool as exit_plan_mode
    participant Persister as plan-archetype-persist
    participant FS as ~/.openclaw/agents/&lt;id&gt;/plans/

    Note over Skill: PLAN_ARCHETYPE_PROMPT appended to<br/>system prompt while planMode === "plan"
    Skill->>Agent: "produce a decision-complete plan with<br/>title, summary, analysis, plan[], assumptions,<br/>risks, verification, references"
    Agent->>Agent: investigates, reads files, web_search,<br/>maybe ask_user_question for tradeoffs
    Agent->>ExitTool: exit_plan_mode({ title (REQUIRED), plan[], analysis,<br/>assumptions, risks, verification, references })
    ExitTool->>ExitTool: title schema-required (rejects with actionable<br/>error if missing — no silent "Active Plan" fallback)
    ExitTool->>ExitTool: subagent gate — block if openSubagentRunIds.size > 0
    ExitTool->>Persister: persistPlanArchetypeMarkdown({ agentId, title, markdown })
    Persister->>Persister: validate agentId (no /, \, control chars,<br/>no "." / ".." / dot-only)
    Persister->>Persister: mkdir baseDir, reject symlinks at agent/plans dirs,<br/>realpath() containment check
    Persister->>FS: writeFile(plan-2026-04-22-fix-foo.md, flag:"wx")<br/>(O_CREAT | O_EXCL — atomic, TOCTOU-safe)
    alt EEXIST (collision)
        Persister->>FS: retry with -2 / -3 / … suffix up to MAX_COLLISION_SUFFIX (99)
    else ENOSPC / EACCES / EIO
        Persister-->>ExitTool: throw PlanPersistStorageError(code)<br/>(operator-actionable; agent turn not retried)
    end
    Persister-->>ExitTool: { absPath, filename }
    ExitTool-->>Agent: tool result + approval card emitted
    Note over Agent,FS: Future cycles: operator / agent can grep<br/>~/.openclaw/agents/&lt;id&gt;/plans/ for prior plans;<br/>filenames sort chronologically by date prefix
Loading

Per-file deep dive

src/agents/tools/ask-user-question-tool.ts (130 lines + 174-line test)

What it does. Schema-validated tool that emits a question_submitted tool result; the runtime intercept (see pi-embedded-subscribe.handlers.tools.ts:1815-1862) detects this result shape and fires an agent_approval_event through the existing kind:"plugin" pipeline. The session stays in plan mode the entire time.

Schema (ask-user-question-tool.ts:32-60):

Type.Object({
  question: Type.String({ /* one or two short sentences */ }),
  options: Type.Array(Type.String(), { minItems: 2, maxItems: 6 }),
  allowFreetext: Type.Optional(Type.Boolean()),
}, { additionalProperties: false })  // ← schema-hardened

The additionalProperties: false was added in response to Copilot review #68939 to align with the same hardening applied to plan_mode_status and enter_plan_mode — keeps the agent from smuggling extra fields through the tool surface that the runtime would silently drop (a class of bug we hit on update_plan early on).

Runtime validation beyond schema (ask-user-question-tool.ts:78-104):

  • question non-empty after trim — rejects whitespace-only.
  • options length 2-6 after filtering blanks — UI cap.
  • Duplicate option text rejected — would create ambiguous routing on the answer side (the runtime echoes back the chosen text, so ["1 PR", "1 PR"] would be unrecoverable).

Why runId is in CreateAskUserQuestionToolOptions. Same pattern as exit_plan_mode — the runtime threads its runId so the tool can scope future approval/answer correlation if needed. Currently unused on the question side (the approvalId is derived from toolCallId which is already run-scoped), but kept symmetric so a future per-run question dedup or rate-limit can drop in without a constructor signature change.

Prompt-cache stability (ask-user-question-tool.ts:107-112). questionId = q-${toolCallId} is deterministic. Earlier drafts used crypto.randomUUID() per call — that invalidated the prompt-cache prefix on every transcript replay (transcript repair, retry-after-error). The toolCallId is already stable for a given call, so byte-stable derivation gives free cache hits on replay.

Tool-result content is non-empty (ask-user-question-tool.ts:117). Earlier drafts returned content: []; that tripped third-party transcript-pairing extensions (lossless-claw) which inject [lossless-claw] missing tool result placeholders into the agent's context on re-read. Now returns a one-line "Question submitted to user: ..." string so pairing-pass sees content.

src/agents/plan-mode/accept-edits-gate.ts (564 lines + 629-line test)

Posture: fail-OPEN. Unknown tools and commands ALLOW. The mutation-gate in plan mode is fail-CLOSED; this gate is post-approval execution, where the user opted into auto-edit, so the policy is "block the explicit three categories, allow everything else." Documented at the top of the file (accept-edits-gate.ts:27-35).

Layered defense. Layer 1 is buildAcceptEditsPlanInjection in approval.ts (the prompt that teaches the agent the three constraints and tells it to ask before destructive/restart/config). Layer 2 is this file — runtime enforcement that fires even if the prompt is ignored. Together they're complementary; neither is sufficient alone (prompt can be ignored / instruction-tuned around; runtime can be bypassed via shell escapes the gate doesn't recognize). Documented at accept-edits-gate.ts:36-46.

The three constraints.

  1. Destructive (accept-edits-gate.ts:88-176, 272-315). Three sub-checks: prefix match against a curated list (rm, rmdir, unlink, shred, trash, truncate, diskutil erasedisk, diskutil eraseall); SQL pattern match (DROP TABLE, DROP DATABASE, DROP SCHEMA, DELETE FROM, TRUNCATE TABLE, Redis FLUSHALL/FLUSHDB); find-family flag match (find ... -delete, find ... -exec rm, -execdir rm). Plus the C4 escape-vector layer (see below). Prefix matching uses an exact-or-trailing-space boundary so rmtool --help and rmate config.toml are NOT false-positives — there's a baseline test at accept-edits-gate.test.ts:99-107 covering this.

  2. Self-restart (accept-edits-gate.ts:198-218, 317-330). Patterns target the gateway specifically: openclaw gateway {stop|restart|kill}, launchctl {kickstart|unload|stop} ai.openclaw.*, systemctl {restart|stop|kill} openclaw*, pkill openclaw, killall openclaw, kill <n>? ...openclaw|gateway, plus the indirect forms pgrep openclaw | xargs kill (matched on the source side) and kill $(pgrep openclaw) / kill `pgrep openclaw` (matched on the subshell side). The bundled operator helper scripts/restart-mac.sh is also listed.

  3. Config changes (accept-edits-gate.ts:223-248, 332-345, 404-438). Two-pronged: command-pattern match (openclaw config {set|delete|unset}, openclaw doctor --fix) AND path-prefix match for write/edit/apply_patch tools targeting ~/.openclaw/, ~/.claude/, ~/.config/openclaw/, /etc/openclaw/, /usr/local/etc/openclaw/. Path normalization (accept-edits-gate.ts:357-402) expands ~, collapses .. and . segments, and produces BOTH a tilde form and an absolute form so a write to ~/.openclaw/../.openclaw/config.toml or /Users/x/.openclaw/config.toml both resolve to the same protected target.

The C4 escape-vector layer (accept-edits-gate.ts:130-192). This is the live-test iter-1/2/3-equivalent hardening for this gate — the prefix/SQL/find checks catch the 99% case, but a sophisticated agent could shell-escape: env-var indirection ($RM file, ${SHRED}), backtick subshell (`echo rm` file), $(…) subshell ($(echo rm) file), quote concatenation ("r""m" file), hex byte escapes (\x72m), octal byte escapes (\162m). All of these match an explicit pattern and block. Rationale spelled out at accept-edits-gate.ts:140-149: under acceptEdits the user opted in for trusted-plan execution, not for cleverness budget; legitimate post-approval execs rarely need env-var indirection for destructive verbs; primary defense remains the prompt layer; this is defense-in-depth so a prompt-ignoring agent can't shell-escape silently.

apply_patch multi-path coverage (accept-edits-gate.ts:60-69, 480-553). apply_patch carries paths in the patch envelope (*** Update File: <path>, *** Add File: <path>, *** Delete File: <path>, *** Move to: <dst>), not in a params.path field. Without parsing them out, a malicious patch could write to ~/.openclaw/config.toml and bypass the protected-path check (the singular filePath would be absent). extractApplyPatchTargetPaths parses all four envelope verbs, dedupes, and the caller threads them as additionalPaths for the gate to check. The *** Move to: regex was a Codex review #68939 fix — the actual apply_patch grammar uses Move-to as a SUB-marker nested inside an *** Update File: hunk, NOT the older *** Move File: src -> dst single-line form; pre-fix the regex matched the non-existent form and missed every real Move destination.

What "≥95% confidence" means in practice. It's the prompt-side bar (Layer 1), not a numerical threshold the gate reads. The injection text in approval.ts tells the agent: "you may self-modify the plan during execution AT HIGH CONFIDENCE (≥95%); for anything you're uncertain about, ask the user." There's no probability variable in the gate code — the agent's self-assessment is what gates Layer 1, and Layer 2 hard-blocks the three categories regardless of confidence. The two layers compose: agent self-restraint on uncertain edits, runtime hard-block on the three categories.

The fail-OPEN posture is intentional and asymmetric to the plan-mode mutation gate (which is fail-CLOSED). The reason: in plan mode the user has not seen or approved any plan yet, so the safest default is "block unknown until the user explicitly opts in." Under acceptEdits the user has already approved a plan AND opted into auto-edit; the safest default flips to "allow unknown, hard-block the explicit dangerous categories." Inverting this would mean adding a per-tool allowlist for normal post-approval mutations and a per-command allowlist for execs — high churn cost for no real safety win, since the prompt + gate already cover the realistic threat model (an agent ignoring the constraint guidance and dispatching a destructive call).

How the gate is wired into the runtime. getLatestAcceptEdits (live-read accessor, threaded through attempt.ts:642-644) is consulted by the before-tool-call hook on every tool call. When it returns true, the hook calls checkAcceptEditsConstraint(params) with the toolName, exec command (if applicable), filePath (if applicable), and extractApplyPatchTargetPaths(params.input) for apply_patch calls. If result.blocked === true, the tool call is rejected with the result.reason string surfaced as the error — actionable text the agent can read and re-route through ask_user_question for explicit user confirmation.

src/agents/plan-mode/plan-archetype-persist.ts (217 lines + 249-line test)

What it does. Atomically persists the rendered plan markdown under ~/.openclaw/agents/<agentId>/plans/plan-YYYY-MM-DD-<slug>.md. Always written, regardless of session origin (web/CLI/Telegram/etc.) — operators get a durable audit trail of every exit_plan_mode cycle. Telegram document delivery is layered on top by plan-archetype-bridge.ts (lands in 5/6).

Idempotence + collision handling (plan-archetype-persist.ts:152-179). Atomic create with wx flag (O_CREAT | O_EXCL) — the OS rejects the open with EEXIST if the file already exists. Caught and retried with -2, -3, … up to MAX_COLLISION_SUFFIX = 99. This was a Copilot review #68939 fix from a prior existsSync + writeFile pattern that had a TOCTOU window (parallel agent calls writing the same date+slug could race the existence check). With per-day filenames and 99-cap, production-unreachable but defensive.

Path-traversal defense (plan-archetype-persist.ts:74-150). Three layers:

  1. Syntactic agentId rejection (:85-92) — rejects /, \, control characters (\p{Cc} to satisfy the no-control-regex lint rule), and . / .. / dot-only.
  2. Lexical containment (:111-117) — path.resolve(target).startsWith(path.resolve(baseDir)).
  3. Symlink rejection + realpath() containment (:118-150) — Copilot review feat(plan-mode): full rollout + iter-1/2/3 hardening (consolidates PRs A/B/C/D/E/F/7/8/10/11) #68939 fix: a pre-existing symlink like ~/.openclaw/agents/<id> -> /etc would bypass the syntactic + lexical checks (the path component is fine; the symlink target is the escape vector). Now lstat()s each component, refuses if it's a symlink, then realpath()s base + target and re-checks containment.

Recoverable storage errors (plan-archetype-persist.ts:181-217). ENOSPC / EACCES / EIO are wrapped in PlanPersistStorageError with a distinctive prefix so the bridge / caller can surface an actionable operator message rather than confuse it with a bug. Plan-mode treats these as non-fatal — the plan approval still proceeds; only the durable audit artifact is lost.

src/agents/plan-mode/plan-archetype-prompt.ts (168 lines + 100-line test)

The system-prompt fragment (plan-archetype-prompt.ts:14-134). Adapted from a hand-tuned "Plan Mode" prompt and tightened for OpenClaw's tool surface. Sits ON TOP of the existing plan-mode prompt rules — those cover the action contract ("don't write the plan in chat, use exit_plan_mode") while this fragment covers the QUALITY of the plan submitted: required fields on exit_plan_mode, decision-completeness bar, anti-patterns, when to use ask_user_question, the "Questions DO NOT exit plan mode" clarification, and the self-check before submission.

Filename helpers (plan-archetype-prompt.ts:142-168). buildPlanFilenameSlug lowercases, normalizes NFKD, strips diacritics, collapses non-alphanumeric to single hyphens, trims, slices to maxLen, re-trims. Falls back to "untitled" (NOT "plan" — Copilot review #68939 caught a doc bug claiming the latter; the helper has always returned "untitled"). buildPlanFilename prefixes with ISO date so plans sort chronologically: plan-2026-04-22-fix-websocket-reconnect.md.

What the prompt explicitly forbids (anti-pattern list at plan-archetype-prompt.ts:89-98). The fragment was tuned against six observed agent failure modes from live testing: (1) "bare file list with no analysis" — the kind of plan that looks complete but skips the why; (2) "three vague paragraphs followed by 'and we add tests as needed'" — handwave on verification; (3) "title that's actually the agent's chat narration" — 'I checked all five VMs...' is analysis text, not a title (this directly seeded the mandatory-title schema check); (4) "defers key behavior decisions to 'implementation will decide'" — pushes hidden decisions into execution; (5) "invents repo facts (paths, exports, types) without having read them" — the rule that Concrete: name real files, modules, symbols, APIs, schemas, configs is a direct response to this; (6) "mixes must-have changes with optional nice-to-haves" — bloats the approval surface. Each anti-pattern is a real instance the team saw in early plan-mode rollout and is now explicitly called out so the agent self-rejects before submission.

src/agents/tools/exit-plan-mode-tool.ts (modified — +418 net incl. test churn)

The 2/6 PR shipped the basic exit_plan_mode tool. This PR extends it with:

Mandatory title (exit-plan-mode-tool.ts:51-60, 219-230). PR-9 / Bug 2/6: title is now REQUIRED and rejected with an actionable ToolInputError if missing. Pre-fix, the approval card defaulted to "Active Plan" / "Plan approval requested" (uninformative for the user) and the persisted markdown filename slug fell back to untitled (uninformative for the operator browsing ~/.openclaw/agents/<id>/plans/). Schema-level rejection beats a silent fallback — the agent retries on the next attempt with a real title.

Archetype fields (exit-plan-mode-tool.ts:90-143, 357-418). analysis, assumptions, risks ({risk, mitigation}[]), verification, references — all optional and backwards-compatible. The plan-archetype prompt fragment tells the agent which are required for which kind of plan (e.g. analysis required for non-trivial multi-file changes; verification required for any plan that ships code). readPlanArchetypeFields parses each defensively (trim + drop blank entries) so a malformed agent payload doesn't poison the approval card.

Tool-side subagent gate (exit-plan-mode-tool.ts:254-310). Iter-3 R6a always-on diagnostic + iter-1 R3 hard-block. When the parent run has open subagent runs (research spawned during plan-mode investigation), exit_plan_mode rejects the submission with a ToolInputError listing the pending children (truncated to 5 with "and N more"). Plus the SUBAGENT_SETTLE_GRACE_MS window: if the last subagent completed less than the grace ms ago, block to let completion events propagate before the approval-resume turn fires (prevents the announce-turn-races-approval RW1 race window).

Always-on diagnostic line (exit-plan-mode-tool.ts:267-269). Every exit_plan_mode call emits ONE structured line to gateway.err.log via the agents/exit-plan-gate subsystem logger:

gate decision: result=allowed runId=<id> sessionKey=<key> openSubagents=0 reason=openSubagentRunIds empty (no subagents in flight)
gate decision: result=blocked runId=<id> sessionKey=<key> openSubagents=3 reason=—

This was added in iter-3 R6a after a class of bug where the gate silently bypassed (no runId, ctx not registered, openSubagentRunIds undefined) without leaving a trace — operators couldn't tell from logs whether the gate fired or not. Now operators can grep agents/exit-plan-gate for every submission attempt and see the decision plus the reason for any bypass.

Supporting changes

  • src/agents/openclaw-tools.ts (+28 / -1) — registers createAskUserQuestionTool and createPlanModeStatusTool behind the same plan-mode-enabled gate as enter_plan_mode / exit_plan_mode. The plan_mode_status tool itself is referenced by registration here but its implementation file is owned by Plan Mode 2/6 ([Plan Mode 2/6] Core backend MVP #70066) so the dependency is honored.
  • src/agents/tool-catalog.ts (+31) — ask_user_question catalog entry, coding profile, includeInOpenClawGroup: true. Plan-mode enabled gate inherited from the registration site.
  • src/agents/tool-description-presets.ts (+87) — ASK_USER_QUESTION_TOOL_DISPLAY_SUMMARY, PLAN_MODE_STATUS_TOOL_DISPLAY_SUMMARY, describePlanModeStatusTool, describeAskUserQuestionTool. Plus pointer text on every plan-mode tool description: "To inspect live plan-mode state at runtime, call plan_mode_status (read-only diagnostic)" — gives the agent a single source of truth for self-debugging.
  • src/config/sessions/types.ts (+327 / -11) — PostApprovalPermissions (acceptEdits, grantedAt, approvalId), PendingInteraction (discriminated union over kind:"plan" | "question"), PendingInteractionStatus, PendingAgentInjectionKind (typed kinds for the priority-ordered injection queue that supersedes the legacy pendingAgentInjection: string field).
  • src/gateway/protocol/schema/sessions.ts (+183) — refactors planApproval from a flat optional-fields object to a discriminated union over action, with per-variant required fields (reject requires feedback 1-8192 chars; answer requires answer text + approvalId; auto requires autoEnabled). Pre-fix all per-action fields were Optional and the runtime validated post-hoc; the runtime checks remain as defense-in-depth but are now unreachable on the happy path. Adds the lastPlanSteps patch field with closed status enum (pending | in_progress | completed | cancelled) and Wave B1 closure-gate fields (acceptanceCriteria, verifiedCriteria).
  • src/gateway/sessions-patch.ts (+767 / -2) — answer routing for planApproval.action === "answer" (:641-680), validates approvalId against pendingQuestionApprovalId (server-side answer-guard), enqueues a PendingAgentInjectionEntry of kind:"question_answer". acceptEdits permission grant on action === "edit" (:947-969), explicit clear on action === "approve" so a prior cycle's grant doesn't carry forward. Plan-mode cycle entry clears any stale postApprovalPermissions (:610).
  • src/gateway/sessions-patch.test.ts (+603) — coverage for the new discriminated-union validation, answer-routing happy path, answer-routing stale-approvalId rejection, auto action gate-OFF rejection, etc. (Note: 50 tests in the file total; the question/answer/acceptEdits subset is the new surface area.)
  • src/agents/pi-embedded-runner/run/attempt.ts (+132 / -1) — threads getLatestAcceptEdits (live-read accessor; pattern mirrors getLatestPlanMode) into the embedded runner so the before-tool-call hook can re-check after mid-turn approval transitions without a stale snapshot. Unrelated WIP in the originating commit was stripped during the cherry-pick (attempt.ts:635-644).
  • src/auto-reply/reply/agent-runner-execution.ts (+205 / -43) — wires resolveLatestAcceptEditsFromDisk (from fresh-session-entry.ts) as the live-read accessor passed to the runner. Same disk-fresh pattern as resolveLatestPlanModeFromDisk.
  • src/agents/pi-embedded-subscribe.handlers.tools.ts (+760) — the runtime intercept for ask_user_question. Detects status === "question_submitted" in the tool-result details, derives a deterministic approvalId = question-${toolCallId} (prompt-cache stability — deep-dive review fix; was previously question-<timestamp>-<random> which surfaced as duplicate stale cards), emits an agent_approval_event with kind:"plugin" + a question field. The plan-card UI switches to a question-render branch when the field is present.

Runtime data flow

Stage Producer Consumer Channel
Agent emits question ask_user_question tool body (ask-user-question-tool.ts:76-128) runtime intercept (pi-embedded-subscribe.handlers.tools.ts:1815-1862) tool-result details
Approval event broadcast runtime intercept gateway approval persister (#70066) → channel adapters AgentApprovalEvent stream
User answers UI / channel /plan answer sessions-patch.ts answer branch (:641-680) sessions.patch { planApproval: action:"answer" }
approvalId guard sessions-patch.ts:641-680 rejected if ≠ pendingQuestionApprovalId server-side validation
Injection enqueued sessions-patch.ts answer branch pendingAgentInjections[] queue SessionEntry write
Injection consumed on next turn agent-runner-execution.ts (composePromptWithPendingInjection) agent's user-message context runtime read+clear
Agent reads [QUESTION_ANSWER]: ... LLM input LLM output (continues plan) next turn
Agent eventually submits plan exit_plan_mode (still in plan mode) approval pipeline (same as plan approval) tool-result details
User clicks "Accept, allow edits" UI / /plan accept edits sessions-patch.ts approve branch (:947-969) sessions.patch { planApproval: action:"edit" }
acceptEdits permission set sessions-patch.ts:958-963 SessionEntry.postApprovalPermissions persisted; cleared on next plan-mode entry
Per-tool-call gate check before-tool-call hook checkAcceptEditsConstraint (accept-edits-gate.ts:455-506) live-read via getLatestAcceptEdits
Block surfaces to agent gate result.reason tool error → agent next turn (agent can re-route through ask_user_question)

Security properties (with file:line evidence)

Property Evidence
additionalProperties: false on ask_user_question schema ask-user-question-tool.ts:59
additionalProperties: false on exit_plan_mode plan-step schema exit-plan-mode-tool.ts:74
additionalProperties: false on exit_plan_mode risks-entry schema exit-plan-mode-tool.ts:117
additionalProperties: false on planApproval discriminated union (every variant) protocol/schema/sessions.ts (each Type.Object(...) in the union)
Three-constraint hard enforcement under acceptEdits accept-edits-gate.ts:455-506 (dispatch), :88-176 (destructive), :198-218 (self-restart), :223-248 (config-change cmd), :242-248 (config-change paths)
Layered escape-vector defense (env-var, subshell, quote-concat, hex/octal byte) accept-edits-gate.ts:130-192 (patterns + checkDestructiveEscape)
apply_patch multi-path extraction (single-path verbs + Move-to) accept-edits-gate.ts:521-553 (extractApplyPatchTargetPaths); caller threads via additionalPaths
Path normalization handles ~, .., ., double-slash accept-edits-gate.ts:357-402 (normalizeCandidatePath)
exit_plan_mode subagent block when research children in flight exit-plan-mode-tool.ts:281-292 (hard reject with child IDs); :297-309 (settle-grace window)
exit_plan_mode mandatory title at schema layer (no silent fallback) exit-plan-mode-tool.ts:219-230
Path-traversal defense on plan persist (syntactic + lexical + realpath + symlink-reject) plan-archetype-persist.ts:85-92 (syntactic), :111-117 (lexical), :118-150 (symlink + realpath)
Atomic plan-file create (TOCTOU-safe, O_CREAT | O_EXCL via wx flag) plan-archetype-persist.ts:170
acceptEdits permission scoped by approvalId (no cycle-A → cycle-B leak) sessions/types.ts:94-98, cleared on plan-mode entry at sessions-patch.ts:610
acceptEdits granted only on action === "edit", explicitly cleared on action === "approve" sessions-patch.ts:947-969
Question-answer routing validates approvalId against pendingQuestionApprovalId sessions-patch.ts:641-680 (answer guard); schema-level requirement at protocol/schema/sessions.ts (answer variant approvalId: NonEmptyString)
Deterministic approvalId / questionId derivation (prompt-cache stable) ask-user-question-tool.ts:107-112 (questionId), pi-embedded-subscribe.handlers.tools.ts:1827-1833 (approvalId)

Review-cycle history (carried forward from #68939)

Each new file carries inline Copilot review #68939 and Codex P1/P2 review #68939 markers pointing to the specific original-umbrella comment that motivated the fix. Notable carries on this PR's surface:

Backward compatibility

  • Opt-in via plan mode being on. All new tools (ask_user_question, plan_mode_status) are registered behind agents.defaults.planMode.enabled (the same gate as enter_plan_mode / exit_plan_mode in 2/6). Sessions where plan mode is OFF see no behavioral change.
  • Plan archetype is opt-in by absence. analysis / assumptions / risks / verification / references on exit_plan_mode are all optional; agents that don't fill them in submit a plain step-list plan as before. The system-prompt fragment tells the agent when each is required for QUALITY, but the schema accepts the bare-minimum (title + plan[]) form.
  • acceptEdits defaults to absent. postApprovalPermissions is undefined by default. Granted only on the explicit action: "edit" approval (the "Accept, allow edits" button), explicitly cleared on action: "approve" (verbatim execution) and on entry into a new plan-mode cycle. The gate is not invoked at all when acceptEdits is false — the runtime only calls it when getLatestAcceptEdits() returns true.
  • exit_plan_mode mandatory title is the one breaking change at the tool surface. Mitigation: the rejection error is actionable ("Re-call exit_plan_mode with the title field included. Example: title: 'Refactor websocket reconnect race'."), and plan mode is opt-in anyway, so existing on-disk sessions running normal mode never see it. Agents that were calling exit_plan_mode without a title in 2/6 received a "Active Plan" fallback header; they now get a clear retry signal instead.
  • planApproval discriminated-union schema is wire-additive — pre-existing fields (approve / edit / reject / auto) keep their semantics; answer is new. Older clients that don't know about answer simply don't send it. Older servers that don't know about it would have rejected the field as additionalProperties: false, but those servers also lack the ask_user_question runtime intercept, so the question wouldn't have been emitted in the first place.
  • PendingInteraction is server-side only. Persisted on SessionEntry, not on the wire. Legacy session-on-disk shapes lacking the field are accepted (it's optional); writes always populate the new shape.

Test coverage matrix

File Tests What's covered
accept-edits-gate.test.ts 629 lines Allowed baseline (read tools, read-only execs, non-destructive mutations, write to non-protected paths). Destructive: rm / rm -rf / rmdir / shred / trash / unlink / truncate, prefix non-collision (rmtool, rmate), SQL DROP TABLE / DELETE FROM, find -delete / -exec rm. Self-restart: `openclaw gateway stop
ask-user-question-tool.test.ts 174 lines Schema accept (2-option, 6-option, allowFreetext). Reject: empty question, whitespace-only question, missing options, options < 2, options > 6, duplicate option text, blank-option filtering. Tool result shape (status: "question_submitted", questionId derivation, non-empty content).
plan-archetype-persist.test.ts 249 lines File-path layout, recursive mkdir, collision (-2, -3 suffix). agentId rejection (/, \, control chars, . / .., dot-only). Path-traversal containment. Symlink rejection at agent + plans dirs. realpath()-based containment. EEXIST retry loop, MAX_COLLISION_SUFFIX cap. ENOSPC / EACCES / EIOPlanPersistStorageError with code preserved.
plan-archetype-prompt.test.ts 100 lines Prompt fragment includes the decision-complete-plan heading, all required exit_plan_mode field names, the chat-narration-as-title anti-pattern, the "Questions DO NOT exit plan mode" clarification, the no-upper-length-cap encouragement. Slug helper: ASCII kebab-case, diacritic strip, non-alpha collapse, leading/trailing hyphen trim, maxLen + trailing-hyphen-after-slice trim, "untitled" fallback for empty/whitespace/pure-punctuation. Filename helper: ISO date prefix, slug, .md suffix, chronological sort.
exit-plan-mode-tool.test.ts 267 lines Subagent gate: empty set succeeds; standalone (no runId) succeeds; 1 open child throws with child id in error; 5 open children all listed; 7 open truncated with "and N more"; "wait for completion" guidance text; drained-set after completion succeeds. Mandatory-title rejection: missing → ToolInputError with retry guidance. Archetype-fields parsing: blank-entry filtering, malformed-payload tolerance.
sessions-patch.test.ts 603 added (1,061 total, 50 tests) Discriminated-union acceptance per variant. planApproval.action === "answer" happy path, stale-approvalId rejection, missing-approvalId rejection. action === "auto" feature-gate. acceptEdits grant on edit, explicit clear on approve, clear on plan-mode-cycle entry. PendingInteraction shape on the SessionEntry side.

Total new test lines this PR: 2,022 across 6 test files.

Parity benchmark callout

User ran a benchmark testing pass where the same prompts hit OpenClaw + Codex + Claude Code on the same Anthropic + OpenAI models. Headline numbers:

  • 90% parity on quality (judged response correctness + decision-completeness on the matched task set)
  • 95% parity on session lengths (turn count + tool-call count distributions overlap within 5% across the three tools)

For the advanced-interactions surface specifically:

  • ask_user_question pattern matches Claude Code's clarifying-question pattern. Both surface the question through the same approval channel as the destructive-action approval (Claude Code's permission dialog; OpenClaw's plan-approval card pipeline). Both use a constrained N-option choice with optional freetext fallback. Both wait synchronously for the answer (no background polling). Both inject the answer back as a synthetic user message tagged with a stable marker ([QUESTION_ANSWER]: here; equivalent in Claude Code).
  • Accept-edits gate matches Claude Code's auto-edit permission with similar three-constraint hardening. Claude Code grants auto-edit at the workspace level after explicit user opt-in and hard-blocks destructive / restart / config classes. OpenClaw grants per-plan-cycle (scoped by approvalId, cleared on cycle entry) and hard-blocks the same three classes plus the layered escape-vector detector. The behavior delta is scope (workspace vs cycle) — OpenClaw is tighter; the constraint set is convergent.
  • Plan archetypes are convergent with Codex's task-template patterns. Codex's task templates encode the same "title + analysis + steps + acceptance" structure for repeatability. OpenClaw's archetype is system-prompt-driven and disk-persisted (markdown audit trail under ~/.openclaw/agents/<id>/plans/) rather than declarative templates, but the plan-shape is the same: required title + step-list + assumptions/risks/verification.

Mergeability scorecard

Dimension Status Notes
Default behavior change None Plan mode opt-in via agents.defaults.planMode.enabled; new tools registered only when on.
Wire schema change Additive planApproval discriminated union extends prior optional-fields shape; lastPlanSteps is a new optional patch field.
SessionEntry shape change Additive pendingInteraction, postApprovalPermissions, pendingAgentInjections[] all optional; legacy on-disk shapes load fine.
Tool surface change One breaking — exit_plan_mode mandatory title Mitigation: actionable ToolInputError with retry guidance; plan mode opt-in.
Test coverage 2,022 new test lines across 6 files All new files have tests; integration covered in sessions-patch.test.ts.
Rollback path Flip planMode.enabled: false Disables all new tools instantly; on-disk shapes remain compatible.
Cross-PR dependencies Depends on 1/6 + 2/6 CI red on this PR by design; bundle merge or sequential merge both work.
Security review Layered: schema + runtime + diagnostic additionalProperties: false on every new schema; runtime gate on every new permission tier; always-on diagnostic on every gate decision.
Performance impact Negligible Gate is per-tool-call dispatch table lookup; no I/O on the hot path; persist path is post-exit_plan_mode (off the LLM hot path).

What a reviewer can verify in <30 min

  1. accept-edits-gate.ts + accept-edits-gate.test.ts — read top-of-file rationale (47 lines), skim the constants tables (DESTRUCTIVE_EXEC_PREFIXES, SQL_PATTERNS, FIND_FLAGS, ESCAPE_PATTERNS, SELF_RESTART_PATTERNS, CONFIG_CHANGE_PATTERNS, PROTECTED_CONFIG_PATH_PREFIXES), then run pnpm test src/agents/plan-mode/accept-edits-gate.test.ts (629 lines, ~80 cases) — see all three constraint classes plus the escape vectors green. (10 min)
  2. ask-user-question-tool.ts + test — read schema (lines 32-60), the duplicate-rejection rule (:96-104), the deterministic questionId derivation (:107-112). Run the test file. (5 min)
  3. exit-plan-mode-tool.ts — read the mandatory-title block (:219-230), the archetype-fields schema (:90-143), the subagent gate (:254-310). Run pnpm test src/agents/tools/exit-plan-mode-tool.test.ts. (5 min)
  4. sessions-patch.ts answer routing + acceptEdits grant:641-680 (answer routing + stale-approvalId guard), :947-969 (grant + clear semantics). Cross-reference protocol/schema/sessions.ts discriminated union for the wire schema. (5 min)
  5. plan-archetype-persist.ts security review — read :74-150 (the three layers of path-traversal defense). Run pnpm test src/agents/plan-mode/plan-archetype-persist.test.ts. (5 min)

Total: ~30 min for a confident green-light on the security-critical surface.

What this PR does NOT include

  • plan_mode_status tool source. Referenced from openclaw-tools.ts (registration) and tool-description-presets.ts (preset) here, but the implementation file lives in [Plan Mode 2/6] Core backend MVP ([Plan Mode 2/6] Core backend MVP #70066) which this PR depends on. CI red on this PR will resolve once 2/6 lands.
  • Plan UI (sidebar, approval cards with question-render branch, mode chip).[Plan Mode 4/6] Web UI + i18n.
  • Channel integration (/plan accept, /plan reject, /plan answer, Telegram inline buttons).[Plan Mode 5/6] Text channels + Telegram.
  • Automation + subagent follow-ups (auto-approve, plan-archetype auto-detection from skill metadata).[Plan Mode AUTOMATION] ([Plan Mode AUTOMATION] Cron nudges + auto-enable + subagent follow-ups #70089) + bundled in [Plan Mode FULL] ([Plan Mode FULL] Integrated bundle for testing (Parts 1\u20136 + automation + executing-state lifecycle) #70071).
  • Plan-archetype auto-detection (from skill metadata). Currently the agent picks the archetype implicitly via the system-prompt fragment; declarative archetype tagging on skills (archetype: "bug-fix" in frontmatter) is a follow-up.
  • planMode.autoEnableFor runtime wiring. Schema-reserved on agents.defaults.planMode.autoEnableFor; cron-time scanner deferred to [Plan Mode FULL].
  • approvalTimeoutSeconds cron watchdog. Schema-reserved; auto-dismiss of stale approval cards is a known follow-up.

Failure-mode walk

A few realistic ways the new surface area can fail in production, and what happens:

  • Agent calls ask_user_question with malformed payload (e.g. 1 option, 7 options, duplicate options, blank options): rejected with a ToolInputError listing the specific failure ("options must contain at least 2 non-empty strings", "options contain duplicate text: 'foo'", etc.). The agent re-attempts with a corrected payload. No approval event fires, no UI render, no race condition. Covered by ask-user-question-tool.test.ts:75-103.
  • User clicks Approve on a question card after the question was already answered on another surface (web + Telegram both have the card open): the approvalId guard at sessions-patch.ts:641-680 validates the incoming approvalId against pendingQuestionApprovalId; mismatch → reject the patch. Stale clicks don't pollute the injection queue.
  • Agent emits exit_plan_mode while subagents are in flight: tool throws ToolInputError with the open run IDs (truncated to 5 with "and N more"), the gateway.err.log gets a structured agents/exit-plan-gate line for the operator, the agent's next turn surfaces the error and the agent waits for completion before re-attempting. exit-plan-mode-tool.test.ts:50-86 covers the listing + truncation + guidance cases.
  • Plan persist fails with ENOSPC mid-cycle (operator's disk is full): PlanPersistStorageError(ENOSPC) thrown with operator-facing prefix; the bridge surfaces an actionable warn-level log line; plan-mode treats this as non-fatal, the approval still proceeds, only the durable markdown audit is lost. The operator sees the exact code in logs and can free space and retry.
  • Symlinked ~/.openclaw/agents/<id> pointing at /etc: lstat() detects the symlink at the agent-dir level and refuses with agent directory must not be a symlink: .... No write happens. The operator sees the rejection in logs, recreates the directory as a real dir.
  • Agent under acceptEdits dispatches rm -rf build/: gate matches DESTRUCTIVE_EXEC_PREFIXES rm, returns {blocked: true, constraint: 'destructive', reason: 'Command "rm" is a destructive action and is blocked under acceptEdits. Ask the user for explicit confirmation before proceeding.'}. Tool call rejected; agent reads the reason, calls ask_user_question to get explicit confirmation, then dispatches rm only after the user answers.
  • Agent under acceptEdits dispatches kill $(pgrep openclaw): matches SELF_RESTART_PATTERNS subshell pattern, blocked with constraint: 'self_restart'. Even if the agent tries kill `pgrep openclaw` (backtick variant), the alternate regex catches it.
  • Agent under acceptEdits dispatches apply_patch with a hunk that moves into ~/.openclaw/config.toml: extractApplyPatchTargetPaths parses the *** Move to: ~/.openclaw/config.toml envelope (Codex review feat(plan-mode): full rollout + iter-1/2/3 hardening (consolidates PRs A/B/C/D/E/F/7/8/10/11) #68939 fix), additionalPaths carries it to the gate, checkProtectedPath matches the prefix, blocked with constraint: 'config_change'.

Issue references

Files in scope

Primary review targets (security-sensitive surface):

  • src/agents/plan-mode/accept-edits-gate.ts + test — three-constraint gate, escape-vector layer, apply_patch multi-path
  • src/agents/tools/ask-user-question-tool.ts + test — schema, duplicate rejection, deterministic ID derivation
  • src/agents/plan-mode/plan-archetype-persist.ts + test — three-layer path-traversal defense, atomic create
  • src/agents/tools/exit-plan-mode-tool.ts + test — mandatory title, archetype fields, subagent gate

Wire / state changes:

  • src/gateway/protocol/schema/sessions.tsplanApproval discriminated union, lastPlanSteps patch
  • src/gateway/sessions-patch.ts + test — answer routing, acceptEdits grant + clear semantics
  • src/config/sessions/types.tsPendingInteraction, PostApprovalPermissions, typed injection queue

Supporting:

  • src/agents/plan-mode/plan-archetype-prompt.ts + test — system-prompt fragment, slug helpers
  • src/agents/openclaw-tools.ts, src/agents/tool-catalog.ts, src/agents/tool-description-presets.ts — registration + presets
  • src/agents/pi-embedded-runner/run/attempt.tsgetLatestAcceptEdits accessor threading
  • src/auto-reply/reply/agent-runner-execution.ts — accessor wiring
  • src/agents/pi-embedded-subscribe.handlers.tools.tsask_user_question runtime intercept

Carry-forward / deferred

  • planMode.autoEnableFor runtime wiring → [Plan Mode FULL]
  • Plan-archetype auto-detection (from skill metadata) → follow-up
  • approvalTimeoutSeconds cron watchdog → [Plan Mode FULL]
  • True edit-and-approve (modified step list at approval time, vs current "approve verbatim") → follow-up (PR-8 review fix Codex P1 #3098235203 — Decision C option (b))
  • Telegram document-attachment delivery for persisted plan markdown → [Plan Mode 5/6] (gated on upstream Telegram SDK surface re-add)

@greptile-apps

greptile-apps Bot commented Apr 22, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds the advanced plan-mode interaction layer: the ask_user_question tool (clarifying questions routed through the approval-card pipeline without exiting plan mode), plan_mode_status (self-diagnosis), plan archetypes (prompt steering for decision-complete plans), and the accept-edits gate (three-constraint runtime enforcement for auto-edit permission). The implementation is thorough and well-tested, with particular attention to security hardening (TOCTOU-safe file writes, symlink rejection, path traversal guards, and layered shell-escape detection in the accept-edits gate).

Confidence Score: 4/5

Safe to merge with minor issues worth addressing before or shortly after landing.

All three findings are P2. The legacy option-validation gap is a real behavioral hole but only affects sessions persisted before the pendingQuestionOptions field was introduced, and the new pendingInteraction path handles it correctly. The quote-concatenation escape pattern is intentionally conservative but lacks a false-positive test. The missing i flag on DESTRUCTIVE_FIND_FLAGS is a minor inconsistency. None block the primary plan-mode paths.

src/gateway/sessions-patch.ts (legacy answer-validation gap) and src/agents/plan-mode/accept-edits-gate.ts (escape pattern breadth + missing /i flag).

Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/gateway/sessions-patch.ts
Line: 705-716

Comment:
**Option validation skipped for legacy sessions with no stored options**

When `resolvePendingQuestionState` resolves via the `"legacy"` path, `pendingQuestion.options` is `entry.pendingQuestionOptions ?? []`. For sessions persisted before `pendingQuestionOptions` was introduced, this is an empty array, so `offeredOptions.length > 0` is `false` and the inner rejection never fires — any freetext answer passes through even when `allowFreetext` is `false`. A text-channel user on such a legacy session could submit arbitrary answer text that bypasses the contract the agent specified.

The guard should either (a) treat an empty stored options list as "no options known, skip validation" (i.e., current behaviour, documented explicitly), or (b) reject the answer entirely when `allowFreetext === false && offeredOptions.length === 0`, since accepting unvalidated text when the question was configured as fixed-choice is semantically wrong.

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

---

This is a comment left during a code review.
Path: src/agents/plan-mode/accept-edits-gate.ts
Line: 161-175

Comment:
**Quote-concatenation escape pattern blocks legitimate commands**

The pattern `/["'][a-z]["']["'][a-z]["']/i` (line 167) matches any adjacent single-character quoted tokens, not only those that reconstruct a destructive verb. For example, `echo "a""b"` or `python -c "x""=1"` would be blocked under `acceptEdits`, with no path to allow them short of asking the user for explicit confirmation.

The "false-positive discipline" test section in the companion test file doesn't cover adjacent-quoted non-destructive commands, so this over-blocking is undetected by the test suite. Given that `acceptEdits` is an elevated permission the user explicitly granted, surprising blocks on innocuous commands could be jarring. Consider narrowing the pattern to only flag adjacent quoted fragments that spell known destructive verbs (e.g., cross-reference with `DESTRUCTIVE_VERBS_FOR_ESCAPE_DETECTION`), or add a test + comment explicitly acknowledging this trade-off.

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

---

This is a comment left during a code review.
Path: src/agents/plan-mode/accept-edits-gate.ts
Line: 106-115

Comment:
**`DESTRUCTIVE_FIND_FLAGS` patterns lack the `/i` flag, inconsistent with peer patterns**

`DESTRUCTIVE_SQL_PATTERNS` and `DESTRUCTIVE_ESCAPE_PATTERNS` all use the `/i` (case-insensitive) flag. `DESTRUCTIVE_FIND_FLAGS` does not. The `-delete` flag and `-exec` are always lowercase in practice, but the inconsistency creates an undocumented assumption. More concretely, `find /tmp -type f -DELETE` (hypothetical) would slip through. Adding `/i` to these regexes aligns them with the rest of the pattern suite and removes the silent assumption.

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

Reviews (1): Last reviewed commit: "feat(plan-mode): split PR4 advanced plan..." | Re-trigger Greptile

Comment on lines +705 to +716
const allowFreetext = pendingQuestion.allowFreetext;
if (!allowFreetext) {
const offeredOptions = pendingQuestion.options;
if (offeredOptions.length > 0 && !offeredOptions.includes(answer)) {
return invalid(
`planApproval action="answer" rejected: answer "${answer}" not in offered options [${offeredOptions
.map((o) => `"${o}"`)
.join(
", ",
)}] (the agent disabled free-text for this question — pick one of the offered options exactly)`,
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Option validation skipped for legacy sessions with no stored options

When resolvePendingQuestionState resolves via the "legacy" path, pendingQuestion.options is entry.pendingQuestionOptions ?? []. For sessions persisted before pendingQuestionOptions was introduced, this is an empty array, so offeredOptions.length > 0 is false and the inner rejection never fires — any freetext answer passes through even when allowFreetext is false. A text-channel user on such a legacy session could submit arbitrary answer text that bypasses the contract the agent specified.

The guard should either (a) treat an empty stored options list as "no options known, skip validation" (i.e., current behaviour, documented explicitly), or (b) reject the answer entirely when allowFreetext === false && offeredOptions.length === 0, since accepting unvalidated text when the question was configured as fixed-choice is semantically wrong.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gateway/sessions-patch.ts
Line: 705-716

Comment:
**Option validation skipped for legacy sessions with no stored options**

When `resolvePendingQuestionState` resolves via the `"legacy"` path, `pendingQuestion.options` is `entry.pendingQuestionOptions ?? []`. For sessions persisted before `pendingQuestionOptions` was introduced, this is an empty array, so `offeredOptions.length > 0` is `false` and the inner rejection never fires — any freetext answer passes through even when `allowFreetext` is `false`. A text-channel user on such a legacy session could submit arbitrary answer text that bypasses the contract the agent specified.

The guard should either (a) treat an empty stored options list as "no options known, skip validation" (i.e., current behaviour, documented explicitly), or (b) reject the answer entirely when `allowFreetext === false && offeredOptions.length === 0`, since accepting unvalidated text when the question was configured as fixed-choice is semantically wrong.

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

Comment on lines +161 to +175
// Quote concatenation: `"r""m" file`, `'r''m' file`. The
// concatenation of adjacent quoted fragments that together
// spell a destructive verb — catches the common "r""m" /
// "rm"+"" / "r"m patterns. Intentionally conservative —
// matches when adjacent quoted tokens start with the first
// letter of a destructive verb and can reconstruct into it.
/["'][a-z]["']["'][a-z]["']/i,
// Hex-encoded destructive verbs: `\x72m`, `\x72\x6d`. A
// destructive verb's first letter is `\xNN` followed by the
// remainder. Conservative — also flags any `\xNN` byte escape
// inside an exec command, which is itself highly suspicious
// under acceptEdits.
/\\x[0-9a-f]{2}/i,
// Octal-encoded bytes (e.g., `\162m`).
/\\[0-7]{3}/,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Quote-concatenation escape pattern blocks legitimate commands

The pattern /["'][a-z]["']["'][a-z]["']/i (line 167) matches any adjacent single-character quoted tokens, not only those that reconstruct a destructive verb. For example, echo "a""b" or python -c "x""=1" would be blocked under acceptEdits, with no path to allow them short of asking the user for explicit confirmation.

The "false-positive discipline" test section in the companion test file doesn't cover adjacent-quoted non-destructive commands, so this over-blocking is undetected by the test suite. Given that acceptEdits is an elevated permission the user explicitly granted, surprising blocks on innocuous commands could be jarring. Consider narrowing the pattern to only flag adjacent quoted fragments that spell known destructive verbs (e.g., cross-reference with DESTRUCTIVE_VERBS_FOR_ESCAPE_DETECTION), or add a test + comment explicitly acknowledging this trade-off.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/plan-mode/accept-edits-gate.ts
Line: 161-175

Comment:
**Quote-concatenation escape pattern blocks legitimate commands**

The pattern `/["'][a-z]["']["'][a-z]["']/i` (line 167) matches any adjacent single-character quoted tokens, not only those that reconstruct a destructive verb. For example, `echo "a""b"` or `python -c "x""=1"` would be blocked under `acceptEdits`, with no path to allow them short of asking the user for explicit confirmation.

The "false-positive discipline" test section in the companion test file doesn't cover adjacent-quoted non-destructive commands, so this over-blocking is undetected by the test suite. Given that `acceptEdits` is an elevated permission the user explicitly granted, surprising blocks on innocuous commands could be jarring. Consider narrowing the pattern to only flag adjacent quoted fragments that spell known destructive verbs (e.g., cross-reference with `DESTRUCTIVE_VERBS_FOR_ESCAPE_DETECTION`), or add a test + comment explicitly acknowledging this trade-off.

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

Comment on lines +106 to +115
const DESTRUCTIVE_SQL_PATTERNS: readonly RegExp[] = [
/\bDROP\s+TABLE\b/i,
/\bDROP\s+DATABASE\b/i,
/\bDROP\s+SCHEMA\b/i,
/\bDELETE\s+FROM\b/i,
/\bTRUNCATE\s+(TABLE\s+)?/i,
// Redis
/\bFLUSHALL\b/i,
/\bFLUSHDB\b/i,
];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 DESTRUCTIVE_FIND_FLAGS patterns lack the /i flag, inconsistent with peer patterns

DESTRUCTIVE_SQL_PATTERNS and DESTRUCTIVE_ESCAPE_PATTERNS all use the /i (case-insensitive) flag. DESTRUCTIVE_FIND_FLAGS does not. The -delete flag and -exec are always lowercase in practice, but the inconsistency creates an undocumented assumption. More concretely, find /tmp -type f -DELETE (hypothetical) would slip through. Adding /i to these regexes aligns them with the rest of the pattern suite and removes the silent assumption.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/plan-mode/accept-edits-gate.ts
Line: 106-115

Comment:
**`DESTRUCTIVE_FIND_FLAGS` patterns lack the `/i` flag, inconsistent with peer patterns**

`DESTRUCTIVE_SQL_PATTERNS` and `DESTRUCTIVE_ESCAPE_PATTERNS` all use the `/i` (case-insensitive) flag. `DESTRUCTIVE_FIND_FLAGS` does not. The `-delete` flag and `-exec` are always lowercase in practice, but the inconsistency creates an undocumented assumption. More concretely, `find /tmp -type f -DELETE` (hypothetical) would slip through. Adding `/i` to these regexes aligns them with the rest of the pattern suite and removes the silent assumption.

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds “advanced plan-mode interactions” to the plan-mode stack: clarifying-question flow via the approval pipeline, plan-mode self-introspection, plan archetype prompting + persistence, and post-approval “accept edits” constraints.

Changes:

  • Extend sessions.patch handling to support plan-mode state transitions, plan approvals, question-answer routing, and queued next-turn injections.
  • Add new plan-mode tools (ask_user_question, plan_mode_status, exit_plan_mode) and wire them into tool catalog + runtime interception.
  • Introduce plan archetype prompt + markdown persistence, and enforce accept-edits constraints during post-approval execution.

Reviewed changes

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

Show a summary per file
File Description
src/gateway/sessions-patch.ts Implements plan-mode toggle/approval/question resolution and injection-queue writes.
src/gateway/sessions-patch.test.ts Adds unit coverage for plan auto-mode, approval dedup, and question answer validation.
src/gateway/protocol/schema/sessions.ts Extends sessions.patch schema with planMode/planApproval/lastPlanSteps fields.
src/config/sessions/types.ts Adds session persistence types for pending interactions, injection queue, and post-approval permissions; adjusts token helpers.
src/auto-reply/reply/agent-runner-execution.ts Consumes pending agent injections at turn start; wires fresh plan-mode/acceptEdits reads; adjusts compaction notice handling.
src/agents/tools/exit-plan-mode-tool.ts Adds exit_plan_mode tool with subagent gate + archetype field parsing.
src/agents/tools/exit-plan-mode-tool.test.ts Tests exit-plan-mode subagent gate and archetype-field parsing behaviors.
src/agents/tools/ask-user-question-tool.ts Adds ask_user_question tool with options validation and deterministic questionId.
src/agents/tools/ask-user-question-tool.test.ts Tests schema/validation, duplicate option rejection, and deterministic IDs.
src/agents/tool-description-presets.ts Adds/updates plan-mode tool display summaries and detailed descriptions.
src/agents/tool-catalog.ts Registers plan-mode tools (including ask_user_question) in the tool catalog.
src/agents/plan-mode/plan-archetype-prompt.ts Adds plan archetype system prompt fragment + filename/slug helpers.
src/agents/plan-mode/plan-archetype-prompt.test.ts Tests prompt fragment content and filename/slug helpers.
src/agents/plan-mode/plan-archetype-persist.ts Persists plan archetype markdown to disk with collision handling + path hardening.
src/agents/plan-mode/plan-archetype-persist.test.ts Tests persistence, collisions, traversal rejection, and recoverable storage errors.
src/agents/plan-mode/accept-edits-gate.ts Implements accept-edits constraint gate + apply_patch target-path extraction.
src/agents/plan-mode/accept-edits-gate.test.ts Adversarial tests for destructive/self-restart/config-change detection and move-path parsing.
src/agents/pi-embedded-subscribe.handlers.tools.ts Intercepts plan-mode tools, persists plan-mode state, emits approval/question events, auto-approve, and nudges.
src/agents/pi-embedded-runner/run/attempt.ts Prepends plan-mode rules/archetype/reference-card content into system prompt; threads planMode/getLatest* to hooks.
src/agents/openclaw-tools.ts Registers plan-mode tools when feature-enabled and threads run/session context.
Comments suppressed due to low confidence (1)

src/config/sessions/types.ts:742

  • resolveSessionTotalTokens appears to have been removed/renamed, but it’s still imported/used elsewhere (e.g. src/commands/sessions.ts and src/commands/status.summary.ts). This will break builds unless all call sites are updated in the same stack step. Consider reintroducing resolveSessionTotalTokens() as a thin alias (non-fresh semantics) or update all call sites to the new name in this PR/stack layer.
export function resolveFreshSessionTotalTokens(
  entry?: Pick<SessionEntry, "totalTokens" | "totalTokensFresh"> | null,
): number | undefined {
  const total = entry?.totalTokens;
  if (typeof total !== "number" || !Number.isFinite(total) || total < 0) {
    return undefined;
  }
  if (entry?.totalTokensFresh === false) {
    return undefined;
  }
  return total;

Comment on lines 1319 to +1355
if (evt.stream === "compaction") {
const phase = readStringValue(evt.data.phase) ?? "";
if (phase === "start") {
// Keep custom compaction callbacks active, but gate the
// fallback user-facing notice behind explicit opt-in.
const notifyUser =
runtimeConfig?.agents?.defaults?.compaction?.notifyUser === true;
if (params.opts?.onCompactionStart) {
await params.opts.onCompactionStart();
} else if (shouldNotifyUserAboutCompaction) {
} else if (notifyUser && params.opts?.onBlockReply) {
// Send directly via opts.onBlockReply (bypassing the
// pipeline) so the notice does not cause final payloads
// to be discarded on non-streaming model paths.
await sendCompactionNotice("start");
}
}
if (phase === "end") {
const completed = evt.data?.completed === true;
if (completed) {
attemptCompactionCount += 1;
if (params.opts?.onCompactionEnd) {
await params.opts.onCompactionEnd();
} else if (shouldNotifyUserAboutCompaction) {
await sendCompactionNotice("end");
const currentMessageId =
params.sessionCtx.MessageSidFull ?? params.sessionCtx.MessageSid;
const noticePayload = params.applyReplyToMode({
text: "🧹 Compacting context...",
replyToId: currentMessageId,
replyToCurrent: true,
isCompactionNotice: true,
});
try {
await params.opts.onBlockReply(noticePayload);
} catch (err) {
// Non-critical notice delivery failure should not
// bubble out of the fire-and-forget event handler.
logVerbose(
`compaction start notice delivery failed (non-fatal): ${String(err)}`,
);
}
} else if (shouldNotifyUserAboutCompaction) {
await sendCompactionNotice("incomplete");
}
}
const completed = evt.data?.completed === true;
if (phase === "end" && completed) {
attemptCompactionCount += 1;
await params.opts?.onCompactionEnd?.();
}

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

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

Compaction user-notification behavior regressed: when agents.defaults.compaction.notifyUser is enabled and no onCompactionEnd callback is provided, the code sends the start notice but no longer sends the completion/incomplete notice. This contradicts existing tests and documented behavior (expects both “Compacting…” and “Compaction complete”). Restore end/incomplete notices (or update tests + schema help consistently).

Copilot uses AI. Check for mistakes.
* Per-variant requirements:
* - `approve` / `edit`: only `approvalId` (optional but
* recommended for staleness protection).
* - `reject`: optional `feedback` (capped to 8 KiB to bound

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

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

The inline documentation contradicts the schema: it says reject has optional feedback, but the discriminated-union schema makes feedback required for action: "reject". Please align the comment with the actual contract so API consumers don’t implement the wrong behavior.

Suggested change
* - `reject`: optional `feedback` (capped to 8 KiB to bound
* - `reject`: REQUIRES `feedback` (capped to 8 KiB to bound

Copilot uses AI. Check for mistakes.
Comment on lines 18 to 34
@@ -19,6 +34,7 @@ import {
} from "../auto-reply/thinking.js";

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

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

This file declares planApprovalGateLog between import blocks and then continues with more static import statements. While valid JS, it violates the repo’s established import ordering convention (all imports first, then constants) and can trip import-sorting lint rules. Move the logger initialization below the full import section.

Copilot uses AI. Check for mistakes.
Comment on lines +7 to +18
// Live-test iter-3 R6a: always-on logger for the tool-side subagent
// gate at exit_plan_mode. Mirrors the iter-2 `gateway/plan-approval-gate`
// diagnostic so operators can see EVERY gate decision (including
// silent-bypass cases like missing runId or unregistered ctx) without
// flipping the env-gated plan-mode debug log.
const exitPlanGateLog = createSubsystemLogger("agents/exit-plan-gate");
import { stringEnum } from "../schema/typebox.js";
import {
describeExitPlanModeTool,
EXIT_PLAN_MODE_TOOL_DISPLAY_SUMMARY,
} from "../tool-description-presets.js";
import { type AnyAgentTool, ToolInputError, readStringParam } from "./common.js";

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

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

Same import-ordering issue as in sessions-patch.ts: exitPlanGateLog is initialized before later static imports. To match repo conventions (and avoid import-order lint failures), keep all imports together at the top and move exitPlanGateLog initialization below them.

Copilot uses AI. Check for mistakes.
Comment on lines +167 to +176
/["'][a-z]["']["'][a-z]["']/i,
// Hex-encoded destructive verbs: `\x72m`, `\x72\x6d`. A
// destructive verb's first letter is `\xNN` followed by the
// remainder. Conservative — also flags any `\xNN` byte escape
// inside an exec command, which is itself highly suspicious
// under acceptEdits.
/\\x[0-9a-f]{2}/i,
// Octal-encoded bytes (e.g., `\162m`).
/\\[0-7]{3}/,
];

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

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

The “byte-escape” detection is currently overly broad: /\\x[0-9a-f]{2}/i (and the octal escape regex) will block any command containing an escape sequence (e.g. printf "\x1b[31m"), even when it isn’t related to destructive verbs. This doesn’t match the surrounding comment that says the escape checks are “near a destructive verb” and risks false positives during normal execution. Consider tightening these patterns to only match escapes that could encode destructive verbs (or only apply them when other destructive indicators are present).

Copilot uses AI. Check for mistakes.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2cca8bfb0e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

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

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

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

Comment on lines +266 to +269
return true;
}
const needle = `${prefix} `;
return cmd.startsWith(needle);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Badge Block destructive commands when wrapped by prefixes

The destructive-action gate only matches when a destructive verb is the first token (cmd.startsWith("rm "), etc.), so commands like sudo rm -rf ... (or other wrappers such as env ... rm) bypass the hard constraint even when acceptEdits is enabled. In those sessions this allows destructive filesystem actions that are supposed to require explicit user confirmation; the matcher should normalize/unwrap common command prefixes or scan command tokens beyond position 0.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Real concern, acknowledged. Not fixed in this immediate cycle (we're in cleanup-mode focused on maintainer-takeover; see #70101 for context). Tracked in the umbrella's deferred-items checklist for the bot-triage follow-up cycle. The fix will land on this branch with a Fixed in {SHA} reply here per the standard pr-review-loop pattern. Maintainers can also opt to merge the per-part PR as-is and address in a post-merge polish cycle.

Comment on lines +423 to +424
if (!prefix.startsWith("~/") && absoluteForm.startsWith(prefix)) {
return matchedProtectedPath(filePath, prefix);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Badge Protect config directory roots, not only descendants

Protected-path checks only use startsWith(prefix) where prefixes are declared with trailing slashes, so exact root targets like ~/.openclaw or /etc/openclaw do not match and are allowed. Under acceptEdits, a write/delete/edit against those exact directory roots should still be blocked as a config-change action; add an equality check for the slash-trimmed root in addition to descendant-prefix matching.

Useful? React with 👍 / 👎.

@100yenadmin

Copy link
Copy Markdown
Contributor Author

Acknowledging Greptile / Copilot / Codex review feedback on this PR. Triage status:

  • Stack-coordination concerns (e.g. unresolved imports referencing symbols added by earlier per-part PRs in the stack) — expected by design. Red CI on this PR is per the rollout plan; CI turns green as the chain merges in sequence (1/6 → 6/6). See the stack-position header banner at the top of this PR body.
  • Real source-code issues flagged here (P1 bugs, P2 nits, etc.) — will be triaged + fixed in a focused follow-up cycle within ~24h. Fix SHAs will be posted as in-line replies on each individual comment per the standard pr-review-loop pattern.

For end-to-end integration testing in the meantime, see #70071 [Plan Mode FULL] — the green-CI integrated bundle of all parts.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 567f96cc0e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

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

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

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

Comment on lines +1855 to +1859
emitAgentApprovalEvent({
runId: ctx.params.runId,
...(ctx.params.sessionKey ? { sessionKey: ctx.params.sessionKey } : {}),
data: questionApprovalData,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Badge Persist question state before emitting approval cards

This branch emits the ask_user_question approval event immediately but never persists a matching pending-question record first, unlike the exit_plan_mode path just above. The new answer handler now hard-rejects planApproval.action="answer" when no pending question state exists, and there is no writer for pendingInteraction/pendingQuestionApprovalId in this commit, so user clicks can be rejected as “no pending ask_user_question” and the question flow becomes unusable.

Useful? React with 👍 / 👎.

Comment on lines +378 to +381
if (seg === "..") {
if (stack.length > 1 || (stack.length === 1 && stack[0] !== "")) {
stack.pop();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Badge Block protected config paths reached via relative traversal

collapse() drops leading .. segments when there is no prior stack, so paths like ../.openclaw/config.toml become .openclaw/config.toml before prefix checks. In a normal repo working directory under $HOME, that relative path resolves into the protected config directory, but checkProtectedPath will miss it and allow the write under acceptEdits, bypassing a hard config-change constraint.

Useful? React with 👍 / 👎.

…m state (v4)

Cherry-pick / file-copy of 23 files from feat/plan-channel-parity-merged-upstream
(merged-head 651a22d) onto current upstream/main. Branch: isolated/pm-3-of-6-advanced.

v4 absorbs the upstream merge into plan-mode dev (Option B; merge commit
b1ead74) plus the post-merge build/test fixes (commit 651a22d).

v4 scope additions: src/agents/context-file-injection-scan.{ts,test.ts}, src/auto-reply/reply/commands-system-prompt.ts
@100yenadmin
100yenadmin force-pushed the isolated/pm-3-of-6-advanced branch from 567f96c to 09d9612 Compare April 22, 2026 14:36

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 09d961276b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

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

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

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

Comment on lines +470 to +472
const configChange = checkConfigChange(cmd);
if (configChange) {
return configChange;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Badge Detect config-path writes in exec commands

The config-change gate only inspects exec/bash via checkConfigChange regexes (openclaw config ..., doctor --fix) and never applies protected-path checks to shell commands, so writes like echo x > ~/.openclaw/config.toml or sed -i ... /etc/openclaw/... pass under acceptEdits even though they are config mutations the policy says to block. This creates a direct bypass of the config constraint for any mutation done through shell redirection or tools like tee/sed.

Useful? React with 👍 / 👎.

@prtags

prtags Bot commented Apr 23, 2026

Copy link
Copy Markdown

Related work from PRtags group ace-bullfrog-hw3e

Title: Open PR candidate: plan-mode carve-out overlaps integrated full bundle

Number Title
#70031 [Plan Mode 1/6] Plan-state foundation
#70066 [Plan Mode 2/6] Core backend MVP
#70067* [Plan Mode 3/6] Advanced plan interactions
#70068 [Plan Mode 4/6] Web UI + i18n
#70069 [Plan Mode 5/6] Text channels + Telegram
#70070 [Plan Mode 6/6] Docs, QA, and help
#70071 [Plan Mode FULL] Integrated bundle for testing (Parts 1\u20136 + automation + executing-state lifecycle)
#70088 [Plan Mode INJECTIONS] Typed pending-injection queue foundation
#70089 [Plan Mode AUTOMATION] Cron nudges + auto-enable + subagent follow-ups

* This PR

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

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants