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
@@ -516,6 +516,9 @@ export function createEngineApiA(bag) {
516
516
  syncContextStats({ allowEstimated: true });
517
517
  set({ ...routeState(), stats: { ...getState().stats } });
518
518
  if (result) {
519
+ if (!result.error && result.changed !== false) {
520
+ set({ items: replaceItems([]) });
521
+ }
519
522
  pushItem({
520
523
  kind: 'statusdone',
521
524
  id: nextId(),
@@ -2,13 +2,14 @@
2
2
  * src/tui/engine/session-flow.mjs - prompt queue drain + session clear/reset. Extracted from engine.mjs.
3
3
  */
4
4
  import { presentErrorText } from '../../runtime/shared/err-text.mjs';
5
+ import { resetAllStreamingMarkdownStablePrefixes } from '../markdown/streaming-markdown.mjs';
5
6
  import { createSessionStats } from './session-stats.mjs';
6
7
  import { queuePriorityValue, defaultQueuePriority, isQueuedEntryEditable, isQueuedEntryVisible, isSlashQueuedEntry, notificationDisplayText, sessionActivityTimestamp, promptDisplayText, mergePromptContents, mergePastedImages, mergePastedTexts, callCommitCallbacks, STEERING_SUPPRESSED_DISPLAY } from './queue-helpers.mjs';
7
8
  import { appendTuiSteeringPersist, dropTuiSteeringPersist, drainTuiSteeringPersist } from './tui-steering-persist.mjs';
8
9
 
9
10
  export function createSessionFlow(bag) {
10
11
  const {
11
- runtime, nextId, tuiDebug, flags, pending, pendingNotificationKeys, displayedExecutionNotificationKeys, clearExecutionDedupState, getState, set, pushItem, replaceItems, pushNotice, pushUserOrSyntheticItem, autoClearState, agentStatusState, routeState, syncContextStats, flushDeferredExecutionPendingResumeKick,
12
+ runtime, nextId, tuiDebug, flags, pending, pendingNotificationKeys, displayedExecutionNotificationKeys, clearExecutionDedupState, clearToastTimers, getState, set, pushItem, replaceItems, pushNotice, pushUserOrSyntheticItem, autoClearState, agentStatusState, routeState, syncContextStats, flushDeferredExecutionPendingResumeKick,
12
13
  } = bag;
13
14
 
14
15
  // Upper bound on the awaited compacting clear. requireCompactSuccess makes
@@ -382,7 +383,10 @@ export function createSessionFlow(bag) {
382
383
  // Shared clear body for idle auto-clear and the session_manage tool.
383
384
  // useCompaction=true mirrors auto-clear (summarize via configured
384
385
  // compactType, context carries forward); false is a plain /clear wipe.
385
- async function performSessionClear({ verb, doneLabel, skipLabel, surface, useCompaction }) {
386
+ async function performSessionClear({
387
+ verb, doneLabel, skipLabel, surface, useCompaction,
388
+ compactTimeoutMs = AUTO_CLEAR_COMPACT_TIMEOUT_MS,
389
+ }) {
386
390
  flags.autoClearRunning = true;
387
391
  const startedAt = Date.now();
388
392
  // commandBusy blocks concurrent session commands (resume/newSession/
@@ -401,6 +405,7 @@ export function createSessionFlow(bag) {
401
405
  const compaction = runtime.getCompactionSettings();
402
406
  compactType = compaction.compactType || compaction.type || null;
403
407
  }
408
+ let clearResult;
404
409
  if (compactType) {
405
410
  // Bounded watchdog around the compacting clear. On timeout we throw so
406
411
  // the catch below keeps the conversation, surfaces a user-visible
@@ -410,21 +415,22 @@ export function createSessionFlow(bag) {
410
415
  // new auto-clear attempts until the abandoned promise settles, and on
411
416
  // late fulfillment we run the same post-success UI sync as the normal
412
417
  // path so the UI cannot diverge from a runtime session that actually
413
- // got cleared. Late rejection is a no-op.
418
+ // got cleared. Late rejection or a false result is a no-op.
414
419
  const clearPromise = runtime.clear({ compactType, requireCompactSuccess: true });
415
420
  let timer = null;
416
421
  const timeout = new Promise((_, reject) => {
417
422
  timer = setTimeout(
418
- () => reject(new Error(`compaction timed out after ${AUTO_CLEAR_COMPACT_TIMEOUT_MS}ms; auto-clear deferred to next idle`)),
419
- AUTO_CLEAR_COMPACT_TIMEOUT_MS,
423
+ () => reject(new Error(`compaction timed out after ${compactTimeoutMs}ms; auto-clear deferred to next idle`)),
424
+ compactTimeoutMs,
420
425
  );
421
426
  });
422
427
  try {
423
- await Promise.race([clearPromise, timeout]);
428
+ clearResult = await Promise.race([clearPromise, timeout]);
424
429
  } catch (raceError) {
425
430
  flags.autoClearInFlight = true;
426
431
  clearPromise.then(
427
- () => {
432
+ (lateResult) => {
433
+ if (lateResult === false) return;
428
434
  if (getState().busy) {
429
435
  // A turn started after commandBusy released; applying the
430
436
  // cleared-session UI now would wipe items/queued and force
@@ -446,7 +452,10 @@ export function createSessionFlow(bag) {
446
452
  if (timer) clearTimeout(timer);
447
453
  }
448
454
  } else {
449
- await runtime.clear({});
455
+ clearResult = await runtime.clear({});
456
+ }
457
+ if (clearResult === false) {
458
+ throw new Error('runtime clear returned false');
450
459
  }
451
460
  applyClearedSessionUi(doneLabel);
452
461
  return true;
@@ -490,6 +499,8 @@ export function createSessionFlow(bag) {
490
499
  return getState().stats;
491
500
  };
492
501
  const clearUiActivityBeforeContextSync = () => {
502
+ clearToastTimers();
503
+ resetAllStreamingMarkdownStablePrefixes();
493
504
  getState().items = replaceItems([]);
494
505
  getState().toasts = [];
495
506
  getState().queued = [];
@@ -12,7 +12,7 @@ import { aggregateRawResult, aggregateBucketForCategory, aggregateSummaries, ass
12
12
 
13
13
  export function createRunTurn(bag) {
14
14
  const {
15
- runtime, nextId, tuiDebug, LEAD_TURN_TIMEOUT_MS, flags, pending, itemIndexById, getState, set, pushItem, patchItem, updateStreamingTail: updateStreamingTailFromStore, settleStreamingTail: settleStreamingTailFromStore, clearStreamingTail: clearStreamingTailFromStore, pushNotice, pushUserOrSyntheticItem, markToolCallActive, markToolCallDone, clearActiveToolSummary, agentStatusState, routeState, syncContextStats, denyAllToolApprovals, requestToolApproval, patchToolCardResult, flushToolResults, flushDeferredExecutionPendingResumeKick, drain, drainPendingSteering,
15
+ runtime, nextId, tuiDebug, LEAD_TURN_TIMEOUT_MS, flags, pending, itemIndexById, getState, set, pushItem, patchItem, replaceItems, updateStreamingTail: updateStreamingTailFromStore, settleStreamingTail: settleStreamingTailFromStore, clearStreamingTail: clearStreamingTailFromStore, pushNotice, pushUserOrSyntheticItem, markToolCallActive, markToolCallDone, clearActiveToolSummary, agentStatusState, routeState, syncContextStats, denyAllToolApprovals, requestToolApproval, patchToolCardResult, flushToolResults, flushDeferredExecutionPendingResumeKick, drain, drainPendingSteering,
16
16
  } = bag;
17
17
  // Small fallbacks keep isolated createRunTurn harnesses source-compatible;
18
18
  // the real engine supplies atomic implementations that also maintain revision.
@@ -209,7 +209,14 @@ export function createRunTurn(bag) {
209
209
  let cancelled = false;
210
210
  let askResult = null;
211
211
  let turnFinishedNormally = false;
212
+ let transcriptCompactedThisTurn = false;
212
213
  const itemsAtTurnStart = getState().items.length;
214
+ const submittedIdSet = new Set(submittedIds.filter((id) => id != null));
215
+ const firstSubmittedIndex = getState().items.findIndex((item) => submittedIdSet.has(item?.id));
216
+ // The submitted user row is pushed immediately before runTurn. Keep it and
217
+ // everything produced after it if compaction succeeds mid-turn; dropping all
218
+ // items would invalidate live tool-card ids and the streaming assistant tail.
219
+ let currentTurnItemsStart = firstSubmittedIndex >= 0 ? firstSubmittedIndex : itemsAtTurnStart;
213
220
  const cardByCallId = new Map();
214
221
  const toolCards = [];
215
222
  const toolGroups = new Map();
@@ -987,6 +994,13 @@ export function createRunTurn(bag) {
987
994
  // later same-category tool call doesn't reuse a card whose count
988
995
  // would then change above this statusdone item.
989
996
  clearAggregateContinuation();
997
+ const compactStatus = String(event?.status || '').toLowerCase();
998
+ if (!['failed', 'skipped', 'no_change'].includes(compactStatus)) {
999
+ const currentTurnItems = getState().items.slice(currentTurnItemsStart);
1000
+ set({ items: replaceItems(currentTurnItems, { preserveStreamingTail: true }) });
1001
+ currentTurnItemsStart = 0;
1002
+ transcriptCompactedThisTurn = true;
1003
+ }
990
1004
  pushItem({
991
1005
  kind: 'statusdone',
992
1006
  id: nextId(),
@@ -1103,6 +1117,13 @@ export function createRunTurn(bag) {
1103
1117
  } else {
1104
1118
  askResult = result;
1105
1119
  markPromptCommitted();
1120
+ if (result?.terminationReason === 'refusal') {
1121
+ pushNotice(
1122
+ 'The model refused to respond (safety refusal) — retry or rephrase your prompt.',
1123
+ 'warn',
1124
+ { transcript: true },
1125
+ );
1126
+ }
1106
1127
 
1107
1128
  flushToolResults(session?.messages || [], toolCards, cardByCallId, toolGroups, resultsDone, { finalize: true });
1108
1129
  finalizeToolHeaders();
@@ -1208,7 +1229,8 @@ export function createRunTurn(bag) {
1208
1229
  }
1209
1230
  }
1210
1231
  const producedTranscriptItem =
1211
- getState().items.length + closingItems.length > itemsAtTurnStart;
1232
+ transcriptCompactedThisTurn
1233
+ || getState().items.length + closingItems.length > itemsAtTurnStart;
1212
1234
  const reclaimed = cancelled && flags.activePromptRestore?.reclaimed === true;
1213
1235
  flags.activePromptRestore = null;
1214
1236
  const elapsedMs = Date.now() - startedAt;
@@ -738,7 +738,6 @@ export async function createEngineSession({
738
738
  autoClearState, agentStatusState, baseRouteState, routeState, syncContextStats,
739
739
  presentNextToolApproval, finishToolApproval, denyAllToolApprovals, requestToolApproval,
740
740
  patchToolCardResult, flushToolResults,
741
- clearExecutionDedupState,
742
741
  kickExecutionPendingResume, flushDeferredExecutionPendingResumeKick, scheduleExecutionPendingResumeKick, discardExecutionPendingResume, updateAgentJobCard, subscribeRuntimeNotifications,
743
742
  });
744
743
  Object.assign(bag, createSessionFlow(bag));
package/src/tui/index.jsx CHANGED
@@ -15,6 +15,7 @@ import { App } from './App.jsx';
15
15
  import { createEngineSession } from './engine.mjs';
16
16
  import { scheduleRenderFrameAck } from './engine/render-timing.mjs';
17
17
  import { installProcessSignalCleanup } from '../runtime/shared/process-shutdown.mjs';
18
+ import { rgbSgr } from '../ui/ansi.mjs';
18
19
  import { emitTerminalBackground, loadThemeSettingFromConfig, theme } from './theme.mjs';
19
20
  import { POP_KITTY, DISABLE_MODIFY_OTHER_KEYS } from './keyboard-protocol.mjs';
20
21
  import { displayWidth } from './display-width.mjs';
@@ -259,10 +260,10 @@ function resolveTuiStderrLogPath() {
259
260
  || join(process.env.MIXDOG_RUNTIME_ROOT || join(tmpdir(), 'mixdog'), 'mixdog-tui.stderr.log');
260
261
  }
261
262
 
262
- /** `rgb(r,g,b)` → truecolor SGR prefix ('' when unparsable). */
263
+ /** `rgb(r,g,b)` → supported RGB SGR prefix ('' when unparsable). */
263
264
  function ansiFg(rgb) {
264
265
  const m = /rgb\((\d+),\s*(\d+),\s*(\d+)\)/.exec(String(rgb || ''));
265
- return m ? `\x1b[38;2;${m[1]};${m[2]};${m[3]}m` : '';
266
+ return m ? rgbSgr(m[1], m[2], m[3]) : '';
266
267
  }
267
268
 
268
269
  /**
@@ -7,6 +7,8 @@ import { configureMarked, hasMarkdownSyntax } from './render-ansi.mjs';
7
7
  import { trimPartialClosingFences, findOpenFenceStart } from './stream-fence.mjs';
8
8
 
9
9
  const stablePrefixByStreamKey = new Map();
10
+ // Reuse the current normalized-text split across measure → render → harvest.
11
+ const resolvedPartsByStreamKey = new Map();
10
12
  const STABLE_PREFIX_LRU_MAX = 32;
11
13
 
12
14
  /** Lockstep with App streaming row estimate (leading/trailing newline trim). */
@@ -37,6 +39,25 @@ function getStablePrefixKey(key) {
37
39
  return value;
38
40
  }
39
41
 
42
+ function getResolvedPartsKey(key, text) {
43
+ if (!key) return null;
44
+ const entry = resolvedPartsByStreamKey.get(key);
45
+ if (!entry || entry.text !== text) return null;
46
+ return entry.parts;
47
+ }
48
+
49
+ function cacheResolvedPartsKey(key, text, parts) {
50
+ if (!key) return parts;
51
+ if (resolvedPartsByStreamKey.has(key)) resolvedPartsByStreamKey.delete(key);
52
+ resolvedPartsByStreamKey.set(key, { text, parts });
53
+ while (resolvedPartsByStreamKey.size > STABLE_PREFIX_LRU_MAX) {
54
+ const oldest = resolvedPartsByStreamKey.keys().next().value;
55
+ if (oldest === undefined) break;
56
+ resolvedPartsByStreamKey.delete(oldest);
57
+ }
58
+ return parts;
59
+ }
60
+
40
61
  function hasOpenFence(text) {
41
62
  let ticks = 0;
42
63
  let tildes = 0;
@@ -93,35 +114,40 @@ export function balanceStreamingMarkdown(text) {
93
114
 
94
115
  export function resetStreamingMarkdownStablePrefix(streamKey) {
95
116
  if (streamKey == null || streamKey === '') return;
96
- stablePrefixByStreamKey.delete(String(streamKey));
117
+ const key = String(streamKey);
118
+ stablePrefixByStreamKey.delete(key);
119
+ resolvedPartsByStreamKey.delete(key);
97
120
  }
98
121
 
99
122
  export function resetAllStreamingMarkdownStablePrefixes() {
100
123
  stablePrefixByStreamKey.clear();
124
+ resolvedPartsByStreamKey.clear();
101
125
  }
102
126
 
103
127
  export function resolveStreamingMarkdownParts(text, streamKey) {
104
128
  const t = streamingLayoutText(text);
105
129
  const key = streamKey == null || streamKey === '' ? null : String(streamKey);
130
+ const cachedParts = getResolvedPartsKey(key, t);
131
+ if (cachedParts) return cachedParts;
106
132
 
107
133
  if (!t) {
108
134
  if (key) stablePrefixByStreamKey.delete(key);
109
- return {
135
+ return cacheResolvedPartsKey(key, t, {
110
136
  plain: true,
111
137
  stablePrefix: '',
112
138
  unstableSuffix: '',
113
139
  unstableForRender: '',
114
- };
140
+ });
115
141
  }
116
142
 
117
143
  if (!hasMarkdownSyntax(t)) {
118
144
  if (key) stablePrefixByStreamKey.delete(key);
119
- return {
145
+ return cacheResolvedPartsKey(key, t, {
120
146
  plain: true,
121
147
  stablePrefix: '',
122
148
  unstableSuffix: t,
123
149
  unstableForRender: t,
124
- };
150
+ });
125
151
  }
126
152
 
127
153
  let stablePrefix = key ? getStablePrefixKey(key) : '';
@@ -141,13 +167,13 @@ export function resolveStreamingMarkdownParts(text, streamKey) {
141
167
  if (key && openPrefix) touchStablePrefixKey(key, openPrefix);
142
168
  else if (key) stablePrefixByStreamKey.delete(key);
143
169
  const unstableSuffix = t.substring(openPrefix.length);
144
- return {
170
+ return cacheResolvedPartsKey(key, t, {
145
171
  plain: false,
146
172
  openFence: true,
147
173
  stablePrefix: openPrefix,
148
174
  unstableSuffix,
149
175
  unstableForRender: unstableSuffix,
150
- };
176
+ });
151
177
  }
152
178
 
153
179
  try {
@@ -179,10 +205,10 @@ export function resolveStreamingMarkdownParts(text, streamKey) {
179
205
  if (isWhitespaceOnlyText(stablePrefix)) stablePrefix = '';
180
206
 
181
207
  const unstableSuffix = t.substring(stablePrefix.length);
182
- return {
208
+ return cacheResolvedPartsKey(key, t, {
183
209
  plain: false,
184
210
  stablePrefix,
185
211
  unstableSuffix,
186
212
  unstableForRender: balanceStreamingMarkdown(unstableSuffix),
187
- };
213
+ });
188
214
  }
@@ -6,6 +6,8 @@
6
6
  * normalizes that at display time so `/theme` can re-tone without touching ui/.
7
7
  */
8
8
 
9
+ import { rgbToAnsi256 } from '../ui/ansi.mjs';
10
+
9
11
  const RESET = '\x1b[0m';
10
12
 
11
13
  /** Truecolor SGR sequences emitted by the shared statusline stack (not theme). */
@@ -23,6 +25,14 @@ function truecolorSgr(r, g, b) {
23
25
  return `\x1b[38;2;${r};${g};${b}m`;
24
26
  }
25
27
 
28
+ function ansi256Sgr(r, g, b) {
29
+ return `\x1b[38;5;${rgbToAnsi256(r, g, b)}m`;
30
+ }
31
+
32
+ function canonicalSgrVariants(rgb) {
33
+ return [truecolorSgr(...rgb), ansi256Sgr(...rgb)];
34
+ }
35
+
26
36
  function footerLocalNum(value) {
27
37
  const n = Number(value);
28
38
  return Number.isFinite(n) ? n : 0;
@@ -139,13 +149,13 @@ export function applyDefaultStatusForegroundAfterReset(text, STATUS, reset = RES
139
149
  export function remapCanonicalStatuslineTruecolor(text, colors) {
140
150
  const c = STATUSLINE_CANONICAL_TRUECOLOR;
141
151
  const pairs = [
142
- [truecolorSgr(...c.statusText), colors.STATUS],
143
- [truecolorSgr(...c.subtle), colors.SUBTLE],
144
- [truecolorSgr(...c.success), colors.SUCCESS],
145
- [truecolorSgr(...c.successBright), colors.SUCCESS],
146
- [truecolorSgr(...c.warning), colors.WARNING],
147
- [truecolorSgr(...c.warningBright), colors.WARNING],
148
- [truecolorSgr(...c.error), colors.ERROR],
152
+ ...canonicalSgrVariants(c.statusText).map((from) => [from, colors.STATUS]),
153
+ ...canonicalSgrVariants(c.subtle).map((from) => [from, colors.SUBTLE]),
154
+ ...canonicalSgrVariants(c.success).map((from) => [from, colors.SUCCESS]),
155
+ ...canonicalSgrVariants(c.successBright).map((from) => [from, colors.SUCCESS]),
156
+ ...canonicalSgrVariants(c.warning).map((from) => [from, colors.WARNING]),
157
+ ...canonicalSgrVariants(c.warningBright).map((from) => [from, colors.WARNING]),
158
+ ...canonicalSgrVariants(c.error).map((from) => [from, colors.ERROR]),
149
159
  ];
150
160
  let out = String(text || '');
151
161
  for (const [from, to] of pairs) {
package/src/ui/ansi.mjs CHANGED
@@ -12,7 +12,7 @@
12
12
  * The decision is computed once at import time but can be recomputed via
13
13
  * `refreshColorSupport()` (used by tests / when output is rebound).
14
14
  */
15
- import { stdout, env } from 'node:process';
15
+ import { stdout, env, platform } from 'node:process';
16
16
 
17
17
  let COLOR_ENABLED = computeColorEnabled();
18
18
 
@@ -39,12 +39,92 @@ export function colorEnabled() {
39
39
  const ESC = '\x1b[';
40
40
  const RESET = `${ESC}0m`;
41
41
 
42
+ /**
43
+ * Whether the terminal can render 24-bit SGR colors.
44
+ *
45
+ * Apple Terminal is the one explicit negative because it advertises a normal
46
+ * xterm TERM while not implementing truecolor. Unknown terminals retain the
47
+ * historical truecolor default.
48
+ */
49
+ export function supportsTruecolor(environment = env, platformName = platform) {
50
+ const termProgram = String(environment?.TERM_PROGRAM || '').trim().toLowerCase();
51
+ if (termProgram === 'apple_terminal') return false;
52
+
53
+ const colorTerm = String(environment?.COLORTERM || '').trim().toLowerCase();
54
+ if (colorTerm === 'truecolor' || colorTerm === '24bit') return true;
55
+ if (environment?.WT_SESSION !== undefined && environment.WT_SESSION !== '') return true;
56
+ if (['iterm.app', 'wezterm', 'ghostty', 'vscode'].includes(termProgram)) return true;
57
+ if (/(?:direct|truecolor)/i.test(String(environment?.TERM || ''))) return true;
58
+ if (platformName === 'win32') return true;
59
+ return true;
60
+ }
61
+
62
+ const ANSI_256_CUBE_LEVELS = Object.freeze([0, 95, 135, 175, 215, 255]);
63
+
64
+ function colorByte(value) {
65
+ const n = Number(value);
66
+ return Number.isFinite(n) ? Math.max(0, Math.min(255, Math.round(n))) : 0;
67
+ }
68
+
69
+ function nearestCubeLevel(value) {
70
+ let best = 0;
71
+ let bestDistance = Infinity;
72
+ for (let i = 0; i < ANSI_256_CUBE_LEVELS.length; i++) {
73
+ const distance = Math.abs(value - ANSI_256_CUBE_LEVELS[i]);
74
+ if (distance < bestDistance) {
75
+ best = i;
76
+ bestDistance = distance;
77
+ }
78
+ }
79
+ return best;
80
+ }
81
+
82
+ /** Convert an RGB triplet to the nearest stable xterm 256-color palette index. */
83
+ export function rgbToAnsi256(r, g, b) {
84
+ const red = colorByte(r);
85
+ const green = colorByte(g);
86
+ const blue = colorByte(b);
87
+
88
+ const redLevel = nearestCubeLevel(red);
89
+ const greenLevel = nearestCubeLevel(green);
90
+ const blueLevel = nearestCubeLevel(blue);
91
+ const cubeRed = ANSI_256_CUBE_LEVELS[redLevel];
92
+ const cubeGreen = ANSI_256_CUBE_LEVELS[greenLevel];
93
+ const cubeBlue = ANSI_256_CUBE_LEVELS[blueLevel];
94
+ const cubeDistance = ((red - cubeRed) ** 2)
95
+ + ((green - cubeGreen) ** 2)
96
+ + ((blue - cubeBlue) ** 2);
97
+
98
+ const average = (red + green + blue) / 3;
99
+ const grayLevel = Math.max(0, Math.min(23, Math.round((average - 8) / 10)));
100
+ const grayValue = 8 + (grayLevel * 10);
101
+ const grayDistance = ((red - grayValue) ** 2)
102
+ + ((green - grayValue) ** 2)
103
+ + ((blue - grayValue) ** 2);
104
+
105
+ if (grayDistance < cubeDistance) return 232 + grayLevel;
106
+ return 16 + (36 * redLevel) + (6 * greenLevel) + blueLevel;
107
+ }
108
+
109
+ /** Raw RGB SGR prefix, downsampled to 256 colors when truecolor is unavailable. */
110
+ export function rgbSgr(r, g, b, background = false) {
111
+ const channel = background ? 48 : 38;
112
+ if (supportsTruecolor()) return `${ESC}${channel};2;${r};${g};${b}m`;
113
+ return `${ESC}${channel};5;${rgbToAnsi256(r, g, b)}m`;
114
+ }
115
+
116
+ function supportedSgrOpen(open) {
117
+ const match = /^(38|48);2;([^;]+);([^;]+);([^;]+)$/.exec(String(open));
118
+ if (!match || supportsTruecolor()) return open;
119
+ return `${match[1]};5;${rgbToAnsi256(match[2], match[3], match[4])}`;
120
+ }
121
+
42
122
  /** Build a `(text) => string` wrapper for the given SGR open code(s). */
43
123
  function sgr(open) {
44
- const openSeq = `${ESC}${open}m`;
45
124
  return (text) => {
46
125
  if (!COLOR_ENABLED) return String(text ?? '');
47
126
  const s = String(text ?? '');
127
+ const openSeq = `${ESC}${supportedSgrOpen(open)}m`;
48
128
  // Re-open after any nested reset so composed styles survive concatenation.
49
129
  return openSeq + s.replace(/\x1b\[0m/g, RESET + openSeq) + RESET;
50
130
  };
@@ -84,9 +164,9 @@ export const bgGray = sgr('100');
84
164
  export const bgBlack = sgr('40');
85
165
  export const bgBlue = sgr('44');
86
166
 
87
- // --- Truecolor (24-bit) -----------------------------------------------------
88
- // The TUI palette uses explicit rgb() values. 16-color SGR can't reproduce them, so we emit
89
- // 24-bit SGR. Honors NO_COLOR / TTY exactly like the named helpers above.
167
+ // --- RGB colors --------------------------------------------------------------
168
+ // Emit 24-bit SGR where supported, otherwise use the nearest 256-color entry.
169
+ // Honors NO_COLOR / TTY exactly like the named helpers above.
90
170
 
91
171
  /** Foreground truecolor wrapper: `rgb(215,119,87)('x')`. */
92
172
  export function rgb(r, g, b) {
@@ -14,14 +14,6 @@ import { num, GRN, R, B } from './statusline-format.mjs';
14
14
  const DEFAULT_HIDDEN_STATUSLINE_AGENTS = Object.freeze(['explorer', 'cycle1-agent', 'cycle2-agent', 'cycle3-agent', 'scheduler-task', 'webhook-handler']);
15
15
  let _hiddenStatuslineAgents = null;
16
16
 
17
- export function summarizeWorkerTags(workers, limit = 3) {
18
- const cleanLabels = [...new Set((Array.isArray(workers) ? workers : [])
19
- .map((worker) => String(worker?.tag || '').trim())
20
- .filter(Boolean))];
21
- if (cleanLabels.length <= limit) return cleanLabels.join(', ');
22
- return `${cleanLabels.slice(0, limit).join(', ')}, +${cleanLabels.length - limit}`;
23
- }
24
-
25
17
  function normalizeAgentWorkerForStatusline(worker = {}) {
26
18
  const tag = String(worker.tag || worker.agent || worker.name || '').trim();
27
19
  if (!tag) return null;
@@ -5,7 +5,7 @@
5
5
  * bar/segment formatters, small numeric helpers, and the TUI-independent
6
6
  * formatElapsed() replica. No behavior change — statusline.mjs re-imports these.
7
7
  */
8
- import { colorEnabled, rgb } from './ansi.mjs';
8
+ import { colorEnabled, rgb, rgbSgr } from './ansi.mjs';
9
9
  import { displayModelName, shortenModelName } from './model-display.mjs';
10
10
  import { getModelMetadataSync } from '../runtime/agent/orchestrator/providers/model-catalog.mjs';
11
11
 
@@ -23,12 +23,12 @@ function sgr(code) {
23
23
 
24
24
  export const R = sgr('0');
25
25
  export const B = sgr('1');
26
- export const D = sgr('38;2;136;136;136');
27
- export const GRN = sgr('38;2;0;170;75');
28
- export const YLW = sgr('38;2;255;193;7');
29
- export const RED = sgr('38;2;220;70;88');
30
- export const CYN = sgr('38;2;136;136;136');
31
- export const GREY = sgr('38;2;136;136;136');
26
+ export const D = colorEnabled() ? rgbSgr(136, 136, 136) : '';
27
+ export const GRN = colorEnabled() ? rgbSgr(0, 170, 75) : '';
28
+ export const YLW = colorEnabled() ? rgbSgr(255, 193, 7) : '';
29
+ export const RED = colorEnabled() ? rgbSgr(220, 70, 88) : '';
30
+ export const CYN = colorEnabled() ? rgbSgr(136, 136, 136) : '';
31
+ export const GREY = colorEnabled() ? rgbSgr(136, 136, 136) : '';
32
32
 
33
33
  export function terminalColumns() {
34
34
  const cols = Number(process.stdout?.columns);
@@ -23,7 +23,7 @@ import {
23
23
  } from './statusline-format.mjs';
24
24
  import { shellJobsStatus, memoryCycleStatus } from './statusline-segments.mjs';
25
25
  import {
26
- summarizeWorkerTags, agentStatuslinePayload, classifyAgentWorkers, activeHiddenAgentWorkers, agentWebSearchStatus,
26
+ agentStatuslinePayload, classifyAgentWorkers, activeHiddenAgentWorkers, agentWebSearchStatus,
27
27
  } from './statusline-agents.mjs';
28
28
  export { createSessionStats, applyUsageDelta } from './session-stats.mjs';
29
29
  // Facade re-exports: keep these public symbols resolving from statusline.mjs.
@@ -362,14 +362,12 @@ function renderNativeStatusline({
362
362
  if (runningWorkers.length) {
363
363
  const n = runningWorkers.length;
364
364
  const label = `Running ${n} Agent${n === 1 ? '' : 's'}`;
365
- const tagSummary = summarizeWorkerTags(runningWorkers);
366
- const tags = tagSummary ? ` ${D}(${R}${B}${tagSummary}${R}${D})${R}` : '';
367
365
  const oldestStart = runningWorkers.reduce((min, w) => {
368
366
  const t = num(w?.startedAtMs);
369
367
  return t > 0 && t < min ? t : min;
370
368
  }, Infinity);
371
369
  const elapsed = Number.isFinite(oldestStart) ? formatElapsed(Date.now() - oldestStart) : '';
372
- addL2(`${spin} ${B}${label}${R}${tags}${elapsedSuffix(elapsed)}`);
370
+ addL2(`${spin} ${B}${label}${R}${elapsedSuffix(elapsed)}`);
373
371
  }
374
372
  const tools = activeTools && typeof activeTools === 'object' ? activeTools : {};
375
373
  const exploreInfo = tools.explore || null;
@@ -13,15 +13,20 @@ Approval is a later explicit user message after the latest plan ("do it",
13
13
  approval with a scope change needs a revised plan and fresh approval. Before
14
14
  approval: no edits, state mutation, or delegation.
15
15
 
16
- Lead orchestrates and verifies. Lead-direct work is allowed only for pure
17
- read/analysis, git/configuration, or when the user explicitly supplies both the
18
- exact target and exact replacement/output. Never infer an exemption from a task
19
- name, file count, or perceived difficulty. Every other implementation, reverse
20
- engineering, debugging application, or artifact generation delegates to the
21
- matching agent. Debugger owns diagnosis and reverse engineering; Worker applies
22
- an established bounded change or fully specified artifact; Heavy Worker owns
23
- implementation that must establish the change through investigation or staged
24
- delivery. Applying a Debugger result is implementation, not diagnosis.
16
+ Lead owns three duties, in order: plan set the direction of the work from its own
17
+ understanding of the task; delegate hand execution to the matching agent; verify —
18
+ judge what comes back. Understanding is never delegated: without its own plan a Lead
19
+ can neither brief nor judge. Lead-direct work is allowed only for pure read/analysis,
20
+ git/configuration, or when the user explicitly supplies both the exact target and exact
21
+ replacement/output. Never infer an exemption from a task name, file count, or
22
+ perceived difficulty. Every other implementation, reverse engineering, debugging
23
+ application, or artifact generation delegates to the matching agent. Debugger is an
24
+ escalation role reserved for explicitly requested debugging or a bug surviving 2+ fix
25
+ cycles — not a default owner of analysis or reverse engineering, which route to
26
+ Worker/Heavy Worker like any implementation. Worker applies an established bounded
27
+ change or fully specified artifact; Heavy Worker owns implementation that must establish
28
+ the change through investigation or staged delivery. Applying a Debugger result is
29
+ implementation, not diagnosis.
25
30
 
26
31
  1. Plan: draft before any implementation; settle scope/plan, ask if ambiguous,
27
32
  then await the gate.
@@ -29,22 +34,26 @@ delivery. Applying a Debugger result is implementation, not diagnosis.
29
34
  one turn, with no count cap. Serialize only a real dependency, overlapping
30
35
  write, or inseparable coupling. Briefs follow the Lead brief contract.
31
36
  After async spawn, end the turn.
32
- 3. Review: review is exempt only for pure read/analysis with no edit, artifact,
33
- or state mutation; git/configuration; or a request where the user explicitly
34
- supplies both the exact target and exact replacement/output. Every
35
- non-exempt mutation or artifact, whether produced or applied by Worker,
36
- Heavy Worker, Debugger, or Lead, gets one Reviewer (all ready reviewers in
37
- one turn) and Lead
38
- integration/cross-scope verification in parallel. Debugger analysis cannot
39
- substitute for implementation review: applying a Debugger result triggers
37
+ 3. Review: triage by the work performed, never by the size, form, or destination
38
+ of its result a one-line answer can carry non-trivial work, and a
39
+ conversational reply is not automatically exempt. Trivial work — a routine
40
+ lookup answered directly, routine git/configuration confirmed by its own
41
+ mechanical check, a change whose correctness is test/build/diff-obvious, or
42
+ applying an exact target and replacement the user supplied — ships on shell
43
+ self-verification alone, and only after that check actually ran and passed.
44
+ Non-trivial work multi-step reasoning, interpretation, investigation, or
45
+ anything whose correctness is not verifiable at a glance — gets a Reviewer
46
+ cross-check (one reviewer per scope, kept across the fix loop; all ready
47
+ reviewers spawn in one turn) and Lead integration/cross-scope verification
48
+ in parallel, whoever performed it, Lead solo included. Debugger analysis
49
+ cannot substitute for implementation review: applying a Debugger result triggers
40
50
  the same Reviewer + Lead verification. Reviewer independently judges risk,
41
51
  intent, boundaries; Lead checks acceptance/interactions, not duplicate
42
52
  same-scope work. High-risk scopes add distinct lenses. Synthesize one
43
53
  verdict; send merged fixes to the original live session; loop fix ->
44
54
  re-verify (same Reviewer + Lead re-check) until clean. Debugger first for
45
- requested debugging or a bug surviving 2+ fix cycles. Exempt mutations still
46
- require shell self-verification before report. Agent reports relay scope,
47
- verdict, next work as in-progress, never conclusions.
55
+ requested debugging or a bug surviving 2+ fix cycles. Agent reports relay
56
+ scope, verdict, next work as in-progress, never conclusions.
48
57
  4. Report: final (not interim) report compares work to approved plan and gives
49
58
  verified result; never forward raw agent output. Ask about ship/deploy when
50
59
  relevant. Build/deploy/commit/push require an explicit user request after
@@ -0,0 +1,47 @@
1
+ ---
2
+ id: solo-review
3
+ name: Solo Review
4
+ description: "Lead implements directly; eligible low-risk single scopes receive one independent Reviewer."
5
+ agents: reviewer
6
+ ---
7
+
8
+ # Solo Review
9
+
10
+ GATE: Before approval, only read-only investigation/planning while consulting.
11
+ Approval is a later explicit user message after the latest plan ("do it",
12
+ "proceed", "go ahead"). Initial/additional/changed requests reset planning;
13
+ approval with a scope change needs a revised plan and fresh approval. Before
14
+ approval: no edits, state mutation, or delegation.
15
+
16
+ 1. Plan: before implementation, Lead classifies the scope as eligible
17
+ low-risk/single-scope or ineligible high-risk/multi-scope, drafts the plan,
18
+ settles scope, asks if ambiguous, then awaits the gate. Ineligible work must
19
+ switch to Default workflow with a revised Default plan and fresh approval
20
+ before implementation. After that switch, Solo Review constraints are
21
+ suspended: Default reviewer-per-scope fan-out and high-risk lenses override
22
+ Solo Review's single-Reviewer and concurrency limits.
23
+ 2. Execute: after approval, Lead performs all implementation and verification
24
+ directly. Never delegate implementation, investigation, debugging, or
25
+ maintenance to implementation helper roles (Worker, Heavy Worker, Explorer,
26
+ Debugger, or Maintainer). Complete in-scope fixes without reapproval; interim
27
+ updates are in-progress, never conclusions.
28
+ 3. Review: only an eligible low-risk, single-scope final deliverable, once
29
+ implemented and Lead-verified, receives exactly one Reviewer to critically
30
+ and independently evaluate the approved intent, complete deliverable,
31
+ affected boundaries, and verification. Lead evaluates the findings, applies
32
+ every necessary fix directly, and re-verifies. Send the revised deliverable
33
+ back to that same live Reviewer for every re-check. If that session is
34
+ unavailable, one cold replacement Reviewer may re-review the original intent,
35
+ current deliverable, and verification evidence; never run replacement or
36
+ additional Reviewers concurrently. Repeat fix -> Lead verification -> Reviewer
37
+ re-check until issue-free or blocked.
38
+ 4. Report: only after the review loop is clean, the final (not interim) report
39
+ compares the result to the approved plan and gives verification and material
40
+ remaining risk/next step; never forward raw Reviewer output. Ask about
41
+ ship/deploy when relevant. Verification builds/tests needed to evaluate the
42
+ deliverable are permitted during execution and review. Release builds,
43
+ deploy/commit/push require an explicit user request after issue-free feedback;
44
+ implementation approval alone is insufficient.
45
+
46
+ On outcome/direction change, pause and re-consult; otherwise continue approved
47
+ work without reapproval.