mixdog 0.9.41 → 0.9.44

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 (73) hide show
  1. package/package.json +3 -2
  2. package/scripts/agent-tag-reuse-smoke.mjs +124 -10
  3. package/scripts/ansi-color-capability-test.mjs +90 -0
  4. package/scripts/anthropic-oauth-refresh-race-test.mjs +59 -0
  5. package/scripts/arg-guard-test.mjs +16 -0
  6. package/scripts/compact-pressure-test.mjs +256 -1
  7. package/scripts/generate-runtime-manifest.mjs +39 -22
  8. package/scripts/grok-oauth-refresh-race-test.mjs +198 -0
  9. package/scripts/internal-comms-bench.mjs +0 -1
  10. package/scripts/internal-comms-smoke.mjs +38 -39
  11. package/scripts/internal-tools-normalization-test.mjs +52 -0
  12. package/scripts/maintenance-default-routes-test.mjs +164 -0
  13. package/scripts/max-output-recovery-test.mjs +49 -0
  14. package/scripts/mcp-client-normalization-test.mjs +45 -0
  15. package/scripts/openai-oauth-ws-1006-retry-test.mjs +123 -0
  16. package/scripts/provider-toolcall-test.mjs +23 -0
  17. package/scripts/result-classification-test.mjs +75 -0
  18. package/scripts/routing-corpus.mjs +1 -1
  19. package/scripts/shell-jobs-windows-hide-test.mjs +164 -0
  20. package/scripts/smoke.mjs +1 -1
  21. package/scripts/submit-commandbusy-race-test.mjs +99 -7
  22. package/scripts/tui-transcript-jitter-harness-entry.jsx +302 -0
  23. package/scripts/tui-transcript-jitter-harness.mjs +58 -0
  24. package/scripts/tui-transcript-perf-test.mjs +56 -1
  25. package/src/agents/reviewer/AGENT.md +5 -3
  26. package/src/rules/lead/lead-brief.md +5 -4
  27. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +40 -3
  28. package/src/runtime/agent/orchestrator/config.mjs +29 -14
  29. package/src/runtime/agent/orchestrator/internal-tools.mjs +16 -3
  30. package/src/runtime/agent/orchestrator/mcp/client.mjs +17 -1
  31. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +49 -6
  32. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +14 -0
  33. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +46 -21
  34. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +59 -2
  35. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +5 -1
  36. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +18 -0
  37. package/src/runtime/agent/orchestrator/session/context-utils.mjs +140 -81
  38. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +23 -5
  39. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +3 -0
  40. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +12 -3
  41. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +4 -2
  42. package/src/runtime/agent/orchestrator/session/result-classification.mjs +4 -1
  43. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +7 -2
  44. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +8 -6
  45. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +15 -3
  46. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +1 -1
  47. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -5
  48. package/src/runtime/memory/data/runtime-manifest.json +11 -11
  49. package/src/runtime/memory/lib/runtime-fetcher.mjs +1 -2
  50. package/src/runtime/memory/tool-defs.mjs +4 -3
  51. package/src/runtime/shared/tool-surface.mjs +1 -2
  52. package/src/session-runtime/context-status.mjs +8 -2
  53. package/src/standalone/agent-tool/render.mjs +2 -0
  54. package/src/standalone/agent-tool.mjs +267 -72
  55. package/src/standalone/explore-tool.mjs +17 -16
  56. package/src/tui/app/provider-setup-picker.mjs +1 -0
  57. package/src/tui/app/use-transcript-window.mjs +5 -1
  58. package/src/tui/components/StatusLine.jsx +11 -32
  59. package/src/tui/dist/index.mjs +257 -106
  60. package/src/tui/engine/session-api.mjs +3 -0
  61. package/src/tui/engine/session-flow.mjs +19 -8
  62. package/src/tui/engine/turn.mjs +24 -2
  63. package/src/tui/engine.mjs +0 -1
  64. package/src/tui/index.jsx +3 -2
  65. package/src/tui/markdown/streaming-markdown.mjs +35 -9
  66. package/src/tui/statusline-ansi-bridge.mjs +17 -7
  67. package/src/ui/ansi.mjs +85 -5
  68. package/src/ui/statusline-agents.mjs +0 -8
  69. package/src/ui/statusline-format.mjs +7 -7
  70. package/src/ui/statusline.mjs +2 -4
  71. package/src/workflows/default/WORKFLOW.md +29 -20
  72. package/src/workflows/solo-review/WORKFLOW.md +47 -0
  73. package/src/workflows/bench/WORKFLOW.md +0 -60
@@ -6,6 +6,8 @@ import { randomUUID } from 'crypto';
6
6
  import { smartReadTruncate } from '../tools/builtin/read-formatting.mjs';
7
7
  import { shutdownStdioChild, killStdioChildTreeFast } from './child-tree.mjs';
8
8
  import { readServicePort, markServiceUnreachable, isConnRefuseError } from '../../../shared/service-discovery.mjs';
9
+ import { makeToolEnvelope, normalizeToolEnvelope } from '../session/tool-envelope.mjs';
10
+ import { classifyResultKind } from '../session/result-classification.mjs';
9
11
  // --- Types ---
10
12
  /** Known auto-detect targets: port file path relative to tmpdir.
11
13
  * Note: `mixdog` used to self-loopback via active-instance.json's
@@ -215,6 +217,16 @@ export async function executeMcpTool(name, args) {
215
217
  throw new Error(`Tool call failed: ${firstMsg}; retry after reconnect also failed: ${retryMsg}`);
216
218
  }
217
219
  }
220
+ const normalized = normalizeToolEnvelope(normalizeMcpToolResult(result));
221
+ const text = capMcpOutput(normalized.result);
222
+ return normalized.explicitSuccess
223
+ ? makeToolEnvelope(text, [], { explicitSuccess: true })
224
+ : text;
225
+ }
226
+
227
+ // Preserve MCP failure metadata across the object→string boundary. The
228
+ // session loop classifies the canonical Error: prefix as toolKind:error.
229
+ export function normalizeMcpToolResult(result) {
218
230
  const content = result.content;
219
231
  let text;
220
232
  if (Array.isArray(content)) {
@@ -224,7 +236,11 @@ export async function executeMcpTool(name, args) {
224
236
  } else {
225
237
  text = typeof content === 'string' ? content : JSON.stringify(content);
226
238
  }
227
- return capMcpOutput(text);
239
+ if (result.isError === true) return !text.startsWith('Error:') ? `Error: ${text}` : text;
240
+ if (result.isError === false && classifyResultKind(text) === 'error') {
241
+ return makeToolEnvelope(text, [], { explicitSuccess: true });
242
+ }
243
+ return text;
228
244
  }
229
245
 
230
246
  // MCP per-tool-call timeout. Default 2min: a hung/unresponsive MCP server
@@ -53,7 +53,10 @@ const OAUTH_SUCCESS_REDIRECT_URL = process.env.ANTHROPIC_OAUTH_SUCCESS_REDIRECT_
53
53
  const OAUTH_LOGIN_TIMEOUT_MS = 5 * 60_000;
54
54
  const OAUTH_TOKEN_TIMEOUT_MS = 30_000;
55
55
 
56
- 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';
57
60
 
58
61
  export function resolveCliVersion() {
59
62
  return process.env.MIXDOG_CLI_VERSION
@@ -210,14 +213,41 @@ export function _normalizeExpiresAt(value) {
210
213
  return value < 1e12 ? value * 1000 : value;
211
214
  }
212
215
 
213
- export function _scrubTokens(text) {
214
- return String(text || '')
215
- .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]')
216
219
  .replace(/sk-ant-[A-Za-z0-9._\-]+/g, '[REDACTED]')
217
220
  .replace(/"access[Tt]oken"\s*:\s*"[^"]+"/g, '"accessToken":"[REDACTED]"')
218
221
  .replace(/"refresh[Tt]oken"\s*:\s*"[^"]+"/g, '"refreshToken":"[REDACTED]"')
219
222
  .replace(/"access_token"\s*:\s*"[^"]+"/g, '"access_token":"[REDACTED]"')
220
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;
232
+ }
233
+
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}`);
221
251
  }
222
252
 
223
253
  export function isAnthropicOAuthRefreshDisabled() {
@@ -261,7 +291,15 @@ async function _refreshOAuthCredentialsUnlocked(creds) {
261
291
  try { json = text ? JSON.parse(text) : null; } catch { /* handled below */ }
262
292
  if (!res.ok) {
263
293
  const isInvalidGrant = text.includes('invalid_grant') || json?.error === 'invalid_grant';
264
- 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
+ );
265
303
  }
266
304
 
267
305
  const accessToken = json?.access_token || json?.accessToken;
@@ -495,7 +533,12 @@ async function exchangeAuthorizationCode({ pkce, code, state, redirectUri }) {
495
533
  });
496
534
  if (!tokenRes.ok) {
497
535
  const text = await tokenRes.text().catch(() => '');
498
- 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
+ );
499
542
  }
500
543
  const json = await tokenRes.json();
501
544
  const accessToken = json?.access_token || json?.accessToken;
@@ -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 +
@@ -22,7 +22,7 @@ import { randomBytes, createHash } from 'crypto';
22
22
  import { readFileSync, existsSync, mkdirSync, statSync, unlinkSync } from 'fs';
23
23
  import { join } from 'path';
24
24
  import { getPluginData } from '../config.mjs';
25
- import { writeJsonAtomicSync } from '../../../shared/atomic-file.mjs';
25
+ import { writeJsonAtomicSync, withFileLock } from '../../../shared/atomic-file.mjs';
26
26
  import { enrichModels, getModelMetadataSync } from './model-catalog.mjs';
27
27
  import { sanitizeModelList } from './model-list-sanitize.mjs';
28
28
  import { makeModelCache } from './model-cache.mjs';
@@ -180,6 +180,10 @@ function getOwnTokenPath() {
180
180
  return join(dir, 'grok-oauth.json');
181
181
  }
182
182
 
183
+ function getRefreshLockPath() {
184
+ return `${getOwnTokenPath()}.refresh.lock`;
185
+ }
186
+
183
187
  // expires_at may arrive as a unix number or an ISO-8601 string. Normalize both
184
188
  // to epoch milliseconds; 0 means unknown.
185
189
  function _normalizeExpiresAt(value) {
@@ -369,32 +373,53 @@ async function _postRefresh(tokens) {
369
373
  }
370
374
  }
371
375
 
372
- // 16 mixdog processes share one grok-oauth.json and xAI rotates refresh
373
- // tokens single-use. Re-read the store immediately before the network POST so
374
- // a peer's just-completed rotation is adopted instead of spending our
375
- // (now-stale) refresh_token; on invalid_grant, re-read once and retry with a
376
- // peer-rotated refresh_token before propagating.
376
+ // Mixdog processes share one grok-oauth.json and xAI rotates refresh tokens
377
+ // single-use. Hold a cross-process lease across the re-read, exchange, and
378
+ // atomic save so only one process can spend a generation.
377
379
  async function refreshTokens(tokens, { force = false } = {}) {
378
380
  if (!tokens?.refresh_token) {
379
381
  throw new Error('[grok-oauth] refresh token not available — open /providers in mixdog to sign in again');
380
382
  }
381
- const disk = _loadOwnTokens();
382
- const validAfter = Date.now() + (force ? 0 : TOKEN_REFRESH_SKEW_MS);
383
- if (disk?.access_token && disk.access_token !== tokens.access_token
384
- && (!disk.expires_at || disk.expires_at >= validAfter)) {
385
- return disk;
386
- }
387
- try {
388
- return await _postRefresh(tokens);
389
- } catch (err) {
390
- if (err?.isInvalidGrant) {
391
- const rotated = _loadOwnTokens();
392
- if (rotated?.refresh_token && rotated.refresh_token !== tokens.refresh_token) {
393
- return await _postRefresh(rotated);
383
+
384
+ return withFileLock(getRefreshLockPath(), async () => {
385
+ const validAfter = Date.now() + (force ? 0 : TOKEN_REFRESH_SKEW_MS);
386
+ const disk = _loadOwnTokens();
387
+ // A waiter that entered with the prior generation adopts the winner's
388
+ // persisted, valid token instead of rotating it again. xAI may rotate
389
+ // only the refresh token while reissuing the same access token.
390
+ const diskGenerationChanged = disk?.access_token
391
+ && (disk.access_token !== tokens.access_token
392
+ || disk.refresh_token !== tokens.refresh_token);
393
+ if (diskGenerationChanged
394
+ && (!disk.expires_at || disk.expires_at >= validAfter)) {
395
+ return disk;
396
+ }
397
+
398
+ const current = disk || tokens;
399
+ try {
400
+ return await _postRefresh(current);
401
+ } catch (err) {
402
+ if (err?.isInvalidGrant) {
403
+ // A writer that does not participate in this lease may still
404
+ // have won the rotation while the request was in flight.
405
+ // Adopt its valid generation; only exchange it when it cannot
406
+ // satisfy this caller.
407
+ const rotated = _loadOwnTokens();
408
+ if (rotated?.refresh_token && rotated.refresh_token !== current.refresh_token) {
409
+ if (rotated.access_token
410
+ && (!rotated.expires_at || rotated.expires_at >= validAfter)) {
411
+ return rotated;
412
+ }
413
+ return await _postRefresh(rotated);
414
+ }
394
415
  }
416
+ throw err;
395
417
  }
396
- throw err;
397
- }
418
+ }, {
419
+ timeoutMs: 120_000,
420
+ staleMs: 120_000,
421
+ secret: true,
422
+ });
398
423
  }
399
424
 
400
425
  // --- Model catalog cache (24h disk TTL) ---
@@ -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 = '') {
@@ -325,7 +325,11 @@ function _classifyMidstreamWs(err, state, attemptIndex, policy) {
325
325
  }
326
326
  if (!state.sawResponseCreated) {
327
327
  const closeCode = Number(err?.wsCloseCode || state.wsCloseCode || 0)
328
- if (closeCode !== 1011 && closeCode !== 1012) return null
328
+ // An abnormal close before response.created has not produced any response
329
+ // bytes to the caller. It is therefore safe to reconnect and replay under
330
+ // the normal ws_1006 bounded retry policy (text/tool emission was denied
331
+ // above before reaching this gate).
332
+ if (closeCode !== 1006 && closeCode !== 1011 && closeCode !== 1012) return null
329
333
  }
330
334
  if (state.userAbort) return null
331
335
 
@@ -145,6 +145,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
145
145
  let lastUsage;
146
146
  let firstTurnUsage;
147
147
  let response;
148
+ let lastSendTools = tools;
148
149
  let contextOverflowRetryUsed = false;
149
150
  // Set when the hard iteration-cap break below fires. Consumed at the final
150
151
  // return to tag terminationReason='iteration_cap' so a worker that exhausts
@@ -281,6 +282,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
281
282
  // the loop as an explicit empty termination instead.
282
283
  let _emptyNudgeStreak = 0;
283
284
  const EMPTY_NUDGE_MAX = 3;
285
+ let _refusalRetryUsed = false;
284
286
  let _maxOutputRecoveryCount = 0;
285
287
  const _maxOutputContentParts = [];
286
288
  // Claude Code parity: queued prompt/task notifications are attached after a
@@ -435,6 +437,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
435
437
  const sendTools = _capFinalToolsDisabled
436
438
  ? tools
437
439
  : (forcedFirstToolDef && toolCallsTotal === 0 ? [forcedFirstToolDef] : tools);
440
+ lastSendTools = sendTools;
438
441
  // Eager-dispatch queue: when the provider streams a tool-call event,
439
442
  // start read-only tools immediately so execution overlaps with the
440
443
  // remaining SSE parse. Writes and unknown tools wait until send()
@@ -572,6 +575,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
572
575
  // additive — callers that ignore these fields keep working.
573
576
  deltaCachedRead: response.usage.cachedTokens || 0,
574
577
  deltaCacheWrite: response.usage.cacheWriteTokens || 0,
578
+ sendTools,
575
579
  ts: Date.now(),
576
580
  });
577
581
  } catch { /* best-effort — never break the loop */ }
@@ -633,6 +637,19 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
633
637
  };
634
638
  break;
635
639
  }
640
+ if (!hasContent && stopReason === 'refusal') {
641
+ if (_refusalRetryUsed) {
642
+ process.stderr.write(`[loop] safety-classifier refusal persisted after one context-changing retry (sess=${sessionId || 'unknown'}); ending loop as refusal termination.\n`);
643
+ break;
644
+ }
645
+ _refusalRetryUsed = true;
646
+ messages.push({
647
+ role: 'user',
648
+ content: '[mixdog-runtime] The previous completion was refused by the provider safety classifier (stopReason=refusal). Do not repeat it. Complete your assigned output within policy by omitting or reframing disallowed content; if no compliant output is possible, briefly state the refusal.',
649
+ meta: { source: 'refusal-recovery', attempt: 1 },
650
+ });
651
+ continue;
652
+ }
636
653
  if (!hasContent && !isHidden) {
637
654
  _emptyNudgeStreak += 1;
638
655
  if (_emptyNudgeStreak > EMPTY_NUDGE_MAX) {
@@ -751,6 +768,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
751
768
  ...response,
752
769
  usage: lastUsage || response.usage,
753
770
  lastTurnUsage: response.usage,
771
+ lastSendTools,
754
772
  firstTurnUsage: firstTurnUsage || response.usage,
755
773
  iterations,
756
774
  toolCallsTotal,