mixdog 0.9.47 → 0.9.49

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 (83) hide show
  1. package/package.json +14 -4
  2. package/scripts/agent-parallel-smoke.mjs +4 -1
  3. package/scripts/agent-route-batch-test.mjs +40 -0
  4. package/scripts/code-graph-aggregate-cwd-test.mjs +154 -0
  5. package/scripts/code-graph-description-contract.mjs +185 -0
  6. package/scripts/code-graph-disk-hit-test.mjs +55 -0
  7. package/scripts/code-graph-dispatch-test.mjs +96 -0
  8. package/scripts/compact-pressure-test.mjs +40 -0
  9. package/scripts/deferred-tool-loading-test.mjs +233 -0
  10. package/scripts/execution-completion-dedup-test.mjs +48 -0
  11. package/scripts/explore-prompt-policy-test.mjs +88 -3
  12. package/scripts/live-worker-smoke.mjs +68 -16
  13. package/scripts/memory-core-input-test.mjs +33 -13
  14. package/scripts/native-edit-wire-test.mjs +152 -0
  15. package/scripts/openai-oauth-ws-1006-retry-test.mjs +294 -16
  16. package/scripts/patch-binary-cache-test.mjs +181 -0
  17. package/scripts/prompt-immediate-render-test.mjs +89 -0
  18. package/scripts/provider-toolcall-test.mjs +280 -15
  19. package/scripts/shell-failure-diagnostics-test.mjs +211 -0
  20. package/scripts/statusline-quota-hysteresis-test.mjs +26 -1
  21. package/scripts/streaming-tail-window-test.mjs +29 -0
  22. package/scripts/tool-failures.mjs +21 -3
  23. package/scripts/tool-smoke.mjs +263 -38
  24. package/scripts/tool-tui-presentation-test.mjs +17 -1
  25. package/scripts/tui-perf-run.ps1 +26 -0
  26. package/scripts/tui-transcript-perf-test.mjs +7 -1
  27. package/scripts/verify-release-assets-test.mjs +647 -0
  28. package/scripts/verify-release-assets.mjs +293 -0
  29. package/scripts/windows-hide-spawn-options-test.mjs +19 -0
  30. package/src/cli.mjs +1 -0
  31. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +16 -5
  32. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +35 -11
  33. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +35 -11
  34. package/src/runtime/agent/orchestrator/providers/custom-tool-wire.mjs +28 -0
  35. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +1 -1
  36. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +34 -34
  37. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +45 -38
  38. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +36 -11
  39. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +4 -4
  40. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +10 -6
  41. package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +4 -3
  42. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +4 -3
  43. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +16 -1
  44. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +2 -2
  45. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +0 -1
  46. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +13 -8
  47. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +30 -16
  48. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +25 -8
  49. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +69 -4
  50. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +5 -4
  51. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
  52. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +12 -12
  53. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +12 -7
  54. package/src/runtime/agent/orchestrator/tools/patch-binary-fetcher.mjs +77 -18
  55. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  56. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +99 -24
  57. package/src/runtime/memory/lib/memory-action-handlers.mjs +21 -11
  58. package/src/runtime/memory/tool-defs.mjs +1 -2
  59. package/src/session-runtime/context-status.mjs +25 -16
  60. package/src/session-runtime/model-route-api.mjs +2 -0
  61. package/src/session-runtime/runtime-core.mjs +2 -2
  62. package/src/session-runtime/session-turn-api.mjs +4 -1
  63. package/src/session-runtime/tool-catalog.mjs +113 -19
  64. package/src/session-runtime/tool-defs.mjs +1 -0
  65. package/src/standalone/agent-tool/helpers.mjs +25 -6
  66. package/src/standalone/agent-tool.mjs +75 -41
  67. package/src/tui/App.jsx +4 -0
  68. package/src/tui/app/input-parsers.mjs +8 -9
  69. package/src/tui/components/Markdown.jsx +6 -1
  70. package/src/tui/components/Message.jsx +11 -2
  71. package/src/tui/components/PromptInput.jsx +19 -21
  72. package/src/tui/components/Spinner.jsx +4 -4
  73. package/src/tui/components/StatusLine.jsx +6 -6
  74. package/src/tui/components/TranscriptItem.jsx +2 -2
  75. package/src/tui/components/prompt-input/immediate-render.mjs +47 -0
  76. package/src/tui/components/tool-execution/surface-detail.mjs +14 -9
  77. package/src/tui/dist/index.mjs +130 -45
  78. package/src/tui/engine/agent-job-feed.mjs +21 -2
  79. package/src/tui/engine/turn.mjs +12 -1
  80. package/src/tui/markdown/measure-rendered-rows.mjs +1 -1
  81. package/src/tui/markdown/streaming-markdown.mjs +20 -0
  82. package/src/ui/statusline.mjs +5 -5
  83. package/src/vendor/statusline/src/gateway/session-routes.mjs +22 -10
@@ -0,0 +1,211 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { chmodSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join, resolve } from 'node:path';
6
+ import { spawnSync } from 'node:child_process';
7
+ import { classifyToolFailure } from '../src/runtime/agent/orchestrator/agent-trace-format.mjs';
8
+ import { ExecResult, execShellCommand } from '../src/runtime/agent/orchestrator/tools/shell-command.mjs';
9
+ import { _composeShellFailure, _shellFailureStatus } from '../src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs';
10
+
11
+ test('shell trace classification uses only the leading status marker', () => {
12
+ assert.equal(classifyToolFailure(
13
+ 'Error: [shell-run-failed] [exit code: 1]\n\ncommand timed out while parsing an aborted field',
14
+ 'shell',
15
+ ), 'command-exit');
16
+ assert.equal(classifyToolFailure(
17
+ 'Error: [shell-run-failed] [signal: SIGKILL]\n\n(no output)',
18
+ 'shell',
19
+ ), 'process/signal');
20
+ assert.equal(classifyToolFailure(
21
+ 'Error: [shell-run-failed] [timeout: 500ms signal: SIGTERM cause: timeout]',
22
+ 'shell',
23
+ ), 'timeout/abort');
24
+ assert.equal(classifyToolFailure(
25
+ 'Error: [shell-run-failed] [signal: SIGTERM cause: cancellation]',
26
+ 'shell',
27
+ ), 'timeout/abort');
28
+ assert.equal(classifyToolFailure(
29
+ 'Error: [shell-run-failed] [signal: SIGKILL cause: output-limit]',
30
+ 'shell',
31
+ ), 'runtime/failure');
32
+ assert.equal(classifyToolFailure(
33
+ '⚠️ destructive command warning\nError: [shell-run-failed] [signal: SIGKILL]',
34
+ 'shell',
35
+ ), 'process/signal');
36
+ });
37
+
38
+ test('shell failure rendering preserves actual signals and runtime kill causes', () => {
39
+ const status = (opts) => _shellFailureStatus(new ExecResult({
40
+ stdout: '', stderr: '', exitCode: null, taskId: 'test', ...opts,
41
+ }), 500).statusDetail;
42
+ assert.match(status({ signal: 'SIGKILL' }), /^\[signal: SIGKILL\]$/);
43
+ assert.match(status({ signal: 'SIGTERM', killed: true, killCause: 'cancellation' }),
44
+ /^\[signal: SIGTERM cause: cancellation\]$/);
45
+ assert.match(status({ signal: 'SIGTERM', killed: true, timedOut: true, killCause: 'timeout' }),
46
+ /^\[timeout: 500ms signal: SIGTERM cause: timeout\]/);
47
+ assert.match(status({
48
+ killed: true,
49
+ killCause: 'output-capture-error',
50
+ outputCaptureError: new Error('disk full'),
51
+ }), /^\[output capture failed cause: output-capture-error signal: SIGKILL\]$/);
52
+ assert.match(status({ signal: 'SIGKILL', killed: true, killCause: 'output-limit' }),
53
+ /^\[signal: SIGKILL cause: output-limit\]$/);
54
+ });
55
+
56
+ test('WMIC rewrite note follows the leading shell failure marker', () => {
57
+ const rendered = _composeShellFailure(
58
+ '[shell-run-failed] [exit code: 1]',
59
+ 'Error: ',
60
+ '[auto-rewrite: deprecated wmic process query -> PowerShell; timeout capped at 30000ms]',
61
+ '(no output)',
62
+ );
63
+ assert.match(rendered, /^Error: \[shell-run-failed\] \[exit code: 1\]\n\[auto-rewrite:/);
64
+ assert.equal(classifyToolFailure(rendered, 'shell'), 'command-exit');
65
+ });
66
+
67
+ async function withoutUnhandledProcessFailure(run) {
68
+ const uncaught = [];
69
+ const rejected = [];
70
+ const onUncaught = (err) => uncaught.push(err);
71
+ const onRejected = (err) => rejected.push(err);
72
+ process.on('uncaughtException', onUncaught);
73
+ process.on('unhandledRejection', onRejected);
74
+ try {
75
+ const result = await run();
76
+ await new Promise((resolveTurn) => setImmediate(resolveTurn));
77
+ assert.deepEqual(uncaught, [], `unexpected uncaught error: ${uncaught[0]?.stack || uncaught[0]}`);
78
+ assert.deepEqual(rejected, [], `unexpected unhandled rejection: ${rejected[0]?.stack || rejected[0]}`);
79
+ return result;
80
+ } finally {
81
+ process.removeListener('uncaughtException', onUncaught);
82
+ process.removeListener('unhandledRejection', onRejected);
83
+ }
84
+ }
85
+
86
+ function assertSpawnToolFailure(result) {
87
+ assert.equal(result.failurePhase, 'tool');
88
+ assert.equal(result.failureReason, 'spawn failed');
89
+ const status = _shellFailureStatus(result, 1000);
90
+ assert.equal(status.shellToolFailed, true);
91
+ const rendered = _composeShellFailure(
92
+ `[shell-tool-failed] ${status.statusDetail}`,
93
+ 'Error: ',
94
+ '',
95
+ result.stderr,
96
+ );
97
+ assert.match(rendered, /^Error: \[shell-tool-failed\] \[spawn failed\]/);
98
+ assert.equal(classifyToolFailure(rendered, 'shell'), 'tool-call/failure');
99
+ }
100
+
101
+ test('asynchronous ENOENT spawn errors remain shell tool failures', async () => {
102
+ const missing = await withoutUnhandledProcessFailure(() => execShellCommand({
103
+ shell: join(tmpdir(), `mixdog-missing-shell-${process.pid}`),
104
+ shellArg: '-c',
105
+ command: 'echo unreachable',
106
+ env: process.env,
107
+ cwd: process.cwd(),
108
+ timeoutMs: 1000,
109
+ }));
110
+ assertSpawnToolFailure(missing);
111
+ assert.match(missing.stderr, /ENOENT|not found/i);
112
+ });
113
+
114
+ test('asynchronous EACCES spawn errors remain shell tool failures', async (t) => {
115
+ if (process.platform === 'win32') return t.skip('executable-bit case is POSIX-only');
116
+ const dir = mkdtempSync(join(tmpdir(), 'mixdog-eacces-shell-'));
117
+ try {
118
+ const denied = join(dir, 'denied.sh');
119
+ writeFileSync(denied, '#!/bin/sh\necho unreachable\n');
120
+ chmodSync(denied, 0o600);
121
+ const result = await withoutUnhandledProcessFailure(() => execShellCommand({
122
+ shell: denied,
123
+ shellArg: '-c',
124
+ command: 'echo unreachable',
125
+ env: process.env,
126
+ cwd: process.cwd(),
127
+ timeoutMs: 1000,
128
+ }));
129
+ assertSpawnToolFailure(result);
130
+ assert.match(result.stderr, /EACCES|permission denied/i);
131
+ } finally {
132
+ rmSync(dir, { recursive: true, force: true });
133
+ }
134
+ });
135
+
136
+ test('execShellCommand carries cancellation cause alongside process signal', async () => {
137
+ const controller = new AbortController();
138
+ const isWindows = process.platform === 'win32';
139
+ const promise = execShellCommand({
140
+ shell: isWindows ? (process.env.ComSpec || 'cmd.exe') : '/bin/sh',
141
+ shellArg: isWindows ? '/c' : '-c',
142
+ command: isWindows ? 'ping 127.0.0.1 -n 20 > nul' : 'sleep 10',
143
+ env: process.env,
144
+ cwd: process.cwd(),
145
+ timeoutMs: 5000,
146
+ abortSignal: controller.signal,
147
+ backgroundOnTimeout: false,
148
+ });
149
+ setTimeout(() => controller.abort(), 100);
150
+ const result = await promise;
151
+ assert.equal(result.killed, true);
152
+ assert.equal(result.killCause, 'cancellation');
153
+ assert.ok(result.signal || process.platform === 'win32');
154
+ });
155
+
156
+ test('cancellation racing with auto-background adoption is returned as cancelled', async () => {
157
+ let abortReads = 0;
158
+ const racingSignal = {
159
+ get aborted() { abortReads += 1; return abortReads >= 2; },
160
+ addEventListener() {},
161
+ removeEventListener() {},
162
+ };
163
+ const isWindows = process.platform === 'win32';
164
+ const result = await execShellCommand({
165
+ shell: isWindows ? (process.env.ComSpec || 'cmd.exe') : '/bin/sh',
166
+ shellArg: isWindows ? '/c' : '-c',
167
+ command: isWindows ? 'ping 127.0.0.1 -n 20 > nul' : 'sleep 10',
168
+ env: process.env,
169
+ cwd: process.cwd(),
170
+ timeoutMs: 5000,
171
+ abortSignal: racingSignal,
172
+ autoBackgroundMs: 25,
173
+ backgroundOnTimeout: false,
174
+ });
175
+ assert.equal(result.backgrounded, false);
176
+ assert.equal(result.killed, true);
177
+ assert.equal(result.killCause, 'cancellation');
178
+ });
179
+
180
+ test('tool-failures separates actionable totals while retaining command-exit rows', () => {
181
+ const dir = mkdtempSync(join(tmpdir(), 'mixdog-tool-failures-test-'));
182
+ try {
183
+ const history = join(dir, 'history');
184
+ mkdirSync(history);
185
+ const rows = [
186
+ { ts: 1, tool_name: 'shell', category: 'process/signal', error_first_line: 'SIGKILL' },
187
+ { ts: 2, tool_name: 'shell', category: 'runtime/failure', error_first_line: 'capture guard' },
188
+ ...Array.from({ length: 45 }, (_, index) => ({
189
+ ts: index + 3,
190
+ tool_name: 'shell',
191
+ category: 'command-exit',
192
+ error_first_line: `exit ${index}`,
193
+ })),
194
+ ];
195
+ writeFileSync(join(history, 'tool-failures.jsonl'), `${rows.map(JSON.stringify).join('\n')}\n`);
196
+ const script = resolve('scripts/tool-failures.mjs');
197
+ const text = spawnSync(process.execPath, [script, '--data-dir', dir, '--limit', '2'], { encoding: 'utf8' });
198
+ assert.equal(text.status, 0, text.stderr);
199
+ assert.match(text.stdout, /actionable failures: 2\/2 shown/);
200
+ assert.match(text.stdout, /command exits: 2\/45 shown \(retained\)/);
201
+ assert.equal((text.stdout.match(/^- /gm) || []).length, 4);
202
+ const json = spawnSync(process.execPath, [script, '--data-dir', dir, '--limit', '2', '--json'], { encoding: 'utf8' });
203
+ assert.equal(json.status, 0, json.stderr);
204
+ const report = JSON.parse(json.stdout);
205
+ assert.deepEqual(report.actionable_failures, { shown: 2, matched: 2 });
206
+ assert.deepEqual(report.command_exits, { shown: 2, matched: 45 });
207
+ assert.equal(report.rows.length, 4);
208
+ } finally {
209
+ rmSync(dir, { recursive: true, force: true });
210
+ }
211
+ });
@@ -1,6 +1,10 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
- import { acceptQuotaSnapshot } from '../src/ui/statusline.mjs';
3
+ import {
4
+ acceptQuotaSnapshot,
5
+ fallbackStatusline,
6
+ resolveContextUsedPct,
7
+ } from '../src/ui/statusline.mjs';
4
8
 
5
9
  // Monotonic hysteresis for the 5H/7D usage segment: once a value has rendered,
6
10
  // an OLDER shared-cache snapshot (written by another mixdog instance) must not
@@ -35,3 +39,24 @@ test('own-instance live data always wins; empty timestamps preserve prior behavi
35
39
  // Shared→shared same asOf → accept (idempotent refresh).
36
40
  assert.equal(acceptQuotaSnapshot({ asOf: 2000, owned: false }, { asOf: 2000, owned: false }), true);
37
41
  });
42
+
43
+ test('context percentage uses compaction pressure over trigger and ignores gateway boundary percent', () => {
44
+ const args = {
45
+ stats: {
46
+ currentEstimatedContextTokens: 75_000,
47
+ currentContextSource: 'estimated',
48
+ },
49
+ contextWindow: 100_000,
50
+ compactBoundaryTokens: 100_000,
51
+ autoCompactTokenLimit: 75_000,
52
+ };
53
+ assert.equal(resolveContextUsedPct({
54
+ ...args,
55
+ gatewayStatus: { contextUsedPct: 75 },
56
+ }), 100);
57
+ assert.match(
58
+ fallbackStatusline({ provider: 'openai', model: 'test', ...args })
59
+ .replace(/\x1b\[[0-9;]*m/g, ''),
60
+ /\b100%/,
61
+ );
62
+ });
@@ -0,0 +1,29 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import {
5
+ windowPlainStreamingText,
6
+ } from '../src/tui/markdown/streaming-markdown.mjs';
7
+ import {
8
+ measureStreamingMarkdownRenderedRows,
9
+ } from '../src/tui/markdown/measure-rendered-rows.mjs';
10
+
11
+ test('bottom-pinned plain streaming text keeps only the visible suffix', () => {
12
+ const lines = Array.from({ length: 100 }, (_, index) => `plain line ${index}`);
13
+ const full = lines.join('\n');
14
+ const windowed = windowPlainStreamingText(full, 80, 12);
15
+
16
+ assert.equal(windowed, lines.slice(-12).join('\n'));
17
+ assert.ok(measureStreamingMarkdownRenderedRows(full, 83, 'plain-window-full') > 12);
18
+ });
19
+
20
+ test('markdown streaming text is never internally windowed', () => {
21
+ const markdown = `${'plain history\n'.repeat(100)}**live markdown**`;
22
+ assert.equal(windowPlainStreamingText(markdown, 80, 12), markdown.trim());
23
+ });
24
+
25
+ test('wrapped plain lines consume the streaming row budget', () => {
26
+ const lines = ['old', 'x'.repeat(160), 'new'];
27
+ assert.equal(windowPlainStreamingText(lines.join('\n'), 80, 2), lines.slice(-1).join('\n'));
28
+ assert.equal(windowPlainStreamingText(lines.join('\n'), 80, 3), lines.slice(-2).join('\n'));
29
+ });
@@ -94,20 +94,33 @@ const rows = files.flatMap(readRows)
94
94
  .filter((row) => !agentFilter || String(row.agent || '-') === agentFilter)
95
95
  .filter((row) => !categoryFilter || rowCategory(row) === categoryFilter)
96
96
  .sort((a, b) => Number(a.ts || 0) - Number(b.ts || 0));
97
- const recent = rows.slice(-limit);
97
+ const isCommandExit = (row) => rowCategory(row) === 'command-exit';
98
+ const actionableRows = rows.filter((row) => !isCommandExit(row));
99
+ const commandExitRows = rows.filter(isCommandExit);
100
+ // Limit each partition independently so a burst of ordinary command exits
101
+ // cannot crowd runtime/actionable failures out of the displayed report.
102
+ const actionableRecent = actionableRows.slice(-limit);
103
+ const commandExitRecent = commandExitRows.slice(-limit);
104
+ const recent = [...actionableRecent, ...commandExitRecent]
105
+ .sort((a, b) => Number(a.ts || 0) - Number(b.ts || 0));
98
106
  const byTool = new Map();
99
107
  const byCategory = new Map();
108
+ const actionableByTool = new Map();
109
+ const commandExitByTool = new Map();
100
110
  for (const row of recent) {
101
111
  const tool = rowTool(row);
102
112
  const category = rowCategory(row);
103
113
  inc(byTool, tool);
104
114
  inc(byCategory, `${tool} / ${category}`);
115
+ inc(isCommandExit(row) ? commandExitByTool : actionableByTool, tool);
105
116
  }
106
117
 
107
118
  if (jsonMode) {
108
119
  console.log(JSON.stringify({
109
120
  shown: recent.length,
110
121
  matched: rows.length,
122
+ actionable_failures: { shown: actionableRecent.length, matched: actionableRows.length },
123
+ command_exits: { shown: commandExitRecent.length, matched: commandExitRows.length },
111
124
  since: sinceTs ? new Date(sinceTs).toISOString() : null,
112
125
  filters: {
113
126
  tool: toolFilter,
@@ -116,13 +129,17 @@ if (jsonMode) {
116
129
  },
117
130
  sources: files.filter(existsSync),
118
131
  tools: Object.fromEntries([...byTool.entries()].sort((a, b) => b[1] - a[1])),
132
+ actionable_tools: Object.fromEntries([...actionableByTool.entries()].sort((a, b) => b[1] - a[1])),
133
+ command_exit_tools: Object.fromEntries([...commandExitByTool.entries()].sort((a, b) => b[1] - a[1])),
119
134
  categories: Object.fromEntries([...byCategory.entries()].sort((a, b) => b[1] - a[1])),
120
135
  rows: recent,
121
136
  }, null, 2));
122
137
  process.exit(0);
123
138
  }
124
139
 
125
- console.log(`tool failures: ${recent.length}/${rows.length} shown`);
140
+ console.log(`actionable failures: ${actionableRecent.length}/${actionableRows.length} shown`);
141
+ console.log(`command exits: ${commandExitRecent.length}/${commandExitRows.length} shown (retained)`);
142
+ console.log(`rows: ${recent.length}/${rows.length} shown`);
126
143
  if (sinceTs) console.log(`since: ${new Date(sinceTs).toISOString()}`);
127
144
  const filterParts = [
128
145
  toolFilter ? `tool=${toolFilter}` : '',
@@ -131,7 +148,8 @@ const filterParts = [
131
148
  ].filter(Boolean);
132
149
  if (filterParts.length) console.log(`filters: ${filterParts.join(', ')}`);
133
150
  if (files.length > 0) console.log(`sources: ${files.filter(existsSync).join(', ') || '(none)'}`);
134
- console.log(`tools: ${[...byTool.entries()].sort((a, b) => b[1] - a[1]).map(([k, v]) => `${k}:${v}`).join(', ') || '(none)'}`);
151
+ console.log(`actionable tools: ${[...actionableByTool.entries()].sort((a, b) => b[1] - a[1]).map(([k, v]) => `${k}:${v}`).join(', ') || '(none)'}`);
152
+ console.log(`command-exit tools: ${[...commandExitByTool.entries()].sort((a, b) => b[1] - a[1]).map(([k, v]) => `${k}:${v}`).join(', ') || '(none)'}`);
135
153
  console.log(`categories: ${[...byCategory.entries()].sort((a, b) => b[1] - a[1]).map(([k, v]) => `${k}:${v}`).join(', ') || '(none)'}`);
136
154
  for (const row of recent) {
137
155
  const tool = rowTool(row);