@@ -57,9 +86,11 @@ export function SessionPermissionContent(props: {
-
+
+
+
diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts
index 58ee4a20a..64371d680 100644
--- a/packages/opencode/src/acp/agent.ts
+++ b/packages/opencode/src/acp/agent.ts
@@ -207,7 +207,10 @@ export namespace ACP {
kind: toToolKind(permission.permission),
locations: toLocations(permission.permission, permission.metadata),
},
- options: this.permissionOptions,
+ options:
+ permission.always.length > 0
+ ? this.permissionOptions
+ : this.permissionOptions.filter((option) => option.optionId !== "always"),
})
.catch(async (error) => {
log.error("failed to request permission from ACP", {
diff --git a/packages/opencode/src/session/prompt/pawwork.txt b/packages/opencode/src/session/prompt/pawwork.txt
index b878c313a..866f15a97 100644
--- a/packages/opencode/src/session/prompt/pawwork.txt
+++ b/packages/opencode/src/session/prompt/pawwork.txt
@@ -51,7 +51,9 @@ If the user has already specified a path, execute it directly without re-asking.
# Scheduling, reminders, and recurring work
-When the user asks to do something later, be reminded, send something at a specific time, or repeat work on a schedule, create a PawWork Automation with the `automate` tool — for one-time and recurring tasks alike. Automations appear in the Automations panel, can be paused or deleted there, and run with the session's project context, model, and credentials.
+When the user asks to do something later, be reminded, send something at a specific time, or repeat work on a schedule, create a PawWork Automation with the `automate` tool — for one-time and recurring tasks alike. Automations appear in the Automations panel and run with the session's project context, model, and credentials.
+
+When the user asks to list, pause, resume, delete, remove, or cancel an existing PawWork Automation, activate `automate_manage` via `tool_info` and manage it there. Do not send the user away to the Automations panel unless they explicitly want to use the UI.
Never install OS-level schedulers for these requests with any tool: no `at`, `cron`, `crontab`, `launchd` or LaunchAgents plists, systemd timers, `schtasks`, background scripts, or sleep loops — neither by running commands nor by writing files. Use other tools only to gather information the scheduled prompt will need. OS schedulers are acceptable only when the user explicitly asks for a system-level scheduler outside PawWork.
diff --git a/packages/opencode/src/tool/automate-manage.ts b/packages/opencode/src/tool/automate-manage.ts
new file mode 100644
index 000000000..f58ed6b60
--- /dev/null
+++ b/packages/opencode/src/tool/automate-manage.ts
@@ -0,0 +1,137 @@
+import { Cause, Effect, Schema } from "effect"
+import { ActiveRunStillRunningError, Automation } from "@/automation"
+import { NotFoundError } from "@/storage/db"
+import * as Tool from "./tool"
+
+const Action = Schema.Literals(["list", "pause", "resume", "delete"])
+
+export const AutomateManageParameters = Schema.Struct({
+ action: Action.annotate({
+ description:
+ 'Management action for an existing PawWork Automation: "list", "pause", "resume", or "delete".',
+ }),
+ id: Schema.optional(Schema.String).annotate({
+ description:
+ "Exact automation id from automate_manage list or an automate creation result. Required for pause, resume, and delete; omit for list.",
+ }),
+})
+
+type Parameters = Schema.Schema.Type
+type Metadata = {
+ automationDefinitions?: Automation.Definition[]
+ automationDefinition?: Automation.Definition
+ automationTombstone?: Automation.Tombstone
+ stoppedRun?: Automation.Run
+}
+
+function schedule(definition: Automation.Definition) {
+ if (definition.kind === "oneshot") return new Date(definition.fireAt).toISOString()
+ if (definition.rhythm.kind === "cron") return definition.rhythm.expression
+ return `every ${definition.rhythm.everyMs}ms`
+}
+
+function item(definition: Automation.Definition) {
+ return {
+ id: definition.id,
+ title: definition.title,
+ kind: definition.kind,
+ paused: definition.paused,
+ schedule: schedule(definition),
+ timezone: definition.timezone,
+ context: definition.context,
+ nextFireAt: definition.kind === "recurring" ? definition.nextFireAt : undefined,
+ }
+}
+
+function requireID(params: Parameters) {
+ if (params.id) return Effect.succeed(params.id)
+ return Effect.fail(new Error(`automate_manage action "${params.action}" requires an exact automation id.`))
+}
+
+function readableAutomationError(error: unknown, id: string) {
+ if (NotFoundError.isInstance(error)) {
+ return new Error(`Automation not found: ${id}. Run automate_manage list to get a current id.`, { cause: error })
+ }
+ if (error instanceof ActiveRunStillRunningError) {
+ return new Error(
+ `Cannot delete automation ${id}: active_run_still_running (${error.runID}). Try again after the active run finishes.`,
+ { cause: error },
+ )
+ }
+ return error
+}
+
+function readableAutomationEffect(effect: Effect.Effect, id: string) {
+ return effect.pipe(
+ Effect.catchCause((cause) => {
+ const error = Cause.squash(cause)
+ const readable = readableAutomationError(error, id)
+ if (readable === error) return Effect.failCause(cause)
+ return Effect.fail(readable)
+ }),
+ )
+}
+
+function getAutomation(automation: Automation.Interface, id: string) {
+ return readableAutomationEffect(automation.get(id), id)
+}
+
+export function createAutomateManageDefinition(
+ automation: Automation.Interface,
+): Tool.DefWithoutID {
+ return {
+ description: [
+ "Manage existing PawWork Automations in the current context. Use this when the user asks to show scheduled tasks, list reminders, pause an automation, resume an automation, or delete/remove/cancel an automation. Never use OS schedulers (crontab, cron, at, launchd, schtasks) to manage PawWork Automations.",
+ "Use action list first when the user has not provided an exact automation id. Pause and resume are reversible and do not need confirmation. Delete is destructive and must ask the user for confirmation before removing anything.",
+ ].join("\n\n"),
+ parameters: AutomateManageParameters,
+ execute: (params, ctx) =>
+ Effect.gen(function* () {
+ if (params.action === "list") {
+ const items = yield* automation.list()
+ return {
+ title: "Automations",
+ metadata: { automationDefinitions: items },
+ output: JSON.stringify({ items: items.map(item) }, null, 2),
+ }
+ }
+
+ const id = yield* requireID(params)
+ const previous = yield* getAutomation(automation, id)
+ if (params.action === "pause" || params.action === "resume") {
+ const definition = yield* readableAutomationEffect(automation.update(id, { paused: params.action === "pause" }), id)
+ if (definition.revision !== previous.revision) {
+ yield* automation.publishDefinitionUpdated(definition)
+ }
+ return {
+ title: params.action === "pause" ? "Automation paused" : "Automation resumed",
+ metadata: { automationDefinition: definition },
+ output: JSON.stringify(item(definition), null, 2),
+ }
+ }
+
+ yield* ctx.ask({
+ permission: "automate_manage",
+ patterns: [id],
+ always: [],
+ metadata: { action: "delete", id, title: previous.title },
+ })
+ const removed = yield* readableAutomationEffect(automation.remove(id), id)
+ if (removed.stoppedRun) yield* automation.publishRunUpdated(removed.stoppedRun)
+ yield* automation.publishDefinitionDeleted(removed.tombstone)
+ return {
+ title: "Automation deleted",
+ metadata: { automationTombstone: removed.tombstone, stoppedRun: removed.stoppedRun },
+ output: JSON.stringify(removed.tombstone, null, 2),
+ }
+ }),
+ }
+}
+
+export const AutomateManageTool = Tool.define(
+ "automate_manage",
+ Effect.gen(function* () {
+ const automation = yield* Automation.Service
+ return createAutomateManageDefinition(automation)
+ }),
+)
diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts
index 13c1e11b8..9f6b59ad5 100644
--- a/packages/opencode/src/tool/registry.ts
+++ b/packages/opencode/src/tool/registry.ts
@@ -42,6 +42,7 @@ import { ApplyPatchTool } from "./apply_patch"
import { EnterWorktreeTool } from "./enter-worktree"
import { ExitWorktreeTool } from "./exit-worktree"
import { AutomateTool } from "./automate"
+import { AutomateManageTool } from "./automate-manage"
import { Automation } from "@/automation"
import { Permission } from "../permission"
import { Glob } from "../util/glob"
@@ -164,6 +165,7 @@ export namespace ToolRegistry {
const enterWorktree = yield* EnterWorktreeTool
const exitWorktree = yield* ExitWorktreeTool
const automate = yield* AutomateTool
+ const automateManage = yield* AutomateManageTool
const browserNavigate = yield* BrowserNavigateTool
const browserSnapshot = yield* BrowserSnapshotTool
const browserClick = yield* BrowserClickTool
@@ -336,6 +338,7 @@ export namespace ToolRegistry {
enterWorktree: Tool.init(enterWorktree),
exitWorktree: Tool.init(exitWorktree),
automate: Tool.init(automate),
+ automateManage: Tool.init(automateManage),
toolInfo: Tool.init(toolInfoInfo),
browserNavigate: Tool.init(browserNavigate),
browserSnapshot: Tool.init(browserSnapshot),
@@ -371,6 +374,7 @@ export namespace ToolRegistry {
...(lspEnabled ? [tool.lsp] : []),
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [tool.plan] : []),
tool.automate,
+ tool.automateManage,
tool.enterWorktree,
tool.exitWorktree,
// Desktop-only: the embedded browser lives in the desktop app's
diff --git a/packages/opencode/src/tool/shell.txt b/packages/opencode/src/tool/shell.txt
index 9205bad27..9cbcec5a4 100644
--- a/packages/opencode/src/tool/shell.txt
+++ b/packages/opencode/src/tool/shell.txt
@@ -38,6 +38,7 @@ Usage notes:
- Write files: Use Write (NOT echo >/cat < {
})
})
+ test("permission.asked without always patterns omits the ACP always option", async () => {
+ await using tmp = await tmpdir()
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const { agent, controller, permissionRequests, stop } = createFakeAgent()
+ const cwd = "/tmp/opencode-acp-test"
+ const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
+
+ controller.push({
+ directory: cwd,
+ payload: {
+ type: "permission.asked",
+ properties: {
+ id: "perm_once_only",
+ sessionID: sessionId,
+ permission: "automate_manage",
+ patterns: ["aut_123"],
+ metadata: { action: "delete", id: "aut_123", title: "Daily repo brief" },
+ always: [],
+ },
+ },
+ } as any)
+
+ await new Promise((r) => setTimeout(r, 20))
+
+ const request = permissionRequests.find((item) => item.sessionId === sessionId)
+ expect(request?.options.map((option) => option.optionId)).toEqual(["once", "reject"])
+ stop()
+ },
+ })
+ })
+
+ test("permission.asked with always patterns includes the ACP always option", async () => {
+ await using tmp = await tmpdir()
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const { agent, controller, permissionRequests, stop } = createFakeAgent()
+ const cwd = "/tmp/opencode-acp-test"
+ const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
+
+ controller.push({
+ directory: cwd,
+ payload: {
+ type: "permission.asked",
+ properties: {
+ id: "perm_persistable",
+ sessionID: sessionId,
+ permission: "bash",
+ patterns: ["echo ok"],
+ metadata: {},
+ always: ["echo ok"],
+ },
+ },
+ } as any)
+
+ await new Promise((r) => setTimeout(r, 20))
+
+ const request = permissionRequests.find((item) => item.sessionId === sessionId)
+ expect(request?.options.map((option) => option.optionId)).toEqual(["once", "always", "reject"])
+ stop()
+ },
+ })
+ })
+
test("permission prompt on session A does not block message updates for session B", async () => {
await using tmp = await tmpdir()
await Instance.provide({
diff --git a/packages/opencode/test/server/automation-scheduler.test.ts b/packages/opencode/test/server/automation-scheduler.test.ts
index ebad3113b..e5b8e9394 100644
--- a/packages/opencode/test/server/automation-scheduler.test.ts
+++ b/packages/opencode/test/server/automation-scheduler.test.ts
@@ -3,6 +3,7 @@ import { Effect, ManagedRuntime } from "effect"
import { Automation } from "../../src/automation"
import { internalTestHooks } from "../../src/automation/__test_hooks"
import { AutomationScheduler } from "../../src/automation/scheduler"
+import { Bus } from "../../src/bus"
import { Instance } from "../../src/project/instance"
import { ProjectID } from "../../src/project/schema"
import { trackActiveRun } from "../../src/session/lifecycle-provenance"
@@ -751,6 +752,28 @@ describe("automation scheduler", () => {
})
})
+ test("cancels scheduled automation when a definition deleted event arrives", async () => {
+ await withAutomation(async (projectID) => {
+ const clock = new FakeClock(0)
+ const calls: number[] = []
+ const scheduler = AutomationScheduler.make({
+ clock,
+ executor: async () => {
+ calls.push(clock.now())
+ return { sessionID: SessionID.descending(), result: "done", cost: 0 }
+ },
+ })
+ const definition = Automation.create(oneshotInput(projectID, 1_000), { now: 0 })
+
+ scheduler.reschedule(definition)
+ await Bus.publish(Automation.Event.DefinitionDeleted, { id: definition.id, deleted: true, revision: 2 })
+ await clock.advance(1_000)
+
+ expect(calls).toEqual([])
+ scheduler.stop()
+ })
+ })
+
test("keeps recurring automation scheduled after an active manual run blocks a fire", async () => {
await withAutomation(async (projectID) => {
const clock = new FakeClock(0)
diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts
index cb73683dd..65c818258 100644
--- a/packages/opencode/test/session/prompt-effect.test.ts
+++ b/packages/opencode/test/session/prompt-effect.test.ts
@@ -44,6 +44,7 @@ import { Shell } from "../../src/shell/shell"
import { Snapshot } from "../../src/snapshot"
import { ToolRegistry } from "../../src/tool/registry"
import { Automation } from "../../src/automation"
+import { AutomationScheduler } from "../../src/automation/scheduler"
import { WebSearchAuth } from "../../src/tool/websearch-auth"
import { Truncate } from "../../src/tool/truncate"
import { Log } from "@opencode-ai/core/util/log"
@@ -148,6 +149,18 @@ function requestTextContaining(inputs: Record[], needle: string
return flattenRequestText(match)
}
+function requestToolNames(input: Record) {
+ const tools = Array.isArray(input.tools) ? input.tools : []
+ return tools.flatMap((tool) => {
+ const fn =
+ tool && typeof tool === "object" && "function" in tool && tool.function && typeof tool.function === "object"
+ ? tool.function
+ : undefined
+ if (!fn || !("name" in fn) || typeof fn.name !== "string") return []
+ return [fn.name]
+ })
+}
+
function envValue(text: string, label: string) {
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
const match = text.match(new RegExp(`^ ${escaped}: (.+)$`, "m"))
@@ -1000,6 +1013,56 @@ it.live("loop continues when finish is tool-calls", () =>
),
)
+it.live("loop activates automate_manage through tool_info before invoking it", () =>
+ provideTmpdirServer(
+ Effect.fnUntraced(function* ({ llm }) {
+ try {
+ const prompt = yield* SessionPrompt.Service
+ const sessions = yield* Session.Service
+ const session = yield* sessions.create({
+ title: "Automation manage activation",
+ permission: [{ permission: "*", pattern: "*", action: "allow" }],
+ })
+ yield* prompt.prompt({
+ sessionID: session.id,
+ agent: "build",
+ noReply: true,
+ parts: [{ type: "text", text: "List my existing PawWork Automations." }],
+ })
+
+ yield* llm.tool("tool_info", { name: "automate_manage" })
+ yield* llm.tool("automate_manage", { action: "list" })
+ yield* llm.text("done")
+
+ const result = yield* prompt.loop({ sessionID: session.id })
+ expect(result.info.role).toBe("assistant")
+
+ const requests = yield* llm.inputs
+ expect(requests.length).toBeGreaterThanOrEqual(2)
+ const [firstRequest, secondRequest] = requests
+ expect(requestToolNames(firstRequest)).toContain("tool_info")
+ expect(requestToolNames(firstRequest)).not.toContain("automate_manage")
+ expect(requestToolNames(secondRequest)).toContain("automate_manage")
+
+ const allMessages = yield* MessageV2.filterCompactedEffect(session.id)
+ const toolParts = allMessages.flatMap((message) =>
+ message.parts.filter((part): part is MessageV2.ToolPart => part.type === "tool"),
+ )
+ const toolInfo = toolParts.find((part) => part.tool === "tool_info")
+ const automateManage = toolParts.find((part) => part.tool === "automate_manage")
+ expect(toolInfo?.state.status).toBe("completed")
+ expect(automateManage?.state.status).toBe("completed")
+ if (automateManage?.state.status === "completed") {
+ expect(JSON.parse(automateManage.state.output)).toEqual({ items: [] })
+ }
+ } finally {
+ AutomationScheduler.stopProcess({ stopRuns: false })
+ }
+ }),
+ { git: true, config: providerCfg },
+ ),
+)
+
itWithExaQuota.live("websearch failures surface Exa recovery copy instead of cleanup abort", () =>
provideTmpdirServer(
({ llm }) =>
diff --git a/packages/opencode/test/tool/automate-manage.test.ts b/packages/opencode/test/tool/automate-manage.test.ts
new file mode 100644
index 000000000..402592f24
--- /dev/null
+++ b/packages/opencode/test/tool/automate-manage.test.ts
@@ -0,0 +1,338 @@
+import { afterEach, describe, expect, test } from "bun:test"
+import { Effect, ManagedRuntime, Schema } from "effect"
+import { Automation } from "../../src/automation"
+import { AutomationScheduler } from "../../src/automation/scheduler"
+import { Bus } from "../../src/bus"
+import { Instance } from "../../src/project/instance"
+import { ProjectID } from "../../src/project/schema"
+import { MessageID, SessionID } from "../../src/session/schema"
+import { AutomateManageParameters, createAutomateManageDefinition } from "../../src/tool/automate-manage"
+import { toJsonSchema } from "../../src/util/effect-zod"
+import { Flock } from "../../src/util/flock"
+import { tmpdir } from "../fixture/fixture"
+import { fakeAutomationProvider } from "../fake/provider"
+
+const { providerID, modelID } = fakeAutomationProvider()
+const runtime = ManagedRuntime.make(Automation.defaultLayer)
+const automation = await runtime.runPromise(Effect.gen(function* () {
+ return yield* Automation.Service
+}))
+
+function recurring(projectID: ProjectID, title: string): Automation.CreateInput {
+ return {
+ kind: "recurring",
+ title,
+ prompt: `Run ${title}.`,
+ context: "fresh",
+ where: { projectID },
+ timezone: "UTC",
+ model: { providerID, modelID },
+ rhythm: { kind: "cron", expression: "0 9 * * *" },
+ stop: { kind: "never" },
+ }
+}
+
+function installScheduler(cancelled: string[] = [], settled: string[] = []) {
+ AutomationScheduler.install({
+ stop: () => undefined,
+ settleOwner: async () => { settled.push("settled") },
+ reschedule: () => undefined,
+ cancel: (automationID) => cancelled.push(automationID),
+ computeNextFireAt: () => null,
+ })
+}
+
+function tool() {
+ return createAutomateManageDefinition(automation)
+}
+
+function toolContext(asks: unknown[] = []) {
+ return {
+ sessionID: SessionID.descending(),
+ messageID: MessageID.ascending(),
+ agent: "build",
+ abort: new AbortController().signal,
+ messages: [],
+ metadata: () => Effect.void,
+ ask: (input: unknown) => Effect.sync(() => { asks.push(input) }),
+ }
+}
+
+afterEach(async () => {
+ AutomationScheduler.stopProcess({ stopRuns: false })
+ await Instance.disposeAll()
+})
+
+describe("automate_manage tool", () => {
+ test("schema keeps the model-facing management surface flat and exact-id based", () => {
+ const decoded = Schema.decodeUnknownSync(AutomateManageParameters)({
+ action: "pause",
+ id: "aut_123",
+ paused: true,
+ where: { projectID: "spoofed" },
+ })
+
+ expect(decoded).toEqual({ action: "pause", id: "aut_123" })
+ })
+
+ test("description and schema carry the model-facing management contract", () => {
+ const definition = tool()
+ expect(definition.description).toContain("current context")
+ expect(definition.description).toContain("exact automation id")
+ expect(definition.description).toContain("confirmation")
+ expect(definition.description).toContain("Never use OS schedulers")
+ for (const action of ["list", "pause", "resume", "delete"]) {
+ expect(definition.description).toContain(action)
+ }
+
+ const schema = toJsonSchema(AutomateManageParameters) as {
+ properties: Record
+ }
+ expect(schema.properties.action.description).toContain("list")
+ expect(schema.properties.action.description).toContain("pause")
+ expect(schema.properties.action.description).toContain("resume")
+ expect(schema.properties.action.description).toContain("delete")
+ expect(schema.properties.id.description).toContain("Exact automation id")
+ expect(schema.properties.id.description).toContain("pause")
+ expect(schema.properties.id.description).toContain("resume")
+ expect(schema.properties.id.description).toContain("delete")
+ })
+
+ test("lists current-scope automations with ids and schedules", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ installScheduler()
+ const created = Automation.create(recurring(Instance.project.id, "Daily repo brief"), { now: 100 })
+
+ const result = await Effect.runPromise(tool().execute({ action: "list" }, toolContext()))
+ const output = JSON.parse(result.output)
+
+ expect(result.title).toBe("Automations")
+ expect(output.items).toEqual([
+ expect.objectContaining({
+ id: created.id,
+ title: "Daily repo brief",
+ paused: false,
+ schedule: "0 9 * * *",
+ timezone: "UTC",
+ }),
+ ])
+ },
+ })
+ })
+
+ test("list reads definitions without settling the scheduler owner", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const settled: string[] = []
+ installScheduler([], settled)
+ Automation.create(recurring(Instance.project.id, "Daily repo brief"), { now: 100 })
+
+ await Effect.runPromise(tool().execute({ action: "list" }, toolContext()))
+
+ expect(settled).toEqual([])
+ },
+ })
+ })
+
+ test("pause and resume update by exact id without asking for confirmation", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ installScheduler()
+ const asks: unknown[] = []
+ const created = Automation.create(recurring(Instance.project.id, "Daily repo brief"), { now: 100 })
+
+ const paused = await Effect.runPromise(tool().execute({ action: "pause", id: created.id }, toolContext(asks)))
+ expect(paused.title).toBe("Automation paused")
+ expect(paused.metadata.automationDefinition).toMatchObject({ id: created.id, paused: true, revision: 2 })
+ expect(Automation.get(created.id).paused).toBe(true)
+
+ const resumed = await Effect.runPromise(tool().execute({ action: "resume", id: created.id }, toolContext(asks)))
+ expect(resumed.title).toBe("Automation resumed")
+ expect(resumed.metadata.automationDefinition).toMatchObject({ id: created.id, paused: false, revision: 3 })
+ expect(Automation.get(created.id).paused).toBe(false)
+ expect(asks).toEqual([])
+ },
+ })
+ })
+
+ test("delete asks once, publishes deletion, and removes the automation", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const asks: unknown[] = []
+ const deletedEvents: Automation.Tombstone[] = []
+ const unsubscribe = Bus.subscribe(Automation.Event.DefinitionDeleted, (event) => {
+ deletedEvents.push(event.properties)
+ })
+ installScheduler()
+ const created = Automation.create(recurring(Instance.project.id, "Daily repo brief"), { now: 100 })
+
+ try {
+ const result = await Effect.runPromise(tool().execute({ action: "delete", id: created.id }, toolContext(asks)))
+ expect(result.title).toBe("Automation deleted")
+ expect(result.metadata.automationTombstone).toEqual({ id: created.id, deleted: true, revision: 2 })
+ expect(JSON.parse(result.output)).toEqual({ id: created.id, deleted: true, revision: 2 })
+ } finally {
+ unsubscribe()
+ }
+
+ expect(deletedEvents).toEqual([{ id: created.id, deleted: true, revision: 2 }])
+ expect(asks).toEqual([
+ {
+ permission: "automate_manage",
+ patterns: [created.id],
+ always: [],
+ metadata: { action: "delete", id: created.id, title: "Daily repo brief" },
+ },
+ ])
+ expect(Automation.list()).toEqual([])
+ },
+ })
+ })
+
+ test("non-list actions require an exact automation id", async () => {
+ installScheduler()
+ await expect(Effect.runPromise(tool().execute({ action: "pause" }, toolContext()))).rejects.toThrow(
+ 'automate_manage action "pause" requires an exact automation id.',
+ )
+ })
+
+ test("pause reports stale automation ids as a readable relist error", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ installScheduler()
+
+ await expect(
+ Effect.runPromise(tool().execute({ action: "pause", id: "aut_missing" }, toolContext())),
+ ).rejects.toThrow("Automation not found: aut_missing. Run automate_manage list to get a current id.")
+ },
+ })
+ })
+
+ test.each(["pause", "resume"] as const)(
+ "%s reports update-time stale ids as a readable relist error",
+ async (action) => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ installScheduler()
+ const created = Automation.create(recurring(Instance.project.id, "Daily repo brief"), { now: 100 })
+ const racingAutomation: Automation.Interface = {
+ ...automation,
+ update: (id, patch, options) =>
+ Effect.gen(function* () {
+ yield* Effect.promise(() => Automation.remove(created.id))
+ return yield* automation.update(id, patch, options)
+ }),
+ }
+ const racingTool = createAutomateManageDefinition(racingAutomation)
+
+ await expect(Effect.runPromise(racingTool.execute({ action, id: created.id }, toolContext()))).rejects.toThrow(
+ `Automation not found: ${created.id}. Run automate_manage list to get a current id.`,
+ )
+ },
+ })
+ },
+ )
+
+ test("delete rejects stale automation ids before asking or removing anything", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ installScheduler()
+ const asks: unknown[] = []
+ const created = Automation.create(recurring(Instance.project.id, "Daily repo brief"), { now: 100 })
+
+ await expect(
+ Effect.runPromise(tool().execute({ action: "delete", id: "aut_missing" }, toolContext(asks))),
+ ).rejects.toThrow("Automation not found: aut_missing. Run automate_manage list to get a current id.")
+
+ expect(asks).toEqual([])
+ expect(Automation.list().map((definition) => definition.id)).toEqual([created.id])
+ },
+ })
+ })
+
+ test("delete reports confirmation-time stale ids as a readable relist error", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ installScheduler()
+ const asks: unknown[] = []
+ const deletedEvents: Automation.Tombstone[] = []
+ const unsubscribe = Bus.subscribe(Automation.Event.DefinitionDeleted, (event) => {
+ deletedEvents.push(event.properties)
+ })
+ const created = Automation.create(recurring(Instance.project.id, "Daily repo brief"), { now: 100 })
+ const ctx = {
+ ...toolContext(asks),
+ ask: (input: unknown) =>
+ Effect.promise(async () => {
+ asks.push(input)
+ await Automation.remove(created.id)
+ }),
+ }
+
+ try {
+ await expect(Effect.runPromise(tool().execute({ action: "delete", id: created.id }, ctx))).rejects.toThrow(
+ `Automation not found: ${created.id}. Run automate_manage list to get a current id.`,
+ )
+ } finally {
+ unsubscribe()
+ }
+
+ expect(deletedEvents).toEqual([])
+ },
+ })
+ })
+
+ test("delete preserves the automation when a live active run is still running", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ installScheduler()
+ const created = Automation.create(recurring(Instance.project.id, "Daily repo brief"), { now: 100 })
+ const active = Automation.runNow(created.id, { now: 200 })
+ await using _lease = await Flock.acquire(`automation-run:${Instance.directory}:${active.id}`)
+
+ await expect(
+ Effect.runPromise(tool().execute({ action: "delete", id: created.id }, toolContext())),
+ ).rejects.toThrow(`Cannot delete automation ${created.id}: active_run_still_running (${active.id})`)
+
+ expect(Automation.get(created.id).id).toBe(created.id)
+ },
+ })
+ })
+
+ test("delete stops before removal when the confirmation is denied", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ installScheduler()
+ const created = Automation.create(recurring(Instance.project.id, "Daily repo brief"), { now: 100 })
+ const ctx = { ...toolContext(), ask: () => Effect.die(new Error("denied")) }
+
+ await expect(Effect.runPromise(tool().execute({ action: "delete", id: created.id }, ctx))).rejects.toThrow(
+ "denied",
+ )
+
+ expect(Automation.get(created.id).id).toBe(created.id)
+ },
+ })
+ })
+})
diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts
index 820ffdd13..75842b97f 100644
--- a/packages/opencode/test/tool/registry.test.ts
+++ b/packages/opencode/test/tool/registry.test.ts
@@ -66,8 +66,48 @@ describe("tool.registry", () => {
})
const surface = tools.map((tool) => tool.id)
expect(surface).toContain("automate")
+ expect(surface).not.toContain("automate_manage")
const card = tools.find((tool) => tool.id === "tool_info")!.description
- expect(card).not.toContain("automate")
+ expect(card).not.toContain("**automate**")
+ expect(card).toContain("**automate_manage**")
+ },
+ })
+ })
+ })
+
+ test("defers automate_manage until activated while keeping automate resident", async () => {
+ await using tmp = await tmpdir()
+
+ await withMockedConfigInstall(async () => {
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const base = {
+ providerID: ProviderID.make("openai"),
+ modelID: ModelID.make("gpt-5"),
+ agent: { name: "build", mode: "primary" as const, permission: [], options: {} },
+ }
+
+ const def = await ToolRegistry.tools(base)
+ const defIds = def.map((tool) => tool.id)
+ expect(defIds).toContain("automate")
+ expect(defIds).not.toContain("automate_manage")
+ expect(def.find((tool) => tool.id === "tool_info")!.description).toContain("**automate_manage**")
+
+ const act = await ToolRegistry.tools({ ...base, activatedTools: new Set(["automate_manage"]) })
+ const actIds = act.map((tool) => tool.id)
+ expect(actIds).toContain("automate")
+ expect(actIds).toContain("automate_manage")
+ expect(act.find((tool) => tool.id === "tool_info")!.description).not.toContain("**automate_manage**")
+
+ const denied = await ToolRegistry.tools({
+ ...base,
+ activatedTools: new Set(["automate_manage"]),
+ deferredAvailable: () => false,
+ })
+ expect(denied.map((tool) => tool.id)).toContain("automate")
+ expect(denied.map((tool) => tool.id)).not.toContain("automate_manage")
+ expect(denied.find((tool) => tool.id === "tool_info")!.description).toContain("No deferred tools")
},
})
})
@@ -108,10 +148,12 @@ describe("tool.registry", () => {
test("keeps scheduling routing contract across prompt surfaces", async () => {
const shellDescription = await Bun.file(new URL("../../src/tool/shell.txt", import.meta.url)).text()
expect(shellDescription).toContain("Scheduled or delayed tasks: Use the automate tool")
+ expect(shellDescription).toContain("Existing PawWork Automations: Use automate_manage via tool_info")
const systemPrompt = await Bun.file(new URL("../../src/session/prompt/pawwork.txt", import.meta.url)).text()
expect(systemPrompt).toContain("# Scheduling, reminders, and recurring work")
expect(systemPrompt).toContain("`automate` tool")
+ expect(systemPrompt).toContain("`automate_manage`")
expect(systemPrompt).toContain("launchd")
expect(systemPrompt).toContain("by writing files")
})
@@ -1183,12 +1225,14 @@ describe("tool.registry", () => {
})
const defIds = def.map((tool) => tool.id)
expect(defIds).toContain("automate")
+ expect(defIds).not.toContain("automate_manage")
expect(defIds).not.toContain("enter-worktree")
expect(defIds).not.toContain("exit-worktree")
expect(defIds).not.toContain("lsp")
expect(defIds).toContain("tool_info")
const card = def.find((tool) => tool.id === "tool_info")!.description
- expect(card).not.toContain("automate")
+ expect(card).not.toContain("**automate**")
+ expect(card).toContain("automate_manage")
expect(card).toContain("enter-worktree")
expect(card).toContain("exit-worktree")
expect(card).toContain("lsp")
@@ -1202,11 +1246,13 @@ describe("tool.registry", () => {
})
const actIds = act.map((tool) => tool.id)
expect(actIds).toContain("automate")
+ expect(actIds).not.toContain("automate_manage")
expect(actIds).toContain("enter-worktree")
expect(actIds).not.toContain("exit-worktree")
expect(actIds).toContain("lsp")
const actCard = act.find((tool) => tool.id === "tool_info")!.description
- expect(actCard).not.toContain("automate")
+ expect(actCard).not.toContain("**automate**")
+ expect(actCard).toContain("automate_manage")
expect(actCard).not.toContain("enter-worktree")
expect(actCard).toContain("exit-worktree")
expect(actCard).not.toContain("lsp")
@@ -1220,6 +1266,7 @@ describe("tool.registry", () => {
deferredAvailable: () => false,
})
expect(denied.map((tool) => tool.id)).toContain("automate")
+ expect(denied.map((tool) => tool.id)).not.toContain("automate_manage")
expect(denied.map((tool) => tool.id)).not.toContain("enter-worktree")
expect(denied.map((tool) => tool.id)).not.toContain("lsp")
expect(denied.find((tool) => tool.id === "tool_info")!.description).toContain("No deferred tools")
diff --git a/packages/opencode/test/tool/tool-info.test.ts b/packages/opencode/test/tool/tool-info.test.ts
index 9e9aa3f54..3d9eafb0e 100644
--- a/packages/opencode/test/tool/tool-info.test.ts
+++ b/packages/opencode/test/tool/tool-info.test.ts
@@ -39,9 +39,9 @@ function assistant(parts: unknown[]): MessageV2.WithParts {
}
describe("tool-info", () => {
- test("DEFERRED_TOOL_IDS is exactly the worktree tools plus lsp plus the browser and opencli groups", () => {
+ test("DEFERRED_TOOL_IDS is exactly the standalone deferred tools plus the browser and opencli groups", () => {
expect([...DEFERRED_TOOL_IDS].sort()).toEqual(
- [...BROWSER_TOOLS, ...OPENCLI_TOOLS, "enter-worktree", "exit-worktree", "lsp"].sort(),
+ [...BROWSER_TOOLS, ...OPENCLI_TOOLS, "automate_manage", "enter-worktree", "exit-worktree", "lsp"].sort(),
)
expect([...DEFERRED_GROUP_IDS].sort()).toEqual(["browser", "opencli"].sort())
expect(deferredGroupMembers("browser").sort()).toEqual([...BROWSER_TOOLS].sort())
@@ -70,8 +70,10 @@ describe("tool-info", () => {
})
test("buildCardList lists available deferred tools with their cards", () => {
- const list = buildCardList(["enter-worktree", "exit-worktree", "lsp"])
- expect(list).not.toContain("automate")
+ const list = buildCardList(["automate_manage", "enter-worktree", "exit-worktree", "lsp"])
+ expect(list).not.toContain("**automate**")
+ expect(list).toContain("automate_manage")
+ expect(list).toContain("pause")
expect(list).toContain("enter-worktree")
expect(list).toContain("exit-worktree")
expect(list).toContain("lsp")
@@ -164,6 +166,7 @@ describe("tool-info", () => {
expect(canonicalDeferredId("enter-worktree")).toBe("enter-worktree")
expect(canonicalDeferredId("Enter-Worktree")).toBe("enter-worktree")
expect(canonicalDeferredId("ENTER-WORKTREE")).toBe("enter-worktree")
+ expect(canonicalDeferredId("Automate_Manage")).toBe("automate_manage")
expect(canonicalDeferredId("Automate")).toBeUndefined()
expect(canonicalDeferredId("LSP")).toBe("lsp")
expect(canonicalDeferredId("read")).toBeUndefined()
@@ -191,6 +194,7 @@ describe("tool-info", () => {
expect(canonicalActivationTarget("browser")).toEqual({ kind: "group", id: "browser" })
expect(canonicalActivationTarget("BROWSER")).toEqual({ kind: "group", id: "browser" })
expect(canonicalActivationTarget("browser_click")).toEqual({ kind: "group", id: "browser" })
+ expect(canonicalActivationTarget("automate_manage")).toEqual({ kind: "tool", id: "automate_manage" })
expect(canonicalActivationTarget("enter-worktree")).toEqual({ kind: "tool", id: "enter-worktree" })
expect(canonicalActivationTarget("read")).toBeUndefined()
})