aide-plugin-author-guide · git:20260829.3450c8e · 2026-08-29 · sha256 3eb4dc05ef258f3f

aide-plugin-author-guide git:20260829.3450c8eA

Immutable. This exact content is served forever at /api/v1/blob/3eb4dc05ef258f3f.

---
name: aide-plugin-author-guide
description: How to build an AIDE plugin — manifest contract, capability model, spawn-protocol rules, the Windows-stdin gotcha that bit the skills-inspector prototype, and the path-resolution patterns that survive both the manager's --permission spawn and direct CLI runs. Use when authoring any new plugin under plugins/, when debugging a plugin that exits 0 with no output, or when porting an existing CLI tool into the plugin system.
---

# AIDE Plugin Author Guide

Born 2026-08-28 from a real attempt to ship `plugins/skills-inspector`
(an inspector over `skills/registry.json` that suggested skills based
on a workspace's file extensions). The plugin ran in the manager's
`spawn()` but exited 0 with empty stdout and empty stderr — the
lessons below are the unwasted bit.

## What the plugin system actually does today

- `plugins/manager.mjs` exports `PluginManager` with `load()`,
  `scaffold(id)`, `setTrust(id, true)`, `execute(id, payload)`.
- Manifest validation is strict: `id` must match its folder, `api_version`
  must be `'1'`, capabilities must be in the allowlist
  (`workspace.read`, `workspace.write`, `terminal.run`, `ui.view`,
  `command.register`, `network.localhost`), entry path is jail-validated.
- `execute()` spawns a child with `--permission --allow-fs-read=<pluginDir>`
  plus per-capability grants. stdout + stderr are captured; the child
  gets `JSON.stringify(payload) + '\n'` on stdin and the pipe is closed.
- The plugin must write a single JSON object to stdout and exit 0.
  Non-zero or invalid JSON → the manager rejects with the captured stderr.


## Manifest contract (verified 2026-08-28)

```json
{
  "id": "my-plugin",                  // MUST equal the folder name
  "name": "My Plugin",
  "version": "0.1.0",
  "api_version": "1",                  // only '1' is accepted
  "description": "One line, used for command palette + preset catalog",
  "capabilities": ["workspace.read"], // see allowlist above
  "entry": "index.mjs",                // optional; null = template (no execute)
  "activation_events": [],             // reserved
  "contributes": {                     // optional, for UI
    "commands": [{"id": "my-plugin.run", "title": "Run My Plugin"}],
    "views": []
  }
}
```


## The Windows-stdin gotcha (this is the big one)

On Windows, **the manager's `child.stdin.end(payload + '\n')` does NOT
emit a `'data'` event on a closed pipe in the child.** The child's
`process.stdin` looks like it's there, but Node treats the closed
pipe as already-EOF and may never deliver the buffered bytes. Symptoms
in the child process:

- Top-level code runs (you see `MODULE_LOADED` in stderr)
- Imports succeed (you see `IMPORTS_DONE`)
- The script's `main()` is never called
- Process exits 0 with empty stdout and empty stderr

Workarounds, in order of preference:

### 1. `process.stdin.on('data', () => {})` at module top-level (keep-alive)
Registering ANY data listener keeps Node's readable stream "open" and
ensures the event loop stays alive long enough to receive the payload.
This is the **most robust** approach. Add it before the imports so
the keep-alive is in place before any module evaluation work.

```javascript
import process from 'node:process';
process.stdin.on('data', () => {});  // <-- keep-alive
import { readFileSync } from 'node:fs';
// ... rest of imports
```

### 2. `process.stdin.resume()` inside readStdin
Forces the stream into flowing mode. Combined with `setEncoding('utf8')`
and listeners for `data` / `end` / `close`, this works on most
platforms. Still flaky on Windows for short payloads — the `end` event
can fire before listeners attach if the writer closed the pipe
synchronously. Always combine with the module-level keep-alive
above for belt-and-suspenders.

### 3. Hard timeout fallback

## Path resolution (survives both spawn and direct CLI)

The manager spawns the entry as `process.execPath <args> <entry>`, so
`process.argv[1]` is the entry path. But for direct-CLI debugging
(`node plugins/<id>/index.mjs`) `argv[1]` is still the entry — so
both paths can use:

```javascript
import { join, dirname } from 'node:path';
const SCRIPT_PATH = process.argv[1] || join(process.cwd(), 'index.mjs');
const PLUGIN_DIR = dirname(SCRIPT_PATH);
const REPO_ROOT = dirname(dirname(PLUGIN_DIR));
```

`import.meta.url` works too in ESM (.mjs) but `process.argv[1]` is
more portable when the plugin might be transpiled or moved.

## --permission flag (CRITICAL on Node 26)

## Testing the plugin (the right way)

Use the existing `plugins/test-manager.mjs` pattern, but write a
companion test that does NOT go through the manager for the first
pass (it eats stdout so you can't see what's wrong). Instead, use
`child_process.spawnSync` with `input: <payload>`, `encoding: 'utf8'`,
`timeout: 5000`. Verify the child writes a single JSON line on stdout
and exits 0. THEN add the manager-level test as a smoke test.

```javascript
const r = spawnSync(process.execPath, [entry], { input: payload, encoding: 'utf8', timeout: 5000 });
assert.equal(r.status, 0);
const out = JSON.parse(r.stdout);
assert.equal(out.ok, true);
```

## Pitfalls (each cost real time on 2026-08-28)

- Do NOT add a `process.exit(0)` at the end of the script. The
  manager's execute() uses a `close` event to resolve; an explicit
  exit is fine but unneeded. If you exit too early, any pending
  stdout writes can be lost — the manager reads from the closed
  stream and may see empty output.
- Do NOT call `process.stdin.setEncoding('utf8')` BEFORE registering
  the keep-alive data listener. The encoding change can race the
  listener attachment.
- Do NOT write to stderr in production. Stderr is for the manager's
  error log; a chatty plugin that logs to stderr will fill the
  manager's log on every call. Reserve stderr for genuine errors.
- Do NOT trust a "successful" exit alone. Always assert the JSON
  shape on stdout. A plugin that exits 0 with a syntax error in
  its output (e.g. undefined) will silently break the manager.
- Do NOT skip the manifest's `api_version: '1'`. The validator
  rejects anything else, and the error message is not always surfaced
  to the user — they see "plugin is unavailable" instead.

## Where to ship the next plugin

High-value, low-risk plugins that fit the existing capability model:
- `plugins/notes-search` (`workspace.read`): index `.aide/notes/`
  and return snippets; pairs with `aide-local-notes` skill.
- `plugins/model-status` (`workspace.read`): read `models/manifest.json`
  + `.aide/index/manifest.json` and return engine health. Pairs with
  `aide-backend-autoselect` and `aide-engine-lifecycle-doctrine`.
- `plugins/skill-search` (`workspace.read`): the working version of
  what `skills-inspector` was supposed to be. Index SKILL.md files,
  keyword-match on file extensions in the workspace, return
  ranked suggestions. The lessons above are the recipe.


The manager's spawn uses `args.push('--no-addons', entry)`. The
`--no-addons` flag is unrelated; what matters is that **the child
process runs with `--permission`**. This means:
- `--allow-fs-read=<pluginDir>` is set (so the entry file can be
  read), but reading OUTSIDE the plugin directory requires
  additional `--allow-fs-read=<other-dir>` flags. Pass paths inside
  the plugin dir only, OR have the user grant extra read rights via
  the trust flow.
- `process.permission.has('net')` is `false` unless the manifest
  declares `network.localhost` AND the runtime is Node 26+. The
  test plugin manager validates this and throws at execute() time.

Wrap your readStdin in a `setTimeout(finish, 2000)` safety net so the
plugin never hangs. The manager has a 10 s outer timeout, but a
plugin-level 2 s cap keeps tests fast.

### 4. Synchronous `fs.readSync(0, buf, 0, buf.length, null)`
Works in some setups, but is brittle — `readSync` on a non-blocking
fd can return `-1` with no data even when data is buffered, and the
loop will exit with empty data. Combine with the keep-alive to be safe.

Trust is stored in `<workspace>/.aide/plugins.json` as `{ "<id>": true }`.
Without trust, `execute()` throws "plugin trust is required".