mixdog 0.9.22 → 0.9.24

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 (132) hide show
  1. package/README.md +1 -4
  2. package/package.json +1 -1
  3. package/scripts/boot-smoke.mjs +1 -1
  4. package/scripts/channel-daemon-smoke.mjs +483 -0
  5. package/scripts/channel-daemon-stub.mjs +80 -0
  6. package/scripts/debounced-skills-async-save-test.mjs +57 -0
  7. package/scripts/explore-bench-tmp.mjs +17 -0
  8. package/scripts/find-fuzzy-hidden-test.mjs +145 -0
  9. package/scripts/mcp-grace-deferred-test.mjs +149 -0
  10. package/scripts/statusline-quota-hysteresis-test.mjs +37 -0
  11. package/scripts/tool-smoke.mjs +68 -47
  12. package/src/rules/agent/30-explorer.md +6 -0
  13. package/src/rules/shared/01-tool.md +11 -4
  14. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +76 -1
  15. package/src/runtime/agent/orchestrator/config.mjs +33 -7
  16. package/src/runtime/agent/orchestrator/context/collect.mjs +43 -8
  17. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +226 -0
  18. package/src/runtime/agent/orchestrator/mcp/client.mjs +184 -33
  19. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +38 -1
  20. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +39 -1
  21. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +1 -1
  22. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +5 -3
  23. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +2 -2
  24. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +2 -2
  25. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1 -1
  26. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +24 -7
  27. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +3 -2
  28. package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +6 -3
  29. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +1 -1
  30. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +37 -4
  31. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +11 -1
  32. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +14 -3
  33. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +40 -6
  34. package/src/runtime/agent/orchestrator/session/store.mjs +30 -14
  35. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +2 -1
  36. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +13 -5
  37. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +62 -24
  38. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -6
  39. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +20 -0
  40. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +34 -3
  41. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +220 -27
  42. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +61 -0
  43. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
  44. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +67 -27
  45. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +6 -5
  46. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -5
  47. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +97 -54
  48. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +49 -31
  49. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +35 -2
  50. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +126 -22
  51. package/src/runtime/channels/lib/crash-log.mjs +4 -2
  52. package/src/runtime/channels/lib/output-forwarder.mjs +2 -1
  53. package/src/runtime/channels/lib/owned-runtime.mjs +67 -223
  54. package/src/runtime/channels/lib/owner-heartbeat.mjs +9 -62
  55. package/src/runtime/channels/lib/parent-bridge.mjs +16 -1
  56. package/src/runtime/channels/lib/runtime-paths.mjs +32 -113
  57. package/src/runtime/channels/lib/session-discovery.mjs +2 -1
  58. package/src/runtime/channels/lib/tool-dispatch.mjs +22 -14
  59. package/src/runtime/channels/lib/tool-format.mjs +7 -2
  60. package/src/runtime/channels/lib/worker-main.mjs +42 -30
  61. package/src/runtime/memory/index.mjs +54 -7
  62. package/src/runtime/memory/lib/embedding-warmup.mjs +7 -0
  63. package/src/runtime/memory/lib/pg/process.mjs +85 -40
  64. package/src/runtime/memory/lib/pg/supervisor.mjs +82 -17
  65. package/src/runtime/memory/lib/query-handlers.mjs +4 -1
  66. package/src/runtime/memory/lib/recall-format.mjs +7 -3
  67. package/src/runtime/memory/tool-defs.mjs +1 -1
  68. package/src/runtime/shared/atomic-file.mjs +150 -6
  69. package/src/runtime/shared/background-tasks.mjs +1 -1
  70. package/src/runtime/shared/config.mjs +53 -1
  71. package/src/runtime/shared/tool-execution-contract.mjs +1 -1
  72. package/src/runtime/shared/tool-primitives.mjs +31 -1
  73. package/src/runtime/shared/tool-surface.mjs +42 -13
  74. package/src/runtime/shared/update-checker.mjs +3 -0
  75. package/src/runtime/shared/user-data-guard.mjs +66 -0
  76. package/src/session-runtime/config-lifecycle.mjs +221 -20
  77. package/src/session-runtime/lifecycle-api.mjs +9 -0
  78. package/src/session-runtime/mcp-glue.mjs +93 -1
  79. package/src/session-runtime/resource-api.mjs +62 -8
  80. package/src/session-runtime/runtime-core.mjs +118 -4
  81. package/src/session-runtime/session-text.mjs +41 -0
  82. package/src/session-runtime/session-turn-api.mjs +50 -0
  83. package/src/session-runtime/settings-api.mjs +8 -1
  84. package/src/session-runtime/tool-catalog.mjs +350 -38
  85. package/src/session-runtime/tool-defs.mjs +7 -7
  86. package/src/session-runtime/workflow.mjs +2 -1
  87. package/src/standalone/channel-admin.mjs +32 -3
  88. package/src/standalone/channel-daemon-client.mjs +226 -0
  89. package/src/standalone/channel-daemon-transport.mjs +545 -0
  90. package/src/standalone/channel-daemon.mjs +176 -0
  91. package/src/standalone/channel-worker.mjs +224 -4
  92. package/src/standalone/explore-tool.mjs +87 -15
  93. package/src/standalone/hook-bus.mjs +71 -3
  94. package/src/tui/App.jsx +107 -19
  95. package/src/tui/app/clipboard.mjs +39 -19
  96. package/src/tui/app/doctor.mjs +57 -0
  97. package/src/tui/app/extension-pickers.mjs +53 -9
  98. package/src/tui/app/maintenance-pickers.mjs +0 -5
  99. package/src/tui/app/slash-dispatch.mjs +4 -4
  100. package/src/tui/app/text-layout.mjs +11 -0
  101. package/src/tui/app/use-mouse-input.mjs +235 -51
  102. package/src/tui/app/use-prompt-handlers.mjs +49 -30
  103. package/src/tui/app/use-transcript-scroll.mjs +124 -27
  104. package/src/tui/app/use-transcript-window.mjs +55 -1
  105. package/src/tui/components/Message.jsx +1 -1
  106. package/src/tui/components/PromptInput.jsx +3 -1
  107. package/src/tui/components/QueuedCommands.jsx +21 -10
  108. package/src/tui/components/StatusLine.jsx +3 -3
  109. package/src/tui/components/ToolExecution.jsx +16 -4
  110. package/src/tui/components/TranscriptItem.jsx +1 -1
  111. package/src/tui/dist/index.mjs +820 -326
  112. package/src/tui/engine/agent-job-feed.mjs +5 -0
  113. package/src/tui/engine/notification-plan.mjs +5 -0
  114. package/src/tui/engine/session-api.mjs +23 -8
  115. package/src/tui/engine/session-flow.mjs +6 -0
  116. package/src/tui/engine/tool-card-results.mjs +14 -5
  117. package/src/tui/engine/turn.mjs +9 -2
  118. package/src/tui/engine.mjs +32 -5
  119. package/src/tui/index.jsx +62 -18
  120. package/src/tui/paste-attachments.mjs +26 -0
  121. package/src/ui/statusline-agents.mjs +36 -0
  122. package/src/ui/statusline.mjs +60 -10
  123. package/src/ui/tool-card.mjs +8 -1
  124. package/src/vendor/statusline/bin/statusline-route.mjs +23 -7
  125. package/src/workflows/bench/WORKFLOW.md +46 -0
  126. package/src/workflows/default/WORKFLOW.md +6 -0
  127. package/src/workflows/solo/WORKFLOW.md +5 -0
  128. package/vendor/ink/build/ink.js +23 -1
  129. package/vendor/ink/build/output.js +154 -71
  130. package/vendor/ink/build/render-node-to-output.js +44 -2
  131. package/vendor/ink/build/render.js +4 -0
  132. package/vendor/ink/build/renderer.js +4 -1
@@ -38,6 +38,7 @@ export function createAgentJobFeed({
38
38
  getPending,
39
39
  agentStatusState,
40
40
  displayedExecutionNotificationKeys,
41
+ pushNotice,
41
42
  }) {
42
43
  let executionResumeKickDeferred = false;
43
44
 
@@ -92,6 +93,10 @@ export function createAgentJobFeed({
92
93
  const notificationKey = notificationQueueKey(event, text, parsed);
93
94
  const delivery = resolveTuiRuntimeNotificationDelivery(event, text);
94
95
  if (delivery.action === 'ignore') return;
96
+ if (delivery.action === 'notice') {
97
+ pushNotice?.(delivery.displayText, delivery.tone || 'info', { transcript: true });
98
+ return true;
99
+ }
95
100
  if (delivery.action === 'status-only') {
96
101
  if (parsed?.taskId) set(agentStatusState({ force: true }));
97
102
  return true;
@@ -61,6 +61,11 @@ export function resolveTuiRuntimeNotificationDelivery(event, text) {
61
61
  if (!trimmed) return { action: 'ignore' };
62
62
  const parsed = parseAgentJob(trimmed);
63
63
  const meta = event?.meta && typeof event.meta === 'object' ? event.meta : {};
64
+ // UI-only notices (e.g. boot auto-update outcome): render as a transcript
65
+ // notice, never enqueue anything model-visible.
66
+ if (meta.kind === 'update-notice') {
67
+ return { action: 'notice', displayText: trimmed, tone: meta.tone === 'warn' ? 'warn' : 'info' };
68
+ }
64
69
  if (!isExecutionNotification(event, trimmed, parsed)) {
65
70
  return { action: 'enqueue', displayText: trimmed, modelContent: trimmed };
66
71
  }
@@ -6,6 +6,8 @@ import { toolErrorDisplay } from './tool-result-text.mjs';
6
6
  import { isQueuedEntryEditable, promptDisplayText } from './queue-helpers.mjs';
7
7
  import { createEngineApiB } from './session-api-ext.mjs';
8
8
  import { buildDoctorReport } from '../app/doctor.mjs';
9
+ import { recomputePromptHistory } from './prompt-history.mjs';
10
+ import { buildMergedPromptHistory, loadPromptHistory } from '../prompt-history-store.mjs';
9
11
 
10
12
  export function createEngineApi(bag) {
11
13
  return { ...createEngineApiA(bag), ...createEngineApiB(bag) };
@@ -225,7 +227,10 @@ export function createEngineApiA(bag) {
225
227
  },
226
228
  setCwd: (path, options = {}) => {
227
229
  const next = runtime.setCwd(path);
228
- set({ cwd: next });
230
+ // Republish up-arrow history for the NEW project: current session prompts
231
+ // merged with the cwd-scoped persisted store for the new cwd.
232
+ const sessionList = recomputePromptHistory(getState().items);
233
+ set({ cwd: next, promptHistoryList: buildMergedPromptHistory(sessionList, loadPromptHistory(next)) });
229
234
  if (options?.notice !== false) {
230
235
  pushNotice(options?.message || `Project set: ${projectNameFromPath(next)}`, 'info');
231
236
  }
@@ -286,17 +291,27 @@ export function createEngineApiA(bag) {
286
291
  }
287
292
  },
288
293
  setMcpServerEnabled: async (name, enabled) => {
289
- if (getState().commandBusy) return null;
290
- set({ commandBusy: true });
291
- try {
292
- const status = await runtime.setMcpServerEnabled?.(name, enabled);
294
+ // No global commandBusy: the runtime adopts config synchronously and
295
+ // serializes the heavy connect/close/recreate per server name, so rapid
296
+ // re-toggles converge instead of being dropped. This awaits the
297
+ // background chain purely to settle the picker on completion/failure.
298
+ const status = await runtime.setMcpServerEnabled?.(name, enabled);
299
+ // The context re-estimate is a full-transcript token count; defer it off
300
+ // the interactive frame so it never runs inside the toggle key handler.
301
+ setImmediate(() => {
293
302
  resetStatsAndSyncContext();
294
303
  set({ ...routeState(), stats: { ...getState().stats } });
304
+ });
305
+ // A connect failure resolves as a status object (not a throw), so inspect
306
+ // this server's row before claiming success: enabling can fail on spawn/
307
+ // handshake. Disabling never spawns, so it is always a success.
308
+ const row = status?.servers?.find((s) => s.name === name);
309
+ if (enabled && row && (row.status === 'failed' || row.error)) {
310
+ pushNotice(`mcp enable failed: ${name}${row.error ? ` — ${row.error}` : ''}`, 'error');
311
+ } else {
295
312
  pushNotice(`mcp ${enabled ? 'enabled' : 'disabled'}: ${name}`, 'info');
296
- return status;
297
- } finally {
298
- set({ commandBusy: false });
299
313
  }
314
+ return status;
300
315
  },
301
316
  getDisabledSkills: () => runtime.getDisabledSkills?.() || { disabled: [] },
302
317
  setDisabledSkills: (disabled) => runtime.setDisabledSkills?.(disabled) || { disabled: [] },
@@ -15,6 +15,12 @@ export function createSessionFlow(bag) {
15
15
  // runtime.clear() resolve only after compaction finishes; without a bound a
16
16
  // stalled compaction wedges autoClearRunning/commandBusy forever, which
17
17
  // suppresses the input drain. On timeout we abandon this attempt.
18
+ // NOTE: this bounds how long the INPUT stays blocked (commandBusy), not the
19
+ // compaction itself — the clear path's worst case (recall cold retries ~38s
20
+ // + size-scaled semantic up to 120s) may exceed it, and that is fine: the
21
+ // abandoned promise keeps running and the late-fulfillment path below
22
+ // (autoClearInFlight / pendingClearedSessionUi) applies the clear when it
23
+ // settles. Do NOT raise this to cover compaction worst cases.
18
24
  const AUTO_CLEAR_COMPACT_TIMEOUT_MS = 60_000;
19
25
 
20
26
  const leadSessionId = () => runtime.id;
@@ -10,9 +10,10 @@
10
10
  * the factory argument (getters/callbacks) — never stale snapshots. Every body
11
11
  * is the original engine.mjs logic verbatim.
12
12
  */
13
- import { summarizeToolResult } from '../../runtime/shared/tool-surface.mjs';
13
+ import { summarizeToolResult, aggregateDoneCategories, classifyToolCategory } from '../../runtime/shared/tool-surface.mjs';
14
14
  import { toolResultText, toolErrorDisplay, toolGroupedDisplayFallback } from './tool-result-text.mjs';
15
15
  import { toolResultCallId } from './tool-call-fields.mjs';
16
+ import { memoryCoreResultErrorText } from '../app/input-parsers.mjs';
16
17
  import { parseAgentJob, agentArgsWithResultMetadata, toolResultStatus, isErrorToolStatus } from './agent-envelope.mjs';
17
18
  import {
18
19
  withCancelledResultMarker,
@@ -55,13 +56,19 @@ export function createToolCardResults({
55
56
  // card in order, so transcript order always matches call order.
56
57
  (card.aggregate?.ensureVisible || card.ensureVisible)?.();
57
58
  const rawText = toolResultText(message?.content);
58
- const isError = message?.isError === true || message?.toolKind === 'error' || /^\s*\[?error/i.test(rawText) || isErrorToolStatus(toolResultStatus(rawText));
59
- const text = isError ? toolErrorDisplay(rawText, card?.name || 'tool') : rawText;
60
-
61
59
  // Aggregate card handling — collect semantic summaries per call
62
60
  const aggregate = card.aggregate;
61
+ const callRec = aggregate && callId ? aggregate.calls.get(callId) : null;
62
+ // Backend "core" memory-op failures are flattened to plain text (isError
63
+ // dropped upstream — see memoryCoreResultErrorText); recover that signal so
64
+ // failed memory writes are excluded from the done count. Gated to Memory
65
+ // calls: the text-matcher's ^(error|failed) catch-all would otherwise
66
+ // misflag legitimate non-memory success output.
67
+ const isMemoryCall = classifyToolCategory(callRec?.name || card?.name || '', callRec?.args || {}) === 'Memory';
68
+ const isError = message?.isError === true || message?.toolKind === 'error' || /^\s*\[?error/i.test(rawText) || isErrorToolStatus(toolResultStatus(rawText)) || (isMemoryCall && memoryCoreResultErrorText(rawText) != null);
69
+ const text = isError ? toolErrorDisplay(rawText, card?.name || 'tool') : rawText;
70
+
63
71
  if (aggregate && card.itemId === aggregate.itemId) {
64
- const callRec = callId ? aggregate.calls.get(callId) : null;
65
72
  if (!callRec) return false;
66
73
  if (callRec.resolved) {
67
74
  card.done = true;
@@ -98,6 +105,7 @@ export function createToolCardResults({
98
105
  errorCount: errors,
99
106
  count: allCalls.length,
100
107
  completedCount: visualCompleted,
108
+ doneCategories: aggregateDoneCategories(allCalls),
101
109
  completedAt: Number(currentItem?.completedAt) || Date.now(),
102
110
  });
103
111
  card.done = true;
@@ -223,6 +231,7 @@ export function createToolCardResults({
223
231
  errorCount: errors,
224
232
  count: allCalls.length,
225
233
  completedCount: totalCompleted,
234
+ doneCategories: aggregateDoneCategories(allCalls),
226
235
  completedAt: Date.now(),
227
236
  });
228
237
  for (const sibling of toolCards || []) {
@@ -1,13 +1,14 @@
1
1
  /**
2
2
  * src/tui/engine/turn.mjs - lead TUI turn engine (createRunTurn). Extracted from engine.mjs.
3
3
  */
4
- import { aggregateToolCategoryEntry, classifyToolCategory, formatAggregateDetail, summarizeToolResult } from '../../runtime/shared/tool-surface.mjs';
4
+ import { aggregateToolCategoryEntry, aggregateDoneCategories, classifyToolCategory, formatAggregateDetail, summarizeToolResult } from '../../runtime/shared/tool-surface.mjs';
5
5
  import { applyUsageDelta } from './session-stats.mjs';
6
6
  import { pickVerb, pickDoneVerb, compactEventLabel, compactEventDetail } from './labels.mjs';
7
7
  import { toolResultText, toolErrorDisplay } from './tool-result-text.mjs';
8
8
  import { toolCallId, toolResultCallId, toolCallName, toolCallArgs } from './tool-call-fields.mjs';
9
9
  import { toolResultStatus, isErrorToolStatus } from './agent-envelope.mjs';
10
10
  import { promptDisplayText } from './queue-helpers.mjs';
11
+ import { memoryCoreResultErrorText } from '../app/input-parsers.mjs';
11
12
  import { yieldToRenderer } from './render-timing.mjs';
12
13
  import { aggregateRawResult, aggregateBucketForCategory, aggregateSummaries, assignAggregateSummaryOrder } from './tool-result-status.mjs';
13
14
 
@@ -299,6 +300,7 @@ export function createRunTurn(bag) {
299
300
  isError: errors > 0,
300
301
  count: allCalls.length,
301
302
  completedCount: allCalls.length,
303
+ doneCategories: aggregateDoneCategories(allCalls),
302
304
  completedAt: Date.now(),
303
305
  });
304
306
  }
@@ -605,7 +607,12 @@ export function createRunTurn(bag) {
605
607
  if (!callRec || callRec.resolved || callRec.completedEarly) return;
606
608
  aggregate.ensureVisible?.();
607
609
  const rawText = toolResultText(message?.content);
608
- const isError = message?.isError === true || message?.toolKind === 'error' || /^\s*\[?error/i.test(rawText) || isErrorToolStatus(toolResultStatus(rawText));
610
+ // Recover flattened memory "core" op failures (isError dropped upstream)
611
+ // so they are excluded from the done count — see memoryCoreResultErrorText.
612
+ // Gated to Memory calls: the text-matcher's ^(error|failed) catch-all
613
+ // would otherwise misflag legitimate non-memory success output.
614
+ const isMemoryCall = classifyToolCategory(callRec.name, callRec.args) === 'Memory';
615
+ const isError = message?.isError === true || message?.toolKind === 'error' || /^\s*\[?error/i.test(rawText) || isErrorToolStatus(toolResultStatus(rawText)) || (isMemoryCall && memoryCoreResultErrorText(rawText) != null);
609
616
  const text = isError ? toolErrorDisplay(rawText, callRec.name || 'tool') : rawText;
610
617
  callRec.summary = !isError ? summarizeToolResult(callRec.name, callRec.args, rawText, isError) : null;
611
618
  assignAggregateSummaryOrder(aggregate, callRec);
@@ -21,6 +21,7 @@ import {
21
21
  isModelVisibleToolCompletionWrapper,
22
22
  isLikelyToolCompletionWrapper,
23
23
  } from '../runtime/shared/tool-execution-contract.mjs';
24
+ import { isLateToolAnnouncement } from '../session-runtime/session-text.mjs';
24
25
  import { presentErrorText } from '../runtime/shared/err-text.mjs';
25
26
  import { listThemes, getThemeSetting, setThemeSetting } from './theme.mjs';
26
27
  import { resetAllStreamingMarkdownStablePrefixes } from './markdown/streaming-markdown.mjs';
@@ -88,6 +89,11 @@ import {
88
89
  } from './engine/tui-steering-persist.mjs';
89
90
  import { createContextState } from './engine/context-state.mjs';
90
91
  import { recomputePromptHistory } from './engine/prompt-history.mjs';
92
+ import {
93
+ appendPromptHistory,
94
+ buildMergedPromptHistory,
95
+ loadPromptHistory,
96
+ } from './prompt-history-store.mjs';
91
97
  import { createSessionFlow } from './engine/session-flow.mjs';
92
98
  import { createRunTurn } from './engine/turn.mjs';
93
99
  import { createEngineApi } from './engine/session-api.mjs';
@@ -215,7 +221,9 @@ export async function createEngineSession({
215
221
  // - promptHistoryList: newest-first deduped user-prompt history, rebuilt
216
222
  // only when a user item is appended (replaces the per-change rescan).
217
223
  activeToolSummary: null,
218
- promptHistoryList: [],
224
+ // Seed from the persisted cwd-scoped store so up-arrow history is available
225
+ // on a fresh start, before any bulk swap / first submit republishes it.
226
+ promptHistoryList: buildMergedPromptHistory([], loadPromptHistory(cwd)),
219
227
  ...baseRouteState(),
220
228
  displayContextWindow: 0,
221
229
  compactBoundaryTokens: 0,
@@ -279,7 +287,12 @@ export async function createEngineSession({
279
287
  // emit (the accompanying set() diffs the full patch). A bulk swap also
280
288
  // discards the old transcript, so drop any tracked active tool calls.
281
289
  activeToolCalls.clear();
282
- state = { ...state, items: nextItems, promptHistoryList: recomputePromptHistory(nextItems), activeToolSummary: null };
290
+ state = {
291
+ ...state,
292
+ items: nextItems,
293
+ promptHistoryList: buildMergedPromptHistory(recomputePromptHistory(nextItems), loadPromptHistory(state.cwd)),
294
+ activeToolSummary: null,
295
+ };
283
296
  return nextItems;
284
297
  };
285
298
  // --- Prompt-history list (newest-first, deduped) maintained incrementally ---
@@ -305,7 +318,11 @@ export async function createEngineSession({
305
318
  if (rec.category === 'Explore') {
306
319
  exploreCount += c;
307
320
  if (started > 0 && (exploreStart === 0 || started < exploreStart)) exploreStart = started;
308
- } else if (rec.category === 'Search') {
321
+ } else if (rec.category === 'Web Research') {
322
+ // L2 "Web Searching" segment tracks WEB searches (search/web_fetch —
323
+ // category 'Web Research'), not local file search ('Search' =
324
+ // grep/find/glob/list). Local search is routine transcript noise and
325
+ // is intentionally NOT surfaced on the statusline.
309
326
  searchCount += c;
310
327
  if (started > 0 && (searchStart === 0 || started < searchStart)) searchStart = started;
311
328
  }
@@ -317,7 +334,7 @@ export async function createEngineSession({
317
334
  if (next !== prev) set({ activeToolSummary: next || null });
318
335
  };
319
336
  const markToolCallActive = (callKey, category, count, startedAt) => {
320
- if (!callKey || (category !== 'Explore' && category !== 'Search')) return;
337
+ if (!callKey || (category !== 'Explore' && category !== 'Web Research')) return;
321
338
  activeToolCalls.set(callKey, { category, count: Math.max(1, Number(count || 1)), startedAt: Number(startedAt || Date.now()) });
322
339
  recomputeActiveToolSummary();
323
340
  };
@@ -343,7 +360,7 @@ export async function createEngineSession({
343
360
  // publish items + the fresh list in ONE set(). Do NOT pre-assign to state
344
361
  // first — set() diffs against the current state, so a pre-assign would make
345
362
  // the references identical and skip emit().
346
- const promptHistoryList = recomputePromptHistory(items);
363
+ const promptHistoryList = buildMergedPromptHistory(recomputePromptHistory(items), loadPromptHistory(state.cwd));
347
364
  set({ items, promptHistoryList });
348
365
  } else {
349
366
  set({ items });
@@ -386,7 +403,16 @@ export async function createEngineSession({
386
403
  // detector only, same as before this change.
387
404
  if (origin === 'injected' && isLikelyToolCompletionWrapper(text)) return;
388
405
  if (isModelVisibleToolCompletionWrapper(text)) return;
406
+ // Late-MCP deferred-tool announcement (model-visible <system-reminder>):
407
+ // keep it in model context, but render NOTHING user-facing — not even the
408
+ // collapsed one-line notice (user request: hide late-tool notices entirely).
409
+ if (isLateToolAnnouncement(text)) return;
389
410
  if (upsertSyntheticToolItem(text, id)) return;
411
+ // Genuine, directly-typed/pasted user submissions only (never injected or
412
+ // synthetic paths, which returned above): persist to the cwd-scoped store so
413
+ // up-arrow history survives across sessions. Runs before pushItem so the
414
+ // merge in pushItem's user branch (loadPromptHistory) already sees it.
415
+ if (origin === 'user') appendPromptHistory(state.cwd, text);
390
416
  pushItem({ kind: 'user', id, text });
391
417
  };
392
418
  const pushToast = (text, tone = 'info', ttlMs = 3000) => {
@@ -505,6 +531,7 @@ export async function createEngineSession({
505
531
  getPending: () => pending,
506
532
  agentStatusState,
507
533
  displayedExecutionNotificationKeys,
534
+ pushNotice,
508
535
  });
509
536
  lifecycle.unsubscribeRuntimeNotifications = subscribeRuntimeNotifications();
510
537
 
package/src/tui/index.jsx CHANGED
@@ -10,19 +10,30 @@ import { closeSync, constants as fsConstants, createWriteStream, mkdirSync, open
10
10
  import { tmpdir } from 'node:os';
11
11
  import { dirname, join } from 'node:path';
12
12
  import { performance } from 'node:perf_hooks';
13
+ import { format } from 'node:util';
13
14
  import { App } from './App.jsx';
14
15
  import { createEngineSession } from './engine.mjs';
15
16
  import { installProcessSignalCleanup } from '../runtime/shared/process-shutdown.mjs';
16
- import { touchUiHeartbeat } from '../runtime/channels/lib/runtime-paths.mjs';
17
17
  import { emitTerminalBackground, loadThemeSettingFromConfig, theme } from './theme.mjs';
18
18
  import { POP_KITTY, DISABLE_MODIFY_OTHER_KEYS } from './keyboard-protocol.mjs';
19
19
  import { displayWidth } from './display-width.mjs';
20
20
  import { rotateBoundedLog, PLUGIN_LOG_MAX_BYTES, PLUGIN_LOG_KEEP_BYTES } from '../lib/mixdog-debug.cjs';
21
21
 
22
- const TERMINAL_MODE_RESET = '\x1b[?1006l\x1b[?1005l\x1b[?1015l\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?2004l\x1b[?25h';
22
+ // Trailing `\x1b[>0s` restores XTSHIFTESCAPE (shift-to-select-extend) to its
23
+ // terminal default; MOUSE_TRACKING_ON opts into `\x1b[>1s`, so every mouse/alt
24
+ // screen teardown that emits this reset also undoes that opt-in.
25
+ const TERMINAL_MODE_RESET = '\x1b[?1006l\x1b[?1005l\x1b[?1015l\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?2004l\x1b[>0s\x1b[?25h';
23
26
  const TERMINAL_OSC_RESET_BG = '\x1b]111\x07';
24
27
  const TERMINAL_MODE_RESET_HIDDEN_CURSOR = TERMINAL_MODE_RESET.replace('\x1b[?25h', '\x1b[?25l');
25
- const MOUSE_TRACKING_ON = '\x1b[?1000h\x1b[?1002h\x1b[?1006h';
28
+ // Trailing `\x1b[>1s` is XTSHIFTESCAPE: terminals that support it forward
29
+ // shift+click/drag to the app so our shift-extend selection paths work.
30
+ // Windows Terminal half-honors it — it forwards the shift events AND still
31
+ // paints its own native selection, so the user sees two overlapping
32
+ // highlights. Gate on WT_SESSION: in WT shift stays fully native (single
33
+ // highlight, native copy); ctrl+click/right-click remain the app-side
34
+ // extend triggers there. Restored via `\x1b[>0s` in TERMINAL_MODE_RESET.
35
+ const XTSHIFTESCAPE_ON = process.env.WT_SESSION ? '' : '\x1b[>1s';
36
+ const MOUSE_TRACKING_ON = `\x1b[?1000h\x1b[?1002h\x1b[?1006h${XTSHIFTESCAPE_ON}`;
26
37
  // Keyboard-protocol teardown. App.jsx enables kitty + modifyOtherKeys
27
38
  // synchronously at raw-mode-on (no query); here we just pop/disable them on
28
39
  // exit. POP_KITTY / DISABLE_MODIFY_OTHER_KEYS come from keyboard-protocol.mjs.
@@ -366,6 +377,36 @@ function installTuiStderrGuard() {
366
377
  };
367
378
  }
368
379
 
380
+ /**
381
+ * Route console.* to the guarded stderr (→ mixdog-tui.stderr.log) while the
382
+ * fullscreen TUI owns the terminal, and mount ink with patchConsole:false.
383
+ *
384
+ * Why: ink's patchConsole path (writeToStdout/writeToStderr) handles an
385
+ * intercepted console line by erasing the whole frame (log.clear()), writing
386
+ * the line, then re-writing the last frame RELATIVE to the cursor. On a
387
+ * fullscreen alt-screen frame that relative rewrite overflows the bottom row
388
+ * by exactly the stray line's height, so the terminal scrolls one line and
389
+ * the next incremental frame snaps it back — the visible "+1 line / -1 line"
390
+ * bounce during streaming (reproduced in a VT harness: one console.log mid-
391
+ * stream = scrollDelta 1, with or without the spinner). The stray text itself
392
+ * was already invisible (stderr guard files it), so the only user-visible
393
+ * effect of the whole dance WAS the bounce. Routing console output straight
394
+ * to the guarded stderr keeps the diagnostics AND skips ink's repaint dance.
395
+ */
396
+ function installTuiConsoleGuard() {
397
+ const methods = ['log', 'info', 'warn', 'error', 'debug', 'trace'];
398
+ const original = new Map();
399
+ for (const m of methods) {
400
+ original.set(m, console[m]);
401
+ console[m] = (...args) => {
402
+ try { process.stderr.write(`[console.${m}] ${format(...args)}\n`); } catch { /* ignore */ }
403
+ };
404
+ }
405
+ return () => {
406
+ for (const [m, fn] of original) console[m] = fn;
407
+ };
408
+ }
409
+
369
410
  export async function runTui({ provider, model, toolMode, remote, forceOnboarding } = {}) {
370
411
  const startedAt = performance.now();
371
412
  bootProfile('run:start', { provider, model, toolMode, remote });
@@ -380,6 +421,7 @@ export async function runTui({ provider, model, toolMode, remote, forceOnboardin
380
421
  }
381
422
 
382
423
  const restoreStderr = installTuiStderrGuard();
424
+ const restoreConsole = installTuiConsoleGuard();
383
425
  const stopPerfProbe = installTuiPerfProbe();
384
426
  const stopLoopProbe = installTuiLoopProbe();
385
427
  const restorePrimedInput = () => drainStdin(process.stdin);
@@ -438,6 +480,7 @@ export async function runTui({ provider, model, toolMode, remote, forceOnboardin
438
480
  stopLoopProbe();
439
481
  restoreTerminal();
440
482
  try { process.off('exit', restoreTerminal); } catch { /* ignore */ }
483
+ restoreConsole();
441
484
  restoreStderr();
442
485
  process.stderr.write(`mixdog: ${error?.message || error}\n`);
443
486
  return 1;
@@ -449,7 +492,7 @@ export async function runTui({ provider, model, toolMode, remote, forceOnboardin
449
492
  // Keep mouse handling app-owned by default: native terminal selections are
450
493
  // cleared by the fullscreen redraws that happen while a turn streams. Users
451
494
  // who prefer their terminal's native mouse behavior can opt out with
452
- // MIXDOG_TUI_MOUSE=0.
495
+ // MIXDOG_TUI_MOUSE=0 (mouse capture stays off at boot, no runtime toggle).
453
496
  const mouseTracking = !/^(0|false|no|off)$/i.test(String(process.env.MIXDOG_TUI_MOUSE || '1'));
454
497
  if (mouseTracking) {
455
498
  process.stdout.write(MOUSE_TRACKING_ON);
@@ -469,22 +512,11 @@ export async function runTui({ provider, model, toolMode, remote, forceOnboardin
469
512
  afterCleanup: (reason) => {
470
513
  restoreTerminal();
471
514
  dumpActiveHandles(`after-${reason}`);
515
+ restoreConsole();
472
516
  restoreStderr();
473
517
  },
474
518
  });
475
519
 
476
- // Zombie-Lead repro (2026-07-02): the render loop can wedge (all owning
477
- // PIDs still alive) leaving active-instance.json's pid-only staleness
478
- // check unable to detect it. Heartbeat every 30s so runtime-paths.mjs can
479
- // fall back to a ui_heartbeat_at staleness check when pids look fine.
480
- // Best-effort: a failed/late write just means the next tick catches up,
481
- // and the timer is unref'd so it never keeps the process alive on its own.
482
- const uiHeartbeatTimer = setInterval(() => {
483
- try { touchUiHeartbeat(); } catch { /* ignore */ }
484
- }, 30_000);
485
- if (typeof uiHeartbeatTimer.unref === 'function') uiHeartbeatTimer.unref();
486
- try { touchUiHeartbeat(); } catch { /* ignore */ }
487
-
488
520
  // Zombie-Lead repro (2026-07-02): stdio can die (TTY hangup, EPIPE on a
489
521
  // detached/piped stdout) without the process ever receiving SIGHUP/SIGTERM,
490
522
  // leaving a zombie renderer looping forever with nothing left to draw to.
@@ -527,7 +559,12 @@ export async function runTui({ provider, model, toolMode, remote, forceOnboardin
527
559
  // [render] incrementalRendering: line-diff repaint (only changed rows are
528
560
  // rewritten) instead of erase-all+rewrite per frame — removes the whole-
529
561
  // frame flash on surface transitions and slash palette open/close.
530
- const instance = render(<App store={store} forceOnboarding={forceOnboarding === true} />, { exitOnCtrlC: false, maxFps: 60, incrementalRendering: true, onRender: makeRenderProfiler() });
562
+ // patchConsole:false console.* is already routed to the stderr log by
563
+ // installTuiConsoleGuard above. Letting ink intercept it instead triggers
564
+ // its clear-frame → write → relative re-render dance, which scrolls the
565
+ // alt screen one line per stray console line (the streaming newline
566
+ // bounce). See installTuiConsoleGuard.
567
+ const instance = render(<App store={store} forceOnboarding={forceOnboarding === true} />, { exitOnCtrlC: false, maxFps: 60, incrementalRendering: true, patchConsole: false, onRender: makeRenderProfiler() });
531
568
  bootProfile('render:mounted', { ms: (performance.now() - startedAt).toFixed(1) });
532
569
  const { waitUntilExit } = instance;
533
570
  // [mixdog fork] Hand the ink renderer's drag-selection setter to the store so
@@ -550,11 +587,17 @@ export async function runTui({ provider, model, toolMode, remote, forceOnboardin
550
587
  if (mouseTracking && typeof instance.getSelectionRows === 'function') {
551
588
  store.getRenderSelectionRows = instance.getSelectionRows;
552
589
  }
590
+ // [mixdog] One-shot full clear+repaint. The app's mouse handler fires it on
591
+ // every button press under Windows Terminal to dismiss WT's persistent
592
+ // NATIVE (shift+drag) selection overlay, which survives incremental
593
+ // repaints and would otherwise sit on top of the app-drawn selection.
594
+ if (mouseTracking && typeof instance.forceFullRepaint === 'function') {
595
+ store.forceRenderRepaint = instance.forceFullRepaint;
596
+ }
553
597
  await waitUntilExit();
554
598
  } finally {
555
599
  stopPerfProbe();
556
600
  stopLoopProbe();
557
- clearInterval(uiHeartbeatTimer);
558
601
  for (const [stream, event, handler] of stdioDeathListeners.splice(0)) {
559
602
  try { stream.off(event, handler); } catch { /* ignore */ }
560
603
  }
@@ -566,6 +609,7 @@ export async function runTui({ provider, model, toolMode, remote, forceOnboardin
566
609
  }
567
610
  restoreTerminal();
568
611
  dumpActiveHandles('after-restore');
612
+ restoreConsole();
569
613
  restoreStderr();
570
614
  }
571
615
  scheduleHardExit(0);
@@ -1,4 +1,5 @@
1
1
  import { execFile } from 'node:child_process';
2
+ import { Buffer } from 'node:buffer';
2
3
  import { randomBytes } from 'node:crypto';
3
4
  import { existsSync, unlinkSync } from 'node:fs';
4
5
  import { readFile as readFileAsync, stat as statAsync } from 'node:fs/promises';
@@ -223,3 +224,28 @@ export async function readClipboardImageAttachment() {
223
224
  try { unlinkSync(file); } catch {}
224
225
  }
225
226
  }
227
+
228
+ // Cross-platform OS-clipboard TEXT read. Mirrors the per-OS command pattern in
229
+ // clipboard.mjs (writes) and readClipboardImageAttachment (reads). Returns '' on
230
+ // any miss/error so callers can cleanly fall through to the image path. Windows
231
+ // round-trips through base64 so non-ASCII survives the console codepage.
232
+ export async function readClipboardText() {
233
+ if (process.platform === 'win32') {
234
+ const ps = '$t=Get-Clipboard -Raw; if ($null -eq $t) { exit 2 }; [Console]::Out.Write([Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($t)))';
235
+ const r = await execFileBuffer('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', ps], { timeout: 3000 });
236
+ if (!r.ok || !r.stdout?.length) return '';
237
+ try { return Buffer.from(r.stdout.toString('ascii').trim(), 'base64').toString('utf8'); } catch { return ''; }
238
+ }
239
+ if (process.platform === 'darwin') {
240
+ const r = await execFileBuffer('pbpaste', [], { timeout: 3000 });
241
+ return r.ok && r.stdout?.length ? r.stdout.toString('utf8') : '';
242
+ }
243
+ if (process.platform === 'linux') {
244
+ const wl = await execFileBuffer('wl-paste', ['--no-newline'], { timeout: 3000 });
245
+ if (wl.ok && wl.stdout?.length) return wl.stdout.toString('utf8');
246
+ const xc = await execFileBuffer('xclip', ['-selection', 'clipboard', '-o'], { timeout: 3000 });
247
+ if (xc.ok && xc.stdout?.length) return xc.stdout.toString('utf8');
248
+ return '';
249
+ }
250
+ return '';
251
+ }
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { forEachSessionRuntime } from '../runtime/agent/orchestrator/session/manager.mjs';
10
10
  import { listHiddenAgentNames } from '../runtime/agent/orchestrator/internal-agents.mjs';
11
+ import { classifyToolCategory } from '../runtime/shared/tool-surface.mjs';
11
12
  import { num, GRN, R, B } from './statusline-format.mjs';
12
13
 
13
14
  const DEFAULT_HIDDEN_STATUSLINE_AGENTS = Object.freeze(['explorer', 'cycle1-agent', 'cycle2-agent', 'cycle3-agent', 'scheduler-task', 'webhook-handler']);
@@ -189,6 +190,41 @@ function isTerminalBridgeStatus(statusText) {
189
190
  return /idle|done|complete|success|closed|error|fail|cancel|killed|timeout/.test(String(statusText || '').toLowerCase());
190
191
  }
191
192
 
193
+ // Agent-side web search activity for the L2 "Web Searching" segment: any live
194
+ // sub-session owned by THIS lead (ownerSessionId / clientHostPid match) whose
195
+ // CURRENT tool call classifies as 'Web Research' (search/web_fetch/...). The
196
+ // lead's own calls are excluded — those arrive via activeTools from the
197
+ // transcript — so counts never double.
198
+ export function agentWebSearchStatus({ sessionId = '', clientHostPid = 0 } = {}) {
199
+ const ownerPid = positiveInt(clientHostPid);
200
+ const ownerSessionId = String(sessionId || '').trim();
201
+ let count = 0;
202
+ let startedAt = 0;
203
+ try {
204
+ for (const [runtimeSessionId, entry] of forEachSessionRuntime() || []) {
205
+ if (!entry || entry.closed === true) continue;
206
+ if (String(entry.stage || '').trim().toLowerCase() !== 'tool_running') continue;
207
+ const session = entry.session || null;
208
+ if (!session || session.closed === true) continue;
209
+ const id = session?.id || runtimeSessionId || null;
210
+ if (ownerSessionId && id === ownerSessionId) continue;
211
+ const sessionOwnerId = String(session?.ownerSessionId || '').trim();
212
+ if (sessionOwnerId && ownerSessionId && sessionOwnerId !== ownerSessionId) continue;
213
+ const pid = positiveInt(session?.clientHostPid);
214
+ if (ownerPid && pid && pid !== ownerPid) continue;
215
+ const tool = String(entry.lastToolCall || '').trim();
216
+ if (!tool) continue;
217
+ let category = null;
218
+ try { category = classifyToolCategory(tool); } catch { /* unknown tool -> skip */ }
219
+ if (category !== 'Web Research') continue;
220
+ count += 1;
221
+ const started = num(entry.toolStartedAt);
222
+ if (started > 0 && (startedAt === 0 || started < startedAt)) startedAt = started;
223
+ }
224
+ } catch {}
225
+ return { count, startedAt };
226
+ }
227
+
192
228
  function timeMs(value) {
193
229
  if (typeof value === 'number' && Number.isFinite(value) && value > 0) return value;
194
230
  const n = Date.parse(String(value || ''));