1111// `import.meta.url` lives under `/$bunfs/` (or `B:/~BUN/` on Windows), a
1212// read-only virtual filesystem. uv cannot write `.venv/` there. We extract
1313// the embedded harness (built into the binary by `script/embed-harness.ts`)
14- // to a single un-versioned directory at `~/.cache/bcode/harness/`.
14+ // to `<dataDir>/harness/`, where dataDir is opencode's XDG_DATA_HOME for
15+ // bcode (~/.local/share/bcode/ on Linux/Mac). The harness is data, not
16+ // cache: it accumulates agent edits to `agent-workspace/agent_helpers.py`
17+ // that must outlive a `~/.cache` wipe.
1518//
16- // Per decisions §4.8, the cache is **un-versioned** so agent edits to
17- // `agent-workspace/agent_helpers.py` survive binary upgrades. Extraction
18- // policy on every launch: walk the embed map and write each file out, with
19- // one exception — `agent-workspace/agent_helpers.py` is preserved if
20- // already present. Everything else (`src/browser_harness/*.py`,
21- // `pyproject.toml`, skills, etc.) is overwritten unconditionally; the
22- // binary is the source of truth for those, and we want curated skill /
23- // daemon / setup updates to land on upgrade.
24- // `agent-workspace/agent_helpers.py` is the one Green-zone file (decisions
25- // §3.7, §4.5) where agent learnings accumulate and must outlive upgrades.
26- // Upstream moved the agent-editable surface from root `helpers.py` to
27- // `agent-workspace/agent_helpers.py` in PR #229; the core `helpers.py`
28- // inside `src/browser_harness/` is now baseline-overwrite.
19+ // A content-hash sentinel at `<harness>/.bcode-build` records the embed
20+ // bundle that produced the on-disk tree. On session start we compare it to
21+ // the bundle hash and skip extraction when they match — warm launches cost
22+ // one stat. Mismatch (binary upgrade) snapshots the active tree to
23+ // `<dataDir>/harness-archive/<old-buildHash>/` (excluding `.venv/` and
24+ // `__pycache__/`) so the agent can read the old skills + helpers when
25+ // migrating its own customizations, then re-extracts every embed file
26+ // except anything under `agent-workspace/` (the Green-zone subtree —
27+ // decisions §3.7, §4.5: agent_helpers.py and any agent-authored files
28+ // like domain-skills/<host>/*.md persist across upgrades). The core
29+ // `src/browser_harness/` package and shipped skill files are
30+ // baseline-overwrite.
2931//
3032// Concurrent first-callers are deduplicated via an in-process promise.
3133// Bun.write is atomic per file; cross-process races just result in the
3234// same bytes being written, which is fine.
35+ //
36+ // On first launch after the relocation, any pre-existing harness at the
37+ // legacy `~/.cache/bcode/harness/` is moved to the new location so agent
38+ // edits under `agent-workspace/` survive the upgrade.
3339
3440import fs from "fs/promises"
3541import os from "os"
@@ -47,41 +53,105 @@ const isCompiled = (() => {
4753 return d . startsWith ( "/$bunfs/" ) || d . startsWith ( "B:/~BUN/" )
4854} ) ( )
4955const DEV_HARNESS_DIR = path . resolve ( __dirname , ".." , "harness" )
50- const cachedHarnessDir = path . join ( os . homedir ( ) , ".cache" , "bcode" , "harness" )
56+ const LEGACY_CACHE_DIR = path . join ( os . homedir ( ) , ".cache" , "bcode" , "harness" )
57+ const SENTINEL_NAME = ".bcode-build"
58+
59+ // Embed paths that are agent-editable and must be preserved across binary
60+ // upgrades. Per decisions §3.7 / §4.5 the entire `agent-workspace/` subtree
61+ // is the Green zone (agent_helpers.py plus any agent-authored files such as
62+ // domain-skills/<host>/*.md). The core `src/browser_harness/` package and
63+ // shipped skill files are baseline-overwrite.
64+ const PRESERVED_PREFIX = "agent-workspace/"
65+
66+ // Compute the harness directory for a given dataDir without touching the
67+ // filesystem. The agent permission whitelist uses this; runtime extraction
68+ // uses `resolveHarnessDir`.
69+ export const harnessDir = ( dataDir : string ) => path . join ( dataDir , "harness" )
5170
52- // Files that are agent-editable and must be preserved across binary upgrades.
53- // Everything in the embed map that isn't in this set is baseline-overwrite.
54- // Per decisions §3.7 / §4.5: only `agent-workspace/agent_helpers.py` is
55- // Green-zone editable inside the harness. The core `src/browser_harness/`
56- // package (daemon, admin, helpers, run, _ipc) is baseline-only.
57- const PRESERVED_PATHS = new Set ( [ "agent-workspace/agent_helpers.py" ] )
71+ // Where past-version snapshots live. Each subdir is named for the buildHash
72+ // of the harness it was extracted from. Read-only after creation.
73+ export const harnessArchiveDir = ( dataDir : string ) => path . join ( dataDir , "harness-archive" )
74+
75+ // Skipped during archive copies — regenerable (.venv) or junk (__pycache__).
76+ // Match by basename at any depth so nested __pycache__/ inside src/ is also
77+ // excluded.
78+ const ARCHIVE_EXCLUDE = new Set ( [ ".venv" , "__pycache__" ] )
5879
5980const exists = ( p : string ) => fs . access ( p ) . then ( ( ) => true , ( ) => false )
6081
61- const extractEmbeddedHarness = async ( ) : Promise < string > => {
82+ const readSentinel = async ( dir : string ) => {
83+ try { return await fs . readFile ( path . join ( dir , SENTINEL_NAME ) , "utf8" ) }
84+ catch { return null }
85+ }
86+
87+ const migrateLegacyIfPresent = async ( target : string ) => {
88+ if ( ! ( await exists ( LEGACY_CACHE_DIR ) ) ) return
89+ if ( await exists ( target ) ) return
90+ await fs . mkdir ( path . dirname ( target ) , { recursive : true } )
91+ try { await fs . rename ( LEGACY_CACHE_DIR , target ) }
92+ catch ( err ) {
93+ if ( ( err as { code ?: string } ) . code !== "EXDEV" ) throw err
94+ await fs . cp ( LEGACY_CACHE_DIR , target , { recursive : true } )
95+ await fs . rm ( LEGACY_CACHE_DIR , { recursive : true , force : true } )
96+ }
97+ }
98+
99+ const archiveExistingHarness = async ( dataDir : string , target : string , oldHash : string ) => {
100+ const archiveTarget = path . join ( harnessArchiveDir ( dataDir ) , oldHash )
101+ if ( await exists ( archiveTarget ) ) return // already archived (re-entry); nothing to do
102+ await fs . mkdir ( harnessArchiveDir ( dataDir ) , { recursive : true } )
103+ await fs . cp ( target , archiveTarget , {
104+ recursive : true ,
105+ filter : ( src ) => ! ARCHIVE_EXCLUDE . has ( path . basename ( src ) ) ,
106+ } )
107+ }
108+
109+ const extractEmbeddedHarness = async ( dataDir : string ) : Promise < string > => {
110+ const target = harnessDir ( dataDir )
111+ await migrateLegacyIfPresent ( target )
112+
62113 // @ts -expect-error generated at build time
63114 const mod = await import ( "bcode-harness.gen.ts" ) . catch ( ( ) => null )
64115 if ( ! mod ) throw new Error ( "bcode-harness.gen.ts not found in compiled binary — was the build script updated?" )
65116 const fileMap = mod . default as Record < string , string >
117+ const buildHash = mod . buildHash as string
118+
119+ const existing = await readSentinel ( target )
120+ if ( existing === buildHash ) return target
121+ if ( existing ) await archiveExistingHarness ( dataDir , target , existing )
66122
67- await fs . mkdir ( cachedHarnessDir , { recursive : true } )
123+ await fs . mkdir ( target , { recursive : true } )
68124 await Promise . all (
69125 Object . entries ( fileMap ) . map ( async ( [ rel , bunfsPath ] ) => {
70- const dest = path . join ( cachedHarnessDir , rel )
71- if ( PRESERVED_PATHS . has ( rel ) && ( await exists ( dest ) ) ) return
126+ const dest = path . join ( target , rel )
127+ if ( rel . startsWith ( PRESERVED_PREFIX ) && ( await exists ( dest ) ) ) return
72128 await fs . mkdir ( path . dirname ( dest ) , { recursive : true } )
73129 await Bun . write ( dest , Bun . file ( bunfsPath ) )
74130 } ) ,
75131 )
76- return cachedHarnessDir
132+ await fs . writeFile ( path . join ( target , SENTINEL_NAME ) , buildHash , "utf8" )
133+ return target
77134}
78135
79- let extractPromise : Promise < string > | null = null
136+ // Per-dataDir cache. In production opencode passes the same Global.Path.data
137+ // every call, so this is effectively a singleton; tests and any future
138+ // multi-instance setup that resolves against multiple dataDirs each get their
139+ // own deduplicated extraction without cross-directory contamination.
140+ const extractCache = new Map < string , Promise < string > > ( )
80141
81- export const resolveHarnessDir = ( ) : Promise < string > => {
142+ export const resolveHarnessDir = ( dataDir : string ) : Promise < string > => {
82143 if ( ! isCompiled ) return Promise . resolve ( DEV_HARNESS_DIR )
83- if ( ! extractPromise ) extractPromise = extractEmbeddedHarness ( )
84- return extractPromise
144+ const cached = extractCache . get ( dataDir )
145+ if ( cached ) return cached
146+ const fresh = extractEmbeddedHarness ( dataDir )
147+ extractCache . set ( dataDir , fresh )
148+ // Evict on rejection so a transient failure (FS hiccup, partial write) doesn't
149+ // permanently brick subsequent calls. The `===` guard avoids clobbering a
150+ // retry that started after the failure but before this handler fired.
151+ fresh . catch ( ( ) => {
152+ if ( extractCache . get ( dataDir ) === fresh ) extractCache . delete ( dataDir )
153+ } )
154+ return fresh
85155}
86156
87157export * as Harness from "./harness"
0 commit comments