[#5153] feat(runner): park Pi approval gates over the ACP permission plane - #5185
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughAdds a shared Pi approval-gate envelope contract and routes Pi permission approvals through an extension UI dialog plane, alongside the existing Claude ACP and relay gates. Generalizes ChangesPi dialog gate approval parking
Estimated code review effort: 4 (Complex) | ~75 minutes Sequence Diagram(s)sequenceDiagram
participant Pi as Pi runtime
participant Extension as agenta.ts
participant User as ctx.ui.confirm dialog
participant Runner as sandbox_agent / acp-interactions
participant Responder as ApprovalResponder
Pi->>Extension: tool_call(ctx, toolCall)
Extension->>Extension: buildPiGateEnvelope(gate, toolName, input)
Extension->>User: ctx.ui.confirm(title, envelope message)
alt UI unavailable or error
Extension-->>Pi: deny (fail-closed)
else user responds
User-->>Extension: allow/deny
Extension-->>Pi: block or proceed via relay
end
Runner->>Runner: parsePiGateEnvelope(request)
Runner->>Runner: buildPiGateDescriptor(envelope, toolSpecs)
Runner->>Responder: onPermission(gate)
Responder-->>Runner: allow/deny/pendingApproval
Runner-->>Runner: pause with gateType (claude-acp-permission or pi-dialog-permission)
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
| ): Promise<void> => { | ||
| const toolCall = req?.toolCall; | ||
| if (toolCall && typeof toolCall === "object") { | ||
| toolCall.toolCallId = envelope.toolCallId; |
There was a problem hiding this comment.
Id normalization (B2): the pi-acp bridge delivers a synthetic pi-ui-<uuid> toolCallId; the envelope carries the real one. Swapping it in place here, before the gate descriptor, pause bookkeeping, the emitted card, and the park record read req.toolCall, is what lets the warm resume match the parked id (approvalDecisionForToolCall) and keeps pause suppression from mis-keying and clobbering the FE approval card. Stamping only the emitted payload would not be enough.
| if (piGate.matched) { | ||
| if (!piGate.envelope) { | ||
| log?.( | ||
| `[HITL] pi-gate malformed envelope id=${id}; reject (fail closed)`, |
There was a problem hiding this comment.
Fail closed (M4): a request whose title matches but whose envelope does not parse is rejected here, never allowed to fall through to the spec-less path below. Under a default-allow permission plan a fallthrough would resolve to allow and confirm an unapproved execution; strict-parse-then-reject makes the worst case a blocked tool call. rejectRequest answers reject WITHOUT creating/resolving a durable interaction (none was recorded for a request refused before classification).
| // so appending in those cases would leave a stale decision for a later gate. Builtins (executor | ||
| // "harness") have no relay second gate, so they are excluded too. | ||
| if ( | ||
| request.gate.executor === "relay" && |
There was a problem hiding this comment.
Double-gate accounting (M2): a pi-custom-tool dialog gate and the relay's execution check both decide() the same key. When the dialog answered from a STORED decision it consumed one queued entry, so the relay would find none and pause again. We re-append exactly that decision. Guarded to fire ONLY for executor 'relay' + effective 'ask' + a resolved allow/deny: a policy allow/deny consumes nothing (decide returns before stored.take), a pause consumes nothing, and builtins have no relay second gate. The warm-resume path never reaches here (the dialog is answered via respondPermission), so it seeds the relay via extractApprovalDecisions instead and does not double-append.
| if ( | ||
| !parked || | ||
| (parked.gateType !== "claude-acp-permission" && | ||
| parked.gateType !== "pi-dialog-permission") |
There was a problem hiding this comment.
Resume guard (B3): widened from the single Claude literal to accept the Pi dialog gate too. Both planes resume via respondPermission on the live session, and the sandbox-agent daemon maps the once/reject reply to the dialog option by kind, so no reply-type widening is needed. Without accepting pi-dialog-permission here a Pi park would fall to the 'unrecognized-gate-type' mismatch below and always degrade cold — fail-closed but never warm (the (b)-half of the plan's B3, which also widens the ParkedApproval union in sandbox_agent.ts).
0b6a8f3 to
19f8e5d
Compare
| // consumed nothing); relay executor only (builtins have no relay second gate); and only | ||
| // when the dialog-gate bridge is on (see ApprovalResponderOptions). | ||
| if ( | ||
| this.options.bridgeRelayDoubleGate && |
There was a problem hiding this comment.
Review-round tightening of the double-gate accounting: the re-append now (a) requires the explicit bridgeRelayDoubleGate option, set only on Pi runs with the dialog gate active (on Claude the relay never enforces, so an appended decision has no consumer and would leak an allow to a LATER identical call — a flag-off behavior change), and (b) fires for ALLOW only (on a deny the extension short-circuits without relaying, so an appended deny would auto-deny a later identical call that should re-prompt).
|
An independent correctness review (Opus subagent) ran against the initial commit. It confirmed the fail-closed malformed-envelope path, the id-normalization ordering, the client-tool exclusion, the gateType plumbing, and the runner-side metadata recovery, and found one real blocker in the double-gate accounting: the re-append fired for deny (which has no relay consumer, since the extension short-circuits on a deny) and was not scoped to Pi (a Claude relay-shaped ask gate answered from a stored decision would have appended an orphan allow, changing flag-off behavior). Both are fixed in the amended commit: the bridge is behind an explicit ApprovalResponderOptions flag set only for Pi dialog-gate runs, and it re-appends allow only. New tests pin the bridge-off (Claude-shaped) case and the deny no-append case. Stale comments the review flagged (the 'never mutated' note in acp-interactions.ts and the 'exactly one gate per call' note in sandbox_agent.ts) were corrected in the same amend. |
19f8e5d to
3fe24e1
Compare
|
Second review round (Codex xhigh, independent): no blockers, two must-fixes, both applied in the amended commit 3fe24e1. MF-1 (flag-off byte-identity): envelope detection ran on every ACP permission request and pre-filtered on the dialog title alone, so with the flag off a Claude gate whose title collided with the literal dialog title (editing a file with that name, a bash command equal to it) would have been auto-rejected where big-agents pauses or resolves it. Detection is now behind an explicit MF-2 (unknown builtin fail-closed): an envelope naming an unrecognized builtin fell through as a harness gate with the raw sandbox-supplied name, diverging from the relay (which denies unknown builtins) and letting an untrusted envelope put a fabricated name on the approval card. Nits included: an unanswerable (empty-id) malformed request now pauses instead of silently dropping (matching the base path, so the in-sandbox confirm dies with teardown rather than hanging); stale docs updated (the parkable-gate JSDoc, the Also recorded in the PR body follow-ups: the pre-existing seeded-decision lingering (one click can auto-resolve a later byte-identical call in the same resumed turn, both harnesses, big-agents behavior); candidate fix is consuming the parked gate's own decision on the resume branch. Tests: 769 pass (+4), same 2 known |
Live QA: Pi approval parking (slice 4)Ran the warm/cold matrix live on the Hetzner dev box, programmatically (direct Environment
Matrix resultsa) Warm approve (KA+DG on, within TTL): PASS. Initial turn parked warm, resume answered the held dialog, original call ran in the original turn. The resume executed the SAME toolCallId ( b) Warm deny: PASS. Resume answered reject, tool did not execute, turn continued live, no orphaned pending state. tool_result: c) Approve after TTL (cold): PASS. Approval TTL lowered to 8s for this run. Park expired, session destroyed, resume fell to the cold path, the durable decision ran the tool after replay, nothing crashed. The model re-issued the call (new toolCallId), the stored decision keyed by canonical name+args answered the re-fired gate d) Flag-off regression (DG off, KA on): PASS (Pi), Claude cross-check partial. Pi ask run with Cold resume completed and ran the tool ( Claude subscription ( Anomalies (non-blocking)
VerdictReady for review. The Pi approval-parking invariant holds live: warm approve resumes the original call in the original turn (no cold replay), warm deny blocks cleanly, TTL expiry falls to the durable cold path, and the flag off is inert. Suggested follow-up: pin one recorded warm-approve and one cold-approve as agent-replay regression tests (slice 4 note), and a one-line playground re-confirm of the Claude gate before flipping DG default-on. |
|
@coderabbitai review |
✅ Action performedReview finished.
|
There was a problem hiding this comment.
Actionable comments posted: 2
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 02dd9ccb-c1cf-4db6-971a-036e5d0b4a67
📒 Files selected for processing (13)
services/runner/src/engines/sandbox_agent.tsservices/runner/src/engines/sandbox_agent/acp-interactions.tsservices/runner/src/engines/sandbox_agent/pi-assets.tsservices/runner/src/engines/sandbox_agent/pi-gate-envelope.tsservices/runner/src/extensions/agenta.tsservices/runner/src/responder.tsservices/runner/src/server.tsservices/runner/tests/unit/extension-tools.test.tsservices/runner/tests/unit/pi-gate-envelope.test.tsservices/runner/tests/unit/responder.test.tsservices/runner/tests/unit/sandbox-agent-acp-interactions.test.tsservices/runner/tests/unit/sandbox-agent-pi-assets.test.tsservices/runner/tests/unit/session-keepalive-approval.test.ts
…on plane removed The dialog gate shipped as the unconditional Pi behavior (Mahmoud, 2026-07-10): no AGENTA_RUNNER_PI_DIALOG_GATE / AGENTA_AGENT_PI_DIALOG_GATE pair, gateType pi-acp-permission (symmetric with claude-acp-permission), the relay permission plumbing and the planned double-gate FIFO bridge deleted, builtin-only runs start no relay. plan.md carries the final mechanism and the reduced warm/cold matrix (keep-alive is the only remaining switch); research.md marks its §1/§7 relay permission mechanics as the replaced code; open-questions #4 (flag coupling) resolved by there being no flag; status.md records slice-0 evidence, the review rounds, and the scope change. Implementation: PR #5185. Claude-Session: https://claude.ai/code/session_01CSTSEXSe4DDhoXCFjZpZ5W
|
Scope change (Mahmoud): the dialog gate is now the ONLY Pi permission path, with no feature flag, and the old relay permission plumbing is deleted (commit 68c3176). What changed in this round:
Docs updated in the same window on the #5153 lane (plan.md final mechanism + reduced warm/cold matrix, research.md landed-note, open-questions #4 resolved, status.md provenance). Tests: 729 pass, tsc clean; the count went down because the dead permission-relay tests were deleted with their machinery. The 2 known sandbox-agent-qa-transcript-replay fixture failures remain pre-existing. |
Both Pi approval gates (builtin + custom-tool) stop expressing an approval as a file-relay poll and raise it as ctx.ui.confirm carrying a JSON envelope with the real gate identity. The pi-acp bridge turns that into a real ACP session/request_permission, which the runner classifies, decides, and (under keep-alive) parks and resumes on the live session, exactly like Claude's gate. Within the approval TTL a human's answer resumes the original tool call with its original arguments; outside it, everything degrades to today's cold path. Behind AGENTA_RUNNER_PI_DIALOG_GATE (default off); flag-off is byte-identical. - pi-gate-envelope.ts: the shared, strict, version-checked envelope contract. - acp-interactions.ts: envelope detection + real-id normalization + runner-side metadata recovery + fail-closed malformed reject + gateType plumbing. - responder.ts: FIFO appendDecision + consume-1-append-1 for the custom-tool double gate. - sandbox_agent.ts / server.ts: ParkedApproval gateType union + resume guard. - agenta.ts / pi-assets.ts: the extension switch and the flag env. Slice 0 (live daemon confidence run) passed both criteria on the dev box. Claude-Session: https://claude.ai/code/session_01CSTSEXSe4DDhoXCFjZpZ5W
… the relay permission plane The dialog gate is now the unconditional Pi behavior. AGENTA_RUNNER_PI_DIALOG_GATE and AGENTA_AGENT_PI_APPROVAL... flags are gone; envelope detection stays scoped to Pi runs structurally (the responder receives the resolved-specs map only on Pi runs, and its presence turns detection on), so a Claude gate whose title collides with the dialog title still takes the base path. Dead code removed with it: - extension relayPermissionCheck poll and its dispatch.ts plumbing - relay.ts permission plane: handlePermissionRelayRequest, the kind:permission record protocol, RelayPermissions and the watcher's decide enforcement - the double-gate FIFO bridge in ConversationDecisions (with the relay re-check gone, the dialog is the single enforcement point) - builtin-only runs start no relay (useToolRelay = custom tools only) Also: gateType renamed to pi-acp-permission (symmetric with claude-acp-permission); CodeRabbit fixes (a custom-tool envelope with no resolved spec fails closed like an unknown builtin; test title-sync nit); args validated before the dialog so a malformed call errors to the model instead of prompting a human. Claude-Session: https://claude.ai/code/session_01CSTSEXSe4DDhoXCFjZpZ5W
…xt-bound args from Pi approval gates Review findings on the parking change (codex P1/P2): P1: the relay dir is sandbox-writable, so an execute record forged with bash bypassed the in-sandbox dialog gate entirely once the relay's permission plane was deleted. The relay now re-checks every record runner-side (Pi runs only): author-allow executes, author-deny never does, and an ask tool executes only by consuming a per-turn grant recorded when the dialog gate (or a parked-approval resume) resolved that exact call to allow. Grants are keyed on approvedCallKey(name, args) with a count, so a forged or replayed record for an ask tool fails closed. P2: for a callRef tool with contextBindings, the approval card and the stored-decision key carried the model's values for bound paths the runner overwrites from runContext at execution — the human approved args that never run. buildPiGateDescriptor now strips bound paths (restored redaction helper), and the grant/consume sides key on the same redacted shape. Also: validate-before-dialog extension test, pi-coding-agent execute-arity contract comment, guard wiring pinned in orchestration tests. Claude-Session: https://claude.ai/code/session_012EjviaDzVYboubuwgESDwS
68c3176 to
3606e5d
Compare
|
Review-fix commit
740 unit tests pass, |
Brings in #5185 (Pi approval parking over the ACP permission plane). Conflicts in services/runner/src/{engines/sandbox_agent.ts,server.ts} were comment-only: #5185 rewords the parkable-gate doc blocks to cover the Pi ACP plane alongside Claude ACP, and adds piToolSpecsByName / onPiGateAllowed above one of them. Took the incoming wording; verified statically that no executable line from either side was dropped. The S5 sandbox lifecycle (reconnect, park-to-warm) and the Pi gate envelope coexist untouched.
Context
A Pi approval destroys the session the moment the gate fires. When a human clicks Approve, a fresh Pi session cold-replays a flattened text transcript and the model re-issues the tool call from that text as a NEW
tool_use. The re-issued call's arguments can drift from the ones the human actually approved, which is where both production Pi approval failures came from (argument drift, task restart). Claude's ACP gate already avoids this: keep-alive slice 2 (#5158) parks the live session with the permission request held open and answers it on the click, so the original call runs with its original arguments. Pi had no such path.This makes both Pi gates (the builtin gate and the custom-tool gate) parkable. Within the keep-alive approval TTL, a human's answer resumes the exact original tool call on the live session (the byte-exact tier). Outside it, everything degrades to today's cold decision-map path, unchanged.
The mechanism is the proven Option C of the parkable-gates design: the gate rides
ctx.ui.confirm(title, message)from the in-sandbox Pi extension, wheremessageis a JSON envelope carrying the real gate identity. Thepi-acpbridge turns that dialog into a real ACPsession/request_permission, which lands at exactly the responder seam that already holds, parks, and answers Claude gates. The relay keeps carrying tool execution and results. Only the approval wait moves.Changes
The dialog gate is the unconditional Pi behavior: no feature flag. The relay's old permission plane (the
kind: "permission"record protocol and its polling) is deleted in the same change; the dialog is where the human decision happens, and the relay carries tool execution and results. Because the relay dir is sandbox-writable, the relay does NOT trust that a record passed the dialog: an execution guard re-checks every record runner-side (see the review round below). Envelope detection on the runner is scoped to Pi runs structurally (the permission responder receives the resolved-specs map only on Pi runs, and its presence turns detection on), so Claude runs never enter it, including on a title collision.The envelope (one new sandbox-internal contract). New
pi-gate-envelope.ts, shared by the runner and the esbuild-bundled extension so build and parse stay one source of truth:{ "v": 1, "kind": "agenta.gate", "gate": "pi-builtin" | "pi-custom-tool", "toolName": "...", "toolCallId": "...", "input": { } }The bridge natively forwards only the dialog strings with a synthetic
pi-ui-<uuid>tool-call id, so the real identity ridesmessage. Parsing is strict and version-checked. A request whose title matches but whose envelope does not parse fails closed (reject), never falls through: under a default-allow plan a fallthrough would confirm an unapproved execution.Slice 1 (runner classification,
acp-interactions.ts). On an envelope hit, the synthetic tool-call id is normalized to the envelope's real id at the top ofhandleRequest, before the gate descriptor, the pause bookkeeping, the emitted approval card, and the park record read it. The gate descriptor is built from the envelope identity, and permission metadata (specPermission,readOnlyHint) is recovered runner-side by spec lookup (piBuiltinIdentityfor builtins), so an author-allow tool stays instant-allow, an author-deny tool stays instant-deny, and a read-only builtin auto-allows. The card'srawInputis set to the real args so it renders like a relay-gate card, with context-bound paths redacted (see the review round below).Slice 2 (extension switch,
agenta.ts+ dead code removal). Both gatesawait ctx.ui.confirm(PI_GATE_DIALOG_TITLE, envelope)instead of polling the relay. No dialogoptsare passed, so Pi arms no reaper and the wait parks indefinitely; any cancellation resolves it to a fail-closed block. Arguments are validated before the dialog, so a malformed call errors to the model instead of prompting a human. The custom-tool gate is scoped to non-client specs; client tools keep their browser-fulfilled relay path. The old permission plumbing is deleted: the extension'srelayPermissionCheckpoll and itsdispatch.tshelpers, the relay watcher's permission handling (handlePermissionRelayRequest, thekind: "permission"record protocol,RelayPermissionsand thepermissions.decidere-check on execution requests), and the planned double-gate bridge (with the relay re-check gone, nothing consumes it). A builtin-only run starts no relay at all (useToolRelayis now custom-tools-only).Slice 3 (park + resume,
sandbox_agent.ts+server.ts).ParkedApproval.gateTypeis widened to include"pi-acp-permission", the park records the plane, and theserver.tsresume guard accepts it. The reply is already correct (once/reject; the sandbox-agent daemon maps it to the dialog option by kind), so no reply-type widening. The pool, TTL, eviction, and multi-gate safe-degrade paths key onparkedApproval, not the harness, so they need no change.Review round (codex P1/P2): the relay execution guard and context-binding redaction.
P1 — the guard. The relay dir is sandbox-writable, so the model could forge an
<id>.req.jsonexecute record with bash and bypass the dialog gate entirely; with the old relay-side re-check deleted, a forged record for anaskordenycustom tool would have executed.startToolRelaynow takes aRelayExecutionGuard(Pi runs only) that re-checks every record runner-side: author-allow executes, author-deny never does, and anasktool executes only by consuming a per-turn grant (ApprovedExecutionGrants, keyed onapprovedCallKey(name, args)with a count). Grants are recorded at the two places an approval-equivalent allow is issued:handlePiGatewhen a custom-tool gate resolves to allow (author/policy/stored-decision — this also covers the cold path, where the re-issued call's dialog consumes the durable decision), and the keep-alive resume branch beforerespondPermission("once")(the resume turn has a fresh relay and ledger). A forged or replayed record fails closed with a deny result text; the guard'sdecide()runs over an empty stored-decision store so it can never race the dialog for the conversation's stored decisions. Claude runs are unchanged (their harness gates fire before the relay, exactly as before this PR).P2 — redaction. For a callRef tool with
contextBindings, the runner overwrites bound argument paths fromrunContextat execution, so the approval card and the stored-decision key must not carry the model's values for them (the human would approve args that never run — the same drift class this PR exists to kill, and the pre-#5185 relay path already redacted them).buildPiGateDescriptornow strips bound paths (restoredredactContextBoundArgs), the card'srawInputand the park record carry the redacted shape, and the guard's grant/consume sides key on the same redaction, so approval, display, decision key, and grant all agree.Before (cold): Pi calls a gated tool, the runner records a durable interaction, ends the turn paused, destroys the session; the human clicks Approve; a fresh session cold-replays and the model re-issues the call, which the decision map matches by name plus canonical arguments (and re-fires the gate on drift).
After (warm, within the TTL): the resume answers the held dialog, the hook returns allow, and the original call runs with its original arguments inside the original
prompt(). Call N+1 to the LLM carries the real result for the original id. Byte-exact.Scope / risk
AGENTA_RUNNER_SESSION_KEEPALIVE) still gates the pool and the parking: with it off, a Pi ask still gets the instant dialog decision and real card identity, and degrades to the cold durable-decision path. The live warm/cold matrix is the separate slice-4 QA stage./runwire SHAPE change. The envelope is sandbox-internal; theinteraction_requestevent and the golden fixtures / both contract tests are untouched. One value-semantics change: a Pi approval'sinteraction_request.idis now the ACP permission id (the tool-call id still ridespayload.toolCallId), matching what Claude approvals already emit; the old relay path used the tool-call id for both. The FE resolves cards by echoing the interaction id, so this aligns Pi with the path Claude already exercises.session/load, tier 2, owned by the harness-session-resume project), and anypi-acpupstream change (the envelope makes the current bridge sufficient; the structured-metadata field is a recorded follow-up).Tests / notes
pi-acpdirectly) on the codex subscription. The envelope arrived byte-exact at a runner-sideonPermissionRequesthandler (hostile probe string included), the daemon exposedavailableReplies: ["once","reject"], andrespondPermission(id, "once")resolved the held dialog to allow so the originalpark_proberan with its original token, both immediately and after a 180-second hold with no reaper. This also confirmed the daemon maps the reply by kind (no reply-mapping needed) and that the ACP request's toolCallId is the syntheticpi-ui-<uuid>while the envelope carries the real id (so the normalization is required).tests/unit/builtin-grant-list.test.ts— then up with the review-round coverage; the 2sandbox-agent-qa-transcript-replayfixture failures are pre-existing and unrelated).tsc --noEmitclean. New coverage: envelope build/parse incl. malformed fail-closed and the hostile probe string; id normalization everywhere the id is read; gate classification with runner-side metadata lookup; the Claude title-collision passthrough (a Claude run never enters envelope detection); the unknown-builtin and unresolved-custom-tool fail-closed rejects; the client-spec exclusion; the extension-level dialog gate; validate-before-dialog (a malformed call errors to the model, never prompts a human); the Pi dialog gate park + resume at the dispatch seam; the relay execution guard (tests/unit/tool-relay-guard.test.ts: forged record fails closed, grant consume-once, author allow/deny, Claude parity, redacted-grant matching); context-binding redaction at the descriptor, card, and park record.How to QA
Prerequisites: the local EE dev stack with keep-alive on.
load-env hosting/docker-compose/ee/.env.ee.devthenrun.sh --ee --dev. SetAGENTA_RUNNER_SESSION_KEEPALIVE=1on the runner sidecar and use a Pi agent whose tool or builtin has anaskpermission. The dialog gate itself needs no configuration (on by default for Pi).Steps:
Expected result: the approval card shows the real tool name and arguments (not
agenta-approval/ envelope JSON). After Approve, the ORIGINAL tool call runs with its original arguments in the same turn (no cold rebuild, no re-issued call). A Deny reports the call as blocked and the turn continues on the same session.Automated tests:
(Key files:
tests/unit/pi-gate-envelope.test.ts,tests/unit/sandbox-agent-acp-interactions.test.ts,tests/unit/tool-relay-guard.test.ts,tests/unit/responder.test.ts,tests/unit/extension-tools.test.ts,tests/unit/session-keepalive-approval.test.ts,tests/unit/sandbox-agent-pi-assets.test.ts.)Edge cases: (1) a Claude run must behave exactly as before (it never enters envelope detection, even for a tool titled
agenta-approval). (2) A custom tool that is author-allowmust NOT newly pause; a read-only builtin must auto-allow; a tool name with no resolved spec must be blocked, never allowed. (3) After a >5-minute wait (past the approval TTL), the click should degrade to the cold decision-map path, not fail the turn. (4) With keep-alive OFF, an ask still pauses and cold-resumes via the durable decision.Plan and evidence:
docs/design/agent-workflows/projects/pi-approval-parking/(ships in #5153).https://claude.ai/code/session_01CSTSEXSe4DDhoXCFjZpZ5W