mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
Ensure OMP SQLite databases are source-backed in agent overlays (#5603)
OMP creates SQLite databases like agent.db lazily under the agent PTY directory. Proactively pre-create and link these database files (plus WAL/SHM sidecars on Windows) to the user's persistent store so that credentials and history are not lost in disposable overlays. - Implement `mirrorOmpPersistentSqliteFiles` to handle linking `agent.db` - Support both Unix symlinks and Windows hardlinks for writable state - Extract prefill and titlebar extension source code into separate files
This commit is contained in:
@@ -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<PiAgentKind, string> = {
|
||||
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')
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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<PiAgentKind, string> = {
|
||||
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<PiAgentKind, string> = {
|
||||
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')
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 `~/.<kind>/agent` source —
|
||||
// disk-presence guessing (always-Pi or first-exists) shadows the other
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user