@cmodjs/core 1.5.1 → 2.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.
Files changed (130) hide show
  1. package/docs/commands.md +5 -5
  2. package/docs/index.md +12 -11
  3. package/docs/jobs.md +12 -16
  4. package/docs/mod.md +117 -39
  5. package/docs/permissions.md +17 -21
  6. package/docs/session.md +171 -82
  7. package/docs/state.md +5 -5
  8. package/docs/testing.md +57 -41
  9. package/docs/ui.md +9 -9
  10. package/jobs/permissions/decide-permission.d.ts.map +1 -1
  11. package/jobs/permissions/decide-permission.js +1 -1
  12. package/jobs/prompt.d.ts +1 -3
  13. package/jobs/prompt.d.ts.map +1 -1
  14. package/jobs/prompt.js +2 -21
  15. package/jobs/slash-command.d.ts +1 -1
  16. package/jobs/slash-command.d.ts.map +1 -1
  17. package/jobs/slash-command.js +1 -1
  18. package/jobs/status-line.d.ts +0 -1
  19. package/jobs/status-line.d.ts.map +1 -1
  20. package/jobs/status-line.js +3 -4
  21. package/mod.d.ts +53 -105
  22. package/mod.d.ts.map +1 -1
  23. package/mod.js +18 -80
  24. package/package.json +1 -1
  25. package/records.d.ts +3 -1
  26. package/records.d.ts.map +1 -1
  27. package/records.js +14 -5
  28. package/register.d.ts.map +1 -1
  29. package/register.js +3 -1
  30. package/runtime/before.d.ts +5 -1
  31. package/runtime/before.d.ts.map +1 -1
  32. package/runtime/before.js +81 -39
  33. package/runtime/claude.d.ts +6 -1
  34. package/runtime/claude.d.ts.map +1 -1
  35. package/runtime/conversations.d.ts.map +1 -1
  36. package/runtime/conversations.js +45 -26
  37. package/runtime/deadline.d.ts +17 -0
  38. package/runtime/deadline.d.ts.map +1 -1
  39. package/runtime/deadline.js +34 -0
  40. package/runtime/dependencies.d.ts +2 -0
  41. package/runtime/dependencies.d.ts.map +1 -1
  42. package/runtime/dependencies.js +12 -0
  43. package/runtime/grants.d.ts +1 -1
  44. package/runtime/grants.d.ts.map +1 -1
  45. package/runtime/grants.js +19 -12
  46. package/runtime/hooks.d.ts +28 -11
  47. package/runtime/hooks.d.ts.map +1 -1
  48. package/runtime/hooks.js +20 -123
  49. package/runtime/lifecycle.d.ts +2 -1
  50. package/runtime/lifecycle.d.ts.map +1 -1
  51. package/runtime/lifecycle.js +67 -50
  52. package/runtime/sessions.d.ts +31 -5
  53. package/runtime/sessions.d.ts.map +1 -1
  54. package/runtime/sessions.js +342 -81
  55. package/runtime/tool-calls.d.ts +0 -1
  56. package/runtime/tool-calls.d.ts.map +1 -1
  57. package/runtime/tool-calls.js +0 -19
  58. package/session/context/turn/message/instruction-file.d.ts +9 -0
  59. package/session/context/turn/message/instruction-file.d.ts.map +1 -0
  60. package/session/context/turn/message/instruction-file.js +4 -0
  61. package/session/context/turn/message/message.d.ts +5 -4
  62. package/session/context/turn/message/message.d.ts.map +1 -1
  63. package/session/context/turn/message/message.js +7 -6
  64. package/session/context/turn/message/sender.d.ts +2 -1
  65. package/session/context/turn/message/sender.d.ts.map +1 -1
  66. package/session/context/turn/message/sender.js +4 -1
  67. package/session/context/turn/message/tool-call.d.ts +5 -0
  68. package/session/context/turn/message/tool-call.d.ts.map +1 -1
  69. package/session/context/turn/message/tool-result.d.ts +6 -1
  70. package/session/context/turn/message/tool-result.d.ts.map +1 -1
  71. package/session/context/turn/turn.d.ts +7 -0
  72. package/session/context/turn/turn.d.ts.map +1 -1
  73. package/session/conversation.d.ts +10 -3
  74. package/session/conversation.d.ts.map +1 -1
  75. package/session/conversation.js +14 -5
  76. package/session/goal/goal.d.ts +1 -1
  77. package/session/goal/goal.d.ts.map +1 -1
  78. package/session/goal/task.d.ts +2 -2
  79. package/session/goal/task.d.ts.map +1 -1
  80. package/session/session.d.ts +361 -26
  81. package/session/session.d.ts.map +1 -1
  82. package/session/session.js +40 -0
  83. package/src/jobs/permissions/decide-permission.ts +2 -1
  84. package/src/jobs/prompt.ts +3 -20
  85. package/src/jobs/slash-command.ts +2 -2
  86. package/src/jobs/status-line.ts +4 -7
  87. package/src/mod.ts +52 -147
  88. package/src/records.ts +15 -5
  89. package/src/register.ts +3 -1
  90. package/src/runtime/before.ts +90 -37
  91. package/src/runtime/claude.ts +5 -1
  92. package/src/runtime/conversations.ts +41 -25
  93. package/src/runtime/deadline.ts +38 -0
  94. package/src/runtime/dependencies.ts +12 -0
  95. package/src/runtime/grants.ts +15 -10
  96. package/src/runtime/hooks.ts +48 -116
  97. package/src/runtime/lifecycle.ts +66 -51
  98. package/src/runtime/sessions.ts +366 -90
  99. package/src/runtime/tool-calls.ts +0 -19
  100. package/src/session/context/turn/message/instruction-file.ts +13 -0
  101. package/src/session/context/turn/message/message.ts +11 -9
  102. package/src/session/context/turn/message/sender.ts +5 -2
  103. package/src/session/context/turn/message/tool-call.ts +3 -0
  104. package/src/session/context/turn/message/tool-result.ts +6 -1
  105. package/src/session/context/turn/turn.ts +4 -0
  106. package/src/session/conversation.ts +21 -8
  107. package/src/session/goal/goal.ts +1 -1
  108. package/src/session/goal/task.ts +2 -2
  109. package/src/session/session.ts +227 -24
  110. package/src/testing/claude-code.ts +23 -0
  111. package/src/testing/fake-claude.ts +5 -1
  112. package/src/testing/fake-sessions.ts +38 -24
  113. package/src/testing.ts +84 -16
  114. package/testing/claude-code.d.ts +12 -0
  115. package/testing/claude-code.d.ts.map +1 -0
  116. package/testing/claude-code.js +10 -0
  117. package/testing/fake-claude.d.ts +4 -0
  118. package/testing/fake-claude.d.ts.map +1 -1
  119. package/testing/fake-claude.js +4 -1
  120. package/testing/fake-sessions.d.ts +8 -6
  121. package/testing/fake-sessions.d.ts.map +1 -1
  122. package/testing/fake-sessions.js +35 -20
  123. package/testing.d.ts +38 -9
  124. package/testing.d.ts.map +1 -1
  125. package/testing.js +54 -5
  126. package/docs/hooks.md +0 -259
  127. package/session/context/turn/message/document.d.ts +0 -7
  128. package/session/context/turn/message/document.d.ts.map +0 -1
  129. package/session/context/turn/message/document.js +0 -1
  130. package/src/session/context/turn/message/document.ts +0 -8
package/docs/commands.md CHANGED
@@ -12,7 +12,7 @@ Each command below restates what `cmod <command> --help` prints, for a mod autho
12
12
  cmod new <name> [--project]
13
13
  ```
14
14
 
15
- Creates a mod in `./<name>`: a `defineMod` with one hook, one pane, and one render of the band above the prompt, a Skill, a test, and a `CHANGELOG.md` ([mod.md](mod.md#release-notes)). Then installs its packages.
15
+ Creates a mod in `./<name>`: a `defineMod` with one listener, one pane, and one render of the band above the prompt, a Skill, a test, and a `CHANGELOG.md` ([mod.md](mod.md#release-notes)). Then installs its packages.
16
16
 
17
17
  - `<name>` is lowercase letters, digits, and `-`, starting with a letter.
18
18
  - `--project` creates a project-scope plugin in `./.claude/skills/<name>/` of the repository in the current folder, which loads for everyone who trusts the repository. Run it in the repository's root.
@@ -179,7 +179,7 @@ Lists every plugin Claude Code has installed or linked, with its version. A mod
179
179
 
180
180
  ```text
181
181
  cmod history sessions [--all] [--since 2d] [--running] [--project <folder>] [--json]
182
- cmod history search <text> [--all] [--since 2d] [--running] [--project <folder>] [--session <id> [--task <id>] [--turn <id>] [--context <n>] [--goal]] [--limit <n>] [--before <session>:<id>] [--json]
182
+ cmod history search <text> [--all] [--since 2d] [--running] [--project <folder>] [--session <id> [--task <id>] [--turn <id>] [--context <n>] [--agent]] [--limit <n>] [--before <session>:<id>] [--json]
183
183
  cmod history messages [<session>] [--task <id>] [--turn <id>] [--after <id>] [--last <n>] [--json]
184
184
  cmod history turns [<session>] [--task <id>] [--context <n>] [--last <n>] [--json]
185
185
  cmod history contexts [<session>] [--task <id>] [--json]
@@ -194,7 +194,7 @@ cmod history index
194
194
  Reads every conversation Claude Code saved, as the sessions, contexts, turns, tasks, and messages `sessions` answers a mod ([session.md](session.md)), so a person or an agent reads them in a terminal without parsing a transcript.
195
195
 
196
196
  - `sessions` lists the conversations of the current folder's project, or `--project`'s, newest first, each with its time, id, and title. `--all` lists every project's, `--since` keeps those active in the last `30m`, `6h`, `2d`, or `1w`, or since a date such as `2026-10-01`, and `--running` keeps those open in Claude Code now.
197
- - `search` prints the newest 50 messages whose text or tool input contains the text, at least 3 characters, any case, each with its time and session. Each tool input value is searched in its first 1,024 characters, and tool results are not searched. `--all`, `--since`, and `--running` scope it as they scope `sessions`, `--limit` changes the 50, and `--before <session>:<id>` prints the page after the message it names, the last one of the page before. `--session` searches one conversation, and inside it `--task` searches a task and the tasks it started, `--turn` one turn, `--context` one context, and `--goal` the tasks of the conversation or of `--task`.
197
+ - `search` prints the newest 50 messages whose text or tool input contains the text, at least 3 characters, any case, each with its time and session. Each tool input value is searched in its first 1,024 characters, and tool results are not searched. `--all`, `--since`, and `--running` scope it as they scope `sessions`, `--limit` changes the 50, and `--before <session>:<id>` prints the page after the message it names, the last one of the page before. `--session` searches one conversation, and inside it `--task` searches a task and the tasks it started, `--turn` one turn, `--context` one context, and `--agent` keeps the main agent's messages, or with `--task` that task's own, without the tasks it started.
198
198
  - `messages` prints one conversation's messages in order: the session named, or the one the command runs in when Claude Code runs it. `--task` reads a task's messages instead, `--turn` keeps one turn's, `--after` starts after a message id, and `--last` keeps that many from the end.
199
199
  - `turns` prints the conversation's turns, each opened by a prompt and closed when Claude finishes. `--context` keeps one context's, and `--last` keeps that many from the end.
200
200
  - `contexts` prints the conversation's contexts, a new one after each compaction.
@@ -205,7 +205,7 @@ Reads every conversation Claude Code saved, as the sessions, contexts, turns, ta
205
205
  - `state` prints what a running conversation resumes from.
206
206
  - `follow` prints each message, turn, context, task, and goal change of the sessions named as Claude Code saves it, until stopped with Ctrl+C. `--running` follows every running session of the project too, and each one that starts later. The cmod plugin runs it to hear other running sessions.
207
207
  - `--json` prints JSON instead of one line each.
208
- - `index` reads every saved conversation into the index at `~/.local/share/cmod/history-5.sqlite`, which holds where each message is in its transcript, not a copy of it, and each conversation's turns, contexts, and tasks. While a mod other than cmod holds the `conversation` permission, the cmod plugin runs it in the background at each session's start, and a second run while one is running exits at once. The index forgets a conversation whose transcript is deleted.
208
+ - `index` reads every saved conversation into the index at `~/.local/share/cmod/history-6.sqlite`, which holds where each message is in its transcript, not a copy of it, and each conversation's turns, contexts, and tasks. While a mod other than cmod holds the `conversation` permission, the cmod plugin runs it in the background at each session's start, and a second run while one is running exits at once. The index forgets a conversation whose transcript is deleted.
209
209
 
210
210
  ## Run in an install script
211
211
 
@@ -251,7 +251,7 @@ Turns one permission of a set-up mod on or off, such as `cmod permission ci-watc
251
251
  cmod settings <mod> <user|project|local> <json> [--project <folder>]
252
252
  ```
253
253
 
254
- Changes Claude Code settings for a set-up mod, as its `settings.user.update`, `settings.project.update`, and `settings.local.update` do ([mod.md](mod.md#settings-claude-code-s-settings-files)), such as `cmod settings keeper user '{"cleanupPeriodDays":365}'`. It writes each top-level key of the JSON object into the user, project, or local settings, and `null` removes the key. The project and local settings are those of `--project`, the current folder when it is left out, and `mcpServers` in the project settings goes to `.mcp.json`. Each key needs the mod's `config` permission for it. cmod keeps every other key and the file's formatting, and `cmod teardown` takes out each value the mod set that is still the same.
254
+ Changes Claude Code settings for a set-up mod, as its `settings.user.update`, `settings.project.update`, and `settings.local.update` do ([mod.md](mod.md#settings-claude-code-s-settings-files)), such as `cmod settings oneclaude user '{"env":{"ANTHROPIC_BASE_URL":"http://127.0.0.1:8317"}}'`. It merges the JSON object into the user, project, or local settings as a JSON Merge Patch: an object merges into the object already there, `null` removes the key, and an array or any other value replaces it. The project and local settings are those of `--project`, the current folder when it is left out, and `mcpServers` in the project settings goes to `.mcp.json`. Each value needs the mod's `config` permission for its key path or a path above it. cmod keeps every other key and the file's formatting, and `cmod teardown` takes out each value the mod set that is still the same, then each object that leaves empty.
255
255
 
256
256
  ### cmod rules
257
257
 
package/docs/index.md CHANGED
@@ -16,7 +16,7 @@ my-mod/
16
16
  ├── .oxlintrc.json leaves .claude-plugin/types/ out of the lint
17
17
  ├── hooks/hooks.json names hooks/register.ts as the hooks module
18
18
  ├── hooks/register.ts registerMod(addHook, myMod, options), and nothing else
19
- ├── src/mod.tsx defineMod: the state, the setup, the hooks, the panes
19
+ ├── src/mod.tsx defineMod: the state, the setup, the listeners, the panes
20
20
  ├── src/state.ts the starting state
21
21
  ├── src/panes/prompts.tsx one definePane per file
22
22
  ├── src/components/prompt-count.tsx what the panes and renders draw with
@@ -57,7 +57,7 @@ A program the mod needs lives as source in `cli/`. `cmod publish` builds it and
57
57
  | Question | File |
58
58
  | --- | --- |
59
59
  | How do I define a mod, and what can `mod` call? | [mod.md](mod.md) |
60
- | How does a mod watch something outside Claude Code, tell Claude about it, or ask a model? | [mod.md](mod.md#mod-every-and-mod-after), [session.md](session.md#send-to-a-conversation) |
60
+ | How does a mod watch something outside Claude Code, tell Claude about it, or ask a model? | [mod.md](mod.md#mod-every-and-mod-after), [session.md](session.md#act-on-a-session) |
61
61
  | How does a mod give Claude a prompt on a schedule, like `/loop`? | [jobs.md](jobs.md#schedule) |
62
62
  | How do I keep values across prompts, sessions, and projects? | [state.md](state.md) |
63
63
  | How does a person set a token, a URL, or a setting for my mod, for themselves or their team? | [options.md](options.md) |
@@ -66,7 +66,8 @@ A program the mod needs lives as source in `cli/`. `cmod publish` builds it and
66
66
  | How does the person finish a setup step only they can do, such as signing in? | [install-steps.md](install-steps.md#steps-in-claude-code) |
67
67
  | How do I add my mod's own page to `/mods`, or change Claude Code's settings? | [mod.md](mod.md#mod-settings), [mod.md](mod.md#settings-claude-code-s-settings-files) |
68
68
  | How does my mod read and follow this conversation, its tasks, or earlier ones, search them, or start a task? | [session.md](session.md) |
69
- | How do I listen to a Claude Code hook, and what can a listener answer? | [hooks.md](hooks.md) |
69
+ | How does my mod hear a prompt, a tool call, a permission dialog, or a model switch, and change or refuse it? | [session.md](session.md#listen-to-a-record), [session.md](session.md#the-session-s-own-events) |
70
+ | How does my mod hear, change, or run any slash command, a built-in such as `/compact` included? | [mod.md](mod.md#commands-slash-commands) |
70
71
  | How do I draw a pane, change a row Claude Code draws, or ask the person something? | [ui.md](ui.md) |
71
72
  | How do I add a slash command, a tool, a permission rule, a check, a prompt, a schedule, a status line, or a background program? | [jobs.md](jobs.md) |
72
73
  | How does one mod call another? | [dependencies.md](dependencies.md) |
@@ -81,20 +82,20 @@ A mod imports from the files below, and every other file in this package is inte
81
82
  | Import | Exports | Docs |
82
83
  | --- | --- | --- |
83
84
  | `mod.js` | `defineMod`, `ModDefinition`, `Mod`, `Disposable`, `Permissions`, `Allowed`, `JobContext`, `Job`, `PaneHandle`, `longestMs`, `messageOf` | [mod.md](mod.md), [jobs.md](jobs.md) |
84
- | `mod.js` | `settings`, `SettingsFile`, `model`, `fs`, `Entry`, `Stat`, `FileMatch`, `metadata`, `Metadata`, `process`, `http`, `Imports` | [mod.md](mod.md) |
85
- | `mod.js` | `sessions`, `Sessions`, `Session`, `SessionScope`, `SessionSend`, `SessionEvent`, `SessionPayload`, `TaskStartArgs`, `Usage`, `Matcher` | [session.md](session.md) |
86
- | `mod.js` | `Message`, `MessageType`, `Prompt`, `Note`, `Reply`, `ToolCall`, `ToolResult`, `Skill`, `Document`, `Notice`, `Sender`, `SenderType`, `Person`, `Schedule`, `Channel`, `Sdk` | [session.md](session.md#message) |
87
- | `mod.js` | `Context`, `ContextStart`, `ContextEnd`, `Turn`, `TurnStart`, `TurnEnd`, `Goal`, `GoalChange`, `Task`, `TaskStart`, `TaskEnd` | [session.md](session.md#turn-context-goal-and-task) |
88
- | `mod.js` | `Hook`, `HookInput`, `HookListener`, `HookOptions`, `HookAnswer`, and one constant for each of Claude Code's 33 hooks | [hooks.md](hooks.md) |
85
+ | `mod.js` | `settings`, `SettingsFile`, `SettingsFileChange`, `SettingsFileChangeListener`, `config`, `ConfigRow`, `commands`, `CommandInfo`, `CommandRun`, `CommandRunAnswer`, `CommandRunResult`, `model`, `fs`, `Entry`, `Stat`, `FileMatch`, `FileChange`, `metadata`, `Metadata`, `process`, `http`, `Imports` | [mod.md](mod.md) |
86
+ | `mod.js` | `sessions`, `Sessions`, `Session`, `CurrentSession`, `TaskOwner`, `RecordPart`, `OnMethods`, `BeforeMethods`, `Listener`, `ListenerTime`, `ReadOptions`, `AnswerOptions`, `Refusal`, `SearchOptions`, `SessionScope`, `SessionSend`, `TaskStartArgs`, `ToolCallArgs`, `Usage`, `Surface`, `Matcher` | [session.md](session.md) |
87
+ | `mod.js` | `Placement`, `ProjectSetup`, `SessionStartAsked`, `SessionStarted`, `SessionEnded`, `ModelChange`, `CwdChange`, `DirectoryAdd`, `Worktree`, `Notification`, `Elicitation`, `ElicitationResponse`, `McpServer`, `PermissionUpdate`, `PermissionCheck`, `PermissionRequest`, `PermissionDenial`, `BatchedCall`, `ToolBatch`, `Todo`, `ReplyDisplay` | [session.md](session.md#the-session-s-own-events) |
88
+ | `mod.js` | `Message`, `MessageType`, `Prompt`, `Note`, `Reply`, `ToolCall`, `ToolResult`, `Skill`, `InstructionFile`, `Notice`, `Sender`, `SenderType`, `Person`, `Mod`, `Schedule`, `Channel`, `Sdk`, `Engine` | [session.md](session.md#message) |
89
+ | `mod.js` | `Context`, `Turn`, `Goal`, `Task` | [session.md](session.md#turn-context-goal-and-task) |
89
90
  | `mod.js` | `Toast`, `ToastId`, `ToastType`, `ToastOptions` | [ui.md](ui.md#toasts) |
90
91
  | `mod.js` | `Place`, `PlaceProps`, `ToolGroup`, `ToolProgress`, `CommandOutput`, `AskUserQuestion`, `Spinner`, `TurnDuration`, `SessionMode`, `PromptHint`, `AbovePrompt` | [ui.md](ui.md#places-change-a-row-claude-code-draws) |
91
92
  | `mod.js` | `notInstalled` | [dependencies.md](dependencies.md) |
92
93
  | `installer.js` | `defineStep`, `Step` | [install-steps.md](install-steps.md#steps-in-claude-code) |
93
94
  | `register.js` | `registerMod`, `registerPermissionCheck`, `registeredMod` | [mod.md](mod.md) |
94
95
  | `options.js` | `option`, `Option`, `Options`, `OptionValues`, `OptionValue`, `OptionType`, `UserConfigField`, `userConfigOf` | [options.md](options.md) |
95
- | `shell.js` | `parseShell`, `ParsedShell`, `ShellCommand` | [hooks.md](hooks.md) |
96
- | `path.js` | `resolve`, `join`, `dirname`, `basename`, `extname`, `relative`, `isAbsolute`, `normalize` | [hooks.md](hooks.md) |
97
- | `testing.js` | `testMod`, `TestOptions`, `TestInput`, `TestedMod`, `Fakes`, `Shown`, `ShownToast`, `TestCall`, `FakeSession`, `MessageDraft` | [testing.md](testing.md) |
96
+ | `shell.js` | `parseShell`, `ParsedShell`, `ShellCommand` | [mod.md](mod.md#shell-js-and-path-js-read-a-command-line) |
97
+ | `path.js` | `resolve`, `join`, `dirname`, `basename`, `extname`, `relative`, `isAbsolute`, `normalize` | [mod.md](mod.md#shell-js-and-path-js-read-a-command-line) |
98
+ | `testing.js` | `testMod`, `TestOptions`, `TestedMod`, `Raise`, `Fakes`, `Shown`, `ShownToast`, `TestCall`, `FakeSession`, `MessageDraft`, `RoutedEvent` | [testing.md](testing.md) |
98
99
  | `ui/define-pane.js` | `definePane`, `Pane` | [ui.md](ui.md) |
99
100
  | `ui/elements.js` | `Box`, `Text`, `Button`, `Link`, `Code`, `Markdown`, `Input`, `Select`, `Image`, `drawWith` | [ui.md](ui.md) |
100
101
  | `ui/components.js` | `Tabs`, `Tab`, `Split`, `Panel`, `Toggle`, `Tooltip`, `Help`, `Dialog`, `Pagination`, `Progress` | [ui.md](ui.md) |
package/docs/jobs.md CHANGED
@@ -258,25 +258,22 @@ It needs `"prompt": true`. Without the grant it adds nothing and logs once which
258
258
  ```ts
259
259
  prompt<State>(options: {
260
260
  name: string
261
- prompt: string | ((input: { userPrompt?: string; call?: Call & { folder: string } }, mod: Mod<State>) => string | undefined | Promise<string | undefined>)
262
- when?: RegExp | ((userPrompt: string) => boolean)
261
+ prompt: string | ((input: { call?: Call & { folder: string } }, mod: Mod<State>) => string | undefined | Promise<string | undefined>)
263
262
  after?: Target | Target[]
264
263
  }): Job<void, State>
265
264
  ```
266
265
 
267
- `prompt` adds text that Claude reads. `when` and `after` pick when:
266
+ `prompt` adds text that Claude reads. `after` picks when:
268
267
 
269
268
  | Options | When Claude reads the text |
270
269
  | --- | --- |
271
- | neither | At the start of each conversation, as a block of context named `name`. A block of that name already in the context wins. Then again whenever `mod.state` changes and the text differs from what Claude last got. |
272
- | `when` | With each prompt the person sends whose text matches the `RegExp`, or for which the function returns true. `input.userPrompt` is the prompt. |
273
- | `after` | After each call that matches a target and succeeds, with the call's result. `input.call` is the matched call, with its `folder` as `permissions` gives it. |
270
+ | no `after` | At the start of each conversation, as a block of context named `name`. A block of that name already in the context wins. Then again whenever `mod.state` changes and the text differs from what Claude last got. |
271
+ | `after` | After each call that matches a target and succeeds, with the call's result, under the heading `# <name>`. `input.call` is the matched call, with its `folder` as `permissions` gives it. |
274
272
 
275
- - With `when` or `after`, the text goes to Claude under the heading `# <name>`.
276
- - With neither, the text follows the mod's state. After a change to `mod.state`, cmod calls `prompt` again, and when the text differs from what Claude last got, it adds `# <name>` and the new text to the conversation as a `Note` Claude reads on its next call to the model, as `sessions.current.send` does ([session.md](session.md#send-to-a-conversation)). Several changes in a row give one note with the last text. A note waits until the conversation has started, so a state change before the first prompt changes only the first block.
273
+ - Without `after`, the text follows the mod's state. After a change to `mod.state`, cmod calls `prompt` again, and when the text differs from what Claude last got, it adds `# <name>` and the new text to the conversation as a `Note` Claude reads on its next call to the model, as `sessions.current.send` does ([session.md](session.md#act-on-a-session)). Several changes in a row give one note with the last text. A note waits until the conversation has started, so a state change before the first prompt changes only the first block.
277
274
  - A `prompt` function that returns `undefined` or `''` adds nothing. One that throws adds nothing, and the error goes to Claude Code's debug log.
278
- - `name` cannot be blank. `when` and `after` together throw: add a second `prompt` for the other trigger. Two prompts with one name in a mod throw.
279
- - A `when` prompt runs on `prompt.submit` and adds its text to the event it passes down. In a test, `fire('prompt.submit', …)` returns that text in the answer's `context`. [testing.md](testing.md#test-a-prompt-with-when) shows how.
275
+ - `name` cannot be blank. Two prompts with one name in a mod throw.
276
+ - Text for the prompts that match a pattern is a `beforePrompt` note: `sessions.current.beforePrompt({ text: /\bmigrat/i }, () => ({ note: 'Every migration has a down step.' }))` ([session.md](session.md#change-what-happens)).
280
277
 
281
278
  ```ts
282
279
  import { defineMod, fs, sessions } from '../node_modules/@cmodjs/core/mod.js'
@@ -288,7 +285,6 @@ export const conventions = defineMod({
288
285
  setup(mod) {
289
286
  mod.use(prompt({ name: 'Commit messages', prompt: 'Write commit messages in the imperative mood.' }))
290
287
  mod.use(prompt({ name: 'Mode', prompt: (_input, mod) => `You are in ${mod.state.session.mode} mode.` }))
291
- mod.use(prompt({ name: 'Migrations', when: /\bmigrat/i, prompt: 'Every migration has a down step.' }))
292
288
  mod.use(prompt({ name: 'Schema', after: { write: 'db/schema.sql' }, prompt: async () => `The schema is now:\n${await fs.read(`${sessions.current.project}/db/schema.sql`)}` }))
293
289
  },
294
290
  })
@@ -411,19 +407,19 @@ type JobContext<State> = {
411
407
  }
412
408
  ```
413
409
 
414
- - `mod` is the mod that uses the job. A job builds on `mod.on`, `mod.every`, `mod.ui`, and the imports from `mod.js`, as `setup` does ([mod.md](mod.md)).
410
+ - `mod` is the mod that uses the job. A job builds on `mod.every`, `mod.ui`, and the imports from `mod.js`, such as the methods of `sessions`, as `setup` does ([mod.md](mod.md)).
415
411
  - `announce` adds a phrase to the list cmod logs when the mod first starts.
416
412
  - `reserveName` throws `taken` when the mod already reserved that `type` and `name`, so a job refuses a duplicate. `dispose()` on what it returns frees the name again.
417
413
 
418
414
  A job that hands back a feature the mod turns off returns a `Disposable`, as `slashCommand` and `tool` do:
419
415
 
420
416
  ```ts
421
- import { defineMod, sessions, Stop, ToolCall, type Disposable, type Job } from '../node_modules/@cmodjs/core/mod.js'
417
+ import { defineMod, sessions, type Disposable, type Job } from '../node_modules/@cmodjs/core/mod.js'
422
418
 
423
419
  const editCounter = (tool: string): Job<Disposable & { readonly count: () => number }> => ({ mod, announce, reserveName }) => {
424
420
  const reserved = reserveName('editCounter', tool, `${mod.name}: edits of ${tool} are already counted.`)
425
421
  let edits = 0
426
- const listening = sessions.current.on(ToolCall, { name: tool }, () => void (edits += 1))
422
+ const listening = sessions.current.onToolCall({ name: tool }, () => void (edits += 1))
427
423
  announce(`a count of ${tool} calls`)
428
424
  return {
429
425
  count: () => edits,
@@ -438,7 +434,7 @@ export const counter = defineMod({
438
434
  name: 'counter',
439
435
  setup(mod) {
440
436
  const writes = mod.use(editCounter('Write'))
441
- mod.on(Stop, () => mod.ui.toast(`${writes.count()} files written so far`))
437
+ sessions.current.onTurnEnd(() => mod.ui.toast(`${writes.count()} files written so far`))
442
438
  },
443
439
  })
444
440
  ```
@@ -460,4 +456,4 @@ A job of your own matches calls against targets through the `permissions`, `chec
460
456
 
461
457
  ## Test a job
462
458
 
463
- `tested.type('/todo milk')` runs a slash command. `tested.callTool('open_ticket', { title: 'Crash' })` runs the permission rules, then the tool. `tested.fire('tool.check', …)` asks the permission rules about any call. `tested.fire('prompt.submit', …)` returns the context a `when` prompt added. [testing.md](testing.md) covers each.
459
+ `tested.type('/todo milk')` runs a slash command. `tested.callTool('open_ticket', { title: 'Crash' })` runs the permission rules, then the tool. `tested.fire('tool.check', …)` asks the permission rules about any call. `tested.shown.notes` holds the notes a `prompt` sent after a state change. [testing.md](testing.md) covers each.
package/docs/mod.md CHANGED
@@ -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, renders, and jobs. It may be `async`.
27
+ - `setup` runs once when the mod starts. It adds listeners, panes, renders, and jobs. It may be `async`.
28
28
 
29
29
  ```tsx
30
- import { defineMod, UserPromptSubmit } from '../node_modules/@cmodjs/core/mod.js'
30
+ import { defineMod, sessions } 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
+ sessions.current.onPrompt(() => {
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,7 +47,7 @@ export const greeter = defineMod({
47
47
 
48
48
  Claude Code loads the mod at the start of each session. cmod then checks that the mod is set up. A mod the person has not accepted yet opens the installer pane, `Install <name>`, which lists its permissions and the changes it makes to the computer, and waits for Accept ([install-steps.md](install-steps.md)). An option with no default and no value is asked on the next page of the same pane. Then cmod loads the saved state, runs `setup`, marks as open each of the mod's panes that Claude Code already shows, and opens the pane again for each `installer` step the mod declares that is not done.
49
49
 
50
- The first time a mod starts on a machine, cmod shows the toast `Installed` under its name. Opened, it lists the mod's `plugin.json` `description`, what setup added, such as `Adds the my-mod pane, a render of AbovePrompt and 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.
50
+ The first time a mod starts on a machine, cmod shows the toast `Installed` under its name. Opened, it lists the mod's `plugin.json` `description`, what setup added, such as `Adds the my-mod pane, a render of AbovePrompt and /deploy.`, what its install step set up, and each permission the person allowed, with a Settings button for `/mods <name>`. The first time a new version starts, the toast is `Updated to <version>`: the first three changes of that version's `CHANGELOG.md` section, the version the machine ran before, and a Changelog button ([Release notes](#release-notes)). A mod with no changelog lists what it adds now and no longer adds instead. A new session, `/reload-plugins`, and a version the machine already ran show no toast.
51
51
 
52
52
  When a step fails, or the person declines the install, the mod stays stopped. Once another session sets it up, the mod starts at the next prompt of every session it stopped in, with no `/reload-plugins`.
53
53
 
@@ -59,7 +59,7 @@ When a step fails, the mod does not start. An error toast above the prompt names
59
59
  - `its setup function threw: <error>` when `setup` throws or rejects.
60
60
  - `its open panes did not load: <error>` when Claude Code cannot list the open panes.
61
61
  - `the details of its session did not load: <error>` when Claude Code cannot name the session's id, model, or start time.
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.
62
+ - `it decides permissions on <events>, so hooks/register.ts must call registerPermissionCheck(addHook) after registerMod` when `setup` adds a `beforePermissionCheck` or `beforePermissionRequest` listener, or a job that decides permissions, and `register.ts` does not register the permission check.
63
63
 
64
64
  Each of these has `Retry`, which starts the mod again, and `Report` and `Hide` ([ui.md](ui.md#toasts)).
65
65
 
@@ -123,9 +123,9 @@ registerPermissionCheck(addHook: On): void
123
123
  registeredMod(): ModDefinition | undefined
124
124
  ```
125
125
 
126
- `registerMod` registers one handler per Claude Code event the mod can use, and routes each event to the mod. `options` is what Claude Code passes `register`: the values of the mod's options ([options.md](options.md)). `registeredMod` returns the definition `registerMod` got, for `cmod check` to read the mod's options without starting it. A `PreToolUse` hook runs on `tool.call`, which `registerMod` registers ([hooks.md](hooks.md)). It leaves out Claude Code's permission check, the events `tool.check` and `PermissionRequest`.
126
+ `registerMod` registers one handler per Claude Code event the mod can use, and routes each event to the mod. `options` is what Claude Code passes `register`: the values of the mod's options ([options.md](options.md)). `registeredMod` returns the definition `registerMod` got, for `cmod check` to read the mod's options without starting it. `registerMod` leaves out Claude Code's permission check, the events `tool.check` and `PermissionRequest`.
127
127
 
128
- A mod that decides permissions calls `registerPermissionCheck` after `registerMod`. That covers a `PreToolUse` hook that answers `permissionDecision: 'allow'` or `'ask'`, a hook on `PermissionRequest`, the `permissions` job, and a job of your own on `tool.check`:
128
+ A mod that decides permissions calls `registerPermissionCheck` after `registerMod`. That covers `sessions.current.beforePermissionCheck`, `sessions.current.beforePermissionRequest`, and the `permissions` job:
129
129
 
130
130
  ```ts
131
131
  export function register(addHook: On, options: PluginOptions): void {
@@ -136,11 +136,11 @@ export function register(addHook: On, options: PluginOptions): void {
136
136
 
137
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.
138
138
 
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.
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 a key, 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.
140
140
 
141
141
  ## mod: this mod
142
142
 
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.
143
+ `setup` gets `mod`, of type `Mod<State, Options>`. Every listener, render, and job gets the same `mod`. `mod` holds what belongs to this mod: its name, its folders, its values, its grants, and what it adds to Claude Code.
144
144
 
145
145
  ```ts
146
146
  type Mod<State, Options> = {
@@ -150,8 +150,6 @@ type Mod<State, Options> = {
150
150
  readonly state: Readonly<State>
151
151
  readonly options: OptionValues<Options>
152
152
  readonly permissions: Permissions
153
- on(hook, listener, options?): Disposable
154
- on(hook, matcher, listener, options?): Disposable
155
153
  every(ms, listener): Disposable
156
154
  after(ms, listener): Disposable
157
155
  use(job): Handle
@@ -170,7 +168,6 @@ type Mod<State, Options> = {
170
168
  | `state` | The mod's values. Assign to a key to change it. | [state.md](state.md) |
171
169
  | `options` | The values a person set for the mod's options, typed from their declaration. Read-only. | [options.md](options.md) |
172
170
  | `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
171
  | `every` and `after` | Run a listener on a timer while the session runs. | below |
175
172
  | `use` | Adds a job, such as a slash command or a tool, and returns its handle. | [jobs.md](jobs.md) |
176
173
  | `log` | Writes a line, under the mod's name, to Claude Code's debug log. | below |
@@ -181,13 +178,15 @@ type Mod<State, Options> = {
181
178
  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
179
 
183
180
  ```ts
184
- import { fs, http, metadata, model, process, sessions, settings } from '../node_modules/@cmodjs/core/mod.js'
181
+ import { commands, config, fs, http, metadata, model, process, sessions, settings } from '../node_modules/@cmodjs/core/mod.js'
185
182
  ```
186
183
 
187
184
  | Import | What it is | Docs |
188
185
  | --- | --- | --- |
189
- | `sessions` | The conversations: the one the mod runs in, every other one, and search across them. | [session.md](session.md) |
186
+ | `sessions` | The conversations: the one the mod runs in, every other one, and search across them. Everything that happens in a session, from a prompt to a permission dialog, is a method on it. | [session.md](session.md) |
187
+ | `commands` | Every slash command: lists them, runs one, and hears or changes each run. | below |
190
188
  | `settings` | Claude Code's `settings.json` files: the person's, the project's, and the project's local one. | below |
189
+ | `config` | The rows of Claude Code's `/config` menu. | below |
191
190
  | `model` | Asks a model one question, outside the conversation. | below |
192
191
  | `fs` | Reads, writes, lists, and finds files. | below |
193
192
  | `metadata` | Reads and changes the mod's keys in one file's metadata. | below |
@@ -198,14 +197,14 @@ Each import works once the mod has started, in `setup` and anything it adds. Eac
198
197
 
199
198
  ### Disposable
200
199
 
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`.
200
+ `mod.every`, `mod.after`, `mod.ui.render`, and every `on` and `before` method each return a `Disposable`. `dispose()` takes back what the call added, so a mod turns a feature off and on while it runs. Everything a mod adds is taken back when the mod stops, so a mod that keeps a feature for the whole session ignores the `Disposable`.
202
201
 
203
202
  ```ts
204
203
  type Disposable = { dispose(): void }
205
204
  ```
206
205
 
207
206
  ```ts
208
- const watching = mod.on(PostToolUse, { tool_name: 'Edit' }, () => void (mod.state.session.edits += 1))
207
+ const watching = sessions.current.onToolCall({ name: 'Edit' }, () => void (mod.state.session.edits += 1))
209
208
  mod.use(slashCommand({ name: 'stop-counting', description: 'Stop counting edits', reply: () => watching.dispose() }))
210
209
  ```
211
210
 
@@ -230,11 +229,13 @@ mod.every(60_000, async () => {
230
229
  ### mod.log
231
230
 
232
231
  ```ts
233
- mod.log(text: string): void
232
+ mod.log(text: string, options?: { to?: 'debug' | 'transcript' }): void
234
233
  ```
235
234
 
236
235
  `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
236
 
237
+ With `{ to: 'transcript' }`, the line shows dim in the conversation, where the person reads it and Claude never does, such as "Formatted 3 files" after a turn.
238
+
238
239
  ### mod.settings
239
240
 
240
241
  ```ts
@@ -249,39 +250,77 @@ mod.settings.page(definePane({ id: 'runs', title: 'Runs', render: (current) => <
249
250
  mod.use(slashCommand({ name: 'ci-runs', description: 'Show the CI runs', reply: () => void mod.settings.open('runs') }))
250
251
  ```
251
252
 
253
+ ## commands: slash commands
254
+
255
+ ```ts
256
+ commands.list(): Promise<CommandInfo[]>
257
+ commands.run(name: string, args?: string): Promise<{ text?: string; note?: string }>
258
+ commands.beforeRun(matcher?, listener: (run: CommandRun) => Answer | void, options?): Disposable
259
+ commands.onRun(matcher?, listener: (run: CommandRun) => unknown, options?): Disposable
260
+
261
+ type CommandInfo = { name: string; description: string; source: 'builtin' | 'plugin' | 'user' | 'mcp'; plugin?: string }
262
+ type CommandRun = { name: string; args: string; source?: 'builtin' | 'plugin' | 'user' | 'mcp'; from: Sender; session: string; cwd: string }
263
+ ```
264
+
265
+ - `list` answers every command the person can type: Claude Code's own, each plugin's, the person's and the project's, and each MCP server's prompts.
266
+ - `run` runs a command, a built-in such as `compact` included, the way the person typing `/<name> <args>` would. It resolves the text the command printed and the note it left Claude. It needs the `prompt` permission.
267
+ - `beforeRun` runs just before any command runs, typed or run by a mod, and its answer changes it: `{ args?, note? }` runs it with new arguments and adds a note Claude reads after its output, `{ result, exitCode? }` answers in its place without running it, and `{ refuse }` runs nothing and shows the reason as its output. An answer needs the `prompt` permission. `exitCode` is what a `claude -p "/lint"` run exits with.
268
+ - `onRun` hears each command once it ran.
269
+ - A command a project or a plugin writes as a Markdown prompt, such as `/review`, also reaches `beforePrompt` after `beforeRun`, as its text goes to Claude ([session.md](session.md#change-what-happens)). A built-in such as `/model` reaches only these two.
270
+
271
+ ```ts
272
+ commands.beforeRun({ name: 'compact' }, () => (mod.state.session.isDeploying ? { refuse: 'Compact once the deploy finishes.' } : undefined))
273
+ commands.onRun({ name: 'model' }, (run) => mod.log(`model set to ${run.args}`))
274
+ ```
275
+
276
+ A command of the mod's own is a `slashCommand` job ([jobs.md](jobs.md#slashcommand)).
277
+
252
278
  ## settings: Claude Code's settings files
253
279
 
254
280
  ```ts
255
- settings.list(): Promise<Setting[]>
256
281
  settings.user.read(): Promise<Settings>
257
282
  settings.user.update(values: Readonly<Record<string, unknown>>): Promise<void>
258
283
  settings.project.read(): Promise<Settings>
259
284
  settings.project.update(values: Readonly<Record<string, unknown>>): Promise<void>
260
285
  settings.local.read(): Promise<Settings>
261
286
  settings.local.update(values: Readonly<Record<string, unknown>>): Promise<void>
287
+ settings.beforeFileChange(matcher?, listener: (change: SettingsFileChange) => { refuse: string } | void): Disposable
262
288
  ```
263
289
 
264
- `settings.list` answers every row of Claude Code's `/config` menu, its own and each plugin's, with the value each holds now:
290
+ `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`.
291
+
292
+ - `read` answers the values in that one file, such as `(await settings.user.read())['enabledPlugins']`.
293
+ - `update` merges `values` into that file as a JSON Merge Patch ([RFC 7396](https://www.rfc-editor.org/rfc/rfc7396)). `mcpServers` in `settings.project` goes to the project's `.mcp.json`, where Claude Code reads a project's servers.
294
+ - An object merges into the object already at that key, at every depth, so every key it does not name stays as it was. `null` removes the key at its depth. An array or any other value replaces the value. To replace a whole object, read it and set each key you drop to `null`.
295
+ - The file's comments and formatting stay as they were.
296
+ - Each value needs a `config` permission for its key path or a path above it ([permissions.md](permissions.md)): `"config": ["env.ANTHROPIC_BASE_URL"]` covers that one value, and `"config": ["env"]` covers every key in `env`. `update` rejects with the line to add for a value no grant covers, and changes nothing.
297
+ - Removing the mod takes out each value it set that still equals what it set, then each object that leaves empty. A value the person changed since stays.
298
+ - `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.
299
+
300
+ A mod that points Claude Code at its local proxy sets one variable in `env`, and the person's other variables stay:
265
301
 
266
302
  ```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 }
303
+ await settings.user.update({ env: { ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317' } })
268
304
  ```
269
305
 
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.
306
+ `beforeFileChange` hears each settings file Claude Code sees edited while the session runs, and a Skill file edited, and `{ refuse }` keeps Claude Code on the values it had. `source` is `'user'`, `'project'`, `'local'`, `'policy'`, or `'skills'`, and `path` is the file, when Claude Code names it:
271
307
 
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`.
308
+ ```ts
309
+ type SettingsFileChange = { source: 'user' | 'project' | 'local' | 'policy' | 'skills'; path?: string }
273
310
 
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.
311
+ settings.beforeFileChange({ source: /^(user|project)$/ }, () => ({ refuse: 'Settings are locked while the deploy runs.' }))
312
+ ```
313
+
314
+ ## config: the /config menu
280
315
 
281
316
  ```ts
282
- await settings.user.update({ cleanupPeriodDays: 365 })
317
+ config.list(): Promise<ConfigRow[]>
318
+
319
+ type ConfigRow = { key: string; label: string; description?: string; kind: 'boolean' | 'choice' | 'text' | 'number'; value: boolean | string | number | readonly string[]; options?: readonly string[]; plugin?: string; isLocked: boolean }
283
320
  ```
284
321
 
322
+ `config.list` answers every row of Claude Code's `/config` menu, its own and each plugin's, with the value each holds now. `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.
323
+
285
324
  ## model: one question outside the conversation
286
325
 
287
326
  ```ts
@@ -292,12 +331,12 @@ model.complete(request: ModelCompleteRequest, options?: { signal?: AbortSignal }
292
331
 
293
332
  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
333
 
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:
334
+ Give the listener that waits on it time too, with its `timeoutMs` ([session.md](session.md#give-a-listener-time)), which also covers the work around the call:
296
335
 
297
336
  ```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.' }
337
+ sessions.current.beforeTurnEnd(async (turn, { signal }) => {
338
+ 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${turn.answer ?? ''}`, timeoutMs: 8000 }, { signal })
339
+ if (verdict.isAnswered && verdict.text.trim().toLowerCase().startsWith('yes')) return { refuse: 'Do that work yourself, then reply.' }
301
340
  }, { timeoutMs: 9000 })
302
341
  ```
303
342
 
@@ -322,15 +361,15 @@ Both run a program without a shell. `argv[0]` is the program and the rest are it
322
361
  A job's deadline bounds the whole callback that makes these calls ([jobs.md](jobs.md#deadlines)).
323
362
 
324
363
  ```ts
325
- import { defineMod, process, SessionStart, sessions } from '../node_modules/@cmodjs/core/mod.js'
364
+ import { defineMod, process, sessions } from '../node_modules/@cmodjs/core/mod.js'
326
365
 
327
366
  export const branchNote = defineMod({
328
367
  name: 'branch-note',
329
- setup(mod) {
330
- mod.on(SessionStart, async () => {
368
+ setup() {
369
+ sessions.current.beforeSessionStart(async () => {
331
370
  const { exitCode, stdout } = await process.run(['git', 'branch', '--show-current'], { cwd: sessions.current.project })
332
371
  if (exitCode !== 0) return
333
- return { hookSpecificOutput: { hookEventName: SessionStart, additionalContext: `The git branch is ${stdout.trim()}.` } }
372
+ return { note: `The git branch is ${stdout.trim()}.` }
334
373
  })
335
374
  },
336
375
  })
@@ -345,9 +384,11 @@ fs.list(path?: string): Promise<Entry[]>
345
384
  fs.exists(path: string): Promise<boolean>
346
385
  fs.stat(path: string, options?: { resolve: boolean }): Promise<Stat>
347
386
  fs.find(glob: string | readonly string[]): Promise<readonly FileMatch[]>
387
+ fs.onChange({ path: string | readonly string[] }, listener: (change: FileChange) => unknown): Disposable
348
388
  ```
349
389
 
350
390
  ```ts
391
+ type FileChange = { path: string; type: 'change' | 'add' | 'unlink' }
351
392
  type Entry = { name: string; isFile: boolean; isFolder: boolean; isLink: boolean; size: number; modifiedAt: number }
352
393
  type Stat = { isFile: boolean; isFolder: boolean; isLink: boolean; size: number; modifiedAt: number; realPath?: string }
353
394
  ```
@@ -358,6 +399,11 @@ type Stat = { isFile: boolean; isFolder: boolean; isLink: boolean; size: number;
358
399
  - `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.
359
400
  - `exists` answers whether the path leads to a file or a folder.
360
401
  - `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.
402
+ - `onChange` hears each file Claude Code sees change, appear, or go at a path or under a folder. Claude Code reads the paths it watches when a session starts, so `onChange` works only in `setup`, and a call later throws. A relative path is under the project. `path` takes strings only, unlike other matchers, because the paths go to Claude Code as they are:
403
+
404
+ ```ts
405
+ fs.onChange({ path: `${mod.dataFolder}/history` }, ({ path, type }) => mod.ui.toast(`${path}: ${type}`))
406
+ ```
361
407
 
362
408
  A file the mod ships is under `mod.folder`. A file the mod keeps for itself belongs in `mod.dataFolder`.
363
409
 
@@ -440,12 +486,12 @@ messageOf(error: unknown): string
440
486
  Returns an `Error`'s `message`, or the value as text. Use it in a `catch`, where the error is `unknown`.
441
487
 
442
488
  ```ts
443
- import { defineMod, fs, messageOf, Stop } from '../node_modules/@cmodjs/core/mod.js'
489
+ import { defineMod, fs, messageOf, sessions } from '../node_modules/@cmodjs/core/mod.js'
444
490
 
445
491
  export const notes = defineMod({
446
492
  name: 'notes',
447
493
  setup(mod) {
448
- mod.on(Stop, async () => {
494
+ sessions.current.onTurnEnd(async () => {
449
495
  await fs.write(`${mod.dataFolder}/last-stop.txt`, new Date().toISOString()).catch((error: unknown) => {
450
496
  mod.ui.toast.error('Could not save the last stop', { description: messageOf(error) })
451
497
  })
@@ -453,3 +499,35 @@ export const notes = defineMod({
453
499
  },
454
500
  })
455
501
  ```
502
+
503
+ ## shell.js and path.js: read a command line
504
+
505
+ `shell.js` reads a Bash line the way cmod does for a tool call's `files` ([session.md](session.md#files-what-a-tool-call-reads-and-changes)), and `path.js` is `node:path`'s POSIX functions, which a mod cannot import from Node:
506
+
507
+ ```ts
508
+ parseShell(line: string): ParsedShell
509
+
510
+ type ParsedShell = { commands: ShellCommand[]; reads: string[]; writes: string[]; fetches: string[]; isFullyParsed: boolean }
511
+ type ShellCommand = { argv: [string, ...string[]]; folder: string; input?: number }
512
+
513
+ resolve(...paths): string join(...paths): string dirname(path): string basename(path, suffix?): string
514
+ extname(path): string relative(from, to): string isAbsolute(path): boolean normalize(path): string
515
+ ```
516
+
517
+ - `commands` lists every command the line runs, those inside `sudo`, `bash -c`, `$(…)`, and heredocs included, with flags such as `-rf` split into `-r` and `-f`.
518
+ - `folder` is where a command runs, as the line wrote it: `''` for the folder the line starts in, `src` after `cd src`, `/tmp` after `cd /tmp`, and `~/p` after `cd ~/p`. `resolve(call.cwd, folder)` makes it absolute.
519
+ - `input` is the index in `commands` of the command whose output this one reads through `|` or `|&`, and is absent for a command that reads no pipe. `trace read a.ts | head -5` gives `head` the `input` `0`. A wrapper such as `timeout 5 trace read a.ts` writes the output of the command it runs, so `input` names `trace`, not `timeout`.
520
+ - `reads`, `writes`, and `fetches` are the paths and URLs the commands name, as written. A path that holds a shell expansion makes `isFullyParsed` false, and so does a line the parser cannot read.
521
+ - `resolve` has no working folder to start from in Claude Code, so pass an absolute folder first, such as `resolve(sessions.current.cwd, path)`.
522
+
523
+ ```ts
524
+ import { basename, resolve } from '../node_modules/@cmodjs/core/path.js'
525
+ import { parseShell } from '../node_modules/@cmodjs/core/shell.js'
526
+ import { sessions } from '../node_modules/@cmodjs/core/mod.js'
527
+
528
+ sessions.current.onToolCall({ name: 'Bash' }, (call) => {
529
+ for (const { argv, folder } of parseShell(String(call.input['command'])).commands) {
530
+ if (basename(argv[0]) === 'trace') mod.ui.toast(`trace runs in ${resolve(call.cwd, folder)}`)
531
+ }
532
+ })
533
+ ```