@cmodjs/core 0.3.0 → 1.0.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.
- package/docs/commands.md +50 -10
- package/docs/dependencies.md +12 -12
- package/docs/hooks.md +39 -27
- package/docs/index.md +29 -25
- package/docs/install-steps.md +9 -7
- package/docs/jobs.md +59 -111
- package/docs/mod.md +200 -174
- package/docs/options.md +2 -2
- package/docs/permissions.md +27 -16
- package/docs/session.md +248 -0
- package/docs/state.md +11 -7
- package/docs/testing.md +65 -22
- package/docs/ui.md +108 -76
- package/jobs/check.d.ts.map +1 -1
- package/jobs/check.js +5 -2
- package/jobs/context.d.ts +7 -8
- package/jobs/context.d.ts.map +1 -1
- package/jobs/context.js +23 -73
- package/jobs/permissions/decide-permission.d.ts +5 -3
- package/jobs/permissions/decide-permission.d.ts.map +1 -1
- package/jobs/permissions/decide-permission.js +6 -4
- package/jobs/permissions/find-project-scope.d.ts +1 -1
- package/jobs/permissions/find-project-scope.d.ts.map +1 -1
- package/jobs/permissions/find-project-scope.js +8 -11
- package/jobs/permissions/match-target.d.ts +2 -2
- package/jobs/permissions/match-target.d.ts.map +1 -1
- package/jobs/permissions.d.ts +1 -1
- package/jobs/permissions.d.ts.map +1 -1
- package/jobs/permissions.js +6 -4
- package/jobs/program.d.ts.map +1 -1
- package/jobs/program.js +8 -3
- package/jobs/prompt.d.ts +4 -2
- package/jobs/prompt.d.ts.map +1 -1
- package/jobs/prompt.js +16 -15
- package/jobs/schedule.d.ts +2 -2
- package/jobs/schedule.d.ts.map +1 -1
- package/jobs/schedule.js +9 -9
- package/jobs/slash-command.d.ts +4 -4
- package/jobs/slash-command.d.ts.map +1 -1
- package/jobs/slash-command.js +14 -9
- package/jobs/status-line.d.ts +1 -11
- package/jobs/status-line.d.ts.map +1 -1
- package/jobs/status-line.js +8 -12
- package/jobs/tool.d.ts +2 -2
- package/jobs/tool.d.ts.map +1 -1
- package/jobs/tool.js +15 -9
- package/mod.d.ts +154 -75
- package/mod.d.ts.map +1 -1
- package/mod.js +94 -3
- package/options.d.ts +2 -2
- package/options.js +19 -19
- package/package.json +3 -3
- package/records.d.ts +10 -8
- package/records.d.ts.map +1 -1
- package/records.js +28 -17
- package/register.d.ts.map +1 -1
- package/register.js +17 -5
- package/runtime/announce.d.ts +18 -0
- package/runtime/announce.d.ts.map +1 -0
- package/runtime/announce.js +66 -0
- package/runtime/bound.d.ts +4 -0
- package/runtime/bound.d.ts.map +1 -0
- package/runtime/bound.js +9 -0
- package/runtime/claude.d.ts +3 -2
- package/runtime/claude.d.ts.map +1 -1
- package/runtime/conversations.d.ts +16 -0
- package/runtime/conversations.d.ts.map +1 -0
- package/runtime/conversations.js +291 -0
- package/runtime/deadline.d.ts +0 -1
- package/runtime/deadline.d.ts.map +1 -1
- package/runtime/deadline.js +1 -1
- package/runtime/dependencies.d.ts +2 -0
- package/runtime/dependencies.d.ts.map +1 -1
- package/runtime/dependencies.js +9 -0
- package/runtime/engine.d.ts +17 -0
- package/runtime/engine.d.ts.map +1 -0
- package/runtime/engine.js +10 -0
- package/runtime/grants.d.ts +4 -0
- package/runtime/grants.d.ts.map +1 -1
- package/runtime/grants.js +63 -26
- package/runtime/hooks.d.ts +5 -4
- package/runtime/hooks.d.ts.map +1 -1
- package/runtime/hooks.js +10 -5
- package/runtime/installer.d.ts.map +1 -1
- package/runtime/installer.js +11 -9
- package/runtime/lifecycle.d.ts +3 -1
- package/runtime/lifecycle.d.ts.map +1 -1
- package/runtime/lifecycle.js +316 -185
- package/runtime/metadata.d.ts +3 -1
- package/runtime/metadata.d.ts.map +1 -1
- package/runtime/metadata.js +21 -11
- package/runtime/options.d.ts +3 -1
- package/runtime/options.d.ts.map +1 -1
- package/runtime/options.js +4 -4
- package/runtime/programs.d.ts +8 -0
- package/runtime/programs.d.ts.map +1 -1
- package/runtime/programs.js +39 -1
- package/runtime/router.d.ts +2 -1
- package/runtime/router.d.ts.map +1 -1
- package/runtime/router.js +11 -3
- package/runtime/sessions.d.ts +36 -0
- package/runtime/sessions.d.ts.map +1 -0
- package/runtime/sessions.js +235 -0
- package/runtime/state.d.ts +6 -2
- package/runtime/state.d.ts.map +1 -1
- package/runtime/state.js +46 -13
- package/runtime/toaster.d.ts +31 -0
- package/runtime/toaster.d.ts.map +1 -0
- package/runtime/toaster.js +176 -0
- package/runtime/ui.d.ts +6 -17
- package/runtime/ui.d.ts.map +1 -1
- package/runtime/ui.js +36 -112
- package/session/context/context.d.ts +10 -0
- package/session/context/context.d.ts.map +1 -0
- package/session/context/context.js +2 -0
- package/session/context/turn/message/document.d.ts +7 -0
- package/session/context/turn/message/document.d.ts.map +1 -0
- package/session/context/turn/message/document.js +1 -0
- package/session/context/turn/message/message.d.ts +54 -0
- package/session/context/turn/message/message.d.ts.map +1 -0
- package/session/context/turn/message/message.js +137 -0
- package/session/context/turn/message/note.d.ts +8 -0
- package/session/context/turn/message/note.d.ts.map +1 -0
- package/session/context/turn/message/note.js +1 -0
- package/session/context/turn/message/notice.d.ts +6 -0
- package/session/context/turn/message/notice.d.ts.map +1 -0
- package/session/context/turn/message/notice.js +1 -0
- package/session/context/turn/message/prompt.d.ts +8 -0
- package/session/context/turn/message/prompt.d.ts.map +1 -0
- package/session/context/turn/message/prompt.js +1 -0
- package/session/context/turn/message/reply.d.ts +6 -0
- package/session/context/turn/message/reply.d.ts.map +1 -0
- package/session/context/turn/message/reply.js +1 -0
- package/session/context/turn/message/sender.d.ts +14 -0
- package/session/context/turn/message/sender.d.ts.map +1 -0
- package/session/context/turn/message/sender.js +30 -0
- package/session/context/turn/message/skill.d.ts +7 -0
- package/session/context/turn/message/skill.d.ts.map +1 -0
- package/session/context/turn/message/skill.js +1 -0
- package/session/context/turn/message/tool-call.d.ts +9 -0
- package/session/context/turn/message/tool-call.d.ts.map +1 -0
- package/session/context/turn/message/tool-call.js +1 -0
- package/session/context/turn/message/tool-result.d.ts +8 -0
- package/session/context/turn/message/tool-result.d.ts.map +1 -0
- package/session/context/turn/message/tool-result.js +1 -0
- package/session/context/turn/turn.d.ts +12 -0
- package/session/context/turn/turn.d.ts.map +1 -0
- package/session/context/turn/turn.js +2 -0
- package/session/conversation.d.ts +64 -0
- package/session/conversation.d.ts.map +1 -0
- package/session/conversation.js +123 -0
- package/session/goal/goal.d.ts +10 -0
- package/session/goal/goal.d.ts.map +1 -0
- package/session/goal/goal.js +1 -0
- package/session/goal/task.d.ts +16 -0
- package/session/goal/task.d.ts.map +1 -0
- package/session/goal/task.js +3 -0
- package/session/session.d.ts +99 -0
- package/session/session.d.ts.map +1 -0
- package/session/session.js +1 -0
- package/src/jobs/check.ts +7 -4
- package/src/jobs/context.ts +24 -82
- package/src/jobs/permissions/decide-permission.ts +10 -8
- package/src/jobs/permissions/find-project-scope.ts +8 -14
- package/src/jobs/permissions/match-target.ts +3 -3
- package/src/jobs/permissions.ts +8 -6
- package/src/jobs/program.ts +7 -3
- package/src/jobs/prompt.ts +21 -24
- package/src/jobs/schedule.ts +14 -14
- package/src/jobs/slash-command.ts +20 -14
- package/src/jobs/status-line.ts +9 -21
- package/src/jobs/tool.ts +17 -11
- package/src/mod.ts +209 -97
- package/src/options.ts +22 -22
- package/src/records.ts +35 -23
- package/src/register.ts +17 -5
- package/src/runtime/announce.ts +74 -0
- package/src/runtime/bound.ts +12 -0
- package/src/runtime/claude.ts +4 -2
- package/src/runtime/conversations.ts +302 -0
- package/src/runtime/deadline.ts +2 -2
- package/src/runtime/dependencies.ts +12 -0
- package/src/runtime/engine.ts +22 -0
- package/src/runtime/grants.ts +66 -24
- package/src/runtime/hooks.ts +19 -10
- package/src/runtime/installer.ts +10 -9
- package/src/runtime/lifecycle.ts +312 -180
- package/src/runtime/metadata.ts +33 -11
- package/src/runtime/options.ts +19 -4
- package/src/runtime/programs.ts +40 -1
- package/src/runtime/router.ts +11 -4
- package/src/runtime/sessions.ts +242 -0
- package/src/runtime/state.ts +50 -14
- package/src/runtime/toaster.ts +218 -0
- package/src/runtime/ui.ts +42 -139
- package/src/session/context/context.ts +12 -0
- package/src/session/context/turn/message/document.ts +8 -0
- package/src/session/context/turn/message/message.ts +165 -0
- package/src/session/context/turn/message/note.ts +9 -0
- package/src/session/context/turn/message/notice.ts +7 -0
- package/src/session/context/turn/message/prompt.ts +9 -0
- package/src/session/context/turn/message/reply.ts +7 -0
- package/src/session/context/turn/message/sender.ts +38 -0
- package/src/session/context/turn/message/skill.ts +8 -0
- package/src/session/context/turn/message/tool-call.ts +10 -0
- package/src/session/context/turn/message/tool-result.ts +9 -0
- package/src/session/context/turn/turn.ts +14 -0
- package/src/session/conversation.ts +169 -0
- package/src/session/goal/goal.ts +9 -0
- package/src/session/goal/task.ts +19 -0
- package/src/session/session.ts +94 -0
- package/src/testing/fake-claude.ts +22 -16
- package/src/testing/fake-elements.ts +3 -2
- package/src/testing/fake-files.ts +1 -1
- package/src/testing/fake-sessions.ts +203 -0
- package/src/testing.ts +112 -33
- package/src/ui/components.ts +3 -3
- package/src/ui/elements.ts +22 -8
- package/src/ui/markdown.ts +22 -26
- package/src/ui/places.ts +49 -0
- package/src/utils/call-effects.ts +3 -3
- package/src/utils/changelog.ts +32 -0
- package/src/utils/matcher.ts +37 -0
- package/src/utils/metadata.ts +17 -15
- package/src/utils/parse-shell.ts +9 -8
- package/src/utils/paths.ts +15 -1
- package/src/utils/text.ts +4 -0
- package/testing/fake-claude.d.ts +4 -1
- package/testing/fake-claude.d.ts.map +1 -1
- package/testing/fake-claude.js +18 -11
- package/testing/fake-elements.js +4 -2
- package/testing/fake-files.js +1 -1
- package/testing/fake-sessions.d.ts +74 -0
- package/testing/fake-sessions.d.ts.map +1 -0
- package/testing/fake-sessions.js +170 -0
- package/testing.d.ts +33 -13
- package/testing.d.ts.map +1 -1
- package/testing.js +92 -21
- package/ui/components.d.ts +2 -3
- package/ui/components.d.ts.map +1 -1
- package/ui/components.js +3 -3
- package/ui/elements.d.ts.map +1 -1
- package/ui/elements.js +19 -9
- package/ui/markdown.d.ts +35 -33
- package/ui/markdown.d.ts.map +1 -1
- package/ui/markdown.js +14 -16
- package/ui/places.d.ts +43 -0
- package/ui/places.d.ts.map +1 -0
- package/ui/places.js +31 -0
- package/utils/call-effects.d.ts +1 -1
- package/utils/call-effects.d.ts.map +1 -1
- package/utils/call-effects.js +2 -2
- package/utils/changelog.d.ts +9 -0
- package/utils/changelog.d.ts.map +1 -0
- package/utils/changelog.js +29 -0
- package/utils/matcher.d.ts +13 -0
- package/utils/matcher.d.ts.map +1 -0
- package/utils/matcher.js +32 -0
- package/utils/metadata.d.ts +3 -3
- package/utils/metadata.js +18 -14
- package/utils/parse-shell.js +9 -7
- package/utils/paths.d.ts +2 -0
- package/utils/paths.d.ts.map +1 -1
- package/utils/paths.js +13 -1
- package/utils/text.d.ts +1 -0
- package/utils/text.d.ts.map +1 -1
- package/utils/text.js +3 -0
- package/src/ui/slots.ts +0 -36
- package/ui/slots.d.ts +0 -208
- package/ui/slots.d.ts.map +0 -1
- 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>):
|
|
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,
|
|
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(
|
|
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,19 +47,45 @@ 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
|
|
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
|
|
52
|
+
When a step fails, or the person declines the install, the mod stays stopped. Once `cmod install <name>` sets it up in a terminal, 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
64
|
Each of these says `Fix it, then run /reload-plugins.`
|
|
62
65
|
|
|
66
|
+
## Release notes
|
|
67
|
+
|
|
68
|
+
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.
|
|
69
|
+
|
|
70
|
+
```markdown
|
|
71
|
+
# Changelog
|
|
72
|
+
|
|
73
|
+
## [Unreleased]
|
|
74
|
+
|
|
75
|
+
## [1.4.0] - 2026-10-13
|
|
76
|
+
|
|
77
|
+
### Added
|
|
78
|
+
- /rollback undoes the last deploy, including its database migration.
|
|
79
|
+
|
|
80
|
+
### Removed
|
|
81
|
+
- /deploy-old. Use /deploy --legacy instead.
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
- `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.
|
|
85
|
+
- 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.
|
|
86
|
+
- A linked checkout whose version has no section yet shows its `[Unreleased]` section, so the notes show as soon as the version moves.
|
|
87
|
+
- `cmod new` writes the file with an `[Unreleased]` and a `[0.1.0]` section.
|
|
88
|
+
|
|
63
89
|
## Register the mod with Claude Code
|
|
64
90
|
|
|
65
91
|
`hooks/hooks.json` names `hooks/register.ts` as the plugin's hooks module. `register.ts` calls `registerMod` and does nothing else:
|
|
@@ -91,89 +117,172 @@ export function register(addHook: On, options: PluginOptions): void {
|
|
|
91
117
|
}
|
|
92
118
|
```
|
|
93
119
|
|
|
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
|
|
120
|
+
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
121
|
|
|
96
122
|
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
123
|
|
|
98
|
-
##
|
|
124
|
+
## mod: this mod
|
|
99
125
|
|
|
100
|
-
`setup` gets `mod`, of type `Mod<State, Options>`. Every hook, render, and job gets the same `mod`.
|
|
126
|
+
`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
127
|
|
|
102
128
|
```ts
|
|
103
129
|
type Mod<State, Options> = {
|
|
104
130
|
readonly name: string
|
|
131
|
+
readonly folder: string
|
|
132
|
+
readonly dataFolder: string
|
|
105
133
|
readonly state: Readonly<State>
|
|
106
134
|
readonly options: OptionValues<Options>
|
|
107
|
-
readonly
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
135
|
+
readonly permissions: Permissions
|
|
136
|
+
on(hook, listener, options?): Disposable
|
|
137
|
+
on(hook, matcher, listener, options?): Disposable
|
|
138
|
+
every(ms, listener): Disposable
|
|
139
|
+
after(ms, listener): Disposable
|
|
111
140
|
use(job): Handle
|
|
112
|
-
|
|
113
|
-
readonly ui: { pane, render, toast,
|
|
114
|
-
readonly process: { run, spawn }
|
|
115
|
-
readonly fs: { read, write, list, exists, stat, find }
|
|
116
|
-
readonly metadata: { read, update }
|
|
117
|
-
readonly http: { fetch }
|
|
141
|
+
log(text): void
|
|
142
|
+
readonly ui: { pane, render, toast, ask, copy, scroll, notice }
|
|
118
143
|
readonly settings: { page, open }
|
|
119
|
-
readonly session: { messages, append, submit }
|
|
120
|
-
readonly agent: { spawn }
|
|
121
|
-
readonly model: { complete }
|
|
122
|
-
readonly permissions: { has }
|
|
123
144
|
readonly dependencies: CmodDependencies
|
|
124
|
-
readonly claude: ModClaude
|
|
125
145
|
}
|
|
126
146
|
```
|
|
127
147
|
|
|
128
148
|
| Member | What it is | Docs |
|
|
129
149
|
| --- | --- | --- |
|
|
130
150
|
| `name` | The mod's name from `defineMod`. | |
|
|
151
|
+
| `folder` | The plugin's own folder, where the files the mod ships live. | |
|
|
152
|
+
| `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
153
|
| `state` | The mod's values. Assign to a key to change it. | [state.md](state.md) |
|
|
132
154
|
| `options` | The values a person set for the mod's options, typed from their declaration. Read-only. | [options.md](options.md) |
|
|
133
|
-
| `
|
|
134
|
-
| `
|
|
135
|
-
| `
|
|
136
|
-
| `on` | Adds a hook on a Claude Code event. | [hooks.md](hooks.md) |
|
|
155
|
+
| `permissions` | Says whether the person granted each permission. | [permissions.md](permissions.md) |
|
|
156
|
+
| `on` | Listens to a Claude Code hook, such as `PreToolUse`. | [hooks.md](hooks.md) |
|
|
157
|
+
| `every` and `after` | Run a listener on a timer while the session runs. | below |
|
|
137
158
|
| `use` | Adds a job, such as a slash command or a tool, and returns its handle. | [jobs.md](jobs.md) |
|
|
138
|
-
| `
|
|
139
|
-
| `ui` | Panes,
|
|
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 |
|
|
159
|
+
| `log` | Writes a line, under the mod's name, to Claude Code's debug log. | below |
|
|
160
|
+
| `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
161
|
| `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
162
|
| `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
163
|
|
|
152
|
-
|
|
164
|
+
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`:
|
|
153
165
|
|
|
154
166
|
```ts
|
|
155
|
-
|
|
167
|
+
import { fs, http, metadata, model, process, sessions, settings } from '../node_modules/@cmodjs/core/mod.js'
|
|
156
168
|
```
|
|
157
169
|
|
|
158
|
-
|
|
170
|
+
| Import | What it is | Docs |
|
|
171
|
+
| --- | --- | --- |
|
|
172
|
+
| `sessions` | The conversations: the one the mod runs in, every other one, and search across them. | [session.md](session.md) |
|
|
173
|
+
| `settings` | Claude Code's `settings.json` files: the person's, the project's, and the project's local one. | below |
|
|
174
|
+
| `model` | Asks a model one question, outside the conversation. | below |
|
|
175
|
+
| `fs` | Reads, writes, lists, and finds files. | below |
|
|
176
|
+
| `metadata` | Reads and changes the mod's keys in one file's metadata. | below |
|
|
177
|
+
| `process` | Runs a program. | below |
|
|
178
|
+
| `http` | Fetches a URL. | below |
|
|
179
|
+
|
|
180
|
+
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)).
|
|
181
|
+
|
|
182
|
+
### Disposable
|
|
183
|
+
|
|
184
|
+
`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`.
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
type Disposable = { dispose(): void }
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
const watching = mod.on(PostToolUse, { tool_name: 'Edit' }, () => void (mod.state.session.edits += 1))
|
|
192
|
+
mod.use(slashCommand({ name: 'stop-counting', description: 'Stop counting edits', reply: () => watching.dispose() }))
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### mod.every and mod.after
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
mod.every(ms: number, listener: () => unknown): Disposable
|
|
199
|
+
mod.after(ms: number, listener: () => unknown): Disposable
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
`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
203
|
|
|
160
204
|
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
205
|
|
|
162
206
|
```ts
|
|
163
207
|
mod.every(60_000, async () => {
|
|
164
|
-
const { stdout } = await
|
|
208
|
+
const { stdout } = await process.run(['gh', 'run', 'list', '--limit', '1', '--json', 'conclusion'])
|
|
165
209
|
mod.state.session.ci = JSON.parse(stdout)[0]?.conclusion ?? 'running'
|
|
166
210
|
})
|
|
167
211
|
```
|
|
168
212
|
|
|
169
|
-
### mod.
|
|
213
|
+
### mod.log
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
mod.log(text: string): void
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
`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)).
|
|
220
|
+
|
|
221
|
+
### mod.settings
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
mod.settings.page(page: Pane<State>): void
|
|
225
|
+
mod.settings.open(pageId?: string): Promise<void>
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
`/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.
|
|
229
|
+
|
|
230
|
+
```tsx
|
|
231
|
+
mod.settings.page(definePane({ id: 'runs', title: 'Runs', render: (current) => <Text>{current.state.session.lastRun}</Text> }))
|
|
232
|
+
mod.use(slashCommand({ name: 'ci-runs', description: 'Show the CI runs', reply: () => void mod.settings.open('runs') }))
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
## settings: Claude Code's settings files
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
settings.user.read(): Promise<Settings>
|
|
239
|
+
settings.user.update(values: Readonly<Record<string, unknown>>): Promise<void>
|
|
240
|
+
settings.project.read(): Promise<Settings>
|
|
241
|
+
settings.project.update(values: Readonly<Record<string, unknown>>): Promise<void>
|
|
242
|
+
settings.local.read(): Promise<Settings>
|
|
243
|
+
settings.local.update(values: Readonly<Record<string, unknown>>): Promise<void>
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
`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`.
|
|
247
|
+
|
|
248
|
+
- `read` answers the values in that one file, such as `(await settings.user.read())['enabledPlugins']`.
|
|
249
|
+
- `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.
|
|
250
|
+
- A key replaces its whole value, and `null` removes the key. Every other key, and the file's formatting, stays as it was.
|
|
251
|
+
- 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.
|
|
252
|
+
- Removing the mod takes out each value it set that still equals what it set, so a value the person changed since stays.
|
|
253
|
+
- `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.
|
|
254
|
+
|
|
255
|
+
```ts
|
|
256
|
+
await settings.user.update({ cleanupPeriodDays: 365 })
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
## model: one question outside the conversation
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
model.complete(request: ModelCompleteRequest, options?: { signal?: AbortSignal }): Promise<ModelCompleteResult>
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
`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.
|
|
266
|
+
|
|
267
|
+
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'`.
|
|
268
|
+
|
|
269
|
+
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:
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
mod.on(Stop, async ({ last_assistant_message }) => {
|
|
273
|
+
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 })
|
|
274
|
+
if (verdict.isAnswered && verdict.text.trim().toLowerCase().startsWith('yes')) return { decision: 'block', reason: 'Do that work yourself, then reply.' }
|
|
275
|
+
}, { timeoutMs: 9000 })
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
## process: run a program
|
|
170
279
|
|
|
171
280
|
```ts
|
|
172
|
-
|
|
173
|
-
|
|
281
|
+
process.run(argv: readonly string[], init?: ProcessRunInit): Promise<ProcessRunResult>
|
|
282
|
+
process.spawn(argv: readonly string[], init?: { cwd?, env?, input? }): HookStream<ProcessSpawnChunk, ProcessSpawnResult>
|
|
174
283
|
```
|
|
175
284
|
|
|
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`.
|
|
285
|
+
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
286
|
|
|
178
287
|
- `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
288
|
- `init.env` sets variables over Claude Code's own environment.
|
|
@@ -182,44 +291,49 @@ Both run a program without a shell. `argv[0]` is the program and the rest are it
|
|
|
182
291
|
- `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
292
|
- `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
293
|
- `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
|
|
294
|
+
- 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
295
|
|
|
187
|
-
|
|
296
|
+
A job's deadline bounds the whole callback that makes these calls ([jobs.md](jobs.md#deadlines)).
|
|
188
297
|
|
|
189
298
|
```ts
|
|
190
|
-
import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
|
|
299
|
+
import { defineMod, process, SessionStart, sessions } from '../node_modules/@cmodjs/core/mod.js'
|
|
191
300
|
|
|
192
301
|
export const branchNote = defineMod({
|
|
193
302
|
name: 'branch-note',
|
|
194
303
|
setup(mod) {
|
|
195
|
-
mod.on(
|
|
196
|
-
const { exitCode, stdout } = await
|
|
304
|
+
mod.on(SessionStart, async () => {
|
|
305
|
+
const { exitCode, stdout } = await process.run(['git', 'branch', '--show-current'], { cwd: sessions.current.project })
|
|
197
306
|
if (exitCode !== 0) return
|
|
198
|
-
return { hookSpecificOutput: { hookEventName:
|
|
307
|
+
return { hookSpecificOutput: { hookEventName: SessionStart, additionalContext: `The git branch is ${stdout.trim()}.` } }
|
|
199
308
|
})
|
|
200
309
|
},
|
|
201
310
|
})
|
|
202
311
|
```
|
|
203
312
|
|
|
204
|
-
|
|
313
|
+
## fs: files
|
|
205
314
|
|
|
206
315
|
```ts
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
316
|
+
fs.read(path: string): Promise<string>
|
|
317
|
+
fs.write(path: string, text: string): Promise<void>
|
|
318
|
+
fs.list(path?: string): Promise<Entry[]>
|
|
319
|
+
fs.exists(path: string): Promise<boolean>
|
|
320
|
+
fs.stat(path: string, options?: { resolve: boolean }): Promise<Stat>
|
|
321
|
+
fs.find(glob: string | readonly string[]): Promise<readonly FileMatch[]>
|
|
213
322
|
```
|
|
214
323
|
|
|
215
|
-
|
|
324
|
+
```ts
|
|
325
|
+
type Entry = { name: string; isFile: boolean; isFolder: boolean; isLink: boolean; size: number; modifiedAt: number }
|
|
326
|
+
type Stat = { isFile: boolean; isFolder: boolean; isLink: boolean; size: number; modifiedAt: number; realPath?: string }
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
- A relative path is under the session's working folder. An absolute path is used as given. `~/.claude` is Claude Code's config folder, and `~` is the home folder. Text is UTF-8.
|
|
216
330
|
- `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.
|
|
331
|
+
- `write` writes the whole file and creates its folders. Writing outside the project and the mod's data folder needs the `files` permission.
|
|
332
|
+
- `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
333
|
- `exists` answers whether the path leads to a file or a folder.
|
|
220
|
-
- `stat` answers
|
|
334
|
+
- `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
335
|
|
|
222
|
-
A file the mod ships is under
|
|
336
|
+
A file the mod ships is under `mod.folder`. A file the mod keeps for itself belongs in `mod.dataFolder`.
|
|
223
337
|
|
|
224
338
|
`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
339
|
|
|
@@ -227,27 +341,28 @@ A file the mod ships is under the plugin's folder, `mod.claude.plugin.root` ([jo
|
|
|
227
341
|
type FileMatch = { readonly path: string; readonly name?: string; readonly metadata: Readonly<Record<string, string>>; readonly error?: string }
|
|
228
342
|
```
|
|
229
343
|
|
|
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
|
|
231
|
-
- `path` is absolute. `name` is a Markdown file's frontmatter `name`. `metadata` holds the mod's own keys, bare, as `
|
|
344
|
+
- 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.
|
|
345
|
+
- `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
346
|
- `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.
|
|
347
|
+
- 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.
|
|
348
|
+
- 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
349
|
- cmod keeps what it read of each file until the file's modified time or size changes.
|
|
235
350
|
|
|
236
351
|
```ts
|
|
237
|
-
const skills = await
|
|
352
|
+
const skills = await fs.find(['~/.claude/skills/*/SKILL.md', '.claude/skills/*/SKILL.md'])
|
|
238
353
|
const planning = skills.filter((skill) => skill.metadata['modes']?.split(' ').includes('plan'))
|
|
239
354
|
```
|
|
240
355
|
|
|
241
|
-
|
|
356
|
+
## metadata: a file's own keys
|
|
242
357
|
|
|
243
358
|
```ts
|
|
244
|
-
|
|
245
|
-
|
|
359
|
+
metadata.read(path: string): Promise<Readonly<Record<string, string>>>
|
|
360
|
+
metadata.update(path: string, change: (metadata: Record<string, string>) => void): Promise<void>
|
|
246
361
|
```
|
|
247
362
|
|
|
248
363
|
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
364
|
|
|
250
|
-
Where a file keeps its metadata depends on its
|
|
365
|
+
Where a file keeps its metadata depends on its type:
|
|
251
366
|
|
|
252
367
|
```markdown
|
|
253
368
|
---
|
|
@@ -268,116 +383,27 @@ metadata:
|
|
|
268
383
|
- 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
384
|
- Any other file keeps it in a sidecar, `<file>.meta`.
|
|
270
385
|
|
|
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 `
|
|
386
|
+
`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.
|
|
387
|
+
|
|
388
|
+
`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
389
|
|
|
273
390
|
```ts
|
|
274
|
-
await
|
|
275
|
-
|
|
391
|
+
await metadata.update('.claude/skills/plan/SKILL.md', (keys) => {
|
|
392
|
+
keys['modes'] = 'plan build'
|
|
276
393
|
})
|
|
277
394
|
```
|
|
278
395
|
|
|
279
396
|
`cmod check` reads the metadata of every file the mod ships, and fails one cmod cannot read.
|
|
280
397
|
|
|
281
|
-
|
|
398
|
+
## http: fetch a URL
|
|
282
399
|
|
|
283
400
|
```ts
|
|
284
|
-
|
|
401
|
+
http.fetch(url: string, init?: HttpInit): Promise<HttpResponse>
|
|
285
402
|
```
|
|
286
403
|
|
|
287
404
|
- `init` takes `method` (`GET` by default), `headers`, `body` as text, and `socketPath`, the absolute path of a Unix socket the request goes over.
|
|
288
405
|
- 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
|
-
```
|
|
406
|
+
- 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
407
|
|
|
382
408
|
## messageOf
|
|
383
409
|
|
|
@@ -388,14 +414,14 @@ messageOf(error: unknown): string
|
|
|
388
414
|
Returns an `Error`'s `message`, or the value as text. Use it in a `catch`, where the error is `unknown`.
|
|
389
415
|
|
|
390
416
|
```ts
|
|
391
|
-
import { defineMod, messageOf } from '../node_modules/@cmodjs/core/mod.js'
|
|
417
|
+
import { defineMod, fs, messageOf, Stop } from '../node_modules/@cmodjs/core/mod.js'
|
|
392
418
|
|
|
393
419
|
export const notes = defineMod({
|
|
394
420
|
name: 'notes',
|
|
395
421
|
setup(mod) {
|
|
396
|
-
mod.on(
|
|
397
|
-
await
|
|
398
|
-
mod.ui.toast(
|
|
422
|
+
mod.on(Stop, async () => {
|
|
423
|
+
await fs.write(`${mod.dataFolder}/last-stop.txt`, new Date().toISOString()).catch((error: unknown) => {
|
|
424
|
+
mod.ui.toast.error('Could not save the last stop', { description: messageOf(error) })
|
|
399
425
|
})
|
|
400
426
|
})
|
|
401
427
|
},
|
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
|
|
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).
|
|
92
|
+
expect(tested.shown.toasts).toContainEqual({ type: 'default', message: 'Watching develop every 10 minutes' })
|
|
93
93
|
```
|