From a8f5372f806663b67b5742372355c9a77cbc4e4b Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Fri, 14 Aug 2026 19:37:09 -0700 Subject: [PATCH] feat(muse): add Muse Code provider integration - Add Muse renderer manifest, icon, controls, and localization - Implement Muse detection, commands, sessions, prompts, and terminal support - Register Muse across provider registries and lifecycle tests - Document Muse adapter capabilities and coverage --- .agents/docs/agent-adapters.md | 25 +- .../components/providers/muse/MuseIcon.tsx | 12 + .../components/providers/muse/index.test.tsx | 67 +++++ .../components/providers/muse/index.tsx | 34 +++ .../components/providers/muse/manifest.ts | 9 + .../providers/providerManifest.test.ts | 1 + src/renderer/locales/de/messages.po | 8 + src/renderer/locales/en/messages.po | 8 + src/renderer/locales/es/messages.po | 8 + src/renderer/locales/fr/messages.po | 8 + src/renderer/locales/ja/messages.po | 8 + src/renderer/locales/ko/messages.po | 8 + src/renderer/locales/pl/messages.po | 8 + src/renderer/locales/pt-BR/messages.po | 8 + src/renderer/locales/ru/messages.po | 8 + src/renderer/locales/tr/messages.po | 8 + src/renderer/locales/uk/messages.po | 8 + src/renderer/locales/vi/messages.po | 8 + src/renderer/locales/zh-CN/messages.po | 8 + .../parts/agentRegistryNative.ts | 15 + src/supervisor/agents/muse/argv.test.ts | 78 +++++ src/supervisor/agents/muse/argv.ts | 66 +++++ src/supervisor/agents/muse/detection.test.ts | 181 ++++++++++++ src/supervisor/agents/muse/detection.ts | 162 +++++++++++ src/supervisor/agents/muse/index.ts | 116 ++++++++ src/supervisor/agents/muse/muse.test.ts | 241 +++++++++++++++ src/supervisor/agents/muse/paths.ts | 45 +++ src/supervisor/agents/muse/prompt.ts | 14 + src/supervisor/agents/muse/sessionFiles.ts | 275 ++++++++++++++++++ src/supervisor/agents/muse/terminal.ts | 65 +++++ .../agents/oneShotCapability.test.ts | 10 +- src/supervisor/agents/registry.test.ts | 3 + src/supervisor/agents/registry.ts | 2 + .../providers-lifecycle.integration.test.ts | 1 + 34 files changed, 1513 insertions(+), 13 deletions(-) create mode 100644 src/renderer/components/providers/muse/MuseIcon.tsx create mode 100644 src/renderer/components/providers/muse/index.test.tsx create mode 100644 src/renderer/components/providers/muse/index.tsx create mode 100644 src/renderer/components/providers/muse/manifest.ts create mode 100644 src/supervisor/agents/muse/argv.test.ts create mode 100644 src/supervisor/agents/muse/argv.ts create mode 100644 src/supervisor/agents/muse/detection.test.ts create mode 100644 src/supervisor/agents/muse/detection.ts create mode 100644 src/supervisor/agents/muse/index.ts create mode 100644 src/supervisor/agents/muse/muse.test.ts create mode 100644 src/supervisor/agents/muse/paths.ts create mode 100644 src/supervisor/agents/muse/prompt.ts create mode 100644 src/supervisor/agents/muse/sessionFiles.ts create mode 100644 src/supervisor/agents/muse/terminal.ts diff --git a/.agents/docs/agent-adapters.md b/.agents/docs/agent-adapters.md index 13e2f6bac..68ba4cfed 100644 --- a/.agents/docs/agent-adapters.md +++ b/.agents/docs/agent-adapters.md @@ -53,18 +53,19 @@ Model/effort lists below are the **statically declared defaults**. Several provi The **Structured Session** column reflects whether the adapter implements `createStructuredSession` (i.e. supports a `"gui"` presentation mode); it is not a model-list default and is authoritative. -| Provider | Models | Efforts | Live Input | Structured Session | -| ------------ | ------------------------------------------------------------------------ | ---------------------------------------- | --------------------- | ---------------------- | -| Claude | opus-4-8, fable-5, opus-4-7, opus-4-6, sonnet, haiku | low, medium, high, xHigh, max, ultracode | terminal | Yes (SDK) | -| Codex | (probed dynamically via app-server) | (probed dynamically) | terminal / GUI server | Yes (stdio app-server) | -| Gemini | (probed dynamically via ACP) | (probed dynamically) | terminal | Yes (ACP) | -| Copilot | (probed via ACP) | (probed via ACP) | terminal | Yes (ACP) | -| Cursor | auto, composer-\*, GPT/Opus/Sonnet variants (probed via `--list-models`) | (embedded in model name) | terminal | Yes (ACP) | -| Grok | grok-build (probed via ACP) | (none) | terminal | Yes (ACP) | -| OpenCode | (probed dynamically via SDK) | (probed dynamically) | terminal / GUI server | Yes (SDK server) | -| Pi | (authenticated models probed via SDK) | off…max, per model | terminal | Yes (native SDK) | -| Antigravity | auto (managed by `agy`) | (none) | terminal | No | -| Command Code | Kimi/Claude/GPT/Gemini/GLM/… (static, `--list-models`) | (none) | terminal | No | +| Provider | Models | Efforts | Live Input | Structured Session | +| ------------ | ------------------------------------------------------------------------ | ---------------------------------------- | --------------------- | ---------------------------------- | +| Claude | opus-4-8, fable-5, opus-4-7, opus-4-6, sonnet, haiku | low, medium, high, xHigh, max, ultracode | terminal | Yes (SDK) | +| Codex | (probed dynamically via app-server) | (probed dynamically) | terminal / GUI server | Yes (stdio app-server) | +| Gemini | (probed dynamically via ACP) | (probed dynamically) | terminal | Yes (ACP) | +| Copilot | (probed via ACP) | (probed via ACP) | terminal | Yes (ACP) | +| Cursor | auto, composer-\*, GPT/Opus/Sonnet variants (probed via `--list-models`) | (embedded in model name) | terminal | Yes (ACP) | +| Grok | grok-build (probed via ACP) | (none) | terminal | Yes (ACP) | +| OpenCode | (probed dynamically via SDK) | (probed dynamically) | terminal / GUI server | Yes (SDK server) | +| Pi | (authenticated models probed via SDK) | off…max, per model | terminal | Yes (native SDK) | +| Antigravity | auto (managed by `agy`) | (none) | terminal | No | +| Command Code | Kimi/Claude/GPT/Gemini/GLM/… (static, `--list-models`) | (none) | terminal | No | +| Muse Code | muse-spark-1.2 / 1.1 / 1.2-contributor | none…ultra | terminal | No (no ACP mode yet; GUI deferred) | ## Adding a New Provider — Full Checklist diff --git a/src/renderer/components/providers/muse/MuseIcon.tsx b/src/renderer/components/providers/muse/MuseIcon.tsx new file mode 100644 index 000000000..166d3e194 --- /dev/null +++ b/src/renderer/components/providers/muse/MuseIcon.tsx @@ -0,0 +1,12 @@ +import { createProviderIcon } from "../common/createProviderIcon"; + +// Meta infinity mark (simple-icons) — a single currentColor path for +// status-tone tinting, matching the brand-glyph convention of other providers. +const MUSE_PATH = + "M6.915 4.03c-1.968 0-3.683 1.28-4.871 3.113C.704 9.208 0 11.883 0 14.449c0 .706.07 1.369.21 1.973a6.624 6.624 0 0 0 .265.86 5.297 5.297 0 0 0 .371.761c.696 1.159 1.818 1.927 3.593 1.927 1.497 0 2.633-.671 3.965-2.444.76-1.012 1.144-1.626 2.663-4.32l.756-1.339.186-.325c.061.1.121.196.183.3l2.152 3.595c.724 1.21 1.665 2.556 2.47 3.314 1.046.987 1.992 1.22 3.06 1.22 1.075 0 1.876-.355 2.455-.843a3.743 3.743 0 0 0 .81-.973c.542-.939.861-2.127.861-3.745 0-2.72-.681-5.357-2.084-7.45-1.282-1.912-2.957-2.93-4.716-2.93-1.047 0-2.088.467-3.053 1.308-.652.57-1.257 1.29-1.82 2.05-.69-.875-1.335-1.547-1.958-2.056-1.182-.966-2.315-1.303-3.454-1.303zm10.16 2.053c1.147 0 2.188.758 2.992 1.999 1.132 1.748 1.647 4.195 1.647 6.4 0 1.548-.368 2.9-1.839 2.9-.58 0-1.027-.23-1.664-1.004-.496-.601-1.343-1.878-2.832-4.358l-.617-1.028a44.908 44.908 0 0 0-1.255-1.98c.07-.109.141-.224.211-.327 1.12-1.667 2.118-2.602 3.358-2.602zm-10.201.553c1.265 0 2.058.791 2.675 1.446.307.327.737.871 1.234 1.579l-1.02 1.566c-.757 1.163-1.882 3.017-2.837 4.338-1.191 1.649-1.81 1.817-2.486 1.817-.524 0-1.038-.237-1.383-.794-.263-.426-.464-1.13-.464-2.046 0-2.221.63-4.535 1.66-6.088.454-.687.964-1.226 1.499-1.56a2.773 2.773 0 0 1 1.122-.258z"; + +export const MuseIcon = createProviderIcon({ + cssPrefix: "poracode-muse-icon", + path: MUSE_PATH, + viewBox: "0 0 24 24", +}); diff --git a/src/renderer/components/providers/muse/index.test.tsx b/src/renderer/components/providers/muse/index.test.tsx new file mode 100644 index 000000000..998ad5d96 --- /dev/null +++ b/src/renderer/components/providers/muse/index.test.tsx @@ -0,0 +1,67 @@ +// @vitest-environment node + +import { describe, expect, it, vi } from "vitest"; +import type { ComposerControl } from "@/renderer/components/thread/ThreadComposer"; +import type { AgentCapability, ThreadConfig } from "@/shared/contracts"; +import { getComposerControls } from "../providerComposer"; +import "./index"; + +const capabilities: AgentCapability = { + models: [ + { id: "muse-spark-1.2", label: "Muse Spark 1.2" }, + { id: "muse-spark-1.1", label: "Muse Spark 1.1" }, + ], + efforts: ["none", "minimal", "low", "medium", "high", "xhigh", "ultra"], + defaultEffort: "high", + modelEfforts: {}, + modes: ["agent"], + approvalPolicies: [ + { id: "untrusted", label: "Untrusted" }, + { id: "on-request", label: "On Request" }, + { id: "never", label: "Never Ask" }, + { id: "yolo", label: "Bypass Approvals" }, + ], + sandboxModes: [], + supportsResume: true, + supportsDirectInput: true, + liveInputMode: "terminal", + presentationMode: "terminal", + presentationModes: ["terminal"], + settingDefs: [], +}; + +function isMenuControl( + control: ComposerControl, +): control is Extract { + return control.kind === undefined || control.kind === "menu"; +} + +function isPermissionMenuControl( + control: ComposerControl, +): control is Extract { + return isMenuControl(control) && control.iconKind === "permission"; +} + +describe("Muse composer controls", () => { + it("exposes Muse approval policies on terminal without a plan/work toggle", () => { + const onConfigChange = vi.fn<(patch: Partial) => void>(); + const controls = getComposerControls("muse")?.({ + capabilities, + config: { model: "muse-spark-1.2", mode: "agent", approvalPolicy: "on-request" }, + isDisabled: false, + onConfigChange, + presentationMode: "terminal", + }); + + expect(controls?.some((c) => "iconKind" in c && c.iconKind === "mode")).toBe(false); + + const permission = controls?.find(isPermissionMenuControl); + expect(permission).toMatchObject({ + value: "on-request", + options: capabilities.approvalPolicies, + }); + + permission?.onChange?.("yolo"); + expect(onConfigChange).toHaveBeenCalledWith({ approvalPolicy: "yolo" }); + }); +}); diff --git a/src/renderer/components/providers/muse/index.tsx b/src/renderer/components/providers/muse/index.tsx new file mode 100644 index 000000000..f502d2013 --- /dev/null +++ b/src/renderer/components/providers/muse/index.tsx @@ -0,0 +1,34 @@ +export * from "./MuseIcon"; + +import { MuseIcon } from "./MuseIcon"; +import providerManifest from "./manifest"; +import { standardPlanApprovalControls } from "../composerControlBuilders"; +import { registerProviderIcon } from "../ProviderIcon"; +import { registerComposerControls } from "../providerComposer"; +import { registerCommitGenDefaults } from "../commitGen"; +import { registerConflictResolverDefaults } from "../conflictResolver"; +import { registerTitleGenDefaults } from "../titleGen"; + +const PROVIDER_KIND = providerManifest.kind; + +registerProviderIcon(PROVIDER_KIND, MuseIcon); + +// Utility defaults for one-shot title/commit generation and full conflict- +// resolution threads. Muse's one-shot path passes the prompt positionally to +// `muse exec` because that command does not consume prompts from stdin. +const MUSE_UTILITY_DEFAULTS = { + label: "Muse Code", + hint: "Muse Spark 1.2", + model: "muse-spark-1.2", + effort: "high", +}; + +registerCommitGenDefaults(PROVIDER_KIND, MUSE_UTILITY_DEFAULTS); +registerTitleGenDefaults(PROVIDER_KIND, MUSE_UTILITY_DEFAULTS); +registerConflictResolverDefaults(PROVIDER_KIND, MUSE_UTILITY_DEFAULTS); + +// Terminal-only provider (no ACP; GUI deferred until Muse ships one) — the +// TUI can prompt for approvals, so it gets the full policy selector. Effort +// selector is driven by capabilities.efforts in the shared model picker — no +// provider-specific registration needed. No plan mode (modes: ["agent"] only). +registerComposerControls(PROVIDER_KIND, (input) => standardPlanApprovalControls(input)); diff --git a/src/renderer/components/providers/muse/manifest.ts b/src/renderer/components/providers/muse/manifest.ts new file mode 100644 index 000000000..f1dc9862c --- /dev/null +++ b/src/renderer/components/providers/muse/manifest.ts @@ -0,0 +1,9 @@ +import { msg } from "@lingui/core/macro"; +import type { RendererProviderManifest } from "../providerManifest"; + +export default { + kind: "muse", + label: msg`Muse Code`, + // Between kimi (45) and antigravity (50). + order: 47, +} satisfies RendererProviderManifest; diff --git a/src/renderer/components/providers/providerManifest.test.ts b/src/renderer/components/providers/providerManifest.test.ts index ba45eb2b9..b634f18cb 100644 --- a/src/renderer/components/providers/providerManifest.test.ts +++ b/src/renderer/components/providers/providerManifest.test.ts @@ -24,6 +24,7 @@ const EXPECTED_PROVIDER_ORDER = [ "qoder", "grok", "kimi", + "muse", "antigravity", "commandcode", "opencode", diff --git a/src/renderer/locales/de/messages.po b/src/renderer/locales/de/messages.po index 3ab513606..8d9f4cc37 100644 --- a/src/renderer/locales/de/messages.po +++ b/src/renderer/locales/de/messages.po @@ -4739,6 +4739,10 @@ msgstr "Erstklassige Grok Build CLI-Integration mit der nativen Laufzeit von Por msgid "First-class Kimi Code CLI integration using Poracode's native runtime." msgstr "Erstklassige Kimi Code CLI-Integration mit der nativen Laufzeit von Poracode." +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "First-class Muse Code CLI integration using Poracode's native terminal runtime." +msgstr "Erstklassige Muse Code CLI-Integration mit Poracodes nativer Terminal-Laufzeit." + #: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "First-class OpenCode integration using Poracode's native SDK runtime." msgstr "Erstklassige OpenCode-Integration mithilfe der nativen SDK-Laufzeitumgebung von Poracode." @@ -6654,6 +6658,10 @@ msgstr "Ihre Änderungen wurden in einen neuen Arbeitsbaum unter „{newBranch} msgid "Moving changes…" msgstr "Änderungen werden verschoben…" +#: src/renderer/components/providers/muse/manifest.ts +msgid "Muse Code" +msgstr "Muse Code" + #: src/renderer/components/mcp/McpServerEditor.tsx msgid "my-mcp-server" msgstr "my-mcp-server" diff --git a/src/renderer/locales/en/messages.po b/src/renderer/locales/en/messages.po index f2400537f..c138b6370 100644 --- a/src/renderer/locales/en/messages.po +++ b/src/renderer/locales/en/messages.po @@ -4744,6 +4744,10 @@ msgstr "First-class Grok Build CLI integration using Poracode's native runtime." msgid "First-class Kimi Code CLI integration using Poracode's native runtime." msgstr "First-class Kimi Code CLI integration using Poracode's native runtime." +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "First-class Muse Code CLI integration using Poracode's native terminal runtime." +msgstr "First-class Muse Code CLI integration using Poracode's native terminal runtime." + #: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "First-class OpenCode integration using Poracode's native SDK runtime." msgstr "First-class OpenCode integration using Poracode's native SDK runtime." @@ -6659,6 +6663,10 @@ msgstr "Moved your changes into a new worktree on \"{newBranch}\". \"{currentBra msgid "Moving changes…" msgstr "Moving changes…" +#: src/renderer/components/providers/muse/manifest.ts +msgid "Muse Code" +msgstr "Muse Code" + #: src/renderer/components/mcp/McpServerEditor.tsx msgid "my-mcp-server" msgstr "my-mcp-server" diff --git a/src/renderer/locales/es/messages.po b/src/renderer/locales/es/messages.po index d51769a57..76fe68199 100644 --- a/src/renderer/locales/es/messages.po +++ b/src/renderer/locales/es/messages.po @@ -4739,6 +4739,10 @@ msgstr "Integración de primera clase de Grok Build CLI usando el runtime nativo msgid "First-class Kimi Code CLI integration using Poracode's native runtime." msgstr "Integración de primera clase de Kimi Code CLI usando el runtime nativo de Poracode." +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "First-class Muse Code CLI integration using Poracode's native terminal runtime." +msgstr "Integración de primera clase de Muse Code CLI usando el runtime de terminal nativo de Poracode." + #: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "First-class OpenCode integration using Poracode's native SDK runtime." msgstr "Integración de primera clase de OpenCode usando el runtime nativo del SDK de Poracode." @@ -6654,6 +6658,10 @@ msgstr "Movió tus cambios a un nuevo worktree en \"{newBranch}\". \"{currentBra msgid "Moving changes…" msgstr "Moviendo cambios…" +#: src/renderer/components/providers/muse/manifest.ts +msgid "Muse Code" +msgstr "Muse Code" + #: src/renderer/components/mcp/McpServerEditor.tsx msgid "my-mcp-server" msgstr "my-mcp-server" diff --git a/src/renderer/locales/fr/messages.po b/src/renderer/locales/fr/messages.po index 315bff414..13944a261 100644 --- a/src/renderer/locales/fr/messages.po +++ b/src/renderer/locales/fr/messages.po @@ -4739,6 +4739,10 @@ msgstr "Intégration CLI Grok Build de première classe à l'aide du runtime nat msgid "First-class Kimi Code CLI integration using Poracode's native runtime." msgstr "Intégration CLI Kimi Code de première classe à l'aide du runtime natif de Poracode." +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "First-class Muse Code CLI integration using Poracode's native terminal runtime." +msgstr "Intégration CLI Muse Code de première classe à l'aide du runtime de terminal natif de Poracode." + #: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "First-class OpenCode integration using Poracode's native SDK runtime." msgstr "Intégration OpenCode de première classe à l'aide du runtime SDK natif de Poracode." @@ -6653,6 +6657,10 @@ msgstr "Vos modifications ont été déplacées vers un nouveau worktree sur \"{ msgid "Moving changes…" msgstr "Déplacement des modifications…" +#: src/renderer/components/providers/muse/manifest.ts +msgid "Muse Code" +msgstr "Muse Code" + #: src/renderer/components/mcp/McpServerEditor.tsx msgid "my-mcp-server" msgstr "my-mcp-server" diff --git a/src/renderer/locales/ja/messages.po b/src/renderer/locales/ja/messages.po index 00eb7067b..d87268878 100644 --- a/src/renderer/locales/ja/messages.po +++ b/src/renderer/locales/ja/messages.po @@ -4738,6 +4738,10 @@ msgstr "Poracode のネイティブ ランタイムを使用したファース msgid "First-class Kimi Code CLI integration using Poracode's native runtime." msgstr "Poracode のネイティブ ランタイムを使用したファーストクラスの Kimi Code CLI 統合。" +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "First-class Muse Code CLI integration using Poracode's native terminal runtime." +msgstr "Poracode のネイティブなターミナル ランタイムを使用したファーストクラスの Muse Code CLI 統合。" + #: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "First-class OpenCode integration using Poracode's native SDK runtime." msgstr "Poracode のネイティブ SDK ランタイムを使用したファーストクラスの OpenCode 統合。" @@ -6652,6 +6656,10 @@ msgstr "変更内容を「{newBranch}」の新しいワークツリーに移動 msgid "Moving changes…" msgstr "変更を移動中…" +#: src/renderer/components/providers/muse/manifest.ts +msgid "Muse Code" +msgstr "Muse Code" + #: src/renderer/components/mcp/McpServerEditor.tsx msgid "my-mcp-server" msgstr "my-mcp-server" diff --git a/src/renderer/locales/ko/messages.po b/src/renderer/locales/ko/messages.po index 7f4df250b..60b6df2c9 100644 --- a/src/renderer/locales/ko/messages.po +++ b/src/renderer/locales/ko/messages.po @@ -4739,6 +4739,10 @@ msgstr "Poracode의 기본 런타임을 사용하는 최고 수준의 Grok Build msgid "First-class Kimi Code CLI integration using Poracode's native runtime." msgstr "Poracode의 기본 런타임을 사용하는 최고 수준의 Kimi Code CLI 통합." +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "First-class Muse Code CLI integration using Poracode's native terminal runtime." +msgstr "Poracode의 기본 터미널 런타임을 사용하는 최고 수준의 Muse Code CLI 통합." + #: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "First-class OpenCode integration using Poracode's native SDK runtime." msgstr "Poracode의 기본 SDK 런타임을 사용한 최고 수준의 OpenCode 통합." @@ -6654,6 +6658,10 @@ msgstr "변경 사항을 \"{newBranch}\"의 새 작업 트리로 옮겼습니다 msgid "Moving changes…" msgstr "변경사항 이동 중…" +#: src/renderer/components/providers/muse/manifest.ts +msgid "Muse Code" +msgstr "Muse Code" + #: src/renderer/components/mcp/McpServerEditor.tsx msgid "my-mcp-server" msgstr "my-mcp-server" diff --git a/src/renderer/locales/pl/messages.po b/src/renderer/locales/pl/messages.po index 8a86d4fac..8f42edf8a 100644 --- a/src/renderer/locales/pl/messages.po +++ b/src/renderer/locales/pl/messages.po @@ -4739,6 +4739,10 @@ msgstr "Pierwszorzędna integracja Grok Build CLI przy użyciu natywnego środow msgid "First-class Kimi Code CLI integration using Poracode's native runtime." msgstr "Pierwszorzędna integracja Kimi Code CLI przy użyciu natywnego środowiska wykonawczego Poracode." +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "First-class Muse Code CLI integration using Poracode's native terminal runtime." +msgstr "Pierwszorzędna integracja Muse Code CLI przy użyciu natywnego środowiska wykonawczego terminala Poracode." + #: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "First-class OpenCode integration using Poracode's native SDK runtime." msgstr "Pierwszorzędna integracja OpenCode z wykorzystaniem natywnego środowiska wykonawczego SDK Poracode." @@ -6654,6 +6658,10 @@ msgstr "Przeniesiono zmiany do nowego drzewa roboczego na „{newBranch}”. „ msgid "Moving changes…" msgstr "Przenoszenie zmian…" +#: src/renderer/components/providers/muse/manifest.ts +msgid "Muse Code" +msgstr "Muse Code" + #: src/renderer/components/mcp/McpServerEditor.tsx msgid "my-mcp-server" msgstr "my-mcp-server" diff --git a/src/renderer/locales/pt-BR/messages.po b/src/renderer/locales/pt-BR/messages.po index 11ce7d1db..0bdbde19a 100644 --- a/src/renderer/locales/pt-BR/messages.po +++ b/src/renderer/locales/pt-BR/messages.po @@ -4739,6 +4739,10 @@ msgstr "Integração Grok Build CLI de primeira classe usando o tempo de execuç msgid "First-class Kimi Code CLI integration using Poracode's native runtime." msgstr "Integração Kimi Code CLI de primeira classe usando o tempo de execução nativo do Poracode." +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "First-class Muse Code CLI integration using Poracode's native terminal runtime." +msgstr "Integração Muse Code CLI de primeira classe usando o tempo de execução de terminal nativo do Poracode." + #: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "First-class OpenCode integration using Poracode's native SDK runtime." msgstr "Integração OpenCode de primeira classe usando o tempo de execução SDK nativo do Poracode." @@ -6654,6 +6658,10 @@ msgstr "Movemos suas alterações para uma nova árvore de trabalho em \"{newBra msgid "Moving changes…" msgstr "Movendo mudanças…" +#: src/renderer/components/providers/muse/manifest.ts +msgid "Muse Code" +msgstr "Muse Code" + #: src/renderer/components/mcp/McpServerEditor.tsx msgid "my-mcp-server" msgstr "my-mcp-server" diff --git a/src/renderer/locales/ru/messages.po b/src/renderer/locales/ru/messages.po index ab48a676a..5796bd4ee 100644 --- a/src/renderer/locales/ru/messages.po +++ b/src/renderer/locales/ru/messages.po @@ -4739,6 +4739,10 @@ msgstr "Первоклассная интеграция Grok Build CLI с исп msgid "First-class Kimi Code CLI integration using Poracode's native runtime." msgstr "Первоклассная интеграция Kimi Code CLI с использованием нативной среды выполнения Poracode." +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "First-class Muse Code CLI integration using Poracode's native terminal runtime." +msgstr "Первоклассная интеграция Muse Code CLI с использованием нативной среды выполнения терминала Poracode." + #: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "First-class OpenCode integration using Poracode's native SDK runtime." msgstr "Первоклассная интеграция OpenCode с использованием нативной среды выполнения SDK Poracode." @@ -6654,6 +6658,10 @@ msgstr "Ваши изменения перемещены в новый worktree msgid "Moving changes…" msgstr "Перемещение изменений…" +#: src/renderer/components/providers/muse/manifest.ts +msgid "Muse Code" +msgstr "Muse Code" + #: src/renderer/components/mcp/McpServerEditor.tsx msgid "my-mcp-server" msgstr "my-mcp-server" diff --git a/src/renderer/locales/tr/messages.po b/src/renderer/locales/tr/messages.po index f170500aa..926124758 100644 --- a/src/renderer/locales/tr/messages.po +++ b/src/renderer/locales/tr/messages.po @@ -4739,6 +4739,10 @@ msgstr "Poracode'un yerel çalışma zamanını kullanan birinci sınıf Grok Bu msgid "First-class Kimi Code CLI integration using Poracode's native runtime." msgstr "Poracode'un yerel çalışma zamanını kullanan birinci sınıf Kimi Code CLI entegrasyonu." +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "First-class Muse Code CLI integration using Poracode's native terminal runtime." +msgstr "Poracode'un yerel terminal çalışma zamanını kullanan birinci sınıf Muse Code CLI entegrasyonu." + #: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "First-class OpenCode integration using Poracode's native SDK runtime." msgstr "Poracode'un yerel SDK çalışma zamanını kullanan birinci sınıf OpenCode entegrasyonu." @@ -6654,6 +6658,10 @@ msgstr "Değişiklikleriniz \"{newBranch}\" üzerindeki yeni bir çalışma ağa msgid "Moving changes…" msgstr "Değişiklikler taşınıyor…" +#: src/renderer/components/providers/muse/manifest.ts +msgid "Muse Code" +msgstr "Muse Code" + #: src/renderer/components/mcp/McpServerEditor.tsx msgid "my-mcp-server" msgstr "my-mcp-server" diff --git a/src/renderer/locales/uk/messages.po b/src/renderer/locales/uk/messages.po index b48a21389..836c1a287 100644 --- a/src/renderer/locales/uk/messages.po +++ b/src/renderer/locales/uk/messages.po @@ -4739,6 +4739,10 @@ msgstr "Першокласна інтеграція Grok Build CLI з викор msgid "First-class Kimi Code CLI integration using Poracode's native runtime." msgstr "Першокласна інтеграція Kimi Code CLI з використанням нативного середовища виконання Poracode." +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "First-class Muse Code CLI integration using Poracode's native terminal runtime." +msgstr "Першокласна інтеграція Muse Code CLI з використанням нативного середовища виконання термінала Poracode." + #: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "First-class OpenCode integration using Poracode's native SDK runtime." msgstr "Першокласна інтеграція OpenCode з використанням нативного середовища виконання SDK Poracode." @@ -6654,6 +6658,10 @@ msgstr "Ваші зміни переміщено в новий worktree на \"{ msgid "Moving changes…" msgstr "Переміщення змін…" +#: src/renderer/components/providers/muse/manifest.ts +msgid "Muse Code" +msgstr "Muse Code" + #: src/renderer/components/mcp/McpServerEditor.tsx msgid "my-mcp-server" msgstr "my-mcp-server" diff --git a/src/renderer/locales/vi/messages.po b/src/renderer/locales/vi/messages.po index 12a25c2bf..77b002b08 100644 --- a/src/renderer/locales/vi/messages.po +++ b/src/renderer/locales/vi/messages.po @@ -4739,6 +4739,10 @@ msgstr "Tích hợp Grok Build CLI hạng nhất bằng cách sử dụng thời msgid "First-class Kimi Code CLI integration using Poracode's native runtime." msgstr "Tích hợp Kimi Code CLI hạng nhất bằng cách sử dụng thời gian chạy gốc của Poracode." +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "First-class Muse Code CLI integration using Poracode's native terminal runtime." +msgstr "Tích hợp Muse Code CLI hạng nhất bằng cách sử dụng thời gian chạy terminal gốc của Poracode." + #: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "First-class OpenCode integration using Poracode's native SDK runtime." msgstr "Tích hợp OpenCode hạng nhất bằng thời gian chạy SDK gốc của Poracode." @@ -6654,6 +6658,10 @@ msgstr "Đã chuyển các thay đổi của bạn sang một cây làm việc m msgid "Moving changes…" msgstr "Đang di chuyển các thay đổi…" +#: src/renderer/components/providers/muse/manifest.ts +msgid "Muse Code" +msgstr "Muse Code" + #: src/renderer/components/mcp/McpServerEditor.tsx msgid "my-mcp-server" msgstr "my-mcp-server" diff --git a/src/renderer/locales/zh-CN/messages.po b/src/renderer/locales/zh-CN/messages.po index 4e730fc99..7eb2ad80b 100644 --- a/src/renderer/locales/zh-CN/messages.po +++ b/src/renderer/locales/zh-CN/messages.po @@ -4739,6 +4739,10 @@ msgstr "使用 Poracode 的本机运行时进行一流的 Grok Build CLI 集成 msgid "First-class Kimi Code CLI integration using Poracode's native runtime." msgstr "使用 Poracode 的本机运行时进行一流的 Kimi Code CLI 集成。" +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "First-class Muse Code CLI integration using Poracode's native terminal runtime." +msgstr "使用 Poracode 的本机终端运行时进行一流的 Muse Code CLI 集成。" + #: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "First-class OpenCode integration using Poracode's native SDK runtime." msgstr "使用 Poracode 的本机 SDK 运行时进行一流的 OpenCode 集成。" @@ -6653,6 +6657,10 @@ msgstr "已将您的更改移至“{newBranch}”上的新工作树中。“{cur msgid "Moving changes…" msgstr "正在移动更改…" +#: src/renderer/components/providers/muse/manifest.ts +msgid "Muse Code" +msgstr "Muse Code" + #: src/renderer/components/mcp/McpServerEditor.tsx msgid "my-mcp-server" msgstr "my-mcp-server" diff --git a/src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts b/src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts index 63a14dd04..9e9372e45 100644 --- a/src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +++ b/src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts @@ -225,6 +225,21 @@ export const NATIVE_AGENT_REGISTRY_ENTRIES: NativeAgentRegistryEntry[] = [ "if (Get-Command irm -ErrorAction SilentlyContinue) { irm https://code.kimi.com/kimi-code/install.ps1 | iex } else { Write-Host 'No supported installer found. Install PowerShell Invoke-RestMethod first, then refresh detected agents.' }", ), }, + { + id: "muse", + description: msg`First-class Muse Code CLI integration using Poracode's native terminal runtime.`, + docsUrl: "https://dev.meta.ai/docs/muse-code", + // Muse Code has no native Windows build. WSL projects get the posix curl + // installer (run inside the distro); a native Windows project gets a clear + // unsupported message pointing users at WSL or macOS/Linux. + installCommand: (project) => + posixOrWindows( + project, + "if command -v curl >/dev/null 2>&1; then curl -fsSL https://dev.meta.ai/install.sh | sh; " + + "else printf 'curl is required to install Muse Code. Install curl, then refresh detected agents.\\n'; fi", + "Write-Host 'Muse Code is not available on native Windows. Open a WSL project and install with: curl -fsSL https://dev.meta.ai/install.sh | sh'", + ), + }, { id: "factory", acpRegistryAliases: [{ id: "factory-droid", nativeSupport: true }], diff --git a/src/supervisor/agents/muse/argv.test.ts b/src/supervisor/agents/muse/argv.test.ts new file mode 100644 index 000000000..45b053431 --- /dev/null +++ b/src/supervisor/agents/muse/argv.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { buildMuseArgs, buildMuseConfigFlags, buildMuseResumeArgs } from "./argv"; + +describe("buildMuseConfigFlags", () => { + it("always includes --trust-workspace", () => { + expect(buildMuseConfigFlags({} as any)).toEqual(["--trust-workspace"]); + }); + + it("maps model and effort", () => { + expect(buildMuseConfigFlags({ model: "muse-spark-1.2", effort: "xhigh" } as any)).toEqual([ + "--trust-workspace", + "--model", + "muse-spark-1.2", + "--reasoning-effort", + "xhigh", + ]); + }); + + it("maps untrusted / on-request / never approval modes", () => { + for (const policy of ["untrusted", "on-request", "never"] as const) { + const args = buildMuseConfigFlags({ approvalPolicy: policy } as any); + expect(args).toEqual(["--trust-workspace", "--approval-mode", policy]); + expect(args).not.toContain("--yolo"); + } + }); + + it("maps yolo / bypassPermissions to --yolo", () => { + for (const policy of ["yolo", "bypassPermissions"] as const) { + const args = buildMuseConfigFlags({ approvalPolicy: policy } as any); + expect(args).toContain("--yolo"); + expect(args).not.toContain("--approval-mode"); + } + }); +}); + +describe("buildMuseArgs", () => { + it("appends a non-empty prompt as a trailing positional", () => { + expect(buildMuseArgs({ model: "muse-spark-1.1" } as any, "hello")).toEqual([ + "--trust-workspace", + "--model", + "muse-spark-1.1", + "hello", + ]); + }); + + it("omits an empty / whitespace prompt", () => { + expect(buildMuseArgs({} as any, " ")).toEqual(["--trust-workspace"]); + expect(buildMuseArgs({} as any)).toEqual(["--trust-workspace"]); + }); +}); + +describe("buildMuseResumeArgs", () => { + it("emits resume plus config flags", () => { + expect( + buildMuseResumeArgs("966713f1-794f-480e-aa37-713e8387fe8e", { + model: "muse-spark-1.2", + effort: "high", + approvalPolicy: "on-request", + } as any), + ).toEqual([ + "resume", + "966713f1-794f-480e-aa37-713e8387fe8e", + "--trust-workspace", + "--model", + "muse-spark-1.2", + "--reasoning-effort", + "high", + "--approval-mode", + "on-request", + ]); + }); + + it("maps bypass to --yolo on resume", () => { + const args = buildMuseResumeArgs("sess", { approvalPolicy: "yolo" } as any); + expect(args.slice(0, 2)).toEqual(["resume", "sess"]); + expect(args).toContain("--yolo"); + }); +}); diff --git a/src/supervisor/agents/muse/argv.ts b/src/supervisor/agents/muse/argv.ts new file mode 100644 index 000000000..7dd26eddd --- /dev/null +++ b/src/supervisor/agents/muse/argv.ts @@ -0,0 +1,66 @@ +import type { ThreadConfig } from "@/shared/contracts"; + +/** + * Flag references — verified against Muse Code 0.1.0 (0.1.0-R708.1): + * + * Interactive TUI: `muse [OPTIONS] [PROMPT]` + * • `--model ` + * • `--reasoning-effort none|minimal|low|medium|high|xhigh|ultra` (default high) + * • `--approval-mode untrusted|on-request|never` (default on-request) + * • `--yolo` — disable approval + sandbox and trust the workspace for this run + * • `--trust-workspace` — trust workspace for this run (skills/rules); no persist + * + * Resume: `muse resume ` (root options may appear on either side + * of `resume`). There is no interactive `--session-id` flag. + * + * Headless `muse exec` is intentionally not used (prompt must be an argv + * argument or a regular `--prompt-file`; stdin piping fails), so there is no + * one-shot support and no GUI structured session — Muse is terminal-only + * until it ships a real ACP mode. + */ + +const MUSE_APPROVAL_MODES = new Set(["untrusted", "on-request", "never"]); + +/** + * Model / effort / approval flags for the interactive TUI. Always includes + * `--trust-workspace` so a PTY never blocks on Muse's trust prompt. + */ +export function buildMuseConfigFlags(config: ThreadConfig): string[] { + const args: string[] = ["--trust-workspace"]; + + if (config.model) { + args.push("--model", config.model); + } + if (config.effort) { + args.push("--reasoning-effort", config.effort); + } + + const policy = config.approvalPolicy; + if (policy === "bypassPermissions" || policy === "yolo") { + args.push("--yolo"); + } else if (policy && MUSE_APPROVAL_MODES.has(policy)) { + args.push("--approval-mode", policy); + } + + return args; +} + +/** + * Argv for interactive `muse` (TUI / PTY). Optional trailing prompt is the + * CLI's documented positional launch prompt. + */ +export function buildMuseArgs(config: ThreadConfig, prompt?: string): string[] { + const args = buildMuseConfigFlags(config); + if (prompt && prompt.trim().length > 0) { + args.push(prompt); + } + return args; +} + +/** + * Argv for `muse resume ` (config flags after the subcommand so + * they stay with the resumed session). + */ +export function buildMuseResumeArgs(sessionRef: string, config: ThreadConfig): string[] { + return ["resume", sessionRef, ...buildMuseConfigFlags(config)]; +} diff --git a/src/supervisor/agents/muse/detection.test.ts b/src/supervisor/agents/muse/detection.test.ts new file mode 100644 index 000000000..b021eb1ce --- /dev/null +++ b/src/supervisor/agents/muse/detection.test.ts @@ -0,0 +1,181 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { ProjectLocation } from "@/shared/contracts"; +import { + MUSE_DEFAULT_MODEL_ID, + museAuthJsonIsAuthenticated, + museDefaultCapabilities, + museDetectionSpec, + museHasStoredCredentials, +} from "./detection"; + +describe("museAuthJsonIsAuthenticated", () => { + it("returns true when providers is a non-empty object", () => { + expect( + museAuthJsonIsAuthenticated( + JSON.stringify({ schema_version: 1, providers: { meta: { api_key: "secret" } } }), + ), + ).toBe(true); + }); + + it("returns false when providers is empty or missing", () => { + expect(museAuthJsonIsAuthenticated(JSON.stringify({ schema_version: 1, providers: {} }))).toBe( + false, + ); + expect(museAuthJsonIsAuthenticated(JSON.stringify({ schema_version: 1 }))).toBe(false); + expect(museAuthJsonIsAuthenticated(undefined)).toBe(false); + expect(museAuthJsonIsAuthenticated("")).toBe(false); + expect(museAuthJsonIsAuthenticated("not-json")).toBe(false); + }); + + it("never depends on config-dir existence alone", () => { + // An empty providers map is the never-signed-in shape after first run. + expect(museAuthJsonIsAuthenticated('{"schema_version":1,"providers":{}}')).toBe(false); + }); +}); + +describe("museHasStoredCredentials (temp dir, never touches ~/.config/muse)", () => { + let configRoot: string; + let previousXdg: string | undefined; + let previousAuthPath: string | undefined; + const location = { kind: "posix", path: "/tmp/muse-proj" } as ProjectLocation; + + beforeEach(() => { + configRoot = mkdtempSync(join(tmpdir(), "muse-config-")); + previousXdg = process.env["XDG_CONFIG_HOME"]; + previousAuthPath = process.env["MUSE_AUTH_PATH"]; + delete process.env["MUSE_AUTH_PATH"]; + process.env["XDG_CONFIG_HOME"] = configRoot; + }); + + afterEach(() => { + if (previousXdg === undefined) delete process.env["XDG_CONFIG_HOME"]; + else process.env["XDG_CONFIG_HOME"] = previousXdg; + if (previousAuthPath === undefined) delete process.env["MUSE_AUTH_PATH"]; + else process.env["MUSE_AUTH_PATH"] = previousAuthPath; + rmSync(configRoot, { recursive: true, force: true }); + }); + + it("reports missing when auth.json is absent", () => { + expect(museHasStoredCredentials(location)).toBe(false); + }); + + it("reports missing when providers is empty", () => { + mkdirSync(join(configRoot, "muse"), { recursive: true }); + writeFileSync( + join(configRoot, "muse", "auth.json"), + JSON.stringify({ schema_version: 1, providers: {} }), + ); + expect(museHasStoredCredentials(location)).toBe(false); + }); + + it("reports authenticated when providers has an entry (key names only)", () => { + mkdirSync(join(configRoot, "muse"), { recursive: true }); + writeFileSync( + join(configRoot, "muse", "auth.json"), + JSON.stringify({ + schema_version: 1, + providers: { meta: { obtained_via: "login" } }, + }), + ); + expect(museHasStoredCredentials(location)).toBe(true); + }); + + it("honors the launcher's MUSE_AUTH_PATH override", () => { + const authPath = join(configRoot, "custom", "credentials.json"); + mkdirSync(join(configRoot, "custom"), { recursive: true }); + writeFileSync(authPath, JSON.stringify({ schema_version: 1, providers: { meta: {} } })); + process.env["MUSE_AUTH_PATH"] = authPath; + + expect(museHasStoredCredentials(location)).toBe(true); + }); +}); + +describe("museDetectionSpec", () => { + it("declares identity, login, and version probe", () => { + expect(museDetectionSpec.kind).toBe("muse"); + expect(museDetectionSpec.label).toBe("Muse Code"); + expect(museDetectionSpec.binary).toBe("muse"); + expect(museDetectionSpec.loginCommand).toBe("muse login"); + expect(museDetectionSpec.versionArgs).toEqual(["--version"]); + expect(museDetectionSpec.probeEnv).toEqual({ MUSE_NO_AUTO_UPDATE: "1" }); + }); + + it("ships installer-only update (no npm, no builtIn)", () => { + expect(museDetectionSpec.update?.npm).toBeUndefined(); + expect(museDetectionSpec.update?.builtIn).toBeUndefined(); + expect(museDetectionSpec.update?.installer?.posix).toEqual({ + binary: "sh", + args: ["-c", "curl -fsSL https://dev.meta.ai/install.sh | sh"], + }); + expect(museDetectionSpec.update?.installer?.windows?.binary).toBe("powershell.exe"); + }); + + it("advertises a terminal login method via capabilitiesProbe", async () => { + expect(typeof museDetectionSpec.capabilitiesProbe).toBe("function"); + const result = await museDetectionSpec.capabilitiesProbe?.({ + location: { kind: "posix", path: "/tmp" }, + executablePath: "/usr/bin/muse", + }); + expect(result?.authMethods).toEqual([ + { id: "muse-terminal-login", name: "Login", type: "terminal" }, + ]); + expect( + await museDetectionSpec.capabilitiesProbe?.({ + location: { kind: "posix", path: "/tmp" }, + executablePath: undefined, + }), + ).toBeUndefined(); + }); + + it("registers auth probes for META_API_KEY and stored credentials", () => { + expect(museDetectionSpec.authProbes).toHaveLength(2); + }); +}); + +describe("museDefaultCapabilities", () => { + it("lists the static Muse models with 1.2 as default-first", () => { + expect(museDefaultCapabilities.models[0]?.id).toBe(MUSE_DEFAULT_MODEL_ID); + expect(museDefaultCapabilities.models.map((m) => m.id)).toEqual([ + "muse-spark-1.2", + "muse-spark-1.2-contributor", + "muse-spark-1.1", + ]); + }); + + it("declares the full effort ladder with default high", () => { + expect(museDefaultCapabilities.efforts).toEqual([ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "ultra", + ]); + expect(museDefaultCapabilities.defaultEffort).toBe("high"); + }); + + it("maps approval policies and a yolo bypass posture", () => { + expect(museDefaultCapabilities.approvalPolicies.map((p) => p.id)).toEqual([ + "untrusted", + "on-request", + "never", + "yolo", + ]); + expect(museDefaultCapabilities.defaultApprovalPolicy).toBe("on-request"); + expect(museDefaultCapabilities.bypassPermissions).toEqual({ approvalPolicy: "yolo" }); + }); + + it("advertises terminal-only with resume, direct input, and exec one-shots", () => { + expect(museDefaultCapabilities.presentationModes).toEqual(["terminal"]); + expect(museDefaultCapabilities.presentationMode).toBe("terminal"); + expect(museDefaultCapabilities.liveInputMode).toBe("terminal"); + expect(museDefaultCapabilities.supportsResume).toBe(true); + expect(museDefaultCapabilities.supportsDirectInput).toBe(true); + expect(museDefaultCapabilities.supportsOneShot).toBe(true); + expect(museDefaultCapabilities.modes).toEqual(["agent"]); + }); +}); diff --git a/src/supervisor/agents/muse/detection.ts b/src/supervisor/agents/muse/detection.ts new file mode 100644 index 000000000..7d07ec07a --- /dev/null +++ b/src/supervisor/agents/muse/detection.ts @@ -0,0 +1,162 @@ +import { existsSync, readFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import type { AgentCapability, AgentTerminalAuthMethod, ProjectLocation } from "@/shared/contracts"; +import { + batchWslCommandsAsync, + envVarAuthProbe, + type AuthProbe, + type DetectionSpec, +} from "../base"; +import { buildContextSizeCapabilities } from "../contextWindowLabel"; +import { nativeMuseAuthPath, WSL_MUSE_AUTH_PATH } from "./paths"; + +// Models are static — Muse has no `list-models` command. All three ship with a +// 1M context window (verified against Muse Code 0.1.0 docs/binary). +export const MUSE_DEFAULT_MODEL_ID = "muse-spark-1.2"; + +const MUSE_MODEL_IDS = [ + MUSE_DEFAULT_MODEL_ID, + "muse-spark-1.2-contributor", + "muse-spark-1.1", +] as const; + +const MUSE_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh", "ultra"] as const; + +// Muse approval modes: untrusted | on-request | never (CLI default on-request). +// `--yolo` is the true full bypass (approval + sandbox + trust) and is the +// bypassPermissions target. +const MUSE_APPROVAL_POLICIES = [ + { id: "untrusted", label: "Untrusted" }, + { id: "on-request", label: "On Request" }, + { id: "never", label: "Never Ask" }, + { id: "yolo", label: "Bypass Approvals" }, +] as const; + +const contextCaps = buildContextSizeCapabilities( + new Map(MUSE_MODEL_IDS.map((id) => [id, 1_000_000])), +); + +export const museDefaultCapabilities: AgentCapability = { + models: [ + { id: MUSE_DEFAULT_MODEL_ID, label: "Muse Spark 1.2" }, + { id: "muse-spark-1.2-contributor", label: "Muse Spark 1.2 Contributor" }, + { id: "muse-spark-1.1", label: "Muse Spark 1.1" }, + ], + efforts: [...MUSE_EFFORTS], + defaultEffort: "high", + modelEfforts: Object.fromEntries(MUSE_MODEL_IDS.map((id) => [id, [...MUSE_EFFORTS]])), + modes: ["agent"], + approvalPolicies: [...MUSE_APPROVAL_POLICIES], + sandboxModes: [], + supportsResume: true, + supportsOneShot: true, + supportsDirectInput: true, + liveInputMode: "terminal", + presentationMode: "terminal", + // Although `muse exec --json` exists, it has no headless approval wire. + // Keep Muse terminal-only until it ships a real structured/ACP mode. + presentationModes: ["terminal"], + defaultApprovalPolicy: "on-request", + bypassPermissions: { approvalPolicy: "yolo" }, + mcpScope: { terminal: "none", gui: "none" }, + settingDefs: [], + ...contextCaps, +}; + +/** + * True when `auth.json` has a non-empty `providers` object. Key *names* only — + * never inspect credential values. Split out so unit tests can cover the rule + * without touching a real `~/.config/muse`. + */ +export function museAuthJsonIsAuthenticated(raw: string | undefined): boolean { + if (!raw) return false; + try { + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return false; + const providers = (parsed as { providers?: unknown }).providers; + if (!providers || typeof providers !== "object" || Array.isArray(providers)) return false; + return Object.keys(providers as Record).length > 0; + } catch { + return false; + } +} + +/** Native (non-WSL) credential check against the resolved auth.json path. */ +export function museHasStoredCredentials(location: ProjectLocation): boolean { + if (location.kind === "wsl") return false; + const authFile = nativeMuseAuthPath(); + if (!existsSync(authFile)) return false; + try { + return museAuthJsonIsAuthenticated(readFileSync(authFile, "utf8")); + } catch { + return false; + } +} + +const storedCredentialsAuthProbe: AuthProbe = async (ctx) => { + if (ctx.location.kind === "wsl") { + const [result] = await batchWslCommandsAsync(ctx.location.distro, [ + `cat "${WSL_MUSE_AUTH_PATH}" 2>/dev/null || true`, + ]); + return museAuthJsonIsAuthenticated(result?.ok ? result.stdout : undefined) + ? "authenticated" + : "missing"; + } + try { + const raw = await readFile(nativeMuseAuthPath(), "utf8"); + return museAuthJsonIsAuthenticated(raw) ? "authenticated" : "missing"; + } catch { + return "missing"; + } +}; + +// Muse authenticates via `muse login` (browser code approval / Meta account). +// No ACP probe — synthesize a terminal auth method so Settings shows Login. +const MUSE_TERMINAL_AUTH: AgentTerminalAuthMethod = { + id: "muse-terminal-login", + name: "Login", + type: "terminal", +}; + +export const museDetectionSpec: DetectionSpec = { + kind: "muse", + label: "Muse Code", + binary: "muse", + loginCommand: "muse login", + capabilities: museDefaultCapabilities, + versionArgs: ["--version"], + // The installed `muse` command is a launcher that otherwise checks for and + // starts a background update. Detection must stay read-only and predictable; + // explicit updates still use the installer spec below. + probeEnv: { MUSE_NO_AUTO_UPDATE: "1" }, + // META_API_KEY takes priority over stored credentials at the CLI; treat either + // as signed-in. The file probe keys off a non-empty `providers` object, not + // mere config-dir existence (the dir appears on first run regardless). + authProbes: [envVarAuthProbe(["META_API_KEY"]), storedCredentialsAuthProbe], + async capabilitiesProbe(ctx) { + if (!ctx.executablePath) return undefined; + return { authMethods: [MUSE_TERMINAL_AUTH] }; + }, + // Muse ships via Meta's installer script only — no npm package, no + // `muse update` / self-updater. Re-run the official install script for + // updates. Windows has no Muse build; the windows installer entry surfaces a + // clear message (schema requires both platforms when `installer` is set). + update: { + installer: { + posix: { + binary: "sh", + args: ["-c", "curl -fsSL https://dev.meta.ai/install.sh | sh"], + }, + windows: { + binary: "powershell.exe", + args: [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + "Write-Host 'Muse Code is not available on Windows. Install it inside WSL or on macOS/Linux.'", + ], + }, + }, + }, +}; diff --git a/src/supervisor/agents/muse/index.ts b/src/supervisor/agents/muse/index.ts new file mode 100644 index 000000000..3a210c695 --- /dev/null +++ b/src/supervisor/agents/muse/index.ts @@ -0,0 +1,116 @@ +import type { AgentCapability } from "@/shared/contracts"; +import { detectAgentInstall, type AgentAdapter } from "../base"; +import { buildMuseArgs, buildMuseResumeArgs } from "./argv"; +import { MUSE_DEFAULT_MODEL_ID, museDefaultCapabilities, museDetectionSpec } from "./detection"; +import { formatMusePromptSegments } from "./prompt"; +import { + makeMuseDiscoverSessionRef, + makeMuseWatchSessionRef, + snapshotMusePreSpawnSessions, +} from "./sessionFiles"; +import { detectMuseTerminalStatus, isMuseReadyForInitialPrompt } from "./terminal"; + +// Muse Code provider — Meta's terminal coding agent CLI. +// Docs: https://dev.meta.ai/docs/muse-code +// Install: curl -fsSL https://dev.meta.ai/install.sh | sh +// +// Terminal-only: interactive TUI via PTY. Muse has no ACP mode; a GUI +// structured session is deliberately deferred until Muse ships real ACP +// support (then wire it through the shared ACP client like Grok/Kimi/Qwen). + +export function createMuseAdapter(): AgentAdapter { + let capabilities: AgentCapability = museDefaultCapabilities; + + return { + kind: museDetectionSpec.kind, + label: museDetectionSpec.label, + binary: museDetectionSpec.binary, + // Surface the update spec on the adapter so the shared updater and the + // Settings registry card can read `adapter.update` (not just status). + ...(museDetectionSpec.update ? { update: museDetectionSpec.update } : {}), + get capabilities() { + return capabilities; + }, + // `muse login` opens a browser for Meta OAuth; BROWSER=/bin/true keeps the + // WSL flow from trying to `xdg-open` inside the distro and hanging the PTY. + spawnEnv: { wsl: { BROWSER: "/bin/true" } }, + + async detectInstall(ctx) { + const status = await detectAgentInstall(ctx, museDetectionSpec); + capabilities = status.capabilities; + return status; + }, + + buildLaunchArgv(location, config, prompt) { + // Muse mints its own session UUID; snapshot existing sessions so + // discoverSessionRef can identify the new one. Returning no sessionRef + // enables that discovery path. + snapshotMusePreSpawnSessions(location); + const args = buildMuseArgs(config, prompt); + return { binary: "muse", args }; + }, + + buildResumeArgv(_location, config, _prompt, sessionRef) { + const id = sessionRef.providerSessionId; + // Degenerate/legacy ref: resume the most recent session in this workspace. + const args = id + ? buildMuseResumeArgs(id, config) + : ["resume", "--last", ...buildMuseArgs(config)]; + return { binary: "muse", args }; + }, + + createInitialSessionRef() { + // CLI mints its own id in interactive mode — no pre-assign flag. + return undefined; + }, + + // Muse writes the date-sharded session dir shortly after launch; poll a + // beat afterward and rely on watchSessionRef to catch creation. + initialSessionRefDiscoveryDelayMs: 1000, + discoverSessionRef: makeMuseDiscoverSessionRef(), + watchSessionRef: makeMuseWatchSessionRef(), + + buildDirectInput(prompt) { + // The TUI treats bulk writes as paste, so an embedded `\r` becomes a + // literal newline. Pause briefly between text and Enter (kimi-style). + return [prompt, "@wait:200", "\r"]; + }, + + // `muse resume ` accepts no positional prompt. Always hand the first + // message to the ready TUI so fresh launches and resumed sessions follow + // one lossless path. + shouldDeferPromptToTerminal() { + return true; + }, + + isReadyForInitialPrompt: isMuseReadyForInitialPrompt, + + formatPromptSegments: formatMusePromptSegments, + + detectTerminalStatus: detectMuseTerminalStatus, + + // `muse exec` requires the prompt as an argv argument (stdin and + // /dev/stdin prompt files are unsupported), which matches the shared + // one-shot fallback path used by other terminal CLIs. + defaultOneShotModel: MUSE_DEFAULT_MODEL_ID, + buildOneShotCommand(model, effort, prompt) { + if (!prompt) return undefined; + return { + command: "muse", + args: [ + "exec", + "--no-session-log", + "--trust-workspace", + "--disable-write", + "--disable-shell", + "--user-input-auto-resolve", + "--model", + model || MUSE_DEFAULT_MODEL_ID, + ...(effort ? ["--reasoning-effort", effort] : []), + prompt, + ], + stdin: "", + }; + }, + }; +} diff --git a/src/supervisor/agents/muse/muse.test.ts b/src/supervisor/agents/muse/muse.test.ts new file mode 100644 index 000000000..ea0a04378 --- /dev/null +++ b/src/supervisor/agents/muse/muse.test.ts @@ -0,0 +1,241 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { ProjectLocation, ThreadConfig } from "@/shared/contracts"; +import { createKnownSessionRef } from "../base"; +import { createMuseAdapter } from "./index"; +import { + discoverMuseSessionRef, + isMuseSessionUuid, + snapshotMusePreSpawnSessions, +} from "./sessionFiles"; +import { detectMuseTerminalStatus, isMuseReadyForInitialPrompt } from "./terminal"; + +const location = { kind: "posix", path: "/tmp/demo" } as ProjectLocation; +const config = { mode: "agent", model: "muse-spark-1.2" } as ThreadConfig; + +describe("createMuseAdapter shape", () => { + const adapter = createMuseAdapter(); + + it("exposes identity metadata", () => { + expect(adapter.kind).toBe("muse"); + expect(adapter.label).toBe("Muse Code"); + expect(adapter.binary).toBe("muse"); + }); + + it("re-exposes the installer-only update spec on the adapter", () => { + expect(adapter.update?.builtIn).toBeUndefined(); + expect(adapter.update?.npm).toBeUndefined(); + expect(adapter.update?.installer?.posix).toEqual({ + binary: "sh", + args: ["-c", "curl -fsSL https://dev.meta.ai/install.sh | sh"], + }); + }); + + it("advertises terminal-only and no structured session (awaiting real ACP)", () => { + expect(adapter.capabilities.presentationModes).toEqual(["terminal"]); + expect(adapter.capabilities.liveInputMode).toBe("terminal"); + expect(adapter.createStructuredSession).toBeUndefined(); + }); + + it("neutralizes the browser for the WSL OAuth flow", () => { + expect(adapter.spawnEnv?.wsl).toEqual({ BROWSER: "/bin/true" }); + }); + + it("wires session discovery + watching and mints no initial ref", () => { + expect(typeof adapter.discoverSessionRef).toBe("function"); + expect(typeof adapter.watchSessionRef).toBe("function"); + expect(adapter.createInitialSessionRef()).toBeUndefined(); + }); + + it("advertises one-shot generation through muse exec", () => { + expect(adapter.defaultOneShotModel).toBe("muse-spark-1.2"); + expect(adapter.buildOneShotCommand).toBeTypeOf("function"); + expect(adapter.capabilities.supportsOneShot).toBe(true); + }); +}); + +describe("createMuseAdapter launch / resume argv", () => { + const adapter = createMuseAdapter(); + + it("launches fresh without a sessionRef so discovery can run", () => { + const result = adapter.buildLaunchArgv(location, config, "hi"); + expect(result.binary).toBe("muse"); + expect(result.sessionRef).toBeUndefined(); + expect(result.args).toEqual(["--trust-workspace", "--model", "muse-spark-1.2", "hi"]); + }); + + it("resumes a discovered id with resume ", () => { + const id = "966713f1-794f-480e-aa37-713e8387fe8e"; + const result = adapter.buildResumeArgv( + location, + { ...config, approvalPolicy: "yolo", effort: "low" }, + "", + createKnownSessionRef(id), + ); + expect(result.binary).toBe("muse"); + expect(result.args).toEqual([ + "resume", + id, + "--trust-workspace", + "--model", + "muse-spark-1.2", + "--reasoning-effort", + "low", + "--yolo", + ]); + }); + + it("chunks direct input with a paste-safe Enter delay", () => { + expect(adapter.buildDirectInput?.("hello")).toEqual(["hello", "@wait:200", "\r"]); + }); + + it("defers initial prompts to the TUI so resumed sessions receive them", () => { + expect(adapter.shouldDeferPromptToTerminal?.(config)).toBe(true); + }); + + it("runs one-shot prompts through muse exec as a read-only invocation", () => { + expect(adapter.buildOneShotCommand?.("muse-spark-1.1", "low", "write a title")).toEqual({ + command: "muse", + args: [ + "exec", + "--no-session-log", + "--trust-workspace", + "--disable-write", + "--disable-shell", + "--user-input-auto-resolve", + "--model", + "muse-spark-1.1", + "--reasoning-effort", + "low", + "write a title", + ], + stdin: "", + }); + expect(adapter.buildOneShotCommand?.("", undefined, undefined)).toBeUndefined(); + }); +}); + +describe("detectMuseTerminalStatus", () => { + // Strings grounded in the captured echo-provider TUI + // (tmp/muse-tui-echo-clean.txt): Working / esc to interrupt / Voice input / + // Muse Code header. + + it("detects working from esc to interrupt", () => { + expect(detectMuseTerminalStatus("◆ Working (0s · esc to interrupt)")).toMatchObject({ + status: "working", + attention: "working", + }); + }); + + it("detects working from the Finishing status strip", () => { + expect(detectMuseTerminalStatus("◆ Finishing (1s · esc to interrupt)")).toMatchObject({ + status: "working", + }); + }); + + it("falls back to idle on Voice input / Muse Code chrome", () => { + expect(detectMuseTerminalStatus("Voice input (⌥ + v to start)")).toMatchObject({ + status: "idle", + attention: "none", + }); + expect(detectMuseTerminalStatus("Muse Code 0.1.0")).toMatchObject({ status: "idle" }); + }); + + it("detects generic approval prompts conservatively", () => { + expect(detectMuseTerminalStatus("Allow shell command? [y/n]")).toMatchObject({ + status: "needs_approval", + attention: "needs_approval", + }); + }); +}); + +describe("isMuseReadyForInitialPrompt", () => { + it("is ready once Muse chrome has painted", () => { + expect(isMuseReadyForInitialPrompt("Muse Code 0.1.0")).toBe(true); + expect(isMuseReadyForInitialPrompt("Voice input (⌥ + v to start)")).toBe(true); + expect(isMuseReadyForInitialPrompt("⟩")).toBe(true); + expect(isMuseReadyForInitialPrompt("")).toBe(false); + expect(isMuseReadyForInitialPrompt("loading…")).toBe(false); + }); +}); + +describe("muse session discovery (native)", () => { + let dataHome: string; + let previousXdg: string | undefined; + let loc: ProjectLocation; + const workspacePath = join(tmpdir(), "muse-proj"); + + const makeSession = (ymd: [string, string, string], id: string, workspace = workspacePath) => { + const dir = join(dataHome, "sessions", ...ymd, id); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "session.jsonl"), + `${JSON.stringify({ + payload_type: "runtime.session.metadata", + payload: { record: { workspace_root: workspace } }, + })}\n`, + ); + return dir; + }; + + beforeEach(() => { + dataHome = mkdtempSync(join(tmpdir(), "muse-data-")); + previousXdg = process.env["XDG_DATA_HOME"]; + process.env["XDG_DATA_HOME"] = dataHome; + // nativeMuseDataHome joins XDG_DATA_HOME + "muse", so nest under a parent. + // Actually nativeMuseDataHome = join(XDG_DATA_HOME, "muse"). Override so + // sessions land at $XDG_DATA_HOME/muse/sessions — set XDG to the parent of + // our temp muse tree. + process.env["XDG_DATA_HOME"] = dataHome; + // paths: join(xdg, "muse") → dataHome/muse. Recreate layout under that. + dataHome = join(dataHome, "muse"); + mkdirSync(join(dataHome, "sessions"), { recursive: true }); + loc = { kind: "posix", path: workspacePath } as ProjectLocation; + }); + + afterEach(() => { + if (previousXdg === undefined) delete process.env["XDG_DATA_HOME"]; + else process.env["XDG_DATA_HOME"] = previousXdg; + // dataHome is …/muse; parent is the mkdtemp root + const parent = join(dataHome, ".."); + rmSync(parent, { recursive: true, force: true }); + }); + + it("recognizes Muse session UUIDs", () => { + expect(isMuseSessionUuid("966713f1-794f-480e-aa37-713e8387fe8e")).toBe(true); + expect(isMuseSessionUuid("not-a-uuid")).toBe(false); + }); + + it("discovers the session dir created after the pre-spawn snapshot", async () => { + makeSession(["2026", "08", "05"], "11111111-1111-1111-1111-111111111111"); + snapshotMusePreSpawnSessions(loc); + makeSession(["2026", "08", "05"], "22222222-2222-2222-2222-222222222222"); + + const ref = await discoverMuseSessionRef(loc); + expect(ref?.providerSessionId).toBe("22222222-2222-2222-2222-222222222222"); + }); + + it("returns undefined when no new sessions exist", async () => { + makeSession(["2026", "08", "05"], "11111111-1111-1111-1111-111111111111"); + snapshotMusePreSpawnSessions(loc); + expect(await discoverMuseSessionRef(loc)).toBeUndefined(); + }); + + it("does not bind a concurrent session created for another project", async () => { + snapshotMusePreSpawnSessions(loc); + makeSession( + ["2026", "08", "05"], + "33333333-3333-3333-3333-333333333333", + join(tmpdir(), "other-muse-project"), + ); + + expect(await discoverMuseSessionRef(loc)).toBeUndefined(); + + makeSession(["2026", "08", "05"], "44444444-4444-4444-4444-444444444444"); + expect((await discoverMuseSessionRef(loc))?.providerSessionId).toBe( + "44444444-4444-4444-4444-444444444444", + ); + }); +}); diff --git a/src/supervisor/agents/muse/paths.ts b/src/supervisor/agents/muse/paths.ts new file mode 100644 index 000000000..b80eeada7 --- /dev/null +++ b/src/supervisor/agents/muse/paths.ts @@ -0,0 +1,45 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; + +/** + * Native Muse Code config / data paths. + * + * Muse follows XDG on Linux-style hosts: + * config → ${XDG_CONFIG_HOME:-~/.config}/muse + * data → ${XDG_DATA_HOME:-~/.local/share}/muse + * + * Kept in a leaf module so detection, session discovery, and tests can share + * the same resolution without import cycles. + */ + +export function nativeMuseConfigHome(): string { + const xdg = process.env["XDG_CONFIG_HOME"]; + if (xdg && xdg.trim().length > 0) return join(xdg, "muse"); + return join(homedir(), ".config", "muse"); +} + +export function nativeMuseDataHome(): string { + const xdg = process.env["XDG_DATA_HOME"]; + if (xdg && xdg.trim().length > 0) return join(xdg, "muse"); + return join(homedir(), ".local", "share", "muse"); +} + +export function nativeMuseAuthPath(): string { + const override = process.env["MUSE_AUTH_PATH"]; + if (override && override.trim().length > 0) return override; + return join(nativeMuseConfigHome(), "auth.json"); +} + +export function nativeMuseSessionsRoot(): string { + return join(nativeMuseDataHome(), "sessions"); +} + +/** POSIX shell snippet that expands Muse config home inside a WSL distro. */ +export const WSL_MUSE_CONFIG_HOME = "${XDG_CONFIG_HOME:-$HOME/.config}/muse"; + +/** POSIX shell snippet resolving the launcher's supported auth-file override. */ +export const WSL_MUSE_AUTH_PATH = + "${MUSE_AUTH_PATH:-${XDG_CONFIG_HOME:-$HOME/.config}/muse/auth.json}"; + +/** POSIX shell snippet that expands Muse data home inside a WSL distro. */ +export const WSL_MUSE_DATA_HOME = "${XDG_DATA_HOME:-$HOME/.local/share}/muse"; diff --git a/src/supervisor/agents/muse/prompt.ts b/src/supervisor/agents/muse/prompt.ts new file mode 100644 index 000000000..0b1690494 --- /dev/null +++ b/src/supervisor/agents/muse/prompt.ts @@ -0,0 +1,14 @@ +import type { PromptSegment } from "@/shared/contracts"; +import { inlinePromptSegmentText } from "@/shared/promptContent"; + +/** + * Format prompt segments for Muse's interactive path. + * Attachments become trailing `@path` tokens (space-joined) after a blank line. + */ +export function formatMusePromptSegments(segments: PromptSegment[]): string { + const attachments = segments.filter((s) => s.kind === "attachment"); + const rest = segments.filter((s) => s.kind !== "attachment"); + const attachmentLines = attachments.map((s) => `@${s.path}`).join(" "); + const restStr = rest.map(inlinePromptSegmentText).join(""); + return attachmentLines ? `${restStr}\n\n${attachmentLines} ` : restStr; +} diff --git a/src/supervisor/agents/muse/sessionFiles.ts b/src/supervisor/agents/muse/sessionFiles.ts new file mode 100644 index 000000000..036a448ef --- /dev/null +++ b/src/supervisor/agents/muse/sessionFiles.ts @@ -0,0 +1,275 @@ +import { existsSync, readdirSync } from "node:fs"; +import { join, posix } from "node:path"; +import type { ProjectLocation, SessionRef } from "@/shared/contracts"; +import { + batchWslCommandsAsync, + createKnownSessionRef, + getCachedWslHomeDirectory, + listSessionDir, + readSessionFileText, + resolveWslHomeDirectoryAsync, + statSessionPaths, + watchSessionPaths, +} from "../base"; +import { nativeMuseDataHome, nativeMuseSessionsRoot } from "./paths"; + +/** + * Muse session discovery. Interactive mode mints its own UUID — there is no + * launch flag to pre-assign one. Sessions land at: + * + * ${XDG_DATA_HOME:-~/.local/share}/muse/sessions/YYYY/MM/DD// + * + * (verified: e.g. sessions/2026/08/05/966713f1-794f-480e-aa37-713e8387fe8e). + * + * We snapshot every UUID dir under the sessions root before spawn, then + * discover the brand-new dir after, returning the UUID as sessionRef so + * resume can run `muse resume `. + */ + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export function isMuseSessionUuid(name: string): boolean { + return UUID_RE.test(name); +} + +// Per-launch snapshots keyed by workspace. Muse stores every project in one +// date-sharded tree, so a process-global Set would let a launch in project B +// overwrite the baseline still needed to discover project A's new session. +const preSpawnSnapshots = new Map>(); + +function cwdOf(location: ProjectLocation): string { + return location.kind === "wsl" ? location.linuxPath : location.path; +} + +function snapshotKey(location: ProjectLocation): string { + return location.kind === "wsl" + ? `wsl:${location.distro}:${location.linuxPath}` + : `${location.kind}:${location.path}`; +} + +function getNativeSessionsRoot(): string { + return nativeMuseSessionsRoot(); +} + +function getMuseDataHome(location: ProjectLocation): string | null { + if (location.kind === "wsl") { + const home = getCachedWslHomeDirectory(location.distro); + // Best-effort default; discovery uses the async XDG-aware form. + return home ? `${home}/.local/share/muse` : null; + } + return nativeMuseDataHome(); +} + +function getMuseSessionsRoot(location: ProjectLocation): string | null { + const data = getMuseDataHome(location); + return data ? `${data}/sessions` : null; +} + +async function resolveWslMuseDataHomeAsync(distro: string): Promise { + const [r] = await batchWslCommandsAsync(distro, [ + 'printf %s "${XDG_DATA_HOME:-$HOME/.local/share}/muse"', + ]); + const home = r?.ok ? r.stdout.trim() : ""; + if (home) return home; + // Fall back to cached $HOME when the probe fails. + const resolved = await resolveWslHomeDirectoryAsync(distro); + return resolved ? `${resolved}/.local/share/muse` : null; +} + +async function getMuseSessionsRootAsync(location: ProjectLocation): Promise { + if (location.kind !== "wsl") return getMuseSessionsRoot(location); + const data = await resolveWslMuseDataHomeAsync(location.distro); + return data ? `${data}/sessions` : null; +} + +function joinMusePath(location: ProjectLocation, ...parts: string[]): string { + return location.kind === "wsl" ? posix.join(...parts) : join(...parts); +} + +/** + * Recursively collect UUID session directory names under `root` (date-sharded + * YYYY/MM/DD/). Native-only sync walk used for the pre-spawn snapshot. + */ +function collectNativeSessionIds(root: string, into: Set, depth = 0): void { + if (depth > 4 || !existsSync(root)) return; + try { + for (const entry of readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (isMuseSessionUuid(entry.name)) { + into.add(entry.name); + continue; + } + // Date shards are digits-only (YYYY / MM / DD). + if (/^\d{2,4}$/.test(entry.name)) { + collectNativeSessionIds(join(root, entry.name), into, depth + 1); + } + } + } catch { + // Best effort. + } +} + +/** + * Record existing Muse session UUIDs before spawning so discovery can ignore + * them. Sync + native only; WSL discovery instead filters candidates by the + * workspace metadata written into each session log. + */ +export function snapshotMusePreSpawnSessions(location: ProjectLocation): void { + const ids = new Set(); + preSpawnSnapshots.set(snapshotKey(location), ids); + if (location.kind === "wsl") return; + const root = getNativeSessionsRoot(); + collectNativeSessionIds(root, ids); +} + +/** + * Walk the date-sharded sessions tree via the shared listSessionDir helper + * (native readdir / WSL bridge) and return every UUID dir path. + */ +async function listMuseSessionCandidates( + location: ProjectLocation, + root: string, +): Promise> { + const yearDirs = await listSessionDir(location, root); + if (!yearDirs) return []; + + const candidates: Array<{ id: string; path: string }> = []; + for (const year of yearDirs) { + if (year.type !== "directory") continue; + // Either a year (YYYY) or, defensively, a UUID at the root. + if (isMuseSessionUuid(year.name)) { + candidates.push({ id: year.name, path: joinMusePath(location, root, year.name) }); + continue; + } + if (!/^\d{4}$/.test(year.name)) continue; + const yearPath = joinMusePath(location, root, year.name); + const monthDirs = await listSessionDir(location, yearPath); + if (!monthDirs) continue; + for (const month of monthDirs) { + if (month.type !== "directory" || !/^\d{2}$/.test(month.name)) continue; + const monthPath = joinMusePath(location, yearPath, month.name); + const dayDirs = await listSessionDir(location, monthPath); + if (!dayDirs) continue; + for (const day of dayDirs) { + if (day.type !== "directory" || !/^\d{2}$/.test(day.name)) continue; + const dayPath = joinMusePath(location, monthPath, day.name); + const sessions = await listSessionDir(location, dayPath); + if (!sessions) continue; + for (const session of sessions) { + if (session.type === "directory" && isMuseSessionUuid(session.name)) { + candidates.push({ + id: session.name, + path: joinMusePath(location, dayPath, session.name), + }); + } + } + } + } + } + return candidates; +} + +function normalizePathForCompare(path: string, location: ProjectLocation): string { + const trimmed = path.trim().replace(/[\\/]+$/, ""); + return location.kind === "windows" ? trimmed.replaceAll("\\", "/").toLowerCase() : trimmed; +} + +/** Read the workspace root recorded near the start of Muse's session log. */ +async function readMuseSessionWorkspace( + location: ProjectLocation, + sessionDir: string, +): Promise { + const raw = await readSessionFileText( + location, + joinMusePath(location, sessionDir, "session.jsonl"), + 512_000, + ); + if (!raw) return undefined; + for (const line of raw.split(/\r?\n/u)) { + if (!line.includes('"runtime.session.metadata"')) continue; + try { + const parsed = JSON.parse(line) as { + payload_type?: unknown; + payload?: { record?: { workspace_root?: unknown } }; + }; + const workspace = parsed.payload?.record?.workspace_root; + if (parsed.payload_type === "runtime.session.metadata" && typeof workspace === "string") { + return workspace; + } + } catch { + // Ignore an incomplete/racing JSONL line and wait for the next watcher tick. + } + } + return undefined; +} + +/** + * Return the newest Muse session UUID that was not present in this workspace's + * pre-spawn snapshot and whose recorded workspace matches the launch location. + */ +export async function discoverMuseSessionRef( + location: ProjectLocation, +): Promise { + const root = await getMuseSessionsRootAsync(location); + if (!root) return undefined; + + const all = await listMuseSessionCandidates(location, root); + const key = snapshotKey(location); + const snapshot = preSpawnSnapshots.get(key) ?? new Set(); + const candidates = all.filter((candidate) => !snapshot.has(candidate.id)); + if (candidates.length === 0) return undefined; + + const wanted = normalizePathForCompare(cwdOf(location), location); + const pool = ( + await Promise.all( + candidates.map(async (candidate) => ({ + candidate, + workspace: await readMuseSessionWorkspace(location, candidate.path), + })), + ) + ).flatMap(({ candidate, workspace }) => + workspace && normalizePathForCompare(workspace, location) === wanted ? [candidate] : [], + ); + // The directory can appear before its metadata record is flushed. Returning + // undefined lets the existing watcher retry instead of binding another + // project's newest session. + if (pool.length === 0) return undefined; + + const stats = await statSessionPaths( + location, + pool.map((c) => c.path), + ); + const ranked = pool + .map((c) => ({ id: c.id, mtime: stats.get(c.path)?.mtimeMs ?? 0 })) + .sort((a, b) => b.mtime - a.mtime); + + const winner = ranked[0]; + if (!winner) return undefined; + preSpawnSnapshots.delete(key); + return createKnownSessionRef(winner.id); +} + +export function makeMuseDiscoverSessionRef() { + return (location: ProjectLocation): Promise => + discoverMuseSessionRef(location); +} + +/** + * Absolute paths to watch for new Muse sessions. Prefer the sessions root; + * include the data home so the first-ever session (which materializes + * sessions/) still wakes the watcher. + */ +export function resolveMuseSessionsWatchPaths(location: ProjectLocation): string[] { + const root = getMuseSessionsRoot(location); + const data = getMuseDataHome(location); + return [root ?? undefined, data ?? undefined].filter((p): p is string => Boolean(p)); +} + +export function makeMuseWatchSessionRef() { + return (location: ProjectLocation, onChanged: () => void): (() => void) | undefined => { + const paths = resolveMuseSessionsWatchPaths(location); + if (paths.length === 0) return undefined; + const label = `muse:${location.kind === "wsl" ? "wsl:" + location.distro : location.kind}`; + return watchSessionPaths(location, paths, onChanged, label); + }; +} diff --git a/src/supervisor/agents/muse/terminal.ts b/src/supervisor/agents/muse/terminal.ts new file mode 100644 index 000000000..53af10036 --- /dev/null +++ b/src/supervisor/agents/muse/terminal.ts @@ -0,0 +1,65 @@ +import { detectTerminalStatusFromHints, type TerminalStatusHint } from "../base"; + +// Heuristics verified against captured Muse Code 0.1.0 TUI output +// (`muse --provider echo --no-session-log --trust-workspace "say hello"` under +// a PTY that answers CSI 6n / DA). Scratch capture: tmp/muse-tui-echo-clean.txt. +// +// Working: status strip reads `◆ Working (0s · esc to interrupt)` and later +// `◆ Finishing … · esc to interrupt`. Anchor on the invariant +// `esc to interrupt` / `Working` / `Finishing` labels. +// Idle / ready: header `Muse Code 0.1.0`, composer footer +// `Voice input (⌥ + v to start)`, and the `⟩` prompt glyph. +// Approval: echo provider never surfaces tool-approval UI; keep generic, +// high-confidence interactive patterns only (conservative). + +const MUSE_STRONG = [ + { + re: /Enter to select|Choose an option/i, + status: "needs_reply" as const, + attention: "needs_reply" as const, + }, + { + re: /\[y\/n\]|\(y\/N\)|Allow\s+.*\?|Do you want to proceed|Continue\?|Approve\??/i, + status: "needs_approval" as const, + attention: "needs_approval" as const, + }, + { + re: /\besc\s+to\s+interrupt\b/i, + status: "working" as const, + attention: "working" as const, + }, + { + re: /◆\s*(?:Working|Finishing)\b/i, + status: "working" as const, + attention: "working" as const, + }, +]; + +const MUSE_FALLBACK_IDLE = [ + { + re: /Voice\s+input/i, + status: "idle" as const, + attention: "none" as const, + }, + { + re: /\bMuse\s+Code\b/i, + status: "idle" as const, + attention: "none" as const, + }, +]; + +export function detectMuseTerminalStatus(text: string): TerminalStatusHint | null { + return detectTerminalStatusFromHints(text, MUSE_STRONG, MUSE_FALLBACK_IDLE); +} + +/** + * True once the interactive TUI has painted enough chrome that typing the + * first prompt is safe. Anchors from the captured startup frame. + */ +export function isMuseReadyForInitialPrompt(text: string): boolean { + if (/\bMuse\s+Code\b/i.test(text)) return true; + if (/Voice\s+input/i.test(text)) return true; + // Composer glyph from the captured TUI (U+27E9). + if (text.includes("⟩")) return true; + return false; +} diff --git a/src/supervisor/agents/oneShotCapability.test.ts b/src/supervisor/agents/oneShotCapability.test.ts index 6720e97f3..791474ae8 100644 --- a/src/supervisor/agents/oneShotCapability.test.ts +++ b/src/supervisor/agents/oneShotCapability.test.ts @@ -45,7 +45,7 @@ describe("supportsOneShot capability", () => { expect(supported).toEqual(["claude", "pi"]); }); - it("marks every first-class adapter as one-shot capable (they are all CLIs)", () => { + it("marks every first-class adapter as one-shot capable", () => { // First-class providers are CLIs with a headless prompt path, so each must // expose one-shot support. Only runtime-registered ACP-registry generics // outside this built-in registry may lack it. @@ -61,4 +61,12 @@ describe("supportsOneShot capability", () => { expect(grok?.capabilities.supportsOneShot).toBe(true); expect(typeof grok?.buildOneShotCommand).toBe("function"); }); + + it("includes Muse through its positional muse exec prompt path", () => { + const muse = adapters.find((adapter) => adapter.kind === "muse"); + expect(muse).toBeDefined(); + expect(muse?.capabilities.supportsOneShot).toBe(true); + expect(muse?.runOneShot).toBeUndefined(); + expect(typeof muse?.buildOneShotCommand).toBe("function"); + }); }); diff --git a/src/supervisor/agents/registry.test.ts b/src/supervisor/agents/registry.test.ts index fc8a843b0..05ddfbf43 100644 --- a/src/supervisor/agents/registry.test.ts +++ b/src/supervisor/agents/registry.test.ts @@ -13,6 +13,7 @@ const EXPECTED_BUILT_IN_ORDER = [ "qoder", "grok", "kimi", + "muse", "antigravity", "commandcode", "cursor", @@ -31,6 +32,7 @@ const EXPECTED_SUBAGENT_APPROVAL_POLICY: Record<(typeof EXPECTED_BUILT_IN_ORDER) qoder: "bypassPermissions", grok: "bypassPermissions", kimi: "yolo", + muse: "yolo", antigravity: "yolo", commandcode: "yolo", cursor: "never", @@ -48,6 +50,7 @@ const EXPECTED_DEFAULT_APPROVAL_POLICY: Record<(typeof EXPECTED_BUILT_IN_ORDER)[ qoder: "bypassPermissions", grok: "bypassPermissions", kimi: "auto", + muse: "on-request", antigravity: "yolo", commandcode: "yolo", cursor: "never", diff --git a/src/supervisor/agents/registry.ts b/src/supervisor/agents/registry.ts index 9d9ce75ba..88d9ce019 100644 --- a/src/supervisor/agents/registry.ts +++ b/src/supervisor/agents/registry.ts @@ -24,6 +24,7 @@ import { createFactoryAdapter } from "./factory"; import { createGeminiAdapter } from "./gemini"; import { createGrokAdapter } from "./grok"; import { createKimiAdapter } from "./kimi"; +import { createMuseAdapter } from "./muse"; import { createOpenCodeAdapter } from "./opencode"; import { createPiAdapter } from "./pi"; import { createQoderAdapter } from "./qoder"; @@ -48,6 +49,7 @@ export function buildAgentRegistry(userInstances: AgentInstanceConfig[]): AgentA createQoderAdapter(), createGrokAdapter(), createKimiAdapter(), + createMuseAdapter(), createAntigravityAdapter(), createCommandCodeAdapter(), createCursorAdapter(), diff --git a/tests/integration/providers-lifecycle.integration.test.ts b/tests/integration/providers-lifecycle.integration.test.ts index 505e4ba0e..97d7a97e1 100644 --- a/tests/integration/providers-lifecycle.integration.test.ts +++ b/tests/integration/providers-lifecycle.integration.test.ts @@ -41,6 +41,7 @@ const PREFERRED_MODEL: Record = { commandcode: "google/gemini-3.1-flash-lite", opencode: "opencode/big-pickle", kimi: "kimi-code/kimi-for-coding", + muse: "muse-spark-1.2", qwen: "qwen3.8-max", qoder: "lite", };