mixdog 0.9.16 → 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 (44) hide show
  1. package/package.json +2 -1
  2. package/scripts/atomic-lock-tryonce-test.mjs +66 -0
  3. package/scripts/bench/cache-probe-tasks.json +1 -1
  4. package/scripts/bench/lead-review-tasks-r3.json +1 -1
  5. package/scripts/bench/r4-mixed-tasks.json +1 -1
  6. package/scripts/bench/review-tasks.json +1 -1
  7. package/scripts/build-runtime-windows.ps1 +242 -242
  8. package/scripts/provider-toolcall-test.mjs +79 -2
  9. package/scripts/recall-usecase-cases.json +1 -1
  10. package/scripts/smoke-runtime-negative.ps1 +106 -106
  11. package/scripts/tool-efficiency-diag.mjs +1 -1
  12. package/src/mixdog-session-runtime.mjs +12 -0
  13. package/src/rules/lead/02-channels.md +3 -3
  14. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +33 -1
  15. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +9 -0
  16. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +49 -0
  17. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +14 -0
  18. package/src/runtime/agent/orchestrator/session/context-utils.mjs +7 -0
  19. package/src/runtime/agent/orchestrator/session/loop.mjs +8 -0
  20. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +32 -18
  21. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +142 -3
  22. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +108 -30
  23. package/src/runtime/agent/orchestrator/session/store.mjs +5 -0
  24. package/src/runtime/agent/orchestrator/stall-policy.mjs +22 -12
  25. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +3 -1
  26. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +15 -15
  27. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +10 -2
  28. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +8 -2
  29. package/src/runtime/channels/lib/runtime-paths.mjs +8 -19
  30. package/src/runtime/memory/index.mjs +37 -0
  31. package/src/runtime/memory/lib/embedding-warmup.mjs +3 -0
  32. package/src/runtime/memory/lib/ko-morph.mjs +1 -0
  33. package/src/runtime/shared/atomic-file.mjs +110 -0
  34. package/src/runtime/shared/transcript-writer.mjs +46 -4
  35. package/src/session-runtime/provider-models.mjs +47 -8
  36. package/src/tui/app/transcript-window.mjs +115 -6
  37. package/src/tui/app/use-transcript-window.mjs +58 -7
  38. package/src/tui/components/StatusLine.jsx +1 -1
  39. package/src/tui/dist/index.mjs +373 -81
  40. package/src/tui/engine/tui-steering-persist.mjs +66 -35
  41. package/src/tui/engine.mjs +6 -4
  42. package/src/tui/index.jsx +97 -6
  43. package/src/ui/statusline-segments.mjs +54 -36
  44. package/src/ui/statusline.mjs +141 -95
@@ -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', () => {
@@ -15,4 +15,4 @@
15
15
  { "id": "uc5-yesterday", "label": "UC5 calendar yesterday", "args": { "period": "yesterday", "limit": 10 }, "expect": "browse" },
16
16
  { "id": "uc5-thisweek", "label": "UC5 calendar this_week", "args": { "period": "this_week", "limit": 10 }, "expect": "browse" },
17
17
  { "id": "uc6-category", "label": "UC6 category decision 7d", "args": { "period": "7d", "category": "decision", "limit": 10 }, "expect": "browse" }
18
- ]
18
+ ]
@@ -1,106 +1,106 @@
1
- # smoke-runtime-negative.ps1 — Negative-path tests against released win32-x64
2
- # runtime tarball. Validates: download, sha256, corrupt-tarball, fresh-extract
3
- # boot, double-init refusal, hostile-env survival.
4
- #
5
- # Env: TAG / OS / ARCH / RUNTIME_RELEASE_REPOSITORY (default tribgames/mixdog)
6
-
7
- $ErrorActionPreference = 'Stop'
8
-
9
- $Tag = if ($env:TAG) { $env:TAG } else { 'runtime-v0.4.0' }
10
- $Os = if ($env:OS) { $env:OS } else { 'win32' }
11
- $Arch = if ($env:ARCH) { $env:ARCH } else { 'x64' }
12
- $ReleaseRepo = if ($env:RUNTIME_RELEASE_REPOSITORY) { $env:RUNTIME_RELEASE_REPOSITORY } else { 'tribgames/mixdog' }
13
- $PgVer = '16.4'
14
- $PgvectorVer = '0.8.2'
15
-
16
- $Asset = "mixdog-runtime-${Os}-${Arch}-pg${PgVer}-pgvector${PgvectorVer}.tar.gz"
17
- $Url = "https://github.com/${ReleaseRepo}/releases/download/${Tag}/${Asset}"
18
-
19
- $Work = New-Item -ItemType Directory -Force -Path "$env:TEMP\smoke-$([guid]::NewGuid().ToString('N'))" | Select-Object -ExpandProperty FullName
20
-
21
- try {
22
- Write-Host "==> Negative-path smoke for ${Os}-${Arch} from $Url"
23
-
24
- Write-Host "==> Test 1: HEAD reaches release asset"
25
- $resp = Invoke-WebRequest -Uri $Url -Method Head -UseBasicParsing -ErrorAction Stop
26
- Write-Host " HTTP $($resp.StatusCode)"
27
-
28
- Write-Host "==> Test 2: full download + sha256"
29
- $TarPath = Join-Path $Work $Asset
30
- Invoke-WebRequest -Uri $Url -OutFile $TarPath -UseBasicParsing
31
- $sha = (Get-FileHash -Algorithm SHA256 $TarPath).Hash.ToLower()
32
- Write-Host " sha256=$sha"
33
-
34
- Write-Host "==> Test 3: corrupt tarball — flip a byte and ensure tar errors"
35
- $CorruptPath = "$Work\corrupt.tar.gz"
36
- Copy-Item $TarPath $CorruptPath
37
- $bytes = [byte[]]::new(1); $rand = [System.Random]::new(); $rand.NextBytes($bytes)
38
- $stream = [System.IO.File]::OpenWrite($CorruptPath)
39
- $stream.Position = 102400
40
- $stream.Write($bytes, 0, 1)
41
- $stream.Close()
42
- $CorruptDir = "$Work\corrupt"
43
- New-Item -ItemType Directory -Force -Path $CorruptDir | Out-Null
44
- $TarProc = Start-Process -FilePath tar -ArgumentList @('-xzf', $CorruptPath, '-C', $CorruptDir.Replace('\','/')) -Wait:$false -PassThru -WindowStyle Hidden
45
- if (-not $TarProc.WaitForExit(15000)) {
46
- Stop-Process -Id $TarProc.Id -Force -ErrorAction SilentlyContinue
47
- Write-Host " PASS: corrupted tarball extraction timed out and was stopped"
48
- } elseif ($TarProc.ExitCode -eq 0) {
49
- Write-Host " WARN: corrupted tarball extracted without error (tar gzip recovery)"
50
- } else {
51
- Write-Host " PASS: corrupted tarball rejected by tar (exit $($TarProc.ExitCode))"
52
- }
53
-
54
- Write-Host "==> Test 4: fresh-extract boot (extension load + distance query)"
55
- $FreshDir = "$Work\fresh"
56
- New-Item -ItemType Directory -Force -Path $FreshDir | Out-Null
57
- & tar -xzf $TarPath -C ($FreshDir.Replace('\','/'))
58
- if ($LASTEXITCODE -ne 0) { throw "fresh extract failed" }
59
-
60
- $PgBin = "$FreshDir\bin"
61
- $Data = "$FreshDir\pgdata"
62
- $Log = "$FreshDir\pg.log"
63
- $Port = 55897
64
-
65
- # Hostile env: minimal PATH (System32 only), no PGROOT/PGDATA
66
- $SavedPath = $env:PATH
67
- $SavedPgRoot = $env:PGROOT
68
- $env:PATH = "$env:SystemRoot\System32;$env:SystemRoot"
69
- $env:PGROOT = $null
70
- $env:PGDATA = $null
71
-
72
- try {
73
- & "$PgBin\postgres.exe" --version
74
- if ($LASTEXITCODE -ne 0) { throw "FAIL: postgres --version" }
75
- & "$PgBin\initdb.exe" -D $Data --username=postgres --auth-local=trust --no-locale -E UTF8 | Out-Null
76
- if ($LASTEXITCODE -ne 0) { throw "FAIL: initdb" }
77
- & "$PgBin\pg_ctl.exe" -D $Data -o "-p $Port -h 127.0.0.1" -l $Log -w start
78
- if ($LASTEXITCODE -ne 0) { Get-Content $Log | Select-Object -Last 30; throw "FAIL: pg_ctl start" }
79
-
80
- try {
81
- & "$PgBin\psql.exe" -h 127.0.0.1 -p $Port -U postgres -d postgres -c "CREATE EXTENSION vector;" | Out-Null
82
- if ($LASTEXITCODE -ne 0) { throw "FAIL: CREATE EXTENSION" }
83
- $ExtV = & "$PgBin\psql.exe" -h 127.0.0.1 -p $Port -U postgres -d postgres -tAc "SELECT extversion FROM pg_extension WHERE extname='vector';"
84
- if ($ExtV.Trim() -ne $PgvectorVer) { throw "FAIL: extversion='$ExtV' expected='$PgvectorVer'" }
85
- Write-Host " PASS: fresh-extract boot + vector extension"
86
- } finally {
87
- & "$PgBin\pg_ctl.exe" -D $Data -m fast stop 2>$null | Out-Null
88
- }
89
-
90
- Write-Host "==> Test 5: second initdb on initialized dir refused"
91
- $rc = (Start-Process -FilePath "$PgBin\initdb.exe" -ArgumentList "-D",$Data,"-U","postgres" -Wait -PassThru -NoNewWindow).ExitCode
92
- if ($rc -eq 0) {
93
- Write-Host " WARN: second initdb succeeded (unexpected)"
94
- } else {
95
- Write-Host " PASS: second initdb refused (exit $rc)"
96
- }
97
- } finally {
98
- $env:PATH = $SavedPath
99
- $env:PGROOT = $SavedPgRoot
100
- }
101
-
102
- Write-Host "==> All negative-path smokes: PASS"
103
- }
104
- finally {
105
- Remove-Item -Recurse -Force $Work -ErrorAction SilentlyContinue
106
- }
1
+ # smoke-runtime-negative.ps1 — Negative-path tests against released win32-x64
2
+ # runtime tarball. Validates: download, sha256, corrupt-tarball, fresh-extract
3
+ # boot, double-init refusal, hostile-env survival.
4
+ #
5
+ # Env: TAG / OS / ARCH / RUNTIME_RELEASE_REPOSITORY (default tribgames/mixdog)
6
+
7
+ $ErrorActionPreference = 'Stop'
8
+
9
+ $Tag = if ($env:TAG) { $env:TAG } else { 'runtime-v0.4.0' }
10
+ $Os = if ($env:OS) { $env:OS } else { 'win32' }
11
+ $Arch = if ($env:ARCH) { $env:ARCH } else { 'x64' }
12
+ $ReleaseRepo = if ($env:RUNTIME_RELEASE_REPOSITORY) { $env:RUNTIME_RELEASE_REPOSITORY } else { 'tribgames/mixdog' }
13
+ $PgVer = '16.4'
14
+ $PgvectorVer = '0.8.2'
15
+
16
+ $Asset = "mixdog-runtime-${Os}-${Arch}-pg${PgVer}-pgvector${PgvectorVer}.tar.gz"
17
+ $Url = "https://github.com/${ReleaseRepo}/releases/download/${Tag}/${Asset}"
18
+
19
+ $Work = New-Item -ItemType Directory -Force -Path "$env:TEMP\smoke-$([guid]::NewGuid().ToString('N'))" | Select-Object -ExpandProperty FullName
20
+
21
+ try {
22
+ Write-Host "==> Negative-path smoke for ${Os}-${Arch} from $Url"
23
+
24
+ Write-Host "==> Test 1: HEAD reaches release asset"
25
+ $resp = Invoke-WebRequest -Uri $Url -Method Head -UseBasicParsing -ErrorAction Stop
26
+ Write-Host " HTTP $($resp.StatusCode)"
27
+
28
+ Write-Host "==> Test 2: full download + sha256"
29
+ $TarPath = Join-Path $Work $Asset
30
+ Invoke-WebRequest -Uri $Url -OutFile $TarPath -UseBasicParsing
31
+ $sha = (Get-FileHash -Algorithm SHA256 $TarPath).Hash.ToLower()
32
+ Write-Host " sha256=$sha"
33
+
34
+ Write-Host "==> Test 3: corrupt tarball — flip a byte and ensure tar errors"
35
+ $CorruptPath = "$Work\corrupt.tar.gz"
36
+ Copy-Item $TarPath $CorruptPath
37
+ $bytes = [byte[]]::new(1); $rand = [System.Random]::new(); $rand.NextBytes($bytes)
38
+ $stream = [System.IO.File]::OpenWrite($CorruptPath)
39
+ $stream.Position = 102400
40
+ $stream.Write($bytes, 0, 1)
41
+ $stream.Close()
42
+ $CorruptDir = "$Work\corrupt"
43
+ New-Item -ItemType Directory -Force -Path $CorruptDir | Out-Null
44
+ $TarProc = Start-Process -FilePath tar -ArgumentList @('-xzf', $CorruptPath, '-C', $CorruptDir.Replace('\','/')) -Wait:$false -PassThru -WindowStyle Hidden
45
+ if (-not $TarProc.WaitForExit(15000)) {
46
+ Stop-Process -Id $TarProc.Id -Force -ErrorAction SilentlyContinue
47
+ Write-Host " PASS: corrupted tarball extraction timed out and was stopped"
48
+ } elseif ($TarProc.ExitCode -eq 0) {
49
+ Write-Host " WARN: corrupted tarball extracted without error (tar gzip recovery)"
50
+ } else {
51
+ Write-Host " PASS: corrupted tarball rejected by tar (exit $($TarProc.ExitCode))"
52
+ }
53
+
54
+ Write-Host "==> Test 4: fresh-extract boot (extension load + distance query)"
55
+ $FreshDir = "$Work\fresh"
56
+ New-Item -ItemType Directory -Force -Path $FreshDir | Out-Null
57
+ & tar -xzf $TarPath -C ($FreshDir.Replace('\','/'))
58
+ if ($LASTEXITCODE -ne 0) { throw "fresh extract failed" }
59
+
60
+ $PgBin = "$FreshDir\bin"
61
+ $Data = "$FreshDir\pgdata"
62
+ $Log = "$FreshDir\pg.log"
63
+ $Port = 55897
64
+
65
+ # Hostile env: minimal PATH (System32 only), no PGROOT/PGDATA
66
+ $SavedPath = $env:PATH
67
+ $SavedPgRoot = $env:PGROOT
68
+ $env:PATH = "$env:SystemRoot\System32;$env:SystemRoot"
69
+ $env:PGROOT = $null
70
+ $env:PGDATA = $null
71
+
72
+ try {
73
+ & "$PgBin\postgres.exe" --version
74
+ if ($LASTEXITCODE -ne 0) { throw "FAIL: postgres --version" }
75
+ & "$PgBin\initdb.exe" -D $Data --username=postgres --auth-local=trust --no-locale -E UTF8 | Out-Null
76
+ if ($LASTEXITCODE -ne 0) { throw "FAIL: initdb" }
77
+ & "$PgBin\pg_ctl.exe" -D $Data -o "-p $Port -h 127.0.0.1" -l $Log -w start
78
+ if ($LASTEXITCODE -ne 0) { Get-Content $Log | Select-Object -Last 30; throw "FAIL: pg_ctl start" }
79
+
80
+ try {
81
+ & "$PgBin\psql.exe" -h 127.0.0.1 -p $Port -U postgres -d postgres -c "CREATE EXTENSION vector;" | Out-Null
82
+ if ($LASTEXITCODE -ne 0) { throw "FAIL: CREATE EXTENSION" }
83
+ $ExtV = & "$PgBin\psql.exe" -h 127.0.0.1 -p $Port -U postgres -d postgres -tAc "SELECT extversion FROM pg_extension WHERE extname='vector';"
84
+ if ($ExtV.Trim() -ne $PgvectorVer) { throw "FAIL: extversion='$ExtV' expected='$PgvectorVer'" }
85
+ Write-Host " PASS: fresh-extract boot + vector extension"
86
+ } finally {
87
+ & "$PgBin\pg_ctl.exe" -D $Data -m fast stop 2>$null | Out-Null
88
+ }
89
+
90
+ Write-Host "==> Test 5: second initdb on initialized dir refused"
91
+ $rc = (Start-Process -FilePath "$PgBin\initdb.exe" -ArgumentList "-D",$Data,"-U","postgres" -Wait -PassThru -NoNewWindow).ExitCode
92
+ if ($rc -eq 0) {
93
+ Write-Host " WARN: second initdb succeeded (unexpected)"
94
+ } else {
95
+ Write-Host " PASS: second initdb refused (exit $rc)"
96
+ }
97
+ } finally {
98
+ $env:PATH = $SavedPath
99
+ $env:PGROOT = $SavedPgRoot
100
+ }
101
+
102
+ Write-Host "==> All negative-path smokes: PASS"
103
+ }
104
+ finally {
105
+ Remove-Item -Recurse -Force $Work -ErrorAction SilentlyContinue
106
+ }
@@ -85,4 +85,4 @@ for (const target of ['heavy-worker', 'worker']) {
85
85
  console.log(` single-call batches: ${batch1}/${batchTot} (${pct(batch1, batchTot)}%)`);
86
86
  console.log(` content greps with -C/-A/-B: ${grepContentWithCtx}/${grepContent} (${pct(grepContentWithCtx, grepContent)}%) grep->read follow-ups: ${grepThenRead}`);
87
87
  console.log(` same-path re-reads >=3x: ${rereads}`);
88
- }
88
+ }
@@ -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
@@ -1,3 +1,3 @@
1
- # Channels
2
-
3
- - Channel features are handled by the runtime.
1
+ # Channels
2
+
3
+ - Channel features are handled by the runtime.
@@ -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
  : {}),