Contents
Articles agents claude_code_mods

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.

Project Continuum Engine Authors @Tristan & @Claude Topic Agents
Created 2026-10-02 Updated 2026-10-02 Version 1.0

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.

LeaseCommandsThe guard
stackstack stop, restart, reset; the integration tests; an AppHost runRefuses while another session holds it, and takes it for 30 minutes when it's free
stackstack startRefuses while another session holds it, and afterwards releases the lease it took
gpumodels up, benchmark, model-quality, lms loadRefuses while another session holds it, and takes it when it's free
gpumodels downRefuses while another session holds it, and afterwards releases the lease it took
gpumodels restart, lms unloadRefuses 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].

← Back to Articles