@cmodjs/core 3.1.0 → 4.0.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.
Files changed (200) hide show
  1. package/docs/commands.md +38 -21
  2. package/docs/dependencies.md +27 -20
  3. package/docs/index.md +21 -17
  4. package/docs/install-steps.md +37 -29
  5. package/docs/jobs.md +25 -52
  6. package/docs/mod.md +93 -65
  7. package/docs/options.md +2 -2
  8. package/docs/permissions.md +4 -4
  9. package/docs/session.md +14 -12
  10. package/docs/testing.md +55 -32
  11. package/docs/ui.md +255 -263
  12. package/hotkeys.d.ts +22 -0
  13. package/hotkeys.d.ts.map +1 -0
  14. package/hotkeys.js +26 -0
  15. package/installer.d.ts +1 -1
  16. package/installer.d.ts.map +1 -1
  17. package/installer.js +11 -6
  18. package/jobs/check.d.ts +2 -1
  19. package/jobs/check.d.ts.map +1 -1
  20. package/jobs/check.js +2 -2
  21. package/jobs/context.d.ts +2 -1
  22. package/jobs/context.d.ts.map +1 -1
  23. package/jobs/context.js +1 -1
  24. package/jobs/permissions.d.ts +2 -1
  25. package/jobs/permissions.d.ts.map +1 -1
  26. package/jobs/permissions.js +2 -2
  27. package/jobs/program.js +1 -1
  28. package/jobs/prompt.d.ts +2 -1
  29. package/jobs/prompt.d.ts.map +1 -1
  30. package/jobs/prompt.js +20 -13
  31. package/jobs/schedule.d.ts +2 -1
  32. package/jobs/schedule.d.ts.map +1 -1
  33. package/jobs/schedule.js +15 -1
  34. package/jobs/slash-command.d.ts +6 -2
  35. package/jobs/slash-command.d.ts.map +1 -1
  36. package/jobs/slash-command.js +6 -5
  37. package/jobs/tool.d.ts +6 -4
  38. package/jobs/tool.d.ts.map +1 -1
  39. package/jobs/tool.js +3 -3
  40. package/mod.d.ts +63 -42
  41. package/mod.d.ts.map +1 -1
  42. package/mod.js +11 -1
  43. package/package.json +1 -1
  44. package/records.d.ts +13 -8
  45. package/records.d.ts.map +1 -1
  46. package/records.js +41 -31
  47. package/register.d.ts.map +1 -1
  48. package/register.js +1 -2
  49. package/runtime/announce.d.ts +1 -1
  50. package/runtime/announce.d.ts.map +1 -1
  51. package/runtime/announce.js +6 -7
  52. package/runtime/before.d.ts +2 -2
  53. package/runtime/before.d.ts.map +1 -1
  54. package/runtime/before.js +1 -1
  55. package/runtime/claude.d.ts +0 -1
  56. package/runtime/claude.d.ts.map +1 -1
  57. package/runtime/conversations.d.ts +4 -1
  58. package/runtime/conversations.d.ts.map +1 -1
  59. package/runtime/conversations.js +42 -14
  60. package/runtime/dependencies.d.ts.map +1 -1
  61. package/runtime/dependencies.js +2 -1
  62. package/runtime/engine.d.ts +5 -0
  63. package/runtime/engine.d.ts.map +1 -1
  64. package/runtime/grants.js +2 -2
  65. package/runtime/hooks.d.ts +1 -1
  66. package/runtime/hooks.d.ts.map +1 -1
  67. package/runtime/hotkeys.d.ts +15 -0
  68. package/runtime/hotkeys.d.ts.map +1 -0
  69. package/runtime/hotkeys.js +58 -0
  70. package/runtime/installer.d.ts +5 -2
  71. package/runtime/installer.d.ts.map +1 -1
  72. package/runtime/installer.js +23 -12
  73. package/runtime/lifecycle.d.ts.map +1 -1
  74. package/runtime/lifecycle.js +190 -141
  75. package/runtime/options.js +2 -2
  76. package/runtime/pages.d.ts +28 -0
  77. package/runtime/pages.d.ts.map +1 -0
  78. package/runtime/pages.js +98 -0
  79. package/runtime/parts.d.ts +14 -0
  80. package/runtime/parts.d.ts.map +1 -0
  81. package/runtime/parts.js +59 -0
  82. package/runtime/permission-check.d.ts +2 -2
  83. package/runtime/permission-check.d.ts.map +1 -1
  84. package/runtime/programs.js +1 -1
  85. package/runtime/router.d.ts +2 -2
  86. package/runtime/router.d.ts.map +1 -1
  87. package/runtime/router.js +1 -1
  88. package/runtime/sessions.d.ts +10 -9
  89. package/runtime/sessions.d.ts.map +1 -1
  90. package/runtime/sessions.js +14 -5
  91. package/runtime/state.d.ts +2 -1
  92. package/runtime/state.d.ts.map +1 -1
  93. package/runtime/state.js +1 -0
  94. package/runtime/toaster.d.ts +1 -1
  95. package/runtime/toaster.d.ts.map +1 -1
  96. package/runtime/toaster.js +10 -4
  97. package/runtime/ui.d.ts +87 -5
  98. package/runtime/ui.d.ts.map +1 -1
  99. package/runtime/ui.js +374 -109
  100. package/runtime/watches.d.ts +9 -0
  101. package/runtime/watches.d.ts.map +1 -0
  102. package/runtime/watches.js +73 -0
  103. package/runtime/widgets.d.ts +48 -0
  104. package/runtime/widgets.d.ts.map +1 -0
  105. package/runtime/widgets.js +219 -0
  106. package/session/context/turn/message/message.d.ts.map +1 -1
  107. package/session/context/turn/message/message.js +7 -2
  108. package/session/session.d.ts +11 -5
  109. package/session/session.d.ts.map +1 -1
  110. package/src/hotkeys.ts +51 -0
  111. package/src/installer.ts +8 -4
  112. package/src/jobs/check.ts +4 -3
  113. package/src/jobs/context.ts +3 -2
  114. package/src/jobs/permissions.ts +4 -3
  115. package/src/jobs/program.ts +1 -1
  116. package/src/jobs/prompt.ts +23 -14
  117. package/src/jobs/schedule.ts +16 -2
  118. package/src/jobs/slash-command.ts +10 -7
  119. package/src/jobs/tool.ts +7 -5
  120. package/src/mod.ts +59 -47
  121. package/src/records.ts +46 -31
  122. package/src/register.ts +2 -3
  123. package/src/runtime/announce.ts +6 -7
  124. package/src/runtime/before.ts +4 -4
  125. package/src/runtime/claude.ts +0 -1
  126. package/src/runtime/conversations.ts +66 -20
  127. package/src/runtime/dependencies.ts +2 -1
  128. package/src/runtime/engine.ts +5 -0
  129. package/src/runtime/grants.ts +2 -2
  130. package/src/runtime/hooks.ts +1 -0
  131. package/src/runtime/hotkeys.ts +80 -0
  132. package/src/runtime/installer.ts +24 -12
  133. package/src/runtime/lifecycle.ts +189 -136
  134. package/src/runtime/options.ts +2 -2
  135. package/src/runtime/pages.ts +127 -0
  136. package/src/runtime/parts.ts +70 -0
  137. package/src/runtime/permission-check.ts +2 -2
  138. package/src/runtime/programs.ts +1 -1
  139. package/src/runtime/router.ts +3 -3
  140. package/src/runtime/sessions.ts +24 -15
  141. package/src/runtime/state.ts +3 -1
  142. package/src/runtime/toaster.ts +10 -4
  143. package/src/runtime/ui.ts +407 -115
  144. package/src/runtime/watches.ts +75 -0
  145. package/src/runtime/widgets.ts +287 -0
  146. package/src/session/context/turn/message/message.ts +7 -2
  147. package/src/session/session.ts +10 -6
  148. package/src/testing/fake-claude.ts +9 -6
  149. package/src/testing/fake-elements.ts +7 -4
  150. package/src/testing/fake-sessions.ts +6 -4
  151. package/src/testing.ts +203 -46
  152. package/src/ui/areas.ts +27 -0
  153. package/src/ui/elements.ts +35 -0
  154. package/src/ui/markdown.ts +18 -18
  155. package/src/ui/panes.ts +73 -0
  156. package/src/ui/styles.ts +53 -0
  157. package/src/utils/subscription.ts +1 -0
  158. package/testing/fake-claude.d.ts +0 -1
  159. package/testing/fake-claude.d.ts.map +1 -1
  160. package/testing/fake-claude.js +9 -5
  161. package/testing/fake-elements.js +7 -4
  162. package/testing/fake-sessions.d.ts +2 -1
  163. package/testing/fake-sessions.d.ts.map +1 -1
  164. package/testing/fake-sessions.js +1 -0
  165. package/testing.d.ts +21 -6
  166. package/testing.d.ts.map +1 -1
  167. package/testing.js +206 -45
  168. package/ui/areas.d.ts +29 -0
  169. package/ui/areas.d.ts.map +1 -0
  170. package/ui/areas.js +6 -0
  171. package/ui/elements.d.ts +3 -0
  172. package/ui/elements.d.ts.map +1 -1
  173. package/ui/elements.js +36 -0
  174. package/ui/markdown.d.ts +14 -14
  175. package/ui/markdown.d.ts.map +1 -1
  176. package/ui/markdown.js +11 -11
  177. package/ui/panes.d.ts +71 -0
  178. package/ui/panes.d.ts.map +1 -0
  179. package/ui/panes.js +16 -0
  180. package/ui/styles.d.ts +44 -0
  181. package/ui/styles.d.ts.map +1 -0
  182. package/ui/styles.js +24 -0
  183. package/utils/subscription.d.ts +4 -0
  184. package/utils/subscription.d.ts.map +1 -0
  185. package/jobs/status-line.d.ts +0 -6
  186. package/jobs/status-line.d.ts.map +0 -1
  187. package/jobs/status-line.js +0 -39
  188. package/src/jobs/status-line.ts +0 -44
  189. package/src/ui/define-pane.ts +0 -27
  190. package/src/ui/places.ts +0 -49
  191. package/src/utils/disposable.ts +0 -3
  192. package/ui/define-pane.d.ts +0 -15
  193. package/ui/define-pane.d.ts.map +0 -1
  194. package/ui/define-pane.js +0 -12
  195. package/ui/places.d.ts +0 -43
  196. package/ui/places.d.ts.map +0 -1
  197. package/ui/places.js +0 -31
  198. package/utils/disposable.d.ts +0 -4
  199. package/utils/disposable.d.ts.map +0 -1
  200. /package/utils/{disposable.js → subscription.js} +0 -0
package/docs/jobs.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Jobs
2
2
 
3
- A job is a ready-made feature of a mod: a slash command, a tool, permission rules, a check, a prompt, a status line, or a background program. Add each in `setup` with `mod.use`.
3
+ A job is a ready-made feature of a mod: a slash command, a tool, permission rules, a check, a prompt, a schedule, or a background program. Add each in `setup` with `mod.use`.
4
4
 
5
5
  ```ts
6
6
  mod.use<Handle>(job: Job<Handle, State>): Handle
@@ -10,7 +10,7 @@ type Job<Handle, State> = (context: JobContext<State>) => Handle
10
10
 
11
11
  Each job function, such as `slashCommand(options)`, returns a `Job`. `mod.use` runs the job and returns its handle. A job that has nothing to hand back returns `void`.
12
12
 
13
- A job's bad options throw at one of two times. `check`, `permissions`, `prompt`, and `schedule` check their options when the job function is called. `slashCommand`, `tool`, `statusLine`, and `program` check theirs when the job runs, at `mod.use`, so the error surfaces as `its setup function threw: …`.
13
+ A job's bad options throw at one of two times. `check`, `permissions`, `prompt`, and `schedule` check their options when the job function is called. `slashCommand`, `tool`, and `program` check theirs when the job runs, at `mod.use`, so the error surfaces as `its setup function threw: …`.
14
14
 
15
15
  | Job | Import | Adds |
16
16
  | --- | --- | --- |
@@ -20,7 +20,6 @@ A job's bad options throw at one of two times. `check`, `permissions`, `prompt`,
20
20
  | `check` | `jobs/check.js` | A command that runs after matching calls, and reports a failure to Claude |
21
21
  | `prompt` | `jobs/prompt.js` | Text Claude reads: kept current with the mod's state, before matching prompts, or after matching calls |
22
22
  | `schedule` | `jobs/schedule.js` | A prompt Claude Code sends Claude on a cron schedule, kept current with the mod's state |
23
- | `statusLine` | `jobs/status-line.js` | A status line |
24
23
  | `program` | `jobs/program.js` | A background program the mod talks to over HTTP |
25
24
 
26
25
  Each job announces itself. The first time a version starts, Claude Mod Manager (cmod) logs what the mod added, such as `tasks added /tasks, the open_ticket tool and 2 permission rules.`
@@ -34,7 +33,6 @@ A job gives each function it calls a deadline. The whole function must answer wi
34
33
  | `slashCommand`'s `reply` | 30 s | The command shows `/<name> failed: /<name> passed the 30 s deadline of slashCommand`. |
35
34
  | `tool`'s `execute` | 30 s | The call is denied with `The <name> tool failed: the <name> tool passed the 30 s deadline of tool`. |
36
35
  | `prompt`'s `prompt` | 5 s | The prompt adds nothing, and the reason goes to Claude Code's debug log. |
37
- | `statusLine`'s `text` | 2 s | The status line keeps its last text, and the reason goes to Claude Code's debug log. |
38
36
  | `permissions`' `when` | 2 s | The rule matches, and its reason adds `Its when check failed: it passed the 2 s deadline of permissions`. |
39
37
  | `check`'s `run` | `timeoutMs`, 60 s by default, 10 minutes at most | The call's result says the command passed the deadline. |
40
38
 
@@ -51,21 +49,19 @@ slashCommand<State>(options: {
51
49
  readonly argumentHint?: string
52
50
  readonly immediate?: true
53
51
  readonly reply: (input: { args: string; positionals: string[] }, mod: Mod<State>) => CommandReply | void | Promise<CommandReply | void>
54
- }): Job<Disposable, State>
52
+ }): Job<SlashCommand, State>
55
53
 
56
54
  type CommandReply = string | { text?: string; context?: string } | undefined
55
+ type SlashCommand = { readonly name: string; remove(): void }
57
56
  ```
58
57
 
59
58
  - `name` is the command without the slash: 1 to 64 letters, digits, `_`, or `-`. The person types `/<name>`. Another name throws `<mod>: "<name>" is not a slash command name.`
60
59
  - `description` is the line the typeahead and `/help` show.
61
60
  - `argumentHint` is drawn dim after the name, such as `[path]`.
62
61
  - `immediate: true` runs the command at once while Claude is still working. Without it, the command waits for the turn to end.
63
- - A command lasts for the session. Adding a command with a name the session already has replaces it, and Claude Code has no call that removes one, so a command whose source is gone answers that it no longer applies. `dispose()` on the handle stops the mod answering the command.
64
- - A key the mod binds in its `package.json` `"cmod": { "keys": { "shift+tab": "/mode" } }` runs the command ([install-steps.md](install-steps.md#key-bindings)). The command of a key takes `immediate: true`, so the key works while Claude works, and a `reply` that returns `undefined`, so a press adds no row to the conversation. It changes `mod.state`, and a pane or band that shows the state is how the person sees the press. cmod logs `<mod>: <key> runs /<name>, which waits for Claude's turn to end and adds a row to the conversation. Give /<name> immediate: true.` for a key whose command lacks it.
65
-
66
- ```ts
67
- mod.use(slashCommand({ name: 'mode', description: 'Switch to the next mode', immediate: true, reply: (_input, mod) => void (mod.state.session.mode = next(mod.state.session.mode)) }))
68
- ```
62
+ - A command lasts for the session. Adding a command with a name the session already has replaces it, and Claude Code has no call that removes one, so a command whose source is gone answers that it no longer applies. `remove()` on the handle stops the mod answering the command.
63
+ - A command's `reply` runs inside the person's own input, so a page or pane it opens shows at any width ([ui.md](ui.md#the-sidebar-and-its-pages)).
64
+ - A key combo is a hotkey, not a command ([mod.md](mod.md#hotkeys)).
69
65
  - `reply` gets `args` and `positionals`. `args` is everything after the name as Claude Code passes it, and `''` for a bare `/<name>`. cmod hands it on unchanged. `positionals` is that text split into words the way a shell splits them, so extra spaces never reach it. `tested.type('/todo milk')` drops the spaces after the name.
70
66
  - A `string` reply, or `text`, shows as the command's output. `context` is text Claude reads after the output, and the person never sees. `undefined` shows nothing.
71
67
  - A `reply` that throws shows `/<name> failed: <error>`.
@@ -103,10 +99,12 @@ tool<Schema, State>(options: {
103
99
  readonly description: string
104
100
  readonly inputSchema?: Schema
105
101
  readonly execute: (input: FromSchema<Schema>, mod: Mod<State>) => unknown
106
- }): Job<Disposable & { readonly name: string }, State>
102
+ }): Job<Tool, State>
103
+
104
+ type Tool = { readonly name: string; remove(): void }
107
105
  ```
108
106
 
109
- - `name` is 1 to 64 letters, digits, `_`, or `-`. Claude sees the tool as `mcp__<plugin name>__<name>`, and the handle's `name` is that full name. `dispose()` on the handle stops the mod answering the tool.
107
+ - `name` is 1 to 64 letters, digits, `_`, or `-`. Claude sees the tool as `mcp__<plugin name>__<name>`, and the handle's `name` is that full name. `remove()` on the handle stops the mod answering the tool.
110
108
  - `description` tells Claude what the tool does.
111
109
  - `inputSchema` is a JSON Schema with `type: 'object'`. It types `execute`'s `input`, so write it inline. cmod checks each call's input against it, draft 2020-12, and denies an input that does not fit with the list of its errors. Its properties cannot be named `tool`, `tool_use_id`, `consent`, or `agentId`, which Claude Code keeps for itself.
112
110
  - `execute` returns the result Claude reads. A `string` and an array of content blocks go to Claude as they are. Any other value goes as JSON. `undefined` returns nothing.
@@ -321,35 +319,6 @@ export const loop = defineMod({
321
319
  })
322
320
  ```
323
321
 
324
- ## statusLine
325
-
326
- ```ts
327
- statusLine<State>(options: {
328
- readonly text: (usage: Usage, mod: Mod<State>) => string | undefined | Promise<string | undefined>
329
- readonly interval?: number
330
- }): Job<void, State>
331
-
332
- type Usage = { model: string; context: { tokens?: number; window: number; percent?: number }; cost?: { usd: number } }
333
- ```
334
-
335
- - `text` returns the status line's text, or `undefined` to clear it.
336
- - cmod calls `text` when the mod starts, every `interval` milliseconds (10000 by default), and whenever Claude Code measures the session. It sends the text only when it changed.
337
- - A `text` that throws keeps the last text, and the error goes to Claude Code's debug log.
338
- - A mod has one status line. A second `statusLine` throws: join the texts in one.
339
- - An `interval` below 1 throws.
340
-
341
- ```ts
342
- import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
343
- import { statusLine } from '../node_modules/@cmodjs/core/jobs/status-line.js'
344
-
345
- export const usage = defineMod({
346
- name: 'usage',
347
- setup(mod) {
348
- mod.use(statusLine({ text: ({ model, context }) => `${model} ${context.percent ?? 0}%` }))
349
- },
350
- })
351
- ```
352
-
353
322
  ## program
354
323
 
355
324
  It needs `"run"` with the program's name. Without the grant the program stops at once and names the line to add ([permissions.md](permissions.md)).
@@ -403,29 +372,33 @@ A `Job` is a function. `mod.use` calls it with a `JobContext` and returns what i
403
372
  type JobContext<State> = {
404
373
  readonly mod: Mod<State>
405
374
  announce(feature: string): void
406
- reserveName(type: string, name: string, taken: string): Disposable
375
+ reserveName(type: string, name: string, taken: string): Reservation
407
376
  }
377
+
378
+ type Reservation = { remove(): void }
408
379
  ```
409
380
 
410
- - `mod` is the mod that uses the job. A job builds on `mod.every`, `mod.ui`, and the imports from `mod.js`, such as the methods of `sessions`, as `setup` does ([mod.md](mod.md)).
381
+ - `mod` is the mod that uses the job. A job builds on `mod.every`, the `add` calls of `mod`, and the imports from `mod.js`, such as the methods of `sessions`, as `setup` does ([mod.md](mod.md)).
411
382
  - `announce` adds a phrase to the list cmod logs when the mod first starts.
412
- - `reserveName` throws `taken` when the mod already reserved that `type` and `name`, so a job refuses a duplicate. `dispose()` on what it returns frees the name again.
383
+ - `reserveName` throws `taken` when the mod already reserved that `type` and `name`, so a job refuses a duplicate. `remove()` on what it returns frees the name again.
413
384
 
414
- A job that hands back a feature the mod turns off returns a `Disposable`, as `slashCommand` and `tool` do:
385
+ A job that hands back a feature the mod turns off returns a handle with `remove()`, as `slashCommand` and `tool` do:
415
386
 
416
387
  ```ts
417
- import { defineMod, sessions, type Disposable, type Job } from '../node_modules/@cmodjs/core/mod.js'
388
+ import { defineMod, sessions, type Job } from '../node_modules/@cmodjs/core/mod.js'
389
+
390
+ type EditCounter = { count(): number; remove(): void }
418
391
 
419
- const editCounter = (tool: string): Job<Disposable & { readonly count: () => number }> => ({ mod, announce, reserveName }) => {
392
+ const editCounter = (tool: string): Job<EditCounter> => ({ mod, announce, reserveName }) => {
420
393
  const reserved = reserveName('editCounter', tool, `${mod.name}: edits of ${tool} are already counted.`)
421
394
  let edits = 0
422
395
  const listening = sessions.current.onToolCall({ name: tool }, () => void (edits += 1))
423
396
  announce(`a count of ${tool} calls`)
424
397
  return {
425
398
  count: () => edits,
426
- dispose() {
427
- listening.dispose()
428
- reserved.dispose()
399
+ remove() {
400
+ listening.remove()
401
+ reserved.remove()
429
402
  },
430
403
  }
431
404
  }
@@ -434,7 +407,7 @@ export const counter = defineMod({
434
407
  name: 'counter',
435
408
  setup(mod) {
436
409
  const writes = mod.use(editCounter('Write'))
437
- sessions.current.onTurnEnd(() => mod.ui.toast(`${writes.count()} files written so far`))
410
+ sessions.current.onTurnEnd(() => mod.toast(`${writes.count()} files written so far`))
438
411
  },
439
412
  })
440
413
  ```
package/docs/mod.md CHANGED
@@ -1,30 +1,30 @@
1
1
  # Define a mod
2
2
 
3
- `defineMod` describes a mod: its name, its state, the methods it offers other mods, and the `setup` function that attaches everything to Claude Code. `registerMod` registers the definition with Claude Code.
3
+ `defineMod` describes a mod: its name, its state, its options, its hotkeys, and the `setup` function that adds everything to Claude Code and returns the methods it offers other mods. `registerMod` registers the definition with Claude Code.
4
4
 
5
5
  ## defineMod
6
6
 
7
7
  ```ts
8
- defineMod<State, Name, Options>(definition: ModDefinition<State, Name, Options>): ModDefinition<State, Name, Options>
8
+ defineMod<State, Name, Options, Hotkeys>(definition: ModDefinition<State, Name, Options, Hotkeys>): ModDefinition<State, Name, Options, Hotkeys>
9
9
  ```
10
10
 
11
11
  ```ts
12
- type ModDefinition<State, Name, Options> = {
12
+ type ModDefinition<State, Name, Options, Hotkeys> = {
13
13
  readonly name: Name
14
14
  readonly state?: State
15
15
  readonly options?: Options
16
- readonly api?: { … }
16
+ readonly hotkeys?: Hotkeys
17
17
  readonly installer?: readonly Step<State>[]
18
- setup(mod: Mod<State, Options>): unknown
18
+ setup(mod: Mod<State, Options, Hotkeys>): Api | void | Promise<Api | void>
19
19
  }
20
20
  ```
21
21
 
22
22
  - `name` is the mod's name. Use the `name` in `.claude-plugin/plugin.json`. Claude Mod Manager (cmod) keys the saved state and the options by this name, and the data folder and the Skill overrides by the plugin's name, so the two must match. `defineMod` throws `defineMod: the mod needs a name, such as the name in .claude-plugin/plugin.json.` for a blank name.
23
23
  - `state` holds the starting values, grouped by how long they last. [state.md](state.md) explains the groups.
24
24
  - `options` declares what a person sets, such as a token, and types `mod.options`. [options.md](options.md) explains them.
25
- - `api` holds the methods other mods call. [dependencies.md](dependencies.md) explains it.
25
+ - `hotkeys` declares the key combos the mod listens for, each with a title and a default combo the person can change ([Hotkeys](#hotkeys)).
26
26
  - `installer` lists the steps the person finishes in Claude Code after the permissions and the options, such as signing in. [install-steps.md](install-steps.md#steps-in-claude-code) explains them.
27
- - `setup` runs once when the mod starts. It adds listeners, panes, renders, and jobs. It may be `async`.
27
+ - `setup` runs once when the mod starts. It adds listeners, pages, panes, widgets, styles, and jobs. It may be `async`. What it returns is the mod's API: an object of methods other mods call. Return nothing for a mod that offers none. [dependencies.md](dependencies.md) explains it.
28
28
 
29
29
  ```tsx
30
30
  import { defineMod, sessions } from '../node_modules/@cmodjs/core/mod.js'
@@ -37,7 +37,7 @@ export const greeter = defineMod({
37
37
  sessions.current.onPrompt(() => {
38
38
  if (mod.state.session.isGreeted) return
39
39
  mod.state.session.isGreeted = true
40
- mod.ui.toast(`Hello from ${mod.name}`)
40
+ mod.toast(`Hello from ${mod.name}`)
41
41
  })
42
42
  },
43
43
  })
@@ -47,15 +47,17 @@ export const greeter = defineMod({
47
47
 
48
48
  Claude Code loads the mod at the start of each session. cmod then checks that the mod is set up. A mod the person has not accepted yet opens the installer pane, `Install <name>`, which lists its permissions and the changes it makes to the computer, and waits for Accept ([install-steps.md](install-steps.md)). An option with no default and no value is asked on the next page of the same pane. Then cmod loads the saved state, runs `setup`, marks as open each of the mod's panes that Claude Code already shows, and opens the pane again for each `installer` step the mod declares that is not done.
49
49
 
50
- The first time a mod starts on a machine, cmod shows the toast `Installed` under its name. Opened, it lists the mod's `plugin.json` `description`, what setup added, such as `Adds the my-mod pane, a render of AbovePrompt and /deploy.`, what its install step set up, and each permission the person allowed, with a Settings button for `/mods <name>`. The first time a new version starts, the toast is `Updated to <version>`: the first three changes of that version's `CHANGELOG.md` section, the version the machine ran before, and a Changelog button ([Release notes](#release-notes)). A mod with no changelog lists what it adds now and no longer adds instead. A new session, `/reload-plugins`, and a version the machine already ran show no toast.
50
+ The first time a mod starts on a machine, cmod shows the toast `Installed` under its name. Opened, it lists the mod's `plugin.json` `description`, what setup added, such as `Adds the my-mod pane, the Branch widget and /deploy.`, what its install step set up, and each permission the person allowed, with a Settings button that opens Settings at the mod. The first time a new version starts, the toast is `Updated to <version>`: the first three changes of that version's `CHANGELOG.md` section, the version the machine ran before, and a Changelog button ([Release notes](#release-notes)). A mod with no changelog lists what it adds now and no longer adds instead. A new session, `/reload-plugins`, and a version the machine already ran show no toast.
51
51
 
52
52
  When a step fails, or the person declines the install, the mod stays stopped. Once another session sets it up, the mod starts at the next prompt of every session it stopped in, with no `/reload-plugins`.
53
53
 
54
+ A mod that loads while a session runs, such as after `/reload-plugins` or `/plugin install`, hears no SessionStart from Claude Code. cmod runs its `onSessionStart` and `beforeSessionStart` listeners before the next prompt instead, once, with the source `resume`.
55
+
54
56
  When a step fails, the mod does not start. An error toast above the prompt names the failure, with the fix under it:
55
57
 
56
58
  - `<mod>: state.<group> is not a lifetime. …` or `<mod>: state.<group> is not an object of values. …` when the declared state is not valid ([state.md](state.md)).
57
59
  - `its state did not load: <error>` when reading the saved values fails.
58
- - `it needs <titles>` when the person chose Not now on an option with no default, or no one can answer, as in `claude -p` ([options.md](options.md)). This one says `Set it in /config.` instead, and Claude Code reloads the mod once the value is set.
60
+ - `it needs <titles>` when the person chose Not now on an option with no default, or no one can answer, as in `claude -p` ([options.md](options.md)). This one says `Retry asks for it.`, and `Retry` opens the question again.
59
61
  - `its setup function threw: <error>` when `setup` throws or rejects.
60
62
  - `its open panes did not load: <error>` when Claude Code cannot list the open panes.
61
63
  - `the details of its session did not load: <error>` when Claude Code cannot name the session's id, model, or start time.
@@ -98,7 +100,7 @@ A mod's `CHANGELOG.md`, at its root, holds what changed in each version, written
98
100
  ```
99
101
 
100
102
  - `cmod publish` releases the version's section as the GitHub release's notes, and refuses a version with no section ([commands.md](commands.md#cmod-publish)). Every release ships the file.
101
- - The Updated toast shows the first three changes of the new version's section, and its Changelog button opens `/mods <name> changelog`, the whole file with the installed version at the top of the view.
103
+ - The Updated toast shows the first three changes of the new version's section, and its Changelog button opens the mod's Changelog in Settings, the whole file with the installed version at the top of the view.
102
104
  - A linked checkout whose version has no section yet shows its `[Unreleased]` section, so the notes show as soon as the version moves.
103
105
  - `cmod new` writes the file with an `[Unreleased]` and a `[0.1.0]` section.
104
106
 
@@ -141,82 +143,87 @@ Keep `register.ts` this small. Claude Code checks a hooks module before it loads
141
143
 
142
144
  ## mod: this mod
143
145
 
144
- `setup` gets `mod`, of type `Mod<State, Options>`. Every listener, render, and job gets the same `mod`. `mod` holds what belongs to this mod: its name, its folders, its values, its grants, and what it adds to Claude Code.
146
+ `setup` gets `mod`, of type `Mod<State, Options, Hotkeys>`. Every listener, render, and job gets the same `mod`. `mod` holds what belongs to this mod: its name, its folders, its values, its grants, its hotkeys, and what it adds to Claude Code.
145
147
 
146
148
  ```ts
147
- type Mod<State, Options> = {
149
+ type Mod<State, Options, Hotkeys> = {
148
150
  readonly name: string
149
151
  readonly folder: string
150
152
  readonly dataFolder: string
151
153
  readonly state: Readonly<State>
152
154
  readonly options: OptionValues<Options>
153
155
  readonly permissions: Permissions
154
- every(ms, listener): Disposable
155
- after(ms, listener): Disposable
156
+ readonly hotkeys: { readonly [name in keyof Hotkeys]: Hotkey }
157
+ every(ms, listener): Timer
158
+ after(ms, listener): Timer
156
159
  use(job): Handle
157
- log(text): void
158
- readonly ui: { pane, render, toast, ask, copy, scroll, notice }
159
- readonly settings: { page, open }
160
+ log(text, options?): void
161
+ readonly sidebar: { add(page): Page }
162
+ readonly settings: { add(page): Page; open(): Promise<void> }
163
+ readonly panes: { add(pane): Pane }
164
+ readonly widgets: { add(widget): Widget }
165
+ readonly styles: { add(style): Style }
166
+ readonly toast: Toast
167
+ ask(question, options?): Promise<string>
160
168
  readonly dependencies: CmodDependencies
161
169
  }
162
170
  ```
163
171
 
164
- | Member | What it is | Docs |
165
- | --- | --- | --- |
166
- | `name` | The mod's name from `defineMod`. | |
167
- | `folder` | The plugin's own folder, where the files the mod ships live. | |
168
- | `dataFolder` | The mod's own folder in the cmod store: `$XDG_DATA_HOME/cmod/data/<plugin name>`, or `~/.local/share/cmod/data/<plugin name>` when `XDG_DATA_HOME` is not set. The install step gets the same folder as `CMOD_DATA`. cmod deletes it when the mod is removed. | [install-steps.md](install-steps.md) |
169
- | `state` | The mod's values. Assign to a key to change it. | [state.md](state.md) |
170
- | `options` | The values a person set for the mod's options, typed from their declaration. Read-only. | [options.md](options.md) |
171
- | `permissions` | Says whether the person granted each permission. | [permissions.md](permissions.md) |
172
- | `every` and `after` | Run a listener on a timer while the session runs. | below |
173
- | `use` | Adds a job, such as a slash command or a tool, and returns its handle. | [jobs.md](jobs.md) |
174
- | `log` | Writes a line, under the mod's name, to Claude Code's debug log. | below |
175
- | `ui` | Panes, renders at a place Claude Code draws, toasts, questions, the clipboard, scrolling, and a line under a permission dialog. | [ui.md](ui.md) |
176
- | `settings` | Adds the mod's own pages to `/mods`, and opens them. | below |
177
- | `dependencies` | Calls the methods of other mods. | [dependencies.md](dependencies.md) |
172
+ - `name` is the mod's name from `defineMod`.
173
+ - `folder` is the plugin's own folder, where the files the mod ships live.
174
+ - `dataFolder` is the mod's own folder in the cmod store: `$XDG_DATA_HOME/cmod/data/<plugin name>`, or `~/.local/share/cmod/data/<plugin name>` when `XDG_DATA_HOME` is not set. The install step gets the same folder as `CMOD_DATA`. cmod deletes it when the mod is removed ([install-steps.md](install-steps.md)).
175
+ - `state` holds the mod's values. Assign to a key to change it ([state.md](state.md)).
176
+ - `options` holds the values a person set for the mod's options, typed from their declaration. Read-only ([options.md](options.md)).
177
+ - `permissions` says whether the person granted each permission ([permissions.md](permissions.md)).
178
+ - `hotkeys` holds each hotkey `defineMod` declared ([Hotkeys](#hotkeys)).
179
+ - `every` and `after` run a listener on a timer while the session runs.
180
+ - `use` adds a job, such as a slash command or a tool, and returns its handle ([jobs.md](jobs.md)).
181
+ - `log` writes a line, under the mod's name, to Claude Code's debug log.
182
+ - `sidebar`, `settings`, `panes`, `widgets`, and `styles` add the mod's pages, panes, widgets, and styles. `toast` shows a short message and `ask` asks a question ([ui.md](ui.md)).
183
+ - `dependencies` calls the methods of other mods ([dependencies.md](dependencies.md)).
178
184
 
179
185
  Everything that is Claude Code's or the computer's rather than the mod's is an import from `mod.js`, so a function in any file calls it without being handed `mod`:
180
186
 
181
187
  ```ts
182
- import { commands, config, fs, http, metadata, model, process, sessions, settings } from '../node_modules/@cmodjs/core/mod.js'
188
+ import { clipboard, commands, config, fs, http, metadata, model, process, sessions, settings } from '../node_modules/@cmodjs/core/mod.js'
183
189
  ```
184
190
 
185
- | Import | What it is | Docs |
186
- | --- | --- | --- |
187
- | `sessions` | The conversations: the one the mod runs in, every other one, and search across them. Everything that happens in a session, from a prompt to a permission dialog, is a method on it. | [session.md](session.md) |
188
- | `commands` | Every slash command: lists them, runs one, and hears or changes each run. | below |
189
- | `settings` | Claude Code's `settings.json` files: the person's, the project's, and the project's local one. | below |
190
- | `config` | The rows of Claude Code's `/config` menu. | below |
191
- | `model` | Asks a model one question, outside the conversation. | below |
192
- | `fs` | Reads, writes, lists, and finds files. | below |
193
- | `metadata` | Reads and changes the mod's keys in one file's metadata. | below |
194
- | `process` | Runs a program. | below |
195
- | `http` | Fetches a URL. | below |
191
+ - `sessions` is the conversations: the one the mod runs in, every other one, and search across them. Everything that happens in a session, from a prompt to a permission dialog, is a method on it ([session.md](session.md)).
192
+ - `commands` is every slash command: it lists them, runs one, and hears or changes each run.
193
+ - `settings` is Claude Code's `settings.json` files: the person's, the project's, and the project's local one.
194
+ - `config` is the rows of Claude Code's `/config` menu.
195
+ - `model` asks a model one question, outside the conversation.
196
+ - `fs` reads, writes, lists, finds, and watches files.
197
+ - `metadata` reads and changes the mod's keys in one file's metadata.
198
+ - `process` runs a program.
199
+ - `http` fetches a URL.
200
+ - `clipboard` puts text on the person's clipboard ([ui.md](ui.md#toasts)).
196
201
 
197
202
  Each import works once the mod has started, in `setup` and anything it adds. Each one checks the permission it needs, and rejects with the line to add or the grant to turn on when the person has not granted it ([permissions.md](permissions.md)).
198
203
 
199
- ### Disposable
204
+ ### Take back what the mod added
200
205
 
201
- `mod.every`, `mod.after`, `mod.ui.render`, and every `on` and `before` method each return a `Disposable`. `dispose()` takes back what the call added, so a mod turns a feature off and on while it runs. Everything a mod adds is taken back when the mod stops, so a mod that keeps a feature for the whole session ignores the `Disposable`.
206
+ Every `add` call, every `on` and `before` method, `fs.onChange`, `settings.beforeFileChange`, and `hotkey.onPress` return a part or a `Subscription`, and each has `remove()`, which takes back what the call added. So a mod turns a feature off and on while it runs. Everything a mod adds is taken back when the mod stops, so a mod that keeps a feature for the whole session ignores what the call returns.
202
207
 
203
208
  ```ts
204
- type Disposable = { dispose(): void }
209
+ type Subscription = { remove(): void }
205
210
  ```
206
211
 
207
212
  ```ts
208
213
  const watching = sessions.current.onToolCall({ name: 'Edit' }, () => void (mod.state.session.edits += 1))
209
- mod.use(slashCommand({ name: 'stop-counting', description: 'Stop counting edits', reply: () => watching.dispose() }))
214
+ mod.use(slashCommand({ name: 'stop-counting', description: 'Stop counting edits', reply: () => watching.remove() }))
210
215
  ```
211
216
 
212
217
  ### mod.every and mod.after
213
218
 
214
219
  ```ts
215
- mod.every(ms: number, listener: () => unknown): Disposable
216
- mod.after(ms: number, listener: () => unknown): Disposable
220
+ mod.every(ms: number, listener: () => unknown): Timer
221
+ mod.after(ms: number, listener: () => unknown): Timer
222
+
223
+ type Timer = { readonly isRunning: boolean; stop(): void }
217
224
  ```
218
225
 
219
- `every` runs `listener` every `ms` milliseconds until the session ends or the mod disposes it. A tick that comes while the last run is still going is skipped, so a slow run never stacks up. `after` runs `listener` once, `ms` milliseconds from now, unless the mod disposes it first. A run that throws shows the error toast `The every <ms> ms timer failed`, or `The after <ms> ms timer failed`, and the next tick of an `every` runs again. `ms` is a whole number above 0 and at most `2147483647`, and any other `ms` stops `setup` with `<mod>: mod.every is <ms>. Give a whole number of milliseconds above 0 and at most 2147483647.`
226
+ `every` runs `listener` every `ms` milliseconds until the session ends or the mod calls `stop()`. A tick that comes while the last run is still going is skipped, so a slow run never stacks up. `after` runs `listener` once, `ms` milliseconds from now, unless the mod calls `stop()` first. `isRunning` is true until the timer stops, and an `after` timer stops once it ran. A run that throws shows the error toast `The every <ms> ms timer failed`, or `The after <ms> ms timer failed`, and the next tick of an `every` runs again. `ms` is a whole number above 0 and at most `2147483647`, and any other `ms` stops `setup` with `<mod>: mod.every is <ms>. Give a whole number of milliseconds above 0 and at most 2147483647.`
220
227
 
221
228
  A watch that reacts on its own is `every` plus `mod.state` plus a `prompt` with no trigger: `every` checks the outside world, writes what it found to `mod.state`, and the panes and the prompt follow the state ([jobs.md](jobs.md#prompt)).
222
229
 
@@ -237,18 +244,39 @@ mod.log(text: string, options?: { to?: 'debug' | 'transcript' }): void
237
244
 
238
245
  With `{ to: 'transcript' }`, the line shows dim in the conversation, where the person reads it and Claude never does, such as "Formatted 3 files" after a turn.
239
246
 
240
- ### mod.settings
247
+ ### Hotkeys
248
+
249
+ A hotkey is a key combo the mod listens for, such as `alt+shift+p` to show a page. The mod declares each one with a title and a default combo, and the person changes the combo in Settings, under the mod, Hotkeys, the way VS Code handles an extension's keybindings.
241
250
 
242
251
  ```ts
243
- mod.settings.page(page: Pane<State>): void
244
- mod.settings.open(pageId?: string): Promise<void>
252
+ hotkeys?: { readonly [name: string]: { readonly title: string; readonly default: Combo } }
253
+
254
+ type Hotkey = {
255
+ readonly title: string
256
+ readonly combo: Combo | undefined
257
+ onPress(listener: () => unknown): Subscription
258
+ }
245
259
  ```
246
260
 
247
- `/mods` shows each mod's description, options, permissions, keys, and changelog, and turns a mod off or removes it. `page` adds a page of the mod's own to its entry there, drawn as any pane is ([ui.md](ui.md)). The id `changelog` is taken by the Changelog page, so `page` refuses it. `open` opens that page, or with no id opens the mod's entry in `/mods`. `open` rejects for an id `page` did not add.
261
+ - A name is letters, digits, and `_`, starting with a letter. `title` is the label Settings shows beside the combo.
262
+ - A combo is one or two of `ctrl`, `alt`, `shift`, and `meta`, in that order, then a key: a letter, a digit, `f1` to `f12`, `tab`, `enter`, `escape`, `space`, `backspace`, `delete`, an arrow, `home`, `end`, `pageup`, `pagedown`, or a mark such as `/`. `ctrl+q`, `alt+shift+p`, and `ctrl+shift+k` are combos.
263
+ - `defineMod` throws for a name, a title, or a combo that is not one, and for two hotkeys with one default.
264
+ - `cmod check` writes the hotkeys into `package.json` `"cmod".hotkeys`, and `cmod publish` refuses a mod whose `package.json` differs from `defineMod`. The install question lists each combo, and `cmod setup` binds it in Claude Code's `keybindings.json`. A combo the person already bound to something else keeps their binding, and the hotkey has no combo until they pick one in Settings.
265
+ - `combo` is the combo bound when the mod started, or `undefined` when `keybindings.json` binds none to the hotkey, such as when another binding kept the default.
266
+ - `onPress` runs `listener` each time the person presses the combo. A page or pane the listener opens shows at any width, because the press is the person's own ([ui.md](ui.md#the-sidebar-and-its-pages)).
267
+ - `tested.hotkey(name)` presses a hotkey in a test ([testing.md](testing.md)).
248
268
 
249
269
  ```tsx
250
- mod.settings.page(definePane({ id: 'runs', title: 'Runs', render: (current) => <Text>{current.state.session.lastRun}</Text> }))
251
- mod.use(slashCommand({ name: 'ci-runs', description: 'Show the CI runs', reply: () => void mod.settings.open('runs') }))
270
+ export const notes = defineMod({
271
+ name: 'notes',
272
+ hotkeys: {
273
+ toggle: { title: 'Show or hide the notes', default: 'alt+shift+n' },
274
+ },
275
+ setup(mod) {
276
+ const page = mod.sidebar.add(notesPage)
277
+ mod.hotkeys.toggle.onPress(() => page.toggle({ focus: true }))
278
+ },
279
+ })
252
280
  ```
253
281
 
254
282
  ## commands: slash commands
@@ -256,8 +284,8 @@ mod.use(slashCommand({ name: 'ci-runs', description: 'Show the CI runs', reply:
256
284
  ```ts
257
285
  commands.list(): Promise<CommandInfo[]>
258
286
  commands.run(name: string, args?: string): Promise<{ text?: string; note?: string }>
259
- commands.beforeRun(matcher?, listener: (run: CommandRun) => Answer | void, options?): Disposable
260
- commands.onRun(matcher?, listener: (run: CommandRun) => unknown, options?): Disposable
287
+ commands.beforeRun(matcher?, listener: (run: CommandRun) => Answer | void, options?): Subscription
288
+ commands.onRun(matcher?, listener: (run: CommandRun) => unknown, options?): Subscription
261
289
 
262
290
  type CommandInfo = { name: string; description: string; source: 'builtin' | 'plugin' | 'user' | 'mcp'; plugin?: string }
263
291
  type CommandRun = { name: string; args: string; source?: 'builtin' | 'plugin' | 'user' | 'mcp'; from: Sender; session: string; cwd: string }
@@ -285,7 +313,7 @@ settings.project.read(): Promise<Settings>
285
313
  settings.project.update(values: Readonly<Record<string, unknown>>): Promise<void>
286
314
  settings.local.read(): Promise<Settings>
287
315
  settings.local.update(values: Readonly<Record<string, unknown>>): Promise<void>
288
- settings.beforeFileChange(matcher?, listener: (change: SettingsFileChange) => { refuse: string } | void): Disposable
316
+ settings.beforeFileChange(matcher?, listener: (change: SettingsFileChange) => { refuse: string } | void): Subscription
289
317
  ```
290
318
 
291
319
  `settings.user` is the person's own `~/.claude/settings.json`, `settings.project` is the project's `.claude/settings.json`, and `settings.local` is the project's `.claude/settings.local.json`.
@@ -385,7 +413,7 @@ fs.list(path?: string): Promise<Entry[]>
385
413
  fs.exists(path: string): Promise<boolean>
386
414
  fs.stat(path: string, options?: { resolve: boolean }): Promise<Stat>
387
415
  fs.find(glob: string | readonly string[]): Promise<readonly FileMatch[]>
388
- fs.onChange({ path: string | readonly string[] }, listener: (change: FileChange) => unknown): Disposable
416
+ fs.onChange({ path: string | readonly string[] }, listener: (change: FileChange) => unknown): Subscription
389
417
  ```
390
418
 
391
419
  ```ts
@@ -400,10 +428,10 @@ type Stat = { isFile: boolean; isFolder: boolean; isLink: boolean; size: number;
400
428
  - `list` lists a folder, the working folder without a path. A symbolic link is neither a file nor a folder, with `isLink` true. `modifiedAt` is the time it last changed, in milliseconds since 1970.
401
429
  - `exists` answers whether the path leads to a file or a folder.
402
430
  - `stat` answers what the path leads to, following a symbolic link, and rejects a missing path. With `{ resolve: true }` it also answers `realPath`: the absolute path, every symbolic link followed.
403
- - `onChange` hears each file Claude Code sees change, appear, or go at a path or under a folder. Claude Code reads the paths it watches when a session starts, so `onChange` works only in `setup`, and a call later throws. A relative path is under the project. `path` takes strings only, unlike other matchers, because the paths go to Claude Code as they are:
431
+ - `onChange` hears each file that changes, appears, or goes at a path or under a folder, the mod's own saves included. cmod checks the paths twice a second, and at most 2000 files under one call's folders. A call over more stops, with an error toast that names its paths. It works from `setup` or any time later, and for a path that does not exist yet. A relative path is under the session's working folder. `path` takes strings only, unlike other matchers:
404
432
 
405
433
  ```ts
406
- fs.onChange({ path: `${mod.dataFolder}/history` }, ({ path, type }) => mod.ui.toast(`${path}: ${type}`))
434
+ fs.onChange({ path: `${mod.dataFolder}/history` }, ({ path, type }) => mod.toast(`${path}: ${type}`))
407
435
  ```
408
436
 
409
437
  A file the mod ships is under `mod.folder`. A file the mod keeps for itself belongs in `mod.dataFolder`.
@@ -494,7 +522,7 @@ export const notes = defineMod({
494
522
  setup(mod) {
495
523
  sessions.current.onTurnEnd(async () => {
496
524
  await fs.write(`${mod.dataFolder}/last-stop.txt`, new Date().toISOString()).catch((error: unknown) => {
497
- mod.ui.toast.error('Could not save the last stop', { description: messageOf(error) })
525
+ mod.toast.error('Could not save the last stop', { description: messageOf(error) })
498
526
  })
499
527
  })
500
528
  },
@@ -528,7 +556,7 @@ import { sessions } from '../node_modules/@cmodjs/core/mod.js'
528
556
 
529
557
  sessions.current.onToolCall({ name: 'Bash' }, (call) => {
530
558
  for (const { argv, folder } of parseShell(String(call.input['command'])).commands) {
531
- if (basename(argv[0]) === 'trace') mod.ui.toast(`trace runs in ${resolve(call.cwd, folder)}`)
559
+ if (basename(argv[0]) === 'trace') mod.toast(`trace runs in ${resolve(call.cwd, folder)}`)
532
560
  }
533
561
  })
534
562
  ```
package/docs/options.md CHANGED
@@ -18,7 +18,7 @@ export const ciWatch = defineMod({
18
18
  branch: option.text({ title: 'Branch', description: 'The branch to watch', default: 'main' }),
19
19
  },
20
20
  setup(mod) {
21
- mod.ui.toast(`Watching ${mod.options.branch} every ${mod.options.every} minutes`)
21
+ mod.toast(`Watching ${mod.options.branch} every ${mod.options.every} minutes`)
22
22
  },
23
23
  })
24
24
  ```
@@ -49,7 +49,7 @@ Each value is, in order of precedence:
49
49
  3. The value the person set in `/config`.
50
50
  4. The `default` in the declaration.
51
51
 
52
- An option with no default and no value is asked before `setup` runs, on a page of the installer pane: one field per option, each saved to `/config` through `cmod option` as the person submits it. A secret is set in `/config` itself, where Claude Code keeps it in the keychain, and the page says so. When the person chooses Not now, or no one can answer, as in `claude -p`, the mod does not start: its error toast says `it needs <title>` and `Set it in /config.`, and Claude Code reloads the mod once the value is set.
52
+ An option with no default and no value is asked before `setup` runs, on a page of the installer pane: one field per option, each saved to `/config` through `cmod option` as the person submits it. A secret is set in `/config` itself, where Claude Code keeps it in the keychain, and the page says so. When the person chooses Not now, or no one can answer, as in `claude -p`, the mod does not start: its error toast says `it needs <title>` and `Retry asks for it.`, and `Retry` opens the question again. A secret has an `Open /config` button, because Claude Code keeps secrets in /config and the keychain.
53
53
 
54
54
  `cmod install`, `cmod link`, and `cmod try` set options from the terminal with `--option key=value`, once per option ([commands.md](commands.md)).
55
55
 
@@ -1,6 +1,6 @@
1
1
  # Permissions
2
2
 
3
- A mod declares what it does beyond drawing and listening: the hosts it connects to, the programs it runs, the files it changes outside its project, and whether it reads the conversation, adds text Claude reads, asks a model, starts tasks, uses Claude's tools, changes Claude Code's settings, or approves tool calls. The person sees each permission as one sentence when they install the mod, turns each one on or off in `/mods`, and cmod checks every call and every answer against what they granted.
3
+ A mod declares what it does beyond drawing and listening: the hosts it connects to, the programs it runs, the files it changes outside its project, and whether it reads the conversation, adds text Claude reads, asks a model, starts tasks, uses Claude's tools, changes Claude Code's settings, or approves tool calls. The person sees each permission as one sentence when they install the mod, turns each one on or off in Settings, and cmod checks every call and every answer against what they granted.
4
4
 
5
5
  Permissions bind every mod built on `@cmodjs/core`, and the plugin directory reads them from `package.json`. They are a contract, not a sandbox: a Claude Code plugin can skip cmod and call Claude Code directly.
6
6
 
@@ -35,9 +35,9 @@ Anthropic's plugin directory does not list a mod that can approve Claude's tool
35
35
 
36
36
  ## What the person sees
37
37
 
38
- The install opens the installer pane in Claude Code and lists every permission in `permissions`, with the install step, the program, and the keys. An update asks again only when it adds a permission, and lists only the new ones. Removing the mod revokes everything it was granted.
38
+ The install opens the installer pane in Claude Code and lists every permission in `permissions`, with the install step, the program, and the hotkeys. An update asks again only when it adds a permission, and lists only the new ones. Removing the mod revokes everything it was granted.
39
39
 
40
- `/mods <mod>` lists the same permissions as toggles. A toggle runs `cmod permission <mod> <name> [value] on|off`, and the mod sees the change before its next call, with no reload.
40
+ Settings lists the same permissions as toggles, under the mod, Permissions. A toggle runs `cmod permission <mod> <name> [value] on|off`, and the mod sees the change before its next call, with no reload.
41
41
 
42
42
  `cmod check` prints the sentences, so you read what the person will see:
43
43
 
@@ -76,7 +76,7 @@ if (mod.permissions.run.allows('gh')) mod.every(60_000, refreshRuns)
76
76
  The call rejects, and the error names the fix:
77
77
 
78
78
  - An undeclared call names the line to add: `ci-watch calls http.fetch(https://api.github.com/repos), which needs "permissions": { "network": ["api.github.com"] } in package.json "cmod".`
79
- - A declared call the person turned off names where to turn it on: `ci-watch calls model.complete without your grant to "Ask a model, which uses your plan". Turn it on in /mods ci-watch.`
79
+ - A declared call the person turned off names where to turn it on: `ci-watch calls model.complete without your grant to "Ask a model, which uses your plan". Turn it on in Settings.` The error toast that shows it has a `Settings` button that opens the mod's Permissions.
80
80
  - A `program` job without its grant stops at once with the same line, with no restarts.
81
81
 
82
82
  ## When a before answer has no grant