@gakim-digital/dexter-bridge 0.13.7 → 0.13.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gakim-digital/dexter-bridge",
3
- "version": "0.13.7",
3
+ "version": "0.13.8",
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
@@ -1233,12 +1233,15 @@ function runProcess(
1233
1233
  stdoutProgress,
1234
1234
  } = {},
1235
1235
  ) {
1236
+ // An abort without a reason carries a DOMException, whose code is read-only.
1237
+ const cancellationError = (reason) =>
1238
+ reason instanceof Error && !(reason instanceof DOMException)
1239
+ ? Object.assign(reason, { code: reason.code || 'RUN_CANCELLED' })
1240
+ : Object.assign(new Error('The model process was cancelled.'), { code: 'RUN_CANCELLED' });
1236
1241
  return new Promise((resolve, reject) => {
1237
1242
  const stopped = [signal, bridgeShutdown.signal].find((candidate) => candidate?.aborted);
1238
1243
  if (stopped) {
1239
- const error = stopped.reason instanceof Error ? stopped.reason : new Error('The model process was cancelled.');
1240
- error.code = error.code || 'RUN_CANCELLED';
1241
- reject(error);
1244
+ reject(cancellationError(stopped.reason));
1242
1245
  return;
1243
1246
  }
1244
1247
  const started = Date.now();
@@ -1327,9 +1330,7 @@ function runProcess(
1327
1330
  const abort = (event) => {
1328
1331
  if (settled || terminationError) return;
1329
1332
  clearTimers();
1330
- const reason = event?.target?.reason;
1331
- const error = reason instanceof Error ? reason : new Error('The model process was cancelled.');
1332
- error.code = error.code || 'RUN_CANCELLED';
1333
+ const error = cancellationError(event?.target?.reason);
1333
1334
  terminationError = error;
1334
1335
  terminateChildTree(error);
1335
1336
  trace?.info('agent_process_cancelled', {
@@ -2520,6 +2521,8 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
2520
2521
  rejectCancellation(cancellationError());
2521
2522
  return cancelAction;
2522
2523
  };
2524
+ // Framer programs can run for minutes; Stop does not wait for them.
2525
+ const unlessCancelled = (work) => Promise.race([work, cancellation]);
2523
2526
  const postActivity = (payload) => {
2524
2527
  activityChain = activityChain
2525
2528
  .then(async () => {
@@ -2633,12 +2636,15 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
2633
2636
  occurredAt: new Date().toISOString(),
2634
2637
  message: `${definition.label} is still working.`,
2635
2638
  });
2639
+ // The run ends on the cancellation itself; a provider that is slow to
2640
+ // stop must not hold up the cancelled event.
2636
2641
  if (controlRequestsCancellation(response)) {
2637
- await requestCancel(controlCancellationSource(response));
2642
+ void requestCancel(controlCancellationSource(response));
2643
+ return;
2638
2644
  }
2639
2645
  } catch (error) {
2640
2646
  if (isCompanionPairingInvalidError(error)) {
2641
- await requestCancel('device_disconnect');
2647
+ void requestCancel('device_disconnect');
2642
2648
  return;
2643
2649
  }
2644
2650
  if (!controlMonitorAbort.signal.aborted && !cancelRequested) {
@@ -3132,7 +3138,7 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
3132
3138
  let previousRepairPrompt = null;
3133
3139
  for (let round = 0; round < OUTCOME_REPAIR_ROUNDS; round += 1) {
3134
3140
  if (typeof harnessTools.assessCompletion === 'function') {
3135
- await harnessTools.assessCompletion(rawCompletion);
3141
+ await unlessCancelled(harnessTools.assessCompletion(rawCompletion));
3136
3142
  assessedCompletion = rawCompletion;
3137
3143
  }
3138
3144
  const unappliedChangePrompt =
@@ -3205,7 +3211,7 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
3205
3211
  });
3206
3212
  }
3207
3213
  if (typeof harnessTools.assessCompletion === 'function' && assessedCompletion !== rawCompletion) {
3208
- await harnessTools.assessCompletion(rawCompletion);
3214
+ await unlessCancelled(harnessTools.assessCompletion(rawCompletion));
3209
3215
  }
3210
3216
  const completion =
3211
3217
  typeof harnessTools.finalizeCompletion === 'function'
@@ -546,12 +546,37 @@ const __dexterTreeRecorders = {
546
546
  makeExternalComponentLocal: (args, result) => __dexterNote(args[0], result),
547
547
  };
548
548
  const __dexterTreeTarget = { insert: 1 };
549
+ // CMS collections, items, and fields, and pages, are deleted only with the
550
+ // user's permission for this run. The target is read before the call runs.
551
+ const __dexterRefused = [];
552
+ const __dexterContentTypes = new Set(['CollectionNode', 'CollectionItemNode', 'WebPageNode', 'DesignPageNode']);
553
+ const __dexterRefuse = (target, content) => {
554
+ __dexterRefused.push({ target, content });
555
+ throw new Error('Deleting ' + content + ' needs the user\\'s permission. Nothing was deleted.');
556
+ };
557
+ const __dexterTreeGuards = __dexterDeletionAllowed ? {} : {
558
+ remove: (tree, args) => {
559
+ const id = __dexterIdOf(args[0]);
560
+ const node = id ? tree.get(id) : null;
561
+ if (node && __dexterContentTypes.has(node.type)) __dexterRefuse(id, node.type + ' "' + (node.name || id) + '"');
562
+ },
563
+ patch: (tree, args) => {
564
+ const id = __dexterIdOf(args[0]);
565
+ const next = args[1] && args[1].variables;
566
+ if (!id || !Array.isArray(next)) return;
567
+ const node = tree.get(id);
568
+ if (!node || node.type !== 'CollectionNode' || !Array.isArray(node.variables)) return;
569
+ const kept = new Set(next.map((variable) => variable && variable.id));
570
+ const dropped = node.variables.filter((variable) => variable && variable.id && !kept.has(variable.id));
571
+ if (dropped.length) __dexterRefuse(id, 'the fields ' + dropped.map((variable) => '"' + (variable.name || variable.id) + '"').join(', ') + ' of collection "' + (node.name || id) + '"');
572
+ },
573
+ };
549
574
  const __dexterFileRecorders = Object.fromEntries(['create', 'write', 'applyPatch', 'delete'].map((op) => [op, (args, result) => {
550
575
  __dexterFiles.push({ op, path: typeof args[0] === 'string' ? args[0] : '', diagnostics: result && Array.isArray(result.diagnostics) ? result.diagnostics : [] });
551
576
  }]));
552
577
  // The proxy sits on an empty stand-in that forwards to the real object, so
553
578
  // methods can be wrapped even when Framer freezes its API objects.
554
- const __dexterRecording = (object, recorders, children = {}, compared = new Set(), targets = {}) => new Proxy({}, {
579
+ const __dexterRecording = (object, recorders, children = {}, compared = new Set(), targets = {}, guards = {}) => new Proxy({}, {
555
580
  has: (_, key) => Reflect.has(object, key),
556
581
  ownKeys: () => Reflect.ownKeys(object),
557
582
  getOwnPropertyDescriptor: (_, key) => {
@@ -572,6 +597,7 @@ const __dexterRecording = (object, recorders, children = {}, compared = new Set(
572
597
  }
573
598
  throw error;
574
599
  };
600
+ if (guards[key]) guards[key](object, args);
575
601
  const layer = checked ? __dexterLayerOf(args[0]) : null;
576
602
  const before = checked ? __dexterStateOf(object, layer) : null;
577
603
  const settle = (result) => {
@@ -591,7 +617,7 @@ const __dexterRecording = (object, recorders, children = {}, compared = new Set(
591
617
  },
592
618
  });
593
619
  const __dexterApi = __dexterRecording(api, {}, {
594
- tree: (tree) => __dexterRecording(tree, __dexterTreeRecorders, {}, __dexterCompared, __dexterTreeTarget),
620
+ tree: (tree) => __dexterRecording(tree, __dexterTreeRecorders, {}, __dexterCompared, __dexterTreeTarget, __dexterTreeGuards),
595
621
  files: (files) => __dexterRecording(files, __dexterFileRecorders),
596
622
  });
597
623
  `;
@@ -714,12 +740,13 @@ export function wrappedReadLineOffset(program) {
714
740
  return readParts(program).prefix.split('\n').length - 1;
715
741
  }
716
742
 
717
- function wrapperParts(program) {
743
+ function wrapperParts(program, { allowContentDeletion = false } = {}) {
718
744
  const { step, body } = splitStep(program);
719
745
  // A program that throws still keeps the changes it made before the throw,
720
746
  // so the error is caught here and returned with the record of those changes.
721
747
  const prefix = `${step}
722
748
  ${MEASURE_PRELUDE}
749
+ const __dexterDeletionAllowed = ${allowContentDeletion === true};
723
750
  ${RECORDING_PRELUDE}
724
751
  let __dexterResult;
725
752
  let __dexterError = null;
@@ -731,13 +758,13 @@ try {
731
758
  } catch (error) {
732
759
  __dexterError = { name: String((error && error.name) || 'Error'), message: String((error && error.message) || error) };
733
760
  }
734
- return { result: __dexterResult, dexterError: __dexterError, dexterFiles: __dexterFiles, dexterTouched: __dexterTouched, dexterFailed: __dexterFailed, dexterUnchanged: __dexterUnchanged };
761
+ return { result: __dexterResult, dexterError: __dexterError, dexterFiles: __dexterFiles, dexterTouched: __dexterTouched, dexterFailed: __dexterFailed, dexterUnchanged: __dexterUnchanged, dexterRefused: __dexterRefused };
735
762
  `;
736
763
  return { prefix, body, suffix };
737
764
  }
738
765
 
739
- export function wrapProgram(program) {
740
- const { prefix, body, suffix } = wrapperParts(program);
766
+ export function wrapProgram(program, options) {
767
+ const { prefix, body, suffix } = wrapperParts(program, options);
741
768
  return `${prefix}${body}${suffix}`;
742
769
  }
743
770
 
@@ -938,6 +965,7 @@ export function createFramerAgentToolRuntime({
938
965
  const projectUrl = String(context.framerProjectUrl || '').trim();
939
966
  const activePageId = String(context.activePageId || '').trim();
940
967
  const allowPublishing = context.permissions?.publish === true;
968
+ const allowContentDeletion = context.permissions?.deleteContent === true;
941
969
  const allowedTools = Array.isArray(assignment?.toolProtocol?.tools)
942
970
  ? new Set(assignment.toolProtocol.tools)
943
971
  : null;
@@ -1372,9 +1400,20 @@ export function createFramerAgentToolRuntime({
1372
1400
  callExtra.helpers = helpersUsed(program);
1373
1401
  await ensureProject();
1374
1402
  await snapshotUnchanged();
1375
- const result = await client.exec(wrapProgram(program));
1403
+ const result = await client.exec(wrapProgram(program, { allowContentDeletion }));
1376
1404
  const applied = applyReceipt(result, screenshotNodeIds);
1377
1405
  requirements = renameRequirementNodes(requirements, applied.receipt.renamedIds);
1406
+ const refused = Array.isArray(applied.output?.result?.dexterRefused) ? applied.output.result.dexterRefused : [];
1407
+ if (refused.length) {
1408
+ throw toolError(
1409
+ 'FRAMER_AGENT_DELETE_NOT_AUTHORIZED',
1410
+ [
1411
+ `Deleting ${refused.map((entry) => entry.content).join('; ')} needs the user's clear permission in this conversation, which they have not given, so nothing was deleted.`,
1412
+ applied.changed ? 'The other changes this program made before that point were kept.' : '',
1413
+ 'If deleting it is still the right step, ask the user one plain question naming exactly what you would delete, and return blocked. When they agree, Dexter gives you that permission in the next run.',
1414
+ ].filter(Boolean).join(' '),
1415
+ );
1416
+ }
1378
1417
  const programError = programErrorOf(applied.output, wrappedLineOffset(program));
1379
1418
  // Layers created by this program only get their real ids once it
1380
1419
  // finishes, so screenshots are taken in a second call.
package/src/protocol.js CHANGED
@@ -511,12 +511,14 @@ export function buildFramerOutcomePrompt(outcome = {}, product, promptContext =
511
511
  'A greeting, thanks, or question that needs no project change is not blocked: answer it in summary without reading or editing the project and return completed.',
512
512
  'Set projectChanged to true only when this run applied a change to the Framer project, and list the ids of the layers you changed in changedNodeIds. The summary must describe only changes that were applied. If a requirement is still not met, or you could not find or change something, name it plainly in the summary; never describe it as done.',
513
513
  'Some requests need something beyond the layers you may change, such as photos on other pages, a component shared across the site, or site-wide styles. If the user has not decided about it yet, ask one plain question that names it and what changing it affects, and return blocked; when they agree, Dexter gives you that reach in the next run. If they already said no, or told you what to do in that case, do not ask again: do what you can within what you may change, and explain plainly what is in the way, such as which shared setting causes it. Never mention access, scope, permissions, grants, or authorization.',
514
+ 'Deleting CMS collections, items, or fields, or pages, needs the user\'s clear permission in this conversation; context.permissions.deleteContent is true when they gave it. A request to change, fix, or replace content is not permission to delete it. Without it, ask one plain question naming exactly what you would delete and return blocked, before building any replacement for it.',
514
515
  'If you changed layers before asking a question, say in your summary what you changed.',
515
516
  'When you return blocked, write the question itself in summary, since summary is what the user reads; blockedReason is a short note for Dexter.',
517
+ 'summary is the only text the user reads; checks are for Dexter and are never shown to them. Write summary in Markdown. After an edit, a short, plain answer about what changed is enough. When the user asks a question or asks for a report, audit, review, list, or plan, summary is the full answer, formatted with headings, lists, or tables where they help: never shorten it to a line or put it in checks.',
516
518
  'Write in the language the user wrote their request in: the summary, any question, and the sentences Dexter shows while you work. Keep layer names and quoted text as they are.',
517
519
  REPAIR_SUMMARY_RULE,
518
- 'When finished, return exactly one JSON object and no markdown.',
519
- '{"status":"completed|blocked|failed","summary":"one or two short, non-technical sentences for the designer","checks":[{"command":"what you verified","status":"passed|failed|skipped","output":"short result"}],"blockedReason":null,"projectChanged":false,"changedNodeIds":[]}',
520
+ 'When finished, return exactly one JSON object with nothing around it. Markdown belongs only inside the summary string.',
521
+ '{"status":"completed|blocked|failed","summary":"your answer to the designer, in Markdown","checks":[{"command":"what you verified","status":"passed|failed|skipped","output":"short result"}],"blockedReason":null,"projectChanged":false,"changedNodeIds":[]}',
520
522
  doctrine
521
523
  ? `\nDexter design and execution doctrine (${String(promptContext.doctrineVersion || 'current')}):\n${doctrine}`
522
524
  : '',
@@ -581,8 +583,9 @@ export function buildWebflowOutcomePrompt(outcome = {}, product, promptContext =
581
583
  'The summary must describe only changes that were applied. If something the user asked for is not done, name it plainly; never describe it as done.',
582
584
  'Some requests need something beyond the elements you may change, such as content on other pages or a component used across the site. If the user has not decided about it yet, ask one plain question that names it and what changing it affects, and return blocked. If they already said no, or told you what to do in that case, do not ask again: do what you can within what you may change, and explain plainly what is in the way. If you changed elements before asking, say in your summary what you changed. Never mention access, scope, permissions, grants, or authorization.',
583
585
  'Write in the language the user wrote their request in: the summary, any question, and the sentences Dexter shows while you work. Keep element names and quoted text as they are.',
584
- 'When finished, return exactly one JSON object and no markdown.',
585
- '{"status":"completed|blocked|failed","summary":"one or two short, non-technical sentences for the designer","checks":[{"command":"what you verified","status":"passed|failed|skipped","output":"short result"}],"blockedReason":null}',
586
+ 'summary is the only text the user reads; checks are for Dexter and are never shown to them. Write summary in Markdown. After an edit, a short, plain answer about what changed is enough. When the user asks a question or asks for a report, audit, review, list, or plan, summary is the full answer, formatted with headings, lists, or tables where they help: never shorten it to a line or put it in checks.',
587
+ 'When finished, return exactly one JSON object with nothing around it. Markdown belongs only inside the summary string.',
588
+ '{"status":"completed|blocked|failed","summary":"your answer to the designer, in Markdown","checks":[{"command":"what you verified","status":"passed|failed|skipped","output":"short result"}],"blockedReason":null}',
586
589
  doctrine
587
590
  ? `\nDexter design and execution doctrine (${String(promptContext.doctrineVersion || 'current')}):\n${doctrine}`
588
591
  : '',
@@ -759,7 +762,7 @@ export function normalizeOutcomeCompletion(
759
762
  status,
760
763
  summary:
761
764
  typeof raw.summary === 'string' && raw.summary.trim()
762
- ? raw.summary.trim().slice(0, 10_000)
765
+ ? raw.summary.trim().slice(0, 40_000)
763
766
  : 'The harness did not provide a summary.',
764
767
  checks,
765
768
  blockedReason: