diff --git a/src/main/pi/prefill-extension-source.ts b/src/main/pi/prefill-extension-source.ts new file mode 100644 index 00000000000..9c07f549aed --- /dev/null +++ b/src/main/pi/prefill-extension-source.ts @@ -0,0 +1,38 @@ +import type { PiAgentKind } from '../../shared/pi-agent-kind' + +export const ORCA_PI_PREFILL_EXTENSION_FILE = 'orca-prefill.ts' + +// Why: prefill-without-submit needs an env-var the bundled `orca-prefill.ts` +// extension can read on session_start. Each kind owns its own variable so an +// OMP PTY never honors a Pi draft (or vice versa). +const PREFILL_ENV_VAR_BY_KIND: Record = { + pi: 'ORCA_PI_PREFILL', + omp: 'ORCA_OMP_PREFILL' +} + +/** Pi's prefill env var. Exported for callers that need the literal name + * (renderer draft-launch plan builder, tests). OMP callers should read + * `ORCA_OMP_PREFILL_ENV_VAR` instead. */ +export const ORCA_PI_PREFILL_ENV_VAR = PREFILL_ENV_VAR_BY_KIND.pi + +/** OMP's prefill env var. Mirrors `ORCA_PI_PREFILL_ENV_VAR` for OMP launches + * so renderer plans and shell-ready restore lines can stay agent-scoped. */ +export const ORCA_OMP_PREFILL_ENV_VAR = PREFILL_ENV_VAR_BY_KIND.omp + +export function getPiPrefillExtensionSource(kind: PiAgentKind): string { + const envVar = PREFILL_ENV_VAR_BY_KIND[kind] + return [ + 'export default function (pi) {', + " pi.on('session_start', async (event, ctx) => {", + " if (event.reason !== 'startup') return", + ` const prefill = process.env.${envVar}`, + ' if (!prefill) return', + ` delete process.env.${envVar}`, + ' try {', + ' ctx.ui.setEditorText(prefill)', + ' } catch {}', + ' })', + '}', + '' + ].join('\n') +} diff --git a/src/main/pi/titlebar-extension-service.test.ts b/src/main/pi/titlebar-extension-service.test.ts index 0beeed23388..d9587aa9b54 100644 --- a/src/main/pi/titlebar-extension-service.test.ts +++ b/src/main/pi/titlebar-extension-service.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { existsSync, + lstatSync, mkdirSync, mkdtempSync, readFileSync, @@ -187,6 +188,25 @@ describe('PiTitlebarExtensionService', () => { expectPiHomeIntact() }) + it('source-backs OMP agent.db before OMP lazily creates it', () => { + const svc = new PiTitlebarExtensionService() + const env = svc.buildPtyEnv('pty-omp-sqlite', piHome, 'omp') + + const sourcePath = join(piHome, 'agent.db') + const overlayPath = join(env.PI_CODING_AGENT_DIR!, 'agent.db') + const content = 'agent.db credentials' + + expect(existsSync(sourcePath)).toBe(true) + expect(existsSync(overlayPath)).toBe(true) + expect(existsSync(join(piHome, 'history.db'))).toBe(false) + writeFileSync(overlayPath, content) + + expect(readFileSync(sourcePath, 'utf-8')).toBe(content) + if (process.platform !== 'win32') { + expect(lstatSync(overlayPath).isSymbolicLink()).toBe(true) + } + }) + it('rebuilding an overlay for the same ptyId does not corrupt the user Pi dir', () => { const svc = new PiTitlebarExtensionService() svc.buildPtyEnv('pty-3', piHome, 'pi') diff --git a/src/main/pi/titlebar-extension-service.ts b/src/main/pi/titlebar-extension-service.ts index 8fa9d5f71bd..9638d516c8d 100644 --- a/src/main/pi/titlebar-extension-service.ts +++ b/src/main/pi/titlebar-extension-service.ts @@ -15,12 +15,22 @@ import { ORCA_PI_AGENT_STATUS_EXTENSION_FILE, getPiAgentStatusExtensionSource } from './agent-status-extension-source' +import { + ORCA_PI_PREFILL_EXTENSION_FILE, + getPiPrefillExtensionSource +} from './prefill-extension-source' +export { ORCA_OMP_PREFILL_ENV_VAR, ORCA_PI_PREFILL_ENV_VAR } from './prefill-extension-source' +import { ORCA_PI_EXTENSION_FILE, getPiTitlebarExtensionSource } from './titlebar-extension-source' import { isSafeDescendCandidate as sharedIsSafeDescendCandidate, mirrorEntry, safeRemoveOverlay, safeRemoveTree } from '../pty/overlay-mirror' +import { + isOmpPersistentSqliteEntry, + mirrorOmpPersistentSqliteFiles +} from '../pty/omp-sqlite-overlay' import { mergePiOverlayUiSettings } from '../../shared/pi-overlay-ui-settings' import type { PiAgentKind } from '../../shared/pi-agent-kind' @@ -30,8 +40,6 @@ import type { PiAgentKind } from '../../shared/pi-agent-kind' // keeps holding after the helper moved to src/main/pty/overlay-mirror.ts. export const isSafeDescendCandidate = sharedIsSafeDescendCandidate -const ORCA_PI_EXTENSION_FILE = 'orca-titlebar-spinner.ts' -const ORCA_PI_PREFILL_EXTENSION_FILE = 'orca-prefill.ts' const PI_AGENT_SUBDIR = 'agent' const PI_AGENT_SETTINGS_FILE = 'settings.json' const PI_OVERLAY_MANIFEST_FILE = '.orca-pi-overlay-manifest.json' @@ -59,112 +67,6 @@ const AGENT_HOME_DIR_NAME: Record = { omp: '.omp' } -// Why: prefill-without-submit needs an env-var the bundled `orca-prefill.ts` -// extension can read on session_start. Each kind owns its own variable so an -// OMP PTY never honors a Pi draft (or vice versa) - the only state the two -// share is the binary-mandated `PI_CODING_AGENT_DIR` itself. Pi keeps its -// original name for back-compat with PTYs already in flight at upgrade time; -// OMP gets a parallel `ORCA_OMP_PREFILL`. -const PREFILL_ENV_VAR_BY_KIND: Record = { - pi: 'ORCA_PI_PREFILL', - omp: 'ORCA_OMP_PREFILL' -} - -/** Pi's prefill env var. Exported for callers that need the literal name - * (renderer draft-launch plan builder, tests). OMP callers should read - * `ORCA_OMP_PREFILL_ENV_VAR` instead. */ -export const ORCA_PI_PREFILL_ENV_VAR = PREFILL_ENV_VAR_BY_KIND.pi - -/** OMP's prefill env var. Mirrors `ORCA_PI_PREFILL_ENV_VAR` for OMP launches - * so renderer plans and shell-ready restore lines can stay agent-scoped. */ -export const ORCA_OMP_PREFILL_ENV_VAR = PREFILL_ENV_VAR_BY_KIND.omp - -// Why: pi/OMP both expose an `ExtensionAPI` to the default-exported factory. -// The extension reads its kind-specific env var on session_start and types -// the payload into the editor without submitting. The env var is consumed -// (deleted from process.env) so `/new` in the same session doesn't re-prefill. -// The extension API name is identical between pi and OMP because OMP keeps -// Pi's `@oh-my-pi/*` runtime packages. -function getPiPrefillExtensionSource(kind: PiAgentKind): string { - const envVar = PREFILL_ENV_VAR_BY_KIND[kind] - return [ - 'export default function (pi) {', - " pi.on('session_start', async (event, ctx) => {", - " if (event.reason !== 'startup') return", - ` const prefill = process.env.${envVar}`, - ' if (!prefill) return', - ` delete process.env.${envVar}`, - ' try {', - ' ctx.ui.setEditorText(prefill)', - ' } catch {}', - ' })', - '}', - '' - ].join('\n') -} - -function getPiTitlebarExtensionSource(): string { - return [ - 'const BRAILLE_FRAMES = [', - " '\\u280b',", - " '\\u2819',", - " '\\u2839',", - " '\\u2838',", - " '\\u283c',", - " '\\u2834',", - " '\\u2826',", - " '\\u2827',", - " '\\u2807',", - " '\\u280f'", - ']', - '', - 'function getBaseTitle(pi) {', - ' const cwd = process.cwd().split(/[\\\\/]/).filter(Boolean).at(-1) || process.cwd()', - ' const session = pi.getSessionName()', - ' return session ? `\\u03c0 - ${session} - ${cwd}` : `\\u03c0 - ${cwd}`', - '}', - '', - 'export default function (pi) {', - ' let timer = null', - ' let frameIndex = 0', - '', - ' function stopAnimation(ctx) {', - ' if (timer) {', - ' clearInterval(timer)', - ' timer = null', - ' }', - ' frameIndex = 0', - ' ctx.ui.setTitle(getBaseTitle(pi))', - ' }', - '', - ' function startAnimation(ctx) {', - ' stopAnimation(ctx)', - ' timer = setInterval(() => {', - ' const frame = BRAILLE_FRAMES[frameIndex % BRAILLE_FRAMES.length]', - ' const cwd = process.cwd().split(/[\\\\/]/).filter(Boolean).at(-1) || process.cwd()', - ' const session = pi.getSessionName()', - ' const title = session ? `${frame} \\u03c0 - ${session} - ${cwd}` : `${frame} \\u03c0 - ${cwd}`', - ' ctx.ui.setTitle(title)', - ' frameIndex++', - ' }, 80)', - ' }', - '', - " pi.on('agent_start', async (_event, ctx) => {", - ' startAnimation(ctx)', - ' })', - '', - " pi.on('agent_end', async (_event, ctx) => {", - ' stopAnimation(ctx)', - ' })', - '', - " pi.on('session_shutdown', async (_event, ctx) => {", - ' stopAnimation(ctx)', - ' })', - '}', - '' - ].join('\n') -} - function getDefaultPiAgentDir(kind: PiAgentKind): string { return join(homedir(), AGENT_HOME_DIR_NAME[kind], PI_AGENT_SUBDIR) } @@ -234,13 +136,18 @@ export class PiTitlebarExtensionService { } } - private mirrorAgentDir(sourceAgentDir: string, overlayDir: string): void { + private mirrorAgentDir(sourceAgentDir: string, overlayDir: string, kind: PiAgentKind): void { const previousManifest = this.readOverlayManifest(overlayDir) this.clearManifestEntries(overlayDir, previousManifest) const nextManifest: PiOverlayManifest = { topLevelEntries: [], extensionEntries: [] } if (!existsSync(sourceAgentDir)) { + if (kind === 'omp') { + nextManifest.topLevelEntries.push( + ...mirrorOmpPersistentSqliteFiles(sourceAgentDir, overlayDir) + ) + } this.writeOverlayManifest(overlayDir, nextManifest) return } @@ -252,6 +159,10 @@ export class PiTitlebarExtensionService { continue } + if (kind === 'omp' && isOmpPersistentSqliteEntry(entry.name)) { + continue + } + if (entry.name === 'extensions') { const isSymlink = entry.isSymbolicLink() let isLinkPointingToDir = false @@ -300,6 +211,12 @@ export class PiTitlebarExtensionService { nextManifest.topLevelEntries.push(entry.name) } + if (kind === 'omp') { + nextManifest.topLevelEntries.push( + ...mirrorOmpPersistentSqliteFiles(sourceAgentDir, overlayDir) + ) + } + this.writeOverlayManifest(overlayDir, nextManifest) } @@ -349,7 +266,7 @@ export class PiTitlebarExtensionService { try { mkdirSync(overlayDir, { recursive: true }) - this.mirrorAgentDir(sourceAgentDir, overlayDir) + this.mirrorAgentDir(sourceAgentDir, overlayDir, kind) this.writeOverlaySettings(sourceAgentDir, overlayDir) const extensionsDir = join(overlayDir, 'extensions') diff --git a/src/main/pi/titlebar-extension-source.ts b/src/main/pi/titlebar-extension-source.ts new file mode 100644 index 00000000000..184e412cd33 --- /dev/null +++ b/src/main/pi/titlebar-extension-source.ts @@ -0,0 +1,63 @@ +export const ORCA_PI_EXTENSION_FILE = 'orca-titlebar-spinner.ts' + +export function getPiTitlebarExtensionSource(): string { + return [ + 'const BRAILLE_FRAMES = [', + " '\\u280b',", + " '\\u2819',", + " '\\u2839',", + " '\\u2838',", + " '\\u283c',", + " '\\u2834',", + " '\\u2826',", + " '\\u2827',", + " '\\u2807',", + " '\\u280f'", + ']', + '', + 'function getBaseTitle(pi) {', + ' const cwd = process.cwd().split(/[\\\\/]/).filter(Boolean).at(-1) || process.cwd()', + ' const session = pi.getSessionName()', + ' return session ? `\\u03c0 - ${session} - ${cwd}` : `\\u03c0 - ${cwd}`', + '}', + '', + 'export default function (pi) {', + ' let timer = null', + ' let frameIndex = 0', + '', + ' function stopAnimation(ctx) {', + ' if (timer) {', + ' clearInterval(timer)', + ' timer = null', + ' }', + ' frameIndex = 0', + ' ctx.ui.setTitle(getBaseTitle(pi))', + ' }', + '', + ' function startAnimation(ctx) {', + ' stopAnimation(ctx)', + ' timer = setInterval(() => {', + ' const frame = BRAILLE_FRAMES[frameIndex % BRAILLE_FRAMES.length]', + ' const cwd = process.cwd().split(/[\\\\/]/).filter(Boolean).at(-1) || process.cwd()', + ' const session = pi.getSessionName()', + ' const title = session ? `${frame} \\u03c0 - ${session} - ${cwd}` : `${frame} \\u03c0 - ${cwd}`', + ' ctx.ui.setTitle(title)', + ' frameIndex++', + ' }, 80)', + ' }', + '', + " pi.on('agent_start', async (_event, ctx) => {", + ' startAnimation(ctx)', + ' })', + '', + " pi.on('agent_end', async (_event, ctx) => {", + ' stopAnimation(ctx)', + ' })', + '', + " pi.on('session_shutdown', async (_event, ctx) => {", + ' stopAnimation(ctx)', + ' })', + '}', + '' + ].join('\n') +} diff --git a/src/main/pty/omp-sqlite-overlay.test.ts b/src/main/pty/omp-sqlite-overlay.test.ts new file mode 100644 index 00000000000..7a4c223d06e --- /dev/null +++ b/src/main/pty/omp-sqlite-overlay.test.ts @@ -0,0 +1,53 @@ +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync +} from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import { mirrorOmpPersistentSqliteFiles } from './omp-sqlite-overlay' + +const tempDirs: string[] = [] + +function makeTempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'orca-omp-sqlite-overlay-')) + tempDirs.push(dir) + return dir +} + +describe('OMP SQLite overlay persistence', () => { + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('replaces overlay-local SQLite files with source-backed DB files', () => { + const root = makeTempDir() + const sourceDir = join(root, 'source-agent') + const overlayDir = join(root, 'overlay-agent') + mkdirSync(overlayDir, { recursive: true }) + writeFileSync(join(overlayDir, 'agent.db'), 'overlay main') + writeFileSync(join(overlayDir, 'agent.db-wal'), 'overlay wal') + + const mirroredEntries = mirrorOmpPersistentSqliteFiles(sourceDir, overlayDir) + + expect(mirroredEntries).toContain('agent.db') + expect(mirroredEntries).not.toContain('history.db') + expect(readFileSync(join(sourceDir, 'agent.db'), 'utf8')).toBe('') + expect(existsSync(join(sourceDir, 'agent.db-wal'))).toBe(process.platform === 'win32') + + writeFileSync(join(overlayDir, 'agent.db'), 'new credentials') + + expect(readFileSync(join(sourceDir, 'agent.db'), 'utf8')).toBe('new credentials') + if (process.platform !== 'win32') { + expect(lstatSync(join(overlayDir, 'agent.db')).isSymbolicLink()).toBe(true) + expect(existsSync(join(overlayDir, 'agent.db-wal'))).toBe(false) + } + }) +}) diff --git a/src/main/pty/omp-sqlite-overlay.ts b/src/main/pty/omp-sqlite-overlay.ts new file mode 100644 index 00000000000..880e712a8b8 --- /dev/null +++ b/src/main/pty/omp-sqlite-overlay.ts @@ -0,0 +1,70 @@ +// Why: OMP creates SQLite auth/history DBs lazily under PI_CODING_AGENT_DIR. +// If Orca only mirrors files that already exist, /login writes land in a +// disposable overlay instead of the user's ~/.omp/agent store. + +import { closeSync, existsSync, mkdirSync, openSync } from 'fs' +import { join } from 'path' +import { mirrorWritableFileEntry, safeRemoveTree } from './overlay-mirror' + +export const OMP_PERSISTENT_SQLITE_FILES = ['agent.db'] as const + +const SQLITE_SIDECAR_SUFFIXES = ['-wal', '-shm'] as const + +export function isOmpPersistentSqliteEntry(entryName: string): boolean { + return OMP_PERSISTENT_SQLITE_FILES.some( + (databaseName) => + entryName === databaseName || + entryName === `${databaseName}-wal` || + entryName === `${databaseName}-shm` + ) +} + +function ensureEmptyFile(path: string): void { + closeSync(openSync(path, 'a')) +} + +function mirrorOmpSqliteFile( + sourcePath: string, + overlayPath: string, + databaseName: string +): string[] { + if (!existsSync(sourcePath)) { + ensureEmptyFile(sourcePath) + } + + safeRemoveTree(overlayPath) + for (const sidecarSuffix of SQLITE_SIDECAR_SUFFIXES) { + safeRemoveTree(`${overlayPath}${sidecarSuffix}`) + } + + mirrorWritableFileEntry(sourcePath, overlayPath) + const mirroredEntries = [databaseName] + + if (process.platform === 'win32') { + // Why: Windows hardlinks do not redirect SQLite's derived WAL filenames. + // Link sidecars too so lazy WAL writes still land in ~/.omp/agent. + for (const sidecarSuffix of SQLITE_SIDECAR_SUFFIXES) { + const sourceSidecar = `${sourcePath}${sidecarSuffix}` + const sidecarName = `${mirroredEntries[0]}${sidecarSuffix}` + ensureEmptyFile(sourceSidecar) + mirrorWritableFileEntry(sourceSidecar, `${overlayPath}${sidecarSuffix}`) + mirroredEntries.push(sidecarName) + } + } + + return mirroredEntries +} + +export function mirrorOmpPersistentSqliteFiles( + sourceAgentDir: string, + overlayDir: string +): string[] { + mkdirSync(sourceAgentDir, { recursive: true }) + return OMP_PERSISTENT_SQLITE_FILES.flatMap((databaseName) => + mirrorOmpSqliteFile( + join(sourceAgentDir, databaseName), + join(overlayDir, databaseName), + databaseName + ) + ) +} diff --git a/src/main/pty/overlay-mirror.ts b/src/main/pty/overlay-mirror.ts index 6d363088d3e..639dfb695d7 100644 --- a/src/main/pty/overlay-mirror.ts +++ b/src/main/pty/overlay-mirror.ts @@ -39,6 +39,27 @@ export function mirrorEntry(sourcePath: string, targetPath: string): void { symlinkSync(sourcePath, targetPath, isDirectoryLike ? 'dir' : 'file') } +export function mirrorWritableFileEntry(sourcePath: string, targetPath: string): void { + if (process.platform === 'win32') { + try { + linkSync(sourcePath, targetPath) + return + } catch { + // Cross-device homes cannot hardlink; try a file symlink so writable + // SQLite state can still flow to source instead of a disposable copy. + } + + try { + symlinkSync(sourcePath, targetPath, 'file') + return + } catch { + throw new Error(`Unable to create source-backed writable file mirror: ${targetPath}`) + } + } + + symlinkSync(sourcePath, targetPath, 'file') +} + // Exported for tests. A "descend candidate" is an entry whose children we // should recurse into when tearing down the overlay. Anything that is a // symlink (including a Windows directory junction) must NOT be a candidate diff --git a/src/relay/plugin-overlay.test.ts b/src/relay/plugin-overlay.test.ts index 422f0b0cee0..7f82c477677 100644 --- a/src/relay/plugin-overlay.test.ts +++ b/src/relay/plugin-overlay.test.ts @@ -169,6 +169,29 @@ describe('PluginOverlayManager', () => { ) }) + it('source-backs lazy OMP agent.db on the relay', () => { + manager.setSources({ piExtensionSource: '// pi extension' }) + const sourceDir = join(homeDir, '.omp', 'agent') + const firstDir = manager.materializePi('tab-relay-omp-sqlite:0', undefined, 'omp') + + expect(firstDir).not.toBeNull() + const sourcePath = join(sourceDir, 'agent.db') + const overlayPath = join(firstDir!, 'agent.db') + const content = 'agent.db relay credentials' + + expect(existsSync(sourcePath)).toBe(true) + expect(existsSync(overlayPath)).toBe(true) + expect(existsSync(join(sourceDir, 'history.db'))).toBe(false) + writeFileSync(overlayPath, content) + + expect(readFileSync(sourcePath, 'utf8')).toBe(content) + + const secondDir = manager.materializePi('tab-relay-omp-sqlite:0', undefined, 'omp') + + expect(secondDir).toBe(firstDir) + expect(readFileSync(join(secondDir!, 'agent.db'), 'utf8')).toBe('agent.db relay credentials') + }) + // Why: per-agent overlay source dir. The renderer picks Pi or OMP per // launch, and the relay must mirror the right `~/./agent` source — // disk-presence guessing (always-Pi or first-exists) shadows the other diff --git a/src/relay/plugin-overlay.ts b/src/relay/plugin-overlay.ts index 745664a969f..c7cf985f29d 100644 --- a/src/relay/plugin-overlay.ts +++ b/src/relay/plugin-overlay.ts @@ -30,6 +30,10 @@ import { import { homedir } from 'os' import { basename, join } from 'path' import { mirrorEntry, safeRemoveOverlay } from '../main/pty/overlay-mirror' +import { + isOmpPersistentSqliteEntry, + mirrorOmpPersistentSqliteFiles +} from '../main/pty/omp-sqlite-overlay' import { mergePiOverlayUiSettings } from '../shared/pi-overlay-ui-settings' import type { PiAgentKind } from '../shared/pi-agent-kind' @@ -218,8 +222,11 @@ export class PluginOverlayManager { return join(this.homeDir, PI_AGENT_HOME_DIR_NAME[kind], PI_AGENT_SUBDIR) } - private mirrorPiAgentDir(sourceAgentDir: string, overlayDir: string): void { + private mirrorPiAgentDir(sourceAgentDir: string, overlayDir: string, kind: PiAgentKind): void { if (!existsSync(sourceAgentDir)) { + if (kind === 'omp') { + mirrorOmpPersistentSqliteFiles(sourceAgentDir, overlayDir) + } return } @@ -230,6 +237,10 @@ export class PluginOverlayManager { continue } + if (kind === 'omp' && isOmpPersistentSqliteEntry(entry.name)) { + continue + } + if (entry.name === 'extensions') { const isSymlink = entry.isSymbolicLink() let isLinkPointingToDir = false @@ -260,6 +271,10 @@ export class PluginOverlayManager { mirrorEntry(sourcePath, join(overlayDir, basename(sourcePath))) } + + if (kind === 'omp') { + mirrorOmpPersistentSqliteFiles(sourceAgentDir, overlayDir) + } } private readPiSettings(sourceAgentDir: string): unknown { @@ -300,17 +315,17 @@ export class PluginOverlayManager { const root = this.piRoots[kind] const dir = join(root, safeDirName(id)) try { + const sourceAgentDir = existingAgentDir ?? this.getDefaultPiAgentDir(kind) + if (existingAgentDir && !existsSync(existingAgentDir)) { + return null + } // Why: PI_CODING_AGENT_DIR is the whole state root for both Pi and OMP // (OMP inherits the env-var name from Pi by design). Mirror the remote // user's default agent dir so Orca's status extension does not hide auth, // sessions, skills, prompts, themes, or user extensions inside SSH panes. safeRemoveOverlay(dir, root) mkdirSync(dir, { recursive: true }) - const sourceAgentDir = existingAgentDir ?? this.getDefaultPiAgentDir(kind) - if (existingAgentDir && !existsSync(existingAgentDir)) { - return null - } - this.mirrorPiAgentDir(sourceAgentDir, dir) + this.mirrorPiAgentDir(sourceAgentDir, dir, kind) this.writePiOverlaySettings(sourceAgentDir, dir) const extensionsDir = join(dir, 'extensions') mkdirSync(extensionsDir, { recursive: true })