From ebed0964a2aab5fe7da150d7e55396f053342aeb Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:13:11 -0700 Subject: [PATCH] feat(agents): add first-class Muse Code harness (#22216) * feat(agents): add first-class Muse Code harness Add Muse as a supervised Orca agent across desktop, mobile, session history, source control, local hooks, SSH, WSL, and native Windows. Preserve user settings, support Muse 1.3 hook environment allowlists, and recognize versioned foreground processes. Include question, waiting, completion, resume, and readiness coverage. Co-authored-by: homesh-dev <300847526+homesh-dev@users.noreply.github.com> Co-authored-by: jeffhuen <32542276+jeffhuen@users.noreply.github.com> Co-authored-by: John Cusack Co-authored-by: Adrien De oliveira <75085839+adriendeoliveira@users.noreply.github.com> * test(agents): cover Muse remote hook registration * test(agents): cover Muse hook and source-control contracts * test(agents): exclude Muse hook metadata from script mode check * test(agents): keep Muse skill picker coverage stable * test(ai-vault): include Muse in every-agent fixture * test(mobile): repin Muse agent icon closure * fix(muse): detect questions and approvals from structured Muse signals Muse 1.3 fires no hook for request_user_input, so a pending question left the pane "working". Its internal reminder subagents also post hooks with their own session ids (even after Stop), which surfaced "tool failed" rows and flipped finished panes back to working. - Read pending questions from Muse's session log (user_input_prompt_requested/settled) via the existing transcript poll, now generalized from Codex subagents to Muse on main and relay. - Drop child-session hooks (SubagentStart ids, or turn_id === session_id). - Treat Notification permission_prompt as the approval wait; PermissionRequest also fires for auto-approved calls, so it only caches the approval card. - Ignore Notification copy as the prompt; poll replays are not new prompts or turn boundaries. - Allowlist USERPROFILE so Windows cmd AutoRun doesn't fail every hook. * perf(muse): parse only question events from the session log Most Muse session-log lines are large model/tool records. Filter raw lines by the user_input_prompt_ marker before JSON.parse via an optional readJsonlCursor line filter. * fix(muse): unwrap batched log records and scope questions to the live turn Review follow-ups: question events inside retained_frame batches were skipped, and a question left open by a crash or interrupt stayed pending for the pane's life. Share the history scanner's retained_frame unwrapper, and only report a pending question whose run_id matches the hook turn_id. * refactor(muse): drop type assertion in retained_frame unwrap * fix(agent-hooks): satisfy exhaustive-switch lint in transcript poll policy --------- Co-authored-by: Adrien De oliveira <75085839+adriendeoliveira@users.noreply.github.com> --- README.md | 2 + ...-web-app-session-terminal-closure.test.mjs | 8 +- config/tsconfig.cli.json | 3 + docs/readme/README.es.md | 1 + docs/readme/README.fr.md | 1 + docs/readme/README.ja.md | 1 + docs/readme/README.ko.md | 1 + docs/readme/README.pt.md | 1 + docs/readme/README.zh-CN.md | 1 + .../content/docs/agents/session-history.mdx | 2 +- docs/site/content/docs/agents/supported.mdx | 3 +- .../components/mobile-agent-icon-assets.ts | 1 + mobile/src/tasks/mobile-tui-agents.ts | 1 + .../managed-agent-hook-registry.ts | 13 +- .../managed-hook-command-contract.test.ts | 8 + .../managed-hook-local-filesystem.test.ts | 8 +- .../remote-hook-service-installers.test.ts | 9 +- .../remote-managed-hook-installers.ts | 4 +- .../server-hook-http-ingest.test.ts | 14 +- .../server-muse-session-log-poll.test.ts | 124 +++++++ .../server-retired-pane-new-turn.test.ts | 3 +- .../server/server-authority-aliases.ts | 2 +- .../server/server-authority-fences.ts | 2 +- src/main/agent-hooks/server/server-cleanup.ts | 2 +- .../agent-hooks/server/server-lifecycle.ts | 4 +- src/main/agent-hooks/server/server-state.ts | 6 +- .../server/server-status-retries.ts | 40 +-- .../agent-hooks/server/server-tab-cleanup.ts | 4 +- .../remote-session-scanner-muse.test.ts | 67 ++++ .../remote-session-scanner-source-parsers.ts | 80 +++++ .../remote-session-scanner-sources.ts | 82 ++--- .../ai-vault/session-scanner-agent-parser.ts | 3 + .../ai-vault/session-scanner-agent-sources.ts | 15 + .../session-scanner-codex-workers.test.ts | 1 + .../session-scanner-every-agent-fixture.ts | 7 +- .../ai-vault/session-scanner-muse-parser.ts | 282 ++++++++++++++++ .../ai-vault/session-scanner-muse-paths.ts | 9 + .../ai-vault/session-scanner-muse.test.ts | 80 +++++ ...canner-opencode-sqlite-coexistence.test.ts | 1 + .../ai-vault/session-scanner-parse-cache.ts | 1 + .../ai-vault/session-scanner-test-fixtures.ts | 76 ++++- src/main/ai-vault/session-scanner-types.ts | 1 + src/main/ai-vault/session-scanner.test.ts | 8 +- src/main/muse/hook-config-json.test.ts | 47 +++ src/main/muse/hook-config-json.ts | 104 ++++++ src/main/muse/hook-service.test.ts | 140 ++++++++ src/main/muse/hook-service.ts | 310 ++++++++++++++++++ src/main/muse/hook-settings.ts | 131 ++++++++ .../muse-empty-folder-ready.meta.json | 9 + .../__fixtures__/muse-empty-folder-ready.txt | 5 + .../runtime/muse-readiness-transcript.test.ts | 31 ++ .../orca-runtime-resolve-exit-waiters.ts | 11 +- .../runtime/runtime-terminal-idle-polls.ts | 5 +- src/main/runtime/runtime-terminal-wait.ts | 5 +- .../runtime/terminal-wait-detection.test.ts | 78 ++++- src/main/runtime/terminal-wait-detection.ts | 28 +- .../terminal-wait-name-only-idle.test.ts | 49 +++ src/main/runtime/tui-idle-evidence.test.ts | 78 +++++ src/main/runtime/tui-idle-evidence.ts | 47 ++- .../skill-discovery-concurrency.test.ts | 2 +- src/main/skills/skill-discovery-sources.ts | 11 + .../agent-hook-result-retry-scheduler.ts | 44 +-- src/relay/agent-hook-server.test.ts | 8 +- src/relay/agent-hook-server.ts | 4 +- .../skills/SkillInstallDialog.test.tsx | 4 +- ...erminal-startup-command-classifier.test.ts | 9 + .../terminal-startup-command-classifier.ts | 3 +- src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/es.json | 3 +- src/renderer/src/i18n/locales/fr.json | 3 +- src/renderer/src/i18n/locales/ja.json | 3 +- src/renderer/src/i18n/locales/ko.json | 3 +- src/renderer/src/i18n/locales/zh.json | 3 +- src/renderer/src/lib/agent-catalog.tsx | 7 + src/renderer/src/lib/agent-favicon-assets.ts | 2 + src/renderer/src/lib/agent-status.ts | 3 +- .../src/lib/tui-agent-startup.test.ts | 20 ++ ...t-resume-host-authority-capability.test.ts | 9 + .../agent-resume-host-authority-capability.ts | 2 + .../remote-agent-session-launch.test.ts | 44 ++- src/shared/agent-headless-command.ts | 8 +- ...listener-claude-compatible-vendors.test.ts | 106 ++++++ ...ent-hook-listener-relay-dependency.test.ts | 4 +- .../agent-hook-listener/listener-state.ts | 14 + .../agent-hook-listener/provider-dispatch.ts | 4 + .../provider-event-routing.ts | 6 + .../providers/muse-events.test.ts | 236 +++++++++++++ .../providers/muse-events.ts | 158 +++++++++ .../agent-hook-listener/source-routing.ts | 3 +- .../transcript-poll-policy.ts | 41 +++ src/shared/agent-hook-relay.ts | 3 +- src/shared/agent-hook-types.ts | 3 +- src/shared/agent-icons/muse.png | Bin 0 -> 2003 bytes src/shared/agent-kind.ts | 3 +- src/shared/agent-process-recognition.test.ts | 111 +++++++ src/shared/agent-process-recognition.ts | 23 +- src/shared/agent-session-resume.ts | 9 +- src/shared/agent-type-label.ts | 3 +- src/shared/ai-vault-resume-command.test.ts | 11 + src/shared/ai-vault-resume-command.ts | 4 + src/shared/ai-vault-types.ts | 6 +- src/shared/codex-rollout-jsonl-cursor.ts | 13 +- src/shared/commit-message-agent-spec.test.ts | 21 ++ .../commit-message-agent-specs-secondary.ts | 27 ++ src/shared/commit-message-plan.test.ts | 25 ++ src/shared/constants.test.ts | 1 + src/shared/muse-headless-command.ts | 9 + src/shared/muse-session-log.test.ts | 140 ++++++++ src/shared/muse-session-log.ts | 140 ++++++++ src/shared/protocol-version.ts | 2 + src/shared/skill-install-providers.ts | 8 + src/shared/skills-cli-agent-keys.ts | 3 +- .../source-control-ai-action-recipes.test.ts | 2 +- src/shared/telemetry-property-schemas.ts | 1 + src/shared/tui-agent-config.test.ts | 3 +- src/shared/tui-agent-config.ts | 6 + src/shared/tui-agent-display-names.ts | 1 + src/shared/tui-agent-permissions.test.ts | 17 + src/shared/tui-agent-permissions.ts | 1 + src/shared/tui-agent-selection.ts | 1 + src/shared/tui-agent-startup.test.ts | 44 +++ src/shared/tui-agent.ts | 1 + 122 files changed, 3274 insertions(+), 196 deletions(-) create mode 100644 src/main/agent-hooks/server-muse-session-log-poll.test.ts create mode 100644 src/main/ai-vault/remote-session-scanner-muse.test.ts create mode 100644 src/main/ai-vault/remote-session-scanner-source-parsers.ts create mode 100644 src/main/ai-vault/session-scanner-muse-parser.ts create mode 100644 src/main/ai-vault/session-scanner-muse-paths.ts create mode 100644 src/main/ai-vault/session-scanner-muse.test.ts create mode 100644 src/main/muse/hook-config-json.test.ts create mode 100644 src/main/muse/hook-config-json.ts create mode 100644 src/main/muse/hook-service.test.ts create mode 100644 src/main/muse/hook-service.ts create mode 100644 src/main/muse/hook-settings.ts create mode 100644 src/main/runtime/__fixtures__/muse-empty-folder-ready.meta.json create mode 100644 src/main/runtime/__fixtures__/muse-empty-folder-ready.txt create mode 100644 src/main/runtime/muse-readiness-transcript.test.ts create mode 100644 src/main/runtime/tui-idle-evidence.test.ts create mode 100644 src/shared/agent-hook-listener/providers/muse-events.test.ts create mode 100644 src/shared/agent-hook-listener/providers/muse-events.ts create mode 100644 src/shared/agent-hook-listener/transcript-poll-policy.ts create mode 100644 src/shared/agent-icons/muse.png create mode 100644 src/shared/muse-headless-command.ts create mode 100644 src/shared/muse-session-log.test.ts create mode 100644 src/shared/muse-session-log.ts diff --git a/README.md b/README.md index db7bd4a80c8..89b9552f7b8 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,7 @@ Works with **any CLI agent** — if it runs in a terminal, it runs in Orca. Grok logo Grok   Cursor logo Cursor   GitHub Copilot logo GitHub Copilot   + Muse logo Muse   OpenCode logo OpenCode   MiMo Code logo MiMo Code   Amp logo Amp   @@ -203,6 +204,7 @@ Works with **any CLI agent** — if it runs in a terminal, it runs in Orca. Mistral Vibe logo Mistral Vibe   Qwen Code logo Qwen Code   Rovo Dev logo Rovo Dev   + Muse logo Muse   + any CLI agent

diff --git a/config/scripts/mobile-web-app-session-terminal-closure.test.mjs b/config/scripts/mobile-web-app-session-terminal-closure.test.mjs index e54aae406f9..6461d4ac91d 100644 --- a/config/scripts/mobile-web-app-session-terminal-closure.test.mjs +++ b/config/scripts/mobile-web-app-session-terminal-closure.test.mjs @@ -406,8 +406,14 @@ const MERMAID_PACKAGE = 'node_modules/mermaid/' * * modules 4212 -> 4213 (+1) * local modules 1026 -> 1027 (+1) + * + * Muse then joined the mobile agent catalog with its bundled icon, one more local input to the + * shared agent picker. + * + * modules 4213 -> 4214 (+1) + * local modules 1027 -> 1028 (+1) */ -const SESSION_ROUTE_MODULES = 4213 +const SESSION_ROUTE_MODULES = 4214 /** What the page enters this route through once the route is a switch with a `.web.tsx` sibling. */ const ROUTE_ENTRY = [ diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 43eac7cb024..3954303811e 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -131,6 +131,9 @@ "../src/main/in-flight-run-dedupe.ts", "../src/main/kimi/hook-service.ts", "../src/main/kimi/kimi-hook-config-toml.ts", + "../src/main/muse/hook-config-json.ts", + "../src/main/muse/hook-service.ts", + "../src/main/muse/hook-settings.ts", "../src/main/openclaude/hook-service.ts", "../src/main/rolling-file-backup.ts", "../src/main/startup/hydrate-shell-path.ts", diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md index aa2b20c2f63..27075756236 100644 --- a/docs/readme/README.es.md +++ b/docs/readme/README.es.md @@ -178,6 +178,7 @@ Funciona con **cualquier agente CLI** — si corre en una terminal, corre en Orc Grok logo Grok   Cursor logo Cursor   GitHub Copilot logo GitHub Copilot   + Muse logo Muse   OpenCode logo OpenCode   Amp logo Amp   OpenClaude logo OpenClaude   diff --git a/docs/readme/README.fr.md b/docs/readme/README.fr.md index e1b261b11ec..86d18b43925 100644 --- a/docs/readme/README.fr.md +++ b/docs/readme/README.fr.md @@ -182,6 +182,7 @@ Fonctionne avec **n'importe quel agent CLI** — s'il tourne dans un terminal, i Logo Grok Grok   Logo Cursor Cursor   Logo GitHub Copilot GitHub Copilot   + Logo Muse Muse   Logo OpenCode OpenCode   Logo MiMo Code MiMo Code   Logo Amp Amp   diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index de3216562a6..ab5ea12e5fb 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -178,6 +178,7 @@ PR、Issue、プロジェクトボードをアプリ内で閲覧 — 任意の Grok logo Grok   Cursor logo Cursor   GitHub Copilot logo GitHub Copilot   + Muse logo Muse   OpenCode logo OpenCode   Amp logo Amp   OpenClaude logo OpenClaude   diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index dbcea975ce6..6245658c152 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -178,6 +178,7 @@ diff의 어느 줄에든 코멘트를 남기고 에이전트에게 바로 보내 Grok logo Grok   Cursor logo Cursor   GitHub Copilot logo GitHub Copilot   + Muse logo Muse   OpenCode logo OpenCode   MiMo Code logo MiMo Code   Amp logo Amp   diff --git a/docs/readme/README.pt.md b/docs/readme/README.pt.md index b465615863b..53524927393 100644 --- a/docs/readme/README.pt.md +++ b/docs/readme/README.pt.md @@ -178,6 +178,7 @@ Funciona com **qualquer agente CLI** — se roda em um terminal, roda no Orca. Logotipo do Grok Grok   Logotipo do Cursor Cursor   Logotipo do GitHub Copilot GitHub Copilot   + Logotipo do Muse Muse   Logotipo do OpenCode OpenCode   Logotipo do MiMo Code MiMo Code   Logotipo do Amp Amp   diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index 69f0e8775ae..5a282888353 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -178,6 +178,7 @@ VS Code 的编辑器,处处自动保存 — 把文件或图片直接拖入智 Grok logo Grok   Cursor logo Cursor   GitHub Copilot logo GitHub Copilot   + Muse logo Muse   OpenCode logo OpenCode   Amp logo Amp   OpenClaude logo OpenClaude   diff --git a/docs/site/content/docs/agents/session-history.mdx b/docs/site/content/docs/agents/session-history.mdx index 0756b2448a5..2218cce349b 100644 --- a/docs/site/content/docs/agents/session-history.mdx +++ b/docs/site/content/docs/agents/session-history.mdx @@ -27,7 +27,7 @@ Remote workspaces can browse local history, but resume actions only run from loc The view-options menu (next to the search box) controls which agents are scanned, plus sort and grouping: -- **Agents** — toggle individual CLIs on or off (Claude, Codex, Hermes, Pi, OMP, Prime Agent, Cursor, Gemini, Antigravity, Rovo Dev, Copilot, OpenCode, Grok, OpenClaw, Devin, Droid, Kimi). Disabled agents are skipped during the scan. Use **Select all** / **Clear** in the Agents header to flip every agent at once — **Clear** leaves none selected so you can turn on only the CLI you care about without unchecking a long list. An empty selection shows **No agents selected** instead of the usual empty-filter message. +- **Agents** — toggle individual CLIs on or off (Claude, Codex, Hermes, Pi, OMP, Prime Agent, Cursor, Gemini, Antigravity, Rovo Dev, Copilot, OpenCode, Grok, OpenClaw, Devin, Droid, Kimi, Muse). Disabled agents are skipped during the scan. Use **Select all** / **Clear** in the Agents header to flip every agent at once — **Clear** leaves none selected so you can turn on only the CLI you care about without unchecking a long list. An empty selection shows **No agents selected** instead of the usual empty-filter message. - **Sort** — `Last updated` or `Created`. - **Group** — `Project`, `Folder` (one heading per `cwd`), or `Agent` (one heading per CLI). - **Hide empty sessions** — drop sessions with zero recorded messages. diff --git a/docs/site/content/docs/agents/supported.mdx b/docs/site/content/docs/agents/supported.mdx index 6fef0e2bb53..f5774c11f18 100644 --- a/docs/site/content/docs/agents/supported.mdx +++ b/docs/site/content/docs/agents/supported.mdx @@ -16,7 +16,7 @@ Orca works with **any CLI agent** — the agent combobox just launches a process ## Permissions default -For new launches, Orca pre-fills each supported CLI's permission-bypass flag — `--dangerously-skip-permissions` for Claude, `--dangerously-bypass-approvals-and-sandbox` for Codex, `--yolo` for Gemini / Cursor / Crush / Kimi / Rovo Dev / Hermes / GitHub Copilot / Command Code, plus the equivalent flag for every other agent that exposes one. These flags allow an agent to act without confirming every shell command; review the trust boundary before using them. +For new launches, Orca pre-fills each supported CLI's permission-bypass flag — `--dangerously-skip-permissions` for Claude, `--dangerously-bypass-approvals-and-sandbox` for Codex, `--yolo` for Gemini / Cursor / Crush / Kimi / Muse / Rovo Dev / Hermes / GitHub Copilot / Command Code, plus the equivalent flag for every other agent that exposes one. These flags allow an agent to act without confirming every shell command; review the trust boundary before using them. Use **Settings → Agents → Agent Permissions** when you want to switch all uncustomized agents between **Yolo** and **Manual** launches. If you already overrode a specific agent's launch arguments or environment, Orca leaves that agent alone so the global switch doesn't erase your custom command. @@ -48,6 +48,7 @@ To restore prompts for one agent only, edit that agent's default arguments or en | Codebuff | Auto-setup | [Codebuff](https://www.codebuff.com/docs/help/quick-start) | | Freebuff | Auto-setup | [Freebuff](https://freebuff.com/cli) | | Command Code | Auto-setup, status | [Command Code](https://commandcode.ai/docs/quickstart) | +| Muse | macOS/Linux; trusts the workspace at launch | [Meta](https://dev.meta.ai/docs/muse-code) | | Continue | Auto-setup | [Continue](https://docs.continue.dev/guides/cli) | | Cursor CLI | Deep integration | [Cursor](https://cursor.com/cli) | | Devin | Auto-setup | [Devin](https://devin.ai/cli) | diff --git a/mobile/src/components/mobile-agent-icon-assets.ts b/mobile/src/components/mobile-agent-icon-assets.ts index e9b62da8c03..ee07291b3b6 100644 --- a/mobile/src/components/mobile-agent-icon-assets.ts +++ b/mobile/src/components/mobile-agent-icon-assets.ts @@ -40,5 +40,6 @@ export const MOBILE_AGENT_ICON_ASSETS: Partial> 'mimo-code': 'mimo.xiaomi.com', ante: 'antigma.ai', trae: 'www.trae.cn', + muse: 'dev.meta.ai', omp: 'omp.sh', 'prime-agent': 'primeintellect.ai', gemini: 'gemini.google.com', diff --git a/src/main/agent-hooks/managed-agent-hook-registry.ts b/src/main/agent-hooks/managed-agent-hook-registry.ts index 49fdcadda42..a642ef4b527 100644 --- a/src/main/agent-hooks/managed-agent-hook-registry.ts +++ b/src/main/agent-hooks/managed-agent-hook-registry.ts @@ -13,6 +13,7 @@ import { geminiHookService } from '../gemini/hook-service' import { grokHookService } from '../grok/hook-service' import { hermesHookService } from '../hermes/hook-service' import { kimiHookService } from '../kimi/hook-service' +import { museHookService } from '../muse/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' // Why (#16441): Codex's installer awaits a codex app-server trust-grant session @@ -50,7 +51,8 @@ export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[] ['copilot', () => copilotHookService.install()], ['hermes', () => hermesHookService.install()], ['devin', () => devinHookService.install()], - ['kimi', () => kimiHookService.install()] + ['kimi', () => kimiHookService.install()], + ['muse', () => museHookService.install()] ] // Why: covers the shared launcher/statusline scripts under ~/.orca/agent-hooks — the files a @@ -71,7 +73,8 @@ export const MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS: readonly ManagedAgentHookScri ['grok', () => grokHookService.refreshManagedScripts()], ['copilot', () => copilotHookService.refreshManagedScripts()], ['devin', () => devinHookService.refreshManagedScripts()], - ['kimi', () => kimiHookService.refreshManagedScripts()] + ['kimi', () => kimiHookService.refreshManagedScripts()], + ['muse', () => museHookService.refreshManagedScripts()] ] export const MANAGED_AGENT_HOOK_REMOVERS: readonly ManagedAgentHookRemover[] = [ @@ -88,7 +91,8 @@ export const MANAGED_AGENT_HOOK_REMOVERS: readonly ManagedAgentHookRemover[] = [ ['copilot', () => copilotHookService.remove()], ['hermes', () => hermesHookService.remove()], ['devin', () => devinHookService.remove()], - ['kimi', () => kimiHookService.remove()] + ['kimi', () => kimiHookService.remove()], + ['muse', () => museHookService.remove()] ] export const MANAGED_AGENT_HOOK_ASYNC_REMOVERS: readonly ManagedAgentHookAsyncRemover[] = [ @@ -109,5 +113,6 @@ export const MANAGED_AGENT_HOOK_STATUS_READERS: readonly ManagedAgentHookStatusR ['copilot', () => copilotHookService.getStatus()], ['hermes', () => hermesHookService.getStatus()], ['devin', () => devinHookService.getStatus()], - ['kimi', () => kimiHookService.getStatus()] + ['kimi', () => kimiHookService.getStatus()], + ['muse', () => museHookService.getStatus()] ] diff --git a/src/main/agent-hooks/managed-hook-command-contract.test.ts b/src/main/agent-hooks/managed-hook-command-contract.test.ts index a49b45b6df4..38ee5eda19d 100644 --- a/src/main/agent-hooks/managed-hook-command-contract.test.ts +++ b/src/main/agent-hooks/managed-hook-command-contract.test.ts @@ -21,6 +21,7 @@ import { } from '../copilot/copilot-managed-hook-definitions' import { getDevinManagedCommand, getDevinRemoteManagedCommand } from '../devin/hook-settings' import { getGrokManagedCommand } from '../grok/grok-hook-script' +import { getMuseManagedCommand, getMuseRemoteManagedCommand } from '../muse/hook-settings' import { wrapPosixHookCommand, wrapWindowsCmdHookCommand, @@ -141,6 +142,13 @@ const buildersByAgent = new Map([ local: (path) => [wrapPosixHookCommand(path.replaceAll('\\', '/'))], remote: (path) => [wrapPosixHookCommand(path)] } + ], + [ + 'muse', + { + local: (path) => [getMuseManagedCommand(path)], + remote: (path) => [getMuseRemoteManagedCommand(path)] + } ] ]) diff --git a/src/main/agent-hooks/managed-hook-local-filesystem.test.ts b/src/main/agent-hooks/managed-hook-local-filesystem.test.ts index 3213ee0210e..53a84d67381 100644 --- a/src/main/agent-hooks/managed-hook-local-filesystem.test.ts +++ b/src/main/agent-hooks/managed-hook-local-filesystem.test.ts @@ -45,13 +45,13 @@ describe('managed-hook local filesystem', () => { const cold = await installRemoteManagedAgentHooks(filesystem, home, options) const warm = await installRemoteManagedAgentHooks(filesystem, home, options) - expect(cold).toHaveLength(14) + expect(cold).toHaveLength(REMOTE_MANAGED_HOOK_INSTALLER_AGENTS.length) expect(cold.filter((result) => result.state === 'error')).toEqual([]) - expect(warm).toHaveLength(14) + expect(warm).toHaveLength(REMOTE_MANAGED_HOOK_INSTALLER_AGENTS.length) expect(warm.filter((result) => result.state === 'error')).toEqual([]) const files = await listFiles(home) expect(files.filter((path) => path.endsWith('.tmp'))).toEqual([]) - const scripts = files.filter((path) => path.includes(join('.orca', 'agent-hooks'))) + const scripts = files.filter((path) => /\.(?:sh|cmd)$/.test(path)) expect(scripts.length).toBeGreaterThanOrEqual(10) if (process.platform !== 'win32') { for (const script of scripts) { @@ -71,7 +71,7 @@ describe('managed-hook local filesystem', () => { agents: REMOTE_MANAGED_HOOK_INSTALLER_AGENTS }) - expect(results).toHaveLength(14) + expect(results).toHaveLength(REMOTE_MANAGED_HOOK_INSTALLER_AGENTS.length) expect(results.find((result) => result.agent === 'claude')?.state).toBe('error') expect(results.find((result) => result.agent === 'openclaude')?.state).toBe('installed') expect(results.find((result) => result.agent === 'kimi')?.state).toBe('installed') diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts index 2e4cbb06496..62d6c0b9857 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -22,6 +22,7 @@ import { CopilotHookService, copilotHookService } from '../copilot/hook-service' import { HermesHookService, hermesHookService } from '../hermes/hook-service' import { DevinHookService, devinHookService } from '../devin/hook-service' import { KimiHookService, kimiHookService } from '../kimi/hook-service' +import { museHookService } from '../muse/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' import { MANAGED_AGENT_HOOK_INSTALLERS } from './managed-agent-hook-controls' import { @@ -57,10 +58,7 @@ function createFakeSftp(initialFiles: Record = {}): { modes: new Map(), failRenameTo: new Set() } - const noEntryError = (path: string): { code: number; message: string } => ({ - code: 2, - message: `ENOENT ${path}` - }) + const noEntryError = (path: string) => ({ code: 2, message: `ENOENT ${path}` }) const fakeStats = (mode: number): { mode: number } => ({ mode }) const sftp = { @@ -709,7 +707,8 @@ describe('remote hook service installers', () => { ['copilot', copilotHookService], ['hermes', hermesHookService], ['devin', devinHookService], - ['kimi', kimiHookService] + ['kimi', kimiHookService], + ['muse', museHookService] ]) // Guard against a service silently missing from the map above as new agents land. diff --git a/src/main/agent-hooks/remote-managed-hook-installers.ts b/src/main/agent-hooks/remote-managed-hook-installers.ts index a335e8ccc77..c33d303baef 100644 --- a/src/main/agent-hooks/remote-managed-hook-installers.ts +++ b/src/main/agent-hooks/remote-managed-hook-installers.ts @@ -13,6 +13,7 @@ import { droidHookService } from '../droid/hook-service' import { grokHookService } from '../grok/hook-service' import { hermesHookService } from '../hermes/hook-service' import { kimiHookService } from '../kimi/hook-service' +import { museHookService } from '../muse/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' export type RemoteManagedHookInstallOptions = { @@ -72,7 +73,8 @@ const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [ ['droid', (sftp, remoteHome) => droidHookService.installRemote(sftp, remoteHome)], ['hermes', (sftp, remoteHome) => hermesHookService.installRemote(sftp, remoteHome)], ['devin', (sftp, remoteHome) => devinHookService.installRemote(sftp, remoteHome)], - ['kimi', (sftp, remoteHome) => kimiHookService.installRemote(sftp, remoteHome)] + ['kimi', (sftp, remoteHome) => kimiHookService.installRemote(sftp, remoteHome)], + ['muse', (sftp, remoteHome) => museHookService.installRemote(sftp, remoteHome)] ] /** Agents wired into the remote (SSH) hook installer. Exported so an invariant diff --git a/src/main/agent-hooks/server-hook-http-ingest.test.ts b/src/main/agent-hooks/server-hook-http-ingest.test.ts index f89dcebd005..4999c23d2cb 100644 --- a/src/main/agent-hooks/server-hook-http-ingest.test.ts +++ b/src/main/agent-hooks/server-hook-http-ingest.test.ts @@ -80,12 +80,13 @@ describe('AgentHookServer listener replay', () => { const server = new AgentHookServer() await server.start({ env: 'production' }) const order: string[] = [] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: spies on protected AgentHookServer methods that exist on the instance. const internal = server as unknown as { scheduleAssistantMessageRetry: (...args: unknown[]) => void - scheduleCodexSubagentPoll: (...args: unknown[]) => void + scheduleTranscriptPoll: (...args: unknown[]) => void } const originalAssistantRetry = internal.scheduleAssistantMessageRetry.bind(server) - const originalCodexRetry = internal.scheduleCodexSubagentPoll.bind(server) + const originalTranscriptPoll = internal.scheduleTranscriptPoll.bind(server) const assistantRetry = vi .spyOn(internal, 'scheduleAssistantMessageRetry') .mockImplementation((...args) => { @@ -93,10 +94,10 @@ describe('AgentHookServer listener replay', () => { originalAssistantRetry(...args) }) const codexRetry = vi - .spyOn(internal, 'scheduleCodexSubagentPoll') + .spyOn(internal, 'scheduleTranscriptPoll') .mockImplementation((...args) => { order.push('codex-retry') - originalCodexRetry(...args) + originalTranscriptPoll(...args) }) const unsubscribeStatus = server.subscribeStatusChanges(() => order.push('status-change')) server.setListener(() => { @@ -131,12 +132,13 @@ describe('AgentHookServer listener replay', () => { it('fails open after a throwing callback with cache retained and retries skipped', async () => { const server = new AgentHookServer() await server.start({ env: 'production' }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: spies on protected AgentHookServer methods that exist on the instance. const internal = server as unknown as { scheduleAssistantMessageRetry: (...args: unknown[]) => void - scheduleCodexSubagentPoll: (...args: unknown[]) => void + scheduleTranscriptPoll: (...args: unknown[]) => void } const assistantRetry = vi.spyOn(internal, 'scheduleAssistantMessageRetry') - const codexRetry = vi.spyOn(internal, 'scheduleCodexSubagentPoll') + const codexRetry = vi.spyOn(internal, 'scheduleTranscriptPoll') server.setListener(() => { throw new Error('listener failed') }) diff --git a/src/main/agent-hooks/server-muse-session-log-poll.test.ts b/src/main/agent-hooks/server-muse-session-log-poll.test.ts new file mode 100644 index 00000000000..962c6ca429c --- /dev/null +++ b/src/main/agent-hooks/server-muse-session-log-poll.test.ts @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { appendFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { AgentHookServer } from './server' +import type { EnrichedAgentHookEventPayload } from './server/server-types' +import { makePaneKey } from '../../shared/stable-pane-id' + +const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') +const SESSION_ID = '01a0caa3-0e77-7d41-bad7-46283a45633d' +const PROMPT_ID = '01a0caa3-a25a-7810-8229-4de04b2e7ca3' +const QUESTION = { id: 'fav_color', question: 'What is your favorite color?' } + +function sessionLogLine(event: Record): string { + return `${JSON.stringify({ payload: { kind: 'run', event } })}\n` +} + +function createSessionLog(dataHome: string): string { + const date = new Date(Number.parseInt(SESSION_ID.replace(/-/g, '').slice(0, 12), 16)) + const dir = join( + dataHome, + 'muse', + 'sessions', + String(date.getFullYear()), + String(date.getMonth() + 1).padStart(2, '0'), + String(date.getDate()).padStart(2, '0'), + SESSION_ID + ) + mkdirSync(dir, { recursive: true }) + const logPath = join(dir, 'session.jsonl') + writeFileSync(logPath, '') + return logPath +} + +describe('AgentHookServer Muse session log polling', () => { + const dirs: string[] = [] + + afterEach(() => { + vi.unstubAllEnvs() + for (const dir of dirs) { + rmSync(dir, { recursive: true, force: true }) + } + dirs.length = 0 + }) + + // Why: Muse fires no hook for request_user_input, so only the poll can surface the wait and its answer. + it('flips the pane to waiting for a logged question and back once it settles', async () => { + const dataHome = mkdtempSync(join(tmpdir(), 'agent-hook-muse-poll-')) + dirs.push(dataHome) + vi.stubEnv('XDG_DATA_HOME', dataHome) + const logPath = createSessionLog(dataHome) + const server = new AgentHookServer() + const published: EnrichedAgentHookEventPayload[] = [] + server.setListener((event) => published.push(event)) + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/muse`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify({ + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { + hook_event_name: 'UserPromptSubmit', + prompt: 'ask my favorite color', + session_id: SESSION_ID, + turn_id: 'e495d1a5-59aa-47b4-8efb-a1bd75509afc', + cwd: '/tmp/ws', + transcript_path: null, + model: 'muse-spark-1.3', + permission_mode: 'default', + model_provider: 'meta' + } + }) + }) + expect(response.status).toBe(204) + expect(server.getStatusSnapshot()[0]?.state).toBe('working') + expect(published.at(-1)?.hasExplicitPrompt).toBe(true) + + appendFileSync( + logPath, + sessionLogLine({ + kind: 'user_input_prompt_requested', + prompt_id: PROMPT_ID, + tool_name: 'request_user_input', + questions: [QUESTION] + }) + ) + await vi.waitFor( + () => { + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'waiting', + interactivePrompt: JSON.stringify({ questions: [QUESTION] }) + }) + }, + { timeout: 3_000, interval: 50 } + ) + const waiting = published.at(-1) + expect(waiting?.payload.state).toBe('waiting') + expect(waiting?.hasExplicitPrompt).toBeUndefined() + + appendFileSync( + logPath, + sessionLogLine({ kind: 'user_input_prompt_settled', prompt_id: PROMPT_ID }) + ) + await vi.waitFor( + () => { + expect(server.getStatusSnapshot()[0]?.state).toBe('working') + }, + { timeout: 3_000, interval: 50 } + ) + expect(published.at(-1)?.hasExplicitPrompt).toBeUndefined() + expect(server.getStatusSnapshot()[0]?.interactivePrompt).toBeUndefined() + } finally { + server.stop() + } + }) +}) diff --git a/src/main/agent-hooks/server-retired-pane-new-turn.test.ts b/src/main/agent-hooks/server-retired-pane-new-turn.test.ts index 3466cb52af9..4e3c4d997b2 100644 --- a/src/main/agent-hooks/server-retired-pane-new-turn.test.ts +++ b/src/main/agent-hooks/server-retired-pane-new-turn.test.ts @@ -40,7 +40,8 @@ const NEW_TURN_EVENT: Record = { opencode: 'SessionStart', opencode2: 'SessionStart', 'mimo-code': null, - 'command-code': null + 'command-code': null, + muse: 'UserPromptSubmit' } function reviveRetiredPane(source: unknown, hookEventName: string): boolean { diff --git a/src/main/agent-hooks/server/server-authority-aliases.ts b/src/main/agent-hooks/server/server-authority-aliases.ts index 83bd2cd77b7..fb4890e10a2 100644 --- a/src/main/agent-hooks/server/server-authority-aliases.ts +++ b/src/main/agent-hooks/server/server-authority-aliases.ts @@ -226,7 +226,7 @@ export abstract class AgentHookServerAuthorityAliases extends AgentHookServerAut this.promptSentDedupeByPaneKey.set(toPaneKey, promptDedupe) } this.clearAssistantMessageRetry(previousOwnerPaneKey) - this.clearCodexSubagentPoll(previousOwnerPaneKey) + this.clearTranscriptPoll(previousOwnerPaneKey) // Why: the live process keeps posting the physical source key after detach; persist a chain-safe mapping to the current owner. this.legacyPaneKeyAliases.set(physicalPaneKey, { stablePaneKey: toPaneKey, diff --git a/src/main/agent-hooks/server/server-authority-fences.ts b/src/main/agent-hooks/server/server-authority-fences.ts index 192c52495a4..3bd289d9c55 100644 --- a/src/main/agent-hooks/server/server-authority-fences.ts +++ b/src/main/agent-hooks/server/server-authority-fences.ts @@ -52,7 +52,7 @@ export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuth this.markPaneClosedForAgentStatus(key) this.restartedStatusLaunchTokenHashByPaneKey.delete(key) this.clearAssistantMessageRetry(key) - this.clearCodexSubagentPoll(key) + this.clearTranscriptPoll(key) clearPaneCacheState(this.state, key) this.activeHookTurnCompletedAtByPaneKey.delete(key) this.runtimeObservedStatusPaneKeys.delete(key) diff --git a/src/main/agent-hooks/server/server-cleanup.ts b/src/main/agent-hooks/server/server-cleanup.ts index 5425acec7f2..f92e3447866 100644 --- a/src/main/agent-hooks/server/server-cleanup.ts +++ b/src/main/agent-hooks/server/server-cleanup.ts @@ -235,7 +235,7 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey) } this.clearAssistantMessageRetry(resolvedPaneKey) - this.clearCodexSubagentPoll(resolvedPaneKey) + this.clearTranscriptPoll(resolvedPaneKey) this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey) this.currentAuthorityObservations.delete(resolvedPaneKey) if (existing.payload.state === 'done') { diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts index f3f7b625c83..816058e55fd 100644 --- a/src/main/agent-hooks/server/server-lifecycle.ts +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -132,7 +132,7 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv const enriched = this.applyNormalizedStatus(event, normalized.onAccepted) if (enriched) { this.scheduleAssistantMessageRetry(source, aliasedBody, enriched) - this.scheduleCodexSubagentPoll(source, aliasedBody, enriched) + this.scheduleTranscriptPoll(source, aliasedBody, enriched) } } res.writeHead(204) @@ -204,7 +204,7 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv clearTimeout(timer) } this.assistantMessageRetryTimers.clear() - this.clearAllCodexSubagentPolls() + this.clearAllTranscriptPolls() this.endpointDir = null this.endpointFilePathCache = null this.endpointFileWritten = false diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts index b637e8082b8..8ba89b9663b 100644 --- a/src/main/agent-hooks/server/server-state.ts +++ b/src/main/agent-hooks/server/server-state.ts @@ -213,9 +213,9 @@ export abstract class AgentHookServerState { ): EnrichedAgentHookEventPayload | undefined protected abstract emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void protected abstract clearAssistantMessageRetry(paneKey: string): void - protected abstract clearCodexSubagentPoll(paneKey: string): void - protected abstract clearAllCodexSubagentPolls(): void - protected abstract scheduleCodexSubagentPoll( + protected abstract clearTranscriptPoll(paneKey: string): void + protected abstract clearAllTranscriptPolls(): void + protected abstract scheduleTranscriptPoll( source: AgentHookSource, body: unknown, original: EnrichedAgentHookEventPayload diff --git a/src/main/agent-hooks/server/server-status-retries.ts b/src/main/agent-hooks/server/server-status-retries.ts index 4620506b210..4d92b372a35 100644 --- a/src/main/agent-hooks/server/server-status-retries.ts +++ b/src/main/agent-hooks/server/server-status-retries.ts @@ -1,10 +1,13 @@ -import { hasCodexTranscriptSubagents } from '../../../shared/agent-hook-listener/providers/codex-state' import { normalizeHookPayload } from '../../../shared/agent-hook-listener' import { hasPendingAgentResultText, preparePendingGrokResultDiscovery } from '../../../shared/agent-hook-listener/grok-result-discovery' import type { AgentHookSource } from '../../../shared/agent-hook-relay' +import { + shouldPollHookTranscript, + transcriptPollUpdate +} from '../../../shared/agent-hook-listener/transcript-poll-policy' import { CodexSubagentPollScheduler } from '../../../shared/codex-subagent-poll-scheduler' import type { EnrichedAgentHookEventPayload } from './server-types' import { @@ -14,20 +17,20 @@ import { } from './server-constants' import { AgentHookServerStatusUpdate } from './server-status-update' -type CodexSubagentPoll = { +type TranscriptPoll = { source: AgentHookSource body: unknown original: EnrichedAgentHookEventPayload } export abstract class AgentHookServerStatusRetries extends AgentHookServerStatusUpdate { - private readonly codexSubagentPollScheduler = new CodexSubagentPollScheduler( + private readonly transcriptPollScheduler = new CodexSubagentPollScheduler( CODEX_SUBAGENT_POLL_MS, - (paneKey, poll) => this.runCodexSubagentPoll(paneKey, poll) + (paneKey, poll) => this.runTranscriptPoll(paneKey, poll) ) - protected clearAllCodexSubagentPolls(): void { - this.codexSubagentPollScheduler.clearAll() + protected clearAllTranscriptPolls(): void { + this.transcriptPollScheduler.clearAll() } protected clearAssistantMessageRetry(paneKey: string): void { @@ -39,27 +42,27 @@ export abstract class AgentHookServerStatusRetries extends AgentHookServerStatus this.assistantMessageRetryTimers.delete(paneKey) } - protected clearCodexSubagentPoll(paneKey: string): void { - this.codexSubagentPollScheduler.clear(paneKey) + protected clearTranscriptPoll(paneKey: string): void { + this.transcriptPollScheduler.clear(paneKey) } - protected scheduleCodexSubagentPoll( + protected scheduleTranscriptPoll( source: AgentHookSource, body: unknown, original: EnrichedAgentHookEventPayload ): void { - // Why: a nested non-codex CLI inherits ORCA_PANE_KEY, so clearing here would silently end a live codex poll. - if (source !== 'codex') { + // Why: a nested CLI of another kind inherits ORCA_PANE_KEY, so clearing here would silently end a live poll. + if (source !== 'codex' && source !== 'muse') { return } - this.codexSubagentPollScheduler.clear(original.paneKey) - if (!hasCodexTranscriptSubagents(this.state, original.paneKey)) { + this.transcriptPollScheduler.clear(original.paneKey) + if (!shouldPollHookTranscript(this.state, source, original)) { return } - this.codexSubagentPollScheduler.schedule(original.paneKey, { source, body, original }) + this.transcriptPollScheduler.schedule(original.paneKey, { source, body, original }) } - private runCodexSubagentPoll(paneKey: string, poll: CodexSubagentPoll): void { + private runTranscriptPoll(paneKey: string, poll: TranscriptPoll): void { const { source, body, original } = poll // Keep the identity check at callback time: a newer event supersedes this // payload even when its pane still has transcript children. @@ -74,11 +77,10 @@ export abstract class AgentHookServerStatusRetries extends AgentHookServerStatus if (!normalized) { return } - const subagentsChanged = - JSON.stringify(normalized.payload.subagents) !== JSON.stringify(original.payload.subagents) - const next = subagentsChanged ? this.applyNormalizedStatus(normalized) : original + const update = transcriptPollUpdate(source, original, normalized) + const next = update ? this.applyNormalizedStatus(update) : original if (next) { - this.scheduleCodexSubagentPoll(source, body, next) + this.scheduleTranscriptPoll(source, body, next) } } diff --git a/src/main/agent-hooks/server/server-tab-cleanup.ts b/src/main/agent-hooks/server/server-tab-cleanup.ts index a108bdbbd22..599665cc98b 100644 --- a/src/main/agent-hooks/server/server-tab-cleanup.ts +++ b/src/main/agent-hooks/server/server-tab-cleanup.ts @@ -75,7 +75,7 @@ export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup { statusChanged = true } this.clearAssistantMessageRetry(paneKey) - this.clearCodexSubagentPoll(paneKey) + this.clearTranscriptPoll(paneKey) clearPaneCacheState(this.state, paneKey) this.activeHookTurnCompletedAtByPaneKey.delete(paneKey) this.runtimeObservedStatusPaneKeys.delete(paneKey) @@ -109,7 +109,7 @@ export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup { | undefined const hadStatus = previousStatus !== undefined this.clearAssistantMessageRetry(resolvedPaneKey) - this.clearCodexSubagentPoll(resolvedPaneKey) + this.clearTranscriptPoll(resolvedPaneKey) clearPaneCacheState(this.state, resolvedPaneKey) this.activeHookTurnCompletedAtByPaneKey.delete(resolvedPaneKey) this.currentAuthorityObservations.delete(resolvedPaneKey) diff --git a/src/main/ai-vault/remote-session-scanner-muse.test.ts b/src/main/ai-vault/remote-session-scanner-muse.test.ts new file mode 100644 index 00000000000..3d94fc1eee4 --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-muse.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { scanRemoteAiVaultSessions } from './remote-session-scanner' +import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-fixtures' + +describe('scanRemoteAiVaultSessions muse', () => { + it('discovers Muse transcripts under the remote XDG sessions root', async () => { + const provider = new MemoryRemoteProvider() + const sessionDir = '/home/ada/.local/share/muse/sessions/2026/07/04/muse-remote' + provider.addFile( + `${sessionDir}/session.jsonl`, + jsonLines([ + { + record_type: 'event', + payload_type: 'runtime.session.metadata', + recorded_at: 1780000000000000, + payload: { kind: 'metadata', record: { workspace_root: '/home/ada/repo' } } + }, + { + record_type: 'event', + payload_type: 'runtime.user_intent.accepted', + recorded_at: 1780000001000000, + payload: { + intent_id: 'intent-remote', + refill_blocks: [{ kind: 'text', text: 'Remote muse title' }] + } + }, + { + record_type: 'event', + payload_type: 'runtime.session', + recorded_at: 1780000002000000, + payload: { + kind: 'run', + run_id: 'run-remote', + event: { + kind: 'model_completed', + model: 'muse-spark-remote', + usage: { input_tokens: 3, output_tokens: 4 } + } + } + } + ]), + 40 + ) + // Sidecars next to the transcript must not list as sessions. + provider.addFile(`${sessionDir}/cli-abc.log`, 'log output', 41) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ + executionHostId: 'ssh:dev-box', + executionHostPlatform: 'linux', + agent: 'muse', + sessionId: 'muse-remote', + title: 'Remote muse title', + model: 'muse-spark-remote', + filePath: `${sessionDir}/session.jsonl` + }) + }) +}) diff --git a/src/main/ai-vault/remote-session-scanner-source-parsers.ts b/src/main/ai-vault/remote-session-scanner-source-parsers.ts new file mode 100644 index 00000000000..c28b69319c2 --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-source-parsers.ts @@ -0,0 +1,80 @@ +import type { AiVaultSession } from '../../shared/ai-vault-types' +import { parseMessageGraphSessionContent } from './session-scanner-graph-parsers' +import { + parseMuseSessionContent, + parseMuseSessionRemoteContent +} from './session-scanner-muse-parser' +import type { FileWithMtime } from './session-scanner-types' +import { normalizeAgentSessionsDir } from './session-scanner-values' +import type { RemoteSessionContent } from './remote-session-content-lines' +import type { RemoteParserOptions } from './remote-session-scanner-types' + +export function parseMuseRemoteContent( + file: FileWithMtime, + content: RemoteSessionContent, + platform: NodeJS.Platform, + options: RemoteParserOptions, + signal?: AbortSignal +): Promise { + if (typeof content === 'string') { + return Promise.resolve(parseMuseSessionContent(file, content, platform, options)) + } + return parseMuseSessionRemoteContent(file, content, platform, options, signal) +} + +export function piParser( + file: FileWithMtime, + content: RemoteSessionContent, + platform: NodeJS.Platform, + options: RemoteParserOptions, + signal?: AbortSignal +): Promise { + return parseMessageGraphSessionContent('pi', file, content, platform, options, signal) +} + +export function ompParser( + file: FileWithMtime, + content: RemoteSessionContent, + platform: NodeJS.Platform, + options: RemoteParserOptions, + signal?: AbortSignal +): Promise { + return parseMessageGraphSessionContent('omp', file, content, platform, options, signal) +} + +export function primeAgentParser( + file: FileWithMtime, + content: RemoteSessionContent, + platform: NodeJS.Platform, + options: RemoteParserOptions, + signal?: AbortSignal +): Promise { + return parseMessageGraphSessionContent('prime-agent', file, content, platform, options, signal) +} + +export function openClawParser( + file: FileWithMtime, + content: RemoteSessionContent, + platform: NodeJS.Platform, + options: RemoteParserOptions, + signal?: AbortSignal +): Promise { + return parseMessageGraphSessionContent('openclaw', file, content, platform, options, signal) +} + +export function remotePathSegments(path: string): string[] { + return path.replace(/\\/g, '/').split('/').filter(Boolean) +} + +export function remotePiSessionsSegments(): string[] { + return normalizeAgentSessionsDir('/.pi/agent/sessions', '.pi').split('/').filter(Boolean) +} + +export function remoteOmpSessionsSegments(): string[] { + return normalizeAgentSessionsDir('/.omp/agent/sessions', '.omp').split('/').filter(Boolean) +} + +// Remote roots are POSIX regardless of the client platform. +export function remotePrimeAgentSessionsSegments(): string[] { + return ['.prime', 'agent', 'sessions'] +} diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts index 8a44061e463..ed34e8b52a5 100644 --- a/src/main/ai-vault/remote-session-scanner-sources.ts +++ b/src/main/ai-vault/remote-session-scanner-sources.ts @@ -7,7 +7,6 @@ import { parseAntigravitySessionContent } from './session-scanner-antigravity-pa import { isAntigravityTranscriptPath } from './session-scanner-antigravity-paths' import { parseCodexSessionContent } from './session-scanner-codex-parser' import { parseDroidSessionContent } from './session-scanner-droid-parser' -import { parseMessageGraphSessionContent } from './session-scanner-graph-parsers' import { parseClaudeSessionContent } from './session-scanner-primary-parsers' import { parseGeminiSessionContent } from './session-scanner-gemini-parsers' import { parseCopilotSessionContent } from './session-scanner-copilot-parser' @@ -15,8 +14,18 @@ import { parseCursorSessionContent } from './session-scanner-cursor-parser' import { parseHermesSessionContent } from './session-scanner-hermes-parser' import { partitionSubagentTranscriptPaths } from './session-scanner-subagent-transcripts' import { partitionOmpSubagentTranscriptPaths } from './session-scanner-omp-subagent-transcripts' +import { + ompParser, + openClawParser, + parseMuseRemoteContent, + piParser, + primeAgentParser, + remoteOmpSessionsSegments, + remotePathSegments, + remotePiSessionsSegments, + remotePrimeAgentSessionsSegments +} from './remote-session-scanner-source-parsers' import type { FileWithMtime } from './session-scanner-types' -import { normalizeAgentSessionsDir } from './session-scanner-values' import { remoteCodexIndexedTitleReader } from './remote-session-scanner-codex-index' import { remoteClineSource } from './remote-session-scanner-cline-source' import { remoteDevinSource } from './remote-session-scanner-devin-source' @@ -106,6 +115,16 @@ export function remoteSessionSources( remotePrimeAgentSessionsSegments(), primeAgentParser ), + jsonlSource( + 'muse', + remoteHome, + hostPlatform, + ['.local', 'share', 'muse', 'sessions'], + parseMuseRemoteContent, + // Why: each session dir holds session.jsonl plus .log/.sqlite3 sidecars; + // match only the transcript (same predicate as local discovery). + (path) => remotePathSegments(path).at(-1) === 'session.jsonl' + ), jsonlSource( 'droid', remoteHome, @@ -260,62 +279,3 @@ function parserOptions(context: RemoteScannerContext): RemoteParserOptions { executionHostPlatform: context.hostPlatform.os } } - -function piParser( - file: FileWithMtime, - content: RemoteSessionContent, - platform: NodeJS.Platform, - options: RemoteParserOptions, - signal?: AbortSignal -): Promise { - return parseMessageGraphSessionContent('pi', file, content, platform, options, signal) -} - -function ompParser( - file: FileWithMtime, - content: RemoteSessionContent, - platform: NodeJS.Platform, - options: RemoteParserOptions, - signal?: AbortSignal -): Promise { - return parseMessageGraphSessionContent('omp', file, content, platform, options, signal) -} - -function primeAgentParser( - file: FileWithMtime, - content: RemoteSessionContent, - platform: NodeJS.Platform, - options: RemoteParserOptions, - signal?: AbortSignal -): Promise { - return parseMessageGraphSessionContent('prime-agent', file, content, platform, options, signal) -} - -function openClawParser( - file: FileWithMtime, - content: RemoteSessionContent, - platform: NodeJS.Platform, - options: RemoteParserOptions, - signal?: AbortSignal -): Promise { - return parseMessageGraphSessionContent('openclaw', file, content, platform, options, signal) -} - -function remotePathSegments(path: string): string[] { - return path.replace(/\\/g, '/').split('/').filter(Boolean) -} - -function remotePiSessionsSegments(): string[] { - return normalizeAgentSessionsDir('/.pi/agent/sessions', '.pi').split('/').filter(Boolean) -} - -function remoteOmpSessionsSegments(): string[] { - return normalizeAgentSessionsDir('/.omp/agent/sessions', '.omp').split('/').filter(Boolean) -} - -// Why: remote roots are posix regardless of the client platform, so these stay literal -// rather than round-tripping through a local-platform path join that would emit -// backslashes on a Windows client and collapse into a single bogus segment. -function remotePrimeAgentSessionsSegments(): string[] { - return ['.prime', 'agent', 'sessions'] -} diff --git a/src/main/ai-vault/session-scanner-agent-parser.ts b/src/main/ai-vault/session-scanner-agent-parser.ts index 604d1c5231a..7ca808173cf 100644 --- a/src/main/ai-vault/session-scanner-agent-parser.ts +++ b/src/main/ai-vault/session-scanner-agent-parser.ts @@ -6,6 +6,7 @@ import { parseClineSessionFile } from './session-scanner-cline-parser' import { parseGrokSessionFile } from './session-scanner-grok-parser' import { parseMessageGraphSessionFile, parseRovoSessionFile } from './session-scanner-graph-parsers' import { parseKimiSessionFile } from './session-scanner-kimi-parser' +import { parseMuseSessionFile } from './session-scanner-muse-parser' import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths' import { captureOpenCodeSqliteSessionViaWorker, @@ -138,5 +139,7 @@ export async function parseAgentSessionFile( return parseDevinSessionFile(candidate.file, platform, messages) case 'kimi': return parseKimiSessionFile(candidate.file, platform, messages) + case 'muse': + return parseMuseSessionFile(candidate.file, platform, messages) } } diff --git a/src/main/ai-vault/session-scanner-agent-sources.ts b/src/main/ai-vault/session-scanner-agent-sources.ts index bd8f83af9e1..ee10422031b 100644 --- a/src/main/ai-vault/session-scanner-agent-sources.ts +++ b/src/main/ai-vault/session-scanner-agent-sources.ts @@ -12,6 +12,7 @@ import { import { cursorChatMetaPath } from './session-scanner-cursor-chat-meta' import { devinSessionsDbDependencyPath } from './session-scanner-devin-db' import { resolveKimiSessionsDir } from './session-scanner-kimi-paths' +import { resolveMuseSessionsDir } from './session-scanner-muse-paths' import { OMP_SESSION_ARTIFACT_DIR_PATTERN } from './session-scanner-omp-subagent-transcripts' import { claudeProjectsRootDirs, @@ -285,6 +286,20 @@ export const AI_VAULT_AGENT_SOURCES: AiVaultAgentSourceTable = { // only those (not the sibling agents/*/wire.jsonl transcripts). filePredicate: (filePath) => basename(filePath) === 'state.json' && basename(dirname(filePath)).startsWith('session_') + }, + muse: { + rootDirs: (options, wslHomeDirs) => + sessionRootDirs(resolveMuseSessionsDir(options.museSessionsDir), wslHomeDirs, [ + '.local', + 'share', + 'muse', + 'sessions' + ]), + extensions: ['.jsonl'], + // Why: each Muse session is /YYYY/MM/DD//session.jsonl; + // match only those (not sibling .log/.sqlite3 sidecars or the .msp-view + // materialized projection). + filePredicate: (filePath) => basename(filePath) === 'session.jsonl' } } diff --git a/src/main/ai-vault/session-scanner-codex-workers.test.ts b/src/main/ai-vault/session-scanner-codex-workers.test.ts index 487661242da..79271cbf39e 100644 --- a/src/main/ai-vault/session-scanner-codex-workers.test.ts +++ b/src/main/ai-vault/session-scanner-codex-workers.test.ts @@ -218,6 +218,7 @@ describe('scanAiVaultSessions Codex worker sessions', () => { droidSessionsDir: join(root, 'droid-sessions'), droidProjectsDir: join(root, 'droid-projects'), kimiSessionsDir: join(root, 'kimi-sessions'), + museSessionsDir: join(root, 'muse-sessions'), platform: 'darwin' }) diff --git a/src/main/ai-vault/session-scanner-every-agent-fixture.ts b/src/main/ai-vault/session-scanner-every-agent-fixture.ts index 84f69b12699..5ae04c11b57 100644 --- a/src/main/ai-vault/session-scanner-every-agent-fixture.ts +++ b/src/main/ai-vault/session-scanner-every-agent-fixture.ts @@ -1,4 +1,8 @@ -import { isolatedScanRoots, writeOpenCode2SqliteFixture } from './session-scanner-test-fixtures' +import { + isolatedScanRoots, + writeMuseScannerFixture, + writeOpenCode2SqliteFixture +} from './session-scanner-test-fixtures' import { writeDocumentAgentFixtures } from './session-scanner-document-agent-fixtures' import { writeLogAgentFixtures } from './session-scanner-log-agent-fixtures' @@ -28,6 +32,7 @@ export async function writeEveryAgentVault(root: string): Promise | null +} + +// Why: `recorded_at` is microseconds since epoch; the shared timeline helpers +// take milliseconds (or ISO strings), so convert here. Values below the +// microsecond floor fall through to the shared parser (seconds/ISO). +function museTimestampMs(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value) && value >= 1e14) { + return Math.floor(value / 1000) + } + const parsed = timestampMs(value) + return Number.isFinite(parsed) ? parsed : null +} + +function unwrapMuseRecords(line: string): MuseRecord[] { + const envelope = parseJsonObject(line) + if (!envelope) { + return [] + } + // Why: retention markers (`retained_marker: omitted_live_only`) stand in for + // ephemeral records excluded from the retained log — no payload to fold. + return unwrapMuseLogRecords(envelope).map((record) => ({ + recordType: extractString(record.record_type), + payloadType: extractString(record.payload_type), + recordedAtMs: museTimestampMs(record.recorded_at), + payload: asRecord(record.payload) + })) +} + +function firstTextBlock(value: unknown): string | null { + for (const block of arrayValue(value)) { + const text = extractString(asRecord(block)?.text) + if (text) { + return text + } + } + return null +} + +// Why: user intent arrives either as `refill_blocks` text blocks or nested +// `model_messages[].content[]` blocks; both shapes carry the same prompt. +function userIntentText(payload: Record): string | null { + return ( + firstTextBlock(payload.refill_blocks) ?? + (() => { + for (const message of arrayValue(payload.model_messages)) { + const text = firstTextBlock(asRecord(message)?.content) + if (text) { + return text + } + } + return null + })() + ) +} + +function foldUserTurn( + accumulator: SessionAccumulator, + text: string | null, + timestampMs: number | null, + dedupe: { text: string | null; ms: number | null }, + // Why: each turn emits both `runtime.user_intent.accepted` and a `run :: + // started` carrying the same prompt ~ms apart — folding both double-counts + // turns and evicts real rows from the 5-message preview window. The intent + // record always folds; `run.started` is the fallback for logs missing intent + // records, so only it dedupes (a deliberately repeated prompt still counts). + skipIfDuplicate: boolean +): void { + if (!text) { + return + } + if ( + skipIfDuplicate && + dedupe.text === text && + dedupe.ms !== null && + timestampMs !== null && + Math.abs(timestampMs - dedupe.ms) < 60_000 + ) { + return + } + dedupe.text = text + dedupe.ms = timestampMs + accumulator.messageCount++ + const titleCandidate = normalizeTitleText(text) + if (titleCandidate) { + accumulator.title ??= titleCandidate + } + addPreviewContent(accumulator, 'user', text, timestampMs ?? undefined) +} + +function foldMuseRecord( + accumulator: SessionAccumulator, + record: MuseRecord, + dedupe: { text: string | null; ms: number | null } +): void { + if (record.recordedAtMs !== null) { + updateTimeline(accumulator, record.recordedAtMs) + } + const payload = record.payload + if (!payload) { + return + } + switch (record.payloadType) { + case 'runtime.session.metadata': { + // Why: the representative cwd is the session's start directory; later + // drift must not move history grouping or the resume `cd` prefix. + accumulator.cwd ??= extractString(asRecord(payload.record)?.workspace_root) + break + } + case 'runtime.session.route_facts': { + // Newer Muse logs carry the execution cwd in route facts as well as + // metadata; retain it as a fallback for partially written sessions. + accumulator.cwd ??= extractString(asRecord(payload.record)?.cwd) + break + } + case 'session.workspace_branch.observed': { + const reference = asRecord(asRecord(payload.record)?.reference) + accumulator.branch ??= extractString(reference?.name) + break + } + case 'run.model.configured': { + accumulator.model ??= extractString(asRecord(payload.record)?.model_id) + break + } + case 'runtime.user_intent.accepted': { + foldUserTurn(accumulator, userIntentText(payload), record.recordedAtMs, dedupe, false) + break + } + case 'runtime.session': { + foldSessionEvent(accumulator, payload, record.recordedAtMs, dedupe) + break + } + case null: + default: + break + } +} + +function foldSessionEvent( + accumulator: SessionAccumulator, + payload: Record, + timestampMs: number | null, + dedupe: { text: string | null; ms: number | null } +): void { + const event = asRecord(payload.event) + if (!event) { + return + } + switch (event.kind) { + case 'started': { + foldUserTurn(accumulator, extractString(event.prompt), timestampMs, dedupe, true) + break + } + case 'assistant_message_committed': { + const text = extractString(event.text) + if (text) { + accumulator.messageCount++ + addPreviewContent(accumulator, 'assistant', text, timestampMs ?? undefined) + } + break + } + case 'model_completed': { + const usage = asRecord(event.usage) + accumulator.totalTokens += + numberValue(usage?.input_tokens) + numberValue(usage?.output_tokens) + accumulator.model ??= extractString(event.model) + break + } + case null: + default: + break + } +} + +type MuseDedupeState = { text: string | null; ms: number | null } + +function foldMuseContent(accumulator: SessionAccumulator, content: string): void { + foldMuseLines(accumulator, content.split('\n')) +} + +function foldMuseLines( + accumulator: SessionAccumulator, + lines: Iterable, + dedupe: MuseDedupeState = { text: null, ms: null } +): void { + for (const line of lines) { + if (!line.trim()) { + continue + } + for (const record of unwrapMuseRecords(line)) { + foldMuseRecord(accumulator, record, dedupe) + } + } +} + +/** Parses remote transcript chunks without requiring the scanner to buffer the file. */ +export async function parseMuseSessionRemoteContent( + file: FileWithMtime, + content: RemoteSessionContent, + platform: NodeJS.Platform = process.platform, + options: ParserSessionOptions = {}, + signal?: AbortSignal +): Promise { + const accumulator = createAccumulator({ + agent: 'muse', + file, + sessionId: museSessionIdFromFilePath(file.path) + }) + const lines = remoteSessionContentLines(content, signal) + const dedupe: MuseDedupeState = { text: null, ms: null } + for await (const line of lines) { + foldMuseLines(accumulator, [line], dedupe) + } + return finalizeSession(accumulator, platform, options) +} + +export async function parseMuseSessionFile( + file: FileWithMtime, + platform: NodeJS.Platform = process.platform, + messages?: TranscriptMessageSink +): Promise { + return parseMuseSessionContent( + file, + await wslGatedReadFile(file.path, 'utf-8', 'scan'), + platform, + {}, + messages + ) +} + +export function parseMuseSessionContent( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform = process.platform, + options: ParserSessionOptions = {}, + messages?: TranscriptMessageSink +): AiVaultSession | null { + const accumulator = createAccumulator({ + agent: 'muse', + file, + sessionId: museSessionIdFromFilePath(file.path), + messages + }) + foldMuseContent(accumulator, content) + return finalizeSession(accumulator, platform, options) +} diff --git a/src/main/ai-vault/session-scanner-muse-paths.ts b/src/main/ai-vault/session-scanner-muse-paths.ts new file mode 100644 index 00000000000..7810571f23c --- /dev/null +++ b/src/main/ai-vault/session-scanner-muse-paths.ts @@ -0,0 +1,9 @@ +import { basename, dirname } from 'node:path' + +export { resolveMuseSessionsDir } from '../../shared/muse-session-log' + +// Layout: /YYYY/MM/DD//session.jsonl — the session id is the +// parent directory name (the basename is always the fixed `session.jsonl`). +export function museSessionIdFromFilePath(filePath: string): string { + return basename(dirname(filePath)) +} diff --git a/src/main/ai-vault/session-scanner-muse.test.ts b/src/main/ai-vault/session-scanner-muse.test.ts new file mode 100644 index 00000000000..568f5c19e04 --- /dev/null +++ b/src/main/ai-vault/session-scanner-muse.test.ts @@ -0,0 +1,80 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { scanAiVaultSessions } from './session-scanner' +import { parseMuseSessionContent } from './session-scanner-muse-parser' +import { + isolatedScanRoots, + jsonLines, + writeMuseScannerFixture +} from './session-scanner-test-fixtures' +import type { TranscriptMessage } from './session-transcript-consumers' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +describe('scanAiVaultSessions muse', () => { + it('indexes Muse envelopes with title, model, tokens, and resume command', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-muse-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const sessionFile = await writeMuseScannerFixture(roots.museSessionsDir) + + const result = await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 20 }) + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(1) + const session = result.sessions[0] + expect(session.agent).toBe('muse') + expect(session.sessionId).toBe('muse-session') + expect(session.title).toBe('Muse vault title') + expect(session.cwd).toBe('/tmp/muse') + expect(session.model).toBe('muse-spark-test') + expect(session.totalTokens).toBe(15) + expect(session.messageCount).toBe(2) + expect(session.filePath).toBe(sessionFile) + expect(session.resumeCommand).toBe("cd '/tmp/muse' && muse resume 'muse-session'") + }) + + it('publishes user and assistant turns to transcript consumers', () => { + const messages: TranscriptMessage[] = [] + const session = parseMuseSessionContent( + { + path: '/tmp/muse-sessions/2026/05/01/muse-capture/session.jsonl', + mtimeMs: 1780000003000, + modifiedAt: '2026-05-01T10:00:03.000Z' + }, + jsonLines([ + { + record_type: 'event', + payload_type: 'runtime.user_intent.accepted', + recorded_at: 1780000000000000, + payload: { refill_blocks: [{ kind: 'text', text: 'Capture this prompt' }] } + }, + { + record_type: 'event', + payload_type: 'runtime.session', + recorded_at: 1780000001000000, + payload: { + kind: 'run', + event: { kind: 'assistant_message_committed', text: 'Captured reply' } + } + } + ]), + 'darwin', + {}, + { active: true, push: (message) => messages.push(message) } + ) + + expect(session?.messageCount).toBe(2) + expect(messages).toEqual([ + { role: 'user', text: 'Capture this prompt', timestamp: '2026-05-28T20:26:40.000Z' }, + { role: 'assistant', text: 'Captured reply', timestamp: '2026-05-28T20:26:41.000Z' } + ]) + }) +}) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts index 6a376f7bd85..ff51677d27f 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts @@ -53,6 +53,7 @@ function isolatedScanRoots(root: string) { droidSessionsDir: join(root, 'droid-sessions'), droidProjectsDir: join(root, 'droid-projects'), kimiSessionsDir: join(root, 'kimi-sessions'), + museSessionsDir: join(root, 'muse-sessions'), ompSessionsDir: join(root, 'omp-sessions'), primeAgentSessionsDir: join(root, 'prime-agent-sessions') } diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index 9a4ffc9b7ea..a5eb98235e4 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -78,6 +78,7 @@ function resumableStateFactoryFor( case 'hermes': case 'cline': case 'kimi': + case 'muse': case 'opencode': case 'opencode2': case 'rovo': diff --git a/src/main/ai-vault/session-scanner-test-fixtures.ts b/src/main/ai-vault/session-scanner-test-fixtures.ts index ddd81effcb0..7146d237bf7 100644 --- a/src/main/ai-vault/session-scanner-test-fixtures.ts +++ b/src/main/ai-vault/session-scanner-test-fixtures.ts @@ -75,7 +75,8 @@ export function isolatedScanRoots(root: string) { droidSessionsDir: join(root, 'droid-sessions'), droidProjectsDir: join(root, 'droid-projects'), clineSessionsDir: join(root, 'cline-sessions'), - kimiSessionsDir: join(root, 'kimi-sessions') + kimiSessionsDir: join(root, 'kimi-sessions'), + museSessionsDir: join(root, 'muse-sessions') } } @@ -190,3 +191,76 @@ export function writeAntigravityScannerFixture( } ]) } + +// Muse sessions are date-sharded /YYYY/MM/DD//session.jsonl +// envelopes mixing bare records, retained_frame envelopes, and +// omitted_live_only retention markers (verified against muse 1.0.3). +export async function writeMuseScannerFixture(sessionsDir: string): Promise { + const sessionFile = join(sessionsDir, '2026', '05', '01', 'muse-session', 'session.jsonl') + const bare = (payloadType: string, payload: unknown, recordedAt: number) => ({ + record_type: 'event', + payload_type: payloadType, + recorded_at: recordedAt, + payload + }) + await writeJsonlFile(sessionFile, [ + bare( + 'runtime.session.metadata', + { kind: 'metadata', record: { workspace_root: '/tmp/muse', provider_id: 'meta' } }, + 1780000000000000 + ), + bare( + 'runtime.user_intent.accepted', + { intent_id: 'intent-1', refill_blocks: [{ kind: 'text', text: 'Muse vault title' }] }, + 1780000001000000 + ), + // Why: every turn also emits `run :: started` carrying the same prompt — + // the parser must fold it once (messageCount stays 2 below). + bare( + 'runtime.session', + { kind: 'run', run_id: 'run-1', event: { kind: 'started', prompt: 'Muse vault title' } }, + 1780000001000007 + ), + { + retained_frame: true, + frame_schema_version: 1, + outer_log_ordinal: 3, + transaction_id: 'txn-1', + children: [ + { + child_index: 0, + record_json: JSON.stringify( + bare( + 'runtime.session', + { + kind: 'run', + run_id: 'run-1', + event: { kind: 'assistant_message_committed', text: 'Muse answer' } + }, + 1780000002000000 + ) + ) + } + ] + }, + bare( + 'runtime.session', + { + kind: 'run', + run_id: 'run-1', + event: { + kind: 'model_completed', + model: 'muse-spark-test', + usage: { input_tokens: 10, output_tokens: 5 } + } + }, + 1780000003000000 + ), + { + retained_marker: 'omitted_live_only', + schema_version: 1, + stream: { kind: 'session', id: 'muse-session' } + } + ]) + return sessionFile +} diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index 6845ec5b6b7..f8838078053 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -40,6 +40,7 @@ export type AiVaultScanOptions = { droidProjectsDir?: string clineSessionsDir?: string kimiSessionsDir?: string + museSessionsDir?: string limit?: number unlimited?: boolean limitPerAgent?: number diff --git a/src/main/ai-vault/session-scanner.test.ts b/src/main/ai-vault/session-scanner.test.ts index c64a4987b85..b8963ce80b8 100644 --- a/src/main/ai-vault/session-scanner.test.ts +++ b/src/main/ai-vault/session-scanner.test.ts @@ -4,7 +4,11 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { AI_VAULT_AGENTS } from '../../shared/ai-vault-types' import { scanAiVaultSessions } from './session-scanner' -import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures' +import { + isolatedScanRoots, + jsonLines, + writeMuseScannerFixture +} from './session-scanner-test-fixtures' import { writeEveryAgentVault } from './session-scanner-every-agent-fixture' // Why: the SQLite worker bundle does not exist in the test runtime; route the @@ -394,6 +398,7 @@ describe('scanAiVaultSessions', () => { tempRoots.push(root) const { roots, antigravitySessionId, ompSessionFile, primeAgentSessionFile } = await writeEveryAgentVault(root) + await writeMuseScannerFixture(roots.museSessionsDir) const result = await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 20 }) @@ -443,6 +448,7 @@ describe('scanAiVaultSessions', () => { expect(commandByAgent.get('cline')).toBe("cd '/tmp/cline' && cline --id 'cline-session'") expect(commandByAgent.get('devin')).toBe("cd '/tmp/devin' && devin --resume 'devin-session'") expect(commandByAgent.get('droid')).toBe("cd '/tmp/droid' && droid --resume 'droid-session'") + expect(commandByAgent.get('muse')).toBe("cd '/tmp/muse' && muse resume 'muse-session'") expect(commandByAgent.get('kimi')).toBe( "cd '/tmp/kimi' && kimi --session 'session_kimi-session'" ) diff --git a/src/main/muse/hook-config-json.test.ts b/src/main/muse/hook-config-json.test.ts new file mode 100644 index 00000000000..15cf94cf1cb --- /dev/null +++ b/src/main/muse/hook-config-json.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { + MUSE_MANAGED_HOOK_ENV_VARS, + parseMuseSettingsText, + serializeMuseSettings +} from './hook-config-json' + +describe('muse hook-config-json', () => { + it('creates a fresh settings file with the required schema_version', () => { + const parsed = parseMuseSettingsText(serializeMuseSettings(null, '/x/muse-hooks.json'), 'test') + expect(parsed?.managed_hooks_env_vars).toEqual(MUSE_MANAGED_HOOK_ENV_VARS) + }) + + it('sets the pointer while preserving user keys and formatting', () => { + const original = '{\n "schema_version": 1,\n "model": "muse-spark-1.2"\n}\n' + const next = serializeMuseSettings(original, '/x/muse-hooks.json') + expect(next).toContain('"model": "muse-spark-1.2"') + expect(JSON.parse(next)).toMatchObject({ + schema_version: 1, + managed_hooks_path: '/x/muse-hooks.json' + }) + }) + + it('removes the pointer on remove while keeping user keys', () => { + const original = + '{\n "schema_version": 1,\n "managed_hooks_path": "/x/muse-hooks.json",\n "model": "muse-spark-1.2"\n}\n' + const next = serializeMuseSettings(original, undefined) + const parsed = parseMuseSettingsText(next, 'test') + expect(parsed?.managed_hooks_path).toBeUndefined() + expect(parsed?.model).toBe('muse-spark-1.2') + expect(parsed?.schema_version).toBe(1) + }) + + it('leaves already-converged text untouched', () => { + const original = JSON.stringify({ + schema_version: 1, + managed_hooks_path: '/x/muse-hooks.json', + managed_hooks_env_vars: MUSE_MANAGED_HOOK_ENV_VARS + }) + expect(serializeMuseSettings(original, '/x/muse-hooks.json')).toBe(original) + }) + + it('rejects malformed settings text', () => { + expect(parseMuseSettingsText('{oops', 'test')).toBeNull() + expect(parseMuseSettingsText('[1,2]', 'test')).toBeNull() + }) +}) diff --git a/src/main/muse/hook-config-json.ts b/src/main/muse/hook-config-json.ts new file mode 100644 index 00000000000..076f7f3dbbc --- /dev/null +++ b/src/main/muse/hook-config-json.ts @@ -0,0 +1,104 @@ +import { readFileSync } from 'node:fs' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' +import { applyEdits, modify, parse as parseJsonc, type ParseError } from 'jsonc-parser' +import { isPlainObject } from '../agent-hooks/installer-utils' + +// Muse strips nonstandard environment variables from managed hooks unless allowlisted. +export const MUSE_MANAGED_HOOK_ENV_VARS = [ + 'ORCA_AGENT_HOOK_PORT', + 'ORCA_AGENT_HOOK_TOKEN', + 'ORCA_AGENT_HOOK_ENV', + 'ORCA_AGENT_HOOK_VERSION', + 'ORCA_AGENT_HOOK_TRANSPORT', + 'ORCA_AGENT_HOOK_ENDPOINT', + 'ORCA_PANE_KEY', + 'ORCA_TAB_ID', + 'ORCA_WORKTREE_ID', + 'ORCA_AGENT_LAUNCH_TOKEN', + // Why: Windows cmd AutoRun scripts commonly live under %USERPROFILE%; without it every hook exits 1. + 'USERPROFILE' +] as const + +export type MuseSettingsSource = { + text: string | null + config: Record +} + +export function parseMuseSettingsText( + text: string, + diagnosticName: string +): Record | null { + const errors: ParseError[] = [] + const parsed = parseJsonc(text, errors) + if (errors.length > 0) { + console.warn( + `Could not parse ${diagnosticName}: ${errors.map((e) => `offset ${e.offset} length ${e.length}`).join(', ')}` + ) + return null + } + if (parsed === undefined) { + return {} + } + return isPlainObject(parsed) ? parsed : null +} + +export function readMuseSettingsSource(configPath: string): MuseSettingsSource | null { + let text: string + try { + text = readFileSync(configPath, 'utf-8') + } catch (error) { + return isDefinitiveAbsence(error) ? { text: null, config: {} } : null + } + const config = parseMuseSettingsText(text, 'Muse settings.json') + return config === null ? null : { text, config } +} + +export function serializeMuseSettings( + originalText: string | null, + managedHooksPath: string | undefined +): string { + if (originalText === null) { + // Why: a fresh settings.json needs `"schema_version": 1` or every muse + // command fails with `malformed settings file`. + const config: Record = { schema_version: 1 } + if (managedHooksPath !== undefined) { + config.managed_hooks_path = managedHooksPath + config.managed_hooks_env_vars = MUSE_MANAGED_HOOK_ENV_VARS + } + return `${JSON.stringify(config, null, 2)}\n` + } + let text = originalText + const parsed = parseMuseSettingsText(originalText, 'Muse settings.json') + if (parsed?.schema_version === undefined) { + text = applyEdits( + text, + modify(text, ['schema_version'], 1, { formattingOptions: { insertSpaces: true, tabSize: 2 } }) + ) + } + const current = parseMuseSettingsText(text, 'Muse settings.json') + if (current?.managed_hooks_path !== managedHooksPath) { + text = applyEdits( + text, + // Why: `undefined` removes the key, which is how remove() drops the pointer. + modify(text, ['managed_hooks_path'], managedHooksPath, { + formattingOptions: { insertSpaces: true, tabSize: 2 } + }) + ) + } + if (managedHooksPath !== undefined) { + const existing = current?.managed_hooks_env_vars + const names = Array.isArray(existing) + ? existing.filter((value): value is string => typeof value === 'string') + : [] + const nextNames = [...new Set([...names, ...MUSE_MANAGED_HOOK_ENV_VARS])] + if (JSON.stringify(existing) !== JSON.stringify(nextNames)) { + text = applyEdits( + text, + modify(text, ['managed_hooks_env_vars'], nextNames, { + formattingOptions: { insertSpaces: true, tabSize: 2 } + }) + ) + } + } + return text +} diff --git a/src/main/muse/hook-service.test.ts b/src/main/muse/hook-service.test.ts new file mode 100644 index 00000000000..af04fc839e1 --- /dev/null +++ b/src/main/muse/hook-service.test.ts @@ -0,0 +1,140 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { parseMuseSettingsText } from './hook-config-json' +import { MuseHookService } from './hook-service' +import { MUSE_HOOK_EVENTS } from './hook-settings' + +// Why: getSharedManagedScriptPath() writes under homedir()/.orca and the +// Muse config resolves via XDG_CONFIG_HOME ?? ~/.config/muse. Point HOME +// at a temp dir and clear XDG_CONFIG_HOME so install/remove never touches the +// real ~/.orca or ~/.config/muse. os.homedir() resolves $HOME on POSIX. +let home: string +let originalHome: string | undefined +let originalXdg: string | undefined + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'orca-muse-hook-')) + originalHome = process.env.HOME + originalXdg = process.env.XDG_CONFIG_HOME + process.env.HOME = home + delete process.env.XDG_CONFIG_HOME +}) + +afterEach(() => { + if (originalHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = originalHome + } + if (originalXdg === undefined) { + delete process.env.XDG_CONFIG_HOME + } else { + process.env.XDG_CONFIG_HOME = originalXdg + } + rmSync(home, { recursive: true, force: true }) +}) + +const configPath = (): string => join(home, '.config', 'muse', 'settings.json') +const managedHooksPath = (): string => join(home, '.orca', 'agent-hooks', 'muse-hooks.json') +const scriptPath = (): string => join(home, '.orca', 'agent-hooks', 'muse-hook.sh') + +describe('MuseHookService', () => { + it('reports not_installed before install', () => { + expect(new MuseHookService().getStatus().state).toBe('not_installed') + }) + + it('installs the managed hooks pointer, file, and script', () => { + const status = new MuseHookService().install() + expect(status.state).toBe('installed') + expect(status.managedHooksPresent).toBe(true) + + // The settings pointer aims at the Orca-owned managed file, and a fresh + // settings.json carries the schema_version muse requires. + const settings = parseMuseSettingsText(readFileSync(configPath(), 'utf-8'), 'test') + expect(settings?.managed_hooks_path).toBe(managedHooksPath()) + expect(settings?.schema_version).toBe(1) + expect(settings?.managed_hooks_env_vars).toContain('ORCA_PANE_KEY') + + const managedText = readFileSync(managedHooksPath(), 'utf-8') + expect(managedText).toContain('agent-hooks/muse-hook.sh') + expect(MUSE_HOOK_EVENTS.every((event) => managedText.includes(`"${event}"`))).toBe(true) + // The managed script must exist and POST to the muse hook endpoint. + const script = readFileSync(scriptPath(), 'utf-8') + expect(script).toContain('/hook/muse') + // Why: payload is piped to curl via stdin so it never lands on the curl + // command line (EDR oversized-command-line false positive). + expect(script).toContain('printf \'%s\' "$payload" | curl') + }) + + it('keeps user settings when installing, then drops only the pointer on remove', () => { + mkdirSync(join(home, '.config', 'muse'), { recursive: true }) + const userSettings = `{\n "schema_version": 1,\n "model": "muse-spark-1.2",\n "approval_mode": "never"\n}\n` + writeFileSync(configPath(), userSettings) + + const service = new MuseHookService() + expect(service.install().state).toBe('installed') + + const installed = readFileSync(configPath(), 'utf-8') + expect(installed).toContain('"model": "muse-spark-1.2"') + expect(installed).toContain('"approval_mode": "never"') + + // Reinstall must converge without duplicating the pointer. + service.install() + const reinstalled = readFileSync(configPath(), 'utf-8') + expect((reinstalled.match(/managed_hooks_path/g) ?? []).length).toBe(1) + + const removed = service.remove() + expect(removed.state).toBe('not_installed') + const afterRemove = parseMuseSettingsText(readFileSync(configPath(), 'utf-8'), 'test') + expect(afterRemove?.managed_hooks_path).toBeUndefined() + expect(afterRemove?.model).toBe('muse-spark-1.2') + }) + + it('reports not_installed when the pointer aims elsewhere', () => { + mkdirSync(join(home, '.config', 'muse'), { recursive: true }) + writeFileSync( + configPath(), + JSON.stringify({ schema_version: 1, managed_hooks_path: '/central/hooks.json' }) + ) + const status = new MuseHookService().getStatus() + expect(status.state).toBe('not_installed') + expect(status.detail).toContain('/central/hooks.json') + }) + + it('does not overwrite a user-managed hooks pointer during install', () => { + mkdirSync(join(home, '.config', 'muse'), { recursive: true }) + const userPath = '/user-owned/muse-hooks.json' + writeFileSync(configPath(), JSON.stringify({ schema_version: 1, managed_hooks_path: userPath })) + const status = new MuseHookService().install() + expect(status.state).toBe('not_installed') + expect(status.detail).toContain(userPath) + expect( + parseMuseSettingsText(readFileSync(configPath(), 'utf-8'), 'test')?.managed_hooks_path + ).toBe(userPath) + }) + + it('treats malformed managed hook entries as absent instead of throwing', () => { + mkdirSync(join(home, '.config', 'muse'), { recursive: true }) + mkdirSync(join(home, '.orca', 'agent-hooks'), { recursive: true }) + const managedPath = join(home, '.orca', 'agent-hooks', 'muse-hooks.json') + writeFileSync(configPath(), JSON.stringify({ schema_version: 1 })) + const service = new MuseHookService() + expect(service.install().state).toBe('installed') + // Hand-edited damage: null definition, non-array hooks, null entry, + // non-string command — status must degrade, never throw. + const damaged = parseMuseSettingsText(readFileSync(managedPath, 'utf-8'), 'test') + expect(damaged).not.toBeNull() + if (!damaged) { + throw new Error('expected generated Muse hooks') + } + damaged.hooks = { + ...(typeof damaged.hooks === 'object' && damaged.hooks !== null ? damaged.hooks : {}), + UserPromptSubmit: [null, { hooks: 'not-an-array' }, { hooks: [null, { command: 42 }] }] + } + writeFileSync(managedPath, JSON.stringify(damaged)) + expect(() => service.getStatus()).not.toThrow() + expect(service.getStatus().state).toBe('partial') + }) +}) diff --git a/src/main/muse/hook-service.ts b/src/main/muse/hook-service.ts new file mode 100644 index 00000000000..130e89fe0ca --- /dev/null +++ b/src/main/muse/hook-service.ts @@ -0,0 +1,310 @@ +import { existsSync, readFileSync, unlinkSync } from 'node:fs' +import type { SFTPWrapper } from 'ssh2' + +// Muse runs managed hooks with an explicit environment allowlist. The installer +// persists Orca's hook coordinates in that allowlist so events stay attributed. +import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { + buildWindowsAgentHookCurlPostCommand, + writeHooksJson, + writeManagedScript +} from '../agent-hooks/installer-utils' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' +import { + readTextFileRemote, + writeManagedScriptRemote, + writeTextFileRemoteAtomic +} from '../agent-hooks/installer-utils-remote' +import { + buildPosixHookPayloadCapture, + buildPosixHookSpoolLines, + buildWindowsHookEnvironmentGuardLines, + buildWindowsHookStdinDrainEpilogue +} from '../agent-hooks/hook-stdin-contract' +import { buildPosixAgentHookPostCommand } from '../agent-hooks/hook-post-command' +import { + buildMuseManagedHooksFile, + getMuseConfigPath, + getMuseManagedCommand, + getMuseManagedCommandMatcher, + getMuseManagedHooksPath, + getMuseManagedScriptPath, + getMuseRemoteConfigPath, + getMuseRemoteManagedCommand, + getMuseRemoteManagedHooksPath, + MUSE_HOOK_EVENTS, + readManagedMuseHookEvents +} from './hook-settings' +import { + MUSE_MANAGED_HOOK_ENV_VARS, + parseMuseSettingsText, + readMuseSettingsSource, + serializeMuseSettings +} from './hook-config-json' + +function getManagedScript(target: 'local' | 'posix' = 'local'): string { + if (target === 'local' && process.platform === 'win32') { + return [ + '@echo off', + 'setlocal', + 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', + ...buildWindowsHookEnvironmentGuardLines(), + buildWindowsAgentHookCurlPostCommand('muse'), + 'exit /b 0', + ...buildWindowsHookStdinDrainEpilogue(), + '' + ].join('\r\n') + } + + return [ + '#!/bin/sh', + ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('muse'), + // Why: endpoint file holds the live port/token; PTYs that outlive an Orca restart carry stale env, so source it to reach the new server (else PTY env). + // Why: silence the `.` builtin (2>/dev/null + `|| :`) so a TOCTOU race can't leak shell parse errors into agent transcripts (fail-open). + 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', + ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', + 'fi', + 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', + ' exit 0', + 'fi', + // Why: redirect on `fi` covers the whole if-statement (both transport branches); `|| spool` keeps the fail-open spool fallback. + ...buildPosixAgentHookPostCommand('muse').map((line, index, lines) => + index === lines.length - 1 ? `${line} >/dev/null 2>&1 || spool_hook_event` : line + ), + 'exit 0', + '' + ].join('\n') +} + +function readManagedHooksFile(managedHooksPath: string): string | null { + if (!existsSync(managedHooksPath)) { + return '' + } + try { + return readFileSync(managedHooksPath, 'utf-8') + } catch { + return null + } +} + +function buildStatus( + config: Record, + pointer: string | undefined, + managedHooksPath: string, + managedText: string | null, + configPath: string +): AgentHookInstallStatus { + const base = { agent: 'muse' as const, configPath } + if (managedText === null) { + return { + ...base, + state: 'error', + managedHooksPresent: false, + detail: 'Could not read Orca managed hooks file' + } + } + if (pointer !== managedHooksPath) { + return { + ...base, + state: 'not_installed', + managedHooksPresent: false, + detail: + pointer === undefined + ? null + : `managed_hooks_path points at ${pointer}, not the Orca managed hooks file` + } + } + const parsed = parseMuseSettingsText(managedText, 'Orca managed Muse hooks') + const present = readManagedMuseHookEvents(parsed, getMuseManagedCommandMatcher()) + const missing = MUSE_HOOK_EVENTS.filter((event) => !present.has(event)) + const configuredEnv = Array.isArray(config.managed_hooks_env_vars) + ? config.managed_hooks_env_vars.filter((value): value is string => typeof value === 'string') + : [] + const missingEnv = MUSE_MANAGED_HOOK_ENV_VARS.filter((name) => !configuredEnv.includes(name)) + let state: AgentHookInstallState + let detail: string | null + if (missing.length === 0 && missingEnv.length === 0) { + state = 'installed' + detail = null + } else if (present.size === 0) { + state = 'not_installed' + detail = null + } else { + state = 'partial' + detail = [ + missing.length > 0 ? `events: ${missing.join(', ')}` : null, + missingEnv.length > 0 ? `environment variables: ${missingEnv.join(', ')}` : null + ] + .filter(Boolean) + .join('; ') + } + return { ...base, state, managedHooksPresent: present.size > 0, detail } +} + +export class MuseHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getMuseManagedScriptPath(), getManagedScript()) + } + + getStatus(): AgentHookInstallStatus { + const configPath = getMuseConfigPath() + const managedHooksPath = getMuseManagedHooksPath() + const source = readMuseSettingsSource(configPath) + if (!source) { + return { + agent: 'muse', + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Could not read Muse settings.json' + } + } + const pointer = + typeof source.config.managed_hooks_path === 'string' + ? source.config.managed_hooks_path + : undefined + return buildStatus( + source.config, + pointer, + managedHooksPath, + readManagedHooksFile(managedHooksPath), + configPath + ) + } + + install(): AgentHookInstallStatus { + const configPath = getMuseConfigPath() + const managedHooksPath = getMuseManagedHooksPath() + const source = readMuseSettingsSource(configPath) + if (!source) { + return { + agent: 'muse', + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Could not read Muse settings.json' + } + } + const existingPointer = + typeof source.config.managed_hooks_path === 'string' + ? source.config.managed_hooks_path + : undefined + if (existingPointer !== undefined && existingPointer !== managedHooksPath) { + return { + agent: 'muse', + state: 'not_installed', + configPath, + managedHooksPresent: false, + detail: `managed_hooks_path points at ${existingPointer}; leaving the user's managed hooks untouched` + } + } + const scriptPath = getMuseManagedScriptPath() + const command = getMuseManagedCommand(scriptPath) + // Write the script and managed hooks file first so settings.json never points at missing files. + writeManagedScript(scriptPath, getManagedScript()) + writeHooksJson( + managedHooksPath, + { hooks: {} }, + { + serialized: buildMuseManagedHooksFile(command) + } + ) + const nextText = serializeMuseSettings(source.text, managedHooksPath) + if (source.text !== nextText) { + writeHooksJson(configPath, source.config, { serialized: nextText }) + } + return this.getStatus() + } + + // Install the Muse hook on an SSH execution host, where the shell contract is POSIX. + async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { + const remoteConfigPath = getMuseRemoteConfigPath(remoteHome) + const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/muse-hook.sh` + const remoteManagedHooksPath = getMuseRemoteManagedHooksPath(remoteHome) + try { + const body = await readTextFileRemote(sftp, remoteConfigPath) + const config = body === null ? {} : parseMuseSettingsText(body, 'remote Muse settings.json') + if (!config) { + return { + agent: 'muse', + state: 'error', + configPath: remoteConfigPath, + managedHooksPresent: false, + detail: 'Could not parse remote Muse settings.json' + } + } + const existingPointer = + typeof config.managed_hooks_path === 'string' ? config.managed_hooks_path : undefined + if (existingPointer !== undefined && existingPointer !== remoteManagedHooksPath) { + return { + agent: 'muse', + state: 'not_installed', + configPath: remoteConfigPath, + managedHooksPresent: false, + detail: `managed_hooks_path points at ${existingPointer}; leaving the user's managed hooks untouched` + } + } + const command = getMuseRemoteManagedCommand(remoteScriptPath) + // Write the script and managed hooks file first so settings.json never points at missing files. + await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix')) + await writeTextFileRemoteAtomic( + sftp, + remoteManagedHooksPath, + buildMuseManagedHooksFile(command) + ) + await writeTextFileRemoteAtomic( + sftp, + remoteConfigPath, + serializeMuseSettings(body, remoteManagedHooksPath) + ) + return { + agent: 'muse', + state: 'installed', + configPath: remoteConfigPath, + managedHooksPresent: true, + detail: null + } + } catch (err) { + return { + agent: 'muse', + state: 'error', + configPath: remoteConfigPath, + managedHooksPresent: false, + detail: err instanceof Error ? err.message : String(err) + } + } + } + + remove(): AgentHookInstallStatus { + const configPath = getMuseConfigPath() + const managedHooksPath = getMuseManagedHooksPath() + const source = readMuseSettingsSource(configPath) + if (!source) { + return { + agent: 'muse', + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Could not read Muse settings.json' + } + } + if (source.config.managed_hooks_path === managedHooksPath) { + const nextText = serializeMuseSettings(source.text, undefined) + if (source.text !== nextText) { + writeHooksJson(configPath, source.config, { serialized: nextText }) + } + } + try { + if (existsSync(managedHooksPath)) { + unlinkSync(managedHooksPath) + } + } catch { + // best effort + } + return this.getStatus() + } +} + +export const museHookService = new MuseHookService() diff --git a/src/main/muse/hook-settings.ts b/src/main/muse/hook-settings.ts new file mode 100644 index 00000000000..5b5fe3f63fd --- /dev/null +++ b/src/main/muse/hook-settings.ts @@ -0,0 +1,131 @@ +import { homedir } from 'node:os' +import { join } from 'node:path' +import { + buildManagedCommandHook, + createManagedCommandMatcher, + getSharedManagedScriptPath, + isPlainObject, + wrapPosixHookCommand, + wrapWindowsHookCommand, + type HookDefinition +} from '../agent-hooks/installer-utils' + +const MUSE_SCRIPT_BASE = 'muse-hook' + +// Muse 1.3 emits Claude-shaped lifecycle events; absent matchers cover every tool. +// SubagentStart names the internal child sessions whose hooks must not drive pane status. +export const MUSE_HOOK_EVENTS = [ + 'SessionStart', + 'SubagentStart', + 'SessionEnd', + 'Notification', + 'UserPromptSubmit', + 'PreToolUse', + 'PostToolUse', + 'PostToolUseFailure', + 'PermissionRequest', + 'Stop', + 'StopFailure' +] as const + +export const MUSE_MANAGED_HOOKS_FILE_NAME = 'muse-hooks.json' + +function getMuseConfigDir(home: string): string { + // Why: honor XDG_CONFIG_HOME like the CLI does; default matches muse's own + // `~/.config/muse` resolution. + const xdg = process.env.XDG_CONFIG_HOME?.trim() + return xdg ? join(xdg, 'muse') : join(home, '.config', 'muse') +} + +export function getMuseConfigPath(): string { + return join(getMuseConfigDir(homedir()), 'settings.json') +} + +export function getMuseManagedScriptFileName(): string { + return process.platform === 'win32' ? `${MUSE_SCRIPT_BASE}.cmd` : `${MUSE_SCRIPT_BASE}.sh` +} + +export function getMuseManagedScriptPath(): string { + return getSharedManagedScriptPath(getMuseManagedScriptFileName()) +} + +export function getMuseManagedHooksPath(): string { + return getSharedManagedScriptPath(MUSE_MANAGED_HOOKS_FILE_NAME) +} + +export function getMuseRemoteConfigPath(remoteHome: string): string { + // Why: remote XDG_CONFIG_HOME is unknown over SFTP; default matches muse's own resolution. + return `${remoteHome.replace(/\/$/, '')}/.config/muse/settings.json` +} + +export function getMuseRemoteManagedHooksPath(remoteHome: string): string { + return `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/${MUSE_MANAGED_HOOKS_FILE_NAME}` +} + +export function getMuseManagedCommand(scriptPath: string): string { + return process.platform === 'win32' + ? wrapWindowsHookCommand(scriptPath) + : wrapPosixHookCommand(scriptPath) +} + +export function getMuseRemoteManagedCommand(scriptPath: string): string { + return wrapPosixHookCommand(scriptPath) +} + +// Why: the managed file is fully Orca-owned (muse runs it without a trust +// step via `managed_hooks_path`), so generate it wholesale — no user content +// to preserve, unlike an inline `hooks` block in settings.json. +export function buildMuseManagedHooksFile(command: string): string { + const hooks: Record = {} + for (const event of MUSE_HOOK_EVENTS) { + hooks[event] = [{ hooks: [buildManagedCommandHook(command)] }] + } + return `${JSON.stringify({ hooks }, null, 2)}\n` +} + +export function readManagedMuseHookEvents( + parsed: unknown, + isManagedCommand: (command: string | undefined) => boolean +): Set { + const present = new Set() + if (!isPlainObject(parsed) || !isPlainObject(parsed.hooks)) { + return present + } + for (const event of MUSE_HOOK_EVENTS) { + const definitions = parsed.hooks[event] + if (!Array.isArray(definitions)) { + continue + } + // Why: a hand-edited managed file can hold null definitions, non-array + // hook lists, or null entries — treat all of them as absent so status + // calculation never throws on user content. + if ( + definitions.some((definition) => + managedHookEntries(definition).some((hook) => isManagedCommand(hookEntryCommand(hook))) + ) + ) { + present.add(event) + } + } + return present +} + +export function getMuseManagedCommandMatcher(): (command: string | undefined) => boolean { + return createManagedCommandMatcher(getMuseManagedScriptFileName()) +} + +function managedHookEntries(definition: unknown): readonly unknown[] { + if (!isPlainObject(definition)) { + return [] + } + const hooks = definition.hooks + return Array.isArray(hooks) ? hooks : [] +} + +function hookEntryCommand(hook: unknown): string | undefined { + if (!isPlainObject(hook)) { + return undefined + } + const command = hook.command + return typeof command === 'string' ? command : undefined +} diff --git a/src/main/runtime/__fixtures__/muse-empty-folder-ready.meta.json b/src/main/runtime/__fixtures__/muse-empty-folder-ready.meta.json new file mode 100644 index 00000000000..56cd85bc144 --- /dev/null +++ b/src/main/runtime/__fixtures__/muse-empty-folder-ready.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-22T09:55:35.466Z", + "platform": "darwin", + "command": ["muse", "--provider", "echo", "--trust-workspace"], + "cols": 120, + "rows": 32, + "note": "Muse 1.3.0 empty folder ready; echo provider", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/muse-empty-folder-ready.txt b/src/main/runtime/__fixtures__/muse-empty-folder-ready.txt new file mode 100644 index 00000000000..7ec22ad0ee7 --- /dev/null +++ b/src/main/runtime/__fixtures__/muse-empty-folder-ready.txt @@ -0,0 +1,5 @@ +]10;?]11;?]4;0;?]4;1;?]4;2;?]4;3;?]4;4;?]4;5;?]4;6;?]4;7;?]4;8;?]4;9;?]4;10;?]4;11;?]4;12;?]4;13;?]4;14;?]4;15;?[?2004h[?1004h[0 q[?25l[>3u[?u + + + +7MMM8Muse Code1.3.0  Muse Code 1.3.0  ]0;muse-ready-workspace______── Voiceinput(⌥+vtostart) ────────────────────────────────────────────────────────────────────────────────────────❯────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────echo·/private/tmp/muse-ready-workspace______[?25h·Auto-review \ No newline at end of file diff --git a/src/main/runtime/muse-readiness-transcript.test.ts b/src/main/runtime/muse-readiness-transcript.test.ts new file mode 100644 index 00000000000..9e4fcdda6ef --- /dev/null +++ b/src/main/runtime/muse-readiness-transcript.test.ts @@ -0,0 +1,31 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { createTranscriptPane } from './agent-transcript-pane-test-harness' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +describe('Muse readiness from captured terminal bytes', () => { + it('recognizes a ready folder workspace without a skills summary', async () => { + const data = readFileSync( + join(__dirname, '__fixtures__', 'muse-empty-folder-ready.txt'), + 'utf8' + ) + expect(data).toContain(String.fromCharCode(27)) + expect(data).not.toContain('Skills:') + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'muse-first-class-workspace', + foregroundProcess: 'muse-bin-1.3.0-R3401.1', + launchAgent: 'muse', + data + }) + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 10_000 }) + ).resolves.toMatchObject({ satisfied: true }) + }, 15_000) +}) diff --git a/src/main/runtime/orca-runtime-resolve-exit-waiters.ts b/src/main/runtime/orca-runtime-resolve-exit-waiters.ts index ea6329db3e6..9d2ea33a6b8 100644 --- a/src/main/runtime/orca-runtime-resolve-exit-waiters.ts +++ b/src/main/runtime/orca-runtime-resolve-exit-waiters.ts @@ -6,7 +6,8 @@ import { buildPtyTerminalWaitResult, buildTerminalWaitResult } from './terminal- import type { AgentStatus } from '../../shared/agent-detection' import { detectExplicitIdleStatusFromTitle, - isKnownReadyPromptPreview + isKnownReadyPromptPreview, + isMuseReadyPromptPreview } from './terminal-wait-detection' import { buildTerminalWaitText } from './terminal-wait-tail-state' import { isTuiIdleSatisfied } from './tui-idle-evidence' @@ -110,6 +111,10 @@ export class OrcaRuntimeWithResolveExitWaiters extends OrcaRuntimeWithBindPtyInc isKnownReadyPromptPreview( buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview) ), + readMuseReadyBodyEvidence: () => + isMuseReadyPromptPreview( + buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview) + ), agent: this.getPaneAgentForTuiIdle(leaf.ptyId), firstPartyStatus: (leaf.ptyId ? this.ptysById.get(leaf.ptyId)?.lastExplicitAgentStatus : null) ?? null, @@ -195,6 +200,10 @@ export class OrcaRuntimeWithResolveExitWaiters extends OrcaRuntimeWithBindPtyInc isKnownReadyPromptPreview( buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview) ), + readMuseReadyBodyEvidence: () => + isMuseReadyPromptPreview( + buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview) + ), agent: this.getPaneAgentForTuiIdle(pty.ptyId), firstPartyStatus: pty.lastExplicitAgentStatus ?? null, quiescenceMs: TUI_IDLE_QUIESCENCE_MS diff --git a/src/main/runtime/runtime-terminal-idle-polls.ts b/src/main/runtime/runtime-terminal-idle-polls.ts index e1be9654611..8960dc79d26 100644 --- a/src/main/runtime/runtime-terminal-idle-polls.ts +++ b/src/main/runtime/runtime-terminal-idle-polls.ts @@ -2,7 +2,8 @@ import { isShellProcess, type AgentStatus } from '../../shared/agent-detection' import type { RuntimeTerminalWait } from '../../shared/runtime-types' import { detectTerminalWaitBlockedReason, - isKnownReadyPromptPreview + isKnownReadyPromptPreview, + isMuseReadyPromptPreview } from './terminal-wait-detection' import { buildPtyTerminalWaitBlockedResult, @@ -128,6 +129,7 @@ export class RuntimeTerminalIdlePolls { record: leaf, rendererTitle: leaf.paneTitle ?? this.deps.getTabTitle(leaf.tabId), readPositiveBodyEvidence: () => isKnownReadyPromptPreview(waitText), + readMuseReadyBodyEvidence: () => isMuseReadyPromptPreview(waitText), agent, firstPartyStatus: this.deps.getFirstPartyAgentStatus(leaf.ptyId), quiescenceMs: this.deps.quiescenceMs @@ -195,6 +197,7 @@ export class RuntimeTerminalIdlePolls { readPositiveBodyEvidence: () => this.deps.getAdoptedPtyIdleStatus(pty) === 'idle' || isKnownReadyPromptPreview(waitText), + readMuseReadyBodyEvidence: () => isMuseReadyPromptPreview(waitText), agent, firstPartyStatus: this.deps.getFirstPartyAgentStatus(pty.ptyId), quiescenceMs: this.deps.quiescenceMs diff --git a/src/main/runtime/runtime-terminal-wait.ts b/src/main/runtime/runtime-terminal-wait.ts index 67acaf2a60b..23d112c8e9f 100644 --- a/src/main/runtime/runtime-terminal-wait.ts +++ b/src/main/runtime/runtime-terminal-wait.ts @@ -5,7 +5,8 @@ import type { import { hasAntigravityTerminalHeader } from './antigravity-terminal-readiness' import { detectTerminalWaitBlockedReason, - isKnownReadyPromptPreview + isKnownReadyPromptPreview, + isMuseReadyPromptPreview } from './terminal-wait-detection' import { buildPtyTerminalWaitBlockedResult, @@ -53,6 +54,7 @@ export class RuntimeTerminalWait { record: pty, readPositiveBodyEvidence: () => this.deps.getAdoptedPtyIdleStatus(pty) === 'idle' || isKnownReadyPromptPreview(waitText), + readMuseReadyBodyEvidence: () => isMuseReadyPromptPreview(waitText), agent: this.deps.getPaneAgent(pty.ptyId), firstPartyStatus: this.deps.getFirstPartyAgentStatus(pty.ptyId), quiescenceMs: this.deps.quiescenceMs @@ -64,6 +66,7 @@ export class RuntimeTerminalWait { record: leaf, rendererTitle: leaf.paneTitle ?? this.deps.getTabTitle(leaf.tabId), readPositiveBodyEvidence: () => isKnownReadyPromptPreview(waitText), + readMuseReadyBodyEvidence: () => isMuseReadyPromptPreview(waitText), agent: this.deps.getPaneAgent(leaf.ptyId), firstPartyStatus: this.deps.getFirstPartyAgentStatus(leaf.ptyId), quiescenceMs: this.deps.quiescenceMs diff --git a/src/main/runtime/terminal-wait-detection.test.ts b/src/main/runtime/terminal-wait-detection.test.ts index 78d4945f2b2..842fa653eac 100644 --- a/src/main/runtime/terminal-wait-detection.test.ts +++ b/src/main/runtime/terminal-wait-detection.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest' import { detectTerminalWaitBlockedReason, - isKnownReadyPromptPreview + isKnownReadyPromptPreview, + isMuseReadyPromptPreview } from './terminal-wait-detection' import { buildTerminalWaitText } from './terminal-wait-tail-state' @@ -506,3 +507,78 @@ describe('Antigravity readiness does not absorb its own startup dialog', () => { }) } }) + +// Real bytes: node-pty capture of `muse --provider echo --trust-workspace` at its ready +// prompt (banner, skills summary, `❯` composer, provider status line), plus the +// trust dialog from the same capture with no trust flag. +const MUSE_READY_SCREEN_ECHO = [ + ' Muse Code 1.3.0', + ' Skills: 77 loaded · 1 warning · 28 details hidden (ctrl+o to expand)', + '── Voice input (⌥ + v to start) ──────────────────────────────────────────', + '❯ ────────────────────────────────────────────────────────────────────', + ' echo · /private/tmp · YOLO' +] + +const MUSE_READY_SCREEN_META = [ + ' Muse Code 1.3.0', + ' Skills: 77 loaded · 1 warning · 28 details hidden (ctrl+o to expand)', + '── Voice input (⌥ + v to start) ──────────────────────────────────────────', + '❯ ────────────────────────────────────────────────────────────────────', + ' muse-spark-1.3 · max · ~/Downloads/interview-coach · YOLO' +] + +const MUSE_TRUST_DIALOG = [ + 'Do you trust this workspace?', + 'Workspace: /private/tmp', + 'Trusting allows project-local skills, rules, hooks, and plugin config to load before the model runs.', + 'Only trust this workspace when you trust its contents.', + '> 1 Trust and continue', + ' 2 Quit', + 'Use Up/Down or 1/2, then Enter. Esc quits.' +] + +describe('isMuseReadyPromptPreview', () => { + it('recognizes a Muse ready screen across providers', () => { + expect(isMuseReadyPromptPreview(waitTextFor(MUSE_READY_SCREEN_ECHO))).toBe(true) + expect(isMuseReadyPromptPreview(waitTextFor(MUSE_READY_SCREEN_META))).toBe(true) + }) + + it('tolerates ANSI styling around the ready markers', () => { + const esc = String.fromCharCode(27) + expect( + isMuseReadyPromptPreview( + waitTextFor([ + ` ${esc}[1m${esc}[38;2;204;211;219;49mMuse Code 1.3.0`, + `── Voice input (⌥ + v to start) ───`, + `${esc}[38;2;90;160;255;49m❯ ${esc}[39m${esc}[49m`, + ` echo · /private/tmp · ${esc}[38;2;243;139;168;49mYOLO` + ]) + ) + ).toBe(true) + }) + + it('refuses a bare Muse mention without its composer', () => { + expect(isMuseReadyPromptPreview(waitTextFor(['comparing Muse Code vs codex']))).toBe(false) + expect( + isMuseReadyPromptPreview(waitTextFor(['Muse Code 1.3.0', ' echo · /private/tmp · YOLO'])) + ).toBe(false) + }) + + it('refuses the Muse trust dialog, which carries no banner or composer', () => { + const waitText = waitTextFor(MUSE_TRUST_DIALOG) + expect(isMuseReadyPromptPreview(waitText)).toBe(false) + expect(detectTerminalWaitBlockedReason(waitText)).toBe('agent-trust-workspace') + }) + + it('dismisses a trust dialog once Muse paints its ready screen', () => { + const waitText = waitTextFor([...MUSE_TRUST_DIALOG, ...MUSE_READY_SCREEN_META]) + expect(detectTerminalWaitBlockedReason(waitText)).toBeNull() + expect(isMuseReadyPromptPreview(waitText)).toBe(true) + }) + + it('refuses a ready screen once a blocked dialog opens below it', () => { + const waitText = waitTextFor([...MUSE_READY_SCREEN_META, ...MUSE_TRUST_DIALOG]) + expect(detectTerminalWaitBlockedReason(waitText)).toBe('agent-trust-workspace') + expect(isMuseReadyPromptPreview(waitText)).toBe(false) + }) +}) diff --git a/src/main/runtime/terminal-wait-detection.ts b/src/main/runtime/terminal-wait-detection.ts index 34745b40c61..e2b22b10427 100644 --- a/src/main/runtime/terminal-wait-detection.ts +++ b/src/main/runtime/terminal-wait-detection.ts @@ -54,6 +54,18 @@ export function isKnownReadyPromptPreview(preview: string): boolean { return true } +// Why separate from isKnownReadyPromptPreview: that one settles tier 1 immediately, while +// a Muse ready screen only proves the TUI is up — the ranking holds it to quiescence. +export function isMuseReadyPromptPreview(preview: string): boolean { + const normalized = preview.toLowerCase() + const readyIndex = findMuseReadyPromptIndex(normalized) + if (readyIndex === null) { + return false + } + const blockedSignal = findTerminalWaitBlockedSignal(normalized) + return blockedSignal === null || blockedSignal.index <= readyIndex +} + export function detectTerminalWaitBlockedReason( preview: string ): RuntimeTerminalWaitBlockedReason | null { @@ -80,7 +92,8 @@ function findDismissedStartupModalIndex(normalized: string): number | null { const indexes = [ findCodexReadyPromptIndex(normalized), findAntigravityReadyPromptIndex(normalized), - findCursorActivePromptIndex(normalized) + findCursorActivePromptIndex(normalized), + findMuseReadyPromptIndex(normalized) ].filter((index): index is number => index !== null) return indexes.length > 0 ? Math.max(...indexes) : null } @@ -114,6 +127,19 @@ function findCursorReadyPromptIndex(normalized: string): number | null { return CURSOR_BUSY_SPINNER_RE.test(normalized.slice(activeIndex)) ? null : activeIndex } +// Why: Muse titles its OSC with the bare cwd and never updates it, so only the body can +// prove the TUI is up. The voice-input composer is present even without loaded skills. +function findMuseReadyPromptIndex(normalized: string): number | null { + const headerIndex = normalized.lastIndexOf('muse code') + if (headerIndex === -1) { + return null + } + const segment = normalized.slice(headerIndex) + return segment.includes('voice') && segment.includes('input') && segment.includes('❯') + ? headerIndex + : null +} + function findCodexReadyPromptIndex(normalized: string): number | null { const headerIndex = normalized.lastIndexOf('openai codex') if (headerIndex === -1) { diff --git a/src/main/runtime/terminal-wait-name-only-idle.test.ts b/src/main/runtime/terminal-wait-name-only-idle.test.ts index 70605598466..95cfa54be56 100644 --- a/src/main/runtime/terminal-wait-name-only-idle.test.ts +++ b/src/main/runtime/terminal-wait-name-only-idle.test.ts @@ -24,6 +24,15 @@ const QUIESCENCE_MS = 3000 const NAME_ONLY_TITLE = 'Codex' const EXPLICIT_IDLE_TITLE = 'Codex ready' const HANDLE = 'terminal-1' +// Real bytes: node-pty capture of `muse --provider echo --trust-workspace` at its ready +// prompt. Muse's OSC title is the bare cwd (`tmp`) and never changes. +const MUSE_READY_TAIL = [ + ' Muse Code 1.3.0', + ' Skills: 77 loaded · 1 warning · 28 details hidden (ctrl+o to expand)', + '── Voice input (⌥ + v to start) ──────────────────────────────────────────', + '❯ ────────────────────────────────────────────────────────────────────', + ' muse-spark-1.3 · max · ~/Downloads/interview-coach · YOLO' +] function createWait(options: { pty?: RuntimePtyWorktreeRecord @@ -198,6 +207,37 @@ describe('tui-idle evidence ranking', () => { await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 4 + QUIESCENCE_MS) expect(settled).not.toHaveBeenCalled() }) + + // Why: Muse sets its OSC title to the bare cwd and never updates it, so the title + // lanes stay null and only the ready-screen body can settle the wait — but only once + // the stream goes quiet, so a mid-turn streaming pane never satisfies. + it('settles a Muse ready screen only once the stream goes quiet', async () => { + const pty = makeTuiIdlePty({ + lastAgentStatus: null, + lastOscTitle: 'tmp', + tailBuffer: [...MUSE_READY_TAIL] + }) + const { wait } = createWait({ pty, agent: 'muse' }) + const settled = watch(wait.wait(HANDLE, { condition: 'tui-idle', timeoutMs: 60_000 })) + + await advanceWhileStreaming(pty, 2) + expect(settled).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(QUIESCENCE_MS + POLL_INTERVAL_MS) + expect(settled).toHaveBeenCalledWith({ ok: expect.objectContaining({ satisfied: true }) }) + }) + + it('never settles another agent quoting Muse in its scrollback', async () => { + const pty = makeTuiIdlePty({ + lastAgentStatus: null, + lastOscTitle: 'Codex', + tailBuffer: [...MUSE_READY_TAIL], + lastOutputAt: Date.now() - QUIESCENCE_MS * 4 + }) + const { wait } = createWait({ pty, agent: 'codex' }) + const settled = watch(wait.wait(HANDLE, { condition: 'tui-idle', timeoutMs: 60_000 })) + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 3 + QUIESCENCE_MS) + expect(settled).not.toHaveBeenCalled() + }) }) const E2E_WORKTREE_ID = 'repo-1::/tmp/name-only-idle' @@ -294,4 +334,13 @@ describe('tui-idle over the live OSC title pipeline', () => { runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_000 }) ).resolves.toMatchObject({ condition: 'tui-idle', satisfied: true }) }) + + it('settles a quiet Muse ready screen over the live PTY pipeline', async () => { + const { runtime, handle } = await makeRuntime('muse') + runtime.onPtyData(E2E_PTY_ID, `${oscTitle('tmp')}${MUSE_READY_TAIL.join('\n')}\n`, Date.now()) + + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 15_000 }) + ).resolves.toMatchObject({ condition: 'tui-idle', satisfied: true }) + }, 20_000) }) diff --git a/src/main/runtime/tui-idle-evidence.test.ts b/src/main/runtime/tui-idle-evidence.test.ts new file mode 100644 index 00000000000..d4c9adfa6fb --- /dev/null +++ b/src/main/runtime/tui-idle-evidence.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest' +import { + hasQuietMuseReadyPrompt, + isTuiIdleSatisfied, + type TuiIdleEvidenceRecord, + type TuiIdleSatisfactionInput +} from './tui-idle-evidence' + +const QUIESCENCE_MS = 3000 + +function record(overrides: Partial = {}): TuiIdleEvidenceRecord { + return { + lastAgentStatus: null, + lastOutputAt: Date.now() - QUIESCENCE_MS * 2, + lastOscTitle: 'tmp', + ...overrides + } +} + +function input(overrides: Partial = {}): TuiIdleSatisfactionInput { + return { + record: record(), + readPositiveBodyEvidence: () => false, + readMuseReadyBodyEvidence: () => true, + agent: 'muse', + firstPartyStatus: null, + quiescenceMs: QUIESCENCE_MS, + ...overrides + } +} + +describe('hasQuietMuseReadyPrompt', () => { + it('settles a Muse ready screen once the stream has gone quiet', () => { + expect(hasQuietMuseReadyPrompt(record(), 'muse', () => true, QUIESCENCE_MS)).toBe(true) + }) + + it('refuses while the pane is still streaming', () => { + expect( + hasQuietMuseReadyPrompt( + record({ lastOutputAt: Date.now() }), + 'muse', + () => true, + QUIESCENCE_MS + ) + ).toBe(false) + }) + + it('refuses without an output clock, like the tier-3 lane', () => { + expect( + hasQuietMuseReadyPrompt(record({ lastOutputAt: null }), 'muse', () => true, QUIESCENCE_MS) + ).toBe(false) + }) + + it('refuses without a ready screen', () => { + expect(hasQuietMuseReadyPrompt(record(), 'muse', () => false, QUIESCENCE_MS)).toBe(false) + }) + + it('covers adopted panes that carry no launch metadata', () => { + expect(hasQuietMuseReadyPrompt(record(), null, () => true, QUIESCENCE_MS)).toBe(true) + expect(hasQuietMuseReadyPrompt(record(), undefined, () => true, QUIESCENCE_MS)).toBe(true) + }) + + it('refuses another agent quoting Muse in its scrollback', () => { + expect(hasQuietMuseReadyPrompt(record(), 'codex', () => true, QUIESCENCE_MS)).toBe(false) + }) +}) + +describe('isTuiIdleSatisfied muse lane', () => { + it('settles a quiet Muse pane with no title signal at all', () => { + expect(isTuiIdleSatisfied(input())).toBe(true) + }) + + it('lets a fresh first-party working status veto the Muse body', () => { + expect( + isTuiIdleSatisfied(input({ firstPartyStatus: { state: 'working', updatedAt: Date.now() } })) + ).toBe(false) + }) +}) diff --git a/src/main/runtime/tui-idle-evidence.ts b/src/main/runtime/tui-idle-evidence.ts index 827760020f8..0ca43b15a6c 100644 --- a/src/main/runtime/tui-idle-evidence.ts +++ b/src/main/runtime/tui-idle-evidence.ts @@ -17,6 +17,8 @@ import { detectExplicitIdleStatusFromTitle } from './terminal-wait-detection' * * 1. POSITIVE — the agent states it is ready: an explicit idle marker in its own * title, or a known ready-prompt body. + * 1b. MUSE — Muse emits no title signal at all, so its ready-screen body stands in + * for the positive evidence, believed only once the stream has gone quiet. * 2. VETO — a fresh first-party agent status (OSC 9999) saying working/blocked/ * waiting. The agent's own account of itself outranks anything inferred. * 3. ABSENCE — a name-only title, or a quiet non-shell foreground process. A last @@ -119,12 +121,44 @@ export type TuiIdleSatisfactionInput = { * (~11us and a multi-KB string on a full tail); the title check below usually answers * first, and then none of that has to happen at all. */ readPositiveBodyEvidence: () => boolean + /** Tier 1b body evidence: a Muse ready screen. Thunk for the same reason as above. */ + readMuseReadyBodyEvidence: () => boolean agent: TuiAgent | null | undefined firstPartyStatus: FirstPartyAgentStatus quiescenceMs: number } -/** The one place the three tiers are combined; every satisfaction site routes here. */ +/** + * Tier 1b: a Muse ready screen in the body, believed only once the stream has gone quiet. + * + * Muse is the one agent with no title signal at all — its OSC title is the bare cwd and + * never changes — so neither the explicit-idle nor the sustained-title lane can fire. + * The ready screen proves the TUI is up; the quiescence demand keeps a mid-turn + * streaming pane from satisfying, mirroring the codex tier-3 lane's + * positive-evidence-plus-quiet shape. Scoped to Muse and agent-unknown panes: another + * agent's scrollback quoting Muse must not settle its wait. + */ +export function hasQuietMuseReadyPrompt( + record: TuiIdleEvidenceRecord, + agent: TuiAgent | null | undefined, + readBodyEvidence: () => boolean, + quiescenceMs: number +): boolean { + if (agent !== null && agent !== undefined && agent !== 'muse') { + return false + } + if (!readBodyEvidence()) { + return false + } + // Why: same rule as the tier-3 lane — without an output clock there is no + // corroboration available, so hold out instead of settling. + if (record.lastOutputAt === null) { + return false + } + return Date.now() - record.lastOutputAt >= quiescenceMs +} + +/** The one place the tiers are combined; every satisfaction site routes here. */ export function isTuiIdleSatisfied(input: TuiIdleSatisfactionInput): boolean { // Why the title before the body: both are tier 1, so either settles, but the title is a // memoized lookup and the body is a fresh multi-KB scan. Same verdict, cheaper order. @@ -134,5 +168,16 @@ export function isTuiIdleSatisfied(input: TuiIdleSatisfactionInput): boolean { if (hasFreshWorkingFirstPartyStatus(input.firstPartyStatus)) { return false } + // Why after the veto: a first-party working account outranks inferred body evidence. + if ( + hasQuietMuseReadyPrompt( + input.record, + input.agent, + input.readMuseReadyBodyEvidence, + input.quiescenceMs + ) + ) { + return true + } return hasSustainedTitleIdle(input.record, input.agent, input.quiescenceMs) } diff --git a/src/main/skills/skill-discovery-concurrency.test.ts b/src/main/skills/skill-discovery-concurrency.test.ts index a51d6dc9d52..782d1692dac 100644 --- a/src/main/skills/skill-discovery-concurrency.test.ts +++ b/src/main/skills/skill-discovery-concurrency.test.ts @@ -185,7 +185,7 @@ describe('bounded concurrent skill discovery', () => { const line = String(info.mock.calls.at(0)?.at(0)) // `present` is the signal that separates "big tree" from "big root set", and // is not derivable from the other counts. - expect(line).toContain('[skills] scan roots=24 present=3 walked=24 skills=3') + expect(line).toContain('[skills] scan roots=25 present=3 walked=25 skills=3') expect(line).toContain('home-claude') expect(line).not.toContain(home) expect(line).not.toContain(tmpdir()) diff --git a/src/main/skills/skill-discovery-sources.ts b/src/main/skills/skill-discovery-sources.ts index 09bf8e208c8..e9a1db8a4fd 100644 --- a/src/main/skills/skill-discovery-sources.ts +++ b/src/main/skills/skill-discovery-sources.ts @@ -232,6 +232,17 @@ export function buildSkillDiscoverySources( 'home', ['agent-skills'], 'aug' + ), + // Why: user skills live under XDG config home (`~/.config/muse/skills` by + // default); project skills are the canonical `.agents/skills` root already + // covered by home-agents/repo-agents, so no agent-specific repo source. + source( + 'home-muse', + 'Muse home', + pathApi.join(home, '.config', 'muse', 'skills'), + 'home', + ['agent-skills'], + 'muse' ) ] diff --git a/src/relay/agent-hook-result-retry-scheduler.ts b/src/relay/agent-hook-result-retry-scheduler.ts index cc6b18c582e..3a6c9cd07fb 100644 --- a/src/relay/agent-hook-result-retry-scheduler.ts +++ b/src/relay/agent-hook-result-retry-scheduler.ts @@ -2,7 +2,6 @@ // when the hook fired, so re-read the same body on a timer and re-apply only if it changed. Both // timer families live in one owner so pane teardown and server stop tear both down in one ordered // place before the listener caches are cleared. -import { hasCodexTranscriptSubagents } from '../shared/agent-hook-listener/providers/codex-state' import { hasPendingAgentResultText, preparePendingGrokResultDiscovery @@ -11,13 +10,17 @@ import { normalizeHookPayload } from '../shared/agent-hook-listener' import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event' import type { HookListenerState } from '../shared/agent-hook-listener/listener-state' import type { AgentHookSource } from '../shared/agent-hook-relay' +import { + shouldPollHookTranscript, + transcriptPollUpdate +} from '../shared/agent-hook-listener/transcript-poll-policy' import { CodexSubagentPollScheduler } from '../shared/codex-subagent-poll-scheduler' const ASSISTANT_MESSAGE_RETRY_ATTEMPTS = 5 const ASSISTANT_MESSAGE_RETRY_MS = 50 const CODEX_SUBAGENT_POLL_MS = 1_000 -type CodexSubagentPoll = { +type TranscriptPoll = { source: AgentHookSource body: unknown original: AgentHookEventPayload @@ -40,14 +43,14 @@ export type AgentHookResultRetryHost = { export class AgentHookResultRetryScheduler { private assistantMessageRetryTimers = new Map>() - private codexSubagentPollScheduler: CodexSubagentPollScheduler + private transcriptPollScheduler: CodexSubagentPollScheduler private host: AgentHookResultRetryHost constructor(host: AgentHookResultRetryHost) { this.host = host - this.codexSubagentPollScheduler = new CodexSubagentPollScheduler( + this.transcriptPollScheduler = new CodexSubagentPollScheduler( CODEX_SUBAGENT_POLL_MS, - (paneKey, poll) => this.runCodexSubagentPoll(paneKey, poll) + (paneKey, poll) => this.runTranscriptPoll(paneKey, poll) ) } @@ -56,7 +59,7 @@ export class AgentHookResultRetryScheduler { clearTimeout(timer) } this.assistantMessageRetryTimers.clear() - this.codexSubagentPollScheduler.clearAll() + this.transcriptPollScheduler.clearAll() } clearAssistantMessageRetry(paneKey: string): void { @@ -68,26 +71,26 @@ export class AgentHookResultRetryScheduler { this.assistantMessageRetryTimers.delete(paneKey) } - clearCodexSubagentPoll(paneKey: string): void { - this.codexSubagentPollScheduler.clear(paneKey) + clearTranscriptPoll(paneKey: string): void { + this.transcriptPollScheduler.clear(paneKey) } - scheduleCodexSubagentPoll( + scheduleTranscriptPoll( source: AgentHookSource, body: unknown, original: AgentHookEventPayload, env?: string, version?: string ): void { - // Why: a nested non-codex CLI inherits ORCA_PANE_KEY, so clearing here would silently end a live codex poll. - if (source !== 'codex') { + // Why: a nested CLI of another kind inherits ORCA_PANE_KEY, so clearing here would silently end a live poll. + if (source !== 'codex' && source !== 'muse') { return } - this.codexSubagentPollScheduler.clear(original.paneKey) - if (!hasCodexTranscriptSubagents(this.host.state, original.paneKey)) { + this.transcriptPollScheduler.clear(original.paneKey) + if (!shouldPollHookTranscript(this.host.state, source, original)) { return } - this.codexSubagentPollScheduler.schedule(original.paneKey, { + this.transcriptPollScheduler.schedule(original.paneKey, { source, body, original, @@ -96,7 +99,7 @@ export class AgentHookResultRetryScheduler { }) } - private runCodexSubagentPoll(paneKey: string, poll: CodexSubagentPoll): void { + private runTranscriptPoll(paneKey: string, poll: TranscriptPoll): void { const { source, body, original, env, version } = poll // Keep the identity check at callback time: a newer event supersedes this // payload even when its pane still has transcript children. @@ -111,13 +114,12 @@ export class AgentHookResultRetryScheduler { if (!event) { return } - const subagentsChanged = - JSON.stringify(event.payload.subagents) !== JSON.stringify(original.payload.subagents) - const next = subagentsChanged ? event : original - if (subagentsChanged) { - this.host.applyEvent(event, source, env, version) + const update = transcriptPollUpdate(source, original, event) + const next = update ?? original + if (update) { + this.host.applyEvent(update, source, env, version) } - this.scheduleCodexSubagentPoll(source, body, next, env, version) + this.scheduleTranscriptPoll(source, body, next, env, version) } scheduleAssistantMessageRetry( diff --git a/src/relay/agent-hook-server.test.ts b/src/relay/agent-hook-server.test.ts index c3a99b8a20c..3ea7609b9d7 100644 --- a/src/relay/agent-hook-server.test.ts +++ b/src/relay/agent-hook-server.test.ts @@ -168,7 +168,7 @@ describe('RelayAgentHookServer', () => { internals = server as unknown as RelayServerInternals const retryScheduler = internals.retryScheduler const originalAssistantRetry = retryScheduler.scheduleAssistantMessageRetry.bind(retryScheduler) - const originalCodexRetry = retryScheduler.scheduleCodexSubagentPoll.bind(retryScheduler) + const originalTranscriptPoll = retryScheduler.scheduleTranscriptPoll.bind(retryScheduler) const assistantRetry = vi .spyOn(retryScheduler, 'scheduleAssistantMessageRetry') .mockImplementation((...args) => { @@ -176,10 +176,10 @@ describe('RelayAgentHookServer', () => { originalAssistantRetry(...args) }) const codexRetry = vi - .spyOn(retryScheduler, 'scheduleCodexSubagentPoll') + .spyOn(retryScheduler, 'scheduleTranscriptPoll') .mockImplementation((...args) => { order.push('codex-retry') - originalCodexRetry(...args) + originalTranscriptPoll(...args) }) await server.start() try { @@ -217,7 +217,7 @@ describe('RelayAgentHookServer', () => { const internals = server as unknown as RelayServerInternals const retryScheduler = internals.retryScheduler const assistantRetry = vi.spyOn(retryScheduler, 'scheduleAssistantMessageRetry') - const codexRetry = vi.spyOn(retryScheduler, 'scheduleCodexSubagentPoll') + const codexRetry = vi.spyOn(retryScheduler, 'scheduleTranscriptPoll') await server.start() try { const { port, token } = server.getCoordinates() diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index 378f81cf022..e75b11a2450 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -225,7 +225,7 @@ export class RelayAgentHookServer { /** Drop a paneKey's cached entries on PTY exit so a terminated pane can't resurface as a ghost event on reconnect. */ clearPaneState(paneKey: string): void { this.retryScheduler.clearAssistantMessageRetry(paneKey) - this.retryScheduler.clearCodexSubagentPoll(paneKey) + this.retryScheduler.clearTranscriptPoll(paneKey) clearPaneCacheState(this.state, paneKey) this.lastEnvelopeMetaByPaneKey.delete(paneKey) } @@ -284,7 +284,7 @@ export class RelayAgentHookServer { const version = hookBodyVersion(hookBody) this.applyEvent(event, source, env, version) this.retryScheduler.scheduleAssistantMessageRetry(source, hookBody, event, env, version) - this.retryScheduler.scheduleCodexSubagentPoll(source, hookBody, event, env, version) + this.retryScheduler.scheduleTranscriptPoll(source, hookBody, event, env, version) } res.writeHead(204) res.end() diff --git a/src/renderer/src/components/skills/SkillInstallDialog.test.tsx b/src/renderer/src/components/skills/SkillInstallDialog.test.tsx index ce60f124d01..9a84d60ce5a 100644 --- a/src/renderer/src/components/skills/SkillInstallDialog.test.tsx +++ b/src/renderer/src/components/skills/SkillInstallDialog.test.tsx @@ -253,7 +253,9 @@ describe('SkillInstallDialog', () => { }) render( undefined} />) await inspectSkill() - await screen.findByRole('button', { name: 'Installing for: Codex' }) + // Canonical-root agents (for example Muse) are shown alongside the + // detected provider, so keep this assertion focused on the detected one. + await screen.findByRole('button', { name: /Installing for: Codex/ }) fireEvent.click(screen.getByRole('button', { name: 'Install skill' })) await waitFor(() => expect(skills.installShare).toHaveBeenCalled()) diff --git a/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.test.ts b/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.test.ts index 3444bf412d6..452a4ff42ee 100644 --- a/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.test.ts @@ -52,6 +52,15 @@ describe('terminal startup command classifier', () => { ) }) + it('recognizes Muse startup commands including the versioned binary', () => { + expect(isKnownTuiAgentTerminalStartupCommand('muse')).toBe(true) + expect( + isKnownTuiAgentTerminalStartupCommand('/Users/me/.local/bin/muse-bin-1.3.0-R3401.1') + ).toBe(true) + expect(isKnownTuiAgentTerminalStartupCommand('/usr/local/bin/not-muse')).toBe(false) + expect(isKnownTuiAgentTerminalStartupCommand('/usr/local/bin/muse-workbench')).toBe(false) + }) + it('bounds pathological single-token startup commands', () => { const split = vi.spyOn(String.prototype, 'split') const command = 'codex'.repeat(TERMINAL_STARTUP_COMMAND_TOKEN_MAX_CHARS) diff --git a/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.ts b/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.ts index f171efc01cc..482cbee74db 100644 --- a/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.ts +++ b/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.ts @@ -60,7 +60,8 @@ export function isKnownTuiAgentTerminalStartupCommand(command: string): boolean return ( KNOWN_TUI_AGENT_EXECUTABLES.has(executable) || executable.startsWith('codex-') || - executable.startsWith('grok-') + executable.startsWith('grok-') || + executable.startsWith('muse-bin-') ) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 6a8d0b8a030..bf89d826a7f 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -608,6 +608,7 @@ "fc80296033": "Devin", "da41abbdd4": "Ante", "060d152fb5": "Trae", + "muse_label": "Muse", "d443a47995": "Prime Agent", "mimo_code_label": "MiMo Code", "opencode2_label": "OpenCode 2" diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 6e66e4aaf4d..11c1a3629f4 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -383,7 +383,8 @@ "da41abbdd4": "Ante", "060d152fb5": "Trae", "d443a47995": "Prime Agent", - "mimo_code_label": "MiMo Code" + "mimo_code_label": "MiMo Code", + "muse_label": "Muse" }, "skill": { "cli": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index d8f5cca304b..fffd22f627d 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -460,7 +460,8 @@ "da41abbdd4": "Ante", "060d152fb5": "Trae", "d443a47995": "Prime Agent", - "mimo_code_label": "MiMo Code" + "mimo_code_label": "MiMo Code", + "muse_label": "Muse" }, "skill": { "cli": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 4bfa562aa38..6c412084d99 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -383,7 +383,8 @@ "da41abbdd4": "Ante", "060d152fb5": "Trae", "d443a47995": "Prime Agent", - "mimo_code_label": "MiMo Code" + "mimo_code_label": "MiMo Code", + "muse_label": "Muse" }, "skill": { "cli": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index c4c9fd6d334..84345b0628b 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -386,7 +386,8 @@ "da41abbdd4": "Ante", "060d152fb5": "Trae", "d443a47995": "Prime Agent", - "mimo_code_label": "MiMo Code" + "mimo_code_label": "MiMo Code", + "muse_label": "Muse" }, "skill": { "cli": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index d1b73cebc75..a88b55f476f 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -386,7 +386,8 @@ "da41abbdd4": "Ante", "060d152fb5": "Trae", "d443a47995": "Prime Agent", - "mimo_code_label": "MiMo Code" + "mimo_code_label": "MiMo Code", + "muse_label": "Muse" }, "skill": { "cli": { diff --git a/src/renderer/src/lib/agent-catalog.tsx b/src/renderer/src/lib/agent-catalog.tsx index a67317d2a3a..32193c7e7b0 100644 --- a/src/renderer/src/lib/agent-catalog.tsx +++ b/src/renderer/src/lib/agent-catalog.tsx @@ -120,6 +120,13 @@ export const getAgentCatalog = createLocalizedCatalog((): AgentCatalogEntry[] => faviconDomain: 'www.trae.cn', homepageUrl: 'https://docs.trae.cn/cli_get-started-with-trae-cli' }, + { + id: 'muse', + label: translate('auto.lib.agent.catalog.muse_label', 'Muse'), + cmd: 'muse', + faviconDomain: 'dev.meta.ai', + homepageUrl: 'https://dev.meta.ai/docs/muse-code' + }, { id: 'pi', label: translate('auto.lib.agent.catalog.302934c5d9', 'Pi'), diff --git a/src/renderer/src/lib/agent-favicon-assets.ts b/src/renderer/src/lib/agent-favicon-assets.ts index c1e8bcfc2ea..98ed4c95b0e 100644 --- a/src/renderer/src/lib/agent-favicon-assets.ts +++ b/src/renderer/src/lib/agent-favicon-assets.ts @@ -24,6 +24,7 @@ import qwenCodeUrl from '../../../shared/agent-icons/qwen-code.png?url' import rovoUrl from '../../../shared/agent-icons/rovo.png?url' import hermesUrl from '../../../shared/agent-icons/hermes.png?url' import devinUrl from '../../../shared/agent-icons/devin.png?url' +import museUrl from '../../../shared/agent-icons/muse.png?url' import openclawUrl from '../../../shared/agent-icons/openclaw.png?url' // Why: these agents have no hand-authored SVG glyph, so previously their icons @@ -59,5 +60,6 @@ export const AGENT_FAVICON_ASSETS: Partial> = { rovo: rovoUrl, hermes: hermesUrl, devin: devinUrl, + muse: museUrl, openclaw: openclawUrl } diff --git a/src/renderer/src/lib/agent-status.ts b/src/renderer/src/lib/agent-status.ts index 3c1793e5e62..8a642c1dd50 100644 --- a/src/renderer/src/lib/agent-status.ts +++ b/src/renderer/src/lib/agent-status.ts @@ -135,7 +135,8 @@ const ICONABLE_AGENT_TYPES: Record = { grok: true, devin: true, ante: true, - trae: true + trae: true, + muse: true } // Why: return null (not a 'claude' fallback) for unknown so Codex panes don't flash the Claude icon before the hook fires. diff --git a/src/renderer/src/lib/tui-agent-startup.test.ts b/src/renderer/src/lib/tui-agent-startup.test.ts index ce948703c94..b4551ae8a23 100644 --- a/src/renderer/src/lib/tui-agent-startup.test.ts +++ b/src/renderer/src/lib/tui-agent-startup.test.ts @@ -144,6 +144,26 @@ describe('buildAgentStartupPlan', () => { ).toBe("traecli -- 'help me name this config'") }) + it('delivers the Muse prompt after its composer is ready', () => { + expect( + buildAgentStartupPlan({ + agent: 'muse', + prompt: 'Summarize the failing tests', + cmdOverrides: {}, + platform: 'linux' + }) + ).toEqual({ + agent: 'muse', + launchCommand: 'muse --trust-workspace', + expectedProcess: 'muse', + followupPrompt: 'Summarize the failing tests', + launchConfig: { + ...emptyLaunchConfig('muse'), + agentCommand: 'muse --trust-workspace' + } + }) + }) + it('passes the prompt to Prime Agent as a positional argv behind a `--` separator', () => { expect( buildAgentStartupPlan({ diff --git a/src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts b/src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts index 1885d059b89..3fca694baa3 100644 --- a/src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts +++ b/src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { RESUMABLE_TUI_AGENTS } from '../../../shared/agent-session-resume' import { AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY, + AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY, RUNTIME_CAPABILITIES @@ -9,6 +10,13 @@ import { import { agentResumeHostAuthorityCapability } from './agent-resume-host-authority-capability' describe('agentResumeHostAuthorityCapability', () => { + it('gates Muse resume behind its own advertised capability', () => { + expect(agentResumeHostAuthorityCapability('muse')).toBe( + AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY + ) + expect(RUNTIME_CAPABILITIES).toContain(AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY) + }) + it('gates OpenCode 2 resume behind its own advertised capability', () => { expect(agentResumeHostAuthorityCapability('opencode2')).toBe( AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY @@ -58,6 +66,7 @@ describe('agentResumeHostAuthorityCapability', () => { devin: undefined, 'prime-agent': undefined, copilot: undefined, + muse: AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY, omp: AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY, kimi: AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY }) diff --git a/src/renderer/src/runtime/agent-resume-host-authority-capability.ts b/src/renderer/src/runtime/agent-resume-host-authority-capability.ts index cd2198e9db2..12c1b96efd6 100644 --- a/src/renderer/src/runtime/agent-resume-host-authority-capability.ts +++ b/src/renderer/src/runtime/agent-resume-host-authority-capability.ts @@ -2,6 +2,7 @@ import type { ResumableTuiAgent } from '../../../shared/agent-session-resume' import type { TuiAgent } from '../../../shared/tui-agent' import { AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY, + AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY, type RuntimeCapability @@ -31,6 +32,7 @@ const RESUME_HOST_AUTHORITY_CAPABILITY_BY_AGENT = { 'prime-agent': undefined, // Ungated to match how main shipped copilot resume; gating it is its own change. copilot: undefined, + muse: AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY, omp: AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY, kimi: AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY } satisfies Record diff --git a/src/renderer/src/runtime/remote-agent-session-launch.test.ts b/src/renderer/src/runtime/remote-agent-session-launch.test.ts index a282b3b5878..d8cf0c542cb 100644 --- a/src/renderer/src/runtime/remote-agent-session-launch.test.ts +++ b/src/renderer/src/runtime/remote-agent-session-launch.test.ts @@ -45,23 +45,47 @@ describe('remote agent-session launch routing', () => { expect(legacy).not.toHaveBeenCalled() }) - it('falls back to legacy when an older host lacks the Kimi resume capability', async () => { - const hostAuthority = vi.fn().mockResolvedValue('structured') - const legacy = vi.fn().mockResolvedValue('legacy') - mocks.supportsCapability.mockResolvedValue(false) + it.each(['kimi', 'muse'] as const)( + 'falls back to legacy when an older host lacks the %s resume capability', + async (agent) => { + const hostAuthority = vi.fn().mockResolvedValue('structured') + const legacy = vi.fn().mockResolvedValue('legacy') + mocks.supportsCapability.mockResolvedValue(false) + + // Why: an old host rejects the widened agent enum with invalid_argument, which is not a + // fallback code — so the probe, not the error handler, has to keep the pane alive. + await expect( + runRemoteAgentSessionLaunch({ + environmentId: 'env-1', + hostAuthority, + hostAuthorityCapability: agentResumeHostAuthorityCapability(agent), + legacy + }) + ).resolves.toBe('legacy') + expect(mocks.supportsCapability).toHaveBeenCalledWith( + 'env-1', + `agent-session.${agent}-resume.v1` + ) + expect(hostAuthority).not.toHaveBeenCalled() + } + ) + + it('uses host authority when the host supports Muse resume', async () => { + const hostAuthority = vi.fn().mockResolvedValue('host') + const legacy = vi.fn() + mocks.supportsCapability.mockResolvedValue(true) - // Why: an old host rejects the widened agent enum with invalid_argument, which is not a - // fallback code — so the probe, not the error handler, has to keep the pane alive. await expect( runRemoteAgentSessionLaunch({ environmentId: 'env-1', hostAuthority, - hostAuthorityCapability: agentResumeHostAuthorityCapability('kimi'), + hostAuthorityCapability: agentResumeHostAuthorityCapability('muse'), legacy }) - ).resolves.toBe('legacy') - expect(mocks.supportsCapability).toHaveBeenCalledWith('env-1', 'agent-session.kimi-resume.v1') - expect(hostAuthority).not.toHaveBeenCalled() + ).resolves.toBe('host') + expect(mocks.supportsCapability).toHaveBeenCalledWith('env-1', 'agent-session.muse-resume.v1') + expect(hostAuthority).toHaveBeenCalledOnce() + expect(legacy).not.toHaveBeenCalled() }) it('preserves the exact legacy path when the capability is absent', async () => { diff --git a/src/shared/agent-headless-command.ts b/src/shared/agent-headless-command.ts index 9ff40e6af1d..74896e029b5 100644 --- a/src/shared/agent-headless-command.ts +++ b/src/shared/agent-headless-command.ts @@ -1,18 +1,20 @@ import { isAnteHeadlessOneShotCommand } from './ante-headless-command' +import { isMuseHeadlessOneShotCommand } from './muse-headless-command' import { isPrimeAgentHeadlessOneShotCommand } from './prime-agent-headless-command' import { isPrintModeHeadlessOneShotCommand } from './print-mode-headless-command' import type { TuiAgent } from './tui-agent' // Why: a table (not an if-chain) so adding an agent is one entry; Claude and Trae share -// the same `--print` one-shot contract, Ante's `--prompt` form and Prime Agent's -// `--mode` forms need their own matchers. +// the same `--print` one-shot contract, Ante's `--prompt` form, Prime Agent's +// `--mode` forms, and Muse's `exec` subcommand need their own matchers. const HEADLESS_ONE_SHOT_MATCHERS: Partial< Record boolean> > = { claude: isPrintModeHeadlessOneShotCommand, trae: isPrintModeHeadlessOneShotCommand, 'prime-agent': isPrimeAgentHeadlessOneShotCommand, - ante: isAnteHeadlessOneShotCommand + ante: isAnteHeadlessOneShotCommand, + muse: isMuseHeadlessOneShotCommand } export function isHeadlessOneShotAgentCommand(agent: TuiAgent, tokens: readonly string[]): boolean { diff --git a/src/shared/agent-hook-listener-claude-compatible-vendors.test.ts b/src/shared/agent-hook-listener-claude-compatible-vendors.test.ts index 601b8aea828..28d450ae0b3 100644 --- a/src/shared/agent-hook-listener-claude-compatible-vendors.test.ts +++ b/src/shared/agent-hook-listener-claude-compatible-vendors.test.ts @@ -286,4 +286,110 @@ describe('shared agent-hook-listener', () => { toolName: 'AskUserQuestion' }) }) + + // Why: Muse emits Claude-compatible hook payloads (captured from muse 1.3.0 hook stdin); + // normalize but attribute to Muse, including the Stop `last_assistant_message`. + it('normalizes Muse Claude-compatible lifecycle events as muse status', () => { + const base = { + session_id: '01a0caa3-0e77-7d41-bad7-46283a45633d', + turn_id: '2c040170-d894-4268-ad7b-1b2f9bf2e2e2', + cwd: '/tmp/ws', + transcript_path: null, + model: 'muse-spark-1.3', + permission_mode: 'default', + model_provider: 'meta' + } + // Why: this id is a real capture; keep the lookup off the developer's own Muse sessions. + vi.stubEnv('XDG_DATA_HOME', '/tmp/orca-muse-vendors-test-no-data') + const bash = { command: 'ls -la' } + const submitted = normalizeAndAccept(state, 'muse', { + ...base, + hook_event_name: 'UserPromptSubmit', + prompt: 'say hi again' + }) + normalizeAndAccept(state, 'muse', { + ...base, + hook_event_name: 'PreToolUse', + tool_name: 'bash', + tool_input: bash, + tool_use_id: 'call_1' + }) + // Why: auto-approved tools also emit PermissionRequest, so only Notification means a prompt. + const permissionRequest = normalizeAndAccept(state, 'muse', { + ...base, + hook_event_name: 'PermissionRequest', + tool_name: 'bash', + tool_input: bash + }) + const waiting = normalizeAndAccept(state, 'muse', { + ...base, + hook_event_name: 'Notification', + notification_type: 'permission_prompt', + title: 'ws — waiting for approval', + message: 'bash wants to run' + }) + const approved = normalizeAndAccept(state, 'muse', { + ...base, + hook_event_name: 'PostToolUse', + tool_name: 'bash', + tool_input: bash, + tool_use_id: 'call_1' + }) + const stopped = normalizeAndAccept(state, 'muse', { + ...base, + hook_event_name: 'Stop', + stop_hook_active: false, + last_assistant_message: 'echo: say hi again' + }) + + expect(submitted?.payload).toMatchObject({ + agentType: 'muse', + state: 'working', + prompt: 'say hi again' + }) + expect(permissionRequest).toBeNull() + expect(waiting?.payload).toMatchObject({ + agentType: 'muse', + state: 'waiting', + toolName: 'bash', + interactivePrompt: JSON.stringify({ approval: { tool: 'bash', summary: 'ls -la' } }) + }) + expect(approved?.payload.state).toBe('working') + expect(approved?.payload.interactivePrompt).toBeUndefined() + expect(stopped?.payload).toMatchObject({ + agentType: 'muse', + state: 'done', + lastAssistantMessage: 'echo: say hi again' + }) + // The Claude-shaped session_id is captured for provider-session resume. + expect(stopped?.providerSession).toMatchObject({ + key: 'session_id', + id: '01a0caa3-0e77-7d41-bad7-46283a45633d' + }) + }) + + it.each(['AskUserQuestion', 'request_user_input'])( + 'keeps Muse %s pending until answered', + (toolName) => { + const toolInput = { questions: [{ question: 'Which color?', options: [{ label: 'Blue' }] }] } + const pending = normalizeAndAccept(state, 'muse', { + hook_event_name: 'PreToolUse', + tool_name: toolName, + tool_input: toolInput + }) + expect(pending?.payload).toMatchObject({ + agentType: 'muse', + state: 'waiting', + interactivePrompt: JSON.stringify(toolInput) + }) + const answered = normalizeAndAccept(state, 'muse', { + hook_event_name: 'PostToolUse', + tool_name: toolName, + tool_input: toolInput, + tool_response: 'Blue' + }) + expect(answered?.payload.state).toBe('working') + expect(answered?.payload.interactivePrompt).toBeUndefined() + } + ) }) diff --git a/src/shared/agent-hook-listener-relay-dependency.test.ts b/src/shared/agent-hook-listener-relay-dependency.test.ts index e23277e9f18..81151f04e8a 100644 --- a/src/shared/agent-hook-listener-relay-dependency.test.ts +++ b/src/shared/agent-hook-listener-relay-dependency.test.ts @@ -125,9 +125,9 @@ describe('agent hook listener relay dependency boundary', () => { 'agent-hook-listener/hook-envelope.ts', 'agent-hook-listener/listener-limits.ts', 'agent-hook-listener/listener-state.ts', - 'agent-hook-listener/providers/codex-state.ts', 'agent-hook-listener/request-body.ts', - 'agent-hook-listener/source-routing.ts' + 'agent-hook-listener/source-routing.ts', + 'agent-hook-listener/transcript-poll-policy.ts' ]) expect( [...visited].some((file) => file.endsWith('/agent-hook-listener/provider-dispatch.ts')) diff --git a/src/shared/agent-hook-listener/listener-state.ts b/src/shared/agent-hook-listener/listener-state.ts index 5f442b1ff77..3e4ed0cae5d 100644 --- a/src/shared/agent-hook-listener/listener-state.ts +++ b/src/shared/agent-hook-listener/listener-state.ts @@ -10,6 +10,7 @@ import type { AgentStatusLegacyIngressCaller } from '../agent-status-legacy-ingr import type { ClaudeSubagentRoster } from '../claude-subagent-roster' import type { CodexSubagentRoster } from '../codex-subagent-roster' import type { CodexSubagentTranscriptState } from '../codex-subagent-transcript' +import type { MuseSessionLogState } from '../muse-session-log' import type { AgentHookEventPayload, ToolSnapshot } from './listener-event' import { moveOpenCodeSessionBindings, @@ -51,6 +52,8 @@ export type HookListenerState = { codexLeadStateByPaneKey: Map /** Newest Grok turn per pane, used to reject end reports that arrive after a replacement prompt. */ grokActiveTurnByPaneKey: Map + /** Muse child-session filter and session-log cursor per pane. */ + musePaneStateByPaneKey: Map /** * OpenCode session id -> owning pane, observed from the client side. The * shared v2 server stamps every post with its own frozen pane, so ingest @@ -62,6 +65,14 @@ export type HookListenerState = { lastLaunchTokenByPaneKey: Map } +export type MusePaneState = { + /** Internal reminder/subagent sessions; their hooks inherit the pane env and fire even after Stop. */ + childSessionIds: Set + log?: MuseSessionLogState + /** Muse emits PermissionRequest for auto-approved calls too; only Notification confirms a visible prompt. */ + pendingApproval?: { toolName?: string; toolInput?: unknown } +} + export type GrokActiveTurn = { promptId?: string sessionId?: string @@ -118,6 +129,7 @@ export function createHookListenerState( codexSubagentTranscriptByPaneKey: new Map(), codexLeadStateByPaneKey: new Map(), grokActiveTurnByPaneKey: new Map(), + musePaneStateByPaneKey: new Map(), opencodeSessionPaneBySessionId: new Map(), lastLaunchTokenByPaneKey: new Map() } @@ -206,6 +218,7 @@ export function clearPaneCacheState(state: HookListenerState, paneKey: string): state.codexSubagentTranscriptByPaneKey.delete(paneKey) state.codexLeadStateByPaneKey.delete(paneKey) state.grokActiveTurnByPaneKey.delete(paneKey) + state.musePaneStateByPaneKey.delete(paneKey) unbindOpenCodeSessionsOfPane(state, paneKey) deletePaneScopedCacheEntry(state.lastLaunchTokenByPaneKey, paneKey) } @@ -282,6 +295,7 @@ export function movePaneCacheState( movePaneScopedMapEntries(state.codexSubagentTranscriptByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.codexLeadStateByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.grokActiveTurnByPaneKey, fromPaneKey, toPaneKey) + movePaneScopedMapEntries(state.musePaneStateByPaneKey, fromPaneKey, toPaneKey) moveOpenCodeSessionBindings(state, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.lastLaunchTokenByPaneKey, fromPaneKey, toPaneKey) } diff --git a/src/shared/agent-hook-listener/provider-dispatch.ts b/src/shared/agent-hook-listener/provider-dispatch.ts index fb3647e2651..9fb7255220d 100644 --- a/src/shared/agent-hook-listener/provider-dispatch.ts +++ b/src/shared/agent-hook-listener/provider-dispatch.ts @@ -22,6 +22,7 @@ import { normalizeCopilotEvent } from './providers/copilot-events' import { normalizeHermesEvent } from './providers/hermes-events' import { normalizeDevinEvent } from './providers/devin-events' import { normalizeKimiEvent } from './providers/kimi-events' +import { normalizeMuseEvent } from './providers/muse-events' export type ProviderDispatchResult = { payload: ParsedAgentStatusPayload | null @@ -149,6 +150,9 @@ export function normalizeProviderEvent(input: { case 'kimi': payload = normalizeKimiEvent(state, eventName, promptText, paneKey, hookPayload) break + case 'muse': + payload = normalizeMuseEvent(state, eventName, promptText, paneKey, hookPayload) + break } return { payload, resolvedPromptText, promptInteractionKey, hasTranscriptPromptEvidence } diff --git a/src/shared/agent-hook-listener/provider-event-routing.ts b/src/shared/agent-hook-listener/provider-event-routing.ts index 39fa150f34b..3b4132b1a6a 100644 --- a/src/shared/agent-hook-listener/provider-event-routing.ts +++ b/src/shared/agent-hook-listener/provider-event-routing.ts @@ -32,6 +32,9 @@ export function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boo case 'kimi': // Why: Kimi Code emits Claude-compatible hook events, so UserPromptSubmit is its new-turn boundary too. return eventName === 'UserPromptSubmit' + case 'muse': + // Muse uses Claude-compatible lifecycle events. + return eventName === 'UserPromptSubmit' case 'codex': return eventName === 'SessionStart' || eventName === 'UserPromptSubmit' case 'gemini': @@ -131,6 +134,9 @@ export function extractToolFields( // Why: Kimi Code uses Claude's tool_name/tool_input payload fields verbatim. // falls through case 'kimi': + // Muse uses Claude-compatible tool fields. + // falls through + case 'muse': return extractClaudeToolFields(eventName, hookPayload) case 'codex': return extractCodexToolFields(eventName, hookPayload) diff --git a/src/shared/agent-hook-listener/providers/muse-events.test.ts b/src/shared/agent-hook-listener/providers/muse-events.test.ts new file mode 100644 index 00000000000..5bfeac9a4fa --- /dev/null +++ b/src/shared/agent-hook-listener/providers/muse-events.test.ts @@ -0,0 +1,236 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { appendFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createHookListenerState, type HookListenerState } from '../listener-state' +import { normalizeAndAccept, PANE_KEY } from '../../agent-hook-listener-test-harness' + +const SESSION_ID = '01a0caa3-0e77-7d41-bad7-46283a45633d' +const TURN_ID = 'e495d1a5-59aa-47b4-8efb-a1bd75509afc' +const CHILD_ID = '1471f5e6-bd60-41a2-bfcf-c9086dbd4092' +const PROMPT_ID = '01a0caa3-a25a-7810-8229-4de04b2e7ca3' +const QUESTION = { + id: 'fav_color', + header: 'Color', + question: 'What is your favorite color?', + options: [{ label: 'Blue' }, { label: 'Green' }, { label: 'Red' }] +} + +const envelope = { + cwd: '/tmp/ws', + transcript_path: null, + model: 'muse-spark-1.3', + permission_mode: 'default', + model_provider: 'meta' +} +const main = { ...envelope, session_id: SESSION_ID, turn_id: TURN_ID } +const child = { ...envelope, session_id: CHILD_ID, turn_id: CHILD_ID } +const reminderInput = { decision: 'none', skill_id: 'bundled:grill' } + +function sessionLogLine(event: Record): string { + return `${JSON.stringify({ + schema_version: 1, + stream: { kind: 'session', id: SESSION_ID }, + record_type: 'event', + payload_type: 'runtime.session', + payload: { kind: 'run', run_id: TURN_ID, event } + })}\n` +} + +function childReminderHooks(): Record[] { + return [ + { + ...child, + hook_event_name: 'PreToolUse', + tool_name: 'submit_reminder_decision', + tool_input: reminderInput, + tool_use_id: 'call_child' + }, + { + ...child, + hook_event_name: 'PermissionRequest', + tool_name: 'submit_reminder_decision', + tool_input: reminderInput + }, + { + ...child, + hook_event_name: 'PostToolUseFailure', + tool_name: 'submit_reminder_decision', + tool_input: reminderInput, + error: + 'tool failed: invalid reminder decision payload: unexpected field `additionalProperties`' + }, + { ...child, hook_event_name: 'SubagentStop', subagent_id: 'skill-reminder' } + ] +} + +describe('Muse hook events', () => { + let state: HookListenerState + let dataHome: string + + beforeEach(() => { + state = createHookListenerState() + dataHome = mkdtempSync(join(tmpdir(), 'muse-events-')) + vi.stubEnv('XDG_DATA_HOME', dataHome) + }) + + afterEach(() => { + vi.unstubAllEnvs() + rmSync(dataHome, { recursive: true, force: true }) + }) + + function sessionLogPath(): string { + const date = new Date(Number.parseInt(SESSION_ID.replace(/-/g, '').slice(0, 12), 16)) + const dir = join( + dataHome, + 'muse', + 'sessions', + String(date.getFullYear()), + String(date.getMonth() + 1).padStart(2, '0'), + String(date.getDate()).padStart(2, '0'), + SESSION_ID + ) + mkdirSync(dir, { recursive: true }) + return join(dir, 'session.jsonl') + } + + it('drops reminder-subagent hooks announced by SubagentStart, even after Stop', () => { + normalizeAndAccept(state, 'muse', { + ...main, + hook_event_name: 'UserPromptSubmit', + prompt: 'list files' + }) + const start = normalizeAndAccept(state, 'muse', { + ...child, + hook_event_name: 'SubagentStart', + subagent_id: 'skill-reminder', + child_session_id: CHILD_ID + }) + expect(start).toBeNull() + for (const hook of childReminderHooks()) { + expect(normalizeAndAccept(state, 'muse', hook)).toBeNull() + } + + const stopped = normalizeAndAccept(state, 'muse', { + ...main, + hook_event_name: 'Stop', + stop_hook_active: false, + last_assistant_message: 'Here are the files.' + }) + expect(stopped?.payload.state).toBe('done') + for (const hook of childReminderHooks()) { + expect(normalizeAndAccept(state, 'muse', hook)).toBeNull() + } + expect(state.lastStatusByPaneKey.get(PANE_KEY)?.payload).toMatchObject({ + state: 'done', + lastAssistantMessage: 'Here are the files.' + }) + }) + + // Why: a SubagentStart that predates this listener (restart, relay reconnect) is never seen. + it('drops child-session hooks whose turn id equals their session id without SubagentStart', () => { + normalizeAndAccept(state, 'muse', { + ...main, + hook_event_name: 'Stop', + stop_hook_active: false, + last_assistant_message: 'done' + }) + for (const hook of childReminderHooks()) { + expect(normalizeAndAccept(state, 'muse', hook)).toBeNull() + } + expect(state.lastStatusByPaneKey.get(PANE_KEY)?.payload.state).toBe('done') + }) + + it('shows the approval card only once Muse notifies a permission prompt', () => { + const bash = { command: 'rm -rf build' } + normalizeAndAccept(state, 'muse', { + ...main, + hook_event_name: 'PreToolUse', + tool_name: 'bash', + tool_input: bash, + tool_use_id: 'call_1' + }) + expect( + normalizeAndAccept(state, 'muse', { + ...main, + hook_event_name: 'PermissionRequest', + tool_name: 'bash', + tool_input: bash + }) + ).toBeNull() + expect(state.lastStatusByPaneKey.get(PANE_KEY)?.payload.state).toBe('working') + + const waiting = normalizeAndAccept(state, 'muse', { + ...main, + hook_event_name: 'Notification', + notification_type: 'permission_prompt', + title: 'ws — waiting for approval', + message: 'bash wants to run rm -rf build' + }) + expect(waiting?.payload).toMatchObject({ + agentType: 'muse', + state: 'waiting', + toolName: 'bash', + interactivePrompt: JSON.stringify({ approval: { tool: 'bash', summary: 'rm -rf build' } }) + }) + + const approved = normalizeAndAccept(state, 'muse', { + ...main, + hook_event_name: 'PostToolUse', + tool_name: 'bash', + tool_input: bash, + tool_use_id: 'call_1' + }) + expect(approved?.payload.state).toBe('working') + expect(approved?.payload.interactivePrompt).toBeUndefined() + }) + + it('ignores Notification types other than permission_prompt', () => { + expect( + normalizeAndAccept(state, 'muse', { + ...main, + hook_event_name: 'Notification', + notification_type: 'idle_prompt', + message: 'Muse is waiting for your input' + }) + ).toBeNull() + }) + + it('reports a request_user_input question from the session log until it settles', () => { + const logPath = sessionLogPath() + const body = { ...main, hook_event_name: 'UserPromptSubmit', prompt: 'ask my favorite color' } + const working = normalizeAndAccept(state, 'muse', body) + expect(working?.payload.state).toBe('working') + + writeFileSync( + logPath, + sessionLogLine({ + kind: 'user_input_prompt_requested', + prompt_id: PROMPT_ID, + tool_name: 'request_user_input', + questions: [QUESTION] + }) + ) + const waiting = normalizeAndAccept(state, 'muse', body) + expect(waiting?.payload).toMatchObject({ + agentType: 'muse', + state: 'waiting', + toolName: 'request_user_input', + prompt: 'ask my favorite color', + interactivePrompt: JSON.stringify({ questions: [QUESTION] }) + }) + + appendFileSync( + logPath, + sessionLogLine({ + kind: 'user_input_prompt_settled', + prompt_id: PROMPT_ID, + outcome: 'answered', + answers: [{ id: 'fav_color', selected_label: 'Blue' }] + }) + ) + const answered = normalizeAndAccept(state, 'muse', body) + expect(answered?.payload.state).toBe('working') + expect(answered?.payload.interactivePrompt).toBeUndefined() + }) +}) diff --git a/src/shared/agent-hook-listener/providers/muse-events.ts b/src/shared/agent-hook-listener/providers/muse-events.ts new file mode 100644 index 00000000000..7c4889bb14a --- /dev/null +++ b/src/shared/agent-hook-listener/providers/muse-events.ts @@ -0,0 +1,158 @@ +import { isAskUserQuestionTool } from '../../agent-question-answered-intent' +import { + normalizeAgentStatusPayload, + type ParsedAgentStatusPayload +} from '../../agent-status-types' +import { createMuseSessionLogState, readMusePendingUserInput } from '../../muse-session-log' +import type { HookListenerState, MusePaneState } from '../listener-state' +import { + resolvePrompt, + resolveToolState, + shouldIgnoreCompactContinuationUserPromptSubmit +} from '../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' +import { readString } from '../tool-input-preview' + +const MAX_TRACKED_CHILD_SESSIONS = 64 + +function getMusePaneState(state: HookListenerState, paneKey: string): MusePaneState { + let pane = state.musePaneStateByPaneKey.get(paneKey) + if (!pane) { + pane = { childSessionIds: new Set() } + state.musePaneStateByPaneKey.set(paneKey, pane) + } + return pane +} + +function rememberChildSession(pane: MusePaneState, childSessionId: string): void { + pane.childSessionIds.add(childSessionId) + if (pane.childSessionIds.size > MAX_TRACKED_CHILD_SESSIONS) { + const oldest = pane.childSessionIds.values().next().value + if (oldest !== undefined) { + pane.childSessionIds.delete(oldest) + } + } +} + +function isChildSessionEvent(pane: MusePaneState, hookPayload: Record): boolean { + const sessionId = readString(hookPayload, 'session_id') + if (!sessionId) { + return false + } + // Why: Muse 1.3 child sessions reuse their session id as turn id; that covers a child whose + // SubagentStart predates this listener (Orca restart, relay reconnect). + return pane.childSessionIds.has(sessionId) || readString(hookPayload, 'turn_id') === sessionId +} + +/** True while a Muse pane has a known session log the transcript poll can read. */ +export function hasMuseSessionLog(state: HookListenerState, paneKey: string): boolean { + return state.musePaneStateByPaneKey.get(paneKey)?.log !== undefined +} + +// Muse uses Claude-compatible hook events but retains its own agent identity. +export function normalizeMuseEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + if (shouldIgnoreCompactContinuationUserPromptSubmit(eventName, promptText)) { + return null + } + + const pane = getMusePaneState(state, paneKey) + if (eventName === 'SubagentStart') { + const childSessionId = + readString(hookPayload, 'child_session_id') ?? readString(hookPayload, 'session_id') + if (childSessionId) { + rememberChildSession(pane, childSessionId) + } + return null + } + if (isChildSessionEvent(pane, hookPayload)) { + return null + } + const sessionId = readString(hookPayload, 'session_id') + if (sessionId && pane.log?.sessionId !== sessionId) { + pane.log = createMuseSessionLogState(sessionId) + } + + const toolName = readString(hookPayload, 'tool_name') + let toolEventName = eventName + let toolPayload = hookPayload + let stateName: 'working' | 'waiting' | 'done' + switch (eventName) { + case 'UserPromptSubmit': + case 'PostToolUse': + case 'PostToolUseFailure': + stateName = 'working' + pane.pendingApproval = undefined + break + case 'PreToolUse': + // Keep pendingApproval: the transcript poll replays this body while the approval is visible. + stateName = isAskUserQuestionTool(toolName) ? 'waiting' : 'working' + break + case 'PermissionRequest': + pane.pendingApproval = { toolName, toolInput: hookPayload.tool_input } + return null + case 'Notification': + if (hookPayload.notification_type !== 'permission_prompt') { + return null + } + stateName = 'waiting' + toolEventName = 'PermissionRequest' + toolPayload = { + ...hookPayload, + tool_name: pane.pendingApproval?.toolName, + tool_input: pane.pendingApproval?.toolInput + } + break + case 'Stop': + case 'StopFailure': + stateName = 'done' + pane.pendingApproval = undefined + break + default: + return null + } + + if (stateName === 'working' && pane.log) { + // Why: Muse fires no hook for `request_user_input`; its session log is the only structured signal. + const pendingInput = readMusePendingUserInput(pane.log, readString(hookPayload, 'turn_id')) + if (pendingInput) { + stateName = 'waiting' + toolEventName = 'PreToolUse' + toolPayload = { + ...hookPayload, + tool_name: 'request_user_input', + tool_input: { questions: pendingInput.questions } + } + } + } + + const snapshot = resolveToolState( + state, + paneKey, + extractToolFields('muse', toolEventName, toolPayload), + { resetOnNewTurn: isNewTurnEvent('muse', eventName) } + ) + + const interrupted = + eventName === 'Stop' && hookPayload['is_interrupt'] === true ? true : undefined + + return normalizeAgentStatusPayload({ + state: stateName, + // Why: Notification's `message` is status copy (" — waiting for approval"), not the user's prompt. + prompt: resolvePrompt(state, paneKey, eventName === 'Notification' ? '' : promptText, { + resetOnNewTurn: isNewTurnEvent('muse', eventName) + }), + agentType: 'muse', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + interactivePrompt: snapshot.interactivePrompt, + lastAssistantMessage: snapshot.lastAssistantMessage, + lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput, + interrupted + }) +} diff --git a/src/shared/agent-hook-listener/source-routing.ts b/src/shared/agent-hook-listener/source-routing.ts index b90da246254..818f4d9da8a 100644 --- a/src/shared/agent-hook-listener/source-routing.ts +++ b/src/shared/agent-hook-listener/source-routing.ts @@ -21,7 +21,8 @@ export const HOOK_SOURCE_BY_PATHNAME: Readonly> '/hook/copilot': 'copilot', '/hook/hermes': 'hermes', '/hook/devin': 'devin', - '/hook/kimi': 'kimi' + '/hook/kimi': 'kimi', + '/hook/muse': 'muse' }) export function resolveHookSource(pathname: string): AgentHookSource | null { diff --git a/src/shared/agent-hook-listener/transcript-poll-policy.ts b/src/shared/agent-hook-listener/transcript-poll-policy.ts new file mode 100644 index 00000000000..d27814cf928 --- /dev/null +++ b/src/shared/agent-hook-listener/transcript-poll-policy.ts @@ -0,0 +1,41 @@ +import type { AgentHookSource } from '../agent-hook-relay' +import type { AgentHookEventPayload } from './listener-event' +import type { HookListenerState } from './listener-state' +import { hasCodexTranscriptSubagents } from './providers/codex-state' +import { hasMuseSessionLog } from './providers/muse-events' + +/** Whether a pane's last hook body should be re-normalized on a timer to pick up transcript-only state. */ +export function shouldPollHookTranscript( + state: HookListenerState, + source: AgentHookSource, + event: AgentHookEventPayload +): boolean { + if (source === 'codex') { + return hasCodexTranscriptSubagents(state, event.paneKey) + } + if (source === 'muse') { + // Why: Muse's question tool fires no hook, so only its session log shows the wait and its answer. + return event.payload.state !== 'done' && hasMuseSessionLog(state, event.paneKey) + } + return false +} + +/** Returns the poll result to publish, or undefined when it carries nothing new. */ +export function transcriptPollUpdate( + source: AgentHookSource, + original: T, + polled: T +): T | undefined { + if (source === 'muse') { + const changed = + polled.payload.state !== original.payload.state || + polled.payload.interactivePrompt !== original.payload.interactivePrompt + // Why: a replayed UserPromptSubmit body is neither a newly sent prompt nor a turn boundary. + return changed + ? { ...polled, hasExplicitPrompt: undefined, hookEventName: undefined } + : undefined + } + const subagentsChanged = + JSON.stringify(polled.payload.subagents) !== JSON.stringify(original.payload.subagents) + return subagentsChanged ? polled : undefined +} diff --git a/src/shared/agent-hook-relay.ts b/src/shared/agent-hook-relay.ts index 6abdcb56aa0..33bee532ad6 100644 --- a/src/shared/agent-hook-relay.ts +++ b/src/shared/agent-hook-relay.ts @@ -53,7 +53,8 @@ const AGENT_HOOK_SOURCES = [ 'copilot', 'hermes', 'devin', - 'kimi' + 'kimi', + 'muse' ] as const export type AgentHookSource = (typeof AGENT_HOOK_SOURCES)[number] diff --git a/src/shared/agent-hook-types.ts b/src/shared/agent-hook-types.ts index 248638c5079..d08223ed053 100644 --- a/src/shared/agent-hook-types.ts +++ b/src/shared/agent-hook-types.ts @@ -17,7 +17,8 @@ export const AGENT_HOOK_TARGETS = [ 'copilot', 'hermes', 'devin', - 'kimi' + 'kimi', + 'muse' ] as const export type AgentHookTarget = (typeof AGENT_HOOK_TARGETS)[number] diff --git a/src/shared/agent-icons/muse.png b/src/shared/agent-icons/muse.png new file mode 100644 index 0000000000000000000000000000000000000000..f223b279d4c43c5a3e5dae69a5782ed5f6a76fa6 GIT binary patch literal 2003 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I3?%1nZ+yeRz|0Wf6XFWwGPL|-$au+6{+*%j z9|OaGhX4N=n*T9`{$pqYGt!@fxIokf7FqS5A^jNxP%!N|L;5?0sy`rZ#w&*MZy=O< zks;zLLw!BN^nVN`pBe1;GgSU$DE`P0zK)@OKZ8Bca0Z6lZ4CQ?R6WS*Q7{?;0~!Jf z(=JS9U|?h@3GxeO`19uv8zbX+<_Z3Jn+(MAnYA`d643EF*LR+qn~7~Ri%%Xa3kxGV z3)6tQn0A(jtuJi{mJ3auE{-7;bKZs>FMDjjBQsOMwe<9)oSS`fcRl^Tzd)WlVvC<& z^(}FkPcobmNo=kx``+IExA*Ub=|8LX+MH&8uX48|@5>9(*-Jir=9La!@@M^r$ALed z-tf76MJAoW|E0C6ECw-D6*oM<N z+kddE@Qd__k)QKk;G4qy`snh%LF~H0Vl5~BUoi1qbWHGr;^DKuR-IcOD70hwyh5Ib zi)&|bSn)Y-lNJm-v*M5)N5P{CUd^>%HZJk0{I<1!)}{$u!pHxAi9Ffgp|EXIrVigi zRf!7`jh9VdD3!KtNOM)bQppu8@J48kNb7Oy#o^a>3+c69`fFUhU-Pwpr+2`o)~87& zeg~%nwa4f$)Umrbb8D{ITK%0uYaf275wdGM+mX4aEh_ID^Xh z#Le+0R?`GC=3LJ_%y4TXm8~j|#&Bbzm0gDxP@^0mO z#dBl!8y-?TuCCK}xasJz?&3T46B{;Kgr2W#(>b)7dB^jmWh`fSqilFT*NDgR@d zFe!RL(UwV5Uz#mreqqn7pce2gPQ3TzWDAKOx(OPCcpN+u;bhPyL{^f&e*nh)IAZ8d$ccf_P?9A sg!ilOwe;{>dL_I)ecm3ieQ)hI+D99ycTAnp!UoFgp00i_>zopr0C7hE&;S4c literal 0 HcmV?d00001 diff --git a/src/shared/agent-kind.ts b/src/shared/agent-kind.ts index 5eb151be3e8..fd1e369c767 100644 --- a/src/shared/agent-kind.ts +++ b/src/shared/agent-kind.ts @@ -51,7 +51,8 @@ const TUI_AGENT_KIND_BY_AGENT = { grok: 'grok', devin: 'devin', ante: 'ante', - trae: 'trae' + trae: 'trae', + muse: 'muse' } satisfies Record // Why: `satisfies Record` makes the lookup exhaustive at compile diff --git a/src/shared/agent-process-recognition.test.ts b/src/shared/agent-process-recognition.test.ts index 7a9e961e292..3375edf2c08 100644 --- a/src/shared/agent-process-recognition.test.ts +++ b/src/shared/agent-process-recognition.test.ts @@ -205,6 +205,68 @@ describe('agent process recognition', () => { }) }) + it('recognizes Muse by its muse binary', () => { + expect(recognizeAgentProcess('muse')).toEqual({ + agent: 'muse', + processName: 'muse' + }) + expect(recognizeAgentProcess('/Users/dev/.local/bin/muse')).toEqual({ + agent: 'muse', + processName: 'muse' + }) + expect(isExpectedAgentProcess('/Users/dev/.local/bin/muse', 'muse')).toBe(true) + expect(isRecognizedAgentType('muse')).toBe(true) + }) + + it('recognizes Muse by its versioned muse-bin sibling binary', () => { + // Why: the `muse` launcher execs `muse-bin-` (a 242MB sibling), + // so the live foreground process carries the versioned name — truncated + // to `muse-bin-1.0.3-R` in macOS comm output (verified on-device). + expect(recognizeAgentProcess('muse-bin-1.0.3-R2198.1')).toEqual({ + agent: 'muse', + processName: 'muse-bin-1.0.3-r2198.1' + }) + expect(recognizeAgentProcess('muse-bin-1.0.3-R')).toEqual({ + agent: 'muse', + processName: 'muse-bin-1.0.3-r' + }) + expect(recognizeAgentProcess('/Users/dev/.local/bin/muse-bin-1.0.3-R2198.1')).toEqual({ + agent: 'muse', + processName: 'muse-bin-1.0.3-r2198.1' + }) + }) + + it('does not recognize Muse headless exec commands as interactive agents', () => { + expect(recognizeAgentProcessFromCommandLine('muse exec "summarize this diff"')).toBeNull() + expect( + recognizeAgentProcessFromCommandLine('muse exec --json "review this" > result.jsonl') + ).toBeNull() + // Why: a bare `exec` token dispatches as the subcommand even past `--` + // (verified: `muse -- resume` still resumes), so this errors instead of + // hosting a pane — never an interactive agent. + expect(recognizeAgentProcessFromCommandLine('muse -- exec "summarize this diff"')).toBeNull() + // Why: a whole-prompt `muse 'exec'` takes the exec missing-prompt error + // path, not a TUI, so filtering it is correct. + expect(recognizeAgentProcessFromCommandLine("muse 'exec'")).toBeNull() + // Why: `muse resume` reopens the interactive TUI, so it still hosts a live session. + expect(recognizeAgentProcessFromCommandLine('muse resume')).toEqual({ + agent: 'muse', + processName: 'muse' + }) + // Why: `muse -- resume` still dispatches to the resume subcommand (verified + // against muse 1.0.3), which reopens the interactive TUI. + expect(recognizeAgentProcessFromCommandLine('muse -- resume')).toEqual({ + agent: 'muse', + processName: 'muse' + }) + // Why: the prompt is one quoted argv, so it never equals the bare `exec` + // token — this is the interactive pane Orca itself launches. + expect(recognizeAgentProcessFromCommandLine('muse -- "exec the release notes"')).toEqual({ + agent: 'muse', + processName: 'muse' + }) + }) + it('recognizes Mistral Vibe by its installed executable and legacy alias', () => { expect(recognizeAgentProcess('/home/dev/.local/bin/vibe')).toEqual({ agent: 'mistral-vibe', @@ -434,4 +496,53 @@ describe('agent process recognition', () => { processName: 'grok-0.2.51' }) }) + + it('recognizes the versioned Muse binary execed by the launcher', () => { + expect(recognizeAgentProcess('muse-bin-1.3.0-r3401.1')).toEqual({ + agent: 'muse', + processName: 'muse-bin-1.3.0-r3401.1' + }) + expect( + recognizeAgentProcessFromCommandLine('/Users/dev/.local/bin/muse-bin-1.3.0-R3401.1') + ).toEqual({ + agent: 'muse', + processName: 'muse-bin-1.3.0-r3401.1' + }) + // Why: Linux truncates comm to 15 chars, so `muse-bin-1.0.3-R…` rows still match. + expect(recognizeAgentProcess('muse-bin-1.0.3-R')).toEqual({ + agent: 'muse', + processName: 'muse-bin-1.0.3-r' + }) + expect(recognizeAgentProcess('muse-workbench')).toBeNull() + expect(isRecognizedAgentType('muse-bin-1.3.0-R3401.1')).toBe(true) + expect(isExpectedAgentProcess('muse', 'muse')).toBe(true) + expect(isExpectedAgentProcess('muse-bin-1.3.0-R3401.1', 'muse')).toBe(true) + expect(isExpectedAgentProcess('/Users/dev/.local/bin/muse-bin-1.3.0-R3401.1', 'muse')).toBe( + true + ) + expect(isExpectedAgentProcess('not-muse', 'muse')).toBe(false) + expect(isExpectedAgentProcess('muse-workbench', 'muse')).toBe(false) + }) + + it('does not recognize Muse headless exec runs as interactive agents', () => { + expect(recognizeAgentProcessFromCommandLine('muse exec "summarize this diff"')).toBeNull() + expect( + recognizeAgentProcessFromCommandLine( + '/Users/dev/.local/bin/muse-bin-1.3.0-R3401.1 exec --session-id abc "hi"' + ) + ).toBeNull() + // Why: `exec` past any position is never the TUI — `muse exec` dispatches headless + // while `muse exec` fails fast with an arg error (verified on Muse 1.3.0). + expect(recognizeAgentProcessFromCommandLine('muse -- exec "summarize this diff"')).toBeNull() + expect(recognizeAgentProcessFromCommandLine('muse --yolo exec "hi"')).toBeNull() + expect(recognizeAgentProcessFromCommandLine('muse -- yolo')).toEqual({ + agent: 'muse', + processName: 'muse' + }) + // Why: subcommand dispatch is case-sensitive, so an uppercase prompt is the TUI. + expect(recognizeAgentProcessFromCommandLine("muse 'EXEC'")).toEqual({ + agent: 'muse', + processName: 'muse' + }) + }) }) diff --git a/src/shared/agent-process-recognition.ts b/src/shared/agent-process-recognition.ts index bfc8a6f240f..baf15a63759 100644 --- a/src/shared/agent-process-recognition.ts +++ b/src/shared/agent-process-recognition.ts @@ -94,6 +94,12 @@ function agentForNormalizedProcess(normalized: string): TuiAgent | undefined { if (normalized.startsWith('grok-')) { return PROCESS_TO_AGENT.get('grok') } + // Why: the `muse` launcher script execs a versioned `muse-bin-` binary, so + // the foreground name never equals `muse` itself. The `muse-bin-` prefix also covers + // comm-truncated rows (`muse-bin-1.0.3-R`) without matching unrelated `muse-*` tools. + if (normalized.startsWith('muse-bin-')) { + return PROCESS_TO_AGENT.get('muse') + } return undefined } @@ -162,10 +168,6 @@ function isInterpreterProcessName(normalized: string): boolean { return STATIC_INTERPRETER_PROCESS_NAMES.has(normalized) || PYTHON_PROCESS_RE.test(normalized) } -const isPythonProcessName = (normalized: string): boolean => PYTHON_PROCESS_RE.test(normalized) - -const optionName = (token: string): string => token.split('=', 1)[0] ?? '' - function findInterpreterEntrypointToken(tokens: string[], firstNormalized: string): string | null { if (!isInterpreterProcessName(firstNormalized)) { return null @@ -175,11 +177,11 @@ function findInterpreterEntrypointToken(tokens: string[], firstNormalized: strin if (token === '--') { continue } - if (isPythonProcessName(firstNormalized) && token === '-m') { + if (PYTHON_PROCESS_RE.test(firstNormalized) && token === '-m') { return tokens[index + 1] ?? null } if (token.startsWith('-')) { - const name = optionName(token) + const name = token.split('=', 1)[0] ?? '' if (INTERPRETER_OPTIONS_WITH_INLINE_SOURCE.has(name)) { return null } @@ -255,6 +257,10 @@ function recognizePythonEntrypoint( return recognizeAgentProcess(entrypoint) ?? recognizePythonScriptEntrypoint(entrypoint) } +// Why: `muse` execs a versioned `muse-bin-` binary (see above), so the +// exact-name check never matches and readiness/follow-up delivery would stall. +// Scoped to muse: a generic `-suffix` rule would misclassify short agent names +// (see the ante-obsidian test). export function isExpectedAgentProcess( processName: string | null | undefined, expectedProcess: string @@ -266,7 +272,8 @@ export function isExpectedAgentProcess( } return ( normalizedProcess === normalizedExpected || - normalizedProcess.startsWith(`${normalizedExpected}.`) + normalizedProcess.startsWith(`${normalizedExpected}.`) || + (normalizedExpected === 'muse' && normalizedProcess.startsWith('muse-bin-')) ) } @@ -306,7 +313,7 @@ export function recognizeAgentProcessFromCommandLine( if (!entrypoint) { return null } - const viaEntrypoint = isPythonProcessName(firstNormalized) + const viaEntrypoint = PYTHON_PROCESS_RE.test(firstNormalized) ? recognizePythonEntrypoint(tokens, entrypoint) : (recognizeAgentProcess(entrypoint) ?? recognizeNodeScriptEntrypoint(entrypoint)) if ( diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index 56aa3952e00..0a0ab7d443a 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -17,7 +17,8 @@ export const RESUMABLE_TUI_AGENTS = [ 'omp', 'prime-agent', 'copilot', - 'kimi' + 'kimi', + 'muse' ] as const satisfies readonly TuiAgent[] export type ResumableTuiAgent = (typeof RESUMABLE_TUI_AGENTS)[number] @@ -200,6 +201,10 @@ export function extractAgentProviderSession( const id = readSessionId(payload, ['session_id']) return id ? { key: 'session_id', id } : null } + case 'muse': { + const id = readSessionId(payload, ['session_id']) + return id ? withTranscriptPath({ key: 'session_id', id }, payload) : null + } case 'antigravity': { const id = readSessionId(payload, ['conversationId']) return id ? { key: 'conversation_id', id } : null @@ -298,5 +303,7 @@ export function getAgentResumeArgv( // Why: Kimi resumes by id with --session; sessions are work-dir-scoped (enforced by callers). case 'kimi': return providerSession.key === 'session_id' ? ['kimi', '--session', id] : null + case 'muse': + return providerSession.key === 'session_id' ? ['muse', 'resume', id] : null } } diff --git a/src/shared/agent-type-label.ts b/src/shared/agent-type-label.ts index 51f1695f508..662a0434bf5 100644 --- a/src/shared/agent-type-label.ts +++ b/src/shared/agent-type-label.ts @@ -25,7 +25,8 @@ const WELL_KNOWN_LABELS: Record = { devin: 'Devin', ante: 'Ante', trae: 'Trae', - kimi: 'Kimi' + kimi: 'Kimi', + muse: 'Muse' } export function formatAgentTypeLabel(agentType: AgentType | null | undefined): string { diff --git a/src/shared/ai-vault-resume-command.test.ts b/src/shared/ai-vault-resume-command.test.ts index 2ebb5d59794..da8ced39e0e 100644 --- a/src/shared/ai-vault-resume-command.test.ts +++ b/src/shared/ai-vault-resume-command.test.ts @@ -152,6 +152,17 @@ describe('buildAiVaultResumeCommand', () => { }) ).toBe("cd '/Users/ada/repo' && prime-agent --resume 'dddddddd-eeee-4fff-8aaa-111111111111'") }) + + it('resumes Muse by session id in the session cwd', () => { + expect( + buildAiVaultResumeCommand({ + agent: 'muse', + sessionId: 'eeeeeeee-ffff-4000-baaa-222222222222', + cwd: '/Users/ada/repo', + platform: 'darwin' + }) + ).toBe("cd '/Users/ada/repo' && muse resume 'eeeeeeee-ffff-4000-baaa-222222222222'") + }) }) describe('buildAiVaultResumeShellCommand env removal', () => { diff --git a/src/shared/ai-vault-resume-command.ts b/src/shared/ai-vault-resume-command.ts index da4cd29e038..fbd0658def1 100644 --- a/src/shared/ai-vault-resume-command.ts +++ b/src/shared/ai-vault-resume-command.ts @@ -212,6 +212,10 @@ function buildAgentResumeInvocation( return `${baseCommand} --session ${sessionArg}` case 'copilot': return `${baseCommand} --resume=${sessionArg}` + // Why: `muse resume ` reopens the session (resume is workspace-scoped, + // so the cwd prefix from buildAiVaultResumeCommand is required). + case 'muse': + return `${baseCommand} resume ${sessionArg}` case 'cline': return `${baseCommand} --id ${sessionArg}` case 'claude': diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index 24567e82fbc..b2635555bf6 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -20,7 +20,8 @@ export const AI_VAULT_AGENTS = [ 'devin', 'droid', 'cline', - 'kimi' + 'kimi', + 'muse' ] as const satisfies readonly TuiAgent[] // Why: the aiVault.listSessions RPC schema CLAMPS scopePaths to this bound @@ -66,7 +67,8 @@ export const AI_VAULT_AGENT_LABELS = { devin: 'Devin', droid: 'Droid', cline: 'Cline', - kimi: 'Kimi' + kimi: 'Kimi', + muse: 'Muse' } as const satisfies Record export type AiVaultSessionPreviewMessage = { diff --git a/src/shared/codex-rollout-jsonl-cursor.ts b/src/shared/codex-rollout-jsonl-cursor.ts index df47d5c3e68..57f68df3b54 100644 --- a/src/shared/codex-rollout-jsonl-cursor.ts +++ b/src/shared/codex-rollout-jsonl-cursor.ts @@ -17,8 +17,12 @@ export function record(value: unknown): JsonRecord | undefined { return typeof value === 'object' && value !== null ? (value as JsonRecord) : undefined } -/** Returns undefined when the file is unreadable, distinguishing a vanished rollout from one with no new lines. */ -export function readJsonlCursor(cursor: JsonlCursor): JsonRecord[] | undefined { +/** Returns undefined when the file is unreadable, distinguishing a vanished rollout from one with no new lines. + * `lineFilter` skips JSON.parse for raw lines the caller can reject by substring. */ +export function readJsonlCursor( + cursor: JsonlCursor, + lineFilter?: (line: string) => boolean +): JsonRecord[] | undefined { if (!cursor.filePath) { return undefined } @@ -63,7 +67,10 @@ export function readJsonlCursor(cursor: JsonlCursor): JsonRecord[] | undefined { } const records: JsonRecord[] = [] for (const line of lines) { - if (Buffer.byteLength(line, 'utf8') > TRANSCRIPT_LINE_MAX_BYTES) { + if ( + (lineFilter && !lineFilter(line)) || + Buffer.byteLength(line, 'utf8') > TRANSCRIPT_LINE_MAX_BYTES + ) { continue } try { diff --git a/src/shared/commit-message-agent-spec.test.ts b/src/shared/commit-message-agent-spec.test.ts index c304e129331..fee9a9f1046 100644 --- a/src/shared/commit-message-agent-spec.test.ts +++ b/src/shared/commit-message-agent-spec.test.ts @@ -37,6 +37,7 @@ describe('COMMIT_MESSAGE_AGENT_SPECS', () => { 'copilot', 'cursor', 'kimi', + 'muse', 'omp', 'opencode', 'opencode2', @@ -71,6 +72,26 @@ describe('COMMIT_MESSAGE_AGENT_SPECS', () => { expect(args).toEqual(expect.arrayContaining(['--model', 'kimi-code/kimi-for-coding'])) }) + it('uses Muse exec for non-interactive Source Control AI generation', () => { + const spec = COMMIT_MESSAGE_AGENT_SPECS.muse + expect(spec).toBeDefined() + expect(spec?.promptDelivery).toBe('argv') + expect(spec?.buildArgs({ prompt: 'Write a concise commit message', model: 'default' })).toEqual( + [ + 'exec', + '--no-session-log', + '--approval-mode', + 'never', + '--disable-sandbox', + '--disable-shell', + '--disable-write', + '--disable-web-tools', + '--', + 'Write a concise commit message' + ] + ) + }) + it('uses the provider-qualified Kimi model id accepted by the CLI', () => { expect(COMMIT_MESSAGE_AGENT_SPECS.kimi?.models.map((m) => m.id)).toEqual([ 'default', diff --git a/src/shared/commit-message-agent-specs-secondary.ts b/src/shared/commit-message-agent-specs-secondary.ts index 53992143d19..80dfe37212f 100644 --- a/src/shared/commit-message-agent-specs-secondary.ts +++ b/src/shared/commit-message-agent-specs-secondary.ts @@ -110,6 +110,33 @@ export function buildSecondaryCommitMessageAgentSpecs({ ], defaultModelId: 'default' }, + muse: { + id: 'muse', + label: 'Muse', + binary: 'muse', + // Muse's `exec` subcommand accepts a positional prompt. Keep Source + // Control AI one-shot and workspace-read-only, matching the other text + // generators rather than launching the interactive TUI. + promptDelivery: 'argv', + buildArgs: ({ prompt, model, thinkingLevel }) => [ + 'exec', + '--no-session-log', + '--approval-mode', + 'never', + '--disable-sandbox', + '--disable-shell', + '--disable-write', + '--disable-web-tools', + ...(model && model !== 'default' ? ['--model', model] : []), + ...(thinkingLevel ? ['--reasoning-effort', thinkingLevel] : []), + '--', + prompt + ], + singletonOptions: [['--model'], ['--reasoning-effort']], + modelSource: 'static', + models: [{ id: 'default', label: 'Config default' }], + defaultModelId: 'default' + }, copilot: { id: 'copilot', label: 'GitHub Copilot', diff --git a/src/shared/commit-message-plan.test.ts b/src/shared/commit-message-plan.test.ts index 2d58a5cf6e7..9e499ae4a95 100644 --- a/src/shared/commit-message-plan.test.ts +++ b/src/shared/commit-message-plan.test.ts @@ -318,6 +318,31 @@ describe('planCommitMessageGeneration', () => { }) }) + it('plans Muse exec with a positional prompt and no workspace side effects', () => { + const result = planCommitMessageGeneration({ agentId: 'muse', model: 'default' }, 'PROMPT') + + expect(result).toEqual({ + ok: true, + plan: { + binary: 'muse', + args: [ + 'exec', + '--no-session-log', + '--approval-mode', + 'never', + '--disable-sandbox', + '--disable-shell', + '--disable-write', + '--disable-web-tools', + '--', + 'PROMPT' + ], + stdinPayload: null, + label: 'Muse' + } + }) + }) + it('uses preset agent command overrides as the spawn command prefix', () => { const result = planCommitMessageGeneration( { diff --git a/src/shared/constants.test.ts b/src/shared/constants.test.ts index 9bf262ef45a..be50ca8464c 100644 --- a/src/shared/constants.test.ts +++ b/src/shared/constants.test.ts @@ -137,6 +137,7 @@ describe('getDefaultSettings', () => { codex: '--dangerously-bypass-approvals-and-sandbox', gemini: '--yolo', cursor: '--yolo', + muse: '--yolo', copilot: '--yolo', grok: '--permission-mode bypassPermissions' }) diff --git a/src/shared/muse-headless-command.ts b/src/shared/muse-headless-command.ts new file mode 100644 index 00000000000..ed1e3018267 --- /dev/null +++ b/src/shared/muse-headless-command.ts @@ -0,0 +1,9 @@ +// Why: `muse exec` runs one prompt headlessly and exits, so a pane running it +// must not classify as the interactive Muse TUI. `exec` matches past any position: +// `muse exec …` dispatches headless while `muse exec …` fails fast with an +// arg error — neither ever hosts the TUI. The match stays case-sensitive because +// subcommand dispatch is (`muse 'EXEC'` is a TUI prompt), and a quoted TUI prompt +// never splits into an `exec` token on its own. +export function isMuseHeadlessOneShotCommand(tokens: readonly string[]): boolean { + return tokens.slice(1).some((token) => token === 'exec') +} diff --git a/src/shared/muse-session-log.test.ts b/src/shared/muse-session-log.test.ts new file mode 100644 index 00000000000..dc82815cd3d --- /dev/null +++ b/src/shared/muse-session-log.test.ts @@ -0,0 +1,140 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { appendFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + createMuseSessionLogState, + findMuseSessionLogPath, + readMusePendingUserInput +} from './muse-session-log' + +const SESSION_ID = '01a0caa3-0e77-7d41-bad7-46283a45633d' +const PROMPT_ID = '01a0caa3-a25a-7810-8229-4de04b2e7ca3' +const QUESTION = { + id: 'fav_color', + header: 'Color', + question: 'What is your favorite color?', + options: [{ label: 'Blue' }, { label: 'Green' }, { label: 'Red' }] +} + +function logLine(event: Record): string { + return `${JSON.stringify({ + schema_version: 1, + stream: { kind: 'session', id: SESSION_ID }, + record_type: 'event', + payload_type: 'runtime.session', + payload: { kind: 'run', run_id: 'fdada6f1-d403-41d8-b610-52f1d8489334', event } + })}\n` +} + +const requested = (promptId = PROMPT_ID): string => + logLine({ + kind: 'user_input_prompt_requested', + prompt_id: promptId, + tool_name: 'request_user_input', + questions: [QUESTION] + }) +const settled = (promptId = PROMPT_ID): string => + logLine({ kind: 'user_input_prompt_settled', prompt_id: promptId, outcome: 'answered' }) + +function localShard(sessionId: string): string[] { + const hex = sessionId.replace(/-/g, '').slice(0, 12) + const date = new Date(Number.parseInt(hex, 16)) + return [ + String(date.getFullYear()), + String(date.getMonth() + 1).padStart(2, '0'), + String(date.getDate()).padStart(2, '0') + ] +} + +describe('muse session log', () => { + let sessionsDir: string + let logPath: string + + beforeEach(() => { + sessionsDir = mkdtempSync(join(tmpdir(), 'muse-session-log-')) + const dir = join(sessionsDir, ...localShard(SESSION_ID), SESSION_ID) + mkdirSync(dir, { recursive: true }) + logPath = join(dir, 'session.jsonl') + }) + + afterEach(() => { + rmSync(sessionsDir, { recursive: true, force: true }) + }) + + it('finds the log in the date shard named by the UUIDv7 timestamp', () => { + writeFileSync(logPath, '') + expect(findMuseSessionLogPath(SESSION_ID, sessionsDir)).toBe(logPath) + }) + + it('returns undefined for a missing log or a non-v7 session id', () => { + expect(findMuseSessionLogPath(SESSION_ID, sessionsDir)).toBeUndefined() + expect( + findMuseSessionLogPath('1471f5e6-bd60-41a2-bfcf-c9086dbd4092', sessionsDir) + ).toBeUndefined() + }) + + it('reads prompts batched inside a retained_frame', () => { + const frame = (line: string): string => + `${JSON.stringify({ record_type: 'retained_frame', children: [{ record_json: line.trim() }] })}\n` + writeFileSync(logPath, frame(requested())) + const log = createMuseSessionLogState(SESSION_ID) + expect(readMusePendingUserInput(log, undefined, sessionsDir)?.promptId).toBe(PROMPT_ID) + appendFileSync(logPath, frame(settled())) + expect(readMusePendingUserInput(log, undefined, sessionsDir)).toBeUndefined() + }) + + it('ignores a prompt left open by an earlier run', () => { + writeFileSync(logPath, requested()) + const log = createMuseSessionLogState(SESSION_ID) + expect( + readMusePendingUserInput(log, 'fdada6f1-d403-41d8-b610-52f1d8489334', sessionsDir)?.promptId + ).toBe(PROMPT_ID) + expect( + readMusePendingUserInput(log, '11111111-2222-4333-8444-555555555555', sessionsDir) + ).toBeUndefined() + }) + + it('returns the open prompt and clears it once settled', () => { + writeFileSync(logPath, requested()) + const log = createMuseSessionLogState(SESSION_ID) + expect(readMusePendingUserInput(log, undefined, sessionsDir)).toEqual({ + promptId: PROMPT_ID, + runId: 'fdada6f1-d403-41d8-b610-52f1d8489334', + questions: [QUESTION] + }) + // Re-reading with no new bytes keeps the prompt pending. + expect(readMusePendingUserInput(log, undefined, sessionsDir)?.promptId).toBe(PROMPT_ID) + + appendFileSync(logPath, settled()) + expect(readMusePendingUserInput(log, undefined, sessionsDir)).toBeUndefined() + }) + + it('holds a partial trailing line until it is completed', () => { + const line = requested() + writeFileSync(logPath, line.slice(0, 40)) + const log = createMuseSessionLogState(SESSION_ID) + expect(readMusePendingUserInput(log, undefined, sessionsDir)).toBeUndefined() + + appendFileSync(logPath, line.slice(40)) + expect(readMusePendingUserInput(log, undefined, sessionsDir)?.questions).toEqual([QUESTION]) + }) + + it('reports the newest of several open prompts', () => { + const second = '01a0caa4-0000-7000-8000-000000000001' + writeFileSync(logPath, `${requested()}${requested(second)}`) + const log = createMuseSessionLogState(SESSION_ID) + expect(readMusePendingUserInput(log, undefined, sessionsDir)?.promptId).toBe(second) + + appendFileSync(logPath, settled(second)) + expect(readMusePendingUserInput(log, undefined, sessionsDir)?.promptId).toBe(PROMPT_ID) + }) + + it('locates a log created after the first read', () => { + const log = createMuseSessionLogState(SESSION_ID) + expect(readMusePendingUserInput(log, undefined, sessionsDir)).toBeUndefined() + + writeFileSync(logPath, requested()) + expect(readMusePendingUserInput(log, undefined, sessionsDir)?.promptId).toBe(PROMPT_ID) + }) +}) diff --git a/src/shared/muse-session-log.ts b/src/shared/muse-session-log.ts new file mode 100644 index 00000000000..1b4d90977c4 --- /dev/null +++ b/src/shared/muse-session-log.ts @@ -0,0 +1,140 @@ +import { existsSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { + readJsonlCursor, + record, + type JsonlCursor, + type JsonRecord +} from './codex-rollout-jsonl-cursor' + +// Why: Muse stores sessions under /muse/sessions (default +// ~/.local/share/muse/sessions), sharded by the host's local start date: +// /YYYY/MM/DD//session.jsonl. No upstream override variable exists. +export function resolveMuseSessionsDir(override?: string): string { + if (override?.trim()) { + return override.trim() + } + const dataHome = process.env.XDG_DATA_HOME?.trim() || join(homedir(), '.local', 'share') + return join(dataHome, 'muse', 'sessions') +} + +const UUID_V7 = /^([0-9a-f]{8})-([0-9a-f]{4})-7[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/i +const DAY_MS = 24 * 60 * 60 * 1000 + +function dayParts(date: Date, utc: boolean): string[] { + const year = utc ? date.getUTCFullYear() : date.getFullYear() + const month = (utc ? date.getUTCMonth() : date.getMonth()) + 1 + const day = utc ? date.getUTCDate() : date.getDate() + return [String(year), String(month).padStart(2, '0'), String(day).padStart(2, '0')] +} + +/** Locates a live session's log from its UUIDv7 id, whose timestamp names the date shard. */ +export function findMuseSessionLogPath( + sessionId: string, + sessionsDir = resolveMuseSessionsDir() +): string | undefined { + const match = UUID_V7.exec(sessionId) + if (!match) { + return undefined + } + const startedAt = Number.parseInt(`${match[1]}${match[2]}`, 16) + const candidates = new Set() + // Why: the shard uses Muse's local zone, which can differ from ours (relay, TZ env), so probe neighbors. + for (const offset of [0, -DAY_MS, DAY_MS]) { + const date = new Date(startedAt + offset) + for (const utc of [false, true]) { + candidates.add(join(sessionsDir, ...dayParts(date, utc), sessionId, 'session.jsonl')) + } + } + for (const candidate of candidates) { + if (existsSync(candidate)) { + return candidate + } + } + return undefined +} + +export type MuseUserInputQuestion = Record + +export type MusePendingUserInput = { + promptId: string + /** Muse run that asked; equals the hook payload's `turn_id`. */ + runId?: string + questions: MuseUserInputQuestion[] +} + +export type MuseSessionLogState = { + sessionId: string + cursor: JsonlCursor + pending: Map +} + +const USER_INPUT_PROMPT_MARKER = '"user_input_prompt_' + +export function createMuseSessionLogState(sessionId: string): MuseSessionLogState { + return { sessionId, cursor: { offset: 0, carry: '' }, pending: new Map() } +} + +/** Muse batches some records into a `retained_frame` whose `children[].record_json` hold them as strings. */ +export function unwrapMuseLogRecords(line: JsonRecord): JsonRecord[] { + if (!Array.isArray(line.children)) { + return [line] + } + const records: JsonRecord[] = [] + for (const child of line.children) { + const raw: unknown = record(child)?.record_json + let value: unknown = raw + try { + value = typeof raw === 'string' ? JSON.parse(raw) : raw + } catch { + value = undefined + } + const parsed = record(value) + if (parsed) { + records.push(parsed) + } + } + return records +} + +function applyUserInputRecord(log: MuseSessionLogState, entry: JsonRecord): void { + const payload = record(entry.payload) + const event = record(payload?.event) + const promptId = typeof event?.prompt_id === 'string' ? event.prompt_id : undefined + if (!event || !promptId) { + return + } + if (event.kind === 'user_input_prompt_requested') { + const questions = Array.isArray(event.questions) + ? event.questions.flatMap((question: unknown) => { + const item = record(question) + return item ? [item] : [] + }) + : [] + const runId = typeof payload?.run_id === 'string' ? payload.run_id : undefined + log.pending.delete(promptId) + log.pending.set(promptId, { promptId, runId, questions }) + } else if (event.kind === 'user_input_prompt_settled') { + log.pending.delete(promptId) + } +} + +/** Advances the log and returns the newest unanswered `request_user_input` prompt of `turnId`'s run. */ +export function readMusePendingUserInput( + log: MuseSessionLogState, + turnId: string | undefined, + sessionsDir?: string +): MusePendingUserInput | undefined { + log.cursor.filePath ??= findMuseSessionLogPath(log.sessionId, sessionsDir) + // Why: most log lines are large model/tool records; parse only the two event kinds we read. + const lines = readJsonlCursor(log.cursor, (line) => line.includes(USER_INPUT_PROMPT_MARKER)) + for (const line of lines ?? []) { + for (const entry of unwrapMuseLogRecords(line)) { + applyUserInputRecord(log, entry) + } + } + // Why: a question left open by a crash or interrupt stays in the log; only the live turn's can block. + const pending = Array.from(log.pending.values()) + return pending.findLast((prompt) => !turnId || !prompt.runId || prompt.runId === turnId) +} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index e3b822323f6..b39f34d237c 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -220,6 +220,7 @@ export const AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY = export const AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY = 'agent-session.kimi-resume.v1' as const export const AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY = 'agent-session.opencode2-resume.v1' as const +export const AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY = 'agent-session.muse-resume.v1' as const // Why: older runtimes strip mutation owner fields, so clients must fence writes before RPC. export const FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY = 'files.mutation-ownership.v1' as const export const FILE_MUTATION_OWNERSHIP_UPDATE_REQUIRED_MESSAGE = @@ -367,6 +368,7 @@ export const RUNTIME_CAPABILITIES = [ AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY, + AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY, FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY, GITHUB_MARK_PR_READY_RUNTIME_CAPABILITY, GITLAB_READY_FOR_REVIEW_RUNTIME_CAPABILITY, diff --git a/src/shared/skill-install-providers.ts b/src/shared/skill-install-providers.ts index 6341b0a80cf..98fbda587b8 100644 --- a/src/shared/skill-install-providers.ts +++ b/src/shared/skill-install-providers.ts @@ -15,6 +15,7 @@ export type SkillInstallProviderId = | 'trae' | 'grok' | 'aug' + | 'muse' export type SkillInstallProviderDefinition = { id: SkillInstallProviderId @@ -80,6 +81,13 @@ export const SKILL_INSTALL_PROVIDERS: readonly SkillInstallProviderDefinition[] displayName: 'Augment', globalSegments: ['.augment', 'skills'], workspaceSegments: ['.augment', 'skills'] + }, + // Why: Muse reads the canonical .agents/skills root at both scopes. + { + id: 'muse', + displayName: 'Muse', + globalSegments: null, + workspaceSegments: null } ] diff --git a/src/shared/skills-cli-agent-keys.ts b/src/shared/skills-cli-agent-keys.ts index 6f0ac5cd8de..f99b1f1c248 100644 --- a/src/shared/skills-cli-agent-keys.ts +++ b/src/shared/skills-cli-agent-keys.ts @@ -51,7 +51,8 @@ export const SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT = { devin: 'devin', ante: null, // Why: Orca detects trae by `traecli`, an alias only TRAE CN ships. - trae: 'trae-cn' + trae: 'trae-cn', + muse: null } satisfies Record /** diff --git a/src/shared/source-control-ai-action-recipes.test.ts b/src/shared/source-control-ai-action-recipes.test.ts index 66310f28420..352f9cfa337 100644 --- a/src/shared/source-control-ai-action-recipes.test.ts +++ b/src/shared/source-control-ai-action-recipes.test.ts @@ -427,7 +427,7 @@ describe('source-control AI action recipes', () => { ).toEqual({ ok: false, error: - 'Agent "aider" does not support Source Control AI commit messages. Supported agents: OMP, Claude, Codex, OpenCode, OpenCode 2, Pi, Amp, Cursor, Kimi, GitHub Copilot, Antigravity, or Custom command.' + 'Agent "aider" does not support Source Control AI commit messages. Supported agents: OMP, Claude, Codex, OpenCode, OpenCode 2, Pi, Amp, Cursor, Kimi, Muse, GitHub Copilot, Antigravity, or Custom command.' }) }) }) diff --git a/src/shared/telemetry-property-schemas.ts b/src/shared/telemetry-property-schemas.ts index 035262cd6d8..81613deff8f 100644 --- a/src/shared/telemetry-property-schemas.ts +++ b/src/shared/telemetry-property-schemas.ts @@ -46,6 +46,7 @@ export const AGENT_KIND_VALUES = [ 'devin', 'ante', 'trae', + 'muse', 'other' ] as const export const agentKindSchema = z.enum(AGENT_KIND_VALUES) diff --git a/src/shared/tui-agent-config.test.ts b/src/shared/tui-agent-config.test.ts index 3ada94778b0..d60562981c0 100644 --- a/src/shared/tui-agent-config.test.ts +++ b/src/shared/tui-agent-config.test.ts @@ -23,7 +23,8 @@ describe('TUI_AGENT_CONFIG', () => { 'claude-agent-teams': { launchCmd: 'orca claude-teams', expectedProcess: 'claude' }, kiro: { launchCmd: 'kiro-cli chat --tui', expectedProcess: 'kiro-cli' }, 'command-code': { launchCmd: 'command-code --trust' }, - hermes: { launchCmd: 'hermes --tui' } + hermes: { launchCmd: 'hermes --tui' }, + muse: { launchCmd: 'muse --trust-workspace' } } for (const [agent, expected] of Object.entries(overrides)) { expect(TUI_AGENT_CONFIG[agent as TuiAgent]).toMatchObject(expected) diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index 256f7d30bec..cbdd8a3a065 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -308,6 +308,12 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { draftPasteReadySignal: 'grok-composer-prompt', ctrlEnterEncoding: 'csi-u' }, + muse: { + detectCmd: 'muse', + launchCmd: 'muse --trust-workspace', + // Muse 1.3 treats subcommand-shaped prompts as commands even after `--`. + promptInjectionMode: 'stdin-after-start' + }, devin: { detectCmd: 'devin', // Why: `devin -- ` auto-submits immediately (docs.devin.ai/cli), so start the REPL with no argv prompt. diff --git a/src/shared/tui-agent-display-names.ts b/src/shared/tui-agent-display-names.ts index 9c71a8f420f..4acb76e61af 100644 --- a/src/shared/tui-agent-display-names.ts +++ b/src/shared/tui-agent-display-names.ts @@ -13,6 +13,7 @@ export const TUI_AGENT_DISPLAY_NAMES: Record = { devin: 'Devin', ante: 'Ante', trae: 'Trae', + muse: 'Muse', autohand: 'Autohand Code', opencode: 'OpenCode', opencode2: 'OpenCode 2', diff --git a/src/shared/tui-agent-permissions.test.ts b/src/shared/tui-agent-permissions.test.ts index e1fa10e1993..3a5570be5c3 100644 --- a/src/shared/tui-agent-permissions.test.ts +++ b/src/shared/tui-agent-permissions.test.ts @@ -66,6 +66,23 @@ describe('tui agent permissions', () => { ) }) + it('switches Muse between yolo and manual arguments', () => { + expect( + applyAgentPermissionMode({ + mode: 'yolo', + agentDefaultArgs: { muse: '' }, + agentDefaultEnv: {} + }).agentDefaultArgs.muse + ).toBe('--yolo') + expect( + applyAgentPermissionMode({ + mode: 'manual', + agentDefaultArgs: { muse: '--yolo' }, + agentDefaultEnv: {} + }).agentDefaultArgs.muse + ).toBe('') + }) + it('resolves custom Codex permission arguments as mixed', () => { expect( resolveTuiAgentPermissionMode({ diff --git a/src/shared/tui-agent-permissions.ts b/src/shared/tui-agent-permissions.ts index 6fa64aa0458..e45adeea0d7 100644 --- a/src/shared/tui-agent-permissions.ts +++ b/src/shared/tui-agent-permissions.ts @@ -20,6 +20,7 @@ export const YOLO_TUI_AGENT_ARGS: Partial> = { continue: '--allow "*"', cursor: '--yolo', kimi: '--yolo', + muse: '--yolo', 'mistral-vibe': '--agent auto-approve', 'qwen-code': '--approval-mode yolo', rovo: '--yolo', diff --git a/src/shared/tui-agent-selection.ts b/src/shared/tui-agent-selection.ts index 8090cd88611..4afacfd9e21 100644 --- a/src/shared/tui-agent-selection.ts +++ b/src/shared/tui-agent-selection.ts @@ -15,6 +15,7 @@ export const TUI_AGENT_AUTO_PICK_ORDER = [ 'mimo-code', 'ante', 'trae', + 'muse', 'pi', 'omp', 'prime-agent', diff --git a/src/shared/tui-agent-startup.test.ts b/src/shared/tui-agent-startup.test.ts index 3e95d9212a0..31690889e9f 100644 --- a/src/shared/tui-agent-startup.test.ts +++ b/src/shared/tui-agent-startup.test.ts @@ -369,6 +369,50 @@ describe('tui agent startup plans', () => { }) }) + it.each([ + ['yolo', 'linux', 'posix', undefined, "muse --trust-workspace '--yolo'"], + ['manual', 'linux', 'posix', { muse: '' }, 'muse --trust-workspace'], + ['yolo', 'darwin', 'posix', undefined, "muse --trust-workspace '--yolo'"], + ['manual', 'darwin', 'posix', { muse: '' }, 'muse --trust-workspace'], + ['yolo', 'win32', 'powershell', undefined, "muse --trust-workspace '--yolo'"], + ['manual', 'win32', 'powershell', { muse: '' }, 'muse --trust-workspace'], + ['yolo', 'win32', 'cmd', undefined, 'muse --trust-workspace "--yolo"'], + ['manual', 'win32', 'cmd', { muse: '' }, 'muse --trust-workspace'] + ] as const)( + 'launches Muse in %s mode on %s/%s before delivering its prompt', + (_, platform, shell, defaults, command) => { + const plan = buildAgentStartupPlan({ + agent: 'muse', + prompt: 'fix it', + cmdOverrides: {}, + platform, + shell, + agentArgs: resolveTuiAgentLaunchArgs('muse', defaults) + }) + + expect(plan).toMatchObject({ + agent: 'muse', + launchCommand: command, + expectedProcess: 'muse', + followupPrompt: 'fix it' + }) + } + ) + + it.each(['exec', 'resume', '--help'])( + 'delivers the reserved Muse prompt %s as text', + (prompt) => { + const plan = buildAgentStartupPlan({ + agent: 'muse', + prompt, + cmdOverrides: {}, + platform: 'linux' + }) + expect(plan?.launchCommand).toBe('muse --trust-workspace') + expect(plan?.followupPrompt).toBe(prompt) + } + ) + it('leaves Claude command overrides untouched', () => { const plan = buildAgentStartupPlan({ agent: 'claude', diff --git a/src/shared/tui-agent.ts b/src/shared/tui-agent.ts index 0c2362bdd66..6bf80af5002 100644 --- a/src/shared/tui-agent.ts +++ b/src/shared/tui-agent.ts @@ -38,4 +38,5 @@ export type TuiAgent = | 'devin' // Devin CLI | 'ante' // Ante (Antigma Labs) | 'trae' // Trae CLI + | 'muse' // Muse (Meta `muse` CLI) | 'prime-agent' // Prime Agent (Prime Intellect)