@gakim-digital/dexter-bridge 0.12.2 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,16 @@
1
+ Dexter Bridge License
2
+
3
+ Copyright (c) 2026 InstaWeb AI. All rights reserved.
4
+
5
+ Permission is granted to download, install, and run this software solely to
6
+ connect your local machine to the Dexter / InstaWeb AI service. You may not
7
+ modify, redistribute, sublicense, or sell this software or use it to build a
8
+ competing service.
9
+
10
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
11
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
12
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
13
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
14
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
15
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
16
+ SOFTWARE.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gakim-digital/dexter-bridge",
3
- "version": "0.12.2",
3
+ "version": "0.13.1",
4
4
  "description": "Local bridge for InstaWebAI and Dexter — runs Codex, Claude Code, or OpenCode on your machine.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agent.js CHANGED
@@ -6,7 +6,10 @@ import path from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import crossSpawn from 'cross-spawn';
8
8
  import {
9
+ callWebflowPanelTool,
10
+ findWebflowImages,
9
11
  getFramerProjectAuthorization,
12
+ webflowMcpEndpoint,
10
13
  readFramerWebsite,
11
14
  isCompanionPairingInvalidError,
12
15
  postRunEvent,
@@ -29,6 +32,7 @@ import {
29
32
  assertOutcomeResponseContract,
30
33
  buildFramerOutcomePrompt,
31
34
  buildOutcomePrompt,
35
+ buildWebflowOutcomePrompt,
32
36
  buildModelTurnDeltaPrompt,
33
37
  buildModelTurnFallbackPrompt,
34
38
  buildModelTurnPrompt,
@@ -65,6 +69,7 @@ import {
65
69
  createHarnessToolRuntime,
66
70
  } from './harnessTools.js';
67
71
  import { createFramerAgentToolRuntime } from './framerTools.js';
72
+ import { assertWebflowHarnessAgent, createWebflowToolRuntime } from './webflowTools.js';
68
73
 
69
74
  const HARNESS_MCP_SERVER_PATH = fileURLToPath(
70
75
  new URL('./harnessMcpServer.js', import.meta.url),
@@ -356,6 +361,18 @@ function writeClaudeSystemPromptFile(content) {
356
361
  return file;
357
362
  }
358
363
 
364
+ function claudeWebflowOutcomeSystemPrompt(product) {
365
+ const name = normalizeBridgeProduct(product).name;
366
+ return [
367
+ `You are an autonomous Webflow harness embedded inside ${name}.`,
368
+ 'Work only through the Dexter Webflow tools connected to the user-selected site.',
369
+ 'Inspect the site, perform the requested work, verify the result, and return the structured outcome.',
370
+ 'Do not access local credentials, unrelated files, other sites, account settings, or billing.',
371
+ 'Do not publish unless the assignment explicitly authorizes publishing.',
372
+ `Return only the structured outcome result requested by ${name}.`,
373
+ ].join(' ');
374
+ }
375
+
359
376
  function claudeOutcomeSystemPrompt(product) {
360
377
  const name = normalizeBridgeProduct(product).name;
361
378
  if (normalizeBridgeProduct(product).id === 'dexter') {
@@ -445,10 +462,11 @@ function argsWithClaudeIsolation(args, definition, options = {}) {
445
462
  const harnessTools = harnessToolNames.map(
446
463
  (name) => `mcp__instawebai__${name}`,
447
464
  );
448
- const webHosts = options.framerHarness
449
- ? framerWebHosts(options.framerHarness)
465
+ const designHarness = options.designHarness || options.framerHarness;
466
+ const webHosts = designHarness
467
+ ? framerWebHosts(designHarness)
450
468
  : [];
451
- const nativeTools = options.framerHarness
469
+ const nativeTools = designHarness
452
470
  ? [
453
471
  ...(options.nativeSkillsEnabled ? ['Skill'] : []),
454
472
  // Web access is limited to the sites the user named. WebSearch cannot
@@ -482,10 +500,10 @@ function argsWithClaudeIsolation(args, definition, options = {}) {
482
500
  const permissionMode = argsWithoutFlagValue(projectSettingsOnly, '--permission-mode');
483
501
  permissionMode.push(
484
502
  '--permission-mode',
485
- options.harnessMode && !options.framerHarness ? 'acceptEdits' : 'dontAsk',
503
+ options.harnessMode && !designHarness ? 'acceptEdits' : 'dontAsk',
486
504
  );
487
505
  if (options.harnessMode) {
488
- const allowedTools = options.framerHarness
506
+ const allowedTools = designHarness
489
507
  ? [
490
508
  ...(options.nativeSkillsEnabled ? ['Skill'] : []),
491
509
  ...webHosts.map((host) => `WebFetch(domain:${host})`),
@@ -505,8 +523,8 @@ function argsWithClaudeIsolation(args, definition, options = {}) {
505
523
  // Framer run with skills leaves it off. The explicit tool list above is what
506
524
  // keeps the shell and file tools out; it grants the Framer tools and Skill
507
525
  // only, and settings stay limited to the workspace the bridge created.
508
- const framerRunWithSkills = Boolean(options.framerHarness && options.nativeSkillsEnabled);
509
- if (options.harnessMode && !framerRunWithSkills && !argsIncludeFlag(configured, '--restricted')) {
526
+ const designRunWithSkills = Boolean(designHarness && options.nativeSkillsEnabled);
527
+ if (options.harnessMode && !designRunWithSkills && !argsIncludeFlag(configured, '--restricted')) {
510
528
  configured.push('--restricted');
511
529
  }
512
530
  return configured;
@@ -1188,6 +1206,15 @@ export function agentFailureMessage(command, code, stdout = '', stderr = '') {
1188
1206
  return agentFailureDetails(command, code, stdout, stderr).message;
1189
1207
  }
1190
1208
 
1209
+ // Agent CLIs run in their own process group, so a signal that stops the
1210
+ // bridge never reaches them; stopping the bridge aborts them explicitly.
1211
+ const bridgeShutdown = new AbortController();
1212
+
1213
+ export function stopAgentProcesses() {
1214
+ if (bridgeShutdown.signal.aborted) return;
1215
+ bridgeShutdown.abort(new CompanionRunCancelledError('The Dexter bridge stopped.', 'device_disconnect'));
1216
+ }
1217
+
1191
1218
  function runProcess(
1192
1219
  command,
1193
1220
  args,
@@ -1206,8 +1233,9 @@ function runProcess(
1206
1233
  } = {},
1207
1234
  ) {
1208
1235
  return new Promise((resolve, reject) => {
1209
- if (signal?.aborted) {
1210
- const error = signal.reason instanceof Error ? signal.reason : new Error('The model process was cancelled.');
1236
+ const stopped = [signal, bridgeShutdown.signal].find((candidate) => candidate?.aborted);
1237
+ if (stopped) {
1238
+ const error = stopped.reason instanceof Error ? stopped.reason : new Error('The model process was cancelled.');
1211
1239
  error.code = error.code || 'RUN_CANCELLED';
1212
1240
  reject(error);
1213
1241
  return;
@@ -1278,6 +1306,7 @@ function runProcess(
1278
1306
  clearTimeout(forceKillTimer);
1279
1307
  clearTimeout(terminationFallbackTimer);
1280
1308
  signal?.removeEventListener('abort', abort);
1309
+ bridgeShutdown.signal.removeEventListener('abort', abort);
1281
1310
  error.stdout = stdout;
1282
1311
  error.stderr = stderr;
1283
1312
  reject(error);
@@ -1294,10 +1323,11 @@ function runProcess(
1294
1323
  }, killGraceMs);
1295
1324
  forceKillTimer.unref?.();
1296
1325
  };
1297
- const abort = () => {
1326
+ const abort = (event) => {
1298
1327
  if (settled || terminationError) return;
1299
1328
  clearTimers();
1300
- const error = signal?.reason instanceof Error ? signal.reason : new Error('The model process was cancelled.');
1329
+ const reason = event?.target?.reason;
1330
+ const error = reason instanceof Error ? reason : new Error('The model process was cancelled.');
1301
1331
  error.code = error.code || 'RUN_CANCELLED';
1302
1332
  terminationError = error;
1303
1333
  terminateChildTree(error);
@@ -1342,6 +1372,7 @@ function runProcess(
1342
1372
  );
1343
1373
  }, hardDeadlineMs);
1344
1374
  signal?.addEventListener('abort', abort, { once: true });
1375
+ bridgeShutdown.signal.addEventListener('abort', abort, { once: true });
1345
1376
  armInactivityTimer();
1346
1377
 
1347
1378
  child.stdout.on('data', (chunk) => {
@@ -1392,6 +1423,7 @@ function runProcess(
1392
1423
  clearTimeout(forceKillTimer);
1393
1424
  clearTimeout(terminationFallbackTimer);
1394
1425
  signal?.removeEventListener('abort', abort);
1426
+ bridgeShutdown.signal.removeEventListener('abort', abort);
1395
1427
  stdoutProgress?.flush();
1396
1428
  const meta = {
1397
1429
  command,
@@ -1570,8 +1602,15 @@ class CompanionRunCancelledError extends Error {
1570
1602
  }
1571
1603
 
1572
1604
  function cancellationMessage(error) {
1605
+ const webflow = error?.webflow || error?.companionUsage?.webflow;
1606
+ if (webflow) {
1607
+ const mutationCalls = Number(webflow.mutationCalls) || 0;
1608
+ if (mutationCalls <= 0) return 'Stopped before Dexter completed a Webflow change.';
1609
+ return `Stopped after ${mutationCalls} successful Webflow change ${mutationCalls === 1 ? 'call' : 'calls'}. Those changes remain in Webflow.`;
1610
+ }
1573
1611
  const framer = error?.framer || error?.companionUsage?.framer;
1574
- const mutationCalls = Number(framer?.mutationCalls) || 0;
1612
+ if (!framer) return 'Dexter stopped this request.';
1613
+ const mutationCalls = Number(framer.mutationCalls) || 0;
1575
1614
  if (mutationCalls <= 0) {
1576
1615
  return 'Stopped before Dexter completed a Framer change.';
1577
1616
  }
@@ -1907,7 +1946,7 @@ async function callLocalJsonAgent(agent, prompt, options = {}) {
1907
1946
  onProgress: options.onProgress,
1908
1947
  stdoutProgress:
1909
1948
  definition.id === 'claude-code'
1910
- ? createClaudeProgressParser(options.onProgress, { cwd: options.cwd })
1949
+ ? createClaudeProgressParser(options.onProgress, { cwd: options.cwd, trace: options.trace })
1911
1950
  : undefined,
1912
1951
  });
1913
1952
  }
@@ -2241,7 +2280,7 @@ async function executeModelTurn(run, send, agent, options = {}) {
2241
2280
  });
2242
2281
  } catch (error) {
2243
2282
  if (cliCancelled || error?.code === 'RUN_CANCELLED') {
2244
- throw new CompanionRunCancelledError();
2283
+ throw new CompanionRunCancelledError(undefined, error?.cancellationSource);
2245
2284
  }
2246
2285
  const failure = agentErrorWithOutputUsage(definition.id, error);
2247
2286
  if (callContextMode !== 'delta' || !resumeFailure(failure)) throw failedModelCall(failure);
@@ -2273,7 +2312,7 @@ async function executeModelTurn(run, send, agent, options = {}) {
2273
2312
  onProgress: reportCliProgress,
2274
2313
  }).catch((error) => {
2275
2314
  if (cliCancelled || error?.code === 'RUN_CANCELLED') {
2276
- throw new CompanionRunCancelledError();
2315
+ throw new CompanionRunCancelledError(undefined, error?.cancellationSource);
2277
2316
  }
2278
2317
  throw failedModelCall(agentErrorWithOutputUsage(definition.id, error));
2279
2318
  });
@@ -2346,6 +2385,10 @@ async function executeModelTurn(run, send, agent, options = {}) {
2346
2385
  });
2347
2386
  }
2348
2387
 
2388
+ // Rounds in which a model that finished with unmet checks is sent back to fix
2389
+ // them, within the run's time limit.
2390
+ const OUTCOME_REPAIR_ROUNDS = 3;
2391
+
2349
2392
  async function executeOutcomeRun(run, send, agent, options = {}) {
2350
2393
  const product = normalizeBridgeProduct(run?.product || options.product);
2351
2394
  const definition = definitionForRuntimeProfile(run?.runtimeProfile, agent);
@@ -2383,10 +2426,13 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
2383
2426
  const framerAgentOutcome =
2384
2427
  run?.outcome?.kind === 'framer-project'
2385
2428
  || run?.outcome?.context?.executionMode === 'framer-mcp';
2429
+ const webflowOutcome = run?.outcome?.kind === 'webflow-site';
2430
+ if (webflowOutcome) assertWebflowHarnessAgent(definition.id);
2431
+ const designOutcome = framerAgentOutcome || webflowOutcome;
2386
2432
  // Every Dexter message starts a fresh provider session. The assignment
2387
2433
  // already carries the recent conversation, and resuming would make every
2388
2434
  // turn of this run re-read the whole previous run as well.
2389
- const sessionKey = framerAgentOutcome
2435
+ const sessionKey = designOutcome
2390
2436
  ? run?.runId || companionSessionKey(run)
2391
2437
  : companionSessionKey(run);
2392
2438
  const infrastructureAbortController = new AbortController();
@@ -2536,10 +2582,10 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
2536
2582
  Boolean(event.message || event.name) ||
2537
2583
  event.kind === 'turn_started';
2538
2584
  if (!meaningful) return;
2539
- // Framer runs narrate in plain text rather than through a progress tool
2585
+ // Design runs narrate in plain text rather than through a progress tool
2540
2586
  // (a tool call costs a whole turn), so that text is the user's progress.
2541
2587
  const narration =
2542
- framerAgentOutcome
2588
+ designOutcome
2543
2589
  && event.kind === 'output_delta'
2544
2590
  && String(event.activity || '').startsWith('assistant:');
2545
2591
  const userFacingUpdate =
@@ -2603,7 +2649,7 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
2603
2649
  }
2604
2650
  };
2605
2651
  const controlMonitor = monitorControl();
2606
- const rememberedSession = framerAgentOutcome
2652
+ const rememberedSession = designOutcome
2607
2653
  ? null
2608
2654
  : rememberedCompanionSession(sessionKey) ||
2609
2655
  (run?.outcome?.session?.providerSessionId
@@ -2704,6 +2750,61 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
2704
2750
  })
2705
2751
  : undefined,
2706
2752
  })
2753
+ : webflowOutcome
2754
+ ? createWebflowToolRuntime({
2755
+ assignment: executionOutcome,
2756
+ trace: options.trace,
2757
+ onActivity: progress,
2758
+ mcpEndpoint:
2759
+ options.apiBaseUrl && options.deviceToken
2760
+ ? webflowMcpEndpoint(options.apiBaseUrl, {
2761
+ deviceToken: options.deviceToken,
2762
+ runId: run.runId,
2763
+ allowInsecureHttp: options.allowInsecureHttp,
2764
+ })
2765
+ : undefined,
2766
+ callPanelTool:
2767
+ options.apiBaseUrl && options.deviceToken
2768
+ ? ({ callId, name, arguments: toolArguments, signal }) =>
2769
+ callWebflowPanelTool(options.apiBaseUrl, {
2770
+ deviceToken: options.deviceToken,
2771
+ runId: run.runId,
2772
+ callId,
2773
+ name,
2774
+ arguments: toolArguments,
2775
+ fetchImpl: options.fetchImpl,
2776
+ signal,
2777
+ allowInsecureHttp: options.allowInsecureHttp,
2778
+ })
2779
+ : undefined,
2780
+ findImages:
2781
+ options.apiBaseUrl && options.deviceToken
2782
+ ? ({ query, orientation, count, signal }) =>
2783
+ findWebflowImages(options.apiBaseUrl, {
2784
+ deviceToken: options.deviceToken,
2785
+ runId: run.runId,
2786
+ query,
2787
+ orientation,
2788
+ count,
2789
+ fetchImpl: options.fetchImpl,
2790
+ signal,
2791
+ allowInsecureHttp: options.allowInsecureHttp,
2792
+ })
2793
+ : undefined,
2794
+ readWebsite:
2795
+ options.apiBaseUrl && options.deviceToken
2796
+ ? ({ url, screenshotTop, signal }) =>
2797
+ readFramerWebsite(options.apiBaseUrl, {
2798
+ deviceToken: options.deviceToken,
2799
+ runId: run.runId,
2800
+ url,
2801
+ screenshotTop,
2802
+ fetchImpl: options.fetchImpl,
2803
+ signal,
2804
+ allowInsecureHttp: options.allowInsecureHttp,
2805
+ })
2806
+ : undefined,
2807
+ })
2707
2808
  : createHarnessToolRuntime({
2708
2809
  assignment: executionOutcome,
2709
2810
  materialized,
@@ -2725,10 +2826,10 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
2725
2826
  let providerAttempts = 0;
2726
2827
  let result;
2727
2828
  try {
2728
- const preflight = framerAgentOutcome
2829
+ const preflight = designOutcome
2729
2830
  ? await Promise.race([harnessTools.preflight(), cancellation])
2730
2831
  : null;
2731
- const framerGuidance = framerAgentOutcome
2832
+ const projectGuidance = designOutcome
2732
2833
  ? await Promise.race([
2733
2834
  harnessTools.loadProjectGuidance(),
2734
2835
  cancellation,
@@ -2737,8 +2838,9 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
2737
2838
  const promptContext = {
2738
2839
  doctrine: run?.doctrine,
2739
2840
  doctrineVersion: run?.doctrineVersion,
2740
- framerGuidance: framerGuidance?.content,
2741
- projectInventory: framerGuidance?.inventory,
2841
+ framerGuidance: framerAgentOutcome ? projectGuidance?.content : undefined,
2842
+ projectInventory: framerAgentOutcome ? projectGuidance?.inventory : undefined,
2843
+ webflowGuidance: webflowOutcome ? projectGuidance?.content : undefined,
2742
2844
  };
2743
2845
  // Claude Code gets the run-independent instructions as its system prompt,
2744
2846
  // which it caches across sessions: later runs and follow-up messages read
@@ -2746,16 +2848,14 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
2746
2848
  let systemPromptFile = null;
2747
2849
  let systemPromptBytes = 0;
2748
2850
  if (
2749
- framerAgentOutcome
2851
+ designOutcome
2750
2852
  && definition.id === 'claude-code'
2751
2853
  && !options.providerAdapter?.runOutcome
2752
2854
  ) {
2753
- const { instructions, task } = buildFramerOutcomePrompt(
2754
- executionOutcome,
2755
- product,
2756
- promptContext,
2757
- );
2758
- const systemPrompt = `${claudeOutcomeSystemPrompt(product)}\n\n${instructions}`;
2855
+ const { instructions, task } = webflowOutcome
2856
+ ? buildWebflowOutcomePrompt(executionOutcome, product, promptContext)
2857
+ : buildFramerOutcomePrompt(executionOutcome, product, promptContext);
2858
+ const systemPrompt = `${(webflowOutcome ? claudeWebflowOutcomeSystemPrompt : claudeOutcomeSystemPrompt)(product)}\n\n${instructions}`;
2759
2859
  systemPromptFile = writeClaudeSystemPromptFile(systemPrompt);
2760
2860
  systemPromptBytes = Buffer.byteLength(systemPrompt, 'utf8');
2761
2861
  prompt = task;
@@ -2837,7 +2937,8 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
2837
2937
  cwd: materialized.directory,
2838
2938
  nativeSkills,
2839
2939
  framerHarness: framerAgentOutcome ? executionOutcome : false,
2840
- webHosts: framerAgentOutcome ? framerWebHosts(executionOutcome) : [],
2940
+ designHarness: designOutcome ? executionOutcome : false,
2941
+ webHosts: designOutcome ? framerWebHosts(executionOutcome) : [],
2841
2942
  imageFiles,
2842
2943
  harnessTools: {
2843
2944
  definitions: codexDynamicToolSpecs(harnessTools.definitions),
@@ -2880,6 +2981,7 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
2880
2981
  harnessMode: true,
2881
2982
  systemPromptFile,
2882
2983
  framerHarness: framerAgentOutcome ? executionOutcome : false,
2984
+ designHarness: designOutcome ? executionOutcome : false,
2883
2985
  harnessToolNames: harnessTools.toolNames,
2884
2986
  nativeSkillsEnabled: Boolean(materializedNativeSkills),
2885
2987
  imageFiles,
@@ -2904,6 +3006,7 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
2904
3006
  harnessMode: true,
2905
3007
  systemPromptFile,
2906
3008
  framerHarness: framerAgentOutcome ? executionOutcome : false,
3009
+ designHarness: designOutcome ? executionOutcome : false,
2907
3010
  harnessToolNames: harnessTools.toolNames,
2908
3011
  nativeSkillsEnabled: Boolean(materializedNativeSkills),
2909
3012
  imageFiles,
@@ -2973,29 +3076,34 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
2973
3076
  ) {
2974
3077
  synchronization = await harnessTools.synchronize();
2975
3078
  }
2976
- if (typeof harnessTools.assessCompletion === 'function') {
2977
- await harnessTools.assessCompletion(rawCompletion);
2978
- }
2979
- const unappliedChangePrompt =
2980
- rawCompletion.status === successStatus
2981
- ? harnessTools.unappliedChangePrompt?.(rawCompletion) || null
2982
- : null;
2983
- const repairPrompt =
2984
- rawCompletion.status === successStatus
2985
- ? harnessTools.verificationRepairPrompt?.() || unappliedChangePrompt
2986
- : harnessTools.unblockedFailurePrompt?.(rawCompletion) || null;
2987
- if (
2988
- repairPrompt
2989
- && Date.now() - callStartedAt < outcomeMaxDurationMs - 5_000
2990
- ) {
3079
+ // The model is sent back until the checks pass or the rounds run out; each
3080
+ // round names exactly what is still wrong.
3081
+ let assessedCompletion = null;
3082
+ for (let round = 0; round < OUTCOME_REPAIR_ROUNDS; round += 1) {
3083
+ if (typeof harnessTools.assessCompletion === 'function') {
3084
+ await harnessTools.assessCompletion(rawCompletion);
3085
+ assessedCompletion = rawCompletion;
3086
+ }
3087
+ const unappliedChangePrompt =
3088
+ rawCompletion.status === successStatus
3089
+ ? harnessTools.unappliedChangePrompt?.(rawCompletion) || null
3090
+ : null;
3091
+ const repairPrompt =
3092
+ rawCompletion.status === successStatus
3093
+ ? harnessTools.verificationRepairPrompt?.() || unappliedChangePrompt
3094
+ : harnessTools.unblockedFailurePrompt?.(rawCompletion) || null;
3095
+ if (
3096
+ !repairPrompt
3097
+ || Date.now() - callStartedAt >= outcomeMaxDurationMs - 5_000
3098
+ ) break;
2991
3099
  progress({
2992
3100
  kind: 'status_update',
2993
3101
  source: 'harness',
2994
3102
  message:
2995
3103
  repairPrompt === unappliedChangePrompt
2996
- ? 'Nothing has changed in Framer yet, so I’m making the change before I finish.'
3104
+ ? `Nothing has changed in ${webflowOutcome ? 'Webflow' : 'Framer'} yet, so I’m making the change before I finish.`
2997
3105
  : rawCompletion.status === successStatus
2998
- ? 'I’m completing the final Framer verification before I finish.'
3106
+ ? 'I’m checking the result against your request before I finish.'
2999
3107
  : 'Nothing is blocking this change, so I’m continuing the work.',
3000
3108
  });
3001
3109
  const repairResult = await Promise.race([
@@ -3037,7 +3145,7 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
3037
3145
  updatedAt: Date.now(),
3038
3146
  });
3039
3147
  }
3040
- if (typeof harnessTools.assessCompletion === 'function') {
3148
+ if (typeof harnessTools.assessCompletion === 'function' && assessedCompletion !== rawCompletion) {
3041
3149
  await harnessTools.assessCompletion(rawCompletion);
3042
3150
  }
3043
3151
  const completion =
@@ -3055,7 +3163,7 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
3055
3163
  synchronization?.sourceHash || harnessTools.sourceHash() || null,
3056
3164
  ...(typeof harnessTools.summary === 'function'
3057
3165
  ? {
3058
- framer: {
3166
+ [webflowOutcome ? 'webflow' : 'framer']: {
3059
3167
  ...harnessTools.summary(),
3060
3168
  webCalls: webCallSummary(),
3061
3169
  },
@@ -3098,19 +3206,22 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
3098
3206
  const failure =
3099
3207
  error instanceof Error
3100
3208
  ? error
3101
- : new Error(String(error || 'The Framer outcome failed.'));
3209
+ : new Error(String(error || 'The Dexter run failed.'));
3102
3210
  usageAccumulator.add(failure.companionUsage || {});
3103
- const framer =
3211
+ const harnessSummary =
3104
3212
  typeof harnessTools.summary === 'function'
3105
3213
  ? {
3106
3214
  ...harnessTools.summary(),
3107
3215
  webCalls: webCallSummary(),
3108
3216
  }
3109
3217
  : undefined;
3110
- failure.framer = framer;
3218
+ if (webflowOutcome) failure.webflow = harnessSummary;
3219
+ else failure.framer = harnessSummary;
3111
3220
  failure.companionUsage = {
3112
3221
  ...usageAccumulator.snapshot(),
3113
- ...(framer ? { framer } : {}),
3222
+ ...(harnessSummary
3223
+ ? { [webflowOutcome ? 'webflow' : 'framer']: harnessSummary }
3224
+ : {}),
3114
3225
  };
3115
3226
  throw failure;
3116
3227
  } finally {
@@ -3118,7 +3229,7 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
3118
3229
  controlMonitorAbort.abort();
3119
3230
  await controlMonitor.catch(() => undefined);
3120
3231
  await harnessTools
3121
- .close({ waitForPending: !cancelRequested })
3232
+ .close({ waitForPending: !cancelRequested && !bridgeShutdown.signal.aborted })
3122
3233
  .catch(() => undefined);
3123
3234
  removeOutcomeReferenceAssets(materialized);
3124
3235
  removeOutcomeWorkspace(materialized);
@@ -521,6 +521,12 @@ export function createClaudeProgressParser(onProgress, options = {}) {
521
521
  if (!trimmed) return;
522
522
  try {
523
523
  const row = JSON.parse(trimmed);
524
+ if (row?.type === 'system' && row.subtype !== 'init') {
525
+ options.trace?.warn?.('claude_system_event', { event: JSON.stringify(row).slice(0, 1_000) });
526
+ }
527
+ if (row?.type === 'rate_limit_event' && row.rate_limit_info?.status !== 'allowed') {
528
+ options.trace?.warn?.('claude_rate_limit', { rateLimit: row.rate_limit_info });
529
+ }
524
530
  if (row?.type === 'stream_event') processStreamEvent(row);
525
531
  if (row?.type === 'assistant') finishDrafting(row);
526
532
  if (row?.type === 'assistant') processAssistant(row);
package/src/api.js CHANGED
@@ -184,6 +184,7 @@ export function pollRun(apiBaseUrl, {
184
184
  model,
185
185
  metadata,
186
186
  runtimeProfiles,
187
+ refreshingModelCatalog = false,
187
188
  fetchImpl,
188
189
  allowInsecureHttp,
189
190
  }) {
@@ -202,6 +203,7 @@ export function pollRun(apiBaseUrl, {
202
203
  status: 'polling',
203
204
  metadata: bridgeMetadata(normalizedAgent, normalizedModel, metadata),
204
205
  runtimeProfiles,
206
+ refreshingModelCatalog,
205
207
  },
206
208
  });
207
209
  }
@@ -265,6 +267,79 @@ export function readFramerWebsite(apiBaseUrl, {
265
267
  );
266
268
  }
267
269
 
270
+ // The run's Webflow MCP endpoint on Dexter's API. The Webflow token never leaves the API.
271
+ export function webflowMcpEndpoint(apiBaseUrl, { deviceToken, runId, allowInsecureHttp }) {
272
+ return {
273
+ url: `${normalizeApiBaseUrl(apiBaseUrl, { allowInsecureHttp })}/ai/framer/companion/runs/${encodeURIComponent(runId)}/webflow-mcp`,
274
+ headers: {
275
+ ...bearerHeaders(deviceToken),
276
+ 'X-Dexter-Companion-Version': BRIDGE_VERSION,
277
+ 'X-Dexter-Platform': process.platform,
278
+ },
279
+ };
280
+ }
281
+
282
+ export function callWebflowPanelTool(apiBaseUrl, {
283
+ deviceToken,
284
+ runId,
285
+ callId,
286
+ name,
287
+ arguments: toolArguments,
288
+ fetchImpl,
289
+ signal,
290
+ allowInsecureHttp,
291
+ }) {
292
+ return requestJson(
293
+ apiBaseUrl,
294
+ `/ai/framer/companion/runs/${encodeURIComponent(runId)}/panel-tools`,
295
+ {
296
+ method: 'POST',
297
+ deviceToken,
298
+ fetchImpl,
299
+ signal,
300
+ allowInsecureHttp,
301
+ timeoutMs: 45_000,
302
+ body: {
303
+ callId,
304
+ name,
305
+ arguments: toolArguments || {},
306
+ platform: process.platform,
307
+ companionVersion: BRIDGE_VERSION,
308
+ },
309
+ },
310
+ );
311
+ }
312
+
313
+ export function findWebflowImages(apiBaseUrl, {
314
+ deviceToken,
315
+ runId,
316
+ query,
317
+ orientation,
318
+ count,
319
+ fetchImpl,
320
+ signal,
321
+ allowInsecureHttp,
322
+ }) {
323
+ return requestJson(
324
+ apiBaseUrl,
325
+ `/ai/framer/companion/runs/${encodeURIComponent(runId)}/find-images`,
326
+ {
327
+ method: 'POST',
328
+ deviceToken,
329
+ fetchImpl,
330
+ signal,
331
+ allowInsecureHttp,
332
+ body: {
333
+ query,
334
+ ...(orientation ? { orientation } : {}),
335
+ ...(count ? { count } : {}),
336
+ platform: process.platform,
337
+ companionVersion: BRIDGE_VERSION,
338
+ },
339
+ },
340
+ );
341
+ }
342
+
268
343
  export function postRunEvent(apiBaseUrl, {
269
344
  deviceToken,
270
345
  runId,