diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 287b29d..b68b037 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,12 +29,29 @@ jobs: - run: npm test - run: npm run pack + windows-minimax: + runs-on: windows-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + node: [22, 26] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ matrix.node }} + cache: npm + - run: npm ci + - run: npm run test:minimax + - run: npm run pack + # A release is a tag vX.Y.Z a maintainer pushes on main; nothing on main names a version. The tag's version is # stamped into every packed manifest, and the release branch, which the hosts install from (Claude Code, WorkBuddy, # Grok and Qoder through their marketplaces, Codex at --ref, Antigravity, OpenCode and DeepSeek Harness from a # clone), moves one commit on, with the tag re-pointed there. A push made with this workflow's token starts no run. publish: - needs: test + needs: [test, windows-minimax] if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest timeout-minutes: 10 diff --git a/.minimax-plugin/plugin.json b/.minimax-plugin/plugin.json new file mode 100644 index 0000000..a50d664 --- /dev/null +++ b/.minimax-plugin/plugin.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 1, + "name": "evolver-plugin", + "displayName": "Evolver", + "version": "1.0.0", + "description": "Recall reusable strategies, report verified results, and distill completed work into local drafts.", + "author": "EvoMap", + "repository": "https://github.com/EvoMap/evolver-agent-plugin", + "license": "MIT", + "icon": "assets/logo.png", + "category": "Code", + "exampleQueries": [], + "apps": [], + "mcpServers": [], + "skills": [], + "hooks": ["hosts/minimax/dist/hooks/hooks.json"] +} diff --git a/README.md b/README.md index 27f5f99..a2b9bdd 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ The plugin is the access layer: shared logic lives in `lib/`, and each host is a | DeepSeek Harness (dsh) | Supported | `hosts/dsh`, an in-process cordis plugin; installs `dist/dsh` | | Grok CLI 1.0.70+ | Supported | `hosts/grok`, command hooks; the strategy reaches the model at the turn's first tool result; installs `dist/grok` | | Qoder CLI (`qodercli`) 1.1.79+ | Supported | `hosts/qoder`, command hooks; installs `dist/qoder` | +| MiniMax Code CLI 0.7.4 / Desktop 3.1.6 | Supported locally | `hosts/minimax`, native hooks; installs `dist/minimax` | | Muse Code | Not yet | No reviewed adapter yet | | OpenAI Dots, Claude Duet, Grok Bot, Meta Muse | Not yet | Cloud agents without hooks; need Evolver's remote MCP and routines | @@ -135,6 +136,17 @@ injections, undelivered reports), so nothing is lost on the way over. prompt box (`evolver-status`, with or without its `/`) and answered as a blocked prompt; `/evolver-evolve` is a Grok command. Each release tag stamps its version into the packed folder; `grok plugin update evolver-plugin` fetches it. - **Cursor:** add this repository as a plugin marketplace and install `evolver-plugin`; it installs the repo itself. +- **MiniMax Code:** run `node scripts/install-minimax.mjs install` from a checkout, + then run `mcode plugin enable evolver-plugin@local --json` and start a + new task. The data root defaults to `~/.minimax`; keep the same `MINIMAX_DATA_DIR` for a separate profile. + A release checkout installs its versioned `dist/minimax` bundle; `--version X.Y.Z` builds the current sources explicitly. + The checkout's manifest is `.minimax-plugin/plugin.json`; source stays in `hosts/minimax/`. + This is a local installation: MiniMax's current public Marketplace submission format excludes hooks. + Use `evolver-status`, `evolver-drafts` and `evolver-evolve` in chat; `evolver-config recall off` and + `evolver-config auto-distill off` save preferences. Longer commands run as background jobs; use + `evolver-jobs`, `evolver-jobs job ` or `evolver-jobs cancel ` to inspect or cancel them. + [The MiniMax adapter guide](hosts/minimax/dist/README.md) covers updates, session-model distillation, private + configuration copies and recovery when a host exits without `SessionEnd`. - **Antigravity:** `agy plugin uninstall evolver-antigravity-plugin`, then from the `release` branch, `git clone --depth 1 --branch release https://github.com/EvoMap/evolver-agent-plugin.git` and `agy plugin install evolver-agent-plugin/dist/antigravity`. diff --git a/bin/worker.mjs b/bin/worker.mjs index 45d75bc..8c12346 100644 --- a/bin/worker.mjs +++ b/bin/worker.mjs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 EvoMap -import { runWorker } from '../lib/channels/worker/runner.js'; +import { runWorkerProcess } from '../lib/channels/worker/runner.js'; const [agent, ...tasks] = process.argv.slice(2); -process.exit((await runWorker(agent, tasks)) ? 0 : 1); +await runWorkerProcess(agent, tasks); diff --git a/docs/architecture.md b/docs/architecture.md index fbeb64c..c9e553f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -23,13 +23,20 @@ them is in `hosts/README.md`): ## Configuration -Every host honours these environment variables: +Hosts whose native environment preserves them honour these environment variables. MiniMax strips inherited +Evolver flags from hooks; its persistent preferences are described below. | Variable | Default | Purpose | |---|---|---| | `HOME` / `USERPROFILE` | (unset) | User directory for Proxy settings and plugin state. A non-empty `HOME` wins. | | `EVOMAP_PROXY_PORT` | `19820` | Fallback Proxy port when `~/.evolver/settings.json` names no URL. | | `EVOLVER_RECALL` | (on) | `off` turns recall off. | +| `EVOLVER_AUTO_DISTILL` | (on) | `off` leaves distillation to an explicit command. | + +MiniMax stores boolean `recall` and `autoDistill` preferences in `/minimax-settings.json`. +`evolver-config recall off` and `evolver-config auto-distill off` update them without opening a model turn. +Status reports the effective values and any invalid preferences; invalid/unreadable files pause both automatic +activities instead of silently enabling them. Explicit environment values win where the host preserves them. Claude Code's options, editable from the `/config` menu, set the same things for its relay process: @@ -42,7 +49,7 @@ Claude Code's options, editable from the `/config` menu, set the same things for ## Troubleshooting -State lives in `~/.evolver/state/` (WorkBuddy: its plugin data folder), every file named `-`. The Hub sign-in is one for every host, as Evolver's identity home is: `~/.evolver/login/`. +State lives in `~/.evolver/state/` (WorkBuddy and MiniMax: their plugin data folders), every file named `-`. The Hub sign-in is one for every host, as Evolver's identity home is: `~/.evolver/login/`. - **Recall injects nothing.** Hub text recall usually takes 3–11 s and sometimes fails with a Hub 504, which the Proxy relays as HTTP 400. The Hub judges each recalled asset's fit to the session's task and returns only those @@ -126,7 +133,11 @@ The plugin never configures the verifier or manufactures receipts; the Proxy val A correction while the model answers stops the post; a candidate is retried at the next session start and given up after three failed tries, and one no model here can take waits a day for an agent's Gene before it is dropped. A headless run carries `EVOLVER_HEADLESS=1`, which turns this plugin's hooks off -inside it. An agent with no model outside the conversation writes the Gene itself when asked. +inside it. MiniMax strips that variable, so a private marker in the runtime's empty scratch folder suppresses +re-entry too. Its candidate captures provider/model/variant from a read-only projection of native v2 session +metadata and checks the hook's model name; an unknown schema or mismatch never selects another provider. +The prepared CLI configuration is private to the run and removed after completion, error or cancellation. +An agent with no model outside the conversation writes the Gene itself when asked. **MCP.** An agent without hooks (Kiro, openclaw, any MCP client) runs `bin/mcp.mjs `, an stdio server with seven tools: `evolver_recall` (a turn starts), `evolver_report` (it ends, with the checks the agent ran), `evolver_distill` @@ -299,6 +310,7 @@ scripts/ pack.mjs, publish-tag.sh ```bash npm ci npm test +npm run test:minimax npx -p typescript tsc -p . npm run validate ``` @@ -309,6 +321,11 @@ update. `npm run validate` packs `dist/claude-code` and runs `claude plugin vali version: bump `version` in a host's manifest with every change meant to reach its users, or the host reports the old version as the latest. +Release CI runs the full suite on Linux and the MiniMax/packed-hook/shared-runtime suite on Windows, each +on Node 22 and 26. Both jobs must pass before publication. MiniMax tests use disposable native profiles and +local service fixtures; they do not require a developer's MiniMax account, Hub token or globally installed CLI. +The MiniMax suite runs up to four test files at once; its multi-process contention tests retain their own parallel workers. + ## Open questions - Whether `@evomap/evolver-core`'s terms allow redistributing a plugin that imports it. diff --git a/hosts/README.md b/hosts/README.md index e462c87..3ac9dfb 100644 --- a/hosts/README.md +++ b/hosts/README.md @@ -23,31 +23,54 @@ a channel. | Owner | `owner: 'self' \| 'spawner' \| 'parent' \| { ancestor: RegExp } \| { ancestorPath: RegExp }`; `spawner` is the process that started this one with no shell between, on every platform | `parent` | | Headless model | `model: { kind: 'cli', command: (env) → path \| { names, dirs?(env), accepts?(name, realPath), nodeScriptOnWindows?, windowsShims? }, args(model, prompt, { workdir, preflight, promptPath }), parse, input?(prompt), workdir?: 'empty', promptFile?, env?, shim?, preflight?: { args, parse({ code, stdout }) } } \| { kind: 'sdk', complete? } \| { kind: 'in-session' } \| null`; names are looked up on PATH, then in `dirs`; `preflight` runs first and either hands `args` what it found or stops the run; `promptFile` writes the prompt into the empty folder under that name instead of stdin; `env` sets variables for the run | `null` | | State | `stateDir(env)` | `~/.evolver/state` | +| Preferences | `settingsFile(env)`; optional JSON file with boolean `recall` and `autoDistill`; `evolver-config recall\|auto-distill on\|off` saves them | environment only | +| Session model record | `modelRecord: { file(env), query }`; optional read-only SQLite projection, exposed to payload readers as `context.modelRecord(sessionId)` | none | | Leftovers | `legacy: [{ folder } \| { rename } \| { sessions } \| { ledgers } \| { turns } \| { retention }]` (`folder`: an older install's state folder beside this one, moved in first): what the host's standalone plugin left, adopted file by file until none is left; its unreported injections are reported aborted at the next session start | `[]` | -Every host also honours `EVOLVER_RECALL=off`, which turns recall off, and `EVOLVER_AUTO_DISTILL=off`, which leaves distillation to `/evolver-distill`. Every host installs the plugin as `evolver-plugin`. +Hosts whose hook environment preserves them honour `EVOLVER_RECALL=off`, which turns recall off, and `EVOLVER_AUTO_DISTILL=off`, which leaves distillation to `/evolver-distill`. MiniMax strips these inherited variables from native hooks. Every host installs the plugin as `evolver-plugin`. ## Matrix From each host's reviewed standalone plugin; bold is where it differs from the default. -| Point | Claude Code | WorkBuddy | Cursor | Antigravity | OpenCode | MCP client | Codex | DeepSeek Harness | Grok | Qoder | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| Channel | **relay**: a hooks module with no Node starts `bin/relay.mjs` and relays each event | hook | hook | hook | **plugin** | **mcp** | hook | **plugin** | hook | hook | -| Prompt from | `turn.start` text, **its origin from `prompt.submit`; a turn nobody typed is recalled for nothing** | payload | payload | **transcript** | **message parts** | **`task` argument** | payload | **the `user` messages of a turn's first `agent/pre-step`** | payload; **a turn grok starts itself (a background task completed: `task-completed-*` id, a prompt of reminders only) is no turn** | payload; **a `` prompt Qoder submits is no turn** | -| Session start | event | event | event | **owner-change** | **first prompt in the instance** | **connection** | event | **`agent/created` (`agent/session-start` before 0.2), or the first step in the process** | event (`new`, or `load` on resume; in `grok -p` it can come after the first prompt, and nothing depends on the order) | event (`startup`, `resume`, `clear`, `new`; **not `compact`**) | -| Answer | **`$.session.append` when the recall lands (budget 0), booked at the turn's next `turn.step`; a failed check's recall rides that `tool.call` result's `context` (`toolResultContext`)** | additionalContext | **`{continue, additional_context}`** | **`injectSteps`, replayed each model call; `allow`/`stop` empty answers** | **synthetic part** | **tool result** | additionalContext; **a command blocks the prompt (`decision: block`)** | **a `plugin:evolver` message appended to the step; a late recall joins the turn's next step** | **held at the prompt (grok discards a prompt hook's context) and answered as the turn's first `PostToolUse` or `PostToolUseFailure` `additionalContext`, booked then; a turn with no tool call does without; a tool result names no turn, so a late one of an interrupted turn could carry the next turn's; a notice is a `systemMessage` to the person; a command blocks the prompt** | additionalContext; **a command blocks the prompt (`decision: block`, its answer as `systemMessage`)** | -| Evidence | **`tool.call` and what it resolved to; a check is named by its description** | **transcript at turn end** | hook exit codes, **answer from afterAgentResponse** | **transcript step exit codes** | **`metadata.exit`** | **agent-reported** | hook output, **no Bash exit code**; edits from `apply_patch` patches | **`tools/execute` results: bash `value.exitCode`, else its `[exit code: N]` marker** | hook output: **`toolResult.exit_code`; a call moved to the background has no verdict until `get_command_or_subagent_output` returns its task's exit code**; edits from `search_replace`/`write` (and the Cursor preset's `path` keys), absolute | hook output: **Bash `exitCode`, or the `Exit code N` error of PostToolUseFailure; a backgrounded run stays pending** | -| Turn end | **`turn.complete` reason; the one-line summary as `{ text }`** | **`final_stop_reason`** | **`status`, 3 s settle** | **`terminationReason`, unknown waits** | **`error.name` / `finish`** | **report `outcome`** | Stop, **Interrupt aborts** | **`turn/end` reason; `blocked` aborts** | **`Stop` (`end_turn`), `StopFailure` errs, `StopCancelled` aborts, keyed on `promptId`; the session-end `Stop` is ignored; an unclosed turn aborts after a 2 s settle** | Stop, **StopFailure as an error, the undocumented `QueryEnd` `abort` as aborted** | -| Session end | event, **written only by a listening relay** | event | event, **for its generation** | **none: the next session start reports and distills a session whose owner exited** | **deleted / dispose** | **connection close** | event; **before 0.145 the next session start reports and distills a session whose owner exited** | **`session/disposed`, or the plugin's unload (headless and the TUI never dispose)** | event, **a plugin's hook gets 1.5 s (`budgets.sessionEnd`), so it waits at most 0.7 s for a late turn end** | event | -| Commands | **`command.run`, registered from `hooks/commands.json`; evolve sent as a prompt** | **inline-script** | **intercept** | cli-only: `node /bin/command.mjs antigravity evolver-status` | **prompt / TUI** | **tool** | **intercept, typed without a slash** | **prompt: the dsh command registry, answered in its UI; `/evolver-evolve` a follow-up turn** | **intercept, typed with or without a slash; `/evolver-evolve` is a command file** | **intercept, with `commands/*.md` fallbacks (`/evolver-plugin:evolver-status`, or `/evolver-status`)** | -| Owner | **spawner: Claude Code, the relay's parent** | parent | **ancestor path `/cursor/`** | **ancestor `agy` / `language_server`** | **self** | **self** | **ancestor `/^codex/i`** | **self** | parent | parent | -| Headless model | **the session's own (`$.model.complete`), run by the module for the relay** | `codebuddy -p`; **the agent writes the Gene when it cannot sign in** | `cursor-agent -p --mode ask` | `agy`, **stream-json on stdin, empty folder** | **SDK helper session** | **in-session** | `codex exec` on the person's own config, **its MCP servers listed and turned off first, hooks and plugins off, JSONL on stdout, empty folder** | **dsh's model service (`llm.stream`) on the session's route** | `grok --prompt-file` on the person's own config, **no tools (`--tools`/`--disallowed-tools`), subagents, plan mode or web search, `--max-turns 1`, empty folder; the Claude Code and Cursor hooks, rules, skills and MCP servers grok imports, its turn summary and memory turned off by env; an answer of more than one model turn refused** | `qodercli -p --output-format json` on the person's sign-in, **`--tools ""`, `--strict-mcp-config`, hooks off, one turn, nothing saved, empty folder** | -| State | default | **plugin data folder** | default | default | default | default | default | default | default | default | -| Installed from | **`dist/claude-code` on the `release` branch** (`git-subdir`) | **`dist/workbuddy` on the `release` branch** (`git-subdir`); **the old data folder is moved in** | the repo root (`.cursor-plugin`) | **`dist/antigravity` on the `release` branch** | **`dist/opencode` on the `release` branch (an npm package, not published yet)** | **its MCP config** | **`dist/codex` on the `release` branch** (`git-subdir`, `.agents/plugins/marketplace.json`) | **`dist/dsh` on the `release` branch, as `@evomap/dsh-evolver` (`dsh plugin add -w `; npm not published yet)** | **`dist/grok` on the `release` branch** (`git-subdir`, `.grok-plugin/marketplace.json`) | **`dist/qoder` on the `release` branch** (`git-subdir`, `.qoder-plugin/marketplace.json`) | +| Point | Claude Code | WorkBuddy | Cursor | Antigravity | OpenCode | MCP client | Codex | DeepSeek Harness | Grok | Qoder | MiniMax Code | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| Channel | **relay**: a hooks module with no Node starts `bin/relay.mjs` and relays each event | hook | hook | hook | **plugin** | **mcp** | hook | **plugin** | hook | hook | hook | +| Prompt from | `turn.start` text, **its origin from `prompt.submit`; a turn nobody typed is recalled for nothing** | payload | payload | **transcript** | **message parts** | **`task` argument** | payload | **the `user` messages of a turn's first `agent/pre-step`** | payload; **a turn grok starts itself (a background task completed: `task-completed-*` id, a prompt of reminders only) is no turn** | payload; **a `` prompt Qoder submits is no turn** | payload | +| Session start | event | event | event | **owner-change** | **first prompt in the instance** | **connection** | event | **`agent/created` (`agent/session-start` before 0.2), or the first step in the process** | event (`new`, or `load` on resume; in `grok -p` it can come after the first prompt, and nothing depends on the order) | event (`startup`, `resume`, `clear`, `new`; **not `compact`**) | event | +| Answer | **`$.session.append` when the recall lands (budget 0), booked at the turn's next `turn.step`; a failed check's recall rides that `tool.call` result's `context` (`toolResultContext`)** | additionalContext | **`{continue, additional_context}`** | **`injectSteps`, replayed each model call; `allow`/`stop` empty answers** | **synthetic part** | **tool result** | additionalContext; **a command blocks the prompt (`decision: block`)** | **a `plugin:evolver` message appended to the step; a late recall joins the turn's next step** | **held at the prompt (grok discards a prompt hook's context) and answered as the turn's first `PostToolUse` or `PostToolUseFailure` `additionalContext`, booked then; a turn with no tool call does without; a tool result names no turn, so a late one of an interrupted turn could carry the next turn's; a notice is a `systemMessage` to the person; a command blocks the prompt** | additionalContext; **a command blocks the prompt (`decision: block`, its answer as `systemMessage`)** | additionalContext; commands block with a user message; Stop may show a user-only summary | +| Evidence | **`tool.call` and what it resolved to; a check is named by its description** | **transcript at turn end** | hook exit codes, **answer from afterAgentResponse** | **transcript step exit codes** | **`metadata.exit`** | **agent-reported** | hook output, **no Bash exit code**; edits from `apply_patch` patches | **`tools/execute` results: bash `value.exitCode`, else its `[exit code: N]` marker** | hook output: **`toolResult.exit_code`; a call moved to the background has no verdict until `get_command_or_subagent_output` returns its task's exit code**; edits from `search_replace`/`write` (and the Cursor preset's `path` keys), absolute | hook output: **Bash `exitCode`, or the `Exit code N` error of PostToolUseFailure; a backgrounded run stays pending** | bash details.execution.exitCode; edit/write paths | +| Turn end | **`turn.complete` reason; the one-line summary as `{ text }`** | **`final_stop_reason`** | **`status`, 3 s settle** | **`terminationReason`, unknown waits** | **`error.name` / `finish`** | **report `outcome`** | Stop, **Interrupt aborts** | **`turn/end` reason; `blocked` aborts** | **`Stop` (`end_turn`), `StopFailure` errs, `StopCancelled` aborts, keyed on `promptId`; the session-end `Stop` is ignored; an unclosed turn aborts after a 2 s settle** | Stop, **StopFailure as an error, the undocumented `QueryEnd` `abort` as aborted** | normal Stop; an unfinished turn closes as error at the next prompt | +| Session end | event, **written only by a listening relay** | event | event, **for its generation** | **none: the next session start reports and distills a session whose owner exited** | **deleted / dispose** | **connection close** | event; **before 0.145 the next session start reports and distills a session whose owner exited** | **`session/disposed`, or the plugin's unload (headless and the TUI never dispose)** | event, **a plugin's hook gets 1.5 s (`budgets.sessionEnd`), so it waits at most 0.7 s for a late turn end** | event | best-effort event; dead-owner recovery at the next session start | +| Commands | **`command.run`, registered from `hooks/commands.json`; evolve sent as a prompt** | **inline-script** | **intercept** | cli-only: `node /bin/command.mjs antigravity evolver-status` | **prompt / TUI** | **tool** | **intercept, typed without a slash** | **prompt: the dsh command registry, answered in its UI; `/evolver-evolve` a follow-up turn** | **intercept, typed with or without a slash; `/evolver-evolve` is a command file** | **intercept, with `commands/*.md` fallbacks (`/evolver-plugin:evolver-status`, or `/evolver-status`)** | chat interception; slow commands run as durable jobs with status and cancellation | +| Owner | **spawner: Claude Code, the relay's parent** | parent | **ancestor path `/cursor/`** | **ancestor `agy` / `language_server`** | **self** | **self** | **ancestor `/^codex/i`** | **self** | parent | parent | parent, skipping shells | +| Headless model | **the session's own (`$.model.complete`), run by the module for the relay** | `codebuddy -p`; **the agent writes the Gene when it cannot sign in** | `cursor-agent -p --mode ask` | `agy`, **stream-json on stdin, empty folder** | **SDK helper session** | **in-session** | `codex exec` on the person's own config, **its MCP servers listed and turned off first, hooks and plugins off, JSONL on stdout, empty folder** | **dsh's model service (`llm.stream`) on the session's route** | `grok --prompt-file` on the person's own config, **no tools (`--tools`/`--disallowed-tools`), subagents, plan mode or web search, `--max-turns 1`, empty folder; the Claude Code and Cursor hooks, rules, skills and MCP servers grok imports, its turn summary and memory turned off by env; an answer of more than one model turn refused** | `qodercli -p --output-format json` on the person's sign-in, **`--tools ""`, `--strict-mcp-config`, hooks off, one turn, nothing saved, empty folder** | mcode exec on the captured session route; lightweight, no tools, private config and scratch marker | +| State | default | **plugin data folder** | default | default | default | default | default | default | default | default | PLUGIN_DATA; persistent recall/auto-distill preferences | +| Installed from | **`dist/claude-code` on the `release` branch** (`git-subdir`) | **`dist/workbuddy` on the `release` branch** (`git-subdir`); **the old data folder is moved in** | the repo root (`.cursor-plugin`) | **`dist/antigravity` on the `release` branch** | **`dist/opencode` on the `release` branch (an npm package, not published yet)** | **its MCP config** | **`dist/codex` on the `release` branch** (`git-subdir`, `.agents/plugins/marketplace.json`) | **`dist/dsh` on the `release` branch, as `@evomap/dsh-evolver` (`dsh plugin add -w `; npm not published yet)** | **`dist/grok` on the `release` branch** (`git-subdir`, `.grok-plugin/marketplace.json`) | **`dist/qoder` on the `release` branch** (`git-subdir`, `.qoder-plugin/marketplace.json`) | dist/minimax locally; scripts/install-minimax.mjs stages install/update | ## Adding a host +MiniMax Code's local adapter uses the hook defaults for `session_id`, `turn_id`, prompt and additional context. +Native `details.execution.exitCode` is verification evidence; missing or unfinished shell results count as no check. +State lives in `PLUGIN_DATA`. Its native shell-form hooks use `owner: 'parent'`; on Windows a bounded CIM snapshot +skips shells and identifies the actual runtime process, so the next session can recover a dead owner's candidate. +There is no fabricated native end event: a read-only session/turn-status projection closes only confirmed native +`failed`/`aborted` turns; the next prompt remains the conservative fallback when metadata is unavailable. +Distillation uses the captured native session provider/model/variant, a private per-run YAML merge and `--mode lightweight`; a scratch +marker also prevents hook re-entry when MiniMax removes `EVOLVER_HEADLESS`. Delivery is local, not Marketplace. +Commands are intercepted in chat. Status, persistent preference changes, draft listings and the evolution +checkpoint fit the hook; longer operations are admitted with `typed.background` to one durable command worker +per profile. `evolver-jobs`, `evolver-jobs job ` and `evolver-jobs cancel ` expose results and cancellation to the person. +The preferences file survives filtered environments. A read-only v2 model projection captures the verified +turn's route; missing or mismatched metadata prevents completion rather than switching providers. Normal Stop +may show a user-only summary. The turn monitor uses native terminal status only for failure/cancellation; +it never treats a completed database row as successful validation. +`scripts/install-minimax.mjs` stages local installs and updates while retaining data outside the package. + +CLI model declarations may additionally use `env(env)` and `configFile: { source(env), prepare(text) }`. +The runtime copies the prepared configuration into its private scratch folder, passes `configPath` to `args`, +and removes the copy and marker on completion, failure or cancellation. No host reader performs file IO. + 1. `hosts//profile.js`: `export default defineProfile({ name, label, channel, events, evidence, … })` with only the points that differ; readers that need more than a line go in sibling files. 2. `hosts//dist/`: the host's hook registration calling `node bin/hook.mjs ` (or its MCP @@ -58,5 +81,5 @@ From each host's reviewed standalone plugin; bold is where it differs from the d 4. `test/hosts//`: every reader against payloads from the real host, and the hooks end to end, from a packed folder where the host installs one. -No shared file changes: `lib/agent/registry.js` finds the profile by its folder name. A difference no point covers +No shared host list changes: `lib/agent/registry.js` finds the profile by its folder name. A difference no point covers yet becomes a new field with a default in `lib/agent/profile.js`, used in one place in `lib/agent/driver.js`. diff --git a/hosts/codex/commands.js b/hosts/codex/commands.js index 885353c..cc0248a 100644 --- a/hosts/codex/commands.js +++ b/hosts/codex/commands.js @@ -5,17 +5,11 @@ // accepted) and the prompt hook answers it. Only a prompt that is exactly a command counts, so a prompt that merely // starts with a command's name stays a prompt, and one with text after a command that takes none (it has no hint). -import { commandCatalog } from '../../lib/agent/commands.js'; +import { commandCatalog, parsePromptCommand } from '../../lib/agent/commands.js'; export const COMMAND_NAMES = Object.freeze(commandCatalog.map((command) => command.name)); export const TAKES_NO_ARGUMENTS = Object.freeze(commandCatalog.filter((command) => !command.hint).map((command) => command.name)); -const TYPED = /^\/?(evolver-[a-z]+)(?:\s+([\s\S]*))?$/; - -/** The command a prompt is, `{ name, args }` with its raw argument text, or null for any other prompt. */ export function promptCommandOf(prompt) { - const match = TYPED.exec(String(prompt ?? '').trim()); - if (!match || !COMMAND_NAMES.includes(match[1])) return null; - const args = (match[2] ?? '').trim(); - return TAKES_NO_ARGUMENTS.includes(match[1]) && args ? null : { name: match[1], args }; + return parsePromptCommand(prompt); } diff --git a/hosts/grok/commands.js b/hosts/grok/commands.js index dce8185..44eba5a 100644 --- a/hosts/grok/commands.js +++ b/hosts/grok/commands.js @@ -7,20 +7,11 @@ // grok discards what a prompt hook adds, so a command for the agent reaches it only as grok's own slash command, from // the plugin's command file: it counts only typed with its slash. -import { commandCatalog } from '../../lib/agent/commands.js'; +import { commandCatalog, parsePromptCommand } from '../../lib/agent/commands.js'; export const COMMAND_NAMES = Object.freeze(commandCatalog.map((command) => command.name)); -const TAKES_NO_ARGUMENTS = Object.freeze(commandCatalog.filter((command) => !command.hint).map((command) => command.name)); export const FOR_AGENT = Object.freeze(commandCatalog.filter((command) => command.forAgent).map((command) => command.name)); -const TYPED = /^(\/?)(?:[\w-]+:)?(evolver-[a-z]+)(?:\s+([\s\S]*))?$/; - -/** The command a prompt is, `{ name, args }` with its raw argument text, or null for any other prompt. */ export function promptCommandOf(prompt) { - const match = TYPED.exec(String(prompt ?? '').trim()); - if (!match || !COMMAND_NAMES.includes(match[2])) return null; - const [, slash, name] = match; - const args = (match[3] ?? '').trim(); - if (FOR_AGENT.includes(name) && !slash) return null; - return TAKES_NO_ARGUMENTS.includes(name) && args ? null : { name, args }; + return parsePromptCommand(prompt, { namespace: true, slashForAgent: true }); } diff --git a/hosts/minimax/commands.js b/hosts/minimax/commands.js new file mode 100644 index 0000000..9ca4dcd --- /dev/null +++ b/hosts/minimax/commands.js @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EvoMap + +import { commandCatalog, hostCommandCatalog, parsePromptCommand } from '../../lib/agent/commands.js'; + +const commands = [...commandCatalog, ...hostCommandCatalog]; + +export const isBackgroundCommand = (name, args) => commandCatalog.some((command) => + command.name === name && !command.forAgent && name !== 'evolver-status' && (name !== 'evolver-drafts' || Boolean(args?.trim()))); + +/** Slow commands are admitted to a durable worker rather than killed by the native ten-second hook budget. */ +export function promptCommandOf(payload, event, { prompt }) { + const typed = parsePromptCommand(prompt, { catalog: commands }); + return typed && isBackgroundCommand(typed.name, typed.args) ? { ...typed, background: true } : typed; +} diff --git a/hosts/minimax/dist/README.md b/hosts/minimax/dist/README.md new file mode 100644 index 0000000..832ca2d --- /dev/null +++ b/hosts/minimax/dist/README.md @@ -0,0 +1,142 @@ +# Evolver for MiniMax Code + +Local plugin for MiniMax Code CLI 0.7.4 and Desktop 3.1.6. Requires Node.js 22.13 or newer, +compatible with MCode, and `node` and `mcode` on PATH. Sign in to MiniMax or configure a provider +through MiniMax itself before using distillation. Credentials belong in the native account configuration. + +The checkout's `.minimax-plugin/plugin.json` points to `hosts/minimax/dist/hooks/hooks.json`. +Packing uses the same manifest and rewrites that hook path to `hooks/hooks.json` in the installed folder. + +From a checkout, install the local plugin: + +```text +node scripts/install-minimax.mjs install --dry-run +node scripts/install-minimax.mjs install +mcode plugin enable evolver-plugin@local --json +``` + +The installer stages the package before replacing its previous installation and restores it if activation +fails. Account configuration, preferences and drafts live outside the package and survive updates. Existing +unmanaged folders and linked plugin paths are refused. To update between tasks, obtain the desired source +revision, then: + +```text +node scripts/install-minimax.mjs update +mcode plugin enable evolver-plugin@local --json +``` + +Start a new task after installation or update. A release checkout uses its ready-made `dist/minimax` package, +preserving its version and contents. Without that folder, the installer builds from the source manifest. +`--version X.Y.Z` explicitly builds the current sources at that version, even when a packed folder exists. +The installer and native package need no `npm ci`; development tests and the generic MCP server do. +`status` reports the installed package; `--data-dir PATH` selects a separate native profile. Package status +does not imply the native host has enabled it. For a prebuilt ZIP, extract its contents directly into +`/plugins/evolver-plugin`, with `.minimax-plugin/plugin.json` directly inside that folder. +Enable it with `mcode plugin enable evolver-plugin@local --json`, then start a new task. +`--data-dir` configures the installer only; set `MINIMAX_DATA_DIR` to that same directory for subsequent `mcode` commands. + +The data root is `MINIMAX_DATA_DIR`, then `MAVIS_DATA_DIR`, otherwise `~/.minimax`. +Do not install two copies of Evolver for the same host. + +To pause this integration, run `mcode plugin disable evolver-plugin@local --json` and start a new task. +To uninstall the package, run `mcode plugin remove evolver-plugin@local --json`. Keep the native plugin-data +folder if you want to retain preferences and unpublished drafts; removal is not a request to delete those files. + +Evolver uses the person's existing loopback Proxy and Hub account. `evolver-status` reports connection +problems; `evolver-login` starts the native device sign-in and shows its link/code through the command job. +Approve that sign-in yourself. No account, token, local Proxy URL or developer machine path is bundled. + +## Commands and preferences + +Type these as ordinary text in the task chat. The hook also accepts a leading slash when the host forwards it: + +```text +evolver-status +evolver-config recall off +evolver-config auto-distill off +evolver-drafts +evolver-evolve retry handling +evolver-search transient Redis connection failures +evolver-jobs +evolver-jobs job +evolver-jobs cancel +``` + +Status and draft listings are shown to the person without opening a model turn. `evolver-evolve` adds the +checkpoint to the agent's turn. Recall and auto-distill preferences persist across sessions; replace `off` +with `on` to enable them again. They live in `/minimax-settings.json`: + +```json +{"recall": true, "autoDistill": true} +``` + +Chat interception blocks the model request. In `mcode exec`, handled status/list commands therefore return +the native rejected-prompt exit code 4 with their result as the reason. For terminal automation, run +`bin/command.mjs` directly instead of wrapping a chat command in `mcode exec`. + +Invalid or unreadable preferences pause both automatic activities and are reported by status. Repair the file +before changing settings. In environments which retain them, explicit `EVOLVER_RECALL` and +`EVOLVER_AUTO_DISTILL` values override the file; MiniMax native hooks strip these inherited variables. + +MiniMax command hooks have a ten-second budget. Login, search, distill, publishing/discarding drafts and +engine commands (`sync`, `review`, `run`, `solidify`) therefore run in a detached command worker. The chat +immediately shows its job id. Use `evolver-jobs job ` for the result, `evolver-jobs` for recent +jobs, or `evolver-jobs cancel ` to request cancellation. Results remain user-only; a searched strategy is +not recorded as reused unless it is subsequently recalled into an agent turn. + +One command runs at a time per profile, on the session and workspace captured when it was requested. The history +retains up to twenty jobs within 512 KiB, with results capped at 16,000 characters each. A job has a twelve-minute execution +budget; existing command, network and model deadlines remain in force. Cancellation stops the worker's active +request/process tree, while completed side effects and a device sign-in already launched remain in place. +A worker that exits without a result is shown as interrupted and is never automatically replayed. Inspect its +operation before retrying, especially publication. Invalid command state is reported rather than reset. +Turning auto-distill off leaves explicit distill available. Direct terminal commands remain supported below. + +## Lifecycle and model selection + +The prompt hook recalls strategies, native tool results supply check evidence, and completed verified turns +become draft candidates. A session-end hook schedules distillation. If the host exits without SessionEnd, +the next session start recovers the dead owner's pending work. A read-only native turn-status monitor closes +failed and aborted turns without waiting for another prompt; neither can produce a verified candidate. + +Distillation runs `mcode exec` on the verified turn's captured provider/model/variant. The hook supplies only +a model name; a read-only projection of the native v2 session metadata supplies its route. The adapter reads +only those model fields, checks them against the hook, and stores the route with the candidate. Later default +or session-model changes do not replace it. Missing, incompatible or mismatched metadata prevents a completion +from starting. Candidates from older versions which recorded `native-default` retain their former behavior. +A private temporary YAML merge preserves native credentials and overrides capabilities; lightweight mode exposes no tools. +The inherited environment marker and a private scratch-folder marker prevent this plugin from running again +inside its own distillation. The adapter never writes the source configuration; the temporary copy is removed on +completion, failure or cancellation. The native CLI may perform its own first-run configuration migrations, as it +does without this adapter. Other installed plugins remain subject to MiniMax's own hook policy. + +MiniMax emits normal Stop only after successful completion. The monitor reads only the matching session/turn +status from the v2 metadata every 500 ms and acts only on `failed` or `aborted`. A completed row does not verify +work; Stop and actual tool evidence still decide that. Monitoring ends when the turn closes or changes, its +owner dies, or after twenty-four hours. A missing/incompatible row is watched for at most thirty seconds. +With missing metadata or an unsupported runtime, the next prompt still closes an unfinished turn as an error; +session-end and dead-owner recovery remain available. No native event or successful check is fabricated. +A normal Stop may show the shared one-line turn summary through the host's user-only `systemMessage`. + +State is stored in MiniMax's writable `PLUGIN_DATA` directory, not in the immutable package cache. +Terminal commands use the same v2 state folder. Set `EVOLVER_SESSION_ID` to target a particular session: + +```text +node /bin/command.mjs minimax evolver-status +node /bin/command.mjs minimax evolver-distill +node /bin/command.mjs minimax evolver-drafts +``` + +Set the same `MINIMAX_DATA_DIR` when using a separate native profile. Commands outside a session act on the +latest candidate. Nothing is published until the person explicitly publishes a draft. + +This package uses native hooks for local installation. MiniMax's public third-party Marketplace intake currently +excludes Hook JSON and the hooks manifest field; local runtime compatibility does not imply Marketplace eligibility. +The v2 data layout is checked before invoking a model; an unknown layout fails without switching accounts. + +## Development checks + +`npm test` runs the full repository suite on Linux. `npm run test:minimax` covers this adapter, its packed +hooks and shared runtime on Windows and Linux, using temporary profiles and local test services. It needs no +MiniMax login, Hub credentials, globally installed Evolver, or personal configuration. Release CI requires both +the full Linux suite and the Windows MiniMax/runtime suite on Node 22 and 26, followed by packing. diff --git a/hosts/minimax/dist/hooks/hooks.json b/hosts/minimax/dist/hooks/hooks.json new file mode 100644 index 0000000..d43e959 --- /dev/null +++ b/hosts/minimax/dist/hooks/hooks.json @@ -0,0 +1,10 @@ +{ + "hooks": { + "SessionStart": [{"hooks": [{"type": "command", "command": "node \"$PLUGIN_ROOT/bin/hook.mjs\" minimax SessionStart", "commandWindows": "node \"%PLUGIN_ROOT%\\bin\\hook.mjs\" minimax SessionStart", "timeout": 10}]}], + "UserPromptSubmit": [{"hooks": [{"type": "command", "command": "node \"$PLUGIN_ROOT/bin/hook.mjs\" minimax UserPromptSubmit", "commandWindows": "node \"%PLUGIN_ROOT%\\bin\\hook.mjs\" minimax UserPromptSubmit", "timeout": 10}]}], + "PreToolUse": [{"matcher": "edit|write", "hooks": [{"type": "command", "command": "node \"$PLUGIN_ROOT/bin/hook.mjs\" minimax PreToolUse", "commandWindows": "node \"%PLUGIN_ROOT%\\bin\\hook.mjs\" minimax PreToolUse", "timeout": 10}]}], + "PostToolUse": [{"matcher": "bash|edit|write", "hooks": [{"type": "command", "command": "node \"$PLUGIN_ROOT/bin/hook.mjs\" minimax PostToolUse", "commandWindows": "node \"%PLUGIN_ROOT%\\bin\\hook.mjs\" minimax PostToolUse", "timeout": 10}]}], + "Stop": [{"hooks": [{"type": "command", "command": "node \"$PLUGIN_ROOT/bin/hook.mjs\" minimax Stop", "commandWindows": "node \"%PLUGIN_ROOT%\\bin\\hook.mjs\" minimax Stop", "timeout": 10}]}], + "SessionEnd": [{"hooks": [{"type": "command", "command": "node \"$PLUGIN_ROOT/bin/hook.mjs\" minimax SessionEnd", "commandWindows": "node \"%PLUGIN_ROOT%\\bin\\hook.mjs\" minimax SessionEnd", "timeout": 10}]}] + } +} diff --git a/hosts/minimax/dist/pack.json b/hosts/minimax/dist/pack.json new file mode 100644 index 0000000..19765e5 --- /dev/null +++ b/hosts/minimax/dist/pack.json @@ -0,0 +1,13 @@ +{ + "entries": ["bin/hook.mjs", "bin/worker.mjs", "bin/command.mjs"], + "root": { + ".minimax-plugin/plugin.json": ".minimax-plugin/plugin.json", + "hooks/hooks.json": "hosts/minimax/dist/hooks/hooks.json", + "README.md": "hosts/minimax/dist/README.md", + "assets/logo.png": "assets/logo.png", + "LICENSE": "LICENSE" + }, + "patch": { + ".minimax-plugin/plugin.json": { "hooks": ["hooks/hooks.json"] } + } +} diff --git a/hosts/minimax/evidence.js b/hosts/minimax/evidence.js new file mode 100644 index 0000000..b12567c --- /dev/null +++ b/hosts/minimax/evidence.js @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EvoMap + +import path from 'node:path'; + +const EDITS = new Set(['edit', 'write']); +const text = value => typeof value === 'string' ? value : ''; +const inputPath = input => text(input?.file_path) || text(input?.path); +const lines = value => text(value) ? value.split('\n').length : 0; + +export function editedFilesOf({ tool_name: name, tool_input: input, cwd }) { + const file = EDITS.has(name) ? inputPath(input) : ''; + if (!file) return []; + return path.isAbsolute(file) ? [file] : path.isAbsolute(text(cwd)) ? [path.resolve(cwd, file)] : []; +} + +export function toolNoteOf(payload) { + const { tool_name: name, tool_input: input, tool_response: response } = payload; + const details = response?.details; + if (EDITS.has(name)) { + const paths = editedFilesOf(payload); + if (!paths.length) return null; + if (response?.isError || response?.is_error || details?.is_error || ['failed', 'cancelled'].includes(details?.status)) return { kind: 'tool', paths }; + return { kind: 'edit', paths, lines: lines(input.old_string) + lines(input.new_string ?? input.content) }; + } + if (name !== 'bash' || !text(input?.command)) return null; + const command = input.command; + const execution = details?.execution; + if (execution?.reason !== 'exited' || !Number.isInteger(execution.exitCode) || details?.processOutput?.interrupted) return { kind: 'tool', command }; + const output = details?.processOutput; + return { kind: 'shell', command, exit: execution.exitCode, output: output ? `${text(output.stdout)}${output.stderr ? `\n${output.stderr}` : ''}` : (response.content ?? []).filter(part => part.type === 'text').map(part => text(part.text)).join('\n') }; +} diff --git a/hosts/minimax/headless.js b/hosts/minimax/headless.js new file mode 100644 index 0000000..f0c39b9 --- /dev/null +++ b/hosts/minimax/headless.js @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EvoMap + +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { modelArguments } from './model.js'; +import { dataDirOf } from './paths.js'; + +/** Let MiniMax's YAML parser merge the original routing and credentials; no YAML dependency or secret logging. */ +export function headlessConfigOf(source) { + const lines = source.replace(/^\uFEFF/, '').replaceAll('\r\n', '\n').split('\n'); + const prefix = []; + while (lines.length && /^(?:\s*(?:#.*)?|%.*)$/.test(lines[0])) prefix.push(lines.shift()); + if (/^---(?:\s*(?:#.*)?)?$/.test(lines[0] ?? '')) lines.shift(); + let last = lines.length - 1; + while (last >= 0 && /^\s*(?:#.*)?$/.test(lines[last])) last -= 1; + if (/^\.\.\.(?:\s*(?:#.*)?)?$/.test(lines[last] ?? '')) lines.splice(last, 1); + if (lines.some(line => /^(?:---|\.\.\.)(?:\s|$)/.test(line))) throw new Error('MiniMax configuration must contain one YAML document'); + const agents = { default: { tools: [], builtinTools: [], skills: [], features: { mavis: false, delegation: false, webSearch: false } } }; + return [ + ...prefix, ...(prefix.some(line => line.startsWith('%')) ? ['---'] : []), + '<<:', ...lines.map(line => ` ${line}`), + `agents: ${JSON.stringify(agents)}`, + 'memory: {enabled: false}', 'askUser: {enabled: false}', 'goal: {enabled: false}', + '', + ].join('\n'); +} + +export function completionOf({ stdout }) { + let result; + try { result = JSON.parse(stdout); } catch { throw new Error('MiniMax returned no valid completion'); } + if (result?.schemaVersion !== 1 || result.type !== 'exec.result' || result.status !== 'succeeded' || typeof result.output !== 'string' || !result.output.trim()) { + const code = /^[A-Z0-9_]+$/.test(result?.error?.code ?? '') ? ` (${result.error.code})` : ''; + throw new Error(`MiniMax returned no successful completion${code}`); + } + const usage = result.usage; + return { answer: result.output, usage: Number.isFinite(usage?.inputTokens) && Number.isFinite(usage?.outputTokens) ? { inputTokens: usage.inputTokens, outputTokens: usage.outputTokens } : null }; +} + +export const headlessMiniMax = Object.freeze({ + kind: 'cli', + command: { names: ['mcode'], windowsShims: true, dirs: env => { + const home = env.HOME || env.USERPROFILE || homedir(); + return [join(home, '.minimax-code'), join(home, '.local', 'bin')]; + } }, + workdir: 'empty', + env: env => { const root = dataDirOf(env); return { MINIMAX_DATA_DIR: root, MAVIS_DATA_DIR: root }; }, + configFile: { source: env => join(dataDirOf(env), 'config.yaml'), prepare: headlessConfigOf }, + args: (model, prompt, { configPath }) => ['exec', '--input', '-', '--config', configPath, ...modelArguments(model), '--mode', 'lightweight', '--permission', 'smart', '--max-steps', '1', '--timeout', '90s', '--output-format', 'json'], + parse: completionOf, +}); diff --git a/hosts/minimax/model.js b/hosts/minimax/model.js new file mode 100644 index 0000000..1cd8871 --- /dev/null +++ b/hosts/minimax/model.js @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EvoMap + +import { runtimeDatabaseOf } from './paths.js'; + +const ROUTE = /^[A-Za-z0-9_:.@-]+\/[A-Za-z0-9_:.@/-]+$/; +const VARIANT = /^[A-Za-z0-9_.-]+$/; + +export const modelRecord = Object.freeze({ + file: runtimeDatabaseOf, + // shortcut: read the 0.7.4/v2 schema until native hooks expose a provider-qualified model route. + query: `SELECT CASE WHEN d.session_id IS NOT NULL THEN json_extract(d.definition_json, '$.model.providerId') || '/' || json_extract(d.definition_json, '$.model.modelId') ELSE json_extract(s.extra_data_json, '$.effectiveModel') END AS route, + CASE WHEN d.session_id IS NOT NULL THEN json_extract(d.definition_json, '$.model.variant') ELSE json_extract(s.extra_data_json, '$.effectiveModelVariant') END AS variant + FROM local_runtime_sessions s LEFT JOIN local_runtime_session_agent_definitions d ON d.session_id = s.session_id WHERE s.session_id = ?`, +}); + +/** Capture the verified turn's route before a later session/default-model change can replace it. */ +export function sessionModelOf(payload, context) { + const row = context?.modelRecord?.(payload.session_id); + if (!row || typeof row.route !== 'string' || !ROUTE.test(row.route) || row.route.length > 512 || (row.variant != null && row.variant !== '' && (typeof row.variant !== 'string' || !VARIANT.test(row.variant)))) return { model: 'unresolved' }; + const bare = row.route.slice(row.route.indexOf('/') + 1); + const reported = typeof payload.model === 'string' ? payload.model : ''; + if (reported && reported !== bare && reported !== row.route && reported !== `${row.route}#${row.variant}`) return { model: 'unresolved' }; + return { model: row.variant ? `${row.route}#${row.variant}` : row.route }; +} + +export function modelArguments(model) { + if (model?.model === 'native-default') return []; // Older candidates predate route snapshots. + const value = model?.model; + const [route, variant, extra] = typeof value === 'string' ? value.split('#') : []; + if (!ROUTE.test(route ?? '') || value.length > 600 || extra !== undefined || (variant !== undefined && !VARIANT.test(variant))) throw new Error('MiniMax session model route is unavailable or inconsistent; no completion was started.'); + return ['--model', value]; +} diff --git a/hosts/minimax/paths.js b/hosts/minimax/paths.js new file mode 100644 index 0000000..1777c24 --- /dev/null +++ b/hosts/minimax/paths.js @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EvoMap + +import { homedir } from 'node:os'; +import { basename, dirname, join, resolve } from 'node:path'; + +/** MiniMax strips data-root variables from hooks; only its native v2 PLUGIN_DATA layout is inferred. */ +export function dataDirOf(env) { + const explicit = env.MINIMAX_DATA_DIR || env.MAVIS_DATA_DIR; + if (explicit) return resolve(explicit); + if (env.PLUGIN_DATA) { + const hooks = dirname(env.PLUGIN_DATA), data = dirname(hooks), version = dirname(data); + if (basename(hooks) !== 'hooks' || basename(data) !== 'plugin-data' || basename(version) !== 'v2') throw new Error('MiniMax plugin data layout is unknown; no model was started'); + return dirname(version); + } + return join(env.HOME || env.USERPROFILE || homedir(), '.minimax'); +} + +export const stateDirOf = env => env.PLUGIN_DATA || join(dataDirOf(env), 'v2', 'plugin-data', 'hooks', 'evolver-plugin'); + +export const runtimeDatabaseOf = (env) => join(dataDirOf(env), 'v2', 'sqlite', 'runtime-state.sqlite'); diff --git a/hosts/minimax/profile.js b/hosts/minimax/profile.js new file mode 100644 index 0000000..9c50b65 --- /dev/null +++ b/hosts/minimax/profile.js @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EvoMap + +import { join } from 'node:path'; + +import { EVENTS } from '../../lib/agent/events.js'; +import { defineProfile } from '../../lib/agent/profile.js'; +import { editedFilesOf, toolNoteOf } from './evidence.js'; +import { headlessMiniMax } from './headless.js'; +import { runtimeDatabaseOf, stateDirOf } from './paths.js'; +import { isBackgroundCommand, promptCommandOf } from './commands.js'; +import { modelRecord, sessionModelOf } from './model.js'; +import { endedTurnSummaryOf } from '../../lib/turn-summary.js'; + +export default defineProfile({ + name: 'minimax', label: 'MiniMax Code', + events: { SessionStart: EVENTS.SESSION_START, UserPromptSubmit: EVENTS.PROMPT, PreToolUse: EVENTS.BEFORE_EDIT, PostToolUse: EVENTS.TOOL_RESULT, Stop: EVENTS.TURN_END, SessionEnd: EVENTS.SESSION_END }, + payload: { model: sessionModelOf }, + modelRecord, + commands: { entry: 'intercept', parse: promptCommandOf, background: isBackgroundCommand }, + reply: { + command: result => ({ decision: 'block', reason: result.text, systemMessage: result.text }), + turnEnd: ended => { const line = endedTurnSummaryOf(ended, { reason: 'answer' }); return line ? { systemMessage: line } : {}; }, + }, + evidence: { toolNote: toolNoteOf, editedFiles: editedFilesOf }, + turnEnd: { + unclosed: { settleMs: 0, as: 'error' }, + statusRecord: { + file: runtimeDatabaseOf, + // shortcut: read the v2 terminal status until MiniMax emits a native failed/cancelled hook. + query: 'SELECT status FROM local_runtime_turn_ingress WHERE session_id = ? AND turn_id = ?', + }, + }, + budgets: { prompt: 10_000, sessionEnd: 3_000 }, + owner: 'parent', + windowsAncestry: true, + drainWindowsHandles: true, + model: headlessMiniMax, + stateDir: stateDirOf, + settingsFile: env => join(stateDirOf(env), 'minimax-settings.json'), +}); diff --git a/hosts/qoder/commands.js b/hosts/qoder/commands.js index b16db12..ec13fe3 100644 --- a/hosts/qoder/commands.js +++ b/hosts/qoder/commands.js @@ -6,16 +6,8 @@ // leading slash always makes a command in Qoder: text after one that takes no argument is dropped, since letting the // prompt through would have the model read the command file's hooks-are-off fallback. -import { commandCatalog } from '../../lib/agent/commands.js'; +import { parsePromptCommand } from '../../lib/agent/commands.js'; -const PLUGIN_NAME = 'evolver-plugin'; -const COMMAND_NAMES = Object.freeze(commandCatalog.map((command) => command.name)); -const TAKES_NO_ARGUMENTS = Object.freeze(commandCatalog.filter((command) => !command.hint).map((command) => command.name)); -const TYPED = new RegExp(`^/(?:${PLUGIN_NAME}:)?(evolver-[a-z]+)(?:\\s+([\\s\\S]*))?$`); - -/** The command a prompt is, `{ name, args }` with its raw argument text, or null for any other prompt. */ export function promptCommandOf(prompt) { - const match = TYPED.exec(String(prompt ?? '').trim()); - if (!match || !COMMAND_NAMES.includes(match[1])) return null; - return { name: match[1], args: TAKES_NO_ARGUMENTS.includes(match[1]) ? '' : (match[2] ?? '').trim() }; + return parsePromptCommand(prompt, { namespace: 'evolver-plugin', requireSlash: true, ignoreExtra: true }); } diff --git a/lib/agent/commands.js b/lib/agent/commands.js index 15572ce..3bfa477 100644 --- a/lib/agent/commands.js +++ b/lib/agent/commands.js @@ -1,7 +1,24 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 EvoMap -import { COMMANDS } from '../app/commands/index.js'; +import { COMMANDS, HOST_COMMANDS } from '../app/commands/index.js'; /** What a host may show of the plugin's commands: name, description, input hint, and whether it is a prompt for the agent. */ -export const commandCatalog = Object.freeze(COMMANDS.map(({ name, description, hint, forAgent }) => Object.freeze({ name, description, hint, forAgent: Boolean(forAgent) }))); +const catalogOf = (commands) => Object.freeze(commands.map(({ name, description, hint, forAgent }) => + Object.freeze({ name, description, hint, forAgent: Boolean(forAgent) }))); +export const commandCatalog = catalogOf(COMMANDS); +export const hostCommandCatalog = catalogOf(HOST_COMMANDS); + +const PROMPT_COMMAND = /^(\/?)(?:([\w-]+):)?(evolver-[a-z]+)(?:\s+([\s\S]*))?$/; + +export function parsePromptCommand(prompt, { catalog = commandCatalog, namespace, requireSlash = false, slashForAgent = false, ignoreExtra = false } = {}) { + const match = PROMPT_COMMAND.exec(String(prompt ?? '').trim()); + if (!match) return null; + const [, slash, prefix, name, rawArgs = ''] = match; + const command = catalog.find((entry) => entry.name === name); + if (!command || (!slash && (requireSlash || (slashForAgent && command.forAgent)))) return null; + if (prefix && namespace !== true && namespace !== prefix) return null; + const args = rawArgs.trim(); + if (args && !command.hint) return ignoreExtra ? { name, args: '' } : null; + return { name, args }; +} diff --git a/lib/agent/driver.js b/lib/agent/driver.js index 8ebe3e8..1c6ecec 100644 --- a/lib/agent/driver.js +++ b/lib/agent/driver.js @@ -53,8 +53,9 @@ const HANDLERS = { const prompt = profile.payload.prompt(payload, context); const isNewSession = profile.sessionStart === 'owner-change' && (await isNewOwner(ports, sessionId)); const notice = isNewSession ? await sessionStarted(ports, { sessionId, projectDir }) : null; - const typed = profile.commands.parse(payload, hostEvent, { prompt }); + const typed = profile.commands.parse(payload, hostEvent, { ...context, prompt }); const isIntercepted = Boolean(typed) && profile.commands.entry === 'intercept'; + if (isIntercepted && typed.background) return profile.reply.command(await ports.commandJobs.enqueue({ ...at, ...typed }), hostEvent, payload); const isForAgent = Boolean(typed && commandNamed(typed.name)?.forAgent); if (isIntercepted && !isForAgent) return profile.reply.command(await commandAnswer(profile, ports, at, typed), hostEvent, payload); // Where the host shows the agent only the typed command, the command's prompt rides this turn's context, also when @@ -69,6 +70,7 @@ const HANDLERS = { const hostTurn = profile.payload.hostTurn(payload, context); const holdForToolResult = profile.reply.strategyAt === STRATEGY_AT.TOOL_RESULT; const { injection, late } = await promptSubmitted(ports, { sessionId, prompt, turnId, turnStart, hostTurn, isCommand: Boolean(typed), waitMs: waitLeft(), keepLate, holdForToolResult }); + if (profile.turnEnd.statusRecord && turnId) ports.scheduler.schedule([`turn:${JSON.stringify({ ...at, turnId })}`]); const answer = profile.reply.context(joined(notice, await checkpointOf(), injection), hostEvent, payload); return late ? profile.reply.late(answer, late, hostEvent, payload) : answer; }, @@ -144,7 +146,7 @@ function geneOutcome(ports, at, { gene, writtenFor }) { export function createDriver({ profile, ports, transcripts, env = process.env, lifetime, startedAt = Date.now() }) { const contextOf = (payload, hostEvent) => { const file = profile.payload.transcriptPath(payload); - return { env, hostEvent, transcriptSize: () => transcripts.size(file), transcriptSince: (offset) => transcripts.readSince(file, offset) }; + return { env, hostEvent, transcriptSize: () => transcripts.size(file), transcriptSince: (offset) => transcripts.readSince(file, offset), modelRecord: key => ports.modelRecord?.(key) }; }; return { /** diff --git a/lib/agent/profile.js b/lib/agent/profile.js index e743ab2..e428587 100644 --- a/lib/agent/profile.js +++ b/lib/agent/profile.js @@ -81,6 +81,10 @@ function problemsOf(profile) { if (profile.model !== null && !MODEL_KINDS.includes(profile.model?.kind)) problems.push(`model must be null or of kind ${MODEL_KINDS.join(', ')}`); if (Object.values(profile.budgets).some((budget) => !Number.isFinite(budget))) problems.push('budgets must be milliseconds'); if (!isFunction(profile.stateDir)) problems.push('stateDir must be a function of the environment'); + if (profile.settingsFile !== undefined && !isFunction(profile.settingsFile)) problems.push('settingsFile must be a function of the environment'); + if (profile.modelRecord !== undefined && (!isFunction(profile.modelRecord.file) || typeof profile.modelRecord.query !== 'string')) problems.push('modelRecord requires file(env) and a fixed query'); + if (profile.turnEnd.statusRecord !== undefined && (!isFunction(profile.turnEnd.statusRecord.file) || typeof profile.turnEnd.statusRecord.query !== 'string')) problems.push('turnEnd.statusRecord requires file(env) and a fixed query'); + if (profile.commands.background !== undefined && !isFunction(profile.commands.background)) problems.push('commands.background must select admitted command names'); if (!Array.isArray(profile.legacy)) problems.push('legacy must be a list of leftovers to adopt'); return problems; } diff --git a/lib/app/background.js b/lib/app/background.js index 58b20cb..c5486ef 100644 --- a/lib/app/background.js +++ b/lib/app/background.js @@ -1,13 +1,27 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 EvoMap +import { runCommand } from './commands/index.js'; import { deliverReports } from './deliver-reports.js'; import { distillLeftovers, dropExpiredLeftovers } from './distill-session.js'; +import { turnEnded } from './turn-end.js'; import { TASKS } from './ports.js'; +function cancellable(ports, signal) { + const combined = (other) => other ? AbortSignal.any([other, signal]) : signal; + return { + ...ports, + scheduler: { ...ports.scheduler, signal: combined(ports.scheduler.signal) }, + proxy: { ...ports.proxy, request: (method, path, body, options = {}) => ports.proxy.request(method, path, body, { ...options, signal: combined(options.signal) }) }, + model: ports.model && { complete: (model, prompt, other) => ports.model.complete(model, prompt, combined(other)) }, + }; +} + const WORK = { [TASKS.ENGINE]: (ports) => ports.engine.setUp(), [TASKS.DRAIN]: deliverReports, + command: (ports, id) => ports.commandJobs?.run(id, (at, signal) => runCommand(cancellable(ports, signal), { ...at, signal })), + turn: (ports, payload) => ports.turnMonitor?.(JSON.parse(payload), (closed) => turnEnded(ports, closed)), // Without a model outside the conversation a leftover cannot be distilled here; trying would only use up its tries // before the agent's own model, asked in its session, gets to write the Gene. A person who turned distillation off // after a session still distills on demand. @@ -19,7 +33,12 @@ export async function runBackgroundTasks(ports, tasks) { let isClean = true; for (const task of tasks) { try { - await WORK[task]?.(ports); + const separator = task.indexOf(':'); + const kind = separator < 0 ? task : task.slice(0, separator); + const payload = separator < 0 ? undefined : task.slice(separator + 1); + if (!Object.hasOwn(WORK, kind)) continue; + const result = await WORK[kind](ports, payload); + if (kind === 'command' && result === false) isClean = false; } catch { isClean = false; } diff --git a/lib/app/commands/host-settings.js b/lib/app/commands/host-settings.js new file mode 100644 index 0000000..346073d --- /dev/null +++ b/lib/app/commands/host-settings.js @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EvoMap + +import { failure, success } from './result.js'; + +export const jobs = { + name: 'evolver-jobs', + description: 'List, inspect or cancel background Evolver commands.', + hint: '[job |cancel ]', + async run({ commandJobs }, { args }) { + if (!commandJobs) return failure('This host does not run background commands.'); + if (!args.trim()) return commandJobs.status(); + const match = /^(job|cancel)\s+(\S+)$/.exec(args.trim()); + if (!match) return failure('Usage: evolver-jobs [job |cancel ].'); + return match[1] === 'cancel' ? commandJobs.cancel(match[2]) : commandJobs.status(match[2]); + }, +}; + +export const config = { + name: 'evolver-config', + description: 'Set persistent Evolver preferences for this host.', + hint: ' ', + async run({ settings }, { args }) { + if (!settings?.configure) return failure('This host configures Evolver through its existing settings.'); + const match = /^(recall|auto-distill)\s+(on|off)$/.exec(args.trim()); + if (!match) return failure('Usage: evolver-config .'); + await settings.configure({ [match[1] === 'recall' ? 'recall' : 'autoDistill']: match[2] === 'on' }); + return success(`Recall: ${settings.recall ? 'on' : 'off'}; automatic distillation: ${settings.autoDistill ? 'on' : 'off'}.`); + }, +}; diff --git a/lib/app/commands/index.js b/lib/app/commands/index.js index 1c09ee5..fd6175f 100644 --- a/lib/app/commands/index.js +++ b/lib/app/commands/index.js @@ -5,6 +5,7 @@ import distill from './distill.js'; import drafts from './drafts.js'; import evolve from './evolve.js'; import evolverCli from './evolver-cli.js'; +import { config, jobs } from './host-settings.js'; import login from './login.js'; import { failure } from './result.js'; import search from './search.js'; @@ -13,6 +14,9 @@ import status from './status.js'; // Every command is run by the plugin, not the model, whichever way the host lets a person type it. export const COMMANDS = Object.freeze([status, login, search, distill, drafts, evolve, ...evolverCli]); +export const HOST_COMMANDS = Object.freeze([jobs, config]); +const ALL_COMMANDS = [...COMMANDS, ...HOST_COMMANDS]; + const TYPED_COMMAND = /^\/((?:[\w-]+:)?evolver-[\w-]+)(?:[ \t]+([\s\S]*))?$/; /** The `/evolver-…` command a prompt is, with its raw argument text, or null; a plugin namespace before it is dropped. */ @@ -22,7 +26,7 @@ export function typedCommandOf(prompt) { return { name: match[1].replace(/^[\w-]+:/, ''), args: (match[2] ?? '').trim() }; } -export const commandNamed = (name) => COMMANDS.find((command) => command.name === name) ?? null; +export const commandNamed = (name) => ALL_COMMANDS.find((command) => command.name === name) ?? null; /** `{ ok, text }`, plus `forAgent` for a prompt meant for the agent and `agentGene` for a Gene the agent may write. */ export async function runCommand(ports, { sessionId, projectDir = null, anySession = false, signal, name, args = '' }) { diff --git a/lib/app/commands/status.js b/lib/app/commands/status.js index 7a7ef86..f02b2bf 100644 --- a/lib/app/commands/status.js +++ b/lib/app/commands/status.js @@ -35,7 +35,7 @@ function signInLine(state) { export default { name: 'evolver-status', description: 'Evolver health: the Proxy, engine setup, Hub sign-in, distillation candidate and drafts.', - async run({ identity, proxy, engine, candidates, drafts, signIn, clock }, { sessionId, anySession }) { + async run({ identity, proxy, engine, candidates, drafts, signIn, clock, settings }, { sessionId, anySession }) { const answered = await proxy.request('GET', '/proxy/status', undefined, { timeoutMs: 3_000 }); const waiting = (await drafts.read()).length; return success([ @@ -43,6 +43,7 @@ export default { engineLine(await engine.outcome(), identity.label), candidateLine(await candidates.read(), { sessionId, anySession }), `${waiting} distilled draft(s) waiting; /evolver-drafts lists them.`, + ...(settings?.file ? [`Recall: ${settings.recall ? 'on' : 'off'}; automatic distillation: ${settings.autoDistill ? 'on' : 'off'}.`, `Plugin settings: ${settings.file}`, ...(settings.error ? [settings.error] : [])] : []), signInLine(signInOf(await signIn.latest(), clock.now())), ].join('\n')); }, diff --git a/lib/app/distill-session.js b/lib/app/distill-session.js index c2407af..6ad86f1 100644 --- a/lib/app/distill-session.js +++ b/lib/app/distill-session.js @@ -35,7 +35,11 @@ async function post(ports, { sessionId, candidate }, args, usage, signal) { function watchCandidate({ candidates }, { sessionId, candidate }) { const controller = new AbortController(); const timer = setInterval(async () => { - if (!isCurrentCandidate(await candidates.read(), sessionId, candidate)) controller.abort(); + try { + if (!isCurrentCandidate(await candidates.read(), sessionId, candidate)) controller.abort(); + } catch { + // Retry a failed read; the fresh check before posting must still succeed. + } }, WATCH_MS); timer.unref?.(); return { signal: controller.signal, stop: () => clearInterval(timer) }; diff --git a/lib/app/ports.js b/lib/app/ports.js index 9d9658e..9fa0f4c 100644 --- a/lib/app/ports.js +++ b/lib/app/ports.js @@ -60,7 +60,7 @@ * * @typedef {object} Clock * @property {() => number} now - * @property {(ms: number) => Promise} sleep + * @property {(ms: number, signal?: AbortSignal) => Promise} sleep * * @typedef {object} Engine Evolver itself: whether it needs setting up, setting it up, and its outcome. * @property {() => Promise} needsSetup diff --git a/lib/app/prompt.js b/lib/app/prompt.js index c49da4d..b439553 100644 --- a/lib/app/prompt.js +++ b/lib/app/prompt.js @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 EvoMap +import { recallRequestOf } from './recall-request.js'; import { withoutCandidate } from '../distill/candidate.js'; import { promptTextOf, recallStrategy, taskTextOf } from '../recall.js'; import { correctionReportsOf, followUpLabelOf } from '../session/follow-ups.js'; @@ -65,11 +66,12 @@ async function recalled(ports, sessionId, { turn, prompt, waitMs, keepLate, hold const session = sessionOf(await ports.sessions.read(sessionId)); const listedIds = listedIdsOf(session); const context = session.task ? { task: session.task, trigger: 'turn_start' } : null; - const proxyFetch = (method, path, body) => ports.proxy.request(method, path, body); - const recipeCutoff = ports.clock.sleep(waitMs > 0 ? Math.max(0, waitMs - RECIPE_MARGIN_MS) : LATE_RECIPE_WAIT_MS); + const { request: proxyFetch, signal, cancel } = recallRequestOf(ports.proxy); + const recipeCutoff = ports.clock.sleep(waitMs > 0 ? Math.max(0, waitMs - RECIPE_MARGIN_MS) : LATE_RECIPE_WAIT_MS, signal); const recall = recallStrategy(proxyFetch, promptTextOf(prompt), { context, listedIds, closingOf: () => CLOSING, recipeCutoff }); const TIMED_OUT = Symbol('timed out'); - const match = waitMs > 0 ? await Promise.race([recall, ports.clock.sleep(waitMs).then(() => TIMED_OUT)]) : TIMED_OUT; + const match = waitMs > 0 ? await Promise.race([recall, ports.clock.sleep(waitMs, signal).then(() => TIMED_OUT)]) : TIMED_OUT; + if (match !== TIMED_OUT || !keepLate) cancel(); if (match !== TIMED_OUT && holdForToolResult) { await waitForToolResult(ports, sessionId, { turn, match }); return { injection: '' }; @@ -80,7 +82,7 @@ async function recalled(ports, sessionId, { turn, prompt, waitMs, keepLate, hold text: landed.text, show: () => shown(ports, sessionId, { turn, ids: landed.ids }), join: async () => (await booked(ports, sessionId, { turn, match: landed, onlyWhileOpen: true })) !== '', - } : null)).catch(() => null); + } : null)).catch(() => null).finally(cancel); return { injection: '', late }; } diff --git a/lib/app/recall-request.js b/lib/app/recall-request.js new file mode 100644 index 0000000..1ecf831 --- /dev/null +++ b/lib/app/recall-request.js @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EvoMap + +export function recallRequestOf(proxy) { + const controller = new AbortController(); + const { signal } = controller; + const pending = new Set(); + return { + signal, + cancel() { + controller.abort(); + for (const request of pending) request.abort(); + }, + async request(method, path, body) { + if (signal.aborted) return { ok: false }; + const request = new AbortController(); + pending.add(request); + try { + return await proxy.request(method, path, body, { signal: request.signal }); + } finally { + pending.delete(request); + } + }, + }; +} diff --git a/lib/app/stuck-recall.js b/lib/app/stuck-recall.js index 6503236..32d6eff 100644 --- a/lib/app/stuck-recall.js +++ b/lib/app/stuck-recall.js @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 EvoMap +import { recallRequestOf } from './recall-request.js'; import { recallStrategy } from '../recall.js'; import { listedIdsOf, sessionOf, withStuckQuery } from '../session/state.js'; import { languagesOf, stuckQueryOf } from '../stuck-query.js'; @@ -38,12 +39,17 @@ export async function recallWhenStuck(ports, { sessionId, turnId = null, note, w const record = turnRecordOf(await ports.notes.read(sessionId)); const query = stuckQueryOf({ output: note.output, paths: record.paths, identifiers: record.identifiers }); if (!query || !(await claimed(ports, sessionId, query, session.turn))) return ''; - const proxyFetch = (method, path, body) => ports.proxy.request(method, path, body); - const recall = recallStrategy(proxyFetch, query, { - context: contextOf(session, record.paths), - listedIds: listedIdsOf(session), - closingOf: () => CLOSING, - }); - const match = await Promise.race([recall, ports.clock.sleep(waitMs).then(() => null)]); + const { request: proxyFetch, signal, cancel } = recallRequestOf(ports.proxy); + let match; + try { + const recall = recallStrategy(proxyFetch, query, { + context: contextOf(session, record.paths), + listedIds: listedIdsOf(session), + closingOf: () => CLOSING, + }); + match = await Promise.race([recall, ports.clock.sleep(waitMs, signal).then(() => null)]); + } finally { + cancel(); + } return booked(ports, sessionId, { turn: session.turn, match, onlyWhileOpen: true }); } diff --git a/lib/channels/hook/runner.js b/lib/channels/hook/runner.js index a591080..633f697 100644 --- a/lib/channels/hook/runner.js +++ b/lib/channels/hook/runner.js @@ -6,6 +6,7 @@ import { createDriver } from '../../agent/driver.js'; import { profileNamed } from '../../agent/registry.js'; import { EPHEMERAL, environmentFor } from '../../runtime/environment.js'; import { isHeadlessRun } from '../../runtime/headless.js'; +import { exitProcess } from '../../runtime/process/exit.js'; import { transcripts } from '../../runtime/transcripts.js'; import { readPayload } from './stdin.js'; @@ -13,10 +14,11 @@ import { readPayload } from './stdin.js'; * One command-hook invocation: the host's payload on stdin, the agent's answer as JSON on stdout, exit 0. Inside * this plugin's own headless distillation the hook answers the agent's empty reply and does nothing else. */ -export async function runHookProcess(agent, hostEvent, { stdin = process.stdin, stdout = process.stdout, env = process.env, exit = process.exit, profileOf = profileNamed } = {}) { +export async function runHookProcess(agent, hostEvent, { stdin = process.stdin, stdout = process.stdout, env = process.env, exit, profileOf = profileNamed } = {}) { const startedAt = Date.now(); - const write = (answer) => stdout.write(JSON.stringify(answer ?? {}), () => exit(0)); const profile = await profileOf(agent).catch(() => null); + const finish = exit ?? ((code) => exitProcess(code, { drainWindowsHandles: profile?.drainWindowsHandles, stdin })); + const write = (answer) => stdout.write(JSON.stringify(answer ?? {}), () => finish(0)); if (!profile) return write({}); const payload = await readPayload(stdin); if (isHeadlessRun(env)) return write(profile.reply.empty(hostEvent)); diff --git a/lib/channels/worker/runner.js b/lib/channels/worker/runner.js index 98be1b7..3d049f3 100644 --- a/lib/channels/worker/runner.js +++ b/lib/channels/worker/runner.js @@ -4,10 +4,14 @@ import { runBackgroundTasks } from '../../app/background.js'; import { profileNamed } from '../../agent/registry.js'; import { EPHEMERAL, environmentFor } from '../../runtime/environment.js'; +import { exitProcess } from '../../runtime/process/exit.js'; -/** The detached worker a hook process starts for slow work: runs `tasks` for `agent`; true when none threw. */ -export async function runWorker(agent, tasks, { env = process.env } = {}) { +export async function runWorkerProcess(agent, tasks, env = process.env) { const profile = await profileNamed(agent).catch(() => null); - if (!profile) return false; - return environmentFor({ profile, env, lifetime: EPHEMERAL, runBackground: runBackgroundTasks }).runTasks(tasks); + let ok = false; + if (profile) { + const { runTasks } = environmentFor({ profile, env, lifetime: EPHEMERAL, runBackground: runBackgroundTasks }); + ok = await runTasks(tasks); + } + exitProcess(ok ? 0 : 1, { drainWindowsHandles: profile?.drainWindowsHandles }); } diff --git a/lib/local-ledger.js b/lib/local-ledger.js index 9c2d211..23b8c94 100644 --- a/lib/local-ledger.js +++ b/lib/local-ledger.js @@ -1,10 +1,10 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 EvoMap -import { execFile } from 'node:child_process'; import { createRequire } from 'node:module'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; +import { runProcess } from './runtime/process/spawn.js'; const HIT_EVENT = 'value.reuse_hit'; const OUTCOME_EVENT = 'value.reuse_outcome'; @@ -16,11 +16,9 @@ const byPath = new Map(); const NPM_ROOT_TIMEOUT_MS = 5_000; -function globalNpmRoot() { - return new Promise((resolve) => { - execFile('npm', ['root', '-g'], { timeout: NPM_ROOT_TIMEOUT_MS, encoding: 'utf8', shell: process.platform === 'win32' }, - (error, stdout) => resolve(error ? null : stdout.trim() || null)); - }); +async function globalNpmRoot() { + const result = await runProcess('npm', ['root', '-g'], { timeoutMs: NPM_ROOT_TIMEOUT_MS, shim: true }); + return result.error || result.code !== 0 ? null : result.stdout.trim() || null; } // A plugin installed from a marketplace has no node_modules of its own. The diff --git a/lib/runtime/command-jobs.js b/lib/runtime/command-jobs.js new file mode 100644 index 0000000..779c8cc --- /dev/null +++ b/lib/runtime/command-jobs.js @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EvoMap + +import { randomUUID } from 'node:crypto'; +import { lstatSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { nodeExecutable } from './process/node.js'; +import { ownerIsGone } from './process/owner.js'; +import { startLogged } from './process/spawn.js'; +import { withLock, writeState } from './store/file-lock.js'; + +const WORKER = fileURLToPath(new URL('../../bin/worker.mjs', import.meta.url)); +const ID = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/; +const ACTIVE = new Set(['queued', 'running', 'cancelling']); +const MAX_RESULT = 16_000; +const MAX_HISTORY_BYTES = 512 * 1024; +const DEADLINE_MS = 12 * 60 * 1000; + +const answer = (ok, text) => ({ ok, text }); +const displayed = job => `Job ${job.id}: ${job.name} — ${job.state}.${job.text ? `\n${job.text}` : ''}`; + +/** One admitted command per host, with durable results and cancellation owned by its live worker. */ +export function commandJobsOf({ dir, agent, env, accepts, start = startLogged, isGone = ownerIsGone }) { + const file = join(dir, 'commands.json'); + const lock = join(dir, 'commands.lock'); + const read = () => { + try { + const stat = lstatSync(file); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 * 1024) throw new Error('invalid file'); + const jobs = JSON.parse(readFileSync(file, 'utf8')); + if (!Array.isArray(jobs) || jobs.length > 20 || jobs.some(job => !ID.test(job?.id ?? '') || typeof job.args !== 'string' || !accepts(job.name, job.args) + || job.args.length > 16_000 || typeof job.sessionId !== 'string' || !job.sessionId || job.sessionId.length > 256 || typeof job.projectDir !== 'string' + || !['queued', 'running', 'cancelling', 'completed', 'cancelled', 'failed', 'interrupted'].includes(job.state) + || (ACTIVE.has(job.state) && (!Number.isSafeInteger(job.pid) || job.pid <= 0)))) throw new Error('invalid state'); + return jobs; + } catch (error) { + if (error.code === 'ENOENT') return []; + throw new Error('Command state is unreadable; inspect the private commands.json before starting another command.'); + } + }; + const refresh = jobs => jobs.map(job => ACTIVE.has(job.state) && job.pid && isGone(job.pid) + ? { ...job, state: 'interrupted', text: 'The worker exited without a result. Inspect the operation before retrying; it was not replayed.' } : job); + const change = async mutate => { + let result = answer(false, 'Command state is busy; try again.'); + for (let attempt = 0; attempt < 100; attempt++) { + const held = await withLock(lock, async () => { + const jobs = refresh(read()); + result = await mutate(jobs); + const kept = jobs.slice(-20); + while (kept.length > 1 && Buffer.byteLength(JSON.stringify(kept)) > MAX_HISTORY_BYTES) kept.shift(); + writeState(file, kept); + }); + if (held) return result; + await new Promise(resolve => setTimeout(resolve, 20)); + } + return result; + }; + return { + enqueue: at => change(async jobs => { + if (typeof at.args !== 'string' || !accepts(at.name, at.args) || at.args.length > 16_000 || typeof at.sessionId !== 'string' || !at.sessionId || at.sessionId.length > 256 || typeof at.projectDir !== 'string') return answer(false, 'Invalid background command.'); + const active = jobs.find(job => ACTIVE.has(job.state)); + if (active) return answer(false, `Job ${active.id} is still ${active.state}; inspect or cancel it before starting another command.`); + const job = { id: randomUUID(), name: at.name, args: at.args, sessionId: at.sessionId, projectDir: at.projectDir, state: 'queued', at: Date.now() }; + jobs.push(job); + // Hold admission until the process exists; its worker claims the same lock before doing any work. + try { + const started = await start(nodeExecutable(), [WORKER, agent, `command:${job.id}`], { env, logFile: join(dir, 'worker.log') }); + if (!started.pid) throw new Error('worker missing'); + job.pid = started.pid; + } catch { job.state = 'failed'; job.text = 'The command worker could not start; no command ran.'; } + return answer(job.state === 'queued', `${displayed(job)}\nUse evolver-jobs job ${job.id} for the result, or evolver-jobs cancel ${job.id}.`); + }), + async status(id) { + if (id && !ID.test(id)) return answer(false, 'Invalid command job id.'); + const jobs = refresh(read()); + const chosen = id ? jobs.filter(job => job.id === id) : jobs.slice(-5); + return answer(chosen.length > 0 || !id, chosen.length ? chosen.map(displayed).join('\n\n') : 'No command jobs found.'); + }, + cancel: id => change(jobs => { + const job = ID.test(id ?? '') && jobs.find(item => item.id === id); + if (!job) return answer(false, 'No command job matches that id.'); + if (ACTIVE.has(job.state)) job.state = job.state === 'queued' ? 'cancelled' : 'cancelling'; + return answer(true, `${displayed(job)}\nCancellation stops the active request; completed side effects are not undone.`); + }), + async run(id, execute) { + if (!ID.test(id)) return false; + let claimed; + await change(jobs => { + const job = jobs.find(item => item.id === id); + if (job?.state !== 'queued' || job.pid !== process.pid) return false; + job.state = 'running'; claimed = { ...job }; + }); + if (!claimed) return false; + const controller = new AbortController(); + const deadline = setTimeout(() => controller.abort(new Error('Command exceeded its execution budget.')), DEADLINE_MS); + const poll = setInterval(() => { + try { + const job = read().find(item => item.id === id); + if (!job || job.state === 'cancelling') controller.abort(new Error('Command cancelled.')); + } catch { /* A failed read is not a cancellation. The next poll and deadline still apply. */ } + }, 200); + let result; + try { result = await execute(claimed, controller.signal); } + catch { result = answer(false, 'Command failed; no automatic retry was made.'); } + finally { clearInterval(poll); clearTimeout(deadline); } + await change(jobs => { + const job = jobs.find(item => item.id === id); + if (!job || job.pid !== process.pid) return; + const cancelled = job.state === 'cancelling'; + job.state = cancelled ? 'cancelled' : controller.signal.aborted ? 'failed' : result?.ok ? 'completed' : 'failed'; + job.text = `${controller.signal.aborted ? `${controller.signal.reason.message}\n` : ''}${String(result?.text ?? '')}`.slice(0, MAX_RESULT); + job.finishedAt = Date.now(); + }); + return true; + }, + }; +} diff --git a/lib/runtime/environment.js b/lib/runtime/environment.js index f4892ba..7dfc2e0 100644 --- a/lib/runtime/environment.js +++ b/lib/runtime/environment.js @@ -3,6 +3,7 @@ import { recordReuseLocally } from '../local-ledger.js'; import { evolverCliOf } from './cli-executor.js'; +import { commandJobsOf } from './command-jobs.js'; import { enginePortOf } from './engine/job.js'; import { hubSignInOf, signInFilesOf } from './hub-login.js'; import { detachedScheduler } from './lifetime/ephemeral.js'; @@ -10,12 +11,20 @@ import { inProcessScheduler } from './lifetime/resident.js'; import { modelRunnerOf } from './model-executor.js'; import { ownerProbeOf } from './process/owner.js'; import { proxyFetchOf, runningProxy } from './proxy.js'; +import { monitorTurn } from './turn-monitor.js'; +import { settingsOf } from './settings.js'; +import { recordReaderOf } from './records.js'; import { fileStoresOf } from './store/file-stores.js'; export const EPHEMERAL = 'ephemeral'; export const RESIDENT = 'resident'; -const clock = Object.freeze({ now: () => Date.now(), sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)) }); +const clock = Object.freeze({ now: () => Date.now(), sleep: (ms, signal) => new Promise(resolve => { + const done = () => { clearTimeout(timer); signal?.removeEventListener('abort', done); resolve(); }; + const timer = setTimeout(done, ms); + signal?.addEventListener('abort', done, { once: true }); + if (signal?.aborted) done(); +}) }); /** * Builds the ports for one agent in one execution environment: the only place execution differences are decided. @@ -26,7 +35,7 @@ const clock = Object.freeze({ now: () => Date.now(), sleep: (ms) => new Promise( export function environmentFor({ profile, env = process.env, lifetime, runBackground, overrides = {} }) { const stores = fileStoresOf({ dir: profile.stateDir(env), agent: profile.name }); stores.adoptLegacy(profile.legacy); - const request = proxyFetchOf(env); + const request = proxyFetchOf(env, { closeConnection: lifetime === EPHEMERAL }); const ports = { identity: { agent: profile.name, label: profile.label }, sessions: stores.sessions, @@ -39,15 +48,20 @@ export function environmentFor({ profile, env = process.env, lifetime, runBackgr retention: stores.retention, proxy: { request }, model: modelRunnerOf(profile.model, env), - owner: ownerProbeOf(profile.owner), + modelRecord: recordReaderOf(profile.modelRecord, env), + turnStatus: recordReaderOf(profile.turnEnd.statusRecord, env), + owner: ownerProbeOf(profile.owner, { windowsAncestry: profile.windowsAncestry }), clock, engine: enginePortOf(stores, { runningProxy: () => runningProxy({ env, proxyFetch: request }) }), ledger: { record: (entry) => recordReuseLocally(entry, { enginePrefix: stores.folder('engine') }) }, cli: evolverCliOf({ enginePrefix: stores.folder('engine') }), signIn: hubSignInOf({ files: signInFilesOf(env), enginePrefix: stores.folder('engine'), env }), - settings: { recall: env.EVOLVER_RECALL !== 'off', autoDistill: env.EVOLVER_AUTO_DISTILL !== 'off' }, + settings: settingsOf(env, profile.settingsFile?.(env)), ...overrides, }; + if (profile.commands.background) ports.commandJobs ??= commandJobsOf({ dir: stores.folder('commands'), agent: profile.name, env, accepts: profile.commands.background }); + if (ports.turnStatus) ports.turnMonitor ??= (at, onClosed) => + monitorTurn(ports, at, onClosed, { dir: stores.folder('turn-monitors') }); const runTasks = async (tasks) => { stores.prune(); return runBackground(ports, tasks); diff --git a/lib/runtime/headless.js b/lib/runtime/headless.js index fd8c97a..6834f9b 100644 --- a/lib/runtime/headless.js +++ b/lib/runtime/headless.js @@ -1,10 +1,29 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 EvoMap -// A headless model run loads the agent's plugins too; this marker in its environment turns this plugin's hooks -// off there, so a distillation never recalls into its own prompt or starts more workers. +import { lstatSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join, resolve } from 'node:path'; + export const HEADLESS_MARKER = 'EVOLVER_HEADLESS'; +const WORKDIR_MARKER = '.evolver-headless'; +const CONTENT = `${HEADLESS_MARKER}=1\n`; + +/** The private scratch marker survives hosts which remove inherited environment variables from their hooks. */ +export function markHeadlessWorkdir(dir) { + writeFileSync(join(dir, WORKDIR_MARKER), CONTENT, { mode: 0o600, flag: 'wx' }); +} -export const isHeadlessRun = (env = process.env) => env[HEADLESS_MARKER] === '1'; +export function isHeadlessRun(env = process.env, cwd = process.cwd()) { + if (env[HEADLESS_MARKER] === '1') return true; + if (typeof cwd !== 'string' || !basename(cwd).startsWith('evolver-model-')) return false; + const normalized = value => process.platform === 'win32' ? resolve(value).toLowerCase() : resolve(value); + if (normalized(dirname(cwd)) !== normalized(tmpdir())) return false; + try { + const file = join(cwd, WORKDIR_MARKER); + const stat = lstatSync(file); + return stat.isFile() && !stat.isSymbolicLink() && stat.size === Buffer.byteLength(CONTENT) && readFileSync(file, 'utf8') === CONTENT; + } catch { return false; } +} export const headlessEnv = (env = process.env) => ({ ...env, [HEADLESS_MARKER]: '1' }); diff --git a/lib/runtime/model-executor.js b/lib/runtime/model-executor.js index fad14a2..e1bf23b 100644 --- a/lib/runtime/model-executor.js +++ b/lib/runtime/model-executor.js @@ -1,16 +1,29 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 EvoMap -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { nodeExecutable } from './process/node.js'; import { runProcess } from './process/spawn.js'; import { executableOf } from './process/which.js'; +import { markHeadlessWorkdir } from './headless.js'; const ERROR_MAX_CHARS = 300; const PREFLIGHT_TIMEOUT_MS = 10_000; +const CONFIG_MAX_BYTES = 1024 * 1024; + +function prepareConfig(declaration, env, workdir) { + if (!declaration.configFile) return undefined; + const source = declaration.configFile.source(env); + const stat = statSync(source); + if (!stat.isFile() || stat.size > CONFIG_MAX_BYTES) throw new Error('Model configuration must be a regular file of at most 1 MiB'); + const content = declaration.configFile.prepare(readFileSync(source, 'utf8')); + const target = join(workdir, 'config.yaml'); + writeFileSync(target, content, { mode: 0o600, flag: 'wx' }); + return target; +} /** Why a run that exited non-zero failed: what its own output says when the declaration can read it, else its exit. */ function exitFailureOf(declaration, command, result) { @@ -28,7 +41,9 @@ function exitFailureOf(declaration, command, result) { * for (see process/which.js), args(model, prompt, { workdir, preflight }) → string[], * parse({ stdout, stderr }) → { answer, usage }, input?(prompt) → stdin text ('' when the prompt is an argument), * workdir?: 'empty' (a fresh folder, removed after), promptFile?: a file name in that folder the prompt is written - * to, handed to `args` as `promptPath`, env?: variables set for the run, shim?, + * to, handed to `args` as `promptPath`, env?: variables or (env) → variables for the run, shim?, + * configFile?: { source(env) → path, prepare(text) → text }, copied privately into the scratch folder and + * handed to `args` as `configPath`; the source file is never modified, * preflight?: { args, parse({ code, stdout }) → what `args` gets as `preflight`, or null not to run the model } } * { kind: 'sdk', complete? } an in-process host API; without `complete` here, the host's entry passes the model * port itself when it builds the channel @@ -46,8 +61,7 @@ export function modelRunnerOf(declaration, env = process.env) { const command = found.viaNode ? nodeExecutable() : found.file; const leading = found.viaNode ? [found.file] : []; const shim = (declaration.shim || /\.cmd$/i.test(found.file)) && !found.viaNode; - const runEnv = declaration.env ? { ...env, ...declaration.env } : env; - const preflightOf = async (signal) => { + const preflightOf = async (signal, runEnv) => { if (!declaration.preflight) return undefined; const ran = await runProcess(command, [...leading, ...declaration.preflight.args], { env: runEnv, signal, shim, headless: true, timeoutMs: PREFLIGHT_TIMEOUT_MS }); const checked = ran.error ? null : declaration.preflight.parse({ code: ran.code, stdout: ran.stdout }); @@ -56,19 +70,22 @@ export function modelRunnerOf(declaration, env = process.env) { }; return { complete: async (model, prompt, signal) => { - const preflight = await preflightOf(signal); - const workdir = declaration.workdir === 'empty' || declaration.promptFile ? mkdtempSync(join(tmpdir(), 'evolver-model-')) : undefined; + const runEnv = declaration.env ? { ...env, ...(typeof declaration.env === 'function' ? declaration.env(env) : declaration.env) } : env; + const preflight = await preflightOf(signal, runEnv); + const workdir = declaration.workdir === 'empty' || declaration.promptFile || declaration.configFile ? mkdtempSync(join(tmpdir(), 'evolver-model-')) : undefined; try { + if (workdir) markHeadlessWorkdir(workdir); + const configPath = prepareConfig(declaration, runEnv, workdir); const promptPath = declaration.promptFile ? join(workdir, declaration.promptFile) : undefined; if (promptPath) writeFileSync(promptPath, prompt); const input = promptPath ? '' : declaration.input ? declaration.input(prompt) : prompt; - const args = [...leading, ...declaration.args(model, prompt, { workdir, preflight, promptPath })]; + const args = [...leading, ...declaration.args(model, prompt, { workdir, preflight, promptPath, configPath })]; const result = await runProcess(command, args, { input, cwd: workdir, env: runEnv, signal, shim, headless: true }); if (result.error) throw result.error; if (result.code !== 0) throw exitFailureOf(declaration, command, result); return declaration.parse(result); } finally { - if (workdir) rmSync(workdir, { recursive: true, force: true }); + if (workdir) rmSync(workdir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } }, }; diff --git a/lib/runtime/process/exit.js b/lib/runtime/process/exit.js new file mode 100644 index 0000000..08f5ef6 --- /dev/null +++ b/lib/runtime/process/exit.js @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EvoMap + +export function exitProcess(code, { drainWindowsHandles, stdin } = {}) { + if (process.platform !== 'win32' || !drainWindowsHandles) process.exit(code); + stdin?.unref?.(); + process.exitCode = code; +} diff --git a/lib/runtime/process/owner.js b/lib/runtime/process/owner.js index 8d39208..39f6b0c 100644 --- a/lib/runtime/process/owner.js +++ b/lib/runtime/process/owner.js @@ -5,7 +5,9 @@ import { spawnSync } from 'node:child_process'; import { readlinkSync } from 'node:fs'; import path from 'node:path'; -const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'fish', 'cmd.exe', 'powershell', 'pwsh']); +import { windowsAncestorsOf } from './windows.js'; + +const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'fish', 'cmd', 'powershell', 'pwsh']); const PS_TIMEOUT_MS = 1000; const MAX_ANCESTORS = 16; @@ -43,9 +45,14 @@ export function processInfo(pid) { } function parentPid(probe) { - const parent = probe(process.ppid); - if (!parent) return null; - return SHELLS.has(parent.name) ? parent.ppid : process.ppid; + let pid = process.ppid; + for (let depth = 0; depth < MAX_ANCESTORS && pid > 1; depth += 1) { + const parent = probe(pid); + if (!parent) return null; + if (!SHELLS.has(parent.name.toLowerCase().replace(/\.exe$/, ''))) return pid; + pid = parent.ppid; + } + return null; } function ancestorPid(isOwner, probe) { @@ -64,17 +71,21 @@ function ancestorPid(isOwner, probe) { * (the process that started this one with no shell between, such as a host's relay), 'parent' (the host that ran this * hook; a shell in between is skipped), `{ ancestor: /name/ }` (the nearest ancestor so named) or * `{ ancestorPath: /path/ }` (the nearest whose executable's path matches: a host running as `node`). The last three - * need a process table; where none can be read, Windows included, they are null and such entries never read as + * need a process table; where none can be read they are null and such entries never read as * orphaned. */ -export function ownerProbeOf(declaration, { probe = processInfo, platform = process.platform } = {}) { +export function ownerProbeOf(declaration, { probe, platform = process.platform, windowsAncestry = false } = {}) { + let windows; + const readProcess = probe ?? (platform === 'win32' + ? (pid) => (windows ??= windowsAncestorsOf(process.ppid)).get(pid) ?? null + : processInfo); const locate = () => { if (declaration === 'self') return process.pid; if (declaration === 'spawner') return process.ppid; - if (platform === 'win32') return null; - if (declaration === 'parent') return parentPid(probe); - if (declaration?.ancestor instanceof RegExp) return ancestorPid((info) => declaration.ancestor.test(info.name), probe); - if (declaration?.ancestorPath instanceof RegExp) return ancestorPid((info) => declaration.ancestorPath.test(info.path ?? info.name), probe); + if (platform === 'win32' && !windowsAncestry) return null; + if (declaration === 'parent') return parentPid(readProcess); + if (declaration?.ancestor instanceof RegExp) return ancestorPid((info) => declaration.ancestor.test(info.name), readProcess); + if (declaration?.ancestorPath instanceof RegExp) return ancestorPid((info) => declaration.ancestorPath.test(info.path ?? info.name), readProcess); return null; }; let current; diff --git a/lib/runtime/process/spawn.js b/lib/runtime/process/spawn.js index 9512f10..c706500 100644 --- a/lib/runtime/process/spawn.js +++ b/lib/runtime/process/spawn.js @@ -14,6 +14,17 @@ import { headlessEnv } from '../headless.js'; const KILL_GRACE_MS = 5_000; const OUTPUT_MAX_BYTES = 16 * 1024 * 1024; const IS_WINDOWS = process.platform === 'win32'; +const WINDOWS_ROOT = process.env.SystemRoot || process.env.WINDIR || ''; +const SYSTEM32 = IS_WINDOWS && path.isAbsolute(WINDOWS_ROOT) ? path.join(WINDOWS_ROOT, 'System32') : null; + +function windowsSystemDirectory() { + // A stripped host environment still has the Windows loader mapped from the system directory. + const loader = process.report.getReport().sharedObjects.find((file) => + path.isAbsolute(file) && /[\\/](?:system32|syswow64)[\\/]ntdll\.dll$/i.test(file)); + return loader ? path.dirname(loader) : null; +} + +const TASKKILL_DIR = IS_WINDOWS ? SYSTEM32 ?? windowsSystemDirectory() : null; // npm shims (`evolver`, `npx`, `npm`) only run through cmd.exe, which reads the line once while the shim re-reads its // arguments, so each argument is quoted for the C runtime and its cmd metacharacters are caret-escaped twice. @@ -24,8 +35,14 @@ export function cmdArgument(arg) { return quoted.replace(CMD_METACHARACTERS, '^$1').replace(CMD_METACHARACTERS, '^$1'); } +const cmdLine = (command, args) => [String(command).replace(CMD_METACHARACTERS, '^$1'), ...args.map(cmdArgument)].join(' '); + function taskkill(pid) { - execFile('taskkill', ['/pid', String(pid), '/T', '/F'], { windowsHide: true }, () => {}); + if (!TASKKILL_DIR) return; + const root = path.dirname(TASKKILL_DIR); + // taskkill itself needs the system root, even when launched by its absolute path. + const env = SYSTEM32 ? process.env : { ...process.env, SystemRoot: root, WINDIR: root }; + execFile(path.join(TASKKILL_DIR, 'taskkill.exe'), ['/pid', String(pid), '/T', '/F'], { env, windowsHide: true }, () => {}); } function signalGroup(pid, name) { @@ -50,6 +67,9 @@ export function endTree(pid, { platform = process.platform, killWindowsTree = ta export function runProcess(command, args = [], { input, env = process.env, cwd = os.homedir(), timeoutMs, signal, shim = false, headless = false } = {}) { return new Promise((resolve) => { const viaShell = IS_WINDOWS && shim; + if ((viaShell && !SYSTEM32) || (IS_WINDOWS && !TASKKILL_DIR && (timeoutMs || signal))) { + return resolve({ code: null, stdout: '', stderr: '', error: Object.assign(new Error('Windows system directory is unavailable; no command was started'), { code: 'ENOENT' }) }); + } let stdout = ''; let stderr = ''; let settled = false; @@ -60,8 +80,8 @@ export function runProcess(command, args = [], { input, env = process.env, cwd = signal?.removeEventListener('abort', stop); resolve({ stdout, stderr, ...result }); }; - const child = spawn(command, viaShell ? args.map(cmdArgument) : args, { - cwd, env: headless ? headlessEnv(env) : env, shell: viaShell, detached: !IS_WINDOWS, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], + const child = spawn(viaShell ? cmdLine(command, args) : command, viaShell ? [] : args, { + cwd, env: headless ? headlessEnv(env) : env, shell: viaShell ? path.join(SYSTEM32, 'cmd.exe') : false, detached: !IS_WINDOWS, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], }); const stop = () => child.pid && endTree(child.pid); const timer = timeoutMs ? setTimeout(() => { stop(); finish({ code: null, error: Object.assign(new Error(`timed out after ${timeoutMs} ms`), { code: 'ETIMEDOUT' }) }); }, timeoutMs) : null; @@ -84,8 +104,9 @@ export function startDetached(command, args, { env = process.env, cwd = os.homed child.unref(); } -function isOnWindowsPath(name) { - return new Promise((resolve) => execFile('where', [name], { windowsHide: true }, (error) => resolve(!error))); +function isOnWindowsPath(name, env = process.env) { + if (!SYSTEM32) return Promise.resolve(false); + return new Promise((resolve) => execFile(path.join(SYSTEM32, 'where.exe'), [name], { env, windowsHide: true }, (error) => resolve(!error))); } function spawnedOrMissing(child) { @@ -102,12 +123,12 @@ function spawnedOrMissing(child) { */ export async function startLogged(command, args, { logFile, env = process.env, cwd = os.homedir(), shim = false }) { const viaShell = IS_WINDOWS && shim; - if (viaShell && !(await isOnWindowsPath(command))) return { missing: true }; + if (viaShell && !(await isOnWindowsPath(command, env))) return { missing: true }; fs.mkdirSync(path.dirname(logFile), { recursive: true, mode: 0o700 }); const log = fs.openSync(logFile, 'w', 0o600); try { - const child = spawn(command, viaShell ? args.map(cmdArgument) : args, { - cwd, env, shell: viaShell, detached: true, windowsHide: true, stdio: ['ignore', log, log], + const child = spawn(viaShell ? cmdLine(command, args) : command, viaShell ? [] : args, { + cwd, env, shell: viaShell ? path.join(SYSTEM32, 'cmd.exe') : false, detached: true, windowsHide: true, stdio: ['ignore', log, log], }); const started = spawnedOrMissing(child); child.on('error', () => {}); diff --git a/lib/runtime/process/which.js b/lib/runtime/process/which.js index 067e518..d38bc67 100644 --- a/lib/runtime/process/which.js +++ b/lib/runtime/process/which.js @@ -22,13 +22,14 @@ function realPathOf(file) { */ export function executableOf(declaration, { env = process.env, platform = process.platform, exists = existsSync, realPath = realPathOf } = {}) { const isWindows = platform === 'win32'; + const paths = isWindows ? path.win32 : path.posix; const viaNode = isWindows && Boolean(declaration.nodeScriptOnWindows); - const pathDirs = viaNode ? [] : String(env.PATH ?? '').split(path.delimiter).filter(Boolean); + const pathDirs = viaNode ? [] : String(env.PATH ?? '').split(paths.delimiter).filter(Boolean); const extensions = !isWindows || viaNode ? [''] : ['.exe', ...(declaration.windowsShims ? ['.cmd'] : [])]; for (const name of declaration.names) { for (const dir of [...pathDirs, ...(declaration.dirs ?? [])]) { for (const extension of extensions) { - const file = path.join(dir, `${name}${extension}`); + const file = paths.join(dir, `${name}${extension}`); if (exists(file) && (!declaration.accepts || declaration.accepts(name, realPath(file)))) return { file, viaNode }; } } diff --git a/lib/runtime/process/windows.js b/lib/runtime/process/windows.js new file mode 100644 index 0000000..2e7e066 --- /dev/null +++ b/lib/runtime/process/windows.js @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EvoMap + +import { spawnSync } from 'node:child_process'; +import { win32 } from 'node:path'; + +/** A bounded ancestor snapshot, without command lines or credentials; an unavailable process table stays unknown. */ +export function windowsAncestorsOf(pid, { run = spawnSync, env = process.env } = {}) { + const empty = () => new Map(); + const root = env.SystemRoot || env.WINDIR || ''; + if (!Number.isSafeInteger(pid) || pid <= 0 || !win32.isAbsolute(root)) return empty(); + // One CIM snapshot avoids a new WMI round trip for each shell or ancestor; only the bounded chain leaves PowerShell. + const script = `$ErrorActionPreference='Stop'; [Console]::OutputEncoding=[System.Text.UTF8Encoding]::new($false); $evolverTable=@{}; Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId,Name,ExecutablePath | ForEach-Object {$evolverTable[[int]$_.ProcessId]=$_}; $evolverPid=${pid}; $evolverRows=@(); for($evolverDepth=0;$evolverDepth -lt 16 -and $evolverPid -gt 0;$evolverDepth++){ $p=$evolverTable[$evolverPid]; if($null -eq $p){break}; $evolverRows += [pscustomobject]@{pid=[int]$p.ProcessId;ppid=[int]$p.ParentProcessId;name=[string]$p.Name;path=[string]$p.ExecutablePath}; if($p.ParentProcessId -eq $evolverPid){break}; $evolverPid=[int]$p.ParentProcessId }; ConvertTo-Json -InputObject @($evolverRows) -Compress`; + try { + const result = run(win32.join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], { encoding: 'utf8', timeout: 2500, maxBuffer: 128 * 1024, windowsHide: true }); + if (result.error || result.status !== 0) return empty(); + const rows = JSON.parse(result.stdout); + if (!Array.isArray(rows) || rows.length > 16 || rows.some(row => !Number.isSafeInteger(row.pid) || row.pid <= 0 || !Number.isSafeInteger(row.ppid) || row.ppid < 0 || typeof row.name !== 'string' || typeof row.path !== 'string')) return empty(); + return new Map(rows.map(({ pid: key, ...info }) => [key, info])); + } catch { return empty(); } +} diff --git a/lib/runtime/proxy.js b/lib/runtime/proxy.js index 53489bc..87fe9c5 100644 --- a/lib/runtime/proxy.js +++ b/lib/runtime/proxy.js @@ -18,10 +18,10 @@ function settingsText(env) { } /** `proxyFetch(method, path, body, { timeoutMs, signal })` against the loopback Proxy, answering a ProxyResult; never throws. */ -export function proxyFetchOf(env = process.env) { +export function proxyFetchOf(env = process.env, { closeConnection = false } = {}) { return async (method, path, body, { timeoutMs = PROXY_TIMEOUT_MS, signal } = {}) => { const { url: base, token } = proxySettingsFrom(settingsText(env), env.EVOMAP_PROXY_PORT || DEFAULT_PROXY_PORT); - const headers = { ...(body !== undefined && { 'Content-Type': 'application/json' }), ...(token && { Authorization: `Bearer ${token}` }) }; + const headers = { ...(body !== undefined && { 'Content-Type': 'application/json' }), ...(token && { Authorization: `Bearer ${token}` }), ...(closeConnection && { Connection: 'close' }) }; const timeout = AbortSignal.timeout(timeoutMs); try { const response = await fetch(base + path, { diff --git a/lib/runtime/records.js b/lib/runtime/records.js new file mode 100644 index 0000000..ff50806 --- /dev/null +++ b/lib/runtime/records.js @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EvoMap + +import { lstatSync } from 'node:fs'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); + +/** Reads one declared metadata projection; the host supplies a fixed query, never SQL from a payload. */ +export function recordReaderOf(declaration, env) { + if (!declaration) return undefined; + return (...keys) => { + if (!keys.length || keys.length > 2 || keys.some(key => typeof key !== 'string' || !key || key.length > 256)) return null; + let db; + try { + const file = declaration.file(env); + const stat = lstatSync(file); + if (!stat.isFile() || stat.isSymbolicLink()) return null; + const { DatabaseSync } = require('node:sqlite'); + db = new DatabaseSync(file, { readOnly: true }); + db.exec('PRAGMA query_only = ON; PRAGMA busy_timeout = 250'); + return db.prepare(declaration.query).get(...keys) ?? null; + } catch { return null; } + finally { try { db?.close(); } catch {} } + }; +} diff --git a/lib/runtime/settings.js b/lib/runtime/settings.js new file mode 100644 index 0000000..cf5a5c0 --- /dev/null +++ b/lib/runtime/settings.js @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EvoMap + +import { lstatSync, readFileSync } from 'node:fs'; + +import { withLock, writeState } from './store/file-lock.js'; + +const KEYS = ['recall', 'autoDistill']; +function checked(value) { + if (!value || Array.isArray(value) || typeof value !== 'object' || Object.entries(value).some(([key, flag]) => !KEYS.includes(key) || typeof flag !== 'boolean')) throw new Error('Plugin settings must contain only boolean recall and autoDistill fields.'); + return value; +} + +function readSaved(file) { + try { + const stat = lstatSync(file); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4096) throw new Error('Invalid settings file'); + return checked(JSON.parse(readFileSync(file, 'utf8'))); + } catch (error) { if (error.code === 'ENOENT') return {}; throw error; } +} + +const INVALID = 'Plugin settings are invalid or unreadable; recall and automatic distillation are paused. Repair the settings file before changing preferences.'; + +/** Persistent host preferences survive native hook environments which strip inherited variables. */ +export function settingsOf(env, file) { + const effective = saved => ({ recall: env.EVOLVER_RECALL === undefined ? saved.recall ?? true : env.EVOLVER_RECALL !== 'off', autoDistill: env.EVOLVER_AUTO_DISTILL === undefined ? saved.autoDistill ?? true : env.EVOLVER_AUTO_DISTILL !== 'off' }); + if (!file) return effective({}); + let saved = {}, error; + try { saved = readSaved(file); } catch { error = INVALID; } + const settings = { ...(error ? { recall: false, autoDistill: false } : effective(saved)), file, error }; + settings.configure = async patch => { + if (error) throw new Error(error); + checked(patch); + const written = await withLock(`${file}.lock`, async () => { + let current; + try { current = readSaved(file); } catch { throw new Error(INVALID); } + const next = { ...current, ...patch }; + writeState(file, next); + Object.assign(settings, effective(next)); + }); + if (!written) throw new Error('Another preference update is running; retry the command.'); + }; + return settings; +} diff --git a/lib/runtime/turn-monitor.js b/lib/runtime/turn-monitor.js new file mode 100644 index 0000000..d36007c --- /dev/null +++ b/lib/runtime/turn-monitor.js @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EvoMap + +import { createHash } from 'node:crypto'; +import { join } from 'node:path'; + +import { withLock } from './store/file-lock.js'; + +const POLL_MS = 500; +const MISSING_ROW_MS = 30_000; +const MAX_WATCH_MS = 24 * 60 * 60 * 1000; + +/** Only an authoritative failed/aborted record closes a turn; an absent record never becomes a verdict. */ +export async function monitorTurn(ports, at, close, { dir, now = Date.now, sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) }) { + if (!ports.turnStatus || !at || !['sessionId', 'turnId'].every(key => typeof at[key] === 'string' && at[key] && at[key].length <= 256) || typeof at.projectDir !== 'string') return false; + const key = createHash('sha256').update(JSON.stringify([at.sessionId, at.turnId])).digest('hex'); + return withLock(join(dir, key), async lock => { + const started = now(); + while (now() - started < MAX_WATCH_MS) { + const session = await ports.sessions.read(at.sessionId); + if (!session.open || session.turnId !== at.turnId || ports.owner.isGone(session.pid)) return; + const row = ports.turnStatus(at.sessionId, at.turnId); + if (row?.status === 'failed' || row?.status === 'aborted') { + await close({ ...at, reason: row.status === 'failed' ? 'error' : 'aborted' }); + return; + } + // Successful turns are still judged by Stop and actual tool evidence, never by a database status alone. + if (row?.status === 'completed' || (row?.status !== 'accepted' && now() - started >= MISSING_ROW_MS)) return; + lock.refresh(); + await sleep(POLL_MS); + } + }); +} diff --git a/package-lock.json b/package-lock.json index e268d2f..6d3dfd1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,38 +13,28 @@ "zod": "4.6.7" }, "devDependencies": { - "@evomap/evolver-core": "2.0.38" + "@evomap/evolver-core": "2.0.47" }, "engines": { "node": ">=22.13.0" } }, "node_modules/@evomap/evolver-core": { - "version": "2.0.38", - "resolved": "https://registry.npmjs.org/@evomap/evolver-core/-/evolver-core-2.0.38.tgz", - "integrity": "sha512-/niM1ByHQ1V1XfKpCUMOFDgquYPkN8X8+mP1oZzCSbt6M9pabE2ymx6ZzY23zQw+9CDgB7UN30ewYuhVteaiEA==", + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/@evomap/evolver-core/-/evolver-core-2.0.47.tgz", + "integrity": "sha512-swrGNEPOKP8jjmZoQSTCp/jHILH50FSOOvXBEThy8eQF+EPlZRG7mS1QL4cImV1WbIylFGuqpw9LdAcb8r07Jg==", "dev": true, "dependencies": { "@evomap/gep-sdk": "1.14.0", "ajv": "^8.20.0", "koffi": "3.2.1", "ulid": "^2.3.0", - "zod": "^3.24.1" + "zod": "^4.6.6" }, "engines": { "node": "^22.13.0 || >=23.4.0" } }, - "node_modules/@evomap/evolver-core/node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/@evomap/gep-sdk": { "version": "1.14.0", "resolved": "https://registry.npmjs.org/@evomap/gep-sdk/-/gep-sdk-1.14.0.tgz", @@ -808,9 +798,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", - "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", "funding": [ { "type": "github", diff --git a/package.json b/package.json index 8677431..1477e44 100644 --- a/package.json +++ b/package.json @@ -10,12 +10,13 @@ "license": "MIT", "scripts": { "test": "node --test --test-timeout=120000 \"test/**/*.test.js\"", + "test:minimax": "node --test --test-concurrency=4 --test-timeout=120000 \"test/*.test.js\" \"test/app/*.test.js\" \"test/runtime/*.test.js\" \"test/hosts/minimax/*.test.js\" test/channels/hook.test.js test/channels/script.test.js test/channels/mcp.test.js", "validate": "node scripts/pack.mjs claude-code dist/claude-code && claude plugin validate dist/claude-code", "dev:sync": "bash scripts/sync-dev-mod.sh", "pack": "node scripts/pack.mjs all dist" }, "devDependencies": { - "@evomap/evolver-core": "2.0.38" + "@evomap/evolver-core": "2.0.47" }, "dependencies": { "@modelcontextprotocol/sdk": "1.32.1", diff --git a/scripts/install-minimax.mjs b/scripts/install-minimax.mjs new file mode 100644 index 0000000..5ee56f4 --- /dev/null +++ b/scripts/install-minimax.mjs @@ -0,0 +1,78 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EvoMap + +import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; + +import { dataDirOf } from '../hosts/minimax/paths.js'; +import { pack } from './pack.mjs'; + +const BUNDLE = fileURLToPath(new URL('../dist/minimax', import.meta.url)); +const validVersion = value => typeof value === 'string' && /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.test(value) + && !value.split('-').slice(1).join('-').split('.').some(part => /^0\d+$/.test(part)); + +function ordinaryDirectory(dir) { + if (!existsSync(dir)) return; + const stat = lstatSync(dir); + if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`Refusing a non-directory or linked plugin path: ${dir}`); +} + +/** Replaces only this tool's packed package; native PLUGIN_DATA and account configuration stay outside it. */ +export function installMiniMax({ operation = 'install', dataDir = dataDirOf(process.env), dryRun = false, version } = {}) { + if (!['install', 'update', 'status'].includes(operation)) throw new Error('Expected install, update or status.'); + if (version !== undefined && !validVersion(version)) throw new Error('Version must be X.Y.Z or X.Y.Z-prerelease.'); + const root = resolve(dataDir), plugins = join(root, 'plugins'), target = join(plugins, 'evolver-plugin'); + for (const dir of [root, plugins, target]) ordinaryDirectory(dir); + const installed = existsSync(target); + if (installed) { + const marker = join(target, '.evolver-packed'); + if (!existsSync(marker) || readFileSync(marker, 'utf8').trim() !== 'minimax') throw new Error(`Refusing to replace an unmanaged plugin: ${target}`); + const manifest = JSON.parse(readFileSync(join(target, '.minimax-plugin/plugin.json'), 'utf8')); + if (manifest.name !== 'evolver-plugin') throw new Error('Installed plugin identity does not match.'); + if (operation === 'status') return { installed: true, path: target, version: manifest.version }; + } else { + if (operation === 'status') return { installed: false, path: target }; + if (operation === 'update') throw new Error('MiniMax plugin is not installed; use install first.'); + } + if (dryRun) return { operation, path: target, dryRun: true }; + mkdirSync(root, { recursive: true, mode: 0o700 }); + mkdirSync(plugins, { recursive: true, mode: 0o700 }); + const stage = mkdtempSync(join(root, '.evolver-install-')); + const prepared = join(stage, 'package'), backup = join(stage, 'previous'); + let moved = false, committed = false; + try { + if (version === undefined && existsSync(BUNDLE)) { + ordinaryDirectory(BUNDLE); + const manifest = JSON.parse(readFileSync(join(BUNDLE, '.minimax-plugin/plugin.json'), 'utf8')); + if (readFileSync(join(BUNDLE, '.evolver-packed'), 'utf8').trim() !== 'minimax' || manifest.name !== 'evolver-plugin' || !validVersion(manifest.version)) throw new Error('The bundled MiniMax package has an invalid identity or version; rebuild it before installing.'); + cpSync(BUNDLE, prepared, { recursive: true }); + } else pack('minimax', prepared, { version }); + if (installed) { renameSync(target, backup); moved = true; } + try { renameSync(prepared, target); } + catch (error) { + if (moved) { + try { renameSync(backup, target); moved = false; } + catch { throw new Error(`Activation and rollback failed. The previous package is retained at ${backup}.`); } + } + throw error; + } + committed = true; + return { installed: true, operation, path: target, next: 'mcode plugin enable evolver-plugin@local --json; start a new task to load the package.' }; + } finally { + // Keep the backup if a filesystem failure also prevented rollback. + if (!moved || committed) rmSync(stage, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } +} + +if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + const { values, positionals } = parseArgs({ options: { 'data-dir': { type: 'string' }, 'dry-run': { type: 'boolean' }, version: { type: 'string' }, help: { type: 'boolean' } }, allowPositionals: true }); + const usage = 'Usage: node scripts/install-minimax.mjs [install|update|status] [--data-dir PATH] [--dry-run] [--version X.Y.Z]'; + if (positionals.length > 1) throw new Error(usage); + if (values.help) console.log(usage); + else console.log(JSON.stringify(installMiniMax({ operation: positionals[0], dataDir: values['data-dir'], dryRun: values['dry-run'], version: values.version }), null, 2)); + } catch (error) { console.error(error.message); process.exitCode = 1; } +} diff --git a/test/app/distill-session.test.js b/test/app/distill-session.test.js index 1828cdc..af3ebfc 100644 --- a/test/app/distill-session.test.js +++ b/test/app/distill-session.test.js @@ -111,6 +111,30 @@ test('a process shutting down stops a leftover\'s model call without counting a assert.deepEqual([s1.attempts, s1.claimedBy ?? null], [0, null]); }); +test('a transient candidate watch read failure is retried without crashing the model run', async t => { + const { ports, requests } = await withCandidate(); + await sessionEnded(ports, { sessionId: 's1', projectDir: '/proj' }); + const reply = Promise.withResolvers(), started = Promise.withResolvers(); + ports.model = { complete: () => { started.resolve(); return reply.promise; } }; + const setInterval = globalThis.setInterval, read = ports.candidates.read; + let check; + t.mock.method(globalThis, 'setInterval', callback => { check = callback; return setInterval(() => {}, 60_000); }); + const running = distillLeftovers(ports); + t.after(async () => { + ports.candidates.read = read; + reply.resolve({ answer: JSON.stringify(GENE) }); + await running; + }); + await started.promise; + ports.candidates.read = async () => { throw Object.assign(new Error('fixture read denial'), { code: 'EBUSY' }); }; + await assert.doesNotReject(check()); + assert.equal(requests.some(request => request.path === '/conversation/distill'), false); + ports.candidates.read = read; + reply.resolve({ answer: JSON.stringify(GENE) }); + assert.equal((await running)[0].status, 'drafted'); + assert.equal(requests.filter(request => request.path === '/conversation/distill').length, 1); +}); + test('the model may decline, which settles the candidate without calling the Proxy', async () => { const { ports, requests } = await withCandidate({ model: modelAnswering({ skip: 'routine edit' }) }); await sessionEnded(ports, { sessionId: 's1', projectDir: '/proj' }); diff --git a/test/app/recall-deadline.test.js b/test/app/recall-deadline.test.js new file mode 100644 index 0000000..d8fb52b --- /dev/null +++ b/test/app/recall-deadline.test.js @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { promptSubmitted } from '../../lib/app/prompt.js'; +import { PROMPT, RECIPE, memoryPorts } from './memory-ports.js'; + +function harness() { + const { ports } = memoryPorts(); + const timers = [], requests = []; + ports.clock.sleep = (ms, signal) => new Promise((resolve) => { + timers.push({ ms, signal, resolve }); + signal.addEventListener('abort', resolve, { once: true }); + }); + const request = ports.proxy.request; + ports.proxy.request = async (method, path, body, { signal }) => { + requests.push({ path, signal }); + return request(method, path, body); + }; + return { ports, timers, requests }; +} + +test('finished recall cancels its timers without aborting completed HTTP requests', async () => { + const { ports, timers, requests } = harness(); + const { injection } = await promptSubmitted(ports, { sessionId: 's', prompt: PROMPT, waitMs: 5_000 }); + assert.match(injection, /Evolution Memory/); + assert.equal(timers.length, 2); + assert.ok(timers.every(({ signal }) => signal.aborted)); + assert.equal(requests.length, 2); + assert.ok(requests.every(({ signal }) => !signal.aborted)); +}); + +test('a recall that misses the hook deadline aborts only its outstanding work', async () => { + const { ports, timers } = harness(); + const requests = []; + ports.proxy.request = (_method, path, _body, { signal }) => new Promise((resolve) => { + requests.push(signal); + signal.addEventListener('abort', () => resolve(path === '/recipe/search' ? { ok: true, data: [RECIPE] } : { ok: false }), { once: true }); + }); + const opened = promptSubmitted(ports, { sessionId: 's', prompt: PROMPT, waitMs: 5_000 }); + while (timers.length < 2) await new Promise(setImmediate); + timers.find(({ ms }) => ms === 5_000).resolve(); + assert.equal((await opened).injection, ''); + await new Promise(setImmediate); + assert.equal(requests.length, 2); + assert.ok(requests.every((signal) => signal.aborted)); + assert.ok(timers.every(({ signal }) => signal.aborted)); +}); + +test('late recall keeps its requests alive until the strategy lands, then cancels its timer', async () => { + const { ports, timers, requests } = harness(); + const request = ports.proxy.request; + const ready = Promise.withResolvers(); + ports.proxy.request = async (...args) => { await ready.promise; return request(...args); }; + const { injection, late } = await promptSubmitted(ports, { sessionId: 's', prompt: PROMPT, waitMs: 0, keepLate: true }); + assert.equal(injection, ''); + assert.ok(timers.every(({ signal }) => !signal.aborted)); + ready.resolve(); + assert.match((await late).text, /Evolution Memory/); + assert.ok(requests.every(({ signal }) => !signal.aborted)); + assert.ok(timers.every(({ signal }) => signal.aborted)); +}); diff --git a/test/app/stuck-recall.test.js b/test/app/stuck-recall.test.js index 2441cdf..f730c68 100644 --- a/test/app/stuck-recall.test.js +++ b/test/app/stuck-recall.test.js @@ -5,7 +5,8 @@ import { promptSubmitted } from '../../lib/app/prompt.js'; import { recallWhenStuck } from '../../lib/app/stuck-recall.js'; import { toolFinished } from '../../lib/app/tool-result.js'; import { turnEnded } from '../../lib/app/turn-end.js'; -import { ASSET, PROMPT, memoryPorts } from './memory-ports.js'; +import { recipeSearchAnswer } from '../helpers/recipe-search.js'; +import { ASSET, PROMPT, RECIPE, memoryPorts } from './memory-ports.js'; const EDIT = { kind: 'edit', paths: ['/proj/lib/redis.js'], lines: 1, identifiers: ['maxRetriesPerRequest'] }; const failedCheck = (output) => ({ kind: 'shell', command: 'npm test', exit: 1, output }); @@ -63,3 +64,67 @@ test('a passing check, a non-check, an error-free failure, a command turn or rec assert.equal(stuckFetches(requests).length, 0, JSON.stringify(note)); } }); + +async function deadlineTurn() { + const { ports } = memoryPorts(); + await stuckTurn(ports); + const timers = []; + ports.clock.sleep = (ms, signal) => new Promise(resolve => { + timers.push({ ms, signal, resolve }); + signal?.addEventListener('abort', resolve, { once: true }); + }); + return { ports, timers }; +} + +test('a failed-check recall aborts all outstanding requests when its deadline expires', async () => { + const { ports, timers } = await deadlineTurn(); + const requests = []; + ports.proxy.request = (_method, path, _body, { signal } = {}) => new Promise(resolve => { + requests.push({ path, signal }); + signal?.addEventListener('abort', () => resolve({ ok: false }), { once: true }); + }); + const running = stuck(ports, FAILED); + while (timers.length === 0) await new Promise(setImmediate); + timers[0].resolve(); + assert.equal(await running, ''); + assert.equal(requests.length, 2); + assert.ok(requests.every(({ signal }) => signal?.aborted)); + assert.ok(timers[0].signal?.aborted); + assert.deepEqual((await ports.sessions.read('s1')).injected, {}); +}); + +test('a Recipe arriving after the failed-check deadline starts no step fetch and leaves completed requests alone', async () => { + const { ports, timers } = await deadlineTurn(); + const requests = []; + ports.proxy.request = async (_method, path, body, { signal } = {}) => { + requests.push({ path, body, signal }); + if (path !== '/recipe/search') return { ok: true, data: { assets: [] } }; + return new Promise(resolve => signal?.addEventListener('abort', () => { + resolve({ ok: true, data: recipeSearchAnswer([RECIPE], body) }); + }, { once: true })); + }; + const running = stuck(ports, FAILED); + while (timers.length === 0) await new Promise(setImmediate); + timers[0].resolve(); + assert.equal(await running, ''); + await new Promise(setImmediate); + assert.equal(requests.length, 2, 'no Recipe step request is issued after the deadline'); + assert.ok(requests.find(({ path }) => path === '/recipe/search').signal?.aborted); + assert.equal(requests.find(({ path }) => path === '/asset/fetch').signal?.aborted, false); + assert.deepEqual((await ports.sessions.read('s1')).injected, {}); +}); + +test('a completed failed-check recall cancels its deadline timer and books the strategy', async () => { + const { ports, timers } = await deadlineTurn(); + const request = ports.proxy.request; + const requests = []; + ports.proxy.request = async (method, path, body, { signal } = {}) => { + requests.push(signal); + return request(method, path, body); + }; + assert.match(await stuck(ports, FAILED), /Evolution Memory/); + assert.ok(timers[0].signal?.aborted); + assert.equal(requests.length, 2); + assert.ok(requests.every(signal => signal && !signal.aborted)); + assert.ok((await ports.sessions.read('s1')).injected[ASSET.asset_id]); +}); diff --git a/test/architecture.test.js b/test/architecture.test.js index 46fa33d..0155e9c 100644 --- a/test/architecture.test.js +++ b/test/architecture.test.js @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { existsSync, lstatSync, readdirSync, readFileSync } from 'node:fs'; -import { join, relative } from 'node:path'; +import { join, relative, sep } from 'node:path'; import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; @@ -13,7 +13,7 @@ const SOURCE = /\.m?js$/; function filesUnder(dir) { if (!existsSync(join(root, dir))) return []; return readdirSync(join(root, dir)).flatMap((name) => { - const path = join(dir, name); + const path = `${dir}/${name}`; const stat = lstatSync(join(root, path)); if (stat.isDirectory()) return filesUnder(path); return stat.isFile() && SOURCE.test(path) ? [path] : []; @@ -23,7 +23,7 @@ function filesUnder(dir) { const sourceOf = (file) => readFileSync(join(root, file), 'utf8'); const importsOf = (file) => importsIn(sourceOf(file)).map(({ specifier }) => specifier); const hostOf = (path) => /^hosts\/([^/]+)\//.exec(path)?.[1] ?? null; -const targetOf = (file, specifier) => (specifier.startsWith('.') ? relative(root, join(root, file, '..', specifier)) : specifier); +const targetOf = (file, specifier) => (specifier.startsWith('.') ? relative(root, join(root, file, '..', specifier)).split(sep).join('/') : specifier); // A host's `dist/` and `bin/` are entries: the glue a host loads, which reaches the plugin only through a channel. const ENTRY = /^(?:bin\/|hosts\/[^/]+\/dist\/)/; diff --git a/test/channels/mcp.test.js b/test/channels/mcp.test.js index 9253eb0..a45212c 100644 --- a/test/channels/mcp.test.js +++ b/test/channels/mcp.test.js @@ -9,7 +9,7 @@ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' import { PROMPT } from '../app/memory-ports.js'; import { installFakeEvolver } from '../helpers/fake-evolver.js'; -import { fakeProxy } from '../helpers/fake-proxy.js'; +import { closeProxyWhenSettled, fakeProxy } from '../helpers/fake-proxy.js'; const SERVER = fileURLToPath(new URL('../../bin/mcp.mjs', import.meta.url)); const GENE = { title: 'Stabilise Redis retries', summary: 'Flaky Redis-backed tests stop failing once retries are unbounded and the client is shared.', strategy: ['Set maxRetriesPerRequest to null', 'Share one client', 'Close it in teardown'] }; @@ -22,7 +22,7 @@ before(async () => { capsule: { type: 'Capsule', asset_id: 'sha256:' + 'c'.repeat(64), summary: GENE.summary }, } }); }); -after(() => proxy.close()); +after(() => closeProxyWhenSettled(proxy)); async function connected(t, env = {}) { const client = new Client({ name: 'evolver-test-client', version: '1.0.0' }); @@ -104,10 +104,10 @@ test('evolver_login answers the link and code, and tells the agent to show them const fakeDir = join(isolated.env.HOME, 'fake-evolver'); const fake = installFakeEvolver(join(isolated.env.HOME, '.evolver', 'state', 'mcp-generic-engine'), fakeDir); const client = await connected(t, { ...isolated.env, ...fake.env }); - t.after(() => { + t.after(async () => { const starts = readdirSync(fakeDir).includes('starts') ? readFileSync(join(fakeDir, 'starts'), 'utf8').trim().split('\n') : []; for (const line of starts) try { process.kill(Number(line.split(' ')[0]), 'SIGKILL'); } catch {} - isolated.close(); + await closeProxyWhenSettled(isolated); }); const { tools } = await client.listTools(); assert.match(tools.find((tool) => tool.name === 'evolver_login').description, /Show the link and code to the person verbatim; never open or approve it yourself\.$/); diff --git a/test/channels/script.test.js b/test/channels/script.test.js index f176e80..6539e49 100644 --- a/test/channels/script.test.js +++ b/test/channels/script.test.js @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { join } from 'node:path'; import { Readable } from 'node:stream'; import { after, before, test } from 'node:test'; @@ -16,7 +17,7 @@ const profile = defineProfile({ commands: { entry: 'inline-script', sessionOf: (env) => env.TEST_SESSION_ID ?? null, - scriptAnswer: (result, script) => `${result.text}\n[${script.session_id} script ${script.script.endsWith('bin/command.mjs')} nonce ${script.nonce.length}]`, + scriptAnswer: (result, script) => `${result.text}\n[${script.session_id} script ${script.script.endsWith(join('bin', 'command.mjs'))} nonce ${script.nonce.length}]`, }, }); diff --git a/test/fixtures/minimax/fake-mcode.mjs b/test/fixtures/minimax/fake-mcode.mjs new file mode 100644 index 0000000..c00811e --- /dev/null +++ b/test/fixtures/minimax/fake-mcode.mjs @@ -0,0 +1,12 @@ +import { appendFileSync, existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const args = process.argv.slice(2); +const config = args[args.indexOf('--config') + 1]; +if (!config || !existsSync(join(process.cwd(), '.evolver-headless')) || !readFileSync(config, 'utf8').includes('"tools":[]')) process.exit(3); +appendFileSync(process.env.FAKE_MINIMAX_LOG, JSON.stringify({ args, cwd: process.cwd(), marker: process.env.EVOLVER_HEADLESS, privateConfig: config }) + '\n'); +let prompt = ''; +for await (const chunk of process.stdin) prompt += chunk; +if (!prompt.includes('strategy')) process.exit(4); +const gene = { title: 'Repair retry checks', summary: 'A queue client passes its retry check after its fixed retry limit is removed.', strategy: ['Run the failing check.', 'Remove the retry limit.', 'Run the same check again.'] }; +console.log(JSON.stringify({ schemaVersion: 1, type: 'exec.result', status: 'succeeded', output: JSON.stringify(gene), usage: { inputTokens: 40, outputTokens: 20 } })); diff --git a/test/helpers/fake-proxy.js b/test/helpers/fake-proxy.js index fce36a9..09ef2a3 100644 --- a/test/helpers/fake-proxy.js +++ b/test/helpers/fake-proxy.js @@ -47,3 +47,14 @@ export async function fakeProxy({ assets = [ASSET], recipes = [], stepAssets = [ }, }; } + +/** A detached hook worker can still finish a state write after the Proxy received its last request. */ +export async function closeProxyWhenSettled(proxy) { + for (let attempt = 0; ; attempt++) { + try { return proxy.close(); } + catch (error) { + if (attempt === 19 || !['ENOTEMPTY', 'EPERM', 'EBUSY'].includes(error.code)) throw error; + await new Promise(resolve => setTimeout(resolve, 100)); + } + } +} diff --git a/test/hosts/antigravity/hooks.test.js b/test/hosts/antigravity/hooks.test.js index 707f28f..4c83234 100644 --- a/test/hosts/antigravity/hooks.test.js +++ b/test/hosts/antigravity/hooks.test.js @@ -11,7 +11,7 @@ import { EDIT_TOOLS } from '../../../hosts/antigravity/evidence.js'; import profile from '../../../hosts/antigravity/profile.js'; import { pack } from '../../../scripts/pack.mjs'; import { ASSET, PROMPT } from '../../app/memory-ports.js'; -import { fakeProxy } from '../../helpers/fake-proxy.js'; +import { closeProxyWhenSettled, fakeProxy } from '../../helpers/fake-proxy.js'; // The hooks as Antigravity runs them: the registration's own shell commands, through `sh -c`, from a stand-in // language server, against the plugin installed (as a link) in the folder Antigravity installs to. @@ -149,7 +149,7 @@ test('a hook command runs only a copy in an install folder, never one in its wor test('a whole turn through the installed hooks: the strategy before each call, allow on edits, the report at Stop', async (t) => { const proxy = await fakeProxy(); - t.after(() => proxy.close()); + t.after(() => closeProxyWhenSettled(proxy)); install(proxy.env.HOME, '.gemini/config/plugins'); const host = antigravity(t, proxy.env); const s = conversation(host, proxy.env.HOME, 's-e2e'); @@ -187,7 +187,7 @@ test('a killed session\'s open strategy is reported aborted at the first model c // Receiving the report precedes the worker's outbox write and lock release. await waitFor(() => !existsSync(stateFile(proxy.env.HOME, 'antigravity-outbox.lock'))); } finally { - proxy.close(); + await closeProxyWhenSettled(proxy); } }); install(proxy.env.HOME, '.gemini/config/plugins'); @@ -207,7 +207,7 @@ test('a killed session\'s open strategy is reported aborted at the first model c test('the candidate of a session whose Antigravity process is gone is distilled by agy run headless, in an empty folder', async (t) => { const proxy = await fakeProxy({ assets: [] }); - t.after(() => proxy.close()); + t.after(() => closeProxyWhenSettled(proxy)); const home = proxy.env.HOME; install(home, '.gemini/config/plugins'); mkdirSync(join(home, 'bin'), { recursive: true }); diff --git a/test/hosts/minimax/commands.test.js b/test/hosts/minimax/commands.test.js new file mode 100644 index 0000000..86320ea --- /dev/null +++ b/test/hosts/minimax/commands.test.js @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import profile from '../../../hosts/minimax/profile.js'; +import { createDriver } from '../../../lib/agent/driver.js'; +import { memoryPorts } from '../../app/memory-ports.js'; + +const parse = prompt => profile.commands.parse({ session_id: 's1' }, 'UserPromptSubmit', { prompt, env: {} }); +const payload = { session_id: 's1', turn_id: 't1', cwd: '/project' }; + +test('background command arguments remain data, including shell-shaped text and apostrophes', () => { + assert.deepEqual(parse("evolver-search it's a retry; $(whoami)"), { name: 'evolver-search', args: "it's a retry; $(whoami)", background: true }); +}); + +test('native commands accept exact names with or without a slash and leave ordinary prompts alone', () => { + for (const command of ['evolver-status', '/evolver-status']) assert.deepEqual(parse(command), { name: 'evolver-status', args: '' }); + assert.deepEqual(parse('/evolver-config recall off'), { name: 'evolver-config', args: 'recall off' }); + assert.deepEqual(parse('evolver-config auto-distill on'), { name: 'evolver-config', args: 'auto-distill on' }); + for (const text of ['please run evolver-status', 'evolver-status explain this error', 'evolver-unknown']) assert.equal(parse(text), null); + assert.equal(parse('evolver-run --dry-run').background, true); + assert.equal(parse('evolver-distill retries').background, true); + assert.deepEqual(parse('evolver-jobs job a'), { name: 'evolver-jobs', args: 'job a' }); +}); + +test('status configures settings without a model turn; slow commands are admitted with their exact session and project', async () => { + const made = memoryPorts(); + made.ports.settings = { recall: true, autoDistill: true, configure: patch => Object.assign(made.ports.settings, patch) }; + made.ports.cli.run = () => { throw new Error('a long CLI must never run in a native hook'); }; + let admitted; + made.ports.commandJobs = { enqueue: at => { admitted = at; return { ok: true, text: 'Job queued.' }; } }; + const driver = createDriver({ profile, ports: made.ports, transcripts: { size: () => 0, readSince: () => '' }, lifetime: 'resident', env: {} }); + const answer = await driver.handle('UserPromptSubmit', { ...payload, prompt: '/evolver-config recall off' }); + assert.equal(answer.decision, 'block'); + assert.match(answer.systemMessage, /Recall: off/); + assert.equal(made.ports.settings.recall, false); + assert.equal(made.sessions.get('s1'), undefined); + const long = await driver.handle('UserPromptSubmit', { ...payload, prompt: 'evolver-run --dry-run' }); + assert.equal(long.decision, 'block'); + assert.match(long.reason, /Job queued/); + assert.deepEqual(admitted, { sessionId: 's1', projectDir: '/project', name: 'evolver-run', args: '--dry-run', background: true }); + assert.equal(made.sessions.get('s1'), undefined); +}); + +test('the evolution checkpoint reaches the agent through context, while status remains a user message', async () => { + const made = memoryPorts(); + const driver = createDriver({ profile, ports: made.ports, transcripts: { size: () => 0, readSince: () => '' }, lifetime: 'resident', env: {} }); + const checkpoint = await driver.handle('UserPromptSubmit', { ...payload, prompt: '/evolver-evolve retries' }); + assert.match(checkpoint.hookSpecificOutput.additionalContext, /Take a deliberate evolution step/); + assert.match(checkpoint.hookSpecificOutput.additionalContext, /Focus on: retries/); + assert.equal(checkpoint.decision, undefined); + const status = await driver.handle('UserPromptSubmit', { ...payload, prompt: 'evolver-status' }); + assert.equal(status.decision, 'block'); + assert.equal(status.hookSpecificOutput, undefined); +}); diff --git a/test/hosts/minimax/dist.test.js b/test/hosts/minimax/dist.test.js new file mode 100644 index 0000000..3311b6b --- /dev/null +++ b/test/hosts/minimax/dist.test.js @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import { existsSync, lstatSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import profile from '../../../hosts/minimax/profile.js'; +import { pack } from '../../../scripts/pack.mjs'; + +const root = fileURLToPath(new URL('../../../', import.meta.url)); +const checkoutManifest = () => JSON.parse(readFileSync(join(root, '.minimax-plugin/plugin.json'), 'utf8')); + +test('the checkout manifest resolves its icon and native hooks from the repository root', () => { + const manifest = checkoutManifest(); + assert.ok(existsSync(join(root, manifest.icon))); + assert.deepEqual(manifest.hooks, ['hosts/minimax/dist/hooks/hooks.json']); + const { hooks } = JSON.parse(readFileSync(join(root, manifest.hooks[0]), 'utf8')); + assert.deepEqual(Object.keys(hooks), Object.keys(profile.events)); + assert.ok(existsSync(join(root, 'bin/hook.mjs'))); +}); + +test('the native packed manifest loads only this host and shell hooks fit MiniMax’s actual ten-second limit', () => { + const dir = mkdtempSync(join(tmpdir(), 'evolver-minimax-pack-')); + try { + pack('minimax', dir, { version: '1.2.3' }); + const manifest = JSON.parse(readFileSync(join(dir, '.minimax-plugin/plugin.json'), 'utf8')); + assert.equal(manifest.name, 'evolver-plugin'); + assert.equal(manifest.version, '1.2.3'); + assert.deepEqual(manifest.hooks, ['hooks/hooks.json']); + assert.deepEqual(manifest, { ...checkoutManifest(), version: '1.2.3', hooks: ['hooks/hooks.json'] }); + assert.ok(existsSync(join(dir, manifest.icon))); + const { hooks } = JSON.parse(readFileSync(join(dir, manifest.hooks[0]), 'utf8')); + assert.deepEqual(Object.keys(hooks), Object.keys(profile.events)); + for (const [event, entries] of Object.entries(hooks)) { + const [handler] = entries[0].hooks; + assert.equal(handler.args, undefined, 'native MINIMAX rejects exec-form args'); + assert.equal(handler.timeout, 10); + assert.ok(handler.command.endsWith(`minimax ${event}`)); + assert.ok(handler.commandWindows.endsWith(`minimax ${event}`)); + } + assert.equal(hooks.UserPromptSubmit[0].hooks[0].timeout * 1000, profile.budgets.prompt); + for (const file of ['bin/hook.mjs', 'bin/worker.mjs', 'bin/command.mjs', 'hosts/minimax/profile.js', 'lib/runtime/process/windows.js']) assert.ok(existsSync(join(dir, file)), file); + for (const file of ['.claude-plugin', '.mcp.json', 'hosts/workbuddy', 'skills', 'node_modules', 'test', 'acceptance', '.git', 'config.yaml']) assert.equal(existsSync(join(dir, file)), false, file); + assert.deepEqual(JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')), { private: true, type: 'module' }); + const inspect = folder => { + for (const name of readdirSync(folder)) { + const file = join(folder, name), stat = lstatSync(file); + assert.equal(stat.isSymbolicLink(), false, file); + if (stat.isDirectory()) inspect(file); + else if (/\.(?:m?js|json|md)$/.test(name)) assert.equal(readFileSync(file, 'utf8').includes(root), false, `${file} embeds its build machine path`); + } + }; + inspect(dir); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); diff --git a/test/hosts/minimax/hooks.test.js b/test/hosts/minimax/hooks.test.js new file mode 100644 index 0000000..7547bd1 --- /dev/null +++ b/test/hosts/minimax/hooks.test.js @@ -0,0 +1,134 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { DatabaseSync } from 'node:sqlite'; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { delimiter, dirname, join } from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { pack } from '../../../scripts/pack.mjs'; +import { PROMPT } from '../../app/memory-ports.js'; +import { closeProxyWhenSettled, fakeProxy } from '../../helpers/fake-proxy.js'; + +const fakeCli = fileURLToPath(new URL('../../fixtures/minimax/fake-mcode.mjs', import.meta.url)); +const DRAFT = { ok: true, status: 'draft', distill_id: 'native-hook-draft', quality: { ok: true, score: 6 }, gene: { type: 'Gene', asset_id: `sha256:${'1'.repeat(64)}` }, capsule: { type: 'Capsule', asset_id: `sha256:${'2'.repeat(64)}` } }; + +async function run(command, args, { env, cwd, input = '', shell = false }) { + const child = spawn(command, args, { env, cwd, shell, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }); + let stdout = '', stderr = ''; + child.stdout.on('data', chunk => { stdout += chunk; }); child.stderr.on('data', chunk => { stderr += chunk; }); + child.stdin.on('error', () => {}); child.stdin.end(input); + return new Promise((resolve, reject) => { child.once('error', reject); child.once('close', code => resolve({ code, stdout, stderr })); }); +} + +async function until(check) { + const deadline = Date.now() + 20000; + for (;;) { const result = check(); if (result) return result; if (Date.now() >= deadline) throw new Error('MiniMax hook work did not settle'); await new Promise(resolve => setTimeout(resolve, 50)); } +} + +async function fixture(t) { + const proxy = await fakeProxy({ distillAnswer: DRAFT }); + const plugin = mkdtempSync(join(tmpdir(), "evolver MiniMax user's gói ")); pack('minimax', plugin); + const data = join(proxy.env.HOME, 'native'), project = join(proxy.env.HOME, "workspace user's tiếng Việt"), bin = join(proxy.env.HOME, "runtime user's & bin"); + const state = join(data, 'v2/plugin-data/hooks/evolver-plugin'); + for (const dir of [join(data, 'v2/sqlite'), project, bin]) mkdirSync(dir, { recursive: true }); + const db = new DatabaseSync(join(data, 'v2/sqlite/runtime-state.sqlite')); + db.exec('CREATE TABLE local_runtime_sessions (session_id TEXT PRIMARY KEY, extra_data_json TEXT); CREATE TABLE local_runtime_session_agent_definitions (session_id TEXT PRIMARY KEY, definition_json TEXT); CREATE TABLE local_runtime_turn_ingress (session_id TEXT, turn_id TEXT, status TEXT)'); + db.prepare('INSERT INTO local_runtime_sessions VALUES (?, ?)').run('s1', JSON.stringify({ effectiveModel: 'custom_provider:first/model-a' })); + writeFileSync(join(data, 'config.yaml'), 'defaultModel: custom_provider:second/model-b\n'); + const originalConfig = readFileSync(join(data, 'config.yaml'), 'utf8'); + const log = join(proxy.env.HOME, 'mcode.jsonl'); + writeFileSync(join(bin, 'mcode.cmd'), `@echo off\r\n"%FAKE_MINIMAX_NODE%" "${fakeCli}" %*\r\n`); + writeFileSync(join(bin, 'mcode'), `#!/usr/bin/env node\nimport(${JSON.stringify(new URL('../../fixtures/minimax/fake-mcode.mjs', import.meta.url).href)});\n`); chmodSync(join(bin, 'mcode'), 0o755); + const system = Object.fromEntries(['SystemRoot', 'WINDIR', 'COMSPEC', 'PATHEXT', 'TEMP', 'TMP'].filter(key => proxy.env[key]).map(key => [key, proxy.env[key]])); + const env = { ...system, HOME: proxy.env.HOME, USERPROFILE: proxy.env.HOME, PLUGIN_ROOT: plugin, PLUGIN_DATA: state, MINIMAX_DATA_DIR: data, MAVIS_DATA_DIR: data, FAKE_MINIMAX_NODE: process.execPath, FAKE_MINIMAX_LOG: log, PATH: [bin, dirname(process.execPath)].join(delimiter) }; + delete env.EVOLVER_RECALL; delete env.EVOLVER_AUTO_DISTILL; + const hooks = JSON.parse(readFileSync(join(plugin, 'hooks/hooks.json'), 'utf8')).hooks; + const base = { session_id: 's1', turn_id: 't1', cwd: project, model: 'model-a' }; + const hook = async (event, extra = {}) => { + const handler = hooks[event][0].hooks[0]; + const windows = process.platform === 'win32'; + const started = Date.now(); + const result = await run(windows ? handler.commandWindows : '/bin/sh', windows ? [] : ['-c', handler.command], { env, cwd: project, shell: windows, input: JSON.stringify({ ...base, hook_event_name: event, ...extra }) }); + assert.equal(result.code, 0, result.stderr); + assert.ok(Date.now() - started < handler.timeout * 1000, `${event} exceeded its native hook budget`); + return JSON.parse(result.stdout); + }; + const read = (name, fallback) => existsSync(join(state, name)) ? JSON.parse(readFileSync(join(state, name), 'utf8')) : fallback; + const calls = () => existsSync(log) ? readFileSync(log, 'utf8').trim().split('\n').map(JSON.parse) : []; + const verified = async () => { + await hook('UserPromptSubmit', { prompt: PROMPT }); + const file = join(project, 'fixture.txt'); writeFileSync(file, 'before'); + const edit = { tool_name: 'edit', tool_input: { file_path: file, old_string: 'before', new_string: 'after' } }; + await hook('PreToolUse', edit); writeFileSync(file, 'after'); await hook('PostToolUse', { ...edit, tool_response: { details: { diff: '-before\n+after' } } }); + for (const exit of [1, 0]) { + const actual = await run(process.execPath, ['-e', `process.exit(${exit})`], { env, cwd: project }); + await hook('PostToolUse', { tool_name: 'bash', tool_input: { command: 'node --test' }, tool_response: { details: { execution: { reason: 'exited', exitCode: actual.code }, processOutput: { stdout: exit ? '# fail 1' : '# pass 1\n# fail 0', stderr: '', interrupted: false } } } }); + } + await hook('Stop', { last_assistant_message: 'The retry check now passes after the repair.' }); + }; + t.after(async () => { + await until(() => !existsSync(join(state, 'minimax-outbox.lock'))); + db.close(); rmSync(plugin, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + await closeProxyWhenSettled(proxy); + }); + return { proxy, plugin, data, state, env, project, db, hook, read, calls, verified, originalConfig }; +} + +test('packed shell hooks persist settings across processes, expose status in chat and keep manual distill available', async t => { + const f = await fixture(t); + assert.equal((await f.hook('UserPromptSubmit', { prompt: 'evolver-config recall off' })).decision, 'block'); + const recalls = f.proxy.requests.filter(x => x.path === '/asset/fetch').length; + assert.equal((await f.hook('UserPromptSubmit', { prompt: PROMPT })).hookSpecificOutput, undefined); + assert.equal(f.proxy.requests.filter(x => x.path === '/asset/fetch').length, recalls); + await f.hook('UserPromptSubmit', { prompt: 'evolver-config auto-distill off' }); + await f.verified(); await f.hook('SessionEnd'); + assert.equal(f.calls().length, 0); + assert.equal(f.read('minimax-settings.json').autoDistill, false); + const manual = await run(process.execPath, [join(f.plugin, 'bin/command.mjs'), 'minimax', 'evolver-distill'], { env: { ...f.env, EVOLVER_SESSION_ID: 's1' }, cwd: f.project }); + assert.equal(manual.code, 0, manual.stderr); + assert.match(manual.stdout, /into a draft/); + assert.equal(f.read('minimax-distill-drafts.json', []).length, 1); + assert.equal(readFileSync(join(f.data, 'config.yaml'), 'utf8'), f.originalConfig); + assert.equal(f.proxy.requests.some(x => x.path === '/asset/submit'), false); +}); + +test('automatic distill uses the captured session route after another model becomes current and settles only once', async t => { + const f = await fixture(t); await f.verified(); + assert.deepEqual(f.read('minimax-distill-pending.json').s1.model, { model: 'custom_provider:first/model-a' }); + f.db.prepare('UPDATE local_runtime_sessions SET extra_data_json = ?').run(JSON.stringify({ effectiveModel: 'custom_provider:second/model-b' })); + await f.hook('SessionEnd'); + await until(() => f.read('minimax-distill-drafts.json', []).length === 1 && !f.read('minimax-distill-pending.json', {}).s1); + const [call] = f.calls(); + assert.equal(call.args[call.args.indexOf('--model') + 1], 'custom_provider:first/model-a'); + assert.equal(call.marker, '1'); assert.equal(existsSync(call.cwd), false); + await f.hook('SessionStart'); + assert.equal(f.calls().length, 1); + assert.equal(f.proxy.requests.filter(x => x.path === '/conversation/distill').length, 1); + assert.equal(f.proxy.requests.some(x => x.path === '/asset/submit'), false); +}); + +test('a chat distill runs in a detached worker, reports its result in chat, and leaves publication explicit', async t => { + const f = await fixture(t); await f.verified(); + const admission = await f.hook('UserPromptSubmit', { prompt: 'evolver-distill retry handling' }); + assert.equal(admission.decision, 'block'); + const id = /Job ([0-9a-f-]+):/.exec(admission.reason)?.[1]; assert.ok(id); + const job = await until(() => { const value = f.read('minimax-commands/commands.json', []).find(item => item.id === id); return value?.state === 'completed' ? value : null; }); + assert.equal(job.sessionId, 's1'); assert.equal(job.projectDir, f.project); + const result = await f.hook('UserPromptSubmit', { prompt: `evolver-jobs job ${id}` }); + assert.equal(result.decision, 'block'); assert.match(result.reason, /into a draft/); + assert.equal(f.read('minimax-distill-drafts.json', []).length, 1); + assert.equal(f.calls().length, 1); + assert.equal(f.proxy.requests.some(item => item.path === '/asset/submit'), false); +}); + +test('native cancellation closes the matching turn through its durable status, without Stop or another prompt', async t => { + const f = await fixture(t); + f.db.prepare('INSERT INTO local_runtime_turn_ingress VALUES (?, ?, ?)').run('s1', 't1', 'accepted'); + await f.hook('UserPromptSubmit', { prompt: PROMPT }); + f.db.prepare('UPDATE local_runtime_turn_ingress SET status = ?').run('aborted'); + await until(() => f.proxy.requests.some(item => item.path === '/asset/reuse-result' && item.body.outcome === 'failed' && item.body.evidence.aborted)); + assert.deepEqual(f.read('minimax-distill-pending.json', {}), {}); + assert.equal(f.calls().length, 0); +}); diff --git a/test/hosts/minimax/install.test.js b/test/hosts/minimax/install.test.js new file mode 100644 index 0000000..be52a68 --- /dev/null +++ b/test/hosts/minimax/install.test.js @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { test } from 'node:test'; +import fs from 'node:fs'; +import { syncBuiltinESMExports } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +import { installMiniMax } from '../../../scripts/install-minimax.mjs'; +import { pack } from '../../../scripts/pack.mjs'; + +test('the installer CLI runs from a symlinked checkout path', (t) => { + const root = mkdtempSync(join(tmpdir(), 'evolver-install-link-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const checkout = fileURLToPath(new URL('../../../', import.meta.url)); + const linked = join(root, 'linked-checkout'); + fs.symlinkSync(checkout, linked, process.platform === 'win32' ? 'junction' : 'dir'); + const dataDir = join(root, 'native-data'); + const stdout = execFileSync(process.execPath, [join(linked, 'scripts/install-minimax.mjs'), 'status', '--data-dir', dataDir], { encoding: 'utf8' }); + assert.deepEqual(JSON.parse(stdout), { installed: false, path: join(dataDir, 'plugins', 'evolver-plugin') }); +}); + +test('the default installer copies the released bundle and its version; an explicit version builds the checkout', t => { + const root = mkdtempSync(join(tmpdir(), "evolver release user's ")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const checkout = join(root, 'checkout'), dataDir = join(root, 'native'); + const repository = fileURLToPath(new URL('../../../', import.meta.url)); + for (const file of ['bin', 'lib', 'scripts', 'hosts/minimax', '.minimax-plugin', 'assets', 'LICENSE', 'package.json']) { + mkdirSync(dirname(join(checkout, file)), { recursive: true }); + cpSync(join(repository, file), join(checkout, file), { recursive: true }); + } + const bundle = join(checkout, 'dist/minimax'); + pack('minimax', bundle, { version: '9.8.7' }); + writeFileSync(join(bundle, 'README.md'), 'The release guide for 9.8.7.\n'); + const run = (operation, ...args) => JSON.parse(execFileSync(process.execPath, + [join(checkout, 'scripts/install-minimax.mjs'), operation, '--data-dir', dataDir, ...args], { cwd: checkout, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })); + const installed = run('install'); + assert.equal(run('status').version, '9.8.7'); + assert.equal(readFileSync(join(installed.path, 'README.md'), 'utf8'), 'The release guide for 9.8.7.\n'); + const state = join(dataDir, 'v2/plugin-data/hooks/evolver-plugin'); + mkdirSync(state, { recursive: true }); + writeFileSync(join(state, 'minimax-settings.json'), '{"recall":false}'); + writeFileSync(join(state, 'minimax-distill-drafts.json'), '[{"title":"keep this draft"}]'); + pack('minimax', bundle, { version: '9.8.8' }); + run('update'); + assert.equal(run('status').version, '9.8.8'); + assert.equal(readFileSync(join(state, 'minimax-settings.json'), 'utf8'), '{"recall":false}'); + assert.equal(readFileSync(join(state, 'minimax-distill-drafts.json'), 'utf8'), '[{"title":"keep this draft"}]'); + run('update', '--version', '10.0.0'); + assert.equal(run('status').version, '10.0.0'); + assert.equal(readFileSync(join(installed.path, 'README.md'), 'utf8'), readFileSync(join(checkout, 'hosts/minimax/dist/README.md'), 'utf8')); + const manifestFile = join(bundle, '.minimax-plugin/plugin.json'); + const released = JSON.parse(readFileSync(manifestFile, 'utf8')); + for (const invalid of [{ name: 'another-plugin' }, { version: 'not-a-version' }]) { + writeFileSync(manifestFile, JSON.stringify({ ...released, ...invalid })); + assert.throws(() => run('update'), /invalid identity or version/); + assert.equal(run('status').version, '10.0.0'); + } + writeFileSync(manifestFile, JSON.stringify(released)); + writeFileSync(join(bundle, '.evolver-packed'), 'grok\n'); + assert.throws(() => run('update'), /invalid identity or version/); + assert.equal(run('status').version, '10.0.0'); + assert.equal(readdirSync(dataDir).some(name => name.startsWith('.evolver-install-')), false); +}); + +test('install and update replace the owned package while preserving native account data, preferences and drafts', () => { + const dataDir = mkdtempSync(join(tmpdir(), 'evolver-install-native-')); + try { + const state = join(dataDir, 'v2/plugin-data/hooks/evolver-plugin'); mkdirSync(state, { recursive: true }); + writeFileSync(join(state, 'minimax-settings.json'), '{"autoDistill":false}'); + writeFileSync(join(state, 'minimax-distill-drafts.json'), '[{"title":"keep this draft"}]'); + writeFileSync(join(dataDir, 'config.yaml'), 'synthetic native account config'); + const dry = installMiniMax({ dataDir, dryRun: true }); + assert.equal(existsSync(dry.path), false); + const installed = installMiniMax({ dataDir, version: '1.2.3' }); + assert.equal(readFileSync(join(installed.path, '.evolver-packed'), 'utf8').trim(), 'minimax'); + writeFileSync(join(installed.path, 'old-package-file'), 'remove during update'); + installMiniMax({ dataDir, operation: 'update', version: '1.2.4' }); + assert.equal(existsSync(join(installed.path, 'old-package-file')), false); + assert.equal(readFileSync(join(state, 'minimax-settings.json'), 'utf8'), '{"autoDistill":false}'); + assert.equal(readFileSync(join(state, 'minimax-distill-drafts.json'), 'utf8'), '[{"title":"keep this draft"}]'); + assert.equal(readFileSync(join(dataDir, 'config.yaml'), 'utf8'), 'synthetic native account config'); + assert.equal(installMiniMax({ dataDir, operation: 'status' }).installed, true); + assert.equal(installMiniMax({ dataDir, operation: 'status' }).version, '1.2.4'); + assert.equal(readdirSync(dataDir).some(name => name.startsWith('.evolver-install-')), false); + } finally { rmSync(dataDir, { recursive: true, force: true }); } +}); + +test('an existing unmanaged plugin is never replaced and an absent update never creates one', () => { + const dataDir = mkdtempSync(join(tmpdir(), 'evolver-unmanaged-native-')); + try { + assert.throws(() => installMiniMax({ dataDir, version: '1.02.3' }), /Version/); + assert.throws(() => installMiniMax({ dataDir, operation: 'update' }), /not installed/); + const target = join(dataDir, 'plugins/evolver-plugin'); mkdirSync(target, { recursive: true }); + writeFileSync(join(target, 'personal-file'), 'keep'); + assert.throws(() => installMiniMax({ dataDir }), /unmanaged/); + assert.equal(readFileSync(join(target, 'personal-file'), 'utf8'), 'keep'); + } finally { rmSync(dataDir, { recursive: true, force: true }); } +}); + +test('an update which cannot activate the prepared package restores the previous package', t => { + const dataDir = mkdtempSync(join(tmpdir(), 'evolver-rollback-native-')); + const originalRename = fs.renameSync; + try { + const installed = installMiniMax({ dataDir }); + writeFileSync(join(installed.path, 'keep-on-failure'), 'previous package'); + t.mock.method(fs, 'renameSync', (from, to) => { + if (String(from).endsWith(`${process.platform === 'win32' ? '\\' : '/'}package`) && to === installed.path) throw new Error('fixture activation failure'); + return originalRename(from, to); + }); + syncBuiltinESMExports(); + assert.throws(() => installMiniMax({ dataDir, operation: 'update' }), /activation failure/); + assert.equal(readFileSync(join(installed.path, 'keep-on-failure'), 'utf8'), 'previous package'); + assert.equal(readdirSync(dataDir).some(name => name.startsWith('.evolver-install-')), false); + } finally { + t.mock.restoreAll(); syncBuiltinESMExports(); + rmSync(dataDir, { recursive: true, force: true }); + } +}); diff --git a/test/hosts/minimax/lifecycle.test.js b/test/hosts/minimax/lifecycle.test.js new file mode 100644 index 0000000..35a97a4 --- /dev/null +++ b/test/hosts/minimax/lifecycle.test.js @@ -0,0 +1,104 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +import profile from '../../../hosts/minimax/profile.js'; +import { createDriver } from '../../../lib/agent/driver.js'; +import { runBackgroundTasks } from '../../../lib/app/background.js'; +import { environmentFor, RESIDENT } from '../../../lib/runtime/environment.js'; +import { ownerIsGone } from '../../../lib/runtime/process/owner.js'; +import { transcripts } from '../../../lib/runtime/transcripts.js'; +import { PROMPT } from '../../app/memory-ports.js'; +import { fakeProxy } from '../../helpers/fake-proxy.js'; + +const GENE = { title: 'Stabilise retries', summary: 'A shared queue client recovers from transient connection failures when its retry limit is removed.', strategy: ['Reproduce the failing check.', 'Remove the fixed retry limit.', 'Run the same verification again.'] }; +const DRAFT = { ok: true, status: 'draft', distill_id: 'minimax-draft', quality: { ok: true, score: 6 }, gene: { type: 'Gene', asset_id: `sha256:${'1'.repeat(64)}` }, capsule: { type: 'Capsule', asset_id: `sha256:${'2'.repeat(64)}` } }; + +async function fixture({ failFirst = false } = {}) { + const proxy = await fakeProxy({ distillAnswer: DRAFT }); + const project = join(proxy.env.HOME, 'project'); mkdirSync(project); + const file = join(project, 'fixture.txt'); writeFileSync(file, 'before'); + const host = spawn(process.execPath, ['-e', 'setInterval(()=>{},60000)'], { windowsHide: true, stdio: 'ignore' }); + await once(host, 'spawn'); + let owner = host.pid, calls = 0; + const nativeData = join(proxy.env.HOME, 'native'); + const env = { ...proxy.env, MINIMAX_DATA_DIR: nativeData, PLUGIN_DATA: join(nativeData, 'v2', 'plugin-data', 'hooks', 'evolver-plugin') }; + const { ports } = environmentFor({ profile, env, lifetime: RESIDENT, runBackground: runBackgroundTasks, overrides: { + owner: { current: () => owner, self: () => process.pid, isGone: ownerIsGone }, + model: { complete: async () => { calls += 1; if (failFirst && calls === 1) throw new Error('transient model failure'); return { answer: JSON.stringify(GENE), usage: null }; } }, + engine: { needsSetup: async () => false, takeUnannounced: async () => null }, + } }); + const driver = createDriver({ profile, ports, transcripts, env, lifetime: RESIDENT }); + const payload = (session, turn) => ({ session_id: session, turn_id: turn, cwd: project, model: 'native-bare-model' }); + const hook = (event, session = 's1', turn = 't1', extra = {}) => driver.handle(event, { ...payload(session, turn), ...extra }); + const idle = () => ports.scheduler.idle(); + const killHost = async () => { const ended = once(host, 'close'); host.kill(); await ended; owner = process.pid; assert.equal(ownerIsGone(host.pid), true); }; + const verified = async () => { + await hook('SessionStart'); await idle(); + const answer = await hook('UserPromptSubmit', 's1', 't1', { prompt: PROMPT }); + assert.match(answer.hookSpecificOutput.additionalContext, /Evolution Memory/); + const edit = { tool_name: 'edit', tool_input: { file_path: file, old_string: 'before', new_string: 'after' } }; + await hook('PreToolUse', 's1', 't1', edit); writeFileSync(file, 'after'); + await hook('PostToolUse', 's1', 't1', { ...edit, tool_response: { details: { diff: '-before\n+after' } } }); + await hook('PostToolUse', 's1', 't1', { tool_name: 'bash', tool_input: { command: 'node --test' }, tool_response: { details: { execution: { reason: 'exited', exitCode: 0 }, processOutput: { stdout: '# pass 1\n# fail 0', stderr: '', interrupted: false } } } }); + await hook('Stop', 's1', 't1', { last_assistant_message: 'Retries fixed and verification passes.' }); await idle(); + assert.equal((await ports.candidates.read()).s1.pid, host.pid); + }; + const close = async () => { + await idle(); ports.scheduler.stop(); + if (host.exitCode === null && host.signalCode === null) { const ended = once(host, 'close'); host.kill(); await ended; } + proxy.close(); + }; + return { proxy, ports, hook, idle, verified, killHost, close, calls: () => calls }; +} + +test('a dead native owner is recovered without SessionEnd, distilled once and kept as an unpublished draft', async () => { + const f = await fixture(); + try { + await f.verified(); + await f.hook('SessionStart', 'still-live'); await f.idle(); + assert.equal(f.calls(), 0, 'an idle live host does not lose its candidate'); + await f.killHost(); + await f.hook('SessionStart', 'recovery'); await f.idle(); + assert.equal(f.calls(), 1); + assert.equal((await f.ports.candidates.read()).s1, undefined); + assert.equal((await f.ports.drafts.read()).length, 1); + const posted = f.proxy.requests.filter(x => x.path === '/conversation/distill'); + assert.equal(posted.length, 1); + assert.equal(posted[0].body.publish, false); + assert.equal(posted[0].body.persist, true, 'the core verifier decides local persistence; publication remains explicit'); + assert.equal(f.proxy.requests.some(x => x.path === '/asset/submit'), false); + await f.hook('SessionStart', 'recovery-again'); await f.idle(); + assert.equal(f.calls(), 1); + } finally { await f.close(); } +}); + +test('a failed recovered completion releases its claim and succeeds at the next start without lowering validation', async () => { + const f = await fixture({ failFirst: true }); + try { + await f.verified(); await f.killHost(); + await f.hook('SessionStart', 'retry-one'); await f.idle(); + const due = (await f.ports.candidates.read()).s1; + assert.equal(due.attempts, 1); + assert.equal(due.claimedBy, null); + assert.equal((await f.ports.drafts.read()).length, 0); + await f.hook('SessionStart', 'retry-two'); await f.idle(); + assert.equal(f.calls(), 2); + assert.equal((await f.ports.drafts.read()).length, 1); + } finally { await f.close(); } +}); + +test('a turn without Stop is closed as an error and late tool output cannot verify the next turn', async () => { + const f = await fixture(); + try { + await f.hook('UserPromptSubmit', 's1', 'old', { prompt: PROMPT }); + await f.hook('UserPromptSubmit', 's1', 'new', { prompt: 'Continue after cancellation.' }); + await f.hook('PostToolUse', 's1', 'old', { tool_name: 'bash', tool_input: { command: 'node --test' }, tool_response: { details: { execution: { reason: 'exited', exitCode: 0 } } } }); + await f.hook('Stop', 's1', 'new', { last_assistant_message: 'No verification ran for this new turn.' }); await f.idle(); + assert.deepEqual(await f.ports.candidates.read(), {}); + assert.equal(f.calls(), 0); + } finally { await f.close(); } +}); diff --git a/test/hosts/minimax/model.test.js b/test/hosts/minimax/model.test.js new file mode 100644 index 0000000..fd7ab31 --- /dev/null +++ b/test/hosts/minimax/model.test.js @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +import profile from '../../../hosts/minimax/profile.js'; +import { modelArguments, sessionModelOf } from '../../../hosts/minimax/model.js'; +import { recordReaderOf } from '../../../lib/runtime/records.js'; + +test('the v2 read-only projection captures a session route and variant, independently of later defaults', () => { + const root = mkdtempSync(join(tmpdir(), 'evolver-native-model-')); + const env = { MINIMAX_DATA_DIR: root }; + const file = profile.modelRecord.file(env); mkdirSync(join(root, 'v2/sqlite'), { recursive: true }); + const db = new DatabaseSync(file); + try { + db.exec('CREATE TABLE local_runtime_sessions (session_id TEXT PRIMARY KEY, extra_data_json TEXT); CREATE TABLE local_runtime_session_agent_definitions (session_id TEXT PRIMARY KEY, definition_json TEXT)'); + db.prepare('INSERT INTO local_runtime_sessions VALUES (?, ?)').run('session-1', JSON.stringify({ effectiveModel: 'custom_provider:one/model-a', effectiveModelVariant: 'thinking', unrelatedSecret: 'synthetic-private' })); + const context = { modelRecord: recordReaderOf(profile.modelRecord, env) }; + const captured = sessionModelOf({ session_id: 'session-1', model: 'model-a' }, context); + assert.deepEqual(captured, { model: 'custom_provider:one/model-a#thinking' }); + db.prepare('UPDATE local_runtime_sessions SET extra_data_json = ?').run(JSON.stringify({ effectiveModel: 'custom_provider:two/model-b' })); + assert.deepEqual(modelArguments(captured), ['--model', 'custom_provider:one/model-a#thinking']); + assert.deepEqual(sessionModelOf({ session_id: 'session-1', model: 'model-a' }, context), { model: 'unresolved' }); + assert.deepEqual(sessionModelOf({ session_id: "x' OR 1=1 --", model: 'model-b' }, context), { model: 'unresolved' }); + db.prepare('INSERT INTO local_runtime_session_agent_definitions VALUES (?, ?)').run('session-1', JSON.stringify({ model: { providerId: 'custom_provider:three', modelId: 'model-c', variant: 'high' } })); + assert.deepEqual(sessionModelOf({ session_id: 'session-1', model: 'model-c' }, context), { model: 'custom_provider:three/model-c#high' }); + db.prepare('UPDATE local_runtime_sessions SET extra_data_json = ?').run(JSON.stringify({ effectiveModel: 'old/model', effectiveModelVariant: 'thinking' })); + db.prepare('UPDATE local_runtime_session_agent_definitions SET definition_json = ?').run(JSON.stringify({ model: { providerId: 'custom_provider:three', modelId: 'model-c' } })); + assert.deepEqual(sessionModelOf({ session_id: 'session-1', model: 'model-c' }, context), { model: 'custom_provider:three/model-c' }, 'an absent variant in the authoritative definition cannot inherit a different model’s stale variant'); + const denied = recordReaderOf({ ...profile.modelRecord, query: 'DELETE FROM local_runtime_sessions WHERE session_id = ?' }, env); + assert.equal(denied('session-1'), null); + assert.equal(db.prepare('SELECT count(*) AS n FROM local_runtime_sessions').get().n, 1); + assert.equal(JSON.stringify(context.modelRecord('session-1')).includes('synthetic-private'), false); + } finally { db.close(); rmSync(root, { recursive: true, force: true }); } +}); + +test('native empty variants preserve the provider route in session metadata and agent definitions', () => { + const root = mkdtempSync(join(tmpdir(), 'evolver-native-variant-')); + const env = { MINIMAX_DATA_DIR: root }; + mkdirSync(join(root, 'v2/sqlite'), { recursive: true }); + const db = new DatabaseSync(profile.modelRecord.file(env)); + try { + db.exec('CREATE TABLE local_runtime_sessions (session_id TEXT PRIMARY KEY, extra_data_json TEXT); CREATE TABLE local_runtime_session_agent_definitions (session_id TEXT PRIMARY KEY, definition_json TEXT)'); + db.prepare('INSERT INTO local_runtime_sessions VALUES (?, ?)').run('native-session', '{}'); + const context = { modelRecord: recordReaderOf(profile.modelRecord, env) }; + for (const definition of [false, true]) { + for (const [variant, expected] of [ + ['', 'custom_provider:audit/model-a'], + [null, 'custom_provider:audit/model-a'], + [undefined, 'custom_provider:audit/model-a'], + ['high', 'custom_provider:audit/model-a#high'], + [' ', 'unresolved'], [';exit', 'unresolved'], [0, 'unresolved'], [false, 'unresolved'], + ]) { + const model = { providerId: 'custom_provider:audit', modelId: 'model-a', variant }; + db.prepare('UPDATE local_runtime_sessions SET extra_data_json = ?').run(JSON.stringify(definition + ? { effectiveModel: 'stale/model', effectiveModelVariant: 'thinking' } + : { effectiveModel: 'custom_provider:audit/model-a', effectiveModelVariant: variant })); + if (definition) db.prepare('INSERT OR REPLACE INTO local_runtime_session_agent_definitions VALUES (?, ?)').run('native-session', JSON.stringify({ model })); + const captured = profile.payload.model({ session_id: 'native-session', model: 'model-a' }, context); + assert.deepEqual(captured, { model: expected }, `${definition ? 'definition' : 'session'} variant ${JSON.stringify(variant)}`); + if (expected !== 'unresolved') { + assert.deepEqual(modelArguments(captured), ['--model', expected]); + assert.deepEqual(profile.payload.model({ session_id: 'native-session', model: 'different-model' }, context), { model: 'unresolved' }); + } else assert.throws(() => modelArguments(captured), /no completion/); + } + } + } finally { db.close(); rmSync(root, { recursive: true, force: true }); } +}); + +test('unknown schemas, mismatched models and shell-shaped routes never select a fallback provider', () => { + for (const row of [null, { route: 'only-a-model' }, { route: 'provider/model', variant: ';exit' }, { route: 'provider/model;whoami' }]) { + assert.deepEqual(sessionModelOf({ session_id: 's' }, { modelRecord: () => row }), { model: 'unresolved' }); + } + for (const model of ['unresolved', 'model-only', 'provider/model;whoami', 'provider/model#x#y']) assert.throws(() => modelArguments({ model }), /no completion/); + assert.deepEqual(modelArguments({ model: 'native-default' }), []); + assert.equal(recordReaderOf({ file: () => join(tmpdir(), 'absent-evolver-native-db'), query: 'SELECT 1' }, {})('s'), null); +}); diff --git a/test/hosts/minimax/readers.test.js b/test/hosts/minimax/readers.test.js new file mode 100644 index 0000000..d0dd785 --- /dev/null +++ b/test/hosts/minimax/readers.test.js @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +import profile from '../../../hosts/minimax/profile.js'; +import { completionOf, headlessConfigOf } from '../../../hosts/minimax/headless.js'; +import { dataDirOf } from '../../../hosts/minimax/paths.js'; + +test('native hook data locates the original profile without silently switching accounts', () => { + const root = join(process.cwd(), 'native-profile'); + assert.equal(dataDirOf({ PLUGIN_DATA: join(root, 'v2', 'plugin-data', 'hooks', 'evolver-plugin') }), root); + assert.equal(dataDirOf({ MINIMAX_DATA_DIR: root, MAVIS_DATA_DIR: join(root, 'ignored') }), root); + assert.throws(() => dataDirOf({ PLUGIN_DATA: join(root, 'unknown-layout') }), /layout is unknown/); +}); + +test('the per-run YAML keeps routes and credentials in the source merge, then overrides capabilities only', () => { + const input = '%YAML 1.2\n---\ndefaultModel: custom_provider:fixture/model\ncustom_provider:\n fixture: {options: {apiKey: synthetic-secret}}\n...\n'; + const output = headlessConfigOf(input); + assert.match(output, /^%YAML 1.2\n---\n<<:\n defaultModel: custom_provider:fixture\/model/); + assert.match(output, /apiKey: synthetic-secret/); + assert.match(output, /agents: .*"tools":\[\]/); + assert.equal(output.includes('bypassPermissions'), false); + assert.throws(() => headlessConfigOf('defaultModel: a\n---\ndefaultModel: b'), /one YAML document/); +}); + +test('headless uses the captured session route, stdin, a private config and lightweight mode with bounded steps and time', () => { + assert.deepEqual(profile.model.args({ model: 'fixture/model' }, 'private prompt', { configPath: '/scratch/config.yaml' }), ['exec', '--input', '-', '--config', '/scratch/config.yaml', '--model', 'fixture/model', '--mode', 'lightweight', '--permission', 'smart', '--max-steps', '1', '--timeout', '90s', '--output-format', 'json']); + assert.equal(profile.model.workdir, 'empty'); + assert.equal(profile.owner, 'parent'); + assert.deepEqual(profile.turnEnd.unclosed, { settleMs: 0, as: 'error' }); +}); + +test('only a complete successful native result is accepted, without reflecting provider errors or credentials', () => { + assert.deepEqual(completionOf({ stdout: JSON.stringify({ schemaVersion: 1, type: 'exec.result', status: 'succeeded', output: 'a gene', usage: { inputTokens: 12, outputTokens: 4 } }) }), { answer: 'a gene', usage: { inputTokens: 12, outputTokens: 4 } }); + for (const status of ['failed', 'timeout', 'cancelled', 'limit_exceeded']) assert.throws(() => completionOf({ stdout: JSON.stringify({ schemaVersion: 1, type: 'exec.result', status, error: { code: 'FAILED', message: 'synthetic-secret' } }) }), error => !error.message.includes('synthetic-secret')); + assert.throws(() => completionOf({ stdout: 'synthetic-secret' }), error => !error.message.includes('synthetic-secret')); + assert.throws(() => completionOf({ stdout: '{"schemaVersion":1,"type":"exec.result","status":"succeeded","output":""}' }), /no successful completion/); +}); + +test('native shell verdicts come from host execution metadata, never a model-written exit-code line', () => { + const note = exitCode => profile.evidence.toolNote({ tool_name: 'bash', tool_input: { command: 'node --test' }, tool_response: { details: { execution: { reason: 'exited', exitCode }, processOutput: { stdout: 'test output', stderr: '', interrupted: false } } } }); + assert.deepEqual(note(7), { kind: 'shell', command: 'node --test', exit: 7, output: 'test output' }); + assert.equal(note(0).exit, 0); + assert.deepEqual(profile.evidence.toolNote({ tool_name: 'bash', tool_input: { command: 'node --test' }, tool_response: { content: [{ type: 'text', text: 'EXITCODE=0' }] } }), { kind: 'tool', command: 'node --test' }); +}); + +test('successful edits are tracked, while denied edits and relative paths without a project are no edit evidence', () => { + const cwd = process.cwd(); + const input = { tool_name: 'edit', cwd, tool_input: { file_path: 'fixture.txt', old_string: 'before', new_string: 'after' }, tool_response: { details: { diff: '-before\n+after' } } }; + assert.deepEqual(profile.evidence.editedFiles(input), [join(cwd, 'fixture.txt')]); + assert.deepEqual(profile.evidence.toolNote(input), { kind: 'edit', paths: [join(cwd, 'fixture.txt')], lines: 2 }); + assert.equal(profile.evidence.toolNote({ ...input, tool_response: { details: { is_error: true } } }).kind, 'tool'); + assert.deepEqual(profile.evidence.editedFiles({ ...input, cwd: 'relative' }), []); +}); diff --git a/test/hosts/opencode/host.js b/test/hosts/opencode/host.js index f3670ce..3c57713 100644 --- a/test/hosts/opencode/host.js +++ b/test/hosts/opencode/host.js @@ -32,7 +32,8 @@ export async function startProxy(t, { assets = [ASSET], answer = defaultAnswer, request.on('end', () => { const parsed = body ? JSON.parse(body) : undefined; requests.push({ path: request.url, body: parsed }); - const reply = () => response.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify(answer(request.url, parsed, assets))); + // Keep a cold Core import from racing the fixture's idle socket expiry on Node 22. + const reply = () => response.writeHead(200, { 'Content-Type': 'application/json', Connection: 'close' }).end(JSON.stringify(answer(request.url, parsed, assets))); if (request.url === '/asset/fetch' && recallDelayMs) setTimeout(reply, recallDelayMs); else reply(); }); diff --git a/test/hosts/opencode/session-end.test.js b/test/hosts/opencode/session-end.test.js index cc9fc2f..55f20a4 100644 --- a/test/hosts/opencode/session-end.test.js +++ b/test/hosts/opencode/session-end.test.js @@ -25,7 +25,12 @@ test('a verified turn is distilled once its session is deleted, in a tool-less h const candidate = (await stores.candidates.read()).ses_d; await deleted(oc, 'ses_d'); - await until(() => distills(proxy).length === 1, 'the distillation'); + try { await until(() => distills(proxy).length === 1, 'the distillation'); } + catch (error) { + const pending = (await stores.candidates.read()).ses_d; + t.diagnostic(JSON.stringify({ calls: client.calls.map(call => call.name), requests: proxy.requests.map(request => request.path), closed: pending?.closed, attempts: pending?.attempts, claimed: Boolean(pending?.claimedBy) })); + throw error; + } await until(async () => (await stores.candidates.read()).ses_d === undefined, 'the candidate to settle'); const [create] = callsNamed(client, 'create'); diff --git a/test/packed-layout.test.js b/test/packed-layout.test.js index d0b9bb7..79fadf6 100644 --- a/test/packed-layout.test.js +++ b/test/packed-layout.test.js @@ -14,6 +14,7 @@ const CONTRACT = { codex: { manifest: '.codex-plugin/plugin.json', name: 'evolver-plugin', entries: ['hooks/hooks.json', 'bin/hook.mjs'] }, grok: { manifest: '.grok-plugin/plugin.json', name: 'evolver-plugin', entries: ['hooks/hooks.json', 'bin/hook.mjs'] }, qoder: { manifest: '.qoder-plugin/plugin.json', name: 'evolver-plugin', entries: ['hooks/hooks.json', 'bin/hook.mjs'] }, + minimax: { manifest: '.minimax-plugin/plugin.json', name: 'evolver-plugin', entries: ['hooks/hooks.json', 'bin/hook.mjs'] }, antigravity: { manifest: 'plugin.json', name: 'evolver-plugin', entries: ['hooks.json', 'bin/hook.mjs'] }, opencode: { manifest: 'package.json', name: '@evomap/evolver-opencode', entries: ['server.mjs', 'tui.mjs'], npm: true }, dsh: { manifest: 'package.json', name: '@evomap/dsh-evolver', entries: ['plugin.mjs'], npm: true }, diff --git a/test/release.test.js b/test/release.test.js index 472cd42..c7b0d18 100644 --- a/test/release.test.js +++ b/test/release.test.js @@ -32,7 +32,11 @@ test('packing with a version stamps it into every manifest a packed host reads', test('only a pushed vX.Y.Z tag publishes, after the tests: pack at its version, publish, then the GitHub Release', () => { assert.match(workflow, /tags: \['v\*'\]/); - assert.match(workflow, /needs: test\n\s+if: startsWith\(github\.ref, 'refs\/tags\/v'\)/); + assert.match(workflow, /needs: \[test, windows-minimax\]\n\s+if: startsWith\(github\.ref, 'refs\/tags\/v'\)/); + const windows = workflow.slice(workflow.indexOf(' windows-minimax:'), workflow.indexOf(' publish:')); + assert.match(windows, /runs-on: windows-latest/); + assert.match(windows, /node: \[22, 26\]/); + assert.match(windows, /run: npm run test:minimax/); const publish = workflow.slice(workflow.indexOf(' publish:')); const steps = ['--version=$GITHUB_REF_NAME', 'publish-tag.sh "$GITHUB_REF_NAME" release', 'gh release create'].map((step) => publish.indexOf(step)); assert.ok(steps.every((at, index) => at > 0 && (index === 0 || at > steps[index - 1])), steps.join(',')); diff --git a/test/runtime/clock.test.js b/test/runtime/clock.test.js new file mode 100644 index 0000000..0c39caa --- /dev/null +++ b/test/runtime/clock.test.js @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +import { environmentFor } from '../../lib/runtime/environment.js'; +import { runProcess } from '../../lib/runtime/process/spawn.js'; + +test('cancelling an unused recall deadline releases its timer and lets a hook exit naturally', async t => { + const dir = mkdtempSync(join(tmpdir(), 'evolver-clock-')); t.after(() => rmSync(dir, { recursive: true, force: true })); + const module = new URL('../../lib/runtime/environment.js', import.meta.url).href; + const profile = `{name:'clock-test',label:'Clock',stateDir:()=>${JSON.stringify(dir)},legacy:[],turnEnd:{},commands:{},owner:'self'}`; + const source = `import{environmentFor}from${JSON.stringify(module)};const{ports}=environmentFor({profile:${profile}});const controller=new AbortController();const waiting=ports.clock.sleep(60000,controller.signal);controller.abort();await waiting;console.log('CANCELLED');`; + const result = await runProcess(process.execPath, ['--input-type=module', '-e', source], { timeoutMs: 5000 }); + assert.equal(result.code, 0, result.error?.message); + assert.equal(result.stdout.trim(), 'CANCELLED'); + const { ports } = environmentFor({ profile: { name: 'clock-test', label: 'Clock', stateDir: () => dir, legacy: [], turnEnd: {}, commands: {}, owner: 'self' } }); + await ports.clock.sleep(1); +}); diff --git a/test/runtime/command-jobs.test.js b/test/runtime/command-jobs.test.js new file mode 100644 index 0000000..62ea751 --- /dev/null +++ b/test/runtime/command-jobs.test.js @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +import { commandJobsOf } from '../../lib/runtime/command-jobs.js'; +import { runProcess } from '../../lib/runtime/process/spawn.js'; + +const invocation = { name: 'evolver-search', args: "it's a query; $(whoami)", sessionId: 's1', projectDir: process.cwd() }; + +function fixture(t, options = {}) { + const dir = mkdtempSync(join(tmpdir(), 'evolver-command-jobs-')); + t.after(() => rmSync(dir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 })); + const jobs = commandJobsOf({ dir, agent: 'minimax', env: process.env, accepts: name => name === invocation.name, start: async () => ({ pid: process.pid }), ...options }); + const read = () => JSON.parse(readFileSync(join(dir, 'commands.json'), 'utf8')); + const id = () => read().at(-1).id; + return { jobs, read, id, dir }; +} + +test('command admission is serial, preserves arguments and session, persists results, and never replays', async t => { + const f = fixture(t); + assert.equal((await f.jobs.enqueue(invocation)).ok, true); + assert.equal((await f.jobs.enqueue(invocation)).ok, false); + let calls = 0; + const run = async at => { calls++; for (const key of Object.keys(invocation)) assert.equal(at[key], invocation[key]); return { ok: true, text: 'result' }; }; + assert.equal(await f.jobs.run(f.id(), run), true); + assert.equal(await f.jobs.run(f.id(), run), false); + assert.equal(calls, 1); + assert.match((await f.jobs.status(f.id())).text, /completed\.\nresult/); + assert.equal((await f.jobs.enqueue({ ...invocation, name: '../../arbitrary' })).ok, false); + assert.equal(await f.jobs.run('../elsewhere', run), false); +}); + +test('queued cancellation runs nothing; active cancellation aborts its own process and records the result', async t => { + const f = fixture(t); + await f.jobs.enqueue(invocation); + await f.jobs.cancel(f.id()); + assert.equal(await f.jobs.run(f.id(), () => { throw new Error('must not run'); }), false); + await f.jobs.enqueue(invocation); + const controller = new AbortController(); + const started = once(controller.signal, 'abort'); + const running = f.jobs.run(f.id(), async (at, signal) => { + controller.abort(); + const result = await runProcess(process.execPath, ['-e', 'setInterval(()=>{},1000)'], { signal }); + assert.equal(result.error.code, 'ABORT_ERR'); + return { ok: false, text: 'child stopped' }; + }); + await started; + await f.jobs.cancel(f.id()); + await running; + assert.equal(f.read().at(-1).state, 'cancelled'); + assert.match((await f.jobs.status(f.id())).text, /child stopped/); +}); + +test('a temporary poll read failure does not abort a running command', async t => { + const f = fixture(t); + await f.jobs.enqueue(invocation); + const id = f.id(); + const file = join(f.dir, 'commands.json'); + await f.jobs.run(id, async (_job, signal) => { + const healthy = readFileSync(file, 'utf8'); + writeFileSync(file, '{temporarily unreadable'); + try { await new Promise(resolve => setTimeout(resolve, 300)); } + finally { writeFileSync(file, healthy); } + return { ok: !signal.aborted, text: 'finished' }; + }); + assert.equal(f.read().at(-1).state, 'completed'); +}); + +test('a dead worker is interrupted, spawn failure is explicit, and corrupt state is never overwritten', async t => { + const f = fixture(t, { isGone: () => true }); + await f.jobs.enqueue(invocation); + assert.match((await f.jobs.status(f.id())).text, /interrupted/); + assert.equal(f.read().at(-1).state, 'queued', 'status does not rewrite persisted state'); + assert.equal(await f.jobs.run(f.id(), () => { throw new Error('no replay'); }), false); + const missing = fixture(t, { start: async () => ({ missing: true }) }); + assert.equal((await missing.jobs.enqueue(invocation)).ok, false); + assert.match((await missing.jobs.status()).text, /no command ran/); + writeFileSync(join(f.dir, 'commands.json'), '{broken'); + await assert.rejects(f.jobs.enqueue(invocation), /unreadable/); + assert.equal(readFileSync(join(f.dir, 'commands.json'), 'utf8'), '{broken'); +}); + +test('worker results and history have fixed storage bounds', async t => { + const f = fixture(t); + for (let i = 0; i < 22; i++) { + await f.jobs.enqueue(invocation); + await f.jobs.run(f.id(), async () => ({ ok: true, text: 'x'.repeat(20_000) })); + } + assert.equal(f.read().length, 20); + assert.equal(f.read().at(-1).text.length, 16_000); +}); + +test('escaped Unicode history stays readable and bounded after many commands', async t => { + const f = fixture(t); + for (let i = 0; i < 22; i++) { + const admitted = await f.jobs.enqueue({ ...invocation, args: '\u0000'.repeat(16_000) }); + assert.equal(admitted.ok, true); + await f.jobs.run(f.id(), async () => ({ ok: true, text: '\u0001'.repeat(16_000) })); + assert.equal((await f.jobs.status(f.id())).ok, true); + assert.ok(Buffer.byteLength(readFileSync(join(f.dir, 'commands.json'), 'utf8')) <= 1024 * 1024); + } + assert.equal(f.read().at(-1).state, 'completed'); +}); diff --git a/test/runtime/local-ledger.test.js b/test/runtime/local-ledger.test.js new file mode 100644 index 0000000..fd4fdb5 --- /dev/null +++ b/test/runtime/local-ledger.test.js @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { delimiter, dirname, join } from 'node:path'; +import { test } from 'node:test'; +import { pathToFileURL } from 'node:url'; + +import { pack } from '../../scripts/pack.mjs'; + +test('a packed plugin finds the global CLI ledger with a clean PATH and no custom command shell', async t => { + const dir = mkdtempSync(join(tmpdir(), 'evolver-ledger-user-')); + const previous = Object.fromEntries(['PATH', 'HOME', 'USERPROFILE', 'ComSpec', 'FAKE_NPM_ROOT'].map(key => [key, process.env[key]])); + t.after(() => { + for (const [key, value] of Object.entries(previous)) if (value === undefined) delete process.env[key]; else process.env[key] = value; + rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + }); + const plugin = join(dir, 'plugin'), bin = join(dir, 'bin'), global = join(dir, 'global'); + pack('minimax', plugin); mkdirSync(bin); + // Hide ancestor node_modules too, so this branch never depends on the developer's installed core. + const unavailable = join(plugin, 'node_modules/@evomap/evolver-core'); mkdirSync(unavailable, { recursive: true }); + writeFileSync(join(unavailable, 'package.json'), '{"name":"@evomap/evolver-core","type":"module","exports":"./index.js"}'); + writeFileSync(join(unavailable, 'index.js'), 'throw new Error("fixture optional core unavailable");'); + const cli = join(global, '@evomap/evolver'); + const core = join(cli, 'node_modules/@evomap/evolver-core'); mkdirSync(core, { recursive: true }); + writeFileSync(join(cli, 'package.json'), '{"name":"@evomap/evolver"}'); + writeFileSync(join(core, 'package.json'), '{"name":"@evomap/evolver-core","type":"module","exports":"./index.js"}'); + writeFileSync(join(core, 'index.js'), `import{appendFileSync}from'node:fs';import{join}from'node:path';export const events={rootEventsPath:()=>join(process.env.HOME,'events.jsonl'),Ingestor:class{constructor({path}){this.path=path}async ingest(event){appendFileSync(this.path,JSON.stringify(event)+'\\n')}}};`); + writeFileSync(join(bin, 'npm.cmd'), '@echo off\r\necho %FAKE_NPM_ROOT%\r\n'); + writeFileSync(join(bin, 'npm'), '#!/bin/sh\nprintf "%s\\n" "$FAKE_NPM_ROOT"\n'); chmodSync(join(bin, 'npm'), 0o755); + Object.assign(process.env, { PATH: [bin, dirname(process.execPath)].join(delimiter), HOME: dir, USERPROFILE: dir, FAKE_NPM_ROOT: global }); + delete process.env.ComSpec; + const { recordReuseLocally } = await import(pathToFileURL(join(plugin, 'lib/local-ledger.js')).href); + assert.equal(await recordReuseLocally({ assetId: 'sha256:fixture', outcome: 'success', sessionId: 's1' }, {}), true); + const event = JSON.parse(readFileSync(join(dir, 'events.jsonl'), 'utf8')); + assert.equal(event.type, 'value.reuse_hit'); + assert.equal(event.payload.assetId, 'sha256:fixture'); +}); diff --git a/test/runtime/minimax-prerequisites.test.js b/test/runtime/minimax-prerequisites.test.js new file mode 100644 index 0000000..6618985 --- /dev/null +++ b/test/runtime/minimax-prerequisites.test.js @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +import { isHeadlessRun } from '../../lib/runtime/headless.js'; +import { modelRunnerOf } from '../../lib/runtime/model-executor.js'; +import { ownerProbeOf } from '../../lib/runtime/process/owner.js'; + +test('Windows hooks find the host beyond cmd.exe and PowerShell, and fail closed for an unknown parent', () => { + const table = new Map([ + [process.ppid, { ppid: 50, name: 'cmd.exe' }], + [50, { ppid: 45, name: 'pwsh.exe' }], + [45, { ppid: 40, name: 'BASH.EXE' }], + [40, { ppid: 1, name: 'node.exe' }], + ]); + assert.equal(ownerProbeOf('parent', { platform: 'win32', windowsAncestry: true, probe: pid => table.get(pid) ?? null }).current(), 40); + assert.equal(ownerProbeOf('parent', { platform: 'win32', windowsAncestry: true, probe: () => null }).current(), null); +}); + +test('failed and cancelled model runs also remove their private configuration', async () => { + const home = mkdtempSync(join(tmpdir(), 'evolver-config-failure-')); + const source = join(home, 'config.yaml'); writeFileSync(source, 'synthetic-private-config'); + try { + for (const cancel of [false, true]) { + let scratch; + const runner = modelRunnerOf({ + kind: 'cli', command: () => process.execPath, + configFile: { source: () => source, prepare: text => text }, + args: (model, prompt, context) => { scratch = context.workdir; return ['-e', cancel ? 'setTimeout(()=>{},60000)' : 'process.exit(1)']; }, + parse: () => { throw new Error('safe model failure'); }, + }); + await assert.rejects(runner.complete(null, 'prompt', AbortSignal.timeout(cancel ? 150 : 5000))); + assert.ok(scratch); + assert.equal(existsSync(scratch), false); + assert.equal(readFileSync(source, 'utf8'), 'synthetic-private-config'); + } + } finally { rmSync(home, { recursive: true, force: true }); } +}); + +test('a private model folder suppresses hooks after the host strips the inherited environment', () => { + const folder = mkdtempSync(join(tmpdir(), 'evolver-model-')); + try { + writeFileSync(join(folder, '.evolver-headless'), 'EVOLVER_HEADLESS=1\n', { mode: 0o600 }); + assert.equal(isHeadlessRun({}, folder), true); + writeFileSync(join(folder, '.evolver-headless'), 'not a runtime marker'); + assert.equal(isHeadlessRun({}, folder), false); + } finally { rmSync(folder, { recursive: true, force: true }); } +}); + +test('a CLI gets a private per-run configuration and derived environment, with the source intact and scratch removed', async () => { + const home = mkdtempSync(join(tmpdir(), 'evolver-config-test-')); + const source = join(home, 'config.yaml'); + const original = 'defaultModel: fixture/model\n'; + writeFileSync(source, original); + try { + const runner = modelRunnerOf({ + kind: 'cli', command: () => process.execPath, workdir: 'empty', + env: env => ({ ROUTE_PROBE: env.NATIVE_HOME }), + configFile: { source: env => join(env.NATIVE_HOME, 'config.yaml'), prepare: text => `<<:\n${text.split('\n').map(line => ` ${line}`).join('\n')}\nprobe: true\n` }, + args: (model, prompt, { configPath }) => ['-e', 'const fs=require("fs");process.stdout.write(JSON.stringify({config:fs.readFileSync(process.argv[1],"utf8"),mode:fs.statSync(process.argv[1]).mode&511,cwd:process.cwd(),route:process.env.ROUTE_PROBE,marker:fs.readFileSync(".evolver-headless","utf8")}))', configPath], + parse: ({ stdout }) => ({ answer: JSON.parse(stdout) }), + }, { ...process.env, NATIVE_HOME: home }); + const { answer } = await runner.complete(null, 'a prompt', AbortSignal.timeout(10_000)); + assert.match(answer.config, /defaultModel: fixture\/model/); + assert.equal(answer.route, home); + assert.equal(answer.marker, 'EVOLVER_HEADLESS=1\n'); + if (process.platform !== 'win32') assert.equal(answer.mode, 0o600); + assert.equal(readFileSync(source, 'utf8'), original); + assert.equal(existsSync(answer.cwd), false); + } finally { rmSync(home, { recursive: true, force: true }); } +}); diff --git a/test/runtime/process.test.js b/test/runtime/process.test.js index 32c1fc7..4c87329 100644 --- a/test/runtime/process.test.js +++ b/test/runtime/process.test.js @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; import { existsSync } from 'node:fs'; import { test } from 'node:test'; +import { promisify } from 'node:util'; import { sessionEndWaitOf, waitBudgetOf } from '../../lib/runtime/budget.js'; import { modelRunnerOf } from '../../lib/runtime/model-executor.js'; @@ -21,6 +23,36 @@ test('a run past its timeout is killed and says so; a missing command reads as E assert.equal((await runProcess('evolver-no-such-command-xyz', [])).error.code, 'ENOENT'); }); +test('Windows timeout ends the process tree without system directory environment variables', { skip: process.platform !== 'win32' }, async () => { + const runtime = new URL('../../lib/runtime/process/spawn.js', import.meta.url).href; + const child = ` + const { spawn } = require('node:child_process'); + const descendant = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 5000)'], { stdio: 'ignore', windowsHide: true }); + console.log(JSON.stringify([process.pid, descendant.pid])); + setTimeout(() => {}, 5000); + `; + const fixture = ` + import { setTimeout as delay } from 'node:timers/promises'; + const env = { ...process.env }; + for (const key of Object.keys(process.env)) { + if (/^(systemroot|windir|comspec)$/i.test(key)) delete process.env[key]; + } + const { runProcess } = await import(${JSON.stringify(runtime)}); + const result = await runProcess(process.execPath, ['--input-type=commonjs', '-e', ${JSON.stringify(child)}], { env, timeoutMs: 1500 }); + if (!result.stdout) throw new Error(result.stderr || result.error?.message || 'Fixture process did not start'); + const pids = JSON.parse(result.stdout); + const alive = pid => { + try { process.kill(pid, 0); return true; } + catch (error) { if (error.code === 'ESRCH') return false; throw error; } + }; + const deadline = Date.now() + 2000; + while (pids.some(alive) && Date.now() < deadline) await delay(50); + console.log(JSON.stringify({ code: result.error?.code, alive: pids.map(alive) })); + `; + const { stdout } = await promisify(execFile)(node, ['--input-type=module', '-e', fixture], { timeout: 12000, windowsHide: true }); + assert.deepEqual(JSON.parse(stdout), { code: 'ETIMEDOUT', alive: [false, false] }); +}); + test('a cancelled run is ended and reported cancelled', async () => { const controller = new AbortController(); setTimeout(() => controller.abort(), 100); @@ -49,6 +81,7 @@ test('the owner is this process, the parent past a shell, or the nearest named a assert.equal(ownerProbeOf('parent', { probe, platform: 'darwin' }).current(), 50); assert.equal(ownerProbeOf({ ancestor: /^codex/ }, { probe, platform: 'darwin' }).current(), 40); assert.equal(ownerProbeOf('parent', { probe, platform: 'win32' }).current(), null); + assert.equal(ownerProbeOf('parent', { probe, platform: 'win32', windowsAncestry: true }).current(), 50); }); test('a hook waits what is left of its budget, never less than a floor', () => { @@ -132,16 +165,17 @@ test('a CLI is looked up name by name, on PATH then in the host\'s folders, and assert.equal(executableOf(cursor, options), null, 'an unrelated agent on PATH is never run'); const workbuddy = { names: ['codebuddy'], dirs: ['/apps/wb'], nodeScriptOnWindows: true }; assert.deepEqual(executableOf(workbuddy, options), { file: '/apps/wb/codebuddy', viaNode: false }); - assert.deepEqual(executableOf(workbuddy, { ...options, platform: 'win32', env: { PATH: '/usr/bin' } }), { file: '/apps/wb/codebuddy', viaNode: true }); + const windowsScript = 'C:\\apps\\wb\\codebuddy'; present.add(windowsScript); + assert.deepEqual(executableOf({ ...workbuddy, dirs: ['C:\\apps\\wb'] }, { ...options, platform: 'win32', env: { PATH: 'C:\\unrelated;C:\\usr\\bin' } }), { file: windowsScript, viaNode: true }); present.add('/usr/bin/tool.cmd'); assert.equal(executableOf({ names: ['tool'] }, { ...options, platform: 'win32' }), null, 'a .cmd shim needs a shell'); }); test('on Windows an npm .cmd shim is found only for a CLI that allows one, after an .exe of the same name', async () => { const { executableOf } = await import('../../lib/runtime/process/which.js'); - const present = new Set(['/npm/codex.cmd', '/tools/codex.exe']); + const present = new Set(['C:\\npm\\codex.cmd', 'C:\\tools\\codex.exe']); const options = (PATH) => ({ env: { PATH }, platform: 'win32', exists: (file) => present.has(file), realPath: (file) => file }); - assert.deepEqual(executableOf({ names: ['codex'], windowsShims: true }, options('/npm')), { file: '/npm/codex.cmd', viaNode: false }); - assert.equal(executableOf({ names: ['codex'] }, options('/npm')), null); - assert.deepEqual(executableOf({ names: ['codex'], windowsShims: true }, options('/tools')), { file: '/tools/codex.exe', viaNode: false }); + assert.deepEqual(executableOf({ names: ['codex'], windowsShims: true }, options('C:\\unrelated;C:\\npm')), { file: 'C:\\npm\\codex.cmd', viaNode: false }); + assert.equal(executableOf({ names: ['codex'] }, options('C:\\npm')), null); + assert.deepEqual(executableOf({ names: ['codex'], windowsShims: true }, options('C:\\tools')), { file: 'C:\\tools\\codex.exe', viaNode: false }); }); diff --git a/test/runtime/settings.test.js b/test/runtime/settings.test.js new file mode 100644 index 0000000..1280a5e --- /dev/null +++ b/test/runtime/settings.test.js @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +import { settingsOf } from '../../lib/runtime/settings.js'; + +test('preferences survive a fresh hook with stripped environment and keep explicit environment overrides', async () => { + const dir = mkdtempSync(join(tmpdir(), 'evolver-preferences-')); + try { + const file = join(dir, 'settings.json'); + await settingsOf({}, file).configure({ recall: false }); + await settingsOf({}, file).configure({ autoDistill: false }); + assert.deepEqual(JSON.parse(readFileSync(file, 'utf8')), { recall: false, autoDistill: false }); + assert.equal(settingsOf({}, file).recall, false); + assert.equal(settingsOf({}, file).autoDistill, false); + assert.equal(settingsOf({ EVOLVER_RECALL: 'on' }, file).recall, true); + assert.equal(settingsOf({ EVOLVER_AUTO_DISTILL: 'off' }, file).autoDistill, false); + assert.deepEqual(settingsOf({ EVOLVER_RECALL: 'off' }), { recall: false, autoDistill: true }); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test('corrupt, oversized or wrong-schema settings pause automatic activity and are not silently overwritten', async () => { + const dir = mkdtempSync(join(tmpdir(), 'evolver-bad-preferences-')); + try { + const file = join(dir, 'settings.json'); + for (const text of ['not JSON', '{"recall":"false"}', '{"unknown":true}', '[]', ' '.repeat(4097)]) { + writeFileSync(file, text); + const settings = settingsOf({ EVOLVER_RECALL: 'on' }, file); + assert.equal(settings.recall, false); + assert.equal(settings.autoDistill, false); + await assert.rejects(settings.configure({ recall: true }), /invalid or unreadable/); + assert.equal(readFileSync(file, 'utf8'), text); + } + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test('a held preference lock refuses an update instead of writing without exclusivity', async () => { + const dir = mkdtempSync(join(tmpdir(), 'evolver-locked-preferences-')); + try { + const file = join(dir, 'settings.json'); + writeFileSync(file, '{"autoDistill":false}'); + mkdirSync(`${file}.lock`); + await assert.rejects(settingsOf({}, file).configure({ recall: false }), /Another preference update/); + assert.equal(readFileSync(file, 'utf8'), '{"autoDistill":false}'); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); diff --git a/test/runtime/turn-monitor.test.js b/test/runtime/turn-monitor.test.js new file mode 100644 index 0000000..a97a558 --- /dev/null +++ b/test/runtime/turn-monitor.test.js @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { test } from 'node:test'; + +import profile from '../../hosts/minimax/profile.js'; +import { recordReaderOf } from '../../lib/runtime/records.js'; +import { monitorTurn } from '../../lib/runtime/turn-monitor.js'; + +test('only the matching native aborted/failed turn closes, without the next prompt or a fabricated candidate', async t => { + const dir = mkdtempSync(join(tmpdir(), 'evolver-turn-monitor-')); + const env = { MINIMAX_DATA_DIR: dir }; + const file = profile.turnEnd.statusRecord.file(env); mkdirSync(dirname(file), { recursive: true }); + const db = new DatabaseSync(file); + t.after(() => { db.close(); rmSync(dir, { recursive: true, force: true }); }); + db.exec('CREATE TABLE local_runtime_turn_ingress(session_id TEXT, turn_id TEXT, status TEXT)'); + const insert = db.prepare('INSERT INTO local_runtime_turn_ingress VALUES (?, ?, ?)'); + insert.run('s1', 't1', 'accepted'); insert.run('other', 't1', 'aborted'); + const at = { sessionId: 's1', turnId: 't1', projectDir: dir }; + const ports = { sessions: { read: async () => ({ open: true, turnId: 't1', pid: process.pid }) }, owner: { isGone: () => false }, turnStatus: recordReaderOf(profile.turnEnd.statusRecord, env) }; + for (const [native, reason] of [['aborted', 'aborted'], ['failed', 'error']]) { + let ticks = 0, closed; + db.prepare('UPDATE local_runtime_turn_ingress SET status = ? WHERE session_id = ?').run('accepted', 's1'); + await monitorTurn(ports, at, async result => { closed = result; }, { dir: join(dir, 'locks'), sleep: async () => { ticks++; db.prepare('UPDATE local_runtime_turn_ingress SET status = ? WHERE session_id = ?').run(native, 's1'); } }); + assert.equal(ticks, 1); + assert.deepEqual(closed, { ...at, reason }); + } + assert.equal(ports.turnStatus("s1' OR 1=1 --", 't1'), null); + assert.equal(ports.turnStatus('s1', 't2'), null); +}); + +test('completed, stale, missing or unavailable metadata never invent a terminal verdict', async t => { + const dir = mkdtempSync(join(tmpdir(), 'evolver-monitor-fallback-')); t.after(() => rmSync(dir, { recursive: true, force: true })); + const at = { sessionId: 's', turnId: 't', projectDir: dir }; + for (const [open, turnId, row] of [[true, 't', { status: 'completed' }], [false, 't', { status: 'aborted' }], [true, 'new', { status: 'failed' }], [true, 't', null], [true, 't', { status: 'incompatible' }]]) { + let now = 0; + const ports = { sessions: { read: async () => ({ open, turnId }) }, owner: { isGone: () => false }, turnStatus: () => row }; + await monitorTurn(ports, at, () => assert.fail('must not close'), { dir, now: () => now, sleep: async () => { now += 500; } }); + assert.ok(now <= 30_000); + } +}); diff --git a/test/runtime/windows-owner.test.js b/test/runtime/windows-owner.test.js new file mode 100644 index 0000000..79c9ea5 --- /dev/null +++ b/test/runtime/windows-owner.test.js @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { windowsAncestorsOf } from '../../lib/runtime/process/windows.js'; + +test('Windows queries bounded ancestry with a pinned PowerShell executable and no command-line collection', () => { + let invocation; + const result = windowsAncestorsOf(123, { env: { SystemRoot: 'C:\\Windows' }, run: (...args) => { + invocation = args; + return { status: 0, stdout: '[{"pid":123,"ppid":12,"name":"cmd.exe","path":"C:\\\\Windows\\\\cmd.exe"}]' }; + } }); + assert.equal(result.get(123).ppid, 12); + assert.equal(invocation[0], 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'); + assert.equal(invocation[2].timeout, 2500); + assert.equal(invocation[2].windowsHide, true); + assert.ok(invocation[1].includes('-NoProfile')); + assert.equal(invocation[1].join(' ').includes('CommandLine'), false); +}); + +test('CIM denial, timeout, invalid JSON, malformed identity and invalid PID all remain unknown', () => { + for (const result of [{ status: 1, stdout: '' }, { error: { code: 'ETIMEDOUT' } }, { status: 0, stdout: 'bad' }, { status: 0, stdout: '[{"pid":"123","ppid":12,"name":"cmd.exe","path":""}]' }]) { + assert.equal(windowsAncestorsOf(123, { env: { SystemRoot: 'C:\\Windows' }, run: () => result }).size, 0); + } + assert.equal(windowsAncestorsOf(-1, { run: () => { throw new Error('must not execute'); } }).size, 0); +});