SLOPSHOPPER

loopd — signal (a mod that tells the PM session a role agent's turn is over)

COMPANION, a mod. In a bundle root it records this session as the bundle's project-manager under loopd.pm.<bundle root> in the plugin store. In a role agent's…

new
v3.0.0MITupdated 2026-10-08cbmono/loopd/plugin-mod-signal
A shopper browsing a rack in a slop shop
README

loopd-mod-signal — the turn-is-over companion (a mod)

A mod: a hooks module Claude Code runs inside every session that loads it. This one lets a role agent's session tell the project-manager's session that its turn is over, the moment it is over, and nothing else.

/plugin marketplace add cbmono/loopd     # already added? skip
/plugin install loopd-mod-signal@loopd

Uninstall it (/plugin → Installed) and step 4 of the tick reads every session exactly as it did before — agent-sessions.sh state, session-usage.sh --settle, check-dispatch.sh — with no other edits anywhere. Absence is the safe behaviour, never an error.

What it does

One module, loaded in every session on the machine, with two sides told apart by the session's working directory and nothing else:

SideWhereWhenWritesSends
PMthe cwd holds instance.config.json (a bundle root)session.startloopd.pm.<bundle root> = { sessionId, at }nothing
role agentthe cwd is a linked worktree of a repo link-repos.sh marked (<repo>/.git/loopd-bundle names the bundle)every turn.complete of the main loop (a subagent's turn, agentId set, is skipped)loopd.signal.<session id> = { bundle, cwd, at, turns, durationMs, reason, isAborted, delivered }one line to the session loopd.pm.<bundle> names

The line, exactly one per turn.complete, is the same delivery the SendMessage tool makes:

loopd-signal: role session <id> finished a turn in <worktree> (reason <reason>, <ms> ms) — settle it first at step 4 of a /loopd:dispatch tick; this line orders the sweep and decides nothing

turn.complete is the end-of-work moment for a role agent, not session.end: a claude --bg session stays alive and idle after its brief is answered and never fires session.end (measured 2026-10-09 on 2.1.293, docs/spikes/mods-in-background-sessions.md in cbmono/loopd). The store record is written before the message, so step 4 can read it whether or not the message lands. Delivery is best-effort by design — a PM session that holds or refuses inbound messages answers isDelivered: false — and after two undelivered sends in one session the role side stops sending and keeps writing the store: a session that said no is not spammed.

The bundle is resolved the way the plugin's deny-destructive.sh hook resolves it, read for read: <cwd>/.git must be a file (so a human's main clone, where .git is a directory, is never a role agent), its gitdir: names the worktree's git dir, that dir's commondir names the repo's common dir (relative, ../.., folded lexically — no symlink is resolved), and <common>/loopd-bundle names a directory that must hold instance.config.json. Any link missing ⇒ the hook hands the event on and does nothing: a repo with no marker (a bundle not re-stamped since the marker shipped, or one outside reposRoot) leaves its agents silent, exactly as it leaves them without a baseline (docs/conventions.md §19).

Core's plugin/tick-steps/step-4-advance.md says what the line is worth: order, never a verdict. A tick that has received one settles that session first with the same three reads it always ran; a tick that has received none runs as before; and a turn that ended is not a PR that exists, so nothing is advanced on the message itself.

What it reads

Five paths, every one of them a link in git's own worktree layout or the one marker the plugin writes, and nothing else — never a parent directory walked, never a path a config file names, never a task document:

PathCallWhy
<cwd>/instance.config.jsonexiststhe cwd is a bundle root ⇒ this is the PM side; a turn.complete here signals nothing
<cwd>/.gitstat, then reada file in a linked worktree, naming the gitdir; a directory in a main clone, which stops the walk
<gitdir>/commondirreadthe repo's common git dir, relative to the gitdir; absent ⇒ the gitdir is the common dir
<common>/loopd-bundlereadthe marker link-repos.sh writes: the bundle root
<bundle>/instance.config.jsonexiststhe marker names a real bundle; a marker naming nothing is ignored

The .git, commondir and loopd-bundle spellings are git's and link-repos.sh's; tests/mods.test.sh asserts that every path literal the module spells is named in this section.

What it never does

A mod runs with your permissions in every session on the machine, so the lines it does not cross are the whole design, and tests/mods.test.sh in cbmono/loopd asserts each on the source and on what claude plugin validate reads out of it:

  • No model call — nothing here spends the plan.
  • No permission decision — it never hooks tool.check; every event it sees is handed on.
  • No process, no network, no environment — $.process, $.http and $.env are never called.
  • No write — $.fs.write is never called, and $.fs.ancestors (which walks UP from the cwd) is never called either. The five reads above are the whole file-system footprint.
  • No decision about a task — it reads no task document, decides no status, re-dispatches nothing. check-dispatch.sh is report-only because a checker that acted on its own reading would automate the loop's most expensive failure (plugin/seed/CONVENTIONS.md); a signal that advanced a task would be that checker.
  • No prompt, no consumption — session.receive passes every message through unchanged: a loopd-signal: line reaches Claude in the PM session as plain text, the tick decides what it is worth, and $.prompt.submit is never called. Nothing here starts a tick.
  • More than one message per turn, or any message after two refusals — never.
  • A store write without a session id — an unattributable record is worse than none.

Where the store lives

Claude Code keeps one JSON file per plugin under ${CLAUDE_CONFIG_DIR:-~/.claude}/plugins/store/, named <plugin name>_<marketplace>-<hash>.json (<plugin name>_inline-<hash>.json for a --plugin-dir load), a flat object of key → value; plugin/scripts/session-usage.sh reads the usage companion's file of the same shape. One loopd.pm.* record per bundle and one loopd.signal.* record per role session, rewritten on every turn, a few hundred bytes each; the 4 MiB store limit is the vendor's and nothing here prunes, which is the usage companion's rule too.

Tested with

Claude Code 2.1.293 (claude --version, 2026-10-09). Mods need v2.1.287 or later; the $.session.send call and the session.receive event need v2.1.293. The events and methods can change between releases, so after a CLI update run, from this directory:

claude plugin validate . --strict
claude plugin test

Measured on 2.1.293: a --plugin-dir load of this mod fires in claude -p and in a detached claude --bg session started from a trusted directory, and turn.complete carries reason, durationMs, isAborted and — for a subagent's turn — agentId. Not measured end to end: a real --bg role agent's line arriving in a real PM session, which needs two live sessions and a stamped bundle; the kit tests pin both halves against the documented shapes (e.to reaches the session.send event as a string, a stub answers { isDelivered }).

Delete it

/plugin uninstall loopd-mod-signal@loopd, or remove the directory from a checkout. The contract this plugin is an instance of — how a companion registers, where core looks, and the rule that a companion ADDS behaviour and never removes a core gate — is in ../plugin/README.md → "Companion plugins".

Source 1 files
hooks/register.ts 177 lines
1// loopd-mod-signal — a role agent's session tells the project-manager's session that its
2// turn is over, the moment it is over, instead of step 4 finding out at the next tick.
3//
4// ONE MODULE, TWO SIDES, told apart by the working directory and nothing else:
5//
6//   · the PM side runs in a bundle root (`<cwd>/instance.config.json` exists). At
7//     session.start it writes `loopd.pm.<bundle root>` = { sessionId, at } to the plugin
8//     store, so a role agent of that bundle can find the session to tell. Newest wins;
9//     a stale entry costs nothing, because a send to a gone session is `isDelivered: false`.
10//   · the role side runs in a linked worktree of a repo `link-repos.sh` marked. At every
11//     turn.complete of the MAIN loop it resolves the bundle the way the plugin's
12//     deny-destructive.sh guard does — `<cwd>/.git` is a FILE naming the gitdir, the gitdir's
13//     `commondir` names the common dir, `<common>/loopd-bundle` names the bundle, and the
14//     bundle must hold `instance.config.json` — writes `loopd.signal.<session id>` to the
15//     store (the durable half, readable whether or not any message lands), and sends the PM
16//     session ONE line. Every step that fails ends the hook silently: a main clone (`.git` a
17//     directory), a repo with no marker, a marker naming nothing — each is "not a role
18//     agent", the same absence-is-silence rule the deny hook keeps (docs/conventions.md §19).
19//
20// `turn.complete` is the end-of-work moment, not `session.end`: a `claude --bg` session stays
21// alive and idle after its brief is answered and never fires session.end (measured
22// 2026-10-09 on 2.1.293, docs/spikes/mods-in-background-sessions.md). A subagent's turn
23// fires turn.complete too, with `agentId` set — an Explore child of a role agent is not the
24// role agent finishing, so those are skipped.
25//
26// THE LINE ORDERS STEP 4 AND DECIDES NOTHING. Nothing here reads a task document, decides a
27// status, submits a prompt or consumes a message: `check-dispatch.sh` is report-only because
28// a checker that acted on its own reading would automate the loop's most expensive failure
29// (plugin/seed/CONVENTIONS.md), and a signal that advanced a task would be that checker. The
30// `session.receive` hook below passes every message through unchanged; it exists so the
31// pass-through is stated and tested, not implied.
32//
33// WHAT IT READS, exactly (README.md → "What it reads", asserted by tests/mods.test.sh in
34// cbmono/loopd): `instance.config.json` under the cwd and under the marker's target, `.git`
35// under the cwd (stat, then read), the gitdir's `commondir`, and `loopd-bundle` in the common
36// dir. Nothing else, and never a write.
37//
38// NEVER: a model call, a hook on the permission event, a process, the network, the
39// environment, a file write, a walk up from the cwd — tests/mods.test.sh asserts each
40// absence on this source, so none of those is spelled here even as a call.
41
42const CONFIG = 'instance.config.json'
43const GIT = '.git'
44const COMMONDIR = 'commondir'
45const MARKER = 'loopd-bundle'
46const PM_KEY = 'loopd.pm.'
47const SIGNAL_KEY = 'loopd.signal.'
48const PREFIX = 'loopd-signal:'
49// After this many undelivered sends in one session the role side stops sending and keeps
50// writing the store: a session that refuses or holds inbound messages is not spammed, and
51// step 4 still has the record.
52const MAX_FAILED_SENDS = 2
53
54// Module state. A reload starts it over; the store is the durable copy.
55let turns = 0
56let failedSends = 0
57
58// ---------------------------------------------------------------- small, guarded helpers
59function str(v: unknown): string { return typeof v === 'string' ? v : '' }
60function num(v: unknown): number { return typeof v === 'number' && isFinite(v) && v >= 0 ? Math.floor(v) : 0 }
61// The one line the PM reads must stay one line: a path is a filename, and a filename may
62// carry a newline or an ESC on this platform (docs/conventions.md §12). Control characters
63// become one space; nothing else is touched.
64function oneLine(s: string): string { return s.replace(/[\u0000-\u001f\u007f]+/g, ' ').trim() }
65// The first line of a small file, trimmed — what `IFS= read -r` gives the deny hook.
66function firstLine(v: unknown): string { return str(v).split('\n')[0].replace(/\r$/, '').trim() }
67
68// Lexical join + normalise: `base/rel` with `.` and `..` folded, no symlink resolved, so a
69// relative `gitdir:` or `commondir` (git writes `../..`) lands on the path git means.
70function join(base: string, rel: string): string {
71  const p = rel.startsWith('/') ? rel : base.replace(/\/+$/, '') + '/' + rel
72  const out: string[] = []
73  for (const part of p.split('/')) {
74    if (part === '' || part === '.') continue
75    if (part === '..') { out.pop(); continue }
76    out.push(part)
77  }
78  return '/' + out.join('/')
79}
80
81async function cwdOf($: any): Promise<string> {
82  let cwd: unknown = null
83  try { cwd = await $.session.cwd() } catch { cwd = null }
84  const s = str(cwd).replace(/\/+$/, '')
85  return s.startsWith('/') ? s : ''
86}
87async function exists($: any, path: string): Promise<boolean> {
88  try { return (await $.fs.exists(path)) === true } catch { return false }
89}
90async function read($: any, path: string): Promise<string> {
91  try { return firstLine(await $.fs.read(path)) } catch { return '' }
92}
93async function sessionId($: any): Promise<string> {
94  let id: unknown = null
95  try { id = await $.session.id() } catch { id = null }
96  return str(id)
97}
98
99// The bundle a role agent's worktree belongs to, or '' — the deny hook's walk, read for read.
100async function bundleOf($: any, cwd: string): Promise<string> {
101  let st: any = null
102  try { st = await $.fs.stat(cwd + '/' + GIT) } catch { st = null }
103  if (!st || st.kind !== 'file') return ''          // a main clone, or no repo: not a role agent
104  let gitdir = read_gitdir(await read($, cwd + '/' + GIT))
105  if (!gitdir) return ''
106  gitdir = join(cwd, gitdir)
107  const common = await read($, gitdir + '/' + COMMONDIR)
108  const commonDir = common ? join(gitdir, common) : gitdir
109  const bundle = await read($, commonDir + '/' + MARKER)
110  if (!bundle || !bundle.startsWith('/')) return ''
111  const root = join('/', bundle)
112  if (!(await exists($, root + '/' + CONFIG))) return ''   // a marker naming nothing: silence
113  return root
114}
115function read_gitdir(line: string): string {
116  if (!line.startsWith('gitdir:')) return ''
117  return line.slice('gitdir:'.length).trim()
118}
119
120// ---------------------------------------------------------------- hooks
121export function register(on: any) {
122  // PM side: a session that starts in a bundle root is that bundle's PM for as long as it
123  // is the newest one written. Anywhere else this hook only hands the event on.
124  on('session.start', async ($: any, e: any, next: any) => {
125    const cwd = await cwdOf($)
126    if (cwd && await exists($, cwd + '/' + CONFIG)) {
127      const sid = await sessionId($)
128      if (sid) await $.store.set(PM_KEY + cwd, { sessionId: sid, at: new Date().toISOString() })
129    }
130    return next(e)
131  }).catch(($: any, e: any, next: any) => next(e))
132
133  // Role side: the main loop's turn ended in a marked worktree ⇒ record it, then say so once.
134  on('turn.complete', async ($: any, e: any, next: any) => {
135    if (e && typeof e.agentId === 'string' && e.agentId) return next(e)   // a subagent's turn
136    turns += 1
137    const cwd = await cwdOf($)
138    if (!cwd || await exists($, cwd + '/' + CONFIG)) return next(e)      // the PM, or no cwd
139    const bundle = await bundleOf($, cwd)
140    if (!bundle) return next(e)
141    const sid = await sessionId($)
142    if (!sid) return next(e)        // no id ⇒ no key ⇒ nothing anyone could attribute
143
144    const reason = str(e ? e.reason : '') || 'unknown'
145    const record = {
146      bundle, cwd, at: new Date().toISOString(), turns,
147      durationMs: num(e ? e.durationMs : 0), reason,
148      isAborted: !!(e && e.isAborted === true),
149      delivered: null as boolean | null,
150    }
151    await $.store.set(SIGNAL_KEY + sid, record)       // the durable half, before any message
152
153    if (failedSends >= MAX_FAILED_SENDS) return next(e)
154    let pm: any = null
155    try { pm = await $.store.get(PM_KEY + bundle) } catch { pm = null }
156    const to = pm && typeof pm === 'object' ? str(pm.sessionId) : ''
157    if (!to || to === sid) return next(e)
158
159    const text = oneLine(PREFIX + ' role session ' + sid + ' finished a turn in ' + cwd
160      + ' (reason ' + reason + ', ' + record.durationMs + ' ms) — settle it first at step 4 of a /loopd:dispatch tick; this line orders the sweep and decides nothing')
161    let delivered = false
162    try {
163      const sent = await $.session.send({ to: { sessionId: to }, text })
164      delivered = !!(sent && sent.isDelivered === true)
165    } catch { delivered = false }
166    if (!delivered) failedSends += 1
167    await $.store.set(SIGNAL_KEY + sid, { ...record, delivered })
168    return next(e)
169  }).catch(($: any, e: any, next: any) => next(e))
170
171  // PM side, receive: a `loopd-signal:` line — and every other message — goes through to
172  // Claude unchanged. Not consumed, no prompt submitted, no file touched: the tick decides.
173  on('session.receive', async (_$: any, e: any, next: any) => {
174    return next(e)
175  }).catch(($: any, e: any, next: any) => next(e))
176}
177