mixdog 0.9.147 → 0.9.149

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 (265) hide show
  1. package/package.json +23 -3
  2. package/scripts/build-voice-runtime-unix.sh +101 -0
  3. package/scripts/embedding-quality-probe.mjs +96 -0
  4. package/scripts/generate-voice-runtime-manifest.mjs +300 -0
  5. package/scripts/generate-voice-runtime-manifest.test.mjs +135 -0
  6. package/scripts/native-binary-arch.mjs +30 -13
  7. package/scripts/native-binary-arch.test.mjs +40 -4
  8. package/scripts/plan-voice-runtime-release.mjs +73 -0
  9. package/scripts/prepare-native-assets.mjs +50 -9
  10. package/scripts/prune-embedding-runtime.mjs +3 -3
  11. package/scripts/sync-voice-runtime-manifest.mjs +46 -0
  12. package/scripts/verify-release-assets.mjs +106 -6
  13. package/scripts/verify-voice-runtime-release.mjs +89 -0
  14. package/scripts/voice-runtime-config.json +52 -0
  15. package/src/app.mjs +42 -5
  16. package/src/headless-exec.mjs +58 -32
  17. package/src/headless-exec.test.mjs +74 -13
  18. package/src/rules/lead/01-general.md +2 -0
  19. package/src/rules/shared/10-tool-workflow.md +7 -4
  20. package/src/rules/shared/30-exploration.md +5 -1
  21. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +28 -9
  22. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +6 -1
  23. package/src/runtime/agent/orchestrator/config.mjs +53 -18
  24. package/src/runtime/agent/orchestrator/mcp/cancellation.test.mjs +139 -0
  25. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +34 -5
  26. package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -10
  27. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +5 -0
  28. package/src/runtime/agent/orchestrator/providers/anthropic-fast-mode.mjs +98 -0
  29. package/src/runtime/agent/orchestrator/providers/anthropic-fast-mode.test.mjs +90 -0
  30. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +67 -16
  31. package/src/runtime/agent/orchestrator/providers/anthropic-messages.mjs +4 -82
  32. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +0 -5
  33. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +62 -121
  34. package/src/runtime/agent/orchestrator/providers/anthropic-server-fallback.mjs +48 -0
  35. package/src/runtime/agent/orchestrator/providers/anthropic-server-fallback.test.mjs +215 -0
  36. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +44 -48
  37. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +54 -31
  38. package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +56 -2
  39. package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +34 -2
  40. package/src/runtime/agent/orchestrator/providers/cursor-wire.mjs +61 -1
  41. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +6 -1
  42. package/src/runtime/agent/orchestrator/providers/gemini.mjs +61 -15
  43. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +120 -1
  44. package/src/runtime/agent/orchestrator/providers/lib/env-utils.mjs +9 -0
  45. package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +0 -3
  46. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +187 -84
  47. package/src/runtime/agent/orchestrator/providers/openai-codex-identity.test.mjs +32 -9
  48. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +78 -33
  49. package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +21 -0
  50. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +36 -36
  51. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +16 -6
  52. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +50 -213
  53. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +135 -126
  54. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +20 -11
  55. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +112 -36
  56. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +220 -50
  57. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +112 -9
  58. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +11 -0
  59. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +130 -60
  60. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +2 -2
  61. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +28 -10
  62. package/src/runtime/agent/orchestrator/providers/registry.mjs +122 -95
  63. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +69 -3
  64. package/src/runtime/agent/orchestrator/providers/tool-stream-state.mjs +4 -22
  65. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +7 -14
  66. package/src/runtime/agent/orchestrator/session/compact-policy.test.mjs +128 -0
  67. package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +5 -0
  68. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +27 -48
  69. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +19 -14
  70. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -43
  71. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +5 -1
  72. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +43 -5
  73. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -2
  74. package/src/runtime/agent/orchestrator/session/manager/pending-messages-persistence.test.mjs +325 -0
  75. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +599 -195
  76. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +22 -0
  77. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +9 -2
  78. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +2 -0
  79. package/src/runtime/agent/orchestrator/session/manager.mjs +3 -0
  80. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +26 -16
  81. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +23 -11
  82. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +118 -3
  83. package/src/runtime/agent/orchestrator/session/store-summary-reader.test.mjs +332 -0
  84. package/src/runtime/agent/orchestrator/session/store.mjs +38 -1
  85. package/src/runtime/agent/orchestrator/tools/builtin/advisory-lock.mjs +27 -6
  86. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +12 -20
  87. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +291 -128
  88. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +46 -9
  89. package/src/runtime/agent/orchestrator/tools/builtin/edit-encoding-eol.test.mjs +224 -0
  90. package/src/runtime/agent/orchestrator/tools/builtin/edit-sequential-occupation.test.mjs +53 -3
  91. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +255 -25
  92. package/src/runtime/agent/orchestrator/tools/builtin/find-search-budget.test.mjs +87 -0
  93. package/src/runtime/agent/orchestrator/tools/builtin/git-command-policy.mjs +1 -1
  94. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +132 -9
  95. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +10 -0
  96. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-pattern-fanout.mjs +29 -3
  97. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-records.mjs +1 -1
  98. package/src/runtime/agent/orchestrator/tools/builtin/list-tool-integrity.test.mjs +14 -0
  99. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +180 -31
  100. package/src/runtime/agent/orchestrator/tools/builtin/local-search-telemetry.mjs +4 -1
  101. package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +32 -5
  102. package/src/runtime/agent/orchestrator/tools/builtin/native-search-health.test.mjs +5 -2
  103. package/src/runtime/agent/orchestrator/tools/builtin/native-search-runner.mjs +10 -1
  104. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +38 -10
  105. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +12 -0
  106. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +31 -1
  107. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +11 -1
  108. package/src/runtime/agent/orchestrator/tools/builtin/read-windows.mjs +22 -4
  109. package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +6 -8
  110. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +25 -24
  111. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +152 -133
  112. package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +114 -42
  113. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +129 -15
  114. package/src/runtime/agent/orchestrator/tools/code-graph/loose-file-target.test.mjs +72 -0
  115. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +7 -8
  116. package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +4 -4
  117. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +201 -19
  118. package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +26 -3
  119. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +10 -1
  120. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-search-scope.test.mjs +55 -0
  121. package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +115 -25
  122. package/src/runtime/agent/orchestrator/tools/code-graph/text-span-mask.test.mjs +268 -0
  123. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +3 -3
  124. package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.mjs +296 -46
  125. package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.test.mjs +104 -0
  126. package/src/runtime/agent/orchestrator/tools/lib/shell-descendants.mjs +136 -0
  127. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +127 -38
  128. package/src/runtime/agent/orchestrator/tools/patch/engine-contract-gate.test.mjs +661 -0
  129. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +350 -9
  130. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +418 -44
  131. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +27 -3
  132. package/src/runtime/agent/orchestrator/tools/patch/patch-codec-eol.test.mjs +552 -0
  133. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +65 -15
  134. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  135. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +284 -109
  136. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +51 -21
  137. package/src/runtime/agent/orchestrator/tools/shell-exec-policy.mjs +44 -27
  138. package/src/runtime/agent/orchestrator/tools/shell-policy.mjs +6 -1
  139. package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
  140. package/src/runtime/attachments/store.mjs +11 -3
  141. package/src/runtime/channels/data/voice-runtime-manifest.json +46 -70
  142. package/src/runtime/channels/lib/event-queue.mjs +51 -41
  143. package/src/runtime/channels/lib/scheduler.mjs +23 -28
  144. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +6 -17
  145. package/src/runtime/channels/lib/voice-runtime-fetcher.test.mjs +17 -0
  146. package/src/runtime/channels/lib/webhook.mjs +39 -20
  147. package/src/runtime/channels/lib/whisper-server.mjs +46 -1
  148. package/src/runtime/channels/lib/worker-main.mjs +3 -1
  149. package/src/runtime/media/adapters/gemini-video.mjs +20 -4
  150. package/src/runtime/media/jobs.mjs +13 -0
  151. package/src/runtime/media/renditions.mjs +7 -1
  152. package/src/runtime/media/store.mjs +42 -22
  153. package/src/runtime/memory/index.mjs +4 -9
  154. package/src/runtime/memory/lib/agent-ipc.mjs +1 -6
  155. package/src/runtime/memory/lib/archive-security.test.mjs +21 -1
  156. package/src/runtime/memory/lib/core-memory-store.mjs +28 -50
  157. package/src/runtime/memory/lib/embedding-model-config.mjs +37 -1
  158. package/src/runtime/memory/lib/embedding-model-config.test.mjs +33 -0
  159. package/src/runtime/memory/lib/embedding-provider.mjs +34 -12
  160. package/src/runtime/memory/lib/embedding-worker.mjs +37 -5
  161. package/src/runtime/memory/lib/ko-morph.mjs +46 -15
  162. package/src/runtime/memory/lib/memory-action-handlers.mjs +18 -1
  163. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -18
  164. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +1 -18
  165. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +98 -45
  166. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +71 -0
  167. package/src/runtime/memory/lib/memory-cycle2-shared.test.mjs +78 -0
  168. package/src/runtime/memory/lib/memory-cycle2.mjs +37 -3
  169. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  170. package/src/runtime/memory/lib/memory-embedding-migration.test.mjs +50 -0
  171. package/src/runtime/memory/lib/memory-recall-store.mjs +9 -24
  172. package/src/runtime/memory/lib/memory.mjs +50 -12
  173. package/src/runtime/memory/lib/pg/process.mjs +1 -10
  174. package/src/runtime/memory/lib/pg/supervisor.mjs +1 -9
  175. package/src/runtime/memory/lib/query-handlers.mjs +2 -4
  176. package/src/runtime/memory/lib/session-ingest-runtime.mjs +2 -2
  177. package/src/runtime/shared/background-tasks.mjs +15 -0
  178. package/src/runtime/shared/background-tasks.test.mjs +78 -0
  179. package/src/runtime/shared/child-guardian.mjs +2 -10
  180. package/src/runtime/shared/pid-liveness.mjs +27 -0
  181. package/src/runtime/shared/pristine-execution-contract.json +0 -1
  182. package/src/runtime/shared/runtime-root.mjs +1 -11
  183. package/src/runtime/shared/service-discovery.mjs +1 -17
  184. package/src/runtime/shared/singleton-owner.mjs +1 -11
  185. package/src/runtime/shared/staged-update.mjs +2 -12
  186. package/src/runtime/shared/tool-status.mjs +5 -1
  187. package/src/runtime/shared/tool-surface.mjs +15 -7
  188. package/src/runtime/shared/turn-snapshot-store.mjs +168 -0
  189. package/src/runtime/shared/turn-snapshot.mjs +137 -13
  190. package/src/runtime/shared/turn-worktree-snapshot.mjs +110 -18
  191. package/src/runtime/shared/webhook-session-run.mjs +16 -2
  192. package/src/runtime/web-search/lib/http-fetch.mjs +34 -4
  193. package/src/runtime/web-search/lib/state.mjs +15 -15
  194. package/src/runtime/web-search/lib/web-tools.mjs +46 -5
  195. package/src/session-runtime/context-status.mjs +4 -12
  196. package/src/session-runtime/runtime-core.mjs +20 -1
  197. package/src/session-runtime/session-lifecycle.mjs +9 -0
  198. package/src/standalone/agent-dispatch-broker.mjs +33 -2
  199. package/src/standalone/agent-tool/job-task-reconcile.mjs +78 -0
  200. package/src/standalone/agent-tool/notify.mjs +3 -1
  201. package/src/standalone/agent-tool/spawn-flow.mjs +52 -70
  202. package/src/standalone/agent-tool/tag-registry.mjs +116 -33
  203. package/src/standalone/agent-tool/tool-def.mjs +2 -2
  204. package/src/standalone/agent-tool/worker-index.mjs +8 -11
  205. package/src/standalone/agent-tool/worker-rows.mjs +4 -1
  206. package/src/standalone/agent-tool.mjs +20 -55
  207. package/src/standalone/agent-watchdog-registry.mjs +28 -4
  208. package/src/standalone/channel-client.mjs +47 -15
  209. package/src/standalone/channel-transport.mjs +44 -25
  210. package/src/standalone/daemon.mjs +31 -2
  211. package/src/standalone/hook-bus/config.mjs +25 -9
  212. package/src/standalone/hook-bus/handlers.mjs +16 -52
  213. package/src/standalone/hook-bus.mjs +29 -23
  214. package/src/standalone/memory-runtime-proxy.mjs +1 -16
  215. package/src/standalone/session-client.mjs +86 -23
  216. package/src/standalone/session-protocol.mjs +0 -2
  217. package/src/standalone/session-runtime-host-health.test.mjs +5 -4
  218. package/src/standalone/session-runtime-host.mjs +101 -6
  219. package/src/standalone/session-runtime-record.mjs +1 -5
  220. package/src/standalone/session-runtime-worker.mjs +52 -57
  221. package/src/standalone/session-service.mjs +4 -1
  222. package/src/standalone/session-transport.mjs +56 -25
  223. package/src/tui/App.jsx +40 -49
  224. package/src/tui/app/app-view.jsx +14 -21
  225. package/src/tui/app/core-memory-picker.mjs +29 -16
  226. package/src/tui/app/create-app-pickers.mjs +15 -36
  227. package/src/tui/app/extension-pickers.mjs +60 -49
  228. package/src/tui/app/maintenance-pickers.mjs +100 -57
  229. package/src/tui/app/message-selector.mjs +6 -5
  230. package/src/tui/app/model-picker.mjs +30 -13
  231. package/src/tui/app/onboarding-steps.mjs +21 -12
  232. package/src/tui/app/panel-epoch.mjs +56 -0
  233. package/src/tui/app/panel-epoch.test.mjs +1009 -0
  234. package/src/tui/app/panel-surface.mjs +98 -0
  235. package/src/tui/app/project-picker.mjs +30 -29
  236. package/src/tui/app/prompt-submit.mjs +192 -97
  237. package/src/tui/app/prompt-submit.test.mjs +206 -0
  238. package/src/tui/app/provider-setup-picker.mjs +74 -43
  239. package/src/tui/app/resume-picker.mjs +11 -8
  240. package/src/tui/app/route-pickers.mjs +45 -28
  241. package/src/tui/app/settings-picker.mjs +69 -18
  242. package/src/tui/app/shell-layout.mjs +1 -3
  243. package/src/tui/app/slash-commands.mjs +0 -2
  244. package/src/tui/app/slash-dispatch.mjs +4 -2
  245. package/src/tui/app/text-entry-policy.mjs +25 -0
  246. package/src/tui/app/theme-effort-pickers.mjs +11 -16
  247. package/src/tui/app/usage-context-panels.mjs +21 -25
  248. package/src/tui/app/use-global-key-input.mjs +3 -2
  249. package/src/tui/app/use-prompt-draft-flow.mjs +6 -18
  250. package/src/tui/app/use-prompt-handlers.mjs +3 -2
  251. package/src/tui/app/use-welcome-prompt-hint.mjs +17 -3
  252. package/src/tui/components/Picker.jsx +1 -1
  253. package/src/tui/components/TextEntryPanel.jsx +7 -2
  254. package/src/tui/dist/index.mjs +652 -561
  255. package/src/tui/lib/voice-setup.mjs +1 -2
  256. package/src/tui/session/agent-job-feed.mjs +73 -11
  257. package/src/tui/session/session-api-ext.mjs +5 -6
  258. package/src/tui/session/session-api.mjs +1 -0
  259. package/src/tui/session/tool-approval.mjs +15 -5
  260. package/src/tui/session/tui-steering-persist.mjs +33 -19
  261. package/src/tui/session/tui-steering-persist.test.mjs +104 -0
  262. package/src/tui/session-local.mjs +0 -22
  263. package/scripts/.tmp-coverage-report.mjs +0 -96
  264. package/src/runtime/agent/orchestrator/tools/builtin/shell-monitor.mjs +0 -57
  265. package/src/runtime/agent/orchestrator/tools/builtin/shell-monitor.test.mjs +0 -57
@@ -5,32 +5,38 @@
5
5
  // re-created per render so it closes over the CURRENT prompt/panel state.
6
6
  import {
7
7
  memoryCoreResultErrorText,
8
- parseHookRuleInput,
9
8
  parseMcpServerInput,
10
9
  parseSkillInput,
11
10
  } from './input-parsers.mjs';
12
11
  import { projectNameFromPath } from './app-format.mjs';
12
+ import { isPanelEpochCurrent, supersedePanelEpoch } from './panel-epoch.mjs';
13
13
  import {
14
14
  buildPromptContentWithImages,
15
15
  imageReferenceIds,
16
16
  pastedTextReferenceIds,
17
17
  } from '../paste-attachments.mjs';
18
18
 
19
+ // In-flight prompt writes, tracked at MODULE scope: createPromptSubmit is
20
+ // re-created on every render, so a closure flag resets mid-write and a second
21
+ // Enter starts an overlapping daemon write whose OLDER ack then closes the
22
+ // panel that already holds newer (restored) input. Each value is the panel
23
+ // epoch its write owns, so a write stops blocking the moment the user takes
24
+ // the surface back (Esc/close bumps the epoch).
25
+ let providerWriteToken = 0;
26
+ let settingsWriteToken = 0;
27
+ const providerWriteInFlight = () => providerWriteToken > 0 && isPanelEpochCurrent(providerWriteToken);
28
+ const settingsWriteInFlight = () => settingsWriteToken > 0 && isPanelEpochCurrent(settingsWriteToken);
29
+
19
30
  export function createPromptSubmit({
20
31
  store,
21
32
  state,
22
33
  providerPrompt,
23
- channelPrompt,
24
- hookPrompt,
25
34
  settingsPrompt,
26
35
  setProviderPrompt,
27
- setChannelPrompt,
28
- setHookPrompt,
29
36
  setSettingsPrompt,
30
37
  oauthSubmitRef,
31
38
  clearModelCaches,
32
39
  openProviderSetupPicker,
33
- openHooksPicker,
34
40
  openSettingsPicker,
35
41
  openProjectPicker,
36
42
  openAutoClearPicker,
@@ -54,6 +60,14 @@ export function createPromptSubmit({
54
60
  }
55
61
  return Promise.resolve(target.apply(store, args));
56
62
  };
63
+ // Panel openers are async on a daemon-backed store: a bare call leaves a
64
+ // rejected open as an unhandled rejection, which terminates the TUI.
65
+ const openPanel = (open, ...args) => {
66
+ if (typeof open !== 'function') return;
67
+ void Promise.resolve(open(...args)).catch((error) => {
68
+ store.pushNotice(`panel failed to open: ${error?.message || error}`, 'error');
69
+ });
70
+ };
57
71
  const submitPrompt = (prompt, options) => {
58
72
  if (typeof store.submitAsync !== 'function') return store.submit(prompt, options);
59
73
  void Promise.resolve(store.submitAsync(prompt, options)).then((accepted) => {
@@ -65,6 +79,17 @@ export function createPromptSubmit({
65
79
  // durable intake, while provider execution continues independently.
66
80
  return true;
67
81
  };
82
+ // A rejected daemon write keeps the settings prompt OPEN with the typed
83
+ // value restored (the epoch remounts the editor, so an identical retry value
84
+ // still re-seeds it) instead of closing and losing the entry.
85
+ const restoreSettingsPrompt = (target, value) => {
86
+ setSettingsPrompt({
87
+ ...target,
88
+ submitting: false,
89
+ initialValue: value,
90
+ restoreEpoch: (Number(target.restoreEpoch) || 0) + 1,
91
+ });
92
+ };
68
93
  const onSubmit = (raw) => {
69
94
  const text = String(raw ?? '');
70
95
  const commandText = text.trim();
@@ -73,57 +98,94 @@ export function createPromptSubmit({
73
98
  store.pushNotice('wait for the current command to finish', 'warn');
74
99
  return false;
75
100
  }
101
+ // Every provider write below is a DAEMON RPC (async). The prompt may only
102
+ // close once the write is acknowledged; a rejection keeps the panel open
103
+ // with the entered value restored so a transport hiccup never silently
104
+ // eats a credential or claims success.
105
+ const beginProviderSave = (target) => {
106
+ // A new submit supersedes every older in-flight write for this surface.
107
+ const token = supersedePanelEpoch();
108
+ providerWriteToken = token;
109
+ setProviderPrompt((prompt) => (prompt === target ? { ...prompt, submitting: true } : prompt));
110
+ return token;
111
+ };
112
+ const endProviderSave = (token) => {
113
+ if (providerWriteToken === token) providerWriteToken = 0;
114
+ };
115
+ const finishProviderSave = (target, token) => {
116
+ endProviderSave(token);
117
+ // Stale ack (newer submit, or the user closed the prompt): never close
118
+ // or navigate a surface this write no longer owns.
119
+ if (!isPanelEpochCurrent(token)) return;
120
+ setProviderPrompt(null);
121
+ if (target.afterSave) target.afterSave();
122
+ else openPanel(openProviderSetupPicker);
123
+ };
124
+ const failProviderSave = (target, value, message, token) => {
125
+ endProviderSave(token);
126
+ store.pushNotice(message, 'error');
127
+ if (!isPanelEpochCurrent(token)) return;
128
+ // The restored panel now owns the surface.
129
+ supersedePanelEpoch();
130
+ setProviderPrompt({
131
+ ...target,
132
+ submitting: false,
133
+ initialValue: value,
134
+ restoreEpoch: (Number(target.restoreEpoch) || 0) + 1,
135
+ });
136
+ };
76
137
  if (providerPrompt.kind === 'api-key') {
77
138
  if (!commandText) {
78
139
  store.pushNotice(`API key is required for ${providerPrompt.providerId}`, 'warn');
79
140
  return false;
80
141
  }
81
- try {
82
- store.saveProviderApiKey(providerPrompt.providerId, commandText);
83
- clearModelCaches('all');
84
- const afterSave = providerPrompt.afterSave;
85
- setProviderPrompt(null);
86
- if (afterSave) afterSave();
87
- else void openProviderSetupPicker();
88
- return true;
89
- } catch (e) {
90
- store.pushNotice(`api key save failed: ${e?.message || e}`, 'error');
142
+ if (providerWriteInFlight()) {
143
+ store.pushNotice('API key is already being saved', 'warn');
91
144
  return false;
92
145
  }
146
+ const target = providerPrompt;
147
+ const token = beginProviderSave(target);
148
+ void serviceCall('saveProviderApiKey', target.providerId, commandText)
149
+ .then(() => {
150
+ clearModelCaches('all');
151
+ finishProviderSave(target, token);
152
+ })
153
+ .catch((error) => failProviderSave(target, commandText, `api key save failed: ${error?.message || error}`, token));
154
+ return true;
93
155
  }
94
156
  if (providerPrompt.kind === 'openai-usage-session') {
95
157
  if (!commandText) {
96
158
  store.pushNotice('OpenAI usage session key is required for credit lookup', 'warn');
97
159
  return false;
98
160
  }
99
- try {
100
- store.saveOpenAIUsageSessionKey(commandText);
101
- const afterSave = providerPrompt.afterSave;
102
- setProviderPrompt(null);
103
- if (afterSave) afterSave();
104
- else void openProviderSetupPicker();
105
- return true;
106
- } catch (e) {
107
- store.pushNotice(`OpenAI usage auth save failed: ${e?.message || e}`, 'error');
161
+ if (providerWriteInFlight()) {
162
+ store.pushNotice('OpenAI usage session key is already being saved', 'warn');
108
163
  return false;
109
164
  }
165
+ const target = providerPrompt;
166
+ const token = beginProviderSave(target);
167
+ void serviceCall('saveOpenAIUsageSessionKey', commandText)
168
+ .then(() => finishProviderSave(target, token))
169
+ .catch((error) => failProviderSave(target, commandText, `OpenAI usage auth save failed: ${error?.message || error}`, token));
170
+ return true;
110
171
  }
111
172
  if (providerPrompt.kind === 'local-url') {
112
- try {
113
- store.setLocalProvider(providerPrompt.providerId, {
114
- enabled: true,
115
- baseURL: commandText || providerPrompt.defaultURL,
116
- });
117
- clearModelCaches('all');
118
- const afterSave = providerPrompt.afterSave;
119
- setProviderPrompt(null);
120
- if (afterSave) afterSave();
121
- else void openProviderSetupPicker();
122
- return true;
123
- } catch (e) {
124
- store.pushNotice(`local provider update failed: ${e?.message || e}`, 'error');
173
+ if (providerWriteInFlight()) {
174
+ store.pushNotice('local provider update is already running', 'warn');
125
175
  return false;
126
176
  }
177
+ const target = providerPrompt;
178
+ const token = beginProviderSave(target);
179
+ void serviceCall('setLocalProvider', target.providerId, {
180
+ enabled: true,
181
+ baseURL: commandText || target.defaultURL,
182
+ })
183
+ .then(() => {
184
+ clearModelCaches('all');
185
+ finishProviderSave(target, token);
186
+ })
187
+ .catch((error) => failProviderSave(target, commandText, `local provider update failed: ${error?.message || error}`, token));
188
+ return true;
127
189
  }
128
190
  if (providerPrompt.kind === 'oauth-code') {
129
191
  if (!commandText) {
@@ -146,7 +208,7 @@ export function createPromptSubmit({
146
208
  store.pushNotice(`${providerPrompt.providerName || 'OAuth'} login complete`, 'info');
147
209
  if (successReturn) successReturn();
148
210
  else if (afterSave) afterSave();
149
- else void openProviderSetupPicker();
211
+ else openPanel(openProviderSetupPicker);
150
212
  })
151
213
  .catch((e) => {
152
214
  oauthSubmitRef.current = false;
@@ -157,48 +219,61 @@ export function createPromptSubmit({
157
219
  return true;
158
220
  }
159
221
  }
160
- // Channel token/target prompt kinds retired with Discord/Telegram
161
- // messaging (user decision: the PWA replaces channels).
162
- if (hookPrompt) {
222
+ // Channel token/target and hook-rule text prompts are retired: channels
223
+ // moved to the PWA and hook rules are edited in the Hooks picker, so no
224
+ // opener sets those prompts any more.
225
+ if (settingsPrompt) {
163
226
  if (state.commandBusy) {
164
227
  store.pushNotice('wait for the current command to finish', 'warn');
165
228
  return false;
166
229
  }
167
- try {
168
- if (hookPrompt.kind === 'rule-add') {
169
- const parsed = parseHookRuleInput(commandText);
170
- if (parsed.error) {
171
- store.pushNotice(parsed.error, 'warn');
172
- return false;
173
- }
174
- store.addHookRule?.(parsed.rule);
175
- setHookPrompt(null);
176
- void openHooksPicker();
177
- return true;
178
- }
179
- } catch (e) {
180
- store.pushNotice(`hook update failed: ${e?.message || e}`, 'error');
181
- return false;
182
- }
183
- }
184
- if (settingsPrompt) {
185
- if (state.commandBusy) {
186
- store.pushNotice('wait for the current command to finish', 'warn');
230
+ // Settings writes are daemon RPCs and the panel stays open until they
231
+ // ACK, so a second Enter used to start an OVERLAPPING write: the older
232
+ // ack then closed the prompt that already held the newer, restored value
233
+ // and the user's input was lost. One write at a time per surface, and a
234
+ // superseded ack is ignored below.
235
+ if (settingsWriteInFlight()) {
236
+ store.pushNotice('wait for the current settings change to finish', 'warn');
187
237
  return false;
188
238
  }
239
+ const beginSettingsSave = (target) => {
240
+ const token = supersedePanelEpoch();
241
+ settingsWriteToken = token;
242
+ setSettingsPrompt((prompt) => (prompt === target ? { ...prompt, submitting: true } : prompt));
243
+ return token;
244
+ };
245
+ const endSettingsSave = (token) => {
246
+ if (settingsWriteToken === token) settingsWriteToken = 0;
247
+ };
248
+ const finishSettingsSave = (token, after) => {
249
+ endSettingsSave(token);
250
+ // Stale ack: a newer submit or an Esc owns this surface now.
251
+ if (!isPanelEpochCurrent(token)) return;
252
+ setSettingsPrompt(null);
253
+ after?.();
254
+ };
255
+ const failSettingsSave = (target, value, message, token, tone = 'error') => {
256
+ endSettingsSave(token);
257
+ store.pushNotice(message, tone);
258
+ if (!isPanelEpochCurrent(token)) return;
259
+ // The restored panel now owns the surface.
260
+ supersedePanelEpoch();
261
+ restoreSettingsPrompt(target, value);
262
+ };
189
263
  try {
190
264
  if (settingsPrompt.kind === 'cwd') {
191
265
  if (!commandText) {
192
266
  store.pushNotice('working directory path is required', 'warn');
193
267
  return false;
194
268
  }
269
+ const target = settingsPrompt;
270
+ const token = beginSettingsSave(target);
195
271
  void serviceCall('setCwd', commandText, {
196
272
  message: `Project set: ${projectNameFromPath(commandText)}`,
197
273
  }).then(() => {
198
- setSettingsPrompt(null);
199
- void openSettingsPicker();
274
+ finishSettingsSave(token, () => openPanel(openSettingsPicker));
200
275
  }).catch((error) => {
201
- store.pushNotice(`project switch failed: ${error?.message || error}`, 'error');
276
+ failSettingsSave(target, commandText, `project switch failed: ${error?.message || error}`, token);
202
277
  });
203
278
  return true;
204
279
  }
@@ -207,17 +282,21 @@ export function createPromptSubmit({
207
282
  store.pushNotice('project path is required', 'warn');
208
283
  return false;
209
284
  }
285
+ const target = settingsPrompt;
286
+ const token = beginSettingsSave(target);
210
287
  void serviceCall('inspectProjectPath', commandText).then((result) => {
211
288
  const path = String(result?.path || commandText);
212
289
  if (result?.directory === true) {
213
- setSettingsPrompt(null);
214
- void registerProject(path);
290
+ finishSettingsSave(token, () => openPanel(registerProject, path));
215
291
  return;
216
292
  }
217
293
  if (result?.exists === true) {
218
- store.pushNotice(`${path} is not a directory`, 'warn');
294
+ failSettingsSave(target, commandText, `${path} is not a directory`, token, 'warn');
219
295
  return;
220
296
  }
297
+ endSettingsSave(token);
298
+ if (!isPanelEpochCurrent(token)) return;
299
+ supersedePanelEpoch();
221
300
  setSettingsPrompt({
222
301
  kind: 'project-create-confirm',
223
302
  label: 'New project · Create folder?',
@@ -225,7 +304,7 @@ export function createPromptSubmit({
225
304
  pendingPath: path,
226
305
  });
227
306
  }).catch((error) => {
228
- store.pushNotice(`project path check failed: ${error?.message || error}`, 'error');
307
+ failSettingsSave(target, commandText, `project path check failed: ${error?.message || error}`, token);
229
308
  });
230
309
  return true;
231
310
  }
@@ -233,12 +312,16 @@ export function createPromptSubmit({
233
312
  const pendingPath = String(settingsPrompt.pendingPath || '');
234
313
  const answer = String(commandText || '').trim().toLowerCase();
235
314
  if (answer === 'y' || answer === 'yes') {
315
+ const target = settingsPrompt;
316
+ const token = beginSettingsSave(target);
236
317
  void serviceCall('ensureProjectDirectory', pendingPath).then((created) => {
237
- setSettingsPrompt(null);
238
- void registerProject(created || pendingPath);
318
+ finishSettingsSave(token, () => openPanel(registerProject, created || pendingPath));
239
319
  }).catch((error) => {
320
+ endSettingsSave(token);
240
321
  store.pushNotice(`could not create folder: ${error?.message || error}`, 'error');
241
- setSettingsPrompt(null);
322
+ // The typed value here is only the y/n answer, so close rather
323
+ // than restore — but never close a surface we no longer own.
324
+ if (isPanelEpochCurrent(token)) setSettingsPrompt(null);
242
325
  });
243
326
  return true;
244
327
  }
@@ -248,21 +331,29 @@ export function createPromptSubmit({
248
331
  }
249
332
  if (settingsPrompt.kind === 'project-rename') {
250
333
  const targetPath = String(settingsPrompt.projectPath || '');
334
+ const target = settingsPrompt;
335
+ const token = beginSettingsSave(target);
251
336
  void serviceCall('renameProject', targetPath, commandText).then((updated) => {
252
337
  if (updated) {
253
338
  store.pushNotice(`project renamed to "${updated.name}"`, 'info');
254
339
  }
255
- setSettingsPrompt(null);
256
- void openProjectPicker();
340
+ finishSettingsSave(token, () => openPanel(openProjectPicker));
257
341
  }).catch((error) => {
258
- store.pushNotice(`rename failed: ${error?.message || error}`, 'error');
342
+ failSettingsSave(target, commandText, `rename failed: ${error?.message || error}`, token);
259
343
  });
260
344
  return true;
261
345
  }
262
346
  if (settingsPrompt.kind === 'system-shell') {
263
- store.setSystemShell?.(commandText);
264
- setSettingsPrompt(null);
265
- void openSettingsPicker();
347
+ const target = settingsPrompt;
348
+ const token = beginSettingsSave(target);
349
+ // Empty is the documented reset to automatic selection.
350
+ void serviceCall('setSystemShell', commandText)
351
+ .then(() => {
352
+ finishSettingsSave(token, () => openPanel(openSettingsPicker));
353
+ })
354
+ .catch((error) => {
355
+ failSettingsSave(target, commandText, `system shell update failed: ${error?.message || error}`, token);
356
+ });
266
357
  return true;
267
358
  }
268
359
  if (settingsPrompt.kind === 'autoclear-provider') {
@@ -271,28 +362,32 @@ export function createPromptSubmit({
271
362
  store.pushNotice('auto-clear provider is missing', 'warn');
272
363
  return false;
273
364
  }
274
- const text = String(commandText || '').trim();
275
- try {
276
- if (text) store.setAutoClear?.({ provider, duration: text });
277
- else store.setAutoClear?.({ provider, resetProvider: true });
278
- store.pushNotice(text ? `Auto-clear ${provider} default set to ${text}` : `Auto-clear ${provider} default reset`, 'info');
279
- } catch (e) {
280
- store.pushNotice(`autoclear failed: ${e?.message || e}`, 'error');
281
- return false;
282
- }
283
- setSettingsPrompt(null);
284
- openAutoClearPicker({ advanced: true, returnTo: settingsPrompt.returnTo });
365
+ const duration = String(commandText || '').trim();
366
+ const target = settingsPrompt;
367
+ const token = beginSettingsSave(target);
368
+ // Empty is the documented reset to the built-in provider default.
369
+ void serviceCall('setAutoClear', duration ? { provider, duration } : { provider, resetProvider: true })
370
+ .then(() => {
371
+ store.pushNotice(duration ? `Auto-clear ${provider} default set to ${duration}` : `Auto-clear ${provider} default reset`, 'info');
372
+ finishSettingsSave(token, () => openPanel(openAutoClearPicker, { advanced: true, returnTo: target.returnTo }));
373
+ })
374
+ .catch((error) => {
375
+ failSettingsSave(target, duration, `autoclear failed: ${error?.message || error}`, token);
376
+ });
285
377
  return true;
286
378
  }
287
379
  if (settingsPrompt.kind === 'profile-title') {
288
- try {
289
- store.setProfile?.({ title: commandText });
290
- store.pushNotice(commandText ? `Title set to "${commandText.trim()}"` : 'Title cleared', 'info');
291
- } catch (e) {
292
- store.pushNotice(`profile update failed: ${e?.message || e}`, 'error');
293
- }
294
- setSettingsPrompt(null);
295
- openProfilePicker();
380
+ const target = settingsPrompt;
381
+ const token = beginSettingsSave(target);
382
+ // Empty is the documented "clear the title" action.
383
+ void serviceCall('setProfile', { title: commandText })
384
+ .then(() => {
385
+ store.pushNotice(commandText ? `Title set to "${commandText.trim()}"` : 'Title cleared', 'info');
386
+ finishSettingsSave(token, () => openPanel(openProfilePicker));
387
+ })
388
+ .catch((error) => {
389
+ failSettingsSave(target, commandText, `profile update failed: ${error?.message || error}`, token);
390
+ });
296
391
  return true;
297
392
  }
298
393
  if (settingsPrompt.kind === 'plugin-add') {
@@ -0,0 +1,206 @@
1
+ // Prompt-submit dispatcher contract for daemon-backed settings writes.
2
+ //
3
+ // Two properties are pinned here because both lose user input when they break:
4
+ // 1. one write per surface at a time (a fast second Enter must be refused,
5
+ // never started as an overlapping write), and
6
+ // 2. an ack that no longer owns the surface (superseded by Esc or by a newer
7
+ // submit) must not close the prompt, navigate, or claim success.
8
+ // Plus the documented clear-by-empty settings actions must reach the daemon.
9
+ import assert from 'node:assert/strict';
10
+ import test from 'node:test';
11
+ import { readFile } from 'node:fs/promises';
12
+
13
+ import { createPromptSubmit } from './prompt-submit.mjs';
14
+ import { supersedePanelEpoch } from './panel-epoch.mjs';
15
+ import { canSubmitTextEntry, textEntryClearsByEmpty } from './text-entry-policy.mjs';
16
+
17
+ function deferred() {
18
+ let resolve;
19
+ let reject;
20
+ const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
21
+ return { promise, resolve, reject };
22
+ }
23
+
24
+ const flush = async () => {
25
+ for (let i = 0; i < 4; i += 1) await new Promise((resolve) => setImmediate(resolve));
26
+ };
27
+
28
+ function createHarness({ settingsPrompt = null, store: storeOverrides = {} } = {}) {
29
+ const notices = [];
30
+ const settingsPromptSet = [];
31
+ const opened = [];
32
+ const store = {
33
+ pushNotice: (message, tone) => notices.push([message, tone]),
34
+ ...storeOverrides,
35
+ };
36
+ const { onSubmit } = createPromptSubmit({
37
+ store,
38
+ state: { commandBusy: false },
39
+ providerPrompt: null,
40
+ settingsPrompt,
41
+ setProviderPrompt: () => {},
42
+ setSettingsPrompt: (next) => {
43
+ settingsPromptSet.push(typeof next === 'function' ? next(settingsPrompt) : next);
44
+ },
45
+ oauthSubmitRef: { current: false },
46
+ clearModelCaches: () => {},
47
+ openProviderSetupPicker: () => { opened.push('providers'); },
48
+ openSettingsPicker: () => { opened.push('settings'); },
49
+ openProjectPicker: () => { opened.push('projects'); },
50
+ openAutoClearPicker: () => { opened.push('autoclear'); },
51
+ openProfilePicker: () => { opened.push('profile'); },
52
+ openPluginsPicker: () => {},
53
+ openMcpServersPicker: () => {},
54
+ openProjectSkillsPicker: () => {},
55
+ openMemoryCorePicker: () => {},
56
+ registerProject: () => {},
57
+ runSlashCommand: () => true,
58
+ armTranscriptFollow: () => {},
59
+ clearPastedImagesSnapshot: () => {},
60
+ clearPastedTextsSnapshot: () => {},
61
+ pastedImagesRef: { current: {} },
62
+ pastedTextsRef: { current: {} },
63
+ });
64
+ return { onSubmit, notices, settingsPromptSet, opened };
65
+ }
66
+
67
+ const closedCount = (harness) => harness.settingsPromptSet.filter((value) => value === null).length;
68
+
69
+ test('a second settings submit is refused while the first daemon write is in flight', async () => {
70
+ supersedePanelEpoch();
71
+ const gate = deferred();
72
+ const shellCalls = [];
73
+ const harness = createHarness({
74
+ settingsPrompt: { kind: 'system-shell', label: 'System shell' },
75
+ store: {
76
+ setSystemShell: (command) => { shellCalls.push(command); return gate.promise; },
77
+ },
78
+ });
79
+
80
+ assert.equal(harness.onSubmit('pwsh'), true);
81
+ // Fast second Enter (before React re-renders the prompt): refused, so the
82
+ // draft is kept and no overlapping write is started.
83
+ assert.equal(harness.onSubmit('bash'), false);
84
+ assert.deepEqual(shellCalls, ['pwsh']);
85
+ assert.ok(harness.notices.some(([message, tone]) => tone === 'warn' && /settings change/.test(message)));
86
+
87
+ gate.resolve(true);
88
+ await flush();
89
+ assert.equal(closedCount(harness), 1);
90
+ assert.deepEqual(harness.opened, ['settings']);
91
+ });
92
+
93
+ test('an ack superseded by Esc and a newer submit never closes the live prompt', async () => {
94
+ supersedePanelEpoch();
95
+ const first = deferred();
96
+ const second = deferred();
97
+
98
+ const firstHarness = createHarness({
99
+ settingsPrompt: { kind: 'system-shell', label: 'System shell' },
100
+ store: { setSystemShell: () => first.promise },
101
+ });
102
+ assert.equal(firstHarness.onSubmit('pwsh'), true);
103
+
104
+ // Esc on the prompt (cancelSettingsPrompt) hands the surface back.
105
+ supersedePanelEpoch();
106
+
107
+ // A fresh prompt + submit: the abandoned write must not block it.
108
+ const secondHarness = createHarness({
109
+ settingsPrompt: { kind: 'system-shell', label: 'System shell' },
110
+ store: { setSystemShell: () => second.promise },
111
+ });
112
+ assert.equal(secondHarness.onSubmit('bash'), true);
113
+
114
+ first.resolve(true);
115
+ await flush();
116
+ // The older ack owns nothing: no close, no navigation, no success claim.
117
+ assert.equal(closedCount(firstHarness), 0);
118
+ assert.deepEqual(firstHarness.opened, []);
119
+
120
+ second.resolve(true);
121
+ await flush();
122
+ assert.equal(closedCount(secondHarness), 1);
123
+ assert.deepEqual(secondHarness.opened, ['settings']);
124
+ });
125
+
126
+ test('a rejected settings write restores the typed value instead of closing', async () => {
127
+ supersedePanelEpoch();
128
+ const gate = deferred();
129
+ const harness = createHarness({
130
+ settingsPrompt: { kind: 'system-shell', label: 'System shell' },
131
+ store: { setSystemShell: () => gate.promise },
132
+ });
133
+ assert.equal(harness.onSubmit('pwsh -NoLogo'), true);
134
+
135
+ gate.reject(new Error('daemon offline'));
136
+ await flush();
137
+
138
+ assert.equal(closedCount(harness), 0);
139
+ const restored = harness.settingsPromptSet[harness.settingsPromptSet.length - 1];
140
+ assert.equal(restored.kind, 'system-shell');
141
+ assert.equal(restored.initialValue, 'pwsh -NoLogo');
142
+ assert.equal(restored.submitting, false);
143
+ assert.ok(restored.restoreEpoch >= 1);
144
+ assert.ok(harness.notices.some(([message, tone]) => tone === 'error' && /daemon offline/.test(message)));
145
+ });
146
+
147
+ test('empty submits reach the daemon as the documented reset/clear actions', async () => {
148
+ supersedePanelEpoch();
149
+ const shellCalls = [];
150
+ const shell = createHarness({
151
+ settingsPrompt: { kind: 'system-shell', label: 'System shell' },
152
+ store: { setSystemShell: (command) => { shellCalls.push(command); return Promise.resolve(true); } },
153
+ });
154
+ assert.equal(shell.onSubmit(''), true);
155
+ await flush();
156
+ assert.deepEqual(shellCalls, ['']);
157
+ assert.equal(closedCount(shell), 1);
158
+
159
+ supersedePanelEpoch();
160
+ const autoClearCalls = [];
161
+ const autoClear = createHarness({
162
+ settingsPrompt: { kind: 'autoclear-provider', label: 'Auto-clear · openai', provider: 'openai' },
163
+ store: { setAutoClear: (patch) => { autoClearCalls.push(patch); return Promise.resolve({ enabled: true }); } },
164
+ });
165
+ assert.equal(autoClear.onSubmit(''), true);
166
+ await flush();
167
+ assert.deepEqual(autoClearCalls, [{ provider: 'openai', resetProvider: true }]);
168
+ assert.ok(autoClear.notices.some(([message]) => /default reset/.test(message)));
169
+
170
+ supersedePanelEpoch();
171
+ const profileCalls = [];
172
+ const profile = createHarness({
173
+ settingsPrompt: { kind: 'profile-title', label: 'Profile · Title' },
174
+ store: { setProfile: (patch) => { profileCalls.push(patch); return Promise.resolve({ title: '' }); } },
175
+ });
176
+ assert.equal(profile.onSubmit(''), true);
177
+ await flush();
178
+ assert.deepEqual(profileCalls, [{ title: '' }]);
179
+ assert.ok(profile.notices.some(([message]) => /Title cleared/.test(message)));
180
+ });
181
+
182
+ test('the settings text-entry panel is wired to the clear-by-empty policy', async () => {
183
+ // The dispatcher cases above enter through onSubmit and therefore bypass
184
+ // TextEntryPanel entirely — they pass even when the view drops the prop and
185
+ // the panel refuses every blank submit. No JSX parser exists in this
186
+ // workspace, so the wiring is pinned as source text instead.
187
+ const view = await readFile(new URL('./app-view.jsx', import.meta.url), 'utf8');
188
+ assert.match(view, /import \{ textEntryClearsByEmpty \} from '\.\/text-entry-policy\.mjs';/);
189
+ assert.match(view, /allowEmpty=\{textEntryClearsByEmpty\(settingsPrompt\.kind\)\}/);
190
+ // The panel's own submit gate must consult the same policy value.
191
+ const panel = await readFile(new URL('../components/TextEntryPanel.jsx', import.meta.url), 'utf8');
192
+ assert.match(panel, /canSubmitTextEntry\(draftRef\.current\.value, allowEmpty\)/);
193
+ assert.match(panel, /canSubmitTextEntry\(next\.value, allowEmpty\)/);
194
+ });
195
+
196
+ test('clear-by-empty prompt kinds bypass the blank-submit gate', () => {
197
+ assert.equal(textEntryClearsByEmpty('system-shell'), true);
198
+ assert.equal(textEntryClearsByEmpty('autoclear-provider'), true);
199
+ assert.equal(textEntryClearsByEmpty('profile-title'), true);
200
+ assert.equal(textEntryClearsByEmpty('core-add'), false);
201
+ assert.equal(textEntryClearsByEmpty(''), false);
202
+ assert.equal(canSubmitTextEntry('', true), true);
203
+ assert.equal(canSubmitTextEntry(' ', true), true);
204
+ assert.equal(canSubmitTextEntry('', false), false);
205
+ assert.equal(canSubmitTextEntry(' value ', false), true);
206
+ });