mixdog 0.9.40 → 0.9.41

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 (33) hide show
  1. package/package.json +2 -1
  2. package/scripts/anthropic-oauth-refresh-race-test.mjs +338 -0
  3. package/scripts/compact-pressure-test.mjs +128 -0
  4. package/scripts/compact-trigger-migration-smoke.mjs +8 -8
  5. package/scripts/internal-comms-smoke.mjs +66 -25
  6. package/scripts/max-output-recovery-persist-test.mjs +81 -0
  7. package/scripts/max-output-recovery-test.mjs +222 -0
  8. package/scripts/provider-toolcall-test.mjs +32 -0
  9. package/src/agents/reviewer/AGENT.md +4 -0
  10. package/src/rules/lead/lead-brief.md +11 -3
  11. package/src/rules/lead/lead-tool.md +0 -2
  12. package/src/rules/shared/01-tool.md +4 -0
  13. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +20 -2
  14. package/src/runtime/agent/orchestrator/context/collect.mjs +7 -3
  15. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +144 -7
  16. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +15 -2
  17. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +2 -2
  18. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +57 -25
  19. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +54 -12
  20. package/src/runtime/agent/orchestrator/session/context-utils.mjs +4 -3
  21. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +106 -8
  22. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +12 -1
  23. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +22 -7
  24. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +26 -1
  25. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -1
  26. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +31 -4
  27. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +11 -2
  28. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +45 -0
  29. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +22 -21
  30. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
  31. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -10
  32. package/src/workflows/bench/WORKFLOW.md +51 -27
  33. package/src/workflows/default/WORKFLOW.md +29 -24
@@ -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',
@@ -67,10 +68,9 @@ function _pushUnique(list, value) {
67
68
  }
68
69
 
69
70
  export function credentialCandidates() {
70
- const paths = [];
71
- _pushUnique(paths, process.env.ANTHROPIC_OAUTH_CREDENTIALS_PATH);
72
- _pushUnique(paths, DEFAULT_CREDENTIALS_PATH);
73
- return paths;
71
+ const explicit = process.env.ANTHROPIC_OAUTH_CREDENTIALS_PATH;
72
+ if (explicit) return [resolve(explicit)];
73
+ return [DEFAULT_CREDENTIALS_PATH];
74
74
  }
75
75
 
76
76
  // Fallback expiry from the access_token's JWT `exp` claim (epoch ms) when the
@@ -109,6 +109,11 @@ function _loadCredentialsFile(path) {
109
109
  }
110
110
  }
111
111
 
112
+ export function loadCredentialsFromPath(path) {
113
+ if (!path) return null;
114
+ return _loadCredentialsFile(resolve(path));
115
+ }
116
+
112
117
  // Cross-process safe credential save. Lockfile (O_EXCL) prevents two Mixdog
113
118
  // refreshers from clobbering each other; atomic rename guarantees readers see
114
119
  // either the old or new file, never a half-written one. Used so refresh_token
@@ -215,7 +220,15 @@ export function _scrubTokens(text) {
215
220
  .replace(/"refresh_token"\s*:\s*"[^"]+"/g, '"refresh_token":"[REDACTED]"');
216
221
  }
217
222
 
218
- export async function refreshOAuthCredentials(creds) {
223
+ export function isAnthropicOAuthRefreshDisabled() {
224
+ return process.env[ANTHROPIC_OAUTH_REFRESH_DISABLED_ENV] === '1';
225
+ }
226
+
227
+ function _refreshLockPath(path) {
228
+ return `${resolve(path)}.anthropic-oauth-refresh.lock`;
229
+ }
230
+
231
+ async function _refreshOAuthCredentialsUnlocked(creds) {
219
232
  if (!creds?.refreshToken) {
220
233
  throw new Error('Anthropic OAuth refresh token not available. Open /providers in mixdog to sign in again.');
221
234
  }
@@ -294,6 +307,130 @@ export async function refreshOAuthCredentials(creds) {
294
307
  }
295
308
  }
296
309
 
310
+ export async function refreshOAuthCredentials(creds) {
311
+ if (isAnthropicOAuthRefreshDisabled()) {
312
+ throw new Error(
313
+ 'Anthropic OAuth refresh is disabled in this process; '
314
+ + 'host credential preflight must provide a fresh snapshot.',
315
+ );
316
+ }
317
+ const credentialPath = resolve(
318
+ creds?.path
319
+ || process.env.ANTHROPIC_OAUTH_CREDENTIALS_PATH
320
+ || DEFAULT_CREDENTIALS_PATH,
321
+ );
322
+ return withFileLock(_refreshLockPath(credentialPath), async () => {
323
+ const disk = _loadCredentialsFile(credentialPath);
324
+ // A waiter that started with the prior generation must consume the
325
+ // winner's persisted rotation, not replay the single-use token.
326
+ if (disk?.accessToken && creds?.accessToken
327
+ && disk.accessToken !== creds.accessToken
328
+ && (!disk.expiresAt || disk.expiresAt > Date.now())) {
329
+ return disk;
330
+ }
331
+ return _refreshOAuthCredentialsUnlocked(
332
+ disk || { ...creds, path: credentialPath },
333
+ );
334
+ }, {
335
+ timeoutMs: 120_000,
336
+ staleMs: 120_000,
337
+ secret: true,
338
+ });
339
+ }
340
+
341
+ /**
342
+ * Serialize host-side refresh, establish a bounded access-token lease, and
343
+ * optionally write an owner-only snapshot while the lease lock is still held.
344
+ * The returned object deliberately contains metadata only, never token bytes.
345
+ */
346
+ export async function preflightAnthropicOAuthCredentials({
347
+ credentialsPath = null,
348
+ minimumValidityMs = TOKEN_REFRESH_SKEW_MS,
349
+ snapshotPath = null,
350
+ refreshFn = refreshOAuthCredentials,
351
+ now = () => Date.now(),
352
+ lockTimeoutMs = 120_000,
353
+ } = {}) {
354
+ if (isAnthropicOAuthRefreshDisabled()) {
355
+ throw new Error('Anthropic OAuth host preflight cannot run while refresh is disabled.');
356
+ }
357
+ const requiredMs = Number(minimumValidityMs);
358
+ if (!Number.isFinite(requiredMs) || requiredMs < 0) {
359
+ throw new Error('Anthropic OAuth host preflight minimumValidityMs must be a non-negative number.');
360
+ }
361
+ const pinnedPath = resolve(
362
+ credentialsPath
363
+ || process.env.ANTHROPIC_OAUTH_CREDENTIALS_PATH
364
+ || DEFAULT_CREDENTIALS_PATH,
365
+ );
366
+ const initial = _loadCredentialsFile(pinnedPath);
367
+ if (!initial?.path || !initial.accessToken) {
368
+ throw new Error(
369
+ `Anthropic OAuth host preflight found no credentials at ${pinnedPath}. `
370
+ + 'Open /providers in mixdog to sign in.',
371
+ );
372
+ }
373
+
374
+ return withFileLock(_refreshLockPath(pinnedPath), async () => {
375
+ let leased = _loadCredentialsFile(pinnedPath);
376
+ if (!leased?.path || !leased.accessToken) {
377
+ throw new Error('Anthropic OAuth credentials disappeared during host preflight.');
378
+ }
379
+
380
+ const validAfter = now() + requiredMs;
381
+ let refreshed = false;
382
+ if (!leased.expiresAt || leased.expiresAt < validAfter) {
383
+ if (!leased.refreshToken) {
384
+ throw new Error(
385
+ 'Anthropic OAuth host preflight cannot establish the required lease: '
386
+ + 'refresh token is unavailable.',
387
+ );
388
+ }
389
+ // The public refresh function owns this same lock. Call its
390
+ // unlocked exchange only because preflight already owns it;
391
+ // injected test exchanges run under the identical ownership.
392
+ const next = refreshFn === refreshOAuthCredentials
393
+ ? await _refreshOAuthCredentialsUnlocked(leased)
394
+ : await refreshFn(leased);
395
+ const persisted = _loadCredentialsFile(pinnedPath);
396
+ if (!persisted?.accessToken || persisted.accessToken !== next?.accessToken) {
397
+ throw new Error('Anthropic OAuth host preflight refresh was not persisted.');
398
+ }
399
+ leased = persisted;
400
+ refreshed = true;
401
+ }
402
+
403
+ const remainingMs = leased.expiresAt ? leased.expiresAt - now() : 0;
404
+ if (remainingMs < requiredMs) {
405
+ throw new Error(
406
+ `Anthropic OAuth host preflight cannot satisfy the ${Math.ceil(requiredMs / 1000)}s `
407
+ + `credential lease (provider granted ${Math.max(0, Math.floor(remainingMs / 1000))}s).`,
408
+ );
409
+ }
410
+
411
+ if (snapshotPath) {
412
+ const raw = JSON.parse(readFileSync(leased.path, 'utf-8'));
413
+ // Containers need only the leased access token. Never distribute
414
+ // the rotating refresh credential, even into disposable storage.
415
+ if (raw?.claudeAiOauth) {
416
+ delete raw.claudeAiOauth.refreshToken;
417
+ delete raw.claudeAiOauth.refresh_token;
418
+ }
419
+ _saveCredentialsFile(snapshotPath, raw);
420
+ }
421
+ return {
422
+ expiresAt: leased.expiresAt,
423
+ remainingMs,
424
+ refreshed,
425
+ snapshotWritten: Boolean(snapshotPath),
426
+ };
427
+ }, {
428
+ timeoutMs: lockTimeoutMs,
429
+ staleMs: 120_000,
430
+ secret: true,
431
+ });
432
+ }
433
+
297
434
  // --- Login flow (PKCE loopback, export for setup UI / CLI) ---
298
435
 
299
436
  function _oauthGeneratePKCE() {
@@ -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);
@@ -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,
@@ -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);
@@ -83,7 +83,7 @@ import {
83
83
  restoreToolCallBodyForId,
84
84
  } from './loop/stored-tool-args.mjs';
85
85
  import { repairTranscriptBeforeProviderSend } from './loop/transcript-repair.mjs';
86
- import { classifyTerminationReason, INCOMPLETE_STOP_REASONS } from './loop/termination.mjs';
86
+ import { classifyTerminationReason, INCOMPLETE_STOP_REASONS, isOutputLimitStopReason } from './loop/termination.mjs';
87
87
  import { createSteeringLadder } from './loop/steering-ladder.mjs';
88
88
  import { runPreSendCompactPass } from './pre-send-compact.mjs';
89
89
  import { createEagerDispatcher } from './eager-dispatch.mjs';
@@ -116,6 +116,11 @@ export {
116
116
  // this catches tight deterministic-failure loops (e.g. a command that errors
117
117
  // the same way every time) far earlier than 100 iterations.
118
118
  const REPEAT_FAIL_LIMIT = 3;
119
+ // A provider max-output stop is not a completed assistant turn, even when it
120
+ // contains useful text. Preserve each partial in the provider transcript and
121
+ // grant at most three direct continuations before surfacing a hard truncation.
122
+ const MAX_OUTPUT_RECOVERY_LIMIT = 3;
123
+ const MAX_OUTPUT_EXHAUSTED_NOTICE = '[mixdog-runtime] Output remained truncated after 3 continuation attempts.';
119
124
  // _scopedCacheOutcomeForCall and executeTool moved to ./loop/tool-exec.mjs
120
125
  // (imported above).
121
126
  /**
@@ -233,10 +238,16 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
233
238
  const reasoningItems = Array.isArray(resp.reasoningItems) && resp.reasoningItems.length
234
239
  ? resp.reasoningItems
235
240
  : null;
236
- if (!content && !reasoningContent && !reasoningItems) return false;
241
+ const thinkingBlocks = Array.isArray(resp.thinkingBlocks) && resp.thinkingBlocks.length
242
+ ? resp.thinkingBlocks
243
+ : null;
244
+ if (!content && !reasoningContent && !reasoningItems && !thinkingBlocks) return false;
237
245
  messages.push({
238
246
  role: 'assistant',
239
247
  content,
248
+ // Anthropic adaptive-thinking signatures must be replayed verbatim
249
+ // before the continuation turn, just like tool-call trajectories.
250
+ ...(thinkingBlocks ? { thinkingBlocks } : {}),
240
251
  ...(reasoningItems ? { reasoningItems } : {}),
241
252
  ...(reasoningContent ? { reasoningContent } : {}),
242
253
  });
@@ -270,6 +281,8 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
270
281
  // the loop as an explicit empty termination instead.
271
282
  let _emptyNudgeStreak = 0;
272
283
  const EMPTY_NUDGE_MAX = 3;
284
+ let _maxOutputRecoveryCount = 0;
285
+ const _maxOutputContentParts = [];
273
286
  // Claude Code parity: queued prompt/task notifications are attached after a
274
287
  // tool batch, before the continuation provider send. Normal batches drain
275
288
  // up to 'next'; a Sleep-like tool grants a 'later' flush.
@@ -517,22 +530,15 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
517
530
  // Provider may have returned despite an abort (SDKs that don't honour
518
531
  // signal) — bail before processing any of its output.
519
532
  throwIfAborted();
520
- // P1 audit fix (Step4): a text-only turn truncated by the provider's
521
- // max-output limit (response.truncated, set by the provider layer
522
- // when stopReason==='length' AND content is non-empty) used to look
523
- // identical to a clean completion — the model's answer could be
524
- // silently cut mid-sentence with zero signal to the operator. Surface
525
- // it as a one-line stderr warning + trace event WITHOUT failing the
526
- // turn (the partial content is still usable and the loop's own
527
- // isIncompleteStop nudge below already re-prompts when content is
528
- // empty).
533
+ // Keep a diagnostic for every provider-declared truncation. Eligible
534
+ // no-tool text turns are recovered below rather than accepted as final.
529
535
  if (response?.truncated === true) {
530
536
  try {
531
537
  process.stderr.write(
532
538
  `[loop] provider output truncated at max-output limit (sess=${sessionId || 'unknown'} `
533
539
  + `iter=${iterations} stopReason=${response.stopReason ?? response.stop_reason ?? 'length'} `
534
540
  + `contentLen=${typeof response.content === 'string' ? response.content.length : 0}); `
535
- + `answer may be cut off mid-sentence.\n`,
541
+ + `continuation recovery will be attempted when eligible.\n`,
536
542
  );
537
543
  } catch { /* best-effort */ }
538
544
  try {
@@ -600,6 +606,33 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
600
606
  const isHidden = HIDDEN_AGENT_NAMES.has(sessionAgent);
601
607
  const stopReason = response.stopReason ?? response.stop_reason ?? null;
602
608
  const isIncompleteStop = stopReason && INCOMPLETE_STOP_REASONS.has(stopReason);
609
+ const isOutputLimitStop = isOutputLimitStopReason(stopReason);
610
+ if (hasContent && isOutputLimitStop) {
611
+ _maxOutputContentParts.push(response.content);
612
+ if (_maxOutputRecoveryCount < MAX_OUTPUT_RECOVERY_LIMIT) {
613
+ // The partial assistant turn must be visible to the model so
614
+ // it can resume at the exact cutoff instead of reconstructing
615
+ // or repeating it. askSession persists this natural recovery
616
+ // chain; historyContent below prevents the aggregate returned
617
+ // to callers from being persisted a second time.
618
+ pushIntermediateAssistantResponse(response);
619
+ _maxOutputRecoveryCount += 1;
620
+ messages.push({
621
+ role: 'user',
622
+ content: 'Output token limit hit. Resume directly — no apology, no recap. Pick up exactly where the previous text stopped.',
623
+ meta: { source: 'max-output-recovery', attempt: _maxOutputRecoveryCount },
624
+ });
625
+ continue;
626
+ }
627
+ const terminalSegment = `${response.content}\n\n${MAX_OUTPUT_EXHAUSTED_NOTICE}`;
628
+ response = {
629
+ ...response,
630
+ content: `${_maxOutputContentParts.slice(0, -1).join('')}${terminalSegment}`,
631
+ historyContent: terminalSegment,
632
+ maxOutputRecoveryAttempts: _maxOutputRecoveryCount,
633
+ };
634
+ break;
635
+ }
603
636
  if (!hasContent && !isHidden) {
604
637
  _emptyNudgeStreak += 1;
605
638
  if (_emptyNudgeStreak > EMPTY_NUDGE_MAX) {
@@ -622,6 +655,15 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
622
655
  messages.push({ role: 'user', content: nudgeMsg });
623
656
  continue;
624
657
  }
658
+ if (_maxOutputContentParts.length > 0) {
659
+ const terminalSegment = typeof response.content === 'string' ? response.content : '';
660
+ response = {
661
+ ...response,
662
+ content: `${_maxOutputContentParts.join('')}${terminalSegment}`,
663
+ historyContent: terminalSegment,
664
+ maxOutputRecoveryAttempts: _maxOutputRecoveryCount,
665
+ };
666
+ }
625
667
  break;
626
668
  }
627
669
  _emptyNudgeStreak = 0;
@@ -307,6 +307,7 @@ function contextSummaryResult(state, count) {
307
307
 
308
308
  export const DEFAULT_COMPACTION_BUFFER_TOKENS = 0;
309
309
  export const DEFAULT_COMPACTION_BUFFER_RATIO = 0.1;
310
+ export const MAIN_COMPACTION_TRIGGER_RATIO = 0.95;
310
311
  export const MAX_COMPACTION_BUFFER_RATIO = 0.25;
311
312
  export const DEFAULT_COMPACTION_KEEP_TOKENS = 8_000;
312
313
  const LEGACY_DEFAULT_COMPACTION_BUFFER_RATIO = 0.1;
@@ -443,8 +444,8 @@ export function resolveCompactTriggerTokens(sessionOrConfig = {}, boundaryTokens
443
444
  // (trigger = limit) for every session type;
444
445
  // - agent-owned semantic sessions otherwise keep the default early-trigger
445
446
  // buffer (config-driven, default 10% -> compact at 90% of the boundary);
446
- // - main/user recall-fasttrack sessions have NO default buffer and compact on
447
- // the boundary itself (100%).
447
+ // - main/user recall-fasttrack sessions keep 5% headroom and compact at 95%
448
+ // of the effective boundary.
448
449
  // Returns the sanitized explicit limit (null when absent/legacy full-window)
449
450
  // plus triggerTokens / bufferTokens / bufferRatio for the given boundary.
450
451
  export function resolveSessionCompactPolicy(sessionOrConfig = {}, boundaryTokens = 0) {
@@ -466,7 +467,7 @@ export function resolveSessionCompactPolicy(sessionOrConfig = {}, boundaryTokens
466
467
  } else if (isAgentOwner(sessionOrConfig)) {
467
468
  triggerTokens = Math.max(1, boundary - resolveCompactBufferTokens(boundary, cfg));
468
469
  } else {
469
- triggerTokens = boundary;
470
+ triggerTokens = Math.max(1, Math.floor(boundary * MAIN_COMPACTION_TRIGGER_RATIO));
470
471
  }
471
472
  const bufferTokens = Math.max(0, boundary - triggerTokens);
472
473
  const bufferRatio = bufferTokens / boundary;