From 30bc55ed8726cee8f1ed6a8c1a62cd9b40dbf984 Mon Sep 17 00:00:00 2001 From: Cole Lyons Date: Wed, 19 Aug 2026 16:17:05 -0400 Subject: [PATCH 1/6] fix: validate Co-Engineer user service boundary --- .codex/release-gate.toml | 7 ++ CHANGELOG.md | 16 ++++ README.md | 14 ++-- docs/configuration.md | 10 ++- .../.codex-plugin/plugin.json | 2 +- plugins/plumbob-harness-control/.mcp.json | 2 + plugins/plumbob-harness-control/README.md | 9 ++- .../mcp/v3/process-boundary.mjs | 19 ++++- .../plumbob-harness-control/mcp/v3/server.mjs | 5 +- .../mcp/v3/supervisor.mjs | 57 ++++++++++++- plugins/plumbob-harness-control/package.json | 2 +- .../skills/control-plumbob-agents/SKILL.md | 12 +-- .../test/branding.test.mjs | 7 +- .../test/v3-process-boundary.test.mjs | 38 ++++++++- .../test/v3-server.test.mjs | 57 ++++++++++--- .../test/v3-supervisor.test.mjs | 74 ++++++++++++++++- scripts/inspector-preflight.mjs | 7 +- scripts/mcp-environment-preflight.mjs | 81 +++++++++++++++++++ scripts/validate-release.mjs | 11 ++- 19 files changed, 380 insertions(+), 50 deletions(-) create mode 100644 scripts/mcp-environment-preflight.mjs diff --git a/.codex/release-gate.toml b/.codex/release-gate.toml index 0dd9a41..72f8784 100644 --- a/.codex/release-gate.toml +++ b/.codex/release-gate.toml @@ -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" diff --git a/CHANGELOG.md b/CHANGELOG.md index ae442d5..9993a83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ ## [Unreleased] +## [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/README.md b/README.md index fe6c75d..084528c 100644 --- a/README.md +++ b/README.md @@ -55,10 +55,12 @@ 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. +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 @@ -81,7 +83,9 @@ 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. This check occurs before 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 diff --git a/docs/configuration.md b/docs/configuration.md index 816f1f3..bbe07ee 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -52,10 +52,12 @@ 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 diff --git a/plugins/plumbob-harness-control/.codex-plugin/plugin.json b/plugins/plumbob-harness-control/.codex-plugin/plugin.json index 8f69b81..5bc5313 100644 --- a/plugins/plumbob-harness-control/.codex-plugin/plugin.json +++ b/plugins/plumbob-harness-control/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "plumbob-harness-control", - "version": "3.0.0", + "version": "3.0.1", "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" diff --git a/plugins/plumbob-harness-control/.mcp.json b/plugins/plumbob-harness-control/.mcp.json index 170a2f4..bccc2bb 100644 --- a/plugins/plumbob-harness-control/.mcp.json +++ b/plugins/plumbob-harness-control/.mcp.json @@ -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/plumbob-harness-control/README.md index 34e1da5..b1d631b 100644 --- a/plugins/plumbob-harness-control/README.md +++ b/plugins/plumbob-harness-control/README.md @@ -123,10 +123,11 @@ 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: diff --git a/plugins/plumbob-harness-control/mcp/v3/process-boundary.mjs b/plugins/plumbob-harness-control/mcp/v3/process-boundary.mjs index 85297c6..8e24a60 100644 --- a/plugins/plumbob-harness-control/mcp/v3/process-boundary.mjs +++ b/plugins/plumbob-harness-control/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/plumbob-harness-control/mcp/v3/server.mjs index 7426b63..67be655 100644 --- a/plugins/plumbob-harness-control/mcp/v3/server.mjs +++ b/plugins/plumbob-harness-control/mcp/v3/server.mjs @@ -122,7 +122,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.1' }, }, }); return; @@ -147,6 +147,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/supervisor.mjs b/plugins/plumbob-harness-control/mcp/v3/supervisor.mjs index e5f40cc..ec636ad 100644 --- a/plugins/plumbob-harness-control/mcp/v3/supervisor.mjs +++ b/plugins/plumbob-harness-control/mcp/v3/supervisor.mjs @@ -31,6 +31,7 @@ import { import { inspectProcessBoundary, launchProcessBoundary, + probeProcessBoundary, restoreProcessBoundary, stopProcessBoundary, } from './process-boundary.mjs'; @@ -53,6 +54,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 +126,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 +450,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); @@ -709,7 +748,7 @@ export async function taskStatus(root, taskId) { return { task, runtime }; } -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 +756,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.1', + healthy: boundary.ready, active: tasks.filter((task) => ACTIVE.has(task.status)).length, providers: ['grok', 'cursor-local', 'dsh', 'cursor-cloud'], - readiness: await providerReadiness(), + local_boundary: boundary, + readiness, tasks: tasks.slice(0, 20), }; } diff --git a/plugins/plumbob-harness-control/package.json b/plugins/plumbob-harness-control/package.json index b7dfbb6..a351971 100644 --- a/plugins/plumbob-harness-control/package.json +++ b/plugins/plumbob-harness-control/package.json @@ -1,6 +1,6 @@ { "name": "plumbob-harness-control", - "version": "3.0.0", + "version": "3.0.1", "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/plumbob-harness-control/skills/control-plumbob-agents/SKILL.md index dd7c7a4..2a8177f 100644 --- a/plugins/plumbob-harness-control/skills/control-plumbob-agents/SKILL.md +++ b/plugins/plumbob-harness-control/skills/control-plumbob-agents/SKILL.md @@ -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. diff --git a/plugins/plumbob-harness-control/test/branding.test.mjs b/plugins/plumbob-harness-control/test/branding.test.mjs index 16e3483..a726243 100644 --- a/plugins/plumbob-harness-control/test/branding.test.mjs +++ b/plugins/plumbob-harness-control/test/branding.test.mjs @@ -12,11 +12,16 @@ test('plugin presents the Co-Engineer brand with usable icon assets', async () = ); assert.equal(manifest.name, 'plumbob-harness-control'); - assert.equal(manifest.version, '3.0.0'); + assert.equal(manifest.version, '3.0.1'); 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 mcp = JSON.parse(await readFile(path.join(ROOT, '.mcp.json'), 'utf8')); + const environment = mcp.mcpServers['plumbob-harness-control'].env_vars; + assert.ok(environment.includes('XDG_RUNTIME_DIR')); + assert.ok(environment.includes('DBUS_SESSION_BUS_ADDRESS')); + const icon = await readFile(path.join(ROOT, 'assets', 'icon.svg'), 'utf8'); assert.match(icon, /aria-label="Co-Engineer"/); diff --git a/plugins/plumbob-harness-control/test/v3-process-boundary.test.mjs b/plugins/plumbob-harness-control/test/v3-process-boundary.test.mjs index 80824e7..19fc6ed 100644 --- a/plugins/plumbob-harness-control/test/v3-process-boundary.test.mjs +++ b/plugins/plumbob-harness-control/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/plumbob-harness-control/test/v3-server.test.mjs b/plugins/plumbob-harness-control/test/v3-server.test.mjs index 7fd5d43..0ff55ce 100644 --- a/plugins/plumbob-harness-control/test/v3-server.test.mjs +++ b/plugins/plumbob-harness-control/test/v3-server.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; -import { mkdtemp } from 'node:fs/promises'; +import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import readline from 'node:readline'; @@ -9,24 +9,44 @@ import { fileURLToPath } from 'node:url'; const SERVER = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'mcp', 'v3', 'server.mjs'); -async function conversation(messages) { +async function conversation(messages, environment = process.env) { 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'], + 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 values = []; - const done = new Promise((resolve) => { + let stderr = ''; + child.stderr.on('data', (chunk) => { stderr = `${stderr}${chunk}`.slice(-4096); }); + const done = new Promise((resolve, reject) => { lines.on('line', (line) => { values.push(JSON.parse(line)); if (values.length === messages.length) resolve(); }); + child.once('error', reject); + child.once('exit', (code, signal) => reject(new Error( + `MCP server exited before the conversation completed (${code ?? signal}): ${stderr}`, + ))); }); - for (const message of messages) child.stdin.write(`${JSON.stringify(message)}\n`); - await done; - child.kill('SIGTERM'); - return values; + try { + for (const message of messages) child.stdin.write(`${JSON.stringify(message)}\n`); + await done; + return values; + } finally { + child.stdin.end(); + child.kill('SIGTERM'); + lines.close(); + await rm(state, { recursive: true, force: true }); + } } test('advertises only the thin public tool surface', async () => { @@ -34,7 +54,7 @@ test('advertises only the thin public tool surface', async () => { { 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.equal(values[0].result.serverInfo.version, '3.0.1'); assert.deepEqual(values[1].result.tools.map((tool) => tool.name), ['status', 'delegate', 'task', 'tasks', 'cancel']); }); @@ -42,7 +62,22 @@ 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.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/plumbob-harness-control/test/v3-supervisor.test.mjs index c306944..31be55d 100644 --- a/plugins/plumbob-harness-control/test/v3-supervisor.test.mjs +++ b/plugins/plumbob-harness-control/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 { + cancelTask, + cleanupManagedWorkspace, + createWriterWorkspace, + launchWorker, + submitTask, + supervisorStatus, + taskStatus, +} from '../mcp/v3/supervisor.mjs'; import { 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' }); }, }), diff --git a/scripts/inspector-preflight.mjs b/scripts/inspector-preflight.mjs index 862ae3e..88b4b82 100755 --- a/scripts/inspector-preflight.mjs +++ b/scripts/inspector-preflight.mjs @@ -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.1'); + 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..e1698e8 --- /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', 'plumbob-harness-control'); +const manifest = JSON.parse(await readFile(path.join(PLUGIN, '.mcp.json'), 'utf8')); +const definition = manifest.mcpServers?.['plumbob-harness-control']; +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.1'); + 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/validate-release.mjs b/scripts/validate-release.mjs index 32208b6..3fdbf2e 100755 --- a/scripts/validate-release.mjs +++ b/scripts/validate-release.mjs @@ -29,7 +29,7 @@ const required = [ `${PLUGIN}/vendor/dsh-acp-demo/package.json`, `${PLUGIN}/skills/control-plumbob-agents/SKILL.md`, '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,8 +45,8 @@ 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.version !== '3.0.1' || packageJson.version !== '3.0.1' || !serverText.includes("version: '3.0.1'")) { + fail('Plugin manifest, package, and MCP server must all be version 3.0.1.'); } if (manifest.interface?.displayName !== 'Codex-Co-Engineer') fail('Public display name mismatch.'); if (packageJson.scripts?.test !== 'node --no-warnings --test test/*.test.mjs') fail('Unexpected test script.'); @@ -58,7 +58,10 @@ const server = mcp.mcpServers?.['plumbob-harness-control']; 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}.`); } From b3c9435841e1b1f37129f13ba5ab72bafdecba08 Mon Sep 17 00:00:00 2001 From: ajhcs <176340565+ajhcs@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:38:50 +0000 Subject: [PATCH 2/6] Rename plugin to Codex-Co-Engineer 3.0.2. Adopt `codex-co-engineer` as the package, plugin, MCP server, skill, and repository-path identifier. Human-facing branding is Codex-Co-Engineer. Bump 3.0.1 to 3.0.2, rewrite the root and plugin READMEs, and drop leftover legacy identity fallbacks and vendor package names. --- .codex/release-gate.toml | 4 +- .github/workflows/ci.yml | 4 +- CHANGELOG.md | 11 ++ CONTRIBUTING.md | 6 +- README.md | 152 ++++++++++++------ SECURITY.md | 2 +- docs/configuration.md | 2 +- .../.codex-plugin/plugin.json | 8 +- .../.mcp.json | 2 +- .../README.md | 120 ++++++++++---- .../assets/acpx-runtime.manifest.json | 2 +- .../assets/acpx-runtime.mjs | 0 .../assets/acpx-third-party-notices.md | 2 +- .../assets/co-engineer.png | Bin .../assets/icon.svg | 0 .../bin/set-model-api-key | 2 +- .../bin/setup.mjs | 0 .../mcp/v3/acp-worker.mjs | 0 .../mcp/v3/cursor-cloud-worker.mjs | 0 .../mcp/v3/process-boundary.mjs | 0 .../mcp/v3/server.mjs | 2 +- .../mcp/v3/single-turn.flow.mjs | 0 .../mcp/v3/supervisor.mjs | 2 +- .../mcp/v3/task-store.mjs | 0 .../package.json | 4 +- .../SKILL.md | 2 +- .../agents/openai.yaml | 4 +- .../test/acpx-fake-agent.mjs | 0 .../test/acpx-runtime.test.mjs | 0 .../test/branding.test.mjs | 18 ++- .../test/fake-acpx.mjs | 0 .../test/setup.test.mjs | 0 .../test/v3-acp-worker.test.mjs | 0 .../test/v3-cursor-cloud-worker.test.mjs | 0 .../test/v3-process-boundary.test.mjs | 0 .../test/v3-server.test.mjs | 4 +- .../test/v3-supervisor.test.mjs | 2 +- .../test/v3-task-store.test.mjs | 0 .../vendor/dsh-acp-demo/LICENSE | 0 .../vendor/dsh-acp-demo/PROVENANCE.json | 0 .../vendor/dsh-acp-demo/lib/bin.js | 0 .../vendor/dsh-acp-demo/lib/index.js | 0 .../vendor/dsh-acp-demo/lib/invariant.js | 0 .../vendor/dsh-acp-demo/package.json | 0 scripts/inspector-preflight.mjs | 4 +- scripts/mcp-environment-preflight.mjs | 6 +- scripts/process-boundary-preflight.mjs | 2 +- scripts/validate-release.mjs | 42 ++++- tools/acpx-vendor/build.mjs | 2 +- tools/acpx-vendor/package-lock.json | 4 +- tools/acpx-vendor/package.json | 2 +- tools/acpx-vendor/reproducible.test.mjs | 2 +- .../acpx-vendor/verify-publish-provenance.mjs | 2 +- 53 files changed, 287 insertions(+), 134 deletions(-) rename plugins/{plumbob-harness-control => codex-co-engineer}/.codex-plugin/plugin.json (91%) rename plugins/{plumbob-harness-control => codex-co-engineer}/.mcp.json (96%) rename plugins/{plumbob-harness-control => codex-co-engineer}/README.md (62%) rename plugins/{plumbob-harness-control => codex-co-engineer}/assets/acpx-runtime.manifest.json (95%) rename plugins/{plumbob-harness-control => codex-co-engineer}/assets/acpx-runtime.mjs (100%) rename plugins/{plumbob-harness-control => codex-co-engineer}/assets/acpx-third-party-notices.md (99%) rename plugins/{plumbob-harness-control => codex-co-engineer}/assets/co-engineer.png (100%) rename plugins/{plumbob-harness-control => codex-co-engineer}/assets/icon.svg (100%) rename plugins/{plumbob-harness-control => codex-co-engineer}/bin/set-model-api-key (88%) rename plugins/{plumbob-harness-control => codex-co-engineer}/bin/setup.mjs (100%) rename plugins/{plumbob-harness-control => codex-co-engineer}/mcp/v3/acp-worker.mjs (100%) rename plugins/{plumbob-harness-control => codex-co-engineer}/mcp/v3/cursor-cloud-worker.mjs (100%) rename plugins/{plumbob-harness-control => codex-co-engineer}/mcp/v3/process-boundary.mjs (100%) rename plugins/{plumbob-harness-control => codex-co-engineer}/mcp/v3/server.mjs (99%) rename plugins/{plumbob-harness-control => codex-co-engineer}/mcp/v3/single-turn.flow.mjs (100%) rename plugins/{plumbob-harness-control => codex-co-engineer}/mcp/v3/supervisor.mjs (99%) rename plugins/{plumbob-harness-control => codex-co-engineer}/mcp/v3/task-store.mjs (100%) rename plugins/{plumbob-harness-control => codex-co-engineer}/package.json (91%) rename plugins/{plumbob-harness-control/skills/control-plumbob-agents => codex-co-engineer/skills/control-codex-co-engineer-agents}/SKILL.md (99%) rename plugins/{plumbob-harness-control/skills/control-plumbob-agents => codex-co-engineer/skills/control-codex-co-engineer-agents}/agents/openai.yaml (60%) rename plugins/{plumbob-harness-control => codex-co-engineer}/test/acpx-fake-agent.mjs (100%) rename plugins/{plumbob-harness-control => codex-co-engineer}/test/acpx-runtime.test.mjs (100%) rename plugins/{plumbob-harness-control => codex-co-engineer}/test/branding.test.mjs (62%) rename plugins/{plumbob-harness-control => codex-co-engineer}/test/fake-acpx.mjs (100%) rename plugins/{plumbob-harness-control => codex-co-engineer}/test/setup.test.mjs (100%) rename plugins/{plumbob-harness-control => codex-co-engineer}/test/v3-acp-worker.test.mjs (100%) rename plugins/{plumbob-harness-control => codex-co-engineer}/test/v3-cursor-cloud-worker.test.mjs (100%) rename plugins/{plumbob-harness-control => codex-co-engineer}/test/v3-process-boundary.test.mjs (100%) rename plugins/{plumbob-harness-control => codex-co-engineer}/test/v3-server.test.mjs (94%) rename plugins/{plumbob-harness-control => codex-co-engineer}/test/v3-supervisor.test.mjs (99%) rename plugins/{plumbob-harness-control => codex-co-engineer}/test/v3-task-store.test.mjs (100%) rename plugins/{plumbob-harness-control => codex-co-engineer}/vendor/dsh-acp-demo/LICENSE (100%) rename plugins/{plumbob-harness-control => codex-co-engineer}/vendor/dsh-acp-demo/PROVENANCE.json (100%) rename plugins/{plumbob-harness-control => codex-co-engineer}/vendor/dsh-acp-demo/lib/bin.js (100%) rename plugins/{plumbob-harness-control => codex-co-engineer}/vendor/dsh-acp-demo/lib/index.js (100%) rename plugins/{plumbob-harness-control => codex-co-engineer}/vendor/dsh-acp-demo/lib/invariant.js (100%) rename plugins/{plumbob-harness-control => codex-co-engineer}/vendor/dsh-acp-demo/package.json (100%) diff --git a/.codex/release-gate.toml b/.codex/release-gate.toml index 72f8784..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 @@ -87,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 9993a83..b007d95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## [Unreleased] +## [3.0.2] - 2026-08-19 + +### 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 09c6d6e..b2f5f50 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,8 @@ 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. - 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 084528c..123cff1 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,46 @@ # 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: +**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. -- Grok Build on the local host -- Cursor Local -- Cursor Cloud -- DeepSeek Harness (DSH) with Muse Spark +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. -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 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`. -The stable plugin identifier is `plumbob-harness-control`. Version 3 exposes -five tools: `status`, `delegate`, `task`, `tasks`, and `cancel`. +## 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 +- poll 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 +48,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 +59,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 ``` @@ -62,42 +87,34 @@ 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. - -## Delegation model +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. -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. +## Safety and workspace model -Every local worker is launched as a manager-owned transient `systemd --user` -service with `KillMode=control-group` solely so cancellation reaches detached +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. This check occurs before 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. +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. @@ -108,9 +125,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 @@ -132,7 +149,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 { @@ -146,7 +165,7 @@ Example local review: } ``` -Example Cursor Cloud implementation: +Cursor Cloud implementation: ```json { @@ -160,9 +179,6 @@ 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; @@ -198,16 +214,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 bbe07ee..7a54fa6 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. 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 5bc5313..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.1", + "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 96% rename from plugins/plumbob-harness-control/.mcp.json rename to plugins/codex-co-engineer/.mcp.json index bccc2bb..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": [ diff --git a/plugins/plumbob-harness-control/README.md b/plugins/codex-co-engineer/README.md similarity index 62% rename from plugins/plumbob-harness-control/README.md rename to plugins/codex-co-engineer/README.md index b1d631b..f9f8a26 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,51 @@ 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 and runtime identity | +| `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 +## 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 | -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 +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,10 +122,9 @@ 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 @@ -137,10 +145,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. @@ -158,8 +165,7 @@ 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. @@ -183,7 +189,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 { @@ -196,9 +204,53 @@ 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 +} +``` + 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 100% rename from plugins/plumbob-harness-control/mcp/v3/cursor-cloud-worker.mjs rename to plugins/codex-co-engineer/mcp/v3/cursor-cloud-worker.mjs diff --git a/plugins/plumbob-harness-control/mcp/v3/process-boundary.mjs b/plugins/codex-co-engineer/mcp/v3/process-boundary.mjs similarity index 100% rename from plugins/plumbob-harness-control/mcp/v3/process-boundary.mjs rename to plugins/codex-co-engineer/mcp/v3/process-boundary.mjs diff --git a/plugins/plumbob-harness-control/mcp/v3/server.mjs b/plugins/codex-co-engineer/mcp/v3/server.mjs similarity index 99% rename from plugins/plumbob-harness-control/mcp/v3/server.mjs rename to plugins/codex-co-engineer/mcp/v3/server.mjs index 67be655..fd41b22 100644 --- a/plugins/plumbob-harness-control/mcp/v3/server.mjs +++ b/plugins/codex-co-engineer/mcp/v3/server.mjs @@ -122,7 +122,7 @@ async function handle(message) { result: { protocolVersion: negotiated, capabilities: { tools: { listChanged: false } }, - serverInfo: { name: 'codex-co-engineer', title: 'Codex-Co-Engineer', version: '3.0.1' }, + serverInfo: { name: 'codex-co-engineer', title: 'Codex-Co-Engineer', version: '3.0.2' }, }, }); 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 99% rename from plugins/plumbob-harness-control/mcp/v3/supervisor.mjs rename to plugins/codex-co-engineer/mcp/v3/supervisor.mjs index ec636ad..02fee48 100644 --- a/plugins/plumbob-harness-control/mcp/v3/supervisor.mjs +++ b/plugins/codex-co-engineer/mcp/v3/supervisor.mjs @@ -766,7 +766,7 @@ export async function supervisorStatus(root = stateRoot(), dependencies = {}) { }; } return { - version: '3.0.1', + version: '3.0.2', healthy: boundary.ready, active: tasks.filter((task) => ACTIVE.has(task.status)).length, providers: ['grok', 'cursor-local', 'dsh', 'cursor-cloud'], diff --git a/plugins/plumbob-harness-control/mcp/v3/task-store.mjs b/plugins/codex-co-engineer/mcp/v3/task-store.mjs similarity index 100% rename from plugins/plumbob-harness-control/mcp/v3/task-store.mjs rename to plugins/codex-co-engineer/mcp/v3/task-store.mjs 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 a351971..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.1", + "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 99% 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 2a8177f..f1b5a46 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. --- 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/plumbob-harness-control/test/branding.test.mjs b/plugins/codex-co-engineer/test/branding.test.mjs similarity index 62% rename from plugins/plumbob-harness-control/test/branding.test.mjs rename to plugins/codex-co-engineer/test/branding.test.mjs index a726243..1afdda3 100644 --- a/plugins/plumbob-harness-control/test/branding.test.mjs +++ b/plugins/codex-co-engineer/test/branding.test.mjs @@ -11,17 +11,29 @@ test('plugin presents the Co-Engineer brand with usable icon assets', async () = await readFile(path.join(ROOT, '.codex-plugin', 'plugin.json'), 'utf8'), ); - assert.equal(manifest.name, 'plumbob-harness-control'); - assert.equal(manifest.version, '3.0.1'); + 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')); - const environment = mcp.mcpServers['plumbob-harness-control'].env_vars; + 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); + const icon = await readFile(path.join(ROOT, 'assets', 'icon.svg'), 'utf8'); assert.match(icon, /aria-label="Co-Engineer"/); 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 100% 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 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 100% rename from plugins/plumbob-harness-control/test/v3-process-boundary.test.mjs rename to plugins/codex-co-engineer/test/v3-process-boundary.test.mjs diff --git a/plugins/plumbob-harness-control/test/v3-server.test.mjs b/plugins/codex-co-engineer/test/v3-server.test.mjs similarity index 94% rename from plugins/plumbob-harness-control/test/v3-server.test.mjs rename to plugins/codex-co-engineer/test/v3-server.test.mjs index 0ff55ce..25874a9 100644 --- a/plugins/plumbob-harness-control/test/v3-server.test.mjs +++ b/plugins/codex-co-engineer/test/v3-server.test.mjs @@ -54,7 +54,9 @@ test('advertises only the thin public tool surface', async () => { { 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.1'); + 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']); }); diff --git a/plugins/plumbob-harness-control/test/v3-supervisor.test.mjs b/plugins/codex-co-engineer/test/v3-supervisor.test.mjs similarity index 99% rename from plugins/plumbob-harness-control/test/v3-supervisor.test.mjs rename to plugins/codex-co-engineer/test/v3-supervisor.test.mjs index 31be55d..8711357 100644 --- a/plugins/plumbob-harness-control/test/v3-supervisor.test.mjs +++ b/plugins/codex-co-engineer/test/v3-supervisor.test.mjs @@ -231,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'], diff --git a/plugins/plumbob-harness-control/test/v3-task-store.test.mjs b/plugins/codex-co-engineer/test/v3-task-store.test.mjs similarity index 100% rename from plugins/plumbob-harness-control/test/v3-task-store.test.mjs rename to plugins/codex-co-engineer/test/v3-task-store.test.mjs 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/scripts/inspector-preflight.mjs b/scripts/inspector-preflight.mjs index 88b4b82..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,7 +34,7 @@ try { const statusEnvelope = inspect('tools/call', 'status'); const status = statusEnvelope.structuredContent ?? JSON.parse(statusEnvelope.content?.[0]?.text ?? '{}'); - assert.equal(status.version, '3.0.1'); + assert.equal(status.version, '3.0.2'); assert.equal(status.healthy, status.local_boundary.ready); assert.equal(status.active, 0); assert.deepEqual(status.tasks, []); diff --git a/scripts/mcp-environment-preflight.mjs b/scripts/mcp-environment-preflight.mjs index e1698e8..ceb2ecf 100644 --- a/scripts/mcp-environment-preflight.mjs +++ b/scripts/mcp-environment-preflight.mjs @@ -9,9 +9,9 @@ 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', 'plumbob-harness-control'); +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?.['plumbob-harness-control']; +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']; @@ -64,7 +64,7 @@ try { })}\n`); }); const status = response.result?.structuredContent; - assert.equal(status?.version, '3.0.1'); + 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'); 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 3fdbf2e..c876f28 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,7 +27,8 @@ 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/mcp-environment-preflight.mjs', 'tools/acpx-vendor/package.json', 'tools/acpx-vendor/package-lock.json', @@ -45,16 +46,29 @@ 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.1' || packageJson.version !== '3.0.1' || !serverText.includes("version: '3.0.1'")) { - fail('Plugin manifest, package, and MCP server must all be version 3.0.1.'); +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.'); @@ -160,4 +174,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, }); From b343bea80bf694a33f91ddf85dceaa2b7cb09585 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 19 Aug 2026 20:47:46 +0000 Subject: [PATCH 3/6] fix: surface live task progress with bounded wait/cursor Project last_event from the append-only event log so MCP task/status results stay current during long ACP runs, and extend task with optional wait_ms/cursor long-poll so callers can wait for meaningful progress or a terminal state without rewriting task.json on every text delta. Co-authored-by: Cole Lyons --- CHANGELOG.md | 11 + CONTRIBUTING.md | 1 + README.md | 10 +- docs/configuration.md | 5 +- docs/data-handling.md | 7 +- plugins/codex-co-engineer/README.md | 29 ++- plugins/codex-co-engineer/mcp/v3/server.mjs | 28 ++- .../codex-co-engineer/mcp/v3/supervisor.mjs | 26 +- .../codex-co-engineer/mcp/v3/task-store.mjs | 223 ++++++++++++++++++ .../control-codex-co-engineer-agents/SKILL.md | 9 +- .../codex-co-engineer/test/branding.test.mjs | 3 + .../codex-co-engineer/test/v3-server.test.mjs | 109 +++++++-- .../test/v3-supervisor.test.mjs | 68 +++++- .../test/v3-task-store.test.mjs | 185 ++++++++++++++- scripts/validate-release.mjs | 3 + 15 files changed, 679 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b007d95..c46944c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ ## [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` long-poll + arguments so Codex can wait for meaningful progress or a terminal state + instead of hammering empty polls. Unsolicited stdio callbacks across + assistant turns are not available. + ### Changed - Adopt `codex-co-engineer` as the package, plugin, MCP server, skill, and diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b2f5f50..2345203 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,6 +34,7 @@ after the provider-free gate. - 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 123cff1..277f409 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,9 @@ 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`. +`status`, `delegate`, `task`, `tasks`, and `cancel`. `task` can long-poll +with optional `wait_ms` and `cursor`; it does not push unsolicited stdio +callbacks across assistant turns. ## What Codex-Co-Engineer is for @@ -21,7 +23,7 @@ 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 -- poll a durable receipt instead of a fire-and-forget shell job +- 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 @@ -182,8 +184,8 @@ Cursor Cloud implementation: ## 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 diff --git a/docs/configuration.md b/docs/configuration.md index 7a54fa6..b1102fb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -62,7 +62,10 @@ 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 long-poll. The +result includes a compact `progress` snapshot; 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/codex-co-engineer/README.md b/plugins/codex-co-engineer/README.md index f9f8a26..a2f8a3c 100644 --- a/plugins/codex-co-engineer/README.md +++ b/plugins/codex-co-engineer/README.md @@ -22,7 +22,7 @@ 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 | +| `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 | @@ -30,6 +30,14 @@ The MCP server exposes five tools: repository root, and a prompt. Providers are `grok`, `cursor-local`, `cursor-cloud`, and `dsh`; roles are `review` and `implement`. +`task` always returns a compact `progress` snapshot (`event_cursor`, +`last_event`, `new_event_count`, `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. 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 | @@ -172,8 +180,8 @@ 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 @@ -218,6 +226,21 @@ Cursor Cloud implementation: } ``` +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 the event log or receipt advances, the task +becomes terminal, or `wait_ms` elapses. 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. diff --git a/plugins/codex-co-engineer/mcp/v3/server.mjs b/plugins/codex-co-engineer/mcp/v3/server.mjs index fd41b22..badb5fe 100644 --- a/plugins/codex-co-engineer/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 long-polls until meaningful progress or a terminal state. 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 long-poll. Returns immediately on meaningful progress, terminal state, or timeout. 0 is a non-blocking snapshot.', + }, + 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)) }); diff --git a/plugins/codex-co-engineer/mcp/v3/supervisor.mjs b/plugins/codex-co-engineer/mcp/v3/supervisor.mjs index 02fee48..893cfe8 100644 --- a/plugins/codex-co-engineer/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 { @@ -741,11 +743,27 @@ 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(), dependencies = {}) { @@ -772,6 +790,6 @@ export async function supervisorStatus(root = stateRoot(), dependencies = {}) { providers: ['grok', 'cursor-local', 'dsh', 'cursor-cloud'], local_boundary: boundary, readiness, - tasks: tasks.slice(0, 20), + 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 index 5c36f67..b5ccbae 100644 --- a/plugins/codex-co-engineer/mcp/v3/task-store.mjs +++ b/plugins/codex-co-engineer/mcp/v3/task-store.mjs @@ -5,10 +5,23 @@ 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 TASK_WAIT_POLL_MS = 50; +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 EVENT_TAIL_PEEK_BYTES = 16 * 1024; +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; @@ -283,6 +296,216 @@ export async function appendTaskEvent(root, taskId, event) { 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 parseCompleteEventLines(buffer) { + const lastNewline = buffer.lastIndexOf(0x0a); + if (lastNewline === -1) { + return { completeBytes: 0, lastEvent: null, eventCount: 0 }; + } + const complete = buffer.subarray(0, lastNewline + 1).toString('utf8'); + let lastEvent = null; + let eventCount = 0; + for (const line of complete.split('\n')) { + if (!line) continue; + eventCount += 1; + try { + lastEvent = JSON.parse(line); + } 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 }; +} + +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 length = size - start; + if (length === 0) { + return { + event_cursor: String(start), + new_event_count: 0, + last_event: null, + }; + } + const buffer = Buffer.alloc(length); + const { bytesRead } = await handle.read(buffer, 0, length, start); + let slice = buffer.subarray(0, bytesRead); + let skipped = 0; + if (requested === null && start > 0) { + const firstNewline = slice.indexOf(0x0a); + if (firstNewline === -1) { + return { + event_cursor: String(start), + new_event_count: 0, + last_event: null, + }; + } + skipped = firstNewline + 1; + slice = slice.subarray(skipped); + } + const parsed = parseCompleteEventLines(slice); + return { + event_cursor: String(start + skipped + parsed.completeBytes), + new_event_count: requested === null ? 0 : parsed.eventCount, + last_event: publicProgressEvent(parsed.lastEvent), + }; + } 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); +} + +export async function waitForTaskProgress(root, taskId, { + cursor, + wait_ms, + poll_ms = TASK_WAIT_POLL_MS, + signal, + now = Date.now, + sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), +} = {}) { + const waitMs = parseTaskWaitMs(wait_ms); + const started = now(); + const initialTask = (await readTask(root, taskId)).task; + 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, + waited_ms: Math.max(0, now() - started), + wait_reason: reason, + }, + }); + const terminal = (task) => TERMINAL.has(task.status); + const requestedCursor = parseEventCursor(cursor); + if (waitMs === 0) { + return snapshot(initialTask, initialProgress, terminal(initialTask) ? 'terminal' : 'current'); + } + if (terminal(initialTask)) { + return snapshot(initialTask, initialProgress, 'terminal'); + } + if (requestedCursor !== null && initialProgress.new_event_count > 0) { + return snapshot(initialTask, initialProgress, 'progress'); + } + if (requestedCursor === null && initialProgress.last_event) { + return snapshot(initialTask, initialProgress, 'current'); + } + const pollMs = Number.isInteger(poll_ms) && poll_ms >= 1 && poll_ms <= 1_000 ? poll_ms : TASK_WAIT_POLL_MS; + while (now() < started + waitMs) { + if (signal?.aborted) break; + const remaining = started + waitMs - now(); + if (remaining <= 0) break; + await sleep(Math.min(pollMs, remaining)); + const currentTask = (await readTask(root, taskId)).task; + const currentProgress = await readTaskEventProgress(root, taskId, { cursor }); + if (terminal(currentTask)) return snapshot(currentTask, currentProgress, 'terminal'); + if (progressAdvanced(currentProgress, initialProgress) + || currentTask.status !== initialTask.status + || currentTask.revision !== initialTask.revision) { + return snapshot(currentTask, currentProgress, 'progress'); + } + } + const finalTask = (await readTask(root, taskId)).task; + const finalProgress = await readTaskEventProgress(root, taskId, { cursor }); + if (terminal(finalTask)) return snapshot(finalTask, finalProgress, 'terminal'); + if (progressAdvanced(finalProgress, initialProgress) + || finalTask.status !== initialTask.status + || finalTask.revision !== initialTask.revision) { + return snapshot(finalTask, finalProgress, 'progress'); + } + return snapshot(finalTask, finalProgress, 'timeout'); +} + +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.'); diff --git a/plugins/codex-co-engineer/skills/control-codex-co-engineer-agents/SKILL.md b/plugins/codex-co-engineer/skills/control-codex-co-engineer-agents/SKILL.md index f1b5a46..9b582e5 100644 --- a/plugins/codex-co-engineer/skills/control-codex-co-engineer-agents/SKILL.md +++ b/plugins/codex-co-engineer/skills/control-codex-co-engineer-agents/SKILL.md @@ -28,8 +28,13 @@ 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 - task. +9. Watch with `task`. A bare `task_id` returns the current receipt plus a + compact `progress` snapshot (`event_cursor`, `last_event`, `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. 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/codex-co-engineer/test/branding.test.mjs b/plugins/codex-co-engineer/test/branding.test.mjs index 1afdda3..e473ba2 100644 --- a/plugins/codex-co-engineer/test/branding.test.mjs +++ b/plugins/codex-co-engineer/test/branding.test.mjs @@ -33,6 +33,9 @@ test('plugin presents the Co-Engineer brand with usable icon assets', async () = '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 callbacks/u); const icon = await readFile(path.join(ROOT, 'assets', 'icon.svg'), 'utf8'); assert.match(icon, /aria-label="Co-Engineer"/); diff --git a/plugins/codex-co-engineer/test/v3-server.test.mjs b/plugins/codex-co-engineer/test/v3-server.test.mjs index 25874a9..e79d02b 100644 --- a/plugins/codex-co-engineer/test/v3-server.test.mjs +++ b/plugins/codex-co-engineer/test/v3-server.test.mjs @@ -7,9 +7,11 @@ 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 conversation(messages, environment = process.env) { +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: { @@ -24,23 +26,29 @@ async function conversation(messages, environment = process.env) { stdio: ['pipe', 'pipe', 'pipe'], }); const lines = readline.createInterface({ input: child.stdout, crlfDelay: Infinity }); - const values = []; + const pending = []; let stderr = ''; child.stderr.on('data', (chunk) => { stderr = `${stderr}${chunk}`.slice(-4096); }); - const done = new Promise((resolve, reject) => { - lines.on('line', (line) => { - values.push(JSON.parse(line)); - if (values.length === messages.length) resolve(); - }); - child.once('error', reject); - child.once('exit', (code, signal) => reject(new Error( - `MCP server exited before the conversation completed (${code ?? signal}): ${stderr}`, - ))); + 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 { - for (const message of messages) child.stdin.write(`${JSON.stringify(message)}\n`); - await done; - return values; + return await callback({ state, request }); } finally { child.stdin.end(); child.kill('SIGTERM'); @@ -49,6 +57,14 @@ async function conversation(messages, environment = process.env) { } } +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' } }, @@ -58,6 +74,71 @@ test('advertises only the thin public tool surface', async () => { 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: 'text_delta', 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 () => { diff --git a/plugins/codex-co-engineer/test/v3-supervisor.test.mjs b/plugins/codex-co-engineer/test/v3-supervisor.test.mjs index 8711357..c07690f 100644 --- a/plugins/codex-co-engineer/test/v3-supervisor.test.mjs +++ b/plugins/codex-co-engineer/test/v3-supervisor.test.mjs @@ -14,7 +14,7 @@ import { supervisorStatus, taskStatus, } from '../mcp/v3/supervisor.mjs'; -import { createLaunchReservation, createTask, readRuntimeRecord, readTask, updateTask } from '../mcp/v3/task-store.mjs'; +import { appendTaskEvent, createLaunchReservation, createTask, readRuntimeRecord, readTask, updateTask } from '../mcp/v3/task-store.mjs'; const SHA = 'a'.repeat(40); const readyBoundary = async () => ({ @@ -308,6 +308,72 @@ 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, + }); + setTimeout(() => { + cancelTask(root, 'wait-cancel', { + stopBoundary: async () => {}, + }).catch(() => {}); + }, 20); + const value = await pending; + 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 index ac29e89..1b32894 100644 --- a/plugins/codex-co-engineer/test/v3-task-store.test.mjs +++ b/plugins/codex-co-engineer/test/v3-task-store.test.mjs @@ -1,8 +1,5 @@ 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 { appendFile, mkdtemp, open, readFile, stat, utimes, writeFile } from 'node:fs/promises'; import { appendTaskEvent, @@ -11,13 +8,18 @@ import { createTask, launchReservationActive, listTasks, + parseEventCursor, + parseTaskWaitMs, + projectLiveLastEvent, readPrompt, readRuntimeRecord, readTask, + readTaskEventProgress, requireTaskId, reserveTaskLaunch, taskPaths, updateTask, + waitForTaskProgress, writeRuntimeRecord, } from '../mcp/v3/task-store.mjs'; @@ -132,6 +134,181 @@ test('events are JSONL and listTasks ignores invalid task directories', async () 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, + poll_ms: 10, + }); + setTimeout(() => { + appendTaskEvent(root, 'wait-one', { + type: 'provider', + event: { type: 'text_delta', 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.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); + + const terminalWait = waitForTaskProgress(root, 'wait-one', { + cursor: woke.progress.event_cursor, + wait_ms: 1_000, + poll_ms: 10, + }); + 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, + poll_ms: 10, + }); + 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, + poll_ms: 10, + }))); + 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: 'text_delta', 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' } }); diff --git a/scripts/validate-release.mjs b/scripts/validate-release.mjs index c876f28..ccfd205 100755 --- a/scripts/validate-release.mjs +++ b/scripts/validate-release.mjs @@ -83,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.'); From 67ef5f349d8cb21259b721b04ee872efa4e253b8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 19 Aug 2026 20:50:41 +0000 Subject: [PATCH 4/6] fix: read configured origin and harden progress tests Use the local remote.origin.url for Cursor Cloud so host insteadOf credential rewrites cannot leak into receipts or fail dispatch. Repair progress-test fixtures and isolate the skill-doc wait contract. Co-authored-by: Cole Lyons --- CHANGELOG.md | 3 +++ .../mcp/v3/cursor-cloud-worker.mjs | 2 +- .../codex-co-engineer/test/branding.test.mjs | 2 +- .../test/v3-cursor-cloud-worker.test.mjs | 21 +++++++++++++++++++ .../test/v3-task-store.test.mjs | 5 ++++- 5 files changed, 30 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c46944c..99da322 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ arguments so Codex can wait for meaningful progress or a terminal state instead of hammering empty polls. 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 diff --git a/plugins/codex-co-engineer/mcp/v3/cursor-cloud-worker.mjs b/plugins/codex-co-engineer/mcp/v3/cursor-cloud-worker.mjs index 0838402..b2e2f08 100644 --- a/plugins/codex-co-engineer/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/codex-co-engineer/test/branding.test.mjs b/plugins/codex-co-engineer/test/branding.test.mjs index e473ba2..4b9c1e0 100644 --- a/plugins/codex-co-engineer/test/branding.test.mjs +++ b/plugins/codex-co-engineer/test/branding.test.mjs @@ -35,7 +35,7 @@ test('plugin presents the Co-Engineer brand with usable icon assets', async () = 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 callbacks/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"/); diff --git a/plugins/codex-co-engineer/test/v3-cursor-cloud-worker.test.mjs b/plugins/codex-co-engineer/test/v3-cursor-cloud-worker.test.mjs index 14e8ba1..545208d 100644 --- a/plugins/codex-co-engineer/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/codex-co-engineer/test/v3-task-store.test.mjs b/plugins/codex-co-engineer/test/v3-task-store.test.mjs index 1b32894..bca02b6 100644 --- a/plugins/codex-co-engineer/test/v3-task-store.test.mjs +++ b/plugins/codex-co-engineer/test/v3-task-store.test.mjs @@ -1,5 +1,8 @@ import assert from 'node:assert/strict'; 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 { appendTaskEvent, @@ -196,7 +199,7 @@ test('partial event lines are not consumed and invalid cursors fail closed', asy 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'); + 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'); From 5c582b5ec8846f1d82c7e8bf3a30d484d3103096 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 19 Aug 2026 21:05:49 +0000 Subject: [PATCH 5/6] fix: replace task wait polling with event-driven coalesced progress Waiters now watch the task directory instead of polling task.json and events.jsonl at 20 Hz. Text deltas are rate-limited; terminal, status, and tool-call boundaries still wake promptly. Cursor catch-up reads a bounded chunk, skips oversized lines without allocating them, and reports more_events for backlog paging. Watchers and timers are always released. Co-authored-by: Cole Lyons --- CHANGELOG.md | 7 +- README.md | 2 +- docs/configuration.md | 5 +- plugins/codex-co-engineer/README.md | 20 +- plugins/codex-co-engineer/mcp/v3/server.mjs | 4 +- .../codex-co-engineer/mcp/v3/task-store.mjs | 365 +++++++++++++++--- .../control-codex-co-engineer-agents/SKILL.md | 13 +- .../codex-co-engineer/test/v3-server.test.mjs | 2 +- .../test/v3-task-store.test.mjs | 338 +++++++++++++++- 9 files changed, 676 insertions(+), 80 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99da322..c76e05c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,11 @@ 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` long-poll +- 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. Unsolicited stdio callbacks across - assistant turns are not available. + 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. diff --git a/README.md b/README.md index 277f409..58c83f3 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ 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 long-poll +`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. diff --git a/docs/configuration.md b/docs/configuration.md index b1102fb..60c5820 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -63,8 +63,9 @@ tests a server launched with only the MCP manifest's allowlisted environment. Repository paths, prompts, roles, deadlines, and workspace/PR intent are inputs to `delegate`; they are not global policy. `task` accepts optional -`wait_ms` (0-60000) and `cursor` arguments for a bounded long-poll. The -result includes a compact `progress` snapshot; it does not stream raw +`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/plugins/codex-co-engineer/README.md b/plugins/codex-co-engineer/README.md index a2f8a3c..18cbf0b 100644 --- a/plugins/codex-co-engineer/README.md +++ b/plugins/codex-co-engineer/README.md @@ -31,12 +31,15 @@ repository root, and a prompt. Providers are `grok`, `cursor-local`, `cursor-cloud`, and `dsh`; roles are `review` and `implement`. `task` always returns a compact `progress` snapshot (`event_cursor`, -`last_event`, `new_event_count`, `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. 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. +`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 @@ -237,8 +240,9 @@ Watch a running task instead of empty-polling: ``` Use the `event_cursor` from the previous `task` result. The call returns a -compact progress snapshot when the event log or receipt advances, the task -becomes terminal, or `wait_ms` elapses. Unsolicited stdio callbacks across +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 diff --git a/plugins/codex-co-engineer/mcp/v3/server.mjs b/plugins/codex-co-engineer/mcp/v3/server.mjs index badb5fe..e713691 100644 --- a/plugins/codex-co-engineer/mcp/v3/server.mjs +++ b/plugins/codex-co-engineer/mcp/v3/server.mjs @@ -36,7 +36,7 @@ const TOOLS = [ }, { name: 'task', - description: 'Inspect one task receipt, a compact live progress snapshot, and an event_cursor. Optional wait_ms long-polls until meaningful progress or a terminal state. Unsolicited stdio callbacks across assistant turns are not available.', + 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: { @@ -45,7 +45,7 @@ const TOOLS = [ type: 'integer', minimum: 0, maximum: 60000, - description: 'Optional bounded long-poll. Returns immediately on meaningful progress, terminal state, or timeout. 0 is a non-blocking snapshot.', + 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', diff --git a/plugins/codex-co-engineer/mcp/v3/task-store.mjs b/plugins/codex-co-engineer/mcp/v3/task-store.mjs index b5ccbae..351ca84 100644 --- a/plugins/codex-co-engineer/mcp/v3/task-store.mjs +++ b/plugins/codex-co-engineer/mcp/v3/task-store.mjs @@ -1,4 +1,5 @@ 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'; @@ -6,13 +7,17 @@ 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 TASK_WAIT_POLL_MS = 50; +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 EVENT_TAIL_PEEK_BYTES = 16 * 1024; +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; @@ -364,25 +369,88 @@ async function assertEventCursorBoundary(handle, offset, size) { } } +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 { completeBytes: 0, lastEvent: null, eventCount: 0 }; - } + 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 }; + 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 } = {}) { @@ -393,36 +461,26 @@ export async function readTaskEventProgress(root, taskId, { cursor } = {}) { 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 length = size - start; - if (length === 0) { - return { - event_cursor: String(start), - new_event_count: 0, - last_event: null, - }; - } + 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 { - event_cursor: String(start), - new_event_count: 0, - last_event: null, - }; - } + if (firstNewline === -1) return progressSnapshot(start, 0, emptyProgressParse(), size, requested); skipped = firstNewline + 1; slice = slice.subarray(skipped); } - const parsed = parseCompleteEventLines(slice); - return { - event_cursor: String(start + skipped + parsed.completeBytes), - new_event_count: requested === null ? 0 : parsed.eventCount, - last_event: publicProgressEvent(parsed.lastEvent), - }; + 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(); } @@ -434,17 +492,148 @@ function progressAdvanced(current, baseline) { || 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, - poll_ms = TASK_WAIT_POLL_MS, signal, now = Date.now, - sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), + 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 initialTask = (await readTask(root, taskId)).task; + const { task: initialTask, paths } = await readTask(root, taskId); const initialProgress = await readTaskEventProgress(root, taskId, { cursor }); const snapshot = (task, progress, reason) => ({ task, @@ -452,48 +641,118 @@ export async function waitForTaskProgress(root, taskId, { 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.new_event_count > 0) { + 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'); } - const pollMs = Number.isInteger(poll_ms) && poll_ms >= 1 && poll_ms <= 1_000 ? poll_ms : TASK_WAIT_POLL_MS; - while (now() < started + waitMs) { - if (signal?.aborted) break; - const remaining = started + waitMs - now(); - if (remaining <= 0) break; - await sleep(Math.min(pollMs, remaining)); - const currentTask = (await readTask(root, taskId)).task; - const currentProgress = await readTaskEventProgress(root, taskId, { cursor }); - if (terminal(currentTask)) return snapshot(currentTask, currentProgress, 'terminal'); - if (progressAdvanced(currentProgress, initialProgress) - || currentTask.status !== initialTask.status - || currentTask.revision !== initialTask.revision) { - return snapshot(currentTask, currentProgress, 'progress'); - } + if (signal?.aborted) { + return snapshot(initialTask, initialProgress, 'timeout'); } - const finalTask = (await readTask(root, taskId)).task; - const finalProgress = await readTaskEventProgress(root, taskId, { cursor }); - if (terminal(finalTask)) return snapshot(finalTask, finalProgress, 'terminal'); - if (progressAdvanced(finalProgress, initialProgress) - || finalTask.status !== initialTask.status - || finalTask.revision !== initialTask.revision) { - return snapshot(finalTask, finalProgress, 'progress'); + + 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); } - return snapshot(finalTask, finalProgress, 'timeout'); } export async function projectLiveLastEvent(root, task) { diff --git a/plugins/codex-co-engineer/skills/control-codex-co-engineer-agents/SKILL.md b/plugins/codex-co-engineer/skills/control-codex-co-engineer-agents/SKILL.md index 9b582e5..7c9b2ae 100644 --- a/plugins/codex-co-engineer/skills/control-codex-co-engineer-agents/SKILL.md +++ b/plugins/codex-co-engineer/skills/control-codex-co-engineer-agents/SKILL.md @@ -29,12 +29,17 @@ Use the five MCP tools for delegation and lifecycle control. 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. Watch with `task`. A bare `task_id` returns the current receipt plus a - compact `progress` snapshot (`event_cursor`, `last_event`, `wait_reason`). + 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. Unsolicited stdio - callbacks across assistant turns are not available. Never replay an - active or prompt-dispatched task. + 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/codex-co-engineer/test/v3-server.test.mjs b/plugins/codex-co-engineer/test/v3-server.test.mjs index e79d02b..b7ba7c0 100644 --- a/plugins/codex-co-engineer/test/v3-server.test.mjs +++ b/plugins/codex-co-engineer/test/v3-server.test.mjs @@ -122,7 +122,7 @@ test('task returns a compact live snapshot and can wait for the next event', asy setTimeout(() => { appendTaskEvent(state, 'server-wait', { type: 'provider', - event: { type: 'text_delta', text: 'second-visible' }, + event: { type: 'tool_call', title: 'read', text: 'second-visible' }, }).catch(() => {}); }, 20); const waited = (await pending).result.structuredContent; diff --git a/plugins/codex-co-engineer/test/v3-task-store.test.mjs b/plugins/codex-co-engineer/test/v3-task-store.test.mjs index bca02b6..0a3184c 100644 --- a/plugins/codex-co-engineer/test/v3-task-store.test.mjs +++ b/plugins/codex-co-engineer/test/v3-task-store.test.mjs @@ -1,14 +1,20 @@ 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, @@ -22,6 +28,7 @@ import { reserveTaskLaunch, taskPaths, updateTask, + waitDelay, waitForTaskProgress, writeRuntimeRecord, } from '../mcp/v3/task-store.mjs'; @@ -227,25 +234,27 @@ test('wait wakes on appended progress, terminal status, or timeout without leaki const pending = waitForTaskProgress(root, 'wait-one', { cursor: started.progress.event_cursor, wait_ms: 1_000, - poll_ms: 10, }); setTimeout(() => { appendTaskEvent(root, 'wait-one', { type: 'provider', - event: { type: 'text_delta', text: 'live-progress', pid: 99, argv: ['leak'] }, + 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, - poll_ms: 10, }); setTimeout(() => { updateTask(root, 'wait-one', { status: 'cancelled', finished_at: new Date().toISOString() }).catch(() => {}); @@ -267,7 +276,6 @@ test('wait wakes on appended progress, terminal status, or timeout without leaki const timedOut = await waitForTaskProgress(root, 'wait-timeout', { cursor: idle.event_cursor, wait_ms: 40, - poll_ms: 10, }); assert.equal(timedOut.progress.wait_reason, 'timeout'); assert.ok(timedOut.progress.waited_ms >= 40); @@ -284,11 +292,10 @@ test('concurrent readers can wait while another process appends events', async ( const waiters = Promise.all(Array.from({ length: 4 }, () => waitForTaskProgress(root, 'wait-race', { cursor: baseline.event_cursor, wait_ms: 1_000, - poll_ms: 10, }))); 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: 'text_delta', text: 'shared' } })}\n`); + await handle.appendFile(`${JSON.stringify({ at: new Date().toISOString(), type: 'provider', event: { type: 'tool_call', title: 'read', text: 'shared' } })}\n`); } finally { await handle.close(); } @@ -323,3 +330,322 @@ test('runtime identity is stored separately from the task receipt', async () => 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'); +}); From 00d4386770b934a16a6e2fa884c2f3b466e21d66 Mon Sep 17 00:00:00 2001 From: ajhcs <176340565+ajhcs@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:19:16 +0000 Subject: [PATCH 6/6] test: await task cancellation before cleanup --- .../codex-co-engineer/test/v3-supervisor.test.mjs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/plugins/codex-co-engineer/test/v3-supervisor.test.mjs b/plugins/codex-co-engineer/test/v3-supervisor.test.mjs index c07690f..f1523ee 100644 --- a/plugins/codex-co-engineer/test/v3-supervisor.test.mjs +++ b/plugins/codex-co-engineer/test/v3-supervisor.test.mjs @@ -366,12 +366,14 @@ test('task wait returns when a later event arrives or the task is cancelled', as cursor: baseline.progress.event_cursor, wait_ms: 1_000, }); - setTimeout(() => { - cancelTask(root, 'wait-cancel', { - stopBoundary: async () => {}, - }).catch(() => {}); - }, 20); - const value = await pending; + 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 {