mixdog 0.9.16 → 0.9.18

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 (43) hide show
  1. package/package.json +2 -1
  2. package/scripts/atomic-lock-tryonce-test.mjs +66 -0
  3. package/scripts/provider-toolcall-test.mjs +79 -2
  4. package/src/mixdog-session-runtime.mjs +12 -0
  5. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +33 -1
  6. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +9 -0
  7. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +49 -0
  8. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +14 -0
  9. package/src/runtime/agent/orchestrator/session/context-utils.mjs +7 -0
  10. package/src/runtime/agent/orchestrator/session/loop.mjs +8 -0
  11. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +32 -18
  12. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +142 -3
  13. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +108 -30
  14. package/src/runtime/agent/orchestrator/session/store.mjs +5 -0
  15. package/src/runtime/agent/orchestrator/stall-policy.mjs +22 -12
  16. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +3 -1
  17. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +15 -15
  18. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +10 -2
  19. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +8 -2
  20. package/src/runtime/channels/backends/discord-attachments.mjs +2 -2
  21. package/src/runtime/channels/backends/discord-gateway.mjs +12 -2
  22. package/src/runtime/channels/backends/discord.mjs +97 -7
  23. package/src/runtime/channels/index.mjs +150 -23
  24. package/src/runtime/channels/lib/crash-log.mjs +21 -3
  25. package/src/runtime/channels/lib/output-forwarder.mjs +118 -7
  26. package/src/runtime/channels/lib/runtime-paths.mjs +21 -19
  27. package/src/runtime/channels/lib/voice-transcription.mjs +4 -2
  28. package/src/runtime/memory/index.mjs +37 -0
  29. package/src/runtime/memory/lib/embedding-warmup.mjs +3 -0
  30. package/src/runtime/memory/lib/ko-morph.mjs +1 -0
  31. package/src/runtime/shared/atomic-file.mjs +110 -0
  32. package/src/runtime/shared/transcript-writer.mjs +46 -4
  33. package/src/session-runtime/provider-models.mjs +47 -8
  34. package/src/standalone/channel-worker.mjs +14 -2
  35. package/src/tui/app/transcript-window.mjs +137 -6
  36. package/src/tui/app/use-transcript-window.mjs +67 -10
  37. package/src/tui/components/StatusLine.jsx +1 -1
  38. package/src/tui/dist/index.mjs +436 -93
  39. package/src/tui/engine/tui-steering-persist.mjs +66 -35
  40. package/src/tui/engine.mjs +66 -14
  41. package/src/tui/index.jsx +97 -6
  42. package/src/ui/statusline-segments.mjs +54 -36
  43. package/src/ui/statusline.mjs +141 -95
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.16",
3
+ "version": "0.9.18",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -46,6 +46,7 @@
46
46
  "smoke:freevars": "node scripts/freevar-smoke.mjs",
47
47
  "test:toolcall": "node --test scripts/toolcall-args-test.mjs",
48
48
  "test:providers": "node --test scripts/provider-toolcall-test.mjs",
49
+ "test:atomiclock": "node --test scripts/atomic-lock-tryonce-test.mjs",
49
50
  "failures": "node scripts/tool-failures.mjs",
50
51
  "trace:llm": "node scripts/llm-trace-summary.mjs",
51
52
  "diag:sessions": "node scripts/session-diag.mjs",
@@ -0,0 +1,66 @@
1
+ // Proves try-once (timeoutMs:0) lock behavior: when the lock is already held,
2
+ // withFileLockSync/withFileLock return IMMEDIATELY with ELOCKCONTENDED and
3
+ // never sleep (no Atomics.wait / setTimeout backoff). Also asserts sync+async
4
+ // lock interop: neither can enter the critical section while the other holds.
5
+ import test from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import { mkdtempSync, rmSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import {
11
+ withFileLockSync,
12
+ withFileLock,
13
+ } from '../src/runtime/shared/atomic-file.mjs';
14
+
15
+ function tmpLock() {
16
+ const dir = mkdtempSync(join(tmpdir(), 'mixlock-'));
17
+ return { dir, lockPath: join(dir, 't.lock') };
18
+ }
19
+
20
+ test('try-once sync throws ELOCKCONTENDED without sleeping when held', () => {
21
+ const { dir, lockPath } = tmpLock();
22
+ try {
23
+ withFileLockSync(lockPath, () => {
24
+ const started = Date.now();
25
+ assert.throws(
26
+ () => withFileLockSync(lockPath, () => 'unreachable', { timeoutMs: 0 }),
27
+ (e) => e?.code === 'ELOCKCONTENDED',
28
+ );
29
+ assert.ok(Date.now() - started < 20, `try-once slept ${Date.now() - started}ms`);
30
+ });
31
+ } finally {
32
+ rmSync(dir, { recursive: true, force: true });
33
+ }
34
+ });
35
+
36
+ test('try-once async rejects ELOCKCONTENDED without sleeping when held', async () => {
37
+ const { dir, lockPath } = tmpLock();
38
+ try {
39
+ await withFileLockSync(lockPath, async () => {
40
+ const started = Date.now();
41
+ await assert.rejects(
42
+ withFileLock(lockPath, () => 'unreachable', { timeoutMs: 0 }),
43
+ (e) => e?.code === 'ELOCKCONTENDED',
44
+ );
45
+ assert.ok(Date.now() - started < 20, `try-once slept ${Date.now() - started}ms`);
46
+ });
47
+ } finally {
48
+ rmSync(dir, { recursive: true, force: true });
49
+ }
50
+ });
51
+
52
+ test('async holder blocks sync try-once, then sync acquires after release', async () => {
53
+ const { dir, lockPath } = tmpLock();
54
+ try {
55
+ await withFileLock(lockPath, () => {
56
+ assert.throws(
57
+ () => withFileLockSync(lockPath, () => 'unreachable', { timeoutMs: 0 }),
58
+ (e) => e?.code === 'ELOCKCONTENDED',
59
+ );
60
+ });
61
+ const val = withFileLockSync(lockPath, () => 7, { timeoutMs: 0 });
62
+ assert.equal(val, 7);
63
+ } finally {
64
+ rmSync(dir, { recursive: true, force: true });
65
+ }
66
+ });
@@ -366,6 +366,81 @@ test('anthropic(-oauth): text-only stream → no toolCalls', async () => {
366
366
  assert.equal(result.content, 'hello');
367
367
  });
368
368
 
369
+ test('anthropic(-oauth): thinking + signature deltas → ordered thinkingBlocks before tool_use', async () => {
370
+ const events = [
371
+ { type: 'message_start', message: { model: 'claude', usage: { input_tokens: 1 } } },
372
+ { type: 'content_block_start', index: 0, content_block: { type: 'thinking', thinking: '' } },
373
+ { type: 'content_block_delta', index: 0, delta: { type: 'thinking_delta', thinking: 'step ' } },
374
+ { type: 'content_block_delta', index: 0, delta: { type: 'thinking_delta', thinking: 'one' } },
375
+ { type: 'content_block_delta', index: 0, delta: { type: 'signature_delta', signature: 'sig123' } },
376
+ { type: 'content_block_stop', index: 0 },
377
+ { type: 'content_block_start', index: 1, content_block: { type: 'tool_use', id: 'toolu_9', name: 'shell' } },
378
+ { type: 'content_block_delta', index: 1, delta: { type: 'input_json_delta', partial_json: '{"command":"ls"}' } },
379
+ { type: 'content_block_stop', index: 1 },
380
+ { type: 'message_delta', delta: { stop_reason: 'tool_use' }, usage: { output_tokens: 1 } },
381
+ { type: 'message_stop' },
382
+ ];
383
+ const result = await anthropicParseSSEStream(
384
+ anthropicSseResponse(events),
385
+ null, () => {}, () => {}, () => {}, {}, null,
386
+ );
387
+ assert.equal(result.hasThinkingContent, true);
388
+ assert.deepEqual(result.thinkingBlocks, [
389
+ { type: 'thinking', thinking: 'step one', signature: 'sig123' },
390
+ ]);
391
+ assert.deepEqual(result.toolCalls, [{ id: 'toolu_9', name: 'shell', arguments: { command: 'ls' } }]);
392
+ });
393
+
394
+ test('anthropic(-oauth): signature-only block (display omitted) → empty thinking kept with signature', async () => {
395
+ const events = [
396
+ { type: 'message_start', message: { model: 'claude', usage: { input_tokens: 1 } } },
397
+ { type: 'content_block_start', index: 0, content_block: { type: 'thinking' } },
398
+ { type: 'content_block_delta', index: 0, delta: { type: 'signature_delta', signature: 'sigABC' } },
399
+ { type: 'content_block_stop', index: 0 },
400
+ { type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 1 } },
401
+ { type: 'message_stop' },
402
+ ];
403
+ const result = await anthropicParseSSEStream(
404
+ anthropicSseResponse(events),
405
+ null, () => {}, () => {}, () => {}, {}, null,
406
+ );
407
+ assert.deepEqual(result.thinkingBlocks, [
408
+ { type: 'thinking', thinking: '', signature: 'sigABC' },
409
+ ]);
410
+ });
411
+
412
+ test('anthropic(-oauth): redacted_thinking round-trips exactly as {type,data} (no extra fields)', async () => {
413
+ const events = [
414
+ { type: 'message_start', message: { model: 'claude', usage: { input_tokens: 1 } } },
415
+ { type: 'content_block_start', index: 0, content_block: { type: 'redacted_thinking', data: 'ENCRYPTED_PAYLOAD' } },
416
+ { type: 'content_block_stop', index: 0 },
417
+ { type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 1 } },
418
+ { type: 'message_stop' },
419
+ ];
420
+ const result = await anthropicParseSSEStream(
421
+ anthropicSseResponse(events),
422
+ null, () => {}, () => {}, () => {}, {}, null,
423
+ );
424
+ assert.deepEqual(result.thinkingBlocks, [
425
+ { type: 'redacted_thinking', data: 'ENCRYPTED_PAYLOAD' },
426
+ ]);
427
+ });
428
+
429
+ test('anthropic effort: legacy claude-3-7-sonnet gets NO adaptive thinking / effort beta', () => {
430
+ const model = 'claude-3-7-sonnet-20250219';
431
+ assert.equal(modelSupportsEffort(model), false);
432
+ const body = _buildRequestBodyForCacheSmoke(
433
+ [{ role: 'user', content: 'hi' }],
434
+ model,
435
+ [],
436
+ { effort: 'high' },
437
+ );
438
+ assert.equal(body.output_config, undefined);
439
+ // Legacy path uses the budget_tokens shape, never thinking:adaptive.
440
+ assert.notEqual(body.thinking?.type, 'adaptive');
441
+ assert.equal(shouldIncludeEffortBeta(model, { effort: 'high' }), false);
442
+ });
443
+
369
444
  // --- Leaked tool-call recovery (shared parseSSEStream guard) ----------------
370
445
  // The model sometimes emits a tool call as plain text tags inside text_delta
371
446
  // instead of a native tool_use block. The guard (8th arg = known tool names)
@@ -933,7 +1008,9 @@ test('anthropic effort: sonnet-4-6 uses output_config + effort beta, not thinkin
933
1008
  { effort: 'high' },
934
1009
  );
935
1010
  assert.deepEqual(body.output_config, { effort: 'high' });
936
- assert.equal(body.thinking, undefined);
1011
+ // Adaptive-thinking models also carry thinking:{type:'adaptive'} — the
1012
+ // legacy budget_tokens shape 400s on these models.
1013
+ assert.deepEqual(body.thinking, { type: 'adaptive', display: 'summarized' });
937
1014
  assert.equal(shouldIncludeEffortBeta(model, { effort: 'high' }), true);
938
1015
  const beta = buildAnthropicBetaHeaders({ effort: true });
939
1016
  assert.ok(beta.includes(EFFORT_BETA_HEADER));
@@ -967,7 +1044,7 @@ test('anthropic effort: xhigh on opus-4-8 is a first-class level (not downgraded
967
1044
  { effort: 'xhigh' },
968
1045
  );
969
1046
  assert.deepEqual(body.output_config, { effort: 'xhigh' });
970
- assert.equal(body.thinking, undefined);
1047
+ assert.deepEqual(body.thinking, { type: 'adaptive', display: 'summarized' });
971
1048
  });
972
1049
 
973
1050
  test('anthropic effort: explicit thinkingBudgetTokens wins over effort', () => {
@@ -1553,6 +1553,12 @@ export async function createMixdogSessionRuntime({
1553
1553
  // caller: the whole probe is detached, but it internally awaits readiness.
1554
1554
  void (async () => {
1555
1555
  try {
1556
+ // Yield one event-loop tick before the heavy chain below (module
1557
+ // resolve, daemon fork/health-poll) starts, so Ink's next render
1558
+ // (scheduled via setImmediate/timers) and any queued keypress
1559
+ // events get a turn first instead of being starved by this
1560
+ // detached chain's synchronous setup work.
1561
+ await new Promise((r) => setImmediate(r));
1556
1562
  const mod = await getMemoryModule();
1557
1563
  const started = typeof mod?.start === 'function' ? await mod.start() : null;
1558
1564
  const port = started?.port;
@@ -1603,6 +1609,12 @@ export async function createMixdogSessionRuntime({
1603
1609
  }
1604
1610
  bootProfile('channels:start-scheduled', { delayMs: 0, immediate: true });
1605
1611
  void (async () => {
1612
+ // Yield before the createCurrentSession/transcript/fork chain below —
1613
+ // same rationale as the memory-eager-init yield above: this detached
1614
+ // chain runs synchronous config/fs work (createCurrentSession, backend
1615
+ // flush, transcript writer) back-to-back, and without a tick break it
1616
+ // can run ahead of Ink's queued render/input handling.
1617
+ await new Promise((r) => setImmediate(r));
1606
1618
  // Immediate-occupancy guarantee: make sure a session + transcript
1607
1619
  // exist BEFORE the worker boots — a freshly-forked worker claims and
1608
1620
  // runs transcript discovery inside its own start(), so publishing the
@@ -43,6 +43,15 @@ function parseClaudeVersion(model) {
43
43
  if (pair) {
44
44
  return { family: pair[1], major: Number(pair[2]), minor: null };
45
45
  }
46
+ // Legacy naming: version comes BEFORE the family (claude-3-7-sonnet,
47
+ // claude-3-5-sonnet, claude-3-opus). These are manual-thinking models —
48
+ // parse them so isLegacyAnthropicReasoningModel excludes them instead of
49
+ // letting modelSupportsEffort's `startsWith('claude-')` fallthrough grant
50
+ // them adaptive thinking + the effort beta (a hard 400).
51
+ const legacy = m.match(/^claude-(\d+)-(\d+)-(haiku|sonnet|opus)/);
52
+ if (legacy) {
53
+ return { family: legacy[3], major: Number(legacy[1]), minor: Number(legacy[2]) };
54
+ }
46
55
  return null;
47
56
  }
48
57
 
@@ -69,7 +78,17 @@ export function modelSupportsEffort(model) {
69
78
  if (m.includes('sonnet-5') || m.includes('fable-5')) return true;
70
79
  if (/^claude-opus-4-(6|7|8)(?:$|[-@])/.test(m)) return true;
71
80
  if (/^claude-opus-5-/.test(m)) return true;
72
- if (m.startsWith('claude-')) return true;
81
+ // Fallthrough for not-yet-enumerated modern models: only grant effort when
82
+ // parseClaudeVersion resolves a family with major>=4 (and not a manual-
83
+ // thinking legacy already excluded above). A bare `startsWith('claude-')`
84
+ // here would hand thinking:adaptive + the effort beta to legacy/unknown
85
+ // IDs (e.g. claude-3-7-sonnet) → hard 400. Unknown-shape IDs (no version
86
+ // parsed) fall through to false: no effort, no adaptive thinking.
87
+ const parsed = parseClaudeVersion(model);
88
+ if (parsed && (parsed.family === 'sonnet' || parsed.family === 'opus' || parsed.family === 'fable')
89
+ && parsed.major >= 4) {
90
+ return true;
91
+ }
73
92
  return false;
74
93
  }
75
94
 
@@ -180,6 +199,19 @@ export function applyAnthropicEffortToBody(
180
199
  : {};
181
200
  body.output_config = { ...existing, effort: normalized };
182
201
  }
202
+ // Adaptive-thinking models (4.6+) require `thinking:{type:"adaptive"}`
203
+ // rather than the legacy budget_tokens shape — sending
204
+ // `thinking:{type:"enabled"}` here 400s on sonnet-5/opus-4-7/4-8.
205
+ // display:"summarized" keeps reasoning blocks populated (4.7+ defaults
206
+ // to "omitted", silently hiding reasoning text). Gated on the same
207
+ // modelSupportsEffort() allowlist so older models never receive it.
208
+ // Set unconditionally (independent of `normalized`) so effort-capable
209
+ // turns always carry adaptive thinking + round-trip signatures.
210
+ body.thinking = { type: 'adaptive', display: 'summarized' };
211
+ // Adaptive/4.7+ models reject any non-default sampling param with a 400.
212
+ delete body.temperature;
213
+ delete body.top_p;
214
+ delete body.top_k;
183
215
  return;
184
216
  }
185
217
 
@@ -333,6 +333,15 @@ function toAnthropicMessages(messages) {
333
333
  content = m.assistantBlocks.slice();
334
334
  } else {
335
335
  content = [];
336
+ // Adaptive-thinking round-trip: prior-turn thinking blocks are
337
+ // REQUIRED back, unmodified (signature intact; empty thinking
338
+ // field allowed), and MUST precede tool_use blocks. Emit them
339
+ // first, verbatim as received from the SSE parser.
340
+ if (Array.isArray(m.thinkingBlocks)) {
341
+ for (const tb of m.thinkingBlocks) {
342
+ if (tb && typeof tb === 'object') content.push(tb);
343
+ }
344
+ }
336
345
  if (m.content) content.push({ type: 'text', text: m.content });
337
346
  for (const tc of m.toolCalls) {
338
347
  content.push({
@@ -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);
@@ -337,6 +337,14 @@ function toAnthropicMessages(messages) {
337
337
  content = m.assistantBlocks.slice();
338
338
  } else {
339
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
+ }
340
348
  if (m.content) content.push({ type: 'text', text: m.content });
341
349
  for (const tc of m.toolCalls) {
342
350
  content.push({
@@ -672,6 +680,12 @@ export class AnthropicProvider {
672
680
  contentBlockTypes: Array.isArray(parseResult.contentBlockTypes)
673
681
  ? parseResult.contentBlockTypes
674
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,
675
689
  usage: {
676
690
  inputTokens: input,
677
691
  outputTokens: output,
@@ -81,6 +81,13 @@ export function messageEstimateText(m) {
81
81
  try { text += `\n${JSON.stringify(m.toolCalls)}`; }
82
82
  catch { text += `\n[${m.toolCalls.length} tool calls]`; }
83
83
  }
84
+ // Anthropic adaptive-thinking blocks round-trip verbatim (thinking text +
85
+ // signature / redacted data) and are re-sent on tool-continuation turns, so
86
+ // they consume real input tokens. Count them or trim/compact undercounts.
87
+ if (m.role === 'assistant' && Array.isArray(m.thinkingBlocks) && m.thinkingBlocks.length) {
88
+ try { text += `\n${JSON.stringify(m.thinkingBlocks)}`; }
89
+ catch { text += `\n[${m.thinkingBlocks.length} thinking blocks]`; }
90
+ }
84
91
  if (m.role === 'tool' && m.toolCallId) text += `\n${m.toolCallId}`;
85
92
  return text;
86
93
  }
@@ -1220,6 +1220,14 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1220
1220
  // turn). Blank it so it never accumulates as input tokens.
1221
1221
  content: suppressMidTurnText ? '' : (response.content || ''),
1222
1222
  toolCalls: compactToolCallsForHistory(calls),
1223
+ // Anthropic adaptive thinking: prior-turn thinking blocks must be
1224
+ // returned verbatim (signature intact; empty thinking allowed) and
1225
+ // are REQUIRED back before tool_use blocks on tool-continuation
1226
+ // turns. Store them so toAnthropicMessages can build assistantBlocks
1227
+ // = [...thinking, tool_use...]. Other providers ignore this field.
1228
+ ...(Array.isArray(response.thinkingBlocks) && response.thinkingBlocks.length
1229
+ ? { thinkingBlocks: response.thinkingBlocks }
1230
+ : {}),
1223
1231
  ...(Array.isArray(response.reasoningItems) && response.reasoningItems.length
1224
1232
  ? { reasoningItems: response.reasoningItems }
1225
1233
  : {}),
@@ -2,7 +2,7 @@
2
2
  // Extracted verbatim from manager.mjs (behavior-preserving).
3
3
  import { join } from 'path';
4
4
  import { resolvePluginData } from '../../../../shared/plugin-paths.mjs';
5
- import { updateJsonAtomicSync } from '../../../../shared/atomic-file.mjs';
5
+ import { updateJsonAtomicSync, updateJsonAtomic } from '../../../../shared/atomic-file.mjs';
6
6
  import { promptContentText, isInternalRuntimeNotificationText } from './prompt-utils.mjs';
7
7
  import { loadSession } from '../store.mjs';
8
8
 
@@ -115,23 +115,33 @@ function persistPendingMessages(sessionId, messages) {
115
115
  .map(pendingMessageText)
116
116
  .filter(Boolean);
117
117
  if (persistedMessages.length === 0) return 0;
118
- let depth = 0;
119
- try {
120
- updateJsonAtomicSync(pendingMessagesPath(), (raw) => {
121
- const next = normalizePendingStore(raw);
122
- const q = Array.isArray(next.sessions[sessionId]) ? next.sessions[sessionId] : [];
123
- q.push(...persistedMessages);
124
- next.sessions[sessionId] = q;
125
- const now = Date.now();
126
- next.updatedAt = now;
127
- touchPendingSessionEntry(next, sessionId, now);
128
- depth = q.length;
129
- return next;
130
- }, { compact: true, lock: true, mode: PENDING_MESSAGES_MODE, fsync: false });
131
- } catch (err) {
132
- try { process.stderr.write(`[session] pending-message persist failed sessionId=${sessionId}: ${err?.message || err}\n`); } catch {}
133
- }
134
- return depth;
118
+ // Async lock wait: this runs on the lead/TUI main process (tool-exec +
119
+ // steering persist). withFileLock waits off the event loop, so cross-
120
+ // process contention on the shared spool never freezes the renderer.
121
+ // Best-effort: the returned promise is fire-and-forget; depth is reported
122
+ // optimistically from the buffered batch length.
123
+ updateJsonAtomic(pendingMessagesPath(), (raw) => {
124
+ const next = normalizePendingStore(raw);
125
+ const q = Array.isArray(next.sessions[sessionId]) ? next.sessions[sessionId] : [];
126
+ q.push(...persistedMessages);
127
+ next.sessions[sessionId] = q;
128
+ const now = Date.now();
129
+ next.updatedAt = now;
130
+ touchPendingSessionEntry(next, sessionId, now);
131
+ return next;
132
+ }, { compact: true, lock: true, mode: PENDING_MESSAGES_MODE, fsync: false })
133
+ .catch((err) => {
134
+ try { process.stderr.write(`[session] pending-message persist failed sessionId=${sessionId}: ${err?.message || err}\n`); } catch {}
135
+ // Requeue on failure (lock timeout/contention): buffered messages
136
+ // were already cleared by the flush, so push them back so the next
137
+ // scheduled flush or session takeover retries instead of losing them.
138
+ try {
139
+ const q = _pendingPersistBuffers.get(sessionId) || [];
140
+ q.push(...persistedMessages);
141
+ _pendingPersistBuffers.set(sessionId, q);
142
+ } catch {}
143
+ });
144
+ return persistedMessages.length;
135
145
  }
136
146
 
137
147
  function flushPendingMessagePersistsSync() {
@@ -175,6 +185,10 @@ function drainPersistedPendingMessages(sessionId) {
175
185
  if (!isValidPendingSessionId(sessionId)) return [];
176
186
  let drained = [];
177
187
  try {
188
+ // Sync drain: called synchronously by drainPendingMessages, whose
189
+ // return value is spread immediately. Kept sync (updateJsonAtomicSync)
190
+ // so ordering/return contract is preserved; this fires only at
191
+ // session takeover, not on the keystroke/turn hot path.
178
192
  updateJsonAtomicSync(pendingMessagesPath(), (raw) => {
179
193
  const next = normalizePendingStore(raw);
180
194
  const q = Array.isArray(next.sessions[sessionId]) ? next.sessions[sessionId] : [];