Summary
In a client/server session (jcode serve + TUI client), invoking a skill as /skill <prompt>
shows "Activated skill: …" in the UI, but the skill body never reaches the model. The agent
receives only the trailing prompt, and the /skill token is stripped from the stored message too,
so nothing downstream can tell a skill invocation happened.
From the model's side, /grill-with-docs do the thing is indistinguishable from do the thing.
Reproduction
jcode v0.75.0 (5ae2385), Linux x86_64, TUI client against a jcode serve --socket … daemon.
/grill-with-docs do you see a SKILL body for grill-with-docs in your system prompt? answer YES or NO and quote its first line
Observed — UI prints Activated skill: grill-with-docs - A relentless interview to sharpen a plan or design, which also creates docs (ADR's and glossary) as we go., and the model replies:
NO.
I only have the one-line catalog entry, not a skill body. Its first line reads:
/grill-with-docs - A relentless interview to sharpen a plan or design, …
To load the actual body, I must run the Skill tool.
Expected — the model's system prompt contains # Active Skill\n\n# Skill: grill-with-docs …,
i.e. the SKILL.md content.
The one-line text the model does see is the # Available Skills catalog entry that
build_system_prompt_split_with_capabilities adds for every registered skill, so this is not
partial delivery. Nothing skill-specific arrives at all.
Impact
Silent, and the failure mode is bad: the UI affirms the skill is active, so the operator assumes
the agent is following it. The agent, having received a bare prompt, does something else entirely.
In my case that cost roughly 200k tokens and half an hour of work in the wrong direction before
either of us noticed.
Every /skill invocation in a remote/daemon session is affected, which on this machine is all of
them.
Cause
The skill body reaches the model only by being written into the local system prompt:
crates/jcode-tui/src/tui/app/turn_memory.rs:17-21 reads self.active_skill and passes
skill_prompt to crate::prompt::build_system_prompt_split.
crates/jcode-base/src/prompt.rs:541-544 appends it as # Active Skill\n\n{skill} to the
dynamic part.
That path is only taken by the local run loop (turn.rs:83). In a remote session the prompt
is built server-side, and the request that carries the turn has nowhere to put the skill:
// crates/jcode-protocol/src/wire.rs:39-49
Request::Message {
id: u64,
content: String,
images: Vec<(String, String)>,
system_reminder: Option<String>,
}
There is no active_skill field, and active_skill appears nowhere in jcode-protocol,
jcode-message-types or jcode-session-types. The client sets its own App::active_skill
(crates/jcode-tui/src/tui/app/input.rs:3691) and the server sets its own
Agent::active_skill (crates/jcode-app-core/src/agent/turn_execution.rs:781), but only from
its own CLI loop. The two are never connected.
Meanwhile the client has already consumed the marker:
// crates/jcode-tui/src/tui/app/input.rs:3691-3701
self.active_skill = Some(skill_name.clone());
self.push_display_message(/* "Activated skill: …" */);
if let Some(prompt) = trailing_prompt {
input = prompt; // <- the /name token is dropped here
} else {
return;
}
crates/jcode-tui/src/tui/app/remote/input_dispatch.rs:203-216 then deliberately re-derives just
the trailing prompt for the remote request. So the server is sent do the thing, with no way to
learn which skill was meant.
Confirmed in storage — ~/.jcode/sessions/session_<id>.json holds the message as:
{"type": "text", "text": "regarding this please /tmp/claudistan-harness-agnostic-handoff.md"}
for an input that was typed as /grill-with-docs regarding this please /tmp/….
Suggested fix
Carry the active skill on the wire. Add an optional field to Request::Message (and
SoftInterrupt), e.g. active_skill: Option<String>, and have the server resolve it against its
own registry when building the system prompt — the server already owns
Agent::active_skill and prompting.rs already consumes it, so only the plumbing is missing.
Sending the resolved body instead of the name would also work and would survive the client and
server disagreeing about which skills exist, at the cost of a larger payload on every turn.
Whichever is chosen, a regression test that asserts the rendered system prompt contains
# Active Skill after a remote /skill x invocation would pin it. The existing coverage
(app/tests.rs:972, remote_tests.rs:504) only asserts active_skill is set on the client,
which is true today and not sufficient.
Secondary issue: bare /skill with no prompt
When there is no trailing prompt the client returns early
(input.rs:3699-3700), so no turn is sent at all. The skill is armed for the next message. That
is defensible, but combined with the above it means a bare /skill and a /skill <prompt> fail in
two different ways, neither visible.
Note on a natural workaround that does not work
Putting the slash at the end — do the thing /grill-with-docs — silently does nothing at all, not
even the UI notice. SkillRegistry::resolve_invocation requires the slash at position 0:
// crates/jcode-base/src/skill/invocation.rs:16
let invocation = input.trim().strip_prefix('/')?;
Worth a mention in the docs, since it is the first thing an operator tries.
The working workaround today is to call the Skill tool rather than the slash command, or to
paste the SKILL.md body inline.
Summary
In a client/server session (
jcode serve+ TUI client), invoking a skill as/skill <prompt>shows "Activated skill: …" in the UI, but the skill body never reaches the model. The agent
receives only the trailing prompt, and the
/skilltoken is stripped from the stored message too,so nothing downstream can tell a skill invocation happened.
From the model's side,
/grill-with-docs do the thingis indistinguishable fromdo the thing.Reproduction
jcode v0.75.0 (5ae2385), Linux x86_64, TUI client against a
jcode serve --socket …daemon.Observed — UI prints
Activated skill: grill-with-docs - A relentless interview to sharpen a plan or design, which also creates docs (ADR's and glossary) as we go., and the model replies:Expected — the model's system prompt contains
# Active Skill\n\n# Skill: grill-with-docs …,i.e. the
SKILL.mdcontent.The one-line text the model does see is the
# Available Skillscatalog entry thatbuild_system_prompt_split_with_capabilitiesadds for every registered skill, so this is notpartial delivery. Nothing skill-specific arrives at all.
Impact
Silent, and the failure mode is bad: the UI affirms the skill is active, so the operator assumes
the agent is following it. The agent, having received a bare prompt, does something else entirely.
In my case that cost roughly 200k tokens and half an hour of work in the wrong direction before
either of us noticed.
Every
/skillinvocation in a remote/daemon session is affected, which on this machine is all ofthem.
Cause
The skill body reaches the model only by being written into the local system prompt:
crates/jcode-tui/src/tui/app/turn_memory.rs:17-21readsself.active_skilland passesskill_prompttocrate::prompt::build_system_prompt_split.crates/jcode-base/src/prompt.rs:541-544appends it as# Active Skill\n\n{skill}to thedynamic part.
That path is only taken by the local run loop (
turn.rs:83). In a remote session the promptis built server-side, and the request that carries the turn has nowhere to put the skill:
There is no
active_skillfield, andactive_skillappears nowhere injcode-protocol,jcode-message-typesorjcode-session-types. The client sets its ownApp::active_skill(
crates/jcode-tui/src/tui/app/input.rs:3691) and the server sets its ownAgent::active_skill(crates/jcode-app-core/src/agent/turn_execution.rs:781), but only fromits own CLI loop. The two are never connected.
Meanwhile the client has already consumed the marker:
crates/jcode-tui/src/tui/app/remote/input_dispatch.rs:203-216then deliberately re-derives justthe trailing prompt for the remote request. So the server is sent
do the thing, with no way tolearn which skill was meant.
Confirmed in storage —
~/.jcode/sessions/session_<id>.jsonholds the message as:{"type": "text", "text": "regarding this please /tmp/claudistan-harness-agnostic-handoff.md"}for an input that was typed as
/grill-with-docs regarding this please /tmp/….Suggested fix
Carry the active skill on the wire. Add an optional field to
Request::Message(andSoftInterrupt), e.g.active_skill: Option<String>, and have the server resolve it against itsown registry when building the system prompt — the server already owns
Agent::active_skillandprompting.rsalready consumes it, so only the plumbing is missing.Sending the resolved body instead of the name would also work and would survive the client and
server disagreeing about which skills exist, at the cost of a larger payload on every turn.
Whichever is chosen, a regression test that asserts the rendered system prompt contains
# Active Skillafter a remote/skill xinvocation would pin it. The existing coverage(
app/tests.rs:972,remote_tests.rs:504) only assertsactive_skillis set on the client,which is true today and not sufficient.
Secondary issue: bare
/skillwith no promptWhen there is no trailing prompt the client
returns early(
input.rs:3699-3700), so no turn is sent at all. The skill is armed for the next message. Thatis defensible, but combined with the above it means a bare
/skilland a/skill <prompt>fail intwo different ways, neither visible.
Note on a natural workaround that does not work
Putting the slash at the end —
do the thing /grill-with-docs— silently does nothing at all, noteven the UI notice.
SkillRegistry::resolve_invocationrequires the slash at position 0:Worth a mention in the docs, since it is the first thing an operator tries.
The working workaround today is to call the
Skilltool rather than the slash command, or topaste the
SKILL.mdbody inline.