diff --git a/packages/core/schema.json b/packages/core/schema.json
index fe843eb9272d..01a080c9de0e 100644
--- a/packages/core/schema.json
+++ b/packages/core/schema.json
@@ -2,9 +2,7 @@
"version": "7",
"dialect": "sqlite",
"id": "2d214a71-3b0a-48c1-a667-741952c4e188",
- "prevIds": [
- "f14a9b18-8207-487e-a3d3-227e629ba9ad"
- ],
+ "prevIds": ["f14a9b18-8207-487e-a3d3-227e629ba9ad"],
"ddl": [
{
"name": "workspace",
@@ -1385,13 +1383,9 @@
"table": "session_v2"
},
{
- "columns": [
- "project_id"
- ],
+ "columns": ["project_id"],
"tableTo": "project",
- "columnsTo": [
- "id"
- ],
+ "columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1400,13 +1394,9 @@
"table": "workspace"
},
{
- "columns": [
- "active_account_id"
- ],
+ "columns": ["active_account_id"],
"tableTo": "account",
- "columnsTo": [
- "id"
- ],
+ "columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "SET NULL",
"nameExplicit": false,
@@ -1415,13 +1405,9 @@
"table": "account_state"
},
{
- "columns": [
- "aggregate_id"
- ],
+ "columns": ["aggregate_id"],
"tableTo": "event_sequence",
- "columnsTo": [
- "aggregate_id"
- ],
+ "columnsTo": ["aggregate_id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1430,13 +1416,9 @@
"table": "event"
},
{
- "columns": [
- "project_id"
- ],
+ "columns": ["project_id"],
"tableTo": "project",
- "columnsTo": [
- "id"
- ],
+ "columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1445,13 +1427,9 @@
"table": "permission"
},
{
- "columns": [
- "project_id"
- ],
+ "columns": ["project_id"],
"tableTo": "project",
- "columnsTo": [
- "id"
- ],
+ "columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1460,13 +1438,9 @@
"table": "project_directory"
},
{
- "columns": [
- "session_id"
- ],
+ "columns": ["session_id"],
"tableTo": "session_v2",
- "columnsTo": [
- "id"
- ],
+ "columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1475,13 +1449,9 @@
"table": "instruction_entry"
},
{
- "columns": [
- "session_id"
- ],
+ "columns": ["session_id"],
"tableTo": "session_v2",
- "columnsTo": [
- "id"
- ],
+ "columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1490,13 +1460,9 @@
"table": "instruction_state"
},
{
- "columns": [
- "session_id"
- ],
+ "columns": ["session_id"],
"tableTo": "session_v2",
- "columnsTo": [
- "id"
- ],
+ "columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1505,13 +1471,9 @@
"table": "session_message"
},
{
- "columns": [
- "session_id"
- ],
+ "columns": ["session_id"],
"tableTo": "session_v2",
- "columnsTo": [
- "id"
- ],
+ "columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1520,13 +1482,9 @@
"table": "session_pending"
},
{
- "columns": [
- "project_id"
- ],
+ "columns": ["project_id"],
"tableTo": "project",
- "columnsTo": [
- "id"
- ],
+ "columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1535,156 +1493,119 @@
"table": "session_v2"
},
{
- "columns": [
- "email",
- "url"
- ],
+ "columns": ["email", "url"],
"nameExplicit": false,
"name": "control_account_pk",
"entityType": "pks",
"table": "control_account"
},
{
- "columns": [
- "project_id",
- "directory"
- ],
+ "columns": ["project_id", "directory"],
"nameExplicit": false,
"name": "project_directory_pk",
"entityType": "pks",
"table": "project_directory"
},
{
- "columns": [
- "session_id",
- "key"
- ],
+ "columns": ["session_id", "key"],
"nameExplicit": false,
"name": "instruction_entry_pk",
"entityType": "pks",
"table": "instruction_entry"
},
{
- "columns": [
- "id"
- ],
+ "columns": ["id"],
"nameExplicit": false,
"name": "workspace_pk",
"table": "workspace",
"entityType": "pks"
},
{
- "columns": [
- "id"
- ],
+ "columns": ["id"],
"nameExplicit": false,
"name": "account_state_pk",
"table": "account_state",
"entityType": "pks"
},
{
- "columns": [
- "id"
- ],
+ "columns": ["id"],
"nameExplicit": false,
"name": "account_pk",
"table": "account",
"entityType": "pks"
},
{
- "columns": [
- "id"
- ],
+ "columns": ["id"],
"nameExplicit": false,
"name": "credential_pk",
"table": "credential",
"entityType": "pks"
},
{
- "columns": [
- "aggregate_id"
- ],
+ "columns": ["aggregate_id"],
"nameExplicit": false,
"name": "event_sequence_pk",
"table": "event_sequence",
"entityType": "pks"
},
{
- "columns": [
- "id"
- ],
+ "columns": ["id"],
"nameExplicit": false,
"name": "event_pk",
"table": "event",
"entityType": "pks"
},
{
- "columns": [
- "key"
- ],
+ "columns": ["key"],
"nameExplicit": false,
"name": "kv_pk",
"table": "kv",
"entityType": "pks"
},
{
- "columns": [
- "id"
- ],
+ "columns": ["id"],
"nameExplicit": false,
"name": "permission_pk",
"table": "permission",
"entityType": "pks"
},
{
- "columns": [
- "id"
- ],
+ "columns": ["id"],
"nameExplicit": false,
"name": "project_pk",
"table": "project",
"entityType": "pks"
},
{
- "columns": [
- "hash"
- ],
+ "columns": ["hash"],
"nameExplicit": false,
"name": "instruction_blob_pk",
"table": "instruction_blob",
"entityType": "pks"
},
{
- "columns": [
- "session_id"
- ],
+ "columns": ["session_id"],
"nameExplicit": false,
"name": "instruction_state_pk",
"table": "instruction_state",
"entityType": "pks"
},
{
- "columns": [
- "id"
- ],
+ "columns": ["id"],
"nameExplicit": false,
"name": "session_message_pk",
"table": "session_message",
"entityType": "pks"
},
{
- "columns": [
- "id"
- ],
+ "columns": ["id"],
"nameExplicit": false,
"name": "session_pending_pk",
"table": "session_pending",
"entityType": "pks"
},
{
- "columns": [
- "id"
- ],
+ "columns": ["id"],
"nameExplicit": false,
"name": "session_v2_pk",
"table": "session_v2",
@@ -1940,4 +1861,4 @@
}
],
"renames": []
-}
\ No newline at end of file
+}
diff --git a/packages/core/script/benchmark-location.ts b/packages/core/script/benchmark-location.ts
index d29fed63b558..60390db77259 100644
--- a/packages/core/script/benchmark-location.ts
+++ b/packages/core/script/benchmark-location.ts
@@ -20,9 +20,7 @@ if (!Number.isInteger(iterations) || iterations < 1) {
}
const ref = Location.Ref.make({ directory: AbsolutePath.make(path.resolve(directory)) })
-const layer = AppNodeBuilder.build(
- LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]),
-)
+const layer = AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]))
const measure = (effect: Effect.Effect) =>
Effect.gen(function* () {
diff --git a/packages/core/src/bus.ts b/packages/core/src/bus.ts
index fc4bf9c1cda5..01182f1c82d5 100644
--- a/packages/core/src/bus.ts
+++ b/packages/core/src/bus.ts
@@ -110,10 +110,13 @@ export interface Subscribe {
*/
(): Stream.Stream
(definition: D): Stream.Stream>
- (definitions: D): Stream.Stream>
+ (
+ definitions: D,
+ ): Stream.Stream>
}
-const isDefinition = (input: Event.Definition | readonly Event.Definition[]): input is Event.Definition => !Array.isArray(input)
+const isDefinition = (input: Event.Definition | readonly Event.Definition[]): input is Event.Definition =>
+ !Array.isArray(input)
export interface Interface {
readonly publish: (
@@ -149,7 +152,6 @@ export interface Interface {
readonly claim: (aggregateID: string, ownerID: string) => Effect.Effect
}
-
export class Service extends Context.Service()("@opencode/Bus") {}
interface Options {
@@ -164,534 +166,543 @@ export function configured(options?: Options) {
return makeGlobalNode({
service: Service,
deps: [Database.node],
- layer: Layer.effect(Service, Effect.gen(function* () {
- const pubsub = {
- live: yield* PubSub.unbounded(),
- durable: new Map>>(),
- typed: new Map>(),
- }
- const projectors = new Map()
- const listeners = new Array()
- const { db } = yield* Database.Service
- const logReadPageSize = options?.logReadPageSize ?? 512
- const persist = options?.persist ?? false
-
- const getOrCreate = (definition: Event.Definition) =>
- Effect.gen(function* () {
- const existing = pubsub.typed.get(definition.type)
- if (existing) return existing
- const created = yield* PubSub.unbounded()
- pubsub.typed.set(definition.type, created)
- return created
- })
+ layer: Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const pubsub = {
+ live: yield* PubSub.unbounded(),
+ durable: new Map>>(),
+ typed: new Map>(),
+ }
+ const projectors = new Map()
+ const listeners = new Array()
+ const { db } = yield* Database.Service
+ const logReadPageSize = options?.logReadPageSize ?? 512
+ const persist = options?.persist ?? false
- yield* Effect.addFinalizer(() =>
- Effect.gen(function* () {
- yield* PubSub.shutdown(pubsub.live)
- yield* Effect.forEach(
- pubsub.durable.values(),
- (pubsubs) => Effect.forEach(pubsubs, PubSub.shutdown, { discard: true }),
- { discard: true },
- )
- yield* Effect.forEach(pubsub.typed.values(), PubSub.shutdown, { discard: true })
- }),
- )
-
- function commitDurableEvent(
- definition: Event.Definition,
- event: Event.Payload,
- input?: {
- readonly seq: number
- readonly aggregateID: string
- readonly ownerID?: string
- readonly strictOwner?: boolean
- },
- commit?: (seq: number) => Effect.Effect,
- ) {
- return Effect.gen(function* () {
- const durable = definition?.durable
- if (durable) {
- const aggregateID = (event.data as Record)[durable.aggregate]
- if (typeof aggregateID !== "string") {
- yield* Effect.die(
- new InvalidDurableEventError({
- type: event.type,
- message: `Expected string aggregate field ${durable.aggregate}`,
- }),
- )
- } else {
- if (input && input.aggregateID !== aggregateID) {
+ const getOrCreate = (definition: Event.Definition) =>
+ Effect.gen(function* () {
+ const existing = pubsub.typed.get(definition.type)
+ if (existing) return existing
+ const created = yield* PubSub.unbounded()
+ pubsub.typed.set(definition.type, created)
+ return created
+ })
+
+ yield* Effect.addFinalizer(() =>
+ Effect.gen(function* () {
+ yield* PubSub.shutdown(pubsub.live)
+ yield* Effect.forEach(
+ pubsub.durable.values(),
+ (pubsubs) => Effect.forEach(pubsubs, PubSub.shutdown, { discard: true }),
+ { discard: true },
+ )
+ yield* Effect.forEach(pubsub.typed.values(), PubSub.shutdown, { discard: true })
+ }),
+ )
+
+ function commitDurableEvent(
+ definition: Event.Definition,
+ event: Event.Payload,
+ input?: {
+ readonly seq: number
+ readonly aggregateID: string
+ readonly ownerID?: string
+ readonly strictOwner?: boolean
+ },
+ commit?: (seq: number) => Effect.Effect,
+ ) {
+ return Effect.gen(function* () {
+ const durable = definition?.durable
+ if (durable) {
+ const aggregateID = (event.data as Record)[durable.aggregate]
+ if (typeof aggregateID !== "string") {
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
- message: `Aggregate mismatch: expected ${input.aggregateID}, got ${aggregateID}`,
+ message: `Expected string aggregate field ${durable.aggregate}`,
}),
)
- }
- const list = projectors.get(versionedType(definition.type, durable.version)) ?? []
- return yield* Effect.uninterruptible(
- Effect.gen(function* () {
- const committed = yield* db
- .transaction(
- () =>
- Effect.gen(function* () {
- const row = yield* db
- .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
- .from(EventSequenceTable)
- .where(eq(EventSequenceTable.aggregate_id, aggregateID))
- .get()
- .pipe(Effect.orDie)
- const latest = row?.seq ?? -1
- const encoded = Schema.encodeUnknownSync(definition.data)(event.data) as Record<
- string,
- unknown
- >
- if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) {
- yield* Effect.die(
- new InvalidDurableEventError({
- type: event.type,
- message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`,
- }),
- )
- }
- if (input && input.seq <= latest) {
- if (!persist) return
- const stored = yield* db
- .select()
- .from(EventTable)
- .where(and(eq(EventTable.aggregate_id, aggregateID), eq(EventTable.seq, input.seq)))
+ } else {
+ if (input && input.aggregateID !== aggregateID) {
+ yield* Effect.die(
+ new InvalidDurableEventError({
+ type: event.type,
+ message: `Aggregate mismatch: expected ${input.aggregateID}, got ${aggregateID}`,
+ }),
+ )
+ }
+ const list = projectors.get(versionedType(definition.type, durable.version)) ?? []
+ return yield* Effect.uninterruptible(
+ Effect.gen(function* () {
+ const committed = yield* db
+ .transaction(
+ () =>
+ Effect.gen(function* () {
+ const row = yield* db
+ .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
+ .from(EventSequenceTable)
+ .where(eq(EventSequenceTable.aggregate_id, aggregateID))
.get()
.pipe(Effect.orDie)
- if (
- stored?.id === event.id &&
- stored.type === versionedType(definition.type, durable.version) &&
- stored.created === DateTime.toEpochMillis(event.created ?? DateTime.makeUnsafe(0)) &&
- isDeepStrictEqual(stored.data, encoded)
- ) {
- if (input.ownerID && row?.ownerID == null) {
- yield* db
- .update(EventSequenceTable)
- .set({ owner_id: input.ownerID })
- .where(eq(EventSequenceTable.aggregate_id, aggregateID))
- .run()
- .pipe(Effect.orDie)
+ const latest = row?.seq ?? -1
+ const encoded = Schema.encodeUnknownSync(definition.data)(event.data) as Record<
+ string,
+ unknown
+ >
+ if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) {
+ yield* Effect.die(
+ new InvalidDurableEventError({
+ type: event.type,
+ message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`,
+ }),
+ )
+ }
+ if (input && input.seq <= latest) {
+ if (!persist) return
+ const stored = yield* db
+ .select()
+ .from(EventTable)
+ .where(and(eq(EventTable.aggregate_id, aggregateID), eq(EventTable.seq, input.seq)))
+ .get()
+ .pipe(Effect.orDie)
+ if (
+ stored?.id === event.id &&
+ stored.type === versionedType(definition.type, durable.version) &&
+ stored.created === DateTime.toEpochMillis(event.created ?? DateTime.makeUnsafe(0)) &&
+ isDeepStrictEqual(stored.data, encoded)
+ ) {
+ if (input.ownerID && row?.ownerID == null) {
+ yield* db
+ .update(EventSequenceTable)
+ .set({ owner_id: input.ownerID })
+ .where(eq(EventSequenceTable.aggregate_id, aggregateID))
+ .run()
+ .pipe(Effect.orDie)
+ }
+ return
}
+ yield* Effect.die(
+ new InvalidDurableEventError({
+ type: event.type,
+ message: `Replay diverged at aggregate ${aggregateID} sequence ${input.seq}`,
+ }),
+ )
+ }
+ if (input && row?.ownerID && row.ownerID !== input.ownerID) {
return
}
- yield* Effect.die(
- new InvalidDurableEventError({
- type: event.type,
- message: `Replay diverged at aggregate ${aggregateID} sequence ${input.seq}`,
- }),
- )
- }
- if (input && row?.ownerID && row.ownerID !== input.ownerID) {
- return
- }
- const seq = input?.seq ?? latest + 1
- if (input && seq !== latest + 1) {
- yield* Effect.die(
- new InvalidDurableEventError({
- type: event.type,
- message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`,
- }),
- )
- }
- if (persist) {
- const stored = yield* db
- .select({ aggregateID: EventTable.aggregate_id, seq: EventTable.seq })
- .from(EventTable)
- .where(eq(EventTable.id, event.id))
- .get()
- .pipe(Effect.orDie)
- if (stored)
+ const seq = input?.seq ?? latest + 1
+ if (input && seq !== latest + 1) {
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
- message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`,
+ message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`,
}),
)
- }
- const committed = {
- ...event,
- durable: { aggregateID, seq, version: durable.version },
- } as Event.Payload
- for (const projector of list) {
- yield* projector(committed)
- }
- if (commit) yield* commit(seq)
- yield* db
- .insert(EventSequenceTable)
- .values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }])
- .onConflictDoUpdate({
- target: EventSequenceTable.aggregate_id,
- set: {
- seq: sql`max(${EventSequenceTable.seq}, ${seq})`,
- ...(input?.ownerID && row?.ownerID == null ? { owner_id: input.ownerID } : {}),
- },
- })
- .run()
- .pipe(Effect.orDie)
- if (persist)
+ }
+ if (persist) {
+ const stored = yield* db
+ .select({ aggregateID: EventTable.aggregate_id, seq: EventTable.seq })
+ .from(EventTable)
+ .where(eq(EventTable.id, event.id))
+ .get()
+ .pipe(Effect.orDie)
+ if (stored)
+ yield* Effect.die(
+ new InvalidDurableEventError({
+ type: event.type,
+ message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`,
+ }),
+ )
+ }
+ const committed = {
+ ...event,
+ durable: { aggregateID, seq, version: durable.version },
+ } as Event.Payload
+ for (const projector of list) {
+ yield* projector(committed)
+ }
+ if (commit) yield* commit(seq)
yield* db
- .insert(EventTable)
- .values([
- {
- id: event.id,
- aggregate_id: aggregateID,
- seq,
- created: DateTime.toEpochMillis(event.created ?? DateTime.makeUnsafe(0)),
- type: versionedType(definition.type, durable.version),
- data: encoded,
+ .insert(EventSequenceTable)
+ .values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }])
+ .onConflictDoUpdate({
+ target: EventSequenceTable.aggregate_id,
+ set: {
+ seq: sql`max(${EventSequenceTable.seq}, ${seq})`,
+ ...(input?.ownerID && row?.ownerID == null ? { owner_id: input.ownerID } : {}),
},
- ])
+ })
.run()
.pipe(Effect.orDie)
- return { aggregateID, seq }
- }),
- { behavior: "immediate" },
- )
- .pipe(Effect.orDie)
- if (committed) {
- yield* Effect.forEach(
- pubsub.durable.get(committed.aggregateID) ?? [],
- (wake) => PubSub.publish(wake, undefined),
- { discard: true },
- )
- }
- return committed
+ if (persist)
+ yield* db
+ .insert(EventTable)
+ .values([
+ {
+ id: event.id,
+ aggregate_id: aggregateID,
+ seq,
+ created: DateTime.toEpochMillis(event.created ?? DateTime.makeUnsafe(0)),
+ type: versionedType(definition.type, durable.version),
+ data: encoded,
+ },
+ ])
+ .run()
+ .pipe(Effect.orDie)
+ return { aggregateID, seq }
+ }),
+ { behavior: "immediate" },
+ )
+ .pipe(Effect.orDie)
+ if (committed) {
+ yield* Effect.forEach(
+ pubsub.durable.get(committed.aggregateID) ?? [],
+ (wake) => PubSub.publish(wake, undefined),
+ { discard: true },
+ )
+ }
+ return committed
+ }),
+ )
+ }
+ }
+ })
+ }
+
+ function publishEvent(
+ definition: D,
+ event: Event.Payload,
+ commit?: PublishOptions["commit"],
+ ) {
+ return Effect.gen(function* () {
+ if (!definition?.durable && commit)
+ return yield* Effect.die(
+ new InvalidDurableEventError({
+ type: event.type,
+ message: "Local commit hooks require a durable event",
}),
)
- }
- }
- })
- }
-
- function publishEvent(definition: D, event: Event.Payload, commit?: PublishOptions["commit"]) {
- return Effect.gen(function* () {
- if (!definition?.durable && commit)
- return yield* Effect.die(
- new InvalidDurableEventError({
- type: event.type,
- message: "Local commit hooks require a durable event",
- }),
- )
- if (definition?.durable) {
- const committed = yield* commitDurableEvent(definition, event as Event.Payload, undefined, commit)
- if (committed) {
- event = {
- ...event,
- durable: envelope(committed.aggregateID, committed.seq, definition.durable.version),
+ if (definition?.durable) {
+ const committed = yield* commitDurableEvent(definition, event as Event.Payload, undefined, commit)
+ if (committed) {
+ event = {
+ ...event,
+ durable: envelope(committed.aggregateID, committed.seq, definition.durable.version),
+ }
+ yield* notify(event as Event.Payload, true)
+ return event
}
- yield* notify(event as Event.Payload, true)
- return event
}
- }
- yield* notify(event as Event.Payload, false)
- return event
- })
- }
-
- const observe = (event: Event.Payload, observer: (event: Event.Payload) => Effect.Effect) =>
- Effect.suspend(() => observer(event)).pipe(
- Effect.catchCauseIf(
- (cause) => !Cause.hasInterrupts(cause),
- (cause) => Effect.logError("Event listener failed", { eventID: event.id, eventType: event.type, cause }),
- ),
- )
+ yield* notify(event as Event.Payload, false)
+ return event
+ })
+ }
- function notify(event: Event.Payload, isolateListeners: boolean) {
- return Effect.gen(function* () {
- yield* Effect.forEach(
- listeners,
- (listener) => (isolateListeners ? observe(event, listener) : listener(event)),
- { discard: true },
- )
- const typed = pubsub.typed.get(event.type)
- if (typed) yield* PubSub.publish(typed, event)
- yield* PubSub.publish(pubsub.live, event)
- })
- }
-
- function publish(definition: D, data: Event.Data, options?: PublishOptions) {
- return Effect.gen(function* () {
- const serviceLocation = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service))
- const location =
- options?.location ??
- (serviceLocation
- ? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
- : undefined)
- return yield* publishEvent(
- definition,
- {
- id: options?.id ?? Event.ID.create(),
- created: yield* DateTime.now,
- ...(options?.metadata ? { metadata: options.metadata } : {}),
- type: definition.type,
- ...(location ? { location } : {}),
- data,
- } as Event.Payload,
- options?.commit,
+ const observe = (event: Event.Payload, observer: (event: Event.Payload) => Effect.Effect) =>
+ Effect.suspend(() => observer(event)).pipe(
+ Effect.catchCauseIf(
+ (cause) => !Cause.hasInterrupts(cause),
+ (cause) => Effect.logError("Event listener failed", { eventID: event.id, eventType: event.type, cause }),
+ ),
)
- })
- }
-
- function replay(
- event: SerializedEvent,
- options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
- ) {
- return Effect.gen(function* () {
- const definition = Durable.get(event.type)
- if (!definition?.durable) {
- yield* Effect.die(
- new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` }),
+
+ function notify(event: Event.Payload, isolateListeners: boolean) {
+ return Effect.gen(function* () {
+ yield* Effect.forEach(
+ listeners,
+ (listener) => (isolateListeners ? observe(event, listener) : listener(event)),
+ { discard: true },
)
- } else {
- const payload = {
- id: event.id,
- created: event.created ?? DateTime.makeUnsafe(0),
- type: definition.type,
- data: Schema.decodeUnknownSync(definition.data)(event.data),
- } as Event.Payload
- const committed = yield* commitDurableEvent(definition, payload, {
- seq: event.seq,
- aggregateID: event.aggregateID,
- ownerID: options?.ownerID,
- strictOwner: options?.strictOwner,
- })
- if (committed && options?.publish) {
- yield* notify(
- {
- ...payload,
- durable: envelope(committed.aggregateID, committed.seq, definition.durable.version),
- },
- true,
+ const typed = pubsub.typed.get(event.type)
+ if (typed) yield* PubSub.publish(typed, event)
+ yield* PubSub.publish(pubsub.live, event)
+ })
+ }
+
+ function publish(definition: D, data: Event.Data, options?: PublishOptions) {
+ return Effect.gen(function* () {
+ const serviceLocation = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service))
+ const location =
+ options?.location ??
+ (serviceLocation
+ ? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
+ : undefined)
+ return yield* publishEvent(
+ definition,
+ {
+ id: options?.id ?? Event.ID.create(),
+ created: yield* DateTime.now,
+ ...(options?.metadata ? { metadata: options.metadata } : {}),
+ type: definition.type,
+ ...(location ? { location } : {}),
+ data,
+ } as Event.Payload,
+ options?.commit,
+ )
+ })
+ }
+
+ function replay(
+ event: SerializedEvent,
+ options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
+ ) {
+ return Effect.gen(function* () {
+ const definition = Durable.get(event.type)
+ if (!definition?.durable) {
+ yield* Effect.die(
+ new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` }),
)
+ } else {
+ const payload = {
+ id: event.id,
+ created: event.created ?? DateTime.makeUnsafe(0),
+ type: definition.type,
+ data: Schema.decodeUnknownSync(definition.data)(event.data),
+ } as Event.Payload
+ const committed = yield* commitDurableEvent(definition, payload, {
+ seq: event.seq,
+ aggregateID: event.aggregateID,
+ ownerID: options?.ownerID,
+ strictOwner: options?.strictOwner,
+ })
+ if (committed && options?.publish) {
+ yield* notify(
+ {
+ ...payload,
+ durable: envelope(committed.aggregateID, committed.seq, definition.durable.version),
+ },
+ true,
+ )
+ }
}
- }
- })
- }
-
- function replayAll(
- events: SerializedEvent[],
- options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
- ) {
- return Effect.gen(function* () {
- const source = events[0]?.aggregateID
- if (!source) return undefined
- if (events.some((event) => event.aggregateID !== source)) {
- yield* Effect.die(
- new InvalidDurableEventError({
- type: events[0]?.type ?? "unknown",
- message: "Replay events must belong to the same aggregate",
+ })
+ }
+
+ function replayAll(
+ events: SerializedEvent[],
+ options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
+ ) {
+ return Effect.gen(function* () {
+ const source = events[0]?.aggregateID
+ if (!source) return undefined
+ if (events.some((event) => event.aggregateID !== source)) {
+ yield* Effect.die(
+ new InvalidDurableEventError({
+ type: events[0]?.type ?? "unknown",
+ message: "Replay events must belong to the same aggregate",
+ }),
+ )
+ }
+ for (const event of events) {
+ yield* replay(event, options)
+ }
+ return source
+ })
+ }
+
+ function remove(aggregateID: string) {
+ return db
+ .transaction(() =>
+ Effect.gen(function* () {
+ yield* db.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).run()
+ yield* db.delete(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).run()
}),
)
- }
- for (const event of events) {
- yield* replay(event, options)
- }
- return source
- })
- }
+ .pipe(Effect.orDie)
+ }
- function remove(aggregateID: string) {
- return db
- .transaction(() =>
- Effect.gen(function* () {
- yield* db.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).run()
- yield* db.delete(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).run()
- }),
- )
- .pipe(Effect.orDie)
- }
-
- function claim(aggregateID: string, ownerID: string) {
- return db
- .update(EventSequenceTable)
- .set({ owner_id: ownerID })
- .where(eq(EventSequenceTable.aggregate_id, aggregateID))
- .run()
- .pipe(Effect.orDie)
- }
-
- const local = (stream: Stream.Stream) =>
- Stream.unwrap(
- Effect.serviceOption(Location.Service).pipe(
- Effect.map((location) =>
- Option.match(location, {
- onNone: () => stream,
- onSome: (location) =>
- stream.pipe(
- Stream.filter(
- (event) =>
- !event.location ||
- (event.location.directory === location.directory &&
- event.location.workspaceID === location.workspaceID),
+ function claim(aggregateID: string, ownerID: string) {
+ return db
+ .update(EventSequenceTable)
+ .set({ owner_id: ownerID })
+ .where(eq(EventSequenceTable.aggregate_id, aggregateID))
+ .run()
+ .pipe(Effect.orDie)
+ }
+
+ const local = (stream: Stream.Stream) =>
+ Stream.unwrap(
+ Effect.serviceOption(Location.Service).pipe(
+ Effect.map((location) =>
+ Option.match(location, {
+ onNone: () => stream,
+ onSome: (location) =>
+ stream.pipe(
+ Stream.filter(
+ (event) =>
+ !event.location ||
+ (event.location.directory === location.directory &&
+ event.location.workspaceID === location.workspaceID),
+ ),
),
- ),
- }),
+ }),
+ ),
),
- ),
- )
+ )
- function subscribe(): Stream.Stream
- function subscribe(definition: D): Stream.Stream>
- function subscribe(
- definitions: D,
- ): Stream.Stream>
- function subscribe(input?: Event.Definition | readonly Event.Definition[]): Stream.Stream {
- if (input === undefined) return streamLive()
- if (isDefinition(input)) {
- return local(Stream.unwrap(getOrCreate(input).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub)))))
+ function subscribe(): Stream.Stream
+ function subscribe(definition: D): Stream.Stream>
+ function subscribe(
+ definitions: D,
+ ): Stream.Stream>
+ function subscribe(input?: Event.Definition | readonly Event.Definition[]): Stream.Stream {
+ if (input === undefined) return streamLive()
+ if (isDefinition(input)) {
+ return local(Stream.unwrap(getOrCreate(input).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub)))))
+ }
+ const types = new Set(input.map((definition) => definition.type))
+ return streamLive().pipe(Stream.filter((event) => types.has(event.type)))
}
- const types = new Set(input.map((definition) => definition.type))
- return streamLive().pipe(Stream.filter((event) => types.has(event.type)))
- }
-
- const streamLive = (): Stream.Stream => local(Stream.fromPubSub(pubsub.live))
-
- const readAfter = (
- aggregateID: string,
- after: number,
- input: { readonly through: number; readonly limit: number },
- ) =>
- (options?.beforeAggregateRead?.(aggregateID) ?? Effect.void).pipe(
- Effect.andThen(
- Effect.suspend(() => {
- const query = db
- .select()
- .from(EventTable)
- .where(
- and(
- eq(EventTable.aggregate_id, aggregateID),
- gt(EventTable.seq, after),
- lte(EventTable.seq, input.through),
- ),
- )
- .orderBy(asc(EventTable.seq))
- return query.limit(input.limit).all()
- }),
- ),
- Effect.orDie,
- // Skip types missing from the durable manifest instead of failing the
- // read: the aggregate may hold events this process cannot decode. The
- // raw tail seq keeps cursors advancing across the resulting gaps.
- Effect.map((rows) => ({
- seq: rows.at(-1)?.seq,
- events: rows.flatMap((event) => {
- if (!Durable.get(event.type)?.durable) return []
- return [
- decodeSerializedEvent({
- id: event.id,
- created: DateTime.makeUnsafe(event.created),
- aggregateID: event.aggregate_id,
- seq: event.seq,
- type: event.type,
- data: event.data,
- }),
- ]
- }),
- })),
- )
- const subscribeDurable = (aggregateID: string) =>
- Effect.gen(function* () {
- const wake = yield* PubSub.sliding(1)
- const subscription = yield* PubSub.subscribe(wake)
- yield* Effect.acquireRelease(
- Effect.sync(() => {
- const wakes = pubsub.durable.get(aggregateID) ?? new Set()
- wakes.add(wake)
- pubsub.durable.set(aggregateID, wakes)
- }),
- () =>
- Effect.sync(() => {
- const wakes = pubsub.durable.get(aggregateID)
- wakes?.delete(wake)
- if (wakes?.size === 0) pubsub.durable.delete(aggregateID)
- }).pipe(Effect.andThen(PubSub.shutdown(wake))),
+ const streamLive = (): Stream.Stream => local(Stream.fromPubSub(pubsub.live))
+
+ const readAfter = (
+ aggregateID: string,
+ after: number,
+ input: { readonly through: number; readonly limit: number },
+ ) =>
+ (options?.beforeAggregateRead?.(aggregateID) ?? Effect.void).pipe(
+ Effect.andThen(
+ Effect.suspend(() => {
+ const query = db
+ .select()
+ .from(EventTable)
+ .where(
+ and(
+ eq(EventTable.aggregate_id, aggregateID),
+ gt(EventTable.seq, after),
+ lte(EventTable.seq, input.through),
+ ),
+ )
+ .orderBy(asc(EventTable.seq))
+ return query.limit(input.limit).all()
+ }),
+ ),
+ Effect.orDie,
+ // Skip types missing from the durable manifest instead of failing the
+ // read: the aggregate may hold events this process cannot decode. The
+ // raw tail seq keeps cursors advancing across the resulting gaps.
+ Effect.map((rows) => ({
+ seq: rows.at(-1)?.seq,
+ events: rows.flatMap((event) => {
+ if (!Durable.get(event.type)?.durable) return []
+ return [
+ decodeSerializedEvent({
+ id: event.id,
+ created: DateTime.makeUnsafe(event.created),
+ aggregateID: event.aggregate_id,
+ seq: event.seq,
+ type: event.type,
+ data: event.data,
+ }),
+ ]
+ }),
+ })),
)
- return subscription
- })
- const log = (input: {
- readonly aggregateID: string
- readonly after?: number
- readonly follow?: boolean
- }): Stream.Stream =>
- Stream.unwrap(
+ const subscribeDurable = (aggregateID: string) =>
Effect.gen(function* () {
- let sequence = input.after ?? -1
- const readThrough = (through: number): Stream.Stream =>
- Stream.paginate(sequence, (cursor) =>
- readAfter(input.aggregateID, cursor, { through, limit: logReadPageSize }).pipe(
- Effect.tap((page) =>
- Effect.sync(() => {
- sequence = page.seq ?? sequence
- }),
- ),
- Effect.map(
- (page) =>
- [
- page.events,
- page.seq !== undefined && page.seq < through ? Option.some(page.seq) : Option.none(),
- ] as const,
+ const wake = yield* PubSub.sliding(1)
+ const subscription = yield* PubSub.subscribe(wake)
+ yield* Effect.acquireRelease(
+ Effect.sync(() => {
+ const wakes = pubsub.durable.get(aggregateID) ?? new Set()
+ wakes.add(wake)
+ pubsub.durable.set(aggregateID, wakes)
+ }),
+ () =>
+ Effect.sync(() => {
+ const wakes = pubsub.durable.get(aggregateID)
+ wakes?.delete(wake)
+ if (wakes?.size === 0) pubsub.durable.delete(aggregateID)
+ }).pipe(Effect.andThen(PubSub.shutdown(wake))),
+ )
+ return subscription
+ })
+
+ const log = (input: {
+ readonly aggregateID: string
+ readonly after?: number
+ readonly follow?: boolean
+ }): Stream.Stream =>
+ Stream.unwrap(
+ Effect.gen(function* () {
+ let sequence = input.after ?? -1
+ const readThrough = (through: number): Stream.Stream =>
+ Stream.paginate(sequence, (cursor) =>
+ readAfter(input.aggregateID, cursor, { through, limit: logReadPageSize }).pipe(
+ Effect.tap((page) =>
+ Effect.sync(() => {
+ sequence = page.seq ?? sequence
+ }),
+ ),
+ Effect.map(
+ (page) =>
+ [
+ page.events,
+ page.seq !== undefined && page.seq < through ? Option.some(page.seq) : Option.none(),
+ ] as const,
+ ),
),
- ),
+ )
+ // Subscribing before the historical read means events committed during
+ // replay either appear in the read or arrive through a post-marker wake.
+ const wakes = input.follow ? yield* subscribeDurable(input.aggregateID) : undefined
+ const target = yield* latestSequence(db, input.aggregateID)
+ const marker: EventLog.Synced = {
+ type: "log.synced",
+ aggregateID: input.aggregateID,
+ ...(target >= 0 ? { seq: Event.Seq.make(target) } : {}),
+ }
+ const replay: Stream.Stream = readThrough(target).pipe(
+ Stream.map((event): LogItem => event),
+ Stream.concat(Stream.make(marker)),
)
- // Subscribing before the historical read means events committed during
- // replay either appear in the read or arrive through a post-marker wake.
- const wakes = input.follow ? yield* subscribeDurable(input.aggregateID) : undefined
- const target = yield* latestSequence(db, input.aggregateID)
- const marker: EventLog.Synced = {
- type: "log.synced",
- aggregateID: input.aggregateID,
- ...(target >= 0 ? { seq: Event.Seq.make(target) } : {}),
- }
- const replay: Stream.Stream = readThrough(target).pipe(
- Stream.map((event): LogItem => event),
- Stream.concat(Stream.make(marker)),
- )
- if (!wakes) return replay
- const live: Stream.Stream = Stream.fromSubscription(wakes).pipe(
- Stream.mapEffect(() => latestSequence(db, input.aggregateID)),
- Stream.filter((target) => target > sequence),
- Stream.flatMap((target) => readThrough(target)),
- Stream.map((event): LogItem => event),
- )
- return Stream.concat(replay, live)
- }),
- )
+ if (!wakes) return replay
+ const live: Stream.Stream = Stream.fromSubscription(wakes).pipe(
+ Stream.mapEffect(() => latestSequence(db, input.aggregateID)),
+ Stream.filter((target) => target > sequence),
+ Stream.flatMap((target) => readThrough(target)),
+ Stream.map((event): LogItem => event),
+ )
+ return Stream.concat(replay, live)
+ }),
+ )
- const listen = (listener: Subscriber): Effect.Effect =>
- Effect.sync(() => {
- listeners.push(listener)
- return Effect.sync(() => {
- const index = listeners.indexOf(listener)
- if (index >= 0) listeners.splice(index, 1)
+ const listen = (listener: Subscriber): Effect.Effect =>
+ Effect.sync(() => {
+ listeners.push(listener)
+ return Effect.sync(() => {
+ const index = listeners.indexOf(listener)
+ if (index >= 0) listeners.splice(index, 1)
+ })
})
- })
- const project = (definition: D, projector: Subscriber): Effect.Effect =>
- Effect.sync(() => {
- const key = definition.durable ? versionedType(definition.type, definition.durable.version) : definition.type
- const list = projectors.get(key) ?? []
- list.push((event) => projector(event as Event.Payload))
- projectors.set(key, list)
- })
+ const project = (definition: D, projector: Subscriber): Effect.Effect =>
+ Effect.sync(() => {
+ const key = definition.durable
+ ? versionedType(definition.type, definition.durable.version)
+ : definition.type
+ const list = projectors.get(key) ?? []
+ list.push((event) => projector(event as Event.Payload))
+ projectors.set(key, list)
+ })
- return Service.of({
- publish,
- subscribe,
- log,
- listen,
- project,
- replay,
- replayAll,
- remove,
- claim,
- })
- })),
+ return Service.of({
+ publish,
+ subscribe,
+ log,
+ listen,
+ project,
+ replay,
+ replayAll,
+ remove,
+ claim,
+ })
+ }),
+ ),
})
}
diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts
index 3314769d04fc..c1dfd5765e86 100644
--- a/packages/core/src/catalog.ts
+++ b/packages/core/src/catalog.ts
@@ -115,8 +115,7 @@ const layer = Layer.effect(
}
draft.providers.set(providerID, record)
}
- const model =
- record.models.get(modelID) ?? (Model.Info.default(providerID, modelID) as Model.MutableInfo)
+ const model = record.models.get(modelID) ?? (Model.Info.default(providerID, modelID) as Model.MutableInfo)
if (!record.models.has(modelID)) record.models.set(modelID, model)
fn(model)
model.id = modelID
@@ -249,8 +248,7 @@ const layer = Layer.effect(
items,
Order.mapInput(
Order.Number,
- (item: (typeof candidates)[number]) =>
- (item.cost / maxCost) * 0.8 + (item.age / maxAge) * 0.2,
+ (item: (typeof candidates)[number]) => (item.cost / maxCost) * 0.8 + (item.age / maxAge) * 0.2,
),
)
return projectModel(selected.model, provider)
diff --git a/packages/core/src/codemode/catalog.ts b/packages/core/src/codemode/catalog.ts
index 00d1b71e2d97..6442749abd6c 100644
--- a/packages/core/src/codemode/catalog.ts
+++ b/packages/core/src/codemode/catalog.ts
@@ -46,9 +46,7 @@ export function summarize(entries: ReadonlyArray, budget = INLINE_BUDGET)
.map((entry) => {
const firstLine = entry.description.split("\n", 1)[0]?.trim() ?? ""
const description =
- firstLine.length > DESCRIPTION_LIMIT
- ? firstLine.slice(0, DESCRIPTION_LIMIT - 3) + "..."
- : firstLine
+ firstLine.length > DESCRIPTION_LIMIT ? firstLine.slice(0, DESCRIPTION_LIMIT - 3) + "..." : firstLine
const suffix = description.length === 0 ? "" : ` // ${description}`
return { path: entry.path, line: ` - ${entry.signature}${suffix}` }
})
diff --git a/packages/core/src/codemode/tool.ts b/packages/core/src/codemode/tool.ts
index b0a13ac58ff1..db4c067345c3 100644
--- a/packages/core/src/codemode/tool.ts
+++ b/packages/core/src/codemode/tool.ts
@@ -43,14 +43,9 @@ const description = [
export const create = (
registrations: ReadonlyMap,
- executeTool: (
- name: string,
- tool: Info,
- input: unknown,
- context: Context,
- ) => Effect.Effect,
+ executeTool: (name: string, tool: Info, input: unknown, context: Context) => Effect.Effect,
) => {
- return ({
+ return {
name: "execute",
description,
input: CodeMode.Input,
@@ -76,7 +71,7 @@ export const create = (
const content =
typeof executed.content === "string"
? [{ type: "text" as const, text: executed.content }]
- : executed.content ?? []
+ : (executed.content ?? [])
const outputFileParts = outputFiles(content)
if (outputFileParts.length > 0)
yield* Ref.update(files, (items) => [...items, { index, files: outputFileParts }])
@@ -134,7 +129,7 @@ export const create = (
metadata,
}
}),
- }) satisfies Info
+ } satisfies Info
}
export const catalog = (registrations: ReadonlyMap) => {
diff --git a/packages/core/src/command.ts b/packages/core/src/command.ts
index 7ce432af8db6..d635d9ebb6b7 100644
--- a/packages/core/src/command.ts
+++ b/packages/core/src/command.ts
@@ -52,103 +52,108 @@ export interface Interface extends State.Transformable {
export class Service extends Context.Service()("@opencode/Command") {}
-export const layer = (options?: ShellSelect.Options) => Layer.effect(
- Service,
- Effect.gen(function* () {
- const mcp = yield* MCP.Service
- const bus = yield* Bus.Service
- const processes = yield* AppProcess.Service
- const config = yield* Config.Service
- const location = yield* Location.Service
- const state = State.create({
- name: "command",
- initial: () => ({ commands: new Map() }),
- draft: (draft) => ({
- list: () => Array.from(draft.commands.values()) as Info[],
- get: (name) => draft.commands.get(name),
- update: (name, update) => {
- const current = draft.commands.get(name) ?? ({ name, template: "" } as Types.DeepMutable)
- if (!draft.commands.has(name)) draft.commands.set(name, current)
- update(current)
- current.name = name
- },
- remove: (name) => {
- draft.commands.delete(name)
- },
- }),
- finalize: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid),
- })
- const staticCommand = (name: string) => state.get().commands.get(name) as Info | undefined
- const mcpCommands = Effect.fnUntraced(function* () {
- return (yield* mcp.prompts()).map((prompt) =>
- Info.make({
- name: mcpCommandName(prompt.server, prompt.name),
- template: "",
- description: prompt.description,
+export const layer = (options?: ShellSelect.Options) =>
+ Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const mcp = yield* MCP.Service
+ const bus = yield* Bus.Service
+ const processes = yield* AppProcess.Service
+ const config = yield* Config.Service
+ const location = yield* Location.Service
+ const state = State.create({
+ name: "command",
+ initial: () => ({ commands: new Map() }),
+ draft: (draft) => ({
+ list: () => Array.from(draft.commands.values()) as Info[],
+ get: (name) => draft.commands.get(name),
+ update: (name, update) => {
+ const current = draft.commands.get(name) ?? ({ name, template: "" } as Types.DeepMutable)
+ if (!draft.commands.has(name)) draft.commands.set(name, current)
+ update(current)
+ current.name = name
+ },
+ remove: (name) => {
+ draft.commands.delete(name)
+ },
}),
- )
- })
-
- return Service.of({
- reload: state.reload,
- transform: state.transform,
- get: Effect.fn("Command.get")(function* (name) {
- const command = staticCommand(name)
- if (command) return command
- return (yield* mcpCommands()).find((command) => command.name === name)
- }),
- list: Effect.fn("Command.list")(function* () {
- const commands = Array.from(state.get().commands.values()) as Info[]
- const names = new Set(commands.map((command) => command.name))
- return [
- ...commands,
- ...(yield* mcpCommands()).filter((command) => !names.has(command.name)),
- ]
- }),
- evaluate: Effect.fn("Command.evaluate")(function* (input) {
- const command = staticCommand(input.name)
- if (command) return yield* evaluateTemplate(input.name, command.template, input.arguments ?? "", {
- config,
- location,
- processes,
- shell: options,
- })
-
- const prompt = (yield* mcp.prompts()).find((prompt) => mcpCommandName(prompt.server, prompt.name) === input.name)
- if (!prompt) return yield* new NotFoundError({ command: input.name, message: `Command not found: ${input.name}` })
- const result = yield* mcp
- .prompt({
- server: prompt.server,
- name: prompt.name,
- args: Object.fromEntries(
- (prompt.arguments ?? []).map((argument, index) => [
- argument.name,
- parseArguments(input.arguments ?? "")[index] ?? "",
- ]),
- ),
- })
- .pipe(
- Effect.catchTag(
- "MCP.NotFoundError",
- () =>
+ finalize: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid),
+ })
+ const staticCommand = (name: string) => state.get().commands.get(name) as Info | undefined
+ const mcpCommands = Effect.fnUntraced(function* () {
+ return (yield* mcp.prompts()).map((prompt) =>
+ Info.make({
+ name: mcpCommandName(prompt.server, prompt.name),
+ template: "",
+ description: prompt.description,
+ }),
+ )
+ })
+
+ return Service.of({
+ reload: state.reload,
+ transform: state.transform,
+ get: Effect.fn("Command.get")(function* (name) {
+ const command = staticCommand(name)
+ if (command) return command
+ return (yield* mcpCommands()).find((command) => command.name === name)
+ }),
+ list: Effect.fn("Command.list")(function* () {
+ const commands = Array.from(state.get().commands.values()) as Info[]
+ const names = new Set(commands.map((command) => command.name))
+ return [...commands, ...(yield* mcpCommands()).filter((command) => !names.has(command.name))]
+ }),
+ evaluate: Effect.fn("Command.evaluate")(function* (input) {
+ const command = staticCommand(input.name)
+ if (command)
+ return yield* evaluateTemplate(input.name, command.template, input.arguments ?? "", {
+ config,
+ location,
+ processes,
+ shell: options,
+ })
+
+ const prompt = (yield* mcp.prompts()).find(
+ (prompt) => mcpCommandName(prompt.server, prompt.name) === input.name,
+ )
+ if (!prompt)
+ return yield* new NotFoundError({ command: input.name, message: `Command not found: ${input.name}` })
+ const result = yield* mcp
+ .prompt({
+ server: prompt.server,
+ name: prompt.name,
+ args: Object.fromEntries(
+ (prompt.arguments ?? []).map((argument, index) => [
+ argument.name,
+ parseArguments(input.arguments ?? "")[index] ?? "",
+ ]),
+ ),
+ })
+ .pipe(
+ Effect.catchTag("MCP.NotFoundError", () =>
Effect.fail(
new EvaluationError({
command: input.name,
message: `MCP server could not be found while evaluating prompt: ${prompt.server}`,
}),
),
- ),
- )
- if (!result)
- return yield* new EvaluationError({
- command: input.name,
- message: `MCP prompt could not be evaluated: ${prompt.server}:${prompt.name}`,
- })
- return { text: result.messages.map((message) => promptMessageText(message.content)).join("\n").trim() }
- }),
- })
- }),
-)
+ ),
+ )
+ if (!result)
+ return yield* new EvaluationError({
+ command: input.name,
+ message: `MCP prompt could not be evaluated: ${prompt.server}:${prompt.name}`,
+ })
+ return {
+ text: result.messages
+ .map((message) => promptMessageText(message.content))
+ .join("\n")
+ .trim(),
+ }
+ }),
+ })
+ }),
+ )
function evaluateTemplate(
command: string,
@@ -179,7 +184,8 @@ function evaluateArguments(template: string, input: string) {
return args[argIndex]
})
const withArguments = expanded.replaceAll("$ARGUMENTS", input)
- if (placeholders.length === 0 && !template.includes("$ARGUMENTS") && input.trim()) return `${withArguments}\n\n${input}`.trim()
+ if (placeholders.length === 0 && !template.includes("$ARGUMENTS") && input.trim())
+ return `${withArguments}\n\n${input}`.trim()
return withArguments.trim()
}
@@ -201,14 +207,23 @@ const evaluateShell = Effect.fnUntraced(function* (
(match) => {
const source = match[1] ?? ""
return services.processes
- .run(ChildProcess.make(shell, ShellSelect.args(shell, source), { cwd: services.location.directory, stdin: "ignore" }), {
- combineOutput: true,
- })
+ .run(
+ ChildProcess.make(shell, ShellSelect.args(shell, source), {
+ cwd: services.location.directory,
+ stdin: "ignore",
+ }),
+ {
+ combineOutput: true,
+ },
+ )
.pipe(
Effect.map((result) => (result.output ?? Buffer.concat([result.stdout, result.stderr])).toString("utf8")),
Effect.mapError(
(error) =>
- new EvaluationError({ command, message: `Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}` }),
+ new EvaluationError({
+ command,
+ message: `Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`,
+ }),
),
)
},
diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts
index 6072acb9cbec..d932181be75e 100644
--- a/packages/core/src/config.ts
+++ b/packages/core/src/config.ts
@@ -78,287 +78,292 @@ export const testLayer = (initial: Entry[] = []) =>
}),
)
-export const layer = (options?: Options) => Layer.effect(
- Service,
- Effect.gen(function* () {
- const fs = yield* FSUtil.Service
- const global = yield* Global.Service
- const location = yield* Location.Service
- const watcher = yield* Watcher.Service
- const bus = yield* Bus.Service
- const credentials = yield* Credential.Service
- const wellknown = yield* WellKnown.Service
- const names = ["opencode.json", "opencode.jsonc"]
- const reloadLock = Semaphore.makeUnsafe(1)
- const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
- const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
- const parseInfo = Effect.fn("Config.parseInfo")(function* (text: string, source: string) {
- const errors: ParseError[] = []
- const input: unknown = parse(text, errors, { allowTrailingComma: true })
- if (errors.length) {
+export const layer = (options?: Options) =>
+ Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const fs = yield* FSUtil.Service
+ const global = yield* Global.Service
+ const location = yield* Location.Service
+ const watcher = yield* Watcher.Service
+ const bus = yield* Bus.Service
+ const credentials = yield* Credential.Service
+ const wellknown = yield* WellKnown.Service
+ const names = ["opencode.json", "opencode.jsonc"]
+ const reloadLock = Semaphore.makeUnsafe(1)
+ const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
+ const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
+ const parseInfo = Effect.fn("Config.parseInfo")(function* (text: string, source: string) {
+ const errors: ParseError[] = []
+ const input: unknown = parse(text, errors, { allowTrailingComma: true })
+ if (errors.length) {
+ yield* Effect.logWarning("configuration normalization diagnostic", {
+ source,
+ path: "$",
+ kind: "invalid",
+ action: "rejected malformed JSON or JSONC document",
+ })
+ return
+ }
+ const result = ConfigNormalize.normalize(input)
+ yield* Effect.forEach(result.diagnostics, (diagnostic) =>
+ Effect.logWarning("configuration normalization diagnostic", {
+ source,
+ path: diagnostic.path[0] === "$" ? "$" : `$.${diagnostic.path.join(".")}`,
+ kind: diagnostic.kind,
+ action: diagnostic.message,
+ }),
+ )
+ if (result.type === "rejected") return
+ const info = Option.getOrUndefined(decodeInfo(result.encoded))
+ if (info) return info
yield* Effect.logWarning("configuration normalization diagnostic", {
source,
path: "$",
kind: "invalid",
- action: "rejected malformed JSON or JSONC document",
+ action: "rejected canonical configuration after final validation",
})
- return
- }
- const result = ConfigNormalize.normalize(input)
- yield* Effect.forEach(result.diagnostics, (diagnostic) =>
- Effect.logWarning("configuration normalization diagnostic", {
- source,
- path: diagnostic.path[0] === "$" ? "$" : `$.${diagnostic.path.join(".")}`,
- kind: diagnostic.kind,
- action: diagnostic.message,
- }),
- )
- if (result.type === "rejected") return
- const info = Option.getOrUndefined(decodeInfo(result.encoded))
- if (info) return info
- yield* Effect.logWarning("configuration normalization diagnostic", {
- source,
- path: "$",
- kind: "invalid",
- action: "rejected canonical configuration after final validation",
})
- })
- const loadFile = Effect.fnUntraced(function* (filepath: string) {
- const text = yield* fs.readFileStringSafe(filepath)
- if (text === undefined) return
- const substituted = yield* ConfigVariable.substitute({ type: "path", path: filepath, text })
- const info = yield* parseInfo(substituted, filepath)
- if (!info) return
- return new Document({ type: "document", path: filepath, info })
- })
+ const loadFile = Effect.fnUntraced(function* (filepath: string) {
+ const text = yield* fs.readFileStringSafe(filepath)
+ if (text === undefined) return
+ const substituted = yield* ConfigVariable.substitute({ type: "path", path: filepath, text })
+ const info = yield* parseInfo(substituted, filepath)
+ if (!info) return
+ return new Document({ type: "document", path: filepath, info })
+ })
- const loadWellknown = Effect.fn("Config.loadWellknown")(function* () {
- const entries = yield* wellknown
- .entries()
- .pipe(
- Effect.catch((error) =>
- Effect.logWarning("failed to discover wellknown config", { error }).pipe(Effect.as([] as const)),
- ),
- )
- return yield* Effect.forEach(entries, (entry) =>
- Effect.gen(function* () {
- const auth = entry.manifest.auth
- if (!auth) return []
- const credential = (yield* credentials.list(entry.integrationID)).findLast(
- (credential) => credential.value.type === "key",
- )
- if (!credential || credential.value.type !== "key") return []
- const variables = { [auth.env]: credential.value.key }
- const configs = yield* wellknown.resolve(entry, variables).pipe(Effect.orDie)
- return yield* Effect.forEach(configs, (config) =>
- ConfigVariable.substitute({
- type: "virtual",
- source: entry.origin,
- dir: entry.origin,
- text: JSON.stringify(config),
- env: variables,
- }).pipe(
- Effect.flatMap((text) => parseInfo(text, entry.origin)),
- Effect.map((info) => (info ? new Document({ type: "document", info }) : undefined)),
+ const loadWellknown = Effect.fn("Config.loadWellknown")(function* () {
+ const entries = yield* wellknown
+ .entries()
+ .pipe(
+ Effect.catch((error) =>
+ Effect.logWarning("failed to discover wellknown config", { error }).pipe(Effect.as([] as const)),
),
- ).pipe(Effect.map((documents) => documents.filter((document) => document !== undefined)))
- }),
- ).pipe(Effect.map((documents) => documents.flat()))
- })
-
- const loadDirectory = Effect.fnUntraced(function* (directory: AbsolutePath) {
- return [
- ...(yield* Effect.forEach(names, (file) => loadFile(path.join(directory, file))).pipe(
- Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)),
- )),
- new Directory({ type: "directory", path: directory }),
- ]
- })
+ )
+ return yield* Effect.forEach(entries, (entry) =>
+ Effect.gen(function* () {
+ const auth = entry.manifest.auth
+ if (!auth) return []
+ const credential = (yield* credentials.list(entry.integrationID)).findLast(
+ (credential) => credential.value.type === "key",
+ )
+ if (!credential || credential.value.type !== "key") return []
+ const variables = { [auth.env]: credential.value.key }
+ const configs = yield* wellknown.resolve(entry, variables).pipe(Effect.orDie)
+ return yield* Effect.forEach(configs, (config) =>
+ ConfigVariable.substitute({
+ type: "virtual",
+ source: entry.origin,
+ dir: entry.origin,
+ text: JSON.stringify(config),
+ env: variables,
+ }).pipe(
+ Effect.flatMap((text) => parseInfo(text, entry.origin)),
+ Effect.map((info) => (info ? new Document({ type: "document", info }) : undefined)),
+ ),
+ ).pipe(Effect.map((documents) => documents.filter((document) => document !== undefined)))
+ }),
+ ).pipe(Effect.map((documents) => documents.flat()))
+ })
- const discover = Effect.fn("Config.discover")(function* () {
- const globalDirectory = AbsolutePath.make(global.config)
- const globalAgentsDirectory = AbsolutePath.make(path.join(global.home, ".agents"))
- const globalClaudeDirectory = AbsolutePath.make(path.join(global.home, ".claude"))
- const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config)
- const discovered = locationIsGlobal || options?.project === false
- ? []
- : yield* fs
- .up({
- targets: [".opencode", ".claude", ".agents", ...names.toReversed()],
- start: location.directory,
- })
- .pipe(Effect.orDie)
+ const loadDirectory = Effect.fnUntraced(function* (directory: AbsolutePath) {
+ return [
+ ...(yield* Effect.forEach(names, (file) => loadFile(path.join(directory, file))).pipe(
+ Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)),
+ )),
+ new Directory({ type: "directory", path: directory }),
+ ]
+ })
- // We load certain files from a few other folders in the ecosystem
- const claude = [
- ...new Set([
- ...((yield* fs.isDir(globalClaudeDirectory)) ? [globalClaudeDirectory] : []),
- ...discovered.filter((item) => path.basename(item) === ".claude"),
- ]),
- ].map((directory) => new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(directory) }))
- const agents = [
- ...new Set([
- ...((yield* fs.isDir(globalAgentsDirectory)) ? [globalAgentsDirectory] : []),
- ...discovered.filter((item) => path.basename(item) === ".agents"),
- ]),
- ].map((directory) => new AgentsDirectory({ type: "agents", path: AbsolutePath.make(directory) }))
+ const discover = Effect.fn("Config.discover")(function* () {
+ const globalDirectory = AbsolutePath.make(global.config)
+ const globalAgentsDirectory = AbsolutePath.make(path.join(global.home, ".agents"))
+ const globalClaudeDirectory = AbsolutePath.make(path.join(global.home, ".claude"))
+ const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config)
+ const discovered =
+ locationIsGlobal || options?.project === false
+ ? []
+ : yield* fs
+ .up({
+ targets: [".opencode", ".claude", ".agents", ...names.toReversed()],
+ start: location.directory,
+ })
+ .pipe(Effect.orDie)
- const directories = [
- globalDirectory,
- ...discovered
- .filter((item) => path.basename(item) === ".opencode")
- .toReversed()
- .map((directory) => AbsolutePath.make(directory)),
- ]
- const directPaths = discovered
- .filter((item) => ![".agents", ".claude", ".opencode"].includes(path.basename(item)))
- .toReversed()
- const direct = yield* Effect.forEach(directPaths, (filepath) =>
- loadFile(filepath).pipe(
- Effect.map((config) => [
- ...(config ? [config] : []),
- new File({ type: "file", path: AbsolutePath.make(filepath) }),
+ // We load certain files from a few other folders in the ecosystem
+ const claude = [
+ ...new Set([
+ ...((yield* fs.isDir(globalClaudeDirectory)) ? [globalClaudeDirectory] : []),
+ ...discovered.filter((item) => path.basename(item) === ".claude"),
]),
- ),
- ).pipe(
- Effect.orDie,
- Effect.map((entries) => entries.flat()),
- )
+ ].map((directory) => new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(directory) }))
+ const agents = [
+ ...new Set([
+ ...((yield* fs.isDir(globalAgentsDirectory)) ? [globalAgentsDirectory] : []),
+ ...discovered.filter((item) => path.basename(item) === ".agents"),
+ ]),
+ ].map((directory) => new AgentsDirectory({ type: "agents", path: AbsolutePath.make(directory) }))
- const file = options?.file
- const explicit = file
- ? yield* loadFile(path.resolve(file)).pipe(
+ const directories = [
+ globalDirectory,
+ ...discovered
+ .filter((item) => path.basename(item) === ".opencode")
+ .toReversed()
+ .map((directory) => AbsolutePath.make(directory)),
+ ]
+ const directPaths = discovered
+ .filter((item) => ![".agents", ".claude", ".opencode"].includes(path.basename(item)))
+ .toReversed()
+ const direct = yield* Effect.forEach(directPaths, (filepath) =>
+ loadFile(filepath).pipe(
Effect.map((config) => [
...(config ? [config] : []),
- new File({ type: "file", path: AbsolutePath.make(path.resolve(file)) }),
+ new File({ type: "file", path: AbsolutePath.make(filepath) }),
]),
- Effect.orDie,
- )
- : []
- const content = options?.content !== undefined
- ? yield* ConfigVariable.substitute({
- type: "virtual",
- source: "OPENCODE_CONFIG_CONTENT",
- dir: location.directory,
- text: options.content,
- }).pipe(
- Effect.flatMap((text) => parseInfo(text, "OPENCODE_CONFIG_CONTENT")),
- Effect.map((info) => (info ? [new Document({ type: "document", info })] : [])),
- Effect.orDie,
- )
- : []
+ ),
+ ).pipe(
+ Effect.orDie,
+ Effect.map((entries) => entries.flat()),
+ )
+
+ const file = options?.file
+ const explicit = file
+ ? yield* loadFile(path.resolve(file)).pipe(
+ Effect.map((config) => [
+ ...(config ? [config] : []),
+ new File({ type: "file", path: AbsolutePath.make(path.resolve(file)) }),
+ ]),
+ Effect.orDie,
+ )
+ : []
+ const content =
+ options?.content !== undefined
+ ? yield* ConfigVariable.substitute({
+ type: "virtual",
+ source: "OPENCODE_CONFIG_CONTENT",
+ dir: location.directory,
+ text: options.content,
+ }).pipe(
+ Effect.flatMap((text) => parseInfo(text, "OPENCODE_CONFIG_CONTENT")),
+ Effect.map((info) => (info ? [new Document({ type: "document", info })] : [])),
+ Effect.orDie,
+ )
+ : []
- const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
- return [
- ...(yield* loadWellknown().pipe(Effect.orDie)),
- ...claude,
- ...agents,
- ...(supplementary[0] ?? []),
- ...explicit,
- ...direct,
- ...supplementary.slice(1).flat(),
- ...content,
- ]
- })
+ const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
+ return [
+ ...(yield* loadWellknown().pipe(Effect.orDie)),
+ ...claude,
+ ...agents,
+ ...(supplementary[0] ?? []),
+ ...explicit,
+ ...direct,
+ ...supplementary.slice(1).flat(),
+ ...content,
+ ]
+ })
- const initial = yield* discover()
- let configs = initial
- const updates = yield* PubSub.unbounded()
- // Vendored trees inside config roots (a plugin's node_modules, a nested
- // .git) produce event blizzards that can never change discovery output.
- const ignore = ["node_modules", ".git", "**/{node_modules,.git}/**"]
- // Watch-once: roots leave discovery only by deletion, so a stale watch is
- // inert, bounded, and dies with this layer — and keeping a deleted root's
- // watch alive is exactly what makes its recreation observable.
- const watched = new Set()
- const reconcile = Effect.fn("Config.reconcileWatches")(function* (entries: readonly Entry[]) {
- const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
- const files = entries.flatMap((entry) => (entry.type === "file" ? [entry.path] : []))
- const targets = [
- ...directories.map((path) => ({ path, type: "directory" as const, ignore })),
- ...files
- .filter((file) => !directories.some((directory) => FSUtil.contains(directory, file)))
- .map((path) => ({ path, type: "file" as const })),
- ]
- for (const target of targets) {
- const key = JSON.stringify(target)
- if (watched.has(key)) continue
- watched.add(key)
- const stream = yield* watcher.subscribe(target)
- yield* stream.pipe(
- Stream.runForEach((update) => PubSub.publish(updates, update)),
- Effect.forkScoped({ startImmediately: true }),
- )
- }
- })
+ const initial = yield* discover()
+ let configs = initial
+ const updates = yield* PubSub.unbounded()
+ // Vendored trees inside config roots (a plugin's node_modules, a nested
+ // .git) produce event blizzards that can never change discovery output.
+ const ignore = ["node_modules", ".git", "**/{node_modules,.git}/**"]
+ // Watch-once: roots leave discovery only by deletion, so a stale watch is
+ // inert, bounded, and dies with this layer — and keeping a deleted root's
+ // watch alive is exactly what makes its recreation observable.
+ const watched = new Set()
+ const reconcile = Effect.fn("Config.reconcileWatches")(function* (entries: readonly Entry[]) {
+ const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
+ const files = entries.flatMap((entry) => (entry.type === "file" ? [entry.path] : []))
+ const targets = [
+ ...directories.map((path) => ({ path, type: "directory" as const, ignore })),
+ ...files
+ .filter((file) => !directories.some((directory) => FSUtil.contains(directory, file)))
+ .map((path) => ({ path, type: "file" as const })),
+ ]
+ for (const target of targets) {
+ const key = JSON.stringify(target)
+ if (watched.has(key)) continue
+ watched.add(key)
+ const stream = yield* watcher.subscribe(target)
+ yield* stream.pipe(
+ Stream.runForEach((update) => PubSub.publish(updates, update)),
+ Effect.forkScoped({ startImmediately: true }),
+ )
+ }
+ })
- const reload = Effect.fn("Config.reload")(() =>
- reloadLock.withPermit(
- Effect.gen(function* () {
- const next = yield* discover()
- if (isDeepStrictEqual(configs, next)) return
- configs = next
- yield* reconcile(next)
- yield* bus.publish(Event.Updated, {})
- }),
- ),
- )
+ const reload = Effect.fn("Config.reload")(() =>
+ reloadLock.withPermit(
+ Effect.gen(function* () {
+ const next = yield* discover()
+ if (isDeepStrictEqual(configs, next)) return
+ configs = next
+ yield* reconcile(next)
+ yield* bus.publish(Event.Updated, {})
+ }),
+ ),
+ )
- yield* Stream.fromPubSub(updates).pipe(
- Stream.debounce("100 millis"),
- Stream.runForEach((update) =>
- reload().pipe(
- Effect.catchCause((cause) => Effect.logError("failed to reload config", { path: update.path, cause })),
+ yield* Stream.fromPubSub(updates).pipe(
+ Stream.debounce("100 millis"),
+ Stream.runForEach((update) =>
+ reload().pipe(
+ Effect.catchCause((cause) => Effect.logError("failed to reload config", { path: update.path, cause })),
+ ),
),
- ),
- Effect.forkScoped({ startImmediately: true }),
- )
- yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
- Stream.filterEffect((event) =>
- wellknown.entries().pipe(
- Effect.map((entries) => entries.some((entry) => entry.integrationID === event.data.integrationID)),
- Effect.catch(() => Effect.succeed(false)),
+ Effect.forkScoped({ startImmediately: true }),
+ )
+ yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
+ Stream.filterEffect((event) =>
+ wellknown.entries().pipe(
+ Effect.map((entries) => entries.some((entry) => entry.integrationID === event.data.integrationID)),
+ Effect.catch(() => Effect.succeed(false)),
+ ),
),
- ),
- Stream.runForEach(() =>
- reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload wellknown config", { cause }))),
- ),
- Effect.forkScoped({ startImmediately: true }),
- )
- yield* bus.subscribe(WellKnown.Event.Updated).pipe(
- Stream.runForEach(() =>
- reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload wellknown sources", { cause }))),
- ),
- Effect.forkScoped({ startImmediately: true }),
- )
- yield* Effect.sleep("10 minutes").pipe(
- Effect.andThen(
- Effect.suspend(() => {
- if (!wellknown.snapshot().length) return Effect.void
- return Effect.gen(function* () {
- const changed = yield* wellknown.refresh().pipe(
- Effect.catch((error) =>
- Effect.logWarning("failed to refresh wellknown manifests", { error }).pipe(Effect.as(false)),
- ),
- )
- if (!changed) yield* reload()
- }).pipe(Effect.catchCause((cause) => Effect.logWarning("failed to refresh wellknown config", { cause })))
- }),
- ),
- Effect.forever,
- Effect.forkScoped({ startImmediately: true }),
- )
- yield* reconcile(initial)
+ Stream.runForEach(() =>
+ reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload wellknown config", { cause }))),
+ ),
+ Effect.forkScoped({ startImmediately: true }),
+ )
+ yield* bus.subscribe(WellKnown.Event.Updated).pipe(
+ Stream.runForEach(() =>
+ reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload wellknown sources", { cause }))),
+ ),
+ Effect.forkScoped({ startImmediately: true }),
+ )
+ yield* Effect.sleep("10 minutes").pipe(
+ Effect.andThen(
+ Effect.suspend(() => {
+ if (!wellknown.snapshot().length) return Effect.void
+ return Effect.gen(function* () {
+ const changed = yield* wellknown
+ .refresh()
+ .pipe(
+ Effect.catch((error) =>
+ Effect.logWarning("failed to refresh wellknown manifests", { error }).pipe(Effect.as(false)),
+ ),
+ )
+ if (!changed) yield* reload()
+ }).pipe(Effect.catchCause((cause) => Effect.logWarning("failed to refresh wellknown config", { cause })))
+ }),
+ ),
+ Effect.forever,
+ Effect.forkScoped({ startImmediately: true }),
+ )
+ yield* reconcile(initial)
- return Service.of({
- entries: Effect.fn("Config.entries")(function* () {
- return configs
- }),
- changes: () => Stream.fromPubSub(updates),
- })
- }),
-)
+ return Service.of({
+ entries: Effect.fn("Config.entries")(function* () {
+ return configs
+ }),
+ changes: () => Stream.fromPubSub(updates),
+ })
+ }),
+ )
export function configured(options?: Options) {
return makeLocationNode({
diff --git a/packages/core/src/config/plugin/agent.ts b/packages/core/src/config/plugin/agent.ts
index 48b5455f14c9..d0a16e1fdf2e 100644
--- a/packages/core/src/config/plugin/agent.ts
+++ b/packages/core/src/config/plugin/agent.ts
@@ -63,9 +63,7 @@ export const Plugin = define({
Effect.catch(() => Effect.succeed(undefined)),
),
).pipe(
- Effect.map((documents) =>
- documents.filter((document): document is Document => document !== undefined),
- ),
+ Effect.map((documents) => documents.filter((document): document is Document => document !== undefined)),
)
})
}).pipe(Effect.map((documents) => documents.flat()))
diff --git a/packages/core/src/database/v1-migration.ts b/packages/core/src/database/v1-migration.ts
index b464a5d01cf9..4aedc2a1fb7b 100644
--- a/packages/core/src/database/v1-migration.ts
+++ b/packages/core/src/database/v1-migration.ts
@@ -92,9 +92,7 @@ type Options = {
readonly nextDatabasePath?: string
}
-type MigrationState =
- | { readonly phase: "sessions"; readonly cursor?: string }
- | { readonly phase: "completed" }
+type MigrationState = { readonly phase: "sessions"; readonly cursor?: string } | { readonly phase: "completed" }
type RuntimeState =
| { readonly status: "idle" }
@@ -487,7 +485,10 @@ export function run(options: Options = {}): Effect.Effect
Effect.gen(function* () {
yield* tx.delete(EventTable).run()
- yield* tx.insert(KVTable).values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } }).run()
+ yield* tx
+ .insert(KVTable)
+ .values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } })
+ .run()
}),
)
.pipe(Effect.orDie)
@@ -505,7 +506,9 @@ export function run(options: Options = {}): Effect.Effect(sql`SELECT id FROM project`)).map((project) => project.id))
+ const projects = new Set(
+ (yield* db.all<{ id: string }>(sql`SELECT id FROM project`)).map((project) => project.id),
+ )
while (true) {
const state = yield* readState(db)
const cursorValue = state?.phase === "sessions" ? state.cursor : undefined
@@ -594,7 +597,7 @@ export function run(options: Options = {}): Effect.Effect(
- "SELECT id, session_id, type, seq, time_created, time_updated, data FROM session_message WHERE session_id = ? ORDER BY seq",
- )
+ .query<
+ NextMessage,
+ [string]
+ >("SELECT id, session_id, type, seq, time_created, time_updated, data FROM session_message WHERE session_id = ? ORDER BY seq")
.all(session.id)
yield* db
.transaction((tx) =>
diff --git a/packages/core/src/event-logger.ts b/packages/core/src/event-logger.ts
index eebdb10101ee..b48bb62befb2 100644
--- a/packages/core/src/event-logger.ts
+++ b/packages/core/src/event-logger.ts
@@ -4,12 +4,7 @@ import { Effect, Layer } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "./bus"
-const Types = new Set([
- "agent.updated",
- "catalog.updated",
- "command.updated",
- "config.updated",
-])
+const Types = new Set(["agent.updated", "catalog.updated", "command.updated", "config.updated"])
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
diff --git a/packages/core/src/filesystem/watcher.ts b/packages/core/src/filesystem/watcher.ts
index 7ff3aa5e5c5a..54c1443e8668 100644
--- a/packages/core/src/filesystem/watcher.ts
+++ b/packages/core/src/filesystem/watcher.ts
@@ -92,9 +92,7 @@ export const layer = (options?: Options) =>
const watchers = yield* RcMap.make({
lookup: (key: Key) =>
Effect.gen(function* () {
- const pubsub = yield* Effect.acquireRelease(PubSub.unbounded(), (pubsub) =>
- PubSub.shutdown(pubsub),
- )
+ const pubsub = yield* Effect.acquireRelease(PubSub.unbounded(), (pubsub) => PubSub.shutdown(pubsub))
const subscription = yield* Effect.acquireRelease(
native.subscribe({
type: key.type,
diff --git a/packages/core/src/form.ts b/packages/core/src/form.ts
index 0a23ef3efe90..6d0716b12f77 100644
--- a/packages/core/src/form.ts
+++ b/packages/core/src/form.ts
@@ -183,7 +183,11 @@ export const layer = Layer.effect(
const invalid = validateAnswer(entry.form, input.answer)
if (invalid) return yield* new InvalidAnswerError({ id: input.id, message: invalid })
const next: TerminalState = { status: "answered", answer: input.answer }
- yield* bus.publish(Form.Event.Replied, { id: input.id, sessionID: entry.form.sessionID, answer: input.answer })
+ yield* bus.publish(Form.Event.Replied, {
+ id: input.id,
+ sessionID: entry.form.sessionID,
+ answer: input.answer,
+ })
yield* Cache.set(forms, input.id, { ...entry, state: next })
yield* Deferred.succeed(entry.deferred, next)
}),
diff --git a/packages/core/src/formatter.ts b/packages/core/src/formatter.ts
index ab2474e68d2a..d228f501a6a1 100644
--- a/packages/core/src/formatter.ts
+++ b/packages/core/src/formatter.ts
@@ -77,9 +77,7 @@ const layer = Layer.effect(
const file = Effect.fn("Formatter.file")(function* (filepath: string) {
yield* load
- const matching = formatters.filter((formatter) =>
- formatter.extensions.includes(path.extname(filepath)),
- )
+ const matching = formatters.filter((formatter) => formatter.extensions.includes(path.extname(filepath)))
for (const formatter of matching) {
const enabled = yield* command(formatter)
diff --git a/packages/core/src/formatter/builtins.ts b/packages/core/src/formatter/builtins.ts
index d86d9a3a8088..f2e7a27e56e1 100644
--- a/packages/core/src/formatter/builtins.ts
+++ b/packages/core/src/formatter/builtins.ts
@@ -213,9 +213,7 @@ export function make(input: {
const bin = which("uv")
if (!bin) return disabled
const output = yield* commandOutput([bin, "format", "--help"])
- return output._tag === "Some" && output.value.exitCode === 0
- ? [bin, "format", "--", "$FILE"]
- : disabled
+ return output._tag === "Some" && output.value.exitCode === 0 ? [bin, "format", "--", "$FILE"] : disabled
}),
}
diff --git a/packages/core/src/instruction-discovery.ts b/packages/core/src/instruction-discovery.ts
index d5d0137a2cea..096d34fef4c3 100644
--- a/packages/core/src/instruction-discovery.ts
+++ b/packages/core/src/instruction-discovery.ts
@@ -28,74 +28,76 @@ export type Options = typeof Options.Type
export class Service extends Context.Service()("@opencode/InstructionDiscovery") {}
-export const layer = (options?: Options) => Layer.effect(
- Service,
- Effect.gen(function* () {
- const fs = yield* FSUtil.Service
- const global = yield* Global.Service
- const location = yield* Location.Service
+export const layer = (options?: Options) =>
+ Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const fs = yield* FSUtil.Service
+ const global = yield* Global.Service
+ const location = yield* Location.Service
- const source = (value: ReadonlyArray | Instructions.Unavailable | Instructions.Removed) =>
- Instructions.make>({
- key,
- codec: Schema.toCodecJson(Files),
- read: Effect.succeed(value),
- render: {
- initial: render,
- changed: (_previous, current) =>
- `These instructions replace all previously loaded ambient instructions.\n\n${render(current)}`,
- removed: () => "Previously loaded instructions no longer apply.",
- },
- })
+ const source = (value: ReadonlyArray | Instructions.Unavailable | Instructions.Removed) =>
+ Instructions.make>({
+ key,
+ codec: Schema.toCodecJson(Files),
+ read: Effect.succeed(value),
+ render: {
+ initial: render,
+ changed: (_previous, current) =>
+ `These instructions replace all previously loaded ambient instructions.\n\n${render(current)}`,
+ removed: () => "Previously loaded instructions no longer apply.",
+ },
+ })
- const observe = Effect.fn("InstructionDiscovery.observe")(function* () {
- const start = yield* fs.resolve(location.directory)
- const stop = yield* fs.resolve(location.project.directory)
- const fromProject = relative(stop, start)
- const insideProject =
- fromProject === "" || (fromProject !== ".." && !fromProject.startsWith(`..${sep}`) && !isAbsolute(fromProject))
- const discovered = new Set(
- yield* Effect.forEach(
- options?.project === false || !insideProject
- ? []
- : yield* fs.up({
- targets: ["AGENTS.md"],
- start,
- stop,
- }),
- fs.resolve,
- ),
- )
- const paths = Array.dedupe([yield* fs.resolve(join(global.config, "AGENTS.md")), ...discovered])
- const files = yield* Effect.forEach(
- paths,
- (path) =>
- fs
- .readFileStringSafe(path)
- .pipe(
- Effect.map((content) =>
- content === undefined ? undefined : new File({ path: AbsolutePath.make(path), content }),
+ const observe = Effect.fn("InstructionDiscovery.observe")(function* () {
+ const start = yield* fs.resolve(location.directory)
+ const stop = yield* fs.resolve(location.project.directory)
+ const fromProject = relative(stop, start)
+ const insideProject =
+ fromProject === "" ||
+ (fromProject !== ".." && !fromProject.startsWith(`..${sep}`) && !isAbsolute(fromProject))
+ const discovered = new Set(
+ yield* Effect.forEach(
+ options?.project === false || !insideProject
+ ? []
+ : yield* fs.up({
+ targets: ["AGENTS.md"],
+ start,
+ stop,
+ }),
+ fs.resolve,
+ ),
+ )
+ const paths = Array.dedupe([yield* fs.resolve(join(global.config, "AGENTS.md")), ...discovered])
+ const files = yield* Effect.forEach(
+ paths,
+ (path) =>
+ fs
+ .readFileStringSafe(path)
+ .pipe(
+ Effect.map((content) =>
+ content === undefined ? undefined : new File({ path: AbsolutePath.make(path), content }),
+ ),
),
- ),
- { concurrency: "unbounded" },
- )
- if (files.some((file, index) => file === undefined && discovered.has(paths[index])))
- return Instructions.unavailable
- return files.filter((file): file is File => file !== undefined)
- })
+ { concurrency: "unbounded" },
+ )
+ if (files.some((file, index) => file === undefined && discovered.has(paths[index])))
+ return Instructions.unavailable
+ return files.filter((file): file is File => file !== undefined)
+ })
- return Service.of({
- load: () =>
- observe().pipe(
- Effect.map((files) =>
- Array.isArray(files) && files.length === 0 ? source(Instructions.removed) : source(files),
+ return Service.of({
+ load: () =>
+ observe().pipe(
+ Effect.map((files) =>
+ Array.isArray(files) && files.length === 0 ? source(Instructions.removed) : source(files),
+ ),
+ Effect.catch(() => Effect.succeed(source(Instructions.unavailable))),
+ Effect.catchDefect(() => Effect.succeed(source(Instructions.unavailable))),
),
- Effect.catch(() => Effect.succeed(source(Instructions.unavailable))),
- Effect.catchDefect(() => Effect.succeed(source(Instructions.unavailable))),
- ),
- })
- }),
-)
+ })
+ }),
+ )
export function configured(options?: Options) {
return makeLocationNode({
diff --git a/packages/core/src/instructions/builtins.ts b/packages/core/src/instructions/builtins.ts
index 3259d5a7d12b..fd42bcee160e 100644
--- a/packages/core/src/instructions/builtins.ts
+++ b/packages/core/src/instructions/builtins.ts
@@ -39,9 +39,7 @@ const layer = Layer.effect(
),
render: {
initial: (environment) =>
- ["Here is some useful information about the environment you are running in:", environment].join(
- "\n",
- ),
+ ["Here is some useful information about the environment you are running in:", environment].join("\n"),
changed: (_previous, environment) =>
["The environment you are running in is now:", environment].join("\n"),
},
diff --git a/packages/core/src/mcp/client.ts b/packages/core/src/mcp/client.ts
index b8f34b1996d4..9c79b4abfc05 100644
--- a/packages/core/src/mcp/client.ts
+++ b/packages/core/src/mcp/client.ts
@@ -209,16 +209,13 @@ export const connect = Effect.fnUntraced(function* (
authProvider,
})
})
- const client = new Client(
- clientInfo,
- {
- capabilities: {
- ...(elicitation ? { elicitation: { form: { applyDefaults: true }, url: {} } } : {}),
- // https://github.com/anomalyco/opencode/issues/2308
- roots: {},
- },
+ const client = new Client(clientInfo, {
+ capabilities: {
+ ...(elicitation ? { elicitation: { form: { applyDefaults: true }, url: {} } } : {}),
+ // https://github.com/anomalyco/opencode/issues/2308
+ roots: {},
},
- )
+ })
client.setRequestHandler(ListRootsRequestSchema, () =>
Promise.resolve({ roots: [{ uri: pathToFileURL(directory).href }] }),
)
diff --git a/packages/core/src/mcp/index.ts b/packages/core/src/mcp/index.ts
index 8d853f067376..213a8b4ec7a5 100644
--- a/packages/core/src/mcp/index.ts
+++ b/packages/core/src/mcp/index.ts
@@ -166,616 +166,615 @@ export const Options = Schema.Struct({
})
export type Options = typeof Options.Type
-export const layer = (options?: Options) => Layer.effect(
- Service,
- Effect.gen(function* () {
- const config = yield* Config.Service
- const location = yield* Location.Service
- const bus = yield* Bus.Service
- const forms = yield* Form.Service
- const integration = yield* Integration.Service
- const credentials = yield* Credential.Service
- const root = yield* Scope.make()
- const fork = yield* FiberSet.makeRuntime()
- yield* Effect.addFinalizer((exit) => Scope.close(root, exit))
-
- const documents = (yield* config.entries()).filter((entry): entry is Document => entry.type === "document")
- // Global MCP timeout defaults, later config files overriding earlier ones.
- const timeout = Object.assign(
- {},
- ...documents.flatMap((entry) => (entry.info.mcp?.timeout ? [entry.info.mcp.timeout] : [])),
- )
- // Later config files win for duplicate server names; per-server timeout overrides globals.
- const runtime = new Map()
- // Serializes lifecycle operations per server. Anything taking this lock from a connection
- // callback must stay forked: lifecycle operations close scopes while holding it, firing onClose.
- const locks = KeyedMutex.makeUnsafe()
- const urlElicitations = new Map()
- for (const entry of documents) {
- for (const [name, server] of Object.entries(entry.info.mcp?.servers ?? {})) {
- runtime.set(ServerName.make(name), {
- config: { ...server, timeout: { ...timeout, ...server.timeout } },
- status: { status: "pending" },
- startup: Deferred.makeUnsafe(),
- })
- }
- }
-
- // Register every remote server as an OAuth integration so credentials live in the global store
- // rather than in committed config. Servers that connect anonymously simply never use the method.
- const owned = new Set()
- const register = Effect.fnUntraced(function* (name: ServerName, entry: ServerEntry) {
- if (entry.config.type !== "remote" || entry.config.oauth === false) return
- const remote = entry.config
- // Key identity on name + url, not url alone: two configs for the same url under different names are
- // distinct logical servers that may hold different accounts, so they must not share a credential row.
- const suffix =
- "mcp_" +
- createHash("sha1")
- .update(name + "\u0000" + remote.url)
- .digest("hex")
- .slice(0, 16)
- const integrationID = Integration.ID.make(suffix)
- entry.integrationID = integrationID
- owned.add(integrationID)
- const methodID = Integration.MethodID.make(suffix)
- // Each registration gets its own child scope so disposal detaches it from the root scope
- // entirely; registering directly on root would accumulate a dead finalizer per replaced or
- // removed server for the lifetime of the layer.
- const scope = yield* Scope.fork(root)
- entry.registration = { dispose: Scope.close(scope, Exit.void) }
- yield* integration
- .transform((draft) => {
- draft.update(integrationID, (ref) => {
- ref.name = name
- })
- draft.method.update({
- integrationID,
- method: { id: methodID, type: "oauth", label: name },
- authorize: () => MCPOAuth.authorize({ name, config: remote, methodID }),
+export const layer = (options?: Options) =>
+ Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const config = yield* Config.Service
+ const location = yield* Location.Service
+ const bus = yield* Bus.Service
+ const forms = yield* Form.Service
+ const integration = yield* Integration.Service
+ const credentials = yield* Credential.Service
+ const root = yield* Scope.make()
+ const fork = yield* FiberSet.makeRuntime()
+ yield* Effect.addFinalizer((exit) => Scope.close(root, exit))
+
+ const documents = (yield* config.entries()).filter((entry): entry is Document => entry.type === "document")
+ // Global MCP timeout defaults, later config files overriding earlier ones.
+ const timeout = Object.assign(
+ {},
+ ...documents.flatMap((entry) => (entry.info.mcp?.timeout ? [entry.info.mcp.timeout] : [])),
+ )
+ // Later config files win for duplicate server names; per-server timeout overrides globals.
+ const runtime = new Map()
+ // Serializes lifecycle operations per server. Anything taking this lock from a connection
+ // callback must stay forked: lifecycle operations close scopes while holding it, firing onClose.
+ const locks = KeyedMutex.makeUnsafe()
+ const urlElicitations = new Map()
+ for (const entry of documents) {
+ for (const [name, server] of Object.entries(entry.info.mcp?.servers ?? {})) {
+ runtime.set(ServerName.make(name), {
+ config: { ...server, timeout: { ...timeout, ...server.timeout } },
+ status: { status: "pending" },
+ startup: Deferred.makeUnsafe(),
})
- })
- .pipe(Scope.provide(scope))
- })
- yield* Effect.forEach(runtime, ([name, entry]) => register(name, entry), { discard: true })
-
- const requireServer = Effect.fnUntraced(function* (server: ServerName | string) {
- const name = ServerName.make(server)
- const entry = runtime.get(name)
- if (!entry) return yield* new NotFoundError({ server: name })
- return { name, entry }
- })
-
- // Builds the connect-time auth provider for a remote OAuth-integration server. The SDK presents and
- // refreshes stored tokens, persisting refreshes back to the same credential row. The provider never
- // opens a browser, so an auth-gated connect ends in UnauthorizedError -> needs_auth rather than a redirect.
- const connectProvider = Effect.fnUntraced(function* (entry: ServerEntry) {
- if (entry.config.type !== "remote" || !entry.integrationID) return undefined
- const remote = entry.config
- const oauth = remote.oauth || undefined
- const base = {
- redirectUrl: oauth?.redirect_uri ?? "http://127.0.0.1/callback",
- scope: oauth?.scope,
- client: oauth?.client_id ? { id: oauth.client_id, secret: oauth.client_secret } : undefined,
- // No browser during connect: an auth-gated server surfaces needs_auth instead of opening a browser.
- onRedirect: () => {},
+ }
}
- const stored = yield* credentials.list(entry.integrationID)
- const found = stored.find((credential) => credential.value.type === "oauth")
- if (!found || found.value.type !== "oauth")
- // No stored credential yet: an empty in-memory store still lets the SDK run the auth handshake, which
- // ends in UnauthorizedError -> needs_auth. Returning no provider instead would let the transport throw
- // a raw HTTP error, hiding the auth requirement behind a generic failed status. Anonymous servers are
- // unaffected: tokens() returns undefined, so no auth header is sent and the SDK never calls auth().
- return MCPOAuth.provider({ ...base, store: MCPOAuth.memoryStore() })
- const credentialID = found.id
- const methodID = found.value.methodID
- let current: Credential.OAuth | undefined = found.value
- return MCPOAuth.provider({
- ...base,
- // Drop a credential the SDK rejected so the next connect cleanly reports needs_auth. Uses the raw
- // credential service (no integration event) to avoid re-triggering the reconnect subscriber mid-connect.
- invalidate: async (scope) => {
- if (scope === "verifier" || scope === "discovery") return
- current = undefined
- await Effect.runPromise(credentials.remove(credentialID))
- },
- store: {
- tokens: async () => (current ? MCPOAuth.toTokens(current) : undefined),
- saveTokens: async (tokens) => {
- current = MCPOAuth.toCredential({
- methodID,
- serverUrl: remote.url,
- tokens,
- client: current ? MCPOAuth.clientFromCredential(current) : undefined,
+
+ // Register every remote server as an OAuth integration so credentials live in the global store
+ // rather than in committed config. Servers that connect anonymously simply never use the method.
+ const owned = new Set()
+ const register = Effect.fnUntraced(function* (name: ServerName, entry: ServerEntry) {
+ if (entry.config.type !== "remote" || entry.config.oauth === false) return
+ const remote = entry.config
+ // Key identity on name + url, not url alone: two configs for the same url under different names are
+ // distinct logical servers that may hold different accounts, so they must not share a credential row.
+ const suffix =
+ "mcp_" +
+ createHash("sha1")
+ .update(name + "\u0000" + remote.url)
+ .digest("hex")
+ .slice(0, 16)
+ const integrationID = Integration.ID.make(suffix)
+ entry.integrationID = integrationID
+ owned.add(integrationID)
+ const methodID = Integration.MethodID.make(suffix)
+ // Each registration gets its own child scope so disposal detaches it from the root scope
+ // entirely; registering directly on root would accumulate a dead finalizer per replaced or
+ // removed server for the lifetime of the layer.
+ const scope = yield* Scope.fork(root)
+ entry.registration = { dispose: Scope.close(scope, Exit.void) }
+ yield* integration
+ .transform((draft) => {
+ draft.update(integrationID, (ref) => {
+ ref.name = name
+ })
+ draft.method.update({
+ integrationID,
+ method: { id: methodID, type: "oauth", label: name },
+ authorize: () => MCPOAuth.authorize({ name, config: remote, methodID }),
})
- await Effect.runPromise(credentials.update(credentialID, { value: current }))
+ })
+ .pipe(Scope.provide(scope))
+ })
+ yield* Effect.forEach(runtime, ([name, entry]) => register(name, entry), { discard: true })
+
+ const requireServer = Effect.fnUntraced(function* (server: ServerName | string) {
+ const name = ServerName.make(server)
+ const entry = runtime.get(name)
+ if (!entry) return yield* new NotFoundError({ server: name })
+ return { name, entry }
+ })
+
+ // Builds the connect-time auth provider for a remote OAuth-integration server. The SDK presents and
+ // refreshes stored tokens, persisting refreshes back to the same credential row. The provider never
+ // opens a browser, so an auth-gated connect ends in UnauthorizedError -> needs_auth rather than a redirect.
+ const connectProvider = Effect.fnUntraced(function* (entry: ServerEntry) {
+ if (entry.config.type !== "remote" || !entry.integrationID) return undefined
+ const remote = entry.config
+ const oauth = remote.oauth || undefined
+ const base = {
+ redirectUrl: oauth?.redirect_uri ?? "http://127.0.0.1/callback",
+ scope: oauth?.scope,
+ client: oauth?.client_id ? { id: oauth.client_id, secret: oauth.client_secret } : undefined,
+ // No browser during connect: an auth-gated server surfaces needs_auth instead of opening a browser.
+ onRedirect: () => {},
+ }
+ const stored = yield* credentials.list(entry.integrationID)
+ const found = stored.find((credential) => credential.value.type === "oauth")
+ if (!found || found.value.type !== "oauth")
+ // No stored credential yet: an empty in-memory store still lets the SDK run the auth handshake, which
+ // ends in UnauthorizedError -> needs_auth. Returning no provider instead would let the transport throw
+ // a raw HTTP error, hiding the auth requirement behind a generic failed status. Anonymous servers are
+ // unaffected: tokens() returns undefined, so no auth header is sent and the SDK never calls auth().
+ return MCPOAuth.provider({ ...base, store: MCPOAuth.memoryStore() })
+ const credentialID = found.id
+ const methodID = found.value.methodID
+ let current: Credential.OAuth | undefined = found.value
+ return MCPOAuth.provider({
+ ...base,
+ // Drop a credential the SDK rejected so the next connect cleanly reports needs_auth. Uses the raw
+ // credential service (no integration event) to avoid re-triggering the reconnect subscriber mid-connect.
+ invalidate: async (scope) => {
+ if (scope === "verifier" || scope === "discovery") return
+ current = undefined
+ await Effect.runPromise(credentials.remove(credentialID))
},
- clientInformation: async () => (current ? MCPOAuth.clientFromCredential(current) : undefined),
- saveClientInformation: async () => {},
- codeVerifier: async () => undefined,
- saveCodeVerifier: async () => {},
- },
+ store: {
+ tokens: async () => (current ? MCPOAuth.toTokens(current) : undefined),
+ saveTokens: async (tokens) => {
+ current = MCPOAuth.toCredential({
+ methodID,
+ serverUrl: remote.url,
+ tokens,
+ client: current ? MCPOAuth.clientFromCredential(current) : undefined,
+ })
+ await Effect.runPromise(credentials.update(credentialID, { value: current }))
+ },
+ clientInformation: async () => (current ? MCPOAuth.clientFromCredential(current) : undefined),
+ saveClientInformation: async () => {},
+ codeVerifier: async () => undefined,
+ saveCodeVerifier: async () => {},
+ },
+ })
})
- })
-
- const elicitation = {
- create: (input: {
- readonly server: string
- readonly params: MCPClient.ElicitationParams
- readonly signal: AbortSignal
- }) =>
- Effect.gen(function* () {
- if (input.params.mode === "url") {
- const formID = Form.ID.create()
- const key = input.server + "\u0000" + input.params.elicitationId
- urlElicitations.set(key, formID)
+
+ const elicitation = {
+ create: (input: {
+ readonly server: string
+ readonly params: MCPClient.ElicitationParams
+ readonly signal: AbortSignal
+ }) =>
+ Effect.gen(function* () {
+ if (input.params.mode === "url") {
+ const formID = Form.ID.create()
+ const key = input.server + "\u0000" + input.params.elicitationId
+ urlElicitations.set(key, formID)
+ return yield* forms
+ .ask({
+ id: formID,
+ sessionID: GLOBAL_ELICITATION_SESSION_ID,
+ title: `${input.server} is requesting input`,
+ metadata: {
+ kind: "mcp-elicitation",
+ server: input.server,
+ elicitationID: input.params.elicitationId,
+ message: input.params.message,
+ },
+ fields: [{ key: URL_ELICITATION_FIELD_KEY, type: "external", url: input.params.url }],
+ })
+ .pipe(
+ Effect.raceFirst(waitForAbort(input.signal)),
+ Effect.ensuring(Effect.sync(() => urlElicitations.delete(key))),
+ Effect.map(
+ (state): MCPClient.ElicitationResult => ({
+ action: state.status === "answered" ? "accept" : "cancel",
+ }),
+ ),
+ )
+ }
+ const params = input.params
+ const [field, ...fields] = Object.entries(params.requestedSchema.properties).map(([key, property]) =>
+ toElicitationField(key, property, params.requestedSchema.required?.includes(key) === true),
+ )
+ if (!field) return { action: "accept", content: {} }
return yield* forms
.ask({
- id: formID,
sessionID: GLOBAL_ELICITATION_SESSION_ID,
title: `${input.server} is requesting input`,
- metadata: {
- kind: "mcp-elicitation",
- server: input.server,
- elicitationID: input.params.elicitationId,
- message: input.params.message,
- },
- fields: [{ key: URL_ELICITATION_FIELD_KEY, type: "external", url: input.params.url }],
+ metadata: { kind: "mcp-elicitation", server: input.server, message: params.message },
+ fields: [field, ...fields],
})
.pipe(
Effect.raceFirst(waitForAbort(input.signal)),
- Effect.ensuring(Effect.sync(() => urlElicitations.delete(key))),
- Effect.map(
- (state): MCPClient.ElicitationResult => ({
- action: state.status === "answered" ? "accept" : "cancel",
- }),
- ),
- )
- }
- const params = input.params
- const [field, ...fields] = Object.entries(params.requestedSchema.properties).map(([key, property]) =>
- toElicitationField(key, property, params.requestedSchema.required?.includes(key) === true),
- )
- if (!field) return { action: "accept", content: {} }
- return yield* forms
- .ask({
- sessionID: GLOBAL_ELICITATION_SESSION_ID,
- title: `${input.server} is requesting input`,
- metadata: { kind: "mcp-elicitation", server: input.server, message: params.message },
- fields: [field, ...fields],
- })
- .pipe(
- Effect.raceFirst(waitForAbort(input.signal)),
- Effect.map((state): MCPClient.ElicitationResult => {
- if (state.status !== "answered") return { action: "cancel" }
- return {
- action: "accept",
- content: Object.fromEntries(
- Object.entries(state.answer).map(
- ([key, value]): [string, NonNullable[string]] =>
- typeof value === "object" ? [key, Array.from(value)] : [key, value],
+ Effect.map((state): MCPClient.ElicitationResult => {
+ if (state.status !== "answered") return { action: "cancel" }
+ return {
+ action: "accept",
+ content: Object.fromEntries(
+ Object.entries(state.answer).map(
+ ([key, value]): [string, NonNullable[string]] =>
+ typeof value === "object" ? [key, Array.from(value)] : [key, value],
+ ),
),
- ),
- }
+ }
+ }),
+ )
+ }),
+ complete: (input: { readonly server: string; readonly elicitationID: string }) =>
+ Effect.gen(function* () {
+ const formID = urlElicitations.get(input.server + "\u0000" + input.elicitationID)
+ if (!formID) return
+ yield* forms.reply({ id: formID, answer: { [URL_ELICITATION_FIELD_KEY]: true } }).pipe(Effect.ignore)
+ }),
+ } satisfies MCPClient.ElicitationHandler
+
+ const toTool = (server: ServerName, entry: ServerEntry, def: MCPClient.ToolDefinition) =>
+ new Tool({
+ server,
+ name: def.name,
+ codemode: entry.config.codemode,
+ description: def.description,
+ inputSchema: def.inputSchema,
+ outputSchema: def.outputSchema,
+ })
+
+ const toPrompt = (server: ServerName, def: MCPClient.PromptDefinition) =>
+ new Prompt({
+ server,
+ name: def.name,
+ description: def.description,
+ arguments: def.arguments?.map(
+ (argument) =>
+ new PromptArgument({
+ name: argument.name,
+ description: argument.description,
+ required: argument.required,
}),
- )
- }),
- complete: (input: { readonly server: string; readonly elicitationID: string }) =>
- Effect.gen(function* () {
- const formID = urlElicitations.get(input.server + "\u0000" + input.elicitationID)
- if (!formID) return
- yield* forms.reply({ id: formID, answer: { [URL_ELICITATION_FIELD_KEY]: true } }).pipe(Effect.ignore)
- }),
- } satisfies MCPClient.ElicitationHandler
-
- const toTool = (server: ServerName, entry: ServerEntry, def: MCPClient.ToolDefinition) =>
- new Tool({
- server,
- name: def.name,
- codemode: entry.config.codemode,
- description: def.description,
- inputSchema: def.inputSchema,
- outputSchema: def.outputSchema,
- })
+ ),
+ })
- const toPrompt = (server: ServerName, def: MCPClient.PromptDefinition) =>
- new Prompt({
- server,
- name: def.name,
- description: def.description,
- arguments: def.arguments?.map(
- (argument) =>
- new PromptArgument({
- name: argument.name,
- description: argument.description,
- required: argument.required,
- }),
- ),
- })
+ const toResource = (server: ServerName, def: MCPClient.ResourceDefinition) =>
+ Resource.make({
+ server,
+ name: def.name,
+ uri: def.uri,
+ description: def.description,
+ mimeType: def.mimeType,
+ })
- const toResource = (server: ServerName, def: MCPClient.ResourceDefinition) =>
- Resource.make({
- server,
- name: def.name,
- uri: def.uri,
- description: def.description,
- mimeType: def.mimeType,
- })
+ const toResourceTemplate = (server: ServerName, def: MCPClient.ResourceTemplateDefinition) =>
+ ResourceTemplate.make({
+ server,
+ name: def.name,
+ uriTemplate: def.uriTemplate,
+ description: def.description,
+ mimeType: def.mimeType,
+ })
- const toResourceTemplate = (server: ServerName, def: MCPClient.ResourceTemplateDefinition) =>
- ResourceTemplate.make({
- server,
- name: def.name,
- uriTemplate: def.uriTemplate,
- description: def.description,
- mimeType: def.mimeType,
- })
+ const refreshTools = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
+ connection.tools().pipe(
+ Effect.map((defs) => {
+ entry.tools = defs.map((def) => toTool(name, entry, def))
+ }),
+ )
- const refreshTools = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
- connection.tools().pipe(
- Effect.map((defs) => {
- entry.tools = defs.map((def) => toTool(name, entry, def))
- }),
- )
+ const refreshPrompts = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
+ connection.prompts().pipe(
+ Effect.map((defs) => {
+ entry.prompts = defs.map((def) => toPrompt(name, def))
+ }),
+ Effect.andThen(bus.publish(Command.Event.Updated, {})),
+ Effect.catch(() =>
+ Effect.sync(() => (entry.prompts = [])).pipe(Effect.andThen(bus.publish(Command.Event.Updated, {}))),
+ ),
+ )
- const refreshPrompts = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
- connection.prompts().pipe(
- Effect.map((defs) => {
- entry.prompts = defs.map((def) => toPrompt(name, def))
- }),
- Effect.andThen(bus.publish(Command.Event.Updated, {})),
- Effect.catch(() =>
- Effect.sync(() => (entry.prompts = [])).pipe(Effect.andThen(bus.publish(Command.Event.Updated, {}))),
- ),
- )
+ // Runs a connection callback under the server lock, dropping it if the connection is no longer
+ // the entry's live client, so late SDK callbacks cannot commit obsolete state.
+ const whenLive =
+ (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
+ (effect: Effect.Effect) =>
+ fork(
+ Effect.suspend(() => (entry.client === connection ? effect : Effect.void)).pipe(
+ locks.withLock(name),
+ Effect.ignore,
+ ),
+ )
- // Runs a connection callback under the server lock, dropping it if the connection is no longer
- // the entry's live client, so late SDK callbacks cannot commit obsolete state.
- const whenLive =
- (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
- (effect: Effect.Effect) =>
- fork(
- Effect.suspend(() => (entry.client === connection ? effect : Effect.void)).pipe(
- locks.withLock(name),
- Effect.ignore,
+ const watch = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => {
+ const live = whenLive(name, entry, connection)
+ connection.onClose(() =>
+ live(
+ Effect.gen(function* () {
+ entry.client = undefined
+ entry.tools = undefined
+ entry.prompts = undefined
+ entry.status = { status: "failed", error: "Connection closed" }
+ yield* bus.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
+ yield* bus.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore)
+ yield* bus.publish(Command.Event.Updated, {}).pipe(Effect.ignore)
+ yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
+ }),
+ ),
+ )
+ connection.onLog((message) => fork(serverLog(name, message).pipe(Effect.ignore)))
+ connection.onToolsChanged(() =>
+ live(
+ refreshTools(name, entry, connection).pipe(
+ Effect.andThen(bus.publish(McpEvent.ToolsChanged, { server: name })),
+ ),
),
)
+ connection.onPromptsChanged(() => live(refreshPrompts(name, entry, connection)))
+ connection.onResourcesChanged(() => live(bus.publish(McpEvent.ResourcesChanged, { server: name })))
+ }
- const watch = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => {
- const live = whenLive(name, entry, connection)
- connection.onClose(() =>
- live(
- Effect.gen(function* () {
- entry.client = undefined
- entry.tools = undefined
- entry.prompts = undefined
- entry.status = { status: "failed", error: "Connection closed" }
+ const serverLog = (server: ServerName, message: MCPClient.LogMessage) => {
+ const fields = { server, logger: message.logger, level: message.level, data: message.data }
+ switch (message.level) {
+ case "debug":
+ return Effect.logDebug("MCP server log", fields)
+ case "info":
+ case "notice":
+ return Effect.logInfo("MCP server log", fields)
+ case "warning":
+ return Effect.logWarning("MCP server log", fields)
+ case "error":
+ case "critical":
+ case "alert":
+ case "emergency":
+ return Effect.logError("MCP server log", fields)
+ }
+ }
+
+ const startServer = (name: ServerName, entry: ServerEntry) =>
+ Effect.gen(function* () {
+ // Announce the handshake so connect() and credential reconnects don't show a stale
+ // disabled/failed status for the duration of the connection attempt.
+ entry.status = { status: "pending" }
+ yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
+ const scope = yield* Scope.fork(root)
+ entry.scope = scope
+ const authProvider = yield* connectProvider(entry)
+ // List tools as part of connect so a failure here marks the server failed rather than
+ // leaving it connected with a silently empty tool list and no path to recover.
+ const result = yield* MCPClient.connect(
+ name,
+ entry.config,
+ location.directory,
+ authProvider,
+ elicitation,
+ options?.clientInfo,
+ ).pipe(
+ Effect.flatMap((connection) => connection.tools().pipe(Effect.map((tools) => ({ connection, tools })))),
+ Scope.provide(scope),
+ Effect.exit,
+ )
+ if (Exit.isSuccess(result)) {
+ entry.client = result.value.connection
+ entry.tools = result.value.tools.map((def) => toTool(name, entry, def))
+ entry.prompts = []
+ entry.status = { status: "connected" }
+ watch(name, entry, result.value.connection)
+ yield* Effect.logInfo("mcp connected", { server: name, tools: entry.tools.length })
+ // Announce the new tool set so the tool registry registers it. A server that finishes connecting
+ // after the initial registration sweep and emits no list-changed notification would otherwise
+ // stay invisible to the model.
yield* bus.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
yield* bus.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore)
- yield* bus.publish(Command.Event.Updated, {}).pipe(Effect.ignore)
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
- }),
- ),
- )
- connection.onLog((message) => fork(serverLog(name, message).pipe(Effect.ignore)))
- connection.onToolsChanged(() =>
- live(
- refreshTools(name, entry, connection).pipe(
- Effect.andThen(bus.publish(McpEvent.ToolsChanged, { server: name })),
- ),
- ),
- )
- connection.onPromptsChanged(() => live(refreshPrompts(name, entry, connection)))
- connection.onResourcesChanged(() => live(bus.publish(McpEvent.ResourcesChanged, { server: name })))
- }
+ whenLive(name, entry, result.value.connection)(refreshPrompts(name, entry, result.value.connection))
+ return
+ }
+ yield* Scope.close(scope, Exit.void)
+ entry.scope = undefined
+ const error = Cause.squash(result.cause)
+ entry.status =
+ error instanceof MCPClient.NeedsAuthError
+ ? { status: "needs_auth" }
+ : { status: "failed", error: error instanceof Error ? error.message : String(error) }
+ yield* Effect.logWarning("mcp connect failed", { server: name, status: entry.status })
+ yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
+ }).pipe(Effect.ensuring(Deferred.succeed(entry.startup, undefined)))
- const serverLog = (server: ServerName, message: MCPClient.LogMessage) => {
- const fields = { server, logger: message.logger, level: message.level, data: message.data }
- switch (message.level) {
- case "debug":
- return Effect.logDebug("MCP server log", fields)
- case "info":
- case "notice":
- return Effect.logInfo("MCP server log", fields)
- case "warning":
- return Effect.logWarning("MCP server log", fields)
- case "error":
- case "critical":
- case "alert":
- case "emergency":
- return Effect.logError("MCP server log", fields)
- }
- }
+ const stopServer = Effect.fnUntraced(function* (name: ServerName, entry: ServerEntry) {
+ const scope = entry.scope
+ if (!scope) return
+ entry.scope = undefined
+ entry.client = undefined
+ entry.tools = undefined
+ entry.prompts = undefined
+ yield* Scope.close(scope, Exit.void)
+ yield* bus.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
+ yield* bus.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore)
+ yield* bus.publish(Command.Event.Updated, {}).pipe(Effect.ignore)
+ })
- const startServer = (name: ServerName, entry: ServerEntry) =>
- Effect.gen(function* () {
- // Announce the handshake so connect() and credential reconnects don't show a stale
- // disabled/failed status for the duration of the connection attempt.
- entry.status = { status: "pending" }
- yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
- const scope = yield* Scope.fork(root)
- entry.scope = scope
- const authProvider = yield* connectProvider(entry)
- // List tools as part of connect so a failure here marks the server failed rather than
- // leaving it connected with a silently empty tool list and no path to recover.
- const result = yield* MCPClient.connect(
- name,
- entry.config,
- location.directory,
- authProvider,
- elicitation,
- options?.clientInfo,
- ).pipe(
- Effect.flatMap((connection) => connection.tools().pipe(Effect.map((tools) => ({ connection, tools })))),
- Scope.provide(scope),
- Effect.exit,
- )
- if (Exit.isSuccess(result)) {
- entry.client = result.value.connection
- entry.tools = result.value.tools.map((def) => toTool(name, entry, def))
- entry.prompts = []
- entry.status = { status: "connected" }
- watch(name, entry, result.value.connection)
- yield* Effect.logInfo("mcp connected", { server: name, tools: entry.tools.length })
- // Announce the new tool set so the tool registry registers it. A server that finishes connecting
- // after the initial registration sweep and emits no list-changed notification would otherwise
- // stay invisible to the model.
- yield* bus.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
- yield* bus.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore)
- yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
- whenLive(name, entry, result.value.connection)(refreshPrompts(name, entry, result.value.connection))
- return
+ // Disabled servers settle their startup immediately so queries never block on them.
+ for (const [name, entry] of runtime) {
+ if (entry.config.disabled) {
+ entry.status = { status: "disabled" }
+ Deferred.doneUnsafe(entry.startup, Exit.void)
+ continue
}
- yield* Scope.close(scope, Exit.void)
- entry.scope = undefined
- const error = Cause.squash(result.cause)
- entry.status =
- error instanceof MCPClient.NeedsAuthError
- ? { status: "needs_auth" }
- : { status: "failed", error: error instanceof Error ? error.message : String(error) }
- yield* Effect.logWarning("mcp connect failed", { server: name, status: entry.status })
- yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
- }).pipe(Effect.ensuring(Deferred.succeed(entry.startup, undefined)))
-
- const stopServer = Effect.fnUntraced(function* (name: ServerName, entry: ServerEntry) {
- const scope = entry.scope
- if (!scope) return
- entry.scope = undefined
- entry.client = undefined
- entry.tools = undefined
- entry.prompts = undefined
- yield* Scope.close(scope, Exit.void)
- yield* bus.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
- yield* bus.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore)
- yield* bus.publish(Command.Event.Updated, {}).pipe(Effect.ignore)
- })
-
- // Disabled servers settle their startup immediately so queries never block on them.
- for (const [name, entry] of runtime) {
- if (entry.config.disabled) {
- entry.status = { status: "disabled" }
- Deferred.doneUnsafe(entry.startup, Exit.void)
- continue
+ fork(startServer(name, entry).pipe(locks.withLock(name)))
}
- fork(startServer(name, entry).pipe(locks.withLock(name)))
- }
- // Bring a server online (or back to needs_auth) when its integration's credential changes, so an
- // OAuth login takes effect without a restart. Only fires for the integrations we registered.
- const reconnect = (integrationID: Integration.ID) =>
- Effect.gen(function* () {
- const match = Array.from(runtime).find(([, entry]) => entry.integrationID === integrationID)
- if (!match) return
- const name = match[0]
- yield* Effect.gen(function* () {
- // add() or remove() may have replaced or deleted the entry while we waited for the lock.
- const entry = runtime.get(name)
- if (!entry || entry.integrationID !== integrationID) return
- if (entry.status.status === "disabled") return
- yield* stopServer(name, entry)
- yield* startServer(name, entry)
- }).pipe(locks.withLock(name))
- })
- fork(
- bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
- Stream.filter((event) => owned.has(event.data.integrationID)),
- Stream.runForEach((event) => Effect.sync(() => fork(reconnect(event.data.integrationID)))),
- Effect.ignore,
- ),
- )
-
- // Suspend so each await sees current entries; a bare Map iterator is exhausted after one run.
- const whenAllReady = Effect.suspend(() =>
- Effect.forEach(Array.from(runtime.values()), (entry) => Deferred.await(entry.startup), {
- concurrency: "unbounded",
- discard: true,
- }),
- )
- return Service.of({
- servers: Effect.fn("MCP.servers")(function* () {
- return Array.from(runtime)
- .toSorted(([a], [b]) => a.localeCompare(b))
- .map(
- ([name, entry]) =>
- new ServerInfo({ name, status: entry.status, integrationID: entry.integrationID }),
- )
- }),
- add: Effect.fn("MCP.add")(function* (server, config) {
- const name = ServerName.make(server)
- yield* Effect.gen(function* () {
- const previous = runtime.get(name)
- if (previous) {
- yield* stopServer(name, previous)
- if (previous.integrationID) owned.delete(previous.integrationID)
- if (previous.registration) yield* previous.registration.dispose
- }
- const entry: ServerEntry = {
- config: { ...config, timeout: { ...timeout, ...config.timeout } },
- status: { status: "pending" },
- startup: Deferred.makeUnsafe(),
- }
- runtime.set(name, entry)
+ // Bring a server online (or back to needs_auth) when its integration's credential changes, so an
+ // OAuth login takes effect without a restart. Only fires for the integrations we registered.
+ const reconnect = (integrationID: Integration.ID) =>
+ Effect.gen(function* () {
+ const match = Array.from(runtime).find(([, entry]) => entry.integrationID === integrationID)
+ if (!match) return
+ const name = match[0]
yield* Effect.gen(function* () {
- yield* register(name, entry)
- if (config.disabled) {
- entry.status = { status: "disabled" }
- yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
- return
- }
+ // add() or remove() may have replaced or deleted the entry while we waited for the lock.
+ const entry = runtime.get(name)
+ if (!entry || entry.integrationID !== integrationID) return
+ if (entry.status.status === "disabled") return
+ yield* stopServer(name, entry)
yield* startServer(name, entry)
- }).pipe(
- // Settle startup even when register fails or add is interrupted, so an entry that made it
- // into runtime can never hang readers awaiting its startup.
- Effect.ensuring(Effect.sync(() => Deferred.doneUnsafe(entry.startup, Exit.void))),
- )
- }).pipe(locks.withLock(name))
- }),
- connect: Effect.fn("MCP.connect")(function* (server) {
- const name = ServerName.make(server)
- yield* Effect.gen(function* () {
- const target = yield* requireServer(name)
- yield* stopServer(name, target.entry)
- yield* startServer(name, target.entry)
- }).pipe(locks.withLock(name))
- }),
- disconnect: Effect.fn("MCP.disconnect")(function* (server) {
- const name = ServerName.make(server)
- yield* Effect.gen(function* () {
- const target = yield* requireServer(name)
- yield* stopServer(name, target.entry)
- target.entry.status = { status: "disabled" }
- yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
- }).pipe(locks.withLock(name))
- }),
- remove: Effect.fn("MCP.remove")(function* (server) {
- const name = ServerName.make(server)
- yield* Effect.gen(function* () {
- const target = yield* requireServer(name)
- yield* stopServer(name, target.entry)
- if (target.entry.integrationID) owned.delete(target.entry.integrationID)
- if (target.entry.registration) yield* target.entry.registration.dispose
- // Credentials are kept: they are keyed by name + url, so re-adding the same server
- // reuses them without forcing re-auth, matching add()'s replacement semantics.
- runtime.delete(name)
- yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
- }).pipe(locks.withLock(name))
- }),
- tools: Effect.fn("MCP.tools")(function* () {
- yield* whenAllReady
- return Array.from(runtime.values())
- .flatMap((entry) => entry.tools ?? [])
- .toSorted((a, b) => a.server.localeCompare(b.server) || a.name.localeCompare(b.name))
- }),
- callTool: Effect.fn("MCP.callTool")(function* (input) {
- const target = yield* requireServer(input.server)
- yield* Deferred.await(target.entry.startup)
- if (!target.entry.client)
- return yield* new ToolCallError({
+ }).pipe(locks.withLock(name))
+ })
+ fork(
+ bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
+ Stream.filter((event) => owned.has(event.data.integrationID)),
+ Stream.runForEach((event) => Effect.sync(() => fork(reconnect(event.data.integrationID)))),
+ Effect.ignore,
+ ),
+ )
+
+ // Suspend so each await sees current entries; a bare Map iterator is exhausted after one run.
+ const whenAllReady = Effect.suspend(() =>
+ Effect.forEach(Array.from(runtime.values()), (entry) => Deferred.await(entry.startup), {
+ concurrency: "unbounded",
+ discard: true,
+ }),
+ )
+ return Service.of({
+ servers: Effect.fn("MCP.servers")(function* () {
+ return Array.from(runtime)
+ .toSorted(([a], [b]) => a.localeCompare(b))
+ .map(([name, entry]) => new ServerInfo({ name, status: entry.status, integrationID: entry.integrationID }))
+ }),
+ add: Effect.fn("MCP.add")(function* (server, config) {
+ const name = ServerName.make(server)
+ yield* Effect.gen(function* () {
+ const previous = runtime.get(name)
+ if (previous) {
+ yield* stopServer(name, previous)
+ if (previous.integrationID) owned.delete(previous.integrationID)
+ if (previous.registration) yield* previous.registration.dispose
+ }
+ const entry: ServerEntry = {
+ config: { ...config, timeout: { ...timeout, ...config.timeout } },
+ status: { status: "pending" },
+ startup: Deferred.makeUnsafe(),
+ }
+ runtime.set(name, entry)
+ yield* Effect.gen(function* () {
+ yield* register(name, entry)
+ if (config.disabled) {
+ entry.status = { status: "disabled" }
+ yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
+ return
+ }
+ yield* startServer(name, entry)
+ }).pipe(
+ // Settle startup even when register fails or add is interrupted, so an entry that made it
+ // into runtime can never hang readers awaiting its startup.
+ Effect.ensuring(Effect.sync(() => Deferred.doneUnsafe(entry.startup, Exit.void))),
+ )
+ }).pipe(locks.withLock(name))
+ }),
+ connect: Effect.fn("MCP.connect")(function* (server) {
+ const name = ServerName.make(server)
+ yield* Effect.gen(function* () {
+ const target = yield* requireServer(name)
+ yield* stopServer(name, target.entry)
+ yield* startServer(name, target.entry)
+ }).pipe(locks.withLock(name))
+ }),
+ disconnect: Effect.fn("MCP.disconnect")(function* (server) {
+ const name = ServerName.make(server)
+ yield* Effect.gen(function* () {
+ const target = yield* requireServer(name)
+ yield* stopServer(name, target.entry)
+ target.entry.status = { status: "disabled" }
+ yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
+ }).pipe(locks.withLock(name))
+ }),
+ remove: Effect.fn("MCP.remove")(function* (server) {
+ const name = ServerName.make(server)
+ yield* Effect.gen(function* () {
+ const target = yield* requireServer(name)
+ yield* stopServer(name, target.entry)
+ if (target.entry.integrationID) owned.delete(target.entry.integrationID)
+ if (target.entry.registration) yield* target.entry.registration.dispose
+ // Credentials are kept: they are keyed by name + url, so re-adding the same server
+ // reuses them without forcing re-auth, matching add()'s replacement semantics.
+ runtime.delete(name)
+ yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
+ }).pipe(locks.withLock(name))
+ }),
+ tools: Effect.fn("MCP.tools")(function* () {
+ yield* whenAllReady
+ return Array.from(runtime.values())
+ .flatMap((entry) => entry.tools ?? [])
+ .toSorted((a, b) => a.server.localeCompare(b.server) || a.name.localeCompare(b.name))
+ }),
+ callTool: Effect.fn("MCP.callTool")(function* (input) {
+ const target = yield* requireServer(input.server)
+ yield* Deferred.await(target.entry.startup)
+ if (!target.entry.client)
+ return yield* new ToolCallError({
+ server: target.name,
+ tool: input.name,
+ message: "MCP server is not connected",
+ })
+ const result = yield* target.entry.client
+ .callTool({ name: input.name, args: input.args })
+ .pipe(
+ Effect.mapError(
+ (error) => new ToolCallError({ server: target.name, tool: input.name, message: error.message }),
+ ),
+ )
+ return new ToolResult({
server: target.name,
tool: input.name,
- message: "MCP server is not connected",
+ isError: result.isError,
+ structured: result.structured,
+ content: result.content,
})
- const result = yield* target.entry.client
- .callTool({ name: input.name, args: input.args })
- .pipe(
- Effect.mapError(
- (error) => new ToolCallError({ server: target.name, tool: input.name, message: error.message }),
+ }),
+ instructions: Effect.fn("MCP.instructions")(function* () {
+ yield* whenAllReady
+ return Array.from(runtime)
+ .flatMap(([server, entry]) => {
+ const instructions = entry.client?.instructions
+ if (!instructions) return []
+ return [new ServerInstructions({ server, instructions })]
+ })
+ .toSorted((a, b) => a.server.localeCompare(b.server))
+ }),
+ prompts: Effect.fn("MCP.prompts")(function* () {
+ return Array.from(runtime.values())
+ .flatMap((entry) => entry.prompts ?? [])
+ .toSorted((a, b) => a.server.localeCompare(b.server) || a.name.localeCompare(b.name))
+ }),
+ prompt: Effect.fn("MCP.prompt")(function* (input) {
+ const target = yield* requireServer(input.server)
+ yield* Deferred.await(target.entry.startup)
+ if (!target.entry.client) return undefined
+ const result = yield* target.entry.client
+ .prompt({ name: input.name, args: input.args })
+ .pipe(Effect.catch(() => Effect.succeed(undefined)))
+ if (!result) return undefined
+ return new PromptResult({
+ server: target.name,
+ name: input.name,
+ messages: result.messages.map(
+ (message) => new PromptMessage({ role: message.role, content: message.content }),
),
+ })
+ }),
+ resourceCatalog: Effect.fn("MCP.resourceCatalog")(function* () {
+ yield* whenAllReady
+ const catalogs = yield* Effect.forEach(
+ Array.from(runtime),
+ ([name, entry]) => {
+ if (!entry.client) return Effect.succeed({ resources: [], templates: [] })
+ return Effect.all(
+ {
+ resources: entry.client.resources().pipe(Effect.catch(() => Effect.succeed([]))),
+ templates: entry.client.resourceTemplates().pipe(Effect.catch(() => Effect.succeed([]))),
+ },
+ { concurrency: "unbounded" },
+ ).pipe(
+ Effect.map((catalog) => ({
+ resources: catalog.resources.map((def) => toResource(name, def)),
+ templates: catalog.templates.map((def) => toResourceTemplate(name, def)),
+ })),
+ )
+ },
+ { concurrency: "unbounded" },
)
- return new ToolResult({
- server: target.name,
- tool: input.name,
- isError: result.isError,
- structured: result.structured,
- content: result.content,
- })
- }),
- instructions: Effect.fn("MCP.instructions")(function* () {
- yield* whenAllReady
- return Array.from(runtime)
- .flatMap(([server, entry]) => {
- const instructions = entry.client?.instructions
- if (!instructions) return []
- return [new ServerInstructions({ server, instructions })]
+ return ResourceCatalog.make({
+ resources: catalogs
+ .flatMap((catalog) => catalog.resources)
+ .toSorted(
+ (a, b) =>
+ a.server.localeCompare(b.server) || a.name.localeCompare(b.name) || a.uri.localeCompare(b.uri),
+ ),
+ templates: catalogs
+ .flatMap((catalog) => catalog.templates)
+ .toSorted(
+ (a, b) =>
+ a.server.localeCompare(b.server) ||
+ a.name.localeCompare(b.name) ||
+ a.uriTemplate.localeCompare(b.uriTemplate),
+ ),
})
- .toSorted((a, b) => a.server.localeCompare(b.server))
- }),
- prompts: Effect.fn("MCP.prompts")(function* () {
- return Array.from(runtime.values())
- .flatMap((entry) => entry.prompts ?? [])
- .toSorted((a, b) => a.server.localeCompare(b.server) || a.name.localeCompare(b.name))
- }),
- prompt: Effect.fn("MCP.prompt")(function* (input) {
- const target = yield* requireServer(input.server)
- yield* Deferred.await(target.entry.startup)
- if (!target.entry.client) return undefined
- const result = yield* target.entry.client
- .prompt({ name: input.name, args: input.args })
- .pipe(Effect.catch(() => Effect.succeed(undefined)))
- if (!result) return undefined
- return new PromptResult({
- server: target.name,
- name: input.name,
- messages: result.messages.map(
- (message) => new PromptMessage({ role: message.role, content: message.content }),
- ),
- })
- }),
- resourceCatalog: Effect.fn("MCP.resourceCatalog")(function* () {
- yield* whenAllReady
- const catalogs = yield* Effect.forEach(
- Array.from(runtime),
- ([name, entry]) => {
- if (!entry.client) return Effect.succeed({ resources: [], templates: [] })
- return Effect.all(
- {
- resources: entry.client.resources().pipe(Effect.catch(() => Effect.succeed([]))),
- templates: entry.client.resourceTemplates().pipe(Effect.catch(() => Effect.succeed([]))),
- },
- { concurrency: "unbounded" },
- ).pipe(
- Effect.map((catalog) => ({
- resources: catalog.resources.map((def) => toResource(name, def)),
- templates: catalog.templates.map((def) => toResourceTemplate(name, def)),
- })),
- )
- },
- { concurrency: "unbounded" },
- )
- return ResourceCatalog.make({
- resources: catalogs
- .flatMap((catalog) => catalog.resources)
- .toSorted(
- (a, b) => a.server.localeCompare(b.server) || a.name.localeCompare(b.name) || a.uri.localeCompare(b.uri),
- ),
- templates: catalogs
- .flatMap((catalog) => catalog.templates)
- .toSorted(
- (a, b) =>
- a.server.localeCompare(b.server) ||
- a.name.localeCompare(b.name) ||
- a.uriTemplate.localeCompare(b.uriTemplate),
- ),
- })
- }),
- readResource: Effect.fn("MCP.readResource")(function* (input) {
- const target = yield* requireServer(input.server)
- yield* Deferred.await(target.entry.startup)
- if (!target.entry.client) return undefined
- const result = yield* target.entry.client
- .readResource({ uri: input.uri })
- .pipe(Effect.catch(() => Effect.succeed(undefined)))
- if (!result) return undefined
- return ResourceContent.make({
- server: target.name,
- uri: input.uri,
- contents: result.contents,
- })
- }),
- })
- }),
-)
+ }),
+ readResource: Effect.fn("MCP.readResource")(function* (input) {
+ const target = yield* requireServer(input.server)
+ yield* Deferred.await(target.entry.startup)
+ if (!target.entry.client) return undefined
+ const result = yield* target.entry.client
+ .readResource({ uri: input.uri })
+ .pipe(Effect.catch(() => Effect.succeed(undefined)))
+ if (!result) return undefined
+ return ResourceContent.make({
+ server: target.name,
+ uri: input.uri,
+ contents: result.contents,
+ })
+ }),
+ })
+ }),
+ )
export function configured(options?: Options) {
return makeLocationNode({
diff --git a/packages/core/src/mcp/oauth.ts b/packages/core/src/mcp/oauth.ts
index 1015e1bd403c..a7164026dbdf 100644
--- a/packages/core/src/mcp/oauth.ts
+++ b/packages/core/src/mcp/oauth.ts
@@ -160,7 +160,9 @@ export const authorize = (input: {
}
const fail = (reason: string) => {
Effect.runFork(Deferred.fail(code, new Error(reason)))
- response.writeHead(400, { "Content-Type": "text/html" }).end(OauthCallbackPage.error(reason, { provider: input.name }))
+ response
+ .writeHead(400, { "Content-Type": "text/html" })
+ .end(OauthCallbackPage.error(reason, { provider: input.name }))
}
const error = url.searchParams.get("error_description") ?? url.searchParams.get("error")
if (error) return fail(error)
@@ -216,7 +218,12 @@ export const authorize = (input: {
// The provider may already hold valid tokens (e.g. a re-auth), in which case there is no browser step.
if (result === "AUTHORIZED") {
- return { url: input.config.url, instructions: `Connected to ${input.name}.`, mode: "auto" as const, callback: finalize }
+ return {
+ url: input.config.url,
+ instructions: `Connected to ${input.name}.`,
+ mode: "auto" as const,
+ callback: finalize,
+ }
}
if (!authorizationUrl)
return yield* Effect.fail(new Error(`MCP server "${input.name}" did not provide an authorization URL`))
@@ -228,7 +235,8 @@ export const authorize = (input: {
callback: Deferred.await(code).pipe(
Effect.flatMap((value) =>
Effect.tryPromise({
- try: () => auth(oauthProvider, { serverUrl: input.config.url, authorizationCode: value, scope: oauth?.scope }),
+ try: () =>
+ auth(oauthProvider, { serverUrl: input.config.url, authorizationCode: value, scope: oauth?.scope }),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
}),
),
diff --git a/packages/core/src/mime.ts b/packages/core/src/mime.ts
index 6ad8de5a605c..20b469271af5 100644
--- a/packages/core/src/mime.ts
+++ b/packages/core/src/mime.ts
@@ -10,8 +10,7 @@ export function detect(bytes: Uint8Array) {
return "image/webp"
if (
startsWith(bytes.subarray(4), [0x66, 0x74, 0x79, 0x70]) &&
- (startsWith(bytes.subarray(8), [0x61, 0x76, 0x69, 0x66]) ||
- startsWith(bytes.subarray(8), [0x61, 0x76, 0x69, 0x73]))
+ (startsWith(bytes.subarray(8), [0x61, 0x76, 0x69, 0x66]) || startsWith(bytes.subarray(8), [0x61, 0x76, 0x69, 0x73]))
)
return "image/avif"
return isText(bytes) ? "text/plain" : "application/octet-stream"
diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts
index 252d04f53045..c76eaa641a7f 100644
--- a/packages/core/src/models-dev.ts
+++ b/packages/core/src/models-dev.ts
@@ -539,122 +539,122 @@ export class Service extends Context.Service()("@opencode/Mo
export const layer = (options?: Options) =>
Layer.effect(
- Service,
- Effect.gen(function* () {
- const fs = yield* FSUtil.Service
- const bus = yield* Bus.Service
+ Service,
+ Effect.gen(function* () {
+ const fs = yield* FSUtil.Service
+ const bus = yield* Bus.Service
const app = yield* App.Metadata
- const http = HttpClient.filterStatusOk(
- (yield* HttpClient.HttpClient).pipe(
- HttpClient.retryTransient({
- retryOn: "errors-and-responses",
- times: 2,
- schedule: Schedule.exponential(200).pipe(Schedule.jittered),
- }),
- ),
- )
+ const http = HttpClient.filterStatusOk(
+ (yield* HttpClient.HttpClient).pipe(
+ HttpClient.retryTransient({
+ retryOn: "errors-and-responses",
+ times: 2,
+ schedule: Schedule.exponential(200).pipe(Schedule.jittered),
+ }),
+ ),
+ )
- const source = options?.url || "https://models.dev"
- const fetch = options?.fetch ?? true
+ const source = options?.url || "https://models.dev"
+ const fetch = options?.fetch ?? true
const userAgent = App.useragent(app)
- const filepath = path.join(
- Global.Path.cache,
- source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`,
- )
- const ttl = Duration.minutes(5)
- const lockKey = `models-dev:${filepath}`
-
- const fresh = Effect.fnUntraced(function* () {
- const stat = yield* fs.stat(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
- if (!stat) return false
- const mtime = Option.getOrElse(stat.mtime, () => new Date(0)).getTime()
- return Date.now() - mtime < Duration.toMillis(ttl)
- })
-
- const fetchApi = Effect.fn("ModelsDev.fetchApi")(function* () {
- return yield* HttpClientRequest.get(`${source}/api.json`).pipe(
- HttpClientRequest.setHeader("User-Agent", userAgent),
- http.execute,
- Effect.flatMap((res) => res.text),
- Effect.timeout("10 seconds"),
+ const filepath = path.join(
+ Global.Path.cache,
+ source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`,
)
- })
+ const ttl = Duration.minutes(5)
+ const lockKey = `models-dev:${filepath}`
+
+ const fresh = Effect.fnUntraced(function* () {
+ const stat = yield* fs.stat(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
+ if (!stat) return false
+ const mtime = Option.getOrElse(stat.mtime, () => new Date(0)).getTime()
+ return Date.now() - mtime < Duration.toMillis(ttl)
+ })
+
+ const fetchApi = Effect.fn("ModelsDev.fetchApi")(function* () {
+ return yield* HttpClientRequest.get(`${source}/api.json`).pipe(
+ HttpClientRequest.setHeader("User-Agent", userAgent),
+ http.execute,
+ Effect.flatMap((res) => res.text),
+ Effect.timeout("10 seconds"),
+ )
+ })
- const loadFromDisk = fs.readJson(options?.file ?? filepath).pipe(
- Effect.map((input) => input as Record),
- Effect.catch((error) => {
+ const loadFromDisk = fs.readJson(options?.file ?? filepath).pipe(
+ Effect.map((input) => input as Record),
+ Effect.catch((error) => {
if (options?.file === undefined && error._tag === "FileSystemError" && error.method === "readJson") {
- return fs.remove(filepath, { force: true }).pipe(Effect.ignore, Effect.as(undefined))
- }
- return Effect.succeed(undefined)
- }),
- )
-
- const loadSnapshot = Effect.sync(() =>
- typeof OPENCODE_MODELS_DEV === "undefined" ? undefined : OPENCODE_MODELS_DEV,
- )
-
- const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
- const text = yield* fetchApi()
- const tempfile = `${filepath}.${process.pid}.${Date.now()}.tmp`
- yield* fs.writeWithDirs(tempfile, text).pipe(
- Effect.andThen(fs.rename(tempfile, filepath)),
- Effect.catch((error) =>
- Effect.gen(function* () {
- yield* fs.remove(tempfile, { force: true }).pipe(Effect.ignore)
- return yield* Effect.fail(error)
- }),
- ),
- )
- return text
- })
-
- const populate = Effect.gen(function* () {
- const fromDisk = yield* loadFromDisk
- if (fromDisk) return normalize(fromDisk)
- const bundled = yield* loadSnapshot
- if (bundled) return normalize(bundled)
- if (!fetch) return []
- // Flock is cross-process: concurrent opencode CLIs can race on this cache file.
- const text = yield* Effect.scoped(
- Effect.gen(function* () {
- yield* Flock.effect(lockKey)
- return yield* fetchAndWrite()
+ return fs.remove(filepath, { force: true }).pipe(Effect.ignore, Effect.as(undefined))
+ }
+ return Effect.succeed(undefined)
}),
)
- return normalize(JSON.parse(text) as Record)
- }).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie)
-
- const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity)
-
- const get = (): Effect.Effect => cachedGet
-
- const refresh = Effect.fn("ModelsDev.refresh")(function* (force = false) {
- if (!force && (yield* fresh())) return
- yield* Effect.scoped(
- Effect.gen(function* () {
- yield* Flock.effect(lockKey)
- // Re-check under the lock: another process may have refreshed between
- // our outer check and lock acquisition.
- if (!force && (yield* fresh())) return
- yield* fetchAndWrite()
- yield* invalidate
- yield* bus.publish(ModelsDev.Event.Refreshed, {})
- }),
- ).pipe(
- Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause: cause })),
- Effect.ignore,
+
+ const loadSnapshot = Effect.sync(() =>
+ typeof OPENCODE_MODELS_DEV === "undefined" ? undefined : OPENCODE_MODELS_DEV,
)
- })
- if (fetch && !process.argv.includes("--get-yargs-completions")) {
- // Schedule.spaced runs the effect once, then waits between completions.
- yield* Effect.forkScoped(refresh().pipe(Effect.repeat(Schedule.spaced(ttl)), Effect.ignore))
- }
+ const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
+ const text = yield* fetchApi()
+ const tempfile = `${filepath}.${process.pid}.${Date.now()}.tmp`
+ yield* fs.writeWithDirs(tempfile, text).pipe(
+ Effect.andThen(fs.rename(tempfile, filepath)),
+ Effect.catch((error) =>
+ Effect.gen(function* () {
+ yield* fs.remove(tempfile, { force: true }).pipe(Effect.ignore)
+ return yield* Effect.fail(error)
+ }),
+ ),
+ )
+ return text
+ })
+
+ const populate = Effect.gen(function* () {
+ const fromDisk = yield* loadFromDisk
+ if (fromDisk) return normalize(fromDisk)
+ const bundled = yield* loadSnapshot
+ if (bundled) return normalize(bundled)
+ if (!fetch) return []
+ // Flock is cross-process: concurrent opencode CLIs can race on this cache file.
+ const text = yield* Effect.scoped(
+ Effect.gen(function* () {
+ yield* Flock.effect(lockKey)
+ return yield* fetchAndWrite()
+ }),
+ )
+ return normalize(JSON.parse(text) as Record)
+ }).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie)
+
+ const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity)
+
+ const get = (): Effect.Effect => cachedGet
- return Service.of({ get, refresh })
- }),
-)
+ const refresh = Effect.fn("ModelsDev.refresh")(function* (force = false) {
+ if (!force && (yield* fresh())) return
+ yield* Effect.scoped(
+ Effect.gen(function* () {
+ yield* Flock.effect(lockKey)
+ // Re-check under the lock: another process may have refreshed between
+ // our outer check and lock acquisition.
+ if (!force && (yield* fresh())) return
+ yield* fetchAndWrite()
+ yield* invalidate
+ yield* bus.publish(ModelsDev.Event.Refreshed, {})
+ }),
+ ).pipe(
+ Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause: cause })),
+ Effect.ignore,
+ )
+ })
+
+ if (fetch && !process.argv.includes("--get-yargs-completions")) {
+ // Schedule.spaced runs the effect once, then waits between completions.
+ yield* Effect.forkScoped(refresh().pipe(Effect.repeat(Schedule.spaced(ttl)), Effect.ignore))
+ }
+
+ return Service.of({ get, refresh })
+ }),
+ )
export function configured(options?: Options) {
return makeGlobalNode({
diff --git a/packages/core/src/permission.ts b/packages/core/src/permission.ts
index b957a2e763c4..457c7ef7d61d 100644
--- a/packages/core/src/permission.ts
+++ b/packages/core/src/permission.ts
@@ -139,14 +139,15 @@ const layer = Layer.effect(
const savedRules = Effect.fnUntraced(function* () {
return (yield* saved.list({ projectID: location.project.id })).map(
- (item): Permission.Rule => ({ action: item.action, resource: item.resource, effect: "allow" }),
+ (item): Permission.Rule => ({
+ action: item.action,
+ resource: item.resource,
+ effect: "allow",
+ }),
)
})
- const configured = Effect.fn("Permission.configured")(function* (
- sessionID: SessionSchema.ID,
- agentID?: Agent.ID,
- ) {
+ const configured = Effect.fn("Permission.configured")(function* (sessionID: SessionSchema.ID, agentID?: Agent.ID) {
const session = yield* sessions.get(sessionID)
if (!session) return yield* new SessionErrors.NotFoundError({ sessionID })
const agent = yield* agents.resolve(agentID ?? session.agent)
diff --git a/packages/core/src/permission/saved.ts b/packages/core/src/permission/saved.ts
index 0c0fe2d067b4..06c4aa304f0c 100644
--- a/packages/core/src/permission/saved.ts
+++ b/packages/core/src/permission/saved.ts
@@ -47,7 +47,12 @@ const layer = Layer.effect(
.all()
.pipe(Effect.orDie)
return rows.map(
- (row): Info => ({ id: row.id, projectID: row.project_id, action: row.action, resource: row.resource }),
+ (row): Info => ({
+ id: row.id,
+ projectID: row.project_id,
+ action: row.action,
+ resource: row.resource,
+ }),
)
})
diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts
index 9f525cab991f..b273f217d793 100644
--- a/packages/core/src/plugin/host.ts
+++ b/packages/core/src/plugin/host.ts
@@ -47,17 +47,13 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
workspaceID: location.workspaceID,
project: location.project,
})
- const locationRef = (input?: {
- readonly location?: { readonly directory?: string; readonly workspace?: string }
- }) =>
+ const locationRef = (input?: { readonly location?: { readonly directory?: string; readonly workspace?: string } }) =>
input?.location === undefined
? undefined
: Location.Ref.make({
directory: AbsolutePath.make(input.location.directory ?? location.directory),
workspaceID:
- input.location.workspace === undefined
- ? location.workspaceID
- : Workspace.ID.make(input.location.workspace),
+ input.location.workspace === undefined ? location.workspaceID : Workspace.ID.make(input.location.workspace),
})
const isCurrentLocation = (ref: Location.Ref) =>
ref.directory === location.directory && ref.workspaceID === location.workspaceID
@@ -72,9 +68,12 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
const ref = locationRef(input)
const output =
ref && !isCurrentLocation(ref)
- ? runtime.location.agent
- .list(ref)
- .pipe(Effect.map((result) => ({ ...result, data: result.data.find((agent) => agent.id === input.agentID) })))
+ ? runtime.location.agent.list(ref).pipe(
+ Effect.map((result) => ({
+ ...result,
+ data: result.data.find((agent) => agent.id === input.agentID),
+ })),
+ )
: response(agents.get(input.agentID))
return output.pipe(
Effect.flatMap((result) =>
@@ -162,8 +161,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
mutable(draft.model.get(Provider.ID.make(providerID), Model.ID.make(modelID))),
update: (providerID, modelID, update) =>
draft.model.update(Provider.ID.make(providerID), Model.ID.make(modelID), update),
- remove: (providerID, modelID) =>
- draft.model.remove(Provider.ID.make(providerID), Model.ID.make(modelID)),
+ remove: (providerID, modelID) => draft.model.remove(Provider.ID.make(providerID), Model.ID.make(modelID)),
default: {
get: draft.model.default.get,
set: (providerID, modelID) =>
diff --git a/packages/core/src/plugin/provider/github-copilot.ts b/packages/core/src/plugin/provider/github-copilot.ts
index e7d27f4cca83..3184f0f98c4d 100644
--- a/packages/core/src/plugin/provider/github-copilot.ts
+++ b/packages/core/src/plugin/provider/github-copilot.ts
@@ -40,106 +40,107 @@ const decodeUser = Schema.decodeUnknownOption(User)
const JsonBody = Schema.UnknownFromJsonString
const decodeBody = Schema.decodeUnknownOption(JsonBody)
-const oauth = (app: App.Info) => ({
- integrationID: Integration.ID.make("github-copilot"),
- method: {
- id: methodID,
- type: "oauth",
- label: "Login with GitHub Copilot",
- prompts: [
- {
- type: "select",
- key: "deploymentType",
- message: "Select GitHub deployment type",
- options: [
- { label: "GitHub.com", value: "github.com", hint: "Public" },
- { label: "GitHub Enterprise", value: "enterprise", hint: "Data residency or self-hosted" },
- ],
- },
- {
- type: "text",
- key: "enterpriseUrl",
- message: "Enter your GitHub Enterprise URL or domain",
- placeholder: "company.ghe.com or https://company.ghe.com",
- when: { key: "deploymentType", op: "eq", value: "enterprise" },
- },
- ],
- },
- authorize: (inputs) =>
- Effect.gen(function* () {
- const enterprise = inputs.deploymentType === "enterprise"
- if (enterprise && !inputs.enterpriseUrl) return yield* Effect.fail(new Error("Enterprise URL is required"))
- const domain = enterprise ? normalizeDomain(inputs.enterpriseUrl ?? "") : "github.com"
- const urls = oauthURLs(domain)
- const device = yield* request(urls.device, {
- method: "POST",
- headers: headers(app),
- body: JSON.stringify({ client_id: clientID, scope: "read:user" }),
- }).pipe(Effect.map(Schema.decodeUnknownSync(Device)))
- const interval = Math.max(device.interval, 1) * 1000
-
- const poll = (wait: number): Effect.Effect =>
- request(urls.token, {
+const oauth = (app: App.Info) =>
+ ({
+ integrationID: Integration.ID.make("github-copilot"),
+ method: {
+ id: methodID,
+ type: "oauth",
+ label: "Login with GitHub Copilot",
+ prompts: [
+ {
+ type: "select",
+ key: "deploymentType",
+ message: "Select GitHub deployment type",
+ options: [
+ { label: "GitHub.com", value: "github.com", hint: "Public" },
+ { label: "GitHub Enterprise", value: "enterprise", hint: "Data residency or self-hosted" },
+ ],
+ },
+ {
+ type: "text",
+ key: "enterpriseUrl",
+ message: "Enter your GitHub Enterprise URL or domain",
+ placeholder: "company.ghe.com or https://company.ghe.com",
+ when: { key: "deploymentType", op: "eq", value: "enterprise" },
+ },
+ ],
+ },
+ authorize: (inputs) =>
+ Effect.gen(function* () {
+ const enterprise = inputs.deploymentType === "enterprise"
+ if (enterprise && !inputs.enterpriseUrl) return yield* Effect.fail(new Error("Enterprise URL is required"))
+ const domain = enterprise ? normalizeDomain(inputs.enterpriseUrl ?? "") : "github.com"
+ const urls = oauthURLs(domain)
+ const device = yield* request(urls.device, {
method: "POST",
headers: headers(app),
- body: JSON.stringify({
- client_id: clientID,
- device_code: device.device_code,
- grant_type: "urn:ietf:params:oauth:grant-type:device_code",
- }),
- }).pipe(
- Effect.map(Schema.decodeUnknownSync(Token)),
- Effect.flatMap((token) => {
- if (token.access_token) {
- const access = token.access_token
- return request(
- `${domain === "github.com" ? "https://api.github.com" : `https://api.${domain}`}/copilot_internal/user`,
- {
- headers: {
- Accept: "application/json",
- Authorization: `Bearer ${access}`,
- "User-Agent": App.useragent(app),
- "X-GitHub-Api-Version": userApiVersion,
+ body: JSON.stringify({ client_id: clientID, scope: "read:user" }),
+ }).pipe(Effect.map(Schema.decodeUnknownSync(Device)))
+ const interval = Math.max(device.interval, 1) * 1000
+
+ const poll = (wait: number): Effect.Effect =>
+ request(urls.token, {
+ method: "POST",
+ headers: headers(app),
+ body: JSON.stringify({
+ client_id: clientID,
+ device_code: device.device_code,
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
+ }),
+ }).pipe(
+ Effect.map(Schema.decodeUnknownSync(Token)),
+ Effect.flatMap((token) => {
+ if (token.access_token) {
+ const access = token.access_token
+ return request(
+ `${domain === "github.com" ? "https://api.github.com" : `https://api.${domain}`}/copilot_internal/user`,
+ {
+ headers: {
+ Accept: "application/json",
+ Authorization: `Bearer ${access}`,
+ "User-Agent": App.useragent(app),
+ "X-GitHub-Api-Version": userApiVersion,
+ },
},
- },
- ).pipe(
- Effect.map((user) => Option.getOrUndefined(decodeUser(user))?.endpoints?.api?.replace(/\/+$/, "")),
- Effect.catch(() => Effect.succeed(undefined)),
- Effect.map((apiEndpoint) =>
- Credential.OAuth.make({
- type: "oauth",
- methodID,
- refresh: access,
- access,
- expires: 0,
- ...((enterprise || apiEndpoint) && {
- metadata: {
- ...(enterprise ? { enterpriseUrl: domain } : {}),
- ...(apiEndpoint ? { apiEndpoint } : {}),
- },
+ ).pipe(
+ Effect.map((user) => Option.getOrUndefined(decodeUser(user))?.endpoints?.api?.replace(/\/+$/, "")),
+ Effect.catch(() => Effect.succeed(undefined)),
+ Effect.map((apiEndpoint) =>
+ Credential.OAuth.make({
+ type: "oauth",
+ methodID,
+ refresh: access,
+ access,
+ expires: 0,
+ ...((enterprise || apiEndpoint) && {
+ metadata: {
+ ...(enterprise ? { enterpriseUrl: domain } : {}),
+ ...(apiEndpoint ? { apiEndpoint } : {}),
+ },
+ }),
}),
- }),
- ),
- )
- }
- if (token.error === "authorization_pending")
- return Effect.sleep(wait + pollingSafetyMargin).pipe(Effect.andThen(poll(wait)))
- if (token.error === "slow_down") {
- const next = token.interval && token.interval > 0 ? token.interval * 1000 : wait + 5000
- return Effect.sleep(next + pollingSafetyMargin).pipe(Effect.andThen(poll(next)))
- }
- return Effect.fail(new Error(`Device authorization failed${token.error ? `: ${token.error}` : ""}`))
- }),
- )
+ ),
+ )
+ }
+ if (token.error === "authorization_pending")
+ return Effect.sleep(wait + pollingSafetyMargin).pipe(Effect.andThen(poll(wait)))
+ if (token.error === "slow_down") {
+ const next = token.interval && token.interval > 0 ? token.interval * 1000 : wait + 5000
+ return Effect.sleep(next + pollingSafetyMargin).pipe(Effect.andThen(poll(next)))
+ }
+ return Effect.fail(new Error(`Device authorization failed${token.error ? `: ${token.error}` : ""}`))
+ }),
+ )
- return {
- mode: "auto" as const,
- url: device.verification_uri,
- instructions: `Enter code: ${device.user_code}`,
- callback: poll(interval),
- }
- }),
-}) satisfies IntegrationOAuthMethodRegistration
+ return {
+ mode: "auto" as const,
+ url: device.verification_uri,
+ instructions: `Enter code: ${device.user_code}`,
+ callback: poll(interval),
+ }
+ }),
+ }) satisfies IntegrationOAuthMethodRegistration
export const GithubCopilotPlugin = define({
id: "opencode.provider.github-copilot",
diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts
index 7f168f58c721..6fdd31270875 100644
--- a/packages/core/src/plugin/provider/openai.ts
+++ b/packages/core/src/plugin/provider/openai.ts
@@ -44,122 +44,126 @@ const Claims = Schema.fromJsonString(
)
const decodeClaims = Schema.decodeUnknownOption(Claims)
-const browser = (app: App.Info) => ({
- integrationID: Integration.ID.make("openai"),
- method: {
- id: browserMethodID,
- type: "oauth",
- label: "ChatGPT Pro/Plus (browser)",
- },
- authorize: () =>
- Effect.gen(function* () {
- const pkce = yield* Effect.promise(generatePKCE)
- const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
- const code = yield* Deferred.make()
- const redirect = `http://localhost:${callbackPort}/auth/callback`
- const server = createServer((request, response) => {
- const url = new URL(request.url ?? "/", `http://localhost:${callbackPort}`)
- if (url.pathname !== "/auth/callback") {
- response.writeHead(404).end("Not found")
- return
- }
- const error = url.searchParams.get("error_description") ?? url.searchParams.get("error")
- const value = url.searchParams.get("code")
- if (error) {
- Effect.runFork(Deferred.fail(code, new Error(error)))
- response
- .writeHead(400, { "Content-Type": "text/html" })
- .end(OauthCallbackPage.error(error, { provider: "ChatGPT" }))
- return
- }
- if (!value || url.searchParams.get("state") !== state) {
- const message = value ? "Invalid OAuth state" : "Missing authorization code"
- Effect.runFork(Deferred.fail(code, new Error(message)))
+const browser = (app: App.Info) =>
+ ({
+ integrationID: Integration.ID.make("openai"),
+ method: {
+ id: browserMethodID,
+ type: "oauth",
+ label: "ChatGPT Pro/Plus (browser)",
+ },
+ authorize: () =>
+ Effect.gen(function* () {
+ const pkce = yield* Effect.promise(generatePKCE)
+ const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
+ const code = yield* Deferred.make()
+ const redirect = `http://localhost:${callbackPort}/auth/callback`
+ const server = createServer((request, response) => {
+ const url = new URL(request.url ?? "/", `http://localhost:${callbackPort}`)
+ if (url.pathname !== "/auth/callback") {
+ response.writeHead(404).end("Not found")
+ return
+ }
+ const error = url.searchParams.get("error_description") ?? url.searchParams.get("error")
+ const value = url.searchParams.get("code")
+ if (error) {
+ Effect.runFork(Deferred.fail(code, new Error(error)))
+ response
+ .writeHead(400, { "Content-Type": "text/html" })
+ .end(OauthCallbackPage.error(error, { provider: "ChatGPT" }))
+ return
+ }
+ if (!value || url.searchParams.get("state") !== state) {
+ const message = value ? "Invalid OAuth state" : "Missing authorization code"
+ Effect.runFork(Deferred.fail(code, new Error(message)))
+ response
+ .writeHead(400, { "Content-Type": "text/html" })
+ .end(OauthCallbackPage.error(message, { provider: "ChatGPT" }))
+ return
+ }
+ Effect.runFork(Deferred.succeed(code, value))
response
- .writeHead(400, { "Content-Type": "text/html" })
- .end(OauthCallbackPage.error(message, { provider: "ChatGPT" }))
- return
+ .writeHead(200, { "Content-Type": "text/html" })
+ .end(OauthCallbackPage.success({ provider: "ChatGPT" }))
+ })
+ yield* Effect.callback((resume) => {
+ server.once("error", (error) => resume(Effect.fail(error)))
+ server.listen(callbackPort, "localhost", () => resume(Effect.void))
+ })
+ yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
+ return {
+ mode: "auto" as const,
+ url: authorizeURL(redirect, pkce, state),
+ instructions: "Complete authorization in your browser. This window will close automatically.",
+ callback: Deferred.await(code).pipe(
+ Effect.flatMap((value) => exchange(value, redirect, pkce, app)),
+ Effect.map((tokens) => credential(browserMethodID, tokens)),
+ ),
}
- Effect.runFork(Deferred.succeed(code, value))
- response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: "ChatGPT" }))
- })
- yield* Effect.callback((resume) => {
- server.once("error", (error) => resume(Effect.fail(error)))
- server.listen(callbackPort, "localhost", () => resume(Effect.void))
- })
- yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
- return {
- mode: "auto" as const,
- url: authorizeURL(redirect, pkce, state),
- instructions: "Complete authorization in your browser. This window will close automatically.",
- callback: Deferred.await(code).pipe(
- Effect.flatMap((value) => exchange(value, redirect, pkce, app)),
- Effect.map((tokens) => credential(browserMethodID, tokens)),
- ),
- }
- }),
- refresh: (value) => refresh(browserMethodID, value, app),
-}) satisfies IntegrationOAuthMethodRegistration
+ }),
+ refresh: (value) => refresh(browserMethodID, value, app),
+ }) satisfies IntegrationOAuthMethodRegistration
-const headless = (app: App.Info) => ({
- integrationID: Integration.ID.make("openai"),
- method: {
- id: headlessMethodID,
- type: "oauth",
- label: "ChatGPT Pro/Plus (headless)",
- },
- authorize: () =>
- Effect.gen(function* () {
- const device = yield* request<{ device_auth_id: string; user_code: string; interval: string }>(
- `${issuer}/api/accounts/deviceauth/usercode`,
- {
- method: "POST",
- headers: headers("application/json", app),
- body: JSON.stringify({ client_id: clientID }),
- },
- )
- const interval = Math.max(Number.parseInt(device.interval) || 5, 1) * 1000
- return {
- mode: "auto" as const,
- url: `${issuer}/codex/device`,
- instructions: `Enter code: ${device.user_code}`,
- callback: Effect.gen(function* () {
- while (true) {
- const response = yield* Effect.tryPromise({
- try: (signal) =>
- fetch(`${issuer}/api/accounts/deviceauth/token`, {
- method: "POST",
- headers: headers("application/json", app),
- body: JSON.stringify({ device_auth_id: device.device_auth_id, user_code: device.user_code }),
- signal,
- }),
- catch: (cause) => cause,
- })
- if (response.ok) {
- const data = (yield* Effect.promise(() => response.json())) as {
- authorization_code: string
- code_verifier: string
+const headless = (app: App.Info) =>
+ ({
+ integrationID: Integration.ID.make("openai"),
+ method: {
+ id: headlessMethodID,
+ type: "oauth",
+ label: "ChatGPT Pro/Plus (headless)",
+ },
+ authorize: () =>
+ Effect.gen(function* () {
+ const device = yield* request<{ device_auth_id: string; user_code: string; interval: string }>(
+ `${issuer}/api/accounts/deviceauth/usercode`,
+ {
+ method: "POST",
+ headers: headers("application/json", app),
+ body: JSON.stringify({ client_id: clientID }),
+ },
+ )
+ const interval = Math.max(Number.parseInt(device.interval) || 5, 1) * 1000
+ return {
+ mode: "auto" as const,
+ url: `${issuer}/codex/device`,
+ instructions: `Enter code: ${device.user_code}`,
+ callback: Effect.gen(function* () {
+ while (true) {
+ const response = yield* Effect.tryPromise({
+ try: (signal) =>
+ fetch(`${issuer}/api/accounts/deviceauth/token`, {
+ method: "POST",
+ headers: headers("application/json", app),
+ body: JSON.stringify({ device_auth_id: device.device_auth_id, user_code: device.user_code }),
+ signal,
+ }),
+ catch: (cause) => cause,
+ })
+ if (response.ok) {
+ const data = (yield* Effect.promise(() => response.json())) as {
+ authorization_code: string
+ code_verifier: string
+ }
+ return credential(
+ headlessMethodID,
+ yield* exchange(
+ data.authorization_code,
+ `${issuer}/deviceauth/callback`,
+ { verifier: data.code_verifier, challenge: "" },
+ app,
+ ),
+ )
}
- return credential(
- headlessMethodID,
- yield* exchange(
- data.authorization_code,
- `${issuer}/deviceauth/callback`,
- { verifier: data.code_verifier, challenge: "" },
- app,
- ),
- )
- }
- if (response.status !== 403 && response.status !== 404) {
- return yield* Effect.fail(new Error(`Device authorization failed: ${response.status}`))
+ if (response.status !== 403 && response.status !== 404) {
+ return yield* Effect.fail(new Error(`Device authorization failed: ${response.status}`))
+ }
+ yield* Effect.sleep(interval + pollingSafetyMargin)
}
- yield* Effect.sleep(interval + pollingSafetyMargin)
- }
- }),
- }
- }),
- refresh: (value) => refresh(headlessMethodID, value, app),
-}) satisfies IntegrationOAuthMethodRegistration
+ }),
+ }
+ }),
+ refresh: (value) => refresh(headlessMethodID, value, app),
+ }) satisfies IntegrationOAuthMethodRegistration
export const OpenAIPlugin = define({
id: "opencode.provider.openai",
diff --git a/packages/core/src/plugin/provider/xai.ts b/packages/core/src/plugin/provider/xai.ts
index af5b2202f640..a9b5393da2a7 100644
--- a/packages/core/src/plugin/provider/xai.ts
+++ b/packages/core/src/plugin/provider/xai.ts
@@ -35,40 +35,41 @@ const DeviceError = Schema.Struct({
})
const decodeDeviceError = Schema.decodeUnknownOption(Schema.fromJsonString(DeviceError))
-const device = (app: App.Info) => ({
- integrationID: Integration.ID.make("xai"),
- method: {
- id: deviceMethodID,
- type: "oauth",
- label: "SuperGrok Subscription",
- },
- authorize: () =>
- request(
- `${issuer}/device/code`,
- {
- method: "POST",
- headers: headers(app),
- body: new URLSearchParams({ client_id: clientID, scope, referrer: "opencode" }).toString(),
- },
- Device,
- ).pipe(
- Effect.flatMap((value) =>
- Clock.currentTimeMillis.pipe(
- Effect.map((created) => {
- const lifetime = positiveSeconds(value.expires_in, 0)
- return {
- mode: "auto" as const,
- url: value.verification_uri_complete ?? value.verification_uri,
- instructions: `Open ${value.verification_uri} on any device and enter code: ${value.user_code}`,
- ...(lifetime ? { expiresAt: created + lifetime * 1000 } : {}),
- callback: poll(value, app).pipe(Effect.flatMap((tokens) => credential(deviceMethodID, tokens))),
- }
- }),
+const device = (app: App.Info) =>
+ ({
+ integrationID: Integration.ID.make("xai"),
+ method: {
+ id: deviceMethodID,
+ type: "oauth",
+ label: "SuperGrok Subscription",
+ },
+ authorize: () =>
+ request(
+ `${issuer}/device/code`,
+ {
+ method: "POST",
+ headers: headers(app),
+ body: new URLSearchParams({ client_id: clientID, scope, referrer: "opencode" }).toString(),
+ },
+ Device,
+ ).pipe(
+ Effect.flatMap((value) =>
+ Clock.currentTimeMillis.pipe(
+ Effect.map((created) => {
+ const lifetime = positiveSeconds(value.expires_in, 0)
+ return {
+ mode: "auto" as const,
+ url: value.verification_uri_complete ?? value.verification_uri,
+ instructions: `Open ${value.verification_uri} on any device and enter code: ${value.user_code}`,
+ ...(lifetime ? { expiresAt: created + lifetime * 1000 } : {}),
+ callback: poll(value, app).pipe(Effect.flatMap((tokens) => credential(deviceMethodID, tokens))),
+ }
+ }),
+ ),
),
),
- ),
- refresh: (value) => refresh(deviceMethodID, Credential.OAuth.make({ ...value, methodID: deviceMethodID }), app),
-}) satisfies IntegrationOAuthMethodRegistration
+ refresh: (value) => refresh(deviceMethodID, Credential.OAuth.make({ ...value, methodID: deviceMethodID }), app),
+ }) satisfies IntegrationOAuthMethodRegistration
export const XAIPlugin = define({
id: "opencode.provider.xai",
diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts
index cccbb5b5af74..fece057b455b 100644
--- a/packages/core/src/project.ts
+++ b/packages/core/src/project.ts
@@ -45,12 +45,9 @@ export interface Resolved {
}
// Keep this filesystem-only; permission checks use it and should not execute VCS commands.
-export const root = Effect.fn("Project.root")(function* (
- fs: FSUtil.Interface,
- input: AbsolutePath,
-) {
+export const root = Effect.fn("Project.root")(function* (fs: FSUtil.Interface, input: AbsolutePath) {
return yield* fs.up({ targets: [".git", ".hg"], start: input, mode: "first" }).pipe(
- Effect.map((matches) => matches[0] ? AbsolutePath.make(path.dirname(matches[0])) : undefined),
+ Effect.map((matches) => (matches[0] ? AbsolutePath.make(path.dirname(matches[0])) : undefined)),
Effect.catch(() => Effect.succeed(undefined)),
)
})
diff --git a/packages/core/src/pty.ts b/packages/core/src/pty.ts
index ad00debe8c05..ffd9fdbaeca5 100644
--- a/packages/core/src/pty.ts
+++ b/packages/core/src/pty.ts
@@ -89,229 +89,230 @@ export interface Interface {
export class Service extends Context.Service()("@opencode/Pty") {}
-export const layer = (options?: ShellSelect.Options) => Layer.effect(
- Service,
- Effect.gen(function* () {
- const bus = yield* Bus.Service
- const location = yield* Location.Service
- const config = yield* Config.Service
- const context = yield* Effect.context()
- const runFork = Effect.runForkWith(context)
- const sessions = new Map()
- const exitOrder: PtyID[] = []
-
- function notifyEnd(session: Active, event: { exitCode?: number }) {
- for (const subscriber of session.subscribers.values()) {
- if (!subscriber.active) {
- subscriber.end = event
- continue
+export const layer = (options?: ShellSelect.Options) =>
+ Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const bus = yield* Bus.Service
+ const location = yield* Location.Service
+ const config = yield* Config.Service
+ const context = yield* Effect.context()
+ const runFork = Effect.runForkWith(context)
+ const sessions = new Map()
+ const exitOrder: PtyID[] = []
+
+ function notifyEnd(session: Active, event: { exitCode?: number }) {
+ for (const subscriber of session.subscribers.values()) {
+ if (!subscriber.active) {
+ subscriber.end = event
+ continue
+ }
+ try {
+ subscriber.onEnd(event)
+ } catch {}
}
- try {
- subscriber.onEnd(event)
- } catch {}
- }
- session.subscribers.clear()
- }
-
- function teardown(session: Active) {
- for (const listener of session.listeners) listener.dispose()
- session.listeners.length = 0
- if (session.info.status === "running") {
- try {
- session.process.kill()
- } catch {}
- }
- notifyEnd(session, {})
- }
-
- yield* Effect.addFinalizer(() =>
- Effect.sync(() => {
- for (const session of sessions.values()) teardown(session)
- sessions.clear()
- exitOrder.length = 0
- }),
- )
-
- const requireSession = Effect.fn("Pty.requireSession")(function* (id: PtyID) {
- const session = sessions.get(id)
- if (!session) return yield* new NotFoundError({ ptyID: id })
- return session
- })
-
- const removeSession = Effect.fnUntraced(function* (id: PtyID) {
- const session = sessions.get(id)
- if (!session) return
- sessions.delete(id)
- const index = exitOrder.indexOf(id)
- if (index !== -1) exitOrder.splice(index, 1)
- yield* Effect.logInfo("removing session", { id })
- teardown(session)
- yield* bus.publish(Pty.Event.Deleted, { id: session.info.id })
- })
-
- const remove = Effect.fn("Pty.remove")(function* (id: PtyID) {
- yield* requireSession(id)
- yield* removeSession(id)
- })
-
- const list = Effect.fn("Pty.list")(function* () {
- return Array.from(sessions.values()).map((session) => session.info)
- })
-
- const get = Effect.fn("Pty.get")(function* (id: PtyID) {
- return (yield* requireSession(id)).info
- })
-
- const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
- const id = PtyID.ascending()
- const command = input.command || ShellSelect.preferred(Config.latest(yield* config.entries(), "shell"), options)
- const args = ShellSelect.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])]
- const cwd = input.cwd || location.directory
- const env = {
- ...process.env,
- ...input.env,
- TERM: "xterm-256color",
- OPENCODE_TERMINAL: "1",
- } as Record
- if (process.platform === "win32") {
- env.LC_ALL = "C.UTF-8"
- env.LC_CTYPE = "C.UTF-8"
- env.LANG = "C.UTF-8"
+ session.subscribers.clear()
}
- yield* Effect.logInfo("creating session", { id, cmd: command, args, cwd })
- const { spawn } = yield* Effect.promise(() => pty())
- const proc = yield* Effect.sync(() => spawn(command, args, { name: "xterm-256color", cwd, env }))
- const info: Info = {
- id,
- title: input.title || `Terminal ${id.slice(-4)}`,
- command,
- args,
- cwd,
- status: "running",
- pid: proc.pid,
- }
- const session: Active = {
- info,
- process: proc,
- buffer: "",
- bufferCursor: 0,
- cursor: 0,
- subscribers: new Map(),
- listeners: [],
+
+ function teardown(session: Active) {
+ for (const listener of session.listeners) listener.dispose()
+ session.listeners.length = 0
+ if (session.info.status === "running") {
+ try {
+ session.process.kill()
+ } catch {}
+ }
+ notifyEnd(session, {})
}
- sessions.set(id, session)
- session.listeners.push(
- proc.onData((chunk) => {
- session.cursor += chunk.length
- for (const [token, subscriber] of session.subscribers.entries()) {
- if (!subscriber.active) {
- subscriber.pending.push(chunk)
- continue
+
+ yield* Effect.addFinalizer(() =>
+ Effect.sync(() => {
+ for (const session of sessions.values()) teardown(session)
+ sessions.clear()
+ exitOrder.length = 0
+ }),
+ )
+
+ const requireSession = Effect.fn("Pty.requireSession")(function* (id: PtyID) {
+ const session = sessions.get(id)
+ if (!session) return yield* new NotFoundError({ ptyID: id })
+ return session
+ })
+
+ const removeSession = Effect.fnUntraced(function* (id: PtyID) {
+ const session = sessions.get(id)
+ if (!session) return
+ sessions.delete(id)
+ const index = exitOrder.indexOf(id)
+ if (index !== -1) exitOrder.splice(index, 1)
+ yield* Effect.logInfo("removing session", { id })
+ teardown(session)
+ yield* bus.publish(Pty.Event.Deleted, { id: session.info.id })
+ })
+
+ const remove = Effect.fn("Pty.remove")(function* (id: PtyID) {
+ yield* requireSession(id)
+ yield* removeSession(id)
+ })
+
+ const list = Effect.fn("Pty.list")(function* () {
+ return Array.from(sessions.values()).map((session) => session.info)
+ })
+
+ const get = Effect.fn("Pty.get")(function* (id: PtyID) {
+ return (yield* requireSession(id)).info
+ })
+
+ const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
+ const id = PtyID.ascending()
+ const command = input.command || ShellSelect.preferred(Config.latest(yield* config.entries(), "shell"), options)
+ const args = ShellSelect.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])]
+ const cwd = input.cwd || location.directory
+ const env = {
+ ...process.env,
+ ...input.env,
+ TERM: "xterm-256color",
+ OPENCODE_TERMINAL: "1",
+ } as Record
+ if (process.platform === "win32") {
+ env.LC_ALL = "C.UTF-8"
+ env.LC_CTYPE = "C.UTF-8"
+ env.LANG = "C.UTF-8"
+ }
+ yield* Effect.logInfo("creating session", { id, cmd: command, args, cwd })
+ const { spawn } = yield* Effect.promise(() => pty())
+ const proc = yield* Effect.sync(() => spawn(command, args, { name: "xterm-256color", cwd, env }))
+ const info: Info = {
+ id,
+ title: input.title || `Terminal ${id.slice(-4)}`,
+ command,
+ args,
+ cwd,
+ status: "running",
+ pid: proc.pid,
+ }
+ const session: Active = {
+ info,
+ process: proc,
+ buffer: "",
+ bufferCursor: 0,
+ cursor: 0,
+ subscribers: new Map(),
+ listeners: [],
+ }
+ sessions.set(id, session)
+ session.listeners.push(
+ proc.onData((chunk) => {
+ session.cursor += chunk.length
+ for (const [token, subscriber] of session.subscribers.entries()) {
+ if (!subscriber.active) {
+ subscriber.pending.push(chunk)
+ continue
+ }
+ try {
+ subscriber.onData(chunk)
+ } catch {
+ session.subscribers.delete(token)
+ }
}
+ session.buffer += chunk
+ if (session.buffer.length <= BUFFER_LIMIT) return
+ const excess = session.buffer.length - BUFFER_LIMIT
+ session.buffer = session.buffer.slice(excess)
+ session.bufferCursor += excess
+ }),
+ proc.onExit(({ exitCode }) => {
+ if (session.info.status === "exited") return
+ session.info.status = "exited"
+ session.info.exitCode = exitCode
+ notifyEnd(session, { exitCode })
+ exitOrder.push(id)
+ runFork(
+ Effect.gen(function* () {
+ yield* Effect.logInfo("session exited", { id, exitCode })
+ yield* bus.publish(Pty.Event.Exited, { id, exitCode })
+ while (exitOrder.length > EXITED_LIMIT) {
+ const oldest = exitOrder[0]
+ if (!oldest) break
+ yield* removeSession(oldest)
+ }
+ }),
+ )
+ }),
+ )
+ yield* bus.publish(Pty.Event.Created, { info })
+ return info
+ })
+
+ const update = Effect.fn("Pty.update")(function* (id: PtyID, input: UpdateInput) {
+ const session = yield* requireSession(id)
+ if (input.title) session.info.title = input.title
+ if (input.size && session.info.status === "running") session.process.resize(input.size.cols, input.size.rows)
+ yield* bus.publish(Pty.Event.Updated, { info: session.info })
+ return session.info
+ })
+
+ const write = Effect.fn("Pty.write")(function* (id: PtyID, data: string) {
+ const session = yield* requireSession(id)
+ if (session.info.status === "running") session.process.write(data)
+ })
+
+ const attach = Effect.fn("Pty.attach")(function* (id: PtyID, input: AttachInput) {
+ const session = yield* requireSession(id)
+ if (session.info.status !== "running") return yield* new ExitedError({ ptyID: id })
+ yield* Effect.logInfo("client attached to session", { id, directory: location.directory })
+ const token = {}
+ const subscriber: Subscriber = {
+ onData: input.onData,
+ onEnd: input.onEnd,
+ active: false,
+ detached: false,
+ pending: [],
+ }
+ session.subscribers.set(token, subscriber)
+ const start = session.bufferCursor
+ const end = session.cursor
+ const from =
+ input.cursor === -1
+ ? end
+ : typeof input.cursor === "number" && Number.isSafeInteger(input.cursor)
+ ? Math.max(0, input.cursor)
+ : 0
+ const replay = (() => {
+ if (!session.buffer || from >= end) return ""
+ const offset = Math.max(0, from - start)
+ if (offset >= session.buffer.length) return ""
+ return session.buffer.slice(offset)
+ })()
+ return {
+ replay,
+ cursor: end,
+ write: (data: string) => {
+ if (session.info.status === "running") session.process.write(data)
+ },
+ activate: () => {
+ if (subscriber.active || subscriber.detached) return
+ subscriber.active = true
try {
- subscriber.onData(chunk)
+ for (const chunk of subscriber.pending) subscriber.onData(chunk)
+ subscriber.pending.length = 0
+ if (subscriber.end) subscriber.onEnd(subscriber.end)
} catch {
session.subscribers.delete(token)
}
- }
- session.buffer += chunk
- if (session.buffer.length <= BUFFER_LIMIT) return
- const excess = session.buffer.length - BUFFER_LIMIT
- session.buffer = session.buffer.slice(excess)
- session.bufferCursor += excess
- }),
- proc.onExit(({ exitCode }) => {
- if (session.info.status === "exited") return
- session.info.status = "exited"
- session.info.exitCode = exitCode
- notifyEnd(session, { exitCode })
- exitOrder.push(id)
- runFork(
- Effect.gen(function* () {
- yield* Effect.logInfo("session exited", { id, exitCode })
- yield* bus.publish(Pty.Event.Exited, { id, exitCode })
- while (exitOrder.length > EXITED_LIMIT) {
- const oldest = exitOrder[0]
- if (!oldest) break
- yield* removeSession(oldest)
- }
- }),
- )
- }),
- )
- yield* bus.publish(Pty.Event.Created, { info })
- return info
- })
-
- const update = Effect.fn("Pty.update")(function* (id: PtyID, input: UpdateInput) {
- const session = yield* requireSession(id)
- if (input.title) session.info.title = input.title
- if (input.size && session.info.status === "running") session.process.resize(input.size.cols, input.size.rows)
- yield* bus.publish(Pty.Event.Updated, { info: session.info })
- return session.info
- })
-
- const write = Effect.fn("Pty.write")(function* (id: PtyID, data: string) {
- const session = yield* requireSession(id)
- if (session.info.status === "running") session.process.write(data)
- })
-
- const attach = Effect.fn("Pty.attach")(function* (id: PtyID, input: AttachInput) {
- const session = yield* requireSession(id)
- if (session.info.status !== "running") return yield* new ExitedError({ ptyID: id })
- yield* Effect.logInfo("client attached to session", { id, directory: location.directory })
- const token = {}
- const subscriber: Subscriber = {
- onData: input.onData,
- onEnd: input.onEnd,
- active: false,
- detached: false,
- pending: [],
- }
- session.subscribers.set(token, subscriber)
- const start = session.bufferCursor
- const end = session.cursor
- const from =
- input.cursor === -1
- ? end
- : typeof input.cursor === "number" && Number.isSafeInteger(input.cursor)
- ? Math.max(0, input.cursor)
- : 0
- const replay = (() => {
- if (!session.buffer || from >= end) return ""
- const offset = Math.max(0, from - start)
- if (offset >= session.buffer.length) return ""
- return session.buffer.slice(offset)
- })()
- return {
- replay,
- cursor: end,
- write: (data: string) => {
- if (session.info.status === "running") session.process.write(data)
- },
- activate: () => {
- if (subscriber.active || subscriber.detached) return
- subscriber.active = true
- try {
- for (const chunk of subscriber.pending) subscriber.onData(chunk)
+ },
+ detach: () => {
+ subscriber.detached = true
subscriber.pending.length = 0
- if (subscriber.end) subscriber.onEnd(subscriber.end)
- } catch {
+ subscriber.end = undefined
session.subscribers.delete(token)
- }
- },
- detach: () => {
- subscriber.detached = true
- subscriber.pending.length = 0
- subscriber.end = undefined
- session.subscribers.delete(token)
- },
- }
- })
+ },
+ }
+ })
- return Service.of({ list, get, create, update, remove, write, attach })
- }),
-)
+ return Service.of({ list, get, create, update, remove, write, attach })
+ }),
+ )
export function configured(options?: ShellSelect.Options) {
return makeLocationNode({ service: Service, layer: layer(options), deps: [Bus.node, Location.node, Config.node] })
diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts
index 2fcde1c67715..6547ca97730e 100644
--- a/packages/core/src/session.ts
+++ b/packages/core/src/session.ts
@@ -345,9 +345,7 @@ const layer = Layer.effect(
Effect.gen(function* () {
yield* mutation(bus, { sessionID: input.sessionID, id: input.inputID }).pipe(
Effect.catchDefect((defect) =>
- defect instanceof SessionPending.LifecycleConflict
- ? pendingConflict(input)
- : Effect.die(defect),
+ defect instanceof SessionPending.LifecycleConflict ? pendingConflict(input) : Effect.die(defect),
),
)
if (wake) yield* execution.wake(input.sessionID)
diff --git a/packages/core/src/session/instruction-state.ts b/packages/core/src/session/instruction-state.ts
index 9415899892ca..0db2ed4d6832 100644
--- a/packages/core/src/session/instruction-state.ts
+++ b/packages/core/src/session/instruction-state.ts
@@ -66,7 +66,10 @@ const renderUpdateText = Effect.fnUntraced(function* (
observation: Observation,
) {
const replaced = Object.entries(observation.previous).filter(([key]) => Object.hasOwn(observation.delta, key))
- const blobs = yield* loadBlobs(db, replaced.map(([, hash]) => hash))
+ const blobs = yield* loadBlobs(
+ db,
+ replaced.map(([, hash]) => hash),
+ )
const previous = Object.fromEntries(replaced.map(([key, hash]) => [key, requireBlob(blobs, hash)]))
const admitted = new Map(
Object.entries(observation.blobs).map(([hash, value]) => [Instructions.Hash.make(hash), value]),
@@ -193,10 +196,7 @@ export const preview = Effect.fn("InstructionState.preview")(function* (
const values = dereference(result.current, observedBlobs)
return { initial: Instructions.renderInitial(instructions, values), update: "" }
}
- const stored = yield* loadBlobs(db, [
- ...Object.values(state.initial_values),
- ...Object.values(state.current_values),
- ])
+ const stored = yield* loadBlobs(db, [...Object.values(state.initial_values), ...Object.values(state.current_values)])
return {
initial: Instructions.renderInitial(instructions, dereference(state.initial_values, stored)),
update: Instructions.renderUpdate(
diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts
index 11106f99f1b9..ed0e7ed86590 100644
--- a/packages/core/src/session/model-request.ts
+++ b/packages/core/src/session/model-request.ts
@@ -249,7 +249,9 @@ export const layer = Layer.effect(
model: resolved.ref,
request: before.request,
response: new Response(
- [204, 205, 304].includes(response.status) ? null : yield* Stream.toReadableStreamEffect(response.stream),
+ [204, 205, 304].includes(response.status)
+ ? null
+ : yield* Stream.toReadableStreamEffect(response.stream),
{ status: response.status, headers: response.headers },
),
})
@@ -274,8 +276,7 @@ export const layer = Layer.effect(
)
}
const executeTool: Prepared["executeTool"] = (input) => {
- if (stepLimitReached)
- return new Tool.Error({ message: "Tools are disabled after the maximum agent steps" })
+ if (stepLimitReached) return new Tool.Error({ message: "Tools are disabled after the maximum agent steps" })
const tool = hooked.get(input.call.name)
// A registered tool absent from the hooked set was removed or renamed by a hook.
if (!tool && registry.has(input.call.name))
diff --git a/packages/core/src/session/pending.ts b/packages/core/src/session/pending.ts
index d656564b5152..9e6dad94d148 100644
--- a/packages/core/src/session/pending.ts
+++ b/packages/core/src/session/pending.ts
@@ -349,14 +349,12 @@ const projectDelivery = Effect.fn("SessionPending.projectDelivery")(function* (
if (!updated) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
})
-export const projectSteered = Effect.fn("SessionPending.projectSteered")(
- (db: DatabaseService, input: PendingRef) =>
- projectDelivery(db, { ...input, from: "queue", to: "steer" }),
+export const projectSteered = Effect.fn("SessionPending.projectSteered")((db: DatabaseService, input: PendingRef) =>
+ projectDelivery(db, { ...input, from: "queue", to: "steer" }),
)
-export const projectQueued = Effect.fn("SessionPending.projectQueued")(
- (db: DatabaseService, input: PendingRef) =>
- projectDelivery(db, { ...input, from: "steer", to: "queue" }),
+export const projectQueued = Effect.fn("SessionPending.projectQueued")((db: DatabaseService, input: PendingRef) =>
+ projectDelivery(db, { ...input, from: "steer", to: "queue" }),
)
export const settleCompaction = Effect.fn("SessionPending.settleCompaction")(function* (
diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts
index 2fbd713131c0..581224cbb4f7 100644
--- a/packages/core/src/session/runner/llm.ts
+++ b/packages/core/src/session/runner/llm.ts
@@ -38,7 +38,11 @@ import { ToolOutput } from "../../tool-output"
type CallOutcome = Data.TaggedEnum<{
Completed: { readonly needsContinuation: boolean; readonly step: number }
Retry: { readonly step: number }
- Continue: { readonly cause: AIError; readonly error: SessionRunnerRetry.RetryableFailure["error"]; readonly step: number }
+ Continue: {
+ readonly cause: AIError
+ readonly error: SessionRunnerRetry.RetryableFailure["error"]
+ readonly step: number
+ }
Restart: { readonly step: number; readonly recoveredOverflow: boolean }
}>
const CallOutcome = Data.taggedEnum()
diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts
index cafbefd67a5a..3607a4772053 100644
--- a/packages/core/src/session/runner/publish-llm-event.ts
+++ b/packages/core/src/session/runner/publish-llm-event.ts
@@ -510,8 +510,7 @@ export const createLLMEventPublisher = (bus: Pick, inp
const progress = Effect.fnUntraced(function* (id: string, update: Tool.Metadata) {
const tool = tools.get(id)
- if (!tool?.called || tool.settled)
- return yield* Effect.die(new Error(`Tool progress outside running call: ${id}`))
+ if (!tool?.called || tool.settled) return yield* Effect.die(new Error(`Tool progress outside running call: ${id}`))
tool.progress = update
yield* bus.publish(SessionEvent.Tool.Progress, {
sessionID: input.sessionID,
@@ -522,11 +521,7 @@ export const createLLMEventPublisher = (bus: Pick, inp
})
/** Publishes one canonical terminal event for a locally executed tool call. */
- const toolExecution = Effect.fnUntraced(function* (
- id: string,
- name: string,
- result: Tool.Result,
- ) {
+ const toolExecution = Effect.fnUntraced(function* (id: string, name: string, result: Tool.Result) {
const tool = tools.get(id)
if (!tool?.called) return yield* Effect.die(new Error(`Tool execution before call: ${id}`))
if (tool.name !== name)
diff --git a/packages/core/src/session/transfer.ts b/packages/core/src/session/transfer.ts
index eb3969a5a7e5..8b250bb57c4a 100644
--- a/packages/core/src/session/transfer.ts
+++ b/packages/core/src/session/transfer.ts
@@ -33,10 +33,7 @@ export interface Interface {
sessionID: Session.ID
sanitize?: boolean
}) => Effect.Effect
- readonly import: (input: {
- data: Data
- location: Location.Ref
- }) => Effect.Effect
+ readonly import: (input: { data: Data; location: Location.Ref }) => Effect.Effect
}
export class Service extends Context.Service()("@opencode/SessionTransfer") {}
@@ -108,7 +105,9 @@ const layer = Layer.effect(
version: app.version,
projectID: project.id,
location: input.location,
- subpath: RelativePath.make(path.relative(project.directory, input.location.directory).replaceAll("\\", "/")),
+ subpath: RelativePath.make(
+ path.relative(project.directory, input.location.directory).replaceAll("\\", "/"),
+ ),
title: input.data.info.title,
agent: input.data.info.agent,
model: input.data.info.model,
@@ -230,8 +229,7 @@ function sanitizeMessage(message: SessionMessage.Info): SessionMessage.Info {
? undefined
: redact("synthetic-description", message.id, message.description),
}
- if (message.type === "system")
- return { ...message, metadata: meta, text: redact("system", message.id, message.text) }
+ if (message.type === "system") return { ...message, metadata: meta, text: redact("system", message.id, message.text) }
if (message.type === "skill") return { ...message, metadata: meta, text: redact("skill", message.id, message.text) }
if (message.type === "shell")
return {
diff --git a/packages/core/src/shell/parser-wasm.node.ts b/packages/core/src/shell/parser-wasm.node.ts
index 9b2527c9cc84..d8a9c5d6efc9 100644
--- a/packages/core/src/shell/parser-wasm.node.ts
+++ b/packages/core/src/shell/parser-wasm.node.ts
@@ -4,8 +4,7 @@ const require = createRequire(import.meta.url)
export const shellParserWasm = {
runtime: process.env.OPENCODE_TREE_SITTER_WASM_PATH ?? require.resolve("web-tree-sitter/tree-sitter.wasm"),
- bash:
- process.env.OPENCODE_TREE_SITTER_BASH_WASM_PATH ?? require.resolve("tree-sitter-bash/tree-sitter-bash.wasm"),
+ bash: process.env.OPENCODE_TREE_SITTER_BASH_WASM_PATH ?? require.resolve("tree-sitter-bash/tree-sitter-bash.wasm"),
powershell:
process.env.OPENCODE_TREE_SITTER_POWERSHELL_WASM_PATH ??
require.resolve("tree-sitter-powershell/tree-sitter-powershell.wasm"),
diff --git a/packages/core/src/snapshot.ts b/packages/core/src/snapshot.ts
index 29ebffc589ec..ee6170735632 100644
--- a/packages/core/src/snapshot.ts
+++ b/packages/core/src/snapshot.ts
@@ -59,7 +59,7 @@ export interface Interface {
/**
* Restore selected project-relative paths from their associated trees. A path
* absent from its selected tree is removed; paths outside the map are untouched.
- */
+ */
readonly restore: (input: RestoreInput) => Effect.Effect
}
diff --git a/packages/core/src/tool-output.ts b/packages/core/src/tool-output.ts
index fb63a783c331..2090a1d73ba5 100644
--- a/packages/core/src/tool-output.ts
+++ b/packages/core/src/tool-output.ts
@@ -24,9 +24,7 @@ export interface Interface {
export class Service extends Context.Service()("@opencode/ToolOutput") {}
const cleanup = Effect.fn("ToolOutput.cleanup")(function* (fs: FSUtil.Interface, directory: string) {
- const cutoff = Identifier.timestamp(
- Identifier.create("tool", "ascending", Date.now() - Duration.toMillis(RETENTION)),
- )
+ const cutoff = Identifier.timestamp(Identifier.create("tool", "ascending", Date.now() - Duration.toMillis(RETENTION)))
const entries = yield* fs.readDirectory(directory).pipe(
Effect.map((entries) => entries.filter((entry) => /^tool_[0-9a-f]{12}/.test(entry))),
Effect.catch(() => Effect.succeed([])),
@@ -122,7 +120,11 @@ const cleanupLayer = Layer.effectDiscard(
}),
)
-const cleanupNode = makeGlobalNode({ name: "tool-output-cleanup", layer: cleanupLayer, deps: [FSUtil.node, Global.node] })
+const cleanupNode = makeGlobalNode({
+ name: "tool-output-cleanup",
+ layer: cleanupLayer,
+ deps: [FSUtil.node, Global.node],
+})
export const node = makeLocationNode({
service: Service,
diff --git a/packages/core/src/tool/mcp.ts b/packages/core/src/tool/mcp.ts
index 8023b5d85b43..51fb8c1e3c8b 100644
--- a/packages/core/src/tool/mcp.ts
+++ b/packages/core/src/tool/mcp.ts
@@ -49,54 +49,54 @@ export const layer = Layer.effectDiscard(
output: (tool.outputSchema ?? {}) as JsonSchema.JsonSchema,
execute: (input, context) =>
Effect.gen(function* () {
- yield* permission.assert({
- action: name(tool.server, tool.name),
- resources: ["*"],
- save: ["*"],
- metadata: {},
- sessionID: context.sessionID,
- agent: context.agent,
- source: {
- type: "tool",
- messageID: context.messageID,
- id: context.id,
- },
- })
- const result = yield* mcp
- .callTool({
- server: tool.server,
- name: tool.name,
- args: (input ?? {}) as Record,
- })
- .pipe(
- Effect.catchTags({
- "MCP.NotFoundError": (error) =>
- new ToolFailure({ message: `MCP server "${error.server}" is not available` }),
- "MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }),
- }),
- )
- if (result.isError)
- return yield* new ToolFailure({
- message:
- result.content
- .flatMap((part) => (part.type === "text" ? [part.text] : []))
- .join("\n")
- .trim() || "MCP tool returned an error",
- })
- const content = result.content.map((part) =>
- part.type === "text"
- ? { type: "text" as const, text: part.text }
- : {
- type: "file" as const,
- uri: `data:${part.mimeType};base64,${part.data}`,
- mime: part.mimeType,
+ yield* permission.assert({
+ action: name(tool.server, tool.name),
+ resources: ["*"],
+ save: ["*"],
+ metadata: {},
+ sessionID: context.sessionID,
+ agent: context.agent,
+ source: {
+ type: "tool",
+ messageID: context.messageID,
+ id: context.id,
},
- )
- const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
- return {
- output: result.structured ?? (text === "" ? null : text),
- ...(content.length === 0 ? {} : { content }),
- }
+ })
+ const result = yield* mcp
+ .callTool({
+ server: tool.server,
+ name: tool.name,
+ args: (input ?? {}) as Record,
+ })
+ .pipe(
+ Effect.catchTags({
+ "MCP.NotFoundError": (error) =>
+ new ToolFailure({ message: `MCP server "${error.server}" is not available` }),
+ "MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }),
+ }),
+ )
+ if (result.isError)
+ return yield* new ToolFailure({
+ message:
+ result.content
+ .flatMap((part) => (part.type === "text" ? [part.text] : []))
+ .join("\n")
+ .trim() || "MCP tool returned an error",
+ })
+ const content = result.content.map((part) =>
+ part.type === "text"
+ ? { type: "text" as const, text: part.text }
+ : {
+ type: "file" as const,
+ uri: `data:${part.mimeType};base64,${part.data}`,
+ mime: part.mimeType,
+ },
+ )
+ const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
+ return {
+ output: result.structured ?? (text === "" ? null : text),
+ ...(content.length === 0 ? {} : { content }),
+ }
}).pipe(
Effect.mapError((error) =>
error instanceof ToolFailure
diff --git a/packages/core/src/tool/plugin/question.ts b/packages/core/src/tool/plugin/question.ts
index 2a054bf257cb..1ab11db94bc6 100644
--- a/packages/core/src/tool/plugin/question.ts
+++ b/packages/core/src/tool/plugin/question.ts
@@ -35,10 +35,7 @@ export class CancelledError extends Schema.TaggedErrorClass()("Q
}
}
-export const toModelOutput = (
- questions: ReadonlyArray,
- answers: ReadonlyArray,
-) => {
+export const toModelOutput = (questions: ReadonlyArray, answers: ReadonlyArray) => {
const formatted = questions
.map(
(question, index) =>
@@ -56,62 +53,60 @@ export const Plugin = {
yield* ctx.tool
.transform((draft) =>
- draft.add(
- ({
- name,
- options: { codemode: false },
- description,
- input: Input,
- output: Output,
- execute: (input, context) =>
- permission
- .assert({
- action: "question",
- resources: ["*"],
- sessionID: context.sessionID,
- agent: context.agent,
- source: { type: "tool", messageID: context.messageID, id: context.id },
- })
- .pipe(
- Effect.mapError((error) => new ToolFailure({ message: "Permission denied: question", error })),
- Effect.andThen(
- forms
- .ask({
- sessionID: context.sessionID,
- title: "Questions",
- metadata: {
- kind: "question",
- tool: { messageID: context.messageID, id: context.id },
- },
- fields: [
- toField(input.questions[0], 0),
- ...input.questions.slice(1).map((question, index) => toField(question, index + 1)),
- ],
- })
- .pipe(Effect.orDie),
- ),
- Effect.flatMap((state) => {
- // Deliberate defect tunnel (see Permission.assert): a dismissal must dodge
- // leaf `mapError` blankets so it never becomes model-facing tool output; it
- // resurfaces as a typed failure at SessionModelRequest.executeTool.
- if (state.status === "cancelled") return Effect.die(new CancelledError())
- const output = {
- answers: input.questions.map((_, index): Question.Answer => {
- const value = state.answer[`q${index}`]
- if (value === undefined) return []
- if (typeof value === "object") return Array.from(value)
- return [String(value)]
- }),
- }
- return Effect.succeed({
- output,
- content: toModelOutput(input.questions, output.answers),
- metadata: { answers: output.answers },
+ draft.add({
+ name,
+ options: { codemode: false },
+ description,
+ input: Input,
+ output: Output,
+ execute: (input, context) =>
+ permission
+ .assert({
+ action: "question",
+ resources: ["*"],
+ sessionID: context.sessionID,
+ agent: context.agent,
+ source: { type: "tool", messageID: context.messageID, id: context.id },
+ })
+ .pipe(
+ Effect.mapError((error) => new ToolFailure({ message: "Permission denied: question", error })),
+ Effect.andThen(
+ forms
+ .ask({
+ sessionID: context.sessionID,
+ title: "Questions",
+ metadata: {
+ kind: "question",
+ tool: { messageID: context.messageID, id: context.id },
+ },
+ fields: [
+ toField(input.questions[0], 0),
+ ...input.questions.slice(1).map((question, index) => toField(question, index + 1)),
+ ],
})
- }),
+ .pipe(Effect.orDie),
),
- }),
- ),
+ Effect.flatMap((state) => {
+ // Deliberate defect tunnel (see Permission.assert): a dismissal must dodge
+ // leaf `mapError` blankets so it never becomes model-facing tool output; it
+ // resurfaces as a typed failure at SessionModelRequest.executeTool.
+ if (state.status === "cancelled") return Effect.die(new CancelledError())
+ const output = {
+ answers: input.questions.map((_, index): Question.Answer => {
+ const value = state.answer[`q${index}`]
+ if (value === undefined) return []
+ if (typeof value === "object") return Array.from(value)
+ return [String(value)]
+ }),
+ }
+ return Effect.succeed({
+ output,
+ content: toModelOutput(input.questions, output.answers),
+ metadata: { answers: output.answers },
+ })
+ }),
+ ),
+ }),
)
.pipe(Effect.orDie)
}),
diff --git a/packages/core/src/tool/plugin/skill.ts b/packages/core/src/tool/plugin/skill.ts
index d24d2a80f206..cacb524f127c 100644
--- a/packages/core/src/tool/plugin/skill.ts
+++ b/packages/core/src/tool/plugin/skill.ts
@@ -56,50 +56,48 @@ export const Plugin = {
const permission = yield* Permission.Service
yield* ctx.tool
.transform((draft) =>
- draft.add(
- ({
- name,
- options: { codemode: false },
- description,
- input: Input,
- output: Output,
- execute: (input, context) =>
- Effect.gen(function* () {
- const current = yield* skills.list()
- const skill = current.find((skill) => skill.id === input.id)
- if (!skill) return yield* unableToLoad(input.id)
- return yield* Effect.gen(function* () {
- yield* permission.assert({
- action: name,
- resources: [skill.id],
- save: [skill.id],
- sessionID: context.sessionID,
- agent: context.agent,
- source: { type: "tool", messageID: context.messageID, id: context.id },
- })
- const directory = path.dirname(skill.location)
- const files =
- path.basename(skill.location) === "SKILL.md"
- ? (yield* fs.scan("**/*", { cwd: directory, absolute: true, include: "file", dot: true }))
- .filter((file) => path.basename(file) !== "SKILL.md")
- .toSorted()
- .slice(0, FILE_LIMIT)
- : []
- return {
- name: skill.name,
- directory,
- output: toModelOutput(skill, files),
- }
- }).pipe(Effect.mapError((error) => unableToLoad(input.id, error)))
- }).pipe(
- Effect.map((output) => ({
- output,
- content: output.output,
- metadata: { name: output.name, directory: output.directory },
- })),
- ),
- }),
- ),
+ draft.add({
+ name,
+ options: { codemode: false },
+ description,
+ input: Input,
+ output: Output,
+ execute: (input, context) =>
+ Effect.gen(function* () {
+ const current = yield* skills.list()
+ const skill = current.find((skill) => skill.id === input.id)
+ if (!skill) return yield* unableToLoad(input.id)
+ return yield* Effect.gen(function* () {
+ yield* permission.assert({
+ action: name,
+ resources: [skill.id],
+ save: [skill.id],
+ sessionID: context.sessionID,
+ agent: context.agent,
+ source: { type: "tool", messageID: context.messageID, id: context.id },
+ })
+ const directory = path.dirname(skill.location)
+ const files =
+ path.basename(skill.location) === "SKILL.md"
+ ? (yield* fs.scan("**/*", { cwd: directory, absolute: true, include: "file", dot: true }))
+ .filter((file) => path.basename(file) !== "SKILL.md")
+ .toSorted()
+ .slice(0, FILE_LIMIT)
+ : []
+ return {
+ name: skill.name,
+ directory,
+ output: toModelOutput(skill, files),
+ }
+ }).pipe(Effect.mapError((error) => unableToLoad(input.id, error)))
+ }).pipe(
+ Effect.map((output) => ({
+ output,
+ content: output.output,
+ metadata: { name: output.name, directory: output.directory },
+ })),
+ ),
+ }),
)
.pipe(Effect.orDie)
}),
diff --git a/packages/core/src/tool/plugin/subagent.ts b/packages/core/src/tool/plugin/subagent.ts
index 0538234ce8ec..79443dfe8d25 100644
--- a/packages/core/src/tool/plugin/subagent.ts
+++ b/packages/core/src/tool/plugin/subagent.ts
@@ -112,138 +112,135 @@ export const Plugin = {
yield* ctx.tool
.transform((draft) =>
- draft.add(
- ({
- name,
- options: { codemode: false },
- description,
- input: Input,
- output: Output,
- execute: (input, context) =>
- Effect.gen(function* () {
- const parent = yield* runtime.session
- .get(context.sessionID)
- .pipe(
- Effect.mapError(
- (error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }),
- ),
- )
- let current = parent
- let depth = 0
- while (current.parentID) {
- depth++
- current = yield* runtime.session
- .get(current.parentID)
- .pipe(
- Effect.mapError(
- (error) => new ToolFailure({ message: `Parent session not found: ${current.parentID}`, error }),
- ),
- )
- }
- const limit = Config.latest(yield* config.entries(), "experimental")?.subagent_depth ?? 1
- if (depth >= limit)
- return yield* new ToolFailure({
- message: `Subagent depth limit reached (${limit}). Increase "experimental.subagent_depth" to allow nested subagents.`,
- })
- const agent = yield* agents.resolve(input.agent)
- if (agent === undefined) return yield* new ToolFailure({ message: `Unknown agent: ${input.agent}` })
- if (agent.mode === "primary")
- return yield* new ToolFailure({ message: `Agent ${input.agent} cannot run as a subagent` })
- yield* permission
- .assert({
- action: name,
- resources: [agent.id],
- save: [agent.id],
- sessionID: context.sessionID,
- agent: context.agent,
- source: {
- type: "tool",
- messageID: context.messageID,
- id: context.id,
- },
- })
- .pipe(Effect.mapError((error) => new ToolFailure({ message: `Subagent denied: ${agent.id}`, error })))
-
- // Model selection is policy/config/session state, not an LLM-facing tool argument.
- const model = agent.model ?? parent.model
- const child = yield* runtime.session
- .create({
- parentID: context.sessionID,
- title: input.description,
- agent: Agent.ID.make(input.agent),
- model,
- // TODO(opencode kkdvxn): derive restricted subagent permissions from the parent
- // session (V1 deriveSubagentSessionPermission). MVP uses the agent's own permissions.
- })
+ draft.add({
+ name,
+ options: { codemode: false },
+ description,
+ input: Input,
+ output: Output,
+ execute: (input, context) =>
+ Effect.gen(function* () {
+ const parent = yield* runtime.session
+ .get(context.sessionID)
+ .pipe(
+ Effect.mapError(
+ (error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }),
+ ),
+ )
+ let current = parent
+ let depth = 0
+ while (current.parentID) {
+ depth++
+ current = yield* runtime.session
+ .get(current.parentID)
.pipe(
Effect.mapError(
- (error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }),
+ (error) => new ToolFailure({ message: `Parent session not found: ${current.parentID}`, error }),
),
)
+ }
+ const limit = Config.latest(yield* config.entries(), "experimental")?.subagent_depth ?? 1
+ if (depth >= limit)
+ return yield* new ToolFailure({
+ message: `Subagent depth limit reached (${limit}). Increase "experimental.subagent_depth" to allow nested subagents.`,
+ })
+ const agent = yield* agents.resolve(input.agent)
+ if (agent === undefined) return yield* new ToolFailure({ message: `Unknown agent: ${input.agent}` })
+ if (agent.mode === "primary")
+ return yield* new ToolFailure({ message: `Agent ${input.agent} cannot run as a subagent` })
+ yield* permission
+ .assert({
+ action: name,
+ resources: [agent.id],
+ save: [agent.id],
+ sessionID: context.sessionID,
+ agent: context.agent,
+ source: {
+ type: "tool",
+ messageID: context.messageID,
+ id: context.id,
+ },
+ })
+ .pipe(Effect.mapError((error) => new ToolFailure({ message: `Subagent denied: ${agent.id}`, error })))
- const background = input.background === true
- yield* context.progress({
- metadata: { sessionID: child.id, status: "running" },
+ // Model selection is policy/config/session state, not an LLM-facing tool argument.
+ const model = agent.model ?? parent.model
+ const child = yield* runtime.session
+ .create({
+ parentID: context.sessionID,
+ title: input.description,
+ agent: Agent.ID.make(input.agent),
+ model,
+ // TODO(opencode kkdvxn): derive restricted subagent permissions from the parent
+ // session (V1 deriveSubagentSessionPermission). MVP uses the agent's own permissions.
})
+ .pipe(
+ Effect.mapError(
+ (error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }),
+ ),
+ )
- const run = Effect.gen(function* () {
- // The child session owns its agent/model (set at create); prompt only admits input.
- yield* runtime.session.prompt({
- sessionID: child.id,
- text: ["You are a subagent spawned by another session.", input.prompt].join("\n"),
- resume: false,
- })
- yield* runtime.session.resume(child.id)
- return yield* latestAssistantText(child.id)
- }).pipe(Effect.onInterrupt(() => runtime.session.interrupt(child.id)))
+ const background = input.background === true
+ yield* context.progress({
+ metadata: { sessionID: child.id, status: "running" },
+ })
- const info = yield* runtime.job.start({
- id: child.id,
- type: name,
- title: input.description,
- metadata: {},
- run,
+ const run = Effect.gen(function* () {
+ // The child session owns its agent/model (set at create); prompt only admits input.
+ yield* runtime.session.prompt({
+ sessionID: child.id,
+ text: ["You are a subagent spawned by another session.", input.prompt].join("\n"),
+ resume: false,
})
+ yield* runtime.session.resume(child.id)
+ return yield* latestAssistantText(child.id)
+ }).pipe(Effect.onInterrupt(() => runtime.session.interrupt(child.id)))
- if (background) {
- yield* runtime.job.background(info.id)
- yield* notifyWhenDone(context.sessionID, child.id, agent.name, input.description)
- return {
- sessionID: child.id,
- status: "running" as const,
- output: backgroundStarted(child.id),
- }
+ const info = yield* runtime.job.start({
+ id: child.id,
+ type: name,
+ title: input.description,
+ metadata: {},
+ run,
+ })
+
+ if (background) {
+ yield* runtime.job.background(info.id)
+ yield* notifyWhenDone(context.sessionID, child.id, agent.name, input.description)
+ return {
+ sessionID: child.id,
+ status: "running" as const,
+ output: backgroundStarted(child.id),
}
+ }
- const result = yield* runtime.job.block({ id: child.id, sessionID: context.sessionID }).pipe(
- Effect.onInterrupt(() =>
- Effect.all([runtime.session.interrupt(child.id), runtime.job.cancel(child.id)], {
- discard: true,
- }),
- ),
- )
- if (result?.type === "backgrounded") {
- yield* notifyWhenDone(context.sessionID, child.id, agent.name, input.description)
- return {
- sessionID: child.id,
- status: "running" as const,
- output: backgroundStarted(child.id),
- }
+ const result = yield* runtime.job.block({ id: child.id, sessionID: context.sessionID }).pipe(
+ Effect.onInterrupt(() =>
+ Effect.all([runtime.session.interrupt(child.id), runtime.job.cancel(child.id)], {
+ discard: true,
+ }),
+ ),
+ )
+ if (result?.type === "backgrounded") {
+ yield* notifyWhenDone(context.sessionID, child.id, agent.name, input.description)
+ return {
+ sessionID: child.id,
+ status: "running" as const,
+ output: backgroundStarted(child.id),
}
- if (result?.info.status === "error")
- return yield* new ToolFailure({ message: result.info.error ?? "Subagent failed" })
- if (result?.info.status === "cancelled")
- return yield* new ToolFailure({ message: "Subagent cancelled" })
- return { sessionID: child.id, status: "completed" as const, output: result?.info.output ?? NO_TEXT }
- }).pipe(
- Effect.map((output) => ({
- output,
- content: output.output,
- metadata: { sessionID: output.sessionID, status: output.status },
- })),
- ),
- }),
- ),
+ }
+ if (result?.info.status === "error")
+ return yield* new ToolFailure({ message: result.info.error ?? "Subagent failed" })
+ if (result?.info.status === "cancelled") return yield* new ToolFailure({ message: "Subagent cancelled" })
+ return { sessionID: child.id, status: "completed" as const, output: result?.info.output ?? NO_TEXT }
+ }).pipe(
+ Effect.map((output) => ({
+ output,
+ content: output.output,
+ metadata: { sessionID: output.sessionID, status: output.status },
+ })),
+ ),
+ }),
)
.pipe(Effect.orDie)
diff --git a/packages/core/src/tool/plugin/webfetch.ts b/packages/core/src/tool/plugin/webfetch.ts
index f01f59b5fef7..d570e8bf10bc 100644
--- a/packages/core/src/tool/plugin/webfetch.ts
+++ b/packages/core/src/tool/plugin/webfetch.ts
@@ -118,62 +118,60 @@ export const Plugin = {
yield* ctx.tool
.transform((draft) =>
- draft.add(
- ({
- name,
- options: { codemode: false },
- description,
- input: Input,
- output: Output,
- execute: (input, context) =>
- Effect.gen(function* () {
- yield* Effect.try({
- try: () => assertHttpUrl(new URL(input.url)),
- catch: (error) => error,
- })
-
- yield* permission.assert({
- action: name,
- resources: [input.url],
- save: ["*"],
- metadata: input,
- sessionID: context.sessionID,
- agent: context.agent,
- source: { type: "tool", messageID: context.messageID, id: context.id },
- })
-
- const { body, contentType } = yield* Effect.gen(function* () {
- const response = yield* execute(http, input.url, input.format).pipe(
- Effect.catchIf(isCloudflareChallenge, () => execute(http, input.url, input.format, "opencode")),
- )
- const contentType = response.headers["content-type"] || ""
- const mime = mimeFrom(contentType)
- if (isImageAttachment(mime))
- return yield* Effect.fail(new Error(`Unsupported fetched image content type: ${mime}`))
- if (!isTextualMime(mime))
- return yield* Effect.fail(new Error(`Unsupported fetched file content type: ${mime}`))
- return { body: yield* collectBody(response), contentType }
- }).pipe(
- Effect.timeoutOrElse({
- duration: Duration.seconds(input.timeout ?? DEFAULT_TIMEOUT_SECONDS),
- orElse: () => Effect.fail(new Error("Request timed out")),
- }),
+ draft.add({
+ name,
+ options: { codemode: false },
+ description,
+ input: Input,
+ output: Output,
+ execute: (input, context) =>
+ Effect.gen(function* () {
+ yield* Effect.try({
+ try: () => assertHttpUrl(new URL(input.url)),
+ catch: (error) => error,
+ })
+
+ yield* permission.assert({
+ action: name,
+ resources: [input.url],
+ save: ["*"],
+ metadata: input,
+ sessionID: context.sessionID,
+ agent: context.agent,
+ source: { type: "tool", messageID: context.messageID, id: context.id },
+ })
+
+ const { body, contentType } = yield* Effect.gen(function* () {
+ const response = yield* execute(http, input.url, input.format).pipe(
+ Effect.catchIf(isCloudflareChallenge, () => execute(http, input.url, input.format, "opencode")),
)
- const content = new TextDecoder().decode(body)
- const output = yield* Effect.try({
- try: () => convert(content, contentType, input.format),
- catch: (error) => error,
- })
- const result = {
- url: input.url,
- contentType,
- format: input.format,
- output,
- }
- return { output: result, content: result.output, metadata: { contentType: result.contentType } }
- }).pipe(Effect.mapError((error) => new ToolFailure({ message: `Unable to fetch ${input.url}`, error }))),
- }),
- ),
+ const contentType = response.headers["content-type"] || ""
+ const mime = mimeFrom(contentType)
+ if (isImageAttachment(mime))
+ return yield* Effect.fail(new Error(`Unsupported fetched image content type: ${mime}`))
+ if (!isTextualMime(mime))
+ return yield* Effect.fail(new Error(`Unsupported fetched file content type: ${mime}`))
+ return { body: yield* collectBody(response), contentType }
+ }).pipe(
+ Effect.timeoutOrElse({
+ duration: Duration.seconds(input.timeout ?? DEFAULT_TIMEOUT_SECONDS),
+ orElse: () => Effect.fail(new Error("Request timed out")),
+ }),
+ )
+ const content = new TextDecoder().decode(body)
+ const output = yield* Effect.try({
+ try: () => convert(content, contentType, input.format),
+ catch: (error) => error,
+ })
+ const result = {
+ url: input.url,
+ contentType,
+ format: input.format,
+ output,
+ }
+ return { output: result, content: result.output, metadata: { contentType: result.contentType } }
+ }).pipe(Effect.mapError((error) => new ToolFailure({ message: `Unable to fetch ${input.url}`, error }))),
+ }),
)
.pipe(Effect.orDie)
}),
diff --git a/packages/core/src/tool/runtime.ts b/packages/core/src/tool/runtime.ts
index c2aad5e2e206..f75dc543a421 100644
--- a/packages/core/src/tool/runtime.ts
+++ b/packages/core/src/tool/runtime.ts
@@ -44,7 +44,8 @@ const encodeOutput = (schema: Tool.ValueSchema, value: unknown) => {
if (Schema.isSchema(schema))
return Schema.encodeEffect(schema)(value).pipe(
Effect.mapError(
- (error) => new Tool.Error({ message: `Tool returned an invalid value for its output schema: ${error.message}` }),
+ (error) =>
+ new Tool.Error({ message: `Tool returned an invalid value for its output schema: ${error.message}` }),
),
)
if (isStandardSchema(schema))
diff --git a/packages/core/src/vcs.ts b/packages/core/src/vcs.ts
index 2da6085103b7..db1f95d66124 100644
--- a/packages/core/src/vcs.ts
+++ b/packages/core/src/vcs.ts
@@ -46,7 +46,7 @@ const layer = Layer.effect(
const bus = yield* Bus.Service
const impl = adapter(proc, fs, location)
const vcs = location.vcs
- const state = { info: impl ? yield* impl.info() : { branch: {} } satisfies Info }
+ const state = { info: impl ? yield* impl.info() : ({ branch: {} } satisfies Info) }
if (vcs && impl) {
const store = yield* fs.realPath(vcs.store).pipe(Effect.catch(() => Effect.succeed(vcs.store)))
diff --git a/packages/core/src/vcs/git.ts b/packages/core/src/vcs/git.ts
index 415bbf12873c..60f84742069f 100644
--- a/packages/core/src/vcs/git.ts
+++ b/packages/core/src/vcs/git.ts
@@ -181,7 +181,10 @@ function makeGit(proc: AppProcess.Interface) {
if (remote) {
const head = yield* run(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd })
if (head.exitCode === 0) {
- const ref = head.text().trim().replace(/^refs\/remotes\//, "")
+ const ref = head
+ .text()
+ .trim()
+ .replace(/^refs\/remotes\//, "")
const name = ref.startsWith(`${remote}/`) ? ref.slice(`${remote}/`.length) : ""
if (name) return { name, ref } satisfies Base
}
diff --git a/packages/core/test/agent.test.ts b/packages/core/test/agent.test.ts
index 50e484b3c98e..928040002444 100644
--- a/packages/core/test/agent.test.ts
+++ b/packages/core/test/agent.test.ts
@@ -130,18 +130,20 @@ describe("Agent", () => {
expect(info?.permissions.slice(0, Agent.Info.default(id).permissions.length)).toEqual(
Agent.Info.default(id).permissions,
)
- expect(Permission.evaluate("external_directory", path.join(global.data, "shell", "*", "*"), info?.permissions ?? []).effect).toBe(
- "allow",
- )
- expect(Permission.evaluate("external_directory", path.join(global.data, "tool-output", "*"), info?.permissions ?? []).effect).toBe(
- "allow",
- )
- expect(Permission.evaluate("external_directory", path.join(global.config, "*"), info?.permissions ?? []).effect).toBe(
- "allow",
- )
- expect(Permission.evaluate("external_directory", path.join(global.tmp, "*"), info?.permissions ?? []).effect).toBe(
- "allow",
- )
+ expect(
+ Permission.evaluate("external_directory", path.join(global.data, "shell", "*", "*"), info?.permissions ?? [])
+ .effect,
+ ).toBe("allow")
+ expect(
+ Permission.evaluate("external_directory", path.join(global.data, "tool-output", "*"), info?.permissions ?? [])
+ .effect,
+ ).toBe("allow")
+ expect(
+ Permission.evaluate("external_directory", path.join(global.config, "*"), info?.permissions ?? []).effect,
+ ).toBe("allow")
+ expect(
+ Permission.evaluate("external_directory", path.join(global.tmp, "*"), info?.permissions ?? []).effect,
+ ).toBe("allow")
yield* agent.transform((editor) => editor.remove(id))
expect(yield* agent.get(id)).toBeUndefined()
diff --git a/packages/core/test/app.test.ts b/packages/core/test/app.test.ts
index 8d62f001412f..06bac015e7a2 100644
--- a/packages/core/test/app.test.ts
+++ b/packages/core/test/app.test.ts
@@ -2,7 +2,5 @@ import { expect, test } from "bun:test"
import { App } from "@opencode-ai/core/app"
test("formats app metadata as a user agent", () => {
- expect(App.useragent(App.make({ name: "sdk", version: "1.2.3", channel: "beta" }))).toBe(
- "opencode/beta/1.2.3/sdk",
- )
+ expect(App.useragent(App.make({ name: "sdk", version: "1.2.3", channel: "beta" }))).toBe("opencode/beta/1.2.3/sdk")
})
diff --git a/packages/core/test/bus.test.ts b/packages/core/test/bus.test.ts
index f9563f9b7d6b..f1f3997962ed 100644
--- a/packages/core/test/bus.test.ts
+++ b/packages/core/test/bus.test.ts
@@ -105,13 +105,9 @@ const it = testEffect(
]),
)
const itWithoutLocation = testEffect(
- AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [
- [Bus.node, Bus.configured({ persist: true })],
- ]),
-)
-const itWithoutPersistence = testEffect(
- AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node])),
+ AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [[Bus.node, Bus.configured({ persist: true })]]),
)
+const itWithoutPersistence = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node])))
describe("Bus", () => {
it.effect("subscribes to multiple event definitions with a discriminated payload union", () =>
@@ -1341,5 +1337,4 @@ describe("Bus", () => {
}).pipe(Effect.provide(eventLayer))
}),
)
-
})
diff --git a/packages/core/test/codemode.test.ts b/packages/core/test/codemode.test.ts
index 25a5f87941d8..6525f17af1f0 100644
--- a/packages/core/test/codemode.test.ts
+++ b/packages/core/test/codemode.test.ts
@@ -12,12 +12,12 @@ describe("CodeMode", () => {
const tools = yield* Tool.Service
yield* tools.transform((draft) =>
draft.add({
- name: "echo",
- description: "Echo text",
- input: Schema.Struct({ text: Schema.String }),
- output: Schema.String,
- options: { pinned: true },
- execute: ({ text }) => Effect.succeed({ output: text }),
+ name: "echo",
+ description: "Echo text",
+ input: Schema.Struct({ text: Schema.String }),
+ output: Schema.String,
+ options: { pinned: true },
+ execute: ({ text }) => Effect.succeed({ output: text }),
}),
)
diff --git a/packages/core/test/codemode/catalog.test.ts b/packages/core/test/codemode/catalog.test.ts
index afa1f063895f..308c15b16276 100644
--- a/packages/core/test/codemode/catalog.test.ts
+++ b/packages/core/test/codemode/catalog.test.ts
@@ -48,10 +48,7 @@ describe("CodeModeCatalog.summarize", () => {
})
test("always retains pinned tools beyond the inline budget", () => {
- const pinned = [
- entry("alpha.first", "First", undefined, true),
- entry("beta.second", "Second", undefined, true),
- ]
+ const pinned = [entry("alpha.first", "First", undefined, true), entry("beta.second", "Second", undefined, true)]
const catalog = CodeModeCatalog.summarize([...pinned, entry("alpha.unpinned", "Unpinned")], 0)
expect(catalog.shown).toBe(2)
diff --git a/packages/core/test/codemode/instructions.test.ts b/packages/core/test/codemode/instructions.test.ts
index f902e647dd87..29f1250223e3 100644
--- a/packages/core/test/codemode/instructions.test.ts
+++ b/packages/core/test/codemode/instructions.test.ts
@@ -70,20 +70,20 @@ describe("CodeModeInstructions", () => {
)
it.effect("stores a canonical sorted snapshot so registration order does not churn history", () => {
- const alpha = ({
+ const alpha = {
name: "alpha",
description: "Alpha tool",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.succeed({ output: "alpha" }),
- })
- const zeta = ({
+ }
+ const zeta = {
name: "zeta",
description: "Zeta tool",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.succeed({ output: "zeta" }),
- })
+ }
const layer = AppNodeBuilder.build(Tool.node, [
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
])
@@ -96,9 +96,7 @@ describe("CodeModeInstructions", () => {
draft.add({ ...zeta, options: { namespace: "tools" } })
draft.add({ ...alpha, options: { namespace: "tools" } })
})
- return yield* readInitial(
- CodeModeInstructions.make((yield* tools.snapshot()).codeModeCatalog),
- )
+ return yield* readInitial(CodeModeInstructions.make((yield* tools.snapshot()).codeModeCatalog))
}),
)
const reordered = yield* Effect.scoped(
@@ -107,10 +105,7 @@ describe("CodeModeInstructions", () => {
draft.add({ ...alpha, options: { namespace: "tools" } })
draft.add({ ...zeta, options: { namespace: "tools" } })
})
- return yield* readUpdate(
- CodeModeInstructions.make((yield* tools.snapshot()).codeModeCatalog),
- initialized,
- )
+ return yield* readUpdate(CodeModeInstructions.make((yield* tools.snapshot()).codeModeCatalog), initialized)
}),
)
diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts
index b2f449fbc74d..346b64517fa5 100644
--- a/packages/core/test/config/config.test.ts
+++ b/packages/core/test/config/config.test.ts
@@ -373,9 +373,7 @@ describe("Config", () => {
const initial = yield* config.entries()
expect(Config.latest(initial, "shell")).toBe("project")
expect(
- initial.flatMap((entry) =>
- entry.type === "document" && entry.info.shell ? [entry.info.shell] : [],
- ),
+ initial.flatMap((entry) => (entry.type === "document" && entry.info.shell ? [entry.info.shell] : [])),
).toEqual(["secret", "global", "project"])
const updated = yield* bus
.subscribe(Event.Updated)
@@ -387,9 +385,7 @@ describe("Config", () => {
const refreshed = yield* config.entries()
expect(Config.latest(refreshed, "shell")).toBe("project")
expect(
- refreshed.flatMap((entry) =>
- entry.type === "document" && entry.info.shell ? [entry.info.shell] : [],
- ),
+ refreshed.flatMap((entry) => (entry.type === "document" && entry.info.shell ? [entry.info.shell] : [])),
).toEqual(["next", "global", "project"])
}).pipe(
Effect.provide(testLayer(project, global, project, undefined, undefined, credentialNode, wellknownNode)),
diff --git a/packages/core/test/effect/observability.test.ts b/packages/core/test/effect/observability.test.ts
index 416d9da4b6f1..0c190fd0af62 100644
--- a/packages/core/test/effect/observability.test.ts
+++ b/packages/core/test/effect/observability.test.ts
@@ -12,7 +12,6 @@ const otelResourceAttributes = process.env.OTEL_RESOURCE_ATTRIBUTES
afterEach(() => {
if (otelResourceAttributes === undefined) delete process.env.OTEL_RESOURCE_ATTRIBUTES
else process.env.OTEL_RESOURCE_ATTRIBUTES = otelResourceAttributes
-
})
describe("resource", () => {
diff --git a/packages/core/test/formatter.test.ts b/packages/core/test/formatter.test.ts
index 441ee4fdb6e6..155b585c1f2d 100644
--- a/packages/core/test/formatter.test.ts
+++ b/packages/core/test/formatter.test.ts
@@ -39,10 +39,7 @@ function formatterLayer(directory: string, configured?: ConfigInput["formatter"]
],
[
Location.node,
- Layer.succeed(
- Location.Service,
- Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
- ),
+ Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
],
[Npm.node, Layer.mock(Npm.Service, { which: () => Effect.succeed(undefined) })],
])
diff --git a/packages/core/test/instruction-state.test.ts b/packages/core/test/instruction-state.test.ts
index 5a56609ee629..36f9c61ef180 100644
--- a/packages/core/test/instruction-state.test.ts
+++ b/packages/core/test/instruction-state.test.ts
@@ -14,7 +14,12 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { InstructionState } from "@opencode-ai/core/session/instruction-state"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionSchema } from "@opencode-ai/core/session/schema"
-import { InstructionBlobTable, InstructionStateTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
+import {
+ InstructionBlobTable,
+ InstructionStateTable,
+ SessionMessageTable,
+ SessionTable,
+} from "@opencode-ai/core/session/sql"
import { testEffect } from "./lib/effect"
const it = testEffect(
diff --git a/packages/core/test/lib/tool.ts b/packages/core/test/lib/tool.ts
index c99d2001330c..2862253efdbe 100644
--- a/packages/core/test/lib/tool.ts
+++ b/packages/core/test/lib/tool.ts
@@ -16,11 +16,7 @@ export const toolIdentity = {
export const toolDefinitions = (registry: Tool.Interface, permissions?: Permission.Ruleset) =>
registry.snapshot(permissions).pipe(Effect.map((toolSet) => toolSet.definitions))
-export function waitForTool(
- registry: Tool.Interface,
- name: string,
- remaining = 1000,
-): Effect.Effect {
+export function waitForTool(registry: Tool.Interface, name: string, remaining = 1000): Effect.Effect {
return Effect.gen(function* () {
if ((yield* toolDefinitions(registry)).some((tool) => tool.name === name)) return
if (remaining === 0) {
diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts
index 846b8835e7a8..7989a53734a3 100644
--- a/packages/core/test/mcp.test.ts
+++ b/packages/core/test/mcp.test.ts
@@ -909,16 +909,18 @@ it.effect("waits for permission before calling an MCP tool", () =>
const registry = yield* Tool.Service
const toolSet = yield* waitForCodeModeTool(registry, "demo.search")
- const fiber = yield* toolSet.execute({
- sessionID: Session.ID.make("ses_mcp_permission"),
- ...toolIdentity,
- call: {
- type: "tool-call",
- id: "call_mcp_permission",
- name: "execute",
- input: { code: "return await tools.demo.search({})" },
- },
- }).pipe(Effect.forkScoped)
+ const fiber = yield* toolSet
+ .execute({
+ sessionID: Session.ID.make("ses_mcp_permission"),
+ ...toolIdentity,
+ call: {
+ type: "tool-call",
+ id: "call_mcp_permission",
+ name: "execute",
+ input: { code: "return await tools.demo.search({})" },
+ },
+ })
+ .pipe(Effect.forkScoped)
expect(yield* Deferred.await(assertion)).toEqual({
action: "demo_search",
resources: ["*"],
diff --git a/packages/core/test/patch.test.ts b/packages/core/test/patch.test.ts
index bd75b2664790..24d3077700cc 100644
--- a/packages/core/test/patch.test.ts
+++ b/packages/core/test/patch.test.ts
@@ -177,11 +177,7 @@ describe("Patch", () => {
})
test("preserves the end-of-file marker", () => {
- expect(
- parse(
- "*** Begin Patch\n*** Update File: file.txt\n@@\n+quux\n*** End of File\n\n*** End Patch",
- ),
- ).toEqual([
+ expect(parse("*** Begin Patch\n*** Update File: file.txt\n@@\n+quux\n*** End of File\n\n*** End Patch")).toEqual([
{
type: "update",
path: "file.txt",
@@ -192,16 +188,16 @@ describe("Patch", () => {
})
test("allows an end-of-file marker before an explicit chunk", () => {
- expect(
- parse("*** Begin Patch\n*** Update File: file.txt\n*** End of File\n@@\n-old\n+new\n*** End Patch"),
- ).toEqual([
- {
- type: "update",
- path: "file.txt",
- movePath: undefined,
- chunks: [{ oldLines: ["old"], newLines: ["new"], changeContext: undefined }],
- },
- ])
+ expect(parse("*** Begin Patch\n*** Update File: file.txt\n*** End of File\n@@\n-old\n+new\n*** End Patch")).toEqual(
+ [
+ {
+ type: "update",
+ path: "file.txt",
+ movePath: undefined,
+ chunks: [{ oldLines: ["old"], newLines: ["new"], changeContext: undefined }],
+ },
+ ],
+ )
})
test("allows an end-of-file marker before an implicit chunk and move", () => {
@@ -247,9 +243,9 @@ describe("Patch", () => {
})
test("appends a pure-addition chunk to a nonempty file", () => {
- expect(Patch.derive("update.txt", [{ oldLines: [], newLines: ["added 1", "added 2"] }], "line 1\nline 2\n").content).toBe(
- "line 1\nline 2\nadded 1\nadded 2\n",
- )
+ expect(
+ Patch.derive("update.txt", [{ oldLines: [], newLines: ["added 1", "added 2"] }], "line 1\nline 2\n").content,
+ ).toBe("line 1\nline 2\nadded 1\nadded 2\n")
})
test("applies a pure-addition chunk after an earlier replacement", () => {
@@ -303,27 +299,35 @@ describe("Patch", () => {
test("matches Unicode minus signs and spaces", () => {
expect(
- Patch.derive("minus.txt", [{ oldLines: ["value - 1"], newLines: ["value - 2"] }], "value − 1\n")
- .content,
+ Patch.derive("minus.txt", [{ oldLines: ["value - 1"], newLines: ["value - 2"] }], "value − 1\n").content,
).toBe("value - 2\n")
- const spaces = ["\u00A0", "\u2002", "\u2003", "\u2004", "\u2005", "\u2006", "\u2007", "\u2008", "\u2009", "\u200A", "\u202F", "\u205F", "\u3000"]
- spaces.forEach(
- (space) => {
- expect(
- Patch.derive(
- "spaces.txt",
- [{ oldLines: ["hello world"], newLines: ["hello there"] }],
- `hello${space}world\n`,
- ).content,
- ).toBe("hello there\n")
- },
- )
+ const spaces = [
+ "\u00A0",
+ "\u2002",
+ "\u2003",
+ "\u2004",
+ "\u2005",
+ "\u2006",
+ "\u2007",
+ "\u2008",
+ "\u2009",
+ "\u200A",
+ "\u202F",
+ "\u205F",
+ "\u3000",
+ ]
+ spaces.forEach((space) => {
+ expect(
+ Patch.derive("spaces.txt", [{ oldLines: ["hello world"], newLines: ["hello there"] }], `hello${space}world\n`)
+ .content,
+ ).toBe("hello there\n")
+ })
})
test("does not normalize ellipses", () => {
- expect(() =>
- Patch.derive("ellipsis.txt", [{ oldLines: ["wait..."], newLines: ["done"] }], "wait…\n"),
- ).toThrow("Failed to find expected lines")
+ expect(() => Patch.derive("ellipsis.txt", [{ oldLines: ["wait..."], newLines: ["done"] }], "wait…\n")).toThrow(
+ "Failed to find expected lines",
+ )
})
test("prefers a later exact match over an earlier normalized match", () => {
@@ -357,9 +361,9 @@ describe("Patch", () => {
})
test("identifies a missing blank line", () => {
- expect(() =>
- Patch.derive("update.txt", [{ oldLines: [""], newLines: ["added"] }], "content\n"),
- ).toThrow("Failed to find an expected blank line in update.txt")
+ expect(() => Patch.derive("update.txt", [{ oldLines: [""], newLines: ["added"] }], "content\n")).toThrow(
+ "Failed to find an expected blank line in update.txt",
+ )
})
test("parses an update without an explicit first chunk header", () => {
@@ -483,7 +487,9 @@ describe("Patch", () => {
"Invalid hunk at line 4: Unexpected line found in update hunk: '@@'",
)
expect(() =>
- parse("*** Begin Patch\n*** Update File: file.txt\n@@\n*** Update File: other.txt\n@@\n-old\n+new\n*** End Patch"),
+ parse(
+ "*** Begin Patch\n*** Update File: file.txt\n@@\n*** Update File: other.txt\n@@\n-old\n+new\n*** End Patch",
+ ),
).toThrow("Invalid hunk at line 4: Unexpected line found in update hunk: '*** Update File: other.txt'")
expect(() => parse("*** Begin Patch\n*** Update File: file.txt\n@@\nbad\n*** End Patch")).toThrow(
"Invalid hunk at line 4: Unexpected line found in update hunk: 'bad'",
@@ -497,7 +503,9 @@ describe("Patch", () => {
expect(() => parse("*** Begin Patch\n*** Update File: file.txt\n@@foo\n*** End Patch")).toThrow(
"Invalid hunk at line 3: Unexpected line found in update hunk: '@@foo'",
)
- expect(() => parse("*** Begin Patch\n*** Update File: file.txt\n@@\n-old\n*** Frobnicate File: foo\n*** End Patch")).toThrow(
+ expect(() =>
+ parse("*** Begin Patch\n*** Update File: file.txt\n@@\n-old\n*** Frobnicate File: foo\n*** End Patch"),
+ ).toThrow(
"Invalid hunk at line 5: Expected update hunk to start with a @@ context marker, got: '*** Frobnicate File: foo'",
)
})
diff --git a/packages/core/test/permission.test.ts b/packages/core/test/permission.test.ts
index 7e7ac0392c6d..e8565abe238c 100644
--- a/packages/core/test/permission.test.ts
+++ b/packages/core/test/permission.test.ts
@@ -26,14 +26,7 @@ const current = Layer.succeed(
)
const it = testEffect(
AppNodeBuilder.build(
- LayerNode.group([
- Database.node,
- Bus.node,
- SessionStore.node,
- PermissionSaved.node,
- Agent.node,
- Permission.node,
- ]),
+ LayerNode.group([Database.node, Bus.node, SessionStore.node, PermissionSaved.node, Agent.node, Permission.node]),
[[Location.node, current]],
),
)
diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts
index c2032348bb86..e91908c541dc 100644
--- a/packages/core/test/plugin.test.ts
+++ b/packages/core/test/plugin.test.ts
@@ -250,16 +250,14 @@ describe("Plugin", () => {
effect: (ctx) =>
ctx.tool
.transform((draft) =>
- draft.add(
- ({
- name: "plugin_tool",
- options: { codemode: false },
- description: "Plugin tool",
- input: Schema.Struct({}),
- output: Schema.Struct({ ok: Schema.Boolean }),
- execute: () => Effect.succeed({ output: { ok: true } }),
- }),
- ),
+ draft.add({
+ name: "plugin_tool",
+ options: { codemode: false },
+ description: "Plugin tool",
+ input: Schema.Struct({}),
+ output: Schema.Struct({ ok: Schema.Boolean }),
+ execute: () => Effect.succeed({ output: { ok: true } }),
+ }),
)
.pipe(Effect.orDie),
})
@@ -276,15 +274,14 @@ describe("Plugin", () => {
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const registry = yield* Tool.Service
- const tool = (name: string, description: string, options?: Tool.Options) =>
- ({
- name,
- options,
- description,
- input: Schema.Struct({}),
- output: Schema.Struct({ ok: Schema.Boolean }),
- execute: () => Effect.succeed({ output: { ok: true } }),
- })
+ const tool = (name: string, description: string, options?: Tool.Options) => ({
+ name,
+ options,
+ description,
+ input: Schema.Struct({}),
+ output: Schema.Struct({ ok: Schema.Boolean }),
+ execute: () => Effect.succeed({ output: { ok: true } }),
+ })
const plugin = EffectPlugin.define({
id: "grouped-tools",
effect: (ctx) =>
@@ -323,17 +320,15 @@ describe("Plugin", () => {
Effect.gen(function* () {
yield* ctx.tool
.transform((draft) =>
- draft.add(
- ({
- name: "echo",
- options: { codemode: false },
- description: "Echo",
- input: Schema.Struct({ text: Schema.String }),
- output: Schema.Struct({ text: Schema.String }),
- execute: ({ text }) =>
- Effect.sync(() => executed.push({ text })).pipe(Effect.as({ output: { text } })),
- }),
- ),
+ draft.add({
+ name: "echo",
+ options: { codemode: false },
+ description: "Echo",
+ input: Schema.Struct({ text: Schema.String }),
+ output: Schema.Struct({ text: Schema.String }),
+ execute: ({ text }) =>
+ Effect.sync(() => executed.push({ text })).pipe(Effect.as({ output: { text } })),
+ }),
)
.pipe(Effect.orDie)
@@ -368,7 +363,8 @@ describe("Plugin", () => {
yield* ctx.tool
.hook("execute.after", (event) =>
Effect.sync(() => {
- if (event.status === "completed" && Array.isArray(event.result.content)) event.result.content.splice(0)
+ if (event.status === "completed" && Array.isArray(event.result.content))
+ event.result.content.splice(0)
}),
)
.pipe(Effect.asVoid)
diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts
index f45364ebdfe3..f35beaa1ec44 100644
--- a/packages/core/test/plugin/host.ts
+++ b/packages/core/test/plugin/host.ts
@@ -226,8 +226,7 @@ export function catalogHost(catalog: Catalog.Interface): Plugin.Context["catalog
})),
})
}),
- remove: (providerID, modelID) =>
- draft.model.remove(Provider.ID.make(providerID), Model.ID.make(modelID)),
+ remove: (providerID, modelID) => draft.model.remove(Provider.ID.make(providerID), Model.ID.make(modelID)),
default: {
get: () => {
const value = draft.model.default.get()
@@ -417,9 +416,7 @@ function method(value: Integration.Method) {
}
}
-function internalMethod(
- value: IntegrationMethodRegistration["method"],
-): Integration.Method {
+function internalMethod(value: IntegrationMethodRegistration["method"]): Integration.Method {
if (value.type === "env") return value
if (value.type === "key") return value
if (value.type === "command") {
diff --git a/packages/core/test/plugin/provider-cerebras.test.ts b/packages/core/test/plugin/provider-cerebras.test.ts
index bf13f452d4dd..1a479b7d8fa5 100644
--- a/packages/core/test/plugin/provider-cerebras.test.ts
+++ b/packages/core/test/plugin/provider-cerebras.test.ts
@@ -65,10 +65,7 @@ describe("CerebrasPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: Model.Info.make({
- ...Model.Info.default(
- Provider.ID.make("custom-cerebras"),
- Model.ID.make("llama-4-scout-17b-16e-instruct"),
- ),
+ ...Model.Info.default(Provider.ID.make("custom-cerebras"), Model.ID.make("llama-4-scout-17b-16e-instruct")),
modelID: Model.ID.make("llama-4-scout-17b-16e-instruct"),
package: "aisdk:test-provider",
}),
@@ -88,10 +85,7 @@ describe("CerebrasPlugin", () => {
yield* addPlugin()
yield* aisdk.runSDK({
model: Model.Info.make({
- ...Model.Info.default(
- Provider.ID.make("custom-cerebras"),
- Model.ID.make("llama-4-scout-17b-16e-instruct"),
- ),
+ ...Model.Info.default(Provider.ID.make("custom-cerebras"), Model.ID.make("llama-4-scout-17b-16e-instruct")),
modelID: Model.ID.make("llama-4-scout-17b-16e-instruct"),
package: "aisdk:test-provider",
}),
@@ -110,10 +104,7 @@ describe("CerebrasPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: Model.Info.make({
- ...Model.Info.default(
- Provider.ID.make("custom-cerebras"),
- Model.ID.make("llama-4-scout-17b-16e-instruct"),
- ),
+ ...Model.Info.default(Provider.ID.make("custom-cerebras"), Model.ID.make("llama-4-scout-17b-16e-instruct")),
modelID: Model.ID.make("llama-4-scout-17b-16e-instruct"),
package: "aisdk:test-provider",
}),
diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts
index 88551c90ebf6..5f0ff9fc58a3 100644
--- a/packages/core/test/plugin/provider-openai.test.ts
+++ b/packages/core/test/plugin/provider-openai.test.ts
@@ -128,12 +128,8 @@ describe("OpenAIPlugin", () => {
expect(eligible.cost).toEqual([])
expect(eligible.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
expect(eligible.enabled).toBe(true)
- expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5-pro"))).enabled).toBe(
- false,
- )
- expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4-pro"))).enabled).toBe(
- false,
- )
+ expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5-pro"))).enabled).toBe(false)
+ expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4-pro"))).enabled).toBe(false)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4"))).limit).toEqual({
context: 400_000,
input: 272_000,
@@ -180,5 +176,4 @@ describe("OpenAIPlugin", () => {
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(true)
}),
)
-
})
diff --git a/packages/core/test/plugin/provider-openrouter.test.ts b/packages/core/test/plugin/provider-openrouter.test.ts
index 7d9cae924ecc..ce97f1b6a3ce 100644
--- a/packages/core/test/plugin/provider-openrouter.test.ts
+++ b/packages/core/test/plugin/provider-openrouter.test.ts
@@ -75,8 +75,7 @@ describe("OpenRouterPlugin", () => {
})
yield* addPlugin()
expect(
- (yield* catalog.model.get(Provider.ID.make("custom-openrouter"), Model.ID.make("gpt-5-chat-latest")))
- ?.enabled,
+ (yield* catalog.model.get(Provider.ID.make("custom-openrouter"), Model.ID.make("gpt-5-chat-latest")))?.enabled,
).toBe(true)
}),
)
diff --git a/packages/core/test/plugin/system-prompt.test.ts b/packages/core/test/plugin/system-prompt.test.ts
index 31ff5b99a66a..78e98b575f1b 100644
--- a/packages/core/test/plugin/system-prompt.test.ts
+++ b/packages/core/test/plugin/system-prompt.test.ts
@@ -43,9 +43,7 @@ describe("SystemPromptPlugin", () => {
expect(PROMPT_META).toContain("`edit` for editing")
expect(PROMPT_META).toContain("`write` for creating files")
expect(PROMPT_META).toContain("https://opencode.ai/v2/docs/")
- expect(PROMPT_META).not.toMatch(
- /TodoWrite|Task tool|WebFetch|\bBash\b|https:\/\/opencode\.ai\/docs/,
- )
+ expect(PROMPT_META).not.toMatch(/TodoWrite|Task tool|WebFetch|\bBash\b|https:\/\/opencode\.ai\/docs/)
})
test("uses granular IDs with a common prefix", () => {
diff --git a/packages/core/test/plugin/websearch-fixture.ts b/packages/core/test/plugin/websearch-fixture.ts
index c6734e57a5b6..090db2b293f1 100644
--- a/packages/core/test/plugin/websearch-fixture.ts
+++ b/packages/core/test/plugin/websearch-fixture.ts
@@ -41,15 +41,9 @@ const http = Layer.succeed(
export const webSearchIntegrationTest = testEffect(
Layer.merge(
- AppNodeBuilder.build(
- LayerNode.group([Integration.node, Credential.node, Bus.node, Form.node, WebSearch.node]),
- [
- [
- Config.node,
- Config.testLayer(),
- ],
- ],
- ),
+ AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, Bus.node, Form.node, WebSearch.node]), [
+ [Config.node, Config.testLayer()],
+ ]),
http,
),
)
diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts
index 8cb44e0f77f3..da4ddf4fb735 100644
--- a/packages/core/test/project.test.ts
+++ b/packages/core/test/project.test.ts
@@ -366,10 +366,7 @@ describe("Project.resolve", () => {
})
expect(
(yield* project.directories({ projectID: id })).toSorted((a, b) => a.directory.localeCompare(b.directory)),
- ).toEqual([
- { directory: yield* real(tmp.path) },
- { directory: yield* real(worktree), strategy: "git_worktree" },
- ])
+ ).toEqual([{ directory: yield* real(tmp.path) }, { directory: yield* real(worktree), strategy: "git_worktree" }])
}),
)
})
diff --git a/packages/core/test/pty/pty-session.test.ts b/packages/core/test/pty/pty-session.test.ts
index e4f0bdf6e784..c2b4e2fc4935 100644
--- a/packages/core/test/pty/pty-session.test.ts
+++ b/packages/core/test/pty/pty-session.test.ts
@@ -213,9 +213,7 @@ const configuredIt = testEffect(
Layer.mock(Config.Service)({
entries: () =>
Effect.succeed(
- configuredShell
- ? [new Document({ type: "document", info: new Info({ shell: configuredShell }) })]
- : [],
+ configuredShell ? [new Document({ type: "document", info: new Info({ shell: configuredShell }) })] : [],
),
}),
],
diff --git a/packages/core/test/ripgrep.test.ts b/packages/core/test/ripgrep.test.ts
index f885c09dde34..95f6ddf854af 100644
--- a/packages/core/test/ripgrep.test.ts
+++ b/packages/core/test/ripgrep.test.ts
@@ -129,7 +129,9 @@ describe("Ripgrep", () => {
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
- yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "generated.ts"), `Cloudflare${"x".repeat(70 * 1024)}\n`))
+ yield* Effect.promise(() =>
+ fs.writeFile(path.join(tmp.path, "generated.ts"), `Cloudflare${"x".repeat(70 * 1024)}\n`),
+ )
const matches = yield* (yield* Ripgrep.Service).grep({
cwd: tmp.path,
diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts
index 40c50c80f37d..7598d487e44f 100644
--- a/packages/core/test/session-prompt.test.ts
+++ b/packages/core/test/session-prompt.test.ts
@@ -563,11 +563,7 @@ describe("Session.prompt", () => {
const input = { sessionID, id: messageID, text: "Fix the failing tests", resume: false }
const first = yield* session.prompt(input)
yield* SessionPending.promote(db, bus, sessionID, "steer")
- yield* db
- .delete(EventTable)
- .where(eq(EventTable.aggregate_id, sessionID))
- .run()
- .pipe(Effect.orDie)
+ yield* db.delete(EventTable).where(eq(EventTable.aggregate_id, sessionID)).run().pipe(Effect.orDie)
const retried = yield* session.prompt(input)
@@ -982,13 +978,9 @@ describe("Session.prompt", () => {
expect(input.delivery).toBe("queue")
expect(yield* SessionPending.has(db, sessionID, "input")).toBe(true)
- expect(
- yield* SessionPending.promote(db, bus, sessionID, "steer"),
- ).toBe(0)
+ expect(yield* SessionPending.promote(db, bus, sessionID, "steer")).toBe(0)
expect(yield* session.messages({ sessionID })).toEqual([])
- expect(
- yield* SessionPending.promote(db, bus, sessionID, "input"),
- ).toBe(1)
+ expect(yield* SessionPending.promote(db, bus, sessionID, "input")).toBe(1)
expect(yield* SessionPending.has(db, sessionID, "input")).toBe(false)
expect(yield* session.messages({ sessionID })).toMatchObject([
{ id: input.id, type: "synthetic", text: "Queued completion" },
@@ -1058,14 +1050,10 @@ describe("Session.pending", () => {
{ id: second.id, type: "user", delivery: "steer" },
])
- expect(
- yield* SessionPending.promote(db, bus, sessionID, "input"),
- ).toBe(2)
+ expect(yield* SessionPending.promote(db, bus, sessionID, "input")).toBe(2)
expect(yield* session.pending(sessionID)).toMatchObject([{ id: queued.id, type: "synthetic" }])
- expect(
- yield* SessionPending.promote(db, bus, sessionID, "input"),
- ).toBe(1)
+ expect(yield* SessionPending.promote(db, bus, sessionID, "input")).toBe(1)
expect(yield* session.pending(sessionID)).toEqual([])
}),
)
@@ -1104,9 +1092,11 @@ describe("Session.pending", () => {
expect(yield* session.pending(sessionID)).toEqual([])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
- expect(
- yield* session.cancelPending({ sessionID, inputID }).pipe(Effect.flip),
- ).toMatchObject({ _tag: "Session.PendingInputConflictError", sessionID, inputID })
+ expect(yield* session.cancelPending({ sessionID, inputID }).pipe(Effect.flip)).toMatchObject({
+ _tag: "Session.PendingInputConflictError",
+ sessionID,
+ inputID,
+ })
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
const retried = yield* session.prompt({
@@ -1151,9 +1141,11 @@ describe("Session.pending", () => {
expect(wakeCalls).toEqual([])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputQueued.type, 1))).toBe(1)
- expect(
- yield* session.steerPending({ sessionID, inputID: alreadySteered.id }).pipe(Effect.flip),
- ).toMatchObject({ _tag: "Session.PendingInputConflictError", sessionID, inputID: alreadySteered.id })
+ expect(yield* session.steerPending({ sessionID, inputID: alreadySteered.id }).pipe(Effect.flip)).toMatchObject({
+ _tag: "Session.PendingInputConflictError",
+ sessionID,
+ inputID: alreadySteered.id,
+ })
yield* session.cancelPending({ sessionID, inputID: alreadySteered.id })
expect(wakeCalls).toEqual([])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputSteered.type, 1))).toBe(1)
diff --git a/packages/core/test/session-runner-tool-events.test.ts b/packages/core/test/session-runner-tool-events.test.ts
index b33aa91d5493..69e9d2dfab38 100644
--- a/packages/core/test/session-runner-tool-events.test.ts
+++ b/packages/core/test/session-runner-tool-events.test.ts
@@ -24,9 +24,7 @@ const capture = (providerMetadataKey = "anthropic", options?: { readonly interru
const publish = Effect.sync(() => {
const event = { id: Event.ID.create(), type: definition.type, data } as Event.Payload
published.push({
- type: definition.durable
- ? Bus.versionedType(definition.type, definition.durable.version)
- : definition.type,
+ type: definition.durable ? Bus.versionedType(definition.type, definition.durable.version) : definition.type,
data,
})
return event
@@ -226,9 +224,7 @@ test("provider-executed tool metadata is flattened using the route key", async (
test("binary failure emits no success event", async () => {
const { published, publisher } = capture()
await Effect.runPromise(publisher.publish(call))
- await Effect.runPromise(
- publisher.failTool(call.id, { type: "tool.execution", message: "Cannot read binary file" }),
- )
+ await Effect.runPromise(publisher.failTool(call.id, { type: "tool.execution", message: "Cannot read binary file" }))
expect(published.some((event) => event.type === "session.tool.success.2")).toBe(false)
expect(published.some((event) => event.type === "session.tool.failed.2")).toBe(true)
})
diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts
index 7db79669b6dc..b3fb1029a0b3 100644
--- a/packages/core/test/session-runner-tool-registry.test.ts
+++ b/packages/core/test/session-runner-tool-registry.test.ts
@@ -48,33 +48,25 @@ const call = (name: string, id = `call-${name}`): Parameters
- ({
- name: "echo",
- description: "Echo text",
- input: Schema.Struct({ text: Schema.String }),
- output: Schema.Struct({ text: Schema.String }),
- execute: ({ text }) => Effect.succeed({ output: { text }, content: text }),
- })
-
-const constant = (text: string): Info =>
- ({
- name: "constant",
- description: "Return text",
- input: Schema.Struct({ text: Schema.String }),
- output: Schema.Struct({ text: Schema.String }),
- execute: () => Effect.succeed({ output: { text }, content: text }),
- })
-
-const transform = (
- service: Tool.Interface,
- tools: Readonly>,
- options?: Tool.Options,
-) =>
+const make = (): Info => ({
+ name: "echo",
+ description: "Echo text",
+ input: Schema.Struct({ text: Schema.String }),
+ output: Schema.Struct({ text: Schema.String }),
+ execute: ({ text }) => Effect.succeed({ output: { text }, content: text }),
+})
+
+const constant = (text: string): Info => ({
+ name: "constant",
+ description: "Return text",
+ input: Schema.Struct({ text: Schema.String }),
+ output: Schema.Struct({ text: Schema.String }),
+ execute: () => Effect.succeed({ output: { text }, content: text }),
+})
+
+const transform = (service: Tool.Interface, tools: Readonly>, options?: Tool.Options) =>
service.transform((draft) =>
- Object.entries(tools).forEach(([name, tool]) =>
- draft.add({ ...tool, name, options: options ?? tool.options }),
- ),
+ Object.entries(tools).forEach(([name, tool]) => draft.add({ ...tool, name, options: options ?? tool.options })),
)
describe("Tool", () => {
@@ -95,8 +87,9 @@ describe("Tool", () => {
const invalid = yield* transform(service, { "123": make() }, { codemode: false }).pipe(Effect.flip)
expect(invalid.message).toBe("Invalid tool name: 123")
- const collision = yield* transform(service, { "echo.tool": make(), echo_tool: make() }, { codemode: false })
- .pipe(Effect.flip)
+ const collision = yield* transform(service, { "echo.tool": make(), echo_tool: make() }, { codemode: false }).pipe(
+ Effect.flip,
+ )
expect(collision.message).toBe("Duplicate normalized tool name: echo_tool")
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
}),
@@ -202,11 +195,7 @@ describe("Tool", () => {
{ action: "*", resource: "*", effect: "deny" },
]),
).toEqual([])
- expect(yield* names([{ action: "edit", resource: "*", effect: "deny" }])).toEqual([
- "bash",
- "question",
- "execute",
- ])
+ expect(yield* names([{ action: "edit", resource: "*", effect: "deny" }])).toEqual(["bash", "question", "execute"])
}),
)
@@ -239,13 +228,12 @@ describe("Tool", () => {
const service = yield* Tool.Service
const scope = yield* Scope.make()
const registered = yield* Deferred.make()
- const fiber = yield* transform(service, { echo: make() }, { codemode: false })
- .pipe(
- Effect.andThen(Deferred.succeed(registered, undefined)),
- Effect.andThen(Effect.never),
- Scope.provide(scope),
- Effect.forkChild,
- )
+ const fiber = yield* transform(service, { echo: make() }, { codemode: false }).pipe(
+ Effect.andThen(Deferred.succeed(registered, undefined)),
+ Effect.andThen(Effect.never),
+ Scope.provide(scope),
+ Effect.forkChild,
+ )
yield* Deferred.await(registered)
yield* Fiber.interrupt(fiber)
@@ -258,15 +246,16 @@ describe("Tool", () => {
it.effect("returns model errors without swallowing interruption or defects", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
- yield* transform(service,
+ yield* transform(
+ service,
{
- failed: ({
+ failed: {
name: "failed",
description: "Failed",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.fail(new Tool.Error({ message: "Denied" })),
- }),
+ },
},
{ codemode: false },
)
@@ -285,15 +274,16 @@ describe("Tool", () => {
}),
).toEqual({ status: "error", error: { type: "tool.execution", message: "Unknown tool: missing" } })
- yield* transform(service,
+ yield* transform(
+ service,
{
- defect: ({
+ defect: {
name: "defect",
description: "Defect",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () => Effect.die("unexpected executor defect"),
- }),
+ },
},
{ codemode: false },
)
@@ -326,16 +316,17 @@ describe("Tool", () => {
Effect.gen(function* () {
const service = yield* Tool.Service
const contexts: Tool.Context[] = []
- yield* transform(service,
+ yield* transform(
+ service,
{
- context: ({
+ context: {
name: "context",
description: "Context",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: (_, context) =>
Effect.sync(() => contexts.push(context)).pipe(Effect.as({ output: { ok: true } })),
- }),
+ },
},
{ codemode: false },
)
@@ -353,9 +344,10 @@ describe("Tool", () => {
it.effect("normalizes image tool output once and drops unresizable images", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
- yield* transform(service,
+ yield* transform(
+ service,
{
- snapshot: ({
+ snapshot: {
name: "snapshot",
description: "Return images",
input: Schema.Struct({ text: Schema.String }),
@@ -375,7 +367,7 @@ describe("Tool", () => {
{ type: "text", text },
],
}),
- }),
+ },
},
{ codemode: false },
)
@@ -405,9 +397,7 @@ describe("Tool", () => {
if (event.status !== "completed") return
event.result = {
...event.result,
- content: [
- { type: "file", uri: "data:image/png;base64,aW1hZ2U=", mime: "image/png", name: "hook.png" },
- ],
+ content: [{ type: "file", uri: "data:image/png;base64,aW1hZ2U=", mime: "image/png", name: "hook.png" }],
}
}),
)
@@ -426,16 +416,17 @@ describe("Tool", () => {
it.effect("publishes progress metadata unchanged", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
- yield* transform(service,
+ yield* transform(
+ service,
{
- progressive: ({
+ progressive: {
name: "progressive",
description: "Emit image progress",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }, context) =>
context.progress({ stage: "capture" }).pipe(Effect.as({ output: { text } })),
- }),
+ },
},
{ codemode: false },
)
@@ -462,16 +453,17 @@ describe("Tool", () => {
encode: SchemaGetter.transform((value) => value === "yes"),
}),
)
- yield* transform(service,
+ yield* transform(
+ service,
{
- transformed: ({
+ transformed: {
name: "transformed",
description: "Transform values",
input: Schema.Struct({ value: Transformed }),
output: Schema.Struct({ value: Transformed }),
execute: ({ value }) =>
Effect.sync(() => executed.push(value)).pipe(Effect.as({ output: { value }, content: String(value) })),
- }),
+ },
},
{ codemode: false },
)
@@ -501,9 +493,10 @@ describe("Tool", () => {
})
expect(executed).toEqual(["yes"])
- yield* transform(service,
+ yield* transform(
+ service,
{
- invalid_output: ({
+ invalid_output: {
name: "invalid_output",
description: "Return invalid output",
input: Schema.Struct({}),
@@ -520,7 +513,7 @@ describe("Tool", () => {
),
}),
execute: () => Effect.succeed({ output: { value: "invalid" } }),
- }),
+ },
},
{ codemode: false },
)
@@ -570,19 +563,18 @@ describe("Tool", () => {
const executed: string[] = []
const scope = yield* Scope.make()
yield* transform(service, {
- echo: ({
- name: "echo",
- description: "Echo text",
- input: Schema.Struct({ text: Schema.String }),
- output: Schema.Struct({ text: Schema.String }),
- execute: ({ text }, context) =>
- Effect.sync(() => executed.push(`old:${text}`)).pipe(
- Effect.andThen(context.progress({ stage: "old" })),
- Effect.as({ output: { text } }),
- ),
- }),
- })
- .pipe(Scope.provide(scope))
+ echo: {
+ name: "echo",
+ description: "Echo text",
+ input: Schema.Struct({ text: Schema.String }),
+ output: Schema.Struct({ text: Schema.String }),
+ execute: ({ text }, context) =>
+ Effect.sync(() => executed.push(`old:${text}`)).pipe(
+ Effect.andThen(context.progress({ stage: "old" })),
+ Effect.as({ output: { text } }),
+ ),
+ },
+ }).pipe(Scope.provide(scope))
const toolSet = yield* service.snapshot()
const execute = toolSet.definitions.find((tool) => tool.name === "execute")
expect(toolSet.codeModeCatalog?.[0]?.signature).toContain("tools.echo")
@@ -590,13 +582,13 @@ describe("Tool", () => {
expect(execute?.description).not.toContain("Echo text")
yield* Scope.close(scope, Exit.void)
yield* transform(service, {
- echo: ({
+ echo: {
name: "echo",
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }) => Effect.sync(() => executed.push(`new:${text}`)).pipe(Effect.as({ output: { text } })),
- }),
+ },
})
const progress: Tool.Metadata[] = []
diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts
index 547b930d7e2f..e44b3d2c9c88 100644
--- a/packages/core/test/session-runner.test.ts
+++ b/packages/core/test/session-runner.test.ts
@@ -3254,10 +3254,7 @@ describe("SessionRunnerLLM", () => {
yield* stream.started
expect(requests).toHaveLength(2)
- expect(requests.map((request) => request.promptCacheKey)).toEqual([
- sessionID,
- otherSessionID,
- ])
+ expect(requests.map((request) => request.promptCacheKey)).toEqual([sessionID, otherSessionID])
yield* stream.release
yield* Fiber.join(first)
yield* Fiber.join(second)
diff --git a/packages/core/test/shell-parse.test.ts b/packages/core/test/shell-parse.test.ts
index 8b7525a8540c..d18fe710978c 100644
--- a/packages/core/test/shell-parse.test.ts
+++ b/packages/core/test/shell-parse.test.ts
@@ -20,7 +20,11 @@ describe("ShellParse", () => {
test("splits PowerShell commands case-insensitively", async () => {
const result = await Effect.runPromise(
- ShellParse.scan("Get-ChildItem; Write-Output 'done'", "C:\\Program Files\\PowerShell\\7\\pwsh.exe", "C:\\workspace"),
+ ShellParse.scan(
+ "Get-ChildItem; Write-Output 'done'",
+ "C:\\Program Files\\PowerShell\\7\\pwsh.exe",
+ "C:\\workspace",
+ ),
)
expect(result.commands).toEqual([
{ resource: "Get-ChildItem", save: "Get-ChildItem *" },
diff --git a/packages/core/test/snapshot.test.ts b/packages/core/test/snapshot.test.ts
index c36d2d447a67..678f68313e47 100644
--- a/packages/core/test/snapshot.test.ts
+++ b/packages/core/test/snapshot.test.ts
@@ -199,7 +199,5 @@ async function initGit(directory: string, commit = false) {
await $`git init`.cwd(directory).quiet()
await $`git -c core.fsmonitor=false add .`.cwd(directory).quiet()
if (!commit) return
- await $`git -c user.email=test@opencode.test -c user.name=Test commit --no-gpg-sign -m initial`
- .cwd(directory)
- .quiet()
+ await $`git -c user.email=test@opencode.test -c user.name=Test commit --no-gpg-sign -m initial`.cwd(directory).quiet()
}
diff --git a/packages/core/test/tool-execute.test.ts b/packages/core/test/tool-execute.test.ts
index adcc49250219..f5e07ff7044d 100644
--- a/packages/core/test/tool-execute.test.ts
+++ b/packages/core/test/tool-execute.test.ts
@@ -33,27 +33,27 @@ test("execute describes invariant Code Mode behavior", () => {
})
test("canonical execution distinguishes declared, model-only, and raw schema outputs", async () => {
- const declared: Info = ({
+ const declared: Info = {
name: "declared",
description: "Declared",
input: Schema.Struct({ value: Schema.String }),
output: Schema.Struct({ value: Schema.String }),
execute: ({ value }) => Effect.succeed({ output: { value } }),
- })
+ }
const modelOnlyInput = Schema.Struct({})
- const modelOnly = ({
+ const modelOnly = {
name: "model_only",
description: "Model only",
input: modelOnlyInput,
execute: () => Effect.succeed({ content: "visible only", metadata: { kind: "model" } }),
- }) satisfies Info
- const raw: Info = ({
+ } satisfies Info
+ const raw: Info = {
name: "raw",
description: "Raw",
input: {},
output: {},
execute: (input) => Effect.succeed({ output: input, content: "raw" }),
- })
+ }
expect(await Effect.runPromise(execute(declared, { value: "encoded" }, context))).toEqual({
output: { value: "encoded" },
@@ -95,22 +95,22 @@ test("declared outputs cannot bypass validation and raw outputs stay JSON-compat
})
test("execute supports callable namespace tools", async () => {
- const callable: Info = ({
+ const callable: Info = {
name: "admin",
description: "Administer Slack",
input: Schema.Struct({}),
output: Schema.String,
options: { namespace: "slack" },
execute: () => Effect.succeed({ output: "admin" }),
- })
- const child: Info = ({
+ }
+ const child: Info = {
name: "create",
description: "Create a Slack resource",
input: Schema.Struct({}),
output: Schema.String,
options: { namespace: "slack.admin" },
execute: () => Effect.succeed({ output: "created" }),
- })
+ }
const codeMode = createCodeMode(
new Map([
["slack_admin", callable],
@@ -118,10 +118,7 @@ test("execute supports callable namespace tools", async () => {
]),
)
const result = await Effect.runPromise(
- codeMode.execute(
- { code: "return [await tools.slack.admin({}), await tools.slack.admin.create({})]" },
- context,
- ),
+ codeMode.execute({ code: "return [await tools.slack.admin({}), await tools.slack.admin.create({})]" }, context),
)
expect(result.metadata).toEqual({
diff --git a/packages/core/test/tool-output.test.ts b/packages/core/test/tool-output.test.ts
index 1f2c5af2f2cc..962187af64fe 100644
--- a/packages/core/test/tool-output.test.ts
+++ b/packages/core/test/tool-output.test.ts
@@ -147,10 +147,7 @@ describe("ToolOutput", () => {
withStore((output, fs, root) =>
Effect.gen(function* () {
const directory = path.join(root, ToolOutput.DIRECTORY)
- const old = path.join(
- directory,
- Identifier.create("tool", "ascending", Date.now() - 8 * 24 * 60 * 60 * 1_000),
- )
+ const old = path.join(directory, Identifier.create("tool", "ascending", Date.now() - 8 * 24 * 60 * 60 * 1_000))
const recent = path.join(directory, Identifier.ascending("tool"))
yield* fs.ensureDir(directory)
yield* fs.writeFileString(old, "old")
diff --git a/packages/core/test/tool-schema.test.ts b/packages/core/test/tool-schema.test.ts
index 7f3e13e685ae..a24e03b46135 100644
--- a/packages/core/test/tool-schema.test.ts
+++ b/packages/core/test/tool-schema.test.ts
@@ -121,13 +121,13 @@ test("portable schemas validate and describe typed tools", async () => {
},
},
}
- const tool: Info = ({
+ const tool: Info = {
name: "portable",
description: "Portable tool",
input,
output,
execute: ({ count }) => Effect.succeed({ output: count + 1 }),
- })
+ }
expect(definition(tool)).toEqual({
name: "portable",
@@ -170,13 +170,13 @@ test("portable schema failures become tool failures", async () => {
test("canonical results carry metadata with typed output", async () => {
const input = Schema.Struct({ value: Schema.String })
const output = Schema.Struct({ value: Schema.String, internal: Schema.Boolean })
- const tool: Info = ({
+ const tool: Info = {
name: "annotated",
description: "Annotated tool",
input,
output,
execute: ({ value }) => Effect.succeed({ output: { value, internal: true }, metadata: { value }, content: value }),
- })
+ }
expect(await Effect.runPromise(tool.execute({ value: "out" }, {} as Tool.Context))).toEqual({
output: { value: "out", internal: true },
@@ -187,12 +187,12 @@ test("canonical results carry metadata with typed output", async () => {
test("raw JSON schemas are render-only and omitted output means model-only", async () => {
const input = { type: "object", properties: { value: { type: "string" } } }
- const tool: Info = ({
+ const tool: Info = {
name: "raw",
description: "Raw tool",
input,
execute: (input) => Effect.succeed({ content: JSON.stringify(input) }),
- })
+ }
expect(definition(tool)).toEqual({
name: "raw",
diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts
index 9d2f834de847..578009ea167c 100644
--- a/packages/core/test/tool-shell.test.ts
+++ b/packages/core/test/tool-shell.test.ts
@@ -309,27 +309,29 @@ describe("ShellTool", () => {
),
)
- it.live("captures stderr-only and mixed stdout/stderr output", () =>
- Effect.acquireUseRelease(
- Effect.promise(() => tmpdir()),
- (tmp) => {
- reset()
- return withSession(tmp.path, (registry) =>
- Effect.gen(function* () {
- const stderr = yield* executeTool(registry, call({ command: stderrCommand }, "call-stderr"))
- expect(stderr.metadata).toMatchObject({ exit: 0, truncated: false })
- expect(stderr.content?.[0]).toEqual({ type: "text", text: "stderr only" })
-
- const mixed = yield* executeTool(registry, call({ command: mixedOutputCommand }, "call-mixed"))
- expect(mixed.metadata).toMatchObject({ exit: 0, truncated: false })
- const output = mixed.content?.[0]?.type === "text" ? mixed.content[0].text : ""
- expect(output).toContain("stdout")
- expect(output).toContain("stderr")
- }),
- )
- },
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
- ),
+ it.live(
+ "captures stderr-only and mixed stdout/stderr output",
+ () =>
+ Effect.acquireUseRelease(
+ Effect.promise(() => tmpdir()),
+ (tmp) => {
+ reset()
+ return withSession(tmp.path, (registry) =>
+ Effect.gen(function* () {
+ const stderr = yield* executeTool(registry, call({ command: stderrCommand }, "call-stderr"))
+ expect(stderr.metadata).toMatchObject({ exit: 0, truncated: false })
+ expect(stderr.content?.[0]).toEqual({ type: "text", text: "stderr only" })
+
+ const mixed = yield* executeTool(registry, call({ command: mixedOutputCommand }, "call-mixed"))
+ expect(mixed.metadata).toMatchObject({ exit: 0, truncated: false })
+ const output = mixed.content?.[0]?.type === "text" ? mixed.content[0].text : ""
+ expect(output).toContain("stdout")
+ expect(output).toContain("stderr")
+ }),
+ )
+ },
+ (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
+ ),
{ timeout: 15_000 },
)
@@ -390,7 +392,9 @@ describe("ShellTool", () => {
const command = isWindows
? `Set-Location -LiteralPath '${outside.path}'; (Get-Location).Path`
: `cd '${outside.path}' && pwd`
- return withSession(active.path, (registry) => executeTool(registry, call({ command }, "call-external-cd"))).pipe(
+ return withSession(active.path, (registry) =>
+ executeTool(registry, call({ command }, "call-external-cd")),
+ ).pipe(
Effect.andThen(
Effect.sync(() => {
expect(assertions.map((item) => item.action)).toEqual(["external_directory", "shell"])
@@ -476,32 +480,34 @@ describe("ShellTool", () => {
),
)
- it.live("truncates the model view and points at the saved output file when output overflows", () =>
- Effect.acquireUseRelease(
- Effect.promise(() => tmpdir()),
- (tmp) => {
- reset()
- const bytes = ToolOutput.MAX_BYTES + 1024
- return withSession(tmp.path, (registry) =>
- executeTool(registry, call({ command: overflowCommand(bytes) }, "call-overflow")),
- ).pipe(
- Effect.andThen((settled) =>
- Effect.sync(() => {
- expect(settled.metadata).toMatchObject({ exit: 0, truncated: true })
- const content = settled.content?.[0]
- if (!content || content.type !== "text") throw new Error("Expected text content")
- expect(content.text.includes("output-start")).toBe(false)
- expect(content.text.includes("output-end")).toBe(true)
- expect(content).toMatchObject({
- type: "text",
- text: expect.stringContaining("output truncated; full output saved to:"),
- })
- }),
- ),
- )
- },
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
- ),
+ it.live(
+ "truncates the model view and points at the saved output file when output overflows",
+ () =>
+ Effect.acquireUseRelease(
+ Effect.promise(() => tmpdir()),
+ (tmp) => {
+ reset()
+ const bytes = ToolOutput.MAX_BYTES + 1024
+ return withSession(tmp.path, (registry) =>
+ executeTool(registry, call({ command: overflowCommand(bytes) }, "call-overflow")),
+ ).pipe(
+ Effect.andThen((settled) =>
+ Effect.sync(() => {
+ expect(settled.metadata).toMatchObject({ exit: 0, truncated: true })
+ const content = settled.content?.[0]
+ if (!content || content.type !== "text") throw new Error("Expected text content")
+ expect(content.text.includes("output-start")).toBe(false)
+ expect(content.text.includes("output-end")).toBe(true)
+ expect(content).toMatchObject({
+ type: "text",
+ text: expect.stringContaining("output truncated; full output saved to:"),
+ })
+ }),
+ ),
+ )
+ },
+ (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
+ ),
{ timeout: 15_000 },
)
@@ -546,10 +552,7 @@ describe("ShellTool", () => {
Effect.gen(function* () {
const observed = yield* Deferred.make()
yield* executeTool(registry, {
- ...call(
- { command: progressOverflowCommand(ToolOutput.MAX_BYTES + 1024, release) },
- "call-progress",
- ),
+ ...call({ command: progressOverflowCommand(ToolOutput.MAX_BYTES + 1024, release) }, "call-progress"),
progress: (update) =>
Effect.gen(function* () {
if (typeof update.shellID !== "string") return
@@ -591,10 +594,12 @@ describe("ShellTool", () => {
{ timeout: 10_000 },
)
- it.live("returns a useful timeout outcome", () =>
- Effect.acquireUseRelease(
- Effect.promise(() => tmpdir()),
- (tmp) => {
+ it.live(
+ "returns a useful timeout outcome",
+ () =>
+ Effect.acquireUseRelease(
+ Effect.promise(() => tmpdir()),
+ (tmp) => {
reset()
return withSession(tmp.path, (registry) =>
executeTool(registry, call({ command: timeoutOutputCommand, timeout: isWindows ? 3_000 : 50 })),
@@ -607,15 +612,15 @@ describe("ShellTool", () => {
text: expect.stringContaining("before timeout"),
})
expect(settled.content?.[1]).toMatchObject({
- type: "text",
- text: expect.stringContaining("Command timed out"),
- })
- }),
- ),
- )
- },
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
- ),
+ type: "text",
+ text: expect.stringContaining("Command timed out"),
+ })
+ }),
+ ),
+ )
+ },
+ (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
+ ),
{ timeout: 15_000 },
)
diff --git a/packages/core/test/tool-skill.test.ts b/packages/core/test/tool-skill.test.ts
index 842b5ea6aae9..dedc4d56e969 100644
--- a/packages/core/test/tool-skill.test.ts
+++ b/packages/core/test/tool-skill.test.ts
@@ -85,14 +85,11 @@ describe("SkillTool", () => {
list: () => Effect.succeed(current),
}),
)
- const skillToolLayer = AppNodeBuilder.build(
- LayerNode.group([Tool.node, skillToolNode]),
- [
- [Permission.node, permission],
- [Skill.node, skills],
- [Image.node, imagePassthrough],
- ],
- )
+ const skillToolLayer = AppNodeBuilder.build(LayerNode.group([Tool.node, skillToolNode]), [
+ [Permission.node, permission],
+ [Skill.node, skills],
+ [Image.node, imagePassthrough],
+ ])
return yield* Effect.gen(function* () {
const registry = yield* Tool.Service
diff --git a/packages/core/test/util/effect-flock.test.ts b/packages/core/test/util/effect-flock.test.ts
index 436074b503b1..04a223b66e6b 100644
--- a/packages/core/test/util/effect-flock.test.ts
+++ b/packages/core/test/util/effect-flock.test.ts
@@ -146,9 +146,9 @@ describe("util.effect-flock", () => {
Effect.gen(function* () {
yield* flock.acquire(key, dir)
const started = performance.now()
- const error = yield* Effect.scoped(
- flock.acquire(key, dir, { staleMs: 10_000, timeoutMs: 300 }),
- ).pipe(Effect.flip)
+ const error = yield* Effect.scoped(flock.acquire(key, dir, { staleMs: 10_000, timeoutMs: 300 })).pipe(
+ Effect.flip,
+ )
expect(error._tag).toBe("LockTimeoutError")
expect(performance.now() - started).toBeLessThan(1_000)
}),
diff --git a/packages/core/test/vcs-hg.test.ts b/packages/core/test/vcs-hg.test.ts
index 33a6db3e817f..38b43f885bfc 100644
--- a/packages/core/test/vcs-hg.test.ts
+++ b/packages/core/test/vcs-hg.test.ts
@@ -46,7 +46,10 @@ const withHg = (f: (directory: string) => Effect.Effect) =>
)
async function hg(directory: string, ...args: string[]) {
- await $`hg ${args}`.cwd(directory).env({ ...process.env, HGPLAIN: "1" }).quiet()
+ await $`hg ${args}`
+ .cwd(directory)
+ .env({ ...process.env, HGPLAIN: "1" })
+ .quiet()
}
async function commitAll(directory: string, message: string) {
@@ -117,11 +120,9 @@ describeHg("Vcs mercurial", () => {
const bus = yield* Bus.Service
expect(yield* vcs.info()).toEqual({ branch: { current: "default", default: "default" } })
- const updated = yield* bus.subscribe(VcsEvent.BranchUpdated).pipe(
- Stream.take(1),
- Stream.runHead,
- Effect.forkScoped({ startImmediately: true }),
- )
+ const updated = yield* bus
+ .subscribe(VcsEvent.BranchUpdated)
+ .pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
yield* Effect.promise(() => hg(directory, "branch", "-q", "feature"))
expect(yield* vcs.info()).toEqual({ branch: { current: "default", default: "default" } })
diff --git a/packages/core/test/vcs.test.ts b/packages/core/test/vcs.test.ts
index 20e72807d0fc..677fb4f156ad 100644
--- a/packages/core/test/vcs.test.ts
+++ b/packages/core/test/vcs.test.ts
@@ -104,11 +104,9 @@ describe("Vcs", () => {
const bus = yield* Bus.Service
expect(yield* vcs.info()).toEqual({ branch: { current: "main", default: undefined } })
- const updated = yield* bus.subscribe(VcsEvent.BranchUpdated).pipe(
- Stream.take(1),
- Stream.runHead,
- Effect.forkScoped({ startImmediately: true }),
- )
+ const updated = yield* bus
+ .subscribe(VcsEvent.BranchUpdated)
+ .pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
yield* Effect.promise(() => $`git checkout -q -b feature`.cwd(directory).quiet())
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, "HEAD"), event: "change" })