Files
OpenResearch/ui
Daniel Kim 874e84080c Plan UI: docked approval strip + right-pane plan tab (#83)
* Plan gate: read-only git verbs + pipelines into pure consumers + 2>&1

Planning agents read experiment code that lives only on git branches
(git show <ref>:<file>, git ls-tree) and habitually pipe output through
head/grep with a stderr-merge — all of which the gate rejected outright
via its whole-command metachar blacklist. Rework classification to be
per-pipeline-stage:

- segments (;, &&) split into pipe stages; stage 0 must be a read-only
  producer (orx read, git read, glue, or pure consumer), later stages
  pure consumers (head/tail/grep/wc/cat/sort/uniq/cut/tr/nl/column —
  tee/xargs/sed/awk deliberately excluded)
- standalone 2>&1 tokens dropped; any other token carrying > or & (or
  backticks/$(/</newlines anywhere) still gates the whole line
- read-only git allowlist: whole-verb reads (status/log/show/diff/grep/
  ls-tree/...) with --output and -O/--open-files-in-pager escape-hatch
  guards; conditional forms for branch/tag (list-query only), stash
  (list/show), remote (bare/show/get-url), worktree (list), reflog;
  global flags limited to -P/--no-pager/-C (rejecting -c config
  injection); git config excluded wholesale
- command_is_readonly exported for reuse by the upcoming MCP permission
  bridge auto-policy

Fail-safe allowlist property preserved: unknown programs, verbs, or
unparseable forms gate the entire command.

* Plan mode: synthesize a plan card when ExitPlanMode never fires + accept-edits option

Observed failure: a plan-mode session ended with the plan as plain chat
text and no ExitPlanMode call — and since only a plan-card answer
switches the resume mode, the user's chat 'yes' resumed still in plan
mode, stranding the session read-only. Root cause of the missing call:
the shared playbook still said 'Never call an interactive
question/elicitation tool — it will hang', guidance that predates the
prompt-card work in #37/#40.

- run_turn tracks (plan_mode, saw_prompt, turn_errored, last_text) and
  synthesizes a WirePrompt{kind:plan, synthesized:true} from the final
  text when a plan-mode turn ends card-less and error-free; the pure
  condition fn is unit-tested
- WirePrompt/ChatPrompt gain ; the UI softens the header to
  'Plan mode — ready to proceed?' for synthesized cards
- playbook: interactive tools surface as cards now; in plan mode, always
  present the finished plan via ExitPlanMode
- plan card gains 'Approve & accept edits' (resumeMode accept-edits —
  synthesize_resume already honors it)

* chore: drop stray npm lockfile (ui uses pnpm)

* Plan mode: MCP permission bridge — desktop-style mid-turn approvals

Headless plan mode had no approval loop: gray-area tools hard-denied
with nobody to ask, WebFetch was denied (verified on claude 2.1.197),
and — worst — ExitPlanMode SELF-APPROVED headless ("User has approved
exiting plan mode", nobody asked), letting the model exit plan mode and
edit files without the user's say.

The fix is a permission bridge on Claude Code's (hidden but functional)
--permission-prompt-tool flag:

  claude ── --mcp-config ──▶ orx mcp-gate (stdio MCP server, hidden cmd)
                                 │ HTTP long-poll, 127.0.0.1:<up port>
                                 ▼
  orx up: POST /api/internal/permissions ─ oneshot ─▶ card (SSE)
          user answers /respond ─▶ ResumeAction::Handled ─▶ claude unblocks

- src/commands/mcp_gate.rs: newline JSON-RPC stdio server (initialize /
  tools/list / tools/call), concurrent calls, single stdout writer; any
  transport failure answers deny — never hangs claude
- ChatHost: request_permission (token gate -> tier-1 auto-policy ->
  card + held oneshot, 55-min timeout under MCP_TOOL_TIMEOUT=60min),
  settle_permission, per-session drain on turn end/interrupt, drop-guard
  so an abandoned request always resolves its card
- auto-policy (pure fn, tested): read-only Bash allow (shared
  plan_gate classifier), WebFetch/WebSearch allow, file edits DENY —
  with a permission tool configured the CLI delegates plan mode's edit
  block to it (verified: an allow creates files mid-plan), so that deny
  IS the safety
- plan_gate hook now also matches ExitPlanMode and answers "ask",
  routing plan approval to the bridge card instead of self-approval;
  without a bridge, ask denies headless — still better than self-approve
- claude.rs: write_mcp_config + flags wired per plan-mode turn
  (best-effort; older CLIs silently ignore the flag and degrade to
  today's gating), ExitPlanMode tool_use renders as a plain tool row
  while the bridge card is authoritative, bridge cards count as
  "saw a prompt" for the synthesized-card fallback
- resume_from_prompt: native (mid-turn) branch — permission allow/deny
  and plan keep-planning settle the held request in place (Handled);
  plan approve interrupts the paused plan turn and resumes via the
  proven --resume + mode-flip machinery (respond()'s SendMessage arm
  now interrupts first for native cards only)

Empirical grounding (claude 2.1.197 spikes): denials don't fail headless
runs (permission_denials + subtype success); WebFetch denied natively in
plan mode; hook allow bypasses the permission tool (no card spam);
bridge tool timeout = tool error, turn continues; hook "ask" on
ExitPlanMode routes it through the bridge and an allow resumes the same
turn.

* Plan UI: docked approval strip + right-pane plan tab

The plan card scrolls away with the transcript and renders the whole
plan inline. Desktop-style flow instead:

- PlanStrip (new): docked between the thread and the composer while a
  plan awaits a decision — "View plan" / "Keep planning" / "Approve &
  run" with a split-menu for accept-edits / bypass. Keep-planning
  consumes the composer draft as the refinement note, so typing
  feedback then clicking the button is the natural gesture. Solid card
  floated over the composer's gradient fade; disappears on resolve via
  the SSE re-emit.
- Plan tab: "View plan" opens the markdown as a right-pane ClosableTab
  (PlanViewDef in the rightTab union, one tab per prompt id, no fetch —
  the text is already client-side); file links inside the plan resolve
  against the plan's session worktree.
- The inline plan card goes compact while a strip host exists: clamped
  to ~9.5em with a fade, "View full plan" link, no buttons (the strip
  owns the actions; single source of truth). Without a strip host the
  card keeps its full button row.

* Store: stop normalizing claude-code plan sessions to auto

The retired-modes migration runs on every Store::open and still listed
'plan' for claude-code — written when plan mode was dropped (pre-#75).
Re-adding plan to options() without updating it meant picking Plan
silently degraded to Auto on the next request; the composer masked it by
re-sending the mode with every message, but any turn without an override
(API callers, resumes) ran in auto. Caught live in the plan-mode E2E.

* chore: bump version to 0.1.60
2026-07-16 11:17:35 -07:00
..
2026-07-06 20:05:41 -07:00
2026-07-08 18:23:16 -07:00
2026-07-06 20:05:41 -07:00
2026-07-06 20:05:41 -07:00