mixdog 1.1.0 → 1.1.2

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 (174) hide show
  1. package/README.md +13 -6
  2. package/package.json +1 -2
  3. package/scripts/latest-guard.mjs +63 -0
  4. package/scripts/lib/run-node-tests.mjs +103 -27
  5. package/scripts/lib/test-failure-summary.mjs +7 -2
  6. package/scripts/merge-mac-update-feed.mjs +107 -0
  7. package/scripts/mobile-release.mjs +210 -0
  8. package/scripts/post-release-check.mjs +353 -0
  9. package/scripts/publish-asset-release.mjs +43 -0
  10. package/scripts/release-guard.mjs +63 -0
  11. package/scripts/release-paths.mjs +39 -2
  12. package/scripts/test.mjs +29 -7
  13. package/src/defaults/shipped-history.json +2 -0
  14. package/src/defaults/skills/computer-use/SKILL.md +1 -1
  15. package/src/defaults/skills/setup/references/actions.md +1 -0
  16. package/src/headless-role.mjs +2 -2
  17. package/src/lib/keychain-cjs.cjs +582 -17
  18. package/src/lib/rules-builder.cjs +0 -23
  19. package/src/rules/lead/LEAD.md +2 -2
  20. package/src/runtime/agent/orchestrator/config-storage.mjs +32 -4
  21. package/src/runtime/agent/orchestrator/config.mjs +18 -0
  22. package/src/runtime/agent/orchestrator/context/instruction-files.mjs +130 -0
  23. package/src/runtime/agent/orchestrator/context/role-instructions.mjs +4 -0
  24. package/src/runtime/agent/orchestrator/providers/lib/oauth-token-utils.mjs +11 -0
  25. package/src/runtime/agent/orchestrator/providers/lib/open-login-url.mjs +9 -0
  26. package/src/runtime/agent/orchestrator/providers/mimo-oauth-login.mjs +254 -0
  27. package/src/runtime/agent/orchestrator/providers/mimo-oauth-profile.mjs +218 -0
  28. package/src/runtime/agent/orchestrator/providers/mimo-oauth-tokens.mjs +239 -0
  29. package/src/runtime/agent/orchestrator/providers/mimo-oauth.mjs +233 -0
  30. package/src/runtime/agent/orchestrator/providers/muse-code-oauth-login.mjs +247 -0
  31. package/src/runtime/agent/orchestrator/providers/muse-code-oauth-tokens.mjs +225 -0
  32. package/src/runtime/agent/orchestrator/providers/muse-code-oauth.mjs +542 -0
  33. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +25 -0
  34. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +4 -1
  35. package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +13 -0
  36. package/src/runtime/agent/orchestrator/providers/openai-compat-responses.mjs +14 -6
  37. package/src/runtime/agent/orchestrator/providers/openai-oauth-catalog-store.mjs +99 -0
  38. package/src/runtime/agent/orchestrator/providers/openai-oauth-catalog.mjs +4 -8
  39. package/src/runtime/agent/orchestrator/providers/openai-oauth-login.mjs +2 -7
  40. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +8 -0
  41. package/src/runtime/agent/orchestrator/providers/registry.mjs +4 -0
  42. package/src/runtime/agent/orchestrator/runtime-core/effort.mjs +2 -0
  43. package/src/runtime/agent/orchestrator/runtime-core/goal-tool-defs.mjs +15 -1
  44. package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +1 -5
  45. package/src/runtime/agent/orchestrator/session/context-utils.mjs +12 -14
  46. package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +11 -1
  47. package/src/runtime/agent/orchestrator/session/loop/tool-call-ids.mjs +90 -0
  48. package/src/runtime/agent/orchestrator/session/loop/tool-exec/before-hook.mjs +19 -11
  49. package/src/runtime/agent/orchestrator/session/loop/tool-phase.mjs +1 -0
  50. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +28 -0
  51. package/src/runtime/agent/orchestrator/session/manager/session-prompt-composition.mjs +8 -0
  52. package/src/runtime/agent/orchestrator/session/manager/session-record.mjs +3 -0
  53. package/src/runtime/agent/orchestrator/session/manager.mjs +1 -5
  54. package/src/runtime/agent/orchestrator/session/read-dedup.mjs +0 -1
  55. package/src/runtime/agent/orchestrator/session/store-agent-worker-rows.mjs +3 -0
  56. package/src/runtime/agent/orchestrator/session/tool-batch/finalize.mjs +2 -0
  57. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +2 -0
  58. package/src/runtime/agent/orchestrator/session/transcript-restore/tool-result-status.mjs +23 -0
  59. package/src/runtime/agent/orchestrator/session/turn-effort.mjs +18 -0
  60. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +11 -8
  61. package/src/runtime/agent/orchestrator/tools/builtin/atomic-write.mjs +9 -4
  62. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/bashism-rewrite.mjs +245 -0
  63. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/command-preflight.mjs +13 -1
  64. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/completed-result.mjs +3 -1
  65. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/invocation-plan.mjs +2 -2
  66. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/result-format.mjs +49 -0
  67. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +3 -3
  68. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +9 -7
  69. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +61 -8
  70. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +28 -3
  71. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +73 -1
  72. package/src/runtime/agent/orchestrator/tools/builtin/read-single-guards.mjs +2 -2
  73. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +3 -1
  74. package/src/runtime/agent/orchestrator/tools/builtin/search-grep-tool.mjs +17 -6
  75. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +23 -5
  76. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +93 -43
  77. package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +32 -6
  78. package/src/runtime/agent/orchestrator/tools/builtin.mjs +9 -2
  79. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +3 -1
  80. package/src/runtime/agent/orchestrator/tools/lib/shell-run-background/promote-job.mjs +12 -15
  81. package/src/runtime/agent/orchestrator/tools/lib/shell-run-background.mjs +2 -2
  82. package/src/runtime/agent/orchestrator/tools/lib/shell-run-state.mjs +4 -2
  83. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +124 -91
  84. package/src/runtime/agent/orchestrator/tools/shell-exec-policy.mjs +19 -4
  85. package/src/runtime/channels/index.mjs +1 -5
  86. package/src/runtime/channels/lib/runtime-paths.mjs +8 -23
  87. package/src/runtime/channels/lib/tool-dispatch.mjs +1 -13
  88. package/src/runtime/channels/lib/worker-ipc.mjs +2 -2
  89. package/src/runtime/channels/lib/worker-main.mjs +4 -34
  90. package/src/runtime/computer-bridge/client.mjs +25 -1
  91. package/src/runtime/computer-bridge/linux-action-capabilities.mjs +84 -0
  92. package/src/runtime/computer-bridge/linux-readiness.d.mts +52 -0
  93. package/src/runtime/computer-bridge/linux-readiness.mjs +411 -0
  94. package/src/runtime/computer-bridge/test-linux-display.mjs +5 -0
  95. package/src/runtime/effort-judge/compact-tokenizer.mjs +13 -8
  96. package/src/runtime/effort-judge/judge-input.mjs +5 -2
  97. package/src/runtime/effort-judge/model-install.mjs +9 -3
  98. package/src/runtime/effort-judge/model-manifest.json +7 -7
  99. package/src/runtime/memory/lib/embedding-worker.mjs +9 -3
  100. package/src/runtime/memory/lib/pg/supervisor.mjs +3 -0
  101. package/src/runtime/shared/config.mjs +50 -2
  102. package/src/runtime/shared/developer-options.mjs +7 -0
  103. package/src/runtime/shared/instruction-files-mode.mjs +9 -0
  104. package/src/runtime/shared/llm/model-catalog.mjs +2 -1
  105. package/src/runtime/shared/llm/openai-daybreak.mjs +79 -0
  106. package/src/runtime/shared/pristine-execution-contract.json +9 -0
  107. package/src/runtime/shared/pristine-execution.mjs +1 -1
  108. package/src/runtime/shared/provider-accounts.mjs +2 -0
  109. package/src/runtime/shared/resource-admission.mjs +10 -3
  110. package/src/runtime/shared/schedule-model-ref.mjs +11 -0
  111. package/src/runtime/shared/semver.d.mts +1 -0
  112. package/src/runtime/shared/semver.mjs +57 -0
  113. package/src/runtime/shared/session-approval-wait.mjs +35 -0
  114. package/src/runtime/shared/shell-display.mjs +4 -2
  115. package/src/runtime/shared/tool-surface.mjs +2 -2
  116. package/src/runtime/shared/turn-worktree-snapshot.mjs +11 -10
  117. package/src/runtime/shared/update-checker.mjs +2 -31
  118. package/src/session-runtime/boot/apis.mjs +5 -0
  119. package/src/session-runtime/boot/begin.mjs +0 -2
  120. package/src/session-runtime/boot/tools.mjs +5 -4
  121. package/src/session-runtime/model-recency.mjs +3 -0
  122. package/src/session-runtime/provider-auth/login.mjs +10 -10
  123. package/src/session-runtime/provider-auth/setup.mjs +22 -2
  124. package/src/session-runtime/provider-models/row-hydration.mjs +12 -4
  125. package/src/session-runtime/provider-readiness.mjs +4 -0
  126. package/src/session-runtime/resource-mcp-api.mjs +7 -3
  127. package/src/session-runtime/runtime-bootstrap.mjs +0 -4
  128. package/src/session-runtime/runtime-facade.mjs +5 -0
  129. package/src/session-runtime/runtime-paths.mjs +0 -1
  130. package/src/session-runtime/schedule-session-run.mjs +3 -1
  131. package/src/session-runtime/services/agent-tool/index-write-queue.mjs +55 -14
  132. package/src/session-runtime/services/agent-tool/job-views/agent-list.mjs +15 -3
  133. package/src/session-runtime/services/agent-tool/job-views/session-progress.mjs +2 -0
  134. package/src/session-runtime/services/agent-tool/worker-index/approval-wait.mjs +97 -0
  135. package/src/session-runtime/services/agent-tool/worker-index/mutation-batch.mjs +4 -2
  136. package/src/session-runtime/services/agent-tool/worker-index/row-liveness.mjs +8 -2
  137. package/src/session-runtime/services/agent-tool/worker-index/row-shape.mjs +3 -0
  138. package/src/session-runtime/services/agent-tool/worker-index/row-store.mjs +58 -2
  139. package/src/session-runtime/services/agent-tool/worker-index.mjs +69 -15
  140. package/src/session-runtime/services/agent-tool/worker-rows.mjs +2 -0
  141. package/src/session-runtime/services/channel-admin.mjs +4 -2
  142. package/src/session-runtime/services/channel-worker.mjs +1 -1
  143. package/src/session-runtime/services/provider-admin.mjs +80 -17
  144. package/src/session-runtime/services/seeds.mjs +7 -10
  145. package/src/session-runtime/services/usage-dashboard-model.mjs +6 -0
  146. package/src/session-runtime/settings-builtin-tools-api.mjs +15 -0
  147. package/src/session-runtime/settings-profile-api.mjs +27 -2
  148. package/src/session-runtime/setup-tool/executor.mjs +5 -0
  149. package/src/session-runtime/setup-tool/tool-defs.mjs +8 -0
  150. package/src/session-runtime/starter-guide-state.mjs +62 -0
  151. package/src/session-runtime/tool-policy-refresh.mjs +3 -0
  152. package/src/session-runtime/webhook-session-run.mjs +3 -1
  153. package/src/session-runtime/workflow-agents-api/onboarding.mjs +3 -2
  154. package/src/standalone/channel-binding.mjs +1 -2
  155. package/src/standalone/channel-transport/call-dispatch.mjs +4 -10
  156. package/src/standalone/daemon-channels-loader.mjs +2 -3
  157. package/src/standalone/local-session-runtime.mjs +2 -78
  158. package/src/standalone/session-protocol.mjs +2 -0
  159. package/src/tui/app/model-options.mjs +13 -1
  160. package/src/tui/app/model-picker/route-selection/fast-parameters.mjs +10 -4
  161. package/src/tui/app/provider-setup/oauth-actions.mjs +5 -0
  162. package/src/tui/dist/index.mjs +84 -34
  163. package/src/tui/session/context-state.mjs +2 -0
  164. package/src/tui/session/live-share/owner.mjs +17 -0
  165. package/src/tui/session/oauth-flows.mjs +2 -0
  166. package/src/tui/session/session-api/extensions.mjs +12 -8
  167. package/src/tui/session/session-api/integrations.mjs +9 -8
  168. package/src/tui/session/session-api/routes.mjs +2 -2
  169. package/src/tui/session/session-api/settings.mjs +1 -0
  170. package/src/tui/session/session-api/shared.mjs +10 -6
  171. package/src/tui/session/tool-card-results/grouped-result.mjs +11 -2
  172. package/src/tui/session/transcript-spill/spill-dir.mjs +22 -6
  173. package/src/tui/spinner-meta.mjs +26 -10
  174. package/src/runtime/channels/tool-defs.mjs +0 -17
package/README.md CHANGED
@@ -12,8 +12,10 @@
12
12
  <a href="https://github.com/tribgames/mixdog/releases/latest/download/mixdog-desktop-win-x64.exe"><img src="https://raw.githubusercontent.com/tribgames/mixdog/main/docs/assets/download-windows.svg" alt="Download for Windows" height="48"></a>
13
13
  &nbsp;
14
14
  <a href="https://github.com/tribgames/mixdog/releases/latest/download/mixdog-desktop-mac-arm64.dmg"><img src="https://raw.githubusercontent.com/tribgames/mixdog/main/docs/assets/download-macos.svg" alt="Download for macOS" height="48"></a>
15
+ &nbsp;
16
+ <a href="https://github.com/tribgames/mixdog/releases/latest/download/mixdog-desktop-linux-x86_64.AppImage"><img src="https://raw.githubusercontent.com/tribgames/mixdog/main/docs/assets/download-linux.svg" alt="Download for Linux" height="48"></a>
15
17
  <br>
16
- <sub>macOS button is for Apple Silicon (M1 and later). Intel Mac? <a href="https://github.com/tribgames/mixdog/releases/latest/download/mixdog-desktop-mac-x64.dmg">Download for Intel Mac</a></sub>
18
+ <sub>macOS button is for Apple Silicon (M1 and later). Intel Mac? <a href="https://github.com/tribgames/mixdog/releases/latest/download/mixdog-desktop-mac-x64.dmg">Download for Intel Mac</a>. Linux button is the x64 AppImage; .deb and ARM64 builds are listed below.</sub>
17
19
  </p>
18
20
 
19
21
  <p align="center">
@@ -35,9 +37,9 @@ https://github.com/user-attachments/assets/af515dc9-b0fb-4b6a-aaa1-1d82db792ed0
35
37
  - [63% fewer tokens](#benchmarks) than Codex CLI on the same model, at the
36
38
  same success rate.
37
39
  - [**Auto reasoning effort**](docs/auto-reasoning-effort.md) — each message
38
- and tool step gets the effort it needs, judged in about 0.1 s by a 130 MB
40
+ and tool step gets the effort it needs, judged in about 0.1 s by a 150 MB
39
41
  model that runs locally on the CPU. On by default.
40
- - Use the **Claude, ChatGPT, or Grok** subscription you already have, API
42
+ - Use the **Claude, ChatGPT, Grok, or Muse Code** subscription you already have, API
41
43
  keys, or local models on your own GPU.
42
44
  - Mix models by role and scale from **Solo to Swarm** — a strong model leads
43
45
  while cheaper ones search, edit, and review in parallel.
@@ -47,7 +49,7 @@ https://github.com/user-attachments/assets/af515dc9-b0fb-4b6a-aaa1-1d82db792ed0
47
49
  continues from your phone, end-to-end encrypted.
48
50
  - Goes beyond code with **browser and desktop control**, Office and PDF
49
51
  documents, and image and video generation.
50
- - Runs on Windows and macOS. No config files.
52
+ - Runs on Windows, macOS, and Linux. No config files.
51
53
 
52
54
  ## Installation
53
55
 
@@ -58,8 +60,12 @@ Download the desktop app:
58
60
  | Windows (x64) | [`mixdog-desktop-win-x64.exe`](https://github.com/tribgames/mixdog/releases/latest/download/mixdog-desktop-win-x64.exe) |
59
61
  | macOS (Apple Silicon) | [`mixdog-desktop-mac-arm64.dmg`](https://github.com/tribgames/mixdog/releases/latest/download/mixdog-desktop-mac-arm64.dmg) |
60
62
  | macOS (Intel) | [`mixdog-desktop-mac-x64.dmg`](https://github.com/tribgames/mixdog/releases/latest/download/mixdog-desktop-mac-x64.dmg) |
63
+ | Linux (x64, AppImage) | [`mixdog-desktop-linux-x86_64.AppImage`](https://github.com/tribgames/mixdog/releases/latest/download/mixdog-desktop-linux-x86_64.AppImage) |
64
+ | Linux (x64, .deb) | [`mixdog-desktop-linux-amd64.deb`](https://github.com/tribgames/mixdog/releases/latest/download/mixdog-desktop-linux-amd64.deb) |
65
+ | Linux (ARM64, AppImage) | [`mixdog-desktop-linux-arm64.AppImage`](https://github.com/tribgames/mixdog/releases/latest/download/mixdog-desktop-linux-arm64.AppImage) |
66
+ | Linux (ARM64, .deb) | [`mixdog-desktop-linux-arm64.deb`](https://github.com/tribgames/mixdog/releases/latest/download/mixdog-desktop-linux-arm64.deb) |
61
67
 
62
- Then sign in with a ChatGPT, Claude, or Grok account, or paste an API key. A
68
+ Then sign in with a ChatGPT, Claude, Grok, or Muse Code account, or paste an API key. A
63
69
  five-step tutorial walks you through the rest.
64
70
 
65
71
  > [!NOTE]
@@ -80,11 +86,12 @@ mixdog
80
86
  - OpenAI API keys and ChatGPT/Codex account OAuth
81
87
  - Google Gemini API keys
82
88
  - xAI API keys and Grok account OAuth
89
+ - Muse Code (Meta) account OAuth
83
90
  - OpenRouter, DeepSeek, and OpenCode Go
84
91
  - Built-in **Local Provider** — download and run models in the app
85
92
  (Windows x64 with an NVIDIA GPU)
86
93
 
87
- Cursor and Antigravity (Gemini) OAuth are off by default under
94
+ Cursor, Antigravity (Gemini), and MiMo OAuth are off by default under
88
95
  **Settings → Developer**; using them through OAuth risks account restrictions.
89
96
 
90
97
  </details>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Open-source coding agent that matches Codex on Terminal-Bench 2.1 with 63% fewer tokens.",
@@ -203,7 +203,6 @@
203
203
  "eslint-scope": "^9.1.2",
204
204
  "lucide-static": "^1.41.0"
205
205
  },
206
- "main": "index.js",
207
206
  "directories": {
208
207
  "doc": "docs"
209
208
  },
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ // Keep GitHub's "Latest" release on the newest published app release.
3
+ // Run by .github/workflows/latest-guard.yml. When Latest points elsewhere (or
4
+ // nowhere) it is set back with `gh release edit <tag> --latest`, the job
5
+ // summary and an issue describe what was wrong, and the job fails so the
6
+ // drift is visible even though it has been repaired.
7
+
8
+ import { appendFileSync } from 'node:fs';
9
+ import { pathToFileURL } from 'node:url';
10
+ import { currentLatestTag, gh, listReleases, selectLatestAppRelease } from './release-guard.mjs';
11
+
12
+ const ISSUE_TITLE = 'Latest release pointer drifted from the newest app release';
13
+
14
+ function report(text) {
15
+ console.log(text);
16
+ if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${text}\n`);
17
+ }
18
+
19
+ function openIssue(repo, body) {
20
+ const existing = JSON.parse(gh([
21
+ 'issue', 'list', '--repo', repo, '--state', 'open', '--search', `"${ISSUE_TITLE}" in:title`,
22
+ '--json', 'number,title',
23
+ ])).find((issue) => issue.title === ISSUE_TITLE);
24
+ if (existing) {
25
+ gh(['issue', 'comment', String(existing.number), '--repo', repo, '--body', body]);
26
+ return `commented on issue #${existing.number}`;
27
+ }
28
+ gh(['issue', 'create', '--repo', repo, '--title', ISSUE_TITLE, '--body', body]);
29
+ return 'opened an issue';
30
+ }
31
+
32
+ export function main(repo = process.env.GITHUB_REPOSITORY) {
33
+ const expected = selectLatestAppRelease(listReleases(repo));
34
+ if (!expected) {
35
+ report('No published app release (vX.Y.Z) exists; nothing to guard.');
36
+ return 0;
37
+ }
38
+ const actual = currentLatestTag(repo);
39
+ if (actual === expected.tag_name) {
40
+ report(`Latest is ${actual}, the newest app release. OK.`);
41
+ return 0;
42
+ }
43
+
44
+ const problem = `GitHub Latest was \`${actual ?? 'none'}\` but the newest published app release is \`${expected.tag_name}\`.`;
45
+ gh(['release', 'edit', expected.tag_name, '--repo', repo, '--latest']);
46
+ const repaired = currentLatestTag(repo);
47
+ const outcome = repaired === expected.tag_name
48
+ ? `Set Latest back to \`${expected.tag_name}\`.`
49
+ : `Could not set Latest to \`${expected.tag_name}\`; it is now \`${repaired ?? 'none'}\`.`;
50
+ const detail = `${problem}\n${outcome}\n\nWorkflow run: ${process.env.GITHUB_SERVER_URL ?? ''}/${repo}/actions/runs/${process.env.GITHUB_RUN_ID ?? ''}\n`
51
+ + 'Check which release or workflow published a non-app release without `--latest=false` / `make_latest: false`.';
52
+ report(`## Latest release guard\n${detail}`);
53
+ try {
54
+ report(`Issue: ${openIssue(repo, detail)}.`);
55
+ } catch (error) {
56
+ report(`Issue not filed: ${error.message}`);
57
+ }
58
+ return 1;
59
+ }
60
+
61
+ if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
62
+ process.exit(main());
63
+ }
@@ -70,19 +70,68 @@ const positiveInt = (value) => {
70
70
  // `node --test` defaults to availableParallelism()-1 file processes. When the
71
71
  // run is one of several concurrent shell commands (the Mixdog shell tool
72
72
  // exports the admission cap as MIXDOG_SHELL_CONCURRENCY_CAP), that multiplies
73
- // across peers, so each run takes its share of the cores. Precedence: an
73
+ // across peers, so a capped run leaves `cap` cores to them: cores - cap, never
74
+ // below 4. Dividing the cores by the cap left a 20-core machine with 2 file
75
+ // processes and tripled the wall time (desktop 289 s vs 63.5 s). Precedence: an
74
76
  // explicit --test-concurrency flag, then MIXDOG_TEST_CONCURRENCY, then the
75
- // share; with none of them Node's default stands and standalone runs are
76
- // unchanged. Returns the flag to add, or null.
77
+ // capped share; with none of them Node's default (cores-1) stands.
78
+ // Returns the flag to add, or null.
77
79
  export function testConcurrencyArg(nodeArgs, env = process.env, parallelism = availableParallelism()) {
78
- if (nodeArgs.some((arg) => arg === '--test-concurrency' || arg.startsWith('--test-concurrency='))) return null;
80
+ if (nodeArgs.some(concurrencyFlag)) return null;
79
81
  const explicit = positiveInt(env.MIXDOG_TEST_CONCURRENCY);
80
82
  if (explicit) return `--test-concurrency=${explicit}`;
81
83
  const cap = positiveInt(env.MIXDOG_SHELL_CONCURRENCY_CAP);
82
84
  if (!cap) return null;
83
- return `--test-concurrency=${Math.max(2, Math.floor(parallelism / cap))}`;
85
+ return `--test-concurrency=${Math.max(4, parallelism - cap)}`;
84
86
  }
85
87
 
88
+ // Every test file process is bounded: node:test fails a file (and records it
89
+ // like any other failure) that is still running after --test-timeout, so a hang
90
+ // costs minutes instead of waiting for the batch watchdog. A --test-timeout
91
+ // flag wins, then MIXDOG_TEST_TIMEOUT_MS (0 turns the bound off), then the
92
+ // caller's default. Returns the flag to add, or null.
93
+ export function testTimeoutArg(nodeArgs, env = process.env, defaultMs = DEFAULT_TEST_TIMEOUT_MS) {
94
+ if (nodeArgs.some((arg) => arg === '--test-timeout' || arg.startsWith('--test-timeout='))) return null;
95
+ const raw = env.MIXDOG_TEST_TIMEOUT_MS;
96
+ if (raw !== undefined && String(raw).trim() === '0') return null;
97
+ const ms = positiveInt(raw) ?? defaultMs;
98
+ return ms ? `--test-timeout=${ms}` : null;
99
+ }
100
+
101
+ const concurrencyFlag = (arg) => arg === '--test-concurrency' || arg.startsWith('--test-concurrency=');
102
+
103
+ // The file-process count a run was given (flag or env), else Node's default.
104
+ function concurrencyOf(runArgs, fileArgs) {
105
+ const args = [...runArgs, ...fileArgs];
106
+ const index = args.findIndex(concurrencyFlag);
107
+ const value = index < 0 ? null : positiveInt(args[index].includes('=') ? args[index].split('=')[1] : args[index + 1]);
108
+ return value ?? Math.max(1, availableParallelism() - 1);
109
+ }
110
+
111
+ function withoutConcurrency(args) {
112
+ return args.filter((arg, i) => !concurrencyFlag(arg) && !(i > 0 && args[i - 1] === '--test-concurrency'));
113
+ }
114
+
115
+ // What keeps a test process off the signed-in user's machine state.
116
+ export function testIsolationEnv(scratchDir) {
117
+ return {
118
+ TEMP: scratchDir,
119
+ TMP: scratchDir,
120
+ TMPDIR: scratchDir,
121
+ // A fixture must never write into the signed-in app's data (~/.mixdog):
122
+ // schedule/webhook logs, design-library bindings and the like resolve the
123
+ // default data dir. The desktop preload already does this per process.
124
+ MIXDOG_DATA_DIR: join(scratchDir, 'data'),
125
+ // Any session a test composes would otherwise read the real home's
126
+ // AGENTS.md / CLAUDE.md whenever the stored setting is on; tests that
127
+ // exercise the feature clear this and pass their own home.
128
+ MIXDOG_BOOT_INSTRUCTION_FILES: '0',
129
+ };
130
+ }
131
+
132
+ export const DEFAULT_TEST_TIMEOUT_MS = 120_000;
133
+ export const LONG_TEST_TIMEOUT_MS = 600_000;
134
+
86
135
  // A batch that has not exited by then is killed with its process tree and
87
136
  // fails the run; MIXDOG_TEST_BATCH_TIMEOUT_MS overrides the default.
88
137
  const DEFAULT_BATCH_TIMEOUT_MS = 60 * 60 * 1000;
@@ -185,8 +234,11 @@ export function ownedProcesses(snapshot, anchors, { batchStart, scratchDir, plat
185
234
  }
186
235
  }
187
236
  const fold = (path) => (platform === 'win32' ? path.toLowerCase() : path);
188
- const scratchPrefix = fold(join(scratchDir, '/'));
189
- for (const entry of snapshot) if (entry.exe && fold(entry.exe).startsWith(scratchPrefix)) claim(entry);
237
+ // An empty scratchDir skips the sweep: batches running side by side share
238
+ // one scratch root, so one batch's exit must not claim its peers' processes.
239
+ const scratchPrefix = scratchDir ? fold(join(scratchDir, '/')) : null;
240
+ if (scratchPrefix)
241
+ for (const entry of snapshot) if (entry.exe && fold(entry.exe).startsWith(scratchPrefix)) claim(entry);
190
242
  while (queue.length > 0) {
191
243
  const parent = queue.pop();
192
244
  for (const entry of childrenOf.get(parent.pid) ?? []) {
@@ -219,6 +271,10 @@ async function reapLeftovers(child, { batchStart, ledger, scratchDir }) {
219
271
  }
220
272
  }
221
273
  const anchors = [...(child.pid ? [{ pid: child.pid, since: batchStart }] : []), ...(await readSpawnLedger(ledger))];
274
+ await killOwned(anchors, { batchStart, scratchDir });
275
+ }
276
+
277
+ async function killOwned(anchors, { batchStart, scratchDir }) {
222
278
  const leftovers = ownedProcesses(await processSnapshot(), anchors, { batchStart, scratchDir });
223
279
  if (leftovers.length === 0) return;
224
280
  const pids = leftovers.map((entry) => entry.pid);
@@ -237,6 +293,7 @@ async function reapLeftovers(child, { batchStart, ledger, scratchDir }) {
237
293
  }
238
294
 
239
295
  // Resolves { code, hung } once the batch exited and its leftovers are gone.
296
+ // `scratchDir` '' = leave the scratch-root sweep to the caller (side-by-side batches).
240
297
  function spawnBatch(args, env, { files, ledger, scratchDir, timeoutMs }) {
241
298
  return new Promise((resolve) => {
242
299
  const batchStart = Date.now();
@@ -313,10 +370,12 @@ async function readFailureRecords(path) {
313
370
  // flag was passed, does not fail the run; a test that fails every re-run
314
371
  // still exits 1. Every run ends with the failure summary block, so a filtered
315
372
  // log can still name what failed.
373
+ //
374
+ // `testTimeoutMs` is the per-file --test-timeout default (see testTimeoutArg).
316
375
  export async function runNodeTests(
317
376
  nodeArgs,
318
377
  fileArgs,
319
- { argBudget = ARG_BUDGET, coverage = false, rerunFailed = 0 } = {}
378
+ { argBudget = ARG_BUDGET, coverage = false, rerunFailed = 0, testTimeoutMs = DEFAULT_TEST_TIMEOUT_MS } = {}
320
379
  ) {
321
380
  await pruneStaleTestLogs(tmpdir());
322
381
  const logPath = join(await mkdtemp(join(tmpdir(), TEST_LOG_PREFIX)), 'full.log');
@@ -330,9 +389,11 @@ export async function runNodeTests(
330
389
  const scratchDir = await mkdtemp(join(tmpdir(), 'mixdog-test-scratch-'));
331
390
  // The explicit entry also accepts Node options alongside its file paths.
332
391
  const concurrencyArg = testConcurrencyArg([...nodeArgs, ...fileArgs]);
392
+ const timeoutArg = testTimeoutArg([...nodeArgs, ...fileArgs], process.env, testTimeoutMs);
333
393
  const runArgs = [
334
394
  ...nodeArgs,
335
395
  ...(concurrencyArg ? [concurrencyArg] : []),
396
+ ...(timeoutArg ? [timeoutArg] : []),
336
397
  // Node's test runner passes --import on to every test file process.
337
398
  `--import=${SPAWN_LEDGER}`,
338
399
  `--test-reporter=${SUMMARY_REPORTER}`,
@@ -343,8 +404,19 @@ export async function runNodeTests(
343
404
  argCost(process.execPath) +
344
405
  runArgs.reduce((total, arg) => total + argCost(arg), 0) +
345
406
  argCost(`--test-reporter-destination=${logPath}`) +
346
- argCost('.999'); // the per-batch log suffix
407
+ argCost('.999') + // the per-batch log suffix
408
+ argCost('--test-concurrency=999'); // the per-batch share when batches overlap
347
409
  const batches = chunkFileArgs(fileArgs, Math.max(argBudget - fixedCost, 1));
410
+ // Batches run side by side (a sequential second batch idled the machine
411
+ // behind the first one's slowest file), so the run's concurrency is split
412
+ // across them instead of multiplied.
413
+ const overlapped = batches.length > 1;
414
+ const totalConcurrency = overlapped ? concurrencyOf(runArgs, fileArgs) : 0;
415
+ const batchRunArgs = () => {
416
+ if (!overlapped) return runArgs;
417
+ const share = Math.max(1, Math.ceil(totalConcurrency / batches.length));
418
+ return [...withoutConcurrency(runArgs), `--test-concurrency=${share}`];
419
+ };
348
420
  const coverageDir = coverage ? await mkdtemp(join(tmpdir(), 'mixdog-v8-coverage-')) : '';
349
421
  // Node 24's test runner rebuilds each test process's execArgv from its option
350
422
  // table and drops V8 flags on the way, so a heap cap passed to the runner no
@@ -356,13 +428,7 @@ export async function runNodeTests(
356
428
  // already budgeted, and a third reporter would warn about listeners.
357
429
  const childEnv = (recordsPath) => ({
358
430
  ...process.env,
359
- TEMP: scratchDir,
360
- TMP: scratchDir,
361
- TMPDIR: scratchDir,
362
- // A fixture must never write into the signed-in app's data (~/.mixdog):
363
- // schedule/webhook logs, design-library bindings and the like resolve the
364
- // default data dir. The desktop preload already does this per process.
365
- MIXDOG_DATA_DIR: join(scratchDir, 'data'),
431
+ ...testIsolationEnv(scratchDir),
366
432
  // Only the immutable validator binary is shared; fixture data stays private.
367
433
  // A gate supplies one directory across lanes; a standalone run owns its own.
368
434
  MIXDOG_TEST_OOXML_CACHE_DIR: process.env.MIXDOG_TEST_OOXML_CACHE_DIR || join(scratchDir, 'ooxml-cache'),
@@ -370,23 +436,33 @@ export async function runNodeTests(
370
436
  ...(coverageDir ? { NODE_V8_COVERAGE: coverageDir } : {}),
371
437
  ...(nodeOptions ? { NODE_OPTIONS: nodeOptions } : {}),
372
438
  });
373
- const batchOptions = (files, recordsPath) => ({
439
+ const batchOptions = (files, recordsPath, sweepScratch = true) => ({
374
440
  files,
375
441
  ledger: spawnLedgerPath(recordsPath),
376
- scratchDir,
442
+ scratchDir: sweepScratch ? scratchDir : '',
377
443
  timeoutMs: positiveInt(process.env.MIXDOG_TEST_BATCH_TIMEOUT_MS) ?? DEFAULT_BATCH_TIMEOUT_MS,
378
444
  });
379
445
  let status = 0;
380
446
  const failures = [];
381
447
  const hung = [];
382
- for (const [index, batch] of batches.entries()) {
383
- const batchLog = batches.length === 1 ? logPath : `${logPath}.${index + 1}`;
384
- const batchFailures = join(failureDir, `batch-${index + 1}.jsonl`);
385
- const outcome = await spawnBatch(
386
- [...runArgs, `--test-reporter-destination=${batchLog}`, ...batch],
387
- childEnv(batchFailures),
388
- batchOptions(batch, batchFailures)
389
- );
448
+ const batchLogOf = (index) => (batches.length === 1 ? logPath : `${logPath}.${index + 1}`);
449
+ const batchFailuresOf = (index) => join(failureDir, `batch-${index + 1}.jsonl`);
450
+ const outcomes = await Promise.all(
451
+ batches.map((batch, index) =>
452
+ spawnBatch(
453
+ [...batchRunArgs(), `--test-reporter-destination=${batchLogOf(index)}`, ...batch],
454
+ childEnv(batchFailuresOf(index)),
455
+ batchOptions(batch, batchFailuresOf(index), !overlapped)
456
+ )
457
+ )
458
+ );
459
+ // Peers were still running while each batch exited, so the scratch root's
460
+ // own processes (e.g. a postgres a test started) are swept once, here.
461
+ if (overlapped) await killOwned([], { batchStart: Date.now(), scratchDir });
462
+ // Logs and records merge in batch order, whatever order the batches finished.
463
+ for (const [index, outcome] of outcomes.entries()) {
464
+ const batchLog = batchLogOf(index);
465
+ const batchFailures = batchFailuresOf(index);
390
466
  const { code } = outcome;
391
467
  if (outcome.hung) hung.push(outcome.hung);
392
468
  if (batchLog !== logPath) {
@@ -443,5 +519,5 @@ export async function runNodeTests(
443
519
  await rm(coverageDir, { recursive: true, force: true });
444
520
  }
445
521
  // Last line of the run, after every reporter and the coverage report.
446
- process.stdout.write(`\n${formatFailureSummary({ failed, flaky, rerunFailed, hung })}`);
522
+ process.stdout.write(`\n${formatFailureSummary({ failed, flaky, rerunFailed, hung, exitCode: status })}`);
447
523
  }
@@ -45,10 +45,15 @@ export function policyLine(rerunFailed) {
45
45
 
46
46
  // `hung` holds one "hung after N min: <files>" message per batch the runner's
47
47
  // watchdog killed; such a batch fails the run whatever its tests reported.
48
- export function formatFailureSummary({ failed = [], flaky = [], rerunFailed = 0, hung = [] }) {
48
+ // A non-zero `exitCode` with no failed test, flaky test or hung batch behind it
49
+ // (a bad node flag, a crashed runner) is its own failure, never "0 failed".
50
+ export function formatFailureSummary({ failed = [], flaky = [], rerunFailed = 0, hung = [], exitCode = 0 }) {
49
51
  const hungCount = hung.length > 0 ? `, ${hung.length} hung batch(es)` : '';
52
+ const unexplained = exitCode !== 0 && failed.length === 0 && hung.length === 0;
50
53
  const lines = [
51
- `${SUMMARY_TAG} ${failed.length} failed, ${flaky.length} flaky${hungCount} (every failed test is named below)`,
54
+ unexplained
55
+ ? `${SUMMARY_TAG} RUN FAILED: exit code ${exitCode} with no failed test recorded (bad argument or runner error; see output above)`
56
+ : `${SUMMARY_TAG} ${failed.length} failed, ${flaky.length} flaky${hungCount} (every failed test is named below)`,
52
57
  ];
53
58
  for (const message of hung) lines.push(`${SUMMARY_TAG} HUNG ${message}`);
54
59
  for (const record of failed) {
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env node
2
+ // electron-builder writes one latest-mac.yml per macOS build, and each runner
3
+ // builds a single architecture, so each feed lists only its own zip and dmg.
4
+ // electron-updater on macOS always reads the single latest-mac.yml of the
5
+ // latest release and picks the zip whose name matches the machine's
6
+ // architecture, so the release needs one feed that lists both builds.
7
+ //
8
+ // The publish job runs without installing dependencies, so this reads the
9
+ // fixed shape electron-builder emits (top-level scalars plus a `files:` list
10
+ // of `- url:` items) directly and fails on anything else.
11
+
12
+ import { readFileSync, writeFileSync } from 'node:fs';
13
+ import { pathToFileURL } from 'node:url';
14
+
15
+ export const MAC_ARCHES = Object.freeze(['arm64', 'x64']);
16
+ // Legacy top-level path/sha512 (read only by electron-updater 1.x) point at
17
+ // the x64 zip, which also runs on Apple silicon through Rosetta.
18
+ const PRIMARY_ARCH = 'x64';
19
+ const TOP_LEVEL_KEYS = ['version', 'path', 'sha512', 'releaseDate'];
20
+
21
+ function zipArch(url) {
22
+ const match = /^mixdog-desktop-mac-([a-z0-9]+)\.zip$/.exec(url);
23
+ return match ? match[1] : null;
24
+ }
25
+
26
+ /** Parse one electron-builder latest-mac.yml. File items keep their raw lines. */
27
+ export function parseMacFeed(text, label) {
28
+ const top = {};
29
+ const files = [];
30
+ let inFiles = false;
31
+ for (const line of text.split(/\r?\n/)) {
32
+ if (!line.trim()) continue;
33
+ if (/^\S/.test(line)) {
34
+ const match = /^([A-Za-z][A-Za-z0-9]*):(?:\s+(.*))?$/.exec(line);
35
+ if (!match) throw new Error(`${label}: unsupported line: ${line}`);
36
+ inFiles = match[1] === 'files';
37
+ if (!inFiles) top[match[1]] = line;
38
+ } else if (inFiles && /^ {2}- url: \S+$/.test(line)) {
39
+ files.push({ url: line.replace(/^ {2}- url:\s+/, ''), lines: [line] });
40
+ } else if (inFiles && files.length > 0 && /^ {4}[A-Za-z][A-Za-z0-9]*: \S.*$/.test(line)) {
41
+ files[files.length - 1].lines.push(line);
42
+ } else {
43
+ throw new Error(`${label}: unsupported line: ${line}`);
44
+ }
45
+ }
46
+ for (const key of TOP_LEVEL_KEYS) {
47
+ if (!top[key]) throw new Error(`${label}: missing ${key}`);
48
+ }
49
+ const value = (key) => top[key].slice(key.length + 1).trim();
50
+ return { version: value('version'), top, files };
51
+ }
52
+
53
+ /** Merge the per-architecture feeds into one feed listing both zips. */
54
+ export function mergeMacFeeds(inputs, expectedVersion) {
55
+ const feeds = inputs.map(({ label, text }) => {
56
+ const feed = parseMacFeed(text, label);
57
+ if (expectedVersion && feed.version !== expectedVersion) {
58
+ throw new Error(`${label}: version ${feed.version} is not ${expectedVersion}`);
59
+ }
60
+ const zips = feed.files.filter((file) => zipArch(file.url));
61
+ if (zips.length !== 1) throw new Error(`${label}: expected exactly one mac zip, found ${zips.length}`);
62
+ const [zip] = zips;
63
+ for (const field of ['sha512', 'size']) {
64
+ if (!zip.lines.some((line) => line.startsWith(` ${field}: `))) {
65
+ throw new Error(`${label}: ${zip.url} has no ${field}`);
66
+ }
67
+ }
68
+ return { ...feed, arch: zipArch(zip.url) };
69
+ });
70
+ const arches = feeds.map((feed) => feed.arch).sort();
71
+ if (JSON.stringify(arches) !== JSON.stringify(MAC_ARCHES)) {
72
+ throw new Error(`Expected one feed per ${MAC_ARCHES.join(' + ')}, found: ${arches.join(', ')}`);
73
+ }
74
+ if (feeds[0].version !== feeds[1].version) {
75
+ throw new Error(`Feed versions differ: ${feeds[0].version} and ${feeds[1].version}`);
76
+ }
77
+ feeds.sort((a, b) => MAC_ARCHES.indexOf(a.arch) - MAC_ARCHES.indexOf(b.arch));
78
+ const entries = feeds.flatMap((feed) => feed.files);
79
+ const urls = entries.map((file) => file.url);
80
+ if (new Set(urls).size !== urls.length) throw new Error(`Duplicate feed files: ${urls.join(', ')}`);
81
+ const primary = feeds.find((feed) => feed.arch === PRIMARY_ARCH);
82
+ return [
83
+ primary.top.version,
84
+ 'files:',
85
+ ...entries.flatMap((file) => file.lines),
86
+ primary.top.path,
87
+ primary.top.sha512,
88
+ primary.top.releaseDate,
89
+ '',
90
+ ].join('\n');
91
+ }
92
+
93
+ if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
94
+ const args = process.argv.slice(2);
95
+ const option = (name) => args.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
96
+ const out = option('out');
97
+ const inputs = args.filter((arg) => !arg.startsWith('--'));
98
+ if (!out || inputs.length === 0) {
99
+ throw new Error('usage: merge-mac-update-feed.mjs --out=latest-mac.yml [--version=X.Y.Z] <feed.yml>...');
100
+ }
101
+ const merged = mergeMacFeeds(
102
+ inputs.map((path) => ({ label: path, text: readFileSync(path, 'utf8') })),
103
+ option('version')
104
+ );
105
+ writeFileSync(out, merged);
106
+ console.log(`Wrote ${out} from ${inputs.length} feeds.`);
107
+ }