feat(cli): add pi as a supported coding agent - #804
Conversation
Adds the pi coding agent to the NeMo Relay CLI as a hook-path agent, plus the pi extension that drives it. pi has no native hook-configuration file and its external stream is observation-only, so hook calls must originate inside an extension. The extension is a thin HTTP client to the gateway: it forwards pi's lifecycle to /hooks/pi and gates tool calls on the gateway's verdict. CLI side: - crates/cli/src/agents/pi/ with descriptor, adapter, launch and doctor - PiPayloadExtractor using SessionHeaderPolicy::RelayOnly so pi never inherits a stray x-claude-code-session-id - /hooks/pi route and pi_hook handler - Pi on CodingAgent, AgentKind, AgentArg and AgentConfigs pi has no plugin marketplace -- no `pi plugin` verb, no manifest, and no MCP client -- so the ~15 marketplace arms reject pi explicitly and point at `pi install <source>` and the auto-discovery directories, rather than synthesizing manifests pi will never read. Two edit sites the compiler does not enforce, both handled: - FileAgentsConfig carries deny_unknown_fields, so [agents.pi] needed the deserializer as well as the runtime struct - InstallTarget::All enumerates agents explicitly; pi is deliberately absent Extension side: - integrations/pi/ forwards session, agent-run, turn and tool lifecycle - tool_call is the only hook that awaits a verdict; the rest are fired without blocking pi's critical path and drained at session_shutdown - a guardrail rejection arrives as HTTP 403 with error.type = nemo_relay_guardrail_rejected, and error.reason is passed to pi verbatim, so the model reads the guardrail's own words Boundary choices worth noting: tool_execution_start is not forwarded as a tool start (it fires before validation and for calls that never execute), and tool_execution_end rather than tool_result is the end boundary (tool_result never fires for blocked calls). Tests: 2 Rust tests pin the 403 and 200 paths on /hooks/pi; 13 Node tests pin the extension's half of the contract. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Yuchen Zhang <yuchenz@nvidia.com>
Three fixes found by running pi against a gateway with the ATOF exporter enabled and reading the emitted trace. 1. pi's turn_start/turn_end produced marks, not turn scopes. TurnEnded was only emitted for the hardcoded name "stop", which is Codex and Claude Code vocabulary that pi never sends. The gateway therefore opened one implicit turn covering the whole run and pi's own turn boundaries were lost. ClassificationRules gains a turn_end list. Codex and Claude Code declare &["Stop", "stop"], which preserves their behaviour exactly; pi declares its native turn_end. agent_settled is deliberately not in pi's list -- it marks the end of a logical agent run, which can span several turns, so closing the turn there would merge every re-entry attempt into one. 2. Unawaited hook posts raced, reordering the lifecycle. Firing observability posts concurrently let them arrive out of order. An observed trace had agent_start landing after turn_start and agent_end after agent_settled, and a session_shutdown that overtook an in-flight post closed the session and let the straggler open a second one. The extension now serializes every post through a chain. Observability hooks are enqueued rather than awaited, so pi's critical path is still not charged. The gating hook does await, which also makes it wait for anything queued ahead of it -- worth the latency, because a tool span opened under the wrong turn is simply wrong. 3. A generic arrow function stopped the extension loading. `<T>(job) => ...` in a .ts file is ambiguous with JSX, and pi's jiti loader resolves it that way. pi collects extension load errors rather than aborting, so the extension silently did not run. Declared as a function instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Yuchen Zhang <yuchenz@nvidia.com>
pi's session_shutdown carries reason: quit | reload | new | resume | fork. The extension ignored it -- the mirrored type did not even declare the field -- and forwarded a session end for every reason. On /reload that is wrong. pi tears down and rebuilds the extension runtime while the session itself continues with the same session id, so ending the gateway session there closes its session scope and the session_start that follows opens a second one. One logical session silently became two disconnected traces. The handling was also asymmetric: session_start's reason was already forwarded. Now: reload drains the queue and returns without ending the session; quit and the three session-replacement reasons end it and forward the reason, plus targetSessionFile when pi supplies one. Known limitation, documented at the handler: attemptIndex and turnSeq live in the factory closure and pi re-runs the factory on reload with moduleCache: false, so they restart at 0 mid-session. turn_seq is therefore monotonic within a runtime rather than strictly within a session. Rebuilding them would mean replaying the session. Adds test/lifecycle.test.mjs, which drives the extension's handlers against a stub gateway. Nothing exercised them before -- the existing suite covers the wire contract in isolation -- so attempt_index and turn_seq were implemented and demonstrated in a live trace but never pinned. Now covered: turn attribution across a re-entry (colliding turn_index, monotonic turn_seq), attempt-counter reset on agent_settled, strict post ordering, session id on every post, and the shutdown-reason matrix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Yuchen Zhang <yuchenz@nvidia.com>
`nemo-relay launch pi` printed a note asserting that model traffic is redirected by the extension registering a gateway-backed provider. It is not: nothing registers a provider yet, so pi's model calls go straight to the provider and the gateway sees no LLM traffic at all. The note now says what is actually true -- tool and turn activity is reported, model calls are not routed, and redirection needs the extension to register a provider because pi has no base-URL flag. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Yuchen Zhang <yuchenz@nvidia.com>
The hook table grouped `turn_start` and `turn_end` into one row and claimed both carry `turn_seq`. Only `turn_start` does, alongside `attempt_index`; `turn_end` posts `turn_index` alone (`integrations/pi/index.ts:191-205`). Split the row so each boundary states what it actually carries. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Yuchen Zhang <yuchenz@nvidia.com>
Closes the two remaining M3 gaps: RELAY-730's turn classification and compaction forwarding, and RELAY-729's attribution. 1. turn_start is classified. Only turn_end was mapped, so the gateway opened the turn implicitly on whichever event arrived first -- agent_start on the first attempt, turn_start on later ones -- and trailing agent_end/agent_settled marks opened an extra empty turn after every run. NormalizedEvent gains TurnStarted and ClassificationRules a turn_start list; Codex and Claude Code declare an empty one and keep their lazily opened turns. Classifying the open is necessary but not sufficient: on its own it adds a *leading* empty turn holding the agent_start mark, because mark() forces a turn open. So for harnesses that report a turn start, a mark arriving between turns is now recorded on the session scope instead. That is what removes the empty turn at both ends, verified by reverting the guard and watching the new test report three turn scopes where pi reported one. 2. Attribution reaches tool spans. tool_call and tool_execution_end carried neither attempt_index nor turn_seq, and turn_end carried turn_index but not turn_seq, so a tool call could be tied to an attempt only by reading arrival order -- which stops working the moment two attempts overlap. The extension now sends both on every attributable hook, and agent_settled sends attempt_index alongside the attempts count. Sending them was not enough. Mark events record the raw payload as their data, but tool spans are built from the extracted call id, name, arguments, result and metadata and drop ToolEvent::payload entirely, so the keys would have been accepted on the wire and silently discarded. PiPayloadExtractor::metadata now promotes the two numeric counters into event metadata. The same promotion puts attribution on the turn scope rather than only on a mark inside it, so "which attempt did this turn belong to" is answerable by walking the scope tree. pi's own turn_index is deliberately not promoted: the gateway assigns its own to the turn scope and the two would collide. 3. Compaction is forwarded. session_before_compact was in none of the three layers. Both halves are now forwarded: session_compact classifies as Compaction, which the runtime treats as proof the context was rebuilt (it marks the owning agent fresh), and session_before_compact stays a mark because it announces an intent any later-loading extension can still cancel. Its willRetry is the only advance notice pi gives an extension that the agent run is about to re-enter. Also drops tool_execution_start from the descriptor's hook_events -- the extension registers it, but only to remember a tool name for the matching end, and never posts it. Verified against a live pi 0.84.0 session with a real model: two turns, both turn_source: turn_start, the read span nested under its turn carrying attempt_index and turn_seq, and the run-level marks on the session scope with no empty trailing turn. Also driven through the hook route with three concurrent tools closing out of submission order and a forced re-entry, where pi's turn_index collides at 0 while turn_seq and attempt_index stay unambiguous. Green: 1163 + 12 + 102 Rust, 29 Node, tsc clean, pre-commit clean apart from cargo-deny/gofmt/go-vet, which are not installed on this machine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Yuchen Zhang <yuchenz@nvidia.com>
integrations/pi/ had zero CI: nothing ran its tests, nothing typechecked it, and it was not an npm workspace, so its package scripts were unreachable from the repo root. Adds it as a workspace member and a `test-pi` recipe, threaded through the same four layers OpenClaw uses: a `pi:` path filter, a `run_pi` output from ci_changes, an input on ci_node, and the pass-through in ci.yaml. The filter also covers crates/cli/src/agents/pi/ and the shared adapter, because the extension and the gateway share one wire contract and a change to either can break the other. `run_node` now also fires on a pi-only change, or the job that hosts the step would never start. Unlike test-openclaw, the recipe does not build the Node binding first: the pi extension is a sidecar HTTP client and loads no native addon. Docs: adds docs/nemo-relay-cli/pi.mdx and lists pi in the four places that enumerate agents -- the CLI about page, basic usage, the support matrix, and the root README. The page is explicit about what is not there: no persistent install because pi has no plugin marketplace, no LLM spans because pi's model traffic does not traverse the gateway, and no subagent representation. Two claims were corrected against the binary while writing the page. There is no `nemo-relay pi` shortcut subcommand -- pi runs through `nemo-relay run --agent pi` -- and NEMO_RELAY_PI_EXTENSION is required rather than optional, because pi extensions live in the user's own configuration directories and there is no Relay-managed location to fall back on. just docs-linkcheck passes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Yuchen Zhang <yuchenz@nvidia.com>
The two limitations left on the extension README's follow-through list, both of which a reader hits without warning. Tool results are cut at 2000 characters before forwarding, so the gateway records what a tool returned rather than necessarily all of it. And pi has no nested-agent hook, so subagents are not represented at all -- including the multi-process case, where a child pi process running this extension resolves its own session id and appears as an unrelated session rather than as a subagent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Yuchen Zhang <yuchenz@nvidia.com>
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughPi support now spans the CLI agent registry, Rust hook and session handling, the TypeScript extension, diagnostics, CI, tests, and documentation. Pi hooks support lifecycle forwarding, policy gating, safe argument transforms, inline-shell handling, and conditional model routing. ChangesPi integration
Estimated code review effort: 5 (Critical) | ~120 minutes Merge Risk: 🟠 High · up to The Pi integration can execute rewritten tool arguments without reapplying conditional policy checks to the final values, which may allow an unapproved action to run. The supported launcher path and session handling also have known failure modes, so merge should wait for these issues to be fixed or explicitly accepted. Sequence Diagram(s)sequenceDiagram
participant Pi
participant RelayExtension
participant Gateway
participant PiHook
participant SessionManager
Pi->>RelayExtension: emit lifecycle or policy hook
RelayExtension->>Gateway: post session-aware hook
Gateway-->>RelayExtension: return allow, block, fault, or transform
RelayExtension->>PiHook: POST /hooks/pi for forwarded events
PiHook->>SessionManager: adapt and apply normalized events
SessionManager-->>PiHook: return HookEffects
PiHook-->>RelayExtension: return transformed tool input
RelayExtension-->>Pi: continue, refuse, or apply rewritten input
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
Full details: Description checkExplanation The description includes all required sections, completed confirmation items, detailed change scope, reviewer guidance, related-issue information, and validation results. It is complete and directly related to the pull request. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
Closes RELAY-732, the last real gap: pi's model calls now traverse the gateway,
so LLM spans land in the same trace as tool and turn spans and a Relay guardrail
can block a model call.
The mechanism is one call, not a provider implementation.
pi resolves a base URL per model from a generated catalog and has no base-URL
flag or generic environment override, so redirection has to happen inside the
extension. The ticket pointed at pi's `custom-provider-*` examples, which
register a `streamSimple` and re-implement a provider protocol. That is the
heavy path and it is not needed: `registerProvider(provider, { baseUrl })` with
no `models` makes pi rewrite the URL of every existing model for that provider
and keep their API, headers, costs and context windows
(`applyExtension`, core/provider-composer.ts:215, verified in pi's source rather
than taken from the doc comment). The extension stays a thin client.
Redirection is conditional, and the condition is the design.
The gateway forwards to one statically configured upstream per API family and a
client cannot override it per request -- inbound internal dispatch headers are
stripped, which is deliberate. So pointing a model at the gateway is only correct
when the gateway's upstream is the endpoint that model would otherwise call.
Redirecting an NVIDIA model into a gateway configured for api.openai.com does not
degrade to "no spans"; it breaks a session that worked a moment earlier.
The launcher therefore passes the gateway's own upstreams
(NEMO_RELAY_PI_{OPENAI,ANTHROPIC}_UPSTREAM, from ResolvedConfig, which
prepare_launch already had and pi ignored) and the extension redirects only on a
match. Skips are recorded as a `model_redirect` mark naming the reason --
upstream-mismatch, unserviceable-api, unknown-upstream -- so a trace without LLM
spans explains itself instead of looking broken. The decision is re-made on every
model_select. NEMO_RELAY_PI_REDIRECT=force skips the check, =off disables it.
Turn boundaries now block, because model traffic does not use the hook queue.
Reading the first redirected trace found a real defect: an LLM span opened under
the previous turn. pi sends model requests to the gateway directly over HTTP
while observability hooks go through the extension's serial queue, so the next
turn's model request beat our queued `turn_end` and was parented by the turn that
was still open. `turn_start` and `turn_end` are now awaited. Two local round
trips per turn buys correct parenting, on the same reasoning that already makes
tool_call await -- a span opened under the wrong turn is simply wrong. The
re-captured trace has every span closing inside the scope that opened it.
Verified against live pi v0.84.0 with a real model: three LLM spans nested under
their own turns alongside the tool span, and separately, with the example policy
plugin configured block_llms = true, a real guardrail rejecting a real pi model
call -- pi surfaced it as a clean 403 and the trace recorded the rejection as a
mark rather than a span, because the call never executed.
Also corrects two counts the ticket carried: pi ships 38 providers, not 39, and
6 of them speak an API the gateway has no route for, not 7 -- "Radius" is an
OAuth mode, not a provider.
Green: 1164 + 12 + 102 Rust, 42 Node, tsc clean, docs-linkcheck 0 errors,
clippy -D warnings clean, pre-commit clean apart from cargo-deny/gofmt/go-vet,
which are not installed on this machine.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Yuchen Zhang <yuchenz@nvidia.com>
The Status section linked out to an issue tracker that is not readable from this repository, and named its identifiers inline. Neither belongs in a public README: a reader outside the org gets dead links, and the identifiers carry no meaning for them. Says the same thing in prose instead. Nothing about the described behaviour changes -- it is still a proof of concept verified against pi v0.84.0, and model redirection is still conditional on the gateway fronting the model's provider. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Yuchen Zhang <yuchenz@nvidia.com>
The transform half of the pi tool-policy work. Guardrails could already block a
call; a request intercept could not change one, because the hook verdict
travelled only as an HTTP status code and there was no channel for a rewritten
payload.
The chain already existed. `tool_request_intercepts(name, args) -> Result<Json>`
is public in core and returns rewritten arguments; `start_tool` ran the guardrail
chain and never this one. So the gateway side is wiring: run the chain, use its
output as the span's arguments so the trace records what will execute, and hand
it back.
Handing it back needed one plumbing change. `pi_hook` builds its response before
`apply_events` runs, so `apply_events` now returns `HookEffects` carrying the
rewrite, and the pi adapter merges it into the body:
{"tool_call": {"tool_call_id": "...", "input": {...}}}
Absent a rewrite the body stays `{}`, which is what an allow has always been, so
an older extension is unaffected. `tool_call_id` is echoed so the extension can
refuse a body belonging to a different call.
Gated per agent. Codex and Claude Code have no way to execute a rewrite, so
running the chain for them would record arguments on the span that never ran --
worse than not running it, because the trace would then disagree with reality.
The extension constrains the rewrite rather than validating it, because it
cannot validate it.
pi validates arguments before the `tool_call` hook and never re-validates -- its
own types say so -- and the extension cannot read a built-in tool's schema: pi
exposes `tools` only on the `Extension` interface, which is an extension's own
registered tools. Of the three options the design considered (fetch the schema,
forward the schema, constrain the transform), the first two are therefore
impossible and the third is forced.
So a transform may rewrite the values of existing keys, preserving each value's
JSON type, recursively. Adding a key, removing one, changing a type or changing
an array's length is refused, which keeps the required keys and types the schema
already accepted. This is structural, not schema validation: pattern, enum and
range constraints are not checked and cannot be, and that limitation is
documented and asserted rather than glossed.
A refused transform blocks the call. Running the original arguments would
silently discard a policy decision, which is the failure the transform existed to
prevent, and it is a different axis from NEMO_RELAY_PI_FAIL, which governs an
unreachable gateway rather than one that answered with something unusable.
Verified against live pi v0.84.0 twice. A shape-preserving rewrite of a read
path executed: the model asked for alpha.txt, the gateway rewrote it to beta.txt,
and pi read beta.txt. A key-adding rewrite blocked every one of eight tool calls,
and the model reported it as a policy misconfiguration rather than a refusal of
its request, which is what the reason string is written to produce.
An earlier run of that second check appeared to pass the unsafe transform
through. It had not: the stub only rewrote paths containing alpha.txt, so when
the block worked the model retried with `cat alpha.txt` through bash, which the
stub left alone. The test was wrong, not the code -- logging every call rather
than only the rewritten ones showed it immediately.
Green: 1166 + 12 + 102 Rust, 52 Node, tsc clean, docs-linkcheck 0 errors,
clippy -D warnings clean, pre-commit clean apart from cargo-deny/gofmt/go-vet,
which are not installed on this machine.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Yuchen Zhang <yuchenz@nvidia.com>
License DiffCompared against Lockfile license changesLockfile License ChangesRustAdded
Removed
Updated/Changed
NodeAdded
Removed
Updated/Changed
PythonAdded
Removed
Updated/Changed
Status output |
pi's `!cmd` and `!!cmd` never reach the tool registry, so `tool_call` does not fire for them and none of the tool gating covered them. They reach pi's `user_bash` hook instead, which is interceptable: a handler that returns a `BashResult` makes pi skip execution entirely and record that result. The extension now posts the command to `/hooks/pi` as a tool start named `user_bash`, so the same conditional-execution guardrail chain and the same 403 contract decide it. The name is deliberately not `bash`: a guardrail receives only the tool name and the arguments, so a policy can tell a command the user typed from one the model proposed only if the two arrive under different names, and "the model may not run shell commands" should not also stop a human typing `!git status`. The cost is that a policy covering both has to name both, which the docs state. pi gives the hook no block-and-reason contract, so a refusal is a synthetic failed `BashResult` that pi records as though the command had run: exit code 126 (found, but could not be executed), an attribution line, then the guardrail's reason verbatim. `NEMO_RELAY_PI_FAIL` governs this path too. A rewritten command is refused rather than run, because pi's result type can replace the result or the execution backend but never the command itself. `emitUserBash` wraps handlers in try/catch, so a throw here fails open and is invisible -- the opposite of `tool_call`. Every path returns an explicit decision, and the catch re-reads the failure policy rather than defaulting to open, so an explicit fail-closed setting is not overridden by an internal error. Two things beyond the gate itself: - Tool events for a harness that reports its own turn start no longer open a turn when none is open. Inline shell is the first tool event that can arrive between turns -- a command typed at an idle prompt -- and opening a turn to hold it invented a boundary pi never reported. This is the rule `mark` already applies, reached from the tool side. It also changes where a `tool_execution_end` that lands after `turn_end` attaches for pi: on the session scope rather than in a manufactured turn. Codex and Claude Code report no turn start and are unaffected. - The descriptor's `hook_events` gains `tool_arguments_transformed`, which the extension has been posting since argument transforms landed. The list is an inventory of what the extension posts, so a test now pins the exact set. Verified live against pi v0.84.0 driven in RPC mode: an allowed command runs and its span sits directly under the session scope; a command refused by the `examples.rust_native_policy` plugin never executes, and the reason reaches the user verbatim with exit code 126; an unreachable gateway under fail-closed refuses with the infrastructure-fault wording. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Yuchen Zhang <yuchenz@nvidia.com>
Three M5 items, all shaped by the same problem: the ways this integration fails are quiet ones. **A doctor preflight for the load path.** pi adds project-scoped extensions to its candidate set only when the project is trusted, and `-p`, `--mode json` and `--mode rpc` never prompt for trust. The skip is a bare conditional rather than an error path, so pi does not treat it as a failure and never reports it -- and the extension cannot report it either, because it is not running. `nemo-relay doctor pi` now warns when an extension sits on a trust-gated path, and probes the gateway the extension will post to. `AgentInfo` gains a `checks` list, empty for Codex and Claude Code and omitted from their JSON entirely: their setup is written by `nemo-relay install`, so `hook_status` already describes it. pi's is installed by the user, wherever they like, and pi's own trust rules decide whether it loads -- a finding that deserves its own status rather than a sentence in a summary. The gateway probe resolves its URL from the resolved `bind` when the environment variable is unset, because the launcher sets that variable *from* the config: a check that only read the variable would report a working gateway as down for anyone who changed `bind`. It classifies rather than just connecting, so "your gateway is down" and "something else owns that port" are told apart, and it never returns `Fail` -- doctor running before the gateway starts is the normal case, not a broken machine. It is skipped under `--offline`, and for an agent that is neither configured nor asked about, so a machine that does not use pi does not spend the timeout budget dialling a gateway nobody mentioned. **Test harness and coverage.** Both test drivers returned the *last* handler's result; pi returns the *first*. That inverted the trap this extension documents in two places, so the harness itself could not catch a regression in preemption behaviour. There is now one shared driver with pi's semantics, and the preemption case is pinned: an extension ahead of ours decides, and the gateway never sees the call. Filled the gaps that left: the `tool_call` gate had no end-to-end test at all -- every component it composes was pinned and the handler wiring them was not, which is easy to miss precisely because the coverage either side looks complete. Also concurrent tools closing out of submission order, unpaired tool boundaries, compaction-driven re-entry, a slow gateway on both gates, and the bound on what an interrupted session loses. **Two limitations documented rather than papered over.** pi registers no SIGINT handler in any mode, so Ctrl+C in a headless mode kills it with teardown never running; what is lost is bounded to marks queued since the last awaited hook, because both gates and both turn boundaries block on their round trip. And a broader one, found while costing the tool-result policy gap: a tool execution intercept registered by any plugin never runs under the CLI gateway. The registry has exactly one consumer, `tool_call_execute`, which the gateway does not call -- it applies policy through the hook path. Guardrails and request intercepts do run there, because both have standalone runners; there is no response-phase equivalent. Worth stating where a user meets it. Also adds `integrations/pi` to the version bump. It is private and unpublished, so this changes nothing today -- it is there so the version cannot already be stale on the day that changes, since a workspace member absent from that list drifts with no lockfile mismatch and no CI failure to catch it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Yuchen Zhang <yuchenz@nvidia.com>
`pi install` resolves a local path or a git URL as readily as an `npm:` specifier, so the package being unpublished does not cost a route -- it costs a spelling of one. Publishing would buy that spelling in exchange for an npm namespace, a build step (the sources are TypeScript nothing compiles today) and release wiring, so `private: true` stays, and now says so on purpose rather than reading as an oversight. Both install routes are spelled out with the commands to run, since "user scope" was previously stated as a rule without showing what it looks like. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Yuchen Zhang <yuchenz@nvidia.com>
Closes the last of the parity gap. Claude Code and Codex take their hooks from the launcher, so setup is the final step they need. pi's hooks can only originate inside an extension, so before this the wizard would finish, congratulate the user, and then hand them a `nemo-relay pi` that does not work. Setup now offers the install it needs. **A direct agent check, not a new abstraction.** The wizard already branches on one agent -- `print_codex_api_key_guide` -- and pi is the only host with anything to provision. A provisioning trait with one implementor would be speculative generality; this follows the shape that is already there, and all the pi-specific logic stays in `agents::pi::install`. **It offers only when pi has no copy at all, in any scope.** An existing install needs no offer, and a project-scoped copy -- the trap the guide warns about twice -- must not quietly become the reason a *second* copy appears, because two copies double every hook and stop the launcher outright. `doctor` reports a project-scoped copy on its own; setup stays out of it. The install runs its own duplicate guard regardless, so the two cannot disagree into a broken state. **Nothing here can fail the wizard**, and that is structural rather than defensive: `offer_pi_extension` returns unit, so there is no error to propagate. The configuration is already saved by the time it runs, a declined or failed install still leaves a working setup for the manual routes, and every path prints `nemo-relay install pi` for later. An interrupted prompt is a skip, exactly as it already is for plugin setup. One scoping limit is worth stating plainly, and the docs now state it: the wizard only runs when no Relay configuration exists yet. On a machine already configured for another agent, `nemo-relay pi` skips setup entirely and the offer never appears -- so `basic-usage.mdx` and `pi.mdx` say to run `nemo-relay install pi` in that case rather than implying the wizard always catches it. `integrations/pi/README.md` also leads with the managed install now. Verified: `cargo test -p nemo-relay-cli` (1365 tests, 2 new covering when an offer is made), clippy `--all-targets`, `cargo fmt --check`, and pre-commit `--all-files` -- 30 hooks pass, and the three that fail (cargo-deny, gofmt, go) are tools absent from this machine rather than anything in this change. Drove the accept path through a pty on a clean `PI_CODING_AGENT_DIR` and `XDG_CONFIG_HOME`: the wizard saves the config, offers the install, writes all seven files, and reports the load path it resolved. Signed-off-by: Yuchen Zhang <yuchenz@nvidia.com>
Two P1 regressions from the pi install feature, both found in review, both
created by it.
**`doctor` aborted for every agent after `nemo-relay install pi`.**
thread 'main' panicked at crates/cli/src/agents/mod.rs:226:25:
internal error: entered unreachable code: pi has no plugin marketplace
One line caused it: teaching `installed_integrations` about pi so `uninstall all`
could see a managed install also handed pi to the *marketplace readiness
collector*, whose `PluginLayout::new` calls `marketplace_manifest_relative` --
`unreachable!()` for pi. So it was never scoped to pi; `doctor claude --json`
aborted too. One `install pi` broke `doctor` for everything, and the installer's
own output tells the user to run it.
A third path was worse than reported: `doctor --plugin pi` panics with **nothing
installed at all**. Adding `pi` to the `<HOST>` value enum for `install` also made
it a valid `--plugin` argument, and every path behind that flag is
`unreachable!()` for pi -- broken from the moment the enum changed.
Fixed by splitting the two questions that were sharing one function.
`installed_integrations` means "has marketplace plugin state" and now excludes pi
explicitly, with a doc comment recording that putting pi back is an abort rather
than a wrong answer. `uninstallable_integrations` means "has state Relay can
remove" and includes pi; `uninstall all` is its only caller. `doctor --plugin pi`
refuses in words and points at `nemo-relay doctor pi`.
**Installing could create the duplicate the launcher refuses to start over.**
`install pi` used `conflicting_extension_site`, which deliberately ignores
project-scoped copies -- correct for the launcher, wrong here. Installing inside a
project holding `.pi/extensions/<copy>` succeeded and left both, and a trusted
project then loads both, doubling every hook and every policy gate.
The asymmetry is the point. The launcher only notes a project copy because
refusing there would block every launch in an untrusted project over a copy that
will not load. Installing is the opposite case: it is the act that creates the
second copy. Declining to create a problem is a lower bar than declining to run
because one exists.
That guard is partial by construction, and the code comment and `pi.mdx` both say
so: it reads only the current directory, so installing from elsewhere leaves the
same project copy in place. It catches installing from the project you work in.
**Why 1365 tests missed both.** None of them ran the binary with a managed install
on disk -- every install test called the function directly, and every doctor test
ran without one. Three CLI regression tests now close that: `doctor` with a
managed install across three invocations including another agent's, `--plugin pi`
refusing rather than aborting, and the project-scope refusal under a controlled
cwd. The first was mutation-checked by restoring the old `installed_integrations`
and confirming it fails with the original panic.
Verified: `cargo test -p nemo-relay-cli` (1369), clippy `--all-targets`,
`cargo fmt --check`, pre-commit `--all-files` -- clean apart from cargo-deny,
gofmt and go, which are not installed on this machine. Reproduced all three panic
paths before the fix and confirmed each is gone after, plus `uninstall all` still
removing a managed pi install.
Signed-off-by: Yuchen Zhang <yuchenz@nvidia.com>
Review round on the pi CLI surfaces. Five defects, three of them created by this work. **A tool span could outlive the scope that opened it.** `close_turn` returned early when no turn was open, *before* closing active tools -- but `ensure_tool_scope_started` parents a span to the session scope when a host with explicit turn boundaries has no turn open, which is exactly pi's inline shell between turns. The session then closed over a span that never ended. Reachable with no signal at all: the extension posts `user_bash_end` through a fire-and-forget path that swallows failures, so one dropped post plus `/quit` was enough. The three closers drain empty collections, so running them before the turn check costs nothing when there is nothing to close. **Uninstall could delete files outside the extension directory.** Every recorded path was joined to the install root and removed, and `Path::join` with an absolute path replaces the root outright -- so a `.nemo-relay-install.json` naming `/etc/...` or `../../..` turned `uninstall pi` into an arbitrary-file delete for the invoking user. The recorded hash was no guard: it is compared against whatever sits at the resolved path, so pairing the traversal with the victim's own digest satisfied it. Paths are now rejected unless every component is `Normal`, and removal additionally canonicalizes the parent and checks containment, which closes the symlinked-parent route as well. **`install all --install-dir` regressed for Codex and Claude Code users.** `All` gained pi, pi rejects `--install-dir`, and that rejection failed the whole command: both marketplace hosts installed correctly and the command still exited 1. pi is now dropped from an `all` run carrying the flag; an explicit `install pi --install-dir` still errors, because there the flag is the user's stated intent. **Install and uninstall could dead-end with a false diagnosis.** Keeping an edited file left a directory with no state and no manifest, which read as somebody's extension -- so every reinstall was refused, `--force` included, while being told the copy already worked. It did not: pi cannot load a directory with no `package.json`. That case is now classified separately and `--force` installs over it. Relatedly, `modified_files` hashed through `read_to_string`, so a file edited into anything non-textual was unreadable, collapsed to "unmodified", and deleted -- the exact file the keep-edited rule exists to protect. It hashes bytes now, and an unreadable file is kept rather than assumed ours. **The drift guard was not wired to the paths that cause drift.** `run_rust` omitted the `pi` filter and `crates/cli/assets/pi-extension/**` matched no filter at all, so an `integrations/pi/**`-only PR could ship a stale vendored extension with every check green. Both fixed; re-syncing the vendored copy in this commit exercised it. Also from the audit: `NEMO_RELAY_PI_TIMEOUT_MS` above 2^31-1 wrapped to ~1 ms and made every gated call fault, which under the default fail-open policy silently stops enforcing -- it is clamped now. A fail-open `user_bash` fault recorded `policy-allowed`, indistinguishable from a real allow, and now records `fault-allowed`. The hook-header comment claimed the gateway strips the session header; it strips it on the provider-passthrough route and *reads* it on the hook route. Docs, all verified against the binary or the source: the launcher does not refuse over a project-scoped copy (it notes one), `doctor pi` is cwd-only like the install guard rather than global, a clean machine gets the wizard's offer rather than a failure, the example plugin injects `plugin_tag`/`plugin_tool` rather than two identifiers that exist nowhere, pi 0.84 prints extension-load failures and exits rather than swallowing them, `user_bash_end` marks the policy decision and not command completion, "tool and turn activity always reach Relay" is qualified by fail-open, pi has no *marketplace* manifest rather than no manifest, the 2000-character tool-result truncation is on the docs site rather than only in the extension README, and the pages that enumerated only Claude Code and Codex now include pi. Verified: `cargo test -p nemo-relay-cli` (1374), 97 extension tests, `tsc` clean, clippy `--all-targets`, `cargo fmt --check`, pre-commit `--all-files` -- clean apart from cargo-deny, gofmt and go, absent on this machine. Both new regression tests were mutation-checked against the original code: the span test reproduces the reported trace exactly, and the traversal tests confirm the victim file survives. Signed-off-by: Yuchen Zhang <yuchenz@nvidia.com>
Second review round on the pi CLI work. Four of these are regressions from `6267dabf` -- the previous round's fixes -- which makes three rounds running where a fix introduced the next defect. **`--force` could overwrite a working extension.** Classifying the install path used a manifest-name test, so "the manifest does not name Relay" was read as "nothing of value here". It is also every manifest-less extension pi loads from a bare `index.ts` (`collectAutoExtensionEntries`, pi `v0.84.0`) and every third-party package. Before `6267dabf` all three were refused; after it, `--force` overwrote two of them, against the guarantee the guide makes. Reverted rather than narrowed. Detection now asks whether pi would load *anything* here -- any manifest, or an `index.ts`/`index.js` -- and both classes are refused with `--force` included. The leftovers variant survives only so the refusal can say what is actually there, which was the original defect; recovery is removing the directory, and the message says so. **Pruning still escaped the install root.** The previous round hardened file removal against forged install state and left `prune_empty_dirs` joining recorded parents blind, so a recorded `src/deep/x.ts` with `src` symlinked out of the tree removed `<outside>/deep`. Bounded to empty directories, so no data loss -- but the same untrusted input, and two of three consumers hardened is not a threat model. It resolves the directory itself now, not its parent, because a symlinked `src` passes a parent check while pointing anywhere. Uninstall also reports what it refused and exits non-zero rather than claiming plain success. **`install all --install-dir` excluded pi three ways.** Filtering pi out of the run was the wrong fix: the exclusion was silent, the "no supported host was detected" error named pi as undetected when it had been detected and filtered, and `uninstall all --install-dir` exited 0 leaving a managed extension where it had previously exited 1 and said why. Now the *flag* is cleared for pi under `all`, not the host dropped, so every host stays in the run and each gets the only meaning the flag has for it. Named explicitly, `install pi --install-dir` still errors. **A test wrote to a predictable global path.** The absolute-traversal case used a fixed `/tmp` filename and deleted it afterward, clobbering any same-named file already there. Both victims are inside the test's own `TempDir` now. **A new assertion was vacuous.** The orphan-span test compared the shell span's `End` against the last `End` of any kind, which its own value can satisfy -- so it could not fail while its comment claimed to enforce containment. Named scopes on both sides and a strict `<` now. The count assertion above it was the load-bearing one and is unchanged. Three fixes from the previous round were unpinned and survived mutation: the containment guard, the `fault-allowed` status, and the timeout clamp. All three have tests now, each mutation-checked. The symlink test needed its victim directory left *empty* to reach the pruning path at all -- with a file in it, `remove_dir` fails for an unrelated reason and hides the escape. Docs: the never-overwrite guarantee is restored and now says what it covers; "tool and turn activity reach Relay whenever the gateway is reachable" also needed the preemption case, because pi runs every extension's handler and one registered ahead of this can decide a call before Relay's gate sees it; and the shared installation guide no longer calls pi's extension system "no plugin system" or folds it into the marketplace/MCP description -- the two mechanisms are now separated, with pi's diagnose and uninstall commands added. Verified: `cargo test -p nemo-relay-cli` (1377), 99 extension tests, `tsc` clean, clippy `--all-targets`, `cargo fmt --check`, pre-commit `--all-files` -- clean apart from cargo-deny, gofmt and go, absent on this machine. `install all --install-dir` and `uninstall all --install-dir` exercised end to end against a scratch `PI_CODING_AGENT_DIR` with pi on PATH. Signed-off-by: Yuchen Zhang <yuchenz@nvidia.com>
Four conflicts in `crates/cli`, all where upstream's hook authentication and
forced-uninstall work landed on top of the pi integration.
**`agents/mod.rs`.** `installed_integrations` gained upstream's
`include_local_install` parameter. Kept the branch's note on why pi is never in
that list -- every consumer feeds it into marketplace-only code that is
`unreachable!()` for pi -- and threaded the flag through
`uninstallable_integrations`, which is the collector that does have a pi answer.
The flag does not gate pi: a Relay-managed extension directory *is* the local
install, so it is always removable.
**`commands/install.rs`.** `uninstall all` keeps asking
`uninstallable_integrations`, so a managed pi install stays visible, and now
forwards `--force` so upstream's force-cleanup targets are still included for the
marketplace hosts. Taking either side alone would have dropped one of the two.
**`sessions/mod.rs`.** Upstream split `apply_events` into a test-only wrapper over
`apply_events_inner`, which returns the owner IDs released in the batch; the
branch needs `HookEffects` back so pi's hook response can carry a rewritten
`tool_call`. `apply_events_inner` now returns both, and `apply_events` stays a
production entry point rather than test-only.
**`server/mod.rs`.** Textual adjacency only. Kept `pi_hook` alongside upstream's
new `authorize_hook_permission` and `permission_denial_reason`.
Two follow-on fixes the merged APIs require:
- The pi adapter sets `AdapterOutcome::permission` to `None`. pi has no
`PermissionRequest` hook; gating rides on `tool_call`, which the gateway answers
with HTTP 403 and the extension turns into pi's `{block, reason}`.
- The pi install tests construct `UninstallRequest::force`, and the collector test
now asserts its invariants for both values of `include_local_install` --
widening the marketplace question must not give pi a marketplace answer.
`/hooks/pi` remains the one hook route that does not authenticate, which is the
branch's existing behavior rather than something this merge changes. The extension
posts with `fetch` from inside pi's own process, and on the standalone-daemon
route there is no launcher to hand it a bootstrap or transparent-proxy token, so
requiring one would break the documented setup. Because that route passes
`authenticated_owners: None`, the ownership checks upstream added short-circuit
permissive for events arriving there. Both `SessionManager::apply_events` and
`pi_hook` now document this and what closing it would need.
Also unresolved and left deliberately: `uninstall pi --force` is accepted by clap
but ignored by pi's uninstall. Forcing past its refusals would mean deleting
directories Relay did not write, which is the failure the install path already
guards against, so the semantics are a design question rather than a merge fix.
`just test-rust` passes: 4556, 10, 13 and 20 tests across the workspace and the
three example plugins, no failures. The 25 `cargo test` failures seen without
nextest reproduce identically on a clean `upstream/main` worktree; they need
nextest's process-per-test isolation and are unrelated to this merge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Yuchen Zhang <yuchenz@nvidia.com>
The extension source existed twice, byte-identical across all seven files pi
loads: `integrations/pi` and a vendored copy under `crates/cli/assets/`, held
together by `just sync-pi-extension` and a byte-for-byte drift test.
The duplication was a packaging workaround, not a design choice. `nemo-relay-cli`
publishes to crates.io and Cargo packages only files under the crate root, so an
`include_str!` reaching up into `integrations/pi` builds in a workspace checkout
and then fails from the published tarball. A build script copying into `OUT_DIR`
fails for the same reason: the source it would copy is not in the tarball either.
`crates/cli/assets/pi-extension` is now the single copy, and
`integrations/pi/{package.json,index.ts,src}` are symlinks to it. The sync recipe
is gone.
**The drift tests were repurposed rather than deleted.** File coverage now reads
the crate's own assets, so it holds for the published crate too, where an omitted
source file would otherwise be undiagnosable. The byte-for-byte comparison became
a symlink guard: git on Windows without `core.symlinks` writes a symlink as a
text file containing its target path, which leaves the crate itself correct --
`include_str!` reads the real file either way -- and surfaces much later as the
Node suite importing a path string. Failing in Rust names the actual cause.
**`integrations/pi` is no longer self-contained**, so the two documented manual
install routes and the launcher's own error message now name
`crates/cli/assets/pi-extension`. Copying a tree of symlinks is platform-
dependent: BSD `cp -r` dereferences them, GNU `cp -r` documents the opposite
default, and `pi install <path>` copies by rules of its own.
Two smaller consequences. The assets path joins the `pi` CI filter, which would
otherwise stop firing for extension edits now that they land under the crate. And
the version bump writes the manifest at its real path rather than through the
symlink, so a checkout without symlink support cannot overwrite a link stub
instead of bumping anything.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Yuchen Zhang <yuchenz@nvidia.com>
Wording pass over the pi guide, following a review of the integration. No behavior is described differently; the tables and every technical claim are unchanged. - A sentence broke mid-phrase across a line, leaving `install` alone on its own. - The opening ran two paragraphs together, so the guide began with a twelve-line block before its first break. - Transparent Run stated the same fact in two consecutive sentences: that a pi install lands in pi's own configuration directory rather than a Relay-owned plugin root. - The Captured Events preamble re-explained what its own table says row by row, and what Model Redirection says again further down. The three synthesized events are still named; only the duplicated explanation is gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Yuchen Zhang <yuchenz@nvidia.com>
Both describe the arrangement before the extension was de-duplicated, and both would mislead the next reader. `assets.rs` claimed `cargo package` follows the `integrations/pi` symlinks and writes their targets in as regular files. Cargo does that when a symlink points out of the crate root, but that is not this layout: the real files already live under the crate root, so packaging includes them directly and never consults those symlinks at all. The `rust` path filter still described `crates/cli/assets/**` as a vendored copy guarded by a test asserting it matches `integrations/pi`. There is no second copy and no such assertion; the tests that run on a change there check the embedded asset inventory. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Yuchen Zhang <yuchenz@nvidia.com>
Fixes the five failing Rust CI jobs on this branch. The upstream merge brought in three `refresh_preflight` tests that write and read marketplace state for every `CodingAgent::ALL`. Upstream that set is two hosts, both with a marketplace. Here it is three, and pi has none: every marketplace path is `pi_marketplace_unreachable!()` for it, so `PluginLayout::new` panics. `CodingAgent::MARKETPLACE_HOSTS` now names the set those call sites actually mean, and both the tests and `refresh_targets` use it. Named rather than filtered at each call site because the failure mode is silent until it is loud -- `ALL` compiles and reads perfectly naturally, and nothing complains until a host without a marketplace reaches the layout. This branch has paid for that once already, when teaching a shared collector about pi panicked `doctor` for every agent. **No behavior changes.** `refresh_targets` iterating `ALL` was already a guaranteed no-op for pi: `persisted_state_exists` is false because pi's install writes into pi's own extension directory rather than a marketplace state file, and `local_install_exists` is false for pi by construction, so every pi target reached `continue`. Filtering at the source makes that deliberate instead of coincidental, and stops asking `registered_install_dirs` about a registry pi never writes to. The panic was only ever reachable because the tests synthesize that state file for every host. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Yuchen Zhang <yuchenz@nvidia.com>
|
/merge |
#### Overview Allows a Relay-launched pi session to forward model requests to the provider’s original endpoint, even when that endpoint is not statically configured on the gateway. These calls continue to produce LLM spans and remain subject to model-call policy. Builds on #804, which introduced the pi integration. - [x] I confirm this contribution is my own work, or I have the right to submit it under this project's license. - [x] I searched existing issues and open pull requests, and this does not duplicate existing work. #### Details - The pi extension sends `x-nemo-relay-upstream-base-url` when the selected provider does not match the gateway’s configured upstream. - The gateway accepts this header only from the launched process tree using its per-invocation proxy credential, validates the destination, and removes the header before forwarding. - Client-named destinations receive only credentials supplied by the caller. Relay never injects configured or environment provider credentials into an endpoint selected by the client. - Standalone gateways retain static routing because they do not issue an invocation credential. - Provider-wide redirection still requires the provider’s models to share a compatible endpoint. - Rust and TypeScript coverage verifies routing, authentication, header stripping, credential handling, LLM spans, and model-call policy. Validation included `cargo nextest run -p nemo-relay-cli`, `just test-pi`, `just docs-linkcheck`, Clippy, pre-commit, and a live NVIDIA-provider run. #### Where should the reviewer start? 1. `crates/cli/src/agents/pi/alignment.rs` — destination validation and trust boundary. 2. `crates/cli/src/gateway/request.rs` — managed-request routing and credential policy. 3. `crates/cli/src/gateway/mod.rs` — `/v1/models` routing. 4. `crates/cli/tests/coverage/shared/server_tests.rs` — end-to-end security and policy coverage. #### Related Issues: (use one of the action keywords Closes / Fixes / Resolves / Relates to) - Relates to: #804 ## Summary by CodeRabbit - **New Features** - Authorized launched Pi sessions can route requests to selected upstream endpoints. - Model redirection supports compatible, previously unconfigured provider endpoints. - Client credentials are preserved for named upstreams, while gateway credentials are not added. - Named upstreams support model requests, streaming, passthrough, and model-list requests. - **Bug Fixes** - Invalid or unauthorized endpoint selections are rejected instead of falling back. - Redirects are not followed for named upstreams, preventing unintended credential forwarding. - Standalone gateways continue using configured upstream matching. - **Documentation** - Updated Pi guidance with endpoint naming, routing behavior, security requirements, and troubleshooting details. Authors: - Yuchen Zhang (https://github.com/yczhang-nv) Approvers: - Maryam Najafian (https://github.com/mnajafian-nv) URL: #977
Overview
Adds pi (
@earendil-works/pi-coding-agent) as a third supported coding agent alongside Codex and Claude Code.The integration is a sidecar. A NeMo Relay-authored pi extension posts pi's lifecycle to the CLI gateway at
POST /hooks/pi, and the gateway builds the scope tree. Nothing in pi's process loads the Node binding. This shape is forced, not chosen: pi has no native hook-configuration file and its external event stream is observation-only, so hooks cannot be injected from outside the process. The extension is deliberately thin — all policy and all span construction stay in the gateway, on the same managed path Codex and Claude Code already use.Tool and turn activity are captured; a guardrail can block a real pi tool call, a model call, and the bang-prefixed inline shell a user types (
!cmd), which never reaches pi's tool registry. A request intercept can rewrite a tool call's arguments and pi executes the rewrite.The user-facing surfaces match the other two agents:
nemo-relay pifor a transparent run,nemo-relay install pi/uninstall pifor a persistent install, andnemo-relay doctor pifor preflight.Details
Most of this is additive — a new agent variant and a new route. The parts worth a reviewer's judgment are the decisions, not the inventory.
Shared code, where a regression would land. Three changes touch files Codex and Claude Code also depend on:
NormalizedEvent::TurnStartedand theturn_start/compactionlists onClassificationRules. Codex and Claude Code declare&[]for both, so their behavior is unchanged.AgentKind::has_explicit_turn_start(). For a harness that reports its own turn start, an event arriving between turns is genuinely between turns, so it is recorded on the session scope instead of manufacturing a turn to hold it. Only pi sets it.AgentInfo.checks,skip_serializing_ifempty, so Codex and Claude Code entries are byte-identical in JSON andschema_versionstays at 2.Argument transforms are constrained, not validated. An allow response may carry
{"tool_call": {"tool_call_id": "…", "input": {…}}}, applied to pi'sevent.inputin place. A transform may only rewrite the values of existing keys, preserving each value's JSON type. Adding a key, removing one, or changing a type is refused, and a refusal blocks rather than falling back to the original arguments, which would silently discard the policy. This is not schema validation, by choice — pi's tool set is per-session mutable, so a schema read once can go stale mid-session. Conditional-execution guardrails decide on the arguments pi proposed and are not re-run on the rewrite, matching the runtime's managed-call order.The inline-shell gate is named
user_bash, notbash. A guardrail receives only a tool name and arguments, so a policy can tell a command the user typed from one the model proposed only if the two arrive under different names. The trade-off is that a policy covering both must name both; the docs say so and a test asserts it.Model redirection is conditional, and the condition is the design. pi resolves
baseUrlper model from a generated catalog with no flag or generic override, so the extension points the active model's provider at the gateway withregisterProvider. That rewrite is provider-wide, so the decision verifies the provider's whole catalog, snapshotted before any registration. A provider mixing API families at different paths (Fireworks) is skipped rather than broken mid-session. Each decision that explains something is recorded as amodel_redirectmark, so a trace without LLM spans states its own reason.The registration also carries this invocation's proxy credential, which is what makes
nemo-relay run --agent piproduce LLM spans at all: a gateway the launcher started authenticates its own client before any intercept can rewrite the route, so a redirected call without it comes back401. It rides on the registration rather than a per-request hook for the same reason the session id does — only providers actually pointed at the gateway ever send it.Installing writes into pi's own configuration, and records what it wrote. pi has no plugin marketplace, so
install pidoes not go through the marketplace path the other two agents use. It writes a package directory into pi's auto-discovery location, which needs nosettings.jsonedit and nopionPATH, and records a hash of every file — souninstall piremoves exactly what it wrote and keeps anything edited. It refuses to create a second copy, because pi de-duplicates its extension set by path rather than by package, and two copies mean every hook fires twice.The extension has one copy, embedded in the binary.
crates/cli/assets/pi-extensionholds the source, andintegrations/pikeeps its README, tsconfig and test suite while symlinking the rest. It lives under the crate for a packaging reason:nemo-relay-clipublishes to crates.io and Cargo packages only files below the crate root, so aninclude_str!reaching intointegrations/pibuilds in a workspace checkout and then fails from the published tarball. That embedding is what letsinstall piwork with no checkout present. One test asserts every.tsundersrc/is embedded; another reads through the symlinks and fails by name on a checkout that did not materialize them.nemo-relay doctor pipredicts failures nothing else reports. pi loads a project-scoped extension only for a trusted project, and its non-interactive modes never prompt — so the extension is dropped by a bare conditional that pi does not treat as a failure and never surfaces, and that the extension cannot surface either, because it is not running. Doctor also catches two copies loading at once and an install whose settings filters switch it off.Where should the reviewer start?
crates/cli/src/agents/shared/adapters.rsandcrates/cli/src/sessions/mod.rsfor the event and session model.For the install surfaces,
crates/cli/src/agents/pi/install.rsis the install/uninstall contract, andcrates/cli/src/agents/pi/assets.rsexplains why the extension lives under the crate.Known limitations, documented rather than papered over
tool_callhandler unless one blocks, sharing one mutableinputwith no re-validation. Loading first with-estops an earlier extension pre-empting the gate; it does nothing about a later one rewriting arguments after Relay authorized them. pi offers no ordering API, so the gate is authoritative over the model, not over the other extensions.private: trueand deliberately not published to npm.nemo-relay install picovers user scope with no checkout; a file drop or a local-pathpi installfromcrates/cli/assets/pi-extensionwork too. A git URL is not a working source: pi clones the repository root, finds nopimanifest there, and loads nothing.Validation
cargo nextest run -p nemo-relay-cli(1402 tests),just test-pi(99 Node tests),just docs-linkcheck(0 errors),cargo clippy --workspace --all-targets -- -D warnings. nextest rather than plaincargo test: a set of CLI tests bind ports and register global plugins, and collide inside one shared process onmainas well as on this branch.uv run pre-commit run --all-files:cargo-deny,go fmtandgo vetfail only because those binaries are not installed on this machine, and no Go or dependency-manifest files are touched.v0.84.0session with a real model, reading the gateway's own ATOF output rather than asserting on hook status codes: turn scopes opening at pi's boundary, tool spans nested under their turn with attempt attribution, LLM spans nested inside their own turns, and a containment check confirming no span outlives the scope that opened it.block_llms = truerejecting a model call (recorded as a rejection mark, not a span, because the call never executed), and the inline-shell gate in pi's RPC mode — the only automatable path that reaches the bang prefix.--offline.crates/cli/tests/coverage/agents/pi_install_tests.rs: what a managed install writes, refusal over a directory Relay did not write, refusal when a second copy would load beside it, uninstall keeping an edited file, and rejection of recorded state naming a path outside the install root.Related Issues: (use one of the action keywords Closes / Fixes / Resolves / Relates to)
🤖 Generated with Claude Code