mixdog 0.9.36 → 0.9.37

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 (103) hide show
  1. package/package.json +3 -2
  2. package/scripts/abort-recovery-test.mjs +118 -0
  3. package/scripts/compact-smoke.mjs +7 -7
  4. package/scripts/explore-bench-tmp.mjs +19 -0
  5. package/scripts/explore-bench.mjs +36 -6
  6. package/scripts/explore-prompt-policy-test.mjs +27 -0
  7. package/scripts/ingest-pure-conversation-smoke.mjs +11 -11
  8. package/scripts/openai-ws-early-settle-test.mjs +176 -0
  9. package/scripts/output-style-smoke.mjs +17 -17
  10. package/scripts/provider-toolcall-test.mjs +333 -14
  11. package/scripts/recall-bench-cases.json +3 -3
  12. package/scripts/recall-bench.mjs +1 -1
  13. package/scripts/recall-quality-cases.json +4 -4
  14. package/scripts/recall-usecase-cases.json +2 -2
  15. package/scripts/session-bench.mjs +13 -13
  16. package/scripts/steering-drain-buckets-test.mjs +72 -11
  17. package/scripts/submit-commandbusy-race-test.mjs +114 -0
  18. package/scripts/tool-smoke.mjs +83 -10
  19. package/src/app.mjs +2 -1
  20. package/src/defaults/cycle3-review-prompt.md +9 -0
  21. package/src/defaults/skills/setup/SKILL.md +93 -293
  22. package/src/lib/rules-builder.cjs +3 -2
  23. package/src/output-styles/default.md +2 -2
  24. package/src/output-styles/extreme-minimal.md +1 -1
  25. package/src/output-styles/minimal.md +1 -1
  26. package/src/output-styles/simple.md +2 -3
  27. package/src/rules/agent/30-explorer.md +15 -7
  28. package/src/rules/lead/01-general.md +4 -0
  29. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +15 -3
  30. package/src/runtime/agent/orchestrator/config.mjs +1 -1
  31. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +6 -6
  32. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +6 -6
  33. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +35 -5
  34. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +27 -0
  35. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +36 -37
  36. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +2 -2
  37. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +72 -1
  38. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +26 -3
  39. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +12 -28
  40. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +18 -15
  41. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +4 -2
  42. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +113 -2
  43. package/src/runtime/agent/orchestrator/providers/registry.mjs +44 -5
  44. package/src/runtime/agent/orchestrator/providers/tool-stream-state.mjs +78 -0
  45. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +57 -31
  46. package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +8 -6
  47. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +28 -2
  48. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +1 -3
  49. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +15 -2
  50. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +26 -17
  51. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +2 -2
  52. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +24 -16
  53. package/src/runtime/agent/orchestrator/session/result-classification.mjs +2 -0
  54. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +5 -2
  55. package/src/runtime/agent/orchestrator/stall-policy.mjs +18 -0
  56. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +35 -20
  57. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -7
  58. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +77 -31
  59. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +25 -3
  60. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +9 -2
  61. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +73 -25
  62. package/src/runtime/agent/orchestrator/tools/builtin.mjs +2 -1
  63. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +17 -7
  64. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -12
  65. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +5 -0
  66. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +56 -6
  67. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -0
  68. package/src/runtime/channels/lib/worker-main.mjs +5 -0
  69. package/src/runtime/memory/lib/core-memory-store.mjs +104 -0
  70. package/src/runtime/memory/lib/ko-morph.mjs +1 -1
  71. package/src/runtime/memory/lib/memory-cycle3.mjs +64 -4
  72. package/src/runtime/memory/lib/memory-text-utils.mjs +4 -4
  73. package/src/runtime/memory/lib/recall-format.mjs +3 -3
  74. package/src/runtime/shared/child-spawn-gate.mjs +15 -0
  75. package/src/runtime/shared/config.mjs +98 -12
  76. package/src/runtime/shared/process-listener-headroom.mjs +12 -0
  77. package/src/session-runtime/cwd-plugins.mjs +6 -3
  78. package/src/session-runtime/model-route-api.mjs +50 -2
  79. package/src/session-runtime/provider-auth-api.mjs +8 -1
  80. package/src/session-runtime/resource-api.mjs +22 -0
  81. package/src/session-runtime/runtime-core.mjs +13 -2
  82. package/src/session-runtime/settings-api.mjs +11 -2
  83. package/src/standalone/agent-tool.mjs +15 -9
  84. package/src/standalone/explore-tool.mjs +4 -1
  85. package/src/tui/App.jsx +6 -5
  86. package/src/tui/app/transcript-window.mjs +60 -10
  87. package/src/tui/app/use-transcript-window.mjs +2 -1
  88. package/src/tui/components/ContextPanel.jsx +4 -1
  89. package/src/tui/components/ToolExecution.jsx +15 -12
  90. package/src/tui/components/TranscriptItem.jsx +1 -1
  91. package/src/tui/components/tool-execution/surface-detail.mjs +8 -8
  92. package/src/tui/dist/index.mjs +467 -151
  93. package/src/tui/engine/agent-job-feed.mjs +26 -6
  94. package/src/tui/engine/notification-plan.mjs +3 -3
  95. package/src/tui/engine/render-timing.mjs +104 -8
  96. package/src/tui/engine/session-api.mjs +58 -3
  97. package/src/tui/engine/session-flow.mjs +78 -28
  98. package/src/tui/engine/tool-card-results.mjs +28 -13
  99. package/src/tui/engine/turn.mjs +232 -156
  100. package/src/tui/engine.mjs +17 -8
  101. package/src/tui/index.jsx +10 -1
  102. package/src/workflows/default/WORKFLOW.md +8 -4
  103. package/src/workflows/solo/WORKFLOW.md +8 -4
@@ -0,0 +1,12 @@
1
+ // Many independent singletons self-register process-level drains (exit,
2
+ // beforeExit, SIGTERM, …). Standalone scripts that import runtime modules
3
+ // directly bypass src/app.mjs, so raise the cap from a shared helper too.
4
+ export function ensureProcessListenerHeadroom(min = 64) {
5
+ try {
6
+ if (typeof process.getMaxListeners !== 'function' || typeof process.setMaxListeners !== 'function') return;
7
+ const current = process.getMaxListeners();
8
+ if (current === 0 || current >= min) return;
9
+ process.setMaxListeners(min);
10
+ } catch { /* ignore */ }
11
+ }
12
+
@@ -234,9 +234,12 @@ export function createCwdPlugins({
234
234
  }
235
235
 
236
236
  async function loadCoreMemoryContext() {
237
- // Boot should not pay for memory/PG startup unless explicitly requested.
238
- // Recall and memory tools still initialize the memory service on first use.
239
- if (process.env.MIXDOG_BOOT_CORE_MEMORY !== '1') {
237
+ // User-curated core memory injects into new sessions by default.
238
+ // Explicit opt-out (MIXDOG_BOOT_CORE_MEMORY=0/false/no/off) skips the
239
+ // memory/PG startup cost; recall and memory tools still initialize the
240
+ // memory service on first use.
241
+ const bootFlag = String(process.env.MIXDOG_BOOT_CORE_MEMORY ?? '').trim().toLowerCase();
242
+ if (bootFlag === '0' || bootFlag === 'false' || bootFlag === 'no' || bootFlag === 'off') {
240
243
  bootProfile('core-memory:skipped');
241
244
  return '';
242
245
  }
@@ -17,6 +17,21 @@ import {
17
17
  SEARCH_DEFAULT_MODEL,
18
18
  } from './workflow.mjs';
19
19
  import { writeStatuslineRoute } from './statusline-route.mjs';
20
+ import { SUMMARY_PREFIX } from '../runtime/agent/orchestrator/session/compact.mjs';
21
+ import {
22
+ hasUserConversationMessage,
23
+ } from '../runtime/agent/orchestrator/session/manager/prompt-utils.mjs';
24
+
25
+ function isSummaryAnchorMessage(message) {
26
+ return message?.role === 'user'
27
+ && typeof message.content === 'string'
28
+ && message.content.startsWith(SUMMARY_PREFIX);
29
+ }
30
+
31
+ function hasRouteHistoryMessage(messages) {
32
+ const list = Array.isArray(messages) ? messages : [];
33
+ return hasUserConversationMessage(list) || list.some(isSummaryAnchorMessage);
34
+ }
20
35
 
21
36
  // Model/route/search-route selection + mutation surface. Extracted verbatim from
22
37
  // the runtime API object; stateless helpers are imported directly and the
@@ -33,6 +48,8 @@ export function createModelRouteApi(deps) {
33
48
  persistLeadRoute, refreshRouteEffort,
34
49
  refreshStatuslineUsageSnapshot, scheduleStatuslineUsageRefresh,
35
50
  invalidateContextStatusCache, invalidateProviderCaches,
51
+ createCurrentSession, invalidatePreSessionToolSurface,
52
+ pushTranscriptRebind,
36
53
  collectSearchProviderModels,
37
54
  } = deps;
38
55
  return {
@@ -125,10 +142,41 @@ export function createModelRouteApi(deps) {
125
142
  await refreshRouteEffort(modelMeta);
126
143
  refreshStatuslineUsageSnapshot(getRoute());
127
144
  scheduleStatuslineUsageRefresh();
128
- if (!applyToCurrentSession) {
145
+ const session = getSession();
146
+ // Model/provider changes are next-session-only for a session the user
147
+ // has already talked in or compacted (provider-keyed prompt cache). But
148
+ // an EMPTY current session — no committed route history and no in-flight
149
+ // first-turn prompt — has no cache to protect, so /model before the first
150
+ // chat takes effect live: route + statusline update immediately.
151
+ const currentSessionEmpty = !!session
152
+ && !hasRouteHistoryMessage(session.messages)
153
+ && !hasRouteHistoryMessage(session.liveTurnMessages);
154
+ const applyLive = applyToCurrentSession || currentSessionEmpty;
155
+ if (!applyLive) {
156
+ return getRoute();
157
+ }
158
+ if (currentSessionEmpty && session?.id && typeof createCurrentSession === 'function') {
159
+ // If the boot create is still finishing SessionStart/deferred-surface
160
+ // work, drain that promise first. Otherwise createCurrentSession()
161
+ // would return the old in-flight promise after we tombstone/null the
162
+ // session, racing the intended rebuild for the new provider.
163
+ await createCurrentSession('model-switch-empty-drain');
164
+ const emptySession = getSession();
165
+ if (!emptySession?.id
166
+ || hasRouteHistoryMessage(emptySession.messages)
167
+ || hasRouteHistoryMessage(emptySession.liveTurnMessages)) {
168
+ invalidateContextStatusCache();
169
+ return getRoute();
170
+ }
171
+ statusRoutes?.clearGatewaySessionRoute?.(emptySession.id);
172
+ mgr.closeSession?.(emptySession.id, 'cli-model-switch-empty', { tombstone: true });
173
+ setSession(null);
174
+ invalidatePreSessionToolSurface?.();
175
+ await createCurrentSession('model-switch-empty');
176
+ pushTranscriptRebind?.();
177
+ invalidateContextStatusCache();
129
178
  return getRoute();
130
179
  }
131
- const session = getSession();
132
180
  if (session) {
133
181
  const route = getRoute();
134
182
  const updated = mgr.updateSessionRoute?.(session.id, {
@@ -31,7 +31,14 @@ export function createProviderAuthApi({
31
31
  }) {
32
32
  function refreshProviderCatalogsSoon() {
33
33
  if (typeof refreshProviderCatalogs !== 'function') return;
34
- try { void Promise.resolve(refreshProviderCatalogs()).catch(() => {}); } catch { /* best-effort */ }
34
+ try {
35
+ void Promise.resolve(refreshProviderCatalogs())
36
+ .then(() => {
37
+ invalidateProviderCaches();
38
+ warmProviderModelCache();
39
+ })
40
+ .catch(() => {});
41
+ } catch { /* best-effort */ }
35
42
  }
36
43
 
37
44
  return {
@@ -80,6 +80,24 @@ export function createResourceApi(deps) {
80
80
  }
81
81
  return chain.running;
82
82
  }
83
+ function configuredProfileIdentityLine() {
84
+ try {
85
+ const config = getConfig();
86
+ const stored = config?.profile ?? config?.agent?.profile;
87
+ const profile = cfgMod.normalizeProfileConfig(stored);
88
+ const title = clean(profile?.title);
89
+ if (!title) return '';
90
+ return `[profile] Current configured user name/identity: ${title}. This profile value is authoritative; ignore stale memory rows that say the user's identity is unknown.`;
91
+ } catch {
92
+ return '';
93
+ }
94
+ }
95
+ function isIdentityRecallQuery(query) {
96
+ const q = clean(query).toLowerCase().replace(/\s+/g, '');
97
+ if (!q) return false;
98
+ return /(?:\uB0B4\uAC00|\uB098\uB294|\uB098|\uC0AC\uC6A9\uC790|\uC720\uC800|user|my|me).*(?:\uB204\uAD6C|\uB204\uAD70|\uC815\uCCB4|\uC774\uB984|name|identity)|(?:whoami|whoami\?|whoami?)|who(?:am)?i|whoami/.test(q)
99
+ || /^(?:\uB098\uB204\uAD6C\uB0D0|\uB098\uB294\uB204\uAD6C\uB0D0|\uB0B4\uAC00\uB204\uAD6C\uB0D0|\uB0B4\uC774\uB984\uBB50|\uB0B4\uC774\uB984\uBB50\uC57C|whoami)$/i.test(q);
100
+ }
83
101
  return {
84
102
  mcpStatus() {
85
103
  return mcpStatus();
@@ -300,6 +318,10 @@ export function createResourceApi(deps) {
300
318
  const session = getSession();
301
319
  const currentCwd = getCurrentCwd();
302
320
  const baseQuery = query || args?.query || '';
321
+ if (isIdentityRecallQuery(baseQuery)) {
322
+ const profileLine = configuredProfileIdentityLine();
323
+ if (profileLine) return profileLine;
324
+ }
303
325
  if (args?.currentSession !== false && session?.id) {
304
326
  const currentText = currentSessionRecallRows(session, baseQuery, { limit: args?.limit });
305
327
  if (!isEmptyRecallText(currentText)) return currentText;
@@ -713,7 +713,7 @@ export async function createMixdogSessionRuntime({
713
713
  // available / installs on quit" nag while the background stage runs
714
714
  // silently. The wording lives in the notice surface (notification-plan):
715
715
  // this emit only carries meta.version. TUI maps meta.kind 'update-notice'
716
- // to a transcript notice, never a model-visible message; tone 'info' =
716
+ // to a transient notice, never a model-visible message; tone 'info' =
717
717
  // non-urgent, applies on the next launch.
718
718
  const announceReady = () => {
719
719
  emitRuntimeNotification('update ready', { kind: 'update-notice', version: ver, tone: 'info' });
@@ -1228,7 +1228,15 @@ export async function createMixdogSessionRuntime({
1228
1228
  if (!startupProviderCatalogRefreshStarted && !closeRequested) {
1229
1229
  startupProviderCatalogRefreshStarted = true;
1230
1230
  try {
1231
- reg.refreshProviderCatalogsOnStartup();
1231
+ void Promise.resolve(reg.refreshProviderCatalogsOnStartup())
1232
+ .then(() => {
1233
+ invalidateProviderCaches();
1234
+ warmProviderModelCache();
1235
+ bootProfile('provider-catalogs:refresh-ready');
1236
+ })
1237
+ .catch((error) => {
1238
+ bootProfile('provider-catalogs:refresh-failed', { error: error?.message || String(error) });
1239
+ });
1232
1240
  bootProfile('provider-catalogs:refresh-started');
1233
1241
  } catch (error) {
1234
1242
  bootProfile('provider-catalogs:refresh-failed', { error: error?.message || String(error) });
@@ -2102,6 +2110,9 @@ export async function createMixdogSessionRuntime({
2102
2110
  scheduleStatuslineUsageRefresh,
2103
2111
  invalidateContextStatusCache,
2104
2112
  invalidateProviderCaches,
2113
+ createCurrentSession,
2114
+ invalidatePreSessionToolSurface,
2115
+ pushTranscriptRebind,
2105
2116
  collectSearchProviderModels,
2106
2117
  });
2107
2118
  const workflowAgentsApi = createWorkflowAgentsApi({
@@ -122,7 +122,12 @@ export function createSettingsApi({
122
122
  // language catalog entry and the full language list for the picker UI.
123
123
  getProfile() {
124
124
  const config = getConfig();
125
- const profile = cfgMod.normalizeProfileConfig(config.profile);
125
+ // In-memory config is flat: `config.profile` is what the save path
126
+ // (buildAgentSaveBuilder) persists into the on-disk `agent.profile`
127
+ // slot. Fall back to a nested `agent.profile` only for any stray
128
+ // nested snapshot.
129
+ const stored = config?.profile ?? config?.agent?.profile;
130
+ const profile = cfgMod.normalizeProfileConfig(stored);
126
131
  return {
127
132
  ...profile,
128
133
  languageEntry: cfgMod.profileLanguageEntry(profile.language),
@@ -152,7 +157,7 @@ export function createSettingsApi({
152
157
  },
153
158
  setProfile(input = {}) {
154
159
  const config = getConfig();
155
- const current = cfgMod.normalizeProfileConfig(config.profile);
160
+ const current = cfgMod.normalizeProfileConfig(config?.profile ?? config?.agent?.profile);
156
161
  const next = { ...current };
157
162
  if (hasOwn(input, 'title') || hasOwn(input, 'name')) {
158
163
  next.title = input.title ?? input.name ?? '';
@@ -161,6 +166,10 @@ export function createSettingsApi({
161
166
  next.language = input.language ?? input.lang ?? 'system';
162
167
  }
163
168
  const normalized = cfgMod.normalizeProfileConfig(next);
169
+ // Persist flat: buildAgentSaveBuilder (config.mjs saveConfig) reads
170
+ // `config.profile` and writes it into the on-disk `agent.profile`
171
+ // section, which the prompt builder (readAgentConfig) reads. Writing a
172
+ // nested `agent.profile` here would be dropped by the save path.
164
173
  saveConfigAndAdopt({ ...config, profile: normalized });
165
174
  return this.getProfile();
166
175
  },
@@ -14,6 +14,7 @@ import {
14
14
  import { presentErrorText, errorLine } from '../runtime/shared/err-text.mjs';
15
15
  import { updateJsonAtomicSync } from '../runtime/shared/atomic-file.mjs';
16
16
  import { normalizeAgentPermission } from '../runtime/shared/markdown-frontmatter.mjs';
17
+ import { ensureProcessListenerHeadroom } from '../runtime/shared/process-listener-headroom.mjs';
17
18
  import { prepareAgentSession } from '../runtime/agent/orchestrator/agent-runtime/session-builder.mjs';
18
19
  import {
19
20
  abortAgentProgressWatchdog,
@@ -63,6 +64,8 @@ import { createNotify } from './agent-tool/notify.mjs';
63
64
  // identical public surface (`import { AGENT_TOOL } from './agent-tool.mjs'`).
64
65
  export { AGENT_TOOL };
65
66
 
67
+ ensureProcessListenerHeadroom(64);
68
+
66
69
  const STANDALONE_SOURCE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
67
70
 
68
71
  // Grace window during which a terminated/idle worker row is kept around so the
@@ -98,15 +101,18 @@ const DEFAULT_SPAWN_PREP_TIMEOUT_MS = envTimeoutMs('MIXDOG_AGENT_SPAWN_PREP_TIME
98
101
 
99
102
  // Global spawn-start stagger: unlimited-N parallel fan-out otherwise fires all
100
103
  // first provider calls in the same instant, racing the server-side prompt-
101
- // cache write/propagation window. Bench (parallel-10, identical-ms starts):
102
- // 12.5% cache miss; 3000ms lane stagger: 4.3%; 166ms: 6.0%. 1000ms default
103
- // picked as a low-cost middle ground. Chain (not a fixed lane count) so it
104
- // scales to any N: each new spawn's start is pushed to at least STAGGER_MS
105
- // after the previous spawn's start; sequential/non-overlapping spawns (now
106
- // already past the window) pay zero added latency. MIXDOG_SPAWN_STAGGER_MS=0
107
- // disables. Applied inside the deferred job body (see startDeferredSpawnJob)
108
- // so the agent tool call itself still returns task_id immediately.
109
- const SPAWN_STAGGER_MS = envTimeoutMs('MIXDOG_SPAWN_STAGGER_MS', 1000);
104
+ // cache write/propagation window. Default 0 (off): mirrors the explore fan-out
105
+ // finding the first spawn's prompt-cache write only lands after its iter1
106
+ // completes (~seconds), so a sub-second stagger yields ~no cross-spawn cache
107
+ // reads while charging every later spawn the full delay, i.e. pure fan-out
108
+ // latency for negligible hit-rate gain. Kept as a knob for tuning: set
109
+ // MIXDOG_SPAWN_STAGGER_MS>0 to re-enable. When >0 it chains (not a fixed lane
110
+ // count) so it scales to any N: each new spawn's start is pushed to at least
111
+ // STAGGER_MS after the previous spawn's start; sequential/non-overlapping
112
+ // spawns pay zero added latency. Applied inside the deferred job body (see
113
+ // startDeferredSpawnJob) so the agent tool call itself still returns task_id
114
+ // immediately.
115
+ const SPAWN_STAGGER_MS = envTimeoutMs('MIXDOG_SPAWN_STAGGER_MS', 0);
110
116
  let lastSpawnStartAt = 0;
111
117
  async function waitForSpawnStagger() {
112
118
  if (SPAWN_STAGGER_MS <= 0) return;
@@ -5,6 +5,9 @@ import { makeAgentDispatch } from '../runtime/agent/orchestrator/agent-runtime/a
5
5
  import { loadConfig } from '../runtime/agent/orchestrator/config.mjs';
6
6
  import { initProviders } from '../runtime/agent/orchestrator/providers/registry.mjs';
7
7
  import { presentErrorText } from '../runtime/shared/err-text.mjs';
8
+ import { ensureProcessListenerHeadroom } from '../runtime/shared/process-listener-headroom.mjs';
9
+
10
+ ensureProcessListenerHeadroom(64);
8
11
 
9
12
  // Ported from the original mixdog tool-defs.mjs `explore` entry.
10
13
  // `aiWrapped` is dropped: in the standalone build there is no aiWrapped
@@ -89,7 +92,7 @@ function escapeXml(str) {
89
92
  // reminder. The full no-verdict contract lives at system level
90
93
  // (rules/agent/30-explorer.md).
91
94
  export function buildExplorerPrompt(query) {
92
- return `<query>${escapeXml(query)}</query>\nReminder: RULE ZERO is a binary gate after every tool result: >=1 path:line matching a SPECIFIC query token (product/library/domain name) -> answer NOW with those exact coordinates; zero -> one more batch if budget remains. A generic-word-only match (schema, handler, config, resolver, index, error...) while the query's specific tokens match nowhere counts as ZERO, not a hit. There is no third branch: "hits exist but I want better ones" IS branch one — answer now. A code_graph symbol hit (find_symbol/symbol_search returning path:line) IS an anchor — emit it directly, never re-locate it with grep. Credibility is mechanical (specific-token match), never judged: "is this the real implementation / final handler / just a wrapper" are caller questions, FORBIDDEN here; mark weak anchors ? and answer anyway. Flow/how/trigger queries: first matching definition/entry anchors ARE the complete answer — never trace the chain, one anchor per concept. You locate WHERE, never WHY. Scope is ALWAYS the session working directory: omit path or pass only a path seen in an earlier result; inventing a directory (/workspace/..., another repo's layout) is a defect — on zero hits change TOKENS, never guess paths. HARD max 3 tool turns; counter line (turn 1/3, turn 2/3, turn 3/3) on every tool message; expected shape is turn 1 -> answer, turns 2-3 are miss-recovery only (previous turn had ZERO matching lines), with changed tokens; after turn 3/3 you MUST answer best-so-far. Each turn = ONE maximal batch: a single grep whose pattern[] packs ALL token variants PLUS code_graph/find/glob in the SAME message; a single-tool turn is a wasted turn. Before emitting EXPLORATION_FAILED re-scan ALL earlier results: any line matching a specific query token -> answer with that anchor (? if weak) — a weak anchor beats a false miss; EXPLORATION_FAILED only when all 3 turns found zero token-matching lines. Output only anchor lines formatted as path:line — symbol/name — short reason, max 3 lines, or EXPLORATION_FAILED. No preamble, bullets, numbering, headings, summary, code quotes, analysis, verdicts, ratings, or recommendations.`;
95
+ return `<query>${escapeXml(query)}</query>\nReminder: RULE ZERO is a binary gate after every tool result: >=1 path:line matching a SPECIFIC query token (product/library/domain name) -> STOP and answer NOW with those exact coordinates, this IS your final turn; zero -> one more batch if budget remains. Turns 2-3 exist SOLELY as zero-hit recovery (the previous turn matched ZERO specific tokens); spending a turn to confirm, refine, or upgrade an anchor you already hold is a defect. A generic-word-only match (schema, handler, config, resolver, index, error...) while the query's specific tokens match nowhere counts as ZERO, not a hit. There is no third branch: "hits exist but I want better ones" IS branch one — answer now. A code_graph symbol hit (find_symbol/symbol_search returning path:line) IS an anchor — emit it directly, never re-locate it with grep. Credibility is mechanical (specific-token match), never judged: "is this the real implementation / final handler / just a wrapper" are caller questions, FORBIDDEN here; mark weak anchors ? and answer anyway. Flow/how/trigger queries: first matching definition/entry anchors ARE the complete answer — never trace the chain, one anchor per concept. You locate WHERE, never WHY. Scope is ALWAYS the session working directory: omit path or pass only a path seen in an earlier result; inventing a directory (/workspace/..., another repo's layout) is a defect — on zero hits change TOKENS, never guess paths. HARD max 3 tool turns; counter line (turn 1/3, turn 2/3, turn 3/3) on every tool message; expected shape is turn 1 -> answer, turns 2-3 are miss-recovery only (previous turn had ZERO matching lines), with changed tokens; after turn 3/3 you MUST answer best-so-far. Each turn = ONE maximal batch: a single grep whose pattern[] packs ALL token variants PLUS code_graph/find/glob in the SAME message; a single-tool turn is a wasted turn. Before emitting EXPLORATION_FAILED re-scan ALL earlier results: any line matching a specific query token -> answer with that anchor (? if weak) — a weak anchor beats a false miss; EXPLORATION_FAILED only when all 3 turns found zero token-matching lines. Output only anchor lines formatted as path:line — symbol/name — short reason, max 3 lines, or EXPLORATION_FAILED. No preamble, bullets, numbering, headings, summary, code quotes, analysis, verdicts, ratings, or recommendations.`;
93
96
  }
94
97
 
95
98
  export function normalizeExploreQueries(rawQuery) {
package/src/tui/App.jsx CHANGED
@@ -1494,7 +1494,8 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
1494
1494
  const n = Number(value || 0);
1495
1495
  const d = Number(total || 0);
1496
1496
  if (!d) return 'N/A';
1497
- return `${((n / d) * 100).toFixed(n > 0 && n < d / 100 ? 1 : 0)}%`;
1497
+ const p = Math.max(0, Math.min(100, (n / d) * 100));
1498
+ return `${p > 0 && p < 1 ? p.toFixed(1) : Math.floor(p)}%`;
1498
1499
  };
1499
1500
  const fmt = (value) => {
1500
1501
  const n = Number(value || 0);
@@ -2153,12 +2154,12 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
2153
2154
  }
2154
2155
  }
2155
2156
  if (!commandText) return false;
2156
- if (state.commandBusy) {
2157
- store.pushNotice('wait for the current command to finish', 'warn');
2158
- return false;
2159
- }
2160
2157
 
2161
2158
  if (commandText.startsWith('/')) {
2159
+ if (state.commandBusy) {
2160
+ store.pushNotice('wait for the current command to finish', 'warn');
2161
+ return false;
2162
+ }
2162
2163
  const [cmd, ...rest] = commandText.slice(1).split(/\s+/);
2163
2164
  const accepted = runSlashCommand(cmd, rest.join(' ').trim());
2164
2165
  if (accepted !== false) clearPastedImagesSnapshot();
@@ -537,6 +537,7 @@ function computeTranscriptItemVariantKey(item) {
537
537
  const count = Number(item.count ?? 0);
538
538
  const completed = item.completedCount === undefined ? 'u' : Number(item.completedCount);
539
539
  const errors = item.errorCount === undefined ? 'u' : Number(item.errorCount);
540
+ const callErrors = item.callErrorCount === undefined ? 'u' : Number(item.callErrorCount);
540
541
  const isError = item.isError ? 1 : 0;
541
542
  const normalizedName = String(normalizeToolName(item.name) || '').toLowerCase();
542
543
  const aggregate = item.aggregate ? 1 : 0;
@@ -547,7 +548,7 @@ function computeTranscriptItemVariantKey(item) {
547
548
  const bgPrompt = textShapeFingerprint(bgArgs.prompt);
548
549
  const bgMessage = textShapeFingerprint(bgArgs.message);
549
550
  const bgError = textShapeFingerprint(bgArgs.error);
550
- return `x${expanded}:n${normalizedName}:g${aggregate}:r${resultShape}:R${rawShape}:c${count}:d${completed}:e${errors}:E${isError}:bt${bgType}:bs${bgStatus}:bk${bgTaskId}:bp${bgPrompt}:bm${bgMessage}:be${bgError}`;
551
+ return `x${expanded}:n${normalizedName}:g${aggregate}:r${resultShape}:R${rawShape}:c${count}:d${completed}:e${errors}:ce${callErrors}:E${isError}:bt${bgType}:bs${bgStatus}:bk${bgTaskId}:bp${bgPrompt}:bm${bgMessage}:be${bgError}`;
551
552
  }
552
553
  return `x${expanded}:s${textShapeFingerprint(item.text ?? item.result ?? '')}`;
553
554
  }
@@ -568,13 +569,43 @@ export const transcriptMeasuredRowsCache = new WeakMap();
568
569
  // (its final settled height is then captured by the normal WeakMap path).
569
570
  export const streamingMeasuredRowsById = new Map();
570
571
 
572
+ // High-water clamp for the STREAMING row ESTIMATE, keyed by stream item id.
573
+ // measureStreamingMarkdownRenderedRows (measure-rendered-rows.mjs) adds a +1
574
+ // gap row only while childCount===2 (stablePrefix box + unstableSuffix box).
575
+ // As the stable/unstable split moves per token — and when the suffix is
576
+ // momentarily whitespace-only, stablePrefix empties — childCount flips 1↔2
577
+ // frame-to-frame, so the estimate oscillates ±1. Streaming text only ever
578
+ // GROWS, so any per-frame DECREASE is spurious. Hold the max estimate seen this
579
+ // streaming run so streamingEstimateRows is NON-DECREASING, killing the -1 dip
580
+ // that shifts the transcript when a newline settles. Entry stores columns/
581
+ // toolExpanded so a real layout-basis change resets the water line (row count
582
+ // legitimately changes with width). Lifecycle mirrors streamingMeasuredRowsById
583
+ // exactly: pruned by pruneStreamingMeasuredRowsById and cleared at the same
584
+ // settle / invalidate delete sites in estimateTranscriptItemRowsCached.
585
+ const streamingEstimateHighWaterById = new Map();
586
+
587
+ /** True when either streaming id-keyed store holds entries, so the mount-prune
588
+ * call site fires even when only the estimate high-water map is populated (an
589
+ * item that never got a Yoga measurement but reached an estimate high-water,
590
+ * then was bulk-replaced, must still be pruned). */
591
+ export function hasStreamingRowStateToPrune() {
592
+ return streamingMeasuredRowsById.size > 0 || streamingEstimateHighWaterById.size > 0;
593
+ }
594
+
571
595
  /** Drop streamingMeasuredRowsById entries for ids no longer mounted, so the
572
596
  * store does not grow unbounded over a long session (mirrors the id→item /
573
597
  * id→callback map pruning already done for the mounted set). */
574
598
  export function pruneStreamingMeasuredRowsById(liveIds) {
575
- if (!liveIds || streamingMeasuredRowsById.size === 0) return;
576
- for (const id of streamingMeasuredRowsById.keys()) {
577
- if (!liveIds.has(id)) streamingMeasuredRowsById.delete(id);
599
+ if (!liveIds) return;
600
+ if (streamingMeasuredRowsById.size > 0) {
601
+ for (const id of streamingMeasuredRowsById.keys()) {
602
+ if (!liveIds.has(id)) streamingMeasuredRowsById.delete(id);
603
+ }
604
+ }
605
+ if (streamingEstimateHighWaterById.size > 0) {
606
+ for (const id of streamingEstimateHighWaterById.keys()) {
607
+ if (!liveIds.has(id)) streamingEstimateHighWaterById.delete(id);
608
+ }
578
609
  }
579
610
  }
580
611
 
@@ -627,7 +658,21 @@ export function streamingEstimateRows(item, columns, toolOutputExpanded) {
627
658
  const trimmedText = assistantTextForStreamingRowEstimate(item.text);
628
659
  const estimateItem = trimmedText === item.text ? item : { ...item, text: trimmedText };
629
660
  const raw = Math.max(1, Math.ceil(estimateTranscriptItemRows(estimateItem, columns, toolOutputExpanded)));
630
- return Math.ceil(raw / STREAMING_ROW_QUANTUM) * STREAMING_ROW_QUANTUM;
661
+ const quantized = Math.ceil(raw / STREAMING_ROW_QUANTUM) * STREAMING_ROW_QUANTUM;
662
+ // High-water clamp: streaming text only grows, so never report fewer rows than
663
+ // this id already reached this run — absorbs the childCount 1↔2 gap flip that
664
+ // otherwise dips the estimate ±1 frame-to-frame. A columns/expanded change
665
+ // resets the water line (new width → legitimately new row count).
666
+ const id = item?.id;
667
+ if (id == null) return quantized;
668
+ const toolExpanded = toolOutputExpanded ? 1 : 0;
669
+ const prev = streamingEstimateHighWaterById.get(id);
670
+ if (!prev || prev.columns !== columns || prev.toolExpanded !== toolExpanded) {
671
+ streamingEstimateHighWaterById.set(id, { rows: quantized, columns, toolExpanded });
672
+ return quantized;
673
+ }
674
+ if (quantized > prev.rows) prev.rows = quantized;
675
+ return prev.rows;
631
676
  }
632
677
 
633
678
  // ── Same-frame streaming-tail growth compensation (scrolled-up only) ────────
@@ -705,15 +750,20 @@ export function estimateTranscriptItemRowsCached(item, columns, toolOutputExpand
705
750
  // estimate frame.
706
751
  return idEntry.rows;
707
752
  }
708
- if (idEntry) streamingMeasuredRowsById.delete(item.id);
753
+ if (idEntry) {
754
+ streamingMeasuredRowsById.delete(item.id);
755
+ streamingEstimateHighWaterById.delete(item.id);
756
+ }
709
757
  // No confirmed measurement yet for this id (item just started streaming):
710
758
  // nothing to defer against, so the first frame falls back to the estimate.
711
759
  return streamingEstimateRows(item, columns, toolOutputExpanded);
712
760
  }
713
- if (item.kind === 'assistant' && streamingMeasuredRowsById.has(item.id)) {
714
- // Item settled (no longer streaming): the id-keyed floor is no longer
715
- // relevant, the normal WeakMap-measured path now owns its height.
716
- streamingMeasuredRowsById.delete(item.id);
761
+ if (item.kind === 'assistant') {
762
+ // Item settled (no longer streaming): the id-keyed floor and the estimate
763
+ // high-water are no longer relevant the normal WeakMap-measured path now
764
+ // owns its height. Clear both so a later id reuse / this run cannot leak.
765
+ if (streamingMeasuredRowsById.has(item.id)) streamingMeasuredRowsById.delete(item.id);
766
+ if (streamingEstimateHighWaterById.has(item.id)) streamingEstimateHighWaterById.delete(item.id);
717
767
  }
718
768
  const variantKey = transcriptItemVariantKey(item);
719
769
  const toolExpanded = toolOutputExpanded ? 1 : 0;
@@ -15,6 +15,7 @@ import {
15
15
  transcriptMeasuredRowsCache,
16
16
  streamingMeasuredRowsById,
17
17
  pruneStreamingMeasuredRowsById,
18
+ hasStreamingRowStateToPrune,
18
19
  transcriptItemVariantKey,
19
20
  buildTranscriptRowIndexIncremental,
20
21
  transcriptStructureSignature,
@@ -632,7 +633,7 @@ export function useTranscriptWindow({
632
633
  if (!els.has(key)) refCache.delete(key);
633
634
  }
634
635
  }
635
- if (streamingMeasuredRowsById.size > 0) {
636
+ if (hasStreamingRowStateToPrune()) {
636
637
  pruneStreamingMeasuredRowsById(new Set(liveItems.keys()));
637
638
  }
638
639
  });
@@ -48,7 +48,10 @@ function percent(value, total) {
48
48
  function percentLabel(value, total) {
49
49
  const pct = percent(value, total);
50
50
  if (pct === null) return 'N/A';
51
- return `${pct > 0 && pct < 1 ? pct.toFixed(1) : Math.round(pct)}%`;
51
+ // Match the footer/statusline display: sub-1% keeps one decimal, otherwise
52
+ // show the floored percentage so /context and the statusline do not disagree
53
+ // by 1% around half-percent boundaries.
54
+ return `${pct > 0 && pct < 1 ? pct.toFixed(1) : Math.floor(pct)}%`;
52
55
  }
53
56
 
54
57
  function usageColor(pct) {
@@ -73,7 +73,6 @@ export function displayToolName(name, args) {
73
73
  }
74
74
 
75
75
  const TOOL_BLINK_MS = 500;
76
- const TOOL_BLINK_LIMIT_MS = 3000;
77
76
  const TOOL_PENDING_SHOW_DELAY_MS = 1000;
78
77
  // One shared-tick cadence covers both the 500ms blink and per-second elapsed;
79
78
  // finer than either boundary so both stay crisp off a single timer.
@@ -87,7 +86,7 @@ function statusCopy(name, label, count, doneCount, pending, isError, args = {})
87
86
  // dropping the pad just normalizes the spacing.
88
87
  return formatToolActionHeader(name, args, { pending, count });
89
88
  }
90
- export function ToolExecution({ name, args, result, rawResult, isError, errorCount, expanded, columns = 80, attached = false, count = 1, completedCount = 0, startedAt = 0, completedAt = 0, aggregate = false, categories = {}, doneCategories = null, headerFinalized = true, deferredDisplayReady = false }) {
89
+ export function ToolExecution({ name, args, result, rawResult, isError, errorCount, callErrorCount, expanded, columns = 80, attached = false, count = 1, completedCount = 0, startedAt = 0, completedAt = 0, aggregate = false, categories = {}, doneCategories = null, headerFinalized = true, deferredDisplayReady = false }) {
91
90
  const rowWidth = Math.max(1, Number(columns || 80));
92
91
  const groupCount = Math.max(1, Number(count || 1));
93
92
  const doneCount = Math.max(0, Math.min(groupCount, Number(completedCount || (result == null ? 0 : groupCount))));
@@ -116,13 +115,11 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
116
115
  // land — no empty band.
117
116
  const hasVisibleProgress = doneCount > 0 || Boolean(String(rt || '').trim());
118
117
  const pendingDisplayReady = !pending || !startedAtMs || pendingDelayElapsed || pendingAgeMs >= TOOL_PENDING_SHOW_DELAY_MS || hasVisibleProgress || deferredDisplayReady;
119
- // Derived blink (was two per-card setIntervals + a setTimeout): the dot blinks
120
- // at TOOL_BLINK_MS while a display-ready pending card is fresh, then goes solid
121
- // once TOOL_BLINK_LIMIT_MS elapses. Phase comes from Date.now() so the cadence
122
- // is identical to the old interval without owning a timer.
118
+ // Derived blink (was two per-card setIntervals + a setTimeout): while pending,
119
+ // the dot keeps blinking until the tool resolves. Phase comes from Date.now()
120
+ // so the cadence is identical to the old interval without owning a timer.
123
121
  const blinkActive = pending && pendingDisplayReady;
124
- const blinkExpired = blinkActive && startedAtMs > 0 && (nowMs - startedAtMs) >= TOOL_BLINK_LIMIT_MS;
125
- const blinkOn = !blinkActive || blinkExpired
122
+ const blinkOn = !blinkActive
126
123
  ? true
127
124
  : Math.floor(nowMs / TOOL_BLINK_MS) % 2 === 0;
128
125
  // Keep the action verb in its active form until the engine explicitly seals
@@ -135,6 +132,12 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
135
132
  const elapsedMs = startedAtMs ? Math.max(0, (pending ? nowMs : (completedAtMs || nowMs)) - startedAtMs) : 0;
136
133
  const elapsed = elapsedMs >= 1000 ? formatElapsed(elapsedMs) : '';
137
134
  const failedCount = clampFailureCount(errorCount, groupCount, isError);
135
+ // Real tool-call failures only (backend isError / error toolKind). Drives the
136
+ // ● dot color; command/result failures (shell exit, failed status) are counted
137
+ // in `failedCount`/L2 detail but never in `callFailedCount`, so they never
138
+ // paint the dot red. Fall back to 0 (never `isError`) when the engine did not
139
+ // supply a call-error count so a result failure can't leak into the dot.
140
+ const callFailedCount = clampFailureCount(callErrorCount, groupCount, false);
138
141
  const displayGroupCount = groupCount;
139
142
  const displayCategories = normalizeCountMap(categories || {});
140
143
  // In the DONE state, count only successful calls: error-terminated calls are
@@ -209,8 +212,8 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
209
212
  const aggregateTerminalStatus = pending
210
213
  ? 'running'
211
214
  : (resultTerminalStatus(rt) || (isError || failedCount > 0 ? 'failed' : 'completed'));
212
- const dotColor = toolStatusColor({ pending, groupCount, failedCount, terminalStatus: aggregateTerminalStatus });
213
- const dotText = pending && !blinkExpired && !blinkOn ? ' ' : TURN_MARKER;
215
+ const dotColor = toolStatusColor({ pending, groupCount, callFailedCount, terminalStatus: aggregateTerminalStatus });
216
+ const dotText = pending && !blinkOn ? ' ' : TURN_MARKER;
214
217
  const gutter = 2;
215
218
  const showHeaderExpandHint = hasRawResult;
216
219
  const hintLabel = `ctrl+o ${expanded ? 'collapse' : 'expand'}`;
@@ -423,7 +426,7 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
423
426
  const keepAgentDetail = (isError || agentFailureText) && !(agentHeaderFailure && !agentSurfaceBrief);
424
427
  visibleDetailLines = keepAgentDetail ? [agentDetailLine] : [];
425
428
  }
426
- const finalStatusColor = toolStatusColor({ pending, groupCount, failedCount, terminalStatus });
429
+ const finalStatusColor = toolStatusColor({ pending, groupCount, callFailedCount, terminalStatus });
427
430
  const dotColor = finalStatusColor;
428
431
  // Agent surface cards use directional markers: `←` for requests going OUT
429
432
  // (spawn/send/etc.) and `→` for the response coming back IN. Background
@@ -441,7 +444,7 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
441
444
  // `●` turn marker is a true 1-cell glyph and keeps the padding-only gutter.
442
445
  const isDirectionalMarker = isAgentResponse || isAgentSurfaceCard;
443
446
  const markerText = isDirectionalMarker ? `${markerGlyph} ` : markerGlyph;
444
- const dotText = pending && !blinkExpired && !blinkOn ? ' ' : markerText;
447
+ const dotText = pending && !blinkOn ? ' ' : markerText;
445
448
  let labelText;
446
449
  if (isAgentResponse) labelText = agentResponseTitle(parsedArgs);
447
450
  else if (isBackgroundResponse) labelText = backgroundTaskResultTitle(normalizedName, backgroundMeta || parsedArgs);
@@ -77,7 +77,7 @@ export const Item = React.memo(function Item({ item, prevKind, columns, toolOutp
77
77
  node = <ToolHookDenialCard item={item} columns={columns} />;
78
78
  break;
79
79
  }
80
- node = <ToolExecution name={item.name} args={item.args} result={item.result} rawResult={item.rawResult} isError={item.isError} errorCount={item.errorCount} expanded={toolOutputExpanded || item.expanded} columns={columns} attached={false} count={item.count} completedCount={item.completedCount} startedAt={item.startedAt} completedAt={item.completedAt} aggregate={item.aggregate} categories={item.categories} doneCategories={item.doneCategories} headerFinalized={item.headerFinalized} deferredDisplayReady={item.deferredDisplayReady} themeEpoch={themeEpoch} />;
80
+ node = <ToolExecution name={item.name} args={item.args} result={item.result} rawResult={item.rawResult} isError={item.isError} errorCount={item.errorCount} callErrorCount={item.callErrorCount} expanded={toolOutputExpanded || item.expanded} columns={columns} attached={false} count={item.count} completedCount={item.completedCount} startedAt={item.startedAt} completedAt={item.completedAt} aggregate={item.aggregate} categories={item.categories} doneCategories={item.doneCategories} headerFinalized={item.headerFinalized} deferredDisplayReady={item.deferredDisplayReady} />;
81
81
  break;
82
82
  }
83
83
  case 'notice':
@@ -390,16 +390,16 @@ export function clampFailureCount(errorCount, groupCount, isError) {
390
390
  // partial failure -> mixdogOrange || warning (some, not all, of the group failed)
391
391
  // all failed -> theme.error
392
392
  // cancelled -> theme.warning
393
- // Note: callers resolve terminalStatus to 'failed' whenever failedCount > 0
394
- // (they cannot tell partial from total failure), so a 'failed' status must
395
- // still fall through to the failedCount check below rather than short-circuit
396
- // to error otherwise a mixed-success batch (e.g. 1 of 3 failed) renders as
397
- // full-failure red instead of partial orange.
398
- export function toolStatusColor({ pending, groupCount, failedCount, terminalStatus = '' }) {
393
+ // The RED/orange failure color is driven ONLY by real tool-call errors
394
+ // (`callFailedCount` backend isError / error toolKind), NOT by command/result
395
+ // failures like a shell non-zero exit or a `[status: failed]` result. Those
396
+ // keep the card's L2 detail showing "Failed" but leave the dot on the success
397
+ // color. `terminalStatus` is still consulted so a cancelled card stays warning.
398
+ export function toolStatusColor({ pending, groupCount, callFailedCount = 0, terminalStatus = '' }) {
399
399
  if (pending) return theme.text;
400
400
  const status = normalizeTerminalStatus(terminalStatus);
401
401
  if (status === 'cancelled') return theme.warning;
402
- if (failedCount <= 0) return status === 'failed' ? theme.error : theme.success;
403
- if (groupCount > 1 && failedCount < groupCount) return theme.mixdogOrange || theme.warning;
402
+ if (callFailedCount <= 0) return theme.success;
403
+ if (groupCount > 1 && callFailedCount < groupCount) return theme.mixdogOrange || theme.warning;
404
404
  return theme.error;
405
405
  }