@g3nomaly/g3n-mods 0.0.0-stage → 0.1.1
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 +68 -0
- package/LICENSE +95 -0
- package/mods/agents-panel/.claude-plugin/plugin.json +9 -0
- package/mods/agents-panel/hooks/hooks.json +1 -0
- package/mods/agents-panel/hooks/register.tsx +277 -0
- package/mods/agents-panel/types/index.d.ts +24 -0
- package/mods/context-bar/.claude-plugin/plugin.json +9 -0
- package/mods/context-bar/hooks/hooks.json +1 -0
- package/mods/context-bar/hooks/register.tsx +215 -0
- package/mods/context-bar/types/index.d.ts +25 -0
- package/mods/custom-crab-agents/.claude-plugin/plugin.json +9 -0
- package/mods/custom-crab-agents/hooks/hooks.json +1 -0
- package/mods/custom-crab-agents/hooks/register.tsx +508 -0
- package/mods/custom-crab-agents/types/index.d.ts +45 -0
- package/mods/delete-guard/.claude-plugin/plugin.json +9 -0
- package/mods/delete-guard/hooks/hooks.json +1 -0
- package/mods/delete-guard/hooks/register.ts +151 -0
- package/mods/delete-guard/types/index.d.ts +14 -0
- package/mods/focus-mode/.claude-plugin/plugin.json +17 -0
- package/mods/focus-mode/hooks/hooks.json +1 -0
- package/mods/focus-mode/hooks/register.tsx +184 -0
- package/mods/focus-mode/types/index.d.ts +14 -0
- package/mods/handoff/.claude-plugin/plugin.json +9 -0
- package/mods/handoff/hooks/hooks.json +1 -0
- package/mods/handoff/hooks/register.tsx +156 -0
- package/mods/handoff/types/index.d.ts +15 -0
- package/mods/hide-personal-data/.claude-plugin/plugin.json +17 -0
- package/mods/hide-personal-data/hooks/hooks.json +1 -0
- package/mods/hide-personal-data/hooks/register.tsx +196 -0
- package/mods/hide-personal-data/types/index.d.ts +17 -0
- package/mods/mini-game/.claude-plugin/plugin.json +9 -0
- package/mods/mini-game/hooks/hooks.json +1 -0
- package/mods/mini-game/hooks/register.tsx +273 -0
- package/mods/mini-game/types/index.d.ts +21 -0
- package/mods/tool-timeline/.claude-plugin/plugin.json +9 -0
- package/mods/tool-timeline/hooks/hooks.json +1 -0
- package/mods/tool-timeline/hooks/register.tsx +311 -0
- package/mods/tool-timeline/types/index.d.ts +25 -0
- package/mods/tsconfig.json +17 -0
- package/mods/you-should-know/.claude-plugin/plugin.json +23 -0
- package/mods/you-should-know/hooks/hooks.json +1 -0
- package/mods/you-should-know/hooks/register.tsx +116 -0
- package/mods/you-should-know/types/index.d.ts +15 -0
- package/package.json +25 -4
- package/README.md +0 -3
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
// packages/g3n-mods/mods/custom-crab-agents/hooks/register.tsx
|
|
2
|
+
import { atom, read, update } from 'claude-code'
|
|
3
|
+
import type { EngineInterface, Register } from 'claude-code'
|
|
4
|
+
|
|
5
|
+
import type { Crab, CrabSteps, PlannedItem } from '../types'
|
|
6
|
+
|
|
7
|
+
const PANE = 'crabs'
|
|
8
|
+
const TITLE = 'Crabs'
|
|
9
|
+
const crabs = atom({ plugin: 'custom-crab-agents', key: 'crabs' } as const, [])
|
|
10
|
+
const planned = atom({ plugin: 'custom-crab-agents', key: 'planned' } as const, [])
|
|
11
|
+
const contextWindow = atom({ plugin: 'custom-crab-agents', key: 'window' } as const, 1_000_000)
|
|
12
|
+
|
|
13
|
+
const KEEP = 30
|
|
14
|
+
const PIXEL = 4
|
|
15
|
+
|
|
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' },
|
|
23
|
+
}
|
|
24
|
+
const DEFAULT_TIER = { name: 'medium', color: '#4c8bf5', hat: 'sailor' as const }
|
|
25
|
+
|
|
26
|
+
/** Sprite rows, 12 pixels wide; each letter is a palette color, `.` is empty. */
|
|
27
|
+
const BODY = [
|
|
28
|
+
'...k....k...',
|
|
29
|
+
'C..b....b..C',
|
|
30
|
+
'CC.bbbbbb.CC',
|
|
31
|
+
'.CbbbbbbbbC.',
|
|
32
|
+
'..bbbbbbbb..',
|
|
33
|
+
'..bbbbbbbb..',
|
|
34
|
+
'..b.b..b.b..',
|
|
35
|
+
'.b..b..b..b.',
|
|
36
|
+
]
|
|
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
|
|
43
|
+
const PALETTE: Readonly<Record<string, string>> = {
|
|
44
|
+
b: '#e07a5f', C: '#c8553d', k: '#1e1e1e',
|
|
45
|
+
g: '#3fa66b', w: '#f0f0f0', u: '#4c8bf5', y: '#f2c14e', t: '#e8e8e8', n: '#7a4a2a',
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Price per million tokens: input, output, cache read, cache write. First match wins. */
|
|
49
|
+
const PRICES: ReadonlyArray<[RegExp, [number, number, number, number]]> = [
|
|
50
|
+
[/fable|mythos/, [10, 50, 0.25, 12.5]],
|
|
51
|
+
[/opus-5-5/, [4, 20, 0.2, 5]],
|
|
52
|
+
[/opus/, [5, 25, 0.5, 6.25]],
|
|
53
|
+
[/sonnet-5/, [2, 10, 0.2, 2.5]],
|
|
54
|
+
[/sonnet/, [3, 15, 0.3, 3.75]],
|
|
55
|
+
[/haiku-5/, [0.1, 0.5, 0.01, 0.125]],
|
|
56
|
+
[/haiku/, [1, 5, 0.1, 1.25]],
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Looks up the tier of an effort level, falling back to the default tier.
|
|
61
|
+
*
|
|
62
|
+
* @param effort - Effort level of an Agent call, e.g. `high`, or `undefined` when none was given.
|
|
63
|
+
* @returns The tier with its name, color and hat.
|
|
64
|
+
*/
|
|
65
|
+
function tierOf(effort: string | undefined) {
|
|
66
|
+
return (effort === undefined ? undefined : TIERS[effort]) ?? DEFAULT_TIER
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Formats a model id or alias as a short display name.
|
|
71
|
+
*
|
|
72
|
+
* @param model - Model id or alias, e.g. `claude-opus-5-5` or `haiku`.
|
|
73
|
+
* @returns The family and version, e.g. `Opus 5.5` or `Haiku`, or `model` unchanged when no family matches.
|
|
74
|
+
*/
|
|
75
|
+
export function modelName(model: string): string {
|
|
76
|
+
const match = /(opus|sonnet|haiku|fable|mythos)(?:-(\d+))?(?:-(\d+))?/i.exec(model)
|
|
77
|
+
if (match === null) return model
|
|
78
|
+
const family = (match[1] ?? '').charAt(0).toUpperCase() + (match[1] ?? '').slice(1).toLowerCase()
|
|
79
|
+
const version = [match[2], match[3]].filter(part => part !== undefined && part.length <= 2).join('.')
|
|
80
|
+
return version.length > 0 ? `${family} ${version}` : family
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Estimates the dollar cost of a usage record from the price table.
|
|
85
|
+
*
|
|
86
|
+
* @param model - Model id or alias, matched against the price table.
|
|
87
|
+
* @param usage - Usage record with token counts under `input_tokens`, `output_tokens`,
|
|
88
|
+
* `cache_read_input_tokens` and `cache_creation_input_tokens`, e.g. `{ input_tokens: 1200 }`.
|
|
89
|
+
* @returns Estimated cost in US dollars, or `undefined` when no price matches the model.
|
|
90
|
+
*/
|
|
91
|
+
export function costOf(model: string, usage: Record<string, unknown>): number | undefined {
|
|
92
|
+
const price = PRICES.find(([match]) => match.test(model))?.[1]
|
|
93
|
+
if (price === undefined) return undefined
|
|
94
|
+
/**
|
|
95
|
+
* Reads one token count from the usage record.
|
|
96
|
+
*
|
|
97
|
+
* @param key - Token field to read, e.g. `input_tokens`.
|
|
98
|
+
* @returns The count, or `0` when the field is missing or not a number.
|
|
99
|
+
*/
|
|
100
|
+
const n = (key: string) => (typeof usage[key] === 'number' ? (usage[key] as number) : 0)
|
|
101
|
+
return (n('input_tokens') * price[0] + n('output_tokens') * price[1]
|
|
102
|
+
+ n('cache_read_input_tokens') * price[2] + n('cache_creation_input_tokens') * price[3]) / 1_000_000
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Formats a token count as `892`, `1.2k` or `52k`.
|
|
107
|
+
*
|
|
108
|
+
* @param tokens - Token count, e.g. `52000`.
|
|
109
|
+
* @returns Short label.
|
|
110
|
+
*/
|
|
111
|
+
function formatTokens(tokens: number): string {
|
|
112
|
+
return tokens >= 10_000 ? `${Math.round(tokens / 1000)}k` : tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Formats a duration as `m:ss`.
|
|
117
|
+
*
|
|
118
|
+
* @param ms - Duration in milliseconds.
|
|
119
|
+
* @returns Minutes and zero-padded seconds, e.g. `1:05`.
|
|
120
|
+
*/
|
|
121
|
+
function minutes(ms: number): string {
|
|
122
|
+
const s = Math.max(0, Math.round(ms / 1000))
|
|
123
|
+
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Draws the crab as one SVG: hat rows for the tier on top of the body; a planned crab is drawn grey.
|
|
128
|
+
*
|
|
129
|
+
* @param hat - Hat to draw, a key of `HATS`.
|
|
130
|
+
* @param isPlanned - Whether the crab is planned work, not a running or finished subagent.
|
|
131
|
+
* @returns The SVG markup as a string.
|
|
132
|
+
*/
|
|
133
|
+
function crabSvg(hat: keyof typeof HATS, isPlanned: boolean): string {
|
|
134
|
+
const rows = [...HATS[hat], ...BODY]
|
|
135
|
+
const rects: string[] = []
|
|
136
|
+
rows.forEach((row, y) => {
|
|
137
|
+
for (let x = 0; x < row.length; x++) {
|
|
138
|
+
const color = PALETTE[row.charAt(x)]
|
|
139
|
+
if (color !== undefined) rects.push(`<rect x="${x * PIXEL}" y="${y * PIXEL}" width="${PIXEL}" height="${PIXEL}" fill="${color}"/>`)
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
const width = 12 * PIXEL
|
|
143
|
+
const height = rows.length * PIXEL
|
|
144
|
+
const filter = isPlanned ? ' opacity="0.45" style="filter:grayscale(1)"' : ''
|
|
145
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"${filter}>${rects.join('')}</svg>`
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Draws a progress bar as one SVG, filled to `share` of its width.
|
|
150
|
+
*
|
|
151
|
+
* @param width - Width of the bar in pixels.
|
|
152
|
+
* @param share - Filled part of the bar, from `0` to `1`.
|
|
153
|
+
* @param color - Fill color as a CSS hex string.
|
|
154
|
+
* @returns The SVG markup as a string.
|
|
155
|
+
*/
|
|
156
|
+
function progressSvg(width: number, share: number, color: string): string {
|
|
157
|
+
const w = Math.max(0, Math.min(1, share)) * width
|
|
158
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="5" viewBox="0 0 ${width} 5"><rect width="${width}" height="5" rx="2" fill="#2b3240"/><rect width="${w.toFixed(1)}" height="5" rx="2" fill="${color}"/></svg>`
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Reads one property of an unknown value when the property passes `guard`.
|
|
163
|
+
*
|
|
164
|
+
* @param value - Any value, e.g. a parsed tool result.
|
|
165
|
+
* @param key - Name of the property to read.
|
|
166
|
+
* @param guard - Type guard the property value must pass.
|
|
167
|
+
* @returns The property value, or `undefined` when `value` is not an object or the guard fails.
|
|
168
|
+
*/
|
|
169
|
+
function field<T>(value: unknown, key: string, guard: (v: unknown) => v is T): T | undefined {
|
|
170
|
+
if (typeof value !== 'object' || value === null) return undefined
|
|
171
|
+
const found = (value as Record<string, unknown>)[key]
|
|
172
|
+
return guard(found) ? found : undefined
|
|
173
|
+
}
|
|
174
|
+
/** Tells whether `v` is a string. */
|
|
175
|
+
const isString = (v: unknown): v is string => typeof v === 'string'
|
|
176
|
+
/** Tells whether `v` is a number. */
|
|
177
|
+
const isNumber = (v: unknown): v is number => typeof v === 'number'
|
|
178
|
+
/** Tells whether `v` is a non-null object. */
|
|
179
|
+
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Applies `patch` to every crab that `match` selects.
|
|
183
|
+
*
|
|
184
|
+
* @param $ - Engine handle the hook received, see `EngineInterface`.
|
|
185
|
+
* @param match - Selects the crabs to change.
|
|
186
|
+
* @param patch - Returns the changed crab for a selected one.
|
|
187
|
+
*/
|
|
188
|
+
async function patchCrab($: EngineInterface, match: (crab: Crab) => boolean, patch: (crab: Crab) => Crab): Promise<void> {
|
|
189
|
+
await update($, crabs, list => list.map(crab => (match(crab) ? patch(crab) : crab)))
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Records a subagent the main thread just started and shows the pane.
|
|
194
|
+
*
|
|
195
|
+
* @param $ - Engine handle the hook received, see `EngineInterface`.
|
|
196
|
+
* @param crab - New running crab to add, see `Crab`.
|
|
197
|
+
*/
|
|
198
|
+
async function startCrab($: EngineInterface, crab: Crab): Promise<void> {
|
|
199
|
+
await update($, crabs, list => [...list.filter(one => one.id !== crab.id), crab].slice(-KEEP))
|
|
200
|
+
void $.ui.open({ id: PANE, title: TITLE })
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Settles a crab from the Agent tool's result: its usage, model and time, or that it went to the background.
|
|
205
|
+
*
|
|
206
|
+
* @param $ - Engine handle the hook received, see `EngineInterface`.
|
|
207
|
+
* @param id - `tool_use_id` of the Agent call that started the crab.
|
|
208
|
+
* @param result - Raw result of the Agent call, of unknown shape.
|
|
209
|
+
* @param isError - Whether the Agent call reported an error.
|
|
210
|
+
*/
|
|
211
|
+
async function finishCrab($: EngineInterface, id: string, result: unknown, isError: boolean): Promise<void> {
|
|
212
|
+
const status = field(result, 'status', isString)
|
|
213
|
+
const agentId = field(result, 'agentId', isString)
|
|
214
|
+
if (status === 'async_launched') {
|
|
215
|
+
await patchCrab($, crab => crab.id === id, crab => ({ ...crab, agentId: agentId ?? crab.agentId }))
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
const usage = field(result, 'usage', isRecord) ?? {}
|
|
219
|
+
const now = await $.clock.now()
|
|
220
|
+
await patchCrab($, crab => crab.id === id, crab => {
|
|
221
|
+
const model = field(result, 'resolvedModel', isString) ?? crab.model
|
|
222
|
+
return {
|
|
223
|
+
...crab,
|
|
224
|
+
agentId: agentId ?? crab.agentId,
|
|
225
|
+
model,
|
|
226
|
+
status: isError ? 'failed' : 'finished',
|
|
227
|
+
durationMs: field(result, 'totalDurationMs', isNumber) ?? now - crab.startedAt,
|
|
228
|
+
tokens: field(result, 'totalTokens', isNumber) ?? crab.tokens,
|
|
229
|
+
costUsd: costOf(model, usage) ?? crab.costUsd,
|
|
230
|
+
steps: crab.steps.total > 0 ? { ...crab.steps, done: crab.steps.total, current: undefined } : crab.steps,
|
|
231
|
+
}
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Settles a background crab when its own loop completes.
|
|
237
|
+
*
|
|
238
|
+
* @param $ - Engine handle the hook received, see `EngineInterface`.
|
|
239
|
+
* @param agentId - Id of the subagent whose loop completed.
|
|
240
|
+
* @param usage - Usage record of that loop, or `undefined` when none was reported.
|
|
241
|
+
*/
|
|
242
|
+
async function finishBackground($: EngineInterface, agentId: string, usage: Record<string, unknown> | undefined): Promise<void> {
|
|
243
|
+
const now = await $.clock.now()
|
|
244
|
+
await patchCrab($, crab => crab.agentId === agentId && crab.status === 'running', crab => ({
|
|
245
|
+
...crab,
|
|
246
|
+
status: 'finished',
|
|
247
|
+
durationMs: now - crab.startedAt,
|
|
248
|
+
tokens: usage === undefined ? crab.tokens : ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens']
|
|
249
|
+
.reduce((sum, key) => sum + (typeof usage[key] === 'number' ? (usage[key] as number) : 0), 0),
|
|
250
|
+
costUsd: usage === undefined ? crab.costUsd : costOf(crab.model, usage),
|
|
251
|
+
}))
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Ties a subagent's id to the newest running crab of its type that has none yet.
|
|
256
|
+
*
|
|
257
|
+
* @param $ - Engine handle the hook received, see `EngineInterface`.
|
|
258
|
+
* @param agentId - Id the engine gave the subagent.
|
|
259
|
+
* @param agentType - Subagent type the crab was started with, e.g. `Explore`.
|
|
260
|
+
*/
|
|
261
|
+
async function bindAgent($: EngineInterface, agentId: string, agentType: string): Promise<void> {
|
|
262
|
+
await update($, crabs, list => {
|
|
263
|
+
if (list.some(crab => crab.agentId === agentId)) return list
|
|
264
|
+
const index = list.map(crab => crab.status === 'running' && crab.agentId === undefined && crab.subagentType === agentType).lastIndexOf(true)
|
|
265
|
+
return index === -1 ? list : list.map((crab, i) => (i === index ? { ...crab, agentId } : crab))
|
|
266
|
+
})
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Turns a todo list into steps (a subagent's own list) or planned items (the main thread's).
|
|
271
|
+
*
|
|
272
|
+
* @param $ - Engine handle the hook received, see `EngineInterface`.
|
|
273
|
+
* @param todos - Raw `todos` field of a TodoWrite call, of unknown shape.
|
|
274
|
+
* @param agentId - Id of the subagent that wrote the list, or `undefined` for the main thread.
|
|
275
|
+
*/
|
|
276
|
+
async function readTodos($: EngineInterface, todos: unknown, agentId: string | undefined): Promise<void> {
|
|
277
|
+
if (!Array.isArray(todos)) return
|
|
278
|
+
const items = todos.filter(isRecord).map(todo => ({
|
|
279
|
+
content: field(todo, 'content', isString) ?? '',
|
|
280
|
+
status: field(todo, 'status', isString) ?? 'pending',
|
|
281
|
+
activeForm: field(todo, 'activeForm', isString),
|
|
282
|
+
}))
|
|
283
|
+
|
|
284
|
+
if (agentId !== undefined) {
|
|
285
|
+
const steps: CrabSteps = {
|
|
286
|
+
done: items.filter(item => item.status === 'completed').length,
|
|
287
|
+
total: items.length,
|
|
288
|
+
current: items.find(item => item.status === 'in_progress')?.activeForm,
|
|
289
|
+
}
|
|
290
|
+
await patchCrab($, crab => crab.agentId === agentId, crab => ({ ...crab, steps }))
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const open: PlannedItem[] = []
|
|
295
|
+
items.forEach((item, i) => {
|
|
296
|
+
if (item.status !== 'pending') return
|
|
297
|
+
const after = items.slice(0, i).map((other, j) => (other.status === 'completed' ? 0 : j + 1)).filter(n => n > 0)
|
|
298
|
+
open.push({ index: i + 1, content: item.content, after })
|
|
299
|
+
})
|
|
300
|
+
await update($, planned, () => open)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Counts a subagent's model requests and keeps its model and effort current.
|
|
305
|
+
*
|
|
306
|
+
* @param $ - Engine handle the hook received, see `EngineInterface`.
|
|
307
|
+
* @param agentId - Id of the subagent that made the request.
|
|
308
|
+
* @param model - Model the request ran on.
|
|
309
|
+
* @param effort - Effort level of the request, or `undefined` when none applies.
|
|
310
|
+
*/
|
|
311
|
+
async function countStep($: EngineInterface, agentId: string, model: string, effort: string | undefined): Promise<void> {
|
|
312
|
+
await patchCrab($, crab => crab.agentId === agentId, crab => ({ ...crab, requests: crab.requests + 1, model, effort: effort ?? crab.effort }))
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Redraws once a second while a crab runs, so its time moves.
|
|
317
|
+
*
|
|
318
|
+
* @param $ - Engine handle the hook received, see `EngineInterface`.
|
|
319
|
+
*/
|
|
320
|
+
async function tick($: EngineInterface): Promise<void> {
|
|
321
|
+
if ((await read($, crabs)).some(crab => crab.status === 'running')) $.ui.invalidate('ui.render')
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Reads the session's context window size into state, keeping the old size when the read fails.
|
|
326
|
+
*
|
|
327
|
+
* @param $ - Engine handle the hook received, see `EngineInterface`.
|
|
328
|
+
*/
|
|
329
|
+
async function readWindow($: EngineInterface): Promise<void> {
|
|
330
|
+
const usage = await $.session.usage().catch(() => undefined)
|
|
331
|
+
if (usage !== undefined && usage.context.window > 0) await update($, contextWindow, () => usage.context.window)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Registers this mod's `crabs` command, its session, tool, subagent and turn hooks, and its pane.
|
|
336
|
+
*
|
|
337
|
+
* @param on - Hook registration function the engine passes in.
|
|
338
|
+
*/
|
|
339
|
+
export const register: Register = on => {
|
|
340
|
+
on('session.start', async ($, e, next) => {
|
|
341
|
+
await $.command.register({ name: 'crabs', description: 'Show or hide the subagent crabs' })
|
|
342
|
+
await readWindow($)
|
|
343
|
+
$.clock.every(1000, () => void tick($))
|
|
344
|
+
return next(e)
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
on('command.run', { command: 'crabs' }, async $ => {
|
|
348
|
+
if ((await $.ui.panes()).some(pane => pane.id === PANE)) {
|
|
349
|
+
await $.ui.close({ id: PANE })
|
|
350
|
+
return { text: 'Crabs hidden.' }
|
|
351
|
+
}
|
|
352
|
+
await $.ui.open({ id: PANE, title: TITLE })
|
|
353
|
+
return { text: 'Crabs shown.' }
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
|
|
357
|
+
if (e.agentId !== undefined) return next(e)
|
|
358
|
+
const startedAt = await $.clock.now()
|
|
359
|
+
await startCrab($, {
|
|
360
|
+
id: e.tool_use_id,
|
|
361
|
+
title: e.description,
|
|
362
|
+
subagentType: e.subagent_type ?? 'general-purpose',
|
|
363
|
+
model: e.model ?? 'inherit',
|
|
364
|
+
effort: e.effort === undefined ? undefined : String(e.effort),
|
|
365
|
+
status: 'running',
|
|
366
|
+
startedAt,
|
|
367
|
+
steps: { done: 0, total: 0 },
|
|
368
|
+
requests: 0,
|
|
369
|
+
})
|
|
370
|
+
const ran = await next(e)
|
|
371
|
+
if ('deny' in ran) {
|
|
372
|
+
await update($, crabs, list => list.filter(crab => crab.id !== e.tool_use_id))
|
|
373
|
+
return ran
|
|
374
|
+
}
|
|
375
|
+
await finishCrab($, e.tool_use_id, ran.result, ran.isError === true)
|
|
376
|
+
return ran
|
|
377
|
+
}).catch(($, e, next) => next(e))
|
|
378
|
+
|
|
379
|
+
on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
|
|
380
|
+
const ran = await next(e)
|
|
381
|
+
if (!('deny' in ran) && ran.isError !== true) await readTodos($, e.todos, e.agentId)
|
|
382
|
+
return ran
|
|
383
|
+
}).catch(($, e, next) => next(e))
|
|
384
|
+
|
|
385
|
+
on('classic.SubagentStart', async ($, e, next) => {
|
|
386
|
+
await bindAgent($, e.agent_id, e.agent_type)
|
|
387
|
+
return next(e)
|
|
388
|
+
}).catch(($, e, next) => next(e))
|
|
389
|
+
|
|
390
|
+
on('turn.step', async function* ($, e, next) {
|
|
391
|
+
if (e.agentId !== undefined) await countStep($, e.agentId, e.model, e.effort === undefined ? undefined : String(e.effort))
|
|
392
|
+
return yield* next(e)
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
on('turn.complete', async ($, e, next) => {
|
|
396
|
+
const result = await next(e)
|
|
397
|
+
if (e.agentId !== undefined) await finishBackground($, e.agentId, e.usage === undefined ? undefined : { ...e.usage })
|
|
398
|
+
return result
|
|
399
|
+
}).catch(($, e, next) => next(e))
|
|
400
|
+
|
|
401
|
+
on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
|
|
402
|
+
const { Box, Text } = $.ui.resolve(e)
|
|
403
|
+
const list = await read($, crabs)
|
|
404
|
+
const plan = await read($, planned)
|
|
405
|
+
const window = await read($, contextWindow)
|
|
406
|
+
const now = await $.clock.now()
|
|
407
|
+
if (list.length === 0 && plan.length === 0) return <Text dimColor>No subagents yet. Each one shows up here as a crab.</Text>
|
|
408
|
+
|
|
409
|
+
const barWidth = Math.max(120, (e.props.bodyColumns - 10) * 7)
|
|
410
|
+
/**
|
|
411
|
+
* Draws a crab sprite: an emoji in the terminal, an SVG elsewhere.
|
|
412
|
+
*
|
|
413
|
+
* @param hat - Hat to draw, a key of `HATS`.
|
|
414
|
+
* @param isPlanned - Whether the crab is planned work.
|
|
415
|
+
* @returns The sprite element.
|
|
416
|
+
*/
|
|
417
|
+
const sprite = (hat: keyof typeof HATS, isPlanned: boolean) => {
|
|
418
|
+
if (e.surface === 'terminal') return <Text color={isPlanned ? '#6b7280' : PALETTE.b}>🦀</Text>
|
|
419
|
+
const { Svg } = $.ui.resolve(e)
|
|
420
|
+
return <Svg source={crabSvg(hat, isPlanned)} alt="crab" width={12 * PIXEL} height={11 * PIXEL} />
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Draws a progress bar for a crab.
|
|
424
|
+
*
|
|
425
|
+
* @param share - Filled part of the bar, from `0` to `1`.
|
|
426
|
+
* @param color - Fill color as a CSS hex string.
|
|
427
|
+
* @returns The bar element.
|
|
428
|
+
*/
|
|
429
|
+
const progress = (share: number, color: string) => {
|
|
430
|
+
if (e.surface === 'terminal') {
|
|
431
|
+
const cells = Math.max(10, e.props.bodyColumns - 10)
|
|
432
|
+
const filled = Math.round(Math.max(0, Math.min(1, share)) * cells)
|
|
433
|
+
return <Text><Text color={color}>{'━'.repeat(filled)}</Text><Text color="#2b3240">{'━'.repeat(cells - filled)}</Text></Text>
|
|
434
|
+
}
|
|
435
|
+
const { Svg } = $.ui.resolve(e)
|
|
436
|
+
return <Svg source={progressSvg(barWidth, share, color)} alt={`${Math.round(share * 100)}%`} width={barWidth} height={5} />
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Draws one crab card: sprite, title, tier, model, step line, stats and progress.
|
|
441
|
+
*
|
|
442
|
+
* @param crab - Crab to draw, see `Crab`.
|
|
443
|
+
* @returns The card element.
|
|
444
|
+
*/
|
|
445
|
+
const card = (crab: Crab) => {
|
|
446
|
+
const tier = tierOf(crab.effort)
|
|
447
|
+
const elapsed = crab.durationMs ?? now - crab.startedAt
|
|
448
|
+
const tokens = crab.tokens ?? 0
|
|
449
|
+
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
|
|
450
|
+
const stepLine = crab.steps.total > 0
|
|
451
|
+
? `${crab.steps.done}/${crab.steps.total}${crab.steps.current === undefined ? '' : ` · ${crab.steps.current}`}`
|
|
452
|
+
: crab.status === 'running' ? `step ${crab.requests}` : `${crab.requests} step${crab.requests === 1 ? '' : 's'}`
|
|
453
|
+
const stats = [
|
|
454
|
+
tokens > 0 ? `ctx ${Math.max(1, Math.round((tokens / window) * 100))}%` : '',
|
|
455
|
+
tokens > 0 ? formatTokens(tokens) : '',
|
|
456
|
+
crab.costUsd === undefined ? '' : `≈$${crab.costUsd.toFixed(2)}`,
|
|
457
|
+
minutes(elapsed),
|
|
458
|
+
].filter(part => part.length > 0).join(' · ')
|
|
459
|
+
const mark = crab.status === 'running' ? <Text color="#2f6b4a">●</Text>
|
|
460
|
+
: crab.status === 'failed' ? <Text color="#e06c6c">✗</Text>
|
|
461
|
+
: <Text color="#7cc47f">✓</Text>
|
|
462
|
+
return (
|
|
463
|
+
<Box flexDirection="row" columnGap={2} paddingY={1}>
|
|
464
|
+
{sprite(tier.hat, false)}
|
|
465
|
+
<Box flexDirection="column" flexGrow={1}>
|
|
466
|
+
<Box flexDirection="row" justifyContent="space-between">
|
|
467
|
+
<Text bold wrap="truncate-end">{crab.title}</Text>
|
|
468
|
+
{mark}
|
|
469
|
+
</Box>
|
|
470
|
+
<Text>
|
|
471
|
+
<Text color={tier.color}>{tier.name}</Text>
|
|
472
|
+
<Text dimColor> {modelName(crab.model)}{crab.effort === undefined ? '' : ` · ${crab.effort}`}</Text>
|
|
473
|
+
</Text>
|
|
474
|
+
<Box flexDirection="row" justifyContent="space-between">
|
|
475
|
+
<Text wrap="truncate-end">{stepLine}</Text>
|
|
476
|
+
<Text dimColor>{stats}</Text>
|
|
477
|
+
</Box>
|
|
478
|
+
{progress(share, tier.color)}
|
|
479
|
+
</Box>
|
|
480
|
+
</Box>
|
|
481
|
+
)
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const running = list.filter(crab => crab.status === 'running')
|
|
485
|
+
const done = list.filter(crab => crab.status !== 'running')
|
|
486
|
+
|
|
487
|
+
return (
|
|
488
|
+
<Box flexDirection="column">
|
|
489
|
+
{running.map(card)}
|
|
490
|
+
{done.length > 0 && <Text dimColor>▾ Finished · {done.length}</Text>}
|
|
491
|
+
{done.slice().reverse().map(card)}
|
|
492
|
+
{plan.length > 0 && <Text dimColor>Planned · {plan.length}</Text>}
|
|
493
|
+
{plan.map(item => (
|
|
494
|
+
<Box flexDirection="row" columnGap={2} paddingY={1}>
|
|
495
|
+
{sprite('beanie', true)}
|
|
496
|
+
<Box flexDirection="column" flexGrow={1}>
|
|
497
|
+
<Box flexDirection="row" justifyContent="space-between">
|
|
498
|
+
<Text bold dimColor wrap="truncate-end">{item.index}. {item.content}</Text>
|
|
499
|
+
<Text dimColor>◷</Text>
|
|
500
|
+
</Box>
|
|
501
|
+
<Text dimColor>planned{item.after.length > 0 ? ` · after ${item.after.join(', ')}` : ''}</Text>
|
|
502
|
+
</Box>
|
|
503
|
+
</Box>
|
|
504
|
+
))}
|
|
505
|
+
</Box>
|
|
506
|
+
)
|
|
507
|
+
})
|
|
508
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// packages/g3n-mods/mods/custom-crab-agents/types/index.d.ts
|
|
2
|
+
|
|
3
|
+
/** Progress a subagent reported through its own todo list. */
|
|
4
|
+
export type CrabSteps = {
|
|
5
|
+
done: number
|
|
6
|
+
total: number
|
|
7
|
+
/** The step in progress, as its todo's active form says, e.g. `Writing hero copy` */
|
|
8
|
+
current?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** One subagent drawn as a crab; times are milliseconds since the epoch. */
|
|
12
|
+
export type Crab = {
|
|
13
|
+
/** The Agent call's tool_use_id */
|
|
14
|
+
id: string
|
|
15
|
+
title: string
|
|
16
|
+
subagentType: string
|
|
17
|
+
/** Model id or alias, e.g. `claude-opus-5-5` or `haiku` */
|
|
18
|
+
model: string
|
|
19
|
+
/** `low`, `medium`, `high`, `xhigh` or `max` */
|
|
20
|
+
effort?: string
|
|
21
|
+
status: 'running' | 'finished' | 'failed'
|
|
22
|
+
agentId?: string
|
|
23
|
+
startedAt: number
|
|
24
|
+
durationMs?: number
|
|
25
|
+
tokens?: number
|
|
26
|
+
costUsd?: number
|
|
27
|
+
steps: CrabSteps
|
|
28
|
+
/** Model requests the subagent has made so far */
|
|
29
|
+
requests: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** One open item of the main thread's todo list, drawn as a planned crab. */
|
|
33
|
+
export type PlannedItem = {
|
|
34
|
+
/** 1-based position in the todo list */
|
|
35
|
+
index: number
|
|
36
|
+
content: string
|
|
37
|
+
/** Positions of the open items before it */
|
|
38
|
+
after: number[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
declare module 'claude-code' {
|
|
42
|
+
interface PluginState {
|
|
43
|
+
'custom-crab-agents': { crabs: Crab[]; planned: PlannedItem[]; window: number }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "modules": ["./register.ts"] }
|