Skip to content

Commit 101f675

Browse files
committed
feat(opencode): add dispatch controls to the task tool
Per-dispatch model override (permission-gated), resume that keeps model and variant, slug task_ids, per-dispatch variant, opaque metadata, an explicit resume consent gate, and timeout with fallback_model.
1 parent 7534d23 commit 101f675

13 files changed

Lines changed: 1320 additions & 19 deletions

File tree

packages/core/src/v1/config/permission.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const InputObject = Schema.StructWithRest(
2828
question: Schema.optional(Action),
2929
webfetch: Schema.optional(Action),
3030
websearch: Schema.optional(Action),
31+
model_override: Schema.optional(Rule),
3132
lsp: Schema.optional(Rule),
3233
doom_loop: Schema.optional(Action),
3334
skill: Schema.optional(Rule),

packages/opencode/src/agent/agent.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ const layer = Layer.effect(
119119
const defaults = Permission.fromConfig({
120120
"*": "allow",
121121
doom_loop: "ask",
122+
model_override: "deny",
122123
external_directory: {
123124
"*": "ask",
124125
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),

packages/opencode/src/provider/transform.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -915,11 +915,18 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
915915
if (model.api.id.toLowerCase().includes("north-mini-code")) {
916916
return Object.fromEntries(["none", "high"].map((effort) => [effort, { reasoningEffort: effort }]))
917917
}
918+
const isDeepseekV4 = model.api.id.toLowerCase().includes("deepseek-v4")
918919
const efforts = [...WIDELY_SUPPORTED_EFFORTS]
919-
if (model.api.id.toLowerCase().includes("deepseek-v4")) {
920+
if (isDeepseekV4) {
920921
efforts.push("max")
921922
}
922-
return Object.fromEntries(efforts.map((effort) => [effort, { reasoningEffort: effort }]))
923+
const result: Record<string, Record<string, any>> = Object.fromEntries(
924+
efforts.map((effort) => [effort, { reasoningEffort: effort }]),
925+
)
926+
if (isDeepseekV4) {
927+
result.none = { thinking: { type: "disabled" } }
928+
}
929+
return result
923930

924931
case "@ai-sdk/azure":
925932
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/azure

packages/opencode/src/session/prompt.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ const layer = Layer.effect(
144144
const ops = Effect.fn("SessionPrompt.ops")(function* () {
145145
return {
146146
cancel: (sessionID: SessionID) => cancel(sessionID),
147+
cancelRun: (sessionID: SessionID) => cancelRun(sessionID),
147148
resolvePromptParts: (template: string) => resolvePromptParts(template),
148149
prompt: (input: PromptInput) => prompt(input).pipe(Effect.catch(Effect.die)),
149150
} satisfies TaskPromptOps
@@ -154,6 +155,10 @@ const layer = Layer.effect(
154155
yield* state.cancel(sessionID)
155156
})
156157

158+
const cancelRun = Effect.fn("SessionPrompt.cancelRun")(function* (sessionID: SessionID) {
159+
yield* state.cancelRun(sessionID)
160+
})
161+
157162
const resolvePromptParts = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) {
158163
const ctx = yield* InstanceState.context
159164
const parts: Types.DeepMutable<PromptInput["parts"]> = [{ type: "text", text: template }]

packages/opencode/src/session/run-state.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { SessionStatus } from "./status"
1111
export interface Interface {
1212
readonly assertNotBusy: (sessionID: SessionID) => Effect.Effect<void, Session.BusyError>
1313
readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
14+
readonly cancelRun: (sessionID: SessionID) => Effect.Effect<void>
1415
readonly ensureRunning: (
1516
sessionID: SessionID,
1617
onInterrupt: Effect.Effect<SessionV1.WithParts>,
@@ -85,6 +86,20 @@ const layer = Layer.effect(
8586
yield* existing.cancel
8687
})
8788

89+
// Runner-only interrupt without cancelling background jobs.
90+
// Exists so the Task tool fallback path can clear a stuck child prompt
91+
// runner before re-prompting the same session, without self-cancelling
92+
// the enclosing background job (the job's id equals the child session id).
93+
const cancelRun = Effect.fn("SessionRunState.cancelRun")(function* (sessionID: SessionID) {
94+
const data = yield* InstanceState.get(state)
95+
const existing = data.runners.get(sessionID)
96+
if (!existing) {
97+
yield* status.set(sessionID, { type: "idle" })
98+
return
99+
}
100+
yield* existing.cancel
101+
})
102+
88103
const ensureRunning = Effect.fn("SessionRunState.ensureRunning")(function* (
89104
sessionID: SessionID,
90105
onInterrupt: Effect.Effect<SessionV1.WithParts>,
@@ -104,7 +119,7 @@ const layer = Layer.effect(
104119
.pipe(Effect.catchTag("RunnerBusy", () => Effect.fail(busyError(sessionID))))
105120
})
106121

107-
return Service.of({ assertNotBusy, cancel, ensureRunning, startShell })
122+
return Service.of({ assertNotBusy, cancel, cancelRun, ensureRunning, startShell })
108123
}),
109124
)
110125

packages/opencode/src/session/session.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,10 +257,15 @@ export const GlobalInfo = Schema.Struct({
257257
}).annotate({ identifier: "GlobalSession" })
258258
export type GlobalInfo = Types.DeepMutable<Schema.Schema.Type<typeof GlobalInfo>>
259259

260+
// session.slug feeds filesystem paths (see plan()); restrict to id/path-safe characters.
261+
export const SESSION_SLUG_PATTERN = /^[a-z0-9][a-z0-9-_]{0,63}$/
262+
260263
export const CreateInput = Schema.optional(
261264
Schema.Struct({
265+
id: Schema.optional(SessionID),
262266
parentID: Schema.optional(SessionID),
263267
title: Schema.optional(Schema.String),
268+
slug: Schema.optional(Schema.String),
264269
agent: Schema.optional(Schema.String),
265270
model: Schema.optional(Model),
266271
metadata: Schema.optional(Metadata),
@@ -416,15 +421,18 @@ export interface Interface {
416421
readonly list: (input?: ListInput) => Effect.Effect<Info[]>
417422
readonly listGlobal: (input?: GlobalListInput) => Effect.Effect<GlobalInfo[]>
418423
readonly create: (input?: {
424+
id?: SessionID
419425
parentID?: SessionID
420426
title?: string
427+
slug?: string
421428
agent?: string
422429
model?: Schema.Schema.Type<typeof Model>
423430
metadata?: typeof Metadata.Type
424431
permission?: PermissionV1.Ruleset
425432
workspaceID?: WorkspaceV2.ID
426433
}) => Effect.Effect<Info>
427434
readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect<Info, NotFound>
435+
readonly root: (sessionID: SessionID) => Effect.Effect<SessionID, NotFound>
428436
readonly touch: (sessionID: SessionID) => Effect.Effect<void>
429437
readonly get: (id: SessionID) => Effect.Effect<Info, NotFound>
430438
readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect<void>
@@ -509,11 +517,14 @@ const layer: Layer.Layer<
509517
path?: string
510518
metadata?: typeof Metadata.Type
511519
permission?: PermissionV1.Ruleset
520+
slug?: string
512521
}) {
522+
if (input.slug !== undefined && !SESSION_SLUG_PATTERN.test(input.slug))
523+
return yield* Effect.die(new Error(`Invalid session slug: "${input.slug}"`))
513524
const ctx = yield* InstanceState.context
514525
const result: Info = {
515526
id: SessionID.descending(input.id),
516-
slug: Slug.create(),
527+
slug: input.slug ?? Slug.create(),
517528
version: InstallationVersion,
518529
projectID: ctx.project.id,
519530
directory: input.directory,
@@ -667,8 +678,10 @@ const layer: Layer.Layer<
667678
})
668679

669680
const create = Effect.fn("Session.create")(function* (input?: {
681+
id?: SessionID
670682
parentID?: SessionID
671683
title?: string
684+
slug?: string
672685
agent?: string
673686
model?: Schema.Schema.Type<typeof Model>
674687
metadata?: typeof Metadata.Type
@@ -678,10 +691,12 @@ const layer: Layer.Layer<
678691
const ctx = yield* InstanceState.context
679692
const workspace = yield* InstanceState.workspaceID
680693
return yield* createNext({
694+
id: input?.id,
681695
parentID: input?.parentID,
682696
directory: ctx.directory,
683697
path: sessionPath(ctx.worktree, ctx.directory),
684698
title: input?.title,
699+
slug: input?.slug,
685700
agent: input?.agent,
686701
model: input?.model,
687702
metadata: input?.metadata,
@@ -733,6 +748,15 @@ const layer: Layer.Layer<
733748
return session
734749
})
735750

751+
const root = Effect.fn("Session.root")(function* (sessionID: SessionID) {
752+
let current = sessionID
753+
while (true) {
754+
const s = yield* get(current)
755+
if (!s.parentID) return current
756+
current = s.parentID
757+
}
758+
})
759+
736760
const patch = (sessionID: SessionID, info: Patch) =>
737761
Effect.gen(function* () {
738762
const current = yield* get(sessionID)
@@ -910,6 +934,7 @@ const layer: Layer.Layer<
910934
listGlobal,
911935
create,
912936
fork,
937+
root,
913938
touch,
914939
get,
915940
setTitle,

0 commit comments

Comments
 (0)