opencode-workflow-guard 1.12.0 → 1.13.1

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.
@@ -14,6 +14,7 @@ type ContinuationState = {
14
14
  generatedMessageIDs: Map<string, Set<string>>;
15
15
  lastUserMessageIDs: Map<string, string>;
16
16
  inFlight: Set<string>;
17
+ interrupted: Set<string>;
17
18
  };
18
19
  export type RalphOutcome = "running" | "completed" | "blocked" | "budget_exhausted" | "user_stopped";
19
20
  const states = new WeakMap<object, Map<string, ContinuationState>>();
@@ -43,7 +44,7 @@ function getState(): ContinuationState | undefined {
43
44
  const root = projectRootKey(getWorkspaceRoot());
44
45
  let state = clientStates.get(root);
45
46
  if (!state) {
46
- state = { counts: new Map(), ralphOutcomes: new Map(), generatedMessageIDs: new Map(), lastUserMessageIDs: new Map(), inFlight: new Set() };
47
+ state = { counts: new Map(), ralphOutcomes: new Map(), generatedMessageIDs: new Map(), lastUserMessageIDs: new Map(), inFlight: new Set(), interrupted: new Set() };
47
48
  clientStates.set(root, state);
48
49
  }
49
50
  return state;
@@ -56,6 +57,9 @@ export async function continueUnfinishedSession(sessionID: string, settleTitle =
56
57
  try {
57
58
  const ralph = isRalphModeEnabled(getWorkspaceRoot());
58
59
  if (ralph && state.ralphOutcomes.get(sessionID) === "user_stopped") return false;
60
+ // A user-initiated interrupt (Esc) suspends automatic continuation:
61
+ // only genuine user input re-enables it (see recordUserMessage).
62
+ if (state.interrupted.has(sessionID)) return false;
59
63
  const effective = await effectiveTodosWithOwner(sessionID);
60
64
  if (!effective || effective.ownerSessionID !== sessionID || !hasActiveTodo(effective.todos)) {
61
65
  if (ralph && state.ralphOutcomes.has(sessionID)) state.ralphOutcomes.set(sessionID, "completed");
@@ -73,7 +77,7 @@ export async function continueUnfinishedSession(sessionID: string, settleTitle =
73
77
  const session = getSdkClient()?.session;
74
78
  if (typeof session?.promptAsync !== "function") return false;
75
79
 
76
- const messageID = `workflow-guard-${randomUUID()}`;
80
+ const messageID = `msg_wg_${randomUUID().replace(/-/g, "")}`;
77
81
  state.counts.set(sessionID, count + 1);
78
82
  if (ralph) state.ralphOutcomes.set(sessionID, "running");
79
83
  const generatedIDs = state.generatedMessageIDs.get(sessionID) ?? new Set<string>();
@@ -122,12 +126,26 @@ export function recordUserMessage(sessionID: string, messageID?: string): void {
122
126
  if (messageID && state.lastUserMessageIDs.get(sessionID) === messageID) return;
123
127
  if (messageID) state.lastUserMessageIDs.set(sessionID, messageID);
124
128
  state.counts.delete(sessionID);
129
+ state.interrupted.delete(sessionID);
125
130
  if (isRalphModeEnabled(getWorkspaceRoot()) && state.ralphOutcomes.has(sessionID)) {
126
131
  if (state.ralphOutcomes.get(sessionID) === "user_stopped") state.ralphOutcomes.delete(sessionID);
127
132
  else state.ralphOutcomes.set(sessionID, "user_stopped");
128
133
  }
129
134
  }
130
135
 
136
+ // Record a user-initiated interrupt (Esc): the session's run was aborted
137
+ // from the user side, so automatic continuation must stop. Ralph runs are
138
+ // marked user_stopped, mirroring genuine user input. A later genuine user
139
+ // message re-enables continuation (recordUserMessage clears the flag).
140
+ export function markInterrupted(sessionID: string): void {
141
+ const state = getState();
142
+ if (!state) return;
143
+ state.interrupted.add(sessionID);
144
+ if (isRalphModeEnabled(getWorkspaceRoot()) && state.ralphOutcomes.has(sessionID)) {
145
+ state.ralphOutcomes.set(sessionID, "user_stopped");
146
+ }
147
+ }
148
+
131
149
  export function getRalphOutcome(sessionID: string): RalphOutcome | undefined {
132
150
  return getState()?.ralphOutcomes.get(sessionID);
133
151
  }
@@ -145,4 +163,5 @@ export function clearContinuationState(sessionID: string): void {
145
163
  state.generatedMessageIDs.delete(sessionID);
146
164
  state.lastUserMessageIDs.delete(sessionID);
147
165
  state.inFlight.delete(sessionID);
166
+ state.interrupted.delete(sessionID);
148
167
  }
@@ -123,7 +123,7 @@ export function normalizeGitCommands(command: string): string {
123
123
  }
124
124
 
125
125
  export const GIT_WRITE_RE =
126
- /\bgit\s+(add|rm|mv|commit|merge|rebase|cherry-pick|revert|stash\s+pop|apply|am|restore|reset|update-ref|filter-branch)\b|\bgit\s+tag\s+(?!--?list\b|-l\b)|\bgit\s+checkout\s+(?!-b\b)|\bgit\s+branch\s+(?:[^|;&]*\s)?-[dDM]\b/;
126
+ /\bgit\s+(add|rm|mv|commit|merge|rebase|cherry-pick|revert|stash\s+pop|apply|am|restore|reset|update-ref|filter-branch)\b|\bgit\s+tag\s+(?!--?list\b|-l\b)(?:[^|;&]*\s)?(?:-d\b|--delete\b)|\bgit\s+checkout\s+(?!-b\b)|\bgit\s+branch\s+(?:[^|;&]*\s)?-[dDM]\b/;
127
127
 
128
128
  export function currentGitBranch(root: string): string | undefined {
129
129
  const result = spawnSync("git", ["branch", "--show-current"], {
@@ -179,6 +179,47 @@ export function pushedProtectedBranchIn(pushCommand: string, root: string): stri
179
179
  return undefined;
180
180
  }
181
181
 
182
+ /**
183
+ * True when a `git push` command pushes a tag refspec: an explicit
184
+ * `refs/tags/<name>` destination, or a short name that resolves to an
185
+ * existing tag in the repository at `root`. Publishing a tag is a release
186
+ * operation, not a branch mutation, so callers exempt it from the
187
+ * protected-branch and merged-branch push rules (#134). Deletion refspecs
188
+ * (`:refs/tags/<name>`, `git push --delete`, `-d`) return false so tag
189
+ * deletions stay blocked.
190
+ */
191
+ export function tagRefspecIn(pushCommand: string, root: string): boolean {
192
+ if (/\s--delete\b|\s-d(?:\b|$)/.test(pushCommand)) return false;
193
+ for (const segment of splitShellSegments(pushCommand)) {
194
+ const parsed = parseGitInvocation(segment);
195
+ if (!parsed || !/\bpush\b/.test(parsed.rest)) continue;
196
+ const tokens = parsed.rest.split(/\s+/).slice(1);
197
+ let remoteSeen = false;
198
+ for (const rawToken of tokens) {
199
+ if (rawToken.startsWith("-")) continue;
200
+ if (!remoteSeen) {
201
+ remoteSeen = true;
202
+ continue;
203
+ }
204
+ const token = rawToken.replace(/^\+/, "");
205
+ if (!token) continue;
206
+ if (token.startsWith(":")) continue; // deletion refspec: empty source
207
+ if (token.includes(":")) {
208
+ if (token.includes(":refs/tags/")) return true; // <src>:refs/tags/<name>
209
+ continue;
210
+ }
211
+ if (token.startsWith("refs/tags/")) return true; // explicit tag refspec
212
+ const probe = spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/tags/${token}`], {
213
+ cwd: root,
214
+ encoding: "utf8",
215
+ timeout: 10_000,
216
+ });
217
+ if (probe.status === 0) return true;
218
+ }
219
+ }
220
+ return false;
221
+ }
222
+
182
223
  export function onProtectedBranch(root: string): boolean {
183
224
  return isProtectedBranchName(currentGitBranch(root) ?? "", root);
184
225
  }
@@ -36,11 +36,23 @@ export function normalizeGlobPathEvasion(text: string): string {
36
36
  .replace(/opencode\.[?*]/gi, ocJson);
37
37
  }
38
38
 
39
+ // Collaboration invocations (hosted-git/PR/issue CLIs) never write local
40
+ // configuration: their arguments may legitimately mention guarded paths.
41
+ const COLLABORATION_INVOCATION_PATTERNS: RegExp[] = [
42
+ /^\s*(?:gh|glab)\s+(?:issue|pr)\b/,
43
+ /^\s*az\s+repos\s+pr\b/,
44
+ ];
45
+
46
+ export function isCollaborationInvocation(segment: string): boolean {
47
+ return COLLABORATION_INVOCATION_PATTERNS.some((re) => re.test(segment));
48
+ }
49
+
39
50
  export function isSettingsTamper(command: string): boolean {
40
51
  const segments = command.split(/[\n|;&]+/).map((s) =>
41
52
  normalizeGlobPathEvasion(normalizeShellEvasion(s)),
42
53
  );
43
54
  return segments.some((segment) =>
55
+ !isCollaborationInvocation(segment) &&
44
56
  SETTINGS_TAMPER_PATTERNS.some((re) => re.test(segment)),
45
57
  );
46
58
  }
@@ -53,6 +65,11 @@ export function isProtectedPath(targetPath: string): boolean {
53
65
  const base = basename(path);
54
66
  const lower = path.toLowerCase();
55
67
  const dotOc = "." + "opencode/";
68
+ // opencode plan mode writes agent plan markdown under the project's
69
+ // .opencode/plans/ directory - plan files are documents, not
70
+ // configuration. The trailing slash keeps the plans directory
71
+ // itself protected.
72
+ if (lower.includes("/" + dotOc + "plans/")) return false;
56
73
  const cfgOc = "/.config/" + "opencode/";
57
74
  const cfgOcJson = "/.config/" + "opencode.json";
58
75
  return (
@@ -1,4 +1,14 @@
1
+ /**
2
+ * Workflow Guard TUI companion plugin for OpenCode V2 (CLI plugin API).
3
+ *
4
+ * - Registers the `/guard-options` palette + slash command to toggle project options.
5
+ * - Renders the Workflow Guard badge in the home and prompt footer status slots.
6
+ *
7
+ * The V1 entrypoint (`WorkflowGuardTui`) remains for OpenCode 1.x TUI clients.
8
+ */
9
+
1
10
  import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui";
11
+ import { Plugin } from "@opencode/plugin/tui";
2
12
  import type { JSX } from "@opentui/solid";
3
13
  import { createElement, insert, setProp } from "@opentui/solid";
4
14
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -75,6 +85,79 @@ export function formatBadge(): { text: string; isBlocked: boolean } {
75
85
  return { text: BADGE_ACTIVE, isBlocked: false };
76
86
  }
77
87
 
88
+ // ── V2 CLI plugin ─────────────────────────────────────────────────────────────
89
+
90
+ type TuiContext = Plugin.Context;
91
+
92
+ const TOGGLE_OPTIONS: Array<{ key: ProjectToggle; label: string; description: string }> = [
93
+ { key: "recoveryCheckpoints", label: "Recovery checkpoints", description: "Toggle durable pre-run Git checkpoints" },
94
+ { key: "projectMemory", label: "Project memory", description: "Toggle durable local project memory" },
95
+ { key: "learning", label: "Learner mode", description: "Toggle evidence-based learning tools" },
96
+ { key: "titleSettleWorkaround", label: "Title settle workaround", description: "Delay automatic continuation while OpenCode generates a session title" },
97
+ { key: "ralphMode", label: "Ralph mode", description: "Opt into bounded autonomous continuation of already-owned todos" },
98
+ ];
99
+
100
+ export const WorkflowGuardTuiV2 = (ctx: TuiContext) => {
101
+ const badge = () => {
102
+ const formatted = formatBadge();
103
+ return text({ fg: ctx.theme.text.feedback.success.base }, [formatted.text]);
104
+ };
105
+
106
+ const optionsRoot = () => ctx.location?.directory || process.cwd();
107
+
108
+ ctx.keymap.layer(() => ({
109
+ commands: [{
110
+ id: "workflow-guard.project-options",
111
+ title: "Workflow Guard: Project Options",
112
+ description: "Toggle Workflow Guard project options (recovery checkpoints, project memory, learning, title settle, ralph mode)",
113
+ group: "Workflow Guard",
114
+ palette: true,
115
+ slash: { name: "guard-options" },
116
+ async run() {
117
+ const root = optionsRoot();
118
+ for (;;) {
119
+ const current = new Map(TOGGLE_OPTIONS.map((option) => [option.key, readProjectOption(root, option.key)]));
120
+ const choice = await ctx.ui.dialog.select<ProjectToggle>({
121
+ title: "Workflow Guard Project Options",
122
+ options: TOGGLE_OPTIONS.map((option) => ({
123
+ title: `${option.label}: ${current.get(option.key) ? "On" : "Off"}`,
124
+ value: option.key,
125
+ description: option.description,
126
+ })),
127
+ });
128
+ if (!choice) break;
129
+ try {
130
+ const enabled = !readProjectOption(root, choice);
131
+ const path = writeProjectOption(root, choice, enabled);
132
+ ctx.ui.toast.show({
133
+ variant: "success",
134
+ title: "Workflow Guard",
135
+ message: `Saved ${choice} ${enabled ? "on" : "off"} in ${path}. Restart OpenCode to apply.`,
136
+ });
137
+ } catch (error) {
138
+ ctx.ui.toast.show({
139
+ variant: "error",
140
+ title: "Workflow Guard",
141
+ message: error instanceof Error ? error.message : String(error),
142
+ });
143
+ break;
144
+ }
145
+ }
146
+ },
147
+ }],
148
+ bindings: [],
149
+ }));
150
+
151
+ ctx.ui.slot({ append: "home.footer.status", render: () => badge() });
152
+ ctx.ui.slot({ append: "prompt.footer.status", render: () => badge() });
153
+
154
+ return () => {
155
+ // Slot claims and keymap layers are disposed automatically on unload.
156
+ };
157
+ };
158
+
159
+ // ── V1 TUI plugin ─────────────────────────────────────────────────────────────
160
+
78
161
  export const WorkflowGuardTui: TuiPlugin = async (api) => {
79
162
  api.keymap.registerLayer({
80
163
  commands: [{
@@ -85,11 +168,6 @@ export const WorkflowGuardTui: TuiPlugin = async (api) => {
85
168
  slashName: "guard-options",
86
169
  run() {
87
170
  const root = api.state.path.worktree || api.state.path.directory;
88
- const recovery = readProjectOption(root, "recoveryCheckpoints");
89
- const memory = readProjectOption(root, "projectMemory");
90
- const learning = readProjectOption(root, "learning");
91
- const titleSettle = readProjectOption(root, "titleSettleWorkaround");
92
- const ralphMode = readProjectOption(root, "ralphMode");
93
171
  const toggle = (key: ProjectToggle) => {
94
172
  const enabled = !readProjectOption(root, key);
95
173
  const path = writeProjectOption(root, key, enabled);
@@ -99,13 +177,12 @@ export const WorkflowGuardTui: TuiPlugin = async (api) => {
99
177
  api.ui.dialog.replace(() => api.ui.DialogSelect({
100
178
  title: "Workflow Guard Project Options",
101
179
  current: undefined,
102
- options: [
103
- { title: `Recovery checkpoints: ${recovery ? "On" : "Off"}`, value: "recoveryCheckpoints", description: "Toggle durable pre-run Git checkpoints", onSelect: () => toggle("recoveryCheckpoints") },
104
- { title: `Project memory: ${memory ? "On" : "Off"}`, value: "projectMemory", description: "Toggle durable local project memory", onSelect: () => toggle("projectMemory") },
105
- { title: `Learner mode: ${learning ? "On" : "Off"}`, value: "learning", description: "Toggle evidence-based learning tools", onSelect: () => toggle("learning") },
106
- { title: `Title settle workaround: ${titleSettle ? "On" : "Off"}`, value: "titleSettleWorkaround", description: "Delay automatic continuation while OpenCode generates a session title", onSelect: () => toggle("titleSettleWorkaround") },
107
- { title: `Ralph mode: ${ralphMode ? "On" : "Off"}`, value: "ralphMode", description: "Opt into bounded autonomous continuation of already-owned todos", onSelect: () => toggle("ralphMode") },
108
- ],
180
+ options: TOGGLE_OPTIONS.map((option) => ({
181
+ title: `${option.label}: ${readProjectOption(root, option.key) ? "On" : "Off"}`,
182
+ value: option.key,
183
+ description: option.description,
184
+ onSelect: () => toggle(option.key),
185
+ })),
109
186
  }));
110
187
  },
111
188
  }],
@@ -129,7 +206,11 @@ export const WorkflowGuardTui: TuiPlugin = async (api) => {
129
206
  });
130
207
  };
131
208
 
209
+ // Default export supports BOTH generations: V1 TUI clients call tui(), V2 reads setup().
132
210
  export default {
133
- id: "workflow-guard-ui",
211
+ ...Plugin.define({
212
+ id: "workflow-guard-ui",
213
+ setup: WorkflowGuardTuiV2,
214
+ }),
134
215
  tui: WorkflowGuardTui,
135
- } satisfies TuiPluginModule;
216
+ } as const satisfies TuiPluginModule;
@@ -8,7 +8,9 @@
8
8
  * from ./lib/, and re-exports the public helper surface for tests.
9
9
  */
10
10
 
11
- import { type Plugin, type PluginModule } from "@opencode-ai/plugin";
11
+ import { type Plugin as V1Plugin, type PluginModule } from "@opencode-ai/plugin";
12
+ import { Plugin } from "@opencode/plugin";
13
+ import { WorkflowGuardV2 } from "./lib/v2-plugin.ts";
12
14
  import { existsSync, readFileSync } from "node:fs";
13
15
  import { resolve } from "node:path";
14
16
  import { join } from "node:path";
@@ -74,10 +76,14 @@ import {
74
76
  getLearningInterventionBudget,
75
77
  recordReviewResult,
76
78
  getLastReviewResult,
79
+ getLastReviewResultForWorkspace,
77
80
  resetReviewState,
78
81
  sessionReviews,
82
+ setSessionWorkspace,
83
+ getSessionWorkspace,
84
+ resolveEffectiveWorkspace,
79
85
  } from "./lib/state.ts";
80
- import { loadProjectConfig, reloadProjectConfig, stripJsonComments } from "./lib/project-config.ts";
86
+ import { findGitRoot, isSameGitRepo, loadProjectConfig, reloadProjectConfig, stripJsonComments } from "./lib/project-config.ts";
81
87
 
82
88
  export {
83
89
  setWorkspaceRoot,
@@ -101,11 +107,18 @@ export {
101
107
  getLearningInterventionBudget,
102
108
  recordReviewResult,
103
109
  getLastReviewResult,
110
+ getLastReviewResultForWorkspace,
104
111
  resetReviewState,
112
+ setSessionWorkspace,
113
+ getSessionWorkspace,
114
+ resolveEffectiveWorkspace,
115
+ findGitRoot,
116
+ isSameGitRepo,
105
117
  };
106
118
 
107
119
  // ── Shared shell/env utilities ───────────────────────────────────────────────
108
120
  import {
121
+ asRecord,
109
122
  extractRecordTargetPath,
110
123
  getCleanEnv,
111
124
  showBlockToast,
@@ -149,14 +162,17 @@ import { ensureProjectMemoryExcluded, getProjectMemoryIdentity, getRecentProject
149
162
  import {
150
163
  getAuditFilePath,
151
164
  getVerifyCacheFilePath,
165
+ getReviewCacheFilePath,
152
166
  persistVerifyCache,
153
167
  loadVerifyCache,
168
+ persistReviewCache,
169
+ loadReviewCache,
154
170
  audit,
155
171
  logDecision,
156
172
  summarizeInput,
157
173
  } from "./lib/audit.ts";
158
174
 
159
- export { getAuditFilePath, getVerifyCacheFilePath, getVerifyHistoryFilePath, persistVerifyCache, loadVerifyCache, getRecentAuditEntries, getRecentVerifyHistory } from "./lib/audit.ts";
175
+ export { getAuditFilePath, getVerifyCacheFilePath, getReviewCacheFilePath, getVerifyHistoryFilePath, persistVerifyCache, loadVerifyCache, persistReviewCache, loadReviewCache, getRecentAuditEntries, getRecentVerifyHistory } from "./lib/audit.ts";
160
176
  export { summarizeInput };
161
177
 
162
178
  export function managedConfigDiagnostic(platform = process.platform, env: NodeJS.ProcessEnv = process.env): string {
@@ -174,6 +190,7 @@ export function managedConfigDiagnostic(platform = process.platform, env: NodeJS
174
190
  // ── Verification engine ──────────────────────────────────────────────────────
175
191
  import {
176
192
  detectVerifyCommand,
193
+ resolveVerifyTimeoutMs,
177
194
  runVerify,
178
195
  snipVerifyOutput,
179
196
  getCurrentGitCommitHash,
@@ -183,6 +200,7 @@ import {
183
200
 
184
201
  export {
185
202
  detectVerifyCommand,
203
+ resolveVerifyTimeoutMs,
186
204
  runVerify,
187
205
  snipVerifyOutput,
188
206
  getCurrentGitCommitHash,
@@ -225,6 +243,7 @@ import {
225
243
  clearContinuationState,
226
244
  continueUnfinishedSession,
227
245
  isGeneratedContinuationMessage,
246
+ markInterrupted,
228
247
  recordUserMessage,
229
248
  } from "./policies/continuation.ts";
230
249
  import {
@@ -363,15 +382,22 @@ export function buildCompactionContext(operationalState: string, priorityBlocks:
363
382
  return context;
364
383
  }
365
384
 
366
- export const WorkflowGuard: Plugin = async (ctx) => {
385
+ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
367
386
  // Honor worktree if present (e.g. opencode worktrees or devcontainers)
368
387
  // so worktree plugins cannot punch through boundary gates. When the host
369
388
  // reports the filesystem root, use the SDK's actual project worktree instead.
370
389
  const hostRoot = ctx.worktree || ctx.directory || process.cwd();
371
390
  const hostIsFilesystemRoot = resolve(hostRoot) === resolve(hostRoot, "..");
372
- const effectiveRoot = hostIsFilesystemRoot && ctx.project?.worktree
391
+ let effectiveRoot = hostIsFilesystemRoot && ctx.project?.worktree && resolve(ctx.project.worktree) !== resolve(ctx.project.worktree, "..")
373
392
  ? ctx.project.worktree
374
393
  : hostRoot;
394
+ if (resolve(effectiveRoot) === resolve(effectiveRoot, "..")) {
395
+ const nonRootCandidate = [ctx.directory, process.cwd()].find((p) => typeof p === "string" && resolve(p) !== resolve(p, ".."));
396
+ if (nonRootCandidate) {
397
+ const gitRoot = findGitRoot(nonRootCandidate);
398
+ effectiveRoot = gitRoot ?? nonRootCandidate;
399
+ }
400
+ }
375
401
  setWorkspaceRoot(effectiveRoot);
376
402
  setSdkClient(ctx.client);
377
403
  reloadProjectConfig(effectiveRoot);
@@ -439,6 +465,16 @@ export const WorkflowGuard: Plugin = async (ctx) => {
439
465
  throw new Error(`[workflow-guard] ${reason}${circuitBreakerSuffix}`);
440
466
  }
441
467
  toolLifecycle.start(input.sessionID, input.callID);
468
+ if (input.sessionID) {
469
+ const record = asRecord(args);
470
+ const workdir = typeof record?.workdir === "string" ? record.workdir : undefined;
471
+ const filePath = typeof record?.filePath === "string" ? record.filePath : (typeof record?.path === "string" ? record.path : undefined);
472
+ const candidatePath = workdir ?? filePath ?? toolWorktree;
473
+ if (candidatePath) {
474
+ const gitRoot = findGitRoot(candidatePath);
475
+ if (gitRoot) setSessionWorkspace(input.sessionID, gitRoot);
476
+ }
477
+ }
442
478
  if (input.tool === "read") {
443
479
  const target = editTargets(args, toolWorktree)[0];
444
480
  if (target) {
@@ -732,12 +768,24 @@ export const WorkflowGuard: Plugin = async (ctx) => {
732
768
  }
733
769
  }
734
770
  if (event?.type === "message.updated") {
735
- const info = (event.properties as { info?: { id?: unknown; role?: unknown; sessionID?: unknown } })?.info;
771
+ const info = (event.properties as { info?: { id?: unknown; role?: unknown; sessionID?: unknown; error?: { name?: unknown } } })?.info;
736
772
  if (info?.role === "user" && typeof info.sessionID === "string") {
737
773
  await runWithRuntimeState(effectiveRoot, ctx.client, () => {
738
774
  recordUserMessage(info.sessionID as string, typeof info.id === "string" ? info.id : undefined);
739
775
  });
740
776
  }
777
+ // A user-initiated interrupt (Esc) surfaces as an aborted
778
+ // assistant message: stop automatic continuation for the
779
+ // session until genuine user input arrives.
780
+ if (info?.role === "assistant" && typeof info.sessionID === "string" && info.error?.name === "MessageAbortedError") {
781
+ await runWithRuntimeState(effectiveRoot, ctx.client, () => markInterrupted(info.sessionID as string));
782
+ }
783
+ }
784
+ if (event?.type === "session.error") {
785
+ const props = event.properties as { sessionID?: unknown; error?: { name?: unknown } };
786
+ if (typeof props?.sessionID === "string" && props?.error?.name === "MessageAbortedError") {
787
+ await runWithRuntimeState(effectiveRoot, ctx.client, () => markInterrupted(props.sessionID as string));
788
+ }
741
789
  }
742
790
  if (event?.type === "session.deleted") {
743
791
  const sessionID = (event.properties as { info?: { id?: unknown } })?.info?.id;
@@ -773,8 +821,12 @@ export const WorkflowGuard: Plugin = async (ctx) => {
773
821
  };
774
822
  };
775
823
 
776
- // Default export MUST be a V1 PluginModule record.
824
+ // Default export supports BOTH generations: V1 calls server(), V2 reads id+setup().
825
+ // Spread Plugin.define() so the V2 definition type-checks separately from server().
777
826
  export default {
778
- id: "workflow-guard",
827
+ ...Plugin.define({
828
+ id: "workflow-guard",
829
+ setup: WorkflowGuardV2,
830
+ }),
779
831
  server: WorkflowGuard,
780
832
  } satisfies PluginModule;