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..ec7732756 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,80 @@ 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); + }); + + // 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, 'off by me\n'); + await setup({ ...core.deps, isAlive: vi.fn(() => false) }).run('--uninstall'); + 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); + }); + + 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'); + 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); + expect(fs.readdirSync(agent)).toEqual([]); + }); + // 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..e636df625 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 { @@ -1049,6 +1056,29 @@ 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 { + // 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`; a failure leaves the core's marker, which means the same. */ +function restoreBytes(file: string, bytes: Buffer): void { + try { + 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 {} @@ -1076,6 +1106,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 +1313,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 +1335,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 +1367,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 } : {}), }); } @@ -1358,13 +1399,25 @@ 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, byte for byte, + // though turning pi_extension off rewrites it. + const pi = readDeliveryRecord(deliveryRecordPath()).pi; + 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}:`))); const ctx = deps.toolContext(); updateDeliveryRecord((current) => { if (done.includes('direct_delivery')) restoreClaudeInbound(recordedClaudeFiles(current, ctx)); return tidyLeftovers({ ...current, + // 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 ec925f61e..d6d33c44b 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,87 @@ 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); + 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'); + 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..6d194f4da 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,28 @@ 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. + * 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 || 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)); +} diff --git a/packages/cli/src/cli/lib/probe-core.test.ts b/packages/cli/src/cli/lib/probe-core.test.ts index b7edb0e18..ff4a4da97 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,37 @@ 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: {} }); + 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 b5e5f0917..35fa63d14 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,25 @@ 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; + const { file, marker } = value as Record; + const extension = flag(value, 'extension'); + const off = flag(value, 'off'); + const dir = flag(value, 'dir'); + // 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 }; +} + /** * The record of what init turned on. * @param file - `/local/core-settings.json` @@ -645,11 +666,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: {} };