Coordinating Claude Code sessions with a mod
Shared state in place of status messages
A Claude Code mod that lets parallel sessions share a GPU and a stack without messaging each other: Continuum's leases drawn above the prompt, a guard that takes or refuses a lease before the command that needs it, and a wait that starts a turn when the lease is free.
Inside Job
A mod's hooks run inside Claude Code, and share one module's state.
A mod is a Claude Code plugin whose code registers event handlers, which Claude Code
calls before it acts on an event: a tool call, a submitted prompt, a request to the
model, a part of the interface being drawn[1]. The plugin's
hooks/hooks.json names one module, and the module exports
register(on). Each hook is ($, e, next): $ is the
mods API, e the event as frozen data, and next(e) runs the mods
after this one and then Claude Code's own behaviour[2].
A hook does one of three things with an event. It observes, returning
next(e) as it came; it rewrites, passing next a copy with a
field changed; or it answers, returning a result of its own, and then neither the later
mods nor Claude Code act on the event at all. A tool.call hook that returns
{ deny } stops a command before the permission check, and Claude reads the
text as the tool's result.
export const register: Register = on => {
on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
if (/git push .*--force/.test(e.command)) {
return { deny: 'Force pushes are refused here. Push a new branch instead.' }
}
return next(e)
})
}
Settings hooks fire on the same events, each as a shell command started for the
event[1]. Continuum's repository has five hook scripts, and the ones that
keep state keep it in files: the journal guard reads the session's transcript backwards
on every prompt to count what the journal hasn't recorded yet. A mod's hooks share its
module's variables and the session's $.state, and between them they can
draw in the interface, add a command that answers without a turn, register a tool for
Claude, run a timer, and start a turn from it[3]. Mods need Claude Code
2.1.287 or later, and draw in the terminal and the Desktop app's Code tab.
Read Receipts
A status message costs the session that receives it a turn.
Tristan runs up to four Claude Code sessions on one machine, and they share its stack,
its GPU, its working tree and each other's processes. Continuum's leases say who holds
the stack and the GPU, as Redis keys whose TTL is their expiry, which
Sessions that share a machine take leases
tells. A session learned who held what by running continuum lease list,
or by being told, so the sessions told each other.
A message from one session reaches the other's model. An idle session wakes for it, and a turn sends the session's context to the model again: Continuum's always-loaded instructions alone came to 240,927 bytes on 28 September, before a word of the conversation. When every session tells every other, four sessions send twelve messages a round, and each one is a turn somewhere. The sessions were very well informed about one another.
Tristan
"I would like to reduce the token usage (for repeated message status etc.) if possible as well. There seems to be an N:N problem with everyone getting everyone's statuses."
Who holds the stack is state, and the state already lives in Redis. So the mod reads it in each session, draws it for the person, and gives it to Claude only when a command is about to run into it.
Above the Fold
The band above the prompt is drawn for the person, and Claude never reads it.
A ui.render hook on the AbovePrompt site draws a row for each
lease held: the resource, who holds it (you for this session), what for,
and until when. While no lease is held it returns next(e), and Claude Code
draws what it would have drawn anyway[4]. The hook, cut to its leases:
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
const now = await read($, board)
if (e.props.hasSurvey || now === null || now.leases.length === 0) { return next(e) }
const { Box, Text } = $.ui.resolve(e)
return (
<Box flexDirection="row" flexWrap="wrap" columnGap={3} paddingX={1}>
{now.leases.map(l => (
<Box flexDirection="row" columnGap={1}>
<Text bold color={l.owner === now.me ? 'green' : 'yellow'}>{l.resource}</Text>
<Text>{l.owner === now.me ? 'you' : l.owner}</Text>
<Text dimColor>{l.purpose} · until {l.until}</Text>
</Box>
))}
</Box>
)
})
The rows come from a timer, started in session.start, that runs
continuum lease list --headless every 30 seconds through
$.process.run, which takes an argument list and uses no shell. The parsed
leases go into $.state. Reading a state value while drawing subscribes the
band to it, so the timer's write redraws the band, and no hook has to ask for a
redraw.
The mod reaches Redis through the Terminal because a hooks module has no network or
file access of its own. Everything outside it goes through $, and every
$ call is itself an event that an earlier mod can watch or
refuse[3]. The Terminal is what the sessions' own shells call, so the mod
passes its owner in CONTINUUM_LEASE_OWNER and reads the same leases under
the same name. The rejected alternative was a settings hook that added the lease list
to every prompt, which would have traded N:N for every session, every prompt.
Look Both Ways
Claude hears about a lease when a command needs one.
A tool.call hook on Bash and PowerShell matches the commands that need the
stack or the GPU. Each pattern is anchored at the start of a command, so a
grep for continuum stack stop matches nothing.
| Lease | Commands | The guard |
|---|---|---|
| stack | stack stop, restart, reset; the integration tests; an AppHost run | Refuses while another session holds it, and takes it for 30 minutes when it's free |
| stack | stack start | Refuses while another session holds it, and afterwards releases the lease it took |
| gpu | models up, benchmark, model-quality, lms load | Refuses while another session holds it, and takes it when it's free |
| gpu | models down | Refuses while another session holds it, and afterwards releases the lease it took |
| gpu | models restart, lms unload | Refuses while another session holds it |
A refusal names the holder, the purpose and the expiry, and tells Claude to ask the
holder or to wait. Taking a free lease, or releasing the guard's own, adds one line after
the command's result, in the result's context, which Claude reads and the
person never sees[4]. Those lines and a refusal are the only times Claude
hears about a lease.
on('tool.call', { tool: ['Bash', 'PowerShell'] }, async ($, e, next) => {
const need = needOf(e.command)
if (need === null) { return next(e) }
const now = await readBoard($)
if (now === null) { return next(e) } // the lease store didn't answer
const held = now.leases.find(l => l.resource === need.resource)
if (held && held.owner !== now.me) { return { deny: refusal(need, held) } }
// Free: take it before the command runs, and say so after the result.
// ...
})
The guard reads the leases before it takes one, and never takes one the session already
holds. Continuum's take script renews a lease for its owner by writing the lease again,
so a guard that took first would replace the purpose of a lease taken by hand for a
whole test window with its own, and reset its expiry to 30 minutes. For the same reason,
stack start and models down release only a lease whose purpose
carries the guard's mark.
When the lease store doesn't answer, the command runs. Redis outlives a stack stop: its
container's lifetime is persistent, and a stack reset flushes everything in it but the
leases. A store that doesn't answer means Docker itself is down, and then nobody holds a
lease. The rejected alternative was holding the command with $.ui.ask
until the person chose. Sessions run while Tristan is out of the room, and a question
nobody answers holds a session where it stands, so the guard refuses and says what to
do instead.
Hold the Line
A session waits for a lease on a timer of its own.
The mod registers a tool, wait_lease, which Claude sees as
mcp__continuum-sessions__wait_lease, taking a resource and a
purpose[3]. Its hook records the wait in $.state and returns at
once, and Claude ends its turn. A person types /wait-lease gpu for the same
wait.
The waiting session's own timer reads the leases every 20 seconds. Once the lease is
free it takes it, through the same take script, whose owner check and write are one
step, so of two sessions waiting for one lease one gets it and the other goes on
waiting. Then $.prompt.submit queues a prompt that starts a turn once the
session is idle. A wait gives up after four hours, with a turn that says so.
const taken = await lease($, now.me, ['take', wait.resource, '--purpose', wait.purpose, '--minutes', '60'])
if (taken?.exitCode === 0) {
await update($, waits, list => list.filter(w => w.resource !== wait.resource))
quietly($.prompt.submit({ text: `${verbLine(taken.stdout)} You asked to wait for it ${minutes} minutes ago, for "${wait.purpose}". …` }))
}
No message goes to the holder, and nothing is spent while the session waits: one
turn, when there is work to do. Before the mod, a waiting session asked the holder to
say when it was done, which worked when the holder remembered. Claude Code's own
SendMessage can ask for one notice when a session goes idle, at no cost to
that session, but an idle session hasn't necessarily released anything, so a lease has a
wait of its own.
A timer stops when its module reloads, and $.state survives the reload.
session.start fires again after one, and re-arms the timer for the waits
still recorded.
Dry Run
The engine runs a mod's tests without a session.
claude plugin test runs a mod's *.test.ts files against
Claude Code's own engine, with the test's hooks beneath the plugin, where Claude Code's
behaviour would be[4]. This mod's tests answer process.run as
the Terminal would, over leases held in memory, and answer tool.call as the
shell would, recording what ran.
Nineteen tests cover the command rules, the table parser, a refusal, a take, a lease the session already holds, the release of the guard's own lease and no other, a command going through when the store doesn't answer, a command that needs no lease starting no process, a wait that takes a freed lease and starts a turn without a message sent, and the band drawn on the terminal and the desktop. With the guard's refusal and its take switched off in a copy of the mod, two of them fail.
claude plugin validate reads the manifest and the module's source without
running them, and lists the events the mod hooks and every $ call it
makes[1]. A mod runs with its user's permissions and outside any sandbox,
so that list is what to read before loading one. This one hooks
session.start, command.run, tool.call and
ui.render, reads one environment variable, CONTINUUM_TERMINAL,
and calls $.process.run, $.prompt.submit,
$.clock.every and $.tool.register, among others.
Sessions load it through CLAUDE_CODE_PLUGIN_DIRS in the env
block of the user's Claude Code settings, read as a session starts, because the Desktop
app takes no --plugin-dir flag[4].
Sources [ − ] [ + ]
Documents
Repos