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…

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.
One module, loaded in every session on the machine, with two sides told apart by the session's working directory and nothing else:
| Side | Where | When | Writes | Sends |
|---|---|---|---|---|
| PM | the cwd holds instance.config.json (a bundle root) | session.start | loopd.pm.<bundle root> = { sessionId, at } | nothing |
| role agent | the 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.
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:
| Path | Call | Why |
|---|---|---|
<cwd>/instance.config.json | exists | the cwd is a bundle root ⇒ this is the PM side; a turn.complete here signals nothing |
<cwd>/.git | stat, then read | a file in a linked worktree, naming the gitdir; a directory in a main clone, which stops the walk |
<gitdir>/commondir | read | the repo's common git dir, relative to the gitdir; absent ⇒ the gitdir is the common dir |
<common>/loopd-bundle | read | the marker link-repos.sh writes: the bundle root |
<bundle>/instance.config.json | exists | the 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.
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:
tool.check; every event it sees is handed on.$.process, $.http and $.env are never called.$.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.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.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.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.
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 }).
/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".
hooks/register.ts 177 lines1// 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