Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
59 changes: 59 additions & 0 deletions packages/app/e2e/snap/fixtures/permission-dock-fixture.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { render } from "solid-js/web"
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
import { LanguageProvider } from "@/context/language"
import { type Platform, PlatformProvider } from "@/context/platform"
import { SessionPermissionContent } from "@/pages/session/composer/session-permission-dock"

const platform: Platform = {
platform: "web",
openLink: () => {},
restart: async () => {},
back: () => {},
forward: () => {},
notify: async () => {},
}

function deleteRequest(input: { id: string; title: string }): PermissionRequest {
return {
id: `perm_${input.id}`,
sessionID: "ses_permission_snap",
permission: "automate_manage",
patterns: [input.id],
always: [],
metadata: { action: "delete", id: input.id, title: input.title },
}
}

const persistableRequest: PermissionRequest = {
id: "perm_bash_echo",
sessionID: "ses_permission_snap",
permission: "bash",
patterns: ["echo ok"],
always: ["echo ok"],
metadata: {},
}

function Block(props: { snap: string; request: PermissionRequest }) {
return (
<div data-snap={props.snap} style={{ width: "640px" }}>
<SessionPermissionContent request={props.request} responding={false} onDecide={() => {}} />
</div>
)
}

function PermissionDockFixture() {
return (
<PlatformProvider value={platform}>
<LanguageProvider locale="en">
<div style={{ display: "grid", gap: "20px", padding: "24px", background: "var(--bg-base)" }}>
<Block snap="delete-once" request={deleteRequest({ id: "aut_daily", title: "Daily repo brief" })} />
<Block snap="persistable" request={persistableRequest} />
</div>
</LanguageProvider>
</PlatformProvider>
)
}

export function mountPermissionDockFixture(root: HTMLElement) {
render(() => <PermissionDockFixture />, root)
}
57 changes: 57 additions & 0 deletions packages/app/e2e/snap/permission-dock.snap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { expect, type Locator } from "@playwright/test"
import { fileURLToPath } from "node:url"
import { test } from "../fixtures"
import { composeGrid, snapOutputPath, type Shot } from "./_compose"

test.use({ viewport: { width: 760, height: 420 }, deviceScaleFactor: 2 })

const fixturePath = fileURLToPath(new URL("./fixtures/permission-dock-fixture.tsx", import.meta.url))

async function captureBlock(name: string, block: Locator): Promise<Shot> {
await expect(block).toBeVisible({ timeout: 30_000 })
return { name, buf: await block.screenshot() }
}

async function waitForThemeBoot(page: import("@playwright/test").Page): Promise<void> {
await page.waitForFunction(
() => getComputedStyle(document.documentElement).getPropertyValue("--bg-base").trim().length > 0,
null,
{ timeout: 30_000 },
)
}

test("permission-dock", async ({ page }) => {
test.setTimeout(180_000)

await page.goto("/")
await waitForThemeBoot(page)
await page.addStyleTag({
content:
'aside[aria-label="Development performance diagnostics"], [data-component="toast-region"] { display: none; }',
})
await page.evaluate(async (path) => {
const mod = await import(path)
mod.mountPermissionDockFixture(document.body)
}, `/@fs/${fixturePath}`)

const deleteOnce = page.locator('[data-snap="delete-once"]')
await expect(deleteOnce).toContainText('Delete automation "Daily repo brief" (aut_daily)', { timeout: 30_000 })
await expect(deleteOnce.getByRole("button", { name: "Allow once" })).toBeVisible()
await expect(deleteOnce.getByRole("button", { name: "Deny" })).toBeVisible()
await expect(deleteOnce.getByRole("button", { name: "Allow always" })).toHaveCount(0)

const persistable = page.locator('[data-snap="persistable"]')
await expect(persistable).toContainText("echo ok", { timeout: 30_000 })
await expect(persistable.getByRole("button", { name: "Allow always" })).toBeVisible()

const out = snapOutputPath("permission-dock")
await composeGrid(
[
await captureBlock("delete-once", deleteOnce),
await captureBlock("persistable", persistable),
],
out,
{ cols: 1 },
)
process.stdout.write(`\n[snap] permission-dock grid -> ${out}\n\n`)
})
1 change: 1 addition & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,7 @@ export const dict = {
"ui.sessionReview.title.git": "Changes",
"ui.sessionReview.title.branch": "Branch",
"ui.sessionReview.title.lastTurn": "Last Turn",
"ui.permission.automateManageDelete": 'Delete automation "{{title}}" ({{id}})',

"session.files.selectToOpen": "Select a file to open",
"session.files.all": "All files",
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,7 @@ export const dict = {
"ui.sessionReview.title.git": "文件变更",
"ui.sessionReview.title.branch": "分支变更",
"ui.sessionReview.title.lastTurn": "上轮变更",
"ui.permission.automateManageDelete": "删除自动化「{{title}}」({{id}})",
"session.files.selectToOpen": "选择要打开的文件",
"session.files.all": "所有文件",
"session.files.empty": "无文件",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, test } from "bun:test"
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
import { canPersistPermission, permissionMetadataLines } from "./session-permission-dock"

const request = (always: string[]): PermissionRequest =>
({
id: "perm_1",
sessionID: "ses_1",
permission: "automate_manage",
patterns: ["aut_123"],
always,
metadata: { action: "delete", id: "aut_123", title: "Daily repo brief" },
}) as PermissionRequest

describe("canPersistPermission", () => {
test("returns false when the request has no always patterns", () => {
expect(canPersistPermission(request([]))).toBe(false)
})

test("returns true when the request has at least one always pattern", () => {
expect(canPersistPermission(request(["*"]))).toBe(true)
})
})

describe("permissionMetadataLines", () => {
test("renders automate_manage delete metadata as a readable confirmation line", () => {
const t = (key: string | number, params?: Record<string, string | number | boolean>) =>
`${key}:${params?.title}:${params?.id}`

expect(permissionMetadataLines(request([]), t)).toEqual([
"ui.permission.automateManageDelete:Daily repo brief:aut_123",
])
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,25 @@ import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { useLanguage } from "@/context/language"

type Translate = ReturnType<typeof useLanguage>["t"]

export function canPersistPermission(request: Pick<PermissionRequest, "always">) {
return request.always.length > 0
}

export function permissionMetadataLines(request: PermissionRequest, t: Translate): string[] {
const metadata = request.metadata ?? {}
if (
request.permission === "automate_manage" &&
metadata["action"] === "delete" &&
typeof metadata["title"] === "string" &&
typeof metadata["id"] === "string"
) {
return [t("ui.permission.automateManageDelete", { title: metadata["title"], id: metadata["id"] })]
}
return []
}

export function SessionPermissionContent(props: {
request: PermissionRequest
responding: boolean
Expand All @@ -17,6 +36,7 @@ export function SessionPermissionContent(props: {
if (value === key) return ""
return value
}
const metadataLines = () => permissionMetadataLines(props.request, language.t)

return (
<div data-component="dock-prompt" data-kind="permission">
Expand All @@ -38,6 +58,15 @@ export function SessionPermissionContent(props: {
</div>
</Show>

<For each={metadataLines()}>
{(line) => (
<div data-slot="permission-row">
<span data-slot="permission-spacer" aria-hidden="true" />
<div data-slot="permission-hint">{line}</div>
</div>
)}
</For>

<Show when={props.request.patterns.length > 0}>
<div data-slot="permission-row">
<span data-slot="permission-spacer" aria-hidden="true" />
Expand All @@ -57,9 +86,11 @@ export function SessionPermissionContent(props: {
<Button variant="ghost" onClick={() => props.onDecide("reject")} disabled={props.responding}>
{language.t("ui.permission.deny")}
</Button>
<Button variant="secondary" onClick={() => props.onDecide("always")} disabled={props.responding}>
{language.t("ui.permission.allowAlways")}
</Button>
<Show when={canPersistPermission(props.request)}>
<Button variant="secondary" onClick={() => props.onDecide("always")} disabled={props.responding}>
{language.t("ui.permission.allowAlways")}
</Button>
</Show>
<Button variant="primary" onClick={() => props.onDecide("once")} disabled={props.responding}>
{language.t("ui.permission.allowOnce")}
</Button>
Expand Down
5 changes: 4 additions & 1 deletion packages/opencode/src/acp/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand Down
4 changes: 3 additions & 1 deletion packages/opencode/src/session/prompt/pawwork.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
137 changes: 137 additions & 0 deletions packages/opencode/src/tool/automate-manage.ts
Original file line number Diff line number Diff line change
@@ -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<typeof AutomateManageParameters>
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.`))
}
Comment thread
Astro-Han marked this conversation as resolved.

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<A, E, R>(effect: Effect.Effect<A, E, R>, 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<typeof AutomateManageParameters, Metadata> {
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) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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)
}),
)
Loading