@g3nomaly/g3n-mods 0.1.0 → 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.
@@ -20,7 +20,12 @@ 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
- /** Frontmatter of a Markdown file as flat `key: value` pairs, quotes stripped. */
23
+ /**
24
+ * Parses the frontmatter of a Markdown file into flat `key: value` pairs.
25
+ *
26
+ * @param text - Full Markdown text, which starts with a `---` block when it has frontmatter.
27
+ * @returns Fields by key with surrounding quotes stripped, or `{}` when there is no frontmatter.
28
+ */
24
29
  export function frontmatter(text: string): Record<string, string> {
25
30
  const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)
26
31
  if (match === null) return {}
@@ -33,12 +38,26 @@ export function frontmatter(text: string): Record<string, string> {
33
38
  return fields
34
39
  }
35
40
 
41
+ /**
42
+ * Picks the dot color of an agent: its own color when it names one, else a palette color by position.
43
+ *
44
+ * @param agent - Definition whose `color` is tried first.
45
+ * @param index - Position of the agent in its list, used to pick a palette color.
46
+ * @returns CSS hex color.
47
+ */
36
48
  function dotColor(agent: AgentDef, index: number): string {
37
49
  const named = agent.color === undefined ? undefined : NAMED_COLORS[agent.color] ?? agent.color
38
50
  return named ?? DOT_COLORS[index % DOT_COLORS.length] ?? '#8a8f98'
39
51
  }
40
52
 
41
- /** Reads every agent definition in one agents folder; a missing folder reads as none. */
53
+ /**
54
+ * Reads every agent definition in one agents folder.
55
+ *
56
+ * @param $ - Engine handle the hook received, see `EngineInterface`.
57
+ * @param dir - Folder to read, e.g. `<root>/.claude/agents`.
58
+ * @param scope - Where the folder lives, as `project` or `user`.
59
+ * @returns Definitions sorted by name, or `[]` when the folder does not exist.
60
+ */
42
61
  async function readAgentsDir($: EngineInterface, dir: string, scope: AgentDef['scope']): Promise<AgentDef[]> {
43
62
  if (!(await $.fs.exists(dir))) return []
44
63
  const found: AgentDef[] = []
@@ -56,7 +75,13 @@ async function readAgentsDir($: EngineInterface, dir: string, scope: AgentDef['s
56
75
  return found.sort((a, b) => a.name.localeCompare(b.name))
57
76
  }
58
77
 
59
- /** Reloads the project's and the user's agent definitions into state. */
78
+ /**
79
+ * Reloads the project's and the user's agent definitions into state.
80
+ *
81
+ * A user agent is dropped when the project has one with the same name.
82
+ *
83
+ * @param $ - Engine handle the hook received, see `EngineInterface`.
84
+ */
60
85
  async function loadAgents($: EngineInterface): Promise<void> {
61
86
  const root = await $.session.root()
62
87
  const home = await $.env.get('HOME')
@@ -66,7 +91,12 @@ async function loadAgents($: EngineInterface): Promise<void> {
66
91
  await update($, agents, () => [...project, ...user.filter(agent => !names.has(agent.name))])
67
92
  }
68
93
 
69
- /** Starts one agent with the task typed in the panel, or its standing job when none was typed. */
94
+ /**
95
+ * Starts one agent with the task typed in the panel, or its standing job when none was typed.
96
+ *
97
+ * @param $ - Engine handle the hook received, see `EngineInterface`.
98
+ * @param agent - Definition of the agent to start.
99
+ */
70
100
  async function runAgent($: EngineInterface, agent: AgentDef): Promise<void> {
71
101
  const typed = (await read($, task)).trim()
72
102
  const started = await $.agent.spawn({
@@ -83,7 +113,11 @@ async function runAgent($: EngineInterface, agent: AgentDef): Promise<void> {
83
113
  $.ui.toast(`Started ${agent.name}`)
84
114
  }
85
115
 
86
- /** Drops the agents that finished from the running list and says which ones did. */
116
+ /**
117
+ * Drops the agents that finished from the running list and says which ones did.
118
+ *
119
+ * @param $ - Engine handle the hook received, see `EngineInterface`.
120
+ */
87
121
  async function pollRunning($: EngineInterface): Promise<void> {
88
122
  const list = await read($, running)
89
123
  if (list.length === 0) return
@@ -100,12 +134,25 @@ async function pollRunning($: EngineInterface): Promise<void> {
100
134
  }
101
135
  }
102
136
 
103
- /** Puts a finished agent's answer in the transcript as a notice the model does not read. */
137
+ /**
138
+ * Puts a finished agent's answer in the transcript as a notice the model does not read.
139
+ *
140
+ * Answers longer than 4000 characters are cut to 4000.
141
+ *
142
+ * @param $ - Engine handle the hook received, see `EngineInterface`.
143
+ * @param run - Agent that finished, from the running list.
144
+ * @param answer - Final text of the agent's reply.
145
+ */
104
146
  async function reportAnswer($: EngineInterface, run: RunningAgent, answer: string): Promise<void> {
105
147
  const text = answer.length > 4000 ? `${answer.slice(0, 4000)}…` : answer
106
148
  await $.session.append({ message: { type: 'system', content: [{ type: 'text', text: `${run.name} finished:\n\n${text}` }] } })
107
149
  }
108
150
 
151
+ /**
152
+ * Registers this mod's `agents-panel` command, its session and turn hooks, and its pane.
153
+ *
154
+ * @param on - Hook registration function the engine passes in.
155
+ */
109
156
  export const register: Register = on => {
110
157
  on('session.start', async ($, e, next) => {
111
158
  await $.command.register({ name: 'agents-panel', description: 'Show or hide the agents panel' })
@@ -163,6 +210,12 @@ export const register: Register = on => {
163
210
  )
164
211
  }
165
212
 
213
+ /**
214
+ * Renders the agents of one scope under a heading, or nothing when the scope has none.
215
+ *
216
+ * @param scope - Scope to render, as `project` or `user`.
217
+ * @returns The section element, or `''` when no agent has that scope.
218
+ */
166
219
  const section = (scope: AgentDef['scope']) => {
167
220
  const members = list.filter(agent => agent.scope === scope)
168
221
  if (members.length === 0) return ''
@@ -28,7 +28,12 @@ const SOURCES: ReadonlyArray<{ match: RegExp; label: string; color: string }> =
28
28
  { match: /message/i, label: 'messages', color: '#e0775a' },
29
29
  ]
30
30
 
31
- /** Tokens as the screenshots spell them: `892`, `4.2k`, `52k`, `1M`. */
31
+ /**
32
+ * Formats a token count the way the bar labels it, with `k` and `M` suffixes.
33
+ *
34
+ * @param tokens - Token count, e.g. `52000`.
35
+ * @returns Short label, e.g. `892`, `4.2k`, `52k` or `1M`.
36
+ */
32
37
  export function formatTokens(tokens: number): string {
33
38
  if (tokens >= 1_000_000) return `${+(tokens / 1_000_000).toFixed(1)}M`
34
39
  if (tokens >= 10_000) return `${Math.round(tokens / 1000)}k`
@@ -36,15 +41,23 @@ export function formatTokens(tokens: number): string {
36
41
  return String(Math.round(tokens))
37
42
  }
38
43
 
44
+ /**
45
+ * Rounds `part` as a percent of `whole`.
46
+ *
47
+ * @returns The rounded percent, or `0` when `whole` is not positive.
48
+ */
39
49
  function percentOf(part: number, whole: number): number {
40
50
  return whole > 0 ? Math.round((part / whole) * 100) : 0
41
51
  }
42
52
 
43
53
  /**
44
- * Measures the context window by source. Categories of the same source add
45
- * up into one row; deferred tools and the compaction buffer are left out.
54
+ * @brief Measures the context window by source.
46
55
  *
47
- * @returns the snapshot, or `null` before the session has measured anything
56
+ * Categories of the same source add up into one row; deferred tools and the
57
+ * compaction buffer are left out.
58
+ *
59
+ * @param $ - Engine handle the hook received, see `EngineInterface`.
60
+ * @returns The snapshot, or `null` before the session has measured anything.
48
61
  */
49
62
  async function measure($: EngineInterface): Promise<ContextSnapshot | null> {
50
63
  const usage = await $.session.usage({ breakdown: 'summary' })
@@ -70,7 +83,13 @@ async function measure($: EngineInterface): Promise<ContextSnapshot | null> {
70
83
  }
71
84
  }
72
85
 
73
- /** The bar as one SVG: a rect per source, the free remainder, and a mark at the compaction point. */
86
+ /**
87
+ * The bar as one SVG: a rect per source, the free remainder, and a mark at the compaction point.
88
+ *
89
+ * @param snap - Snapshot to draw, see `ContextSnapshot`.
90
+ * @param width - Width of the SVG in pixels.
91
+ * @returns The SVG markup as a string.
92
+ */
74
93
  function barSvg(snap: ContextSnapshot, width: number): string {
75
94
  const height = 14
76
95
  let x = 0
@@ -89,7 +108,11 @@ function barSvg(snap: ContextSnapshot, width: number): string {
89
108
  let lastMeasured = 0
90
109
  let isScheduled = false
91
110
 
92
- /** Measures now, or once the gap since the last measurement has passed; calls in between fold into one. */
111
+ /**
112
+ * Measures now, or once the gap since the last measurement has passed; calls in between fold into one.
113
+ *
114
+ * @param $ - Engine handle the hook received, see `EngineInterface`.
115
+ */
93
116
  async function refresh($: EngineInterface): Promise<void> {
94
117
  const now = await $.clock.now()
95
118
  const wait = lastMeasured + MEASURE_GAP_MS - now
@@ -107,6 +130,11 @@ async function refresh($: EngineInterface): Promise<void> {
107
130
  if (snap !== null) await update($, snapshot, () => snap)
108
131
  }
109
132
 
133
+ /**
134
+ * Registers this mod's `context-bar` command, its session, measure and turn hooks, and its pane.
135
+ *
136
+ * @param on - Hook registration function the engine passes in.
137
+ */
110
138
  export const register: Register = on => {
111
139
  on('session.start', async ($, e, next) => {
112
140
  await $.command.register({ name: 'context-bar', description: 'Show or hide the context window bar' })
@@ -56,11 +56,22 @@ const PRICES: ReadonlyArray<[RegExp, [number, number, number, number]]> = [
56
56
  [/haiku/, [1, 5, 0.1, 1.25]],
57
57
  ]
58
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
+ */
59
65
  function tierOf(effort: string | undefined) {
60
66
  return (effort === undefined ? undefined : TIERS[effort]) ?? DEFAULT_TIER
61
67
  }
62
68
 
63
- /** A model id or alias as people say it: `claude-opus-5-5` → `Opus 5.5`, `haiku` → `Haiku`. */
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
+ */
64
75
  export function modelName(model: string): string {
65
76
  const match = /(opus|sonnet|haiku|fable|mythos)(?:-(\d+))?(?:-(\d+))?/i.exec(model)
66
77
  if (match === null) return model
@@ -69,25 +80,56 @@ export function modelName(model: string): string {
69
80
  return version.length > 0 ? `${family} ${version}` : family
70
81
  }
71
82
 
72
- /** Estimated dollars for a usage record, or `undefined` when the model is not priced. */
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
+ */
73
91
  export function costOf(model: string, usage: Record<string, unknown>): number | undefined {
74
92
  const price = PRICES.find(([match]) => match.test(model))?.[1]
75
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
+ */
76
100
  const n = (key: string) => (typeof usage[key] === 'number' ? (usage[key] as number) : 0)
77
101
  return (n('input_tokens') * price[0] + n('output_tokens') * price[1]
78
102
  + n('cache_read_input_tokens') * price[2] + n('cache_creation_input_tokens') * price[3]) / 1_000_000
79
103
  }
80
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
+ */
81
111
  function formatTokens(tokens: number): string {
82
112
  return tokens >= 10_000 ? `${Math.round(tokens / 1000)}k` : tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens)
83
113
  }
84
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
+ */
85
121
  function minutes(ms: number): string {
86
122
  const s = Math.max(0, Math.round(ms / 1000))
87
123
  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
88
124
  }
89
125
 
90
- /** The crab as one SVG: hat rows for the tier on top of the body; a planned crab is drawn grey. */
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
+ */
91
133
  function crabSvg(hat: keyof typeof HATS, isPlanned: boolean): string {
92
134
  const rows = [...HATS[hat], ...BODY]
93
135
  const rects: string[] = []
@@ -103,31 +145,69 @@ function crabSvg(hat: keyof typeof HATS, isPlanned: boolean): string {
103
145
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"${filter}>${rects.join('')}</svg>`
104
146
  }
105
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
+ */
106
156
  function progressSvg(width: number, share: number, color: string): string {
107
157
  const w = Math.max(0, Math.min(1, share)) * width
108
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>`
109
159
  }
110
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
+ */
111
169
  function field<T>(value: unknown, key: string, guard: (v: unknown) => v is T): T | undefined {
112
170
  if (typeof value !== 'object' || value === null) return undefined
113
171
  const found = (value as Record<string, unknown>)[key]
114
172
  return guard(found) ? found : undefined
115
173
  }
174
+ /** Tells whether `v` is a string. */
116
175
  const isString = (v: unknown): v is string => typeof v === 'string'
176
+ /** Tells whether `v` is a number. */
117
177
  const isNumber = (v: unknown): v is number => typeof v === 'number'
178
+ /** Tells whether `v` is a non-null object. */
118
179
  const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null
119
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
+ */
120
188
  async function patchCrab($: EngineInterface, match: (crab: Crab) => boolean, patch: (crab: Crab) => Crab): Promise<void> {
121
189
  await update($, crabs, list => list.map(crab => (match(crab) ? patch(crab) : crab)))
122
190
  }
123
191
 
124
- /** Records a subagent the main thread just started and shows the pane. */
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
+ */
125
198
  async function startCrab($: EngineInterface, crab: Crab): Promise<void> {
126
199
  await update($, crabs, list => [...list.filter(one => one.id !== crab.id), crab].slice(-KEEP))
127
200
  void $.ui.open({ id: PANE, title: TITLE })
128
201
  }
129
202
 
130
- /** Settles a crab from the Agent tool's result: its usage, model and time, or that it went to the background. */
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
+ */
131
211
  async function finishCrab($: EngineInterface, id: string, result: unknown, isError: boolean): Promise<void> {
132
212
  const status = field(result, 'status', isString)
133
213
  const agentId = field(result, 'agentId', isString)
@@ -152,7 +232,13 @@ async function finishCrab($: EngineInterface, id: string, result: unknown, isErr
152
232
  })
153
233
  }
154
234
 
155
- /** Settles a background crab when its own loop completes. */
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
+ */
156
242
  async function finishBackground($: EngineInterface, agentId: string, usage: Record<string, unknown> | undefined): Promise<void> {
157
243
  const now = await $.clock.now()
158
244
  await patchCrab($, crab => crab.agentId === agentId && crab.status === 'running', crab => ({
@@ -165,7 +251,13 @@ async function finishBackground($: EngineInterface, agentId: string, usage: Reco
165
251
  }))
166
252
  }
167
253
 
168
- /** Ties a subagent's id to the newest running crab of its type that has none yet. */
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
+ */
169
261
  async function bindAgent($: EngineInterface, agentId: string, agentType: string): Promise<void> {
170
262
  await update($, crabs, list => {
171
263
  if (list.some(crab => crab.agentId === agentId)) return list
@@ -174,7 +266,13 @@ async function bindAgent($: EngineInterface, agentId: string, agentType: string)
174
266
  })
175
267
  }
176
268
 
177
- /** Turns a todo list into steps (a subagent's own list) or planned items (the main thread's). */
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
+ */
178
276
  async function readTodos($: EngineInterface, todos: unknown, agentId: string | undefined): Promise<void> {
179
277
  if (!Array.isArray(todos)) return
180
278
  const items = todos.filter(isRecord).map(todo => ({
@@ -202,21 +300,42 @@ async function readTodos($: EngineInterface, todos: unknown, agentId: string | u
202
300
  await update($, planned, () => open)
203
301
  }
204
302
 
205
- /** Counts a subagent's model requests and keeps its model and effort current. */
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
+ */
206
311
  async function countStep($: EngineInterface, agentId: string, model: string, effort: string | undefined): Promise<void> {
207
312
  await patchCrab($, crab => crab.agentId === agentId, crab => ({ ...crab, requests: crab.requests + 1, model, effort: effort ?? crab.effort }))
208
313
  }
209
314
 
210
- /** Redraws once a second while a crab runs, so its time moves. */
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
+ */
211
320
  async function tick($: EngineInterface): Promise<void> {
212
321
  if ((await read($, crabs)).some(crab => crab.status === 'running')) $.ui.invalidate('ui.render')
213
322
  }
214
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
+ */
215
329
  async function readWindow($: EngineInterface): Promise<void> {
216
330
  const usage = await $.session.usage().catch(() => undefined)
217
331
  if (usage !== undefined && usage.context.window > 0) await update($, contextWindow, () => usage.context.window)
218
332
  }
219
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
+ */
220
339
  export const register: Register = on => {
221
340
  on('session.start', async ($, e, next) => {
222
341
  await $.command.register({ name: 'crabs', description: 'Show or hide the subagent crabs' })
@@ -288,11 +407,25 @@ export const register: Register = on => {
288
407
  if (list.length === 0 && plan.length === 0) return <Text dimColor>No subagents yet. Each one shows up here as a crab.</Text>
289
408
 
290
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
+ */
291
417
  const sprite = (hat: keyof typeof HATS, isPlanned: boolean) => {
292
418
  if (e.surface === 'terminal') return <Text color={isPlanned ? '#6b7280' : PALETTE.b}>🦀</Text>
293
419
  const { Svg } = $.ui.resolve(e)
294
420
  return <Svg source={crabSvg(hat, isPlanned)} alt="crab" width={12 * PIXEL} height={11 * PIXEL} />
295
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
+ */
296
429
  const progress = (share: number, color: string) => {
297
430
  if (e.surface === 'terminal') {
298
431
  const cells = Math.max(10, e.props.bodyColumns - 10)
@@ -303,6 +436,12 @@ export const register: Register = on => {
303
436
  return <Svg source={progressSvg(barWidth, share, color)} alt={`${Math.round(share * 100)}%`} width={barWidth} height={5} />
304
437
  }
305
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
+ */
306
445
  const card = (crab: Crab) => {
307
446
  const tier = tierOf(crab.effort)
308
447
  const elapsed = crab.durationMs ?? now - crab.startedAt
@@ -21,18 +21,33 @@ const TRASH_COMMANDS = [
21
21
  { probe: 'gio', spell: 'gio trash' },
22
22
  ] as const
23
23
 
24
- /** Splits a shell command line into its commands, each trimmed. */
24
+ /**
25
+ * Splits a shell command line into its commands, each trimmed.
26
+ *
27
+ * @param command - Shell command line, e.g. `cd build && rm -rf dist`.
28
+ * @returns The non-empty commands in order.
29
+ */
25
30
  function segments(command: string): string[] {
26
31
  return command.split(SEGMENT_BREAK).map(part => part.trim()).filter(part => part.length > 0)
27
32
  }
28
33
 
29
- /** Words of a command after its prefixes, the program name without its directory. */
34
+ /**
35
+ * Splits one command into words, dropping its prefixes and the program's directory.
36
+ *
37
+ * @param segment - One command, as `segments` returns it.
38
+ * @returns The program name first, then its arguments.
39
+ */
30
40
  function words(segment: string): string[] {
31
41
  const [program = '', ...rest] = segment.replace(PREFIX, '').split(/\s+/)
32
42
  return [program.split('/').pop() ?? program, ...rest]
33
43
  }
34
44
 
35
- /** The operands of a command: every word that is not a flag, and every word after `--`. */
45
+ /**
46
+ * Picks the operands of a command: every word that is not a flag, and every word after `--`.
47
+ *
48
+ * @param args - Words after the program name.
49
+ * @returns The operands in order.
50
+ */
36
51
  function operands(args: readonly string[]): string[] {
37
52
  const end = args.indexOf('--')
38
53
  const flagged = end === -1 ? args : args.slice(0, end)
@@ -41,10 +56,13 @@ function operands(args: readonly string[]): string[] {
41
56
  }
42
57
 
43
58
  /**
44
- * Finds the first permanent delete in a shell command line: `rm`, `shred`,
45
- * `unlink`, `rimraf`, `find -delete`, `find -exec rm` or a forced `git clean`.
59
+ * @brief Finds the first permanent delete in a shell command line.
60
+ *
61
+ * Recognises `rm`, `shred`, `unlink`, `rimraf`, `find -delete`, `find -exec rm`
62
+ * and a forced `git clean`.
46
63
  *
47
- * @returns the delete and what it removes, or `undefined` when nothing is deleted for good
64
+ * @param command - Full shell command line, as the Bash tool received it.
65
+ * @returns The delete and what it removes, or `undefined` when nothing is deleted for good.
48
66
  */
49
67
  export function findDeletion(command: string): Deletion | undefined {
50
68
  for (const segment of segments(command)) {
@@ -62,7 +80,13 @@ export function findDeletion(command: string): Deletion | undefined {
62
80
  return undefined
63
81
  }
64
82
 
65
- /** What Claude should run instead, spelled with the trash command this machine has. */
83
+ /**
84
+ * Builds the command Claude should run instead of a permanent delete.
85
+ *
86
+ * @param deletion - Delete to replace, see `Deletion`.
87
+ * @param trash - Trash command spelled as this machine has it, e.g. `gio trash`.
88
+ * @returns The suggested command, with `<paths>` where no path was named.
89
+ */
66
90
  function insteadOf(deletion: Deletion, trash: string): string {
67
91
  switch (deletion.kind) {
68
92
  case 'files':
@@ -74,7 +98,12 @@ function insteadOf(deletion: Deletion, trash: string): string {
74
98
  }
75
99
  }
76
100
 
77
- /** First trash command found on this machine's PATH, or `undefined` when there is none. */
101
+ /**
102
+ * Finds the first trash command on this machine's PATH.
103
+ *
104
+ * @param $ - Engine handle the hook received, see `EngineInterface`.
105
+ * @returns The command as it should be spelled, or `undefined` when none is installed.
106
+ */
78
107
  async function detectTrash($: EngineInterface): Promise<string | undefined> {
79
108
  for (const candidate of TRASH_COMMANDS) {
80
109
  const found = await $.process.run(['sh', '-c', `command -v ${candidate.probe}`]).catch(() => undefined)
@@ -83,6 +112,11 @@ async function detectTrash($: EngineInterface): Promise<string | undefined> {
83
112
  return undefined
84
113
  }
85
114
 
115
+ /**
116
+ * Registers this mod's `delete-guard` command, its session hook, and its Bash tool hook.
117
+ *
118
+ * @param on - Hook registration function the engine passes in.
119
+ */
86
120
  export const register: Register = on => {
87
121
  let trash: string | undefined
88
122
 
@@ -14,7 +14,12 @@ const MAX_SQUARES = 30
14
14
  const FILLED = '#e6b450'
15
15
  const EMPTY = '#3a3f4b'
16
16
 
17
- /** Remaining time as `mm:ss`, or `h:mm:ss` from an hour up. */
17
+ /**
18
+ * Formats remaining time as `mm:ss`, or `h:mm:ss` from an hour up.
19
+ *
20
+ * @param ms - Milliseconds left. Rounded up to whole seconds; negative values count as `0`.
21
+ * @returns Clock text, e.g. `24:59`.
22
+ */
18
23
  export function clock(ms: number): string {
19
24
  const total = Math.max(0, Math.ceil(ms / 1000))
20
25
  const h = Math.floor(total / 3600)
@@ -25,7 +30,12 @@ export function clock(ms: number): string {
25
30
  return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`
26
31
  }
27
32
 
28
- /** A wall-clock time as `3:10 pm`. */
33
+ /**
34
+ * Formats a wall-clock time as `3:10 pm`.
35
+ *
36
+ * @param epochMs - Time in milliseconds since the epoch, shown in local time.
37
+ * @returns 12-hour clock text.
38
+ */
29
39
  function wallTime(epochMs: number): string {
30
40
  const date = new Date(epochMs)
31
41
  const hours = date.getHours()
@@ -33,6 +43,13 @@ function wallTime(epochMs: number): string {
33
43
  return `${hours % 12 === 0 ? 12 : hours % 12}:${minutes} ${hours < 12 ? 'am' : 'pm'}`
34
44
  }
35
45
 
46
+ /**
47
+ * Draws a row of squares as one SVG, lighting the first `filled` of them.
48
+ *
49
+ * @param count - Number of squares.
50
+ * @param filled - Number of lit squares, counted from the left.
51
+ * @returns The SVG markup as a string.
52
+ */
36
53
  function squaresSvg(count: number, filled: number): string {
37
54
  const size = 10
38
55
  const gap = 3
@@ -42,14 +59,24 @@ function squaresSvg(count: number, filled: number): string {
42
59
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${size}" viewBox="0 0 ${width} ${size}">${rects.join('')}</svg>`
43
60
  }
44
61
 
45
- /** Starts a block of `minutes`, replacing one already running, and shows the pane. */
62
+ /**
63
+ * Starts a block of `minutes`, replacing one already running, and shows the pane.
64
+ *
65
+ * @param $ - Engine handle the hook received, see `EngineInterface`.
66
+ * @param minutes - Length of the block in minutes.
67
+ */
46
68
  async function startFocus($: EngineInterface, minutes: number): Promise<void> {
47
69
  const startedAt = await $.clock.now()
48
70
  await update($, block, () => ({ startedAt, endsAt: startedAt + minutes * 60_000, minutes }))
49
71
  await $.ui.open({ id: PANE, title: TITLE })
50
72
  }
51
73
 
52
- /** Ends the running block early, or on time when `isDone`. */
74
+ /**
75
+ * Ends the running block, and counts it as done when it ran its full time.
76
+ *
77
+ * @param $ - Engine handle the hook received, see `EngineInterface`.
78
+ * @param isDone - `true` when the block ran out, `false` when the user ended it early.
79
+ */
53
80
  async function endFocus($: EngineInterface, isDone: boolean): Promise<void> {
54
81
  const current = await read($, block)
55
82
  if (current === null) return
@@ -62,7 +89,11 @@ async function endFocus($: EngineInterface, isDone: boolean): Promise<void> {
62
89
  }
63
90
  }
64
91
 
65
- /** Once a second: ends a block whose time is up, else moves the countdown on. */
92
+ /**
93
+ * Moves the running block on by one second: ends it when its time is up, else updates the countdown.
94
+ *
95
+ * @param $ - Engine handle the hook received, see `EngineInterface`.
96
+ */
66
97
  async function tick($: EngineInterface): Promise<void> {
67
98
  const current = await read($, block)
68
99
  if (current === null) return
@@ -75,6 +106,12 @@ async function tick($: EngineInterface): Promise<void> {
75
106
  $.ui.invalidate('ui.render')
76
107
  }
77
108
 
109
+ /**
110
+ * Registers this mod's `focus-mode` command, its session hook, its tick, and its pane.
111
+ *
112
+ * @param on - Hook registration function the engine passes in.
113
+ * @param options - Plugin options; `minutes` sets the default block length.
114
+ */
78
115
  export const register: Register = (on, options) => {
79
116
  const configured = Number(options.minutes ?? DEFAULT_MINUTES)
80
117
  const defaultMinutes = Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_MINUTES
@@ -20,10 +20,22 @@ const FALLBACK_SKILL = [
20
20
  'If the user passed arguments, treat them as a description of what the next session will focus on and tailor the doc accordingly.',
21
21
  ].join('\n\n')
22
22
 
23
+ /**
24
+ * Removes the frontmatter block from a Markdown skill file.
25
+ *
26
+ * @param text - Full text of a `SKILL.md` file.
27
+ * @returns The body, trimmed.
28
+ */
23
29
  function withoutFrontmatter(text: string): string {
24
30
  return text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '').trim()
25
31
  }
26
32
 
33
+ /**
34
+ * Tells whether a stored value has the shape of a `PendingHandoff`.
35
+ *
36
+ * @param value - Value read from the store, of unknown shape.
37
+ * @returns `true` when `text` and `path` are strings and `createdAt` is a number.
38
+ */
27
39
  function isPendingHandoff(value: unknown): value is PendingHandoff {
28
40
  return typeof value === 'object' && value !== null
29
41
  && 'text' in value && typeof value.text === 'string'
@@ -31,7 +43,12 @@ function isPendingHandoff(value: unknown): value is PendingHandoff {
31
43
  && 'createdAt' in value && typeof value.createdAt === 'number'
32
44
  }
33
45
 
34
- /** The `handoff` skill's body from the project, else the user's skills, else the built-in copy. */
46
+ /**
47
+ * Loads the `handoff` skill's instructions: the project's copy, else the user's, else the built-in copy.
48
+ *
49
+ * @param $ - Engine handle the hook received, see `EngineInterface`.
50
+ * @returns The instructions without their frontmatter.
51
+ */
35
52
  async function loadSkill($: EngineInterface): Promise<string> {
36
53
  const root = await $.session.root()
37
54
  const home = await $.env.get('HOME')
@@ -41,19 +58,34 @@ async function loadSkill($: EngineInterface): Promise<string> {
41
58
  return FALLBACK_SKILL
42
59
  }
43
60
 
44
- /** Absolute path in the OS temporary folder for a handoff written at `now`. */
61
+ /**
62
+ * Builds the path of a handoff document in the OS temporary folder, named by the time it was written.
63
+ *
64
+ * @param $ - Engine handle the hook received, see `EngineInterface`.
65
+ * @param now - Time the handoff was written, in milliseconds since the epoch.
66
+ * @returns Absolute path, e.g. `/tmp/claude-handoff-2026-10-11T09-30-00.md`.
67
+ */
45
68
  async function documentPath($: EngineInterface, now: number): Promise<string> {
46
69
  const tmp = ((await $.env.get('TMPDIR')) ?? '/tmp').replace(/\/$/, '')
47
70
  const stamp = new Date(now).toISOString().replace(/[:.]/g, '-').slice(0, 19)
48
71
  return `${tmp}/claude-handoff-${stamp}.md`
49
72
  }
50
73
 
51
- /** Runs /clear once the session is idle, after the command that asked for it has answered. */
74
+ /**
75
+ * Runs /clear, after the command that asked for it has answered. Shows a toast when /clear cannot run.
76
+ *
77
+ * @param $ - Engine handle the hook received, see `EngineInterface`.
78
+ */
52
79
  async function clearSession($: EngineInterface): Promise<void> {
53
80
  await $.command.run({ command: 'clear' }).catch(() => $.ui.toast('Run /clear to start the fresh session; the handoff waits for your next prompt.'))
54
81
  }
55
82
 
56
- /** The handoff waiting for the next prompt, dropping one that has gone stale. */
83
+ /**
84
+ * Takes the handoff waiting for the next prompt, and removes it from the store either way.
85
+ *
86
+ * @param $ - Engine handle the hook received, see `EngineInterface`.
87
+ * @returns The handoff, or `undefined` when none waits or it is older than 24 hours.
88
+ */
57
89
  async function takePending($: EngineInterface): Promise<PendingHandoff | undefined> {
58
90
  const stored = await $.store.get(STORE_KEY)
59
91
  if (!isPendingHandoff(stored)) return undefined
@@ -64,6 +96,11 @@ async function takePending($: EngineInterface): Promise<PendingHandoff | undefin
64
96
  return now - stored.createdAt > MAX_AGE_MS ? undefined : stored
65
97
  }
66
98
 
99
+ /**
100
+ * Registers this mod's `handoff-new` command, its session hook, and its prompt hook.
101
+ *
102
+ * @param on - Hook registration function the engine passes in.
103
+ */
67
104
  export const register: Register = on => {
68
105
  on('session.start', async ($, e, next) => {
69
106
  await $.command.register({
@@ -34,21 +34,35 @@ const FIRST_NAMES = new Set(`
34
34
  const FULL_NAME = /\b([A-Z][a-z]+) ((?:van |de |von |da )?[A-Z][a-z]+(?:[-'][A-Z][a-z]+)?)\b/g
35
35
  const TITLED_NAME = /\b(?:Mr|Mrs|Ms|Dr|Prof)\.? [A-Z][a-z]+(?:[-'][A-Z][a-z]+)?\b/g
36
36
 
37
+ /**
38
+ * Counts the digits in a text.
39
+ *
40
+ * @param text - Text to count in, e.g. a phone number.
41
+ * @returns Number of digit characters.
42
+ */
37
43
  function digitCount(text: string): number {
38
44
  return text.replace(/\D/g, '').length
39
45
  }
40
46
 
47
+ /**
48
+ * Escapes the characters that have a meaning in a regular expression.
49
+ *
50
+ * @param text - Literal text to match exactly.
51
+ * @returns The text with each special character escaped.
52
+ */
41
53
  function escapeRegExp(text: string): string {
42
54
  return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
43
55
  }
44
56
 
45
57
  /**
46
- * Finds personal data in a text: emails, phone numbers, IBANs, card numbers
47
- * and names, the latter from `names` and from common first names followed by
48
- * a surname. Overlapping matches keep the earlier, longer one.
58
+ * @brief Finds personal data in a text: emails, phone numbers, IBANs, card numbers and names.
49
59
  *
50
- * @param names - Exact names to mask wherever they occur, e.g. the git user's
51
- * @returns the matches in text order
60
+ * Names come from `names` and from common first names followed by a surname.
61
+ * Overlapping matches keep the earlier, longer one.
62
+ *
63
+ * @param text - Text to search, a line or a whole message.
64
+ * @param names - Exact names to mask wherever they occur, e.g. `["Ada Lovelace"]`.
65
+ * @returns The matches in text order.
52
66
  */
53
67
  export function findPersonalData(text: string, names: readonly string[]): PersonalSpan[] {
54
68
  const spans: PersonalSpan[] = []
@@ -80,12 +94,22 @@ export function findPersonalData(text: string, names: readonly string[]): Person
80
94
  return kept
81
95
  }
82
96
 
83
- /** Inline Markdown a line drawn as plain text would show raw: emphasis marks and heading hashes. */
97
+ /**
98
+ * Strips the Markdown a plain-text line would show raw: heading hashes, bold, underscore and code marks.
99
+ *
100
+ * @param line - One line of a message.
101
+ * @returns The line without those marks.
102
+ */
84
103
  function plain(line: string): string {
85
104
  return line.replace(/^#{1,6}\s+/, '').replace(/(\*\*|__|`)/g, '')
86
105
  }
87
106
 
88
- /** Reads the git user's name and email, the names most likely to show up in replies. */
107
+ /**
108
+ * Reads the git user's name and email from the repository config, the names most likely to show up in replies.
109
+ *
110
+ * @param $ - Engine handle the hook received, see `EngineInterface`.
111
+ * @returns The values that are set, `user.name` first, or `[]` when neither is set.
112
+ */
89
113
  async function gitIdentity($: EngineInterface): Promise<string[]> {
90
114
  const found: string[] = []
91
115
  for (const key of ['user.name', 'user.email']) {
@@ -96,6 +120,12 @@ async function gitIdentity($: EngineInterface): Promise<string[]> {
96
120
  return found
97
121
  }
98
122
 
123
+ /**
124
+ * Registers this mod's `hide-personal-data` command, its session hook, and its assistant message hook.
125
+ *
126
+ * @param on - Hook registration function the engine passes in.
127
+ * @param options - Plugin options; `names` is a comma-separated list of names to always mask.
128
+ */
99
129
  export const register: Register = (on, options) => {
100
130
  const configured = typeof options.names === 'string'
101
131
  ? options.names.split(',').map(name => name.trim()).filter(name => name.length > 0)
@@ -128,6 +158,9 @@ export const register: Register = (on, options) => {
128
158
  const { Box, Text, Markdown } = $.ui.resolve(e)
129
159
  const blocks: RenderChildren[] = []
130
160
  let prose: string[] = []
161
+ /**
162
+ * Emits the prose lines collected so far as one Markdown block, then clears them.
163
+ */
131
164
  const flush = () => {
132
165
  if (prose.length > 0) blocks.push(<Markdown text={prose.join('\n')} />)
133
166
  prose = []
@@ -16,7 +16,13 @@ const BEST_KEY = 'best-seconds'
16
16
  const HINT_AFTER_MS = 60_000
17
17
  const NUMBER_COLORS = [undefined, '#6b8fd6', '#7cc47f', '#e06c6c', '#9b87f5', '#e0775a', '#5fb3c4', '#d0d0d0', '#8a8f98']
18
18
 
19
- /** Cells around `cell` on a `size` × `size` board. */
19
+ /**
20
+ * Lists the cells around `cell` on a `size` × `size` board.
21
+ *
22
+ * @param cell - Cell number, counted row by row from `0`.
23
+ * @param size - Board width and height in cells.
24
+ * @returns The neighboring cell numbers, from one to eight of them.
25
+ */
20
26
  export function neighbors(cell: number, size: number): number[] {
21
27
  const row = Math.floor(cell / size)
22
28
  const col = cell % size
@@ -31,11 +37,24 @@ export function neighbors(cell: number, size: number): number[] {
31
37
  return found
32
38
  }
33
39
 
40
+ /**
41
+ * Counts the mines in the cells around `cell`.
42
+ *
43
+ * @param board - Board to read, see `Game`.
44
+ * @param cell - Cell number to count around.
45
+ * @returns Number of neighboring mines, from `0` to `8`.
46
+ */
34
47
  function adjacentMines(board: Game, cell: number): number {
35
48
  return neighbors(cell, board.size).filter(n => board.mines.includes(n)).length
36
49
  }
37
50
 
38
- /** Places mines anywhere but `safe` and the cells around it. */
51
+ /**
52
+ * Picks random cells for the mines, leaving `safe` and the cells around it free.
53
+ *
54
+ * @param board - Board to place mines on, see `Game`.
55
+ * @param safe - Cell the first reveal hit, which must not be a mine.
56
+ * @returns Mine cell numbers, `board.mineCount` of them.
57
+ */
39
58
  function placeMines(board: Game, safe: number): number[] {
40
59
  const banned = new Set([safe, ...neighbors(safe, board.size)])
41
60
  const free = Array.from({ length: board.size * board.size }, (_, i) => i).filter(i => !banned.has(i))
@@ -47,10 +66,13 @@ function placeMines(board: Game, safe: number): number[] {
47
66
  }
48
67
 
49
68
  /**
50
- * Reveals one cell: the first reveal places the mines, a zero opens its
69
+ * @brief Reveals one cell: the first reveal places the mines, a zero opens its
51
70
  * neighbours, a mine ends the game, and the last safe cell wins it.
52
71
  *
53
- * @param now - Milliseconds since the epoch, stamped as the start or end time
72
+ * @param board - Board to reveal on, see `Game`.
73
+ * @param cell - Cell number to reveal.
74
+ * @param now - Milliseconds since the epoch, stamped as the start or end time.
75
+ * @returns The board after the reveal, or the same board when the cell cannot be revealed.
54
76
  */
55
77
  export function reveal(board: Game, cell: number, now: number): Game {
56
78
  if (board.status === 'won' || board.status === 'lost') return board
@@ -74,18 +96,33 @@ export function reveal(board: Game, cell: number, now: number): Game {
74
96
  return isWon ? { ...started, revealed, status: 'won', endedAt: now } : { ...started, revealed }
75
97
  }
76
98
 
99
+ /**
100
+ * Creates a board in the `ready` state, with no mines placed yet.
101
+ *
102
+ * @param now - Milliseconds since the epoch, stamped as the start time.
103
+ * @returns The new board, see `Game`.
104
+ */
77
105
  function newGame(now: number): Game {
78
106
  return { size: SIZE, mineCount: MINES, mines: [], revealed: [], flagged: [], status: 'ready', isFlagMode: false, startedAt: now }
79
107
  }
80
108
 
81
- /** Starts a new board and shows the pane. */
109
+ /**
110
+ * Starts a new board and shows the pane.
111
+ *
112
+ * @param $ - Engine handle the hook received, see `EngineInterface`.
113
+ */
82
114
  async function startGame($: EngineInterface): Promise<void> {
83
115
  const now = await $.clock.now()
84
116
  await update($, game, () => newGame(now))
85
117
  await $.ui.open({ id: PANE, title: TITLE })
86
118
  }
87
119
 
88
- /** Reveals or flags a cell, as the flag mode says, and records a best time on a win. */
120
+ /**
121
+ * Reveals or flags a cell, as the flag mode says, and records a best time on a win.
122
+ *
123
+ * @param $ - Engine handle the hook received, see `EngineInterface`.
124
+ * @param cell - Cell number the player pressed.
125
+ */
89
126
  async function pressCell($: EngineInterface, cell: number): Promise<void> {
90
127
  const now = await $.clock.now()
91
128
  const after = await update($, game, board => {
@@ -106,11 +143,20 @@ async function pressCell($: EngineInterface, cell: number): Promise<void> {
106
143
  }
107
144
  }
108
145
 
146
+ /**
147
+ * Switches the board between revealing and flagging cells.
148
+ *
149
+ * @param $ - Engine handle the hook received, see `EngineInterface`.
150
+ */
109
151
  async function toggleFlagMode($: EngineInterface): Promise<void> {
110
152
  await update($, game, board => (board === null ? board : { ...board, isFlagMode: !board.isFlagMode }))
111
153
  }
112
154
 
113
- /** Redraws the timer once a second while a game is on. */
155
+ /**
156
+ * Redraws the timer once a second while a game is on.
157
+ *
158
+ * @param $ - Engine handle the hook received, see `EngineInterface`.
159
+ */
114
160
  async function tick($: EngineInterface): Promise<void> {
115
161
  if ((await read($, game))?.status === 'playing') $.ui.invalidate('ui.render')
116
162
  }
@@ -118,7 +164,12 @@ async function tick($: EngineInterface): Promise<void> {
118
164
  let hintTurn: string | undefined
119
165
  let isHinted = false
120
166
 
121
- /** Suggests a game once per session, when a turn is still running after a minute. */
167
+ /**
168
+ * Suggests a game once per session, when a turn is still running after a minute.
169
+ *
170
+ * @param $ - Engine handle the hook received, see `EngineInterface`.
171
+ * @param turnId - Turn the hint was scheduled for; the hint is skipped when another turn is running.
172
+ */
122
173
  async function hint($: EngineInterface, turnId: string): Promise<void> {
123
174
  if (isHinted || hintTurn !== turnId) return
124
175
  if ((await $.ui.panes()).some(pane => pane.id === PANE)) return
@@ -126,6 +177,11 @@ async function hint($: EngineInterface, turnId: string): Promise<void> {
126
177
  $.ui.toast('Waiting on Claude? /mini-game opens Minesweeper.')
127
178
  }
128
179
 
180
+ /**
181
+ * Registers this mod's `mini-game` command, its session, turn and pane hooks.
182
+ *
183
+ * @param on - Hook registration function the engine passes in.
184
+ */
129
185
  export const register: Register = on => {
130
186
  on('session.start', async ($, e, next) => {
131
187
  await $.command.register({ name: 'mini-game', description: 'Play Minesweeper while Claude works' })
@@ -166,6 +222,12 @@ export const register: Register = on => {
166
222
  const record = await read($, best)
167
223
  const isOver = board.status === 'won' || board.status === 'lost'
168
224
 
225
+ /**
226
+ * Draws one cell: a number, a flag, a mine, or a button that reveals or flags it.
227
+ *
228
+ * @param cell - Cell number to draw.
229
+ * @returns The cell element.
230
+ */
169
231
  const cellView = (cell: number) => {
170
232
  const isRevealed = board.revealed.includes(cell)
171
233
  const isMine = board.mines.includes(cell)
@@ -31,16 +31,23 @@ const TOOL_COLORS: ReadonlyArray<[RegExp, string]> = [
31
31
  /** Input fields that say most about a call, tried in order. */
32
32
  const LABEL_FIELDS = ['command', 'file_path', 'notebook_path', 'pattern', 'url', 'query', 'description', 'skill', 'prompt'] as const
33
33
 
34
+ /** Finds the badge color of a tool, or the dim color for a tool outside every family. */
34
35
  function colorOf(tool: string): string {
35
36
  return TOOL_COLORS.find(([match]) => match.test(tool))?.[1] ?? DIM
36
37
  }
37
38
 
39
+ /** Picks the one-character badge a tool shows: `$` for shell tools, else its first letter. */
38
40
  function badgeOf(tool: string): string {
39
41
  if (tool === 'Bash' || tool === 'PowerShell') return '$'
40
42
  return tool.replace(/^mcp__[^_]+__/, '').charAt(0).toUpperCase()
41
43
  }
42
44
 
43
- /** Short form of a call's input: a command, a file's name, a pattern, or the first text field. */
45
+ /**
46
+ * @brief Shortens a call's input to the text the pane shows: a command, a file's name, a pattern, or the first text field.
47
+ *
48
+ * @param input - Arguments of the tool call.
49
+ * @returns The label, with whitespace collapsed to single spaces.
50
+ */
44
51
  export function labelOf(input: Record<string, unknown>): string {
45
52
  for (const field of LABEL_FIELDS) {
46
53
  const value = input[field]
@@ -52,7 +59,12 @@ export function labelOf(input: Record<string, unknown>): string {
52
59
  return first?.replace(/\s+/g, ' ').trim() ?? ''
53
60
  }
54
61
 
55
- /** Text of a tool result's content, which is a string or a list of text blocks. */
62
+ /**
63
+ * @brief Joins the text of a tool result's content, which is a string or a list of text blocks.
64
+ *
65
+ * @param content - Result content: a string, or a list of blocks with a `text` field.
66
+ * @returns The text, one block per line.
67
+ */
56
68
  function resultText(content: unknown): string {
57
69
  if (typeof content === 'string') return content
58
70
  if (!Array.isArray(content)) return ''
@@ -61,16 +73,17 @@ function resultText(content: unknown): string {
61
73
  .join('\n')
62
74
  }
63
75
 
64
- /** Whether an error text reads as a refusal: a hook's deny, the permission dialog's no, a blocked call. */
76
+ /** Checks whether an error text reads as a refusal: a hook's deny, the permission dialog's no, a blocked call. */
65
77
  function isRefusal(text: string): boolean {
66
78
  return /\bdenied\b|\bblocked\b|doesn't want to proceed|was rejected|permission/i.test(text)
67
79
  }
68
80
 
81
+ /** Formats a duration in milliseconds as seconds, with one decimal below ten seconds. */
69
82
  function seconds(ms: number): string {
70
83
  return ms >= 10_000 ? `${Math.round(ms / 1000)}s` : `${(ms / 1000).toFixed(1)}s`
71
84
  }
72
85
 
73
- /** Short note for the callout, e.g. `use the trash` for delete-guard's deny. */
86
+ /** Shortens a call's note for the callout, e.g. `use the trash` for delete-guard's deny. */
74
87
  function shortNote(note: string | undefined): string {
75
88
  if (note === undefined) return ''
76
89
  if (/trash/i.test(note)) return 'use the trash'
@@ -78,7 +91,13 @@ function shortNote(note: string | undefined): string {
78
91
  return text.length > 40 ? `${text.slice(0, 39)}…` : text
79
92
  }
80
93
 
81
- /** Adds a call or changes the one with its id; the list stays in start order and capped. */
94
+ /**
95
+ * @brief Adds a call or changes the one with its id; the list stays in start order and capped.
96
+ *
97
+ * @param $ - Engine handle the calls state is updated through.
98
+ * @param id - Tool use id of the call.
99
+ * @param patch - Builds the call's next value from its stored value, or from `undefined` when the call is new.
100
+ */
82
101
  async function patchCall($: EngineInterface, id: string, patch: (call: TimelineCall | undefined) => TimelineCall): Promise<void> {
83
102
  await update($, calls, list => {
84
103
  const index = list.findIndex(call => call.id === id)
@@ -87,7 +106,14 @@ async function patchCall($: EngineInterface, id: string, patch: (call: TimelineC
87
106
  })
88
107
  }
89
108
 
90
- /** Ends a call with its status, keeping the first ending it got. */
109
+ /**
110
+ * @brief Ends a call with its status, keeping the first ending it got.
111
+ *
112
+ * @param $ - Engine handle the calls state is updated through.
113
+ * @param id - Tool use id of the call.
114
+ * @param status - Status the call ends with.
115
+ * @param note - Text shown with the status, such as a refusal reason.
116
+ */
91
117
  async function settleCall($: EngineInterface, id: string, status: CallStatus, note?: string): Promise<void> {
92
118
  const endedAt = await $.clock.now()
93
119
  await patchCall($, id, call =>
@@ -97,23 +123,46 @@ async function settleCall($: EngineInterface, id: string, status: CallStatus, no
97
123
  )
98
124
  }
99
125
 
100
- /** Redraws the pane while a call runs, so its bar and time move. */
126
+ /**
127
+ * @brief Redraws the pane while a call runs, so its bar and time move.
128
+ *
129
+ * @param $ - Engine handle the pane is redrawn through.
130
+ */
101
131
  async function tick($: EngineInterface): Promise<void> {
102
132
  const list = await read($, calls)
103
133
  if (list.some(call => call.status === 'running')) $.ui.invalidate('ui.render')
104
134
  }
105
135
 
106
- /** The call a denied call was replaced by: the next call of the same tool. */
136
+ /**
137
+ * @brief Finds the call that replaced a denied call: the next call of the same tool.
138
+ *
139
+ * @param list - Calls in start order.
140
+ * @param index - Position of the denied call in `list`.
141
+ * @returns The replacing call, or `undefined` when none follows.
142
+ */
107
143
  function replacementOf(list: readonly TimelineCall[], index: number): TimelineCall | undefined {
108
144
  const denied = list[index]
109
145
  return denied === undefined ? undefined : list.slice(index + 1).find(call => call.tool === denied.tool)
110
146
  }
111
147
 
148
+ /**
149
+ * @brief Builds the SVG of a track with a bar filled to `fill` pixels.
150
+ *
151
+ * @param width - Width of the track in pixels.
152
+ * @param fill - Filled length in pixels, clamped to the width.
153
+ * @param color - Fill color.
154
+ * @returns The SVG markup.
155
+ */
112
156
  function barSvg(width: number, fill: number, color: string): string {
113
157
  const w = Math.max(0, Math.min(width, fill))
114
158
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="8" viewBox="0 0 ${width} 8"><rect width="${width}" height="8" rx="2" fill="${TRACK}"/><rect width="${w.toFixed(1)}" height="8" rx="2" fill="${color}"/></svg>`
115
159
  }
116
160
 
161
+ /**
162
+ * @brief Subscribes the timeline's handlers to the engine's events, its command and its pane.
163
+ *
164
+ * @param on - Subscribes a handler to one engine event, optionally filtered.
165
+ */
117
166
  export const register: Register = on => {
118
167
  on('session.start', async ($, e, next) => {
119
168
  await $.command.register({ name: 'tool-timeline', description: 'Show or hide the tool call timeline' })
@@ -185,12 +234,14 @@ export const register: Register = on => {
185
234
  const now = await $.clock.now()
186
235
  const room = Math.max(4, (e.viewport?.rows ?? 30) - 6)
187
236
  const shown = list.slice(-room)
237
+ /** Length of a call so far, or up to its end when it has one. */
188
238
  const durationOf = (call: TimelineCall): number => (call.endedAt ?? now) - call.startedAt
189
239
  const longest = Math.max(1, ...shown.map(durationOf))
190
240
  const total = list.reduce((sum, call) => sum + (call.status === 'denied' ? 0 : durationOf(call)), 0)
191
241
  const barCells = Math.max(8, Math.floor(e.props.bodyColumns * 0.3))
192
242
  const labelCells = Math.max(12, e.props.bodyColumns - barCells - 12)
193
243
 
244
+ /** Draws the bar showing a call's share of the longest call's time. */
194
245
  const bar = (call: TimelineCall) => {
195
246
  const color = call.status === 'error' ? ERROR : call.status === 'ok' && /^Bash$/.test(call.tool) ? '#5fae6e' : colorOf(call.tool)
196
247
  const share = call.status === 'denied' ? 0.04 : Math.max(0.03, durationOf(call) / longest)
@@ -208,6 +259,7 @@ export const register: Register = on => {
208
259
  return <Svg source={barSvg(width, share * width, color)} alt={`${call.tool} ${seconds(durationOf(call))}`} width={width} height={8} />
209
260
  }
210
261
 
262
+ /** Renders a call's status at the end of its row: its time, `denied`, `queued` or a mark for an error. */
211
263
  const outcome = (call: TimelineCall) => {
212
264
  switch (call.status) {
213
265
  case 'denied': return <Text color={ERROR}>denied</Text>
@@ -23,11 +23,17 @@ const SYSTEM = [
23
23
 
24
24
  const BULB_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40"><circle cx="20" cy="20" r="20" fill="#3d3424"/><path d="M20 11a6 6 0 0 0-3.6 10.8c.8.6 1.1 1.3 1.1 2.2v1h5v-1c0-.9.3-1.6 1.1-2.2A6 6 0 0 0 20 11z" fill="none" stroke="#e5b567" stroke-width="1.6"/><path d="M17.5 28h5M18.3 30.5h3.4" stroke="#e5b567" stroke-width="1.6" stroke-linecap="round"/></svg>'
25
25
 
26
+ /** Collapses every run of whitespace to one space and trims both ends. */
26
27
  function normalize(text: string): string {
27
28
  return text.replace(/\s+/g, ' ').trim()
28
29
  }
29
30
 
30
- /** Reads the model's answer: a card's headline and detail, or `undefined` for a skip or anything malformed. */
31
+ /**
32
+ * @brief Reads the model's answer into a card's headline and detail.
33
+ *
34
+ * @param text - Raw answer from the model.
35
+ * @returns The headline and detail, or `undefined` for a skip or anything malformed.
36
+ */
31
37
  export function parseCard(text: string): Omit<Card, 'tail'> | undefined {
32
38
  const json = /\{[\s\S]*\}/.exec(text)?.[0]
33
39
  if (json === undefined) return undefined
@@ -43,7 +49,13 @@ export function parseCard(text: string): Omit<Card, 'tail'> | undefined {
43
49
  return { headline: headline.trim().slice(0, 90), detail: detail.trim().slice(0, 160) }
44
50
  }
45
51
 
46
- /** Asks the model for the reply's key point and keeps the card when there is one. */
52
+ /**
53
+ * @brief Asks the model for the reply's key point and keeps the card when there is one.
54
+ *
55
+ * @param $ - Engine handle the model call and the cards state go through.
56
+ * @param answer - Full text of the assistant's reply.
57
+ * @param model - Model that reads the reply.
58
+ */
47
59
  async function extract($: EngineInterface, answer: string, model: string): Promise<void> {
48
60
  const result = await $.model.complete({ model, system: SYSTEM, prompt: answer.slice(-MAX_PROMPT_CHARS), maxTokens: 300 })
49
61
  if (!result.isAnswered) return
@@ -53,6 +65,12 @@ async function extract($: EngineInterface, answer: string, model: string): Promi
53
65
  await update($, cards, list => [...list.filter(one => one.tail !== tail), { tail, ...card }].slice(-KEEP))
54
66
  }
55
67
 
68
+ /**
69
+ * @brief Subscribes the card handlers to the engine's events and renders the card.
70
+ *
71
+ * @param on - Subscribes a handler to one engine event.
72
+ * @param options - Plugin options: `minChars` sets the shortest reply read, `model` the model that reads it.
73
+ */
56
74
  export const register: Register = (on, options) => {
57
75
  const minChars = Number(options.minChars ?? DEFAULT_MIN_CHARS)
58
76
  const model = typeof options.model === 'string' && options.model.length > 0 ? options.model : DEFAULT_MODEL
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@g3nomaly/g3n-mods",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "The g3n Claude Code mods: panes, status lines, commands and hooks, installed with @g3nomaly/mods.",
5
5
  "type": "module",
6
6
  "files": [