| name | moonshine-listen |
| description | Listen for moonshine authorship feedback and address comments on an interval. Invoke when the author asks you to watch for feedback from the article HUD, or when prompted to start the listener. Best run under /loop (e.g. `/loop $moonshine-listen`) so it keeps ticking while the session is idle. |
moonshine-listen — authorship feedback listener (Claude Code adapter)
This is the idle-coverage half of the moonshine authorship-feedback loop
(plugins/moonshine/FEEDBACK.md). The Stop hook already handles delivery at
turn boundaries; this skill covers requests that land while the session is
idle, and powers the HUD's live status (the heartbeat) and its mode controls
(the control.json it reads). Note the default mode is accumulate:
comments pile up and are only drained when the author presses Address in the
HUD (address.json) or has opted into continuous auto-address (listen).
Each invocation performs one tick. Run it under /loop so the ticks repeat.
Scope
- If invoked with a project name argument, operate on
~/.agent/moonshine/<arg>/.feedback/ only.
- Otherwise operate on every existing
~/.agent/moonshine/*/.feedback/ inbox.
- If
MOONSHINE_FEEDBACK=off is set in the environment, do nothing and end
the loop — the same kill switch the Stop hook and the dev server honor.
Use absolute, ISO-8601 UTC timestamps (date -u +%Y-%m-%dT%H:%M:%SZ) everywhere.
One tick
For each in-scope .feedback/ directory:
-
Manifest. If adapter.json is missing, write
{"harness":"claude-code","version":"adapter","installedAt":"<now>"}.
-
Read control. Read control.json; treat a missing file as
{"mode":"accumulate"} (the default: hold comments, don't drain).
- If
mode == "stopped" and there is no address.json: write a final heartbeat.json with
"mode":"stopped" and the current ts, then end — do not schedule
another tick for this project. (If all in-scope projects are stopped, end
the loop entirely; tell the user the listener stopped.)
-
Route to the authoring session. Read the optional sessionId from
address.json (for a one-shot request), or from control.json when mode is
listen. Compare it with $CLAUDE_CODE_SESSION_ID. If the request names a
different session — or names one but the current session ID is unavailable
— skip this project entirely and do not consume its request. Legacy files
without a sessionId remain unscoped.
-
Heartbeat. Write heartbeat.json:
{"harness":"claude-code","mode":"<mode>","ts":"<now>","intervalSec":90,"pending":<n>}
where <n> is the number of status:"pending" comment files.
-
Drain (only when the author asked). Drain when mode == "listen"
(continuous auto-address), or whenever address.json exists (the HUD's
explicit one-shot Address request). Address is a deliberate override of
pause/stop: drain once while leaving the underlying control mode unchanged.
After a drain pass
triggered by address.json, delete address.json — the request is
consumed. When mode == "accumulate" and there is no address.json, skip
draining entirely: comments accumulate by design.
For each comment file (any *.json other than control.json /
heartbeat.json / adapter.json / address.json) that
is either status == "pending" or status == "delivered" with a
deliveredAt more than 300s ago (a comment claimed by an earlier
turn/tick that was never addressed — re-surface it rather than strand it):
- Claim it exclusively by first renaming the record itself to a private name
(
mv <id>.json <id>.json.claiming.$$). That rename is the mutual-exclusion
point — if another drainer already took it, your mv fails and you skip
the comment. Renaming a rewritten copy over the original is NOT a claim
(both racers would win). Then rewrite the claimed file with
status:"delivered" and deliveredAt:"<now>" and rename it back to
<id>.json.
- Read
target (path, kind, figureId, range, excerpt, anchorHash)
and comment. The source file is <project>/content/<target.path>.
- If
anchorHash no longer matches the current file body, the prose moved —
locate the passage by excerpt instead of trusting range.
- Address the comment by editing the source file (or the figure
component / registry it points at).
- Record the outcome: set
status:"addressed", addressedAt:"<now>", and a
one-line reply summarizing what you changed. Write atomically.
- When or and there is no address request,
skip draining. If stopped with an address request, drain once, write the
final stopped heartbeat, and then end this project's loop.
Continue the loop
After the tick, unless every in-scope project was stopped:
- When running under
/loop (dynamic): call ScheduleWakeup with
delaySeconds: 90 and the same prompt ($moonshine-listen) so the next tick
fires. Keep intervalSec in the heartbeat aligned with this delay. If the
harness has no ScheduleWakeup tool, fall back to telling the author to
re-invoke the skill (or run it under /loop).
- If you are not in a loop, tell the author to run
/loop /moonshine:moonshine-listen for continuous listening; a bare
invocation only does a single tick. (Plugin skills are namespaced — the bare
/moonshine-listen does not resolve.)
Notes
- The Stop hook and this loop both claim by atomic rename, so they never
double-process a comment even if they run close together.
- Both paths honor a request's
sessionId, so another Claude session cannot
steal feedback intended for the article's authoring session.
- Keep replies short and factual — they surface back in the author's HUD next
to their original comment.