opencode-workflow-guard 1.12.1 → 1.13.2

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.
@@ -8,7 +8,7 @@ import {
8
8
  recordMutation,
9
9
  } from "../lib/state.ts";
10
10
  import { shellWords, unwrapShellCommand } from "../lib/shell.ts";
11
- import { isProtectedPath, PROTECTED_PATH_REASON } from "./tamper.ts";
11
+ import { isCollaborationInvocation, isProtectedPath, PROTECTED_PATH_REASON } from "./tamper.ts";
12
12
  import { isSecretPath, secretIn } from "./secrets.ts";
13
13
  import { onProtectedBranch, branchGuardReason } from "./git.ts";
14
14
  import {
@@ -129,14 +129,22 @@ export function teeTargetsIn(segment: string): string[] {
129
129
 
130
130
  function redirectMutationsIn(segment: string): ShellMutation[] {
131
131
  const mutations: ShellMutation[] = [];
132
- const redirectRe = /(?:^|[\s>]|(?<=[^\s"']))([0-9]*&?>>?&?)\s*["']?([^\s>&|;"']+)/g;
132
+ // The `(?!=)` lookahead after the op rejects comparison operators (`>=`,
133
+ // `==`) so they cannot match as a redirect op with `=` as its target.
134
+ const redirectRe = /(?:^|[\s>]|(?<=[^\s"']))([0-9]*&?>>?&?(?!=))\s*["']?([^\s>&|;"']+)/g;
133
135
  for (const redirectMatch of segment.matchAll(redirectRe)) {
134
136
  if (!redirectMatch[1] || !redirectMatch[2]) continue;
135
137
  const op = redirectMatch[1];
136
138
  const target = redirectMatch[2];
137
139
  // Filter fd duplication (e.g. 2>&1, >&2) where target is purely an fd number
138
140
  const isFdDup = op.endsWith("&") && /^\d+$/.test(target);
139
- if (!isFdDup && !/^\/dev\/(?:null|stdout|stderr|tty|fd\/\d+)$/.test(target)) {
141
+ // Filter comparison operands from embedded non-shell syntax (SQL, awk,
142
+ // test expressions): `WHERE count > 5`, `x >= 10`. A bare `>` whose
143
+ // target is purely numeric is overwhelmingly a comparison operand, not
144
+ // a redirect into a numeric filename. `>>` and fd forms (`2>`) keep
145
+ // redirect semantics.
146
+ const isComparisonOperand = op === ">" && /^\d+$/.test(target);
147
+ if (!isFdDup && !isComparisonOperand && !/^\/dev\/(?:null|stdout|stderr|tty|fd\/\d+)$/.test(target)) {
140
148
  mutations.push({
141
149
  kind: "redirect",
142
150
  target,
@@ -303,8 +311,17 @@ export function secretSourceInFilesystemCommand(segment: string): string | undef
303
311
  return undefined;
304
312
  }
305
313
 
314
+ // Remove single- and double-quoted spans from a shell segment. Used to
315
+ // analyze the residue of collaboration invocations: quoted arguments are
316
+ // command data and can never be shell redirects, while unquoted redirects
317
+ // keep receiving full validation.
318
+ function stripQuotedSpans(segment: string): string {
319
+ return segment.replace(/'[^'\n]*'/g, " ").replace(/"[^"\n]*"/g, " ");
320
+ }
321
+
306
322
  export function detectShellMutation(command: string): ShellMutation | undefined {
307
- for (const segment of command.split(/[\n|;&]+/)) {
323
+ for (const rawSegment of command.split(/[\n|;&]+/)) {
324
+ const segment = isCollaborationInvocation(rawSegment) ? stripQuotedSpans(rawSegment) : rawSegment;
308
325
  const simpleMutations = simpleFilesystemMutations(segment);
309
326
  if (simpleMutations.length > 0) return simpleMutations[0];
310
327
  const teeTargets = teeTargetsIn(segment);
@@ -328,7 +345,11 @@ export async function guardShellMutation(
328
345
  ): Promise<string | undefined> {
329
346
  const root = getWorkspaceRoot();
330
347
  let hasMutation = false;
331
- for (const segment of command.split(/[\n|;&]+/)) {
348
+ for (const rawSegment of command.split(/[\n|;&]+/)) {
349
+ // Same residue analysis as detectShellMutation: quoted arguments of
350
+ // gh/glab/az PR/issue commands are command data, not shell redirects,
351
+ // while unquoted redirects still get full validation below.
352
+ const segment = isCollaborationInvocation(rawSegment) ? stripQuotedSpans(rawSegment) : rawSegment;
332
353
  const secretSource = secretSourceInFilesystemCommand(segment);
333
354
  if (secretSource) {
334
355
  return `Blocked: shell command would copy, move, or link sensitive file '${secretSource}' under a non-secret name.`;
@@ -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");
@@ -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,95 @@ 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` creates a layer owned by the calling component and only
109
+ // resolves inside the TUI's provider render tree; plugin setup() runs as a
110
+ // plain async call outside it. Slot renders execute in a reactive scope under
111
+ // the provider, so the layer is registered from an app-slot claim instead.
112
+ // The guard keeps repeated renders from stacking duplicate layers; layers and
113
+ // slot claims are disposed automatically on unload.
114
+ let keymapLayerRegistered = false;
115
+ ctx.ui.slot({
116
+ append: "app",
117
+ render: () => {
118
+ if (!keymapLayerRegistered) {
119
+ keymapLayerRegistered = true;
120
+ ctx.keymap.layer(() => ({
121
+ commands: [{
122
+ id: "workflow-guard.project-options",
123
+ title: "Workflow Guard: Project Options",
124
+ description: "Toggle Workflow Guard project options (recovery checkpoints, project memory, learning, title settle, ralph mode)",
125
+ group: "Workflow Guard",
126
+ palette: true,
127
+ slash: { name: "guard-options" },
128
+ async run() {
129
+ const root = optionsRoot();
130
+ for (;;) {
131
+ const current = new Map(TOGGLE_OPTIONS.map((option) => [option.key, readProjectOption(root, option.key)]));
132
+ const choice = await ctx.ui.dialog.select<ProjectToggle>({
133
+ title: "Workflow Guard Project Options",
134
+ options: TOGGLE_OPTIONS.map((option) => ({
135
+ title: `${option.label}: ${current.get(option.key) ? "On" : "Off"}`,
136
+ value: option.key,
137
+ description: option.description,
138
+ })),
139
+ });
140
+ if (!choice) break;
141
+ try {
142
+ const enabled = !readProjectOption(root, choice);
143
+ const path = writeProjectOption(root, choice, enabled);
144
+ ctx.ui.toast.show({
145
+ variant: "success",
146
+ title: "Workflow Guard",
147
+ message: `Saved ${choice} ${enabled ? "on" : "off"} in ${path}. Restart OpenCode to apply.`,
148
+ });
149
+ } catch (error) {
150
+ ctx.ui.toast.show({
151
+ variant: "error",
152
+ title: "Workflow Guard",
153
+ message: error instanceof Error ? error.message : String(error),
154
+ });
155
+ break;
156
+ }
157
+ }
158
+ },
159
+ }],
160
+ bindings: [],
161
+ }));
162
+ }
163
+ return null;
164
+ },
165
+ });
166
+
167
+ ctx.ui.slot({ append: "home.footer.status", render: () => badge() });
168
+ ctx.ui.slot({ append: "prompt.footer.status", render: () => badge() });
169
+
170
+ return () => {
171
+ // Slot claims and keymap layers are disposed automatically on unload.
172
+ };
173
+ };
174
+
175
+ // ── V1 TUI plugin ─────────────────────────────────────────────────────────────
176
+
78
177
  export const WorkflowGuardTui: TuiPlugin = async (api) => {
79
178
  api.keymap.registerLayer({
80
179
  commands: [{
@@ -85,11 +184,6 @@ export const WorkflowGuardTui: TuiPlugin = async (api) => {
85
184
  slashName: "guard-options",
86
185
  run() {
87
186
  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
187
  const toggle = (key: ProjectToggle) => {
94
188
  const enabled = !readProjectOption(root, key);
95
189
  const path = writeProjectOption(root, key, enabled);
@@ -99,13 +193,12 @@ export const WorkflowGuardTui: TuiPlugin = async (api) => {
99
193
  api.ui.dialog.replace(() => api.ui.DialogSelect({
100
194
  title: "Workflow Guard Project Options",
101
195
  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
- ],
196
+ options: TOGGLE_OPTIONS.map((option) => ({
197
+ title: `${option.label}: ${readProjectOption(root, option.key) ? "On" : "Off"}`,
198
+ value: option.key,
199
+ description: option.description,
200
+ onSelect: () => toggle(option.key),
201
+ })),
109
202
  }));
110
203
  },
111
204
  }],
@@ -129,7 +222,11 @@ export const WorkflowGuardTui: TuiPlugin = async (api) => {
129
222
  });
130
223
  };
131
224
 
225
+ // Default export supports BOTH generations: V1 TUI clients call tui(), V2 reads setup().
132
226
  export default {
133
- id: "workflow-guard-ui",
227
+ ...Plugin.define({
228
+ id: "workflow-guard-ui",
229
+ setup: WorkflowGuardTuiV2,
230
+ }),
134
231
  tui: WorkflowGuardTui,
135
- } satisfies TuiPluginModule;
232
+ } 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";
@@ -188,6 +190,7 @@ export function managedConfigDiagnostic(platform = process.platform, env: NodeJS
188
190
  // ── Verification engine ──────────────────────────────────────────────────────
189
191
  import {
190
192
  detectVerifyCommand,
193
+ resolveVerifyTimeoutMs,
191
194
  runVerify,
192
195
  snipVerifyOutput,
193
196
  getCurrentGitCommitHash,
@@ -197,6 +200,7 @@ import {
197
200
 
198
201
  export {
199
202
  detectVerifyCommand,
203
+ resolveVerifyTimeoutMs,
200
204
  runVerify,
201
205
  snipVerifyOutput,
202
206
  getCurrentGitCommitHash,
@@ -239,6 +243,7 @@ import {
239
243
  clearContinuationState,
240
244
  continueUnfinishedSession,
241
245
  isGeneratedContinuationMessage,
246
+ markInterrupted,
242
247
  recordUserMessage,
243
248
  } from "./policies/continuation.ts";
244
249
  import {
@@ -377,7 +382,7 @@ export function buildCompactionContext(operationalState: string, priorityBlocks:
377
382
  return context;
378
383
  }
379
384
 
380
- export const WorkflowGuard: Plugin = async (ctx) => {
385
+ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
381
386
  // Honor worktree if present (e.g. opencode worktrees or devcontainers)
382
387
  // so worktree plugins cannot punch through boundary gates. When the host
383
388
  // reports the filesystem root, use the SDK's actual project worktree instead.
@@ -763,12 +768,24 @@ export const WorkflowGuard: Plugin = async (ctx) => {
763
768
  }
764
769
  }
765
770
  if (event?.type === "message.updated") {
766
- 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;
767
772
  if (info?.role === "user" && typeof info.sessionID === "string") {
768
773
  await runWithRuntimeState(effectiveRoot, ctx.client, () => {
769
774
  recordUserMessage(info.sessionID as string, typeof info.id === "string" ? info.id : undefined);
770
775
  });
771
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
+ }
772
789
  }
773
790
  if (event?.type === "session.deleted") {
774
791
  const sessionID = (event.properties as { info?: { id?: unknown } })?.info?.id;
@@ -804,8 +821,12 @@ export const WorkflowGuard: Plugin = async (ctx) => {
804
821
  };
805
822
  };
806
823
 
807
- // 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().
808
826
  export default {
809
- id: "workflow-guard",
827
+ ...Plugin.define({
828
+ id: "workflow-guard",
829
+ setup: WorkflowGuardV2,
830
+ }),
810
831
  server: WorkflowGuard,
811
832
  } satisfies PluginModule;