mu-agent 0.1.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 (280) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/LICENSE +21 -0
  3. package/README.md +62 -0
  4. package/dist/bundle/chunks/anthropic-messages-MYU5ZMRF.js +6 -0
  5. package/dist/bundle/chunks/anthropic.js +75 -0
  6. package/dist/bundle/chunks/azure-openai-responses-HUSQ3GP2.js +2 -0
  7. package/dist/bundle/chunks/bedrock-converse-stream.js +83 -0
  8. package/dist/bundle/chunks/chunk-4L3WN2XY.js +2 -0
  9. package/dist/bundle/chunks/chunk-6CYCI3EJ.js +12 -0
  10. package/dist/bundle/chunks/chunk-6R7UI4IY.js +2 -0
  11. package/dist/bundle/chunks/chunk-7ZC4UXSL.js +2 -0
  12. package/dist/bundle/chunks/chunk-A3JYRWB6.js +2 -0
  13. package/dist/bundle/chunks/chunk-DRQDKNXA.js +2 -0
  14. package/dist/bundle/chunks/chunk-DTD7JQ7Y.js +42 -0
  15. package/dist/bundle/chunks/chunk-FUXEF6JQ.js +44 -0
  16. package/dist/bundle/chunks/chunk-L2ZPNLYD.js +2 -0
  17. package/dist/bundle/chunks/chunk-NUHFSC37.js +25 -0
  18. package/dist/bundle/chunks/chunk-PGXDEOVV.js +2 -0
  19. package/dist/bundle/chunks/chunk-PJACZCIR.js +2 -0
  20. package/dist/bundle/chunks/chunk-PRRNXB7S.js +2 -0
  21. package/dist/bundle/chunks/chunk-QB4HYZZE.js +1561 -0
  22. package/dist/bundle/chunks/chunk-RCZIEVGO.js +11 -0
  23. package/dist/bundle/chunks/chunk-S7SZN6Z3.js +2 -0
  24. package/dist/bundle/chunks/chunk-TEPMHNKQ.js +2 -0
  25. package/dist/bundle/chunks/chunk-TRDNDS6A.js +4 -0
  26. package/dist/bundle/chunks/chunk-USJZMAIR.js +2 -0
  27. package/dist/bundle/chunks/chunk-WIUZI2CJ.js +11 -0
  28. package/dist/bundle/chunks/chunk-XDUWOHPL.js +6 -0
  29. package/dist/bundle/chunks/chunk-XNGRGP62.js +2 -0
  30. package/dist/bundle/chunks/chunk-Z2DUOAN4.js +2 -0
  31. package/dist/bundle/chunks/github-copilot.js +2 -0
  32. package/dist/bundle/chunks/google-generative-ai-IMS5EK2Y.js +2 -0
  33. package/dist/bundle/chunks/google-vertex-O4YJEQUW.js +2 -0
  34. package/dist/bundle/chunks/https-proxy-agent-2VXB7436.js +13 -0
  35. package/dist/bundle/chunks/image-resize-worker.js +2 -0
  36. package/dist/bundle/chunks/jiti-loader-C3NU2SDH.js +21 -0
  37. package/dist/bundle/chunks/jiti-static-loader-E6ANQD5T.js +2 -0
  38. package/dist/bundle/chunks/kimi-coding.js +2 -0
  39. package/dist/bundle/chunks/lib-HGQMNYVT.js +9 -0
  40. package/dist/bundle/chunks/mistral-conversations-NOSQHBEY.js +5 -0
  41. package/dist/bundle/chunks/multipart-parser-SQ6GDJL7.js +3 -0
  42. package/dist/bundle/chunks/node-O3RIBJRH.js +16 -0
  43. package/dist/bundle/chunks/node-TWVVAGPO.js +2 -0
  44. package/dist/bundle/chunks/openai-codex-responses-QDSOTBBO.js +10 -0
  45. package/dist/bundle/chunks/openai-codex.js +75 -0
  46. package/dist/bundle/chunks/openai-completions-KQPOPEVM.js +7 -0
  47. package/dist/bundle/chunks/openai-responses-HWEUZ6WZ.js +2 -0
  48. package/dist/bundle/chunks/openrouter-images-N4XVVFSD.js +2 -0
  49. package/dist/bundle/chunks/openrouter.js +75 -0
  50. package/dist/bundle/chunks/pi-messages-GFKBWJHZ.js +8 -0
  51. package/dist/bundle/chunks/radius.js +75 -0
  52. package/dist/bundle/chunks/src-UK77SYZE.js +4 -0
  53. package/dist/bundle/chunks/virtual-modules-ROULVYS3.js +2 -0
  54. package/dist/bundle/chunks/xai.js +2 -0
  55. package/dist/bundle/cli.js +3 -0
  56. package/dist/bundle/index.js +2 -0
  57. package/dist/bundle/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
  58. package/dist/bundle/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
  59. package/dist/bundle/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
  60. package/dist/bundle/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
  61. package/dist/bundle/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
  62. package/dist/bundle/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
  63. package/dist/bundle/rpc-entry.js +3 -0
  64. package/dist/core/export-html/template.css +1066 -0
  65. package/dist/core/export-html/template.html +55 -0
  66. package/dist/core/export-html/template.js +1864 -0
  67. package/dist/core/export-html/vendor/highlight.min.js +1213 -0
  68. package/dist/core/export-html/vendor/marked.min.js +78 -0
  69. package/dist/modes/interactive/assets/clankolas.png +0 -0
  70. package/dist/modes/interactive/theme/dark.json +91 -0
  71. package/dist/modes/interactive/theme/light.json +90 -0
  72. package/dist/modes/interactive/theme/theme-controller.d.ts +41 -0
  73. package/dist/modes/interactive/theme/theme-controller.d.ts.map +1 -0
  74. package/dist/modes/interactive/theme/theme-controller.js +130 -0
  75. package/dist/modes/interactive/theme/theme-controller.js.map +1 -0
  76. package/dist/modes/interactive/theme/theme-json.d.ts +84 -0
  77. package/dist/modes/interactive/theme/theme-json.d.ts.map +1 -0
  78. package/dist/modes/interactive/theme/theme-json.js +130 -0
  79. package/dist/modes/interactive/theme/theme-json.js.map +1 -0
  80. package/dist/modes/interactive/theme/theme-schema.json +356 -0
  81. package/dist/modes/interactive/theme/theme.d.ts +131 -0
  82. package/dist/modes/interactive/theme/theme.d.ts.map +1 -0
  83. package/dist/modes/interactive/theme/theme.js +991 -0
  84. package/dist/modes/interactive/theme/theme.js.map +1 -0
  85. package/dist/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
  86. package/dist/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
  87. package/dist/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
  88. package/dist/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
  89. package/dist/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
  90. package/dist/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
  91. package/docs/compaction.md +444 -0
  92. package/docs/containerization.md +156 -0
  93. package/docs/custom-provider.md +786 -0
  94. package/docs/development.md +90 -0
  95. package/docs/docs.json +160 -0
  96. package/docs/environment-variables.md +100 -0
  97. package/docs/extensions.md +3037 -0
  98. package/docs/images/doom-extension.png +0 -0
  99. package/docs/images/exy.png +0 -0
  100. package/docs/images/interactive-mode.png +0 -0
  101. package/docs/images/tree-view.png +0 -0
  102. package/docs/index.md +84 -0
  103. package/docs/json.md +98 -0
  104. package/docs/keybindings.md +239 -0
  105. package/docs/llama-cpp.md +101 -0
  106. package/docs/models.md +605 -0
  107. package/docs/packages.md +228 -0
  108. package/docs/prompt-templates.md +96 -0
  109. package/docs/providers.md +317 -0
  110. package/docs/quickstart.md +167 -0
  111. package/docs/rpc.md +1618 -0
  112. package/docs/sdk.md +1226 -0
  113. package/docs/security.md +59 -0
  114. package/docs/session-format.md +480 -0
  115. package/docs/sessions.md +172 -0
  116. package/docs/settings.md +428 -0
  117. package/docs/shell-aliases.md +13 -0
  118. package/docs/skills.md +232 -0
  119. package/docs/terminal-setup.md +192 -0
  120. package/docs/termux.md +126 -0
  121. package/docs/themes.md +322 -0
  122. package/docs/tmux.md +63 -0
  123. package/docs/tui.md +961 -0
  124. package/docs/usage.md +312 -0
  125. package/docs/windows.md +39 -0
  126. package/examples/README.md +28 -0
  127. package/examples/extensions/README.md +212 -0
  128. package/examples/extensions/auto-commit-on-exit.ts +49 -0
  129. package/examples/extensions/bash-spawn-hook.ts +30 -0
  130. package/examples/extensions/bookmark.ts +50 -0
  131. package/examples/extensions/border-status-editor.ts +150 -0
  132. package/examples/extensions/built-in-tool-renderer.ts +249 -0
  133. package/examples/extensions/claude-rules.ts +86 -0
  134. package/examples/extensions/commands.ts +72 -0
  135. package/examples/extensions/confirm-destructive.ts +59 -0
  136. package/examples/extensions/custom-compaction.ts +117 -0
  137. package/examples/extensions/custom-footer.ts +64 -0
  138. package/examples/extensions/custom-header.ts +73 -0
  139. package/examples/extensions/custom-provider-anthropic/index.ts +617 -0
  140. package/examples/extensions/custom-provider-anthropic/package-lock.json +24 -0
  141. package/examples/extensions/custom-provider-anthropic/package.json +19 -0
  142. package/examples/extensions/custom-provider-gitlab-duo/index.ts +405 -0
  143. package/examples/extensions/custom-provider-gitlab-duo/package.json +16 -0
  144. package/examples/extensions/custom-provider-gitlab-duo/test.ts +82 -0
  145. package/examples/extensions/dirty-repo-guard.ts +56 -0
  146. package/examples/extensions/doom-overlay/README.md +46 -0
  147. package/examples/extensions/doom-overlay/doom/build/doom.js +21 -0
  148. package/examples/extensions/doom-overlay/doom/build/doom.wasm +0 -0
  149. package/examples/extensions/doom-overlay/doom/build.sh +152 -0
  150. package/examples/extensions/doom-overlay/doom/doomgeneric_pi.c +72 -0
  151. package/examples/extensions/doom-overlay/doom-component.ts +132 -0
  152. package/examples/extensions/doom-overlay/doom-engine.ts +173 -0
  153. package/examples/extensions/doom-overlay/doom-keys.ts +104 -0
  154. package/examples/extensions/doom-overlay/index.ts +74 -0
  155. package/examples/extensions/doom-overlay/wad-finder.ts +55 -0
  156. package/examples/extensions/dynamic-resources/SKILL.md +8 -0
  157. package/examples/extensions/dynamic-resources/dynamic.json +81 -0
  158. package/examples/extensions/dynamic-resources/dynamic.md +5 -0
  159. package/examples/extensions/dynamic-resources/index.ts +15 -0
  160. package/examples/extensions/dynamic-tools.ts +74 -0
  161. package/examples/extensions/entry-renderer.ts +41 -0
  162. package/examples/extensions/event-bus.ts +43 -0
  163. package/examples/extensions/file-trigger.ts +41 -0
  164. package/examples/extensions/git-checkpoint.ts +53 -0
  165. package/examples/extensions/git-merge-and-resolve.ts +115 -0
  166. package/examples/extensions/github-issue-autocomplete.ts +185 -0
  167. package/examples/extensions/gondolin/index.ts +531 -0
  168. package/examples/extensions/gondolin/package-lock.json +185 -0
  169. package/examples/extensions/gondolin/package.json +19 -0
  170. package/examples/extensions/handoff.ts +190 -0
  171. package/examples/extensions/hello.ts +26 -0
  172. package/examples/extensions/hidden-thinking-label.ts +53 -0
  173. package/examples/extensions/inline-bash.ts +94 -0
  174. package/examples/extensions/input-transform-streaming.ts +39 -0
  175. package/examples/extensions/input-transform.ts +43 -0
  176. package/examples/extensions/interactive-shell.ts +196 -0
  177. package/examples/extensions/mac-system-theme.ts +47 -0
  178. package/examples/extensions/message-renderer.ts +59 -0
  179. package/examples/extensions/minimal-mode.ts +426 -0
  180. package/examples/extensions/modal-editor.ts +85 -0
  181. package/examples/extensions/model-status.ts +31 -0
  182. package/examples/extensions/notify.ts +57 -0
  183. package/examples/extensions/overlay-qa-tests.ts +1450 -0
  184. package/examples/extensions/overlay-test.ts +153 -0
  185. package/examples/extensions/permission-gate.ts +34 -0
  186. package/examples/extensions/pirate.ts +47 -0
  187. package/examples/extensions/plan-mode/README.md +66 -0
  188. package/examples/extensions/plan-mode/index.ts +390 -0
  189. package/examples/extensions/plan-mode/utils.ts +168 -0
  190. package/examples/extensions/preset.ts +436 -0
  191. package/examples/extensions/project-trust.ts +64 -0
  192. package/examples/extensions/prompt-customizer.ts +49 -0
  193. package/examples/extensions/protected-paths.ts +30 -0
  194. package/examples/extensions/provider-payload.ts +18 -0
  195. package/examples/extensions/qna.ts +118 -0
  196. package/examples/extensions/question.ts +286 -0
  197. package/examples/extensions/questionnaire.ts +448 -0
  198. package/examples/extensions/rainbow-editor.ts +88 -0
  199. package/examples/extensions/reload-runtime.ts +37 -0
  200. package/examples/extensions/rpc-demo.ts +118 -0
  201. package/examples/extensions/sandbox/index.ts +321 -0
  202. package/examples/extensions/sandbox/package-lock.json +92 -0
  203. package/examples/extensions/sandbox/package.json +19 -0
  204. package/examples/extensions/send-user-message.ts +97 -0
  205. package/examples/extensions/session-name.ts +27 -0
  206. package/examples/extensions/shutdown-command.ts +63 -0
  207. package/examples/extensions/snake.ts +343 -0
  208. package/examples/extensions/space-invaders.ts +560 -0
  209. package/examples/extensions/ssh.ts +220 -0
  210. package/examples/extensions/status-line.ts +32 -0
  211. package/examples/extensions/structured-output.ts +65 -0
  212. package/examples/extensions/subagent/README.md +177 -0
  213. package/examples/extensions/subagent/agents/planner.md +37 -0
  214. package/examples/extensions/subagent/agents/reviewer.md +35 -0
  215. package/examples/extensions/subagent/agents/scout.md +50 -0
  216. package/examples/extensions/subagent/agents/worker.md +24 -0
  217. package/examples/extensions/subagent/agents.ts +157 -0
  218. package/examples/extensions/subagent/index.ts +1038 -0
  219. package/examples/extensions/subagent/prompts/implement-and-review.md +10 -0
  220. package/examples/extensions/subagent/prompts/implement.md +10 -0
  221. package/examples/extensions/subagent/prompts/scout-and-plan.md +9 -0
  222. package/examples/extensions/summarize.ts +199 -0
  223. package/examples/extensions/system-prompt-header.ts +17 -0
  224. package/examples/extensions/tic-tac-toe.ts +1008 -0
  225. package/examples/extensions/timed-confirm.ts +70 -0
  226. package/examples/extensions/titlebar-spinner.ts +58 -0
  227. package/examples/extensions/todo.ts +297 -0
  228. package/examples/extensions/tool-override.ts +144 -0
  229. package/examples/extensions/tools.ts +146 -0
  230. package/examples/extensions/trigger-compact.ts +50 -0
  231. package/examples/extensions/truncated-tool.ts +195 -0
  232. package/examples/extensions/widget-placement.ts +9 -0
  233. package/examples/extensions/with-deps/index.ts +32 -0
  234. package/examples/extensions/with-deps/package-lock.json +31 -0
  235. package/examples/extensions/with-deps/package.json +22 -0
  236. package/examples/extensions/working-indicator.ts +123 -0
  237. package/examples/extensions/working-message-test.ts +25 -0
  238. package/examples/plugins/pi-example-plugin/README.md +38 -0
  239. package/examples/plugins/pi-example-plugin/package.json +10 -0
  240. package/examples/plugins/pi-example-plugin/src/contract.ts +13 -0
  241. package/examples/plugins/pi-example-plugin/src/session.ts +23 -0
  242. package/examples/plugins/pi-example-plugin/src/tui.ts +31 -0
  243. package/examples/rpc-extension-ui.ts +641 -0
  244. package/examples/sdk/01-minimal.ts +26 -0
  245. package/examples/sdk/02-custom-model.ts +49 -0
  246. package/examples/sdk/03-custom-prompt.ts +75 -0
  247. package/examples/sdk/04-skills.ts +55 -0
  248. package/examples/sdk/05-tools.ts +48 -0
  249. package/examples/sdk/06-extensions.ts +99 -0
  250. package/examples/sdk/07-context-files.ts +47 -0
  251. package/examples/sdk/08-prompt-templates.ts +51 -0
  252. package/examples/sdk/09-api-keys-and-oauth.ts +34 -0
  253. package/examples/sdk/10-settings.ts +53 -0
  254. package/examples/sdk/11-sessions.ts +52 -0
  255. package/examples/sdk/12-full-control.ts +74 -0
  256. package/examples/sdk/13-session-runtime.ts +67 -0
  257. package/examples/sdk/README.md +140 -0
  258. package/judge/THIRD_PARTY_NOTICES.md +35 -0
  259. package/judge/agents/browser.md +17 -0
  260. package/judge/agents/investigator.md +16 -0
  261. package/judge/agents/planner.md +15 -0
  262. package/judge/agents/reviewer.md +17 -0
  263. package/judge/agents/scout.md +19 -0
  264. package/judge/agents/worker.md +15 -0
  265. package/judge/dist/chrome.js +326 -0
  266. package/judge/dist/kyrn-judge.js +52542 -0
  267. package/judge/package.json +6 -0
  268. package/judge/prompts/implement-and-review.md +14 -0
  269. package/judge/prompts/implement.md +15 -0
  270. package/judge/prompts/init.md +18 -0
  271. package/judge/prompts/scout-and-plan.md +12 -0
  272. package/judge/skills/mu-browser/SKILL.md +30 -0
  273. package/judge/skills/mu-github/SKILL.md +90 -0
  274. package/kyrn/bin/kyrn-doctor +202 -0
  275. package/kyrn/bin/kyrn-judge-local +131 -0
  276. package/kyrn/bin/kyrn-ledger +90 -0
  277. package/kyrn/bin/mu.mjs +1120 -0
  278. package/kyrn/local-judge/requirements.lock.txt +26 -0
  279. package/kyrn/local-judge/server.py +332 -0
  280. package/package.json +45 -0
@@ -0,0 +1,1120 @@
1
+ #!/usr/bin/env node
2
+ // mu command line: everything the launcher does, in plain Node so that it runs on macOS, Linux, WSL and Windows.
3
+ // (`mu help` prints the commands: see USAGE below.)
4
+ //
5
+ // Who starts this file:
6
+ // - kyrn/bin/mu (bash) on macOS, Linux and WSL. It only finds a Node >= 22.19, from PATH or from nvm: a
7
+ // machine whose default Node is older cannot be trusted to run this file at all.
8
+ // - kyrn/bin/mu.cmd on Windows, with whatever `node` is on PATH (mu.ps1 is the same for PowerShell).
9
+ // - npm's own `mu` command, in the npm package mu-agent (kyrn/npm/build.mjs). The package is laid out like the
10
+ // repository, so this file is at kyrn/bin/mu.mjs there too, and the root is two folders up in both.
11
+ //
12
+ // No dependency and no TypeScript here: this runs before tsx has been found.
13
+ //
14
+ // Every decision is an exported function that takes the platform, the environment and the file system as
15
+ // parameters and returns what would be done. There is no Windows machine where this is developed, so the
16
+ // Windows and WSL behaviour is proven by calling these functions with win32 and linux parameters
17
+ // (packages/kyrn-judge/test/launcher-platforms.test.ts); `main` only carries the plans out.
18
+ //
19
+ // What the launcher sets up:
20
+ // - mu's own home, ~/.mu/agent (auth, sessions, settings), apart from a stock pi in ~/.pi.
21
+ // A machine that still has ~/.kyrn and no ~/.mu keeps using ~/.kyrn until that folder is moved (`mu migrate`).
22
+ // - mu's name on the agent: PI_PACKAGE_DIR points at ~/.mu/app, a view of packages/coding-agent whose
23
+ // package.json carries piConfig {name: mu, configDir: .mu}. No upstream file is edited.
24
+ // - the judgment layer (packages/kyrn-judge), with judges from ~/.mu/agent/mu.json or MU_JUDGE
25
+ // (e.g. "laya", "laya,luna", "laya,jev", "off"). When "laya" is among them the sidecar is started (macOS only).
26
+ // kyrn.json and every KYRN_* variable are still read when the mu spelling is absent.
27
+ // - the repo-root .env (the Jev key), read as data and handed to the agent without ever being printed.
28
+ // The npm package reads ~/.mu/.env instead: there is no repository around it.
29
+ import { spawn, spawnSync } from "node:child_process";
30
+ import {
31
+ accessSync,
32
+ constants as fsConstants,
33
+ copyFileSync,
34
+ existsSync,
35
+ lstatSync,
36
+ mkdirSync,
37
+ readFileSync,
38
+ readlinkSync,
39
+ realpathSync,
40
+ renameSync,
41
+ statSync,
42
+ symlinkSync,
43
+ unlinkSync,
44
+ writeFileSync,
45
+ writeSync,
46
+ } from "node:fs";
47
+ import { homedir, constants as osConstants } from "node:os";
48
+ import nodePath from "node:path";
49
+ import { fileURLToPath } from "node:url";
50
+
51
+ /** pi's minimum. */
52
+ export const MIN_NODE = [22, 19];
53
+
54
+ /** True when a Node version such as "24.16.0" or "v22.19.0" can run pi. */
55
+ export function nodeVersionOk(version) {
56
+ const [major, minor] = String(version).replace(/^v/, "").split(".").map(Number);
57
+ if (!Number.isInteger(major) || !Number.isInteger(minor)) return false;
58
+ return major > MIN_NODE[0] || (major === MIN_NODE[0] && minor >= MIN_NODE[1]);
59
+ }
60
+
61
+ /** `node:path` for the platform in question, so that win32 paths can be computed (and tested) on macOS. */
62
+ export function pathFor(platform) {
63
+ return platform === "win32" ? nodePath.win32 : nodePath.posix;
64
+ }
65
+
66
+ /** `MU_<name>`, or the `KYRN_<name>` spelling from before the rename when the new one is unset or empty. */
67
+ export function muEnv(name, env) {
68
+ return env[`MU_${name}`] || env[`KYRN_${name}`] || undefined;
69
+ }
70
+
71
+ /**
72
+ * ~/.mu, or ~/.kyrn on a machine whose home has not been moved yet. Nothing here ever creates ~/.mu beside
73
+ * ~/.kyrn: two homes would mean two logins and two session lists.
74
+ */
75
+ export function muHome({ home, platform, isDir }) {
76
+ const path = pathFor(platform);
77
+ const current = path.join(home, ".mu");
78
+ return isDir(current) || !isDir(path.join(home, ".kyrn")) ? current : path.join(home, ".kyrn");
79
+ }
80
+
81
+ /** WSL is Linux to the launcher. It matters to the browser and to what `mu doctor` reports. */
82
+ export function detectWsl({ platform, env, procVersion }) {
83
+ if (platform !== "linux") return false;
84
+ return Boolean(env.WSL_DISTRO_NAME) || /microsoft/i.test(procVersion ?? "");
85
+ }
86
+
87
+ export function platformName({ platform, wsl }) {
88
+ if (platform === "darwin") return "macOS";
89
+ if (platform === "win32") return "Windows";
90
+ if (platform === "linux") return wsl ? "WSL" : "Linux";
91
+ return platform;
92
+ }
93
+
94
+ export function usage(platform) {
95
+ const linkDir = platform === "win32" ? "%USERPROFILE%\\.local\\bin" : "~/.local/bin";
96
+ return [
97
+ "mu command line. (The project was called KYRN until 2026-09-21; the old spellings below still work.)",
98
+ "",
99
+ " mu interactive session in the current directory",
100
+ ' mu "prompt" interactive, starting with this prompt',
101
+ ' mu -p "prompt" one-shot: print the answer and exit',
102
+ " mu -c | -r continue the last session | pick one to resume",
103
+ " mu judge <cmd> the local judge (Laya): setup | start | stop | status | run",
104
+ " mu ledger [n] [--json] what the judge decided in the last n sessions",
105
+ " mu doctor check the installation",
106
+ ` mu link | unlink put the \`mu\` command on your PATH (${linkDir}) | remove it`,
107
+ " mu migrate [--dry-run] once: move the home from before the rename, ~/.kyrn, to ~/.mu",
108
+ " mu help | version",
109
+ "",
110
+ "Everything else goes to the agent unchanged: `mu --help` lists every flag,",
111
+ "and `mu install|remove|update|list|config` manage pi packages.",
112
+ ].join("\n");
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------------------------------------
116
+ // .env
117
+ // ---------------------------------------------------------------------------------------------------------
118
+
119
+ /**
120
+ * Reads KEY=VALUE lines. The file is data, not a script: nothing in it is executed or expanded, which is
121
+ * what makes it work without a shell (the bash launcher used to source it).
122
+ *
123
+ * Understood: blank lines, `# comments`, an `export ` prefix, single quotes (literal), double quotes (with
124
+ * \" \\ \$ \` and a value that continues over several lines), and ` # comment` after an unquoted value.
125
+ * A line that is none of these is skipped and only its line NUMBER is reported: its text may be a secret.
126
+ */
127
+ export function parseEnvFile(text) {
128
+ const entries = [];
129
+ const problems = [];
130
+ const lines = String(text).replace(/^\uFEFF/, "").split(/\r?\n/);
131
+ for (let index = 0; index < lines.length; index++) {
132
+ const line = lines[index].trim();
133
+ if (!line || line.startsWith("#")) continue;
134
+ const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
135
+ if (!match) {
136
+ problems.push(index + 1);
137
+ continue;
138
+ }
139
+ const [, key, rest] = match;
140
+ const quote = rest[0];
141
+ if (quote !== '"' && quote !== "'") {
142
+ entries.push([key, rest.replace(/\s+#.*$/, "").trim()]);
143
+ continue;
144
+ }
145
+ // A quoted value ends at the matching quote, which may be some lines further down.
146
+ let body = rest.slice(1);
147
+ let end = closingQuote(body, quote);
148
+ const startedAt = index;
149
+ while (end < 0 && index + 1 < lines.length) {
150
+ body += `\n${lines[++index]}`;
151
+ end = closingQuote(body, quote);
152
+ }
153
+ if (end < 0) {
154
+ // Never closed: only that line is given up, the lines after it are still read.
155
+ problems.push(startedAt + 1);
156
+ index = startedAt;
157
+ continue;
158
+ }
159
+ const value = body.slice(0, end);
160
+ entries.push([key, quote === '"' ? value.replace(/\\(["\\$`])/g, "$1") : value]);
161
+ }
162
+ return { entries, problems };
163
+ }
164
+
165
+ function closingQuote(text, quote) {
166
+ for (let index = 0; index < text.length; index++) {
167
+ if (quote === '"' && text[index] === "\\") index++;
168
+ else if (text[index] === quote) return index;
169
+ }
170
+ return -1;
171
+ }
172
+
173
+ /**
174
+ * What the file adds to the environment: only the variables that are not already there. The real environment
175
+ * wins, also with an empty value (`AI_GATEWAY_API_KEY= mu` is how one run goes without the key).
176
+ * Windows treats variable names without regard to case.
177
+ */
178
+ export function envFileAdditions({ env, entries, platform }) {
179
+ const fold = (name) => (platform === "win32" ? name.toUpperCase() : name);
180
+ const taken = new Set(Object.keys(env).map(fold));
181
+ const added = {};
182
+ for (const [key, value] of entries) {
183
+ if (taken.has(fold(key))) continue;
184
+ taken.add(fold(key));
185
+ added[key] = value;
186
+ }
187
+ return added;
188
+ }
189
+
190
+ // ---------------------------------------------------------------------------------------------------------
191
+ // The app view: <home>/app
192
+ // ---------------------------------------------------------------------------------------------------------
193
+
194
+ /**
195
+ * <home>/app is packages/coding-agent seen through links, plus a package.json that names the app. It lives
196
+ * outside the repository on purpose: a linked source tree inside it makes biome skip the real one.
197
+ *
198
+ * Symbolic links need a privilege on Windows (or Developer Mode). Directory junctions do not, so folders
199
+ * become junctions there; a junction cannot point at a file, so the two files become copies.
200
+ */
201
+ export function appViewPlan(platform) {
202
+ const folder = platform === "win32" ? "junction" : "symlink";
203
+ const file = platform === "win32" ? "copy" : "symlink";
204
+ return [
205
+ { name: "src", kind: folder },
206
+ { name: "docs", kind: folder },
207
+ { name: "examples", kind: folder },
208
+ { name: "README.md", kind: file },
209
+ { name: "CHANGELOG.md", kind: file },
210
+ ];
211
+ }
212
+
213
+ /** Its package name stays pi's: pi updates itself by that name, and `mu` on npm is someone else's package. */
214
+ export function appPackageJson(upstreamText) {
215
+ const pkg = JSON.parse(upstreamText);
216
+ pkg.piConfig = { name: "mu", configDir: ".mu" };
217
+ return `${JSON.stringify(pkg, null, "\t")}\n`;
218
+ }
219
+
220
+ const realFs = {
221
+ copyFileSync,
222
+ existsSync,
223
+ lstatSync,
224
+ mkdirSync,
225
+ readFileSync,
226
+ readlinkSync,
227
+ statSync,
228
+ symlinkSync,
229
+ unlinkSync,
230
+ writeFileSync,
231
+ };
232
+
233
+ function lstatOrUndefined(fs, target) {
234
+ try {
235
+ return fs.lstatSync(target);
236
+ } catch {
237
+ return undefined;
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Brings the view up to date and returns what it did, one line per change. Only links and files are ever
243
+ * replaced, with `unlink`: nothing here deletes recursively, because through a junction that would reach
244
+ * the real source tree.
245
+ */
246
+ export function ensureAppView({ platform, app, upstream, fs = realFs }) {
247
+ const path = pathFor(platform);
248
+ const same = (a, b) => {
249
+ const [left, right] = [path.resolve(a), path.resolve(b)];
250
+ return platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
251
+ };
252
+ const done = [];
253
+ fs.mkdirSync(app, { recursive: true });
254
+ for (const { name, kind } of appViewPlan(platform)) {
255
+ const source = path.join(upstream, name);
256
+ const target = path.join(app, name);
257
+ const stat = lstatOrUndefined(fs, target);
258
+ if (kind === "copy") {
259
+ if (!fs.existsSync(source)) continue;
260
+ const stale =
261
+ !stat ||
262
+ !stat.isFile() ||
263
+ stat.size !== fs.statSync(source).size ||
264
+ stat.mtimeMs < fs.statSync(source).mtimeMs;
265
+ if (!stale) continue;
266
+ if (stat?.isDirectory()) continue;
267
+ if (stat) fs.unlinkSync(target);
268
+ fs.copyFileSync(source, target);
269
+ done.push(`copy ${name}`);
270
+ continue;
271
+ }
272
+ if (stat?.isSymbolicLink()) {
273
+ let current;
274
+ try {
275
+ current = fs.readlinkSync(target);
276
+ } catch {}
277
+ if (current !== undefined && same(current, source)) continue;
278
+ }
279
+ // A real folder here was put there by hand. It is left alone rather than emptied.
280
+ if (stat?.isDirectory()) continue;
281
+ if (stat) fs.unlinkSync(target);
282
+ if (kind === "junction") fs.symlinkSync(source, target, "junction");
283
+ else fs.symlinkSync(source, target);
284
+ done.push(`${kind} ${name}`);
285
+ }
286
+ // Rebuilt when upstream moved on, and when the view still names the app something else (a home from before the rename).
287
+ const manifest = path.join(app, "package.json");
288
+ const upstreamManifest = path.join(upstream, "package.json");
289
+ const current = lstatOrUndefined(fs, manifest);
290
+ const rebuild =
291
+ !current ||
292
+ fs.statSync(upstreamManifest).mtimeMs > current.mtimeMs ||
293
+ !fs.readFileSync(manifest, "utf8").includes('"name": "mu"');
294
+ if (rebuild) {
295
+ fs.writeFileSync(manifest, appPackageJson(fs.readFileSync(upstreamManifest, "utf8")));
296
+ done.push("package.json");
297
+ }
298
+ return done;
299
+ }
300
+
301
+ // ---------------------------------------------------------------------------------------------------------
302
+ // Starting pi
303
+ // ---------------------------------------------------------------------------------------------------------
304
+
305
+ /**
306
+ * Where this mu comes from: "repo", a checkout that runs the TypeScript sources through tsx, or "package", the
307
+ * npm package mu-agent, which carries pi's bundle (dist/bundle/cli.js) and the judgment layer built to
308
+ * JavaScript (judge/dist/kyrn-judge.js). A checkout without its dependencies is still a checkout.
309
+ */
310
+ export function layoutOf({ root, platform, exists }) {
311
+ const path = pathFor(platform);
312
+ if (exists(path.join(root, "packages", "coding-agent", "package.json"))) return "repo";
313
+ return exists(path.join(root, "dist", "bundle", "cli.js")) ? "package" : "repo";
314
+ }
315
+
316
+ /** The two files the npm package runs. */
317
+ export function packageEntries({ root, platform }) {
318
+ const path = pathFor(platform);
319
+ return {
320
+ cli: path.join(root, "dist", "bundle", "cli.js"),
321
+ extension: path.join(root, "judge", "dist", "kyrn-judge.js"),
322
+ };
323
+ }
324
+
325
+ /** The .env the launcher reads: the checkout's, or for the npm package the one in mu's home. */
326
+ export function envFilePath({ layout, root, muDir, platform }) {
327
+ return pathFor(platform).join(layout === "package" ? muDir : root, ".env");
328
+ }
329
+
330
+ /**
331
+ * The JavaScript file behind the `tsx` command. node_modules/.bin/tsx is a `.cmd` shim on Windows, and a shim
332
+ * can only be started through a shell, where a prompt with quotes or `&` in it is re-parsed. Running the real
333
+ * entry with the Node that runs this file avoids the shell on every platform.
334
+ */
335
+ export function resolveTsx({ root, platform, exists, readFile }) {
336
+ const path = pathFor(platform);
337
+ // Looked for the way Node resolves an import: here, then in every parent. A git worktree of this repository
338
+ // kept under the main checkout (as the agent worktrees are) has no node_modules of its own and runs on the
339
+ // main checkout's, exactly as its imports do.
340
+ for (let dir = root; ; dir = path.dirname(dir)) {
341
+ const entry = tsxEntry(path.join(dir, "node_modules", "tsx"), path, exists, readFile);
342
+ if (entry) return entry;
343
+ if (path.dirname(dir) === dir) return undefined;
344
+ }
345
+ }
346
+
347
+ function tsxEntry(dir, path, exists, readFile) {
348
+ let bin;
349
+ try {
350
+ bin = JSON.parse(readFile(path.join(dir, "package.json"))).bin;
351
+ } catch {
352
+ return undefined;
353
+ }
354
+ const relative = typeof bin === "string" ? bin : bin?.tsx;
355
+ if (typeof relative !== "string") return undefined;
356
+ const entry = path.join(dir, ...relative.split("/").filter((part) => part && part !== "."));
357
+ return exists(entry) ? entry : undefined;
358
+ }
359
+
360
+ export function installHint({ root, platform }) {
361
+ const steps = [`cd ${root}`, "npm ci --ignore-scripts", "npm run hydrate:model-data"];
362
+ // Windows PowerShell 5 has no `&&`.
363
+ return platform === "win32"
364
+ ? `mu's dependencies are not installed. Run:\n${steps.map((step) => ` ${step}`).join("\n")}`
365
+ : `mu's dependencies are not installed. Run: ${steps.join(" && ")}`;
366
+ }
367
+
368
+ /**
369
+ * pi reads <APP NAME>_CODING_AGENT_DIR and nothing else, so with the app named mu that export decides the
370
+ * home. The pi and KYRN spellings are exported too, for scripts that still read them.
371
+ */
372
+ export function agentDirFor({ env, muDir, platform }) {
373
+ return (
374
+ muEnv("AGENT_DIR", env) || muEnv("CODING_AGENT_DIR", env) || pathFor(platform).join(muDir, "agent")
375
+ );
376
+ }
377
+
378
+ /** Whether the configuration asks for the local judge. */
379
+ export function wantsLaya({ env, agentDir, platform, readFile }) {
380
+ const judges = muEnv("JUDGE", env);
381
+ if (judges) return `,${judges},`.includes(",laya");
382
+ const path = pathFor(platform);
383
+ for (const name of ["mu.json", "kyrn.json"]) {
384
+ let text;
385
+ try {
386
+ text = readFile(path.join(agentDir, name));
387
+ } catch {
388
+ continue;
389
+ }
390
+ // Like the file itself: kyrn.json is only looked at when there is no mu.json.
391
+ return text.includes('"laya');
392
+ }
393
+ return false;
394
+ }
395
+
396
+ /** The Core ML sidecar exists for macOS only. Anything else needs a server of its own behind MU_LOCAL_JUDGE_URL. */
397
+ export function localJudgeSupport({ platform, env, wsl = false }) {
398
+ if (platform === "darwin") return { runs: true };
399
+ const url = muEnv("LOCAL_JUDGE_URL", env);
400
+ const where = platformName({ platform, wsl });
401
+ return {
402
+ runs: false,
403
+ url,
404
+ message: [
405
+ `The local judge (Laya) is a Core ML sidecar: it runs on macOS with Apple Silicon only, not on ${where}.`,
406
+ "mu works without it: use Jev (TYPESAFE_API_KEY or AI_GATEWAY_API_KEY) or an llm judge in mu.json;",
407
+ "the desktop app's own local judge is being built. Decisions without a judge fall back to pi's behaviour.",
408
+ "A server that speaks the same contract (GET /health, POST /evaluate) can be used from here:",
409
+ " MU_LOCAL_JUDGE_URL=http://host:port",
410
+ ].join("\n"),
411
+ };
412
+ }
413
+
414
+ /** exec replaces this process, as the bash launcher did; spawn is what Windows has. MU_LAUNCH forces either. */
415
+ export function launchStrategy({ platform, env, canExec }) {
416
+ const forced = muEnv("LAUNCH", env);
417
+ if (platform === "win32" || !canExec || forced === "spawn") return "spawn";
418
+ return "exec";
419
+ }
420
+
421
+ /**
422
+ * Everything about starting pi, decided without touching anything: the command, its arguments as an array
423
+ * (never a shell string) and the child's whole environment.
424
+ *
425
+ * `fs` is { exists, isDir, readFile }. Returns { error } when pi cannot be started.
426
+ */
427
+ export function planLaunch({ platform, env, argv, root, home, execPath, fs, canExec = false, wsl = false }) {
428
+ const path = pathFor(platform);
429
+ const layout = layoutOf({ root, platform, exists: fs.exists });
430
+ let entry;
431
+ if (layout === "package") {
432
+ const files = packageEntries({ root, platform });
433
+ if (!fs.exists(files.extension)) {
434
+ return { error: `This mu-agent package is incomplete (${files.extension} is missing). Reinstall it: npm i -g mu-agent` };
435
+ }
436
+ entry = [files.cli, "-e", files.extension];
437
+ } else {
438
+ const tsx = resolveTsx({ root, platform, exists: fs.exists, readFile: fs.readFile });
439
+ if (!tsx) return { error: installHint({ root, platform }) };
440
+ entry = [
441
+ tsx,
442
+ "--tsconfig",
443
+ path.join(root, "tsconfig.json"),
444
+ path.join(root, "packages", "coding-agent", "src", "experimental", "cli.ts"),
445
+ "-e",
446
+ path.join(root, "packages", "kyrn-judge", "src", "extension", "kyrn-judge.ts"),
447
+ ];
448
+ }
449
+
450
+ const muDir = muHome({ home, platform, isDir: fs.isDir });
451
+ // The package names itself mu in its own package.json; a checkout needs the view at <home>/app for that.
452
+ const appDir = layout === "package" ? root : muEnv("APP_DIR", env) || path.join(muDir, "app");
453
+ const agentDir = agentDirFor({ env, muDir, platform });
454
+
455
+ const notes = [];
456
+ let fromFile = {};
457
+ let envText;
458
+ try {
459
+ envText = fs.readFile(envFilePath({ layout, root, muDir, platform }));
460
+ } catch {}
461
+ if (envText !== undefined) {
462
+ const parsed = parseEnvFile(envText);
463
+ fromFile = envFileAdditions({ env, entries: parsed.entries, platform });
464
+ // Line numbers only: the text of a line that did not parse may well be a secret.
465
+ if (parsed.problems.length > 0) notes.push(`mu: .env: skipped line ${parsed.problems.join(", ")} (not KEY=VALUE)`);
466
+ }
467
+
468
+ const childEnv = {};
469
+ for (const [key, value] of Object.entries({ ...fromFile, ...env })) {
470
+ if (typeof value === "string") childEnv[key] = value;
471
+ }
472
+ childEnv.PI_PACKAGE_DIR = appDir;
473
+ childEnv.MU_CODING_AGENT_DIR = agentDir;
474
+ childEnv.KYRN_CODING_AGENT_DIR = agentDir;
475
+ childEnv.PI_CODING_AGENT_DIR = agentDir;
476
+ // pi's release check asks pi.dev about pi's versions, which say nothing about mu's: a checkout follows
477
+ // upstream through git, and the package through npm.
478
+ childEnv.PI_SKIP_VERSION_CHECK = childEnv.PI_SKIP_VERSION_CHECK || "1";
479
+ // pi reports a fresh install to pi.dev with its version. mu-agent is not a pi install; asked for, it still can be.
480
+ if (layout === "package" && childEnv.PI_TELEMETRY === undefined) childEnv.PI_TELEMETRY = "0";
481
+
482
+ let startJudge = false;
483
+ if (wantsLaya({ env: childEnv, agentDir, platform, readFile: fs.readFile })) {
484
+ const support = localJudgeSupport({ platform, env: childEnv, wsl });
485
+ if (support.runs) startJudge = true;
486
+ else if (!support.url) {
487
+ notes.push(
488
+ `mu: the configuration asks for the local judge (laya), which runs on macOS only; on ${platformName({ platform, wsl })} its decisions fall back to the next judge or to pi's behaviour (mu judge status)`,
489
+ );
490
+ }
491
+ }
492
+
493
+ return {
494
+ command: execPath,
495
+ args: [...entry, ...argv],
496
+ env: childEnv,
497
+ strategy: launchStrategy({ platform, env, canExec }),
498
+ layout,
499
+ muDir,
500
+ appDir,
501
+ agentDir,
502
+ startJudge,
503
+ notes,
504
+ preface: argv[0] === "-h" || argv[0] === "--help" ? `${usage(platform)}\n\nAgent flags:\n` : undefined,
505
+ };
506
+ }
507
+
508
+ // ---------------------------------------------------------------------------------------------------------
509
+ // mu judge
510
+ // ---------------------------------------------------------------------------------------------------------
511
+
512
+ /** What `mu judge <cmd>` does here: the macOS script, a look at a server of one's own, or a plain explanation. */
513
+ export function planJudge({ platform, env, argv, bin, wsl = false }) {
514
+ const args = argv.length > 0 ? argv : ["status"];
515
+ const support = localJudgeSupport({ platform, env, wsl });
516
+ if (support.runs) return { kind: "script", command: pathFor(platform).join(bin, "kyrn-judge-local"), args };
517
+ if (args[0] === "status" && support.url) return { kind: "health", url: `${support.url.replace(/\/+$/, "")}/health` };
518
+ return { kind: "unsupported", message: support.message };
519
+ }
520
+
521
+ // ---------------------------------------------------------------------------------------------------------
522
+ // mu link | unlink
523
+ // ---------------------------------------------------------------------------------------------------------
524
+
525
+ export function linkPath({ platform, env, home }) {
526
+ const path = pathFor(platform);
527
+ const dir = muEnv("LINK_DIR", env) || path.join(home, ".local", "bin");
528
+ return path.join(dir, platform === "win32" ? "mu.cmd" : "mu");
529
+ }
530
+
531
+ const SHIM_MARK = "rem mu-link-shim: written by `mu link`, removed by `mu unlink`.";
532
+
533
+ /**
534
+ * The file `mu link` writes on Windows, where a link to a script cannot be run. It names mu.cmd relative to
535
+ * itself when both are on one drive: cmd reads a batch file in the console's code page, so a path written
536
+ * out in full breaks under a non-ASCII user name, while %~dp0 is filled in by cmd itself.
537
+ */
538
+ export function shimContent({ linkDir, bin }) {
539
+ const path = nodePath.win32;
540
+ const target = path.join(bin, "mu.cmd");
541
+ const relative = path.relative(linkDir, target);
542
+ const named = path.isAbsolute(relative) ? target : `%~dp0${relative}`;
543
+ return ["@echo off", SHIM_MARK, `"${named}" %*`, ""].join("\r\n");
544
+ }
545
+
546
+ /** The first `name` a shell would run. `isFile` answers for a path that exists, is a file and may be executed. */
547
+ export function findOnPath({ name, platform, env, isFile }) {
548
+ const path = pathFor(platform);
549
+ const raw = (platform === "win32" ? (env.PATH ?? env.Path ?? env.path) : env.PATH) ?? "";
550
+ const extensions =
551
+ platform === "win32"
552
+ ? (env.PATHEXT ?? env.Pathext ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)
553
+ : [""];
554
+ for (const dir of raw.split(path.delimiter)) {
555
+ if (!dir) continue;
556
+ for (const extension of extensions) {
557
+ const candidate = path.join(dir, `${name}${extension.toLowerCase()}`);
558
+ if (isFile(candidate)) return candidate;
559
+ }
560
+ }
561
+ return undefined;
562
+ }
563
+
564
+ export function onPath({ dir, platform, env }) {
565
+ const path = pathFor(platform);
566
+ const raw = (platform === "win32" ? (env.PATH ?? env.Path ?? env.path) : env.PATH) ?? "";
567
+ const fold = (value) => {
568
+ const trimmed = value.length > 3 ? value.replace(/[\\/]+$/, "") : value;
569
+ return platform === "win32" ? trimmed.toLowerCase() : trimmed;
570
+ };
571
+ return raw.split(path.delimiter).some((entry) => entry && fold(entry) === fold(dir));
572
+ }
573
+
574
+ /**
575
+ * Homebrew's maildir-utils is also called `mu`. A command that is not this one is never replaced, and never
576
+ * shadowed silently. `fs` is { realPath, exists, isFile, readFile }: realPath follows links the way a shell
577
+ * does, also through a link whose target is gone.
578
+ */
579
+ export function planLink({ platform, env, home, bin, argv, fs }) {
580
+ const path = pathFor(platform);
581
+ const link = linkPath({ platform, env, home });
582
+ const linkDir = path.dirname(link);
583
+ const windows = platform === "win32";
584
+ const mine = path.join(bin, windows ? "mu.cmd" : "mu");
585
+ const content = windows ? shimContent({ linkDir, bin }) : undefined;
586
+ const same = (a, b) => (windows ? a.toLowerCase() === b.toLowerCase() : a === b);
587
+ const isThisMu = (candidate) => {
588
+ if (same(fs.realPath(candidate), mine)) return true;
589
+ if (!windows) return false;
590
+ try {
591
+ return fs.readFile(candidate) === content;
592
+ } catch {
593
+ return false;
594
+ }
595
+ };
596
+
597
+ if (fs.exists(link) && !isThisMu(link)) {
598
+ return {
599
+ action: "refuse",
600
+ errors: [
601
+ `${link} already exists and is not this mu; it was left alone. Choose another folder: MU_LINK_DIR=<dir> mu link`,
602
+ ],
603
+ };
604
+ }
605
+ const other = findOnPath({ name: "mu", platform, env, isFile: fs.isFile });
606
+ if (other && !isThisMu(other) && argv[0] !== "--force") {
607
+ return {
608
+ action: "refuse",
609
+ errors: [
610
+ `Another mu is already on your PATH: ${other}`,
611
+ "Nothing was linked. To put this one first anyway: mu link --force (or MU_LINK_DIR=<dir> mu link)",
612
+ ],
613
+ };
614
+ }
615
+ const notes = [];
616
+ if (!onPath({ dir: linkDir, platform, env })) {
617
+ notes.push(`Note: ${linkDir} is not on your PATH yet.`);
618
+ if (windows) {
619
+ // The user's own PATH, never `setx PATH "%PATH%;..."`: that copies the machine's PATH into it and cuts it at 1024 characters.
620
+ notes.push(
621
+ "Add it for your user in PowerShell, then open a new terminal:",
622
+ ` [Environment]::SetEnvironmentVariable("Path", [Environment]::GetEnvironmentVariable("Path", "User") + ";${linkDir}", "User")`,
623
+ );
624
+ }
625
+ }
626
+ if (windows && /[^\x00-\x7f]/.test(content)) {
627
+ notes.push(
628
+ "Note: the path from that folder to this repository has non-ASCII characters in it, and cmd reads a batch file in the",
629
+ "console's code page. If `mu` then cannot find its target, choose a folder on the same drive (MU_LINK_DIR), or put",
630
+ `${bin} on your PATH instead.`,
631
+ );
632
+ }
633
+ return { action: "link", link, target: mine, content, message: `mu -> ${mine}`, notes };
634
+ }
635
+
636
+ export function planUnlink({ platform, env, home, fs }) {
637
+ const link = linkPath({ platform, env, home });
638
+ if (platform === "win32") {
639
+ let text;
640
+ try {
641
+ text = fs.readFile(link);
642
+ } catch {}
643
+ return text?.includes(SHIM_MARK)
644
+ ? { remove: link, message: `removed ${link}` }
645
+ : { message: `${link} is not a shim written by mu link; nothing removed` };
646
+ }
647
+ return fs.isLink(link)
648
+ ? { remove: link, message: `removed ${link}` }
649
+ : { message: `${link} is not a link; nothing removed` };
650
+ }
651
+
652
+ /** What `mu doctor` says about the command. */
653
+ export function linkState({ platform, link, bin, fs }) {
654
+ if (platform === "win32") {
655
+ try {
656
+ return fs.readFile(link).includes(SHIM_MARK) ? `${link} -> ${pathFor(platform).join(bin, "mu.cmd")}` : undefined;
657
+ } catch {
658
+ return undefined;
659
+ }
660
+ }
661
+ try {
662
+ return `${link} -> ${fs.readlink(link)}`;
663
+ } catch {
664
+ return undefined;
665
+ }
666
+ }
667
+
668
+ // ---------------------------------------------------------------------------------------------------------
669
+ // mu migrate
670
+ // ---------------------------------------------------------------------------------------------------------
671
+
672
+ /** Minimal CSV, as `tasklist /FO CSV` and PowerShell's ConvertTo-Csv write it: quoted fields, "" for a quote. */
673
+ export function parseCsv(text) {
674
+ const rows = [];
675
+ let row = [];
676
+ let field = "";
677
+ let quoted = false;
678
+ const source = String(text).replace(/^\uFEFF/, "");
679
+ for (let index = 0; index < source.length; index++) {
680
+ const char = source[index];
681
+ if (quoted) {
682
+ if (char === '"' && source[index + 1] === '"') {
683
+ field += '"';
684
+ index++;
685
+ } else if (char === '"') quoted = false;
686
+ else field += char;
687
+ } else if (char === '"') quoted = true;
688
+ else if (char === ",") {
689
+ row.push(field);
690
+ field = "";
691
+ } else if (char === "\n" || char === "\r") {
692
+ if (char === "\r" && source[index + 1] === "\n") index++;
693
+ row.push(field);
694
+ field = "";
695
+ if (row.some((cell) => cell !== "")) rows.push(row);
696
+ row = [];
697
+ } else field += char;
698
+ }
699
+ row.push(field);
700
+ if (row.some((cell) => cell !== "")) rows.push(row);
701
+ return rows;
702
+ }
703
+
704
+ /** `tasklist /FO CSV /NH`: "Image Name","PID","Session Name","Session#","Mem Usage". */
705
+ export function parseTasklist(text) {
706
+ return parseCsv(text)
707
+ .map(([name, pid]) => ({ name: name ?? "", pid: Number(pid), command: "" }))
708
+ .filter((entry) => Number.isInteger(entry.pid) && entry.pid > 0);
709
+ }
710
+
711
+ /** `Get-CimInstance Win32_Process | Select-Object ProcessId,Name,CommandLine | ConvertTo-Csv -NoTypeInformation`. */
712
+ export function parseWindowsProcesses(text) {
713
+ const [header, ...rows] = parseCsv(text);
714
+ if (!header) return [];
715
+ const column = (title) => header.findIndex((cell) => cell.toLowerCase() === title);
716
+ const [pid, name, command] = [column("processid"), column("name"), column("commandline")];
717
+ if (pid < 0) return [];
718
+ return rows
719
+ .map((row) => ({ pid: Number(row[pid]), name: row[name] ?? "", command: row[command] ?? "" }))
720
+ .filter((entry) => Number.isInteger(entry.pid) && entry.pid > 0);
721
+ }
722
+
723
+ const SESSION_MARK = "kyrn-judge/src/extension/kyrn-judge.ts";
724
+
725
+ /**
726
+ * Who is still using the old home, from a process list ({ pid, name, command }). Windows paths are compared
727
+ * with forward slashes and without regard to case.
728
+ */
729
+ export function busyFromProcesses({ processes, old, platform, self }) {
730
+ const fold = (value) => (platform === "win32" ? value.replaceAll("\\", "/").toLowerCase() : value);
731
+ const others = processes.filter((entry) => entry.pid !== self);
732
+ const pids = (match) => others.filter(match).map((entry) => entry.pid);
733
+ const profile = fold(pathFor(platform).join(old, "browser-profile"));
734
+ return {
735
+ sessions: pids((entry) => fold(entry.command).includes(SESSION_MARK)),
736
+ browser: pids((entry) => fold(entry.command).includes(profile)),
737
+ desktop: pids((entry) => /^aioncore(\.exe)?$/i.test(entry.name)),
738
+ };
739
+ }
740
+
741
+ /**
742
+ * Looks at what is running. POSIX asks pgrep, exactly as the bash launcher did. Windows has no pgrep:
743
+ * tasklist gives the names and one PowerShell call gives the command lines. `run(command, args)` returns
744
+ * stdout, or undefined when the command could not be run.
745
+ */
746
+ export function findBusy({ platform, old, run, self }) {
747
+ if (platform !== "win32") {
748
+ const pgrep = (args) =>
749
+ (run("pgrep", args) ?? "")
750
+ .split(/\s+/)
751
+ .filter(Boolean)
752
+ .map(Number)
753
+ .filter((pid) => pid !== self);
754
+ return {
755
+ sessions: pgrep(["-f", SESSION_MARK]),
756
+ browser: pgrep(["-f", pathFor(platform).join(old, "browser-profile")]),
757
+ desktop: pgrep(["-x", "aioncore"]),
758
+ };
759
+ }
760
+ const names = run("tasklist", ["/FO", "CSV", "/NH"]);
761
+ const commands = run("powershell.exe", [
762
+ "-NoProfile",
763
+ "-NonInteractive",
764
+ "-Command",
765
+ "[Console]::OutputEncoding=[Text.Encoding]::UTF8; Get-CimInstance Win32_Process | Select-Object ProcessId,Name,CommandLine | ConvertTo-Csv -NoTypeInformation",
766
+ ]);
767
+ // Without a process list nothing can be ruled out, and then nothing is moved.
768
+ if (names === undefined || commands === undefined) return undefined;
769
+ return busyFromProcesses({
770
+ processes: [...parseTasklist(names), ...parseWindowsProcesses(commands)],
771
+ old,
772
+ platform,
773
+ self,
774
+ });
775
+ }
776
+
777
+ /**
778
+ * ~/.kyrn becomes ~/.mu: one rename of the whole folder, so nothing inside is opened, copied or rewritten,
779
+ * and the login files keep their permissions. The old path stays behind as a link (a junction on Windows),
780
+ * because the desktop app's session mappings store absolute paths into it.
781
+ *
782
+ * `fs` is { isLink, isDir, exists, readlink, readFile, rename, link(target, at, kind) }. Returns the exit code.
783
+ */
784
+ export function migrate({ platform, home, argv, fs, run, alive, self, out, err }) {
785
+ const path = pathFor(platform);
786
+ const old = path.join(home, ".kyrn");
787
+ const next = path.join(home, ".mu");
788
+ const dry = argv[0] === "--dry-run";
789
+ if (fs.isLink(old)) {
790
+ out(`Already moved: ${old} is a link to ${fs.readlink(old)}.`);
791
+ return 0;
792
+ }
793
+ if (!fs.isDir(old)) {
794
+ out(`Nothing to move: there is no ${old}.`);
795
+ return 0;
796
+ }
797
+ if (fs.exists(next) || fs.isLink(next)) {
798
+ err(`${next} already exists, so nothing was moved. Two homes are never merged or overwritten here:`);
799
+ err("look at both folders and decide which one is the real one.");
800
+ return 1;
801
+ }
802
+ // Whatever is running would write into the gap between the rename and the link, and split the home in two.
803
+ const busy = [];
804
+ let pid = 0;
805
+ try {
806
+ pid = Number.parseInt(fs.readFile(path.join(old, "local-judge", "judge.pid")).trim(), 10);
807
+ } catch {}
808
+ if (pid > 0 && alive(pid)) busy.push(` the local judge (pid ${pid}). Stop it: mu judge stop`);
809
+ const found = findBusy({ platform, old, run, self });
810
+ if (!found) {
811
+ err("Nothing was moved: the list of running processes could not be read (tasklist, powershell), so it is not known whether mu is still running.");
812
+ return 1;
813
+ }
814
+ const list = (pids) => pids.join(" ");
815
+ if (found.sessions.length > 0) {
816
+ busy.push(` mu sessions, in a terminal or behind the desktop app (pid ${list(found.sessions)}). Close them.`);
817
+ }
818
+ if (found.browser.length > 0) busy.push(` the mu browser (pid ${list(found.browser)}). Close it.`);
819
+ if (found.desktop.length > 0) {
820
+ busy.push(` the desktop app's backend (pid ${list(found.desktop)}). Quit the desktop app.`);
821
+ }
822
+ if (busy.length > 0) {
823
+ err(`Nothing was moved, because these are still using ${old}:`);
824
+ for (const line of busy) err(line);
825
+ return 1;
826
+ }
827
+ const config = path.join("agent", "kyrn.json");
828
+ const renameConfig = fs.exists(path.join(old, config)) && !fs.exists(path.join(old, "agent", "mu.json"));
829
+ if (dry) {
830
+ out(`Would move ${old} -> ${next}`);
831
+ if (renameConfig) out(`Would rename ${path.join(next, config)} -> mu.json`);
832
+ out(`Would link ${old} -> ${next}`);
833
+ out("Nothing was changed.");
834
+ return 0;
835
+ }
836
+ fs.rename(old, next);
837
+ // A junction needs no privilege on Windows; elsewhere the kind of link is ignored.
838
+ fs.link(next, old, platform === "win32" ? "junction" : "dir");
839
+ out(`moved ${old} -> ${next}`);
840
+ if (renameConfig) {
841
+ fs.rename(path.join(next, config), path.join(next, "agent", "mu.json"));
842
+ out(`renamed ${path.join(next, config)} -> mu.json`);
843
+ }
844
+ out(`linked ${old} -> ${next} (paths stored from before the rename keep working)`);
845
+ out("Check it: mu doctor");
846
+ return 0;
847
+ }
848
+
849
+ // ---------------------------------------------------------------------------------------------------------
850
+ // Carrying the plans out
851
+ // ---------------------------------------------------------------------------------------------------------
852
+
853
+ function isDir(target) {
854
+ try {
855
+ return statSync(target).isDirectory();
856
+ } catch {
857
+ return false;
858
+ }
859
+ }
860
+
861
+ function isLink(target) {
862
+ try {
863
+ return lstatSync(target).isSymbolicLink();
864
+ } catch {
865
+ return false;
866
+ }
867
+ }
868
+
869
+ /** Where a path really is, through any chain of links, also when the last target is gone. */
870
+ function realPath(target) {
871
+ let current = target;
872
+ for (let hops = 0; hops < 40 && isLink(current); hops++) {
873
+ const dir = nodePath.dirname(current);
874
+ let physical = dir;
875
+ try {
876
+ physical = realpathSync(dir);
877
+ } catch {}
878
+ current = nodePath.resolve(physical, readlinkSync(current));
879
+ }
880
+ try {
881
+ return nodePath.join(realpathSync(nodePath.dirname(current)), nodePath.basename(current));
882
+ } catch {
883
+ return current;
884
+ }
885
+ }
886
+
887
+ function isRunnableFile(target) {
888
+ try {
889
+ if (!statSync(target).isFile()) return false;
890
+ if (process.platform !== "win32") accessSync(target, fsConstants.X_OK);
891
+ return true;
892
+ } catch {
893
+ return false;
894
+ }
895
+ }
896
+
897
+ /** Synchronous on purpose: whatever is printed must be out before the agent, which shares the descriptor, writes. */
898
+ function write(fd, text) {
899
+ try {
900
+ writeSync(fd, `${text}\n`);
901
+ } catch {
902
+ (fd === 2 ? console.error : console.log)(text);
903
+ }
904
+ }
905
+ const out = (text) => write(1, text);
906
+ const err = (text) => write(2, text);
907
+
908
+ /** Runs a command to its end and gives back its exit code, replacing this process where that is possible. */
909
+ function handOver({ command, args, env, strategy }) {
910
+ if (strategy === "exec") {
911
+ try {
912
+ process.execve(command, [command, ...args], env);
913
+ } catch {
914
+ // Not available after all (a permission model, an exotic platform): start it as a child instead.
915
+ }
916
+ }
917
+ return new Promise((resolve) => {
918
+ const child = spawn(command, args, { stdio: "inherit", env });
919
+ // A terminal delivers ctrl+c to the child by itself. On Windows `kill` cannot deliver a signal, it only
920
+ // ends the process, so there this process just stays alive until the child has dealt with it.
921
+ const relay = (signal) => {
922
+ if (process.platform !== "win32") child.kill(signal);
923
+ };
924
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(signal, () => relay(signal));
925
+ if (process.platform === "win32") process.on("SIGBREAK", () => {});
926
+ child.on("error", (error) => {
927
+ err(`mu: could not start ${command}: ${error.message}`);
928
+ resolve(1);
929
+ });
930
+ child.on("exit", (code, signal) => resolve(code ?? 128 + (osConstants.signals[signal] ?? 0)));
931
+ });
932
+ }
933
+
934
+ export async function main(argv = process.argv.slice(2)) {
935
+ const platform = process.platform;
936
+ const env = process.env;
937
+ const home = homedir();
938
+ const path = pathFor(platform);
939
+ const bin = realpathSync(nodePath.dirname(fileURLToPath(import.meta.url)));
940
+ const root = path.resolve(bin, "..", "..");
941
+ const canExec = typeof process.execve === "function";
942
+ const strategy = launchStrategy({ platform, env, canExec });
943
+ const readText = (target) => readFileSync(target, "utf8");
944
+ let procVersion = "";
945
+ try {
946
+ if (platform === "linux") procVersion = readText("/proc/version");
947
+ } catch {}
948
+ const wsl = detectWsl({ platform, env, procVersion });
949
+ const layout = layoutOf({ root, platform, exists: existsSync });
950
+
951
+ if (!nodeVersionOk(process.versions.node)) {
952
+ err(`mu needs Node >= ${MIN_NODE.join(".")} (found ${process.version}). Try: nvm install 24`);
953
+ return 1;
954
+ }
955
+
956
+ const [command, ...rest] = argv;
957
+ if (command === "help") {
958
+ out(usage(platform));
959
+ return 0;
960
+ }
961
+ if (command === "migrate") {
962
+ return migrate({
963
+ platform,
964
+ home,
965
+ argv: rest,
966
+ self: process.pid,
967
+ out,
968
+ err,
969
+ alive: (pid) => {
970
+ try {
971
+ return process.kill(pid, 0);
972
+ } catch {
973
+ return false;
974
+ }
975
+ },
976
+ run: (file, args) => {
977
+ const result = spawnSync(file, args, { encoding: "utf8", windowsHide: true });
978
+ return result.error ? undefined : result.stdout;
979
+ },
980
+ fs: {
981
+ isLink,
982
+ isDir,
983
+ exists: existsSync,
984
+ readlink: readlinkSync,
985
+ readFile: readText,
986
+ rename: renameSync,
987
+ link: (target, at, kind) => symlinkSync(target, at, kind),
988
+ },
989
+ });
990
+ }
991
+ if (command === "judge") {
992
+ const plan = planJudge({ platform, env, argv: rest, bin, wsl });
993
+ if (plan.kind === "script") return handOver({ command: plan.command, args: plan.args, env, strategy });
994
+ if (plan.kind === "unsupported") {
995
+ err(plan.message);
996
+ return 1;
997
+ }
998
+ try {
999
+ const response = await fetch(plan.url, { signal: AbortSignal.timeout(2000) });
1000
+ out(await response.text());
1001
+ return 0;
1002
+ } catch {
1003
+ out(`the judge server does not answer at ${plan.url}`);
1004
+ return 1;
1005
+ }
1006
+ }
1007
+ if (command === "ledger") {
1008
+ return handOver({ command: process.execPath, args: [path.join(bin, "kyrn-ledger"), ...rest], env, strategy });
1009
+ }
1010
+ if (command === "doctor") {
1011
+ const link = linkPath({ platform, env, home });
1012
+ return handOver({
1013
+ command: process.execPath,
1014
+ args: [path.join(bin, "kyrn-doctor"), root, link, ...rest],
1015
+ env,
1016
+ strategy,
1017
+ });
1018
+ }
1019
+ if ((command === "link" || command === "unlink") && layout === "package") {
1020
+ out("This mu was installed with npm, which already put `mu` on your PATH (npm i -g mu-agent); nothing to link.");
1021
+ return 0;
1022
+ }
1023
+ if (command === "link") {
1024
+ const plan = planLink({
1025
+ platform,
1026
+ env,
1027
+ home,
1028
+ bin,
1029
+ argv: rest,
1030
+ fs: { realPath, exists: (target) => existsSync(target) || isLink(target), isFile: isRunnableFile, readFile: readText },
1031
+ });
1032
+ if (plan.action === "refuse") {
1033
+ for (const line of plan.errors) err(line);
1034
+ return 1;
1035
+ }
1036
+ mkdirSync(path.dirname(plan.link), { recursive: true });
1037
+ if (platform === "win32") writeFileSync(plan.link, plan.content);
1038
+ else {
1039
+ if (isLink(plan.link)) unlinkSync(plan.link);
1040
+ symlinkSync(plan.target, plan.link);
1041
+ }
1042
+ out(plan.message);
1043
+ for (const line of plan.notes) err(line);
1044
+ return 0;
1045
+ }
1046
+ if (command === "unlink") {
1047
+ const plan = planUnlink({ platform, env, home, fs: { isLink, readFile: readText } });
1048
+ if (plan.remove) unlinkSync(plan.remove);
1049
+ out(plan.message);
1050
+ return 0;
1051
+ }
1052
+
1053
+ if (layout === "package" && command === "version") {
1054
+ const pkg = JSON.parse(readText(path.join(root, "package.json")));
1055
+ out(`mu ${pkg.version} (pi ${pkg.muBuild?.pi ?? "?"}, npm package ${pkg.name})`);
1056
+ return 0;
1057
+ }
1058
+ // Asked before `version` too, as it always was: a checkout without its dependencies says so at the first command.
1059
+ if (layout === "repo" && !resolveTsx({ root, platform, exists: existsSync, readFile: readText })) {
1060
+ err(installHint({ root, platform }));
1061
+ return 1;
1062
+ }
1063
+ if (command === "version") {
1064
+ const version = (file) => JSON.parse(readText(path.join(root, "packages", file, "package.json"))).version;
1065
+ out(`mu ${version("kyrn-judge")} (pi ${version("coding-agent")})`);
1066
+ return 0;
1067
+ }
1068
+
1069
+ const plan = planLaunch({
1070
+ platform,
1071
+ env,
1072
+ argv,
1073
+ root,
1074
+ home,
1075
+ execPath: process.execPath,
1076
+ canExec,
1077
+ wsl,
1078
+ fs: { exists: existsSync, isDir, readFile: readText },
1079
+ });
1080
+ if (plan.error) {
1081
+ err(plan.error);
1082
+ return 1;
1083
+ }
1084
+
1085
+ if (plan.layout === "repo") {
1086
+ ensureAppView({ platform, app: plan.appDir, upstream: path.join(root, "packages", "coding-agent") });
1087
+ }
1088
+ mkdirSync(plan.agentDir, { recursive: true });
1089
+ for (const note of plan.notes) err(note);
1090
+ // The local judge is one shared sidecar; starting it is idempotent and must never block mu.
1091
+ if (plan.startJudge) {
1092
+ const started = spawnSync(path.join(bin, "kyrn-judge-local"), ["start"], { stdio: "ignore", env: plan.env });
1093
+ if (started.status !== 0) {
1094
+ err("mu: the local judge did not start (mu judge status); decisions fall back to pi's behaviour");
1095
+ }
1096
+ }
1097
+ if (plan.preface) out(plan.preface);
1098
+ return handOver(plan);
1099
+ }
1100
+
1101
+ function isMain() {
1102
+ if (!process.argv[1]) return false;
1103
+ try {
1104
+ const [invoked, self] = [realpathSync(process.argv[1]), fileURLToPath(import.meta.url)];
1105
+ return process.platform === "win32" ? invoked.toLowerCase() === self.toLowerCase() : invoked === self;
1106
+ } catch {
1107
+ return false;
1108
+ }
1109
+ }
1110
+
1111
+ if (isMain()) {
1112
+ main().then(
1113
+ // Everything was written synchronously, so nothing is cut off; an idle keep-alive socket must not hold the exit up.
1114
+ (code) => process.exit(code),
1115
+ (error) => {
1116
+ err(`mu: ${error instanceof Error ? error.message : String(error)}`);
1117
+ process.exitCode = 1;
1118
+ },
1119
+ );
1120
+ }