From a5c537a96e0951ac3c22d2608987b8567406ba8d Mon Sep 17 00:00:00 2001 From: kjgbot Date: Fri, 30 Oct 2026 01:54:43 -0700 Subject: [PATCH 1/6] fix(cli): relay init --uninstall leaves Pi as it found it Turning pi_extension off makes the core write ~/.agentworkforce/desktop/pi-extension.off. Under --cli-managed that is how "off" is recorded, but the desktop app reads the same marker as the person's opt-out: it stops installing the Pi extension at start and keeps Pi sessions off. So `relay init --tools pi` then `--uninstall` left Pi turned off where it had been on by default, plus an empty ~/.pi/agent/extensions folder init had created. relay init now records the Pi extension's place before the core edits it (extension file, off marker, extensions folder), as it does for the OpenCode plugin, and once pi_extension is off again removes the core's marker and an extensions folder it created when empty. A marker that was there before init, a person's own agent-relay.js, and a file at the marker name the core did not write are kept. Found in a live Pi 1.4.0 run against agent-relay@15.0.0-next.6. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + packages/cli/src/cli/commands/init.test.ts | 63 ++++++++++++++ packages/cli/src/cli/commands/init.ts | 24 +++++- .../cli/src/cli/lib/init-leftovers.test.ts | 83 +++++++++++++++++++ packages/cli/src/cli/lib/init-leftovers.ts | 66 ++++++++++++++- packages/cli/src/cli/lib/probe-core.test.ts | 22 +++++ packages/cli/src/cli/lib/probe-core.ts | 19 ++++- 7 files changed, 273 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 626d065ea..8bcf7d4e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Injected reply instructions now tell agents to answer only through this computer's Agent Relay route and never through another Agent Relay connector or app (such as Agent Relay in ChatGPT), which sends as a different identity. - Claude Code sessions set up by `relay init` now read and answer relayed messages in Claude's default permission mode with no approval prompt: the SessionStart hook adds `permissions.allow` rules to `~/.claude/settings.json` that allow only the Agent Relay core's own read, reply, attachment-fetch and peer-list commands on its session socket, and Claude's reply hints are that single command instead of curl. The rules go when the hook is turned off; your own rules stay. - `relay init --uninstall` now removes a `~/.claude/settings.json` it created (or restores yours byte for byte) and the OpenCode plugin's `agent-relay.js.off` marker when init installed the plugin. +- `relay init --uninstall` now removes the core's `~/.agentworkforce/desktop/pi-extension.off` marker and an empty `~/.pi/agent/extensions` folder when init installed the Pi extension, so the desktop app installs the extension and Pi sessions work again afterwards. A marker that was there before init is kept. - `relay init --uninstall` now leaves `~/.codex` as it found it: it removes a `hooks.json` it created (or restores yours byte for byte), its `.relay-desktop-backup`, the Codex hook-trust entries it added to `config.toml`, and its sandbox rules file. Your own hooks, trust entries and rules are kept. ### Security diff --git a/packages/cli/src/cli/commands/init.test.ts b/packages/cli/src/cli/commands/init.test.ts index d08f58083..25801ed7f 100644 --- a/packages/cli/src/cli/commands/init.test.ts +++ b/packages/cli/src/cli/commands/init.test.ts @@ -893,6 +893,8 @@ function fakeCore( claudeFiles?: string[]; /** The OpenCode plugin file `opencode_plugin` writes, and whose off marker it leaves. */ openCodePlugin?: string; + /** Pi's agent folder, where `pi_extension` writes the extension; off leaves the core's marker. */ + piAgentDir?: string; } = {} ) { const settings: Record = { @@ -948,6 +950,21 @@ function fakeCore( "Agent Relay's OpenCode plugin was turned off in Agent Relay's settings.\n" ); }; + /** As `PiSetup::set_extension`: on writes the extension, off removes it and leaves the marker. */ + const writePiExtension = (key: string, value: unknown) => { + if (key !== 'pi_extension' || !options.piAgentDir) return; + const file = path.join(options.piAgentDir, 'extensions', 'agent-relay.js'); + const marker = path.join(toolHome, '.agentworkforce', 'desktop', 'pi-extension.off'); + if (value === true) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, '// AGENT_RELAY_PI_EXTENSION=2\n'); + fs.rmSync(marker, { force: true }); + return; + } + fs.rmSync(file, { force: true }); + fs.mkdirSync(path.dirname(marker), { recursive: true }); + fs.writeFileSync(marker, "Agent Relay's Pi extension was turned off (pi_extension).\n"); + }; const client: CoreClient = { async call(method: string, params: Record = {}): Promise { calls.push([method, params]); @@ -967,6 +984,7 @@ function fakeCore( } else { settings[key] = value; writePlugin(key, value); + writePiExtension(key, value); if (key === 'direct_delivery') { userChoseInbound = false; if (value === false) writeInbound('hold', 'accept'); @@ -1205,6 +1223,13 @@ describe('relay init message injection', () => { 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', 'pi_auto_activate'], + pi: { + file: path.join(toolHome, '.pi', 'agent', 'extensions', 'agent-relay.js'), + marker: path.join(toolHome, '.agentworkforce', 'desktop', 'pi-extension.off'), + extension: false, + off: false, + dir: false, + }, }); const toMcp = setup(core.deps); @@ -1272,6 +1297,44 @@ describe('relay init message injection', () => { expect(fs.existsSync(path.join(home, 'local', 'core-settings.json'))).toBe(false); }); + // The relay init end-to-end Pi run on 2026-10-30: --uninstall left + // ~/.agentworkforce/desktop/pi-extension.off, which keeps the desktop app + // from installing the extension (Pi sessions off), and an empty extensions folder. + it("leaves Pi's folder and the core's Pi opt-out as it found them on --uninstall", async () => { + installedProbe(); + const agent = path.join(toolHome, '.pi', 'agent'); + fs.mkdirSync(agent, { recursive: true }); + fs.writeFileSync(path.join(agent, 'auth.json'), '{}'); + const marker = path.join(toolHome, '.agentworkforce', 'desktop', 'pi-extension.off'); + const core = fakeCore({ piAgentDir: agent }); + await setup(core.deps).run('--tools', 'pi'); + expect(fs.existsSync(path.join(agent, 'extensions', 'agent-relay.js'))).toBe(true); + + await setup({ ...core.deps, isAlive: vi.fn(() => false) }).run('--uninstall'); + expect(core.settings).toMatchObject({ + pi_extension: false, + pi_auto_activate: false, + auto_activate: false, + }); + expect(fs.readdirSync(agent)).toEqual(['auth.json']); + expect(fs.existsSync(marker)).toBe(false); + expect(fs.existsSync(path.join(home, 'local', 'core-settings.json'))).toBe(false); + }); + + it('keeps a Pi opt-out marker that was there before init', async () => { + installedProbe(); + const agent = path.join(toolHome, '.pi', 'agent'); + fs.mkdirSync(agent, { recursive: true }); + const marker = path.join(toolHome, '.agentworkforce', 'desktop', 'pi-extension.off'); + fs.mkdirSync(path.dirname(marker), { recursive: true }); + fs.writeFileSync(marker, "Agent Relay's Pi extension was turned off (pi_extension).\n"); + const core = fakeCore({ piAgentDir: agent }); + await setup(core.deps).run('--tools', 'pi'); + expect(fs.existsSync(marker)).toBe(false); + await setup({ ...core.deps, isAlive: vi.fn(() => false) }).run('--uninstall'); + expect(fs.existsSync(marker)).toBe(true); + }); + // Review on #2021: a tool rolled back in the same run is tidied too. it('removes the Claude settings file it created when Claude is rolled back to MCP', async () => { installedProbe(); diff --git a/packages/cli/src/cli/commands/init.ts b/packages/cli/src/cli/commands/init.ts index e1c6346d3..735bd67c7 100644 --- a/packages/cli/src/cli/commands/init.ts +++ b/packages/cli/src/cli/commands/init.ts @@ -78,10 +78,14 @@ import { claudeFileStates, openCodeConfigDir, openCodeState, + piAgentDir, + piState, tidyClaudeSettings, tidyOpenCodePlugin, + tidyPiExtension, type ClaudeFileState, type OpenCodeState, + type PiState, } from '../lib/init-leftovers.js'; import { persistWorkspaceSession } from '../lib/workspace-session.js'; import { readWorkspaceStore } from '../lib/workspace-store.js'; @@ -986,10 +990,12 @@ async function setupInjection( // included, instead of the hold the core writes on each login. const before = tools.includes('claude') ? claudeInboundValues(ctx) : {}; // The files the core is about to edit, as they are now: what --uninstall - // tidies back to (a settings.json or plugins folder init created). + // tidies back to (a settings.json, plugins or extensions folder, or off + // marker init created). const files: Leftovers = { claudeFiles: tools.includes('claude') ? claudeFileStates(claudeLoginSettings(ctx)) : undefined, openCode: tools.includes('opencode') ? openCodeState(openCodeConfigDir(ctx.env, ctx.home)) : undefined, + pi: tools.includes('pi') ? piState(piAgentDir(ctx.env, ctx.home), ctx.home) : undefined, }; const { results, turnedOn, turnedOff } = await enableDelivery(session.client, tools, record.turnedOn); // Applied to the record as it is now, not as it was read: another @@ -1019,6 +1025,7 @@ async function setupInjection( openCode: touched.includes('opencode_plugin') ? (current.openCode ?? files.openCode) : current.openCode, + pi: touched.includes('pi_extension') ? (current.pi ?? files.pi) : current.pi, }); }); try { @@ -1076,6 +1083,9 @@ async function undoUnrecorded( if (turnedOn.includes('opencode_plugin') && !failed('opencode_plugin') && files.openCode) { tidyOpenCodePlugin(files.openCode); } + if (turnedOn.includes('pi_extension') && !failed('pi_extension') && files.pi) { + tidyPiExtension(files.pi); + } if (failures.length > 0) { throw new InjectionStuckError( `Could not save ${deliveryRecordPath()} (${cause.message}), and these settings relay init turned on ` + @@ -1280,18 +1290,20 @@ const CLAUDE_FILE_KEYS = ['direct_delivery', 'claude_session_hook']; interface Leftovers { claudeFiles?: Record; openCode?: OpenCodeState; + pi?: PiState; } /** * Once nothing init turned on edits a tool's files any more, tidies them * back to how init found them (a Claude settings.json init created, the - * OpenCode plugin's off marker) and drops their recorded state. Run after + * OpenCode plugin's and Pi extension's off markers) and drops their + * recorded state. Run after * the core turned the settings off and the original crossSessionInbound is * back. * @returns The record without the states it used */ function tidyLeftovers(record: DeliveryRecord): DeliveryRecord { - let { claudeFiles, openCode } = record; + let { claudeFiles, openCode, pi } = record; if (claudeFiles && !record.turnedOn.some((key) => CLAUDE_FILE_KEYS.includes(key))) { tidyClaudeSettings(claudeFiles); claudeFiles = undefined; @@ -1300,11 +1312,16 @@ function tidyLeftovers(record: DeliveryRecord): DeliveryRecord { tidyOpenCodePlugin(openCode); openCode = undefined; } + if (pi && !record.turnedOn.includes('pi_extension')) { + tidyPiExtension(pi); + pi = undefined; + } return { turnedOn: record.turnedOn, claudeInbound: record.claudeInbound, ...(claudeFiles ? { claudeFiles } : {}), ...(openCode ? { openCode } : {}), + ...(pi ? { pi } : {}), }; } @@ -1327,6 +1344,7 @@ function writeDeliveryRecord(record: DeliveryRecord): void { ...(Object.keys(record.claudeInbound).length > 0 ? { claudeInbound: record.claudeInbound } : {}), ...(record.claudeFiles ? { claudeFiles: record.claudeFiles } : {}), ...(record.openCode ? { openCode: record.openCode } : {}), + ...(record.pi ? { pi: record.pi } : {}), }); } diff --git a/packages/cli/src/cli/lib/init-leftovers.test.ts b/packages/cli/src/cli/lib/init-leftovers.test.ts index ec925f61e..e61b816ea 100644 --- a/packages/cli/src/cli/lib/init-leftovers.test.ts +++ b/packages/cli/src/cli/lib/init-leftovers.test.ts @@ -9,8 +9,11 @@ import { claudeFileStates, openCodeConfigDir, openCodeState, + piAgentDir, + piState, tidyClaudeSettings, tidyOpenCodePlugin, + tidyPiExtension, } from './init-leftovers.js'; let root: string; @@ -224,3 +227,83 @@ describe('tidyOpenCodePlugin', () => { expect(fs.readFileSync(marker(state.file), 'utf8')).toBe('my note\n'); }); }); + +describe('tidyPiExtension', () => { + const coreOff = (marker: string) => { + fs.mkdirSync(path.dirname(marker), { recursive: true }); + fs.writeFileSync( + marker, + "Agent Relay's Pi extension was turned off (pi_extension). Delete this file, or turn " + + 'Pi sessions on in Agent Relay, to allow it again.\n' + ); + }; + + it('reads the Pi agent folder from an absolute PI_CODING_AGENT_DIR, else ~/.pi/agent, as the core does', () => { + expect(piAgentDir({ PI_CODING_AGENT_DIR: '/x/pi' }, '/home/me')).toBe('/x/pi'); + expect(piAgentDir({ PI_CODING_AGENT_DIR: 'relative' }, '/home/me')).toBe('/home/me/.pi/agent'); + expect(piAgentDir({}, '/home/me')).toBe('/home/me/.pi/agent'); + }); + + // The relay init end-to-end Pi run on 2026-10-30: --uninstall left + // ~/.agentworkforce/desktop/pi-extension.off and an empty extensions folder. + it('removes the off marker an init-installed extension left, and an extensions folder init created', () => { + const agent = path.join(root, '.pi', 'agent'); + fs.mkdirSync(agent, { recursive: true }); + fs.writeFileSync(path.join(agent, 'auth.json'), '{}'); + const before = tree(agent); + const state = piState(agent, root); + expect(state).toEqual({ + file: path.join(agent, 'extensions', 'agent-relay.js'), + marker: path.join(root, '.agentworkforce', 'desktop', 'pi-extension.off'), + extension: false, + off: false, + dir: false, + }); + + fs.mkdirSync(path.dirname(state.file)); + fs.writeFileSync(state.file, '// AGENT_RELAY_PI_EXTENSION=2\n'); + fs.rmSync(state.file); + coreOff(state.marker); + + tidyPiExtension(state); + expect(tree(agent)).toEqual(before); + expect(fs.existsSync(state.marker)).toBe(false); + }); + + it("keeps a marker that was there before init, the person's own extension and their extensions", () => { + const agent = path.join(root, '.pi', 'agent'); + const extensions = path.join(agent, 'extensions'); + fs.mkdirSync(extensions, { recursive: true }); + const marker = path.join(root, '.agentworkforce', 'desktop', 'pi-extension.off'); + coreOff(marker); + const offBefore = piState(agent, root); + tidyPiExtension(offBefore); + expect(fs.existsSync(marker)).toBe(true); + + fs.rmSync(marker); + const file = path.join(extensions, 'agent-relay.js'); + fs.writeFileSync(file, '// mine\n'); + const theirs = piState(agent, root); + coreOff(marker); + tidyPiExtension(theirs); + expect(fs.existsSync(marker)).toBe(true); + + fs.rmSync(file); + fs.rmSync(marker); + fs.writeFileSync(path.join(extensions, 'other.ts'), '//\n'); + const fresh = piState(agent, root); + coreOff(marker); + tidyPiExtension(fresh); + expect(fs.existsSync(marker)).toBe(false); + expect(fs.readdirSync(extensions)).toEqual(['other.ts']); + }); + + it('leaves a file at the marker name that the core did not write', () => { + const agent = path.join(root, '.pi', 'agent'); + const state = piState(agent, root); + fs.mkdirSync(path.dirname(state.marker), { recursive: true }); + fs.writeFileSync(state.marker, 'my note\n'); + tidyPiExtension(state); + expect(fs.readFileSync(state.marker, 'utf8')).toBe('my note\n'); + }); +}); diff --git a/packages/cli/src/cli/lib/init-leftovers.ts b/packages/cli/src/cli/lib/init-leftovers.ts index 8baa526f6..99bc257c0 100644 --- a/packages/cli/src/cli/lib/init-leftovers.ts +++ b/packages/cli/src/cli/lib/init-leftovers.ts @@ -6,7 +6,8 @@ * rules back out, and copies a file it changes once, beside it, as * `.relay-desktop-backup`), but it cannot know which files relay init made: * a Claude `settings.json` that only existed for `crossSessionInbound` and - * the SessionStart hook, or the OpenCode plugin's off marker. This records + * the SessionStart hook, or the OpenCode plugin's or Pi extension's off + * marker. This records * that before init turns anything on and tidies only those afterwards. */ @@ -24,6 +25,9 @@ export const OPENCODE_OFF_SUFFIX = '.off'; /** The first words of the core's OpenCode off marker; any other file there is not the core's. */ const OPENCODE_OFF_TEXT = "Agent Relay's OpenCode plugin was turned off"; +/** The first words of the core's Pi off marker (`pi::setup`); any other file there is not the core's. */ +const PI_OFF_TEXT = "Agent Relay's Pi extension was turned off"; + /** A Claude login's `settings.json` before init. */ export interface ClaudeFileState { /** The settings file existed. */ @@ -46,6 +50,20 @@ export interface OpenCodeState { dir: boolean; } +/** The Pi extension's place before init. */ +export interface PiState { + /** `/extensions/agent-relay.js`. */ + file: string; + /** The core's off marker, `~/.agentworkforce/desktop/pi-extension.off` (`PiConfig::off_marker`). */ + marker: string; + /** A file of the extension's name existed. */ + extension: boolean; + /** The off marker existed. */ + off: boolean; + /** The extensions folder existed. */ + dir: boolean; +} + const exists = (file: string): boolean => { try { fs.lstatSync(file); @@ -103,6 +121,29 @@ export function openCodeConfigDir(env: NodeJS.ProcessEnv, home: string): string ); } +/** Pi's agent folder, as the core reads it (`PiConfig::from_env`): an absolute `PI_CODING_AGENT_DIR`, else `~/.pi/agent`. */ +export function piAgentDir(env: NodeJS.ProcessEnv, home: string): string { + const configured = env.PI_CODING_AGENT_DIR; + return configured && path.isAbsolute(configured) ? configured : path.join(home, '.pi', 'agent'); +} + +/** + * The Pi extension's place before init. + * @param agentDir - Pi's agent folder ({@link piAgentDir}) + * @param home - The home the core keeps its data under + */ +export function piState(agentDir: string, home: string): PiState { + const file = path.join(agentDir, 'extensions', 'agent-relay.js'); + const marker = path.join(home, '.agentworkforce', 'desktop', 'pi-extension.off'); + return { + file, + marker, + extension: exists(file), + off: exists(marker), + dir: exists(path.dirname(file)), + }; +} + function readJson(file: string): unknown { try { return JSON.parse(fs.readFileSync(file, 'utf8')) as unknown; @@ -210,3 +251,26 @@ export function tidyOpenCodePlugin(state: OpenCodeState): void { } if (!state.dir) removeEmptyDir(path.dirname(state.file)); } + +/** + * Removes what turning off a Pi extension init installed left behind: the + * core's off marker (`pi-extension.off`), which would otherwise keep the + * desktop app from installing the extension and keep Pi sessions off, and + * the extensions folder when init created it and nothing else is in it. + * Only where there was neither an extension file nor a marker before init: + * a marker that was there is the person's choice (or the desktop app's), + * and a marker the core did not write is not touched. Run once the core has + * turned the extension off. + * @param state - The state {@link piState} recorded before init + */ +export function tidyPiExtension(state: PiState): void { + if (state.extension || state.off) return; + if (exists(state.file)) return; + try { + if (fs.readFileSync(state.marker, 'utf8').startsWith(PI_OFF_TEXT)) + fs.rmSync(state.marker, { force: true }); + } catch { + // No marker. + } + if (!state.dir) removeEmptyDir(path.dirname(state.file)); +} diff --git a/packages/cli/src/cli/lib/probe-core.test.ts b/packages/cli/src/cli/lib/probe-core.test.ts index b7edb0e18..4a8ff39bb 100644 --- a/packages/cli/src/cli/lib/probe-core.test.ts +++ b/packages/cli/src/cli/lib/probe-core.test.ts @@ -394,6 +394,13 @@ describe('records', () => { off: false, dir: false, }, + pi: { + file: '/h/.pi/agent/extensions/agent-relay.js', + marker: '/h/.agentworkforce/desktop/pi-extension.off', + extension: false, + off: false, + dir: true, + }, }) ); expect(readDeliveryRecord(file)).toEqual({ @@ -401,8 +408,23 @@ describe('records', () => { claudeInbound: {}, claudeFiles: { '/h/.claude/settings.json': { existed: false, backup: false, dir: true } }, openCode: { file: '/h/.config/opencode/plugins/agent-relay.js', plugin: false, off: false, dir: false }, + pi: { + file: '/h/.pi/agent/extensions/agent-relay.js', + marker: '/h/.agentworkforce/desktop/pi-extension.off', + extension: false, + off: false, + dir: true, + }, }); fs.writeFileSync(file, JSON.stringify({ turnedOn: [], openCode: { file: 'relative', plugin: false } })); expect(readDeliveryRecord(file)).toEqual({ turnedOn: [], claudeInbound: {} }); + fs.writeFileSync( + file, + JSON.stringify({ + turnedOn: [], + pi: { file: '/h/x.js', marker: 'relative', extension: false, off: false, dir: false }, + }) + ); + expect(readDeliveryRecord(file)).toEqual({ turnedOn: [], claudeInbound: {} }); }); }); diff --git a/packages/cli/src/cli/lib/probe-core.ts b/packages/cli/src/cli/lib/probe-core.ts index b5e5f0917..a2236b8a6 100644 --- a/packages/cli/src/cli/lib/probe-core.ts +++ b/packages/cli/src/cli/lib/probe-core.ts @@ -21,7 +21,7 @@ import net from 'node:net'; import path from 'node:path'; import type { ToolId } from './agent-tools-mcp.js'; -import type { ClaudeFileState, OpenCodeState } from './init-leftovers.js'; +import type { ClaudeFileState, OpenCodeState, PiState } from './init-leftovers.js'; import type { EnginePlatform } from './relaycast-engine-install.js'; /** The core `relay init` installs. Pinned so every machine runs the same core. */ @@ -587,6 +587,8 @@ export interface DeliveryRecord { claudeFiles?: Record; /** The OpenCode plugin's place as init found it (`tidyOpenCodePlugin`). */ openCode?: OpenCodeState; + /** The Pi extension's place as init found it (`tidyPiExtension`). */ + pi?: PiState; } const strings = (value: unknown): string[] => @@ -635,6 +637,19 @@ function openCodeStateOf(value: unknown): OpenCodeState | undefined { return { file, plugin, off, dir }; } +/** The recorded Pi state, if sane. */ +function piStateOf(value: unknown): PiState | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const { file, marker } = value as Record; + const extension = flag(value, 'extension'); + const off = flag(value, 'off'); + const dir = flag(value, 'dir'); + if (typeof file !== 'string' || !path.isAbsolute(file)) return undefined; + if (typeof marker !== 'string' || !path.isAbsolute(marker)) return undefined; + if (extension === undefined || off === undefined || dir === undefined) return undefined; + return { file, marker, extension, off, dir }; +} + /** * The record of what init turned on. * @param file - `/local/core-settings.json` @@ -645,11 +660,13 @@ export function readDeliveryRecord(file: string): DeliveryRecord { const parsed = JSON.parse(fs.readFileSync(file, 'utf8')) as Record | null; const claudeFiles = claudeFileStatesOf(parsed?.claudeFiles); const openCode = openCodeStateOf(parsed?.openCode); + const pi = piStateOf(parsed?.pi); return { turnedOn: strings(parsed?.turnedOn), claudeInbound: originals(parsed?.claudeInbound), ...(claudeFiles ? { claudeFiles } : {}), ...(openCode ? { openCode } : {}), + ...(pi ? { pi } : {}), }; } catch { return { turnedOn: [], claudeInbound: {} }; From b72b1c51858834e095d06c26d0c8492c7410a48f Mon Sep 17 00:00:00 2001 From: kjgbot Date: Fri, 30 Oct 2026 01:57:45 -0700 Subject: [PATCH 2/6] fix(cli): keep a Pi opt-out made after init; accept only the core's Pi paths - --uninstall keeps pi-extension.off when it is already there before it turns pi_extension off: that is the person's own opt-out (the desktop app's switch) after init, not this run's marker. - A recorded Pi state is read only when its paths are the core's (/extensions/agent-relay.js, ~/.agentworkforce/desktop/pi-extension.off). Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/cli/commands/init.test.ts | 17 +++++++++++++++++ packages/cli/src/cli/commands/init.ts | 5 +++++ packages/cli/src/cli/lib/probe-core.test.ts | 14 ++++++++++++++ packages/cli/src/cli/lib/probe-core.ts | 10 ++++++++-- 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/cli/commands/init.test.ts b/packages/cli/src/cli/commands/init.test.ts index 25801ed7f..6b847133b 100644 --- a/packages/cli/src/cli/commands/init.test.ts +++ b/packages/cli/src/cli/commands/init.test.ts @@ -1321,6 +1321,23 @@ describe('relay init message injection', () => { expect(fs.existsSync(path.join(home, 'local', 'core-settings.json'))).toBe(false); }); + // Review on #2022: turning Pi off in the desktop app after init is the person's choice. + it('keeps a Pi opt-out marker the person left after init on --uninstall', async () => { + installedProbe(); + const agent = path.join(toolHome, '.pi', 'agent'); + fs.mkdirSync(agent, { recursive: true }); + const marker = path.join(toolHome, '.agentworkforce', 'desktop', 'pi-extension.off'); + const core = fakeCore({ piAgentDir: agent }); + await setup(core.deps).run('--tools', 'pi'); + // As the desktop app's Pi switch: the extension goes and the marker stays. + fs.rmSync(path.join(agent, 'extensions', 'agent-relay.js')); + fs.mkdirSync(path.dirname(marker), { recursive: true }); + fs.writeFileSync(marker, "Agent Relay's Pi extension was turned off (pi_extension).\n"); + await setup({ ...core.deps, isAlive: vi.fn(() => false) }).run('--uninstall'); + expect(fs.existsSync(marker)).toBe(true); + expect(fs.existsSync(path.join(home, 'local', 'core-settings.json'))).toBe(false); + }); + it('keeps a Pi opt-out marker that was there before init', async () => { installedProbe(); const agent = path.join(toolHome, '.pi', 'agent'); diff --git a/packages/cli/src/cli/commands/init.ts b/packages/cli/src/cli/commands/init.ts index 735bd67c7..e44cd1f05 100644 --- a/packages/cli/src/cli/commands/init.ts +++ b/packages/cli/src/cli/commands/init.ts @@ -1376,6 +1376,10 @@ async function turnOffInjection(deps: InitCommandDependencies, keys: string[]): throw new Error(`Could not reach the Agent Relay core to turn off message injection: ${session.reason}`); } try { + // A Pi off marker already there was the person's own opt-out after init + // (the desktop app's switch), not this run's: it is kept. + const pi = readDeliveryRecord(deliveryRecordPath()).pi; + const piOptedOut = keys.includes('pi_extension') && pi !== undefined && fs.existsSync(pi.marker); const failures = await disableDelivery(session.client, keys); const done = keys.filter((key) => !failures.some((failure) => failure.startsWith(`${key}:`))); const ctx = deps.toolContext(); @@ -1383,6 +1387,7 @@ async function turnOffInjection(deps: InitCommandDependencies, keys: string[]): if (done.includes('direct_delivery')) restoreClaudeInbound(recordedClaudeFiles(current, ctx)); return tidyLeftovers({ ...current, + ...(piOptedOut && done.includes('pi_extension') ? { pi: undefined } : {}), turnedOn: current.turnedOn.filter((key) => !done.includes(key)), claudeInbound: done.includes('direct_delivery') ? {} : current.claudeInbound, }); diff --git a/packages/cli/src/cli/lib/probe-core.test.ts b/packages/cli/src/cli/lib/probe-core.test.ts index 4a8ff39bb..ff4a4da97 100644 --- a/packages/cli/src/cli/lib/probe-core.test.ts +++ b/packages/cli/src/cli/lib/probe-core.test.ts @@ -426,5 +426,19 @@ describe('records', () => { }) ); expect(readDeliveryRecord(file)).toEqual({ turnedOn: [], claudeInbound: {} }); + fs.writeFileSync( + file, + JSON.stringify({ + turnedOn: [], + pi: { + file: '/h/.pi/agent/extensions/agent-relay.js', + marker: '/h/important.txt', + extension: false, + off: false, + dir: false, + }, + }) + ); + expect(readDeliveryRecord(file)).toEqual({ turnedOn: [], claudeInbound: {} }); }); }); diff --git a/packages/cli/src/cli/lib/probe-core.ts b/packages/cli/src/cli/lib/probe-core.ts index a2236b8a6..35fa63d14 100644 --- a/packages/cli/src/cli/lib/probe-core.ts +++ b/packages/cli/src/cli/lib/probe-core.ts @@ -637,6 +637,9 @@ function openCodeStateOf(value: unknown): OpenCodeState | undefined { return { file, plugin, off, dir }; } +const PI_EXTENSION_TAIL = path.join(path.sep, 'extensions', 'agent-relay.js'); +const PI_MARKER_TAIL = path.join(path.sep, '.agentworkforce', 'desktop', 'pi-extension.off'); + /** The recorded Pi state, if sane. */ function piStateOf(value: unknown): PiState | undefined { if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; @@ -644,8 +647,11 @@ function piStateOf(value: unknown): PiState | undefined { const extension = flag(value, 'extension'); const off = flag(value, 'off'); const dir = flag(value, 'dir'); - if (typeof file !== 'string' || !path.isAbsolute(file)) return undefined; - if (typeof marker !== 'string' || !path.isAbsolute(marker)) return undefined; + // Only the places the core uses: tidying removes the marker and an empty folder. + if (typeof file !== 'string' || !path.isAbsolute(file) || !file.endsWith(PI_EXTENSION_TAIL)) + return undefined; + if (typeof marker !== 'string' || !path.isAbsolute(marker) || !marker.endsWith(PI_MARKER_TAIL)) + return undefined; if (extension === undefined || off === undefined || dir === undefined) return undefined; return { file, marker, extension, off, dir }; } From 04b47fcc07a0648da67033c28a53306873fc754f Mon Sep 17 00:00:00 2001 From: kjgbot Date: Fri, 30 Oct 2026 02:02:35 -0700 Subject: [PATCH 3/6] fix(cli): remove an empty Pi extensions folder init created even when the opt-out marker is kept Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/cli/commands/init.test.ts | 2 ++ packages/cli/src/cli/commands/init.ts | 3 ++- .../cli/src/cli/lib/init-leftovers.test.ts | 4 ++++ packages/cli/src/cli/lib/init-leftovers.ts | 22 ++++++++++--------- 4 files changed, 20 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/cli/commands/init.test.ts b/packages/cli/src/cli/commands/init.test.ts index 6b847133b..ae4e53751 100644 --- a/packages/cli/src/cli/commands/init.test.ts +++ b/packages/cli/src/cli/commands/init.test.ts @@ -1335,6 +1335,7 @@ describe('relay init message injection', () => { fs.writeFileSync(marker, "Agent Relay's Pi extension was turned off (pi_extension).\n"); await setup({ ...core.deps, isAlive: vi.fn(() => false) }).run('--uninstall'); expect(fs.existsSync(marker)).toBe(true); + expect(fs.readdirSync(agent)).toEqual([]); expect(fs.existsSync(path.join(home, 'local', 'core-settings.json'))).toBe(false); }); @@ -1350,6 +1351,7 @@ describe('relay init message injection', () => { expect(fs.existsSync(marker)).toBe(false); await setup({ ...core.deps, isAlive: vi.fn(() => false) }).run('--uninstall'); expect(fs.existsSync(marker)).toBe(true); + expect(fs.readdirSync(agent)).toEqual([]); }); // Review on #2021: a tool rolled back in the same run is tidied too. diff --git a/packages/cli/src/cli/commands/init.ts b/packages/cli/src/cli/commands/init.ts index e44cd1f05..74ec9b684 100644 --- a/packages/cli/src/cli/commands/init.ts +++ b/packages/cli/src/cli/commands/init.ts @@ -1387,7 +1387,8 @@ async function turnOffInjection(deps: InitCommandDependencies, keys: string[]): if (done.includes('direct_delivery')) restoreClaudeInbound(recordedClaudeFiles(current, ctx)); return tidyLeftovers({ ...current, - ...(piOptedOut && done.includes('pi_extension') ? { pi: undefined } : {}), + // Kept as if it had been there before init; an extensions folder init created still goes. + ...(piOptedOut && current.pi ? { pi: { ...current.pi, off: true } } : {}), turnedOn: current.turnedOn.filter((key) => !done.includes(key)), claudeInbound: done.includes('direct_delivery') ? {} : current.claudeInbound, }); diff --git a/packages/cli/src/cli/lib/init-leftovers.test.ts b/packages/cli/src/cli/lib/init-leftovers.test.ts index e61b816ea..d6d33c44b 100644 --- a/packages/cli/src/cli/lib/init-leftovers.test.ts +++ b/packages/cli/src/cli/lib/init-leftovers.test.ts @@ -276,9 +276,13 @@ describe('tidyPiExtension', () => { fs.mkdirSync(extensions, { recursive: true }); const marker = path.join(root, '.agentworkforce', 'desktop', 'pi-extension.off'); coreOff(marker); + fs.rmdirSync(extensions); const offBefore = piState(agent, root); + fs.mkdirSync(extensions); tidyPiExtension(offBefore); expect(fs.existsSync(marker)).toBe(true); + expect(fs.existsSync(extensions)).toBe(false); + fs.mkdirSync(extensions); fs.rmSync(marker); const file = path.join(extensions, 'agent-relay.js'); diff --git a/packages/cli/src/cli/lib/init-leftovers.ts b/packages/cli/src/cli/lib/init-leftovers.ts index 99bc257c0..6d194f4da 100644 --- a/packages/cli/src/cli/lib/init-leftovers.ts +++ b/packages/cli/src/cli/lib/init-leftovers.ts @@ -257,20 +257,22 @@ export function tidyOpenCodePlugin(state: OpenCodeState): void { * core's off marker (`pi-extension.off`), which would otherwise keep the * desktop app from installing the extension and keep Pi sessions off, and * the extensions folder when init created it and nothing else is in it. - * Only where there was neither an extension file nor a marker before init: - * a marker that was there is the person's choice (or the desktop app's), - * and a marker the core did not write is not touched. Run once the core has + * Nothing where there was an extension file before init. A marker that was + * there (`off`) is the person's choice (or the desktop app's) and is kept, + * though an extensions folder init created still goes once empty; a marker + * the core did not write is not touched. Run once the core has * turned the extension off. * @param state - The state {@link piState} recorded before init */ export function tidyPiExtension(state: PiState): void { - if (state.extension || state.off) return; - if (exists(state.file)) return; - try { - if (fs.readFileSync(state.marker, 'utf8').startsWith(PI_OFF_TEXT)) - fs.rmSync(state.marker, { force: true }); - } catch { - // No marker. + if (state.extension || exists(state.file)) return; + if (!state.off) { + try { + if (fs.readFileSync(state.marker, 'utf8').startsWith(PI_OFF_TEXT)) + fs.rmSync(state.marker, { force: true }); + } catch { + // No marker. + } } if (!state.dir) removeEmptyDir(path.dirname(state.file)); } From 3ac7ff3a084d8312e6964310ced6fd1e4f5b1451 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Fri, 30 Oct 2026 02:11:01 -0700 Subject: [PATCH 4/6] fix(cli): keep a post-init Pi opt-out marker byte for byte on --uninstall Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/cli/commands/init.test.ts | 4 ++-- packages/cli/src/cli/commands/init.ts | 26 ++++++++++++++++++++-- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/cli/commands/init.test.ts b/packages/cli/src/cli/commands/init.test.ts index ae4e53751..f98a93305 100644 --- a/packages/cli/src/cli/commands/init.test.ts +++ b/packages/cli/src/cli/commands/init.test.ts @@ -1332,9 +1332,9 @@ describe('relay init message injection', () => { // As the desktop app's Pi switch: the extension goes and the marker stays. fs.rmSync(path.join(agent, 'extensions', 'agent-relay.js')); fs.mkdirSync(path.dirname(marker), { recursive: true }); - fs.writeFileSync(marker, "Agent Relay's Pi extension was turned off (pi_extension).\n"); + fs.writeFileSync(marker, 'off by me\n'); await setup({ ...core.deps, isAlive: vi.fn(() => false) }).run('--uninstall'); - expect(fs.existsSync(marker)).toBe(true); + expect(fs.readFileSync(marker, 'utf8')).toBe('off by me\n'); expect(fs.readdirSync(agent)).toEqual([]); expect(fs.existsSync(path.join(home, 'local', 'core-settings.json'))).toBe(false); }); diff --git a/packages/cli/src/cli/commands/init.ts b/packages/cli/src/cli/commands/init.ts index 74ec9b684..f0a526840 100644 --- a/packages/cli/src/cli/commands/init.ts +++ b/packages/cli/src/cli/commands/init.ts @@ -1056,6 +1056,25 @@ async function setupInjection( } } +/** A regular file's bytes; undefined when there is none (or it is a link or folder). */ +function readIfFile(file: string): Buffer | undefined { + try { + return fs.lstatSync(file).isFile() ? fs.readFileSync(file) : undefined; + } catch { + return undefined; + } +} + +/** Puts `bytes` back in `file` when they differ; a failure leaves the core's marker, which means the same. */ +function restoreBytes(file: string, bytes: Buffer): void { + try { + const now = readIfFile(file); + if (now === undefined || !now.equals(bytes)) fs.writeFileSync(file, bytes); + } catch { + // The core's own marker stays: Pi is still off. + } +} + /** Settings init turned on that are neither recorded nor turned off: setup stops. */ class InjectionStuckError extends Error {} @@ -1377,10 +1396,13 @@ async function turnOffInjection(deps: InitCommandDependencies, keys: string[]): } try { // A Pi off marker already there was the person's own opt-out after init - // (the desktop app's switch), not this run's: it is kept. + // (the desktop app's switch), not this run's: it is kept, byte for byte, + // though turning pi_extension off rewrites it. const pi = readDeliveryRecord(deliveryRecordPath()).pi; - const piOptedOut = keys.includes('pi_extension') && pi !== undefined && fs.existsSync(pi.marker); + const piMarker = keys.includes('pi_extension') && pi !== undefined ? readIfFile(pi.marker) : undefined; + const piOptedOut = piMarker !== undefined; const failures = await disableDelivery(session.client, keys); + if (pi && piMarker) restoreBytes(pi.marker, piMarker); const done = keys.filter((key) => !failures.some((failure) => failure.startsWith(`${key}:`))); const ctx = deps.toolContext(); updateDeliveryRecord((current) => { From 8cbae4e7fb95d632fbec89a555fc485d80a2b47f Mon Sep 17 00:00:00 2001 From: kjgbot Date: Fri, 30 Oct 2026 02:20:09 -0700 Subject: [PATCH 5/6] fix(cli): keep a symlinked post-init Pi opt-out marker and its target Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/cli/commands/init.test.ts | 17 +++++++++++++++++ packages/cli/src/cli/commands/init.ts | 11 +++++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/cli/commands/init.test.ts b/packages/cli/src/cli/commands/init.test.ts index f98a93305..ec7732756 100644 --- a/packages/cli/src/cli/commands/init.test.ts +++ b/packages/cli/src/cli/commands/init.test.ts @@ -1339,6 +1339,23 @@ describe('relay init message injection', () => { expect(fs.existsSync(path.join(home, 'local', 'core-settings.json'))).toBe(false); }); + it("keeps a Pi opt-out marker that links to the person's own file on --uninstall", async () => { + installedProbe(); + const agent = path.join(toolHome, '.pi', 'agent'); + fs.mkdirSync(agent, { recursive: true }); + const marker = path.join(toolHome, '.agentworkforce', 'desktop', 'pi-extension.off'); + const core = fakeCore({ piAgentDir: agent }); + await setup(core.deps).run('--tools', 'pi'); + fs.rmSync(path.join(agent, 'extensions', 'agent-relay.js')); + const mine = path.join(toolHome, 'pi-off.txt'); + fs.writeFileSync(mine, 'off by me\n'); + fs.mkdirSync(path.dirname(marker), { recursive: true }); + fs.symlinkSync(mine, marker); + await setup({ ...core.deps, isAlive: vi.fn(() => false) }).run('--uninstall'); + expect(fs.lstatSync(marker).isSymbolicLink()).toBe(true); + expect(fs.readFileSync(mine, 'utf8')).toBe('off by me\n'); + }); + it('keeps a Pi opt-out marker that was there before init', async () => { installedProbe(); const agent = path.join(toolHome, '.pi', 'agent'); diff --git a/packages/cli/src/cli/commands/init.ts b/packages/cli/src/cli/commands/init.ts index f0a526840..41347a4e4 100644 --- a/packages/cli/src/cli/commands/init.ts +++ b/packages/cli/src/cli/commands/init.ts @@ -1056,10 +1056,10 @@ async function setupInjection( } } -/** A regular file's bytes; undefined when there is none (or it is a link or folder). */ +/** The bytes of the regular file `file` is or links to; undefined when there is none. */ function readIfFile(file: string): Buffer | undefined { try { - return fs.lstatSync(file).isFile() ? fs.readFileSync(file) : undefined; + return fs.statSync(file).isFile() ? fs.readFileSync(file) : undefined; } catch { return undefined; } @@ -1399,8 +1399,11 @@ async function turnOffInjection(deps: InitCommandDependencies, keys: string[]): // (the desktop app's switch), not this run's: it is kept, byte for byte, // though turning pi_extension off rewrites it. const pi = readDeliveryRecord(deliveryRecordPath()).pi; - const piMarker = keys.includes('pi_extension') && pi !== undefined ? readIfFile(pi.marker) : undefined; - const piOptedOut = piMarker !== undefined; + const piOptedOut = + keys.includes('pi_extension') && + pi !== undefined && + fs.lstatSync(pi.marker, { throwIfNoEntry: false }) !== undefined; + const piMarker = piOptedOut && pi ? readIfFile(pi.marker) : undefined; const failures = await disableDelivery(session.client, keys); if (pi && piMarker) restoreBytes(pi.marker, piMarker); const done = keys.filter((key) => !failures.some((failure) => failure.startsWith(`${key}:`))); From c5b89449bc4e80f4af9d95053b7ab5addbfb9f91 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Fri, 30 Oct 2026 02:56:05 -0700 Subject: [PATCH 6/6] fix(cli): read the Pi opt-out marker through one descriptor Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/cli/commands/init.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/cli/commands/init.ts b/packages/cli/src/cli/commands/init.ts index 41347a4e4..e636df625 100644 --- a/packages/cli/src/cli/commands/init.ts +++ b/packages/cli/src/cli/commands/init.ts @@ -1058,18 +1058,22 @@ async function setupInjection( /** The bytes of the regular file `file` is or links to; undefined when there is none. */ function readIfFile(file: string): Buffer | undefined { + let fd: number | undefined; try { - return fs.statSync(file).isFile() ? fs.readFileSync(file) : undefined; + // One descriptor for the check and the read, so they see the same file. + fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK); + return fs.fstatSync(fd).isFile() ? fs.readFileSync(fd) : undefined; } catch { return undefined; + } finally { + if (fd !== undefined) fs.closeSync(fd); } } -/** Puts `bytes` back in `file` when they differ; a failure leaves the core's marker, which means the same. */ +/** Puts `bytes` back in `file`; a failure leaves the core's marker, which means the same. */ function restoreBytes(file: string, bytes: Buffer): void { try { - const now = readIfFile(file); - if (now === undefined || !now.equals(bytes)) fs.writeFileSync(file, bytes); + fs.writeFileSync(file, bytes); } catch { // The core's own marker stays: Pi is still off. }