Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
e17b5aa
feat(core): add background-job message channel
iceteaSA Jun 13, 2026
ceefbbf
feat(opencode): add Messaging service for agent-to-agent replies
iceteaSA Jun 13, 2026
a7d5f3a
fix(opencode): forward background-job message channel through wrapper
iceteaSA Jun 13, 2026
725a72f
fix(opencode): make agent-messaging send/message interruption-safe
iceteaSA Jun 13, 2026
8c362c0
feat: add experimentalAgentMessaging flag and message permission key
iceteaSA Jun 13, 2026
41cac5e
feat(opencode): add message tool
iceteaSA Jun 13, 2026
1821586
feat(opencode): register message tool and yield subagent messages to …
iceteaSA Jun 13, 2026
9d56539
fix(opencode): harden agent messaging (authz, send race, body escapin…
iceteaSA Jun 13, 2026
b641b99
fix(opencode): close send publish interruption window; add messaging …
iceteaSA Jun 13, 2026
0a58561
chore(sdk): regenerate for agent messaging
iceteaSA Jun 13, 2026
065e20e
feat(tui): visible transcript markers for agent messages
iceteaSA Jun 15, 2026
8263c9c
fix(opencode): make agent-message markers incoming-only
iceteaSA Jun 15, 2026
89593d6
fix: adapt agent-messaging to current dev APIs
iceteaSA Jul 2, 2026
1eabc6f
refactor(schema): move messaging events to the event manifest
iceteaSA Jul 2, 2026
97a9109
chore(sdk): regenerate
iceteaSA Jul 2, 2026
913b936
fix(opencode): repair message test layer composability for dev APIs
iceteaSA Jul 2, 2026
05dc4a1
feat(opencode): add subagent interrupt (steer/cancel/abort)
iceteaSA Jun 15, 2026
afcb78b
fix: adapt subagent interrupt to current dev APIs
iceteaSA Jul 2, 2026
3b6c298
refactor(schema): move interrupt events to the event manifest
iceteaSA Jul 2, 2026
e110d4a
chore(sdk): regenerate
iceteaSA Jul 2, 2026
2eeb651
feat(opencode): register message tool and yield subagent messages to …
iceteaSA Jun 13, 2026
05ffabb
refactor(tui,session): extract one shared Marker.render({kind}) helpe…
iceteaSA Jun 15, 2026
1b306d7
feat(messaging): slug→SessionID registry + per-child allow-list storage
iceteaSA Jun 15, 2026
4aa001b
feat(messaging): per-session FIFO inbox with budget, cap, LRU dedup, …
iceteaSA Jun 15, 2026
c09cded
feat(messaging): bounded Inbox.await for coordinator fan-in
iceteaSA Jun 15, 2026
6a94adc
feat(message): peer-slug sibling send with allow-list + parentID vali…
iceteaSA Jun 16, 2026
23f479c
test(tool): refresh parameters snapshot for message_allow addition
iceteaSA Jun 16, 2026
97a9bc5
feat(session): drain coordinator inbox at runLoop boundary (batched, …
iceteaSA Jun 16, 2026
04eb996
test(message): e2e collaborative-implementer coordinator-messaging fl…
iceteaSA Jun 16, 2026
f36ec8e
docs(messaging,message): document awaitInbox bounded-behavior + fix b…
iceteaSA Jun 16, 2026
a164d54
refactor(schema): move interrupt events to the event manifest
iceteaSA Jul 2, 2026
c22d4a9
fix: adapt coordinator-messaging to current dev APIs (harness + bridge)
iceteaSA Jul 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 49 additions & 1 deletion packages/core/src/background-job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ import { makeGlobalNode } from "./effect/app-node"

export type Status = "running" | "completed" | "error" | "cancelled"

export type MessagePayload = {
childSessionID: string
parentSessionID: string
body: string
expectReply: boolean
}

export type Info = {
id: string
type: string
Expand All @@ -28,6 +35,7 @@ type Active = {
output?: { sequence: number; text: string }
tail: Deferred.Deferred<void>
promoted: Deferred.Deferred<Info>
messaged: Deferred.Deferred<MessagePayload>
onPromote?: Effect.Effect<void>
}

Expand All @@ -48,6 +56,11 @@ type PromoteResult = {
onPromote?: Effect.Effect<void>
}

type MessageResult = {
info?: Info
messaged?: Deferred.Deferred<MessagePayload>
}

type StartResult = { info: Info } | { info: Info; scope: Scope.Closeable; token: object }

type ExtendResult =
Expand Down Expand Up @@ -92,6 +105,8 @@ export interface Interface {
readonly extend: (input: ExtendInput) => Effect.Effect<boolean>
readonly wait: (input: WaitInput) => Effect.Effect<WaitResult>
readonly waitForPromotion: (id: string) => Effect.Effect<Info>
readonly message: (id: string, payload: MessagePayload) => Effect.Effect<Info | undefined>
readonly waitForMessage: (id: string) => Effect.Effect<MessagePayload>
readonly promote: (id: string) => Effect.Effect<Info | undefined>
readonly cancel: (id: string) => Effect.Effect<Info | undefined>
}
Expand Down Expand Up @@ -206,6 +221,7 @@ export const make = Effect.gen(function* () {
const started_at = yield* Clock.currentTimeMillis
const done = yield* Deferred.make<Info>()
const promoted = yield* Deferred.make<Info>()
const messaged = yield* Deferred.make<MessagePayload>()
const tail = yield* Deferred.make<void>()
const result = yield* SynchronizedRef.modifyEffect(
state.jobs,
Expand All @@ -232,6 +248,7 @@ export const make = Effect.gen(function* () {
next: 1,
tail,
promoted,
messaged,
onPromote: input.onPromote,
}
return [{ info: snapshot(job), scope, token }, new Map(jobs).set(id, job)] as readonly [
Expand Down Expand Up @@ -307,6 +324,37 @@ export const make = Effect.gen(function* () {
return yield* Deferred.await(job.promoted)
})

const message: Interface["message"] = Effect.fn("BackgroundJob.message")(function* (id, payload) {
return yield* Effect.uninterruptible(
Effect.gen(function* () {
const result = yield* SynchronizedRef.modifyEffect(
state.jobs,
Effect.fnUntraced(function* (jobs) {
const job = jobs.get(id)
if (!job || job.info.status !== "running")
return [{} as MessageResult, jobs] as readonly [MessageResult, Map<string, Active>]
const next = {
...job,
info: { ...job.info, metadata: { ...job.info.metadata, messaged: true } },
}
return [
{ info: snapshot(next), messaged: job.messaged },
new Map(jobs).set(id, next),
] as readonly [MessageResult, Map<string, Active>]
}),
)
if (result.info && result.messaged) yield* Deferred.succeed(result.messaged, payload).pipe(Effect.ignore)
return result.info
}),
)
})

const waitForMessage: Interface["waitForMessage"] = Effect.fn("BackgroundJob.waitForMessage")(function* (id) {
const job = (yield* SynchronizedRef.get(state.jobs)).get(id)
if (!job || job.info.status !== "running") return yield* Effect.never
return yield* Deferred.await(job.messaged)
})

const promote: Interface["promote"] = Effect.fn("BackgroundJob.promote")(function* (id) {
const result = yield* SynchronizedRef.modifyEffect(
state.jobs,
Expand Down Expand Up @@ -357,7 +405,7 @@ export const make = Effect.gen(function* () {
return result.info
})

return Service.of({ list, get, start, extend, wait, waitForPromotion, promote, cancel })
return Service.of({ list, get, start, extend, wait, waitForPromotion, message, waitForMessage, promote, cancel })
})

const layer = Layer.effect(Service, make)
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/cross-spawn-spawner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as Effect from "effect/Effect"
import * as Exit from "effect/Exit"
import * as FileSystem from "effect/FileSystem"
import * as Layer from "effect/Layer"
import { LayerNode } from "./effect/layer-node"
import * as Path from "effect/Path"
import * as PlatformError from "effect/PlatformError"
import * as Predicate from "effect/Predicate"
Expand Down Expand Up @@ -497,11 +498,12 @@ export const make = Effect.gen(function* () {
return makeSpawner(spawnCommand)
})

const layer: Layer.Layer<ChildProcessSpawner, never, FileSystem.FileSystem | Path.Path> = Layer.effect(
export const layer: Layer.Layer<ChildProcessSpawner, never, FileSystem.FileSystem | Path.Path> = Layer.effect(
ChildProcessSpawner,
make,
)

export const node = makeGlobalNode({ service: ChildProcessSpawner, layer, deps: [filesystem, path] })
export const defaultLayer = layer

export * as CrossSpawnSpawner from "./cross-spawn-spawner"
4 changes: 4 additions & 0 deletions packages/core/src/v1/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,10 @@ export const Info = Schema.Struct({
mcp_timeout: Schema.optional(PositiveInt).annotate({
description: "Timeout in milliseconds for model context protocol (MCP) requests",
}),
subagent_interrupt: Schema.optional(Schema.Boolean).annotate({
description:
"Enable the subagent interrupt HTTP endpoint and TUI esc-with-reason UX. Server-controlled; reflects the OPENCODE_EXPERIMENTAL_SUBAGENT_INTERRUPT runtime flag.",
}),
policies: Schema.optional(Schema.mutable(Schema.Array(ConfigExperimental.Policy))).annotate({
description: "Policy statements applied to supported resources, such as provider access",
}),
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/v1/config/permission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ const InputObject = Schema.StructWithRest(
external_directory: Schema.optional(Rule),
todowrite: Schema.optional(Action),
question: Schema.optional(Action),
interrupt: Schema.optional(Action),
message: Schema.optional(Action),
webfetch: Schema.optional(Action),
websearch: Schema.optional(Action),
lsp: Schema.optional(Rule),
Expand Down
30 changes: 29 additions & 1 deletion packages/core/test/background-job.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import { BackgroundJob } from "@opencode-ai/core/background-job"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Deferred, Effect, Exit, Scope } from "effect"
import { Deferred, Effect, Exit, Fiber, Scope } from "effect"
import { it } from "./lib/effect"

const jobsLayer = LayerNode.compile(BackgroundJob.node)
Expand Down Expand Up @@ -103,4 +103,32 @@ describe("BackgroundJob", () => {
expect((yield* jobs.get(job.id))?.status).toBe("running")
}),
)

it.live("message - resolves waitForMessage and marks metadata.messaged", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const started = yield* jobs.start({
id: "ses_child_msg",
type: "task",
run: Effect.never,
})
expect(started.status).toBe("running")

const fiber = yield* Effect.forkChild(jobs.waitForMessage("ses_child_msg"))
const payload = {
childSessionID: "ses_child_msg",
parentSessionID: "ses_parent",
body: "need a decision",
expectReply: true,
}
const info = yield* jobs.message("ses_child_msg", payload)
expect(info?.metadata?.messaged).toBe(true)
expect(info?.metadata?.background).toBeUndefined()

const received = yield* Fiber.join(fiber)
expect(received).toEqual(payload)

yield* jobs.cancel("ses_child_msg")
}).pipe(Effect.provide(jobsLayer)),
)
})
3 changes: 2 additions & 1 deletion packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Ag

export const use = serviceUse(Service)

const layer = Layer.effect(
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
Expand Down Expand Up @@ -449,5 +449,6 @@ export const node = LayerNode.make({
layer: layer,
deps: [Config.node, Auth.node, Plugin.node, Skill.node, Provider.node, locationServiceMapNode],
})
export const defaultLayer = layer

export * as Agent from "./agent"
3 changes: 3 additions & 0 deletions packages/opencode/src/background/job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export {
type ExtendInput,
type Info,
type Interface,
type MessagePayload,
type StartInput,
type Status,
type WaitInput,
Expand All @@ -26,6 +27,8 @@ const layer = Layer.effect(
extend: (input) => InstanceState.useEffect(state, (jobs) => jobs.extend(input)),
wait: (input) => InstanceState.useEffect(state, (jobs) => jobs.wait(input)),
waitForPromotion: (id) => InstanceState.useEffect(state, (jobs) => jobs.waitForPromotion(id)),
message: (id, payload) => InstanceState.useEffect(state, (jobs) => jobs.message(id, payload)),
waitForMessage: (id) => InstanceState.useEffect(state, (jobs) => jobs.waitForMessage(id)),
promote: (id) => InstanceState.useEffect(state, (jobs) => jobs.promote(id)),
cancel: (id) => InstanceState.useEffect(state, (jobs) => jobs.cancel(id)),
})
Expand Down
3 changes: 2 additions & 1 deletion packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ function writableGlobal(info: Info) {
return next
}

const layer = Layer.effect(
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
Expand Down Expand Up @@ -677,5 +677,6 @@ export const node = LayerNode.make({
layer: layer,
deps: [FSUtil.node, Auth.node, Account.node, Env.node, Npm.node, httpClient],
})
export const defaultLayer = layer

export * as Config from "./config"
2 changes: 2 additions & 0 deletions packages/opencode/src/effect/app-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { SessionCompaction } from "@/session/compaction"
import { SessionRevert } from "@/session/revert"
import { SessionSummary } from "@/session/summary"
import { SessionPrompt } from "@/session/prompt"
import { Interrupt } from "@/session/interrupt"
import { Instruction } from "@/session/instruction"
import { LLM } from "@/session/llm"
import { LSP } from "@/lsp/lsp"
Expand Down Expand Up @@ -88,6 +89,7 @@ export const AppLayer = AppNodeBuilderV1.build(
SessionRevert.node,
SessionSummary.node,
SessionPrompt.node,
Interrupt.node,
Instruction.node,
LLM.node,
LSP.node,
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/effect/runtime-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
enableQuestionTool: bool("OPENCODE_ENABLE_QUESTION_TOOL"),
experimentalReferences: enabledByExperimental("OPENCODE_EXPERIMENTAL_REFERENCES"),
experimentalBackgroundSubagents: enabledByExperimental("OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS"),
experimentalSubagentInterrupt: enabledByExperimental("OPENCODE_EXPERIMENTAL_SUBAGENT_INTERRUPT"),
experimentalAgentMessaging: enabledByExperimental("OPENCODE_EXPERIMENTAL_AGENT_MESSAGING"),
experimentalLspTy: bool("OPENCODE_EXPERIMENTAL_LSP_TY"),
experimentalLspTool: enabledByExperimental("OPENCODE_EXPERIMENTAL_LSP_TOOL"),
experimentalOxfmt: enabledByExperimental("OPENCODE_EXPERIMENTAL_OXFMT"),
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/event-v2-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,5 +67,6 @@ const layer = Layer.effect(
)

export const node = LayerNode.make({ service: Service, layer: layer, deps: [EventV2.node] })
export const defaultLayer = layer

export * as EventV2Bridge from "./event-v2-bridge"
Loading
Loading