mixdog 0.9.95 → 0.9.96

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 (178) hide show
  1. package/LICENSES/Apache-2.0.txt +201 -0
  2. package/LICENSES/MIT.txt +56 -0
  3. package/LICENSES/codex-NOTICE.txt +6 -0
  4. package/NOTICE.md +46 -9
  5. package/package.json +12 -9
  6. package/scripts/.tmp-coverage-report.mjs +96 -0
  7. package/scripts/build-tui.mjs +17 -1
  8. package/scripts/fixtures/patch-replay-corpus.json +98 -0
  9. package/src/defaults/mixdog-config.template.json +5 -8
  10. package/src/rules/agent/30-explorer.md +7 -4
  11. package/src/rules/shared/01-tool.md +20 -29
  12. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +18 -2
  13. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +31 -1
  14. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +6 -17
  15. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -1
  16. package/src/runtime/agent/orchestrator/config.mjs +246 -67
  17. package/src/runtime/agent/orchestrator/mcp/client.mjs +26 -11
  18. package/src/runtime/agent/orchestrator/mcp/reconnect-singleflight.mjs +23 -0
  19. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +113 -14
  20. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +5 -1
  21. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +32 -11
  22. package/src/runtime/agent/orchestrator/providers/gemini.mjs +89 -2
  23. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -1
  24. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +19 -1
  25. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +144 -39
  26. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +22 -8
  27. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +15 -1
  28. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +1 -1
  29. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +3 -4
  30. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +67 -7
  31. package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +291 -0
  32. package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +21 -0
  33. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +28 -4
  34. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +4 -33
  35. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +13 -0
  36. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +2 -2
  37. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +0 -24
  38. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +1 -27
  39. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +6 -10
  40. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +5 -0
  41. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +142 -84
  42. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +4 -0
  43. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +31 -1
  44. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +29 -9
  45. package/src/runtime/agent/orchestrator/session/manager.mjs +2 -0
  46. package/src/runtime/agent/orchestrator/session/result-classification.mjs +65 -0
  47. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +86 -8
  48. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +12 -0
  49. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +3 -33
  50. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +4 -5
  51. package/src/runtime/agent/orchestrator/stall-policy.mjs +1 -1
  52. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +2 -2
  53. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +35 -1
  54. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +118 -18
  55. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +8 -9
  56. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +2 -2
  57. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +2 -2
  58. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +6 -15
  59. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +10 -26
  60. package/src/runtime/agent/orchestrator/tools/builtin/read-image.mjs +1 -1
  61. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +3 -3
  62. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +1 -23
  63. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +23 -9
  64. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +7 -0
  65. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +17 -39
  66. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +73 -0
  67. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
  68. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +0 -2
  69. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +3 -8
  70. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +30 -0
  71. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +5 -5
  72. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +87 -15
  73. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +6 -15
  74. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +2 -2
  75. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +83 -13
  76. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +39 -1
  77. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +29 -1
  78. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +16 -0
  79. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +108 -12
  80. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +10 -10
  81. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +56 -8
  82. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +2 -2
  83. package/src/runtime/channels/lib/config.mjs +2 -2
  84. package/src/runtime/channels/lib/inbound-handler.mjs +1 -1
  85. package/src/runtime/channels/lib/scheduler.mjs +2 -2
  86. package/src/runtime/memory/index.mjs +5 -38
  87. package/src/runtime/memory/lib/agent-ipc.mjs +145 -81
  88. package/src/runtime/memory/lib/compact-vector-cache.mjs +88 -0
  89. package/src/runtime/memory/lib/core-memory-store.mjs +18 -1
  90. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +12 -33
  91. package/src/runtime/memory/lib/embedding-provider.mjs +47 -26
  92. package/src/runtime/memory/lib/embedding-worker.mjs +30 -34
  93. package/src/runtime/memory/lib/http-router.mjs +8 -0
  94. package/src/runtime/memory/lib/ko-morph.mjs +3 -20
  95. package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -1
  96. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -0
  97. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -2
  98. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +1 -0
  99. package/src/runtime/memory/lib/memory-cycle3.mjs +3 -1
  100. package/src/runtime/memory/lib/pg/process.mjs +32 -9
  101. package/src/runtime/memory/tool-defs.mjs +1 -5
  102. package/src/runtime/search/tool-defs.mjs +2 -2
  103. package/src/runtime/shared/agent-route-config.mjs +119 -0
  104. package/src/runtime/shared/atomic-file.mjs +80 -4
  105. package/src/runtime/shared/channel-notification-routing.mjs +2 -2
  106. package/src/runtime/shared/child-guardian.mjs +204 -108
  107. package/src/runtime/shared/child-spawn-gate.mjs +60 -31
  108. package/src/runtime/shared/config.mjs +130 -44
  109. package/src/runtime/shared/resource-admission.mjs +146 -97
  110. package/src/runtime/shared/stream-progress.mjs +6 -0
  111. package/src/runtime/shared/tool-surface.mjs +0 -5
  112. package/src/runtime/shared/turn-snapshot.mjs +31 -5
  113. package/src/runtime/shared/turn-worktree-snapshot.mjs +14 -2
  114. package/src/session-runtime/config-helpers.mjs +39 -37
  115. package/src/session-runtime/model-route-api.mjs +7 -5
  116. package/src/session-runtime/provider-auth-api.mjs +0 -7
  117. package/src/session-runtime/runtime-core.mjs +11 -11
  118. package/src/session-runtime/runtime-tunables.mjs +4 -4
  119. package/src/session-runtime/session-lifecycle.mjs +9 -2
  120. package/src/session-runtime/session-turn-api.mjs +41 -9
  121. package/src/session-runtime/settings-api.mjs +5 -17
  122. package/src/session-runtime/tool-defs.mjs +3 -3
  123. package/src/session-runtime/workflow-agents-api.mjs +55 -81
  124. package/src/session-runtime/workflow.mjs +14 -24
  125. package/src/standalone/agent-dispatch-broker.mjs +198 -0
  126. package/src/standalone/agent-tool/helpers.mjs +2 -2
  127. package/src/standalone/agent-tool/spawn-flow.mjs +2 -0
  128. package/src/standalone/agent-tool/spawn-preset.mjs +11 -21
  129. package/src/standalone/agent-tool/tool-def.mjs +0 -14
  130. package/src/standalone/backend-daemon.mjs +97 -36
  131. package/src/standalone/channel-admin.mjs +12 -12
  132. package/src/standalone/channel-daemon-client.mjs +17 -1
  133. package/src/standalone/channel-daemon-transport.mjs +90 -8
  134. package/src/standalone/engine-daemon-client.mjs +380 -205
  135. package/src/standalone/engine-daemon-protocol.mjs +3 -4
  136. package/src/standalone/engine-daemon-service.mjs +378 -134
  137. package/src/standalone/engine-daemon-transport.mjs +268 -77
  138. package/src/standalone/explore-tool.mjs +187 -70
  139. package/src/standalone/fair-call-scheduler.mjs +264 -0
  140. package/src/standalone/session-protocol.mjs +188 -0
  141. package/src/tui/App.jsx +30 -17
  142. package/src/tui/app/app-view.jsx +5 -2
  143. package/src/tui/app/core-memory-picker.mjs +19 -19
  144. package/src/tui/app/doctor.mjs +5 -11
  145. package/src/tui/app/message-selector.mjs +103 -0
  146. package/src/tui/app/onboarding-steps.mjs +1 -2
  147. package/src/tui/app/project-picker.mjs +80 -56
  148. package/src/tui/app/prompt-submit.mjs +46 -41
  149. package/src/tui/app/resume-picker.mjs +2 -2
  150. package/src/tui/app/route-pickers.mjs +3 -1
  151. package/src/tui/app/settings-picker.mjs +6 -8
  152. package/src/tui/app/transcript-window.mjs +38 -0
  153. package/src/tui/app/use-prompt-draft-flow.mjs +5 -7
  154. package/src/tui/app/use-prompt-handlers.mjs +86 -21
  155. package/src/tui/app/use-prompt-hint.mjs +3 -2
  156. package/src/tui/app/use-prompt-queue-history.mjs +10 -3
  157. package/src/tui/app/use-transcript-scroll.mjs +9 -2
  158. package/src/tui/app/use-transcript-window.mjs +50 -17
  159. package/src/tui/components/PromptInput.jsx +71 -38
  160. package/src/tui/components/Spinner.jsx +1 -1
  161. package/src/tui/components/prompt-input/edit-helpers.mjs +9 -11
  162. package/src/tui/components/prompt-input/escape-policy.mjs +42 -0
  163. package/src/tui/components/prompt-input/immediate-render.mjs +0 -10
  164. package/src/tui/components/prompt-input/interrupt-policy.mjs +10 -0
  165. package/src/tui/components/prompt-input/restore-policy.mjs +10 -0
  166. package/src/tui/dist/index.mjs +920 -1592
  167. package/src/tui/engine/live-share.mjs +34 -0
  168. package/src/tui/engine/session-api-ext.mjs +1 -4
  169. package/src/tui/engine/session-api.mjs +103 -33
  170. package/src/tui/engine/session-flow.mjs +7 -1
  171. package/src/tui/engine/tool-card-results.mjs +4 -2
  172. package/src/tui/engine/tool-result-text.mjs +10 -0
  173. package/src/tui/engine/turn.mjs +9 -69
  174. package/src/tui/engine-local-session.mjs +47 -31
  175. package/src/tui/index.jsx +1 -2
  176. package/src/tui/spinner-meta.mjs +4 -4
  177. package/src/tui/spinner-verbs.mjs +51 -3
  178. package/src/standalone/engine-daemon-local-bridge.mjs +0 -20
@@ -709,3 +709,37 @@ export function createLiveShare({
709
709
  },
710
710
  };
711
711
  }
712
+
713
+ /**
714
+ * ONE viewer-submit intake for EVERY engine entry point. The TUI store calls
715
+ * submit(); the daemon awaits submitAsync() (engine-daemon-service prefers it).
716
+ * Wrapping only one of them let the other path enqueue the prompt on the VIEWER
717
+ * engine, so that surface rendered its own local user row beside the owner's
718
+ * mirrored one — the same message twice on screen
719
+ * (user: 데스크탑에서만 입력이 두 개로 들어간다).
720
+ *
721
+ * `spool(submissionId)` is the durable fallback. The submission id is minted
722
+ * here when the caller has none, so the pipe frame and the spool entry carry
723
+ * the SAME identity and the owner reuses it for the settled user row — which is
724
+ * what releases the submitting surface's optimistic bubble.
725
+ */
726
+ export function forwardViewerSubmit({ text, options = {}, share, spool, pid = process.pid }) {
727
+ const value = String(text || '').trim();
728
+ if (!value) return false;
729
+ // Reconcile first so a session that became attachable this event-loop turn
730
+ // takes the instant pipe path instead of the durable detour.
731
+ try { share.ensure?.(); } catch { /* durable fallback below */ }
732
+ const submissionId = options.id != null && String(options.id).trim()
733
+ ? String(options.id).trim()
734
+ : `view-submit-${pid}-${Date.now()}`;
735
+ const deliverDurably = () => spool?.(submissionId) === true;
736
+ // A pipe WRITE is not a delivery: the owner may refuse the prompt and the
737
+ // socket may die between write and read, so an unacknowledged submit is
738
+ // re-delivered through the spool — late, never lost.
739
+ if (share.sendSubmit(value, {
740
+ id: submissionId,
741
+ submittedAt: options.submittedAt,
742
+ onUndelivered: deliverDurably,
743
+ })) return true;
744
+ return deliverDurably();
745
+ }
@@ -623,9 +623,6 @@ export function createEngineApiB(bag) {
623
623
  setAgentRoute: async (agentId, opts) => {
624
624
  return await runtime.setAgentRoute?.(agentId, opts);
625
625
  },
626
- setDefaultProvider: async (provider) => {
627
- return await runtime.setDefaultProvider?.(provider);
628
- },
629
626
  listProviders: () => {
630
627
  return runtime.listProviders();
631
628
  },
@@ -883,7 +880,7 @@ export function createEngineApiB(bag) {
883
880
  renameSessionTitle: (id, title) => {
884
881
  return runtime.renameSessionTitle?.(id, title) ?? false;
885
882
  },
886
- // Desktop sidebar watcher hook: EngineHost fs.watches this directory so
883
+ // Desktop sidebar watcher hook: the backend watches this directory so
887
884
  // heartbeat sidecar create/delete pushes instant working/dot updates.
888
885
  // Without it the watcher silently no-ops and the sidebar falls back to
889
886
  // the 60s safety poll (user: spinner kept spinning after the turn ended).
@@ -7,8 +7,7 @@ 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
9
  import { recomputePromptHistory } from './prompt-history.mjs';
10
- import { buildMergedPromptHistory, loadPromptHistory } from '../prompt-history-store.mjs';
11
- import { touchProjectSelected } from '../../standalone/projects.mjs';
10
+ import { appendPromptHistory, buildMergedPromptHistory, loadPromptHistory, promptHistoryKey } from '../prompt-history-store.mjs';
12
11
 
13
12
  // Upper bound on how long a manual Esc abort may leave the store busy while the
14
13
  // in-flight runtime.ask() unwinds. runtime.abort() normally rejects the pending
@@ -33,7 +32,7 @@ export function createEngineApi(bag) {
33
32
  const snapshot = {
34
33
  autoClear: await read(api.getAutoClear),
35
34
  compaction: await read(api.getCompactionSettings),
36
- memory: await read(api.getMemorySettings),
35
+ recap: await read(api.getRecapSettings),
37
36
  channels: await read(api.getChannelSettings, { includeStatus: false }),
38
37
  systemShell: await read(api.getSystemShell),
39
38
  outputStyle: (await read(api.getOutputStyle)) || (await read(api.listOutputStyles)),
@@ -58,27 +57,34 @@ export function createEngineApiA(bag) {
58
57
  const {
59
58
  runtime, nextId, flags, pending, listeners, getState, getPublishedState = getState, set, flushEmitImmediate, pushItem, patchItem, replaceItems, restoreOlderTranscript, restoreNewerTranscript, settleStreamingTail, clearStreamingTail, pushNotice, autoClearState, agentStatusState, routeState, syncContextStats, denyAllToolApprovals, updateAgentJobCard, requeueEntriesFront, enqueue, autoClearBeforeSubmit, restoreQueued, resetStatsAndSyncContext, drain, flushDeferredExecutionPendingResumeKick, discardExecutionPendingResume,
60
59
  } = bag;
61
- const submit = (text, options = {}) => {
60
+ const submission = (text, options = {}) => {
62
61
  const t = promptDisplayText(text, options).trim();
63
- if (!t) return false;
62
+ if (!t) return null;
64
63
  const mode = options.mode || 'prompt';
65
- // Prompt input queued while a turn is active keeps the
66
- // default `next` priority, so it is injected at the next tool/model
67
- // boundary. Explicit options.priority still wins.
64
+ // Prompt input queued while a turn is active keeps the default `next`
65
+ // priority, so it is injected at the next tool/model boundary. Explicit
66
+ // options.priority still wins.
68
67
  const priority = options.priority;
69
- const queueOptions = {
70
- ...options,
71
- // Always mint a submission id so daemon retries / dual-path intake
72
- // can dedupe instead of booking the same prompt twice.
73
- id: String(options.id || '').trim() || nextId(),
74
- mode,
75
- displayText: promptDisplayText(text, options),
76
- priority,
68
+ return {
69
+ text,
70
+ queueOptions: {
71
+ ...options,
72
+ // Always mint a submission id so daemon retries / dual-path intake
73
+ // can dedupe instead of booking the same prompt twice.
74
+ id: String(options.id || '').trim() || nextId(),
75
+ mode,
76
+ displayText: promptDisplayText(text, options),
77
+ priority,
78
+ },
77
79
  };
80
+ };
81
+ const submit = (text, options = {}) => {
82
+ const intake = submission(text, options);
83
+ if (!intake) return false;
78
84
  // A running clear (idle auto-clear or session_manage) sets commandBusy;
79
85
  // queue the prompt instead of dropping it — it drains after the clear.
80
86
  if (flags.autoClearRunning) {
81
- return enqueue(text, queueOptions) !== false;
87
+ return enqueue(intake.text, intake.queueOptions) !== false;
82
88
  }
83
89
  // Any in-flight session command (clear/setModel/newSession/resume/...)
84
90
  // holds commandBusy. Previously the prompt was dropped here and only the
@@ -86,17 +92,31 @@ export function createEngineApiA(bag) {
86
92
  // commandBusy, and the central release hook re-kicks drain once the
87
93
  // command settles, so the prompt runs afterwards rather than vanishing.
88
94
  if (getState().commandBusy) {
89
- return enqueue(text, queueOptions) !== false;
95
+ return enqueue(intake.text, intake.queueOptions) !== false;
90
96
  }
91
97
  if (getState().busy) {
92
- return enqueue(text, queueOptions) !== false;
98
+ return enqueue(intake.text, intake.queueOptions) !== false;
93
99
  }
94
100
  // If autoClearBeforeSubmit rejects (e.g. compaction timeout throws), the
95
101
  // prompt must still be queued — swallow the rejection so enqueue always
96
102
  // runs and the submit is never silently lost.
97
- void autoClearBeforeSubmit().catch(() => {}).then(() => enqueue(text, queueOptions));
103
+ void autoClearBeforeSubmit().catch(() => {}).then(
104
+ () => enqueue(intake.text, intake.queueOptions),
105
+ );
98
106
  return true;
99
107
  };
108
+ const submitAsync = async (text, options = {}) => {
109
+ const intake = submission(text, options);
110
+ if (!intake) return false;
111
+ // Daemon intake needs an acknowledgement boundary stronger than the TUI's
112
+ // synchronous submit(): by the time this Promise resolves, the prompt is
113
+ // present either as a visible queue entry or as the first durable user row.
114
+ // Provider execution still runs detached through drain().
115
+ if (!flags.autoClearRunning && !getState().commandBusy && !getState().busy) {
116
+ await autoClearBeforeSubmit().catch(() => {});
117
+ }
118
+ return enqueue(intake.text, intake.queueOptions) !== false;
119
+ };
100
120
  return {
101
121
  getState: () => getPublishedState(),
102
122
  patchItem,
@@ -107,7 +127,7 @@ export function createEngineApiA(bag) {
107
127
  return () => listeners.delete(listener);
108
128
  },
109
129
  submit,
110
- submitAsync: async (text, options = {}) => submit(text, options),
130
+ submitAsync,
111
131
  reserveSession: (sessionId) => {
112
132
  const id = runtime.reserveSessionId?.(sessionId);
113
133
  if (!id) return false;
@@ -116,6 +136,46 @@ export function createEngineApiA(bag) {
116
136
  return String(getState().sessionId || '') === String(id);
117
137
  },
118
138
  restoreQueued,
139
+ // Claude Code's message selector ("jump back to a previous message"):
140
+ // rewind the conversation to just before a user prompt and hand its text
141
+ // back for editing. Idle-only — a live turn must be interrupted first.
142
+ rewindToItem: async (itemId) => {
143
+ const target = String(itemId ?? '').trim();
144
+ if (!target) return null;
145
+ if (getState().busy || getState().commandBusy) return null;
146
+ const items = getState().items || [];
147
+ const index = items.findIndex(
148
+ (item) => item?.kind === 'user' && String(item?.id) === target,
149
+ );
150
+ if (index < 0) return null;
151
+ const text = String(items[index]?.text || '').trim();
152
+ if (!text) return null;
153
+ set({ commandBusy: true });
154
+ try {
155
+ const rewound = await runtime.rewindMessages?.({ text });
156
+ if (!rewound) return null;
157
+ const restoreKey = promptHistoryKey(text);
158
+ set({
159
+ items: replaceItems(items.slice(0, index), {
160
+ preserveSpill: true,
161
+ preserveTranscriptView: true,
162
+ }),
163
+ spinner: null,
164
+ thinking: null,
165
+ lastTurn: null,
166
+ // The prompt returns to the draft, so it must not ALSO sit in the
167
+ // Up-arrow history — otherwise it shows up twice.
168
+ promptHistoryList: (getState().promptHistoryList || [])
169
+ .filter((entry) => promptHistoryKey(entry) !== restoreKey),
170
+ });
171
+ syncContextStats({ allowEstimated: true });
172
+ set({ stats: { ...getState().stats } });
173
+ flushEmitImmediate?.();
174
+ return { text, removed: items.length - index, messages: rewound.removed };
175
+ } finally {
176
+ set({ commandBusy: false });
177
+ }
178
+ },
119
179
  setModel: async (m) => {
120
180
  if (getState().commandBusy) return false;
121
181
  set({ commandBusy: true });
@@ -209,14 +269,14 @@ export function createEngineApiA(bag) {
209
269
  set({ commandBusy: false });
210
270
  }
211
271
  },
212
- getMemorySettings: () => {
213
- return runtime.getMemorySettings?.() || { enabled: true };
272
+ getRecapSettings: () => {
273
+ return runtime.getRecapSettings?.() || { enabled: true };
214
274
  },
215
- setMemoryEnabled: async (enabled) => {
275
+ setRecapEnabled: async (enabled) => {
216
276
  if (getState().commandBusy) return null;
217
277
  set({ commandBusy: true });
218
278
  try {
219
- const next = await runtime.setMemoryEnabled?.(enabled);
279
+ const next = await runtime.setRecapEnabled?.(enabled);
220
280
  set({ ...routeState(), stats: { ...getState().stats } });
221
281
  // Deferred for the same reason as setCompactionSettings above: keep
222
282
  // the recompute off the key-handler tick so the toggle repaints
@@ -296,12 +356,6 @@ export function createEngineApiA(bag) {
296
356
  },
297
357
  setCwd: (path, options = {}) => {
298
358
  const next = runtime.setCwd(path);
299
- // "Last opened" recency belongs to the ACTUAL cwd switch, not to one
300
- // caller: the project picker, its Current Path row and the /cwd prompt
301
- // all land here, so the project list stays ordered by what was really
302
- // opened last. Unregistered folders are ignored — this never registers
303
- // a project on its own (addProject remains the only registration path).
304
- try { touchProjectSelected(next); } catch { /* best-effort recency */ }
305
359
  // Republish up-arrow history for the NEW project: current session prompts
306
360
  // merged with the cwd-scoped persisted store for the new cwd.
307
361
  const sessionList = recomputePromptHistory(getState().items);
@@ -311,6 +365,15 @@ export function createEngineApiA(bag) {
311
365
  }
312
366
  return next;
313
367
  },
368
+ rememberPromptHistory: (value) => {
369
+ const text = String(value || '').trim();
370
+ if (!text) return false;
371
+ const persisted = appendPromptHistory(getState().cwd, text);
372
+ if (!persisted) return false;
373
+ const sessionList = recomputePromptHistory(getState().items);
374
+ set({ promptHistoryList: buildMergedPromptHistory(sessionList, persisted) });
375
+ return true;
376
+ },
314
377
  getSystemShell: () => {
315
378
  return runtime.getSystemShell?.() || runtime.systemShell || { source: 'auto', command: '', effective: '' };
316
379
  },
@@ -608,7 +671,7 @@ export function createEngineApiA(bag) {
608
671
  set({ commandBusy: false, commandStatus: null });
609
672
  }
610
673
  },
611
- abort: () => {
674
+ abort: (options = {}) => {
612
675
  if (!getState().busy) return false;
613
676
  denyAllToolApprovals('interrupted by user');
614
677
  const restoreState = flags.activePromptRestore;
@@ -616,7 +679,9 @@ export function createEngineApiA(bag) {
616
679
  // interrupting should just cancel the running turn and let the steering
617
680
  // prompt run next, NOT resurrect the in-flight prompt back into the draft.
618
681
  const hasPendingSteering = pending.some((entry) => isQueuedEntryEditable(entry));
619
- const canRestore = restoreState?.restorable && !hasPendingSteering;
682
+ const canRestore = options?.restorePrompt !== false
683
+ && restoreState?.restorable
684
+ && !hasPendingSteering;
620
685
  const restoreText = canRestore ? restoreState.text : '';
621
686
  const restorePastedImages = canRestore && restoreState?.pastedImages ? restoreState.pastedImages : null;
622
687
  const restorePastedTexts = canRestore && restoreState?.pastedTexts ? restoreState.pastedTexts : null;
@@ -644,6 +709,11 @@ export function createEngineApiA(bag) {
644
709
  restoreState.reclaimed = true;
645
710
  const idSet = new Set((restoreState.submittedIds || []).filter((id) => id != null));
646
711
  const patch = { spinner: null, thinking: null, lastTurn: null };
712
+ if (restoreText) {
713
+ const restoreKey = promptHistoryKey(restoreText);
714
+ patch.promptHistoryList = (getState().promptHistoryList || [])
715
+ .filter((entry) => promptHistoryKey(entry) !== restoreKey);
716
+ }
647
717
  if (idSet.size > 0) {
648
718
  const items = getState().items.filter((item) => !idSet.has(item?.id));
649
719
  if (items.length !== getState().items.length) {
@@ -560,7 +560,13 @@ export function createSessionFlow(bag) {
560
560
  removeQueuedEntries(queued);
561
561
  const queuedText = queued.map((item) => item.text).filter((text) => String(text || '').trim()).join('\n');
562
562
  const combinedText = [queuedText, String(currentText || '')].filter((text) => text.trim()).join('\n');
563
- return { count: queued.length, text: combinedText, pastedImages: mergePastedImages(queued), pastedTexts: mergePastedTexts(queued) };
563
+ return {
564
+ count: queued.length,
565
+ ids: queued.map((item) => String(item.id || '')).filter(Boolean),
566
+ text: combinedText,
567
+ pastedImages: mergePastedImages(queued),
568
+ pastedTexts: mergePastedTexts(queued),
569
+ };
564
570
  }
565
571
 
566
572
  const resetStats = () => {
@@ -11,7 +11,7 @@
11
11
  * is the original engine.mjs logic verbatim.
12
12
  */
13
13
  import { summarizeToolResult, aggregateDoneCategories } from '../../runtime/shared/tool-surface.mjs';
14
- import { toolResultText, toolErrorDisplay, toolGroupedDisplayFallback } from './tool-result-text.mjs';
14
+ import { toolResultText, toolErrorDisplay, toolGroupedDisplayFallback, stripShellExitHeader } from './tool-result-text.mjs';
15
15
  import { toolResultCallId } from './tool-call-fields.mjs';
16
16
  import { parseAgentJob } from './agent-envelope.mjs';
17
17
  import {
@@ -82,7 +82,9 @@ export function createToolCardResults({
82
82
  // outcomes remain successful calls with their raw/semantic result detail.
83
83
  const { exitCode, isExitError, isCallError } = toolCallOutcome(message, rawText);
84
84
  const isError = isCallError;
85
- const text = isError ? toolErrorDisplay(rawText, card?.name || 'tool') : rawText;
85
+ const text = isError
86
+ ? toolErrorDisplay(rawText, card?.name || 'tool')
87
+ : (isExitError ? stripShellExitHeader(rawText) : rawText);
86
88
 
87
89
  if (aggregate && card.itemId === aggregate.itemId) {
88
90
  if (!callRec) return false;
@@ -124,3 +124,13 @@ export function toolErrorDisplay(value, surface = 'tool') {
124
124
  }
125
125
  return /^error\s*:/i.test(text) ? text : `Error: ${text}`;
126
126
  }
127
+
128
+ // Display-only: a command-exit card already renders `Exit N` as its state, so
129
+ // the machine header (`Error: [shell-run-failed] [exit code: N]`) is redundant
130
+ // in the body — and the bare word "Error" reads as a tool failure even though
131
+ // the tool ran fine and the command merely exited non-zero. The model-facing
132
+ // result keeps the header untouched; only the rendered body drops it.
133
+ export function stripShellExitHeader(text) {
134
+ const body = String(text ?? '');
135
+ return body.replace(/(^|\n)(?:Error:\s*)?\[shell-run-failed\][^\n]*\n{0,2}/i, '$1');
136
+ }
@@ -4,7 +4,7 @@
4
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
- import { toolResultText, toolErrorDisplay } from './tool-result-text.mjs';
7
+ import { toolResultText, toolErrorDisplay, stripShellExitHeader } from './tool-result-text.mjs';
8
8
  import { toolCallId, toolResultCallId, toolCallName, toolCallArgs } from './tool-call-fields.mjs';
9
9
  import { promptDisplayText, STEERING_SUPPRESSED_DISPLAY } from './queue-helpers.mjs';
10
10
  import { TUI_FRAME_MS, yieldToRenderer } from './render-timing.mjs';
@@ -183,12 +183,6 @@ export function createRunTurn(bag) {
183
183
  const toolCards = [];
184
184
  const toolGroups = new Map();
185
185
  const resultsDone = new Set();
186
- // apply_patch+post_shell twin display: parent callId → synthetic shell
187
- // callId. The protocol stays ONE call/ONE result, but the transcript shows
188
- // TWO cards (edit + verify shell) so a long post_shell no longer looks
189
- // like a stuck 'Editing 1 file'. deliverToolResultMessage splits the
190
- // combined result text back between the two cards.
191
- const postShellTwins = new Map();
192
186
  // ── Live shell-output tail → running tool card ─────────────────────────
193
187
  // 1 s poll of orchestrator liveness while this turn runs. When exactly one
194
188
  // standalone (non-aggregate) card is unresolved and the runtime reports a
@@ -748,7 +742,9 @@ export function createRunTurn(bag) {
748
742
  // the provider's isError/error-tool envelope drives failure counts/red.
749
743
  const { exitCode, isExitError, isCallError } = toolCallOutcome(message, rawText);
750
744
  const isError = isCallError;
751
- const text = isError ? toolErrorDisplay(rawText, callRec.name || 'tool') : rawText;
745
+ const text = isError
746
+ ? toolErrorDisplay(rawText, callRec.name || 'tool')
747
+ : (isExitError ? stripShellExitHeader(rawText) : rawText);
752
748
  callRec.summary = !isError ? summarizeToolResult(callRec.name, callRec.args, rawText, isError) : null;
753
749
  assignAggregateSummaryOrder(aggregate, callRec);
754
750
  callRec.isError = isError;
@@ -805,51 +801,9 @@ export function createRunTurn(bag) {
805
801
  const earlyCallId = toolResultCallId(message);
806
802
  if (earlyCallId) {
807
803
  markToolCardCompletedState(earlyCallId, message);
808
- const earlyTwin = postShellTwins.get(earlyCallId);
809
- if (earlyTwin) markToolCardCompletedState(earlyTwin, message);
810
804
  }
811
805
  return;
812
806
  }
813
- const parentCallId = toolResultCallId(message);
814
- const twinCallId = parentCallId ? postShellTwins.get(parentCallId) : null;
815
- if (twinCallId && !resultsDone.has(twinCallId)) {
816
- const combined = String(toolResultText(message?.content) ?? '');
817
- // tool-exec composes `${patch}\n\n${header}\n${shell}` (or appends the
818
- // skipped marker to a failed patch). Split on those exact seams.
819
- const headerMatch = combined.match(/\n+--- post_shell( FAILED[^\n]*)? ---\n?/);
820
- const skippedMatch = headerMatch ? null : combined.match(/\n+--- post_shell skipped: patch failed ---\s*$/);
821
- let patchPart = combined;
822
- let shellPart = null;
823
- if (headerMatch) {
824
- patchPart = combined.slice(0, headerMatch.index);
825
- shellPart = combined.slice(headerMatch.index + headerMatch[0].length);
826
- // Keep the FAILED banner on the shell card so its error state and
827
- // fix-and-re-verify guidance survive the split.
828
- if (headerMatch[1]) shellPart = `Error: post_shell FAILED (patch is applied; fix and re-verify)\n${shellPart}`;
829
- } else if (skippedMatch) {
830
- patchPart = combined.slice(0, skippedMatch.index);
831
- shellPart = 'Error: post_shell skipped: patch failed';
832
- }
833
- if (shellPart !== null) {
834
- flushToolResults([
835
- {
836
- role: 'tool',
837
- tool_call_id: parentCallId,
838
- content: patchPart,
839
- ...(Object.hasOwn(message || {}, 'uiDiff') ? { uiDiff: message.uiDiff } : {}),
840
- },
841
- { role: 'tool', tool_call_id: twinCallId, content: shellPart },
842
- ], toolCards, cardByCallId, toolGroups, resultsDone);
843
- return;
844
- }
845
- // Unsplittable (offloaded/preview-shaped result): parent keeps the
846
- // combined text; settle the twin instead of leaving it Running.
847
- flushToolResults([
848
- message,
849
- { role: 'tool', tool_call_id: twinCallId, content: '(post_shell output included in the patch result above)' },
850
- ], toolCards, cardByCallId, toolGroups, resultsDone);
851
- return;
852
- }
853
807
  flushToolResults([message], toolCards, cardByCallId, toolGroups, resultsDone);
854
808
  };
855
809
 
@@ -918,24 +872,7 @@ export function createRunTurn(bag) {
918
872
  }
919
873
  const batchCalls = (calls || []).filter(Boolean);
920
874
  if (batchCalls.length === 0) return;
921
- // Expand each apply_patch carrying post_shell into itself PLUS a
922
- // synthetic trailing shell call, so the (possibly long) verify phase
923
- // renders as its own Running command card in call order.
924
- const displayCalls = [];
925
- for (const c of batchCalls) {
926
- displayCalls.push(c);
927
- if (toolCallName(c) !== 'apply_patch') continue;
928
- let patchCallArgs = toolCallArgs(c);
929
- if (typeof patchCallArgs === 'string') {
930
- try { patchCallArgs = JSON.parse(patchCallArgs); } catch { patchCallArgs = null; }
931
- }
932
- const postShellCmd = typeof patchCallArgs?.post_shell === 'string' ? patchCallArgs.post_shell.trim() : '';
933
- const parentCallId = toolCallId(c);
934
- if (!postShellCmd || !parentCallId) continue;
935
- const twinCallId = `${parentCallId}::post_shell`;
936
- postShellTwins.set(parentCallId, twinCallId);
937
- displayCalls.push({ id: twinCallId, name: 'shell', args: { command: postShellCmd } });
938
- }
875
+ const displayCalls = batchCalls;
939
876
  const agentBatch = ++providerToolBatch;
940
877
  const committedAssistantSegment = commitAssistantSegment({ sealToolBlock: true });
941
878
  if (committedAssistantSegment) {
@@ -1230,8 +1167,11 @@ export function createRunTurn(bag) {
1230
1167
  if (!isCurrentTurn() || watchdog.tripped) return;
1231
1168
  if (String(chunk ?? '')) {
1232
1169
  if (!markTurnProgress('reasoning-delta')) return;
1233
- markPromptCommitted();
1234
1170
  }
1171
+ // Reasoning is NOT a commit boundary (Claude Code parity): a turn
1172
+ // interrupted while it is still thinking produced no model-visible
1173
+ // message, so Esc must hand the prompt back to the input box. Text,
1174
+ // tool calls and turn end still commit it below.
1235
1175
  startThinkingSegment();
1236
1176
  thinkingText += String(chunk ?? '');
1237
1177
  // Accumulate reasoning text; fire at most one render per STREAM_BATCH_INTERVAL_MS.
@@ -109,7 +109,7 @@ import { createSessionFlow } from './engine/session-flow.mjs';
109
109
  import { createRunTurn } from './engine/turn.mjs';
110
110
  import { createEngineApi } from './engine/session-api.mjs';
111
111
  import { createFrameBatchedStorePublisher } from './engine/frame-batched-store.mjs';
112
- import { createLiveShare, liveSharePipePath } from './engine/live-share.mjs';
112
+ import { createLiveShare, forwardViewerSubmit, liveSharePipePath } from './engine/live-share.mjs';
113
113
  import { displayModelName } from '../ui/model-display.mjs';
114
114
 
115
115
  const SESSION_RUNTIME_MODULE = '../mixdog-session-runtime.mjs';
@@ -132,6 +132,17 @@ export function preloadSessionRuntimeModule() {
132
132
  });
133
133
  }
134
134
 
135
+ let agentLoopPrewarmPromise = null;
136
+ export function preloadAgentLoopRuntime() {
137
+ agentLoopPrewarmPromise ??= import(
138
+ '../runtime/agent/orchestrator/session/manager/runtime-loaders.mjs'
139
+ ).then((module) => module.prewarmAgentLoop());
140
+ void agentLoopPrewarmPromise.catch(() => {
141
+ // Reservation-time prewarm retries through the same shared loader.
142
+ agentLoopPrewarmPromise = null;
143
+ });
144
+ }
145
+
135
146
  // Windows keychain reads go through a DPAPI PowerShell host whose cold start
136
147
  // dominates a packaged boot (measured ~1.5s). The runtime batches every secret
137
148
  // into one call, but it only starts that batch when the runtime is created —
@@ -948,38 +959,43 @@ export async function createLocalEngineSession({
948
959
  // owner frames own stats/agent/tool state (see runtimePulseTimer above).
949
960
  bag.liveShareMirroring = () => state.sessionRemoteAttached && liveShare.viewerConnected();
950
961
  // Live viewer submits ride the owner's pipe (instant user bubble + shared
951
- // streaming); the durable spool path below remains the fallback.
962
+ // streaming); the durable spool remains the fallback. Returns null when this
963
+ // surface is NOT an attached viewer, so the local engine keeps the prompt.
964
+ const viewerSubmitIntake = (prompt, options = {}) => {
965
+ if (!state.sessionRemoteAttached) return null;
966
+ const text = String(promptDisplayText(prompt, options) || '').trim();
967
+ if (!text) return { accepted: false };
968
+ return {
969
+ accepted: forwardViewerSubmit({
970
+ text,
971
+ options,
972
+ share: liveShare,
973
+ // Writing to the owner's spool instead of starting a fake local turn
974
+ // that would render an error/synthetic assistant message here.
975
+ spool: (submissionId) => runtime.enqueueRemoteAttachedPrompt?.({
976
+ content: prompt,
977
+ text,
978
+ id: submissionId,
979
+ }) === true,
980
+ }),
981
+ };
982
+ };
952
983
  if (typeof api.submit === 'function') {
953
984
  const baseSubmit = api.submit;
954
985
  api.submit = (prompt, options = {}) => {
955
- if (state.sessionRemoteAttached) {
956
- const text = String(promptDisplayText(prompt, options) || '').trim();
957
- if (!text) return false;
958
- // Reconcile first so a session that became attachable this event-loop
959
- // turn uses the instant pipe path. If the pipe is still opening, write
960
- // directly to the durable owner spool instead of starting a fake local
961
- // turn that renders an error/synthetic assistant message.
962
- try { liveShare.ensure(); } catch { /* durable fallback below */ }
963
- const submissionId = options.id != null && String(options.id).trim()
964
- ? String(options.id).trim()
965
- : `view-submit-${process.pid}-${Date.now()}`;
966
- const spoolFallback = () => runtime.enqueueRemoteAttachedPrompt?.({
967
- content: prompt,
968
- text: text,
969
- id: submissionId,
970
- }) === true;
971
- // A pipe WRITE is not a delivery: the owner may refuse the prompt or
972
- // the socket may die between write and read. sendSubmit stays
973
- // synchronous (the store contract), but an unacknowledged submit is
974
- // re-delivered through the durable spool — late, never lost.
975
- if (text && liveShare.sendSubmit(text, {
976
- id: submissionId,
977
- submittedAt: options.submittedAt,
978
- onUndelivered: spoolFallback,
979
- })) return true;
980
- return spoolFallback();
981
- }
982
- return baseSubmit(prompt, options);
986
+ const forwarded = viewerSubmitIntake(prompt, options);
987
+ return forwarded ? forwarded.accepted : baseSubmit(prompt, options);
988
+ };
989
+ }
990
+ // The daemon's intake boundary is submitAsync (engine-daemon-service prefers
991
+ // it over submit). Leaving it unwrapped let a daemon-hosted VIEWER — the
992
+ // desktop pane on a session the terminal owns — queue the prompt locally, so
993
+ // its own user row stood beside the owner's mirrored twin.
994
+ if (typeof api.submitAsync === 'function') {
995
+ const baseSubmitAsync = api.submitAsync;
996
+ api.submitAsync = async (prompt, options = {}) => {
997
+ const forwarded = viewerSubmitIntake(prompt, options);
998
+ return forwarded ? forwarded.accepted : baseSubmitAsync(prompt, options);
983
999
  };
984
1000
  }
985
1001
  // Viewer stop button: the local engine has no in-flight turn to cancel —
@@ -1010,7 +1026,7 @@ export async function createLocalEngineSession({
1010
1026
  reconcileLiveShareNow();
1011
1027
  if (method === 'resume' && result === true && state.sessionRemoteAttached) {
1012
1028
  const id = String(state.sessionId || '');
1013
- // EngineHost holds renderer publications across resume. Keep that hold
1029
+ // The session projection holds renderer publications across resume. Keep that hold
1014
1030
  // until the owner's first FULL frame replaces the persisted transcript,
1015
1031
  // then synchronously publish the complete draft before getState().
1016
1032
  // A healthy local owner serves its full frame on connect within tens
package/src/tui/index.jsx CHANGED
@@ -30,7 +30,6 @@ import { POP_KITTY, DISABLE_MODIFY_OTHER_KEYS } from './keyboard-protocol.mjs';
30
30
  import { displayWidth } from './display-width.mjs';
31
31
  import { rotateBoundedLog, PLUGIN_LOG_MAX_BYTES, PLUGIN_LOG_KEEP_BYTES } from '../lib/mixdog-debug.cjs';
32
32
  import { localPackageVersion } from '../runtime/shared/update-checker.mjs';
33
- import { touchProjectSelected } from '../standalone/projects.mjs';
34
33
 
35
34
  // Trailing `\x1b[>0s` restores XTSHIFTESCAPE (shift-to-select-extend) to its
36
35
  // terminal default; MOUSE_TRACKING_ON opts into `\x1b[>1s`, so every mouse/alt
@@ -517,7 +516,7 @@ export async function runTui({ provider, model, toolMode, remote, forceOnboardin
517
516
  // this, a project entered straight from the shell never moved in the
518
517
  // picker's most-recent-first order. Unregistered folders are ignored.
519
518
  try {
520
- touchProjectSelected(store.getState?.()?.cwd || process.cwd());
519
+ await store.touchProjectSelected(store.getState?.()?.cwd || process.cwd());
521
520
  } catch { /* best-effort recency */ }
522
521
  // The palette lives in THIS process: on a daemon-backed store the engine's
523
522
  // theme methods would run inside the daemon, so the picker saw a promise
@@ -9,9 +9,6 @@
9
9
  * for 30s. One builder, one answer.
10
10
  */
11
11
 
12
- /** Short turns stay quiet: elapsed/token meta only after this long. */
13
- export const SHOW_TOKENS_AFTER_MS = 30_000;
14
-
15
12
  const compactNumberFormatter = new Intl.NumberFormat('en-US', {
16
13
  notation: 'compact',
17
14
  maximumFractionDigits: 1,
@@ -62,7 +59,10 @@ export function buildSpinnerMeta({
62
59
  const thinkingText = spinnerThinkingLabel({ thinking, thinkingSince, thinkingMs, effort });
63
60
  return {
64
61
  tokensText: tokens > 0 ? `${formatSpinnerTokens(tokens)} tokens` : '',
65
- showTokens: tokens > 0 && (Boolean(verbose) || Number(elapsedMs) > SHOW_TOKENS_AFTER_MS),
62
+ // The count appears with the FIRST token (user: 30초가 아니라 바로).
63
+ // Elapsed was never gated, so the byline used to grow a second field
64
+ // mid-turn for no reason the reader could see; now both arrive together.
65
+ showTokens: tokens > 0,
66
66
  thinkingText,
67
67
  thinkingActive: Boolean(thinking || Number(thinkingSince) > 0),
68
68
  };