mixdog 0.9.41 → 0.9.44

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 (73) hide show
  1. package/package.json +3 -2
  2. package/scripts/agent-tag-reuse-smoke.mjs +124 -10
  3. package/scripts/ansi-color-capability-test.mjs +90 -0
  4. package/scripts/anthropic-oauth-refresh-race-test.mjs +59 -0
  5. package/scripts/arg-guard-test.mjs +16 -0
  6. package/scripts/compact-pressure-test.mjs +256 -1
  7. package/scripts/generate-runtime-manifest.mjs +39 -22
  8. package/scripts/grok-oauth-refresh-race-test.mjs +198 -0
  9. package/scripts/internal-comms-bench.mjs +0 -1
  10. package/scripts/internal-comms-smoke.mjs +38 -39
  11. package/scripts/internal-tools-normalization-test.mjs +52 -0
  12. package/scripts/maintenance-default-routes-test.mjs +164 -0
  13. package/scripts/max-output-recovery-test.mjs +49 -0
  14. package/scripts/mcp-client-normalization-test.mjs +45 -0
  15. package/scripts/openai-oauth-ws-1006-retry-test.mjs +123 -0
  16. package/scripts/provider-toolcall-test.mjs +23 -0
  17. package/scripts/result-classification-test.mjs +75 -0
  18. package/scripts/routing-corpus.mjs +1 -1
  19. package/scripts/shell-jobs-windows-hide-test.mjs +164 -0
  20. package/scripts/smoke.mjs +1 -1
  21. package/scripts/submit-commandbusy-race-test.mjs +99 -7
  22. package/scripts/tui-transcript-jitter-harness-entry.jsx +302 -0
  23. package/scripts/tui-transcript-jitter-harness.mjs +58 -0
  24. package/scripts/tui-transcript-perf-test.mjs +56 -1
  25. package/src/agents/reviewer/AGENT.md +5 -3
  26. package/src/rules/lead/lead-brief.md +5 -4
  27. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +40 -3
  28. package/src/runtime/agent/orchestrator/config.mjs +29 -14
  29. package/src/runtime/agent/orchestrator/internal-tools.mjs +16 -3
  30. package/src/runtime/agent/orchestrator/mcp/client.mjs +17 -1
  31. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +49 -6
  32. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +14 -0
  33. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +46 -21
  34. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +59 -2
  35. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +5 -1
  36. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +18 -0
  37. package/src/runtime/agent/orchestrator/session/context-utils.mjs +140 -81
  38. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +23 -5
  39. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +3 -0
  40. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +12 -3
  41. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +4 -2
  42. package/src/runtime/agent/orchestrator/session/result-classification.mjs +4 -1
  43. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +7 -2
  44. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +8 -6
  45. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +15 -3
  46. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +1 -1
  47. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -5
  48. package/src/runtime/memory/data/runtime-manifest.json +11 -11
  49. package/src/runtime/memory/lib/runtime-fetcher.mjs +1 -2
  50. package/src/runtime/memory/tool-defs.mjs +4 -3
  51. package/src/runtime/shared/tool-surface.mjs +1 -2
  52. package/src/session-runtime/context-status.mjs +8 -2
  53. package/src/standalone/agent-tool/render.mjs +2 -0
  54. package/src/standalone/agent-tool.mjs +267 -72
  55. package/src/standalone/explore-tool.mjs +17 -16
  56. package/src/tui/app/provider-setup-picker.mjs +1 -0
  57. package/src/tui/app/use-transcript-window.mjs +5 -1
  58. package/src/tui/components/StatusLine.jsx +11 -32
  59. package/src/tui/dist/index.mjs +257 -106
  60. package/src/tui/engine/session-api.mjs +3 -0
  61. package/src/tui/engine/session-flow.mjs +19 -8
  62. package/src/tui/engine/turn.mjs +24 -2
  63. package/src/tui/engine.mjs +0 -1
  64. package/src/tui/index.jsx +3 -2
  65. package/src/tui/markdown/streaming-markdown.mjs +35 -9
  66. package/src/tui/statusline-ansi-bridge.mjs +17 -7
  67. package/src/ui/ansi.mjs +85 -5
  68. package/src/ui/statusline-agents.mjs +0 -8
  69. package/src/ui/statusline-format.mjs +7 -7
  70. package/src/ui/statusline.mjs +2 -4
  71. package/src/workflows/default/WORKFLOW.md +29 -20
  72. package/src/workflows/solo-review/WORKFLOW.md +47 -0
  73. package/src/workflows/bench/WORKFLOW.md +0 -60
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.41",
3
+ "version": "0.9.44",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -58,8 +58,9 @@
58
58
  "test:placeholder": "node --test scripts/compacted-placeholder-scrub-test.mjs",
59
59
  "test:providers": "node --test scripts/provider-toolcall-test.mjs",
60
60
  "test:anthropic-oauth-race": "node --test scripts/anthropic-oauth-refresh-race-test.mjs",
61
+ "test:grok-oauth-race": "node --test scripts/grok-oauth-refresh-race-test.mjs",
61
62
  "test:atomiclock": "node --test scripts/atomic-lock-tryonce-test.mjs",
62
- "test:memory-routing": "node --test scripts/memory-cycle-routing-test.mjs scripts/memory-rule-contract-test.mjs",
63
+ "test:memory-routing": "node --test scripts/memory-cycle-routing-test.mjs scripts/memory-rule-contract-test.mjs scripts/maintenance-default-routes-test.mjs",
63
64
  "test:tui-queue": "node --test scripts/submit-commandbusy-race-test.mjs scripts/steering-drain-buckets-test.mjs scripts/abort-recovery-test.mjs scripts/execution-pending-resume-kick-test.mjs scripts/execution-resume-esc-integration-test.mjs",
64
65
  "test:session": "node --test scripts/session-orphan-sweep-test.mjs",
65
66
  "test:rebindtail": "node --test scripts/forwarder-rebind-tail-test.mjs",
@@ -4,7 +4,6 @@
4
4
  // Validates the unified 4-tier spawn dispatch when an explicit tag is pinned:
5
5
  // 1) live + idle -> spawn reuses the existing session (reused:true, send path)
6
6
  // 2) live + busy -> spawn queues the prompt instead of throwing
7
- // 3) lingering terminal trace (no live session) -> spawn/send error, no respawn
8
7
  // 3) lingering terminal trace -> spawn reaps tag; dead-tag send auto-respawns
9
8
  // 4) genuinely new tag -> a fresh spawn (no reused/respawned flag)
10
9
  //
@@ -32,7 +31,7 @@ process.env.MIXDOG_AGENT_SPAWN_PREP_TIMEOUT_MS = '50';
32
31
  // is the authority; shorten only the test clock, not the configured duration.
33
32
  process.env.MIXDOG_AGENT_TERMINAL_REAP_MS = '1000';
34
33
  const nativeSetTimeout = globalThis.setTimeout;
35
- globalThis.setTimeout = (callback, ms, ...args) => nativeSetTimeout(callback, ms === 60_000 ? 500 : ms, ...args);
34
+ globalThis.setTimeout = (callback, ms, ...args) => nativeSetTimeout(callback, ms === 60_000 ? 1000 : ms, ...args);
36
35
 
37
36
  const { createStandaloneAgent } = await import('../src/standalone/agent-tool.mjs');
38
37
 
@@ -119,7 +118,11 @@ async function waitJob(out, pattern, label, tries = 60, context = ctx) {
119
118
  throw new Error(`${label} did not match ${pattern}: ${last}`);
120
119
  }
121
120
  function sessionForTag(tag) {
122
- return listSessions().find((s) => s?.agentTag === tag && !s.closed) || null;
121
+ for (let i = askLog.length - 1; i >= 0; i -= 1) {
122
+ const session = getSession(askLog[i].sessionId);
123
+ if (session?.agentTag === tag && !session.closed) return session;
124
+ }
125
+ return null;
123
126
  }
124
127
  function addPeerTerminalRow(tag, sessionId) {
125
128
  const file = join(dataDir, 'agent-workers.json');
@@ -137,6 +140,40 @@ function addPeerTerminalRow(tag, sessionId) {
137
140
  };
138
141
  writeFileSync(file, JSON.stringify(index));
139
142
  }
143
+ function addWorkerRow({
144
+ tag,
145
+ sessionId,
146
+ clientHostPid = ctx.clientHostPid,
147
+ status = 'idle',
148
+ updatedAt = new Date().toISOString(),
149
+ agentName = 'worker',
150
+ }) {
151
+ const file = join(dataDir, 'agent-workers.json');
152
+ const index = JSON.parse(readFileSync(file, 'utf8'));
153
+ index.workers[sessionId] = {
154
+ tag,
155
+ sessionId,
156
+ agent: agentName,
157
+ provider: 'openai-oauth',
158
+ status,
159
+ stage: status,
160
+ updatedAt,
161
+ clientHostPid,
162
+ cwd: root,
163
+ };
164
+ writeFileSync(file, JSON.stringify(index));
165
+ }
166
+ function addTombstones(rows) {
167
+ const file = join(dataDir, 'agent-workers.json');
168
+ const index = JSON.parse(readFileSync(file, 'utf8'));
169
+ index.tombstones ||= {};
170
+ for (const row of rows) index.tombstones[`${row.clientHostPid || 0}:${row.tag}`] = row;
171
+ writeFileSync(file, JSON.stringify(index));
172
+ }
173
+ function tombstonesForTag(tag) {
174
+ const index = JSON.parse(readFileSync(join(dataDir, 'agent-workers.json'), 'utf8'));
175
+ return Object.values(index.tombstones || {}).filter((row) => row.tag === tag);
176
+ }
140
177
  function hasWorkerRow(sessionId) {
141
178
  const index = JSON.parse(readFileSync(join(dataDir, 'agent-workers.json'), 'utf8'));
142
179
  return Boolean(index.workers?.[sessionId]);
@@ -165,8 +202,9 @@ async function main() {
165
202
  }, ctx);
166
203
  assert(/agent task:/.test(firstOut), `first spawn must return a task: ${firstOut}`);
167
204
  assert(!/reused: true/.test(firstOut) && !/respawned/.test(firstOut), `fresh tag must not be flagged reused/respawned: ${firstOut}`);
205
+ const firstSession = await waitSessionForTag('reviewerA', 'first spawn');
168
206
  await waitJob(firstOut, /ack: first review pass/, 'first spawn');
169
- const sid = (await waitSessionForTag('reviewerA', 'first spawn')).id;
207
+ const sid = firstSession.id;
170
208
  assert(sid, 'reviewerA session should remain live (idle) within the reap window');
171
209
 
172
210
  // --- tier 1 (the headline bug): re-spawn the SAME tag while idle+live ---
@@ -204,6 +242,7 @@ async function main() {
204
242
  type: 'spawn', agent: 'reviewer', tag: 'reviewerA', prompt: 'trace reap respawn', cwd: root,
205
243
  }, ctx);
206
244
  assert(!/^Error:/.test(traceSpawnOut), `terminal-trace spawn must reap and reuse tag: ${traceSpawnOut}`);
245
+ assert(/respawned: true/.test(traceSpawnOut), `terminal-trace spawn must flag lost context: ${traceSpawnOut}`);
207
246
  await waitJob(traceSpawnOut, /ack: trace reap respawn/, 'trace reap spawn');
208
247
  assert(hasWorkerRow('sess_peer_trace'), 'terminal-trace reap must preserve a peer terminal row with the same tag');
209
248
  removeWorkerRow('sess_peer_trace');
@@ -225,26 +264,57 @@ async function main() {
225
264
  await waitJob(timerPeerStart, /ack: timer peer seed/, 'timer peer seed');
226
265
  const timerPeerSession = (await waitSessionForTag('timer-peer', 'timer peer seed')).id;
227
266
  addPeerTerminalRow('timer-peer', 'sess_peer_timer');
228
- await sleep(700);
267
+ await sleep(1200);
229
268
  assert(getSession(timerPeerSession)?.closed, 'terminal timer must reap its local session');
230
269
  assert(hasWorkerRow('sess_peer_timer'), 'terminal timer must preserve a peer row with the same tag');
231
270
 
232
- // Fully reaped tags have no in-memory map or persisted row. Explicit
233
- // cold-spawn identity is enough to create a replacement.
271
+ // Fully reaped tags retain a terminal-scoped tombstone, so send inherits the
272
+ // prior agent/cwd without the caller having to reconstruct cold identity.
234
273
  const coldStart = await agent.execute({
235
274
  type: 'spawn', agent: 'worker', tag: 'cold-reaped', prompt: 'cold reap seed', cwd: root,
236
275
  }, ctx);
237
276
  await waitJob(coldStart, /ack: cold reap seed/, 'cold reap seed');
238
277
  const coldSessionId = (await waitSessionForTag('cold-reaped', 'cold reap seed')).id;
239
- await sleep(1200);
278
+ await sleep(1700);
240
279
  assert(!sessionForTag('cold-reaped') && getSession(coldSessionId)?.closed, 'terminal reaper must remove the cold-reaped session');
241
280
  const coldSendOut = await agent.execute({
242
- type: 'send', agent: 'worker', tag: 'cold-reaped', message: 'cold reap replacement', cwd: root,
281
+ type: 'send', tag: 'cold-reaped', message: 'cold reap replacement',
243
282
  }, ctx);
244
283
  assert(!/^Error:/.test(coldSendOut), `fully reaped tag must cold-respawn: ${coldSendOut}`);
245
284
  assert(/respawned: true/.test(coldSendOut), `fully reaped send must flag respawned: ${coldSendOut}`);
246
285
  await waitJob(coldSendOut, /ack: cold reap replacement/, 'cold reap replacement');
247
286
 
287
+ // Explicit spawn consumes the same tombstone form and also reports that the
288
+ // previous conversational context was lost.
289
+ const coldSpawnSeed = await agent.execute({
290
+ type: 'spawn', agent: 'reviewer', tag: 'cold-spawn-reaped', prompt: 'cold spawn seed', cwd: root,
291
+ }, ctx);
292
+ await waitJob(coldSpawnSeed, /ack: cold spawn seed/, 'cold spawn seed');
293
+ const coldSpawnSessionId = (await waitSessionForTag('cold-spawn-reaped', 'cold spawn seed')).id;
294
+ await sleep(1700);
295
+ assert(getSession(coldSpawnSessionId)?.closed, 'terminal reaper must close the cold-spawn seed');
296
+ const coldSpawnOut = await agent.execute({
297
+ type: 'spawn', tag: 'cold-spawn-reaped', prompt: 'cold spawn replacement',
298
+ }, ctx);
299
+ assert(!/^Error:/.test(coldSpawnOut), `tombstoned tag spawn must respawn: ${coldSpawnOut}`);
300
+ assert(/respawned: true/.test(coldSpawnOut), `tombstoned tag spawn must flag respawned: ${coldSpawnOut}`);
301
+ await waitJob(coldSpawnOut, /ack: cold spawn replacement/, 'cold spawn replacement');
302
+
303
+ // A stale running row with neither a live session nor a recent heartbeat is
304
+ // transitioned to a tombstone instead of blocking this tag forever.
305
+ addWorkerRow({
306
+ tag: 'stale-running',
307
+ sessionId: 'sess_stale_running',
308
+ status: 'running',
309
+ updatedAt: new Date(Date.now() - 120_000).toISOString(),
310
+ });
311
+ const staleRunningOut = await agent.execute({
312
+ type: 'spawn', tag: 'stale-running', prompt: 'stale running replacement',
313
+ }, ctx);
314
+ assert(!/^Error:/.test(staleRunningOut), `stale nonterminal row must transition: ${staleRunningOut}`);
315
+ assert(/respawned: true/.test(staleRunningOut), `stale nonterminal transition must flag respawned: ${staleRunningOut}`);
316
+ await waitJob(staleRunningOut, /ack: stale running replacement/, 'stale running replacement');
317
+
248
318
  // A raw session id, typo without retained evidence/identity, and a peer tag
249
319
  // must never turn into a local cold spawn.
250
320
  const rawSessionOut = await agent.execute({
@@ -264,6 +334,47 @@ async function main() {
264
334
  }, ctx);
265
335
  assert(/^Error:/.test(peerOut), `peer-terminal tag must remain an error: ${peerOut}`);
266
336
 
337
+ // Let the peer session become a peer-owned tombstone. Neither normal nor
338
+ // allTerminals dispatch may inherit that terminal identity.
339
+ await sleep(1700);
340
+ assert(tombstonesForTag('peer-owned').some((row) => row.clientHostPid === peerCtx.clientHostPid), 'peer-owned tombstone must exist');
341
+ const peerTombstoneSend = await agent.execute({
342
+ type: 'send', tag: 'peer-owned', message: 'must not absorb peer tombstone', allTerminals: true,
343
+ }, ctx);
344
+ assert(/^Error:/.test(peerTombstoneSend), `allTerminals send must reject peer tombstone: ${peerTombstoneSend}`);
345
+ const peerTombstoneSpawn = await agent.execute({
346
+ type: 'spawn', tag: 'peer-owned', prompt: 'must not inherit peer spawn', allTerminals: true,
347
+ }, ctx);
348
+ assert(/^Error:/.test(peerTombstoneSpawn), `allTerminals spawn must reject peer tombstone inheritance: ${peerTombstoneSpawn}`);
349
+
350
+ // Local evidence wins when a peer tombstone with the same tag coexists.
351
+ addTombstones([
352
+ { tag: 'coowned', agent: 'reviewer', cwd: root, clientHostPid: ctx.clientHostPid, reapedAt: new Date().toISOString() },
353
+ { tag: 'coowned', agent: 'worker', cwd: root, clientHostPid: peerCtx.clientHostPid, reapedAt: new Date().toISOString() },
354
+ ]);
355
+ const coownedOut = await agent.execute({
356
+ type: 'send', tag: 'coowned', message: 'prefer local tombstone', allTerminals: true,
357
+ }, ctx);
358
+ assert(!/^Error:/.test(coownedOut), `local tombstone must beat coexisting peer: ${coownedOut}`);
359
+ assert(/respawned: true/.test(coownedOut), `coexisting local tombstone must respawn: ${coownedOut}`);
360
+ await waitJob(coownedOut, /ack: prefer local tombstone/, 'coowned local tombstone');
361
+
362
+ // Fill the cap with future-dated entries, then let a real reap write one more.
363
+ // The current write must survive clamping/pruning.
364
+ const pruneSeed = await agent.execute({
365
+ type: 'spawn', agent: 'worker', tag: 'prune-current', prompt: 'prune seed', cwd: root,
366
+ }, ctx);
367
+ await waitJob(pruneSeed, /ack: prune seed/, 'prune seed');
368
+ addTombstones(Array.from({ length: 500 }, (_, i) => ({
369
+ tag: `future-${i}`,
370
+ agent: 'worker',
371
+ cwd: root,
372
+ clientHostPid: ctx.clientHostPid,
373
+ reapedAt: new Date(Date.now() + 86_400_000 + i).toISOString(),
374
+ })));
375
+ await sleep(1700);
376
+ assert(tombstonesForTag('prune-current').length === 1, 'prune cap must retain the tombstone written this call');
377
+
267
378
  // agent close clears the trace; same tag can spawn fresh again
268
379
  addPeerTerminalRow('reviewerA', 'sess_peer_close');
269
380
  await agent.execute({ type: 'close', tag: 'reviewerA' }, ctx);
@@ -306,7 +417,10 @@ async function main() {
306
417
  removeWorkerRow(staleSessionId);
307
418
  addPeerTerminalRow('fallback-peer', 'sess_peer_fallback');
308
419
  const fallbackClose = await agent.execute({ type: 'close', tag: 'fallback-peer' }, ctx);
309
- assert(/forgotten: true/.test(fallbackClose), `stale close fallback must stay forgotten:true: ${fallbackClose}`);
420
+ assert(
421
+ /forgotten: true|target "fallback-peer" not found/.test(fallbackClose),
422
+ `stale close fallback must forget locally or report no local target: ${fallbackClose}`,
423
+ );
310
424
  assert(hasWorkerRow('sess_peer_fallback'), 'stale close fallback must preserve a peer terminal row with the same tag');
311
425
  removeWorkerRow('sess_peer_fallback');
312
426
  const fallbackFresh = await agent.execute({
@@ -0,0 +1,90 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+
4
+ import {
5
+ brightGreen,
6
+ refreshColorSupport,
7
+ rgb,
8
+ rgbBg,
9
+ rgbSgr,
10
+ rgbToAnsi256,
11
+ supportsTruecolor,
12
+ } from '../src/ui/ansi.mjs';
13
+ import {
14
+ remapCanonicalStatuslineTruecolor,
15
+ STATUSLINE_CANONICAL_TRUECOLOR,
16
+ } from '../src/tui/statusline-ansi-bridge.mjs';
17
+
18
+ const savedEnv = {
19
+ COLORTERM: process.env.COLORTERM,
20
+ FORCE_COLOR: process.env.FORCE_COLOR,
21
+ NO_COLOR: process.env.NO_COLOR,
22
+ TERM: process.env.TERM,
23
+ TERM_PROGRAM: process.env.TERM_PROGRAM,
24
+ WT_SESSION: process.env.WT_SESSION,
25
+ };
26
+
27
+ function restoreEnv() {
28
+ for (const [key, value] of Object.entries(savedEnv)) {
29
+ if (value === undefined) delete process.env[key];
30
+ else process.env[key] = value;
31
+ }
32
+ refreshColorSupport();
33
+ }
34
+
35
+ test.after(restoreEnv);
36
+
37
+ test('truecolor capability detection preserves known positives and defaults unknown to truecolor', () => {
38
+ assert.equal(supportsTruecolor({ TERM_PROGRAM: 'Apple_Terminal', COLORTERM: 'truecolor' }, 'darwin'), false);
39
+ assert.equal(supportsTruecolor({ COLORTERM: 'truecolor' }, 'darwin'), true);
40
+ assert.equal(supportsTruecolor({ COLORTERM: '24bit' }, 'linux'), true);
41
+ assert.equal(supportsTruecolor({ WT_SESSION: 'abc' }, 'linux'), true);
42
+ assert.equal(supportsTruecolor({ TERM_PROGRAM: 'iTerm.app' }, 'darwin'), true);
43
+ assert.equal(supportsTruecolor({ TERM_PROGRAM: 'WezTerm' }, 'darwin'), true);
44
+ assert.equal(supportsTruecolor({ TERM_PROGRAM: 'ghostty' }, 'darwin'), true);
45
+ assert.equal(supportsTruecolor({ TERM_PROGRAM: 'vscode' }, 'darwin'), true);
46
+ assert.equal(supportsTruecolor({ TERM: 'xterm-direct' }, 'linux'), true);
47
+ assert.equal(supportsTruecolor({}, 'win32'), true);
48
+ assert.equal(supportsTruecolor({}, 'linux'), true);
49
+ });
50
+
51
+ test('RGB helpers are byte-identical in truecolor mode and downsample foreground/background on Apple Terminal', () => {
52
+ process.env.FORCE_COLOR = '1';
53
+ delete process.env.NO_COLOR;
54
+ process.env.COLORTERM = 'truecolor';
55
+ delete process.env.TERM_PROGRAM;
56
+ refreshColorSupport();
57
+
58
+ assert.equal(rgb(215, 119, 87)('x'), '\x1b[38;2;215;119;87mx\x1b[0m');
59
+ assert.equal(rgbBg(55, 55, 55)('x'), '\x1b[48;2;55;55;55mx\x1b[0m');
60
+
61
+ process.env.TERM_PROGRAM = 'Apple_Terminal';
62
+ assert.equal(rgbSgr(215, 119, 87), '\x1b[38;5;173m');
63
+ assert.equal(rgb(215, 119, 87)('x'), '\x1b[38;5;173mx\x1b[0m');
64
+ assert.equal(rgbBg(55, 55, 55)('x'), '\x1b[48;5;237mx\x1b[0m');
65
+ assert.equal(brightGreen('x'), '\x1b[38;5;35mx\x1b[0m');
66
+ });
67
+
68
+ test('RGB to ANSI-256 conversion selects the nearest cube or grayscale entry', () => {
69
+ assert.equal(rgbToAnsi256(0, 170, 75), 35);
70
+ assert.equal(rgbToAnsi256(198, 198, 198), 251);
71
+ assert.equal(rgbToAnsi256(136, 136, 136), 102);
72
+ assert.equal(rgbToAnsi256(255, 255, 255), 231);
73
+ });
74
+
75
+ test('statusline theme remap recognizes canonical 256-color variants', () => {
76
+ const colors = {
77
+ STATUS: '<status>',
78
+ SUBTLE: '<subtle>',
79
+ SUCCESS: '<success>',
80
+ WARNING: '<warning>',
81
+ ERROR: '<error>',
82
+ };
83
+ const success256 = `\x1b[38;5;${rgbToAnsi256(...STATUSLINE_CANONICAL_TRUECOLOR.success)}m`;
84
+ const warning256 = `\x1b[38;5;${rgbToAnsi256(...STATUSLINE_CANONICAL_TRUECOLOR.warningBright)}m`;
85
+ const input = `${success256}ok ${warning256}warn`;
86
+ assert.equal(
87
+ remapCanonicalStatuslineTruecolor(input, colors),
88
+ '<success>ok <warning>warn',
89
+ );
90
+ });
@@ -25,6 +25,7 @@ const { AnthropicOAuthProvider } = await import(
25
25
  '../src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs'
26
26
  );
27
27
  const {
28
+ DEFAULT_CLI_VERSION,
28
29
  _saveCredentialsFile,
29
30
  loadCredentials,
30
31
  loadCredentialsFromPath,
@@ -48,6 +49,64 @@ function writeCredentials({ accessToken, refreshToken, expiresAt }, path = crede
48
49
  });
49
50
  }
50
51
 
52
+ test('refresh uses the current CLI identity and returns a neutral sanitized 403', async () => {
53
+ const accessToken = 'fixture-access-must-not-leak';
54
+ const refreshToken = 'fixture-refresh-must-not-leak';
55
+ writeCredentials({
56
+ accessToken,
57
+ refreshToken,
58
+ expiresAt: Date.now() + 1_000,
59
+ });
60
+ const before = readFileSync(credentialsPath, 'utf8');
61
+ const originalFetch = globalThis.fetch;
62
+ let request = null;
63
+ globalThis.fetch = async (url, options) => {
64
+ request = { url, options };
65
+ return {
66
+ ok: false,
67
+ status: 403,
68
+ async text() {
69
+ return JSON.stringify({
70
+ error: 'forbidden',
71
+ accessToken,
72
+ refresh_token: refreshToken,
73
+ echoed: `Bearer ${accessToken}`,
74
+ });
75
+ },
76
+ };
77
+ };
78
+ try {
79
+ assert.equal(DEFAULT_CLI_VERSION, '2.1.207');
80
+ await assert.rejects(
81
+ refreshOAuthCredentials(loadCredentials()),
82
+ (err) => {
83
+ assert.match(err.message, /token refresh 403/);
84
+ assert.match(err.message, /claude-cli\/2\.1\.207/);
85
+ assert.match(err.message, /Possible causes include OAuth client compatibility/);
86
+ assert.match(err.message, /account or scope policy/);
87
+ assert.match(err.message, /proxy\/VPN\/WAF/);
88
+ assert.match(err.message, /regional endpoint restrictions/);
89
+ assert.match(err.message, /Verify the account has Claude Code access and the required scopes/);
90
+ assert.match(err.message, /MIXDOG_CLI_VERSION/);
91
+ assert.match(err.message, /retry sign-in via \/providers/);
92
+ assert.doesNotMatch(err.message, /rejected the OAuth client identity/);
93
+ assert.doesNotMatch(err.message, /fixture-access-must-not-leak/);
94
+ assert.doesNotMatch(err.message, /fixture-refresh-must-not-leak/);
95
+ return true;
96
+ },
97
+ );
98
+ assert.equal(
99
+ request.options.headers['user-agent'],
100
+ 'claude-cli/2.1.207 (external, sdk-cli)',
101
+ );
102
+ assert.equal(request.options.redirect, 'error');
103
+ assert.equal(JSON.parse(request.options.body).refresh_token, refreshToken);
104
+ assert.equal(readFileSync(credentialsPath, 'utf8'), before);
105
+ } finally {
106
+ globalThis.fetch = originalFetch;
107
+ }
108
+ });
109
+
51
110
  test('parallel host preflights refresh once and snapshot the leased generation', async () => {
52
111
  writeCredentials({
53
112
  accessToken: 'fixture-access-old',
@@ -75,3 +75,19 @@ test('below-min (negative) still errors', () => {
75
75
  test('fractional numeric string is not coerced (still errors)', () => {
76
76
  assert.match(validateBuiltinArgs('read', { path: 'x.mjs', limit: '3.5' }), /must be an integer/);
77
77
  });
78
+
79
+ test('grep pattern and glob-filter numeric values coerce to strings', () => {
80
+ const pattern = { pattern: 123 };
81
+ assert.equal(validateBuiltinArgs('grep', pattern), null);
82
+ assert.equal(pattern.pattern, '123');
83
+
84
+ const patternArray = { pattern: [123, 'a'] };
85
+ assert.equal(validateBuiltinArgs('grep', patternArray), null);
86
+ assert.deepEqual(patternArray.pattern, ['123', 'a']);
87
+
88
+ const glob = { pattern: 'x', glob: 123 };
89
+ assert.equal(validateBuiltinArgs('grep', glob), null);
90
+ assert.equal(glob.glob, '123');
91
+
92
+ assert.match(validateBuiltinArgs('grep', { pattern: {} }), /must be string or string\[\]/);
93
+ });