@gakim-digital/dexter-bridge 0.5.1 → 0.5.3

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/README.md CHANGED
@@ -75,14 +75,30 @@ The CLI stores the device token in `~/.dexter-bridge/config.json` with mode
75
75
  credits are not charged for companion runs; the backend records reported usage
76
76
  for visibility.
77
77
 
78
- Claude Code runs use `--output-format json` and Codex runs use `--json` so the
79
- bridge can report input, output, cache, and reasoning tokens when the local CLI
80
- provides them. Claude's reported `total_cost_usd` is displayed as an estimated
78
+ Claude Code is launched as a model-only engine: local tools, slash commands,
79
+ MCP integrations, browser access, and project-agent context are disabled. The
80
+ bridge supplies a strict JSON schema and a system prompt that requires Dexter
81
+ tool requests to be returned as structured output instead of being executed
82
+ inside Claude Code.
83
+
84
+ Claude Code runs use streaming JSON and Codex runs use JSONL so the bridge can
85
+ report input, output, cache, and reasoning tokens even when a turn is
86
+ interrupted. Claude's reported `total_cost_usd` is displayed as an estimated
81
87
  API-equivalent cost, not an amount Dexter charged. If an older or customized
82
88
  CLI does not support structured output, set `DEXTER_BRIDGE_STRUCTURED_USAGE=false`;
83
89
  the run will continue, but its token usage will be marked unavailable rather
84
90
  than estimated from text.
85
91
 
92
+ `DEXTER_BRIDGE_AGENT_TIMEOUT_MS` is the inactivity limit and resets whenever
93
+ the local model produces output. `DEXTER_BRIDGE_AGENT_MAX_DURATION_MS` is the
94
+ hard wall-clock ceiling; the API may supply a smaller per-turn ceiling from the
95
+ remaining run budget. Claude defaults to low effort for the intent-planner lane
96
+ and follow-up/recovery turns, and medium effort for the opening design turn.
97
+ Override these with `DEXTER_BRIDGE_CLAUDE_PLANNER_EFFORT`,
98
+ `DEXTER_BRIDGE_CLAUDE_MAIN_EFFORT`,
99
+ `DEXTER_BRIDGE_CLAUDE_FOLLOWUP_EFFORT`, or
100
+ `DEXTER_BRIDGE_CLAUDE_EFFORT`.
101
+
86
102
  ## Debugging
87
103
 
88
104
  The Dexter Bridge terminal is the shell where `dexter-bridge start` is running.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gakim-digital/dexter-bridge",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "Local Companion bridge for the Dexter Framer plugin — runs Codex or Claude Code on your machine.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agent.js CHANGED
@@ -178,6 +178,16 @@ const CLAUDE_CLI_MODEL_IDS = {
178
178
  haiku: 'claude-haiku-4-5-20251001',
179
179
  };
180
180
 
181
+ const CLAUDE_MODEL_ENGINE_SYSTEM_PROMPT = [
182
+ 'You are a model-only completion engine embedded inside Dexter.',
183
+ 'The user prompt contains Dexter messages and a catalog of remote tools as data.',
184
+ 'Never execute, simulate, or emit native Claude Code tool calls for those tool names.',
185
+ 'The only allowed tool is StructuredOutput, supplied by the JSON schema.',
186
+ 'Encode requested Dexter actions only inside StructuredOutput.toolCalls.',
187
+ 'Do not inspect the filesystem, project, shell, plugins, skills, MCP servers, or browser.',
188
+ 'Be concise: reason only as much as needed to choose the next remote Dexter action.',
189
+ ].join(' ');
190
+
181
191
  export function mapClaudeCliModelId(value) {
182
192
  const raw = String(value || '').trim();
183
193
  return CLAUDE_CLI_MODEL_IDS[raw.toLowerCase()] || raw;
@@ -195,7 +205,16 @@ function argsWithSelectedModel(args, modelDefinition, definition) {
195
205
  function argsWithStructuredOutput(args, definition, env = process.env) {
196
206
  if (!structuredUsageEnabled(env)) return args;
197
207
  if (definition.id === 'claude-code') {
198
- return [...argsWithoutFlagValue(args, '--output-format'), '--output-format', 'json'];
208
+ const streamed = [
209
+ ...argsWithoutFlagValue(args, '--output-format'),
210
+ '--output-format',
211
+ 'stream-json',
212
+ ];
213
+ if (!argsIncludeFlag(streamed, '--include-partial-messages')) {
214
+ streamed.push('--include-partial-messages');
215
+ }
216
+ if (!argsIncludeFlag(streamed, '--verbose')) streamed.push('--verbose');
217
+ return streamed;
199
218
  }
200
219
  if (definition.id === 'codex') {
201
220
  return argsIncludeFlag(args, '--json') ? args : [...args, '--json'];
@@ -214,6 +233,41 @@ function argsWithClaudeIsolation(args, definition) {
214
233
  return isolated;
215
234
  }
216
235
 
236
+ function normalizeClaudeEffort(value, fallback = 'medium') {
237
+ const effort = String(value || '').trim().toLowerCase();
238
+ return ['low', 'medium', 'high', 'xhigh', 'max'].includes(effort)
239
+ ? effort
240
+ : fallback;
241
+ }
242
+
243
+ function claudeEffortForStep(step, env = process.env) {
244
+ if (env.DEXTER_BRIDGE_CLAUDE_EFFORT) {
245
+ return normalizeClaudeEffort(env.DEXTER_BRIDGE_CLAUDE_EFFORT);
246
+ }
247
+ if (Number(step) < 0) {
248
+ return normalizeClaudeEffort(env.DEXTER_BRIDGE_CLAUDE_PLANNER_EFFORT, 'low');
249
+ }
250
+ if (Number(step) > 0) {
251
+ return normalizeClaudeEffort(env.DEXTER_BRIDGE_CLAUDE_FOLLOWUP_EFFORT, 'low');
252
+ }
253
+ return normalizeClaudeEffort(env.DEXTER_BRIDGE_CLAUDE_MAIN_EFFORT, 'medium');
254
+ }
255
+
256
+ function argsWithClaudeModelEngine(args, definition, options = {}, env = process.env) {
257
+ if (definition.id !== 'claude-code') return args;
258
+ let isolated = argsWithoutFlagValue(args, '--system-prompt');
259
+ isolated = argsWithoutFlagValue(isolated, '--append-system-prompt');
260
+ isolated = argsWithoutFlagValue(isolated, '--agent');
261
+ isolated = argsWithoutFlagValue(isolated, '--effort');
262
+ return [
263
+ ...isolated,
264
+ '--system-prompt',
265
+ CLAUDE_MODEL_ENGINE_SYSTEM_PROMPT,
266
+ '--effort',
267
+ claudeEffortForStep(options.step, env),
268
+ ];
269
+ }
270
+
217
271
  function argsWithoutFlagValue(args, flag) {
218
272
  const filtered = [];
219
273
  for (let index = 0; index < args.length; index += 1) {
@@ -316,7 +370,8 @@ export function buildAgentArgs(definition, modelDefinition, env = process.env, o
316
370
  const modelArgs = argsWithSelectedModel(requiredArgs, modelDefinition, definition);
317
371
  const structuredArgs = argsWithStructuredOutput(modelArgs, definition, env);
318
372
  const isolatedArgs = argsWithClaudeIsolation(structuredArgs, definition);
319
- const schemaArgs = argsWithOutputSchema(isolatedArgs, definition, options.outputSchema);
373
+ const modelEngineArgs = argsWithClaudeModelEngine(isolatedArgs, definition, options, env);
374
+ const schemaArgs = argsWithOutputSchema(modelEngineArgs, definition, options.outputSchema);
320
375
  const resumedArgs = argsWithResumedSession(schemaArgs, definition, options.resumeSessionId);
321
376
  return argsWithPromptInput(resumedArgs, definition);
322
377
  }
@@ -593,12 +648,17 @@ export function agentFailureMessage(command, code, stdout = '', stderr = '') {
593
648
 
594
649
  function runProcess(command, args, stdin, {
595
650
  timeoutMs = 120000,
651
+ maxDurationMs,
596
652
  trace,
597
653
  childEnv: providedChildEnv,
598
654
  platform = process.platform,
599
655
  } = {}) {
600
656
  return new Promise((resolve, reject) => {
601
657
  const started = Date.now();
658
+ // timeoutMs is an inactivity timeout (reset whenever the child produces
659
+ // output) so slow-but-streaming model turns are not killed mid-generation;
660
+ // hardDeadlineMs bounds total wall-clock time regardless of activity.
661
+ const hardDeadlineMs = Math.max(maxDurationMs || timeoutMs * 5, 10);
602
662
  const childEnv = providedChildEnv || processEnvWithCliPath(platform);
603
663
  const cwd = resolveAgentCwd(childEnv, process.cwd(), platform);
604
664
  const invocation = processInvocation(command, args, childEnv, platform);
@@ -610,6 +670,7 @@ function runProcess(command, args, stdin, {
610
670
  env: processEnvSummary(childEnv),
611
671
  stdinChars: stdin.length,
612
672
  timeoutMs,
673
+ maxDurationMs: hardDeadlineMs,
613
674
  });
614
675
  const child = spawn(invocation.command, invocation.args, {
615
676
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -620,33 +681,54 @@ function runProcess(command, args, stdin, {
620
681
  let stdout = '';
621
682
  let stderr = '';
622
683
  let settled = false;
623
- const timer = setTimeout(() => {
684
+ let inactivityTimer = null;
685
+ const timeOut = (message) => {
624
686
  if (settled) return;
625
687
  settled = true;
688
+ clearTimeout(inactivityTimer);
689
+ clearTimeout(deadlineTimer);
626
690
  child.kill('SIGTERM');
627
- const error = new Error(`${command} timed out after ${timeoutMs}ms.`);
691
+ const error = new Error(message);
692
+ error.code = 'AGENT_TIMEOUT';
693
+ error.stdout = stdout;
694
+ error.stderr = stderr;
695
+ error.timedOut = true;
628
696
  trace?.error('agent_process_timeout', {
629
697
  command,
630
698
  durationMs: Date.now() - started,
631
699
  timeoutMs,
700
+ maxDurationMs: hardDeadlineMs,
632
701
  stdoutChars: stdout.length,
633
702
  stderrChars: stderr.length,
634
703
  stdoutExcerpt: clip(stdout, 1000),
635
704
  stderrExcerpt: clip(stderr, 1000),
636
705
  });
637
706
  reject(error);
638
- }, timeoutMs);
707
+ };
708
+ const armInactivityTimer = () => {
709
+ clearTimeout(inactivityTimer);
710
+ inactivityTimer = setTimeout(() => {
711
+ timeOut(`${command} produced no output for ${timeoutMs}ms.`);
712
+ }, timeoutMs);
713
+ };
714
+ const deadlineTimer = setTimeout(() => {
715
+ timeOut(`${command} timed out after ${hardDeadlineMs}ms.`);
716
+ }, hardDeadlineMs);
717
+ armInactivityTimer();
639
718
 
640
719
  child.stdout.on('data', (chunk) => {
641
720
  stdout += chunk.toString('utf8');
721
+ armInactivityTimer();
642
722
  });
643
723
  child.stderr.on('data', (chunk) => {
644
724
  stderr += chunk.toString('utf8');
725
+ armInactivityTimer();
645
726
  });
646
727
  child.on('error', (error) => {
647
728
  if (settled) return;
648
729
  settled = true;
649
- clearTimeout(timer);
730
+ clearTimeout(inactivityTimer);
731
+ clearTimeout(deadlineTimer);
650
732
  trace?.error('agent_process_error', {
651
733
  command,
652
734
  durationMs: Date.now() - started,
@@ -657,7 +739,8 @@ function runProcess(command, args, stdin, {
657
739
  child.on('close', (code) => {
658
740
  if (settled) return;
659
741
  settled = true;
660
- clearTimeout(timer);
742
+ clearTimeout(inactivityTimer);
743
+ clearTimeout(deadlineTimer);
661
744
  const meta = {
662
745
  command,
663
746
  code,
@@ -834,8 +917,14 @@ async function callLocalJsonAgent(agent, prompt, options = {}) {
834
917
  const args = buildAgentArgs(definition, modelDefinition, process.env, {
835
918
  resumeSessionId: options.resumeSessionId,
836
919
  outputSchema: options.outputSchema,
920
+ step: options.step,
837
921
  });
838
922
  const timeoutMs = options.timeoutMs || Number(process.env.DEXTER_BRIDGE_AGENT_TIMEOUT_MS || 120000);
923
+ const maxDurationMs = boundedDurationMs(
924
+ options.maxDurationMs ?? process.env.DEXTER_BRIDGE_AGENT_MAX_DURATION_MS,
925
+ Math.max(timeoutMs * 5, 600000),
926
+ 10,
927
+ );
839
928
  options.trace?.info('agent_step_invoke', {
840
929
  agent: definition.id,
841
930
  command,
@@ -847,13 +936,28 @@ async function callLocalJsonAgent(agent, prompt, options = {}) {
847
936
  promptChars: prompt.length,
848
937
  resumeSessionId: options.resumeSessionId,
849
938
  timeoutMs,
939
+ maxDurationMs,
850
940
  });
851
941
  return runProcess(command, args, prompt, {
852
942
  timeoutMs,
943
+ maxDurationMs,
853
944
  trace: options.trace,
854
945
  });
855
946
  }
856
947
 
948
+ function agentErrorWithOutputUsage(agent, error) {
949
+ const failure = error instanceof Error
950
+ ? error
951
+ : new Error(String(error || 'The companion model call failed.'));
952
+ const parsed = parseAgentOutput(agent, failure.stdout || '');
953
+ if (!parsed.usageAvailable) return failure;
954
+ const accumulator = createCompanionUsageAccumulator(agent);
955
+ accumulator.add(failure.companionUsage || {});
956
+ accumulator.add(parsed);
957
+ failure.companionUsage = accumulator.snapshot();
958
+ return failure;
959
+ }
960
+
857
961
  async function executeModelTurnRun(run, send, agent, options = {}) {
858
962
  if (normalizeAgentName(agent) === 'dry-run') {
859
963
  await send('done', {
@@ -952,6 +1056,13 @@ async function executeModelTurnRun(run, send, agent, options = {}) {
952
1056
  120000,
953
1057
  1000,
954
1058
  ),
1059
+ maxDurationMs: boundedDurationMs(
1060
+ run?.modelTurn?.maxDurationMs
1061
+ ?? options.maxDurationMs
1062
+ ?? options.env?.DEXTER_BRIDGE_AGENT_MAX_DURATION_MS,
1063
+ 600000,
1064
+ 1000,
1065
+ ),
955
1066
  };
956
1067
  const adapterOptions = {
957
1068
  send,
@@ -1006,15 +1117,19 @@ async function executeModelTurnRun(run, send, agent, options = {}) {
1006
1117
  runtime,
1007
1118
  resumeSessionId,
1008
1119
  outputSchema: modelTurnOutputSchema(run.modelTurn),
1120
+ step: run?.modelTurn?.step,
1121
+ maxDurationMs: run?.modelTurn?.maxDurationMs ?? options.maxDurationMs,
1009
1122
  });
1010
1123
  } catch (error) {
1011
- if (callContextMode !== 'delta' || !resumeFailure(error)) throw failedModelCall(error);
1124
+ const failure = agentErrorWithOutputUsage(definition.id, error);
1125
+ if (callContextMode !== 'delta' || !resumeFailure(failure)) throw failedModelCall(failure);
1126
+ usageAccumulator.add(failure.companionUsage || {});
1012
1127
  fallbackAfterResumeFailure = true;
1013
1128
  callContextMode = 'full';
1014
1129
  prompt = buildModelTurnFallbackPrompt(run.modelTurn);
1015
1130
  options.trace?.warn('agent_cli_resume_fallback', {
1016
1131
  resumeSessionId,
1017
- error: errorMeta(error),
1132
+ error: errorMeta(failure),
1018
1133
  fallbackPromptChars: prompt.length,
1019
1134
  });
1020
1135
  result = await callLocalJsonAgent(definition.id, prompt, {
@@ -1023,8 +1138,10 @@ async function executeModelTurnRun(run, send, agent, options = {}) {
1023
1138
  runtime,
1024
1139
  resumeSessionId: undefined,
1025
1140
  outputSchema: modelTurnOutputSchema(run.modelTurn),
1141
+ step: run?.modelTurn?.step,
1142
+ maxDurationMs: run?.modelTurn?.maxDurationMs ?? options.maxDurationMs,
1026
1143
  }).catch((error) => {
1027
- throw failedModelCall(error);
1144
+ throw failedModelCall(agentErrorWithOutputUsage(definition.id, error));
1028
1145
  });
1029
1146
  }
1030
1147
  const parsed = parseAgentOutput(definition.id, result.stdout);
@@ -79,11 +79,25 @@ function parseWholeJson(stdout) {
79
79
  }
80
80
  }
81
81
 
82
- export function parseClaudeOutput(stdout) {
83
- const parsed = parseWholeJson(stdout);
82
+ function parseJsonLines(stdout) {
83
+ return String(stdout || '')
84
+ .split(/\r?\n/)
85
+ .map((line) => line.trim())
86
+ .filter(Boolean)
87
+ .flatMap((line) => {
88
+ try {
89
+ const parsed = JSON.parse(line);
90
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? [parsed] : [];
91
+ } catch {
92
+ return [];
93
+ }
94
+ });
95
+ }
96
+
97
+ function parseClaudeEnvelope(parsed, fallbackText = '') {
84
98
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
85
99
  return {
86
- resultText: String(stdout || '').trim(),
100
+ resultText: String(fallbackText || '').trim(),
87
101
  tokenUsage: normalizeCompanionTokenUsage(),
88
102
  modelUsage: [],
89
103
  usageAvailable: false,
@@ -101,7 +115,7 @@ export function parseClaudeOutput(stdout) {
101
115
  || parsed.modelUsage;
102
116
  if (!hasEnvelope) {
103
117
  return {
104
- resultText: String(stdout || '').trim(),
118
+ resultText: String(fallbackText || '').trim(),
105
119
  tokenUsage: normalizeCompanionTokenUsage(),
106
120
  modelUsage: [],
107
121
  usageAvailable: false,
@@ -136,19 +150,111 @@ export function parseClaudeOutput(stdout) {
136
150
  };
137
151
  }
138
152
 
139
- function parseJsonLines(stdout) {
140
- return String(stdout || '')
141
- .split(/\r?\n/)
142
- .map((line) => line.trim())
143
- .filter(Boolean)
144
- .flatMap((line) => {
145
- try {
146
- const parsed = JSON.parse(line);
147
- return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? [parsed] : [];
148
- } catch {
149
- return [];
153
+ function mergeMaximumUsage(target, rawUsage) {
154
+ const usage = normalizeCompanionTokenUsage(rawUsage || {});
155
+ for (const field of TOKEN_FIELDS) target[field] = Math.max(target[field], usage[field]);
156
+ target.totalTokens = target.inputTokens + target.outputTokens + target.reasoningOutputTokens;
157
+ }
158
+
159
+ function parseClaudeStreamUsage(events) {
160
+ const turns = new Map();
161
+ let activeTurnKey;
162
+ let anonymousSequence = 0;
163
+ let sessionId;
164
+ let partialText = '';
165
+
166
+ const ensureTurn = (key, model) => {
167
+ const resolvedKey = key || activeTurnKey || `turn-${++anonymousSequence}`;
168
+ if (!turns.has(resolvedKey)) {
169
+ turns.set(resolvedKey, {
170
+ model: typeof model === 'string' && model.trim() ? model.trim() : undefined,
171
+ usage: normalizeCompanionTokenUsage(),
172
+ });
173
+ } else if (model && !turns.get(resolvedKey).model) {
174
+ turns.get(resolvedKey).model = model;
175
+ }
176
+ activeTurnKey = resolvedKey;
177
+ return turns.get(resolvedKey);
178
+ };
179
+
180
+ for (const row of events) {
181
+ if (
182
+ (row.type === 'system' || row.type === 'init')
183
+ && typeof (row.session_id || row.sessionId) === 'string'
184
+ ) {
185
+ sessionId = row.session_id || row.sessionId;
186
+ }
187
+
188
+ if (row.type === 'assistant' && row.message && typeof row.message === 'object') {
189
+ const message = row.message;
190
+ const turn = ensureTurn(message.id, message.model);
191
+ mergeMaximumUsage(turn.usage, message.usage);
192
+ for (const part of Array.isArray(message.content) ? message.content : []) {
193
+ if (part?.type === 'text' && typeof part.text === 'string') partialText += part.text;
150
194
  }
151
- });
195
+ }
196
+
197
+ if (row.type !== 'stream_event' || !row.event || typeof row.event !== 'object') continue;
198
+ const event = row.event;
199
+ if (event.type === 'message_start' && event.message && typeof event.message === 'object') {
200
+ const turn = ensureTurn(event.message.id, event.message.model);
201
+ mergeMaximumUsage(turn.usage, event.message.usage);
202
+ continue;
203
+ }
204
+ if (event.type === 'message_delta') {
205
+ const turn = ensureTurn(activeTurnKey);
206
+ mergeMaximumUsage(turn.usage, event.usage);
207
+ continue;
208
+ }
209
+ if (
210
+ event.type === 'content_block_delta'
211
+ && event.delta?.type === 'text_delta'
212
+ && typeof event.delta.text === 'string'
213
+ ) {
214
+ partialText += event.delta.text;
215
+ }
216
+ }
217
+
218
+ const tokenUsage = normalizeCompanionTokenUsage();
219
+ const modelTotals = new Map();
220
+ for (const turn of turns.values()) {
221
+ for (const field of TOKEN_FIELDS) tokenUsage[field] += turn.usage[field];
222
+ tokenUsage.totalTokens += turn.usage.totalTokens;
223
+ if (!turn.model) continue;
224
+ const current = modelTotals.get(turn.model) || {
225
+ model: turn.model,
226
+ ...normalizeCompanionTokenUsage(),
227
+ };
228
+ for (const field of TOKEN_FIELDS) current[field] += turn.usage[field];
229
+ current.totalTokens += turn.usage.totalTokens;
230
+ modelTotals.set(turn.model, current);
231
+ }
232
+ const usageAvailable = usageHasReportedTokens(tokenUsage);
233
+ return {
234
+ resultText: partialText.trim(),
235
+ tokenUsage,
236
+ modelUsage: Array.from(modelTotals.values()),
237
+ sessionId,
238
+ usageAvailable,
239
+ usageSource: 'claude-code',
240
+ usageAccuracy: usageAvailable ? 'reported' : 'unavailable',
241
+ };
242
+ }
243
+
244
+ export function parseClaudeOutput(stdout) {
245
+ const whole = parseWholeJson(stdout);
246
+ if (whole && typeof whole === 'object' && !Array.isArray(whole)) {
247
+ return parseClaudeEnvelope(whole, stdout);
248
+ }
249
+ const events = parseJsonLines(stdout);
250
+ const resultEnvelope = [...events].reverse().find((event) =>
251
+ event.type === 'result'
252
+ || event.structured_output !== undefined
253
+ || event.structuredOutput !== undefined
254
+ || typeof event.result === 'string');
255
+ if (resultEnvelope) return parseClaudeEnvelope(resultEnvelope, stdout);
256
+ if (events.length) return parseClaudeStreamUsage(events);
257
+ return parseClaudeEnvelope(null, stdout);
152
258
  }
153
259
 
154
260
  export function parseCodexOutput(stdout) {
package/src/config.js CHANGED
@@ -3,7 +3,9 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
 
5
5
  export const DEFAULT_API_BASE_URL = 'http://localhost:3800/iwm-api/0.0.1';
6
- export const BRIDGE_VERSION = '0.5.1';
6
+ export const BRIDGE_VERSION = JSON.parse(
7
+ fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
8
+ ).version;
7
9
  export const BRIDGE_CAPABILITIES = [
8
10
  'model-turn-v1',
9
11
  ];