@cmodjs/core 0.3.0 → 1.5.0

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 (280) hide show
  1. package/docs/commands.md +61 -10
  2. package/docs/dependencies.md +16 -14
  3. package/docs/hooks.md +64 -29
  4. package/docs/index.md +29 -25
  5. package/docs/install-steps.md +13 -9
  6. package/docs/jobs.md +59 -111
  7. package/docs/mod.md +227 -175
  8. package/docs/options.md +2 -2
  9. package/docs/permissions.md +27 -16
  10. package/docs/session.md +311 -0
  11. package/docs/state.md +11 -7
  12. package/docs/testing.md +68 -23
  13. package/docs/ui.md +116 -76
  14. package/jobs/check.d.ts.map +1 -1
  15. package/jobs/check.js +5 -2
  16. package/jobs/context.d.ts +7 -8
  17. package/jobs/context.d.ts.map +1 -1
  18. package/jobs/context.js +23 -73
  19. package/jobs/permissions/decide-permission.d.ts +5 -3
  20. package/jobs/permissions/decide-permission.d.ts.map +1 -1
  21. package/jobs/permissions/decide-permission.js +6 -4
  22. package/jobs/permissions/find-project-scope.d.ts +1 -1
  23. package/jobs/permissions/find-project-scope.d.ts.map +1 -1
  24. package/jobs/permissions/find-project-scope.js +8 -11
  25. package/jobs/permissions/match-target.d.ts +2 -2
  26. package/jobs/permissions/match-target.d.ts.map +1 -1
  27. package/jobs/permissions.d.ts +1 -1
  28. package/jobs/permissions.d.ts.map +1 -1
  29. package/jobs/permissions.js +6 -4
  30. package/jobs/program.d.ts.map +1 -1
  31. package/jobs/program.js +14 -4
  32. package/jobs/prompt.d.ts +4 -2
  33. package/jobs/prompt.d.ts.map +1 -1
  34. package/jobs/prompt.js +16 -15
  35. package/jobs/schedule.d.ts +2 -2
  36. package/jobs/schedule.d.ts.map +1 -1
  37. package/jobs/schedule.js +9 -9
  38. package/jobs/slash-command.d.ts +4 -4
  39. package/jobs/slash-command.d.ts.map +1 -1
  40. package/jobs/slash-command.js +14 -9
  41. package/jobs/status-line.d.ts +1 -11
  42. package/jobs/status-line.d.ts.map +1 -1
  43. package/jobs/status-line.js +8 -12
  44. package/jobs/tool.d.ts +2 -2
  45. package/jobs/tool.d.ts.map +1 -1
  46. package/jobs/tool.js +15 -9
  47. package/mod.d.ts +178 -75
  48. package/mod.d.ts.map +1 -1
  49. package/mod.js +127 -3
  50. package/options.d.ts +2 -2
  51. package/options.js +19 -19
  52. package/package.json +3 -3
  53. package/records.d.ts +13 -8
  54. package/records.d.ts.map +1 -1
  55. package/records.js +33 -17
  56. package/register.d.ts.map +1 -1
  57. package/register.js +34 -5
  58. package/runtime/announce.d.ts +18 -0
  59. package/runtime/announce.d.ts.map +1 -0
  60. package/runtime/announce.js +68 -0
  61. package/runtime/before.d.ts +32 -0
  62. package/runtime/before.d.ts.map +1 -0
  63. package/runtime/before.js +214 -0
  64. package/runtime/bound.d.ts +4 -0
  65. package/runtime/bound.d.ts.map +1 -0
  66. package/runtime/bound.js +9 -0
  67. package/runtime/claude.d.ts +4 -2
  68. package/runtime/claude.d.ts.map +1 -1
  69. package/runtime/conversations.d.ts +17 -0
  70. package/runtime/conversations.d.ts.map +1 -0
  71. package/runtime/conversations.js +563 -0
  72. package/runtime/deadline.d.ts +0 -1
  73. package/runtime/deadline.d.ts.map +1 -1
  74. package/runtime/deadline.js +1 -1
  75. package/runtime/dependencies.d.ts +2 -0
  76. package/runtime/dependencies.d.ts.map +1 -1
  77. package/runtime/dependencies.js +10 -1
  78. package/runtime/engine.d.ts +13 -0
  79. package/runtime/engine.d.ts.map +1 -0
  80. package/runtime/engine.js +10 -0
  81. package/runtime/grants.d.ts +4 -0
  82. package/runtime/grants.d.ts.map +1 -1
  83. package/runtime/grants.js +68 -31
  84. package/runtime/hooks.d.ts +5 -4
  85. package/runtime/hooks.d.ts.map +1 -1
  86. package/runtime/hooks.js +29 -9
  87. package/runtime/installer.d.ts +1 -1
  88. package/runtime/installer.d.ts.map +1 -1
  89. package/runtime/installer.js +17 -14
  90. package/runtime/lifecycle.d.ts +3 -1
  91. package/runtime/lifecycle.d.ts.map +1 -1
  92. package/runtime/lifecycle.js +414 -212
  93. package/runtime/metadata.d.ts +3 -1
  94. package/runtime/metadata.d.ts.map +1 -1
  95. package/runtime/metadata.js +25 -14
  96. package/runtime/options.d.ts +3 -1
  97. package/runtime/options.d.ts.map +1 -1
  98. package/runtime/options.js +4 -4
  99. package/runtime/programs.d.ts +8 -0
  100. package/runtime/programs.d.ts.map +1 -1
  101. package/runtime/programs.js +39 -1
  102. package/runtime/router.d.ts +2 -1
  103. package/runtime/router.d.ts.map +1 -1
  104. package/runtime/router.js +11 -3
  105. package/runtime/sessions.d.ts +58 -0
  106. package/runtime/sessions.d.ts.map +1 -0
  107. package/runtime/sessions.js +433 -0
  108. package/runtime/state.d.ts +6 -2
  109. package/runtime/state.d.ts.map +1 -1
  110. package/runtime/state.js +46 -13
  111. package/runtime/toaster.d.ts +24 -0
  112. package/runtime/toaster.d.ts.map +1 -0
  113. package/runtime/toaster.js +286 -0
  114. package/runtime/ui.d.ts +6 -17
  115. package/runtime/ui.d.ts.map +1 -1
  116. package/runtime/ui.js +62 -118
  117. package/session/context/context.d.ts +12 -0
  118. package/session/context/context.d.ts.map +1 -0
  119. package/session/context/context.js +2 -0
  120. package/session/context/turn/message/document.d.ts +7 -0
  121. package/session/context/turn/message/document.d.ts.map +1 -0
  122. package/session/context/turn/message/document.js +1 -0
  123. package/session/context/turn/message/message.d.ts +55 -0
  124. package/session/context/turn/message/message.d.ts.map +1 -0
  125. package/session/context/turn/message/message.js +178 -0
  126. package/session/context/turn/message/note.d.ts +8 -0
  127. package/session/context/turn/message/note.d.ts.map +1 -0
  128. package/session/context/turn/message/note.js +1 -0
  129. package/session/context/turn/message/notice.d.ts +6 -0
  130. package/session/context/turn/message/notice.d.ts.map +1 -0
  131. package/session/context/turn/message/notice.js +1 -0
  132. package/session/context/turn/message/prompt.d.ts +8 -0
  133. package/session/context/turn/message/prompt.d.ts.map +1 -0
  134. package/session/context/turn/message/prompt.js +1 -0
  135. package/session/context/turn/message/reply.d.ts +6 -0
  136. package/session/context/turn/message/reply.d.ts.map +1 -0
  137. package/session/context/turn/message/reply.js +1 -0
  138. package/session/context/turn/message/sender.d.ts +14 -0
  139. package/session/context/turn/message/sender.d.ts.map +1 -0
  140. package/session/context/turn/message/sender.js +30 -0
  141. package/session/context/turn/message/skill.d.ts +7 -0
  142. package/session/context/turn/message/skill.d.ts.map +1 -0
  143. package/session/context/turn/message/skill.js +1 -0
  144. package/session/context/turn/message/tool-call.d.ts +9 -0
  145. package/session/context/turn/message/tool-call.d.ts.map +1 -0
  146. package/session/context/turn/message/tool-call.js +1 -0
  147. package/session/context/turn/message/tool-result.d.ts +8 -0
  148. package/session/context/turn/message/tool-result.d.ts.map +1 -0
  149. package/session/context/turn/message/tool-result.js +1 -0
  150. package/session/context/turn/turn.d.ts +13 -0
  151. package/session/context/turn/turn.d.ts.map +1 -0
  152. package/session/context/turn/turn.js +2 -0
  153. package/session/conversation.d.ts +73 -0
  154. package/session/conversation.d.ts.map +1 -0
  155. package/session/conversation.js +146 -0
  156. package/session/goal/goal.d.ts +11 -0
  157. package/session/goal/goal.d.ts.map +1 -0
  158. package/session/goal/goal.js +1 -0
  159. package/session/goal/task.d.ts +16 -0
  160. package/session/goal/task.d.ts.map +1 -0
  161. package/session/goal/task.js +3 -0
  162. package/session/session.d.ts +158 -0
  163. package/session/session.d.ts.map +1 -0
  164. package/session/session.js +2 -0
  165. package/src/jobs/check.ts +7 -4
  166. package/src/jobs/context.ts +24 -82
  167. package/src/jobs/permissions/decide-permission.ts +10 -8
  168. package/src/jobs/permissions/find-project-scope.ts +8 -14
  169. package/src/jobs/permissions/match-target.ts +3 -3
  170. package/src/jobs/permissions.ts +8 -6
  171. package/src/jobs/program.ts +13 -4
  172. package/src/jobs/prompt.ts +21 -24
  173. package/src/jobs/schedule.ts +14 -14
  174. package/src/jobs/slash-command.ts +20 -14
  175. package/src/jobs/status-line.ts +9 -21
  176. package/src/jobs/tool.ts +17 -11
  177. package/src/mod.ts +252 -97
  178. package/src/options.ts +22 -22
  179. package/src/records.ts +40 -23
  180. package/src/register.ts +34 -5
  181. package/src/runtime/announce.ts +76 -0
  182. package/src/runtime/before.ts +200 -0
  183. package/src/runtime/bound.ts +12 -0
  184. package/src/runtime/claude.ts +7 -2
  185. package/src/runtime/conversations.ts +579 -0
  186. package/src/runtime/deadline.ts +2 -2
  187. package/src/runtime/dependencies.ts +13 -1
  188. package/src/runtime/engine.ts +23 -0
  189. package/src/runtime/grants.ts +69 -29
  190. package/src/runtime/hooks.ts +39 -14
  191. package/src/runtime/installer.ts +16 -14
  192. package/src/runtime/lifecycle.ts +413 -201
  193. package/src/runtime/metadata.ts +37 -14
  194. package/src/runtime/options.ts +19 -4
  195. package/src/runtime/programs.ts +40 -1
  196. package/src/runtime/router.ts +11 -4
  197. package/src/runtime/sessions.ts +447 -0
  198. package/src/runtime/state.ts +50 -14
  199. package/src/runtime/toaster.ts +334 -0
  200. package/src/runtime/ui.ts +65 -143
  201. package/src/session/context/context.ts +14 -0
  202. package/src/session/context/turn/message/document.ts +8 -0
  203. package/src/session/context/turn/message/message.ts +203 -0
  204. package/src/session/context/turn/message/note.ts +9 -0
  205. package/src/session/context/turn/message/notice.ts +7 -0
  206. package/src/session/context/turn/message/prompt.ts +9 -0
  207. package/src/session/context/turn/message/reply.ts +7 -0
  208. package/src/session/context/turn/message/sender.ts +38 -0
  209. package/src/session/context/turn/message/skill.ts +8 -0
  210. package/src/session/context/turn/message/tool-call.ts +10 -0
  211. package/src/session/context/turn/message/tool-result.ts +9 -0
  212. package/src/session/context/turn/turn.ts +15 -0
  213. package/src/session/conversation.ts +194 -0
  214. package/src/session/goal/goal.ts +10 -0
  215. package/src/session/goal/task.ts +19 -0
  216. package/src/session/session.ts +141 -0
  217. package/src/testing/fake-claude.ts +25 -17
  218. package/src/testing/fake-elements.ts +3 -2
  219. package/src/testing/fake-files.ts +1 -1
  220. package/src/testing/fake-sessions.ts +277 -0
  221. package/src/testing.ts +138 -35
  222. package/src/ui/components.ts +3 -3
  223. package/src/ui/elements.ts +22 -8
  224. package/src/ui/markdown.ts +22 -26
  225. package/src/ui/places.ts +49 -0
  226. package/src/utils/call-effects.ts +5 -5
  227. package/src/utils/changelog.ts +32 -0
  228. package/src/utils/disposable.ts +3 -0
  229. package/src/utils/matcher.ts +37 -0
  230. package/src/utils/metadata.ts +17 -15
  231. package/src/utils/parse-shell.ts +9 -8
  232. package/src/utils/paths.ts +21 -4
  233. package/src/utils/text.ts +4 -0
  234. package/testing/fake-claude.d.ts +5 -1
  235. package/testing/fake-claude.d.ts.map +1 -1
  236. package/testing/fake-claude.js +21 -12
  237. package/testing/fake-elements.js +4 -2
  238. package/testing/fake-files.js +1 -1
  239. package/testing/fake-sessions.d.ts +81 -0
  240. package/testing/fake-sessions.d.ts.map +1 -0
  241. package/testing/fake-sessions.js +249 -0
  242. package/testing.d.ts +34 -13
  243. package/testing.d.ts.map +1 -1
  244. package/testing.js +115 -23
  245. package/ui/components.d.ts +2 -3
  246. package/ui/components.d.ts.map +1 -1
  247. package/ui/components.js +3 -3
  248. package/ui/elements.d.ts.map +1 -1
  249. package/ui/elements.js +19 -9
  250. package/ui/markdown.d.ts +35 -33
  251. package/ui/markdown.d.ts.map +1 -1
  252. package/ui/markdown.js +14 -16
  253. package/ui/places.d.ts +43 -0
  254. package/ui/places.d.ts.map +1 -0
  255. package/ui/places.js +31 -0
  256. package/utils/call-effects.d.ts +2 -1
  257. package/utils/call-effects.d.ts.map +1 -1
  258. package/utils/call-effects.js +3 -3
  259. package/utils/changelog.d.ts +9 -0
  260. package/utils/changelog.d.ts.map +1 -0
  261. package/utils/changelog.js +29 -0
  262. package/utils/disposable.d.ts +4 -0
  263. package/utils/disposable.d.ts.map +1 -0
  264. package/utils/disposable.js +1 -0
  265. package/utils/matcher.d.ts +13 -0
  266. package/utils/matcher.d.ts.map +1 -0
  267. package/utils/matcher.js +32 -0
  268. package/utils/metadata.d.ts +3 -3
  269. package/utils/metadata.js +18 -14
  270. package/utils/parse-shell.js +9 -7
  271. package/utils/paths.d.ts +4 -1
  272. package/utils/paths.d.ts.map +1 -1
  273. package/utils/paths.js +19 -4
  274. package/utils/text.d.ts +1 -0
  275. package/utils/text.d.ts.map +1 -1
  276. package/utils/text.js +3 -0
  277. package/src/ui/slots.ts +0 -36
  278. package/ui/slots.d.ts +0 -208
  279. package/ui/slots.d.ts.map +0 -1
  280. package/ui/slots.js +0 -19
package/docs/mod.md CHANGED
@@ -15,7 +15,7 @@ type ModDefinition<State, Name, Options> = {
15
15
  readonly options?: Options
16
16
  readonly api?: { … }
17
17
  readonly installer?: readonly Step<State>[]
18
- setup(mod: Mod<State, Options>): void | Promise<void>
18
+ setup(mod: Mod<State, Options>): unknown
19
19
  }
20
20
  ```
21
21
 
@@ -24,17 +24,17 @@ type ModDefinition<State, Name, Options> = {
24
24
  - `options` declares what a person sets, such as a token, and types `mod.options`. [options.md](options.md) explains them.
25
25
  - `api` holds the methods other mods call. [dependencies.md](dependencies.md) explains it.
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 hooks, panes, slot renders, and jobs. It may be `async`.
27
+ - `setup` runs once when the mod starts. It adds hooks, panes, renders, and jobs. It may be `async`.
28
28
 
29
29
  ```tsx
30
- import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
30
+ import { defineMod, UserPromptSubmit } from '../node_modules/@cmodjs/core/mod.js'
31
31
 
32
32
  export const greeter = defineMod({
33
33
  name: 'greeter',
34
34
  state: { session: { isGreeted: false } },
35
35
 
36
36
  setup(mod) {
37
- mod.on('UserPromptSubmit', () => {
37
+ mod.on(UserPromptSubmit, () => {
38
38
  if (mod.state.session.isGreeted) return
39
39
  mod.state.session.isGreeted = true
40
40
  mod.ui.toast(`Hello from ${mod.name}`)
@@ -47,18 +47,61 @@ 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 version starts, cmod shows the toast `<name> is ready` and logs `<name> added <what setup added>`, such as `the my-mod pane, a render of AbovePrompt and a hook on UserPromptSubmit`.
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 a hook on UserPromptSubmit.`, 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.
51
51
 
52
- When a step fails, the mod does not start. Its progress line above the prompt names the failure and the fix:
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
+
54
+ When a step fails, the mod does not start. An error toast above the prompt names the failure, with the fix under it:
53
55
 
54
56
  - `<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)).
55
57
  - `its state did not load: <error>` when reading the saved values fails.
56
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.
57
59
  - `its setup function threw: <error>` when `setup` throws or rejects.
58
60
  - `its open panes did not load: <error>` when Claude Code cannot list the open panes.
61
+ - `the details of its session did not load: <error>` when Claude Code cannot name the session's id, model, or start time.
59
62
  - `it decides permissions on <events>, so hooks/register.ts must call registerPermissionCheck(addHook) after registerMod` when `setup` adds a hook on `PermissionRequest`, or a job that decides permissions, and `register.ts` does not register the permission check.
60
63
 
61
- Each of these says `Fix it, then run /reload-plugins.`
64
+ Each of these has `Retry`, which starts the mod again, and `Report` and `Hide` ([ui.md](ui.md#toasts)).
65
+
66
+ ## Rules
67
+
68
+ A mod's `rules/` folder holds rules in Claude Code's own rule format: one Markdown file per rule, with an optional `paths` list in its front matter. A rule without `paths` applies to every conversation, and a rule with `paths` applies when Claude reads a matching file.
69
+
70
+ ```
71
+ my-mod/
72
+ └── rules/
73
+ ├── tests.md <- applies to every conversation
74
+ └── sql.md <- front matter paths: ["**/*.sql"]
75
+ ```
76
+
77
+ While the mod is on and has the `prompt` permission, cmod links the folder into Claude Code's rules folder: a mod the person installed links at `<config>/rules/<mod>`, and a mod turned on for one project at `<project>/.claude/rules/<mod>`. A mod turned off for one project keeps its rules out of that project through `claudeMdExcludes` in the project's `.claude/settings.local.json`. A rule the person excluded with `claudeMdExcludes` stays out of the notes too. Turning the mod off, turning `prompt` off, and removing the mod remove the link, and an update links the new version's folder. cmod changes only the links and exclusions it made.
78
+
79
+ Claude Code reads rules without `paths` only as a session starts. So when cmod links a mod's rules into a running session, it adds them to the conversation as one note, and when it removes the link, one note says the mod's rules no longer apply. A rule taken out of the folder is withdrawn by a note too. When an update changes a rule without `paths`, one note gives its new text. Claude Code loads rules with `paths` itself, at the read that matches.
80
+
81
+ The `Installed` toast lists the rules, as in `Adds 2 rules: sql and tests.` `cmod check` fails a mod with a `rules/` folder whose `package.json` `"cmod"` does not ask for `"prompt": true`.
82
+
83
+ ## Release notes
84
+
85
+ A mod's `CHANGELOG.md`, at its root, holds what changed in each version, written for the people who use it. It follows the Keep a Changelog format: an `## [Unreleased]` section for what is not released yet, then one `## [<version>] - <date>` section per release, newest first, with its entries under `### Added`, `### Changed`, `### Removed`, and `### Fixed`. A breaking change goes under Changed or Removed, and raises the major version.
86
+
87
+ ```markdown
88
+ # Changelog
89
+
90
+ ## [Unreleased]
91
+
92
+ ## [1.4.0] - 2026-10-13
93
+
94
+ ### Added
95
+ - /rollback undoes the last deploy, including its database migration.
96
+
97
+ ### Removed
98
+ - /deploy-old. Use /deploy --legacy instead.
99
+ ```
100
+
101
+ - `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.
102
+ - 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
+ - A linked checkout whose version has no section yet shows its `[Unreleased]` section, so the notes show as soon as the version moves.
104
+ - `cmod new` writes the file with an `[Unreleased]` and a `[0.1.0]` section.
62
105
 
63
106
  ## Register the mod with Claude Code
64
107
 
@@ -91,89 +134,181 @@ export function register(addHook: On, options: PluginOptions): void {
91
134
  }
92
135
  ```
93
136
 
94
- Such a mod also declares `"approve": true` in its `package.json` `"cmod".permissions`, so the person grants it at install. `cmod check` adds the line when it is missing, and without the grant cmod drops every allow or ask the mod answers, so Claude Code decides those calls itself ([permissions.md](permissions.md)). A mod without `registerPermissionCheck` has no part in Claude Code's permission check, so the plugin directory reads it as one that never answers a permission. A mod that decides permissions without it does not start, and its progress line names the line to add.
137
+ Such a mod also declares `"approve": true` in its `package.json` `"cmod".permissions`, so the person grants it at install. `cmod check` adds the line when it is missing, and without the grant cmod drops every allow or ask the mod answers, so Claude Code decides those calls itself ([permissions.md](permissions.md)). A mod without `registerPermissionCheck` has no part in Claude Code's permission check, so the plugin directory reads it as one that never answers a permission. A mod that decides permissions without it does not start, and its error toast names the line to add.
95
138
 
96
139
  Keep `register.ts` this small. Claude Code checks a hooks module before it loads it, and refuses some shapes, such as a `$` passed to a function in another module. The plugin directory reads the bundled module too, and flags it when the name of `register`'s first parameter is declared anywhere else in the bundle. `@cmodjs/core` declares `on` as the method `mod.on`, so name that parameter `addHook`. Declare `register` as a function, as here: `cmod publish` bundles the hooks module into one file, and Claude Code refuses a bundled `register` that is not one.
97
140
 
98
- ## What `mod` can call
141
+ ## mod: this mod
99
142
 
100
- `setup` gets `mod`, of type `Mod<State, Options>`. Every hook, render, and job gets the same `mod`.
143
+ `setup` gets `mod`, of type `Mod<State, Options>`. Every hook, 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.
101
144
 
102
145
  ```ts
103
146
  type Mod<State, Options> = {
104
147
  readonly name: string
148
+ readonly folder: string
149
+ readonly dataFolder: string
105
150
  readonly state: Readonly<State>
106
151
  readonly options: OptionValues<Options>
107
- readonly dataFolder: string
108
- readonly projectRoot: string
109
- readonly cwd: string
110
- on(event, hook, options?): void
152
+ readonly permissions: Permissions
153
+ on(hook, listener, options?): Disposable
154
+ on(hook, matcher, listener, options?): Disposable
155
+ every(ms, listener): Disposable
156
+ after(ms, listener): Disposable
111
157
  use(job): Handle
112
- every(ms, hook): Timer
113
- readonly ui: { pane, render, toast, progress, ask, scroll }
114
- readonly process: { run, spawn }
115
- readonly fs: { read, write, list, exists, stat, find }
116
- readonly metadata: { read, update }
117
- readonly http: { fetch }
158
+ log(text): void
159
+ readonly ui: { pane, render, toast, ask, copy, scroll, notice }
118
160
  readonly settings: { page, open }
119
- readonly session: { messages, append, submit }
120
- readonly agent: { spawn }
121
- readonly model: { complete }
122
- readonly permissions: { has }
123
161
  readonly dependencies: CmodDependencies
124
- readonly claude: ModClaude
125
162
  }
126
163
  ```
127
164
 
128
165
  | Member | What it is | Docs |
129
166
  | --- | --- | --- |
130
167
  | `name` | The mod's name from `defineMod`. | |
168
+ | `folder` | The plugin's own folder, where the files the mod ships live. | |
169
+ | `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) |
131
170
  | `state` | The mod's values. Assign to a key to change it. | [state.md](state.md) |
132
171
  | `options` | The values a person set for the mod's options, typed from their declaration. Read-only. | [options.md](options.md) |
133
- | `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) |
134
- | `projectRoot` | The project the session works in. It follows `/cd`. | |
135
- | `cwd` | The session's working folder. It follows a `cd` in a Bash or PowerShell call, and `/cd`. | |
136
- | `on` | Adds a hook on a Claude Code event. | [hooks.md](hooks.md) |
172
+ | `permissions` | Says whether the person granted each permission. | [permissions.md](permissions.md) |
173
+ | `on` | Listens to a Claude Code hook, such as `PreToolUse`. | [hooks.md](hooks.md) |
174
+ | `every` and `after` | Run a listener on a timer while the session runs. | below |
137
175
  | `use` | Adds a job, such as a slash command or a tool, and returns its handle. | [jobs.md](jobs.md) |
138
- | `every` | Runs a function every so many milliseconds while the session runs. | below |
139
- | `ui` | Panes, slot renders, toasts, progress lines, questions, and scrolling. | [ui.md](ui.md) |
140
- | `process` | Runs a program. | below |
141
- | `fs` | Reads, writes, looks up, and finds files. | below |
142
- | `metadata` | Reads and changes the mod's keys in one file's metadata. | below |
143
- | `http` | Fetches a URL. | below |
176
+ | `log` | Writes a line, under the mod's name, to Claude Code's debug log. | below |
177
+ | `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) |
144
178
  | `settings` | Adds the mod's own pages to `/mods`, and opens them. | below |
145
- | `session` | Reads the conversation or a subagent's, adds a note Claude reads, and asks Claude for a turn. | below |
146
- | `agent` | Starts a subagent. | below |
147
- | `model` | Asks a model one question, outside the conversation. | below |
148
- | `permissions` | Says whether the person has a permission turned on. `process`, `fs.write`, `metadata.update`, `http`, `session`, `agent`, `model`, and `claude` each check theirs. | [permissions.md](permissions.md) |
149
179
  | `dependencies` | Calls the methods of other mods. | [dependencies.md](dependencies.md) |
150
- | `claude` | Claude Code's own calls and events, for what `mod` has no member for. | [jobs.md](jobs.md#the-claude-members) |
151
180
 
152
- ### mod.every
181
+ 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`:
182
+
183
+ ```ts
184
+ import { fs, http, metadata, model, process, sessions, settings } from '../node_modules/@cmodjs/core/mod.js'
185
+ ```
186
+
187
+ | Import | What it is | Docs |
188
+ | --- | --- | --- |
189
+ | `sessions` | The conversations: the one the mod runs in, every other one, and search across them. | [session.md](session.md) |
190
+ | `settings` | Claude Code's `settings.json` files: the person's, the project's, and the project's local one. | 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 |
196
+
197
+ 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
+
199
+ ### Disposable
200
+
201
+ `mod.on`, `mod.every`, `mod.after`, `mod.ui.render`, and a session's `on` 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`.
202
+
203
+ ```ts
204
+ type Disposable = { dispose(): void }
205
+ ```
206
+
207
+ ```ts
208
+ const watching = mod.on(PostToolUse, { tool_name: 'Edit' }, () => void (mod.state.session.edits += 1))
209
+ mod.use(slashCommand({ name: 'stop-counting', description: 'Stop counting edits', reply: () => watching.dispose() }))
210
+ ```
211
+
212
+ ### mod.every and mod.after
153
213
 
154
214
  ```ts
155
- mod.every(ms: number, hook: () => unknown): Timer
215
+ mod.every(ms: number, listener: () => unknown): Disposable
216
+ mod.after(ms: number, listener: () => unknown): Disposable
156
217
  ```
157
218
 
158
- `every` runs `hook` every `ms` milliseconds until the session ends or the mod calls `cancel()` on the `Timer` it returns. A tick that comes while the last run is still going is skipped, so a slow run never stacks up. A run that throws logs `<mod>: the every <ms> ms hook failed: <error>`, and the next tick runs again. `ms` is a whole number above 0 and at most `2147483647`, and any other `ms` stops `setup` with the fix.
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.`
159
220
 
160
221
  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)).
161
222
 
162
223
  ```ts
163
224
  mod.every(60_000, async () => {
164
- const { stdout } = await mod.process.run(['gh', 'run', 'list', '--limit', '1', '--json', 'conclusion'])
225
+ const { stdout } = await process.run(['gh', 'run', 'list', '--limit', '1', '--json', 'conclusion'])
165
226
  mod.state.session.ci = JSON.parse(stdout)[0]?.conclusion ?? 'running'
166
227
  })
167
228
  ```
168
229
 
169
- ### mod.process
230
+ ### mod.log
231
+
232
+ ```ts
233
+ mod.log(text: string): void
234
+ ```
235
+
236
+ `log` writes `<mod>: <text>` to Claude Code's debug log, which `claude --debug` shows. It is for what helps the mod's author find a fault and the person never needs to see. Tell the person with a toast instead ([ui.md](ui.md#toasts)).
237
+
238
+ ### mod.settings
239
+
240
+ ```ts
241
+ mod.settings.page(page: Pane<State>): void
242
+ mod.settings.open(pageId?: string): Promise<void>
243
+ ```
244
+
245
+ `/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.
246
+
247
+ ```tsx
248
+ mod.settings.page(definePane({ id: 'runs', title: 'Runs', render: (current) => <Text>{current.state.session.lastRun}</Text> }))
249
+ mod.use(slashCommand({ name: 'ci-runs', description: 'Show the CI runs', reply: () => void mod.settings.open('runs') }))
250
+ ```
251
+
252
+ ## settings: Claude Code's settings files
170
253
 
171
254
  ```ts
172
- mod.process.run(argv: readonly string[], init?: ProcessRunInit): Promise<ProcessRunResult>
173
- mod.process.spawn(argv: readonly string[], init?: { cwd?, env?, input? }): HookStream<ProcessSpawnChunk, ProcessSpawnResult>
255
+ settings.list(): Promise<Setting[]>
256
+ settings.user.read(): Promise<Settings>
257
+ settings.user.update(values: Readonly<Record<string, unknown>>): Promise<void>
258
+ settings.project.read(): Promise<Settings>
259
+ settings.project.update(values: Readonly<Record<string, unknown>>): Promise<void>
260
+ settings.local.read(): Promise<Settings>
261
+ settings.local.update(values: Readonly<Record<string, unknown>>): Promise<void>
174
262
  ```
175
263
 
176
- Both run a program without a shell. `argv[0]` is the program and the rest are its arguments. A bare name that cmod installed, such as `trace`, runs from cmod's programs folder ([install-steps.md](install-steps.md#run-a-program-by-its-name)), and any other name runs through Claude Code's `PATH`.
264
+ `settings.list` answers every row of Claude Code's `/config` menu, its own and each plugin's, with the value each holds now:
265
+
266
+ ```ts
267
+ type Setting = { key: string; label: string; description?: string; kind: 'boolean' | 'choice' | 'text' | 'number'; value: boolean | string | number | readonly string[]; options?: readonly string[]; plugin?: string; isLocked: boolean }
268
+ ```
269
+
270
+ `plugin` names the plugin a row belongs to, and Claude Code's own rows have none. `isLocked` is true when a managed setting or the organization's policy holds the value.
271
+
272
+ `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`.
273
+
274
+ - `read` answers the values in that one file, such as `(await settings.user.read())['enabledPlugins']`.
275
+ - `update` writes each top-level key of `values` into that file. `mcpServers` in `settings.project` goes to the project's `.mcp.json`, where Claude Code reads a project's servers.
276
+ - A key replaces its whole value, and `null` removes the key. Every other key, and the file's formatting, stays as it was.
277
+ - Each key needs its own `config` permission, such as `"config": ["cleanupPeriodDays"]` ([permissions.md](permissions.md)), and `update` rejects with the line to add for a key without one.
278
+ - Removing the mod takes out each value it set that still equals what it set, so a value the person changed since stays.
279
+ - `fs.write` to a Claude Code settings file rejects and names `update`, because a whole-file write loses the person's own edits and leaves the mod's values behind when it is removed.
280
+
281
+ ```ts
282
+ await settings.user.update({ cleanupPeriodDays: 365 })
283
+ ```
284
+
285
+ ## model: one question outside the conversation
286
+
287
+ ```ts
288
+ model.complete(request: ModelCompleteRequest, options?: { signal?: AbortSignal }): Promise<ModelCompleteResult>
289
+ ```
290
+
291
+ `complete` asks a model one question through Claude Code's own login, outside the conversation, so the answer costs no context and the person sees no turn. The request names the `model`, such as `'haiku'`, and the `prompt`, and takes `system`, `maxTokens`, `effort`, and `timeoutMs`. `timeoutMs` stops a model that is slow or down, so the listener that waits on it still answers in time. `options.signal` stops the call when its `AbortController` aborts. It needs the `model` permission.
292
+
293
+ The call never rejects for what the model did. The result's `isAnswered` is true with the reply's `text`, or false with a `reason`, such as `'api-error'` or `'aborted'`.
294
+
295
+ Put a limit on the listener too, with `mod.on`'s `timeoutMs` ([hooks.md](hooks.md#mod-on)), which also covers the work around the call:
296
+
297
+ ```ts
298
+ mod.on(Stop, async ({ last_assistant_message }) => {
299
+ const verdict = await model.complete({ model: 'haiku', prompt: `Does this reply ask the person to do work Claude could do itself? Answer yes or no.\n\n${last_assistant_message}`, timeoutMs: 8000 })
300
+ if (verdict.isAnswered && verdict.text.trim().toLowerCase().startsWith('yes')) return { decision: 'block', reason: 'Do that work yourself, then reply.' }
301
+ }, { timeoutMs: 9000 })
302
+ ```
303
+
304
+ ## process: run a program
305
+
306
+ ```ts
307
+ process.run(argv: readonly string[], init?: ProcessRunInit): Promise<ProcessRunResult>
308
+ process.spawn(argv: readonly string[], init?: { cwd?, env?, input? }): HookStream<ProcessSpawnChunk, ProcessSpawnResult>
309
+ ```
310
+
311
+ Both run a program without a shell. `argv[0]` is the program and the rest are its arguments. A bare name that cmod installed, such as `trace`, runs from cmod's programs folder ([install-steps.md](install-steps.md#run-a-program-by-its-name)), and any other name runs through Claude Code's `PATH`. Each program needs its `run` permission.
177
312
 
178
313
  - `init.cwd` is the program's folder, absolute or relative to the session's working folder. Without it the program runs in the session's working folder.
179
314
  - `init.env` sets variables over Claude Code's own environment.
@@ -182,44 +317,49 @@ Both run a program without a shell. `argv[0]` is the program and the rest are it
182
317
  - `run` resolves `{ exitCode, stdout, stderr, isStdoutTruncated, isStderrTruncated }` once the program exits, whatever its exit code. It keeps the first 4 MiB of each stream. A program ended by a signal has `exitCode` 1.
183
318
  - `run` rejects when the program cannot start, and when it is still running at `timeoutMs`. It reads the whole output, so a background process the program leaves writing holds the call until the timeout.
184
319
  - `spawn` streams `{ stream: 'stdout' | 'stderr', text }` pieces as the program writes them. Its `result` resolves `{ code, signal }` once the stream is read to its end. Its first pull rejects when the program cannot start.
185
- - The `for await` loop over a `spawn` is the program's life. Leaving the loop, or calling `return()` on the stream, kills the program, and so does unloading the mod. Returning from the hook does not.
320
+ - The `for await` loop over a `spawn` is the program's life. Leaving the loop, or calling `return()` on the stream, kills the program, and so does unloading the mod. Returning from the listener does not.
186
321
 
187
- Inside a job, cmod adds the job's deadline to each call ([jobs.md](jobs.md)).
322
+ A job's deadline bounds the whole callback that makes these calls ([jobs.md](jobs.md#deadlines)).
188
323
 
189
324
  ```ts
190
- import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
325
+ import { defineMod, process, SessionStart, sessions } from '../node_modules/@cmodjs/core/mod.js'
191
326
 
192
327
  export const branchNote = defineMod({
193
328
  name: 'branch-note',
194
329
  setup(mod) {
195
- mod.on('SessionStart', async () => {
196
- const { exitCode, stdout } = await mod.process.run(['git', 'branch', '--show-current'], { cwd: mod.projectRoot })
330
+ mod.on(SessionStart, async () => {
331
+ const { exitCode, stdout } = await process.run(['git', 'branch', '--show-current'], { cwd: sessions.current.project })
197
332
  if (exitCode !== 0) return
198
- return { hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: `The git branch is ${stdout.trim()}.` } }
333
+ return { hookSpecificOutput: { hookEventName: SessionStart, additionalContext: `The git branch is ${stdout.trim()}.` } }
199
334
  })
200
335
  },
201
336
  })
202
337
  ```
203
338
 
204
- ### mod.fs
339
+ ## fs: files
205
340
 
206
341
  ```ts
207
- mod.fs.read(path: string): Promise<string>
208
- mod.fs.write(path: string, text: string): Promise<void>
209
- mod.fs.list(path?: string): Promise<FsEntry[]>
210
- mod.fs.exists(path: string): Promise<boolean>
211
- mod.fs.stat(path: string, options?: { resolve: boolean }): Promise<FsStat>
212
- mod.fs.find(glob: string | readonly string[]): Promise<readonly FileMatch[]>
342
+ fs.read(path: string): Promise<string>
343
+ fs.write(path: string, text: string): Promise<void>
344
+ fs.list(path?: string): Promise<Entry[]>
345
+ fs.exists(path: string): Promise<boolean>
346
+ fs.stat(path: string, options?: { resolve: boolean }): Promise<Stat>
347
+ fs.find(glob: string | readonly string[]): Promise<readonly FileMatch[]>
213
348
  ```
214
349
 
215
- - A relative path is under the session's working folder. An absolute path is used as given. Text is UTF-8.
350
+ ```ts
351
+ type Entry = { name: string; isFile: boolean; isFolder: boolean; isLink: boolean; size: number; modifiedAt: number }
352
+ type Stat = { isFile: boolean; isFolder: boolean; isLink: boolean; size: number; modifiedAt: number; realPath?: string }
353
+ ```
354
+
355
+ - A relative path is under the session's working folder. An absolute path is used as given. `~/.claude` and `$CLAUDE_CONFIG_DIR` are Claude Code's config folder, and `~` and `$HOME` are the home folder. Text is UTF-8.
216
356
  - `read` rejects a missing file and a file over 4 MiB.
217
- - `write` writes the whole file and creates its folders.
218
- - `list` lists a folder, the working folder without a path. Each entry is `{ name, kind, size, mtimeMs, isLink }`, and `kind` is `'file'`, `'dir'`, or `'other'`. A symbolic link is `'other'` with `isLink` true.
357
+ - `write` writes the whole file and creates its folders. Writing outside the project and the mod's data folder needs the `files` permission.
358
+ - `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.
219
359
  - `exists` answers whether the path leads to a file or a folder.
220
- - `stat` answers `{ kind, size, mtimeMs, isLink }` of 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.
360
+ - `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.
221
361
 
222
- A file the mod ships is under the plugin's folder, `mod.claude.plugin.root` ([jobs.md](jobs.md#the-claude-members)). A file the mod keeps for itself belongs in `mod.dataFolder`.
362
+ A file the mod ships is under `mod.folder`. A file the mod keeps for itself belongs in `mod.dataFolder`.
223
363
 
224
364
  `find` answers every file a glob matches, each with its metadata, so a mod finds the Skills, agents, scripts, or notes it acts on in one call:
225
365
 
@@ -227,27 +367,28 @@ A file the mod ships is under the plugin's folder, `mod.claude.plugin.root` ([jo
227
367
  type FileMatch = { readonly path: string; readonly name?: string; readonly metadata: Readonly<Record<string, string>>; readonly error?: string }
228
368
  ```
229
369
 
230
- - A glob starting with `~/.claude` starts at Claude Code's config folder, `CLAUDE_CONFIG_DIR` when it is set. One starting with `~/` starts at home, one starting with `/` is absolute, and any other starts at the project root. Several globs answer each file once.
231
- - `path` is absolute. `name` is a Markdown file's frontmatter `name`. `metadata` holds the mod's own keys, bare, as `mod.metadata.read` answers them.
370
+ - A glob starting with `~/.claude` starts at Claude Code's config folder, `CLAUDE_CONFIG_DIR` when it is set. One starting with `~/` starts at home, one starting with `/` is absolute, and any other starts at the project. Several globs answer each file once.
371
+ - `path` is absolute. `name` is a Markdown file's frontmatter `name`. `metadata` holds the mod's own keys, bare, as `metadata.read` answers them.
232
372
  - `find` follows symbolic links into the folders they point at, once each. It skips `.git` and `node_modules` unless the glob names them.
233
- - A file whose metadata cmod cannot read comes back with empty `metadata` and an `error` naming the line, and cmod logs it once.
373
+ - A file whose metadata cmod cannot read comes back with empty `metadata` and an `error` naming the line, and cmod logs it once. A file cmod cannot open comes back the same way, with an `error` naming the reason.
374
+ - A folder cmod cannot list, such as one it has no permission to read, is left out, and cmod writes `<mod>: fs.find skipped <folder>: <reason>` to Claude Code's debug log once.
234
375
  - cmod keeps what it read of each file until the file's modified time or size changes.
235
376
 
236
377
  ```ts
237
- const skills = await mod.fs.find(['~/.claude/skills/*/SKILL.md', '.claude/skills/*/SKILL.md'])
378
+ const skills = await fs.find(['~/.claude/skills/*/SKILL.md', '.claude/skills/*/SKILL.md'])
238
379
  const planning = skills.filter((skill) => skill.metadata['modes']?.split(' ').includes('plan'))
239
380
  ```
240
381
 
241
- ### mod.metadata
382
+ ## metadata: a file's own keys
242
383
 
243
384
  ```ts
244
- mod.metadata.read(path: string): Promise<Readonly<Record<string, string>>>
245
- mod.metadata.update(path: string, change: (metadata: Record<string, string>) => void): Promise<void>
385
+ metadata.read(path: string): Promise<Readonly<Record<string, string>>>
386
+ metadata.update(path: string, change: (metadata: Record<string, string>) => void): Promise<void>
246
387
  ```
247
388
 
248
389
  Metadata is a file's own set of string keys, kept in the file, so it moves and is committed with the file. Each mod reads and writes its own keys only: the file stores them as `<mod>.<key>`, and the mod sees them bare. A value is one line of text, and a list is one space-separated string.
249
390
 
250
- Where a file keeps its metadata depends on its kind:
391
+ Where a file keeps its metadata depends on its type:
251
392
 
252
393
  ```markdown
253
394
  ---
@@ -268,116 +409,27 @@ metadata:
268
409
  - A `.sh`, `.py`, `.ts`, `.js`, `.yaml`, or `.toml` file keeps it in a `/// metadata` comment block after any shebang, in the file's own comment, `#` or `//`, the way PEP 723 keeps a Python script's dependencies.
269
410
  - Any other file keeps it in a sidecar, `<file>.meta`.
270
411
 
271
- `update` hands `change` the mod's current keys, and writes what `change` leaves. It changes only the metadata block, keeps other mods' keys, writes nothing when nothing changed, and removes an empty block. It writes through a symbolic link to the file it points at. Writing a file outside the project and the mod's data folder needs the `files` permission, as `mod.fs.write` does ([permissions.md](permissions.md)). A key is letters, digits, `_`, `.`, and `-`, and `update` rejects any other key and a value with a line break.
412
+ `update` hands `change` the mod's current keys, and writes what `change` leaves. It changes only the metadata block, keeps other mods' keys, writes nothing when nothing changed, and removes an empty block. It writes through a symbolic link to the file it points at. Writing a file outside the project and the mod's data folder needs the `files` permission, as `fs.write` does ([permissions.md](permissions.md)). A key is letters, digits, `_`, `.`, and `-`, and `update` rejects any other key and a value with a line break.
413
+
414
+ `read` rejects when cmod cannot open the file or read its metadata, with the reason or the line. `update` rejects the same way and writes nothing, so it never overwrites metadata it could not read.
272
415
 
273
416
  ```ts
274
- await mod.metadata.update('.claude/skills/plan/SKILL.md', (metadata) => {
275
- metadata['modes'] = 'plan build'
417
+ await metadata.update('.claude/skills/plan/SKILL.md', (keys) => {
418
+ keys['modes'] = 'plan build'
276
419
  })
277
420
  ```
278
421
 
279
422
  `cmod check` reads the metadata of every file the mod ships, and fails one cmod cannot read.
280
423
 
281
- ### mod.http
424
+ ## http: fetch a URL
282
425
 
283
426
  ```ts
284
- mod.http.fetch(url: string, init?: HttpInit): Promise<HttpResponse>
427
+ http.fetch(url: string, init?: HttpInit): Promise<HttpResponse>
285
428
  ```
286
429
 
287
430
  - `init` takes `method` (`GET` by default), `headers`, `body` as text, and `socketPath`, the absolute path of a Unix socket the request goes over.
288
431
  - The response is `{ status, ok, headers, text }`. `ok` is true for a 2xx status. Header names are lower case.
289
-
290
- ### mod.settings
291
-
292
- ```ts
293
- mod.settings.page(page: Pane<State>): void
294
- mod.settings.open(pageId?: string): Promise<void>
295
- ```
296
-
297
- `/mods` shows each mod's description, options, permissions, and keys, 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)). `open` opens that page, or with no id opens the mod's entry in `/mods`. `open` rejects for an id `page` did not add.
298
-
299
- ```tsx
300
- mod.settings.page(definePane({ id: 'runs', title: 'Runs', render: (current) => <Text>{current.state.session.lastRun}</Text> }))
301
- mod.use(slashCommand({ name: 'ci-runs', description: 'Show the CI runs', reply: () => void mod.settings.open('runs') }))
302
- ```
303
-
304
- Claude Code's own `settings.json` files are read with `mod.claude.settings.read`, such as `(await mod.claude.settings.read({ source: 'user' }))['enabledPlugins']` ([jobs.md](jobs.md#the-claude-members)).
305
-
306
- ### mod.session
307
-
308
- ```ts
309
- mod.session.messages(): Promise<SessionMessage[]>
310
- mod.session.messages(args: { agentId: string }): Promise<SessionMessage[] | { deny: string }>
311
- mod.session.append(text: string): Promise<void>
312
- mod.session.submit(text: string): Promise<void>
313
- ```
314
-
315
- `messages` reads the main conversation, one `{ role, text, toolUses, toolResults }` entry per message. A message has no id of its own, and each tool use carries its `tool_use_id`. `{ agentId }` reads that subagent's conversation instead, the id a `SubagentStart` or `SubagentStop` hook gets as `agent_id`, and answers `{ deny }` when the session cannot read it, so `Array.isArray` tells the two apart. The Messages API form, `{ role, content }` with the content blocks whole, is `mod.claude.session.messages({ as: 'api' })`.
316
-
317
- ```ts
318
- mod.on('SubagentStop', async ({ agent_id }) => {
319
- const read = await mod.session.messages({ agentId: agent_id })
320
- if (Array.isArray(read)) mod.ui.toast(read.at(-1)?.text ?? '')
321
- })
322
- ```
323
-
324
- `append` adds `text` to the conversation as a user message the person does not see, so Claude reads it on its next call to the model. It starts no turn. It rejects with `<mod>: Claude Code refused the note: <reason>` when a plugin's `session.append` handler refuses it.
325
-
326
- `submit` sends `text` as the person's next prompt, so Claude answers it as a turn of its own. Claude Code holds it until the session is idle, so it never cuts into a turn, and shows it under `The <mod> plugin sent a message:`. It rejects with `<mod>: Claude Code dropped the prompt: <reason>` when a plugin drops it. A prompt that comes on a clock, such as every 10 minutes, belongs in a `schedule` job instead, which Claude Code shows as one `Running scheduled task` row ([jobs.md](jobs.md#schedule)).
327
-
328
- ```ts
329
- mod.every(60_000, async () => {
330
- const { stdout } = await mod.process.run(['gh', 'run', 'list', '--limit', '1', '--json', 'conclusion'])
331
- if (JSON.parse(stdout)[0]?.conclusion !== 'failure') return
332
- await mod.session.append('CI failed on the last push.')
333
- await mod.session.submit('Find the cause of the CI failure and fix it.')
334
- })
335
- ```
336
-
337
- ### mod.agent
338
-
339
- ```ts
340
- mod.agent.spawn(args: { prompt: string; description?; subagentType?; model?; name?; cwd? }): Promise<AgentSpawnResult>
341
- ```
342
-
343
- `spawn` starts a subagent in the background, the way the Agent tool starts one, and resolves `{ agentId, model }` once it started, or `{ deny }` when a hook refused it. It does not wait for the subagent to finish. `subagentType` names an agent type, such as `explorer`. `mod.session.messages({ agentId })` reads the subagent's conversation so far.
344
-
345
- The subagent's answer arrives on the mod's `SubagentStop` hook once it finishes: the input's `agent_id` is the `agentId` that `spawn` resolved, and its `last_assistant_message` is the subagent's final reply. A hook may wait for it, as this one does:
346
-
347
- ```ts
348
- const answers = new Map<string, (answer: string) => void>()
349
-
350
- mod.on('SubagentStop', (input) => {
351
- answers.get(input.agent_id)?.(input.last_assistant_message ?? '')
352
- })
353
-
354
- mod.on('UserPromptSubmit', async (input) => {
355
- if (!input.prompt.startsWith('/summarize')) return
356
- const spawned = await mod.agent.spawn({ prompt: 'Summarize README.md in one line.', subagentType: 'Explore' })
357
- if (spawned.agentId === undefined) return
358
- const summary = await new Promise<string>((resolve) => answers.set(spawned.agentId as string, resolve))
359
- return { hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: summary } }
360
- })
361
- ```
362
-
363
- ### mod.model
364
-
365
- ```ts
366
- mod.model.complete(request: ModelCompleteRequest, options?: { signal?: AbortSignal }): Promise<ModelCompleteResult>
367
- ```
368
-
369
- `complete` asks a model one question through Claude Code's own login, outside the conversation, so the answer costs no context and the person sees no turn. The request names the `model`, such as `'haiku'`, and the `prompt`, and takes `system`, `maxTokens`, `effort`, and `timeoutMs`. `timeoutMs` stops a model that is slow or down, so the hook that waits on it still answers in time. `options.signal` stops the call when its `AbortController` aborts.
370
-
371
- The call never rejects for what the model did. The result's `isAnswered` is true with the reply's `text`, or false with a `reason`, such as `'api-error'` or `'aborted'`.
372
-
373
- Put a limit on the hook too, with `mod.on`'s `timeoutMs` ([hooks.md](hooks.md#mod-on)), which also covers the work around the call:
374
-
375
- ```ts
376
- mod.on('Stop', async ({ last_assistant_message }) => {
377
- const verdict = await mod.model.complete({ model: 'haiku', prompt: `Does this reply ask the person to do work Claude could do itself? Answer yes or no.\n\n${last_assistant_message}`, timeoutMs: 8000 })
378
- if (verdict.isAnswered && verdict.text.trim().toLowerCase().startsWith('yes')) return { decision: 'block', reason: 'Do that work yourself, then reply.' }
379
- }, { timeoutMs: 9000 })
380
- ```
432
+ - Each host needs its `network` permission. A fetch to the address the mod's own `program` job answers on needs none, while that program runs ([jobs.md](jobs.md#program)).
381
433
 
382
434
  ## messageOf
383
435
 
@@ -388,14 +440,14 @@ messageOf(error: unknown): string
388
440
  Returns an `Error`'s `message`, or the value as text. Use it in a `catch`, where the error is `unknown`.
389
441
 
390
442
  ```ts
391
- import { defineMod, messageOf } from '../node_modules/@cmodjs/core/mod.js'
443
+ import { defineMod, fs, messageOf, Stop } from '../node_modules/@cmodjs/core/mod.js'
392
444
 
393
445
  export const notes = defineMod({
394
446
  name: 'notes',
395
447
  setup(mod) {
396
- mod.on('Stop', async () => {
397
- await mod.fs.write(`${mod.dataFolder}/last-stop.txt`, new Date().toISOString()).catch((error: unknown) => {
398
- mod.ui.toast(`notes could not save: ${messageOf(error)}`)
448
+ mod.on(Stop, async () => {
449
+ await fs.write(`${mod.dataFolder}/last-stop.txt`, new Date().toISOString()).catch((error: unknown) => {
450
+ mod.ui.toast.error('Could not save the last stop', { description: messageOf(error) })
399
451
  })
400
452
  })
401
453
  },
package/docs/options.md CHANGED
@@ -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 progress line 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 `Set it in /config.`, and Claude Code reloads the mod once the value is set.
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
 
@@ -89,5 +89,5 @@ Claude Code reads a plugin's options from `userConfig` in `.claude-plugin/plugin
89
89
  ```ts
90
90
  const tested = testMod(ciWatch, { options: { githubToken: 'ghp_test', branch: 'develop' } })
91
91
  await tested.start()
92
- expect(tested.shown.toasts).toContain('Watching develop every 10 minutes')
92
+ expect(tested.shown.toasts).toContainEqual({ type: 'default', message: 'Watching develop every 10 minutes' })
93
93
  ```