SLOPSHOPPER

octopus-crew

Shows an octopus above the prompt for each summoned subagent or firstmate crew worker.

newbandcommandtimeragents
v0.3.2no licenseupdated 2026-10-13EmanueleNene/claude-mods/octopus-crew
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · octopus-crew
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /octopuses ⎿ octopus-crew: Octopuses hidden. Run /octopuses to show them. [ Show octopuses (3) ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
[ Show octopuses (3) ]
README

claude-mods

Two small mods for Claude Code.

ModWhat it does
delete-guardWhile the clean-generated-artifacts skill runs (from the moment it loads until that turn ends), holds any delete command (rm, rmdir, unlink, shred, trash, git rm, git clean, find -delete) and opens a pane listing every file. Toggle each file between delete and keep, then Confirm or Refuse all. Files marked keep are dropped from the command when it is retried. Open the pane by hand with /delete-guard.
octopus-crewDraws Clawd, Claude Code's pixel mascot, above the prompt for the current model (Claude orange), advisor and subagent models, each live Claude Code subagent, and optionally each firstmate ship, scout, or secondmate. Firstmate octopuses show the task id and state colour.

Install

At a Claude Code prompt in a terminal:

/plugin install delete-guard --marketplace EmanueleNene/claude-mods
/plugin install octopus-crew --marketplace EmanueleNene/claude-mods

Answer y to add the marketplace, then pick a scope (user scope loads in every session).

Notes

  • delete-guard cannot wait open for a click, so the first delete attempt is held and Claude is told your decision afterwards, then re-runs the command. It only sees deletions run through the shell tool.
  • octopus-crew reads the models when it loads, so a later /model or /advisor change shows after the next reload. Its firstmateHome plugin option is unset by default; set it to the captain's firstmate home (/home/emanuele/firstmate) to show crew. Leave it unset to disable crew octopuses.
  • Crew state is read-only: the mod polls <firstmateHome>/state about every five seconds, reads .meta and .status files, and never changes or steers fleet tasks. It shows kind=ship, kind=scout, and kind=secondmate workers (including Pi-harness workers dispatched by firstmate); secondmates are labelled secondmate · <task-id>. working, decision-waiting, done and endpoint-gone states use distinct hues; completed tasks stay dimmed until their .meta file disappears. Nothing from the fleet is added to model-facing context.
  • Claude Code subagents started in the session are shown individually from agent.spawn until their turn.complete. A Pi subagent that is not launched as a firstmate state-directory worker is not observable from this Claude Code mod; the mod does not invent a signal for it.
  • Click Hide octopuses in the band to collapse it to a Show octopuses (n) button, where n counts the octopuses waiting. It stays hidden through new subagents and reloads until you press Show. Without a mouse, focus the band with ctrl+x tab and press Enter.

Develop

claude plugin validate delete-guard
claude plugin test delete-guard
claude --plugin-dir ./octopus-crew
Source 2 files
hooks/register.tsx 275 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, RasterProps, Register } from 'claude-code'
3
4import type { CrewOctopus, Octopus } from '../types'
5
6const octopuses = atom({ plugin: 'octopus-crew', key: 'octopuses' } as const, [])
7const crewOctopuses = atom({ plugin: 'octopus-crew', key: 'crewOctopuses' } as const, [])
8const isHidden = atom({ plugin: 'octopus-crew', key: 'isHidden' } as const, false)
9
10/** One colour per octopus, in the order they first appear. */
11/** Claude's own orange, Clawd's body colour: kept for the session's current model alone. */
12const CLAUDE_ORANGE = '#d77757'
13const PALETTE = ['#e255d0', '#35c9d8', '#3b82f6', '#7ed957', '#ff6b6b', '#8f7bff', '#f2e94e', '#ff9ecb']
14const CREW_PALETTES = {
15  working: ['#00a6a6', '#008f83', '#007f9e', '#2563eb', '#0e7490', '#a21caf', '#059669', '#0f766e', '#0891b2', '#0284c7', '#0d9488', '#16a34a', '#4f46e5', '#7c3aed', '#9333ea', '#c026d3'],
16  waiting: ['#b77900', '#a16207', '#c2410c', '#9a6700', '#d97706', '#b45309', '#854d0e', '#ca8a04'],
17  done: ['#15803d', '#16803c', '#047857', '#2f855a', '#166534', '#0f766e', '#3f7d20', '#4d7c0f'],
18  gone: ['#6b7280', '#737373', '#64748b', '#71717a', '#78716c', '#475569', '#52525b', '#57534e'],
19} as const
20const CREW_STATE_COLORS: Record<FleetStatus, readonly string[]> = CREW_PALETTES
21
22const ART = [' ,---. ', '( o o )', ' \\_-_/ ', '/|/|\\|\\']
23const MAX = 8
24const FLEET_REFRESH_MS = 5000
25
26// The creature is Claude Code's own mascot, Clawd, after the pixel grid the CLI draws, at half
27// height: a body, two arms, two eyes and four legs. B body, A arm or leg, E eye.
28const SPRITE = ['.BBBBBBB.', '.BEBBBEB.', 'AABBBBBAA', '.A.A.A.A.']
29const SPRITE_WIDTH = 9
30const SPRITE_HEIGHT = 4
31
32const DEFAULT = 0x01000000
33const EYE = 0x141414
34
35const rgb = (hex: string): number => parseInt(hex.slice(1), 16)
36const darker = (value: number, by: number): number => {
37  const part = (shift: number) => Math.round(((value >> shift) & 255) * by)
38  return (part(16) << 16) | (part(8) << 8) | part(0)
39}
40
41const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
42const base64 = (bytes: number[]): string => {
43  let out = ''
44  for (let i = 0; i < bytes.length; i += 3) {
45    const a = bytes[i] ?? 0
46    const b = bytes[i + 1] ?? 0
47    const c = bytes[i + 2] ?? 0
48    const n = (a << 16) | (b << 8) | c
49    out += B64[(n >> 18) & 63]
50    out += B64[(n >> 12) & 63]
51    out += i + 1 < bytes.length ? B64[(n >> 6) & 63] : '='
52    out += i + 2 < bytes.length ? B64[n & 63] : '='
53  }
54  return out
55}
56
57/** The sprite in half blocks, one terminal row per two pixel rows, as a Raster's packed cells. */
58const cellsFor = (hex: string): string => {
59  const body = rgb(hex)
60  const paint = (ch: string | undefined): number | undefined =>
61    ch === 'B' ? body : ch === 'A' ? darker(body, 0.72) : ch === 'E' ? EYE : undefined
62  const bytes: number[] = []
63  const word = (n: number) => bytes.push(n & 255, (n >> 8) & 255, (n >> 16) & 255, (n >>> 24) & 255)
64  for (let r = 0; r < SPRITE_HEIGHT / 2; r++) {
65    for (let x = 0; x < SPRITE_WIDTH; x++) {
66      const top = paint(SPRITE[r * 2]?.[x])
67      const bottom = paint(SPRITE[r * 2 + 1]?.[x])
68      if (top === undefined && bottom === undefined) word(0x20), word(DEFAULT), word(DEFAULT)
69      else if (bottom === undefined) word(0x2580), word(top ?? DEFAULT), word(DEFAULT)
70      else if (top === undefined) word(0x2584), word(bottom), word(DEFAULT)
71      else word(0x2580), word(top), word(bottom)
72    }
73  }
74  return base64(bytes)
75}
76
77const asText = (value: unknown): string | undefined =>
78  typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
79
80/** Adds or retunes one octopus; a colour once given stays. */
81const upsert = (list: Octopus[], next: Omit<Octopus, 'color'>, fixed?: string): Octopus[] => {
82  const at = list.findIndex(o => o.id === next.id)
83  if (at >= 0) return list.map((o, i) => (i === at ? { ...o, ...next, color: fixed ?? o.color } : o))
84  const used = new Set(list.map(o => o.color))
85  const color = fixed ?? PALETTE.find(c => !used.has(c)) ?? PALETTE[list.length % PALETTE.length] ?? '#ffffff'
86  return [...list, { ...next, color }].slice(-MAX)
87}
88
89const HEAD = ['main', 'advisor', 'subagent-default']
90
91type FleetStatus = CrewOctopus['state']
92
93/** Status files are append-only; ignore wake/event lines and use the latest state record. */
94export const parseCrewStatus = (text: string): { state: FleetStatus; finished: boolean } => {
95  const recognized = text.split(/\r?\n/).map(line => /^(working|waiting|blocked|needs-decision|done|failed|cancelled|endpoint-gone|endpoint gone):/i.exec(line.trim())?.[1]?.toLowerCase()).filter((s): s is string => Boolean(s))
96  const last = recognized.at(-1) ?? 'working'
97  if (last === 'done') return { state: 'done', finished: true }
98  if (last === 'failed' || last === 'cancelled') return { state: 'gone', finished: true }
99  if (last === 'endpoint-gone' || last === 'endpoint gone') return { state: 'gone', finished: true }
100  if (last === 'blocked' || last === 'waiting' || last === 'needs-decision') return { state: 'waiting', finished: false }
101  return { state: 'working', finished: false }
102}
103
104const parseMeta = (text: string): Record<string, string> => {
105  const values: Record<string, string> = {}
106  for (const line of text.split(/\r?\n/)) {
107    const cut = line.indexOf('=')
108    if (cut > 0) values[line.slice(0, cut).trim()] = line.slice(cut + 1).trim()
109  }
110  return values
111}
112
113export const isCrewDimmed = (octopus: CrewOctopus): boolean => octopus.finished
114
115export const parseFleetFiles = (files: Readonly<Record<string, string>>): CrewOctopus[] => {
116  const used: Record<FleetStatus, Set<string>> = { working: new Set(), waiting: new Set(), done: new Set(), gone: new Set() }
117  return Object.entries(files)
118    .filter(([name]) => name.endsWith('.meta'))
119    .sort(([a], [b]) => a.localeCompare(b))
120    .flatMap(([name, metaText]) => {
121      const taskId = name.slice(0, -'.meta'.length)
122      const kind = parseMeta(metaText).kind
123      if (kind !== 'ship' && kind !== 'scout' && kind !== 'secondmate') return []
124      const status = parseCrewStatus(files[`${taskId}.status`] ?? '')
125      const role = kind === 'secondmate' ? `secondmate · ${taskId}` : taskId
126      return [{ id: `crew:${taskId}`, role, model: status.state, color: crewColor(status.state, taskId, used[status.state]), state: status.state, finished: status.finished }]
127    })
128}
129
130const crewColor = (state: FleetStatus, taskId: string, used: Set<string>): string => {
131  const colors = CREW_STATE_COLORS[state]
132  let hash = 0
133  for (const char of taskId) hash = (hash * 31 + char.charCodeAt(0)) >>> 0
134  const start = hash % colors.length
135  const color = colors.map((_, offset) => colors[(start + offset) % colors.length]).find(candidate => candidate !== undefined && !used.has(candidate)) ?? colors[start] ?? '#ffffff'
136  used.add(color)
137  return color
138}
139
140const loadCrew = async ($: EngineInterface, home: string): Promise<CrewOctopus[]> => {
141  try {
142    const entries = await $.fs.list(`${home.replace(/\/$/, '')}/state`)
143    const metas = entries.filter(entry => entry.kind === 'file' && entry.name.endsWith('.meta'))
144    const files: Record<string, string> = {}
145    for (const entry of metas) {
146      try {
147        files[entry.name] = await $.fs.read(`${home.replace(/\/$/, '')}/state/${entry.name}`)
148        const statusName = `${entry.name.slice(0, -'.meta'.length)}.status`
149        files[statusName] = await $.fs.read(`${home.replace(/\/$/, '')}/state/${statusName}`).catch(() => '')
150      } catch {
151        // A task may disappear while being read; the next poll will reconcile it.
152      }
153    }
154    return parseFleetFiles(files)
155  } catch {
156    // An unset, missing, or inaccessible fleet folder is deliberately silent.
157    return []
158  }
159}
160
161export const register: Register = (on, options: PluginOptions) => {
162  let stopFleetPolling: ReturnType<EngineInterface['clock']['every']> | undefined
163
164  // A reload runs session.start again: read the models afresh; the hidden choice is kept.
165  on('session.start', async ($, e, next) => {
166    stopFleetPolling?.cancel()
167    stopFleetPolling = undefined
168    const settings = await $.settings.read()
169    const settingsEnv = (settings.env ?? {}) as Record<string, unknown>
170    const advisor = asText(settings.advisorModel)
171    const subagents =
172      (await $.env.get('CLAUDE_CODE_SUBAGENT_MODEL')) ?? asText(settingsEnv.CLAUDE_CODE_SUBAGENT_MODEL)
173    const main = await $.session.model()
174
175    await update($, octopuses, list => {
176      // The three fixed octopuses lead, in order; spawned ones follow, colours kept.
177      const spawned = list.filter(o => !HEAD.includes(o.id))
178      const fresh = upsert(upsert(upsert(spawned, { id: 'main', role: 'current', model: main }, CLAUDE_ORANGE), { id: 'advisor', role: 'advisor', model: advisor ?? 'not set' }), {
179        id: 'subagent-default',
180        role: 'subagents',
181        model: subagents ?? `${main} (inherits)`,
182      })
183      return [...HEAD.map(id => fresh.find(o => o.id === id)).filter((o): o is Octopus => o !== undefined), ...fresh.filter(o => !HEAD.includes(o.id))]
184    })
185    // The band's buttons need a mouse or ctrl+x tab; the command works from any terminal.
186    await $.command.register({ name: 'octopuses', description: 'Hide or show the octopuses', immediate: true })
187
188    const home = asText(options.firstmateHome)
189    if (home) {
190      await update($, crewOctopuses, () => [])
191      const initialCrew = await loadCrew($, home)
192      await update($, crewOctopuses, () => initialCrew)
193      stopFleetPolling = $.clock.every(FLEET_REFRESH_MS, async () => {
194        const refreshed = await loadCrew($, home)
195        await update($, crewOctopuses, () => refreshed)
196      })
197    } else {
198      await update($, crewOctopuses, () => [])
199    }
200
201    return next(e)
202  })
203
204  on('command.run', { command: 'octopuses' }, async $ => {
205    const hidden = await update($, isHidden, value => !value)
206    return { text: hidden ? 'Octopuses hidden. Run /octopuses to show them.' : 'Octopuses shown.' }
207  })
208
209  // Each Claude subagent gets its own octopus, keyed by the runtime's agent id.
210  on('agent.spawn', async ($, e, next) => {
211    const settings = await $.settings.read()
212    const settingsEnv = (settings.env ?? {}) as Record<string, unknown>
213    const model =
214      asText(e.model) ??
215      (await $.env.get('CLAUDE_CODE_SUBAGENT_MODEL')) ??
216      asText(settingsEnv.CLAUDE_CODE_SUBAGENT_MODEL) ??
217      (await $.session.model())
218    const role = asText(e.subagentType) ?? 'subagent'
219    const result = await next(e)
220    if (result.agentId) {
221      await update($, octopuses, list => upsert(list, { id: `spawn:${result.agentId}`, role, model: asText(result.model) ?? model }))
222    }
223    return result
224  })
225
226  // turn.complete is the documented end of an agent loop; remove its live octopus.
227  on('turn.complete', async ($, e, next) => {
228    if (e.agentId) await update($, octopuses, list => list.filter(o => o.id !== `spawn:${e.agentId}`))
229    return next(e)
230  })
231
232  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
233    const list = await read($, octopuses)
234    const crew = await read($, crewOctopuses)
235    const hidden = await read($, isHidden)
236    if (e.props.hasSurvey) return next(e)
237
238    const displayed: Array<Octopus | CrewOctopus> = [...list, ...crew]
239
240    const ui = $.ui.resolve(e)
241    const { Box, Button, Text } = ui
242    const Raster = e.surface === 'terminal' ? (ui as { Raster?: (props: RasterProps) => JSX.Element }).Raster : undefined
243    const toggle = () => update($, isHidden, value => !value)
244
245    if (hidden) {
246      return <Button key="show-octopuses" label={`Show octopuses (${displayed.length})`} onPress={toggle} />
247    }
248
249    return (
250      <Box flexDirection="column">
251        <Box flexWrap="wrap">
252          {displayed.map(o => (
253            <Box flexDirection="column" key={`o:${o.id}`} marginRight={3}>
254              {Raster ? (
255                <Raster key={`r:${o.id}`} columns={SPRITE_WIDTH} rows={SPRITE_HEIGHT / 2} cells={cellsFor(o.color)} />
256              ) : (
257                ART.map((line, i) => (
258                  <Text color={o.color} key={`l:${i}`}>
259                    {line}
260                  </Text>
261                ))
262              )}
263              <Text bold color={o.color} dimColor={'finished' in o && isCrewDimmed(o)}>
264                {o.role}
265              </Text>
266              <Text dimColor={'finished' in o && isCrewDimmed(o)}>{'state' in o ? o.state : o.model}</Text>
267            </Box>
268          ))}
269        </Box>
270        <Button key="hide" label="Hide octopuses" onPress={toggle} />
271      </Box>
272    )
273  })
274}
275
types/index.d.ts 28 lines
1export type Octopus = {
2  /** Stable key: "main", "advisor", "subagent-default", or "spawn:<agent-id>". */
3  id: string
4  /** Who it stands for: "current", "advisor", "subagents" or a subagent type. */
5  role: string
6  /** The model it runs, as configured or as the spawn named it. */
7  model: string
8  /** Its colour, fixed when it first appears. */
9  color: string
10}
11
12export type CrewOctopus = {
13  id: string
14  /** The firstmate task id. */
15  role: string
16  /** The current fleet state, shown below the task id. */
17  model: string
18  color: string
19  state: 'working' | 'waiting' | 'done' | 'gone'
20  finished: boolean
21}
22
23declare module 'claude-code' {
24  interface PluginState {
25    'octopus-crew': { octopuses: Octopus[]; crewOctopuses: CrewOctopus[]; isHidden: boolean }
26  }
27}
28