anthropics / anthropics/claude-code
`${CLAUDE_PLUGIN_ROOT}` does not resolve in plugin `settings.json`, so a plugin-shipped `subagentStatusLine` cannot reference its own script
- Dominant language
- Python
- Stars
- 145k
- Forks
- 23.1k
- PR merge metrics
- PR metrics pending
Description
**Version:** Claude Code 2.1.220 (current latest), Linux 6.8.0
## Summary
A plugin can ship a `subagentStatusLine` in its `settings.json` — the docs say so explicitly — but there is no supported way to point that command at a script the plugin bundles. Plugin settings values are merged verbatim, without the `${CLAUDE_PLUGIN_ROOT}` substitution applied to every other command-bearing plugin component, so the placeholder reaches the shell as an undefined variable and the command silently fails.
The net effect: the one documented command-bearing key a plugin may ship cannot reference the plugin's own files.
## What the docs say
From [statusline](https://code.claude.com/docs/en/statusline#subagent-status-lines):
> The same trust and `disableAllHooks` gates that apply to `statusLine` apply here. Plugins can ship a default `subagentStatusLine` in their [`settings.json`](/docs/en/plugins-reference#standard-plugin-layout).
From the [plugins reference](https://code.claude.com/docs/en/plugins-reference) component table:
> | **Settings** | `settings.json` | Default configuration applied when the plugin is enabled. Only the `agent` and `subagentStatusLine` keys are currently supported |
To be fair to the docs: the plugins reference's placeholder-resolution table does **not** promise substitution in settings. It lists five components, and settings is not among them:
> | Plugin component | Fields where placeholders resolve |
> | Skill and agent content | Anywhere the placeholder appears |
> | Hook and monitor commands | Anywhere the placeholder appears |
> | MCP `stdio` servers | `command`, `args`, `env` |
> | MCP `http`, `sse`, `ws` servers | `url`, `headers`, `headersHelper` |
> | LSP servers | `command`, `args`, `env`, `workspaceFolder` |
So this is not a contradiction between two pages. It is a gap: a feature was extended to plugins without extending path resolution with it, and without documenting the consequence. A plugin author following the statusline page has no way to learn that the pattern used by every other component will not work here.
## Reproducer
1. In a plugin's root `settings.json`:
```json
{
"subagentStatusLine": {
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/subagent-statusline.js\""
}
}
```
2. Install/enable the plugin, start a session, dispatch a subagent.
3. Rows render with Claude Code's defaults. Nothing indicates the plugin's renderer was tried and failed.
## Root cause
Plugin settings are merged by this function in the 2.1.220 bundle (recovered with `strings` from `~/.local/share/claude/versions/2.1.220`, a Bun-compiled executable — not a disassembly):
```js
function Cyy(e){let t;for(let r of e){if(!r.settings)continue;if(!t)t={};
for(let[n,o]of Object.entries(r.settings)){
if(n in t)w(`Plugin "${r.name}" overrides setting "${n}" (previously set by another plugin)`);
t[n]=o}}return t}
```
Every key is copied with a bare `t[n]=o`. The only extra logic is a same-key-collision warning across plugins.
The substitution helper exists and is applied elsewhere in the same bundle:
```js
function Xbe(e,t){let r=(o)=>o,
n=e.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g,()=>r(t.path));
if(n=n.replace(/\$\{CLAUDE_PROJECT_DIR\}/g,()=>r(Rl())),t.source){let o=t.source;
n=n.replace(/\$\{CLAUDE_PLUGIN_DATA\}/g,()=>r(lVe(o)))}return ...}
```
`Cyy` never calls it.
## Observed failure
With the placeholder unexpanded, the command Claude Code runs is `node "/scripts/subagent-statusline.js"`:
```
$ sh -c 'node "${CLAUDE_PLUGIN_ROOT}/scripts/subagent-statusline.js"'
Error: Cannot find module '/scripts/subagent-statusline.js'
at Function._resolveFilename (node:internal/modules/cjs/loader:1430:15)
code: 'MODULE_NOT_FOUND'
exit=1
```
Claude Code catches the non-zero exit and falls back to default rows — the bundle carries the strings `subagentStatusLine exited ` and `subagentStatusLine tick failed:`. The failure is invisible at the UI: rows appear, they are simply not the plugin's. That is what makes this expensive to diagnose; it reads as "my renderer produced nothing" rather than "my renderer was never found".
## Relationship to #52079
[#52079](https://github.com/anthropics/claude-code/issues/52079) reported the same class of problem for `statusLine.command` and was closed with:
> CLAUDE_PLUGIN_ROOT expansion only applies to plugin features, not to all bash commands run by Claude Code. statusline currently lives in settings *outside of plugins*, so it does not support CLAUDE_PLUGIN_ROOT. There is no mention of statusline in the plugin documentation either.
Both premises have since changed. `subagentStatusLine` **is** a plugin feature now — it is one of exactly two keys a plugin's `settings.json` may set — and the plugin documentation **does** mention it, in the component table quoted above. The rationale for closing #52079 doesn't extend to this case.
## Requested
Either of these resolves it; the first is preferable:
1. Apply the existing `Xbe` substitution to plugin settings values when merging, so `${CLAUDE_PLUGIN_ROOT}` works in `subagentStatusLine.command` as it does in hook and monitor commands.
2. Failing that, document the limitation on the statusline page and in the plugins reference component table, and state a supported way for a plugin to reference its own bundled renderer.
## Workaround, for anyone hitting this
A plugin's `SessionStart` hook *does* get the substitution, and in any case knows its own location from `__dirname`. Have it write that path to a well-known file, and have the shipped command read it:
```js
// hooks/session-start.js
const root = path.resolve(__dirname, '..');
fs.writeFileSync(path.join(os.tmpdir(), 'my-plugin-root'), root);
```
```json
{
"subagentStatusLine": {
"type": "command",
"command": "node \"$(cat \"${TMPDIR:-/tmp}/my-plugin-root\")/scripts/renderer.js\""
}
}
```
This survives plugin updates and works identically for a marketplace install and a `--plugin-dir` working tree. Two caveats: `disableAllHooks` leaves no breadcrumb (rows fall back to defaults), and with two sessions on different copies of the plugin the last to start owns the file.
Contributor guide
No contributing guide indexed for this repository
Research direction
Start by locating the plugin settings merge represented by function Cyy in the 2.1.220 bundle, then compare it with the Xbe placeholder-resolution helper and the code handling subagentStatusLine. Check how plugin settings are tested or documented, and verify that a plugin settings.json command resolves its own root and no longer falls back after the bundled renderer fails.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, node.js
- Domain
- cli, tooling
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100