@g3nomaly/g3n-mods 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,19 +19,19 @@
19
19
  {
20
20
  "name": "custom-crab-agents",
21
21
  "source": "./mods/custom-crab-agents",
22
- "description": "Each subagent as a crab with a hat: effort tier, model, steps, context, tokens, cost and time.",
22
+ "description": "Draws each subagent in the agents panel as a crab with a hat: effort tier, model, steps, context, tokens, cost and time.",
23
23
  "version": "0.1.0"
24
24
  },
25
25
  {
26
26
  "name": "delete-guard",
27
27
  "source": "./mods/delete-guard",
28
- "description": "Denies rm -rf and other permanent deletes and points Claude to the trash.",
28
+ "description": "Holds rm -rf and other permanent deletes on a card that lists what they remove, until you press Delete or Cancel.",
29
29
  "version": "0.1.0"
30
30
  },
31
31
  {
32
32
  "name": "focus-mode",
33
33
  "source": "./mods/focus-mode",
34
- "description": "A timed focus block with a countdown, a progress bar and an end button.",
34
+ "description": "A timed focus block in which you're away: Claude asks no questions and gets no permission prompts. Countdown, progress bar and end button.",
35
35
  "version": "0.1.0"
36
36
  },
37
37
  {
package/README.md CHANGED
@@ -22,20 +22,20 @@ Then open the project in VS Code and trust the workspace. New sessions load the
22
22
  |---|---|---|
23
23
  | `agents-panel` | The agents in `.claude/agents` with their model and a run button. A running agent shows a spinner, and a toast says when it starts and finishes | `/agents-panel` |
24
24
  | `context-bar` | One bar of context-window use split by source, with the total and where compaction starts | `/context-bar` |
25
- | `custom-crab-agents` | Each subagent as a crab with a hat: effort tier, model, steps, context, tokens, cost and time | `/crabs` |
26
- | `delete-guard` | Denies `rm -rf` and other permanent deletes and tells Claude which trash command to use instead | `/delete-guard on \| off` |
27
- | `focus-mode` | A timed focus block with a countdown, a progress bar and an end button. 25 minutes unless you pass a number | `/focus-mode [minutes] \| end` |
25
+ | `custom-crab-agents` | Adds each subagent to the agents panel as a crab: its role (from its name or type) picks its hat and what it holds, such as a reviewer's deerstalker and magnifying glass, a tester's work helmet and wrench, a designer's top hat and brush. Shows model, effort, steps, context, tokens, cost and time. Click a crab to open its whole conversation in a tab and message it there. Needs `agents-panel`, which installing it adds | `/agents-panel` |
26
+ | `delete-guard` | Off by default: deletes run unasked. Turn it on with the switch in its pane, or `/delete-guard on`; it stays on in later sessions. While on, `rm -rf` and other permanent deletes wait on a card listing the files, their sizes and whether git can restore them, until you press **Delete** or **Cancel**. Cancel tells Claude which trash command to use instead. With nothing to draw the card on, the delete is refused | `/delete-guard on \| off` |
27
+ | `focus-mode` | A timed focus block in which you're away: Claude's questions are refused, so it picks for itself and says what it assumed, and permission prompts are refused, so it works around the step and lists it for later. Countdown, progress bar and end button. Set the length with − and + in the pane, or pass the minutes; the length you pick is kept for later blocks | `/focus-mode [minutes] \| end` |
28
28
  | `handoff` | Writes a handoff with the `handoff` skill, clears the session, and attaches the handoff to your next prompt | `/handoff-new [what the next session is for]` |
29
29
  | `hide-personal-data` | Masks names, emails, phone numbers, IBANs and card numbers in replies. Hover a mask to read it | `/hide-personal-data on \| off` |
30
30
  | `mini-game` | Minesweeper, to play while Claude works | `/mini-game` |
31
31
  | `tool-timeline` | Each tool call with its duration. A denied call is struck through, next to the call that replaced it | `/tool-timeline` |
32
32
  | `you-should-know` | A card under long replies with their key point, a headline and one supporting line. It costs one small Haiku call per reply of 1500 characters or more | none |
33
33
 
34
- For `agents-panel`, `context-bar`, `custom-crab-agents` and `tool-timeline`, the command opens the pane and running it again closes it.
34
+ For `agents-panel`, `context-bar` and `tool-timeline`, the command opens the pane and running it again closes it.
35
35
 
36
36
  Three mods take settings, declared under `userConfig` in their `.claude-plugin/plugin.json`:
37
37
 
38
- - `focus-mode`: `minutes`, the default block length (25).
38
+ - `focus-mode`: `minutes`, the block length before you pick one (25).
39
39
  - `hide-personal-data`: `names`, extra names to always mask, separated by commas.
40
40
  - `you-should-know`: `minChars`, the shortest reply that gets a card (1500), and `model`, the model that picks the key point (`haiku`).
41
41
 
@@ -1,6 +1,6 @@
1
1
  // packages/g3n-mods/mods/agents-panel/hooks/register.tsx
2
2
  import { atom, read, update } from 'claude-code'
3
- import type { EngineInterface, Register } from 'claude-code'
3
+ import type { EngineInterface, Register, RenderElement } from 'claude-code'
4
4
 
5
5
  import type { AgentDef, RunningAgent } from '../types'
6
6
 
@@ -20,6 +20,27 @@ const NAMED_COLORS: Readonly<Record<string, string>> = {
20
20
  const SPINNER = ['◐', '◓', '◑', '◒']
21
21
  const DONE_STATUSES = new Set(['completed', 'failed', 'killed', 'idle'])
22
22
 
23
+ /** Key prefix of the panel's own elements; an add-on looks for it to find the panel's tree. */
24
+ export const OWN_KEY = 'agents:'
25
+
26
+ /** Key prefix an add-on's section carries, such as custom-crab-agents' crabs, so the panel knows to show it. */
27
+ export const ADDON_KEY = 'addon:'
28
+
29
+ /**
30
+ * Tells whether a drawn tree holds an element whose key starts with `prefix`.
31
+ *
32
+ * @param tree - Tree as a `ui.render` chain returned it, or one of its children.
33
+ * @param prefix - Key prefix to look for, e.g. `addon:`.
34
+ * @returns True when some element's key starts with `prefix`.
35
+ */
36
+ export function holdsKey(tree: unknown, prefix: string): boolean {
37
+ if (Array.isArray(tree)) return tree.some(child => holdsKey(child, prefix))
38
+ if (typeof tree !== 'object' || tree === null) return false
39
+ const { props, children } = tree as { props?: { key?: unknown }; children?: unknown }
40
+ if (typeof props?.key === 'string' && props.key.startsWith(prefix)) return true
41
+ return holdsKey(children, prefix)
42
+ }
43
+
23
44
  /**
24
45
  * Parses the frontmatter of a Markdown file into flat `key: value` pairs.
25
46
  *
@@ -184,8 +205,13 @@ export const register: Register = on => {
184
205
  return result
185
206
  }).catch(($, e, next) => next(e))
186
207
 
187
- on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
208
+ on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
188
209
  const { Box, Text, Button } = $.ui.resolve(e)
210
+ // An add-on that depends on this panel, such as custom-crab-agents, may draw beneath this hook; its section joins the panel.
211
+ const below: RenderElement | undefined = await next(e).catch(() => undefined)
212
+ const addOn = below !== undefined && holdsKey(below, ADDON_KEY) ? below : undefined
213
+ /** Puts an add-on's section under the panel's own tree, when one was drawn beneath. */
214
+ const withAddOn = (own: RenderElement) => (addOn === undefined ? own : <Box flexDirection="column" gap={1}>{own}{addOn}</Box>)
189
215
  const list = await read($, agents)
190
216
  const runs = await read($, running)
191
217
  const now = await $.clock.now()
@@ -203,11 +229,11 @@ export const register: Register = on => {
203
229
  )
204
230
 
205
231
  if (list.length === 0) {
206
- return (
207
- <Box flexDirection="column" gap={1}>
232
+ return withAddOn(
233
+ <Box key={`${OWN_KEY}body`} flexDirection="column" gap={1}>
208
234
  {header}
209
235
  <Text dimColor>No agents in {AGENTS_DIR} or ~/{AGENTS_DIR}. Add one with /agents.</Text>
210
- </Box>
236
+ </Box>,
211
237
  )
212
238
  }
213
239
 
@@ -265,14 +291,14 @@ export const register: Register = on => {
265
291
  )
266
292
  })()
267
293
 
268
- return (
269
- <Box flexDirection="column" gap={1}>
294
+ return withAddOn(
295
+ <Box key={`${OWN_KEY}body`} flexDirection="column" gap={1}>
270
296
  {header}
271
297
  {input}
272
298
  {section('project')}
273
299
  {section('user')}
274
300
  <Text dimColor>click ▶ run to start one · /agents-panel to hide</Text>
275
- </Box>
301
+ </Box>,
276
302
  )
277
303
  })
278
304
  }
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "custom-crab-agents",
3
3
  "version": "0.1.0",
4
- "description": "Each subagent as a crab with a hat: effort tier, model, steps, context, tokens, cost and time.",
4
+ "description": "Draws each subagent in the agents panel as a crab with a hat: effort tier, model, steps, context, tokens, cost and time.",
5
5
  "author": {
6
6
  "name": "g3ntrovert"
7
7
  },
8
- "types": "./types/index.d.ts"
8
+ "types": "./types/index.d.ts",
9
+ "dependencies": [
10
+ "agents-panel"
11
+ ]
9
12
  }
@@ -1,27 +1,59 @@
1
1
  // packages/g3n-mods/mods/custom-crab-agents/hooks/register.tsx
2
2
  import { atom, read, update } from 'claude-code'
3
- import type { EngineInterface, Register } from 'claude-code'
3
+ import type { EngineInterface, Register, RenderElement } from 'claude-code'
4
4
 
5
- import type { Crab, CrabSteps, PlannedItem } from '../types'
5
+ import type { Crab, CrabSteps, PlannedItem, Role } from '../types'
6
6
 
7
- const PANE = 'crabs'
8
- const TITLE = 'Crabs'
7
+ /** The agents-panel mod's pane, which this mod depends on and draws its crabs into. */
8
+ const PANEL = 'agents-panel'
9
+
10
+ /** Key prefix of the agents panel's own elements, and the one this section carries so the panel shows it. */
11
+ const PANEL_KEY = 'agents:'
12
+ const ADDON_KEY = 'addon:'
9
13
  const crabs = atom({ plugin: 'custom-crab-agents', key: 'crabs' } as const, [])
10
14
  const planned = atom({ plugin: 'custom-crab-agents', key: 'planned' } as const, [])
11
15
  const contextWindow = atom({ plugin: 'custom-crab-agents', key: 'window' } as const, 1_000_000)
16
+ const chats = atom({ plugin: 'custom-crab-agents', key: 'chats' } as const, [])
17
+
18
+ /** Pane id prefix of a subagent's chat; the viewer opens a pane whose id starts with `tab-` in a tab of its own. */
19
+ const CHAT_PREFIX = 'tab-chat-'
20
+
21
+ /** Most of a subagent's conversation entries a chat draws, newest kept. */
22
+ const CHAT_ROWS = 300
12
23
 
13
24
  const KEEP = 30
14
25
  const PIXEL = 4
15
26
 
16
- /** Effort level → tier name, color and hat. */
17
- const TIERS: Readonly<Record<string, { name: string; color: string; hat: keyof typeof HATS }>> = {
18
- low: { name: 'light', color: '#3fa66b', hat: 'cap' },
19
- medium: { name: 'medium', color: '#4c8bf5', hat: 'sailor' },
20
- high: { name: 'careful', color: '#e0a030', hat: 'hardhat' },
21
- xhigh: { name: 'heavy', color: '#e05a4f', hat: 'beanie' },
22
- max: { name: 'heavy', color: '#e05a4f', hat: 'beanie' },
27
+ /** Effort level → tier name and the color of the crab's bar. */
28
+ const TIERS: Readonly<Record<string, { name: string; color: string }>> = {
29
+ low: { name: 'light', color: '#3fa66b' },
30
+ medium: { name: 'medium', color: '#4c8bf5' },
31
+ high: { name: 'careful', color: '#e0a030' },
32
+ xhigh: { name: 'heavy', color: '#e05a4f' },
33
+ max: { name: 'heavy', color: '#e05a4f' },
34
+ }
35
+ const DEFAULT_TIER = { name: 'medium', color: '#4c8bf5' }
36
+
37
+ /** Words in an agent's title or type that give its role, tried in order; the first match wins. */
38
+ const ROLE_RULES: ReadonlyArray<[Role, RegExp]> = [
39
+ ['review', /review|audit|inspect|critic|check/i],
40
+ ['test', /test|\bqa\b|verif/i],
41
+ ['design', /design|artist|\bart\b|style|graphic|visual|\bui\b/i],
42
+ ['explore', /explor|research|search|scout|find/i],
43
+ ['plan', /plan|architect|\bspec/i],
44
+ ['build', /build|implement|\bcode|\bfix|refactor|general-purpose/i],
45
+ ]
46
+
47
+ /** What each role is called on its crab, and the emoji it gets in the terminal. */
48
+ const ROLES: Readonly<Record<Role, { name: string; emoji: string }>> = {
49
+ review: { name: 'reviewer', emoji: '🔍' },
50
+ test: { name: 'tester', emoji: '🔧' },
51
+ design: { name: 'designer', emoji: '🎩' },
52
+ explore: { name: 'explorer', emoji: '🧭' },
53
+ plan: { name: 'planner', emoji: '📜' },
54
+ build: { name: 'builder', emoji: '🔨' },
55
+ other: { name: 'helper', emoji: '' },
23
56
  }
24
- const DEFAULT_TIER = { name: 'medium', color: '#4c8bf5', hat: 'sailor' as const }
25
57
 
26
58
  /** Sprite rows, 12 pixels wide; each letter is a palette color, `.` is empty. */
27
59
  const BODY = [
@@ -34,15 +66,37 @@ const BODY = [
34
66
  '..b.b..b.b..',
35
67
  '.b..b..b..b.',
36
68
  ]
37
- const HATS = {
38
- cap: ['...gggg.....', '..gggggg....', '..ggggggggg.'],
39
- sailor: ['...wwwwww...', '..wwwwwwww..', '..uuuuuuuu..'],
40
- hardhat: ['....yyyy....', '...yyyyyy...', '.yyyyyyyyyy.'],
41
- beanie: ['.....t......', '...nnnnnn...', '..nnnnnnnn..'],
42
- } as const
69
+ /** Each role's hat, four rows over the body: an inspector's deerstalker, a work helmet, a top hat, and so on. */
70
+ const HATS: Readonly<Record<Role, readonly string[]>> = {
71
+ review: ['............', '....hhhh....', '...hHhHhh...', '.hhhhhhhhhh.'],
72
+ test: ['.....aa.....', '...yyyyyy...', '..yyyyyyyy..', '.yyyyyyyyyy.'],
73
+ design: ['...tttttt...', '...tttttt...', '...vvvvvv...', '.tttttttttt.'],
74
+ explore: ['............', '....pppp....', '...pppppp...', '.pppppppppp.'],
75
+ plan: ['............', '.mmmmmmmmmmq', '...mmmmmm..q', '...mmmmmm...'],
76
+ build: ['............', '...gggg.....', '..gggggg....', '..ggggggggg.'],
77
+ other: ['............', '...wwwwww...', '..wwwwwwww..', '..uuuuuuuu..'],
78
+ }
79
+
80
+ /** What each role holds in its right claw, four pixels wide beside the body, by sprite row. */
81
+ const PROPS: Readonly<Record<Role, Readonly<Record<number, string>>>> = {
82
+ review: { 3: '.oo.', 4: 'oLLo', 5: 'oLLo', 6: '.oo.', 7: 's...' },
83
+ test: { 4: 'w.w.', 5: 'www.', 6: '.w..', 7: '.w..', 8: '.w..' },
84
+ design: { 2: '..P.', 3: '..P.', 4: '..f.', 5: '.s..', 6: '.s..', 7: 's...' },
85
+ explore: { 2: '.rrr', 3: '.rrr', 4: '.s..', 5: '.s..', 6: '.s..', 7: '.s..' },
86
+ plan: { 5: 'eee.', 6: 'eEe.', 7: 'eee.' },
87
+ build: { 3: 'mmm.', 4: 'mmm.', 5: '.s..', 6: '.s..', 7: '.s..' },
88
+ other: {},
89
+ }
90
+
91
+ /** Width of the whole sprite in pixels: the 12-pixel crab and the 4-pixel item it holds. */
92
+ const SPRITE_COLUMNS = 16
93
+
43
94
  const PALETTE: Readonly<Record<string, string>> = {
44
95
  b: '#e07a5f', C: '#c8553d', k: '#1e1e1e',
45
- g: '#3fa66b', w: '#f0f0f0', u: '#4c8bf5', y: '#f2c14e', t: '#e8e8e8', n: '#7a4a2a',
96
+ g: '#3fa66b', w: '#f0f0f0', u: '#4c8bf5', y: '#f2c14e', a: '#fff3b0',
97
+ h: '#8b6b3e', H: '#c9a46a', t: '#22252b', v: '#9b59d0', p: '#d8c38f',
98
+ m: '#2f3542', q: '#f2c14e', o: '#c9d1d9', L: '#9fd3e6', s: '#7a4a2a',
99
+ P: '#4c8bf5', f: '#c9d1d9', r: '#e05a4f', e: '#efe6c8', E: '#b8a77a',
46
100
  }
47
101
 
48
102
  /** Price per million tokens: input, output, cache read, cache write. First match wins. */
@@ -56,6 +110,21 @@ const PRICES: ReadonlyArray<[RegExp, [number, number, number, number]]> = [
56
110
  [/haiku/, [1, 5, 0.1, 1.25]],
57
111
  ]
58
112
 
113
+ /**
114
+ * Finds an agent's role from its title, then its subagent type.
115
+ *
116
+ * @param title - What the agent was started to do, e.g. `review: read mod files`.
117
+ * @param subagentType - Its subagent type, e.g. `Explore`.
118
+ * @returns The first role whose words match, or `other`.
119
+ */
120
+ export function roleOf(title: string, subagentType: string): Role {
121
+ for (const text of [title, subagentType]) {
122
+ const found = ROLE_RULES.find(([, words]) => words.test(text))
123
+ if (found !== undefined) return found[0]
124
+ }
125
+ return 'other'
126
+ }
127
+
59
128
  /**
60
129
  * Looks up the tier of an effort level, falling back to the default tier.
61
130
  *
@@ -124,14 +193,14 @@ function minutes(ms: number): string {
124
193
  }
125
194
 
126
195
  /**
127
- * Draws the crab as one SVG: hat rows for the tier on top of the body; a planned crab is drawn grey.
196
+ * Draws the crab as one SVG: its role's hat over the body, and the item it holds; a planned crab is drawn grey.
128
197
  *
129
- * @param hat - Hat to draw, a key of `HATS`.
198
+ * @param role - Role whose hat and item to draw.
130
199
  * @param isPlanned - Whether the crab is planned work, not a running or finished subagent.
131
200
  * @returns The SVG markup as a string.
132
201
  */
133
- function crabSvg(hat: keyof typeof HATS, isPlanned: boolean): string {
134
- const rows = [...HATS[hat], ...BODY]
202
+ function crabSvg(role: Role, isPlanned: boolean): string {
203
+ const rows = [...HATS[role], ...BODY].map((row, y) => row + (PROPS[role][y] ?? '....'))
135
204
  const rects: string[] = []
136
205
  rows.forEach((row, y) => {
137
206
  for (let x = 0; x < row.length; x++) {
@@ -139,7 +208,7 @@ function crabSvg(hat: keyof typeof HATS, isPlanned: boolean): string {
139
208
  if (color !== undefined) rects.push(`<rect x="${x * PIXEL}" y="${y * PIXEL}" width="${PIXEL}" height="${PIXEL}" fill="${color}"/>`)
140
209
  }
141
210
  })
142
- const width = 12 * PIXEL
211
+ const width = SPRITE_COLUMNS * PIXEL
143
212
  const height = rows.length * PIXEL
144
213
  const filter = isPlanned ? ' opacity="0.45" style="filter:grayscale(1)"' : ''
145
214
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"${filter}>${rects.join('')}</svg>`
@@ -178,6 +247,21 @@ const isNumber = (v: unknown): v is number => typeof v === 'number'
178
247
  /** Tells whether `v` is a non-null object. */
179
248
  const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null
180
249
 
250
+ /**
251
+ * Tells whether a drawn tree holds an element whose key starts with `prefix`.
252
+ *
253
+ * @param tree - Tree as a `ui.render` chain returned it, or one of its children.
254
+ * @param prefix - Key prefix to look for, e.g. `agents:`.
255
+ * @returns True when some element's key starts with `prefix`.
256
+ */
257
+ export function holdsKey(tree: unknown, prefix: string): boolean {
258
+ if (Array.isArray(tree)) return tree.some(child => holdsKey(child, prefix))
259
+ if (typeof tree !== 'object' || tree === null) return false
260
+ const { props, children } = tree as { props?: { key?: unknown }; children?: unknown }
261
+ if (typeof props?.key === 'string' && props.key.startsWith(prefix)) return true
262
+ return holdsKey(children, prefix)
263
+ }
264
+
181
265
  /**
182
266
  * Applies `patch` to every crab that `match` selects.
183
267
  *
@@ -190,14 +274,13 @@ async function patchCrab($: EngineInterface, match: (crab: Crab) => boolean, pat
190
274
  }
191
275
 
192
276
  /**
193
- * Records a subagent the main thread just started and shows the pane.
277
+ * Records a subagent the main thread just started.
194
278
  *
195
279
  * @param $ - Engine handle the hook received, see `EngineInterface`.
196
280
  * @param crab - New running crab to add, see `Crab`.
197
281
  */
198
282
  async function startCrab($: EngineInterface, crab: Crab): Promise<void> {
199
283
  await update($, crabs, list => [...list.filter(one => one.id !== crab.id), crab].slice(-KEEP))
200
- void $.ui.open({ id: PANE, title: TITLE })
201
284
  }
202
285
 
203
286
  /**
@@ -332,28 +415,70 @@ async function readWindow($: EngineInterface): Promise<void> {
332
415
  }
333
416
 
334
417
  /**
335
- * Registers this mod's `crabs` command, its session, tool, subagent and turn hooks, and its pane.
418
+ * Opens a crab's conversation as a pane of its own, which the viewer shows in a tab of its own.
419
+ *
420
+ * @param $ - Engine handle the press came through.
421
+ * @param crabId - `id` of the crab whose agent to show.
422
+ */
423
+ async function openChat($: EngineInterface, crabId: string): Promise<void> {
424
+ const crab = (await read($, crabs)).find(one => one.id === crabId)
425
+ if (crab?.agentId === undefined) {
426
+ $.ui.toast('This agent has no conversation yet; try again once it has started.')
427
+ return
428
+ }
429
+ const agentId = crab.agentId
430
+ const id = `${CHAT_PREFIX}${agentId.replace(/[^A-Za-z0-9_-]/g, '')}`.slice(0, 64)
431
+ await update($, chats, list => [...list.filter(chat => chat.id !== id), { id, agentId, title: crab.title }])
432
+ await $.ui.open({ id, title: `Chat · ${crab.title}`, focus: true })
433
+ }
434
+
435
+ /**
436
+ * Sends a message typed in a chat to its agent, which resumes a finished one.
437
+ *
438
+ * @param $ - Engine handle the submit came through.
439
+ * @param agentId - Agent the chat shows.
440
+ * @param text - What the person typed.
441
+ */
442
+ async function sendToAgent($: EngineInterface, agentId: string, text: string): Promise<void> {
443
+ const message = text.trim()
444
+ if (message === '') return
445
+ const sent = await $.session.send({ to: { agentId }, text: message }).catch((error: unknown) => ({ isDelivered: false as const, reason: String(error) }))
446
+ if (!sent.isDelivered) {
447
+ $.ui.toast(`Not delivered: ${sent.reason ?? 'the agent could not be reached'}`)
448
+ return
449
+ }
450
+ await patchCrab($, crab => crab.agentId === agentId, crab => ({ ...crab, status: 'running', durationMs: undefined }))
451
+ $.ui.invalidate('ui.render')
452
+ }
453
+
454
+ /**
455
+ * Shortens a tool call to the text its one line in a chat shows: a command, a file's name, a pattern.
456
+ *
457
+ * @param input - Arguments of the tool call.
458
+ * @returns The label with whitespace collapsed, at most 200 characters.
459
+ */
460
+ function toolLabel(input: Record<string, unknown>): string {
461
+ for (const key of ['command', 'file_path', 'pattern', 'url', 'query', 'description', 'prompt']) {
462
+ const value = input[key]
463
+ if (typeof value !== 'string' || value === '') continue
464
+ const text = key === 'file_path' ? value.split('/').pop() ?? value : value
465
+ return text.replace(/\s+/g, ' ').trim().slice(0, 200)
466
+ }
467
+ return ''
468
+ }
469
+
470
+ /**
471
+ * Registers this mod's session, tool, subagent and turn hooks, its section of the agents panel, and the agents' chats.
336
472
  *
337
473
  * @param on - Hook registration function the engine passes in.
338
474
  */
339
475
  export const register: Register = on => {
340
476
  on('session.start', async ($, e, next) => {
341
- await $.command.register({ name: 'crabs', description: 'Show or hide the subagent crabs' })
342
477
  await readWindow($)
343
- void $.ui.open({ id: PANE, title: TITLE })
344
478
  $.clock.every(1000, () => void tick($))
345
479
  return next(e)
346
480
  })
347
481
 
348
- on('command.run', { command: 'crabs' }, async $ => {
349
- if ((await $.ui.panes()).some(pane => pane.id === PANE)) {
350
- await $.ui.close({ id: PANE })
351
- return { text: 'Crabs hidden.' }
352
- }
353
- await $.ui.open({ id: PANE, title: TITLE })
354
- return { text: 'Crabs shown.' }
355
- })
356
-
357
482
  on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
358
483
  if (e.agentId !== undefined) return next(e)
359
484
  const startedAt = await $.clock.now()
@@ -399,26 +524,105 @@ export const register: Register = on => {
399
524
  return result
400
525
  }).catch(($, e, next) => next(e))
401
526
 
402
- on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
403
- const { Box, Text } = $.ui.resolve(e)
527
+ // Forgets a chat once its pane is closed.
528
+ on('ui.close', async ($, e, next) => {
529
+ if (e.id.startsWith(CHAT_PREFIX)) await update($, chats, list => list.filter(chat => chat.id !== e.id))
530
+ return next(e)
531
+ }).catch(($, e, next) => next(e))
532
+
533
+ // A subagent's chat: its whole conversation, each tool call on one line, and a box to message it.
534
+ on('ui.render', { component: 'Pane' }, async ($, e, next) => {
535
+ const chat = (await read($, chats)).find(one => one.id === e.requestId)
536
+ if (chat === undefined) return next(e)
537
+ const { Box, Text, Markdown } = $.ui.resolve(e)
538
+ const crab = (await read($, crabs)).find(one => one.agentId === chat.agentId)
539
+ const found = await $.session.messages({ agentId: chat.agentId })
540
+ const role = roleOf(chat.title, crab?.subagentType ?? '')
541
+ const status = crab === undefined ? '' : crab.status === 'running' ? '● running' : crab.status === 'failed' ? '✗ failed' : '✓ finished'
542
+
543
+ const header = (
544
+ <Box flexDirection="row" columnGap={1}>
545
+ <Text bold wrap="truncate-end">{chat.title}</Text>
546
+ <Text dimColor wrap="truncate-end">{ROLES[role].name}{crab === undefined ? '' : ` · ${modelName(crab.model)} · ${status}`}</Text>
547
+ </Box>
548
+ )
549
+ if ('deny' in found) return <Box flexDirection="column" gap={1}>{header}<Text dimColor>{found.deny}</Text></Box>
550
+
551
+ const rows = found.slice(-CHAT_ROWS).flatMap((message, index) => {
552
+ const parts = []
553
+ if (message.role === 'user' && message.text.trim() !== '') {
554
+ parts.push(
555
+ <Box key={`m${index}`} borderStyle="round" borderColor="#3a3f4b" paddingX={1} marginTop={1}>
556
+ <Text wrap="wrap">{message.text}</Text>
557
+ </Box>,
558
+ )
559
+ }
560
+ if (message.role === 'assistant' && message.text.trim() !== '') {
561
+ parts.push(<Box key={`m${index}`} marginTop={1}><Markdown text={message.text} /></Box>)
562
+ }
563
+ message.toolUses.forEach((use, n) => {
564
+ parts.push(
565
+ <Box key={`t${index}:${n}`} flexDirection="row" columnGap={1}>
566
+ <Text color={use.isError === true ? '#e06c6c' : '#7cc47f'}>⏺</Text>
567
+ <Text wrap="truncate-end">{use.tool} <Text dimColor>{toolLabel(use.input)}</Text></Text>
568
+ </Box>,
569
+ )
570
+ })
571
+ return parts
572
+ })
573
+
574
+ const input = e.surface === 'mobile'
575
+ ? ''
576
+ : (() => {
577
+ const { Input } = $.ui.resolve(e)
578
+ return (
579
+ <Input
580
+ key="send"
581
+ placeholder={crab?.status === 'running' ? 'Message this agent; it reads it after its current step' : 'Message this agent to continue it'}
582
+ submitLabel="Send"
583
+ onSubmit={value => sendToAgent($, chat.agentId, value)}
584
+ />
585
+ )
586
+ })()
587
+
588
+ return (
589
+ <Box flexDirection="column">
590
+ {header}
591
+ {rows.length === 0 ? <Text dimColor>Nothing in this conversation yet.</Text> : rows}
592
+ <Box marginTop={1}>{input}</Box>
593
+ </Box>
594
+ )
595
+ })
596
+
597
+ // Draws into the agents panel: under the panel's tree when the panel's hook lies beneath this one, or alone with an
598
+ // `addon:` key that the panel's hook picks up when it lies above. Either way the crabs end up under the agents.
599
+ on('ui.render', { component: 'Pane', requestId: PANEL }, async ($, e, next) => {
600
+ const { Box, Text, Button } = $.ui.resolve(e)
601
+ const below: RenderElement | undefined = await next(e).catch(() => undefined)
602
+ const panel = below !== undefined && holdsKey(below, PANEL_KEY) ? below : undefined
603
+ /** Puts the crabs' section under the panel's tree, when it was drawn beneath. */
604
+ const joined = (section: RenderElement) => (panel === undefined ? section : <Box flexDirection="column" gap={1}>{panel}{section}</Box>)
605
+
404
606
  const list = await read($, crabs)
405
607
  const plan = await read($, planned)
406
608
  const window = await read($, contextWindow)
407
609
  const now = await $.clock.now()
408
- if (list.length === 0 && plan.length === 0) return <Text dimColor>No subagents yet. Each one shows up here as a crab.</Text>
610
+ if (list.length === 0 && plan.length === 0) {
611
+ return joined(<Box key={`${ADDON_KEY}crabs`}><Text dimColor>No subagents yet. Each one shows up here as a crab.</Text></Box>)
612
+ }
409
613
 
410
614
  const barWidth = Math.max(120, (e.props.bodyColumns - 10) * 7)
411
615
  /**
412
616
  * Draws a crab sprite: an emoji in the terminal, an SVG elsewhere.
413
617
  *
414
- * @param hat - Hat to draw, a key of `HATS`.
618
+ * @param role - Role whose hat and item to draw.
415
619
  * @param isPlanned - Whether the crab is planned work.
416
620
  * @returns The sprite element.
417
621
  */
418
- const sprite = (hat: keyof typeof HATS, isPlanned: boolean) => {
419
- if (e.surface === 'terminal') return <Text color={isPlanned ? '#6b7280' : PALETTE.b}>🦀</Text>
622
+ const sprite = (role: Role, isPlanned: boolean) => {
623
+ if (e.surface === 'terminal') return <Text color={isPlanned ? '#6b7280' : PALETTE.b}>{ROLES[role].emoji}🦀</Text>
420
624
  const { Svg } = $.ui.resolve(e)
421
- return <Svg source={crabSvg(hat, isPlanned)} alt="crab" width={12 * PIXEL} height={11 * PIXEL} />
625
+ return <Svg source={crabSvg(role, isPlanned)} alt={`${ROLES[role].name} crab`} width={SPRITE_COLUMNS * PIXEL} height={12 * PIXEL} />
422
626
  }
423
627
  /**
424
628
  * Draws a progress bar for a crab.
@@ -445,6 +649,7 @@ export const register: Register = on => {
445
649
  */
446
650
  const card = (crab: Crab) => {
447
651
  const tier = tierOf(crab.effort)
652
+ const role = roleOf(crab.title, crab.subagentType)
448
653
  const elapsed = crab.durationMs ?? now - crab.startedAt
449
654
  const tokens = crab.tokens ?? 0
450
655
  const share = crab.steps.total > 0 ? crab.steps.done / crab.steps.total : crab.status === 'running' ? 0.08 + Math.min(0.8, crab.requests / 40) : 1
@@ -462,14 +667,17 @@ export const register: Register = on => {
462
667
  : <Text color="#7cc47f">✓</Text>
463
668
  return (
464
669
  <Box flexDirection="row" columnGap={2} paddingY={1}>
465
- {sprite(tier.hat, false)}
670
+ {sprite(role, false)}
466
671
  <Box flexDirection="column" flexGrow={1}>
467
672
  <Box flexDirection="row" justifyContent="space-between">
468
- <Text bold wrap="truncate-end">{crab.title}</Text>
673
+ <Button key={`chat:${crab.id}`} plain onPress={() => openChat($, crab.id)}>
674
+ <Text bold wrap="truncate-end">{crab.title}</Text>
675
+ <Text dimColor> ↗</Text>
676
+ </Button>
469
677
  {mark}
470
678
  </Box>
471
679
  <Text>
472
- <Text color={tier.color}>{tier.name}</Text>
680
+ <Text color={tier.color}>{ROLES[role].name}</Text>
473
681
  <Text dimColor> {modelName(crab.model)}{crab.effort === undefined ? '' : ` · ${crab.effort}`}</Text>
474
682
  </Text>
475
683
  <Box flexDirection="row" justifyContent="space-between">
@@ -485,15 +693,16 @@ export const register: Register = on => {
485
693
  const running = list.filter(crab => crab.status === 'running')
486
694
  const done = list.filter(crab => crab.status !== 'running')
487
695
 
488
- return (
489
- <Box flexDirection="column">
696
+ return joined(
697
+ <Box key={`${ADDON_KEY}crabs`} flexDirection="column">
698
+ <Text bold>Subagents</Text>
490
699
  {running.map(card)}
491
700
  {done.length > 0 && <Text dimColor>▾ Finished · {done.length}</Text>}
492
701
  {done.slice().reverse().map(card)}
493
702
  {plan.length > 0 && <Text dimColor>Planned · {plan.length}</Text>}
494
703
  {plan.map(item => (
495
704
  <Box flexDirection="row" columnGap={2} paddingY={1}>
496
- {sprite('beanie', true)}
705
+ {sprite(roleOf(item.content, ''), true)}
497
706
  <Box flexDirection="column" flexGrow={1}>
498
707
  <Box flexDirection="row" justifyContent="space-between">
499
708
  <Text bold dimColor wrap="truncate-end">{item.index}. {item.content}</Text>
@@ -503,7 +712,7 @@ export const register: Register = on => {
503
712
  </Box>
504
713
  </Box>
505
714
  ))}
506
- </Box>
715
+ </Box>,
507
716
  )
508
717
  })
509
718
  }
@@ -38,8 +38,19 @@ export type PlannedItem = {
38
38
  after: number[]
39
39
  }
40
40
 
41
+ /** What an agent does, read from its title or type; it picks the crab's hat and the item it holds. */
42
+ export type Role = 'review' | 'test' | 'design' | 'explore' | 'plan' | 'build' | 'other'
43
+
44
+ /** A subagent's conversation opened as a pane of its own. */
45
+ export type ChatPane = {
46
+ /** Pane id, `tab-chat-` and the agent's id */
47
+ id: string
48
+ agentId: string
49
+ title: string
50
+ }
51
+
41
52
  declare module 'claude-code' {
42
53
  interface PluginState {
43
- 'custom-crab-agents': { crabs: Crab[]; planned: PlannedItem[]; window: number }
54
+ 'custom-crab-agents': { crabs: Crab[]; planned: PlannedItem[]; window: number; chats: ChatPane[] }
44
55
  }
45
56
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "delete-guard",
3
3
  "version": "0.1.0",
4
- "description": "Denies rm -rf and other permanent deletes and points Claude to the trash.",
4
+ "description": "Holds rm -rf and other permanent deletes on a card that lists what they remove, until you press Delete or Cancel.",
5
5
  "author": {
6
6
  "name": "g3ntrovert"
7
7
  },
@@ -1 +1 @@
1
- { "modules": ["./register.ts"] }
1
+ { "modules": ["./register.tsx"] }