Skip to content

fix(lody): new Claude sessions start in Bypass, and a permission pick survives a session switch - #246

Merged
pythonlearner1025 merged 1 commit into
mainfrom
fix/lody-permission-mode
Sep 6, 2026
Merged

fix(lody): new Claude sessions start in Bypass, and a permission pick survives a session switch#246
pythonlearner1025 merged 1 commit into
mainfrom
fix/lody-permission-mode

Conversation

@pythonlearner1025

Copy link
Copy Markdown
Member

What part this touches

The chat surface in the webapp is Lody. We vendor Lody as a git subtree at
vendor/lody. The composer at the bottom of a session carries a permission
selector. It offers Auto, Manual, Accept Edits, Plan, Don't Ask and Bypass
Permissions.

Three parts decide which mode a session runs:

  • The composer's selector state, in use-acp-session-config-selection.ts.
  • The latest SENT turn, which stores its own run configuration in the session
    document.
  • A built-in table of per-agent defaults, in packages/shared/src/ai.ts.

The selector reads them in this order: an unsent pick, then the agent-confirmed
runtime value, then the latest sent turn, then the built-in default.

BlitzOS only edits vendor/lody at a seam declared in
vendor/lody/BLITZ-PATCHES.md. This PR declares seams 28 and 29.

The bug

Two faults, both reported from the product.

One: a new Claude session does not start in Bypass Permissions.
BUILTIN_DEFAULT_MODE_IDS sets claude: 'auto'
(vendor/lody/packages/shared/src/ai.ts:402). Our agent-config rows use
cliType: "builtin", so getBuiltinDefaultModeId returns that value. The
existing bypassPermissions pins in this repo cover only the terminal
(packages/box/rootfs/usr/local/bin/blitz-term:38) and Remote Control. They
never reach the Lody composer.

Two: a permission pick is lost when you leave the session. selectMode
writes an unsent edit into React state, and nothing else. Two things then erase
it. fenceAcpSessionUserEdits returns an empty set when the target key changes
(vendor/lody/packages/components/src/lib/acp-session-config-selection.ts:71).
A sidebar navigation also unmounts the surface, which destroys the state.

The picker then falls back to the durable value. That value comes only from the
latest SENT turn
(vendor/lody/packages/shared/src/session-input.ts:141). So the member sees
this: pick Bypass, switch session, come back, and the selector reads Auto again.

The fault looks intermittent, and it is not. Pick and SEND, and the mode sticks.
Pick and LEAVE, and the mode is gone.

The fix

Seam 28 — a host may name its own builtin default mode. Two hunks in
vendor/lody/packages/shared/src/ai.ts.

  • getBuiltinDefaultModeId reads an optional host override before the Lody
    table.
  • The override is absent by default. Then every upstream default is unchanged.
  • publishLodyLocalBridge publishes { claude: "bypassPermissions" } beside
    the __LODY_LOCAL_BRIDGE__ flag it already sets.
  • Its existing guarded disposer removes the override during a bridge hand-over.

Seam 29 — an unsent run-config pick survives leaving the session. Four hunks
in use-acp-session-config-selection.ts.

  • A bounded module cache holds unsent edits, keyed by target key.
  • Only a caller that passes preserveUnsentUserEdits: true uses the cache.
    draft-session-chat-interface.tsx does not pass it, so it keeps today's
    behaviour.
  • A target change stashes the outgoing edits, then seeds the incoming target
    from the cache.
  • One cleanup-only effect stashes the edits when the surface unmounts.
  • A restored pick passes through the existing fence. The fence drops the pick
    when the session's durable preference has captured it.
  • The cache holds eight targets. It evicts the oldest target first.

The risk trade

Bypass Permissions removes the human approval step for a new Claude session. The
member can still pick another mode, and that pick now survives. A box is an
isolated VM, and the terminal already runs Claude with
--dangerously-skip-permissions, so this makes the composer agree with the
terminal.

The seams cost upstream merge burden. Both are small and default-inert: 6 added
lines in ai.ts and 41 in one hook file. Absent host configuration leaves
upstream behaviour identical, so a conflict resolves by reapplying one predicate.

The module cache is process state, not React state. Its cleanup writes only the
cache and never calls setState, so it cannot restart the nested-update loop
that the hook's header comment describes.

The rejected alternative: push the pick to the daemon at pick time, through a
new set-mode RPC that writes the runtime-config snapshot. That fixes more —
the mode would reach the live agent without a turn — but it needs a new daemon
verb on a cross-runtime contract. applyAcpRuntimeConfigPatch also refuses to
persist for a session that has no user turn yet
(vendor/lody/apps/cli/src/lib/loro/doc.ts:2222), so a pick before the first
message still had nowhere to live.

Tests

Two new files in packages/webapp/test/, 2 tests.

  • lody-default-permission-mode.test.tsgetBuiltinDefaultModeId returns
    auto before publish, bypassPermissions while the bridge is published, and
    auto again after the disposer runs. The Codex default is untouched.
  • lody-unsent-run-config.test.tsx — drives the real hook. A pick survives an
    unmount and remount. A switch to another target shows no pick. A switch back
    restores it. A caller without preserveUnsentUserEdits retains nothing. A
    pick that the preferences have captured is dropped.

Both tests fail when the source change is reverted. Measured:

FAIL  test/lody-default-permission-mode.test.ts
AssertionError: expected 'auto' to be 'bypassPermissions'

FAIL  test/lody-unsent-run-config.test.tsx
AssertionError: expected 'none' to be 'bypassPermissions'

Gates:

  • typecheck — schema, webapp and box guest tests pass. Control-plane passes
    after its bindings are regenerated from wrangler.toml.example; the stale
    local wrangler.toml is machine noise, not a finding.
  • lint:gate — passed. 43 anti-slop findings, 0 blitz-house findings, every
    per-rule count equal to the baseline. 8 max-lines warnings, unchanged.
  • npm test -w @blitzos/webapp — 120 files passed, 12 skipped. 1008 tests
    passed, 1 expected fail, 62 skipped.
  • git diff -- tools/oxlint/anti-slop is empty.

One test on this branch is intermittent, and it is not this change:
lody-terminal-tab-wave3.test.tsx > F7 … is inert on an address with no session to be missing. Six full-suite runs isolate it.

Tree Result
clean origin/main pass
clean, plus one inert extra test file pass
seam 28 source, no new test files pass
seam 28 source, plus the new test file fail
this branch fail, then pass, then pass

The test asserts a pathname after settle(), which flushes exactly one
macrotask tick (test/dom.tsx:25). Its own file records that a sibling test in
the same describe was deleted for the same reason: it "failed one full-suite
run and passed the next two, while passing every time this file ran alone". This
branch does not touch that code. I left the test alone on purpose. Making it
settle longer could hide a real race in useLodyRail, and that deserves its own
change.

Deploy

The webapp ships inside the control-plane Worker. npm run deploy -w packages/control-plane builds it (.github/workflows/canary.yml:477). A merge
to main therefore deploys this to canary with no other step. No box image and
no payload change: this is browser code.

# canary, automatic on merge to main
npm run deploy -w packages/control-plane

Rollback: revert the commit and merge. Canary redeploys the previous webapp
bundle. Members get it on the next page load.

🤖 Generated with Claude Code

https://claude.ai/code/session_019ZvBaFqU4mgX75HAbiYJYV

… survives a session switch

Two faults in the composer's permission selector.

A new Claude session started in Auto. `BUILTIN_DEFAULT_MODE_IDS.claude` is
`auto`, and our agent-config rows are `cliType: "builtin"`, so the Lody table
won. The `bypassPermissions` pins in this repo reach only the terminal and
Remote Control.

A permission pick was also lost when the member left the session. `selectMode`
wrote an unsent edit into React state alone. A target change emptied it, and a
sidebar navigation unmounted the surface. The picker then fell back to the
latest SENT turn, so the old mode came back.

Seam 28 lets a host name its own builtin default mode. The override is absent by
default, so upstream behaviour does not change. The BlitzOS bridge publishes
`{ claude: "bypassPermissions" }` and its existing disposer removes it.

Seam 29 retains unsent run-config edits per target in a bounded module cache.
Only a caller that passes `preserveUnsentUserEdits` uses it. A restored pick
passes through the existing fence, so a pick the durable preference captured is
dropped.

Both seams are declared in vendor/lody/BLITZ-PATCHES.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ZvBaFqU4mgX75HAbiYJYV
@pythonlearner1025
pythonlearner1025 merged commit f49cffa into main Sep 6, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant