mobygate 0.8.0 → 0.8.2

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/inspector.html ADDED
@@ -0,0 +1,422 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>mobygate — inspector</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=VT323&display=swap" rel="stylesheet">
10
+ <style>
11
+ :root {
12
+ --bg: #0B0B09;
13
+ --bg-2: #14140F;
14
+ --bg-3: #1F1F17;
15
+ --fg: #F3EFE4;
16
+ --muted: #5A5F54;
17
+ --dim: #8A8F84;
18
+ --accent: #B7E56D;
19
+ --warn: #F1B05A;
20
+ --crit: #E66B5C;
21
+ --hit: #6CC994;
22
+ --border: #2A2A1F;
23
+ }
24
+ * { box-sizing: border-box; }
25
+ html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg); font-family: 'JetBrains Mono', ui-monospace, Menlo, monospace; font-size: 13px; height: 100vh; overflow: hidden; }
26
+ .display { font-family: 'VT323', monospace; letter-spacing: 0.02em; }
27
+ a { color: var(--accent); text-decoration: none; }
28
+ a:hover { text-decoration: underline; }
29
+ button { font-family: inherit; font-size: inherit; }
30
+ pre, code { font-family: 'JetBrains Mono', ui-monospace, monospace; }
31
+
32
+ header {
33
+ display: flex; align-items: center; gap: 16px;
34
+ padding: 12px 20px; border-bottom: 1px solid var(--border);
35
+ background: var(--bg-2);
36
+ }
37
+ header .title { font-size: 28px; line-height: 1; color: var(--accent); }
38
+ header .breadcrumb { color: var(--dim); font-size: 12px; }
39
+ header .spacer { flex: 1; }
40
+ header .toggle {
41
+ display: flex; align-items: center; gap: 8px;
42
+ padding: 6px 12px; border: 1px solid var(--border);
43
+ background: var(--bg-3); cursor: pointer; color: var(--fg);
44
+ }
45
+ header .toggle.on { border-color: var(--accent); color: var(--accent); }
46
+ header .toggle .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
47
+ header .toggle.on .dot { background: var(--accent); animation: pulse 1.8s infinite; }
48
+ @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
49
+
50
+ .layout { display: grid; grid-template-columns: 460px 1fr; height: calc(100vh - 53px); }
51
+
52
+ .list-pane { border-right: 1px solid var(--border); overflow-y: auto; background: var(--bg); }
53
+ .list-stats { padding: 10px 16px; color: var(--dim); font-size: 11px; border-bottom: 1px solid var(--border); }
54
+ .row {
55
+ padding: 10px 16px; border-bottom: 1px solid var(--border);
56
+ cursor: pointer; transition: background .1s;
57
+ }
58
+ .row:hover { background: var(--bg-2); }
59
+ .row.active { background: var(--bg-3); border-left: 3px solid var(--accent); padding-left: 13px; }
60
+ .row .r1 { display: flex; gap: 8px; align-items: baseline; margin-bottom: 4px; }
61
+ .row .r1 .ts { color: var(--dim); font-size: 11px; }
62
+ .row .r1 .path { color: var(--fg); font-size: 12px; }
63
+ .row .r1 .path.native { color: var(--accent); }
64
+ .row .r2 { color: var(--dim); font-size: 11px; display: flex; gap: 12px; flex-wrap: wrap; }
65
+ .row .r2 .pill { padding: 1px 6px; background: var(--bg-3); border-radius: 2px; }
66
+ .row .r2 .hit { color: var(--hit); }
67
+ .row .r2 .nohit { color: var(--warn); }
68
+
69
+ .detail-pane { overflow-y: auto; padding: 20px 28px; }
70
+ .detail-pane.empty { display: flex; align-items: center; justify-content: center; color: var(--dim); }
71
+
72
+ .stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 24px; }
73
+ .stat {
74
+ padding: 12px 14px; border: 1px solid var(--border); background: var(--bg-2);
75
+ }
76
+ .stat .label { color: var(--dim); font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 4px; }
77
+ .stat .val { color: var(--fg); font-family: 'VT323', monospace; font-size: 24px; line-height: 1; }
78
+ .stat .val.accent { color: var(--accent); }
79
+ .stat .val.warn { color: var(--warn); }
80
+ .stat .val.crit { color: var(--crit); }
81
+ .stat .sub { color: var(--dim); font-size: 11px; margin-top: 2px; }
82
+
83
+ .section { margin-bottom: 24px; }
84
+ .section h3 { color: var(--accent); font-size: 12px; margin: 0 0 10px; text-transform: uppercase; letter-spacing: 0.05em; }
85
+
86
+ .sys-block {
87
+ padding: 8px 12px; margin-bottom: 4px;
88
+ border-left: 2px solid var(--border); background: var(--bg-2);
89
+ font-size: 11px;
90
+ }
91
+ .sys-block.cached { border-left-color: var(--hit); }
92
+ .sys-block .head { color: var(--dim); margin-bottom: 4px; }
93
+ .sys-block .head .marker { color: var(--hit); margin-left: 8px; }
94
+ .sys-block .preview { color: var(--fg); white-space: pre-wrap; max-height: 80px; overflow: hidden; opacity: 0.7; }
95
+
96
+ .msg-row { padding: 6px 0; border-bottom: 1px solid var(--border); display: grid; grid-template-columns: 40px 80px 70px 1fr; gap: 12px; align-items: center; font-size: 11px; }
97
+ .msg-row .idx { color: var(--dim); text-align: right; }
98
+ .msg-row .role { padding: 1px 6px; background: var(--bg-3); text-align: center; }
99
+ .msg-row .role.user { color: #88B4FF; }
100
+ .msg-row .role.assistant { color: var(--accent); }
101
+ .msg-row .role.tool { color: var(--warn); }
102
+ .msg-row .role.system { color: var(--crit); }
103
+ .msg-row .bytes { color: var(--dim); text-align: right; }
104
+ .msg-row .preview { color: var(--fg); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; opacity: 0.85; }
105
+ .msg-row.cached { background: rgba(108, 201, 148, 0.04); }
106
+
107
+ .tool-list { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
108
+ .tool { padding: 6px 10px; background: var(--bg-2); border: 1px solid var(--border); font-size: 11px; }
109
+
110
+ .usage-grid { display: grid; grid-template-columns: 2fr 1fr 1fr 1fr; gap: 8px; font-size: 11px; }
111
+ .usage-grid > div { padding: 8px 12px; background: var(--bg-2); border: 1px solid var(--border); }
112
+ .usage-grid .label { color: var(--dim); text-transform: uppercase; letter-spacing: 0.05em; font-size: 10px; }
113
+ .usage-grid .val { color: var(--fg); font-size: 18px; font-family: 'VT323', monospace; line-height: 1; margin-top: 4px; }
114
+ .usage-grid .val.hit { color: var(--hit); }
115
+ .usage-grid .val.dim { color: var(--dim); }
116
+
117
+ .placeholder { color: var(--dim); font-size: 12px; padding: 16px; text-align: center; }
118
+
119
+ /* Scrollbar */
120
+ ::-webkit-scrollbar { width: 8px; height: 8px; }
121
+ ::-webkit-scrollbar-track { background: var(--bg); }
122
+ ::-webkit-scrollbar-thumb { background: var(--border); }
123
+ ::-webkit-scrollbar-thumb:hover { background: var(--muted); }
124
+ </style>
125
+ </head>
126
+ <body>
127
+ <header>
128
+ <div class="title display">mobygate :: inspector</div>
129
+ <div class="breadcrumb">~/.mobygate/captures/</div>
130
+ <div class="spacer"></div>
131
+ <button id="toggle" class="toggle">
132
+ <span class="dot"></span>
133
+ <span id="toggle-label">capture: ?</span>
134
+ </button>
135
+ <a href="/">← dashboard</a>
136
+ </header>
137
+
138
+ <div class="layout">
139
+ <div class="list-pane" id="list-pane">
140
+ <div class="list-stats" id="list-stats">loading…</div>
141
+ <div id="list"></div>
142
+ </div>
143
+ <div class="detail-pane empty" id="detail">
144
+ <div>Select a capture from the left to inspect.</div>
145
+ </div>
146
+ </div>
147
+
148
+ <script>
149
+ let selectedFilename = null;
150
+ let captureCache = []; // last fetch result, used to compute deltas
151
+
152
+ // ──────────── helpers ────────────
153
+
154
+ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
155
+ const fmt = (n) => Number(n || 0).toLocaleString();
156
+ const fmtBytes = (b) => {
157
+ if (b < 1024) return b + ' B';
158
+ if (b < 1024*1024) return (b/1024).toFixed(1) + ' KB';
159
+ return (b/(1024*1024)).toFixed(1) + ' MB';
160
+ };
161
+ const fmtTime = (ms) => {
162
+ const d = new Date(ms);
163
+ return d.toLocaleTimeString('en-US', { hour12: false });
164
+ };
165
+
166
+ // ──────────── capture toggle ────────────
167
+
168
+ async function refreshToggleState() {
169
+ try {
170
+ const r = await fetch('/dashboard/captures-state');
171
+ const s = await r.json();
172
+ const btn = document.getElementById('toggle');
173
+ const lbl = document.getElementById('toggle-label');
174
+ btn.classList.toggle('on', !!s.enabled);
175
+ lbl.textContent = 'capture: ' + (s.enabled ? 'ON' : 'off') + (s.envVar ? ' (env)' : '');
176
+ } catch {}
177
+ }
178
+
179
+ document.getElementById('toggle').addEventListener('click', async () => {
180
+ const cur = document.getElementById('toggle').classList.contains('on');
181
+ try {
182
+ await fetch('/dashboard/captures-toggle', {
183
+ method: 'POST',
184
+ headers: { 'content-type': 'application/json' },
185
+ body: JSON.stringify({ enabled: !cur }),
186
+ });
187
+ refreshToggleState();
188
+ } catch {}
189
+ });
190
+
191
+ // ──────────── list rendering ────────────
192
+
193
+ async function refreshList() {
194
+ try {
195
+ const r = await fetch('/dashboard/captures?limit=200');
196
+ const data = await r.json();
197
+ captureCache = data.captures || [];
198
+ renderList(captureCache, data);
199
+ } catch (e) {
200
+ document.getElementById('list-stats').textContent = 'failed: ' + e.message;
201
+ }
202
+ }
203
+
204
+ function renderList(captures, meta) {
205
+ const stats = document.getElementById('list-stats');
206
+ if (!captures.length) {
207
+ stats.textContent = 'no captures yet — toggle capture on and send a request';
208
+ document.getElementById('list').innerHTML = '';
209
+ return;
210
+ }
211
+ stats.textContent = `${captures.length} captures · ${meta.total} total`;
212
+
213
+ const html = captures.map(c => {
214
+ const isNative = c.path === '/v1/messages';
215
+ const hitClass = c.cacheHitPct == null ? '' : (parseFloat(c.cacheHitPct) > 50 ? 'hit' : 'nohit');
216
+ const hitText = c.cacheHitPct == null ? 'no resp yet' : `${c.cacheHitPct}% cache`;
217
+ const sel = c.filename === selectedFilename ? ' active' : '';
218
+ return `<div class="row${sel}" data-fn="${esc(c.filename)}">
219
+ <div class="r1">
220
+ <span class="ts">${fmtTime(c.ts)}</span>
221
+ <span class="path${isNative ? ' native' : ''}">${esc(c.path)}</span>
222
+ <span class="ts" style="margin-left:auto">${esc(c.model || '')}</span>
223
+ </div>
224
+ <div class="r2">
225
+ <span class="pill">${c.msgCount} msgs</span>
226
+ <span class="pill">${fmt(c.grandTokens)} tok</span>
227
+ <span class="pill ${hitClass}">${hitText}</span>
228
+ ${c.cacheControlSystem ? `<span class="pill">sys-cache ${esc(c.cacheControlSystem)}</span>` : ''}
229
+ </div>
230
+ </div>`;
231
+ }).join('');
232
+ document.getElementById('list').innerHTML = html;
233
+
234
+ // Wire row clicks
235
+ document.querySelectorAll('.row').forEach(el => {
236
+ el.addEventListener('click', () => loadDetail(el.dataset.fn));
237
+ });
238
+ }
239
+
240
+ // ──────────── detail rendering ────────────
241
+
242
+ async function loadDetail(filename) {
243
+ selectedFilename = filename;
244
+ document.querySelectorAll('.row').forEach(r => r.classList.toggle('active', r.dataset.fn === filename));
245
+ const detail = document.getElementById('detail');
246
+ detail.classList.remove('empty');
247
+ detail.innerHTML = `<div class="placeholder">loading ${esc(filename)}…</div>`;
248
+ try {
249
+ const r = await fetch('/dashboard/captures/' + encodeURIComponent(filename));
250
+ const data = await r.json();
251
+ renderDetail(data);
252
+ } catch (e) {
253
+ detail.innerHTML = `<div class="placeholder">failed: ${esc(e.message)}</div>`;
254
+ }
255
+ }
256
+
257
+ function renderDetail({ filename, body, summary }) {
258
+ const sysBlocks = Array.isArray(body.system) ? body.system : (typeof body.system === 'string' ? [{ type: 'text', text: body.system }] : []);
259
+ const msgs = body.messages || [];
260
+ const tools = body.tools || [];
261
+ const sysBytes = sysBlocks.reduce((a, b) => a + (b?.text?.length || 0), 0);
262
+ const msgBytes = msgs.reduce((a, m) => {
263
+ if (typeof m.content === 'string') return a + m.content.length;
264
+ if (Array.isArray(m.content)) return a + m.content.reduce((aa, b) => aa + (b?.text?.length || 0) + (b?.input ? JSON.stringify(b.input).length : 0) + (typeof b?.content === 'string' ? b.content.length : 0), 0);
265
+ return a;
266
+ }, 0);
267
+ const toolBytes = JSON.stringify(tools).length;
268
+ const grand = sysBytes + msgBytes + toolBytes;
269
+
270
+ // Pull response stats out of the appended summary text
271
+ const grab = (re) => { const m = summary.match(re); return m ? m[1].trim() : null; };
272
+ const inputUncached = parseInt(grab(/input_tokens \(uncached\):\s+(\d+)/) || '0', 10);
273
+ const cacheRead = parseInt(grab(/cache_read_input_tokens:\s+(\d+)/) || '0', 10);
274
+ const cacheCreate = parseInt(grab(/cache_creation_input_tokens:\s+(\d+)/) || '0', 10);
275
+ const outputTokens = parseInt(grab(/output_tokens:\s+(\d+)/) || '0', 10);
276
+ const cacheHit = grab(/cache hit rate:\s+([\d.]+)%/);
277
+ const savings = grab(/savings from cache:\s+([\d.]+)%/);
278
+ const duration = grab(/duration:\s+(\d+) ms/);
279
+
280
+ const sysHtml = sysBlocks.length === 0
281
+ ? '<div class="placeholder">no system block</div>'
282
+ : sysBlocks.map((b, i) => {
283
+ const cached = !!b.cache_control;
284
+ const preview = (b.text || '').slice(0, 600);
285
+ return `<div class="sys-block${cached ? ' cached' : ''}">
286
+ <div class="head">[${i}] ${esc(b.type || '?')} · ${fmt((b.text||'').length)} bytes${cached ? `<span class="marker">cache_control: ${esc(JSON.stringify(b.cache_control))}</span>` : ''}</div>
287
+ <div class="preview">${esc(preview)}${(b.text||'').length > 600 ? '…' : ''}</div>
288
+ </div>`;
289
+ }).join('');
290
+
291
+ const msgHtml = msgs.length > 60
292
+ ? renderTrimmedMessages(msgs)
293
+ : msgs.map((m, i) => renderMessageRow(m, i)).join('');
294
+
295
+ const toolsHtml = tools.length === 0
296
+ ? '<div class="placeholder">no tools declared</div>'
297
+ : `<div class="tool-list">${tools.map(t => {
298
+ const name = t.name || t.function?.name || '(unnamed)';
299
+ return `<div class="tool">${esc(name)}</div>`;
300
+ }).join('')}</div>`;
301
+
302
+ const usageHtml = (cacheRead + cacheCreate + inputUncached + outputTokens === 0)
303
+ ? '<div class="placeholder">response not captured yet (request still in flight, or capture was off when response landed)</div>'
304
+ : `<div class="usage-grid">
305
+ <div>
306
+ <div class="label">cache hit rate</div>
307
+ <div class="val hit">${cacheHit || '0'}%</div>
308
+ <div class="label" style="margin-top:6px">savings</div>
309
+ <div class="val ${parseFloat(savings)>50?'hit':''}" style="font-size:14px">${savings || '0'}%</div>
310
+ </div>
311
+ <div>
312
+ <div class="label">input (uncached)</div>
313
+ <div class="val">${fmt(inputUncached)}</div>
314
+ </div>
315
+ <div>
316
+ <div class="label">cache read</div>
317
+ <div class="val hit">${fmt(cacheRead)}</div>
318
+ </div>
319
+ <div>
320
+ <div class="label">cache create</div>
321
+ <div class="val ${cacheCreate>0?'':'dim'}">${fmt(cacheCreate)}</div>
322
+ <div class="label" style="margin-top:6px">output</div>
323
+ <div class="val" style="font-size:14px">${fmt(outputTokens)}</div>
324
+ </div>
325
+ </div>`;
326
+
327
+ document.getElementById('detail').innerHTML = `
328
+ <div class="stat-grid">
329
+ <div class="stat">
330
+ <div class="label">model</div>
331
+ <div class="val accent">${esc(body.model || '?')}</div>
332
+ <div class="sub">${body.stream ? 'streaming' : 'sync'} · ${duration ? duration + ' ms' : '—'}</div>
333
+ </div>
334
+ <div class="stat">
335
+ <div class="label">messages</div>
336
+ <div class="val">${msgs.length}</div>
337
+ <div class="sub">${fmtBytes(msgBytes)}</div>
338
+ </div>
339
+ <div class="stat">
340
+ <div class="label">total wire</div>
341
+ <div class="val">${fmt(Math.round(grand/4))}</div>
342
+ <div class="sub">~tokens · ${fmtBytes(grand)}</div>
343
+ </div>
344
+ <div class="stat">
345
+ <div class="label">system block</div>
346
+ <div class="val ${sysBlocks.some(b=>b.cache_control)?'accent':'warn'}">${fmtBytes(sysBytes)}</div>
347
+ <div class="sub">${sysBlocks.length} block(s) · ${sysBlocks.filter(b=>b.cache_control).length} cached</div>
348
+ </div>
349
+ </div>
350
+
351
+ <div class="section">
352
+ <h3>Response usage</h3>
353
+ ${usageHtml}
354
+ </div>
355
+
356
+ <div class="section">
357
+ <h3>System blocks</h3>
358
+ ${sysHtml}
359
+ </div>
360
+
361
+ <div class="section">
362
+ <h3>Messages timeline (${msgs.length})</h3>
363
+ ${msgHtml}
364
+ </div>
365
+
366
+ <div class="section">
367
+ <h3>Tools (${tools.length})</h3>
368
+ ${toolsHtml}
369
+ </div>
370
+
371
+ <div class="section">
372
+ <h3>Raw summary</h3>
373
+ <pre style="background:var(--bg-2);padding:12px;border:1px solid var(--border);font-size:11px;color:var(--dim);white-space:pre-wrap;max-height:300px;overflow:auto">${esc(summary)}</pre>
374
+ </div>
375
+ `;
376
+ }
377
+
378
+ function renderMessageRow(m, i) {
379
+ const role = m.role || '?';
380
+ const cached = Array.isArray(m.content) && m.content.some(b => b?.cache_control);
381
+ let preview = '';
382
+ let bytes = 0;
383
+ if (typeof m.content === 'string') {
384
+ preview = m.content.slice(0, 200);
385
+ bytes = m.content.length;
386
+ } else if (Array.isArray(m.content)) {
387
+ const parts = m.content.map(b => {
388
+ if (b?.type === 'text') return b.text || '';
389
+ if (b?.type === 'tool_use') return `🔧 ${b.name || '?'}(${JSON.stringify(b.input || {}).slice(0, 80)})`;
390
+ if (b?.type === 'tool_result') {
391
+ const tc = Array.isArray(b.content) ? (b.content.find(x => x.type === 'text')?.text || '') : (b.content || '');
392
+ return `← ${String(tc).slice(0, 100)}`;
393
+ }
394
+ if (b?.type === 'image') return '🖼️ [image]';
395
+ return JSON.stringify(b).slice(0, 80);
396
+ });
397
+ preview = parts.join(' · ').slice(0, 250);
398
+ bytes = m.content.reduce((a, b) => a + (b?.text?.length || 0) + (b?.input ? JSON.stringify(b.input).length : 0) + (typeof b?.content === 'string' ? b.content.length : 0), 0);
399
+ }
400
+ return `<div class="msg-row${cached ? ' cached' : ''}">
401
+ <span class="idx">${i}</span>
402
+ <span class="role ${role}">${role}</span>
403
+ <span class="bytes">${fmtBytes(bytes)}</span>
404
+ <span class="preview">${esc(preview)}${cached ? ' 🔒' : ''}</span>
405
+ </div>`;
406
+ }
407
+
408
+ function renderTrimmedMessages(msgs) {
409
+ // Show first 5 and last 10 for long conversations
410
+ const head = msgs.slice(0, 5).map((m, i) => renderMessageRow(m, i)).join('');
411
+ const tail = msgs.slice(-10).map((m, i) => renderMessageRow(m, msgs.length - 10 + i)).join('');
412
+ return head + `<div class="placeholder">… ${msgs.length - 15} messages collapsed …</div>` + tail;
413
+ }
414
+
415
+ // ──────────── kickoff ────────────
416
+
417
+ refreshToggleState();
418
+ refreshList();
419
+ setInterval(() => { refreshList(); refreshToggleState(); }, 3000);
420
+ </script>
421
+ </body>
422
+ </html>
package/lib/anthropic.js CHANGED
@@ -27,6 +27,29 @@
27
27
 
28
28
  import { v4 as uuidv4 } from 'uuid';
29
29
 
30
+ // ---------------------------------------------------------------------------
31
+ // SDK usage extraction
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /**
35
+ * Pulls the full token usage from an SDK 'result' message — defensive
36
+ * against shape variations (the Claude Agent SDK sometimes nests these
37
+ * under `.usage`, sometimes places them flat on the message). Returns
38
+ * a complete usage shape with cache_read / cache_creation fields zeroed
39
+ * out if absent. Used by the 4 mobygate handlers to populate response
40
+ * captures and dashboard cache-hit metrics.
41
+ */
42
+ export function extractSdkUsage(message) {
43
+ if (!message) return { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 };
44
+ const u = message.usage || message;
45
+ return {
46
+ input_tokens: u.input_tokens || 0,
47
+ output_tokens: u.output_tokens || 0,
48
+ cache_read_input_tokens: u.cache_read_input_tokens || 0,
49
+ cache_creation_input_tokens: u.cache_creation_input_tokens || 0,
50
+ };
51
+ }
52
+
30
53
  // ---------------------------------------------------------------------------
31
54
  // Content extraction — read individual block types out of an Anthropic message
32
55
  // ---------------------------------------------------------------------------
@@ -142,7 +142,9 @@ export const hermesConnector = {
142
142
  });
143
143
  const result = writeConfigSafe(plan.configPath, yamlOut);
144
144
  return {
145
- applied: true,
145
+ applied: !result.unchanged,
146
+ unchanged: !!result.unchanged,
147
+ reason: result.unchanged ? 'config already up-to-date (byte-identical)' : null,
146
148
  configPath: result.path,
147
149
  backupPath: result.backupPath,
148
150
  bytesWritten: result.bytesWritten,
@@ -124,12 +124,46 @@ export const openclawConnector = {
124
124
  return { configPath, parsed };
125
125
  },
126
126
 
127
- async inspect() {
127
+ async inspect({ baseUrl = DEFAULT_BASE_URL } = {}) {
128
128
  const det = await this.detect();
129
129
  if (!det) return { installed: false };
130
130
  if (det.parseError) return { installed: true, parseError: det.parseError };
131
131
 
132
132
  const providers = det.parsed?.models?.providers || {};
133
+
134
+ // Detect "shadow" providers: ones that point at our base URL but
135
+ // aren't registered under our canonical names. This catches the
136
+ // pre-v0.8.0 hand-rolled `claude-max-proxy` style configs that
137
+ // would otherwise silently bypass `mobygate connect`'s native
138
+ // surface — exactly the situation that caused OpenClaw to keep
139
+ // sending OpenAI-shape requests in the v0.8.0 → v0.8.1 era despite
140
+ // the connector having registered moby-native.
141
+ //
142
+ // For each shadow provider we report its name, current api type,
143
+ // and a recommendation. Surfacing this in inspect() (and the CLI)
144
+ // turns "why is the shape wrong?" from a forensics task into a
145
+ // single command.
146
+ const shadowProviders = [];
147
+ const baseHost = String(baseUrl).replace(/\/+$/, '');
148
+ for (const [name, p] of Object.entries(providers)) {
149
+ if (name === PROVIDER_NAME_OPENAI || name === PROVIDER_NAME_ANTHROPIC) continue;
150
+ if (!p?.baseUrl) continue;
151
+ const provHost = String(p.baseUrl).replace(/\/+$/, '');
152
+ // Match exact or with /v1 suffix; tolerate localhost vs 127.0.0.1.
153
+ const norm = (s) => s.replace('localhost', '127.0.0.1').replace(/\/v1$/, '');
154
+ if (norm(provHost) === norm(baseHost)) {
155
+ shadowProviders.push({
156
+ name,
157
+ api: p.api || '(unset)',
158
+ baseUrl: p.baseUrl,
159
+ recommendation: p.api === 'anthropic-messages'
160
+ ? `OK — already on native shape. Could rename to "${PROVIDER_NAME_ANTHROPIC}" for clarity.`
161
+ : `Flip api: "${p.api}" → "anthropic-messages" to enable cache_control + native blocks. ` +
162
+ `Or run \`mobygate connect openclaw\` to register canonical providers.`,
163
+ });
164
+ }
165
+ }
166
+
133
167
  return {
134
168
  installed: true,
135
169
  configPath: det.configPath,
@@ -137,6 +171,7 @@ export const openclawConnector = {
137
171
  mobyNativeProviderExists: !!providers[PROVIDER_NAME_ANTHROPIC],
138
172
  currentMain: det.parsed?.models?.main || null,
139
173
  currentDefault: det.parsed?.models?.default || null,
174
+ shadowProviders, // pre-v0.8.0 entries pointing at our base URL
140
175
  };
141
176
  },
142
177
 
@@ -205,7 +240,9 @@ export const openclawConnector = {
205
240
  const jsonOut = JSON.stringify(plan.after, null, 2) + '\n';
206
241
  const result = writeConfigSafe(plan.configPath, jsonOut);
207
242
  return {
208
- applied: true,
243
+ applied: !result.unchanged,
244
+ unchanged: !!result.unchanged,
245
+ reason: result.unchanged ? 'config already up-to-date (byte-identical)' : null,
209
246
  configPath: result.path,
210
247
  backupPath: result.backupPath,
211
248
  bytesWritten: result.bytesWritten,
@@ -64,6 +64,23 @@ export function writeConfigSafe(path, content) {
64
64
  const dir = dirname(path);
65
65
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
66
66
 
67
+ // Idempotency guard: if the existing on-disk content is byte-identical
68
+ // to what we'd write, skip the rewrite entirely. This prevents
69
+ // `mobygate connect <client>` from producing spurious "(changed)" diffs
70
+ // when re-run with no real change — a real bug seen in v0.8.0 where
71
+ // diffSummary's structural comparison disagreed with actual file bytes.
72
+ if (existsSync(path)) {
73
+ try {
74
+ const current = readFileSync(path, 'utf8');
75
+ if (current === content) {
76
+ return { path, backupPath: null, bytesWritten: 0, unchanged: true };
77
+ }
78
+ } catch {
79
+ // Read failure is non-fatal — we'll fall through to the normal write
80
+ // path which will surface any real I/O problem.
81
+ }
82
+ }
83
+
67
84
  const backupPath = backup(path);
68
85
  const tempPath = `${path}.mobygate-tmp-${ISO_SAFE()}`;
69
86
 
@@ -89,7 +106,7 @@ export function writeConfigSafe(path, content) {
89
106
  (backupPath ? ` (original preserved at ${backupPath})` : ''));
90
107
  }
91
108
 
92
- return { path, backupPath, bytesWritten: Buffer.byteLength(content, 'utf8') };
109
+ return { path, backupPath, bytesWritten: Buffer.byteLength(content, 'utf8'), unchanged: false };
93
110
  }
94
111
 
95
112
  /**