`${CLAUDE_PLUGIN_ROOT}` does not resolve in plugin `settings.json`, so a plugin-shipped `subagentStatusLine` cannot reference its own script
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:
The same trust anddisableAllHooksgates that apply tostatusLineapply here. Plugins can ship a defaultsubagentStatusLinein their [settings.json](/docs/en/plugins-reference#standard-plugin-layout).
From the plugins reference component table:
| Settings |settings.json| Default configuration applied when the plugin is enabled. Only theagentandsubagentStatusLinekeys 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 | | MCPstdioservers |command,args,env| | MCPhttp,sse,wsservers |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
- In a plugin's root
settings.json:
``json``
{
"subagentStatusLine": {
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/subagent-statusline.js\""
}
}
- Install/enable the plugin, start a session, dispatch a subagent.
- 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):
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:
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 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:
- Apply the existing
Xbesubstitution to plugin settings values when merging, so${CLAUDE_PLUGIN_ROOT}works insubagentStatusLine.commandas it does in hook and monitor commands. - 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:
// hooks/session-start.js
const root = path.resolve(__dirname, '..');
fs.writeFileSync(path.join(os.tmpdir(), 'my-plugin-root'), root);
{
"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.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗