diff --git a/.codex/release-gate.toml b/.codex/release-gate.toml index 0dd9a41..521b1cd 100644 --- a/.codex/release-gate.toml +++ b/.codex/release-gate.toml @@ -26,7 +26,7 @@ env = { ACPX_NPM_CACHE = "/tmp/codex-acpx-release-npm-cache", NPM_CONFIG_CACHE = [[stages]] name = "co-engineer-unit" kind = "unit_tests" -command = ["npm", "--prefix", "plugins/plumbob-harness-control", "test"] +command = ["npm", "--prefix", "plugins/codex-co-engineer", "test"] failure_class = "product_test_failed" timeout_seconds = 300 quiet_seconds = 30 @@ -61,6 +61,13 @@ command = ["node", "scripts/process-boundary-preflight.mjs"] failure_class = "environment_blocked" timeout_seconds = 30 +[[stages]] +name = "codex-mcp-environment" +kind = "integration_tests" +command = ["node", "scripts/mcp-environment-preflight.mjs"] +failure_class = "environment_blocked" +timeout_seconds = 30 + [[stages]] name = "acpx-reproducible-build" kind = "build" @@ -80,7 +87,7 @@ env = { ACPX_NPM_CACHE = "/tmp/codex-acpx-release-npm-cache", NPM_CONFIG_CACHE = [[stages]] name = "co-engineer-package-inventory" kind = "artifact_verification" -command = ["npm", "pack", "./plugins/plumbob-harness-control", "--dry-run", "--ignore-scripts", "--offline", "--json"] +command = ["npm", "pack", "./plugins/codex-co-engineer", "--dry-run", "--ignore-scripts", "--offline", "--json"] failure_class = "product_test_failed" timeout_seconds = 60 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc36841..491ded7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,11 +23,11 @@ jobs: - run: node scripts/release-prerequisites.mjs - run: node scripts/validate-release.mjs - run: npm --prefix tools/acpx-vendor ci --ignore-scripts --no-audit --no-fund - - run: npm --prefix plugins/plumbob-harness-control test + - run: npm --prefix plugins/codex-co-engineer test - run: npm --prefix plugins/cursor-cloud-control test - run: npm --prefix tools/acpx-vendor run test:publish-provenance - run: node scripts/inspector-preflight.mjs - run: npm --prefix tools/acpx-vendor run test:reproducible - run: npm --prefix tools/acpx-vendor run verify:publish-provenance - - run: npm pack ./plugins/plumbob-harness-control --dry-run --ignore-scripts --offline --json + - run: npm pack ./plugins/codex-co-engineer --dry-run --ignore-scripts --offline --json - run: npm pack ./plugins/cursor-cloud-control --dry-run --ignore-scripts --offline --json diff --git a/CHANGELOG.md b/CHANGELOG.md index ae442d5..c76e05c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,48 @@ ## [Unreleased] +## [3.0.2] - 2026-08-19 + +### Fixed + +- Project a compact live `last_event` / `progress` snapshot from the + append-only event log so `task` and `status` no longer stay stale while + ACP workers are streaming. `task.json` is still not rewritten on every + text delta. +- Extend `task` with optional bounded `wait_ms` and `cursor` wait + arguments so Codex can wait for meaningful progress or a terminal state + instead of hammering empty polls. Waits are event-driven; text deltas + are coalesced and event-log catch-up is memory-bounded. Unsolicited + stdio callbacks across assistant turns are not available. +- Read the configured `remote.origin.url` for Cursor Cloud so host + `insteadOf` credential rewrites cannot leak into receipts or fail + dispatch. + +### Changed + +- Adopt `codex-co-engineer` as the package, plugin, MCP server, skill, and + repository-path identifier. Human-facing branding is Codex-Co-Engineer. +- Remove leftover environment fallbacks and vendor package names from the + previous identity. +- Rewrite the root and plugin READMEs around the current 3.x supervisor, + provider matrix, workspace model, and discovery/install paths. + +## [3.0.1] - 2026-08-19 + +### Fixed + +- Forward the user-session runtime and D-Bus locators required by transient + `systemd --user` services when Codex applies the plugin environment + allowlist. +- Report local process-boundary readiness through `status` and fail local + providers closed before creating a worktree, task receipt, or prompt file. +- Wait for the `systemd-run` client result so queueing failures are classified + accurately instead of surfacing as a later unit-inspection failure. +- Keep the stdio server alive while its newly connected client prepares the + first JSON-RPC frame. +- Exercise the exact manifest-filtered MCP environment in the authoritative + release gate. + ## [3.0.0] - 2026-08-19 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 09c6d6e..2345203 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,7 +19,7 @@ host boundary. ```bash node --version -npm --prefix plugins/plumbob-harness-control test +npm --prefix plugins/codex-co-engineer test node scripts/validate-release.mjs node scripts/inspector-preflight.mjs git diff --check @@ -32,8 +32,9 @@ after the provider-free gate. ## Public contract -- Preserve `plumbob-harness-control` as the stable MCP identifier and keep - the five-tool surface small. +- Preserve `codex-co-engineer` as the stable MCP/package identifier, present + the product as Codex-Co-Engineer, and keep the five-tool surface small. + Extend `task` in place for live wait/progress; do not add a sixth tool. - Local tasks use ACP first and may use the same provider's CLI only when ACP fails before prompt dispatch. Never replay an accepted prompt. - `workspace_mode: "managed"` is the default for local tasks and creates one diff --git a/README.md b/README.md index fe6c75d..58c83f3 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,48 @@ # Codex-Co-Engineer -Codex-Co-Engineer is a small stdio MCP supervisor that lets Codex delegate -real review and implementation work to authenticated peer coding agents: - -- Grok Build on the local host -- Cursor Local -- Cursor Cloud -- DeepSeek Harness (DSH) with Muse Spark - -Codex remains the chief engineer, reviewer, and merge authority. Providers -retain their normal coding capabilities, persistent logins, shell access, and -dependency installation. Co-Engineer adds lifecycle tracking, optional local -worktree isolation, bounded cancellation, and useful receipts—not another -sandbox or policy engine. - -The stable plugin identifier is `plumbob-harness-control`. Version 3 exposes -five tools: `status`, `delegate`, `task`, `tasks`, and `cancel`. +**Codex-Co-Engineer** is a small stdio [MCP](https://modelcontextprotocol.io/) +supervisor that lets Codex delegate real review and implementation work to +authenticated peer coding agents. + +Codex stays the chief engineer, reviewer, and merge authority. The peers keep +their normal coding capabilities, persistent logins, shell access, and +dependency installation. Codex-Co-Engineer adds lifecycle tracking, optional +local worktree isolation, bounded cancellation, and inspectable receipts. It +is not another sandbox or policy engine. + +The stable machine identifier is `codex-co-engineer`. The bundled skill is +`control-codex-co-engineer-agents`. Version 3.0.2 exposes five tools: +`status`, `delegate`, `task`, `tasks`, and `cancel`. `task` can wait +with optional `wait_ms` and `cursor`; it does not push unsolicited stdio +callbacks across assistant turns. + +## What Codex-Co-Engineer is for + +Use Codex-Co-Engineer when you want Codex to: + +- run a review or implementation on Grok Build, Cursor Local, Cursor Cloud, or + DeepSeek Harness (DSH) / Muse Spark +- keep one writer per managed local worktree and branch +- wait on a durable receipt instead of a fire-and-forget shell job +- cancel an owned local process group or Cursor Cloud run +- inspect the result before Codex pushes, opens a PR, or merges + +Do not use it as a security sandbox, a credential broker, or a replacement for +the provider's own login and approval flow. + +## Provider matrix + +| Provider | Identifier | Transport | Workspace | Local process boundary | +| --- | --- | --- | --- | --- | +| Grok Build | `grok` | ACP first; CLI fallback only before prompt dispatch | Managed worktree by default, or explicit `direct` | Required | +| Cursor Local | `cursor-local` | ACP first; CLI fallback only before prompt dispatch | Managed worktree by default, or explicit `direct` | Required | +| DeepSeek Harness / Muse | `dsh` | Official rc.7 ACP composition through ACPX | Managed worktree by default, or explicit `direct` | Required | +| Cursor Cloud | `cursor-cloud` | Official Cursor SDK | Remote provider branch; no local worktree | Not used | + +Roles are `review` and `implement`. An accepted prompt is never replayed +through another transport. ACPX does not provide an authoritative prompt-sent +acknowledgement, so a DSH task is marked `dispatch_uncertain` as soon as ACPX +spawns and is never replayed through CLI. ## Install @@ -23,8 +50,8 @@ Requirements: - Node.js 24 or newer. The release gate is intentionally pinned to Node 24. - Git and the `worktree-bootstrap` CLI/skill for managed local workspaces. -- Linux with a working `systemd --user` manager, `systemd-run` 244 or - newer, and a unified cgroup v2 hierarchy for local providers. +- Linux with a working `systemd --user` manager, `systemd-run` 244 or newer, + and a unified cgroup v2 hierarchy for local providers. - Authenticated Grok Build and Cursor Local CLIs. - A Cursor Cloud API key in its normal owner-only configuration file. - The DSH/Muse model credential in its normal owner-only configuration file. @@ -34,7 +61,7 @@ installed plugin package directory—the directory containing `package.json` and `bin/setup.mjs`. In this source checkout that directory is: ```bash -cd plugins/plumbob-harness-control +cd plugins/codex-co-engineer npm run setup npm run setup:check ``` @@ -55,45 +82,41 @@ bin/set-model-api-key `npm run setup:check` validates the DSH/ACPX composition and CLI, Cursor SDK, and `worktree-bootstrap` dependency. It does not install or authenticate Grok -or Cursor Local, validate their CLIs, validate the Cursor Cloud key, or prove -that the Linux systemd/cgroup process boundary is usable. Call the `status` -tool after setup to check provider readiness; the release gate and live host -acceptance must validate the process boundary before starting local agents. - -Cursor Cloud uses `CURSOR_API_KEY`, -`CURSOR_API_KEY_FILE`, or the existing owner-only -`~/.config/cursor-cloud-control/api-key`. The DSH key defaults to the -owner-only `~/.config/codex-co-engineer/model-api-key`. Never put credentials -in MCP arguments or prompts. - -## Delegation model - -Local Grok and Cursor tasks use ACP. DSH uses the official rc.7 ACP -composition through ACPX. Cursor Cloud uses the official Cursor SDK. A local -CLI fallback is allowed only when ACP fails before prompt dispatch; an -accepted prompt is never replayed through another transport. ACPX does not -provide an authoritative prompt-sent acknowledgement, so a DSH task is marked -`dispatch_uncertain` as soon as ACPX spawns and is never replayed through CLI. - -Every local worker is launched as a manager-owned transient `systemd --user` -service with `KillMode=control-group` solely so cancellation reaches detached +or Cursor Local or validate the Cursor Cloud key. Call `status` after setup: +its `local_boundary` result verifies the Linux systemd/cgroup prerequisite in +the MCP process's actual environment, and local providers are reported +unavailable when that boundary is unavailable. The release gate also launches +the MCP through the manifest's exact environment allowlist before accepting a +local-provider release. + +Cursor Cloud uses `CURSOR_API_KEY`, `CURSOR_API_KEY_FILE`, or the existing +owner-only `~/.config/cursor-cloud-control/api-key`. The DSH key defaults to +the owner-only `~/.config/codex-co-engineer/model-api-key`. Never put +credentials in MCP arguments or prompts. + +## Safety and workspace model + +Local workers are launched as manager-owned transient `systemd --user` +services with `KillMode=control-group` solely so cancellation reaches detached descendants and the worker survives the launching client. This is a lifecycle/cleanup boundary, not a sandbox: providers inherit the normal environment, network, filesystem, credentials, and shell capabilities. Local dispatch fails closed when the Linux systemd/cgroup prerequisite is not -available. Cursor Cloud runs in the provider's remote environment. +available. The check occurs before Codex-Co-Engineer creates a managed +worktree, task receipt, or prompt file. Cursor Cloud runs in the provider's +remote environment and does not depend on the local process boundary. -Cursor Local and DSH's official fallback CLIs take the prompt positionally, -so it may be visible to other processes running as the same Unix user for the +Cursor Local and DSH's official fallback CLIs take the prompt positionally, so +it may be visible to other processes running as the same Unix user for the duration of that fallback. Grok fallback uses an owner-only prompt file. ### Local workspace modes Local providers accept `workspace_mode`: -- `managed` (default) creates and locks one - `worktree-bootstrap` worktree and branch per task. This is the normal mode - for parallel implementation and review. +- `managed` (default) creates and locks one `worktree-bootstrap` worktree and + branch per task. This is the normal mode for parallel implementation and + review. - `direct` runs against the supplied checkout. Use it only when you explicitly accept direct mutation of that checkout. @@ -104,9 +127,9 @@ one task → one worktree → one branch → one writer ``` If `worktree-bootstrap` fails before returning an authoritative receipt and -path, Co-Engineer does not guess at or delete an unknown worktree. Inspect the -repository with `git worktree list` and the `worktree-bootstrap` lock tooling; -clean only an exact task/lock that the tooling identifies. +path, Codex-Co-Engineer does not guess at or delete an unknown worktree. +Inspect the repository with `git worktree list` and the `worktree-bootstrap` +lock tooling; clean only an exact task/lock that the tooling identifies. ### Cursor Cloud requirements @@ -128,7 +151,9 @@ tasks reject it. A local implementation returns its branch and handoff for Codex to inspect; Codex may push and open a PR only after confirming that real commits exist. Codex controls the final merge. -Example local review: +## Examples + +Local review: ```json { @@ -142,7 +167,7 @@ Example local review: } ``` -Example Cursor Cloud implementation: +Cursor Cloud implementation: ```json { @@ -156,14 +181,11 @@ Example Cursor Cloud implementation: } ``` -Providers are `grok`, `cursor-local`, `cursor-cloud`, and `dsh`. Roles -are `review` and `implement`. - ## Handoff and cleanup Terminal managed tasks retain their worktree and branch for Codex inspection; -they are not silently deleted. Poll `task`, then run the authoritative -handoff from the recorded worktree: +they are not silently deleted. Watch with `task` (optionally `wait_ms` + +`cursor`), then run the authoritative handoff from the recorded worktree: ```bash worktree-bootstrap handoff TASK --repo /absolute/worktree --format markdown @@ -194,16 +216,46 @@ normal authenticated environment because they are trusted peer coding agents. Task prompts, events, logs, runtime identities, local paths, branch names, and opaque provider IDs are stored under the owner-only state directory, normally -`$XDG_STATE_HOME/codex-co-engineer` or -`~/.local/state/codex-co-engineer`. Task directories are `0700`; files are -`0600`. Prompts and session data are retained for inspection until the -operator removes the exact terminal task state. See -[data handling](docs/data-handling.md). +`$XDG_STATE_HOME/codex-co-engineer` or `~/.local/state/codex-co-engineer`. +Task directories are `0700`; files are `0600`. Prompts and session data are +retained for inspection until the operator removes the exact terminal task +state. See [data handling](docs/data-handling.md). + +## Troubleshooting + +**How do I check whether Codex-Co-Engineer can dispatch locally?** +Call `status`. Local providers are ready only when `local_boundary.ready` is +true. If it is false, the MCP process is missing Linux `systemd --user`, +`systemd-run` 244+, unified cgroup v2, or the forwarded user-session locators +(`XDG_RUNTIME_DIR`, `DBUS_SESSION_BUS_ADDRESS`). + +**Setup passed, but local providers are unavailable.** +`setup:check` does not prove the MCP environment. Re-run `status` from the +actual MCP server process, then confirm the plugin `.mcp.json` allowlist +forwards `HOME`, `PATH`, `XDG_*`, and `DBUS_SESSION_BUS_ADDRESS`. + +**Where is the installed plugin?** +Use the package path reported by Codex or its plugin manager. In this +repository the source package is `plugins/codex-co-engineer`. + +**A managed worktree appeared without a receipt.** +Do not guess or delete it. Inspect `git worktree list` and +`worktree-bootstrap lock inspect`, then clean only an exact identified +task/lock. + +**Cursor Cloud returned HTTP 400 for a valid SHA.** +Treat it as a provider visibility failure. Make the commit reachable from an +open PR or the default branch, then retry. Do not replay a prompt that was +already dispatched. + +**Can I put API keys in the MCP tool arguments?** +No. Use normal provider login or the owner-only key files. Credentials must +not appear in MCP arguments, prompts, receipts, fixtures, or Git. ## Development and release ```bash -npm --prefix plugins/plumbob-harness-control test +npm --prefix plugins/codex-co-engineer test node scripts/validate-release.mjs node scripts/inspector-preflight.mjs ``` diff --git a/SECURITY.md b/SECURITY.md index 380e53d..dedf4d5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -10,7 +10,7 @@ controls. ## Supported versions Security fixes target the latest release on the default branch. Report the -stable MCP identifier `plumbob-harness-control` together with the public +stable MCP identifier `codex-co-engineer` together with the public package version. ## Report privately diff --git a/docs/configuration.md b/docs/configuration.md index 816f1f3..60c5820 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -34,7 +34,7 @@ npm run setup:check ``` In a source checkout, that directory is -`plugins/plumbob-harness-control`. For a Codex-managed installation, use the +`plugins/codex-co-engineer`. For a Codex-managed installation, use the package path reported by Codex or its plugin manager instead of assuming a project-relative path. @@ -52,15 +52,21 @@ verified. `npm run setup:check` validates the DSH/ACPX composition and CLI, Cursor SDK, and `worktree-bootstrap` dependency. It does not install or authenticate Grok -or Cursor Local, validate their CLIs, validate the Cursor Cloud key, or prove -the systemd/cgroup prerequisite. Call the `status` tool after setup to check -provider readiness. The release gate and live host acceptance must validate -the Linux process boundary before local agents run. +or Cursor Local or validate the Cursor Cloud key. Call `status` after setup. +Its `local_boundary` object validates the systemd/cgroup prerequisite in the +MCP process's real environment; Grok, Cursor Local, and DSH are forced to +`ready: false` when the boundary is unavailable. Local delegation repeats the +check before creating any workspace or task artifact. The release gate also +tests a server launched with only the MCP manifest's allowlisted environment. ## Task inputs Repository paths, prompts, roles, deadlines, and workspace/PR intent are -inputs to `delegate`; they are not global policy. +inputs to `delegate`; they are not global policy. `task` accepts optional +`wait_ms` (0-60000) and `cursor` arguments for a bounded wait. The +result includes a compact `progress` snapshot; text deltas are coalesced +and large logs are paged with `more_events`. It does not stream raw +events or emit unsolicited stdio callbacks across assistant turns. ### Local providers diff --git a/docs/data-handling.md b/docs/data-handling.md index d64ef44..8128214 100644 --- a/docs/data-handling.md +++ b/docs/data-handling.md @@ -83,6 +83,10 @@ State contains: - local repository/worktree paths, branch names, and commit references; - opaque local session, cloud agent, run, branch, and PR identifiers. +MCP `task`/`status` results may overlay a compact live `last_event` from +`events.jsonl` while the durable `task.json` receipt is still mid-run. +Those snapshots omit prompt text, argv, secrets, and raw event streams. + It can contain sensitive private-repository context. Do not publish or commit the state directory. Terminal task state is retained for inspection until the operator deliberately removes that exact task directory after handoff. @@ -90,7 +94,8 @@ operator deliberately removes that exact task directory after handoff. ## Handoff and cleanup Terminal managed tasks retain their worktree and branch; completion does not -silently delete evidence. Poll `task`, inspect the receipt, and run: +silently delete evidence. Watch with `task` (optionally `wait_ms` + +`cursor`), inspect the receipt, and run: ```bash worktree-bootstrap handoff TASK --repo /absolute/worktree --format markdown diff --git a/plugins/plumbob-harness-control/.codex-plugin/plugin.json b/plugins/codex-co-engineer/.codex-plugin/plugin.json similarity index 91% rename from plugins/plumbob-harness-control/.codex-plugin/plugin.json rename to plugins/codex-co-engineer/.codex-plugin/plugin.json index 8f69b81..9b055b8 100644 --- a/plugins/plumbob-harness-control/.codex-plugin/plugin.json +++ b/plugins/codex-co-engineer/.codex-plugin/plugin.json @@ -1,9 +1,9 @@ { - "name": "plumbob-harness-control", - "version": "3.0.0", + "name": "codex-co-engineer", + "version": "3.0.2", "description": "Codex-Co-Engineer delegates real coding work to Grok, Cursor Local, Cursor Cloud, and DeepSeek Harness through a thin ACP-first supervisor.", "author": { - "name": "Plumbob" + "name": "Codex-Co-Engineer" }, "license": "MIT", "keywords": [ @@ -23,7 +23,7 @@ "displayName": "Codex-Co-Engineer", "shortDescription": "Delegate work to Grok, Cursor Local/Cloud, and DSH", "longDescription": "Use Codex-Co-Engineer to run trusted peer coding agents through ACP-first local transports or Cursor Cloud. Local tasks support managed or explicitly direct workspaces and require a Linux manager-owned transient systemd user service with a cgroup cleanup boundary; Cloud tasks start from pushed immutable commit SHAs. Codex retains review and merge authority.", - "developerName": "Plumbob", + "developerName": "Codex-Co-Engineer", "category": "Developer Tools", "capabilities": [ "Interactive", diff --git a/plugins/plumbob-harness-control/.mcp.json b/plugins/codex-co-engineer/.mcp.json similarity index 89% rename from plugins/plumbob-harness-control/.mcp.json rename to plugins/codex-co-engineer/.mcp.json index 170a2f4..56d1b28 100644 --- a/plugins/plumbob-harness-control/.mcp.json +++ b/plugins/codex-co-engineer/.mcp.json @@ -1,6 +1,6 @@ { "mcpServers": { - "plumbob-harness-control": { + "codex-co-engineer": { "cwd": ".", "command": "node", "args": [ @@ -10,6 +10,8 @@ ], "env_vars": [ "XDG_STATE_HOME", + "XDG_RUNTIME_DIR", + "DBUS_SESSION_BUS_ADDRESS", "HOME", "PATH", "XDG_CONFIG_HOME", diff --git a/plugins/plumbob-harness-control/README.md b/plugins/codex-co-engineer/README.md similarity index 53% rename from plugins/plumbob-harness-control/README.md rename to plugins/codex-co-engineer/README.md index 34e1da5..18cbf0b 100644 --- a/plugins/plumbob-harness-control/README.md +++ b/plugins/codex-co-engineer/README.md @@ -1,8 +1,8 @@ # Codex-Co-Engineer -Codex-Co-Engineer is a small stdio MCP supervisor that lets Codex delegate -real review and implementation work to trusted, authenticated peer coding -agents: +**Codex-Co-Engineer** is the ACP-first stdio MCP supervisor that lets Codex +delegate real review and implementation work to trusted, authenticated peer +coding agents: - Grok Build on the local host - Cursor Local @@ -11,42 +11,62 @@ agents: Codex remains the chief engineer, reviewer, and merge authority. Providers retain their normal shell, coding, and dependency-installation capabilities. +The package, plugin, and MCP server identifier is `codex-co-engineer`. +This release is 3.0.2. ## Tools The MCP server exposes five tools: -- `status` — supervisor health, provider readiness, and recent task state -- `delegate` — start a review or implementation task -- `task` — inspect one task receipt and runtime identity -- `tasks` — list recent task receipts -- `cancel` — stop one owned local process group or Cursor Cloud run +| Tool | Purpose | +| --- | --- | +| `status` | Supervisor health, provider readiness, and recent task state | +| `delegate` | Start a review or implementation task | +| `task` | Inspect one task receipt, compact live progress, and optional wait | +| `tasks` | List recent task receipts | +| `cancel` | Stop one owned local process group or Cursor Cloud run | `delegate` requires a stable `task_id`, a provider, an absolute Git repository root, and a prompt. Providers are `grok`, `cursor-local`, `cursor-cloud`, and `dsh`; roles are `review` and `implement`. -## Execution model - -Grok and Cursor Local use ACP as their primary transport. DSH uses the -official rc.7 ACP composition through ACPX. Cursor Cloud uses the official -Cursor SDK. A local CLI fallback is allowed only when ACP fails before prompt -dispatch. Once a prompt is dispatched, Co-Engineer never replays it through +`task` always returns a compact `progress` snapshot (`event_cursor`, +`last_event`, `new_event_count`, `more_events`, `waited_ms`, +`wait_reason`) derived from the append-only event log. Pass the previous +`event_cursor` as `cursor` and a bounded `wait_ms` (0-60000) to block +until meaningful progress or a terminal state. Terminal, status, and +tool-call boundaries wake promptly; text deltas are coalesced. Cursor +catch-up reads a bounded chunk and sets `more_events` when more log +remains. The five-tool API is unchanged; unsolicited stdio callbacks +across assistant turns are not available. Do not expect `task.json` +itself to rewrite `last_event` on every text delta. + +## Provider matrix + +| Provider | Transport | Workspace | Notes | +| --- | --- | --- | --- | +| `grok` | ACP first | `managed` default, `direct` explicit | CLI fallback only before prompt dispatch; owner-only prompt file | +| `cursor-local` | ACP first | `managed` default, `direct` explicit | Official fallback CLI takes the prompt positionally | +| `dsh` | Official rc.7 ACP via ACPX | `managed` default, `direct` explicit | Marked `dispatch_uncertain` after ACPX spawn; never CLI-replayed | +| `cursor-cloud` | Official Cursor SDK | Remote branch | Requires a pushed immutable `starting_ref` SHA | + +Once a prompt is dispatched, Codex-Co-Engineer never replays it through another transport. ACPX does not provide an authoritative prompt-sent -acknowledgement, so DSH receipts use `dispatch_uncertain` after ACPX spawns and -never fall back to CLI from that point. +acknowledgement. -Cursor Local and DSH's official fallback CLIs take the prompt positionally, -so it may be visible to other processes running as the same Unix user during -that fallback. Grok fallback uses an owner-only prompt file. +## Execution and safety model Each local worker runs in a manager-owned transient `systemd --user` service with `KillMode=control-group` so cancellation reaches detached descendants and -the worker survives the launching client. This -is only a lifecycle/cleanup boundary, not a provider sandbox: the normal -environment, credentials, network, filesystem, and shell capabilities are -inherited unchanged. Local dispatch fails closed when Linux systemd or unified -cgroup v2 is unavailable. Cursor Cloud uses the provider's remote runtime. +the worker survives the launching client. This is only a lifecycle/cleanup +boundary, not a provider sandbox: the normal environment, credentials, +network, filesystem, and shell capabilities are inherited unchanged. Local +dispatch fails closed when Linux systemd or unified cgroup v2 is unavailable. +Cursor Cloud uses the provider's remote runtime. + +Cursor Local and DSH's official fallback CLIs take the prompt positionally, so +it may be visible to other processes running as the same Unix user during that +fallback. Grok fallback uses an owner-only prompt file. ### Local workspace modes @@ -113,20 +133,20 @@ npm run setup:check npm test ``` -In a source checkout, that directory is -`plugins/plumbob-harness-control`. For a Codex-managed installation, use the -package path reported by Codex or its plugin manager instead of assuming a -project-relative path. +In a source checkout, that directory is `plugins/codex-co-engineer`. For a +Codex-managed installation, use the package path reported by Codex or its +plugin manager instead of assuming a project-relative path. Setup installs pinned ACPX `0.13.0`, Cursor SDK `1.0.28`, and the cohesive official DSH `0.1.0-rc.7` composition. It writes a key-free DSH ACP configuration and owner-only session directory; it does not perform login. `npm run setup:check` validates the DSH/ACPX composition and CLI, Cursor SDK, and `worktree-bootstrap` dependencies. It does not install or authenticate -Grok or Cursor Local, validate their CLIs, validate the Cursor Cloud key, or -validate the Linux systemd/cgroup process boundary. Call the `status` tool -after setup to check provider readiness; release and live host acceptance -perform the process-boundary check before local dispatch. +Grok or Cursor Local or validate the Cursor Cloud key. Call `status` after +setup: `local_boundary` verifies the Linux systemd/cgroup prerequisite from +the MCP process's actual environment, and local providers are reported +unavailable when it fails. Release acceptance also launches the MCP through +the manifest's exact environment allowlist before local dispatch is allowed. Authenticate providers normally so sessions persist across Codex tasks: @@ -136,10 +156,9 @@ cursor-agent login bin/set-model-api-key ``` -DSH uses `MODEL_API_KEY`, -`CODEX_CO_ENGINEER_MODEL_API_KEY_FILE`, or the default owner-only -`~/.config/codex-co-engineer/model-api-key`. Cursor Cloud uses -`CURSOR_API_KEY`, `CURSOR_API_KEY_FILE`, or the existing owner-only +DSH uses `MODEL_API_KEY`, `CODEX_CO_ENGINEER_MODEL_API_KEY_FILE`, or the +default owner-only `~/.config/codex-co-engineer/model-api-key`. Cursor Cloud +uses `CURSOR_API_KEY`, `CURSOR_API_KEY_FILE`, or the existing owner-only `~/.config/cursor-cloud-control/api-key`. Credentials are never MCP arguments or task receipts. @@ -157,16 +176,15 @@ arguments or task receipts. | `CODEX_CO_ENGINEER_DSH_ACP_COMMAND` | DSH ACP server override. | | `CODEX_CO_ENGINEER_ACPX_COMMAND` | ACPX executable override. | -The default state directory is -`${XDG_STATE_HOME}/codex-co-engineer` or +The default state directory is `${XDG_STATE_HOME}/codex-co-engineer` or `~/.local/state/codex-co-engineer`. Task directories are `0700`; files are `0600`. Prompts, events, logs, and ACP/DSH session data remain owner-only until the operator removes the exact terminal task state. ## Handoff and cleanup -Terminal managed tasks retain their worktree and branch for inspection. Poll -`task`, then run: +Terminal managed tasks retain their worktree and branch for inspection. Watch +with `task` (optionally `wait_ms` + `cursor`), then run: ```bash worktree-bootstrap handoff TASK --repo /absolute/worktree --format markdown @@ -182,7 +200,9 @@ needed. Direct-mode tasks have no managed worktree. Cursor Cloud agents are archived after terminal completion where supported; their remote branch or PR remains for Codex review. -## Example +## Examples + +Local review: ```json { @@ -195,9 +215,69 @@ remains for Codex review. } ``` +Cursor Cloud implementation: + +```json +{ + "task_id": "cloud-auth-1", + "provider": "cursor-cloud", + "role": "implement", + "repo": "/absolute/path/to/clean-checkout", + "starting_ref": "0123456789abcdef0123456789abcdef01234567", + "prompt": "Implement the requested change, run tests, and commit the result.", + "create_pr": true +} +``` + +Watch a running task instead of empty-polling: + +```json +{ + "task_id": "review-auth-1", + "wait_ms": 25000, + "cursor": "184" +} +``` + +Use the `event_cursor` from the previous `task` result. The call returns a +compact progress snapshot when meaningful progress arrives, the task +becomes terminal, or `wait_ms` elapses. Text deltas are coalesced; a large +event log is paged with `more_events`. Unsolicited stdio callbacks across +assistant turns are not available. + For an implementation, use `role: "implement"`. A local managed task creates and locks its worktree before the provider starts. +## Troubleshooting + +**Which identifier should Codex, npm, and MCP configs use?** +Use `codex-co-engineer`. The human-facing product name is Codex-Co-Engineer. +Skill configs use the lowercase `control-codex-co-engineer-agents` name. + +**Why are local providers marked not ready after setup?** +`setup:check` validates pinned CLIs and packages. `status.local_boundary` +validates the MCP process environment. Local dispatch stays fail-closed until +Linux `systemd --user`, `systemd-run` 244+, and unified cgroup v2 are visible +to that process. + +**Where should I run setup?** +From the installed plugin directory that contains `package.json` and +`bin/setup.mjs`. In this repository that path is `plugins/codex-co-engineer`. + +**A managed worktree appeared without a receipt.** +Do not guess or delete it. Inspect `git worktree list` and +`worktree-bootstrap lock inspect`, then clean only an exact identified +task/lock. + +**Cursor Cloud returned HTTP 400 for a valid SHA.** +Treat it as a provider visibility failure. Make the commit reachable from an +open PR or the default branch, then retry. Do not replay a prompt that was +already dispatched. + +**Can I put API keys in the MCP tool arguments?** +No. Use normal provider login or the owner-only key files. Credentials must +not appear in MCP arguments, prompts, receipts, fixtures, or Git. + ## Data handling Prompts and selected repository content may leave the machine for the chosen diff --git a/plugins/plumbob-harness-control/assets/acpx-runtime.manifest.json b/plugins/codex-co-engineer/assets/acpx-runtime.manifest.json similarity index 95% rename from plugins/plumbob-harness-control/assets/acpx-runtime.manifest.json rename to plugins/codex-co-engineer/assets/acpx-runtime.manifest.json index bd46226..15e8eb9 100644 --- a/plugins/plumbob-harness-control/assets/acpx-runtime.manifest.json +++ b/plugins/codex-co-engineer/assets/acpx-runtime.manifest.json @@ -49,7 +49,7 @@ "version": "1.3.0", "integrity": "sha512-i3h/efaeuMUFAO1HSfo97QZQnnvMd7wWBYtBsdL6UMZg3a78sk3Ffya5Xu7C7tYsXomXoDXJBAzQF2PcFKAhIQ==" }, - "lock_sha512": "sha512-yugpDRvMZI1PxzGHMsM+ePY8FqQHkEJytF5S4wE4+bYF54pECLiFQEE1QGhgJHrYFAfGtZe+MvKqDkU2YrME8Q==", + "lock_sha512": "sha512-gZHGpLTRyflEcNQszD/mm/Bsg36LAUfV+lM8PNv5c7kAjB/5y2rjNDiMUGgUSc44qJQq+OnIiaax4OsXunP11Q==", "bundler": { "package": "esbuild", "version": "0.28.2", diff --git a/plugins/plumbob-harness-control/assets/acpx-runtime.mjs b/plugins/codex-co-engineer/assets/acpx-runtime.mjs similarity index 100% rename from plugins/plumbob-harness-control/assets/acpx-runtime.mjs rename to plugins/codex-co-engineer/assets/acpx-runtime.mjs diff --git a/plugins/plumbob-harness-control/assets/acpx-third-party-notices.md b/plugins/codex-co-engineer/assets/acpx-third-party-notices.md similarity index 99% rename from plugins/plumbob-harness-control/assets/acpx-third-party-notices.md rename to plugins/codex-co-engineer/assets/acpx-third-party-notices.md index 43416ab..4683ca3 100644 --- a/plugins/plumbob-harness-control/assets/acpx-third-party-notices.md +++ b/plugins/codex-co-engineer/assets/acpx-third-party-notices.md @@ -8,7 +8,7 @@ Authoritative reproduction uses `npm ci --offline` in a clean temporary tree and therefore requires a preseeded npm cache; it does not claim an empty-cache or network-free dependency bootstrap. -Exact lock SHA-512: `sha512-yugpDRvMZI1PxzGHMsM+ePY8FqQHkEJytF5S4wE4+bYF54pECLiFQEE1QGhgJHrYFAfGtZe+MvKqDkU2YrME8Q==` +Exact lock SHA-512: `sha512-gZHGpLTRyflEcNQszD/mm/Bsg36LAUfV+lM8PNv5c7kAjB/5y2rjNDiMUGgUSc44qJQq+OnIiaax4OsXunP11Q==` ## Bundled runtime licenses diff --git a/plugins/plumbob-harness-control/assets/co-engineer.png b/plugins/codex-co-engineer/assets/co-engineer.png similarity index 100% rename from plugins/plumbob-harness-control/assets/co-engineer.png rename to plugins/codex-co-engineer/assets/co-engineer.png diff --git a/plugins/plumbob-harness-control/assets/icon.svg b/plugins/codex-co-engineer/assets/icon.svg similarity index 100% rename from plugins/plumbob-harness-control/assets/icon.svg rename to plugins/codex-co-engineer/assets/icon.svg diff --git a/plugins/plumbob-harness-control/bin/set-model-api-key b/plugins/codex-co-engineer/bin/set-model-api-key similarity index 88% rename from plugins/plumbob-harness-control/bin/set-model-api-key rename to plugins/codex-co-engineer/bin/set-model-api-key index 4f8bb20..01ae764 100755 --- a/plugins/plumbob-harness-control/bin/set-model-api-key +++ b/plugins/codex-co-engineer/bin/set-model-api-key @@ -3,7 +3,7 @@ set -euo pipefail config_home="${XDG_CONFIG_HOME:-${HOME:?}/.config}" default_file="${config_home}/codex-co-engineer/model-api-key" -secret_file="${CODEX_CO_ENGINEER_MODEL_API_KEY_FILE:-${PLUMBOB_HARNESS_MODEL_API_KEY_FILE:-${default_file}}}" +secret_file="${CODEX_CO_ENGINEER_MODEL_API_KEY_FILE:-${default_file}}" secret_dir="$(dirname -- "${secret_file}")" umask 077 diff --git a/plugins/plumbob-harness-control/bin/setup.mjs b/plugins/codex-co-engineer/bin/setup.mjs similarity index 100% rename from plugins/plumbob-harness-control/bin/setup.mjs rename to plugins/codex-co-engineer/bin/setup.mjs diff --git a/plugins/plumbob-harness-control/mcp/v3/acp-worker.mjs b/plugins/codex-co-engineer/mcp/v3/acp-worker.mjs similarity index 100% rename from plugins/plumbob-harness-control/mcp/v3/acp-worker.mjs rename to plugins/codex-co-engineer/mcp/v3/acp-worker.mjs diff --git a/plugins/plumbob-harness-control/mcp/v3/cursor-cloud-worker.mjs b/plugins/codex-co-engineer/mcp/v3/cursor-cloud-worker.mjs similarity index 99% rename from plugins/plumbob-harness-control/mcp/v3/cursor-cloud-worker.mjs rename to plugins/codex-co-engineer/mcp/v3/cursor-cloud-worker.mjs index 0838402..b2e2f08 100644 --- a/plugins/plumbob-harness-control/mcp/v3/cursor-cloud-worker.mjs +++ b/plugins/codex-co-engineer/mcp/v3/cursor-cloud-worker.mjs @@ -552,7 +552,7 @@ export async function runCursorCloudTask({ root, taskId, sdk, apiKey, signal } = client = sdk ?? await deadlineCall(deadlineAt, () => loadCursorSdk(), 'SDK loading'); key = apiKey ?? await deadlineCall(deadlineAt, () => loadCursorApiKey(), 'credential loading'); const [rawRepoUrl, head] = await Promise.all([ - deadlineCall(deadlineAt, () => gitValue(task.cwd, ['remote', 'get-url', 'origin']), 'origin discovery'), + deadlineCall(deadlineAt, () => gitValue(task.cwd, ['config', '--local', '--get', 'remote.origin.url']), 'origin discovery'), deadlineCall(deadlineAt, () => gitValue(task.cwd, ['rev-parse', 'HEAD']), 'immutable head discovery'), ]); const repoUrl = providerRepoUrl(rawRepoUrl); diff --git a/plugins/plumbob-harness-control/mcp/v3/process-boundary.mjs b/plugins/codex-co-engineer/mcp/v3/process-boundary.mjs similarity index 96% rename from plugins/plumbob-harness-control/mcp/v3/process-boundary.mjs rename to plugins/codex-co-engineer/mcp/v3/process-boundary.mjs index 85297c6..8e24a60 100644 --- a/plugins/plumbob-harness-control/mcp/v3/process-boundary.mjs +++ b/plugins/codex-co-engineer/mcp/v3/process-boundary.mjs @@ -266,7 +266,7 @@ export function buildProcessBoundaryArgv({ unit, description, command, args = [] ]; } -function awaitSpawn(child, timeoutMs) { +function awaitLauncherResult(child, timeoutMs) { if (!child || typeof child.once !== 'function') fail('launch_failed', 'systemd-run did not return a child process handle.'); return new Promise((resolve, reject) => { let timer; @@ -274,14 +274,25 @@ function awaitSpawn(child, timeoutMs) { clearTimeout(timer); child.off?.('spawn', onSpawn); child.off?.('error', onError); + child.off?.('exit', onExit); }; - const onSpawn = () => { cleanup(); resolve(); }; + const onSpawn = () => {}; const onError = (error) => { cleanup(); reject(new ProcessBoundaryError('launch_failed', `systemd-run could not start (${compact(error.message)}).`, { cause: error })); }; + const onExit = (code, signal) => { + cleanup(); + if (code === 0) resolve(); + else reject(new ProcessBoundaryError( + 'systemd_run_failed', + `systemd-run did not queue the transient service (${code === null ? `signal ${compact(signal)}` : `exit ${code}`}).`, + )); + }; child.once('spawn', onSpawn); child.once('error', onError); + child.once('exit', onExit); + if (child.exitCode !== null) queueMicrotask(() => onExit(child.exitCode, child.signalCode)); timer = setTimeout(() => { cleanup(); - reject(new ProcessBoundaryError('launch_timeout', 'systemd-run did not start within the bounded deadline.')); + reject(new ProcessBoundaryError('launch_timeout', 'systemd-run did not queue the transient service within the bounded deadline.')); }, timeoutMs); }); } @@ -443,7 +454,7 @@ export async function launchProcessBoundary({ command, args = [], cwd, env = pro stdio, }); try { - await awaitSpawn(child, PROCESS_BOUNDARY_DEFAULTS.launchTimeoutMs); + await awaitLauncherResult(child, PROCESS_BOUNDARY_DEFAULTS.launchTimeoutMs); for (let attempt = 0; attempt < 40; attempt += 1) { const shown = await showUnit(host, unit); if (shown.found && shown.properties.Description === description && shown.properties.Id === unit) { diff --git a/plugins/plumbob-harness-control/mcp/v3/server.mjs b/plugins/codex-co-engineer/mcp/v3/server.mjs similarity index 78% rename from plugins/plumbob-harness-control/mcp/v3/server.mjs rename to plugins/codex-co-engineer/mcp/v3/server.mjs index 7426b63..e713691 100644 --- a/plugins/plumbob-harness-control/mcp/v3/server.mjs +++ b/plugins/codex-co-engineer/mcp/v3/server.mjs @@ -36,10 +36,23 @@ const TOOLS = [ }, { name: 'task', - description: 'Inspect one task receipt and its local worker identity.', + description: 'Inspect one task receipt, a compact live progress snapshot, and an event_cursor. Optional wait_ms waits until meaningful progress or a terminal state. Terminal, status, and tool-call boundaries wake promptly; text deltas are coalesced. Cursor catch-up is bounded. Unsolicited stdio callbacks across assistant turns are not available.', inputSchema: { type: 'object', - properties: { task_id: { type: 'string', pattern: '^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$' } }, + properties: { + task_id: { type: 'string', pattern: '^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$' }, + wait_ms: { + type: 'integer', + minimum: 0, + maximum: 60000, + description: 'Optional bounded wait. Returns on meaningful progress, terminal state, or timeout. 0 is a non-blocking snapshot. Text deltas are coalesced; terminal/status/tool-call boundaries wake promptly.', + }, + cursor: { + type: 'string', + pattern: '^[0-9]{1,16}$', + description: 'Opaque event_cursor from a previous task result. Wait for events after this boundary instead of hammering empty polls.', + }, + }, required: ['task_id'], additionalProperties: false, }, @@ -98,8 +111,15 @@ async function callTool(name, args = {}) { return result({ task: publicTask(value.task), runtime: value.runtime }); } if (name === 'task') { - const value = await taskStatus(root, args.task_id); - return result({ task: publicTask(value.task), runtime: value.runtime }); + const value = await taskStatus(root, args.task_id, { + cursor: args.cursor, + wait_ms: args.wait_ms, + }); + return result({ + task: publicTask(value.task), + runtime: value.runtime, + progress: value.progress, + }); } if (name === 'tasks') return result({ tasks: (await listTasks(root)).map(publicTask) }); if (name === 'cancel') return result({ task: publicTask(await cancelTask(root, args.task_id)) }); @@ -122,7 +142,7 @@ async function handle(message) { result: { protocolVersion: negotiated, capabilities: { tools: { listChanged: false } }, - serverInfo: { name: 'codex-co-engineer', title: 'Codex-Co-Engineer', version: '3.0.0' }, + serverInfo: { name: 'codex-co-engineer', title: 'Codex-Co-Engineer', version: '3.0.2' }, }, }); return; @@ -147,6 +167,9 @@ async function handle(message) { } const input = readline.createInterface({ input: process.stdin, crlfDelay: Infinity, terminal: false }); +// A newly spawned stdio server can otherwise exit before its parent has time +// to write the first JSON-RPC frame when the pipe is initially empty. +process.stdin.resume(); input.on('line', (line) => { let message; try { message = JSON.parse(line); } catch { return; } diff --git a/plugins/plumbob-harness-control/mcp/v3/single-turn.flow.mjs b/plugins/codex-co-engineer/mcp/v3/single-turn.flow.mjs similarity index 100% rename from plugins/plumbob-harness-control/mcp/v3/single-turn.flow.mjs rename to plugins/codex-co-engineer/mcp/v3/single-turn.flow.mjs diff --git a/plugins/plumbob-harness-control/mcp/v3/supervisor.mjs b/plugins/codex-co-engineer/mcp/v3/supervisor.mjs similarity index 90% rename from plugins/plumbob-harness-control/mcp/v3/supervisor.mjs rename to plugins/codex-co-engineer/mcp/v3/supervisor.mjs index e5f40cc..893cfe8 100644 --- a/plugins/plumbob-harness-control/mcp/v3/supervisor.mjs +++ b/plugins/codex-co-engineer/mcp/v3/supervisor.mjs @@ -13,6 +13,7 @@ import { createTask, launchReservationActive, listTasks, + projectLiveLastEvent, readRuntimeRecord, readTask, requireTaskId, @@ -20,6 +21,7 @@ import { stateRoot, taskPaths, updateTask, + waitForTaskProgress, writeRuntimeRecord, } from './task-store.mjs'; import { @@ -31,6 +33,7 @@ import { import { inspectProcessBoundary, launchProcessBoundary, + probeProcessBoundary, restoreProcessBoundary, stopProcessBoundary, } from './process-boundary.mjs'; @@ -53,6 +56,16 @@ const PUBLIC_STARTUP_MESSAGES = Object.freeze({ cancelled: 'The task was cancelled before worker startup.', provider_startup_failed: 'Provider startup could not be prepared.', task_launch_busy: 'Another worker already owns this task launch.', + local_boundary_unavailable: 'The local systemd/cgroup process boundary is unavailable.', + systemd_user_manager_unavailable: 'The local systemd user manager is unavailable.', + systemd_user_cgroup_unverifiable: 'The local systemd user-manager cgroup could not be verified.', + systemd_run_unavailable: 'The local systemd-run client is unavailable.', + systemd_too_old: 'The local systemd version is too old.', + cgroup_v2_unavailable: 'The local unified cgroup v2 hierarchy is unavailable.', + linux_required: 'Local providers require Linux.', + posix_uid_required: 'Local providers require a normal Linux user identity.', + boundary_probe_failed: 'The local process boundary could not be checked.', + systemd_run_failed: 'systemd-run could not queue the local worker service.', worker_start_failed: 'The worker failed to start.', }); @@ -115,6 +128,31 @@ async function workerEnvironment(provider, source = process.env) { return env; } +async function localBoundaryReadiness(probe = probeProcessBoundary) { + try { + const boundary = await probe(); + if (boundary && typeof boundary === 'object' && typeof boundary.ready === 'boolean') return boundary; + } catch { + // Return a bounded public result rather than leaking a host command error. + } + return Object.freeze({ + ready: false, + status: 'unavailable', + reason: 'boundary_probe_failed', + action: 'Inspect the local systemd user-manager and unified cgroup v2 prerequisites.', + provider_started: false, + }); +} + +function requireLocalBoundary(boundary) { + if (boundary.ready) return boundary; + const error = new SupervisorError( + typeof boundary.reason === 'string' ? boundary.reason : 'local_boundary_unavailable', + 'The local process boundary is unavailable.', + ); + throw publicStartupError(error, 'local_boundary_unavailable'); +} + function parseJsonSuffix(stdout) { const text = String(stdout ?? '').trim(); for (let index = text.lastIndexOf('{'); index >= 0; index = text.lastIndexOf('{', index - 1)) { @@ -414,6 +452,9 @@ export async function submitTask(input, dependencies = {}) { if (error instanceof SupervisorError) throw error; if (error?.code !== 'ENOENT') throw error; } + if (input.provider !== 'cursor-cloud') { + requireLocalBoundary(await localBoundaryReadiness(dependencies.probeBoundary)); + } let launchEnv; try { launchEnv = await workerEnvironment(input.provider, dependencies.env ?? process.env); @@ -702,14 +743,30 @@ export async function cancelTask(root, taskId, dependencies = {}) { return updateTask(root, taskId, { status: 'cancelled', finished_at: new Date().toISOString() }); } -export async function taskStatus(root, taskId) { +export async function taskStatus(root, taskId, options = {}) { const { task: initialTask } = await readTask(root, taskId); const runtime = taskRuntime(await readRuntimeRecord(root, taskId), initialTask); - const task = await reconcileInactiveTask(root, initialTask, runtime); - return { task, runtime }; + const reconciled = await reconcileInactiveTask(root, initialTask, runtime); + const waited = await waitForTaskProgress(root, taskId, { + cursor: options.cursor, + wait_ms: options.wait_ms, + }); + const latestRuntime = taskRuntime(await readRuntimeRecord(root, taskId), waited.task); + const task = await projectLiveLastEvent( + root, + await reconcileInactiveTask(root, waited.task, latestRuntime), + ); + return { + task, + runtime: latestRuntime, + progress: { + ...waited.progress, + last_event: task.last_event ?? waited.progress.last_event, + }, + }; } -export async function supervisorStatus(root = stateRoot()) { +export async function supervisorStatus(root = stateRoot(), dependencies = {}) { const tasks = await listTasks(root); for (let index = 0; index < tasks.length; index += 1) { const task = tasks[index]; @@ -717,12 +774,22 @@ export async function supervisorStatus(root = stateRoot()) { const runtime = taskRuntime(await readRuntimeRecord(root, task.id), task); tasks[index] = await reconcileInactiveTask(root, task, runtime); } + const boundary = await localBoundaryReadiness(dependencies.probeBoundary); + const readiness = await (dependencies.readProviderReadiness ?? providerReadiness)(); + for (const provider of ['grok', 'cursor-local', 'dsh']) { + if (!boundary.ready) readiness[provider] = { + ...readiness[provider], + ready: false, + reason: boundary.reason ?? 'local_boundary_unavailable', + }; + } return { - version: '3.0.0', - healthy: true, + version: '3.0.2', + healthy: boundary.ready, active: tasks.filter((task) => ACTIVE.has(task.status)).length, providers: ['grok', 'cursor-local', 'dsh', 'cursor-cloud'], - readiness: await providerReadiness(), - tasks: tasks.slice(0, 20), + local_boundary: boundary, + readiness, + tasks: await Promise.all(tasks.slice(0, 20).map((task) => projectLiveLastEvent(root, task))), }; } diff --git a/plugins/codex-co-engineer/mcp/v3/task-store.mjs b/plugins/codex-co-engineer/mcp/v3/task-store.mjs new file mode 100644 index 0000000..351ca84 --- /dev/null +++ b/plugins/codex-co-engineer/mcp/v3/task-store.mjs @@ -0,0 +1,806 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { watch as watchDirectory } from 'node:fs'; +import { appendFile, chmod, mkdir, open, readFile, readdir, rename, stat, unlink, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import path from 'node:path'; + +export const TASK_SCHEMA = 'codex-co-engineer.task.v1'; +export const LAUNCH_RESERVATION_GRACE_MS = 15_000; +export const MAX_TASK_WAIT_MS = 60_000; +export const TEXT_DELTA_COALESCE_MS = 400; +export const TASK_WAIT_WATCH_FALLBACK_MS = 1_000; +export const MAX_EVENT_READ_BYTES = 64 * 1024; +export const EVENT_TAIL_PEEK_BYTES = 16 * 1024; +export const EVENT_CURSOR_PATTERN = /^[0-9]{1,16}$/u; +const TASK_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/u; +const TERMINAL = new Set(['completed', 'failed', 'cancelled', 'timeout']); +const UPDATE_LOCK_STALE_MS = 2_000; +const LOCAL_UPDATE_TAILS = new Map(); +const OVERSIZE_EVENT_SCAN_BYTES = 4 * 1024; +const TEXT_DELTA_TYPES = new Set(['text_delta', 'thought_delta', 'message_delta', 'output_text_delta']); +const MAX_PUBLIC_EVENT_TEXT = 4 * 1024; +const MAX_PUBLIC_EVENT_KEYS = 24; +const MAX_PUBLIC_EVENT_DEPTH = 4; +const OMIT_PUBLIC_EVENT_KEYS = new Set([ + 'pid', 'ppid', 'process_group', 'provider_process_group', 'provider_process_start_ticks', + 'argv', 'agent_argv', 'cli_argv', 'command', 'rawinput', 'rawoutput', 'content', + 'availablecommands', 'home', 'env', 'stderr', 'stdout', 'prompt', +]); +const SENSITIVE_PUBLIC_EVENT_KEY = /(?:api[_-]?key|authorization|access[_-]?token|refresh[_-]?token|bearer|token|password|secret|cookie|credential|private[_-]?key)/iu; + +function validLaunchReservation(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + if (typeof value.token !== 'string' || !/^[0-9a-f-]{36}$/iu.test(value.token)) return false; + return typeof value.expires_at === 'string' && Number.isFinite(Date.parse(value.expires_at)); +} + +export function createLaunchReservation({ now = Date.now(), graceMs = LAUNCH_RESERVATION_GRACE_MS } = {}) { + if (!Number.isFinite(now) || !Number.isFinite(graceMs) || graceMs < 1_000 || graceMs > 5 * 60_000) { + throw Object.assign(new Error('Launch reservation timing is invalid.'), { code: 'invalid_launch_reservation' }); + } + return Object.freeze({ + token: randomUUID(), + expires_at: new Date(now + graceMs).toISOString(), + }); +} + +export function launchReservationActive(task, now = Date.now()) { + const reservation = task?.launch_reservation; + return validLaunchReservation(reservation) && Date.parse(reservation.expires_at) > now; +} + +export function requireTaskId(value) { + if (typeof value !== 'string' || !TASK_ID.test(value)) { + throw Object.assign(new Error('task_id must be 1-80 safe characters.'), { code: 'invalid_task_id' }); + } + return value; +} + +export function stateRoot(env = process.env) { + if (env.CODEX_CO_ENGINEER_STATE_DIR) { + if (!path.isAbsolute(env.CODEX_CO_ENGINEER_STATE_DIR)) { + throw Object.assign(new Error('CODEX_CO_ENGINEER_STATE_DIR must be absolute.'), { code: 'invalid_state_dir' }); + } + return path.resolve(env.CODEX_CO_ENGINEER_STATE_DIR); + } + const base = env.XDG_STATE_HOME + ? path.resolve(env.XDG_STATE_HOME) + : path.join(env.HOME ? path.resolve(env.HOME) : homedir(), '.local', 'state'); + return path.join(base, 'codex-co-engineer'); +} + +export function taskPaths(root, taskId) { + const id = requireTaskId(taskId); + const directory = path.join(path.resolve(root), 'tasks', id); + return { + directory, + record: path.join(directory, 'task.json'), + prompt: path.join(directory, 'prompt.txt'), + events: path.join(directory, 'events.jsonl'), + request: path.join(directory, 'worker-request.json'), + runtime: path.join(directory, 'runtime.json'), + log: path.join(directory, 'worker.log'), + updateLock: path.join(directory, 'update.lock'), + }; +} + +async function prepareDirectory(directory) { + await mkdir(directory, { recursive: true, mode: 0o700 }); + await chmod(directory, 0o700); +} + +async function writeExclusive(file, value) { + const handle = await open(file, 'wx', 0o600); + try { + await handle.writeFile(value, 'utf8'); + await handle.sync(); + } finally { + await handle.close(); + } +} + +async function writeAtomic(file, value) { + const temporary = `${file}.tmp-${process.pid}-${randomUUID()}`; + await writeFile(temporary, value, { encoding: 'utf8', mode: 0o600, flag: 'wx' }); + await chmod(temporary, 0o600); + await rename(temporary, file); +} + +function normalizeRecord(record) { + if (!record || typeof record !== 'object' || Array.isArray(record)) { + throw Object.assign(new Error('Task record is invalid.'), { code: 'invalid_task_record' }); + } + if (record.schema !== TASK_SCHEMA || record.id !== requireTaskId(record.id)) { + throw Object.assign(new Error('Task record identity is invalid.'), { code: 'invalid_task_record' }); + } + return record; +} + +export async function createTask({ root = stateRoot(), prompt, record }) { + if (typeof prompt !== 'string' || prompt.trim().length === 0) { + throw Object.assign(new Error('prompt must be non-empty text.'), { code: 'invalid_prompt' }); + } + const now = new Date().toISOString(); + const id = requireTaskId(record.id); + const paths = taskPaths(root, id); + await prepareDirectory(path.resolve(root)); + await prepareDirectory(path.dirname(paths.directory)); + await mkdir(paths.directory, { mode: 0o700 }); + await chmod(paths.directory, 0o700); + const task = normalizeRecord({ + ...record, + schema: TASK_SCHEMA, + id, + prompt_sha256: createHash('sha256').update(prompt).digest('hex'), + created_at: record.created_at ?? now, + updated_at: now, + revision: 1, + }); + await writeExclusive(paths.prompt, prompt); + await writeExclusive(paths.events, ''); + await writeExclusive(paths.record, `${JSON.stringify(task, null, 2)}\n`); + return { task, paths }; +} + +export async function readTask(root, taskId) { + const paths = taskPaths(root, taskId); + const record = normalizeRecord(JSON.parse(await readFile(paths.record, 'utf8'))); + return { task: record, paths }; +} + +export async function readPrompt(root, taskId) { + const { paths } = await readTask(root, taskId); + return readFile(paths.prompt, 'utf8'); +} + +async function liveLockOwner(lockFile) { + let raw; + let metadata; + try { + [raw, metadata] = await Promise.all([readFile(lockFile, 'utf8'), stat(lockFile)]); + } catch (error) { + if (error?.code === 'ENOENT') return false; + return true; + } + try { + const value = JSON.parse(raw); + if (!Number.isInteger(value.pid) || typeof value.start_ticks !== 'string') { + return Date.now() - metadata.mtimeMs < UPDATE_LOCK_STALE_MS; + } + try { + const proc = await readFile(`/proc/${value.pid}/stat`, 'utf8'); + const ticks = proc.slice(proc.lastIndexOf(')') + 2).trim().split(/\s+/u)[19] ?? null; + return ticks === value.start_ticks; + } catch { + return false; + } + } catch { + return Date.now() - metadata.mtimeMs < UPDATE_LOCK_STALE_MS; + } +} + +async function acquireUpdateLock(lockFile) { + const nonce = randomUUID(); + for (let attempt = 0; attempt < 200; attempt += 1) { + try { + const handle = await open(lockFile, 'wx', 0o600); + try { + let startTicks = null; + try { + const proc = await readFile(`/proc/${process.pid}/stat`, 'utf8'); + startTicks = proc.slice(proc.lastIndexOf(')') + 2).trim().split(/\s+/u)[19] ?? null; + } catch { + // The age lease below remains a safe fallback on non-/proc hosts. + } + await handle.writeFile(`${JSON.stringify({ pid: process.pid, start_ticks: startTicks, nonce })}\n`, 'utf8'); + await handle.sync(); + } catch (error) { + await handle.close().catch(() => {}); + await unlink(lockFile).catch(() => {}); + throw error; + } + return { handle, nonce }; + } catch (error) { + if (error?.code !== 'EEXIST') throw error; + if (!(await liveLockOwner(lockFile))) { + await unlink(lockFile).catch((unlinkError) => { + if (unlinkError?.code !== 'ENOENT') throw unlinkError; + }); + continue; + } + if (attempt === 199) { + throw Object.assign(new Error('Timed out waiting for task update lock.'), { code: 'task_update_busy' }); + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + } + throw Object.assign(new Error('Timed out waiting for task update lock.'), { code: 'task_update_busy' }); +} + +export async function updateTask(root, taskId, changes) { + const paths = taskPaths(root, taskId); + let releaseLocal; + const localGate = new Promise((resolve) => { releaseLocal = resolve; }); + const previousLocal = LOCAL_UPDATE_TAILS.get(paths.record) ?? Promise.resolve(); + const localTail = previousLocal.then(() => localGate, () => localGate); + LOCAL_UPDATE_TAILS.set(paths.record, localTail); + await previousLocal.catch(() => {}); + try { + return await updateTaskWithFileLock(root, paths, taskId, changes); + } finally { + releaseLocal(); + if (LOCAL_UPDATE_TAILS.get(paths.record) === localTail) LOCAL_UPDATE_TAILS.delete(paths.record); + } +} + +export async function reserveTaskLaunch(root, taskId, reservation = createLaunchReservation()) { + if (!validLaunchReservation(reservation)) { + throw Object.assign(new Error('Launch reservation is invalid.'), { code: 'invalid_launch_reservation' }); + } + const task = await updateTask(root, taskId, (current) => { + if (current.status !== 'accepted') return current; + if (launchReservationActive(current) && current.launch_reservation.token !== reservation.token) return current; + return { launch_reservation: reservation }; + }); + if (task.launch_reservation?.token !== reservation.token) { + throw Object.assign(new Error('Another worker already owns the task launch reservation.'), { code: 'task_launch_busy' }); + } + return reservation; +} + +export async function clearTaskLaunchReservation(root, taskId, token) { + return updateTask(root, taskId, (current) => { + if (!current.launch_reservation) return current; + if (token !== undefined && current.launch_reservation.token !== token) return current; + return { launch_reservation: null }; + }); +} + +async function updateTaskWithFileLock(root, paths, taskId, changes) { + const lock = await acquireUpdateLock(paths.updateLock); + try { + const { task } = await readTask(root, taskId); + const nextChanges = typeof changes === 'function' ? await changes({ ...task }) : changes; + if (!nextChanges || typeof nextChanges !== 'object' || Array.isArray(nextChanges)) { + throw new TypeError('Task update must be an object.'); + } + if (TERMINAL.has(task.status) && nextChanges.status && nextChanges.status !== task.status) return task; + if (task.status === 'cancelling' && nextChanges.status + && !['cancelling', 'cancelled', 'transport_lost'].includes(nextChanges.status)) { + return task; + } + const next = normalizeRecord({ + ...task, + ...nextChanges, + schema: TASK_SCHEMA, + id: task.id, + created_at: task.created_at, + updated_at: new Date().toISOString(), + revision: task.revision + 1, + }); + await writeAtomic(paths.record, `${JSON.stringify(next, null, 2)}\n`); + return next; + } finally { + await lock.handle.close().catch(() => {}); + try { + const current = JSON.parse(await readFile(paths.updateLock, 'utf8')); + if (current.nonce === lock.nonce) await unlink(paths.updateLock); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + } +} + +export async function appendTaskEvent(root, taskId, event) { + const { paths } = await readTask(root, taskId); + const entry = { + at: new Date().toISOString(), + ...event, + }; + await appendFile(paths.events, `${JSON.stringify(entry)}\n`, { encoding: 'utf8', mode: 0o600 }); + return entry; +} + +function plainObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +export function parseEventCursor(value) { + if (value === undefined || value === null || value === '') return null; + if (typeof value !== 'string' || !EVENT_CURSOR_PATTERN.test(value)) { + throw Object.assign(new Error('cursor must be a decimal event-log byte offset.'), { code: 'invalid_event_cursor' }); + } + const offset = Number(value); + if (!Number.isSafeInteger(offset) || offset < 0) { + throw Object.assign(new Error('cursor must be a decimal event-log byte offset.'), { code: 'invalid_event_cursor' }); + } + return offset; +} + +export function parseTaskWaitMs(value) { + if (value === undefined || value === null) return 0; + if (!Number.isInteger(value) || value < 0 || value > MAX_TASK_WAIT_MS) { + throw Object.assign(new Error(`wait_ms must be an integer from 0 to ${MAX_TASK_WAIT_MS}.`), { code: 'invalid_wait_ms' }); + } + return value; +} + +function sanitizePublicEvent(value, depth = 0) { + if (value === null || typeof value === 'boolean') return value; + if (typeof value === 'number') return Number.isFinite(value) ? value : undefined; + if (typeof value === 'string') return value.slice(0, MAX_PUBLIC_EVENT_TEXT); + if (typeof value !== 'object' || depth >= MAX_PUBLIC_EVENT_DEPTH) return undefined; + if (Array.isArray(value)) { + return value.slice(0, 16).map((entry) => sanitizePublicEvent(entry, depth + 1)).filter((entry) => entry !== undefined); + } + const sanitized = {}; + for (const [key, entry] of Object.entries(value)) { + const normalized = key.toLowerCase(); + if (OMIT_PUBLIC_EVENT_KEYS.has(normalized) || SENSITIVE_PUBLIC_EVENT_KEY.test(key)) continue; + const next = sanitizePublicEvent(entry, depth + 1); + if (next === undefined) continue; + sanitized[key.slice(0, 64)] = next; + if (Object.keys(sanitized).length >= MAX_PUBLIC_EVENT_KEYS) break; + } + return sanitized; +} + +export function publicProgressEvent(entry) { + if (!plainObject(entry)) return null; + const at = typeof entry.at === 'string' ? entry.at : undefined; + const body = entry.type === 'provider' && plainObject(entry.event) ? entry.event : entry; + const sanitized = sanitizePublicEvent(body); + if (!sanitized || typeof sanitized !== 'object' || Array.isArray(sanitized) || Object.keys(sanitized).length === 0) { + return at ? { type: 'status', at } : null; + } + if (at && sanitized.at === undefined) sanitized.at = at; + return sanitized; +} + +async function assertEventCursorBoundary(handle, offset, size) { + if (offset > size) { + throw Object.assign(new Error('cursor is beyond the event log.'), { code: 'invalid_event_cursor' }); + } + if (offset === 0) return; + const boundary = Buffer.alloc(1); + const { bytesRead } = await handle.read(boundary, 0, 1, offset - 1); + if (bytesRead !== 1 || boundary[0] !== 0x0a) { + throw Object.assign(new Error('cursor must land on an event-log line boundary.'), { code: 'invalid_event_cursor' }); + } +} + +function emptyProgressParse() { + return { + completeBytes: 0, + lastEvent: null, + eventCount: 0, + immediateCount: 0, + textDeltaCount: 0, + }; +} + +function eventTypeName(entry) { + if (!plainObject(entry)) return null; + if (entry.type === 'provider' && plainObject(entry.event) && typeof entry.event.type === 'string') { + return entry.event.type; + } + return typeof entry.type === 'string' ? entry.type : null; +} + +export function isTextDeltaEvent(entry) { + const type = eventTypeName(entry); + return type != null && TEXT_DELTA_TYPES.has(type); +} + +export function isImmediateProgressEvent(entry) { + return plainObject(entry) && !isTextDeltaEvent(entry); +} + +function parseCompleteEventLines(buffer) { + const lastNewline = buffer.lastIndexOf(0x0a); + if (lastNewline === -1) return emptyProgressParse(); + const complete = buffer.subarray(0, lastNewline + 1).toString('utf8'); + let lastEvent = null; + let eventCount = 0; + let immediateCount = 0; + let textDeltaCount = 0; + for (const line of complete.split('\n')) { + if (!line) continue; + eventCount += 1; + try { + lastEvent = JSON.parse(line); + if (isTextDeltaEvent(lastEvent)) textDeltaCount += 1; + else immediateCount += 1; + } catch { + // A corrupt complete line is skipped for projection but still consumed + // so waiters cannot get stuck on it. + } + } + return { completeBytes: lastNewline + 1, lastEvent, eventCount, immediateCount, textDeltaCount }; +} + +async function skipOversizedEvent(handle, start, size) { + const window = Buffer.alloc(OVERSIZE_EVENT_SCAN_BYTES); + let offset = start; + while (offset < size) { + const length = Math.min(window.length, size - offset); + const { bytesRead } = await handle.read(window, 0, length, offset); + if (bytesRead === 0) break; + const newline = window.subarray(0, bytesRead).indexOf(0x0a); + if (newline !== -1) { + return { + completeBytes: (offset - start) + newline + 1, + lastEvent: { type: 'status', truncated: true }, + eventCount: 1, + immediateCount: 1, + textDeltaCount: 0, + }; + } + offset += bytesRead; + } + return emptyProgressParse(); +} + +function progressSnapshot(start, skipped, parsed, size, requested, { hitBudget = false } = {}) { + const consumed = start + skipped + parsed.completeBytes; + return { + event_cursor: String(consumed), + new_event_count: requested === null ? 0 : parsed.eventCount, + last_event: publicProgressEvent(parsed.lastEvent), + more_events: requested !== null && hitBudget && consumed < size, + immediate_event_count: requested === null ? 0 : parsed.immediateCount, + text_delta_count: requested === null ? 0 : parsed.textDeltaCount, + }; +} + +export async function readTaskEventProgress(root, taskId, { cursor } = {}) { + const { paths } = await readTask(root, taskId); + const requested = parseEventCursor(cursor); + const handle = await open(paths.events, 'r'); + try { + const size = (await handle.stat()).size; + if (requested !== null) await assertEventCursorBoundary(handle, requested, size); + const start = requested !== null ? requested : Math.max(0, size - EVENT_TAIL_PEEK_BYTES); + const budget = requested !== null ? MAX_EVENT_READ_BYTES : EVENT_TAIL_PEEK_BYTES; + const length = Math.min(Math.max(0, size - start), budget); + if (length === 0) return progressSnapshot(start, 0, emptyProgressParse(), size, requested); + const buffer = Buffer.alloc(length); + const { bytesRead } = await handle.read(buffer, 0, length, start); + const hitBudget = bytesRead >= budget; + let slice = buffer.subarray(0, bytesRead); + let skipped = 0; + if (requested === null && start > 0) { + const firstNewline = slice.indexOf(0x0a); + if (firstNewline === -1) return progressSnapshot(start, 0, emptyProgressParse(), size, requested); + skipped = firstNewline + 1; + slice = slice.subarray(skipped); + } + let parsed = parseCompleteEventLines(slice); + if (parsed.completeBytes === 0 && requested !== null && hitBudget && start + bytesRead < size) { + parsed = await skipOversizedEvent(handle, start, size); + } + const latestSize = (await handle.stat()).size; + return progressSnapshot(start, skipped, parsed, latestSize, requested, { hitBudget }); + } finally { + await handle.close(); + } +} + +function progressAdvanced(current, baseline) { + return current.event_cursor !== baseline.event_cursor + || current.new_event_count > 0 + || Boolean(current.last_event && !baseline.last_event); +} + +function receiptAdvanced(current, baseline) { + return current.status !== baseline.status || current.revision !== baseline.revision; +} + +function waitWakeReason(task, progress, initialTask, { coalesceFrom, coalesceMs, clock }) { + if (TERMINAL.has(task.status)) return 'terminal'; + if (receiptAdvanced(task, initialTask)) return 'progress'; + if (progress.immediate_event_count > 0) return 'progress'; + if (progress.more_events && progress.new_event_count > 0) return 'progress'; + if (progress.text_delta_count > 0 && coalesceFrom != null && clock() >= coalesceFrom + coalesceMs) { + return 'progress'; + } + return null; +} + +export function waitDelay(milliseconds, signal) { + return new Promise((resolve) => { + if (signal?.aborted) { + resolve('abort'); + return; + } + if (!Number.isFinite(milliseconds) || milliseconds <= 0) { + resolve('timeout'); + return; + } + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + resolve('timeout'); + }, milliseconds); + const onAbort = () => { + clearTimeout(timer); + resolve('abort'); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + }); +} + +function createNotifyGate() { + let pending = null; + let resolve = null; + let token = 0; + return { + notify(reason) { + token += 1; + pending = { token, reason }; + if (resolve) { + const deliver = resolve; + resolve = null; + deliver(pending); + } + }, + take() { + if (pending) { + const value = pending; + pending = null; + return Promise.resolve(value.reason); + } + return new Promise((next) => { + resolve = (value) => { + if (pending && pending.token === value.token) pending = null; + next(value.reason); + }; + }); + }, + detachWaiter() { + resolve = null; + }, + }; +} + +function defaultWatch(directory, listener) { + return watchDirectory(directory, { persistent: true }, listener); +} + +function attachTaskWatcher(directory, watch, notify) { + const watcher = watch(directory, (_eventType, _filename) => { + notify('watch'); + }); + if (typeof watcher?.on === 'function') { + watcher.on('error', () => notify('watch-error')); + } + return watcher; +} + +function closeTaskWatcher(watcher) { + if (!watcher) return; + try { watcher.close(); } catch { /* already closed or unsupported */ } +} + +async function raceWait({ + delay, + remainingMs, + coalesceMs, + fallbackMs, + notifyTake, + signal, +}) { + const local = new AbortController(); + const stop = () => local.abort(); + const linkAbort = () => stop(); + if (signal?.aborted) { + stop(); + return 'abort'; + } + signal?.addEventListener('abort', linkAbort, { once: true }); + try { + const candidates = [ + notifyTake().then((reason) => reason), + delay(remainingMs, local.signal).then((reason) => (reason === 'abort' ? 'abort' : 'timeout')), + ]; + if (coalesceMs != null) { + candidates.push(delay(coalesceMs, local.signal).then((reason) => (reason === 'abort' ? 'abort' : 'coalesce'))); + } + if (fallbackMs != null) { + candidates.push(delay(fallbackMs, local.signal).then((reason) => (reason === 'abort' ? 'abort' : 'fallback'))); + } + return await Promise.race(candidates); + } finally { + signal?.removeEventListener('abort', linkAbort); + stop(); + } +} + +export async function waitForTaskProgress(root, taskId, { + cursor, + wait_ms, + signal, + now = Date.now, + watch = defaultWatch, + delay = waitDelay, + coalesce_ms = TEXT_DELTA_COALESCE_MS, + fallback_ms = TASK_WAIT_WATCH_FALLBACK_MS, +} = {}) { + const waitMs = parseTaskWaitMs(wait_ms); + const coalesceMs = Number.isInteger(coalesce_ms) && coalesce_ms >= 0 && coalesce_ms <= MAX_TASK_WAIT_MS + ? coalesce_ms + : TEXT_DELTA_COALESCE_MS; + const fallbackMs = Number.isInteger(fallback_ms) && fallback_ms >= 1 && fallback_ms <= MAX_TASK_WAIT_MS + ? fallback_ms + : TASK_WAIT_WATCH_FALLBACK_MS; + const started = now(); + const { task: initialTask, paths } = await readTask(root, taskId); + const initialProgress = await readTaskEventProgress(root, taskId, { cursor }); + const snapshot = (task, progress, reason) => ({ + task, + progress: { + event_cursor: progress.event_cursor, + last_event: task.last_event ?? progress.last_event, + new_event_count: progress.new_event_count, + more_events: Boolean(progress.more_events), + waited_ms: Math.max(0, now() - started), + wait_reason: reason, + }, + }); + const terminal = (task) => TERMINAL.has(task.status); + const requestedCursor = parseEventCursor(cursor); + const evaluate = (task, progress, coalesceFrom) => waitWakeReason(task, progress, initialTask, { + coalesceFrom, + coalesceMs, + clock: now, + }); + + if (waitMs === 0) { + return snapshot(initialTask, initialProgress, terminal(initialTask) ? 'terminal' : 'current'); + } + if (terminal(initialTask)) { + return snapshot(initialTask, initialProgress, 'terminal'); + } + if (requestedCursor !== null && initialProgress.immediate_event_count > 0) { + return snapshot(initialTask, initialProgress, 'progress'); + } + if (requestedCursor !== null && initialProgress.more_events && initialProgress.new_event_count > 0) { + return snapshot(initialTask, initialProgress, 'progress'); + } + if (requestedCursor === null && initialProgress.last_event) { + return snapshot(initialTask, initialProgress, 'current'); + } + if (signal?.aborted) { + return snapshot(initialTask, initialProgress, 'timeout'); + } + + let coalesceFrom = requestedCursor !== null && initialProgress.text_delta_count > 0 ? started : null; + const deadline = started + waitMs; + const gate = createNotifyGate(); + let watcher = null; + let watchFailed = false; + let rewatchAttempted = false; + const onAbort = () => gate.notify('abort'); + signal?.addEventListener('abort', onAbort, { once: true }); + + const armWatch = () => { + closeTaskWatcher(watcher); + watcher = null; + try { + watcher = attachTaskWatcher(paths.directory, watch, (reason) => gate.notify(reason)); + if (!watcher || typeof watcher.close !== 'function') { + watchFailed = true; + watcher = null; + } else { + watchFailed = false; + } + } catch { + watchFailed = true; + watcher = null; + } + }; + + try { + armWatch(); + // Snapshot after arming the watcher so an append/rename that raced the + // first read cannot be lost if inotify also missed it. + let currentTask = (await readTask(root, taskId)).task; + let currentProgress = await readTaskEventProgress(root, taskId, { cursor }); + if (currentProgress.text_delta_count > 0 && coalesceFrom == null) coalesceFrom = now(); + let wake = evaluate(currentTask, currentProgress, coalesceFrom); + if (wake) return snapshot(currentTask, currentProgress, wake); + + while (now() < deadline) { + if (signal?.aborted) break; + const remaining = deadline - now(); + if (remaining <= 0) break; + const coalesceRemaining = coalesceFrom == null ? null : Math.max(0, coalesceFrom + coalesceMs - now()); + const reason = await raceWait({ + delay, + remainingMs: remaining, + coalesceMs: coalesceRemaining, + fallbackMs: watchFailed ? Math.min(fallbackMs, remaining) : null, + notifyTake: gate.take, + signal, + }); + gate.detachWaiter(); + if (reason === 'abort') break; + currentTask = (await readTask(root, taskId)).task; + currentProgress = await readTaskEventProgress(root, taskId, { cursor }); + if (currentProgress.text_delta_count > 0 && coalesceFrom == null) coalesceFrom = now(); + wake = evaluate(currentTask, currentProgress, coalesceFrom); + if (wake) return snapshot(currentTask, currentProgress, wake); + if (reason === 'timeout') break; + if (reason === 'watch-error') { + if (!rewatchAttempted) { + rewatchAttempted = true; + armWatch(); + } else { + closeTaskWatcher(watcher); + watcher = null; + watchFailed = true; + } + } + } + + const finalTask = (await readTask(root, taskId)).task; + const finalProgress = await readTaskEventProgress(root, taskId, { cursor }); + if (terminal(finalTask)) return snapshot(finalTask, finalProgress, 'terminal'); + if (receiptAdvanced(finalTask, initialTask) || progressAdvanced(finalProgress, initialProgress)) { + return snapshot(finalTask, finalProgress, 'progress'); + } + return snapshot(finalTask, finalProgress, 'timeout'); + } finally { + signal?.removeEventListener('abort', onAbort); + closeTaskWatcher(watcher); + } +} + +export async function projectLiveLastEvent(root, task) { + if (task?.last_event) return task; + try { + const progress = await readTaskEventProgress(root, task.id); + return progress.last_event ? { ...task, last_event: progress.last_event } : task; + } catch { + return task; + } +} + +export async function writeRuntimeRecord(root, taskId, record) { + const { paths } = await readTask(root, taskId); + if (!record || typeof record !== 'object' || Array.isArray(record)) throw new TypeError('Runtime record must be an object.'); + const value = { task_id: requireTaskId(taskId), ...record, updated_at: new Date().toISOString() }; + await writeAtomic(paths.runtime, `${JSON.stringify(value, null, 2)}\n`); + return value; +} + +export async function readRuntimeRecord(root, taskId) { + const { paths } = await readTask(root, taskId); + try { + return JSON.parse(await readFile(paths.runtime, 'utf8')); + } catch (error) { + if (error?.code === 'ENOENT') return null; + throw error; + } +} + +export async function listTasks(root = stateRoot()) { + const tasksDirectory = path.join(path.resolve(root), 'tasks'); + let entries; + try { + entries = await readdir(tasksDirectory, { withFileTypes: true }); + } catch (error) { + if (error?.code === 'ENOENT') return []; + throw error; + } + const records = []; + for (const entry of entries) { + if (!entry.isDirectory() || !TASK_ID.test(entry.name)) continue; + try { + records.push((await readTask(root, entry.name)).task); + } catch { + // A corrupt record remains on disk for operator inspection but is not + // projected as a valid task. + } + } + return records.sort((left, right) => right.created_at.localeCompare(left.created_at)); +} diff --git a/plugins/plumbob-harness-control/package.json b/plugins/codex-co-engineer/package.json similarity index 91% rename from plugins/plumbob-harness-control/package.json rename to plugins/codex-co-engineer/package.json index b7dfbb6..18ef43d 100644 --- a/plugins/plumbob-harness-control/package.json +++ b/plugins/codex-co-engineer/package.json @@ -1,6 +1,6 @@ { - "name": "plumbob-harness-control", - "version": "3.0.0", + "name": "codex-co-engineer", + "version": "3.0.2", "private": false, "description": "Codex-Co-Engineer: ACP-first delegation to Grok, Cursor, Cursor Cloud, and DeepSeek Harness.", "license": "MIT", diff --git a/plugins/plumbob-harness-control/skills/control-plumbob-agents/SKILL.md b/plugins/codex-co-engineer/skills/control-codex-co-engineer-agents/SKILL.md similarity index 75% rename from plugins/plumbob-harness-control/skills/control-plumbob-agents/SKILL.md rename to plugins/codex-co-engineer/skills/control-codex-co-engineer-agents/SKILL.md index dd7c7a4..7c9b2ae 100644 --- a/plugins/plumbob-harness-control/skills/control-plumbob-agents/SKILL.md +++ b/plugins/codex-co-engineer/skills/control-codex-co-engineer-agents/SKILL.md @@ -1,5 +1,5 @@ --- -name: control-plumbob-agents +name: control-codex-co-engineer-agents description: Delegate review and implementation work to Grok, Cursor Local, Cursor Cloud, or DeepSeek Harness through the Codex-Co-Engineer ACP-first MCP supervisor. Use for parallel coding, review, task monitoring, worktree-isolated or direct local changes, and cancellation. --- @@ -7,11 +7,13 @@ description: Delegate review and implementation work to Grok, Cursor Local, Curs Use the five MCP tools for delegation and lifecycle control. -1. Call `status` when provider or supervisor readiness is unknown. -2. Before local dispatch, ensure the host has Linux, a working - `systemd --user` manager, `systemd-run` 244 or newer, and unified cgroup - v2. `setup:check` checks CLI/worktree dependencies but not this boundary; - release/live acceptance must prove it. +1. Call `status` when provider or supervisor readiness is unknown. Require + `local_boundary.ready: true` before local dispatch; local provider readiness + is forced false when the boundary is unavailable. +2. Local dispatch requires Linux, a working `systemd --user` manager, + `systemd-run` 244 or newer, and unified cgroup v2. `setup:check` checks + CLI/worktree dependencies; `status`, dispatch preflight, and release/live + acceptance validate the boundary from their actual MCP environment. 3. Choose `grok`, `cursor-local`, `cursor-cloud`, or `dsh`. 4. Call `delegate` with a stable task ID, absolute Git root, and clear prompt. 5. Use `role: "review"` for analysis and `role: "implement"` for changes. @@ -26,7 +28,17 @@ Use the five MCP tools for delegation and lifecycle control. reachability before retrying. 8. Set `create_pr` only for Cursor Cloud. Local tasks reject it; Codex decides whether local commits justify a PR after inspecting the handoff. -9. Poll `task` until terminal. Never replay an active or prompt-dispatched +9. Watch with `task`. A bare `task_id` returns the current receipt plus a + compact `progress` snapshot (`event_cursor`, `last_event`, + `new_event_count`, `more_events`, `wait_reason`). + To wait instead of hammering polls, pass the previous `event_cursor` as + `cursor` and a bounded `wait_ms` (0-60000). The call returns on + meaningful progress, terminal state, or timeout. Terminal, status, and + tool-call boundaries wake promptly; text deltas are coalesced so a + `task(wait_ms, cursor)` loop does not churn on every token. Cursor + catch-up reads a bounded event-log chunk and sets `more_events` when + another page remains. Unsolicited stdio callbacks across assistant + turns are not available. Never replay an active or prompt-dispatched task. 10. Use `cancel` for explicit cancellation or verified orphan recovery. 11. Inspect commits, handoff, and receipts before Codex merges anything. diff --git a/plugins/plumbob-harness-control/skills/control-plumbob-agents/agents/openai.yaml b/plugins/codex-co-engineer/skills/control-codex-co-engineer-agents/agents/openai.yaml similarity index 60% rename from plugins/plumbob-harness-control/skills/control-plumbob-agents/agents/openai.yaml rename to plugins/codex-co-engineer/skills/control-codex-co-engineer-agents/agents/openai.yaml index 468950a..64b33d7 100644 --- a/plugins/plumbob-harness-control/skills/control-plumbob-agents/agents/openai.yaml +++ b/plugins/codex-co-engineer/skills/control-codex-co-engineer-agents/agents/openai.yaml @@ -1,11 +1,11 @@ interface: display_name: "Codex-Co-Engineer" short_description: "Delegate work to Grok, Cursor Local/Cloud, and DSH" - default_prompt: "Use $control-plumbob-agents to show status and delegate the requested work to the best peer agent, selecting managed/direct local workspaces or a pushed immutable Cloud SHA as appropriate." + default_prompt: "Use $control-codex-co-engineer-agents to show status and delegate the requested work to the best peer agent, selecting managed/direct local workspaces or a pushed immutable Cloud SHA as appropriate." dependencies: tools: - type: "mcp" - value: "plumbob-harness-control" + value: "codex-co-engineer" description: "ACP-first delegation, workspace, PR, and lifecycle control for Grok, Cursor Local, Cursor Cloud, and DSH; local workers use a Linux systemd/cgroup cleanup boundary" policy: allow_implicit_invocation: true diff --git a/plugins/plumbob-harness-control/test/acpx-fake-agent.mjs b/plugins/codex-co-engineer/test/acpx-fake-agent.mjs similarity index 100% rename from plugins/plumbob-harness-control/test/acpx-fake-agent.mjs rename to plugins/codex-co-engineer/test/acpx-fake-agent.mjs diff --git a/plugins/plumbob-harness-control/test/acpx-runtime.test.mjs b/plugins/codex-co-engineer/test/acpx-runtime.test.mjs similarity index 100% rename from plugins/plumbob-harness-control/test/acpx-runtime.test.mjs rename to plugins/codex-co-engineer/test/acpx-runtime.test.mjs diff --git a/plugins/codex-co-engineer/test/branding.test.mjs b/plugins/codex-co-engineer/test/branding.test.mjs new file mode 100644 index 0000000..4b9c1e0 --- /dev/null +++ b/plugins/codex-co-engineer/test/branding.test.mjs @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +test('plugin presents the Co-Engineer brand with usable icon assets', async () => { + const manifest = JSON.parse( + await readFile(path.join(ROOT, '.codex-plugin', 'plugin.json'), 'utf8'), + ); + + assert.equal(manifest.name, 'codex-co-engineer'); + assert.equal(manifest.version, '3.0.2'); + assert.equal(manifest.interface.displayName, 'Codex-Co-Engineer'); + assert.equal(manifest.interface.developerName, 'Codex-Co-Engineer'); + assert.equal(manifest.interface.composerIcon, './assets/icon.svg'); + assert.equal(manifest.interface.logo, './assets/co-engineer.png'); + + const mcp = JSON.parse(await readFile(path.join(ROOT, '.mcp.json'), 'utf8')); + assert.deepEqual(Object.keys(mcp.mcpServers), ['codex-co-engineer']); + const environment = mcp.mcpServers['codex-co-engineer'].env_vars; + assert.ok(environment.includes('XDG_RUNTIME_DIR')); + assert.ok(environment.includes('DBUS_SESSION_BUS_ADDRESS')); + + const packageJson = JSON.parse(await readFile(path.join(ROOT, 'package.json'), 'utf8')); + assert.equal(packageJson.name, 'codex-co-engineer'); + assert.equal(packageJson.version, '3.0.2'); + + const skill = await readFile( + path.join(ROOT, 'skills', 'control-codex-co-engineer-agents', 'SKILL.md'), + 'utf8', + ); + assert.match(skill, /^name: control-codex-co-engineer-agents$/mu); + assert.match(skill, /wait_ms/u); + assert.match(skill, /event_cursor/u); + assert.match(skill, /Unsolicited stdio\s+callbacks/u); + + const icon = await readFile(path.join(ROOT, 'assets', 'icon.svg'), 'utf8'); + assert.match(icon, /aria-label="Co-Engineer"/); + + const logo = await readFile(path.join(ROOT, 'assets', 'co-engineer.png')); + assert.deepEqual([...logo.subarray(0, 8)], [137, 80, 78, 71, 13, 10, 26, 10]); +}); diff --git a/plugins/plumbob-harness-control/test/fake-acpx.mjs b/plugins/codex-co-engineer/test/fake-acpx.mjs similarity index 100% rename from plugins/plumbob-harness-control/test/fake-acpx.mjs rename to plugins/codex-co-engineer/test/fake-acpx.mjs diff --git a/plugins/plumbob-harness-control/test/setup.test.mjs b/plugins/codex-co-engineer/test/setup.test.mjs similarity index 100% rename from plugins/plumbob-harness-control/test/setup.test.mjs rename to plugins/codex-co-engineer/test/setup.test.mjs diff --git a/plugins/plumbob-harness-control/test/v3-acp-worker.test.mjs b/plugins/codex-co-engineer/test/v3-acp-worker.test.mjs similarity index 100% rename from plugins/plumbob-harness-control/test/v3-acp-worker.test.mjs rename to plugins/codex-co-engineer/test/v3-acp-worker.test.mjs diff --git a/plugins/plumbob-harness-control/test/v3-cursor-cloud-worker.test.mjs b/plugins/codex-co-engineer/test/v3-cursor-cloud-worker.test.mjs similarity index 97% rename from plugins/plumbob-harness-control/test/v3-cursor-cloud-worker.test.mjs rename to plugins/codex-co-engineer/test/v3-cursor-cloud-worker.test.mjs index 14e8ba1..545208d 100644 --- a/plugins/plumbob-harness-control/test/v3-cursor-cloud-worker.test.mjs +++ b/plugins/codex-co-engineer/test/v3-cursor-cloud-worker.test.mjs @@ -59,6 +59,27 @@ test('uses stable Cursor agent/run idempotency and records returned PR', async ( assert.equal(observed.archived, observed.create.agentId); }); +test('uses the configured origin instead of an insteadOf credential rewrite', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'co-engineer-cursor-origin-config-')); + const repo = await createCloudRepo(root); + await run('git', [ + '-C', repo, 'config', + 'url.https://x-access-token:ghs_testtoken12345678@github.com/.insteadOf', + 'https://github.com/', + ]); + await createTask({ root, prompt: 'implement it', record: { + id: 'cloud-origin-config', status: 'accepted', provider: 'cursor-cloud', role: 'implement', cwd: repo, + } }); + const sdk = { Agent: { archive: async () => {}, create: async () => ({ + send: async () => ({ id: 'run-origin', wait: async () => ({ id: 'run-origin', status: 'finished', result: 'done' }) }), + close() {}, + }) } }; + const terminal = await runCursorCloudTask({ root, taskId: 'cloud-origin-config', sdk, apiKey: 'test-key' }); + assert.equal(terminal.status, 'completed'); + assert.equal(terminal.provider_repo_url, 'https://github.com/example/repo.git'); + assert.doesNotMatch(JSON.stringify(terminal), /ghs_testtoken|x-access-token/u); +}); + test('rejects an initial run with a mismatched request identity', async () => { const root = await mkdtemp(path.join(tmpdir(), 'co-engineer-cursor-request-mismatch-')); const repo = await createCloudRepo(root); diff --git a/plugins/plumbob-harness-control/test/v3-process-boundary.test.mjs b/plugins/codex-co-engineer/test/v3-process-boundary.test.mjs similarity index 86% rename from plugins/plumbob-harness-control/test/v3-process-boundary.test.mjs rename to plugins/codex-co-engineer/test/v3-process-boundary.test.mjs index 80824e7..19fc6ed 100644 --- a/plugins/plumbob-harness-control/test/v3-process-boundary.test.mjs +++ b/plugins/codex-co-engineer/test/v3-process-boundary.test.mjs @@ -24,10 +24,16 @@ function receipt(overrides = {}) { }; } -function fakeChild() { +function fakeChild(exitCode = 0) { const child = new EventEmitter(); + child.exitCode = null; + child.signalCode = null; child.kill = () => { child.emit('exit', null, 'SIGTERM'); }; - queueMicrotask(() => child.emit('spawn')); + queueMicrotask(() => { + child.emit('spawn'); + child.exitCode = exitCode; + child.emit('exit', exitCode, null); + }); return child; } @@ -121,6 +127,34 @@ test('launch preserves cwd, full env, stdio, and provider command while verifyin assert.equal(calls[0].args.includes('--property=StandardOutput=append:/state/task.log'), true); }); +test('reports a failed systemd-run client before attempting unit ownership verification', async () => { + let inspectCalls = 0; + await assert.rejects( + launchProcessBoundary({ + command: '/usr/bin/node', + args: ['worker.mjs'], + cwd: '/workspace/repo', + env: { HOME: '/home/test-user', PATH: '/bin' }, + stdio: 'ignore', + adapter: { + platform: 'linux', + uid: 1000, + spawn: () => fakeChild(1), + execFile: async () => { + inspectCalls += 1; + return { stdout: 'LoadState=not-found\n' }; + }, + readFile: async () => 'populated 0\n', + sleep: async () => {}, + }, + }), + (error) => error instanceof ProcessBoundaryError && error.code === 'systemd_run_failed', + ); + // One best-effort cleanup inspection is allowed, but the launch loop must + // not repeatedly poll a unit that systemd-run never queued. + assert.ok(inspectCalls <= 1); +}); + test('stop signals all members, escalates only after the owned cgroup stays populated, and is idempotent', async () => { const actions = []; let populated = true; diff --git a/plugins/codex-co-engineer/test/v3-server.test.mjs b/plugins/codex-co-engineer/test/v3-server.test.mjs new file mode 100644 index 0000000..b7ba7c0 --- /dev/null +++ b/plugins/codex-co-engineer/test/v3-server.test.mjs @@ -0,0 +1,166 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import readline from 'node:readline'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { appendTaskEvent, createTask } from '../mcp/v3/task-store.mjs'; + +const SERVER = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'mcp', 'v3', 'server.mjs'); + +async function withServer(callback, environment = process.env) { + const state = await mkdtemp(path.join(tmpdir(), 'co-engineer-v3-server-')); + const child = spawn(process.execPath, ['--no-warnings', SERVER], { + env: { + ...environment, + CODEX_CO_ENGINEER_STATE_DIR: state, + CODEX_CO_ENGINEER_GROK_COMMAND: '/bin/false', + CODEX_CO_ENGINEER_CURSOR_COMMAND: '/bin/false', + CODEX_CO_ENGINEER_DSH_COMMAND: '/bin/false', + CODEX_CO_ENGINEER_ACPX_COMMAND: '/bin/false', + CODEX_CO_ENGINEER_DSH_ACP_COMMAND: 'false', + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + const lines = readline.createInterface({ input: child.stdout, crlfDelay: Infinity }); + const pending = []; + let stderr = ''; + child.stderr.on('data', (chunk) => { stderr = `${stderr}${chunk}`.slice(-4096); }); + const nextValue = () => new Promise((resolve, reject) => { + pending.push({ resolve, reject }); + }); + lines.on('line', (line) => { + const waiter = pending.shift(); + if (waiter) waiter.resolve(JSON.parse(line)); + }); + child.once('error', (error) => { + for (const waiter of pending.splice(0)) waiter.reject(error); + }); + child.once('exit', (code, signal) => { + const error = new Error(`MCP server exited (${code ?? signal}): ${stderr}`); + for (const waiter of pending.splice(0)) waiter.reject(error); + }); + const request = async (message) => { + child.stdin.write(`${JSON.stringify(message)}\n`); + return nextValue(); + }; + try { + return await callback({ state, request }); + } finally { + child.stdin.end(); + child.kill('SIGTERM'); + lines.close(); + await rm(state, { recursive: true, force: true }); + } +} + +async function conversation(messages, environment = process.env) { + return withServer(async ({ request }) => { + const values = []; + for (const message of messages) values.push(await request(message)); + return values; + }, environment); +} + +test('advertises only the thin public tool surface', async () => { + const values = await conversation([ + { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-11-25' } }, + { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }, + ]); + assert.equal(values[0].result.serverInfo.name, 'codex-co-engineer'); + assert.equal(values[0].result.serverInfo.title, 'Codex-Co-Engineer'); + assert.equal(values[0].result.serverInfo.version, '3.0.2'); + assert.deepEqual(values[1].result.tools.map((tool) => tool.name), ['status', 'delegate', 'task', 'tasks', 'cancel']); + const taskTool = values[1].result.tools.find((tool) => tool.name === 'task'); + assert.deepEqual(Object.keys(taskTool.inputSchema.properties), ['task_id', 'wait_ms', 'cursor']); + assert.equal(taskTool.inputSchema.properties.wait_ms.maximum, 60000); + assert.match(taskTool.description, /event_cursor/u); + assert.match(taskTool.description, /Unsolicited stdio callbacks/u); +}); + +test('task returns a compact live snapshot and can wait for the next event', async () => { + await withServer(async ({ state, request }) => { + await createTask({ + root: state, + prompt: 'do not return this prompt', + record: { + id: 'server-wait', + status: 'running', + provider: 'grok', + agent_argv: ['grok', 'agent'], + }, + }); + await appendTaskEvent(state, 'server-wait', { + type: 'provider', + event: { type: 'text_delta', text: 'first-visible', pid: 12, argv: ['secret-argv'] }, + }); + const immediate = await request({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'task', arguments: { task_id: 'server-wait' } }, + }); + const first = immediate.result.structuredContent; + assert.equal(first.task.last_event.text, 'first-visible'); + assert.equal(first.task.agent_argv, undefined); + assert.equal(first.progress.last_event.pid, undefined); + assert.equal(first.progress.wait_reason, 'current'); + assert.doesNotMatch(JSON.stringify(first), /do not return this prompt|secret-argv/u); + + const pending = request({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'task', + arguments: { task_id: 'server-wait', wait_ms: 1000, cursor: first.progress.event_cursor }, + }, + }); + setTimeout(() => { + appendTaskEvent(state, 'server-wait', { + type: 'provider', + event: { type: 'tool_call', title: 'read', text: 'second-visible' }, + }).catch(() => {}); + }, 20); + const waited = (await pending).result.structuredContent; + assert.equal(waited.progress.wait_reason, 'progress'); + assert.equal(waited.progress.last_event.text, 'second-visible'); + assert.ok(waited.progress.event_cursor !== first.progress.event_cursor); + + const invalid = await request({ + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'task', arguments: { task_id: 'server-wait', cursor: 'nope' } }, + }); + assert.equal(invalid.result.isError, true); + assert.equal(invalid.result.structuredContent.error.code, 'invalid_event_cursor'); + }); +}); + +test('status works without starting a daemon or provider', async () => { + const [value] = await conversation([ + { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'status', arguments: {} } }, + ]); + assert.equal( + value.result.structuredContent.healthy, + value.result.structuredContent.local_boundary.ready, + ); + assert.equal(value.result.structuredContent.active, 0); +}); + +test('status fails local providers closed when the MCP environment lacks the user-bus locator', async () => { + const environment = { ...process.env }; + delete environment.XDG_RUNTIME_DIR; + delete environment.DBUS_SESSION_BUS_ADDRESS; + const [value] = await conversation([ + { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'status', arguments: {} } }, + ], environment); + const status = value.result.structuredContent; + assert.equal(status.healthy, false); + assert.equal(status.local_boundary.ready, false); + for (const provider of ['grok', 'cursor-local', 'dsh']) assert.equal(status.readiness[provider].ready, false); +}); diff --git a/plugins/plumbob-harness-control/test/v3-supervisor.test.mjs b/plugins/codex-co-engineer/test/v3-supervisor.test.mjs similarity index 68% rename from plugins/plumbob-harness-control/test/v3-supervisor.test.mjs rename to plugins/codex-co-engineer/test/v3-supervisor.test.mjs index c306944..f1523ee 100644 --- a/plugins/plumbob-harness-control/test/v3-supervisor.test.mjs +++ b/plugins/codex-co-engineer/test/v3-supervisor.test.mjs @@ -5,10 +5,24 @@ import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; -import { cancelTask, cleanupManagedWorkspace, createWriterWorkspace, launchWorker, submitTask, taskStatus } from '../mcp/v3/supervisor.mjs'; -import { createLaunchReservation, createTask, readRuntimeRecord, readTask, updateTask } from '../mcp/v3/task-store.mjs'; +import { + cancelTask, + cleanupManagedWorkspace, + createWriterWorkspace, + launchWorker, + submitTask, + supervisorStatus, + taskStatus, +} from '../mcp/v3/supervisor.mjs'; +import { appendTaskEvent, createLaunchReservation, createTask, readRuntimeRecord, readTask, updateTask } from '../mcp/v3/task-store.mjs'; const SHA = 'a'.repeat(40); +const readyBoundary = async () => ({ + ready: true, + status: 'prerequisites_ready', + provider_started: false, + boundary: 'systemd-user-service-cgroup', +}); test('writer workspace parses noisy pretty JSON and requests a bounded large buffer', async () => { const calls = []; @@ -77,6 +91,7 @@ test('direct local mode uses the caller worktree and does not invoke bootstrap', root, env: {}, execute, + probeBoundary: readyBoundary, launch: async (request) => { launches.push(request); return { pid: 9001, process_group: 9001, process_start_ticks: '1' }; @@ -119,6 +134,62 @@ test('local create_pr and starting_ref are rejected before workspace creation', } }); +test('local boundary failure happens before workspace, task, or prompt creation', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'co-engineer-supervisor-preflight-')); + let createCalls = 0; + try { + await assert.rejects( + submitTask({ task_id: 'no-boundary', provider: 'grok', repo: '/repo', prompt: 'do not persist' }, { + root, + env: {}, + probeBoundary: async () => ({ + ready: false, + status: 'unavailable', + reason: 'systemd_user_manager_unavailable', + provider_started: false, + }), + createWorkspace: async () => { createCalls += 1; }, + }), + (error) => error.code === 'systemd_user_manager_unavailable' + && error.message === 'The local systemd user manager is unavailable.', + ); + assert.equal(createCalls, 0); + await assert.rejects(readTask(root, 'no-boundary'), (error) => error.code === 'ENOENT'); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('status makes boundary health explicit and fails only local providers closed', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'co-engineer-supervisor-status-')); + const providerReadiness = { + grok: { installed: true, ready: true, transport: 'acp' }, + 'cursor-local': { installed: true, ready: true, transport: 'acp' }, + dsh: { installed: true, ready: true, transport: 'acpx' }, + 'cursor-cloud': { installed: true, ready: true, transport: 'cursor-sdk' }, + }; + try { + const status = await supervisorStatus(root, { + probeBoundary: async () => ({ + ready: false, + status: 'unavailable', + reason: 'systemd_user_manager_unavailable', + provider_started: false, + }), + readProviderReadiness: async () => structuredClone(providerReadiness), + }); + assert.equal(status.healthy, false); + assert.equal(status.local_boundary.reason, 'systemd_user_manager_unavailable'); + for (const provider of ['grok', 'cursor-local', 'dsh']) { + assert.equal(status.readiness[provider].ready, false); + assert.equal(status.readiness[provider].reason, 'systemd_user_manager_unavailable'); + } + assert.equal(status.readiness['cursor-cloud'].ready, true); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test('managed launch failure marks the task failed and cleans an abandoned writer lock', async () => { const root = await mkdtemp(path.join(os.tmpdir(), 'co-engineer-supervisor-failure-')); const calls = []; @@ -146,6 +217,7 @@ test('managed launch failure marks the task failed and cleans an abandoned write root, env: {}, execute, + probeBoundary: readyBoundary, createWorkspace: async () => workspace, launch: async () => { throw Object.assign(new Error('worker failed at /home/test-user/private?token=secret'), { code: 'worker_failed' }); }, }), @@ -159,7 +231,7 @@ test('managed launch failure marks the task failed and cleans an abandoned write const task = (await readTask(root, 'launch-fail')).task; assert.equal(task.status, 'failed'); assert.equal(task.error.code, 'worker_failed'); - assert.doesNotMatch(task.error.message, /private|secret|plumbob/iu); + assert.doesNotMatch(task.error.message, /private|secret/iu); assert.deepEqual(calls.at(-1), [ 'worktree-bootstrap', ['lock', 'clean', 'launch-fail', '--repo', '/worktrees/launch-fail', '--policy', 'dead-local', '--lock-id', 'dead-lock'], @@ -236,6 +308,74 @@ test('launch reservation keeps status from declaring a missing runtime during st assert.equal(expired.task.status, 'transport_lost'); assert.equal(expired.task.launch_reservation, null); assert.equal(await readRuntimeRecord(root, 'launch-grace'), null); + assert.equal(expired.progress.wait_reason, 'current'); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('task and status project live last_event from the event log', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'co-engineer-supervisor-progress-')); + try { + await createTask({ + root, + prompt: 'keep this prompt private', + record: { + id: 'live-status', + status: 'running', + provider: 'grok', + agent_argv: ['grok', 'agent', '--always-approve', 'stdio'], + }, + }); + await appendTaskEvent(root, 'live-status', { + type: 'provider', + event: { type: 'text_delta', text: 'reviewing files', pid: 77 }, + }); + const value = await taskStatus(root, 'live-status'); + assert.equal(value.task.last_event.text, 'reviewing files'); + assert.equal(value.task.last_event.pid, undefined); + assert.equal(value.progress.last_event.text, 'reviewing files'); + assert.equal(value.progress.wait_reason, 'current'); + assert.equal((await readTask(root, 'live-status')).task.last_event, undefined); + const status = await supervisorStatus(root, { + probeBoundary: readyBoundary, + readProviderReadiness: async () => ({ + grok: { installed: true, ready: true, transport: 'acp' }, + 'cursor-local': { installed: true, ready: true, transport: 'acp' }, + dsh: { installed: true, ready: true, transport: 'acpx' }, + 'cursor-cloud': { installed: true, ready: true, transport: 'cursor-sdk' }, + }), + }); + assert.equal(status.tasks[0].last_event.text, 'reviewing files'); + assert.doesNotMatch(JSON.stringify(value.progress), /keep this prompt private/u); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('task wait returns when a later event arrives or the task is cancelled', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'co-engineer-supervisor-wait-')); + try { + await createTask({ + root, + prompt: 'wait for cancel', + record: { id: 'wait-cancel', status: 'running', provider: 'grok', cwd: root }, + }); + const baseline = await taskStatus(root, 'wait-cancel'); + const pending = taskStatus(root, 'wait-cancel', { + cursor: baseline.progress.event_cursor, + wait_ms: 1_000, + }); + const cancellation = new Promise((resolve, reject) => { + setTimeout(() => { + cancelTask(root, 'wait-cancel', { + stopBoundary: async () => {}, + }).then(resolve, reject); + }, 20); + }); + const [value] = await Promise.all([pending, cancellation]); + assert.ok(['terminal', 'progress'].includes(value.progress.wait_reason)); + assert.ok(['cancelling', 'cancelled', 'transport_lost'].includes(value.task.status)); } finally { await rm(root, { recursive: true, force: true }); } diff --git a/plugins/codex-co-engineer/test/v3-task-store.test.mjs b/plugins/codex-co-engineer/test/v3-task-store.test.mjs new file mode 100644 index 0000000..0a3184c --- /dev/null +++ b/plugins/codex-co-engineer/test/v3-task-store.test.mjs @@ -0,0 +1,651 @@ +import assert from 'node:assert/strict'; +import { appendFileSync, watch as watchDirectory } from 'node:fs'; +import { appendFile, mkdtemp, open, readFile, stat, utimes, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { + EVENT_TAIL_PEEK_BYTES, + MAX_EVENT_READ_BYTES, + TEXT_DELTA_COALESCE_MS, + appendTaskEvent, + clearTaskLaunchReservation, + createLaunchReservation, + createTask, + isImmediateProgressEvent, + isTextDeltaEvent, + launchReservationActive, + listTasks, + parseEventCursor, + parseTaskWaitMs, + projectLiveLastEvent, + readPrompt, + readRuntimeRecord, + readTask, + readTaskEventProgress, + requireTaskId, + reserveTaskLaunch, + taskPaths, + updateTask, + waitDelay, + waitForTaskProgress, + writeRuntimeRecord, +} from '../mcp/v3/task-store.mjs'; + +async function temporaryRoot() { + return mkdtemp(path.join(tmpdir(), 'co-engineer-task-store-')); +} + +test('task identifiers reject path traversal', () => { + assert.throws(() => requireTaskId('../escape'), /task_id/u); + assert.equal(requireTaskId('agent-42.review'), 'agent-42.review'); +}); + +test('task records and prompts are owner-only and prompt text is not in the record', async () => { + const root = await temporaryRoot(); + const prompt = 'Implement the adapter without leaking this prompt.'; + const { task, paths } = await createTask({ + root, + prompt, + record: { id: 'adapter', provider: 'grok', state: 'queued' }, + }); + + assert.equal(task.schema, 'codex-co-engineer.task.v1'); + assert.equal(await readPrompt(root, 'adapter'), prompt); + assert.equal((await stat(paths.directory)).mode & 0o777, 0o700); + assert.equal((await stat(paths.record)).mode & 0o777, 0o600); + assert.equal((await stat(paths.prompt)).mode & 0o777, 0o600); + assert.doesNotMatch(await readFile(paths.record, 'utf8'), /without leaking/u); +}); + +test('updates preserve identity and monotonically advance revision', async () => { + const root = await temporaryRoot(); + await createTask({ root, prompt: 'test', record: { id: 'run-1', state: 'queued' } }); + const running = await updateTask(root, 'run-1', { state: 'running', pid: 123 }); + const complete = await updateTask(root, 'run-1', (task) => ({ state: 'completed', pid: task.pid })); + + assert.equal(running.revision, 2); + assert.equal(complete.revision, 3); + assert.equal(complete.id, 'run-1'); + assert.equal(complete.pid, 123); +}); + +test('launch reservations are owner-local, bounded, and cannot be stolen while active', async () => { + const root = await temporaryRoot(); + await createTask({ root, prompt: 'reserve', record: { id: 'reserve-one', status: 'accepted' } }); + const now = Date.now(); + const first = await reserveTaskLaunch(root, 'reserve-one', createLaunchReservation({ now })); + const task = (await readTask(root, 'reserve-one')).task; + assert.equal(task.launch_reservation.token, first.token); + assert.equal(launchReservationActive(task, now + 1), true); + assert.equal(launchReservationActive(task, Date.parse(first.expires_at) + 1), false); + await assert.rejects( + reserveTaskLaunch(root, 'reserve-one', createLaunchReservation({ now: now + 1 })), + (error) => error.code === 'task_launch_busy', + ); + await clearTaskLaunchReservation(root, 'reserve-one', first.token); + assert.equal((await readTask(root, 'reserve-one')).task.launch_reservation, null); +}); + +test('concurrent updates serialize and terminal cancellation wins', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'co-engineer-store-race-')); + await createTask({ root, prompt: 'race', record: { id: 'race-one', status: 'running', provider: 'grok', cwd: root } }); + await Promise.all(Array.from({ length: 12 }, (_, index) => updateTask(root, 'race-one', { marker: index }))); + const afterRace = (await readTask(root, 'race-one')).task; + assert.equal(afterRace.revision, 13); + await updateTask(root, 'race-one', { status: 'cancelling' }); + await updateTask(root, 'race-one', { status: 'completed', result: 'late worker result' }); + await updateTask(root, 'race-one', { status: 'cancelled' }); + const terminal = (await readTask(root, 'race-one')).task; + assert.equal(terminal.status, 'cancelled'); + assert.equal(terminal.result, undefined); +}); + +test('a lock left by a dead process is recovered', async () => { + const root = await temporaryRoot(); + const { paths } = await createTask({ root, prompt: 'recover', record: { id: 'stale-lock', status: 'running' } }); + await writeFile(paths.updateLock, `${JSON.stringify({ pid: 999999999, start_ticks: '1', nonce: 'dead' })}\n`, { mode: 0o600 }); + const task = await updateTask(root, 'stale-lock', { recovered: true }); + assert.equal(task.recovered, true); +}); + +test('an old malformed update lock is recovered', async () => { + const root = await temporaryRoot(); + const { paths } = await createTask({ root, prompt: 'recover malformed', record: { id: 'malformed-lock', status: 'running' } }); + await writeFile(paths.updateLock, '{partial', { mode: 0o600 }); + const old = new Date(Date.now() - 10_000); + await utimes(paths.updateLock, old, old); + const task = await updateTask(root, 'malformed-lock', { recovered: true }); + assert.equal(task.recovered, true); +}); + +test('uncertain cancellation can remain reconcilable without accepting a late completion', async () => { + const root = await temporaryRoot(); + await createTask({ root, prompt: 'cancel', record: { id: 'cancel-uncertain', status: 'running' } }); + await updateTask(root, 'cancel-uncertain', { status: 'cancelling' }); + await updateTask(root, 'cancel-uncertain', { status: 'transport_lost', error: { code: 'cancel_unconfirmed' } }); + const uncertain = (await readTask(root, 'cancel-uncertain')).task; + assert.equal(uncertain.status, 'transport_lost'); + assert.equal(uncertain.finished_at, undefined); +}); + +test('events are JSONL and listTasks ignores invalid task directories', async () => { + const root = await temporaryRoot(); + await createTask({ root, prompt: 'one', record: { id: 'one', state: 'queued' } }); + await createTask({ root, prompt: 'two', record: { id: 'two', state: 'queued' } }); + await appendTaskEvent(root, 'one', { type: 'accepted' }); + const paths = taskPaths(root, 'one'); + const event = JSON.parse((await readFile(paths.events, 'utf8')).trim()); + + assert.equal(event.type, 'accepted'); + assert.match(event.at, /^20/u); + assert.deepEqual(new Set((await listTasks(root)).map((task) => task.id)), new Set(['one', 'two'])); + assert.equal((await readTask(root, 'one')).task.state, 'queued'); +}); + +test('event cursors reject non-boundary offsets and wait_ms bounds', () => { + assert.equal(parseEventCursor(undefined), null); + assert.equal(parseEventCursor('12'), 12); + assert.throws(() => parseEventCursor('-1'), (error) => error.code === 'invalid_event_cursor'); + assert.throws(() => parseEventCursor('1e2'), (error) => error.code === 'invalid_event_cursor'); + assert.throws(() => parseEventCursor(4), (error) => error.code === 'invalid_event_cursor'); + assert.equal(parseTaskWaitMs(undefined), 0); + assert.equal(parseTaskWaitMs(25_000), 25_000); + assert.throws(() => parseTaskWaitMs(60_001), (error) => error.code === 'invalid_wait_ms'); + assert.throws(() => parseTaskWaitMs(1.5), (error) => error.code === 'invalid_wait_ms'); +}); + +test('live progress tails events.jsonl without rewriting task.json', async () => { + const root = await temporaryRoot(); + const { task, paths } = await createTask({ + root, + prompt: 'hidden prompt must not leak', + record: { id: 'live-one', status: 'running', provider: 'grok' }, + }); + const first = await appendTaskEvent(root, 'live-one', { + type: 'provider', + event: { type: 'text_delta', text: 'chunk-one', pid: 4321, argv: ['grok', '--secret'] }, + }); + const snapshot = await readTaskEventProgress(root, 'live-one'); + assert.equal((await readTask(root, 'live-one')).task.revision, task.revision); + assert.equal((await readTask(root, 'live-one')).task.last_event, undefined); + assert.equal(snapshot.last_event.type, 'text_delta'); + assert.equal(snapshot.last_event.text, 'chunk-one'); + assert.equal(snapshot.last_event.pid, undefined); + assert.equal(snapshot.last_event.argv, undefined); + assert.doesNotMatch(JSON.stringify(snapshot), /hidden prompt/u); + assert.match(snapshot.event_cursor, /^[0-9]+$/u); + assert.equal(snapshot.new_event_count, 0); + + const delta = await readTaskEventProgress(root, 'live-one', { cursor: '0' }); + assert.equal(delta.new_event_count, 1); + assert.equal(delta.last_event.text, 'chunk-one'); + await appendTaskEvent(root, 'live-one', { + type: 'provider', + event: { type: 'text_delta', text: 'chunk-two', prompt: 'hidden prompt must not leak' }, + }); + const next = await readTaskEventProgress(root, 'live-one', { cursor: snapshot.event_cursor }); + assert.equal(next.new_event_count, 1); + assert.equal(next.last_event.text, 'chunk-two'); + assert.equal(next.last_event.prompt, undefined); + assert.equal(first.type, 'provider'); + assert.equal((await readFile(paths.record, 'utf8')).includes('chunk-two'), false); +}); + +test('partial event lines are not consumed and invalid cursors fail closed', async () => { + const root = await temporaryRoot(); + const { paths } = await createTask({ + root, + prompt: 'partial', + record: { id: 'partial-one', status: 'running' }, + }); + await appendTaskEvent(root, 'partial-one', { type: 'transport', state: 'session_ready' }); + const ready = await readTaskEventProgress(root, 'partial-one'); + await appendFile(paths.events, '{"type":"provider","event":{"type":"text_delta","text":"incomp'); + const midWrite = await readTaskEventProgress(root, 'partial-one', { cursor: ready.event_cursor }); + assert.equal(midWrite.new_event_count, 0); + assert.equal(midWrite.event_cursor, ready.event_cursor); + await appendFile(paths.events, 'lete"}}\n'); + const complete = await readTaskEventProgress(root, 'partial-one', { cursor: ready.event_cursor }); + assert.equal(complete.new_event_count, 1); + assert.equal(complete.last_event.text, 'incomplete'); + await assert.rejects( + readTaskEventProgress(root, 'partial-one', { cursor: String(Number(complete.event_cursor) + 8) }), + (error) => error.code === 'invalid_event_cursor', + ); + await assert.rejects( + readTaskEventProgress(root, 'partial-one', { cursor: '1' }), + (error) => error.code === 'invalid_event_cursor', + ); +}); + +test('wait wakes on appended progress, terminal status, or timeout without leaking internals', async () => { + const root = await temporaryRoot(); + await createTask({ + root, + prompt: 'secret waiter prompt', + record: { id: 'wait-one', status: 'running', provider: 'grok', agent_argv: ['grok', 'agent'] }, + }); + const started = await waitForTaskProgress(root, 'wait-one', { wait_ms: 0 }); + assert.equal(started.progress.wait_reason, 'current'); + assert.equal(started.progress.last_event, null); + + const pending = waitForTaskProgress(root, 'wait-one', { + cursor: started.progress.event_cursor, + wait_ms: 1_000, + }); + setTimeout(() => { + appendTaskEvent(root, 'wait-one', { + type: 'provider', + event: { type: 'tool_call', title: 'read', text: 'live-progress', pid: 99, argv: ['leak'] }, + }).catch(() => {}); + }, 20); + const woke = await pending; + assert.equal(woke.progress.wait_reason, 'progress'); + assert.equal(woke.progress.last_event.type, 'tool_call'); + assert.equal(woke.progress.last_event.text, 'live-progress'); + assert.equal(woke.progress.last_event.pid, undefined); + assert.equal(woke.task.last_event, undefined); + assert.doesNotMatch(JSON.stringify(woke.progress), /secret waiter prompt|"leak"/u); + for (const key of ['event_cursor', 'last_event', 'new_event_count', 'more_events', 'waited_ms', 'wait_reason']) { + assert.ok(key in woke.progress); + } + + const terminalWait = waitForTaskProgress(root, 'wait-one', { + cursor: woke.progress.event_cursor, + wait_ms: 1_000, + }); + setTimeout(() => { + updateTask(root, 'wait-one', { status: 'cancelled', finished_at: new Date().toISOString() }).catch(() => {}); + }, 20); + const cancelled = await terminalWait; + assert.equal(cancelled.progress.wait_reason, 'terminal'); + assert.equal(cancelled.task.status, 'cancelled'); + + const already = await waitForTaskProgress(root, 'wait-one', { wait_ms: 1_000 }); + assert.equal(already.progress.wait_reason, 'terminal'); + assert.ok(already.progress.waited_ms < 200); + + await createTask({ + root, + prompt: 'still running', + record: { id: 'wait-timeout', status: 'running' }, + }); + const idle = await readTaskEventProgress(root, 'wait-timeout'); + const timedOut = await waitForTaskProgress(root, 'wait-timeout', { + cursor: idle.event_cursor, + wait_ms: 40, + }); + assert.equal(timedOut.progress.wait_reason, 'timeout'); + assert.ok(timedOut.progress.waited_ms >= 40); +}); + +test('concurrent readers can wait while another process appends events', async () => { + const root = await temporaryRoot(); + await createTask({ + root, + prompt: 'race wait', + record: { id: 'wait-race', status: 'running' }, + }); + const baseline = await readTaskEventProgress(root, 'wait-race'); + const waiters = Promise.all(Array.from({ length: 4 }, () => waitForTaskProgress(root, 'wait-race', { + cursor: baseline.event_cursor, + wait_ms: 1_000, + }))); + const handle = await open(taskPaths(root, 'wait-race').events, 'a', 0o600); + try { + await handle.appendFile(`${JSON.stringify({ at: new Date().toISOString(), type: 'provider', event: { type: 'tool_call', title: 'read', text: 'shared' } })}\n`); + } finally { + await handle.close(); + } + const values = await waiters; + for (const value of values) { + assert.equal(value.progress.wait_reason, 'progress'); + assert.equal(value.progress.last_event.text, 'shared'); + } +}); + +test('projectLiveLastEvent overlays event-log progress onto a stale receipt', async () => { + const root = await temporaryRoot(); + await createTask({ + root, + prompt: 'overlay', + record: { id: 'overlay-one', status: 'running' }, + }); + await appendTaskEvent(root, 'overlay-one', { type: 'provider', event: { type: 'text_delta', text: 'visible' } }); + const projected = await projectLiveLastEvent(root, (await readTask(root, 'overlay-one')).task); + assert.equal(projected.last_event.text, 'visible'); + assert.equal((await readTask(root, 'overlay-one')).task.last_event, undefined); +}); + +test('runtime identity is stored separately from the task receipt', async () => { + const root = await temporaryRoot(); + await createTask({ root, prompt: 'hello', record: { id: 'runtime', status: 'accepted' } }); + await writeRuntimeRecord(root, 'runtime', { pid: 123, process_start_ticks: '456', process_group: 123 }); + const runtime = await readRuntimeRecord(root, 'runtime'); + assert.equal(runtime.task_id, 'runtime'); + assert.equal(runtime.pid, 123); + assert.equal(runtime.process_start_ticks, '456'); + assert.equal(runtime.process_group, 123); + assert.equal((await readTask(root, 'runtime')).task.pid, undefined); +}); + +function createMockWatch() { + const state = { opened: 0, closed: 0, listener: null, errorHandler: null }; + const watch = (_directory, listener) => { + state.opened += 1; + state.listener = listener; + return { + close() { state.closed += 1; }, + on(event, handler) { + if (event === 'error') state.errorHandler = handler; + return this; + }, + }; + }; + return { watch, state }; +} + +function recordingDelay(delays) { + return (milliseconds, signal) => { + delays.push(milliseconds); + return waitDelay(milliseconds, signal); + }; +} + +test('progress event classes distinguish coalesced text from immediate boundaries', () => { + assert.equal(isTextDeltaEvent({ type: 'provider', event: { type: 'text_delta', text: 'x' } }), true); + assert.equal(isTextDeltaEvent({ type: 'provider', event: { type: 'thought_delta' } }), true); + assert.equal(isImmediateProgressEvent({ type: 'provider', event: { type: 'tool_call', title: 'read' } }), true); + assert.equal(isImmediateProgressEvent({ type: 'terminal', status: 'cancelled' }), true); + assert.equal(isImmediateProgressEvent({ type: 'transport', state: 'session_ready' }), true); + assert.ok(TEXT_DELTA_COALESCE_MS >= 200 && TEXT_DELTA_COALESCE_MS <= 1_000); + assert.ok(EVENT_TAIL_PEEK_BYTES <= MAX_EVENT_READ_BYTES); +}); + +test('wait snapshots stay backward compatible and wait_ms 0 is a non-blocking current view', async () => { + const root = await temporaryRoot(); + await createTask({ root, prompt: 'snapshot', record: { id: 'snap-one', status: 'running' } }); + const snap = await waitForTaskProgress(root, 'snap-one', { wait_ms: 0 }); + assert.equal(snap.progress.wait_reason, 'current'); + assert.match(snap.progress.event_cursor, /^[0-9]+$/u); + assert.equal(snap.progress.last_event, null); + assert.equal(snap.progress.new_event_count, 0); + assert.equal(typeof snap.progress.more_events, 'boolean'); + assert.equal(typeof snap.progress.waited_ms, 'number'); + assert.equal(snap.progress.waited_ms < 50, true); + for (const key of ['event_cursor', 'last_event', 'new_event_count', 'more_events', 'waited_ms', 'wait_reason']) { + assert.ok(Object.hasOwn(snap.progress, key)); + } +}); + +test('idle wait is event-driven and does not busy-poll', async () => { + const root = await temporaryRoot(); + await createTask({ root, prompt: 'idle', record: { id: 'wait-idle', status: 'running' } }); + const baseline = await readTaskEventProgress(root, 'wait-idle'); + const delays = []; + const { watch, state } = createMockWatch(); + const timedOut = await waitForTaskProgress(root, 'wait-idle', { + cursor: baseline.event_cursor, + wait_ms: 80, + watch, + delay: recordingDelay(delays), + }); + assert.equal(timedOut.progress.wait_reason, 'timeout'); + assert.ok(timedOut.progress.waited_ms >= 80); + assert.ok(delays.length <= 2); + assert.ok(delays.some((value) => value >= 70)); + assert.equal(delays.filter((value) => value <= 50).length, 0); + assert.equal(state.opened, 1); + assert.equal(state.closed, state.opened); +}); + +test('filesystem notify wakes immediately on tool-call boundaries and cleans up the watcher', async () => { + const root = await temporaryRoot(); + await createTask({ root, prompt: 'watch', record: { id: 'wait-watch', status: 'running' } }); + const baseline = await readTaskEventProgress(root, 'wait-watch'); + const delays = []; + const { watch, state } = createMockWatch(); + const pending = waitForTaskProgress(root, 'wait-watch', { + cursor: baseline.event_cursor, + wait_ms: 1_000, + watch, + delay: recordingDelay(delays), + coalesce_ms: 400, + }); + await new Promise((resolve) => setTimeout(resolve, 15)); + await appendTaskEvent(root, 'wait-watch', { + type: 'provider', + event: { type: 'tool_call', title: 'read', text: 'boundary' }, + }); + state.listener('change', 'events.jsonl'); + const woke = await pending; + assert.equal(woke.progress.wait_reason, 'progress'); + assert.equal(woke.progress.last_event.type, 'tool_call'); + assert.ok(woke.progress.waited_ms < 200); + assert.ok(delays.length <= 2); + assert.equal(state.closed, state.opened); +}); + +test('after-watch snapshot catches an append that raced watcher arming', async () => { + const root = await temporaryRoot(); + const { paths } = await createTask({ + root, + prompt: 'race arm', + record: { id: 'wait-arm-race', status: 'running' }, + }); + const baseline = await readTaskEventProgress(root, 'wait-arm-race'); + const line = `${JSON.stringify({ + at: new Date().toISOString(), + type: 'provider', + event: { type: 'tool_call', title: 'read', text: 'raced' }, + })}\n`; + const watch = (directory, listener) => { + appendFileSync(paths.events, line); + return watchDirectory(directory, { persistent: true }, listener); + }; + const woke = await waitForTaskProgress(root, 'wait-arm-race', { + cursor: baseline.event_cursor, + wait_ms: 1_000, + watch, + }); + assert.equal(woke.progress.wait_reason, 'progress'); + assert.equal(woke.progress.last_event.text, 'raced'); +}); + +test('status replacement and abort both settle without leaking watchers', async () => { + const root = await temporaryRoot(); + await createTask({ root, prompt: 'status', record: { id: 'wait-status', status: 'running' } }); + const baseline = await readTaskEventProgress(root, 'wait-status'); + const statusWait = waitForTaskProgress(root, 'wait-status', { + cursor: baseline.event_cursor, + wait_ms: 1_000, + }); + setTimeout(() => { + updateTask(root, 'wait-status', { status: 'cancelling' }).catch(() => {}); + }, 15); + const statusWoke = await statusWait; + assert.equal(statusWoke.progress.wait_reason, 'progress'); + assert.equal(statusWoke.task.status, 'cancelling'); + + const { watch, state } = createMockWatch(); + const controller = new AbortController(); + const pending = waitForTaskProgress(root, 'wait-status', { + cursor: statusWoke.progress.event_cursor, + wait_ms: 2_000, + watch, + signal: controller.signal, + }); + await new Promise((resolve) => setTimeout(resolve, 15)); + controller.abort(); + const aborted = await pending; + assert.equal(aborted.progress.wait_reason, 'timeout'); + assert.ok(aborted.progress.waited_ms < 200); + assert.equal(state.closed, state.opened); +}); + +test('watcher errors re-arm once, then fall back without leaking handles', async () => { + const root = await temporaryRoot(); + await createTask({ root, prompt: 'error', record: { id: 'wait-error', status: 'running' } }); + const baseline = await readTaskEventProgress(root, 'wait-error'); + const delays = []; + const { watch, state } = createMockWatch(); + const pending = waitForTaskProgress(root, 'wait-error', { + cursor: baseline.event_cursor, + wait_ms: 90, + watch, + delay: recordingDelay(delays), + fallback_ms: 1_000, + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + state.errorHandler(new Error('watch failed')); + await new Promise((resolve) => setTimeout(resolve, 10)); + state.errorHandler(new Error('watch failed again')); + const timedOut = await pending; + assert.equal(timedOut.progress.wait_reason, 'timeout'); + assert.equal(state.closed, state.opened); + assert.ok(state.opened >= 2); + assert.ok(delays.length <= 4); + assert.equal(delays.filter((value) => value > 0 && value <= 50).length, 0); +}); + +test('text deltas are coalesced while tool-call boundaries preempt the hold', async () => { + const root = await temporaryRoot(); + await createTask({ root, prompt: 'coalesce', record: { id: 'wait-coalesce', status: 'running' } }); + const baseline = await readTaskEventProgress(root, 'wait-coalesce'); + const held = waitForTaskProgress(root, 'wait-coalesce', { + cursor: baseline.event_cursor, + wait_ms: 1_000, + coalesce_ms: 70, + }); + await appendTaskEvent(root, 'wait-coalesce', { + type: 'provider', + event: { type: 'text_delta', text: 'delta-one' }, + }); + await appendTaskEvent(root, 'wait-coalesce', { + type: 'provider', + event: { type: 'text_delta', text: 'delta-two' }, + }); + const coalesced = await held; + assert.equal(coalesced.progress.wait_reason, 'progress'); + assert.equal(coalesced.progress.last_event.text, 'delta-two'); + assert.ok(coalesced.progress.new_event_count >= 2); + assert.ok(coalesced.progress.waited_ms >= 60); + + const already = await waitForTaskProgress(root, 'wait-coalesce', { + cursor: baseline.event_cursor, + wait_ms: 500, + coalesce_ms: 50, + }); + assert.equal(already.progress.wait_reason, 'progress'); + assert.ok(already.progress.waited_ms >= 40); + + const pre = await readTaskEventProgress(root, 'wait-coalesce'); + const preempt = waitForTaskProgress(root, 'wait-coalesce', { + cursor: pre.event_cursor, + wait_ms: 1_000, + coalesce_ms: 400, + }); + await appendTaskEvent(root, 'wait-coalesce', { + type: 'provider', + event: { type: 'text_delta', text: 'still-holding' }, + }); + await appendTaskEvent(root, 'wait-coalesce', { + type: 'provider', + event: { type: 'tool_call', title: 'edit', text: 'boundary' }, + }); + const woke = await preempt; + assert.equal(woke.progress.wait_reason, 'progress'); + assert.equal(woke.progress.last_event.type, 'tool_call'); + assert.ok(woke.progress.waited_ms < 200); +}); + +test('large event logs page with bounded reads and skip oversized lines', async () => { + const root = await temporaryRoot(); + const { paths } = await createTask({ + root, + prompt: 'large', + record: { id: 'big-log', status: 'running' }, + }); + const line = `${JSON.stringify({ + at: '2026-01-01T00:00:00.000Z', + type: 'provider', + event: { type: 'text_delta', text: 'n'.repeat(64) }, + })}\n`; + const handle = await open(paths.events, 'a'); + const block = line.repeat(256); + try { + for (let index = 0; index < 120; index += 1) await handle.appendFile(block); + } finally { + await handle.close(); + } + const bytes = (await stat(paths.events)).size; + assert.ok(bytes > 2 * 1024 * 1024); + + const first = await readTaskEventProgress(root, 'big-log', { cursor: '0' }); + assert.equal(first.more_events, true); + assert.ok(first.new_event_count > 0); + assert.ok(Number(first.event_cursor) > 0); + assert.ok(Number(first.event_cursor) <= MAX_EVENT_READ_BYTES); + + let cursor = first.event_cursor; + let pages = 1; + let events = first.new_event_count; + while (true) { + const next = await readTaskEventProgress(root, 'big-log', { cursor }); + assert.ok(Number(next.event_cursor) - Number(cursor) <= MAX_EVENT_READ_BYTES); + events += next.new_event_count; + cursor = next.event_cursor; + pages += 1; + if (!next.more_events) break; + assert.ok(pages < 500); + } + assert.ok(pages > 10); + assert.ok(events > 10_000); + assert.equal(Number(cursor), bytes); + + const paged = await waitForTaskProgress(root, 'big-log', { + cursor: '0', + wait_ms: 5_000, + coalesce_ms: 400, + }); + assert.equal(paged.progress.wait_reason, 'progress'); + assert.equal(paged.progress.more_events, true); + assert.ok(paged.progress.waited_ms < 200); + assert.ok(Number(paged.progress.event_cursor) <= MAX_EVENT_READ_BYTES); + + await createTask({ root, prompt: 'oversize', record: { id: 'oversize-one', status: 'running' } }); + const oversizePaths = taskPaths(root, 'oversize-one'); + const start = await readTaskEventProgress(root, 'oversize-one'); + await appendFile(oversizePaths.events, `{"type":"provider","event":{"type":"text_delta","text":"${'Z'.repeat(200_000)}"}}\n`); + await appendTaskEvent(root, 'oversize-one', { type: 'terminal', status: 'completed' }); + const skipped = await readTaskEventProgress(root, 'oversize-one', { cursor: start.event_cursor }); + assert.equal(skipped.last_event.type, 'status'); + assert.equal(skipped.last_event.truncated, true); + assert.equal(skipped.new_event_count, 1); + assert.ok(Number(skipped.event_cursor) - Number(start.event_cursor) > MAX_EVENT_READ_BYTES); + const rest = await readTaskEventProgress(root, 'oversize-one', { cursor: skipped.event_cursor }); + assert.equal(rest.last_event.type, 'terminal'); + assert.equal(rest.more_events, false); +}); + +test('cursor catch-up stays on line boundaries and rejects unsafe offsets', async () => { + const root = await temporaryRoot(); + const { paths } = await createTask({ + root, + prompt: 'cursor', + record: { id: 'cursor-bound', status: 'running' }, + }); + await appendTaskEvent(root, 'cursor-bound', { type: 'accepted' }); + const first = await readTaskEventProgress(root, 'cursor-bound', { cursor: '0' }); + const atEnd = await readTaskEventProgress(root, 'cursor-bound', { cursor: first.event_cursor }); + assert.equal(atEnd.new_event_count, 0); + assert.equal(atEnd.more_events, false); + assert.equal(atEnd.event_cursor, first.event_cursor); + await assert.rejects( + readTaskEventProgress(root, 'cursor-bound', { cursor: String((await stat(paths.events)).size + 1) }), + (error) => error.code === 'invalid_event_cursor', + ); + assert.throws(() => parseEventCursor('9007199254740993'), (error) => error.code === 'invalid_event_cursor'); +}); diff --git a/plugins/plumbob-harness-control/vendor/dsh-acp-demo/LICENSE b/plugins/codex-co-engineer/vendor/dsh-acp-demo/LICENSE similarity index 100% rename from plugins/plumbob-harness-control/vendor/dsh-acp-demo/LICENSE rename to plugins/codex-co-engineer/vendor/dsh-acp-demo/LICENSE diff --git a/plugins/plumbob-harness-control/vendor/dsh-acp-demo/PROVENANCE.json b/plugins/codex-co-engineer/vendor/dsh-acp-demo/PROVENANCE.json similarity index 100% rename from plugins/plumbob-harness-control/vendor/dsh-acp-demo/PROVENANCE.json rename to plugins/codex-co-engineer/vendor/dsh-acp-demo/PROVENANCE.json diff --git a/plugins/plumbob-harness-control/vendor/dsh-acp-demo/lib/bin.js b/plugins/codex-co-engineer/vendor/dsh-acp-demo/lib/bin.js similarity index 100% rename from plugins/plumbob-harness-control/vendor/dsh-acp-demo/lib/bin.js rename to plugins/codex-co-engineer/vendor/dsh-acp-demo/lib/bin.js diff --git a/plugins/plumbob-harness-control/vendor/dsh-acp-demo/lib/index.js b/plugins/codex-co-engineer/vendor/dsh-acp-demo/lib/index.js similarity index 100% rename from plugins/plumbob-harness-control/vendor/dsh-acp-demo/lib/index.js rename to plugins/codex-co-engineer/vendor/dsh-acp-demo/lib/index.js diff --git a/plugins/plumbob-harness-control/vendor/dsh-acp-demo/lib/invariant.js b/plugins/codex-co-engineer/vendor/dsh-acp-demo/lib/invariant.js similarity index 100% rename from plugins/plumbob-harness-control/vendor/dsh-acp-demo/lib/invariant.js rename to plugins/codex-co-engineer/vendor/dsh-acp-demo/lib/invariant.js diff --git a/plugins/plumbob-harness-control/vendor/dsh-acp-demo/package.json b/plugins/codex-co-engineer/vendor/dsh-acp-demo/package.json similarity index 100% rename from plugins/plumbob-harness-control/vendor/dsh-acp-demo/package.json rename to plugins/codex-co-engineer/vendor/dsh-acp-demo/package.json diff --git a/plugins/plumbob-harness-control/mcp/v3/task-store.mjs b/plugins/plumbob-harness-control/mcp/v3/task-store.mjs deleted file mode 100644 index 5c36f67..0000000 --- a/plugins/plumbob-harness-control/mcp/v3/task-store.mjs +++ /dev/null @@ -1,324 +0,0 @@ -import { createHash, randomUUID } from 'node:crypto'; -import { appendFile, chmod, mkdir, open, readFile, readdir, rename, stat, unlink, writeFile } from 'node:fs/promises'; -import { homedir } from 'node:os'; -import path from 'node:path'; - -export const TASK_SCHEMA = 'codex-co-engineer.task.v1'; -export const LAUNCH_RESERVATION_GRACE_MS = 15_000; -const TASK_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/u; -const TERMINAL = new Set(['completed', 'failed', 'cancelled', 'timeout']); -const UPDATE_LOCK_STALE_MS = 2_000; -const LOCAL_UPDATE_TAILS = new Map(); - -function validLaunchReservation(value) { - if (!value || typeof value !== 'object' || Array.isArray(value)) return false; - if (typeof value.token !== 'string' || !/^[0-9a-f-]{36}$/iu.test(value.token)) return false; - return typeof value.expires_at === 'string' && Number.isFinite(Date.parse(value.expires_at)); -} - -export function createLaunchReservation({ now = Date.now(), graceMs = LAUNCH_RESERVATION_GRACE_MS } = {}) { - if (!Number.isFinite(now) || !Number.isFinite(graceMs) || graceMs < 1_000 || graceMs > 5 * 60_000) { - throw Object.assign(new Error('Launch reservation timing is invalid.'), { code: 'invalid_launch_reservation' }); - } - return Object.freeze({ - token: randomUUID(), - expires_at: new Date(now + graceMs).toISOString(), - }); -} - -export function launchReservationActive(task, now = Date.now()) { - const reservation = task?.launch_reservation; - return validLaunchReservation(reservation) && Date.parse(reservation.expires_at) > now; -} - -export function requireTaskId(value) { - if (typeof value !== 'string' || !TASK_ID.test(value)) { - throw Object.assign(new Error('task_id must be 1-80 safe characters.'), { code: 'invalid_task_id' }); - } - return value; -} - -export function stateRoot(env = process.env) { - if (env.CODEX_CO_ENGINEER_STATE_DIR) { - if (!path.isAbsolute(env.CODEX_CO_ENGINEER_STATE_DIR)) { - throw Object.assign(new Error('CODEX_CO_ENGINEER_STATE_DIR must be absolute.'), { code: 'invalid_state_dir' }); - } - return path.resolve(env.CODEX_CO_ENGINEER_STATE_DIR); - } - const base = env.XDG_STATE_HOME - ? path.resolve(env.XDG_STATE_HOME) - : path.join(env.HOME ? path.resolve(env.HOME) : homedir(), '.local', 'state'); - return path.join(base, 'codex-co-engineer'); -} - -export function taskPaths(root, taskId) { - const id = requireTaskId(taskId); - const directory = path.join(path.resolve(root), 'tasks', id); - return { - directory, - record: path.join(directory, 'task.json'), - prompt: path.join(directory, 'prompt.txt'), - events: path.join(directory, 'events.jsonl'), - request: path.join(directory, 'worker-request.json'), - runtime: path.join(directory, 'runtime.json'), - log: path.join(directory, 'worker.log'), - updateLock: path.join(directory, 'update.lock'), - }; -} - -async function prepareDirectory(directory) { - await mkdir(directory, { recursive: true, mode: 0o700 }); - await chmod(directory, 0o700); -} - -async function writeExclusive(file, value) { - const handle = await open(file, 'wx', 0o600); - try { - await handle.writeFile(value, 'utf8'); - await handle.sync(); - } finally { - await handle.close(); - } -} - -async function writeAtomic(file, value) { - const temporary = `${file}.tmp-${process.pid}-${randomUUID()}`; - await writeFile(temporary, value, { encoding: 'utf8', mode: 0o600, flag: 'wx' }); - await chmod(temporary, 0o600); - await rename(temporary, file); -} - -function normalizeRecord(record) { - if (!record || typeof record !== 'object' || Array.isArray(record)) { - throw Object.assign(new Error('Task record is invalid.'), { code: 'invalid_task_record' }); - } - if (record.schema !== TASK_SCHEMA || record.id !== requireTaskId(record.id)) { - throw Object.assign(new Error('Task record identity is invalid.'), { code: 'invalid_task_record' }); - } - return record; -} - -export async function createTask({ root = stateRoot(), prompt, record }) { - if (typeof prompt !== 'string' || prompt.trim().length === 0) { - throw Object.assign(new Error('prompt must be non-empty text.'), { code: 'invalid_prompt' }); - } - const now = new Date().toISOString(); - const id = requireTaskId(record.id); - const paths = taskPaths(root, id); - await prepareDirectory(path.resolve(root)); - await prepareDirectory(path.dirname(paths.directory)); - await mkdir(paths.directory, { mode: 0o700 }); - await chmod(paths.directory, 0o700); - const task = normalizeRecord({ - ...record, - schema: TASK_SCHEMA, - id, - prompt_sha256: createHash('sha256').update(prompt).digest('hex'), - created_at: record.created_at ?? now, - updated_at: now, - revision: 1, - }); - await writeExclusive(paths.prompt, prompt); - await writeExclusive(paths.events, ''); - await writeExclusive(paths.record, `${JSON.stringify(task, null, 2)}\n`); - return { task, paths }; -} - -export async function readTask(root, taskId) { - const paths = taskPaths(root, taskId); - const record = normalizeRecord(JSON.parse(await readFile(paths.record, 'utf8'))); - return { task: record, paths }; -} - -export async function readPrompt(root, taskId) { - const { paths } = await readTask(root, taskId); - return readFile(paths.prompt, 'utf8'); -} - -async function liveLockOwner(lockFile) { - let raw; - let metadata; - try { - [raw, metadata] = await Promise.all([readFile(lockFile, 'utf8'), stat(lockFile)]); - } catch (error) { - if (error?.code === 'ENOENT') return false; - return true; - } - try { - const value = JSON.parse(raw); - if (!Number.isInteger(value.pid) || typeof value.start_ticks !== 'string') { - return Date.now() - metadata.mtimeMs < UPDATE_LOCK_STALE_MS; - } - try { - const proc = await readFile(`/proc/${value.pid}/stat`, 'utf8'); - const ticks = proc.slice(proc.lastIndexOf(')') + 2).trim().split(/\s+/u)[19] ?? null; - return ticks === value.start_ticks; - } catch { - return false; - } - } catch { - return Date.now() - metadata.mtimeMs < UPDATE_LOCK_STALE_MS; - } -} - -async function acquireUpdateLock(lockFile) { - const nonce = randomUUID(); - for (let attempt = 0; attempt < 200; attempt += 1) { - try { - const handle = await open(lockFile, 'wx', 0o600); - try { - let startTicks = null; - try { - const proc = await readFile(`/proc/${process.pid}/stat`, 'utf8'); - startTicks = proc.slice(proc.lastIndexOf(')') + 2).trim().split(/\s+/u)[19] ?? null; - } catch { - // The age lease below remains a safe fallback on non-/proc hosts. - } - await handle.writeFile(`${JSON.stringify({ pid: process.pid, start_ticks: startTicks, nonce })}\n`, 'utf8'); - await handle.sync(); - } catch (error) { - await handle.close().catch(() => {}); - await unlink(lockFile).catch(() => {}); - throw error; - } - return { handle, nonce }; - } catch (error) { - if (error?.code !== 'EEXIST') throw error; - if (!(await liveLockOwner(lockFile))) { - await unlink(lockFile).catch((unlinkError) => { - if (unlinkError?.code !== 'ENOENT') throw unlinkError; - }); - continue; - } - if (attempt === 199) { - throw Object.assign(new Error('Timed out waiting for task update lock.'), { code: 'task_update_busy' }); - } - await new Promise((resolve) => setTimeout(resolve, 25)); - } - } - throw Object.assign(new Error('Timed out waiting for task update lock.'), { code: 'task_update_busy' }); -} - -export async function updateTask(root, taskId, changes) { - const paths = taskPaths(root, taskId); - let releaseLocal; - const localGate = new Promise((resolve) => { releaseLocal = resolve; }); - const previousLocal = LOCAL_UPDATE_TAILS.get(paths.record) ?? Promise.resolve(); - const localTail = previousLocal.then(() => localGate, () => localGate); - LOCAL_UPDATE_TAILS.set(paths.record, localTail); - await previousLocal.catch(() => {}); - try { - return await updateTaskWithFileLock(root, paths, taskId, changes); - } finally { - releaseLocal(); - if (LOCAL_UPDATE_TAILS.get(paths.record) === localTail) LOCAL_UPDATE_TAILS.delete(paths.record); - } -} - -export async function reserveTaskLaunch(root, taskId, reservation = createLaunchReservation()) { - if (!validLaunchReservation(reservation)) { - throw Object.assign(new Error('Launch reservation is invalid.'), { code: 'invalid_launch_reservation' }); - } - const task = await updateTask(root, taskId, (current) => { - if (current.status !== 'accepted') return current; - if (launchReservationActive(current) && current.launch_reservation.token !== reservation.token) return current; - return { launch_reservation: reservation }; - }); - if (task.launch_reservation?.token !== reservation.token) { - throw Object.assign(new Error('Another worker already owns the task launch reservation.'), { code: 'task_launch_busy' }); - } - return reservation; -} - -export async function clearTaskLaunchReservation(root, taskId, token) { - return updateTask(root, taskId, (current) => { - if (!current.launch_reservation) return current; - if (token !== undefined && current.launch_reservation.token !== token) return current; - return { launch_reservation: null }; - }); -} - -async function updateTaskWithFileLock(root, paths, taskId, changes) { - const lock = await acquireUpdateLock(paths.updateLock); - try { - const { task } = await readTask(root, taskId); - const nextChanges = typeof changes === 'function' ? await changes({ ...task }) : changes; - if (!nextChanges || typeof nextChanges !== 'object' || Array.isArray(nextChanges)) { - throw new TypeError('Task update must be an object.'); - } - if (TERMINAL.has(task.status) && nextChanges.status && nextChanges.status !== task.status) return task; - if (task.status === 'cancelling' && nextChanges.status - && !['cancelling', 'cancelled', 'transport_lost'].includes(nextChanges.status)) { - return task; - } - const next = normalizeRecord({ - ...task, - ...nextChanges, - schema: TASK_SCHEMA, - id: task.id, - created_at: task.created_at, - updated_at: new Date().toISOString(), - revision: task.revision + 1, - }); - await writeAtomic(paths.record, `${JSON.stringify(next, null, 2)}\n`); - return next; - } finally { - await lock.handle.close().catch(() => {}); - try { - const current = JSON.parse(await readFile(paths.updateLock, 'utf8')); - if (current.nonce === lock.nonce) await unlink(paths.updateLock); - } catch (error) { - if (error?.code !== 'ENOENT') throw error; - } - } -} - -export async function appendTaskEvent(root, taskId, event) { - const { paths } = await readTask(root, taskId); - const entry = { - at: new Date().toISOString(), - ...event, - }; - await appendFile(paths.events, `${JSON.stringify(entry)}\n`, { encoding: 'utf8', mode: 0o600 }); - return entry; -} - -export async function writeRuntimeRecord(root, taskId, record) { - const { paths } = await readTask(root, taskId); - if (!record || typeof record !== 'object' || Array.isArray(record)) throw new TypeError('Runtime record must be an object.'); - const value = { task_id: requireTaskId(taskId), ...record, updated_at: new Date().toISOString() }; - await writeAtomic(paths.runtime, `${JSON.stringify(value, null, 2)}\n`); - return value; -} - -export async function readRuntimeRecord(root, taskId) { - const { paths } = await readTask(root, taskId); - try { - return JSON.parse(await readFile(paths.runtime, 'utf8')); - } catch (error) { - if (error?.code === 'ENOENT') return null; - throw error; - } -} - -export async function listTasks(root = stateRoot()) { - const tasksDirectory = path.join(path.resolve(root), 'tasks'); - let entries; - try { - entries = await readdir(tasksDirectory, { withFileTypes: true }); - } catch (error) { - if (error?.code === 'ENOENT') return []; - throw error; - } - const records = []; - for (const entry of entries) { - if (!entry.isDirectory() || !TASK_ID.test(entry.name)) continue; - try { - records.push((await readTask(root, entry.name)).task); - } catch { - // A corrupt record remains on disk for operator inspection but is not - // projected as a valid task. - } - } - return records.sort((left, right) => right.created_at.localeCompare(left.created_at)); -} diff --git a/plugins/plumbob-harness-control/test/branding.test.mjs b/plugins/plumbob-harness-control/test/branding.test.mjs deleted file mode 100644 index 16e3483..0000000 --- a/plugins/plumbob-harness-control/test/branding.test.mjs +++ /dev/null @@ -1,25 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; -import path from 'node:path'; -import test from 'node:test'; -import { fileURLToPath } from 'node:url'; - -const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); - -test('plugin presents the Co-Engineer brand with usable icon assets', async () => { - const manifest = JSON.parse( - await readFile(path.join(ROOT, '.codex-plugin', 'plugin.json'), 'utf8'), - ); - - assert.equal(manifest.name, 'plumbob-harness-control'); - assert.equal(manifest.version, '3.0.0'); - assert.equal(manifest.interface.displayName, 'Codex-Co-Engineer'); - assert.equal(manifest.interface.composerIcon, './assets/icon.svg'); - assert.equal(manifest.interface.logo, './assets/co-engineer.png'); - - const icon = await readFile(path.join(ROOT, 'assets', 'icon.svg'), 'utf8'); - assert.match(icon, /aria-label="Co-Engineer"/); - - const logo = await readFile(path.join(ROOT, 'assets', 'co-engineer.png')); - assert.deepEqual([...logo.subarray(0, 8)], [137, 80, 78, 71, 13, 10, 26, 10]); -}); diff --git a/plugins/plumbob-harness-control/test/v3-server.test.mjs b/plugins/plumbob-harness-control/test/v3-server.test.mjs deleted file mode 100644 index 7fd5d43..0000000 --- a/plugins/plumbob-harness-control/test/v3-server.test.mjs +++ /dev/null @@ -1,48 +0,0 @@ -import assert from 'node:assert/strict'; -import { spawn } from 'node:child_process'; -import { mkdtemp } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import readline from 'node:readline'; -import test from 'node:test'; -import { fileURLToPath } from 'node:url'; - -const SERVER = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'mcp', 'v3', 'server.mjs'); - -async function conversation(messages) { - const state = await mkdtemp(path.join(tmpdir(), 'co-engineer-v3-server-')); - const child = spawn(process.execPath, ['--no-warnings', SERVER], { - env: { ...process.env, CODEX_CO_ENGINEER_STATE_DIR: state }, - stdio: ['pipe', 'pipe', 'inherit'], - }); - const lines = readline.createInterface({ input: child.stdout, crlfDelay: Infinity }); - const values = []; - const done = new Promise((resolve) => { - lines.on('line', (line) => { - values.push(JSON.parse(line)); - if (values.length === messages.length) resolve(); - }); - }); - for (const message of messages) child.stdin.write(`${JSON.stringify(message)}\n`); - await done; - child.kill('SIGTERM'); - return values; -} - -test('advertises only the thin public tool surface', async () => { - const values = await conversation([ - { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-11-25' } }, - { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }, - ]); - assert.equal(values[0].result.serverInfo.version, '3.0.0'); - assert.deepEqual(values[1].result.tools.map((tool) => tool.name), ['status', 'delegate', 'task', 'tasks', 'cancel']); -}); - -test('status works without starting a daemon or provider', async () => { - const [value] = await conversation([ - { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'status', arguments: {} } }, - ]); - assert.equal(value.result.structuredContent.healthy, true); - assert.equal(value.result.structuredContent.active, 0); -}); - diff --git a/plugins/plumbob-harness-control/test/v3-task-store.test.mjs b/plugins/plumbob-harness-control/test/v3-task-store.test.mjs deleted file mode 100644 index ac29e89..0000000 --- a/plugins/plumbob-harness-control/test/v3-task-store.test.mjs +++ /dev/null @@ -1,145 +0,0 @@ -import assert from 'node:assert/strict'; -import { mkdtemp, readFile, stat, utimes, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import test from 'node:test'; - -import { - appendTaskEvent, - clearTaskLaunchReservation, - createLaunchReservation, - createTask, - launchReservationActive, - listTasks, - readPrompt, - readRuntimeRecord, - readTask, - requireTaskId, - reserveTaskLaunch, - taskPaths, - updateTask, - writeRuntimeRecord, -} from '../mcp/v3/task-store.mjs'; - -async function temporaryRoot() { - return mkdtemp(path.join(tmpdir(), 'co-engineer-task-store-')); -} - -test('task identifiers reject path traversal', () => { - assert.throws(() => requireTaskId('../escape'), /task_id/u); - assert.equal(requireTaskId('agent-42.review'), 'agent-42.review'); -}); - -test('task records and prompts are owner-only and prompt text is not in the record', async () => { - const root = await temporaryRoot(); - const prompt = 'Implement the adapter without leaking this prompt.'; - const { task, paths } = await createTask({ - root, - prompt, - record: { id: 'adapter', provider: 'grok', state: 'queued' }, - }); - - assert.equal(task.schema, 'codex-co-engineer.task.v1'); - assert.equal(await readPrompt(root, 'adapter'), prompt); - assert.equal((await stat(paths.directory)).mode & 0o777, 0o700); - assert.equal((await stat(paths.record)).mode & 0o777, 0o600); - assert.equal((await stat(paths.prompt)).mode & 0o777, 0o600); - assert.doesNotMatch(await readFile(paths.record, 'utf8'), /without leaking/u); -}); - -test('updates preserve identity and monotonically advance revision', async () => { - const root = await temporaryRoot(); - await createTask({ root, prompt: 'test', record: { id: 'run-1', state: 'queued' } }); - const running = await updateTask(root, 'run-1', { state: 'running', pid: 123 }); - const complete = await updateTask(root, 'run-1', (task) => ({ state: 'completed', pid: task.pid })); - - assert.equal(running.revision, 2); - assert.equal(complete.revision, 3); - assert.equal(complete.id, 'run-1'); - assert.equal(complete.pid, 123); -}); - -test('launch reservations are owner-local, bounded, and cannot be stolen while active', async () => { - const root = await temporaryRoot(); - await createTask({ root, prompt: 'reserve', record: { id: 'reserve-one', status: 'accepted' } }); - const now = Date.now(); - const first = await reserveTaskLaunch(root, 'reserve-one', createLaunchReservation({ now })); - const task = (await readTask(root, 'reserve-one')).task; - assert.equal(task.launch_reservation.token, first.token); - assert.equal(launchReservationActive(task, now + 1), true); - assert.equal(launchReservationActive(task, Date.parse(first.expires_at) + 1), false); - await assert.rejects( - reserveTaskLaunch(root, 'reserve-one', createLaunchReservation({ now: now + 1 })), - (error) => error.code === 'task_launch_busy', - ); - await clearTaskLaunchReservation(root, 'reserve-one', first.token); - assert.equal((await readTask(root, 'reserve-one')).task.launch_reservation, null); -}); - -test('concurrent updates serialize and terminal cancellation wins', async () => { - const root = await mkdtemp(path.join(tmpdir(), 'co-engineer-store-race-')); - await createTask({ root, prompt: 'race', record: { id: 'race-one', status: 'running', provider: 'grok', cwd: root } }); - await Promise.all(Array.from({ length: 12 }, (_, index) => updateTask(root, 'race-one', { marker: index }))); - const afterRace = (await readTask(root, 'race-one')).task; - assert.equal(afterRace.revision, 13); - await updateTask(root, 'race-one', { status: 'cancelling' }); - await updateTask(root, 'race-one', { status: 'completed', result: 'late worker result' }); - await updateTask(root, 'race-one', { status: 'cancelled' }); - const terminal = (await readTask(root, 'race-one')).task; - assert.equal(terminal.status, 'cancelled'); - assert.equal(terminal.result, undefined); -}); - -test('a lock left by a dead process is recovered', async () => { - const root = await temporaryRoot(); - const { paths } = await createTask({ root, prompt: 'recover', record: { id: 'stale-lock', status: 'running' } }); - await writeFile(paths.updateLock, `${JSON.stringify({ pid: 999999999, start_ticks: '1', nonce: 'dead' })}\n`, { mode: 0o600 }); - const task = await updateTask(root, 'stale-lock', { recovered: true }); - assert.equal(task.recovered, true); -}); - -test('an old malformed update lock is recovered', async () => { - const root = await temporaryRoot(); - const { paths } = await createTask({ root, prompt: 'recover malformed', record: { id: 'malformed-lock', status: 'running' } }); - await writeFile(paths.updateLock, '{partial', { mode: 0o600 }); - const old = new Date(Date.now() - 10_000); - await utimes(paths.updateLock, old, old); - const task = await updateTask(root, 'malformed-lock', { recovered: true }); - assert.equal(task.recovered, true); -}); - -test('uncertain cancellation can remain reconcilable without accepting a late completion', async () => { - const root = await temporaryRoot(); - await createTask({ root, prompt: 'cancel', record: { id: 'cancel-uncertain', status: 'running' } }); - await updateTask(root, 'cancel-uncertain', { status: 'cancelling' }); - await updateTask(root, 'cancel-uncertain', { status: 'transport_lost', error: { code: 'cancel_unconfirmed' } }); - const uncertain = (await readTask(root, 'cancel-uncertain')).task; - assert.equal(uncertain.status, 'transport_lost'); - assert.equal(uncertain.finished_at, undefined); -}); - -test('events are JSONL and listTasks ignores invalid task directories', async () => { - const root = await temporaryRoot(); - await createTask({ root, prompt: 'one', record: { id: 'one', state: 'queued' } }); - await createTask({ root, prompt: 'two', record: { id: 'two', state: 'queued' } }); - await appendTaskEvent(root, 'one', { type: 'accepted' }); - const paths = taskPaths(root, 'one'); - const event = JSON.parse((await readFile(paths.events, 'utf8')).trim()); - - assert.equal(event.type, 'accepted'); - assert.match(event.at, /^20/u); - assert.deepEqual(new Set((await listTasks(root)).map((task) => task.id)), new Set(['one', 'two'])); - assert.equal((await readTask(root, 'one')).task.state, 'queued'); -}); - -test('runtime identity is stored separately from the task receipt', async () => { - const root = await temporaryRoot(); - await createTask({ root, prompt: 'hello', record: { id: 'runtime', status: 'accepted' } }); - await writeRuntimeRecord(root, 'runtime', { pid: 123, process_start_ticks: '456', process_group: 123 }); - const runtime = await readRuntimeRecord(root, 'runtime'); - assert.equal(runtime.task_id, 'runtime'); - assert.equal(runtime.pid, 123); - assert.equal(runtime.process_start_ticks, '456'); - assert.equal(runtime.process_group, 123); - assert.equal((await readTask(root, 'runtime')).task.pid, undefined); -}); diff --git a/scripts/inspector-preflight.mjs b/scripts/inspector-preflight.mjs index 862ae3e..884700e 100755 --- a/scripts/inspector-preflight.mjs +++ b/scripts/inspector-preflight.mjs @@ -13,7 +13,7 @@ const inspector = process.env.MCP_INSPECTOR_COMMAND ?? 'mcp-inspector'; function inspect(method, toolName) { const argv = [ - '--cli', 'node', 'plugins/plumbob-harness-control/mcp/v3/server.mjs', + '--cli', 'node', 'plugins/codex-co-engineer/mcp/v3/server.mjs', '--method', method, ]; if (toolName) argv.push('--tool-name', toolName, '--tool-args-json', '{}'); @@ -34,11 +34,14 @@ try { const statusEnvelope = inspect('tools/call', 'status'); const status = statusEnvelope.structuredContent ?? JSON.parse(statusEnvelope.content?.[0]?.text ?? '{}'); - assert.equal(status.version, '3.0.0'); - assert.equal(status.healthy, true); + assert.equal(status.version, '3.0.2'); + assert.equal(status.healthy, status.local_boundary.ready); assert.equal(status.active, 0); assert.deepEqual(status.tasks, []); assert.deepEqual(status.providers, ['grok', 'cursor-local', 'dsh', 'cursor-cloud']); + for (const provider of ['grok', 'cursor-local', 'dsh']) { + if (!status.local_boundary.ready) assert.equal(status.readiness[provider].ready, false); + } process.stdout.write(`${JSON.stringify({ tools: listed.tools.map((tool) => tool.name), status }, null, 2)}\n`); } finally { await rm(state, { recursive: true, force: true }); diff --git a/scripts/mcp-environment-preflight.mjs b/scripts/mcp-environment-preflight.mjs new file mode 100644 index 0000000..ceb2ecf --- /dev/null +++ b/scripts/mcp-environment-preflight.mjs @@ -0,0 +1,81 @@ +#!/usr/bin/env node + +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import readline from 'node:readline'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const PLUGIN = path.join(ROOT, 'plugins', 'codex-co-engineer'); +const manifest = JSON.parse(await readFile(path.join(PLUGIN, '.mcp.json'), 'utf8')); +const definition = manifest.mcpServers?.['codex-co-engineer']; +assert.ok(definition, 'Co-Engineer MCP definition is missing.'); + +const required = ['XDG_RUNTIME_DIR', 'DBUS_SESSION_BUS_ADDRESS']; +for (const name of required) { + assert.ok(definition.env_vars?.includes(name), `MCP environment allowlist is missing ${name}.`); + assert.ok(process.env[name], `Release host environment is missing ${name}.`); +} + +const state = await mkdtemp(path.join(os.tmpdir(), 'co-engineer-mcp-environment-')); +const childEnvironment = {}; +for (const name of definition.env_vars) { + if (process.env[name] !== undefined) childEnvironment[name] = process.env[name]; +} +childEnvironment.CODEX_CO_ENGINEER_STATE_DIR = state; +// Keep this acceptance focused on the manifest-filtered MCP/systemd boundary; +// provider authentication has its own status checks and must not make the +// boundary proof depend on external CLI latency. +childEnvironment.CODEX_CO_ENGINEER_GROK_COMMAND = '/bin/false'; +childEnvironment.CODEX_CO_ENGINEER_CURSOR_COMMAND = '/bin/false'; +childEnvironment.CODEX_CO_ENGINEER_DSH_COMMAND = '/bin/false'; +childEnvironment.CODEX_CO_ENGINEER_ACPX_COMMAND = '/bin/false'; +childEnvironment.CODEX_CO_ENGINEER_DSH_ACP_COMMAND = 'false'; + +const child = spawn(definition.command, definition.args, { + cwd: PLUGIN, + env: childEnvironment, + stdio: ['pipe', 'pipe', 'pipe'], +}); +const output = readline.createInterface({ input: child.stdout, crlfDelay: Infinity }); +let stderr = ''; +child.stderr.on('data', (chunk) => { stderr = `${stderr}${chunk}`.slice(-4096); }); + +try { + const response = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`Timed out waiting for MCP status. ${stderr}`)), 15_000); + child.once('error', (error) => { clearTimeout(timer); reject(error); }); + child.once('exit', (code, signal) => { + clearTimeout(timer); + reject(new Error(`MCP exited before status: ${code ?? signal}. ${stderr}`)); + }); + output.once('line', (line) => { + clearTimeout(timer); + try { resolve(JSON.parse(line)); } catch (error) { reject(error); } + }); + child.stdin.write(`${JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'status', arguments: {} }, + })}\n`); + }); + const status = response.result?.structuredContent; + assert.equal(status?.version, '3.0.2'); + assert.equal(status?.healthy, true, JSON.stringify(status?.local_boundary)); + assert.equal(status?.local_boundary?.ready, true, JSON.stringify(status?.local_boundary)); + assert.equal(status?.local_boundary?.boundary, 'systemd-user-service-cgroup'); + process.stdout.write(`${JSON.stringify({ + version: status.version, + healthy: status.healthy, + local_boundary: status.local_boundary, + environment_forwarded: required, + })}\n`); +} finally { + child.kill('SIGTERM'); + output.close(); + await rm(state, { recursive: true, force: true }); +} diff --git a/scripts/process-boundary-preflight.mjs b/scripts/process-boundary-preflight.mjs index 934634b..24b86ba 100644 --- a/scripts/process-boundary-preflight.mjs +++ b/scripts/process-boundary-preflight.mjs @@ -9,7 +9,7 @@ import { launchProcessBoundary, probeProcessBoundary, stopProcessBoundary, -} from '../plugins/plumbob-harness-control/mcp/v3/process-boundary.mjs'; +} from '../plugins/codex-co-engineer/mcp/v3/process-boundary.mjs'; function processAlive(pid) { try { diff --git a/scripts/validate-release.mjs b/scripts/validate-release.mjs index 32208b6..ccfd205 100755 --- a/scripts/validate-release.mjs +++ b/scripts/validate-release.mjs @@ -6,7 +6,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const PLUGIN = 'plugins/plumbob-harness-control'; +const PLUGIN = 'plugins/codex-co-engineer'; function fail(message) { throw new Error(message); } const absolute = (relative) => path.join(ROOT, relative); @@ -27,9 +27,10 @@ const required = [ `${PLUGIN}/assets/acpx-third-party-notices.md`, `${PLUGIN}/vendor/dsh-acp-demo/LICENSE`, `${PLUGIN}/vendor/dsh-acp-demo/PROVENANCE.json`, `${PLUGIN}/vendor/dsh-acp-demo/package.json`, - `${PLUGIN}/skills/control-plumbob-agents/SKILL.md`, + `${PLUGIN}/skills/control-codex-co-engineer-agents/SKILL.md`, + `${PLUGIN}/skills/control-codex-co-engineer-agents/agents/openai.yaml`, 'scripts/release-prerequisites.mjs', 'scripts/validate-release.mjs', 'scripts/inspector-preflight.mjs', - 'scripts/process-boundary-preflight.mjs', + 'scripts/process-boundary-preflight.mjs', 'scripts/mcp-environment-preflight.mjs', 'tools/acpx-vendor/package.json', 'tools/acpx-vendor/package-lock.json', ]; for (const relative of required) { @@ -45,20 +46,36 @@ const manifest = await json(`${PLUGIN}/.codex-plugin/plugin.json`); const packageJson = await json(`${PLUGIN}/package.json`); const mcp = await json(`${PLUGIN}/.mcp.json`); const serverText = await text(`${PLUGIN}/mcp/v3/server.mjs`); -if (manifest.version !== '3.0.0' || packageJson.version !== '3.0.0' || !serverText.includes("version: '3.0.0'")) { - fail('Plugin manifest, package, and MCP server must all be version 3.0.0.'); +if (manifest.name !== 'codex-co-engineer' || packageJson.name !== 'codex-co-engineer') { + fail('Plugin manifest and package must use the codex-co-engineer identifier.'); +} +if (manifest.version !== '3.0.2' || packageJson.version !== '3.0.2' || !serverText.includes("version: '3.0.2'")) { + fail('Plugin manifest, package, and MCP server must all be version 3.0.2.'); } if (manifest.interface?.displayName !== 'Codex-Co-Engineer') fail('Public display name mismatch.'); +if (manifest.interface?.developerName !== 'Codex-Co-Engineer') fail('Public developer name mismatch.'); +if (JSON.stringify(Object.keys(mcp.mcpServers ?? {})) !== JSON.stringify(['codex-co-engineer'])) { + fail('MCP manifest must expose exactly the codex-co-engineer server key.'); +} +const skillText = await text(`${PLUGIN}/skills/control-codex-co-engineer-agents/SKILL.md`); +if (!/^name: control-codex-co-engineer-agents$/mu.test(skillText)) { + fail('Skill name must be the lowercase control-codex-co-engineer-agents identifier.'); +} +const changelog = await text('CHANGELOG.md'); +if (!changelog.includes('## [3.0.2]')) fail('CHANGELOG.md must record the 3.0.2 release.'); if (packageJson.scripts?.test !== 'node --no-warnings --test test/*.test.mjs') fail('Unexpected test script.'); if (JSON.stringify(packageJson.files) !== JSON.stringify([ '.codex-plugin', '.mcp.json', 'README.md', 'assets', 'bin', 'mcp', 'skills', 'vendor', 'package.json', ])) fail('Co-Engineer package roots changed.'); -const server = mcp.mcpServers?.['plumbob-harness-control']; +const server = mcp.mcpServers?.['codex-co-engineer']; if (server?.command !== 'node' || JSON.stringify(server.args) !== JSON.stringify(['--no-warnings', './mcp/v3/server.mjs', '--stdio']) || server.tool_timeout_sec !== 65) fail('MCP launch contract mismatch.'); -for (const variable of ['HOME', 'PATH', 'XDG_CONFIG_HOME', 'XDG_STATE_HOME', 'CODEX_CO_ENGINEER_STATE_DIR']) { +for (const variable of [ + 'HOME', 'PATH', 'XDG_CONFIG_HOME', 'XDG_STATE_HOME', 'XDG_RUNTIME_DIR', + 'DBUS_SESSION_BUS_ADDRESS', 'CODEX_CO_ENGINEER_STATE_DIR', +]) { if (!server.env_vars?.includes(variable)) fail(`MCP environment allowlist is missing ${variable}.`); } @@ -66,6 +83,9 @@ const toolNames = [...serverText.matchAll(/name: '([^']+)'/gu)].map((match) => m if (JSON.stringify(toolNames) !== JSON.stringify(['status', 'delegate', 'task', 'tasks', 'cancel'])) { fail('MCP tool catalog must contain exactly status, delegate, task, tasks, cancel.'); } +if (!serverText.includes('wait_ms') || !serverText.includes('event_cursor') || !serverText.includes("pattern: '^[0-9]{1,16}$'")) { + fail('task tool must advertise bounded wait_ms/cursor live progress.'); +} const mcpEntries = await readdir(absolute(`${PLUGIN}/mcp`)); if (JSON.stringify(mcpEntries) !== JSON.stringify(['v3'])) fail('Legacy MCP modules remain packaged.'); @@ -157,4 +177,20 @@ for (const obsolete of [ } } -process.stdout.write('Codex-Co-Engineer 3.0 release validation passed.\n'); +const forbiddenLegacy = [ + ['plumbob', '-', 'harness', '-', 'control'].join(''), + ['plumbob', '_', 'harness', '_', 'control'].join(''), + ['plumbob', '-', 'acpx'].join(''), + ['control', '-', 'plumbob', '-', 'agents'].join(''), + ['PLUMBOB', '_', 'HARNESS'].join(''), +]; +for (const relative of tracked) { + const value = await readFile(absolute(relative)).catch(() => null); + if (!value || value.includes(0)) continue; + const source = value.toString('utf8'); + if (forbiddenLegacy.some((token) => source.toLowerCase().includes(token.toLowerCase()))) { + fail(`Legacy product identifier remains in ${relative}.`); + } +} + +process.stdout.write('Codex-Co-Engineer 3.0.2 release validation passed.\n'); diff --git a/tools/acpx-vendor/build.mjs b/tools/acpx-vendor/build.mjs index 94f6ba5..b2965ff 100644 --- a/tools/acpx-vendor/build.mjs +++ b/tools/acpx-vendor/build.mjs @@ -12,7 +12,7 @@ const lockPath = join(here, 'package-lock.json'); const packagePath = join(here, 'package.json'); const defaultOutputDirectory = join( repositoryRoot, - 'plugins/plumbob-harness-control/assets', + 'plugins/codex-co-engineer/assets', ); const BUNDLE_NAME = 'acpx-runtime.mjs'; diff --git a/tools/acpx-vendor/package-lock.json b/tools/acpx-vendor/package-lock.json index 49e2421..8ec7d1d 100644 --- a/tools/acpx-vendor/package-lock.json +++ b/tools/acpx-vendor/package-lock.json @@ -1,11 +1,11 @@ { - "name": "plumbob-acpx-runtime-vendor", + "name": "codex-co-engineer-acpx-runtime-vendor", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "plumbob-acpx-runtime-vendor", + "name": "codex-co-engineer-acpx-runtime-vendor", "version": "0.1.0", "license": "MIT", "dependencies": { diff --git a/tools/acpx-vendor/package.json b/tools/acpx-vendor/package.json index ef45f01..53159f2 100644 --- a/tools/acpx-vendor/package.json +++ b/tools/acpx-vendor/package.json @@ -1,5 +1,5 @@ { - "name": "plumbob-acpx-runtime-vendor", + "name": "codex-co-engineer-acpx-runtime-vendor", "private": true, "version": "0.1.0", "description": "Reproducible build workspace for the embedded ACPX runtime bundle.", diff --git a/tools/acpx-vendor/reproducible.test.mjs b/tools/acpx-vendor/reproducible.test.mjs index b07df98..108e7b9 100644 --- a/tools/acpx-vendor/reproducible.test.mjs +++ b/tools/acpx-vendor/reproducible.test.mjs @@ -21,7 +21,7 @@ const vendorRoot = dirname(fileURLToPath(import.meta.url)); const repositoryRoot = resolve(vendorRoot, '..', '..'); const checkedAssets = join( repositoryRoot, - 'plugins/plumbob-harness-control/assets', + 'plugins/codex-co-engineer/assets', ); const ASSET_NAMES = [ 'acpx-runtime.mjs', diff --git a/tools/acpx-vendor/verify-publish-provenance.mjs b/tools/acpx-vendor/verify-publish-provenance.mjs index fedf49d..475eab2 100644 --- a/tools/acpx-vendor/verify-publish-provenance.mjs +++ b/tools/acpx-vendor/verify-publish-provenance.mjs @@ -129,7 +129,7 @@ async function queryRegistryJson(fetchImpl, url, timeoutMs, codes) { credentials: 'omit', headers: { accept: 'application/json', - 'user-agent': 'plumbob-acpx-publish-provenance/1', + 'user-agent': 'codex-co-engineer-acpx-publish-provenance/1', }, signal, });