mixdog 0.9.39 → 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 (59) hide show
  1. package/package.json +2 -1
  2. package/scripts/agent-terminal-reap-test.mjs +6 -6
  3. package/scripts/anthropic-oauth-refresh-race-test.mjs +338 -0
  4. package/scripts/compact-pressure-test.mjs +128 -0
  5. package/scripts/compact-trigger-migration-smoke.mjs +8 -8
  6. package/scripts/execution-resume-esc-integration-test.mjs +4 -2
  7. package/scripts/internal-comms-smoke.mjs +66 -25
  8. package/scripts/max-output-recovery-persist-test.mjs +81 -0
  9. package/scripts/max-output-recovery-test.mjs +222 -0
  10. package/scripts/provider-toolcall-test.mjs +32 -0
  11. package/scripts/steering-drain-buckets-test.mjs +140 -5
  12. package/scripts/tui-transcript-perf-test.mjs +279 -0
  13. package/src/agents/reviewer/AGENT.md +4 -0
  14. package/src/rules/lead/lead-brief.md +11 -3
  15. package/src/rules/lead/lead-tool.md +0 -2
  16. package/src/rules/shared/01-tool.md +4 -0
  17. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +20 -2
  18. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +7 -0
  19. package/src/runtime/agent/orchestrator/context/collect.mjs +7 -3
  20. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +144 -7
  21. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +15 -2
  22. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +2 -2
  23. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +57 -25
  24. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +54 -12
  25. package/src/runtime/agent/orchestrator/session/context-utils.mjs +4 -3
  26. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +106 -8
  27. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +12 -1
  28. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +22 -7
  29. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +26 -1
  30. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +17 -1
  31. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +26 -0
  32. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +31 -4
  33. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +2 -0
  34. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +11 -2
  35. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +45 -0
  36. package/src/runtime/agent/orchestrator/session/store.mjs +9 -1
  37. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +43 -1
  38. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +49 -34
  39. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
  40. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -10
  41. package/src/runtime/shared/buffered-appender.mjs +13 -2
  42. package/src/session-runtime/config-helpers.mjs +6 -6
  43. package/src/session-runtime/lifecycle-api.mjs +4 -0
  44. package/src/session-runtime/runtime-core.mjs +1 -0
  45. package/src/session-runtime/session-turn-api.mjs +12 -0
  46. package/src/standalone/agent-tool.mjs +8 -1
  47. package/src/tui/App.jsx +2 -1
  48. package/src/tui/app/transcript-window.mjs +9 -10
  49. package/src/tui/app/use-transcript-window.mjs +38 -56
  50. package/src/tui/dist/index.mjs +354 -163
  51. package/src/tui/engine/agent-job-feed.mjs +76 -6
  52. package/src/tui/engine/session-api.mjs +7 -1
  53. package/src/tui/engine/session-flow.mjs +3 -1
  54. package/src/tui/engine/tool-card-results.mjs +10 -5
  55. package/src/tui/engine/turn.mjs +96 -36
  56. package/src/tui/engine.mjs +136 -37
  57. package/src/tui/index.jsx +2 -2
  58. package/src/workflows/bench/WORKFLOW.md +51 -27
  59. package/src/workflows/default/WORKFLOW.md +29 -24
@@ -3,6 +3,7 @@
3
3
  // runRecallFastTrackCompact stays in the loop (it drives the recall pipeline
4
4
  // against live session state).
5
5
  import {
6
+ estimateMessagesTokens,
6
7
  estimateRequestReserveTokens,
7
8
  resolveSessionCompactPolicy,
8
9
  } from '../context-utils.mjs';
@@ -18,6 +19,7 @@ import {
18
19
  } from '../compact.mjs';
19
20
  import { positiveTokenInt, envFlag, envTokenInt } from './env.mjs';
20
21
  import { isAgentOwner } from '../../agent-owner.mjs';
22
+ import { providerInputExcludesCache } from '../../providers/registry.mjs';
21
23
 
22
24
  // Unified context-share rule (compact/constants.mjs CONTEXT_SHARE_RATIO): the
23
25
  // post-compaction target is 10% of the boundary/context window — the same 10%
@@ -91,8 +93,8 @@ export function resolveWorkerCompactPolicy(sessionRef, tools) {
91
93
  if (!boundaryTokens) return null;
92
94
  const compactBoundaryTokens = Math.max(1, Math.floor(boundaryTokens * COMPACT_SAFETY_PERCENT));
93
95
  // Shared session-compaction policy (context-utils): agent semantic keeps the
94
- // default early-trigger buffer (90%); main/user compact on the boundary
95
- // (100%); a truly-explicit sub-boundary limit wins. explicitAutoCompactTokenLimit
96
+ // default early-trigger buffer (90%); main/user keep 5% headroom (95%);
97
+ // a truly-explicit sub-boundary limit wins. explicitAutoCompactTokenLimit
96
98
  // is the sanitized (null when legacy full-window) value so telemetry never
97
99
  // re-persists a boundary-collapsing limit.
98
100
  const policy = resolveSessionCompactPolicy(sessionRef, compactBoundaryTokens);
@@ -132,15 +134,103 @@ export function resolveWorkerCompactPolicy(sessionRef, tools) {
132
134
  configuredReserveTokens: configuredReserve,
133
135
  };
134
136
  }
135
- /** Transcript + request reserve only (never provider lastContextTokens). */
137
+ /** Transcript + request reserve fallback used until an aligned provider baseline exists. */
136
138
  function compactPressureTokens(messageTokensEst, policy) {
137
139
  if (messageTokensEst === null) return 0;
138
140
  return Math.max(0, messageTokensEst + (policy?.reserveTokens || 0));
139
141
  }
140
142
 
143
+ function providerPressureTokens(sessionRef, usage) {
144
+ if (!usage || typeof usage !== 'object') return 0;
145
+ const input = Math.max(0, Number(usage.inputTokens) || 0);
146
+ const cachedRead = Math.max(0, Number(usage.cachedTokens) || 0);
147
+ const cacheWrite = Math.max(0, Number(usage.cacheWriteTokens) || 0);
148
+ const explicitPrompt = Math.max(0, Number(usage.promptTokens) || 0);
149
+ const normalizedPrompt = providerInputExcludesCache(sessionRef?.provider)
150
+ ? input + cachedRead + cacheWrite
151
+ : input;
152
+ const prompt = Math.max(explicitPrompt, normalizedPrompt);
153
+ const output = Math.max(0, Number(usage.outputTokens) || 0);
154
+ return Math.max(0, Math.round(prompt + output));
155
+ }
156
+
157
+ /**
158
+ * Align an authoritative provider usage snapshot to the message prefix it
159
+ * covers. Later pressure checks add estimates only for messages after this
160
+ * baseline, matching Claude Code's actual-usage-plus-growth accounting.
161
+ */
162
+ export function recordProviderContextBaseline(sessionRef, messages, usage, { boundary = 'complete' } = {}) {
163
+ if (!sessionRef || !Array.isArray(messages)) return false;
164
+ const tokens = providerPressureTokens(sessionRef, usage);
165
+ if (!tokens) return false;
166
+ sessionRef.contextPressureBaselineTokens = tokens;
167
+ sessionRef.contextPressureBaselineOutputTokens = Math.max(0, Math.round(Number(usage?.outputTokens) || 0));
168
+ sessionRef.contextPressureBaselineMessageCount = messages.length;
169
+ // provider_send usage arrives before the response's assistant message is
170
+ // appended. Mark that request boundary so pressure resolution skips the
171
+ // first subsequent assistant representation: its output (including opaque
172
+ // reasoningItems/tool calls) is already authoritative provider usage.
173
+ sessionRef.contextPressureBaselineBoundary = boundary === 'request' ? 'request' : 'complete';
174
+ sessionRef.contextPressureBaselineUpdatedAt = Date.now();
175
+ sessionRef.lastContextTokensStaleAfterCompact = false;
176
+ return true;
177
+ }
178
+
179
+ /** A changed transcript cannot reuse usage measured against its old prefix. */
180
+ export function invalidateProviderContextBaseline(sessionRef) {
181
+ if (!sessionRef) return;
182
+ sessionRef.contextPressureBaselineTokens = null;
183
+ sessionRef.contextPressureBaselineOutputTokens = null;
184
+ sessionRef.contextPressureBaselineMessageCount = null;
185
+ sessionRef.contextPressureBaselineBoundary = null;
186
+ sessionRef.contextPressureBaselineUpdatedAt = null;
187
+ sessionRef.lastContextTokensStaleAfterCompact = true;
188
+ }
189
+
190
+ function providerBaselinePressureTokens(messages, sessionRef) {
191
+ if (!Array.isArray(messages) || !sessionRef
192
+ || sessionRef.lastContextTokensStaleAfterCompact === true) return null;
193
+ let tokens = positiveTokenInt(sessionRef.contextPressureBaselineTokens);
194
+ const outputTokens = Math.max(0, Number(sessionRef.contextPressureBaselineOutputTokens) || 0);
195
+ let count = Number(sessionRef.contextPressureBaselineMessageCount);
196
+ const baselineAt = Number(sessionRef.contextPressureBaselineUpdatedAt || 0);
197
+ const compactAt = Number(sessionRef.compaction?.lastChangedAt || sessionRef.compaction?.lastCompactAt || 0);
198
+ if (!tokens || !Number.isInteger(count) || count < 0 || count > messages.length
199
+ || (compactAt > 0 && baselineAt > 0 && baselineAt < compactAt)) return null;
200
+ if (sessionRef.contextPressureBaselineBoundary === 'request') {
201
+ const assistantOffset = messages.slice(count).findIndex(message => message?.role === 'assistant');
202
+ if (assistantOffset >= 0) {
203
+ // The represented assistant is covered by actual output usage.
204
+ count += assistantOffset + 1;
205
+ } else {
206
+ // Empty/thinking-only continuations append no assistant replay.
207
+ // Their output was billed but is absent from the next request, so
208
+ // remove it and estimate every genuinely later message (the nudge).
209
+ tokens = Math.max(0, tokens - outputTokens);
210
+ }
211
+ }
212
+ try {
213
+ const growth = count < messages.length
214
+ ? estimateMessagesTokens(messages.slice(count))
215
+ : 0;
216
+ return Math.max(0, tokens + growth);
217
+ } catch {
218
+ return null;
219
+ }
220
+ }
221
+
222
+ export function resolveCompactionPressureTokens(messageTokensEst, policy, { messages, sessionRef } = {}) {
223
+ return providerBaselinePressureTokens(messages, sessionRef)
224
+ ?? compactPressureTokens(messageTokensEst, policy);
225
+ }
226
+
141
227
  /** Telemetry pressure when a reactive overflow retry forces the next compact. */
142
- export function compactionTelemetryPressureTokens(messageTokensEst, policy, { reactivePending = false } = {}) {
143
- const base = compactPressureTokens(messageTokensEst, policy);
228
+ export function compactionTelemetryPressureTokens(messageTokensEst, policy, {
229
+ reactivePending = false,
230
+ messages,
231
+ sessionRef,
232
+ } = {}) {
233
+ const base = resolveCompactionPressureTokens(messageTokensEst, policy, { messages, sessionRef });
144
234
  if (!reactivePending) return base;
145
235
  const floor = positiveTokenInt(policy?.triggerTokens) || positiveTokenInt(policy?.boundaryTokens) || 0;
146
236
  return floor ? Math.max(base, floor) : base;
@@ -152,11 +242,19 @@ export function compactTargetBudget(policy) {
152
242
  const targetEffective = resolveCompactTargetTokens(boundary, policy) || boundary;
153
243
  return Math.max(1, Math.min(boundary, targetEffective + reserve));
154
244
  }
155
- export function shouldCompactForSession(messageTokensEst, policy, { forceReactive = false } = {}) {
245
+ export function shouldCompactForSession(messageTokensEst, policy, {
246
+ forceReactive = false,
247
+ messages,
248
+ sessionRef,
249
+ pressureTokens,
250
+ } = {}) {
156
251
  if (!policy?.auto || !policy.boundaryTokens) return false;
157
252
  if (forceReactive) return true;
158
253
  if (messageTokensEst === null) return true;
159
- return compactPressureTokens(messageTokensEst, policy) >= (policy.triggerTokens || policy.boundaryTokens);
254
+ const pressure = Number.isFinite(Number(pressureTokens))
255
+ ? Number(pressureTokens)
256
+ : resolveCompactionPressureTokens(messageTokensEst, policy, { messages, sessionRef });
257
+ return pressure >= (policy.triggerTokens || policy.boundaryTokens);
160
258
  }
161
259
  export function countPrunedToolOutputs(before, after) {
162
260
  if (!Array.isArray(before) || !Array.isArray(after)) return 0;
@@ -228,7 +326,7 @@ export function rememberCompactTelemetry(sessionRef, policy, meta = {}) {
228
326
  const changedAt = Date.now();
229
327
  sessionRef.compaction.lastChangedAt = changedAt;
230
328
  sessionRef.compaction.lastCompactAt = changedAt;
231
- sessionRef.lastContextTokensStaleAfterCompact = true;
329
+ invalidateProviderContextBaseline(sessionRef);
232
330
  }
233
331
  sessionRef.contextWindow = policy.contextWindow || sessionRef.contextWindow;
234
332
  sessionRef.rawContextWindow = policy.rawContextWindow || sessionRef.rawContextWindow;
@@ -146,6 +146,14 @@ export function createSteeringLadder(ctx) {
146
146
  // read-permission sessions legitimately never edit, so they get the
147
147
  // report-oriented level-2 text.
148
148
  const readOnlyRole = ctx.readOnlyRole === true;
149
+ // Edit-push steering is EXPLORER-ONLY: leads legitimately read broadly
150
+ // before delegating, and reviewer/debugger-style roles read continuously
151
+ // by design. Pushing "start editing / apply the edit" at those roles
152
+ // suppresses delegation (observed on TB2.1: forced-delegation workflow
153
+ // leads went solo right after these nudges). Non-explorer roles keep the
154
+ // batching guidance but never receive an edit directive, and level-2
155
+ // edit-push is skipped for them entirely.
156
+ const editPushEligible = sessionAgent === 'explorer';
149
157
 
150
158
  // Step 1: escalation ladder. _level1FireCount is CUMULATIVE (never reset)
151
159
  // so repeated batching reminders accumulate across the whole session.
@@ -194,6 +202,7 @@ export function createSteeringLadder(ctx) {
194
202
  // level-1 streak and the independent all-read-only streak). Sets the latch
195
203
  // so it fires at most once per 5 turns regardless of which path triggered.
196
204
  const _emitLevel2Steer = () => {
205
+ if (!editPushEligible && !readOnlyRole) return;
197
206
  const iterations = getIterations();
198
207
  _level2LatchAtIteration = iterations;
199
208
  _level2FireCount += 1;
@@ -252,7 +261,9 @@ export function createSteeringLadder(ctx) {
252
261
  if (_canEscalate) {
253
262
  _emitLevel2Steer();
254
263
  } else {
255
- pushSystemReminder('Last 2 turns each ran a single read-only tool. Batch independent lookups (read/grep/glob/code_graph) into ONE turn, or start editing if you have enough context.');
264
+ pushSystemReminder(editPushEligible
265
+ ? 'Last 2 turns each ran a single read-only tool. Batch independent lookups (read/grep/glob/code_graph) into ONE turn, or start editing if you have enough context.'
266
+ : 'Last 2 turns each ran a single read-only tool. Batch independent lookups (read/grep/glob/code_graph) into ONE turn.');
256
267
  }
257
268
  _hintFiredThisTurn = true;
258
269
  }
@@ -3,14 +3,27 @@
3
3
  // the classification ladder is verbatim from the tail of agentLoop.
4
4
  import { HIDDEN_AGENT_NAMES } from './hidden-agents.mjs';
5
5
 
6
- // Stop reasons that signal the turn was cut short mid-synthesis (token cap,
7
- // provider pause). Empty content + one of these reasons means the worker
8
- // was not done. Covers Anthropic (pause_turn, max_tokens), OpenAI (length),
9
- // Gemini (MAX_TOKENS, OTHER), and case variants.
6
+ // Stop reasons that signal the turn was cut short mid-synthesis. This broad set
7
+ // retains the pre-existing EMPTY-turn nudge semantics for provider pauses and
8
+ // unknown Gemini stops; only OUTPUT_LIMIT_STOP_REASONS below is eligible for
9
+ // non-empty continuation recovery.
10
10
  export const INCOMPLETE_STOP_REASONS = new Set([
11
11
  'pause_turn', 'max_tokens', 'length', 'MAX_TOKENS', 'OTHER',
12
12
  ]);
13
13
 
14
+ // True provider output ceilings. pause_turn is a provider-controlled pause and
15
+ // Gemini OTHER is intentionally opaque; neither is safe to treat as a token-cap
16
+ // continuation. Compare case-insensitively so provider spelling variants do not
17
+ // create a second policy.
18
+ export const OUTPUT_LIMIT_STOP_REASONS = new Set([
19
+ 'length', 'max_tokens', 'max_output_tokens',
20
+ ]);
21
+
22
+ export function isOutputLimitStopReason(reason) {
23
+ return typeof reason === 'string'
24
+ && OUTPUT_LIMIT_STOP_REASONS.has(reason.trim().toLowerCase());
25
+ }
26
+
14
27
  // Classify WHY the loop ended so agent-tool can promote an empty/abnormal
15
28
  // finish to an explicit Lead-facing error instead of a silent empty
16
29
  // "completed". Determine "has content" exactly the way the no-tool-call
@@ -23,15 +36,17 @@ export function classifyTerminationReason(response, {
23
36
  || (typeof response?.reasoningContent === 'string' && response.reasoningContent.trim().length > 0);
24
37
  const _finalStopReason = response?.stopReason ?? response?.stop_reason ?? null;
25
38
  const _finalIncompleteStop = _finalStopReason && INCOMPLETE_STOP_REASONS.has(_finalStopReason);
39
+ const _finalOutputLimitStop = isOutputLimitStopReason(_finalStopReason);
26
40
  const _finalIsHidden = HIDDEN_AGENT_NAMES.has(sessionAgent);
27
41
  if (terminatedByCap) {
28
42
  // Real problem regardless of hidden/public: the loop never terminated
29
43
  // on its own contract.
30
44
  return 'iteration_cap';
31
45
  }
32
- if (!_finalHasContent && _finalIncompleteStop) {
33
- // Cut short mid-synthesis (token cap / provider pause). Real problem
34
- // for hidden agents too.
46
+ if (_finalOutputLimitStop || (!_finalHasContent && _finalIncompleteStop)) {
47
+ // Exhausted token-cap recovery is abnormal even with preserved partial
48
+ // text. pause_turn/OTHER retain their prior non-empty completion
49
+ // semantics, while their empty forms remain abnormal.
35
50
  return 'truncated';
36
51
  }
37
52
  if (!_finalHasContent && !_finalIsHidden) {
@@ -44,6 +44,7 @@ import { _tryBridgeExplicitPrefetch } from './prefetch-bridge.mjs';
44
44
  import { sanitizeSessionMessagesForModel, persistCompactedOutgoingAfterAskFailure } from './message-sanitize.mjs';
45
45
  import { _getAgentLoop } from './runtime-loaders.mjs';
46
46
  import { getAgentRuntimeSync } from './agent-runtime-singleton.mjs';
47
+ import { recordProviderContextBaseline } from '../loop/compact-policy.mjs';
47
48
 
48
49
  /**
49
50
  * Wrap an async call so that if the session's controller aborts mid-flight,
@@ -327,6 +328,21 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
327
328
  onAssistantText: typeof askOpts?.onAssistantText === 'function' ? askOpts.onAssistantText : undefined,
328
329
  onUsageDelta: (d) => {
329
330
  persistIterationMetrics(d).catch(() => {});
331
+ // provider_send usage arrives before agentLoop appends
332
+ // the assistant response. Preserve the full actual
333
+ // input/cache/output count and mark this request
334
+ // boundary; compact pressure will skip that first
335
+ // assistant representation and estimate only later
336
+ // tool results/steering.
337
+ if (d?.source === 'provider_send') {
338
+ recordProviderContextBaseline(session, outgoing, {
339
+ inputTokens: d.deltaInput,
340
+ outputTokens: d.deltaOutput,
341
+ promptTokens: d.deltaPrompt,
342
+ cachedTokens: d.deltaCachedRead,
343
+ cacheWriteTokens: d.deltaCacheWrite,
344
+ }, { boundary: 'request' });
345
+ }
330
346
  try { askOpts?.onUsageDelta?.(d); } catch {}
331
347
  },
332
348
  onToolResult: typeof askOpts?.onToolResult === 'function' ? askOpts.onToolResult : undefined,
@@ -401,12 +417,20 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
401
417
  // contextStatus() reverts to the authoritative committed transcript.
402
418
  session.liveTurnMessages = null;
403
419
  if (result.content || result.reasoningContent) {
420
+ // Max-output recovery returns the complete concatenated text to
421
+ // callers/TUI, while outgoing already contains prior partial
422
+ // assistant turns and their continuation prompts. Persist only
423
+ // the terminal segment here so model history contains every byte
424
+ // exactly once.
425
+ const persistedAssistantContent = typeof result.historyContent === 'string'
426
+ ? result.historyContent
427
+ : (result.content || '');
404
428
  session.messages.push({
405
429
  role: 'assistant',
406
430
  // Keep content as-is in memory (model-visible). Image bytes,
407
431
  // if any, are swapped for a placeholder only at disk write
408
432
  // time inside the session store (store.mjs _sessionForDisk).
409
- content: result.content || '',
433
+ content: persistedAssistantContent,
410
434
  ...(typeof result.reasoningContent === 'string' && result.reasoningContent
411
435
  ? { reasoningContent: result.reasoningContent }
412
436
  : {}),
@@ -458,6 +482,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
458
482
  applyAskTerminalUsageTotals(session, result, {
459
483
  skipTotalsIfIncremental: runtime?.usageMetricsTurnIncremental === true,
460
484
  });
485
+ recordProviderContextBaseline(session, session.messages, result.lastTurnUsage || result.usage);
461
486
  // Agent Runtime cache stats — record hit/miss after every successful
462
487
  // ask so the registry reflects all agent traffic, not just
463
488
  // maintenance cycles. Guarded against any agent-runtime error so
@@ -26,6 +26,10 @@ import {
26
26
  compactTypeForSession,
27
27
  } from './context-meta.mjs';
28
28
  import { uncachedInputTokensForProvider } from './usage-metrics.mjs';
29
+ import { pruneOffloadSession } from '../tool-result-offload.mjs';
30
+ import { _getPendingMessagesForSession } from './pending-messages.mjs';
31
+ import { isSessionCompactionBlocked } from './runtime-liveness.mjs';
32
+ import { invalidateProviderContextBaseline } from '../loop/compact-policy.mjs';
29
33
 
30
34
  // 'compacting' is a transient in-flight stage written just before semantic /
31
35
  // recall-fasttrack compaction runs. If the process crashes or only partially
@@ -489,6 +493,18 @@ export async function runSessionCompaction(session, opts = {}) {
489
493
  const unchangedReason = changed ? null : (force ? 'nothing to compact' : 'below threshold');
490
494
  const now = Date.now();
491
495
  session.messages = compacted;
496
+ // Best-effort GC only: the 10-minute mtime gate plus this idle-only guard
497
+ // lets an in-flight turn's sidecars survive until a later compaction/close.
498
+ const pruneSessionId = opts.sessionId || session.id;
499
+ if (!isSessionCompactionBlocked(pruneSessionId)) {
500
+ try {
501
+ await pruneOffloadSession(pruneSessionId, () => [
502
+ session.messages,
503
+ session.liveTurnMessages,
504
+ _getPendingMessagesForSession(pruneSessionId),
505
+ ]);
506
+ } catch { /* best-effort */ }
507
+ }
492
508
  session.providerState = undefined;
493
509
  session.compaction = {
494
510
  ...(session.compaction || {}),
@@ -524,7 +540,7 @@ export async function runSessionCompaction(session, opts = {}) {
524
540
  } : null,
525
541
  compactCount: (session.compaction?.compactCount || 0) + (changed ? 1 : 0),
526
542
  };
527
- if (changed && mode === 'auto') session.lastContextTokensStaleAfterCompact = true;
543
+ if (changed) invalidateProviderContextBaseline(session);
528
544
  return {
529
545
  changed,
530
546
  reason: unchangedReason,
@@ -1,6 +1,7 @@
1
1
  // Steering / pending-message queue with sync buffered + atomic-file persistence.
2
2
  // Extracted verbatim from manager.mjs (behavior-preserving).
3
3
  import { join } from 'path';
4
+ import { readFileSync } from 'fs';
4
5
  import { resolvePluginData } from '../../../../shared/plugin-paths.mjs';
5
6
  import { updateJsonAtomicSync, updateJsonAtomic } from '../../../../shared/atomic-file.mjs';
6
7
  import { promptContentText, isInternalRuntimeNotificationText } from './prompt-utils.mjs';
@@ -458,6 +459,31 @@ export function drainPendingMessages(sessionId) {
458
459
  return out;
459
460
  }
460
461
 
462
+ // Snapshot queued entries without draining them. Compaction uses this to keep
463
+ // sidecars referenced by a message that is waiting for the next turn, whether
464
+ // it is still in memory, buffered for persistence, or already on disk.
465
+ export function _getPendingMessagesForSession(sessionId) {
466
+ if (!isValidPendingSessionId(sessionId)) return [];
467
+ const queued = [
468
+ ...(_sessionPendingMessages.get(sessionId) || []),
469
+ ...(_pendingPersistBuffers.get(sessionId) || []),
470
+ ];
471
+ let raw;
472
+ try {
473
+ raw = readFileSync(pendingMessagesPath(), 'utf8');
474
+ } catch (err) {
475
+ if (err?.code === 'ENOENT') return queued;
476
+ throw err;
477
+ }
478
+ try {
479
+ const persisted = normalizePendingStore(JSON.parse(raw)).sessions[sessionId];
480
+ if (Array.isArray(persisted)) queued.push(...persisted);
481
+ } catch (err) {
482
+ throw err;
483
+ }
484
+ return queued;
485
+ }
486
+
461
487
  // Cleanup hook for closeSession — drop the in-memory queue and buffered-persist
462
488
  // entry so both Maps do not accumulate one entry per closed session.
463
489
  export function _dropPendingMessageState(id, { clearPersisted = true } = {}) {
@@ -11,7 +11,7 @@
11
11
  // avoid a circular import back into manager.mjs.
12
12
  //
13
13
  // Entry shape: {
14
- // stage, lastStreamDeltaAt, lastToolCall, lastError, updatedAt,
14
+ // stage, lastStreamDeltaAt, lastTransportAt, lastToolCall, lastError, updatedAt,
15
15
  // controller?: AbortController, // set while an ask is in flight
16
16
  // generation?: number, // snapshot taken at ask start
17
17
  // closed?: boolean, // flipped by closeSession()
@@ -46,7 +46,7 @@ export function configureRuntimeLiveness(deps = {}) {
46
46
  export function _touchRuntime(id) {
47
47
  let entry = _runtimeState.get(id);
48
48
  if (!entry) {
49
- entry = { stage: 'idle', lastStreamDeltaAt: null, lastToolCall: null, lastError: null, updatedAt: Date.now() };
49
+ entry = { stage: 'idle', lastStreamDeltaAt: null, lastTransportAt: null, lastToolCall: null, lastError: null, updatedAt: Date.now() };
50
50
  _runtimeState.set(id, entry);
51
51
  }
52
52
  return entry;
@@ -106,6 +106,8 @@ export function markSessionAskStart(id) {
106
106
  if (sessionForTurn) bumpUsageMetricsTurnId(sessionForTurn);
107
107
  entry.stage = 'connecting';
108
108
  entry.lastStreamDeltaAt = null;
109
+ entry.lastTransportAt = null;
110
+ entry.transportTrackingEnabled = false;
109
111
  entry.lastToolCall = null;
110
112
  entry.toolStartedAt = null;
111
113
  entry.toolSelfDeadlineMs = null;
@@ -134,6 +136,27 @@ export function markSessionAskStart(id) {
134
136
  // markSessionStreamDelta keeps refreshing once chunks arrive.
135
137
  publishHeartbeat(id, now);
136
138
  }
139
+ export function enableSessionTransportTracking(id) {
140
+ if (!id) return;
141
+ const entry = _runtimeState.get(id);
142
+ if (!entry || entry.closed || entry.controller?.signal?.aborted) return;
143
+ entry.transportTrackingEnabled = true;
144
+ }
145
+ export function disableSessionTransportTracking(id) {
146
+ if (!id) return;
147
+ const entry = _runtimeState.get(id);
148
+ if (!entry) return;
149
+ entry.transportTrackingEnabled = false;
150
+ }
151
+ export function markSessionTransportActivity(id) {
152
+ if (!id) return;
153
+ const entry = _runtimeState.get(id);
154
+ if (!entry || entry.closed || entry.controller?.signal?.aborted) return;
155
+ const now = Date.now();
156
+ entry.lastTransportAt = now;
157
+ entry.updatedAt = now;
158
+ publishHeartbeat(id, now);
159
+ }
137
160
  export async function markSessionStreamDelta(id) {
138
161
  if (!id) return;
139
162
  // Non-creating lookup: a live ask ALWAYS has a runtime entry (markSessionAskStart
@@ -285,16 +308,20 @@ export function getSessionProgressSnapshot(sessionId) {
285
308
  entry.toolStartedAt || 0,
286
309
  );
287
310
  const stage = entry.stage || 'idle';
311
+ const waitingStage = stage === 'connecting'
312
+ || stage === 'requesting'
313
+ || (stage === 'streaming' && entry.transportTrackingEnabled === true);
288
314
  const waitingForFirstActivity = Boolean(
289
315
  modelRequestStartedAt
290
- && (stage === 'connecting' || stage === 'requesting')
291
- && firstActivityAt <= modelRequestStartedAt
316
+ && waitingStage
317
+ && (!firstActivityAt || firstActivityAt <= modelRequestStartedAt)
292
318
  );
293
319
  return {
294
320
  stage,
295
321
  askStartedAt,
296
322
  modelRequestStartedAt,
297
323
  firstActivityAt,
324
+ lastTransportAt: entry.lastTransportAt || 0,
298
325
  lastStreamDeltaAt: entry.lastStreamDeltaAt || 0,
299
326
  toolStartedAt: entry.toolStartedAt || 0,
300
327
  currentTool: entry.lastToolCall || null,
@@ -163,6 +163,8 @@ export function bumpUsageMetricsTurnId(session) {
163
163
  if (!session || typeof session !== 'object') return 0;
164
164
  const next = (Number(session.usageMetricsTurnId) || 0) + 1;
165
165
  session.usageMetricsTurnId = next;
166
+ const seen = _metricSeenIter.get(session.id);
167
+ if (seen) seen.clear();
166
168
  return next;
167
169
  }
168
170
 
@@ -61,8 +61,17 @@ export async function runPreSendCompactPass(state) {
61
61
  };
62
62
  const messageTokensEst = estimateMessagesTokensSafe(messages);
63
63
  const reactivePending = reactiveOverflowRetryPending === true;
64
- const shouldCompact = shouldCompactForSession(messageTokensEst, compactPolicy, { forceReactive: reactivePending });
65
- const pressureTokens = compactionTelemetryPressureTokens(messageTokensEst, compactPolicy, { reactivePending });
64
+ const pressureTokens = compactionTelemetryPressureTokens(messageTokensEst, compactPolicy, {
65
+ reactivePending,
66
+ messages,
67
+ sessionRef,
68
+ });
69
+ const shouldCompact = shouldCompactForSession(messageTokensEst, compactPolicy, {
70
+ forceReactive: reactivePending,
71
+ messages,
72
+ sessionRef,
73
+ pressureTokens,
74
+ });
66
75
  // A pending reactive-overflow retry makes THIS compact pass the
67
76
  // recovery from a provider overflow refusal, not the proactive
68
77
  // pressure trigger. Tag the emitted events so telemetry can tell
@@ -9,6 +9,24 @@ import { isContextOverflowError } from '../providers/retry-classifier.mjs';
9
9
  import { resolveWorkerCompactPolicy } from './loop/compact-policy.mjs';
10
10
  import { agentContextOverflowError } from './loop/context-overflow.mjs';
11
11
  import { estimateMessagesTokensSafe } from './loop/compact-debug.mjs';
12
+ import { isOutputLimitStopReason } from './loop/termination.mjs';
13
+
14
+ function normalizedIncompleteUsage(raw) {
15
+ if (!raw || typeof raw !== 'object') return undefined;
16
+ const inputTokens = Number(raw.promptTokenCount ?? raw.prompt_token_count ?? raw.input_tokens ?? raw.prompt_tokens ?? 0) || 0;
17
+ const candidateTokens = Number(raw.candidatesTokenCount ?? raw.candidates_token_count ?? 0) || 0;
18
+ const thoughtTokens = Number(raw.thoughtsTokenCount ?? raw.thoughts_token_count ?? 0) || 0;
19
+ const outputFallback = Number(raw.output_tokens ?? raw.completion_tokens ?? 0) || 0;
20
+ const cachedTokens = Number(raw.cachedContentTokenCount ?? raw.cached_content_token_count ?? raw.cached_tokens ?? 0) || 0;
21
+ return {
22
+ inputTokens,
23
+ outputTokens: candidateTokens + thoughtTokens || outputFallback,
24
+ cachedTokens,
25
+ cacheWriteTokens: 0,
26
+ promptTokens: inputTokens,
27
+ raw,
28
+ };
29
+ }
12
30
 
13
31
  export async function sendWithRecovery(ctx) {
14
32
  const {
@@ -19,6 +37,33 @@ export async function sendWithRecovery(ctx) {
19
37
  try {
20
38
  response = await provider.send(messages, model, sendTools.length ? sendTools : undefined, opts);
21
39
  } catch (sendErr) {
40
+ // Gemini REST/SDK reports MAX_TOKENS by throwing a typed
41
+ // ProviderIncompleteError after preserving the streamed candidate.
42
+ // Normalize only that exact, safe no-tool output-limit shape into a
43
+ // regular truncated response; all moderation/OTHER/tool-bearing and
44
+ // unrelated errors continue through their existing error paths.
45
+ if (
46
+ sendErr?.providerIncomplete === true
47
+ && sendErr.code === 'PROVIDER_INCOMPLETE'
48
+ && isOutputLimitStopReason(sendErr.finishReason)
49
+ && typeof sendErr.partialContent === 'string'
50
+ && sendErr.partialContent.trim().length > 0
51
+ && sendErr.pendingToolUse !== true
52
+ && sendErr.emittedToolCall !== true
53
+ && !(Array.isArray(sendErr.partialToolCalls) && sendErr.partialToolCalls.length > 0)
54
+ ) {
55
+ response = {
56
+ content: sendErr.partialContent,
57
+ model: sendErr.model || model,
58
+ toolCalls: undefined,
59
+ usage: normalizedIncompleteUsage(sendErr.rawUsage),
60
+ stopReason: sendErr.finishReason,
61
+ truncated: true,
62
+ providerState: opts.providerState,
63
+ providerIncompleteRecovery: true,
64
+ };
65
+ return { action: 'proceed', response };
66
+ } else
22
67
  // Partial-final recovery (owner-notify fix): the recurring "worker
23
68
  // finished but the task hung / no result delivered" case is a FINAL,
24
69
  // no-tool summary stream that wedges (ping-only) AFTER all real tool
@@ -289,7 +289,10 @@ function _getOrSpawnWorker() {
289
289
  // (the originating call plus any that coalesced onto it before it was
290
290
  // posted). A supersede never lands here as a rejection — only a real
291
291
  // worker failure does.
292
- if (ok) { for (const w of waiters) w.resolve(); }
292
+ if (ok) {
293
+ clearSessionSaveError(id);
294
+ for (const w of waiters) w.resolve();
295
+ }
293
296
  else {
294
297
  const e = new Error(`[session-store] worker save failed: ${error}`);
295
298
  for (const w of waiters) w.reject(e);
@@ -441,6 +444,7 @@ function _doSaveSync(payload) {
441
444
  }
442
445
  _renameWithRetrySync(tmp, target);
443
446
  _upsertSessionSummary(session);
447
+ clearSessionSaveError(id);
444
448
  } catch (err) {
445
449
  try { unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
446
450
  throw err;
@@ -580,6 +584,7 @@ async function _doSave(payload) {
580
584
  }
581
585
  _renameWithRetrySync(tmp, target);
582
586
  _upsertSessionSummary(session);
587
+ clearSessionSaveError(id);
583
588
  } catch (err) {
584
589
  try { unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
585
590
  _savePending.delete(id);
@@ -643,6 +648,7 @@ export function markSessionClosed(id, reason = 'manual') {
643
648
  return null;
644
649
  }
645
650
  _savePending.delete(id);
651
+ clearSessionSaveError(id);
646
652
  _clearLiveSession(id);
647
653
  _deleteHeartbeat(id);
648
654
  _upsertSessionSummary(tombstone);
@@ -702,6 +708,7 @@ export function bumpSessionGeneration(id, reason = 'detach') {
702
708
  return null;
703
709
  }
704
710
  _savePending.delete(id);
711
+ clearSessionSaveError(id);
705
712
  _clearLiveSession(id);
706
713
  _deleteHeartbeat(id);
707
714
  _upsertSessionSummary(detached);
@@ -748,6 +755,7 @@ export function deleteSession(id, options = {}) {
748
755
  catch { /* fall through to .hb cleanup */ }
749
756
  }
750
757
  _deleteHeartbeat(id);
758
+ if (removed || !existsSync(path)) clearSessionSaveError(id);
751
759
  // deferSummaryUpdate: bulk callers (tombstone sweep) remove thousands of
752
760
  // rows — a per-id _removeSessionSummary would parse+rewrite the multi-MB
753
761
  // summary index once PER DELETION. They batch the index update themselves.