Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 31 additions & 10 deletions STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,36 +6,57 @@

## Overview

Frankencode is a fork of OpenCode that adds surgical, reversible, agent-driven context editing with content-addressable storage and a conversation history graph. All 4 planned phases are implemented. Currently in hardening/upstream-sync phase.
Frankencode is a fork of OpenCode that adds surgical, reversible, agent-driven context editing with content-addressable storage and a conversation history graph. All 4 planned feature phases are implemented. Upstream sync is complete. Effect-ification of all 16 `Instance.state()` modules is complete.

## Branch Status

| Branch | Status | PR |
|--------|--------|----|
| `dev` | Main development branch | — |
| `fix/code-review-bugs` | 16 bug fixes + 25 tests | [#12](https://github.com/e6qu/frankencode/pull/12) (merged) |
| `docs/upstream-sync-notes` | Docs update with upstream analysis | [#13](https://github.com/e6qu/frankencode/pull/13) |
| `fix/upstream-backports-p1` | Phase 1: 9 upstream bug fixes (B1-B9) | [#16](https://github.com/e6qu/frankencode/pull/16) (merged) |
| `fix/upstream-backports-p2` | Phase 2: 6 upstream bug fixes (B10-B16) | [#17](https://github.com/e6qu/frankencode/pull/17) (merged) |
| `fix/upstream-backports-p3` | Phase 3: 6 upstream app fixes (B17-B22) | [#18](https://github.com/e6qu/frankencode/pull/18) (merged) |
| `fix/upstream-backports-p4` | Phase 4: rebase onto upstream/dev (Effect integration) | pending |
| `fix/upstream-backports-p4` | Phase 4: rebase onto upstream/dev (Effect integration) | [#19](https://github.com/e6qu/frankencode/pull/19) (merged) |
| `refactor/effectify-trivial` | Effect-ification of all 16 Instance.state() modules | In progress |

## Upstream Sync

- **Fully synced** with `upstream/dev` (0 commits behind)
- **15 commits ahead** of upstream (Frankencode features)
- Phase 4 rebase integrated all upstream Effect-ification, SnapshotService, model updates, and misc fixes
- **Fully synced** with `upstream/dev` as of Phase 4 rebase
- **17 commits ahead** of upstream (Frankencode features only)
- 2 new upstream commits since sync: TruncateService effectification (#17957) — minor, next routine sync

## Effect-ification Status

### Already effectified (upstream, integrated in Phase 4):
FileService, FileTimeService, FileWatcherService, VcsService, SkillService, FormatService, QuestionService, PermissionService, ProviderAuthService, SnapshotService

### Converted from `Instance.state()` (this branch):
All 16 modules converted. `Instance.state()` method and `State` module deleted. `Scheduler` module deleted (replaced by inline timer in bootstrap).

Modules with Effect services registered in `instances.ts`:
- EnvService, BusService, SessionStatusService, InstructionService

Modules using `registerDisposer` for lifecycle (can't be in `instances.ts` due to circular deps):
- Config, TuiConfig, Plugin, ToolRegistry, Provider, Agent, Command, Prompt, PTY, LSP, MCP

### Fork modules (no `Instance.state()`, may benefit from Effect services):
- `cas/index.ts` — Database-backed, no caching (OK as-is)
- `cas/graph.ts` — Complex DAG with atomicity needs
- `context-edit/index.ts` — Very complex, 709 lines, transaction semantics
- `session/side-thread.ts` — Simple CRUD (OK as-is)
- `session/objective.ts` — Trivial KV (OK as-is)

## Test Status

- **1430 tests passing**, 0 failures, 8 skipped
- **25 new regression tests** for bug fixes (verify, refine, scripts, skill cache, agent permissions)
- **Typecheck:** clean (`bun typecheck`)
- **1423 tests passing**, 0 failures, 8 skipped
- **25 regression tests** for bug fixes
- **Typecheck:** clean (`bun typecheck`) across all 13 packages

## Bug Status

- **0 active bugs**
- **40 bugs fixed** (tracked in BUGS.md)
- **40 bugs fixed**
- **4 open design issues** (CAS GC, objective staleness, EditGraph leak, CAS ownership)

## Feature Inventory
Expand Down
20 changes: 18 additions & 2 deletions packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ModelID, ProviderID } from "../provider/schema"
import { generateObject, streamObject, type ModelMessage } from "ai"
import { SystemPrompt } from "../session/system"
import { Instance } from "../project/instance"
import { registerDisposer } from "@/effect/instance-registry"
import { Truncate } from "../tool/truncation"
import { Auth } from "../auth"
import { ProviderTransform } from "../provider/transform"
Expand All @@ -26,6 +27,11 @@ import path from "path"
import { Plugin } from "@/plugin"
import { Skill } from "../skill"

export const agentStates = new Map<string, Promise<Record<string, Agent.Info>>>()
registerDisposer(async (directory) => {
agentStates.delete(directory)
})

export namespace Agent {
export const Info = z
.object({
Expand Down Expand Up @@ -54,7 +60,17 @@ export namespace Agent {
})
export type Info = z.infer<typeof Info>

const state = Instance.state(async () => {
function state(): Promise<Record<string, Info>> {
const dir = Instance.directory
let s = agentStates.get(dir)
if (!s) {
s = initAgents()
agentStates.set(dir, s)
}
return s
}

async function initAgents(): Promise<Record<string, Info>> {
const cfg = await Config.get()

const skillDirs = await Skill.dirs()
Expand Down Expand Up @@ -361,7 +377,7 @@ export namespace Agent {
}

return result
})
}

export async function get(agent: string) {
return state().then((x) => x[agent])
Expand Down
83 changes: 60 additions & 23 deletions packages/opencode/src/bus/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,20 @@ import { Log } from "../util/log"
import { Instance } from "../project/instance"
import { BusEvent } from "./bus-event"
import { GlobalBus } from "./global"
import { Effect, Layer, ServiceMap } from "effect"

type BusSubscription = (event: any) => void
const states = new Map<string, { subscriptions: Map<any, BusSubscription[]> }>()

function state() {
const dir = Instance.directory
let s = states.get(dir)
if (!s) {
s = { subscriptions: new Map() }
states.set(dir, s)
}
return s
}

export namespace Bus {
const log = Log.create({ service: "bus" })
Expand All @@ -15,29 +29,6 @@ export namespace Bus {
}),
)

const state = Instance.state(
() => {
const subscriptions = new Map<any, Subscription[]>()

return {
subscriptions,
}
},
async (entry) => {
const wildcard = entry.subscriptions.get("*")
if (!wildcard) return
const event = {
type: InstanceDisposed.type,
properties: {
directory: Instance.directory,
},
}
for (const sub of [...wildcard]) {
sub(event)
}
},
)

export async function publish<Definition extends BusEvent.Definition>(
def: Definition,
properties: z.output<Definition["properties"]>,
Expand Down Expand Up @@ -104,3 +95,49 @@ export namespace Bus {
}
}
}

export namespace BusService {
export interface Service {
readonly publish: typeof Bus.publish
readonly subscribe: typeof Bus.subscribe
readonly once: typeof Bus.once
readonly subscribeAll: typeof Bus.subscribeAll
}
}

export class BusService extends ServiceMap.Service<BusService, BusService.Service>()("@opencode/Bus") {
static readonly layer = Layer.effect(
BusService,
Effect.gen(function* () {
const dir = Instance.directory
let s = states.get(dir)
if (!s) {
s = { subscriptions: new Map() }
states.set(dir, s)
}
const entry = s
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
// Emit InstanceDisposed to wildcard subscribers before cleanup
const wildcard = entry.subscriptions.get("*")
if (wildcard) {
const event = {
type: Bus.InstanceDisposed.type,
properties: { directory: dir },
}
for (const sub of [...wildcard]) {
sub(event)
}
}
states.delete(dir)
}),
)
return BusService.of({
publish: Bus.publish,
subscribe: Bus.subscribe,
once: Bus.once,
subscribeAll: Bus.subscribeAll,
})
}),
)
}
20 changes: 18 additions & 2 deletions packages/opencode/src/command/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { SessionID, MessageID } from "@/session/schema"
import z from "zod"
import { Config } from "../config/config"
import { Instance } from "../project/instance"
import { registerDisposer } from "@/effect/instance-registry"
import { Identifier } from "../id/id"
import PROMPT_INITIALIZE from "./template/initialize.txt"
import PROMPT_REVIEW from "./template/review.txt"
Expand All @@ -20,6 +21,11 @@ import PROMPT_VERIFY from "./template/verify.txt"
import { MCP } from "../mcp"
import { Skill } from "../skill"

export const commandStates = new Map<string, Promise<Record<string, Command.Info>>>()
registerDisposer(async (directory) => {
commandStates.delete(directory)
})

export namespace Command {
export const Event = {
Executed: BusEvent.define(
Expand Down Expand Up @@ -80,7 +86,17 @@ export namespace Command {
VERIFY: "verify",
} as const

const state = Instance.state(async () => {
function state(): Promise<Record<string, Info>> {
const dir = Instance.directory
let s = commandStates.get(dir)
if (!s) {
s = initCommands()
commandStates.set(dir, s)
}
return s
}

async function initCommands(): Promise<Record<string, Info>> {
const cfg = await Config.get()

const result: Record<string, Info> = {
Expand Down Expand Up @@ -272,7 +288,7 @@ export namespace Command {
}

return result
})
}

export async function get(name: string) {
return state().then((x) => x[name])
Expand Down
24 changes: 20 additions & 4 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
printParseErrorCode,
} from "jsonc-parser"
import { Instance } from "../project/instance"
import { registerDisposer } from "@/effect/instance-registry"
import { LSPServer } from "../lsp/server"
import { BunProc } from "@/bun"
import { Installation } from "@/installation"
Expand All @@ -39,6 +40,12 @@ import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process"
import { Lock } from "@/util/lock"

type ConfigStateResult = { config: Config.Info; directories: string[]; deps: Promise<void>[] }
export const configStates = new Map<string, Promise<ConfigStateResult>>()
registerDisposer(async (directory) => {
configStates.delete(directory)
})

export namespace Config {
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })

Expand Down Expand Up @@ -75,7 +82,17 @@ export namespace Config {
return merged
}

export const state = Instance.state(async () => {
function state(): Promise<ConfigStateResult> {
const dir = Instance.directory
let s = configStates.get(dir)
if (!s) {
s = initConfig()
configStates.set(dir, s)
}
return s
}

async function initConfig(): Promise<ConfigStateResult> {
const auth = await Auth.all()

// Config loading order (low -> high precedence): https://opencode.ai/docs/config#precedence-order
Expand Down Expand Up @@ -263,7 +280,7 @@ export namespace Config {
directories,
deps,
}
})
}

export async function waitForDependencies() {
const deps = await state().then((x) => x.deps)
Expand Down Expand Up @@ -1402,6 +1419,7 @@ export namespace Config {
const filepath = path.join(Instance.directory, "config.json")
const existing = await loadFile(filepath)
await Filesystem.writeJson(filepath, mergeDeep(existing, config))
configStates.delete(Instance.directory)
await Instance.dispose()
}

Expand Down Expand Up @@ -1512,5 +1530,3 @@ export namespace Config {
return state().then((x) => x.directories)
}
}
Filesystem.write
Filesystem.write
20 changes: 18 additions & 2 deletions packages/opencode/src/config/tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ import { Instance } from "@/project/instance"
import { Flag } from "@/flag/flag"
import { Log } from "@/util/log"
import { Global } from "@/global"
import { registerDisposer } from "@/effect/instance-registry"

const tuiStates = new Map<string, Promise<{ config: TuiConfig.Info }>>()
registerDisposer(async (directory) => {
tuiStates.delete(directory)
})

export namespace TuiConfig {
const log = Log.create({ service: "tui.config" })
Expand All @@ -25,7 +31,17 @@ export namespace TuiConfig {
return Flag.OPENCODE_TUI_CONFIG
}

const state = Instance.state(async () => {
function state() {
const dir = Instance.directory
let s = tuiStates.get(dir)
if (!s) {
s = initTuiConfig()
tuiStates.set(dir, s)
}
return s
}

async function initTuiConfig() {
let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? []
: await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree)
Expand Down Expand Up @@ -71,7 +87,7 @@ export namespace TuiConfig {
return {
config: result,
}
})
}

export async function get() {
return state().then((x) => x.config)
Expand Down
Loading
Loading