@gakim-digital/dexter-bridge 0.11.12 → 0.11.14

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.11.12",
3
+ "version": "0.11.14",
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": {
@@ -39,7 +39,7 @@
39
39
  "@ai-sdk/openai-compatible": "^3.0.41",
40
40
  "@ai-sdk/xai": "^4.0.50",
41
41
  "@anthropic-ai/claude-agent-sdk": "^0.2.73",
42
- "@framer/agent": "0.0.44",
42
+ "@framer/agent": "0.0.46",
43
43
  "@modelcontextprotocol/sdk": "^1.30.0",
44
44
  "@opencode-ai/sdk": "^1.18.25",
45
45
  "@openrouter/ai-sdk-provider": "^3.0.0",
package/src/agent.js CHANGED
@@ -51,6 +51,7 @@ import { createLocalAgentAdapter } from './providers/index.js';
51
51
  import { discoverClaudeCodeModels } from './providers/claudeAgentSdk.js';
52
52
  import {
53
53
  collectOutcomeWorkspaceChanges,
54
+ materializeModelTurnImages,
54
55
  materializeOutcomeReferenceAssets,
55
56
  materializeOutcomeWorkspace,
56
57
  removeOutcomeReferenceAssets,
@@ -405,7 +406,7 @@ function argsWithClaudeIsolation(args, definition, options = {}) {
405
406
  (name) => `mcp__instawebai__${name}`,
406
407
  );
407
408
  const nativeTools = options.framerHarness
408
- ? harnessTools
409
+ ? [...(options.nativeSkillsEnabled ? ['Skill'] : []), ...harnessTools]
409
410
  : [
410
411
  'Read',
411
412
  'Write',
@@ -442,7 +443,12 @@ function argsWithClaudeIsolation(args, definition, options = {}) {
442
443
  configured = argsWithoutFlagValue(configured, '--mcp-config');
443
444
  configured.push('--mcp-config', JSON.stringify(options.mcpConfig));
444
445
  }
445
- if (options.harnessMode && !argsIncludeFlag(configured, '--restricted')) {
446
+ // Restricted mode ignores the skills written into the run workspace, so a
447
+ // Framer run with skills leaves it off. The explicit tool list above is what
448
+ // keeps the shell and file tools out; it grants the Framer tools and Skill
449
+ // only, and settings stay limited to the workspace the bridge created.
450
+ const framerRunWithSkills = Boolean(options.framerHarness && options.nativeSkillsEnabled);
451
+ if (options.harnessMode && !framerRunWithSkills && !argsIncludeFlag(configured, '--restricted')) {
446
452
  configured.push('--restricted');
447
453
  }
448
454
  return configured;
@@ -1824,6 +1830,15 @@ function agentErrorWithOutputUsage(agent, error) {
1824
1830
  }
1825
1831
 
1826
1832
  async function executeModelTurnRun(run, send, agent, options = {}) {
1833
+ const images = materializeModelTurnImages(run?.modelTurn?.messages);
1834
+ try {
1835
+ return await executeModelTurn(run, send, agent, { ...options, imageFiles: images.files });
1836
+ } finally {
1837
+ images.remove();
1838
+ }
1839
+ }
1840
+
1841
+ async function executeModelTurn(run, send, agent, options = {}) {
1827
1842
  const product = normalizeBridgeProduct(run?.product || options.product);
1828
1843
  const productName = product.name;
1829
1844
  if (normalizeAgentName(agent) === 'dry-run' && !options.providerAdapter) {
@@ -1949,6 +1964,7 @@ async function executeModelTurnRun(run, send, agent, options = {}) {
1949
1964
  1000,
1950
1965
  ),
1951
1966
  nativeSkills,
1967
+ imageFiles: options.imageFiles,
1952
1968
  };
1953
1969
  const adapterOptions = {
1954
1970
  send,
@@ -2345,6 +2361,26 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
2345
2361
  });
2346
2362
  return;
2347
2363
  }
2364
+ if (event.kind === 'skill_used') {
2365
+ // The harness loaded a skill on its own. Dexter shows which one; the
2366
+ // aggregate of picked skills is already known to it.
2367
+ const skill = (materializedNativeSkills?.skills || []).find(
2368
+ (candidate) => candidate.nativeName === event.skillName,
2369
+ );
2370
+ if (!skill) return;
2371
+ void postActivity({
2372
+ stage: 'outcome',
2373
+ outcomeId: run?.outcome?.outcomeId,
2374
+ source: 'harness',
2375
+ meaningful: true,
2376
+ kind: 'skill_used',
2377
+ provider: definition.id,
2378
+ skillId: skill.skillId,
2379
+ occurredAt: event.occurredAt || new Date().toISOString(),
2380
+ message: `Using ${skill.name}`,
2381
+ });
2382
+ return;
2383
+ }
2348
2384
  const meaningful =
2349
2385
  Boolean(event.message || event.name) ||
2350
2386
  event.kind === 'turn_started';
@@ -397,6 +397,11 @@ export function createClaudeProgressParser(onProgress, options = {}) {
397
397
  });
398
398
  continue;
399
399
  }
400
+ if (part.name === 'Skill' && progressText(input.skill)) {
401
+ tools.set(part.id, { suppressResult: true });
402
+ emit({ kind: 'skill_used', skillName: progressText(input.skill) });
403
+ continue;
404
+ }
400
405
  const progress = claudeToolProgress(part.name, input, options.cwd);
401
406
  tools.set(part.id, { name: part.name, ...progress });
402
407
  emit({
@@ -429,11 +434,45 @@ export function createClaudeProgressParser(onProgress, options = {}) {
429
434
  }
430
435
  };
431
436
 
437
+ // A large edit can take the model a minute to write, and nothing else is
438
+ // reported until it has finished writing. Report the moment it starts, and
439
+ // close that report when the finished tool call arrives.
440
+ const drafting = new Map();
441
+ const DRAFTING_MESSAGE = 'Preparing changes for the Framer project';
442
+ const processStreamEvent = (row) => {
443
+ const event = row.event;
444
+ const block = event?.type === 'content_block_start' ? event.content_block : null;
445
+ if (block?.type !== 'tool_use' || !block.id || drafting.has(block.id)) return;
446
+ const shortName = String(block.name || '').replace(/^mcp__instawebai__/, '');
447
+ if (!['framer_apply_changes', 'framer_update_code_file', 'framer_write'].includes(shortName)) return;
448
+ drafting.set(block.id, true);
449
+ emit({
450
+ kind: 'item_started',
451
+ phase: 'started',
452
+ activity: `tool-draft:${block.id}`,
453
+ message: DRAFTING_MESSAGE,
454
+ });
455
+ };
456
+ const finishDrafting = (row) => {
457
+ for (const part of Array.isArray(row.message?.content) ? row.message.content : []) {
458
+ if (part?.type !== 'tool_use' || drafting.get(part.id) !== true) continue;
459
+ drafting.set(part.id, false);
460
+ emit({
461
+ kind: 'item_completed',
462
+ phase: 'completed',
463
+ activity: `tool-draft:${part.id}`,
464
+ message: DRAFTING_MESSAGE,
465
+ });
466
+ }
467
+ };
468
+
432
469
  const processLine = (line) => {
433
470
  const trimmed = line.trim();
434
471
  if (!trimmed) return;
435
472
  try {
436
473
  const row = JSON.parse(trimmed);
474
+ if (row?.type === 'stream_event') processStreamEvent(row);
475
+ if (row?.type === 'assistant') finishDrafting(row);
437
476
  if (row?.type === 'assistant') processAssistant(row);
438
477
  if (row?.type === 'user') processUser(row);
439
478
  } catch {
package/src/cli.js CHANGED
@@ -232,13 +232,63 @@ function isInvalidPairingError(error) {
232
232
  return isCompanionPairingInvalidError(error);
233
233
  }
234
234
 
235
- function clearInvalidPairing(configDir, cause) {
236
- const product = normalizeBridgeProduct(readConfig(configDir).product);
237
- saveConfigPatch({
238
- deviceToken: null,
239
- device: null,
240
- pairedAt: null,
241
- }, configDir);
235
+ const HOMEBREW_FORMULA = 'gakim-digital/tap/dexter-bridge';
236
+ const SELF_UPDATE_INTERVAL_MS = 60 * 60 * 1000;
237
+ const PAIRING_WAIT_POLL_MS = 5000;
238
+
239
+ // The Homebrew background service must recover on its own: the user never sees
240
+ // its terminal, so exiting would leave Dexter silently offline.
241
+ function runsAsBackgroundService() {
242
+ return process.env.DEXTER_BRIDGE_INSTALL_METHOD === 'homebrew' && !process.stdin.isTTY;
243
+ }
244
+
245
+ function isUpdateRequiredError(error) {
246
+ return error?.status === 426 || error?.body?.code === 'FRAMER_COMPANION_UPDATE_REQUIRED';
247
+ }
248
+
249
+ function runCommand(command, args) {
250
+ return new Promise((resolve) => {
251
+ const child = spawn(command, args, { stdio: 'inherit' });
252
+ child.on('error', () => resolve(false));
253
+ child.on('exit', (code) => resolve(code === 0));
254
+ });
255
+ }
256
+
257
+ // Upgrades the Homebrew install at most once an hour. On success the process
258
+ // exits non-zero so launchd restarts the service on the new version.
259
+ async function selfUpdateWithHomebrew(configDir, logger) {
260
+ const lastAttempt = Date.parse(readConfig(configDir).lastSelfUpdateAt || '');
261
+ if (Number.isFinite(lastAttempt) && Date.now() - lastAttempt < SELF_UPDATE_INTERVAL_MS) return;
262
+ saveConfigPatch({ lastSelfUpdateAt: new Date().toISOString() }, configDir);
263
+ console.log('A newer Dexter Bridge is required. Updating with Homebrew...');
264
+ const updated = await runCommand('brew', ['upgrade', HOMEBREW_FORMULA]);
265
+ logger.info('self_update_finished', { updated });
266
+ if (!updated) return;
267
+ const restart = new Error('Dexter Bridge updated. Restarting on the new version.');
268
+ restart.exitCode = 75;
269
+ throw restart;
270
+ }
271
+
272
+ async function waitForPairing(configDir) {
273
+ console.log('Waiting for a new pairing from Dexter...');
274
+ while (true) {
275
+ const token = readConfig(configDir).deviceToken;
276
+ if (token) return token;
277
+ await wait(PAIRING_WAIT_POLL_MS);
278
+ }
279
+ }
280
+
281
+ function clearInvalidPairing(configDir, cause, rejectedToken) {
282
+ const current = readConfig(configDir);
283
+ const product = normalizeBridgeProduct(current.product);
284
+ // Another bridge command may have just saved a fresh pairing; keep it.
285
+ if (!rejectedToken || current.deviceToken === rejectedToken) {
286
+ saveConfigPatch({
287
+ deviceToken: null,
288
+ device: null,
289
+ pairedAt: null,
290
+ }, configDir);
291
+ }
242
292
  const error = new Error(
243
293
  `This ${product.name} Bridge pairing was disconnected. Reopen ${product.name} and run the new pairing command.`,
244
294
  { cause },
@@ -534,14 +584,19 @@ async function startCommand({ apiBaseUrl, config, flags, configDir }) {
534
584
  let deviceToken = config.deviceToken;
535
585
  if (!deviceToken) {
536
586
  // First run: pair interactively so `npx @gakim-digital/dexter-bridge` alone is
537
- if (!process.stdin.isTTY) requireDeviceToken(config);
538
- console.log('This local agent bridge is not paired yet.');
539
- console.log('Open the product connection screen and generate a new bridge command.');
540
- const code = await promptForPairingCode();
541
- if (!code) requireDeviceToken(config);
542
- await pairCommand({ apiBaseUrl, args: [code], flags, configDir });
543
- activeConfig = readConfig(configDir);
544
- deviceToken = activeConfig.deviceToken;
587
+ if (runsAsBackgroundService()) {
588
+ deviceToken = await waitForPairing(configDir);
589
+ activeConfig = readConfig(configDir);
590
+ } else {
591
+ if (!process.stdin.isTTY) requireDeviceToken(config);
592
+ console.log('This local agent bridge is not paired yet.');
593
+ console.log('Open the product connection screen and generate a new bridge command.');
594
+ const code = await promptForPairingCode();
595
+ if (!code) requireDeviceToken(config);
596
+ await pairCommand({ apiBaseUrl, args: [code], flags, configDir });
597
+ activeConfig = readConfig(configDir);
598
+ deviceToken = activeConfig.deviceToken;
599
+ }
545
600
  }
546
601
  const product = normalizeBridgeProduct(activeConfig.product);
547
602
  const runtimeSelection = requestedRuntime(flags, activeConfig);
@@ -651,9 +706,22 @@ async function startCommand({ apiBaseUrl, config, flags, configDir }) {
651
706
  status: error.status,
652
707
  code: error.body?.code || null,
653
708
  });
654
- const disconnected = clearInvalidPairing(configDir, error);
709
+ const savedToken = readConfig(configDir).deviceToken;
710
+ if (savedToken && savedToken !== deviceToken) {
711
+ // Re-paired by another bridge command; adopt the new pairing.
712
+ deviceToken = savedToken;
713
+ pollFailureCount = 0;
714
+ continue;
715
+ }
716
+ const disconnected = clearInvalidPairing(configDir, error, deviceToken);
655
717
  console.log(disconnected.message);
656
- return;
718
+ if (!runsAsBackgroundService()) return;
719
+ deviceToken = await waitForPairing(configDir);
720
+ pollFailureCount = 0;
721
+ continue;
722
+ }
723
+ if (isUpdateRequiredError(error) && runsAsBackgroundService()) {
724
+ await selfUpdateWithHomebrew(configDir, pollLogger);
657
725
  }
658
726
  if (once) throw error;
659
727
  pollFailureCount += 1;
@@ -619,7 +619,7 @@ function installedFramerAgentVersion() {
619
619
  return null;
620
620
  }
621
621
 
622
- function readGuidanceFile(projectRoot, relativePath, remainingBytes) {
622
+ function readGuidanceFile(projectRoot, relativePath, remainingBytes, section) {
623
623
  const normalized = relativePath.replaceAll('\\', '/');
624
624
  if (
625
625
  normalized.startsWith('/')
@@ -635,12 +635,27 @@ function readGuidanceFile(projectRoot, relativePath, remainingBytes) {
635
635
  }
636
636
  const maximum = Math.min(MAX_GUIDANCE_FILE_BYTES, remainingBytes);
637
637
  if (maximum <= 0) return null;
638
+ let content = fs.readFileSync(absolutePath, 'utf8');
639
+ if (section) {
640
+ content = markdownSection(content, section);
641
+ if (!content) return null;
642
+ }
638
643
  return {
639
644
  path: normalized,
640
- content: boundedText(fs.readFileSync(absolutePath, 'utf8'), maximum),
645
+ content: boundedText(content, maximum),
641
646
  };
642
647
  }
643
648
 
649
+ function markdownSection(markdown, heading) {
650
+ const lines = markdown.split('\n');
651
+ const start = lines.findIndex((line) => line.trim() === `## ${heading}`);
652
+ if (start < 0) return '';
653
+ const end = lines.findIndex(
654
+ (line, index) => index > start && line.startsWith('## '),
655
+ );
656
+ return lines.slice(start, end < 0 ? undefined : end).join('\n').trim();
657
+ }
658
+
644
659
  function normalizeProjectId(value) {
645
660
  const projectId = String(value || '').trim();
646
661
  if (!PROJECT_ID_PATTERN.test(projectId)) {
@@ -1650,12 +1665,16 @@ export function createFramerAgentToolRuntime({
1650
1665
  { root: projectRoot, path: 'prompt/overview.md' },
1651
1666
  { root: projectRoot, path: 'prompt/guardrails.md' },
1652
1667
  { root: projectRoot, path: 'prompt/critical-reminders.md' },
1653
- ...(visualVerificationRequired
1654
- ? [
1655
- { root: projectRoot, path: 'prompt/design-rules.md' },
1656
- { root: projectRoot, path: 'prompt/how-projects-work.md' },
1657
- ]
1658
- : []),
1668
+ // The byte cap clips how-projects-work.md before its 3D transform
1669
+ // guidance, so that section loads on its own first.
1670
+ {
1671
+ root: projectRoot,
1672
+ path: 'prompt/how-projects-work.md',
1673
+ section: 'Transforms',
1674
+ label: 'prompt/how-projects-work.md#transforms',
1675
+ },
1676
+ { root: projectRoot, path: 'prompt/design-rules.md' },
1677
+ { root: projectRoot, path: 'prompt/how-projects-work.md' },
1659
1678
  ];
1660
1679
  const files = [];
1661
1680
  let remainingBytes = MAX_GUIDANCE_BYTES;
@@ -1664,6 +1683,7 @@ export function createFramerAgentToolRuntime({
1664
1683
  requestedFile.root,
1665
1684
  requestedFile.path,
1666
1685
  remainingBytes,
1686
+ requestedFile.section,
1667
1687
  );
1668
1688
  if (!loaded) continue;
1669
1689
  files.push({
@@ -174,7 +174,7 @@ function renderAggregateClaudeSkill(skills, nativeName) {
174
174
  return [
175
175
  '---',
176
176
  `name: ${yamlString(nativeName)}`,
177
- `description: ${yamlString('Apply every selected InstaWebAI skill for this model turn.')}`,
177
+ `description: ${yamlString('Apply every skill the user selected for this request.')}`,
178
178
  'user-invocable: true',
179
179
  'disable-model-invocation: true',
180
180
  '---',
@@ -231,7 +231,7 @@ export function materializeNativeSkills(
231
231
  ['always', 'explicit', 'command'].includes(skill.trigger),
232
232
  );
233
233
  if (provider === 'claude-code' && requiredSkills.length > 0) {
234
- aggregateName = `instawebai-active-${digest.slice(0, 8)}`;
234
+ aggregateName = `dexter-active-${digest.slice(0, 8)}`;
235
235
  const aggregateDirectory = path.join(skillsRoot, aggregateName);
236
236
  fs.mkdirSync(aggregateDirectory, { recursive: true, mode: 0o700 });
237
237
  aggregatePath = path.join(aggregateDirectory, 'SKILL.md');
@@ -260,7 +260,7 @@ export function claudeNativeSkillPrompt(prompt, materialized) {
260
260
  return [
261
261
  materialized?.aggregateName ? `/${materialized.aggregateName}` : '',
262
262
  catalog
263
- ? `Available optional skills; invoke one only when it is relevant:\n${catalog}`
263
+ ? `Available optional skills; invoke one only when it is relevant, and do not use any skill that is not listed here:\n${catalog}`
264
264
  : '',
265
265
  String(prompt || ''),
266
266
  ].filter(Boolean).join('\n\n');
@@ -184,6 +184,30 @@ export function materializeOutcomeReferenceAssets(materialized, attachments) {
184
184
  return output;
185
185
  }
186
186
 
187
+ // Image parts in a server-owned model turn become native image input for the
188
+ // provider CLI; the caller removes the directory when the turn settles.
189
+ export function materializeModelTurnImages(messages) {
190
+ const decoded = (Array.isArray(messages) ? messages : [])
191
+ .flatMap((message) => (Array.isArray(message?.content) ? message.content : []))
192
+ .filter((part) => part?.type === 'image' && typeof part.base64 === 'string')
193
+ .slice(0, 4)
194
+ .map((part) => decodedReferenceAsset({ dataUrl: `data:${part.mimeType};base64,${part.base64}` }))
195
+ .filter(Boolean);
196
+ if (!decoded.length) return { files: [], remove() {} };
197
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'dexter-turn-images-'));
198
+ const files = decoded.map((image, index) => {
199
+ const file = path.join(directory, `${String(index + 1).padStart(2, '0')}.${image.extension}`);
200
+ fs.writeFileSync(file, image.buffer, { mode: 0o600 });
201
+ return file;
202
+ });
203
+ return {
204
+ files,
205
+ remove() {
206
+ fs.rmSync(directory, { recursive: true, force: true });
207
+ },
208
+ };
209
+ }
210
+
187
211
  export function removeOutcomeReferenceAssets(materialized) {
188
212
  if (!materialized?.directory) return;
189
213
  fs.rmSync(
package/src/protocol.js CHANGED
@@ -141,11 +141,12 @@ function compactModelTurnContent(content) {
141
141
  if (!Array.isArray(content)) return content;
142
142
  return content.map((part) => {
143
143
  if (!isRecord(part) || part.type !== 'image') return part;
144
+ // Inline image bytes travel as native image input next to this prompt.
144
145
  return {
145
146
  type: 'image',
146
147
  mimeType: part.mimeType,
147
148
  url: part.url,
148
- omittedBase64Bytes: typeof part.base64 === 'string' ? part.base64.length : undefined,
149
+ ...(typeof part.base64 === 'string' ? { attachedAsImageInput: true } : {}),
149
150
  };
150
151
  });
151
152
  }
@@ -494,6 +495,7 @@ export function buildOutcomePrompt(outcome = {}, product, promptContext = {}) {
494
495
  'For repeated behavior, declare every targetNodeId and one representativeTargetNodeId in the mechanism plan. Implement and verify only that representative before fan-out, then verify complete target coverage after the final mutation. Only a contradicted result permits one evidence-scoped repair. Repeated unchanged evidence or exhausted verification budget must end as partial rather than starting another loop.',
495
496
  'Do not reuse a mechanism recorded as rejected by the user or repeated contradictory live evidence. Unknown evidence never rejects a mechanism.',
496
497
  'For visual work, evaluate hierarchy, spacing, contrast, typography, balance, imagery, interaction states, and responsive integrity.',
498
+ 'For depth and 3D, use native transforms through framer_apply_changes as described in the Framer guidance Transforms section: perspective on the parent, preserve3d on a container whose children must stay in 3D space, rotateX, rotateY, rotateZ, translateZ, and transformOrigin on the moving layers, and backfaceVisibility for flip faces. Rotations without a parent perspective render flat. Spend 3D on one signature moment, such as a tilted product mockup, layered card stack, flip card, or hover tilt; keep text readable, reduce or flatten the effect on smaller breakpoints, and confirm it in a screenshot. Do not fake 3D with images or code components when native transforms can achieve it.',
497
499
  'You decide when the run is complete. Avoid repeated inspection of unchanged evidence and stop when the requested outcome has been implemented and verified.',
498
500
  'Dexter will not perform a second design verification after you finish. Structural verification must be described honestly and must never be called runtime-tested unless an interaction was actually exercised.',
499
501
  'If Framer authorization or a required user decision is missing, return blocked with a concise explanation. Otherwise return completed only after you have finished and verified the work.',