opencode-workflow-guard 1.5.1 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -6
- package/bin/opencode-workflow-guard.mjs +59 -0
- package/package.json +7 -2
- package/src/lib/audit.ts +82 -7
- package/src/lib/checkpoint.ts +308 -0
- package/src/lib/custom-tools.ts +198 -0
- package/src/lib/guard-dispatcher.ts +478 -0
- package/src/lib/project-config.ts +68 -0
- package/src/lib/project-memory.ts +46 -3
- package/src/lib/shell.ts +399 -0
- package/src/lib/state.ts +23 -102
- package/src/lib/tool-lifecycle.ts +63 -0
- package/src/lib/tool-outcomes.ts +64 -0
- package/src/lib/types.ts +36 -0
- package/src/lib/utils.ts +13 -380
- package/src/lib/verify.ts +69 -8
- package/src/policies/boundary.ts +3 -3
- package/src/policies/changelog.ts +2 -2
- package/src/policies/continuation.ts +5 -0
- package/src/policies/destructive.ts +2 -1
- package/src/policies/file-claims.ts +56 -0
- package/src/policies/git.ts +1 -1
- package/src/policies/post-edit-validation.ts +76 -0
- package/src/policies/secrets.ts +1 -1
- package/src/policies/stale-write.ts +70 -0
- package/src/policies/tamper.ts +1 -1
- package/src/policies/todo.ts +25 -15
- package/src/workflow-guard-ui.ts +75 -0
- package/src/workflow-guard.ts +171 -1118
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { readlinkSync, realpathSync } from "node:fs";
|
|
2
|
+
import { dirname, relative, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
interface FileClaim {
|
|
5
|
+
sessionID: string;
|
|
6
|
+
callID: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const claims = new Map<string, FileClaim>();
|
|
10
|
+
|
|
11
|
+
export function canonicalPath(path: string, seen = new Set<string>()): string {
|
|
12
|
+
const resolved = resolve(path);
|
|
13
|
+
if (seen.has(resolved)) return resolved;
|
|
14
|
+
seen.add(resolved);
|
|
15
|
+
try {
|
|
16
|
+
return realpathSync(resolved);
|
|
17
|
+
} catch {
|
|
18
|
+
try {
|
|
19
|
+
return canonicalPath(resolve(dirname(resolved), readlinkSync(resolved)), seen);
|
|
20
|
+
} catch {}
|
|
21
|
+
let ancestor = dirname(resolved);
|
|
22
|
+
while (true) {
|
|
23
|
+
try {
|
|
24
|
+
return resolve(realpathSync(ancestor), relative(ancestor, resolved));
|
|
25
|
+
} catch {
|
|
26
|
+
const parent = dirname(ancestor);
|
|
27
|
+
if (parent === ancestor) return resolved;
|
|
28
|
+
ancestor = parent;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function claimFiles(paths: string[], sessionID: string, callID: string): string | undefined {
|
|
35
|
+
const canonical = [...new Set(paths.map((path) => canonicalPath(path)))];
|
|
36
|
+
for (const path of canonical) {
|
|
37
|
+
const existing = claims.get(path);
|
|
38
|
+
if (existing && existing.sessionID !== sessionID) {
|
|
39
|
+
return `Blocked: file '${path}' is claimed by another active session ('${existing.sessionID}'). Wait for that edit to finish or use an isolated worktree.`;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
for (const path of canonical) {
|
|
43
|
+
if (!claims.has(path)) {
|
|
44
|
+
claims.set(path, { sessionID, callID });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function releaseFileClaims(sessionID: string, callID?: string): void {
|
|
51
|
+
for (const [path, claim] of claims) {
|
|
52
|
+
if (claim.sessionID === sessionID && (callID === undefined || claim.callID === callID)) {
|
|
53
|
+
claims.delete(path);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/policies/git.ts
CHANGED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
4
|
+
import { getProjectConfig } from "../lib/state.ts";
|
|
5
|
+
import { runVerify, snipVerifyOutput } from "../lib/verify.ts";
|
|
6
|
+
import { extractPatchPaths } from "./boundary.ts";
|
|
7
|
+
|
|
8
|
+
export interface FileSnapshot {
|
|
9
|
+
path: string;
|
|
10
|
+
digest?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function globRegex(pattern: string): RegExp {
|
|
14
|
+
let source = "^";
|
|
15
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
16
|
+
const char = pattern[i];
|
|
17
|
+
if (char === "*") {
|
|
18
|
+
if (pattern[i + 1] === "*") {
|
|
19
|
+
i++;
|
|
20
|
+
if (pattern[i + 1] === "/") {
|
|
21
|
+
i++;
|
|
22
|
+
source += "(?:.*/)?";
|
|
23
|
+
} else {
|
|
24
|
+
source += ".*";
|
|
25
|
+
}
|
|
26
|
+
} else {
|
|
27
|
+
source += "[^/]*";
|
|
28
|
+
}
|
|
29
|
+
} else if (char === "?") {
|
|
30
|
+
source += "[^/]";
|
|
31
|
+
} else {
|
|
32
|
+
source += char.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return new RegExp(`${source}$`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function snapshotFile(path: string): FileSnapshot {
|
|
39
|
+
try {
|
|
40
|
+
return { path, digest: createHash("sha256").update(readFileSync(path)).digest("hex") };
|
|
41
|
+
} catch {
|
|
42
|
+
return { path };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function editTargets(args: unknown, root: string): string[] {
|
|
47
|
+
if (!args || typeof args !== "object") return [];
|
|
48
|
+
const record = args as Record<string, unknown>;
|
|
49
|
+
const raw = typeof record.filePath === "string" ? record.filePath : typeof record.path === "string" ? record.path : undefined;
|
|
50
|
+
if (raw) return [isAbsolute(raw) ? raw : resolve(root, raw)];
|
|
51
|
+
if (typeof record.patchText !== "string") return [];
|
|
52
|
+
return extractPatchPaths(record.patchText).map((path) => isAbsolute(path) ? path : resolve(root, path));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function runPostEditValidators(root: string, before: FileSnapshot): Promise<string | undefined> {
|
|
56
|
+
const after = snapshotFile(before.path);
|
|
57
|
+
if (after.digest === before.digest) return undefined;
|
|
58
|
+
const relativePath = relative(root, before.path).replaceAll("\\", "/");
|
|
59
|
+
const validators = getProjectConfig(root).postEditValidators;
|
|
60
|
+
if (!Array.isArray(validators)) return undefined;
|
|
61
|
+
|
|
62
|
+
const failures: string[] = [];
|
|
63
|
+
for (const [index, validator] of validators.entries()) {
|
|
64
|
+
if (!validator || typeof validator.pattern !== "string" || !validator.pattern || /[{}\[\]]/.test(validator.pattern) || typeof validator.command !== "string" || !validator.command.trim() || (validator.timeoutMs !== undefined && (!Number.isFinite(validator.timeoutMs) || validator.timeoutMs <= 0))) {
|
|
65
|
+
failures.push(`[workflow-guard] Invalid postEditValidators configuration at index ${index}: pattern must be a non-empty *, **, ? glob without brace/class syntax; command must be non-empty; timeoutMs must be a positive finite number when provided.`);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (!globRegex(validator.pattern).test(relativePath)) continue;
|
|
69
|
+
const timeoutMs = validator.timeoutMs ?? 30_000;
|
|
70
|
+
const result = await runVerify(validator.command, root, timeoutMs);
|
|
71
|
+
if (!result.passed) {
|
|
72
|
+
failures.push(`[workflow-guard] Post-edit validator failed for ${relativePath}: ${validator.command}\n${snipVerifyOutput(result.output, false)}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return failures.length ? failures.join("\n\n") : undefined;
|
|
76
|
+
}
|
package/src/policies/secrets.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { realpathSync } from "node:fs";
|
|
2
2
|
import { basename, resolve } from "node:path";
|
|
3
3
|
import { getWorkspaceRoot } from "../lib/state.ts";
|
|
4
|
-
import { shellWords, unwrapShellCommand } from "../lib/
|
|
4
|
+
import { shellWords, unwrapShellCommand } from "../lib/shell.ts";
|
|
5
5
|
|
|
6
6
|
export const SAFE_ENV_FIXTURE_RE =
|
|
7
7
|
/\.env\.(example|sample|template|dist|schema)(\.[\w-]+)*$/i;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { canonicalPath } from "./file-claims.ts";
|
|
4
|
+
|
|
5
|
+
interface ReadFingerprint {
|
|
6
|
+
dev: bigint;
|
|
7
|
+
ino: bigint;
|
|
8
|
+
size: bigint;
|
|
9
|
+
mtimeNs: bigint;
|
|
10
|
+
digest: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ReadObservation {
|
|
14
|
+
path: string;
|
|
15
|
+
fingerprint: ReadFingerprint;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const reads = new Map<string, Map<string, ReadFingerprint>>();
|
|
19
|
+
|
|
20
|
+
function fingerprint(path: string): ReadFingerprint | undefined {
|
|
21
|
+
try {
|
|
22
|
+
const stat = statSync(path, { bigint: true });
|
|
23
|
+
if (!stat.isFile()) return undefined;
|
|
24
|
+
return {
|
|
25
|
+
dev: stat.dev,
|
|
26
|
+
ino: stat.ino,
|
|
27
|
+
size: stat.size,
|
|
28
|
+
mtimeNs: stat.mtimeNs,
|
|
29
|
+
digest: createHash("sha256").update(readFileSync(path)).digest("hex"),
|
|
30
|
+
};
|
|
31
|
+
} catch {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function equal(a: ReadFingerprint, b: ReadFingerprint): boolean {
|
|
37
|
+
return a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeNs === b.mtimeNs && a.digest === b.digest;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function beginReadObservation(path: string): ReadObservation | undefined {
|
|
41
|
+
const observed = fingerprint(path);
|
|
42
|
+
if (!observed) return undefined;
|
|
43
|
+
return { path: canonicalPath(path), fingerprint: observed };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function recordSuccessfulRead(observation: ReadObservation, sessionID: string): void {
|
|
47
|
+
const current = fingerprint(observation.path);
|
|
48
|
+
if (!current || !equal(observation.fingerprint, current)) return;
|
|
49
|
+
const sessionReads = reads.get(sessionID) ?? new Map<string, ReadFingerprint>();
|
|
50
|
+
sessionReads.set(observation.path, observation.fingerprint);
|
|
51
|
+
reads.set(sessionID, sessionReads);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function staleWriteReason(path: string, sessionID: string): string | undefined {
|
|
55
|
+
const current = fingerprint(path);
|
|
56
|
+
const canonical = canonicalPath(path);
|
|
57
|
+
const observed = reads.get(sessionID)?.get(canonical);
|
|
58
|
+
if (!observed) {
|
|
59
|
+
if (!current) return undefined;
|
|
60
|
+
return `Blocked: existing file '${canonical}' has not been read by this session. Re-read it before editing or overwriting it.`;
|
|
61
|
+
}
|
|
62
|
+
if (!current || !equal(observed, current)) {
|
|
63
|
+
return `Blocked: file '${canonical}' changed since this session read it. Re-read it before editing or overwriting it.`;
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function clearReadFingerprints(sessionID: string): void {
|
|
69
|
+
reads.delete(sessionID);
|
|
70
|
+
}
|
package/src/policies/tamper.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { realpathSync } from "node:fs";
|
|
2
2
|
import { basename, relative, resolve } from "node:path";
|
|
3
3
|
import { getWorkspaceRoot } from "../lib/state.ts";
|
|
4
|
-
import { decodeShellEscapes } from "../lib/
|
|
4
|
+
import { decodeShellEscapes } from "../lib/shell.ts";
|
|
5
5
|
|
|
6
6
|
export const PROTECTED_PATH_REASON =
|
|
7
7
|
"Blocked: modifying Open" +
|
package/src/policies/todo.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { TodoItem } from "../lib/types.ts";
|
|
2
|
-
import { getSdkClient } from "../lib/state.ts";
|
|
2
|
+
import { getSessionMutationCount, getSdkClient, getSubagentMutationBudget } from "../lib/state.ts";
|
|
3
3
|
|
|
4
4
|
export const EDIT_TOOL_NAMES = new Set(["edit", "write", "patch", "apply_patch"]);
|
|
5
5
|
export const ACTIVE_TODO_STATUSES = new Set(["pending", "in_progress"]);
|
|
@@ -19,16 +19,34 @@ export async function fetchSessionTodos(sessionID: string): Promise<TodoItem[] |
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export async function fetchParentSessionID(sessionID: string): Promise<string | undefined> {
|
|
22
|
+
const result = await fetchParentSession(sessionID);
|
|
23
|
+
return result.ok ? result.parentID : undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function subagentMutationBudgetReason(
|
|
27
|
+
sessionID: string,
|
|
28
|
+
root: string,
|
|
29
|
+
): Promise<string | undefined> {
|
|
30
|
+
if (!await fetchParentSessionID(sessionID)) return undefined;
|
|
31
|
+
const count = getSessionMutationCount(sessionID);
|
|
32
|
+
const budget = getSubagentMutationBudget(root);
|
|
33
|
+
if (count < budget) return undefined;
|
|
34
|
+
return `Blocked: subagent session '${sessionID}' has reached its mutation budget (${count}/${budget}). Hand work back to parent orchestrator.`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function fetchParentSession(sessionID: string): Promise<{ ok: true; parentID?: string } | { ok: false }> {
|
|
22
38
|
const client = getSdkClient();
|
|
23
39
|
const session = client?.session;
|
|
24
40
|
const get = session?.get;
|
|
25
|
-
if (typeof get !== "function") return
|
|
41
|
+
if (typeof get !== "function") return { ok: false };
|
|
26
42
|
try {
|
|
27
43
|
const result = await get.call(session, { path: { id: sessionID } });
|
|
28
|
-
const
|
|
29
|
-
|
|
44
|
+
const data = (result as { data?: { parentID?: unknown } } | undefined)?.data;
|
|
45
|
+
if (!data) return { ok: false };
|
|
46
|
+
const parent = data.parentID;
|
|
47
|
+
return { ok: true, parentID: typeof parent === "string" && parent ? parent : undefined };
|
|
30
48
|
} catch {
|
|
31
|
-
return
|
|
49
|
+
return { ok: false };
|
|
32
50
|
}
|
|
33
51
|
}
|
|
34
52
|
|
|
@@ -58,16 +76,8 @@ export async function effectiveTodoOwnerSessionID(
|
|
|
58
76
|
sessionID: string | undefined,
|
|
59
77
|
): Promise<string | undefined> {
|
|
60
78
|
if (!sessionID) return undefined;
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
while (current && !seen.has(current)) {
|
|
64
|
-
seen.add(current);
|
|
65
|
-
const todos = await fetchSessionTodos(current);
|
|
66
|
-
if (todos === undefined) return sessionID;
|
|
67
|
-
if (todos.length > 0) return current;
|
|
68
|
-
current = await fetchParentSessionID(current);
|
|
69
|
-
}
|
|
70
|
-
return sessionID;
|
|
79
|
+
const effective = await effectiveTodosWithOwner(sessionID);
|
|
80
|
+
return effective?.ownerSessionID ?? sessionID;
|
|
71
81
|
}
|
|
72
82
|
|
|
73
83
|
export function hasActiveTodo(todos: TodoItem[]): boolean {
|
package/src/workflow-guard-ui.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui";
|
|
2
2
|
import type { JSX } from "@opentui/solid";
|
|
3
3
|
import { createElement, insert, setProp } from "@opentui/solid";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { dirname, isAbsolute, join, relative } from "node:path";
|
|
6
|
+
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser";
|
|
7
|
+
import { projectConfigPath } from "./lib/project-config.ts";
|
|
8
|
+
import { canonicalPath } from "./policies/file-claims.ts";
|
|
4
9
|
|
|
5
10
|
type Child = JSX.Element | string | number | null | undefined | false;
|
|
6
11
|
|
|
@@ -29,6 +34,43 @@ let lastBlockedReason: string | undefined;
|
|
|
29
34
|
const sessionBlockedReasons = new Map<string, string>();
|
|
30
35
|
const badgeNodes = new Map<string, unknown>();
|
|
31
36
|
|
|
37
|
+
export function readProjectOption(root: string, option: "recoveryCheckpoints" | "projectMemory" | "learning"): boolean {
|
|
38
|
+
const path = projectConfigPath(root);
|
|
39
|
+
if (!existsSync(path)) return option === "projectMemory";
|
|
40
|
+
const errors: ParseError[] = [];
|
|
41
|
+
const config = parse(readFileSync(path, "utf8"), errors, { allowTrailingComma: true });
|
|
42
|
+
if (errors.length > 0) throw new Error(`Invalid Workflow Guard project config: ${path}`);
|
|
43
|
+
return option === "projectMemory" ? config?.projectMemory !== false : config?.[option] === true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function writeProjectOption(root: string, option: "recoveryCheckpoints" | "projectMemory" | "learning", enabled: boolean): string {
|
|
47
|
+
const path = projectConfigPath(root);
|
|
48
|
+
const raw = existsSync(path) ? readFileSync(path, "utf8") : "{}\n";
|
|
49
|
+
const errors: ParseError[] = [];
|
|
50
|
+
parse(raw, errors, { allowTrailingComma: true });
|
|
51
|
+
if (errors.length > 0) throw new Error(`Invalid Workflow Guard project config: ${path}`);
|
|
52
|
+
const realRoot = canonicalPath(root);
|
|
53
|
+
const realPath = canonicalPath(path);
|
|
54
|
+
const rel = relative(realRoot, realPath);
|
|
55
|
+
if (rel === ".." || rel.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || isAbsolute(rel)) {
|
|
56
|
+
throw new Error(`Refusing to write Workflow Guard config outside project: ${path}`);
|
|
57
|
+
}
|
|
58
|
+
const next = applyEdits(raw, modify(raw, [option], enabled, {
|
|
59
|
+
formattingOptions: { insertSpaces: true, tabSize: 2 },
|
|
60
|
+
}));
|
|
61
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
62
|
+
writeFileSync(path, next.endsWith("\n") ? next : next + "\n");
|
|
63
|
+
return path;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function readRecoveryCheckpointsOption(root: string): boolean {
|
|
67
|
+
return readProjectOption(root, "recoveryCheckpoints");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function writeRecoveryCheckpointsOption(root: string, enabled: boolean): string {
|
|
71
|
+
return writeProjectOption(root, "recoveryCheckpoints", enabled);
|
|
72
|
+
}
|
|
73
|
+
|
|
32
74
|
export function setLastBlockedReasonForTesting(reason: string | undefined): void {
|
|
33
75
|
lastBlockedReason = reason;
|
|
34
76
|
if (reason === undefined) sessionBlockedReasons.clear();
|
|
@@ -49,6 +91,39 @@ export function formatBadge(sessionID?: string): { text: string; isBlocked: bool
|
|
|
49
91
|
}
|
|
50
92
|
|
|
51
93
|
export const WorkflowGuardTui: TuiPlugin = async (api) => {
|
|
94
|
+
api.keymap.registerLayer({
|
|
95
|
+
commands: [{
|
|
96
|
+
name: "workflow-guard.project-options",
|
|
97
|
+
title: "Workflow Guard: Project Options",
|
|
98
|
+
category: "Workflow Guard",
|
|
99
|
+
namespace: "palette",
|
|
100
|
+
slashName: "guard-options",
|
|
101
|
+
run() {
|
|
102
|
+
const root = api.state.path.worktree || api.state.path.directory;
|
|
103
|
+
const recovery = readProjectOption(root, "recoveryCheckpoints");
|
|
104
|
+
const memory = readProjectOption(root, "projectMemory");
|
|
105
|
+
const learning = readProjectOption(root, "learning");
|
|
106
|
+
api.ui.dialog.replace(() => api.ui.DialogSelect({
|
|
107
|
+
title: "Workflow Guard Project Options",
|
|
108
|
+
current: undefined,
|
|
109
|
+
options: [
|
|
110
|
+
{ title: `Recovery checkpoints: ${recovery ? "On" : "Off"}`, value: "recoveryCheckpoints", description: "Toggle durable pre-run Git checkpoints" },
|
|
111
|
+
{ title: `Project memory: ${memory ? "On" : "Off"}`, value: "projectMemory", description: "Toggle durable local project memory" },
|
|
112
|
+
{ title: `Learner mode: ${learning ? "On" : "Off"}`, value: "learning", description: "Toggle evidence-based learning tools" },
|
|
113
|
+
],
|
|
114
|
+
onSelect: (option) => {
|
|
115
|
+
const key = option.value as "recoveryCheckpoints" | "projectMemory" | "learning";
|
|
116
|
+
const enabled = !readProjectOption(root, key);
|
|
117
|
+
const path = writeProjectOption(root, key, enabled);
|
|
118
|
+
api.ui.dialog.clear();
|
|
119
|
+
api.ui.toast({ variant: "success", title: "Workflow Guard", message: `Saved ${key} ${enabled ? "on" : "off"} in ${path}. Restart OpenCode to apply.` });
|
|
120
|
+
},
|
|
121
|
+
}));
|
|
122
|
+
},
|
|
123
|
+
}],
|
|
124
|
+
bindings: [],
|
|
125
|
+
});
|
|
126
|
+
|
|
52
127
|
try {
|
|
53
128
|
api.event?.on?.("tui.toast.show" as any, (e: any) => {
|
|
54
129
|
const title = e?.properties?.title ?? e?.title;
|