SLOPSHOPPER

remote-kernels

Cloud GPU instances with Jupyter kernels for AI/ML workloads

newbandtoastprocess
★ 26v0.3.6no licenseupdated 2026-10-09Crazytieguy/alignment-hive/plugins/remote-kernels
A shopper browsing a rack in a slop shop
README

remote-kernels

Cloud GPU machines with Jupyter kernels for AI/ML workloads — on RunPod, vast.ai, or Kubernetes.

Motivation

This plugin decouples the agent from the execution environment, in the same spirit as Anthropic's managed agents architecture: Claude and your session stay on your machine (the "brain"), while cloud GPUs are interchangeable "hands" it attaches, uses, and discards. Running Claude Code inside a cloud container couples the two outright; piping SSH commands at a long-lived dev box decouples them only awkwardly. Doing it properly buys:

  • Less manual work — Claude starts, stops, and terminates machines on demand. No pre-provisioning, no manual SSH setup, and your local Claude Code configuration (permissions, plugins, settings) applies unchanged whichever GPU is in use.
  • Cross-session persistence — everything durable lives with the agent, not on a disposable GPU box: transcripts, memories, downloaded results, and an .ipynb notebook per kernel. A later session reconnects and picks up where it left off; the machine itself is safe to lose.
  • More autonomy — machines are cattle, not pets. Claude can pick marketplace hosts, run several machines concurrently, and clean up after itself — inside budget and cleanup policies you set once.

What This Plugin Does

Three runtimes, one interface — RunPod (managed pods, reliable stop/resume), vast.ai (cheapest marketplace GPUs — Claude can search offers and pick hosts, or take the cheapest qualifying automatically; VM mode runs Docker inside, e.g. for UK AISI Inspect's sandboxed evals), and Kubernetes (lab clusters: pods from a lab-owned template, Kueue queue/priority aware). Configure via remote-kernels.toml; switch per machine with start(runtime=...).

Multiple concurrent machines — Every start() provisions a fresh machine with a unique id. A restarted server picks its own session's running machines back up automatically; resuming a stopped machine or adopting another session's is an explicit attach(machine_id), with kernel state and output recovered across sessions. Machines start in parallel, each with its own kernels, and kernel calls route automatically — no instance bookkeeping.

Jupyter kernel execution — Run code on remote GPUs through persistent Jupyter kernels. Claude can execute cells, inspect outputs (plots included, as images), and iterate — all within the conversation. Kernel activity is saved as .ipynb files.

File sync — Sync local project files to a machine (.gitignore-aware) and download results back, both rooted at the project directory.

Budget controls — Set a spending limit once and each Claude session gets its own cap, covering all the machines that session pays for — including storage that keeps billing on stopped ones. On exhaustion, that session's machines get a grace window to save their work before being stopped or terminated — enforced on the machine itself, so it holds even if your laptop dies.

Automatic cleanup — When a session disconnects (background sessions, closed laptops, crashes), the machine waits for its running work, runs your configured command to save results, and then cleans itself up the way you chose: stop, terminate, or leave it running. If saving fails, the machine is kept rather than deleted. A later session reattaches and picks up where you left off.

Requirements

  • An API key for the runtime(s) you use: RUNPOD_API_KEY and/or VAST_API_KEY (a plain console key; vast accounts with 2FA enabled reject API writes — the setup skill explains the options, disabling 2FA being the supported one), or a kubeconfig for Kubernetes.
  • Run the setup skill to configure interactively.
Source 3 files
hooks/register.tsx 60 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { noticeRows } from './notice-band'
5import type { Notice, NoticeAction } from './notice-band'
6
7const PLUGIN = 'remote-kernels'
8const notices = atom({ plugin: 'remote-kernels', key: 'notices' } as const, [] as Notice[])
9// Rows dismissed this session; the script itself shows `updated` once per binary version.
10const dismissed = atom({ plugin: 'remote-kernels', key: 'dismissed' } as const, [] as string[])
11
12/** Runs the plugin's session-start check and keeps its rows; none when it fails. */
13async function check($: EngineInterface, projectDir: string): Promise<void> {
14  try {
15    const { exitCode, stdout } = await $.process.run(['bash', `${$.plugin.root}/scripts/check.sh`], {
16      env: { CLAUDE_PLUGIN_ROOT: $.plugin.root, CLAUDE_PROJECT_DIR: projectDir },
17    })
18    const found = exitCode === 0 && stdout.trim() ? (JSON.parse(stdout) as { notices: Notice[] }).notices : []
19    await update($, notices, () => found)
20  } catch {
21    // A check that fails shows nothing rather than a wrong row.
22  }
23}
24
25export const register: Register = on => {
26  // The SessionStart hook's lifecycle: startup and /clear, awaited.
27  on('classic.SessionStart', async ($, e, next) => {
28    if (e.source === 'startup' || e.source === 'clear') await check($, e.cwd)
29    return next(e)
30  })
31
32  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
33    const [below, found, hidden] = await Promise.all([next(e), read($, notices), read($, dismissed)])
34    if (e.props.hasSurvey) return below
35
36    const rows = (found as Notice[]).filter(notice => !hidden.includes(notice.id))
37    if (rows.length === 0) return below
38
39    const onAction = async (notice: Notice, action: NoticeAction) => {
40      // Running setup dismisses the row for this session; the next session's check
41      // decides whether it comes back.
42      await update($, dismissed, list => [...list, notice.id])
43      if (action.kind === 'command' && action.command) {
44        await $.command.run({ command: action.command.slice(1) }).catch(() => $.ui.toast(`Could not run ${action.command}`))
45      }
46    }
47    const onDismiss = (notice: Notice) => update($, dismissed, list => [...list, notice.id])
48
49    const ui = $.ui.resolve(e)
50    const drawn = noticeRows(ui, PLUGIN, rows, { onAction, onDismiss })
51
52    return (
53      <ui.Box flexDirection="column">
54        {drawn}
55        {below}
56      </ui.Box>
57    )
58  })
59}
60
hooks/notice-band.tsx 92 lines
1import type { EngineInterface, RenderElement } from 'claude-code'
2
3// One plugin's rows in the band above the prompt, drawn without `$`: each
4// plugin's own `ui.render` hook reads its state and stacks this over what the
5// plugins beneath drew, so third-party plugins on the band keep working.
6//
7// A plugin cannot import another's files, so each plugin that shows notices
8// carries this file; plugins/notice-band.test.ts keeps the copies identical.
9// The `notices` its check prints are this file's `Notice` as JSON.
10
11export type Severity = 'urgent' | 'problem' | 'action' | 'info'
12
13export type NoticeAction = {
14  id: string
15  label: string
16  // command: a slash command; copy: `command` goes on the clipboard, for a
17  // terminal command that is interactive; plugin: the plugin's own handler decides.
18  kind: 'command' | 'copy' | 'plugin'
19  command?: string
20}
21
22export type Notice = {
23  id: string
24  severity: Severity
25  // The whole message: rows wrap rather than hide what the person needs to act.
26  text: string
27  actions?: NoticeAction[]
28  // Set on "updated" notices: a dismissal holds until the plugin's next
29  // version. Without it, a dismissal holds for this session only.
30  version?: string
31  // What a pressed button is doing ("restarting…"): drawn after the text in
32  // place of the buttons, so the row never looks inert. Set by the plugin.
33  busy?: string
34}
35
36export type NoticeHandlers = {
37  onAction: (notice: Notice, action: NoticeAction) => unknown
38  onDismiss: (notice: Notice) => unknown
39}
40
41const MARK: Record<Severity, { rank: number; glyph: string; color: string }> = {
42  urgent: { rank: 0, glyph: '✖', color: 'error' },
43  problem: { rank: 1, glyph: '!', color: 'warning' },
44  action: { rank: 2, glyph: '›', color: 'suggestion' },
45  info: { rank: 3, glyph: '↑', color: 'ide' },
46}
47
48export function noticeRows(
49  ui: ReturnType<EngineInterface['ui']['resolve']>,
50  plugin: string,
51  notices: Notice[],
52  { onAction, onDismiss }: NoticeHandlers,
53): RenderElement[] {
54  const { Box, Button, Text } = ui
55
56  return [...notices]
57    .sort((a, b) => MARK[a.severity].rank - MARK[b.severity].rank)
58    .map(notice => {
59      const mark = MARK[notice.severity]
60      const key = `${plugin}:${notice.id}`
61
62      const buttons = (notice.busy ? [] : (notice.actions ?? [])).map((action, index) => (
63        <Button
64          key={`${key}:${action.id}`}
65          label={action.label}
66          variant={index === 0 ? 'primary' : 'secondary'}
67          onPress={() => onAction(notice, action)}
68        />
69      ))
70      // An info row is a state, not a request: the band's own collapse hides it.
71      if (notice.severity !== 'info' && !notice.busy) {
72        buttons.push(<Button key={`${key}:dismiss`} label="Dismiss" dimColor onPress={() => onDismiss(notice)} />)
73      }
74
75      // The buttons wrap under the text where the row does not fit.
76      return (
77        <Box key={key} flexDirection="row" flexWrap="wrap" justifyContent="space-between" columnGap={2}>
78          <Text>
79            <Text color={mark.color} bold>
80              {mark.glyph}{' '}
81            </Text>
82            <Text bold>{plugin}</Text> {notice.busy ? `${notice.text} · ${notice.busy}` : notice.text}
83          </Text>
84          {/* Grows to fill its line, so the buttons stay right-aligned when they wrap. */}
85          <Box flexDirection="row" flexGrow={1} justifyContent="flex-end" columnGap={1}>
86            {buttons}
87          </Box>
88        </Box>
89      )
90    })
91}
92
types/index.d.ts 7 lines
1declare module 'claude-code' {
2  interface PluginState {
3    // `notices`: the check's rows, hooks/notice-band.tsx's Notice as parsed JSON.
4    'remote-kernels': { notices: unknown[]; dismissed: string[] }
5  }
6}
7