Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
203505a
feat(core): speak opencode-pty protocol v8 and add pipe process API
jlongster Nov 5, 2026
8ecfa5c
feat(core): track planned-shutdown detaching state
jlongster Nov 5, 2026
3a486ee
feat(core): stop shell commands without forgetting them
jlongster Nov 5, 2026
90f7884
fix(core): leave jobs unsettled when teardown interrupts them
jlongster Nov 5, 2026
c0cc5e6
fix(core): keep a shell command's output when its call is interrupted
jlongster Nov 5, 2026
592a5ce
feat(core): run local shell commands in the process daemon
jlongster Nov 5, 2026
1b02e47
test(core): cover daemon-backed shell commands with the fake daemon
jlongster Nov 5, 2026
9467bcc
feat(core): reattach shell commands after a planned restart
jlongster Nov 5, 2026
81c2b7e
test(core): cover planned-shutdown recording and the real daemon path
jlongster Nov 5, 2026
c13fa9b
fix(core): give up at once on an incompatible opencode-pty binary
jlongster Nov 5, 2026
17ce2b3
fix(core): keep an interrupted tool's settled call interrupted
jlongster Nov 5, 2026
805b9f1
fix(core): tell shutdown and Location close apart from a user interrupt
jlongster Nov 5, 2026
1d4455b
fix(core): settle killed shell calls before recovered children resume
jlongster Nov 5, 2026
1efd16a
fix(core): leave a closed Location's daemon commands to its replacement
jlongster Nov 5, 2026
f94671a
test(core): cover shell recovery ordering after a killed server
jlongster Nov 5, 2026
d986836
test(core): run shell daemon tests on the fake daemon and the test clock
jlongster Nov 5, 2026
1ef8f93
test(core): check a relative cwd resolves against the Location
jlongster Nov 5, 2026
3050c9b
chore: use @opencode/pty 0.5.0
jlongster Nov 5, 2026
923b926
fix(core): keep watching daemon commands when their Location closes
jlongster Nov 5, 2026
0d93fd7
test(core): drive shell daemon timers with the test clock and fake-da…
jlongster Nov 5, 2026
992ff96
fix(core): keep an interrupted shell call's output visible in clients
jlongster Nov 5, 2026
e2f1dab
fix(core): report a killed shell command as killed
jlongster Nov 5, 2026
11c9997
fix(core): keep background jobs running for the next server on any sh…
jlongster Nov 5, 2026
0475da6
fix(core): forget a prepared handoff when the daemon is shut down
jlongster Nov 5, 2026
71005e7
refactor(core): keep daemon shell commands in one server-wide registry
jlongster Nov 5, 2026
6739eb5
refactor(core): record a foreground shell command in the daemon inste…
jlongster Nov 5, 2026
703d627
fix(core): settle an interrupted shell call without waiting for the stop
jlongster Nov 5, 2026
9c5e408
fix(core): stop what a failed daemon shell command left in its group
jlongster Nov 5, 2026
744907b
fix(core): forget stale finished daemon processes in the shell sweep
jlongster Nov 5, 2026
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
24 changes: 12 additions & 12 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"@effect/platform-node": "catalog:",
"@opencode/client": "workspace:*",
"@opencode/plugin": "workspace:*",
"@opencode/pty": "0.4.0",
"@opencode/pty": "0.5.0",
"@opencode/schema": "workspace:*",
"@opencode/server": "workspace:*",
"@opencode/tui": "workspace:*",
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@
"@modelcontextprotocol/client": "2.3.1",
"@modelcontextprotocol/core": "2.3.1",
"@modelcontextprotocol/server": "2.3.1",
"@opencode/pty": "0.4.0",
"@opencode/pty": "0.5.0",
"@opencode/ai": "workspace:*",
"@opencode/codemode": "workspace:*",
"@opencode/plugin": "workspace:*",
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,9 @@ export interface Interface extends State.Transformable<Editor> {
readonly provider?: ProviderID
}) => Effect.Effect<readonly Info[], DisabledError | Session.NotFoundError>
readonly create: (input: CreateInput) => Effect.Effect<Info, Error>
readonly get: (id: ID) => Effect.Effect<Content, DisabledError | NotFoundError | ProviderNotFoundError | ProviderError>
readonly get: (
id: ID,
) => Effect.Effect<Content, DisabledError | NotFoundError | ProviderNotFoundError | ProviderError>
readonly info: (id: ID) => Effect.Effect<Info, DisabledError | NotFoundError>
readonly update: (id: ID, input: UpdateInput) => Effect.Effect<Info, Error>
readonly remove: (id: ID) => Effect.Effect<void, DisabledError | NotFoundError | ProviderError>
Expand Down
51 changes: 51 additions & 0 deletions packages/core/src/detaching.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
export * as Detaching from "./detaching.js"

import { Context, Effect, Layer } from "effect"
import { makeGlobalNode } from "@opencode/util/effect/app-node"

/**
* Planned-shutdown state of this server process.
*
* A prepared daemon handoff means a new server will take over the daemon's processes. If this server
* then shuts down, it is detaching: work that can outlive it in the daemon is left running for the new
* server instead of being stopped. Without a prepared handoff (a normal quit), shutdown stops everything
* as before.
*/
export interface Interface {
/** Records that a daemon handoff was prepared successfully. */
readonly handoffPrepared: Effect.Effect<void>
/** Records that the prepared handoff no longer holds: the daemon was shut down, so nothing can outlive this server. */
readonly handoffDropped: Effect.Effect<void>
/** Called once shutdown starts, before any service is torn down. Returns whether this server is detaching. */
readonly begin: Effect.Effect<boolean>
readonly isDetaching: () => boolean
/** Whether shutdown has started, detaching or not. Interrupts from then on are not the user's. */
readonly isStopping: () => boolean
}

export class Service extends Context.Service<Service, Interface>()("@opencode/Detaching") {}

export const make = Effect.sync(() => {
let prepared = false
let detaching = false
let stopping = false
return Service.of({
handoffPrepared: Effect.sync(() => {
prepared = true
}),
handoffDropped: Effect.sync(() => {
prepared = false
}),
begin: Effect.sync(() => {
stopping = true
detaching = prepared
return detaching
}),
isDetaching: () => detaching,
isStopping: () => stopping,
})
})

export const layer = Layer.effect(Service, make)

export const node = makeGlobalNode({ service: Service, layer, deps: [] })
3 changes: 3 additions & 0 deletions packages/core/src/file-retention.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export const cleanup = Effect.fn("FileRetention.cleanup")(function* (
fs: FSUtil.Interface,
files: ReadonlyArray<string>,
retention: Duration.Input,
// Runs before an expired file is removed.
beforeRemove?: (file: string) => Effect.Effect<void>,
) {
const cutoff = Date.now() - Duration.toMillis(retention)
yield* Effect.forEach(
Expand All @@ -16,6 +18,7 @@ export const cleanup = Effect.fn("FileRetention.cleanup")(function* (
const info = yield* fs.stat(file).pipe(Effect.orElseSucceed(() => undefined))
const mtime = info && Option.getOrUndefined(info.mtime)
if (!mtime || mtime.getTime() >= cutoff) return
if (beforeRemove) yield* beforeRemove(file)
yield* fs.remove(file).pipe(Effect.ignore)
}),
{ concurrency: 8, discard: true },
Expand Down
92 changes: 49 additions & 43 deletions packages/core/src/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,24 +90,26 @@ export const layer = Layer.effect(
const decisionClient = yield* DecisionClient.Service

const runText = Effect.fn("Generate.text")(function* (input: TextInput) {
const resolved = yield* resolver.resolve(input.model).pipe(
Effect.catchTag(
[
"SessionRunnerModel.VariantUnavailableError",
"SessionRunnerModel.UnsupportedPackageError",
"SessionRunnerModel.ModelConfigurationError",
"SessionRunnerModel.ModelInitializationError",
"SessionRunnerModel.UnresolvedProviderVariablesError",
"SessionRunnerModel.UnsupportedCompactionError",
],
(error) =>
Effect.fail(
input.model
? resolutionError(input.model, "chat", error)
: new UnavailableError({ message: error.message, service: error.providerID }),
),
),
)
const resolved = yield* resolver
.resolve(input.model)
.pipe(
Effect.catchTag(
[
"SessionRunnerModel.VariantUnavailableError",
"SessionRunnerModel.UnsupportedPackageError",
"SessionRunnerModel.ModelConfigurationError",
"SessionRunnerModel.ModelInitializationError",
"SessionRunnerModel.UnresolvedProviderVariablesError",
"SessionRunnerModel.UnsupportedCompactionError",
],
(error) =>
Effect.fail(
input.model
? resolutionError(input.model, "chat", error)
: new UnavailableError({ message: error.message, service: error.providerID }),
),
),
)
if (!resolved && input.model) return yield* missing(input.model, "chat")
if (!resolved)
return yield* new UnavailableError({ message: "No model specified and no supported model is available" })
Expand Down Expand Up @@ -152,18 +154,20 @@ export const layer = Layer.effect(
.pipe(Effect.mapError((error) => resolutionError(input.model, "image", error)))
if (!model) return yield* missing(input.model, "image")
// `ai.image.generate` turns an invalid request into a typed error; building it with `ai.image.request` would throw.
const response = yield* ai.image.generate({
model,
prompt: input.prompt,
images: input.images?.map((file) => Media.base64(file.data, file.mime)),
n: input.n,
aspect: input.aspect,
resolution: input.resolution,
}).pipe(
Effect.provideService(ImageClient.Service, images),
ModelRetry.retryOneShot,
Effect.mapError((error) => providerError(input.model, error)),
)
const response = yield* ai.image
.generate({
model,
prompt: input.prompt,
images: input.images?.map((file) => Media.base64(file.data, file.mime)),
n: input.n,
aspect: input.aspect,
resolution: input.resolution,
})
.pipe(
Effect.provideService(ImageClient.Service, images),
ModelRetry.retryOneShot,
Effect.mapError((error) => providerError(input.model, error)),
)
// URL results are downloaded so every image comes back inline, like the request's own files.
// Materializing also detects the type of a downloaded image from its bytes when the URL didn't say.
const outputs = yield* Effect.forEach(response.images, (asset) =>
Expand All @@ -190,19 +194,21 @@ export const layer = Layer.effect(
.resolveDecision(input.model)
.pipe(Effect.mapError((error) => resolutionError(input.model, "decision", error)))
if (!model) return yield* missing(input.model, "decision")
const response = yield* ai.decision.generate({
model,
input: input.input.map((part) =>
part.type === "file"
? { type: "media" as const, media: Media.base64(part.data, part.mime), filename: part.name }
: part,
),
questions: input.questions,
}).pipe(
Effect.provideService(DecisionClient.Service, decisionClient),
ModelRetry.retryOneShot,
Effect.mapError((error) => providerError(input.model, error)),
)
const response = yield* ai.decision
.generate({
model,
input: input.input.map((part) =>
part.type === "file"
? { type: "media" as const, media: Media.base64(part.data, part.mime), filename: part.name }
: part,
),
questions: input.questions,
})
.pipe(
Effect.provideService(DecisionClient.Service, decisionClient),
ModelRetry.retryOneShot,
Effect.mapError((error) => providerError(input.model, error)),
)
const usage = response.usage
return {
answers: response.answers,
Expand Down
34 changes: 32 additions & 2 deletions packages/core/src/job.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
export * as Job from "./job.js"

import { asc, eq, or } from "drizzle-orm"
import { Array, Cause, Clock, Context, Deferred, Effect, Exit, Layer, Schema, Scope, SynchronizedRef } from "effect"
import {
Array,
Cause,
Clock,
Context,
Deferred,
Effect,
Exit,
Layer,
Option,
Schema,
Scope,
SynchronizedRef,
} from "effect"
import { makeGlobalNode } from "@opencode/util/effect/app-node"
import { Database } from "./database/database.js"
import { Detaching } from "./detaching.js"
import { JobList } from "./job/list.js"
import { type Recovery, RecoverySchema } from "./job/recovery.js"
import { JobTable } from "./job/sql.js"
Expand Down Expand Up @@ -59,6 +73,8 @@ type State = {
type FinishResult = {
info?: Info
done?: Deferred.Deferred<Info>
// Teardown interrupted the work: `done` is interrupted instead of settled.
interrupted?: boolean
scope?: Scope.Closeable
generation?: Scope.Closeable
}
Expand Down Expand Up @@ -187,6 +203,8 @@ function decrementSession(input: Map<SessionSchema.ID, number>, sessionID: Sessi
*/
export const make = Effect.gen(function* () {
const db = (yield* Database.Service).db
// Optional so registries built outside the application graph (tests, tools) need not provide it.
const detaching = Option.getOrUndefined(yield* Effect.serviceOption(Detaching.Service))
const state: State = {
jobs: yield* SynchronizedRef.make(new Map()),
scope: yield* Scope.Scope,
Expand Down Expand Up @@ -248,6 +266,15 @@ export const make = Effect.gen(function* () {
if (!job) return [{}, jobs]
if (job.scope !== scope) return [{}, jobs]
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
// Teardown (this registry closing, or any server shutdown) is not a terminal outcome: leave the job
// and its durable row running for the next server, and interrupt rather than settle its observers.
// A user cancel has already settled the job as cancelled before interrupting its work.
if (
Exit.isFailure(exit) &&
Cause.hasInterruptsOnly(exit.cause) &&
(scope.state._tag === "Closed" || detaching?.isStopping())
)
return [{ done: job.done, scope: job.scope, interrupted: true }, jobs]
const status: Exclude<Status, "running"> = Exit.isSuccess(exit)
? "completed"
: Cause.hasInterruptsOnly(exit.cause)
Expand All @@ -268,6 +295,7 @@ export const make = Effect.gen(function* () {
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
}),
)
if (result.interrupted && result.done) yield* Deferred.interrupt(result.done)
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info)
if (result.scope) {
yield* Scope.close(result.scope, Exit.void).pipe(Effect.forkIn(state.scope, { startImmediately: true }))
Expand Down Expand Up @@ -295,6 +323,8 @@ export const make = Effect.gen(function* () {
return [{ info: snapshot(existing) }, jobs]
}
const scope = yield* Scope.fork(state.scope, "parallel")
// Closing the registry interrupts the work without settling it; interrupt its observers too.
yield* Scope.addFinalizer(scope, Deferred.interrupt(done))
const job = {
info: {
id: input.id,
Expand Down Expand Up @@ -551,4 +581,4 @@ export const make = Effect.gen(function* () {

const layer = Layer.effect(Service, make)

export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] })
export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node, Detaching.node] })
Loading
Loading