diff --git a/packages/app/e2e/app/server-default.spec.ts b/packages/app/e2e/app/server-default.spec.ts
index 6a12bcd52..d4320975a 100644
--- a/packages/app/e2e/app/server-default.spec.ts
+++ b/packages/app/e2e/app/server-default.spec.ts
@@ -2,7 +2,7 @@ import { test, expect } from "../fixtures"
import { serverNamePattern, serverUrls } from "../utils"
import { closeDialog, clickMenuItem } from "../actions"
-const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
+const DEFAULT_SERVER_URL_KEY = "pawwork.settings.dat:defaultServerUrl"
test("can set a default server on web", async ({ page, gotoSession }) => {
await page.addInitScript((key: string) => {
diff --git a/packages/app/e2e/commands/panels.spec.ts b/packages/app/e2e/commands/panels.spec.ts
index 5af3f8895..20ea347a5 100644
--- a/packages/app/e2e/commands/panels.spec.ts
+++ b/packages/app/e2e/commands/panels.spec.ts
@@ -176,7 +176,7 @@ test("desktop right-panel collapses shell tab labels below the compact threshold
test("legacy changes side-panel state restores into the review tab", async ({ page, gotoSession, slug }) => {
await page.addInitScript(({ slug }) => {
- const key = "opencode.global.dat:layout"
+ const key = "pawwork.global.dat:layout"
const raw = localStorage.getItem(key)
const parsed = (() => {
if (!raw) return {}
diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts
index fe2eb9c1a..15ceec054 100644
--- a/packages/app/e2e/fixtures.ts
+++ b/packages/app/e2e/fixtures.ts
@@ -548,7 +548,7 @@ async function seedStorage(
extra: string[]
model: { providerID: string; modelID: string }
}) => {
- const key = "opencode.global.dat:server"
+ const key = "pawwork.global.dat:server"
const raw = localStorage.getItem(key)
const parsed = (() => {
if (!raw) return undefined
@@ -586,7 +586,7 @@ async function seedStorage(
}
localStorage.setItem(key, JSON.stringify({ list: nextList, projects: next, lastProject }))
- localStorage.setItem("opencode.settings.dat:defaultServerUrl", args.serverUrl)
+ localStorage.setItem("pawwork.settings.dat:defaultServerUrl", args.serverUrl)
const win = window as E2EWindow
win.__opencode_e2e = {
@@ -595,7 +595,7 @@ async function seedStorage(
prompt: { enabled: true },
terminal: { enabled: true, terminals: {} },
}
- localStorage.setItem("opencode.global.dat:model", JSON.stringify({ recent: [args.model], user: [], variant: {} }))
+ localStorage.setItem("pawwork.global.dat:model", JSON.stringify({ recent: [args.model], user: [], variant: {} }))
},
{ directory: input.directory, serverUrl: origin, extra: input.extra ?? [], model: input.model ?? seedModel },
)
diff --git a/packages/app/e2e/settings/settings.spec.ts b/packages/app/e2e/settings/settings.spec.ts
index 9ade243d2..44830b738 100644
--- a/packages/app/e2e/settings/settings.spec.ts
+++ b/packages/app/e2e/settings/settings.spec.ts
@@ -16,8 +16,8 @@ import {
test("@smoke new installs start with the PawWork theme", async ({ page, gotoSession }) => {
await page.addInitScript(() => {
- localStorage.removeItem("opencode-theme-id")
- localStorage.removeItem("opencode-color-scheme")
+ localStorage.removeItem("pawwork-theme-id")
+ localStorage.removeItem("pawwork-color-scheme")
})
await gotoSession()
@@ -51,7 +51,7 @@ test('@smoke PawWork settings opens as a full-pane surface, not a dialog', async
test("changing language updates settings labels", async ({ page, gotoSession }) => {
await page.addInitScript(() => {
- localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale: "en" }))
+ localStorage.setItem("pawwork.global.dat:language", JSON.stringify({ locale: "en" }))
})
await gotoSession()
@@ -85,11 +85,11 @@ test.skip("changing theme persists in localStorage", async () => {
// and cannot exercise switching. Revisit once a second theme is added.
})
-test("legacy theme ids migrate to pawwork and clear cached CSS", async ({ page, gotoSession }) => {
+test("unknown theme ids migrate to pawwork and clear cached css", async ({ page, gotoSession }) => {
await page.addInitScript(() => {
- localStorage.setItem("opencode-theme-id", "dracula")
- localStorage.setItem("opencode-theme-css-light", "--background-base:#fff;")
- localStorage.setItem("opencode-theme-css-dark", "--background-base:#000;")
+ localStorage.setItem("pawwork-theme-id", "dracula")
+ localStorage.setItem("pawwork-theme-css-light", "--background-base:#fff;")
+ localStorage.setItem("pawwork-theme-css-dark", "--background-base:#000;")
})
await gotoSession()
@@ -99,7 +99,7 @@ test("legacy theme ids migrate to pawwork and clear cached CSS", async ({ page,
await expect
.poll(async () => {
return await page.evaluate(() => {
- return localStorage.getItem("opencode-theme-id")
+ return localStorage.getItem("pawwork-theme-id")
})
})
.toBe("pawwork")
@@ -107,7 +107,7 @@ test("legacy theme ids migrate to pawwork and clear cached CSS", async ({ page,
await expect
.poll(async () => {
return await page.evaluate(() => {
- return localStorage.getItem("opencode-theme-css-light")
+ return localStorage.getItem("pawwork-theme-css-light")
})
})
.toBeNull()
@@ -115,7 +115,7 @@ test("legacy theme ids migrate to pawwork and clear cached CSS", async ({ page,
await expect
.poll(async () => {
return await page.evaluate(() => {
- return localStorage.getItem("opencode-theme-css-dark")
+ return localStorage.getItem("pawwork-theme-css-dark")
})
})
.toBeNull()
@@ -316,8 +316,8 @@ test("clearing the UI font field restores the default placeholder and stack", as
test("code font and UI font rehydrate after reload", async ({ page, gotoSession }) => {
await page.addInitScript(() => {
if (sessionStorage.getItem("settings-rehydrate-init")) return
- localStorage.setItem("opencode-theme-id", "pawwork")
- localStorage.setItem("opencode-color-scheme", "light")
+ localStorage.setItem("pawwork-theme-id", "pawwork")
+ localStorage.setItem("pawwork-color-scheme", "light")
sessionStorage.setItem("settings-rehydrate-init", "1")
})
diff --git a/packages/app/e2e/sidebar/sidebar-session-search.spec.ts b/packages/app/e2e/sidebar/sidebar-session-search.spec.ts
index 15093d379..2159541eb 100644
--- a/packages/app/e2e/sidebar/sidebar-session-search.spec.ts
+++ b/packages/app/e2e/sidebar/sidebar-session-search.spec.ts
@@ -11,7 +11,7 @@ test("command palette prioritizes pinned and recent PawWork sessions", async ({
await page.addInitScript((sessionID) => {
localStorage.setItem(
- "opencode.global.dat:layout.page",
+ "pawwork.global.dat:layout.page",
JSON.stringify({
pawworkPinnedSessions: [sessionID],
pawworkSortMode: "time",
@@ -23,7 +23,7 @@ test("command palette prioritizes pinned and recent PawWork sessions", async ({
await expect
.poll(() =>
page.evaluate(() => {
- const raw = localStorage.getItem("opencode.global.dat:layout.page")
+ const raw = localStorage.getItem("pawwork.global.dat:layout.page")
const next = raw ? (JSON.parse(raw) as { pawworkPinnedSessions?: string[] }).pawworkPinnedSessions : []
return next ?? []
}),
diff --git a/packages/app/e2e/sidebar/sidebar.spec.ts b/packages/app/e2e/sidebar/sidebar.spec.ts
index 548328f35..07de5f1e8 100644
--- a/packages/app/e2e/sidebar/sidebar.spec.ts
+++ b/packages/app/e2e/sidebar/sidebar.spec.ts
@@ -33,7 +33,7 @@ test("sidebar collapsed state persists across navigation and reload", async ({ p
await expect(button).toHaveAttribute("aria-expanded", "false")
const opened = await page.evaluate(
- () => JSON.parse(localStorage.getItem("opencode.global.dat:layout") ?? "{}").sidebar?.opened,
+ () => JSON.parse(localStorage.getItem("pawwork.global.dat:layout") ?? "{}").sidebar?.opened,
)
await expect(opened).toBe(false)
})
diff --git a/packages/app/e2e/utils.ts b/packages/app/e2e/utils.ts
index 17a878566..0737c9f85 100644
--- a/packages/app/e2e/utils.ts
+++ b/packages/app/e2e/utils.ts
@@ -59,5 +59,5 @@ export function sessionPath(directory: string, sessionID?: string) {
export function workspacePersistKey(directory: string, key: string) {
const head = (directory.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-")
const sum = checksum(directory) ?? "0"
- return `opencode.workspace.${head}.${sum}.dat:workspace:${key}`
+ return `pawwork.workspace.${head}.${sum}.dat:workspace:${key}`
}
diff --git a/packages/app/public/oc-theme-preload.js b/packages/app/public/oc-theme-preload.js
index 621d0fa3f..8e350c210 100644
--- a/packages/app/public/oc-theme-preload.js
+++ b/packages/app/public/oc-theme-preload.js
@@ -1,12 +1,12 @@
// Phase 1 ships only the `pawwork` theme, which is locked to light mode.
// When a dark-capable theme is bundled, this file must gain a theme-aware
-// guard so `opencode-color-scheme` is forced to "light" ONLY for light-only
+// guard so `pawwork-color-scheme` is forced to "light" ONLY for light-only
// themes; tracked in issue #23.
;(function () {
- var key = "opencode-theme-id"
- var schemeKey = "opencode-color-scheme"
- var cssLightKey = "opencode-theme-css-light"
- var cssDarkKey = "opencode-theme-css-dark"
+ var key = "pawwork-theme-id"
+ var schemeKey = "pawwork-color-scheme"
+ var cssLightKey = "pawwork-theme-css-light"
+ var cssDarkKey = "pawwork-theme-css-dark"
try {
var storedTheme = localStorage.getItem(key)
diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx
index 27539bab6..377cfec9b 100644
--- a/packages/app/src/app.tsx
+++ b/packages/app/src/app.tsx
@@ -135,6 +135,12 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
{
void window.api?.setTitlebar?.({ mode })
}}
diff --git a/packages/app/src/context/language.tsx b/packages/app/src/context/language.tsx
index d7799f9ac..211fca603 100644
--- a/packages/app/src/context/language.tsx
+++ b/packages/app/src/context/language.tsx
@@ -82,7 +82,7 @@ export function normalizeLocale(value: unknown): Locale {
function readStoredLocale() {
if (typeof localStorage !== "object") return
try {
- const raw = localStorage.getItem("opencode.global.dat:language")
+ const raw = localStorage.getItem("pawwork.global.dat:language")
if (!raw) return
const next = JSON.parse(raw) as { locale?: string }
if (typeof next?.locale !== "string") return
diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx
index 59c35dda9..565fe2a48 100644
--- a/packages/app/src/context/terminal.tsx
+++ b/packages/app/src/context/terminal.tsx
@@ -513,7 +513,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
legacy: getLegacyTerminalStorageKeys(dir, params.id),
at: new Date().toISOString(),
})
- const store = platform.storage?.("opencode.global.dat")
+ const store = platform.storage?.("pawwork.global.dat")
if (!store) return
void Promise.resolve(store.setItem("debug:terminal-invalid-all", payload)).catch(() => undefined)
})
diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx
index 6ec7784f9..a4c72a487 100644
--- a/packages/app/src/entry.tsx
+++ b/packages/app/src/entry.tsx
@@ -9,7 +9,7 @@ import { handleNotificationClick } from "@/utils/notification-click"
import pkg from "../package.json"
import { ServerConnection } from "./context/server"
-const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
+const DEFAULT_SERVER_URL_KEY = "pawwork.settings.dat:defaultServerUrl"
const getLocale = () => {
if (typeof navigator !== "object") return "en" as const
diff --git a/packages/app/src/hooks/use-providers.test.ts b/packages/app/src/hooks/use-providers.test.ts
new file mode 100644
index 000000000..9a537253e
--- /dev/null
+++ b/packages/app/src/hooks/use-providers.test.ts
@@ -0,0 +1,12 @@
+import { expect, mock, test } from "bun:test"
+
+mock.module("@solidjs/router", () => ({
+ useParams: () => ({}),
+}))
+
+const { popularProviders } = await import("./use-providers")
+
+test("popular providers keep OpenCode Zen and OpenCode Go visible", () => {
+ expect(popularProviders).toContain("opencode")
+ expect(popularProviders).toContain("opencode-go")
+})
diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx
index 7d1cd0ebe..7f0618abf 100644
--- a/packages/app/src/pages/session/terminal-panel.tsx
+++ b/packages/app/src/pages/session/terminal-panel.tsx
@@ -141,7 +141,7 @@ export function TerminalPanel(props: { embedded?: boolean }) {
active: terminal.active(),
at: new Date().toISOString(),
})
- const store = platform.storage?.("opencode.global.dat")
+ const store = platform.storage?.("pawwork.global.dat")
if (store) {
void Promise.resolve(store.setItem("debug:terminal-panel-invalid-all", payload)).catch(() => undefined)
}
diff --git a/packages/app/src/theme-preload.test.ts b/packages/app/src/theme-preload.test.ts
index 35f6ca859..21ecc5d03 100644
--- a/packages/app/src/theme-preload.test.ts
+++ b/packages/app/src/theme-preload.test.ts
@@ -26,32 +26,32 @@ describe("theme preload", () => {
})
test("locks PawWork to light and rewrites stored color scheme", () => {
- localStorage.setItem("opencode-theme-id", "pawwork")
- localStorage.setItem("opencode-color-scheme", "dark")
+ localStorage.setItem("pawwork-theme-id", "pawwork")
+ localStorage.setItem("pawwork-color-scheme", "dark")
run()
expect(document.documentElement.dataset.theme).toBe("pawwork")
expect(document.documentElement.dataset.colorScheme).toBe("light")
- expect(localStorage.getItem("opencode-theme-id")).toBe("pawwork")
- expect(localStorage.getItem("opencode-color-scheme")).toBe("light")
+ expect(localStorage.getItem("pawwork-theme-id")).toBe("pawwork")
+ expect(localStorage.getItem("pawwork-color-scheme")).toBe("light")
})
for (const legacy of ["oc-1", "oc-2", "dracula", "nightowl", "amoled"]) {
test(`migrates legacy "${legacy}" theme to pawwork and clears cached css`, () => {
- localStorage.setItem("opencode-theme-id", legacy)
- localStorage.setItem("opencode-color-scheme", "dark")
- localStorage.setItem("opencode-theme-css-light", "--background-base:#ffffff;")
- localStorage.setItem("opencode-theme-css-dark", "--background-base:#000000;")
+ localStorage.setItem("pawwork-theme-id", legacy)
+ localStorage.setItem("pawwork-color-scheme", "dark")
+ localStorage.setItem("pawwork-theme-css-light", "--background-base:#ffffff;")
+ localStorage.setItem("pawwork-theme-css-dark", "--background-base:#000000;")
run()
expect(document.documentElement.dataset.theme).toBe("pawwork")
expect(document.documentElement.dataset.colorScheme).toBe("light")
- expect(localStorage.getItem("opencode-theme-id")).toBe("pawwork")
- expect(localStorage.getItem("opencode-color-scheme")).toBe("light")
- expect(localStorage.getItem("opencode-theme-css-light")).toBeNull()
- expect(localStorage.getItem("opencode-theme-css-dark")).toBeNull()
+ expect(localStorage.getItem("pawwork-theme-id")).toBe("pawwork")
+ expect(localStorage.getItem("pawwork-color-scheme")).toBe("light")
+ expect(localStorage.getItem("pawwork-theme-css-light")).toBeNull()
+ expect(localStorage.getItem("pawwork-theme-css-dark")).toBeNull()
})
}
})
diff --git a/packages/app/src/utils/persist.test.ts b/packages/app/src/utils/persist.test.ts
index 673acd224..bf3bf4922 100644
--- a/packages/app/src/utils/persist.test.ts
+++ b/packages/app/src/utils/persist.test.ts
@@ -4,11 +4,17 @@ type PersistTestingType = typeof import("./persist").PersistTesting
class MemoryStorage implements Storage {
private values = new Map()
+ private failingSets = new Map()
readonly events: string[] = []
readonly calls = { get: 0, set: 0, remove: 0 }
clear() {
this.values.clear()
+ this.failingSets.clear()
+ }
+
+ failSet(key: string, times: number) {
+ this.failingSets.set(key, times)
}
get length() {
@@ -22,22 +28,27 @@ class MemoryStorage implements Storage {
getItem(key: string) {
this.calls.get += 1
this.events.push(`get:${key}`)
- if (key.startsWith("opencode.throw")) throw new Error("storage get failed")
+ if (key.startsWith("pawwork.throw")) throw new Error("storage get failed")
return this.values.get(key) ?? null
}
setItem(key: string, value: string) {
this.calls.set += 1
this.events.push(`set:${key}`)
- if (key.startsWith("opencode.quota")) throw new DOMException("quota", "QuotaExceededError")
- if (key.startsWith("opencode.throw")) throw new Error("storage set failed")
+ const remaining = this.failingSets.get(key) ?? 0
+ if (remaining > 0) {
+ this.failingSets.set(key, remaining - 1)
+ throw new DOMException("quota", "QuotaExceededError")
+ }
+ if (key.startsWith("pawwork.quota")) throw new DOMException("quota", "QuotaExceededError")
+ if (key.startsWith("pawwork.throw")) throw new Error("storage set failed")
this.values.set(key, value)
}
removeItem(key: string) {
this.calls.remove += 1
this.events.push(`remove:${key}`)
- if (key.startsWith("opencode.throw")) throw new Error("storage remove failed")
+ if (key.startsWith("pawwork.throw")) throw new Error("storage remove failed")
this.values.delete(key)
}
}
@@ -69,15 +80,15 @@ beforeEach(() => {
describe("persist localStorage resilience", () => {
test("does not cache values as persisted when quota write and eviction fail", () => {
- const storageApi = persistTesting.localStorageWithPrefix("opencode.quota.scope")
+ const storageApi = persistTesting.localStorageWithPrefix("pawwork.quota.scope")
storageApi.setItem("value", '{"value":1}')
- expect(storage.getItem("opencode.quota.scope:value")).toBeNull()
+ expect(storage.getItem("pawwork.quota.scope:value")).toBeNull()
expect(storageApi.getItem("value")).toBeNull()
})
test("disables only the failing scope when storage throws", () => {
- const bad = persistTesting.localStorageWithPrefix("opencode.throw.scope")
+ const bad = persistTesting.localStorageWithPrefix("pawwork.throw.scope")
bad.setItem("value", '{"value":1}')
const before = storage.calls.set
@@ -85,13 +96,13 @@ describe("persist localStorage resilience", () => {
expect(storage.calls.set).toBe(before)
expect(bad.getItem("value")).toBeNull()
- const healthy = persistTesting.localStorageWithPrefix("opencode.safe.scope")
+ const healthy = persistTesting.localStorageWithPrefix("pawwork.safe.scope")
healthy.setItem("value", '{"value":3}')
- expect(storage.getItem("opencode.safe.scope:value")).toBe('{"value":3}')
+ expect(storage.getItem("pawwork.safe.scope:value")).toBe('{"value":3}')
})
test("failing fallback scope does not poison direct storage scope", () => {
- const broken = persistTesting.localStorageWithPrefix("opencode.throw.scope2")
+ const broken = persistTesting.localStorageWithPrefix("pawwork.throw.scope2")
broken.setItem("value", '{"value":1}')
const direct = persistTesting.localStorageDirect()
@@ -100,6 +111,21 @@ describe("persist localStorage resilience", () => {
expect(storage.getItem("direct-value")).toBe('{"value":5}')
})
+ test("quota eviction can remove legacy OpenCode local entries", () => {
+ storage.setItem("opencode.workspace.old.dat:value", "old workspace")
+ storage.setItem("opencode.global.dat:value", "old global")
+ storage.setItem("opencode.settings.dat:value", "old settings")
+ storage.failSet("pawwork.workspace.new.dat:value", 4)
+
+ const storageApi = persistTesting.localStorageWithPrefix("pawwork.workspace.new.dat")
+ storageApi.setItem("value", '{"value":1}')
+
+ expect(storage.getItem("opencode.workspace.old.dat:value")).toBeNull()
+ expect(storage.getItem("opencode.global.dat:value")).toBeNull()
+ expect(storage.getItem("opencode.settings.dat:value")).toBeNull()
+ expect(storage.getItem("pawwork.workspace.new.dat:value")).toBe('{"value":1}')
+ })
+
test("normalizer rejects malformed JSON payloads", () => {
const result = persistTesting.normalize({ value: "ok" }, '{"value":"\\x"}')
expect(result).toBeUndefined()
@@ -108,7 +134,7 @@ describe("persist localStorage resilience", () => {
test("workspace storage sanitizes Windows filename characters", () => {
const result = persistTesting.workspaceStorage("C:\\Users\\foo")
- expect(result).toStartWith("opencode.workspace.")
+ expect(result).toStartWith("pawwork.workspace.")
expect(result.endsWith(".dat")).toBeTrue()
expect(/[:\\/]/.test(result)).toBeFalse()
})
diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts
index 0c050d5db..0565f752f 100644
--- a/packages/app/src/utils/persist.ts
+++ b/packages/app/src/utils/persist.ts
@@ -20,8 +20,11 @@ type PersistTarget = {
}
const LEGACY_STORAGE = "default.dat"
-const GLOBAL_STORAGE = "opencode.global.dat"
-const LOCAL_PREFIX = "opencode."
+const GLOBAL_STORAGE = "pawwork.global.dat"
+const LOCAL_PREFIX = "pawwork."
+const LEGACY_LOCAL_PREFIX = "opencode."
+// The legacy prefix intentionally covers old workspace, global, and settings keys.
+const EVICT_PREFIXES = [LOCAL_PREFIX, LEGACY_LOCAL_PREFIX]
const fallback = new Map()
const CACHE_MAX_ENTRIES = 500
@@ -111,7 +114,7 @@ function evict(storage: Storage, keep: string, value: string) {
for (const index of indexes) {
const name = storage.key(index)
if (!name) continue
- if (!name.startsWith(LOCAL_PREFIX)) continue
+ if (!EVICT_PREFIXES.some((prefix) => name.startsWith(prefix))) continue
if (name === keep) continue
const stored = storage.getItem(name)
items.push({ key: name, size: stored?.length ?? 0 })
@@ -211,7 +214,7 @@ function normalize(defaults: unknown, raw: string, migrate?: (value: unknown) =>
function workspaceStorage(dir: string) {
const head = (dir.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-")
const sum = checksum(dir) ?? "0"
- return `opencode.workspace.${head}.${sum}.dat`
+ return `pawwork.workspace.${head}.${sum}.dat`
}
function localStorageWithPrefix(prefix: string): SyncStorage {
diff --git a/packages/desktop-electron/scripts/ci-smoke.test.ts b/packages/desktop-electron/scripts/ci-smoke.test.ts
index f9d2ce30d..ad8031f35 100644
--- a/packages/desktop-electron/scripts/ci-smoke.test.ts
+++ b/packages/desktop-electron/scripts/ci-smoke.test.ts
@@ -18,6 +18,9 @@ describe("ci smoke helpers", () => {
expect(env.PAWWORK_CI_SMOKE_HOME).toBe("/tmp/pawwork-ci-smoke")
expect(env.HOME).toBe("/tmp/pawwork-ci-smoke")
expect(env.XDG_DATA_HOME).toBe("/tmp/pawwork-ci-smoke")
+ expect(env.XDG_CACHE_HOME).toBe("/tmp/pawwork-ci-smoke")
+ expect(env.XDG_CONFIG_HOME).toBe("/tmp/pawwork-ci-smoke")
+ expect(env.XDG_STATE_HOME).toBe("/tmp/pawwork-ci-smoke")
expect(env.CI).toBe("true")
})
diff --git a/packages/desktop-electron/scripts/ci-smoke.ts b/packages/desktop-electron/scripts/ci-smoke.ts
index d0d75aa81..20728bc03 100644
--- a/packages/desktop-electron/scripts/ci-smoke.ts
+++ b/packages/desktop-electron/scripts/ci-smoke.ts
@@ -23,6 +23,9 @@ export function buildSmokeEnv(homeDir: string) {
PAWWORK_CI_SMOKE: "true",
PAWWORK_CI_SMOKE_HOME: homeDir,
XDG_DATA_HOME: homeDir,
+ XDG_CACHE_HOME: homeDir,
+ XDG_CONFIG_HOME: homeDir,
+ XDG_STATE_HOME: homeDir,
OPENCODE_CHANNEL: "dev",
}
}
diff --git a/packages/desktop-electron/src/main/constants.ts b/packages/desktop-electron/src/main/constants.ts
index 1e21661c1..811ce23cd 100644
--- a/packages/desktop-electron/src/main/constants.ts
+++ b/packages/desktop-electron/src/main/constants.ts
@@ -1,10 +1,11 @@
import { app } from "electron"
+import { PAWWORK_RUNTIME } from "./runtime-namespace"
type Channel = "dev" | "beta" | "prod"
const raw = import.meta.env.OPENCODE_CHANNEL
export const CHANNEL: Channel = raw === "dev" || raw === "beta" || raw === "prod" ? raw : "dev"
-export const SETTINGS_STORE = "opencode.settings"
+export const SETTINGS_STORE = PAWWORK_RUNTIME.settingsStore
export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
export const WSL_ENABLED_KEY = "wslEnabled"
export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev"
diff --git a/packages/desktop-electron/src/main/index-sidecar-source.test.ts b/packages/desktop-electron/src/main/index-sidecar-source.test.ts
new file mode 100644
index 000000000..72e37dc69
--- /dev/null
+++ b/packages/desktop-electron/src/main/index-sidecar-source.test.ts
@@ -0,0 +1,12 @@
+import { describe, expect, test } from "bun:test"
+
+describe("desktop sidecar source guard", () => {
+ test("publishes PawWork credentials and does not block on stale migration probes", async () => {
+ const source = await Bun.file(new URL("./index.ts", import.meta.url)).text()
+
+ expect(source).toContain("username: PAWWORK_RUNTIME.serverUsername")
+ expect(source).toContain("const needsMigration = false")
+ expect(source).not.toContain("sqliteFileExists")
+ expect(source).not.toContain('username: "opencode"')
+ })
+})
diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts
index 189ed8700..342c7a89e 100644
--- a/packages/desktop-electron/src/main/index.ts
+++ b/packages/desktop-electron/src/main/index.ts
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto"
import { EventEmitter } from "node:events"
-import { existsSync, mkdirSync, writeFileSync } from "node:fs"
+import { mkdirSync, writeFileSync } from "node:fs"
import { createServer } from "node:net"
import { homedir } from "node:os"
import { dirname, join } from "node:path"
@@ -50,6 +50,7 @@ import { initLogging } from "./logging"
import { parseMarkdown } from "./markdown"
import { createMenu } from "./menu"
import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server"
+import { PAWWORK_RUNTIME } from "./runtime-namespace"
import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows"
import {
registerWindowLifecycle,
@@ -199,10 +200,10 @@ function setInitStep(step: InitStep) {
}
async function initialize() {
- // CI smoke only verifies that the desktop shell boots and the embedded
- // sidecar is healthy; sqlite migration coverage stays outside this gate.
- const needsMigration = !CI_SMOKE_ENABLED && !sqliteFileExists()
- const sqliteDone = needsMigration ? defer() : undefined
+ // The embedded server owns DB initialization. The desktop shell must not
+ // block first launch on a migration progress event that the embedded runtime
+ // does not emit.
+ const needsMigration = false
let overlay: BrowserWindow | null = null
const port = await getSidecarPort()
@@ -215,7 +216,7 @@ async function initialize() {
server = listener
serverReady.resolve({
url,
- username: "opencode",
+ username: PAWWORK_RUNTIME.serverUsername,
password,
})
@@ -226,13 +227,8 @@ async function initialize() {
setInitStep({ phase: "sqlite_waiting" })
if (overlay) sendSqliteMigrationProgress(overlay, progress)
if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress)
- if (progress.type === "Done") sqliteDone?.resolve()
})
- if (needsMigration) {
- await sqliteDone?.promise
- }
-
await Promise.race([
health.wait,
delay(30_000).then(() => {
@@ -377,12 +373,6 @@ async function getSidecarPort() {
})
}
-function sqliteFileExists() {
- const xdg = process.env.XDG_DATA_HOME
- const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".local", "share")
- return existsSync(join(base, "opencode", "opencode.db"))
-}
-
function setupAutoUpdater() {
if (!UPDATER_ENABLED) return
autoUpdater.logger = logger
diff --git a/packages/desktop-electron/src/main/runtime-namespace.ts b/packages/desktop-electron/src/main/runtime-namespace.ts
new file mode 100644
index 000000000..a90830d8c
--- /dev/null
+++ b/packages/desktop-electron/src/main/runtime-namespace.ts
@@ -0,0 +1,21 @@
+import { join } from "node:path"
+
+export const PAWWORK_RUNTIME = {
+ client: "desktop",
+ serverUsername: "PawWork",
+ settingsStore: "pawwork.settings",
+ databaseName: "pawwork.db",
+} as const
+
+export function runtimeRoots(userData: string) {
+ return {
+ data: join(userData, "data"),
+ cache: join(userData, "cache"),
+ config: join(userData, "config"),
+ state: join(userData, "state"),
+ }
+}
+
+export function databasePathForUserData(userData: string) {
+ return join(runtimeRoots(userData).data, "pawwork", PAWWORK_RUNTIME.databaseName)
+}
diff --git a/packages/desktop-electron/src/main/server.test.ts b/packages/desktop-electron/src/main/server.test.ts
new file mode 100644
index 000000000..a5f4514e6
--- /dev/null
+++ b/packages/desktop-electron/src/main/server.test.ts
@@ -0,0 +1,122 @@
+import { afterEach, describe, expect, mock, test } from "bun:test"
+import path from "node:path"
+
+const userData = "/tmp/pawwork-user-data"
+
+mock.module("electron", () => ({
+ app: {
+ getPath: (name: string) => (name === "userData" ? userData : `/tmp/${name}`),
+ isPackaged: false,
+ },
+}))
+
+mock.module("./store", () => ({
+ store: {
+ get: () => null,
+ set: () => undefined,
+ delete: () => undefined,
+ },
+}))
+
+mock.module("./shell-env", () => ({
+ getUserShell: () => null,
+ loadShellEnv: () => ({}),
+}))
+
+const originalEnv = { ...process.env }
+
+afterEach(() => {
+ for (const key of Object.keys(process.env)) {
+ if (!(key in originalEnv)) delete process.env[key]
+ }
+ Object.assign(process.env, originalEnv)
+})
+
+describe("desktop server runtime namespace", () => {
+ test("prepares PawWork-owned server environment before embedded server import", async () => {
+ const { buildServerEnvForTest } = await import("./server")
+
+ const env = buildServerEnvForTest("secret")
+
+ expect(env.OPENCODE_CLIENT).toBe("desktop")
+ expect(env.OPENCODE_SERVER_USERNAME).toBe("PawWork")
+ expect(env.OPENCODE_SERVER_PASSWORD).toBe("secret")
+ expect(env.PAWWORK_RUNTIME_NAMESPACE).toBe("pawwork")
+ expect(env.XDG_DATA_HOME).toBe("/tmp/pawwork-user-data/data")
+ expect(env.XDG_CACHE_HOME).toBe("/tmp/pawwork-user-data/cache")
+ expect(env.XDG_CONFIG_HOME).toBe("/tmp/pawwork-user-data/config")
+ expect(env.XDG_STATE_HOME).toBe("/tmp/pawwork-user-data/state")
+ })
+
+ test("runtime roots keep Windows-shaped user data under PawWork", async () => {
+ const { databasePathForUserData, runtimeRoots } = await import("./runtime-namespace")
+ const root = "C:\\Users\\u\\AppData\\Roaming\\ai.pawwork.desktop.dev"
+ const roots = runtimeRoots(root)
+
+ expect(roots).toEqual({
+ data: path.join(root, "data"),
+ cache: path.join(root, "cache"),
+ config: path.join(root, "config"),
+ state: path.join(root, "state"),
+ })
+ expect(databasePathForUserData(root)).toBe(path.join(root, "data", "pawwork", "pawwork.db"))
+ })
+
+ test("health check uses PawWork Basic Auth username", async () => {
+ let authorization = ""
+ const previousFetch = globalThis.fetch
+ globalThis.fetch = (async (_url: RequestInfo | URL, init?: RequestInit) => {
+ authorization = new Headers(init?.headers).get("authorization") ?? ""
+ return new Response(null, { status: 200 })
+ }) as typeof fetch
+
+ try {
+ const { checkHealth } = await import("./server")
+ expect(await checkHealth("http://127.0.0.1:4096", "secret")).toBeTrue()
+ expect(Buffer.from(authorization.replace("Basic ", ""), "base64").toString("utf8")).toBe("PawWork:secret")
+ } finally {
+ globalThis.fetch = previousFetch
+ }
+ })
+
+ test("spawnLocalServer prepares env before importing the embedded server", async () => {
+ let captured: Record | undefined
+
+ mock.module("virtual:opencode-server", () => {
+ captured = {
+ XDG_DATA_HOME: process.env.XDG_DATA_HOME,
+ XDG_CACHE_HOME: process.env.XDG_CACHE_HOME,
+ XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
+ XDG_STATE_HOME: process.env.XDG_STATE_HOME,
+ PAWWORK_RUNTIME_NAMESPACE: process.env.PAWWORK_RUNTIME_NAMESPACE,
+ OPENCODE_CLIENT: process.env.OPENCODE_CLIENT,
+ OPENCODE_SERVER_USERNAME: process.env.OPENCODE_SERVER_USERNAME,
+ }
+ return {
+ Log: { init: async () => undefined },
+ Server: {
+ listen: async () => ({ stop: async () => undefined }),
+ },
+ }
+ })
+
+ const previousFetch = globalThis.fetch
+ globalThis.fetch = (async () => new Response(null, { status: 200 })) as typeof fetch
+
+ try {
+ const { spawnLocalServer } = await import("./server")
+ await spawnLocalServer("127.0.0.1", 4096, "secret")
+ expect(captured).toEqual({
+ XDG_DATA_HOME: "/tmp/pawwork-user-data/data",
+ XDG_CACHE_HOME: "/tmp/pawwork-user-data/cache",
+ XDG_CONFIG_HOME: "/tmp/pawwork-user-data/config",
+ XDG_STATE_HOME: "/tmp/pawwork-user-data/state",
+ PAWWORK_RUNTIME_NAMESPACE: "pawwork",
+ OPENCODE_CLIENT: "desktop",
+ OPENCODE_SERVER_USERNAME: "PawWork",
+ })
+ } finally {
+ globalThis.fetch = previousFetch
+ }
+ })
+})
diff --git a/packages/desktop-electron/src/main/server.ts b/packages/desktop-electron/src/main/server.ts
index eef3a1696..dfa94d77a 100644
--- a/packages/desktop-electron/src/main/server.ts
+++ b/packages/desktop-electron/src/main/server.ts
@@ -1,5 +1,6 @@
import { app } from "electron"
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
+import { PAWWORK_RUNTIME, runtimeRoots } from "./runtime-namespace"
import { getUserShell, loadShellEnv } from "./shell-env"
import { store } from "./store"
@@ -55,22 +56,33 @@ export async function spawnLocalServer(hostname: string, port: number, password:
return { listener, health: { wait } }
}
-function prepareServerEnv(password: string) {
+function buildServerEnv(password: string) {
const shell = process.platform === "win32" ? null : getUserShell()
const shellEnv = shell ? (loadShellEnv(shell) ?? {}) : {}
- const env = {
+ const roots = runtimeRoots(app.getPath("userData"))
+ return {
...process.env,
...shellEnv,
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
- OPENCODE_CLIENT: "desktop",
- OPENCODE_SERVER_USERNAME: "opencode",
+ OPENCODE_CLIENT: PAWWORK_RUNTIME.client,
+ OPENCODE_SERVER_USERNAME: PAWWORK_RUNTIME.serverUsername,
OPENCODE_SERVER_PASSWORD: password,
- XDG_STATE_HOME: app.getPath("userData"),
+ PAWWORK_RUNTIME_NAMESPACE: "pawwork",
+ XDG_DATA_HOME: roots.data,
+ XDG_CACHE_HOME: roots.cache,
+ XDG_CONFIG_HOME: roots.config,
+ XDG_STATE_HOME: roots.state,
}
- Object.assign(process.env, env)
}
+function prepareServerEnv(password: string) {
+ // Mutates the current process because the embedded server is imported in-process and reads env at module load.
+ Object.assign(process.env, buildServerEnv(password))
+}
+
+export const buildServerEnvForTest = buildServerEnv
+
export async function checkHealth(url: string, password?: string | null): Promise {
let healthUrl: URL
try {
@@ -81,7 +93,7 @@ export async function checkHealth(url: string, password?: string | null): Promis
const headers = new Headers()
if (password) {
- const auth = Buffer.from(`opencode:${password}`).toString("base64")
+ const auth = Buffer.from(`${PAWWORK_RUNTIME.serverUsername}:${password}`).toString("base64")
headers.set("authorization", `Basic ${auth}`)
}
diff --git a/packages/desktop-electron/src/main/store.ts b/packages/desktop-electron/src/main/store.ts
index cf2d25b11..d6f6c00b0 100644
--- a/packages/desktop-electron/src/main/store.ts
+++ b/packages/desktop-electron/src/main/store.ts
@@ -4,7 +4,7 @@ import { SETTINGS_STORE } from "./constants"
const cache = new Map()
-export function getStore(name = SETTINGS_STORE) {
+export function getStore(name: string = SETTINGS_STORE) {
const cached = cache.get(name)
if (cached) return cached
const next = new Store({ name, fileExtension: "" })
diff --git a/packages/desktop-electron/src/renderer/i18n/index.ts b/packages/desktop-electron/src/renderer/i18n/index.ts
index 1b71531a1..b9b5a2d1f 100644
--- a/packages/desktop-electron/src/renderer/i18n/index.ts
+++ b/packages/desktop-electron/src/renderer/i18n/index.ts
@@ -86,7 +86,7 @@ export function initI18n(): Promise {
if (cached) return cached
const promise = (async () => {
- const raw = await window.api.storeGet("opencode.global.dat", "language").catch(() => null)
+ const raw = await window.api.storeGet("pawwork.global.dat", "language").catch(() => null)
const value = parseStored(raw)
const next = pickLocale(value) ?? state.locale
diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx
index 246b40cd7..4ae3b5755 100644
--- a/packages/desktop-electron/src/renderer/index.tsx
+++ b/packages/desktop-electron/src/renderer/index.tsx
@@ -296,7 +296,7 @@ listenForDeepLinks()
render(() => {
const platform = createPlatform()
const loadLocale = async () => {
- const current = await platform.storage?.("opencode.global.dat").getItem("language")
+ const current = await platform.storage?.("pawwork.global.dat").getItem("language")
const legacy = current ? undefined : await platform.storage?.().getItem("language.v1")
const raw = current ?? legacy
if (!raw) return
diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts
index 9c2f3a583..f9a2b2712 100644
--- a/packages/opencode/src/agent/agent.ts
+++ b/packages/opencode/src/agent/agent.ts
@@ -22,6 +22,7 @@ import { Skill } from "../skill"
import { Effect, Context, Layer } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
+import { Runtime } from "@opencode-ai/shared/runtime"
export namespace Agent {
export const Info = z
@@ -114,6 +115,7 @@ export namespace Agent {
})
const user = Permission.fromConfig(cfg.permission ?? {})
+ const projectPlansDir = Runtime.isPawWork() ? ".pawwork" : ".opencode"
const agents: Record = {
build: {
@@ -145,7 +147,7 @@ export namespace Agent {
},
edit: {
"*": "deny",
- [path.join(".opencode", "plans", "*.md")]: "allow",
+ [path.join(projectPlansDir, "plans", "*.md")]: "allow",
[path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]:
"allow",
},
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 1eb749455..bf2b41d88 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -46,12 +46,39 @@ import { Npm } from "@/npm"
import { Filesystem } from "@/util/filesystem"
import { Flock } from "@/util/flock"
import { Installation } from "@/installation"
+import { Runtime } from "@opencode-ai/shared/runtime"
const log = Log.create({ service: "config" })
-const PROJECT_CONFIG_NAMES = ["opencode", "pawwork"] as const
-const PROJECT_CONFIG_FILES = PROJECT_CONFIG_NAMES.flatMap((name) => [`${name}.json`, `${name}.jsonc`])
-const GLOBAL_CONFIG_LOAD_FILES = ["config.json", "opencode.json", "opencode.jsonc", "pawwork.json", "pawwork.jsonc"] as const
-const GLOBAL_CONFIG_UPDATE_FILES = ["pawwork.jsonc", "pawwork.json", "opencode.jsonc", "opencode.json", "config.json"] as const
+const OPENCODE_PROJECT_CONFIG_NAMES = ["config", "opencode"] as const
+const PAWWORK_PROJECT_CONFIG_NAMES = ["config", "opencode", "pawwork"] as const
+const OPENCODE_PROJECT_CONFIG_FILES = OPENCODE_PROJECT_CONFIG_NAMES.flatMap((name) => [`${name}.json`, `${name}.jsonc`])
+const PAWWORK_PROJECT_CONFIG_FILES = PAWWORK_PROJECT_CONFIG_NAMES.flatMap((name) => [`${name}.json`, `${name}.jsonc`])
+const PAWWORK_GLOBAL_CONFIG_FILES = ["pawwork.json", "pawwork.jsonc"] as const
+const OPENCODE_GLOBAL_CONFIG_FILES = OPENCODE_PROJECT_CONFIG_FILES
+
+function globalConfigFiles() {
+ return Runtime.isPawWork() ? PAWWORK_GLOBAL_CONFIG_FILES : OPENCODE_GLOBAL_CONFIG_FILES
+}
+
+function projectConfigNames() {
+ return Runtime.isPawWork() ? PAWWORK_PROJECT_CONFIG_NAMES : OPENCODE_PROJECT_CONFIG_NAMES
+}
+
+function projectConfigFilesForDirectory(dir: string) {
+ const base = path.basename(dir)
+ if (base === ".pawwork") return Runtime.isPawWork() ? PAWWORK_PROJECT_CONFIG_FILES : []
+ if (base === ".opencode" || dir === Flag.OPENCODE_CONFIG_DIR) {
+ return Runtime.isPawWork() ? PAWWORK_PROJECT_CONFIG_FILES : OPENCODE_PROJECT_CONFIG_FILES
+ }
+ return []
+}
+
+function shouldGenerateInDirectory(dir: string) {
+ const base = path.basename(dir)
+ if (Runtime.isPawWork() && base === ".opencode") return false
+ if (!Runtime.isPawWork() && base === ".pawwork") return false
+ return true
+}
type Package = {
dependencies?: Record
@@ -317,11 +344,25 @@ export interface Interface {
export class Service extends Context.Service()("@opencode/Config") {}
function globalConfigFile() {
- const candidates = GLOBAL_CONFIG_UPDATE_FILES.map((file) => path.join(Global.Path.config, file))
- for (const file of candidates) {
+ const candidates = globalConfigFiles().map((file) => path.join(Global.Path.config, file))
+ for (const file of [...candidates].reverse()) {
+ if (existsSync(file)) return file
+ }
+ return path.join(Global.Path.config, Runtime.isPawWork() ? "pawwork.json" : "opencode.json")
+}
+
+function projectConfigFile(dir: string) {
+ // OpenCode still writes existing legacy `config.*` files, but new project config uses `opencode.json`.
+ // PawWork writes only PawWork project filenames; shared project loading above preserves read compatibility.
+ const candidates = (
+ Runtime.isPawWork()
+ ? ["pawwork.json", "pawwork.jsonc"]
+ : ["config.json", "config.jsonc", "opencode.json", "opencode.jsonc"]
+ ).map((file) => path.join(dir, file))
+ for (const file of [...candidates].reverse()) {
if (existsSync(file)) return file
}
- return path.join(Global.Path.config, "pawwork.json")
+ return path.join(dir, Runtime.isPawWork() ? "pawwork.json" : "opencode.json")
}
function patchJsonc(input: string, patch: unknown, path: string[] = []): string {
@@ -408,7 +449,7 @@ const rawLayer = Layer.effect(
const loadGlobal = Effect.fnUntraced(function* () {
let result: Info = {}
- for (const file of GLOBAL_CONFIG_LOAD_FILES) {
+ for (const file of globalConfigFiles()) {
result = pipe(result, mergeDeep(yield* loadFile(path.join(Global.Path.config, file))))
}
@@ -421,7 +462,10 @@ const rawLayer = Layer.effect(
if (provider && model) result.model = `${provider}/${model}`
result["$schema"] = "https://opencode.ai/config.json"
result = mergeDeep(result, rest)
- await fsNode.writeFile(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2))
+ await fsNode.writeFile(
+ path.join(Global.Path.config, Runtime.isPawWork() ? "pawwork.json" : "opencode.json"),
+ JSON.stringify(result, null, 2),
+ )
await fsNode.unlink(legacy)
})
.catch(() => {}),
@@ -535,7 +579,9 @@ const rawLayer = Layer.effect(
}
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
- for (const file of yield* ConfigPaths.files(PROJECT_CONFIG_NAMES, ctx.directory, ctx.worktree).pipe(Effect.orDie)) {
+ for (const file of yield* ConfigPaths.files(projectConfigNames(), ctx.directory, ctx.worktree).pipe(
+ Effect.orDie,
+ )) {
yield* merge(file, yield* loadFile(file), "local")
}
}
@@ -546,15 +592,23 @@ const rawLayer = Layer.effect(
const directories = yield* ConfigPaths.directories(ctx.directory, ctx.worktree)
- if (Flag.OPENCODE_CONFIG_DIR) {
+ const pawworkConfigDir = Flag.PAWWORK_CONFIG_DIR
+ if (Runtime.isPawWork() && pawworkConfigDir) {
+ log.debug("loading config from PAWWORK_CONFIG_DIR", { path: pawworkConfigDir })
+ } else if (Flag.OPENCODE_CONFIG_DIR) {
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
}
const deps: Fiber.Fiber[] = []
for (const dir of directories) {
- if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
- for (const file of PROJECT_CONFIG_FILES) {
+ const configFiles =
+ Runtime.isPawWork() && pawworkConfigDir && dir === pawworkConfigDir
+ ? globalConfigFiles()
+ : projectConfigFilesForDirectory(dir)
+
+ if (configFiles.length > 0) {
+ for (const file of configFiles) {
const source = path.join(dir, file)
log.debug(`loading config from ${source}`)
yield* merge(source, yield* loadFile(source))
@@ -564,16 +618,18 @@ const rawLayer = Layer.effect(
}
}
- yield* ensureGitignore(dir).pipe(Effect.orDie)
+ if (shouldGenerateInDirectory(dir)) {
+ yield* ensureGitignore(dir).pipe(Effect.orDie)
- const dep = yield* Effect.promise(async () => {
- try {
- await installDependencies(dir)
- } catch (error) {
- log.warn("background dependency install failed", { dir, error: String(error) })
- }
- }).pipe(Effect.forkDetach)
- deps.push(dep)
+ const dep = yield* Effect.promise(async () => {
+ try {
+ await installDependencies(dir)
+ } catch (error) {
+ log.warn("background dependency install failed", { dir, error: String(error) })
+ }
+ }).pipe(Effect.forkDetach)
+ deps.push(dep)
+ }
result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => ConfigCommand.load(dir)))
result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.load(dir)))
@@ -639,7 +695,7 @@ const rawLayer = Layer.effect(
const managedDir = ConfigManaged.managedConfigDir()
if (existsSync(managedDir)) {
- for (const file of PROJECT_CONFIG_FILES) {
+ for (const file of Runtime.isPawWork() ? globalConfigFiles() : OPENCODE_PROJECT_CONFIG_FILES) {
const source = path.join(managedDir, file)
yield* merge(source, yield* loadFile(source), "global")
}
@@ -736,11 +792,19 @@ const rawLayer = Layer.effect(
const update = Effect.fn("Config.update")(function* (config: Info) {
const dir = yield* InstanceState.directory
- const file = path.join(dir, "config.json")
- const existing = yield* loadFile(file)
- yield* fs
- .writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2))
- .pipe(Effect.orDie)
+ const file = projectConfigFile(dir)
+ const input = writable(config)
+
+ if (!file.endsWith(".jsonc")) {
+ const existing = yield* loadFile(file)
+ yield* fs
+ .writeFileString(file, JSON.stringify(mergeDeep(writable(existing), input), null, 2))
+ .pipe(Effect.orDie)
+ } else {
+ const before = (yield* readConfigFile(file)) ?? "{}"
+ yield* fs.writeFileString(file, patchJsonc(before, input)).pipe(Effect.orDie)
+ }
+
yield* Effect.promise(() => Instance.dispose())
})
diff --git a/packages/opencode/src/config/managed.ts b/packages/opencode/src/config/managed.ts
index 0e378b45e..f074d975f 100644
--- a/packages/opencode/src/config/managed.ts
+++ b/packages/opencode/src/config/managed.ts
@@ -4,10 +4,13 @@ import { existsSync } from "fs"
import os from "os"
import path from "path"
import { Log, Process } from "../util"
+import { Runtime } from "@opencode-ai/shared/runtime"
const log = Log.create({ service: "config" })
-const MANAGED_PLIST_DOMAIN = "ai.opencode.managed"
+function managedPlistDomain() {
+ return Runtime.isPawWork() ? "ai.pawwork.managed" : "ai.opencode.managed"
+}
// Keys injected by macOS/MDM into the managed plist that are not OpenCode config
const PLIST_META = new Set([
@@ -20,13 +23,14 @@ const PLIST_META = new Set([
])
function systemManagedConfigDir(): string {
+ const app = Runtime.appName()
switch (process.platform) {
case "darwin":
- return "/Library/Application Support/opencode"
+ return `/Library/Application Support/${app}`
case "win32":
- return path.join(process.env.ProgramData || "C:\\ProgramData", "opencode")
+ return path.join(process.env.ProgramData || "C:\\ProgramData", app)
default:
- return "/etc/opencode"
+ return `/etc/${app}`
}
}
@@ -52,9 +56,10 @@ export async function readManagedPreferences() {
if (process.platform !== "darwin") return
const user = os.userInfo().username
+ const domain = managedPlistDomain()
const paths = [
- path.join("/Library/Managed Preferences", user, `${MANAGED_PLIST_DOMAIN}.plist`),
- path.join("/Library/Managed Preferences", `${MANAGED_PLIST_DOMAIN}.plist`),
+ path.join("/Library/Managed Preferences", user, `${domain}.plist`),
+ path.join("/Library/Managed Preferences", `${domain}.plist`),
]
for (const plist of paths) {
diff --git a/packages/opencode/src/config/paths.ts b/packages/opencode/src/config/paths.ts
index ae9f30e31..ec2cbb9a6 100644
--- a/packages/opencode/src/config/paths.ts
+++ b/packages/opencode/src/config/paths.ts
@@ -10,6 +10,7 @@ import { unique } from "remeda"
import { JsonError } from "./error"
import * as Effect from "effect/Effect"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
+import { Runtime } from "@opencode-ai/shared/runtime"
/** Find project config files while preserving root-to-leaf config precedence and per-directory alias order. */
export const files = Effect.fn("ConfigPaths.projectFiles")(function* (
@@ -30,6 +31,20 @@ export const files = Effect.fn("ConfigPaths.projectFiles")(function* (
/** Return every config directory that can contribute files, commands, agents, plugins, or config dependencies. */
export const directories = Effect.fn("ConfigPaths.directories")(function* (directory: string, worktree?: string) {
const afs = yield* AppFileSystem.Service
+ if (Runtime.isPawWork()) {
+ return unique([
+ Global.Path.config,
+ ...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
+ ? yield* afs.up({
+ targets: [".opencode", ".pawwork"],
+ start: directory,
+ stop: worktree,
+ })
+ : []),
+ ...(Flag.PAWWORK_CONFIG_DIR ? [Flag.PAWWORK_CONFIG_DIR] : []),
+ ])
+ }
+
return unique([
Global.Path.config,
...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts
index 01f25ed8d..4954bb982 100644
--- a/packages/opencode/src/flag/flag.ts
+++ b/packages/opencode/src/flag/flag.ts
@@ -20,6 +20,7 @@ export namespace Flag {
export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"]
export declare const OPENCODE_PURE: boolean
export declare const OPENCODE_CONFIG_DIR: string | undefined
+ export declare const PAWWORK_CONFIG_DIR: string | undefined
export declare const OPENCODE_PLUGIN_META_FILE: string | undefined
export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"]
export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
@@ -113,6 +114,17 @@ Object.defineProperty(Flag, "OPENCODE_CONFIG_DIR", {
configurable: false,
})
+// Dynamic getter for PAWWORK_CONFIG_DIR
+// This must be evaluated at access time, not module load time,
+// because tests and external tooling may set this env var at runtime
+Object.defineProperty(Flag, "PAWWORK_CONFIG_DIR", {
+ get() {
+ return process.env["PAWWORK_CONFIG_DIR"]
+ },
+ enumerable: true,
+ configurable: false,
+})
+
// Dynamic getter for OPENCODE_PURE
// This must be evaluated at access time, not module load time,
// because the CLI can set this flag at runtime
diff --git a/packages/opencode/src/global/index.ts b/packages/opencode/src/global/index.ts
index 869019e2c..e7db1952a 100644
--- a/packages/opencode/src/global/index.ts
+++ b/packages/opencode/src/global/index.ts
@@ -3,8 +3,9 @@ import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir"
import path from "path"
import os from "os"
import { Filesystem } from "../util/filesystem"
+import { Runtime } from "@opencode-ai/shared/runtime"
-const app = "opencode"
+const app = Runtime.appName()
const data = path.join(xdgData!, app)
const cache = path.join(xdgCache!, app)
diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts
index 8f110c6fb..930bda43f 100644
--- a/packages/opencode/src/index.ts
+++ b/packages/opencode/src/index.ts
@@ -28,7 +28,6 @@ import { PrCommand } from "./cli/cmd/pr"
import { SessionCommand } from "./cli/cmd/session"
import { DbCommand } from "./cli/cmd/db"
import path from "path"
-import { Global } from "./global"
import { JsonMigration } from "./storage/json-migration"
import { Database } from "./storage/db"
import { errorMessage } from "./util/error"
@@ -107,7 +106,7 @@ const cli = yargs(args)
args: process.argv.slice(2),
})
- const marker = path.join(Global.Path.data, "opencode.db")
+ const marker = Database.getChannelPath()
if (!(await Filesystem.exists(marker))) {
const tty = process.stderr.isTTY
process.stderr.write("Performing one time database migration, may take a few minutes..." + EOL)
diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts
index 05a7fa7db..ea7b2c056 100644
--- a/packages/opencode/src/session/instruction.ts
+++ b/packages/opencode/src/session/instruction.ts
@@ -10,6 +10,7 @@ import { withTransientReadRetry } from "@/util/effect-http-client"
import { Global } from "../global"
import { Instance } from "../project/instance"
import { Log } from "../util/log"
+import { Runtime } from "@opencode-ai/shared/runtime"
import type { MessageV2 } from "./message-v2"
import type { MessageID } from "./schema"
@@ -21,10 +22,15 @@ const FILES = [
"CONTEXT.md", // deprecated
]
-function globalFiles() {
+function configDir() {
+ return Runtime.isPawWork() ? Flag.PAWWORK_CONFIG_DIR : Flag.OPENCODE_CONFIG_DIR
+}
+
+function globalInstructionFiles() {
const files = []
- if (Flag.OPENCODE_CONFIG_DIR) {
- files.push(path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.md"))
+ const dir = configDir()
+ if (dir) {
+ files.push(path.join(dir, "AGENTS.md"))
}
files.push(path.join(Global.Path.config, "AGENTS.md"))
if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT) {
@@ -87,14 +93,16 @@ export const layer: Layer.Layer Effect.succeed([] as string[])))
}
- if (!Flag.OPENCODE_CONFIG_DIR) {
+ const dir = configDir()
+ if (!dir) {
+ const env = Runtime.isPawWork() ? "PAWWORK_CONFIG_DIR" : "OPENCODE_CONFIG_DIR"
log.warn(
- `Skipping relative instruction "${instruction}" - no OPENCODE_CONFIG_DIR set while project config is disabled`,
+ `Skipping relative instruction "${instruction}" - no ${env} set while project config is disabled`,
)
return []
}
return yield* fs
- .globUp(instruction, Flag.OPENCODE_CONFIG_DIR, Flag.OPENCODE_CONFIG_DIR)
+ .globUp(instruction, dir, dir)
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
})
@@ -132,7 +140,7 @@ export const layer: Layer.Layer {
diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts
index 5d9a2c7f4..d39147b27 100644
--- a/packages/opencode/test/agent/agent.test.ts
+++ b/packages/opencode/test/agent/agent.test.ts
@@ -63,6 +63,27 @@ test("plan agent denies edits except .opencode/plans/*", async () => {
})
})
+test("plan agent allows .pawwork plans in PawWork runtime mode", async () => {
+ await using tmp = await tmpdir()
+ const previous = process.env.PAWWORK_RUNTIME_NAMESPACE
+ process.env.PAWWORK_RUNTIME_NAMESPACE = "pawwork"
+
+ try {
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const plan = await Agent.get("plan")
+ expect(plan).toBeDefined()
+ expect(Permission.evaluate("edit", ".pawwork/plans/foo.md", plan!.permission).action).toBe("allow")
+ expect(Permission.evaluate("edit", ".opencode/plans/foo.md", plan!.permission).action).toBe("deny")
+ },
+ })
+ } finally {
+ if (previous === undefined) delete process.env.PAWWORK_RUNTIME_NAMESPACE
+ else process.env.PAWWORK_RUNTIME_NAMESPACE = previous
+ }
+})
+
test("explore agent denies edit and write", async () => {
await using tmp = await tmpdir()
await Instance.provide({
diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts
index 3604d9407..361f7b27c 100644
--- a/packages/opencode/test/config/config.test.ts
+++ b/packages/opencode/test/config/config.test.ts
@@ -60,6 +60,19 @@ const listDirs = () =>
const ready = () =>
Effect.runPromise(Config.Service.use((svc) => svc.waitForDependencies()).pipe(Effect.scoped, Effect.provide(layer)))
+async function withPawWorkRuntime(fn: () => Promise) {
+ const previous = process.env.PAWWORK_RUNTIME_NAMESPACE
+ process.env.PAWWORK_RUNTIME_NAMESPACE = "pawwork"
+ await clear(true)
+ try {
+ await fn()
+ } finally {
+ if (previous === undefined) delete process.env.PAWWORK_RUNTIME_NAMESPACE
+ else process.env.PAWWORK_RUNTIME_NAMESPACE = previous
+ await clear(true)
+ }
+}
+
async function waitFor(fn: () => Promise, timeout = 2000) {
const start = Date.now()
while (!(await fn())) {
@@ -198,151 +211,161 @@ test("config entry names tolerate unnormalized roots", () => {
})
test("loads PawWork project config aliases", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await writeConfig(
- dir,
- {
- $schema: "https://opencode.ai/config.json",
- model: "test/pawwork-root",
- },
- "pawwork.json",
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const config = await load()
- expect(config.model).toBe("test/pawwork-root")
- },
+ await withPawWorkRuntime(async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await writeConfig(
+ dir,
+ {
+ $schema: "https://opencode.ai/config.json",
+ model: "test/pawwork-root",
+ },
+ "pawwork.json",
+ )
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await load()
+ expect(config.model).toBe("test/pawwork-root")
+ },
+ })
})
})
test("loads PawWork configs from .opencode directories", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- const configDir = path.join(dir, ".opencode")
- await fs.mkdir(configDir, { recursive: true })
- await writeConfig(
- configDir,
- {
- $schema: "https://opencode.ai/config.json",
- model: "test/pawwork-dir",
- },
- "pawwork.json",
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const config = await load()
- expect(config.model).toBe("test/pawwork-dir")
- },
- })
-})
-
-test("loads and updates global PawWork config aliases", async () => {
- await using globalTmp = await tmpdir()
- await using tmp = await tmpdir()
- const prev = Global.Path.config
- ;(Global.Path as { config: string }).config = globalTmp.path
- await clear()
- try {
- await writeConfig(
- globalTmp.path,
- {
- $schema: "https://opencode.ai/config.json",
- username: "global-pawwork",
+ await withPawWorkRuntime(async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const configDir = path.join(dir, ".opencode")
+ await fs.mkdir(configDir, { recursive: true })
+ await writeConfig(
+ configDir,
+ {
+ $schema: "https://opencode.ai/config.json",
+ model: "test/pawwork-dir",
+ },
+ "pawwork.json",
+ )
},
- "pawwork.json",
- )
-
+ })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
- expect(config.username).toBe("global-pawwork")
-
- const updated = await Effect.runPromise(
- Config.Service.use((svc) => svc.updateGlobal({ ...config, username: "updated-pawwork" })).pipe(
- Effect.scoped,
- Effect.provide(layer),
- ),
- )
- expect(updated.username).toBe("updated-pawwork")
+ expect(config.model).toBe("test/pawwork-dir")
},
})
-
- const content = await Filesystem.readJson<{ username?: string }>(path.join(globalTmp.path, "pawwork.json"))
- expect(content.username).toBe("updated-pawwork")
- expect(await Filesystem.exists(path.join(globalTmp.path, "opencode.jsonc"))).toBe(false)
- } finally {
- ;(Global.Path as { config: string }).config = prev
- await clear()
- }
+ })
})
-test("PawWork config aliases win consistently when OpenCode aliases coexist", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await writeConfig(
- dir,
- {
- $schema: "https://opencode.ai/config.json",
- model: "test/opencode-root",
- },
- "opencode.json",
- )
+test("loads and updates global PawWork config aliases", async () => {
+ await withPawWorkRuntime(async () => {
+ await using globalTmp = await tmpdir()
+ await using tmp = await tmpdir()
+ const prev = Global.Path.config
+ ;(Global.Path as { config: string }).config = globalTmp.path
+ await clear()
+ try {
await writeConfig(
- dir,
+ globalTmp.path,
{
$schema: "https://opencode.ai/config.json",
- model: "test/pawwork-root",
+ username: "global-pawwork",
},
"pawwork.json",
)
- },
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await load()
+ expect(config.username).toBe("global-pawwork")
+
+ const updated = await Effect.runPromise(
+ Config.Service.use((svc) => svc.updateGlobal({ ...config, username: "updated-pawwork" })).pipe(
+ Effect.scoped,
+ Effect.provide(layer),
+ ),
+ )
+ expect(updated.username).toBe("updated-pawwork")
+ },
+ })
+
+ const content = await Filesystem.readJson<{ username?: string }>(path.join(globalTmp.path, "pawwork.json"))
+ expect(content.username).toBe("updated-pawwork")
+ expect(await Filesystem.exists(path.join(globalTmp.path, "opencode.jsonc"))).toBe(false)
+ } finally {
+ ;(Global.Path as { config: string }).config = prev
+ await clear()
+ }
})
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const config = await load()
- expect(config.model).toBe("test/pawwork-root")
- },
+})
+
+test("PawWork config aliases win consistently when OpenCode aliases coexist", async () => {
+ await withPawWorkRuntime(async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await writeConfig(
+ dir,
+ {
+ $schema: "https://opencode.ai/config.json",
+ model: "test/opencode-root",
+ },
+ "opencode.json",
+ )
+ await writeConfig(
+ dir,
+ {
+ $schema: "https://opencode.ai/config.json",
+ model: "test/pawwork-root",
+ },
+ "pawwork.json",
+ )
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await load()
+ expect(config.model).toBe("test/pawwork-root")
+ },
+ })
})
})
test("PawWork .opencode aliases win consistently when OpenCode aliases coexist", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- const configDir = path.join(dir, ".opencode")
- await fs.mkdir(configDir, { recursive: true })
- await writeConfig(
- configDir,
- {
- $schema: "https://opencode.ai/config.json",
- model: "test/opencode-dir",
- },
- "opencode.json",
- )
- await writeConfig(
- configDir,
- {
- $schema: "https://opencode.ai/config.json",
- model: "test/pawwork-dir",
- },
- "pawwork.json",
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const config = await load()
- expect(config.model).toBe("test/pawwork-dir")
- },
+ await withPawWorkRuntime(async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const configDir = path.join(dir, ".opencode")
+ await fs.mkdir(configDir, { recursive: true })
+ await writeConfig(
+ configDir,
+ {
+ $schema: "https://opencode.ai/config.json",
+ model: "test/opencode-dir",
+ },
+ "opencode.json",
+ )
+ await writeConfig(
+ configDir,
+ {
+ $schema: "https://opencode.ai/config.json",
+ model: "test/pawwork-dir",
+ },
+ "pawwork.json",
+ )
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await load()
+ expect(config.model).toBe("test/pawwork-dir")
+ },
+ })
})
})
@@ -1255,7 +1278,7 @@ test("updates config and writes to file", async () => {
const newConfig = { model: "updated/model" }
await save(newConfig as any)
- const writtenConfig = await Filesystem.readJson<{ model: string }>(path.join(tmp.path, "config.json"))
+ const writtenConfig = await Filesystem.readJson<{ model: string }>(path.join(tmp.path, "opencode.json"))
expect(writtenConfig.model).toBe("updated/model")
},
})
@@ -1874,29 +1897,31 @@ test("managed settings override project settings", async () => {
})
test("managed settings honor PawWork config aliases", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await writeConfig(dir, {
- $schema: "https://opencode.ai/config.json",
- model: "user/model",
- })
- },
- })
+ await withPawWorkRuntime(async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await writeConfig(dir, {
+ $schema: "https://opencode.ai/config.json",
+ model: "user/model",
+ })
+ },
+ })
- await writeManagedSettings(
- {
- $schema: "https://opencode.ai/config.json",
- model: "managed/pawwork",
- },
- "pawwork.json",
- )
+ await writeManagedSettings(
+ {
+ $schema: "https://opencode.ai/config.json",
+ model: "managed/pawwork",
+ },
+ "pawwork.json",
+ )
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const config = await load()
- expect(config.model).toBe("managed/pawwork")
- },
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await load()
+ expect(config.model).toBe("managed/pawwork")
+ },
+ })
})
})
diff --git a/packages/opencode/test/config/pawwork-global-config.test.ts b/packages/opencode/test/config/pawwork-global-config.test.ts
new file mode 100644
index 000000000..9c7b00282
--- /dev/null
+++ b/packages/opencode/test/config/pawwork-global-config.test.ts
@@ -0,0 +1,469 @@
+import { afterEach, beforeEach, describe, expect, test } from "bun:test"
+import fs from "fs/promises"
+import path from "path"
+import { Effect, Layer, Option } from "effect"
+import { NodeFileSystem, NodePath } from "@effect/platform-node"
+import { Account } from "../../src/account"
+import { Auth } from "../../src/auth"
+import { Config, ConfigManaged } from "../../src/config"
+import { ConfigPaths } from "../../src/config/paths"
+import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
+import { AppFileSystem } from "../../src/filesystem"
+import { Global } from "../../src/global"
+import { Instance } from "../../src/project/instance"
+import { Filesystem } from "../../src/util/filesystem"
+import { tmpdir } from "../fixture/fixture"
+
+const infra = CrossSpawnSpawner.defaultLayer.pipe(
+ Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)),
+)
+
+const emptyAccount = Layer.mock(Account.Service)({
+ active: () => Effect.succeed(Option.none()),
+ activeOrg: () => Effect.succeed(Option.none()),
+})
+
+const emptyAuth = Layer.mock(Auth.Service)({
+ all: () => Effect.succeed({}),
+})
+
+const layer = Config.layer.pipe(
+ Layer.provide(AppFileSystem.defaultLayer),
+ Layer.provide(emptyAuth),
+ Layer.provide(emptyAccount),
+ Layer.provideMerge(infra),
+)
+
+const load = () => Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(layer)))
+const save = (config: Config.Info) =>
+ Effect.runPromise(Config.Service.use((svc) => svc.update(config)).pipe(Effect.scoped, Effect.provide(layer)))
+const saveGlobal = (config: Config.Info) =>
+ Effect.runPromise(Config.Service.use((svc) => svc.updateGlobal(config)).pipe(Effect.scoped, Effect.provide(layer)))
+const clear = (wait = false) =>
+ Effect.runPromise(Config.Service.use((svc) => svc.invalidate(wait)).pipe(Effect.scoped, Effect.provide(layer)))
+const listConfigDirs = (directory: string, worktree: string) =>
+ Effect.runPromise(ConfigPaths.directories(directory, worktree).pipe(Effect.provide(AppFileSystem.defaultLayer)))
+
+const originalRuntimeNamespace = process.env.PAWWORK_RUNTIME_NAMESPACE
+
+beforeEach(async () => {
+ process.env.PAWWORK_RUNTIME_NAMESPACE = "pawwork"
+ await clear(true)
+})
+
+afterEach(async () => {
+ await Instance.disposeAll()
+ await clear(true)
+ if (originalRuntimeNamespace === undefined) delete process.env.PAWWORK_RUNTIME_NAMESPACE
+ else process.env.PAWWORK_RUNTIME_NAMESPACE = originalRuntimeNamespace
+})
+
+describe("default OpenCode config compatibility", () => {
+ test("keeps OPENCODE_CONFIG_DIR outside PawWork runtime mode", async () => {
+ await using opencodeConfig = await tmpdir()
+ await using project = await tmpdir({ git: true })
+ const previousRuntime = process.env.PAWWORK_RUNTIME_NAMESPACE
+ const previousOpenCode = process.env.OPENCODE_CONFIG_DIR
+
+ delete process.env.PAWWORK_RUNTIME_NAMESPACE
+ process.env.OPENCODE_CONFIG_DIR = opencodeConfig.path
+
+ try {
+ const dirs = await listConfigDirs(project.path, project.path)
+ expect(dirs).toContain(opencodeConfig.path)
+ } finally {
+ if (previousRuntime === undefined) delete process.env.PAWWORK_RUNTIME_NAMESPACE
+ else process.env.PAWWORK_RUNTIME_NAMESPACE = previousRuntime
+ if (previousOpenCode === undefined) delete process.env.OPENCODE_CONFIG_DIR
+ else process.env.OPENCODE_CONFIG_DIR = previousOpenCode
+ }
+ })
+
+ test("keeps OpenCode managed config defaults outside PawWork runtime mode", () => {
+ const previousRuntime = process.env.PAWWORK_RUNTIME_NAMESPACE
+ const previousManaged = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR
+ delete process.env.PAWWORK_RUNTIME_NAMESPACE
+ delete process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR
+
+ try {
+ const managed = ConfigManaged.managedConfigDir()
+ expect(path.basename(managed)).toBe("opencode")
+ } finally {
+ if (previousRuntime === undefined) delete process.env.PAWWORK_RUNTIME_NAMESPACE
+ else process.env.PAWWORK_RUNTIME_NAMESPACE = previousRuntime
+ if (previousManaged === undefined) delete process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR
+ else process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR = previousManaged
+ }
+ })
+
+ test("PawWork runtime mode computes Global config under PawWork before module load", async () => {
+ await using root = await tmpdir()
+ const project = path.join(root.path, "project")
+ const script = `
+ process.env.PAWWORK_RUNTIME_NAMESPACE = "pawwork"
+ process.env.XDG_CONFIG_HOME = ${JSON.stringify(root.path)}
+ const { ConfigPaths } = await import("./src/config/paths.ts")
+ const { AppFileSystem } = await import("./src/filesystem/index.ts")
+ const { Effect } = await import("effect")
+ const dirs = await Effect.runPromise(
+ ConfigPaths.directories(${JSON.stringify(project)}, ${JSON.stringify(project)}).pipe(Effect.provide(AppFileSystem.defaultLayer)),
+ )
+ console.log(JSON.stringify(dirs[0]))
+ `
+ const result = Bun.spawnSync({
+ cmd: [process.execPath, "--eval", script],
+ cwd: path.join(import.meta.dir, "..", ".."),
+ stdout: "pipe",
+ stderr: "pipe",
+ env: { ...process.env },
+ })
+
+ if (result.exitCode !== 0) throw new Error(Buffer.from(result.stderr).toString())
+ expect(JSON.parse(Buffer.from(result.stdout).toString())).toBe(path.join(root.path, "pawwork"))
+ })
+
+ test("keeps legacy project .opencode config.json compatibility", async () => {
+ await using project = await tmpdir({ git: true })
+
+ const configDir = path.join(project.path, ".opencode")
+ await fs.mkdir(configDir, { recursive: true })
+ await Filesystem.write(path.join(configDir, "config.json"), JSON.stringify({ model: "compat/config" }))
+
+ await Instance.provide({
+ directory: project.path,
+ fn: async () => {
+ const config = await load()
+ expect(config.model).toBe("compat/config")
+ },
+ })
+ })
+
+ test("OpenCode runtime ignores PawWork project config aliases", async () => {
+ await using project = await tmpdir({ git: true })
+ const previousRuntime = process.env.PAWWORK_RUNTIME_NAMESPACE
+ delete process.env.PAWWORK_RUNTIME_NAMESPACE
+
+ try {
+ await Filesystem.write(path.join(project.path, "pawwork.json"), JSON.stringify({ model: "leaked/root" }))
+ await fs.mkdir(path.join(project.path, ".pawwork"), { recursive: true })
+ await Filesystem.write(
+ path.join(project.path, ".pawwork", "pawwork.json"),
+ JSON.stringify({ model: "leaked/directory" }),
+ )
+
+ await Instance.provide({
+ directory: project.path,
+ fn: async () => {
+ const config = await load()
+ expect(config.model).not.toBe("leaked/root")
+ expect(config.model).not.toBe("leaked/directory")
+ },
+ })
+ } finally {
+ if (previousRuntime === undefined) delete process.env.PAWWORK_RUNTIME_NAMESPACE
+ else process.env.PAWWORK_RUNTIME_NAMESPACE = previousRuntime
+ }
+ })
+
+ test("OpenCode runtime ignores PawWork global config aliases", async () => {
+ await using global = await tmpdir()
+ await using project = await tmpdir({ git: true })
+ const previousRuntime = process.env.PAWWORK_RUNTIME_NAMESPACE
+ const previousConfig = Global.Path.config
+ delete process.env.PAWWORK_RUNTIME_NAMESPACE
+ ;(Global.Path as { config: string }).config = global.path
+
+ try {
+ await Filesystem.write(path.join(global.path, "pawwork.json"), JSON.stringify({ model: "leaked/global" }))
+ await Instance.provide({
+ directory: project.path,
+ fn: async () => {
+ const config = await load()
+ expect(config.model).not.toBe("leaked/global")
+ },
+ })
+ } finally {
+ ;(Global.Path as { config: string }).config = previousConfig
+ if (previousRuntime === undefined) delete process.env.PAWWORK_RUNTIME_NAMESPACE
+ else process.env.PAWWORK_RUNTIME_NAMESPACE = previousRuntime
+ }
+ })
+})
+
+describe("PawWork global config isolation", () => {
+ test("does not discover home-level .opencode config implicitly", async () => {
+ await using home = await tmpdir()
+ await using project = await tmpdir({ git: true })
+ const previousHome = process.env.OPENCODE_TEST_HOME
+ process.env.OPENCODE_TEST_HOME = home.path
+
+ try {
+ const homeConfigDir = path.join(home.path, ".opencode")
+ await fs.mkdir(homeConfigDir, { recursive: true })
+ await Filesystem.write(path.join(homeConfigDir, "opencode.json"), JSON.stringify({ model: "leaked/model" }))
+
+ const dirs = await listConfigDirs(project.path, project.path)
+
+ expect(dirs).not.toContain(homeConfigDir)
+
+ await Instance.provide({
+ directory: project.path,
+ fn: async () => {
+ const config = await load()
+ expect(config.model).not.toBe("leaked/model")
+ },
+ })
+ } finally {
+ if (previousHome === undefined) delete process.env.OPENCODE_TEST_HOME
+ else process.env.OPENCODE_TEST_HOME = previousHome
+ }
+ })
+
+ test("ignores OPENCODE_CONFIG_DIR as an implicit OpenCode global config path", async () => {
+ await using opencodeConfig = await tmpdir()
+ await using project = await tmpdir({ git: true })
+ const previousOpenCode = process.env.OPENCODE_CONFIG_DIR
+ const previousPawWork = process.env.PAWWORK_CONFIG_DIR
+ process.env.OPENCODE_CONFIG_DIR = opencodeConfig.path
+ delete process.env.PAWWORK_CONFIG_DIR
+
+ try {
+ await Filesystem.write(path.join(opencodeConfig.path, "opencode.json"), JSON.stringify({ model: "leaked/env" }))
+
+ const dirs = await listConfigDirs(project.path, project.path)
+ expect(dirs).not.toContain(opencodeConfig.path)
+
+ await Instance.provide({
+ directory: project.path,
+ fn: async () => {
+ const config = await load()
+ expect(config.model).not.toBe("leaked/env")
+ },
+ })
+ } finally {
+ if (previousOpenCode === undefined) delete process.env.OPENCODE_CONFIG_DIR
+ else process.env.OPENCODE_CONFIG_DIR = previousOpenCode
+ if (previousPawWork === undefined) delete process.env.PAWWORK_CONFIG_DIR
+ else process.env.PAWWORK_CONFIG_DIR = previousPawWork
+ }
+ })
+
+ test("PAWWORK_CONFIG_DIR reads PawWork filenames and ignores OpenCode filenames", async () => {
+ await using pawworkConfig = await tmpdir()
+ await using project = await tmpdir({ git: true })
+ const previousOpenCode = process.env.OPENCODE_CONFIG_DIR
+ const previousPawWork = process.env.PAWWORK_CONFIG_DIR
+ delete process.env.OPENCODE_CONFIG_DIR
+ process.env.PAWWORK_CONFIG_DIR = pawworkConfig.path
+
+ try {
+ await Filesystem.write(path.join(pawworkConfig.path, "opencode.json"), JSON.stringify({ model: "leaked/env" }))
+ await Filesystem.write(path.join(pawworkConfig.path, "pawwork.json"), JSON.stringify({ model: "expected/model" }))
+
+ await Instance.provide({
+ directory: project.path,
+ fn: async () => {
+ const config = await load()
+ expect(config.model).toBe("expected/model")
+ },
+ })
+ } finally {
+ if (previousOpenCode === undefined) delete process.env.OPENCODE_CONFIG_DIR
+ else process.env.OPENCODE_CONFIG_DIR = previousOpenCode
+ if (previousPawWork === undefined) delete process.env.PAWWORK_CONFIG_DIR
+ else process.env.PAWWORK_CONFIG_DIR = previousPawWork
+ }
+ })
+
+ test("PAWWORK_CONFIG_DIR with only opencode.json does not affect config", async () => {
+ await using pawworkConfig = await tmpdir()
+ await using project = await tmpdir({ git: true })
+ const previousOpenCode = process.env.OPENCODE_CONFIG_DIR
+ const previousPawWork = process.env.PAWWORK_CONFIG_DIR
+ delete process.env.OPENCODE_CONFIG_DIR
+ process.env.PAWWORK_CONFIG_DIR = pawworkConfig.path
+
+ try {
+ await Filesystem.write(path.join(pawworkConfig.path, "opencode.json"), JSON.stringify({ model: "leaked/env" }))
+
+ await Instance.provide({
+ directory: project.path,
+ fn: async () => {
+ const config = await load()
+ expect(config.model).not.toBe("leaked/env")
+ },
+ })
+ } finally {
+ if (previousOpenCode === undefined) delete process.env.OPENCODE_CONFIG_DIR
+ else process.env.OPENCODE_CONFIG_DIR = previousOpenCode
+ if (previousPawWork === undefined) delete process.env.PAWWORK_CONFIG_DIR
+ else process.env.PAWWORK_CONFIG_DIR = previousPawWork
+ }
+ })
+
+ test("loads project .pawwork config directories", async () => {
+ await using project = await tmpdir({ git: true })
+ const pawworkDir = path.join(project.path, ".pawwork")
+ await fs.mkdir(pawworkDir, { recursive: true })
+ await Filesystem.write(path.join(pawworkDir, "pawwork.json"), JSON.stringify({ model: "project/pawwork" }))
+
+ await Instance.provide({
+ directory: project.path,
+ fn: async () => {
+ const dirs = await listConfigDirs(project.path, project.path)
+ expect(dirs).toContain(pawworkDir)
+
+ const config = await load()
+ expect(config.model).toBe("project/pawwork")
+ },
+ })
+ })
+
+ test("project .opencode directories stay read-only for dependency installs", async () => {
+ await using project = await tmpdir({ git: true })
+ const opencodeDir = path.join(project.path, ".opencode")
+ await fs.mkdir(opencodeDir, { recursive: true })
+ await Filesystem.write(path.join(opencodeDir, "opencode.json"), JSON.stringify({ model: "compat/model" }))
+
+ await Instance.provide({
+ directory: project.path,
+ fn: async () => {
+ const config = await load()
+ expect(config.model).toBe("compat/model")
+
+ expect(await Bun.file(path.join(opencodeDir, "package.json")).exists()).toBeFalse()
+ expect(await Bun.file(path.join(opencodeDir, ".gitignore")).exists()).toBeFalse()
+ },
+ })
+ })
+
+ test("project config update writes pawwork.json and reloads it", async () => {
+ await using project = await tmpdir({ git: true })
+
+ await Instance.provide({
+ directory: project.path,
+ fn: async () => {
+ await save({ model: "project/model" })
+
+ expect(await Bun.file(path.join(project.path, "config.json")).exists()).toBeFalse()
+ expect(await Bun.file(path.join(project.path, "pawwork.json")).exists()).toBeTrue()
+
+ const after = await load()
+ expect(after.model).toBe("project/model")
+ },
+ })
+ })
+
+ test("project config update writes to the active PawWork jsonc file", async () => {
+ await using project = await tmpdir({ git: true })
+
+ await Instance.provide({
+ directory: project.path,
+ fn: async () => {
+ await Filesystem.write(path.join(project.path, "pawwork.json"), JSON.stringify({ model: "json/model" }))
+ await Filesystem.write(path.join(project.path, "pawwork.jsonc"), JSON.stringify({ model: "jsonc/model" }))
+
+ const before = await load()
+ expect(before.model).toBe("jsonc/model")
+
+ await save({ model: "updated/project" })
+ const after = await load()
+ expect(after.model).toBe("updated/project")
+ expect(JSON.parse(await Bun.file(path.join(project.path, "pawwork.jsonc")).text()).model).toBe(
+ "updated/project",
+ )
+ },
+ })
+ })
+
+ test("global config update writes pawwork.json and ignores app-level opencode.json", async () => {
+ await using project = await tmpdir({ git: true })
+ await using global = await tmpdir()
+ const globalDir = global.path
+ const previousConfig = Global.Path.config
+ ;(Global.Path as { config: string }).config = globalDir
+
+ try {
+ await Instance.provide({
+ directory: project.path,
+ fn: async () => {
+ await Filesystem.write(path.join(globalDir, "opencode.json"), JSON.stringify({ model: "leaked/global" }))
+
+ const before = await load()
+ expect(before.model).not.toBe("leaked/global")
+
+ await saveGlobal({ model: "test/model" })
+ const configPath = path.join(globalDir, "pawwork.json")
+ expect(await Bun.file(configPath).exists()).toBeTrue()
+ expect(JSON.parse(await Bun.file(configPath).text()).model).toBe("test/model")
+ },
+ })
+ } finally {
+ ;(Global.Path as { config: string }).config = previousConfig
+ }
+ })
+
+ test("global config update writes to the active PawWork config file", async () => {
+ await using project = await tmpdir({ git: true })
+ await using global = await tmpdir()
+ const globalDir = global.path
+ const previousConfig = Global.Path.config
+ ;(Global.Path as { config: string }).config = globalDir
+
+ try {
+ await Instance.provide({
+ directory: project.path,
+ fn: async () => {
+ await Filesystem.write(path.join(globalDir, "pawwork.json"), JSON.stringify({ model: "json/model" }))
+ await Filesystem.write(path.join(globalDir, "pawwork.jsonc"), JSON.stringify({ model: "jsonc/model" }))
+
+ const before = await load()
+ expect(before.model).toBe("jsonc/model")
+
+ await saveGlobal({ model: "updated/model" })
+ const after = await load()
+ expect(after.model).toBe("updated/model")
+ expect(JSON.parse(await Bun.file(path.join(globalDir, "pawwork.jsonc")).text()).model).toBe("updated/model")
+ },
+ })
+ } finally {
+ ;(Global.Path as { config: string }).config = previousConfig
+ }
+ })
+
+ test("managed config defaults use PawWork-owned locations", () => {
+ const previous = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR
+ delete process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR
+
+ try {
+ const managed = ConfigManaged.managedConfigDir()
+ expect(path.basename(managed)).toBe("pawwork")
+ } finally {
+ if (previous === undefined) delete process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR
+ else process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR = previous
+ }
+ })
+
+ test("managed config ignores opencode.json in PawWork runtime mode", async () => {
+ await using managed = await tmpdir()
+ await using project = await tmpdir({ git: true })
+ const previous = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR
+ process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR = managed.path
+
+ try {
+ await Filesystem.write(path.join(managed.path, "opencode.json"), JSON.stringify({ model: "leaked/managed" }))
+ await Filesystem.write(path.join(managed.path, "pawwork.json"), JSON.stringify({ model: "expected/managed" }))
+
+ await Instance.provide({
+ directory: project.path,
+ fn: async () => {
+ const config = await load()
+ expect(config.model).toBe("expected/managed")
+ },
+ })
+ } finally {
+ if (previous === undefined) delete process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR
+ else process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR = previous
+ }
+ })
+})
diff --git a/packages/opencode/test/global/runtime-namespace.test.ts b/packages/opencode/test/global/runtime-namespace.test.ts
new file mode 100644
index 000000000..1bd2ac051
--- /dev/null
+++ b/packages/opencode/test/global/runtime-namespace.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, test } from "bun:test"
+import path from "path"
+
+function readGlobalPath(namespace?: string) {
+ const script = `
+ process.env.XDG_DATA_HOME = "/tmp/pawwork-runtime-test/share"
+ process.env.XDG_CACHE_HOME = "/tmp/pawwork-runtime-test/cache"
+ process.env.XDG_CONFIG_HOME = "/tmp/pawwork-runtime-test/config"
+ process.env.XDG_STATE_HOME = "/tmp/pawwork-runtime-test/state"
+ if (${JSON.stringify(namespace)} !== undefined) {
+ process.env.PAWWORK_RUNTIME_NAMESPACE = ${JSON.stringify(namespace)}
+ }
+ const { Global } = await import("./src/global/index.ts")
+ console.log(JSON.stringify(Global.Path))
+ `
+ const result = Bun.spawnSync({
+ cmd: [process.execPath, "--eval", script],
+ cwd: path.join(import.meta.dir, "..", ".."),
+ stdout: "pipe",
+ stderr: "pipe",
+ env: { ...process.env },
+ })
+
+ if (result.exitCode !== 0) throw new Error(Buffer.from(result.stderr).toString())
+ return JSON.parse(Buffer.from(result.stdout).toString()) as Record
+}
+
+describe("Global runtime namespace", () => {
+ test("defaults to OpenCode namespace outside PawWork desktop", () => {
+ const paths = readGlobalPath()
+
+ expect(paths.data).toBe("/tmp/pawwork-runtime-test/share/opencode")
+ expect(paths.cache).toBe("/tmp/pawwork-runtime-test/cache/opencode")
+ expect(paths.config).toBe("/tmp/pawwork-runtime-test/config/opencode")
+ expect(paths.state).toBe("/tmp/pawwork-runtime-test/state/opencode")
+ })
+
+ test("uses PawWork namespace when enabled", () => {
+ const paths = readGlobalPath("pawwork")
+
+ expect(paths.data).toBe("/tmp/pawwork-runtime-test/share/pawwork")
+ expect(paths.cache).toBe("/tmp/pawwork-runtime-test/cache/pawwork")
+ expect(paths.config).toBe("/tmp/pawwork-runtime-test/config/pawwork")
+ expect(paths.state).toBe("/tmp/pawwork-runtime-test/state/pawwork")
+ expect(paths.bin).toBe("/tmp/pawwork-runtime-test/cache/pawwork/bin")
+ expect(paths.log).toBe("/tmp/pawwork-runtime-test/share/pawwork/log")
+ })
+})
diff --git a/packages/opencode/test/index-runtime-namespace.test.ts b/packages/opencode/test/index-runtime-namespace.test.ts
new file mode 100644
index 000000000..816ef1269
--- /dev/null
+++ b/packages/opencode/test/index-runtime-namespace.test.ts
@@ -0,0 +1,10 @@
+import { describe, expect, test } from "bun:test"
+
+describe("startup migration marker", () => {
+ test("uses the namespaced database path instead of a hard-coded OpenCode database", async () => {
+ const source = await Bun.file(new URL("../src/index.ts", import.meta.url)).text()
+
+ expect(source).toMatch(/\bDatabase\.getChannelPath\(\)/)
+ expect(source).not.toMatch(/path\.join\(\s*Global\.Path\.data\s*,\s*["'`]opencode\.db["'`]\s*\)/)
+ })
+})
diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts
index 2bc573f1b..cdcd12ffd 100644
--- a/packages/opencode/test/provider/provider.test.ts
+++ b/packages/opencode/test/provider/provider.test.ts
@@ -61,6 +61,29 @@ async function defaultModel() {
return run((provider) => provider.defaultModel())
}
+test("OpenCode Zen and OpenCode Go providers remain discoverable in PawWork runtime mode", async () => {
+ await using tmp = await tmpdir({ git: true })
+ const previous = process.env.PAWWORK_RUNTIME_NAMESPACE
+ process.env.PAWWORK_RUNTIME_NAMESPACE = "pawwork"
+
+ try {
+ await Instance.provide({
+ directory: tmp.path,
+ init: async () => {
+ set("OPENCODE_API_KEY", "test-api-key")
+ },
+ fn: async () => {
+ const providers = await list()
+ expect(providers[ProviderID.make("opencode")]?.name).toBe("OpenCode Zen")
+ expect(providers[ProviderID.make("opencode-go")]?.name).toBe("OpenCode Go")
+ },
+ })
+ } finally {
+ if (previous === undefined) delete process.env.PAWWORK_RUNTIME_NAMESPACE
+ else process.env.PAWWORK_RUNTIME_NAMESPACE = previous
+ }
+})
+
function paid(providers: Awaited>) {
const item = providers[ProviderID.make("opencode")]
expect(item).toBeDefined()
diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts
index c46bbd20b..c25ad571d 100644
--- a/packages/opencode/test/session/instruction.test.ts
+++ b/packages/opencode/test/session/instruction.test.ts
@@ -385,3 +385,145 @@ describe("Instruction.systemPaths OPENCODE_CONFIG_DIR", () => {
}
})
})
+
+describe("Instruction.systemPaths PawWork runtime config dir", () => {
+ const original = {
+ opencodeConfigDir: process.env.OPENCODE_CONFIG_DIR,
+ pawworkConfigDir: process.env.PAWWORK_CONFIG_DIR,
+ runtimeNamespace: process.env.PAWWORK_RUNTIME_NAMESPACE,
+ disableProjectConfig: process.env.OPENCODE_DISABLE_PROJECT_CONFIG,
+ }
+
+ afterEach(() => {
+ if (original.opencodeConfigDir === undefined) delete process.env.OPENCODE_CONFIG_DIR
+ else process.env.OPENCODE_CONFIG_DIR = original.opencodeConfigDir
+ if (original.pawworkConfigDir === undefined) delete process.env.PAWWORK_CONFIG_DIR
+ else process.env.PAWWORK_CONFIG_DIR = original.pawworkConfigDir
+ if (original.runtimeNamespace === undefined) delete process.env.PAWWORK_RUNTIME_NAMESPACE
+ else process.env.PAWWORK_RUNTIME_NAMESPACE = original.runtimeNamespace
+ if (original.disableProjectConfig === undefined) delete process.env.OPENCODE_DISABLE_PROJECT_CONFIG
+ else process.env.OPENCODE_DISABLE_PROJECT_CONFIG = original.disableProjectConfig
+ })
+
+ test("ignores OPENCODE_CONFIG_DIR AGENTS.md in PawWork runtime mode", async () => {
+ await using profileTmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(path.join(dir, "AGENTS.md"), "# OpenCode Profile Instructions")
+ },
+ })
+ await using globalTmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions")
+ },
+ })
+ await using projectTmp = await tmpdir()
+
+ process.env.PAWWORK_RUNTIME_NAMESPACE = "pawwork"
+ process.env.OPENCODE_CONFIG_DIR = profileTmp.path
+ delete process.env.PAWWORK_CONFIG_DIR
+ const originalGlobalConfig = Global.Path.config
+ ;(Global.Path as { config: string }).config = globalTmp.path
+
+ try {
+ await Instance.provide({
+ directory: projectTmp.path,
+ fn: () =>
+ run(
+ Instruction.Service.use((svc) =>
+ Effect.gen(function* () {
+ const paths = yield* svc.systemPaths()
+ expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(false)
+ expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
+ }),
+ ),
+ ),
+ })
+ } finally {
+ ;(Global.Path as { config: string }).config = originalGlobalConfig
+ }
+ })
+
+ test("prefers PAWWORK_CONFIG_DIR AGENTS.md over global when both exist", async () => {
+ await using profileTmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(path.join(dir, "AGENTS.md"), "# PawWork Profile Instructions")
+ },
+ })
+ await using globalTmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions")
+ },
+ })
+ await using projectTmp = await tmpdir()
+
+ process.env.PAWWORK_RUNTIME_NAMESPACE = "pawwork"
+ delete process.env.OPENCODE_CONFIG_DIR
+ process.env.PAWWORK_CONFIG_DIR = profileTmp.path
+ const originalGlobalConfig = Global.Path.config
+ ;(Global.Path as { config: string }).config = globalTmp.path
+
+ try {
+ await Instance.provide({
+ directory: projectTmp.path,
+ fn: () =>
+ run(
+ Instruction.Service.use((svc) =>
+ Effect.gen(function* () {
+ const paths = yield* svc.systemPaths()
+ expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(true)
+ expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(false)
+ }),
+ ),
+ ),
+ })
+ } finally {
+ ;(Global.Path as { config: string }).config = originalGlobalConfig
+ }
+ })
+
+ test("resolves relative instruction paths from PAWWORK_CONFIG_DIR when project config is disabled", async () => {
+ await using pawworkConfig = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(path.join(dir, "rules", "extra.md"), "# PawWork Relative Instructions")
+ },
+ })
+ await using opencodeConfig = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(path.join(dir, "rules", "extra.md"), "# OpenCode Relative Instructions")
+ },
+ })
+ await using globalTmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(path.join(dir, "pawwork.json"), JSON.stringify({ instructions: ["rules/extra.md"] }))
+ },
+ })
+ await using projectTmp = await tmpdir()
+
+ process.env.PAWWORK_RUNTIME_NAMESPACE = "pawwork"
+ process.env.OPENCODE_DISABLE_PROJECT_CONFIG = "1"
+ process.env.OPENCODE_CONFIG_DIR = opencodeConfig.path
+ process.env.PAWWORK_CONFIG_DIR = pawworkConfig.path
+ const originalGlobalConfig = Global.Path.config
+ ;(Global.Path as { config: string }).config = globalTmp.path
+
+ try {
+ await Instance.provide({
+ directory: projectTmp.path,
+ fn: () =>
+ run(
+ Instruction.Service.use((svc) =>
+ Effect.gen(function* () {
+ const rules = yield* svc.system()
+ expect(rules).toContain(
+ `Instructions from: ${path.join(pawworkConfig.path, "rules", "extra.md")}\n# PawWork Relative Instructions`,
+ )
+ expect(rules.join("\n")).not.toContain("OpenCode Relative Instructions")
+ }),
+ ),
+ ),
+ })
+ } finally {
+ ;(Global.Path as { config: string }).config = originalGlobalConfig
+ }
+ })
+})
diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts
index cfd6da26d..20a129d56 100644
--- a/packages/opencode/test/session/session.test.ts
+++ b/packages/opencode/test/session/session.test.ts
@@ -11,6 +11,28 @@ import { tmpdir } from "../fixture/fixture"
const projectRoot = path.join(__dirname, "../..")
void Log.init({ print: false })
+describe("PawWork runtime namespace", () => {
+ test("plan files use .pawwork in git projects", async () => {
+ await using tmp = await tmpdir({ git: true })
+ const previous = process.env.PAWWORK_RUNTIME_NAMESPACE
+ process.env.PAWWORK_RUNTIME_NAMESPACE = "pawwork"
+
+ try {
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ expect(SessionNs.plan({ slug: "test-plan", time: { created: 123 } })).toBe(
+ path.join(tmp.path, ".pawwork", "plans", "123-test-plan.md"),
+ )
+ },
+ })
+ } finally {
+ if (previous === undefined) delete process.env.PAWWORK_RUNTIME_NAMESPACE
+ else process.env.PAWWORK_RUNTIME_NAMESPACE = previous
+ }
+ })
+})
+
describe("session.created event", () => {
test("should emit session.created event when session is created", async () => {
await Instance.provide({
diff --git a/packages/opencode/test/storage/db.test.ts b/packages/opencode/test/storage/db.test.ts
index f6b605559..bfe94680c 100644
--- a/packages/opencode/test/storage/db.test.ts
+++ b/packages/opencode/test/storage/db.test.ts
@@ -6,9 +6,24 @@ import { Database } from "../../src/storage/db"
describe("Database.Path", () => {
test("returns database path for the current channel", () => {
- const expected = ["latest", "beta"].includes(Installation.CHANNEL)
+ const expected = ["latest", "beta", "prod"].includes(Installation.CHANNEL)
? path.join(Global.Path.data, "opencode.db")
: path.join(Global.Path.data, `opencode-${Installation.CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`)
expect(Database.getChannelPath()).toBe(expected)
})
+
+ test("uses PawWork database name when PawWork runtime namespace is enabled", () => {
+ const previous = process.env.PAWWORK_RUNTIME_NAMESPACE
+ process.env.PAWWORK_RUNTIME_NAMESPACE = "pawwork"
+
+ try {
+ const expected = ["latest", "beta", "prod"].includes(Installation.CHANNEL)
+ ? path.join(Global.Path.data, "pawwork.db")
+ : path.join(Global.Path.data, `pawwork-${Installation.CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`)
+ expect(Database.getChannelPath()).toBe(expected)
+ } finally {
+ if (previous === undefined) delete process.env.PAWWORK_RUNTIME_NAMESPACE
+ else process.env.PAWWORK_RUNTIME_NAMESPACE = previous
+ }
+ })
})
diff --git a/packages/shared/src/global.ts b/packages/shared/src/global.ts
index 538cc091b..d872b3b33 100644
--- a/packages/shared/src/global.ts
+++ b/packages/shared/src/global.ts
@@ -2,6 +2,7 @@ import path from "path"
import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir"
import os from "os"
import { Context, Effect, Layer } from "effect"
+import { Runtime } from "./runtime"
export namespace Global {
export class Service extends Context.Service()("@opencode/Global") {}
@@ -19,7 +20,7 @@ export namespace Global {
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
- const app = "opencode"
+ const app = Runtime.appName()
const home = process.env.OPENCODE_TEST_HOME ?? os.homedir()
const data = path.join(xdgData!, app)
const cache = path.join(xdgCache!, app)
diff --git a/packages/shared/src/runtime.ts b/packages/shared/src/runtime.ts
new file mode 100644
index 000000000..d93ccf3ae
--- /dev/null
+++ b/packages/shared/src/runtime.ts
@@ -0,0 +1,9 @@
+export namespace Runtime {
+ export function isPawWork() {
+ return process.env.PAWWORK_RUNTIME_NAMESPACE?.startsWith("pawwork") ?? false
+ }
+
+ export function appName() {
+ return isPawWork() ? "pawwork" : "opencode"
+ }
+}
diff --git a/packages/shared/test/global.test.ts b/packages/shared/test/global.test.ts
new file mode 100644
index 000000000..82f8e5a4b
--- /dev/null
+++ b/packages/shared/test/global.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, test } from "bun:test"
+import { spawnSync } from "node:child_process"
+import os from "node:os"
+import path from "path"
+
+function readSharedGlobalPath(namespace?: string) {
+ const root = path.join(os.tmpdir(), "pawwork-shared-runtime-test")
+ const data = path.join(root, "share")
+ const cache = path.join(root, "cache")
+ const config = path.join(root, "config")
+ const state = path.join(root, "state")
+ const script = `
+ process.env.XDG_DATA_HOME = ${JSON.stringify(data)}
+ process.env.XDG_CACHE_HOME = ${JSON.stringify(cache)}
+ process.env.XDG_CONFIG_HOME = ${JSON.stringify(config)}
+ process.env.XDG_STATE_HOME = ${JSON.stringify(state)}
+ if (${JSON.stringify(namespace)} !== undefined) {
+ process.env.PAWWORK_RUNTIME_NAMESPACE = ${JSON.stringify(namespace)}
+ } else {
+ delete process.env.PAWWORK_RUNTIME_NAMESPACE
+ }
+ const { Effect } = await import("effect")
+ const { Global } = await import("./src/global.ts")
+ const paths = await Effect.gen(function* () {
+ return yield* Global.Service
+ }).pipe(Effect.provide(Global.layer), Effect.runPromise)
+ console.log(JSON.stringify(paths))
+ `
+ const result = spawnSync(process.execPath, ["--eval", script], {
+ cwd: path.join(import.meta.dir, ".."),
+ env: { ...process.env },
+ })
+
+ if (result.status !== 0) throw new Error(result.stderr.toString())
+ return {
+ paths: JSON.parse(result.stdout.toString()) as Record,
+ root: { data, cache, config, state },
+ }
+}
+
+describe("shared Global runtime namespace", () => {
+ test("defaults to OpenCode namespace outside PawWork desktop", () => {
+ const { paths, root } = readSharedGlobalPath()
+ expect(paths.data).toBe(path.join(root.data, "opencode"))
+ })
+
+ test("uses PawWork namespace when enabled", () => {
+ const { paths, root } = readSharedGlobalPath("pawwork")
+
+ expect(paths.data).toBe(path.join(root.data, "pawwork"))
+ expect(paths.cache).toBe(path.join(root.cache, "pawwork"))
+ expect(paths.config).toBe(path.join(root.config, "pawwork"))
+ expect(paths.state).toBe(path.join(root.state, "pawwork"))
+ })
+
+ test("accepts PawWork variant namespaces", () => {
+ const { paths, root } = readSharedGlobalPath("pawwork-dev")
+
+ expect(paths.data).toBe(path.join(root.data, "pawwork"))
+ })
+})
diff --git a/packages/ui/src/theme/context.tsx b/packages/ui/src/theme/context.tsx
index e1c92f243..da773bf74 100644
--- a/packages/ui/src/theme/context.tsx
+++ b/packages/ui/src/theme/context.tsx
@@ -8,11 +8,18 @@ import type { DesktopTheme } from "./types"
export type ColorScheme = "light" | "dark" | "system"
-const STORAGE_KEYS = {
- THEME_ID: "opencode-theme-id",
- COLOR_SCHEME: "opencode-color-scheme",
- THEME_CSS_LIGHT: "opencode-theme-css-light",
- THEME_CSS_DARK: "opencode-theme-css-dark",
+export type ThemeStorageKeys = {
+ themeId: string
+ colorScheme: string
+ cssLight: string
+ cssDark: string
+}
+
+const DEFAULT_STORAGE_KEYS: ThemeStorageKeys = {
+ themeId: "opencode-theme-id",
+ colorScheme: "opencode-color-scheme",
+ cssLight: "opencode-theme-css-light",
+ cssDark: "opencode-theme-css-dark",
} as const
const THEME_STYLE_ID = "oc-theme"
@@ -69,9 +76,9 @@ function drop(key: string) {
} catch {}
}
-function clear() {
- drop(STORAGE_KEYS.THEME_CSS_LIGHT)
- drop(STORAGE_KEYS.THEME_CSS_DARK)
+function clear(keys: ThemeStorageKeys) {
+ drop(keys.cssLight)
+ drop(keys.cssDark)
}
function ensureThemeStyleElement(): HTMLStyleElement {
@@ -97,14 +104,14 @@ function shouldCacheTheme(themeId: string) {
return themeId !== DEFAULT_THEME_ID
}
-function applyThemeCss(theme: DesktopTheme, themeId: string, mode: "light" | "dark") {
+function applyThemeCss(theme: DesktopTheme, themeId: string, mode: "light" | "dark", keys: ThemeStorageKeys) {
const isDark = mode === "dark"
const variant = isDark ? theme.dark : theme.light
const tokens = resolveThemeVariant(variant, isDark)
const css = themeToCss(tokens)
if (shouldCacheTheme(themeId)) {
- write(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css)
+ write(isDark ? keys.cssDark : keys.cssLight, css)
}
const fullCss = `:root {
@@ -119,23 +126,28 @@ function applyThemeCss(theme: DesktopTheme, themeId: string, mode: "light" | "da
document.documentElement.dataset.colorScheme = mode
}
-function cacheThemeVariants(theme: DesktopTheme, themeId: string) {
+function cacheThemeVariants(theme: DesktopTheme, themeId: string, keys: ThemeStorageKeys) {
if (!shouldCacheTheme(themeId)) return
for (const mode of ["light", "dark"] as const) {
const isDark = mode === "dark"
const variant = isDark ? theme.dark : theme.light
const tokens = resolveThemeVariant(variant, isDark)
const css = themeToCss(tokens)
- write(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css)
+ write(isDark ? keys.cssDark : keys.cssLight, css)
}
}
export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
name: "Theme",
- init: (props: { defaultTheme?: string; onThemeApplied?: (theme: DesktopTheme, mode: "light" | "dark") => void }) => {
- const rawStoredTheme = read(STORAGE_KEYS.THEME_ID)
+ init: (props: {
+ defaultTheme?: string
+ storageKeys?: Partial
+ onThemeApplied?: (theme: DesktopTheme, mode: "light" | "dark") => void
+ }) => {
+ const storageKeys = { ...DEFAULT_STORAGE_KEYS, ...props.storageKeys }
+ const rawStoredTheme = read(storageKeys.themeId)
const storedTheme = rawStoredTheme && knownThemes().has(rawStoredTheme) ? rawStoredTheme : null
- const storedScheme = read(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null
+ const storedScheme = read(storageKeys.colorScheme) as ColorScheme | null
const firstInstall = !rawStoredTheme && !storedScheme
const fallbackDefault =
props.defaultTheme && knownThemes().has(props.defaultTheme) ? props.defaultTheme : DEFAULT_THEME_ID
@@ -178,7 +190,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
}
const applyTheme = (theme: DesktopTheme, themeId: string, mode: "light" | "dark") => {
- applyThemeCss(theme, themeId, mode)
+ applyThemeCss(theme, themeId, mode, storageKeys)
props.onThemeApplied?.(theme, mode)
}
@@ -194,22 +206,22 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
const loadThemes = () => Promise.all(themeIDs().map(load)).then(() => store.themes)
const onStorage = (e: StorageEvent) => {
- if (e.key === STORAGE_KEYS.THEME_ID && e.newValue) {
+ if (e.key === storageKeys.themeId && e.newValue) {
const next = e.newValue
if (!knownThemes().has(next) && !store.themes[next]) return
setStore("themeId", next)
if (next === DEFAULT_THEME_ID) {
setStore("colorScheme", "light")
setStore("mode", "light")
- clear()
+ clear(storageKeys)
return
}
void load(next).then((theme) => {
if (!theme || store.themeId !== next) return
- cacheThemeVariants(theme, next)
+ cacheThemeVariants(theme, next, storageKeys)
})
}
- if (e.key === STORAGE_KEYS.COLOR_SCHEME && e.newValue) {
+ if (e.key === storageKeys.colorScheme && e.newValue) {
const nextScheme = store.themeId === DEFAULT_THEME_ID ? "light" : (e.newValue as ColorScheme)
setStore("colorScheme", nextScheme)
setStore("mode", resolveMode(store.themeId, nextScheme))
@@ -226,27 +238,27 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
}
makeEventListener(mediaQuery, "change", onMedia)
- const rawTheme = read(STORAGE_KEYS.THEME_ID)
- const rawScheme = read(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null
+ const rawTheme = read(storageKeys.themeId)
+ const rawScheme = read(storageKeys.colorScheme) as ColorScheme | null
const firstInstall = !rawTheme && !rawScheme
const candidate = rawTheme ?? props.defaultTheme
const savedTheme = candidate && knownThemes().has(candidate) ? candidate : DEFAULT_THEME_ID
const savedScheme =
savedTheme === DEFAULT_THEME_ID ? "light" : ((rawScheme ?? (firstInstall ? "light" : "system")) as ColorScheme)
if (rawTheme && rawTheme !== savedTheme) {
- write(STORAGE_KEYS.THEME_ID, savedTheme)
- clear()
+ write(storageKeys.themeId, savedTheme)
+ clear(storageKeys)
}
if (savedTheme === DEFAULT_THEME_ID) {
- write(STORAGE_KEYS.THEME_ID, savedTheme)
- write(STORAGE_KEYS.COLOR_SCHEME, "light")
+ write(storageKeys.themeId, savedTheme)
+ write(storageKeys.colorScheme, "light")
}
if (savedTheme !== store.themeId) setStore("themeId", savedTheme)
if (savedScheme !== store.colorScheme) setStore("colorScheme", savedScheme)
setStore("mode", resolveMode(savedTheme, savedScheme))
void load(savedTheme).then((theme) => {
if (!theme || store.themeId !== savedTheme) return
- cacheThemeVariants(theme, savedTheme)
+ cacheThemeVariants(theme, savedTheme, storageKeys)
})
})
@@ -265,22 +277,22 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
if (id === DEFAULT_THEME_ID) {
setStore("colorScheme", "light")
setStore("mode", "light")
- write(STORAGE_KEYS.THEME_ID, id)
- write(STORAGE_KEYS.COLOR_SCHEME, "light")
- clear()
+ write(storageKeys.themeId, id)
+ write(storageKeys.colorScheme, "light")
+ clear(storageKeys)
return
}
void load(id).then((theme) => {
if (!theme || store.themeId !== id) return
- cacheThemeVariants(theme, id)
- write(STORAGE_KEYS.THEME_ID, id)
+ cacheThemeVariants(theme, id, storageKeys)
+ write(storageKeys.themeId, id)
})
}
const setColorScheme = (scheme: ColorScheme) => {
const next = store.themeId === DEFAULT_THEME_ID ? "light" : scheme
setStore("colorScheme", next)
- write(STORAGE_KEYS.COLOR_SCHEME, next)
+ write(storageKeys.colorScheme, next)
setStore("mode", resolveMode(store.themeId, next))
}
diff --git a/packages/ui/src/theme/index.ts b/packages/ui/src/theme/index.ts
index d7fe4cbfe..716dc0d1b 100644
--- a/packages/ui/src/theme/index.ts
+++ b/packages/ui/src/theme/index.ts
@@ -31,6 +31,6 @@ export {
export { resolveThemeVariant, resolveTheme, themeToCss } from "./resolve"
export { applyTheme, loadThemeFromUrl, getActiveTheme, removeTheme, setColorScheme } from "./loader"
-export { ThemeProvider, useTheme, type ColorScheme } from "./context"
+export { ThemeProvider, useTheme, type ColorScheme, type ThemeStorageKeys } from "./context"
export { DEFAULT_THEMES, pawworkTheme } from "./default-themes"
diff --git a/packages/ui/src/theme/loader.ts b/packages/ui/src/theme/loader.ts
index 4d48000da..4ad15a509 100644
--- a/packages/ui/src/theme/loader.ts
+++ b/packages/ui/src/theme/loader.ts
@@ -2,7 +2,7 @@ import type { DesktopTheme, ResolvedTheme } from "./types"
import { resolveThemeVariant, themeToCss } from "./resolve"
let activeTheme: DesktopTheme | null = null
-const THEME_STYLE_ID = "opencode-theme"
+const THEME_STYLE_ID = "oc-theme"
function ensureLoaderStyleElement(): HTMLStyleElement {
const existing = document.getElementById(THEME_STYLE_ID) as HTMLStyleElement | null