@g3nomaly/g3n-mods 0.1.0 → 0.1.2
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/README.md +76 -0
- package/mods/agents-panel/hooks/register.tsx +59 -6
- package/mods/context-bar/hooks/register.tsx +34 -6
- package/mods/custom-crab-agents/hooks/register.tsx +149 -10
- package/mods/delete-guard/hooks/register.ts +42 -8
- package/mods/focus-mode/hooks/register.tsx +42 -5
- package/mods/handoff/hooks/register.tsx +41 -4
- package/mods/hide-personal-data/hooks/register.tsx +40 -7
- package/mods/mini-game/hooks/register.tsx +70 -8
- package/mods/tool-timeline/hooks/register.tsx +60 -8
- package/mods/you-should-know/hooks/register.tsx +20 -2
- package/package.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# @g3nomaly/g3n-mods
|
|
2
|
+
|
|
3
|
+
The g3n mods for Claude Code. A mod adds a pane, a status line, a slash command or a hook to Claude Code.
|
|
4
|
+
|
|
5
|
+
This package holds the mods themselves. You don't install it directly. The installer, [`@g3nomaly/mods`](https://www.npmjs.com/package/@g3nomaly/mods), knows it as the built-in `g3n-mods` source and copies mods out of it into your project.
|
|
6
|
+
|
|
7
|
+
## How to install
|
|
8
|
+
|
|
9
|
+
Run these from the root of your project:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
bunx @g3nomaly/mods init # creates .claude/mods.json and sets up VS Code
|
|
13
|
+
bunx @g3nomaly/mods add context-bar # installs one mod
|
|
14
|
+
bunx @g3nomaly/mods add g3n-mods # installs every mod in this package
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Then open the project in VS Code and trust the workspace. New sessions load the mods, and `/reload-plugins` loads them into a session that is already open. Updating, removing and installing for every project with `--user` are covered in the [`@g3nomaly/mods` README](https://www.npmjs.com/package/@g3nomaly/mods).
|
|
18
|
+
|
|
19
|
+
## The mods
|
|
20
|
+
|
|
21
|
+
| Mod | What it does | Command |
|
|
22
|
+
|---|---|---|
|
|
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
|
+
| `context-bar` | One bar of context-window use split by source, with the total and where compaction starts | `/context-bar` |
|
|
25
|
+
| `custom-crab-agents` | Each subagent as a crab with a hat: effort tier, model, steps, context, tokens, cost and time | `/crabs` |
|
|
26
|
+
| `delete-guard` | Denies `rm -rf` and other permanent deletes and tells Claude which trash command to use instead | `/delete-guard on \| off` |
|
|
27
|
+
| `focus-mode` | A timed focus block with a countdown, a progress bar and an end button. 25 minutes unless you pass a number | `/focus-mode [minutes] \| end` |
|
|
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
|
+
| `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
|
+
| `mini-game` | Minesweeper, to play while Claude works | `/mini-game` |
|
|
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
|
+
| `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
|
+
|
|
34
|
+
For `agents-panel`, `context-bar`, `custom-crab-agents` and `tool-timeline`, the command opens the pane and running it again closes it.
|
|
35
|
+
|
|
36
|
+
Three mods take settings, declared under `userConfig` in their `.claude-plugin/plugin.json`:
|
|
37
|
+
|
|
38
|
+
- `focus-mode`: `minutes`, the default block length (25).
|
|
39
|
+
- `hide-personal-data`: `names`, extra names to always mask, separated by commas.
|
|
40
|
+
- `you-should-know`: `minChars`, the shortest reply that gets a card (1500), and `model`, the model that picks the key point (`haiku`).
|
|
41
|
+
|
|
42
|
+
## Requirements
|
|
43
|
+
|
|
44
|
+
- [Bun](https://bun.sh), to run the installer
|
|
45
|
+
- Claude Code for VS Code (`anthropic.claude-code`) 2.1.296 or newer. The mods are typed against that release.
|
|
46
|
+
|
|
47
|
+
The mods also load in the terminal, but they are built for the VS Code extension.
|
|
48
|
+
|
|
49
|
+
## Making your own mod
|
|
50
|
+
|
|
51
|
+
Each mod here is a plugin folder under `mods/`, and `.claude-plugin/marketplace.json` lists them:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
mods/<name>/
|
|
55
|
+
├── .claude-plugin/plugin.json # name, version, description
|
|
56
|
+
├── hooks/hooks.json # { "modules": ["./register.tsx"] }
|
|
57
|
+
├── hooks/register.tsx # exports `register`
|
|
58
|
+
└── types/index.d.ts
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The folder name must match `name` in `plugin.json`. Mods are TypeScript only, and the installer refuses a mod that contains any `.js`, `.mjs`, `.cjs` or `.jsx` file. To share your own mods, put them in a git repository, npm package or local folder with the same layout and register it with `bunx @g3nomaly/mods source add`.
|
|
62
|
+
|
|
63
|
+
## Development
|
|
64
|
+
|
|
65
|
+
Run `bun install` at the repository root first, then these from `packages/g3n-mods`:
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
bun run build # rewrites .claude-plugin/marketplace.json from each mod's plugin.json
|
|
69
|
+
bun run typecheck
|
|
70
|
+
bun run validate # runs `claude plugin validate` on every mod
|
|
71
|
+
bun run types # copies the mod API types into types/claude-code.d.ts
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## License
|
|
75
|
+
|
|
76
|
+
[Elastic License 2.0](https://www.elastic.co/licensing/elastic-license). See `LICENSE`.
|
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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.
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
45
|
-
*
|
|
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
|
-
* @
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
51
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|