mixdog 0.8.0 → 0.9.0

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 (285) hide show
  1. package/README.md +47 -23
  2. package/package.json +33 -27
  3. package/scripts/_test-folder-dialog.mjs +30 -0
  4. package/scripts/agent-parallel-smoke.mjs +388 -0
  5. package/scripts/agent-tag-reuse-smoke.mjs +183 -0
  6. package/scripts/background-task-meta-smoke.mjs +38 -0
  7. package/scripts/boot-smoke.mjs +52 -9
  8. package/scripts/build-runtime-linux.sh +348 -0
  9. package/scripts/build-runtime-macos.sh +217 -0
  10. package/scripts/build-runtime-windows.ps1 +242 -0
  11. package/scripts/compact-active-turn-test.mjs +68 -0
  12. package/scripts/compact-smoke.mjs +859 -129
  13. package/scripts/compact-trigger-migration-smoke.mjs +187 -0
  14. package/scripts/fix-brief-fn.mjs +35 -0
  15. package/scripts/fix-format-tool-surface.mjs +24 -0
  16. package/scripts/fix-tool-exec-visible.mjs +42 -0
  17. package/scripts/generate-runtime-manifest.mjs +166 -0
  18. package/scripts/hook-bus-test.mjs +330 -0
  19. package/scripts/lead-workflow-smoke.mjs +33 -39
  20. package/scripts/live-worker-smoke.mjs +43 -37
  21. package/scripts/llm-trace-summary.mjs +315 -0
  22. package/scripts/memory-meta-concurrency-test.mjs +20 -0
  23. package/scripts/output-style-smoke.mjs +56 -15
  24. package/scripts/parent-abort-link-test.mjs +44 -0
  25. package/scripts/patch-agent-brief.mjs +48 -0
  26. package/scripts/patch-app.mjs +21 -0
  27. package/scripts/patch-app2.mjs +18 -0
  28. package/scripts/patch-dist-brief.mjs +96 -0
  29. package/scripts/patch-tool-exec.mjs +70 -0
  30. package/scripts/pretool-ask-runtime-test.mjs +54 -0
  31. package/scripts/provider-toolcall-test.mjs +376 -0
  32. package/scripts/reactive-compact-persist-smoke.mjs +124 -0
  33. package/scripts/sanitize-tool-pairs-test.mjs +260 -0
  34. package/scripts/session-context-bench.mjs +344 -0
  35. package/scripts/session-ingest-smoke.mjs +177 -0
  36. package/scripts/set-effort-config-test.mjs +41 -0
  37. package/scripts/smoke-runtime-negative.ps1 +106 -0
  38. package/scripts/smoke-runtime-negative.sh +97 -0
  39. package/scripts/smoke.mjs +25 -0
  40. package/scripts/tool-result-hook-test.mjs +48 -0
  41. package/scripts/tool-smoke.mjs +1223 -95
  42. package/scripts/toolcall-args-test.mjs +150 -0
  43. package/scripts/tui-background-failure-smoke.mjs +73 -0
  44. package/scripts/usage-metrics-epoch-smoke.mjs +114 -0
  45. package/src/agents/debugger/AGENT.md +8 -0
  46. package/src/agents/explore/AGENT.md +4 -0
  47. package/src/agents/heavy-worker/AGENT.md +9 -3
  48. package/src/agents/maintainer/AGENT.md +4 -0
  49. package/src/agents/reviewer/AGENT.md +8 -0
  50. package/src/agents/scheduler-task/AGENT.md +12 -0
  51. package/src/agents/scheduler-task/agent.json +6 -0
  52. package/src/agents/webhook-handler/AGENT.md +12 -0
  53. package/src/agents/webhook-handler/agent.json +6 -0
  54. package/src/agents/worker/AGENT.md +9 -3
  55. package/src/app.mjs +77 -3
  56. package/src/defaults/hidden-roles.json +17 -12
  57. package/src/headless-role.mjs +117 -0
  58. package/src/help.mjs +30 -0
  59. package/src/hooks/lib/permission-evaluator.cjs +11 -475
  60. package/src/lib/keychain-cjs.cjs +9 -1
  61. package/src/lib/mixdog-debug.cjs +0 -7
  62. package/src/lib/rules-builder.cjs +240 -96
  63. package/src/lib/text-utils.cjs +1 -1
  64. package/src/mixdog-session-runtime.mjs +2325 -450
  65. package/src/output-styles/default.md +12 -28
  66. package/src/output-styles/extreme-simple.md +9 -6
  67. package/src/output-styles/simple.md +22 -9
  68. package/src/repl.mjs +118 -59
  69. package/src/rules/agent/00-common.md +15 -0
  70. package/src/rules/{bridge → agent}/20-skip-protocol.md +1 -2
  71. package/src/rules/agent/30-explorer.md +22 -0
  72. package/src/rules/{bridge → agent}/40-cycle1-agent.md +7 -0
  73. package/src/rules/{bridge → agent}/41-cycle2-agent.md +7 -0
  74. package/src/rules/{bridge → agent}/42-cycle3-agent.md +7 -0
  75. package/src/rules/lead/01-general.md +9 -5
  76. package/src/rules/lead/04-workflow.md +51 -12
  77. package/src/rules/lead/lead-tool.md +6 -0
  78. package/src/rules/shared/01-tool.md +12 -1
  79. package/src/runtime/agent/orchestrator/activity-bus.mjs +7 -18
  80. package/src/runtime/agent/orchestrator/agent-owner.mjs +11 -0
  81. package/src/runtime/agent/orchestrator/{smart-bridge/bridge-llm.mjs → agent-runtime/agent-dispatch.mjs} +138 -111
  82. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +94 -0
  83. package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/cache-strategy.mjs +32 -23
  84. package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/session-builder.mjs +33 -27
  85. package/src/runtime/agent/orchestrator/{bridge-trace.mjs → agent-trace.mjs} +132 -81
  86. package/src/runtime/agent/orchestrator/cache-mtime.mjs +0 -21
  87. package/src/runtime/agent/orchestrator/config.mjs +174 -55
  88. package/src/runtime/agent/orchestrator/context/collect.mjs +195 -487
  89. package/src/runtime/agent/orchestrator/dispatch-persist.mjs +1 -1
  90. package/src/runtime/agent/orchestrator/internal-roles.mjs +77 -29
  91. package/src/runtime/agent/orchestrator/internal-tools.mjs +5 -6
  92. package/src/runtime/agent/orchestrator/mcp/client.mjs +15 -9
  93. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -1
  94. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +377 -243
  95. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +146 -93
  96. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +236 -4
  97. package/src/runtime/agent/orchestrator/providers/custom-tool-wire.mjs +49 -0
  98. package/src/runtime/agent/orchestrator/providers/gemini.mjs +58 -13
  99. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -149
  100. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +132 -2
  101. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +4 -1
  102. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +45 -0
  103. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +61 -116
  104. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +25 -0
  105. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +79 -255
  106. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +221 -70
  107. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +477 -147
  108. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +343 -496
  109. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +6 -6
  110. package/src/runtime/agent/orchestrator/providers/registry.mjs +88 -51
  111. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +31 -11
  112. package/src/runtime/agent/orchestrator/providers/statusline-route-meta.mjs +41 -8
  113. package/src/runtime/agent/orchestrator/session/cache/post-edit-marks.mjs +4 -4
  114. package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +1 -1
  115. package/src/runtime/agent/orchestrator/session/compact.mjs +1173 -267
  116. package/src/runtime/agent/orchestrator/session/context-utils.mjs +199 -36
  117. package/src/runtime/agent/orchestrator/session/loop.mjs +851 -674
  118. package/src/runtime/agent/orchestrator/session/manager.mjs +1593 -466
  119. package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +107 -0
  120. package/src/runtime/agent/orchestrator/session/store.mjs +291 -46
  121. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +2 -2
  122. package/src/runtime/agent/orchestrator/stall-policy.mjs +31 -16
  123. package/src/runtime/agent/orchestrator/tool-loop-guard.mjs +3 -219
  124. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +34 -7
  125. package/src/runtime/agent/orchestrator/tools/builtin/advisory-lock.mjs +1 -1
  126. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +19 -0
  127. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +9 -9
  128. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +60 -37
  129. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +21 -2
  130. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +1 -1
  131. package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -7
  132. package/src/runtime/agent/orchestrator/tools/builtin/glob-walk.mjs +1 -3
  133. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +36 -12
  134. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +2 -0
  135. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -2
  136. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +5 -12
  137. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +1 -1
  138. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +4 -36
  139. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +2 -40
  140. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +148 -27
  141. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +2 -2
  142. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +43 -75
  143. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +90 -20
  144. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +1 -1
  145. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -5
  146. package/src/runtime/agent/orchestrator/tools/code-graph-state.mjs +86 -0
  147. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +11 -11
  148. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4106 -4019
  149. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -4
  150. package/src/runtime/agent/orchestrator/tools/patch.mjs +90 -6
  151. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +6 -4
  152. package/src/runtime/agent/orchestrator/tools/result-compression.mjs +4 -4
  153. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +8 -1
  154. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +4 -4
  155. package/src/runtime/channels/index.mjs +152 -24
  156. package/src/runtime/channels/lib/scheduler.mjs +18 -14
  157. package/src/runtime/channels/lib/session-discovery.mjs +3 -2
  158. package/src/runtime/channels/lib/tool-format.mjs +0 -2
  159. package/src/runtime/channels/lib/transcript-discovery.mjs +3 -2
  160. package/src/runtime/channels/lib/webhook.mjs +1 -1
  161. package/src/runtime/channels/tool-defs.mjs +29 -29
  162. package/src/runtime/memory/index.mjs +635 -107
  163. package/src/runtime/memory/lib/agent-ipc.mjs +29 -12
  164. package/src/runtime/memory/lib/core-memory-store.mjs +2 -2
  165. package/src/runtime/memory/lib/embedding-model-config.mjs +55 -0
  166. package/src/runtime/memory/lib/embedding-provider.mjs +31 -4
  167. package/src/runtime/memory/lib/embedding-worker.mjs +19 -10
  168. package/src/runtime/memory/lib/memory-cycle1.mjs +38 -17
  169. package/src/runtime/memory/lib/memory-cycle2.mjs +6 -7
  170. package/src/runtime/memory/lib/memory-cycle3.mjs +4 -4
  171. package/src/runtime/memory/lib/memory-ops-policy.mjs +2 -1
  172. package/src/runtime/memory/lib/memory-session-merge.mjs +38 -0
  173. package/src/runtime/memory/lib/memory.mjs +88 -9
  174. package/src/runtime/memory/lib/model-profile.mjs +1 -1
  175. package/src/runtime/memory/lib/pg/adapter.mjs +15 -1
  176. package/src/runtime/memory/lib/pg/supervisor.mjs +12 -0
  177. package/src/runtime/memory/lib/runtime-fetcher.mjs +37 -3
  178. package/src/runtime/memory/lib/session-ingest.mjs +194 -0
  179. package/src/runtime/memory/lib/trace-store.mjs +96 -51
  180. package/src/runtime/memory/tool-defs.mjs +46 -37
  181. package/src/runtime/search/index.mjs +102 -466
  182. package/src/runtime/search/lib/web-tools.mjs +45 -25
  183. package/src/runtime/search/tool-defs.mjs +16 -23
  184. package/src/runtime/shared/abort-controller.mjs +1 -1
  185. package/src/runtime/shared/atomic-file.mjs +4 -3
  186. package/src/runtime/shared/background-tasks.mjs +122 -11
  187. package/src/runtime/shared/child-spawn-gate.mjs +145 -0
  188. package/src/runtime/shared/config.mjs +7 -4
  189. package/src/runtime/shared/err-text.mjs +131 -4
  190. package/src/runtime/shared/llm/cost.mjs +2 -2
  191. package/src/runtime/shared/llm/http-agent.mjs +23 -7
  192. package/src/runtime/shared/llm/index.mjs +34 -11
  193. package/src/runtime/shared/llm/usage-log.mjs +4 -4
  194. package/src/runtime/shared/markdown-frontmatter.mjs +56 -0
  195. package/src/runtime/shared/singleton-owner.mjs +104 -0
  196. package/src/runtime/shared/tool-execution-contract.mjs +199 -20
  197. package/src/runtime/shared/tool-execution-contract.test.mjs +183 -0
  198. package/src/runtime/shared/tool-surface.mjs +624 -98
  199. package/src/runtime/shared/user-data-guard.mjs +0 -2
  200. package/src/standalone/agent-task-status.mjs +203 -0
  201. package/src/standalone/agent-task-status.test.mjs +76 -0
  202. package/src/standalone/agent-tool.mjs +1913 -0
  203. package/src/standalone/channel-worker.mjs +370 -14
  204. package/src/standalone/explore-tool.mjs +165 -70
  205. package/src/standalone/folder-dialog.mjs +314 -0
  206. package/src/standalone/hook-bus.mjs +898 -22
  207. package/src/standalone/memory-runtime-proxy.mjs +320 -0
  208. package/src/standalone/projects.mjs +226 -0
  209. package/src/standalone/provider-admin.mjs +41 -24
  210. package/src/standalone/seeds.mjs +2 -69
  211. package/src/standalone/usage-dashboard.mjs +96 -8
  212. package/src/tui/App.jsx +4798 -2153
  213. package/src/tui/components/AnsiText.jsx +39 -28
  214. package/src/tui/components/ContextPanel.jsx +87 -29
  215. package/src/tui/components/Markdown.jsx +43 -77
  216. package/src/tui/components/MarkdownTable.jsx +9 -184
  217. package/src/tui/components/Message.jsx +28 -11
  218. package/src/tui/components/Picker.jsx +95 -56
  219. package/src/tui/components/PromptInput.jsx +367 -239
  220. package/src/tui/components/QueuedCommands.jsx +1 -1
  221. package/src/tui/components/SlashCommandPalette.jsx +27 -21
  222. package/src/tui/components/Spinner.jsx +67 -38
  223. package/src/tui/components/StatusLine.jsx +606 -38
  224. package/src/tui/components/TextEntryPanel.jsx +128 -9
  225. package/src/tui/components/ToolExecution.jsx +617 -368
  226. package/src/tui/components/TurnDone.jsx +3 -3
  227. package/src/tui/components/UsagePanel.jsx +3 -5
  228. package/src/tui/components/tool-output-format.mjs +365 -0
  229. package/src/tui/components/tool-output-format.test.mjs +220 -0
  230. package/src/tui/dist/index.mjs +8915 -2418
  231. package/src/tui/engine-runtime-notification.test.mjs +115 -0
  232. package/src/tui/engine-tool-result-text.test.mjs +75 -0
  233. package/src/tui/engine.mjs +1455 -279
  234. package/src/tui/figures.mjs +21 -40
  235. package/src/tui/index.jsx +75 -31
  236. package/src/tui/input-editing.mjs +25 -0
  237. package/src/tui/markdown/format-token.mjs +511 -68
  238. package/src/tui/markdown/format-token.test.mjs +216 -0
  239. package/src/tui/markdown/render-ansi.mjs +94 -0
  240. package/src/tui/markdown/render-ansi.test.mjs +108 -0
  241. package/src/tui/markdown/stream-fence.mjs +34 -0
  242. package/src/tui/markdown/stream-fence.test.mjs +26 -0
  243. package/src/tui/markdown/table-layout.mjs +250 -0
  244. package/src/tui/paste-attachments.mjs +0 -7
  245. package/src/tui/spinner-verbs.mjs +1 -2
  246. package/src/tui/statusline-ansi-bridge.mjs +172 -0
  247. package/src/tui/statusline-ansi-bridge.test.mjs +159 -0
  248. package/src/tui/theme.mjs +746 -24
  249. package/src/tui/time-format.mjs +1 -1
  250. package/src/tui/transcript-tool-failures.mjs +67 -0
  251. package/src/tui/transcript-tool-failures.test.mjs +111 -0
  252. package/src/ui/ansi.mjs +1 -2
  253. package/src/ui/markdown.mjs +85 -26
  254. package/src/ui/markdown.test.mjs +70 -0
  255. package/src/ui/model-display.mjs +121 -0
  256. package/src/ui/session-stats.mjs +44 -0
  257. package/src/ui/statusline-context-label.test.mjs +15 -0
  258. package/src/ui/statusline.mjs +386 -178
  259. package/src/ui/tool-card.mjs +3 -16
  260. package/src/vendor/statusline/bin/statusline-lib.mjs +8 -4
  261. package/src/vendor/statusline/bin/statusline-route.mjs +169 -37
  262. package/src/vendor/statusline/bin/statusline-route.test.mjs +80 -0
  263. package/src/vendor/statusline/scripts/lib/gateway-settings.mjs +3 -3
  264. package/src/vendor/statusline/src/gateway/claude-current.mjs +1 -1
  265. package/src/vendor/statusline/src/gateway/route-meta.mjs +44 -6
  266. package/src/vendor/statusline/src/gateway/session-routes.mjs +1 -1
  267. package/src/workflows/default/WORKFLOW.md +12 -5
  268. package/src/workflows/default/workflow.json +0 -1
  269. package/src/workflows/solo/WORKFLOW.md +15 -0
  270. package/src/workflows/solo/workflow.json +7 -0
  271. package/vendor/ink/build/output.js +6 -1
  272. package/src/agents/scheduler-task.md +0 -3
  273. package/src/agents/web-researcher/AGENT.md +0 -3
  274. package/src/agents/web-researcher/agent.json +0 -6
  275. package/src/agents/webhook-handler.md +0 -3
  276. package/src/rules/bridge/00-common.md +0 -5
  277. package/src/rules/bridge/30-explorer.md +0 -4
  278. package/src/rules/lead/00-tool-lead.md +0 -5
  279. package/src/rules/shared/00-language.md +0 -3
  280. package/src/runtime/agent/orchestrator/tools/builtin/native-edit-runner.mjs +0 -110
  281. package/src/runtime/agent/orchestrator/tools/mutation-content-cache.mjs +0 -67
  282. package/src/runtime/memory/lib/bridge-trace-queries.mjs +0 -120
  283. package/src/runtime/shared/llm/pid-cleanup.mjs +0 -27
  284. package/src/standalone/bridge-tool.mjs +0 -1414
  285. package/src/tui/runtime/shared/process-shutdown.mjs +0 -1
@@ -1,38 +1,25 @@
1
1
  import { isAbsolute, resolve } from 'node:path';
2
2
  import { homedir } from 'node:os';
3
- import {
4
- TOOL_ASYNC_EXECUTION_CONTRACT,
5
- TOOL_SYNC_EXECUTION_CONTRACT,
6
- cancelBackgroundTask,
7
- executionModeSchemaDescription,
8
- getBackgroundTask,
9
- renderBackgroundTask,
10
- renderBackgroundTaskList,
11
- resolveExecutionMode,
12
- startBackgroundTask,
13
- taskIdFromArgs,
14
- } from '../runtime/shared/background-tasks.mjs';
15
- import { makeBridgeLlm } from '../runtime/agent/orchestrator/smart-bridge/bridge-llm.mjs';
3
+ import { makeAgentDispatch } from '../runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs';
4
+ import { loadConfig } from '../runtime/agent/orchestrator/config.mjs';
5
+ import { initProviders } from '../runtime/agent/orchestrator/providers/registry.mjs';
6
+ import { presentErrorText } from '../runtime/shared/err-text.mjs';
16
7
 
17
8
  // Ported from the original mixdog tool-defs.mjs `explore` entry.
18
9
  // `aiWrapped` is dropped: in the standalone build there is no aiWrapped
19
10
  // dispatch hub — execution is wired directly in the runtime executor below
20
- // via makeBridgeLlm. The standalone surface is synchronous: it returns a
11
+ // via makeAgentDispatch. The standalone surface is synchronous: it returns a
21
12
  // bounded locator result in this tool call.
22
13
  export const EXPLORE_TOOL = {
23
14
  name: 'explore',
24
15
  title: 'Explore',
25
16
  annotations: { title: 'Explore', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
26
- description: `Broad-scope locator only; returns unverified leads. Prefer mode=async for broad exploration that can continue in parallel; use sync only when the next step must block on this locator result. ${TOOL_SYNC_EXECUTION_CONTRACT} ${TOOL_ASYNC_EXECUTION_CONTRACT} Use code_graph/grep/glob first when any symbol, term, file kind, or config clue exists. Use explore only after direct narrowing fails or topics are unrelated. One short location question.`,
17
+ description: 'Repo anchor locator. Use for broad/uncertain paths. Array only for independent targets.',
27
18
  inputSchema: {
28
19
  type: 'object',
29
20
  properties: {
30
- query: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }], description: 'One short location question, or an array only for unrelated topics. Do not use for a known symbol/term/config key; use code_graph/grep first. Never pass a whole brief/context dump.' },
31
- cwd: { type: 'string', description: 'Project/root directory to explore; narrow to the relevant repo or subtree.' },
32
- mode: { type: 'string', enum: ['async', 'sync'], description: `${executionModeSchemaDescription('sync')} Prefer async for non-trivial exploration; choose sync only for an explicit blocking lookup.` },
33
- action: { type: 'string', enum: ['run', 'list', 'status', 'read', 'cancel'], description: 'Default run. list/status/read/cancel are manual recovery controls for async explore tasks.' },
34
- task_id: { type: 'string', description: 'Shared background task id for manual status/read/cancel recovery.' },
35
- background: { type: 'boolean', description: 'Legacy alias for mode=async.' },
21
+ query: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }], description: 'Narrow locator query; array only for independent targets.' },
22
+ cwd: { type: 'string', description: 'Project/root directory.' },
36
23
  },
37
24
  required: [],
38
25
  additionalProperties: false,
@@ -46,6 +33,12 @@ export const EXPLORE_OUTPUT_CHAR_CAP = 24_000;
46
33
  const EXPLORE_TRUNCATION_MARKER = '\n\n[explore: output truncated; narrow cwd or split queries to see more]';
47
34
  // Bound fan-out so a hostile/poisoned query array cannot spawn unbounded subs.
48
35
  export const MAX_FANOUT_QUERIES = 8;
36
+ const EXPLORE_RESULT_CACHE_MAX_ENTRIES = 64;
37
+ const EXPLORE_RESULT_CACHE_TTL_MS = (() => {
38
+ const raw = Number(process.env.MIXDOG_EXPLORE_RESULT_CACHE_TTL_MS);
39
+ return Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : 5 * 60_000;
40
+ })();
41
+ const EXPLORE_RESULT_CACHE = new Map(); // key -> { ts, value?, promise? }
49
42
 
50
43
  function escapeXml(str) {
51
44
  return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
@@ -54,9 +47,9 @@ function escapeXml(str) {
54
47
  // Mirrors buildExplorerPrompt from the original ai-wrapped-dispatch: each
55
48
  // explorer receives ONLY its own query, with a trailing descriptive-only
56
49
  // reminder. The full no-verdict contract lives at system level
57
- // (rules/bridge/30-explorer.md).
50
+ // (rules/agent/30-explorer.md).
58
51
  export function buildExplorerPrompt(query) {
59
- return `<query>${escapeXml(query)}</query>\nReminder: describe with file:line evidence; no verdicts, ratings, or recommendations.`;
52
+ return `<query>${escapeXml(query)}</query>\nReminder: output only anchor lines formatted as path:line symbol/name — short reason, or EXPLORATION_FAILED. No preamble, bullets, numbering, headings, summary, code quotes, analysis, verdicts, ratings, or recommendations.`;
60
53
  }
61
54
 
62
55
  export function normalizeExploreQueries(rawQuery) {
@@ -91,11 +84,28 @@ function settledExplorerResult(result) {
91
84
  if (result?.status !== 'fulfilled') {
92
85
  return { ok: false, text: `[explorer error] ${result?.reason?.message || String(result?.reason)}` };
93
86
  }
94
- const text = typeof result.value === 'string' ? result.value.trim() : responseText(result.value).trim();
87
+ const text = cleanExplorerText(typeof result.value === 'string' ? result.value : responseText(result.value));
95
88
  if (!text) return { ok: false, text: '[explorer error] empty response' };
96
89
  return { ok: true, text };
97
90
  }
98
91
 
92
+ function isFatalExploreError(error) {
93
+ if (!error) return false;
94
+ const status = Number(error.httpStatus || error.status || error.response?.status || 0) || 0;
95
+ if (status === 401 || status === 403 || status === 429) return true;
96
+ if (error.providerQuota === true || error.quotaExceeded === true) return true;
97
+ const code = String(error.code || '').toUpperCase();
98
+ if (code === 'PROVIDER_QUOTA' || code === 'EACCES' || code === 'EAUTH') return true;
99
+ const name = String(error.name || '');
100
+ if (/ProviderQuotaError|Auth|Authentication|Permission/i.test(name)) return true;
101
+ const text = String(error.message || error.reason || error || '');
102
+ return /\b(?:quota|quota_exceeded|insufficient_quota|rate[_ -]?limit|too many requests|resource exhausted|unauthorized|authentication|forbidden|permission denied|invalid api key|token expired)\b/i.test(text);
103
+ }
104
+
105
+ function fatalExploreError(settled) {
106
+ return (settled || []).find((r) => r?.status === 'rejected' && isFatalExploreError(r.reason))?.reason || null;
107
+ }
108
+
99
109
  function mergeSettled(settled, queries) {
100
110
  const single = queries.length === 1;
101
111
  if (single) {
@@ -109,7 +119,7 @@ function mergeSettled(settled, queries) {
109
119
  const sep = '\n\n';
110
120
  let truncated = false;
111
121
  for (let i = 0; i < settled.length; i++) {
112
- const header = `## Q${i + 1}: ${String(queries[i] ?? '').replace(/\s+/g, ' ').slice(0, 60)}`;
122
+ const header = `Q${i + 1}: ${String(queries[i] ?? '').replace(/\s+/g, ' ').slice(0, 60)}`;
113
123
  const { text: body } = settledExplorerResult(settled[i]);
114
124
  const piece = `${header}\n${body}`;
115
125
  const addLen = (parts.length === 0 ? 0 : sep.length) + piece.length;
@@ -138,6 +148,90 @@ function clean(value) {
138
148
  return String(value ?? '').trim();
139
149
  }
140
150
 
151
+ function exploreResultCacheEnabled() {
152
+ return EXPLORE_RESULT_CACHE_TTL_MS > 0
153
+ && !/^(?:0|false|off|no)$/i.test(String(process.env.MIXDOG_EXPLORE_RESULT_CACHE || '1'));
154
+ }
155
+
156
+ function exploreResultCacheKey({ cwd, presetName, query }) {
157
+ return JSON.stringify({
158
+ cwd: String(cwd || ''),
159
+ presetName: String(presetName || ''),
160
+ query: String(query || ''),
161
+ });
162
+ }
163
+
164
+ function findConfigPreset(config, presetName) {
165
+ const wanted = clean(presetName);
166
+ if (!wanted) return null;
167
+ return (Array.isArray(config?.presets) ? config.presets : [])
168
+ .find((preset) => clean(preset?.id) === wanted || clean(preset?.name) === wanted)
169
+ || null;
170
+ }
171
+
172
+ async function ensureExploreProviderReady(config) {
173
+ const preset = findConfigPreset(config, config?.maintenance?.explore);
174
+ const provider = clean(preset?.provider);
175
+ if (!provider) return;
176
+ const providers = { ...(config?.providers || {}) };
177
+ providers[provider] = { ...(providers[provider] || {}), enabled: true };
178
+ await initProviders(providers);
179
+ }
180
+
181
+ function scheduleExploreCodeGraphPrewarm(cwd) {
182
+ if (/^(?:1|true|on|yes)$/i.test(String(process.env.MIXDOG_DISABLE_EXPLORE_CODE_GRAPH_PREWARM || ''))) return;
183
+ void import('../runtime/agent/orchestrator/tools/code-graph.mjs')
184
+ .then((mod) => mod?.prewarmCodeGraphIfProject?.(cwd))
185
+ .catch(() => { /* best-effort */ });
186
+ }
187
+
188
+ function getCachedExploreResult(key) {
189
+ if (!exploreResultCacheEnabled()) return null;
190
+ const entry = EXPLORE_RESULT_CACHE.get(key);
191
+ if (!entry) return null;
192
+ if (Date.now() - entry.ts > EXPLORE_RESULT_CACHE_TTL_MS) {
193
+ EXPLORE_RESULT_CACHE.delete(key);
194
+ return null;
195
+ }
196
+ if (typeof entry.value === 'string') {
197
+ EXPLORE_RESULT_CACHE.delete(key);
198
+ EXPLORE_RESULT_CACHE.set(key, entry);
199
+ return entry.value;
200
+ }
201
+ return null;
202
+ }
203
+
204
+ async function runExploreCached(key, compute) {
205
+ const cached = getCachedExploreResult(key);
206
+ if (cached !== null) return cached;
207
+ if (!exploreResultCacheEnabled()) return await compute();
208
+ const pending = EXPLORE_RESULT_CACHE.get(key)?.promise;
209
+ if (pending) return await pending;
210
+ const promise = Promise.resolve()
211
+ .then(() => compute())
212
+ .then((value) => {
213
+ const text = typeof value === 'string' ? value : responseText(value);
214
+ const cleaned = cleanExplorerText(text);
215
+ if (cleaned && cleaned !== 'EXPLORATION_FAILED') {
216
+ EXPLORE_RESULT_CACHE.set(key, { ts: Date.now(), value: text });
217
+ while (EXPLORE_RESULT_CACHE.size > EXPLORE_RESULT_CACHE_MAX_ENTRIES) {
218
+ const oldest = EXPLORE_RESULT_CACHE.keys().next().value;
219
+ if (!oldest) break;
220
+ EXPLORE_RESULT_CACHE.delete(oldest);
221
+ }
222
+ } else {
223
+ EXPLORE_RESULT_CACHE.delete(key);
224
+ }
225
+ return value;
226
+ })
227
+ .catch((err) => {
228
+ EXPLORE_RESULT_CACHE.delete(key);
229
+ throw err;
230
+ });
231
+ EXPLORE_RESULT_CACHE.set(key, { ts: Date.now(), promise });
232
+ return await promise;
233
+ }
234
+
141
235
  function responseText(response) {
142
236
  if (typeof response === 'string') return response;
143
237
  const parts = Array.isArray(response?.content) ? response.content : [];
@@ -148,57 +242,49 @@ function responseText(response) {
148
242
  return text || JSON.stringify(response, null, 2);
149
243
  }
150
244
 
151
- function controlExploreTask(action, args, ctx = {}) {
152
- if (action === 'list') return ok(renderBackgroundTaskList({ surface: 'explore', context: ctx }));
153
- const taskId = taskIdFromArgs(args);
154
- if (!taskId) return fail('task_id is required');
155
- const task = getBackgroundTask(taskId, { surface: 'explore', context: ctx });
156
- if (!task) return fail(`task not found: ${taskId}`);
157
- if (action === 'cancel') {
158
- cancelBackgroundTask(taskId, 'cancelled by explore control');
159
- return ok(renderBackgroundTask(task, { includeResult: true }));
245
+ const ANCHOR_LINE_RE = /(?:^|\s)(?:[A-Za-z]:[\\/][^:\n]+|\.{0,2}[\\/][^:\n]+|src[\\/][^:\n]+|[\w.-]+[\\/][^:\n]+):\d+\b/;
246
+ const FAILED_RE = /\b(?:EXPLORATION_FAILED|exploration failed|no credible (?:anchor|location)|no relevant (?:anchor|location)|not found|could(?: not|n't) find)\b/i;
247
+
248
+ function normalizeExplorerLine(line) {
249
+ return String(line || '')
250
+ .trim()
251
+ .replace(/^[-*•]\s+/, '')
252
+ .replace(/^\d+[.)]\s+/, '')
253
+ .replace(/^>\s*/, '')
254
+ .replace(/`/g, '')
255
+ .replace(/\*\*/g, '')
256
+ .trim();
257
+ }
258
+
259
+ function cleanExplorerText(text) {
260
+ const raw = String(text || '').trim();
261
+ if (!raw) return '';
262
+ const anchors = [];
263
+ for (const sourceLine of raw.split(/\r?\n/)) {
264
+ const line = normalizeExplorerLine(sourceLine);
265
+ if (!line || /^-{3,}$/.test(line) || /^#+\s+/.test(line)) continue;
266
+ if (/^EXPLORATION_FAILED\b/i.test(line)) return 'EXPLORATION_FAILED';
267
+ if (ANCHOR_LINE_RE.test(line)) anchors.push(line);
160
268
  }
161
- return ok(renderBackgroundTask(task, { includeResult: action === 'read' }));
269
+ if (anchors.length) return anchors.join('\n');
270
+ if (FAILED_RE.test(raw)) return 'EXPLORATION_FAILED';
271
+ return raw;
162
272
  }
163
273
 
164
274
  /**
165
275
  * Standalone explore executor.
166
276
  *
167
277
  * Dispatches the hidden `explorer` read-only filesystem role (one ephemeral
168
- * sub-session per query) via makeBridgeLlm — the same path recall/search-style
278
+ * sub-session per query) via makeAgentDispatch — the same path recall/search-style
169
279
  * hidden roles use. Runs query items concurrently (Promise.allSettled), then
170
280
  * aggregates the findings into one bounded text result.
171
281
  *
172
- * Runs synchronously by default. With mode=async/background:true, registers a
173
- * shared background task and delivers completion to the owner session.
282
+ * Runs synchronously and returns the bounded locator result in this tool call.
174
283
  *
175
- * @param {object} args — tool args ({ query, cwd, background }).
284
+ * @param {object} args — tool args ({ query, cwd }).
176
285
  * @param {object} ctx — { callerCwd, callerSessionId }.
177
286
  */
178
287
  export async function runExplore(args = {}, ctx = {}) {
179
- const action = clean(args.action || 'run').toLowerCase();
180
- if (['list', 'status', 'read', 'cancel'].includes(action)) return controlExploreTask(action, args, ctx);
181
-
182
- const mode = resolveExecutionMode(args, 'sync');
183
- if (mode === 'async') {
184
- const queries = normalizeExploreQueries(args.query);
185
- if (queries.length === 0) return fail('query is required (one or more non-empty strings)');
186
- const task = startBackgroundTask({
187
- surface: 'explore',
188
- operation: 'explore',
189
- label: queries[0].replace(/\s+/g, ' ').slice(0, 120),
190
- input: { query: args.query, cwd: args.cwd || null },
191
- context: ctx,
192
- resultType: 'explore_task_result',
193
- renderResult: responseText,
194
- run: async () => {
195
- const response = await runExploreSync(args, ctx);
196
- if (response?.isError) throw new Error(responseText(response));
197
- return response;
198
- },
199
- });
200
- return ok(renderBackgroundTask(task));
201
- }
202
288
  return runExploreSync(args, ctx);
203
289
  }
204
290
 
@@ -214,18 +300,27 @@ async function runExploreSync(args = {}, ctx = {}) {
214
300
  }
215
301
 
216
302
  const resolvedCwd = resolveExploreCwd(args.cwd, ctx.callerCwd);
303
+ scheduleExploreCodeGraphPrewarm(resolvedCwd);
304
+ const config = loadConfig();
305
+ await ensureExploreProviderReady(config);
306
+ const presetName = config?.maintenance?.explore || '';
307
+ const llm = makeAgentDispatch({
308
+ role: 'explorer',
309
+ cwd: resolvedCwd,
310
+ brief: true,
311
+ parentSessionId: ctx.callerSessionId || null,
312
+ clientHostPid: ctx.clientHostPid || null,
313
+ config,
314
+ });
217
315
 
218
316
  const settled = await Promise.allSettled(working.map((q) => {
219
- const llm = makeBridgeLlm({
220
- role: 'explorer',
221
- cwd: resolvedCwd,
222
- brief: true,
223
- parentSessionId: ctx.callerSessionId || null,
224
- clientHostPid: ctx.clientHostPid || null,
225
- });
226
- return llm({ prompt: buildExplorerPrompt(q) });
317
+ const key = exploreResultCacheKey({ cwd: resolvedCwd, presetName, query: q });
318
+ return runExploreCached(key, () => llm({ prompt: buildExplorerPrompt(q) }));
227
319
  }));
228
320
 
321
+ const fatal = fatalExploreError(settled);
322
+ if (fatal) return fail(presentErrorText(fatal, { surface: 'explore' }));
323
+
229
324
  const merged = mergeSettled(settled, working);
230
325
  const allFailed = settled.every((r) => !settledExplorerResult(r).ok);
231
326
  const out = capNotice + merged;
@@ -0,0 +1,314 @@
1
+ /**
2
+ * standalone/folder-dialog.mjs — cross-platform native "choose folder" dialog.
3
+ *
4
+ * Spawns the OS-native directory picker and resolves with the selected absolute
5
+ * path, or null when the user cancels / no dialog tool is available. The TUI
6
+ * falls back to manual path typing when this returns { available:false }.
7
+ *
8
+ * Windows : PowerShell IFileOpenDialog (Explorer-style folder picker)
9
+ * macOS : osascript `choose folder`
10
+ * Linux : zenity --file-selection --directory (fallback: kdialog)
11
+ */
12
+ import { spawn } from 'node:child_process';
13
+ import path from 'node:path';
14
+
15
+ const DIALOG_TIMEOUT_MS = 5 * 60 * 1000;
16
+
17
+ /** Run a command, capture stdout, resolve { code, stdout }. Never rejects. */
18
+ function runCapture(cmd, args, { timeoutMs = DIALOG_TIMEOUT_MS } = {}) {
19
+ return new Promise((resolve) => {
20
+ let child;
21
+ try {
22
+ child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });
23
+ } catch (error) {
24
+ resolve({ ok: false, code: -1, stdout: '', error });
25
+ return;
26
+ }
27
+ let stdout = '';
28
+ let settled = false;
29
+ const timer = setTimeout(() => {
30
+ if (settled) return;
31
+ settled = true;
32
+ try { child.kill(); } catch { /* ignore */ }
33
+ resolve({ ok: false, code: -1, stdout: '', error: new Error('dialog timed out') });
34
+ }, timeoutMs);
35
+ timer.unref?.();
36
+ child.stdout.on('data', (chunk) => { stdout += chunk.toString('utf8'); });
37
+ child.on('error', (error) => {
38
+ if (settled) return;
39
+ settled = true;
40
+ clearTimeout(timer);
41
+ resolve({ ok: false, code: -1, stdout: '', error });
42
+ });
43
+ child.on('close', (code) => {
44
+ if (settled) return;
45
+ settled = true;
46
+ clearTimeout(timer);
47
+ resolve({ ok: code === 0, code, stdout });
48
+ });
49
+ });
50
+ }
51
+
52
+ // Sentinel: runCapture sets code === -1 ONLY for spawn failures and timeouts
53
+ // (the dialog tool could not actually run). A clean non-zero exit (e.g. the
54
+ // user cancelling) keeps the real exit code, so callers can distinguish
55
+ // "tool unavailable / broken" from "user cancelled".
56
+ function spawnFailed(result) {
57
+ return !!result && result.code === -1;
58
+ }
59
+
60
+ /** Whether a command exists on PATH (best-effort, non-blocking spawn probe). */
61
+ function commandExists(cmd) {
62
+ return new Promise((resolve) => {
63
+ const probe = process.platform === 'win32'
64
+ ? spawn('where', [cmd], { stdio: 'ignore', windowsHide: true })
65
+ : spawn('which', [cmd], { stdio: 'ignore' });
66
+ probe.on('error', () => resolve(false));
67
+ probe.on('close', (code) => resolve(code === 0));
68
+ });
69
+ }
70
+
71
+ function psSingleQuoted(value) {
72
+ return String(value ?? '').replace(/'/g, "''");
73
+ }
74
+
75
+ /** Absolute directory path for the Windows picker initial folder (best-effort). */
76
+ function resolveInitialPath(initialPath) {
77
+ const trimmed = String(initialPath ?? '').trim();
78
+ try {
79
+ return path.win32.resolve(trimmed || process.cwd());
80
+ } catch {
81
+ return trimmed || process.cwd();
82
+ }
83
+ }
84
+
85
+ function powershellScript(title, initialPath) {
86
+ // Run STA so the COM file dialog works; print the selected path or nothing.
87
+ const safeTitle = psSingleQuoted(title || 'Select a project folder');
88
+ const safeInitialPath = psSingleQuoted(initialPath || '');
89
+ return [
90
+ // Force UTF-8 stdout so non-ASCII selected paths are not mangled by the
91
+ // console's default code page (PowerShell 5.1 is not UTF-8 by default).
92
+ '$ErrorActionPreference = "Stop"',
93
+ '[Console]::OutputEncoding = [Text.Encoding]::UTF8',
94
+ 'Add-Type -AssemblyName System.Windows.Forms | Out-Null',
95
+ `$code = @'
96
+ using System;
97
+ using System.Runtime.InteropServices;
98
+
99
+ namespace Mixdog {
100
+ public static class ExplorerFolderPicker {
101
+ private const int ERROR_CANCELLED = unchecked((int)0x800704C7);
102
+
103
+ [Flags]
104
+ private enum FOS : uint {
105
+ NOCHANGEDIR = 0x00000008,
106
+ PICKFOLDERS = 0x00000020,
107
+ FORCEFILESYSTEM = 0x00000040,
108
+ PATHMUSTEXIST = 0x00000800,
109
+ FILEMUSTEXIST = 0x00001000
110
+ }
111
+
112
+ private enum SIGDN : uint {
113
+ FILESYSPATH = 0x80058000
114
+ }
115
+
116
+ [ComImport, Guid("DC1C5A9C-E88A-4DDE-A5A1-60F82A20AEF7")]
117
+ private class FileOpenDialog {}
118
+
119
+ [ComImport, Guid("42f85136-db7e-439c-85f1-e4075d135fc8"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
120
+ private interface IFileDialog {
121
+ [PreserveSig] int Show(IntPtr parent);
122
+ [PreserveSig] int SetFileTypes(uint cFileTypes, IntPtr rgFilterSpec);
123
+ [PreserveSig] int SetFileTypeIndex(uint iFileType);
124
+ [PreserveSig] int GetFileTypeIndex(out uint piFileType);
125
+ [PreserveSig] int Advise(IntPtr pfde, out uint pdwCookie);
126
+ [PreserveSig] int Unadvise(uint dwCookie);
127
+ [PreserveSig] int SetOptions(FOS fos);
128
+ [PreserveSig] int GetOptions(out FOS pfos);
129
+ [PreserveSig] int SetDefaultFolder(IShellItem psi);
130
+ [PreserveSig] int SetFolder(IShellItem psi);
131
+ [PreserveSig] int GetFolder(out IShellItem ppsi);
132
+ [PreserveSig] int GetCurrentSelection(out IShellItem ppsi);
133
+ [PreserveSig] int SetFileName([MarshalAs(UnmanagedType.LPWStr)] string pszName);
134
+ [PreserveSig] int GetFileName([MarshalAs(UnmanagedType.LPWStr)] out string pszName);
135
+ [PreserveSig] int SetTitle([MarshalAs(UnmanagedType.LPWStr)] string pszTitle);
136
+ [PreserveSig] int SetOkButtonLabel([MarshalAs(UnmanagedType.LPWStr)] string pszText);
137
+ [PreserveSig] int SetFileNameLabel([MarshalAs(UnmanagedType.LPWStr)] string pszLabel);
138
+ [PreserveSig] int GetResult(out IShellItem ppsi);
139
+ [PreserveSig] int AddPlace(IShellItem psi, int fdap);
140
+ [PreserveSig] int SetDefaultExtension([MarshalAs(UnmanagedType.LPWStr)] string pszDefaultExtension);
141
+ [PreserveSig] int Close(int hr);
142
+ [PreserveSig] int SetClientGuid(ref Guid guid);
143
+ [PreserveSig] int ClearClientData();
144
+ [PreserveSig] int SetFilter(IntPtr pFilter);
145
+ }
146
+
147
+ [ComImport, Guid("43826d1e-e718-42ee-bc55-a1e261c37bfe"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
148
+ private interface IShellItem {
149
+ [PreserveSig] int BindToHandler(IntPtr pbc, ref Guid bhid, ref Guid riid, out IntPtr ppv);
150
+ [PreserveSig] int GetParent(out IShellItem ppsi);
151
+ [PreserveSig] int GetDisplayName(SIGDN sigdnName, out IntPtr ppszName);
152
+ [PreserveSig] int GetAttributes(uint sfgaoMask, out uint psfgaoAttribs);
153
+ [PreserveSig] int Compare(IShellItem psi, uint hint, out int piOrder);
154
+ }
155
+
156
+ [DllImport("shell32.dll", CharSet = CharSet.Unicode, PreserveSig = false)]
157
+ private static extern void SHCreateItemFromParsingName(
158
+ [MarshalAs(UnmanagedType.LPWStr)] string pszPath,
159
+ IntPtr pbc,
160
+ ref Guid riid,
161
+ out IShellItem ppv
162
+ );
163
+
164
+ [StructLayout(LayoutKind.Sequential)]
165
+ private struct RECT {
166
+ public int Left;
167
+ public int Top;
168
+ public int Right;
169
+ public int Bottom;
170
+ }
171
+
172
+ [DllImport("user32.dll")]
173
+ private static extern IntPtr GetForegroundWindow();
174
+
175
+ [DllImport("kernel32.dll")]
176
+ private static extern IntPtr GetConsoleWindow();
177
+
178
+ [DllImport("user32.dll", SetLastError = true)]
179
+ private static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect);
180
+
181
+ public static int[] GetDialogOwnerCenter() {
182
+ IntPtr hwnd = GetConsoleWindow();
183
+ if (hwnd == IntPtr.Zero) hwnd = GetForegroundWindow();
184
+ RECT rect;
185
+ if (hwnd != IntPtr.Zero && GetWindowRect(hwnd, out rect)) {
186
+ return new int[] { (rect.Left + rect.Right) / 2, (rect.Top + rect.Bottom) / 2 };
187
+ }
188
+ var area = System.Windows.Forms.Screen.PrimaryScreen.WorkingArea;
189
+ return new int[] { area.Left + (area.Width / 2), area.Top + (area.Height / 2) };
190
+ }
191
+
192
+ public static string Pick(string title, string initialPath, IntPtr owner) {
193
+ IFileDialog dialog = null;
194
+ IShellItem item = null;
195
+ IShellItem initialItem = null;
196
+ IntPtr pathPtr = IntPtr.Zero;
197
+ try {
198
+ dialog = (IFileDialog)new FileOpenDialog();
199
+ FOS options;
200
+ ThrowIfFailed(dialog.GetOptions(out options));
201
+ ThrowIfFailed(dialog.SetOptions(options | FOS.PICKFOLDERS | FOS.FORCEFILESYSTEM | FOS.PATHMUSTEXIST | FOS.FILEMUSTEXIST | FOS.NOCHANGEDIR));
202
+ if (!String.IsNullOrEmpty(title)) ThrowIfFailed(dialog.SetTitle(title));
203
+ if (!String.IsNullOrEmpty(initialPath) && System.IO.Directory.Exists(initialPath)) {
204
+ try {
205
+ Guid shellItemId = new Guid("43826d1e-e718-42ee-bc55-a1e261c37bfe");
206
+ SHCreateItemFromParsingName(initialPath, IntPtr.Zero, ref shellItemId, out initialItem);
207
+ if (initialItem != null) {
208
+ ThrowIfFailed(dialog.SetDefaultFolder(initialItem));
209
+ ThrowIfFailed(dialog.SetFolder(initialItem));
210
+ }
211
+ } catch {
212
+ // Initial folder is a convenience only; still show the picker.
213
+ }
214
+ }
215
+ int hr = dialog.Show(owner);
216
+ if (hr == ERROR_CANCELLED) return null;
217
+ ThrowIfFailed(hr);
218
+ ThrowIfFailed(dialog.GetResult(out item));
219
+ ThrowIfFailed(item.GetDisplayName(SIGDN.FILESYSPATH, out pathPtr));
220
+ return Marshal.PtrToStringUni(pathPtr);
221
+ } finally {
222
+ if (pathPtr != IntPtr.Zero) Marshal.FreeCoTaskMem(pathPtr);
223
+ if (initialItem != null) Marshal.ReleaseComObject(initialItem);
224
+ if (item != null) Marshal.ReleaseComObject(item);
225
+ if (dialog != null) Marshal.ReleaseComObject(dialog);
226
+ }
227
+ }
228
+
229
+ private static void ThrowIfFailed(int hr) {
230
+ if (hr < 0) Marshal.ThrowExceptionForHR(hr);
231
+ }
232
+ }
233
+ }
234
+ '@`,
235
+ 'Add-Type -TypeDefinition $code -ReferencedAssemblies System.Windows.Forms | Out-Null',
236
+ // Invisible TopMost owner anchored to the TUI console (or foreground window)
237
+ // so IFileOpenDialog is modal, centered, and not detached on another monitor.
238
+ '$owner = New-Object System.Windows.Forms.Form',
239
+ '$owner.TopMost = $true',
240
+ '$owner.ShowInTaskbar = $false',
241
+ '$owner.FormBorderStyle = "None"',
242
+ '$owner.StartPosition = "Manual"',
243
+ '$owner.Width = 1',
244
+ '$owner.Height = 1',
245
+ '$owner.Opacity = 0',
246
+ '$ownerCenter = [Mixdog.ExplorerFolderPicker]::GetDialogOwnerCenter()',
247
+ '$owner.Left = [int]$ownerCenter[0]',
248
+ '$owner.Top = [int]$ownerCenter[1]',
249
+ '$owner.Show()',
250
+ '$owner.Activate()',
251
+ 'try {',
252
+ ` $path = [Mixdog.ExplorerFolderPicker]::Pick('${safeTitle}', '${safeInitialPath}', $owner.Handle)`,
253
+ ' if ($path) { [Console]::Out.Write($path) }',
254
+ '} finally {',
255
+ ' $owner.Dispose()',
256
+ '}',
257
+ ].join('\n');
258
+ }
259
+
260
+ /**
261
+ * Open the native folder picker.
262
+ * @returns {Promise<{ available: boolean, path: string|null }>}
263
+ * available=false → no usable dialog tool; caller should fall back to typing.
264
+ * path=null with available=true → the user cancelled.
265
+ */
266
+ export async function pickFolder({ title = 'Select a project folder', initialPath = '' } = {}) {
267
+ const platform = process.platform;
268
+
269
+ if (platform === 'win32') {
270
+ const resolvedInitial = resolveInitialPath(initialPath);
271
+ const result = await runCapture('powershell.exe', [
272
+ '-NoLogo', '-NoProfile', '-NonInteractive', '-STA',
273
+ '-Command', powershellScript(title, resolvedInitial),
274
+ ]);
275
+ // Could not run PowerShell / Common File Dialog → fall back to manual typing.
276
+ if (spawnFailed(result)) return { available: false, path: null };
277
+ const path = String(result.stdout || '').trim();
278
+ if (!result.ok && !path) return { available: false, path: null };
279
+ return { available: true, path: path || null };
280
+ }
281
+
282
+ if (platform === 'darwin') {
283
+ const safeTitle = String(title).replace(/"/g, '\\"');
284
+ const script = `set f to choose folder with prompt "${safeTitle}"\nPOSIX path of f`;
285
+ const result = await runCapture('osascript', ['-e', script]);
286
+ // Could not run osascript (spawn failure / timeout) → manual fallback.
287
+ if (spawnFailed(result)) return { available: false, path: null };
288
+ // osascript exits non-zero (code 1) on user cancel; treat as cancel.
289
+ const path = String(result.stdout || '').trim();
290
+ return { available: true, path: path || null };
291
+ }
292
+
293
+ // Linux / other unix: prefer zenity, then kdialog.
294
+ if (await commandExists('zenity')) {
295
+ const result = await runCapture('zenity', [
296
+ '--file-selection', '--directory', `--title=${title}`,
297
+ ]);
298
+ // Spawn failure / timeout (broken display, missing portal, etc.) → manual
299
+ // fallback rather than silently looping back to the picker.
300
+ if (spawnFailed(result)) return { available: false, path: null };
301
+ const path = String(result.stdout || '').trim();
302
+ if (!result.ok && !path) return { available: true, path: null }; // cancel
303
+ return { available: true, path: path || null };
304
+ }
305
+ if (await commandExists('kdialog')) {
306
+ const result = await runCapture('kdialog', ['--getexistingdirectory', '.']);
307
+ if (spawnFailed(result)) return { available: false, path: null };
308
+ const path = String(result.stdout || '').trim();
309
+ if (!result.ok && !path) return { available: true, path: null };
310
+ return { available: true, path: path || null };
311
+ }
312
+
313
+ return { available: false, path: null };
314
+ }