From 186d4a2ca0dca992a3674a0fd9f83a99a392bbe7 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Thu, 29 Oct 2026 10:10:25 -0700 Subject: [PATCH 1/3] fix(init): let Pi and OpenCode sessions join Relay on their own after relay init The core's activation check hard-coded open Pi and OpenCode sessions as never eligible, so `auto_activate` (which relay init turns on) put Codex and Claude sessions on the relay but never Pi or OpenCode ones. Add per-provider opt-ins `pi_auto_activate` and `opencode_auto_activate` (store settings, off by default, person-only to turn on). With `auto_activate` on, a live Pi or open OpenCode session now joins by itself only when its own opt-in is on; `auto_activate` alone (the desktop's switch) keeps today's join-only-when-added behavior. relay init turns on the opt-in for each chosen tool next to `pi_extension` / `opencode_plugin`, records it, and `--uninstall` / `--delivery mcp` turn off exactly that. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + crates/probe/contract/CoreContract.swift | 36 ++++- crates/probe/docs/relay-core.md | 2 +- crates/probe/src/relay/agents.rs | 8 +- .../probe/src/relay/agents/auto_activation.rs | 129 +++++++++++++-- .../src/relay/contract/agents/settings.rs | 30 ++++ .../probe/src/relay/local/directory_trait.rs | 11 +- .../relay/local/tests/automatic_admission.rs | 153 +++++++++++++++++- crates/probe/src/relay/settings_registry.rs | 4 +- crates/probe/src/relay/setup.rs | 15 +- crates/probe/src/relay/spawn/settings.rs | 35 +++- .../probe/src/relay/store/settings_extras.rs | 36 +++++ crates/probe/src/relay/store_settings.rs | 26 ++- docs/probe-move.md | 11 +- packages/cli/src/cli/commands/init.test.ts | 61 ++++++- packages/cli/src/cli/lib/probe-core.test.ts | 10 +- packages/cli/src/cli/lib/probe-core.ts | 13 +- 17 files changed, 541 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f9f160c7b..58e5cd6159 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Broker: `spawn:` capabilities now declare `metadata.configured.node_kind: "broker"`, so Cloud keeps placing spawns on unclaimed CLI brokers instead of refusing them until `agent-relay node claim`. An already-declared kind (e.g. `desktop`) is preserved. - `relay init` downloads the standalone Relaycast server (`relaycast-engine`) for your platform into the install directory's `bin` when it is missing, verifying its SHA-256, so the local server now starts from an npm install on any Node.js version and with npm install scripts disabled. It falls back to the `@relaycast/engine` npm package only if the download fails. +- After `relay init` sets up Pi or OpenCode, new Pi and OpenCode sessions now join Relay on their own and receive injected messages, like Codex and Claude Code. New core settings `pi_auto_activate` and `opencode_auto_activate` (off by default; `relay init` turns on the one for each tool you choose and `--uninstall` turns it off again) gate this, so turning on `auto_activate` alone still leaves Pi and OpenCode sessions joining only when added. - Codex 0.16x workers no longer dead-letter their initial task with `initial_injection_incomplete` when the composer scrolls long input. The broker now checks that the end of each typed chunk is visible and ignores Codex's `↑`/`↓` scroll markers. - `relay agent rotate `, and `agent register` for a name that already exists, now issue a new token through the audited owner takeover instead of failing with "already exists", so a lost token can always be replaced. - `@agent-relay/sdk` docs for `workspace.register()` now say registration is create-only: an existing name fails with `name_conflict` (with or without `{ strict: true }`) and keeps its token. Resume an agent with `workspace.reconnect({ apiToken })`, and get a new token with `relay agent rotate `. diff --git a/crates/probe/contract/CoreContract.swift b/crates/probe/contract/CoreContract.swift index e05cf40d3e..33de5e7dbd 100644 --- a/crates/probe/contract/CoreContract.swift +++ b/crates/probe/contract/CoreContract.swift @@ -7443,7 +7443,13 @@ public struct SettingsChange: Codable, Equatable, Hashable, Sendable { public let mobileNativeContinuation: Bool? /// Turns the OpenClaw Gateway route on or off. public let openclawGateway: Bool? + /// Whether open OpenCode sessions join by themselves while + /// `auto_activate` is on. + public let opencodeAutoActivate: Bool? public let opencodePlugin: Bool? + /// Whether open Pi sessions join by themselves while `auto_activate` + /// is on. + public let piAutoActivate: Bool? /// Installs (`true`) or removes (`false`) Agent Relay's Pi extension. public let piExtension: Bool? /// Shows each relay message whole in agent chats (`true`), or hides it @@ -7466,7 +7472,7 @@ public struct SettingsChange: Codable, Equatable, Hashable, Sendable { /// Replaces the relay senders trusted like the person. public let trustedRelaySenders: [String]? - public init(acceptSpawns: Bool? = nil, allowSelfRegistration: Bool? = nil, analyticsEnabled: Bool? = nil, autoActivate: Bool? = nil, autoJoinGeneral: Bool? = nil, claudeCloudSessions: Bool? = nil, claudeConfigDir: ClaudeConfigDirChange? = nil, claudeConfigDirs: [String]? = nil, claudeSessionHook: Bool? = nil, codexStopHook: Bool? = nil, contractorAccess: Bool? = nil, cursorDirectDelivery: Bool? = nil, devinDirectDelivery: Bool? = nil, directDelivery: Bool? = nil, directDeliveryDefault: Bool? = nil, grokLeader: Bool? = nil, hermesAgent: Bool? = nil, historyRetentionDays: Int? = nil, mobileClaudeHandoff: Bool? = nil, mobileNativeContinuation: Bool? = nil, openclawGateway: Bool? = nil, opencodePlugin: Bool? = nil, piExtension: Bool? = nil, showRelayMessages: Bool? = nil, spawnAllowTeammates: Bool? = nil, spawnBillingClaude: String? = nil, spawnBillingCodex: String? = nil, spawnFullAccessClaude: Bool? = nil, spawnFullAccessCodex: Bool? = nil, spawnHarnesses: [String: SpawnHarnessSettings]? = nil, spawnMaxAgents: Int? = nil, spawnNativeInput: Bool? = nil, spawnRepositories: [String]? = nil, teammateNativeInput: Bool? = nil, trustWorkspaceMembers: Bool? = nil, trustedRelaySenders: [String]? = nil) { + public init(acceptSpawns: Bool? = nil, allowSelfRegistration: Bool? = nil, analyticsEnabled: Bool? = nil, autoActivate: Bool? = nil, autoJoinGeneral: Bool? = nil, claudeCloudSessions: Bool? = nil, claudeConfigDir: ClaudeConfigDirChange? = nil, claudeConfigDirs: [String]? = nil, claudeSessionHook: Bool? = nil, codexStopHook: Bool? = nil, contractorAccess: Bool? = nil, cursorDirectDelivery: Bool? = nil, devinDirectDelivery: Bool? = nil, directDelivery: Bool? = nil, directDeliveryDefault: Bool? = nil, grokLeader: Bool? = nil, hermesAgent: Bool? = nil, historyRetentionDays: Int? = nil, mobileClaudeHandoff: Bool? = nil, mobileNativeContinuation: Bool? = nil, openclawGateway: Bool? = nil, opencodeAutoActivate: Bool? = nil, opencodePlugin: Bool? = nil, piAutoActivate: Bool? = nil, piExtension: Bool? = nil, showRelayMessages: Bool? = nil, spawnAllowTeammates: Bool? = nil, spawnBillingClaude: String? = nil, spawnBillingCodex: String? = nil, spawnFullAccessClaude: Bool? = nil, spawnFullAccessCodex: Bool? = nil, spawnHarnesses: [String: SpawnHarnessSettings]? = nil, spawnMaxAgents: Int? = nil, spawnNativeInput: Bool? = nil, spawnRepositories: [String]? = nil, teammateNativeInput: Bool? = nil, trustWorkspaceMembers: Bool? = nil, trustedRelaySenders: [String]? = nil) { self.acceptSpawns = acceptSpawns self.allowSelfRegistration = allowSelfRegistration self.analyticsEnabled = analyticsEnabled @@ -7488,7 +7494,9 @@ public struct SettingsChange: Codable, Equatable, Hashable, Sendable { self.mobileClaudeHandoff = mobileClaudeHandoff self.mobileNativeContinuation = mobileNativeContinuation self.openclawGateway = openclawGateway + self.opencodeAutoActivate = opencodeAutoActivate self.opencodePlugin = opencodePlugin + self.piAutoActivate = piAutoActivate self.piExtension = piExtension self.showRelayMessages = showRelayMessages self.spawnAllowTeammates = spawnAllowTeammates @@ -7527,7 +7535,9 @@ public struct SettingsChange: Codable, Equatable, Hashable, Sendable { case mobileClaudeHandoff = "mobile_claude_handoff" case mobileNativeContinuation = "mobile_native_continuation" case openclawGateway = "openclaw_gateway" + case opencodeAutoActivate = "opencode_auto_activate" case opencodePlugin = "opencode_plugin" + case piAutoActivate = "pi_auto_activate" case piExtension = "pi_extension" case showRelayMessages = "show_relay_messages" case spawnAllowTeammates = "spawn_allow_teammates" @@ -7567,7 +7577,9 @@ public struct SettingsChange: Codable, Equatable, Hashable, Sendable { self.mobileClaudeHandoff = try c.decodeIfPresent(Bool.self, forKey: .mobileClaudeHandoff) self.mobileNativeContinuation = try c.decodeIfPresent(Bool.self, forKey: .mobileNativeContinuation) self.openclawGateway = try c.decodeIfPresent(Bool.self, forKey: .openclawGateway) + self.opencodeAutoActivate = try c.decodeIfPresent(Bool.self, forKey: .opencodeAutoActivate) self.opencodePlugin = try c.decodeIfPresent(Bool.self, forKey: .opencodePlugin) + self.piAutoActivate = try c.decodeIfPresent(Bool.self, forKey: .piAutoActivate) self.piExtension = try c.decodeIfPresent(Bool.self, forKey: .piExtension) self.showRelayMessages = try c.decodeIfPresent(Bool.self, forKey: .showRelayMessages) self.spawnAllowTeammates = try c.decodeIfPresent(Bool.self, forKey: .spawnAllowTeammates) @@ -7607,7 +7619,9 @@ public struct SettingsChange: Codable, Equatable, Hashable, Sendable { try c.encodeIfPresent(self.mobileClaudeHandoff, forKey: .mobileClaudeHandoff) try c.encodeIfPresent(self.mobileNativeContinuation, forKey: .mobileNativeContinuation) try c.encodeIfPresent(self.openclawGateway, forKey: .openclawGateway) + try c.encodeIfPresent(self.opencodeAutoActivate, forKey: .opencodeAutoActivate) try c.encodeIfPresent(self.opencodePlugin, forKey: .opencodePlugin) + try c.encodeIfPresent(self.piAutoActivate, forKey: .piAutoActivate) try c.encodeIfPresent(self.piExtension, forKey: .piExtension) try c.encodeIfPresent(self.showRelayMessages, forKey: .showRelayMessages) try c.encodeIfPresent(self.spawnAllowTeammates, forKey: .spawnAllowTeammates) @@ -7712,10 +7726,20 @@ public struct SettingsValues: Codable, Equatable, Hashable, Sendable { /// limit relay sessions (older than 2026.8.1): its relay senders then /// get the agent's usual tools. Read-only; absent otherwise. public let openclawUnlimitedVersion: String? + /// Whether an open OpenCode session joins the relay by itself while + /// `auto_activate` is on. Off unless turned on (`relay init` turns it on + /// for a person who chose OpenCode): with it off, an OpenCode session + /// joins only when added. Absent in older cores. + public let opencodeAutoActivate: Bool? /// Whether Agent Relay's OpenCode plugin is installed in OpenCode's /// global `plugins/`, the route into a plain OpenCode session. Absent /// where OpenCode has never run. public let opencodePlugin: Bool? + /// Whether an open Pi session joins the relay by itself while + /// `auto_activate` is on. Off unless turned on (`relay init` turns it on + /// for a person who chose Pi): with it off, a Pi session joins only when + /// added. Absent in older cores. + public let piAutoActivate: Bool? /// Whether Agent Relay's Pi extension is installed in Pi's global /// `extensions/`, the route into a Pi session. The desktop's core /// installs it at start unless it was turned off; a core `relay init` @@ -7773,7 +7797,7 @@ public struct SettingsValues: Codable, Equatable, Hashable, Sendable { /// off, signed out, or Cloud's list has not been read lately. public let workspaceMembers: [TrustedWorkspaceMember]? - public init(acceptSpawns: Bool? = nil, agentChanges: [AgentSettingsChange]? = nil, allowSelfRegistration: Bool, analyticsEnabled: Bool? = nil, autoActivate: Bool, autoJoinGeneral: Bool? = nil, claudeCloudSessions: Bool? = nil, claudeConfigDirs: [ClaudeConfigDir]? = nil, claudeSessionHook: Bool? = nil, codexStopHook: Bool? = nil, contractorAccess: Bool? = nil, cursorDirectDelivery: Bool? = nil, devinDirectDelivery: Bool? = nil, directDelivery: Bool? = nil, directDeliveryManaged: String? = nil, grokLeader: Bool? = nil, hermesAgent: Bool? = nil, historyRetentionDays: Int? = nil, mobileClaudeHandoff: Bool? = nil, mobileClaudeHandoffSupported: Bool? = nil, mobileNativeContinuation: Bool? = nil, mobileNativeContinuationSupported: Bool? = nil, openclawGateway: Bool? = nil, openclawUnlimitedVersion: String? = nil, opencodePlugin: Bool? = nil, piExtension: Bool? = nil, showRelayMessages: Bool? = nil, spawnAllowTeammates: Bool? = nil, spawnBillingClaude: String? = nil, spawnBillingCodex: String? = nil, spawnFullAccessClaude: Bool? = nil, spawnFullAccessCodex: Bool? = nil, spawnHarnesses: [String: SpawnHarnessSettings]? = nil, spawnMaxAgents: Int? = nil, spawnNativeInput: Bool? = nil, spawnRepositories: [String]? = nil, teammateNativeInput: Bool? = nil, trustWorkspaceMembers: Bool? = nil, trustedRelaySenders: [String]? = nil, workspaceMembers: [TrustedWorkspaceMember]? = nil) { + public init(acceptSpawns: Bool? = nil, agentChanges: [AgentSettingsChange]? = nil, allowSelfRegistration: Bool, analyticsEnabled: Bool? = nil, autoActivate: Bool, autoJoinGeneral: Bool? = nil, claudeCloudSessions: Bool? = nil, claudeConfigDirs: [ClaudeConfigDir]? = nil, claudeSessionHook: Bool? = nil, codexStopHook: Bool? = nil, contractorAccess: Bool? = nil, cursorDirectDelivery: Bool? = nil, devinDirectDelivery: Bool? = nil, directDelivery: Bool? = nil, directDeliveryManaged: String? = nil, grokLeader: Bool? = nil, hermesAgent: Bool? = nil, historyRetentionDays: Int? = nil, mobileClaudeHandoff: Bool? = nil, mobileClaudeHandoffSupported: Bool? = nil, mobileNativeContinuation: Bool? = nil, mobileNativeContinuationSupported: Bool? = nil, openclawGateway: Bool? = nil, openclawUnlimitedVersion: String? = nil, opencodeAutoActivate: Bool? = nil, opencodePlugin: Bool? = nil, piAutoActivate: Bool? = nil, piExtension: Bool? = nil, showRelayMessages: Bool? = nil, spawnAllowTeammates: Bool? = nil, spawnBillingClaude: String? = nil, spawnBillingCodex: String? = nil, spawnFullAccessClaude: Bool? = nil, spawnFullAccessCodex: Bool? = nil, spawnHarnesses: [String: SpawnHarnessSettings]? = nil, spawnMaxAgents: Int? = nil, spawnNativeInput: Bool? = nil, spawnRepositories: [String]? = nil, teammateNativeInput: Bool? = nil, trustWorkspaceMembers: Bool? = nil, trustedRelaySenders: [String]? = nil, workspaceMembers: [TrustedWorkspaceMember]? = nil) { self.acceptSpawns = acceptSpawns self.agentChanges = agentChanges self.allowSelfRegistration = allowSelfRegistration @@ -7798,7 +7822,9 @@ public struct SettingsValues: Codable, Equatable, Hashable, Sendable { self.mobileNativeContinuationSupported = mobileNativeContinuationSupported self.openclawGateway = openclawGateway self.openclawUnlimitedVersion = openclawUnlimitedVersion + self.opencodeAutoActivate = opencodeAutoActivate self.opencodePlugin = opencodePlugin + self.piAutoActivate = piAutoActivate self.piExtension = piExtension self.showRelayMessages = showRelayMessages self.spawnAllowTeammates = spawnAllowTeammates @@ -7841,7 +7867,9 @@ public struct SettingsValues: Codable, Equatable, Hashable, Sendable { case mobileNativeContinuationSupported = "mobile_native_continuation_supported" case openclawGateway = "openclaw_gateway" case openclawUnlimitedVersion = "openclaw_unlimited_version" + case opencodeAutoActivate = "opencode_auto_activate" case opencodePlugin = "opencode_plugin" + case piAutoActivate = "pi_auto_activate" case piExtension = "pi_extension" case showRelayMessages = "show_relay_messages" case spawnAllowTeammates = "spawn_allow_teammates" @@ -7885,7 +7913,9 @@ public struct SettingsValues: Codable, Equatable, Hashable, Sendable { self.mobileNativeContinuationSupported = try c.decodeIfPresent(Bool.self, forKey: .mobileNativeContinuationSupported) self.openclawGateway = try c.decodeIfPresent(Bool.self, forKey: .openclawGateway) self.openclawUnlimitedVersion = try c.decodeIfPresent(String.self, forKey: .openclawUnlimitedVersion) + self.opencodeAutoActivate = try c.decodeIfPresent(Bool.self, forKey: .opencodeAutoActivate) self.opencodePlugin = try c.decodeIfPresent(Bool.self, forKey: .opencodePlugin) + self.piAutoActivate = try c.decodeIfPresent(Bool.self, forKey: .piAutoActivate) self.piExtension = try c.decodeIfPresent(Bool.self, forKey: .piExtension) self.showRelayMessages = try c.decodeIfPresent(Bool.self, forKey: .showRelayMessages) self.spawnAllowTeammates = try c.decodeIfPresent(Bool.self, forKey: .spawnAllowTeammates) @@ -7929,7 +7959,9 @@ public struct SettingsValues: Codable, Equatable, Hashable, Sendable { try c.encodeIfPresent(self.mobileNativeContinuationSupported, forKey: .mobileNativeContinuationSupported) try c.encodeIfPresent(self.openclawGateway, forKey: .openclawGateway) try c.encodeIfPresent(self.openclawUnlimitedVersion, forKey: .openclawUnlimitedVersion) + try c.encodeIfPresent(self.opencodeAutoActivate, forKey: .opencodeAutoActivate) try c.encodeIfPresent(self.opencodePlugin, forKey: .opencodePlugin) + try c.encodeIfPresent(self.piAutoActivate, forKey: .piAutoActivate) try c.encodeIfPresent(self.piExtension, forKey: .piExtension) try c.encodeIfPresent(self.showRelayMessages, forKey: .showRelayMessages) try c.encodeIfPresent(self.spawnAllowTeammates, forKey: .spawnAllowTeammates) diff --git a/crates/probe/docs/relay-core.md b/crates/probe/docs/relay-core.md index 6fe2e4f45f..4d326750a3 100644 --- a/crates/probe/docs/relay-core.md +++ b/crates/probe/docs/relay-core.md @@ -521,7 +521,7 @@ uploads already; an install a newer probe runs is left alone. | `agents.webhook.revoke` | `{"name"}` | `{"already_deleted"}` | 2a | | `agents.integrations.subscribe` | `{"name","provider","resource"}` | `{"subscribed":true,"provider","resource","path_glob"}` | 2a | | `agents.integrations.unsubscribe` / `.clean_up` | `{"name","provider","resource"}` | `{"subscribed":false,"provider","resource","already_unsubscribed"?}`; also finishes an unfinished subscription | 2a | -| `settings.get` / `settings.set` | `{"allow_self_registration"?, "auto_activate"?, "claude_session_hook"?, "direct_delivery"?, "direct_delivery_default"?, "devin_direct_delivery"?, "pi_extension"?, "analytics_enabled"?, "contractor_access"?}` | All settings, plus the read-only `direct_delivery_managed` (the organization's `crossSessionInbound`, absent when it sets none); `direct_delivery_default` is write-only: `true` sets `accept` unless the user's own Claude settings already name `crossSessionInbound`, `false` changes nothing; `claude_session_hook` is off unless turned on and absent when no Claude login directory exists (the SessionStart hook never registers a session); `devin_direct_delivery` adds or removes Agent Relay's lifecycle hooks in `~/.config/devin/config.json`, on by default where Devin has run, and absent where it has not; `pi_extension` installs or removes Agent Relay's extension in `~/.pi/agent/extensions` (only its own file), installed at start by a desktop core unless turned off and only on request by a `--cli-managed` one; while it is off no Pi session is listed and new messages to Pi are refused, even for a session that loaded the extension earlier, though a message already queued before it was turned off stays accepted and that loaded extension can still deliver it; absent where Pi has not run; a setting the core runs without is `invalid_params`, and so are an unknown or read-only key and a non-boolean, which change nothing | 2a (store settings), 2b (`direct_delivery`, `claude_session_hook`, `devin_direct_delivery`), 2c (`analytics_enabled`, `contractor_access`) | +| `settings.get` / `settings.set` | `{"allow_self_registration"?, "auto_activate"?, "claude_session_hook"?, "direct_delivery"?, "direct_delivery_default"?, "devin_direct_delivery"?, "pi_extension"?, "pi_auto_activate"?, "opencode_auto_activate"?, "analytics_enabled"?, "contractor_access"?}` | All settings, plus the read-only `direct_delivery_managed` (the organization's `crossSessionInbound`, absent when it sets none); `direct_delivery_default` is write-only: `true` sets `accept` unless the user's own Claude settings already name `crossSessionInbound`, `false` changes nothing; `claude_session_hook` is off unless turned on and absent when no Claude login directory exists (the SessionStart hook never registers a session); `devin_direct_delivery` adds or removes Agent Relay's lifecycle hooks in `~/.config/devin/config.json`, on by default where Devin has run, and absent where it has not; `pi_extension` installs or removes Agent Relay's extension in `~/.pi/agent/extensions` (only its own file), installed at start by a desktop core unless turned off and only on request by a `--cli-managed` one; while it is off no Pi session is listed and new messages to Pi are refused, even for a session that loaded the extension earlier, though a message already queued before it was turned off stays accepted and that loaded extension can still deliver it; absent where Pi has not run; `pi_auto_activate` and `opencode_auto_activate` (off unless turned on; `relay init` turns on the one for each tool chosen) let an open Pi or OpenCode session join by itself while `auto_activate` is on, which alone never activates those kinds; a setting the core runs without is `invalid_params`, and so are an unknown or read-only key and a non-boolean, which change nothing | 2a (store settings), 2b (`direct_delivery`, `claude_session_hook`, `devin_direct_delivery`), 2c (`analytics_enabled`, `contractor_access`) | | `activity.list` | `{"limit"?}` | `{"activity":[{"at_ms","agent","sender","preview","outcome":"injected"\|"skipped"\|"failed"\|"waiting"\|"in_doubt"\|"answered","detail","settlement":"handed_off"\|"waiting_for_turn"\|"visible"\|null}]}`, newest first; `settlement` is set on entries that place the message in the session (a queue handoff, a handoff behind the session's running turn, or the message recorded in the session) and includes Relay Connect deliveries under the participant's name | 2a | | `claude_delivery.status` | — | `{"restricted","sessions":[{"id","agent","title","direct"}],"allowed_senders":[{"address","name"}],"held":[{"id","agent","session_id","sender","sender_name","text","held_at_ms"}]}` | 2b | | `claude_delivery.restrict` / `.session` / `.sender` | `{"restricted"}` / `{"session_id","direct"}` / `{"sender","name","allowed"}` | `{}`; controls allowed-member enforcement, per-session Ask first, and the exact sender allowlist | 2b | diff --git a/crates/probe/src/relay/agents.rs b/crates/probe/src/relay/agents.rs index 0b822e5362..72142d9d5a 100644 --- a/crates/probe/src/relay/agents.rs +++ b/crates/probe/src/relay/agents.rs @@ -1646,12 +1646,14 @@ impl Core { // OpenClaw Gateway that answers this user's loopback handshake, and // a Hermes API that answers its own key are already caller proof, // re-proved on every send — though an explicit Auto-activate off is - // still honored. Every other provider needs the setting on. - let setting = self.store.read(|s| s.auto_activate); + // still honored. Every other provider needs the setting on, and an + // open Pi or OpenCode session its own opt-in too. + let settings = self.store.read(auto_activation::ActivationSettings::of); + let setting = settings.auto_activate; if setting == Some(false) { return failures; } - found.retain(|target, _| auto_activation::activation_applies(setting, target.kind)); + found.retain(|target, _| auto_activation::activation_applies(&settings, target.kind)); if setting.is_none() && found.is_empty() { return failures; } diff --git a/crates/probe/src/relay/agents/auto_activation.rs b/crates/probe/src/relay/agents/auto_activation.rs index a76fd8a321..904d7dfd81 100644 --- a/crates/probe/src/relay/agents/auto_activation.rs +++ b/crates/probe/src/relay/agents/auto_activation.rs @@ -1,6 +1,6 @@ //! Which sessions join the relay without the auto-activate opt-in. -use crate::relay::store::SessionKind; +use crate::relay::store::{SessionKind, Store}; /// Kinds joining without the auto-activate opt-in: Devin's lock proof and /// a Muse session's live registry record are already caller proof, and an @@ -13,13 +13,47 @@ pub(crate) fn auto_activates_by_default(kind: SessionKind) -> bool { ) } +/// The store settings automatic activation reads: `auto_activate`, and the +/// per-provider opt-ins that widen it to open Pi and OpenCode sessions. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct ActivationSettings { + pub auto_activate: Option, + pub pi: bool, + pub opencode: bool, +} + +impl ActivationSettings { + pub(crate) fn of(store: &Store) -> Self { + Self { + auto_activate: store.auto_activate, + pi: store.pi_auto_activates(), + opencode: store.opencode_auto_activates(), + } + } +} + +/// Kinds that join only with their own opt-in on top of `auto_activate`: an +/// open Pi or OpenCode session is reached through an extension or plugin +/// the desktop installs by default, so turning `auto_activate` on alone +/// never puts one on the relay; `pi_auto_activate` / `opencode_auto_activate` +/// (which `relay init` turns on for a person who chose that tool) do. +fn own_opt_in(settings: &ActivationSettings, kind: SessionKind) -> Option { + match kind { + SessionKind::Pi => Some(settings.pi), + SessionKind::OpenCodeOpen => Some(settings.opencode), + _ => None, + } +} + /// Whether automatic activation applies to sessions of `kind` under the -/// `auto_activate` setting: on, every kind; off, none; unset, only the kinds -/// that join by default. The one policy for activation itself and for -/// `/setup/status`'s `auto_activates`. -pub(crate) fn activation_applies(setting: Option, kind: SessionKind) -> bool { - match setting { - Some(enabled) => enabled, +/// `auto_activate` setting: on, every kind (Pi and open OpenCode only with +/// their own opt-in too); off, none; unset, only the kinds that join by +/// default. The one policy for activation itself and for `/setup/status`'s +/// `auto_activates`. +pub(crate) fn activation_applies(settings: &ActivationSettings, kind: SessionKind) -> bool { + match settings.auto_activate { + Some(true) => own_opt_in(settings, kind).unwrap_or(true), + Some(false) => false, None => auto_activates_by_default(kind), } } @@ -40,6 +74,13 @@ mod tests { assert!(!auto_activates_by_default(SessionKind::OpenCodeOpen)); } + fn setting(auto_activate: Option) -> ActivationSettings { + ActivationSettings { + auto_activate, + ..ActivationSettings::default() + } + } + #[test] fn the_setting_decides_and_unset_keeps_the_default_kinds() { for kind in [ @@ -47,15 +88,77 @@ mod tests { SessionKind::MuseOpen, SessionKind::Codex, ] { - assert!(activation_applies(Some(true), kind)); - assert!(!activation_applies(Some(false), kind)); + assert!(activation_applies(&setting(Some(true)), kind)); + assert!(!activation_applies(&setting(Some(false)), kind)); assert_eq!( - activation_applies(None, kind), + activation_applies(&setting(None), kind), auto_activates_by_default(kind) ); } - assert!(activation_applies(None, SessionKind::Devin)); - assert!(activation_applies(None, SessionKind::MuseOpen)); - assert!(!activation_applies(None, SessionKind::ClaudeTerminal)); + assert!(activation_applies(&setting(None), SessionKind::Devin)); + assert!(activation_applies(&setting(None), SessionKind::MuseOpen)); + assert!(!activation_applies( + &setting(None), + SessionKind::ClaudeTerminal + )); + } + + // `auto_activate` on alone (a desktop person who turned it on) keeps + // Pi and open OpenCode sessions joining only when added; each joins by + // itself once its own opt-in is on too, and neither opt-in widens the + // other kind or overrides `auto_activate` off or unset. + #[test] + fn pi_and_opencode_need_their_own_opt_in_on_top_of_auto_activate() { + for kind in [SessionKind::Pi, SessionKind::OpenCodeOpen] { + assert!(!activation_applies(&setting(Some(true)), kind), "{kind:?}"); + assert!(!activation_applies(&setting(None), kind), "{kind:?}"); + } + let both = ActivationSettings { + auto_activate: Some(true), + pi: true, + opencode: true, + }; + assert!(activation_applies(&both, SessionKind::Pi)); + assert!(activation_applies(&both, SessionKind::OpenCodeOpen)); + let pi_only = ActivationSettings { + opencode: false, + ..both + }; + assert!(activation_applies(&pi_only, SessionKind::Pi)); + assert!(!activation_applies(&pi_only, SessionKind::OpenCodeOpen)); + for auto_activate in [Some(false), None] { + let opted_in = ActivationSettings { + auto_activate, + ..both + }; + assert!(!activation_applies(&opted_in, SessionKind::Pi)); + assert!(!activation_applies(&opted_in, SessionKind::OpenCodeOpen)); + } + // The opt-ins never change another kind. + assert!(activation_applies(&both, SessionKind::Codex)); + assert!(!activation_applies( + &ActivationSettings { + auto_activate: Some(false), + ..both + }, + SessionKind::Codex + )); + } + + #[test] + fn the_settings_are_read_from_the_store() { + let mut store = Store::default(); + assert_eq!(ActivationSettings::of(&store), setting(None)); + store.auto_activate = Some(true); + store.extras.pi_auto_activate = Some(true); + store.extras.opencode_auto_activate = Some(false); + assert_eq!( + ActivationSettings::of(&store), + ActivationSettings { + auto_activate: Some(true), + pi: true, + opencode: false, + } + ); } } diff --git a/crates/probe/src/relay/contract/agents/settings.rs b/crates/probe/src/relay/contract/agents/settings.rs index 05449537fd..c12e1711ed 100644 --- a/crates/probe/src/relay/contract/agents/settings.rs +++ b/crates/probe/src/relay/contract/agents/settings.rs @@ -123,6 +123,18 @@ pub struct SettingsValues { /// never run. #[serde(default, skip_serializing_if = "Option::is_none")] pub pi_extension: Option, + /// Whether an open Pi session joins the relay by itself while + /// `auto_activate` is on. Off unless turned on (`relay init` turns it on + /// for a person who chose Pi): with it off, a Pi session joins only when + /// added. Absent in older cores. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pi_auto_activate: Option, + /// Whether an open OpenCode session joins the relay by itself while + /// `auto_activate` is on. Off unless turned on (`relay init` turns it on + /// for a person who chose OpenCode): with it off, an OpenCode session + /// joins only when added. Absent in older cores. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opencode_auto_activate: Option, /// Whether Agent Relay lists the agents of the local OpenClaw Gateway /// and delivers to them, each relay sender in a session of its own. /// Absent where OpenClaw is not set up. @@ -434,6 +446,24 @@ pub struct SettingsChange { )] #[schemars(with = "bool")] pub pi_extension: Option, + /// Whether open Pi sessions join by themselves while `auto_activate` + /// is on. + #[serde( + default, + deserialize_with = "given", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "bool")] + pub pi_auto_activate: Option, + /// Whether open OpenCode sessions join by themselves while + /// `auto_activate` is on. + #[serde( + default, + deserialize_with = "given", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "bool")] + pub opencode_auto_activate: Option, /// Turns the OpenClaw Gateway route on or off. #[serde( default, diff --git a/crates/probe/src/relay/local/directory_trait.rs b/crates/probe/src/relay/local/directory_trait.rs index 2eff0dcdff..503b0f0b1c 100644 --- a/crates/probe/src/relay/local/directory_trait.rs +++ b/crates/probe/src/relay/local/directory_trait.rs @@ -415,8 +415,15 @@ impl LocalSessions { // and delivery re-proves each one in Muse's own registry // on every send, pinning the courier reference. SessionKind::MuseOpen => true, - // Opt-in, like every provider route the plugin opens. - SessionKind::OpenCodeOpen | SessionKind::Pi => false, + // A Pi row exists only while a live Pi process of this user + // holds its private registry open through Agent Relay's + // extension, and an open OpenCode row only while a live + // OpenCode process of this user serves it through the + // plugin's socket; delivery re-proves that process on every + // send. Whether either joins by itself is the store's + // per-provider opt-in (`activation_applies`), so it is + // eligible here like a Codex thread. + SessionKind::OpenCodeOpen | SessionKind::Pi => true, // A Devin row exists only while a `devin` process holds // the session's lock open, which is also its caller // proof. Devin Cloud rows return early above; any other diff --git a/crates/probe/src/relay/local/tests/automatic_admission.rs b/crates/probe/src/relay/local/tests/automatic_admission.rs index 914fe10139..51ea14f590 100644 --- a/crates/probe/src/relay/local/tests/automatic_admission.rs +++ b/crates/probe/src/relay/local/tests/automatic_admission.rs @@ -48,7 +48,7 @@ async fn a_retained_closed_codex_row_cannot_create_an_automatic_registration() { let data = home.join("app-data"); fake_relaycast::sign_in_store(&data, fake_relaycast::WORKSPACE_KEY, &fake.base_url); let sessions = Arc::new(world.sessions); - let (core, _signals) = Core::with_plugins( + let (core, signals) = Core::with_plugins( Config { paths: CorePaths::for_socket(home.join("core.sock")).unwrap(), home: home.into(), @@ -118,6 +118,157 @@ async fn a_retained_closed_codex_row_cannot_create_an_automatic_registration() { .read(|store| store.registrations.iter().any(|row| row.target == target))); } +const PI_SESSION: &str = "12345678-1234-4234-8234-123456789abc"; + +/// A live Pi session in `world`'s Pi runtime, as Agent Relay's extension +/// records it: a private registry its process (pid 600) holds open. +fn live_pi_session(world: &mut World) -> Target { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + let uid = crate::relay::peer::current_uid(); + world.sessions.config.uid = uid; + let runtime = world.sessions.config.pi.runtime.clone(); + let instance = runtime.join("123456789012345678901234567890ab"); + std::fs::create_dir_all(&instance).unwrap(); + for dir in [&runtime, &instance] { + std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + let registry = instance.join("session.json"); + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(®istry) + .unwrap(); + std::io::Write::write_all( + &mut file, + serde_json::json!({"version": 1, "pid": 600, "sessionId": PI_SESSION, + "instance": "123456789012345678901234567890ab", "cwd": "/code/pi-project", + "title": "Pi task"}) + .to_string() + .as_bytes(), + ) + .unwrap(); + world.table.insert( + 600, + FakeProcess { + uid, + executable: Some("/usr/local/bin/pi".into()), + open_files: vec![registry.canonicalize().unwrap()], + has_terminal: true, + ..process(1, 60) + }, + ); + Target::new(SessionKind::Pi, PI_SESSION) +} + +/// A core over `sessions`, signed in to `fake`'s workspace. +fn core_over( + home: &std::path::Path, + fake: &fake_relaycast::FakeRelaycast, + sessions: Arc, +) -> ( + Arc, + tokio::sync::mpsc::UnboundedReceiver, +) { + let data = home.join("app-data"); + fake_relaycast::sign_in_store(&data, fake_relaycast::WORKSPACE_KEY, &fake.base_url); + let (core, signals) = Core::with_plugins( + Config { + paths: CorePaths::for_socket(home.join("core.sock")).unwrap(), + home: home.into(), + app_data_dir: data, + env: Default::default(), + version: "test".into(), + host_name: "Test Desk.local".into(), + rediscover_every: Duration::from_secs(3600), + push_timing: PushTiming::default(), + delivery_timing: DeliveryTiming::default(), + }, + Plugins { + sessions, + ..Default::default() + }, + ); + core.rediscover_workspace(); + (core, signals) +} + +// `auto_activate` on alone (the desktop's Auto-activate switch) leaves an +// open Pi session joining only when added; `pi_auto_activate`, which +// `relay init` turns on for a person who chose Pi, puts it on the relay by +// itself, pinned to the live extension that holds it. +#[tokio::test] +async fn a_pi_session_auto_activates_only_with_its_own_opt_in() { + let mut world = world(); + let target = live_pi_session(&mut world); + let home = world._temp.path().to_path_buf(); + let fake = fake_relaycast::FakeRelaycast::start(Default::default()).await; + let sessions = Arc::new(world.sessions); + assert!( + sessions + .auto_activation_sessions() + .iter() + .any(|row| row.target == target), + "a live Pi session is eligible like a Codex thread" + ); + let (core, _signals) = core_over(&home, &fake, sessions); + core.store + .update(|store| store.auto_activate = Some(true)) + .unwrap(); + assert!(core.activate_new_sessions().await.is_empty()); + assert_eq!(fake.calls("POST", "/v1/agents").len(), 0); + assert!(core.store.read(|store| store.registrations.is_empty())); + + // OpenCode's opt-in does not widen Pi. + core.store + .update(|store| store.extras.opencode_auto_activate = Some(true)) + .unwrap(); + assert!(core.activate_new_sessions().await.is_empty()); + assert!(core.store.read(|store| store.registrations.is_empty())); + + core.store + .update(|store| store.extras.pi_auto_activate = Some(true)) + .unwrap(); + assert!(core.activate_new_sessions().await.is_empty()); + assert_eq!(fake.calls("POST", "/v1/agents").len(), 1); + let registration = core + .store + .read(|store| store.registration_for(&target).cloned()) + .expect("the Pi session joined by itself"); + assert!( + registration + .route_binding + .as_deref() + .is_some_and(|binding| binding.starts_with("pi:600:")), + "{:?}", + registration.route_binding + ); +} + +// An explicit Auto-activate off, or none, is honored over the Pi opt-in. +#[tokio::test] +async fn the_pi_opt_in_needs_auto_activate_on() { + let mut world = world(); + live_pi_session(&mut world); + let home = world._temp.path().to_path_buf(); + let fake = fake_relaycast::FakeRelaycast::start(Default::default()).await; + let (core, _signals) = core_over(&home, &fake, Arc::new(world.sessions)); + for auto_activate in [Some(false), None] { + core.store + .update(|store| { + store.auto_activate = auto_activate; + store.extras.pi_auto_activate = Some(true); + }) + .unwrap(); + assert!(core.activate_new_sessions().await.is_empty()); + assert_eq!( + fake.calls("POST", "/v1/agents").len(), + 0, + "{auto_activate:?}" + ); + } +} + #[cfg(feature = "history")] /// `cloud_catalog` with each row's originator (`automation` for a /// Devin review). diff --git a/crates/probe/src/relay/settings_registry.rs b/crates/probe/src/relay/settings_registry.rs index 0bdba666e0..3930a98a3c 100644 --- a/crates/probe/src/relay/settings_registry.rs +++ b/crates/probe/src/relay/settings_registry.rs @@ -36,7 +36,7 @@ pub const RECORDS: [&str; 1] = ["agent_changes"]; /// Claude Code's Keychain login; mobile handoff changes where sessions /// run; mobile continuation permits native owner input). An agent may turn /// them off. -pub const PERSON_TURNS_ON: [&str; 23] = [ +pub const PERSON_TURNS_ON: [&str; 25] = [ "accept_spawns", "allow_self_registration", "auto_activate", @@ -52,7 +52,9 @@ pub const PERSON_TURNS_ON: [&str; 23] = [ "mobile_claude_handoff", "mobile_native_continuation", "openclaw_gateway", + "opencode_auto_activate", "opencode_plugin", + "pi_auto_activate", "pi_extension", "spawn_allow_teammates", "spawn_full_access_claude", diff --git a/crates/probe/src/relay/setup.rs b/crates/probe/src/relay/setup.rs index e1da84305e..3a419031c8 100644 --- a/crates/probe/src/relay/setup.rs +++ b/crates/probe/src/relay/setup.rs @@ -299,8 +299,16 @@ impl Setup { let found = tokio::task::spawn_blocking(move || directory.auto_activation_sessions()) .await .unwrap_or_default(); + let settings = self + .core + .store + .read(crate::relay::agents::auto_activation::ActivationSettings::of); found.iter().any(|session| { session.live + && crate::relay::agents::auto_activation::activation_applies( + &settings, + session.target.kind, + ) && self .core .store @@ -705,10 +713,13 @@ impl Setup { // Whether the core will put this session on the relay by itself: // automatic activation is on, there is a workspace to join, and // activation's own check admits this session. - let setting = self.core.store.read(|s| s.auto_activate); + let settings = self + .core + .store + .read(crate::relay::agents::auto_activation::ActivationSettings::of); let auto_activates = self.core.workspace().is_some() && crate::relay::agents::auto_activation::activation_applies( - setting, + &settings, session.target.kind, ) && { diff --git a/crates/probe/src/relay/spawn/settings.rs b/crates/probe/src/relay/spawn/settings.rs index 0dad1bbfe6..429b1c2376 100644 --- a/crates/probe/src/relay/spawn/settings.rs +++ b/crates/probe/src/relay/spawn/settings.rs @@ -369,8 +369,17 @@ impl Core { let spawn = Change::read(changes).map_err(RpcError::invalid_params)?; let change = |key: &str| changes.get(key).and_then(Value::as_bool); let flags = crate::relay::store_settings::STORE_SETTINGS.map(change); - let [allow, auto, show, general, mobile_handoff, native_continuation, teammate_input] = - flags; + let [ + allow, + auto, + show, + general, + mobile_handoff, + native_continuation, + teammate_input, + pi_auto, + opencode_auto, + ] = flags; if native_continuation == Some(true) { self.require_native_continuation_claim().map_err(|message| { RpcError::new(crate::relay::protocol::ErrorKind::Refused, message) @@ -407,6 +416,9 @@ impl Core { store.extras.auto_join_general = general.or(store.extras.auto_join_general); store.extras.teammate_native_input = teammate_input.or(store.extras.teammate_native_input); + store.extras.pi_auto_activate = pi_auto.or(store.extras.pi_auto_activate); + store.extras.opencode_auto_activate = + opencode_auto.or(store.extras.opencode_auto_activate); spawn.apply(store); Some(StoreWrite { previous, @@ -460,8 +472,17 @@ impl Core { return Ok(()); }; let changed = |key: &str| changes.get(key).and_then(Value::as_bool).is_some(); - let [allow, auto, show, general, mobile_handoff, native_continuation, teammate_input] = - crate::relay::store_settings::STORE_SETTINGS.map(changed); + let [ + allow, + auto, + show, + general, + mobile_handoff, + native_continuation, + teammate_input, + pi_auto, + opencode_auto, + ] = crate::relay::store_settings::STORE_SETTINGS.map(changed); let (handoff, continuation, spawning) = self .store .update(|store| { @@ -480,6 +501,12 @@ impl Core { if teammate_input { put_back!(store, write, extras.teammate_native_input); } + if pi_auto { + put_back!(store, write, extras.pi_auto_activate); + } + if opencode_auto { + put_back!(store, write, extras.opencode_auto_activate); + } ( mobile_handoff && put_back!(store, write, mobile_claude_handoff), native_continuation && put_back!(store, write, mobile_native_continuation), diff --git a/crates/probe/src/relay/store/settings_extras.rs b/crates/probe/src/relay/store/settings_extras.rs index a331e34b2c..8870aaf8dd 100644 --- a/crates/probe/src/relay/store/settings_extras.rs +++ b/crates/probe/src/relay/store/settings_extras.rs @@ -33,6 +33,22 @@ pub struct SettingsExtras { skip_serializing_if = "Option::is_none" )] pub auto_join_general: Option, + /// Missing means false: an open Pi session joins only when added. On, + /// it joins by itself while `auto_activate` is on (`pi_auto_activate`). + #[serde( + rename = "piAutoActivate", + default, + skip_serializing_if = "Option::is_none" + )] + pub pi_auto_activate: Option, + /// As `pi_auto_activate`, for an open OpenCode session + /// (`opencode_auto_activate`). + #[serde( + rename = "opencodeAutoActivate", + default, + skip_serializing_if = "Option::is_none" + )] + pub opencode_auto_activate: Option, /// Newest first, at most `AGENT_CHANGES_KEPT`. #[serde( rename = "agentSettingsChanges", @@ -80,6 +96,16 @@ impl SettingsExtras { } else { theirs.teammate_native_input }, + pi_auto_activate: if ours.pi_auto_activate != base.pi_auto_activate { + ours.pi_auto_activate + } else { + theirs.pi_auto_activate + }, + opencode_auto_activate: if ours.opencode_auto_activate != base.opencode_auto_activate { + ours.opencode_auto_activate + } else { + theirs.opencode_auto_activate + }, agent_changes: changes, } } @@ -117,18 +143,24 @@ mod tests { auto_join_general: Some(true), spawn_native_input: None, teammate_native_input: Some(false), + pi_auto_activate: Some(true), agent_changes: vec![change("2026-10-16T00:00:02Z", "auto_join_general")], + ..SettingsExtras::default() }; let theirs = SettingsExtras { auto_join_general: None, spawn_native_input: Some(true), teammate_native_input: None, + opencode_auto_activate: Some(true), agent_changes: vec![change("2026-10-16T00:00:03Z", "auto_activate")], + ..SettingsExtras::default() }; let merged = SettingsExtras::merged(&base, &ours, &theirs); assert_eq!(merged.auto_join_general, Some(true)); assert_eq!(merged.spawn_native_input, Some(true)); assert_eq!(merged.teammate_native_input, Some(false)); + assert_eq!(merged.pi_auto_activate, Some(true)); + assert_eq!(merged.opencode_auto_activate, Some(true)); let keys: Vec<_> = merged .agent_changes .iter() @@ -150,12 +182,16 @@ mod tests { let mut store = Store::default(); store.extras.auto_join_general = Some(true); store.extras.spawn_native_input = Some(true); + store.extras.pi_auto_activate = Some(true); + store.extras.opencode_auto_activate = Some(false); store .extras .record(vec![change("2026-10-16T00:00:00Z", "auto_activate")]); let value = serde_json::to_value(&store).unwrap(); assert_eq!(value["autoJoinGeneral"], true); assert_eq!(value["spawnNativeInput"], true); + assert_eq!(value["piAutoActivate"], true); + assert_eq!(value["opencodeAutoActivate"], false); assert_eq!(value["agentSettingsChanges"][0]["by"], "api"); let back: Store = serde_json::from_value(value).unwrap(); assert_eq!(back.extras, store.extras); diff --git a/crates/probe/src/relay/store_settings.rs b/crates/probe/src/relay/store_settings.rs index 28e1e6416b..964be25c81 100644 --- a/crates/probe/src/relay/store_settings.rs +++ b/crates/probe/src/relay/store_settings.rs @@ -18,7 +18,7 @@ pub const AUTO_JOIN_GENERAL: &str = "auto_join_general"; /// The store's settings, in `store_settings` order; `spawn::settings` saves /// them with spawning's in one write (`Core::set_store_settings`). -pub(crate) const STORE_SETTINGS: [&str; 7] = [ +pub(crate) const STORE_SETTINGS: [&str; 9] = [ "allow_self_registration", "auto_activate", SHOW_RELAY_MESSAGES, @@ -26,8 +26,19 @@ pub(crate) const STORE_SETTINGS: [&str; 7] = [ "mobile_claude_handoff", "mobile_native_continuation", TEAMMATE_NATIVE_INPUT, + PI_AUTO_ACTIVATE, + OPENCODE_AUTO_ACTIVATE, ]; +/// Whether an open Pi session joins the relay by itself while +/// `auto_activate` is on. Off unless turned on (`relay init` turns it on for +/// a person who chose Pi), so turning `auto_activate` on alone keeps Pi +/// sessions joining only when added. +pub const PI_AUTO_ACTIVATE: &str = "pi_auto_activate"; + +/// As [`PI_AUTO_ACTIVATE`], for an open OpenCode session. +pub const OPENCODE_AUTO_ACTIVATE: &str = "opencode_auto_activate"; + /// Whether a teammate's direct message to a Claude Code session with Agent /// Relay's plugin starts a user turn, labelled with the sender. On unless /// turned off; channel posts and mentions stay peer messages either way. @@ -70,6 +81,17 @@ impl Store { self.extras.teammate_native_input != Some(false) } + /// Whether open Pi sessions join by themselves (`PI_AUTO_ACTIVATE`). + pub fn pi_auto_activates(&self) -> bool { + self.extras.pi_auto_activate == Some(true) + } + + /// Whether open OpenCode sessions join by themselves + /// (`OPENCODE_AUTO_ACTIVATE`). + pub fn opencode_auto_activates(&self) -> bool { + self.extras.opencode_auto_activate == Some(true) + } + pub fn hides_relay_messages(&self) -> bool { !self.shows_relay_messages() } @@ -107,6 +129,8 @@ impl Core { && claimed && s.mobile_native_continuation_on(), s.teammate_native_input_on(), + s.pi_auto_activates(), + s.opencode_auto_activates(), ]; (values, s.extras.agent_changes.clone()) }); diff --git a/docs/probe-move.md b/docs/probe-move.md index 006230efdc..9e04710073 100644 --- a/docs/probe-move.md +++ b/docs/probe-move.md @@ -353,10 +353,17 @@ own, named `--`) and, per tool, through `settings.set`: | ----------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | | Claude Code | `direct_delivery_default`, `claude_session_hook` | `crossSessionInbound: "accept"` (only when unset) and a SessionStart hook in `~/.claude/settings.json` | | Codex | `codex_stop_hook` | PostToolUse and Stop hooks in `~/.codex/hooks.json`, trusted through Codex's app-server | -| OpenCode | `opencode_plugin` | the Agent Relay plugin under `~/.config/opencode/plugins` | +| OpenCode | `opencode_plugin`, `opencode_auto_activate` | the Agent Relay plugin under `~/.config/opencode/plugins` | | Grok CLI | `grok_leader` | `[cli] use_leader` in `~/.grok/config.toml` | | Devin | `devin_direct_delivery` | lifecycle hooks in `~/.config/devin/config.json` | -| Pi | `pi_extension` | the Agent Relay extension in `~/.pi/agent/extensions` (a desktop core installs it at start) | +| Pi | `pi_extension`, `pi_auto_activate` | the Agent Relay extension in `~/.pi/agent/extensions` (a desktop core installs it at start) | + +`auto_activate` alone never puts an open Pi or OpenCode session on the relay +(the desktop installs both routes by default, so its Auto-activate switch +keeps them joining only when added). `pi_auto_activate` and +`opencode_auto_activate`, off unless turned on, let those sessions join by +themselves while `auto_activate` is on; init turns on the one for each tool +chosen, and they are recorded and undone like the rest. A setting that is already on is left alone; the ones init turned on are recorded in `/local/core-settings.json`, and `relay init diff --git a/packages/cli/src/cli/commands/init.test.ts b/packages/cli/src/cli/commands/init.test.ts index 6f083d788a..2ba84a5a84 100644 --- a/packages/cli/src/cli/commands/init.test.ts +++ b/packages/cli/src/cli/commands/init.test.ts @@ -899,9 +899,11 @@ function fakeCore( claude_session_hook: false, codex_stop_hook: false, opencode_plugin: false, + opencode_auto_activate: false, grok_leader: false, devin_direct_delivery: false, pi_extension: false, + pi_auto_activate: false, ...options.settings, }; /** The person's own crossSessionInbound, which the default never overrides. */ @@ -1174,27 +1176,80 @@ describe('relay init message injection', () => { const core = fakeCore(); await setup(core.deps).run('--tools', 'codex'); expect(core.settings.pi_extension).toBe(false); + expect(core.settings.pi_auto_activate).toBe(false); await setup(core.deps).run('--tools', 'pi'); - expect(core.settings.pi_extension).toBe(true); + // Pi sessions join on their own only with Pi's own opt-in on top of auto_activate. + expect(core.settings).toMatchObject({ pi_extension: true, pi_auto_activate: true, auto_activate: true }); + expect(core.settings.opencode_auto_activate).toBe(false); expect(JSON.parse(fs.readFileSync(path.join(home, 'local', 'core-settings.json'), 'utf8'))).toEqual({ - turnedOn: ['codex_stop_hook', 'auto_activate', 'pi_extension'], + turnedOn: ['codex_stop_hook', 'auto_activate', 'pi_extension', 'pi_auto_activate'], }); const toMcp = setup(core.deps); await toMcp.run('--tools', 'pi', '--delivery', 'mcp'); - expect(core.settings.pi_extension).toBe(false); + expect(core.settings).toMatchObject({ pi_extension: false, pi_auto_activate: false }); expect(core.settings.codex_stop_hook).toBe(true); await setup(core.deps).run('--tools', 'pi'); await setup({ ...core.deps, isAlive: vi.fn(() => false) }).run('--uninstall'); expect(core.settings).toMatchObject({ pi_extension: false, + pi_auto_activate: false, codex_stop_hook: false, auto_activate: false, }); }); + it('lets OpenCode sessions join on their own and turns exactly that off on --uninstall', async () => { + installedProbe(); + const core = fakeCore(); + await setup(core.deps).run('--tools', 'opencode'); + expect(core.sets()).toEqual([ + { opencode_plugin: true }, + { opencode_auto_activate: true }, + { auto_activate: true }, + ]); + expect(core.settings.pi_auto_activate).toBe(false); + expect(JSON.parse(fs.readFileSync(path.join(home, 'local', 'core-settings.json'), 'utf8'))).toEqual({ + turnedOn: ['opencode_plugin', 'opencode_auto_activate', 'auto_activate'], + }); + + await setup({ ...core.deps, isAlive: vi.fn(() => false) }).run('--uninstall'); + expect(core.settings).toMatchObject({ + opencode_plugin: false, + opencode_auto_activate: false, + auto_activate: false, + }); + expect(core.settings.pi_auto_activate).toBe(false); + }); + + it('leaves a Pi or OpenCode opt-in the person turned on themselves on --uninstall', async () => { + installedProbe(); + const core = fakeCore({ running: true, settings: { opencode_auto_activate: true } }); + await setup(core.deps).run('--tools', 'opencode'); + expect(core.sets()).toEqual([{ opencode_plugin: true }, { auto_activate: true }]); + await setup({ ...core.deps, isAlive: vi.fn(() => false) }).run('--uninstall'); + expect(core.settings).toMatchObject({ + opencode_plugin: false, + opencode_auto_activate: true, + auto_activate: false, + }); + }); + + it('gives Pi the MCP server, with no opt-in left on, when the core refuses Pi auto-activation', async () => { + installedProbe(); + const core = fakeCore({ refuse: ['pi_auto_activate'] }); + const { run, logs } = setup(core.deps); + await run('--tools', 'pi'); + expect(core.settings).toMatchObject({ + pi_extension: false, + pi_auto_activate: false, + auto_activate: false, + }); + expect(logs.join('\n')).toMatch(/Pi\s+not injected: pi_auto_activate is not available\./); + }); + it('turns off what it enabled when its record cannot be saved, then uses the MCP server', async () => { installedProbe(); const core = fakeCore({ running: true }); diff --git a/packages/cli/src/cli/lib/probe-core.test.ts b/packages/cli/src/cli/lib/probe-core.test.ts index ca1f0319a7..aff44ef6ae 100644 --- a/packages/cli/src/cli/lib/probe-core.test.ts +++ b/packages/cli/src/cli/lib/probe-core.test.ts @@ -235,11 +235,17 @@ describe('enableDelivery', () => { auto_activate: true, codex_stop_hook: false, opencode_plugin: true, + opencode_auto_activate: true, pi_extension: false, + pi_auto_activate: false, }); const outcome = await enableDelivery(core, ['codex', 'opencode', 'pi']); - expect(core.sets).toEqual([{ codex_stop_hook: true }, { pi_extension: true }]); - expect(outcome.turnedOn).toEqual(['codex_stop_hook', 'pi_extension']); + expect(core.sets).toEqual([ + { codex_stop_hook: true }, + { pi_extension: true }, + { pi_auto_activate: true }, + ]); + expect(outcome.turnedOn).toEqual(['codex_stop_hook', 'pi_extension', 'pi_auto_activate']); expect(outcome.results.map((r) => [r.id, r.status])).toEqual([ ['codex', 'on'], ['opencode', 'on'], diff --git a/packages/cli/src/cli/lib/probe-core.ts b/packages/cli/src/cli/lib/probe-core.ts index 3c0256c1f0..0863e69668 100644 --- a/packages/cli/src/cli/lib/probe-core.ts +++ b/packages/cli/src/cli/lib/probe-core.ts @@ -353,14 +353,21 @@ export const PROBE_DELIVERY: Record Date: Thu, 29 Oct 2026 10:16:57 -0700 Subject: [PATCH 2/3] test(probe): settings.get lists the Pi and OpenCode auto-activate opt-ins Co-Authored-By: Claude Opus 5.5 --- crates/probe/tests/relay_agents.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/probe/tests/relay_agents.rs b/crates/probe/tests/relay_agents.rs index f3b6251130..ce423c003b 100644 --- a/crates/probe/tests/relay_agents.rs +++ b/crates/probe/tests/relay_agents.rs @@ -2701,7 +2701,7 @@ async fn settings_round_trip_and_refuse_unknown_keys() { settings, json!({ "allow_self_registration": true, "auto_activate": false, "show_relay_messages": false, - "auto_join_general": false, "mobile_claude_handoff": false, "mobile_claude_handoff_supported": cfg!(unix), "mobile_native_continuation": false, "mobile_native_continuation_supported": cfg!(unix), "teammate_native_input": true, "agent_changes": [], + "auto_join_general": false, "mobile_claude_handoff": false, "mobile_claude_handoff_supported": cfg!(unix), "mobile_native_continuation": false, "mobile_native_continuation_supported": cfg!(unix), "teammate_native_input": true, "pi_auto_activate": false, "opencode_auto_activate": false, "agent_changes": [], "accept_spawns": true, "spawn_max_agents": 0, "spawn_full_access_codex": true, "spawn_full_access_claude": true, "spawn_allow_teammates": false, "spawn_native_input": true, From 7c66962edfb2f4df9541bb3af02d1f2093440eff Mon Sep 17 00:00:00 2001 From: kjgbot Date: Thu, 29 Oct 2026 10:25:46 -0700 Subject: [PATCH 3/3] test(probe): keep the Codex admission test's unused push receiver named as unused Co-Authored-By: Claude Opus 5.5 --- crates/probe/src/relay/local/tests/automatic_admission.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/probe/src/relay/local/tests/automatic_admission.rs b/crates/probe/src/relay/local/tests/automatic_admission.rs index 51ea14f590..17136566c0 100644 --- a/crates/probe/src/relay/local/tests/automatic_admission.rs +++ b/crates/probe/src/relay/local/tests/automatic_admission.rs @@ -48,7 +48,7 @@ async fn a_retained_closed_codex_row_cannot_create_an_automatic_registration() { let data = home.join("app-data"); fake_relaycast::sign_in_store(&data, fake_relaycast::WORKSPACE_KEY, &fake.base_url); let sessions = Arc::new(world.sessions); - let (core, signals) = Core::with_plugins( + let (core, _signals) = Core::with_plugins( Config { paths: CorePaths::for_socket(home.join("core.sock")).unwrap(), home: home.into(),