diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx
index a57c3042fb..5a5414c472 100644
--- a/packages/app/src/components/session/session-header.tsx
+++ b/packages/app/src/components/session/session-header.tsx
@@ -39,6 +39,7 @@ import { useServerSync } from "@/context/server-sync"
import { useGlobal } from "@/context/global"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { sessionListDirectories, sortedRootSessions } from "@/pages/layout/helpers"
+import { sessionListState } from "@/utils/session-list-state"
import { useNavigate } from "@solidjs/router"
import type { Session } from "@opencode-ai/sdk/v2/client"
@@ -759,6 +760,30 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {})
}
})
+ // D2 honest states: "not yet fetched" (a completed list request is the only
+ // authority for "genuinely empty") vs "empty" vs ready — never render the
+ // empty state while no fetch has resolved (#293's invisible failure).
+ const activeListState = createMemo(() => {
+ if (!open()) return "ready" as const
+ try {
+ const conn = server.current
+ if (!conn) return "unfetched" as const
+ const ctx = globalCtx.ensureServerCtx(conn)
+ if (!ctx) return "unfetched" as const
+ const directories = sessionListDirectories(ctx.projects.list(), ctx.sync.data?.project ?? [])
+ let fetched = false
+ let count = 0
+ for (const dir of directories) {
+ const [store] = ctx.sync.child(dir, { bootstrap: false })
+ if (store.sessions_fetched) fetched = true
+ count += store.session?.length ?? 0
+ }
+ return sessionListState({ fetched, count, searching: !!searchQuery() })
+ } catch {
+ return "unfetched" as const
+ }
+ })
+
// Sort: open-tab sessions first
const sortedActiveSessions = createMemo(() => {
if (!open()) return []
@@ -1071,7 +1096,11 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {})
when={filteredActiveSessions().length > 0}
fallback={
- {searchQuery() ? language.t("home.sessions.search.noResults", { query: search() }) : language.t("home.sessions.empty")}
+ {searchQuery()
+ ? language.t("home.sessions.search.noResults", { query: search() })
+ : activeListState() === "unfetched"
+ ? language.t("common.loading")
+ : language.t("home.sessions.empty")}
}
>
diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts
index 9ebd27d984..ff01da54f5 100644
--- a/packages/app/src/context/global-sync/child-store.ts
+++ b/packages/app/src/context/global-sync/child-store.ts
@@ -10,9 +10,11 @@ import {
type IconCache,
type MetaCache,
type ProjectMeta,
+ type SessionSnapshotCache,
type State,
type VcsCache,
} from "./types"
+import type { SessionSnapshot } from "./session-snapshot"
import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
import { useQuery } from "@tanstack/solid-query"
import { QueryOptionsApi } from "../server-sync"
@@ -39,6 +41,7 @@ export function createChildStoreManager(input: {
const vcsCache = new Map()
const metaCache = new Map()
const iconCache = new Map()
+ const snapshotCache = new Map()
const lifecycle = new Map()
const pins = new Map()
const ownerPins = new WeakMap>()
@@ -117,6 +120,7 @@ export function createChildStoreManager(input: {
vcsCache.delete(key)
metaCache.delete(key)
iconCache.delete(key)
+ snapshotCache.delete(key)
lifecycle.delete(key)
mcpDirectories.delete(key)
mcpToggles.delete(key)
@@ -181,6 +185,19 @@ export function createChildStoreManager(input: {
if (!icon) throw new Error(input.translate("error.childStore.persistedProjectIconCreateFailed"))
iconCache.set(key, { store: icon[0], setStore: icon[1], ready: icon[3] })
+ // D2: the persisted session snapshot is a render accelerator, never an
+ // authority — hydrated only until the first real list fetch resolves,
+ // and verified against the server's currency token on every boot.
+ const sessionSnapshot = runWithOwner(input.owner, () =>
+ input.persist(Persist.serverWorkspace(input.scope, directory, "session:snapshot"), createStore({ value: undefined as SessionSnapshot | undefined })),
+ )
+ if (!sessionSnapshot) throw new Error(input.translate("error.childStore.persistedCacheCreateFailed"))
+ snapshotCache.set(key, {
+ store: sessionSnapshot[0],
+ setStore: sessionSnapshot[1],
+ ready: sessionSnapshot[3],
+ })
+
const init = () =>
createRoot((dispose) => {
const initialMeta = meta[0].value
@@ -228,6 +245,7 @@ export function createChildStoreManager(input: {
},
session: [],
sessionTotal: 0,
+ sessions_fetched: false,
session_status: {},
session_working(id: string) {
const type = this.session_status[id]?.type
@@ -288,6 +306,16 @@ export function createChildStoreManager(input: {
if (child[0].icon !== initialIcon) return
child[1]("icon", icon[0].value)
})
+
+ // D2: hydrate the persisted snapshot as a render accelerator — only
+ // until a real list fetch resolves (sessions_fetched flips true),
+ // never over a store a fetch already filled.
+ onPersistedInit(sessionSnapshot[2], () => {
+ if (child[0].sessions_fetched) return
+ const cached = sessionSnapshot[0].value
+ if (!cached || child[0].session.length > 0) return
+ child[1]("session", cached.sessions)
+ })
})
runWithOwner(input.owner, init)
@@ -393,5 +421,21 @@ export function createChildStoreManager(input: {
vcsCache,
metaCache,
iconCache,
+ // D2: the persisted snapshot's read/write seam — server-sync verifies the
+ // token on every list response and overwrites the snapshot; the panel
+ // reset invalidates all of them.
+ sessionSnapshot(directory: string) {
+ return snapshotCache.get(directoryKey(directory))?.store.value
+ },
+ writeSessionSnapshot(directory: string, next: SessionSnapshot) {
+ const cache = snapshotCache.get(directoryKey(directory))
+ if (!cache) return
+ cache.setStore("value", next)
+ },
+ resetSessionSnapshots() {
+ for (const cache of snapshotCache.values()) {
+ cache.setStore("value", undefined)
+ }
+ },
}
}
diff --git a/packages/app/src/context/global-sync/h1-client-boot.test.ts b/packages/app/src/context/global-sync/h1-client-boot.test.ts
new file mode 100644
index 0000000000..6eb149b53f
--- /dev/null
+++ b/packages/app/src/context/global-sync/h1-client-boot.test.ts
@@ -0,0 +1,77 @@
+import { describe, expect, test } from "bun:test"
+import { bootClient, createSeededHub, memorySessionStorage } from "./h1-client-boot"
+
+const session = (id: string, updated: number) => ({
+ id,
+ directory: "/home",
+ projectID: "p1",
+ slug: id,
+ version: "test",
+ title: `Session ${id}`,
+ time: { created: updated, updated },
+})
+
+const hubSessions = () => [session("ses_a", 100), session("ses_b", 200)]
+
+describe("H1 — the fresh-client boot harness (#288's headless boot, hub request log)", () => {
+ test("a fresh client boots with the session-list fetch initiated before the list first renders", async () => {
+ const hub = createSeededHub({ sessions: hubSessions(), currency: "v1.4.200.200.build-a" })
+ const storage = memorySessionStorage()
+
+ const boot = await bootClient({ hub, storage, directory: "/home" })
+
+ // The criterion is measured on the hub's request log, the same evidence
+ // that diagnosed #293: at least one session-list request in the boot
+ // window.
+ expect(boot.sessionListRequests.length).toBeGreaterThanOrEqual(1)
+ expect(boot.fetchedBeforeFirstRender).toBe(true)
+ // The fetched rows are adopted and the state is honest: a resolved fetch
+ // over a populated projection renders ready.
+ expect(boot.rendered.map((item) => item.id)).toEqual(["ses_b", "ses_a"])
+ expect(boot.state).toBe("ready")
+ // The snapshot layer now holds the fetched rows plus the token.
+ const persisted = storage.read("/home")
+ expect(persisted?.currency).toBe("v1.4.200.200.build-a")
+ expect(persisted?.sessions).toHaveLength(2)
+ })
+
+ test("a client seeded with the #293 stale-storage shape self-heals on boot", async () => {
+ const hub = createSeededHub({ sessions: hubSessions(), currency: "v1.4.200.200.build-b" })
+ const storage = memorySessionStorage({
+ "/home": { sessions: [session("ses_stale", 1)], currency: "v1.4.200.200.build-a" },
+ })
+
+ const boot = await bootClient({ hub, storage, directory: "/home" })
+
+ expect(boot.selfHealed).toBe(true)
+ // The stale snapshot never wins: the rendered list is the hub's.
+ expect(boot.rendered.map((item) => item.id)).toEqual(["ses_b", "ses_a"])
+ expect(boot.state).toBe("ready")
+ // The persisted snapshot was re-primed from the server's truth.
+ expect(storage.read("/home")?.currency).toBe("v1.4.200.200.build-b")
+ })
+
+ test("a tokenless persisted snapshot (the founding shape) is invalidated too", async () => {
+ const hub = createSeededHub({ sessions: [], currency: "v1.4.200.200.build-b" })
+ const storage = memorySessionStorage({ "/home": { sessions: [session("ses_stale", 1)] } })
+
+ const boot = await bootClient({ hub, storage, directory: "/home" })
+
+ expect(boot.selfHealed).toBe(true)
+ expect(boot.rendered).toHaveLength(0)
+ // Honest states: a resolved fetch over an empty projection is genuinely
+ // empty — never "not yet fetched".
+ expect(boot.state).toBe("empty")
+ })
+
+ test("a fresh snapshot is not flagged as self-healed", async () => {
+ const currency = "v1.4.200.200.build-a"
+ const hub = createSeededHub({ sessions: hubSessions(), currency })
+ const storage = memorySessionStorage({ "/home": { sessions: hubSessions(), currency } })
+
+ const boot = await bootClient({ hub, storage, directory: "/home" })
+
+ expect(boot.selfHealed).toBe(false)
+ expect(boot.rendered).toHaveLength(2)
+ })
+})
diff --git a/packages/app/src/context/global-sync/h1-client-boot.ts b/packages/app/src/context/global-sync/h1-client-boot.ts
new file mode 100644
index 0000000000..e8c5f8db79
--- /dev/null
+++ b/packages/app/src/context/global-sync/h1-client-boot.ts
@@ -0,0 +1,150 @@
+import type { Session } from "@opencode-ai/sdk/v2/client"
+import { bootCurrencyDecision, toSnapshot, type SessionSnapshot } from "./session-snapshot"
+import { sessionListState, type SessionListState } from "@/utils/session-list-state"
+
+/**
+ * H1 — the fresh-client boot harness (#288's headless boot).
+ *
+ * Boots the client's session-list layer against a seeded hub and asserts on
+ * the hub's request log — the same evidence that diagnosed #293. Headless by
+ * design (no DOM): it composes the real boot pieces — persisted-snapshot
+ * hydration, the unconditional boot fetch, bootCurrencyDecision, the honest
+ * sessionListState machine — in the boot order the D2 contract requires. The
+ * fetch-before-first-render and boot-self-heal criteria's tests wire through
+ * this fixture.
+ */
+
+export type HubRequest = {
+ endpoint: "session.list"
+ directory?: string
+ limit?: number
+ order?: string
+ cursor?: string
+ /** Monotonic tick — boot ordering assertions read this. */
+ at: number
+}
+
+export type SeededHub = {
+ /** The v2 session.list surface, shaped like the real client's. */
+ api: { list: (input: { directory: string; parentID: null; limit: number; order: "desc"; cursor?: string }) => Promise<{ data: Session[]; cursor: { next?: string }; currency?: string | null }> }
+ /** Every request the hub saw, in order. */
+ log: HubRequest[]
+ sessions: Session[]
+ currency: string | undefined
+}
+
+export function createSeededHub(input: { sessions: Session[]; currency?: string; pageSize?: number }): SeededHub {
+ const log: HubRequest[] = []
+ let tick = 0
+ const pageSize = input.pageSize ?? input.sessions.length
+ // desc store: newest first.
+ const ordered = input.sessions.toReversed()
+ return {
+ log,
+ sessions: input.sessions,
+ currency: input.currency,
+ api: {
+ list: async (request) => {
+ const start = request.cursor ? Number(request.cursor) : 0
+ const page = ordered.slice(start, start + (pageSize || input.sessions.length))
+ const next = start + page.length < input.sessions.length && page.length > 0 ? String(start + page.length) : undefined
+ log.push({
+ endpoint: "session.list",
+ directory: request.directory,
+ limit: request.limit,
+ order: request.order,
+ cursor: request.cursor,
+ at: tick++,
+ })
+ return { data: page, cursor: { next }, currency: input.currency }
+ },
+ },
+ }
+}
+
+export type PersistedSessionStorage = {
+ read: (key: string) => SessionSnapshot | undefined
+ write: (key: string, snapshot: SessionSnapshot | undefined) => void
+}
+
+export function memorySessionStorage(seed: Record = {}): PersistedSessionStorage {
+ const store = new Map(Object.entries(seed))
+ return {
+ read: (key) => structuredClone(store.get(key)),
+ write: (key, snapshot) => {
+ if (snapshot === undefined) store.delete(key)
+ else store.set(key, structuredClone(snapshot))
+ },
+ }
+}
+
+export type BootResult = {
+ /** Every session-list request the hub saw during the boot window. */
+ sessionListRequests: HubRequest[]
+ /** The rendered projection after the boot fetch resolved. */
+ rendered: Session[]
+ /** The session-list fetch was initiated before the list first rendered. */
+ fetchedBeforeFirstRender: boolean
+ /** A seeded snapshot was proven stale (the #293 shape) and invalidated. */
+ selfHealed: boolean
+ /** The honest list state after boot. */
+ state: SessionListState
+}
+
+/** The persisted snapshot store is per-workspace; the harness keys it by the
+ * booted directory (the real client namespaces this inside its workspace
+ * storage as the `session:snapshot` target). */
+const snapshotKey = (directory: string) => directory
+
+export async function bootClient(input: {
+ hub: SeededHub
+ storage: PersistedSessionStorage
+ directory: string
+}): Promise {
+ const key = snapshotKey(input.directory)
+ const timeline: string[] = []
+
+ // Boot order per D2: hydrate the persisted snapshot (accelerator), initiate
+ // the session-list fetch BEFORE the list first renders, then render.
+ timeline.push("snapshot-hydrated")
+ const snapshot = input.storage.read(key)
+
+ // The fetch is initiated unconditionally — a persisted snapshot is never an
+ // authority, so no boot may skip it (the founding #293 failure).
+ let fetchFailure: unknown
+ const pending = input.hub.api
+ .list({ directory: input.directory, parentID: null, limit: 100, order: "desc" })
+ .then((response) => ({ response }))
+ .catch((error) => {
+ fetchFailure = error
+ return undefined
+ })
+ timeline.push("fetch-initiated")
+
+ // First render: the snapshot as accelerator, or the honest unfetched state.
+ timeline.push("first-render")
+ const firstRendered = snapshot?.sessions ?? []
+ void firstRendered
+
+ const settled = await pending
+ timeline.push("fetch-resolved")
+ if (fetchFailure !== undefined) throw fetchFailure
+
+ const response = settled!.response
+ const decision = bootCurrencyDecision({ snapshot, response: { sessions: response.data, currency: response.currency } })
+ // Invalidation is materialized by the overwrite: the fetched rows are the
+ // authority and the snapshot layer is re-primed from them.
+ input.storage.write(key, toSnapshot(response.data, decision.currency))
+
+ return {
+ sessionListRequests: input.hub.log.filter((request) => request.endpoint === "session.list"),
+ rendered: response.data,
+ // The criterion measured on the boot order: the fetch was initiated
+ // before the list first rendered — and the hub's log carries it.
+ fetchedBeforeFirstRender:
+ timeline.indexOf("fetch-initiated") < timeline.indexOf("first-render") &&
+ input.hub.log.some((request) => request.endpoint === "session.list"),
+ selfHealed: decision.stale,
+ state: sessionListState({ fetched: true, count: response.data.length }),
+ }
+}
diff --git a/packages/app/src/context/global-sync/home-session-index.test.ts b/packages/app/src/context/global-sync/home-session-index.test.ts
index 4e40cc78ea..3842a16574 100644
--- a/packages/app/src/context/global-sync/home-session-index.test.ts
+++ b/packages/app/src/context/global-sync/home-session-index.test.ts
@@ -152,3 +152,75 @@ describe("Home V2 session index", () => {
expect(homeSessionIndexRefresh("session.next.moved", true).refetch).toBe(true)
})
})
+
+describe("Home boot fetch pagination (D2/D4: recent tail first, continues paging)", () => {
+ function seed(count: number): SessionV2Info[] {
+ // time_created ascending with index; the store's newest session is last.
+ return Array.from({ length: count }, (_, index) =>
+ session({ id: `ses_${String(index).padStart(6, "0")}`, updated: 1_000 + index }),
+ ) as SessionV2Info[]
+ }
+
+ test("pages a 1250-session store desc-first via continuation cursors until exhausted", async () => {
+ const store = seed(1250)
+ const pages: { order?: string; cursor?: string }[] = []
+ const PAGE_SIZE = 500 // a hub that caps page size below the requested limit
+ const list = async (input: { limit: number; order?: "asc" | "desc"; cursor?: string }) => {
+ pages.push({ order: input.order, cursor: input.cursor })
+ const start = input.cursor ? Number(input.cursor) : 0
+ // The store answers desc: the newest (recent tail) first.
+ const page = store.toReversed().slice(start, start + PAGE_SIZE)
+ const next = start + PAGE_SIZE < store.length ? String(start + PAGE_SIZE) : undefined
+ return { data: { data: page, cursor: { next } } }
+ }
+
+ const result = await loadHomeSessionIndex(list as never)
+
+ // The recent tail arrives first: page one holds the newest sessions.
+ expect(pages[0]!.order).toBe("desc")
+ expect(pages[0]!.cursor).toBeUndefined()
+ // 1250 sessions at 500/page take exactly three pages.
+ expect(pages).toHaveLength(3)
+ expect(pages[1]!.cursor).toBeDefined()
+ expect(pages[2]!.cursor).toBeDefined()
+ // The full store is assembled, newest session included.
+ expect(result.sessions).toHaveLength(1250)
+ const newest = store[store.length - 1]!.id
+ expect(result.sessions.some((item) => item.id === newest)).toBe(true)
+ })
+
+ test("a store smaller than one page needs exactly one request", async () => {
+ const store = seed(12)
+ let calls = 0
+ const list = async (input: { limit: number }) => {
+ calls++
+ return { data: { data: store.toReversed().slice(0, input.limit), cursor: {} } }
+ }
+
+ const result = await loadHomeSessionIndex(list as never)
+
+ expect(calls).toBe(1)
+ expect(result.sessions).toHaveLength(12)
+ })
+
+ test("respects an abort signal between pages", async () => {
+ const store = seed(HOME_V2_SESSION_PAGE_LIMIT * 2)
+ const controller = new AbortController()
+ let calls = 0
+ const list = async (
+ input: { limit: number; order?: "asc" | "desc"; cursor?: string },
+ options?: { signal?: AbortSignal },
+ ) => {
+ calls++
+ if (calls === 2) controller.abort()
+ if (options?.signal?.aborted) throw new Error("aborted")
+ const start = input.cursor ? Number(input.cursor) : 0
+ const page = store.toReversed().slice(start, start + HOME_V2_SESSION_PAGE_LIMIT)
+ const next = start + HOME_V2_SESSION_PAGE_LIMIT < store.length ? String(start + HOME_V2_SESSION_PAGE_LIMIT) : undefined
+ return { data: { data: page, cursor: { next } } }
+ }
+
+ await expect(loadHomeSessionIndex(list as never, 0, controller.signal)).rejects.toThrow("aborted")
+ expect(calls).toBe(2)
+ })
+})
diff --git a/packages/app/src/context/global-sync/home-session-index.ts b/packages/app/src/context/global-sync/home-session-index.ts
index 03a085e34d..0cab2a0bdc 100644
--- a/packages/app/src/context/global-sync/home-session-index.ts
+++ b/packages/app/src/context/global-sync/home-session-index.ts
@@ -45,8 +45,11 @@ export async function loadHomeSessionIndex(
)
const page = response.data!
data.push(...page.data)
- if (page.data.length < HOME_V2_SESSION_PAGE_LIMIT || !page.cursor.next)
- return { sessions: parseHomeSessionIndex(data), eventSequence }
+ // The cursor is the only exhaustion signal: a page shorter than the
+ // requested limit does NOT mean the store ended — a hub that caps page
+ // size below the request would otherwise silently drop everything past
+ // its first short page.
+ if (!page.cursor.next) return { sessions: parseHomeSessionIndex(data), eventSequence }
cursor = page.cursor.next
}
}
diff --git a/packages/app/src/context/global-sync/session-load.ts b/packages/app/src/context/global-sync/session-load.ts
index 46d8ec6a05..cf92bedf91 100644
--- a/packages/app/src/context/global-sync/session-load.ts
+++ b/packages/app/src/context/global-sync/session-load.ts
@@ -1,28 +1,52 @@
import type { SessionApi } from "@opencode-ai/client/promise"
import { normalizeSessionInfo } from "@/utils/session"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
+import type { Session } from "@opencode-ai/sdk/v2/client"
-export async function loadRootSessions(input: { api: Pick; directory: string; limit: number }) {
+/** One page of the boot session-list fetch. `currency` carries the server's
+ * derived currency token (D2) — undefined for v1 hubs, which predate it. */
+export type RootSessionsPage = {
+ readonly data: Session[]
+ readonly limit: number
+ readonly limited: boolean
+ readonly currency: string | undefined
+}
+
+export async function loadRootSessions(input: {
+ api: Pick
+ directory: string
+ limit: number
+}): Promise {
const result = await input.api.list({
directory: input.directory,
parentID: null,
limit: input.limit,
order: "desc",
})
+ // D2: the derived currency token rides the response so the client can
+ // verify its persisted snapshot against the server on boot. The vendored
+ // client's generated types predate the additive field — read it
+ // structurally until the vendored snapshot is refreshed.
+ const currency = (result as { currency?: string | null }).currency ?? undefined
return {
data: result.data.map(normalizeSessionInfo),
limit: input.limit,
limited: true,
- } as const
+ currency,
+ }
}
-export async function loadRootSessionsV1(input: { client: OpencodeClient; directory: string; limit: number }) {
+export async function loadRootSessionsV1(input: {
+ client: OpencodeClient
+ directory: string
+ limit: number
+}): Promise {
try {
const result = await input.client.session.list({ directory: input.directory, roots: true, limit: input.limit })
- return { data: result.data, limit: input.limit, limited: true } as const
+ return { data: result.data ?? [], limit: input.limit, limited: true, currency: undefined }
} catch {
const result = await input.client.session.list({ directory: input.directory, roots: true })
- return { data: result.data, limit: input.limit, limited: false } as const
+ return { data: result.data ?? [], limit: input.limit, limited: false, currency: undefined }
}
}
diff --git a/packages/app/src/context/global-sync/session-snapshot.test.ts b/packages/app/src/context/global-sync/session-snapshot.test.ts
new file mode 100644
index 0000000000..cf8b8ad7ac
--- /dev/null
+++ b/packages/app/src/context/global-sync/session-snapshot.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, test } from "bun:test"
+import { bootCurrencyDecision, toSnapshot, type SessionSnapshot } from "./session-snapshot"
+
+const session = (id: string) => ({
+ id,
+ directory: "/home",
+ projectID: "p1",
+ slug: id,
+ version: "test",
+ title: `Session ${id}`,
+ time: { created: 1, updated: 1 },
+})
+
+describe("bootCurrencyDecision (D2: the #293 stale-storage shape self-heals on boot)", () => {
+ test("a snapshot with a stale token is proven stale and the fetched rows are adopted", () => {
+ const snapshot: SessionSnapshot = { sessions: [session("old")], currency: "v1.3.100.100.build-a" }
+ const decision = bootCurrencyDecision({
+ snapshot,
+ response: { sessions: [session("fresh")], currency: "v1.4.200.200.build-a" },
+ })
+
+ expect(decision.adopt).toBe(true)
+ expect(decision.stale).toBe(true)
+ expect(decision.currency).toBe("v1.4.200.200.build-a")
+ })
+
+ test("a snapshot written without a token (the #293 shape) cannot be trusted", () => {
+ const snapshot: SessionSnapshot = { sessions: [], currency: undefined }
+ const decision = bootCurrencyDecision({
+ snapshot,
+ response: { sessions: [session("fresh")], currency: "v1.4.200.200.build-a" },
+ })
+
+ expect(decision.stale).toBe(true)
+ expect(decision.adopt).toBe(true)
+ })
+
+ test("a matching token means the snapshot was fresh", () => {
+ const snapshot: SessionSnapshot = { sessions: [session("old")], currency: "v1.4.200.200.build-a" }
+ const decision = bootCurrencyDecision({
+ snapshot,
+ response: { sessions: [session("old")], currency: "v1.4.200.200.build-a" },
+ })
+
+ expect(decision.stale).toBe(false)
+ expect(decision.currency).toBe("v1.4.200.200.build-a")
+ })
+
+ test("a hub without a currency token (additive base default) cannot be verified", () => {
+ const snapshot: SessionSnapshot = { sessions: [session("old")], currency: "v1.4.200.200.build-a" }
+ const decision = bootCurrencyDecision({ snapshot, response: { sessions: [session("fresh")] } })
+
+ expect(decision.stale).toBe(false)
+ expect(decision.currency).toBeUndefined()
+ })
+
+ test("a fresh client with no snapshot adopts the fetched rows without a verdict", () => {
+ const decision = bootCurrencyDecision({
+ response: { sessions: [session("fresh")], currency: "v1.4.200.200.build-a" },
+ })
+
+ expect(decision.adopt).toBe(true)
+ expect(decision.stale).toBe(false)
+ expect(decision.currency).toBe("v1.4.200.200.build-a")
+ })
+
+ test("toSnapshot shapes what gets persisted: rows plus the token", () => {
+ const snapshot = toSnapshot([session("a"), session("b")], "v1.4.200.200.build-a")
+ expect(snapshot.sessions).toHaveLength(2)
+ expect(snapshot.currency).toBe("v1.4.200.200.build-a")
+ })
+})
diff --git a/packages/app/src/context/global-sync/session-snapshot.ts b/packages/app/src/context/global-sync/session-snapshot.ts
new file mode 100644
index 0000000000..139c7eecb7
--- /dev/null
+++ b/packages/app/src/context/global-sync/session-snapshot.ts
@@ -0,0 +1,45 @@
+import type { Session } from "@opencode-ai/sdk/v2/client"
+
+/**
+ * D2 (spec spec-20260905-045114-session-device-lifecycle): the persisted
+ * session snapshot is a render accelerator, never an authority. Every boot
+ * compares the snapshot's derived currency token against the server's; a
+ * stale or absent token marks the snapshot for invalidation, so the #293
+ * stale-storage shape self-heals with zero manual action.
+ */
+
+export type SessionSnapshot = {
+ sessions: Session[]
+ currency?: string
+}
+
+export type BootCurrencyDecision = {
+ /** The fetched response is always adopted — it is the authority. */
+ adopt: boolean
+ /** The persisted snapshot contradicts the server (stale or tokenless) and
+ * must be invalidated (overwritten by the fetched state). */
+ stale: boolean
+ /** The token to persist alongside the adopted rows; undefined when the hub
+ * does not yet supply one (additive base default — cannot be verified). */
+ currency: string | undefined
+}
+
+export function bootCurrencyDecision(input: {
+ snapshot?: SessionSnapshot
+ response: { sessions: Session[]; currency?: string | null }
+}): BootCurrencyDecision {
+ const currency = input.response.currency ?? undefined
+ // A mismatch requires the server to have asserted a token: a hub without
+ // one (additive base default absent) cannot be verified either way. A
+ // tokenless snapshot was written by a hub that could not prove its own
+ // currency — the founding #293 shape — and reads as stale on first proof.
+ const stale =
+ currency !== undefined &&
+ input.snapshot !== undefined &&
+ (input.snapshot.currency === undefined || input.snapshot.currency !== currency)
+ return { adopt: true, stale, currency }
+}
+
+export function toSnapshot(sessions: Session[], currency: string | undefined): SessionSnapshot {
+ return { sessions, currency }
+}
diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts
index af9824c588..c669d22186 100644
--- a/packages/app/src/context/global-sync/types.ts
+++ b/packages/app/src/context/global-sync/types.ts
@@ -16,6 +16,7 @@ import type {
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import type { CommandInfo, McpResource, McpServer, SessionMessageInfo } from "@opencode-ai/client/promise"
+import type { SessionSnapshot } from "./session-snapshot"
import type { Accessor } from "solid-js"
import type { SetStoreFunction, Store } from "solid-js/store"
@@ -44,6 +45,10 @@ export type State = {
path: Path
session: Session[]
sessionTotal: number
+ // D2 honest states: has a session-list fetch completed for this directory?
+ // A persisted snapshot is a render accelerator, never an authority — until a
+ // fetch resolves, the UI renders "not yet fetched", never "empty".
+ sessions_fetched?: boolean
session_status: {
[sessionID: string]: SessionStatus
}
@@ -94,6 +99,12 @@ export type VcsCache = {
ready: Accessor
}
+export type SessionSnapshotCache = {
+ store: Store<{ value: SessionSnapshot | undefined }>
+ setStore: SetStoreFunction<{ value: SessionSnapshot | undefined }>
+ ready: Accessor
+}
+
export type MetaCache = {
store: Store<{ value: ProjectMeta | undefined }>
setStore: SetStoreFunction<{ value: ProjectMeta | undefined }>
diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx
index b9140d78a5..6a49ad1fcc 100644
--- a/packages/app/src/context/server-sync.tsx
+++ b/packages/app/src/context/server-sync.tsx
@@ -28,6 +28,7 @@ import {
import { createChildStoreManager } from "./global-sync/child-store"
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
import { estimateRootSessionTotal, loadRootSessions, loadRootSessionsV1 } from "./global-sync/session-load"
+import { bootCurrencyDecision, toSnapshot } from "./global-sync/session-snapshot"
import { trimSessions } from "./global-sync/session-trim"
import type { ProjectMeta } from "./global-sync/types"
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
@@ -404,9 +405,14 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
limit: retainedLimit,
permission: session.data.permission,
})
- if (next.length !== store.session.length) {
- setStore("session", reconcile(next, { key: "id" }))
- }
+ batch(() => {
+ // D2 honest states: a completed list fetch means the UI may render
+ // "genuinely empty" — never "not yet fetched".
+ if (!store.sessions_fetched) setStore("sessions_fetched", true)
+ if (next.length !== store.session.length) {
+ setStore("session", reconcile(next, { key: "id" }))
+ }
+ })
children.unpin(key)
return
}
@@ -447,9 +453,22 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
limited: x.limited,
}),
)
+ // D2 honest states: the fetch resolved (even to empty) — the
+ // UI may now distinguish "genuinely empty" from "not yet
+ // fetched".
+ setStore("sessions_fetched", true)
setStore("session", reconcile(next, { key: "id" }))
})
sessionMeta.set(key, { limit: retained })
+ // D2: verify the persisted snapshot against the server's
+ // derived currency token — a stale or tokenless snapshot is
+ // invalidated by this overwrite, so the #293 shape self-heals
+ // on boot with zero manual action.
+ const decision = bootCurrencyDecision({
+ snapshot: children.sessionSnapshot(directory),
+ response: { sessions: next, currency: x.currency },
+ })
+ children.writeSessionSnapshot(directory, toSnapshot(next, decision.currency))
})
.catch((err) => {
console.error("Failed to load sessions", err)
@@ -659,6 +678,32 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
icon(directory: string, value: string | undefined) {
children.projectIcon(directory, value)
},
+ // D2 in-product reset: clears session caches ONLY — in-memory session
+ // state plus the session-list query keys. Persisted workspace preferences
+ // (settings, archive cutoff, posture config) live in stores this never
+ // touches, so recovery never destroys configuration and never requires
+ // filesystem surgery (#293).
+ resetSessionCaches() {
+ sessionMeta.clear()
+ // D2: the persisted snapshots are session caches — the reset invalidates
+ // every one of them (they re-prime from the next fetch).
+ children.resetSessionSnapshots()
+ for (const child of Object.values(children.children)) {
+ const [store, setStore] = child
+ batch(() => {
+ if (store.sessions_fetched) setStore("sessions_fetched", false)
+ if (store.sessionTotal !== 0) setStore("sessionTotal", 0)
+ setStore("session", reconcile([], { key: "id" }))
+ setStore("session_status", reconcile({}))
+ })
+ }
+ queryClient.removeQueries({
+ predicate: (query) => {
+ const name = (query.queryKey as readonly unknown[])[2]
+ return name === "loadSessions" || name === "activeSessions"
+ },
+ })
+ },
}
const updateConfigMutation = useMutation(() => ({
diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts
index f5b871d14f..70957473c4 100644
--- a/packages/app/src/i18n/ar.ts
+++ b/packages/app/src/i18n/ar.ts
@@ -21,6 +21,7 @@ export const dict = {
"theme.scheme.dark": "داكن",
"command.sidebar.toggle": "تبديل الشريط الجانبي",
"command.project.open": "فتح مشروع",
+ "command.panel.reset": "إعادة تعيين حالة اللوحة",
"command.project.previous": "المشروع السابق",
"command.project.next": "المشروع التالي",
"command.project.index": "التبديل إلى المشروع {{index}}",
diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts
index 8e553fbcd7..dcd1ef6b58 100644
--- a/packages/app/src/i18n/br.ts
+++ b/packages/app/src/i18n/br.ts
@@ -21,6 +21,7 @@ export const dict = {
"theme.scheme.dark": "Escuro",
"command.sidebar.toggle": "Alternar barra lateral",
"command.project.open": "Abrir projeto",
+ "command.panel.reset": "Redefinir estado do painel",
"command.project.previous": "Projeto anterior",
"command.project.next": "Próximo projeto",
"command.project.index": "Alternar para o projeto {{index}}",
diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts
index b1da656f3f..a3255479ff 100644
--- a/packages/app/src/i18n/bs.ts
+++ b/packages/app/src/i18n/bs.ts
@@ -23,6 +23,7 @@ export const dict = {
"command.sidebar.toggle": "Prikaži/sakrij bočnu traku",
"command.project.open": "Otvori projekat",
+ "command.panel.reset": "Resetuj stanje panela",
"command.project.previous": "Prethodni projekat",
"command.project.next": "Sljedeći projekat",
"command.project.index": "Prebaci na projekat {{index}}",
diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts
index 6c8eccd873..4dee8cf357 100644
--- a/packages/app/src/i18n/da.ts
+++ b/packages/app/src/i18n/da.ts
@@ -23,6 +23,7 @@ export const dict = {
"command.sidebar.toggle": "Skift sidebjælke",
"command.project.open": "Åbn projekt",
+ "command.panel.reset": "Nulstil paneltilstand",
"command.project.previous": "Forrige projekt",
"command.project.next": "Næste projekt",
"command.project.index": "Skift til projekt {{index}}",
diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts
index 0fc55e7596..a783f66c94 100644
--- a/packages/app/src/i18n/de.ts
+++ b/packages/app/src/i18n/de.ts
@@ -25,6 +25,7 @@ export const dict = {
"theme.scheme.dark": "Dunkel",
"command.sidebar.toggle": "Seitenleiste umschalten",
"command.project.open": "Projekt öffnen",
+ "command.panel.reset": "Panel-Zustand zurücksetzen",
"command.project.previous": "Vorheriges Projekt",
"command.project.next": "Nächstes Projekt",
"command.project.index": "Zu Projekt {{index}} wechseln",
diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts
index 32dcd9b0ec..93cf874002 100644
--- a/packages/app/src/i18n/en.ts
+++ b/packages/app/src/i18n/en.ts
@@ -23,6 +23,7 @@ export const dict = {
"command.sidebar.toggle": "Toggle sidebar",
"command.project.open": "Open project",
+ "command.panel.reset": "Reset panel state",
"command.project.previous": "Previous project",
"command.project.next": "Next project",
"command.project.index": "Switch to project {{index}}",
diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts
index 72f3152adf..1c0d382e53 100644
--- a/packages/app/src/i18n/es.ts
+++ b/packages/app/src/i18n/es.ts
@@ -23,6 +23,7 @@ export const dict = {
"command.sidebar.toggle": "Alternar barra lateral",
"command.project.open": "Abrir proyecto",
+ "command.panel.reset": "Restablecer estado del panel",
"command.project.previous": "Proyecto anterior",
"command.project.next": "Siguiente proyecto",
"command.project.index": "Cambiar al proyecto {{index}}",
diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts
index ee47be3696..438ad470b1 100644
--- a/packages/app/src/i18n/fr.ts
+++ b/packages/app/src/i18n/fr.ts
@@ -21,6 +21,7 @@ export const dict = {
"theme.scheme.dark": "Sombre",
"command.sidebar.toggle": "Basculer la barre latérale",
"command.project.open": "Ouvrir un projet",
+ "command.panel.reset": "Réinitialiser l'état du panneau",
"command.project.previous": "Projet précédent",
"command.project.next": "Projet suivant",
"command.project.index": "Passer au projet {{index}}",
diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts
index 5789239ec6..c0b167aed3 100644
--- a/packages/app/src/i18n/ja.ts
+++ b/packages/app/src/i18n/ja.ts
@@ -21,6 +21,7 @@ export const dict = {
"theme.scheme.dark": "ダーク",
"command.sidebar.toggle": "サイドバーの切り替え",
"command.project.open": "プロジェクトを開く",
+ "command.panel.reset": "パネル状態をリセット",
"command.project.previous": "前のプロジェクト",
"command.project.next": "次のプロジェクト",
"command.project.index": "プロジェクト{{index}}に切り替え",
diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts
index 3c87105751..69c8e150ee 100644
--- a/packages/app/src/i18n/ko.ts
+++ b/packages/app/src/i18n/ko.ts
@@ -21,6 +21,7 @@ export const dict = {
"theme.scheme.dark": "다크",
"command.sidebar.toggle": "사이드바 토글",
"command.project.open": "프로젝트 열기",
+ "command.panel.reset": "패널 상태 초기화",
"command.provider.connect": "공급자 연결",
"command.server.switch": "서버 전환",
"command.settings.open": "설정 열기",
diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts
index 21fa443890..8fd730ac57 100644
--- a/packages/app/src/i18n/no.ts
+++ b/packages/app/src/i18n/no.ts
@@ -26,6 +26,7 @@ export const dict = {
"command.sidebar.toggle": "Veksle sidepanel",
"command.project.open": "Åpne prosjekt",
+ "command.panel.reset": "Tilbakestill paneltilstand",
"command.provider.connect": "Koble til leverandør",
"command.server.switch": "Bytt server",
"command.settings.open": "Åpne innstillinger",
diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts
index 8bee1740b5..62009b984b 100644
--- a/packages/app/src/i18n/pl.ts
+++ b/packages/app/src/i18n/pl.ts
@@ -21,6 +21,7 @@ export const dict = {
"theme.scheme.dark": "Ciemny",
"command.sidebar.toggle": "Przełącz pasek boczny",
"command.project.open": "Otwórz projekt",
+ "command.panel.reset": "Zresetuj stan panelu",
"command.project.previous": "Poprzedni projekt",
"command.project.next": "Następny projekt",
"command.project.index": "Przełącz na projekt {{index}}",
diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts
index 19de4d5d60..3ef745e683 100644
--- a/packages/app/src/i18n/ru.ts
+++ b/packages/app/src/i18n/ru.ts
@@ -23,6 +23,7 @@ export const dict = {
"command.sidebar.toggle": "Переключить боковую панель",
"command.project.open": "Открыть проект",
+ "command.panel.reset": "Сбросить состояние панели",
"command.project.previous": "Предыдущий проект",
"command.project.next": "Следующий проект",
"command.project.index": "Переключиться на проект {{index}}",
diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts
index 07d1ba248d..682da65285 100644
--- a/packages/app/src/i18n/th.ts
+++ b/packages/app/src/i18n/th.ts
@@ -23,6 +23,7 @@ export const dict = {
"command.sidebar.toggle": "สลับแถบข้าง",
"command.project.open": "เปิดโปรเจกต์",
+ "command.panel.reset": "รีเซ็ตสถานะแผง",
"command.project.previous": "โปรเจกต์ก่อนหน้า",
"command.project.next": "โปรเจกต์ถัดไป",
"command.project.index": "สลับไปยังโปรเจกต์ {{index}}",
diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts
index 09ab55fb90..379c4d6148 100644
--- a/packages/app/src/i18n/tr.ts
+++ b/packages/app/src/i18n/tr.ts
@@ -27,6 +27,7 @@ export const dict = {
"command.sidebar.toggle": "Kenar çubuğunu aç/kapat",
"command.project.open": "Proje aç",
+ "command.panel.reset": "Panel durumunu sıfırla",
"command.project.previous": "Önceki proje",
"command.project.next": "Sonraki proje",
"command.project.index": "{{index}} numaralı projeye geç",
diff --git a/packages/app/src/i18n/uk.ts b/packages/app/src/i18n/uk.ts
index 43aa325a3f..f1953ab833 100644
--- a/packages/app/src/i18n/uk.ts
+++ b/packages/app/src/i18n/uk.ts
@@ -23,6 +23,7 @@ export const dict = {
"command.sidebar.toggle": "Перемкнути бічну панель",
"command.project.open": "Відкрити проєкт",
+ "command.panel.reset": "Скинути стан панелі",
"command.project.previous": "Попередній проєкт",
"command.project.next": "Наступний проєкт",
"command.project.index": "Перемкнути на проєкт {{index}}",
diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts
index adfcff20e6..d98871fddd 100644
--- a/packages/app/src/i18n/zh.ts
+++ b/packages/app/src/i18n/zh.ts
@@ -28,6 +28,7 @@ export const dict = {
"command.sidebar.toggle": "切换侧边栏",
"command.project.open": "打开项目",
+ "command.panel.reset": "重置面板状态",
"command.project.previous": "上一个项目",
"command.project.next": "下一个项目",
"command.project.index": "切换到项目 {{index}}",
diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts
index 83bc71249b..841c5c93ec 100644
--- a/packages/app/src/i18n/zht.ts
+++ b/packages/app/src/i18n/zht.ts
@@ -27,6 +27,7 @@ export const dict = {
"command.sidebar.toggle": "切換側邊欄",
"command.project.open": "開啟專案",
+ "command.panel.reset": "重置面板狀態",
"command.project.previous": "上一個專案",
"command.project.next": "下一個專案",
"command.project.index": "切換至專案 {{index}}",
diff --git a/packages/app/src/pages/home/home-sessions-controller.tsx b/packages/app/src/pages/home/home-sessions-controller.tsx
index a12e9800e9..7c7a60c505 100644
--- a/packages/app/src/pages/home/home-sessions-controller.tsx
+++ b/packages/app/src/pages/home/home-sessions-controller.tsx
@@ -17,6 +17,7 @@ import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import { sessionHasOpenTab, useTabs } from "@/context/tabs"
import { displayName, errorMessage, projectForSession } from "@/pages/layout/helpers"
+import { sessionListState } from "@/utils/session-list-state"
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
import { pathKey } from "@/utils/path-key"
import { showToast } from "@/utils/toast"
@@ -173,6 +174,15 @@ export function createHomeSessionsController(home: HomeController) {
records,
groups,
loading: () => sessionLoad.isLoading,
+ // D2 honest states: the home list distinguishes "not yet fetched" (no
+ // index fetch has ever resolved) from "genuinely empty" (it has, and
+ // the projection is empty) — never render the empty state during the
+ // boot fetch (#293's invisible failure shape).
+ listState: () =>
+ sessionListState({
+ fetched: sessionLoad.isSuccess,
+ count: records().length,
+ }),
searchRecords: allRecords,
},
session: {
diff --git a/packages/app/src/pages/home/home-sessions-view.tsx b/packages/app/src/pages/home/home-sessions-view.tsx
index 438267c1b2..1c7af954f1 100644
--- a/packages/app/src/pages/home/home-sessions-view.tsx
+++ b/packages/app/src/pages/home/home-sessions-view.tsx
@@ -10,6 +10,7 @@ import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import { SessionTabAvatarView } from "@/pages/layout/session-tab-avatar"
import { sessionTitle } from "@/utils/session-title"
+import { type SessionListState } from "@/utils/session-list-state"
import { shouldOpenSessionInBackground } from "../home-session-open"
import {
HomeSessionStatusController,
@@ -39,6 +40,7 @@ function isBackgroundOpen(event: MouseEvent) {
export type HomeSessionsViewProps = {
language: ReturnType
groups: Accessor
+ listState: Accessor
showProjectName: Accessor
server: Accessor
canCreateSession: Accessor
@@ -113,7 +115,7 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
}
>
0}
+ when={props.groups().length > 0 || props.listState() === "unfetched"}
fallback={
}
>
+
+
+
+ }
+ >
{(group, index) => (
@@ -140,6 +150,7 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
)}
+
diff --git a/packages/app/src/pages/home/home-sessions.tsx b/packages/app/src/pages/home/home-sessions.tsx
index 7bda4dd363..59fcc6b744 100644
--- a/packages/app/src/pages/home/home-sessions.tsx
+++ b/packages/app/src/pages/home/home-sessions.tsx
@@ -12,6 +12,7 @@ export function HomeSessions(props: {
{
const commands: CommandOption[] = [
+ {
+ // D2: in-product recovery — clears session caches ONLY; workspace
+ // preferences survive. Never requires filesystem surgery (#293).
+ id: "panel.reset",
+ title: language.t("command.panel.reset"),
+ category: language.t("command.category.view"),
+ onSelect: async () => {
+ serverSync().project.resetSessionCaches()
+ },
+ },
{
id: "sidebar.toggle",
title: language.t("command.sidebar.toggle"),
diff --git a/packages/app/src/utils/session-list-state.test.ts b/packages/app/src/utils/session-list-state.test.ts
new file mode 100644
index 0000000000..d8f3e94558
--- /dev/null
+++ b/packages/app/src/utils/session-list-state.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, test } from "bun:test"
+import { classifyResetTarget, panelResetTouches, sessionListState } from "./session-list-state"
+
+describe("sessionListState (D2: honest states)", () => {
+ test("renders 'not yet fetched' while the list fetch is in flight", () => {
+ expect(sessionListState({ fetched: false, count: 0 })).toBe("unfetched")
+ })
+
+ test("renders 'genuinely empty' only once a fetch has completed", () => {
+ expect(sessionListState({ fetched: true, count: 0 })).toBe("empty")
+ })
+
+ test("a populated list is ready regardless of fetch bookkeeping", () => {
+ expect(sessionListState({ fetched: false, count: 3 })).toBe("ready")
+ expect(sessionListState({ fetched: true, count: 3 })).toBe("ready")
+ })
+
+ test("a search never shows the unfetched state (results are filtered, not loading)", () => {
+ expect(sessionListState({ fetched: false, count: 0, searching: true })).toBe("empty")
+ })
+
+ test("home contract: the index query's isSuccess is the fetched source", () => {
+ // Boot: the home session-index query is pending and has never succeeded.
+ expect(sessionListState({ fetched: false, count: 0 })).toBe("unfetched")
+ // After the first successful fetch (even a refetch with data present),
+ // an empty projection is genuinely empty.
+ expect(sessionListState({ fetched: true, count: 0 })).toBe("empty")
+ })
+})
+
+describe("panel reset scope (D2: clears session caches only)", () => {
+ test("session-scoped cache keys are cleared", () => {
+ expect(classifyResetTarget("session:ses_1:layout")).toBe("session-cache")
+ expect(classifyResetTarget("session:ses_1:comments")).toBe("session-cache")
+ })
+
+ test("workspace preferences survive the reset", () => {
+ expect(classifyResetTarget("workspace:settings")).toBe("workspace-pref")
+ expect(classifyResetTarget("workspace:vcs")).toBe("workspace-pref")
+ expect(classifyResetTarget("workspace:project")).toBe("workspace-pref")
+ expect(classifyResetTarget("settings.v3")).toBe("workspace-pref")
+ })
+
+ test("user drafts are never destroyed by a reset", () => {
+ expect(classifyResetTarget("session:ses_1:prompt", { draft: true })).toBe("workspace-pref")
+ })
+
+ test("the reset's touch-list holds only session caches, never workspace preferences", () => {
+ const touches = panelResetTouches()
+ expect(touches).toContain("session")
+ for (const pref of ["workspace:settings", "workspace:vcs", "workspace:project", "settings.v3", "workspace:archive-cutoff"]) {
+ expect(touches).not.toContain(pref)
+ }
+ for (const touch of touches) {
+ expect(classifyResetTarget(`session:x:${touch}`)).toBe("session-cache")
+ }
+ })
+})
diff --git a/packages/app/src/utils/session-list-state.ts b/packages/app/src/utils/session-list-state.ts
new file mode 100644
index 0000000000..64c8970623
--- /dev/null
+++ b/packages/app/src/utils/session-list-state.ts
@@ -0,0 +1,43 @@
+// D2 (spec spec-20260905-045114-session-device-lifecycle): honest client
+// states and the in-product panel-state reset scope. Pure and vscode/solid-
+// free so it unit-tests headless (the consumers wire it to the sync stores).
+
+export type SessionListState = "unfetched" | "empty" | "ready"
+
+/** Distinguish "not yet fetched" (a persisted snapshot may exist but no list
+ * request has completed — incident #293's invisible failure) from "genuinely
+ * empty" (a fetch completed and the projection is empty). */
+export function sessionListState(input: { fetched: boolean; count: number; searching?: boolean }): SessionListState {
+ if (input.count > 0) return "ready"
+ if (!input.fetched && !input.searching) return "unfetched"
+ return "empty"
+}
+
+export type ResetClass = "session-cache" | "workspace-pref"
+
+/** Classify a persisted-store key for the "Reset panel state" command: the
+ * reset clears session caches ONLY — workspace preferences (settings,
+ * archive cutoff, posture config) and user drafts (prompt content) are
+ * never destroyed by a recovery action. */
+export function classifyResetTarget(key: string, target?: { draft?: boolean }): ResetClass {
+ if (target?.draft) return "workspace-pref"
+ if (key.startsWith("session:")) return "session-cache"
+ return "workspace-pref"
+}
+
+/** The canonical list of session caches the "Reset panel state" command
+ * clears: child-store session fields plus the session-list query keys.
+ * Persisted workspace preferences are not on this list — recovery never
+ * destroys configuration. */
+export function panelResetTouches(): readonly string[] {
+ return [
+ "session",
+ "session_status",
+ "sessionTotal",
+ "session_diff",
+ "diff_version",
+ "session:snapshot",
+ "loadSessions",
+ "activeSessions",
+ ]
+}
diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts
index a24d133e67..8650553b3c 100644
--- a/packages/client/src/generated/types.ts
+++ b/packages/client/src/generated/types.ts
@@ -267,6 +267,7 @@ export type SessionsListOutput = {
}>
}
}>
+ readonly currency?: string | null
readonly cursor: { readonly previous?: string | null; readonly next?: string | null }
}
diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts
index 49e054d7cc..48517d6d7c 100644
--- a/packages/core/src/project.ts
+++ b/packages/core/src/project.ts
@@ -17,6 +17,16 @@ export type ID = ProjectSchema.ID
export const Vcs = ProjectSchema.Vcs
export type Vcs = ProjectSchema.Vcs
+/**
+ * Directory-keyed project identity for non-git homes (D1, spec
+ * spec-20260905-045114-session-device-lifecycle): every directory a session
+ * opens is a first-class home, keyed on the resolved worktree path — never
+ * collapsed into the global project.
+ */
+export function dirKey(directory: AbsolutePath): ID {
+ return ID.make(Hash.fast(`dir:${directory}`))
+}
+
export class Info extends Schema.Class("Project.Info")({
id: ID,
}) {}
@@ -109,7 +119,12 @@ const layer = Layer.effect(
const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) {
const repo = yield* git.repo.discover(input)
- if (!repo) return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined }
+ if (!repo) {
+ // D1: a non-git directory is a first-class home keyed on its own
+ // resolved path — never the global project, never the filesystem root.
+ const directory = AbsolutePath.make(yield* fs.resolve(input))
+ return { id: dirKey(directory), directory, vcs: undefined }
+ }
const previous = yield* cached(repo.commonDirectory)
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
diff --git a/packages/core/src/project/backfill.ts b/packages/core/src/project/backfill.ts
new file mode 100644
index 0000000000..2aace8415c
--- /dev/null
+++ b/packages/core/src/project/backfill.ts
@@ -0,0 +1,132 @@
+export * as ProjectBackfill from "./backfill"
+
+import { and, eq, ne } from "drizzle-orm"
+import { Context, Effect, Layer } from "effect"
+import { WorkspaceTable } from "../control-plane/workspace.sql"
+import { Database } from "../database/database"
+import { makeGlobalNode } from "../effect/app-node"
+import { AbsolutePath } from "../schema"
+import { SessionTable } from "../session/sql"
+import { ProjectV2 } from "../project"
+import { ProjectDirectoryTable, ProjectTable } from "./sql"
+
+export interface Result {
+ /** Distinct session directories inspected. */
+ readonly directories: number
+ /** Sessions moved to their directory's resolved project. */
+ readonly repointed: number
+ /** Auto-created (directory-keyed) project rows retired by the re-key. */
+ readonly retired: readonly ProjectV2.ID[]
+}
+
+export interface Interface {
+ /**
+ * D1 boot-time backfill: resolve every distinct session directory and re-key
+ * sessions whose project row no longer matches that resolution. Idempotent,
+ * one transaction per worktree (#272 discipline) — a merge interrupted
+ * mid-flight converges on the next run.
+ */
+ readonly run: Effect.Effect
+}
+
+export class Service extends Context.Service()("@opencode/ProjectBackfill") {}
+
+const layer = Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const db = (yield* Database.Service).db
+ const projects = yield* ProjectV2.Service
+
+ const run = Effect.fn("ProjectBackfill.run")(function* () {
+ const directories = yield* db
+ .selectDistinct({ directory: SessionTable.directory })
+ .from(SessionTable)
+ .all()
+ .pipe(Effect.orDie)
+
+ let repointed = 0
+ const retired: ProjectV2.ID[] = []
+
+ for (const { directory } of directories) {
+ // Legacy rows may persist an empty directory — nothing to resolve.
+ if (!directory) continue
+ const resolved = yield* projects.resolve(AbsolutePath.make(directory))
+ const stale = yield* db
+ .selectDistinct({ id: SessionTable.project_id })
+ .from(SessionTable)
+ .where(and(eq(SessionTable.directory, directory), ne(SessionTable.project_id, resolved.id)))
+ .all()
+ .pipe(Effect.orDie)
+ if (stale.length === 0) continue
+
+ const moving = yield* db
+ .select({ id: SessionTable.id })
+ .from(SessionTable)
+ .where(and(eq(SessionTable.directory, directory), ne(SessionTable.project_id, resolved.id)))
+ .all()
+ .pipe(Effect.orDie)
+
+ // One transaction per worktree: the project row, the session re-key and
+ // the auto-row retirement land atomically or not at all.
+ yield* db
+ .transaction(
+ (tx) =>
+ Effect.gen(function* () {
+ if (resolved.id !== ProjectV2.ID.global) {
+ yield* tx
+ .insert(ProjectTable)
+ .values({
+ id: resolved.id,
+ worktree: resolved.directory,
+ vcs: resolved.vcs?.type ?? null,
+ sandboxes: [],
+ })
+ .onConflictDoNothing()
+ .run()
+ yield* tx
+ .insert(ProjectDirectoryTable)
+ .values({ project_id: resolved.id, directory: AbsolutePath.make(directory) })
+ .onConflictDoNothing()
+ .run()
+ }
+ yield* tx
+ .update(SessionTable)
+ .set({ project_id: resolved.id })
+ .where(and(eq(SessionTable.directory, directory), ne(SessionTable.project_id, resolved.id)))
+ .run()
+
+ for (const { id } of stale) {
+ if (id === ProjectV2.ID.global) continue
+ const row = yield* tx.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()
+ if (!row) continue
+ // Only auto-created rows (directory-keyed, no VCS) retire.
+ // Git rows and any hand-made row stay — the junk-home lint,
+ // not the backfill, owns zero-session pruning.
+ if (row.vcs !== null) continue
+ if (row.id !== ProjectV2.dirKey(row.worktree)) continue
+ const remaining = yield* tx
+ .select({ id: SessionTable.id })
+ .from(SessionTable)
+ .where(eq(SessionTable.project_id, id))
+ .all()
+ if (remaining.length > 0) continue
+ yield* tx.update(WorkspaceTable).set({ project_id: resolved.id }).where(eq(WorkspaceTable.project_id, id)).run()
+ yield* tx.delete(ProjectTable).where(eq(ProjectTable.id, id)).run()
+ retired.push(id)
+ }
+ }),
+ { behavior: "immediate" },
+ )
+ .pipe(Effect.orDie)
+
+ repointed += moving.length
+ }
+
+ return { directories: directories.length, repointed, retired }
+ })
+
+ return Service.of({ run: run() })
+ }),
+)
+
+export const node = makeGlobalNode({ service: Service, layer: layer, deps: [Database.node, ProjectV2.node] })
diff --git a/packages/core/src/project/junk-lint.ts b/packages/core/src/project/junk-lint.ts
new file mode 100644
index 0000000000..cbdeebd45b
--- /dev/null
+++ b/packages/core/src/project/junk-lint.ts
@@ -0,0 +1,84 @@
+export * as ProjectJunkLint from "./junk-lint"
+
+import { and, eq, lt, ne, sql } from "drizzle-orm"
+import { Context, Effect, Layer } from "effect"
+import { WorkspaceTable } from "../control-plane/workspace.sql"
+import { Database } from "../database/database"
+import { makeGlobalNode } from "../effect/app-node"
+import { SessionTable } from "../session/sql"
+import { ProjectV2 } from "../project"
+import { ProjectTable } from "./sql"
+
+/** F6 (spec spec-20260905-045114-session-device-lifecycle): validate against
+ * real usage — the constant is named so it stays adjustable. */
+export const DEFAULT_CUTOFF_DAYS = 30
+
+export interface PruneResult {
+ /** Project rows removed: zero sessions of ANY state, no workspaces, stale. */
+ readonly pruned: readonly ProjectV2.ID[]
+}
+
+export interface Interface {
+ /**
+ * D1 junk-home policy: prune project rows that have held no sessions of any
+ * state (active or archived) and no workspaces for longer than the cutoff.
+ * The global fallback and rows still holding archived sessions survive.
+ */
+ readonly prune: Effect.Effect
+}
+
+export class Service extends Context.Service()("@opencode/ProjectJunkLint") {}
+
+const layer = Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const db = (yield* Database.Service).db
+
+ const prune = Effect.fn("ProjectJunkLint.prune")(function* () {
+ const cutoff = Date.now() - DEFAULT_CUTOFF_DAYS * 24 * 60 * 60 * 1000
+ // A NOT EXISTS subquery, not a join-count: "zero sessions of any state"
+ // must stay true when the session table grows.
+ const junk = yield* db
+ .select({ id: ProjectTable.id })
+ .from(ProjectTable)
+ .where(
+ and(
+ ne(ProjectTable.id, ProjectV2.ID.global),
+ lt(ProjectTable.time_updated, cutoff),
+ sql`(SELECT COUNT(*) FROM ${SessionTable} WHERE ${eq(SessionTable.project_id, ProjectTable.id)}) = 0`,
+ sql`(SELECT COUNT(*) FROM ${WorkspaceTable} WHERE ${eq(WorkspaceTable.project_id, ProjectTable.id)}) = 0`,
+ ),
+ )
+ .all()
+ .pipe(Effect.orDie)
+ if (junk.length === 0) return { pruned: [] }
+
+ const pruned: ProjectV2.ID[] = []
+ for (const { id } of junk) {
+ // Re-check per row inside the delete transaction: the lint runs
+ // unattended, so a home may gain a session between scan and delete.
+ yield* db
+ .transaction(
+ (tx) =>
+ Effect.gen(function* () {
+ const holding = yield* tx
+ .select({ id: SessionTable.id })
+ .from(SessionTable)
+ .where(eq(SessionTable.project_id, id))
+ .all()
+ if (holding.length > 0) return
+ yield* tx.delete(ProjectTable).where(eq(ProjectTable.id, id)).run()
+ pruned.push(id)
+ }),
+ { behavior: "immediate" },
+ )
+ .pipe(Effect.orDie)
+ }
+ return { pruned }
+ })
+
+ return Service.of({ prune: prune() })
+ }),
+)
+
+export const node = makeGlobalNode({ service: Service, layer: layer, deps: [Database.node] })
diff --git a/packages/core/src/session/currency.ts b/packages/core/src/session/currency.ts
new file mode 100644
index 0000000000..f0ea15cc7e
--- /dev/null
+++ b/packages/core/src/session/currency.ts
@@ -0,0 +1,36 @@
+export * as SessionCurrency from "./currency"
+
+import { DateTime } from "effect"
+import { InstallationVersion } from "../installation/version"
+import { SessionSchema } from "./schema"
+
+/**
+ * D2 (spec spec-20260905-045114-session-device-lifecycle): the list-currency
+ * token is DERIVED from the session-table projection the client renders —
+ * (count, max time_updated, sum of time_updated) over the rows the list
+ * returns, plus the hub build id. Never hand-bumped: any write that changes
+ * the rendered projection — same-tick touches, archive churn, out-of-band SQL
+ * — changes the token by construction, because it is recomputed from the rows
+ * on every list response.
+ */
+
+export interface Projection {
+ readonly count: number
+ readonly maxUpdated: number
+ readonly sumUpdated: number
+}
+
+export function projection(rows: readonly Pick[]): Projection {
+ let maxUpdated = 0
+ let sumUpdated = 0
+ for (const row of rows) {
+ const updated = DateTime.toEpochMillis(row.time.updated)
+ if (updated > maxUpdated) maxUpdated = updated
+ sumUpdated += updated
+ }
+ return { count: rows.length, maxUpdated, sumUpdated }
+}
+
+export function token(input: Projection, build: string = InstallationVersion): string {
+ return `v1.${input.count}.${input.maxUpdated}.${input.sumUpdated}.${build}`
+}
diff --git a/packages/core/test/project-backfill.test.ts b/packages/core/test/project-backfill.test.ts
new file mode 100644
index 0000000000..4e8de789a5
--- /dev/null
+++ b/packages/core/test/project-backfill.test.ts
@@ -0,0 +1,179 @@
+import { describe, expect } from "bun:test"
+import { $ } from "bun"
+import fs from "fs/promises"
+import { Effect } from "effect"
+import { eq } from "drizzle-orm"
+import { Database } from "@opencode-ai/core/database/database"
+import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
+import { ProjectBackfill } from "@opencode-ai/core/project/backfill"
+import { ProjectV2 } from "@opencode-ai/core/project"
+import { ProjectTable } from "@opencode-ai/core/project/sql"
+import { AbsolutePath } from "@opencode-ai/core/schema"
+import { SessionV2 } from "@opencode-ai/core/session"
+import { Hash } from "@opencode-ai/core/util/hash"
+import { SessionTable } from "@opencode-ai/core/session/sql"
+import { testEffect } from "./lib/effect"
+import { tmpdir } from "./fixture/tmpdir"
+
+const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, ProjectV2.node, ProjectBackfill.node])))
+
+function remoteID(remote: string) {
+ return ProjectV2.ID.make(Hash.fast(`git-remote:${remote}`))
+}
+
+function abs(value: string) {
+ return AbsolutePath.make(value)
+}
+
+async function initRepo(dir: string, opts?: { remote?: string }) {
+ await $`git init`.cwd(dir).quiet()
+ await $`git config user.email test@opencode.test`.cwd(dir).quiet()
+ await $`git config user.name Test`.cwd(dir).quiet()
+ await $`git commit --allow-empty -m root`.cwd(dir).quiet()
+ if (opts?.remote) await $`git remote add origin ${opts.remote}`.cwd(dir).quiet()
+}
+
+const seed = (home: {
+ db: EffectDrizzleSqlite.EffectSQLiteDatabase
+ project: { id: ProjectV2.ID; worktree: string; vcs?: string | null }
+ sessions?: { id: string; projectID: ProjectV2.ID }[]
+}) =>
+ Effect.gen(function* () {
+ yield* home.db
+ .insert(ProjectTable)
+ .values({
+ id: home.project.id,
+ worktree: abs(home.project.worktree),
+ vcs: home.project.vcs ?? null,
+ sandboxes: [],
+ })
+ .onConflictDoNothing()
+ .run()
+ .pipe(Effect.orDie)
+ for (const session of home.sessions ?? []) {
+ yield* home.db
+ .insert(SessionTable)
+ .values({
+ id: SessionV2.ID.make(session.id),
+ project_id: session.projectID,
+ slug: session.id,
+ directory: home.project.worktree,
+ title: `Session ${session.id}`,
+ version: "test",
+ })
+ .onConflictDoNothing()
+ .run()
+ .pipe(Effect.orDie)
+ }
+ })
+
+describe("ProjectBackfill", () => {
+ it.effect("re-points pre-existing global sessions in a non-git home to a first-class project", () =>
+ Effect.gen(function* () {
+ const tmp = yield* Effect.acquireRelease(
+ Effect.promise(() => tmpdir()),
+ (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
+ )
+ const real = AbsolutePath.make(yield* Effect.promise(() => fs.realpath(tmp.path)))
+ const db = (yield* Database.Service).db
+ yield* seed({ db, project: { id: ProjectV2.ID.global, worktree: "/" } })
+ yield* seed({
+ db,
+ project: { id: ProjectV2.ID.global, worktree: tmp.path },
+ sessions: [
+ { id: "ses_a", projectID: ProjectV2.ID.global },
+ { id: "ses_b", projectID: ProjectV2.ID.global },
+ ],
+ })
+ const backfill = yield* ProjectBackfill.Service
+
+ const result = yield* backfill.run
+
+ const homeID = ProjectV2.dirKey(real)
+ const rows = yield* db.select().from(SessionTable).all().pipe(Effect.orDie)
+ expect(rows.map((row) => row.project_id)).toEqual([homeID, homeID])
+ expect(result.repointed).toBe(2)
+ const projects = yield* db.select().from(ProjectTable).all().pipe(Effect.orDie)
+ const home = projects.find((row) => row.id === homeID)
+ expect(home?.worktree).toBe(real)
+ expect(home?.vcs).toBeNull()
+ expect(projects.find((row) => row.id === ProjectV2.ID.global)).toBeDefined()
+ }),
+ )
+
+ it.effect("re-keys a non-git home that later became a git repo and retires the auto row", () =>
+ Effect.gen(function* () {
+ const tmp = yield* Effect.acquireRelease(
+ Effect.promise(() => tmpdir()),
+ (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
+ )
+ const real = AbsolutePath.make(yield* Effect.promise(() => fs.realpath(tmp.path)))
+ const autoID = ProjectV2.dirKey(real)
+ const db = (yield* Database.Service).db
+ yield* seed({
+ db,
+ project: { id: autoID, worktree: tmp.path },
+ sessions: [
+ { id: "ses_a", projectID: autoID },
+ { id: "ses_b", projectID: autoID },
+ ],
+ })
+ yield* Effect.promise(() => initRepo(tmp.path, { remote: "git@github.com:Acme/App.git" }))
+ const backfill = yield* ProjectBackfill.Service
+
+ yield* backfill.run
+
+ const gitID = remoteID("github.com/Acme/App")
+ const rows = yield* db.select().from(SessionTable).all().pipe(Effect.orDie)
+ expect(rows.map((row) => row.project_id)).toEqual([gitID, gitID])
+ const projects = yield* db.select().from(ProjectTable).all().pipe(Effect.orDie)
+ expect(projects.find((row) => row.id === autoID)).toBeUndefined()
+ const git = projects.find((row) => row.id === gitID)
+ expect(git?.worktree).toBe(real)
+ expect(git?.vcs).toBe("git")
+ }),
+ )
+
+ it.effect("converges when a merge was applied only partially", () =>
+ Effect.gen(function* () {
+ const tmp = yield* Effect.acquireRelease(
+ Effect.promise(() => tmpdir()),
+ (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
+ )
+ const real = AbsolutePath.make(yield* Effect.promise(() => fs.realpath(tmp.path)))
+ const autoID = ProjectV2.dirKey(real)
+ const db = (yield* Database.Service).db
+ yield* seed({
+ db,
+ project: { id: autoID, worktree: tmp.path },
+ sessions: [
+ { id: "ses_a", projectID: autoID },
+ { id: "ses_b", projectID: autoID },
+ ],
+ })
+ yield* Effect.promise(() => initRepo(tmp.path, { remote: "git@github.com:Acme/App.git" }))
+ const gitID = remoteID("github.com/Acme/App")
+ // Simulate a merge killed mid-flight: one session already re-keyed
+ // (its git project row exists), the second still on the auto row and
+ // the auto row not yet retired.
+ yield* seed({ db, project: { id: gitID, worktree: tmp.path, vcs: "git" } })
+ yield* db
+ .update(SessionTable)
+ .set({ project_id: gitID })
+ .where(eq(SessionTable.id, SessionV2.ID.make("ses_a")))
+ .run()
+ const backfill = yield* ProjectBackfill.Service
+
+ yield* backfill.run
+ yield* backfill.run
+
+ const rows = yield* db.select().from(SessionTable).all().pipe(Effect.orDie)
+ expect(rows.map((row) => row.project_id)).toEqual([gitID, gitID])
+ const projects = yield* db.select().from(ProjectTable).all().pipe(Effect.orDie)
+ expect(projects.find((row) => row.id === autoID)).toBeUndefined()
+ expect(projects.find((row) => row.id === gitID)).toBeDefined()
+ }),
+ )
+})
diff --git a/packages/core/test/project-junk-lint.test.ts b/packages/core/test/project-junk-lint.test.ts
new file mode 100644
index 0000000000..3f81ff7186
--- /dev/null
+++ b/packages/core/test/project-junk-lint.test.ts
@@ -0,0 +1,131 @@
+import { describe, expect } from "bun:test"
+import { Effect } from "effect"
+import { Database } from "@opencode-ai/core/database/database"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
+import { ProjectJunkLint } from "@opencode-ai/core/project/junk-lint"
+import { ProjectV2 } from "@opencode-ai/core/project"
+import { ProjectTable } from "@opencode-ai/core/project/sql"
+import { AbsolutePath } from "@opencode-ai/core/schema"
+import { SessionV2 } from "@opencode-ai/core/session"
+import { SessionTable } from "@opencode-ai/core/session/sql"
+import { testEffect } from "./lib/effect"
+
+const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, ProjectJunkLint.node])))
+
+const DAY = 24 * 60 * 60 * 1000
+
+const home = (values: { id: ProjectV2.ID; worktree: string; ageDays?: number }) =>
+ Effect.gen(function* () {
+ const db = (yield* Database.Service).db
+ yield* db
+ .insert(ProjectTable)
+ .values({
+ id: values.id,
+ worktree: AbsolutePath.make(values.worktree),
+ sandboxes: [],
+ time_created: Date.now() - (values.ageDays ?? 0) * DAY,
+ time_updated: Date.now() - (values.ageDays ?? 0) * DAY,
+ })
+ .onConflictDoNothing()
+ .run()
+ .pipe(Effect.orDie)
+ })
+
+const archivedSession = (values: { id: string; projectID: ProjectV2.ID; directory: string }) =>
+ Effect.gen(function* () {
+ const db = (yield* Database.Service).db
+ yield* db
+ .insert(SessionTable)
+ .values({
+ id: SessionV2.ID.make(values.id),
+ project_id: values.projectID,
+ slug: values.id,
+ directory: values.directory,
+ title: `Session ${values.id}`,
+ version: "test",
+ time_created: Date.now() - 60 * DAY,
+ time_updated: Date.now() - 60 * DAY,
+ time_archived: Date.now() - 30 * DAY,
+ })
+ .onConflictDoNothing()
+ .run()
+ .pipe(Effect.orDie)
+ })
+
+const projectIDs = Effect.gen(function* () {
+ const db = (yield* Database.Service).db
+ const rows = yield* db.select({ id: ProjectTable.id }).from(ProjectTable).all().pipe(Effect.orDie)
+ return rows.map((row) => row.id)
+})
+
+describe("ProjectJunkLint", () => {
+ it.effect("prunes a zero-session home past the cutoff", () =>
+ Effect.gen(function* () {
+ const id = ProjectV2.ID.make("junk-home")
+ yield* home({ id, worktree: "/tmp/junk-home", ageDays: 60 })
+ const lint = yield* ProjectJunkLint.Service
+
+ const result = yield* lint.prune
+
+ expect(result.pruned).toEqual([id])
+ expect(yield* projectIDs).not.toContain(id)
+ }),
+ )
+
+ it.effect("never prunes a home holding archived sessions", () =>
+ Effect.gen(function* () {
+ const id = ProjectV2.ID.make("archived-home")
+ yield* home({ id, worktree: "/tmp/archived-home", ageDays: 60 })
+ yield* archivedSession({ id: "ses_archived", projectID: id, directory: "/tmp/archived-home" })
+ const lint = yield* ProjectJunkLint.Service
+
+ const result = yield* lint.prune
+
+ expect(result.pruned).toEqual([])
+ expect(yield* projectIDs).toContain(id)
+ }),
+ )
+
+ it.effect("never prunes a fresh zero-session home", () =>
+ Effect.gen(function* () {
+ const id = ProjectV2.ID.make("fresh-home")
+ yield* home({ id, worktree: "/tmp/fresh-home", ageDays: 2 })
+ const lint = yield* ProjectJunkLint.Service
+
+ const result = yield* lint.prune
+
+ expect(result.pruned).toEqual([])
+ expect(yield* projectIDs).toContain(id)
+ }),
+ )
+
+ it.effect("never prunes the global fallback", () =>
+ Effect.gen(function* () {
+ yield* home({ id: ProjectV2.ID.global, worktree: "/", ageDays: 400 })
+ const lint = yield* ProjectJunkLint.Service
+
+ const result = yield* lint.prune
+
+ expect(result.pruned).toEqual([])
+ expect(yield* projectIDs).toContain(ProjectV2.ID.global)
+ }),
+ )
+
+ it.effect("prunes only homes past the cutoff across a mixed table", () =>
+ Effect.gen(function* () {
+ const old = ProjectV2.ID.make("old-empty")
+ const fresh = ProjectV2.ID.make("fresh-empty")
+ yield* home({ id: old, worktree: "/tmp/old-empty", ageDays: 45 })
+ yield* home({ id: fresh, worktree: "/tmp/fresh-empty", ageDays: 5 })
+ const lint = yield* ProjectJunkLint.Service
+
+ const result = yield* lint.prune
+
+ expect(result.pruned).toEqual([old])
+ const ids = yield* projectIDs
+ expect(ids).not.toContain(old)
+ expect(ids).toContain(fresh)
+ }),
+ )
+})
diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts
index fa709a8b2b..7248346f01 100644
--- a/packages/core/test/project.test.ts
+++ b/packages/core/test/project.test.ts
@@ -39,7 +39,7 @@ async function rootCommit(dir: string) {
}
describe("ProjectV2.resolve", () => {
- it.live("returns global for non-git directory", () =>
+ it.live("resolves a non-git directory to a first-class project keyed on the path", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
@@ -49,13 +49,35 @@ describe("ProjectV2.resolve", () => {
const result = yield* project.resolve(abs(tmp.path))
- expect(result.id).toBe(ProjectV2.ID.make("global"))
- expect(path.resolve(result.directory)).toBe(path.parse(tmp.path).root)
+ expect(result.id).toBe(ProjectV2.dirKey(AbsolutePath.make(yield* Effect.promise(() => fs.realpath(tmp.path)))))
+ expect(result.id).not.toBe(ProjectV2.ID.global)
+ expect(result.directory).toBe(yield* real(tmp.path))
expect(result.previous).toBeUndefined()
expect(result.vcs).toBeUndefined()
}),
)
+ it.live("non-git projects are path-keyed: same path resolves identically, distinct paths differ", () =>
+ Effect.gen(function* () {
+ const a = yield* Effect.acquireRelease(
+ Effect.promise(() => tmpdir()),
+ (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
+ )
+ const b = yield* Effect.acquireRelease(
+ Effect.promise(() => tmpdir()),
+ (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
+ )
+ const project = yield* ProjectV2.Service
+
+ const first = yield* project.resolve(abs(a.path))
+ const second = yield* project.resolve(abs(a.path))
+ const other = yield* project.resolve(abs(b.path))
+
+ expect(second.id).toBe(first.id)
+ expect(other.id).not.toBe(first.id)
+ }),
+ )
+
it.live("returns git global for repo with no commits and no remote", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
diff --git a/packages/core/test/session-currency.test.ts b/packages/core/test/session-currency.test.ts
new file mode 100644
index 0000000000..620a1a1846
--- /dev/null
+++ b/packages/core/test/session-currency.test.ts
@@ -0,0 +1,170 @@
+import { describe, expect } from "bun:test"
+import { eq, sql } from "drizzle-orm"
+import { Effect, Layer } from "effect"
+import { Database } from "@opencode-ai/core/database/database"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
+import { EventV2 } from "@opencode-ai/core/event"
+import { ProjectV2 } from "@opencode-ai/core/project"
+import { AbsolutePath } from "@opencode-ai/core/schema"
+import { SessionCurrency } from "@opencode-ai/core/session/currency"
+import { SessionV2 } from "@opencode-ai/core/session"
+import { SessionExecution } from "@opencode-ai/core/session/execution"
+import { SessionProjector } from "@opencode-ai/core/session/projector"
+import { SessionStore } from "@opencode-ai/core/session/store"
+import { SessionTable } from "@opencode-ai/core/session/sql"
+import { Location } from "@opencode-ai/core/location"
+import { testEffect } from "./lib/effect"
+import { tmpdir } from "./fixture/tmpdir"
+
+const projects = Layer.succeed(
+ ProjectV2.Service,
+ ProjectV2.Service.of({
+ resolve: (directory) =>
+ Effect.succeed({ id: ProjectV2.ID.make("cur-test-project"), directory, vcs: undefined }),
+ directories: () => Effect.succeed([]),
+ commit: () => Effect.void,
+ }),
+)
+const it = testEffect(
+ AppNodeBuilder.build(
+ LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]),
+ [
+ [ProjectV2.node, projects],
+ [SessionExecution.node, SessionExecution.noopLayer],
+ ],
+ ),
+)
+
+const location = Location.Ref.make({ directory: AbsolutePath.make("/currency-home") })
+const BUILD = "test-build-1"
+
+const render = Effect.gen(function* () {
+ const session = yield* SessionV2.Service
+ const rows = yield* session.list()
+ return SessionCurrency.token(SessionCurrency.projection(rows), BUILD)
+})
+
+describe("SessionCurrency (H4: derived, never hand-bumped)", () => {
+ it.effect("advances on same-tick writes", () =>
+ Effect.gen(function* () {
+ const session = yield* SessionV2.Service
+ const before = yield* render
+
+ const first = yield* session.create({ location })
+ const second = yield* session.create({ location })
+ // Force the same tick: identical created/updated stamps for both rows.
+ const now = Date.now()
+ yield* Database.Service.use(({ db }) =>
+ db
+ .update(SessionTable)
+ .set({ time_created: now, time_updated: now })
+ .where(sql`${SessionTable.id} in (${first.id}, ${second.id})`)
+ .run()
+ .pipe(Effect.orDie),
+ )
+
+ const after = yield* render
+
+ expect(after).not.toBe(before)
+ }),
+ )
+
+ it.effect("advances on archive churn", () =>
+ Effect.gen(function* () {
+ const session = yield* SessionV2.Service
+ const created = yield* session.create({ location })
+ const seeded = yield* render
+
+ // Out-of-band archive: the write path bypasses every API, as migrations
+ // and ops sometimes do. The rendered projection still changes.
+ yield* Database.Service.use(({ db }) =>
+ db
+ .update(SessionTable)
+ .set({ time_archived: Date.now(), time_updated: Date.now() })
+ .where(eq(SessionTable.id, created.id))
+ .run()
+ .pipe(Effect.orDie),
+ )
+
+ const after = yield* render
+
+ expect(after).not.toBe(seeded)
+ }),
+ )
+
+ it.effect("advances on delete-then-touch pairs", () =>
+ Effect.gen(function* () {
+ const session = yield* SessionV2.Service
+ const victim = yield* session.create({ location })
+ const survivor = yield* session.create({ location })
+ const victimRow = yield* Database.Service.use(({ db }) =>
+ db.select().from(SessionTable).where(eq(SessionTable.id, victim.id)).get().pipe(Effect.orDie),
+ )
+ const seeded = yield* render
+
+ yield* Database.Service.use(({ db }) =>
+ db.delete(SessionTable).where(eq(SessionTable.id, victim.id)).run().pipe(Effect.orDie),
+ )
+ // Touch the survivor back to the victim's old timestamp: count drops by
+ // one, max stays, sum changes — the tuple moves without any "hand bump".
+ yield* Database.Service.use(({ db }) =>
+ db
+ .update(SessionTable)
+ .set({ time_updated: victimRow!.time_created })
+ .where(eq(SessionTable.id, survivor.id))
+ .run()
+ .pipe(Effect.orDie),
+ )
+
+ const after = yield* render
+
+ expect(after).not.toBe(seeded)
+ }),
+ )
+
+ it.effect("advances on direct out-of-band writes", () =>
+ Effect.gen(function* () {
+ const session = yield* SessionV2.Service
+ yield* session.create({ location })
+ const seeded = yield* render
+
+ yield* Database.Service.use(({ db }) =>
+ db
+ .update(SessionTable)
+ .set({ time_updated: Date.now() + 5000 })
+ .run()
+ .pipe(Effect.orDie),
+ )
+
+ const after = yield* render
+
+ expect(after).not.toBe(seeded)
+ }),
+ )
+
+ it.effect("binds the hub build id: same rows under a different build give a different token", () =>
+ Effect.gen(function* () {
+ const session = yield* SessionV2.Service
+ const rows = yield* session.list()
+
+ const a = SessionCurrency.token(SessionCurrency.projection(rows), "build-a")
+ const b = SessionCurrency.token(SessionCurrency.projection(rows), "build-b")
+
+ expect(a).not.toBe(b)
+ }),
+ )
+
+ it.effect("is a pure function of the projection: same projection, same token", () =>
+ Effect.gen(function* () {
+ const session = yield* SessionV2.Service
+ yield* session.create({ location })
+ const rows = yield* session.list()
+
+ const first = SessionCurrency.token(SessionCurrency.projection(rows), BUILD)
+ const second = SessionCurrency.token(SessionCurrency.projection(rows), BUILD)
+
+ expect(second).toBe(first)
+ }),
+ )
+})
diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts
index 9870377b21..81c4364974 100644
--- a/packages/opencode/src/project/project.ts
+++ b/packages/opencode/src/project/project.ts
@@ -15,6 +15,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { AppProcess } from "@opencode-ai/core/process"
import { ProjectV2 } from "@opencode-ai/core/project"
+import { ProjectBackfill } from "@opencode-ai/core/project/backfill"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
@@ -109,6 +110,7 @@ const layer = Layer.effect(
const fs = yield* FSUtil.Service
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const projectV2 = yield* ProjectV2.Service
+ const backfill = yield* ProjectBackfill.Service
const projectDirectories = yield* ProjectDirectories.Service
const events = yield* EventV2Bridge.Service
const flags = yield* RuntimeFlags.Service
@@ -397,6 +399,13 @@ const layer = Layer.effect(
const init = Effect.fn("Project.init")(function* () {
yield* InstanceState.get(initState)
+ // D1: every instance boot also reconciles every session home in the
+ // table — backfill for pre-existing non-git homes and re-key when a
+ // home became a git repo. Idempotent, so forking it here is safe.
+ yield* backfill.run.pipe(
+ Effect.catchCause((cause) => Effect.logWarning("project backfill failed", { cause })),
+ Effect.forkIn(scope),
+ )
})
const sandboxes = Effect.fn("Project.sandboxes")(function* (id: ProjectV2.ID) {
@@ -474,6 +483,7 @@ export const node = LayerNode.make({
CrossSpawnSpawner.node,
ProjectV2.node,
ProjectDirectories.node,
+ ProjectBackfill.node,
EventV2Bridge.node,
RuntimeFlags.node,
Database.node,
diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts
index 8945693d57..3ca832f963 100644
--- a/packages/opencode/src/session/session.ts
+++ b/packages/opencode/src/session/session.ts
@@ -28,6 +28,7 @@ import { or } from "drizzle-orm"
import type { SQL } from "drizzle-orm"
import { PartTable, SessionTable } from "@opencode-ai/core/session/sql"
import { ProjectTable } from "@opencode-ai/core/project/sql"
+import { AbsolutePath } from "@opencode-ai/core/schema"
import { MessageV2 } from "./message-v2"
import type { InstanceContext } from "../project/instance-context"
import { InstanceState } from "@/effect/instance-state"
@@ -488,7 +489,7 @@ export type Patch = Omit, "time" | "share" | "summary" | "revert"
const layer: Layer.Layer<
Service,
never,
- BackgroundJob.Service | RuntimeFlags.Service | Database.Service | EventV2Bridge.Service | Snapshot.Service
+ BackgroundJob.Service | RuntimeFlags.Service | Database.Service | EventV2Bridge.Service | Snapshot.Service | ProjectV2.Service
> = Layer.effect(
Service,
Effect.gen(function* () {
@@ -498,6 +499,7 @@ const layer: Layer.Layer<
const events = yield* EventV2Bridge.Service
const flags = yield* RuntimeFlags.Service
const snapshot = yield* Snapshot.Service
+ const projectV2 = yield* ProjectV2.Service
const createNext = Effect.fn("Session.createNext")(function* (input: {
id?: SessionID
@@ -760,13 +762,31 @@ const layer: Layer.Layer<
const setArchived = Effect.fn("Session.setArchived")(function* (input: { sessionID: SessionID; time?: number }) {
const current = yield* get(input.sessionID).pipe(Effect.orDie)
+ // D1: restoring re-resolves the session's home by worktree, so archive
+ // never strands a session on a retired project row — the row the
+ // directory resolves to now wins, in the row AND in the published event
+ // (the projector rewrites the whole session row from the event).
+ let projectID = current.projectID
+ if (input.time === undefined) {
+ const resolved = yield* projectV2.resolve(AbsolutePath.make(current.directory))
+ if (resolved.id !== current.projectID) {
+ yield* db
+ .insert(ProjectTable)
+ .values({ id: resolved.id, worktree: resolved.directory, vcs: resolved.vcs?.type ?? null, sandboxes: [] })
+ .onConflictDoNothing()
+ .run()
+ .pipe(Effect.orDie)
+ projectID = resolved.id
+ }
+ }
const next = {
...current,
+ projectID,
time: { ...current.time, archived: input.time, updated: Date.now() },
} as Info
yield* db
.update(SessionTable)
- .set({ time_archived: input.time ?? null, time_updated: next.time.updated })
+ .set({ time_archived: input.time ?? null, time_updated: next.time.updated, project_id: projectID })
.where(eq(SessionTable.id, input.sessionID))
.run()
.pipe(Effect.orDie)
@@ -1188,7 +1208,7 @@ function listByProject(
export const node = LayerNode.make({
service: Service,
layer: layer,
- deps: [BackgroundJob.node, RuntimeFlags.node, Database.node, EventV2Bridge.node, Snapshot.node],
+ deps: [BackgroundJob.node, RuntimeFlags.node, Database.node, EventV2Bridge.node, Snapshot.node, ProjectV2.node],
})
export * as Session from "./session"
diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts
index 804b92b08e..2ced15bdbc 100644
--- a/packages/opencode/test/project/project.test.ts
+++ b/packages/opencode/test/project/project.test.ts
@@ -139,12 +139,14 @@ describe("Project.fromDirectory", () => {
}),
)
- it.live("returns global for non-git directory", () =>
+ it.live("resolves a non-git directory to a first-class project keyed on the path", () =>
Effect.gen(function* () {
const project = yield* Project.Service
const tmp = yield* tmpdirScoped()
const result = yield* project.fromDirectory(tmp)
- expect(result.project.id).toBe(ProjectV2.ID.global)
+ expect(result.project.id).not.toBe(ProjectV2.ID.global)
+ expect(result.project.worktree).toBe(tmp)
+ expect(result.project.vcs).toBeUndefined()
}),
)
diff --git a/packages/opencode/test/server/session-restore-resolve.test.ts b/packages/opencode/test/server/session-restore-resolve.test.ts
new file mode 100644
index 0000000000..fd3dc7bcf5
--- /dev/null
+++ b/packages/opencode/test/server/session-restore-resolve.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect } from "bun:test"
+import { $ } from "bun"
+import { eq } from "drizzle-orm"
+import { Effect } from "effect"
+import { Database } from "@opencode-ai/core/database/database"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
+import { ProjectV2 } from "@opencode-ai/core/project"
+import { Hash } from "@opencode-ai/core/util/hash"
+import { SessionProjector } from "@opencode-ai/core/session/projector"
+import { SessionTable } from "@opencode-ai/core/session/sql"
+import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
+import { Project } from "@/project/project"
+import { Session as SessionNs } from "@/session/session"
+import { provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture"
+import { testEffect } from "../lib/effect"
+
+const it = testEffect(
+ LayerNode.compile(
+ LayerNode.group([
+ SessionNs.node,
+ SessionProjector.node,
+ Project.node,
+ CrossSpawnSpawner.node,
+ Database.node,
+ ]),
+ ),
+)
+
+const withSession = (input?: Parameters[0]) =>
+ Effect.acquireRelease(SessionNs.use.create(input), (created) =>
+ SessionNs.Service.use((session) => session.remove(created.id).pipe(Effect.ignore)),
+ )
+
+const remoteID = (remote: string) => ProjectV2.ID.make(Hash.fast(`git-remote:${remote}`))
+
+describe("Session.setArchived restore", () => {
+ it.instance(
+ "re-resolves the session's project by worktree when the home changed while archived",
+ () =>
+ Effect.gen(function* () {
+ const home = yield* tmpdirScoped()
+ const session = yield* withSession({ title: "restore-me" }).pipe(provideInstance(home))
+
+ yield* SessionNs.Service.use((s) => s.setArchived({ sessionID: session.id, time: Date.now() }))
+
+ // While archived, the home becomes a git repo — its project identity
+ // changes under the session.
+ yield* Effect.promise(() =>
+ $`git init && git config user.email test@opencode.test && git config user.name Test && git commit --allow-empty -m root && git remote add origin git@github.com:Acme/Restored.git`
+ .cwd(home)
+ .quiet()
+ .nothrow(),
+ )
+
+ yield* SessionNs.Service.use((s) => s.setArchived({ sessionID: session.id, time: undefined }))
+
+ const db = (yield* Database.Service).db
+ const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, session.id)).get().pipe(Effect.orDie)
+ const expected = remoteID("github.com/Acme/Restored")
+ expect(row?.project_id).toBe(expected)
+ const project = yield* Project.use.get(expected)
+ expect(project?.worktree).toBe(home)
+ }),
+ { git: false },
+ )
+})
diff --git a/packages/protocol/src/groups/session-list-semantics.ts b/packages/protocol/src/groups/session-list-semantics.ts
new file mode 100644
index 0000000000..67fe381640
--- /dev/null
+++ b/packages/protocol/src/groups/session-list-semantics.ts
@@ -0,0 +1,70 @@
+export * as SessionListSemantics from "./session-list-semantics"
+
+import { SessionsQuery } from "./session"
+
+export { SessionsQuery }
+
+/**
+ * D6 (spec spec-20260905-045114-session-device-lifecycle): the query semantics
+ * of session-list endpoints are declared, frozen surface. This manifest IS the
+ * companion update — a PR that changes what a query means must update it in
+ * the same change, or the drift-gate fixture (test/session-list-semantics)
+ * goes red. Additive optional fields with base defaults are permitted without
+ * a companion update; semantic changes (removals, requiredness flips, default
+ * changes, kind changes) are not. The 2026-09-05 v1-route semantics change
+ * (directory-filtered → project-scoped) is the founding incident.
+ *
+ * The server's handlers import `defaults` from here, so the declared defaults
+ * are load-bearing, not documentation.
+ */
+
+export interface QueryField {
+ readonly name: string
+ readonly kind: "scope" | "filter" | "cursor"
+ readonly required: boolean
+ readonly default: number | string | undefined
+}
+
+export const queryFields = [
+ { name: "workspace", kind: "scope", required: false, default: undefined },
+ { name: "directory", kind: "scope", required: false, default: undefined },
+ { name: "project", kind: "scope", required: false, default: undefined },
+ { name: "subpath", kind: "scope", required: false, default: undefined },
+ { name: "cursor", kind: "cursor", required: false, default: undefined },
+ { name: "limit", kind: "filter", required: false, default: 50 },
+ { name: "order", kind: "filter", required: false, default: "desc" },
+ { name: "search", kind: "filter", required: false, default: undefined },
+] as const satisfies readonly QueryField[]
+
+export const defaults = {
+ limit: queryFields.find((field) => field.name === "limit")!.default as number,
+ order: queryFields.find((field) => field.name === "order")!.default as "asc" | "desc",
+}
+
+export type Verdict = { readonly ok: true } | { readonly ok: false; readonly reason: string }
+
+/** Classify a candidate change against the frozen semantics. Additive
+ * optional fields (with or without a base default) pass; everything that
+ * changes what an existing query means fails. */
+export function classify(previous: readonly QueryField[], current: readonly QueryField[]): Verdict {
+ const previousByName = new Map(previous.map((field) => [field.name, field]))
+ for (const field of current) {
+ const before = previousByName.get(field.name)
+ if (!before) {
+ if (field.required)
+ return { ok: false, reason: `new field ${field.name} is required — additive fields must be optional with a base default` }
+ continue
+ }
+ if (field.required !== before.required)
+ return { ok: false, reason: `field ${field.name} changed requiredness ${before.required} → ${field.required}` }
+ if (field.kind !== before.kind)
+ return { ok: false, reason: `field ${field.name} changed kind ${before.kind} → ${field.kind}` }
+ if (field.default !== before.default)
+ return { ok: false, reason: `field ${field.name} changed default ${JSON.stringify(before.default)} → ${JSON.stringify(field.default)}` }
+ }
+ const currentByName = new Map(current.map((field) => [field.name, field]))
+ for (const field of previous) {
+ if (!currentByName.has(field.name)) return { ok: false, reason: `field ${field.name} was removed` }
+ }
+ return { ok: true }
+}
diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts
index 8ce85ef796..c78f25ce5b 100644
--- a/packages/protocol/src/groups/session.ts
+++ b/packages/protocol/src/groups/session.ts
@@ -110,6 +110,10 @@ export const makeSessionGroup = (sessionLo
query: SessionsQuery,
success: Schema.Struct({
data: Schema.Array(Session.Info),
+ // D2: derived list-currency token over the returned rows plus hub
+ // build id. Additive optional field with base default (absent) —
+ // registered in the session-list semantics manifest.
+ currency: Schema.optional(Schema.String),
cursor: Schema.Struct({
previous: SessionsCursor.pipe(Schema.optional),
next: SessionsCursor.pipe(Schema.optional),
diff --git a/packages/protocol/test/session-list-semantics.test.ts b/packages/protocol/test/session-list-semantics.test.ts
new file mode 100644
index 0000000000..3caa90fc35
--- /dev/null
+++ b/packages/protocol/test/session-list-semantics.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, it } from "bun:test"
+import { Schema } from "effect"
+import { SessionListSemantics, SessionsQuery } from "../src/groups/session-list-semantics"
+
+const decode = Schema.decodeUnknownSync(SessionsQuery)
+
+const liveFields = () => {
+ const probe: Record = {}
+ try {
+ decode(probe)
+ } catch {
+ throw new Error("empty query should decode — session.list has no required fields")
+ }
+ return Object.keys(SessionsQuery.fields).sort()
+}
+
+describe("SessionListSemantics (H5: the drift gate)", () => {
+ it("the live session.list query schema matches the frozen semantics manifest", () => {
+ const fields = liveFields()
+ expect(fields).toEqual(SessionListSemantics.queryFields.map((field) => field.name).sort())
+ for (const field of SessionListSemantics.queryFields) {
+ expect((SessionsQuery.fields as Record)[field.name]).toBeDefined()
+ }
+ // Optionality is part of the frozen meaning: the manifest records every
+ // field as optional, and the live schema agrees — an empty query decodes.
+ expect(SessionListSemantics.queryFields.every((field) => !field.required)).toBe(true)
+ })
+
+ it("an additive optional field with a base default passes the gate", () => {
+ const next: readonly SessionListSemantics.QueryField[] = [
+ ...SessionListSemantics.queryFields,
+ { name: "fleet_view", kind: "filter", required: false, default: undefined },
+ ]
+ const verdict = SessionListSemantics.classify(SessionListSemantics.queryFields, next)
+ expect(verdict).toEqual({ ok: true })
+ })
+
+ it("removing a query field without a companion update fails the gate", () => {
+ const next = SessionListSemantics.queryFields.filter((field) => field.name !== "directory")
+ const verdict = SessionListSemantics.classify(SessionListSemantics.queryFields, next)
+ expect(verdict.ok).toBe(false)
+ })
+
+ it("flipping a field from optional to required fails the gate", () => {
+ const next = SessionListSemantics.queryFields.map((field) =>
+ field.name === "workspace" ? { ...field, required: true } : field,
+ )
+ const verdict = SessionListSemantics.classify(SessionListSemantics.queryFields, next)
+ expect(verdict.ok).toBe(false)
+ })
+
+ it("changing a default fails the gate", () => {
+ const next = SessionListSemantics.queryFields.map((field) =>
+ field.name === "limit" ? { ...field, default: 100 } : field,
+ )
+ const verdict = SessionListSemantics.classify(SessionListSemantics.queryFields, next)
+ expect(verdict.ok).toBe(false)
+ })
+})
diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts
index 5b7d354b04..450aa6bc96 100644
--- a/packages/server/src/handlers/session.ts
+++ b/packages/server/src/handlers/session.ts
@@ -1,8 +1,11 @@
import { SessionV2 } from "@opencode-ai/core/session"
+import { SessionCurrency } from "@opencode-ai/core/session/currency"
+import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { DateTime, Effect, Stream } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { SessionsCursor } from "@opencode-ai/protocol/groups/session"
+import { SessionListSemantics } from "@opencode-ai/protocol/groups/session-list-semantics"
import {
ConflictError,
InvalidCursorError,
@@ -13,7 +16,9 @@ import {
} from "@opencode-ai/protocol/errors"
import { AbsolutePath } from "@opencode-ai/core/schema"
-const DefaultSessionsLimit = 50
+// D6: the default rides the frozen session-list semantics manifest — changing
+// it means changing the manifest in the same PR, or the drift gate goes red.
+const DefaultSessionsLimit = SessionListSemantics.defaults.limit
const DefaultSessionHistoryLimit = 50
export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) =>
@@ -39,6 +44,9 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
const last = sessions.at(-1)
return {
data: sessions,
+ // D2: recomputed from the rendered rows on every response — the
+ // token is derived, never hand-bumped.
+ currency: SessionCurrency.token(SessionCurrency.projection(sessions), InstallationVersion),
cursor: {
previous: first
? SessionsCursor.make({