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

Two small mods for Claude Code.
| Mod | What it does |
|---|---|
delete-guard | While 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-crew | Draws 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. |
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).
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.<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.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.claude plugin validate delete-guard
claude plugin test delete-guard
claude --plugin-dir ./octopus-crewhooks/register.tsx 275 lines1import { 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}
275types/index.d.ts 28 lines1export 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