mixdog 0.9.15 → 0.9.17

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 (134) hide show
  1. package/package.json +2 -1
  2. package/scripts/atomic-lock-tryonce-test.mjs +66 -0
  3. package/scripts/explore-bench.mjs +5 -4
  4. package/scripts/ingest-pure-conversation-smoke.mjs +27 -0
  5. package/scripts/output-style-smoke.mjs +3 -3
  6. package/scripts/provider-toolcall-test.mjs +79 -2
  7. package/scripts/termio-input-smoke.mjs +199 -0
  8. package/scripts/tool-efficiency-diag.mjs +88 -0
  9. package/scripts/tool-smoke.mjs +40 -24
  10. package/scripts/tui-frame-harness-shim.mjs +32 -0
  11. package/scripts/tui-frame-harness.mjs +306 -0
  12. package/src/agents/heavy-worker/AGENT.md +6 -7
  13. package/src/agents/worker/AGENT.md +6 -7
  14. package/src/lib/keychain-cjs.cjs +28 -11
  15. package/src/lib/rules-builder.cjs +6 -10
  16. package/src/mixdog-session-runtime.mjs +102 -20
  17. package/src/output-styles/simple.md +22 -24
  18. package/src/rules/agent/00-core.md +12 -11
  19. package/src/rules/agent/30-explorer.md +22 -21
  20. package/src/rules/lead/01-general.md +8 -8
  21. package/src/rules/lead/lead-brief.md +11 -12
  22. package/src/rules/shared/01-tool.md +25 -14
  23. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +4 -3
  24. package/src/runtime/agent/orchestrator/config.mjs +1 -1
  25. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +33 -1
  26. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +31 -1
  27. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +49 -0
  28. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +35 -1
  29. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +29 -8
  30. package/src/runtime/agent/orchestrator/providers/gemini.mjs +13 -5
  31. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +1 -1
  32. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +11 -0
  33. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +18 -0
  34. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +1 -1
  35. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +30 -2
  36. package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +19 -0
  37. package/src/runtime/agent/orchestrator/session/compact/constants.mjs +2 -2
  38. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +1 -1
  39. package/src/runtime/agent/orchestrator/session/compact/summary.mjs +37 -15
  40. package/src/runtime/agent/orchestrator/session/context-utils.mjs +62 -0
  41. package/src/runtime/agent/orchestrator/session/lifecycle-scan.mjs +177 -0
  42. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +12 -19
  43. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +10 -0
  44. package/src/runtime/agent/orchestrator/session/loop.mjs +30 -1
  45. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +8 -0
  46. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +13 -18
  47. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +37 -19
  48. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +4 -1
  49. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +142 -3
  50. package/src/runtime/agent/orchestrator/session/manager.mjs +59 -2
  51. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +118 -22
  52. package/src/runtime/agent/orchestrator/session/store.mjs +38 -25
  53. package/src/runtime/agent/orchestrator/stall-policy.mjs +22 -12
  54. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +4 -2
  55. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +7 -5
  56. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +38 -1
  57. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +24 -0
  58. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +17 -16
  59. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +14 -1
  60. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +25 -1
  61. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +73 -3
  62. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +23 -10
  63. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +55 -0
  64. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +10 -2
  65. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +8 -2
  66. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +21 -13
  67. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +13 -2
  68. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +44 -6
  69. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +5 -0
  70. package/src/runtime/channels/backends/telegram.mjs +29 -9
  71. package/src/runtime/channels/lib/event-queue.mjs +6 -2
  72. package/src/runtime/channels/lib/memory-client.mjs +66 -1
  73. package/src/runtime/channels/lib/runtime-paths.mjs +8 -19
  74. package/src/runtime/channels/lib/webhook/deliveries.mjs +22 -1
  75. package/src/runtime/memory/index.mjs +132 -8
  76. package/src/runtime/memory/lib/cycle-scheduler.mjs +24 -5
  77. package/src/runtime/memory/lib/embedding-warmup.mjs +3 -0
  78. package/src/runtime/memory/lib/ko-morph.mjs +1 -0
  79. package/src/runtime/memory/lib/memory-cycle1.mjs +45 -3
  80. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +7 -3
  81. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +54 -10
  82. package/src/runtime/memory/lib/memory-cycle2.mjs +21 -8
  83. package/src/runtime/memory/lib/memory-embed.mjs +48 -0
  84. package/src/runtime/memory/lib/memory-recall-store.mjs +57 -13
  85. package/src/runtime/memory/lib/memory.mjs +88 -1
  86. package/src/runtime/memory/lib/session-ingest.mjs +34 -3
  87. package/src/runtime/memory/lib/trace-store.mjs +52 -3
  88. package/src/runtime/memory/lib/transcript-ingest.mjs +27 -4
  89. package/src/runtime/shared/atomic-file.mjs +110 -0
  90. package/src/runtime/shared/child-guardian.mjs +4 -2
  91. package/src/runtime/shared/open-url.mjs +2 -1
  92. package/src/runtime/shared/singleton-owner.mjs +26 -0
  93. package/src/runtime/shared/tool-execution-contract.mjs +25 -0
  94. package/src/runtime/shared/transcript-writer.mjs +49 -5
  95. package/src/session-runtime/config-helpers.mjs +7 -2
  96. package/src/session-runtime/cwd-plugins.mjs +6 -1
  97. package/src/session-runtime/effort.mjs +6 -1
  98. package/src/session-runtime/plugin-mcp.mjs +116 -12
  99. package/src/session-runtime/provider-models.mjs +47 -8
  100. package/src/session-runtime/settings-api.mjs +7 -1
  101. package/src/standalone/channel-worker.mjs +25 -3
  102. package/src/standalone/explore-tool.mjs +5 -5
  103. package/src/standalone/hook-bus/config.mjs +38 -2
  104. package/src/standalone/hook-bus/handlers.mjs +103 -11
  105. package/src/standalone/hook-bus.mjs +20 -6
  106. package/src/standalone/memory-runtime-proxy.mjs +40 -5
  107. package/src/tui/App.jsx +214 -30
  108. package/src/tui/app/core-memory-picker.mjs +6 -6
  109. package/src/tui/app/model-options.mjs +10 -4
  110. package/src/tui/app/settings-picker.mjs +5 -30
  111. package/src/tui/app/slash-commands.mjs +0 -1
  112. package/src/tui/app/slash-dispatch.mjs +0 -16
  113. package/src/tui/app/text-layout.mjs +57 -0
  114. package/src/tui/app/transcript-window.mjs +162 -2
  115. package/src/tui/app/use-mouse-input.mjs +60 -47
  116. package/src/tui/app/use-transcript-window.mjs +96 -17
  117. package/src/tui/components/PromptInput.jsx +18 -1
  118. package/src/tui/components/StatusLine.jsx +1 -1
  119. package/src/tui/components/TextEntryPanel.jsx +97 -33
  120. package/src/tui/dist/index.mjs +856 -288
  121. package/src/tui/engine/tool-card-results.mjs +22 -5
  122. package/src/tui/engine/tui-steering-persist.mjs +66 -35
  123. package/src/tui/engine.mjs +41 -9
  124. package/src/tui/index.jsx +101 -7
  125. package/src/ui/statusline-segments.mjs +54 -36
  126. package/src/ui/statusline.mjs +141 -95
  127. package/src/workflows/default/WORKFLOW.md +27 -31
  128. package/vendor/ink/build/components/App.js +62 -17
  129. package/vendor/ink/build/ink.js +78 -3
  130. package/vendor/ink/build/input-parser.d.ts +9 -4
  131. package/vendor/ink/build/input-parser.js +45 -176
  132. package/vendor/ink/build/log-update.js +47 -2
  133. package/vendor/ink/build/termio-keypress.js +240 -0
  134. package/vendor/ink/build/termio-tokenize.js +253 -0
@@ -102,6 +102,11 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
102
102
  let content = '';
103
103
  let hasThinkingContent = false;
104
104
  const contentBlockTypes = new Set();
105
+ // Ordered extended-thinking blocks, keyed by content_block index. Each
106
+ // holds the accumulated thinking text + signature exactly as received so
107
+ // it can be round-tripped verbatim on tool-continuation turns (required
108
+ // back on tool_use turns; empty thinking + signature is valid).
109
+ const thinkingBlocks = new Map();
105
110
  let model = '';
106
111
  let toolCalls = [];
107
112
  let usage = { inputTokens: 0, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, raw: null };
@@ -389,6 +394,26 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
389
394
  inputJson: '',
390
395
  });
391
396
  }
397
+ if (block?.type === 'thinking' || block?.type === 'redacted_thinking') {
398
+ if (block.type === 'redacted_thinking') {
399
+ // Redacted blocks round-trip EXACTLY as
400
+ // {type:'redacted_thinking',data} — no thinking/
401
+ // signature fields (the API rejects the extras).
402
+ // `data` carries the opaque payload verbatim.
403
+ thinkingBlocks.set(event.index, {
404
+ type: 'redacted_thinking',
405
+ data: typeof block.data === 'string' ? block.data : '',
406
+ });
407
+ } else {
408
+ // Seed an ordered thinking block; deltas below
409
+ // append text + signature into this same slot.
410
+ thinkingBlocks.set(event.index, {
411
+ type: 'thinking',
412
+ thinking: typeof block.thinking === 'string' ? block.thinking : '',
413
+ signature: typeof block.signature === 'string' ? block.signature : '',
414
+ });
415
+ }
416
+ }
392
417
  }
393
418
 
394
419
  if (event.type === 'content_block_delta') {
@@ -433,6 +458,21 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
433
458
  // tool_use) can be classified by the loop as
434
459
  // synthesis-stalled rather than silent empty.
435
460
  hasThinkingContent = true;
461
+ // Accumulate the block content in order so it can be
462
+ // returned intact and round-tripped on the next turn.
463
+ // A signature_delta may arrive before any thinking_delta
464
+ // seeded the slot (display-omitted models emit only a
465
+ // signature) — lazily create it.
466
+ let tb = thinkingBlocks.get(event.index);
467
+ if (!tb) {
468
+ tb = { type: 'thinking', thinking: '', signature: '' };
469
+ thinkingBlocks.set(event.index, tb);
470
+ }
471
+ if (delta.type === 'thinking_delta') {
472
+ tb.thinking += delta.thinking || '';
473
+ } else {
474
+ tb.signature += delta.signature || '';
475
+ }
436
476
  try { onStreamDelta?.(); } catch {}
437
477
  }
438
478
  if (delta?.type === 'input_json_delta') {
@@ -577,6 +617,15 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
577
617
  stopReason,
578
618
  hasThinkingContent,
579
619
  contentBlockTypes: Array.from(contentBlockTypes),
620
+ // Ordered extended-thinking blocks (verbatim thinking text +
621
+ // signature) for round-tripping on tool-continuation turns. Emitted
622
+ // in content_block index order. Empty thinking + signature is a
623
+ // valid block (display-omitted models) and is kept intact.
624
+ thinkingBlocks: thinkingBlocks.size
625
+ ? [...thinkingBlocks.entries()]
626
+ .sort((a, b) => a[0] - b[0])
627
+ .map(([, b]) => b)
628
+ : undefined,
580
629
  };
581
630
  } finally {
582
631
  if (idleTimer) clearTimeout(idleTimer);
@@ -1,6 +1,6 @@
1
1
  import { createRequire } from 'node:module';
2
2
  import { loadConfig } from '../config.mjs';
3
- import { sanitizeToolPairs, sanitizeAnthropicContentPairs } from '../session/context-utils.mjs';
3
+ import { sanitizeToolPairs, sanitizeAnthropicContentPairs, foldUserTextIntoToolResultTail } from '../session/context-utils.mjs';
4
4
  import { classifyError, midstreamBackoffFor, sleepWithAbort, withRetry } from './retry-classifier.mjs';
5
5
  import { traceAgentUsage } from '../agent-trace.mjs';
6
6
  import {
@@ -310,6 +310,10 @@ function nativeAnthropicTools(opts) {
310
310
  }
311
311
  function deferredAnthropicTools(activeTools, opts) {
312
312
  if (opts?.session?.deferredNativeTools !== true) return [];
313
+ // Guard against an all-deferred tools array — the API rejects it with
314
+ // `400: At least one tool must have defer_loading=false` (iteration-cap
315
+ // final turn sends tools: []). See anthropic-oauth.mjs counterpart.
316
+ if (!Array.isArray(activeTools) || activeTools.length === 0) return [];
313
317
  const active = new Set((activeTools || []).map((tool) => String(tool?.name || '').trim()).filter(Boolean));
314
318
  const catalog = Array.isArray(opts.session.deferredToolCatalog) ? opts.session.deferredToolCatalog : [];
315
319
  return catalog
@@ -333,6 +337,14 @@ function toAnthropicMessages(messages) {
333
337
  content = m.assistantBlocks.slice();
334
338
  } else {
335
339
  content = [];
340
+ // Adaptive-thinking round-trip: prior-turn thinking blocks are
341
+ // REQUIRED back, unmodified (signature intact; empty thinking
342
+ // field allowed), and MUST precede tool_use blocks.
343
+ if (Array.isArray(m.thinkingBlocks)) {
344
+ for (const tb of m.thinkingBlocks) {
345
+ if (tb && typeof tb === 'object') content.push(tb);
346
+ }
347
+ }
336
348
  if (m.content) content.push({ type: 'text', text: m.content });
337
349
  for (const tc of m.toolCalls) {
338
350
  content.push({
@@ -366,6 +378,12 @@ function toAnthropicMessages(messages) {
366
378
  }
367
379
  continue;
368
380
  }
381
+ // Claude Code parity: fold a user text turn that directly follows a
382
+ // tool_result turn into that tool_result's content (empty end_turn
383
+ // livelock prevention; see foldUserTextIntoToolResultTail).
384
+ if (m.role === 'user' && foldUserTextIntoToolResultTail(result, normalizeContentForAnthropic(m.content))) {
385
+ continue;
386
+ }
369
387
  result.push({ role: m.role, content: normalizeContentForAnthropic(m.content) });
370
388
  }
371
389
  return sanitizeAnthropicContentPairs(result);
@@ -662,6 +680,12 @@ export class AnthropicProvider {
662
680
  contentBlockTypes: Array.isArray(parseResult.contentBlockTypes)
663
681
  ? parseResult.contentBlockTypes
664
682
  : [],
683
+ // Round-trip adaptive-thinking blocks (verbatim thinking +
684
+ // signature) so the loop can store them and replay them before
685
+ // tool_use on the next turn. Matches anthropic-oauth's return.
686
+ thinkingBlocks: Array.isArray(parseResult.thinkingBlocks) && parseResult.thinkingBlocks.length
687
+ ? parseResult.thinkingBlocks
688
+ : undefined,
665
689
  usage: {
666
690
  inputTokens: input,
667
691
  outputTokens: output,
@@ -808,6 +832,16 @@ export class AnthropicProvider {
808
832
  if (attemptIndex > 0 && firstAttemptError) {
809
833
  try { firstAttemptError.midstreamRetries = attemptIndex; } catch {}
810
834
  try { firstAttemptError.midstreamClassifier = firstAttemptClassifier; } catch {}
835
+ // firstAttemptError (from an earlier retried attempt)
836
+ // is what actually propagates when live text was
837
+ // emitted this attempt — stamp the unsafe/partial
838
+ // flags onto IT too, otherwise upstream sees an
839
+ // unmarked error and retries, duplicating streamed
840
+ // output.
841
+ try {
842
+ firstAttemptError.liveTextEmitted = true;
843
+ firstAttemptError.unsafeToRetry = true;
844
+ } catch {}
811
845
  throw firstAttemptError;
812
846
  }
813
847
  throw err;
@@ -47,22 +47,38 @@ export function geminiTruncatedStreamError(message) {
47
47
  // stall after visible output was silently retried. Visible-text stalls also
48
48
  // gain streamStalled + partialContent so the loop's partial-final path can
49
49
  // keep the streamed output instead of dropping the turn.
50
- export function stampGeminiStreamFailure(err, { relayedText = '', textLeakGuard = null } = {}) {
50
+ export function stampGeminiStreamFailure(err, { relayedText = '', textLeakGuard = null, sawFunctionCall = false } = {}) {
51
51
  if (!err || typeof err !== 'object') return err;
52
52
  const leaked = (textLeakGuard?.getLeakedToolCalls?.() || []).length > 0;
53
53
  const visible = relayedText.length > 0;
54
+ // A native functionCall chunk (not a text-leaked one) is also an in-flight
55
+ // tool use — replaying would double-dispatch, and partial-final must NOT
56
+ // treat it as a clean no-tool summary.
57
+ const pendingTool = leaked || sawFunctionCall === true;
54
58
  try {
55
59
  if (visible) { err.liveTextEmitted = true; err.unsafeToRetry = true; }
56
- if (leaked) { err.emittedToolCall = true; err.unsafeToRetry = true; }
57
- if (visible && !leaked && err.code === 'EGEMINITIMEOUT') {
60
+ if (pendingTool) { err.emittedToolCall = true; err.unsafeToRetry = true; }
61
+ // TRUNCATED_STREAM EOF after visible output must also carry the
62
+ // partial-final stamps (streamStalled/partialContent), aligning with the
63
+ // compat streams — otherwise live output is dropped instead of kept.
64
+ if (visible && !leaked
65
+ && (err.code === 'EGEMINITIMEOUT' || err.code === 'TRUNCATED_STREAM' || err.truncatedStream === true)) {
58
66
  err.streamStalled = true;
59
67
  if (typeof err.partialContent !== 'string') err.partialContent = relayedText;
60
- if (err.pendingToolUse === undefined) err.pendingToolUse = false;
68
+ if (err.pendingToolUse === undefined) err.pendingToolUse = pendingTool;
61
69
  }
62
70
  } catch { /* best-effort */ }
63
71
  return err;
64
72
  }
65
73
 
74
+ // True when a streamed Gemini chunk carries a native functionCall part (as
75
+ // opposed to a tool call leaked as plain text, tracked by textLeakGuard).
76
+ export function geminiChunkHasFunctionCall(chunk) {
77
+ const parts = chunk?.candidates?.[0]?.content?.parts;
78
+ if (!Array.isArray(parts)) return false;
79
+ return parts.some((p) => p && p.functionCall);
80
+ }
81
+
66
82
  /**
67
83
  * Aggregate streamed GenerateContentResponse chunks into one response object
68
84
  * (same shape as a non-streaming generateContent JSON body).
@@ -245,6 +261,7 @@ export async function consumeGeminiRestStreamResponse(response, { signal, onStre
245
261
  let idleTimer = null;
246
262
  let idleReject = null;
247
263
  let relayedText = '';
264
+ let sawFunctionCall = false;
248
265
 
249
266
  let firstByteTimer = setTimeout(() => {
250
267
  try { reader.cancel('first byte timeout'); } catch {}
@@ -332,6 +349,7 @@ export async function consumeGeminiRestStreamResponse(response, { signal, onStre
332
349
  }
333
350
  allChunks.push(parsed);
334
351
  try { onStreamDelta?.(); } catch {}
352
+ if (!sawFunctionCall && geminiChunkHasFunctionCall(parsed)) sawFunctionCall = true;
335
353
  if (onTextDelta || textLeakGuard) {
336
354
  const t = geminiChunkText(parsed);
337
355
  if (t) relayedText += t;
@@ -352,6 +370,7 @@ export async function consumeGeminiRestStreamResponse(response, { signal, onStre
352
370
  }
353
371
  allChunks.push(parsed);
354
372
  try { onStreamDelta?.(); } catch {}
373
+ if (!sawFunctionCall && geminiChunkHasFunctionCall(parsed)) sawFunctionCall = true;
355
374
  if (onTextDelta || textLeakGuard) {
356
375
  const t = geminiChunkText(parsed);
357
376
  if (t) relayedText += t;
@@ -362,7 +381,7 @@ export async function consumeGeminiRestStreamResponse(response, { signal, onStre
362
381
  }
363
382
  }
364
383
  } catch (err) {
365
- throw stampGeminiStreamFailure(err, { relayedText, textLeakGuard });
384
+ throw stampGeminiStreamFailure(err, { relayedText, textLeakGuard, sawFunctionCall });
366
385
  } finally {
367
386
  clearFirstByteTimer();
368
387
  if (idleTimer) clearTimeout(idleTimer);
@@ -380,7 +399,7 @@ export async function consumeGeminiRestStreamResponse(response, { signal, onStre
380
399
  try {
381
400
  assertGeminiStreamCompleted({ sawStreamChunk, finishReason, label });
382
401
  } catch (err) {
383
- throw stampGeminiStreamFailure(err, { relayedText, textLeakGuard });
402
+ throw stampGeminiStreamFailure(err, { relayedText, textLeakGuard, sawFunctionCall });
384
403
  }
385
404
  return aggregated;
386
405
  }
@@ -393,6 +412,7 @@ export async function consumeGeminiSdkStream(streamResult, { signal, onStreamDel
393
412
  let firstByteTimer = null;
394
413
  let inFlightReject = null;
395
414
  let relayedText = '';
415
+ let sawFunctionCall = false;
396
416
 
397
417
  const armFirstByteTimer = () => {
398
418
  if (firstByteTimer) clearTimeout(firstByteTimer);
@@ -504,6 +524,7 @@ export async function consumeGeminiSdkStream(streamResult, { signal, onStreamDel
504
524
  resetIdleTimer();
505
525
  if (step.value) collectedChunks.push(step.value);
506
526
  try { onStreamDelta?.(); } catch {}
527
+ if (!sawFunctionCall && geminiChunkHasFunctionCall(step.value)) sawFunctionCall = true;
507
528
  if (onTextDelta || textLeakGuard) {
508
529
  const t = geminiChunkText(step.value);
509
530
  if (t) relayedText += t;
@@ -519,7 +540,7 @@ export async function consumeGeminiSdkStream(streamResult, { signal, onStreamDel
519
540
  const reason = signal.reason;
520
541
  throw reason instanceof Error ? reason : new Error(`${label} aborted`);
521
542
  }
522
- throw stampGeminiStreamFailure(err, { relayedText, textLeakGuard });
543
+ throw stampGeminiStreamFailure(err, { relayedText, textLeakGuard, sawFunctionCall });
523
544
  } finally {
524
545
  clearFirstByteTimer();
525
546
  if (idleTimer) clearTimeout(idleTimer);
@@ -559,7 +580,7 @@ export async function consumeGeminiSdkStream(streamResult, { signal, onStreamDel
559
580
  try {
560
581
  assertGeminiStreamCompleted({ sawStreamChunk, finishReason, label });
561
582
  } catch (err) {
562
- throw stampGeminiStreamFailure(err, { relayedText, textLeakGuard });
583
+ throw stampGeminiStreamFailure(err, { relayedText, textLeakGuard, sawFunctionCall });
563
584
  }
564
585
  return raw;
565
586
  }
@@ -68,7 +68,7 @@ const DEFAULT_MODEL = MODELS[0].id;
68
68
  const MODEL_CACHE_TTL_MS = 24 * 60 * 60_000;
69
69
  // Bump when the on-disk cache shape changes so stale-shape entries are
70
70
  // discarded instead of misread.
71
- const GEMINI_MODEL_CACHE_SCHEMA_VERSION = 1;
71
+ const GEMINI_MODEL_CACHE_SCHEMA_VERSION = 2;
72
72
 
73
73
  // De-dupes concurrent force-refreshes so they share one HTTP round-trip,
74
74
  // mirroring anthropic-oauth's _modelRefreshInFlight.
@@ -155,6 +155,10 @@ export class GeminiProvider {
155
155
  const cfg = freshConfig.providers?.gemini;
156
156
  const newKey = cfg?.apiKey || process.env.GEMINI_API_KEY;
157
157
  if (newKey) {
158
+ // Keep this.config in sync so REST/cache paths (which read the
159
+ // key via _getApiKey() → this.config.apiKey) don't keep using
160
+ // the stale key after a rotation; genAI alone is not enough.
161
+ this.config = { ...(this.config || {}), ...(cfg || {}), apiKey: newKey };
158
162
  this.genAI = new GoogleGenerativeAI(newKey);
159
163
  }
160
164
  } catch { /* best effort */ }
@@ -871,10 +875,11 @@ export class GeminiProvider {
871
875
  if (!res.ok) throw new Error(`gemini list_models ${res.status}`);
872
876
  const data = await res.json();
873
877
  const items = Array.isArray(data?.models) ? data.models : [];
874
- // Filter to Gemini family; skip embedding/imagen endpoints.
878
+ // Filter to Gemini family; skip embedding/imagen and non-coding SKUs
879
+ // (robotics, computer-use) that the chat picker should never show.
875
880
  const normalized = items
876
881
  .filter(m => (m?.name || '').includes('gemini'))
877
- .filter(m => !/embedding|aqa|imagen/.test(m?.name || ''))
882
+ .filter(m => !/embedding|aqa|imagen|robotics|computer-use/.test(m?.name || ''))
878
883
  .map(m => {
879
884
  const id = (m.name || '').replace(/^models\//, '');
880
885
  const family = /flash-lite/.test(id) ? 'gemini-flash-lite'
@@ -886,8 +891,11 @@ export class GeminiProvider {
886
891
  display: m.displayName || id,
887
892
  family,
888
893
  provider: 'gemini',
889
- contextWindow: m.inputTokenLimit || 1000000,
890
- outputTokens: m.outputTokenLimit || 8192,
894
+ // No fabricated fallbacks: when the API omits limits leave
895
+ // them null so enrichModels can fill from the catalog and
896
+ // the picker shows no context label instead of a fake 1M.
897
+ contextWindow: m.inputTokenLimit || null,
898
+ outputTokens: m.outputTokenLimit || null,
891
899
  tier: 'version',
892
900
  latest: false,
893
901
  description: m.description || '',
@@ -250,7 +250,7 @@ function saveTokens(tokens) {
250
250
  expires_at: tokens.expires_at || 0,
251
251
  token_endpoint: tokens.token_endpoint || null,
252
252
  user_id: tokens.user_id || tokens.userId || _identityFromAccessToken(tokens.access_token).user_id || undefined,
253
- }, { lock: true, fsyncDir: true });
253
+ }, { lock: true, fsyncDir: true, mode: 0o600, secret: true });
254
254
  }
255
255
 
256
256
  function _scrubTokens(text) {
@@ -41,6 +41,7 @@ const NON_CHAT_RE = new RegExp(
41
41
  'dall[-_]?e', 'sora', 'imagen', 'imagine', 'veo', 'kling', 'runway',
42
42
  'realtime', 'transcribe', 'transcription', 'diarize',
43
43
  'guard', 'safety', 'classifier',
44
+ 'robotics', 'computer[-_]?use',
44
45
  ].join('|') +
45
46
  ')([-_/\\s]|$)',
46
47
  'i',
@@ -64,6 +65,15 @@ const NON_LLM_ID_PATTERNS = [
64
65
  /^gpt-audio(\b|-)/i, // gpt-audio, gpt-audio-mini, gpt-audio-1.5
65
66
  /^gpt-realtime(\b|-)/i, // gpt-realtime*, gpt-realtime-mini
66
67
  /-tts(\b|-)/i, // any *-tts SKU
68
+ // Purpose-specific SKUs that report mode:'chat' but are never useful for a
69
+ // coding agent (embodied/robotics, browser computer-use, audio-native,
70
+ // customtools picker-duplicate variants).
71
+ /(^|-)robotics(\b|-)/i, // gemini-robotics-er-*
72
+ /computer[-_]?use/i, // gemini-2.5-computer-use-preview-*
73
+ /native[-_]?audio/i, // gemini-2.5-flash-native-audio-*
74
+ /-customtools(\b|-|$)/i, // gemini-3.1-pro-preview-customtools
75
+ /^codex-auto-review$/i, // Codex backend auto-review model, not a picker choice
76
+ /^grok-build$/i, // proxy alias duplicating grok-build-0.1 (exact — 0.1 stays)
67
77
  ];
68
78
 
69
79
  function _isNonLlmId(lid) {
@@ -89,6 +99,7 @@ const LEGACY_PATTERNS = [
89
99
  /^claude-(1|2|3|instant)(\b|-|\.)/i,
90
100
  // Gemini legacy — gemini-1*, gemini-pro. gemini-2/3 not matched.
91
101
  /^gemini-1(\b|-|\.)/i,
102
+ /^gemini-2\.0(\b|-|\.)/i,
92
103
  /^gemini-pro(\b|-)/i,
93
104
  // Grok legacy — grok-1/2/3 and grok-beta. The digit boundary stops grok-3
94
105
  // from catching grok-4.20-beta.
@@ -669,6 +669,17 @@ function handleCompatResponsesStreamEvent(event, state, { label, parseResponsesT
669
669
  } else if (event.response.status === 'incomplete') {
670
670
  const reason = incompleteReasonFromResponsesEvent(event);
671
671
  if (isMaxOutputIncompleteReason(reason)) {
672
+ // Max-output cutoff with a tool call still in flight means
673
+ // the function-call arguments were truncated — do NOT mark
674
+ // this a clean completion, or partial args surface as a
675
+ // successful tool call. Treat as unsafe/partial instead.
676
+ if (state.toolInFlight || (state.pendingCalls && state.pendingCalls.size > 0)) {
677
+ const err = truncatedCompatStreamError(label, `incomplete (${reason}) with tool call in flight`);
678
+ err.streamStalled = true;
679
+ err.pendingToolUse = true;
680
+ err.partialContent = state.content || '';
681
+ throw err;
682
+ }
672
683
  state.completed = true;
673
684
  state.stopReason = 'length';
674
685
  state.completedResponse = event.response || state.completedResponse;
@@ -686,6 +697,13 @@ function handleCompatResponsesStreamEvent(event, state, { label, parseResponsesT
686
697
  case 'response.incomplete': {
687
698
  const reason = incompleteReasonFromResponsesEvent(event);
688
699
  if (isMaxOutputIncompleteReason(reason)) {
700
+ if (state.toolInFlight || (state.pendingCalls && state.pendingCalls.size > 0)) {
701
+ const err = truncatedCompatStreamError(label, `incomplete (${reason}) with tool call in flight`);
702
+ err.streamStalled = true;
703
+ err.pendingToolUse = true;
704
+ err.partialContent = state.content || '';
705
+ throw err;
706
+ }
689
707
  state.completed = true;
690
708
  state.stopReason = 'length';
691
709
  state.completedResponse = event.response || state.completedResponse;
@@ -113,7 +113,7 @@ async function _resolveCodexClientVersion() {
113
113
  return CODEX_CLIENT_VERSION_FLOOR;
114
114
  }
115
115
  const CODEX_MODEL_CACHE_TTL_MS = 24 * 60 * 60_000;
116
- const CODEX_MODEL_CACHE_SCHEMA_VERSION = 2;
116
+ const CODEX_MODEL_CACHE_SCHEMA_VERSION = 3;
117
117
  const TOKEN_REFRESH_SKEW_MS = 5 * 60_000;
118
118
 
119
119
  const _codexModelCache = makeModelCache({
@@ -587,6 +587,10 @@ export async function _streamResponse({
587
587
  // Partial-final recovery: attach streamed partial state so
588
588
  // a wedged FINAL no-tool summary can be accepted as partial-final
589
589
  // success by the loop. pendingToolUse gates out mid-flight tools.
590
+ // Fold the held leak-guard tail into `content` FIRST so the
591
+ // partial snapshot below keeps legitimate trailing text; finish()
592
+ // then skips flushLeak (terminalError set) without losing it.
593
+ flushLeak();
590
594
  try {
591
595
  terminalError.partialContent = content;
592
596
  terminalError.partialToolCalls = toolCalls.length ? toolCalls.slice() : undefined;
@@ -631,9 +635,14 @@ export async function _streamResponse({
631
635
  };
632
636
  const finish = () => {
633
637
  logReasoningDeltaSuppression();
638
+ // On a terminal error we must NOT flush buffered text/tool calls:
639
+ // finish() rejects below, so flushing would emit partial output the
640
+ // caller then never consumes as a clean result (double-render/
641
+ // double-dispatch risk). The partial-final path reads partialContent
642
+ // off the error instead. Only flush the held-back tail on success.
643
+ if (!terminalError) flushLeak();
634
644
  // Flush any partial-sentinel tail held back mid-stream so
635
645
  // legitimate trailing text is never lost (streamed-text path).
636
- flushLeak();
637
646
  cleanup();
638
647
  if (terminalError) { reject(terminalError); return; }
639
648
  resolve({
@@ -694,6 +703,12 @@ export async function _streamResponse({
694
703
  // first-meaningful watchdog (keepalive/metadata frames do
695
704
  // NOT reach this case, so they never clear it).
696
705
  clearFirstMeaningfulWatchdog();
706
+ // Arm the semantic idle/stall timer from response.created
707
+ // too: it otherwise only arms on the first meaningful delta
708
+ // (bumpSemanticIdle), so a created+keepalive-only stream that
709
+ // never emits a delta would stall unbounded until the outer
710
+ // watchdog. Arming here bounds that gap.
711
+ resetSemanticIdle();
697
712
  break;
698
713
  case 'response.output_text.delta':
699
714
  try {
@@ -748,6 +763,10 @@ export async function _streamResponse({
748
763
  // response.output_item.done.
749
764
  _toolInFlight = true;
750
765
  }
766
+ // Item lifecycle is genuine progress: reset the semantic-idle
767
+ // timer so long server-side tool latency after item-added
768
+ // (before any arg delta) is not mistaken for a silent stall.
769
+ resetSemanticIdle();
751
770
  break;
752
771
  case 'response.function_call_arguments.delta':
753
772
  _toolInFlight = true;
@@ -826,6 +845,9 @@ export async function _streamResponse({
826
845
  if (event.item?.type === 'custom_tool_call') {
827
846
  pushCustomToolCall(event.item);
828
847
  }
848
+ // Item-done is genuine lifecycle progress — reset semantic
849
+ // idle so latency before the next item/args does not stall.
850
+ resetSemanticIdle();
829
851
  break;
830
852
  case 'response.completed': {
831
853
  const completedServiceTier = event.response?.service_tier || event.response?.serviceTier || '';
@@ -1048,7 +1070,13 @@ export async function _streamResponse({
1048
1070
  try { onStreamDelta?.(); } catch {}
1049
1071
  bumpSemanticIdle();
1050
1072
  }
1051
- // Trace-only events (response.in_progress, etc.)
1073
+ // response.in_progress is a server lifecycle heartbeat during
1074
+ // long tool/generation latency — reset semantic idle so it is
1075
+ // not counted as silence (resetIdle already ran at the top).
1076
+ else if (event.type === 'response.in_progress') {
1077
+ resetSemanticIdle();
1078
+ }
1079
+ // Other trace-only events fall through.
1052
1080
  break;
1053
1081
  }
1054
1082
  };
@@ -134,6 +134,25 @@ function _scopedDependencyRoots(toolName, args, cwd) {
134
134
  }
135
135
  if (rawPaths.length > 0) {
136
136
  for (const p of rawPaths) add(p);
137
+ // `glob` results are gated on the pattern's static (non-magic) prefix,
138
+ // not just cwd/path root — a pattern like "src/**/*.mjs" must register
139
+ // "src", not just cwd, or edits under src/ that are not directly under
140
+ // the given path root will not invalidate the cached glob result.
141
+ if (toolName === 'glob' && canonicalArgs && typeof canonicalArgs.pattern !== 'undefined') {
142
+ const patterns = [];
143
+ _collectPathValues(canonicalArgs.pattern, patterns);
144
+ for (const pattern of patterns) {
145
+ if (typeof pattern !== 'string' || !_hasGlobMagic(pattern)) continue;
146
+ const patternRoot = _extractGlobRoot(pattern);
147
+ if (_pathIsAbs(patternRoot)) {
148
+ add(patternRoot);
149
+ } else if (rawPaths.length > 0) {
150
+ for (const p of rawPaths) add(join(p, patternRoot));
151
+ } else {
152
+ add(patternRoot);
153
+ }
154
+ }
155
+ }
137
156
  } else if (cwd && typeof cwd === 'string') {
138
157
  add(cwd);
139
158
  }
@@ -22,11 +22,11 @@ export {
22
22
  export const SUMMARY_PREFIX = 'A previous model worked on this task and produced the compacted handoff summary below. Build on the work already done and avoid duplicating it; treat the summary as authoritative context for continuing the task. You also retain the preserved recent turns that follow.';
23
23
  export const SUMMARY_OUTPUT_TOKENS = 4_096;
24
24
  // Unified context-share rule: every derived "how much of the model context
25
- // may this budget take" ratio uses ONE number — 5%. Consumers:
25
+ // may this budget take" ratio uses ONE number — 10%. Consumers:
26
26
  // - compact target budget (loop/compact-policy.mjs COMPACT_TARGET_RATIO)
27
27
  // - recall-fasttrack injection cap (loop.mjs recallTokenCap)
28
28
  // Keep them in lockstep; do not fork per-consumer ratios without a decision.
29
- export const CONTEXT_SHARE_RATIO = 0.05;
29
+ export const CONTEXT_SHARE_RATIO = 0.10;
30
30
  // Floor for the recall-injection cap so small-context models still get a
31
31
  // usable recall slice (cap = max(floor, contextWindow * CONTEXT_SHARE_RATIO)).
32
32
  export const RECALL_TOKEN_CAP_FLOOR_TOKENS = 2_048;
@@ -672,7 +672,7 @@ function _recallFastTrackCompactMessages(messages, budgetTokens, opts = {}) {
672
672
  };
673
673
  // Recall injection room is capped separately from the mandatory
674
674
  // (system + preserved tail) budget: recall text never gets more than
675
- // opts.recallTokenCap (5% of context window, floor 2048 tokens, set by
675
+ // opts.recallTokenCap (10% of context window, floor 2048 tokens, set by
676
676
  // the caller); the remaining budget stays reserved for live
677
677
  // conversation. Only applied when the cap is a positive finite number,
678
678
  // preserving current uncapped behavior otherwise.
@@ -253,21 +253,43 @@ export function fitCompactionPrompt(input, targetTokens) {
253
253
  const buildReduced = (k) => {
254
254
  const kept = k > 0 ? head.slice(head.length - k) : [];
255
255
  const omitted = head.length - kept.length;
256
- const stubHead = omitted > 0
257
- ? [{ role: 'user', content: `[${omitted} older messages omitted]` }, ...kept]
258
- : kept;
259
- let inp = { ...baseNoFacts, head: stubHead };
260
- // Also shrink/drop a prior <previous-summary> (same as the normal fitAt
261
- // path) a large prior summary can keep the prompt over budget even at
262
- // K=0. fitPreviousSummaryForCompactionPrompt is a no-op when there is no
263
- // previousSummary, so this is safe for the summary-less case.
264
- if (estimateCompactionPromptTokens(inp, 0) > targetTokens) {
265
- const fitted = fitPreviousSummaryForCompactionPrompt(inp, 0, targetTokens);
266
- if (!fitted) return null;
267
- inp = fitted;
268
- if (estimateCompactionPromptTokens(inp, 0) > targetTokens) return null;
269
- }
270
- return buildCompactionPrompt(inp, 0);
256
+ const finalize = (stubHead) => {
257
+ let inp = { ...baseNoFacts, head: stubHead };
258
+ // Also shrink/drop a prior <previous-summary> (same as the normal
259
+ // fitAt path) a large prior summary can keep the prompt over
260
+ // budget even at K=0. fitPreviousSummaryForCompactionPrompt is a
261
+ // no-op when there is no previousSummary, so this is safe for the
262
+ // summary-less case.
263
+ if (estimateCompactionPromptTokens(inp, 0) > targetTokens) {
264
+ const fitted = fitPreviousSummaryForCompactionPrompt(inp, 0, targetTokens);
265
+ if (!fitted) return null;
266
+ inp = fitted;
267
+ if (estimateCompactionPromptTokens(inp, 0) > targetTokens) return null;
268
+ }
269
+ return buildCompactionPrompt(inp, 0);
270
+ };
271
+ if (omitted <= 0) return finalize(kept);
272
+ // The omitted head messages never reappear in the session afterward
273
+ // (the caller replaces the whole head with the produced summary), so
274
+ // a bare "[N older messages omitted]" stub used to discard their
275
+ // content with zero trace. Prefer a compact per-message digest line
276
+ // for each omitted message so at least a sliver of detail survives
277
+ // into the summary input; only fall back to the count-only stub if
278
+ // even the digest cannot fit the emergency budget, preserving the
279
+ // original guarantee that this reduction always finds a fit.
280
+ const digestLines = head.slice(0, omitted).map((m, i) => {
281
+ const role = m?.role || 'unknown';
282
+ const text = truncateMiddle(extractText(m).trim(), 30);
283
+ return text ? `${i + 1}. ${role}: ${text}` : `${i + 1}. ${role}`;
284
+ });
285
+ const digestStub = {
286
+ role: 'user',
287
+ content: [`[${omitted} older messages compacted to a digest below]`, ...digestLines].join('\n'),
288
+ };
289
+ const withDigest = finalize([digestStub, ...kept]);
290
+ if (withDigest) return withDigest;
291
+ const countStub = { role: 'user', content: `[${omitted} older messages omitted]` };
292
+ return finalize([countStub, ...kept]);
271
293
  };
272
294
  let lo = 0;
273
295
  let hi = head.length;