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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.15",
3
+ "version": "0.9.17",
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
+ });
@@ -88,8 +88,9 @@ function readTraceSince(ts) {
88
88
  }
89
89
 
90
90
  const t0 = Date.now();
91
- const results = [];
92
- for (const { q, expect, expectFail } of QUERIES) {
91
+ // Parallel round: all queries fire at once. Per-session trace attribution is
92
+ // by session_id so counts stay correct; per-query ms includes queueing.
93
+ const results = await Promise.all(QUERIES.map(async ({ q, expect, expectFail }) => {
93
94
  const qt0 = Date.now();
94
95
  const res = await runExplore({ query: q, cwd: CWD }, { callerCwd: CWD });
95
96
  const text = res?.content?.[0]?.text || '';
@@ -99,8 +100,8 @@ for (const { q, expect, expectFail } of QUERIES) {
99
100
  const hit = expectFail
100
101
  ? (failed && anchors === 0)
101
102
  : (!failed && (expect || []).some((e) => lower.includes(e.toLowerCase())));
102
- results.push({ q: q.slice(0, 48), ms: Date.now() - qt0, anchors, failed, hit, bytes: text.length });
103
- }
103
+ return { q: q.slice(0, 48), ms: Date.now() - qt0, anchors, failed, hit, bytes: text.length };
104
+ }));
104
105
 
105
106
  // Attribute tool calls per explorer session spawned during this run.
106
107
  const rows = readTraceSince(t0);
@@ -56,6 +56,25 @@ const summaryRow = { role: 'user', content: `${SUMMARY_PREFIX}\nmessages=10 sha2
56
56
  const ackRow = { role: 'assistant', content: '.' }
57
57
  // (vi) internal runtime nudge user row.
58
58
  const nudgeRow = { role: 'user', content: '[mixdog-runtime] Your previous response was empty. Continue.' }
59
+ // (vi-b) model-visible tool-completion wrapper row (mgr.enqueuePendingMessage
60
+ // mirror of modelVisibleToolCompletionMessage — the transcript-watcher bug
61
+ // this change fixes: these were previously persisted verbatim).
62
+ const completionWrapperRow = {
63
+ role: 'user',
64
+ content:
65
+ 'The async shell task task_123 has finished (completed) - review this result in your next step.\n\n' +
66
+ 'Result:\n> background task\n> task_id: task_123\n> status: completed',
67
+ }
68
+ // (vi-c) UNQUOTED persisted form — taken from a real DB row. No `> ` prefix
69
+ // on the Result body; isModelVisibleToolCompletionWrapper alone misses this
70
+ // shape, so shouldExcludeIngestMessage falls back to the instruction-head
71
+ // regex (isUnquotedToolCompletionHead).
72
+ const unquotedCompletionRow = {
73
+ role: 'user',
74
+ content:
75
+ 'The async shell task task_456 has finished (completed, exit 0) - review this result in your next step.\n' +
76
+ 'Result:\nbackground task\ntask_id: task_456\nstatus: completed',
77
+ }
59
78
  // (vii) normal human + normal assistant.
60
79
  const normalUser = { role: 'user', content: '안녕' }
61
80
  const normalAsst = { role: 'assistant', content: '네 안녕하세요' }
@@ -67,6 +86,8 @@ const before = {
67
86
  summaryRow: summaryRow.content,
68
87
  ackRow: ackRow.content,
69
88
  nudgeRow: nudgeRow.content,
89
+ completionWrapperRow: completionWrapperRow.content,
90
+ unquotedCompletionRow: unquotedCompletionRow.content,
70
91
  normalUser: normalUser.content,
71
92
  normalAsst: normalAsst.content,
72
93
  }
@@ -78,6 +99,8 @@ const after = {
78
99
  summaryRow: ingestRow(summaryRow),
79
100
  ackRow: ingestRow(ackRow),
80
101
  nudgeRow: ingestRow(nudgeRow),
102
+ completionWrapperRow: ingestRow(completionWrapperRow),
103
+ unquotedCompletionRow: ingestRow(unquotedCompletionRow),
81
104
  normalUser: ingestRow(normalUser),
82
105
  normalAsst: ingestRow(normalAsst),
83
106
  }
@@ -96,6 +119,8 @@ assert(after.referenceRow === null, 'Reference files row must be excluded')
96
119
  assert(after.summaryRow === null, 'SUMMARY_PREFIX row must be excluded')
97
120
  assert(after.ackRow === null, '`.` ack row must be excluded')
98
121
  assert(after.nudgeRow === null, '[mixdog-runtime] nudge row must be excluded')
122
+ assert(after.completionWrapperRow === null, 'model-visible tool-completion wrapper row must be excluded')
123
+ assert(after.unquotedCompletionRow === null, 'unquoted tool-completion wrapper row (real DB shape) must be excluded')
99
124
 
100
125
  // (vii) normal conversation survives intact.
101
126
  assert(after.normalUser === '안녕', 'normal human text must survive intact')
@@ -136,6 +161,8 @@ const rows = [
136
161
  ['(iv) SUMMARY_PREFIX', before.summaryRow, after.summaryRow],
137
162
  ['(v) `.` ack', before.ackRow, after.ackRow],
138
163
  ['(vi) [mixdog-runtime] nudge', before.nudgeRow, after.nudgeRow],
164
+ ['(vi-b) tool-completion wrapper', before.completionWrapperRow, after.completionWrapperRow],
165
+ ['(vi-c) unquoted tool-completion (real DB shape)', before.unquotedCompletionRow, after.unquotedCompletionRow],
139
166
  ['(vii) normal human', before.normalUser, after.normalUser],
140
167
  ['(vii) normal assistant', before.normalAsst, after.normalAsst],
141
168
  ]
@@ -69,7 +69,7 @@ for (const required of [
69
69
  'Outcome-first concise handoffs for coding work',
70
70
  'Practical concise — outcome-first handoffs',
71
71
  'file_path:line_number',
72
- 'controlled detail',
72
+ 'Controlled detail',
73
73
  'Synthesize agent or retrieval results',
74
74
  'Do not hide blockers',
75
75
  'verification was not run, say so once',
@@ -106,9 +106,9 @@ try {
106
106
  assert(!customRules.includes('Mixdog default — the most detailed of the three styles'), 'custom outputStyle should not append default style');
107
107
  const profileMeta = rulesBuilder.buildLeadMetaContent({ PLUGIN_ROOT: join(root, 'src'), DATA_DIR: dataDir });
108
108
  assert(profileMeta.includes('Use "재영님" when directly addressing the user'), 'profile title must inject into Lead BP3 meta');
109
- assert(profileMeta.includes('Do not repeat it in routine progress updates or pre-tool preambles'), 'profile title must not encourage title in preambles');
109
+ assert(profileMeta.includes('do not repeat it in routine progress updates or pre-tool preambles'), 'profile title must not encourage title in preambles');
110
110
  assert(/Default user-facing response language from system locale/.test(profileMeta), 'system profile language must resolve from system locale');
111
- assert(profileMeta.includes('including pre-tool preambles'), 'profile language must cover pre-tool preambles');
111
+ assert(profileMeta.includes('pre-tool preambles (even single-line)'), 'profile language must cover pre-tool preambles');
112
112
  } finally {
113
113
  rmSync(dataDir, { recursive: true, force: true });
114
114
  }
@@ -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', () => {
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * termio-input-smoke.mjs — throwaway smoke for the vendored termio input
4
+ * pipeline. Feeds synthetic chunk sequences through createInputParser() and
5
+ * asserts the typed event kinds / channel routing decisions.
6
+ *
7
+ * Run: node scripts/termio-input-smoke.mjs
8
+ */
9
+ import { createInputParser } from '../vendor/ink/build/input-parser.js';
10
+
11
+ let failures = 0;
12
+ function check(name, cond, detail) {
13
+ if (cond) {
14
+ console.log(`ok - ${name}`);
15
+ } else {
16
+ failures++;
17
+ console.log(`FAIL - ${name}${detail ? ' :: ' + detail : ''}`);
18
+ }
19
+ }
20
+
21
+ // Helper: push a list of chunks, collect all emitted events.
22
+ function run(chunks) {
23
+ const p = createInputParser();
24
+ const events = [];
25
+ for (const c of chunks) events.push(...p.push(c));
26
+ return { p, events };
27
+ }
28
+
29
+ // 1. ESC split across two chunks -> single arrow-up key, no lone escape.
30
+ {
31
+ const { events } = run(['\x1b', '[A']);
32
+ check('split ESC -> one up key',
33
+ events.length === 1 && events[0].kind === 'key' && events[0].name === 'up',
34
+ JSON.stringify(events));
35
+ }
36
+
37
+ // 2. SGR mouse click (button 0 press) -> ParsedMouse.
38
+ {
39
+ const { events } = run(['\x1b[<0;12;34M']);
40
+ const m = events[0];
41
+ check('SGR click -> mouse press',
42
+ events.length === 1 && m.kind === 'mouse' && m.action === 'press' &&
43
+ m.col === 12 && m.row === 34 && m.button === 0,
44
+ JSON.stringify(events));
45
+ }
46
+
47
+ // 3. SGR wheel-up (button 64) -> key wheelup (stays a key, routes to mouse chan).
48
+ {
49
+ const { events } = run(['\x1b[<64;5;5M']);
50
+ const k = events[0];
51
+ check('SGR wheel -> wheelup key',
52
+ events.length === 1 && k.kind === 'key' && k.name === 'wheelup',
53
+ JSON.stringify(events));
54
+ }
55
+
56
+ // 4. X10 mouse: CSI M + 3 payload bytes (wheel-up: Cb = 0x40+32 = 96 = '`').
57
+ {
58
+ const { events } = run(['\x1b[M\x60\x30\x30']);
59
+ const k = events[0];
60
+ check('X10 wheel -> wheelup key',
61
+ events.length === 1 && k.kind === 'key' && k.name === 'wheelup',
62
+ JSON.stringify(events));
63
+ }
64
+
65
+ // 5. Bracketed paste split across 3 chunks -> one pasted key with full body.
66
+ {
67
+ const { events } = run(['\x1b[200~hel', 'lo wor', 'ld\x1b[201~']);
68
+ const paste = events.find((e) => e.isPasted);
69
+ check('split paste -> one pasted key "hello world"',
70
+ events.length === 1 && paste && paste.sequence === 'hello world',
71
+ JSON.stringify(events));
72
+ }
73
+
74
+ // 6. Paste whose body ends with an X10 mouse tail `\x1b[M` — the ESC in the
75
+ // payload slot must NOT be consumed as X10 mouse; it stays paste content and
76
+ // PASTE_END still terminates the paste.
77
+ {
78
+ const { events } = run(['\x1b[200~abc\x1b[M12\x1b[201~']);
79
+ const paste = events.find((e) => e.isPasted);
80
+ check('paste with \\x1b[M tail stays paste',
81
+ events.length === 1 && paste && paste.sequence === 'abc\x1b[M12',
82
+ JSON.stringify(events));
83
+ }
84
+
85
+ // 7. Terminal response (DA1) -> ParsedResponse.
86
+ {
87
+ const { events } = run(['\x1b[?62;1c']);
88
+ const r = events[0];
89
+ check('DA1 -> response',
90
+ events.length === 1 && r.kind === 'response' && r.response.type === 'da1',
91
+ JSON.stringify(events));
92
+ }
93
+
94
+ // 8. Lone ESC buffered -> hasPendingEscape true; flushPendingEscape -> escape key.
95
+ {
96
+ const p = createInputParser();
97
+ const first = p.push('\x1b');
98
+ check('lone ESC buffers (no immediate events)', first.length === 0, JSON.stringify(first));
99
+ check('hasPendingEscape true for lone ESC', p.hasPendingEscape() === true);
100
+ const flushed = p.flushPendingEscape();
101
+ check('flushPendingEscape -> escape key',
102
+ Array.isArray(flushed) && flushed.length === 1 && flushed[0].name === 'escape',
103
+ JSON.stringify(flushed));
104
+ }
105
+
106
+ // 9. Incomplete paste-start marker must NOT arm the escape flush timer.
107
+ {
108
+ const p = createInputParser();
109
+ p.push('\x1b[200');
110
+ check('partial paste-start does not arm flush', p.hasPendingEscape() === false);
111
+ }
112
+
113
+ // 9b. Generic partial CSI (`\x1b[`, `\x1b[2`, `\x1b[20`) — none of these are
114
+ // paste-start prefixes/penultimate, so hasPendingEscape MUST arm to avoid
115
+ // buffering indefinitely.
116
+ {
117
+ for (const partial of ['\x1b[', '\x1b[2', '\x1b[20']) {
118
+ const p = createInputParser();
119
+ p.push(partial);
120
+ check(`generic partial CSI ${JSON.stringify(partial)} arms flush`,
121
+ p.hasPendingEscape() === true);
122
+ }
123
+ }
124
+
125
+ // 9c. `\x1b[200` and `\x1b[200~`-prefixed pending must NOT arm the flush
126
+ // (paste-start marker / its penultimate prefix complete on their own).
127
+ {
128
+ const p1 = createInputParser();
129
+ p1.push('\x1b[200');
130
+ check('"\\x1b[200" does not arm flush', p1.hasPendingEscape() === false);
131
+
132
+ const p2 = createInputParser();
133
+ p2.push('\x1b[200~hel'); // still inside paste body once PASTE_START token completes
134
+ check('paste-start-prefixed pending does not arm flush', p2.hasPendingEscape() === false);
135
+ }
136
+
137
+ // 9d. Held-backspace chunk splits into individual key events; `\r`/`\t` in
138
+ // text are NOT split.
139
+ {
140
+ const { events } = run(['abc\x7f\x7f\x7f']);
141
+ const names = events.map((e) => e.name || e.sequence);
142
+ check('abc + 3x backspace -> text key(s) + 3 separate backspace keys',
143
+ events.length === 4 &&
144
+ events[0].sequence === 'abc' &&
145
+ events.slice(1).every((e) => e.name === 'backspace' || e.sequence === '\x7f'),
146
+ JSON.stringify(names));
147
+ }
148
+ {
149
+ const { events } = run(['a\rb\tc']);
150
+ check('\\r and \\t inside text are not split (single key for whole run)',
151
+ events.length === 1 && events[0].sequence === 'a\rb\tc',
152
+ JSON.stringify(events));
153
+ }
154
+
155
+ // 10. Plain text -> key events, sequence carries the character.
156
+ {
157
+ const { events } = run(['a']);
158
+ check('plain char -> key "a"',
159
+ events.length === 1 && events[0].kind === 'key' && events[0].sequence === 'a',
160
+ JSON.stringify(events));
161
+ }
162
+
163
+ // 11. Consumer-level routing: synthetic 'mouse' channel events reach the
164
+ // use-mouse-input dispatch decisions exactly once each. We replicate the
165
+ // hook's branch keys (kind:'key' wheel with ctrl-mask from sequence vs.
166
+ // kind:'mouse' press) to guard against double-handling / mis-routing.
167
+ // MOUSE_CTRL_MASK = 16; wheel ctrl bit is read from the SGR button in
168
+ // the ParsedKey.sequence via /\x1b\[<(\d+);/ (no button field on wheel).
169
+ {
170
+ const MOUSE_CTRL_MASK = 16;
171
+ const WHEEL_SGR = /\x1b\[<(\d+);/;
172
+ // Feed real parser output so field shapes match production.
173
+ const wheelPlain = run(['\x1b[<64;5;5M']).events[0]; // wheelup, no ctrl
174
+ const wheelCtrl = run(['\x1b[<80;5;5M']).events[0]; // 64|16 -> ctrl wheelup
175
+ const click = run(['\x1b[<0;12;34M']).events[0]; // left press
176
+
177
+ const routes = { zoom: 0, scroll: 0, press: 0, ignored: 0 };
178
+ function route(event) {
179
+ if (!event || typeof event !== 'object') return;
180
+ if (event.kind === 'key') {
181
+ if (event.name !== 'wheelup' && event.name !== 'wheeldown') { routes.ignored++; return; }
182
+ const wm = WHEEL_SGR.exec(typeof event.sequence === 'string' ? event.sequence : '');
183
+ const ctrl = wm ? ((Number(wm[1]) & MOUSE_CTRL_MASK) !== 0) : false;
184
+ if (ctrl) routes.zoom++; else routes.scroll++;
185
+ return;
186
+ }
187
+ if (event.kind !== 'mouse') { routes.ignored++; return; }
188
+ const button = Number(event.button);
189
+ if ((button & 3) === 0 && event.action === 'press' && (button & 32) === 0) routes.press++;
190
+ }
191
+ for (const e of [wheelPlain, wheelCtrl, click]) route(e);
192
+ check('mouse routing: plain wheel -> scroll x1', routes.scroll === 1, JSON.stringify(routes));
193
+ check('mouse routing: ctrl wheel -> zoom x1', routes.zoom === 1, JSON.stringify(routes));
194
+ check('mouse routing: left press -> press x1 (no double)', routes.press === 1, JSON.stringify(routes));
195
+ check('mouse routing: nothing mis-routed', routes.ignored === 0, JSON.stringify(routes));
196
+ }
197
+
198
+ console.log(failures === 0 ? '\nALL PASS' : `\n${failures} FAILURE(S)`);
199
+ process.exit(failures === 0 ? 0 : 1);
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ // Tool-usage efficiency diag for worker/heavy-worker (before/after rule changes).
3
+ // Usage: node scripts/tool-efficiency-diag.mjs [--since 24h|<epoch-ms>|ISO]
4
+ import { readFileSync, existsSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
6
+ import { resolve } from 'node:path';
7
+
8
+ function sinceArg() {
9
+ const i = process.argv.indexOf('--since');
10
+ const raw = i >= 0 ? process.argv[i + 1] : '24h';
11
+ const rel = String(raw).match(/^(\d+(?:\.\d+)?)(h|m|d)$/i);
12
+ if (rel) {
13
+ const mult = { m: 60_000, h: 3_600_000, d: 86_400_000 }[rel[2].toLowerCase()];
14
+ return Date.now() - Number(rel[1]) * mult;
15
+ }
16
+ if (/^\d+$/.test(raw)) return Number(raw);
17
+ const p = Date.parse(raw);
18
+ return Number.isFinite(p) ? p : Date.now() - 86_400_000;
19
+ }
20
+
21
+ const dataDir = process.env.MIXDOG_DATA_DIR || resolve(homedir(), '.mixdog', 'data');
22
+ const files = [resolve(dataDir, 'history', 'agent-trace.jsonl.1'), resolve(dataDir, 'history', 'agent-trace.jsonl')];
23
+ const since = sinceArg();
24
+ const rows = [];
25
+ for (const f of files) {
26
+ if (!existsSync(f)) continue;
27
+ for (const line of readFileSync(f, 'utf8').split(/\r?\n/)) {
28
+ if (!line) continue;
29
+ try { const r = JSON.parse(line); if ((r.ts || 0) >= since) rows.push(r); } catch {}
30
+ }
31
+ }
32
+ const F = (r, n) => (r[n] != null ? r[n] : (r.payload && r.payload[n] != null ? r.payload[n] : null));
33
+ const bySess = new Map();
34
+ for (const r of rows) {
35
+ const k = r.sessionId || r.session_id || '?';
36
+ if (!bySess.has(k)) bySess.set(k, []);
37
+ bySess.get(k).push(r);
38
+ }
39
+ console.log(`window since ${new Date(since).toISOString()}`);
40
+ for (const target of ['heavy-worker', 'worker']) {
41
+ let batch1 = 0, batchTot = 0, llmCalls = 0, sessions = 0, toolCalls = 0;
42
+ let grepContent = 0, grepContentWithCtx = 0, grepThenRead = 0, rereads = 0, outTok = 0;
43
+ for (const [, rs] of bySess) {
44
+ let agent = null;
45
+ for (const r of rs) { const x = F(r, 'agent'); if (x) { agent = x; break; } }
46
+ if (agent !== target) continue;
47
+ sessions++;
48
+ rs.sort((a, b) => (a.ts || 0) - (b.ts || 0));
49
+ for (const b of rs.filter((r) => r.kind === 'batch')) {
50
+ const c = Number(F(b, 'tool_call_count')) || 0;
51
+ batchTot++; if (c === 1) batch1++;
52
+ }
53
+ const usage = rs.filter((r) => r.kind === 'usage_raw');
54
+ llmCalls += usage.length;
55
+ outTok += usage.reduce((s, r) => s + (Number(F(r, 'output_tokens')) || 0), 0);
56
+ const tools = rs.filter((r) => r.kind === 'tool');
57
+ toolCalls += tools.length;
58
+ const seenRead = new Map();
59
+ for (let i = 0; i < tools.length; i++) {
60
+ const t = tools[i];
61
+ const n = String(F(t, 'tool_name'));
62
+ const a = F(t, 'tool_args_summary') || {};
63
+ if (n === 'read') {
64
+ const p = typeof a.path === 'string' ? a.path : JSON.stringify(a.path);
65
+ seenRead.set(p, (seenRead.get(p) || 0) + 1);
66
+ }
67
+ if (n === 'grep' && ['content', 'content_with_context'].includes(String(a.output_mode || ''))) {
68
+ grepContent++;
69
+ if (a['-C'] != null || a['-A'] != null || a['-B'] != null) grepContentWithCtx++;
70
+ const gpath = typeof a.path === 'string' ? a.path : JSON.stringify(a.path || '');
71
+ for (let j = i + 1; j < Math.min(i + 4, tools.length); j++) {
72
+ const u = tools[j];
73
+ if (String(F(u, 'tool_name')) !== 'read') continue;
74
+ const ra = F(u, 'tool_args_summary') || {};
75
+ const rpath = typeof ra.path === 'string' ? ra.path : JSON.stringify(ra.path || '');
76
+ if (rpath && gpath && (rpath.includes(gpath) || gpath.includes(rpath))) { grepThenRead++; break; }
77
+ }
78
+ }
79
+ }
80
+ for (const [, c] of seenRead) if (c >= 3) rereads++;
81
+ }
82
+ const pct = (a, b) => (b ? Math.round((a / b) * 100) : 0);
83
+ console.log(`\n${target}: sessions=${sessions} llmCalls=${llmCalls} toolCalls=${toolCalls} outTok=${outTok}`);
84
+ console.log(` llmCalls/session=${sessions ? Math.round(llmCalls / sessions) : 0} tools/llmCall=${llmCalls ? (toolCalls / llmCalls).toFixed(2) : '-'}`);
85
+ console.log(` single-call batches: ${batch1}/${batchTot} (${pct(batch1, batchTot)}%)`);
86
+ console.log(` content greps with -C/-A/-B: ${grepContentWithCtx}/${grepContent} (${pct(grepContentWithCtx, grepContent)}%) grep->read follow-ups: ${grepThenRead}`);
87
+ console.log(` same-path re-reads >=3x: ${rereads}`);
88
+ }