From dbab9e15681101e2d567cbbab031c259d58b6798 Mon Sep 17 00:00:00 2001 From: SaladDay <92240037+SaladDay@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:41:04 +0800 Subject: [PATCH 1/2] fix(local-runtime): refresh only owned built-in skills in shared dir The seed wiped the whole .builtin-skills dir on a fingerprint mismatch. That dir is shared between the desktop app and the CLI/ACP agent, so a CLI start deleted desktop-only skills (mavis, create-agent, ...). Replace only the dirs of skills this seed ships; per-skill rm failures (EPERM, EACCES) are logged and retried next start instead of aborting. --- .../local-runtime/src/skills/seed-builtin.ts | 29 ++++++--- .../test/unit/seed-builtin-shared-dir.test.ts | 61 +++++++++++++++++++ release/public-source.json | 1 + test/vitest-suites.json | 1 + 4 files changed, 83 insertions(+), 9 deletions(-) create mode 100644 packages/local-runtime/test/unit/seed-builtin-shared-dir.test.ts diff --git a/packages/local-runtime/src/skills/seed-builtin.ts b/packages/local-runtime/src/skills/seed-builtin.ts index 2a497ff34..0d689bb21 100644 --- a/packages/local-runtime/src/skills/seed-builtin.ts +++ b/packages/local-runtime/src/skills/seed-builtin.ts @@ -48,12 +48,11 @@ export interface SeedBuiltinSkillsDeps { * combined fingerprint (over the per-skill aggregates of every enabled * skill) of the last fully successful seed. When the marker matches the * current combined value and every enabled skill dir exists, the whole seed - * is skipped without a single write. On any mismatch the dest dir is wiped - * and every enabled skill copied fresh; the marker is written last, only + * is skipped without a single write. On any mismatch every enabled skill dir + * is replaced in place (other entries in the shared dir are left alone); the marker is written last, only * after every skill copied, so a crash or partial failure replays the whole - * seed on the next startup (self-healing). User files placed inside - * `builtinSkillsDir` are cleared on every rewrite — an accepted rev3 - * trade-off for mechanism simplicity. + * seed on the next startup (self-healing). Entries not owned by this seed + * (another host's skills, user files) are never removed. * * Must not import registry code. Fail-open everywhere: a broken skill or an * unreadable source never throws out of this function. @@ -163,21 +162,33 @@ export function seedBuiltinSkills(deps: SeedBuiltinSkillsDeps): void { if (allPresent) return; } - // Wipe + full reseed. rmSync never follows symlinks, so a user-planted link - // inside the dest tree is removed as a link and its target stays intact. + // Per-skill refresh: only dirs named after skills this seed owns are + // replaced. The dest dir is shared with other hosts (desktop app, CLI/ACP), + // so foreign skills and user files are never touched. Each failure (EPERM, + // EBUSY, ...) is contained to its skill; the marker is withheld so the next + // startup retries. try { - rmSync(builtinSkillsDir, REMOVE_OPTIONS); mkdirSync(builtinSkillsDir, { recursive: true }); } catch (err) { logger.error( { builtinSkillsDir, err: errorMessage(err) }, - 'Failed to reset built-in skills dir for seeding (kept existing content, local-runtime will continue)', + 'Failed to create built-in skills dir for seeding (local-runtime will continue)', ); return; } for (const [skillName, { src }] of enabledSkills) { const dest = join(builtinSkillsDir, skillName); + try { + rmSync(dest, REMOVE_OPTIONS); + } catch (err) { + allOk = false; + logger.error( + { skillName, err: errorMessage(err) }, + 'Failed to clear built-in skill for reseed (kept existing copy, local-runtime will continue)', + ); + continue; + } try { copySkillDir(src, dest, true); } catch (err) { diff --git a/packages/local-runtime/test/unit/seed-builtin-shared-dir.test.ts b/packages/local-runtime/test/unit/seed-builtin-shared-dir.test.ts new file mode 100644 index 000000000..58089b19a --- /dev/null +++ b/packages/local-runtime/test/unit/seed-builtin-shared-dir.test.ts @@ -0,0 +1,61 @@ +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { seedBuiltinSkills } from '../../src/skills/seed-builtin.js'; + +const roots: string[] = []; +function tmp(): string { + const dir = mkdtempSync(join(tmpdir(), 'seed-shared-')); + roots.push(dir); + return dir; +} +function skill(dir: string, name: string, body: string): void { + mkdirSync(join(dir, name), { recursive: true }); + writeFileSync(join(dir, name, 'SKILL.md'), `---\nname: ${name}\ndescription: d\n---\n${body}\n`); +} +afterEach(() => { + for (const r of roots.splice(0)) { + try { chmodSync(r, 0o755); } catch { /* ignore */ } + rmSync(r, { recursive: true, force: true }); + } +}); + +describe('seedBuiltinSkills on a shared dest dir', () => { + it('refreshes owned skills without deleting foreign skills or files', () => { + const src = tmp(); + const dest = join(tmp(), '.builtin-skills'); + skill(dest, 'mavis', 'desktop'); + skill(dest, 'create-agent', 'desktop'); + writeFileSync(join(dest, 'agent.md'), 'user'); + skill(dest, 'cli-skill', 'stale'); + skill(src, 'cli-skill', 'v1'); + + seedBuiltinSkills({ builtinSkillsDir: dest, sourceDirs: [src] }); + expect(readFileSync(join(dest, 'cli-skill', 'SKILL.md'), 'utf8')).toContain('v1'); + + skill(src, 'cli-skill', 'v2'); + seedBuiltinSkills({ builtinSkillsDir: dest, sourceDirs: [src] }); + expect(readFileSync(join(dest, 'cli-skill', 'SKILL.md'), 'utf8')).toContain('v2'); + + expect(existsSync(join(dest, 'mavis', 'SKILL.md'))).toBe(true); + expect(existsSync(join(dest, 'create-agent', 'SKILL.md'))).toBe(true); + expect(readFileSync(join(dest, 'agent.md'), 'utf8')).toBe('user'); + }); + + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'does not throw when the dest dir is not writable (EACCES/EPERM)', + () => { + const src = tmp(); + const dest = join(tmp(), '.builtin-skills'); + skill(src, 'cli-skill', 'v1'); + skill(dest, 'mavis', 'desktop'); + skill(dest, 'cli-skill', 'old'); + chmodSync(dest, 0o555); + expect(() => seedBuiltinSkills({ builtinSkillsDir: dest, sourceDirs: [src] })).not.toThrow(); + chmodSync(dest, 0o755); + expect(existsSync(join(dest, 'mavis', 'SKILL.md'))).toBe(true); + }, + ); +}); diff --git a/release/public-source.json b/release/public-source.json index 35cb481d5..4883d934e 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -2733,6 +2733,7 @@ "packages/local-runtime/test/unit/local-thread-goal-store.test.ts", "packages/local-runtime/test/unit/local-thread-goal-wiring.test.ts", "packages/local-runtime/test/unit/metrics-telemetry-privacy.test.ts", + "packages/local-runtime/test/unit/seed-builtin-shared-dir.test.ts", "packages/local-runtime/test/unit/session-diff-fallback.test.ts", "packages/local-runtime/test/unit/skill-directory-symlinks.test.ts", "packages/local-runtime/test/unit/thread-goal-kickoff-host.test.ts", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index bfe9bd78d..157c81564 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -6,6 +6,7 @@ ], "capability": [ "packages/local-runtime-v2/test/integration/compaction-openai-transport.integration.test.ts", + "packages/local-runtime/test/unit/seed-builtin-shared-dir.test.ts", "packages/local-runtime/test/unit/skill-directory-symlinks.test.ts", "packages/agent-core/test/unit/bash-subprocess-env.test.ts", "packages/agent-modules/permission/test/unit/permission/bash-policy-regressions.test.ts", From 348c38fbe143f4686183d49d37cc7e91ccc28af6 Mon Sep 17 00:00:00 2001 From: SaladDay <92240037+SaladDay@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:42:05 +0800 Subject: [PATCH 2/2] feat(acp): add @minimax-ai/code-acp launcher and lockstep publish job @minimax-ai/code ships two bins (mcode, mcode-tools), so the ACP registry's `npx @minimax-ai/code acp` cannot pick one and the registry entry is stuck at 0.2.7. code-acp is a single-bin launcher that runs the mcode bin of a pinned @minimax-ai/code. The opt-in publish-acp job stamps and publishes it at the same version after @minimax-ai/code is on npm. --- .github/workflows/cli-release.yml | 42 +++++++++++++++++++++++++++ packages/code-acp/README.md | 18 ++++++++++++ packages/code-acp/acp.js | 47 +++++++++++++++++++++++++++++++ packages/code-acp/package.json | 30 ++++++++++++++++++++ release/public-source.json | 3 ++ 5 files changed, 140 insertions(+) create mode 100644 packages/code-acp/README.md create mode 100755 packages/code-acp/acp.js create mode 100644 packages/code-acp/package.json diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml index cc7d1b103..8a0fde010 100644 --- a/.github/workflows/cli-release.yml +++ b/.github/workflows/cli-release.yml @@ -144,3 +144,45 @@ jobs: GH_REPO: ${{ github.repository }} MCODE_RELEASE_DIRECTORY: ${{ runner.temp }}/package MCODE_RELEASE_REPORTS: ${{ runner.temp }}/reports + # Publishes the single-bin ACP launcher (@minimax-ai/code-acp) in lockstep + # with @minimax-ai/code, which the ACP registry runs via `npx ... acp`. + # Opt-in: set repo variable MCODE_ACP_PUBLISH=true and secret NPM_TOKEN. + publish-acp: + needs: [build, publish] + if: github.event_name == 'push' && vars.MCODE_ACP_PUBLISH == 'true' + permissions: + contents: read + id-token: write + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + VERSION: ${{ needs.build.outputs.version }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + registry-url: 'https://registry.npmjs.org' + package-manager-cache: false + - name: Wait for @minimax-ai/code at this version on npm + shell: bash + run: | + for _ in $(seq 1 80); do + npm view "@minimax-ai/code@$VERSION" version >/dev/null 2>&1 && exit 0 + sleep 20 + done + echo "@minimax-ai/code@$VERSION not on npm after waiting" >&2; exit 1 + - name: Stamp and publish @minimax-ai/code-acp + working-directory: packages/code-acp + shell: bash + run: | + if npm view "@minimax-ai/code-acp@$VERSION" version >/dev/null 2>&1; then + echo "already published"; exit 0 + fi + npm pkg set version="$VERSION" "dependencies.@minimax-ai/code=$VERSION" + tag=latest; [[ "$VERSION" == *-* ]] && tag=next + npm publish --access public --provenance --tag "$tag" + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/packages/code-acp/README.md b/packages/code-acp/README.md new file mode 100644 index 000000000..1fac43e52 --- /dev/null +++ b/packages/code-acp/README.md @@ -0,0 +1,18 @@ +# @minimax-ai/code-acp + +ACP launcher for [MiniMax Code](https://agent.minimax.io). It runs the `mcode` +binary from `@minimax-ai/code`, forwarding all arguments, stdio, signals and the +exit code: + +```sh +npx -y @minimax-ai/code-acp@ acp +``` + +Why: `@minimax-ai/code` ships two bins (`mcode`, `mcode-tools`), so +`npx @minimax-ai/code acp` cannot decide which to run. This package has a single +bin, which is what the [ACP registry](https://github.com/agentclientprotocol/registry) +npx distribution needs. + +**Release rule:** publish this package at the same version as `@minimax-ai/code`, +with `dependencies["@minimax-ai/code"]` pinned to that exact version. The ACP +registry auto-update follows this package's version. diff --git a/packages/code-acp/acp.js b/packages/code-acp/acp.js new file mode 100755 index 000000000..7728a75c6 --- /dev/null +++ b/packages/code-acp/acp.js @@ -0,0 +1,47 @@ +#!/usr/bin/env node +// Thin launcher: run the `mcode` bin of @minimax-ai/code with the given args. +// Exists because @minimax-ai/code ships two bins, so `npx @minimax-ai/code acp` +// cannot pick one. +import { spawn } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import os from 'node:os'; +import { dirname, resolve } from 'node:path'; + +const require = createRequire(import.meta.url); +const pkgPath = require.resolve('@minimax-ai/code/package.json'); +const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); +const bin = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.mcode; +if (!bin) { + console.error('code-acp: @minimax-ai/code has no "mcode" bin'); + process.exit(1); +} +const entry = resolve(dirname(pkgPath), bin); + +const child = spawn(process.execPath, [entry, ...process.argv.slice(2)], { + stdio: 'inherit', + windowsHide: true, +}); + +for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) { + process.on(sig, () => { + try { child.kill(sig); } catch {} + }); +} + +child.on('error', (err) => { + console.error(`code-acp: failed to start mcode: ${err.message}`); + process.exit(1); +}); + +child.on('exit', (code, signal) => { + if (signal) { + for (const s of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.removeAllListeners(s); + try { + process.kill(process.pid, signal); + } catch {} + // Fallback if self-signal is unsupported (e.g. Windows): 128 + signo. + process.exit(128 + ((os.constants.signals)[signal] ?? 0)); + } + process.exit(code ?? 1); +}); diff --git a/packages/code-acp/package.json b/packages/code-acp/package.json new file mode 100644 index 000000000..019a35295 --- /dev/null +++ b/packages/code-acp/package.json @@ -0,0 +1,30 @@ +{ + "name": "@minimax-ai/code-acp", + "version": "0.0.0-dev", + "description": "Agent Client Protocol (ACP) launcher for MiniMax Code. Runs `mcode` from @minimax-ai/code so `npx @minimax-ai/code-acp acp` works. Version is stamped at release to match @minimax-ai/code.", + "type": "module", + "bin": { + "code-acp": "acp.js" + }, + "files": [ + "acp.js", + "README.md" + ], + "dependencies": { + "@minimax-ai/code": "0.0.0-dev" + }, + "engines": { + "node": ">=22.19 <23 || >=24 <27" + }, + "license": "MIT", + "homepage": "https://agent.minimax.io", + "repository": { + "type": "git", + "url": "git+https://github.com/MiniMax-AI/minimax-code.git" + }, + "keywords": [ + "minimax", + "acp", + "agent-client-protocol" + ] +} diff --git a/release/public-source.json b/release/public-source.json index 4883d934e..df74562df 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -476,6 +476,9 @@ "packages/browser-core/src/element-map-manager.ts", "packages/browser-core/src/index.ts", "packages/browser-core/src/operation-timeout.ts", + "packages/code-acp/README.md", + "packages/code-acp/acp.js", + "packages/code-acp/package.json", "packages/config/package.json", "packages/config/src/agent-capabilities.ts", "packages/config/src/agent-runtime-config.ts",