mixdog 0.9.25 → 0.9.26

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 (68) hide show
  1. package/package.json +1 -1
  2. package/scripts/steering-fold-provenance-test.mjs +71 -0
  3. package/scripts/webhook-smoke.mjs +46 -53
  4. package/src/defaults/skills/setup/SKILL.md +6 -1
  5. package/src/rules/shared/01-tool.md +8 -4
  6. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +17 -0
  7. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +2 -2
  8. package/src/runtime/agent/orchestrator/mcp/client.mjs +28 -10
  9. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +8 -1
  10. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +14 -1
  11. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +51 -15
  12. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +5 -1
  13. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +164 -9
  14. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +45 -0
  15. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +4 -0
  16. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +12 -1
  17. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +2 -2
  18. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +7 -3
  19. package/src/runtime/channels/lib/config.mjs +13 -11
  20. package/src/runtime/channels/lib/memory-client.mjs +22 -2
  21. package/src/runtime/channels/lib/owned-runtime.mjs +1 -1
  22. package/src/runtime/channels/lib/scheduler.mjs +226 -208
  23. package/src/runtime/channels/lib/status-snapshot.mjs +16 -0
  24. package/src/runtime/channels/lib/webhook/deliveries.mjs +7 -318
  25. package/src/runtime/channels/lib/webhook.mjs +98 -150
  26. package/src/runtime/channels/lib/worker-main.mjs +1 -1
  27. package/src/runtime/memory/index.mjs +50 -25
  28. package/src/runtime/memory/lib/cycle-scheduler.mjs +3 -1
  29. package/src/runtime/memory/lib/memory-config-flags.mjs +13 -1
  30. package/src/runtime/memory/lib/pg/adapter.mjs +6 -1
  31. package/src/runtime/memory/lib/pg/process.mjs +19 -1
  32. package/src/runtime/memory/lib/pg/supervisor.mjs +125 -14
  33. package/src/runtime/memory/lib/query-handlers.mjs +102 -0
  34. package/src/runtime/memory/lib/recall-format.mjs +60 -22
  35. package/src/runtime/memory/lib/session-ingest-runtime.mjs +20 -6
  36. package/src/runtime/memory/tool-defs.mjs +1 -1
  37. package/src/runtime/shared/child-guardian.mjs +2 -2
  38. package/src/runtime/shared/open-url.mjs +2 -1
  39. package/src/runtime/shared/schedules-db.mjs +350 -0
  40. package/src/runtime/shared/service-discovery.mjs +169 -0
  41. package/src/runtime/shared/spawn-flags.mjs +27 -0
  42. package/src/runtime/shared/tool-primitives.mjs +3 -1
  43. package/src/runtime/shared/tool-surface.mjs +19 -3
  44. package/src/runtime/shared/update-checker.mjs +54 -10
  45. package/src/runtime/shared/webhooks-db.mjs +405 -0
  46. package/src/session-runtime/channel-config-api.mjs +13 -13
  47. package/src/session-runtime/lifecycle-api.mjs +14 -0
  48. package/src/session-runtime/runtime-core.mjs +39 -20
  49. package/src/standalone/agent-tool.mjs +42 -11
  50. package/src/standalone/channel-admin.mjs +173 -121
  51. package/src/standalone/channel-worker.mjs +2 -2
  52. package/src/standalone/memory-runtime-proxy.mjs +10 -5
  53. package/src/tui/App.jsx +32 -10
  54. package/src/tui/app/channel-pickers.mjs +9 -9
  55. package/src/tui/app/clipboard.mjs +14 -0
  56. package/src/tui/app/doctor.mjs +1 -1
  57. package/src/tui/app/maintenance-pickers.mjs +17 -7
  58. package/src/tui/app/settings-picker.mjs +2 -2
  59. package/src/tui/app/transcript-window.mjs +37 -1
  60. package/src/tui/app/use-mouse-input.mjs +19 -6
  61. package/src/tui/components/ToolExecution.jsx +12 -4
  62. package/src/tui/display-width.mjs +10 -4
  63. package/src/tui/dist/index.mjs +129 -55
  64. package/src/tui/engine/session-api-ext.mjs +12 -12
  65. package/src/tui/index.jsx +12 -2
  66. package/src/ui/statusline-segments.mjs +12 -1
  67. package/vendor/ink/build/display-width.js +5 -4
  68. package/src/runtime/shared/schedules-store.mjs +0 -82
package/src/tui/App.jsx CHANGED
@@ -1338,7 +1338,14 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
1338
1338
  copySelection();
1339
1339
  return;
1340
1340
  }
1341
- showSelectionCopyHint('select text to copy · Esc interrupts', 'plain');
1341
+ // No app-owned selection. On Windows Terminal the same Ctrl+C is also the
1342
+ // native terminal's copy shortcut for a mouse selection we can't see — so
1343
+ // rendering a hint here fights that copy and flashes a spurious message.
1344
+ // Suppress the hint on win32 (interrupt routing is unchanged: Esc still
1345
+ // interrupts). Other platforms keep the guidance.
1346
+ if (process.platform !== 'win32') {
1347
+ showSelectionCopyHint('select text to copy · Esc interrupts', 'plain');
1348
+ }
1342
1349
  return;
1343
1350
  }
1344
1351
  if (key.ctrl && (input === 'o' || input === 'O')) {
@@ -1855,19 +1862,34 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
1855
1862
  }
1856
1863
  if (channelPrompt.kind === 'schedule-add') {
1857
1864
  const [name, time, instructions, channel, model] = parts;
1858
- store.saveSchedule({ name, time, instructions, channel, model });
1859
- setChannelPrompt(null);
1860
- void openChannelSetupPicker('schedules');
1865
+ // saveSchedule is async (PG-backed). Only close the prompt / open the
1866
+ // list on success; surface a failure notice and keep the prompt open
1867
+ // so the input is not lost and the user can retry.
1868
+ Promise.resolve(store.saveSchedule({ name, time, instructions, channel, model }))
1869
+ .then(() => {
1870
+ setChannelPrompt(null);
1871
+ void openChannelSetupPicker('schedules');
1872
+ })
1873
+ .catch((e) => {
1874
+ store.pushNotice(`schedule save failed: ${e?.message || e}`, 'error');
1875
+ });
1861
1876
  return true;
1862
1877
  }
1863
1878
  if (channelPrompt.kind === 'webhook-add') {
1864
1879
  const [name, instructions, channel, model, parser] = parts;
1865
- const result = store.saveWebhook({ name, instructions, channel, model, parser });
1866
- if (result?.secret) {
1867
- store.pushNotice(`webhook secret for ${result.name}: ${result.secret}`, 'info');
1868
- }
1869
- setChannelPrompt(null);
1870
- void openChannelSetupPicker('webhooks');
1880
+ // saveWebhook is async (PG-backed). Only close the prompt / open the
1881
+ // list on success; surface a failure notice and keep the prompt open.
1882
+ Promise.resolve(store.saveWebhook({ name, instructions, channel, model, parser }))
1883
+ .then((result) => {
1884
+ if (result?.secret) {
1885
+ store.pushNotice(`webhook secret for ${result.name}: ${result.secret}`, 'info');
1886
+ }
1887
+ setChannelPrompt(null);
1888
+ void openChannelSetupPicker('webhooks');
1889
+ })
1890
+ .catch((e) => {
1891
+ store.pushNotice(`webhook save failed: ${e?.message || e}`, 'error');
1892
+ });
1871
1893
  return true;
1872
1894
  }
1873
1895
  } catch (e) {
@@ -27,7 +27,7 @@ export function createChannelPickers({
27
27
  // a long first-time install finishing later must not hijack whatever
28
28
  // screen/picker the user has since navigated to.
29
29
  let pickerGen = 0;
30
- const openChannelTypeActionsPicker = (backend, options = {}) => {
30
+ const openChannelTypeActionsPicker = async (backend, options = {}) => {
31
31
  pickerGen += 1;
32
32
  const parentReturn = typeof options.returnTo === 'function'
33
33
  ? options.returnTo
@@ -38,7 +38,7 @@ export function createChannelPickers({
38
38
  setContextPanel(null);
39
39
  let setup;
40
40
  try {
41
- setup = store.getChannelSetup();
41
+ setup = await store.getChannelSetup();
42
42
  } catch (e) {
43
43
  store.pushNotice(`channels failed: ${e?.message || e}`, 'error');
44
44
  return;
@@ -125,7 +125,7 @@ export function createChannelPickers({
125
125
  });
126
126
  };
127
127
 
128
- const openChannelSettingTypePicker = (options = {}) => {
128
+ const openChannelSettingTypePicker = async (options = {}) => {
129
129
  pickerGen += 1;
130
130
  const returnTo = typeof options.returnTo === 'function' ? options.returnTo : () => {};
131
131
  setProviderPrompt(null);
@@ -135,7 +135,7 @@ export function createChannelPickers({
135
135
  setContextPanel(null);
136
136
  let setup;
137
137
  try {
138
- setup = store.getChannelSetup();
138
+ setup = await store.getChannelSetup();
139
139
  } catch (e) {
140
140
  store.pushNotice(`channels failed: ${e?.message || e}`, 'error');
141
141
  return;
@@ -203,7 +203,7 @@ export function createChannelPickers({
203
203
  setContextPanel(null);
204
204
  let setup;
205
205
  try {
206
- setup = store.getChannelSetup();
206
+ setup = await store.getChannelSetup();
207
207
  } catch (e) {
208
208
  store.pushNotice(`channels failed: ${e?.message || e}`, 'error');
209
209
  return;
@@ -239,14 +239,14 @@ export function createChannelPickers({
239
239
  _action: 'noop',
240
240
  }]),
241
241
  ];
242
- const toggleSchedule = (item) => {
242
+ const toggleSchedule = async (item) => {
243
243
  if (item._action !== 'schedule-toggle') return;
244
244
  if (!channelRemoteEnabled) {
245
245
  store.pushNotice('enable channel first', 'warn');
246
246
  return;
247
247
  }
248
248
  try {
249
- store.setScheduleEnabled?.(item._name, !item._enabled);
249
+ await store.setScheduleEnabled?.(item._name, !item._enabled);
250
250
  void openChannelSetupPicker('schedules', { highlightValue: `schedule:${item._name}` });
251
251
  } catch (e) {
252
252
  store.pushNotice(`schedule toggle failed: ${e?.message || e}`, 'error');
@@ -346,14 +346,14 @@ export function createChannelPickers({
346
346
  _action: 'noop',
347
347
  }]),
348
348
  ];
349
- const toggleWebhook = (item) => {
349
+ const toggleWebhook = async (item) => {
350
350
  if (item._action !== 'webhook-toggle') return;
351
351
  if (!channelRemoteEnabled) {
352
352
  store.pushNotice('enable channel first', 'warn');
353
353
  return;
354
354
  }
355
355
  try {
356
- store.setWebhookEnabled?.(item._name, !item._enabled);
356
+ await store.setWebhookEnabled?.(item._name, !item._enabled);
357
357
  void openChannelSetupPicker('webhooks', { highlightValue: `webhook:${item._name}` });
358
358
  } catch (e) {
359
359
  store.pushNotice(`webhook toggle failed: ${e?.message || e}`, 'error');
@@ -18,8 +18,22 @@ export function osc52ClipboardSequence(text) {
18
18
  // helper. ~256KB of clipboard text → ~350KB of base64.
19
19
  const OSC52_MAX_BYTES = 256 * 1024;
20
20
 
21
+ // On native Windows (ConPTY) a large OSC 52 base64 payload written synchronously
22
+ // to stdout can stall for multiple seconds under terminal backpressure while a
23
+ // mouse selection is active — a debugger-confirmed Ctrl+C copy freeze. clip.exe
24
+ // alone is the reliable local clipboard writer, so skip OSC 52 entirely on
25
+ // win32 UNLESS we're in a remote/multiplexed session (SSH or tmux) where OSC 52
26
+ // is the only way to reach the user's real terminal.
27
+ function shouldSkipOsc52() {
28
+ if (process.platform !== 'win32') return false;
29
+ if (process.env.TMUX) return false;
30
+ if (process.env.SSH_CONNECTION || process.env.SSH_TTY) return false;
31
+ return true;
32
+ }
33
+
21
34
  export function writeOsc52Clipboard(text) {
22
35
  const value = String(text ?? '');
36
+ if (shouldSkipOsc52()) return false;
23
37
  if (Buffer.byteLength(value, 'utf8') > OSC52_MAX_BYTES) return false;
24
38
  try {
25
39
  process.stdout.write(osc52ClipboardSequence(value));
@@ -161,7 +161,7 @@ export async function buildDoctorReport(runtime = {}, getState = () => ({})) {
161
161
  return;
162
162
  }
163
163
  const worker = settings.status || runtime.getChannelWorkerStatus?.() || {};
164
- const setup = runtime.getChannelSetup?.() || {};
164
+ const setup = (await runtime.getChannelSetup?.()) || {};
165
165
  const tokens = [];
166
166
  if (setup.discord?.authenticated) tokens.push('discord');
167
167
  if (setup.telegram?.authenticated) tokens.push('telegram');
@@ -31,6 +31,10 @@ export function createMaintenancePickers({
31
31
  const upd = readSettings();
32
32
  const status = readStatus();
33
33
  const current = upd.currentVersion || 'unknown';
34
+ // After a successful in-place install the running process is still the
35
+ // old version; surface the pending version so "Current" doesn't look
36
+ // stale/broken until restart.
37
+ const installedVersion = status.phase === 'installed' ? (status.version || upd.latestVersion || null) : null;
34
38
  const latestMeta = checking || status.phase === 'checking'
35
39
  ? 'checking…'
36
40
  : (upd.latestVersion || 'unknown');
@@ -38,8 +42,10 @@ export function createMaintenancePickers({
38
42
  {
39
43
  value: 'current',
40
44
  label: 'Current version',
41
- meta: current,
42
- description: 'Installed mixdog version.',
45
+ meta: installedVersion ? `${current} → ${installedVersion}` : current,
46
+ description: installedVersion
47
+ ? `v${installedVersion} installed — restart mixdog to apply.`
48
+ : 'Installed mixdog version.',
43
49
  _action: 'current',
44
50
  },
45
51
  {
@@ -73,13 +79,17 @@ export function createMaintenancePickers({
73
79
  buttons: [
74
80
  {
75
81
  value: 'update-now',
76
- label: upd.updateAvailable
77
- ? `Update to v${upd.latestVersion || 'latest'}`
78
- : 'Update now',
82
+ label: installedVersion
83
+ ? `v${installedVersion} installed — restart to apply`
84
+ : (status.phase === 'installing'
85
+ ? 'Installing…'
86
+ : (upd.updateAvailable
87
+ ? `Update to v${upd.latestVersion || 'latest'}`
88
+ : 'Update now')),
79
89
  },
80
90
  ],
81
91
  onConfirm: (button) => {
82
- if (button?.value === 'update-now') runUpdate();
92
+ if (button?.value === 'update-now' && !installedVersion && status.phase !== 'installing') runUpdate();
83
93
  },
84
94
  },
85
95
  onSelect: (_value, item) => {
@@ -118,7 +128,7 @@ export function createMaintenancePickers({
118
128
  void Promise.resolve(store.runUpdateNow?.())
119
129
  .then((result) => {
120
130
  if (result?.ok) {
121
- store.pushNotice(`v${result.version} installed — restart to apply`, 'info');
131
+ store.pushNotice(`v${result.version} installed — restart to apply`, 'warn');
122
132
  } else {
123
133
  store.pushNotice(`Update failed: ${result?.error || 'unknown error'}`, 'error');
124
134
  }
@@ -43,7 +43,7 @@ export function createSettingsPicker({
43
43
  openUpdatePicker,
44
44
  openChannelSettingTypePicker,
45
45
  }) {
46
- const openSettingsPicker = (opts = {}) => {
46
+ const openSettingsPicker = async (opts = {}) => {
47
47
  const light = opts.light === true;
48
48
  const overrides = opts.overrides || null;
49
49
  const heavyCache = light ? settingsHeavyCacheRef.current : null;
@@ -63,7 +63,7 @@ export function createSettingsPicker({
63
63
  channelBackend = heavyCache.channelBackend || 'discord';
64
64
  } else {
65
65
  try {
66
- channelBackend = store.getChannelSetup?.()?.backend || 'discord';
66
+ channelBackend = (await store.getChannelSetup?.())?.backend || 'discord';
67
67
  } catch {
68
68
  channelBackend = 'discord';
69
69
  }
@@ -272,6 +272,33 @@ function toolArgPathForRows(item) {
272
272
  return a?.path ?? a?.file_path ?? a?.file ?? '';
273
273
  }
274
274
 
275
+ // Mirror ToolExecution's collapsed agent-card rule: an agent surface collapses
276
+ // to a SINGLE header row unless the detail row carries failure info (the call
277
+ // errored, or the brief/status reads as a failure/cancel). Pending agent cards
278
+ // never carry failure info yet, so they collapse to one row. A header-failure-
279
+ // only card is handled earlier by toolHeaderFailureOnlyForRows (also one row).
280
+ function agentCardKeepsCollapsedDetailForRows(item, normalizedName) {
281
+ if (normalizedName !== 'agent') return false;
282
+ if (toolItemPendingForRows(item)) return false;
283
+ const bgArgs = backgroundArgsForRows(item.args);
284
+ const isError = Boolean(item.isError);
285
+ const hasDisplayResult = toolHasDisplayResultForRows(item);
286
+ const displayedResultText = toolDisplayedResultTextForRows(item);
287
+ const rt = item.result == null ? '' : String(item.result).replace(/\s+$/, '');
288
+ const isAgentResponse = hasDisplayResult && isAgentResponseResultText(rt);
289
+ const briefRaw = summarizeAgentSurfaceBrief(item.name, bgArgs, displayedResultText, {
290
+ isError,
291
+ isResponse: isAgentResponse,
292
+ });
293
+ const brief = String(briefRaw || '').trim();
294
+ const status = String(bgArgs.status || '').toLowerCase();
295
+ const failureText = /\b(cancelled|canceled|failed)\b/i.test(brief)
296
+ || /^(failed|error|timeout|cancelled|canceled|killed)$/i.test(status);
297
+ const agentHeaderFailure = isError && String(bgArgs.error || '').trim() && !hasDisplayResult;
298
+ if (agentHeaderFailure && !brief) return false;
299
+ return isError || failureText;
300
+ }
301
+
275
302
  function isShellSurfaceForRows(normalizedName, label = '') {
276
303
  const n = String(normalizedName || '').toLowerCase();
277
304
  const l = String(label || '').toLowerCase();
@@ -332,6 +359,11 @@ function estimateTranscriptItemRows(item, columns, toolOutputExpanded) {
332
359
  if (toolHeaderFailureOnlyForRows(item, normalizedName, hasDisplayResult)) {
333
360
  return TOOL_MARGIN_TOP + 1;
334
361
  }
362
+ if (isAgentSurface) {
363
+ return agentCardKeepsCollapsedDetailForRows(item, normalizedName)
364
+ ? TOOL_MARGIN_TOP + 1 + 1
365
+ : TOOL_MARGIN_TOP + 1;
366
+ }
335
367
  return TOOL_MARGIN_TOP + 1 + 1;
336
368
  }
337
369
  if (hasRawResult) {
@@ -341,7 +373,11 @@ function estimateTranscriptItemRows(item, columns, toolOutputExpanded) {
341
373
  : { pathArg: toolArgPathForRows(item), isShell: isShellSurfaceForToolItem(item, normalizedName) };
342
374
  return TOOL_MARGIN_TOP + 1 + estimateToolRenderedResultRows(estimateText, { ...rawOpts, columns });
343
375
  }
344
- if (isAgentSurface && !hasResult) return TOOL_MARGIN_TOP + 1 + 1;
376
+ if (isAgentSurface && !hasResult) {
377
+ return agentCardKeepsCollapsedDetailForRows(item, normalizedName)
378
+ ? TOOL_MARGIN_TOP + 1 + 1
379
+ : TOOL_MARGIN_TOP + 1;
380
+ }
345
381
  if (isSkillSurface && !hasResult) return TOOL_MARGIN_TOP + 1;
346
382
  if (item.aggregate) {
347
383
  if (hasRawResult) {
@@ -18,8 +18,11 @@ const MOUSE_TRACKING_OFF = '\x1b[?1006l\x1b[?1002l\x1b[?1000l';
18
18
  // conversion WINS over WT's native ctrl+wheel font zoom. During the zoom
19
19
  // passthrough window we must turn BOTH mouse tracking and alternate scroll
20
20
  // off, or ctrl+wheel lands as Up/Down (prompt history) instead of zooming.
21
+ // 1007 is kept OFF for the entire session (index.jsx boots with ?1007l and
22
+ // the restore below re-asserts it): if mouse tracking ever drops while 1007
23
+ // is on, every wheel notch turns into prompt-history Up/Down. Off means a
24
+ // degraded wheel is a no-op, never history navigation.
21
25
  const ALT_SCROLL_OFF = '\x1b[?1007l';
22
- const ALT_SCROLL_ON = '\x1b[?1007h';
23
26
  const MOUSE_MODIFIER_MASK = 4 | 8 | 16;
24
27
  const MOUSE_CTRL_MASK = 16;
25
28
  // Bit 2 (4) of the SGR button byte = shift held during the click. Wheel/ctrl
@@ -76,15 +79,25 @@ export function useMouseInput({
76
79
  } catch {
77
80
  return;
78
81
  }
79
- if (zoomTimerRef.current) clearTimeout(zoomTimerRef.current);
80
- zoomTimerRef.current = setTimeout(() => {
82
+ // Re-enable with RETRIES: a single swallowed write failure (EAGAIN
83
+ // backpressure is real on Windows console streams) used to leave mouse
84
+ // tracking off for the rest of the session — wheel scroll dead, and with
85
+ // alternate scroll enabled the wheel then typed into prompt history.
86
+ const restore = (attempt) => {
81
87
  zoomTimerRef.current = null;
82
88
  try {
83
- stdout.write(ALT_SCROLL_ON + MOUSE_TRACKING_ON);
89
+ stdout.write(MOUSE_TRACKING_ON + ALT_SCROLL_OFF);
84
90
  } catch {
85
- // The terminal may already be closing.
91
+ // The terminal may be closing — but it may also be transient
92
+ // backpressure; retry a few times before giving up.
93
+ if (attempt < 5) {
94
+ zoomTimerRef.current = setTimeout(() => restore(attempt + 1), 200);
95
+ zoomTimerRef.current.unref?.();
96
+ }
86
97
  }
87
- }, 700);
98
+ };
99
+ if (zoomTimerRef.current) clearTimeout(zoomTimerRef.current);
100
+ zoomTimerRef.current = setTimeout(() => restore(0), 700);
88
101
  zoomTimerRef.current.unref?.();
89
102
  }, [stdout, zoomTimerRef]);
90
103
 
@@ -198,9 +198,10 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
198
198
  // Mirror estimateTranscriptItemRows: a non-aggregate skill surface collapses
199
199
  // to a single header row; everything else reserves header + one detail row.
200
200
  const placeholderNormalizedName = String(formatToolSurface(name, args)?.normalizedName || '').toLowerCase();
201
- // Skill surfaces collapse to a single header row; agent surfaces reserve
202
- // header + one brief detail row (see estimateTranscriptItemRows).
203
- const placeholderSingleRow = !aggregate && SKILL_SURFACE_NAMES.has(placeholderNormalizedName);
201
+ // Skill AND agent surfaces collapse to a single header row when collapsed
202
+ // (see estimateTranscriptItemRows); reserve one row for both.
203
+ const placeholderSingleRow = !aggregate
204
+ && (SKILL_SURFACE_NAMES.has(placeholderNormalizedName) || isAgentTool(placeholderNormalizedName));
204
205
  return (
205
206
  <Box flexDirection="column" marginTop={attached ? 0 : 1} width={rowWidth} overflow="hidden">
206
207
  <Text> </Text>
@@ -443,11 +444,18 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
443
444
  } else if (isBackgroundMetadataResult && backgroundMetadataHeaderFailure && !showRawResult) {
444
445
  visibleDetailLines = [];
445
446
  } else if (isAgentSurfaceCard && !showRawResult) {
447
+ // Agent cards collapse to a SINGLE header row like skill surfaces. Keep the
448
+ // ⎿ detail row ONLY when it carries failure info: the call errored, or the
449
+ // brief/summary reads as a failure/cancel. A header-failure-only card (no
450
+ // brief) shows the cause in the header, so it stays a single row too.
451
+ // Expanded (ctrl+o raw) still flows through the showRawResult path above.
446
452
  const agentDetailFallback = collapsedDetail
447
453
  || (pending ? (pendingDetailPlaceholder || 'Running') : 'Finished');
448
454
  const agentDetailLine = agentSurfaceBrief
449
455
  || truncateToWidth(String(agentDetailFallback), Math.min(AGENT_SURFACE_BRIEF_MAX, maxResultChars));
450
- visibleDetailLines = agentHeaderFailure && !agentSurfaceBrief ? [] : [agentDetailLine];
456
+ const agentFailureText = /\b(Cancelled|Canceled|Failed)\b/i.test(agentSurfaceBrief || collapsedDetail || '');
457
+ const keepAgentDetail = (isError || agentFailureText) && !(agentHeaderFailure && !agentSurfaceBrief);
458
+ visibleDetailLines = keepAgentDetail ? [agentDetailLine] : [];
451
459
  }
452
460
  const finalStatusColor = toolStatusColor({ pending, groupCount, failedCount, terminalStatus });
453
461
  const dotColor = finalStatusColor;
@@ -12,7 +12,9 @@
12
12
  * - Widen ONLY the two problem ranges below. NEVER widen box-drawing
13
13
  * (U+2500–U+257F └ │ ⎿ ─), block elements, or the figures.mjs glyphs —
14
14
  * those are also EAW-ambiguous but must stay 1 cell.
15
- * - Enabled by default ONLY under Windows Terminal (WT_SESSION set).
15
+ * - Enabled by default on Windows (process.platform === 'win32') or under
16
+ * Windows Terminal (WT_SESSION set — covers WT reached via e.g. SSH from
17
+ * a non-Windows host).
16
18
  * - MIXDOG_TUI_AMBIGUOUS_WIDE overrides the default: '1' forces on, '0'
17
19
  * forces off; the override always wins.
18
20
  * - When OFF, behaviour is byte-for-byte identical to plain string-width.
@@ -43,12 +45,16 @@ export function isProblemCodePoint(cp) {
43
45
  // vendor/ink/build/display-width.js.
44
46
  const PROBLEM_RE = /[\u2190-\u21ff\u2460-\u24ff]/;
45
47
 
46
- /** Resolve the wide policy from env. Override wins over the WT_SESSION default. */
47
- export function resolveAmbiguousWidePolicy(env = process.env) {
48
+ /**
49
+ * Resolve the wide policy from env. Override wins over the default; the
50
+ * default is ON on Windows (WT_SESSION does not reliably propagate to child
51
+ * processes, so gate on the OS itself) or when WT_SESSION is present.
52
+ */
53
+ export function resolveAmbiguousWidePolicy(env = process.env, platform = process.platform) {
48
54
  const override = env?.MIXDOG_TUI_AMBIGUOUS_WIDE;
49
55
  if (override === '1') return true;
50
56
  if (override === '0') return false;
51
- return Boolean(env?.WT_SESSION);
57
+ return platform === 'win32' || Boolean(env?.WT_SESSION);
52
58
  }
53
59
 
54
60
  /** Resolved once at module load (matches the "computed once" requirement). */