SLOPSHOPPER

statewright

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

newpanebandrowsguardcommand
★ 506v0.3.4FSL-1.1-ALv2updated 2026-10-14statewright/statewright/plugins/claude-code
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · statewright
│ ┃ Statewright ✕ › fix the failing auth test and add an audit log call │ ┃ Statewright would like to connect │ ┃ c: Cancel connection ⏺ Read(src/auth.ts) │ ┃ Powerline · this session: Use saved default ⎿ Read 6 lines │ ┃ b: Back to conversation ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /statewright │ │ arbiter > statewright.ai ∞ > Checking connection… Cancel connection ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
arbiter > statewright.ai ∞ > Checking connection… Cancel connection ⟨Claude Code's own drawing⟩
Pane · Statewright
Statewright would like to connect c: Cancel connection Powerline · this session: Use saved default (Off) ▾ b: Back to conversation
README

Statewright for Claude 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.

Install and connect

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.

Use a workflow

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.

Another gateway or an SSH 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.

Upgrade from manual hooks

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.

Troubleshooting and limits

  • No Arbiter: send the first message in Desktop Code → Local, then check /status and /plugin. Organization policy may prevent mods from loading.
  • Missing Statewright tools: reload plugins; if tools remain missing, quit and reopen the affected Claude host, then resume the conversation.
  • Connection needs attention: open the sidebar for the failed step. A native permission denial is not an OAuth failure; signing in again does not fix it.
  • A command was denied: preserve the refusal. Shell classification can be conservative; do not retry the denied operation through another interpreter.
  • Model unavailable: only the recognized Fable usage-credit gate has an adapter fallback to Opus at the same effort. Other provider/policy failures remain visible; partial responses are never replayed.
  • Enforcement boundary: native tool hooks check the call presented before permission settlement. A host-side argument rewrite after that check may execute without another pre-check; post-call input comparison is detection, not prevention. Statewright does not replace Claude's own authorization.

Development

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.

Source 10 files
hooks/connect.js 1056 lines
1import { 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}
1056
hooks/catalog.mjs 76 lines
1// 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}
76
hooks/native-control.mjs 175 lines
1// 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}
175
hooks/native-execution.mjs 130 lines
1// 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}
130
hooks/workflow-ui.mjs 98 lines
1// 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}
98
hooks/result-ui.mjs 102 lines
1// 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}
102
hooks/desktop-ui.mjs 50 lines
1import { ARBITER_PURPLE, ARBITER_ACCENT, observeJourney, eventSymbol } from './workflow-ui.mjs';
2
3const escape = value => String(value).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' })[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}
50
hooks/diagnostic-event.mjs 79 lines
1// 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}
79
hooks/native-routing.mjs 81 lines
1// 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}
81
hooks/desktop-action.js 11 lines
1// 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