Statewright workflows with the Arbiter, native approvals, and model routing in Claude Code and Desktop Code.

The 0.3.4 plugin adds the Arbiter, workflow sidebar, compact tool results and per-state model routing to Claude Code and Claude Desktop → Code → Local. Use ordinary Claude; no separate app or Statewright launcher is required.
Use Claude Code 2.1.295 or newer. Desktop bundles its own Code engine: check /status inside a Code conversation, not only the Desktop application version. This integration is not for the ordinary Chat tab or Cowork.
See the Claude Desktop guide for the first-message startup, clickable pills, workflow map, result details and connection recovery.
In Claude Code:
/plugin marketplace add statewright/statewright
/plugin install statewright@statewright
For an existing install, run claude plugin update statewright@statewright in a terminal. Reload an open conversation with /reload-plugins. Installation is shared with Desktop Code.
If the old installation used setup.sh or hooks added directly to settings, complete Upgrade from manual hooks before starting a workflow. Plugin updates do not remove those hooks; do not run the native and legacy integrations together.
The Arbiter appears when the Code conversation starts. Select its connection status to open the sidebar, then Connect. Finish Statewright sign-in and Claude's native permission prompts. Claude owns OAuth credentials and refresh; a Statewright API key file is not required.
The connection and permission to control a conversation are separate. An existing login can restore without another browser visit. Workflow-control consent is scoped to the live session; reloading or closing it clears that grant. It does not override file/command permission rules or workflow approvals.
Optional diagnostic reports follow your current Statewright account telemetry choice. /statewright configure → Diagnostic reports turns them off for this plugin even when the account allows them. Reports exclude prompts, commands, code and credentials. See telemetry and privacy.
Select a workflow in the sidebar and choose Start, or ask Claude to use a named workflow. Selecting its name alone does not start it. The Arbiter shows current state, available events, and model/effort when known. Expand tool-result Details to inspect the exact JSON behind a compact summary.
Use /statewright to reopen the sidebar, or turn the workflow Off there. Off restores the host-selected model. Requested effort is a setting, not proof of internal reasoning; routed response receipts identify the observed model.
Terminal separators are plain by default. /statewright configure opens saved preferences. Enable Powerline only if your terminal font supports it; /statewright powerline on, off, or default overrides only this session.
Set Statewright gateway through /statewright configure, then reload plugins. The default is https://mcp.statewright.ai/mcp. Your service must support the native Claude control protocol and register the statewright-claude OAuth client.
If browser sign-in is unavailable, run this in a separate terminal on the same machine and in the same Claude configuration scope:
claude mcp login plugin:statewright:statewright --no-browser
Open its link on a browser-capable computer and paste the full callback URL into that terminal. Return to Claude and connect again. Do not paste tokens or callback URLs into the conversation. Both paths require the native Claude CLI; automatic browser sign-in also requires Expect.
Marketplace-only installations upgrade under the same statewright@statewright identity. If you previously ran setup.sh or installed Statewright hooks directly in settings, the plugin update cannot remove them.
Close affected Claude sessions first. From this plugin directory, preview the cleanup using the exact old plugin directory named in your settings:
node scripts/migrate-native.mjs --settings /absolute/path/to/settings.json \
--legacy-root /absolute/path/to/old/claude-code
Review the reported positions. Repeat with --apply only after review; pass additional --legacy-root arguments for distinct old installations. The tool removes only exact legacy commands, retains a private byte-for-byte backup, and preserves unrelated hooks, permissions, plugin settings and credentials. Custom/ambiguous Statewright commands require manual review, not guessing. Check user and project settings separately. Do not edit settings concurrently.
Also remove a separately configured legacy Statewright MCP server through Claude's MCP settings, if present, and stop using the old managed Claude launcher. Do not remove other servers or grant blanket tool permissions. Never run the old setup.sh to repair a native installation; it now exits without changing anything.
/status and /plugin. Organization policy may prevent mods from loading.node --test plugins/claude-code/tests/*.test.mjs runs source tests from the repository root. claude plugin validate plugins/claude-code and claude plugin test plugins/claude-code exercise the native plugin contract. Fixture passes do not replace live OAuth, TUI or Desktop acceptance.
See Statewright docs, Claude mod support, and the repository.
hooks/connect.js 1056 lines1import { decodeCatalogConnection, connectionFailure, validName, resolveWorkflowName, gatewayTool, gatewayServer, catalogTool, catalogCall, emptyArgs, SERVER,
2 NATIVE_SERVER, LOGIN_COMMAND } from './catalog.mjs';
3import { createNativeControl, NATIVE_CONTROL_TOOL, NATIVE_CONNECT_TOOL, decodeNativeConnection,
4 executionReadiness, progressNodes } from './native-control.mjs';
5import { createExecutionAdapter, outcome } from './native-execution.mjs';
6import { ARBITER_PURPLE, ARBITER_ACCENT, STATE_BACKGROUND, observeJourney, statusPresentation, graphRows, eventSymbol } from './workflow-ui.mjs';
7import { registerResultUI } from './result-ui.mjs';
8import { desktopGraph, desktopTitle } from './desktop-ui.mjs';
9import { DEFAULT_SENTRY_DSN, diagnosticEvent, diagnosticsAllowed, formatEnvelope, observeTelemetryConsent as observeConsent } from './diagnostic-event.mjs';
10import { claudeRoute, routeEvent, routeReceipt, blockedStep, routingView, fableCreditGate, fableFallback, fallbackPermitted } from './native-routing.mjs';
11
12const PANE = 'statewright';
13const PAGE_SIZE = 32; // Native Select accepts at most 64 options.
14let navigationRevision = 0;
15const fresh = conversation => ({ conversation, desktopNavigationRevision: ++navigationRevision, epoch: 0, busy: false, connected: false,
16 names: [], workflowAliases: {}, selected: '', page: 0, notice: '', error: '', connectionIssue: null, manual: false, timer: null, paneOpen: false, handoff: false,
17 checked: false, interactive: false, pending: null, prompted: false, prompting: false, powerlineOverride: null, appearanceGeneration: 0,
18 native: null, control: null, controlError: '', generation: 0, requiresEnforcement: false, controlConsent: false, consentEpoch: 0,
19 runningTool: null, actionBusy: false, journey: null, showGraph: true, routes: [], routeError: '', modelActivity: null, baseRequest: null, fallback: null });
20let view = fresh('');
21let diagnosticOptions = {};
22const diagnosticSeen = new Set();
23let diagnosticAttempts = 0;
24function reportDiagnostic($, v, reason, operation, phase) {
25 if (['auth', 'request_aborted'].includes(reason)) return; // Signed-out/cancelled is not a malfunction.
26 const event = diagnosticEvent({ plugin: 'claude-code', version: '0.3.4', platform: 'claude-native',
27 handled: true, context: { mechanism: 'handled', runtime: 'claude-native', reason, operation, phase } });
28 const key = JSON.stringify(event.fingerprint);
29 if (diagnosticSeen.has(key) || diagnosticAttempts >= 20) return;
30 diagnosticSeen.add(key);
31 // Sending must not stall the connection or native permission result.
32 $.clock.after(0, async () => {
33 let delivered = false;
34 try {
35 if (view !== v || diagnosticOptions.diagnostics === false) return;
36 const disabled = await $.env.get('STATEWRIGHT_SENTRY_DISABLED');
37 const enabled = await $.env.get('STATEWRIGHT_SENTRY_ENABLED');
38 if (view !== v || !diagnosticsAllowed({ disabled, enabled,
39 consent: Date.now() - (v.telemetryCheckedAt ?? 0) <= 60000 ? v.telemetryConsent : null })) return;
40 diagnosticAttempts++;
41 const response = await $.http.fetch('https://glitch.enhasa.cloud/api/12/envelope/?sentry_version=7&sentry_key=3c30b803a5b44d74bf9657db7a89f033', {
42 method: 'POST', headers: { 'Content-Type': 'application/x-sentry-envelope' },
43 body: formatEnvelope({ dsn: DEFAULT_SENTRY_DSN, event }),
44 });
45 delivered = response.ok;
46 } catch { /* Never recurse, log secrets or affect host decisions. */ }
47 finally { if (!delivered) diagnosticSeen.delete(key); }
48 });
49}
50let connectTool = '';
51const workflowTools = new Map();
52const permissionFrames = new Map();
53let controlDiagnosticWrites = Promise.resolve();
54const canonical = value => Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object'
55 ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value;
56const argumentKey = args => JSON.stringify(canonical(args));
57const workflowSpecs = [
58 ['statewright_get_state', 'get_state', 'Read this conversation’s current Statewright workflow.', {}],
59 ['statewright_load_workflow', 'load_workflow', 'Start a named Statewright workflow in this conversation, or return its current state if that workflow is already active. Continue an active workflow; do not restart it.', { name: { type: 'string' } }],
60 ['statewright_transition', 'transition', 'Take a declared workflow event. A required human decision opens a native prompt; never supply an approval yourself.', { event: { type: 'string' }, data: { type: 'object' } }],
61 ['statewright_deactivate', 'deactivate', 'Turn Statewright Off when the user asks to stop or disable the workflow.', {}],
62 ['statewright_request_approval', 'request_approval', 'Show the current pending workflow decision to its authorized human reviewer. No decision argument.', {}],
63];
64function own($, next) { return next.origin?.plugin === $.plugin.name && typeof $.plugin.name === 'string'; }
65const flatArgs = ({ tool, tool_use_id, agentId, consent, requestMeta, ...args }) => args;
66const CONTROL_QUESTION = 'Allow Statewright to manage workflows in this conversation? This includes workflow changes and sending tool inputs and results for workflow checks. Claude’s file and command permissions and separate workflow approvals stay in place. This permission ends when the session closes or the plugin reloads.';
67const CONTROL_OPTIONS = ['Allow this session', 'Not now'];
68const CONTROL_QUESTION_INPUT = { questions: [{ question: CONTROL_QUESTION, header: 'Statewright',
69 options: CONTROL_OPTIONS.map(label => ({ label, description: '' })), multiSelect: false }] };
70async function isControlQuestion($, e, next, input) {
71 const v = await current($), q = v.controlQuestion;
72 if (!q || q.generation !== v.generation || q.epoch !== v.consentEpoch || !own($, next) || e.tool !== 'AskUserQuestion' ||
73 !e.tool_use_id || e.agentId || next.signal?.aborted ||
74 (q.invocation && q.invocation !== e.tool_use_id) || argumentKey(input) !== argumentKey(CONTROL_QUESTION_INPUT)) return false;
75 q.invocation = e.tool_use_id;
76 return true;
77}
78async function callControl($, args, permission) {
79 const v = await current($), key = argumentKey(args);
80 if (args.context?.conversation_id !== v.conversation) throw new Error('The Statewright conversation changed before dispatch.');
81 const frame = { v, generation: v.generation, consentEpoch: v.consentEpoch, permission, invocation: null };
82 if (permissionFrames.has(key)) throw new Error('This Statewright request is already in progress.');
83 permissionFrames.set(key, frame);
84 try { return await $.mcp.call(NATIVE_SERVER, NATIVE_CONTROL_TOOL, args); }
85 catch (error) {
86 const diagnostic = frame.failure ?? controlFailure(error);
87 const write = async () => {
88 if (await current($) !== v || frame.generation !== v.generation) return;
89 await $.store.set('latest-control-diagnostic', {
90 schema: 'statewright/control-diagnostic/v1', conversation: v.conversation,
91 at: Date.now(), operation: args.operation, ...diagnostic,
92 });
93 };
94 controlDiagnosticWrites = controlDiagnosticWrites.then(write, write).catch(() => {});
95 await controlDiagnosticWrites;
96 reportControlDiagnostic($, v, diagnostic.code, args.operation);
97 const message = diagnostic.code === 'auto_review_unavailable'
98 ? 'Claude couldn’t review this workflow action.'
99 : diagnostic.code === 'permission_refused' ? 'Claude did not permit this workflow action.'
100 : diagnostic.code === 'tool_unavailable' ? 'Claude hasn’t loaded Statewright’s workflow tools. Restart Claude and resume this conversation.'
101 : 'Statewright could not complete this workflow action. Refresh its status before trying again.';
102 throw new Error(`${message} [control/${diagnostic.code}]`);
103 }
104 finally { if (permissionFrames.get(key) === frame) permissionFrames.delete(key); }
105}
106function reportControlDiagnostic($, v, code, operation) { reportDiagnostic($, v, code, operation, 'control'); }
107function controlFailure(error) {
108 const { code } = connectionFailure('control', error);
109 // Error text is diagnostic only. Even native deny can withhold a result
110 // after execution; no exception proves that a remote mutation did not run.
111 return { code };
112}
113async function nativeRequest($, operation, input, permission, fenceRead = false, owner) {
114 const v = await current($);
115 if (owner && (v !== owner.view || v.generation !== owner.generation))
116 throw new Error('This request was superseded by a newer workflow action.');
117 const generation = v.generation;
118 if (!v.control) throw new Error('Connect to Statewright first.');
119 const result = await v.control.request({ operation, input,
120 ...(operation === 'get_state' && !fenceRead ? {} : { expectedRevision: v.native?.revision }) }, permission);
121 if (await current($) !== v || v.generation !== generation) throw new Error('This request was superseded by a newer workflow action.');
122 v.native = result;
123 observeConsent(v, result.telemetry_consent);
124 v.journey = observeJourney(v.journey, result);
125 v.requiresEnforcement = Boolean(result.state && !result.state.is_final);
126 await $.store.set(`workflow-active:${v.conversation}`, v.requiresEnforcement);
127 redraw($); $.ui.invalidate('prompt.context');
128 return result;
129}
130async function workflowAction($, operation, input = {}, shownRevision, permission) {
131 const v = await current($);
132 if (operation !== 'deactivate' && (v.actionBusy || (v.runningTool && operation !== 'get_state')))
133 throw new Error('Wait for the current tool to finish before changing the workflow.');
134 if (shownRevision !== undefined && shownRevision !== v.native?.revision)
135 throw new Error('The workflow changed. Refresh before choosing its next action.');
136 if (!v.control) throw new Error('Connect to Statewright first.');
137 if (operation === 'load_workflow' && !executionReadiness(v.native, true).available)
138 throw new Error(executionReadiness(v.native, true).reason);
139 v.actionBusy = true; v.controlError = ''; redraw($);
140 if (operation === 'load_workflow' || operation === 'deactivate') v.generation++;
141 const generation = v.generation;
142 const owner = { view: v, generation };
143 try {
144 if (operation === 'load_workflow' && v.native?.state && !v.native.state.is_final) {
145 // Sidebar Start and a later natural-language request express the same
146 // intent. Read the authoritative state, not an old cached load outcome.
147 // Never send a duplicate mutation or replace a different active run.
148 const snapshot = await nativeRequest($, 'get_state', {}, permission, false, owner);
149 if (await current($) !== v || v.generation !== generation)
150 throw new Error('This request was superseded by a newer workflow action.');
151 if (snapshot.state && !snapshot.state.is_final) {
152 // A fresh alias map proves its targets are in the current owned catalog;
153 // the connection's selectable names may predate additions or removals.
154 const aliases = snapshot.workflow_aliases ?? v.workflowAliases;
155 const names = snapshot.workflow_aliases === undefined ? v.names : Object.values(aliases);
156 if (snapshot.state.workflow !== resolveWorkflowName(input.name, names, aliases)) return { ...snapshot, status: 'workflow_conflict',
157 message: 'A different workflow is active in this conversation. Continue it, or ask the user before turning it Off and starting another.' };
158 return { ...snapshot, status: 'already_active',
159 message: 'This workflow is already active. Continue from the current state and follow its tools and transitions; no workflow was restarted.' };
160 }
161 }
162 if (operation === 'deactivate') {
163 // Off must not wait behind an outstanding human prompt. Read a fresh
164 // revision in the same authenticated conversation, not another session.
165 v.control = createNativeControl({ conversation: () => $.session.id(),
166 call: (args, verdict) => callControl($, args, verdict) });
167 // Include the last displayed revision on this read. The server allows
168 // read-only requests with it; the exact invocation differs from an old
169 // unfenced read still waiting in Claude's permission dialog.
170 await nativeRequest($, 'get_state', {}, permission, true, owner);
171 }
172 if (operation === 'load_workflow') {
173 v.requiresEnforcement = true;
174 await $.store.set(`workflow-active:${v.conversation}`, true);
175 }
176 if (operation === 'request_approval') {
177 const state = v.native?.state;
178 if (!state?.pending_approval || !state.run_id) throw new Error('There is no pending review for this conversation.');
179 input = { run_id: state.run_id, approval_id: state.pending_approval.approval_id };
180 }
181 // Storage and readback can yield while Off or a resumed conversation wins.
182 // Do not let the following request recapture that newer controller.
183 if (await current($) !== v || v.generation !== generation)
184 throw new Error('This request was superseded by a newer workflow action.');
185 let result = await nativeRequest($, operation, input, permission, false, owner);
186 if (result.outcome?.isError) throw new Error(result.outcome.content.map(b => b.text).join('\n'));
187 if (operation === 'deactivate') { v.controlConsent = false; v.consentEpoch++; v.controlQuestion = null; }
188 // A transition's pending ticket is resolved only through authenticated MCP
189 // elicitation. The model and workspace never submit approve/reject fields.
190 if (operation === 'transition' && result.state?.pending_approval && result.state.run_id) {
191 const pending = result.state.pending_approval;
192 result = await nativeRequest($, 'request_approval', { run_id: result.state.run_id, approval_id: pending.approval_id }, permission, false, owner);
193 }
194 return result;
195 } catch (error) {
196 if (await current($) === v && v.generation === generation) {
197 v.controlError = error?.message || 'Statewright could not complete this action. Refresh and try again.';
198 }
199 throw error;
200 } finally { if (await current($) === v && v.generation === generation) { v.actionBusy = false; redraw($); } }
201}
202function executionFor($) {
203 // Host capabilities remain statically scoped to this hooks module. The pure
204 // adapter receives callbacks, never the native host object itself.
205 return createExecutionAdapter({ view: () => current($), request: (_, operation, input) => nativeRequest($, operation, input),
206 after: (ms, callback) => $.clock.after(ms, callback),
207 fault: (v, message) => { v.controlError = message; redraw($); } });
208}
209// Native Claude owns OAuth and its credential store. No shell, token access or
210// generic /mcp picker. The login child reads the same installed plugin and
211// saved gateway configuration; it never overrides enabled plugins or policy.
212// Keep one bounded login across view cancellation/resume until it settles.
213let nativeSignIn = null;
214function signIn($) {
215 if (!nativeSignIn) nativeSignIn = Promise.resolve()
216 .then(() => $.process.run(['/usr/bin/expect', `${$.plugin.root}/scripts/native-login.exp`], { timeoutMs: 330000 }))
217 .then(result => {
218 if (result.exitCode !== 0) throw Object.assign(new Error('Native sign-in did not complete'), { loginExit: result.exitCode });
219 })
220 .finally(() => { nativeSignIn = null; });
221 return nativeSignIn;
222}
223function invalidate(v) {
224 v.epoch++; v.timer?.cancel(); v.timer = null; v.busy = false;
225 v.pending?.resolve(); v.pending = null;
226}
227async function current($) {
228 const id = await $.session.id();
229 if (typeof id !== 'string' || !id) throw new Error('Conversation unavailable');
230 if (id !== view.conversation) { invalidate(view); view = fresh(id); }
231 return view;
232}
233const live = async ($, v, epoch) => (await current($)) === v && v.epoch === epoch;
234const redraw = $ => $.ui.invalidate('ui.render');
235const desktopViewKey = v => JSON.stringify([
236 v.conversation, v.epoch, v.generation, v.appearanceGeneration, v.busy,
237 v.connected, v.names, v.selected, v.page, v.notice, v.error, v.connectionIssue, v.manual,
238 Boolean(nativeSignIn), v.actionBusy, v.runningTool, v.controlError,
239 v.native, v.journey, v.showGraph, routingView(v), v.desktopClientFailed,
240]);
241async function desktopAction($, conversation, revision, key) {
242 const v = await current($);
243 if (conversation !== v.conversation || revision !== v.desktopRevision || v.desktopKey !== desktopViewKey(v)) return;
244 const action = v.desktopActions?.get(key);
245 if (!action) return;
246 v.connectionSurface = 'desktop';
247 v.desktopRevision++; v.desktopKey = null;
248 try { await action(); }
249 catch { if (await current($) === v) v.controlError = 'That action did not complete. Refresh the connection and try again.'; }
250 finally { if (await current($) === v) redraw($); }
251}
252async function* showModelRequest($, v, event, next, probe, fallback = false) {
253 const activity = { model: event.model, effort: event.effort ?? null, probe, fallback };
254 v.modelActivity = activity; redraw($);
255 let stream, completed = false;
256 try {
257 stream = next(event);
258 while (true) {
259 const item = await stream.next();
260 if (item.done) { completed = true; return item.value; }
261 if (probe && item.value.kind !== 'engine') probe.semantic = true;
262 yield item.value;
263 }
264 }
265 finally {
266 try { if (!completed) await stream?.return?.(); }
267 finally {
268 // A late stream completion cannot clear a newer session/request's display.
269 if (view === v && v.modelActivity === activity) { v.modelActivity = null; redraw($); }
270 }
271 }
272}
273function* stopRoute(v, e, message) {
274 v.routeError = message;
275 return yield* blockedStep(e, message);
276}
277async function openPane($, focus = true, navigation) {
278 const v = await current($);
279 if (navigation && (v !== navigation.view || v.desktopNavigationRevision !== navigation.revision)) return;
280 v.paneOpen = true;
281 redraw($);
282 return $.ui.open({ id: PANE, title: 'Statewright', ...(focus ? { focus: true } : {}), closeOnEscape: true, rows: 12 });
283}
284async function cancel($, v) {
285 if (await current($) !== v) return;
286 invalidate(v); v.connected = false;
287 v.checked = true;
288 v.notice = 'Connection cancelled here. You can close any open sign-in window.'; redraw($);
289}
290const refusals = {
291 policy: 'Claude’s policy does not allow this connection. Your administrator can review it.',
292 disabled: 'The Statewright server is disabled in Claude. Open /mcp to review it.',
293 unapproved: 'Claude needs your permission to use this server. Open /mcp to review it.',
294 unlisted: 'The Statewright connection is missing from this plugin. Reload the installed plugin.',
295 failed: 'Statewright could not be reached. Check the connection and try again.',
296 auth: 'Sign-in hasn’t completed. Try connecting again when you’re ready.',
297};
298
299let connectionDiagnosticWrites = Promise.resolve();
300async function recordConnection($, v, epoch, stage, outcome, code, operation = 'unknown') {
301 if (!await live($, v, epoch)) return;
302 const diagnostic = { schema: 'statewright/connection-diagnostic/v1',
303 conversation: v.conversation, attempt: epoch, at: Date.now(), stage, outcome, code, operation };
304 // One bounded record, not an unbounded transcript. A storage/log failure must
305 // never change connection policy or prevent Cancel/recovery from working.
306 const write = async () => {
307 if (await live($, v, epoch)) await $.store.set('latest-connection-diagnostic', diagnostic);
308 };
309 connectionDiagnosticWrites = connectionDiagnosticWrites.then(write, write).catch(() => {});
310 await connectionDiagnosticWrites;
311 if (outcome === 'failed' && await live($, v, epoch)) {
312 reportDiagnostic($, v, code, stage === 'login' ? 'login' : 'connect', stage);
313 v.connectionIssue = code;
314 if (code === 'auto_review_unavailable')
315 v.error = 'Claude couldn’t review this request. Choose Connect to approve this connection in Claude’s permission prompt. Your permission mode stays unchanged.';
316 if (code === 'tool_unavailable')
317 v.error = 'Claude hasn’t loaded Statewright’s connection tools.';
318 v.error += ` [${stage}/${code}]`;
319 try { await $.ui.log(`Statewright connection: ${stage}/${code} (${operation}).`); } catch { /* sidebar remains available */ }
320 }
321}
322
323async function connect($, v, epoch, allowSignIn) {
324 let stage = 'check';
325 try {
326 if (!await live($, v, epoch)) return;
327 v.telemetryConsent = null; v.telemetryCheckedAt = 0;
328 await recordConnection($, v, epoch, stage, 'running', 'checking');
329 if (!await live($, v, epoch)) return;
330 let result;
331 // session.start precedes the native REPL binding. Only this explicit
332 // pre-dispatch readiness error can wait; policy/auth/network refusals
333 // are never retried here. Nine waits total at most 2.25 seconds.
334 for (let attempt = 0; attempt < 10; attempt++) {
335 try { result = await $.mcp.connect(SERVER); break; }
336 catch (error) {
337 if (allowSignIn || !String(error?.message).includes('no session is bound')) throw error;
338 if (attempt === 9) {
339 v.notice = 'Claude is still starting. Open /statewright to check your saved connection.';
340 return;
341 }
342 await new Promise(resolve => $.clock.after(250, resolve));
343 if (!await live($, v, epoch)) return;
344 }
345 }
346 if (!await live($, v, epoch)) return;
347 if (!result?.isConnected && result?.reason === 'auth' && allowSignIn) {
348 v.notice = 'Opening Statewright sign-in in your browser…';
349 v.manual = true;
350 redraw($);
351 // Leave the conversation visible while Claude waits for the browser.
352 v.handoff = true;
353 try {
354 if (v.connectionSurface !== 'desktop') await $.ui.close({ id: PANE });
355 if (!await live($, v, epoch)) return;
356 await signIn($);
357 }
358 catch (error) {
359 if (await live($, v, epoch)) v.error = error?.loginExit === 127
360 ? 'Claude Code’s sign-in helper could not start. Check that the native Claude Code CLI is installed, then try again.'
361 : error?.loginExit === 124
362 ? 'Statewright sign-in timed out. Try connecting again and finish approval in your browser.'
363 : v.connectionSurface === 'desktop'
364 ? 'Sign-in didn’t complete. Try connecting again.'
365 : 'Sign-in didn’t complete. Try again, or use the terminal command below.';
366 await recordConnection($, v, epoch, 'login', 'failed', error?.loginExit === 127 ? 'helper_unavailable'
367 : error?.loginExit === 124 ? 'login_timeout' : 'login_incomplete');
368 return;
369 }
370 finally { v.handoff = false; }
371 if (!await live($, v, epoch)) return;
372 // A CLI login updates the native credential store outside this process.
373 // Reconnect just this server before asking the mod connection API again.
374 stage = 'reconnect';
375 await $.command.run({ command: 'mcp', args: `reconnect ${NATIVE_SERVER}` });
376 if (!await live($, v, epoch)) return;
377 result = await $.mcp.connect(SERVER);
378 if (!await live($, v, epoch)) return;
379 }
380 if (!result?.isConnected) {
381 v.error = refusals[result?.reason] ?? refusals.failed;
382 v.manual = result?.reason === 'auth';
383 await recordConnection($, v, epoch, stage, 'failed', Object.hasOwn(refusals, result?.reason) ? result.reason : 'failed');
384 return;
385 }
386 // Do not trust a foreign server identity returned by another mod.
387 stage = 'identity';
388 if (result.server !== NATIVE_SERVER) throw new Error('Unexpected server');
389 // Authentication succeeded: a later catalog failure is not a login prompt.
390 v.manual = false;
391 stage = 'catalog';
392 await recordConnection($, v, epoch, stage, 'running', 'reading_workflows');
393 if (!await live($, v, epoch)) return;
394 if (allowSignIn) { v.notice = 'Confirm the connection in Claude’s permission prompt…'; redraw($); }
395 // Only a deliberate user action invokes the gateway's marked consent read.
396 // Quiet startup stays on the ordinary policy path. No fallback after denial.
397 const resultCatalog = allowSignIn
398 ? await $.mcp.call(result.server, NATIVE_CONNECT_TOOL, { context: { version: 1, conversation_id: v.conversation } })
399 : await $.mcp.call(result.server, 'statewright_list_workflows', {});
400 stage = 'catalog_decode';
401 const catalog = allowSignIn ? decodeNativeConnection(resultCatalog, v.conversation) : decodeCatalogConnection(resultCatalog);
402 if (!await live($, v, epoch)) return;
403 observeConsent(v, catalog.snapshot?.telemetry_consent ?? catalog.telemetryConsent);
404 const names = catalog.workflows;
405 v.names = names;
406 v.workflowAliases = catalog.workflowAliases;
407 if (!names.includes(v.selected)) v.selected = names.includes('agentic-engineering')
408 ? 'agentic-engineering' : names[0] ?? '';
409 v.page = Math.max(0, Math.floor(names.indexOf(v.selected) / PAGE_SIZE));
410 v.connected = true;
411 v.manual = false;
412 v.notice = '';
413 v.error = '';
414 await recordConnection($, v, epoch, 'complete', 'connected', 'connected');
415 if (!await live($, v, epoch)) return;
416 if (catalog.unsupportedControl)
417 v.controlError = 'Workflow status needs a newer Statewright plugin. Your workflow list is still available.';
418 if (catalog.nativeControl) {
419 // A catalog's legacy `active` never identifies this conversation. Read
420 // only when this authenticated connection advertises the new contract.
421 const control = createNativeControl({ conversation: () => $.session.id(),
422 call: (args, verdict) => callControl($, args, verdict) });
423 v.control = control;
424 try {
425 const snapshot = allowSignIn ? await control.acceptConnection(resultCatalog)
426 : await control.request({ operation: 'get_state', input: {} });
427 if (!await live($, v, epoch)) return;
428 v.native = snapshot;
429 observeConsent(v, snapshot.telemetry_consent);
430 v.journey = observeJourney(v.journey, snapshot);
431 v.requiresEnforcement = Boolean(snapshot.state && !snapshot.state.is_final);
432 await $.store.set(`workflow-active:${v.conversation}`, v.requiresEnforcement);
433 $.ui.invalidate('prompt.context');
434 } catch {
435 if (!await live($, v, epoch)) return;
436 v.controlError = 'Couldn’t read this conversation’s workflow. Reconnect to refresh its status.';
437 }
438 }
439 // Do not steal focus if the person closed the workspace during sign-in.
440 } catch (error) {
441 if (await live($, v, epoch)) {
442 v.connected = false;
443 v.error = stage === 'identity'
444 ? 'Claude returned a different Statewright server identity. Reload plugins to refresh this connection.'
445 : stage === 'check'
446 ? String(error?.message).includes('no session is bound')
447 ? 'Claude has not attached Statewright to this conversation yet. Reload plugins, then try connecting again.'
448 : 'Claude couldn’t check your Statewright connection. Try again; if it persists, reload plugins.'
449 : stage === 'reconnect'
450 ? 'Sign-in finished, but Claude couldn’t refresh this connection. Reload plugins, then try again.'
451 : 'Connected to Statewright. Couldn’t read your workflows from Statewright. Try connecting again.';
452 const diagnostic = stage === 'catalog' && v.catalogFailure?.epoch === epoch
453 ? v.catalogFailure : connectionFailure(stage, error);
454 await recordConnection($, v, epoch, stage, 'failed', diagnostic.code, diagnostic.operation);
455 }
456 } finally {
457 if (await live($, v, epoch)) {
458 const initialCheck = !v.checked;
459 v.busy = false;
460 v.checked = true;
461 if (v.error) v.notice = '';
462 v.timer?.cancel(); v.timer = null;
463 v.pending?.resolve(); v.pending = null;
464 redraw($);
465 if (v.paneOpen || (initialCheck && v.manual)) await openPane($, false);
466 }
467 }
468}
469async function schedule($, v, allowSignIn = true) {
470 if (await current($) !== v || v.busy) return;
471 v.busy = true; v.error = ''; v.notice = ''; v.connected = false; v.manual = false;
472 v.native = null; v.controlError = '';
473 v.catalogFailure = null;
474 v.connectionIssue = null;
475 v.interactive = allowSignIn;
476 const epoch = ++v.epoch;
477 const pending = { started: false };
478 pending.promise = new Promise(resolve => { pending.resolve = resolve; });
479 pending.start = () => {
480 if (pending.started) return;
481 pending.started = true;
482 return connect($, v, epoch, allowSignIn);
483 };
484 v.pending = pending;
485 v.timer = $.clock.after(allowSignIn ? 600000 : 20000, async () => {
486 if (!await live($, v, epoch)) return;
487 invalidate(v);
488 v.checked = true;
489 v.error = allowSignIn ? 'Connection timed out. You can try again; any open sign-in window can be closed.'
490 : 'Statewright could not be reached in time. Open /statewright to retry.';
491 redraw($);
492 });
493 redraw($);
494 $.clock.after(0, pending.start);
495}
496async function restore($) {
497 const v = await current($);
498 if (!v.checked && !v.busy) v.requiresEnforcement = (await $.store.get(`workflow-active:${v.conversation}`)) === true;
499 if (!v.checked && !v.busy) await schedule($, v, false);
500}
501
502export function register(on, options = {}) {
503 diagnosticOptions = options;
504 registerResultUI(on, ARBITER_PURPLE);
505 on('ui.message', async ($, e, next) => {
506 if (e.surface === 'desktop' && e.component === 'AbovePrompt' &&
507 e.element === 'status-sidebar' && e.module === 'hooks/desktop-action.js') {
508 const v = await current($), data = e.data;
509 if (data?.action === 'show-sidebar' && data.conversation === v.conversation &&
510 data.revision === v.desktopNavigationRevision) await openPane($, true, { view: v, revision: data.revision });
511 return {};
512 }
513 if (e.surface !== 'desktop' || e.component !== 'Pane' || e.requestId !== PANE ||
514 e.module !== 'hooks/desktop-action.js') return next(e);
515 const v = await current($), data = e.data;
516 if (!data || data.conversation !== v.conversation || data.revision !== v.desktopRevision ||
517 e.element !== `action-${data.action}`) return {};
518 // Consume the displayed action before async work; duplicate messages and
519 // clicks from an obsolete conversation/revision cannot dispatch it twice.
520 await desktopAction($, data.conversation, data.revision, data.action);
521 return {};
522 });
523 on('ui.fault', async ($, e, next) => {
524 if (e.surface === 'desktop' && e.component === 'AbovePrompt' && e.element === 'status-sidebar' && e.module === 'hooks/desktop-action.js') {
525 const v = await current($); v.desktopStatusClientFailed = true;
526 reportDiagnostic($, v, 'ui_failed', 'native_ui');
527 }
528 if (e.surface === 'desktop' && e.component === 'Pane' && e.requestId === PANE && e.module === 'hooks/desktop-action.js') {
529 const v = await current($); v.desktopClientFailed = true;
530 reportDiagnostic($, v, 'ui_failed', 'native_ui');
531 }
532 return next(e);
533 });
534 on('session.append', async ($, e, next) => {
535 const activity = view.modelActivity;
536 if (activity?.probe && /^claude-fable-5(?:-1)?$/.test(activity.model) && fableCreditGate(e)) activity.probe.creditGate = true;
537 return next(e);
538 });
539 on('session.start', async ($, e, next) => {
540 const v = await current($);
541 // Appearance overrides are local to this live session, never the persisted
542 // conversation or saved configuration (a resume may use another terminal).
543 v.powerlineOverride = null;
544 v.controlConsent = false;
545 v.consentEpoch++;
546 v.controlQuestion = null;
547 v.baseRequest = null;
548 v.appearanceGeneration++;
549 v.desktopNavigationRevision = ++navigationRevision;
550 await $.command.register({ name: 'statewright', description: 'Open your Statewright workflow or set session appearance',
551 argumentHint: '[configure | powerline on|off|default]' });
552 workflowTools.clear();
553 for (const [name, operation, description, properties] of workflowSpecs) {
554 const required = operation === 'load_workflow' ? ['name'] : operation === 'transition' ? ['event'] : [];
555 const { tool } = await $.tool.register({ name, description, isDeferred: false,
556 inputSchema: { type: 'object', properties, required, additionalProperties: false } });
557 workflowTools.set(tool, operation);
558 }
559 ({ tool: connectTool } = await $.tool.register({ name: 'statewright_connect', isDeferred: false,
560 description: 'Connect to Statewright and open its native workspace. If a workflow name is supplied, start it after connecting. The human completes native sign-in.',
561 inputSchema: { type: 'object', properties: { workflow: { type: 'string', description: 'Optional exact workflow name requested by the user', maxLength: 200 } }, additionalProperties: false } }));
562 await restore($);
563 redraw($);
564 return next(e);
565 });
566 on('classic.SessionStart', async ($, e, next) => { await restore($); redraw($); return next(e); });
567 on('classic.PostModelSwitch', async ($, e, next) => { view.baseRequest = null; redraw($); return next(e); });
568 on('session.end', async ($, e, next) => { invalidate(view); view = fresh(''); return next(e); });
569 on('turn.start', async ($, e, next) => { view.fallback = null; return next(e); });
570 on('turn.complete', async ($, e, next) => {
571 if (view.fallback?.turnId === e.turnId) { view.fallback = null; redraw($); }
572 return next(e);
573 });
574 on('turn.step', async function* ($, e, next) {
575 if (e.agentId) return yield* next(e);
576 await restore($);
577 const v = await current($);
578 v.baseRequest = { model: e.model, effort: e.effort ?? null };
579 v.routeError = '';
580 if (v.busy && !v.interactive) { const pending = v.pending; pending?.start(); await pending?.promise; }
581 if (await current($) !== v) return yield* blockedStep(e, 'Statewright: the conversation changed. Retry in the current chat.');
582 if (!v.requiresEnforcement && !v.native?.state) return yield* showModelRequest($, v, e, next);
583 const snapshot = v.native;
584 const generation = v.generation, control = v.control;
585 if (!v.connected || v.busy || v.actionBusy || !snapshot || await v.control?.snapshot() !== snapshot)
586 return yield* stopRoute(v, e, 'Statewright: refresh the workflow in /statewright before continuing, or turn it Off.');
587 let route;
588 try { route = claudeRoute(snapshot.state); }
589 catch (error) { return yield* stopRoute(v, e, `Statewright: ${error.message}. Update its route or turn Statewright Off.`); }
590 if (next.signal?.aborted || await current($) !== v || v.native !== snapshot ||
591 v.generation !== generation || v.control !== control || v.actionBusy || v.busy || !v.connected)
592 return yield* stopRoute(v, e, 'Statewright: the workflow changed before dispatch. Retry in the current state.');
593 if (!route) return yield* showModelRequest($, v, e, next);
594 const previous = v.routes.at(-1);
595 if (previous?.status === 'mismatch' && previous.run_id === (snapshot.state.run_id ?? null) &&
596 previous.state === snapshot.state.state && previous.requested_model === route.requested_model)
597 return yield* stopRoute(v, e, 'Statewright: the provider answered with a different model. Update the workflow route or turn Statewright Off before another request.');
598 // At most one explicitly identified pre-response Fable credit-gate fallback.
599 // No exceptions, partial streams, generic missing usage or refusals replay.
600 const permitsFallback = async () => fallbackPermitted(await $.settings.read(), await $.env.get('CLAUDE_CODE_NO_MODEL_FALLBACK'));
601 const stillCurrent = () => view === v && !next.signal?.aborted &&
602 v.native === snapshot && v.generation === generation && v.control === control && !v.actionBusy && !v.busy && v.connected;
603 if (v.fallback?.turnId === e.turnId && v.fallback.runId === snapshot.state.run_id && v.fallback.generation === generation && route.model === v.fallback.from && await permitsFallback())
604 route = { ...route, model: 'claude-opus-5-5', fallback_from: route.model, fallback_reason: v.fallback.reason };
605 const attempts = [], probe = { creditGate: false, semantic: false };
606 const record = async (result, failed = false) => {
607 const last = { ...routeReceipt(e, route, result), ...(failed ? { failure: 'native_request_failed' } : {}) };
608 const receipt = { ...last, workflow: snapshot.state.workflow, run_id: snapshot.state.run_id ?? null,
609 state: snapshot.state.state, revision: snapshot.revision, ...(attempts.length ? { attempts: [...attempts, last] } : {}) };
610 if (await current($) !== v) return;
611 v.routes = [...v.routes, receipt].slice(-64);
612 if (failed) v.routeError = 'The native model request failed. Retry to continue, or open /statewright.';
613 try { await $.store.set(`model-routes:${v.conversation}`, v.routes); } catch { /* Never replay for receipt storage. */ }
614 redraw($);
615 if (receipt.status !== 'observed') $.ui.log(`Statewright model routing: ${receipt.status}; requested ${receipt.native_model}, observed ${receipt.observed_model ?? 'unavailable'}.`);
616 };
617 // The final predicate is synchronous in this generator, after every await.
618 // An async helper returning true leaves a microtask window for Off/cancel.
619 if (await current($) !== v || !stillCurrent()) return yield* stopRoute(v, e, 'Statewright: the workflow changed before dispatch. Retry in the current state.');
620 let result;
621 try {
622 result = yield* showModelRequest($, v, routeEvent(e, route), next, probe, Boolean(route.fallback_from));
623 const fallback = fableFallback(route, result, probe);
624 if (fallback && await permitsFallback() && await current($) === v && stillCurrent()) {
625 attempts.push(routeReceipt(e, route, result));
626 v.fallback = { turnId: e.turnId, runId: snapshot.state.run_id, generation, from: route.model, reason: fallback.fallback_reason };
627 $.ui.log('Statewright: Fable requires usage credits. Continuing this turn on Opus at the same effort.');
628 route = fallback;
629 result = yield* showModelRequest($, v, routeEvent(e, route), next, undefined, true);
630 }
631 } catch (error) { await record(null, true); throw error; }
632 await record(result);
633 return result;
634 }).catch(async function* ($, e, next) {
635 if (next.called) return; // Never replay a partially or fully dispatched stream.
636 return yield* stopRoute(view, e, 'Statewright could not prepare the model route. Open /statewright to refresh or turn it Off.');
637 });
638 on('command.run', { command: 'statewright' }, async ($, e) => {
639 const v = await current($);
640 const args = (e.args ?? '').trim().toLowerCase();
641 if (args === 'configure') {
642 const generation = v.appearanceGeneration;
643 // Native commands queue until idle: release this handler before opening
644 // the built-in configuration dialog, and never carry it into a resume.
645 $.clock.after(0, async () => {
646 if (await current($) !== v || v.appearanceGeneration !== generation) return;
647 try { await $.command.run({ command: 'plugin', args: 'configure statewright@statewright' }); }
648 catch { await $.ui.log('Could not open Statewright preferences. Use /plugin configure statewright@statewright.'); }
649 });
650 return { text: 'Opening Statewright preferences…' };
651 }
652 if (args) {
653 const match = /^powerline(?:\s+(on|off|default))?$/.exec(args);
654 if (!match) return { text: 'Use /statewright to open the workspace, /statewright configure for saved preferences, or /statewright powerline on|off|default.' };
655 if (match[1]) v.powerlineOverride = match[1] === 'default' ? null : match[1] === 'on';
656 redraw($);
657 const enabled = v.powerlineOverride ?? (options.powerline === true);
658 return { text: `Powerline: ${enabled ? 'on' : 'off'}${v.powerlineOverride === null ? ' (saved default)' : ' for this session only'}. Saved preference unchanged. Desktop uses > separators.\n/statewright powerline on|off|default` };
659 }
660 await openPane($);
661 if (!v.connected && !v.busy) await schedule($, v, false);
662 return {};
663 });
664 on('prompt.context', async ($, e, next) => {
665 const result = await next(e);
666 return { ...result, blocks: [...result.blocks, { name: 'statewright-connection',
667 text: `For Statewright, use this plugin’s statewright_connect, statewright_get_state, statewright_load_workflow, statewright_transition and statewright_deactivate tools. Do not search local files or run shell login commands. Statewright’s tool checks do not replace Claude’s own permissions. Native main-conversation requests use local workflow model equivalents, separately from the remote gateway's advisory model_routing flag. Inspect client_model_routing.last_request for provider evidence; a selected route alone is not proof. ${view.native?.state ? JSON.stringify(view.native.state) : 'No verified active workflow yet.'}` }] };
668 });
669 // Host-stamped origin admits only our scoped control calls. A genuine human
670 // can authorize these controls for this view's lifetime through tool.check,
671 // the supported permission extension. Never retry a refused native call or
672 // confuse this grant with ordinary-tool or workflow-decision approval.
673 // Separate handlers are essential: ordinary-tool checks call pre_tool, whose
674 // nested MCP permission check must not be skipped as this handler's re-entry.
675 on('tool.check', { tool: /^mcp__plugin[:_]statewright[:_]statewright__/ }, async ($, e, next) => {
676 if (gatewayTool(e.tool)) {
677 if (!own($, next) && !catalogTool(e.tool, e.input))
678 return { decision: 'deny', reason: 'Use the Statewright workspace tools for this conversation.' };
679 const frame = permissionFrames.get(argumentKey(e.input));
680 const native = await next(e), v = await current($);
681 if (native.decision === 'deny') return native;
682 if (own($, next) && frame && (frame.v !== v || frame.generation !== v.generation || frame.consentEpoch !== v.consentEpoch ||
683 (e.tool_use_id && frame.invocation && frame.invocation !== e.tool_use_id)))
684 return { decision: 'deny', reason: 'This Statewright action was superseded. Read the current workflow before continuing.' };
685 // A registered facade has no native implementation. Carry its permission
686 // requirement to the exact real MCP invocation.
687 let required = native;
688 if (own($, next) && e.tool_use_id && frame?.v === v && frame.generation === v.generation &&
689 frame.permission?.decision === 'ask' && (!frame.invocation || frame.invocation === e.tool_use_id)) {
690 frame.invocation = e.tool_use_id;
691 if (native.decision === 'allow') required = { ...frame.permission, decision: 'ask' };
692 }
693 const scoped = () => own($, next) && typeof e.tool_use_id === 'string' && !e.agentId &&
694 e.tool.endsWith(`__${NATIVE_CONTROL_TOOL}`) && frame?.v === v && frame.generation === v.generation && frame.consentEpoch === v.consentEpoch &&
695 (!frame.invocation || frame.invocation === e.tool_use_id) &&
696 e.input?.context?.conversation_id === v.conversation && v.connected && !next.signal?.aborted;
697 // Explicit ask rules/hooks and administrator ceilings keep their native
698 // path, even after our session grant. Denials were already returned above.
699 // A saved gateway ALLOW is not an ask constraint: a newly registered
700 // facade can still require consent at this exact invocation after reload.
701 if (required.decision !== 'ask' || !scoped() || e.ceiling || native.ceiling ||
702 (native.decision === 'ask' && (native.rule || native.hook)) ||
703 (frame.permission?.decision === 'ask' && (frame.permission.rule || frame.permission.hook)) || frame.permission?.ceiling)
704 return required;
705 frame.invocation = e.tool_use_id;
706 if (!v.controlConsent) {
707 if (v.controlQuestion) return { decision: 'deny', reason: 'A Statewright permission question is already waiting for your answer.' };
708 const question = { generation: v.generation, epoch: v.consentEpoch, invocation: null };
709 v.controlQuestion = question;
710 let answer;
711 try {
712 answer = await $.ui.ask(CONTROL_QUESTION, { header: 'Statewright', options: CONTROL_OPTIONS });
713 } catch { /* Dismissal, cancellation and headless hosts grant nothing. */ }
714 finally { if (v.controlQuestion === question) v.controlQuestion = null; }
715 if (await current($) !== v || !scoped()) return { decision: 'deny', reason: 'The Statewright request changed while awaiting your answer.' };
716 if (answer !== 'Allow this session') return { decision: 'deny', reason: 'Statewright workflow controls were not approved. No action was sent.' };
717 v.controlConsent = true;
718 }
719 return { decision: 'allow', reason: 'You approved Statewright workflow controls for this session.' };
720 }
721 return next(e);
722 }).catch(() => ({ decision: 'deny', reason: 'Statewright could not verify this control request. Refresh its connection.' }));
723 on('tool.check', { tool: /^(?!mcp__plugin[:_]statewright[:_]statewright__)/ }, async ($, e, next) => {
724 if (await isControlQuestion($, e, next, e.input)) return next(e);
725 if (e.tool === connectTool || workflowTools.has(e.tool)) return next(e);
726 const verdict = await executionFor($).check(null, e, next);
727 if (verdict?.decision === 'deny') reportDiagnostic($, await current($), 'permission_refused', 'pre_tool', 'control');
728 return verdict;
729 }).catch(async ($, e, next) => next.error?.kind === 're-entry' && await isControlQuestion($, e, next, e.input)
730 ? next(e) : { decision: 'deny', reason: 'Statewright could not verify this tool request. Refresh its connection.' });
731 on('tool.call', async ($, e, next) => {
732 if (await isControlQuestion($, e, next, flatArgs(e))) return next(e);
733 if (gatewayTool(e.tool)) return own($, next) || catalogCall(e) ? next(e)
734 : { deny: 'Use the Statewright workspace tools for this conversation.' };
735 if (workflowTools.has(e.tool)) {
736 const v = await current($);
737 const permission = await $.tool.check({ tool: e.tool, input: flatArgs(e) });
738 if (permission.decision === 'deny') return { deny: permission.reason || 'Claude does not permit this Statewright action.' };
739 if (!v.connected && !v.busy) await schedule($, v, false);
740 if (v.busy && !v.interactive) { const pending = v.pending; pending?.start(); await pending?.promise; }
741 if (await current($) !== v) return { deny: 'The conversation changed. Request Statewright again here.' };
742 try { const result = await workflowAction($, workflowTools.get(e.tool), flatArgs(e), undefined, permission);
743 return { result: JSON.stringify({ ...result, client_model_routing: routingView(v) }) }; }
744 catch (error) { await openPane($, false); return { result: JSON.stringify({ status: 'action_needed', message: error.message }) }; }
745 }
746 if (!connectTool || e.tool !== connectTool) return executionFor($).call(null, e, next);
747 const v = await current($);
748 if (e.workflow !== undefined && !validName(e.workflow)) return { deny: 'Provide a valid workflow name.' };
749 if (v.prompting) return { result: JSON.stringify({ status: 'awaiting_confirmation',
750 message: 'The Statewright connection question is waiting for your answer. Do not start another login.', executionAvailable: false }) };
751 // Check native saved authentication, not this view's initial boolean. Join
752 // startup's read without waiting for a deferred timer or a browser login.
753 if (!v.connected && !v.busy) await schedule($, v, false);
754 if (v.busy && !v.interactive) {
755 const pending = v.pending;
756 pending?.start();
757 await pending?.promise;
758 }
759 if (await current($) !== v) return { result: JSON.stringify({ status: 'cancelled',
760 message: 'The conversation changed. Request Statewright again here.', executionAvailable: false }) };
761 if (e.workflow && (!v.connected || v.names.includes(e.workflow))) {
762 v.selected = e.workflow;
763 v.page = Math.max(0, Math.floor(v.names.indexOf(v.selected) / PAGE_SIZE));
764 }
765 const placement = await openPane($);
766 if (v.manual && !v.busy && !v.prompted) {
767 v.prompted = true;
768 v.prompting = true;
769 const epoch = v.epoch;
770 // Tool-opened panes may not be placed on narrow terminals. Native Ask
771 // works independently of pane placement and waits for a real person.
772 let answer;
773 try { answer = await $.ui.ask('Statewright would like to connect. Open sign-in in your browser?',
774 { header: 'Statewright', options: ['Connect', 'Not now'] }); } catch { /* Dismissed/headless: no consent. */ }
775 finally { v.prompting = false; }
776 if (!await live($, v, epoch)) return { result: JSON.stringify({ status: 'cancelled',
777 message: 'The connection request changed. Open /statewright to continue.', executionAvailable: false }) };
778 if (answer === 'Connect') await schedule($, v, true);
779 }
780 if (e.workflow && v.connected && executionReadiness(v.native, true).available && (!v.native?.state || v.native.state.is_final)) {
781 const permission = await $.tool.check({ tool: e.tool, input: flatArgs(e) });
782 if (permission.decision === 'deny') return { deny: permission.reason || 'Claude does not permit this Statewright action.' };
783 try { await workflowAction($, 'load_workflow', { name: e.workflow }, undefined, permission); } catch { /* Pane shows the exact recovery action. */ }
784 }
785 const ready = executionReadiness(v.native, true);
786 const loadFailed = Boolean(e.workflow && v.controlError);
787 const status = loadFailed ? 'action_needed' : v.busy ? 'sign_in_in_progress' : v.connected ? v.native?.state ? 'workflow_active' : 'connected'
788 : v.manual ? 'connection_required' : 'connection_error';
789 return { result: JSON.stringify({ status,
790 message: loadFailed ? v.controlError : v.busy ? 'Statewright sign-in is in progress. Wait for the user to finish; do not start another login.'
791 : v.connected ? v.native?.state ? 'Workflow loaded. Follow its current state and declared transitions.' : 'Choose a workflow and Start when you’re ready.' : v.manual ? 'Sign-in is needed. Open /statewright and choose Connect when you’re ready.'
792 : v.error || v.notice || 'Open /statewright to retry the connection.',
793 paneVisible: placement?.isPlaced === true,
794 workflows: v.connected ? v.names : [], selected: v.selected || null, executionAvailable: v.connected && ready.available,
795 state: v.connected ? v.native?.state ?? null : null,
796 capabilities: v.connected ? v.native?.capabilities ?? null : null,
797 client_model_routing: routingView(v),
798 executionUnavailableReason: v.controlError || ready.reason }) };
799 }).catch(async ($, e, next) => next.called || (next.error?.kind === 're-entry' && await isControlQuestion($, e, next, flatArgs(e)))
800 ? next(e) : { deny: 'Statewright could not handle this request. Open /statewright to retry.' });
801 on('mcp.call', async ($, e, next) => {
802 const catalog = gatewayServer(e.server) && ((e.tool === 'statewright_list_workflows' && emptyArgs(e.args)) ||
803 (e.tool === NATIVE_CONNECT_TOOL && own($, next)));
804 if (gatewayServer(e.server) && !catalog && !own($, next))
805 return { deny: 'Use the Statewright workspace tools for this conversation.' };
806 const v = catalog && own($, next) ? await current($) : null, epoch = v?.epoch;
807 try { return await next(e); }
808 catch (error) {
809 // Preserve the finite cause before the existing middleware fallback turns
810 // every native exception into "Statewright control is unavailable".
811 if (v?.busy && await live($, v, epoch)) v.catalogFailure = { epoch, ...connectionFailure('catalog', error) };
812 if (gatewayServer(e.server) && e.tool === NATIVE_CONTROL_TOOL && own($, next)) {
813 const frame = permissionFrames.get(argumentKey(e.args));
814 if (frame) frame.failure = controlFailure(error);
815 }
816 throw error;
817 }
818 })
819 .catch(($, e, next) => gatewayServer(e.server) ? { deny: 'Statewright control is unavailable.' } : next(e));
820 on('classic.PostToolUse', async ($, e, next) => { await executionFor($).observe(null, e, false); return next(e); });
821 on('classic.PostToolUseFailure', async ($, e, next) => {
822 await executionFor($).observe(null, e, true);
823 if (['Bash', 'Shell', 'shell_command', 'exec_command'].includes(e.tool_name))
824 reportDiagnostic($, await current($), 'command_failed', 'command');
825 return next(e);
826 });
827 on('classic.Stop', async ($, e, next) => {
828 const result = await next(e), v = await current($);
829 if (result?.block || v.routeError || !v.requiresEnforcement || v.native?.state?.pending_approval) return result;
830 const decision = outcome(await nativeRequest($, 'stop', {}));
831 return decision.decision === 'block' ? { ...result, block: decision.reason } : result;
832 }).catch(() => ({ block: 'Statewright could not confirm workflow completion. Read its state or turn it Off.' }));
833 on('ui.close', async ($, e, next) => {
834 if (e.id === PANE) {
835 const v = await current($);
836 if (!v.handoff || e.origin?.kind !== 'plugin') v.paneOpen = false;
837 }
838 return next(e);
839 });
840 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
841 const v = await current($), { Box, Text, Button, Client, Select } = $.ui.resolve(e), rest = await next(e);
842 const selectedModel = await $.session.model();
843 if (await current($) !== v) return rest;
844 const columns = e.props?.bodyColumns ?? 80;
845 if (e.surface === 'desktop') {
846 const p = statusPresentation({ ...v, selectedModel }, { surface: 'desktop' });
847 const chip = (key, label, color = ARBITER_PURPLE, brand = false, control) => Box({ key,
848 borderStyle: brand ? 'single' : 'round', borderColor: color,
849 height: 1, paddingY: 0, alignItems: 'center',
850 backgroundColor: STATE_BACKGROUND, paddingX: 1, children: [control ?? Text({ color, bold: brand, children: [label] })] });
851 // Navigation is deliberately separate from the pane's consumed action
852 // revisions: opening/focusing it neither connects nor grants consent.
853 const revision = v.desktopNavigationRevision, color = p.attention ? '#FFD580' : ARBITER_PURPLE;
854 const statusControl = v.desktopStatusClientFailed
855 ? Select({ key: 'status-sidebar-fallback', label: p.status,
856 options: [{ value: 'sidebar', label: 'Show Statewright sidebar' }], onSelect: async value => {
857 if (value === 'sidebar' && await current($) === v && revision === v.desktopNavigationRevision)
858 await openPane($, true, { view: v, revision });
859 } })
860 : Client({ key: 'status-sidebar', module: './desktop-action.js', props: {
861 action: 'show-sidebar', label: p.status, color,
862 conversation: v.conversation, revision,
863 } });
864 return Box({ flexDirection: 'column', children: [
865 Box({ key: 'arbiter-status', flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', gap: 1, children: [
866 chip('arbiter-label', 'arbiter', ARBITER_ACCENT),
867 chip('arbiter-brand', 'statewright.ai ∞', ARBITER_PURPLE, true),
868 chip('desktop-state', p.status, color, false, statusControl),
869 ...(p.model ? [chip('desktop-model', p.model, '#DAD1EC')] : []),
870 ...(p.tail ? [Text({ color: ARBITER_ACCENT, children: [p.tail.trim()] })] : []),
871 ] }),
872 ...(rest ? [rest] : []),
873 ] });
874 }
875 const p = statusPresentation({ ...v, selectedModel }, { surface: e.surface, powerline: v.powerlineOverride ?? (options.powerline === true), compact: columns < 55, columns });
876 const narrow = p.compact;
877 const brand = narrow ? 'sw∞' : 'statewright.ai ∞';
878 // One native Text row owns truncation, so narrow panes do not wrap into a
879 // second status line. A decision/error stays ahead of the optional events.
880 const segments = p.powerline ? [
881 ...(!narrow ? [Text({ key: 'arbiter-label', bold: true, color: '#171321', backgroundColor: ARBITER_ACCENT, children: [' arbiter '] }),
882 Text({ color: ARBITER_ACCENT, backgroundColor: ARBITER_PURPLE, children: [''] })] : []),
883 Text({ bold: true, color: '#171321', backgroundColor: ARBITER_PURPLE, children: [` ${brand} `] }),
884 Text({ color: ARBITER_PURPLE, backgroundColor: STATE_BACKGROUND, children: [''] }),
885 Text({ bold: true, color: p.attention ? '#FFD580' : '#FFFFFF', backgroundColor: STATE_BACKGROUND, children: [` ${p.status} `] }),
886 Text({ color: STATE_BACKGROUND, children: [''] }),
887 ] : [
888 ...(!narrow ? [Text({ key: 'arbiter-label', bold: true, color: ARBITER_ACCENT, children: ['arbiter > '] })] : []),
889 Text({ bold: true, color: ARBITER_PURPLE, children: [`${brand}${narrow ? '>' : ' >'} ${p.status}`] }),
890 ];
891 return Box({ flexDirection: 'column', children: [
892 Box({ key: 'arbiter-status', height: 1, flexDirection: 'row', children: [
893 Text({ wrap: 'truncate-end', children: [
894 ...segments,
895 ...(p.model ? [Text({ color: ARBITER_PURPLE,
896 children: [`${p.powerline ? ' ' : ' > '}${p.model}${p.tail ? p.powerline ? ' ' : ' >' : ''}`] })] : []),
897 ...(!p.model && p.tail && !p.powerline ? [Text({ color: ARBITER_PURPLE, children: [' >'] })] : []),
898 ...(p.tail ? [Text({ color: '#8BD5E8', children: [p.tail] })] : []),
899 ] }),
900 ] }),
901 ...(v.busy && v.notice ? [Text({ children: [v.notice] })] : []),
902 ...(v.busy ? [Button({ key: 'cancel-connect', label: 'Cancel connection', plain: true, onPress: () => cancel($, v) })] : []),
903 ...(rest ? [rest] : []),
904 ] });
905 });
906 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
907 if (e.requestId !== PANE) return next(e);
908 const v = await current($), { Box, Text, Button, Select, Svg, Client } = $.ui.resolve(e);
909 const desktop = e.surface === 'desktop';
910 // Client buttons survive the Desktop focus redraw that drops native Button
911 // handles (#99211). Their messages still have to match the current view.
912 const desktopKey = desktop ? desktopViewKey(v) : null;
913 if (desktopKey && v.desktopKey !== desktopKey) {
914 v.desktopKey = desktopKey; v.desktopRevision = (v.desktopRevision ?? 0) + 1;
915 }
916 if (desktop) v.desktopActions = new Map();
917 const appearanceGeneration = v.appearanceGeneration;
918 const text = value => Text({ children: [value] });
919 const button = (key, label, hotkey, action) => {
920 if (!desktop) return Button({ key, label, hotkey, plain: true, onPress: action });
921 v.desktopActions.set(key, action);
922 if (v.desktopClientFailed) {
923 const revision = v.desktopRevision, choice = `${key}-${revision}`;
924 return Select({ key: `action-${choice}`, label,
925 options: [{ value: choice, label }], onSelect: async value => {
926 if (value === choice) await desktopAction($, v.conversation, revision, key);
927 } });
928 }
929 return Client({ key: `action-${key}`, module: './desktop-action.js', props: {
930 action: key, label, primary: ['connect', 'start', 'review'].includes(key),
931 conversation: v.conversation, revision: v.desktopRevision,
932 } });
933 };
934 const rows = [desktop ? Text({ bold: true, color: ARBITER_PURPLE, children: [desktopTitle(v)] })
935 : text(v.connected ? 'Connected to Statewright' : v.connectionIssue === 'auto_review_unavailable' ? 'Check Claude permissions' : 'Statewright would like to connect')];
936 if (desktop && !v.connected && !v.busy && !['auto_review_unavailable', 'tool_unavailable'].includes(v.connectionIssue)) rows.push(text('Connect to see your workflows and use them in this conversation.'));
937 if (desktop && v.busy) rows.push(text(v.notice || 'Checking Statewright. You can keep this conversation open.'));
938 // Keep the action above explanatory copy, even in a short/narrow pane.
939 if (v.busy) rows.push(button('cancel-connect', 'Cancel connection', 'c', () => cancel($, v)));
940 else if (!v.connected) {
941 if (v.connectionIssue !== 'tool_unavailable') rows.push(button('connect', v.connectionIssue === 'auto_review_unavailable' ? 'Connect' : v.error ? 'Try connecting again' : 'Connect', 'c', () => schedule($, v)));
942 }
943 else {
944 if (v.native?.state) {
945 const nodes = progressNodes(v.native);
946 rows.push(text(v.native.state.workflow), text(`Current: ${nodes.current}`));
947 if (nodes.pending) rows.push(text('This step needs your decision.'));
948 const shownRevision = v.native.revision;
949 const action = (operation, input = {}) => async () => {
950 const generation = v.generation;
951 try { await workflowAction($, operation, input, shownRevision); }
952 catch (error) { if (await current($) === v && v.generation === generation) { v.controlError = error.message; redraw($); } }
953 };
954 if (nodes.pending && !v.actionBusy) rows.push(button('review', 'Review this step', 'a', action('request_approval')));
955 if (!nodes.pending && !nodes.final && !v.actionBusy) {
956 const transitions = v.native.state.transitions.slice(0, 64);
957 if (transitions.length) rows.push(Select({ key: 'next-state', label: 'Next step',
958 options: transitions.map(t => ({ value: t.event, label: `${eventSymbol(t.event)} → ${t.target}` })),
959 onSelect: event => { if (transitions.some(t => t.event === event)) return action('transition', { event })(); },
960 }));
961 }
962 rows.push(button('off', 'Turn Statewright Off', 'o', action('deactivate')));
963 if (v.actionBusy) rows.push(text(nodes.pending ? 'Waiting for your review…' : 'Updating workflow…'));
964 rows.push(button('toggle-map', v.showGraph ? 'Hide workflow map' : 'Show workflow map', 'm', async () => {
965 if (await current($) !== v) return;
966 v.showGraph = !v.showGraph; redraw($);
967 }));
968 if (v.showGraph && desktop) {
969 rows.push(Box({ key: 'workflow-map', flexDirection: 'column', children: [
970 Svg(desktopGraph(v.native, v.journey)),
971 Text({ dimColor: true, children: ['Solid: observed path · Dashed: possible next steps or unobserved gaps.'] }),
972 ] }));
973 } else if (v.showGraph) rows.push(Box({ key: 'workflow-map', flexDirection: 'column', children: [
974 ...graphRows(v.native, v.journey).map(row => Text({
975 color: row.kind === 'current' ? ARBITER_PURPLE : undefined,
976 bold: row.kind === 'current', dimColor: row.kind === 'past' || row.kind === 'edge',
977 wrap: 'truncate-end', children: [row.text],
978 })),
979 ...(nodes.next.length ? [Text({ dimColor: true, wrap: 'wrap',
980 children: ['Possible next steps; their requirements still apply.'] })] : []),
981 ] }));
982 }
983 if (v.names.length) {
984 const epoch = v.epoch;
985 const pages = Math.ceil(v.names.length / PAGE_SIZE);
986 const names = v.names.slice(v.page * PAGE_SIZE, (v.page + 1) * PAGE_SIZE);
987 const go = page => async () => {
988 if (!await live($, v, epoch) || v.busy || page < 0 || page >= pages) return;
989 v.page = page; redraw($);
990 };
991 rows.push(Select({ key: 'workflow', label: 'Choose a workflow',
992 ...(names.includes(v.selected) ? { value: v.selected } : {}),
993 options: names.map(name => ({ value: name, label: name })), onSelect: async name => {
994 if (!await live($, v, epoch) || v.busy || !names.includes(name)) return;
995 v.selected = name; redraw($);
996 } }), text(`Selected: ${v.selected}`));
997 if (pages > 1) {
998 rows.push(text(`Page ${v.page + 1} of ${pages} · ${v.names.length} workflows`));
999 if (v.page > 0) rows.push(button('previous-page', 'Previous workflows', 'p', go(v.page - 1)));
1000 if (v.page + 1 < pages) rows.push(button('next-page', 'More workflows', 'n', go(v.page + 1)));
1001 }
1002 }
1003 else rows.push(text('No workflows are available in this account.'));
1004 const readiness = executionReadiness(v.native, true);
1005 if ((!v.native?.state || v.native.state.is_final) && v.selected && readiness.available && !v.actionBusy) {
1006 const shownRevision = v.native.revision, name = v.selected;
1007 rows.push(button('start', 'Start workflow', 's', async () => {
1008 try { await workflowAction($, 'load_workflow', { name }, shownRevision); }
1009 catch (error) { v.controlError = error.message; redraw($); }
1010 }));
1011 }
1012 if (!readiness.available) rows.push(text(readiness.reason));
1013 if (v.native?.state) {
1014 const routing = routingView(v);
1015 if (routing.error) rows.push(text(routing.error));
1016 else if (routing.configured) rows.push(text(`Workflow requests: ${routing.configured.model}${routing.configured.effort ? ` / ${routing.configured.effort}` : ''}. Off restores your selected model.`));
1017 else rows.push(text('Claude keeps its selected model; this state has no active model route.'));
1018 rows.push(text('Effort is the requested setting, not a measure of internal reasoning.'));
1019 if (routing.last_request) rows.push(text(`Last routed response: ${routing.last_request.observed_model ?? 'unavailable'} (${routing.last_request.status}).`));
1020 }
1021 if (!v.actionBusy && !v.runningTool) rows.push(button('refresh', 'Refresh workflows', 'r', () => schedule($, v)));
1022 if (v.controlError) rows.push(text(v.controlError));
1023 }
1024 if (!desktop && v.manual && !v.busy && !nativeSignIn) rows.push(button('copy-login', 'Copy SSH sign-in command', 's', async () => {
1025 if (await current($) !== v || v.busy || nativeSignIn) return;
1026 try {
1027 const result = await $.ui.copy({ text: LOGIN_COMMAND });
1028 if (await current($) !== v) return;
1029 v.notice = result.isCopied ? 'Copied. Run it in a separate terminal, then reconnect here.' : 'Clipboard unavailable. The sign-in command is below.';
1030 } catch { if (await current($) === v) v.notice = 'Clipboard unavailable. The sign-in command is below.'; }
1031 redraw($);
1032 }));
1033 if (e.surface === 'terminal') rows.push(Select({ key: 'powerline-session', label: 'Powerline · this session',
1034 value: v.powerlineOverride === null ? 'default' : v.powerlineOverride ? 'on' : 'off',
1035 options: [{ value: 'default', label: `Use saved default (${options.powerline === true ? 'On' : 'Off'})` },
1036 { value: 'on', label: 'On for this session' }, { value: 'off', label: 'Off for this session' }],
1037 onSelect: async value => {
1038 if (await current($) !== v || v.appearanceGeneration !== appearanceGeneration || !['default', 'on', 'off'].includes(value)) return;
1039 v.powerlineOverride = value === 'default' ? null : value === 'on'; redraw($);
1040 },
1041 }));
1042 rows.push(button('close', 'Back to conversation', 'b', async () => {
1043 if (await current($) !== v) return; v.paneOpen = false; await $.ui.close({ id: PANE });
1044 }));
1045 if (v.error) rows.push(text(v.error));
1046 if (v.connectionIssue === 'tool_unavailable') rows.push(text(desktop
1047 ? 'Quit and reopen Claude Desktop, then resume this conversation. If the tools are still missing, update Statewright.'
1048 : 'Restart Claude Code, then resume this conversation. If the tools are still missing, update Statewright.'));
1049 if (v.notice) rows.push(text(v.notice));
1050 if (!desktop && v.manual && !v.busy) rows.push(text('Sign in from another computer — in a separate terminal:'), text(LOGIN_COMMAND),
1051 text('Open the link there, paste the full callback URL into that terminal, then reconnect here.'));
1052 if (!v.connected && !v.busy && !v.error) rows.push(text('Choose Connect to sign in and see your workflows.'));
1053 return Box({ flexDirection: 'column', rowGap: desktop ? 1 : 0, ...(desktop ? { paddingX: 1 } : {}), children: rows });
1054 });
1055}
1056hooks/catalog.mjs 76 lines1// Consumer connection identity. Native Claude owns OAuth, tokens and refresh.
2import { validTelemetryConsent } from './diagnostic-event.mjs';
3export const SERVER = 'statewright';
4export const GATEWAY = 'https://mcp.statewright.ai/mcp';
5export const NATIVE_SERVER = `plugin:statewright:${SERVER}`;
6export const LOGIN_COMMAND = `claude mcp login ${NATIVE_SERVER} --no-browser`;
7export const CATALOG_ONLY = 'This connection can list and select workflows, but cannot start or change them yet.';
8// Persist only a closed diagnostic vocabulary. Native exceptions can contain
9// URLs, credentials, tool arguments or server responses; never copy them to UI/store.
10export function connectionFailure(stage, error) {
11 const message = typeof error?.message === 'string' ? error.message : '';
12 const code = stage === 'identity' ? 'server_identity'
13 : stage === 'catalog_decode' ? 'catalog_invalid'
14 : /no session is bound/.test(message) ? 'session_unbound'
15 : /no connected MCP tool|no tool named/.test(message) ? 'tool_unavailable'
16 : /Statewright control is unavailable/.test(message) ? 'control_hook_failed'
17 : /not admitted|withheld|not a loaded plugin/.test(message) ? 'module_unavailable'
18 : /host rule|host check/.test(message) ? 'capability_unavailable'
19 // Native Auto-mode unavailability is not a verdict against the action.
20 // Distinguish it for guidance only; never grant permission or retry here.
21 : /auto mode/i.test(message) && /gave no verdict|is temporarily unavailable/i.test(message) ? 'auto_review_unavailable'
22 : /aborted|AbortError/i.test(message) ? 'request_aborted'
23 : /timed? out|timeout|deadline/i.test(message) ? 'request_timeout'
24 : /refused|denied|permission/i.test(message) ? 'permission_refused'
25 : 'native_error';
26 return { code, operation: /\$\.mcp\.connect/.test(message) ? 'mcp.connect'
27 : /\$\.mcp\.call/.test(message) ? 'mcp.call'
28 : /\$\.tool\.call/.test(message) ? 'tool.call'
29 : /\$\.tool\.check/.test(message) ? 'tool.check' : 'unknown' };
30}
31export const gatewayTool = name => typeof name === 'string' &&
32 name.replace(/[:-]/g, '_').startsWith(`mcp__plugin_statewright_${SERVER}__`);
33export const gatewayServer = name => typeof name === 'string' &&
34 [SERVER, NATIVE_SERVER, NATIVE_SERVER.replace(/:/g, '_')].includes(name);
35export const emptyArgs = args => args !== null && typeof args === 'object' &&
36 !Array.isArray(args) && Object.keys(args).length === 0;
37export const catalogTool = (name, args) => typeof name === 'string' &&
38 /^mcp__plugin[:_]statewright[:_]statewright__statewright_list_workflows$/.test(name) && emptyArgs(args);
39// tool.call spreads arguments beside these engine-owned envelope fields.
40export function catalogCall({ tool, tool_use_id, agentId, requestMeta, consent, ...args }) {
41 return catalogTool(tool, args);
42}
43export function validName(name) {
44 return typeof name === 'string' && name.trim().length > 0 && name.length <= 200 &&
45 !/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test(name);
46}
47// Use only the gateway's owned-catalog resolution. An enabled-name list alone
48// cannot distinguish an absent alias from an explicitly disabled exact entry.
49export function resolveWorkflowName(name, workflows, aliases = {}) {
50 if (workflows.includes(name)) return name;
51 return name === 'engineering' && aliases.engineering === 'agentic-engineering' &&
52 workflows.includes('agentic-engineering') ? 'agentic-engineering' : name;
53}
54export const validWorkflowAliases = value => value !== null && typeof value === 'object' && !Array.isArray(value) &&
55 Object.entries(value).every(([alias, target]) => alias === 'engineering' && target === 'agentic-engineering');
56export function decodeCatalog(result) {
57 return decodeCatalogConnection(result).workflows;
58}
59export function decodeCatalogConnection(result) {
60 if (result?.isError || !Array.isArray(result?.content) || result.content.length !== 1 ||
61 result.content[0]?.type !== 'text' || typeof result.content[0].text !== 'string' ||
62 result.content[0].text.length > 131072) throw new Error('Invalid catalog');
63 const { workflows, workflow_aliases = {}, native_control, telemetry_consent } = JSON.parse(result.content[0].text);
64 if (!Array.isArray(workflows) || workflows.length > 1000 || !workflows.every(validName) ||
65 new Set(workflows).size !== workflows.length) throw new Error('Invalid catalog');
66 if (!validWorkflowAliases(workflow_aliases) ||
67 Object.entries(workflow_aliases).some(([alias, target]) => workflows.includes(alias) || !workflows.includes(target)))
68 throw new Error('Invalid workflow aliases');
69 // The gateway's `active` is NOT bound to this native conversation. Never use it.
70 const supported = native_control !== null && typeof native_control === 'object' &&
71 !Array.isArray(native_control) && native_control.version === 1;
72 return { workflows, workflowAliases: workflow_aliases, nativeControl: supported,
73 telemetryConsent: validTelemetryConsent(telemetry_consent) ? telemetry_consent : null,
74 unsupportedControl: native_control !== undefined && native_control !== null && !supported };
75}
76hooks/native-control.mjs 175 lines1// Native workflow-control transport. This does NOT enforce ordinary Claude
2// tools or authorize a human decision. The workspace must use executionReadiness
3// before enabling Start; a connected gateway alone is insufficient.
4import { validName, validWorkflowAliases, decodeCatalogConnection } from './catalog.mjs';
5
6export const NATIVE_CONTROL_TOOL = 'statewright_native_request';
7export const NATIVE_CONNECT_TOOL = 'statewright_native_connect';
8const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
9const uuid = value => typeof value === 'string' &&
10 /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(value) &&
11 value !== '00000000-0000-0000-0000-000000000000';
12const only = (value, keys) => object(value) && Object.keys(value).every(key => keys.includes(key));
13const count = value => Number.isSafeInteger(value) && value >= 0;
14export function nativeReadArgs(args, conversation) {
15 return uuid(conversation) && only(args, ['context', 'operation', 'input', 'expected_revision']) &&
16 args.operation === 'get_state' && only(args.context, ['version', 'conversation_id']) &&
17 args.context.version === 1 && args.context.conversation_id === conversation && only(args.input, []) &&
18 (args.expected_revision === undefined || args.expected_revision === null);
19}
20export function nativeReadTool(name, args, conversation) {
21 return typeof name === 'string' &&
22 /^mcp__plugin[:_]statewright[:_]statewright__statewright_native_request$/.test(name) &&
23 nativeReadArgs(args, conversation);
24}
25export function nativeReadCall({ tool, tool_use_id, agentId, requestMeta, consent, ...args }, conversation) {
26 return nativeReadTool(tool, args, conversation);
27}
28function frozen(value) {
29 if (object(value) || Array.isArray(value)) {
30 for (const entry of Object.values(value)) frozen(entry);
31 Object.freeze(value);
32 }
33 return value;
34}
35function toolResult(value) {
36 return object(value) && (value.isError === undefined || typeof value.isError === 'boolean') &&
37 Array.isArray(value.content) && value.content.length <= 100 && value.content.every(block =>
38 object(block) && block.type === 'text' && typeof block.text === 'string');
39}
40export function decodeNativeControl(result, conversation) {
41 if (!uuid(conversation) || !toolResult(result) || result.isError || result.content.length !== 1 ||
42 result.content[0].text.length > 262144) throw new Error('Statewright response unavailable');
43 const value = JSON.parse(result.content[0].text);
44 if (value?.workflow_aliases !== undefined && !validWorkflowAliases(value.workflow_aliases))
45 throw new Error('Invalid workflow aliases');
46 if (!object(value) || value.protocol_version !== 1 || value.conversation_id !== conversation ||
47 !uuid(value.revision) || !object(value.capabilities) ||
48 !['workflow_control', 'native_tool_enforcement', 'human_approval', 'model_routing']
49 .every(key => typeof value.capabilities[key] === 'boolean') ||
50 (value.outcome !== null && !toolResult(value.outcome))) throw new Error('Invalid native control receipt');
51 const s = value.state;
52 if (s !== null && (!object(s) || !validName(s.workflow) || !validName(s.state) || typeof s.is_final !== 'boolean' ||
53 !count(s.transition_count) || !Array.isArray(s.transitions) || s.transitions.length > 256 ||
54 !s.transitions.every(t => object(t) && validName(t.event) && validName(t.target)) ||
55 new Set(s.transitions.map(t => t.event)).size !== s.transitions.length ||
56 !Array.isArray(s.allowed_tools) || s.allowed_tools.length > 1000 || !s.allowed_tools.every(validName) ||
57 (s.run_id !== undefined && !validName(s.run_id)))) throw new Error('Invalid native workflow state');
58 if (s?.pending_approval !== undefined && s.pending_approval !== null) {
59 const p = s.pending_approval;
60 if (!object(p) || ![p.approval_id, p.event, p.from_state, p.to_state].every(validName) ||
61 p.from_state !== s.state || s.is_final ||
62 (p.message !== null && (typeof p.message !== 'string' || p.message.length > 10000)))
63 throw new Error('Invalid native pending approval');
64 }
65 return frozen(value);
66}
67
68// One explicitly approved connection read returns both catalog and same-chat
69// status. It carries no authorization for any subsequent workflow operation.
70export function decodeNativeConnection(result, conversation) {
71 if (!toolResult(result) || result.isError || result.content.length !== 1 ||
72 result.content[0].text.length > 393216) throw new Error('Invalid native connection receipt');
73 const value = JSON.parse(result.content[0].text);
74 if (!only(value, ['protocol_version', 'conversation_id', 'workflows', 'snapshot']) ||
75 value.protocol_version !== 1 || value.conversation_id !== conversation)
76 throw new Error('Invalid native connection identity');
77 const wrap = value => ({ content: [{ type: 'text', text: JSON.stringify(value) }] });
78 const snapshot = decodeNativeControl(wrap(value.snapshot), conversation);
79 const { workflows, workflowAliases } = decodeCatalogConnection(wrap({ workflows: value.workflows,
80 workflow_aliases: snapshot.workflow_aliases }));
81 if (snapshot.outcome !== null) throw new Error('Connection must be read-only');
82 return { workflows, workflowAliases, nativeControl: true, snapshot };
83}
84
85// Local enforcement is an integration prerequisite, not a server claim or a
86// model-supplied argument. The shipping workspace supplies its native adapter.
87export function executionReadiness(snapshot, localEnforcementReady = false) {
88 if (!snapshot?.capabilities?.workflow_control) return { available: false, reason: 'Workflow controls are not available on this connection yet.' };
89 if (localEnforcementReady !== true || snapshot.capabilities.native_tool_enforcement !== true)
90 return { available: false, reason: 'Workflow tool restrictions are not connected yet.' };
91 if (snapshot.capabilities.human_approval !== true)
92 return { available: false, reason: 'Human approval is not connected yet.' };
93 return { available: true, reason: '' };
94}
95
96export function progressNodes(snapshot) {
97 const s = snapshot?.state;
98 if (!s) return null;
99 const next = [...new Set(s.transitions.map(t => t.target))];
100 // Possible destinations are not proof that their guards currently pass.
101 // No synthetic history: the wire contract does not yet provide visit history.
102 return { current: s.state, next: next.slice(0, 4), more: Math.max(0, next.length - 4),
103 final: s.is_final, pending: Boolean(s.pending_approval) };
104}
105
106function actionValid(action) {
107 if (!only(action, ['operation', 'input', 'expectedRevision'])) return false;
108 const i = action.input;
109 switch (action.operation) {
110 case 'get_state': case 'deactivate': case 'stop': return only(i, []) && Object.keys(i).length === 0;
111 case 'load_workflow': return only(i, ['name']) && validName(i.name);
112 case 'transition': return only(i, ['event', 'data']) && validName(i.event) &&
113 (i.data === undefined || object(i.data));
114 case 'request_approval': return only(i, ['run_id', 'approval_id']) && validName(i.run_id) && validName(i.approval_id);
115 case 'pre_tool': return only(i, ['tool_name', 'tool_input']) && validName(i.tool_name) && object(i.tool_input);
116 case 'post_tool': return only(i, ['tool_name', 'tool_input', 'tool_response', 'is_error']) &&
117 validName(i.tool_name) && object(i.tool_input) && typeof i.tool_response === 'string' && typeof i.is_error === 'boolean';
118 default: return false;
119 }
120}
121
122export function createNativeControl({ conversation, call }) {
123 let owner = '', snapshot = null, busy = false;
124 async function current() {
125 const id = await conversation();
126 if (!uuid(id)) { owner = ''; snapshot = null; throw new Error('Native conversation unavailable'); }
127 if (owner !== id) { owner = id; snapshot = null; }
128 return id;
129 }
130 return {
131 async snapshot() { await current(); return snapshot; },
132 async acceptConnection(result) {
133 if (busy) throw new Error('A Statewright request is already in progress');
134 busy = true;
135 try {
136 const id = await current();
137 const connection = decodeNativeConnection(result, id);
138 snapshot = connection.snapshot;
139 return snapshot;
140 } finally { busy = false; }
141 },
142 async request(action, permission) {
143 if (!actionValid(action)) throw new Error('Invalid native workflow action');
144 if (busy) throw new Error('A Statewright request is already in progress');
145 // Reserve before yielding: a second native callback cannot race dispatch.
146 busy = true;
147 let dispatched = false;
148 try {
149 const id = await current();
150 const mutation = action.operation !== 'get_state';
151 if (mutation && (!snapshot || !uuid(action.expectedRevision) || snapshot.revision !== action.expectedRevision))
152 throw new Error('Refresh Statewright before trying this action');
153 const before = snapshot?.revision;
154 const args = { context: { version: 1, conversation_id: id }, operation: action.operation,
155 input: action.input, expected_revision: mutation ? before : action.expectedRevision ?? null };
156 dispatched = true;
157 const result = await call(args, permission);
158 if (await current() !== id) throw new Error('The conversation changed during this request');
159 const next = decodeNativeControl(result, id);
160 if ((mutation && (next.outcome === null || next.revision === before)) ||
161 (!mutation && next.outcome !== null)) throw new Error('Incomplete native workflow receipt');
162 // Nested isError remains visible to the caller; never turn it into a
163 // successful Start/transition merely because the outer MCP call worked.
164 snapshot = next;
165 return next;
166 } catch (error) {
167 // A dispatched mutation may have succeeded remotely. No retries or
168 // reuse of its old revision; a new read is required to learn the truth.
169 if (dispatched) snapshot = null;
170 throw error;
171 } finally { busy = false; }
172 },
173 };
174}
175hooks/native-execution.mjs 130 lines1// Thin native hook binding to the existing gateway adapter. Claude owns tool
2// execution and permissions. Permission-time rewrites are a documented host
3// limitation; post observations report actual inputs, never invented effects.
4const active = v => Boolean(v.requiresEnforcement || (v.native?.state && !v.native.state.is_final));
5const canonical = value => Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object'
6 ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value;
7const same = (a, b) => JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
8// Adapters are created for each hook invocation. Queue ownership belongs to the
9// conversation view, and the running slot includes post_tool completion.
10const queues = new WeakMap();
11const CANCELLED = 'This native tool call was cancelled before execution.';
12const STALE = 'The conversation or workflow changed while this tool was waiting. Read the current state before continuing.';
13const UNCERTAIN = 'Statewright could not record the preceding tool safely. Refresh Statewright before continuing.';
14function waitForRead(v, record, signal, after) {
15 const queue = queues.get(v) ?? [];
16 queues.set(v, queue);
17 if (v.runningTool.id === record.id || queue.some(entry => entry.record.id === record.id))
18 return Promise.resolve('This tool invocation identity is already running or queued.');
19 if (queue.length >= 32) return Promise.resolve('The Statewright Read queue is full (32 waiting calls).');
20 return new Promise(resolve => {
21 let timer, settled = false;
22 const finish = reason => {
23 if (settled) return;
24 settled = true;
25 timer?.cancel(); signal?.removeEventListener('abort', abort);
26 const index = queue.indexOf(entry);
27 if (index !== -1) queue.splice(index, 1);
28 resolve(reason);
29 };
30 const abort = () => finish(CANCELLED);
31 const entry = { record, finish };
32 queue.push(entry);
33 signal?.addEventListener('abort', abort, { once: true });
34 if (signal?.aborted) abort();
35 else {
36 try { timer = after(60_000, () => finish('The Statewright Read queue wait timed out. The running tool was not interrupted.')); }
37 catch { finish('Statewright could not schedule this Read safely. Refresh Statewright before continuing.'); }
38 }
39 });
40}
41function release(v, record) {
42 if (v.runningTool !== record) return;
43 v.runningTool = null;
44 const queue = queues.get(v);
45 if (record.uncertain) {
46 for (const entry of [...(queue ?? [])]) entry.finish(UNCERTAIN);
47 } else if (queue?.length) {
48 const entry = queue[0];
49 // Reserve synchronously so a new call cannot overtake an existing waiter.
50 v.runningTool = entry.record;
51 entry.finish();
52 }
53}
54export function outcome(snapshot) {
55 if (snapshot?.outcome?.isError || snapshot?.outcome?.content?.length !== 1) throw new Error('Statewright action failed');
56 return JSON.parse(snapshot.outcome.content[0].text);
57}
58export function createExecutionAdapter({ view, request, fault, after }) {
59 return {
60 async call($, e, next) {
61 const v = await view($);
62 if (!active(v)) return next(e);
63 if (!e.tool_use_id) return { deny: 'Claude did not provide a tool invocation identity.' };
64 if (next.signal?.aborted) return { deny: CANCELLED };
65 const record = { id: e.tool_use_id, tool: e.tool, generation: v.generation ?? 0, input: null, checked: false, observed: null, signal: next.signal };
66 if (v.runningTool) {
67 if (e.tool !== 'Read' || v.runningTool.tool !== 'Read')
68 return { deny: 'A Statewright tool is still running. Continue with one tool at a time.' };
69 const denied = await waitForRead(v, record, next.signal, after);
70 if (denied) return { deny: denied };
71 } else v.runningTool = record;
72 let result;
73 const fail = message => { record.uncertain = true; fault(v, message); };
74 try {
75 if (await view($) !== v || (v.generation ?? 0) !== record.generation || !active(v)) return { deny: STALE };
76 if (next.signal?.aborted) return { deny: CANCELLED };
77 if (v.actionBusy || v.controlError) return { deny: 'Refresh Statewright after its current workflow action before continuing.' };
78 result = await next(e); return result;
79 }
80 finally {
81 try {
82 if (await view($) === v && (v.generation ?? 0) === record.generation && active(v) && record.checked) {
83 const o = record.observed;
84 if (o) {
85 if (!same(record.input, o.tool_input)) fail('Claude changed the tool input during permission handling. The actual result is recorded.');
86 outcome(await request($, 'post_tool', { tool_name: record.tool, tool_input: o.tool_input,
87 tool_response: o.response, is_error: o.failed }));
88 } else if (!result?.deny) fail('Claude did not provide completion evidence for the last tool. Its effects were not counted.');
89 }
90 } catch { fail('Couldn’t record the last tool result. Refresh Statewright before continuing.'); }
91 finally { release(v, record); }
92 }
93 },
94 async check($, e, next) {
95 // Native permission handling may wait for a human while Off or resume
96 // replaces the workflow. Keep the originating owner across that await.
97 const v = await view($), record = v.runningTool, generation = v.generation ?? 0;
98 const enforced = active(v), owned = record?.id === e.tool_use_id && record?.tool === e.tool;
99 const native = await next(e);
100 if (native.decision === 'deny' || !e.tool_use_id) return native;
101 if (await view($) !== v || generation !== (v.generation ?? 0) ||
102 (enforced && !active(v)) || (owned && record.generation !== generation))
103 return { decision: 'deny', reason: STALE };
104 if ((owned && record.signal?.aborted) || next.signal?.aborted)
105 return { decision: 'deny', reason: CANCELLED };
106 if (!active(v)) return native;
107 if (!owned || v.runningTool !== record)
108 return { decision: 'deny', reason: 'Statewright could not correlate this native tool request.' };
109 if (record.checked) return same(record.input, e.input) ? native : { decision: 'deny', reason: 'Tool arguments changed after the workflow check. Submit a new call.' };
110 const s = await request($, 'pre_tool', { tool_name: e.tool, tool_input: e.input });
111 const decision = outcome(s);
112 if (await view($) !== v || record.generation !== (v.generation ?? 0) || !active(v) || v.runningTool !== record)
113 return { decision: 'deny', reason: STALE };
114 if (record.signal?.aborted || next.signal?.aborted) return { decision: 'deny', reason: CANCELLED };
115 if (decision.decision !== 'allow') return { decision: 'deny', reason: decision.reason || 'This tool is not allowed in the current workflow state.' };
116 record.input = JSON.parse(JSON.stringify(e.input)); record.checked = true;
117 // An allow from Statewright never replaces a native ask or organization rule.
118 return native;
119 },
120 async observe($, e, failed) {
121 const v = await view($), record = v.runningTool;
122 if (!record || record.id !== e.tool_use_id || record.tool !== e.tool_name || record.observed) return;
123 const raw = failed ? String(e.error ?? 'Native tool failed') : typeof e.tool_response === 'string'
124 ? e.tool_response : JSON.stringify(e.tool_response ?? null);
125 // Bounded context observation, no transcript or result persistence here.
126 record.observed = { tool_input: e.tool_input, response: raw.slice(0, 200000), failed: failed === true };
127 },
128 };
129}
130hooks/workflow-ui.mjs 98 lines1// Presentation only. All inputs are verified native snapshots; graph edges
2// describe possible transitions, never authorization or predicted execution.
3import { claudeRoute } from './native-routing.mjs';
4export const ARBITER_PURPLE = '#BA97ED';
5export const ARBITER_ACCENT = '#8BD5E8';
6export const STATE_BACKGROUND = '#30264A';
7export const eventSymbol = event => `:${event.toLowerCase()}`;
8
9function modelName(model, compact) {
10 const match = /^claude-(haiku|sonnet|opus|fable)-(\d+)-(\d+)(?:-\d{8})?$/.exec(model);
11 return match ? `${match[1][0].toUpperCase()}${match[1].slice(1)}${compact ? '' : ` ${match[2]}.${match[3]}`}` : model;
12}
13export function modelPresentation(view, compact = false) {
14 const active = view.modelActivity;
15 const label = (model, effort) => `${modelName(model, compact)}${compact ? '/' : ' / '}${effort ?? 'default'}`;
16 // A native request may still be streaming after one of its tools changes the
17 // workflow. Show that request until it settles, not the next state's model.
18 if (active) return `${label(active.model, active.effort)}${active.fallback ? compact ? '↘' : ' · fallback' : ''}${compact ? '…' : ' · running'}`;
19 if (!view.connected || view.busy || view.actionBusy || view.controlError || view.routeError) return '';
20 try {
21 const route = claudeRoute(view.native?.state);
22 if (route && view.fallback?.from === route.model && view.fallback.generation === view.generation && view.fallback.runId === view.native?.state?.run_id)
23 return `${compact ? '→' : 'next '}${label('claude-opus-5-5', route.effort)}${compact ? '↘' : ' · fallback'}`;
24 if (route) return `${compact ? '→' : 'next '}${label(route.model, route.effort)}`;
25 // The host's current selection, not the last routed provider response.
26 // Keep effort only when the last unmodified request matches that selection.
27 if (typeof view.selectedModel !== 'string' || !view.selectedModel) return '';
28 const effort = view.baseRequest?.model === view.selectedModel ? view.baseRequest.effort : null;
29 return effort != null ? label(view.selectedModel, effort) : modelName(view.selectedModel, compact);
30 } catch { return ''; }
31}
32
33export function observeJourney(previous, snapshot) {
34 const s = snapshot?.state;
35 if (!s) return null;
36 const owner = `${snapshot.conversation_id}:${s.run_id ?? ''}`;
37 const count = s.transition_count;
38 const item = { state: s.state, count, gap: false };
39 if (!s.run_id || !previous || previous.owner !== owner || count < previous.entries.at(-1).count)
40 return { owner, entries: [item] };
41 const last = previous.entries.at(-1);
42 if (last.count === count && last.state === s.state) return previous;
43 // A refreshed snapshot can skip visits. Do not draw a confirmed edge across
44 // a gap or infer history from the workflow definition's possible next states.
45 item.gap = count !== last.count + 1;
46 return { owner, entries: [...previous.entries, item].slice(-5) };
47}
48
49export function statusPresentation(view, { surface = 'terminal', powerline = false, compact = false, columns = Infinity } = {}) {
50 const s = view.connected ? view.native?.state : null;
51 const status = view.busy ? (view.interactive ? 'Connecting…' : 'Checking connection…')
52 : view.controlError || view.routeError ? 'Needs attention'
53 : s?.pending_approval ? compact ? 'Review needed' : `${s.state} · Needs your decision`
54 : s ? `${s.state}${s.is_final ? ' · Complete' : ''}`
55 : view.connected ? 'Connected to Statewright'
56 : view.connectionIssue === 'auto_review_unavailable' ? 'Check Claude permissions'
57 : view.error ? compact ? 'Check connection' : 'Connection needs attention' : 'Not connected';
58 const events = s && !s.is_final && !s.pending_approval && !view.controlError && !view.routeError && !view.busy
59 ? s.transitions.slice(0, 16).map(t => eventSymbol(t.event)) : [];
60 const usePowerline = surface === 'terminal' && powerline === true;
61 const tail = events.length ? ` events: ${events.join(' | ')}${s.transitions.length > events.length ? ' | …' : ''} ${usePowerline ? '' : '>'}` : '';
62 const model = modelPresentation(view, compact);
63 // Reserve the mandatory state/model/effort cells before optional next events.
64 // Include segment separators and native row affordance. A wider terminal must
65 // not lose effort just because it crossed a fixed "wide" breakpoint.
66 if (!compact && 28 + status.length + (model ? model.length + 11 : 7) > columns)
67 return statusPresentation(view, { surface, powerline, compact: true, columns });
68 const shownStatus = compact && model && !view.controlError && !view.routeError && !s?.pending_approval && status.length > 8
69 ? `${status.slice(0, 7)}…` : status;
70 // Optional events add a divider and a truncation ellipsis. Never let those
71 // overwrite the final character of a model/effort that otherwise fits.
72 const shownTail = model && (compact ? 3 : 28) + shownStatus.length + model.length + 10 > columns ? '' : tail;
73 return { status: shownStatus, model, tail: shownTail, compact, powerline: usePowerline,
74 attention: Boolean(view.controlError || view.routeError || view.error || s?.pending_approval) };
75}
76
77export function graphRows(snapshot, journey) {
78 const s = snapshot?.state;
79 if (!s) return [];
80 const current = observeJourney(journey, snapshot);
81 const rows = [];
82 for (const [i, item] of current.entries.entries()) {
83 if (i) rows.push({ text: item.gap ? '┊ …' : '│', kind: 'edge' });
84 rows.push({ text: `${i === current.entries.length - 1 ? '●' : '○'} ${item.state}`,
85 kind: i === current.entries.length - 1 ? 'current' : 'past' });
86 }
87 if (!s.is_final) {
88 const branches = s.transitions.slice(0, 8);
89 branches.forEach((t, i) => rows.push({
90 text: `${i === branches.length - 1 && s.transitions.length <= 8 ? '└' : '├'}─ ${eventSymbol(t.event)} → ○ ${t.target}`,
91 kind: 'next',
92 }));
93 if (s.transitions.length > branches.length)
94 rows.push({ text: `└─ … ${s.transitions.length - branches.length} more possible transitions`, kind: 'next' });
95 }
96 return rows;
97}
98hooks/result-ui.mjs 102 lines1// Render the result of THIS call, never the live workspace or another call.
2// Deterministic presentation: no model request and no mutation of tool output.
3const object = v => v !== null && typeof v === 'object' && !Array.isArray(v);
4const label = key => key.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ');
5const clean = value => String(value).replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').replace(/[\u202a-\u202e\u2066-\u2069]/g, '');
6const short = (value, max = 240) => { const s = clean(value); return s.length > max ? `${s.slice(0, max - 1)}…` : s; };
7const text = v => typeof v === 'string' && v.trim() ? v : undefined;
8const message = v => text(v) ?? (object(v) ? text(v.message) : undefined);
9const owned = tool => /^mcp__statewright__statewright_(connect|get_state|load_workflow|transition|deactivate|request_approval)$/.test(tool);
10const textEnvelope = v => Array.isArray(v.content) && v.content.length === 1 && v.content[0]?.type === 'text' && typeof v.content[0].text === 'string';
11const cardKey = async ($, e) => `${await $.session.id()}:${e.surface}:${e.props.tool}:${e.props.tool_use_id}`;
12export function resultSummary(output) {
13 let value = output;
14 // Native plugin tools return a JSON string; MCP tools may wrap text blocks.
15 if (object(value) && Array.isArray(value.content)) {
16 if (value.isError || !textEnvelope(value)) return null;
17 value = value.content[0].text;
18 }
19 if (typeof value === 'string') {
20 if (value.length > 131072) return null;
21 try { value = JSON.parse(value); } catch { return null; }
22 }
23 if (!object(value)) return null;
24 let outcome = value.outcome;
25 let failed = value.isError === true || Boolean(value.error) || outcome?.isError === true;
26 if (object(outcome) && 'content' in outcome) {
27 if (!textEnvelope(outcome)) return null; // Never hide mixed/media/error detail.
28 outcome = outcome.content[0].text;
29 }
30 if (typeof outcome === 'string') {
31 if (outcome.length > 131072) return null;
32 try { outcome = JSON.parse(outcome); } catch { /* A plain outcome is already readable. */ }
33 }
34 failed ||= outcome?.isError === true || Boolean(outcome?.error);
35 const state = object(value.state) ? value.state : null;
36 const pending = state?.pending_approval ?? value.pending_approval;
37 const currentState = value.state === null ? 'No active workflow' : text(value.state) ?? text(state?.state) ?? text(value.current_state);
38 const transition = object(outcome) ? outcome : value;
39 // Only the exact successful receipt proves a move. A pending target is not
40 // a completed transition, and an old/global UI snapshot cannot supply from.
41 const moved = !failed && !pending && transition.transitioned === true && text(transition.from) && text(transition.to) &&
42 (currentState === undefined || currentState === transition.to);
43 const rows = [];
44 const row = (label, value, attention = false) => { if (value !== undefined) rows.push({ label, value: short(value), attention }); };
45 row('State', moved ? `${transition.from} => ${transition.to}` : currentState);
46 row('Outcome', failed ? 'Error' : text(outcome?.status) ?? text(value.status) ?? (moved ? 'Transitioned' : undefined) ?? text(outcome) ??
47 (value.outcome != null && !outcome?.decision && !outcome?.disposition ? message(outcome) ?? 'No outcome detail' : undefined), failed);
48 const allowed = state?.allowed_tools ?? value.allowed_tools;
49 if (Array.isArray(allowed) && allowed.every(t => typeof t === 'string'))
50 row('Allowed tools', !allowed.length ? 'None' : allowed.slice(0, 12).join(', ') + (allowed.length > 12 ? `, … +${allowed.length - 12}` : ''));
51 row('Disposition', pending ? 'Awaiting approval' : text(outcome?.disposition) ?? text(outcome?.decision) ?? text(value.disposition) ?? text(value.decision), Boolean(pending));
52 const reasons = [message(value.error), message(outcome?.error), failed ? text(outcome) : undefined,
53 text(outcome?.reasoning) ?? text(outcome?.reason) ?? text(outcome?.rationale) ?? message(outcome),
54 text(value.reasoning) ?? text(value.reason) ?? text(value.rationale) ?? text(value.executionUnavailableReason) ?? text(value.message),
55 message(pending)].filter(Boolean);
56 if (reasons.length) row('Reasoning', [...new Set(reasons)].join(' · '), failed || Boolean(pending));
57 return rows.length ? { rows } : null;
58}
59export function registerResultUI(on, purple) {
60 // Keys belong to rendered historical calls, not a mutable current snapshot.
61 const expanded = new Set();
62 const cards = new Set();
63 // Own the whole completed call, including grouped/verbose rows. Replacing
64 // only ToolResult leaves the native ToolUse header/inline JSON above it.
65 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
66 if (!owned(e.props.tool)) return next(e);
67 const ownership = await cardKey($, e);
68 const summary = resultSummary(e.props.output);
69 if (e.props.isErrored || e.props.isRunning || e.props.isInterrupted || !summary) {
70 if (cards.delete(ownership)) $.ui.invalidate('ui.render');
71 return next(e);
72 }
73 const { Box, Text, Button } = $.ui.resolve(e);
74 const key = `${await $.session.id()}:${e.props.tool_use_id}`;
75 const raw = expanded.has(key);
76 const title = label(e.props.tool.split('__').at(-1).replace(/^statewright_/, ''));
77 const card = Box({ borderStyle: 'round', borderColor: purple, flexDirection: 'column', paddingX: 1, children: [
78 Text({ bold: true, color: purple, children: [`statewright.ai ∞ › ${title}`] }),
79 ...(raw ? [Text({ children: [JSON.stringify({ input: e.props.input,
80 output: typeof e.props.output === 'string' ? JSON.parse(e.props.output) : e.props.output }, null, 2)] })] : summary.rows.map(row => Text({ children: [
81 Text({ bold: true, color: row.attention ? '#FFD580' : purple, children: [`${row.label} `] }),
82 Text({ children: [row.value] }),
83 ] }))),
84 Button({ key: 'result-details', label: raw ? '▾ Summary' : '▸ Details', plain: true, onPress: () => {
85 if (raw) expanded.delete(key); else { if (expanded.size >= 128) expanded.delete(expanded.values().next().value); expanded.add(key); }
86 $.ui.invalidate('ui.render');
87 } }),
88 ].filter(Boolean) });
89 if (!cards.has(ownership)) {
90 if (cards.size >= 512) cards.delete(cards.values().next().value);
91 cards.add(ownership);
92 $.ui.invalidate('ui.render');
93 }
94 return card;
95 });
96 // Standalone calls get a second result slot; the whole-call card above owns
97 // its output already. Unrecognized results and native failures stay native.
98 on('ui.render', { component: 'ToolResult' }, async ($, e, next) =>
99 owned(e.props.tool) && !e.props.isErrored && resultSummary(e.props.output) && cards.has(await cardKey($, e))
100 ? $.ui.resolve(e).Box({ children: [] }) : next(e));
101}
102hooks/desktop-ui.mjs 50 lines1import { ARBITER_PURPLE, ARBITER_ACCENT, observeJourney, eventSymbol } from './workflow-ui.mjs';
2
3const escape = value => String(value).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
4const short = (value, length = 30) => String(value).length > length ? `${String(value).slice(0, length - 1)}…` : String(value);
5
6// A bounded, descriptive graph. Never invent visits or imply the next branch
7// ran. Escaped labels are data, not SVG markup, links or event handlers.
8export function desktopGraph(snapshot, journey) {
9 const state = snapshot?.state;
10 if (!state) return null;
11 const visits = observeJourney(journey, snapshot).entries;
12 const next = state.is_final ? [] : state.transitions.slice(0, 4);
13 const height = 24 + visits.length * 42 + next.length * 60 + (state.transitions.length > next.length && !state.is_final ? 24 : 0);
14 const parts = [`<svg xmlns="http://www.w3.org/2000/svg" width="320" height="${height}" viewBox="0 0 320 ${height}" role="img">`,
15 '<style>text{font-family:system-ui,sans-serif;font-size:12px}</style>'];
16 const captions = [];
17 visits.forEach((visit, i) => {
18 const current = i === visits.length - 1, y = 12 + i * 42;
19 if (i) parts.push(`<path d="M30 ${y - 14}V${y}" stroke="${visit.gap ? '#E8BC70' : '#75688E'}" stroke-width="2"${visit.gap ? ' stroke-dasharray="2 5"' : ''}/>`);
20 parts.push(`<rect x="12" y="${y}" width="296" height="28" rx="14" fill="${current ? '#30264A' : '#211E2C'}" stroke="${current ? ARBITER_PURPLE : '#75688E'}"/>`,
21 `<circle cx="30" cy="${y + 14}" r="4" fill="${current ? ARBITER_PURPLE : '#75688E'}"/>`,
22 `<text x="44" y="${y + 19}" fill="${current ? '#F2E9FF' : '#C7BDD9'}">${escape(short(visit.state, 29))}</text>`,
23 `<title>${escape(`${current ? 'Current' : 'Visited'}: ${visit.state}${visit.gap ? ' (intervening visits not observed)' : ''}`)}</title>`);
24 captions.push(`${visit.gap ? 'Unobserved gap; ' : ''}${current ? 'Current' : 'Visited'}: ${visit.state}`);
25 });
26 const from = 12 + (visits.length - 1) * 42 + 28;
27 next.forEach((transition, i) => {
28 const y = 12 + visits.length * 42 + i * 60;
29 parts.push(`<path d="M30 ${from}V${y + 28}H54" fill="none" stroke="${ARBITER_ACCENT}" stroke-dasharray="3 4"/>`,
30 `<text x="58" y="${y + 8}" fill="${ARBITER_ACCENT}">${escape(short(eventSymbol(transition.event), 31))}</text>`,
31 `<rect x="54" y="${y + 15}" width="254" height="28" rx="14" fill="#192A30" stroke="${ARBITER_ACCENT}" stroke-dasharray="3 3"/>`,
32 `<text x="66" y="${y + 34}" fill="#C5EDF5">${escape(short(transition.target, 30))}</text>`);
33 captions.push(`Possible ${eventSymbol(transition.event)} to ${transition.target}`);
34 });
35 if (!state.is_final && state.transitions.length > next.length) {
36 const extra = state.transitions.length - next.length;
37 parts.push(`<text x="54" y="${height - 5}" fill="${ARBITER_ACCENT}">+${extra} more events in Next step</text>`);
38 captions.push(`${extra} more events in Next step`);
39 }
40 parts.push('</svg>');
41 return { source: parts.join(''), alt: captions.join('. '), height };
42}
43
44export function desktopTitle(view) {
45 if (view.busy) return view.interactive ? 'Connecting to Statewright…' : 'Checking your saved connection…';
46 if (view.connected) return 'Connected to Statewright';
47 if (view.connectionIssue === 'auto_review_unavailable') return 'Check Claude permissions';
48 return view.error ? 'Connection needs attention' : 'Statewright would like to connect';
49}
50hooks/diagnostic-event.mjs 79 lines1// Portable between Node entrypoints and Claude's native hooks. This is a closed
2// vocabulary, not a redactor: no message, stack, URL, path or tool input leaves.
3export const DEFAULT_SENTRY_DSN = 'https://3c30b803a5b44d74bf9657db7a89f033@glitch.enhasa.cloud/12';
4const words = value => new Set(value.split(' '));
5const fields = {
6 mechanism: words('test entrypoint child_exit child_spawn transport protocol shutdown approval_evidence unhandled_rejection uncaught_exception handled'),
7 operation: words('unknown managed_client unmanaged_client managed_exec resident_tui native_tui app_server app_server_proxy resident_app_server prepare_review statewright_exec statewright_codex connect login catalog control get_state load_workflow transition deactivate request_approval pre_tool post_tool stop command native_ui canary hook mcp.connect mcp.call tool.call tool.check'),
8 host: words('codex claude claude-code pi opencode omx'),
9 transport: words('websocket http stdio'),
10 side: words('native_close upstream_close native_error upstream_error native upstream'),
11 phase: words('check login reconnect identity catalog catalog_decode complete control'),
12 runtime: words('node claude-native'),
13 reason: words('unknown oauth_failed credential_unavailable permission_refused auto_review_unavailable helper_unavailable login_timeout login_incomplete auth policy disabled unapproved unlisted failed session_unbound tool_unavailable control_hook_failed module_unavailable capability_unavailable request_timeout server_identity catalog_invalid native_error command_failed workflow_failed ui_failed synthetic_canary'),
14 signal: words('SIGINT SIGTERM SIGKILL SIGABRT SIGSEGV SIGHUP SIGPIPE'),
15};
16const classes = words('Error TypeError RangeError SyntaxError ReferenceError URIError EvalError AggregateError AbortError');
17const plugins = words('codex claude-code managed-client executor pi opencode omx');
18export function sanitizeContext(context = {}) {
19 const safe = {};
20 for (const [key, value] of Object.entries(context)) {
21 if (Object.hasOwn(fields, key) && fields[key].has(value)) safe[key] = value;
22 else if (['exit_code', 'close_code', 'http_status'].includes(key) && Number.isSafeInteger(value) && value >= 0 && value <= 4294967295) safe[key] = value;
23 }
24 return safe;
25}
26export function sanitizeError(error) {
27 return { type: classes.has(error?.name) ? error.name : 'Error',
28 value: 'Unexpected plugin failure (message withheld).' };
29}
30// One versioned product-improvement choice, not a host's or a historical
31// recording-only consent. Unknown and explicit local opt-out never enable it.
32export function diagnosticsAllowed({ enabled, disabled, consent } = {}) {
33 if (disabled === true || disabled === 'true' || enabled === false || enabled === 'false') return false;
34 if (consent?.version === 2 && consent.enabled === false) return false;
35 return enabled === true || enabled === 'true' ||
36 (consent?.version === 2 && consent?.enabled === true);
37}
38export function validTelemetryConsent(value) {
39 return value !== null && typeof value === 'object' && !Array.isArray(value) &&
40 Object.keys(value).every(key => ['version', 'enabled', 'updated_at'].includes(key)) &&
41 value.version === 2 && typeof value.enabled === 'boolean' &&
42 typeof value.updated_at === 'string' && value.updated_at.length < 40 && Number.isFinite(Date.parse(value.updated_at));
43}
44export function observeTelemetryConsent(view, value, now = Date.now()) {
45 const next = validTelemetryConsent(value) ? value : null;
46 const stamp = next ? Date.parse(next.updated_at) : 0, watermark = view.telemetryWatermark ?? 0;
47 if (!next || !next.enabled || stamp > watermark ||
48 (stamp === watermark && view.telemetryWatermarkDenied !== true)) {
49 view.telemetryConsent = next; view.telemetryCheckedAt = now;
50 view.telemetryWatermark = Math.max(stamp, watermark);
51 if (next) view.telemetryWatermarkDenied = !next.enabled;
52 }
53}
54export function sentryEndpoint(dsn) {
55 try {
56 const parsed = new URL(dsn), parts = parsed.pathname.split('/').filter(Boolean), project = parts.pop();
57 if (!['https:', 'http:'].includes(parsed.protocol) || !parsed.username || !/^\d+$/.test(project ?? '') || parsed.password || parsed.search || parsed.hash) return null;
58 return `${parsed.protocol}//${parsed.host}${parts.length ? `/${parts.join('/')}` : ''}/api/${project}/envelope/?sentry_version=7&sentry_key=${encodeURIComponent(parsed.username)}`;
59 } catch { return null; }
60}
61export function diagnosticEvent({ plugin, version, platform, environment, eventId, error, context = {}, handled = false }) {
62 const tags = sanitizeContext(context);
63 tags.plugin = plugins.has(plugin) ? plugin : 'unknown';
64 if (['darwin-arm64', 'darwin-x64', 'linux-x64', 'linux-arm64', 'win32-x64', 'win32-arm64', 'claude-native'].includes(platform)) tags.platform = platform;
65 const release = `statewright-${tags.plugin}@${/^\d+\.\d+\.\d+(?:\.\d+)?$/.test(version ?? '') ? version : 'unknown'}`;
66 const failure = handled ? tags.reason ?? 'unknown' : sanitizeError(error).type;
67 return {
68 ...(eventId ? { event_id: eventId } : {}), timestamp: new Date().toISOString(), platform: 'node',
69 level: handled && tags.reason === 'permission_refused' ? 'warning' : 'error',
70 release, environment: ['production', 'staging', 'development', 'test'].includes(environment) ? environment : 'production',
71 tags, fingerprint: ['statewright-plugin-v1', tags.plugin, tags.operation ?? 'unknown', failure],
72 ...(handled ? { message: `Statewright ${tags.operation ?? 'unknown'}: ${failure}` }
73 : { exception: { values: [{ ...sanitizeError(error), mechanism: { type: tags.mechanism ?? 'generic', handled: !['uncaught_exception', 'unhandled_rejection'].includes(tags.mechanism) } }] } }),
74 };
75}
76export function formatEnvelope({ dsn, event }) {
77 return `${JSON.stringify({ ...(event.event_id ? { event_id: event.event_id } : {}), sent_at: event.timestamp, dsn, sdk: { name: 'statewright.plugin', version: '1' } })}\n${JSON.stringify({ type: 'event', content_type: 'application/json' })}\n${JSON.stringify(event)}\n`;
78}
79hooks/native-routing.mjs 81 lines1// Native per-request translation. Never change the session model or credentials.
2const nativeFamily = model => /^(?:claude-)?(haiku|sonnet|opus|fable)(?:-[0-9][a-z0-9.-]*)?$/.exec(model)?.[1];
3// turn.step bypasses the CLI alias resolver. Verified with Claude 2.1.296's
4// native resolver (e2e/native-model-probe), without making provider requests.
5const nativeModels = Object.freeze({ haiku: 'claude-haiku-5-5', sonnet: 'claude-sonnet-5-5',
6 opus: 'claude-opus-5-5', fable: 'claude-fable-5-1' });
7export function claudeRoute(state) {
8 if (!state || state.is_final || state.model == null || state.model === '') return null;
9 if (typeof state.model !== 'string') throw new Error('Invalid workflow model');
10 const parts = state.model.split('/');
11 const name = parts.pop(), provider = parts.pop();
12 if (parts.length || (provider && !['openai', 'openai-codex', 'anthropic'].includes(provider)))
13 throw new Error('No native Claude equivalent for this workflow provider');
14 const family = /^(?:gpt-\d+(?:\.\d+)?-)?(luna|terra|sol|astra)$/.exec(name)?.[1];
15 const level = state.thinking_level ?? null;
16 if (level !== null && !['low', 'medium', 'high', 'xhigh', 'max'].includes(level))
17 throw new Error('No native Claude equivalent for this workflow effort');
18 let model;
19 if (family && provider !== 'anthropic') model = family === 'luna' || (family === 'terra' && level === 'low')
20 ? 'haiku' : family === 'terra' ? 'sonnet' : family === 'sol' ? 'opus' : 'fable';
21 else if ((!provider || provider === 'anthropic') && nativeFamily(name)) model = name;
22 else throw new Error('No native Claude equivalent for this workflow model');
23 // Resolve bare aliases only; an explicit versioned native model stays pinned.
24 model = nativeModels[model] ?? nativeModels[model.replace(/^claude-/, '')] ?? model;
25 if (!model.startsWith('claude-')) model = `claude-${model}`;
26 return { requested_model: state.model, requested_effort: level, model,
27 effort: level,
28 effort_note: level === null ? 'Native model default' : 'Requested effort; native model support and account policy may constrain it' };
29}
30export function routeEvent(event, route) {
31 if (!route) return event;
32 const { effort, ...base } = event;
33 return { ...base, model: route.model, ...(route.effort === null ? {} : { effort: route.effort }) };
34}
35// Claude-only compatibility policy, never a workflow JSON setting. Match only
36// a native synthetic gate observed inside the owned Fable request; ordinary
37// text, null usage, generic errors and refusals are NOT availability evidence.
38export function fableCreditGate(event) {
39 if (event.agentId || event.door !== 'response' || event.origin?.kind !== 'model' || event.origin.model !== '<synthetic>') return false;
40 const blocks = event.message?.content;
41 return blocks?.length === 1 && blocks[0].type === 'text' &&
42 /^Fable 5(?:\.1)? requires usage credits\./.test(blocks[0].text);
43}
44export function fableFallback(route, result, probe) {
45 if (!/^claude-fable-5(?:-1)?$/.test(route.model) || !probe?.creditGate || probe.semantic ||
46 !result || result.usage !== null || result.stopReason !== null || result.answer !== '' ||
47 result.toolUses?.length !== 0 || (result.serverToolUses?.length ?? 0) !== 0) return null;
48 return { ...route, model: nativeModels.opus, fallback_from: route.model, fallback_reason: 'fable_usage_credits_required' };
49}
50export function fallbackPermitted(settings, disabled) {
51 if (['1', 'true'].includes(String(disabled).toLowerCase())) return false;
52 const allowed = settings?.availableModels;
53 return allowed === undefined || (Array.isArray(allowed) && allowed.some(m => m === 'opus' || m === nativeModels.opus));
54}
55export function routeReceipt(event, route, result) {
56 const actual = typeof result?.usage?.model === 'string' ? result.usage.model : null;
57 const matches = actual && actual === route.model;
58 const tokens = {};
59 for (const field of ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens']) {
60 const value = result?.usage?.[field];
61 if (Number.isSafeInteger(value) && value >= 0) tokens[field] = value;
62 }
63 return { turn_id: event.turnId, step: event.index, requested_model: route.requested_model,
64 requested_effort: route.requested_effort, native_model: route.model, native_effort: route.effort,
65 effort_note: route.effort_note, observed_model: actual,
66 ...(route.fallback_from ? { fallback_from: route.fallback_from, fallback_reason: route.fallback_reason } : {}),
67 status: !actual ? 'unavailable' : matches ? 'observed' : 'mismatch', tokens };
68}
69export function* blockedStep(event, message) {
70 yield { kind: 'text', index: 0, text: message };
71 yield { kind: 'stop', stopReason: 'end_turn', usage: null };
72 return { turnId: event.turnId, index: event.index, answer: message, toolUses: [], stopReason: 'end_turn', usage: null };
73}
74export function routingView(view) {
75 let configured = null, error = null;
76 try { configured = claudeRoute(view.native?.state); } catch (e) { error = e.message; }
77 return { mode: 'native_request_override', scope: 'main_conversation', configured, error,
78 last_request: view.routes?.at(-1) ?? null,
79 note: 'The gateway model_routing flag describes the remote connector. This local mod overrides native main-conversation requests; only observed_model is execution evidence.' };
80}
81hooks/desktop-action.js 11 lines1// A Client keeps its button alive across Desktop focus/redraw events. The
2// message is only an intent; connect.js validates its conversation and revision.
3export default function DesktopAction(props, surface) {
4 const { Button, Text } = surface.elements;
5 return Button({ key: props.action, label: props.label,
6 ...(props.color ? { children: [Text({ color: props.color, children: [props.label] })] } : {}),
7 ...(props.primary ? { variant: 'primary' } : {}),
8 onPress: () => surface.post({ action: props.action, revision: props.revision, conversation: props.conversation }),
9 });
10}
11