mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 00:02:34 +00:00
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 <johncusackccm@gmail.com> 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>
This commit is contained in:
co-authored by
Adrien De oliveira
parent
7c46a69049
commit
ebed0964a2
@@ -178,6 +178,7 @@ Works with **any CLI agent** — if it runs in a terminal, it runs in Orca.
|
||||
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" alt="Grok logo" width="16" valign="middle" /> Grok</kbd></a>
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://dev.meta.ai/docs/muse-code"><kbd><img src="src/shared/agent-icons/muse.png" alt="Muse logo" width="16" valign="middle" /> Muse</kbd></a>
|
||||
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a>
|
||||
<a href="https://mimo.xiaomi.com/coder"><kbd><img src="https://www.google.com/s2/favicons?domain=mimo.xiaomi.com&sz=64" alt="MiMo Code logo" width="16" valign="middle" /> MiMo Code</kbd></a>
|
||||
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a>
|
||||
@@ -203,6 +204,7 @@ Works with **any CLI agent** — if it runs in a terminal, it runs in Orca.
|
||||
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" alt="Mistral Vibe logo" width="16" valign="middle" /> Mistral Vibe</kbd></a>
|
||||
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" alt="Qwen Code logo" width="16" valign="middle" /> Qwen Code</kbd></a>
|
||||
<a href="https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/"><kbd><img src="https://www.google.com/s2/favicons?domain=atlassian.com&sz=64" alt="Rovo Dev logo" width="16" valign="middle" /> Rovo Dev</kbd></a>
|
||||
<a href="https://dev.meta.ai/docs/muse-code"><kbd><img src="https://www.google.com/s2/favicons?domain=dev.meta.ai&sz=64" alt="Muse logo" width="16" valign="middle" /> Muse</kbd></a>
|
||||
<kbd>+ any CLI agent</kbd>
|
||||
</p>
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -178,6 +178,7 @@ Funciona con **cualquier agente CLI** — si corre en una terminal, corre en Orc
|
||||
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" alt="Grok logo" width="16" valign="middle" /> Grok</kbd></a>
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://dev.meta.ai/docs/muse-code"><kbd><img src="../../src/shared/agent-icons/muse.png" alt="Muse logo" width="16" valign="middle" /> Muse</kbd></a>
|
||||
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a>
|
||||
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a>
|
||||
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" alt="OpenClaude logo" width="16" valign="middle" /> OpenClaude</kbd></a>
|
||||
|
||||
@@ -182,6 +182,7 @@ Fonctionne avec **n'importe quel agent CLI** — s'il tourne dans un terminal, i
|
||||
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" alt="Logo Grok" width="16" valign="middle" /> Grok</kbd></a>
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Logo Cursor" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="Logo GitHub Copilot" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://dev.meta.ai/docs/muse-code"><kbd><img src="../../src/shared/agent-icons/muse.png" alt="Logo Muse" width="16" valign="middle" /> Muse</kbd></a>
|
||||
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="Logo OpenCode" width="16" valign="middle" /> OpenCode</kbd></a>
|
||||
<a href="https://mimo.xiaomi.com/coder"><kbd><img src="https://www.google.com/s2/favicons?domain=mimo.xiaomi.com&sz=64" alt="Logo MiMo Code" width="16" valign="middle" /> MiMo Code</kbd></a>
|
||||
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Logo Amp" width="16" valign="middle" /> Amp</kbd></a>
|
||||
|
||||
@@ -178,6 +178,7 @@ PR、Issue、プロジェクトボードをアプリ内で閲覧 — 任意の
|
||||
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" alt="Grok logo" width="16" valign="middle" /> Grok</kbd></a>
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://dev.meta.ai/docs/muse-code"><kbd><img src="../../src/shared/agent-icons/muse.png" alt="Muse logo" width="16" valign="middle" /> Muse</kbd></a>
|
||||
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a>
|
||||
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a>
|
||||
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" alt="OpenClaude logo" width="16" valign="middle" /> OpenClaude</kbd></a>
|
||||
|
||||
@@ -178,6 +178,7 @@ diff의 어느 줄에든 코멘트를 남기고 에이전트에게 바로 보내
|
||||
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" alt="Grok logo" width="16" valign="middle" /> Grok</kbd></a>
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://dev.meta.ai/docs/muse-code"><kbd><img src="../../src/shared/agent-icons/muse.png" alt="Muse logo" width="16" valign="middle" /> Muse</kbd></a>
|
||||
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a>
|
||||
<a href="https://mimo.xiaomi.com/coder"><kbd><img src="https://www.google.com/s2/favicons?domain=mimo.xiaomi.com&sz=64" alt="MiMo Code logo" width="16" valign="middle" /> MiMo Code</kbd></a>
|
||||
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a>
|
||||
|
||||
@@ -178,6 +178,7 @@ Funciona com **qualquer agente CLI** — se roda em um terminal, roda no Orca.
|
||||
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" alt="Logotipo do Grok" width="16" valign="middle" /> Grok</kbd></a>
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Logotipo do Cursor" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="Logotipo do GitHub Copilot" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://dev.meta.ai/docs/muse-code"><kbd><img src="../../src/shared/agent-icons/muse.png" alt="Logotipo do Muse" width="16" valign="middle" /> Muse</kbd></a>
|
||||
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="Logotipo do OpenCode" width="16" valign="middle" /> OpenCode</kbd></a>
|
||||
<a href="https://mimo.xiaomi.com/coder"><kbd><img src="https://www.google.com/s2/favicons?domain=mimo.xiaomi.com&sz=64" alt="Logotipo do MiMo Code" width="16" valign="middle" /> MiMo Code</kbd></a>
|
||||
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Logotipo do Amp" width="16" valign="middle" /> Amp</kbd></a>
|
||||
|
||||
@@ -178,6 +178,7 @@ VS Code 的编辑器,处处自动保存 — 把文件或图片直接拖入智
|
||||
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" alt="Grok logo" width="16" valign="middle" /> Grok</kbd></a>
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://dev.meta.ai/docs/muse-code"><kbd><img src="../../src/shared/agent-icons/muse.png" alt="Muse logo" width="16" valign="middle" /> Muse</kbd></a>
|
||||
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a>
|
||||
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a>
|
||||
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" alt="OpenClaude logo" width="16" valign="middle" /> OpenClaude</kbd></a>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -40,5 +40,6 @@ export const MOBILE_AGENT_ICON_ASSETS: Partial<Record<TuiAgent, ImageSourcePropT
|
||||
rovo: require('../../../src/shared/agent-icons/rovo.png'),
|
||||
hermes: require('../../../src/shared/agent-icons/hermes.png'),
|
||||
devin: require('../../../src/shared/agent-icons/devin.png'),
|
||||
muse: require('../../../src/shared/agent-icons/muse.png'),
|
||||
openclaw: require('../../../src/shared/agent-icons/openclaw.png')
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export const MOBILE_TUI_AGENT_FAVICON_DOMAINS: Partial<Record<TuiAgent, string>>
|
||||
'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',
|
||||
|
||||
@@ -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()]
|
||||
]
|
||||
|
||||
@@ -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<string, CommandBuilders>([
|
||||
local: (path) => [wrapPosixHookCommand(path.replaceAll('\\', '/'))],
|
||||
remote: (path) => [wrapPosixHookCommand(path)]
|
||||
}
|
||||
],
|
||||
[
|
||||
'muse',
|
||||
{
|
||||
local: (path) => [getMuseManagedCommand(path)],
|
||||
remote: (path) => [getMuseRemoteManagedCommand(path)]
|
||||
}
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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<string, string> = {}): {
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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, unknown>): 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -40,7 +40,8 @@ const NEW_TURN_EVENT: Record<AgentHookSource, string | null> = {
|
||||
opencode: 'SessionStart',
|
||||
opencode2: 'SessionStart',
|
||||
'mimo-code': null,
|
||||
'command-code': null
|
||||
'command-code': null,
|
||||
muse: 'UserPromptSubmit'
|
||||
}
|
||||
|
||||
function reviveRetiredPane(source: unknown, hookEventName: string): boolean {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<CodexSubagentPoll>(
|
||||
private readonly transcriptPollScheduler = new CodexSubagentPollScheduler<TranscriptPoll>(
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<AiVaultSession | null> {
|
||||
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<AiVaultSession | null> {
|
||||
return parseMessageGraphSessionContent('pi', file, content, platform, options, signal)
|
||||
}
|
||||
|
||||
export function ompParser(
|
||||
file: FileWithMtime,
|
||||
content: RemoteSessionContent,
|
||||
platform: NodeJS.Platform,
|
||||
options: RemoteParserOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseMessageGraphSessionContent('omp', file, content, platform, options, signal)
|
||||
}
|
||||
|
||||
export function primeAgentParser(
|
||||
file: FileWithMtime,
|
||||
content: RemoteSessionContent,
|
||||
platform: NodeJS.Platform,
|
||||
options: RemoteParserOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseMessageGraphSessionContent('prime-agent', file, content, platform, options, signal)
|
||||
}
|
||||
|
||||
export function openClawParser(
|
||||
file: FileWithMtime,
|
||||
content: RemoteSessionContent,
|
||||
platform: NodeJS.Platform,
|
||||
options: RemoteParserOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSession | null> {
|
||||
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']
|
||||
}
|
||||
@@ -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<AiVaultSession | null> {
|
||||
return parseMessageGraphSessionContent('pi', file, content, platform, options, signal)
|
||||
}
|
||||
|
||||
function ompParser(
|
||||
file: FileWithMtime,
|
||||
content: RemoteSessionContent,
|
||||
platform: NodeJS.Platform,
|
||||
options: RemoteParserOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseMessageGraphSessionContent('omp', file, content, platform, options, signal)
|
||||
}
|
||||
|
||||
function primeAgentParser(
|
||||
file: FileWithMtime,
|
||||
content: RemoteSessionContent,
|
||||
platform: NodeJS.Platform,
|
||||
options: RemoteParserOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseMessageGraphSessionContent('prime-agent', file, content, platform, options, signal)
|
||||
}
|
||||
|
||||
function openClawParser(
|
||||
file: FileWithMtime,
|
||||
content: RemoteSessionContent,
|
||||
platform: NodeJS.Platform,
|
||||
options: RemoteParserOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSession | null> {
|
||||
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']
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <root>/YYYY/MM/DD/<uuid>/session.jsonl;
|
||||
// match only those (not sibling .log/.sqlite3 sidecars or the .msp-view
|
||||
// materialized projection).
|
||||
filePredicate: (filePath) => basename(filePath) === 'session.jsonl'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
|
||||
|
||||
@@ -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<EveryAgentVaul
|
||||
const antigravitySessionId = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'
|
||||
const { ompSessionFile, primeAgentSessionFile } = await writeLogAgentFixtures(roots)
|
||||
await writeDocumentAgentFixtures(root, roots, antigravitySessionId)
|
||||
await writeMuseScannerFixture(roots.museSessionsDir)
|
||||
roots.opencodeDbPaths = [await writeOpenCode2SqliteFixture(root)]
|
||||
return { roots, antigravitySessionId, ompSessionFile, primeAgentSessionFile }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import { unwrapMuseLogRecords } from '../../shared/muse-session-log'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import {
|
||||
remoteSessionContentLines,
|
||||
type RemoteSessionContent
|
||||
} from './remote-session-content-lines'
|
||||
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
|
||||
import type { TranscriptMessageSink } from './session-transcript-consumers'
|
||||
import {
|
||||
addPreviewContent,
|
||||
createAccumulator,
|
||||
finalizeSession,
|
||||
updateTimeline
|
||||
} from './session-scanner-accumulator'
|
||||
import { museSessionIdFromFilePath } from './session-scanner-muse-paths'
|
||||
import {
|
||||
arrayValue,
|
||||
asRecord,
|
||||
extractString,
|
||||
normalizeTitleText,
|
||||
numberValue,
|
||||
parseJsonObject,
|
||||
timestampMs
|
||||
} from './session-scanner-values'
|
||||
|
||||
type ParserSessionOptions = {
|
||||
executionHostId?: ExecutionHostId
|
||||
executionHostPlatform?: NodeJS.Platform | null
|
||||
}
|
||||
|
||||
type MuseRecord = {
|
||||
recordType: string | null
|
||||
payloadType: string | null
|
||||
recordedAtMs: number | null
|
||||
payload: Record<string, unknown> | 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, unknown>): 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<string, unknown>,
|
||||
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<string>,
|
||||
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<AiVaultSession | null> {
|
||||
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<AiVaultSession | null> {
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { basename, dirname } from 'node:path'
|
||||
|
||||
export { resolveMuseSessionsDir } from '../../shared/muse-session-log'
|
||||
|
||||
// Layout: <root>/YYYY/MM/DD/<uuid>/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))
|
||||
}
|
||||
@@ -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' }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ function resumableStateFactoryFor(
|
||||
case 'hermes':
|
||||
case 'cline':
|
||||
case 'kimi':
|
||||
case 'muse':
|
||||
case 'opencode':
|
||||
case 'opencode2':
|
||||
case 'rovo':
|
||||
|
||||
@@ -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 <root>/YYYY/MM/DD/<uuid>/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<string> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ export type AiVaultScanOptions = {
|
||||
droidProjectsDir?: string
|
||||
clineSessionsDir?: string
|
||||
kimiSessionsDir?: string
|
||||
museSessionsDir?: string
|
||||
limit?: number
|
||||
unlimited?: boolean
|
||||
limitPerAgent?: number
|
||||
|
||||
@@ -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'"
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown>
|
||||
}
|
||||
|
||||
export function parseMuseSettingsText(
|
||||
text: string,
|
||||
diagnosticName: string
|
||||
): Record<string, unknown> | 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<string, unknown> = { 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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown>,
|
||||
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<void> {
|
||||
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<AgentHookInstallStatus> {
|
||||
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()
|
||||
@@ -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<string, HookDefinition[]> = {}
|
||||
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<string> {
|
||||
const present = new Set<string>()
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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[c[6n
|
||||
|
||||
|
||||
|
||||
[5;1H[2K7[1;7r[1;1HMMM[r8[2;3H[1mMuse Code[2;13H[22m1.3.0[m[m[m[0m[1;1H [2;1H [1mMuse Code[22m 1.3.0 [3;1H [m[m[m[0m[4;1H[J[22;0t]0;muse-ready-workspace______[4;1H[2m── [22mVoice[4;10Hinput[4;16H(⌥[4;19H+[4;21Hv[4;23Hto[4;26Hstart)[2m ────────────────────────────────────────────────────────────────────────────────────────[5;1H[22m❯[6;1H[2m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[7;3H[22mecho[7;8H·[7;10H/private/tmp/muse-ready-workspace______[m[m[m[0m[5;3H[?25h[m[m[m[0m[5;3H[7;50H·[7;52HAuto-review[m[m[m[0m[5;3H[m[m[m[0m[5;3H[m[m[m[0m[5;3H[m[m[m[0m[5;3H
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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> = {}): TuiIdleEvidenceRecord {
|
||||
return {
|
||||
lastAgentStatus: null,
|
||||
lastOutputAt: Date.now() - QUIESCENCE_MS * 2,
|
||||
lastOscTitle: 'tmp',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function input(overrides: Partial<TuiIdleSatisfactionInput> = {}): 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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -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<string, ReturnType<typeof setTimeout>>()
|
||||
private codexSubagentPollScheduler: CodexSubagentPollScheduler<CodexSubagentPoll>
|
||||
private transcriptPollScheduler: CodexSubagentPollScheduler<TranscriptPoll>
|
||||
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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -253,7 +253,9 @@ describe('SkillInstallDialog', () => {
|
||||
})
|
||||
render(<SkillInstallDialog open onOpenChange={() => 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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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-')
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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<Record<TuiAgent, string>> = {
|
||||
rovo: rovoUrl,
|
||||
hermes: hermesUrl,
|
||||
devin: devinUrl,
|
||||
muse: museUrl,
|
||||
openclaw: openclawUrl
|
||||
}
|
||||
|
||||
@@ -135,7 +135,8 @@ const ICONABLE_AGENT_TYPES: Record<TuiAgent, true> = {
|
||||
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.
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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<ResumableTuiAgent, RuntimeCapability | undefined>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<TuiAgent, (tokens: readonly string[]) => boolean>
|
||||
> = {
|
||||
claude: isPrintModeHeadlessOneShotCommand,
|
||||
trae: isPrintModeHeadlessOneShotCommand,
|
||||
'prime-agent': isPrimeAgentHeadlessOneShotCommand,
|
||||
ante: isAnteHeadlessOneShotCommand
|
||||
ante: isAnteHeadlessOneShotCommand,
|
||||
muse: isMuseHeadlessOneShotCommand
|
||||
}
|
||||
|
||||
export function isHeadlessOneShotAgentCommand(agent: TuiAgent, tokens: readonly string[]): boolean {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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<string, CodexLeadTurnState>
|
||||
/** Newest Grok turn per pane, used to reject end reports that arrive after a replacement prompt. */
|
||||
grokActiveTurnByPaneKey: Map<string, GrokActiveTurn>
|
||||
/** Muse child-session filter and session-log cursor per pane. */
|
||||
musePaneStateByPaneKey: Map<string, MusePaneState>
|
||||
/**
|
||||
* 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<string, string>
|
||||
}
|
||||
|
||||
export type MusePaneState = {
|
||||
/** Internal reminder/subagent sessions; their hooks inherit the pane env and fire even after Stop. */
|
||||
childSessionIds: Set<string>
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, unknown>): 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<string, unknown>[] {
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown>): 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<string, unknown>
|
||||
): 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 ("<dir> — 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
|
||||
})
|
||||
}
|
||||
@@ -21,7 +21,8 @@ export const HOOK_SOURCE_BY_PATHNAME: Readonly<Record<string, AgentHookSource>>
|
||||
'/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 {
|
||||
|
||||
@@ -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<T extends AgentHookEventPayload>(
|
||||
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
|
||||
}
|
||||
@@ -53,7 +53,8 @@ const AGENT_HOOK_SOURCES = [
|
||||
'copilot',
|
||||
'hermes',
|
||||
'devin',
|
||||
'kimi'
|
||||
'kimi',
|
||||
'muse'
|
||||
] as const
|
||||
|
||||
export type AgentHookSource = (typeof AGENT_HOOK_SOURCES)[number]
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
@@ -51,7 +51,8 @@ const TUI_AGENT_KIND_BY_AGENT = {
|
||||
grok: 'grok',
|
||||
devin: 'devin',
|
||||
ante: 'ante',
|
||||
trae: 'trae'
|
||||
trae: 'trae',
|
||||
muse: 'muse'
|
||||
} satisfies Record<TuiAgent, ConcreteAgentKind>
|
||||
|
||||
// Why: `satisfies Record<TuiAgent, …>` makes the lookup exhaustive at compile
|
||||
|
||||
@@ -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-<version>` (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 <flags> 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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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-<version>` 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-<version>` 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 (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ const WELL_KNOWN_LABELS: Record<string, string> = {
|
||||
devin: 'Devin',
|
||||
ante: 'Ante',
|
||||
trae: 'Trae',
|
||||
kimi: 'Kimi'
|
||||
kimi: 'Kimi',
|
||||
muse: 'Muse'
|
||||
}
|
||||
|
||||
export function formatAgentTypeLabel(agentType: AgentType | null | undefined): string {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -212,6 +212,10 @@ function buildAgentResumeInvocation(
|
||||
return `${baseCommand} --session ${sessionArg}`
|
||||
case 'copilot':
|
||||
return `${baseCommand} --resume=${sessionArg}`
|
||||
// Why: `muse resume <uuid>` 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':
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user