Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
a0670cc
feat(cli): add pi as a hook-path coding agent
yczhang-nv Aug 18, 2026
e17d997
fix(cli): map pi turn boundaries and serialize extension hook posts
yczhang-nv Aug 18, 2026
12d081f
fix(pi): do not end the gateway session on /reload
yczhang-nv Aug 18, 2026
18c6acf
fix(cli): stop claiming pi model traffic is redirected
yczhang-nv Aug 18, 2026
9430725
docs(pi): correct the turn hook row in the extension README
yczhang-nv Aug 18, 2026
b7f393a
feat(pi): open turn scopes at pi's boundary and attribute every hook
yczhang-nv Aug 18, 2026
839bccb
ci(pi): give the pi integration CI, a just recipe, and docs
yczhang-nv Aug 18, 2026
ebe97d2
docs(pi): state the tool-result truncation and the subagent gap
yczhang-nv Aug 18, 2026
d35d1b9
feat(pi): route pi model traffic through the gateway when it is safe
yczhang-nv Aug 18, 2026
184b416
docs(pi): drop tracker links from the extension README
yczhang-nv Aug 18, 2026
4410b3a
feat(pi): apply gateway argument transforms to pi tool calls
yczhang-nv Aug 19, 2026
27373c4
feat(pi): gate pi's bang-prefixed inline shell through the gateway
yczhang-nv Aug 19, 2026
18bbf19
feat(pi): add a doctor preflight, and harden the integration
yczhang-nv Aug 19, 2026
e4a8c39
docs(pi): record that the extension is not published to npm
yczhang-nv Aug 19, 2026
80ca784
fix(pi): correct two facts a review pass found wrong
yczhang-nv Aug 19, 2026
f2354af
docs(pi): drop a backwards reason from the inline-shell notes
yczhang-nv Aug 19, 2026
4c5f63a
fix(pi): close four review findings, two of them fail-open
yczhang-nv Aug 19, 2026
b7c8f46
fix(pi): close the remaining review findings
yczhang-nv Aug 19, 2026
de05c0f
fix(pi): identify this extension, and scope the session key by constr…
yczhang-nv Aug 19, 2026
9e94e4a
docs(pi): the tool schema is reachable; the transform design is a choice
yczhang-nv Aug 19, 2026
acd4290
fix(pi): launch from the same resolution doctor reports, and check it…
yczhang-nv Aug 19, 2026
e15aa27
fix(pi): publish an argument rewrite only once the tool start cannot …
yczhang-nv Aug 19, 2026
540b471
test(pi): exercise the three surfaces this PR added but left unasserted
yczhang-nv Aug 19, 2026
836e76b
fix(pi): close a lookup that fails open, and name the redirect skip h…
yczhang-nv Aug 19, 2026
9e9e679
chore(pi): declare the node types this workspace typechecks against
yczhang-nv Aug 19, 2026
e716494
docs(pi): complete the wire contract, the hook inventory, and the out…
yczhang-nv Aug 19, 2026
17ee262
test(pi): assert the guardrail saw the pre-rewrite arguments, not jus…
yczhang-nv Aug 19, 2026
27cf84d
docs(pi): name where each synthesized event actually comes from
yczhang-nv Aug 19, 2026
bda027f
docs(pi): evaluation is per model select; the mark is not
yczhang-nv Aug 19, 2026
3a9fa20
fix(pi): two copies load, and an installed one could not be seen
yczhang-nv Aug 19, 2026
a16c965
fix(pi): send the launcher's proxy credential on a redirected provider
yczhang-nv Aug 19, 2026
e7a6943
fix(pi): fold preflight findings into the status, configured or not
yczhang-nv Aug 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/ci-path-filters.yml
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,21 @@ node:
openclaw:
- 'integrations/openclaw/**'

# The pi extension is a hook client for the CLI gateway, so a change to the
# adapter it posts against should re-run the extension's own suite.
#
# Only the adapter paths are listed, deliberately. `test-pi` is a TypeScript
# typecheck plus Node tests against a *stub* gateway, so it cannot observe the
# `/hooks/pi` route, the event classifier, or the session manager no matter what
# changes there -- and `crates/cli/src/sessions/**` alone would put a full Node
# matrix on nearly every CLI change. Those files are covered by the `rust`
# filter, which matches every `crates/**/*.rs` file and runs the workspace tests
# that exercise the route for real.
pi:
- 'crates/cli/src/agents/pi/**'
- 'crates/cli/src/agents/shared/adapters.rs'
- 'integrations/pi/**'

python:
- 'crates/python/Cargo.toml'
- 'crates/python/src/**'
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ jobs:
ref_name: ${{ github.ref_name }}
run_package: ${{ needs.ci_changes.outputs.run_node_package == 'true' }}
run_openclaw: ${{ needs.ci_changes.outputs.run_openclaw == 'true' }}
run_pi: ${{ needs.ci_changes.outputs.run_pi == 'true' }}

ci_python:
name: Python
Expand Down
6 changes: 5 additions & 1 deletion .github/workflows/ci_changes.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ on:
run_openclaw:
description: 'Whether OpenClaw integration jobs should run'
value: ${{ jobs.changes.outputs.run_openclaw }}
run_pi:
description: 'Whether pi integration jobs should run'
value: ${{ jobs.changes.outputs.run_pi }}
run_python:
description: 'Whether Python jobs should run'
value: ${{ jobs.changes.outputs.run_python }}
Expand Down Expand Up @@ -77,9 +80,10 @@ jobs:
run_docs: ${{ (inputs.full_ci || startsWith(inputs.ref_name, 'pull-request/')) && (inputs.full_ci || steps.filter.outputs.ci == 'true' || steps.filter.outputs.shared == 'true' || steps.filter.outputs.docs == 'true') }}
run_go: ${{ inputs.full_ci || steps.filter.outputs.ci == 'true' || steps.filter.outputs.shared == 'true' || steps.filter.outputs.go == 'true' }}
run_installer: ${{ inputs.full_ci || steps.filter.outputs.installer == 'true' }}
run_node: ${{ inputs.full_ci || steps.filter.outputs.ci == 'true' || steps.filter.outputs.shared == 'true' || steps.filter.outputs.node == 'true' || steps.filter.outputs.openclaw == 'true' }}
run_node: ${{ inputs.full_ci || steps.filter.outputs.ci == 'true' || steps.filter.outputs.shared == 'true' || steps.filter.outputs.node == 'true' || steps.filter.outputs.openclaw == 'true' || steps.filter.outputs.pi == 'true' }}
run_node_package: ${{ inputs.full_ci || steps.filter.outputs.ci == 'true' || steps.filter.outputs.node_package == 'true' }}
run_openclaw: ${{ inputs.full_ci || steps.filter.outputs.ci == 'true' || steps.filter.outputs.shared == 'true' || steps.filter.outputs.node == 'true' || steps.filter.outputs.openclaw == 'true' }}
run_pi: ${{ inputs.full_ci || steps.filter.outputs.ci == 'true' || steps.filter.outputs.shared == 'true' || steps.filter.outputs.node == 'true' || steps.filter.outputs.pi == 'true' }}
run_python: ${{ inputs.full_ci || steps.filter.outputs.ci == 'true' || steps.filter.outputs.shared == 'true' || steps.filter.outputs.python == 'true' }}
run_python_integration_langchain: ${{ inputs.full_ci || steps.filter.outputs.ci == 'true' || steps.filter.outputs.shared == 'true' || steps.filter.outputs.python_integration_langchain == 'true' }}
run_python_package: ${{ inputs.full_ci || steps.filter.outputs.ci == 'true' || steps.filter.outputs.python_package == 'true' }}
Expand Down
10 changes: 10 additions & 0 deletions .github/workflows/ci_node.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ on:
required: false
default: false
type: boolean
run_pi:
description: 'Whether to run pi integration checks'
required: false
default: false
type: boolean
secrets:
CODECOV_TOKEN:
required: false
Expand Down Expand Up @@ -122,6 +127,11 @@ jobs:
working-directory: ${{ env.NEMO_RELAY_CI_WORKSPACE }}
run: just --set ci true test-openclaw

- name: Run pi integration checks
if: ${{ inputs.run_pi }}
working-directory: ${{ env.NEMO_RELAY_CI_WORKSPACE }}
run: just --set ci true test-pi

- name: Upload Node coverage to Codecov
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6
if: ${{ !startsWith(matrix.platform, 'windows') }}
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,6 @@ CHANGELOG.md
# Relay
/.nemo-relay
/artifacts/

# Generated per machine by the dynamic-plugin recipe: local dylib name + sha256.
examples/**/relay-plugin.local.toml
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@ coverage.
|:--|:--:|:--:|:--:|:--|
| Claude Code | Yes | Yes | Partial | Hook forwarding, pre-tool blocking, and gateway-routed LLM observability are supported. |
| Codex | Yes | Yes | Partial | Persistent install verifies the exact plugin hooks. Each `Stop` finalizes a turn snapshot; the supported generated schema does not install `SessionEnd`. |
| pi | Partial | Yes | No | Proof of concept. A Relay-authored pi extension forwards tool and turn activity, gates tool calls, and points the active model's provider at the gateway when the gateway fronts that provider — which is what enables model-call enforcement and LLM spans. |
| Hermes Agent | Yes | Yes | Partial | NeMo Relay is built into Hermes Agent, and Hermes Agent understands NeMo Relay plugin configurations. No separate observability plugin or Relay CLI setup is required. |

### Public API Integrations
Expand Down
6 changes: 6 additions & 0 deletions crates/cli/src/agents/claude/adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ pub(crate) fn adapt(payload: Value, headers: &HeaderMap) -> AdapterOutcome {
"PermissionDenied",
"permissionDenied",
],
// Claude Code reports only the close of a turn (`Stop`); its turns stay
// lazily opened, and `PreCompact`/`PostCompact` are matched by the
// shared fallback rather than by an adapter-specific rule.
turn_start: &[],
turn_end: &["Stop", "stop"],
compaction: &[],
},
);
// Response shape is decided by the primary event (first in the vec); secondary events like
Expand Down
6 changes: 6 additions & 0 deletions crates/cli/src/agents/codex/adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ pub(crate) fn adapt(payload: Value, headers: &HeaderMap) -> AdapterOutcome {
subagent_end: &["subagentStop", "subagentEnd", "subagent_stop"],
tool_start: &["preToolUse", "toolStarted", "tool_start"],
tool_end: &["postToolUse", "toolEnded", "tool_end", "toolFailed"],
// Codex reports only the close of a turn (`Stop`); its turns stay
// lazily opened, and `PreCompact`/`PostCompact` are matched by the
// shared fallback rather than by an adapter-specific rule.
turn_start: &[],
turn_end: &["Stop", "stop"],
compaction: &[],
},
);
AdapterOutcome {
Expand Down
78 changes: 76 additions & 2 deletions crates/cli/src/agents/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

pub(crate) mod claude;
pub(crate) mod codex;
pub(crate) mod pi;
pub(crate) mod shared;

use semver::Version;
Expand All @@ -15,6 +16,7 @@ pub(crate) enum CodingAgent {
/// `claude-code` remains an input alias for older Relay configuration.
ClaudeCode,
Codex,
Pi,
}

#[derive(Debug, Clone, Copy)]
Expand All @@ -29,13 +31,35 @@ pub(super) struct AgentDescriptor {
hook_events: &'static [&'static str],
}

/// Why pi has no marketplace-plugin implementation.
///
/// Codex and Claude Code both install NeMo Relay through a plugin marketplace
/// (`codex plugin add`, `claude plugin install`) backed by a generated manifest.
/// pi has none of that: it has no `pi plugin` verb, no marketplace, and no MCP
/// client for a plugin-owned server to serve. Extensions are installed with
/// `pi install <source>` or by placing a file in an auto-discovered directory.
///
/// Rather than synthesize manifests pi will never read, the marketplace surface
/// rejects pi explicitly and the gateway surface (hooks, launch, doctor) is
/// implemented for real.
pub(crate) const PI_MARKETPLACE_UNSUPPORTED: &str = "pi has no plugin marketplace; install the NeMo Relay pi extension with `pi install <source>` \
or place it in `~/.pi/agent/extensions/`, then run pi through `nemo-relay run --agent pi`";

/// Reached only if a marketplace code path forgets to reject pi first.
macro_rules! pi_marketplace_unreachable {
() => {
unreachable!("{}", PI_MARKETPLACE_UNSUPPORTED)
};
}

impl CodingAgent {
pub(crate) const ALL: [Self; 2] = [Self::ClaudeCode, Self::Codex];
pub(crate) const ALL: [Self; 3] = [Self::ClaudeCode, Self::Codex, Self::Pi];

const fn descriptor(self) -> AgentDescriptor {
match self {
Self::ClaudeCode => claude::DESCRIPTOR,
Self::Codex => codex::DESCRIPTOR,
Self::Pi => pi::DESCRIPTOR,
}
}

Expand Down Expand Up @@ -108,6 +132,7 @@ impl CodingAgent {
match self {
Self::ClaudeCode => claude::parse_version(raw),
Self::Codex => codex::parse_version(raw),
Self::Pi => pi::parse_version(raw),
}
}

Expand All @@ -129,6 +154,7 @@ impl CodingAgent {
match name {
"claude" | "claude-code" => Some(Self::ClaudeCode),
"codex" => Some(Self::Codex),
"pi" => Some(Self::Pi),
_ => None,
}
}
Expand Down Expand Up @@ -159,13 +185,15 @@ impl crate::installation::marketplace::MarketplaceHost for CodingAgent {
match self {
Self::Codex => &[".agents", "plugins", "marketplace.json"],
Self::ClaudeCode => &[".claude-plugin", "marketplace.json"],
Self::Pi => pi_marketplace_unreachable!(),
}
}

fn plugin_manifest_relative(self) -> &'static [&'static str] {
match self {
Self::Codex => &[".codex-plugin", "plugin.json"],
Self::ClaudeCode => &[".claude-plugin", "plugin.json"],
Self::Pi => pi_marketplace_unreachable!(),
}
}

Expand Down Expand Up @@ -206,13 +234,15 @@ impl crate::installation::marketplace::MarketplaceHost for CodingAgent {
"--scope".into(),
"user".into(),
],
Self::Pi => pi_marketplace_unreachable!(),
}
}

fn plugin_removal_args(self, plugin_name: &str, plugin_id: &str) -> Vec<String> {
match self {
Self::Codex => vec!["plugin".into(), "remove".into(), plugin_id.into()],
Self::ClaudeCode => vec!["plugin".into(), "uninstall".into(), plugin_name.into()],
Self::Pi => pi_marketplace_unreachable!(),
}
}

Expand All @@ -228,6 +258,7 @@ impl crate::installation::marketplace::MarketplaceHost for CodingAgent {
Self::ClaudeCode => {
crate::installation::marketplace::host::claude_registration_report(options, runner)
}
Self::Pi => Err(PI_MARKETPLACE_UNSUPPORTED.to_string()),
}
}

Expand All @@ -243,6 +274,7 @@ impl crate::installation::marketplace::MarketplaceHost for CodingAgent {
Self::ClaudeCode => format!(
"cannot safely replace or uninstall an existing Claude Code plugin because its MCP generation marker {problem}; close all Claude Code clients and standalone `nemo-relay mcp` processes, run `claude plugin uninstall nemo-relay-plugin` and `claude plugin marketplace remove nemo-relay-local`, remove the stale marketplace and state from the selected install directory, then run `nemo-relay install claude-code --force` to create a fenced install (and `nemo-relay uninstall claude-code` afterward if removal was intended)"
),
Self::Pi => pi_marketplace_unreachable!(),
}
}

Expand All @@ -268,6 +300,8 @@ impl crate::installation::marketplace::MarketplaceHost for CodingAgent {
|| plugin_root.join(".mcp.json").exists()
|| generation_fence.exists()
}
// pi installs nothing through this surface, so nothing to detect.
Self::Pi => false,
}
}

Expand Down Expand Up @@ -329,13 +363,15 @@ pub(crate) fn marketplace_manifest(
match agent {
CodingAgent::Codex => codex::assets::marketplace_manifest(marketplace, plugin),
CodingAgent::ClaudeCode => claude::assets::marketplace_manifest(marketplace, plugin),
CodingAgent::Pi => pi_marketplace_unreachable!(),
}
}

pub(crate) fn plugin_manifest(agent: CodingAgent, plugin: &str) -> serde_json::Value {
match agent {
CodingAgent::Codex => codex::assets::plugin_manifest(plugin),
CodingAgent::ClaudeCode => claude::assets::plugin_manifest(plugin),
CodingAgent::Pi => pi_marketplace_unreachable!(),
}
}

Expand All @@ -346,6 +382,8 @@ pub(crate) fn plugin_mcp_config(
match agent {
CodingAgent::Codex => codex::assets::mcp_config(server),
CodingAgent::ClaudeCode => Ok(claude::assets::mcp_config(server)),
// pi ships no MCP client, so a plugin-owned server would have no consumer.
CodingAgent::Pi => Err(PI_MARKETPLACE_UNSUPPORTED.to_string()),
}
}

Expand All @@ -361,7 +399,7 @@ pub(crate) fn prepare_launch(
agent: CodingAgent,
launch: &mut crate::process::PreparedAgentLaunch,
gateway_url: &str,
_resolved: &crate::configuration::ResolvedConfig,
resolved: &crate::configuration::ResolvedConfig,
proxy_credential: &crate::provider_auth::TransparentProxyCredential,
dry_run: bool,
) -> Result<(), crate::error::CliError> {
Expand All @@ -374,6 +412,10 @@ pub(crate) fn prepare_launch(
CodingAgent::ClaudeCode => {
claude::launch::prepare(launch, gateway_url, proxy_credential, dry_run)
}
// pi is the only agent whose launcher needs the gateway's upstream configuration: its
// extension redirects model traffic only when the selected model already targets that
// upstream. See `pi::launch::prepare`.
CodingAgent::Pi => pi::launch::prepare(launch, gateway_url, &resolved.gateway),
}
}

Expand All @@ -388,6 +430,7 @@ pub(crate) const fn config(
match agent {
CodingAgent::ClaudeCode => &configs.claude,
CodingAgent::Codex => &configs.codex,
CodingAgent::Pi => &configs.pi,
}
}

Expand All @@ -398,6 +441,7 @@ pub(crate) fn hook_status(
match agent {
CodingAgent::Codex => codex::doctor::hook_status(),
CodingAgent::ClaudeCode => claude::doctor::hook_status(),
CodingAgent::Pi => pi::doctor::hook_status(),
}
}

Expand Down Expand Up @@ -428,6 +472,7 @@ pub(crate) fn snapshot_setup(agent: CodingAgent) -> Result<SetupSnapshot, String
match agent {
CodingAgent::Codex => snapshot_codex_setup().map(SetupSnapshot::Codex),
CodingAgent::ClaudeCode => snapshot_claude_setup().map(SetupSnapshot::Claude),
CodingAgent::Pi => Err(PI_MARKETPLACE_UNSUPPORTED.to_string()),
}
}

Expand All @@ -449,6 +494,7 @@ pub(crate) fn setup_marketplace_plugin(
install_codex_plugin_with_generation(gateway_url, plugin_root, generation_token)
}
CodingAgent::ClaudeCode => enable_claude_provider(gateway_url),
CodingAgent::Pi => Err(PI_MARKETPLACE_UNSUPPORTED.to_string()),
}
}

Expand All @@ -460,6 +506,7 @@ pub(crate) fn uninstall_marketplace_plugin(
match agent {
CodingAgent::Codex => uninstall_codex_plugin(gateway_url, plugin_root),
CodingAgent::ClaudeCode => restore_claude_provider(gateway_url),
CodingAgent::Pi => Err(PI_MARKETPLACE_UNSUPPORTED.to_string()),
}
}

Expand All @@ -477,6 +524,7 @@ pub(crate) fn doctor_marketplace_plugin(
generation_token,
),
CodingAgent::ClaudeCode => doctor_plugin(CodingAgent::ClaudeCode, gateway_url, plugin_root),
CodingAgent::Pi => Err(PI_MARKETPLACE_UNSUPPORTED.to_string()),
}
}

Expand All @@ -490,6 +538,7 @@ pub(crate) fn doctor_marketplace_plugin_json(
CodingAgent::ClaudeCode => {
doctor_plugin_json(CodingAgent::ClaudeCode, gateway_url, plugin_root)
}
CodingAgent::Pi => Err(PI_MARKETPLACE_UNSUPPORTED.to_string()),
}
}

Expand All @@ -500,6 +549,9 @@ pub(crate) fn install_integration(
match agent {
CodingAgent::Codex => codex::install::install(command),
CodingAgent::ClaudeCode => claude::install::install(command),
CodingAgent::Pi => Err(crate::error::CliError::Install(
PI_MARKETPLACE_UNSUPPORTED.to_string(),
)),
}
}

Expand All @@ -510,6 +562,9 @@ pub(crate) fn uninstall_integration(
match agent {
CodingAgent::Codex => codex::install::uninstall(command),
CodingAgent::ClaudeCode => claude::install::uninstall(command),
CodingAgent::Pi => Err(crate::error::CliError::Install(
PI_MARKETPLACE_UNSUPPORTED.to_string(),
)),
}
}

Expand Down Expand Up @@ -789,6 +844,21 @@ pub(crate) fn doctor_plugin_json(
Some(trust),
)
}
// pi's hooks live inside a user-loaded extension rather than in a
// NeMo Relay-managed plugin root, so the only checkable fact here is
// whether that extension is discoverable.
CodingAgent::Pi => {
let extension = pi::doctor::extension_configured();
(
json!({
"plugin_binary": plugin_binary,
"sidecar_running": sidecar_running,
"pi_extension_located": extension
}),
plugin_binary && extension,
None,
)
}
};
let mut report = json!({
"ok": ok,
Expand Down Expand Up @@ -848,6 +918,10 @@ fn doctor_ok(
print_info("codex hook trust", &trust.summary());
}
}
CodingAgent::Pi => {
ok &= print_check("pi extension located", pi::doctor::extension_configured());
print_info("pi hooks", &pi::doctor::hook_status()?);
}
}
Ok(ok)
}
Expand Down
Loading
Loading