Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/workflows/cli-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
18 changes: 18 additions & 0 deletions packages/code-acp/README.md
Original file line number Diff line number Diff line change
@@ -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@<version> 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.
47 changes: 47 additions & 0 deletions packages/code-acp/acp.js
Original file line number Diff line number Diff line change
@@ -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);
});
30 changes: 30 additions & 0 deletions packages/code-acp/package.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
29 changes: 20 additions & 9 deletions packages/local-runtime/src/skills/seed-builtin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
},
);
});
4 changes: 4 additions & 0 deletions release/public-source.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions test/vitest-suites.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading