@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.
- package/.claude-plugin/marketplace.json +3 -3
- package/README.md +5 -5
- package/mods/agents-panel/hooks/register.tsx +34 -8
- package/mods/custom-crab-agents/.claude-plugin/plugin.json +5 -2
- package/mods/custom-crab-agents/hooks/register.tsx +261 -52
- package/mods/custom-crab-agents/types/index.d.ts +12 -1
- package/mods/delete-guard/.claude-plugin/plugin.json +1 -1
- package/mods/delete-guard/hooks/hooks.json +1 -1
- package/mods/delete-guard/hooks/register.tsx +585 -0
- package/mods/delete-guard/types/index.d.ts +26 -1
- package/mods/focus-mode/.claude-plugin/plugin.json +1 -1
- package/mods/focus-mode/hooks/register.tsx +71 -7
- package/mods/focus-mode/types/index.d.ts +1 -1
- package/mods/mini-game/hooks/register.tsx +14 -6
- package/mods/tool-timeline/hooks/register.tsx +6 -3
- package/package.json +2 -2
- package/mods/delete-guard/hooks/register.ts +0 -151
|
@@ -19,19 +19,19 @@
|
|
|
19
19
|
{
|
|
20
20
|
"name": "custom-crab-agents",
|
|
21
21
|
"source": "./mods/custom-crab-agents",
|
|
22
|
-
"description": "
|
|
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": "
|
|
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
|
|
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` |
|
|
26
|
-
| `delete-guard` |
|
|
27
|
-
| `focus-mode` | A timed focus block
|
|
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
|
|
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
|
|
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": "
|
|
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
|
-
|
|
8
|
-
const
|
|
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
|
|
17
|
-
const TIERS: Readonly<Record<string, { name: string; color: string
|
|
18
|
-
low: { name: 'light', color: '#3fa66b'
|
|
19
|
-
medium: { name: 'medium', color: '#4c8bf5'
|
|
20
|
-
high: { name: 'careful', color: '#e0a030'
|
|
21
|
-
xhigh: { name: 'heavy', color: '#e05a4f'
|
|
22
|
-
max: { name: 'heavy', color: '#e05a4f'
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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',
|
|
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
|
|
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
|
|
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(
|
|
134
|
-
const rows = [...HATS[
|
|
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 =
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
403
|
-
|
|
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)
|
|
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
|
|
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 = (
|
|
419
|
-
if (e.surface === 'terminal') return <Text color={isPlanned ? '#6b7280' : PALETTE.b}
|
|
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(
|
|
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(
|
|
670
|
+
{sprite(role, false)}
|
|
466
671
|
<Box flexDirection="column" flexGrow={1}>
|
|
467
672
|
<Box flexDirection="row" justifyContent="space-between">
|
|
468
|
-
<
|
|
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}>{
|
|
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('
|
|
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": "
|
|
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.
|
|
1
|
+
{ "modules": ["./register.tsx"] }
|