@gakim-digital/dexter-bridge 0.11.6 → 0.11.9

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.6",
3
+ "version": "0.11.9",
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,6 +6,7 @@ import { fileURLToPath } from 'node:url';
6
6
  import crossSpawn from 'cross-spawn';
7
7
  import {
8
8
  getFramerProjectAuthorization,
9
+ isCompanionPairingInvalidError,
9
10
  postRunEvent,
10
11
  reportFramerProjectAuthorizationVerification,
11
12
  } from './api.js';
@@ -182,6 +183,10 @@ function nowIso() {
182
183
  const TERMINAL_EVENT_TYPES = new Set(['done', 'error']);
183
184
  const TERMINAL_EVENT_MAX_ATTEMPTS = 5;
184
185
  const TOOL_EVENT_MAX_ATTEMPTS = 3;
186
+ // Progress/status events are retried too, but briefly: they are posted in
187
+ // order on one chain, so a long backoff would stall every later event.
188
+ const STREAM_EVENT_MAX_ATTEMPTS = 3;
189
+ const STREAM_EVENT_MAX_RETRY_DELAY_MS = 2_000;
185
190
 
186
191
  function waitForRetry(delayMs) {
187
192
  return new Promise((resolve) => setTimeout(resolve, delayMs));
@@ -205,14 +210,20 @@ function createEventPoster({
205
210
  trace,
206
211
  allowInsecureHttp = false,
207
212
  }) {
213
+ // Stream events carry a per-run sequence so the API can drop a retried
214
+ // event it already published (for example after a lost response). Tool
215
+ // calls are idempotent by callId and terminal events by run completion.
216
+ let sequence = 0;
208
217
  return async function send(type, payload = {}) {
209
218
  const eventId = `${type}_${Date.now()}_${Math.random().toString(36).slice(2)}`;
219
+ const stream = !TERMINAL_EVENT_TYPES.has(type) && type !== 'tool_call';
220
+ const eventSequence = stream ? (sequence += 1) : undefined;
210
221
  const started = Date.now();
211
222
  const maxAttempts = TERMINAL_EVENT_TYPES.has(type)
212
223
  ? TERMINAL_EVENT_MAX_ATTEMPTS
213
224
  : type === 'tool_call'
214
225
  ? TOOL_EVENT_MAX_ATTEMPTS
215
- : 1;
226
+ : STREAM_EVENT_MAX_ATTEMPTS;
216
227
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
217
228
  trace?.info('event_post_start', {
218
229
  type,
@@ -229,6 +240,7 @@ function createEventPoster({
229
240
  event: {
230
241
  type,
231
242
  eventId,
243
+ ...(eventSequence ? { sequence: eventSequence } : {}),
232
244
  payload,
233
245
  },
234
246
  });
@@ -244,7 +256,11 @@ function createEventPoster({
244
256
  });
245
257
  return response;
246
258
  } catch (error) {
247
- const retryDelayMs = attempt < maxAttempts ? terminalEventRetryDelay(error, attempt) : null;
259
+ const baseDelayMs = attempt < maxAttempts ? terminalEventRetryDelay(error, attempt) : null;
260
+ const retryDelayMs =
261
+ stream && baseDelayMs !== null
262
+ ? Math.min(STREAM_EVENT_MAX_RETRY_DELAY_MS, baseDelayMs)
263
+ : baseDelayMs;
248
264
  trace?.error('event_post_failed', {
249
265
  type,
250
266
  eventId,
@@ -378,7 +394,12 @@ function argsWithClaudeIsolation(args, definition, options = {}) {
378
394
  'preview_control',
379
395
  'browser_control',
380
396
  'data_inspect',
397
+ 'data_schema_get',
398
+ 'data_schema_apply',
381
399
  'verification_run',
400
+ 'platform_catalog_search',
401
+ 'platform_resource_configure',
402
+ 'platform_resource_status',
382
403
  ];
383
404
  const harnessTools = harnessToolNames.map(
384
405
  (name) => `mcp__instawebai__${name}`,
@@ -673,7 +694,40 @@ export function buildAgentArgs(definition, modelDefinition, env = process.env, o
673
694
  options.responseContract,
674
695
  );
675
696
  const resumedArgs = argsWithResumedSession(schemaArgs, definition, options.resumeSessionId);
676
- return argsWithPromptInput(resumedArgs, definition);
697
+ return argsWithPromptInput(argsWithImages(resumedArgs, definition, options.imageFiles), definition);
698
+ }
699
+
700
+ function argsWithImages(args, definition, imageFiles = []) {
701
+ if (!imageFiles.length) return args;
702
+ if (definition.id === 'codex') {
703
+ const withoutStdin = args.filter((arg) => arg !== '-');
704
+ return [...withoutStdin, ...imageFiles.map((file) => `--image=${file}`)];
705
+ }
706
+ if (definition.id === 'claude-code') {
707
+ const streamed = argsWithoutFlagValue(argsWithoutFlagValue(args, '--input-format'), '--output-format');
708
+ streamed.push('--input-format', 'stream-json', '--output-format', 'stream-json');
709
+ if (!argsIncludeFlag(streamed, '--verbose')) streamed.push('--verbose');
710
+ return streamed;
711
+ }
712
+ return args;
713
+ }
714
+
715
+ const IMAGE_MEDIA_TYPES = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp' };
716
+
717
+ // Claude Code's stream-json input carries images as native content blocks.
718
+ function claudeStreamJsonPrompt(prompt, imageFiles) {
719
+ const content = [
720
+ ...imageFiles.map((file) => ({
721
+ type: 'image',
722
+ source: {
723
+ type: 'base64',
724
+ media_type: IMAGE_MEDIA_TYPES[path.extname(file).toLowerCase()] || 'image/png',
725
+ data: fs.readFileSync(file).toString('base64'),
726
+ },
727
+ })),
728
+ { type: 'text', text: prompt },
729
+ ];
730
+ return `${JSON.stringify({ type: 'user', message: { role: 'user', content } })}\n`;
677
731
  }
678
732
 
679
733
  export function parseEnvOutput(output) {
@@ -1523,6 +1577,9 @@ async function callProviderAdapter(
1523
1577
  if (controlRequestsCancellation(response)) await requestCancel();
1524
1578
  })
1525
1579
  .catch((error) => {
1580
+ if (isCompanionPairingInvalidError(error)) {
1581
+ void requestCancel();
1582
+ }
1526
1583
  if (!finished) {
1527
1584
  trace?.warn('adapter_activity_post_failed', {
1528
1585
  error: errorMeta(error),
@@ -1562,6 +1619,10 @@ async function callProviderAdapter(
1562
1619
  if (controlRequestsCancellation(response)) await requestCancel();
1563
1620
  } catch (error) {
1564
1621
  if (monitorAbort.signal.aborted) return;
1622
+ if (isCompanionPairingInvalidError(error)) {
1623
+ await requestCancel();
1624
+ return;
1625
+ }
1565
1626
  trace?.warn('adapter_control_poll_failed', { error: errorMeta(error) });
1566
1627
  }
1567
1628
  }
@@ -1713,17 +1774,11 @@ async function callLocalJsonAgent(agent, prompt, options = {}) {
1713
1774
  const definition = definitionForAgent(agent);
1714
1775
  const command = options.runtime?.command || commandFromEnv(definition.commandEnv, definition.fallbackCommand);
1715
1776
  const modelDefinition = companionModelDefinition(options.model, definition.id);
1716
- const args = buildAgentArgs(definition, modelDefinition, process.env, {
1717
- resumeSessionId: options.resumeSessionId,
1718
- outputSchema: options.outputSchema,
1719
- responseContract: options.responseContract,
1720
- step: options.step,
1721
- product: options.product,
1722
- nativeSkillsEnabled: options.nativeSkillsEnabled,
1723
- harnessMode: options.harnessMode,
1724
- mcpConfig: options.mcpConfig,
1725
- harnessToolNames: options.harnessToolNames,
1726
- });
1777
+ const args = buildAgentArgs(definition, modelDefinition, process.env, options);
1778
+ const processInput =
1779
+ definition.id === 'claude-code' && options.imageFiles?.length
1780
+ ? claudeStreamJsonPrompt(prompt, options.imageFiles)
1781
+ : prompt;
1727
1782
  const timeoutMs = options.timeoutMs || Number(process.env.DEXTER_BRIDGE_AGENT_TIMEOUT_MS || 120000);
1728
1783
  const maxDurationMs = boundedDurationMs(
1729
1784
  options.maxDurationMs ?? process.env.DEXTER_BRIDGE_AGENT_MAX_DURATION_MS,
@@ -1743,7 +1798,7 @@ async function callLocalJsonAgent(agent, prompt, options = {}) {
1743
1798
  timeoutMs,
1744
1799
  maxDurationMs,
1745
1800
  });
1746
- return runProcess(command, args, prompt, {
1801
+ return runProcess(command, args, processInput, {
1747
1802
  timeoutMs,
1748
1803
  maxDurationMs,
1749
1804
  trace: options.trace,
@@ -1847,7 +1902,7 @@ async function executeModelTurnRun(run, send, agent, options = {}) {
1847
1902
  callId: run.runId,
1848
1903
  step: Number(run?.modelTurn?.step ?? 0),
1849
1904
  model: selectedModelId,
1850
- status: 'failed',
1905
+ callStatus: 'failed',
1851
1906
  durationMs: Date.now() - callStartedAt,
1852
1907
  promptChars: prompt.length,
1853
1908
  requestedContextMode: deltaRequested ? 'delta' : 'full',
@@ -2006,6 +2061,9 @@ async function executeModelTurnRun(run, send, agent, options = {}) {
2006
2061
  if (controlRequestsCancellation(response)) requestCliCancel();
2007
2062
  })
2008
2063
  .catch((error) => {
2064
+ if (isCompanionPairingInvalidError(error)) {
2065
+ requestCliCancel();
2066
+ }
2009
2067
  if (!cliFinished) {
2010
2068
  options.trace?.warn('cli_activity_post_failed', {
2011
2069
  error: errorMeta(error),
@@ -2044,6 +2102,10 @@ async function executeModelTurnRun(run, send, agent, options = {}) {
2044
2102
  if (cliCancelled) return;
2045
2103
  } catch (error) {
2046
2104
  if (monitorAbort.signal.aborted) return;
2105
+ if (isCompanionPairingInvalidError(error)) {
2106
+ requestCliCancel();
2107
+ return;
2108
+ }
2047
2109
  options.trace?.warn('cli_control_poll_failed', {
2048
2110
  error: errorMeta(error),
2049
2111
  });
@@ -2150,7 +2212,7 @@ async function executeModelTurnRun(run, send, agent, options = {}) {
2150
2212
  };
2151
2213
  const usage = {
2152
2214
  ...usageSnapshot,
2153
- modelCalls: [{ ...callTelemetry, status: 'succeeded' }],
2215
+ modelCalls: [{ ...callTelemetry, callStatus: 'succeeded' }],
2154
2216
  };
2155
2217
  let completion;
2156
2218
  try {
@@ -2158,7 +2220,7 @@ async function executeModelTurnRun(run, send, agent, options = {}) {
2158
2220
  } catch (error) {
2159
2221
  error.companionUsage = {
2160
2222
  ...usage,
2161
- modelCalls: [{ ...callTelemetry, status: 'rejected' }],
2223
+ modelCalls: [{ ...callTelemetry, callStatus: 'rejected' }],
2162
2224
  };
2163
2225
  throw error;
2164
2226
  }
@@ -2255,6 +2317,9 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
2255
2317
  }
2256
2318
  })
2257
2319
  .catch((error) => {
2320
+ if (isCompanionPairingInvalidError(error)) {
2321
+ void requestCancel('device_disconnect');
2322
+ }
2258
2323
  if (!finished) {
2259
2324
  options.trace?.warn?.('outcome_activity_post_failed', {
2260
2325
  error: errorMeta(error),
@@ -2331,6 +2396,10 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
2331
2396
  await requestCancel(controlCancellationSource(response));
2332
2397
  }
2333
2398
  } catch (error) {
2399
+ if (isCompanionPairingInvalidError(error)) {
2400
+ await requestCancel('device_disconnect');
2401
+ return;
2402
+ }
2334
2403
  if (!controlMonitorAbort.signal.aborted && !cancelRequested) {
2335
2404
  options.trace?.warn?.('outcome_control_poll_failed', {
2336
2405
  error: errorMeta(error),
@@ -2374,6 +2443,23 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
2374
2443
  },
2375
2444
  }
2376
2445
  : run.outcome;
2446
+ const referenceImagePaths = referenceAssetFiles.map((file) =>
2447
+ path.join(materialized.directory, file.path),
2448
+ );
2449
+ const materializedReferenceIds = new Set(
2450
+ referenceAssetFiles.map((file) => file.id),
2451
+ );
2452
+ // Materialized images ride on the first provider turn as native image input.
2453
+ const referenceAssets = [
2454
+ ...new Set(
2455
+ requestAttachments
2456
+ .map((attachment) => String(attachment?.id || '').slice(0, 180))
2457
+ .filter(Boolean),
2458
+ ),
2459
+ ].map((id) => ({
2460
+ id,
2461
+ materialized: materializedReferenceIds.has(id),
2462
+ }));
2377
2463
  const nativeSkills = normalizeNativeSkills(run?.outcome?.nativeSkills);
2378
2464
  const nativeProvider =
2379
2465
  definition.id === 'claude-code'
@@ -2400,6 +2486,7 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
2400
2486
  trace: options.trace,
2401
2487
  onActivity: progress,
2402
2488
  runCli: options.framerAgentRunCli,
2489
+ fetchImpl: options.framerScreenshotFetch,
2403
2490
  authorizeProject:
2404
2491
  options.apiBaseUrl && options.deviceToken
2405
2492
  ? ({ initiate, forceRefresh, signal }) =>
@@ -2519,6 +2606,9 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
2519
2606
  resumeSessionIdOverride,
2520
2607
  step = 0,
2521
2608
  ) => {
2609
+ // User attachments ride on the first turn as native image input, exactly
2610
+ // as if pasted into the agent; follow-up turns already have them in context.
2611
+ const imageFiles = step === 0 ? referenceImagePaths : [];
2522
2612
  if (options.providerAdapter?.runOutcome) {
2523
2613
  const providerResult = await options.providerAdapter.runOutcome({
2524
2614
  runId: run.runId,
@@ -2533,6 +2623,7 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
2533
2623
  cwd: materialized.directory,
2534
2624
  nativeSkills,
2535
2625
  framerHarness: framerAgentOutcome,
2626
+ imageFiles,
2536
2627
  harnessTools: {
2537
2628
  definitions: codexDynamicToolSpecs(harnessTools.definitions),
2538
2629
  invoke: harnessTools.invoke,
@@ -2575,6 +2666,7 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
2575
2666
  framerHarness: framerAgentOutcome,
2576
2667
  harnessToolNames: harnessTools.toolNames,
2577
2668
  nativeSkillsEnabled: Boolean(materializedNativeSkills),
2669
+ imageFiles,
2578
2670
  signal: infrastructureAbortController.signal,
2579
2671
  ...(mcpConfig ? { mcpConfig } : {}),
2580
2672
  onProgress: progress,
@@ -2597,6 +2689,7 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
2597
2689
  framerHarness: framerAgentOutcome,
2598
2690
  harnessToolNames: harnessTools.toolNames,
2599
2691
  nativeSkillsEnabled: Boolean(materializedNativeSkills),
2692
+ imageFiles,
2600
2693
  signal: infrastructureAbortController.signal,
2601
2694
  ...(mcpConfig ? { mcpConfig } : {}),
2602
2695
  onProgress: progress,
@@ -2741,6 +2834,7 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
2741
2834
  outcome: {
2742
2835
  ...normalized,
2743
2836
  files,
2837
+ referenceAssets,
2744
2838
  },
2745
2839
  model: selectedModelId,
2746
2840
  providerSessionId:
@@ -2753,9 +2847,10 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
2753
2847
  callId: run.runId,
2754
2848
  outcomeId: run?.outcome?.outcomeId,
2755
2849
  model: selectedModelId,
2756
- status:
2850
+ callStatus: 'succeeded',
2851
+ outcomeStatus:
2757
2852
  normalized.status === successStatus
2758
- ? 'succeeded'
2853
+ ? 'completed'
2759
2854
  : normalized.status,
2760
2855
  durationMs: Date.now() - callStartedAt,
2761
2856
  providerAttemptCount: Math.max(1, providerAttempts),
@@ -2810,6 +2905,7 @@ export async function executeRun(
2810
2905
  inspectAgentAuthentication,
2811
2906
  runtimeProfile: suppliedRuntimeProfile,
2812
2907
  framerAgentRunCli,
2908
+ framerScreenshotFetch,
2813
2909
  allowInsecureHttp = false,
2814
2910
  } = {},
2815
2911
  ) {
@@ -2928,6 +3024,7 @@ export async function executeRun(
2928
3024
  product,
2929
3025
  runtimeProfile,
2930
3026
  framerAgentRunCli,
3027
+ framerScreenshotFetch,
2931
3028
  apiBaseUrl,
2932
3029
  deviceToken,
2933
3030
  fetchImpl,
@@ -2991,6 +3088,7 @@ export function checkCommand(command, args = ['--version'], timeoutMs = 10000) {
2991
3088
 
2992
3089
  export async function checkAgentAvailability(agent, model, options = {}) {
2993
3090
  const normalizedAgent = normalizeAgentName(agent);
3091
+ const modelCatalogRefreshedAt = nowIso();
2994
3092
  if (normalizedAgent === 'dry-run') {
2995
3093
  return {
2996
3094
  ok: true,
@@ -3002,6 +3100,8 @@ export async function checkAgentAvailability(agent, model, options = {}) {
3002
3100
  installed: true,
3003
3101
  signedIn: true,
3004
3102
  status: 'ready',
3103
+ modelCatalogSource: 'runtime',
3104
+ modelCatalogRefreshedAt,
3005
3105
  };
3006
3106
  }
3007
3107
  const definition = definitionForAgent(normalizedAgent);
@@ -3025,6 +3125,8 @@ export async function checkAgentAvailability(agent, model, options = {}) {
3025
3125
  installed: false,
3026
3126
  signedIn: false,
3027
3127
  status: 'unavailable',
3128
+ modelCatalogSource: 'fallback',
3129
+ modelCatalogRefreshedAt,
3028
3130
  };
3029
3131
  }
3030
3132
  if (normalizedAgent === 'codex') {
@@ -3051,9 +3153,19 @@ export async function checkAgentAvailability(agent, model, options = {}) {
3051
3153
  error:
3052
3154
  detection.error
3053
3155
  || 'Codex is not signed in. Run `codex login` on this computer, then run the bridge command again.',
3156
+ modelCatalogSource: 'fallback',
3157
+ modelCatalogRefreshedAt,
3054
3158
  };
3055
3159
  }
3056
- const reportedModels = await adapter.models();
3160
+ let reportedModels = supportedModels;
3161
+ let modelCatalogSource = 'fallback';
3162
+ let modelCatalogError = null;
3163
+ try {
3164
+ reportedModels = await adapter.models();
3165
+ modelCatalogSource = 'runtime';
3166
+ } catch (error) {
3167
+ modelCatalogError = error?.message || String(error);
3168
+ }
3057
3169
  return {
3058
3170
  ...runtime,
3059
3171
  ...detection,
@@ -3062,7 +3174,9 @@ export async function checkAgentAvailability(agent, model, options = {}) {
3062
3174
  label: definition.label,
3063
3175
  models: reportedModels.map((candidate) => candidate.id),
3064
3176
  modelDetails: reportedModels,
3065
- modelCatalogSource: 'runtime',
3177
+ modelCatalogSource,
3178
+ modelCatalogRefreshedAt,
3179
+ ...(modelCatalogError ? { modelCatalogError } : {}),
3066
3180
  installed: true,
3067
3181
  signedIn: true,
3068
3182
  status: 'ready',
@@ -3108,6 +3222,7 @@ export async function checkAgentAvailability(agent, model, options = {}) {
3108
3222
  models: reportedModels.map((candidate) => candidate.id),
3109
3223
  modelDetails: reportedModels,
3110
3224
  modelCatalogSource,
3225
+ modelCatalogRefreshedAt,
3111
3226
  ...(modelCatalogError ? { modelCatalogError } : {}),
3112
3227
  installed: true,
3113
3228
  };
@@ -301,6 +301,11 @@ function claudeToolProgress(name, input = {}, cwd) {
301
301
  preview_control: ['Starting the live preview', 'Started the live preview'],
302
302
  browser_control: ['Checking the app in the browser', 'Finished checking the app in the browser'],
303
303
  data_inspect: ['Checking app data', 'Finished checking app data'],
304
+ data_schema_get: ['Reading the app tables', 'Finished reading the app tables'],
305
+ data_schema_apply: ['Updating the app tables', 'Finished updating the app tables'],
306
+ platform_catalog_search: ['Looking up connections', 'Found the available connections'],
307
+ platform_resource_configure: ['Setting up a connection', 'Finished setting up the connection'],
308
+ platform_resource_status: ['Checking a connection', 'Finished checking the connection'],
304
309
  verification_run: ['Running final verification', 'Finished final verification'],
305
310
  };
306
311
  const [startedMessage, completedMessage] = messages[shortName] || [
package/src/api.js CHANGED
@@ -16,6 +16,15 @@ export class DexterBridgeApiError extends Error {
16
16
  }
17
17
  }
18
18
 
19
+ export function isCompanionPairingInvalidError(error) {
20
+ return error instanceof DexterBridgeApiError
21
+ && error.status === 401
22
+ && (
23
+ error.body?.code === 'FRAMER_COMPANION_NOT_PAIRED'
24
+ || /not paired|pair again/i.test(error.message)
25
+ );
26
+ }
27
+
19
28
  function bearerHeaders(deviceToken) {
20
29
  return deviceToken ? { Authorization: `Bearer ${deviceToken}` } : {};
21
30
  }
package/src/cli.js CHANGED
@@ -19,8 +19,8 @@ import {
19
19
  import { checkAgentAvailability, checkAllAgents, executeRun } from './agent.js';
20
20
  import {
21
21
  claimPairing,
22
- DexterBridgeApiError,
23
22
  heartbeat,
23
+ isCompanionPairingInvalidError,
24
24
  pollRun,
25
25
  } from './api.js';
26
26
  import {
@@ -30,6 +30,7 @@ import {
30
30
  } from './logger.js';
31
31
  import { createLocalAgentAdapter } from './providers/index.js';
32
32
  import {
33
+ diagnosticsFromAgentCheck,
33
34
  discoverRuntimeProfiles,
34
35
  normalizeRuntimeSelection,
35
36
  publicRuntimeProfile,
@@ -228,12 +229,7 @@ function wait(delayMs) {
228
229
  }
229
230
 
230
231
  function isInvalidPairingError(error) {
231
- return error instanceof DexterBridgeApiError
232
- && error.status === 401
233
- && (
234
- error.body?.code === 'FRAMER_COMPANION_NOT_PAIRED'
235
- || /not paired|pair again/i.test(error.message)
236
- );
232
+ return isCompanionPairingInvalidError(error);
237
233
  }
238
234
 
239
235
  function clearInvalidPairing(configDir, cause) {
@@ -266,7 +262,48 @@ function agentEnvironment(config = {}, baseEnv = process.env) {
266
262
  return env;
267
263
  }
268
264
 
269
- async function inspectAvailability(config = {}, runtimeSelection = null) {
265
+ function modelCatalogStatus(runtimeProfiles = []) {
266
+ return Object.fromEntries(
267
+ runtimeProfiles.flatMap((profile) => {
268
+ const agent =
269
+ profile.driver === 'codex-app-server'
270
+ ? 'codex'
271
+ : profile.driver === 'claude-code-cli'
272
+ ? 'claude-code'
273
+ : null;
274
+ return agent ? [[agent, profile.modelCatalog]] : [];
275
+ }),
276
+ );
277
+ }
278
+
279
+ function preserveLastKnownModelCatalog(previousProfiles = [], nextProfiles = []) {
280
+ const previousById = new Map(previousProfiles.map((profile) => [profile.id, profile]));
281
+ return nextProfiles.map((profile) => {
282
+ const previous = previousById.get(profile.id);
283
+ if (
284
+ !previous?.models?.length
285
+ || profile.modelCatalog?.source === 'runtime'
286
+ || !profile.modelCatalog?.error
287
+ ) {
288
+ return profile;
289
+ }
290
+ return {
291
+ ...profile,
292
+ models: previous.models,
293
+ modelCatalog: {
294
+ ...profile.modelCatalog,
295
+ source: 'stale',
296
+ modelCount: previous.models.length,
297
+ },
298
+ };
299
+ });
300
+ }
301
+
302
+ async function inspectAvailability(
303
+ config = {},
304
+ runtimeSelection = null,
305
+ { forceModelRefresh = false, previousRuntimeProfiles = [] } = {},
306
+ ) {
270
307
  try {
271
308
  const bridgeEnv = agentEnvironment(config);
272
309
  const checks =
@@ -275,22 +312,33 @@ async function inspectAvailability(config = {}, runtimeSelection = null) {
275
312
  agents: [
276
313
  await checkAgentAvailability(runtimeSelection, undefined, {
277
314
  env: bridgeEnv,
315
+ forceModelRefresh,
278
316
  }),
279
317
  ],
280
318
  dryRun: null,
281
319
  }
282
320
  : runtimeSelection === 'opencode'
283
321
  ? { agents: [], dryRun: null }
284
- : await checkAllAgents({ env: bridgeEnv });
322
+ : await checkAllAgents({ env: bridgeEnv, forceModelRefresh });
285
323
  const allChecks = [...checks.agents, ...(checks.dryRun ? [checks.dryRun] : [])];
286
324
  const available = allChecks.filter((check) => check.ok);
287
- const runtimeProfiles = await discoverRuntimeProfiles({
325
+ const discoveredRuntimeProfiles = await discoverRuntimeProfiles({
288
326
  agentChecks: checks.agents,
289
327
  config,
290
328
  env: bridgeEnv,
291
329
  runtimeSelection,
292
330
  });
331
+ const runtimeProfiles = preserveLastKnownModelCatalog(
332
+ previousRuntimeProfiles,
333
+ discoveredRuntimeProfiles,
334
+ );
293
335
  const profileModels = runtimeProfiles.flatMap((profile) => profile.models || []);
336
+ const catalogStatus = modelCatalogStatus(runtimeProfiles);
337
+ const refreshedAt = Object.values(catalogStatus)
338
+ .map((status) => status?.refreshedAt)
339
+ .filter(Boolean)
340
+ .sort()
341
+ .at(-1);
294
342
  return {
295
343
  metadata: {
296
344
  availableAgents: available.map((check) => check.agent).join(','),
@@ -298,6 +346,8 @@ async function inspectAvailability(config = {}, runtimeSelection = null) {
298
346
  availableModelDetails: JSON.stringify(
299
347
  profileModels,
300
348
  ),
349
+ modelCatalogStatus: JSON.stringify(catalogStatus),
350
+ ...(refreshedAt ? { modelCatalogRefreshedAt: refreshedAt } : {}),
301
351
  agentVersions: available.map((check) => `${check.agent}=${check.version || 'unknown'}`).join(','),
302
352
  bridgeCapabilities: BRIDGE_CAPABILITIES.join(','),
303
353
  bridgeVersion: BRIDGE_VERSION,
@@ -555,7 +605,10 @@ async function startCommand({ apiBaseUrl, config, flags, configDir }) {
555
605
  while (true) {
556
606
  if (Date.now() >= nextCatalogRefreshAt) {
557
607
  try {
558
- const refreshedAvailability = await inspectAvailability(activeConfig, runtimeSelection);
608
+ const refreshedAvailability = await inspectAvailability(activeConfig, runtimeSelection, {
609
+ forceModelRefresh: true,
610
+ previousRuntimeProfiles: availability.runtimeProfiles,
611
+ });
559
612
  const refreshedRuntimeProfile = runtimeSelection
560
613
  ? requireAvailableRuntime(refreshedAvailability, runtimeSelection)
561
614
  : null;
@@ -598,7 +651,9 @@ async function startCommand({ apiBaseUrl, config, flags, configDir }) {
598
651
  status: error.status,
599
652
  code: error.body?.code || null,
600
653
  });
601
- throw clearInvalidPairing(configDir, error);
654
+ const disconnected = clearInvalidPairing(configDir, error);
655
+ console.log(disconnected.message);
656
+ return;
602
657
  }
603
658
  if (once) throw error;
604
659
  pollFailureCount += 1;
@@ -610,6 +665,45 @@ async function startCommand({ apiBaseUrl, config, flags, configDir }) {
610
665
  await wait(retryInMs);
611
666
  continue;
612
667
  }
668
+ if (poll?.control?.refreshModelCatalog) {
669
+ try {
670
+ const refreshedAvailability = await inspectAvailability(activeConfig, runtimeSelection, {
671
+ forceModelRefresh: true,
672
+ previousRuntimeProfiles: availability.runtimeProfiles,
673
+ });
674
+ availability = refreshedAvailability;
675
+ runtimeProfile = runtimeSelection
676
+ ? requireAvailableRuntime(refreshedAvailability, runtimeSelection)
677
+ : null;
678
+ check = runtimeProfile
679
+ ? null
680
+ : requireAvailableAgent(refreshedAvailability, agent);
681
+ model = runtimeProfile
682
+ ? runtimeProfile.models.find((candidate) => candidate.id === requestedModel)?.id
683
+ || runtimeProfile.models[0].id
684
+ : availableModel(check, requestedModel);
685
+ metadata = availability.metadata;
686
+ profiles = runtimeProfileMap(availability.runtimeProfiles);
687
+ await heartbeat(apiBaseUrl, {
688
+ deviceToken,
689
+ status: 'ready',
690
+ agent,
691
+ model,
692
+ metadata,
693
+ runtimeProfiles: availability.runtimeProfiles.map(publicRuntimeProfile),
694
+ allowInsecureHttp: flags['allow-insecure-http'],
695
+ });
696
+ pollLogger.info('model_catalog_refresh_completed', {
697
+ requestedAt: poll.control.refreshModelCatalog.requestedAt,
698
+ models: availability.runtimeProfiles.flatMap((profile) =>
699
+ profile.models.map((candidate) => candidate.id)),
700
+ });
701
+ } catch (error) {
702
+ pollLogger.warn('model_catalog_refresh_failed', { error: errorMeta(error) });
703
+ } finally {
704
+ nextCatalogRefreshAt = Date.now() + MODEL_CATALOG_REFRESH_MS;
705
+ }
706
+ }
613
707
  if (poll.run) {
614
708
  console.log(`Claimed run ${poll.run.runId}.`);
615
709
  const runtimeProfile = poll.run?.runtimeProfile || null;
@@ -661,19 +755,28 @@ async function startCommand({ apiBaseUrl, config, flags, configDir }) {
661
755
  }
662
756
  }
663
757
 
664
- async function doctorCommand(runtimeSelection = null) {
665
- if (runtimeSelection) {
666
- const availability = await inspectAvailability({}, runtimeSelection);
667
- const profile = requireAvailableRuntime(availability, runtimeSelection);
668
- console.log(`${runtimeLabel(runtimeSelection)}: ready`);
669
- console.log(`Models: ${profile.models.map((model) => model.displayName || model.id).join(', ')}`);
670
- return;
758
+ function printDiagnostics(diagnostics) {
759
+ const marks = { info: '✓', warn: '!', error: '✗' };
760
+ for (const check of diagnostics.checks) {
761
+ console.log(` ${marks[check.level] || '-'} ${check.message}`);
762
+ if (check.hint) console.log(` ${check.hint}`);
671
763
  }
672
- const checks = await checkAllAgents();
673
- for (const check of checks.agents) {
674
- if (check.ok) console.log(`${check.command}: ok ${check.output || ''}`.trim());
675
- else console.log(`${check.command}: unavailable (${check.error})`);
764
+ }
765
+
766
+ async function doctorCommand(runtimeSelection = null) {
767
+ const bridgeEnv = agentEnvironment({});
768
+ const checks =
769
+ runtimeSelection === 'codex' || runtimeSelection === 'claude-code'
770
+ ? [await checkAgentAvailability(runtimeSelection, undefined, { env: bridgeEnv })]
771
+ : (await checkAllAgents({ env: bridgeEnv })).agents;
772
+ let failed = false;
773
+ for (const check of checks) {
774
+ const diagnostics = diagnosticsFromAgentCheck(check, bridgeEnv);
775
+ failed ||= diagnostics.status === 'fail';
776
+ console.log(`${check.label || check.agent}: ${diagnostics.status}`);
777
+ printDiagnostics(diagnostics);
676
778
  }
779
+ if (failed && runtimeSelection) process.exitCode = 1;
677
780
  }
678
781
 
679
782
  export async function runCli(argv) {
package/src/config.js CHANGED
@@ -52,6 +52,7 @@ export const BRIDGE_CAPABILITIES = [
52
52
  'runtime-profiles-v1',
53
53
  'native-skills-v1',
54
54
  'model-progress-v1',
55
+ 'model-catalog-refresh-v1',
55
56
  ];
56
57
  export const BRIDGE_PRODUCTS = {
57
58
  dexter: {