mixdog 0.9.40 → 0.9.43

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.
Files changed (66) hide show
  1. package/package.json +2 -1
  2. package/scripts/agent-tag-reuse-smoke.mjs +124 -10
  3. package/scripts/anthropic-oauth-refresh-race-test.mjs +397 -0
  4. package/scripts/arg-guard-test.mjs +16 -0
  5. package/scripts/compact-pressure-test.mjs +383 -0
  6. package/scripts/compact-trigger-migration-smoke.mjs +8 -8
  7. package/scripts/internal-comms-bench.mjs +0 -1
  8. package/scripts/internal-comms-smoke.mjs +54 -31
  9. package/scripts/internal-tools-normalization-test.mjs +52 -0
  10. package/scripts/max-output-recovery-persist-test.mjs +81 -0
  11. package/scripts/max-output-recovery-test.mjs +271 -0
  12. package/scripts/mcp-client-normalization-test.mjs +45 -0
  13. package/scripts/provider-toolcall-test.mjs +55 -0
  14. package/scripts/result-classification-test.mjs +75 -0
  15. package/scripts/smoke.mjs +1 -1
  16. package/scripts/submit-commandbusy-race-test.mjs +99 -7
  17. package/scripts/tui-transcript-perf-test.mjs +56 -1
  18. package/src/agents/reviewer/AGENT.md +8 -0
  19. package/src/rules/lead/lead-brief.md +11 -3
  20. package/src/rules/lead/lead-tool.md +0 -2
  21. package/src/rules/shared/01-tool.md +4 -0
  22. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +20 -2
  23. package/src/runtime/agent/orchestrator/context/collect.mjs +7 -3
  24. package/src/runtime/agent/orchestrator/internal-tools.mjs +16 -3
  25. package/src/runtime/agent/orchestrator/mcp/client.mjs +17 -1
  26. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +193 -13
  27. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +15 -2
  28. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +14 -0
  29. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +2 -2
  30. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +59 -2
  31. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +57 -25
  32. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +72 -12
  33. package/src/runtime/agent/orchestrator/session/context-utils.mjs +144 -84
  34. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +124 -8
  35. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +12 -1
  36. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +25 -7
  37. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +12 -3
  38. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +28 -1
  39. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -1
  40. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +31 -4
  41. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +11 -2
  42. package/src/runtime/agent/orchestrator/session/result-classification.mjs +4 -1
  43. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +45 -0
  44. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +7 -2
  45. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +8 -6
  46. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +15 -3
  47. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +22 -21
  48. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
  49. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +1 -1
  50. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -10
  51. package/src/runtime/memory/tool-defs.mjs +4 -3
  52. package/src/runtime/shared/tool-surface.mjs +1 -2
  53. package/src/session-runtime/context-status.mjs +8 -2
  54. package/src/standalone/agent-tool/render.mjs +2 -0
  55. package/src/standalone/agent-tool.mjs +202 -22
  56. package/src/tui/components/StatusLine.jsx +4 -26
  57. package/src/tui/dist/index.mjs +46 -31
  58. package/src/tui/engine/session-api.mjs +3 -0
  59. package/src/tui/engine/session-flow.mjs +19 -8
  60. package/src/tui/engine/turn.mjs +24 -2
  61. package/src/tui/engine.mjs +0 -1
  62. package/src/ui/statusline-agents.mjs +0 -8
  63. package/src/ui/statusline.mjs +2 -4
  64. package/src/workflows/default/WORKFLOW.md +38 -24
  65. package/src/workflows/solo-review/WORKFLOW.md +47 -0
  66. package/src/workflows/bench/WORKFLOW.md +0 -36
@@ -7,10 +7,10 @@
7
7
  * the public functions so external callers keep their existing import path.
8
8
  */
9
9
  import { readFileSync, existsSync, statSync } from 'fs';
10
- import { join } from 'path';
10
+ import { join, resolve } from 'path';
11
11
  import { createServer } from 'http';
12
12
  import { randomBytes, createHash } from 'crypto';
13
- import { writeJsonAtomicSync } from '../../../shared/atomic-file.mjs';
13
+ import { writeJsonAtomicSync, withFileLock } from '../../../shared/atomic-file.mjs';
14
14
  import { resolvePluginData } from '../../../shared/plugin-paths.mjs';
15
15
  import { getLlmDispatcher } from '../../../shared/llm/http-agent.mjs';
16
16
 
@@ -33,6 +33,7 @@ export const TOKEN_URL = assertSafeTokenURL(process.env.ANTHROPIC_OAUTH_TOKEN_UR
33
33
  export const DEFAULT_CREDENTIALS_PATH = join(resolvePluginData(), 'anthropic-oauth-credentials.json');
34
34
  export const CLAUDE_CODE_CLIENT_ID = process.env.ANTHROPIC_OAUTH_CLIENT_ID || '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
35
35
  export const TOKEN_REFRESH_SKEW_MS = 5 * 60_000;
36
+ export const ANTHROPIC_OAUTH_REFRESH_DISABLED_ENV = 'MIXDOG_ANTHROPIC_OAUTH_REFRESH_DISABLED';
36
37
  const CLAUDE_AI_AUTHORIZE_URL = 'https://claude.com/cai/oauth/authorize';
37
38
  const ALL_OAUTH_SCOPES = [
38
39
  'org:create_api_key',
@@ -52,7 +53,10 @@ const OAUTH_SUCCESS_REDIRECT_URL = process.env.ANTHROPIC_OAUTH_SUCCESS_REDIRECT_
52
53
  const OAUTH_LOGIN_TIMEOUT_MS = 5 * 60_000;
53
54
  const OAUTH_TOKEN_TIMEOUT_MS = 30_000;
54
55
 
55
- export const DEFAULT_CLI_VERSION = '2.1.77';
56
+ // Anthropic's token edge validates the Claude Code client identity. Keep this
57
+ // fallback aligned with the current official CLI while retaining the env
58
+ // override for seats where Claude Code is updated ahead of Mixdog.
59
+ export const DEFAULT_CLI_VERSION = '2.1.207';
56
60
 
57
61
  export function resolveCliVersion() {
58
62
  return process.env.MIXDOG_CLI_VERSION
@@ -67,10 +71,9 @@ function _pushUnique(list, value) {
67
71
  }
68
72
 
69
73
  export function credentialCandidates() {
70
- const paths = [];
71
- _pushUnique(paths, process.env.ANTHROPIC_OAUTH_CREDENTIALS_PATH);
72
- _pushUnique(paths, DEFAULT_CREDENTIALS_PATH);
73
- return paths;
74
+ const explicit = process.env.ANTHROPIC_OAUTH_CREDENTIALS_PATH;
75
+ if (explicit) return [resolve(explicit)];
76
+ return [DEFAULT_CREDENTIALS_PATH];
74
77
  }
75
78
 
76
79
  // Fallback expiry from the access_token's JWT `exp` claim (epoch ms) when the
@@ -109,6 +112,11 @@ function _loadCredentialsFile(path) {
109
112
  }
110
113
  }
111
114
 
115
+ export function loadCredentialsFromPath(path) {
116
+ if (!path) return null;
117
+ return _loadCredentialsFile(resolve(path));
118
+ }
119
+
112
120
  // Cross-process safe credential save. Lockfile (O_EXCL) prevents two Mixdog
113
121
  // refreshers from clobbering each other; atomic rename guarantees readers see
114
122
  // either the old or new file, never a half-written one. Used so refresh_token
@@ -205,17 +213,52 @@ export function _normalizeExpiresAt(value) {
205
213
  return value < 1e12 ? value * 1000 : value;
206
214
  }
207
215
 
208
- export function _scrubTokens(text) {
209
- return String(text || '')
210
- .replace(/Bearer [A-Za-z0-9._\-]+/g, 'Bearer [REDACTED]')
216
+ export function _scrubTokens(text, secretValues = []) {
217
+ let scrubbed = String(text || '')
218
+ .replace(/Bearer [A-Za-z0-9._\-]+/gi, 'Bearer [REDACTED]')
211
219
  .replace(/sk-ant-[A-Za-z0-9._\-]+/g, '[REDACTED]')
212
220
  .replace(/"access[Tt]oken"\s*:\s*"[^"]+"/g, '"accessToken":"[REDACTED]"')
213
221
  .replace(/"refresh[Tt]oken"\s*:\s*"[^"]+"/g, '"refreshToken":"[REDACTED]"')
214
222
  .replace(/"access_token"\s*:\s*"[^"]+"/g, '"access_token":"[REDACTED]"')
215
223
  .replace(/"refresh_token"\s*:\s*"[^"]+"/g, '"refresh_token":"[REDACTED]"');
224
+ // Token services sometimes echo submitted values in non-JSON diagnostics.
225
+ // Scrub the exact request secrets as a final guard without logging them.
226
+ for (const secret of secretValues) {
227
+ if (typeof secret === 'string' && secret) {
228
+ scrubbed = scrubbed.split(secret).join('[REDACTED]');
229
+ }
230
+ }
231
+ return scrubbed;
216
232
  }
217
233
 
218
- export async function refreshOAuthCredentials(creds) {
234
+ function _tokenEndpointError(operation, status, text, secretValues = []) {
235
+ const safeDetail = _scrubTokens(text, secretValues)
236
+ .replace(/\s+/g, ' ')
237
+ .trim()
238
+ .slice(0, 500);
239
+ const detail = safeDetail ? `: ${safeDetail}` : '';
240
+ const compatibility = status === 403
241
+ ? (
242
+ ` Anthropic returned HTTP 403 for a request sent as claude-cli/${resolveCliVersion()}.`
243
+ + ' Possible causes include OAuth client compatibility, account or scope policy,'
244
+ + ' an intercepting proxy/VPN/WAF, or regional endpoint restrictions.'
245
+ + ' Verify the account has Claude Code access and the required scopes;'
246
+ + ' update Mixdog or set MIXDOG_CLI_VERSION to the installed official Claude Code version;'
247
+ + ' then retry sign-in via /providers without an intercepting network layer if applicable.'
248
+ )
249
+ : '';
250
+ return new Error(`${operation} ${status}${detail}.${compatibility}`);
251
+ }
252
+
253
+ export function isAnthropicOAuthRefreshDisabled() {
254
+ return process.env[ANTHROPIC_OAUTH_REFRESH_DISABLED_ENV] === '1';
255
+ }
256
+
257
+ function _refreshLockPath(path) {
258
+ return `${resolve(path)}.anthropic-oauth-refresh.lock`;
259
+ }
260
+
261
+ async function _refreshOAuthCredentialsUnlocked(creds) {
219
262
  if (!creds?.refreshToken) {
220
263
  throw new Error('Anthropic OAuth refresh token not available. Open /providers in mixdog to sign in again.');
221
264
  }
@@ -248,7 +291,15 @@ export async function refreshOAuthCredentials(creds) {
248
291
  try { json = text ? JSON.parse(text) : null; } catch { /* handled below */ }
249
292
  if (!res.ok) {
250
293
  const isInvalidGrant = text.includes('invalid_grant') || json?.error === 'invalid_grant';
251
- throw Object.assign(new Error(`token refresh ${res.status}: ${_scrubTokens(text).slice(0, 200)}`), { isInvalidGrant });
294
+ throw Object.assign(
295
+ _tokenEndpointError(
296
+ 'token refresh',
297
+ res.status,
298
+ text,
299
+ [creds.refreshToken, creds.accessToken],
300
+ ),
301
+ { isInvalidGrant },
302
+ );
252
303
  }
253
304
 
254
305
  const accessToken = json?.access_token || json?.accessToken;
@@ -294,6 +345,130 @@ export async function refreshOAuthCredentials(creds) {
294
345
  }
295
346
  }
296
347
 
348
+ export async function refreshOAuthCredentials(creds) {
349
+ if (isAnthropicOAuthRefreshDisabled()) {
350
+ throw new Error(
351
+ 'Anthropic OAuth refresh is disabled in this process; '
352
+ + 'host credential preflight must provide a fresh snapshot.',
353
+ );
354
+ }
355
+ const credentialPath = resolve(
356
+ creds?.path
357
+ || process.env.ANTHROPIC_OAUTH_CREDENTIALS_PATH
358
+ || DEFAULT_CREDENTIALS_PATH,
359
+ );
360
+ return withFileLock(_refreshLockPath(credentialPath), async () => {
361
+ const disk = _loadCredentialsFile(credentialPath);
362
+ // A waiter that started with the prior generation must consume the
363
+ // winner's persisted rotation, not replay the single-use token.
364
+ if (disk?.accessToken && creds?.accessToken
365
+ && disk.accessToken !== creds.accessToken
366
+ && (!disk.expiresAt || disk.expiresAt > Date.now())) {
367
+ return disk;
368
+ }
369
+ return _refreshOAuthCredentialsUnlocked(
370
+ disk || { ...creds, path: credentialPath },
371
+ );
372
+ }, {
373
+ timeoutMs: 120_000,
374
+ staleMs: 120_000,
375
+ secret: true,
376
+ });
377
+ }
378
+
379
+ /**
380
+ * Serialize host-side refresh, establish a bounded access-token lease, and
381
+ * optionally write an owner-only snapshot while the lease lock is still held.
382
+ * The returned object deliberately contains metadata only, never token bytes.
383
+ */
384
+ export async function preflightAnthropicOAuthCredentials({
385
+ credentialsPath = null,
386
+ minimumValidityMs = TOKEN_REFRESH_SKEW_MS,
387
+ snapshotPath = null,
388
+ refreshFn = refreshOAuthCredentials,
389
+ now = () => Date.now(),
390
+ lockTimeoutMs = 120_000,
391
+ } = {}) {
392
+ if (isAnthropicOAuthRefreshDisabled()) {
393
+ throw new Error('Anthropic OAuth host preflight cannot run while refresh is disabled.');
394
+ }
395
+ const requiredMs = Number(minimumValidityMs);
396
+ if (!Number.isFinite(requiredMs) || requiredMs < 0) {
397
+ throw new Error('Anthropic OAuth host preflight minimumValidityMs must be a non-negative number.');
398
+ }
399
+ const pinnedPath = resolve(
400
+ credentialsPath
401
+ || process.env.ANTHROPIC_OAUTH_CREDENTIALS_PATH
402
+ || DEFAULT_CREDENTIALS_PATH,
403
+ );
404
+ const initial = _loadCredentialsFile(pinnedPath);
405
+ if (!initial?.path || !initial.accessToken) {
406
+ throw new Error(
407
+ `Anthropic OAuth host preflight found no credentials at ${pinnedPath}. `
408
+ + 'Open /providers in mixdog to sign in.',
409
+ );
410
+ }
411
+
412
+ return withFileLock(_refreshLockPath(pinnedPath), async () => {
413
+ let leased = _loadCredentialsFile(pinnedPath);
414
+ if (!leased?.path || !leased.accessToken) {
415
+ throw new Error('Anthropic OAuth credentials disappeared during host preflight.');
416
+ }
417
+
418
+ const validAfter = now() + requiredMs;
419
+ let refreshed = false;
420
+ if (!leased.expiresAt || leased.expiresAt < validAfter) {
421
+ if (!leased.refreshToken) {
422
+ throw new Error(
423
+ 'Anthropic OAuth host preflight cannot establish the required lease: '
424
+ + 'refresh token is unavailable.',
425
+ );
426
+ }
427
+ // The public refresh function owns this same lock. Call its
428
+ // unlocked exchange only because preflight already owns it;
429
+ // injected test exchanges run under the identical ownership.
430
+ const next = refreshFn === refreshOAuthCredentials
431
+ ? await _refreshOAuthCredentialsUnlocked(leased)
432
+ : await refreshFn(leased);
433
+ const persisted = _loadCredentialsFile(pinnedPath);
434
+ if (!persisted?.accessToken || persisted.accessToken !== next?.accessToken) {
435
+ throw new Error('Anthropic OAuth host preflight refresh was not persisted.');
436
+ }
437
+ leased = persisted;
438
+ refreshed = true;
439
+ }
440
+
441
+ const remainingMs = leased.expiresAt ? leased.expiresAt - now() : 0;
442
+ if (remainingMs < requiredMs) {
443
+ throw new Error(
444
+ `Anthropic OAuth host preflight cannot satisfy the ${Math.ceil(requiredMs / 1000)}s `
445
+ + `credential lease (provider granted ${Math.max(0, Math.floor(remainingMs / 1000))}s).`,
446
+ );
447
+ }
448
+
449
+ if (snapshotPath) {
450
+ const raw = JSON.parse(readFileSync(leased.path, 'utf-8'));
451
+ // Containers need only the leased access token. Never distribute
452
+ // the rotating refresh credential, even into disposable storage.
453
+ if (raw?.claudeAiOauth) {
454
+ delete raw.claudeAiOauth.refreshToken;
455
+ delete raw.claudeAiOauth.refresh_token;
456
+ }
457
+ _saveCredentialsFile(snapshotPath, raw);
458
+ }
459
+ return {
460
+ expiresAt: leased.expiresAt,
461
+ remainingMs,
462
+ refreshed,
463
+ snapshotWritten: Boolean(snapshotPath),
464
+ };
465
+ }, {
466
+ timeoutMs: lockTimeoutMs,
467
+ staleMs: 120_000,
468
+ secret: true,
469
+ });
470
+ }
471
+
297
472
  // --- Login flow (PKCE loopback, export for setup UI / CLI) ---
298
473
 
299
474
  function _oauthGeneratePKCE() {
@@ -358,7 +533,12 @@ async function exchangeAuthorizationCode({ pkce, code, state, redirectUri }) {
358
533
  });
359
534
  if (!tokenRes.ok) {
360
535
  const text = await tokenRes.text().catch(() => '');
361
- throw new Error(`[anthropic-oauth] token exchange ${tokenRes.status}: ${_scrubTokens(text).slice(0, 500)}`);
536
+ throw _tokenEndpointError(
537
+ '[anthropic-oauth] token exchange',
538
+ tokenRes.status,
539
+ text,
540
+ [cleanCode, pkce.verifier, state],
541
+ );
362
542
  }
363
543
  const json = await tokenRes.json();
364
544
  const accessToken = json?.access_token || json?.accessToken;
@@ -27,6 +27,7 @@ import {
27
27
  import { sanitizeToolPairs, sanitizeAnthropicContentPairs, foldUserTextIntoToolResultTail } from '../session/context-utils.mjs';
28
28
  import {
29
29
  TOKEN_REFRESH_SKEW_MS,
30
+ isAnthropicOAuthRefreshDisabled,
30
31
  resolveCliVersion,
31
32
  loadCredentials,
32
33
  hasAnthropicOAuthCredentials,
@@ -342,7 +343,7 @@ function toAnthropicMessages(messages) {
342
343
  const m = messages[idx];
343
344
  if (m.role === 'system') continue;
344
345
 
345
- if (m.role === 'assistant' && (m.toolCalls?.length || m.assistantBlocks?.length)) {
346
+ if (m.role === 'assistant' && (m.toolCalls?.length || m.assistantBlocks?.length || m.thinkingBlocks?.length)) {
346
347
  let content;
347
348
  if (m.assistantBlocks?.length) {
348
349
  content = m.assistantBlocks.slice();
@@ -358,7 +359,7 @@ function toAnthropicMessages(messages) {
358
359
  }
359
360
  }
360
361
  if (m.content) content.push({ type: 'text', text: m.content });
361
- for (const tc of m.toolCalls) {
362
+ for (const tc of m.toolCalls || []) {
362
363
  content.push({
363
364
  type: 'tool_use',
364
365
  id: tc.id,
@@ -703,6 +704,12 @@ export class AnthropicOAuthProvider {
703
704
  const expiring = this.credentials.expiresAt
704
705
  && this.credentials.expiresAt < Date.now() + TOKEN_REFRESH_SKEW_MS;
705
706
  if (forceRefresh || expiring) {
707
+ if (isAnthropicOAuthRefreshDisabled()) {
708
+ throw new Error(
709
+ 'Anthropic OAuth credentials require refresh, but refresh is disabled '
710
+ + 'in this container. Host credential preflight must provide a fresh snapshot.',
711
+ );
712
+ }
706
713
  this.credentials = await this._refreshCredentials({ force: forceRefresh, reason });
707
714
  }
708
715
 
@@ -710,6 +717,12 @@ export class AnthropicOAuthProvider {
710
717
  }
711
718
 
712
719
  async _refreshCredentials({ force = false, reason = 'preemptive' } = {}) {
720
+ if (isAnthropicOAuthRefreshDisabled()) {
721
+ throw new Error(
722
+ 'Anthropic OAuth refresh is disabled in this container; '
723
+ + 'host credential preflight must provide a fresh snapshot.',
724
+ );
725
+ }
713
726
  const currentToken = this.credentials?.accessToken || null;
714
727
  const disk = loadCredentials();
715
728
  const validAfter = Date.now() + (force ? 0 : TOKEN_REFRESH_SKEW_MS);
@@ -111,6 +111,7 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
111
111
  let toolCalls = [];
112
112
  let usage = { inputTokens: 0, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, raw: null };
113
113
  let stopReason = null;
114
+ let stopDetails;
114
115
  let buffer = '';
115
116
  let idleTimedOut = false;
116
117
  let firstMessageTimedOut = false;
@@ -546,6 +547,18 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
546
547
  if (event.delta?.stop_reason) {
547
548
  stopReason = event.delta.stop_reason;
548
549
  }
550
+ if (event.delta && (event.delta.stop_details != null || event.delta.category != null)) {
551
+ const details = event.delta.stop_details;
552
+ stopDetails = details && typeof details === 'object' && !Array.isArray(details)
553
+ ? {
554
+ ...details,
555
+ ...(event.delta.category != null ? { category: event.delta.category } : {}),
556
+ }
557
+ : {
558
+ ...(details != null ? { value: details } : {}),
559
+ ...(event.delta.category != null ? { category: event.delta.category } : {}),
560
+ };
561
+ }
549
562
  if (event.usage) {
550
563
  usage.outputTokens = event.usage.output_tokens || 0;
551
564
  usage.raw = { ...(usage.raw || {}), ...event.usage };
@@ -615,6 +628,7 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
615
628
  toolCalls: toolCalls.length ? toolCalls : undefined,
616
629
  usage,
617
630
  stopReason,
631
+ stopDetails,
618
632
  hasThinkingContent,
619
633
  contentBlockTypes: Array.from(contentBlockTypes),
620
634
  // Ordered extended-thinking blocks (verbatim thinking text +
@@ -347,7 +347,7 @@ function toAnthropicMessages(messages) {
347
347
  for (let idx = 0; idx < messages.length; idx++) {
348
348
  const m = messages[idx];
349
349
  if (m.role === 'system') continue;
350
- if (m.role === 'assistant' && (m.toolCalls?.length || m.assistantBlocks?.length)) {
350
+ if (m.role === 'assistant' && (m.toolCalls?.length || m.assistantBlocks?.length || m.thinkingBlocks?.length)) {
351
351
  let content;
352
352
  if (m.assistantBlocks?.length) {
353
353
  content = m.assistantBlocks.slice();
@@ -362,7 +362,7 @@ function toAnthropicMessages(messages) {
362
362
  }
363
363
  }
364
364
  if (m.content) content.push({ type: 'text', text: m.content });
365
- for (const tc of m.toolCalls) {
365
+ for (const tc of m.toolCalls || []) {
366
366
  content.push({
367
367
  type: 'tool_use',
368
368
  id: tc.id,
@@ -1,3 +1,5 @@
1
+ import { createHash } from 'node:crypto';
2
+
1
3
  const DEFAULT_IMAGE_MIME = 'image/png';
2
4
 
3
5
  function cleanMimeType(value) {
@@ -96,6 +98,9 @@ function contentParts(content) {
96
98
  if (content && typeof content === 'object' && Array.isArray(content.content)) {
97
99
  return content.content;
98
100
  }
101
+ if (content && typeof content === 'object'
102
+ && (imageUrlFromPart(content) || imageFileIdFromPart(content)
103
+ || imageFileUriFromPart(content) || geminiInlineInfo(content))) return [content];
99
104
  return null;
100
105
  }
101
106
 
@@ -108,9 +113,61 @@ function jsonFallbackFromPart(block) {
108
113
  }
109
114
 
110
115
  export function contentHasImage(content) {
116
+ return contentImageCount(content) > 0;
117
+ }
118
+
119
+ export function contentImageCount(content) {
120
+ return contentImageDescriptors(content).length;
121
+ }
122
+
123
+ function positiveDimension(...values) {
124
+ for (const value of values) {
125
+ const number = Number(value);
126
+ if (Number.isFinite(number) && number > 0) return number;
127
+ }
128
+ return null;
129
+ }
130
+
131
+ function imageIdentity(kind, value, mimeType = '') {
132
+ return createHash('sha256')
133
+ .update(`${kind}\0${mimeType}\0`)
134
+ .update(String(value || ''))
135
+ .digest('hex');
136
+ }
137
+
138
+ function imageDescriptor(part) {
139
+ if (!part || typeof part !== 'object') return null;
140
+ const info = imageInfo(part);
141
+ const inline = geminiInlineInfo(part);
142
+ const url = imageUrlFromPart(part);
143
+ const fileId = imageFileIdFromPart(part);
144
+ const fileUri = imageFileUriFromPart(part);
145
+ if (!info && !inline && !url && !fileId && !fileUri) return null;
146
+ const imageUrl = part.image_url && typeof part.image_url === 'object' ? part.image_url : null;
147
+ const source = part.source && typeof part.source === 'object' ? part.source : null;
148
+ const inlineData = part.inlineData || part.inline_data;
149
+ const dimensions = part.dimensions && typeof part.dimensions === 'object' ? part.dimensions : null;
150
+ const width = positiveDimension(part.width, dimensions?.width, imageUrl?.width, source?.width, inlineData?.width);
151
+ const height = positiveDimension(part.height, dimensions?.height, imageUrl?.height, source?.height, inlineData?.height);
152
+ const detail = String(part.detail ?? imageUrl?.detail ?? source?.detail ?? inlineData?.detail ?? 'auto').toLowerCase();
153
+ if (info || inline) {
154
+ const raw = info || inline;
155
+ return {
156
+ identity: imageIdentity('inline', raw.data, raw.mimeType),
157
+ width,
158
+ height,
159
+ detail,
160
+ };
161
+ }
162
+ if (fileId) return { identity: imageIdentity('file-id', fileId), width, height, detail };
163
+ if (fileUri) return { identity: imageIdentity('file-uri', fileUri.fileUri, fileUri.mimeType), width, height, detail };
164
+ return { identity: imageIdentity('url', url), width, height, detail };
165
+ }
166
+
167
+ export function contentImageDescriptors(content) {
111
168
  const parts = contentParts(content);
112
- if (!parts) return false;
113
- return parts.some((part) => !!imageUrlFromPart(part) || !!imageFileIdFromPart(part) || !!imageFileUriFromPart(part) || !!geminiInlineInfo(part));
169
+ if (!parts) return [];
170
+ return parts.map(imageDescriptor).filter(Boolean);
114
171
  }
115
172
 
116
173
  export function contentToText(content, fallback = '') {
@@ -51,6 +51,11 @@ import {
51
51
  createActiveToolItemTracker,
52
52
  hasCompleteToolCall as sharedHasCompleteToolCall,
53
53
  } from './tool-stream-state.mjs';
54
+ import {
55
+ enableSessionTransportTracking,
56
+ disableSessionTransportTracking,
57
+ markSessionTransportActivity,
58
+ } from '../session/manager/runtime-liveness.mjs';
54
59
 
55
60
  // Facade re-exports: the delta/matching helpers and usage/event helpers below
56
61
  // were extracted to openai-ws-delta.mjs / openai-ws-events.mjs (no behavior
@@ -92,6 +97,11 @@ export const WS_PRE_RESPONSE_CREATED_MS = (() => {
92
97
  // including metadata/keepalive, proves the socket is live.
93
98
  export const WS_INTER_CHUNK_MS = PROVIDER_WS_INTER_CHUNK_TIMEOUT_MS;
94
99
  const X_CODEX_TURN_STATE_HEADER = 'x-codex-turn-state';
100
+ const WS_TRACE_ENABLED = process.env.MIXDOG_WS_TRACE === '1';
101
+
102
+ function _writeWsLifecycleTrace(lifecycle) {
103
+ process.stderr.write(`[ws-trace] t=${new Date().toISOString()} lifecycle=${lifecycle}\n`);
104
+ }
95
105
 
96
106
  function _responseItemKey(item, fallbackIndex = 0) {
97
107
  if (!item || typeof item !== 'object') return `primitive:${fallbackIndex}`;
@@ -239,6 +249,7 @@ export async function _streamResponse({
239
249
  }) {
240
250
  const errLabel = _wsErrLabel(traceProvider);
241
251
  const socket = entry.socket;
252
+ enableSessionTransportTracking(state?.sessionId);
242
253
  const preResponseCreatedMs = _positiveInt(_timeouts?.preResponseCreatedMs, WS_PRE_RESPONSE_CREATED_MS);
243
254
  const interChunkMs = _positiveInt(_timeouts?.interChunkMs, WS_INTER_CHUNK_MS);
244
255
  // First-MEANINGFUL-frame deadline. Distinct from preResponseCreatedMs (a
@@ -471,6 +482,9 @@ export async function _streamResponse({
471
482
  let messageHandler = null;
472
483
  let closeHandler = null;
473
484
  let errorHandler = null;
485
+ let traceOpenHandler = null;
486
+ let tracePingHandler = null;
487
+ let tracePongHandler = null;
474
488
  // SEMANTIC idle timer: distinct from the inter-chunk
475
489
  // timer, which resets on EVERY frame (rate_limits/metadata/keepalive keep
476
490
  // the socket "alive"). This timer resets ONLY on meaningful output deltas
@@ -668,6 +682,7 @@ export async function _streamResponse({
668
682
  // frames never reach here, so they cannot satisfy the watchdog.
669
683
  const bumpSemanticIdle = () => { clearFirstMeaningfulWatchdog(); resetSemanticIdle(); };
670
684
  const cleanup = () => {
685
+ disableSessionTransportTracking(midState.sessionId);
671
686
  if (idleTimer) clearTimeout(idleTimer);
672
687
  if (interChunkTimer) { clearTimeout(interChunkTimer); interChunkTimer = null; }
673
688
  if (semanticIdleTimer) { clearTimeout(semanticIdleTimer); semanticIdleTimer = null; }
@@ -676,6 +691,9 @@ export async function _streamResponse({
676
691
  if (messageHandler) socket.off('message', messageHandler);
677
692
  if (closeHandler) socket.off('close', closeHandler);
678
693
  if (errorHandler) socket.off('error', errorHandler);
694
+ if (traceOpenHandler) socket.off('open', traceOpenHandler);
695
+ if (tracePingHandler) socket.off('ping', tracePingHandler);
696
+ if (tracePongHandler) socket.off('pong', tracePongHandler);
679
697
  if (abortHandler && externalSignal) externalSignal.removeEventListener('abort', abortHandler);
680
698
  };
681
699
  const finish = () => {
@@ -755,6 +773,15 @@ export async function _streamResponse({
755
773
  const text = typeof data === 'string' ? data : data.toString('utf-8');
756
774
  const event = _parseEvent(text);
757
775
  if (!event) return;
776
+ if (WS_TRACE_ENABLED) {
777
+ const type = typeof event.type === 'string' ? event.type : '';
778
+ const empty = type.endsWith('.delta')
779
+ && (event.delta == null || event.delta === '');
780
+ process.stderr.write(
781
+ `[ws-trace] t=${new Date().toISOString()} type=${type} bytes=${Buffer.byteLength(text)} empty=${empty}\n`,
782
+ );
783
+ }
784
+ markSessionTransportActivity(midState.sessionId);
758
785
  _traceWsHeaderKeys(entry, event, midState, traceProvider, model);
759
786
  _captureTurnStateFromEvent(entry, event);
760
787
  if (event.error) {
@@ -780,12 +807,9 @@ export async function _streamResponse({
780
807
  // first-meaningful watchdog (keepalive/metadata frames do
781
808
  // NOT reach this case, so they never clear it).
782
809
  clearFirstMeaningfulWatchdog();
783
- // Arm the semantic idle/stall timer from response.created
784
- // too: it otherwise only arms on the first meaningful delta
785
- // (bumpSemanticIdle), so a created+keepalive-only stream that
786
- // never emits a delta would stall unbounded until the outer
787
- // watchdog. Arming here bounds that gap.
788
- resetSemanticIdle();
810
+ // Do not arm semantic idle until actual model progress.
811
+ // Transport-only reasoning heartbeats are bounded by the
812
+ // outer first-visible ceiling instead.
789
813
  break;
790
814
  case 'response.output_text.delta':
791
815
  try {
@@ -814,14 +838,12 @@ export async function _streamResponse({
814
838
  case 'response.reasoning_summary_text.delta':
815
839
  if (event.type === 'response.reasoning_text.delta') reasoningTextDeltaCount += 1;
816
840
  else reasoningSummaryTextDeltaCount += 1;
817
- // Reasoning text is live model progress — refresh
818
- // lastStreamDeltaAt so stream-watchdog does not flag a
819
- // long reasoning span as a stall. The local WS idle timer
820
- // was already reset by resetIdle() at the top of
821
- // messageHandler. Reasoning is still suppressed from user
822
- // content (no `content +=` here).
823
- try { onStreamDelta?.(); } catch {}
824
- bumpSemanticIdle();
841
+ // Only non-empty reasoning text is model progress. Empty
842
+ // deltas remain transport activity via resetIdle() above.
843
+ if (event.delta) {
844
+ try { onStreamDelta?.(); } catch {}
845
+ bumpSemanticIdle();
846
+ }
825
847
  break;
826
848
  case 'response.output_item.added':
827
849
  if (event.item?.type === 'function_call') {
@@ -1184,25 +1206,24 @@ export async function _streamResponse({
1184
1206
  && event.type.startsWith('response.reasoning')
1185
1207
  && event.type.endsWith('.delta')) {
1186
1208
  reasoningOtherDeltaCount += 1;
1187
- // These ARE live model progress (reviewer Medium): a
1188
- // provider that emits only these reasoning variants for a
1189
- // long span would otherwise trip the SEMANTIC idle abort.
1190
- // Refresh both the watchdog and the semantic idle timer,
1191
- // matching the named reasoning_text.delta case above.
1192
- try { onStreamDelta?.(); } catch {}
1193
- bumpSemanticIdle();
1209
+ // Only non-empty reasoning deltas are model progress;
1210
+ // empty variants remain transport activity only.
1211
+ if (event.delta) {
1212
+ try { onStreamDelta?.(); } catch {}
1213
+ bumpSemanticIdle();
1214
+ }
1194
1215
  }
1195
- // response.in_progress is a server lifecycle heartbeat during
1196
- // long tool/generation latency reset semantic idle so it is
1197
- // not counted as silence (resetIdle already ran at the top).
1216
+ // response.in_progress is transport activity only;
1217
+ // resetIdle() already kept the socket alive above.
1198
1218
  else if (event.type === 'response.in_progress') {
1199
- resetSemanticIdle();
1219
+ // No semantic/model progress.
1200
1220
  }
1201
1221
  // Other trace-only events fall through.
1202
1222
  break;
1203
1223
  }
1204
1224
  };
1205
1225
  closeHandler = (code, reason) => {
1226
+ if (WS_TRACE_ENABLED) _writeWsLifecycleTrace('close');
1206
1227
  if (done) return;
1207
1228
  midState.wsCloseCode = code;
1208
1229
  if (!terminalError) {
@@ -1219,6 +1240,7 @@ export async function _streamResponse({
1219
1240
  finish();
1220
1241
  };
1221
1242
  errorHandler = (err) => {
1243
+ if (WS_TRACE_ENABLED) _writeWsLifecycleTrace('error');
1222
1244
  if (done) return;
1223
1245
  const wrapped = err instanceof Error ? err : new Error(String(err));
1224
1246
  if (terminalError) {
@@ -1268,6 +1290,15 @@ export async function _streamResponse({
1268
1290
  socket.on('message', messageHandler);
1269
1291
  socket.on('close', closeHandler);
1270
1292
  socket.on('error', errorHandler);
1293
+ if (WS_TRACE_ENABLED) {
1294
+ traceOpenHandler = () => _writeWsLifecycleTrace('open');
1295
+ tracePingHandler = () => _writeWsLifecycleTrace('ping');
1296
+ tracePongHandler = () => _writeWsLifecycleTrace('pong');
1297
+ socket.on('open', traceOpenHandler);
1298
+ socket.on('ping', tracePingHandler);
1299
+ socket.on('pong', tracePongHandler);
1300
+ if (socket.readyState === WebSocket.OPEN) _writeWsLifecycleTrace('open');
1301
+ }
1271
1302
  armPreStreamWatchdog();
1272
1303
  armFirstMeaningfulWatchdog();
1273
1304
  // Periodic client-side WS ping while the stream is active. The server's
@@ -1280,6 +1311,7 @@ export async function _streamResponse({
1280
1311
  keepaliveTimer = setInterval(() => {
1281
1312
  try {
1282
1313
  if (socket.readyState !== WebSocket.OPEN) return;
1314
+ if (WS_TRACE_ENABLED) _writeWsLifecycleTrace('ping');
1283
1315
  socket.ping();
1284
1316
  } catch {}
1285
1317
  }, 10_000);